feat: Drama Studio 프로젝트 초기 구조 설정
- FastAPI 백엔드 (audio-studio-api) - Next.js 프론트엔드 (audio-studio-ui) - Qwen3-TTS 엔진 (audio-studio-tts) - MusicGen 서비스 (audio-studio-musicgen) - Docker Compose 개발/운영 환경 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
352
.claude/project-knowledge.md
Normal file
352
.claude/project-knowledge.md
Normal file
@ -0,0 +1,352 @@
|
|||||||
|
# Project Knowledge
|
||||||
|
|
||||||
|
이 문서는 프로젝트의 개발 표준 및 패턴을 정의합니다.
|
||||||
|
Claude.ai Projects 또는 Claude Code에서 참조하여 일관된 코드를 작성하세요.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 프로젝트 개요
|
||||||
|
|
||||||
|
### 기술 스택
|
||||||
|
- **Frontend**: Next.js 16 + React 19 + TypeScript + Tailwind CSS 4 + shadcn/ui
|
||||||
|
- **Backend**: FastAPI + Python 3.11 + Pydantic v2
|
||||||
|
- **Database**: MongoDB 7.0 (motor async driver) + Redis 7
|
||||||
|
- **AI**: Claude API (Anthropic) + OpenAI API
|
||||||
|
- **Containerization**: Docker + Docker Compose
|
||||||
|
- **Repository**: Gitea (http://gitea.yakenator.io/yakenator/)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Docker 배포 규칙
|
||||||
|
|
||||||
|
### Dockerfile 패턴 (Python Worker)
|
||||||
|
```dockerfile
|
||||||
|
FROM python:3.11-slim
|
||||||
|
WORKDIR /app
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends git && rm -rf /var/lib/apt/lists/*
|
||||||
|
COPY requirements.txt .
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
COPY *.py .
|
||||||
|
CMD ["python", "worker.py"]
|
||||||
|
```
|
||||||
|
|
||||||
|
### docker-compose 서비스 패턴
|
||||||
|
```yaml
|
||||||
|
services:
|
||||||
|
{서비스명}:
|
||||||
|
build:
|
||||||
|
context: ./repos/{서비스명}
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
container_name: {프로젝트}-{서비스명}
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
- REDIS_URL=redis://redis:6379
|
||||||
|
- MONGODB_URL=mongodb://${MONGO_USER}:${MONGO_PASSWORD}@mongodb:27017/
|
||||||
|
- DB_NAME={데이터베이스명}
|
||||||
|
depends_on:
|
||||||
|
redis:
|
||||||
|
condition: service_healthy
|
||||||
|
mongodb:
|
||||||
|
condition: service_healthy
|
||||||
|
networks:
|
||||||
|
- {프로젝트}-network
|
||||||
|
```
|
||||||
|
|
||||||
|
### 네이밍 규칙
|
||||||
|
- 컨테이너: `{프로젝트}-{서비스명}`
|
||||||
|
- 볼륨: `{프로젝트}_{데이터유형}_data`
|
||||||
|
- 네트워크: `{프로젝트}-network`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 한국어 개발 컨벤션
|
||||||
|
|
||||||
|
### Docstring
|
||||||
|
```python
|
||||||
|
async def get_data(self, name: str, context: List[str] = None) -> DataInfo:
|
||||||
|
"""
|
||||||
|
데이터 조회 (context 기반 후보 선택)
|
||||||
|
|
||||||
|
Args:
|
||||||
|
name: 데이터 이름
|
||||||
|
context: 컨텍스트 키워드
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
DataInfo 객체
|
||||||
|
"""
|
||||||
|
```
|
||||||
|
|
||||||
|
### 로깅 메시지 (한글 + 영문 혼용)
|
||||||
|
```python
|
||||||
|
logger.info(f"Found {len(items)} item(s) for '{name}'")
|
||||||
|
logger.warning(f"Operation failed (non-critical): {e}")
|
||||||
|
```
|
||||||
|
|
||||||
|
### 커밋 메시지
|
||||||
|
```
|
||||||
|
feat: 기능 설명
|
||||||
|
|
||||||
|
- 변경사항 1
|
||||||
|
- 변경사항 2
|
||||||
|
|
||||||
|
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. FastAPI 패턴
|
||||||
|
|
||||||
|
### 앱 초기화
|
||||||
|
```python
|
||||||
|
from fastapi import FastAPI, HTTPException
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
app = FastAPI(
|
||||||
|
title="{서비스명} API",
|
||||||
|
description="서비스 설명",
|
||||||
|
version="1.0.0"
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Pydantic 모델
|
||||||
|
```python
|
||||||
|
class CreateRequest(BaseModel):
|
||||||
|
name: str
|
||||||
|
description: Optional[str] = ""
|
||||||
|
data_type: Optional[str] = "default"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 엔드포인트
|
||||||
|
```python
|
||||||
|
@app.get("/health")
|
||||||
|
async def health_check():
|
||||||
|
return {"status": "healthy", "timestamp": datetime.now().isoformat()}
|
||||||
|
|
||||||
|
@app.post("/create")
|
||||||
|
async def create_item(request: CreateRequest):
|
||||||
|
try:
|
||||||
|
# 처리 로직
|
||||||
|
return {"success": True, "data": result}
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. React/Next.js 패턴
|
||||||
|
|
||||||
|
### 디렉토리 구조
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
├── app/ # Next.js App Router
|
||||||
|
├── components/
|
||||||
|
│ ├── ui/ # shadcn/ui 컴포넌트
|
||||||
|
│ └── providers.tsx # Context Providers
|
||||||
|
├── hooks/ # 커스텀 훅
|
||||||
|
├── lib/utils.ts # cn() 등 유틸리티
|
||||||
|
└── types/ # TypeScript 타입
|
||||||
|
```
|
||||||
|
|
||||||
|
### cn() 유틸리티
|
||||||
|
```typescript
|
||||||
|
import { clsx, type ClassValue } from "clsx"
|
||||||
|
import { twMerge } from "tailwind-merge"
|
||||||
|
|
||||||
|
export function cn(...inputs: ClassValue[]) {
|
||||||
|
return twMerge(clsx(inputs))
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 폼 패턴 (react-hook-form + zod)
|
||||||
|
```typescript
|
||||||
|
const formSchema = z.object({
|
||||||
|
title: z.string().min(1, "제목을 입력하세요"),
|
||||||
|
content: z.string().min(10, "내용은 10자 이상"),
|
||||||
|
})
|
||||||
|
|
||||||
|
const form = useForm<z.infer<typeof formSchema>>({
|
||||||
|
resolver: zodResolver(formSchema),
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. MongoDB 패턴
|
||||||
|
|
||||||
|
### 연결 (motor async)
|
||||||
|
```python
|
||||||
|
from motor.motor_asyncio import AsyncIOMotorClient
|
||||||
|
|
||||||
|
client = AsyncIOMotorClient(os.getenv("MONGODB_URL"))
|
||||||
|
db = client[os.getenv("DB_NAME")]
|
||||||
|
```
|
||||||
|
|
||||||
|
### 인덱스 생성
|
||||||
|
```python
|
||||||
|
await collection.create_index("unique_field", unique=True, sparse=True)
|
||||||
|
await collection.create_index("name")
|
||||||
|
await collection.create_index("updated_at")
|
||||||
|
```
|
||||||
|
|
||||||
|
### Upsert 패턴
|
||||||
|
```python
|
||||||
|
result = await collection.update_one(
|
||||||
|
{"unique_field": data["unique_field"]},
|
||||||
|
{
|
||||||
|
"$set": update_doc,
|
||||||
|
"$setOnInsert": {"created_at": datetime.now()}
|
||||||
|
},
|
||||||
|
upsert=True
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
### TTL 캐시
|
||||||
|
```python
|
||||||
|
CACHE_TTL_DAYS = 7
|
||||||
|
|
||||||
|
def _is_cache_fresh(self, cached_data: Dict) -> bool:
|
||||||
|
updated_at = cached_data.get("updated_at")
|
||||||
|
expiry_date = updated_at + timedelta(days=CACHE_TTL_DAYS)
|
||||||
|
return datetime.now() < expiry_date
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. AI API 통합
|
||||||
|
|
||||||
|
### Claude API
|
||||||
|
```python
|
||||||
|
from anthropic import AsyncAnthropic
|
||||||
|
|
||||||
|
client = AsyncAnthropic(api_key=os.getenv("CLAUDE_API_KEY"))
|
||||||
|
|
||||||
|
response = await client.messages.create(
|
||||||
|
model="claude-sonnet-4-20250514",
|
||||||
|
max_tokens=8192,
|
||||||
|
messages=[{"role": "user", "content": prompt}]
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 프롬프트 템플릿 (MongoDB 저장)
|
||||||
|
```python
|
||||||
|
async def _get_prompt_template(self) -> str:
|
||||||
|
# 캐시 확인
|
||||||
|
if self._cached_prompt and time.time() - self._prompt_cache_time < 300:
|
||||||
|
return self._cached_prompt
|
||||||
|
|
||||||
|
# DB에서 조회
|
||||||
|
custom_prompt = await self.db.prompts.find_one({"service": "{서비스명}"})
|
||||||
|
return custom_prompt.get("content") if custom_prompt else self._default_prompt
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 환경 변수
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# .env
|
||||||
|
MONGO_USER=admin
|
||||||
|
MONGO_PASSWORD={비밀번호}
|
||||||
|
REDIS_URL=redis://redis:6379
|
||||||
|
CLAUDE_API_KEY=sk-ant-...
|
||||||
|
OPENAI_API_KEY=sk-...
|
||||||
|
JWT_SECRET_KEY={시크릿키}
|
||||||
|
DB_NAME={데이터베이스명}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. 헬스체크
|
||||||
|
|
||||||
|
### FastAPI
|
||||||
|
```python
|
||||||
|
@app.get("/health")
|
||||||
|
async def health():
|
||||||
|
return {"status": "healthy"}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Docker Compose
|
||||||
|
```yaml
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 3
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. 데이터베이스 백업 정책
|
||||||
|
|
||||||
|
### 백업 규칙
|
||||||
|
- **주기**: 하루에 한 번 (daily)
|
||||||
|
- **보관 기간**: 최소 7일
|
||||||
|
- **백업 위치**: 프로젝트 루트의 `./backups/` 디렉토리
|
||||||
|
|
||||||
|
### MongoDB 백업 명령어
|
||||||
|
```bash
|
||||||
|
BACKUP_NAME="mongodb_backup_$(date +%Y%m%d_%H%M%S)"
|
||||||
|
docker exec {프로젝트}-mongodb mongodump \
|
||||||
|
--uri="mongodb://{user}:{password}@localhost:27017" \
|
||||||
|
--authenticationDatabase=admin \
|
||||||
|
--out="/tmp/$BACKUP_NAME"
|
||||||
|
docker cp {프로젝트}-mongodb:/tmp/$BACKUP_NAME ./backups/
|
||||||
|
```
|
||||||
|
|
||||||
|
### 복원 명령어
|
||||||
|
```bash
|
||||||
|
docker cp ./backups/$BACKUP_NAME {프로젝트}-mongodb:/tmp/
|
||||||
|
docker exec {프로젝트}-mongodb mongorestore \
|
||||||
|
--uri="mongodb://{user}:{password}@localhost:27017" \
|
||||||
|
--authenticationDatabase=admin \
|
||||||
|
"/tmp/$BACKUP_NAME"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 자동화 (cron)
|
||||||
|
```bash
|
||||||
|
# crontab -e
|
||||||
|
0 2 * * * /path/to/backup-script.sh # 매일 새벽 2시
|
||||||
|
```
|
||||||
|
|
||||||
|
### 주의사항
|
||||||
|
- 새 프로젝트 생성 시 반드시 백업 스크립트 설정
|
||||||
|
- 백업 디렉토리는 .gitignore에 추가
|
||||||
|
- 중요 데이터는 외부 스토리지에 추가 백업 권장
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Gitea 리포지토리 관리
|
||||||
|
|
||||||
|
### 서버 정보
|
||||||
|
- **URL**: http://gitea.yakenator.io
|
||||||
|
- **사용자**: yakenator
|
||||||
|
- **비밀번호**: asdfg23we
|
||||||
|
|
||||||
|
### 새 리포지토리 생성 (API)
|
||||||
|
```bash
|
||||||
|
curl -X POST "http://gitea.yakenator.io/api/v1/user/repos" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-u "yakenator:asdfg23we" \
|
||||||
|
-d '{"name": "{repo-name}", "private": false}'
|
||||||
|
```
|
||||||
|
|
||||||
|
### 새 프로젝트 초기화 및 푸시
|
||||||
|
```bash
|
||||||
|
# 1. Git 초기화
|
||||||
|
git init
|
||||||
|
|
||||||
|
# 2. 첫 커밋
|
||||||
|
git add -A
|
||||||
|
git commit -m "Initial commit"
|
||||||
|
|
||||||
|
# 3. Gitea에 리포지토리 생성 (위 API 사용)
|
||||||
|
|
||||||
|
# 4. Remote 추가 및 푸시
|
||||||
|
git remote add origin http://yakenator:asdfg23we@gitea.yakenator.io/yakenator/{repo-name}.git
|
||||||
|
git branch -M main
|
||||||
|
git push -u origin main
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
이 문서를 Claude.ai Projects의 "Project Knowledge"에 추가하면 프로젝트 컨텍스트를 공유할 수 있습니다.
|
||||||
7
.claude/settings.json
Normal file
7
.claude/settings.json
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"permissions": {
|
||||||
|
"allow": [
|
||||||
|
"Bash(git fetch:*)"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
58
.claude/skills/README.md
Normal file
58
.claude/skills/README.md
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
# Project Skills
|
||||||
|
|
||||||
|
프로젝트 개발 표준 및 패턴 가이드입니다.
|
||||||
|
|
||||||
|
## 중요도 1 (핵심)
|
||||||
|
|
||||||
|
| Skill | 설명 | 파일 |
|
||||||
|
|-------|------|------|
|
||||||
|
| deployment-standards | Docker 배포 규칙 | [deployment-standards.md](deployment-standards.md) |
|
||||||
|
| project-stack | React+Next.js+FastAPI 기본 구조 | [project-stack.md](project-stack.md) |
|
||||||
|
| korean-dev-conventions | 한국어 주석, 에러 처리 등 | [korean-dev-conventions.md](korean-dev-conventions.md) |
|
||||||
|
| gpu-local-models | GPU 인프라 및 로컬 LLM 서빙 | [gpu-local-models.md](gpu-local-models.md) |
|
||||||
|
| flume-architecture | Flume 파이프라인 시스템 설계 | [flume-architecture.md](flume-architecture.md) |
|
||||||
|
|
||||||
|
## 중요도 2 (주요)
|
||||||
|
|
||||||
|
| Skill | 설명 | 파일 |
|
||||||
|
|-------|------|------|
|
||||||
|
| ai-api-integration | AI 모델 FastAPI 통합 패턴 | [ai-api-integration.md](ai-api-integration.md) |
|
||||||
|
| api-design-standards | RESTful API 설계 규칙 | [api-design-standards.md](api-design-standards.md) |
|
||||||
|
| frontend-component-patterns | React 컴포넌트 패턴 | [frontend-component-patterns.md](frontend-component-patterns.md) |
|
||||||
|
| database-patterns | MongoDB 설계 패턴 | [database-patterns.md](database-patterns.md) |
|
||||||
|
|
||||||
|
## 중요도 3 (참고)
|
||||||
|
|
||||||
|
| Skill | 설명 | 파일 |
|
||||||
|
|-------|------|------|
|
||||||
|
| infrastructure-setup | MAAS, K8s, Rancher 인프라 구축 | [infrastructure-setup.md](infrastructure-setup.md) |
|
||||||
|
| testing-standards | Jest/pytest 테스트 작성 | [testing-standards.md](testing-standards.md) |
|
||||||
|
| monitoring-logging | Prometheus/Grafana 모니터링 | [monitoring-logging.md](monitoring-logging.md) |
|
||||||
|
| gitea-workflow | Gitea 리포지토리 관리 | [gitea-workflow.md](gitea-workflow.md) |
|
||||||
|
|
||||||
|
## 사용법
|
||||||
|
|
||||||
|
Claude Code에서 작업 시 이 Skills를 참조하여 프로젝트 표준에 맞게 코드를 작성합니다.
|
||||||
|
|
||||||
|
### 기술 스택 요약
|
||||||
|
|
||||||
|
- **Frontend**: Next.js 16 + React 19 + Tailwind CSS 4 + shadcn/ui
|
||||||
|
- **Backend**: FastAPI + Python 3.11
|
||||||
|
- **Database**: MongoDB 7.0 + Redis 7
|
||||||
|
- **Containerization**: Docker + Kubernetes
|
||||||
|
- **AI**: Claude API + OpenAI API + 로컬 LLM (vLLM)
|
||||||
|
- **GPU**: V100 16GB×8, V100 32GB×4, RTX 3090 24GB×2 (총 304GB VRAM)
|
||||||
|
- **Repository**: Gitea (http://gitea.yakenator.io/yakenator/)
|
||||||
|
|
||||||
|
### 플레이스홀더
|
||||||
|
|
||||||
|
템플릿에서 다음 플레이스홀더를 실제 값으로 교체하세요:
|
||||||
|
|
||||||
|
| 플레이스홀더 | 설명 |
|
||||||
|
|-------------|------|
|
||||||
|
| `{프로젝트}` | 프로젝트명 |
|
||||||
|
| `{서비스명}` | 서비스명 |
|
||||||
|
| `{데이터베이스명}` | DB명 |
|
||||||
|
| `{user}` | DB 사용자 |
|
||||||
|
| `{password}` | DB 비밀번호 |
|
||||||
|
| `{호스트포트}` | 호스트 포트 |
|
||||||
213
.claude/skills/ai-api-integration.md
Normal file
213
.claude/skills/ai-api-integration.md
Normal file
@ -0,0 +1,213 @@
|
|||||||
|
# AI API 통합 패턴 (AI API Integration)
|
||||||
|
|
||||||
|
이 프로젝트의 AI 모델 API 통합 패턴입니다.
|
||||||
|
|
||||||
|
## Claude API 통합
|
||||||
|
|
||||||
|
### 클라이언트 초기화
|
||||||
|
```python
|
||||||
|
from anthropic import AsyncAnthropic
|
||||||
|
|
||||||
|
class AIArticleGeneratorWorker:
|
||||||
|
def __init__(self):
|
||||||
|
self.claude_api_key = os.getenv("CLAUDE_API_KEY")
|
||||||
|
self.claude_client = None
|
||||||
|
|
||||||
|
async def start(self):
|
||||||
|
if self.claude_api_key:
|
||||||
|
self.claude_client = AsyncAnthropic(api_key=self.claude_api_key)
|
||||||
|
else:
|
||||||
|
logger.error("Claude API key not configured")
|
||||||
|
return
|
||||||
|
```
|
||||||
|
|
||||||
|
### API 호출 패턴
|
||||||
|
```python
|
||||||
|
async def _call_claude_api(self, prompt: str) -> str:
|
||||||
|
"""Claude API 호출"""
|
||||||
|
try:
|
||||||
|
response = await self.claude_client.messages.create(
|
||||||
|
model="claude-sonnet-4-20250514", # 또는 claude-3-5-sonnet-latest
|
||||||
|
max_tokens=8192,
|
||||||
|
messages=[
|
||||||
|
{
|
||||||
|
"role": "user",
|
||||||
|
"content": prompt
|
||||||
|
}
|
||||||
|
]
|
||||||
|
)
|
||||||
|
return response.content[0].text
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Claude API error: {e}")
|
||||||
|
raise
|
||||||
|
```
|
||||||
|
|
||||||
|
### JSON 응답 파싱
|
||||||
|
```python
|
||||||
|
async def _generate_article(self, prompt: str) -> Dict[str, Any]:
|
||||||
|
"""기사 생성 및 JSON 파싱"""
|
||||||
|
response_text = await self._call_claude_api(prompt)
|
||||||
|
|
||||||
|
# JSON 블록 추출
|
||||||
|
json_match = re.search(r'```json\s*(.*?)\s*```', response_text, re.DOTALL)
|
||||||
|
if json_match:
|
||||||
|
json_str = json_match.group(1)
|
||||||
|
else:
|
||||||
|
json_str = response_text
|
||||||
|
|
||||||
|
return json.loads(json_str)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 프롬프트 관리
|
||||||
|
|
||||||
|
### MongoDB 기반 동적 프롬프트
|
||||||
|
```python
|
||||||
|
class AIArticleGeneratorWorker:
|
||||||
|
def __init__(self):
|
||||||
|
self._cached_prompt = None
|
||||||
|
self._prompt_cache_time = None
|
||||||
|
self._prompt_cache_ttl = 300 # 5분 캐시
|
||||||
|
self._default_prompt = """..."""
|
||||||
|
|
||||||
|
async def _get_prompt_template(self) -> str:
|
||||||
|
"""MongoDB에서 프롬프트 템플릿을 가져옴 (캐시 적용)"""
|
||||||
|
import time
|
||||||
|
current_time = time.time()
|
||||||
|
|
||||||
|
# 캐시가 유효하면 캐시된 프롬프트 반환
|
||||||
|
if (self._cached_prompt and self._prompt_cache_time and
|
||||||
|
current_time - self._prompt_cache_time < self._prompt_cache_ttl):
|
||||||
|
return self._cached_prompt
|
||||||
|
|
||||||
|
try:
|
||||||
|
prompts_collection = self.db.prompts
|
||||||
|
custom_prompt = await prompts_collection.find_one({"service": "article_generator"})
|
||||||
|
|
||||||
|
if custom_prompt and custom_prompt.get("content"):
|
||||||
|
self._cached_prompt = custom_prompt["content"]
|
||||||
|
logger.info("Using custom prompt from database")
|
||||||
|
else:
|
||||||
|
self._cached_prompt = self._default_prompt
|
||||||
|
logger.info("Using default prompt")
|
||||||
|
|
||||||
|
self._prompt_cache_time = current_time
|
||||||
|
return self._cached_prompt
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Error fetching prompt from database: {e}, using default")
|
||||||
|
return self._default_prompt
|
||||||
|
```
|
||||||
|
|
||||||
|
### 프롬프트 템플릿 형식
|
||||||
|
```python
|
||||||
|
prompt_template = """Write a comprehensive article based on the following news information.
|
||||||
|
|
||||||
|
Keyword: {keyword}
|
||||||
|
|
||||||
|
News Information:
|
||||||
|
Title: {title}
|
||||||
|
Summary: {summary}
|
||||||
|
Link: {link}
|
||||||
|
{search_text}
|
||||||
|
|
||||||
|
Please write in the following JSON format:
|
||||||
|
{{
|
||||||
|
"title": "Article title",
|
||||||
|
"summary": "One-line summary",
|
||||||
|
"subtopics": [
|
||||||
|
{{
|
||||||
|
"title": "Subtopic 1",
|
||||||
|
"content": ["Paragraph 1", "Paragraph 2", ...]
|
||||||
|
}}
|
||||||
|
],
|
||||||
|
"categories": ["Category1", "Category2"],
|
||||||
|
"entities": {{
|
||||||
|
"people": [{{"name": "Name", "context": ["role", "company"]}}],
|
||||||
|
"organizations": [{{"name": "Name", "context": ["industry", "type"]}}]
|
||||||
|
}}
|
||||||
|
}}
|
||||||
|
|
||||||
|
Requirements:
|
||||||
|
- Structure with 2-5 subtopics
|
||||||
|
- Professional and objective tone
|
||||||
|
- Write in English
|
||||||
|
"""
|
||||||
|
```
|
||||||
|
|
||||||
|
## OpenAI API 통합 (참고)
|
||||||
|
|
||||||
|
### 클라이언트 초기화
|
||||||
|
```python
|
||||||
|
from openai import AsyncOpenAI
|
||||||
|
|
||||||
|
class OpenAIService:
|
||||||
|
def __init__(self):
|
||||||
|
self.api_key = os.getenv("OPENAI_API_KEY")
|
||||||
|
self.client = AsyncOpenAI(api_key=self.api_key)
|
||||||
|
|
||||||
|
async def generate(self, prompt: str) -> str:
|
||||||
|
response = await self.client.chat.completions.create(
|
||||||
|
model="gpt-4o",
|
||||||
|
messages=[{"role": "user", "content": prompt}],
|
||||||
|
max_tokens=4096
|
||||||
|
)
|
||||||
|
return response.choices[0].message.content
|
||||||
|
```
|
||||||
|
|
||||||
|
## 에러 처리 및 재시도
|
||||||
|
|
||||||
|
### 재시도 패턴
|
||||||
|
```python
|
||||||
|
import asyncio
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
async def _call_with_retry(
|
||||||
|
self,
|
||||||
|
func,
|
||||||
|
max_retries: int = 3,
|
||||||
|
initial_delay: float = 1.0
|
||||||
|
) -> Optional[Any]:
|
||||||
|
"""지수 백오프 재시도"""
|
||||||
|
delay = initial_delay
|
||||||
|
|
||||||
|
for attempt in range(max_retries):
|
||||||
|
try:
|
||||||
|
return await func()
|
||||||
|
except Exception as e:
|
||||||
|
if attempt == max_retries - 1:
|
||||||
|
logger.error(f"All {max_retries} attempts failed: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
logger.warning(f"Attempt {attempt + 1} failed: {e}, retrying in {delay}s")
|
||||||
|
await asyncio.sleep(delay)
|
||||||
|
delay *= 2 # 지수 백오프
|
||||||
|
```
|
||||||
|
|
||||||
|
## 환경 변수
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# .env 파일
|
||||||
|
CLAUDE_API_KEY=sk-ant-...
|
||||||
|
OPENAI_API_KEY=sk-...
|
||||||
|
|
||||||
|
# docker-compose.yml
|
||||||
|
environment:
|
||||||
|
- CLAUDE_API_KEY=${CLAUDE_API_KEY}
|
||||||
|
- OPENAI_API_KEY=${OPENAI_API_KEY}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 비용 최적화
|
||||||
|
|
||||||
|
### 토큰 제한
|
||||||
|
```python
|
||||||
|
response = await self.claude_client.messages.create(
|
||||||
|
model="claude-sonnet-4-20250514",
|
||||||
|
max_tokens=8192, # 출력 토큰 제한
|
||||||
|
messages=[...]
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 캐싱 전략
|
||||||
|
- MongoDB에 응답 캐시 저장
|
||||||
|
- TTL 기반 캐시 만료
|
||||||
|
- 동일 입력에 대한 중복 호출 방지
|
||||||
269
.claude/skills/api-design-standards.md
Normal file
269
.claude/skills/api-design-standards.md
Normal file
@ -0,0 +1,269 @@
|
|||||||
|
# RESTful API 설계 규칙 (API Design Standards)
|
||||||
|
|
||||||
|
이 프로젝트의 RESTful API 설계 패턴입니다.
|
||||||
|
|
||||||
|
## FastAPI 기본 구조
|
||||||
|
|
||||||
|
### 앱 초기화
|
||||||
|
```python
|
||||||
|
from fastapi import FastAPI, HTTPException
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
app = FastAPI(
|
||||||
|
title="Biocode API",
|
||||||
|
description="바이오코드 성격 분석 및 유명인 등록 API",
|
||||||
|
version="1.0.0"
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Pydantic 모델 정의
|
||||||
|
```python
|
||||||
|
class AddFamousPersonRequest(BaseModel):
|
||||||
|
year: int
|
||||||
|
month: int
|
||||||
|
day: int
|
||||||
|
name: str
|
||||||
|
description: Optional[str] = ""
|
||||||
|
entity_type: Optional[str] = "person" # "person" or "organization"
|
||||||
|
|
||||||
|
class BiocodeResponse(BaseModel):
|
||||||
|
biocode: str
|
||||||
|
g_code: str
|
||||||
|
s_code: str
|
||||||
|
personality: dict
|
||||||
|
```
|
||||||
|
|
||||||
|
## 엔드포인트 패턴
|
||||||
|
|
||||||
|
### Health Check
|
||||||
|
```python
|
||||||
|
@app.get("/health")
|
||||||
|
async def health_check():
|
||||||
|
"""헬스체크 엔드포인트"""
|
||||||
|
return {"status": "healthy", "timestamp": datetime.now().isoformat()}
|
||||||
|
```
|
||||||
|
|
||||||
|
### GET - 조회
|
||||||
|
```python
|
||||||
|
@app.get("/biocode/{year}/{month}/{day}")
|
||||||
|
async def get_biocode(year: int, month: int, day: int):
|
||||||
|
"""생년월일로 바이오코드 조회"""
|
||||||
|
try:
|
||||||
|
biocode = calculate_biocode(year, month, day)
|
||||||
|
g_code = f"g{biocode[:2]}"
|
||||||
|
s_code = biocode[2:]
|
||||||
|
|
||||||
|
if g_code not in biocode_data:
|
||||||
|
raise HTTPException(status_code=404, detail=f"G code {g_code} not found")
|
||||||
|
|
||||||
|
personality = biocode_data[g_code].get("codes", {}).get(biocode, {})
|
||||||
|
|
||||||
|
return {
|
||||||
|
"biocode": biocode,
|
||||||
|
"g_code": g_code,
|
||||||
|
"s_code": s_code,
|
||||||
|
"personality": personality
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
```
|
||||||
|
|
||||||
|
### POST - 생성
|
||||||
|
```python
|
||||||
|
@app.post("/add_famous_person")
|
||||||
|
async def add_famous_person(request: AddFamousPersonRequest):
|
||||||
|
"""유명인/조직 등록"""
|
||||||
|
try:
|
||||||
|
biocode = calculate_biocode(request.year, request.month, request.day)
|
||||||
|
g_code = f"g{biocode[:2]}"
|
||||||
|
|
||||||
|
if g_code not in biocode_data:
|
||||||
|
raise HTTPException(status_code=404, detail=f"G code {g_code} not found")
|
||||||
|
|
||||||
|
# 엔티티 타입에 따라 저장 위치 결정
|
||||||
|
is_organization = request.entity_type == "organization"
|
||||||
|
target_field = "famousOrganizations" if is_organization else "famousPeople"
|
||||||
|
|
||||||
|
# 데이터 추가
|
||||||
|
new_entry = {
|
||||||
|
"name": request.name,
|
||||||
|
"code": biocode,
|
||||||
|
"description": request.description or ""
|
||||||
|
}
|
||||||
|
|
||||||
|
if target_field not in biocode_data[g_code]["codes"][biocode]:
|
||||||
|
biocode_data[g_code]["codes"][biocode][target_field] = []
|
||||||
|
|
||||||
|
biocode_data[g_code]["codes"][biocode][target_field].append(new_entry)
|
||||||
|
|
||||||
|
# JSON 파일 저장
|
||||||
|
save_biocode_data(g_code)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"biocode": biocode,
|
||||||
|
"entity_type": request.entity_type,
|
||||||
|
"name": request.name
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
```
|
||||||
|
|
||||||
|
## 라우터 분리 패턴
|
||||||
|
|
||||||
|
### 메인 앱 (main.py)
|
||||||
|
```python
|
||||||
|
from fastapi import FastAPI
|
||||||
|
from app.routers import users, articles
|
||||||
|
|
||||||
|
app = FastAPI(title="News Engine API")
|
||||||
|
|
||||||
|
app.include_router(users.router, prefix="/api/users", tags=["users"])
|
||||||
|
app.include_router(articles.router, prefix="/api/articles", tags=["articles"])
|
||||||
|
|
||||||
|
@app.get("/health")
|
||||||
|
async def health():
|
||||||
|
return {"status": "healthy"}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 라우터 (routers/users.py)
|
||||||
|
```python
|
||||||
|
from fastapi import APIRouter, HTTPException, Depends
|
||||||
|
from app.models.user import UserCreate, UserResponse
|
||||||
|
from app.database import get_database
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
@router.post("/", response_model=UserResponse)
|
||||||
|
async def create_user(user: UserCreate, db = Depends(get_database)):
|
||||||
|
"""새 사용자 생성"""
|
||||||
|
existing = await db.users.find_one({"email": user.email})
|
||||||
|
if existing:
|
||||||
|
raise HTTPException(status_code=400, detail="Email already registered")
|
||||||
|
|
||||||
|
result = await db.users.insert_one(user.dict())
|
||||||
|
return UserResponse(id=str(result.inserted_id), **user.dict())
|
||||||
|
|
||||||
|
@router.get("/{user_id}", response_model=UserResponse)
|
||||||
|
async def get_user(user_id: str, db = Depends(get_database)):
|
||||||
|
"""사용자 조회"""
|
||||||
|
user = await db.users.find_one({"_id": ObjectId(user_id)})
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(status_code=404, detail="User not found")
|
||||||
|
return UserResponse(**user)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 에러 처리
|
||||||
|
|
||||||
|
### HTTPException 사용
|
||||||
|
```python
|
||||||
|
from fastapi import HTTPException
|
||||||
|
|
||||||
|
# 404 Not Found
|
||||||
|
raise HTTPException(status_code=404, detail="Resource not found")
|
||||||
|
|
||||||
|
# 400 Bad Request
|
||||||
|
raise HTTPException(status_code=400, detail="Invalid input data")
|
||||||
|
|
||||||
|
# 500 Internal Server Error
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
```
|
||||||
|
|
||||||
|
### 전역 예외 처리
|
||||||
|
```python
|
||||||
|
from fastapi import Request
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
|
|
||||||
|
@app.exception_handler(Exception)
|
||||||
|
async def global_exception_handler(request: Request, exc: Exception):
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=500,
|
||||||
|
content={"detail": "Internal server error", "error": str(exc)}
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 응답 형식
|
||||||
|
|
||||||
|
### 성공 응답
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"data": { ... },
|
||||||
|
"message": "Operation completed successfully"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 에러 응답
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"detail": "Error message here"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 목록 응답
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"items": [...],
|
||||||
|
"total": 100,
|
||||||
|
"page": 1,
|
||||||
|
"page_size": 20
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## CORS 설정
|
||||||
|
|
||||||
|
```python
|
||||||
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
|
||||||
|
app.add_middleware(
|
||||||
|
CORSMiddleware,
|
||||||
|
allow_origins=["http://localhost:3000", "https://yourdomain.com"],
|
||||||
|
allow_credentials=True,
|
||||||
|
allow_methods=["*"],
|
||||||
|
allow_headers=["*"],
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
## API 버저닝
|
||||||
|
|
||||||
|
```python
|
||||||
|
# URL 기반 버저닝
|
||||||
|
app.include_router(v1_router, prefix="/api/v1")
|
||||||
|
app.include_router(v2_router, prefix="/api/v2")
|
||||||
|
|
||||||
|
# 또는 헤더 기반
|
||||||
|
@app.get("/api/resource")
|
||||||
|
async def get_resource(api_version: str = Header(default="v1")):
|
||||||
|
if api_version == "v2":
|
||||||
|
return v2_response()
|
||||||
|
return v1_response()
|
||||||
|
```
|
||||||
|
|
||||||
|
## 인증
|
||||||
|
|
||||||
|
### JWT 토큰 검증
|
||||||
|
```python
|
||||||
|
from fastapi import Depends, HTTPException
|
||||||
|
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
||||||
|
import jwt
|
||||||
|
|
||||||
|
security = HTTPBearer()
|
||||||
|
|
||||||
|
async def verify_token(credentials: HTTPAuthorizationCredentials = Depends(security)):
|
||||||
|
try:
|
||||||
|
payload = jwt.decode(
|
||||||
|
credentials.credentials,
|
||||||
|
os.getenv("JWT_SECRET_KEY"),
|
||||||
|
algorithms=["HS256"]
|
||||||
|
)
|
||||||
|
return payload
|
||||||
|
except jwt.ExpiredSignatureError:
|
||||||
|
raise HTTPException(status_code=401, detail="Token expired")
|
||||||
|
except jwt.InvalidTokenError:
|
||||||
|
raise HTTPException(status_code=401, detail="Invalid token")
|
||||||
|
|
||||||
|
@router.get("/protected")
|
||||||
|
async def protected_route(user = Depends(verify_token)):
|
||||||
|
return {"user": user}
|
||||||
|
```
|
||||||
384
.claude/skills/database-patterns.md
Normal file
384
.claude/skills/database-patterns.md
Normal file
@ -0,0 +1,384 @@
|
|||||||
|
# MongoDB 설계 패턴 (Database Patterns)
|
||||||
|
|
||||||
|
이 프로젝트의 MongoDB 설계 및 사용 패턴입니다.
|
||||||
|
|
||||||
|
## 연결 설정
|
||||||
|
|
||||||
|
### Motor (async driver)
|
||||||
|
```python
|
||||||
|
from motor.motor_asyncio import AsyncIOMotorClient
|
||||||
|
|
||||||
|
class WikipediaEnrichmentWorker:
|
||||||
|
def __init__(self):
|
||||||
|
self.mongodb_url = os.getenv("MONGODB_URL", "mongodb://mongodb:27017")
|
||||||
|
self.db_name = os.getenv("DB_NAME", "ai_writer_db")
|
||||||
|
self.db = None
|
||||||
|
|
||||||
|
async def start(self):
|
||||||
|
client = AsyncIOMotorClient(self.mongodb_url)
|
||||||
|
self.db = client[self.db_name]
|
||||||
|
```
|
||||||
|
|
||||||
|
### PyMongo (sync driver)
|
||||||
|
```python
|
||||||
|
from pymongo import MongoClient
|
||||||
|
from pymongo.database import Database
|
||||||
|
|
||||||
|
client: MongoClient = None
|
||||||
|
db: Database = None
|
||||||
|
|
||||||
|
def connect_to_mongo():
|
||||||
|
global client, db
|
||||||
|
try:
|
||||||
|
client = MongoClient(MONGODB_URL)
|
||||||
|
db = client[DATABASE_NAME]
|
||||||
|
client.admin.command('ping') # 연결 테스트
|
||||||
|
print(f"Successfully connected to MongoDB: {DATABASE_NAME}")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error connecting to MongoDB: {e}")
|
||||||
|
raise e
|
||||||
|
```
|
||||||
|
|
||||||
|
## 컬렉션 설계
|
||||||
|
|
||||||
|
### 기사 컬렉션 (articles_en)
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"_id": "ObjectId",
|
||||||
|
"news_id": "unique_id",
|
||||||
|
"title": "Article Title",
|
||||||
|
"summary": "One-line summary",
|
||||||
|
"subtopics": [
|
||||||
|
{
|
||||||
|
"title": "Subtopic 1",
|
||||||
|
"content": ["Paragraph 1", "Paragraph 2"]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"categories": ["Category1", "Category2"],
|
||||||
|
"entities": {
|
||||||
|
"people": [
|
||||||
|
{
|
||||||
|
"name": "Person Name",
|
||||||
|
"context": ["role", "company"],
|
||||||
|
"birth_date": "1990-01-15",
|
||||||
|
"wikipedia_url": "https://...",
|
||||||
|
"image_urls": ["https://..."],
|
||||||
|
"verified": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"organizations": [
|
||||||
|
{
|
||||||
|
"name": "Organization Name",
|
||||||
|
"context": ["industry", "type"],
|
||||||
|
"founding_date": "2004-02-04",
|
||||||
|
"wikipedia_url": "https://...",
|
||||||
|
"image_urls": ["https://..."],
|
||||||
|
"verified": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"wikipedia_enriched": true,
|
||||||
|
"wikipedia_enriched_at": "2024-01-15T10:30:00",
|
||||||
|
"created_at": "2024-01-15T10:00:00",
|
||||||
|
"updated_at": "2024-01-15T10:30:00"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 엔티티 캐시 컬렉션 (entity_people)
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"_id": "ObjectId",
|
||||||
|
"name": "Elon Musk",
|
||||||
|
"context": ["Tesla", "SpaceX", "CEO"],
|
||||||
|
"birth_date": "1971-06-28",
|
||||||
|
"wikipedia_url": "https://en.wikipedia.org/wiki/Elon_Musk",
|
||||||
|
"image_urls": ["https://..."],
|
||||||
|
"verified": true,
|
||||||
|
"created_at": "2024-01-10T00:00:00",
|
||||||
|
"updated_at": "2024-01-15T10:30:00"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 인덱스 설계
|
||||||
|
|
||||||
|
### 인덱스 생성 패턴
|
||||||
|
```python
|
||||||
|
class EntityCache:
|
||||||
|
async def ensure_indexes(self):
|
||||||
|
"""인덱스 생성 (이미 존재하면 무시)"""
|
||||||
|
try:
|
||||||
|
# wikipedia_url이 unique key (동명이인 구분)
|
||||||
|
try:
|
||||||
|
await self.people_collection.create_index(
|
||||||
|
"wikipedia_url", unique=True, sparse=True
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass # 이미 존재
|
||||||
|
|
||||||
|
# 이름으로 검색용 (동명이인 가능)
|
||||||
|
try:
|
||||||
|
await self.people_collection.create_index("name")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# context 검색용
|
||||||
|
try:
|
||||||
|
await self.people_collection.create_index("context")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# TTL 정책용
|
||||||
|
try:
|
||||||
|
await self.people_collection.create_index("updated_at")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
logger.info("Entity cache indexes ensured")
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Error ensuring indexes: {e}")
|
||||||
|
```
|
||||||
|
|
||||||
|
## CRUD 패턴
|
||||||
|
|
||||||
|
### Create (삽입)
|
||||||
|
```python
|
||||||
|
async def save_person(self, data: Dict[str, Any]) -> bool:
|
||||||
|
"""인물 정보 저장/갱신 (wikipedia_url 기준)"""
|
||||||
|
now = datetime.now()
|
||||||
|
|
||||||
|
update_doc = {
|
||||||
|
"name": data.get("name"),
|
||||||
|
"context": data.get("context", []),
|
||||||
|
"birth_date": data.get("birth_date"),
|
||||||
|
"wikipedia_url": data.get("wikipedia_url"),
|
||||||
|
"image_urls": data.get("image_urls", []),
|
||||||
|
"verified": data.get("verified", False),
|
||||||
|
"updated_at": now
|
||||||
|
}
|
||||||
|
|
||||||
|
if data.get("wikipedia_url"):
|
||||||
|
# upsert: 있으면 업데이트, 없으면 삽입
|
||||||
|
result = await self.people_collection.update_one(
|
||||||
|
{"wikipedia_url": data["wikipedia_url"]},
|
||||||
|
{
|
||||||
|
"$set": update_doc,
|
||||||
|
"$setOnInsert": {"created_at": now}
|
||||||
|
},
|
||||||
|
upsert=True
|
||||||
|
)
|
||||||
|
return result.modified_count > 0 or result.upserted_id is not None
|
||||||
|
```
|
||||||
|
|
||||||
|
### Read (조회)
|
||||||
|
```python
|
||||||
|
async def get_person(self, name: str, context: List[str] = None) -> Tuple[Optional[Dict], bool]:
|
||||||
|
"""
|
||||||
|
인물 정보 조회 (context 기반 최적 매칭)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (cached_data, needs_refresh)
|
||||||
|
"""
|
||||||
|
# 이름으로 모든 후보 검색
|
||||||
|
cursor = self.people_collection.find({"name": {"$regex": f"^{name}$", "$options": "i"}})
|
||||||
|
candidates = await cursor.to_list(length=10)
|
||||||
|
|
||||||
|
if not candidates:
|
||||||
|
return None, True
|
||||||
|
|
||||||
|
# context가 있으면 최적 후보 선택
|
||||||
|
if context:
|
||||||
|
best_match = None
|
||||||
|
best_score = -1
|
||||||
|
|
||||||
|
for candidate in candidates:
|
||||||
|
score = self._calculate_context_match_score(
|
||||||
|
candidate.get("context", []), context
|
||||||
|
)
|
||||||
|
if score > best_score:
|
||||||
|
best_score = score
|
||||||
|
best_match = candidate
|
||||||
|
|
||||||
|
if best_match and best_score >= MIN_CONTEXT_MATCH:
|
||||||
|
needs_refresh = not self._is_cache_fresh(best_match)
|
||||||
|
return best_match, needs_refresh
|
||||||
|
|
||||||
|
# context 없으면 첫 번째 후보 반환
|
||||||
|
candidate = candidates[0]
|
||||||
|
needs_refresh = not self._is_cache_fresh(candidate)
|
||||||
|
return candidate, needs_refresh
|
||||||
|
```
|
||||||
|
|
||||||
|
### Update (수정)
|
||||||
|
```python
|
||||||
|
async def update_article(self, mongodb_id: str, update_data: Dict[str, Any]):
|
||||||
|
"""기사 정보 업데이트"""
|
||||||
|
result = await self.collection.update_one(
|
||||||
|
{"_id": ObjectId(mongodb_id)},
|
||||||
|
{
|
||||||
|
"$set": {
|
||||||
|
"entities.people": update_data.get("people", []),
|
||||||
|
"entities.organizations": update_data.get("organizations", []),
|
||||||
|
"wikipedia_enriched": True,
|
||||||
|
"wikipedia_enriched_at": datetime.now().isoformat()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return result.modified_count > 0
|
||||||
|
```
|
||||||
|
|
||||||
|
### Delete (삭제)
|
||||||
|
```python
|
||||||
|
async def delete_old_cache(self, days: int = 30):
|
||||||
|
"""오래된 캐시 데이터 삭제"""
|
||||||
|
cutoff_date = datetime.now() - timedelta(days=days)
|
||||||
|
result = await self.people_collection.delete_many({
|
||||||
|
"updated_at": {"$lt": cutoff_date}
|
||||||
|
})
|
||||||
|
return result.deleted_count
|
||||||
|
```
|
||||||
|
|
||||||
|
## 캐싱 전략
|
||||||
|
|
||||||
|
### TTL 기반 캐시
|
||||||
|
```python
|
||||||
|
# 캐시 유효 기간 (7일)
|
||||||
|
CACHE_TTL_DAYS = 7
|
||||||
|
|
||||||
|
def _is_cache_fresh(self, cached_data: Dict[str, Any]) -> bool:
|
||||||
|
"""캐시 데이터가 신선한지 확인"""
|
||||||
|
if not cached_data:
|
||||||
|
return False
|
||||||
|
|
||||||
|
updated_at = cached_data.get("updated_at")
|
||||||
|
if not updated_at:
|
||||||
|
return False
|
||||||
|
|
||||||
|
if isinstance(updated_at, str):
|
||||||
|
updated_at = datetime.fromisoformat(updated_at)
|
||||||
|
|
||||||
|
expiry_date = updated_at + timedelta(days=CACHE_TTL_DAYS)
|
||||||
|
return datetime.now() < expiry_date
|
||||||
|
```
|
||||||
|
|
||||||
|
### 갱신 정책
|
||||||
|
```python
|
||||||
|
# 정책:
|
||||||
|
# - 7일이 지나면 갱신 시도 (삭제 아님)
|
||||||
|
# - API 호출 실패 시 기존 데이터 유지
|
||||||
|
# - 데이터 동일 시 확인 일자만 갱신
|
||||||
|
|
||||||
|
async def save_person(self, new_data: Dict, existing_data: Dict = None):
|
||||||
|
"""기존 데이터와 비교하여 적절히 처리"""
|
||||||
|
if existing_data and existing_data.get("verified"):
|
||||||
|
# 기존에 검증된 데이터가 있음
|
||||||
|
if not new_data.get("birth_date") and existing_data.get("birth_date"):
|
||||||
|
# 새 데이터가 덜 완전하면 기존 데이터 유지, 시간만 갱신
|
||||||
|
await self.people_collection.update_one(
|
||||||
|
{"wikipedia_url": existing_data["wikipedia_url"]},
|
||||||
|
{"$set": {"updated_at": datetime.now()}}
|
||||||
|
)
|
||||||
|
return
|
||||||
|
# 새 데이터로 갱신
|
||||||
|
await self._upsert_person(new_data)
|
||||||
|
```
|
||||||
|
|
||||||
|
## GridFS (대용량 파일)
|
||||||
|
|
||||||
|
### 오디오 파일 저장
|
||||||
|
```python
|
||||||
|
from motor.motor_asyncio import AsyncIOMotorGridFSBucket
|
||||||
|
|
||||||
|
class AudioStorage:
|
||||||
|
def __init__(self, db):
|
||||||
|
self.fs = AsyncIOMotorGridFSBucket(db, bucket_name="audio")
|
||||||
|
|
||||||
|
async def save_audio(self, audio_data: bytes, filename: str) -> str:
|
||||||
|
"""오디오 파일 저장"""
|
||||||
|
file_id = await self.fs.upload_from_stream(
|
||||||
|
filename,
|
||||||
|
audio_data,
|
||||||
|
metadata={"content_type": "audio/mpeg"}
|
||||||
|
)
|
||||||
|
return str(file_id)
|
||||||
|
|
||||||
|
async def get_audio(self, file_id: str) -> bytes:
|
||||||
|
"""오디오 파일 조회"""
|
||||||
|
grid_out = await self.fs.open_download_stream(ObjectId(file_id))
|
||||||
|
return await grid_out.read()
|
||||||
|
```
|
||||||
|
|
||||||
|
## 백업 정책
|
||||||
|
|
||||||
|
### 규칙
|
||||||
|
- **주기**: 하루에 한 번 (daily)
|
||||||
|
- **보관 기간**: 최소 7일
|
||||||
|
- **백업 위치**: 프로젝트 루트의 `./backups/` 디렉토리
|
||||||
|
|
||||||
|
### MongoDB 백업
|
||||||
|
```bash
|
||||||
|
# 백업 실행
|
||||||
|
BACKUP_NAME="mongodb_backup_$(date +%Y%m%d_%H%M%S)"
|
||||||
|
docker exec {프로젝트}-mongodb mongodump \
|
||||||
|
--uri="mongodb://{user}:{password}@localhost:27017" \
|
||||||
|
--authenticationDatabase=admin \
|
||||||
|
--out="/tmp/$BACKUP_NAME"
|
||||||
|
docker cp {프로젝트}-mongodb:/tmp/$BACKUP_NAME ./backups/
|
||||||
|
echo "백업 완료: ./backups/$BACKUP_NAME"
|
||||||
|
```
|
||||||
|
|
||||||
|
### MongoDB 복원
|
||||||
|
```bash
|
||||||
|
docker cp ./backups/$BACKUP_NAME {프로젝트}-mongodb:/tmp/
|
||||||
|
docker exec {프로젝트}-mongodb mongorestore \
|
||||||
|
--uri="mongodb://{user}:{password}@localhost:27017" \
|
||||||
|
--authenticationDatabase=admin \
|
||||||
|
"/tmp/$BACKUP_NAME"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 자동화 스크립트 (backup-mongodb.sh)
|
||||||
|
```bash
|
||||||
|
#!/bin/bash
|
||||||
|
PROJECT_NAME="{프로젝트명}"
|
||||||
|
BACKUP_DIR="{프로젝트경로}/backups"
|
||||||
|
BACKUP_NAME="mongodb_backup_$(date +%Y%m%d_%H%M%S)"
|
||||||
|
|
||||||
|
# 백업 실행
|
||||||
|
docker exec ${PROJECT_NAME}-mongodb mongodump \
|
||||||
|
--uri="mongodb://{user}:{password}@localhost:27017" \
|
||||||
|
--authenticationDatabase=admin \
|
||||||
|
--out="/tmp/$BACKUP_NAME"
|
||||||
|
|
||||||
|
docker cp ${PROJECT_NAME}-mongodb:/tmp/$BACKUP_NAME $BACKUP_DIR/
|
||||||
|
|
||||||
|
# 7일 이상 된 백업 삭제
|
||||||
|
find $BACKUP_DIR -type d -name "mongodb_backup_*" -mtime +7 -exec rm -rf {} \;
|
||||||
|
|
||||||
|
echo "$(date): Backup completed - $BACKUP_NAME" >> $BACKUP_DIR/backup.log
|
||||||
|
```
|
||||||
|
|
||||||
|
### cron 설정
|
||||||
|
```bash
|
||||||
|
# crontab -e
|
||||||
|
0 2 * * * /path/to/backup-mongodb.sh # 매일 새벽 2시
|
||||||
|
```
|
||||||
|
|
||||||
|
### 주의사항
|
||||||
|
- 새 프로젝트 생성 시 반드시 백업 스크립트 설정
|
||||||
|
- 백업 디렉토리는 .gitignore에 추가하여 커밋 제외
|
||||||
|
- 중요 데이터는 외부 스토리지에 추가 백업 권장
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 환경 변수
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# .env
|
||||||
|
MONGODB_URL=mongodb://admin:password123@mongodb:27017/
|
||||||
|
DB_NAME=ai_writer_db
|
||||||
|
TARGET_COLLECTION=articles_en
|
||||||
|
|
||||||
|
# docker-compose.yml
|
||||||
|
environment:
|
||||||
|
- MONGODB_URL=mongodb://${MONGO_USER}:${MONGO_PASSWORD}@mongodb:27017/
|
||||||
|
- DB_NAME=ai_writer_db
|
||||||
|
```
|
||||||
220
.claude/skills/deployment-standards.md
Normal file
220
.claude/skills/deployment-standards.md
Normal file
@ -0,0 +1,220 @@
|
|||||||
|
# Docker 배포 규칙 (Deployment Standards)
|
||||||
|
|
||||||
|
Docker 배포 표준입니다.
|
||||||
|
|
||||||
|
## Dockerfile 패턴
|
||||||
|
|
||||||
|
### Python 마이크로서비스 (Worker)
|
||||||
|
```dockerfile
|
||||||
|
FROM python:3.11-slim
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# 필요한 시스템 패키지 설치 (git은 공통 라이브러리 설치에 필요)
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends git && rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# 의존성 설치 (공통 라이브러리 포함 시)
|
||||||
|
COPY requirements.txt .
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt && \
|
||||||
|
pip install --no-cache-dir git+http://gitea.yakenator.io/yakenator/{공통라이브러리}.git
|
||||||
|
|
||||||
|
# 애플리케이션 코드 복사
|
||||||
|
COPY *.py .
|
||||||
|
|
||||||
|
# 워커 실행
|
||||||
|
CMD ["python", "worker.py"]
|
||||||
|
```
|
||||||
|
|
||||||
|
### Python API 서비스
|
||||||
|
```dockerfile
|
||||||
|
FROM python:3.11-slim
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# curl은 healthcheck에 필요
|
||||||
|
RUN apt-get update && apt-get install -y curl && rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
COPY requirements.txt .
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
COPY app/ ./app/
|
||||||
|
|
||||||
|
EXPOSE 8000
|
||||||
|
|
||||||
|
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||||
|
```
|
||||||
|
|
||||||
|
## docker-compose.yml 패턴
|
||||||
|
|
||||||
|
### 서비스 구조
|
||||||
|
```yaml
|
||||||
|
services:
|
||||||
|
# ===================
|
||||||
|
# Infrastructure (독립 포트 사용)
|
||||||
|
# ===================
|
||||||
|
mongodb:
|
||||||
|
image: mongo:7.0
|
||||||
|
container_name: {프로젝트}-mongodb
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
MONGO_INITDB_ROOT_USERNAME: ${MONGO_USER:-admin}
|
||||||
|
MONGO_INITDB_ROOT_PASSWORD: ${MONGO_PASSWORD:-password123}
|
||||||
|
ports:
|
||||||
|
- "{호스트포트}:27017" # 호스트 포트는 충돌 방지를 위해 변경
|
||||||
|
volumes:
|
||||||
|
- {프로젝트}_mongodb_data:/data/db
|
||||||
|
networks:
|
||||||
|
- {프로젝트}-network
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "mongosh", "--eval", "db.adminCommand('ping')"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 3
|
||||||
|
```
|
||||||
|
|
||||||
|
### Worker 서비스 패턴
|
||||||
|
```yaml
|
||||||
|
{서비스명}:
|
||||||
|
build:
|
||||||
|
context: ./repos/{서비스명}
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
container_name: {프로젝트}-{서비스명}
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
- REDIS_URL=redis://redis:6379
|
||||||
|
- MONGODB_URL=mongodb://${MONGO_USER}:${MONGO_PASSWORD}@mongodb:27017/
|
||||||
|
- DB_NAME={데이터베이스명}
|
||||||
|
- TARGET_COLLECTION={컬렉션명}
|
||||||
|
depends_on:
|
||||||
|
redis:
|
||||||
|
condition: service_healthy
|
||||||
|
mongodb:
|
||||||
|
condition: service_healthy
|
||||||
|
networks:
|
||||||
|
- {프로젝트}-network
|
||||||
|
```
|
||||||
|
|
||||||
|
## 컨테이너 네이밍 규칙
|
||||||
|
|
||||||
|
- 컨테이너명: `{프로젝트}-{서비스명}`
|
||||||
|
- 볼륨명: `{프로젝트}_{데이터유형}_data`
|
||||||
|
- 네트워크: `{프로젝트}-network`
|
||||||
|
|
||||||
|
## 환경 변수 관리
|
||||||
|
|
||||||
|
- `.env` 파일로 민감 정보 관리
|
||||||
|
- docker-compose.yml에서 `${VAR:-default}` 형태로 기본값 제공
|
||||||
|
- 공통 변수: `MONGO_USER`, `MONGO_PASSWORD`, `REDIS_URL`, `JWT_SECRET_KEY`
|
||||||
|
|
||||||
|
## 헬스체크 패턴
|
||||||
|
|
||||||
|
### Redis
|
||||||
|
```yaml
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "redis-cli", "ping"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 3
|
||||||
|
```
|
||||||
|
|
||||||
|
### MongoDB
|
||||||
|
```yaml
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "mongosh", "--eval", "db.adminCommand('ping')"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 3
|
||||||
|
```
|
||||||
|
|
||||||
|
### FastAPI 서비스
|
||||||
|
```yaml
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 3
|
||||||
|
```
|
||||||
|
|
||||||
|
## 프라이빗 컨테이너 레지스트리
|
||||||
|
|
||||||
|
Docker Hub rate limit 우회 및 프라이빗 이미지 관리를 위해 자체 레지스트리를 사용합니다.
|
||||||
|
|
||||||
|
### 레지스트리 구성
|
||||||
|
| 서비스 | 주소 | 용도 |
|
||||||
|
|--------|------|------|
|
||||||
|
| **Private Registry** | `docker.yakenator.io` / `10.0.0.3:5000` | 프라이빗 이미지 Push/Pull |
|
||||||
|
| **Docker Hub Cache** | `10.0.0.3:5001` | Docker Hub 이미지 캐시 (Pull-through) |
|
||||||
|
| **Registry UI** | http://reg.yakenator.io | 웹 UI |
|
||||||
|
|
||||||
|
### 프라이빗 이미지 Push/Pull (5000)
|
||||||
|
```bash
|
||||||
|
# 빌드 후 태깅
|
||||||
|
docker build -t {이미지명}:{태그} .
|
||||||
|
docker tag {이미지명}:{태그} 10.0.0.3:5000/{프로젝트}/{이미지명}:{태그}
|
||||||
|
|
||||||
|
# Push
|
||||||
|
docker push 10.0.0.3:5000/{프로젝트}/{이미지명}:{태그}
|
||||||
|
|
||||||
|
# 예시
|
||||||
|
docker build -t drama-studio-api:latest ./audio-studio-api
|
||||||
|
docker tag drama-studio-api:latest 10.0.0.3:5000/drama-studio/api:latest
|
||||||
|
docker push 10.0.0.3:5000/drama-studio/api:latest
|
||||||
|
```
|
||||||
|
|
||||||
|
### Docker Hub 캐시 사용 (5001)
|
||||||
|
```bash
|
||||||
|
# Docker Hub 이미지를 캐시 경유로 pull (rate limit 우회)
|
||||||
|
docker pull 10.0.0.3:5001/library/mongo:7.0
|
||||||
|
docker pull 10.0.0.3:5001/library/redis:7-alpine
|
||||||
|
docker pull 10.0.0.3:5001/library/python:3.11-slim
|
||||||
|
|
||||||
|
# 공식 이미지는 library/ 접두사 사용
|
||||||
|
docker pull 10.0.0.3:5001/library/node:20-alpine
|
||||||
|
|
||||||
|
# 사용자 이미지는 {username}/ 접두사 사용
|
||||||
|
docker pull 10.0.0.3:5001/joxit/docker-registry-ui:latest
|
||||||
|
```
|
||||||
|
|
||||||
|
### docker-compose.yml에서 사용
|
||||||
|
```yaml
|
||||||
|
services:
|
||||||
|
# 프라이빗 이미지 사용
|
||||||
|
api:
|
||||||
|
image: 10.0.0.3:5000/{프로젝트}/api:latest
|
||||||
|
|
||||||
|
# Docker Hub 캐시 이미지 사용
|
||||||
|
mongodb:
|
||||||
|
image: 10.0.0.3:5001/library/mongo:7.0
|
||||||
|
|
||||||
|
redis:
|
||||||
|
image: 10.0.0.3:5001/library/redis:7-alpine
|
||||||
|
```
|
||||||
|
|
||||||
|
### Docker 데몬 설정 (Insecure Registry)
|
||||||
|
```bash
|
||||||
|
# /etc/docker/daemon.json
|
||||||
|
{
|
||||||
|
"insecure-registries": ["10.0.0.3:5000", "10.0.0.3:5001"]
|
||||||
|
}
|
||||||
|
|
||||||
|
# 설정 후 재시작
|
||||||
|
sudo systemctl restart docker
|
||||||
|
```
|
||||||
|
|
||||||
|
### CI/CD에서 사용
|
||||||
|
```bash
|
||||||
|
# 빌드 및 배포 스크립트 예시
|
||||||
|
VERSION=$(git rev-parse --short HEAD)
|
||||||
|
IMAGE="10.0.0.3:5000/${PROJECT}/${SERVICE}:${VERSION}"
|
||||||
|
|
||||||
|
docker build -t $IMAGE .
|
||||||
|
docker push $IMAGE
|
||||||
|
docker tag $IMAGE 10.0.0.3:5000/${PROJECT}/${SERVICE}:latest
|
||||||
|
docker push 10.0.0.3:5000/${PROJECT}/${SERVICE}:latest
|
||||||
|
```
|
||||||
|
|
||||||
|
## 참고 리포지토리
|
||||||
|
|
||||||
|
- Gitea: http://gitea.yakenator.io/yakenator/
|
||||||
|
- Container Registry: http://reg.yakenator.io
|
||||||
|
- Docker Registry API: http://docker.yakenator.io/v2/_catalog
|
||||||
365
.claude/skills/flume-architecture.md
Normal file
365
.claude/skills/flume-architecture.md
Normal file
@ -0,0 +1,365 @@
|
|||||||
|
# Flume - K8s 기반 범용 파이프라인 시스템
|
||||||
|
|
||||||
|
## 개요
|
||||||
|
|
||||||
|
Flume은 K8s 네이티브 범용 파이프라인/워크플로우 시스템입니다.
|
||||||
|
|
||||||
|
- **비주얼 에디터** + **YAML/JSON 정의** 지원
|
||||||
|
- **하이브리드 실행**: 경량 작업은 중앙 엔진, 무거운 작업은 K8s Job
|
||||||
|
- **로컬 LLM 통합**: vLLM 기반 GPU 노드 지원
|
||||||
|
|
||||||
|
## 아키텍처
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TB
|
||||||
|
subgraph Frontend["Frontend (Next.js)"]
|
||||||
|
VE[Visual Node Editor<br/>ReactFlow]
|
||||||
|
PD[Pipeline Dashboard]
|
||||||
|
MC[Monitoring Console]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph API["API Server (FastAPI)"]
|
||||||
|
CRUD[Pipeline CRUD]
|
||||||
|
TM[Trigger Manager]
|
||||||
|
NR[Node Registry]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph Engine["Pipeline Engine"]
|
||||||
|
SCH[Scheduler<br/>APScheduler]
|
||||||
|
EXE[Executor<br/>Async]
|
||||||
|
SM[State Manager]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph K8s["Kubernetes Cluster"]
|
||||||
|
OP[Flume Operator<br/>kopf]
|
||||||
|
subgraph Workers["Worker Pods"]
|
||||||
|
CPU[CPU Workers]
|
||||||
|
GPU[GPU Workers<br/>vLLM]
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph Storage["Storage"]
|
||||||
|
Redis[(Redis<br/>Queue/Cache)]
|
||||||
|
MongoDB[(MongoDB<br/>Definitions/History)]
|
||||||
|
end
|
||||||
|
|
||||||
|
Frontend --> API
|
||||||
|
API --> Engine
|
||||||
|
Engine --> Redis
|
||||||
|
Engine --> MongoDB
|
||||||
|
Engine --> OP
|
||||||
|
OP --> Workers
|
||||||
|
GPU -.->|OpenAI API| EXE
|
||||||
|
```
|
||||||
|
|
||||||
|
## 실행 모델 (하이브리드)
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart LR
|
||||||
|
subgraph Light["경량 작업 (중앙 실행)"]
|
||||||
|
L1[HTTP Request]
|
||||||
|
L2[JSON Transform]
|
||||||
|
L3[Condition/Switch]
|
||||||
|
L4[Variable Set]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph Heavy["무거운 작업 (K8s Job)"]
|
||||||
|
H1[LLM Generate]
|
||||||
|
H2[Image Process]
|
||||||
|
H3[Data ETL]
|
||||||
|
H4[Custom Script]
|
||||||
|
end
|
||||||
|
|
||||||
|
ENG[Engine] --> Light
|
||||||
|
ENG --> |K8s Job 생성| Heavy
|
||||||
|
```
|
||||||
|
|
||||||
|
### 실행 기준
|
||||||
|
|
||||||
|
| 구분 | 실행 위치 | 예시 |
|
||||||
|
|------|----------|------|
|
||||||
|
| 경량 | 중앙 엔진 | HTTP 요청, 데이터 변환, 조건 분기 |
|
||||||
|
| GPU | GPU Pod | LLM 생성, 이미지 생성 |
|
||||||
|
| 무거운 CPU | K8s Job | 대용량 ETL, 장시간 스크립트 |
|
||||||
|
|
||||||
|
## 핵심 컴포넌트
|
||||||
|
|
||||||
|
### 1. Frontend (flume-ui)
|
||||||
|
|
||||||
|
```
|
||||||
|
flume-ui/
|
||||||
|
├── src/
|
||||||
|
│ ├── app/
|
||||||
|
│ │ ├── page.tsx # 대시보드
|
||||||
|
│ │ ├── pipelines/
|
||||||
|
│ │ │ ├── page.tsx # 파이프라인 목록
|
||||||
|
│ │ │ ├── [id]/
|
||||||
|
│ │ │ │ ├── page.tsx # 파이프라인 상세
|
||||||
|
│ │ │ │ └── edit/page.tsx # 비주얼 에디터
|
||||||
|
│ │ │ └── new/page.tsx # 새 파이프라인
|
||||||
|
│ │ └── runs/page.tsx # 실행 히스토리
|
||||||
|
│ ├── components/
|
||||||
|
│ │ ├── editor/ # ReactFlow 노드 에디터
|
||||||
|
│ │ ├── nodes/ # 커스텀 노드 컴포넌트
|
||||||
|
│ │ └── ui/ # shadcn/ui
|
||||||
|
│ └── lib/
|
||||||
|
│ └── api.ts # API 클라이언트
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. API Server (flume-api)
|
||||||
|
|
||||||
|
```
|
||||||
|
flume-api/
|
||||||
|
├── app/
|
||||||
|
│ ├── main.py
|
||||||
|
│ ├── database.py
|
||||||
|
│ ├── models/
|
||||||
|
│ │ ├── pipeline.py # 파이프라인 정의
|
||||||
|
│ │ ├── run.py # 실행 기록
|
||||||
|
│ │ └── node.py # 노드 정의
|
||||||
|
│ ├── routers/
|
||||||
|
│ │ ├── pipelines.py # CRUD
|
||||||
|
│ │ ├── runs.py # 실행 관리
|
||||||
|
│ │ ├── triggers.py # 트리거 관리
|
||||||
|
│ │ └── nodes.py # 노드 레지스트리
|
||||||
|
│ └── services/
|
||||||
|
│ └── engine_client.py # 엔진 통신
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Pipeline Engine (flume-engine)
|
||||||
|
|
||||||
|
```
|
||||||
|
flume-engine/
|
||||||
|
├── engine/
|
||||||
|
│ ├── executor.py # DAG 실행기
|
||||||
|
│ ├── scheduler.py # 스케줄러 (cron, interval)
|
||||||
|
│ ├── state.py # 상태 관리
|
||||||
|
│ └── k8s_client.py # K8s Job 생성
|
||||||
|
├── nodes/
|
||||||
|
│ ├── base.py # 노드 베이스 클래스
|
||||||
|
│ ├── builtin/
|
||||||
|
│ │ ├── http.py # HTTP 요청
|
||||||
|
│ │ ├── transform.py # 데이터 변환
|
||||||
|
│ │ ├── condition.py # 조건 분기
|
||||||
|
│ │ ├── llm.py # LLM 호출
|
||||||
|
│ │ └── database.py # DB 작업
|
||||||
|
│ └── registry.py # 노드 레지스트리
|
||||||
|
└── worker.py # 메인 워커
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. K8s Operator (flume-operator)
|
||||||
|
|
||||||
|
```
|
||||||
|
flume-operator/
|
||||||
|
├── operator.py # kopf 기반 오퍼레이터
|
||||||
|
├── crds/
|
||||||
|
│ └── flumejob.yaml # CRD 정의
|
||||||
|
└── templates/
|
||||||
|
├── cpu_job.yaml
|
||||||
|
└── gpu_job.yaml
|
||||||
|
```
|
||||||
|
|
||||||
|
## 파이프라인 정의
|
||||||
|
|
||||||
|
### YAML 형식
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
apiVersion: flume/v1
|
||||||
|
kind: Pipeline
|
||||||
|
metadata:
|
||||||
|
name: article-generator
|
||||||
|
description: 기사 생성 파이프라인
|
||||||
|
|
||||||
|
trigger:
|
||||||
|
type: webhook
|
||||||
|
# type: cron
|
||||||
|
# schedule: "0 9 * * *"
|
||||||
|
|
||||||
|
variables:
|
||||||
|
model: llama3.1-70b
|
||||||
|
|
||||||
|
nodes:
|
||||||
|
- id: fetch-topic
|
||||||
|
type: http-request
|
||||||
|
config:
|
||||||
|
method: GET
|
||||||
|
url: "https://api.example.com/topics"
|
||||||
|
|
||||||
|
- id: generate-article
|
||||||
|
type: llm-generate
|
||||||
|
runOn: gpu # GPU Pod에서 실행
|
||||||
|
config:
|
||||||
|
model: "{{variables.model}}"
|
||||||
|
maxTokens: 4096
|
||||||
|
inputs:
|
||||||
|
prompt: |
|
||||||
|
주제: {{fetch-topic.output.topic}}
|
||||||
|
위 주제로 뉴스 기사를 작성하세요.
|
||||||
|
|
||||||
|
- id: save-article
|
||||||
|
type: mongodb-insert
|
||||||
|
config:
|
||||||
|
database: flume
|
||||||
|
collection: articles
|
||||||
|
inputs:
|
||||||
|
document:
|
||||||
|
title: "{{fetch-topic.output.topic}}"
|
||||||
|
content: "{{generate-article.output}}"
|
||||||
|
createdAt: "{{$now}}"
|
||||||
|
|
||||||
|
edges:
|
||||||
|
- from: fetch-topic
|
||||||
|
to: generate-article
|
||||||
|
- from: generate-article
|
||||||
|
to: save-article
|
||||||
|
```
|
||||||
|
|
||||||
|
## 노드 SDK
|
||||||
|
|
||||||
|
### 커스텀 노드 작성
|
||||||
|
|
||||||
|
```python
|
||||||
|
from flume.nodes import Node, NodeInput, NodeOutput
|
||||||
|
|
||||||
|
class MyCustomNode(Node):
|
||||||
|
"""커스텀 노드 예시"""
|
||||||
|
|
||||||
|
name = "my-custom-node"
|
||||||
|
category = "custom"
|
||||||
|
run_on = "engine" # engine | cpu | gpu
|
||||||
|
|
||||||
|
class Input(NodeInput):
|
||||||
|
data: str
|
||||||
|
count: int = 1
|
||||||
|
|
||||||
|
class Output(NodeOutput):
|
||||||
|
result: list[str]
|
||||||
|
|
||||||
|
async def execute(self, input: Input) -> Output:
|
||||||
|
"""노드 실행 로직"""
|
||||||
|
results = [input.data] * input.count
|
||||||
|
return self.Output(result=results)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 노드 등록
|
||||||
|
|
||||||
|
```python
|
||||||
|
from flume.nodes import registry
|
||||||
|
|
||||||
|
registry.register(MyCustomNode)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 빌트인 노드
|
||||||
|
|
||||||
|
| 카테고리 | 노드 | 설명 | 실행 위치 |
|
||||||
|
|---------|------|------|----------|
|
||||||
|
| **Trigger** | webhook | 웹훅 수신 | - |
|
||||||
|
| | cron | 스케줄 실행 | - |
|
||||||
|
| | manual | 수동 실행 | - |
|
||||||
|
| **HTTP** | http-request | HTTP 요청 | engine |
|
||||||
|
| | http-response | 응답 반환 | engine |
|
||||||
|
| **Transform** | json-transform | JSON 변환 | engine |
|
||||||
|
| | template | 템플릿 렌더링 | engine |
|
||||||
|
| **Logic** | condition | 조건 분기 | engine |
|
||||||
|
| | switch | 다중 분기 | engine |
|
||||||
|
| | loop | 반복 실행 | engine |
|
||||||
|
| **AI** | llm-generate | LLM 텍스트 생성 | gpu |
|
||||||
|
| | llm-chat | LLM 대화 | gpu |
|
||||||
|
| | embedding | 임베딩 생성 | gpu |
|
||||||
|
| **Database** | mongodb-query | MongoDB 조회 | engine |
|
||||||
|
| | mongodb-insert | MongoDB 삽입 | engine |
|
||||||
|
| | redis-get/set | Redis 작업 | engine |
|
||||||
|
| **Utility** | delay | 지연 | engine |
|
||||||
|
| | log | 로깅 | engine |
|
||||||
|
| | error | 에러 발생 | engine |
|
||||||
|
|
||||||
|
## 데이터 모델 (MongoDB)
|
||||||
|
|
||||||
|
### pipelines 컬렉션
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
{
|
||||||
|
_id: ObjectId,
|
||||||
|
name: "article-generator",
|
||||||
|
description: "기사 생성 파이프라인",
|
||||||
|
definition: { /* YAML을 파싱한 객체 */ },
|
||||||
|
trigger: { type: "webhook", config: {} },
|
||||||
|
status: "active", // active | paused | draft
|
||||||
|
createdAt: ISODate,
|
||||||
|
updatedAt: ISODate
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### runs 컬렉션
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
{
|
||||||
|
_id: ObjectId,
|
||||||
|
pipelineId: ObjectId,
|
||||||
|
status: "running", // pending | running | completed | failed
|
||||||
|
trigger: { type: "webhook", data: {} },
|
||||||
|
nodes: {
|
||||||
|
"fetch-topic": {
|
||||||
|
status: "completed",
|
||||||
|
output: { topic: "..." },
|
||||||
|
startedAt: ISODate,
|
||||||
|
completedAt: ISODate
|
||||||
|
},
|
||||||
|
"generate-article": {
|
||||||
|
status: "running",
|
||||||
|
startedAt: ISODate
|
||||||
|
}
|
||||||
|
},
|
||||||
|
startedAt: ISODate,
|
||||||
|
completedAt: ISODate,
|
||||||
|
error: null
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 환경 변수
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# flume-api
|
||||||
|
MONGODB_URL=mongodb://admin:password@mongodb:27017/
|
||||||
|
DB_NAME=flume
|
||||||
|
REDIS_URL=redis://redis:6379
|
||||||
|
ENGINE_URL=http://flume-engine:8001
|
||||||
|
|
||||||
|
# flume-engine
|
||||||
|
MONGODB_URL=mongodb://admin:password@mongodb:27017/
|
||||||
|
DB_NAME=flume
|
||||||
|
REDIS_URL=redis://redis:6379
|
||||||
|
VLLM_URL=http://vllm:8000/v1
|
||||||
|
K8S_NAMESPACE=flume
|
||||||
|
|
||||||
|
# flume-ui
|
||||||
|
NEXT_PUBLIC_API_URL=http://localhost:8000
|
||||||
|
```
|
||||||
|
|
||||||
|
## 개발 로드맵
|
||||||
|
|
||||||
|
### Phase 1: 코어
|
||||||
|
- [ ] 파이프라인 정의 스키마
|
||||||
|
- [ ] API Server 기본 CRUD
|
||||||
|
- [ ] Engine 코어 (DAG 실행)
|
||||||
|
- [ ] 빌트인 노드 (HTTP, Transform, Logic)
|
||||||
|
|
||||||
|
### Phase 2: 실행
|
||||||
|
- [ ] 스케줄러 (cron, interval)
|
||||||
|
- [ ] K8s Job 실행
|
||||||
|
- [ ] 상태 관리 및 재시도
|
||||||
|
|
||||||
|
### Phase 3: AI
|
||||||
|
- [ ] LLM 노드 (vLLM 연동)
|
||||||
|
- [ ] 임베딩 노드
|
||||||
|
- [ ] GPU 스케줄링
|
||||||
|
|
||||||
|
### Phase 4: UI
|
||||||
|
- [ ] 비주얼 노드 에디터
|
||||||
|
- [ ] 실행 모니터링
|
||||||
|
- [ ] 로그 뷰어
|
||||||
|
|
||||||
|
### Phase 5: 확장
|
||||||
|
- [ ] 커스텀 노드 SDK
|
||||||
|
- [ ] 플러그인 시스템
|
||||||
|
- [ ] 멀티 테넌시
|
||||||
325
.claude/skills/frontend-component-patterns.md
Normal file
325
.claude/skills/frontend-component-patterns.md
Normal file
@ -0,0 +1,325 @@
|
|||||||
|
# React 컴포넌트 패턴 (Frontend Component Patterns)
|
||||||
|
|
||||||
|
이 프로젝트의 React/Next.js 컴포넌트 패턴입니다.
|
||||||
|
|
||||||
|
## shadcn/ui 기반 컴포넌트
|
||||||
|
|
||||||
|
### 설치 및 초기화
|
||||||
|
```bash
|
||||||
|
# shadcn/ui 초기화
|
||||||
|
npx shadcn@latest init
|
||||||
|
|
||||||
|
# 컴포넌트 추가
|
||||||
|
npx shadcn@latest add button card dialog tabs table form
|
||||||
|
```
|
||||||
|
|
||||||
|
### Button 컴포넌트 (CVA 패턴)
|
||||||
|
```tsx
|
||||||
|
import * as React from "react"
|
||||||
|
import { Slot } from "@radix-ui/react-slot"
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const buttonVariants = cva(
|
||||||
|
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
||||||
|
destructive: "bg-destructive text-white hover:bg-destructive/90",
|
||||||
|
outline: "border bg-background shadow-xs hover:bg-accent",
|
||||||
|
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||||
|
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||||
|
link: "text-primary underline-offset-4 hover:underline",
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
default: "h-9 px-4 py-2",
|
||||||
|
sm: "h-8 rounded-md px-3",
|
||||||
|
lg: "h-10 rounded-md px-6",
|
||||||
|
icon: "size-9",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
size: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
function Button({
|
||||||
|
className,
|
||||||
|
variant,
|
||||||
|
size,
|
||||||
|
asChild = false,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"button"> &
|
||||||
|
VariantProps<typeof buttonVariants> & {
|
||||||
|
asChild?: boolean
|
||||||
|
}) {
|
||||||
|
const Comp = asChild ? Slot : "button"
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Comp
|
||||||
|
className={cn(buttonVariants({ variant, size, className }))}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Button, buttonVariants }
|
||||||
|
```
|
||||||
|
|
||||||
|
## Next.js App Router 구조
|
||||||
|
|
||||||
|
### 레이아웃 (layout.tsx)
|
||||||
|
```tsx
|
||||||
|
import type { Metadata } from "next";
|
||||||
|
import { Geist, Geist_Mono } from "next/font/google";
|
||||||
|
import "./globals.css";
|
||||||
|
import { Providers } from "@/components/providers";
|
||||||
|
import { Toaster } from "@/components/ui/sonner";
|
||||||
|
|
||||||
|
const geistSans = Geist({
|
||||||
|
variable: "--font-geist-sans",
|
||||||
|
subsets: ["latin"],
|
||||||
|
});
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "News Engine Admin",
|
||||||
|
description: "Admin dashboard for News Pipeline management",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function RootLayout({
|
||||||
|
children,
|
||||||
|
}: Readonly<{ children: React.ReactNode }>) {
|
||||||
|
return (
|
||||||
|
<html lang="en">
|
||||||
|
<body className={`${geistSans.variable} antialiased`}>
|
||||||
|
<Providers>
|
||||||
|
{children}
|
||||||
|
<Toaster />
|
||||||
|
</Providers>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Provider 패턴
|
||||||
|
```tsx
|
||||||
|
// components/providers.tsx
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import { ThemeProvider } from "next-themes"
|
||||||
|
|
||||||
|
export function Providers({ children }: { children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<ThemeProvider
|
||||||
|
attribute="class"
|
||||||
|
defaultTheme="system"
|
||||||
|
enableSystem
|
||||||
|
disableTransitionOnChange
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</ThemeProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 유틸리티 함수
|
||||||
|
|
||||||
|
### cn() 함수 (lib/utils.ts)
|
||||||
|
```tsx
|
||||||
|
import { clsx, type ClassValue } from "clsx"
|
||||||
|
import { twMerge } from "tailwind-merge"
|
||||||
|
|
||||||
|
export function cn(...inputs: ClassValue[]) {
|
||||||
|
return twMerge(clsx(inputs))
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 커스텀 훅 패턴
|
||||||
|
|
||||||
|
### 데이터 페칭 훅
|
||||||
|
```tsx
|
||||||
|
// hooks/use-articles.ts
|
||||||
|
import { useState, useEffect } from "react"
|
||||||
|
|
||||||
|
interface Article {
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
summary: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useArticles() {
|
||||||
|
const [articles, setArticles] = useState<Article[]>([])
|
||||||
|
const [isLoading, setIsLoading] = useState(true)
|
||||||
|
const [error, setError] = useState<Error | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
async function fetchArticles() {
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/articles")
|
||||||
|
if (!res.ok) throw new Error("Failed to fetch")
|
||||||
|
const data = await res.json()
|
||||||
|
setArticles(data.items)
|
||||||
|
} catch (e) {
|
||||||
|
setError(e as Error)
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fetchArticles()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return { articles, isLoading, error }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 토글 훅
|
||||||
|
```tsx
|
||||||
|
// hooks/use-toggle.ts
|
||||||
|
import { useState, useCallback } from "react"
|
||||||
|
|
||||||
|
export function useToggle(initialState = false) {
|
||||||
|
const [state, setState] = useState(initialState)
|
||||||
|
|
||||||
|
const toggle = useCallback(() => setState((s) => !s), [])
|
||||||
|
const setTrue = useCallback(() => setState(true), [])
|
||||||
|
const setFalse = useCallback(() => setState(false), [])
|
||||||
|
|
||||||
|
return { state, toggle, setTrue, setFalse }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 폼 패턴 (react-hook-form + zod)
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useForm } from "react-hook-form"
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod"
|
||||||
|
import { z } from "zod"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Input } from "@/components/ui/input"
|
||||||
|
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form"
|
||||||
|
|
||||||
|
const formSchema = z.object({
|
||||||
|
title: z.string().min(1, "제목을 입력하세요"),
|
||||||
|
content: z.string().min(10, "내용은 10자 이상이어야 합니다"),
|
||||||
|
})
|
||||||
|
|
||||||
|
type FormValues = z.infer<typeof formSchema>
|
||||||
|
|
||||||
|
export function ArticleForm() {
|
||||||
|
const form = useForm<FormValues>({
|
||||||
|
resolver: zodResolver(formSchema),
|
||||||
|
defaultValues: {
|
||||||
|
title: "",
|
||||||
|
content: "",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
async function onSubmit(values: FormValues) {
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/articles", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(values),
|
||||||
|
})
|
||||||
|
if (!res.ok) throw new Error("Failed to create")
|
||||||
|
// 성공 처리
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Form {...form}>
|
||||||
|
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="title"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>제목</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Button type="submit">저장</Button>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 다크모드 지원
|
||||||
|
|
||||||
|
### 테마 토글 버튼
|
||||||
|
```tsx
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useTheme } from "next-themes"
|
||||||
|
import { Moon, Sun } from "lucide-react"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
|
||||||
|
export function ThemeToggle() {
|
||||||
|
const { theme, setTheme } = useTheme()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => setTheme(theme === "dark" ? "light" : "dark")}
|
||||||
|
>
|
||||||
|
<Sun className="h-5 w-5 rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
|
||||||
|
<Moon className="absolute h-5 w-5 rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 토스트 알림 (sonner)
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { toast } from "sonner"
|
||||||
|
|
||||||
|
// 성공 알림
|
||||||
|
toast.success("저장되었습니다")
|
||||||
|
|
||||||
|
// 에러 알림
|
||||||
|
toast.error("오류가 발생했습니다")
|
||||||
|
|
||||||
|
// 로딩 알림
|
||||||
|
const toastId = toast.loading("처리 중...")
|
||||||
|
// 완료 후
|
||||||
|
toast.success("완료!", { id: toastId })
|
||||||
|
```
|
||||||
|
|
||||||
|
## 파일 구조
|
||||||
|
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
├── app/ # Next.js App Router
|
||||||
|
│ ├── layout.tsx # 루트 레이아웃
|
||||||
|
│ ├── page.tsx # 메인 페이지
|
||||||
|
│ └── dashboard/
|
||||||
|
│ └── page.tsx
|
||||||
|
├── components/
|
||||||
|
│ ├── ui/ # shadcn/ui 기본 컴포넌트
|
||||||
|
│ │ ├── button.tsx
|
||||||
|
│ │ ├── card.tsx
|
||||||
|
│ │ └── ...
|
||||||
|
│ ├── providers.tsx # Context Providers
|
||||||
|
│ └── app-sidebar.tsx # 앱 전용 컴포넌트
|
||||||
|
├── hooks/ # 커스텀 훅
|
||||||
|
│ └── use-articles.ts
|
||||||
|
├── lib/ # 유틸리티
|
||||||
|
│ └── utils.ts
|
||||||
|
└── types/ # TypeScript 타입
|
||||||
|
└── index.ts
|
||||||
|
```
|
||||||
142
.claude/skills/gitea-workflow.md
Normal file
142
.claude/skills/gitea-workflow.md
Normal file
@ -0,0 +1,142 @@
|
|||||||
|
# Gitea 워크플로우 (Gitea Workflow)
|
||||||
|
|
||||||
|
Gitea 리포지토리 관리 워크플로우입니다.
|
||||||
|
|
||||||
|
## Gitea 서버 정보
|
||||||
|
|
||||||
|
- **URL**: http://gitea.yakenator.io
|
||||||
|
- **사용자**: yakenator
|
||||||
|
- **비밀번호**: asdfg23we
|
||||||
|
|
||||||
|
## 새 리포지토리 생성
|
||||||
|
|
||||||
|
### 1. Gitea CLI (tea) 사용
|
||||||
|
```bash
|
||||||
|
# tea 설치 (macOS)
|
||||||
|
brew install tea
|
||||||
|
|
||||||
|
# 로그인
|
||||||
|
tea login add --url http://gitea.yakenator.io --user yakenator --password asdfg23we
|
||||||
|
|
||||||
|
# 리포지토리 생성
|
||||||
|
tea repo create --name {repo-name} --private=false
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. API 사용
|
||||||
|
```bash
|
||||||
|
# 리포지토리 생성
|
||||||
|
curl -X POST "http://gitea.yakenator.io/api/v1/user/repos" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-u "yakenator:asdfg23we" \
|
||||||
|
-d '{
|
||||||
|
"name": "{repo-name}",
|
||||||
|
"description": "서비스 설명",
|
||||||
|
"private": true,
|
||||||
|
"auto_init": true
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. 웹 UI 사용
|
||||||
|
1. http://gitea.yakenator.io 접속
|
||||||
|
2. yakenator / asdfg23we 로 로그인
|
||||||
|
3. "New Repository" 클릭
|
||||||
|
4. 리포지토리 정보 입력 후 생성
|
||||||
|
|
||||||
|
## 새 프로젝트 초기화 및 푸시
|
||||||
|
|
||||||
|
### 전체 워크플로우
|
||||||
|
```bash
|
||||||
|
# 1. 프로젝트 디렉토리 생성
|
||||||
|
mkdir ./{new-project}
|
||||||
|
cd ./{new-project}
|
||||||
|
|
||||||
|
# 2. Git 초기화
|
||||||
|
git init
|
||||||
|
|
||||||
|
# 3. 파일 생성 (Dockerfile, requirements.txt 등)
|
||||||
|
|
||||||
|
# 4. 첫 커밋
|
||||||
|
git add -A
|
||||||
|
git commit -m "Initial commit: {project-name} 초기 구성
|
||||||
|
|
||||||
|
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>"
|
||||||
|
|
||||||
|
# 5. Gitea에 리포지토리 생성 (API)
|
||||||
|
curl -X POST "http://gitea.yakenator.io/api/v1/user/repos" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-u "yakenator:asdfg23we" \
|
||||||
|
-d '{"name": "{new-project}", "private": false}'
|
||||||
|
|
||||||
|
# 6. Remote 추가 및 푸시
|
||||||
|
git remote add origin http://yakenator:asdfg23we@gitea.yakenator.io/yakenator/{new-project}.git
|
||||||
|
git branch -M main
|
||||||
|
git push -u origin main
|
||||||
|
```
|
||||||
|
|
||||||
|
## 기존 리포지토리 클론
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# HTTP 클론 (인증 포함)
|
||||||
|
git clone http://yakenator:asdfg23we@gitea.yakenator.io/yakenator/{repo-name}.git
|
||||||
|
|
||||||
|
# 또는 클론 후 credential 설정
|
||||||
|
git clone http://gitea.yakenator.io/yakenator/{repo-name}.git
|
||||||
|
cd {repo-name}
|
||||||
|
git config credential.helper store
|
||||||
|
```
|
||||||
|
|
||||||
|
## 리포지토리 URL 패턴
|
||||||
|
|
||||||
|
```
|
||||||
|
http://gitea.yakenator.io/yakenator/{repo-name}.git
|
||||||
|
```
|
||||||
|
|
||||||
|
## 커밋 및 푸시
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 변경사항 확인
|
||||||
|
git status
|
||||||
|
|
||||||
|
# 스테이징 및 커밋
|
||||||
|
git add -A
|
||||||
|
git commit -m "$(cat <<'EOF'
|
||||||
|
feat: 기능 설명
|
||||||
|
|
||||||
|
- 변경사항 1
|
||||||
|
- 변경사항 2
|
||||||
|
|
||||||
|
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
|
||||||
|
EOF
|
||||||
|
)"
|
||||||
|
|
||||||
|
# 푸시
|
||||||
|
git push
|
||||||
|
```
|
||||||
|
|
||||||
|
## 브랜치 전략
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 기본 브랜치: main
|
||||||
|
# 기능 브랜치: feature/{기능명}
|
||||||
|
# 버그 수정: fix/{버그설명}
|
||||||
|
|
||||||
|
# 브랜치 생성 및 전환
|
||||||
|
git checkout -b feature/new-feature
|
||||||
|
|
||||||
|
# 작업 후 푸시
|
||||||
|
git push -u origin feature/new-feature
|
||||||
|
|
||||||
|
# PR 생성 (웹 UI 또는 API)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 주의사항
|
||||||
|
|
||||||
|
- 민감 정보(.env, credentials)는 절대 커밋하지 않음
|
||||||
|
- .gitignore에 다음 항목 포함:
|
||||||
|
```
|
||||||
|
.env
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
node_modules/
|
||||||
|
.DS_Store
|
||||||
|
```
|
||||||
213
.claude/skills/gpu-local-models.md
Normal file
213
.claude/skills/gpu-local-models.md
Normal file
@ -0,0 +1,213 @@
|
|||||||
|
# GPU 로컬 모델 인프라 (GPU Local Models)
|
||||||
|
|
||||||
|
로컬 LLM/ML 모델 서빙을 위한 GPU 인프라 가이드입니다.
|
||||||
|
|
||||||
|
## GPU 인벤토리
|
||||||
|
|
||||||
|
| GPU | VRAM | 수량 | 용도 |
|
||||||
|
|-----|------|------|------|
|
||||||
|
| NVIDIA V100 | 16GB | 8 | 중형 모델, 병렬 추론 |
|
||||||
|
| NVIDIA V100 | 32GB | 4 | 대형 모델, 파인튜닝 |
|
||||||
|
| NVIDIA RTX 3090 | 24GB | 2 | 개발/테스트, 중형 모델 |
|
||||||
|
|
||||||
|
**총 VRAM**: 16×8 + 32×4 + 24×2 = 128 + 128 + 48 = **304GB**
|
||||||
|
|
||||||
|
## VRAM별 모델 가이드
|
||||||
|
|
||||||
|
### 16GB (V100 16GB, 단일)
|
||||||
|
- Llama 3.1 8B (Q4 양자화)
|
||||||
|
- Mistral 7B
|
||||||
|
- Phi-3 Medium
|
||||||
|
- Gemma 2 9B
|
||||||
|
|
||||||
|
### 24GB (RTX 3090)
|
||||||
|
- Llama 3.1 8B (FP16)
|
||||||
|
- Qwen2.5 14B (Q4)
|
||||||
|
- CodeLlama 13B
|
||||||
|
|
||||||
|
### 32GB (V100 32GB, 단일)
|
||||||
|
- Llama 3.1 70B (Q4 양자화)
|
||||||
|
- Qwen2.5 32B
|
||||||
|
- DeepSeek Coder 33B
|
||||||
|
|
||||||
|
### 멀티 GPU (텐서 병렬)
|
||||||
|
- V100 32GB × 2 (64GB): Llama 3.1 70B (FP16)
|
||||||
|
- V100 32GB × 4 (128GB): Llama 3.1 70B + 여유 / 대형 모델
|
||||||
|
- V100 16GB × 8 (128GB): 대규모 배치 추론
|
||||||
|
|
||||||
|
## 모델 서빙 프레임워크
|
||||||
|
|
||||||
|
### vLLM (권장)
|
||||||
|
```bash
|
||||||
|
# Docker 실행
|
||||||
|
docker run --gpus all -p 8000:8000 \
|
||||||
|
-v ~/.cache/huggingface:/root/.cache/huggingface \
|
||||||
|
vllm/vllm-openai:latest \
|
||||||
|
--model meta-llama/Llama-3.1-8B-Instruct \
|
||||||
|
--tensor-parallel-size 1
|
||||||
|
```
|
||||||
|
|
||||||
|
### Text Generation Inference (TGI)
|
||||||
|
```bash
|
||||||
|
docker run --gpus all -p 8080:80 \
|
||||||
|
-v ~/.cache/huggingface:/data \
|
||||||
|
ghcr.io/huggingface/text-generation-inference:latest \
|
||||||
|
--model-id meta-llama/Llama-3.1-8B-Instruct
|
||||||
|
```
|
||||||
|
|
||||||
|
### Ollama (개발/테스트용)
|
||||||
|
```bash
|
||||||
|
# 설치
|
||||||
|
curl -fsSL https://ollama.com/install.sh | sh
|
||||||
|
|
||||||
|
# 모델 실행
|
||||||
|
ollama run llama3.1:8b
|
||||||
|
```
|
||||||
|
|
||||||
|
## Docker Compose GPU 패턴
|
||||||
|
|
||||||
|
### 단일 GPU 서비스
|
||||||
|
```yaml
|
||||||
|
services:
|
||||||
|
llm-server:
|
||||||
|
image: vllm/vllm-openai:latest
|
||||||
|
container_name: {프로젝트}-llm
|
||||||
|
restart: unless-stopped
|
||||||
|
deploy:
|
||||||
|
resources:
|
||||||
|
reservations:
|
||||||
|
devices:
|
||||||
|
- driver: nvidia
|
||||||
|
count: 1
|
||||||
|
capabilities: [gpu]
|
||||||
|
environment:
|
||||||
|
- HUGGING_FACE_HUB_TOKEN=${HF_TOKEN}
|
||||||
|
volumes:
|
||||||
|
- ~/.cache/huggingface:/root/.cache/huggingface
|
||||||
|
ports:
|
||||||
|
- "8000:8000"
|
||||||
|
command: >
|
||||||
|
--model meta-llama/Llama-3.1-8B-Instruct
|
||||||
|
--max-model-len 8192
|
||||||
|
networks:
|
||||||
|
- {프로젝트}-network
|
||||||
|
```
|
||||||
|
|
||||||
|
### 멀티 GPU 텐서 병렬
|
||||||
|
```yaml
|
||||||
|
services:
|
||||||
|
llm-large:
|
||||||
|
image: vllm/vllm-openai:latest
|
||||||
|
container_name: {프로젝트}-llm-large
|
||||||
|
restart: unless-stopped
|
||||||
|
deploy:
|
||||||
|
resources:
|
||||||
|
reservations:
|
||||||
|
devices:
|
||||||
|
- driver: nvidia
|
||||||
|
count: 4
|
||||||
|
capabilities: [gpu]
|
||||||
|
environment:
|
||||||
|
- HUGGING_FACE_HUB_TOKEN=${HF_TOKEN}
|
||||||
|
- CUDA_VISIBLE_DEVICES=0,1,2,3
|
||||||
|
volumes:
|
||||||
|
- ~/.cache/huggingface:/root/.cache/huggingface
|
||||||
|
ports:
|
||||||
|
- "8001:8000"
|
||||||
|
command: >
|
||||||
|
--model meta-llama/Llama-3.1-70B-Instruct
|
||||||
|
--tensor-parallel-size 4
|
||||||
|
--max-model-len 4096
|
||||||
|
networks:
|
||||||
|
- {프로젝트}-network
|
||||||
|
```
|
||||||
|
|
||||||
|
### 특정 GPU 지정
|
||||||
|
```yaml
|
||||||
|
deploy:
|
||||||
|
resources:
|
||||||
|
reservations:
|
||||||
|
devices:
|
||||||
|
- driver: nvidia
|
||||||
|
device_ids: ['0', '1'] # GPU 0, 1 지정
|
||||||
|
capabilities: [gpu]
|
||||||
|
```
|
||||||
|
|
||||||
|
## API 통합 패턴
|
||||||
|
|
||||||
|
### OpenAI 호환 클라이언트 (vLLM)
|
||||||
|
```python
|
||||||
|
from openai import AsyncOpenAI
|
||||||
|
|
||||||
|
client = AsyncOpenAI(
|
||||||
|
base_url="http://llm-server:8000/v1",
|
||||||
|
api_key="not-needed" # 로컬은 API 키 불필요
|
||||||
|
)
|
||||||
|
|
||||||
|
response = await client.chat.completions.create(
|
||||||
|
model="meta-llama/Llama-3.1-8B-Instruct",
|
||||||
|
messages=[{"role": "user", "content": prompt}],
|
||||||
|
max_tokens=2048,
|
||||||
|
temperature=0.7
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 로컬/원격 전환 패턴
|
||||||
|
```python
|
||||||
|
import os
|
||||||
|
from openai import AsyncOpenAI
|
||||||
|
|
||||||
|
def get_llm_client():
|
||||||
|
"""환경에 따라 로컬 또는 원격 LLM 클라이언트 반환"""
|
||||||
|
if os.getenv("USE_LOCAL_LLM", "false").lower() == "true":
|
||||||
|
return AsyncOpenAI(
|
||||||
|
base_url=os.getenv("LOCAL_LLM_URL", "http://llm-server:8000/v1"),
|
||||||
|
api_key="not-needed"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
return AsyncOpenAI(api_key=os.getenv("OPENAI_API_KEY"))
|
||||||
|
```
|
||||||
|
|
||||||
|
## 환경 변수
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# .env 추가 항목
|
||||||
|
HF_TOKEN=hf_... # Hugging Face 토큰
|
||||||
|
USE_LOCAL_LLM=true # 로컬 LLM 사용 여부
|
||||||
|
LOCAL_LLM_URL=http://llm-server:8000/v1 # 로컬 LLM 엔드포인트
|
||||||
|
LOCAL_MODEL_NAME=meta-llama/Llama-3.1-8B-Instruct
|
||||||
|
```
|
||||||
|
|
||||||
|
## GPU 모니터링
|
||||||
|
|
||||||
|
### nvidia-smi
|
||||||
|
```bash
|
||||||
|
# 실시간 모니터링
|
||||||
|
watch -n 1 nvidia-smi
|
||||||
|
|
||||||
|
# 특정 정보만
|
||||||
|
nvidia-smi --query-gpu=index,name,memory.used,memory.total,utilization.gpu --format=csv
|
||||||
|
```
|
||||||
|
|
||||||
|
### Docker 내부에서
|
||||||
|
```bash
|
||||||
|
docker exec {컨테이너명} nvidia-smi
|
||||||
|
```
|
||||||
|
|
||||||
|
## 헬스체크
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 30s
|
||||||
|
retries: 3
|
||||||
|
start_period: 120s # 모델 로딩 시간 고려
|
||||||
|
```
|
||||||
|
|
||||||
|
## 주의사항
|
||||||
|
|
||||||
|
- 모델 첫 로딩 시 시간 소요 (수 분)
|
||||||
|
- VRAM 부족 시 OOM 에러 → 양자화 또는 GPU 추가
|
||||||
|
- Hugging Face 게이트 모델은 토큰 및 동의 필요
|
||||||
|
- 멀티 GPU 사용 시 NVLink 유무에 따라 성능 차이
|
||||||
268
.claude/skills/infrastructure-setup.md
Normal file
268
.claude/skills/infrastructure-setup.md
Normal file
@ -0,0 +1,268 @@
|
|||||||
|
# 인프라 구축 가이드 (Infrastructure Setup)
|
||||||
|
|
||||||
|
인프라 설정 패턴입니다.
|
||||||
|
|
||||||
|
## MAAS (Metal as a Service)
|
||||||
|
|
||||||
|
### 개요
|
||||||
|
- 베어메탈 서버 프로비저닝 도구
|
||||||
|
- Ubuntu 기반 자동 배포
|
||||||
|
- 네트워크 자동 설정
|
||||||
|
|
||||||
|
### 기본 설정
|
||||||
|
```yaml
|
||||||
|
# MAAS 설정 예시
|
||||||
|
machines:
|
||||||
|
- hostname: k8s-master-01
|
||||||
|
architecture: amd64
|
||||||
|
cpu_count: 8
|
||||||
|
memory: 32768
|
||||||
|
storage: 500GB
|
||||||
|
tags:
|
||||||
|
- kubernetes
|
||||||
|
- master
|
||||||
|
|
||||||
|
- hostname: k8s-worker-01
|
||||||
|
architecture: amd64
|
||||||
|
cpu_count: 16
|
||||||
|
memory: 65536
|
||||||
|
storage: 1TB
|
||||||
|
tags:
|
||||||
|
- kubernetes
|
||||||
|
- worker
|
||||||
|
```
|
||||||
|
|
||||||
|
### 네트워크 설정
|
||||||
|
```yaml
|
||||||
|
# 서브넷 설정
|
||||||
|
subnets:
|
||||||
|
- cidr: 10.10.0.0/16
|
||||||
|
gateway_ip: 10.10.0.1
|
||||||
|
dns_servers:
|
||||||
|
- 8.8.8.8
|
||||||
|
- 8.8.4.4
|
||||||
|
```
|
||||||
|
|
||||||
|
## Kubernetes 클러스터
|
||||||
|
|
||||||
|
### 클러스터 구성
|
||||||
|
```yaml
|
||||||
|
# 마스터 노드: 3대 (HA 구성)
|
||||||
|
# 워커 노드: 3대 이상
|
||||||
|
# etcd: 마스터 노드에 내장
|
||||||
|
```
|
||||||
|
|
||||||
|
### kubeadm 초기화
|
||||||
|
```bash
|
||||||
|
# 마스터 노드 초기화
|
||||||
|
kubeadm init --pod-network-cidr=10.244.0.0/16 \
|
||||||
|
--control-plane-endpoint="k8s-api.example.com:6443" \
|
||||||
|
--upload-certs
|
||||||
|
|
||||||
|
# 워커 노드 조인
|
||||||
|
kubeadm join k8s-api.example.com:6443 \
|
||||||
|
--token <token> \
|
||||||
|
--discovery-token-ca-cert-hash sha256:<hash>
|
||||||
|
```
|
||||||
|
|
||||||
|
### CNI 설치 (Flannel)
|
||||||
|
```bash
|
||||||
|
kubectl apply -f https://raw.githubusercontent.com/coreos/flannel/master/Documentation/kube-flannel.yml
|
||||||
|
```
|
||||||
|
|
||||||
|
## Rancher 설치
|
||||||
|
|
||||||
|
### Docker 기반 설치
|
||||||
|
```bash
|
||||||
|
docker run -d --restart=unless-stopped \
|
||||||
|
-p 80:80 -p 443:443 \
|
||||||
|
--privileged \
|
||||||
|
-v /opt/rancher:/var/lib/rancher \
|
||||||
|
rancher/rancher:latest
|
||||||
|
```
|
||||||
|
|
||||||
|
### Helm 기반 설치
|
||||||
|
```bash
|
||||||
|
# Rancher Helm 레포 추가
|
||||||
|
helm repo add rancher-latest https://releases.rancher.com/server-charts/latest
|
||||||
|
helm repo update
|
||||||
|
|
||||||
|
# 네임스페이스 생성
|
||||||
|
kubectl create namespace cattle-system
|
||||||
|
|
||||||
|
# cert-manager 설치 (Let's Encrypt 사용 시)
|
||||||
|
kubectl apply -f https://github.com/cert-manager/cert-manager/releases/download/v1.13.0/cert-manager.yaml
|
||||||
|
|
||||||
|
# Rancher 설치
|
||||||
|
helm install rancher rancher-latest/rancher \
|
||||||
|
--namespace cattle-system \
|
||||||
|
--set hostname=rancher.example.com \
|
||||||
|
--set replicas=3 \
|
||||||
|
--set ingress.tls.source=letsEncrypt \
|
||||||
|
--set letsEncrypt.email=admin@example.com
|
||||||
|
```
|
||||||
|
|
||||||
|
## Docker Compose 배포
|
||||||
|
|
||||||
|
### docker-compose.yml 구조
|
||||||
|
```yaml
|
||||||
|
services:
|
||||||
|
# ===================
|
||||||
|
# Infrastructure
|
||||||
|
# ===================
|
||||||
|
mongodb:
|
||||||
|
image: mongo:7.0
|
||||||
|
container_name: {프로젝트}-mongodb
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
MONGO_INITDB_ROOT_USERNAME: ${MONGO_USER:-admin}
|
||||||
|
MONGO_INITDB_ROOT_PASSWORD: ${MONGO_PASSWORD:-password123}
|
||||||
|
ports:
|
||||||
|
- "{호스트포트}:27017"
|
||||||
|
volumes:
|
||||||
|
- {프로젝트}_mongodb_data:/data/db
|
||||||
|
networks:
|
||||||
|
- {프로젝트}-network
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "mongosh", "--eval", "db.adminCommand('ping')"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 3
|
||||||
|
|
||||||
|
redis:
|
||||||
|
image: redis:7-alpine
|
||||||
|
container_name: {프로젝트}-redis
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "{호스트포트}:6379"
|
||||||
|
volumes:
|
||||||
|
- {프로젝트}_redis_data:/data
|
||||||
|
networks:
|
||||||
|
- {프로젝트}-network
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
{프로젝트}_mongodb_data:
|
||||||
|
{프로젝트}_redis_data:
|
||||||
|
|
||||||
|
networks:
|
||||||
|
{프로젝트}-network:
|
||||||
|
driver: bridge
|
||||||
|
```
|
||||||
|
|
||||||
|
### 배포 명령어
|
||||||
|
```bash
|
||||||
|
# 전체 서비스 시작
|
||||||
|
docker-compose up -d
|
||||||
|
|
||||||
|
# 특정 서비스만 재빌드
|
||||||
|
docker-compose up -d --build {서비스명}
|
||||||
|
|
||||||
|
# 로그 확인
|
||||||
|
docker-compose logs -f {서비스명}
|
||||||
|
|
||||||
|
# 서비스 상태 확인
|
||||||
|
docker-compose ps
|
||||||
|
```
|
||||||
|
|
||||||
|
## 환경 변수 관리
|
||||||
|
|
||||||
|
### .env 파일
|
||||||
|
```bash
|
||||||
|
# Infrastructure
|
||||||
|
MONGO_USER=admin
|
||||||
|
MONGO_PASSWORD={비밀번호}
|
||||||
|
REDIS_URL=redis://redis:6379
|
||||||
|
|
||||||
|
# API Keys
|
||||||
|
CLAUDE_API_KEY=sk-ant-...
|
||||||
|
OPENAI_API_KEY=sk-...
|
||||||
|
JWT_SECRET_KEY={시크릿키}
|
||||||
|
|
||||||
|
# Database
|
||||||
|
DB_NAME={데이터베이스명}
|
||||||
|
TARGET_COLLECTION={컬렉션명}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 환경별 설정
|
||||||
|
```bash
|
||||||
|
# 개발 환경
|
||||||
|
docker-compose -f docker-compose.yml -f docker-compose.dev.yml up -d
|
||||||
|
|
||||||
|
# 프로덕션 환경
|
||||||
|
docker-compose -f docker-compose.yml -f docker-compose.prod.yml up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
## 백업 전략
|
||||||
|
|
||||||
|
### MongoDB 백업
|
||||||
|
```bash
|
||||||
|
# 백업
|
||||||
|
BACKUP_NAME="mongodb_backup_$(date +%Y%m%d_%H%M%S)"
|
||||||
|
docker exec {프로젝트}-mongodb mongodump \
|
||||||
|
--uri="mongodb://{user}:{password}@localhost:27017" \
|
||||||
|
--authenticationDatabase=admin \
|
||||||
|
--out="/tmp/$BACKUP_NAME"
|
||||||
|
docker cp {프로젝트}-mongodb:/tmp/$BACKUP_NAME ./backups/
|
||||||
|
|
||||||
|
# 복원
|
||||||
|
docker cp ./backups/$BACKUP_NAME {프로젝트}-mongodb:/tmp/
|
||||||
|
docker exec {프로젝트}-mongodb mongorestore \
|
||||||
|
--uri="mongodb://{user}:{password}@localhost:27017" \
|
||||||
|
--authenticationDatabase=admin \
|
||||||
|
"/tmp/$BACKUP_NAME"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 볼륨 백업
|
||||||
|
```bash
|
||||||
|
# 볼륨 백업
|
||||||
|
docker run --rm \
|
||||||
|
-v {프로젝트}_mongodb_data:/data \
|
||||||
|
-v $(pwd)/backups:/backup \
|
||||||
|
alpine tar czf /backup/mongodb_volume.tar.gz -C /data .
|
||||||
|
```
|
||||||
|
|
||||||
|
## 네트워크 구성
|
||||||
|
|
||||||
|
### 포트 매핑 규칙
|
||||||
|
```yaml
|
||||||
|
# Infrastructure (기본 포트 + 오프셋)
|
||||||
|
MongoDB: {오프셋}+27017:27017
|
||||||
|
Redis: {오프셋}+6379:6379
|
||||||
|
|
||||||
|
# Application Services
|
||||||
|
api-service: 8000:8000
|
||||||
|
admin-frontend: 3000:3000
|
||||||
|
```
|
||||||
|
|
||||||
|
### 내부 통신
|
||||||
|
```yaml
|
||||||
|
# 컨테이너 간 통신은 서비스명 사용
|
||||||
|
mongodb: mongodb://{user}:{password}@mongodb:27017/
|
||||||
|
redis: redis://redis:6379
|
||||||
|
```
|
||||||
|
|
||||||
|
## 모니터링 접근점
|
||||||
|
|
||||||
|
### 헬스체크 엔드포인트
|
||||||
|
```bash
|
||||||
|
# MongoDB
|
||||||
|
docker exec {프로젝트}-mongodb mongosh --eval "db.adminCommand('ping')"
|
||||||
|
|
||||||
|
# Redis
|
||||||
|
docker exec {프로젝트}-redis redis-cli ping
|
||||||
|
|
||||||
|
# FastAPI 서비스
|
||||||
|
curl http://localhost:{포트}/health
|
||||||
|
```
|
||||||
|
|
||||||
|
### 로그 수집
|
||||||
|
```bash
|
||||||
|
# 전체 로그
|
||||||
|
docker-compose logs -f
|
||||||
|
|
||||||
|
# 특정 서비스 로그
|
||||||
|
docker-compose logs -f {서비스명}
|
||||||
|
|
||||||
|
# 최근 100줄만
|
||||||
|
docker-compose logs --tail=100 {서비스명}
|
||||||
|
```
|
||||||
174
.claude/skills/korean-dev-conventions.md
Normal file
174
.claude/skills/korean-dev-conventions.md
Normal file
@ -0,0 +1,174 @@
|
|||||||
|
# 한국어 개발 컨벤션 (Korean Dev Conventions)
|
||||||
|
|
||||||
|
이 프로젝트의 한국어 개발 규칙입니다.
|
||||||
|
|
||||||
|
## 주석 규칙
|
||||||
|
|
||||||
|
### Python Docstring
|
||||||
|
```python
|
||||||
|
async def get_organization_info(self, name: str, context: List[str] = None) -> OrganizationInfo:
|
||||||
|
"""
|
||||||
|
조직/단체 정보 조회 (context 기반 후보 선택)
|
||||||
|
|
||||||
|
Args:
|
||||||
|
name: 조직 이름
|
||||||
|
context: 기사에서 추출한 컨텍스트 키워드 (산업, 유형 등)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
OrganizationInfo with founding_year, wikipedia_url, and image_url if found
|
||||||
|
"""
|
||||||
|
```
|
||||||
|
|
||||||
|
### 섹션 구분 주석
|
||||||
|
```python
|
||||||
|
# ===================
|
||||||
|
# Infrastructure (독립 포트 사용)
|
||||||
|
# ===================
|
||||||
|
```
|
||||||
|
|
||||||
|
### 인라인 주석
|
||||||
|
```python
|
||||||
|
# 캐시에서 조회 (context 기반 매칭)
|
||||||
|
cached_data, needs_refresh = await self.entity_cache.get_person(name, context=context)
|
||||||
|
|
||||||
|
# P154 = logo image
|
||||||
|
"property": "P154",
|
||||||
|
```
|
||||||
|
|
||||||
|
## 로깅 메시지
|
||||||
|
|
||||||
|
### 한글 + 영문 혼용 패턴
|
||||||
|
```python
|
||||||
|
logger.info(f"Found {len(image_urls)} image(s) for '{name}' (logo preferred)")
|
||||||
|
logger.info(f"Article {news_id} enriched with Wikipedia data in {processing_time:.2f}s")
|
||||||
|
logger.warning(f"Biocode registration failed (non-critical): {e}")
|
||||||
|
```
|
||||||
|
|
||||||
|
### 워커 로그 패턴
|
||||||
|
```python
|
||||||
|
logger.info("Starting Wikipedia Enrichment Worker")
|
||||||
|
logger.info(f"Processing job {job.job_id} for Wikipedia enrichment")
|
||||||
|
logger.info(f"Job {job.job_id} forwarded to image_generation")
|
||||||
|
```
|
||||||
|
|
||||||
|
## 에러 처리
|
||||||
|
|
||||||
|
### Try-Except 패턴
|
||||||
|
```python
|
||||||
|
try:
|
||||||
|
info = await self.wikipedia_service.get_person_info(name, context=context)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error getting person info for '{name}': {e}")
|
||||||
|
```
|
||||||
|
|
||||||
|
### 비치명적 에러 처리
|
||||||
|
```python
|
||||||
|
try:
|
||||||
|
stats = await self.biocode_client.register_entities(people, organizations)
|
||||||
|
except Exception as e:
|
||||||
|
# Biocode 등록 실패는 전체 파이프라인을 중단시키지 않음
|
||||||
|
logger.warning(f"Biocode registration failed (non-critical): {e}")
|
||||||
|
```
|
||||||
|
|
||||||
|
## 변수/함수 네이밍
|
||||||
|
|
||||||
|
### Python (snake_case)
|
||||||
|
```python
|
||||||
|
# 변수
|
||||||
|
birth_date = "1990-01-15"
|
||||||
|
founding_year = 2004
|
||||||
|
image_urls = []
|
||||||
|
existing_names = set()
|
||||||
|
|
||||||
|
# 함수
|
||||||
|
def get_existing_names(biocode_data: dict) -> set:
|
||||||
|
async def _enrich_organizations(self, entities: List[Dict[str, Any]]):
|
||||||
|
```
|
||||||
|
|
||||||
|
### TypeScript (camelCase)
|
||||||
|
```typescript
|
||||||
|
// 변수
|
||||||
|
const articleCount = 100;
|
||||||
|
const isLoading = false;
|
||||||
|
|
||||||
|
// 함수
|
||||||
|
function getArticleById(id: string): Article
|
||||||
|
async function fetchDashboardStats(): Promise<Stats>
|
||||||
|
```
|
||||||
|
|
||||||
|
## 상수 정의
|
||||||
|
|
||||||
|
```python
|
||||||
|
# 영문 상수명 + 한글 주석
|
||||||
|
API_URL = "https://en.wikipedia.org/api/rest_v1/page/summary/{title}"
|
||||||
|
SEARCH_URL = "https://en.wikipedia.org/w/api.php"
|
||||||
|
|
||||||
|
# 기본값
|
||||||
|
DEFAULT_TIMEOUT = 10 # seconds
|
||||||
|
MAX_RETRIES = 3
|
||||||
|
```
|
||||||
|
|
||||||
|
## 타입 힌트
|
||||||
|
|
||||||
|
```python
|
||||||
|
from typing import Optional, Dict, Any, List
|
||||||
|
|
||||||
|
async def enrich_entities(
|
||||||
|
self,
|
||||||
|
people: List[Dict[str, Any]],
|
||||||
|
organizations: List[Dict[str, Any]]
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""엔티티 목록을 Wikipedia 정보로 보강 (context 지원)"""
|
||||||
|
```
|
||||||
|
|
||||||
|
## 커밋 메시지
|
||||||
|
|
||||||
|
### 형식
|
||||||
|
```
|
||||||
|
<type>: <description>
|
||||||
|
|
||||||
|
- <detail 1>
|
||||||
|
- <detail 2>
|
||||||
|
|
||||||
|
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 타입
|
||||||
|
- `feat`: 새로운 기능
|
||||||
|
- `fix`: 버그 수정
|
||||||
|
- `chore`: 설정, 문서 등 잡다한 작업
|
||||||
|
- `refactor`: 리팩토링
|
||||||
|
- `docs`: 문서 수정
|
||||||
|
|
||||||
|
### 예시
|
||||||
|
```
|
||||||
|
feat: Pass entity_type to biocode API
|
||||||
|
|
||||||
|
- biocode_worker: Forward entity_type (person/organization) to API
|
||||||
|
- Enables proper storage in famousPeople or famousOrganizations
|
||||||
|
|
||||||
|
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
|
||||||
|
```
|
||||||
|
|
||||||
|
## 파일 인코딩
|
||||||
|
|
||||||
|
- 모든 파일: UTF-8
|
||||||
|
- JSON 파일: `ensure_ascii=False` 사용
|
||||||
|
|
||||||
|
```python
|
||||||
|
with open(file_path, 'w', encoding='utf-8') as f:
|
||||||
|
json.dump(content, f, ensure_ascii=False, indent=2)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 다이어그램
|
||||||
|
|
||||||
|
- **도구**: Mermaid 사용 (ASCII art 금지)
|
||||||
|
- **용도**: 아키텍처, 플로우차트, 시퀀스 다이어그램, ERD 등
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
```mermaid
|
||||||
|
flowchart LR
|
||||||
|
A[입력] --> B{처리}
|
||||||
|
B --> C[출력]
|
||||||
|
```
|
||||||
|
```
|
||||||
361
.claude/skills/monitoring-logging.md
Normal file
361
.claude/skills/monitoring-logging.md
Normal file
@ -0,0 +1,361 @@
|
|||||||
|
# 모니터링 및 로깅 (Monitoring & Logging)
|
||||||
|
|
||||||
|
이 프로젝트의 모니터링 및 로깅 패턴입니다.
|
||||||
|
|
||||||
|
## Python 로깅
|
||||||
|
|
||||||
|
### 기본 설정
|
||||||
|
```python
|
||||||
|
import logging
|
||||||
|
|
||||||
|
logging.basicConfig(level=logging.INFO)
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 로깅 패턴
|
||||||
|
```python
|
||||||
|
# 정보성 로그
|
||||||
|
logger.info(f"Starting Wikipedia Enrichment Worker")
|
||||||
|
logger.info(f"Processing job {job.job_id} for Wikipedia enrichment")
|
||||||
|
logger.info(f"Found {len(image_urls)} image(s) for '{name}' (logo preferred)")
|
||||||
|
|
||||||
|
# 경고 로그 (비치명적 오류)
|
||||||
|
logger.warning(f"Biocode registration failed (non-critical): {e}")
|
||||||
|
logger.warning(f"Failed to get logo for '{title}': {e}")
|
||||||
|
|
||||||
|
# 에러 로그
|
||||||
|
logger.error(f"Error processing job {job.job_id}: {e}")
|
||||||
|
logger.error(f"Claude API key not configured")
|
||||||
|
|
||||||
|
# 디버그 로그
|
||||||
|
logger.debug(f"Selected candidate '{candidate.get('title')}' with score: {best_score}")
|
||||||
|
```
|
||||||
|
|
||||||
|
### 구조화된 로깅
|
||||||
|
```python
|
||||||
|
import json
|
||||||
|
|
||||||
|
def log_structured(level: str, message: str, **kwargs):
|
||||||
|
"""구조화된 JSON 로깅"""
|
||||||
|
log_entry = {
|
||||||
|
"timestamp": datetime.now().isoformat(),
|
||||||
|
"level": level,
|
||||||
|
"message": message,
|
||||||
|
**kwargs
|
||||||
|
}
|
||||||
|
print(json.dumps(log_entry))
|
||||||
|
|
||||||
|
# 사용 예시
|
||||||
|
log_structured("INFO", "Article processed",
|
||||||
|
job_id=job.job_id,
|
||||||
|
processing_time=processing_time,
|
||||||
|
people_count=len(enriched_people),
|
||||||
|
orgs_count=len(enriched_orgs)
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Docker 로그
|
||||||
|
|
||||||
|
### 로그 확인
|
||||||
|
```bash
|
||||||
|
# 전체 로그
|
||||||
|
docker-compose logs -f
|
||||||
|
|
||||||
|
# 특정 서비스 로그
|
||||||
|
docker-compose logs -f news-wikipedia-enrichment
|
||||||
|
|
||||||
|
# 최근 100줄만
|
||||||
|
docker-compose logs --tail=100 news-article-generator
|
||||||
|
|
||||||
|
# 시간 범위 지정
|
||||||
|
docker-compose logs --since 2024-01-15T10:00:00 news-wikipedia-enrichment
|
||||||
|
```
|
||||||
|
|
||||||
|
### 로그 드라이버 설정
|
||||||
|
```yaml
|
||||||
|
# docker-compose.yml
|
||||||
|
services:
|
||||||
|
news-wikipedia-enrichment:
|
||||||
|
logging:
|
||||||
|
driver: "json-file"
|
||||||
|
options:
|
||||||
|
max-size: "10m"
|
||||||
|
max-file: "3"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Prometheus 설정
|
||||||
|
|
||||||
|
### docker-compose.yml
|
||||||
|
```yaml
|
||||||
|
services:
|
||||||
|
prometheus:
|
||||||
|
image: prom/prometheus:latest
|
||||||
|
container_name: {프로젝트}-prometheus
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "9090:9090"
|
||||||
|
volumes:
|
||||||
|
- ./prometheus/prometheus.yml:/etc/prometheus/prometheus.yml
|
||||||
|
- {프로젝트}_prometheus_data:/prometheus
|
||||||
|
networks:
|
||||||
|
- {프로젝트}-network
|
||||||
|
command:
|
||||||
|
- '--config.file=/etc/prometheus/prometheus.yml'
|
||||||
|
- '--storage.tsdb.path=/prometheus'
|
||||||
|
- '--web.enable-lifecycle'
|
||||||
|
```
|
||||||
|
|
||||||
|
### prometheus.yml
|
||||||
|
```yaml
|
||||||
|
global:
|
||||||
|
scrape_interval: 15s
|
||||||
|
evaluation_interval: 15s
|
||||||
|
|
||||||
|
scrape_configs:
|
||||||
|
- job_name: 'prometheus'
|
||||||
|
static_configs:
|
||||||
|
- targets: ['localhost:9090']
|
||||||
|
|
||||||
|
- job_name: 'fastapi-services'
|
||||||
|
static_configs:
|
||||||
|
- targets:
|
||||||
|
- 'base-auth:8000'
|
||||||
|
- 'base-image:8000'
|
||||||
|
- 'news-user-service:8000'
|
||||||
|
metrics_path: '/metrics'
|
||||||
|
|
||||||
|
- job_name: 'redis'
|
||||||
|
static_configs:
|
||||||
|
- targets: ['redis-exporter:9121']
|
||||||
|
|
||||||
|
- job_name: 'mongodb'
|
||||||
|
static_configs:
|
||||||
|
- targets: ['mongodb-exporter:9216']
|
||||||
|
```
|
||||||
|
|
||||||
|
### FastAPI 메트릭 노출
|
||||||
|
```python
|
||||||
|
from prometheus_client import Counter, Histogram, generate_latest
|
||||||
|
from fastapi import Response
|
||||||
|
|
||||||
|
# 메트릭 정의
|
||||||
|
REQUEST_COUNT = Counter(
|
||||||
|
'http_requests_total',
|
||||||
|
'Total HTTP requests',
|
||||||
|
['method', 'endpoint', 'status']
|
||||||
|
)
|
||||||
|
|
||||||
|
REQUEST_LATENCY = Histogram(
|
||||||
|
'http_request_duration_seconds',
|
||||||
|
'HTTP request latency',
|
||||||
|
['method', 'endpoint']
|
||||||
|
)
|
||||||
|
|
||||||
|
@app.get("/metrics")
|
||||||
|
async def metrics():
|
||||||
|
return Response(
|
||||||
|
content=generate_latest(),
|
||||||
|
media_type="text/plain"
|
||||||
|
)
|
||||||
|
|
||||||
|
@app.middleware("http")
|
||||||
|
async def track_metrics(request: Request, call_next):
|
||||||
|
start_time = time.time()
|
||||||
|
response = await call_next(request)
|
||||||
|
duration = time.time() - start_time
|
||||||
|
|
||||||
|
REQUEST_COUNT.labels(
|
||||||
|
method=request.method,
|
||||||
|
endpoint=request.url.path,
|
||||||
|
status=response.status_code
|
||||||
|
).inc()
|
||||||
|
|
||||||
|
REQUEST_LATENCY.labels(
|
||||||
|
method=request.method,
|
||||||
|
endpoint=request.url.path
|
||||||
|
).observe(duration)
|
||||||
|
|
||||||
|
return response
|
||||||
|
```
|
||||||
|
|
||||||
|
## Grafana 설정
|
||||||
|
|
||||||
|
### docker-compose.yml
|
||||||
|
```yaml
|
||||||
|
services:
|
||||||
|
grafana:
|
||||||
|
image: grafana/grafana:latest
|
||||||
|
container_name: {프로젝트}-grafana
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "3000:3000"
|
||||||
|
volumes:
|
||||||
|
- {프로젝트}_grafana_data:/var/lib/grafana
|
||||||
|
- ./grafana/provisioning:/etc/grafana/provisioning
|
||||||
|
environment:
|
||||||
|
- GF_SECURITY_ADMIN_USER=admin
|
||||||
|
- GF_SECURITY_ADMIN_PASSWORD=admin123
|
||||||
|
- GF_USERS_ALLOW_SIGN_UP=false
|
||||||
|
networks:
|
||||||
|
- {프로젝트}-network
|
||||||
|
```
|
||||||
|
|
||||||
|
### 데이터소스 프로비저닝
|
||||||
|
```yaml
|
||||||
|
# grafana/provisioning/datasources/datasources.yml
|
||||||
|
apiVersion: 1
|
||||||
|
|
||||||
|
datasources:
|
||||||
|
- name: Prometheus
|
||||||
|
type: prometheus
|
||||||
|
access: proxy
|
||||||
|
url: http://prometheus:9090
|
||||||
|
isDefault: true
|
||||||
|
editable: false
|
||||||
|
```
|
||||||
|
|
||||||
|
### 대시보드 예시 (JSON)
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"dashboard": {
|
||||||
|
"title": "News Pipeline Monitoring",
|
||||||
|
"panels": [
|
||||||
|
{
|
||||||
|
"title": "Request Rate",
|
||||||
|
"type": "graph",
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"expr": "rate(http_requests_total[5m])",
|
||||||
|
"legendFormat": "{{method}} {{endpoint}}"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Request Latency (p95)",
|
||||||
|
"type": "graph",
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"expr": "histogram_quantile(0.95, rate(http_request_duration_seconds_bucket[5m]))",
|
||||||
|
"legendFormat": "{{endpoint}}"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 헬스체크
|
||||||
|
|
||||||
|
### FastAPI 헬스체크 엔드포인트
|
||||||
|
```python
|
||||||
|
@app.get("/health")
|
||||||
|
async def health_check():
|
||||||
|
"""헬스체크 엔드포인트"""
|
||||||
|
checks = {
|
||||||
|
"status": "healthy",
|
||||||
|
"timestamp": datetime.now().isoformat(),
|
||||||
|
"checks": {}
|
||||||
|
}
|
||||||
|
|
||||||
|
# MongoDB 체크
|
||||||
|
try:
|
||||||
|
await db.command("ping")
|
||||||
|
checks["checks"]["mongodb"] = "healthy"
|
||||||
|
except Exception as e:
|
||||||
|
checks["checks"]["mongodb"] = f"unhealthy: {e}"
|
||||||
|
checks["status"] = "unhealthy"
|
||||||
|
|
||||||
|
# Redis 체크
|
||||||
|
try:
|
||||||
|
await redis.ping()
|
||||||
|
checks["checks"]["redis"] = "healthy"
|
||||||
|
except Exception as e:
|
||||||
|
checks["checks"]["redis"] = f"unhealthy: {e}"
|
||||||
|
checks["status"] = "unhealthy"
|
||||||
|
|
||||||
|
status_code = 200 if checks["status"] == "healthy" else 503
|
||||||
|
return JSONResponse(content=checks, status_code=status_code)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Docker 헬스체크
|
||||||
|
```yaml
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 3
|
||||||
|
start_period: 40s
|
||||||
|
```
|
||||||
|
|
||||||
|
## 워커 하트비트
|
||||||
|
|
||||||
|
### Redis 기반 하트비트
|
||||||
|
```python
|
||||||
|
class QueueManager:
|
||||||
|
async def start_heartbeat(self, worker_name: str):
|
||||||
|
"""워커 하트비트 시작"""
|
||||||
|
async def heartbeat_loop():
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
await self.redis.setex(
|
||||||
|
f"worker:heartbeat:{worker_name}",
|
||||||
|
60, # 60초 TTL
|
||||||
|
datetime.now().isoformat()
|
||||||
|
)
|
||||||
|
await asyncio.sleep(30) # 30초마다 갱신
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Heartbeat error: {e}")
|
||||||
|
|
||||||
|
asyncio.create_task(heartbeat_loop())
|
||||||
|
|
||||||
|
async def get_active_workers(self) -> List[str]:
|
||||||
|
"""활성 워커 목록 조회"""
|
||||||
|
keys = await self.redis.keys("worker:heartbeat:*")
|
||||||
|
return [key.decode().split(":")[-1] for key in keys]
|
||||||
|
```
|
||||||
|
|
||||||
|
## 알림 설정 (Alertmanager)
|
||||||
|
|
||||||
|
### alertmanager.yml
|
||||||
|
```yaml
|
||||||
|
global:
|
||||||
|
slack_api_url: 'https://hooks.slack.com/services/xxx'
|
||||||
|
|
||||||
|
route:
|
||||||
|
receiver: 'slack-notifications'
|
||||||
|
group_wait: 30s
|
||||||
|
group_interval: 5m
|
||||||
|
repeat_interval: 4h
|
||||||
|
|
||||||
|
receivers:
|
||||||
|
- name: 'slack-notifications'
|
||||||
|
slack_configs:
|
||||||
|
- channel: '#alerts'
|
||||||
|
send_resolved: true
|
||||||
|
title: '{{ .GroupLabels.alertname }}'
|
||||||
|
text: '{{ range .Alerts }}{{ .Annotations.description }}{{ end }}'
|
||||||
|
```
|
||||||
|
|
||||||
|
### 알림 규칙
|
||||||
|
```yaml
|
||||||
|
# prometheus/rules/alerts.yml
|
||||||
|
groups:
|
||||||
|
- name: service-alerts
|
||||||
|
rules:
|
||||||
|
- alert: HighErrorRate
|
||||||
|
expr: rate(http_requests_total{status=~"5.."}[5m]) > 0.1
|
||||||
|
for: 5m
|
||||||
|
labels:
|
||||||
|
severity: critical
|
||||||
|
annotations:
|
||||||
|
description: "High error rate detected"
|
||||||
|
|
||||||
|
- alert: WorkerDown
|
||||||
|
expr: absent(up{job="fastapi-services"})
|
||||||
|
for: 1m
|
||||||
|
labels:
|
||||||
|
severity: warning
|
||||||
|
annotations:
|
||||||
|
description: "Worker service is down"
|
||||||
|
```
|
||||||
117
.claude/skills/project-stack.md
Normal file
117
.claude/skills/project-stack.md
Normal file
@ -0,0 +1,117 @@
|
|||||||
|
# 프로젝트 기술 스택 (Project Stack)
|
||||||
|
|
||||||
|
이 프로젝트의 기본 기술 스택과 구조입니다.
|
||||||
|
|
||||||
|
## Frontend (news-engine-admin)
|
||||||
|
|
||||||
|
### 기술 스택
|
||||||
|
- **Framework**: Next.js 16.x (App Router)
|
||||||
|
- **Language**: TypeScript 5.x
|
||||||
|
- **React**: 19.x
|
||||||
|
- **Styling**: Tailwind CSS 4.x
|
||||||
|
- **UI Components**: Radix UI + shadcn/ui
|
||||||
|
- **Form**: react-hook-form + zod
|
||||||
|
- **Icons**: lucide-react
|
||||||
|
- **Theme**: next-themes (다크모드 지원)
|
||||||
|
|
||||||
|
### 디렉토리 구조
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
├── app/ # Next.js App Router 페이지
|
||||||
|
│ ├── layout.tsx # 루트 레이아웃
|
||||||
|
│ ├── page.tsx # 메인 페이지
|
||||||
|
│ └── dashboard/ # 대시보드 페이지들
|
||||||
|
├── components/ # React 컴포넌트
|
||||||
|
│ ├── ui/ # shadcn/ui 기본 컴포넌트
|
||||||
|
│ ├── dashboard/ # 대시보드 전용 컴포넌트
|
||||||
|
│ └── providers/ # Context Provider
|
||||||
|
├── hooks/ # 커스텀 훅
|
||||||
|
├── lib/ # 유틸리티 함수
|
||||||
|
│ └── utils.ts # cn() 등 공통 유틸
|
||||||
|
└── types/ # TypeScript 타입 정의
|
||||||
|
```
|
||||||
|
|
||||||
|
### 설치 명령어
|
||||||
|
```bash
|
||||||
|
# 프로젝트 생성
|
||||||
|
npx create-next-app@latest --typescript --tailwind --app
|
||||||
|
|
||||||
|
# shadcn/ui 초기화
|
||||||
|
npx shadcn@latest init
|
||||||
|
|
||||||
|
# 컴포넌트 추가
|
||||||
|
npx shadcn@latest add button card dialog tabs
|
||||||
|
```
|
||||||
|
|
||||||
|
## Backend (FastAPI 마이크로서비스)
|
||||||
|
|
||||||
|
### 기술 스택
|
||||||
|
- **Framework**: FastAPI
|
||||||
|
- **Language**: Python 3.11
|
||||||
|
- **Database**: MongoDB (motor - async driver)
|
||||||
|
- **Queue**: Redis (aioredis)
|
||||||
|
- **Validation**: Pydantic v2
|
||||||
|
- **HTTP Client**: aiohttp
|
||||||
|
|
||||||
|
### 마이크로서비스 구조
|
||||||
|
```
|
||||||
|
service/
|
||||||
|
├── Dockerfile
|
||||||
|
├── requirements.txt
|
||||||
|
├── worker.py # 메인 워커 (큐 처리)
|
||||||
|
├── *_service.py # 비즈니스 로직
|
||||||
|
└── *_client.py # 외부 서비스 클라이언트
|
||||||
|
```
|
||||||
|
|
||||||
|
### API 서비스 구조
|
||||||
|
```
|
||||||
|
service/
|
||||||
|
├── Dockerfile
|
||||||
|
├── requirements.txt
|
||||||
|
└── app/
|
||||||
|
├── __init__.py
|
||||||
|
├── main.py # FastAPI 앱 엔트리포인트
|
||||||
|
├── database.py # DB 연결 관리
|
||||||
|
├── models/ # Pydantic 모델
|
||||||
|
└── routers/ # API 라우터
|
||||||
|
```
|
||||||
|
|
||||||
|
## 공통 라이브러리 (news-commons)
|
||||||
|
|
||||||
|
### 제공 기능
|
||||||
|
- `QueueManager`: Redis 큐 관리 (enqueue, dequeue, heartbeat)
|
||||||
|
- `PipelineJob`: 파이프라인 작업 데이터 모델
|
||||||
|
- `PersonEntity`, `OrganizationEntity`: 엔티티 모델
|
||||||
|
- 로깅, 설정 유틸리티
|
||||||
|
|
||||||
|
### 사용 예시
|
||||||
|
```python
|
||||||
|
from news_commons import PipelineJob, QueueManager
|
||||||
|
|
||||||
|
queue_manager = QueueManager(redis_url="redis://redis:6379")
|
||||||
|
await queue_manager.connect()
|
||||||
|
|
||||||
|
job = await queue_manager.dequeue('wikipedia_enrichment', timeout=5)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 인프라
|
||||||
|
|
||||||
|
### 컨테이너
|
||||||
|
- **MongoDB**: 7.0
|
||||||
|
- **Redis**: 7-alpine
|
||||||
|
- **Docker Compose**: 서비스 오케스트레이션
|
||||||
|
|
||||||
|
### 외부 서비스
|
||||||
|
- **Gitea**: 코드 저장소 (http://gitea.yakenator.io/)
|
||||||
|
- **OpenAI API**: GPT 모델 사용
|
||||||
|
- **Claude API**: Claude 모델 사용
|
||||||
|
|
||||||
|
## 참고 리포지토리
|
||||||
|
|
||||||
|
| 서비스 | 설명 | URL |
|
||||||
|
|--------|------|-----|
|
||||||
|
| news-commons | 공통 라이브러리 | gitea.yakenator.io/sapiens/news-commons |
|
||||||
|
| news-article-generator | 기사 생성 | gitea.yakenator.io/sapiens/news-article-generator |
|
||||||
|
| news-wikipedia-enrichment | 위키피디아 보강 | gitea.yakenator.io/sapiens/news-wikipedia-enrichment |
|
||||||
|
| news-image-generator | 이미지 생성 | gitea.yakenator.io/sapiens/news-image-generator |
|
||||||
|
| mcp_biocode | 바이오코드 API | gitea.yakenator.io/sapiens/mcp_biocode |
|
||||||
360
.claude/skills/testing-standards.md
Normal file
360
.claude/skills/testing-standards.md
Normal file
@ -0,0 +1,360 @@
|
|||||||
|
# 테스트 작성 표준 (Testing Standards)
|
||||||
|
|
||||||
|
이 프로젝트의 테스트 작성 패턴입니다.
|
||||||
|
|
||||||
|
## Python (pytest)
|
||||||
|
|
||||||
|
### 설치
|
||||||
|
```bash
|
||||||
|
pip install pytest pytest-asyncio pytest-cov
|
||||||
|
```
|
||||||
|
|
||||||
|
### 디렉토리 구조
|
||||||
|
```
|
||||||
|
service/
|
||||||
|
├── worker.py
|
||||||
|
├── service.py
|
||||||
|
├── tests/
|
||||||
|
│ ├── __init__.py
|
||||||
|
│ ├── conftest.py # 공통 fixture
|
||||||
|
│ ├── test_worker.py
|
||||||
|
│ └── test_service.py
|
||||||
|
└── pytest.ini
|
||||||
|
```
|
||||||
|
|
||||||
|
### pytest.ini 설정
|
||||||
|
```ini
|
||||||
|
[pytest]
|
||||||
|
asyncio_mode = auto
|
||||||
|
testpaths = tests
|
||||||
|
python_files = test_*.py
|
||||||
|
python_functions = test_*
|
||||||
|
```
|
||||||
|
|
||||||
|
### Fixture 패턴 (conftest.py)
|
||||||
|
```python
|
||||||
|
import pytest
|
||||||
|
import asyncio
|
||||||
|
from motor.motor_asyncio import AsyncIOMotorClient
|
||||||
|
|
||||||
|
@pytest.fixture(scope="session")
|
||||||
|
def event_loop():
|
||||||
|
"""세션 범위의 이벤트 루프"""
|
||||||
|
loop = asyncio.get_event_loop_policy().new_event_loop()
|
||||||
|
yield loop
|
||||||
|
loop.close()
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
async def mongodb():
|
||||||
|
"""테스트용 MongoDB 연결"""
|
||||||
|
client = AsyncIOMotorClient("mongodb://localhost:27017")
|
||||||
|
db = client["test_db"]
|
||||||
|
yield db
|
||||||
|
# 테스트 후 정리
|
||||||
|
await client.drop_database("test_db")
|
||||||
|
client.close()
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def sample_article():
|
||||||
|
"""테스트용 기사 데이터"""
|
||||||
|
return {
|
||||||
|
"title": "Test Article",
|
||||||
|
"summary": "Test summary",
|
||||||
|
"entities": {
|
||||||
|
"people": [{"name": "Elon Musk", "context": ["Tesla", "CEO"]}],
|
||||||
|
"organizations": [{"name": "Tesla", "context": ["EV", "automotive"]}]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 단위 테스트 예시
|
||||||
|
```python
|
||||||
|
import pytest
|
||||||
|
from service import calculate_biocode
|
||||||
|
|
||||||
|
def test_calculate_biocode_basic():
|
||||||
|
"""바이오코드 계산 기본 테스트"""
|
||||||
|
result = calculate_biocode(1990, 5, 15)
|
||||||
|
assert result is not None
|
||||||
|
assert len(result) == 4 # g코드 2자리 + s코드 2자리
|
||||||
|
|
||||||
|
def test_calculate_biocode_edge_cases():
|
||||||
|
"""경계값 테스트"""
|
||||||
|
# 연초
|
||||||
|
result = calculate_biocode(1990, 1, 1)
|
||||||
|
assert result.endswith("60") # 대설
|
||||||
|
|
||||||
|
# 연말
|
||||||
|
result = calculate_biocode(1990, 12, 31)
|
||||||
|
assert result is not None
|
||||||
|
```
|
||||||
|
|
||||||
|
### 비동기 테스트 예시
|
||||||
|
```python
|
||||||
|
import pytest
|
||||||
|
from wikipedia_service import WikipediaService
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_person_info():
|
||||||
|
"""인물 정보 조회 테스트"""
|
||||||
|
service = WikipediaService()
|
||||||
|
|
||||||
|
try:
|
||||||
|
info = await service.get_person_info(
|
||||||
|
"Elon Musk",
|
||||||
|
context=["Tesla", "SpaceX"]
|
||||||
|
)
|
||||||
|
|
||||||
|
assert info is not None
|
||||||
|
assert info.name == "Elon Musk"
|
||||||
|
assert info.wikipedia_url is not None
|
||||||
|
finally:
|
||||||
|
await service.close()
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_organization_info_with_logo():
|
||||||
|
"""조직 로고 우선 조회 테스트"""
|
||||||
|
service = WikipediaService()
|
||||||
|
|
||||||
|
try:
|
||||||
|
info = await service.get_organization_info(
|
||||||
|
"Apple Inc.",
|
||||||
|
context=["technology", "iPhone"]
|
||||||
|
)
|
||||||
|
|
||||||
|
assert info is not None
|
||||||
|
assert info.image_urls # 로고 이미지가 있어야 함
|
||||||
|
finally:
|
||||||
|
await service.close()
|
||||||
|
```
|
||||||
|
|
||||||
|
### Mock 사용 예시
|
||||||
|
```python
|
||||||
|
from unittest.mock import AsyncMock, patch
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_worker_process_job():
|
||||||
|
"""워커 작업 처리 테스트 (외부 API 모킹)"""
|
||||||
|
with patch('worker.WikipediaService') as mock_service:
|
||||||
|
mock_instance = AsyncMock()
|
||||||
|
mock_instance.get_person_info.return_value = PersonInfo(
|
||||||
|
name="Test Person",
|
||||||
|
birth_date="1990-01-15",
|
||||||
|
verified=True
|
||||||
|
)
|
||||||
|
mock_service.return_value = mock_instance
|
||||||
|
|
||||||
|
worker = WikipediaEnrichmentWorker()
|
||||||
|
# ... 테스트 수행
|
||||||
|
```
|
||||||
|
|
||||||
|
### 테스트 실행
|
||||||
|
```bash
|
||||||
|
# 전체 테스트
|
||||||
|
pytest
|
||||||
|
|
||||||
|
# 커버리지 포함
|
||||||
|
pytest --cov=. --cov-report=html
|
||||||
|
|
||||||
|
# 특정 테스트만
|
||||||
|
pytest tests/test_service.py -v
|
||||||
|
|
||||||
|
# 특정 함수만
|
||||||
|
pytest tests/test_service.py::test_calculate_biocode_basic -v
|
||||||
|
```
|
||||||
|
|
||||||
|
## JavaScript/TypeScript (Jest)
|
||||||
|
|
||||||
|
### 설치
|
||||||
|
```bash
|
||||||
|
npm install --save-dev jest @types/jest ts-jest
|
||||||
|
```
|
||||||
|
|
||||||
|
### jest.config.js
|
||||||
|
```javascript
|
||||||
|
module.exports = {
|
||||||
|
preset: 'ts-jest',
|
||||||
|
testEnvironment: 'node',
|
||||||
|
roots: ['<rootDir>/src'],
|
||||||
|
testMatch: ['**/*.test.ts', '**/*.spec.ts'],
|
||||||
|
collectCoverageFrom: [
|
||||||
|
'src/**/*.{ts,tsx}',
|
||||||
|
'!src/**/*.d.ts',
|
||||||
|
],
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 단위 테스트 예시
|
||||||
|
```typescript
|
||||||
|
// utils.test.ts
|
||||||
|
import { cn, formatDate } from './utils'
|
||||||
|
|
||||||
|
describe('cn utility', () => {
|
||||||
|
it('should merge class names', () => {
|
||||||
|
const result = cn('foo', 'bar')
|
||||||
|
expect(result).toBe('foo bar')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle conditional classes', () => {
|
||||||
|
const result = cn('foo', false && 'bar', 'baz')
|
||||||
|
expect(result).toBe('foo baz')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('formatDate', () => {
|
||||||
|
it('should format date correctly', () => {
|
||||||
|
const date = new Date('2024-01-15')
|
||||||
|
expect(formatDate(date)).toBe('2024-01-15')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
### React 컴포넌트 테스트
|
||||||
|
```typescript
|
||||||
|
// Button.test.tsx
|
||||||
|
import { render, screen, fireEvent } from '@testing-library/react'
|
||||||
|
import { Button } from './Button'
|
||||||
|
|
||||||
|
describe('Button', () => {
|
||||||
|
it('renders correctly', () => {
|
||||||
|
render(<Button>Click me</Button>)
|
||||||
|
expect(screen.getByText('Click me')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('calls onClick handler', () => {
|
||||||
|
const handleClick = jest.fn()
|
||||||
|
render(<Button onClick={handleClick}>Click me</Button>)
|
||||||
|
fireEvent.click(screen.getByText('Click me'))
|
||||||
|
expect(handleClick).toHaveBeenCalledTimes(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('applies variant styles', () => {
|
||||||
|
render(<Button variant="destructive">Delete</Button>)
|
||||||
|
const button = screen.getByText('Delete')
|
||||||
|
expect(button).toHaveClass('bg-destructive')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
### API 테스트 (MSW Mock)
|
||||||
|
```typescript
|
||||||
|
// api.test.ts
|
||||||
|
import { rest } from 'msw'
|
||||||
|
import { setupServer } from 'msw/node'
|
||||||
|
import { fetchArticles } from './api'
|
||||||
|
|
||||||
|
const server = setupServer(
|
||||||
|
rest.get('/api/articles', (req, res, ctx) => {
|
||||||
|
return res(
|
||||||
|
ctx.json({
|
||||||
|
items: [{ id: '1', title: 'Test Article' }],
|
||||||
|
total: 1
|
||||||
|
})
|
||||||
|
)
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
beforeAll(() => server.listen())
|
||||||
|
afterEach(() => server.resetHandlers())
|
||||||
|
afterAll(() => server.close())
|
||||||
|
|
||||||
|
describe('fetchArticles', () => {
|
||||||
|
it('fetches articles successfully', async () => {
|
||||||
|
const articles = await fetchArticles()
|
||||||
|
expect(articles.items).toHaveLength(1)
|
||||||
|
expect(articles.items[0].title).toBe('Test Article')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
### 테스트 실행
|
||||||
|
```bash
|
||||||
|
# 전체 테스트
|
||||||
|
npm test
|
||||||
|
|
||||||
|
# 감시 모드
|
||||||
|
npm test -- --watch
|
||||||
|
|
||||||
|
# 커버리지
|
||||||
|
npm test -- --coverage
|
||||||
|
|
||||||
|
# 특정 파일만
|
||||||
|
npm test -- Button.test.tsx
|
||||||
|
```
|
||||||
|
|
||||||
|
## E2E 테스트 (Playwright)
|
||||||
|
|
||||||
|
### 설치
|
||||||
|
```bash
|
||||||
|
npm install --save-dev @playwright/test
|
||||||
|
npx playwright install
|
||||||
|
```
|
||||||
|
|
||||||
|
### playwright.config.ts
|
||||||
|
```typescript
|
||||||
|
import { defineConfig } from '@playwright/test'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
testDir: './e2e',
|
||||||
|
fullyParallel: true,
|
||||||
|
forbidOnly: !!process.env.CI,
|
||||||
|
retries: process.env.CI ? 2 : 0,
|
||||||
|
use: {
|
||||||
|
baseURL: 'http://localhost:3000',
|
||||||
|
trace: 'on-first-retry',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
### E2E 테스트 예시
|
||||||
|
```typescript
|
||||||
|
// e2e/dashboard.spec.ts
|
||||||
|
import { test, expect } from '@playwright/test'
|
||||||
|
|
||||||
|
test.describe('Dashboard', () => {
|
||||||
|
test('should display article list', async ({ page }) => {
|
||||||
|
await page.goto('/dashboard')
|
||||||
|
await expect(page.getByRole('heading', { name: 'Articles' })).toBeVisible()
|
||||||
|
await expect(page.getByRole('table')).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('should filter articles by keyword', async ({ page }) => {
|
||||||
|
await page.goto('/dashboard')
|
||||||
|
await page.fill('[placeholder="Search..."]', 'technology')
|
||||||
|
await page.click('button:has-text("Search")')
|
||||||
|
await expect(page.locator('table tbody tr')).toHaveCount(5)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
## CI/CD 통합
|
||||||
|
|
||||||
|
### GitHub Actions
|
||||||
|
```yaml
|
||||||
|
# .github/workflows/test.yml
|
||||||
|
name: Tests
|
||||||
|
|
||||||
|
on: [push, pull_request]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
python-tests:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v3
|
||||||
|
- uses: actions/setup-python@v4
|
||||||
|
with:
|
||||||
|
python-version: '3.11'
|
||||||
|
- run: pip install -r requirements.txt
|
||||||
|
- run: pytest --cov
|
||||||
|
|
||||||
|
js-tests:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v3
|
||||||
|
- uses: actions/setup-node@v3
|
||||||
|
with:
|
||||||
|
node-version: '20'
|
||||||
|
- run: npm ci
|
||||||
|
- run: npm test -- --coverage
|
||||||
|
```
|
||||||
24
.env.example
Normal file
24
.env.example
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
# Audio Studio 환경 변수
|
||||||
|
|
||||||
|
# MongoDB
|
||||||
|
MONGO_USER=admin
|
||||||
|
MONGO_PASSWORD=your-mongo-password
|
||||||
|
|
||||||
|
# Redis
|
||||||
|
REDIS_URL=redis://redis:6379
|
||||||
|
|
||||||
|
# Hugging Face (모델 다운로드용)
|
||||||
|
HF_TOKEN=your-huggingface-token
|
||||||
|
|
||||||
|
# Freesound API (https://freesound.org/apiv2/apply 에서 발급)
|
||||||
|
FREESOUND_API_KEY=your-freesound-api-key
|
||||||
|
|
||||||
|
# 서비스 URL (내부 통신용)
|
||||||
|
TTS_ENGINE_URL=http://tts-engine:8001
|
||||||
|
MUSICGEN_URL=http://musicgen:8002
|
||||||
|
|
||||||
|
# 데이터베이스
|
||||||
|
DB_NAME=audio_studio
|
||||||
|
|
||||||
|
# JWT (추후 인증용)
|
||||||
|
JWT_SECRET_KEY=your-secret-key-change-in-production
|
||||||
40
.gitignore
vendored
Normal file
40
.gitignore
vendored
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
# Environment
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
|
||||||
|
# Dependencies
|
||||||
|
node_modules/
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
.venv/
|
||||||
|
venv/
|
||||||
|
|
||||||
|
# Build outputs
|
||||||
|
.next/
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
*.egg-info/
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
*.log
|
||||||
|
logs/
|
||||||
|
|
||||||
|
# Docker volumes
|
||||||
|
data/
|
||||||
|
|
||||||
|
# Models (large files)
|
||||||
|
models/
|
||||||
|
*.pt
|
||||||
|
*.bin
|
||||||
|
*.safetensors
|
||||||
58
CLAUDE.md
Normal file
58
CLAUDE.md
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
# CLAUDE.md
|
||||||
|
|
||||||
|
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||||
|
|
||||||
|
## 프로젝트 개요
|
||||||
|
|
||||||
|
**Flume** - K8s 기반 범용 파이프라인 시스템
|
||||||
|
|
||||||
|
**기술 스택:**
|
||||||
|
- Frontend: Next.js 16 + React 19 + TypeScript + Tailwind CSS 4 + shadcn/ui
|
||||||
|
- Backend: FastAPI + Python 3.11 + Pydantic v2
|
||||||
|
- Database: MongoDB 7.0 (motor async) + Redis 7
|
||||||
|
- AI: Claude API + OpenAI API + 로컬 LLM (vLLM)
|
||||||
|
- GPU: V100 16GB×8, V100 32GB×4, RTX 3090 24GB×2 (총 304GB VRAM)
|
||||||
|
- Container: Docker + Kubernetes
|
||||||
|
- Repository: Gitea (http://gitea.yakenator.io/yakenator/)
|
||||||
|
|
||||||
|
## 개발 컨벤션
|
||||||
|
|
||||||
|
### 언어
|
||||||
|
- Docstring, 주석, 로그: 한국어 + 영문 혼용
|
||||||
|
- 변수/함수: Python은 snake_case, TypeScript는 camelCase
|
||||||
|
- 파일 인코딩: UTF-8 (`ensure_ascii=False`)
|
||||||
|
|
||||||
|
### 커밋 메시지
|
||||||
|
```
|
||||||
|
<type>: <description>
|
||||||
|
|
||||||
|
- <detail>
|
||||||
|
|
||||||
|
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
|
||||||
|
```
|
||||||
|
타입: feat, fix, chore, refactor, docs
|
||||||
|
|
||||||
|
## 네이밍 규칙
|
||||||
|
|
||||||
|
- 컨테이너: `{프로젝트}-{서비스명}`
|
||||||
|
- 볼륨: `{프로젝트}_{데이터유형}_data`
|
||||||
|
- 네트워크: `{프로젝트}-network`
|
||||||
|
|
||||||
|
## 상세 가이드
|
||||||
|
|
||||||
|
프로젝트 표준 및 패턴은 `.claude/skills/` 디렉토리 참조:
|
||||||
|
|
||||||
|
| 우선순위 | 스킬 | 설명 |
|
||||||
|
|---------|------|------|
|
||||||
|
| 1 | deployment-standards | Docker 배포 규칙 |
|
||||||
|
| 1 | project-stack | 기술 스택 및 구조 |
|
||||||
|
| 1 | korean-dev-conventions | 한국어 컨벤션 |
|
||||||
|
| 1 | gpu-local-models | GPU 인프라 및 로컬 LLM |
|
||||||
|
| 1 | flume-architecture | Flume 파이프라인 시스템 |
|
||||||
|
| 2 | ai-api-integration | Claude/OpenAI 통합 |
|
||||||
|
| 2 | api-design-standards | RESTful API 설계 |
|
||||||
|
| 2 | frontend-component-patterns | React 컴포넌트 |
|
||||||
|
| 2 | database-patterns | MongoDB 패턴 |
|
||||||
|
| 3 | gitea-workflow | Git 워크플로우 |
|
||||||
|
| 3 | testing-standards | 테스트 작성 |
|
||||||
|
| 3 | monitoring-logging | 모니터링 |
|
||||||
33
audio-studio-api/Dockerfile
Normal file
33
audio-studio-api/Dockerfile
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
# Audio Studio API Server - Dockerfile
|
||||||
|
|
||||||
|
FROM python:3.11-slim
|
||||||
|
|
||||||
|
# 환경 변수
|
||||||
|
ENV PYTHONUNBUFFERED=1
|
||||||
|
ENV PYTHONDONTWRITEBYTECODE=1
|
||||||
|
|
||||||
|
# 시스템 패키지 설치
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
curl \
|
||||||
|
libsndfile1 \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# 작업 디렉토리
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# 의존성 설치 (캐시 활용)
|
||||||
|
COPY requirements.txt .
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
# 소스 코드 복사
|
||||||
|
COPY app/ ./app/
|
||||||
|
|
||||||
|
# 포트 노출
|
||||||
|
EXPOSE 8000
|
||||||
|
|
||||||
|
# 헬스체크
|
||||||
|
HEALTHCHECK --interval=30s --timeout=10s --start-period=10s --retries=3 \
|
||||||
|
CMD curl -f http://localhost:8000/health || exit 1
|
||||||
|
|
||||||
|
# 서버 실행
|
||||||
|
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||||
0
audio-studio-api/app/__init__.py
Normal file
0
audio-studio-api/app/__init__.py
Normal file
169
audio-studio-api/app/database.py
Normal file
169
audio-studio-api/app/database.py
Normal file
@ -0,0 +1,169 @@
|
|||||||
|
"""데이터베이스 연결 설정
|
||||||
|
|
||||||
|
MongoDB (motor async) + GridFS (오디오 저장)
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import logging
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from motor.motor_asyncio import AsyncIOMotorClient, AsyncIOMotorDatabase, AsyncIOMotorGridFSBucket
|
||||||
|
from redis.asyncio import Redis
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class Database:
|
||||||
|
"""데이터베이스 연결 관리"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.client: Optional[AsyncIOMotorClient] = None
|
||||||
|
self.db: Optional[AsyncIOMotorDatabase] = None
|
||||||
|
self.gridfs: Optional[AsyncIOMotorGridFSBucket] = None
|
||||||
|
self.redis: Optional[Redis] = None
|
||||||
|
|
||||||
|
async def connect(self):
|
||||||
|
"""데이터베이스 연결"""
|
||||||
|
# MongoDB
|
||||||
|
mongodb_url = os.getenv("MONGODB_URL", "mongodb://localhost:27017/")
|
||||||
|
db_name = os.getenv("DB_NAME", "audio_studio")
|
||||||
|
|
||||||
|
logger.info(f"MongoDB 연결 중: {db_name}")
|
||||||
|
self.client = AsyncIOMotorClient(mongodb_url)
|
||||||
|
self.db = self.client[db_name]
|
||||||
|
|
||||||
|
# GridFS (오디오 파일 저장용)
|
||||||
|
self.gridfs = AsyncIOMotorGridFSBucket(self.db, bucket_name="audio_files")
|
||||||
|
|
||||||
|
# 연결 테스트
|
||||||
|
await self.client.admin.command("ping")
|
||||||
|
logger.info("MongoDB 연결 성공")
|
||||||
|
|
||||||
|
# Redis
|
||||||
|
redis_url = os.getenv("REDIS_URL", "redis://localhost:6379")
|
||||||
|
logger.info("Redis 연결 중...")
|
||||||
|
self.redis = Redis.from_url(redis_url, decode_responses=True)
|
||||||
|
|
||||||
|
# 연결 테스트
|
||||||
|
await self.redis.ping()
|
||||||
|
logger.info("Redis 연결 성공")
|
||||||
|
|
||||||
|
# 인덱스 생성
|
||||||
|
await self._create_indexes()
|
||||||
|
|
||||||
|
async def _create_indexes(self):
|
||||||
|
"""컬렉션 인덱스 생성"""
|
||||||
|
# voices 컬렉션
|
||||||
|
await self.db.voices.create_index("voice_id", unique=True)
|
||||||
|
await self.db.voices.create_index("owner_id")
|
||||||
|
await self.db.voices.create_index("type")
|
||||||
|
await self.db.voices.create_index("language")
|
||||||
|
await self.db.voices.create_index("is_public")
|
||||||
|
|
||||||
|
# tts_generations 컬렉션
|
||||||
|
await self.db.tts_generations.create_index("generation_id", unique=True)
|
||||||
|
await self.db.tts_generations.create_index("user_id")
|
||||||
|
await self.db.tts_generations.create_index("voice_id")
|
||||||
|
await self.db.tts_generations.create_index("created_at")
|
||||||
|
|
||||||
|
# sound_effects 컬렉션
|
||||||
|
await self.db.sound_effects.create_index("source_id")
|
||||||
|
await self.db.sound_effects.create_index("categories")
|
||||||
|
await self.db.sound_effects.create_index("tags")
|
||||||
|
|
||||||
|
# music_tracks 컬렉션
|
||||||
|
await self.db.music_tracks.create_index("source")
|
||||||
|
await self.db.music_tracks.create_index("genre")
|
||||||
|
await self.db.music_tracks.create_index("mood")
|
||||||
|
|
||||||
|
logger.info("인덱스 생성 완료")
|
||||||
|
|
||||||
|
async def disconnect(self):
|
||||||
|
"""데이터베이스 연결 해제"""
|
||||||
|
if self.client:
|
||||||
|
self.client.close()
|
||||||
|
logger.info("MongoDB 연결 해제")
|
||||||
|
|
||||||
|
if self.redis:
|
||||||
|
await self.redis.close()
|
||||||
|
logger.info("Redis 연결 해제")
|
||||||
|
|
||||||
|
# ========================================
|
||||||
|
# 컬렉션 접근자
|
||||||
|
# ========================================
|
||||||
|
|
||||||
|
@property
|
||||||
|
def voices(self):
|
||||||
|
"""voices 컬렉션"""
|
||||||
|
return self.db.voices
|
||||||
|
|
||||||
|
@property
|
||||||
|
def tts_generations(self):
|
||||||
|
"""tts_generations 컬렉션"""
|
||||||
|
return self.db.tts_generations
|
||||||
|
|
||||||
|
@property
|
||||||
|
def sound_effects(self):
|
||||||
|
"""sound_effects 컬렉션"""
|
||||||
|
return self.db.sound_effects
|
||||||
|
|
||||||
|
@property
|
||||||
|
def music_tracks(self):
|
||||||
|
"""music_tracks 컬렉션"""
|
||||||
|
return self.db.music_tracks
|
||||||
|
|
||||||
|
@property
|
||||||
|
def user_voice_library(self):
|
||||||
|
"""user_voice_library 컬렉션"""
|
||||||
|
return self.db.user_voice_library
|
||||||
|
|
||||||
|
# ========================================
|
||||||
|
# GridFS 오디오 저장
|
||||||
|
# ========================================
|
||||||
|
|
||||||
|
async def save_audio(
|
||||||
|
self,
|
||||||
|
audio_bytes: bytes,
|
||||||
|
filename: str,
|
||||||
|
content_type: str = "audio/wav",
|
||||||
|
metadata: dict = None,
|
||||||
|
) -> str:
|
||||||
|
"""오디오 파일을 GridFS에 저장
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
file_id (str)
|
||||||
|
"""
|
||||||
|
file_id = await self.gridfs.upload_from_stream(
|
||||||
|
filename,
|
||||||
|
audio_bytes,
|
||||||
|
metadata={
|
||||||
|
"content_type": content_type,
|
||||||
|
**(metadata or {}),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return str(file_id)
|
||||||
|
|
||||||
|
async def get_audio(self, file_id: str) -> bytes:
|
||||||
|
"""GridFS에서 오디오 파일 읽기"""
|
||||||
|
from bson import ObjectId
|
||||||
|
from io import BytesIO
|
||||||
|
|
||||||
|
buffer = BytesIO()
|
||||||
|
await self.gridfs.download_to_stream(ObjectId(file_id), buffer)
|
||||||
|
buffer.seek(0)
|
||||||
|
return buffer.read()
|
||||||
|
|
||||||
|
async def delete_audio(self, file_id: str):
|
||||||
|
"""GridFS에서 오디오 파일 삭제"""
|
||||||
|
from bson import ObjectId
|
||||||
|
await self.gridfs.delete(ObjectId(file_id))
|
||||||
|
|
||||||
|
|
||||||
|
# 싱글톤 인스턴스
|
||||||
|
db = Database()
|
||||||
|
|
||||||
|
|
||||||
|
# FastAPI 의존성
|
||||||
|
async def get_db() -> Database:
|
||||||
|
"""데이터베이스 인스턴스 반환 (의존성 주입용)"""
|
||||||
|
return db
|
||||||
163
audio-studio-api/app/main.py
Normal file
163
audio-studio-api/app/main.py
Normal file
@ -0,0 +1,163 @@
|
|||||||
|
"""Drama Studio API Server
|
||||||
|
|
||||||
|
AI 라디오 드라마 제작 - TTS, 보이스, 효과음, 배경음악, 드라마 생성 API
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
|
|
||||||
|
from fastapi import FastAPI
|
||||||
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
|
|
||||||
|
from app.database import db
|
||||||
|
from app.routers import voices, tts, recordings, sound_effects, music, drama
|
||||||
|
|
||||||
|
# 로깅 설정
|
||||||
|
logging.basicConfig(
|
||||||
|
level=logging.INFO,
|
||||||
|
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s"
|
||||||
|
)
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
# ========================================
|
||||||
|
# 앱 생명주기
|
||||||
|
# ========================================
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def lifespan(app: FastAPI):
|
||||||
|
"""앱 시작/종료 시 실행"""
|
||||||
|
# 시작 시 DB 연결
|
||||||
|
logger.info("Drama Studio API 서버 시작...")
|
||||||
|
try:
|
||||||
|
await db.connect()
|
||||||
|
logger.info("데이터베이스 연결 완료")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"데이터베이스 연결 실패: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
yield
|
||||||
|
|
||||||
|
# 종료 시 DB 연결 해제
|
||||||
|
await db.disconnect()
|
||||||
|
logger.info("Drama Studio API 서버 종료")
|
||||||
|
|
||||||
|
|
||||||
|
# ========================================
|
||||||
|
# FastAPI 앱
|
||||||
|
# ========================================
|
||||||
|
|
||||||
|
app = FastAPI(
|
||||||
|
title="Drama Studio API",
|
||||||
|
description="""
|
||||||
|
Drama Studio API - AI 라디오 드라마 제작 플랫폼
|
||||||
|
|
||||||
|
## 기능
|
||||||
|
|
||||||
|
### Voice (보이스 관리)
|
||||||
|
- 프리셋 보이스 목록 조회
|
||||||
|
- Voice Clone (목소리 복제)
|
||||||
|
- Voice Design (AI 음성 생성)
|
||||||
|
- 사용자 보이스 라이브러리
|
||||||
|
|
||||||
|
### TTS (음성 합성)
|
||||||
|
- 텍스트를 음성으로 변환
|
||||||
|
- 다양한 언어 지원 (한국어, 영어, 일본어 등)
|
||||||
|
|
||||||
|
### Recording (녹음)
|
||||||
|
- 녹음 업로드 및 품질 검증
|
||||||
|
- Voice Clone용 레퍼런스 관리
|
||||||
|
|
||||||
|
### Sound Effects (효과음)
|
||||||
|
- Freesound 검색 및 다운로드
|
||||||
|
- 로컬 효과음 라이브러리
|
||||||
|
|
||||||
|
### Drama (드라마 생성)
|
||||||
|
- 스크립트 기반 라디오 드라마 생성
|
||||||
|
- 자동 TTS/BGM/효과음 합성
|
||||||
|
- 타임라인 기반 오디오 믹싱
|
||||||
|
""",
|
||||||
|
version="0.1.0",
|
||||||
|
lifespan=lifespan,
|
||||||
|
)
|
||||||
|
|
||||||
|
# CORS 설정
|
||||||
|
app.add_middleware(
|
||||||
|
CORSMiddleware,
|
||||||
|
allow_origins=["*"], # 개발 환경용, 프로덕션에서는 제한 필요
|
||||||
|
allow_credentials=True,
|
||||||
|
allow_methods=["*"],
|
||||||
|
allow_headers=["*"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ========================================
|
||||||
|
# 라우터 등록
|
||||||
|
# ========================================
|
||||||
|
|
||||||
|
app.include_router(voices.router)
|
||||||
|
app.include_router(tts.router)
|
||||||
|
app.include_router(recordings.router)
|
||||||
|
app.include_router(sound_effects.router)
|
||||||
|
app.include_router(music.router)
|
||||||
|
app.include_router(drama.router)
|
||||||
|
|
||||||
|
|
||||||
|
# ========================================
|
||||||
|
# 기본 엔드포인트
|
||||||
|
# ========================================
|
||||||
|
|
||||||
|
@app.get("/")
|
||||||
|
async def root():
|
||||||
|
"""API 루트"""
|
||||||
|
return {
|
||||||
|
"name": "Drama Studio API",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"docs": "/docs",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/health")
|
||||||
|
async def health_check():
|
||||||
|
"""헬스체크"""
|
||||||
|
try:
|
||||||
|
# MongoDB 연결 확인
|
||||||
|
await db.client.admin.command("ping")
|
||||||
|
mongo_status = "healthy"
|
||||||
|
except Exception as e:
|
||||||
|
mongo_status = f"unhealthy: {str(e)}"
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Redis 연결 확인
|
||||||
|
await db.redis.ping()
|
||||||
|
redis_status = "healthy"
|
||||||
|
except Exception as e:
|
||||||
|
redis_status = f"unhealthy: {str(e)}"
|
||||||
|
|
||||||
|
status = "healthy" if mongo_status == "healthy" and redis_status == "healthy" else "degraded"
|
||||||
|
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=200 if status == "healthy" else 503,
|
||||||
|
content={
|
||||||
|
"status": status,
|
||||||
|
"services": {
|
||||||
|
"mongodb": mongo_status,
|
||||||
|
"redis": redis_status,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ========================================
|
||||||
|
# 에러 핸들러
|
||||||
|
# ========================================
|
||||||
|
|
||||||
|
@app.exception_handler(Exception)
|
||||||
|
async def global_exception_handler(request, exc):
|
||||||
|
"""전역 예외 핸들러"""
|
||||||
|
logger.error(f"Unhandled exception: {exc}", exc_info=True)
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=500,
|
||||||
|
content={"detail": "Internal server error"},
|
||||||
|
)
|
||||||
0
audio-studio-api/app/routers/__init__.py
Normal file
0
audio-studio-api/app/routers/__init__.py
Normal file
193
audio-studio-api/app/routers/drama.py
Normal file
193
audio-studio-api/app/routers/drama.py
Normal file
@ -0,0 +1,193 @@
|
|||||||
|
# 드라마 API 라우터
|
||||||
|
from fastapi import APIRouter, HTTPException, BackgroundTasks
|
||||||
|
from fastapi.responses import FileResponse
|
||||||
|
from typing import Optional
|
||||||
|
import os
|
||||||
|
|
||||||
|
from app.models.drama import (
|
||||||
|
DramaCreateRequest, DramaGenerateRequest, DramaResponse,
|
||||||
|
ParsedScript, Character
|
||||||
|
)
|
||||||
|
from app.services.script_parser import script_parser
|
||||||
|
from app.services.drama_orchestrator import drama_orchestrator
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/v1/drama", tags=["drama"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/parse", response_model=ParsedScript)
|
||||||
|
async def parse_script(script: str):
|
||||||
|
"""
|
||||||
|
스크립트 파싱 (미리보기)
|
||||||
|
|
||||||
|
마크다운 형식의 스크립트를 구조화된 데이터로 변환합니다.
|
||||||
|
실제 프로젝트 생성 없이 파싱 결과만 확인할 수 있습니다.
|
||||||
|
"""
|
||||||
|
is_valid, errors = script_parser.validate_script(script)
|
||||||
|
if not is_valid:
|
||||||
|
raise HTTPException(status_code=400, detail={"errors": errors})
|
||||||
|
|
||||||
|
return script_parser.parse(script)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/projects", response_model=DramaResponse)
|
||||||
|
async def create_project(request: DramaCreateRequest):
|
||||||
|
"""
|
||||||
|
새 드라마 프로젝트 생성
|
||||||
|
|
||||||
|
스크립트를 파싱하고 프로젝트를 생성합니다.
|
||||||
|
voice_mapping으로 캐릭터별 보이스를 지정할 수 있습니다.
|
||||||
|
"""
|
||||||
|
# 스크립트 유효성 검사
|
||||||
|
is_valid, errors = script_parser.validate_script(request.script)
|
||||||
|
if not is_valid:
|
||||||
|
raise HTTPException(status_code=400, detail={"errors": errors})
|
||||||
|
|
||||||
|
project = await drama_orchestrator.create_project(request)
|
||||||
|
|
||||||
|
return DramaResponse(
|
||||||
|
project_id=project.project_id,
|
||||||
|
title=project.title,
|
||||||
|
status=project.status,
|
||||||
|
characters=project.script_parsed.characters if project.script_parsed else [],
|
||||||
|
element_count=len(project.script_parsed.elements) if project.script_parsed else 0,
|
||||||
|
estimated_duration=drama_orchestrator.estimate_duration(project.script_parsed) if project.script_parsed else None
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/projects", response_model=list[DramaResponse])
|
||||||
|
async def list_projects(skip: int = 0, limit: int = 20):
|
||||||
|
"""프로젝트 목록 조회"""
|
||||||
|
projects = await drama_orchestrator.list_projects(skip=skip, limit=limit)
|
||||||
|
|
||||||
|
return [
|
||||||
|
DramaResponse(
|
||||||
|
project_id=p.project_id,
|
||||||
|
title=p.title,
|
||||||
|
status=p.status,
|
||||||
|
characters=p.script_parsed.characters if p.script_parsed else [],
|
||||||
|
element_count=len(p.script_parsed.elements) if p.script_parsed else 0,
|
||||||
|
output_file_id=p.output_file_id,
|
||||||
|
error_message=p.error_message
|
||||||
|
)
|
||||||
|
for p in projects
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/projects/{project_id}", response_model=DramaResponse)
|
||||||
|
async def get_project(project_id: str):
|
||||||
|
"""프로젝트 상세 조회"""
|
||||||
|
project = await drama_orchestrator.get_project(project_id)
|
||||||
|
if not project:
|
||||||
|
raise HTTPException(status_code=404, detail="프로젝트를 찾을 수 없습니다")
|
||||||
|
|
||||||
|
return DramaResponse(
|
||||||
|
project_id=project.project_id,
|
||||||
|
title=project.title,
|
||||||
|
status=project.status,
|
||||||
|
characters=project.script_parsed.characters if project.script_parsed else [],
|
||||||
|
element_count=len(project.script_parsed.elements) if project.script_parsed else 0,
|
||||||
|
estimated_duration=drama_orchestrator.estimate_duration(project.script_parsed) if project.script_parsed else None,
|
||||||
|
output_file_id=project.output_file_id,
|
||||||
|
error_message=project.error_message
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/projects/{project_id}/render")
|
||||||
|
async def render_project(
|
||||||
|
project_id: str,
|
||||||
|
background_tasks: BackgroundTasks,
|
||||||
|
output_format: str = "wav"
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
드라마 렌더링 시작
|
||||||
|
|
||||||
|
백그라운드에서 TTS 생성, 효과음 검색, 믹싱을 수행합니다.
|
||||||
|
완료되면 status가 'completed'로 변경됩니다.
|
||||||
|
"""
|
||||||
|
project = await drama_orchestrator.get_project(project_id)
|
||||||
|
if not project:
|
||||||
|
raise HTTPException(status_code=404, detail="프로젝트를 찾을 수 없습니다")
|
||||||
|
|
||||||
|
if project.status == "processing":
|
||||||
|
raise HTTPException(status_code=400, detail="이미 렌더링 중입니다")
|
||||||
|
|
||||||
|
# 백그라운드 렌더링 시작
|
||||||
|
background_tasks.add_task(
|
||||||
|
drama_orchestrator.render,
|
||||||
|
project_id,
|
||||||
|
output_format
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"project_id": project_id,
|
||||||
|
"status": "processing",
|
||||||
|
"message": "렌더링이 시작되었습니다"
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/projects/{project_id}/download")
|
||||||
|
async def download_project(project_id: str):
|
||||||
|
"""렌더링된 드라마 다운로드"""
|
||||||
|
project = await drama_orchestrator.get_project(project_id)
|
||||||
|
if not project:
|
||||||
|
raise HTTPException(status_code=404, detail="프로젝트를 찾을 수 없습니다")
|
||||||
|
|
||||||
|
if project.status != "completed":
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail=f"렌더링이 완료되지 않았습니다 (현재 상태: {project.status})"
|
||||||
|
)
|
||||||
|
|
||||||
|
if not project.output_file_id or not os.path.exists(project.output_file_id):
|
||||||
|
raise HTTPException(status_code=404, detail="출력 파일을 찾을 수 없습니다")
|
||||||
|
|
||||||
|
return FileResponse(
|
||||||
|
project.output_file_id,
|
||||||
|
media_type="audio/wav",
|
||||||
|
filename=f"{project.title}.wav"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/projects/{project_id}/voices")
|
||||||
|
async def update_voice_mapping(
|
||||||
|
project_id: str,
|
||||||
|
voice_mapping: dict[str, str]
|
||||||
|
):
|
||||||
|
"""캐릭터-보이스 매핑 업데이트"""
|
||||||
|
project = await drama_orchestrator.get_project(project_id)
|
||||||
|
if not project:
|
||||||
|
raise HTTPException(status_code=404, detail="프로젝트를 찾을 수 없습니다")
|
||||||
|
|
||||||
|
from app.database import db
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
await db.dramas.update_one(
|
||||||
|
{"project_id": project_id},
|
||||||
|
{
|
||||||
|
"$set": {
|
||||||
|
"voice_mapping": voice_mapping,
|
||||||
|
"updated_at": datetime.utcnow()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return {"message": "보이스 매핑이 업데이트되었습니다"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/projects/{project_id}")
|
||||||
|
async def delete_project(project_id: str):
|
||||||
|
"""프로젝트 삭제"""
|
||||||
|
project = await drama_orchestrator.get_project(project_id)
|
||||||
|
if not project:
|
||||||
|
raise HTTPException(status_code=404, detail="프로젝트를 찾을 수 없습니다")
|
||||||
|
|
||||||
|
from app.database import db
|
||||||
|
|
||||||
|
# 출력 파일 삭제
|
||||||
|
if project.output_file_id and os.path.exists(project.output_file_id):
|
||||||
|
os.remove(project.output_file_id)
|
||||||
|
|
||||||
|
# DB에서 삭제
|
||||||
|
await db.dramas.delete_one({"project_id": project_id})
|
||||||
|
|
||||||
|
return {"message": "프로젝트가 삭제되었습니다"}
|
||||||
278
audio-studio-api/app/routers/music.py
Normal file
278
audio-studio-api/app/routers/music.py
Normal file
@ -0,0 +1,278 @@
|
|||||||
|
"""배경음악 API 라우터
|
||||||
|
|
||||||
|
MusicGen 연동 및 외부 음악 소스
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Optional, List
|
||||||
|
|
||||||
|
from fastapi import APIRouter, HTTPException, Depends, Query, UploadFile, File, Form
|
||||||
|
from fastapi.responses import Response
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from app.database import Database, get_db
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/v1/music", tags=["music"])
|
||||||
|
|
||||||
|
MUSICGEN_URL = os.getenv("MUSICGEN_URL", "http://localhost:8002")
|
||||||
|
|
||||||
|
|
||||||
|
# ========================================
|
||||||
|
# Pydantic 모델
|
||||||
|
# ========================================
|
||||||
|
|
||||||
|
class MusicGenerateRequest(BaseModel):
|
||||||
|
"""음악 생성 요청"""
|
||||||
|
prompt: str = Field(..., min_length=5, max_length=500, description="음악 설명")
|
||||||
|
duration: int = Field(default=30, ge=5, le=30, description="생성 길이 (초)")
|
||||||
|
save_to_library: bool = Field(default=True, description="라이브러리에 저장")
|
||||||
|
|
||||||
|
|
||||||
|
class MusicTrackResponse(BaseModel):
|
||||||
|
"""음악 트랙 응답"""
|
||||||
|
id: str
|
||||||
|
name: str
|
||||||
|
description: Optional[str] = None
|
||||||
|
source: str # musicgen | pixabay | uploaded
|
||||||
|
generation_prompt: Optional[str] = None
|
||||||
|
duration_seconds: float
|
||||||
|
genre: Optional[str] = None
|
||||||
|
mood: List[str] = []
|
||||||
|
license: str = ""
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class MusicListResponse(BaseModel):
|
||||||
|
"""음악 목록 응답"""
|
||||||
|
tracks: List[MusicTrackResponse]
|
||||||
|
total: int
|
||||||
|
page: int
|
||||||
|
page_size: int
|
||||||
|
|
||||||
|
|
||||||
|
# ========================================
|
||||||
|
# API 엔드포인트
|
||||||
|
# ========================================
|
||||||
|
|
||||||
|
@router.post("/generate")
|
||||||
|
async def generate_music(
|
||||||
|
request: MusicGenerateRequest,
|
||||||
|
db: Database = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""AI로 배경음악 생성
|
||||||
|
|
||||||
|
MusicGen을 사용하여 텍스트 프롬프트 기반 음악 생성
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# MusicGen 서비스 호출
|
||||||
|
async with httpx.AsyncClient(timeout=120.0) as client:
|
||||||
|
response = await client.post(
|
||||||
|
f"{MUSICGEN_URL}/generate",
|
||||||
|
json={
|
||||||
|
"prompt": request.prompt,
|
||||||
|
"duration": request.duration,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
audio_bytes = response.content
|
||||||
|
|
||||||
|
except httpx.TimeoutException:
|
||||||
|
raise HTTPException(status_code=504, detail="Music generation timed out")
|
||||||
|
except httpx.HTTPStatusError as e:
|
||||||
|
raise HTTPException(status_code=502, detail=f"MusicGen error: {e.response.text}")
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=f"Music generation failed: {str(e)}")
|
||||||
|
|
||||||
|
# 라이브러리에 저장
|
||||||
|
if request.save_to_library:
|
||||||
|
track_id = f"music_{uuid.uuid4().hex[:12]}"
|
||||||
|
now = datetime.utcnow()
|
||||||
|
|
||||||
|
# GridFS에 오디오 저장
|
||||||
|
audio_file_id = await db.save_audio(
|
||||||
|
audio_bytes,
|
||||||
|
f"{track_id}.wav",
|
||||||
|
metadata={
|
||||||
|
"type": "generated_music",
|
||||||
|
"prompt": request.prompt,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
# DB에 트랙 정보 저장
|
||||||
|
track_doc = {
|
||||||
|
"track_id": track_id,
|
||||||
|
"name": f"Generated: {request.prompt[:30]}...",
|
||||||
|
"description": request.prompt,
|
||||||
|
"source": "musicgen",
|
||||||
|
"generation_prompt": request.prompt,
|
||||||
|
"audio_file_id": audio_file_id,
|
||||||
|
"duration_seconds": request.duration,
|
||||||
|
"format": "wav",
|
||||||
|
"genre": None,
|
||||||
|
"mood": [],
|
||||||
|
"license": "CC-BY-NC", # MusicGen 모델 라이센스
|
||||||
|
"created_at": now,
|
||||||
|
}
|
||||||
|
await db.music_tracks.insert_one(track_doc)
|
||||||
|
|
||||||
|
return Response(
|
||||||
|
content=audio_bytes,
|
||||||
|
media_type="audio/wav",
|
||||||
|
headers={
|
||||||
|
"X-Duration": str(request.duration),
|
||||||
|
"Content-Disposition": 'attachment; filename="generated_music.wav"',
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/library", response_model=MusicListResponse)
|
||||||
|
async def list_music_library(
|
||||||
|
page: int = Query(1, ge=1),
|
||||||
|
page_size: int = Query(20, ge=1, le=100),
|
||||||
|
source: Optional[str] = Query(None, description="소스 필터 (musicgen, pixabay, uploaded)"),
|
||||||
|
genre: Optional[str] = Query(None, description="장르 필터"),
|
||||||
|
db: Database = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""음악 라이브러리 목록 조회"""
|
||||||
|
query = {}
|
||||||
|
if source:
|
||||||
|
query["source"] = source
|
||||||
|
if genre:
|
||||||
|
query["genre"] = genre
|
||||||
|
|
||||||
|
total = await db.music_tracks.count_documents(query)
|
||||||
|
skip = (page - 1) * page_size
|
||||||
|
|
||||||
|
cursor = db.music_tracks.find(query).sort("created_at", -1).skip(skip).limit(page_size)
|
||||||
|
|
||||||
|
tracks = []
|
||||||
|
async for doc in cursor:
|
||||||
|
tracks.append(MusicTrackResponse(
|
||||||
|
id=doc.get("track_id", str(doc["_id"])),
|
||||||
|
name=doc["name"],
|
||||||
|
description=doc.get("description"),
|
||||||
|
source=doc.get("source", "unknown"),
|
||||||
|
generation_prompt=doc.get("generation_prompt"),
|
||||||
|
duration_seconds=doc.get("duration_seconds", 0),
|
||||||
|
genre=doc.get("genre"),
|
||||||
|
mood=doc.get("mood", []),
|
||||||
|
license=doc.get("license", ""),
|
||||||
|
created_at=doc.get("created_at", datetime.utcnow()),
|
||||||
|
))
|
||||||
|
|
||||||
|
return MusicListResponse(
|
||||||
|
tracks=tracks,
|
||||||
|
total=total,
|
||||||
|
page=page,
|
||||||
|
page_size=page_size,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{track_id}")
|
||||||
|
async def get_music_track(
|
||||||
|
track_id: str,
|
||||||
|
db: Database = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""음악 트랙 상세 정보"""
|
||||||
|
doc = await db.music_tracks.find_one({"track_id": track_id})
|
||||||
|
if not doc:
|
||||||
|
raise HTTPException(status_code=404, detail="Track not found")
|
||||||
|
|
||||||
|
return MusicTrackResponse(
|
||||||
|
id=doc.get("track_id", str(doc["_id"])),
|
||||||
|
name=doc["name"],
|
||||||
|
description=doc.get("description"),
|
||||||
|
source=doc.get("source", "unknown"),
|
||||||
|
generation_prompt=doc.get("generation_prompt"),
|
||||||
|
duration_seconds=doc.get("duration_seconds", 0),
|
||||||
|
genre=doc.get("genre"),
|
||||||
|
mood=doc.get("mood", []),
|
||||||
|
license=doc.get("license", ""),
|
||||||
|
created_at=doc.get("created_at", datetime.utcnow()),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{track_id}/audio")
|
||||||
|
async def get_music_audio(
|
||||||
|
track_id: str,
|
||||||
|
db: Database = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""음악 오디오 스트리밍"""
|
||||||
|
doc = await db.music_tracks.find_one({"track_id": track_id})
|
||||||
|
if not doc:
|
||||||
|
raise HTTPException(status_code=404, detail="Track not found")
|
||||||
|
|
||||||
|
audio_file_id = doc.get("audio_file_id")
|
||||||
|
if not audio_file_id:
|
||||||
|
raise HTTPException(status_code=404, detail="Audio file not found")
|
||||||
|
|
||||||
|
audio_bytes = await db.get_audio(audio_file_id)
|
||||||
|
|
||||||
|
return Response(
|
||||||
|
content=audio_bytes,
|
||||||
|
media_type="audio/wav",
|
||||||
|
headers={"Content-Disposition": f'inline; filename="{track_id}.wav"'},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{track_id}")
|
||||||
|
async def delete_music_track(
|
||||||
|
track_id: str,
|
||||||
|
db: Database = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""음악 트랙 삭제"""
|
||||||
|
doc = await db.music_tracks.find_one({"track_id": track_id})
|
||||||
|
if not doc:
|
||||||
|
raise HTTPException(status_code=404, detail="Track not found")
|
||||||
|
|
||||||
|
# 오디오 파일 삭제
|
||||||
|
if doc.get("audio_file_id"):
|
||||||
|
await db.delete_audio(doc["audio_file_id"])
|
||||||
|
|
||||||
|
# 문서 삭제
|
||||||
|
await db.music_tracks.delete_one({"track_id": track_id})
|
||||||
|
|
||||||
|
return {"status": "deleted", "track_id": track_id}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/prompts/examples")
|
||||||
|
async def get_example_prompts():
|
||||||
|
"""예시 프롬프트 목록"""
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||||
|
response = await client.get(f"{MUSICGEN_URL}/prompts")
|
||||||
|
response.raise_for_status()
|
||||||
|
return response.json()
|
||||||
|
except Exception:
|
||||||
|
# MusicGen 서비스 연결 실패 시 기본 프롬프트 반환
|
||||||
|
return {
|
||||||
|
"examples": [
|
||||||
|
{
|
||||||
|
"category": "Ambient",
|
||||||
|
"prompts": [
|
||||||
|
"calm piano music, peaceful, ambient",
|
||||||
|
"lo-fi hip hop beats, relaxing, study music",
|
||||||
|
"meditation music, calm, zen",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"category": "Electronic",
|
||||||
|
"prompts": [
|
||||||
|
"upbeat electronic dance music",
|
||||||
|
"retro synthwave 80s style",
|
||||||
|
"chill electronic ambient",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"category": "Cinematic",
|
||||||
|
"prompts": [
|
||||||
|
"epic orchestral cinematic music",
|
||||||
|
"tense suspenseful thriller music",
|
||||||
|
"cheerful happy video game background",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
184
audio-studio-api/app/routers/recordings.py
Normal file
184
audio-studio-api/app/routers/recordings.py
Normal file
@ -0,0 +1,184 @@
|
|||||||
|
"""녹음 관리 API 라우터"""
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
import io
|
||||||
|
from typing import List
|
||||||
|
|
||||||
|
from fastapi import APIRouter, HTTPException, Depends, UploadFile, File, Form
|
||||||
|
from fastapi.responses import Response
|
||||||
|
import soundfile as sf
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
from app.database import Database, get_db
|
||||||
|
from app.models.voice import RecordingValidateResponse, RecordingUploadResponse
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/v1/recordings", tags=["recordings"])
|
||||||
|
|
||||||
|
|
||||||
|
def analyze_audio(audio_bytes: bytes) -> dict:
|
||||||
|
"""오디오 파일 분석
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
duration, sample_rate, quality_score, issues
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# 오디오 로드
|
||||||
|
audio_data, sample_rate = sf.read(io.BytesIO(audio_bytes))
|
||||||
|
|
||||||
|
# 모노로 변환
|
||||||
|
if len(audio_data.shape) > 1:
|
||||||
|
audio_data = audio_data.mean(axis=1)
|
||||||
|
|
||||||
|
duration = len(audio_data) / sample_rate
|
||||||
|
|
||||||
|
# 품질 분석
|
||||||
|
issues = []
|
||||||
|
quality_score = 1.0
|
||||||
|
|
||||||
|
# 길이 체크
|
||||||
|
if duration < 1.0:
|
||||||
|
issues.append("오디오가 너무 짧습니다 (최소 1초 이상)")
|
||||||
|
quality_score -= 0.3
|
||||||
|
elif duration < 3.0:
|
||||||
|
issues.append("Voice Clone에는 3초 이상의 오디오가 권장됩니다")
|
||||||
|
quality_score -= 0.1
|
||||||
|
|
||||||
|
# RMS 레벨 체크 (볼륨)
|
||||||
|
rms = np.sqrt(np.mean(audio_data ** 2))
|
||||||
|
if rms < 0.01:
|
||||||
|
issues.append("볼륨이 너무 낮습니다")
|
||||||
|
quality_score -= 0.2
|
||||||
|
elif rms > 0.5:
|
||||||
|
issues.append("볼륨이 너무 높습니다 (클리핑 가능성)")
|
||||||
|
quality_score -= 0.1
|
||||||
|
|
||||||
|
# 피크 체크
|
||||||
|
peak = np.max(np.abs(audio_data))
|
||||||
|
if peak > 0.99:
|
||||||
|
issues.append("오디오가 클리핑되었습니다")
|
||||||
|
quality_score -= 0.2
|
||||||
|
|
||||||
|
# 노이즈 체크 (간단한 휴리스틱)
|
||||||
|
# 실제로는 더 정교한 노이즈 감지 필요
|
||||||
|
silence_threshold = 0.01
|
||||||
|
silent_samples = np.sum(np.abs(audio_data) < silence_threshold)
|
||||||
|
silence_ratio = silent_samples / len(audio_data)
|
||||||
|
|
||||||
|
if silence_ratio > 0.7:
|
||||||
|
issues.append("대부분이 무음입니다")
|
||||||
|
quality_score -= 0.3
|
||||||
|
elif silence_ratio > 0.5:
|
||||||
|
issues.append("무음 구간이 많습니다")
|
||||||
|
quality_score -= 0.1
|
||||||
|
|
||||||
|
quality_score = max(0.0, min(1.0, quality_score))
|
||||||
|
|
||||||
|
return {
|
||||||
|
"duration": duration,
|
||||||
|
"sample_rate": sample_rate,
|
||||||
|
"quality_score": quality_score,
|
||||||
|
"issues": issues,
|
||||||
|
"rms": float(rms),
|
||||||
|
"peak": float(peak),
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return {
|
||||||
|
"duration": 0,
|
||||||
|
"sample_rate": 0,
|
||||||
|
"quality_score": 0,
|
||||||
|
"issues": [f"오디오 분석 실패: {str(e)}"],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/validate", response_model=RecordingValidateResponse)
|
||||||
|
async def validate_recording(
|
||||||
|
audio: UploadFile = File(..., description="검증할 오디오 파일"),
|
||||||
|
):
|
||||||
|
"""녹음 품질 검증
|
||||||
|
|
||||||
|
Voice Clone에 사용할 녹음의 품질을 검증합니다.
|
||||||
|
"""
|
||||||
|
audio_bytes = await audio.read()
|
||||||
|
|
||||||
|
if len(audio_bytes) < 1000:
|
||||||
|
raise HTTPException(status_code=400, detail="파일이 너무 작습니다")
|
||||||
|
|
||||||
|
analysis = analyze_audio(audio_bytes)
|
||||||
|
|
||||||
|
return RecordingValidateResponse(
|
||||||
|
valid=analysis["quality_score"] > 0.5 and analysis["duration"] > 1.0,
|
||||||
|
duration=analysis["duration"],
|
||||||
|
sample_rate=analysis["sample_rate"],
|
||||||
|
quality_score=analysis["quality_score"],
|
||||||
|
issues=analysis["issues"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/upload", response_model=RecordingUploadResponse)
|
||||||
|
async def upload_recording(
|
||||||
|
audio: UploadFile = File(..., description="업로드할 오디오 파일"),
|
||||||
|
transcript: str = Form(None, description="오디오의 텍스트 내용"),
|
||||||
|
db: Database = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""녹음 파일 업로드
|
||||||
|
|
||||||
|
Voice Clone에 사용할 녹음을 업로드합니다.
|
||||||
|
"""
|
||||||
|
audio_bytes = await audio.read()
|
||||||
|
|
||||||
|
# 품질 분석
|
||||||
|
analysis = analyze_audio(audio_bytes)
|
||||||
|
|
||||||
|
if analysis["duration"] < 0.5:
|
||||||
|
raise HTTPException(status_code=400, detail="오디오가 너무 짧습니다")
|
||||||
|
|
||||||
|
# GridFS에 저장
|
||||||
|
file_id = await db.save_audio(
|
||||||
|
audio_bytes,
|
||||||
|
audio.filename or f"recording_{uuid.uuid4()}.wav",
|
||||||
|
metadata={
|
||||||
|
"type": "recording",
|
||||||
|
"transcript": transcript,
|
||||||
|
"duration": analysis["duration"],
|
||||||
|
"sample_rate": analysis["sample_rate"],
|
||||||
|
"quality_score": analysis["quality_score"],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
return RecordingUploadResponse(
|
||||||
|
file_id=file_id,
|
||||||
|
filename=audio.filename or "recording.wav",
|
||||||
|
duration=analysis["duration"],
|
||||||
|
sample_rate=analysis["sample_rate"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{file_id}")
|
||||||
|
async def get_recording(
|
||||||
|
file_id: str,
|
||||||
|
db: Database = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""녹음 파일 다운로드"""
|
||||||
|
try:
|
||||||
|
audio_bytes = await db.get_audio(file_id)
|
||||||
|
return Response(
|
||||||
|
content=audio_bytes,
|
||||||
|
media_type="audio/wav",
|
||||||
|
headers={"Content-Disposition": f'attachment; filename="{file_id}.wav"'},
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=404, detail="Recording not found")
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{file_id}")
|
||||||
|
async def delete_recording(
|
||||||
|
file_id: str,
|
||||||
|
db: Database = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""녹음 파일 삭제"""
|
||||||
|
try:
|
||||||
|
await db.delete_audio(file_id)
|
||||||
|
return {"status": "deleted", "file_id": file_id}
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=404, detail="Recording not found")
|
||||||
340
audio-studio-api/app/routers/sound_effects.py
Normal file
340
audio-studio-api/app/routers/sound_effects.py
Normal file
@ -0,0 +1,340 @@
|
|||||||
|
"""효과음 API 라우터
|
||||||
|
|
||||||
|
Freesound API 연동
|
||||||
|
"""
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Optional, List
|
||||||
|
|
||||||
|
from fastapi import APIRouter, HTTPException, Depends, Query
|
||||||
|
from fastapi.responses import Response
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
from app.database import Database, get_db
|
||||||
|
from app.services.freesound_client import freesound_client
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/v1/sound-effects", tags=["sound-effects"])
|
||||||
|
|
||||||
|
|
||||||
|
# ========================================
|
||||||
|
# Pydantic 모델
|
||||||
|
# ========================================
|
||||||
|
|
||||||
|
class SoundEffectResponse(BaseModel):
|
||||||
|
"""효과음 응답"""
|
||||||
|
id: str
|
||||||
|
freesound_id: Optional[int] = None
|
||||||
|
name: str
|
||||||
|
description: str
|
||||||
|
duration: float
|
||||||
|
tags: List[str] = []
|
||||||
|
preview_url: Optional[str] = None
|
||||||
|
license: str = ""
|
||||||
|
username: Optional[str] = None
|
||||||
|
source: str = "freesound" # freesound | local
|
||||||
|
|
||||||
|
|
||||||
|
class SoundEffectSearchResponse(BaseModel):
|
||||||
|
"""효과음 검색 응답"""
|
||||||
|
count: int
|
||||||
|
page: int
|
||||||
|
page_size: int
|
||||||
|
results: List[SoundEffectResponse]
|
||||||
|
|
||||||
|
|
||||||
|
class SoundEffectImportRequest(BaseModel):
|
||||||
|
"""효과음 가져오기 요청"""
|
||||||
|
freesound_id: int
|
||||||
|
|
||||||
|
|
||||||
|
# ========================================
|
||||||
|
# API 엔드포인트
|
||||||
|
# ========================================
|
||||||
|
|
||||||
|
@router.get("/search", response_model=SoundEffectSearchResponse)
|
||||||
|
async def search_sound_effects(
|
||||||
|
query: str = Query(..., min_length=1, description="검색어"),
|
||||||
|
page: int = Query(1, ge=1),
|
||||||
|
page_size: int = Query(20, ge=1, le=100),
|
||||||
|
min_duration: Optional[float] = Query(None, ge=0, description="최소 길이 (초)"),
|
||||||
|
max_duration: Optional[float] = Query(None, ge=0, description="최대 길이 (초)"),
|
||||||
|
sort: str = Query("score", description="정렬 (score, duration_asc, duration_desc)"),
|
||||||
|
):
|
||||||
|
"""Freesound에서 효과음 검색"""
|
||||||
|
try:
|
||||||
|
result = await freesound_client.search(
|
||||||
|
query=query,
|
||||||
|
page=page,
|
||||||
|
page_size=page_size,
|
||||||
|
min_duration=min_duration,
|
||||||
|
max_duration=max_duration,
|
||||||
|
sort=sort,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 응답 형식 변환
|
||||||
|
sounds = []
|
||||||
|
for item in result["results"]:
|
||||||
|
sounds.append(SoundEffectResponse(
|
||||||
|
id=f"fs_{item['freesound_id']}",
|
||||||
|
freesound_id=item["freesound_id"],
|
||||||
|
name=item["name"],
|
||||||
|
description=item["description"],
|
||||||
|
duration=item["duration"],
|
||||||
|
tags=item["tags"],
|
||||||
|
preview_url=item["preview_url"],
|
||||||
|
license=item["license"],
|
||||||
|
username=item.get("username"),
|
||||||
|
source="freesound",
|
||||||
|
))
|
||||||
|
|
||||||
|
return SoundEffectSearchResponse(
|
||||||
|
count=result["count"],
|
||||||
|
page=page,
|
||||||
|
page_size=page_size,
|
||||||
|
results=sounds,
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=f"Search failed: {str(e)}")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/library", response_model=SoundEffectSearchResponse)
|
||||||
|
async def list_local_sound_effects(
|
||||||
|
page: int = Query(1, ge=1),
|
||||||
|
page_size: int = Query(20, ge=1, le=100),
|
||||||
|
category: Optional[str] = Query(None, description="카테고리 필터"),
|
||||||
|
db: Database = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""로컬 효과음 라이브러리 조회"""
|
||||||
|
query = {}
|
||||||
|
if category:
|
||||||
|
query["categories"] = category
|
||||||
|
|
||||||
|
total = await db.sound_effects.count_documents(query)
|
||||||
|
skip = (page - 1) * page_size
|
||||||
|
|
||||||
|
cursor = db.sound_effects.find(query).sort("created_at", -1).skip(skip).limit(page_size)
|
||||||
|
|
||||||
|
sounds = []
|
||||||
|
async for doc in cursor:
|
||||||
|
sounds.append(SoundEffectResponse(
|
||||||
|
id=str(doc["_id"]),
|
||||||
|
freesound_id=doc.get("source_id"),
|
||||||
|
name=doc["name"],
|
||||||
|
description=doc.get("description", ""),
|
||||||
|
duration=doc.get("duration_seconds", 0),
|
||||||
|
tags=doc.get("tags", []),
|
||||||
|
preview_url=None, # 로컬 파일은 별도 엔드포인트로 제공
|
||||||
|
license=doc.get("license", ""),
|
||||||
|
source="local",
|
||||||
|
))
|
||||||
|
|
||||||
|
return SoundEffectSearchResponse(
|
||||||
|
count=total,
|
||||||
|
page=page,
|
||||||
|
page_size=page_size,
|
||||||
|
results=sounds,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/import", response_model=SoundEffectResponse)
|
||||||
|
async def import_sound_effect(
|
||||||
|
request: SoundEffectImportRequest,
|
||||||
|
db: Database = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Freesound에서 효과음 가져오기 (로컬 캐시)"""
|
||||||
|
try:
|
||||||
|
# Freesound에서 상세 정보 조회
|
||||||
|
sound_info = await freesound_client.get_sound(request.freesound_id)
|
||||||
|
|
||||||
|
# 프리뷰 다운로드
|
||||||
|
preview_url = sound_info.get("previews", {}).get("preview-hq-mp3", "")
|
||||||
|
if not preview_url:
|
||||||
|
raise HTTPException(status_code=400, detail="Preview not available")
|
||||||
|
|
||||||
|
audio_bytes = await freesound_client.download_preview(preview_url)
|
||||||
|
|
||||||
|
# GridFS에 저장
|
||||||
|
file_id = await db.save_audio(
|
||||||
|
audio_bytes,
|
||||||
|
f"sfx_{request.freesound_id}.mp3",
|
||||||
|
content_type="audio/mpeg",
|
||||||
|
metadata={"freesound_id": request.freesound_id},
|
||||||
|
)
|
||||||
|
|
||||||
|
# DB에 메타데이터 저장
|
||||||
|
now = datetime.utcnow()
|
||||||
|
doc = {
|
||||||
|
"name": sound_info.get("name", ""),
|
||||||
|
"description": sound_info.get("description", ""),
|
||||||
|
"source": "freesound",
|
||||||
|
"source_id": request.freesound_id,
|
||||||
|
"source_url": f"https://freesound.org/s/{request.freesound_id}/",
|
||||||
|
"audio_file_id": file_id,
|
||||||
|
"duration_seconds": sound_info.get("duration", 0),
|
||||||
|
"format": "mp3",
|
||||||
|
"categories": [],
|
||||||
|
"tags": sound_info.get("tags", [])[:20], # 최대 20개
|
||||||
|
"license": sound_info.get("license", ""),
|
||||||
|
"attribution": sound_info.get("username", ""),
|
||||||
|
"created_at": now,
|
||||||
|
"updated_at": now,
|
||||||
|
}
|
||||||
|
|
||||||
|
result = await db.sound_effects.insert_one(doc)
|
||||||
|
|
||||||
|
return SoundEffectResponse(
|
||||||
|
id=str(result.inserted_id),
|
||||||
|
freesound_id=request.freesound_id,
|
||||||
|
name=doc["name"],
|
||||||
|
description=doc["description"],
|
||||||
|
duration=doc["duration_seconds"],
|
||||||
|
tags=doc["tags"],
|
||||||
|
license=doc["license"],
|
||||||
|
source="local",
|
||||||
|
)
|
||||||
|
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=f"Import failed: {str(e)}")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{sound_id}")
|
||||||
|
async def get_sound_effect_info(
|
||||||
|
sound_id: str,
|
||||||
|
db: Database = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""효과음 상세 정보 조회"""
|
||||||
|
# Freesound ID인 경우
|
||||||
|
if sound_id.startswith("fs_"):
|
||||||
|
freesound_id = int(sound_id[3:])
|
||||||
|
try:
|
||||||
|
sound_info = await freesound_client.get_sound(freesound_id)
|
||||||
|
return SoundEffectResponse(
|
||||||
|
id=sound_id,
|
||||||
|
freesound_id=freesound_id,
|
||||||
|
name=sound_info.get("name", ""),
|
||||||
|
description=sound_info.get("description", ""),
|
||||||
|
duration=sound_info.get("duration", 0),
|
||||||
|
tags=sound_info.get("tags", []),
|
||||||
|
preview_url=sound_info.get("previews", {}).get("preview-hq-mp3", ""),
|
||||||
|
license=sound_info.get("license", ""),
|
||||||
|
source="freesound",
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=404, detail="Sound not found")
|
||||||
|
|
||||||
|
# 로컬 ID인 경우
|
||||||
|
from bson import ObjectId
|
||||||
|
try:
|
||||||
|
doc = await db.sound_effects.find_one({"_id": ObjectId(sound_id)})
|
||||||
|
except:
|
||||||
|
raise HTTPException(status_code=400, detail="Invalid sound ID")
|
||||||
|
|
||||||
|
if not doc:
|
||||||
|
raise HTTPException(status_code=404, detail="Sound not found")
|
||||||
|
|
||||||
|
return SoundEffectResponse(
|
||||||
|
id=str(doc["_id"]),
|
||||||
|
freesound_id=doc.get("source_id"),
|
||||||
|
name=doc["name"],
|
||||||
|
description=doc.get("description", ""),
|
||||||
|
duration=doc.get("duration_seconds", 0),
|
||||||
|
tags=doc.get("tags", []),
|
||||||
|
license=doc.get("license", ""),
|
||||||
|
source="local",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{sound_id}/audio")
|
||||||
|
async def get_sound_effect_audio(
|
||||||
|
sound_id: str,
|
||||||
|
db: Database = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""효과음 오디오 스트리밍"""
|
||||||
|
# Freesound ID인 경우 프리뷰 리다이렉트
|
||||||
|
if sound_id.startswith("fs_"):
|
||||||
|
freesound_id = int(sound_id[3:])
|
||||||
|
try:
|
||||||
|
sound_info = await freesound_client.get_sound(freesound_id)
|
||||||
|
preview_url = sound_info.get("previews", {}).get("preview-hq-mp3", "")
|
||||||
|
if preview_url:
|
||||||
|
audio_bytes = await freesound_client.download_preview(preview_url)
|
||||||
|
return Response(
|
||||||
|
content=audio_bytes,
|
||||||
|
media_type="audio/mpeg",
|
||||||
|
headers={"Content-Disposition": f'inline; filename="{freesound_id}.mp3"'},
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=404, detail="Audio not found")
|
||||||
|
|
||||||
|
# 로컬 ID인 경우
|
||||||
|
from bson import ObjectId
|
||||||
|
try:
|
||||||
|
doc = await db.sound_effects.find_one({"_id": ObjectId(sound_id)})
|
||||||
|
except:
|
||||||
|
raise HTTPException(status_code=400, detail="Invalid sound ID")
|
||||||
|
|
||||||
|
if not doc or not doc.get("audio_file_id"):
|
||||||
|
raise HTTPException(status_code=404, detail="Audio not found")
|
||||||
|
|
||||||
|
audio_bytes = await db.get_audio(doc["audio_file_id"])
|
||||||
|
content_type = "audio/mpeg" if doc.get("format") == "mp3" else "audio/wav"
|
||||||
|
|
||||||
|
return Response(
|
||||||
|
content=audio_bytes,
|
||||||
|
media_type=content_type,
|
||||||
|
headers={"Content-Disposition": f'inline; filename="{sound_id}.{doc.get("format", "wav")}"'},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/categories")
|
||||||
|
async def list_categories(
|
||||||
|
db: Database = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""효과음 카테고리 목록"""
|
||||||
|
# 로컬 라이브러리의 카테고리 집계
|
||||||
|
pipeline = [
|
||||||
|
{"$unwind": "$categories"},
|
||||||
|
{"$group": {"_id": "$categories", "count": {"$sum": 1}}},
|
||||||
|
{"$sort": {"count": -1}},
|
||||||
|
]
|
||||||
|
|
||||||
|
categories = []
|
||||||
|
async for doc in db.sound_effects.aggregate(pipeline):
|
||||||
|
categories.append({
|
||||||
|
"name": doc["_id"],
|
||||||
|
"count": doc["count"],
|
||||||
|
})
|
||||||
|
|
||||||
|
return {"categories": categories}
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{sound_id}")
|
||||||
|
async def delete_sound_effect(
|
||||||
|
sound_id: str,
|
||||||
|
db: Database = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""로컬 효과음 삭제"""
|
||||||
|
if sound_id.startswith("fs_"):
|
||||||
|
raise HTTPException(status_code=400, detail="Cannot delete Freesound reference")
|
||||||
|
|
||||||
|
from bson import ObjectId
|
||||||
|
try:
|
||||||
|
doc = await db.sound_effects.find_one({"_id": ObjectId(sound_id)})
|
||||||
|
except:
|
||||||
|
raise HTTPException(status_code=400, detail="Invalid sound ID")
|
||||||
|
|
||||||
|
if not doc:
|
||||||
|
raise HTTPException(status_code=404, detail="Sound not found")
|
||||||
|
|
||||||
|
# 오디오 파일 삭제
|
||||||
|
if doc.get("audio_file_id"):
|
||||||
|
await db.delete_audio(doc["audio_file_id"])
|
||||||
|
|
||||||
|
# 문서 삭제
|
||||||
|
await db.sound_effects.delete_one({"_id": ObjectId(sound_id)})
|
||||||
|
|
||||||
|
return {"status": "deleted", "sound_id": sound_id}
|
||||||
227
audio-studio-api/app/routers/tts.py
Normal file
227
audio-studio-api/app/routers/tts.py
Normal file
@ -0,0 +1,227 @@
|
|||||||
|
"""TTS API 라우터"""
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from fastapi import APIRouter, HTTPException, Depends
|
||||||
|
from fastapi.responses import Response, StreamingResponse
|
||||||
|
|
||||||
|
from app.database import Database, get_db
|
||||||
|
from app.models.voice import TTSSynthesizeRequest, TTSGenerationResponse, VoiceType
|
||||||
|
from app.services.tts_client import tts_client
|
||||||
|
from app.routers.voices import PRESET_VOICES
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/v1/tts", tags=["tts"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/synthesize")
|
||||||
|
async def synthesize(
|
||||||
|
request: TTSSynthesizeRequest,
|
||||||
|
db: Database = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""TTS 음성 합성
|
||||||
|
|
||||||
|
지정된 보이스로 텍스트를 음성으로 변환합니다.
|
||||||
|
"""
|
||||||
|
voice_id = request.voice_id
|
||||||
|
|
||||||
|
# 프리셋 보이스 확인
|
||||||
|
preset_speaker = None
|
||||||
|
for preset in PRESET_VOICES:
|
||||||
|
if preset["voice_id"] == voice_id:
|
||||||
|
preset_speaker = preset["preset_voice_id"]
|
||||||
|
break
|
||||||
|
|
||||||
|
if preset_speaker:
|
||||||
|
# 프리셋 음성 합성
|
||||||
|
try:
|
||||||
|
audio_bytes, sr = await tts_client.synthesize(
|
||||||
|
text=request.text,
|
||||||
|
speaker=preset_speaker,
|
||||||
|
language="ko",
|
||||||
|
instruct=request.instruct,
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=f"TTS synthesis failed: {str(e)}")
|
||||||
|
|
||||||
|
else:
|
||||||
|
# DB에서 보이스 정보 조회
|
||||||
|
voice_doc = await db.voices.find_one({"voice_id": voice_id})
|
||||||
|
if not voice_doc:
|
||||||
|
raise HTTPException(status_code=404, detail="Voice not found")
|
||||||
|
|
||||||
|
voice_type = voice_doc.get("type")
|
||||||
|
|
||||||
|
if voice_type == VoiceType.CLONED.value:
|
||||||
|
# Voice Clone 합성 (레퍼런스 오디오 필요)
|
||||||
|
ref_audio_id = voice_doc.get("reference_audio_id")
|
||||||
|
ref_transcript = voice_doc.get("reference_transcript", "")
|
||||||
|
|
||||||
|
if not ref_audio_id:
|
||||||
|
raise HTTPException(status_code=400, detail="Reference audio not found")
|
||||||
|
|
||||||
|
ref_audio = await db.get_audio(ref_audio_id)
|
||||||
|
|
||||||
|
try:
|
||||||
|
audio_bytes, sr = await tts_client.voice_clone(
|
||||||
|
text=request.text,
|
||||||
|
ref_audio=ref_audio,
|
||||||
|
ref_text=ref_transcript,
|
||||||
|
language=voice_doc.get("language", "ko"),
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=f"Voice clone synthesis failed: {str(e)}")
|
||||||
|
|
||||||
|
elif voice_type == VoiceType.DESIGNED.value:
|
||||||
|
# Voice Design 합성
|
||||||
|
design_prompt = voice_doc.get("design_prompt", "")
|
||||||
|
|
||||||
|
try:
|
||||||
|
audio_bytes, sr = await tts_client.voice_design(
|
||||||
|
text=request.text,
|
||||||
|
instruct=design_prompt,
|
||||||
|
language=voice_doc.get("language", "ko"),
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=f"Voice design synthesis failed: {str(e)}")
|
||||||
|
|
||||||
|
else:
|
||||||
|
raise HTTPException(status_code=400, detail=f"Unknown voice type: {voice_type}")
|
||||||
|
|
||||||
|
# 생성 기록 저장
|
||||||
|
generation_id = f"gen_{uuid.uuid4().hex[:12]}"
|
||||||
|
now = datetime.utcnow()
|
||||||
|
|
||||||
|
# 오디오 저장
|
||||||
|
audio_file_id = await db.save_audio(
|
||||||
|
audio_bytes,
|
||||||
|
f"{generation_id}.wav",
|
||||||
|
metadata={"voice_id": voice_id, "text": request.text[:100]},
|
||||||
|
)
|
||||||
|
|
||||||
|
# 생성 기록 저장
|
||||||
|
gen_doc = {
|
||||||
|
"generation_id": generation_id,
|
||||||
|
"voice_id": voice_id,
|
||||||
|
"text": request.text,
|
||||||
|
"audio_file_id": audio_file_id,
|
||||||
|
"status": "completed",
|
||||||
|
"created_at": now,
|
||||||
|
}
|
||||||
|
await db.tts_generations.insert_one(gen_doc)
|
||||||
|
|
||||||
|
return Response(
|
||||||
|
content=audio_bytes,
|
||||||
|
media_type="audio/wav",
|
||||||
|
headers={
|
||||||
|
"X-Sample-Rate": str(sr),
|
||||||
|
"X-Generation-ID": generation_id,
|
||||||
|
"Content-Disposition": f'attachment; filename="{generation_id}.wav"',
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/synthesize/async", response_model=TTSGenerationResponse)
|
||||||
|
async def synthesize_async(
|
||||||
|
request: TTSSynthesizeRequest,
|
||||||
|
db: Database = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""비동기 TTS 음성 합성 (긴 텍스트용)
|
||||||
|
|
||||||
|
생성 작업을 큐에 등록하고 generation_id를 반환합니다.
|
||||||
|
완료 후 /generations/{generation_id}/audio로 다운로드 가능합니다.
|
||||||
|
"""
|
||||||
|
# 긴 텍스트 처리를 위한 비동기 방식
|
||||||
|
# 현재는 동기 방식과 동일하게 처리 (추후 Redis 큐 연동)
|
||||||
|
|
||||||
|
generation_id = f"gen_{uuid.uuid4().hex[:12]}"
|
||||||
|
now = datetime.utcnow()
|
||||||
|
|
||||||
|
gen_doc = {
|
||||||
|
"generation_id": generation_id,
|
||||||
|
"voice_id": request.voice_id,
|
||||||
|
"text": request.text,
|
||||||
|
"status": "pending",
|
||||||
|
"created_at": now,
|
||||||
|
}
|
||||||
|
await db.tts_generations.insert_one(gen_doc)
|
||||||
|
|
||||||
|
# 실제로는 백그라운드 워커에서 처리해야 함
|
||||||
|
# 여기서는 바로 처리
|
||||||
|
try:
|
||||||
|
# synthesize 로직과 동일...
|
||||||
|
# (간소화를 위해 생략, 실제 구현 시 비동기 워커 사용)
|
||||||
|
pass
|
||||||
|
except Exception as e:
|
||||||
|
await db.tts_generations.update_one(
|
||||||
|
{"generation_id": generation_id},
|
||||||
|
{"$set": {"status": "failed", "error_message": str(e)}},
|
||||||
|
)
|
||||||
|
|
||||||
|
return TTSGenerationResponse(
|
||||||
|
generation_id=generation_id,
|
||||||
|
voice_id=request.voice_id,
|
||||||
|
text=request.text,
|
||||||
|
status="pending",
|
||||||
|
created_at=now,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/generations/{generation_id}", response_model=TTSGenerationResponse)
|
||||||
|
async def get_generation(
|
||||||
|
generation_id: str,
|
||||||
|
db: Database = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""TTS 생성 상태 조회"""
|
||||||
|
doc = await db.tts_generations.find_one({"generation_id": generation_id})
|
||||||
|
if not doc:
|
||||||
|
raise HTTPException(status_code=404, detail="Generation not found")
|
||||||
|
|
||||||
|
return TTSGenerationResponse(
|
||||||
|
generation_id=doc["generation_id"],
|
||||||
|
voice_id=doc["voice_id"],
|
||||||
|
text=doc["text"],
|
||||||
|
status=doc["status"],
|
||||||
|
audio_file_id=str(doc.get("audio_file_id")) if doc.get("audio_file_id") else None,
|
||||||
|
duration_seconds=doc.get("duration_seconds"),
|
||||||
|
created_at=doc["created_at"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/generations/{generation_id}/audio")
|
||||||
|
async def get_generation_audio(
|
||||||
|
generation_id: str,
|
||||||
|
db: Database = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""생성된 오디오 다운로드"""
|
||||||
|
doc = await db.tts_generations.find_one({"generation_id": generation_id})
|
||||||
|
if not doc:
|
||||||
|
raise HTTPException(status_code=404, detail="Generation not found")
|
||||||
|
|
||||||
|
if doc["status"] != "completed":
|
||||||
|
raise HTTPException(status_code=400, detail=f"Generation not completed: {doc['status']}")
|
||||||
|
|
||||||
|
audio_file_id = doc.get("audio_file_id")
|
||||||
|
if not audio_file_id:
|
||||||
|
raise HTTPException(status_code=404, detail="Audio file not found")
|
||||||
|
|
||||||
|
audio_bytes = await db.get_audio(audio_file_id)
|
||||||
|
|
||||||
|
return Response(
|
||||||
|
content=audio_bytes,
|
||||||
|
media_type="audio/wav",
|
||||||
|
headers={
|
||||||
|
"Content-Disposition": f'attachment; filename="{generation_id}.wav"',
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/health")
|
||||||
|
async def tts_health():
|
||||||
|
"""TTS 엔진 헬스체크"""
|
||||||
|
try:
|
||||||
|
health = await tts_client.health_check()
|
||||||
|
return {"status": "healthy", "tts_engine": health}
|
||||||
|
except Exception as e:
|
||||||
|
return {"status": "unhealthy", "error": str(e)}
|
||||||
426
audio-studio-api/app/routers/voices.py
Normal file
426
audio-studio-api/app/routers/voices.py
Normal file
@ -0,0 +1,426 @@
|
|||||||
|
"""Voice 관리 API 라우터"""
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Optional, List
|
||||||
|
|
||||||
|
from fastapi import APIRouter, HTTPException, Depends, Query, UploadFile, File, Form
|
||||||
|
from fastapi.responses import Response
|
||||||
|
|
||||||
|
from app.database import Database, get_db
|
||||||
|
from app.models.voice import (
|
||||||
|
VoiceType,
|
||||||
|
LanguageCode,
|
||||||
|
VoiceResponse,
|
||||||
|
VoiceListResponse,
|
||||||
|
VoiceCloneRequest,
|
||||||
|
VoiceDesignRequest,
|
||||||
|
VoiceUpdateRequest,
|
||||||
|
)
|
||||||
|
from app.services.tts_client import tts_client
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/v1/voices", tags=["voices"])
|
||||||
|
|
||||||
|
|
||||||
|
# ========================================
|
||||||
|
# 프리셋 보이스 목록 (시스템 기본)
|
||||||
|
# ========================================
|
||||||
|
|
||||||
|
PRESET_VOICES = [
|
||||||
|
{
|
||||||
|
"voice_id": "preset_chelsie",
|
||||||
|
"name": "Chelsie",
|
||||||
|
"description": "밝고 활기찬 여성 목소리",
|
||||||
|
"type": VoiceType.PRESET,
|
||||||
|
"preset_voice_id": "Chelsie",
|
||||||
|
"language": LanguageCode.EN,
|
||||||
|
"gender": "female",
|
||||||
|
"style_tags": ["bright", "energetic"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"voice_id": "preset_ethan",
|
||||||
|
"name": "Ethan",
|
||||||
|
"description": "차분하고 신뢰감 있는 남성 목소리",
|
||||||
|
"type": VoiceType.PRESET,
|
||||||
|
"preset_voice_id": "Ethan",
|
||||||
|
"language": LanguageCode.EN,
|
||||||
|
"gender": "male",
|
||||||
|
"style_tags": ["calm", "trustworthy"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"voice_id": "preset_vivian",
|
||||||
|
"name": "Vivian",
|
||||||
|
"description": "부드럽고 따뜻한 여성 목소리",
|
||||||
|
"type": VoiceType.PRESET,
|
||||||
|
"preset_voice_id": "Vivian",
|
||||||
|
"language": LanguageCode.EN,
|
||||||
|
"gender": "female",
|
||||||
|
"style_tags": ["soft", "warm"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"voice_id": "preset_benjamin",
|
||||||
|
"name": "Benjamin",
|
||||||
|
"description": "깊고 전문적인 남성 목소리",
|
||||||
|
"type": VoiceType.PRESET,
|
||||||
|
"preset_voice_id": "Benjamin",
|
||||||
|
"language": LanguageCode.EN,
|
||||||
|
"gender": "male",
|
||||||
|
"style_tags": ["deep", "professional"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"voice_id": "preset_aurora",
|
||||||
|
"name": "Aurora",
|
||||||
|
"description": "우아하고 세련된 여성 목소리",
|
||||||
|
"type": VoiceType.PRESET,
|
||||||
|
"preset_voice_id": "Aurora",
|
||||||
|
"language": LanguageCode.EN,
|
||||||
|
"gender": "female",
|
||||||
|
"style_tags": ["elegant", "refined"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"voice_id": "preset_oliver",
|
||||||
|
"name": "Oliver",
|
||||||
|
"description": "친근하고 편안한 남성 목소리",
|
||||||
|
"type": VoiceType.PRESET,
|
||||||
|
"preset_voice_id": "Oliver",
|
||||||
|
"language": LanguageCode.EN,
|
||||||
|
"gender": "male",
|
||||||
|
"style_tags": ["friendly", "casual"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"voice_id": "preset_luna",
|
||||||
|
"name": "Luna",
|
||||||
|
"description": "따뜻하고 감성적인 여성 목소리",
|
||||||
|
"type": VoiceType.PRESET,
|
||||||
|
"preset_voice_id": "Luna",
|
||||||
|
"language": LanguageCode.EN,
|
||||||
|
"gender": "female",
|
||||||
|
"style_tags": ["warm", "emotional"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"voice_id": "preset_jasper",
|
||||||
|
"name": "Jasper",
|
||||||
|
"description": "전문적이고 명확한 남성 목소리",
|
||||||
|
"type": VoiceType.PRESET,
|
||||||
|
"preset_voice_id": "Jasper",
|
||||||
|
"language": LanguageCode.EN,
|
||||||
|
"gender": "male",
|
||||||
|
"style_tags": ["professional", "clear"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"voice_id": "preset_aria",
|
||||||
|
"name": "Aria",
|
||||||
|
"description": "표현력 풍부한 여성 목소리",
|
||||||
|
"type": VoiceType.PRESET,
|
||||||
|
"preset_voice_id": "Aria",
|
||||||
|
"language": LanguageCode.EN,
|
||||||
|
"gender": "female",
|
||||||
|
"style_tags": ["expressive", "dynamic"],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _voice_doc_to_response(doc: dict) -> VoiceResponse:
|
||||||
|
"""MongoDB 문서를 VoiceResponse로 변환"""
|
||||||
|
return VoiceResponse(
|
||||||
|
voice_id=doc["voice_id"],
|
||||||
|
name=doc["name"],
|
||||||
|
description=doc.get("description"),
|
||||||
|
type=doc["type"],
|
||||||
|
language=doc.get("language", LanguageCode.KO),
|
||||||
|
preset_voice_id=doc.get("preset_voice_id"),
|
||||||
|
design_prompt=doc.get("design_prompt"),
|
||||||
|
reference_transcript=doc.get("reference_transcript"),
|
||||||
|
gender=doc.get("gender"),
|
||||||
|
age_range=doc.get("age_range"),
|
||||||
|
style_tags=doc.get("style_tags", []),
|
||||||
|
owner_id=str(doc.get("owner_id")) if doc.get("owner_id") else None,
|
||||||
|
is_public=doc.get("is_public", True),
|
||||||
|
sample_audio_id=str(doc.get("sample_audio_id")) if doc.get("sample_audio_id") else None,
|
||||||
|
created_at=doc.get("created_at", datetime.utcnow()),
|
||||||
|
updated_at=doc.get("updated_at", datetime.utcnow()),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=VoiceListResponse)
|
||||||
|
async def list_voices(
|
||||||
|
type: Optional[VoiceType] = Query(None, description="보이스 타입 필터"),
|
||||||
|
language: Optional[LanguageCode] = Query(None, description="언어 필터"),
|
||||||
|
is_public: bool = Query(True, description="공개 보이스만"),
|
||||||
|
include_presets: bool = Query(True, description="프리셋 포함"),
|
||||||
|
page: int = Query(1, ge=1),
|
||||||
|
page_size: int = Query(20, ge=1, le=100),
|
||||||
|
db: Database = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""보이스 목록 조회"""
|
||||||
|
voices = []
|
||||||
|
|
||||||
|
# 프리셋 보이스 추가
|
||||||
|
if include_presets and (type is None or type == VoiceType.PRESET):
|
||||||
|
for preset in PRESET_VOICES:
|
||||||
|
if language and preset["language"] != language:
|
||||||
|
continue
|
||||||
|
voices.append(VoiceResponse(
|
||||||
|
**preset,
|
||||||
|
is_public=True,
|
||||||
|
created_at=datetime.utcnow(),
|
||||||
|
updated_at=datetime.utcnow(),
|
||||||
|
))
|
||||||
|
|
||||||
|
# DB에서 사용자 보이스 조회
|
||||||
|
query = {"is_public": True} if is_public else {}
|
||||||
|
if type and type != VoiceType.PRESET:
|
||||||
|
query["type"] = type.value
|
||||||
|
if language:
|
||||||
|
query["language"] = language.value
|
||||||
|
|
||||||
|
cursor = db.voices.find(query).sort("created_at", -1)
|
||||||
|
skip = (page - 1) * page_size
|
||||||
|
cursor = cursor.skip(skip).limit(page_size)
|
||||||
|
|
||||||
|
async for doc in cursor:
|
||||||
|
voices.append(_voice_doc_to_response(doc))
|
||||||
|
|
||||||
|
total = len(PRESET_VOICES) + await db.voices.count_documents(query)
|
||||||
|
|
||||||
|
return VoiceListResponse(
|
||||||
|
voices=voices,
|
||||||
|
total=total,
|
||||||
|
page=page,
|
||||||
|
page_size=page_size,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{voice_id}", response_model=VoiceResponse)
|
||||||
|
async def get_voice(
|
||||||
|
voice_id: str,
|
||||||
|
db: Database = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""보이스 상세 조회"""
|
||||||
|
# 프리셋 체크
|
||||||
|
for preset in PRESET_VOICES:
|
||||||
|
if preset["voice_id"] == voice_id:
|
||||||
|
return VoiceResponse(
|
||||||
|
**preset,
|
||||||
|
is_public=True,
|
||||||
|
created_at=datetime.utcnow(),
|
||||||
|
updated_at=datetime.utcnow(),
|
||||||
|
)
|
||||||
|
|
||||||
|
# DB 조회
|
||||||
|
doc = await db.voices.find_one({"voice_id": voice_id})
|
||||||
|
if not doc:
|
||||||
|
raise HTTPException(status_code=404, detail="Voice not found")
|
||||||
|
|
||||||
|
return _voice_doc_to_response(doc)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{voice_id}/sample")
|
||||||
|
async def get_voice_sample(
|
||||||
|
voice_id: str,
|
||||||
|
db: Database = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""보이스 샘플 오디오 스트리밍"""
|
||||||
|
# 프리셋인 경우 TTS로 샘플 생성
|
||||||
|
for preset in PRESET_VOICES:
|
||||||
|
if preset["voice_id"] == voice_id:
|
||||||
|
sample_text = "안녕하세요, 저는 AI 음성입니다."
|
||||||
|
audio_bytes, sr = await tts_client.synthesize(
|
||||||
|
text=sample_text,
|
||||||
|
speaker=preset["preset_voice_id"],
|
||||||
|
language="ko",
|
||||||
|
)
|
||||||
|
return Response(
|
||||||
|
content=audio_bytes,
|
||||||
|
media_type="audio/wav",
|
||||||
|
headers={"Content-Disposition": f'inline; filename="{voice_id}_sample.wav"'},
|
||||||
|
)
|
||||||
|
|
||||||
|
# DB에서 조회
|
||||||
|
doc = await db.voices.find_one({"voice_id": voice_id})
|
||||||
|
if not doc:
|
||||||
|
raise HTTPException(status_code=404, detail="Voice not found")
|
||||||
|
|
||||||
|
if not doc.get("sample_audio_id"):
|
||||||
|
raise HTTPException(status_code=404, detail="No sample audio available")
|
||||||
|
|
||||||
|
audio_bytes = await db.get_audio(doc["sample_audio_id"])
|
||||||
|
return Response(
|
||||||
|
content=audio_bytes,
|
||||||
|
media_type="audio/wav",
|
||||||
|
headers={"Content-Disposition": f'inline; filename="{voice_id}_sample.wav"'},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/clone", response_model=VoiceResponse)
|
||||||
|
async def create_voice_clone(
|
||||||
|
name: str = Form(...),
|
||||||
|
description: Optional[str] = Form(None),
|
||||||
|
reference_transcript: str = Form(...),
|
||||||
|
language: LanguageCode = Form(LanguageCode.KO),
|
||||||
|
is_public: bool = Form(False),
|
||||||
|
reference_audio: UploadFile = File(...),
|
||||||
|
db: Database = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Voice Clone으로 새 보이스 생성
|
||||||
|
|
||||||
|
레퍼런스 오디오를 기반으로 목소리를 복제합니다.
|
||||||
|
3초 이상의 오디오가 권장됩니다.
|
||||||
|
"""
|
||||||
|
# 오디오 파일 읽기
|
||||||
|
audio_content = await reference_audio.read()
|
||||||
|
|
||||||
|
# Voice Clone으로 샘플 생성
|
||||||
|
sample_text = "안녕하세요, 저는 복제된 AI 음성입니다."
|
||||||
|
try:
|
||||||
|
sample_audio, sr = await tts_client.voice_clone(
|
||||||
|
text=sample_text,
|
||||||
|
ref_audio=audio_content,
|
||||||
|
ref_text=reference_transcript,
|
||||||
|
language=language.value,
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=f"Voice clone failed: {str(e)}")
|
||||||
|
|
||||||
|
# GridFS에 오디오 저장
|
||||||
|
ref_audio_id = await db.save_audio(
|
||||||
|
audio_content,
|
||||||
|
f"ref_{uuid.uuid4()}.wav",
|
||||||
|
metadata={"type": "reference"},
|
||||||
|
)
|
||||||
|
sample_audio_id = await db.save_audio(
|
||||||
|
sample_audio,
|
||||||
|
f"sample_{uuid.uuid4()}.wav",
|
||||||
|
metadata={"type": "sample"},
|
||||||
|
)
|
||||||
|
|
||||||
|
# DB에 보이스 저장
|
||||||
|
voice_id = f"clone_{uuid.uuid4().hex[:12]}"
|
||||||
|
now = datetime.utcnow()
|
||||||
|
|
||||||
|
doc = {
|
||||||
|
"voice_id": voice_id,
|
||||||
|
"name": name,
|
||||||
|
"description": description,
|
||||||
|
"type": VoiceType.CLONED.value,
|
||||||
|
"language": language.value,
|
||||||
|
"reference_audio_id": ref_audio_id,
|
||||||
|
"reference_transcript": reference_transcript,
|
||||||
|
"sample_audio_id": sample_audio_id,
|
||||||
|
"is_public": is_public,
|
||||||
|
"created_at": now,
|
||||||
|
"updated_at": now,
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.voices.insert_one(doc)
|
||||||
|
|
||||||
|
return _voice_doc_to_response(doc)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/design", response_model=VoiceResponse)
|
||||||
|
async def create_voice_design(
|
||||||
|
request: VoiceDesignRequest,
|
||||||
|
db: Database = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Voice Design으로 새 보이스 생성
|
||||||
|
|
||||||
|
텍스트 프롬프트를 기반으로 새로운 음성을 생성합니다.
|
||||||
|
예: "30대 남성, 부드럽고 차분한 목소리"
|
||||||
|
"""
|
||||||
|
# Voice Design으로 샘플 생성
|
||||||
|
sample_text = "안녕하세요, 저는 AI로 생성된 음성입니다."
|
||||||
|
try:
|
||||||
|
sample_audio, sr = await tts_client.voice_design(
|
||||||
|
text=sample_text,
|
||||||
|
instruct=request.design_prompt,
|
||||||
|
language=request.language.value,
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=f"Voice design failed: {str(e)}")
|
||||||
|
|
||||||
|
# GridFS에 샘플 저장
|
||||||
|
sample_audio_id = await db.save_audio(
|
||||||
|
sample_audio,
|
||||||
|
f"sample_{uuid.uuid4()}.wav",
|
||||||
|
metadata={"type": "sample"},
|
||||||
|
)
|
||||||
|
|
||||||
|
# DB에 보이스 저장
|
||||||
|
voice_id = f"design_{uuid.uuid4().hex[:12]}"
|
||||||
|
now = datetime.utcnow()
|
||||||
|
|
||||||
|
doc = {
|
||||||
|
"voice_id": voice_id,
|
||||||
|
"name": request.name,
|
||||||
|
"description": request.description,
|
||||||
|
"type": VoiceType.DESIGNED.value,
|
||||||
|
"language": request.language.value,
|
||||||
|
"design_prompt": request.design_prompt,
|
||||||
|
"sample_audio_id": sample_audio_id,
|
||||||
|
"is_public": request.is_public,
|
||||||
|
"created_at": now,
|
||||||
|
"updated_at": now,
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.voices.insert_one(doc)
|
||||||
|
|
||||||
|
return _voice_doc_to_response(doc)
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/{voice_id}", response_model=VoiceResponse)
|
||||||
|
async def update_voice(
|
||||||
|
voice_id: str,
|
||||||
|
request: VoiceUpdateRequest,
|
||||||
|
db: Database = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""보이스 정보 수정"""
|
||||||
|
# 프리셋은 수정 불가
|
||||||
|
for preset in PRESET_VOICES:
|
||||||
|
if preset["voice_id"] == voice_id:
|
||||||
|
raise HTTPException(status_code=400, detail="Cannot modify preset voice")
|
||||||
|
|
||||||
|
# 업데이트할 필드만 추출
|
||||||
|
update_data = {k: v for k, v in request.model_dump().items() if v is not None}
|
||||||
|
if not update_data:
|
||||||
|
raise HTTPException(status_code=400, detail="No fields to update")
|
||||||
|
|
||||||
|
update_data["updated_at"] = datetime.utcnow()
|
||||||
|
|
||||||
|
result = await db.voices.update_one(
|
||||||
|
{"voice_id": voice_id},
|
||||||
|
{"$set": update_data},
|
||||||
|
)
|
||||||
|
|
||||||
|
if result.matched_count == 0:
|
||||||
|
raise HTTPException(status_code=404, detail="Voice not found")
|
||||||
|
|
||||||
|
doc = await db.voices.find_one({"voice_id": voice_id})
|
||||||
|
return _voice_doc_to_response(doc)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{voice_id}")
|
||||||
|
async def delete_voice(
|
||||||
|
voice_id: str,
|
||||||
|
db: Database = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""보이스 삭제"""
|
||||||
|
# 프리셋은 삭제 불가
|
||||||
|
for preset in PRESET_VOICES:
|
||||||
|
if preset["voice_id"] == voice_id:
|
||||||
|
raise HTTPException(status_code=400, detail="Cannot delete preset voice")
|
||||||
|
|
||||||
|
# 먼저 조회
|
||||||
|
doc = await db.voices.find_one({"voice_id": voice_id})
|
||||||
|
if not doc:
|
||||||
|
raise HTTPException(status_code=404, detail="Voice not found")
|
||||||
|
|
||||||
|
# 관련 오디오 파일 삭제
|
||||||
|
if doc.get("reference_audio_id"):
|
||||||
|
await db.delete_audio(doc["reference_audio_id"])
|
||||||
|
if doc.get("sample_audio_id"):
|
||||||
|
await db.delete_audio(doc["sample_audio_id"])
|
||||||
|
|
||||||
|
# 보이스 삭제
|
||||||
|
await db.voices.delete_one({"voice_id": voice_id})
|
||||||
|
|
||||||
|
return {"status": "deleted", "voice_id": voice_id}
|
||||||
0
audio-studio-api/app/services/__init__.py
Normal file
0
audio-studio-api/app/services/__init__.py
Normal file
260
audio-studio-api/app/services/audio_mixer.py
Normal file
260
audio-studio-api/app/services/audio_mixer.py
Normal file
@ -0,0 +1,260 @@
|
|||||||
|
# 오디오 믹서 서비스
|
||||||
|
# pydub를 사용한 오디오 합성/믹싱
|
||||||
|
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
from typing import Optional
|
||||||
|
from pydub import AudioSegment
|
||||||
|
from pydub.effects import normalize
|
||||||
|
from app.models.drama import TimelineItem
|
||||||
|
|
||||||
|
|
||||||
|
class AudioMixer:
|
||||||
|
"""
|
||||||
|
오디오 믹서
|
||||||
|
|
||||||
|
기능:
|
||||||
|
- 여러 오디오 트랙 합성
|
||||||
|
- 볼륨 조절
|
||||||
|
- 페이드 인/아웃
|
||||||
|
- 타임라인 기반 믹싱
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, sample_rate: int = 44100):
|
||||||
|
self.sample_rate = sample_rate
|
||||||
|
|
||||||
|
def load_audio(self, file_path: str) -> AudioSegment:
|
||||||
|
"""오디오 파일 로드"""
|
||||||
|
return AudioSegment.from_file(file_path)
|
||||||
|
|
||||||
|
def adjust_volume(self, audio: AudioSegment, volume: float) -> AudioSegment:
|
||||||
|
"""볼륨 조절 (0.0 ~ 2.0, 1.0 = 원본)"""
|
||||||
|
if volume == 1.0:
|
||||||
|
return audio
|
||||||
|
# dB 변환: 0.5 = -6dB, 2.0 = +6dB
|
||||||
|
db_change = 20 * (volume ** 0.5 - 1) if volume > 0 else -120
|
||||||
|
return audio + db_change
|
||||||
|
|
||||||
|
def apply_fade(
|
||||||
|
self,
|
||||||
|
audio: AudioSegment,
|
||||||
|
fade_in_ms: int = 0,
|
||||||
|
fade_out_ms: int = 0
|
||||||
|
) -> AudioSegment:
|
||||||
|
"""페이드 인/아웃 적용"""
|
||||||
|
if fade_in_ms > 0:
|
||||||
|
audio = audio.fade_in(fade_in_ms)
|
||||||
|
if fade_out_ms > 0:
|
||||||
|
audio = audio.fade_out(fade_out_ms)
|
||||||
|
return audio
|
||||||
|
|
||||||
|
def concatenate(self, segments: list[AudioSegment]) -> AudioSegment:
|
||||||
|
"""오디오 세그먼트 연결"""
|
||||||
|
if not segments:
|
||||||
|
return AudioSegment.silent(duration=0)
|
||||||
|
|
||||||
|
result = segments[0]
|
||||||
|
for segment in segments[1:]:
|
||||||
|
result += segment
|
||||||
|
return result
|
||||||
|
|
||||||
|
def overlay(
|
||||||
|
self,
|
||||||
|
base: AudioSegment,
|
||||||
|
overlay_audio: AudioSegment,
|
||||||
|
position_ms: int = 0
|
||||||
|
) -> AudioSegment:
|
||||||
|
"""오디오 오버레이 (배경음악 위에 보이스 등)"""
|
||||||
|
return base.overlay(overlay_audio, position=position_ms)
|
||||||
|
|
||||||
|
def create_silence(self, duration_ms: int) -> AudioSegment:
|
||||||
|
"""무음 생성"""
|
||||||
|
return AudioSegment.silent(duration=duration_ms)
|
||||||
|
|
||||||
|
def mix_timeline(
|
||||||
|
self,
|
||||||
|
timeline: list[TimelineItem],
|
||||||
|
audio_files: dict[str, str] # audio_path -> 실제 파일 경로
|
||||||
|
) -> AudioSegment:
|
||||||
|
"""
|
||||||
|
타임라인 기반 믹싱
|
||||||
|
|
||||||
|
Args:
|
||||||
|
timeline: 타임라인 아이템 리스트
|
||||||
|
audio_files: 오디오 경로 매핑
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
믹싱된 오디오
|
||||||
|
"""
|
||||||
|
if not timeline:
|
||||||
|
return AudioSegment.silent(duration=1000)
|
||||||
|
|
||||||
|
# 전체 길이 계산
|
||||||
|
total_duration_ms = max(
|
||||||
|
int((item.start_time + item.duration) * 1000)
|
||||||
|
for item in timeline
|
||||||
|
)
|
||||||
|
|
||||||
|
# 트랙별 분리 (voice, music, sfx)
|
||||||
|
voice_track = AudioSegment.silent(duration=total_duration_ms)
|
||||||
|
music_track = AudioSegment.silent(duration=total_duration_ms)
|
||||||
|
sfx_track = AudioSegment.silent(duration=total_duration_ms)
|
||||||
|
|
||||||
|
for item in timeline:
|
||||||
|
if not item.audio_path or item.audio_path not in audio_files:
|
||||||
|
continue
|
||||||
|
|
||||||
|
file_path = audio_files[item.audio_path]
|
||||||
|
if not os.path.exists(file_path):
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 오디오 로드 및 처리
|
||||||
|
audio = self.load_audio(file_path)
|
||||||
|
|
||||||
|
# 볼륨 조절
|
||||||
|
audio = self.adjust_volume(audio, item.volume)
|
||||||
|
|
||||||
|
# 페이드 적용
|
||||||
|
fade_in_ms = int(item.fade_in * 1000)
|
||||||
|
fade_out_ms = int(item.fade_out * 1000)
|
||||||
|
audio = self.apply_fade(audio, fade_in_ms, fade_out_ms)
|
||||||
|
|
||||||
|
# 위치 계산
|
||||||
|
position_ms = int(item.start_time * 1000)
|
||||||
|
|
||||||
|
# 트랙에 오버레이
|
||||||
|
if item.type == "voice":
|
||||||
|
voice_track = voice_track.overlay(audio, position=position_ms)
|
||||||
|
elif item.type == "music":
|
||||||
|
music_track = music_track.overlay(audio, position=position_ms)
|
||||||
|
elif item.type == "sfx":
|
||||||
|
sfx_track = sfx_track.overlay(audio, position=position_ms)
|
||||||
|
|
||||||
|
# 트랙 믹싱 (music -> sfx -> voice 순서로 레이어링)
|
||||||
|
mixed = music_track.overlay(sfx_track).overlay(voice_track)
|
||||||
|
|
||||||
|
return mixed
|
||||||
|
|
||||||
|
def auto_duck(
|
||||||
|
self,
|
||||||
|
music: AudioSegment,
|
||||||
|
voice: AudioSegment,
|
||||||
|
duck_amount_db: float = -10,
|
||||||
|
attack_ms: int = 100,
|
||||||
|
release_ms: int = 300
|
||||||
|
) -> AudioSegment:
|
||||||
|
"""
|
||||||
|
Auto-ducking: 보이스가 나올 때 음악 볼륨 자동 감소
|
||||||
|
|
||||||
|
간단한 구현 - 보이스가 있는 구간에서 음악 볼륨 낮춤
|
||||||
|
"""
|
||||||
|
# 보이스 길이에 맞춰 음악 조절
|
||||||
|
if len(music) < len(voice):
|
||||||
|
music = music + AudioSegment.silent(duration=len(voice) - len(music))
|
||||||
|
|
||||||
|
# 보이스의 무음/유음 구간 감지 (간단한 RMS 기반)
|
||||||
|
chunk_ms = 50
|
||||||
|
ducked_music = AudioSegment.silent(duration=0)
|
||||||
|
|
||||||
|
for i in range(0, len(voice), chunk_ms):
|
||||||
|
voice_chunk = voice[i:i + chunk_ms]
|
||||||
|
music_chunk = music[i:i + chunk_ms]
|
||||||
|
|
||||||
|
# 보이스 RMS가 임계값 이상이면 ducking
|
||||||
|
if voice_chunk.rms > 100: # 임계값 조정 가능
|
||||||
|
music_chunk = music_chunk + duck_amount_db
|
||||||
|
|
||||||
|
ducked_music += music_chunk
|
||||||
|
|
||||||
|
return ducked_music
|
||||||
|
|
||||||
|
def export(
|
||||||
|
self,
|
||||||
|
audio: AudioSegment,
|
||||||
|
output_path: str,
|
||||||
|
format: str = "wav",
|
||||||
|
normalize_audio: bool = True
|
||||||
|
) -> str:
|
||||||
|
"""
|
||||||
|
오디오 내보내기
|
||||||
|
|
||||||
|
Args:
|
||||||
|
audio: 오디오 세그먼트
|
||||||
|
output_path: 출력 파일 경로
|
||||||
|
format: 출력 포맷 (wav, mp3)
|
||||||
|
normalize_audio: 노멀라이즈 여부
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
저장된 파일 경로
|
||||||
|
"""
|
||||||
|
if normalize_audio:
|
||||||
|
audio = normalize(audio)
|
||||||
|
|
||||||
|
# 포맷별 설정
|
||||||
|
export_params = {}
|
||||||
|
if format == "mp3":
|
||||||
|
export_params = {"format": "mp3", "bitrate": "192k"}
|
||||||
|
else:
|
||||||
|
export_params = {"format": "wav"}
|
||||||
|
|
||||||
|
audio.export(output_path, **export_params)
|
||||||
|
return output_path
|
||||||
|
|
||||||
|
def create_with_background(
|
||||||
|
self,
|
||||||
|
voice_segments: list[tuple[AudioSegment, float]], # (audio, start_time)
|
||||||
|
background_music: Optional[AudioSegment] = None,
|
||||||
|
music_volume: float = 0.3,
|
||||||
|
gap_between_lines_ms: int = 500
|
||||||
|
) -> AudioSegment:
|
||||||
|
"""
|
||||||
|
보이스 + 배경음악 간단 합성
|
||||||
|
|
||||||
|
Args:
|
||||||
|
voice_segments: (오디오, 시작시간) 튜플 리스트
|
||||||
|
background_music: 배경음악 (없으면 무음)
|
||||||
|
music_volume: 배경음악 볼륨
|
||||||
|
gap_between_lines_ms: 대사 간 간격
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
합성된 오디오
|
||||||
|
"""
|
||||||
|
if not voice_segments:
|
||||||
|
return AudioSegment.silent(duration=1000)
|
||||||
|
|
||||||
|
# 전체 보이스 트랙 생성
|
||||||
|
voice_track = AudioSegment.silent(duration=0)
|
||||||
|
for audio, start_time in voice_segments:
|
||||||
|
# 시작 위치까지 무음 추가
|
||||||
|
current_pos = len(voice_track)
|
||||||
|
target_pos = int(start_time * 1000)
|
||||||
|
if target_pos > current_pos:
|
||||||
|
voice_track += AudioSegment.silent(duration=target_pos - current_pos)
|
||||||
|
voice_track += audio
|
||||||
|
voice_track += AudioSegment.silent(duration=gap_between_lines_ms)
|
||||||
|
|
||||||
|
total_duration = len(voice_track)
|
||||||
|
|
||||||
|
# 배경음악 처리
|
||||||
|
if background_music:
|
||||||
|
# 음악 길이 조정
|
||||||
|
if len(background_music) < total_duration:
|
||||||
|
# 루프
|
||||||
|
loops_needed = (total_duration // len(background_music)) + 1
|
||||||
|
background_music = background_music * loops_needed
|
||||||
|
background_music = background_music[:total_duration]
|
||||||
|
|
||||||
|
# 볼륨 조절
|
||||||
|
background_music = self.adjust_volume(background_music, music_volume)
|
||||||
|
|
||||||
|
# Auto-ducking 적용
|
||||||
|
background_music = self.auto_duck(background_music, voice_track)
|
||||||
|
|
||||||
|
# 믹싱
|
||||||
|
return background_music.overlay(voice_track)
|
||||||
|
else:
|
||||||
|
return voice_track
|
||||||
|
|
||||||
|
|
||||||
|
# 싱글톤 인스턴스
|
||||||
|
audio_mixer = AudioMixer()
|
||||||
362
audio-studio-api/app/services/drama_orchestrator.py
Normal file
362
audio-studio-api/app/services/drama_orchestrator.py
Normal file
@ -0,0 +1,362 @@
|
|||||||
|
# 드라마 오케스트레이터
|
||||||
|
# 스크립트 파싱 → 에셋 생성 → 타임라인 구성 → 믹싱 조율
|
||||||
|
|
||||||
|
import os
|
||||||
|
import uuid
|
||||||
|
import asyncio
|
||||||
|
import tempfile
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Optional
|
||||||
|
from pydub import AudioSegment
|
||||||
|
|
||||||
|
from app.models.drama import (
|
||||||
|
ParsedScript, ScriptElement, ElementType, Character,
|
||||||
|
TimelineItem, DramaProject, DramaCreateRequest
|
||||||
|
)
|
||||||
|
from app.services.script_parser import script_parser
|
||||||
|
from app.services.audio_mixer import audio_mixer
|
||||||
|
from app.services.tts_client import tts_client
|
||||||
|
from app.services.freesound_client import freesound_client
|
||||||
|
from app.database import db
|
||||||
|
|
||||||
|
|
||||||
|
class DramaOrchestrator:
|
||||||
|
"""
|
||||||
|
드라마 생성 오케스트레이터
|
||||||
|
|
||||||
|
워크플로우:
|
||||||
|
1. 스크립트 파싱
|
||||||
|
2. 캐릭터-보이스 매핑
|
||||||
|
3. 에셋 생성 (TTS, 음악, 효과음)
|
||||||
|
4. 타임라인 구성
|
||||||
|
5. 오디오 믹싱
|
||||||
|
6. 최종 파일 출력
|
||||||
|
"""
|
||||||
|
|
||||||
|
# 기본 대사 간격 (초)
|
||||||
|
DEFAULT_DIALOGUE_GAP = 0.5
|
||||||
|
# 효과음 기본 길이 (초)
|
||||||
|
DEFAULT_SFX_DURATION = 2.0
|
||||||
|
# 예상 TTS 속도 (글자/초)
|
||||||
|
TTS_CHARS_PER_SECOND = 5
|
||||||
|
|
||||||
|
async def create_project(
|
||||||
|
self,
|
||||||
|
request: DramaCreateRequest
|
||||||
|
) -> DramaProject:
|
||||||
|
"""새 드라마 프로젝트 생성"""
|
||||||
|
project_id = str(uuid.uuid4())
|
||||||
|
|
||||||
|
# 스크립트 파싱
|
||||||
|
parsed = script_parser.parse(request.script)
|
||||||
|
|
||||||
|
# 보이스 매핑 적용
|
||||||
|
voice_mapping = request.voice_mapping or {}
|
||||||
|
for char in parsed.characters:
|
||||||
|
if char.name in voice_mapping:
|
||||||
|
char.voice_id = voice_mapping[char.name]
|
||||||
|
|
||||||
|
project = DramaProject(
|
||||||
|
project_id=project_id,
|
||||||
|
title=request.title or parsed.title or "Untitled Drama",
|
||||||
|
script_raw=request.script,
|
||||||
|
script_parsed=parsed,
|
||||||
|
voice_mapping=voice_mapping,
|
||||||
|
status="draft"
|
||||||
|
)
|
||||||
|
|
||||||
|
# DB 저장
|
||||||
|
await db.dramas.insert_one(project.model_dump())
|
||||||
|
|
||||||
|
return project
|
||||||
|
|
||||||
|
async def get_project(self, project_id: str) -> Optional[DramaProject]:
|
||||||
|
"""프로젝트 조회"""
|
||||||
|
doc = await db.dramas.find_one({"project_id": project_id})
|
||||||
|
if doc:
|
||||||
|
return DramaProject(**doc)
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def update_project_status(
|
||||||
|
self,
|
||||||
|
project_id: str,
|
||||||
|
status: str,
|
||||||
|
error_message: Optional[str] = None
|
||||||
|
):
|
||||||
|
"""프로젝트 상태 업데이트"""
|
||||||
|
update = {
|
||||||
|
"status": status,
|
||||||
|
"updated_at": datetime.utcnow()
|
||||||
|
}
|
||||||
|
if error_message:
|
||||||
|
update["error_message"] = error_message
|
||||||
|
|
||||||
|
await db.dramas.update_one(
|
||||||
|
{"project_id": project_id},
|
||||||
|
{"$set": update}
|
||||||
|
)
|
||||||
|
|
||||||
|
def estimate_duration(self, parsed: ParsedScript) -> float:
|
||||||
|
"""예상 재생 시간 계산 (초)"""
|
||||||
|
total = 0.0
|
||||||
|
|
||||||
|
for element in parsed.elements:
|
||||||
|
if element.type == ElementType.DIALOGUE:
|
||||||
|
# 대사 길이 추정
|
||||||
|
text_len = len(element.text or "")
|
||||||
|
total += text_len / self.TTS_CHARS_PER_SECOND
|
||||||
|
total += self.DEFAULT_DIALOGUE_GAP
|
||||||
|
elif element.type == ElementType.PAUSE:
|
||||||
|
total += element.duration or 1.0
|
||||||
|
elif element.type == ElementType.SFX:
|
||||||
|
total += self.DEFAULT_SFX_DURATION
|
||||||
|
|
||||||
|
return total
|
||||||
|
|
||||||
|
async def generate_assets(
|
||||||
|
self,
|
||||||
|
project: DramaProject,
|
||||||
|
temp_dir: str
|
||||||
|
) -> dict[str, str]:
|
||||||
|
"""
|
||||||
|
에셋 생성 (TTS, SFX)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
audio_id -> 파일 경로 매핑
|
||||||
|
"""
|
||||||
|
assets: dict[str, str] = {}
|
||||||
|
parsed = project.script_parsed
|
||||||
|
|
||||||
|
if not parsed:
|
||||||
|
return assets
|
||||||
|
|
||||||
|
dialogue_index = 0
|
||||||
|
|
||||||
|
for element in parsed.elements:
|
||||||
|
if element.type == ElementType.DIALOGUE:
|
||||||
|
# TTS 생성
|
||||||
|
audio_id = f"dialogue_{dialogue_index}"
|
||||||
|
|
||||||
|
# 보이스 ID 결정
|
||||||
|
voice_id = project.voice_mapping.get(element.character)
|
||||||
|
if not voice_id:
|
||||||
|
# 기본 보이스 사용 (첫 번째 프리셋)
|
||||||
|
voice_id = "default"
|
||||||
|
|
||||||
|
try:
|
||||||
|
# TTS 엔진 호출
|
||||||
|
audio_data = await tts_client.synthesize(
|
||||||
|
text=element.text or "",
|
||||||
|
voice_id=voice_id,
|
||||||
|
instruct=element.emotion
|
||||||
|
)
|
||||||
|
|
||||||
|
# 파일 저장
|
||||||
|
file_path = os.path.join(temp_dir, f"{audio_id}.wav")
|
||||||
|
with open(file_path, "wb") as f:
|
||||||
|
f.write(audio_data)
|
||||||
|
|
||||||
|
assets[audio_id] = file_path
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"TTS 생성 실패 ({element.character}): {e}")
|
||||||
|
# 무음으로 대체
|
||||||
|
silence_duration = len(element.text or "") / self.TTS_CHARS_PER_SECOND
|
||||||
|
silence = AudioSegment.silent(duration=int(silence_duration * 1000))
|
||||||
|
file_path = os.path.join(temp_dir, f"{audio_id}.wav")
|
||||||
|
silence.export(file_path, format="wav")
|
||||||
|
assets[audio_id] = file_path
|
||||||
|
|
||||||
|
dialogue_index += 1
|
||||||
|
|
||||||
|
elif element.type == ElementType.SFX:
|
||||||
|
# Freesound에서 효과음 검색
|
||||||
|
audio_id = f"sfx_{element.description}"
|
||||||
|
|
||||||
|
try:
|
||||||
|
results = await freesound_client.search(
|
||||||
|
query=element.description,
|
||||||
|
page_size=1
|
||||||
|
)
|
||||||
|
|
||||||
|
if results and len(results) > 0:
|
||||||
|
sound = results[0]
|
||||||
|
# 프리뷰 다운로드
|
||||||
|
if sound.get("preview_url"):
|
||||||
|
audio_data = await freesound_client.download_preview(
|
||||||
|
sound["preview_url"]
|
||||||
|
)
|
||||||
|
file_path = os.path.join(temp_dir, f"sfx_{sound['id']}.mp3")
|
||||||
|
with open(file_path, "wb") as f:
|
||||||
|
f.write(audio_data)
|
||||||
|
assets[audio_id] = file_path
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"SFX 검색 실패 ({element.description}): {e}")
|
||||||
|
|
||||||
|
elif element.type == ElementType.MUSIC:
|
||||||
|
# MusicGen은 GPU 필요하므로 여기서는 placeholder
|
||||||
|
# 실제 구현 시 music_client 추가 필요
|
||||||
|
audio_id = f"music_{element.description}"
|
||||||
|
# TODO: MusicGen 연동
|
||||||
|
|
||||||
|
return assets
|
||||||
|
|
||||||
|
def build_timeline(
|
||||||
|
self,
|
||||||
|
parsed: ParsedScript,
|
||||||
|
assets: dict[str, str]
|
||||||
|
) -> list[TimelineItem]:
|
||||||
|
"""타임라인 구성"""
|
||||||
|
timeline: list[TimelineItem] = []
|
||||||
|
current_time = 0.0
|
||||||
|
dialogue_index = 0
|
||||||
|
current_music: Optional[dict] = None
|
||||||
|
|
||||||
|
for element in parsed.elements:
|
||||||
|
if element.type == ElementType.DIALOGUE:
|
||||||
|
audio_id = f"dialogue_{dialogue_index}"
|
||||||
|
|
||||||
|
if audio_id in assets:
|
||||||
|
# 오디오 길이 확인
|
||||||
|
try:
|
||||||
|
audio = AudioSegment.from_file(assets[audio_id])
|
||||||
|
duration = len(audio) / 1000.0
|
||||||
|
except:
|
||||||
|
duration = len(element.text or "") / self.TTS_CHARS_PER_SECOND
|
||||||
|
|
||||||
|
timeline.append(TimelineItem(
|
||||||
|
start_time=current_time,
|
||||||
|
duration=duration,
|
||||||
|
type="voice",
|
||||||
|
audio_path=audio_id,
|
||||||
|
volume=1.0
|
||||||
|
))
|
||||||
|
|
||||||
|
current_time += duration + self.DEFAULT_DIALOGUE_GAP
|
||||||
|
|
||||||
|
dialogue_index += 1
|
||||||
|
|
||||||
|
elif element.type == ElementType.PAUSE:
|
||||||
|
current_time += element.duration or 1.0
|
||||||
|
|
||||||
|
elif element.type == ElementType.SFX:
|
||||||
|
audio_id = f"sfx_{element.description}"
|
||||||
|
|
||||||
|
if audio_id in assets:
|
||||||
|
try:
|
||||||
|
audio = AudioSegment.from_file(assets[audio_id])
|
||||||
|
duration = len(audio) / 1000.0
|
||||||
|
except:
|
||||||
|
duration = self.DEFAULT_SFX_DURATION
|
||||||
|
|
||||||
|
timeline.append(TimelineItem(
|
||||||
|
start_time=current_time,
|
||||||
|
duration=duration,
|
||||||
|
type="sfx",
|
||||||
|
audio_path=audio_id,
|
||||||
|
volume=element.volume or 1.0
|
||||||
|
))
|
||||||
|
|
||||||
|
elif element.type == ElementType.MUSIC:
|
||||||
|
audio_id = f"music_{element.description}"
|
||||||
|
|
||||||
|
if element.action == "stop":
|
||||||
|
current_music = None
|
||||||
|
elif element.action in ("play", "change", "fade_in"):
|
||||||
|
if audio_id in assets:
|
||||||
|
# 음악은 현재 시점부터 끝까지 (나중에 조정)
|
||||||
|
current_music = {
|
||||||
|
"audio_id": audio_id,
|
||||||
|
"start_time": current_time,
|
||||||
|
"volume": element.volume or 0.3,
|
||||||
|
"fade_in": element.fade_duration if element.action == "fade_in" else 0
|
||||||
|
}
|
||||||
|
|
||||||
|
# 배경음악 아이템 추가 (전체 길이로)
|
||||||
|
if current_music:
|
||||||
|
timeline.append(TimelineItem(
|
||||||
|
start_time=current_music["start_time"],
|
||||||
|
duration=current_time - current_music["start_time"],
|
||||||
|
type="music",
|
||||||
|
audio_path=current_music["audio_id"],
|
||||||
|
volume=current_music["volume"],
|
||||||
|
fade_in=current_music.get("fade_in", 0)
|
||||||
|
))
|
||||||
|
|
||||||
|
return timeline
|
||||||
|
|
||||||
|
async def render(
|
||||||
|
self,
|
||||||
|
project_id: str,
|
||||||
|
output_format: str = "wav"
|
||||||
|
) -> Optional[str]:
|
||||||
|
"""
|
||||||
|
드라마 렌더링
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
출력 파일 경로
|
||||||
|
"""
|
||||||
|
project = await self.get_project(project_id)
|
||||||
|
if not project or not project.script_parsed:
|
||||||
|
return None
|
||||||
|
|
||||||
|
await self.update_project_status(project_id, "processing")
|
||||||
|
|
||||||
|
try:
|
||||||
|
with tempfile.TemporaryDirectory() as temp_dir:
|
||||||
|
# 1. 에셋 생성
|
||||||
|
assets = await self.generate_assets(project, temp_dir)
|
||||||
|
|
||||||
|
# 2. 타임라인 구성
|
||||||
|
timeline = self.build_timeline(project.script_parsed, assets)
|
||||||
|
|
||||||
|
# 3. 믹싱
|
||||||
|
mixed_audio = audio_mixer.mix_timeline(timeline, assets)
|
||||||
|
|
||||||
|
# 4. 출력
|
||||||
|
output_path = os.path.join(temp_dir, f"drama_{project_id}.{output_format}")
|
||||||
|
audio_mixer.export(mixed_audio, output_path, format=output_format)
|
||||||
|
|
||||||
|
# 5. GridFS에 저장 (TODO: 실제 구현)
|
||||||
|
# file_id = await save_to_gridfs(output_path)
|
||||||
|
|
||||||
|
# 임시: 파일 복사
|
||||||
|
final_path = f"/tmp/drama_{project_id}.{output_format}"
|
||||||
|
import shutil
|
||||||
|
shutil.copy(output_path, final_path)
|
||||||
|
|
||||||
|
# 상태 업데이트
|
||||||
|
await db.dramas.update_one(
|
||||||
|
{"project_id": project_id},
|
||||||
|
{
|
||||||
|
"$set": {
|
||||||
|
"status": "completed",
|
||||||
|
"timeline": [t.model_dump() for t in timeline],
|
||||||
|
"output_file_id": final_path,
|
||||||
|
"updated_at": datetime.utcnow()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return final_path
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
await self.update_project_status(project_id, "error", str(e))
|
||||||
|
raise
|
||||||
|
|
||||||
|
async def list_projects(
|
||||||
|
self,
|
||||||
|
skip: int = 0,
|
||||||
|
limit: int = 20
|
||||||
|
) -> list[DramaProject]:
|
||||||
|
"""프로젝트 목록 조회"""
|
||||||
|
cursor = db.dramas.find().sort("created_at", -1).skip(skip).limit(limit)
|
||||||
|
projects = []
|
||||||
|
async for doc in cursor:
|
||||||
|
projects.append(DramaProject(**doc))
|
||||||
|
return projects
|
||||||
|
|
||||||
|
|
||||||
|
# 싱글톤 인스턴스
|
||||||
|
drama_orchestrator = DramaOrchestrator()
|
||||||
165
audio-studio-api/app/services/freesound_client.py
Normal file
165
audio-studio-api/app/services/freesound_client.py
Normal file
@ -0,0 +1,165 @@
|
|||||||
|
"""Freesound API 클라이언트
|
||||||
|
|
||||||
|
효과음 검색 및 다운로드
|
||||||
|
https://freesound.org/docs/api/
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import logging
|
||||||
|
from typing import Optional, List, Dict
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class FreesoundClient:
|
||||||
|
"""Freesound API 클라이언트"""
|
||||||
|
|
||||||
|
BASE_URL = "https://freesound.org/apiv2"
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.api_key = os.getenv("FREESOUND_API_KEY", "")
|
||||||
|
self.timeout = httpx.Timeout(30.0, connect=10.0)
|
||||||
|
|
||||||
|
def _get_headers(self) -> dict:
|
||||||
|
"""인증 헤더 반환"""
|
||||||
|
return {"Authorization": f"Token {self.api_key}"}
|
||||||
|
|
||||||
|
async def search(
|
||||||
|
self,
|
||||||
|
query: str,
|
||||||
|
page: int = 1,
|
||||||
|
page_size: int = 20,
|
||||||
|
filter_fields: Optional[str] = None,
|
||||||
|
sort: str = "score",
|
||||||
|
min_duration: Optional[float] = None,
|
||||||
|
max_duration: Optional[float] = None,
|
||||||
|
) -> Dict:
|
||||||
|
"""효과음 검색
|
||||||
|
|
||||||
|
Args:
|
||||||
|
query: 검색어
|
||||||
|
page: 페이지 번호
|
||||||
|
page_size: 페이지당 결과 수
|
||||||
|
filter_fields: 필터 (예: "duration:[1 TO 5]")
|
||||||
|
sort: 정렬 (score, duration_asc, duration_desc, created_desc 등)
|
||||||
|
min_duration: 최소 길이 (초)
|
||||||
|
max_duration: 최대 길이 (초)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
검색 결과 딕셔너리
|
||||||
|
"""
|
||||||
|
if not self.api_key:
|
||||||
|
logger.warning("Freesound API 키가 설정되지 않음")
|
||||||
|
return {"count": 0, "results": []}
|
||||||
|
|
||||||
|
# 필터 구성
|
||||||
|
filters = []
|
||||||
|
if min_duration is not None or max_duration is not None:
|
||||||
|
min_d = min_duration if min_duration is not None else 0
|
||||||
|
max_d = max_duration if max_duration is not None else "*"
|
||||||
|
filters.append(f"duration:[{min_d} TO {max_d}]")
|
||||||
|
|
||||||
|
if filter_fields:
|
||||||
|
filters.append(filter_fields)
|
||||||
|
|
||||||
|
params = {
|
||||||
|
"query": query,
|
||||||
|
"page": page,
|
||||||
|
"page_size": min(page_size, 150), # Freesound 최대 150
|
||||||
|
"sort": sort,
|
||||||
|
"fields": "id,name,description,duration,tags,previews,license,username",
|
||||||
|
}
|
||||||
|
|
||||||
|
if filters:
|
||||||
|
params["filter"] = " ".join(filters)
|
||||||
|
|
||||||
|
async with httpx.AsyncClient(timeout=self.timeout) as client:
|
||||||
|
response = await client.get(
|
||||||
|
f"{self.BASE_URL}/search/text/",
|
||||||
|
params=params,
|
||||||
|
headers=self._get_headers(),
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
data = response.json()
|
||||||
|
|
||||||
|
# 결과 정리
|
||||||
|
results = []
|
||||||
|
for sound in data.get("results", []):
|
||||||
|
results.append({
|
||||||
|
"freesound_id": sound["id"],
|
||||||
|
"name": sound.get("name", ""),
|
||||||
|
"description": sound.get("description", ""),
|
||||||
|
"duration": sound.get("duration", 0),
|
||||||
|
"tags": sound.get("tags", []),
|
||||||
|
"preview_url": sound.get("previews", {}).get("preview-hq-mp3", ""),
|
||||||
|
"license": sound.get("license", ""),
|
||||||
|
"username": sound.get("username", ""),
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
"count": data.get("count", 0),
|
||||||
|
"page": page,
|
||||||
|
"page_size": page_size,
|
||||||
|
"results": results,
|
||||||
|
}
|
||||||
|
|
||||||
|
async def get_sound(self, sound_id: int) -> Dict:
|
||||||
|
"""사운드 상세 정보 조회"""
|
||||||
|
if not self.api_key:
|
||||||
|
raise ValueError("Freesound API 키 필요")
|
||||||
|
|
||||||
|
async with httpx.AsyncClient(timeout=self.timeout) as client:
|
||||||
|
response = await client.get(
|
||||||
|
f"{self.BASE_URL}/sounds/{sound_id}/",
|
||||||
|
headers=self._get_headers(),
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
return response.json()
|
||||||
|
|
||||||
|
async def download_preview(self, preview_url: str) -> bytes:
|
||||||
|
"""프리뷰 오디오 다운로드 (인증 불필요)"""
|
||||||
|
async with httpx.AsyncClient(timeout=self.timeout) as client:
|
||||||
|
response = await client.get(preview_url)
|
||||||
|
response.raise_for_status()
|
||||||
|
return response.content
|
||||||
|
|
||||||
|
async def get_similar_sounds(
|
||||||
|
self,
|
||||||
|
sound_id: int,
|
||||||
|
page_size: int = 10,
|
||||||
|
) -> List[Dict]:
|
||||||
|
"""유사한 사운드 검색"""
|
||||||
|
if not self.api_key:
|
||||||
|
return []
|
||||||
|
|
||||||
|
async with httpx.AsyncClient(timeout=self.timeout) as client:
|
||||||
|
response = await client.get(
|
||||||
|
f"{self.BASE_URL}/sounds/{sound_id}/similar/",
|
||||||
|
params={
|
||||||
|
"page_size": page_size,
|
||||||
|
"fields": "id,name,description,duration,tags,previews,license",
|
||||||
|
},
|
||||||
|
headers=self._get_headers(),
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
data = response.json()
|
||||||
|
|
||||||
|
results = []
|
||||||
|
for sound in data.get("results", []):
|
||||||
|
results.append({
|
||||||
|
"freesound_id": sound["id"],
|
||||||
|
"name": sound.get("name", ""),
|
||||||
|
"description": sound.get("description", ""),
|
||||||
|
"duration": sound.get("duration", 0),
|
||||||
|
"tags": sound.get("tags", []),
|
||||||
|
"preview_url": sound.get("previews", {}).get("preview-hq-mp3", ""),
|
||||||
|
"license": sound.get("license", ""),
|
||||||
|
})
|
||||||
|
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
# 싱글톤 인스턴스
|
||||||
|
freesound_client = FreesoundClient()
|
||||||
174
audio-studio-api/app/services/script_parser.py
Normal file
174
audio-studio-api/app/services/script_parser.py
Normal file
@ -0,0 +1,174 @@
|
|||||||
|
# 드라마 스크립트 파서
|
||||||
|
# 마크다운 형식의 대본을 구조화된 데이터로 변환
|
||||||
|
|
||||||
|
import re
|
||||||
|
from typing import Optional
|
||||||
|
from app.models.drama import (
|
||||||
|
ParsedScript, ScriptElement, Character, ElementType
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ScriptParser:
|
||||||
|
"""
|
||||||
|
드라마 스크립트 파서
|
||||||
|
|
||||||
|
지원 형식:
|
||||||
|
- # 제목
|
||||||
|
- [장소: 설명] 또는 [지문]
|
||||||
|
- [효과음: 설명]
|
||||||
|
- [음악: 설명] 또는 [음악 시작/중지/변경: 설명]
|
||||||
|
- [쉼: 2초]
|
||||||
|
- 캐릭터명(설명, 감정): 대사
|
||||||
|
- 캐릭터명: 대사
|
||||||
|
"""
|
||||||
|
|
||||||
|
# 정규식 패턴
|
||||||
|
TITLE_PATTERN = re.compile(r'^#\s+(.+)$')
|
||||||
|
DIRECTION_PATTERN = re.compile(r'^\[(?:장소|지문|장면):\s*(.+)\]$')
|
||||||
|
SFX_PATTERN = re.compile(r'^\[효과음:\s*(.+)\]$')
|
||||||
|
MUSIC_PATTERN = re.compile(r'^\[음악(?:\s+(시작|중지|변경|페이드인|페이드아웃))?:\s*(.+)\]$')
|
||||||
|
PAUSE_PATTERN = re.compile(r'^\[쉼:\s*(\d+(?:\.\d+)?)\s*초?\]$')
|
||||||
|
DIALOGUE_PATTERN = re.compile(r'^([^(\[:]+?)(?:\(([^)]*)\))?:\s*(.+)$')
|
||||||
|
|
||||||
|
# 음악 액션 매핑
|
||||||
|
MUSIC_ACTIONS = {
|
||||||
|
None: "play",
|
||||||
|
"시작": "play",
|
||||||
|
"중지": "stop",
|
||||||
|
"변경": "change",
|
||||||
|
"페이드인": "fade_in",
|
||||||
|
"페이드아웃": "fade_out",
|
||||||
|
}
|
||||||
|
|
||||||
|
def parse(self, script: str) -> ParsedScript:
|
||||||
|
"""스크립트 파싱"""
|
||||||
|
lines = script.strip().split('\n')
|
||||||
|
|
||||||
|
title: Optional[str] = None
|
||||||
|
characters: dict[str, Character] = {}
|
||||||
|
elements: list[ScriptElement] = []
|
||||||
|
|
||||||
|
for line in lines:
|
||||||
|
line = line.strip()
|
||||||
|
if not line:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 제목
|
||||||
|
if match := self.TITLE_PATTERN.match(line):
|
||||||
|
title = match.group(1)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 지문/장면
|
||||||
|
if match := self.DIRECTION_PATTERN.match(line):
|
||||||
|
elements.append(ScriptElement(
|
||||||
|
type=ElementType.DIRECTION,
|
||||||
|
text=match.group(1)
|
||||||
|
))
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 효과음
|
||||||
|
if match := self.SFX_PATTERN.match(line):
|
||||||
|
elements.append(ScriptElement(
|
||||||
|
type=ElementType.SFX,
|
||||||
|
description=match.group(1),
|
||||||
|
volume=1.0
|
||||||
|
))
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 음악
|
||||||
|
if match := self.MUSIC_PATTERN.match(line):
|
||||||
|
action_kr = match.group(1)
|
||||||
|
action = self.MUSIC_ACTIONS.get(action_kr, "play")
|
||||||
|
elements.append(ScriptElement(
|
||||||
|
type=ElementType.MUSIC,
|
||||||
|
description=match.group(2),
|
||||||
|
action=action,
|
||||||
|
volume=0.3,
|
||||||
|
fade_duration=2.0
|
||||||
|
))
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 쉼
|
||||||
|
if match := self.PAUSE_PATTERN.match(line):
|
||||||
|
elements.append(ScriptElement(
|
||||||
|
type=ElementType.PAUSE,
|
||||||
|
duration=float(match.group(1))
|
||||||
|
))
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 대사
|
||||||
|
if match := self.DIALOGUE_PATTERN.match(line):
|
||||||
|
char_name = match.group(1).strip()
|
||||||
|
char_info = match.group(2) # 괄호 안 내용 (설명, 감정)
|
||||||
|
dialogue_text = match.group(3).strip()
|
||||||
|
|
||||||
|
# 캐릭터 정보 파싱
|
||||||
|
emotion = None
|
||||||
|
description = None
|
||||||
|
if char_info:
|
||||||
|
parts = [p.strip() for p in char_info.split(',')]
|
||||||
|
if len(parts) >= 2:
|
||||||
|
description = parts[0]
|
||||||
|
emotion = parts[1]
|
||||||
|
else:
|
||||||
|
# 단일 값은 감정으로 처리
|
||||||
|
emotion = parts[0]
|
||||||
|
|
||||||
|
# 캐릭터 등록
|
||||||
|
if char_name not in characters:
|
||||||
|
characters[char_name] = Character(
|
||||||
|
name=char_name,
|
||||||
|
description=description
|
||||||
|
)
|
||||||
|
elif description and not characters[char_name].description:
|
||||||
|
characters[char_name].description = description
|
||||||
|
|
||||||
|
elements.append(ScriptElement(
|
||||||
|
type=ElementType.DIALOGUE,
|
||||||
|
character=char_name,
|
||||||
|
text=dialogue_text,
|
||||||
|
emotion=emotion
|
||||||
|
))
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 매칭 안 되는 줄은 지문으로 처리 (대괄호 없는 일반 텍스트)
|
||||||
|
if not line.startswith('[') and not line.startswith('#'):
|
||||||
|
# 콜론이 없으면 지문으로 처리
|
||||||
|
if ':' not in line:
|
||||||
|
elements.append(ScriptElement(
|
||||||
|
type=ElementType.DIRECTION,
|
||||||
|
text=line
|
||||||
|
))
|
||||||
|
|
||||||
|
return ParsedScript(
|
||||||
|
title=title,
|
||||||
|
characters=list(characters.values()),
|
||||||
|
elements=elements
|
||||||
|
)
|
||||||
|
|
||||||
|
def validate_script(self, script: str) -> tuple[bool, list[str]]:
|
||||||
|
"""
|
||||||
|
스크립트 유효성 검사
|
||||||
|
Returns: (is_valid, error_messages)
|
||||||
|
"""
|
||||||
|
errors = []
|
||||||
|
|
||||||
|
if not script or not script.strip():
|
||||||
|
errors.append("스크립트가 비어있습니다")
|
||||||
|
return False, errors
|
||||||
|
|
||||||
|
parsed = self.parse(script)
|
||||||
|
|
||||||
|
if not parsed.elements:
|
||||||
|
errors.append("파싱된 요소가 없습니다")
|
||||||
|
|
||||||
|
# 대사가 있는지 확인
|
||||||
|
dialogue_count = sum(1 for e in parsed.elements if e.type == ElementType.DIALOGUE)
|
||||||
|
if dialogue_count == 0:
|
||||||
|
errors.append("대사가 없습니다")
|
||||||
|
|
||||||
|
return len(errors) == 0, errors
|
||||||
|
|
||||||
|
|
||||||
|
# 싱글톤 인스턴스
|
||||||
|
script_parser = ScriptParser()
|
||||||
135
audio-studio-api/app/services/tts_client.py
Normal file
135
audio-studio-api/app/services/tts_client.py
Normal file
@ -0,0 +1,135 @@
|
|||||||
|
"""TTS 엔진 클라이언트
|
||||||
|
|
||||||
|
audio-studio-tts 서비스와 통신
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import logging
|
||||||
|
from typing import Optional, Tuple, List
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class TTSClient:
|
||||||
|
"""TTS 엔진 HTTP 클라이언트"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.base_url = os.getenv("TTS_ENGINE_URL", "http://localhost:8001")
|
||||||
|
self.timeout = httpx.Timeout(120.0, connect=10.0) # TTS는 시간이 걸릴 수 있음
|
||||||
|
|
||||||
|
async def health_check(self) -> dict:
|
||||||
|
"""TTS 엔진 헬스체크"""
|
||||||
|
async with httpx.AsyncClient(timeout=self.timeout) as client:
|
||||||
|
response = await client.get(f"{self.base_url}/health")
|
||||||
|
response.raise_for_status()
|
||||||
|
return response.json()
|
||||||
|
|
||||||
|
async def get_speakers(self) -> List[str]:
|
||||||
|
"""프리셋 스피커 목록 조회"""
|
||||||
|
async with httpx.AsyncClient(timeout=self.timeout) as client:
|
||||||
|
response = await client.get(f"{self.base_url}/speakers")
|
||||||
|
response.raise_for_status()
|
||||||
|
return response.json()["speakers"]
|
||||||
|
|
||||||
|
async def get_languages(self) -> dict:
|
||||||
|
"""지원 언어 목록 조회"""
|
||||||
|
async with httpx.AsyncClient(timeout=self.timeout) as client:
|
||||||
|
response = await client.get(f"{self.base_url}/languages")
|
||||||
|
response.raise_for_status()
|
||||||
|
return response.json()["languages"]
|
||||||
|
|
||||||
|
async def synthesize(
|
||||||
|
self,
|
||||||
|
text: str,
|
||||||
|
speaker: str = "Chelsie",
|
||||||
|
language: str = "ko",
|
||||||
|
instruct: Optional[str] = None,
|
||||||
|
) -> Tuple[bytes, int]:
|
||||||
|
"""프리셋 음성으로 TTS 합성
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
(audio_bytes, sample_rate)
|
||||||
|
"""
|
||||||
|
async with httpx.AsyncClient(timeout=self.timeout) as client:
|
||||||
|
payload = {
|
||||||
|
"text": text,
|
||||||
|
"speaker": speaker,
|
||||||
|
"language": language,
|
||||||
|
}
|
||||||
|
if instruct:
|
||||||
|
payload["instruct"] = instruct
|
||||||
|
|
||||||
|
response = await client.post(
|
||||||
|
f"{self.base_url}/synthesize",
|
||||||
|
json=payload,
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
|
||||||
|
# 샘플레이트 추출
|
||||||
|
sample_rate = int(response.headers.get("X-Sample-Rate", "24000"))
|
||||||
|
|
||||||
|
return response.content, sample_rate
|
||||||
|
|
||||||
|
async def voice_clone(
|
||||||
|
self,
|
||||||
|
text: str,
|
||||||
|
ref_audio: bytes,
|
||||||
|
ref_text: str,
|
||||||
|
language: str = "ko",
|
||||||
|
) -> Tuple[bytes, int]:
|
||||||
|
"""Voice Clone으로 TTS 합성
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
(audio_bytes, sample_rate)
|
||||||
|
"""
|
||||||
|
async with httpx.AsyncClient(timeout=self.timeout) as client:
|
||||||
|
# multipart/form-data로 전송
|
||||||
|
files = {"ref_audio": ("reference.wav", ref_audio, "audio/wav")}
|
||||||
|
data = {
|
||||||
|
"text": text,
|
||||||
|
"ref_text": ref_text,
|
||||||
|
"language": language,
|
||||||
|
}
|
||||||
|
|
||||||
|
response = await client.post(
|
||||||
|
f"{self.base_url}/voice-clone",
|
||||||
|
files=files,
|
||||||
|
data=data,
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
|
||||||
|
sample_rate = int(response.headers.get("X-Sample-Rate", "24000"))
|
||||||
|
return response.content, sample_rate
|
||||||
|
|
||||||
|
async def voice_design(
|
||||||
|
self,
|
||||||
|
text: str,
|
||||||
|
instruct: str,
|
||||||
|
language: str = "ko",
|
||||||
|
) -> Tuple[bytes, int]:
|
||||||
|
"""Voice Design으로 TTS 합성
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
(audio_bytes, sample_rate)
|
||||||
|
"""
|
||||||
|
async with httpx.AsyncClient(timeout=self.timeout) as client:
|
||||||
|
payload = {
|
||||||
|
"text": text,
|
||||||
|
"instruct": instruct,
|
||||||
|
"language": language,
|
||||||
|
}
|
||||||
|
|
||||||
|
response = await client.post(
|
||||||
|
f"{self.base_url}/voice-design",
|
||||||
|
json=payload,
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
|
||||||
|
sample_rate = int(response.headers.get("X-Sample-Rate", "24000"))
|
||||||
|
return response.content, sample_rate
|
||||||
|
|
||||||
|
|
||||||
|
# 싱글톤 인스턴스
|
||||||
|
tts_client = TTSClient()
|
||||||
31
audio-studio-api/requirements.txt
Normal file
31
audio-studio-api/requirements.txt
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
# Audio Studio API Server - Dependencies
|
||||||
|
|
||||||
|
# FastAPI
|
||||||
|
fastapi==0.115.6
|
||||||
|
uvicorn[standard]==0.34.0
|
||||||
|
python-multipart==0.0.20
|
||||||
|
|
||||||
|
# Database
|
||||||
|
motor==3.7.0
|
||||||
|
pymongo==4.10.1
|
||||||
|
redis==5.2.1
|
||||||
|
|
||||||
|
# Pydantic
|
||||||
|
pydantic==2.10.4
|
||||||
|
pydantic-settings==2.7.1
|
||||||
|
|
||||||
|
# HTTP Client
|
||||||
|
httpx==0.28.1
|
||||||
|
aiofiles==24.1.0
|
||||||
|
|
||||||
|
# Audio Processing
|
||||||
|
soundfile>=0.12.1
|
||||||
|
numpy>=1.26.0
|
||||||
|
pydub>=0.25.1
|
||||||
|
|
||||||
|
# Freesound API (httpx로 직접 구현)
|
||||||
|
|
||||||
|
# Utilities
|
||||||
|
python-dotenv==1.0.1
|
||||||
|
python-jose[cryptography]==3.3.0
|
||||||
|
passlib[bcrypt]==1.7.4
|
||||||
56
audio-studio-musicgen/Dockerfile.gpu
Normal file
56
audio-studio-musicgen/Dockerfile.gpu
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
# Audio Studio MusicGen - GPU Dockerfile
|
||||||
|
|
||||||
|
FROM nvidia/cuda:12.4.1-devel-ubuntu22.04
|
||||||
|
|
||||||
|
# 환경 변수
|
||||||
|
ENV PYTHONUNBUFFERED=1
|
||||||
|
ENV DEBIAN_FRONTEND=noninteractive
|
||||||
|
ENV CUDA_HOME=/usr/local/cuda
|
||||||
|
ENV PATH="${CUDA_HOME}/bin:${PATH}"
|
||||||
|
ENV LD_LIBRARY_PATH="${CUDA_HOME}/lib64:${LD_LIBRARY_PATH}"
|
||||||
|
|
||||||
|
# 시스템 패키지 설치
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
python3.11 \
|
||||||
|
python3.11-dev \
|
||||||
|
python3-pip \
|
||||||
|
git \
|
||||||
|
curl \
|
||||||
|
libsndfile1 \
|
||||||
|
ffmpeg \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Python 심볼릭 링크
|
||||||
|
RUN ln -sf /usr/bin/python3.11 /usr/bin/python && \
|
||||||
|
ln -sf /usr/bin/python3.11 /usr/bin/python3
|
||||||
|
|
||||||
|
# pip 업그레이드
|
||||||
|
RUN python -m pip install --upgrade pip setuptools wheel
|
||||||
|
|
||||||
|
# 작업 디렉토리
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# 의존성 설치 (캐시 활용)
|
||||||
|
COPY requirements.txt .
|
||||||
|
|
||||||
|
# PyTorch + CUDA 설치
|
||||||
|
RUN pip install --no-cache-dir torch torchaudio --index-url https://download.pytorch.org/whl/cu124
|
||||||
|
|
||||||
|
# AudioCraft 설치
|
||||||
|
RUN pip install --no-cache-dir audiocraft
|
||||||
|
|
||||||
|
# 나머지 의존성 설치
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
# 소스 코드 복사
|
||||||
|
COPY app/ ./app/
|
||||||
|
|
||||||
|
# 포트 노출
|
||||||
|
EXPOSE 8002
|
||||||
|
|
||||||
|
# 헬스체크
|
||||||
|
HEALTHCHECK --interval=30s --timeout=30s --start-period=120s --retries=3 \
|
||||||
|
CMD curl -f http://localhost:8002/health || exit 1
|
||||||
|
|
||||||
|
# 서버 실행
|
||||||
|
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8002"]
|
||||||
0
audio-studio-musicgen/app/__init__.py
Normal file
0
audio-studio-musicgen/app/__init__.py
Normal file
205
audio-studio-musicgen/app/main.py
Normal file
205
audio-studio-musicgen/app/main.py
Normal file
@ -0,0 +1,205 @@
|
|||||||
|
"""Audio Studio MusicGen API
|
||||||
|
|
||||||
|
AI 음악 생성 API 서버
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from fastapi import FastAPI, HTTPException, UploadFile, File, Form
|
||||||
|
from fastapi.responses import Response
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
from app.services.musicgen_service import musicgen_service
|
||||||
|
|
||||||
|
# 로깅 설정
|
||||||
|
logging.basicConfig(
|
||||||
|
level=logging.INFO,
|
||||||
|
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s"
|
||||||
|
)
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
# ========================================
|
||||||
|
# Pydantic 모델
|
||||||
|
# ========================================
|
||||||
|
|
||||||
|
class GenerateRequest(BaseModel):
|
||||||
|
"""음악 생성 요청"""
|
||||||
|
prompt: str = Field(..., min_length=5, max_length=500, description="음악 설명")
|
||||||
|
duration: int = Field(default=30, ge=5, le=30, description="생성 길이 (초)")
|
||||||
|
top_k: int = Field(default=250, ge=50, le=500, description="top-k 샘플링")
|
||||||
|
temperature: float = Field(default=1.0, ge=0.5, le=2.0, description="생성 다양성")
|
||||||
|
|
||||||
|
|
||||||
|
class HealthResponse(BaseModel):
|
||||||
|
"""헬스체크 응답"""
|
||||||
|
status: str
|
||||||
|
model_info: dict
|
||||||
|
|
||||||
|
|
||||||
|
# ========================================
|
||||||
|
# 앱 생명주기
|
||||||
|
# ========================================
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def lifespan(app: FastAPI):
|
||||||
|
"""앱 시작/종료 시 실행"""
|
||||||
|
logger.info("MusicGen 서비스 시작...")
|
||||||
|
try:
|
||||||
|
await musicgen_service.initialize()
|
||||||
|
logger.info("MusicGen 서비스 준비 완료")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"MusicGen 초기화 실패: {e}")
|
||||||
|
# 초기화 실패해도 서버는 시작 (lazy loading 시도)
|
||||||
|
|
||||||
|
yield
|
||||||
|
|
||||||
|
logger.info("MusicGen 서비스 종료")
|
||||||
|
|
||||||
|
|
||||||
|
# ========================================
|
||||||
|
# FastAPI 앱
|
||||||
|
# ========================================
|
||||||
|
|
||||||
|
app = FastAPI(
|
||||||
|
title="Audio Studio MusicGen",
|
||||||
|
description="AI 음악 생성 API (Meta AudioCraft)",
|
||||||
|
version="0.1.0",
|
||||||
|
lifespan=lifespan,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ========================================
|
||||||
|
# API 엔드포인트
|
||||||
|
# ========================================
|
||||||
|
|
||||||
|
@app.get("/health", response_model=HealthResponse)
|
||||||
|
async def health_check():
|
||||||
|
"""헬스체크 엔드포인트"""
|
||||||
|
return HealthResponse(
|
||||||
|
status="healthy" if musicgen_service.is_initialized() else "initializing",
|
||||||
|
model_info=musicgen_service.get_model_info(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/generate")
|
||||||
|
async def generate_music(request: GenerateRequest):
|
||||||
|
"""텍스트 프롬프트로 음악 생성
|
||||||
|
|
||||||
|
예시 프롬프트:
|
||||||
|
- "upbeat electronic music for gaming"
|
||||||
|
- "calm piano music, peaceful, ambient"
|
||||||
|
- "energetic rock music with drums"
|
||||||
|
- "lo-fi hip hop beats, relaxing"
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
audio_bytes = await musicgen_service.generate(
|
||||||
|
prompt=request.prompt,
|
||||||
|
duration=request.duration,
|
||||||
|
top_k=request.top_k,
|
||||||
|
temperature=request.temperature,
|
||||||
|
)
|
||||||
|
|
||||||
|
return Response(
|
||||||
|
content=audio_bytes,
|
||||||
|
media_type="audio/wav",
|
||||||
|
headers={
|
||||||
|
"X-Sample-Rate": "32000",
|
||||||
|
"X-Duration": str(request.duration),
|
||||||
|
"Content-Disposition": 'attachment; filename="generated_music.wav"',
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"음악 생성 실패: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail=f"Music generation failed: {str(e)}")
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/generate-with-melody")
|
||||||
|
async def generate_with_melody(
|
||||||
|
prompt: str = Form(..., min_length=5, description="음악 설명"),
|
||||||
|
duration: int = Form(default=30, ge=5, le=30, description="생성 길이"),
|
||||||
|
melody_audio: UploadFile = File(..., description="참조 멜로디 오디오"),
|
||||||
|
):
|
||||||
|
"""멜로디 조건부 음악 생성
|
||||||
|
|
||||||
|
참조 멜로디의 멜로디/하모니를 유지하면서 새로운 음악 생성
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
melody_bytes = await melody_audio.read()
|
||||||
|
|
||||||
|
if len(melody_bytes) < 1000:
|
||||||
|
raise HTTPException(status_code=400, detail="Melody audio is too small")
|
||||||
|
|
||||||
|
audio_bytes = await musicgen_service.generate_with_melody(
|
||||||
|
prompt=prompt,
|
||||||
|
melody_audio=melody_bytes,
|
||||||
|
duration=duration,
|
||||||
|
)
|
||||||
|
|
||||||
|
return Response(
|
||||||
|
content=audio_bytes,
|
||||||
|
media_type="audio/wav",
|
||||||
|
headers={
|
||||||
|
"X-Sample-Rate": "32000",
|
||||||
|
"X-Duration": str(duration),
|
||||||
|
"Content-Disposition": 'attachment; filename="melody_based_music.wav"',
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"멜로디 기반 생성 실패: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail=f"Generation failed: {str(e)}")
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/prompts")
|
||||||
|
async def get_example_prompts():
|
||||||
|
"""예시 프롬프트 목록"""
|
||||||
|
return {
|
||||||
|
"examples": [
|
||||||
|
{
|
||||||
|
"category": "Electronic",
|
||||||
|
"prompts": [
|
||||||
|
"upbeat electronic dance music with synthesizers",
|
||||||
|
"chill electronic ambient music",
|
||||||
|
"retro synthwave 80s style music",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"category": "Classical",
|
||||||
|
"prompts": [
|
||||||
|
"calm piano solo, classical style",
|
||||||
|
"orchestral epic cinematic music",
|
||||||
|
"gentle string quartet, romantic",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"category": "Pop/Rock",
|
||||||
|
"prompts": [
|
||||||
|
"energetic rock music with electric guitar",
|
||||||
|
"upbeat pop song with catchy melody",
|
||||||
|
"acoustic guitar folk music",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"category": "Ambient/Lo-fi",
|
||||||
|
"prompts": [
|
||||||
|
"lo-fi hip hop beats, relaxing, study music",
|
||||||
|
"peaceful ambient nature sounds music",
|
||||||
|
"meditation music, calm, zen",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"category": "Game/Film",
|
||||||
|
"prompts": [
|
||||||
|
"epic adventure game soundtrack",
|
||||||
|
"tense suspenseful thriller music",
|
||||||
|
"cheerful happy video game background",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
0
audio-studio-musicgen/app/services/__init__.py
Normal file
0
audio-studio-musicgen/app/services/__init__.py
Normal file
199
audio-studio-musicgen/app/services/musicgen_service.py
Normal file
199
audio-studio-musicgen/app/services/musicgen_service.py
Normal file
@ -0,0 +1,199 @@
|
|||||||
|
"""MusicGen 서비스
|
||||||
|
|
||||||
|
Meta AudioCraft MusicGen을 사용한 AI 음악 생성
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import io
|
||||||
|
import logging
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
import torch
|
||||||
|
import soundfile as sf
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class MusicGenService:
|
||||||
|
"""MusicGen 음악 생성 서비스"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.device = "cuda:0" if torch.cuda.is_available() else "cpu"
|
||||||
|
self.model_name = os.getenv("MODEL_NAME", "facebook/musicgen-medium")
|
||||||
|
self.model = None
|
||||||
|
self._initialized = False
|
||||||
|
|
||||||
|
async def initialize(self):
|
||||||
|
"""모델 초기화 (서버 시작 시 호출)"""
|
||||||
|
if self._initialized:
|
||||||
|
return
|
||||||
|
|
||||||
|
logger.info(f"MusicGen 모델 로딩 중: {self.model_name}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
from audiocraft.models import MusicGen
|
||||||
|
|
||||||
|
self.model = MusicGen.get_pretrained(self.model_name)
|
||||||
|
self.model.set_generation_params(
|
||||||
|
use_sampling=True,
|
||||||
|
top_k=250,
|
||||||
|
duration=30, # 기본 30초
|
||||||
|
)
|
||||||
|
|
||||||
|
self._initialized = True
|
||||||
|
logger.info(f"MusicGen 모델 로드 완료 (device: {self.device})")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"MusicGen 모델 로드 실패: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
async def generate(
|
||||||
|
self,
|
||||||
|
prompt: str,
|
||||||
|
duration: int = 30,
|
||||||
|
top_k: int = 250,
|
||||||
|
temperature: float = 1.0,
|
||||||
|
) -> bytes:
|
||||||
|
"""텍스트 프롬프트로 음악 생성
|
||||||
|
|
||||||
|
Args:
|
||||||
|
prompt: 음악 설명 (예: "upbeat electronic music for gaming")
|
||||||
|
duration: 생성 길이 (초, 최대 30초)
|
||||||
|
top_k: top-k 샘플링 파라미터
|
||||||
|
temperature: 생성 다양성 (높을수록 다양)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
WAV 바이트
|
||||||
|
"""
|
||||||
|
if not self._initialized:
|
||||||
|
await self.initialize()
|
||||||
|
|
||||||
|
# 파라미터 제한
|
||||||
|
duration = min(max(duration, 5), 30)
|
||||||
|
|
||||||
|
logger.info(f"음악 생성 시작: prompt='{prompt[:50]}...', duration={duration}s")
|
||||||
|
|
||||||
|
try:
|
||||||
|
# 생성 파라미터 설정
|
||||||
|
self.model.set_generation_params(
|
||||||
|
use_sampling=True,
|
||||||
|
top_k=top_k,
|
||||||
|
top_p=0.0,
|
||||||
|
temperature=temperature,
|
||||||
|
duration=duration,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 생성
|
||||||
|
wav = self.model.generate([prompt])
|
||||||
|
|
||||||
|
# 결과 처리 (첫 번째 결과만)
|
||||||
|
audio_data = wav[0].cpu().numpy()
|
||||||
|
|
||||||
|
# 스테레오인 경우 모노로 변환
|
||||||
|
if len(audio_data.shape) > 1:
|
||||||
|
audio_data = audio_data.mean(axis=0)
|
||||||
|
|
||||||
|
# WAV 바이트로 변환
|
||||||
|
buffer = io.BytesIO()
|
||||||
|
sf.write(buffer, audio_data, 32000, format='WAV') # MusicGen은 32kHz
|
||||||
|
buffer.seek(0)
|
||||||
|
|
||||||
|
logger.info(f"음악 생성 완료: {duration}초")
|
||||||
|
return buffer.read()
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"음악 생성 실패: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
async def generate_with_melody(
|
||||||
|
self,
|
||||||
|
prompt: str,
|
||||||
|
melody_audio: bytes,
|
||||||
|
duration: int = 30,
|
||||||
|
) -> bytes:
|
||||||
|
"""멜로디 조건부 음악 생성
|
||||||
|
|
||||||
|
Args:
|
||||||
|
prompt: 음악 설명
|
||||||
|
melody_audio: 참조 멜로디 오디오 (WAV)
|
||||||
|
duration: 생성 길이
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
WAV 바이트
|
||||||
|
"""
|
||||||
|
if not self._initialized:
|
||||||
|
await self.initialize()
|
||||||
|
|
||||||
|
duration = min(max(duration, 5), 30)
|
||||||
|
|
||||||
|
logger.info(f"멜로디 기반 음악 생성: prompt='{prompt[:50]}...', duration={duration}s")
|
||||||
|
|
||||||
|
try:
|
||||||
|
# 멜로디 로드
|
||||||
|
import torchaudio
|
||||||
|
|
||||||
|
buffer = io.BytesIO(melody_audio)
|
||||||
|
melody, sr = torchaudio.load(buffer)
|
||||||
|
|
||||||
|
# 리샘플링 (32kHz로)
|
||||||
|
if sr != 32000:
|
||||||
|
melody = torchaudio.functional.resample(melody, sr, 32000)
|
||||||
|
|
||||||
|
# 모노로 변환
|
||||||
|
if melody.shape[0] > 1:
|
||||||
|
melody = melody.mean(dim=0, keepdim=True)
|
||||||
|
|
||||||
|
# 길이 제한 (30초)
|
||||||
|
max_samples = 32000 * 30
|
||||||
|
if melody.shape[1] > max_samples:
|
||||||
|
melody = melody[:, :max_samples]
|
||||||
|
|
||||||
|
# 생성 파라미터 설정
|
||||||
|
self.model.set_generation_params(
|
||||||
|
use_sampling=True,
|
||||||
|
top_k=250,
|
||||||
|
duration=duration,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 멜로디 조건부 생성
|
||||||
|
wav = self.model.generate_with_chroma(
|
||||||
|
descriptions=[prompt],
|
||||||
|
melody_wavs=melody.unsqueeze(0).to(self.device),
|
||||||
|
melody_sample_rate=32000,
|
||||||
|
progress=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 결과 처리
|
||||||
|
audio_data = wav[0].cpu().numpy()
|
||||||
|
if len(audio_data.shape) > 1:
|
||||||
|
audio_data = audio_data.mean(axis=0)
|
||||||
|
|
||||||
|
buffer = io.BytesIO()
|
||||||
|
sf.write(buffer, audio_data, 32000, format='WAV')
|
||||||
|
buffer.seek(0)
|
||||||
|
|
||||||
|
logger.info(f"멜로디 기반 음악 생성 완료")
|
||||||
|
return buffer.read()
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"멜로디 기반 생성 실패: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
def is_initialized(self) -> bool:
|
||||||
|
"""초기화 상태 확인"""
|
||||||
|
return self._initialized
|
||||||
|
|
||||||
|
def get_model_info(self) -> dict:
|
||||||
|
"""모델 정보 반환"""
|
||||||
|
return {
|
||||||
|
"model_name": self.model_name,
|
||||||
|
"device": self.device,
|
||||||
|
"initialized": self._initialized,
|
||||||
|
"max_duration": 30,
|
||||||
|
"sample_rate": 32000,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# 싱글톤 인스턴스
|
||||||
|
musicgen_service = MusicGenService()
|
||||||
24
audio-studio-musicgen/requirements.txt
Normal file
24
audio-studio-musicgen/requirements.txt
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
# Audio Studio MusicGen - Dependencies
|
||||||
|
|
||||||
|
# FastAPI
|
||||||
|
fastapi==0.115.6
|
||||||
|
uvicorn[standard]==0.34.0
|
||||||
|
|
||||||
|
# PyTorch (CUDA 12.x)
|
||||||
|
--extra-index-url https://download.pytorch.org/whl/cu124
|
||||||
|
torch>=2.5.0
|
||||||
|
torchaudio>=2.5.0
|
||||||
|
|
||||||
|
# AudioCraft (MusicGen)
|
||||||
|
audiocraft>=1.3.0
|
||||||
|
|
||||||
|
# Audio Processing
|
||||||
|
soundfile>=0.12.1
|
||||||
|
numpy>=1.26.0
|
||||||
|
scipy>=1.14.0
|
||||||
|
|
||||||
|
# Utilities
|
||||||
|
httpx>=0.28.0
|
||||||
|
pydantic>=2.10.0
|
||||||
|
pydantic-settings>=2.7.0
|
||||||
|
python-dotenv>=1.0.1
|
||||||
57
audio-studio-tts/Dockerfile.gpu
Normal file
57
audio-studio-tts/Dockerfile.gpu
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
# Audio Studio TTS Engine - GPU Dockerfile
|
||||||
|
# Qwen3-TTS 1.7B 모델 서빙
|
||||||
|
|
||||||
|
FROM nvidia/cuda:12.4.1-devel-ubuntu22.04
|
||||||
|
|
||||||
|
# 환경 변수
|
||||||
|
ENV PYTHONUNBUFFERED=1
|
||||||
|
ENV DEBIAN_FRONTEND=noninteractive
|
||||||
|
ENV CUDA_HOME=/usr/local/cuda
|
||||||
|
ENV PATH="${CUDA_HOME}/bin:${PATH}"
|
||||||
|
ENV LD_LIBRARY_PATH="${CUDA_HOME}/lib64:${LD_LIBRARY_PATH}"
|
||||||
|
|
||||||
|
# 시스템 패키지 설치
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
python3.11 \
|
||||||
|
python3.11-dev \
|
||||||
|
python3-pip \
|
||||||
|
git \
|
||||||
|
curl \
|
||||||
|
libsndfile1 \
|
||||||
|
ffmpeg \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Python 심볼릭 링크
|
||||||
|
RUN ln -sf /usr/bin/python3.11 /usr/bin/python && \
|
||||||
|
ln -sf /usr/bin/python3.11 /usr/bin/python3
|
||||||
|
|
||||||
|
# pip 업그레이드
|
||||||
|
RUN python -m pip install --upgrade pip setuptools wheel
|
||||||
|
|
||||||
|
# 작업 디렉토리
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# 의존성 설치 (캐시 활용)
|
||||||
|
COPY requirements.txt .
|
||||||
|
|
||||||
|
# PyTorch + CUDA 설치
|
||||||
|
RUN pip install --no-cache-dir torch torchaudio --index-url https://download.pytorch.org/whl/cu124
|
||||||
|
|
||||||
|
# FlashAttention 2 설치 (VRAM 최적화)
|
||||||
|
RUN pip install --no-cache-dir flash-attn --no-build-isolation || echo "FlashAttention 설치 실패, SDPA 사용"
|
||||||
|
|
||||||
|
# 나머지 의존성 설치
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
# 소스 코드 복사
|
||||||
|
COPY app/ ./app/
|
||||||
|
|
||||||
|
# 포트 노출
|
||||||
|
EXPOSE 8001
|
||||||
|
|
||||||
|
# 헬스체크
|
||||||
|
HEALTHCHECK --interval=30s --timeout=30s --start-period=180s --retries=3 \
|
||||||
|
CMD curl -f http://localhost:8001/health || exit 1
|
||||||
|
|
||||||
|
# 서버 실행
|
||||||
|
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8001"]
|
||||||
0
audio-studio-tts/app/__init__.py
Normal file
0
audio-studio-tts/app/__init__.py
Normal file
280
audio-studio-tts/app/main.py
Normal file
280
audio-studio-tts/app/main.py
Normal file
@ -0,0 +1,280 @@
|
|||||||
|
"""Audio Studio TTS Engine
|
||||||
|
|
||||||
|
Qwen3-TTS 기반 음성 합성 API 서버
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
|
from typing import Optional, List
|
||||||
|
|
||||||
|
from fastapi import FastAPI, HTTPException, UploadFile, File, Form
|
||||||
|
from fastapi.responses import Response, JSONResponse
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
from app.services.qwen_tts import tts_service, PRESET_SPEAKERS, LANGUAGE_MAP
|
||||||
|
|
||||||
|
# 로깅 설정
|
||||||
|
logging.basicConfig(
|
||||||
|
level=logging.INFO,
|
||||||
|
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s"
|
||||||
|
)
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
# ========================================
|
||||||
|
# Pydantic 모델
|
||||||
|
# ========================================
|
||||||
|
|
||||||
|
class SynthesizeRequest(BaseModel):
|
||||||
|
"""기본 TTS 합성 요청"""
|
||||||
|
text: str = Field(..., min_length=1, max_length=5000, description="합성할 텍스트")
|
||||||
|
speaker: str = Field(default="Chelsie", description="프리셋 스피커 이름")
|
||||||
|
language: str = Field(default="ko", description="언어 코드 (ko, en, ja 등)")
|
||||||
|
instruct: Optional[str] = Field(default=None, description="감정/스타일 지시")
|
||||||
|
|
||||||
|
|
||||||
|
class VoiceDesignRequest(BaseModel):
|
||||||
|
"""Voice Design 요청"""
|
||||||
|
text: str = Field(..., min_length=1, max_length=5000, description="합성할 텍스트")
|
||||||
|
instruct: str = Field(..., min_length=10, description="음성 디자인 프롬프트")
|
||||||
|
language: str = Field(default="ko", description="언어 코드")
|
||||||
|
|
||||||
|
|
||||||
|
class HealthResponse(BaseModel):
|
||||||
|
"""헬스체크 응답"""
|
||||||
|
status: str
|
||||||
|
initialized: bool
|
||||||
|
loaded_models: List[str]
|
||||||
|
device: str
|
||||||
|
|
||||||
|
|
||||||
|
class SpeakersResponse(BaseModel):
|
||||||
|
"""스피커 목록 응답"""
|
||||||
|
speakers: List[str]
|
||||||
|
|
||||||
|
|
||||||
|
class LanguagesResponse(BaseModel):
|
||||||
|
"""언어 목록 응답"""
|
||||||
|
languages: dict
|
||||||
|
|
||||||
|
|
||||||
|
# ========================================
|
||||||
|
# 앱 생명주기
|
||||||
|
# ========================================
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def lifespan(app: FastAPI):
|
||||||
|
"""앱 시작/종료 시 실행"""
|
||||||
|
# 시작 시 모델 초기화
|
||||||
|
logger.info("TTS 엔진 시작...")
|
||||||
|
try:
|
||||||
|
await tts_service.initialize(preload_models=["custom"])
|
||||||
|
logger.info("TTS 엔진 준비 완료")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"TTS 엔진 초기화 실패: {e}")
|
||||||
|
# 초기화 실패해도 서버는 시작 (lazy loading 시도)
|
||||||
|
|
||||||
|
yield
|
||||||
|
|
||||||
|
# 종료 시 정리
|
||||||
|
logger.info("TTS 엔진 종료")
|
||||||
|
|
||||||
|
|
||||||
|
# ========================================
|
||||||
|
# FastAPI 앱
|
||||||
|
# ========================================
|
||||||
|
|
||||||
|
app = FastAPI(
|
||||||
|
title="Audio Studio TTS Engine",
|
||||||
|
description="Qwen3-TTS 기반 음성 합성 API",
|
||||||
|
version="0.1.0",
|
||||||
|
lifespan=lifespan,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ========================================
|
||||||
|
# API 엔드포인트
|
||||||
|
# ========================================
|
||||||
|
|
||||||
|
@app.get("/health", response_model=HealthResponse)
|
||||||
|
async def health_check():
|
||||||
|
"""헬스체크 엔드포인트"""
|
||||||
|
return HealthResponse(
|
||||||
|
status="healthy",
|
||||||
|
initialized=tts_service.is_initialized(),
|
||||||
|
loaded_models=tts_service.get_loaded_models(),
|
||||||
|
device=tts_service.device,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/speakers", response_model=SpeakersResponse)
|
||||||
|
async def get_speakers():
|
||||||
|
"""프리셋 스피커 목록 조회"""
|
||||||
|
return SpeakersResponse(speakers=tts_service.get_preset_speakers())
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/languages", response_model=LanguagesResponse)
|
||||||
|
async def get_languages():
|
||||||
|
"""지원 언어 목록 조회"""
|
||||||
|
return LanguagesResponse(languages=tts_service.get_supported_languages())
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/synthesize")
|
||||||
|
async def synthesize(request: SynthesizeRequest):
|
||||||
|
"""프리셋 음성으로 TTS 합성
|
||||||
|
|
||||||
|
CustomVoice 모델을 사용하여 텍스트를 음성으로 변환합니다.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# 스피커 유효성 검사
|
||||||
|
if request.speaker not in PRESET_SPEAKERS:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail=f"Invalid speaker. Available: {PRESET_SPEAKERS}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 언어 유효성 검사
|
||||||
|
if request.language not in LANGUAGE_MAP:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail=f"Invalid language. Available: {list(LANGUAGE_MAP.keys())}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# TTS 합성
|
||||||
|
audio_bytes, sr = await tts_service.synthesize_custom(
|
||||||
|
text=request.text,
|
||||||
|
speaker=request.speaker,
|
||||||
|
language=request.language,
|
||||||
|
instruct=request.instruct,
|
||||||
|
)
|
||||||
|
|
||||||
|
return Response(
|
||||||
|
content=audio_bytes,
|
||||||
|
media_type="audio/wav",
|
||||||
|
headers={
|
||||||
|
"X-Sample-Rate": str(sr),
|
||||||
|
"Content-Disposition": 'attachment; filename="output.wav"',
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"TTS 합성 실패: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail=f"TTS synthesis failed: {str(e)}")
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/voice-clone")
|
||||||
|
async def voice_clone(
|
||||||
|
text: str = Form(..., description="합성할 텍스트"),
|
||||||
|
ref_text: str = Form(..., description="레퍼런스 오디오의 트랜스크립트"),
|
||||||
|
language: str = Form(default="ko", description="언어 코드"),
|
||||||
|
ref_audio: UploadFile = File(..., description="레퍼런스 오디오 파일"),
|
||||||
|
):
|
||||||
|
"""Voice Clone으로 TTS 합성
|
||||||
|
|
||||||
|
레퍼런스 오디오를 기반으로 목소리를 복제하여 새 텍스트를 합성합니다.
|
||||||
|
3초 이상의 오디오가 권장됩니다.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# 언어 유효성 검사
|
||||||
|
if language not in LANGUAGE_MAP:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail=f"Invalid language. Available: {list(LANGUAGE_MAP.keys())}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 오디오 파일 읽기
|
||||||
|
audio_content = await ref_audio.read()
|
||||||
|
if len(audio_content) < 1000: # 최소 크기 체크
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail="Reference audio is too small"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Voice Clone 합성
|
||||||
|
audio_bytes, sr = await tts_service.synthesize_clone(
|
||||||
|
text=text,
|
||||||
|
ref_audio=audio_content,
|
||||||
|
ref_text=ref_text,
|
||||||
|
language=language,
|
||||||
|
)
|
||||||
|
|
||||||
|
return Response(
|
||||||
|
content=audio_bytes,
|
||||||
|
media_type="audio/wav",
|
||||||
|
headers={
|
||||||
|
"X-Sample-Rate": str(sr),
|
||||||
|
"Content-Disposition": 'attachment; filename="cloned.wav"',
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Voice Clone 실패: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail=f"Voice clone failed: {str(e)}")
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/voice-design")
|
||||||
|
async def voice_design(request: VoiceDesignRequest):
|
||||||
|
"""Voice Design으로 TTS 합성
|
||||||
|
|
||||||
|
텍스트 프롬프트를 기반으로 새로운 음성을 생성합니다.
|
||||||
|
예: "30대 남성, 부드럽고 차분한 목소리"
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# 언어 유효성 검사
|
||||||
|
if request.language not in LANGUAGE_MAP:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail=f"Invalid language. Available: {list(LANGUAGE_MAP.keys())}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Voice Design 합성
|
||||||
|
audio_bytes, sr = await tts_service.synthesize_design(
|
||||||
|
text=request.text,
|
||||||
|
instruct=request.instruct,
|
||||||
|
language=request.language,
|
||||||
|
)
|
||||||
|
|
||||||
|
return Response(
|
||||||
|
content=audio_bytes,
|
||||||
|
media_type="audio/wav",
|
||||||
|
headers={
|
||||||
|
"X-Sample-Rate": str(sr),
|
||||||
|
"Content-Disposition": 'attachment; filename="designed.wav"',
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Voice Design 실패: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail=f"Voice design failed: {str(e)}")
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/load-model")
|
||||||
|
async def load_model(model_type: str):
|
||||||
|
"""특정 모델 로드 (관리용)
|
||||||
|
|
||||||
|
Args:
|
||||||
|
model_type: custom | base | design
|
||||||
|
"""
|
||||||
|
valid_types = ["custom", "base", "design"]
|
||||||
|
if model_type not in valid_types:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail=f"Invalid model type. Available: {valid_types}"
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
await tts_service._load_model(model_type)
|
||||||
|
return JSONResponse({
|
||||||
|
"status": "loaded",
|
||||||
|
"model_type": model_type,
|
||||||
|
"loaded_models": tts_service.get_loaded_models(),
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"모델 로드 실패: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail=f"Model load failed: {str(e)}")
|
||||||
0
audio-studio-tts/app/services/__init__.py
Normal file
0
audio-studio-tts/app/services/__init__.py
Normal file
272
audio-studio-tts/app/services/qwen_tts.py
Normal file
272
audio-studio-tts/app/services/qwen_tts.py
Normal file
@ -0,0 +1,272 @@
|
|||||||
|
"""Qwen3-TTS 모델 서비스 래퍼
|
||||||
|
|
||||||
|
Voice Clone, Voice Design, Custom Voice 기능 제공
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import io
|
||||||
|
import logging
|
||||||
|
from typing import Optional, Tuple, List
|
||||||
|
from enum import Enum
|
||||||
|
|
||||||
|
import torch
|
||||||
|
import soundfile as sf
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class VoiceType(str, Enum):
|
||||||
|
"""지원하는 음성 타입"""
|
||||||
|
CLONE = "clone" # 레퍼런스 오디오 기반 복제
|
||||||
|
DESIGN = "design" # 텍스트 프롬프트 기반 설계
|
||||||
|
CUSTOM = "custom" # 프리셋 음성
|
||||||
|
|
||||||
|
|
||||||
|
# CustomVoice 모델의 프리셋 스피커
|
||||||
|
PRESET_SPEAKERS = [
|
||||||
|
"Chelsie", # 여성, 밝고 활기찬
|
||||||
|
"Ethan", # 남성, 차분한
|
||||||
|
"Vivian", # 여성, 부드러운
|
||||||
|
"Benjamin", # 남성, 깊은
|
||||||
|
"Aurora", # 여성, 우아한
|
||||||
|
"Oliver", # 남성, 친근한
|
||||||
|
"Luna", # 여성, 따뜻한
|
||||||
|
"Jasper", # 남성, 전문적인
|
||||||
|
"Aria", # 여성, 표현력 있는
|
||||||
|
]
|
||||||
|
|
||||||
|
# 언어 코드 매핑
|
||||||
|
LANGUAGE_MAP = {
|
||||||
|
"ko": "Korean",
|
||||||
|
"en": "English",
|
||||||
|
"ja": "Japanese",
|
||||||
|
"zh": "Chinese",
|
||||||
|
"de": "German",
|
||||||
|
"fr": "French",
|
||||||
|
"ru": "Russian",
|
||||||
|
"pt": "Portuguese",
|
||||||
|
"es": "Spanish",
|
||||||
|
"it": "Italian",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class QwenTTSService:
|
||||||
|
"""Qwen3-TTS 통합 서비스
|
||||||
|
|
||||||
|
세 가지 모델을 관리:
|
||||||
|
- Base: Voice Clone용
|
||||||
|
- CustomVoice: 프리셋 음성용
|
||||||
|
- VoiceDesign: 음성 설계용
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.device = "cuda:0" if torch.cuda.is_available() else "cpu"
|
||||||
|
self.dtype = torch.bfloat16 if torch.cuda.is_available() else torch.float32
|
||||||
|
|
||||||
|
# 모델 ID (환경변수에서 가져오거나 기본값 사용)
|
||||||
|
self.model_ids = {
|
||||||
|
"base": os.getenv("MODEL_ID", "Qwen/Qwen3-TTS-12Hz-1.7B-Base"),
|
||||||
|
"custom": os.getenv("CLONE_MODEL_ID", "Qwen/Qwen3-TTS-12Hz-1.7B-CustomVoice"),
|
||||||
|
"design": os.getenv("DESIGN_MODEL_ID", "Qwen/Qwen3-TTS-12Hz-1.7B-VoiceDesign"),
|
||||||
|
}
|
||||||
|
|
||||||
|
# 모델 인스턴스 (lazy loading)
|
||||||
|
self._models = {}
|
||||||
|
self._initialized = False
|
||||||
|
|
||||||
|
async def initialize(self, preload_models: Optional[List[str]] = None):
|
||||||
|
"""모델 초기화 (서버 시작 시 호출)
|
||||||
|
|
||||||
|
Args:
|
||||||
|
preload_models: 미리 로드할 모델 리스트 (예: ["custom", "design"])
|
||||||
|
"""
|
||||||
|
if self._initialized:
|
||||||
|
return
|
||||||
|
|
||||||
|
logger.info(f"Qwen3-TTS 서비스 초기화 (device: {self.device})")
|
||||||
|
|
||||||
|
# 기본적으로 CustomVoice 모델만 로드 (가장 많이 사용)
|
||||||
|
if preload_models is None:
|
||||||
|
preload_models = ["custom"]
|
||||||
|
|
||||||
|
for model_type in preload_models:
|
||||||
|
await self._load_model(model_type)
|
||||||
|
|
||||||
|
self._initialized = True
|
||||||
|
logger.info("Qwen3-TTS 서비스 초기화 완료")
|
||||||
|
|
||||||
|
async def _load_model(self, model_type: str):
|
||||||
|
"""모델 로딩 (lazy loading)"""
|
||||||
|
if model_type in self._models:
|
||||||
|
return self._models[model_type]
|
||||||
|
|
||||||
|
model_id = self.model_ids.get(model_type)
|
||||||
|
if not model_id:
|
||||||
|
raise ValueError(f"Unknown model type: {model_type}")
|
||||||
|
|
||||||
|
logger.info(f"모델 로딩 중: {model_id}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
from qwen_tts import Qwen3TTSModel
|
||||||
|
|
||||||
|
# FlashAttention 사용 가능 여부 확인
|
||||||
|
attn_impl = "flash_attention_2"
|
||||||
|
try:
|
||||||
|
import flash_attn
|
||||||
|
except ImportError:
|
||||||
|
attn_impl = "sdpa"
|
||||||
|
logger.warning("FlashAttention 미설치, SDPA 사용")
|
||||||
|
|
||||||
|
model = Qwen3TTSModel.from_pretrained(
|
||||||
|
model_id,
|
||||||
|
device_map=self.device,
|
||||||
|
dtype=self.dtype,
|
||||||
|
attn_implementation=attn_impl,
|
||||||
|
)
|
||||||
|
|
||||||
|
self._models[model_type] = model
|
||||||
|
logger.info(f"모델 로드 완료: {model_id}")
|
||||||
|
return model
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"모델 로드 실패: {model_id} - {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
def _get_language(self, lang_code: str) -> str:
|
||||||
|
"""언어 코드를 모델 언어명으로 변환"""
|
||||||
|
return LANGUAGE_MAP.get(lang_code, "English")
|
||||||
|
|
||||||
|
def _to_wav_bytes(self, audio: np.ndarray, sample_rate: int) -> bytes:
|
||||||
|
"""numpy 배열을 WAV 바이트로 변환"""
|
||||||
|
buffer = io.BytesIO()
|
||||||
|
sf.write(buffer, audio, sample_rate, format='WAV')
|
||||||
|
buffer.seek(0)
|
||||||
|
return buffer.read()
|
||||||
|
|
||||||
|
async def synthesize_custom(
|
||||||
|
self,
|
||||||
|
text: str,
|
||||||
|
speaker: str = "Chelsie",
|
||||||
|
language: str = "ko",
|
||||||
|
instruct: Optional[str] = None,
|
||||||
|
) -> Tuple[bytes, int]:
|
||||||
|
"""프리셋 음성으로 TTS 합성
|
||||||
|
|
||||||
|
Args:
|
||||||
|
text: 합성할 텍스트
|
||||||
|
speaker: 프리셋 스피커 이름
|
||||||
|
language: 언어 코드 (ko, en, ja 등)
|
||||||
|
instruct: 감정/스타일 지시 (선택)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
(WAV 바이트, 샘플레이트)
|
||||||
|
"""
|
||||||
|
model = await self._load_model("custom")
|
||||||
|
lang = self._get_language(language)
|
||||||
|
|
||||||
|
logger.info(f"Custom TTS 합성: speaker={speaker}, lang={lang}, text_len={len(text)}")
|
||||||
|
|
||||||
|
wavs, sr = model.generate_custom_voice(
|
||||||
|
text=text,
|
||||||
|
language=lang,
|
||||||
|
speaker=speaker,
|
||||||
|
instruct=instruct,
|
||||||
|
)
|
||||||
|
|
||||||
|
audio_bytes = self._to_wav_bytes(wavs[0], sr)
|
||||||
|
return audio_bytes, sr
|
||||||
|
|
||||||
|
async def synthesize_clone(
|
||||||
|
self,
|
||||||
|
text: str,
|
||||||
|
ref_audio: bytes,
|
||||||
|
ref_text: str,
|
||||||
|
language: str = "ko",
|
||||||
|
) -> Tuple[bytes, int]:
|
||||||
|
"""Voice Clone으로 TTS 합성
|
||||||
|
|
||||||
|
Args:
|
||||||
|
text: 합성할 텍스트
|
||||||
|
ref_audio: 레퍼런스 오디오 바이트 (3초 이상 권장)
|
||||||
|
ref_text: 레퍼런스 오디오의 트랜스크립트
|
||||||
|
language: 언어 코드
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
(WAV 바이트, 샘플레이트)
|
||||||
|
"""
|
||||||
|
model = await self._load_model("base")
|
||||||
|
lang = self._get_language(language)
|
||||||
|
|
||||||
|
# 레퍼런스 오디오를 임시 파일로 저장
|
||||||
|
import tempfile
|
||||||
|
with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as f:
|
||||||
|
f.write(ref_audio)
|
||||||
|
ref_audio_path = f.name
|
||||||
|
|
||||||
|
try:
|
||||||
|
logger.info(f"Voice Clone 합성: lang={lang}, text_len={len(text)}")
|
||||||
|
|
||||||
|
wavs, sr = model.generate_voice_clone(
|
||||||
|
text=text,
|
||||||
|
language=lang,
|
||||||
|
ref_audio=ref_audio_path,
|
||||||
|
ref_text=ref_text,
|
||||||
|
)
|
||||||
|
|
||||||
|
audio_bytes = self._to_wav_bytes(wavs[0], sr)
|
||||||
|
return audio_bytes, sr
|
||||||
|
|
||||||
|
finally:
|
||||||
|
# 임시 파일 삭제
|
||||||
|
os.unlink(ref_audio_path)
|
||||||
|
|
||||||
|
async def synthesize_design(
|
||||||
|
self,
|
||||||
|
text: str,
|
||||||
|
instruct: str,
|
||||||
|
language: str = "ko",
|
||||||
|
) -> Tuple[bytes, int]:
|
||||||
|
"""Voice Design으로 TTS 합성
|
||||||
|
|
||||||
|
Args:
|
||||||
|
text: 합성할 텍스트
|
||||||
|
instruct: 음성 디자인 프롬프트 (예: "30대 남성, 부드럽고 차분한 목소리")
|
||||||
|
language: 언어 코드
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
(WAV 바이트, 샘플레이트)
|
||||||
|
"""
|
||||||
|
model = await self._load_model("design")
|
||||||
|
lang = self._get_language(language)
|
||||||
|
|
||||||
|
logger.info(f"Voice Design 합성: lang={lang}, instruct={instruct[:50]}...")
|
||||||
|
|
||||||
|
wavs, sr = model.generate_voice_design(
|
||||||
|
text=text,
|
||||||
|
language=lang,
|
||||||
|
instruct=instruct,
|
||||||
|
)
|
||||||
|
|
||||||
|
audio_bytes = self._to_wav_bytes(wavs[0], sr)
|
||||||
|
return audio_bytes, sr
|
||||||
|
|
||||||
|
def get_preset_speakers(self) -> List[str]:
|
||||||
|
"""프리셋 스피커 목록 반환"""
|
||||||
|
return PRESET_SPEAKERS.copy()
|
||||||
|
|
||||||
|
def get_supported_languages(self) -> dict:
|
||||||
|
"""지원 언어 목록 반환"""
|
||||||
|
return LANGUAGE_MAP.copy()
|
||||||
|
|
||||||
|
def is_initialized(self) -> bool:
|
||||||
|
"""초기화 상태 확인"""
|
||||||
|
return self._initialized
|
||||||
|
|
||||||
|
def get_loaded_models(self) -> List[str]:
|
||||||
|
"""현재 로드된 모델 목록"""
|
||||||
|
return list(self._models.keys())
|
||||||
|
|
||||||
|
|
||||||
|
# 싱글톤 인스턴스
|
||||||
|
tts_service = QwenTTSService()
|
||||||
29
audio-studio-tts/requirements.txt
Normal file
29
audio-studio-tts/requirements.txt
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
# Audio Studio TTS Engine - Dependencies
|
||||||
|
|
||||||
|
# FastAPI
|
||||||
|
fastapi==0.115.6
|
||||||
|
uvicorn[standard]==0.34.0
|
||||||
|
python-multipart==0.0.20
|
||||||
|
|
||||||
|
# Qwen3-TTS
|
||||||
|
qwen-tts>=0.0.5
|
||||||
|
|
||||||
|
# PyTorch (CUDA 12.x)
|
||||||
|
--extra-index-url https://download.pytorch.org/whl/cu124
|
||||||
|
torch>=2.5.0
|
||||||
|
torchaudio>=2.5.0
|
||||||
|
|
||||||
|
# Audio Processing
|
||||||
|
soundfile>=0.12.1
|
||||||
|
numpy>=1.26.0
|
||||||
|
scipy>=1.14.0
|
||||||
|
librosa>=0.10.2
|
||||||
|
|
||||||
|
# FlashAttention 2 (optional, 별도 설치 권장)
|
||||||
|
# flash-attn>=2.7.0
|
||||||
|
|
||||||
|
# Utilities
|
||||||
|
httpx>=0.28.0
|
||||||
|
pydantic>=2.10.0
|
||||||
|
pydantic-settings>=2.7.0
|
||||||
|
python-dotenv>=1.0.1
|
||||||
43
audio-studio-ui/Dockerfile
Normal file
43
audio-studio-ui/Dockerfile
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
# Audio Studio UI - Dockerfile
|
||||||
|
|
||||||
|
FROM node:22-alpine AS base
|
||||||
|
|
||||||
|
# 의존성 설치
|
||||||
|
FROM base AS deps
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY package.json package-lock.json* ./
|
||||||
|
RUN npm ci
|
||||||
|
|
||||||
|
# 빌드
|
||||||
|
FROM base AS builder
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY --from=deps /app/node_modules ./node_modules
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# 프로덕션
|
||||||
|
FROM base AS runner
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
|
||||||
|
RUN addgroup --system --gid 1001 nodejs
|
||||||
|
RUN adduser --system --uid 1001 nextjs
|
||||||
|
|
||||||
|
COPY --from=builder /app/public ./public
|
||||||
|
|
||||||
|
# standalone 모드 사용
|
||||||
|
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
||||||
|
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
||||||
|
|
||||||
|
USER nextjs
|
||||||
|
|
||||||
|
EXPOSE 3000
|
||||||
|
|
||||||
|
ENV PORT=3000
|
||||||
|
ENV HOSTNAME="0.0.0.0"
|
||||||
|
|
||||||
|
CMD ["node", "server.js"]
|
||||||
6
audio-studio-ui/next-env.d.ts
vendored
Normal file
6
audio-studio-ui/next-env.d.ts
vendored
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
/// <reference types="next" />
|
||||||
|
/// <reference types="next/image-types/global" />
|
||||||
|
/// <reference path="./.next/types/routes.d.ts" />
|
||||||
|
|
||||||
|
// NOTE: This file should not be edited
|
||||||
|
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||||
10
audio-studio-ui/next.config.ts
Normal file
10
audio-studio-ui/next.config.ts
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
import type { NextConfig } from "next";
|
||||||
|
|
||||||
|
const nextConfig: NextConfig = {
|
||||||
|
output: "standalone",
|
||||||
|
env: {
|
||||||
|
NEXT_PUBLIC_API_URL: process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default nextConfig;
|
||||||
7238
audio-studio-ui/package-lock.json
generated
Normal file
7238
audio-studio-ui/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
42
audio-studio-ui/package.json
Normal file
42
audio-studio-ui/package.json
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
{
|
||||||
|
"name": "drama-studio-ui",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"dev": "next dev",
|
||||||
|
"build": "next build",
|
||||||
|
"start": "next start",
|
||||||
|
"lint": "next lint"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"next": "^15.1.0",
|
||||||
|
"react": "^19.0.0",
|
||||||
|
"react-dom": "^19.0.0",
|
||||||
|
"@radix-ui/react-dialog": "^1.1.4",
|
||||||
|
"@radix-ui/react-dropdown-menu": "^2.1.4",
|
||||||
|
"@radix-ui/react-icons": "^1.3.2",
|
||||||
|
"@radix-ui/react-label": "^2.1.1",
|
||||||
|
"@radix-ui/react-select": "^2.1.4",
|
||||||
|
"@radix-ui/react-slider": "^1.2.2",
|
||||||
|
"@radix-ui/react-slot": "^1.1.1",
|
||||||
|
"@radix-ui/react-tabs": "^1.1.2",
|
||||||
|
"@radix-ui/react-toast": "^1.2.4",
|
||||||
|
"@radix-ui/react-tooltip": "^1.1.6",
|
||||||
|
"class-variance-authority": "^0.7.1",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"lucide-react": "^0.469.0",
|
||||||
|
"tailwind-merge": "^2.6.0",
|
||||||
|
"tailwindcss-animate": "^1.0.7"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@tailwindcss/postcss": "^4.0.0",
|
||||||
|
"@types/node": "^22.10.0",
|
||||||
|
"@types/react": "^19.0.0",
|
||||||
|
"@types/react-dom": "^19.0.0",
|
||||||
|
"typescript": "^5.7.0",
|
||||||
|
"tailwindcss": "^4.0.0",
|
||||||
|
"postcss": "^8.4.49",
|
||||||
|
"eslint": "^9.17.0",
|
||||||
|
"eslint-config-next": "^15.1.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
5
audio-studio-ui/postcss.config.mjs
Normal file
5
audio-studio-ui/postcss.config.mjs
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
"@tailwindcss/postcss": {},
|
||||||
|
},
|
||||||
|
};
|
||||||
0
audio-studio-ui/public/.gitkeep
Normal file
0
audio-studio-ui/public/.gitkeep
Normal file
432
audio-studio-ui/src/app/drama/page.tsx
Normal file
432
audio-studio-ui/src/app/drama/page.tsx
Normal file
@ -0,0 +1,432 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import {
|
||||||
|
parseScript,
|
||||||
|
createDramaProject,
|
||||||
|
getDramaProjects,
|
||||||
|
getDramaProject,
|
||||||
|
renderDrama,
|
||||||
|
getDramaDownloadUrl,
|
||||||
|
getVoices,
|
||||||
|
updateVoiceMapping,
|
||||||
|
DramaProject,
|
||||||
|
ParsedScript,
|
||||||
|
Voice,
|
||||||
|
} from "@/lib/api";
|
||||||
|
import { formatDuration } from "@/lib/utils";
|
||||||
|
|
||||||
|
// 예시 스크립트
|
||||||
|
const EXAMPLE_SCRIPT = `# 아침의 카페
|
||||||
|
|
||||||
|
[장소: 조용한 카페, 아침 햇살이 들어오는 창가]
|
||||||
|
|
||||||
|
[음악: 잔잔한 재즈 피아노]
|
||||||
|
|
||||||
|
민수(30대 남성, 차분): 오랜만이야. 잘 지냈어?
|
||||||
|
|
||||||
|
수진(20대 여성, 밝음): 어! 민수 오빠! 완전 오랜만이다!
|
||||||
|
|
||||||
|
[효과음: 커피잔 내려놓는 소리]
|
||||||
|
|
||||||
|
민수: 커피 시켰어. 아메리카노 맞지?
|
||||||
|
|
||||||
|
수진(감사하며): 고마워. 오빠는 여전하네.
|
||||||
|
|
||||||
|
[쉼: 2초]
|
||||||
|
|
||||||
|
민수(조용히): 그동안... 많이 보고 싶었어.
|
||||||
|
|
||||||
|
[음악 페이드아웃: 여운]`;
|
||||||
|
|
||||||
|
export default function DramaPage() {
|
||||||
|
// 상태
|
||||||
|
const [script, setScript] = useState(EXAMPLE_SCRIPT);
|
||||||
|
const [title, setTitle] = useState("새 드라마");
|
||||||
|
const [parsedScript, setParsedScript] = useState<ParsedScript | null>(null);
|
||||||
|
const [parseError, setParseError] = useState<string | null>(null);
|
||||||
|
const [projects, setProjects] = useState<DramaProject[]>([]);
|
||||||
|
const [selectedProject, setSelectedProject] = useState<DramaProject | null>(null);
|
||||||
|
const [voices, setVoices] = useState<Voice[]>([]);
|
||||||
|
const [voiceMapping, setVoiceMapping] = useState<Record<string, string>>({});
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [renderStatus, setRenderStatus] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// 초기 로드
|
||||||
|
useEffect(() => {
|
||||||
|
loadProjects();
|
||||||
|
loadVoices();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
async function loadProjects() {
|
||||||
|
try {
|
||||||
|
const data = await getDramaProjects();
|
||||||
|
setProjects(data);
|
||||||
|
} catch (e) {
|
||||||
|
console.error("프로젝트 로드 실패:", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadVoices() {
|
||||||
|
try {
|
||||||
|
const response = await getVoices({ page_size: 100 });
|
||||||
|
setVoices(response.voices);
|
||||||
|
} catch (e) {
|
||||||
|
console.error("보이스 로드 실패:", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 스크립트 미리보기 파싱
|
||||||
|
async function handleParsePreview() {
|
||||||
|
setParseError(null);
|
||||||
|
try {
|
||||||
|
const parsed = await parseScript(script);
|
||||||
|
setParsedScript(parsed);
|
||||||
|
|
||||||
|
// 캐릭터별 기본 보이스 매핑
|
||||||
|
const mapping: Record<string, string> = {};
|
||||||
|
parsed.characters.forEach((char) => {
|
||||||
|
if (!voiceMapping[char.name] && voices.length > 0) {
|
||||||
|
mapping[char.name] = voices[0].voice_id;
|
||||||
|
} else {
|
||||||
|
mapping[char.name] = voiceMapping[char.name] || "";
|
||||||
|
}
|
||||||
|
});
|
||||||
|
setVoiceMapping(mapping);
|
||||||
|
} catch (e) {
|
||||||
|
setParseError(e instanceof Error ? e.message : "파싱 실패");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 프로젝트 생성
|
||||||
|
async function handleCreateProject() {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const project = await createDramaProject({
|
||||||
|
title,
|
||||||
|
script,
|
||||||
|
voice_mapping: voiceMapping,
|
||||||
|
auto_generate_assets: true,
|
||||||
|
});
|
||||||
|
setSelectedProject(project);
|
||||||
|
await loadProjects();
|
||||||
|
} catch (e) {
|
||||||
|
alert(e instanceof Error ? e.message : "프로젝트 생성 실패");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 렌더링 시작
|
||||||
|
async function handleRender() {
|
||||||
|
if (!selectedProject) return;
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
setRenderStatus("렌더링 시작 중...");
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 보이스 매핑 업데이트
|
||||||
|
await updateVoiceMapping(selectedProject.project_id, voiceMapping);
|
||||||
|
|
||||||
|
// 렌더링 시작
|
||||||
|
await renderDrama(selectedProject.project_id);
|
||||||
|
setRenderStatus("렌더링 중... (TTS, 효과음 생성 및 믹싱)");
|
||||||
|
|
||||||
|
// 상태 폴링
|
||||||
|
const pollStatus = async () => {
|
||||||
|
const project = await getDramaProject(selectedProject.project_id);
|
||||||
|
setSelectedProject(project);
|
||||||
|
|
||||||
|
if (project.status === "completed") {
|
||||||
|
setRenderStatus("완료!");
|
||||||
|
setLoading(false);
|
||||||
|
} else if (project.status === "error") {
|
||||||
|
setRenderStatus(`오류: ${project.error_message}`);
|
||||||
|
setLoading(false);
|
||||||
|
} else {
|
||||||
|
setTimeout(pollStatus, 2000);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
setTimeout(pollStatus, 2000);
|
||||||
|
} catch (e) {
|
||||||
|
setRenderStatus(`오류: ${e instanceof Error ? e.message : "렌더링 실패"}`);
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 프로젝트 선택
|
||||||
|
async function handleSelectProject(project: DramaProject) {
|
||||||
|
setSelectedProject(project);
|
||||||
|
try {
|
||||||
|
const fullProject = await getDramaProject(project.project_id);
|
||||||
|
setSelectedProject(fullProject);
|
||||||
|
|
||||||
|
// 캐릭터 보이스 매핑
|
||||||
|
const mapping: Record<string, string> = {};
|
||||||
|
fullProject.characters.forEach((char) => {
|
||||||
|
mapping[char.name] = char.voice_id || "";
|
||||||
|
});
|
||||||
|
setVoiceMapping(mapping);
|
||||||
|
} catch (e) {
|
||||||
|
console.error("프로젝트 상세 로드 실패:", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* 헤더 */}
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold">드라마 스튜디오</h1>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
스크립트를 입력하면 AI가 라디오 드라마를 생성합니다
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-6 lg:grid-cols-3">
|
||||||
|
{/* 스크립트 에디터 */}
|
||||||
|
<div className="lg:col-span-2 space-y-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={title}
|
||||||
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
|
placeholder="드라마 제목"
|
||||||
|
className="flex-1 rounded-lg border bg-background px-3 py-2 text-sm font-medium focus:outline-none focus:ring-2 focus:ring-ring"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={handleParsePreview}
|
||||||
|
className="rounded-lg border px-4 py-2 text-sm font-medium hover:bg-accent"
|
||||||
|
>
|
||||||
|
미리보기
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="text-sm font-medium">스크립트</label>
|
||||||
|
<textarea
|
||||||
|
value={script}
|
||||||
|
onChange={(e) => setScript(e.target.value)}
|
||||||
|
placeholder="스크립트를 입력하세요..."
|
||||||
|
className="mt-1 w-full h-80 rounded-lg border bg-background px-3 py-2 text-sm font-mono resize-none focus:outline-none focus:ring-2 focus:ring-ring"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 문법 가이드 */}
|
||||||
|
<div className="rounded-lg bg-secondary p-4 text-sm">
|
||||||
|
<h3 className="font-medium mb-2">스크립트 문법</h3>
|
||||||
|
<ul className="space-y-1 text-muted-foreground">
|
||||||
|
<li><code className="bg-background px-1 rounded"># 제목</code> - 드라마 제목</li>
|
||||||
|
<li><code className="bg-background px-1 rounded">[장소: 설명]</code> - 장면 지문</li>
|
||||||
|
<li><code className="bg-background px-1 rounded">[효과음: 설명]</code> - 효과음 삽입</li>
|
||||||
|
<li><code className="bg-background px-1 rounded">[음악: 설명]</code> - 배경음악</li>
|
||||||
|
<li><code className="bg-background px-1 rounded">[쉼: 2초]</code> - 간격</li>
|
||||||
|
<li><code className="bg-background px-1 rounded">캐릭터(감정): 대사</code> - 대사</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 파싱 에러 */}
|
||||||
|
{parseError && (
|
||||||
|
<div className="rounded-lg border border-destructive/50 bg-destructive/10 p-3 text-sm text-destructive">
|
||||||
|
{parseError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 파싱 결과 */}
|
||||||
|
{parsedScript && (
|
||||||
|
<div className="rounded-lg border p-4 space-y-4">
|
||||||
|
<h3 className="font-medium">파싱 결과</h3>
|
||||||
|
|
||||||
|
{/* 캐릭터 목록 & 보이스 매핑 */}
|
||||||
|
<div>
|
||||||
|
<h4 className="text-sm font-medium mb-2">등장인물 ({parsedScript.characters.length}명)</h4>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{parsedScript.characters.map((char) => (
|
||||||
|
<div key={char.name} className="flex items-center gap-3">
|
||||||
|
<span className="w-24 text-sm truncate">{char.name}</span>
|
||||||
|
<span className="text-xs text-muted-foreground w-32 truncate">
|
||||||
|
{char.description || "-"}
|
||||||
|
</span>
|
||||||
|
<select
|
||||||
|
value={voiceMapping[char.name] || ""}
|
||||||
|
onChange={(e) =>
|
||||||
|
setVoiceMapping({ ...voiceMapping, [char.name]: e.target.value })
|
||||||
|
}
|
||||||
|
className="flex-1 rounded-lg border bg-background px-2 py-1 text-sm"
|
||||||
|
>
|
||||||
|
<option value="">보이스 선택...</option>
|
||||||
|
{voices.map((voice) => (
|
||||||
|
<option key={voice.voice_id} value={voice.voice_id}>
|
||||||
|
{voice.name} ({voice.type})
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 요소 수 */}
|
||||||
|
<div className="text-sm text-muted-foreground">
|
||||||
|
총 {parsedScript.elements.length}개 요소
|
||||||
|
(대사 {parsedScript.elements.filter((e) => e.type === "dialogue").length}개,
|
||||||
|
효과음 {parsedScript.elements.filter((e) => e.type === "sfx").length}개,
|
||||||
|
음악 {parsedScript.elements.filter((e) => e.type === "music").length}개)
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 프로젝트 생성 버튼 */}
|
||||||
|
<button
|
||||||
|
onClick={handleCreateProject}
|
||||||
|
disabled={loading}
|
||||||
|
className="w-full inline-flex items-center justify-center gap-2 rounded-lg bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<>
|
||||||
|
<div className="h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent" />
|
||||||
|
생성 중...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
"프로젝트 생성"
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 프로젝트 목록 & 상세 */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h2 className="font-semibold">프로젝트</h2>
|
||||||
|
|
||||||
|
{/* 프로젝트 목록 */}
|
||||||
|
<div className="space-y-2 max-h-60 overflow-y-auto">
|
||||||
|
{projects.map((project) => (
|
||||||
|
<button
|
||||||
|
key={project.project_id}
|
||||||
|
onClick={() => handleSelectProject(project)}
|
||||||
|
className={`w-full text-left rounded-lg border p-3 transition-colors ${
|
||||||
|
selectedProject?.project_id === project.project_id
|
||||||
|
? "border-primary bg-primary/5"
|
||||||
|
: "hover:bg-accent"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="font-medium text-sm truncate">{project.title}</span>
|
||||||
|
<span
|
||||||
|
className={`text-xs px-1.5 py-0.5 rounded ${
|
||||||
|
project.status === "completed"
|
||||||
|
? "bg-green-100 text-green-700"
|
||||||
|
: project.status === "processing"
|
||||||
|
? "bg-yellow-100 text-yellow-700"
|
||||||
|
: project.status === "error"
|
||||||
|
? "bg-red-100 text-red-700"
|
||||||
|
: "bg-secondary"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{project.status === "completed"
|
||||||
|
? "완료"
|
||||||
|
: project.status === "processing"
|
||||||
|
? "처리 중"
|
||||||
|
: project.status === "error"
|
||||||
|
? "오류"
|
||||||
|
: "초안"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-muted-foreground mt-1">
|
||||||
|
{project.characters.length}명 · {project.element_count}개 요소
|
||||||
|
{project.estimated_duration && (
|
||||||
|
<> · {formatDuration(project.estimated_duration)}</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{projects.length === 0 && (
|
||||||
|
<p className="text-sm text-muted-foreground text-center py-4">
|
||||||
|
프로젝트가 없습니다
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 선택된 프로젝트 상세 */}
|
||||||
|
{selectedProject && (
|
||||||
|
<div className="rounded-lg border p-4 space-y-4">
|
||||||
|
<h3 className="font-medium">{selectedProject.title}</h3>
|
||||||
|
|
||||||
|
<div className="text-sm space-y-1">
|
||||||
|
<p>상태: {selectedProject.status}</p>
|
||||||
|
<p>등장인물: {selectedProject.characters.length}명</p>
|
||||||
|
<p>요소: {selectedProject.element_count}개</p>
|
||||||
|
{selectedProject.estimated_duration && (
|
||||||
|
<p>예상 길이: {formatDuration(selectedProject.estimated_duration)}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 렌더링 상태 */}
|
||||||
|
{renderStatus && (
|
||||||
|
<div className="text-sm text-muted-foreground">{renderStatus}</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 액션 버튼 */}
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{selectedProject.status === "draft" && (
|
||||||
|
<button
|
||||||
|
onClick={handleRender}
|
||||||
|
disabled={loading}
|
||||||
|
className="flex-1 inline-flex items-center justify-center gap-2 rounded-lg bg-primary px-3 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<>
|
||||||
|
<div className="h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent" />
|
||||||
|
처리 중...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
style={{ width: 16, height: 16 }}
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="currentColor"
|
||||||
|
>
|
||||||
|
<polygon points="5 3 19 12 5 21 5 3" />
|
||||||
|
</svg>
|
||||||
|
렌더링
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{selectedProject.status === "completed" && (
|
||||||
|
<a
|
||||||
|
href={getDramaDownloadUrl(selectedProject.project_id)}
|
||||||
|
download
|
||||||
|
className="flex-1 inline-flex items-center justify-center gap-2 rounded-lg bg-primary px-3 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
style={{ width: 16, height: 16 }}
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
>
|
||||||
|
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
|
||||||
|
<polyline points="7 10 12 15 17 10" />
|
||||||
|
<line x1="12" y1="15" x2="12" y2="3" />
|
||||||
|
</svg>
|
||||||
|
다운로드
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{selectedProject.error_message && (
|
||||||
|
<div className="rounded-lg border border-destructive/50 bg-destructive/10 p-3 text-sm text-destructive">
|
||||||
|
{selectedProject.error_message}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
59
audio-studio-ui/src/app/globals.css
Normal file
59
audio-studio-ui/src/app/globals.css
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
|
|
||||||
|
@theme {
|
||||||
|
--color-background: oklch(1 0 0);
|
||||||
|
--color-foreground: oklch(0.145 0 0);
|
||||||
|
--color-card: oklch(1 0 0);
|
||||||
|
--color-card-foreground: oklch(0.145 0 0);
|
||||||
|
--color-popover: oklch(1 0 0);
|
||||||
|
--color-popover-foreground: oklch(0.145 0 0);
|
||||||
|
--color-primary: oklch(0.205 0 0);
|
||||||
|
--color-primary-foreground: oklch(0.985 0 0);
|
||||||
|
--color-secondary: oklch(0.97 0 0);
|
||||||
|
--color-secondary-foreground: oklch(0.205 0 0);
|
||||||
|
--color-muted: oklch(0.97 0 0);
|
||||||
|
--color-muted-foreground: oklch(0.556 0 0);
|
||||||
|
--color-accent: oklch(0.97 0 0);
|
||||||
|
--color-accent-foreground: oklch(0.205 0 0);
|
||||||
|
--color-destructive: oklch(0.577 0.245 27.325);
|
||||||
|
--color-destructive-foreground: oklch(0.577 0.245 27.325);
|
||||||
|
--color-border: oklch(0.922 0 0);
|
||||||
|
--color-input: oklch(0.922 0 0);
|
||||||
|
--color-ring: oklch(0.708 0.165 254.624);
|
||||||
|
--radius: 0.625rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
* {
|
||||||
|
@apply border-border;
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
@apply bg-background text-foreground;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 오디오 파형 스타일 */
|
||||||
|
.waveform-container {
|
||||||
|
@apply w-full h-16 bg-muted rounded-lg overflow-hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 오디오 플레이어 스타일 */
|
||||||
|
.audio-player {
|
||||||
|
@apply flex items-center gap-4 p-4 bg-card rounded-lg border;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 녹음 버튼 애니메이션 */
|
||||||
|
@keyframes pulse-recording {
|
||||||
|
0%, 100% {
|
||||||
|
opacity: 1;
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
opacity: 0.8;
|
||||||
|
transform: scale(1.05);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.recording-pulse {
|
||||||
|
animation: pulse-recording 1.5s ease-in-out infinite;
|
||||||
|
}
|
||||||
85
audio-studio-ui/src/app/layout.tsx
Normal file
85
audio-studio-ui/src/app/layout.tsx
Normal file
@ -0,0 +1,85 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import "./globals.css";
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "Drama Studio",
|
||||||
|
description: "AI 라디오 드라마 제작 시스템",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function RootLayout({
|
||||||
|
children,
|
||||||
|
}: Readonly<{
|
||||||
|
children: React.ReactNode;
|
||||||
|
}>) {
|
||||||
|
return (
|
||||||
|
<html lang="ko">
|
||||||
|
<body className="min-h-screen bg-background antialiased">
|
||||||
|
<div className="flex min-h-screen">
|
||||||
|
{/* 사이드바 */}
|
||||||
|
<aside className="w-48 border-r bg-card px-3 py-4">
|
||||||
|
<div className="mb-5 px-2">
|
||||||
|
<h1 className="text-base font-bold">Drama Studio</h1>
|
||||||
|
<p className="text-xs text-muted-foreground">AI 드라마 제작</p>
|
||||||
|
</div>
|
||||||
|
<nav className="space-y-0.5">
|
||||||
|
<a href="/" className="flex items-center gap-2 rounded-md px-2 py-1.5 text-sm hover:bg-accent">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" style={{width: 16, height: 16, flexShrink: 0}} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" />
|
||||||
|
<polyline points="9,22 9,12 15,12 15,22" />
|
||||||
|
</svg>
|
||||||
|
대시보드
|
||||||
|
</a>
|
||||||
|
<a href="/drama" className="flex items-center gap-2 rounded-md px-2 py-1.5 text-sm hover:bg-accent">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" style={{width: 16, height: 16, flexShrink: 0}} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20" />
|
||||||
|
<path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z" />
|
||||||
|
</svg>
|
||||||
|
드라마
|
||||||
|
</a>
|
||||||
|
<a href="/voices" className="flex items-center gap-2 rounded-md px-2 py-1.5 text-sm hover:bg-accent">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" style={{width: 16, height: 16, flexShrink: 0}} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<path d="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z" />
|
||||||
|
<path d="M19 10v2a7 7 0 0 1-14 0v-2" />
|
||||||
|
<line x1="12" y1="19" x2="12" y2="23" />
|
||||||
|
<line x1="8" y1="23" x2="16" y2="23" />
|
||||||
|
</svg>
|
||||||
|
보이스
|
||||||
|
</a>
|
||||||
|
<a href="/tts" className="flex items-center gap-2 rounded-md px-2 py-1.5 text-sm hover:bg-accent">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" style={{width: 16, height: 16, flexShrink: 0}} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5" />
|
||||||
|
<path d="M19.07 4.93a10 10 0 0 1 0 14.14M15.54 8.46a5 5 0 0 1 0 7.07" />
|
||||||
|
</svg>
|
||||||
|
TTS
|
||||||
|
</a>
|
||||||
|
<a href="/recordings" className="flex items-center gap-2 rounded-md px-2 py-1.5 text-sm hover:bg-accent">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" style={{width: 16, height: 16, flexShrink: 0}} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<circle cx="12" cy="12" r="10" />
|
||||||
|
<circle cx="12" cy="12" r="3" fill="currentColor" />
|
||||||
|
</svg>
|
||||||
|
녹음
|
||||||
|
</a>
|
||||||
|
<a href="/sound-effects" className="flex items-center gap-2 rounded-md px-2 py-1.5 text-sm hover:bg-accent">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" style={{width: 16, height: 16, flexShrink: 0}} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2" />
|
||||||
|
</svg>
|
||||||
|
효과음
|
||||||
|
</a>
|
||||||
|
<a href="/music" className="flex items-center gap-2 rounded-md px-2 py-1.5 text-sm hover:bg-accent">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" style={{width: 16, height: 16, flexShrink: 0}} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<path d="M9 18V5l12-2v13" />
|
||||||
|
<circle cx="6" cy="18" r="3" />
|
||||||
|
<circle cx="18" cy="16" r="3" />
|
||||||
|
</svg>
|
||||||
|
음악
|
||||||
|
</a>
|
||||||
|
</nav>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
{/* 메인 콘텐츠 */}
|
||||||
|
<main className="flex-1 p-6">{children}</main>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
||||||
251
audio-studio-ui/src/app/music/page.tsx
Normal file
251
audio-studio-ui/src/app/music/page.tsx
Normal file
@ -0,0 +1,251 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useRef } from "react";
|
||||||
|
import { formatDuration } from "@/lib/utils";
|
||||||
|
|
||||||
|
const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000";
|
||||||
|
|
||||||
|
// 예시 프롬프트
|
||||||
|
const EXAMPLE_PROMPTS = [
|
||||||
|
{ category: "Ambient", prompts: ["calm piano music, peaceful, ambient", "lo-fi hip hop beats, relaxing, study music", "meditation music, calm, zen"] },
|
||||||
|
{ category: "Electronic", prompts: ["upbeat electronic dance music", "retro synthwave 80s style", "chill electronic ambient"] },
|
||||||
|
{ category: "Cinematic", prompts: ["epic orchestral cinematic music", "tense suspenseful thriller music", "cheerful happy video game background"] },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function MusicPage() {
|
||||||
|
const [prompt, setPrompt] = useState("");
|
||||||
|
const [duration, setDuration] = useState(30);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [audioUrl, setAudioUrl] = useState<string | null>(null);
|
||||||
|
const audioRef = useRef<HTMLAudioElement>(null);
|
||||||
|
|
||||||
|
async function handleGenerate() {
|
||||||
|
if (!prompt.trim()) {
|
||||||
|
setError("프롬프트를 입력하세요");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_BASE_URL}/api/v1/music/generate`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
prompt: prompt.trim(),
|
||||||
|
duration,
|
||||||
|
save_to_library: true,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorData = await response.json().catch(() => ({ detail: "Unknown error" }));
|
||||||
|
throw new Error(errorData.detail || `HTTP ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const blob = await response.blob();
|
||||||
|
|
||||||
|
// 기존 URL 해제
|
||||||
|
if (audioUrl) {
|
||||||
|
URL.revokeObjectURL(audioUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
setAudioUrl(url);
|
||||||
|
|
||||||
|
// 자동 재생
|
||||||
|
setTimeout(() => {
|
||||||
|
audioRef.current?.play();
|
||||||
|
}, 100);
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : "음악 생성 실패");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDownload() {
|
||||||
|
if (!audioUrl) return;
|
||||||
|
|
||||||
|
const a = document.createElement("a");
|
||||||
|
a.href = audioUrl;
|
||||||
|
a.download = `music_${Date.now()}.wav`;
|
||||||
|
a.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectPrompt(p: string) {
|
||||||
|
setPrompt(p);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* 헤더 */}
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold">배경음악 생성</h1>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
AI로 원하는 분위기의 배경음악을 생성하세요 (MusicGen)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-6 lg:grid-cols-3">
|
||||||
|
{/* 프롬프트 예시 */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h2 className="font-semibold">프롬프트 예시</h2>
|
||||||
|
<div className="space-y-4">
|
||||||
|
{EXAMPLE_PROMPTS.map((category) => (
|
||||||
|
<div key={category.category}>
|
||||||
|
<h3 className="text-sm font-medium text-muted-foreground mb-2">
|
||||||
|
{category.category}
|
||||||
|
</h3>
|
||||||
|
<div className="space-y-1">
|
||||||
|
{category.prompts.map((p) => (
|
||||||
|
<button
|
||||||
|
key={p}
|
||||||
|
onClick={() => selectPrompt(p)}
|
||||||
|
className="w-full text-left rounded-lg border px-3 py-2 text-sm hover:bg-accent transition-colors"
|
||||||
|
>
|
||||||
|
{p}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 생성 영역 */}
|
||||||
|
<div className="lg:col-span-2 space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="text-sm font-medium">프롬프트 (영어 권장)</label>
|
||||||
|
<textarea
|
||||||
|
value={prompt}
|
||||||
|
onChange={(e) => setPrompt(e.target.value)}
|
||||||
|
placeholder="원하는 음악을 설명하세요... (예: calm piano music, peaceful, ambient)"
|
||||||
|
className="mt-1 w-full h-32 rounded-lg border bg-background px-3 py-2 text-sm resize-none focus:outline-none focus:ring-2 focus:ring-ring"
|
||||||
|
maxLength={500}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground text-right">
|
||||||
|
{prompt.length}/500
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="text-sm font-medium">길이: {duration}초</label>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min={5}
|
||||||
|
max={30}
|
||||||
|
value={duration}
|
||||||
|
onChange={(e) => setDuration(Number(e.target.value))}
|
||||||
|
className="mt-1 w-full"
|
||||||
|
/>
|
||||||
|
<div className="flex justify-between text-xs text-muted-foreground">
|
||||||
|
<span>5초</span>
|
||||||
|
<span>30초</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 에러 */}
|
||||||
|
{error && (
|
||||||
|
<div className="rounded-lg border border-destructive/50 bg-destructive/10 p-3 text-sm text-destructive">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 버튼 */}
|
||||||
|
<button
|
||||||
|
onClick={handleGenerate}
|
||||||
|
disabled={loading || !prompt.trim()}
|
||||||
|
className="w-full inline-flex items-center justify-center gap-2 rounded-lg bg-primary px-4 py-3 text-sm font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<>
|
||||||
|
<div className="h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent" />
|
||||||
|
생성 중... (최대 2분 소요)
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
className="h-4 w-4"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
>
|
||||||
|
<path d="M9 18V5l12-2v13" />
|
||||||
|
<circle cx="6" cy="18" r="3" />
|
||||||
|
<circle cx="18" cy="16" r="3" />
|
||||||
|
</svg>
|
||||||
|
음악 생성
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* 오디오 플레이어 */}
|
||||||
|
{audioUrl && (
|
||||||
|
<div className="rounded-lg border bg-card p-4 space-y-3">
|
||||||
|
<h3 className="font-medium">생성된 음악</h3>
|
||||||
|
<audio
|
||||||
|
ref={audioRef}
|
||||||
|
src={audioUrl}
|
||||||
|
controls
|
||||||
|
className="w-full"
|
||||||
|
/>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => audioRef.current?.play()}
|
||||||
|
className="inline-flex items-center gap-2 rounded-lg border px-3 py-2 text-sm font-medium hover:bg-accent"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
className="h-4 w-4"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="currentColor"
|
||||||
|
>
|
||||||
|
<polygon points="5 3 19 12 5 21 5 3" />
|
||||||
|
</svg>
|
||||||
|
재생
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleDownload}
|
||||||
|
className="inline-flex items-center gap-2 rounded-lg border px-3 py-2 text-sm font-medium hover:bg-accent"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
className="h-4 w-4"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
>
|
||||||
|
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
|
||||||
|
<polyline points="7 10 12 15 17 10" />
|
||||||
|
<line x1="12" y1="15" x2="12" y2="3" />
|
||||||
|
</svg>
|
||||||
|
다운로드
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
* MusicGen으로 생성된 음악은 CC-BY-NC 라이센스입니다. 비상업적 용도로 사용 가능합니다.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 정보 */}
|
||||||
|
<div className="rounded-lg bg-secondary p-4">
|
||||||
|
<h3 className="font-medium mb-2">MusicGen 정보</h3>
|
||||||
|
<ul className="text-sm text-muted-foreground space-y-1">
|
||||||
|
<li>• Meta AI의 오픈소스 AI 음악 생성 모델</li>
|
||||||
|
<li>• 텍스트 프롬프트로 다양한 스타일의 음악 생성</li>
|
||||||
|
<li>• 최대 30초 길이의 음악 생성 가능</li>
|
||||||
|
<li>• 영어 프롬프트 사용 권장</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
235
audio-studio-ui/src/app/page.tsx
Normal file
235
audio-studio-ui/src/app/page.tsx
Normal file
@ -0,0 +1,235 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { healthCheck, getDramaProjects, DramaProject } from "@/lib/api";
|
||||||
|
|
||||||
|
export default function DashboardPage() {
|
||||||
|
const [health, setHealth] = useState<{
|
||||||
|
status: string;
|
||||||
|
services: { mongodb: string; redis: string };
|
||||||
|
} | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [projects, setProjects] = useState<DramaProject[]>([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
healthCheck()
|
||||||
|
.then(setHealth)
|
||||||
|
.catch((e) => setError(e.message));
|
||||||
|
|
||||||
|
getDramaProjects()
|
||||||
|
.then(setProjects)
|
||||||
|
.catch(() => {});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 프로젝트 통계
|
||||||
|
const stats = {
|
||||||
|
total: projects.length,
|
||||||
|
draft: projects.filter(p => p.status === "draft").length,
|
||||||
|
processing: projects.filter(p => p.status === "processing").length,
|
||||||
|
completed: projects.filter(p => p.status === "completed").length,
|
||||||
|
error: projects.filter(p => p.status === "error").length,
|
||||||
|
};
|
||||||
|
|
||||||
|
const statusLabel: Record<string, { text: string; color: string }> = {
|
||||||
|
draft: { text: "편집 중", color: "text-gray-600 bg-gray-100" },
|
||||||
|
processing: { text: "렌더링", color: "text-blue-600 bg-blue-100" },
|
||||||
|
completed: { text: "완료", color: "text-green-600 bg-green-100" },
|
||||||
|
error: { text: "오류", color: "text-red-600 bg-red-100" },
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6 max-w-5xl">
|
||||||
|
{/* 헤더 */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold">Drama Studio</h1>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
AI 라디오 드라마 제작 시스템
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<a
|
||||||
|
href="/drama"
|
||||||
|
className="inline-flex items-center gap-2 rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90"
|
||||||
|
>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" style={{width: 16, height: 16}} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<line x1="12" y1="5" x2="12" y2="19" />
|
||||||
|
<line x1="5" y1="12" x2="19" y2="12" />
|
||||||
|
</svg>
|
||||||
|
새 드라마
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 프로젝트 현황 */}
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-semibold mb-3">프로젝트 현황</h2>
|
||||||
|
<div className="grid gap-3 grid-cols-2 lg:grid-cols-5">
|
||||||
|
<div className="rounded-lg border bg-card p-4">
|
||||||
|
<h3 className="text-xs font-medium text-muted-foreground">전체</h3>
|
||||||
|
<p className="mt-1 text-2xl font-bold">{stats.total}</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-lg border bg-card p-4">
|
||||||
|
<h3 className="text-xs font-medium text-muted-foreground">편집 중</h3>
|
||||||
|
<p className="mt-1 text-2xl font-bold text-gray-600">{stats.draft}</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-lg border bg-card p-4">
|
||||||
|
<h3 className="text-xs font-medium text-muted-foreground">렌더링</h3>
|
||||||
|
<p className="mt-1 text-2xl font-bold text-blue-600">{stats.processing}</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-lg border bg-card p-4">
|
||||||
|
<h3 className="text-xs font-medium text-muted-foreground">완료</h3>
|
||||||
|
<p className="mt-1 text-2xl font-bold text-green-600">{stats.completed}</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-lg border bg-card p-4">
|
||||||
|
<h3 className="text-xs font-medium text-muted-foreground">오류</h3>
|
||||||
|
<p className="mt-1 text-2xl font-bold text-red-600">{stats.error}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 최근 프로젝트 */}
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<h2 className="text-lg font-semibold">최근 프로젝트</h2>
|
||||||
|
{projects.length > 0 && (
|
||||||
|
<a href="/drama" className="text-sm text-primary hover:underline">
|
||||||
|
전체 보기
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{projects.length === 0 ? (
|
||||||
|
<div className="rounded-lg border border-dashed p-8 text-center">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" style={{width: 40, height: 40, margin: "0 auto"}} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" className="text-muted-foreground">
|
||||||
|
<path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20" />
|
||||||
|
<path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z" />
|
||||||
|
</svg>
|
||||||
|
<p className="mt-3 text-sm text-muted-foreground">
|
||||||
|
아직 생성된 드라마가 없습니다
|
||||||
|
</p>
|
||||||
|
<a
|
||||||
|
href="/drama"
|
||||||
|
className="mt-3 inline-flex items-center gap-1 text-sm text-primary hover:underline"
|
||||||
|
>
|
||||||
|
첫 드라마 만들기
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" style={{width: 14, height: 14}} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<line x1="5" y1="12" x2="19" y2="12" />
|
||||||
|
<polyline points="12 5 19 12 12 19" />
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{projects.slice(0, 5).map((project) => (
|
||||||
|
<a
|
||||||
|
key={project.project_id}
|
||||||
|
href={`/drama?id=${project.project_id}`}
|
||||||
|
className="flex items-center justify-between rounded-lg border bg-card p-4 hover:bg-accent transition-colors"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" style={{width: 20, height: 20, flexShrink: 0}} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" className="text-muted-foreground">
|
||||||
|
<path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20" />
|
||||||
|
<path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z" />
|
||||||
|
</svg>
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-medium">{project.title}</h3>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{project.characters.length}명 캐릭터 · {project.element_count}개 요소
|
||||||
|
{project.estimated_duration && ` · ${Math.round(project.estimated_duration / 60)}분`}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span className={`rounded-full px-2 py-0.5 text-xs font-medium ${statusLabel[project.status]?.color || ""}`}>
|
||||||
|
{statusLabel[project.status]?.text || project.status}
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 시스템 상태 */}
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-semibold mb-3">시스템 상태</h2>
|
||||||
|
<div className="grid gap-3 grid-cols-2 lg:grid-cols-4">
|
||||||
|
<div className="rounded-lg border bg-card p-4">
|
||||||
|
<h3 className="text-xs font-medium text-muted-foreground">API</h3>
|
||||||
|
{error ? (
|
||||||
|
<p className="mt-1 text-lg font-semibold text-destructive">오프라인</p>
|
||||||
|
) : health ? (
|
||||||
|
<p className={`mt-1 text-lg font-semibold ${health.status === "healthy" ? "text-green-600" : "text-yellow-600"}`}>
|
||||||
|
{health.status === "healthy" ? "정상" : "점검 중"}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<p className="mt-1 text-lg font-semibold text-muted-foreground">...</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-lg border bg-card p-4">
|
||||||
|
<h3 className="text-xs font-medium text-muted-foreground">MongoDB</h3>
|
||||||
|
<p className={`mt-1 text-lg font-semibold ${health?.services.mongodb === "healthy" ? "text-green-600" : "text-muted-foreground"}`}>
|
||||||
|
{health?.services.mongodb === "healthy" ? "연결됨" : "-"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-lg border bg-card p-4">
|
||||||
|
<h3 className="text-xs font-medium text-muted-foreground">Redis</h3>
|
||||||
|
<p className={`mt-1 text-lg font-semibold ${health?.services.redis === "healthy" ? "text-green-600" : "text-muted-foreground"}`}>
|
||||||
|
{health?.services.redis === "healthy" ? "연결됨" : "-"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-lg border bg-card p-4">
|
||||||
|
<h3 className="text-xs font-medium text-muted-foreground">TTS 엔진</h3>
|
||||||
|
<p className="mt-1 text-lg font-semibold text-muted-foreground">-</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 빠른 시작 */}
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-semibold mb-3">빠른 시작</h2>
|
||||||
|
<div className="grid gap-3 grid-cols-1 md:grid-cols-3">
|
||||||
|
<a href="/drama" className="flex items-center gap-3 rounded-lg border bg-card p-4 hover:bg-accent transition-colors">
|
||||||
|
<div className="rounded-lg bg-orange-500/10 p-2">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" style={{width: 16, height: 16}} viewBox="0 0 24 24" fill="none" stroke="#ea580c" strokeWidth="2">
|
||||||
|
<path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20" />
|
||||||
|
<path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-medium">드라마 제작</h3>
|
||||||
|
<p className="text-xs text-muted-foreground">대본으로 라디오 드라마 생성</p>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<a href="/tts" className="flex items-center gap-3 rounded-lg border bg-card p-4 hover:bg-accent transition-colors">
|
||||||
|
<div className="rounded-lg bg-blue-500/10 p-2">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" style={{width: 16, height: 16}} viewBox="0 0 24 24" fill="none" stroke="#2563eb" strokeWidth="2">
|
||||||
|
<polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5" />
|
||||||
|
<path d="M19.07 4.93a10 10 0 0 1 0 14.14M15.54 8.46a5 5 0 0 1 0 7.07" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-medium">TTS 스튜디오</h3>
|
||||||
|
<p className="text-xs text-muted-foreground">텍스트를 음성으로 변환</p>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<a href="/voices" className="flex items-center gap-3 rounded-lg border bg-card p-4 hover:bg-accent transition-colors">
|
||||||
|
<div className="rounded-lg bg-green-500/10 p-2">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" style={{width: 16, height: 16}} viewBox="0 0 24 24" fill="none" stroke="#16a34a" strokeWidth="2">
|
||||||
|
<path d="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z" />
|
||||||
|
<path d="M19 10v2a7 7 0 0 1-14 0v-2" />
|
||||||
|
<line x1="12" y1="19" x2="12" y2="23" />
|
||||||
|
<line x1="8" y1="23" x2="16" y2="23" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-medium">보이스 클론</h3>
|
||||||
|
<p className="text-xs text-muted-foreground">3초 녹음으로 복제</p>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
323
audio-studio-ui/src/app/recordings/page.tsx
Normal file
323
audio-studio-ui/src/app/recordings/page.tsx
Normal file
@ -0,0 +1,323 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useRef, useEffect } from "react";
|
||||||
|
import { validateRecording, uploadRecording } from "@/lib/api";
|
||||||
|
import { formatDuration } from "@/lib/utils";
|
||||||
|
|
||||||
|
export default function RecordingsPage() {
|
||||||
|
const [isRecording, setIsRecording] = useState(false);
|
||||||
|
const [audioUrl, setAudioUrl] = useState<string | null>(null);
|
||||||
|
const [audioBlob, setAudioBlob] = useState<Blob | null>(null);
|
||||||
|
const [duration, setDuration] = useState(0);
|
||||||
|
const [validation, setValidation] = useState<{
|
||||||
|
valid: boolean;
|
||||||
|
quality_score: number;
|
||||||
|
issues: string[];
|
||||||
|
} | null>(null);
|
||||||
|
const [transcript, setTranscript] = useState("");
|
||||||
|
const [uploading, setUploading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [uploadResult, setUploadResult] = useState<{ file_id: string } | null>(null);
|
||||||
|
|
||||||
|
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
|
||||||
|
const chunksRef = useRef<Blob[]>([]);
|
||||||
|
const timerRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
|
const audioRef = useRef<HTMLAudioElement>(null);
|
||||||
|
|
||||||
|
// 녹음 시간 업데이트
|
||||||
|
useEffect(() => {
|
||||||
|
if (isRecording) {
|
||||||
|
timerRef.current = setInterval(() => {
|
||||||
|
setDuration((d) => d + 1);
|
||||||
|
}, 1000);
|
||||||
|
} else {
|
||||||
|
if (timerRef.current) {
|
||||||
|
clearInterval(timerRef.current);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (timerRef.current) {
|
||||||
|
clearInterval(timerRef.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [isRecording]);
|
||||||
|
|
||||||
|
async function startRecording() {
|
||||||
|
try {
|
||||||
|
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||||
|
|
||||||
|
const mediaRecorder = new MediaRecorder(stream, {
|
||||||
|
mimeType: "audio/webm;codecs=opus",
|
||||||
|
});
|
||||||
|
|
||||||
|
mediaRecorderRef.current = mediaRecorder;
|
||||||
|
chunksRef.current = [];
|
||||||
|
|
||||||
|
mediaRecorder.ondataavailable = (e) => {
|
||||||
|
if (e.data.size > 0) {
|
||||||
|
chunksRef.current.push(e.data);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
mediaRecorder.onstop = async () => {
|
||||||
|
const blob = new Blob(chunksRef.current, { type: "audio/webm" });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
|
||||||
|
setAudioBlob(blob);
|
||||||
|
setAudioUrl(url);
|
||||||
|
|
||||||
|
// 스트림 정리
|
||||||
|
stream.getTracks().forEach((track) => track.stop());
|
||||||
|
|
||||||
|
// 품질 검증
|
||||||
|
await validateAudio(blob);
|
||||||
|
};
|
||||||
|
|
||||||
|
mediaRecorder.start();
|
||||||
|
setIsRecording(true);
|
||||||
|
setDuration(0);
|
||||||
|
setAudioUrl(null);
|
||||||
|
setValidation(null);
|
||||||
|
setUploadResult(null);
|
||||||
|
setError(null);
|
||||||
|
} catch (e) {
|
||||||
|
setError("마이크 접근 권한이 필요합니다");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopRecording() {
|
||||||
|
if (mediaRecorderRef.current && isRecording) {
|
||||||
|
mediaRecorderRef.current.stop();
|
||||||
|
setIsRecording(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function validateAudio(blob: Blob) {
|
||||||
|
try {
|
||||||
|
const file = new File([blob], "recording.webm", { type: "audio/webm" });
|
||||||
|
const result = await validateRecording(file);
|
||||||
|
setValidation({
|
||||||
|
valid: result.valid,
|
||||||
|
quality_score: result.quality_score,
|
||||||
|
issues: result.issues,
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
console.error("검증 실패:", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleUpload() {
|
||||||
|
if (!audioBlob) return;
|
||||||
|
|
||||||
|
setUploading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const file = new File([audioBlob], "recording.webm", { type: "audio/webm" });
|
||||||
|
const result = await uploadRecording({
|
||||||
|
audio: file,
|
||||||
|
transcript: transcript.trim() || undefined,
|
||||||
|
});
|
||||||
|
setUploadResult(result);
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : "업로드 실패");
|
||||||
|
} finally {
|
||||||
|
setUploading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleReset() {
|
||||||
|
if (audioUrl) {
|
||||||
|
URL.revokeObjectURL(audioUrl);
|
||||||
|
}
|
||||||
|
setAudioUrl(null);
|
||||||
|
setAudioBlob(null);
|
||||||
|
setValidation(null);
|
||||||
|
setUploadResult(null);
|
||||||
|
setDuration(0);
|
||||||
|
setTranscript("");
|
||||||
|
setError(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* 헤더 */}
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold">녹음</h1>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
Voice Clone에 사용할 음성을 녹음하세요 (3초 이상 권장)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 녹음 카드 */}
|
||||||
|
<div className="rounded-lg border bg-card p-6 max-w-xl mx-auto">
|
||||||
|
{/* 녹음 버튼 */}
|
||||||
|
<div className="flex flex-col items-center">
|
||||||
|
<button
|
||||||
|
onClick={isRecording ? stopRecording : startRecording}
|
||||||
|
className={`relative w-24 h-24 rounded-full flex items-center justify-center transition-colors ${
|
||||||
|
isRecording
|
||||||
|
? "bg-destructive text-destructive-foreground recording-pulse"
|
||||||
|
: "bg-primary text-primary-foreground hover:bg-primary/90"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{isRecording ? (
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
className="h-10 w-10"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="currentColor"
|
||||||
|
>
|
||||||
|
<rect x="6" y="6" width="12" height="12" rx="2" />
|
||||||
|
</svg>
|
||||||
|
) : (
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
className="h-10 w-10"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
>
|
||||||
|
<path d="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z" />
|
||||||
|
<path d="M19 10v2a7 7 0 0 1-14 0v-2" />
|
||||||
|
<line x1="12" y1="19" x2="12" y2="23" />
|
||||||
|
<line x1="8" y1="23" x2="16" y2="23" />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<p className="mt-4 text-lg font-mono">
|
||||||
|
{formatDuration(duration)}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{isRecording ? "녹음 중... 클릭하여 중지" : "클릭하여 녹음 시작"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 스크립트 가이드 */}
|
||||||
|
{!audioUrl && (
|
||||||
|
<div className="mt-6 rounded-lg bg-secondary p-4">
|
||||||
|
<h3 className="font-medium mb-2">녹음 가이드</h3>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
다음 문장을 또박또박 읽어주세요:
|
||||||
|
</p>
|
||||||
|
<p className="mt-2 text-sm font-medium">
|
||||||
|
"안녕하세요, 저는 인공지능 음성 합성 테스트를 위해 녹음하고
|
||||||
|
있습니다. 오늘 날씨가 정말 좋네요."
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 에러 */}
|
||||||
|
{error && (
|
||||||
|
<div className="mt-4 rounded-lg border border-destructive/50 bg-destructive/10 p-3 text-sm text-destructive">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 녹음 결과 */}
|
||||||
|
{audioUrl && (
|
||||||
|
<div className="mt-6 space-y-4">
|
||||||
|
<audio
|
||||||
|
ref={audioRef}
|
||||||
|
src={audioUrl}
|
||||||
|
controls
|
||||||
|
className="w-full"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 검증 결과 */}
|
||||||
|
{validation && (
|
||||||
|
<div
|
||||||
|
className={`rounded-lg p-4 ${
|
||||||
|
validation.valid
|
||||||
|
? "bg-green-50 border border-green-200"
|
||||||
|
: "bg-yellow-50 border border-yellow-200"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<span className="font-medium">
|
||||||
|
{validation.valid ? "녹음 품질 양호" : "품질 개선 필요"}
|
||||||
|
</span>
|
||||||
|
<span className="text-sm">
|
||||||
|
점수: {Math.round(validation.quality_score * 100)}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{validation.issues.length > 0 && (
|
||||||
|
<ul className="text-sm space-y-1">
|
||||||
|
{validation.issues.map((issue, i) => (
|
||||||
|
<li key={i} className="text-muted-foreground">
|
||||||
|
• {issue}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 트랜스크립트 입력 */}
|
||||||
|
<div>
|
||||||
|
<label className="text-sm font-medium">
|
||||||
|
녹음 내용 (선택)
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
value={transcript}
|
||||||
|
onChange={(e) => setTranscript(e.target.value)}
|
||||||
|
placeholder="녹음에서 말한 내용을 입력하세요..."
|
||||||
|
className="mt-1 w-full h-20 rounded-lg border bg-background px-3 py-2 text-sm resize-none focus:outline-none focus:ring-2 focus:ring-ring"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 업로드 결과 */}
|
||||||
|
{uploadResult && (
|
||||||
|
<div className="rounded-lg bg-green-50 border border-green-200 p-4">
|
||||||
|
<p className="font-medium text-green-700">업로드 완료!</p>
|
||||||
|
<p className="text-sm text-green-600">
|
||||||
|
File ID: {uploadResult.file_id}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-muted-foreground mt-2">
|
||||||
|
이제 보이스 클론 페이지에서 이 녹음을 사용할 수 있습니다.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 버튼 */}
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<button
|
||||||
|
onClick={handleReset}
|
||||||
|
className="flex-1 inline-flex items-center justify-center rounded-lg border px-4 py-2 text-sm font-medium hover:bg-accent"
|
||||||
|
>
|
||||||
|
다시 녹음
|
||||||
|
</button>
|
||||||
|
{!uploadResult && (
|
||||||
|
<button
|
||||||
|
onClick={handleUpload}
|
||||||
|
disabled={uploading}
|
||||||
|
className="flex-1 inline-flex items-center justify-center gap-2 rounded-lg bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{uploading ? (
|
||||||
|
<>
|
||||||
|
<div className="h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent" />
|
||||||
|
업로드 중...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
"업로드"
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{uploadResult && (
|
||||||
|
<a
|
||||||
|
href="/voices"
|
||||||
|
className="flex-1 inline-flex items-center justify-center rounded-lg bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90"
|
||||||
|
>
|
||||||
|
보이스 클론으로 이동
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
255
audio-studio-ui/src/app/sound-effects/page.tsx
Normal file
255
audio-studio-ui/src/app/sound-effects/page.tsx
Normal file
@ -0,0 +1,255 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { searchSoundEffects, getSoundEffectAudioUrl, SoundEffect } from "@/lib/api";
|
||||||
|
import { formatDuration } from "@/lib/utils";
|
||||||
|
|
||||||
|
export default function SoundEffectsPage() {
|
||||||
|
const [query, setQuery] = useState("");
|
||||||
|
const [results, setResults] = useState<SoundEffect[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [playingId, setPlayingId] = useState<string | null>(null);
|
||||||
|
const [totalCount, setTotalCount] = useState(0);
|
||||||
|
|
||||||
|
async function handleSearch(e?: React.FormEvent) {
|
||||||
|
e?.preventDefault();
|
||||||
|
if (!query.trim()) return;
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await searchSoundEffects({
|
||||||
|
query: query.trim(),
|
||||||
|
page_size: 20,
|
||||||
|
});
|
||||||
|
setResults(response.results);
|
||||||
|
setTotalCount(response.count);
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : "검색 실패");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function playSound(soundId: string, previewUrl?: string) {
|
||||||
|
const url = previewUrl || getSoundEffectAudioUrl(soundId);
|
||||||
|
const audio = new Audio(url);
|
||||||
|
audio.onplay = () => setPlayingId(soundId);
|
||||||
|
audio.onended = () => setPlayingId(null);
|
||||||
|
audio.onerror = () => {
|
||||||
|
setPlayingId(null);
|
||||||
|
alert("재생 실패");
|
||||||
|
};
|
||||||
|
audio.play();
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* 헤더 */}
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold">효과음 라이브러리</h1>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
Freesound에서 무료 효과음을 검색하고 다운로드하세요
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 검색 */}
|
||||||
|
<form onSubmit={handleSearch} className="flex gap-3">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={query}
|
||||||
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
|
placeholder="효과음 검색 (예: door, explosion, rain...)"
|
||||||
|
className="flex-1 rounded-lg border bg-background px-4 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading || !query.trim()}
|
||||||
|
className="inline-flex items-center gap-2 rounded-lg bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<div className="h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent" />
|
||||||
|
) : (
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
className="h-4 w-4"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
>
|
||||||
|
<circle cx="11" cy="11" r="8" />
|
||||||
|
<line x1="21" y1="21" x2="16.65" y2="16.65" />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
검색
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{/* 에러 */}
|
||||||
|
{error && (
|
||||||
|
<div className="rounded-lg border border-destructive/50 bg-destructive/10 p-4 text-destructive">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 결과 수 */}
|
||||||
|
{totalCount > 0 && (
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
총 {totalCount.toLocaleString()}개의 결과
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 결과 그리드 */}
|
||||||
|
{results.length > 0 && (
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{results.map((sound) => (
|
||||||
|
<div
|
||||||
|
key={sound.id}
|
||||||
|
className="rounded-lg border bg-card p-4 space-y-3"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<h3 className="font-medium truncate" title={sound.name}>
|
||||||
|
{sound.name}
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-muted-foreground line-clamp-2">
|
||||||
|
{sound.description || "설명 없음"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||||
|
<span>{formatDuration(sound.duration)}</span>
|
||||||
|
<span>·</span>
|
||||||
|
<span className="truncate">{sound.license || "Unknown"}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 태그 */}
|
||||||
|
{sound.tags && sound.tags.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{sound.tags.slice(0, 5).map((tag) => (
|
||||||
|
<span
|
||||||
|
key={tag}
|
||||||
|
className="rounded bg-secondary px-1.5 py-0.5 text-xs"
|
||||||
|
>
|
||||||
|
{tag}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 버튼 */}
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => playSound(sound.id, sound.preview_url)}
|
||||||
|
disabled={playingId === sound.id}
|
||||||
|
className="flex-1 inline-flex items-center justify-center gap-2 rounded-lg border px-3 py-2 text-sm font-medium hover:bg-accent disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{playingId === sound.id ? (
|
||||||
|
<>
|
||||||
|
<div className="h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent" />
|
||||||
|
재생 중
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
className="h-4 w-4"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="currentColor"
|
||||||
|
>
|
||||||
|
<polygon points="5 3 19 12 5 21 5 3" />
|
||||||
|
</svg>
|
||||||
|
재생
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
{sound.preview_url && (
|
||||||
|
<a
|
||||||
|
href={sound.preview_url}
|
||||||
|
download
|
||||||
|
className="inline-flex items-center justify-center rounded-lg border px-3 py-2 text-sm font-medium hover:bg-accent"
|
||||||
|
title="다운로드"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
className="h-4 w-4"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
>
|
||||||
|
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
|
||||||
|
<polyline points="7 10 12 15 17 10" />
|
||||||
|
<line x1="12" y1="15" x2="12" y2="3" />
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 빈 상태 */}
|
||||||
|
{!loading && results.length === 0 && query && (
|
||||||
|
<div className="flex flex-col items-center justify-center py-12 text-center">
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
className="h-12 w-12 text-muted-foreground"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
>
|
||||||
|
<circle cx="11" cy="11" r="8" />
|
||||||
|
<line x1="21" y1="21" x2="16.65" y2="16.65" />
|
||||||
|
</svg>
|
||||||
|
<h3 className="mt-4 font-semibold">검색 결과 없음</h3>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
다른 키워드로 검색해보세요
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 초기 상태 */}
|
||||||
|
{!loading && results.length === 0 && !query && (
|
||||||
|
<div className="flex flex-col items-center justify-center py-12 text-center">
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
className="h-12 w-12 text-muted-foreground"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
>
|
||||||
|
<path d="M9 18V5l12-2v13" />
|
||||||
|
<circle cx="6" cy="18" r="3" />
|
||||||
|
<circle cx="18" cy="16" r="3" />
|
||||||
|
</svg>
|
||||||
|
<h3 className="mt-4 font-semibold">효과음 검색</h3>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
검색어를 입력하여 Freesound에서 효과음을 찾아보세요
|
||||||
|
</p>
|
||||||
|
<div className="mt-4 flex flex-wrap justify-center gap-2">
|
||||||
|
{["door", "footsteps", "rain", "thunder", "explosion", "whoosh"].map(
|
||||||
|
(term) => (
|
||||||
|
<button
|
||||||
|
key={term}
|
||||||
|
onClick={() => {
|
||||||
|
setQuery(term);
|
||||||
|
setTimeout(() => handleSearch(), 0);
|
||||||
|
}}
|
||||||
|
className="rounded-full border px-3 py-1 text-sm hover:bg-accent"
|
||||||
|
>
|
||||||
|
{term}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
298
audio-studio-ui/src/app/tts/page.tsx
Normal file
298
audio-studio-ui/src/app/tts/page.tsx
Normal file
@ -0,0 +1,298 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Suspense, useEffect, useState, useRef } from "react";
|
||||||
|
import { useSearchParams } from "next/navigation";
|
||||||
|
import { getVoices, synthesize, Voice } from "@/lib/api";
|
||||||
|
|
||||||
|
function TTSContent() {
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const initialVoiceId = searchParams.get("voice");
|
||||||
|
|
||||||
|
const [voices, setVoices] = useState<Voice[]>([]);
|
||||||
|
const [selectedVoiceId, setSelectedVoiceId] = useState<string>(
|
||||||
|
initialVoiceId || ""
|
||||||
|
);
|
||||||
|
const [text, setText] = useState("");
|
||||||
|
const [instruct, setInstruct] = useState("");
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [audioUrl, setAudioUrl] = useState<string | null>(null);
|
||||||
|
const audioRef = useRef<HTMLAudioElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadVoices();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (initialVoiceId && !selectedVoiceId) {
|
||||||
|
setSelectedVoiceId(initialVoiceId);
|
||||||
|
}
|
||||||
|
}, [initialVoiceId, selectedVoiceId]);
|
||||||
|
|
||||||
|
async function loadVoices() {
|
||||||
|
try {
|
||||||
|
const response = await getVoices({ page_size: 100 });
|
||||||
|
setVoices(response.voices);
|
||||||
|
|
||||||
|
// 기본 선택
|
||||||
|
if (!selectedVoiceId && response.voices.length > 0) {
|
||||||
|
setSelectedVoiceId(response.voices[0].voice_id);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error("보이스 로드 실패:", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSynthesize() {
|
||||||
|
if (!selectedVoiceId || !text.trim()) {
|
||||||
|
setError("보이스와 텍스트를 입력하세요");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const blob = await synthesize({
|
||||||
|
voice_id: selectedVoiceId,
|
||||||
|
text: text.trim(),
|
||||||
|
instruct: instruct.trim() || undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 기존 URL 해제
|
||||||
|
if (audioUrl) {
|
||||||
|
URL.revokeObjectURL(audioUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
setAudioUrl(url);
|
||||||
|
|
||||||
|
// 자동 재생
|
||||||
|
setTimeout(() => {
|
||||||
|
audioRef.current?.play();
|
||||||
|
}, 100);
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : "TTS 합성 실패");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDownload() {
|
||||||
|
if (!audioUrl) return;
|
||||||
|
|
||||||
|
const a = document.createElement("a");
|
||||||
|
a.href = audioUrl;
|
||||||
|
a.download = `tts_${Date.now()}.wav`;
|
||||||
|
a.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectedVoice = voices.find((v) => v.voice_id === selectedVoiceId);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* 헤더 */}
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold">TTS 스튜디오</h1>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
텍스트를 자연스러운 음성으로 변환하세요
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-6 lg:grid-cols-3">
|
||||||
|
{/* 보이스 선택 */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h2 className="font-semibold">보이스 선택</h2>
|
||||||
|
|
||||||
|
<div className="space-y-2 max-h-96 overflow-y-auto">
|
||||||
|
{voices.map((voice) => (
|
||||||
|
<button
|
||||||
|
key={voice.voice_id}
|
||||||
|
onClick={() => setSelectedVoiceId(voice.voice_id)}
|
||||||
|
className={`w-full text-left rounded-lg border p-3 transition-colors ${
|
||||||
|
selectedVoiceId === voice.voice_id
|
||||||
|
? "border-primary bg-primary/5"
|
||||||
|
: "hover:bg-accent"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="font-medium">{voice.name}</span>
|
||||||
|
<span
|
||||||
|
className={`text-xs px-1.5 py-0.5 rounded ${
|
||||||
|
voice.type === "preset"
|
||||||
|
? "bg-blue-100 text-blue-700"
|
||||||
|
: voice.type === "cloned"
|
||||||
|
? "bg-green-100 text-green-700"
|
||||||
|
: "bg-purple-100 text-purple-700"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{voice.type === "preset"
|
||||||
|
? "프리셋"
|
||||||
|
: voice.type === "cloned"
|
||||||
|
? "클론"
|
||||||
|
: "디자인"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{voice.description && (
|
||||||
|
<p className="text-sm text-muted-foreground truncate">
|
||||||
|
{voice.description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{selectedVoice && (
|
||||||
|
<div className="rounded-lg border bg-card p-4">
|
||||||
|
<h3 className="font-medium mb-2">선택된 보이스</h3>
|
||||||
|
<p className="text-sm">{selectedVoice.name}</p>
|
||||||
|
{selectedVoice.style_tags && selectedVoice.style_tags.length > 0 && (
|
||||||
|
<div className="mt-2 flex flex-wrap gap-1">
|
||||||
|
{selectedVoice.style_tags.map((tag) => (
|
||||||
|
<span
|
||||||
|
key={tag}
|
||||||
|
className="rounded bg-secondary px-1.5 py-0.5 text-xs"
|
||||||
|
>
|
||||||
|
{tag}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 입력 영역 */}
|
||||||
|
<div className="lg:col-span-2 space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="text-sm font-medium">텍스트 입력</label>
|
||||||
|
<textarea
|
||||||
|
value={text}
|
||||||
|
onChange={(e) => setText(e.target.value)}
|
||||||
|
placeholder="변환할 텍스트를 입력하세요..."
|
||||||
|
className="mt-1 w-full h-40 rounded-lg border bg-background px-3 py-2 text-sm resize-none focus:outline-none focus:ring-2 focus:ring-ring"
|
||||||
|
maxLength={5000}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground text-right">
|
||||||
|
{text.length}/5000
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="text-sm font-medium">
|
||||||
|
감정/스타일 지시 (선택)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={instruct}
|
||||||
|
onChange={(e) => setInstruct(e.target.value)}
|
||||||
|
placeholder="예: 밝고 활기차게, 슬프게, 화난 목소리로..."
|
||||||
|
className="mt-1 w-full rounded-lg border bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 에러 */}
|
||||||
|
{error && (
|
||||||
|
<div className="rounded-lg border border-destructive/50 bg-destructive/10 p-3 text-sm text-destructive">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 버튼 */}
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<button
|
||||||
|
onClick={handleSynthesize}
|
||||||
|
disabled={loading || !text.trim() || !selectedVoiceId}
|
||||||
|
className="flex-1 inline-flex items-center justify-center gap-2 rounded-lg bg-primary px-4 py-3 text-sm font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<>
|
||||||
|
<div className="h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent" />
|
||||||
|
생성 중...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
className="h-4 w-4"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
>
|
||||||
|
<polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5" />
|
||||||
|
<path d="M19.07 4.93a10 10 0 0 1 0 14.14M15.54 8.46a5 5 0 0 1 0 7.07" />
|
||||||
|
</svg>
|
||||||
|
음성 생성
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 오디오 플레이어 */}
|
||||||
|
{audioUrl && (
|
||||||
|
<div className="rounded-lg border bg-card p-4 space-y-3">
|
||||||
|
<h3 className="font-medium">생성된 음성</h3>
|
||||||
|
<audio
|
||||||
|
ref={audioRef}
|
||||||
|
src={audioUrl}
|
||||||
|
controls
|
||||||
|
className="w-full"
|
||||||
|
/>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => audioRef.current?.play()}
|
||||||
|
className="inline-flex items-center gap-2 rounded-lg border px-3 py-2 text-sm font-medium hover:bg-accent"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
className="h-4 w-4"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="currentColor"
|
||||||
|
>
|
||||||
|
<polygon points="5 3 19 12 5 21 5 3" />
|
||||||
|
</svg>
|
||||||
|
재생
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleDownload}
|
||||||
|
className="inline-flex items-center gap-2 rounded-lg border px-3 py-2 text-sm font-medium hover:bg-accent"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
className="h-4 w-4"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
>
|
||||||
|
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
|
||||||
|
<polyline points="7 10 12 15 17 10" />
|
||||||
|
<line x1="12" y1="15" x2="12" y2="3" />
|
||||||
|
</svg>
|
||||||
|
다운로드
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function LoadingFallback() {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center min-h-[400px]">
|
||||||
|
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function TTSPage() {
|
||||||
|
return (
|
||||||
|
<Suspense fallback={<LoadingFallback />}>
|
||||||
|
<TTSContent />
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
|
}
|
||||||
235
audio-studio-ui/src/app/voices/page.tsx
Normal file
235
audio-studio-ui/src/app/voices/page.tsx
Normal file
@ -0,0 +1,235 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { getVoices, getVoiceSampleUrl, Voice, VoiceListResponse } from "@/lib/api";
|
||||||
|
|
||||||
|
export default function VoicesPage() {
|
||||||
|
const [voices, setVoices] = useState<Voice[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [filter, setFilter] = useState<"all" | "preset" | "cloned" | "designed">("all");
|
||||||
|
const [playingId, setPlayingId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadVoices();
|
||||||
|
}, [filter]);
|
||||||
|
|
||||||
|
async function loadVoices() {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const params: { type?: string } = {};
|
||||||
|
if (filter !== "all") {
|
||||||
|
params.type = filter;
|
||||||
|
}
|
||||||
|
const response = await getVoices(params);
|
||||||
|
setVoices(response.voices);
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : "보이스 로드 실패");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function playVoiceSample(voiceId: string) {
|
||||||
|
const audio = new Audio(getVoiceSampleUrl(voiceId));
|
||||||
|
audio.onplay = () => setPlayingId(voiceId);
|
||||||
|
audio.onended = () => setPlayingId(null);
|
||||||
|
audio.onerror = () => {
|
||||||
|
setPlayingId(null);
|
||||||
|
alert("샘플 재생 실패");
|
||||||
|
};
|
||||||
|
audio.play();
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* 헤더 */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold">보이스 라이브러리</h1>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
다양한 AI 음성을 선택하거나 새로운 보이스를 만들어보세요
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<a
|
||||||
|
href="/voices/clone"
|
||||||
|
className="inline-flex items-center gap-2 rounded-lg bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
className="h-4 w-4"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
>
|
||||||
|
<path d="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z" />
|
||||||
|
<path d="M19 10v2a7 7 0 0 1-14 0v-2" />
|
||||||
|
</svg>
|
||||||
|
보이스 클론
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href="/voices/design"
|
||||||
|
className="inline-flex items-center gap-2 rounded-lg border px-4 py-2 text-sm font-medium hover:bg-accent"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
className="h-4 w-4"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
>
|
||||||
|
<path d="M12 20h9" />
|
||||||
|
<path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z" />
|
||||||
|
</svg>
|
||||||
|
보이스 디자인
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 필터 */}
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{[
|
||||||
|
{ value: "all", label: "전체" },
|
||||||
|
{ value: "preset", label: "프리셋" },
|
||||||
|
{ value: "cloned", label: "클론" },
|
||||||
|
{ value: "designed", label: "디자인" },
|
||||||
|
].map((item) => (
|
||||||
|
<button
|
||||||
|
key={item.value}
|
||||||
|
onClick={() => setFilter(item.value as typeof filter)}
|
||||||
|
className={`rounded-full px-4 py-1.5 text-sm font-medium transition-colors ${
|
||||||
|
filter === item.value
|
||||||
|
? "bg-primary text-primary-foreground"
|
||||||
|
: "bg-secondary hover:bg-secondary/80"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 에러 */}
|
||||||
|
{error && (
|
||||||
|
<div className="rounded-lg border border-destructive/50 bg-destructive/10 p-4 text-destructive">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 로딩 */}
|
||||||
|
{loading && (
|
||||||
|
<div className="flex items-center justify-center py-12">
|
||||||
|
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 보이스 그리드 */}
|
||||||
|
{!loading && voices.length > 0 && (
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{voices.map((voice) => (
|
||||||
|
<div
|
||||||
|
key={voice.voice_id}
|
||||||
|
className="flex flex-col rounded-lg border bg-card p-4 hover:shadow-md transition-shadow"
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div>
|
||||||
|
<h3 className="font-semibold">{voice.name}</h3>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{voice.description || "설명 없음"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
className={`rounded-full px-2 py-0.5 text-xs font-medium ${
|
||||||
|
voice.type === "preset"
|
||||||
|
? "bg-blue-100 text-blue-700"
|
||||||
|
: voice.type === "cloned"
|
||||||
|
? "bg-green-100 text-green-700"
|
||||||
|
: "bg-purple-100 text-purple-700"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{voice.type === "preset"
|
||||||
|
? "프리셋"
|
||||||
|
: voice.type === "cloned"
|
||||||
|
? "클론"
|
||||||
|
: "디자인"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 스타일 태그 */}
|
||||||
|
{voice.style_tags && voice.style_tags.length > 0 && (
|
||||||
|
<div className="mt-2 flex flex-wrap gap-1">
|
||||||
|
{voice.style_tags.slice(0, 3).map((tag) => (
|
||||||
|
<span
|
||||||
|
key={tag}
|
||||||
|
className="rounded bg-secondary px-1.5 py-0.5 text-xs"
|
||||||
|
>
|
||||||
|
{tag}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 액션 버튼 */}
|
||||||
|
<div className="mt-4 flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => playVoiceSample(voice.voice_id)}
|
||||||
|
disabled={playingId === voice.voice_id}
|
||||||
|
className="flex-1 inline-flex items-center justify-center gap-2 rounded-lg border px-3 py-2 text-sm font-medium hover:bg-accent disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{playingId === voice.voice_id ? (
|
||||||
|
<>
|
||||||
|
<div className="h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent" />
|
||||||
|
재생 중
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
className="h-4 w-4"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="currentColor"
|
||||||
|
>
|
||||||
|
<polygon points="5 3 19 12 5 21 5 3" />
|
||||||
|
</svg>
|
||||||
|
미리듣기
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
<a
|
||||||
|
href={`/tts?voice=${voice.voice_id}`}
|
||||||
|
className="inline-flex items-center justify-center rounded-lg bg-primary px-3 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90"
|
||||||
|
>
|
||||||
|
사용
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 빈 상태 */}
|
||||||
|
{!loading && voices.length === 0 && (
|
||||||
|
<div className="flex flex-col items-center justify-center py-12 text-center">
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
className="h-12 w-12 text-muted-foreground"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
>
|
||||||
|
<path d="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z" />
|
||||||
|
<path d="M19 10v2a7 7 0 0 1-14 0v-2" />
|
||||||
|
</svg>
|
||||||
|
<h3 className="mt-4 font-semibold">보이스가 없습니다</h3>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
새로운 보이스를 클론하거나 디자인해보세요
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
371
audio-studio-ui/src/lib/api.ts
Normal file
371
audio-studio-ui/src/lib/api.ts
Normal file
@ -0,0 +1,371 @@
|
|||||||
|
/**
|
||||||
|
* Audio Studio API 클라이언트
|
||||||
|
*/
|
||||||
|
|
||||||
|
const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8010";
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// 타입 정의
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
export interface Voice {
|
||||||
|
voice_id: string;
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
type: "preset" | "cloned" | "designed";
|
||||||
|
language: string;
|
||||||
|
preset_voice_id?: string;
|
||||||
|
design_prompt?: string;
|
||||||
|
reference_transcript?: string;
|
||||||
|
gender?: string;
|
||||||
|
style_tags?: string[];
|
||||||
|
is_public: boolean;
|
||||||
|
sample_audio_id?: string;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VoiceListResponse {
|
||||||
|
voices: Voice[];
|
||||||
|
total: number;
|
||||||
|
page: number;
|
||||||
|
page_size: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TTSGenerationResponse {
|
||||||
|
generation_id: string;
|
||||||
|
voice_id: string;
|
||||||
|
text: string;
|
||||||
|
status: string;
|
||||||
|
audio_file_id?: string;
|
||||||
|
duration_seconds?: number;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RecordingValidateResponse {
|
||||||
|
valid: boolean;
|
||||||
|
duration: number;
|
||||||
|
sample_rate: number;
|
||||||
|
quality_score: number;
|
||||||
|
issues: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SoundEffect {
|
||||||
|
id: string;
|
||||||
|
freesound_id?: number;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
duration: number;
|
||||||
|
tags: string[];
|
||||||
|
preview_url?: string;
|
||||||
|
license: string;
|
||||||
|
source: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SoundEffectSearchResponse {
|
||||||
|
count: number;
|
||||||
|
page: number;
|
||||||
|
page_size: number;
|
||||||
|
results: SoundEffect[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// API 함수
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
async function fetchAPI<T>(
|
||||||
|
endpoint: string,
|
||||||
|
options?: RequestInit
|
||||||
|
): Promise<T> {
|
||||||
|
const response = await fetch(`${API_BASE_URL}${endpoint}`, {
|
||||||
|
...options,
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
...options?.headers,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.json().catch(() => ({ detail: "Unknown error" }));
|
||||||
|
throw new Error(error.detail || `HTTP ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// Voice API
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
export async function getVoices(params?: {
|
||||||
|
type?: string;
|
||||||
|
language?: string;
|
||||||
|
page?: number;
|
||||||
|
page_size?: number;
|
||||||
|
}): Promise<VoiceListResponse> {
|
||||||
|
const searchParams = new URLSearchParams();
|
||||||
|
if (params?.type) searchParams.set("type", params.type);
|
||||||
|
if (params?.language) searchParams.set("language", params.language);
|
||||||
|
if (params?.page) searchParams.set("page", String(params.page));
|
||||||
|
if (params?.page_size) searchParams.set("page_size", String(params.page_size));
|
||||||
|
|
||||||
|
return fetchAPI(`/api/v1/voices?${searchParams}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getVoice(voiceId: string): Promise<Voice> {
|
||||||
|
return fetchAPI(`/api/v1/voices/${voiceId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getVoiceSampleUrl(voiceId: string): string {
|
||||||
|
return `${API_BASE_URL}/api/v1/voices/${voiceId}/sample`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createVoiceClone(data: {
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
reference_transcript: string;
|
||||||
|
language?: string;
|
||||||
|
is_public?: boolean;
|
||||||
|
reference_audio: File;
|
||||||
|
}): Promise<Voice> {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append("name", data.name);
|
||||||
|
if (data.description) formData.append("description", data.description);
|
||||||
|
formData.append("reference_transcript", data.reference_transcript);
|
||||||
|
formData.append("language", data.language || "ko");
|
||||||
|
formData.append("is_public", String(data.is_public || false));
|
||||||
|
formData.append("reference_audio", data.reference_audio);
|
||||||
|
|
||||||
|
const response = await fetch(`${API_BASE_URL}/api/v1/voices/clone`, {
|
||||||
|
method: "POST",
|
||||||
|
body: formData,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.json().catch(() => ({ detail: "Unknown error" }));
|
||||||
|
throw new Error(error.detail || `HTTP ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createVoiceDesign(data: {
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
design_prompt: string;
|
||||||
|
language?: string;
|
||||||
|
is_public?: boolean;
|
||||||
|
}): Promise<Voice> {
|
||||||
|
return fetchAPI("/api/v1/voices/design", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteVoice(voiceId: string): Promise<void> {
|
||||||
|
await fetchAPI(`/api/v1/voices/${voiceId}`, { method: "DELETE" });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// TTS API
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
export async function synthesize(data: {
|
||||||
|
voice_id: string;
|
||||||
|
text: string;
|
||||||
|
instruct?: string;
|
||||||
|
}): Promise<Blob> {
|
||||||
|
const response = await fetch(`${API_BASE_URL}/api/v1/tts/synthesize`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.json().catch(() => ({ detail: "Unknown error" }));
|
||||||
|
throw new Error(error.detail || `HTTP ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.blob();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getGeneration(generationId: string): Promise<TTSGenerationResponse> {
|
||||||
|
return fetchAPI(`/api/v1/tts/generations/${generationId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getGenerationAudioUrl(generationId: string): string {
|
||||||
|
return `${API_BASE_URL}/api/v1/tts/generations/${generationId}/audio`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// Recording API
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
export async function validateRecording(audio: File): Promise<RecordingValidateResponse> {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append("audio", audio);
|
||||||
|
|
||||||
|
const response = await fetch(`${API_BASE_URL}/api/v1/recordings/validate`, {
|
||||||
|
method: "POST",
|
||||||
|
body: formData,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.json().catch(() => ({ detail: "Unknown error" }));
|
||||||
|
throw new Error(error.detail || `HTTP ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function uploadRecording(data: {
|
||||||
|
audio: File;
|
||||||
|
transcript?: string;
|
||||||
|
}): Promise<{ file_id: string; filename: string; duration: number }> {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append("audio", data.audio);
|
||||||
|
if (data.transcript) formData.append("transcript", data.transcript);
|
||||||
|
|
||||||
|
const response = await fetch(`${API_BASE_URL}/api/v1/recordings/upload`, {
|
||||||
|
method: "POST",
|
||||||
|
body: formData,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.json().catch(() => ({ detail: "Unknown error" }));
|
||||||
|
throw new Error(error.detail || `HTTP ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// Sound Effects API
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
export async function searchSoundEffects(params: {
|
||||||
|
query: string;
|
||||||
|
page?: number;
|
||||||
|
page_size?: number;
|
||||||
|
min_duration?: number;
|
||||||
|
max_duration?: number;
|
||||||
|
}): Promise<SoundEffectSearchResponse> {
|
||||||
|
const searchParams = new URLSearchParams();
|
||||||
|
searchParams.set("query", params.query);
|
||||||
|
if (params.page) searchParams.set("page", String(params.page));
|
||||||
|
if (params.page_size) searchParams.set("page_size", String(params.page_size));
|
||||||
|
if (params.min_duration) searchParams.set("min_duration", String(params.min_duration));
|
||||||
|
if (params.max_duration) searchParams.set("max_duration", String(params.max_duration));
|
||||||
|
|
||||||
|
return fetchAPI(`/api/v1/sound-effects/search?${searchParams}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getSoundEffectAudioUrl(soundId: string): string {
|
||||||
|
return `${API_BASE_URL}/api/v1/sound-effects/${soundId}/audio`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// Drama API
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
export interface DramaCharacter {
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
voice_id?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DramaProject {
|
||||||
|
project_id: string;
|
||||||
|
title: string;
|
||||||
|
status: "draft" | "processing" | "completed" | "error";
|
||||||
|
characters: DramaCharacter[];
|
||||||
|
element_count: number;
|
||||||
|
estimated_duration?: number;
|
||||||
|
output_file_id?: string;
|
||||||
|
error_message?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ParsedScript {
|
||||||
|
title?: string;
|
||||||
|
characters: DramaCharacter[];
|
||||||
|
elements: Array<{
|
||||||
|
type: "dialogue" | "direction" | "sfx" | "music" | "pause";
|
||||||
|
character?: string;
|
||||||
|
text?: string;
|
||||||
|
description?: string;
|
||||||
|
emotion?: string;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function parseScript(script: string): Promise<ParsedScript> {
|
||||||
|
const response = await fetch(`${API_BASE_URL}/api/v1/drama/parse`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(script),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.json().catch(() => ({ detail: "Unknown error" }));
|
||||||
|
throw new Error(error.detail || `HTTP ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createDramaProject(data: {
|
||||||
|
title: string;
|
||||||
|
script: string;
|
||||||
|
voice_mapping?: Record<string, string>;
|
||||||
|
auto_generate_assets?: boolean;
|
||||||
|
}): Promise<DramaProject> {
|
||||||
|
return fetchAPI("/api/v1/drama/projects", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getDramaProjects(): Promise<DramaProject[]> {
|
||||||
|
return fetchAPI("/api/v1/drama/projects");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getDramaProject(projectId: string): Promise<DramaProject> {
|
||||||
|
return fetchAPI(`/api/v1/drama/projects/${projectId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function renderDrama(
|
||||||
|
projectId: string,
|
||||||
|
outputFormat: string = "wav"
|
||||||
|
): Promise<{ project_id: string; status: string; message: string }> {
|
||||||
|
return fetchAPI(`/api/v1/drama/projects/${projectId}/render?output_format=${outputFormat}`, {
|
||||||
|
method: "POST",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateVoiceMapping(
|
||||||
|
projectId: string,
|
||||||
|
voiceMapping: Record<string, string>
|
||||||
|
): Promise<void> {
|
||||||
|
await fetchAPI(`/api/v1/drama/projects/${projectId}/voices`, {
|
||||||
|
method: "PUT",
|
||||||
|
body: JSON.stringify(voiceMapping),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getDramaDownloadUrl(projectId: string): string {
|
||||||
|
return `${API_BASE_URL}/api/v1/drama/projects/${projectId}/download`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteDramaProject(projectId: string): Promise<void> {
|
||||||
|
await fetchAPI(`/api/v1/drama/projects/${projectId}`, { method: "DELETE" });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// Health Check
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
export async function healthCheck(): Promise<{
|
||||||
|
status: string;
|
||||||
|
services: { mongodb: string; redis: string };
|
||||||
|
}> {
|
||||||
|
return fetchAPI("/health");
|
||||||
|
}
|
||||||
18
audio-studio-ui/src/lib/utils.ts
Normal file
18
audio-studio-ui/src/lib/utils.ts
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
import { type ClassValue, clsx } from "clsx";
|
||||||
|
import { twMerge } from "tailwind-merge";
|
||||||
|
|
||||||
|
export function cn(...inputs: ClassValue[]) {
|
||||||
|
return twMerge(clsx(inputs));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatDuration(seconds: number): string {
|
||||||
|
const mins = Math.floor(seconds / 60);
|
||||||
|
const secs = Math.floor(seconds % 60);
|
||||||
|
return `${mins}:${secs.toString().padStart(2, "0")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatFileSize(bytes: number): string {
|
||||||
|
if (bytes < 1024) return `${bytes} B`;
|
||||||
|
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||||
|
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||||
|
}
|
||||||
27
audio-studio-ui/tsconfig.json
Normal file
27
audio-studio-ui/tsconfig.json
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2017",
|
||||||
|
"lib": ["dom", "dom.iterable", "esnext"],
|
||||||
|
"allowJs": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"strict": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"module": "esnext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"jsx": "preserve",
|
||||||
|
"incremental": true,
|
||||||
|
"plugins": [
|
||||||
|
{
|
||||||
|
"name": "next"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./src/*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||||
|
"exclude": ["node_modules"]
|
||||||
|
}
|
||||||
89
docker-compose.dev.yml
Normal file
89
docker-compose.dev.yml
Normal file
@ -0,0 +1,89 @@
|
|||||||
|
# Drama Studio - Development Mode
|
||||||
|
# UI 핫리로드 지원
|
||||||
|
|
||||||
|
services:
|
||||||
|
mongodb:
|
||||||
|
image: mongo:7.0
|
||||||
|
container_name: drama-studio-mongodb
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
MONGO_INITDB_ROOT_USERNAME: ${MONGO_USER:-admin}
|
||||||
|
MONGO_INITDB_ROOT_PASSWORD: ${MONGO_PASSWORD:-password123}
|
||||||
|
ports:
|
||||||
|
- "27021:27017"
|
||||||
|
volumes:
|
||||||
|
- drama_studio_mongodb_data:/data/db
|
||||||
|
networks:
|
||||||
|
- drama-studio-network
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "mongosh", "--eval", "db.adminCommand('ping')"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 3
|
||||||
|
|
||||||
|
redis:
|
||||||
|
image: redis:7-alpine
|
||||||
|
container_name: drama-studio-redis
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "6383:6379"
|
||||||
|
volumes:
|
||||||
|
- drama_studio_redis_data:/data
|
||||||
|
networks:
|
||||||
|
- drama-studio-network
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "redis-cli", "ping"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 3
|
||||||
|
|
||||||
|
api:
|
||||||
|
build:
|
||||||
|
context: ./audio-studio-api
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
container_name: drama-studio-api
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
- MONGODB_URL=mongodb://${MONGO_USER}:${MONGO_PASSWORD}@mongodb:27017/
|
||||||
|
- DB_NAME=${DB_NAME:-drama_studio}
|
||||||
|
- REDIS_URL=redis://redis:6379
|
||||||
|
- TTS_ENGINE_URL=http://tts-engine:8001
|
||||||
|
- MUSICGEN_URL=http://musicgen:8002
|
||||||
|
- FREESOUND_API_KEY=${FREESOUND_API_KEY}
|
||||||
|
ports:
|
||||||
|
- "8010:8000"
|
||||||
|
depends_on:
|
||||||
|
mongodb:
|
||||||
|
condition: service_healthy
|
||||||
|
redis:
|
||||||
|
condition: service_healthy
|
||||||
|
networks:
|
||||||
|
- drama-studio-network
|
||||||
|
|
||||||
|
# UI 개발 모드 - 핫리로드
|
||||||
|
ui:
|
||||||
|
image: node:20-alpine
|
||||||
|
container_name: drama-studio-ui-dev
|
||||||
|
working_dir: /app
|
||||||
|
command: sh -c "npm install && npm run dev"
|
||||||
|
environment:
|
||||||
|
- NEXT_PUBLIC_API_URL=http://localhost:8010
|
||||||
|
- WATCHPACK_POLLING=true
|
||||||
|
ports:
|
||||||
|
- "3010:3000"
|
||||||
|
volumes:
|
||||||
|
- ./audio-studio-ui:/app
|
||||||
|
- /app/node_modules
|
||||||
|
- /app/.next
|
||||||
|
depends_on:
|
||||||
|
- api
|
||||||
|
networks:
|
||||||
|
- drama-studio-network
|
||||||
|
|
||||||
|
networks:
|
||||||
|
drama-studio-network:
|
||||||
|
driver: bridge
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
drama_studio_mongodb_data:
|
||||||
|
drama_studio_redis_data:
|
||||||
165
docker-compose.yml
Normal file
165
docker-compose.yml
Normal file
@ -0,0 +1,165 @@
|
|||||||
|
# Drama Studio - Docker Compose
|
||||||
|
# AI 라디오 드라마 제작 시스템
|
||||||
|
|
||||||
|
services:
|
||||||
|
# ===================
|
||||||
|
# Infrastructure
|
||||||
|
# ===================
|
||||||
|
mongodb:
|
||||||
|
image: mongo:7.0
|
||||||
|
container_name: drama-studio-mongodb
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
MONGO_INITDB_ROOT_USERNAME: ${MONGO_USER:-admin}
|
||||||
|
MONGO_INITDB_ROOT_PASSWORD: ${MONGO_PASSWORD:-password123}
|
||||||
|
ports:
|
||||||
|
- "27021:27017"
|
||||||
|
volumes:
|
||||||
|
- drama_studio_mongodb_data:/data/db
|
||||||
|
networks:
|
||||||
|
- drama-studio-network
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "mongosh", "--eval", "db.adminCommand('ping')"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 3
|
||||||
|
|
||||||
|
redis:
|
||||||
|
image: redis:7-alpine
|
||||||
|
container_name: drama-studio-redis
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "6383:6379"
|
||||||
|
volumes:
|
||||||
|
- drama_studio_redis_data:/data
|
||||||
|
networks:
|
||||||
|
- drama-studio-network
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "redis-cli", "ping"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 3
|
||||||
|
|
||||||
|
# ===================
|
||||||
|
# API Server
|
||||||
|
# ===================
|
||||||
|
api:
|
||||||
|
build:
|
||||||
|
context: ./audio-studio-api
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
container_name: drama-studio-api
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
- MONGODB_URL=mongodb://${MONGO_USER}:${MONGO_PASSWORD}@mongodb:27017/
|
||||||
|
- DB_NAME=${DB_NAME:-drama_studio}
|
||||||
|
- REDIS_URL=redis://redis:6379
|
||||||
|
- TTS_ENGINE_URL=http://tts-engine:8001
|
||||||
|
- MUSICGEN_URL=http://musicgen:8002
|
||||||
|
- FREESOUND_API_KEY=${FREESOUND_API_KEY}
|
||||||
|
ports:
|
||||||
|
- "8010:8000"
|
||||||
|
depends_on:
|
||||||
|
mongodb:
|
||||||
|
condition: service_healthy
|
||||||
|
redis:
|
||||||
|
condition: service_healthy
|
||||||
|
networks:
|
||||||
|
- drama-studio-network
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 3
|
||||||
|
|
||||||
|
# ===================
|
||||||
|
# GPU Services
|
||||||
|
# ===================
|
||||||
|
tts-engine:
|
||||||
|
build:
|
||||||
|
context: ./audio-studio-tts
|
||||||
|
dockerfile: Dockerfile.gpu
|
||||||
|
container_name: drama-studio-tts
|
||||||
|
restart: unless-stopped
|
||||||
|
deploy:
|
||||||
|
resources:
|
||||||
|
reservations:
|
||||||
|
devices:
|
||||||
|
- driver: nvidia
|
||||||
|
device_ids: ['0']
|
||||||
|
capabilities: [gpu]
|
||||||
|
environment:
|
||||||
|
- HF_TOKEN=${HF_TOKEN}
|
||||||
|
- CUDA_VISIBLE_DEVICES=0
|
||||||
|
- MODEL_ID=Qwen/Qwen3-TTS-12Hz-1.7B-Base
|
||||||
|
- CLONE_MODEL_ID=Qwen/Qwen3-TTS-12Hz-1.7B-CustomVoice
|
||||||
|
- DESIGN_MODEL_ID=Qwen/Qwen3-TTS-12Hz-1.7B-VoiceDesign
|
||||||
|
volumes:
|
||||||
|
- ~/.cache/huggingface:/root/.cache/huggingface
|
||||||
|
- drama_studio_models:/app/models
|
||||||
|
ports:
|
||||||
|
- "8001:8001"
|
||||||
|
networks:
|
||||||
|
- drama-studio-network
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "curl", "-f", "http://localhost:8001/health"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 30s
|
||||||
|
retries: 3
|
||||||
|
start_period: 180s # 모델 로딩 대기
|
||||||
|
|
||||||
|
musicgen:
|
||||||
|
build:
|
||||||
|
context: ./audio-studio-musicgen
|
||||||
|
dockerfile: Dockerfile.gpu
|
||||||
|
container_name: drama-studio-musicgen
|
||||||
|
restart: unless-stopped
|
||||||
|
deploy:
|
||||||
|
resources:
|
||||||
|
reservations:
|
||||||
|
devices:
|
||||||
|
- driver: nvidia
|
||||||
|
device_ids: ['1']
|
||||||
|
capabilities: [gpu]
|
||||||
|
environment:
|
||||||
|
- HF_TOKEN=${HF_TOKEN}
|
||||||
|
- CUDA_VISIBLE_DEVICES=0
|
||||||
|
- MODEL_NAME=facebook/musicgen-medium
|
||||||
|
volumes:
|
||||||
|
- ~/.cache/huggingface:/root/.cache/huggingface
|
||||||
|
ports:
|
||||||
|
- "8002:8002"
|
||||||
|
networks:
|
||||||
|
- drama-studio-network
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "curl", "-f", "http://localhost:8002/health"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 30s
|
||||||
|
retries: 3
|
||||||
|
start_period: 120s
|
||||||
|
|
||||||
|
# ===================
|
||||||
|
# Frontend
|
||||||
|
# ===================
|
||||||
|
ui:
|
||||||
|
build:
|
||||||
|
context: ./audio-studio-ui
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
container_name: drama-studio-ui
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
- NEXT_PUBLIC_API_URL=http://localhost:8010
|
||||||
|
ports:
|
||||||
|
- "3010:3000"
|
||||||
|
depends_on:
|
||||||
|
- api
|
||||||
|
networks:
|
||||||
|
- drama-studio-network
|
||||||
|
|
||||||
|
networks:
|
||||||
|
drama-studio-network:
|
||||||
|
driver: bridge
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
drama_studio_mongodb_data:
|
||||||
|
drama_studio_redis_data:
|
||||||
|
drama_studio_models:
|
||||||
Reference in New Issue
Block a user