From cc547372c0c378064bf9d43e0539ead5b94e1a12 Mon Sep 17 00:00:00 2001 From: jungwoo choi Date: Mon, 26 Jan 2026 11:39:38 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20Drama=20Studio=20=ED=94=84=EB=A1=9C?= =?UTF-8?q?=EC=A0=9D=ED=8A=B8=20=EC=B4=88=EA=B8=B0=20=EA=B5=AC=EC=A1=B0=20?= =?UTF-8?q?=EC=84=A4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .claude/project-knowledge.md | 352 + .claude/settings.json | 7 + .claude/skills/README.md | 58 + .claude/skills/ai-api-integration.md | 213 + .claude/skills/api-design-standards.md | 269 + .claude/skills/database-patterns.md | 384 + .claude/skills/deployment-standards.md | 220 + .claude/skills/flume-architecture.md | 365 + .claude/skills/frontend-component-patterns.md | 325 + .claude/skills/gitea-workflow.md | 142 + .claude/skills/gpu-local-models.md | 213 + .claude/skills/infrastructure-setup.md | 268 + .claude/skills/korean-dev-conventions.md | 174 + .claude/skills/monitoring-logging.md | 361 + .claude/skills/project-stack.md | 117 + .claude/skills/testing-standards.md | 360 + .env.example | 24 + .gitignore | 40 + CLAUDE.md | 58 + audio-studio-api/Dockerfile | 33 + audio-studio-api/app/__init__.py | 0 audio-studio-api/app/database.py | 169 + audio-studio-api/app/main.py | 163 + audio-studio-api/app/routers/__init__.py | 0 audio-studio-api/app/routers/drama.py | 193 + audio-studio-api/app/routers/music.py | 278 + audio-studio-api/app/routers/recordings.py | 184 + audio-studio-api/app/routers/sound_effects.py | 340 + audio-studio-api/app/routers/tts.py | 227 + audio-studio-api/app/routers/voices.py | 426 + audio-studio-api/app/services/__init__.py | 0 audio-studio-api/app/services/audio_mixer.py | 260 + .../app/services/drama_orchestrator.py | 362 + .../app/services/freesound_client.py | 165 + .../app/services/script_parser.py | 174 + audio-studio-api/app/services/tts_client.py | 135 + audio-studio-api/requirements.txt | 31 + audio-studio-musicgen/Dockerfile.gpu | 56 + audio-studio-musicgen/app/__init__.py | 0 audio-studio-musicgen/app/main.py | 205 + .../app/services/__init__.py | 0 .../app/services/musicgen_service.py | 199 + audio-studio-musicgen/requirements.txt | 24 + audio-studio-tts/Dockerfile.gpu | 57 + audio-studio-tts/app/__init__.py | 0 audio-studio-tts/app/main.py | 280 + audio-studio-tts/app/services/__init__.py | 0 audio-studio-tts/app/services/qwen_tts.py | 272 + audio-studio-tts/requirements.txt | 29 + audio-studio-ui/Dockerfile | 43 + audio-studio-ui/next-env.d.ts | 6 + audio-studio-ui/next.config.ts | 10 + audio-studio-ui/package-lock.json | 7238 +++++++++++++++++ audio-studio-ui/package.json | 42 + audio-studio-ui/postcss.config.mjs | 5 + audio-studio-ui/public/.gitkeep | 0 audio-studio-ui/src/app/drama/page.tsx | 432 + audio-studio-ui/src/app/globals.css | 59 + audio-studio-ui/src/app/layout.tsx | 85 + audio-studio-ui/src/app/music/page.tsx | 251 + audio-studio-ui/src/app/page.tsx | 235 + audio-studio-ui/src/app/recordings/page.tsx | 323 + .../src/app/sound-effects/page.tsx | 255 + audio-studio-ui/src/app/tts/page.tsx | 298 + audio-studio-ui/src/app/voices/page.tsx | 235 + audio-studio-ui/src/lib/api.ts | 371 + audio-studio-ui/src/lib/utils.ts | 18 + audio-studio-ui/tsconfig.json | 27 + docker-compose.dev.yml | 89 + docker-compose.yml | 165 + 70 files changed, 18399 insertions(+) create mode 100644 .claude/project-knowledge.md create mode 100644 .claude/settings.json create mode 100644 .claude/skills/README.md create mode 100644 .claude/skills/ai-api-integration.md create mode 100644 .claude/skills/api-design-standards.md create mode 100644 .claude/skills/database-patterns.md create mode 100644 .claude/skills/deployment-standards.md create mode 100644 .claude/skills/flume-architecture.md create mode 100644 .claude/skills/frontend-component-patterns.md create mode 100644 .claude/skills/gitea-workflow.md create mode 100644 .claude/skills/gpu-local-models.md create mode 100644 .claude/skills/infrastructure-setup.md create mode 100644 .claude/skills/korean-dev-conventions.md create mode 100644 .claude/skills/monitoring-logging.md create mode 100644 .claude/skills/project-stack.md create mode 100644 .claude/skills/testing-standards.md create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 CLAUDE.md create mode 100644 audio-studio-api/Dockerfile create mode 100644 audio-studio-api/app/__init__.py create mode 100644 audio-studio-api/app/database.py create mode 100644 audio-studio-api/app/main.py create mode 100644 audio-studio-api/app/routers/__init__.py create mode 100644 audio-studio-api/app/routers/drama.py create mode 100644 audio-studio-api/app/routers/music.py create mode 100644 audio-studio-api/app/routers/recordings.py create mode 100644 audio-studio-api/app/routers/sound_effects.py create mode 100644 audio-studio-api/app/routers/tts.py create mode 100644 audio-studio-api/app/routers/voices.py create mode 100644 audio-studio-api/app/services/__init__.py create mode 100644 audio-studio-api/app/services/audio_mixer.py create mode 100644 audio-studio-api/app/services/drama_orchestrator.py create mode 100644 audio-studio-api/app/services/freesound_client.py create mode 100644 audio-studio-api/app/services/script_parser.py create mode 100644 audio-studio-api/app/services/tts_client.py create mode 100644 audio-studio-api/requirements.txt create mode 100644 audio-studio-musicgen/Dockerfile.gpu create mode 100644 audio-studio-musicgen/app/__init__.py create mode 100644 audio-studio-musicgen/app/main.py create mode 100644 audio-studio-musicgen/app/services/__init__.py create mode 100644 audio-studio-musicgen/app/services/musicgen_service.py create mode 100644 audio-studio-musicgen/requirements.txt create mode 100644 audio-studio-tts/Dockerfile.gpu create mode 100644 audio-studio-tts/app/__init__.py create mode 100644 audio-studio-tts/app/main.py create mode 100644 audio-studio-tts/app/services/__init__.py create mode 100644 audio-studio-tts/app/services/qwen_tts.py create mode 100644 audio-studio-tts/requirements.txt create mode 100644 audio-studio-ui/Dockerfile create mode 100644 audio-studio-ui/next-env.d.ts create mode 100644 audio-studio-ui/next.config.ts create mode 100644 audio-studio-ui/package-lock.json create mode 100644 audio-studio-ui/package.json create mode 100644 audio-studio-ui/postcss.config.mjs create mode 100644 audio-studio-ui/public/.gitkeep create mode 100644 audio-studio-ui/src/app/drama/page.tsx create mode 100644 audio-studio-ui/src/app/globals.css create mode 100644 audio-studio-ui/src/app/layout.tsx create mode 100644 audio-studio-ui/src/app/music/page.tsx create mode 100644 audio-studio-ui/src/app/page.tsx create mode 100644 audio-studio-ui/src/app/recordings/page.tsx create mode 100644 audio-studio-ui/src/app/sound-effects/page.tsx create mode 100644 audio-studio-ui/src/app/tts/page.tsx create mode 100644 audio-studio-ui/src/app/voices/page.tsx create mode 100644 audio-studio-ui/src/lib/api.ts create mode 100644 audio-studio-ui/src/lib/utils.ts create mode 100644 audio-studio-ui/tsconfig.json create mode 100644 docker-compose.dev.yml create mode 100644 docker-compose.yml diff --git a/.claude/project-knowledge.md b/.claude/project-knowledge.md new file mode 100644 index 0000000..48501ed --- /dev/null +++ b/.claude/project-knowledge.md @@ -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 +``` + +--- + +## 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>({ + 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"에 추가하면 프로젝트 컨텍스트를 공유할 수 있습니다. diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..b9241ba --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,7 @@ +{ + "permissions": { + "allow": [ + "Bash(git fetch:*)" + ] + } +} diff --git a/.claude/skills/README.md b/.claude/skills/README.md new file mode 100644 index 0000000..f6e115b --- /dev/null +++ b/.claude/skills/README.md @@ -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 비밀번호 | +| `{호스트포트}` | 호스트 포트 | diff --git a/.claude/skills/ai-api-integration.md b/.claude/skills/ai-api-integration.md new file mode 100644 index 0000000..1e2abdf --- /dev/null +++ b/.claude/skills/ai-api-integration.md @@ -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 기반 캐시 만료 +- 동일 입력에 대한 중복 호출 방지 diff --git a/.claude/skills/api-design-standards.md b/.claude/skills/api-design-standards.md new file mode 100644 index 0000000..0ebaf09 --- /dev/null +++ b/.claude/skills/api-design-standards.md @@ -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} +``` diff --git a/.claude/skills/database-patterns.md b/.claude/skills/database-patterns.md new file mode 100644 index 0000000..649d4ef --- /dev/null +++ b/.claude/skills/database-patterns.md @@ -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 +``` diff --git a/.claude/skills/deployment-standards.md b/.claude/skills/deployment-standards.md new file mode 100644 index 0000000..8cd3868 --- /dev/null +++ b/.claude/skills/deployment-standards.md @@ -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 diff --git a/.claude/skills/flume-architecture.md b/.claude/skills/flume-architecture.md new file mode 100644 index 0000000..2b84713 --- /dev/null +++ b/.claude/skills/flume-architecture.md @@ -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
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
APScheduler] + EXE[Executor
Async] + SM[State Manager] + end + + subgraph K8s["Kubernetes Cluster"] + OP[Flume Operator
kopf] + subgraph Workers["Worker Pods"] + CPU[CPU Workers] + GPU[GPU Workers
vLLM] + end + end + + subgraph Storage["Storage"] + Redis[(Redis
Queue/Cache)] + MongoDB[(MongoDB
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 +- [ ] 플러그인 시스템 +- [ ] 멀티 테넌시 diff --git a/.claude/skills/frontend-component-patterns.md b/.claude/skills/frontend-component-patterns.md new file mode 100644 index 0000000..a432fdc --- /dev/null +++ b/.claude/skills/frontend-component-patterns.md @@ -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 & { + asChild?: boolean + }) { + const Comp = asChild ? Slot : "button" + + return ( + + ) +} + +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 ( + + + + {children} + + + + + ); +} +``` + +### Provider 패턴 +```tsx +// components/providers.tsx +"use client" + +import { ThemeProvider } from "next-themes" + +export function Providers({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ) +} +``` + +## 유틸리티 함수 + +### 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([]) + const [isLoading, setIsLoading] = useState(true) + const [error, setError] = useState(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 + +export function ArticleForm() { + const form = useForm({ + 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 ( +
+ + ( + + 제목 + + + + + + )} + /> + + + + ) +} +``` + +## 다크모드 지원 + +### 테마 토글 버튼 +```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 ( + + ) +} +``` + +## 토스트 알림 (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 +``` diff --git a/.claude/skills/gitea-workflow.md b/.claude/skills/gitea-workflow.md new file mode 100644 index 0000000..de7d7c7 --- /dev/null +++ b/.claude/skills/gitea-workflow.md @@ -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 " + +# 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 +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 + ``` diff --git a/.claude/skills/gpu-local-models.md b/.claude/skills/gpu-local-models.md new file mode 100644 index 0000000..084d4da --- /dev/null +++ b/.claude/skills/gpu-local-models.md @@ -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 유무에 따라 성능 차이 diff --git a/.claude/skills/infrastructure-setup.md b/.claude/skills/infrastructure-setup.md new file mode 100644 index 0000000..b149231 --- /dev/null +++ b/.claude/skills/infrastructure-setup.md @@ -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 \ + --discovery-token-ca-cert-hash sha256: +``` + +### 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 {서비스명} +``` diff --git a/.claude/skills/korean-dev-conventions.md b/.claude/skills/korean-dev-conventions.md new file mode 100644 index 0000000..28c8f16 --- /dev/null +++ b/.claude/skills/korean-dev-conventions.md @@ -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 +``` + +## 상수 정의 + +```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 지원)""" +``` + +## 커밋 메시지 + +### 형식 +``` +: + +- +- + +Co-Authored-By: Claude Opus 4.5 +``` + +### 타입 +- `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 +``` + +## 파일 인코딩 + +- 모든 파일: 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[출력] +``` +``` diff --git a/.claude/skills/monitoring-logging.md b/.claude/skills/monitoring-logging.md new file mode 100644 index 0000000..2fc65f7 --- /dev/null +++ b/.claude/skills/monitoring-logging.md @@ -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" +``` diff --git a/.claude/skills/project-stack.md b/.claude/skills/project-stack.md new file mode 100644 index 0000000..f9ddde5 --- /dev/null +++ b/.claude/skills/project-stack.md @@ -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 | diff --git a/.claude/skills/testing-standards.md b/.claude/skills/testing-standards.md new file mode 100644 index 0000000..4080009 --- /dev/null +++ b/.claude/skills/testing-standards.md @@ -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: ['/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() + expect(screen.getByText('Click me')).toBeInTheDocument() + }) + + it('calls onClick handler', () => { + const handleClick = jest.fn() + render() + fireEvent.click(screen.getByText('Click me')) + expect(handleClick).toHaveBeenCalledTimes(1) + }) + + it('applies variant styles', () => { + render() + 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 +``` diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..a4e2d95 --- /dev/null +++ b/.env.example @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f328f1c --- /dev/null +++ b/.gitignore @@ -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 diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..776643b --- /dev/null +++ b/CLAUDE.md @@ -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`) + +### 커밋 메시지 +``` +: + +- + +Co-Authored-By: Claude Opus 4.5 +``` +타입: 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 | 모니터링 | diff --git a/audio-studio-api/Dockerfile b/audio-studio-api/Dockerfile new file mode 100644 index 0000000..b38e808 --- /dev/null +++ b/audio-studio-api/Dockerfile @@ -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"] diff --git a/audio-studio-api/app/__init__.py b/audio-studio-api/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/audio-studio-api/app/database.py b/audio-studio-api/app/database.py new file mode 100644 index 0000000..4a24039 --- /dev/null +++ b/audio-studio-api/app/database.py @@ -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 diff --git a/audio-studio-api/app/main.py b/audio-studio-api/app/main.py new file mode 100644 index 0000000..04cc1e9 --- /dev/null +++ b/audio-studio-api/app/main.py @@ -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"}, + ) diff --git a/audio-studio-api/app/routers/__init__.py b/audio-studio-api/app/routers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/audio-studio-api/app/routers/drama.py b/audio-studio-api/app/routers/drama.py new file mode 100644 index 0000000..b9fdd00 --- /dev/null +++ b/audio-studio-api/app/routers/drama.py @@ -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": "프로젝트가 삭제되었습니다"} diff --git a/audio-studio-api/app/routers/music.py b/audio-studio-api/app/routers/music.py new file mode 100644 index 0000000..e871a1b --- /dev/null +++ b/audio-studio-api/app/routers/music.py @@ -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", + ], + }, + ] + } diff --git a/audio-studio-api/app/routers/recordings.py b/audio-studio-api/app/routers/recordings.py new file mode 100644 index 0000000..729eb49 --- /dev/null +++ b/audio-studio-api/app/routers/recordings.py @@ -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") diff --git a/audio-studio-api/app/routers/sound_effects.py b/audio-studio-api/app/routers/sound_effects.py new file mode 100644 index 0000000..fd72d8e --- /dev/null +++ b/audio-studio-api/app/routers/sound_effects.py @@ -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} diff --git a/audio-studio-api/app/routers/tts.py b/audio-studio-api/app/routers/tts.py new file mode 100644 index 0000000..4caf336 --- /dev/null +++ b/audio-studio-api/app/routers/tts.py @@ -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)} diff --git a/audio-studio-api/app/routers/voices.py b/audio-studio-api/app/routers/voices.py new file mode 100644 index 0000000..fa2fd85 --- /dev/null +++ b/audio-studio-api/app/routers/voices.py @@ -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} diff --git a/audio-studio-api/app/services/__init__.py b/audio-studio-api/app/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/audio-studio-api/app/services/audio_mixer.py b/audio-studio-api/app/services/audio_mixer.py new file mode 100644 index 0000000..abd4e7c --- /dev/null +++ b/audio-studio-api/app/services/audio_mixer.py @@ -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() diff --git a/audio-studio-api/app/services/drama_orchestrator.py b/audio-studio-api/app/services/drama_orchestrator.py new file mode 100644 index 0000000..878cece --- /dev/null +++ b/audio-studio-api/app/services/drama_orchestrator.py @@ -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() diff --git a/audio-studio-api/app/services/freesound_client.py b/audio-studio-api/app/services/freesound_client.py new file mode 100644 index 0000000..a19bbd3 --- /dev/null +++ b/audio-studio-api/app/services/freesound_client.py @@ -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() diff --git a/audio-studio-api/app/services/script_parser.py b/audio-studio-api/app/services/script_parser.py new file mode 100644 index 0000000..0a09136 --- /dev/null +++ b/audio-studio-api/app/services/script_parser.py @@ -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() diff --git a/audio-studio-api/app/services/tts_client.py b/audio-studio-api/app/services/tts_client.py new file mode 100644 index 0000000..74fee94 --- /dev/null +++ b/audio-studio-api/app/services/tts_client.py @@ -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() diff --git a/audio-studio-api/requirements.txt b/audio-studio-api/requirements.txt new file mode 100644 index 0000000..0258b4a --- /dev/null +++ b/audio-studio-api/requirements.txt @@ -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 diff --git a/audio-studio-musicgen/Dockerfile.gpu b/audio-studio-musicgen/Dockerfile.gpu new file mode 100644 index 0000000..b9ee3b2 --- /dev/null +++ b/audio-studio-musicgen/Dockerfile.gpu @@ -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"] diff --git a/audio-studio-musicgen/app/__init__.py b/audio-studio-musicgen/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/audio-studio-musicgen/app/main.py b/audio-studio-musicgen/app/main.py new file mode 100644 index 0000000..866cacb --- /dev/null +++ b/audio-studio-musicgen/app/main.py @@ -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", + ], + }, + ] + } diff --git a/audio-studio-musicgen/app/services/__init__.py b/audio-studio-musicgen/app/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/audio-studio-musicgen/app/services/musicgen_service.py b/audio-studio-musicgen/app/services/musicgen_service.py new file mode 100644 index 0000000..335ce83 --- /dev/null +++ b/audio-studio-musicgen/app/services/musicgen_service.py @@ -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() diff --git a/audio-studio-musicgen/requirements.txt b/audio-studio-musicgen/requirements.txt new file mode 100644 index 0000000..069c1f0 --- /dev/null +++ b/audio-studio-musicgen/requirements.txt @@ -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 diff --git a/audio-studio-tts/Dockerfile.gpu b/audio-studio-tts/Dockerfile.gpu new file mode 100644 index 0000000..ff04f38 --- /dev/null +++ b/audio-studio-tts/Dockerfile.gpu @@ -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"] diff --git a/audio-studio-tts/app/__init__.py b/audio-studio-tts/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/audio-studio-tts/app/main.py b/audio-studio-tts/app/main.py new file mode 100644 index 0000000..525aad3 --- /dev/null +++ b/audio-studio-tts/app/main.py @@ -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)}") diff --git a/audio-studio-tts/app/services/__init__.py b/audio-studio-tts/app/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/audio-studio-tts/app/services/qwen_tts.py b/audio-studio-tts/app/services/qwen_tts.py new file mode 100644 index 0000000..0e3042b --- /dev/null +++ b/audio-studio-tts/app/services/qwen_tts.py @@ -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() diff --git a/audio-studio-tts/requirements.txt b/audio-studio-tts/requirements.txt new file mode 100644 index 0000000..fd301e3 --- /dev/null +++ b/audio-studio-tts/requirements.txt @@ -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 diff --git a/audio-studio-ui/Dockerfile b/audio-studio-ui/Dockerfile new file mode 100644 index 0000000..8dc4ba3 --- /dev/null +++ b/audio-studio-ui/Dockerfile @@ -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"] diff --git a/audio-studio-ui/next-env.d.ts b/audio-studio-ui/next-env.d.ts new file mode 100644 index 0000000..830fb59 --- /dev/null +++ b/audio-studio-ui/next-env.d.ts @@ -0,0 +1,6 @@ +/// +/// +/// + +// NOTE: This file should not be edited +// see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/audio-studio-ui/next.config.ts b/audio-studio-ui/next.config.ts new file mode 100644 index 0000000..1151ef9 --- /dev/null +++ b/audio-studio-ui/next.config.ts @@ -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; diff --git a/audio-studio-ui/package-lock.json b/audio-studio-ui/package-lock.json new file mode 100644 index 0000000..10c372b --- /dev/null +++ b/audio-studio-ui/package-lock.json @@ -0,0 +1,7238 @@ +{ + "name": "drama-studio-ui", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "drama-studio-ui", + "version": "0.1.0", + "dependencies": { + "@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", + "next": "^15.1.0", + "react": "^19.0.0", + "react-dom": "^19.0.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", + "eslint": "^9.17.0", + "eslint-config-next": "^15.1.0", + "postcss": "^8.4.49", + "tailwindcss": "^4.0.0", + "typescript": "^5.7.0" + } + }, + "node_modules/@alloc/quick-lru": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", + "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@emnapi/core": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.8.1.tgz", + "integrity": "sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.1.0", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.8.1.tgz", + "integrity": "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.1.0.tgz", + "integrity": "sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.21.1", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.1.tgz", + "integrity": "sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^2.1.7", + "debug": "^4.3.1", + "minimatch": "^3.1.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", + "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/core": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", + "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.3.tgz", + "integrity": "sha512-Kr+LPIUVKz2qkx1HAMH8q1q6azbqBAsXJUxBl/ODDuVPX45Z9DfwB8tPjTi6nNZ8BuM3nbJxC5zCAg5elnBUTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.12.4", + "debug": "^4.3.2", + "espree": "^10.0.1", + "globals": "^14.0.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.1", + "minimatch": "^3.1.2", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/js": { + "version": "9.39.2", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.2.tgz", + "integrity": "sha512-q1mjIoW1VX4IvSocvM/vbTiveKC4k9eLrajNEuSsmjymSDEbpGddtpfOoN7YGAqBK3NG+uqo8ia4PDTt8buCYA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + } + }, + "node_modules/@eslint/object-schema": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", + "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", + "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0", + "levn": "^0.4.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@floating-ui/core": { + "version": "1.7.3", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.3.tgz", + "integrity": "sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w==", + "license": "MIT", + "dependencies": { + "@floating-ui/utils": "^0.2.10" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.7.4", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.4.tgz", + "integrity": "sha512-OOchDgh4F2CchOX94cRVqhvy7b3AFb+/rQXyswmzmGakRfkMgoWVjfnLWkRirfLEfuD4ysVW16eXzwt3jHIzKA==", + "license": "MIT", + "dependencies": { + "@floating-ui/core": "^1.7.3", + "@floating-ui/utils": "^0.2.10" + } + }, + "node_modules/@floating-ui/react-dom": { + "version": "2.1.6", + "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.6.tgz", + "integrity": "sha512-4JX6rEatQEvlmgU80wZyq9RT96HZJa88q8hp0pBd+LrczeDI4o6uA2M+uvxngVHo4Ihr8uibXxH6+70zhAFrVw==", + "license": "MIT", + "dependencies": { + "@floating-ui/dom": "^1.7.4" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.10", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.10.tgz", + "integrity": "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==", + "license": "MIT" + }, + "node_modules/@humanfs/core": { + "version": "0.19.1", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", + "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.7", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz", + "integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.1", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@img/colour": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.0.0.tgz", + "integrity": "sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz", + "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz", + "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz", + "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz", + "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz", + "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==", + "cpu": [ + "arm" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz", + "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-ppc64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz", + "integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==", + "cpu": [ + "ppc64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-riscv64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz", + "integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==", + "cpu": [ + "riscv64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz", + "integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==", + "cpu": [ + "s390x" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz", + "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz", + "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz", + "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz", + "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==", + "cpu": [ + "arm" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz", + "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-ppc64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz", + "integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==", + "cpu": [ + "ppc64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-ppc64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-riscv64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz", + "integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==", + "cpu": [ + "riscv64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-riscv64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz", + "integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==", + "cpu": [ + "s390x" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz", + "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz", + "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz", + "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz", + "integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==", + "cpu": [ + "wasm32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.7.0" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz", + "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz", + "integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==", + "cpu": [ + "ia32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz", + "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "0.2.12", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz", + "integrity": "sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.4.3", + "@emnapi/runtime": "^1.4.3", + "@tybys/wasm-util": "^0.10.0" + } + }, + "node_modules/@next/env": { + "version": "15.5.9", + "resolved": "https://registry.npmjs.org/@next/env/-/env-15.5.9.tgz", + "integrity": "sha512-4GlTZ+EJM7WaW2HEZcyU317tIQDjkQIyENDLxYJfSWlfqguN+dHkZgyQTV/7ykvobU7yEH5gKvreNrH4B6QgIg==", + "license": "MIT" + }, + "node_modules/@next/eslint-plugin-next": { + "version": "15.5.9", + "resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-15.5.9.tgz", + "integrity": "sha512-kUzXx0iFiXw27cQAViE1yKWnz/nF8JzRmwgMRTMh8qMY90crNsdXJRh2e+R0vBpFR3kk1yvAR7wev7+fCCb79Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-glob": "3.3.1" + } + }, + "node_modules/@next/swc-darwin-arm64": { + "version": "15.5.7", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.5.7.tgz", + "integrity": "sha512-IZwtxCEpI91HVU/rAUOOobWSZv4P2DeTtNaCdHqLcTJU4wdNXgAySvKa/qJCgR5m6KI8UsKDXtO2B31jcaw1Yw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-darwin-x64": { + "version": "15.5.7", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-15.5.7.tgz", + "integrity": "sha512-UP6CaDBcqaCBuiq/gfCEJw7sPEoX1aIjZHnBWN9v9qYHQdMKvCKcAVs4OX1vIjeE+tC5EIuwDTVIoXpUes29lg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-arm64-gnu": { + "version": "15.5.7", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.5.7.tgz", + "integrity": "sha512-NCslw3GrNIw7OgmRBxHtdWFQYhexoUCq+0oS2ccjyYLtcn1SzGzeM54jpTFonIMUjNbHmpKpziXnpxhSWLcmBA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-arm64-musl": { + "version": "15.5.7", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.5.7.tgz", + "integrity": "sha512-nfymt+SE5cvtTrG9u1wdoxBr9bVB7mtKTcj0ltRn6gkP/2Nu1zM5ei8rwP9qKQP0Y//umK+TtkKgNtfboBxRrw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-x64-gnu": { + "version": "15.5.7", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-15.5.7.tgz", + "integrity": "sha512-hvXcZvCaaEbCZcVzcY7E1uXN9xWZfFvkNHwbe/n4OkRhFWrs1J1QV+4U1BN06tXLdaS4DazEGXwgqnu/VMcmqw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-x64-musl": { + "version": "15.5.7", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-15.5.7.tgz", + "integrity": "sha512-4IUO539b8FmF0odY6/SqANJdgwn1xs1GkPO5doZugwZ3ETF6JUdckk7RGmsfSf7ws8Qb2YB5It33mvNL/0acqA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-arm64-msvc": { + "version": "15.5.7", + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.5.7.tgz", + "integrity": "sha512-CpJVTkYI3ZajQkC5vajM7/ApKJUOlm6uP4BknM3XKvJ7VXAvCqSjSLmM0LKdYzn6nBJVSjdclx8nYJSa3xlTgQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-x64-msvc": { + "version": "15.5.7", + "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.5.7.tgz", + "integrity": "sha512-gMzgBX164I6DN+9/PGA+9dQiwmTkE4TloBNx8Kv9UiGARsr9Nba7IpcBRA1iTV9vwlYnrE3Uy6I7Aj6qLjQuqw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nolyfill/is-core-module": { + "version": "1.0.39", + "resolved": "https://registry.npmjs.org/@nolyfill/is-core-module/-/is-core-module-1.0.39.tgz", + "integrity": "sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.4.0" + } + }, + "node_modules/@radix-ui/number": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.1.tgz", + "integrity": "sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==", + "license": "MIT" + }, + "node_modules/@radix-ui/primitive": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz", + "integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==", + "license": "MIT" + }, + "node_modules/@radix-ui/react-arrow": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.7.tgz", + "integrity": "sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-collection": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.7.tgz", + "integrity": "sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-collection/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-compose-refs": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz", + "integrity": "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-context": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", + "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dialog": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.15.tgz", + "integrity": "sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-focus-guards": "1.1.3", + "@radix-ui/react-focus-scope": "1.1.7", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.6.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-direction": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.1.tgz", + "integrity": "sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dismissable-layer": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.11.tgz", + "integrity": "sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-escape-keydown": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dropdown-menu": { + "version": "2.1.16", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dropdown-menu/-/react-dropdown-menu-2.1.16.tgz", + "integrity": "sha512-1PLGQEynI/3OX/ftV54COn+3Sud/Mn8vALg2rWnBLnRaGtJDduNW/22XjlGgPdpcIbiQxjKtb7BkcjP00nqfJw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-menu": "2.1.16", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-focus-guards": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.3.tgz", + "integrity": "sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-focus-scope": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.7.tgz", + "integrity": "sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-icons": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-icons/-/react-icons-1.3.2.tgz", + "integrity": "sha512-fyQIhGDhzfc9pK2kH6Pl9c4BDJGfMkPqkyIgYDthyNYoNg3wVhoJMMh19WS4Up/1KMPFVpNsT2q3WmXn2N1m6g==", + "license": "MIT", + "peerDependencies": { + "react": "^16.x || ^17.x || ^18.x || ^19.0.0 || ^19.0.0-rc" + } + }, + "node_modules/@radix-ui/react-id": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.1.tgz", + "integrity": "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-label": { + "version": "2.1.8", + "resolved": "https://registry.npmjs.org/@radix-ui/react-label/-/react-label-2.1.8.tgz", + "integrity": "sha512-FmXs37I6hSBVDlO4y764TNz1rLgKwjJMQ0EGte6F3Cb3f4bIuHB/iLa/8I9VKkmOy+gNHq8rql3j686ACVV21A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-label/node_modules/@radix-ui/react-primitive": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.4.tgz", + "integrity": "sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-menu": { + "version": "2.1.16", + "resolved": "https://registry.npmjs.org/@radix-ui/react-menu/-/react-menu-2.1.16.tgz", + "integrity": "sha512-72F2T+PLlphrqLcAotYPp0uJMr5SjP5SL01wfEspJbru5Zs5vQaSHb4VB3ZMJPimgHHCHG7gMOeOB9H3Hdmtxg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-focus-guards": "1.1.3", + "@radix-ui/react-focus-scope": "1.1.7", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-popper": "1.2.8", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-roving-focus": "1.1.11", + "@radix-ui/react-slot": "1.2.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.6.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-popper": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.8.tgz", + "integrity": "sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw==", + "license": "MIT", + "dependencies": { + "@floating-ui/react-dom": "^2.0.0", + "@radix-ui/react-arrow": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-layout-effect": "1.1.1", + "@radix-ui/react-use-rect": "1.1.1", + "@radix-ui/react-use-size": "1.1.1", + "@radix-ui/rect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-portal": { + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.9.tgz", + "integrity": "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-presence": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.5.tgz", + "integrity": "sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-primitive/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-roving-focus": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.11.tgz", + "integrity": "sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-select": { + "version": "2.2.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-select/-/react-select-2.2.6.tgz", + "integrity": "sha512-I30RydO+bnn2PQztvo25tswPH+wFBjehVGtmagkU78yMdwTwVf12wnAOF+AeP8S2N8xD+5UPbGhkUfPyvT+mwQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/number": "1.1.1", + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-focus-guards": "1.1.3", + "@radix-ui/react-focus-scope": "1.1.7", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-popper": "1.2.8", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-layout-effect": "1.1.1", + "@radix-ui/react-use-previous": "1.1.1", + "@radix-ui/react-visually-hidden": "1.2.3", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.6.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-select/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-slider": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slider/-/react-slider-1.3.6.tgz", + "integrity": "sha512-JPYb1GuM1bxfjMRlNLE+BcmBC8onfCi60Blk7OBqi2MLTFdS+8401U4uFjnwkOr49BLmXxLC6JHkvAsx5OJvHw==", + "license": "MIT", + "dependencies": { + "@radix-ui/number": "1.1.1", + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-layout-effect": "1.1.1", + "@radix-ui/react-use-previous": "1.1.1", + "@radix-ui/react-use-size": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-slot": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.4.tgz", + "integrity": "sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tabs": { + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.1.13.tgz", + "integrity": "sha512-7xdcatg7/U+7+Udyoj2zodtI9H/IIopqo+YOIcZOq1nJwXWBZ9p8xiu5llXlekDbZkca79a/fozEYQXIA4sW6A==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-roving-focus": "1.1.11", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-toast": { + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-toast/-/react-toast-1.2.15.tgz", + "integrity": "sha512-3OSz3TacUWy4WtOXV38DggwxoqJK4+eDkNMl5Z/MJZaoUPaP4/9lf81xXMe1I2ReTAptverZUpbPY4wWwWyL5g==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-layout-effect": "1.1.1", + "@radix-ui/react-visually-hidden": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tooltip": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.2.8.tgz", + "integrity": "sha512-tY7sVt1yL9ozIxvmbtN5qtmH2krXcBCfjEiCgKGLqunJHvgvZG2Pcl2oQ3kbcZARb1BGEHdkLzcYGO8ynVlieg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-popper": "1.2.8", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-visually-hidden": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-callback-ref": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz", + "integrity": "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-controllable-state": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.2.tgz", + "integrity": "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-effect-event": "0.0.2", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-effect-event": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.2.tgz", + "integrity": "sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-escape-keydown": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.1.1.tgz", + "integrity": "sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-callback-ref": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-layout-effect": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.1.tgz", + "integrity": "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-previous": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-previous/-/react-use-previous-1.1.1.tgz", + "integrity": "sha512-2dHfToCj/pzca2Ck724OZ5L0EVrr3eHRNsG/b3xQJLA2hZpVCS99bLAX+hm1IHXDEnzU6by5z/5MIY794/a8NQ==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-rect": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-rect/-/react-use-rect-1.1.1.tgz", + "integrity": "sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w==", + "license": "MIT", + "dependencies": { + "@radix-ui/rect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-size": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-size/-/react-use-size-1.1.1.tgz", + "integrity": "sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-visually-hidden": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.2.3.tgz", + "integrity": "sha512-pzJq12tEaaIhqjbzpCuv/OypJY/BPavOofm+dbab+MHLajy277+1lLm6JFcGgF5eskJ6mquGirhXY2GD/8u8Ug==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/rect": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.1.tgz", + "integrity": "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==", + "license": "MIT" + }, + "node_modules/@rtsao/scc": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz", + "integrity": "sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rushstack/eslint-patch": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/@rushstack/eslint-patch/-/eslint-patch-1.15.0.tgz", + "integrity": "sha512-ojSshQPKwVvSMR8yT2L/QtUkV5SXi/IfDiJ4/8d6UbTPjiHVmxZzUAzGD8Tzks1b9+qQkZa0isUOvYObedITaw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@swc/helpers": { + "version": "0.5.15", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz", + "integrity": "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.8.0" + } + }, + "node_modules/@tailwindcss/node": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.18.tgz", + "integrity": "sha512-DoR7U1P7iYhw16qJ49fgXUlry1t4CpXeErJHnQ44JgTSKMaZUdf17cfn5mHchfJ4KRBZRFA/Coo+MUF5+gOaCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.4", + "enhanced-resolve": "^5.18.3", + "jiti": "^2.6.1", + "lightningcss": "1.30.2", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.1.18" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.1.18.tgz", + "integrity": "sha512-EgCR5tTS5bUSKQgzeMClT6iCY3ToqE1y+ZB0AKldj809QXk1Y+3jB0upOYZrn9aGIzPtUsP7sX4QQ4XtjBB95A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.1.18", + "@tailwindcss/oxide-darwin-arm64": "4.1.18", + "@tailwindcss/oxide-darwin-x64": "4.1.18", + "@tailwindcss/oxide-freebsd-x64": "4.1.18", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.18", + "@tailwindcss/oxide-linux-arm64-gnu": "4.1.18", + "@tailwindcss/oxide-linux-arm64-musl": "4.1.18", + "@tailwindcss/oxide-linux-x64-gnu": "4.1.18", + "@tailwindcss/oxide-linux-x64-musl": "4.1.18", + "@tailwindcss/oxide-wasm32-wasi": "4.1.18", + "@tailwindcss/oxide-win32-arm64-msvc": "4.1.18", + "@tailwindcss/oxide-win32-x64-msvc": "4.1.18" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.1.18.tgz", + "integrity": "sha512-dJHz7+Ugr9U/diKJA0W6N/6/cjI+ZTAoxPf9Iz9BFRF2GzEX8IvXxFIi/dZBloVJX/MZGvRuFA9rqwdiIEZQ0Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.1.18.tgz", + "integrity": "sha512-Gc2q4Qhs660bhjyBSKgq6BYvwDz4G+BuyJ5H1xfhmDR3D8HnHCmT/BSkvSL0vQLy/nkMLY20PQ2OoYMO15Jd0A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.1.18.tgz", + "integrity": "sha512-FL5oxr2xQsFrc3X9o1fjHKBYBMD1QZNyc1Xzw/h5Qu4XnEBi3dZn96HcHm41c/euGV+GRiXFfh2hUCyKi/e+yw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.1.18.tgz", + "integrity": "sha512-Fj+RHgu5bDodmV1dM9yAxlfJwkkWvLiRjbhuO2LEtwtlYlBgiAT4x/j5wQr1tC3SANAgD+0YcmWVrj8R9trVMA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.1.18.tgz", + "integrity": "sha512-Fp+Wzk/Ws4dZn+LV2Nqx3IilnhH51YZoRaYHQsVq3RQvEl+71VGKFpkfHrLM/Li+kt5c0DJe/bHXK1eHgDmdiA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.1.18.tgz", + "integrity": "sha512-S0n3jboLysNbh55Vrt7pk9wgpyTTPD0fdQeh7wQfMqLPM/Hrxi+dVsLsPrycQjGKEQk85Kgbx+6+QnYNiHalnw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.1.18.tgz", + "integrity": "sha512-1px92582HkPQlaaCkdRcio71p8bc8i/ap5807tPRDK/uw953cauQBT8c5tVGkOwrHMfc2Yh6UuxaH4vtTjGvHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.1.18.tgz", + "integrity": "sha512-v3gyT0ivkfBLoZGF9LyHmts0Isc8jHZyVcbzio6Wpzifg/+5ZJpDiRiUhDLkcr7f/r38SWNe7ucxmGW3j3Kb/g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.1.18.tgz", + "integrity": "sha512-bhJ2y2OQNlcRwwgOAGMY0xTFStt4/wyU6pvI6LSuZpRgKQwxTec0/3Scu91O8ir7qCR3AuepQKLU/kX99FouqQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.1.18.tgz", + "integrity": "sha512-LffYTvPjODiP6PT16oNeUQJzNVyJl1cjIebq/rWWBF+3eDst5JGEFSc5cWxyRCJ0Mxl+KyIkqRxk1XPEs9x8TA==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1", + "@emnapi/wasi-threads": "^1.1.0", + "@napi-rs/wasm-runtime": "^1.1.0", + "@tybys/wasm-util": "^0.10.1", + "tslib": "^2.4.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.18.tgz", + "integrity": "sha512-HjSA7mr9HmC8fu6bdsZvZ+dhjyGCLdotjVOgLA2vEqxEBZaQo9YTX4kwgEvPCpRh8o4uWc4J/wEoFzhEmjvPbA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.1.18.tgz", + "integrity": "sha512-bJWbyYpUlqamC8dpR7pfjA0I7vdF6t5VpUGMWRkXVE3AXgIZjYUYAK7II1GNaxR8J1SSrSrppRar8G++JekE3Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/postcss": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/postcss/-/postcss-4.1.18.tgz", + "integrity": "sha512-Ce0GFnzAOuPyfV5SxjXGn0CubwGcuDB0zcdaPuCSzAa/2vII24JTkH+I6jcbXLb1ctjZMZZI6OjDaLPJQL1S0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "@tailwindcss/node": "4.1.18", + "@tailwindcss/oxide": "4.1.18", + "postcss": "^8.4.41", + "tailwindcss": "4.1.18" + } + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", + "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json5": { + "version": "0.0.29", + "resolved": "https://registry.npmjs.org/@types/json5/-/json5-0.0.29.tgz", + "integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "22.19.7", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.7.tgz", + "integrity": "sha512-MciR4AKGHWl7xwxkBa6xUGxQJ4VBOmPTF7sL+iGzuahOFaO0jHCsuEfS80pan1ef4gWId1oWOweIhrDEYLuaOw==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/react": { + "version": "19.2.9", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.9.tgz", + "integrity": "sha512-Lpo8kgb/igvMIPeNV2rsYKTgaORYdO1XGVZ4Qz3akwOj0ySGYMPlQWa8BaLn0G63D1aSaAQ5ldR06wCpChQCjA==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", + "devOptional": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.53.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.53.1.tgz", + "integrity": "sha512-cFYYFZ+oQFi6hUnBTbLRXfTJiaQtYE3t4O692agbBl+2Zy+eqSKWtPjhPXJu1G7j4RLjKgeJPDdq3EqOwmX5Ag==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.53.1", + "@typescript-eslint/type-utils": "8.53.1", + "@typescript-eslint/utils": "8.53.1", + "@typescript-eslint/visitor-keys": "8.53.1", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.4.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.53.1", + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.53.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.53.1.tgz", + "integrity": "sha512-nm3cvFN9SqZGXjmw5bZ6cGmvJSyJPn0wU9gHAZZHDnZl2wF9PhHv78Xf06E0MaNk4zLVHL8hb2/c32XvyJOLQg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.53.1", + "@typescript-eslint/types": "8.53.1", + "@typescript-eslint/typescript-estree": "8.53.1", + "@typescript-eslint/visitor-keys": "8.53.1", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.53.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.53.1.tgz", + "integrity": "sha512-WYC4FB5Ra0xidsmlPb+1SsnaSKPmS3gsjIARwbEkHkoWloQmuzcfypljaJcR78uyLA1h8sHdWWPHSLDI+MtNog==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.53.1", + "@typescript-eslint/types": "^8.53.1", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.53.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.53.1.tgz", + "integrity": "sha512-Lu23yw1uJMFY8cUeq7JlrizAgeQvWugNQzJp8C3x8Eo5Jw5Q2ykMdiiTB9vBVOOUBysMzmRRmUfwFrZuI2C4SQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.53.1", + "@typescript-eslint/visitor-keys": "8.53.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.53.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.53.1.tgz", + "integrity": "sha512-qfvLXS6F6b1y43pnf0pPbXJ+YoXIC7HKg0UGZ27uMIemKMKA6XH2DTxsEDdpdN29D+vHV07x/pnlPNVLhdhWiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.53.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.53.1.tgz", + "integrity": "sha512-MOrdtNvyhy0rHyv0ENzub1d4wQYKb2NmIqG7qEqPWFW7Mpy2jzFC3pQ2yKDvirZB7jypm5uGjF2Qqs6OIqu47w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.53.1", + "@typescript-eslint/typescript-estree": "8.53.1", + "@typescript-eslint/utils": "8.53.1", + "debug": "^4.4.3", + "ts-api-utils": "^2.4.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.53.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.53.1.tgz", + "integrity": "sha512-jr/swrr2aRmUAUjW5/zQHbMaui//vQlsZcJKijZf3M26bnmLj8LyZUpj8/Rd6uzaek06OWsqdofN/Thenm5O8A==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.53.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.53.1.tgz", + "integrity": "sha512-RGlVipGhQAG4GxV1s34O91cxQ/vWiHJTDHbXRr0li2q/BGg3RR/7NM8QDWgkEgrwQYCvmJV9ichIwyoKCQ+DTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.53.1", + "@typescript-eslint/tsconfig-utils": "8.53.1", + "@typescript-eslint/types": "8.53.1", + "@typescript-eslint/visitor-keys": "8.53.1", + "debug": "^4.4.3", + "minimatch": "^9.0.5", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.4.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.53.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.53.1.tgz", + "integrity": "sha512-c4bMvGVWW4hv6JmDUEG7fSYlWOl3II2I4ylt0NM+seinYQlZMQIaKaXIIVJWt9Ofh6whrpM+EdDQXKXjNovvrg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.53.1", + "@typescript-eslint/types": "8.53.1", + "@typescript-eslint/typescript-estree": "8.53.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.53.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.53.1.tgz", + "integrity": "sha512-oy+wV7xDKFPRyNggmXuZQSBzvoLnpmJs+GhzRhPjrxl2b/jIlyjVokzm47CZCDUdXKr2zd7ZLodPfOBpOPyPlg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.53.1", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@unrs/resolver-binding-android-arm-eabi": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm-eabi/-/resolver-binding-android-arm-eabi-1.11.1.tgz", + "integrity": "sha512-ppLRUgHVaGRWUx0R0Ut06Mjo9gBaBkg3v/8AxusGLhsIotbBLuRk51rAzqLC8gq6NyyAojEXglNjzf6R948DNw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@unrs/resolver-binding-android-arm64": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm64/-/resolver-binding-android-arm64-1.11.1.tgz", + "integrity": "sha512-lCxkVtb4wp1v+EoN+HjIG9cIIzPkX5OtM03pQYkG+U5O/wL53LC4QbIeazgiKqluGeVEeBlZahHalCaBvU1a2g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@unrs/resolver-binding-darwin-arm64": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-arm64/-/resolver-binding-darwin-arm64-1.11.1.tgz", + "integrity": "sha512-gPVA1UjRu1Y/IsB/dQEsp2V1pm44Of6+LWvbLc9SDk1c2KhhDRDBUkQCYVWe6f26uJb3fOK8saWMgtX8IrMk3g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@unrs/resolver-binding-darwin-x64": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-x64/-/resolver-binding-darwin-x64-1.11.1.tgz", + "integrity": "sha512-cFzP7rWKd3lZaCsDze07QX1SC24lO8mPty9vdP+YVa3MGdVgPmFc59317b2ioXtgCMKGiCLxJ4HQs62oz6GfRQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@unrs/resolver-binding-freebsd-x64": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-freebsd-x64/-/resolver-binding-freebsd-x64-1.11.1.tgz", + "integrity": "sha512-fqtGgak3zX4DCB6PFpsH5+Kmt/8CIi4Bry4rb1ho6Av2QHTREM+47y282Uqiu3ZRF5IQioJQ5qWRV6jduA+iGw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm-gnueabihf": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-gnueabihf/-/resolver-binding-linux-arm-gnueabihf-1.11.1.tgz", + "integrity": "sha512-u92mvlcYtp9MRKmP+ZvMmtPN34+/3lMHlyMj7wXJDeXxuM0Vgzz0+PPJNsro1m3IZPYChIkn944wW8TYgGKFHw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm-musleabihf": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-musleabihf/-/resolver-binding-linux-arm-musleabihf-1.11.1.tgz", + "integrity": "sha512-cINaoY2z7LVCrfHkIcmvj7osTOtm6VVT16b5oQdS4beibX2SYBwgYLmqhBjA1t51CarSaBuX5YNsWLjsqfW5Cw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm64-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-gnu/-/resolver-binding-linux-arm64-gnu-1.11.1.tgz", + "integrity": "sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm64-musl": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-musl/-/resolver-binding-linux-arm64-musl-1.11.1.tgz", + "integrity": "sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-ppc64-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-ppc64-gnu/-/resolver-binding-linux-ppc64-gnu-1.11.1.tgz", + "integrity": "sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-riscv64-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-gnu/-/resolver-binding-linux-riscv64-gnu-1.11.1.tgz", + "integrity": "sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-riscv64-musl": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-musl/-/resolver-binding-linux-riscv64-musl-1.11.1.tgz", + "integrity": "sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-s390x-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-s390x-gnu/-/resolver-binding-linux-s390x-gnu-1.11.1.tgz", + "integrity": "sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-x64-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-gnu/-/resolver-binding-linux-x64-gnu-1.11.1.tgz", + "integrity": "sha512-C3ZAHugKgovV5YvAMsxhq0gtXuwESUKc5MhEtjBpLoHPLYM+iuwSj3lflFwK3DPm68660rZ7G8BMcwSro7hD5w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-x64-musl": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-musl/-/resolver-binding-linux-x64-musl-1.11.1.tgz", + "integrity": "sha512-rV0YSoyhK2nZ4vEswT/QwqzqQXw5I6CjoaYMOX0TqBlWhojUf8P94mvI7nuJTeaCkkds3QE4+zS8Ko+GdXuZtA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-wasm32-wasi": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-wasm32-wasi/-/resolver-binding-wasm32-wasi-1.11.1.tgz", + "integrity": "sha512-5u4RkfxJm+Ng7IWgkzi3qrFOvLvQYnPBmjmZQ8+szTK/b31fQCnleNl1GgEt7nIsZRIf5PLhPwT0WM+q45x/UQ==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@napi-rs/wasm-runtime": "^0.2.11" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@unrs/resolver-binding-win32-arm64-msvc": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-arm64-msvc/-/resolver-binding-win32-arm64-msvc-1.11.1.tgz", + "integrity": "sha512-nRcz5Il4ln0kMhfL8S3hLkxI85BXs3o8EYoattsJNdsX4YUU89iOkVn7g0VHSRxFuVMdM4Q1jEpIId1Ihim/Uw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@unrs/resolver-binding-win32-ia32-msvc": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-ia32-msvc/-/resolver-binding-win32-ia32-msvc-1.11.1.tgz", + "integrity": "sha512-DCEI6t5i1NmAZp6pFonpD5m7i6aFrpofcp4LA2i8IIq60Jyo28hamKBxNrZcyOwVOZkgsRp9O2sXWBWP8MnvIQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@unrs/resolver-binding-win32-x64-msvc": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-x64-msvc/-/resolver-binding-win32-x64-msvc-1.11.1.tgz", + "integrity": "sha512-lrW200hZdbfRtztbygyaq/6jP6AKE8qQN2KvPcJ+x7wiD038YtnYtZ82IMNJ69GJibV7bwL3y9FgK+5w/pYt6g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/acorn": { + "version": "8.15.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", + "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/aria-hidden": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.6.tgz", + "integrity": "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/aria-query": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz", + "integrity": "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/array-buffer-byte-length": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.2.tgz", + "integrity": "sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "is-array-buffer": "^3.0.5" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array-includes": { + "version": "3.1.9", + "resolved": "https://registry.npmjs.org/array-includes/-/array-includes-3.1.9.tgz", + "integrity": "sha512-FmeCCAenzH0KH381SPT5FZmiA/TmpndpcaShhfgEN9eCVjnFBqq3l1xrI42y8+PPLI6hypzou4GXw00WHmPBLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.24.0", + "es-object-atoms": "^1.1.1", + "get-intrinsic": "^1.3.0", + "is-string": "^1.1.1", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.findlast": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/array.prototype.findlast/-/array.prototype.findlast-1.2.5.tgz", + "integrity": "sha512-CVvd6FHg1Z3POpBLxO6E6zr+rSKEQ9L6rZHAaY7lLfhKsWYUBBOuMs0e9o24oopj6H+geRCX0YJ+TJLBK2eHyQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.findlastindex": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/array.prototype.findlastindex/-/array.prototype.findlastindex-1.2.6.tgz", + "integrity": "sha512-F/TKATkzseUExPlfvmwQKGITM3DGTK+vkAsCZoDc5daVygbJBnjEUCbgkAvVFsgfXfX4YIqZ/27G3k3tdXrTxQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.9", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "es-shim-unscopables": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.flat": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/array.prototype.flat/-/array.prototype.flat-1.3.3.tgz", + "integrity": "sha512-rwG/ja1neyLqCuGZ5YYrznA62D4mZXg0i1cIskIUKSiqF3Cje9/wXAls9B9s1Wa2fomMsIv8czB8jZcPmxCXFg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.flatmap": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/array.prototype.flatmap/-/array.prototype.flatmap-1.3.3.tgz", + "integrity": "sha512-Y7Wt51eKJSyi80hFrJCePGGNo5ktJCslFuboqJsbf57CCPcm5zztluPlc4/aD8sWsKvlwatezpV4U1efk8kpjg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.tosorted": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/array.prototype.tosorted/-/array.prototype.tosorted-1.1.4.tgz", + "integrity": "sha512-p6Fx8B7b7ZhL/gmUsAy0D15WhvDccw3mnGNbZpi3pmeJdxtWsj2jEaI4Y6oo3XiHfzuSgPwKc04MYt6KgvC/wA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.3", + "es-errors": "^1.3.0", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/arraybuffer.prototype.slice": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/arraybuffer.prototype.slice/-/arraybuffer.prototype.slice-1.0.4.tgz", + "integrity": "sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-buffer-byte-length": "^1.0.1", + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "is-array-buffer": "^3.0.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/ast-types-flow": { + "version": "0.0.8", + "resolved": "https://registry.npmjs.org/ast-types-flow/-/ast-types-flow-0.0.8.tgz", + "integrity": "sha512-OH/2E5Fg20h2aPrbe+QL8JZQFko0YZaF+j4mnQ7BGhfavO7OpSLa8a0y9sBwomHdSbkhTS8TQNayBfnW5DwbvQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/async-function": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/async-function/-/async-function-1.0.0.tgz", + "integrity": "sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/available-typed-arrays": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", + "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "possible-typed-array-names": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/axe-core": { + "version": "4.11.1", + "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.11.1.tgz", + "integrity": "sha512-BASOg+YwO2C+346x3LZOeoovTIoTrRqEsqMa6fmfAV0P+U9mFr9NsyOEpiYvFjbc64NMrSswhV50WdXzdb/Z5A==", + "dev": true, + "license": "MPL-2.0", + "engines": { + "node": ">=4" + } + }, + "node_modules/axobject-query": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", + "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/call-bind": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz", + "integrity": "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.0", + "es-define-property": "^1.0.0", + "get-intrinsic": "^1.2.4", + "set-function-length": "^1.2.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001766", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001766.tgz", + "integrity": "sha512-4C0lfJ0/YPjJQHagaE9x2Elb69CIqEPZeG0anQt9SIvIoOH4a4uaRl73IavyO+0qZh6MDLH//DrXThEYKHkmYA==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/class-variance-authority": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz", + "integrity": "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==", + "license": "Apache-2.0", + "dependencies": { + "clsx": "^2.1.1" + }, + "funding": { + "url": "https://polar.sh/cva" + } + }, + "node_modules/client-only": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz", + "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==", + "license": "MIT" + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/damerau-levenshtein": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz", + "integrity": "sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==", + "dev": true, + "license": "BSD-2-Clause" + }, + "node_modules/data-view-buffer": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz", + "integrity": "sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/data-view-byte-length": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/data-view-byte-length/-/data-view-byte-length-1.0.2.tgz", + "integrity": "sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/inspect-js" + } + }, + "node_modules/data-view-byte-offset": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/data-view-byte-offset/-/data-view-byte-offset-1.0.1.tgz", + "integrity": "sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/define-properties": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", + "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.0.1", + "has-property-descriptors": "^1.0.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "devOptional": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/detect-node-es": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz", + "integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==", + "license": "MIT" + }, + "node_modules/doctrine": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", + "integrity": "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "esutils": "^2.0.2" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "dev": true, + "license": "MIT" + }, + "node_modules/enhanced-resolve": { + "version": "5.18.4", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.4.tgz", + "integrity": "sha512-LgQMM4WXU3QI+SYgEc2liRgznaD5ojbmY3sb8LxyguVkIg5FxdpTkvk72te2R38/TGKxH634oLxXRGY6d7AP+Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.2.0" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/es-abstract": { + "version": "1.24.1", + "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.1.tgz", + "integrity": "sha512-zHXBLhP+QehSSbsS9Pt23Gg964240DPd6QCf8WpkqEXxQ7fhdZzYsocOr5u7apWonsS5EjZDmTF+/slGMyasvw==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-buffer-byte-length": "^1.0.2", + "arraybuffer.prototype.slice": "^1.0.4", + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "data-view-buffer": "^1.0.2", + "data-view-byte-length": "^1.0.2", + "data-view-byte-offset": "^1.0.1", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "es-set-tostringtag": "^2.1.0", + "es-to-primitive": "^1.3.0", + "function.prototype.name": "^1.1.8", + "get-intrinsic": "^1.3.0", + "get-proto": "^1.0.1", + "get-symbol-description": "^1.1.0", + "globalthis": "^1.0.4", + "gopd": "^1.2.0", + "has-property-descriptors": "^1.0.2", + "has-proto": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "internal-slot": "^1.1.0", + "is-array-buffer": "^3.0.5", + "is-callable": "^1.2.7", + "is-data-view": "^1.0.2", + "is-negative-zero": "^2.0.3", + "is-regex": "^1.2.1", + "is-set": "^2.0.3", + "is-shared-array-buffer": "^1.0.4", + "is-string": "^1.1.1", + "is-typed-array": "^1.1.15", + "is-weakref": "^1.1.1", + "math-intrinsics": "^1.1.0", + "object-inspect": "^1.13.4", + "object-keys": "^1.1.1", + "object.assign": "^4.1.7", + "own-keys": "^1.0.1", + "regexp.prototype.flags": "^1.5.4", + "safe-array-concat": "^1.1.3", + "safe-push-apply": "^1.0.0", + "safe-regex-test": "^1.1.0", + "set-proto": "^1.0.0", + "stop-iteration-iterator": "^1.1.0", + "string.prototype.trim": "^1.2.10", + "string.prototype.trimend": "^1.0.9", + "string.prototype.trimstart": "^1.0.8", + "typed-array-buffer": "^1.0.3", + "typed-array-byte-length": "^1.0.3", + "typed-array-byte-offset": "^1.0.4", + "typed-array-length": "^1.0.7", + "unbox-primitive": "^1.1.0", + "which-typed-array": "^1.1.19" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-iterator-helpers": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/es-iterator-helpers/-/es-iterator-helpers-1.2.2.tgz", + "integrity": "sha512-BrUQ0cPTB/IwXj23HtwHjS9n7O4h9FX94b4xc5zlTHxeLgTAdzYUDyy6KdExAl9lbN5rtfe44xpjpmj9grxs5w==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.24.1", + "es-errors": "^1.3.0", + "es-set-tostringtag": "^2.1.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.3.0", + "globalthis": "^1.0.4", + "gopd": "^1.2.0", + "has-property-descriptors": "^1.0.2", + "has-proto": "^1.2.0", + "has-symbols": "^1.1.0", + "internal-slot": "^1.1.0", + "iterator.prototype": "^1.1.5", + "safe-array-concat": "^1.1.3" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-shim-unscopables": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/es-shim-unscopables/-/es-shim-unscopables-1.1.0.tgz", + "integrity": "sha512-d9T8ucsEhh8Bi1woXCf+TIKDIROLG5WCkxg8geBCbvk22kzwC5G2OnXVMO6FUsvQlgUUXQ2itephWDLqDzbeCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-to-primitive": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.3.0.tgz", + "integrity": "sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-callable": "^1.2.7", + "is-date-object": "^1.0.5", + "is-symbol": "^1.0.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "9.39.2", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.2.tgz", + "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.21.1", + "@eslint/config-helpers": "^0.4.2", + "@eslint/core": "^0.17.0", + "@eslint/eslintrc": "^3.3.1", + "@eslint/js": "9.39.2", + "@eslint/plugin-kit": "^0.4.1", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.12.4", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^8.4.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", + "esquery": "^1.5.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.2", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-config-next": { + "version": "15.5.9", + "resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-15.5.9.tgz", + "integrity": "sha512-852JYI3NkFNzW8CqsMhI0K2CDRxTObdZ2jQJj5CtpEaOkYHn13107tHpNuD/h0WRpU4FAbCdUaxQsrfBtNK9Kw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@next/eslint-plugin-next": "15.5.9", + "@rushstack/eslint-patch": "^1.10.3", + "@typescript-eslint/eslint-plugin": "^5.4.2 || ^6.0.0 || ^7.0.0 || ^8.0.0", + "@typescript-eslint/parser": "^5.4.2 || ^6.0.0 || ^7.0.0 || ^8.0.0", + "eslint-import-resolver-node": "^0.3.6", + "eslint-import-resolver-typescript": "^3.5.2", + "eslint-plugin-import": "^2.31.0", + "eslint-plugin-jsx-a11y": "^6.10.0", + "eslint-plugin-react": "^7.37.0", + "eslint-plugin-react-hooks": "^5.0.0" + }, + "peerDependencies": { + "eslint": "^7.23.0 || ^8.0.0 || ^9.0.0", + "typescript": ">=3.3.1" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/eslint-import-resolver-node": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.9.tgz", + "integrity": "sha512-WFj2isz22JahUv+B788TlO3N6zL3nNJGU8CcZbPZvVEkBPaJdCV4vy5wyghty5ROFbCRnm132v8BScu5/1BQ8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^3.2.7", + "is-core-module": "^2.13.0", + "resolve": "^1.22.4" + } + }, + "node_modules/eslint-import-resolver-node/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/eslint-import-resolver-typescript": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/eslint-import-resolver-typescript/-/eslint-import-resolver-typescript-3.10.1.tgz", + "integrity": "sha512-A1rHYb06zjMGAxdLSkN2fXPBwuSaQ0iO5M/hdyS0Ajj1VBaRp0sPD3dn1FhME3c/JluGFbwSxyCfqdSbtQLAHQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "@nolyfill/is-core-module": "1.0.39", + "debug": "^4.4.0", + "get-tsconfig": "^4.10.0", + "is-bun-module": "^2.0.0", + "stable-hash": "^0.0.5", + "tinyglobby": "^0.2.13", + "unrs-resolver": "^1.6.2" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint-import-resolver-typescript" + }, + "peerDependencies": { + "eslint": "*", + "eslint-plugin-import": "*", + "eslint-plugin-import-x": "*" + }, + "peerDependenciesMeta": { + "eslint-plugin-import": { + "optional": true + }, + "eslint-plugin-import-x": { + "optional": true + } + } + }, + "node_modules/eslint-module-utils": { + "version": "2.12.1", + "resolved": "https://registry.npmjs.org/eslint-module-utils/-/eslint-module-utils-2.12.1.tgz", + "integrity": "sha512-L8jSWTze7K2mTg0vos/RuLRS5soomksDPoJLXIslC7c8Wmut3bx7CPpJijDcBZtxQ5lrbUdM+s0OlNbz0DCDNw==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^3.2.7" + }, + "engines": { + "node": ">=4" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + } + } + }, + "node_modules/eslint-module-utils/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/eslint-plugin-import": { + "version": "2.32.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.32.0.tgz", + "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rtsao/scc": "^1.1.0", + "array-includes": "^3.1.9", + "array.prototype.findlastindex": "^1.2.6", + "array.prototype.flat": "^1.3.3", + "array.prototype.flatmap": "^1.3.3", + "debug": "^3.2.7", + "doctrine": "^2.1.0", + "eslint-import-resolver-node": "^0.3.9", + "eslint-module-utils": "^2.12.1", + "hasown": "^2.0.2", + "is-core-module": "^2.16.1", + "is-glob": "^4.0.3", + "minimatch": "^3.1.2", + "object.fromentries": "^2.0.8", + "object.groupby": "^1.0.3", + "object.values": "^1.2.1", + "semver": "^6.3.1", + "string.prototype.trimend": "^1.0.9", + "tsconfig-paths": "^3.15.0" + }, + "engines": { + "node": ">=4" + }, + "peerDependencies": { + "eslint": "^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8 || ^9" + } + }, + "node_modules/eslint-plugin-import/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/eslint-plugin-import/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/eslint-plugin-jsx-a11y": { + "version": "6.10.2", + "resolved": "https://registry.npmjs.org/eslint-plugin-jsx-a11y/-/eslint-plugin-jsx-a11y-6.10.2.tgz", + "integrity": "sha512-scB3nz4WmG75pV8+3eRUQOHZlNSUhFNq37xnpgRkCCELU3XMvXAxLk1eqWWyE22Ki4Q01Fnsw9BA3cJHDPgn2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "aria-query": "^5.3.2", + "array-includes": "^3.1.8", + "array.prototype.flatmap": "^1.3.2", + "ast-types-flow": "^0.0.8", + "axe-core": "^4.10.0", + "axobject-query": "^4.1.0", + "damerau-levenshtein": "^1.0.8", + "emoji-regex": "^9.2.2", + "hasown": "^2.0.2", + "jsx-ast-utils": "^3.3.5", + "language-tags": "^1.0.9", + "minimatch": "^3.1.2", + "object.fromentries": "^2.0.8", + "safe-regex-test": "^1.0.3", + "string.prototype.includes": "^2.0.1" + }, + "engines": { + "node": ">=4.0" + }, + "peerDependencies": { + "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9" + } + }, + "node_modules/eslint-plugin-react": { + "version": "7.37.5", + "resolved": "https://registry.npmjs.org/eslint-plugin-react/-/eslint-plugin-react-7.37.5.tgz", + "integrity": "sha512-Qteup0SqU15kdocexFNAJMvCJEfa2xUKNV4CC1xsVMrIIqEy3SQ/rqyxCWNzfrd3/ldy6HMlD2e0JDVpDg2qIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-includes": "^3.1.8", + "array.prototype.findlast": "^1.2.5", + "array.prototype.flatmap": "^1.3.3", + "array.prototype.tosorted": "^1.1.4", + "doctrine": "^2.1.0", + "es-iterator-helpers": "^1.2.1", + "estraverse": "^5.3.0", + "hasown": "^2.0.2", + "jsx-ast-utils": "^2.4.1 || ^3.0.0", + "minimatch": "^3.1.2", + "object.entries": "^1.1.9", + "object.fromentries": "^2.0.8", + "object.values": "^1.2.1", + "prop-types": "^15.8.1", + "resolve": "^2.0.0-next.5", + "semver": "^6.3.1", + "string.prototype.matchall": "^4.0.12", + "string.prototype.repeat": "^1.0.0" + }, + "engines": { + "node": ">=4" + }, + "peerDependencies": { + "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9.7" + } + }, + "node_modules/eslint-plugin-react-hooks": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-5.2.0.tgz", + "integrity": "sha512-+f15FfK64YQwZdJNELETdn5ibXEUQmW1DZL6KXhNnc2heoy/sg9VJJeT7n8TlMWouzWqSWavFkIhHyIbIAEapg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0" + } + }, + "node_modules/eslint-plugin-react/node_modules/resolve": { + "version": "2.0.0-next.5", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.5.tgz", + "integrity": "sha512-U7WjGVG9sH8tvjW5SmGbQuui75FiyjAX72HX15DwBBwF9dNiQZRQAg9nnPhYy+TUnE0+VcrttuvNI8oSxZcocA==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-core-module": "^2.13.0", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/eslint-plugin-react/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/eslint-scope": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-glob": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.1.tgz", + "integrity": "sha512-kNFPyjhh5cKjrUltxs+wFx+ZkbRaxxmZ+X0ZU31SOsxCEtP9VPgtq2teZw1DebupL5GmDaNQ6yKMMVcM41iqDg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.4" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fastq": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", + "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", + "dev": true, + "license": "ISC" + }, + "node_modules/for-each": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", + "integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-callable": "^1.2.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/function.prototype.name": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/function.prototype.name/-/function.prototype.name-1.1.8.tgz", + "integrity": "sha512-e5iwyodOHhbMr/yNrc7fDYG4qlbIvI5gajyzPnb5TCwyhjApznQh1BMFou9b30SevY43gCJKXycoCBjMbsuW0Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "functions-have-names": "^1.2.3", + "hasown": "^2.0.2", + "is-callable": "^1.2.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/functions-have-names": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz", + "integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/generator-function": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/generator-function/-/generator-function-2.0.1.tgz", + "integrity": "sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-nonce": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz", + "integrity": "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-symbol-description": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.1.0.tgz", + "integrity": "sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-tsconfig": { + "version": "4.13.0", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.0.tgz", + "integrity": "sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/globals": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/globalthis": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz", + "integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-properties": "^1.2.1", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/has-bigints": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz", + "integrity": "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-proto": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.2.0.tgz", + "integrity": "sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/internal-slot": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", + "integrity": "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "hasown": "^2.0.2", + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/is-array-buffer": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", + "integrity": "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-async-function": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-async-function/-/is-async-function-2.1.1.tgz", + "integrity": "sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "async-function": "^1.0.0", + "call-bound": "^1.0.3", + "get-proto": "^1.0.1", + "has-tostringtag": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-bigint": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.1.0.tgz", + "integrity": "sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-bigints": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-boolean-object": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.2.2.tgz", + "integrity": "sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-bun-module": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-bun-module/-/is-bun-module-2.0.0.tgz", + "integrity": "sha512-gNCGbnnnnFAUGKeZ9PdbyeGYJqewpmc2aKHUEMO5nQPWU9lOmv7jcmQIv+qHD8fXW6W7qfuCwX4rY9LNRjXrkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.7.1" + } + }, + "node_modules/is-callable": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", + "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-core-module": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", + "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-data-view": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-data-view/-/is-data-view-1.0.2.tgz", + "integrity": "sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "get-intrinsic": "^1.2.6", + "is-typed-array": "^1.1.13" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-date-object": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.1.0.tgz", + "integrity": "sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-finalizationregistry": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-finalizationregistry/-/is-finalizationregistry-1.1.1.tgz", + "integrity": "sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-generator-function": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz", + "integrity": "sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.4", + "generator-function": "^2.0.0", + "get-proto": "^1.0.1", + "has-tostringtag": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-map": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.3.tgz", + "integrity": "sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-negative-zero": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.3.tgz", + "integrity": "sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-number-object": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.1.1.tgz", + "integrity": "sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-regex": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", + "integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-set": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-set/-/is-set-2.0.3.tgz", + "integrity": "sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-shared-array-buffer": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.4.tgz", + "integrity": "sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-string": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.1.1.tgz", + "integrity": "sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-symbol": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.1.1.tgz", + "integrity": "sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-symbols": "^1.1.0", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-typed-array": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.15.tgz", + "integrity": "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakmap": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz", + "integrity": "sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakref": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-weakref/-/is-weakref-1.1.1.tgz", + "integrity": "sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakset": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/is-weakset/-/is-weakset-2.0.4.tgz", + "integrity": "sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/isarray": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", + "dev": true, + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/iterator.prototype": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/iterator.prototype/-/iterator.prototype-1.1.5.tgz", + "integrity": "sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.6", + "get-proto": "^1.0.0", + "has-symbols": "^1.1.0", + "set-function-name": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/jiti": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", + "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/json5": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/json5/-/json5-1.0.2.tgz", + "integrity": "sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==", + "dev": true, + "license": "MIT", + "dependencies": { + "minimist": "^1.2.0" + }, + "bin": { + "json5": "lib/cli.js" + } + }, + "node_modules/jsx-ast-utils": { + "version": "3.3.5", + "resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz", + "integrity": "sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-includes": "^3.1.6", + "array.prototype.flat": "^1.3.1", + "object.assign": "^4.1.4", + "object.values": "^1.1.6" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/language-subtag-registry": { + "version": "0.3.23", + "resolved": "https://registry.npmjs.org/language-subtag-registry/-/language-subtag-registry-0.3.23.tgz", + "integrity": "sha512-0K65Lea881pHotoGEa5gDlMxt3pctLi2RplBb7Ezh4rRdLEOtgi7n4EwK9lamnUCkKBqaeKRVebTq6BAxSkpXQ==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/language-tags": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/language-tags/-/language-tags-1.0.9.tgz", + "integrity": "sha512-MbjN408fEndfiQXbFQ1vnd+1NoLDsnQW41410oQBXiyXDMYH5z505juWa4KUE1LqxRC7DgOgZDbKLxHIwm27hA==", + "dev": true, + "license": "MIT", + "dependencies": { + "language-subtag-registry": "^0.3.20" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/lightningcss": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.2.tgz", + "integrity": "sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.30.2", + "lightningcss-darwin-arm64": "1.30.2", + "lightningcss-darwin-x64": "1.30.2", + "lightningcss-freebsd-x64": "1.30.2", + "lightningcss-linux-arm-gnueabihf": "1.30.2", + "lightningcss-linux-arm64-gnu": "1.30.2", + "lightningcss-linux-arm64-musl": "1.30.2", + "lightningcss-linux-x64-gnu": "1.30.2", + "lightningcss-linux-x64-musl": "1.30.2", + "lightningcss-win32-arm64-msvc": "1.30.2", + "lightningcss-win32-x64-msvc": "1.30.2" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.30.2.tgz", + "integrity": "sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.30.2.tgz", + "integrity": "sha512-ylTcDJBN3Hp21TdhRT5zBOIi73P6/W0qwvlFEk22fkdXchtNTOU4Qc37SkzV+EKYxLouZ6M4LG9NfZ1qkhhBWA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.30.2.tgz", + "integrity": "sha512-oBZgKchomuDYxr7ilwLcyms6BCyLn0z8J0+ZZmfpjwg9fRVZIR5/GMXd7r9RH94iDhld3UmSjBM6nXWM2TfZTQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.30.2.tgz", + "integrity": "sha512-c2bH6xTrf4BDpK8MoGG4Bd6zAMZDAXS569UxCAGcA7IKbHNMlhGQ89eRmvpIUGfKWNVdbhSbkQaWhEoMGmGslA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.30.2.tgz", + "integrity": "sha512-eVdpxh4wYcm0PofJIZVuYuLiqBIakQ9uFZmipf6LF/HRj5Bgm0eb3qL/mr1smyXIS1twwOxNWndd8z0E374hiA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.30.2.tgz", + "integrity": "sha512-UK65WJAbwIJbiBFXpxrbTNArtfuznvxAJw4Q2ZGlU8kPeDIWEX1dg3rn2veBVUylA2Ezg89ktszWbaQnxD/e3A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.30.2.tgz", + "integrity": "sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.30.2.tgz", + "integrity": "sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.30.2.tgz", + "integrity": "sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.30.2.tgz", + "integrity": "sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.30.2.tgz", + "integrity": "sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lucide-react": { + "version": "0.469.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.469.0.tgz", + "integrity": "sha512-28vvUnnKQ/dBwiCQtwJw7QauYnE7yd2Cyp4tTTJpvglX4EMpbflcdBgrgToX2j71B3YvugK/NH3BGUk+E/p/Fw==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/napi-postinstall": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/napi-postinstall/-/napi-postinstall-0.3.4.tgz", + "integrity": "sha512-PHI5f1O0EP5xJ9gQmFGMS6IZcrVvTjpXjz7Na41gTE7eE2hK11lg04CECCYEEjdc17EV4DO+fkGEtt7TpTaTiQ==", + "dev": true, + "license": "MIT", + "bin": { + "napi-postinstall": "lib/cli.js" + }, + "engines": { + "node": "^12.20.0 || ^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/napi-postinstall" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/next": { + "version": "15.5.9", + "resolved": "https://registry.npmjs.org/next/-/next-15.5.9.tgz", + "integrity": "sha512-agNLK89seZEtC5zUHwtut0+tNrc0Xw4FT/Dg+B/VLEo9pAcS9rtTKpek3V6kVcVwsB2YlqMaHdfZL4eLEVYuCg==", + "license": "MIT", + "dependencies": { + "@next/env": "15.5.9", + "@swc/helpers": "0.5.15", + "caniuse-lite": "^1.0.30001579", + "postcss": "8.4.31", + "styled-jsx": "5.1.6" + }, + "bin": { + "next": "dist/bin/next" + }, + "engines": { + "node": "^18.18.0 || ^19.8.0 || >= 20.0.0" + }, + "optionalDependencies": { + "@next/swc-darwin-arm64": "15.5.7", + "@next/swc-darwin-x64": "15.5.7", + "@next/swc-linux-arm64-gnu": "15.5.7", + "@next/swc-linux-arm64-musl": "15.5.7", + "@next/swc-linux-x64-gnu": "15.5.7", + "@next/swc-linux-x64-musl": "15.5.7", + "@next/swc-win32-arm64-msvc": "15.5.7", + "@next/swc-win32-x64-msvc": "15.5.7", + "sharp": "^0.34.3" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.1.0", + "@playwright/test": "^1.51.1", + "babel-plugin-react-compiler": "*", + "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", + "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", + "sass": "^1.3.0" + }, + "peerDependenciesMeta": { + "@opentelemetry/api": { + "optional": true + }, + "@playwright/test": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + }, + "sass": { + "optional": true + } + } + }, + "node_modules/next/node_modules/postcss": { + "version": "8.4.31", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz", + "integrity": "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.6", + "picocolors": "^1.0.0", + "source-map-js": "^1.0.2" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.assign": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.7.tgz", + "integrity": "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0", + "has-symbols": "^1.1.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object.entries": { + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/object.entries/-/object.entries-1.1.9.tgz", + "integrity": "sha512-8u/hfXFRBD1O0hPUjioLhoWFHRmt6tKA4/vZPyckBr18l1KE9uHrFaFaUi8MDRTpi4uak2goyPTSNJLXX2k2Hw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.fromentries": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/object.fromentries/-/object.fromentries-2.0.8.tgz", + "integrity": "sha512-k6E21FzySsSK5a21KRADBd/NGneRegFO5pLHfdQLpRDETUNJueLXs3WCzyQ3tFRDYgbq3KHGXfTbi2bs8WQ6rQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object.groupby": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/object.groupby/-/object.groupby-1.0.3.tgz", + "integrity": "sha512-+Lhy3TQTuzXI5hevh8sBGqbmurHbbIjAi0Z4S63nthVLmLxfbj4T54a4CfZrXIrt9iP4mVAPYMo/v99taj3wjQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.values": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/object.values/-/object.values-1.2.1.tgz", + "integrity": "sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/own-keys": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.1.tgz", + "integrity": "sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-intrinsic": "^1.2.6", + "object-keys": "^1.1.1", + "safe-push-apply": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/possible-typed-array-names": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", + "integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/postcss": { + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/prop-types": { + "version": "15.8.1", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", + "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", + "dev": true, + "license": "MIT", + "dependencies": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.13.1" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/react": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz", + "integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.3" + } + }, + "node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/react-remove-scroll": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz", + "integrity": "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q==", + "license": "MIT", + "dependencies": { + "react-remove-scroll-bar": "^2.3.7", + "react-style-singleton": "^2.2.3", + "tslib": "^2.1.0", + "use-callback-ref": "^1.3.3", + "use-sidecar": "^1.1.3" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-remove-scroll-bar": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/react-remove-scroll-bar/-/react-remove-scroll-bar-2.3.8.tgz", + "integrity": "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==", + "license": "MIT", + "dependencies": { + "react-style-singleton": "^2.2.2", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-style-singleton": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz", + "integrity": "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==", + "license": "MIT", + "dependencies": { + "get-nonce": "^1.0.0", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/reflect.getprototypeof": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", + "integrity": "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.9", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.7", + "get-proto": "^1.0.1", + "which-builtin-type": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/regexp.prototype.flags": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz", + "integrity": "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-errors": "^1.3.0", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "set-function-name": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/resolve": { + "version": "1.22.11", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", + "integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/safe-array-concat": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.3.tgz", + "integrity": "sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.2", + "get-intrinsic": "^1.2.6", + "has-symbols": "^1.1.0", + "isarray": "^2.0.5" + }, + "engines": { + "node": ">=0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safe-push-apply": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz", + "integrity": "sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "isarray": "^2.0.5" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safe-regex-test": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz", + "integrity": "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-regex": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.7.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "devOptional": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/set-function-length": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", + "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/set-function-name": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/set-function-name/-/set-function-name-2.0.2.tgz", + "integrity": "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "functions-have-names": "^1.2.3", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/set-proto": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/set-proto/-/set-proto-1.0.0.tgz", + "integrity": "sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/sharp": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz", + "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==", + "hasInstallScript": true, + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@img/colour": "^1.0.0", + "detect-libc": "^2.1.2", + "semver": "^7.7.3" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.34.5", + "@img/sharp-darwin-x64": "0.34.5", + "@img/sharp-libvips-darwin-arm64": "1.2.4", + "@img/sharp-libvips-darwin-x64": "1.2.4", + "@img/sharp-libvips-linux-arm": "1.2.4", + "@img/sharp-libvips-linux-arm64": "1.2.4", + "@img/sharp-libvips-linux-ppc64": "1.2.4", + "@img/sharp-libvips-linux-riscv64": "1.2.4", + "@img/sharp-libvips-linux-s390x": "1.2.4", + "@img/sharp-libvips-linux-x64": "1.2.4", + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", + "@img/sharp-libvips-linuxmusl-x64": "1.2.4", + "@img/sharp-linux-arm": "0.34.5", + "@img/sharp-linux-arm64": "0.34.5", + "@img/sharp-linux-ppc64": "0.34.5", + "@img/sharp-linux-riscv64": "0.34.5", + "@img/sharp-linux-s390x": "0.34.5", + "@img/sharp-linux-x64": "0.34.5", + "@img/sharp-linuxmusl-arm64": "0.34.5", + "@img/sharp-linuxmusl-x64": "0.34.5", + "@img/sharp-wasm32": "0.34.5", + "@img/sharp-win32-arm64": "0.34.5", + "@img/sharp-win32-ia32": "0.34.5", + "@img/sharp-win32-x64": "0.34.5" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stable-hash": { + "version": "0.0.5", + "resolved": "https://registry.npmjs.org/stable-hash/-/stable-hash-0.0.5.tgz", + "integrity": "sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA==", + "dev": true, + "license": "MIT" + }, + "node_modules/stop-iteration-iterator": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", + "integrity": "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "internal-slot": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/string.prototype.includes": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/string.prototype.includes/-/string.prototype.includes-2.0.1.tgz", + "integrity": "sha512-o7+c9bW6zpAdJHTtujeePODAhkuicdAryFsfVKwA+wGw89wJ4GTY484WTucM9hLtDEOpOvI+aHnzqnC5lHp4Rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.3" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/string.prototype.matchall": { + "version": "4.0.12", + "resolved": "https://registry.npmjs.org/string.prototype.matchall/-/string.prototype.matchall-4.0.12.tgz", + "integrity": "sha512-6CC9uyBL+/48dYizRf7H7VAYCMCNTBeM78x/VTUe9bFEaxBepPJDa1Ow99LqI/1yF7kuy7Q3cQsYMrcjGUcskA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.6", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.6", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "internal-slot": "^1.1.0", + "regexp.prototype.flags": "^1.5.3", + "set-function-name": "^2.0.2", + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.repeat": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/string.prototype.repeat/-/string.prototype.repeat-1.0.0.tgz", + "integrity": "sha512-0u/TldDbKD8bFCQ/4f5+mNRrXwZ8hg2w7ZR8wa16e8z9XpePWl3eGEcUD0OXpEH/VJH/2G3gjUtR3ZOiBe2S/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-properties": "^1.1.3", + "es-abstract": "^1.17.5" + } + }, + "node_modules/string.prototype.trim": { + "version": "1.2.10", + "resolved": "https://registry.npmjs.org/string.prototype.trim/-/string.prototype.trim-1.2.10.tgz", + "integrity": "sha512-Rs66F0P/1kedk5lyYyH9uBzuiI/kNRmwJAR9quK6VOtIpZ2G+hMZd+HQbbv25MgCA6gEffoMZYxlTod4WcdrKA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.2", + "define-data-property": "^1.1.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-object-atoms": "^1.0.0", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trimend": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.9.tgz", + "integrity": "sha512-G7Ok5C6E/j4SGfyLCloXTrngQIQU3PWtXGst3yM7Bea9FRURf1S42ZHlZZtsNque2FN2PoUhfZXYLNWwEr4dLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.2", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trimstart": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.8.tgz", + "integrity": "sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/strip-bom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", + "integrity": "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/styled-jsx": { + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz", + "integrity": "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==", + "license": "MIT", + "dependencies": { + "client-only": "0.0.1" + }, + "engines": { + "node": ">= 12.0.0" + }, + "peerDependencies": { + "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0" + }, + "peerDependenciesMeta": { + "@babel/core": { + "optional": true + }, + "babel-plugin-macros": { + "optional": true + } + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/tailwind-merge": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-2.6.0.tgz", + "integrity": "sha512-P+Vu1qXfzediirmHOC3xKGAYeZtPcV9g76X+xg2FD4tYgR71ewMA35Y3sCz3zhiN/dwefRpJX0yBcgwi1fXNQA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/dcastil" + } + }, + "node_modules/tailwindcss": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.18.tgz", + "integrity": "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw==", + "license": "MIT" + }, + "node_modules/tailwindcss-animate": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/tailwindcss-animate/-/tailwindcss-animate-1.0.7.tgz", + "integrity": "sha512-bl6mpH3T7I3UFxuvDEXLxy/VuFxBk5bbzplh7tXI68mwMokNYd1t9qPBHlnyTwfa4JGC4zP516I1hYYtQ/vspA==", + "license": "MIT", + "peerDependencies": { + "tailwindcss": ">=3.0.0 || insiders" + } + }, + "node_modules/tapable": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz", + "integrity": "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyglobby/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/ts-api-utils": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.4.0.tgz", + "integrity": "sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/tsconfig-paths": { + "version": "3.15.0", + "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.15.0.tgz", + "integrity": "sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/json5": "^0.0.29", + "json5": "^1.0.2", + "minimist": "^1.2.6", + "strip-bom": "^3.0.0" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/typed-array-buffer": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz", + "integrity": "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-typed-array": "^1.1.14" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/typed-array-byte-length": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typed-array-byte-length/-/typed-array-byte-length-1.0.3.tgz", + "integrity": "sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "for-each": "^0.3.3", + "gopd": "^1.2.0", + "has-proto": "^1.2.0", + "is-typed-array": "^1.1.14" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typed-array-byte-offset": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/typed-array-byte-offset/-/typed-array-byte-offset-1.0.4.tgz", + "integrity": "sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "for-each": "^0.3.3", + "gopd": "^1.2.0", + "has-proto": "^1.2.0", + "is-typed-array": "^1.1.15", + "reflect.getprototypeof": "^1.0.9" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typed-array-length": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/typed-array-length/-/typed-array-length-1.0.7.tgz", + "integrity": "sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "for-each": "^0.3.3", + "gopd": "^1.0.1", + "is-typed-array": "^1.1.13", + "possible-typed-array-names": "^1.0.0", + "reflect.getprototypeof": "^1.0.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/unbox-primitive": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz", + "integrity": "sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-bigints": "^1.0.2", + "has-symbols": "^1.1.0", + "which-boxed-primitive": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/unrs-resolver": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/unrs-resolver/-/unrs-resolver-1.11.1.tgz", + "integrity": "sha512-bSjt9pjaEBnNiGgc9rUiHGKv5l4/TGzDmYw3RhnkJGtLhbnnA/5qJj7x3dNDCRx/PJxu774LlH8lCOlB4hEfKg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "napi-postinstall": "^0.3.0" + }, + "funding": { + "url": "https://opencollective.com/unrs-resolver" + }, + "optionalDependencies": { + "@unrs/resolver-binding-android-arm-eabi": "1.11.1", + "@unrs/resolver-binding-android-arm64": "1.11.1", + "@unrs/resolver-binding-darwin-arm64": "1.11.1", + "@unrs/resolver-binding-darwin-x64": "1.11.1", + "@unrs/resolver-binding-freebsd-x64": "1.11.1", + "@unrs/resolver-binding-linux-arm-gnueabihf": "1.11.1", + "@unrs/resolver-binding-linux-arm-musleabihf": "1.11.1", + "@unrs/resolver-binding-linux-arm64-gnu": "1.11.1", + "@unrs/resolver-binding-linux-arm64-musl": "1.11.1", + "@unrs/resolver-binding-linux-ppc64-gnu": "1.11.1", + "@unrs/resolver-binding-linux-riscv64-gnu": "1.11.1", + "@unrs/resolver-binding-linux-riscv64-musl": "1.11.1", + "@unrs/resolver-binding-linux-s390x-gnu": "1.11.1", + "@unrs/resolver-binding-linux-x64-gnu": "1.11.1", + "@unrs/resolver-binding-linux-x64-musl": "1.11.1", + "@unrs/resolver-binding-wasm32-wasi": "1.11.1", + "@unrs/resolver-binding-win32-arm64-msvc": "1.11.1", + "@unrs/resolver-binding-win32-ia32-msvc": "1.11.1", + "@unrs/resolver-binding-win32-x64-msvc": "1.11.1" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/use-callback-ref": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.3.tgz", + "integrity": "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/use-sidecar": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.3.tgz", + "integrity": "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==", + "license": "MIT", + "dependencies": { + "detect-node-es": "^1.1.0", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/which-boxed-primitive": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.1.1.tgz", + "integrity": "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-bigint": "^1.1.0", + "is-boolean-object": "^1.2.1", + "is-number-object": "^1.1.1", + "is-string": "^1.1.1", + "is-symbol": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-builtin-type": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/which-builtin-type/-/which-builtin-type-1.2.1.tgz", + "integrity": "sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "function.prototype.name": "^1.1.6", + "has-tostringtag": "^1.0.2", + "is-async-function": "^2.0.0", + "is-date-object": "^1.1.0", + "is-finalizationregistry": "^1.1.0", + "is-generator-function": "^1.0.10", + "is-regex": "^1.2.1", + "is-weakref": "^1.0.2", + "isarray": "^2.0.5", + "which-boxed-primitive": "^1.1.0", + "which-collection": "^1.0.2", + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-collection": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/which-collection/-/which-collection-1.0.2.tgz", + "integrity": "sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-map": "^2.0.3", + "is-set": "^2.0.3", + "is-weakmap": "^2.0.2", + "is-weakset": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-typed-array": { + "version": "1.1.20", + "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.20.tgz", + "integrity": "sha512-LYfpUkmqwl0h9A2HL09Mms427Q1RZWuOHsukfVcKRq9q95iQxdw0ix1JQrqbcDR9PH1QDwf5Qo8OZb5lksZ8Xg==", + "dev": true, + "license": "MIT", + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "for-each": "^0.3.5", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + } + } +} diff --git a/audio-studio-ui/package.json b/audio-studio-ui/package.json new file mode 100644 index 0000000..7742cc5 --- /dev/null +++ b/audio-studio-ui/package.json @@ -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" + } +} diff --git a/audio-studio-ui/postcss.config.mjs b/audio-studio-ui/postcss.config.mjs new file mode 100644 index 0000000..c2ddf74 --- /dev/null +++ b/audio-studio-ui/postcss.config.mjs @@ -0,0 +1,5 @@ +export default { + plugins: { + "@tailwindcss/postcss": {}, + }, +}; diff --git a/audio-studio-ui/public/.gitkeep b/audio-studio-ui/public/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/audio-studio-ui/src/app/drama/page.tsx b/audio-studio-ui/src/app/drama/page.tsx new file mode 100644 index 0000000..170f16a --- /dev/null +++ b/audio-studio-ui/src/app/drama/page.tsx @@ -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(null); + const [parseError, setParseError] = useState(null); + const [projects, setProjects] = useState([]); + const [selectedProject, setSelectedProject] = useState(null); + const [voices, setVoices] = useState([]); + const [voiceMapping, setVoiceMapping] = useState>({}); + const [loading, setLoading] = useState(false); + const [renderStatus, setRenderStatus] = useState(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 = {}; + 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 = {}; + fullProject.characters.forEach((char) => { + mapping[char.name] = char.voice_id || ""; + }); + setVoiceMapping(mapping); + } catch (e) { + console.error("프로젝트 상세 로드 실패:", e); + } + } + + return ( +
+ {/* 헤더 */} +
+

드라마 스튜디오

+

+ 스크립트를 입력하면 AI가 라디오 드라마를 생성합니다 +

+
+ +
+ {/* 스크립트 에디터 */} +
+
+ 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" + /> + +
+ +
+ +