commit ddd51da26e7458fca7c3b0307b534e7d09711f15 Author: root Date: Mon Apr 20 06:15:35 2026 +0900 feat: VoiceScript STT+OCR 초기 버전 diff --git a/README.md b/README.md new file mode 100644 index 0000000..a6e724c --- /dev/null +++ b/README.md @@ -0,0 +1,601 @@ +# VoiceScript — 음성 변환(STT) + 이미지 인식(OCR) 통합 툴 + +> **Debian OS + Docker Compose** 기반 자체 호스팅 서비스 +> faster-whisper(STT) + PaddleOCR 3.x / Ollama Vision(OCR) 듀얼 백엔드 + +--- + +## 목차 + +1. [기능 개요](#기능-개요) +2. [프로젝트 구조](#프로젝트-구조) +3. [시스템 요구사항](#시스템-요구사항) +4. [설치 전 필수 확인사항 ⚠️](#설치-전-필수-확인사항) +5. [환경 변수 설정](#환경-변수-설정) +6. [빌드 및 실행](#빌드-및-실행) +7. [Nginx 연동 (SSL)](#nginx-연동-ssl) +8. [Ollama 모델 준비](#ollama-모델-준비) +9. [운영 관리](#운영-관리) +10. [트러블슈팅 (알려진 이슈)](#트러블슈팅-알려진-이슈) +11. [API 엔드포인트](#api-엔드포인트) + +--- + +## 기능 개요 + +### 🎙 STT — 음성 텍스트 변환 +- **엔진**: [faster-whisper](https://github.com/SYSTRAN/faster-whisper) (OpenAI Whisper 최적화 포크) +- 지원 형식: `mp3` `wav` `m4a` `ogg` `flac` `aac` `mp4` `webm` `mkv` 등 +- VAD(무음 구간 자동 제거) 적용 +- 타임스탬프 세그먼트 분리 출력 +- TXT 파일 다운로드 + +### 🔍 OCR — 이미지 텍스트 인식 +- 지원 형식: `jpg` `png` `bmp` `tiff` `webp` `gif` +- **PaddleOCR 모드**: 로컬 실행, 표 구조 분석(PP-Structure), Excel 다운로드 +- **Ollama Vision 모드**: 기존 Ollama 서버 활용, 자연어 지시 가능, 커스텀 프롬프트 + +### 🔐 인증 +- JWT 기반 로그인 (만료 시간 설정 가능) +- 모든 API 토큰 인증 필수 + +--- + +## 프로젝트 구조 + +``` +whisper-stt/ +│ +├── docker-compose.yml # 전체 서비스 정의 +│ +├── app/ +│ ├── Dockerfile # Python 3.11-slim + ffmpeg + PaddlePaddle +│ ├── requirements.txt # Python 패키지 목록 +│ │ +│ ├── main.py # FastAPI 앱 (인증 + STT + OCR 엔드포인트) +│ ├── auth.py # JWT 인증 모듈 +│ ├── tasks.py # Celery STT 태스크 (faster-whisper) +│ ├── ocr_tasks.py # Celery OCR 태스크 (PaddleOCR / Ollama) +│ │ +│ └── static/ +│ └── index.html # 웹 프론트엔드 (로그인 + STT + OCR 탭) +│ +└── nginx/ # 참고용 (호스트 Nginx 사용 시 불필요) + ├── Dockerfile + └── nginx.conf +``` + +### 컨테이너 구성 + +``` +┌─────────────────────────────────────────────────┐ +│ 호스트 Nginx (SSL/certbot) │ +│ → 리버스 프록시 → 127.0.0.1:8800 │ +└─────────────────────────────────────────────────┘ + │ + ▼ +┌──────────────────┐ ┌──────────────────┐ +│ whisper_app │ │ whisper_worker │ +│ FastAPI:8000 │ │ Celery Worker │ +│ (포트 8800) │ │ STT + OCR 처리 │ +└────────┬─────────┘ └────────┬─────────┘ + │ │ + └──────────┬────────────┘ + ▼ + ┌──────────────────┐ + │ whisper_redis │ + │ Redis:6379 │ + │ (작업 큐/결과) │ + └──────────────────┘ +``` + +--- + +## 시스템 요구사항 + +| 항목 | 최소 | 권장 | +|------|------|------| +| CPU | 4코어 이상 | AMD 5825u 이상 | +| RAM | 8GB | 16GB (medium 모델 기준) | +| 디스크 | 20GB | 50GB 이상 | +| OS | Debian 11+ | Debian 12 (Bookworm) | +| Docker | 24.0+ | 최신 | +| Docker Compose | v2.0+ | 최신 | + +### 의존 서비스 +- **Ollama** (OCR Vision 모드 사용 시): 호스트에서 `11434` 포트로 실행 중이어야 함 + +--- + +## 설치 전 필수 확인사항 + +> ⚠️ 이 섹션을 건너뛰면 빌드 후 오류가 발생할 수 있습니다. + +### 1. 호스트 IP 확인 — OLLAMA_URL 설정 + +Docker 컨테이너에서 호스트의 Ollama에 접근할 때 `host.docker.internal`은 +**Linux에서 동작하지 않을 수 있습니다.** 반드시 실제 LAN IP를 사용하세요. + +```bash +# 호스트 IP 확인 +ip addr show | grep "inet " | grep -v 127.0.0.1 +``` + +`docker-compose.yml`에서 아래 두 곳을 실제 IP로 변경: +```yaml +- OLLAMA_URL=http://실제호스트IP:11434 +``` + +### 2. 인증 정보 변경 — 기본값 절대 사용 금지 + +```yaml +# docker-compose.yml — app, worker 두 곳 모두 변경 +- AUTH_USERNAME=원하는아이디 +- AUTH_PASSWORD=강력한비밀번호 +- JWT_SECRET=랜덤한긴문자열 # openssl rand -hex 32 로 생성 권장 +``` + +JWT 시크릿 생성: +```bash +openssl rand -hex 32 +``` + +### 3. 포트 충돌 확인 + +기본 포트 `8800`이 사용 중인지 확인: +```bash +ss -tlnp | grep 8800 +``` + +충돌 시 `docker-compose.yml`에서 변경: +```yaml +ports: + - "원하는포트:8000" +``` + +### 4. 디스크 용량 확인 + +첫 빌드 시 자동 다운로드 크기: + +| 모델 | 크기 | 비고 | +|------|------|------| +| Whisper medium | ~1.5GB | HuggingFace 자동 다운로드 | +| PaddleOCR korean | ~200MB | 첫 실행 시 자동 다운로드 | +| PaddlePaddle 3.0.0 | ~300MB | 빌드 시 pip install | + +```bash +df -h / +``` + +여유 공간 **5GB 이상** 권장. + +### 5. Ollama 서버 실행 확인 + +```bash +curl http://localhost:11434/api/tags +``` + +응답이 없으면 Ollama가 실행 중이지 않은 것입니다. + +### 6. Redis 포트 충돌 확인 + +이 프로젝트의 Redis(`whisper_redis`)는 **내부 네트워크 전용**이라 호스트에 포트를 노출하지 않습니다. 별도 포트 충돌 없음. + +--- + +## 환경 변수 설정 + +`docker-compose.yml`에서 관리. `app`과 `worker` 두 서비스에 **동일하게** 설정해야 합니다. + +### 인증 + +| 변수 | 기본값 | 설명 | +|------|--------|------| +| `AUTH_USERNAME` | `admin` | 로그인 아이디 | +| `AUTH_PASSWORD` | `changeme1234` | 로그인 비밀번호 | +| `JWT_SECRET` | *(변경 필수)* | JWT 서명 키 | +| `JWT_EXPIRE_HOURS` | `12` | 토큰 유효 시간(시간) | + +### Whisper STT + +| 변수 | 기본값 | 설명 | +|------|--------|------| +| `WHISPER_MODEL` | `medium` | `tiny` `base` `small` `medium` `large-v3` | +| `WHISPER_DEVICE` | `cpu` | GPU 없는 경우 `cpu` | +| `WHISPER_COMPUTE_TYPE` | `int8` | CPU 최적화: `int8` 권장 | +| `WHISPER_LANGUAGE` | `ko` | 언어 고정. 비우면 자동 감지 | +| `WHISPER_BEAM_SIZE` | `5` | 정확도↑ vs 속도↓ | +| `WHISPER_INITIAL_PROMPT` | *(비어있음)* | 도메인 힌트. 예: `"고객 상담 녹취록입니다."` | + +**모델별 성능 (5825u CPU 기준)** + +| 모델 | 크기 | 1분 변환 | 한국어 정확도 | +|------|------|---------|--------------| +| tiny | 75MB | ~5초 | 보통 | +| base | 145MB | ~10초 | 보통 | +| small | 484MB | ~30초 | 양호 | +| **medium** | **1.5GB** | **~90초** | **우수** ← 권장 | +| large-v3 | 3GB | ~5분+ | 최고 | + +### PaddleOCR + +| 변수 | 기본값 | 설명 | +|------|--------|------| +| `OCR_LANG` | `korean` | `korean` `en` `japan` `chinese_cht` `ch` | + +### Ollama OCR + +| 변수 | 기본값 | 설명 | +|------|--------|------| +| `OLLAMA_URL` | `http://192.168.0.126:11434` | **호스트 실제 IP로 변경 필수** | +| `OLLAMA_TIMEOUT` | `180` | 초 단위. 11b 이상 모델은 `300` 이상 권장 | + +### 파일 관리 + +| 변수 | 기본값 | 설명 | +|------|--------|------| +| `MAX_UPLOAD_MB` | `500` | 업로드 최대 파일 크기 (MB) | +| `OUTPUT_KEEP_HOURS` | `48` | 결과 파일 보관 시간. `0`=무제한 | + +--- + +## 빌드 및 실행 + +```bash +# 1. 저장소 클론 +git clone http://gitea.byunc.com/[사용자명]/whisper-stt.git +cd whisper-stt + +# 2. docker-compose.yml 필수 설정 변경 +# - AUTH_USERNAME, AUTH_PASSWORD, JWT_SECRET +# - OLLAMA_URL (호스트 실제 IP) + +# 3. 빌드 및 시작 +docker compose up -d --build + +# 4. 진행 상황 확인 (Whisper 모델 다운로드 완료까지 대기) +docker compose logs -f worker +``` + +`[Whisper] 로드 완료` 및 `celery@... ready.` 메시지 확인 후 접속: +``` +http://서버IP:8800 +``` + +--- + +## Nginx 연동 (SSL) + +호스트 Nginx + certbot으로 SSL을 운용 중인 경우: + +### Nginx 설정 (`/etc/nginx/sites-available/voicescript.conf`) + +```nginx +server { + listen 443 ssl; + server_name stt.yourdomain.com; + + ssl_certificate /etc/letsencrypt/live/stt.yourdomain.com/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/stt.yourdomain.com/privkey.pem; + + # 대용량 음성 파일 업로드 허용 + client_max_body_size 500M; + client_body_timeout 300s; + proxy_read_timeout 600s; + proxy_send_timeout 600s; + + location / { + proxy_pass http://127.0.0.1:8800; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } +} + +server { + listen 80; + server_name stt.yourdomain.com; + return 301 https://$host$request_uri; +} +``` + +```bash +# SSL 인증서 발급 +sudo certbot --nginx -d stt.yourdomain.com + +# 설정 적용 +sudo nginx -t && sudo systemctl reload nginx +``` + +> **주의**: Nginx에서 `client_max_body_size 500M`을 반드시 설정하세요. +> 기본값(1MB)이면 음성 파일 업로드가 413 에러로 실패합니다. + +--- + +## Ollama 모델 준비 + +### 권장 OCR 모델 (호스트에서 미리 pull) + +```bash +# 문서/표 특화 — 2GB, 가장 균형 잡힌 선택 +ollama pull granite3.2-vision + +# OCR 전용 경량 모델 — 2GB +ollama pull deepseek-ocr:3b + +# 범용 고정확도 — 8GB (RAM 16GB+ 필요) +ollama pull llama3.2-vision:11b + +# 최고 정확도 — 9GB (RAM 16GB+ 필요) +ollama pull richardyoung/olmocr2:7b-q8 +``` + +> `granite3.2-vision`이 설치되어 있으면 즉시 사용 가능합니다. + +--- + +## 운영 관리 + +### 기본 명령어 + +```bash +# 상태 확인 +docker compose ps + +# 로그 확인 +docker compose logs app --tail=30 +docker compose logs worker --tail=30 +docker compose logs -f # 전체 실시간 + +# 재시작 +docker compose restart + +# 중지 +docker compose down + +# 설정 변경 후 재시작 (재빌드 없이) +docker compose up -d --force-recreate app worker +``` + +### Docker 이미지 정리 + +빌드를 여러 번 반복하면 dangling 이미지가 누적됩니다. + +```bash +# 사용량 확인 +docker system df + +# 불필요한 이미지/컨테이너 정리 (볼륨 제외) +docker system prune -f + +# 특정 이미지만 삭제 +docker image ls | grep whisper +docker image rm [IMAGE_ID] +``` + +### 볼륨 관리 + +| 볼륨 | 내용 | 삭제 시 영향 | +|------|------|-------------| +| `whisper_models` | Whisper 모델 (~1.5GB) | 재다운로드 필요 | +| `paddle_models` | PaddleOCR 모델 (~700MB) | 재다운로드 필요 | +| `stt_data` | 업로드/결과 파일 | 데이터 손실 | +| `redis_data` | 작업 큐 상태 | 진행 중 작업 손실 | + +```bash +# 모델 캐시 포함 완전 초기화 (주의!) +docker compose down -v +``` + +### 결과 파일 수동 정리 + +```bash +# API로 정리 (OUTPUT_KEEP_HOURS 기준) +curl -X POST http://localhost:8800/api/cleanup \ + -H "Authorization: Bearer [토큰]" +``` + +--- + +## 트러블슈팅 (알려진 이슈) + +실제 배포 과정에서 겪은 오류들과 해결 방법입니다. + +--- + +### ❌ `No matching distribution found for paddlepaddle==2.6.1` + +**원인**: 미러에서 해당 버전 제거됨 +**해결**: `Dockerfile`에서 버전을 `3.0.0`으로 변경 + +```dockerfile +RUN pip install --no-cache-dir paddlepaddle==3.0.0 \ + -i https://pypi.tuna.tsinghua.edu.cn/simple +``` + +--- + +### ❌ `ValueError: password cannot be longer than 72 bytes` (bcrypt) + +**원인**: `passlib[bcrypt]` 초기화 시 버그 +**해결**: `auth.py`에서 bcrypt 완전 제거, 직접 문자열 비교로 대체 +`requirements.txt`에서 `passlib` 줄 삭제 + +--- + +### ❌ `AttributeError: 'DisabledBackend' object has no attribute '_get_task_meta_for'` + +**원인**: `main.py`에서 `from celery.result import AsyncResult` 사용 시 백엔드 설정이 누락됨 +**해결**: `celery_app.AsyncResult(task_id)` 방식으로 변경 + +```python +# main.py +from tasks import celery_app +r = celery_app.AsyncResult(task_id) # ✅ +# AsyncResult(task_id) # ❌ +``` + +--- + +### ❌ `ModuleNotFoundError: No module named 'ocr_tasks'` + +**원인**: `tasks.py`의 `celery_app.autodiscover_tasks(["ocr_tasks"])` 동작 안 함 +**해결**: `autodiscover_tasks` 제거, 직접 import로 변경 + +```python +# tasks.py 상단에 추가 +from ocr_tasks import ocr_task # noqa: F401 +``` + +--- + +### ❌ `KeyError: 'tasks.ocr_task'` + +**원인**: worker에 ocr_task가 등록되지 않음 (위와 동일 원인) +**해결**: 위와 동일 (`from ocr_tasks import ocr_task`) + +--- + +### ❌ `Unknown argument: use_gpu` / `Unknown argument: show_log` + +**원인**: PaddleOCR 3.x에서 파라미터 제거됨 +**해결**: `ocr_tasks.py`에서 해당 파라미터 삭제 + +```python +# ✅ PaddleOCR 3.x +_ocr_engine = PaddleOCR(use_angle_cls=True, lang=OCR_LANG) + +# ❌ 구버전 방식 +_ocr_engine = PaddleOCR(use_angle_cls=True, lang=OCR_LANG, use_gpu=False, show_log=False) +``` + +--- + +### ❌ `PaddleOCR.predict() got an unexpected keyword argument 'cls'` + +**원인**: PaddleOCR 3.x에서 API 변경 +**해결**: `ocr(img, cls=True)` → `ocr(img)` + +```python +result = get_ocr().ocr(img) # ✅ PaddleOCR 3.x +``` + +--- + +### ❌ `'paddle.base.libpaddle.AnalysisConfig' object has no attribute 'set_optimization_level'` + +**원인**: PaddleOCR 3.x와 paddlepaddle 2.x 버전 불일치 +**해결**: `Dockerfile`에서 paddlepaddle `3.0.0`으로 업그레이드 + +--- + +### ❌ `too many values to unpack (expected 2)` + +**원인**: PaddleOCR 3.x 결과 구조 변경 (`[bbox, (text, conf)]` → dict 형태) +**해결**: `ocr_tasks.py`의 결과 파싱 로직 변경 + +```python +# ✅ PaddleOCR 3.x +r = result[0] +texts = r.get("rec_texts", []) +scores = r.get("rec_scores", []) + +# ❌ 구버전 +for bbox, (text, conf) in result[0]: + ... +``` + +--- + +### ❌ `MISCONF Redis is configured to save RDB snapshots...` + +**원인**: 디스크 용량 부족 또는 권한 문제로 Redis RDB 저장 실패 → 쓰기 차단 +**해결**: `docker-compose.yml` Redis에 옵션 추가 (Celery 브로커 용도는 영속성 불필요) + +```yaml +redis: + command: redis-server --stop-writes-on-bgsave-error no +``` + +--- + +### ❌ Ollama 연결 타임아웃 (`ConnectError`) + +**원인**: `host.docker.internal`이 Linux에서 동작하지 않음 +**해결**: `OLLAMA_URL`을 호스트의 실제 LAN IP로 변경 + +```yaml +- OLLAMA_URL=http://192.168.x.x:11434 # ✅ 실제 IP +# - OLLAMA_URL=http://host.docker.internal:11434 # ❌ Linux에서 불안정 +``` + +--- + +### ❌ STT 5%에서 멈춤 (진행 없음) + +**원인**: Whisper 모델 첫 다운로드 중 (HuggingFace에서 ~1.5GB) +**해결**: 기다리면 됩니다. worker 로그로 진행 확인: + +```bash +docker compose logs -f worker +``` + +`[Whisper] 로드 완료` 메시지 나올 때까지 대기 (네트워크 속도에 따라 5~20분). + +--- + +## API 엔드포인트 + +### 인증 + +| 메서드 | 경로 | 설명 | +|--------|------|------| +| `POST` | `/api/login` | 로그인 (form: `username`, `password`) | +| `GET` | `/api/me` | 현재 사용자 확인 | + +### STT + +| 메서드 | 경로 | 설명 | +|--------|------|------| +| `POST` | `/api/transcribe` | 음성 파일 업로드 및 변환 시작 | +| `GET` | `/api/status/{task_id}` | 작업 진행 상태 조회 | +| `GET` | `/api/download/{filename}` | 결과 파일 다운로드 | + +### OCR + +| 메서드 | 경로 | 설명 | +|--------|------|------| +| `POST` | `/api/ocr` | 이미지 업로드 및 인식 시작 | + +**OCR POST 파라미터** + +| 파라미터 | 기본값 | 설명 | +|---------|--------|------| +| `file` | — | 이미지 파일 | +| `mode` | `text` | `text` \| `structure` (표 분석) | +| `backend` | `paddle` | `paddle` \| `ollama` | +| `ollama_model` | `granite3.2-vision` | Ollama 사용 시 모델명 | +| `custom_prompt` | *(비어있음)* | Ollama 사용 시 커스텀 프롬프트 | + +### 관리 + +| 메서드 | 경로 | 설명 | +|--------|------|------| +| `POST` | `/api/cleanup` | 오래된 결과 파일 정리 | + +--- + +## 기술 스택 + +| 구성요소 | 버전 | 역할 | +|---------|------|------| +| Python | 3.11 | 런타임 | +| FastAPI | 0.115 | API 서버 | +| Celery | 5.4 | 비동기 태스크 큐 | +| Redis | 7 (alpine) | 메시지 브로커 | +| faster-whisper | 1.0.3 | STT 엔진 | +| PaddlePaddle | 3.0.0 | OCR 딥러닝 프레임워크 | +| PaddleOCR | 3.x | OCR 엔진 | +| httpx | 0.27+ | Ollama API 호출 | +| Ollama | 호스트 운용 | Vision 모델 서버 | diff --git a/app/Dockerfile b/app/Dockerfile new file mode 100644 index 0000000..6da8ab2 --- /dev/null +++ b/app/Dockerfile @@ -0,0 +1,33 @@ +FROM python:3.11-slim + +RUN apt-get update && apt-get install -y \ + ffmpeg \ + libsndfile1 \ + libgomp1 \ + libglib2.0-0 \ + libsm6 \ + libxext6 \ + libxrender1 \ + libgl1 \ + wget \ + curl \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /app + +COPY requirements.txt . + +# PaddlePaddle CPU (AMD64) — paddleocr 3.x 호환 +RUN pip install --no-cache-dir paddlepaddle==3.0.0 \ + -i https://pypi.tuna.tsinghua.edu.cn/simple + +# 나머지 패키지 +RUN pip install --no-cache-dir -r requirements.txt + +COPY . . + +RUN mkdir -p /data/uploads /data/outputs + +EXPOSE 8000 + +CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/app/auth.py b/app/auth.py new file mode 100644 index 0000000..8e92857 --- /dev/null +++ b/app/auth.py @@ -0,0 +1,45 @@ +import os +from datetime import datetime, timedelta + +from fastapi import Depends, HTTPException, status +from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials +from jose import JWTError, jwt + +SECRET_KEY = os.getenv("JWT_SECRET", "fallback-secret-change-this") +ALGORITHM = "HS256" +EXPIRE_HOURS = int(os.getenv("JWT_EXPIRE_HOURS", "12")) + +AUTH_USERNAME = os.getenv("AUTH_USERNAME", "admin") +AUTH_PASSWORD = os.getenv("AUTH_PASSWORD", "changeme1234") + +bearer = HTTPBearer(auto_error=False) + + +def authenticate(username: str, password: str) -> bool: + return username == AUTH_USERNAME and password == AUTH_PASSWORD + + +def create_access_token(username: str) -> str: + expire = datetime.utcnow() + timedelta(hours=EXPIRE_HOURS) + return jwt.encode({"sub": username, "exp": expire}, SECRET_KEY, algorithm=ALGORITHM) + + +def require_auth(credentials: HTTPAuthorizationCredentials = Depends(bearer)): + if credentials is None: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="인증이 필요합니다", + headers={"WWW-Authenticate": "Bearer"}, + ) + try: + payload = jwt.decode(credentials.credentials, SECRET_KEY, algorithms=[ALGORITHM]) + username = payload.get("sub") + if username is None or username != AUTH_USERNAME: + raise JWTError() + except JWTError: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="토큰이 유효하지 않거나 만료되었습니다", + headers={"WWW-Authenticate": "Bearer"}, + ) + return username diff --git a/app/main.py b/app/main.py new file mode 100644 index 0000000..2db2aee --- /dev/null +++ b/app/main.py @@ -0,0 +1,153 @@ +import os +import uuid +import time +import glob +import aiofiles +from fastapi import FastAPI, UploadFile, File, HTTPException, Depends, Form, Request +from fastapi.staticfiles import StaticFiles +from fastapi.responses import FileResponse + +from auth import authenticate, create_access_token, require_auth +from tasks import celery_app, transcribe_task +from ocr_tasks import ocr_task + +app = FastAPI(title="VoiceScript API") + +UPLOAD_DIR = os.getenv("UPLOAD_DIR", "/data/uploads") +OUTPUT_DIR = os.getenv("OUTPUT_DIR", "/data/outputs") +MAX_UPLOAD_BYTES = int(os.getenv("MAX_UPLOAD_MB", "500")) * 1024 * 1024 +OUTPUT_KEEP_SECS = int(os.getenv("OUTPUT_KEEP_HOURS", "48")) * 3600 + +os.makedirs(UPLOAD_DIR, exist_ok=True) +os.makedirs(OUTPUT_DIR, exist_ok=True) + +AUDIO_EXT = {"mp3","mp4","wav","m4a","ogg","flac","aac","wma","webm","mkv","avi","mov"} +IMAGE_EXT = {"jpg","jpeg","png","bmp","tiff","tif","webp","gif"} + + +# ── 인증 ────────────────────────────────────────────────────── +@app.post("/api/login") +def login(username: str = Form(...), password: str = Form(...)): + if not authenticate(username, password): + raise HTTPException(status_code=401, detail="아이디 또는 비밀번호가 올바르지 않습니다") + return {"access_token": create_access_token(username), "token_type": "bearer"} + +@app.get("/api/me") +def me(user: str = Depends(require_auth)): + return {"username": user} + + +# ── STT ─────────────────────────────────────────────────────── +@app.post("/api/transcribe") +async def transcribe(request: Request, file: UploadFile = File(...), + _: str = Depends(require_auth)): + _check_size(request) + ext = _ext(file.filename) + if ext not in AUDIO_EXT: + raise HTTPException(400, f"지원하지 않는 형식. 지원: {', '.join(sorted(AUDIO_EXT))}") + file_id = str(uuid.uuid4()) + save_path = os.path.join(UPLOAD_DIR, f"{file_id}.{ext}") + await _save(file, save_path) + task = transcribe_task.delay(file_id, save_path) + return {"task_id": task.id, "file_id": file_id, "filename": file.filename} + + +# ── OCR ─────────────────────────────────────────────────────── +@app.post("/api/ocr") +async def ocr( + request: Request, + file: UploadFile = File(...), + mode: str = Form("text"), + backend: str = Form("paddle"), + ollama_model: str = Form("granite3.2-vision"), + custom_prompt: str = Form(""), + _: str = Depends(require_auth), +): + _check_size(request) + ext = _ext(file.filename) + if ext not in IMAGE_EXT: + raise HTTPException(400, f"지원하지 않는 형식. 지원: {', '.join(sorted(IMAGE_EXT))}") + if mode not in ("text", "structure"): mode = "text" + if backend not in ("paddle", "ollama"): backend = "paddle" + file_id = str(uuid.uuid4()) + save_path = os.path.join(UPLOAD_DIR, f"{file_id}.{ext}") + await _save(file, save_path) + task = ocr_task.delay(file_id, save_path, mode, backend, ollama_model, custom_prompt) + return {"task_id": task.id, "file_id": file_id, + "filename": file.filename, "mode": mode, "backend": backend} + + +# ── 상태 조회 (celery_app.AsyncResult 사용) ─────────────────── +@app.get("/api/status/{task_id}") +def get_status(task_id: str, _: str = Depends(require_auth)): + r = celery_app.AsyncResult(task_id) + if r.state == "PENDING": + return {"state": "pending", "progress": 0, "message": "대기 중..."} + if r.state == "PROGRESS": + m = r.info or {} + return {"state": "progress", "progress": m.get("progress", 0), + "message": m.get("message", "처리 중...")} + if r.state == "SUCCESS": + return {"state": "success", "progress": 100, **r.result} + if r.state == "FAILURE": + return {"state": "failure", "progress": 0, "message": str(r.info)} + return {"state": r.state.lower(), "progress": 0} + + +# ── 다운로드 ────────────────────────────────────────────────── +@app.get("/api/download/{filename}") +def download(filename: str, _: str = Depends(require_auth)): + if ".." in filename or "/" in filename: + raise HTTPException(400, "잘못된 파일명") + path = os.path.join(OUTPUT_DIR, filename) + if not os.path.exists(path): + raise HTTPException(404, "파일을 찾을 수 없습니다") + media = ("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" + if filename.endswith(".xlsx") else "text/plain") + return FileResponse(path, media_type=media, filename=filename) + + +# ── 결과 파일 정리 ──────────────────────────────────────────── +@app.post("/api/cleanup") +def cleanup(_: str = Depends(require_auth)): + return {"removed": _cleanup_outputs()} + +@app.on_event("startup") +async def on_startup(): + _cleanup_outputs() + + +# ── 유틸 ────────────────────────────────────────────────────── +def _check_size(request: Request): + cl = request.headers.get("content-length") + if cl and int(cl) > MAX_UPLOAD_BYTES: + raise HTTPException(413, f"파일이 너무 큽니다. 최대 {MAX_UPLOAD_BYTES//1024//1024}MB") + +def _cleanup_outputs() -> int: + if OUTPUT_KEEP_SECS == 0: + return 0 + cutoff = time.time() - OUTPUT_KEEP_SECS + removed = 0 + for f in glob.glob(os.path.join(OUTPUT_DIR, "*")): + try: + if os.path.getmtime(f) < cutoff: + os.remove(f); removed += 1 + except Exception: + pass + return removed + +def _ext(fn): + return fn.rsplit(".", 1)[-1].lower() if "." in fn else "" + +async def _save(file: UploadFile, path: str): + written = 0 + async with aiofiles.open(path, "wb") as f: + while chunk := await file.read(1024 * 1024): + written += len(chunk) + if written > MAX_UPLOAD_BYTES: + await f.close() + os.remove(path) + raise HTTPException(413, f"파일이 너무 큽니다. 최대 {MAX_UPLOAD_BYTES//1024//1024}MB") + await f.write(chunk) + +app.mount("/", StaticFiles(directory="static", html=True), name="static") diff --git a/app/ocr_tasks.py b/app/ocr_tasks.py new file mode 100644 index 0000000..c5bc231 --- /dev/null +++ b/app/ocr_tasks.py @@ -0,0 +1,288 @@ +""" +OCR Celery Tasks +- PaddleOCR 3.x 호환 (use_gpu/show_log/cls 파라미터 제거, 결과구조 변경 반영) +- backend="paddle" → PaddleOCR 로컬 실행 +- backend="ollama" → Ollama Vision API 호출 +""" +import os +import base64 + +import httpx +from celery import Celery +import openpyxl +from openpyxl.styles import Font, PatternFill, Alignment, Border, Side + +REDIS_URL = os.getenv("REDIS_URL", "redis://redis:6379/0") +OUTPUT_DIR = os.getenv("OUTPUT_DIR", "/data/outputs") +OCR_LANG = os.getenv("OCR_LANG", "korean") +OLLAMA_URL = os.getenv("OLLAMA_URL", "http://192.168.0.126:11434") +OLLAMA_TIMEOUT = int(os.getenv("OLLAMA_TIMEOUT", "180")) + +celery_app = Celery("ocr_tasks", broker=REDIS_URL, backend=REDIS_URL) +celery_app.conf.update( + task_serializer="json", + result_serializer="json", + accept_content=["json"], + task_track_started=True, + result_expires=3600, +) + +# PaddleOCR 싱글톤 +_ocr_engine = None +_struct_engine = None + +def get_ocr(): + global _ocr_engine + if _ocr_engine is None: + from paddleocr import PaddleOCR + print(f"[PaddleOCR] 로딩 (lang={OCR_LANG})") + # PaddleOCR 3.x: use_gpu/show_log 파라미터 제거됨 + _ocr_engine = PaddleOCR(use_angle_cls=True, lang=OCR_LANG) + print("[PaddleOCR] 완료") + return _ocr_engine + +def get_structure(): + global _struct_engine + if _struct_engine is None: + from paddleocr import PPStructure + print("[PPStructure] 로딩") + _struct_engine = PPStructure(table=True, ocr=True, lang=OCR_LANG) + print("[PPStructure] 완료") + return _struct_engine + + +# ════════════════════════════════════════════════════════════════ +# 메인 Task +# ════════════════════════════════════════════════════════════════ +@celery_app.task(bind=True, name="tasks.ocr_task", queue="ocr") +def ocr_task(self, file_id, image_path, mode="text", + backend="paddle", ollama_model="granite3.2-vision", custom_prompt=""): + self.update_state(state="PROGRESS", meta={"progress": 8, "message": "엔진 준비 중..."}) + try: + if backend == "ollama": + result = _run_ollama(self, file_id, image_path, mode, ollama_model, custom_prompt) + else: + result = _run_paddle(self, file_id, image_path, mode) + try: os.remove(image_path) + except: pass + return result + except Exception as e: + try: os.remove(image_path) + except: pass + raise Exception(f"OCR 실패: {str(e)}") + + +# ════════════════════════════════════════════════════════════════ +# Ollama 백엔드 +# ════════════════════════════════════════════════════════════════ +_OLLAMA_PROMPTS = { + "text": "이 이미지에서 모든 텍스트를 정확하게 추출해줘. 원본의 줄 구분과 단락 구조를 유지해줘.", + "structure": "이 이미지를 분석해서 표는 마크다운 표 형식으로, 나머지 텍스트는 원본 구조를 유지하며 추출해줘.", +} + +def _run_ollama(task, file_id, image_path, mode, ollama_model, custom_prompt): + task.update_state(state="PROGRESS", + meta={"progress": 15, "message": f"Ollama ({ollama_model}) 연결 중..."}) + with open(image_path, "rb") as f: + img_b64 = base64.b64encode(f.read()).decode() + prompt = custom_prompt.strip() or _OLLAMA_PROMPTS.get(mode, _OLLAMA_PROMPTS["text"]) + task.update_state(state="PROGRESS", meta={"progress": 30, "message": "모델 추론 중..."}) + try: + resp = httpx.post(f"{OLLAMA_URL}/api/chat", json={ + "model": ollama_model, + "messages": [{"role": "user", "content": prompt, "images": [img_b64]}], + "stream": False, + "options": {"temperature": 0.1}, + }, timeout=float(OLLAMA_TIMEOUT)) + resp.raise_for_status() + except httpx.ConnectError: + raise Exception(f"Ollama 서버 연결 실패 ({OLLAMA_URL})") + except httpx.TimeoutException: + raise Exception(f"Ollama 응답 시간 초과 ({OLLAMA_TIMEOUT}초). OLLAMA_TIMEOUT 값을 늘려주세요.") + + task.update_state(state="PROGRESS", meta={"progress": 85, "message": "결과 저장 중..."}) + full_text = resp.json().get("message", {}).get("content", "").strip() + if not full_text: + raise Exception("Ollama 빈 응답. 모델이 설치되어 있는지 확인하세요.") + + tables = _parse_md_tables(full_text) if mode == "structure" else [] + os.makedirs(OUTPUT_DIR, exist_ok=True) + txt_file = f"{file_id}_ocr.txt" + with open(os.path.join(OUTPUT_DIR, txt_file), "w", encoding="utf-8") as f: + f.write(f"# OCR 결과 (Ollama / {ollama_model})\n\n{full_text}") + xlsx_file = None + if tables: + xlsx_file = f"{file_id}_tables.xlsx" + _save_excel(tables, os.path.join(OUTPUT_DIR, xlsx_file)) + tables_html = [_md_table_to_html(t) for t in tables] + lines = [{"text": l, "confidence": 1.0, "bbox": []} + for l in full_text.splitlines() if l.strip()] + return { + "mode": mode, "backend": "ollama", "ollama_model": ollama_model, + "full_text": full_text, "lines": lines, "line_count": len(lines), + "txt_file": txt_file, + "tables": [{"html": h, "rows": len(t), + "cols": max(len(r) for r in t) if t else 0} + for h, t in zip(tables_html, tables)], + "xlsx_file": xlsx_file, + } + + +# ════════════════════════════════════════════════════════════════ +# PaddleOCR 백엔드 +# ════════════════════════════════════════════════════════════════ +def _run_paddle(task, file_id, image_path, mode): + import cv2 + img = cv2.imread(image_path) + if img is None: + raise ValueError("이미지를 읽을 수 없습니다") + os.makedirs(OUTPUT_DIR, exist_ok=True) + return _paddle_structure(task, file_id, img) if mode == "structure" \ + else _paddle_text(task, file_id, img) + + +def _paddle_text(task, file_id, img): + task.update_state(state="PROGRESS", meta={"progress": 30, "message": "텍스트 인식 중..."}) + # PaddleOCR 3.x: cls 파라미터 제거, 결과 구조 변경 + result = get_ocr().ocr(img) + task.update_state(state="PROGRESS", meta={"progress": 80, "message": "결과 정리 중..."}) + + lines = [] + if result and len(result) > 0: + r = result[0] + # PaddleOCR 3.x 결과 구조: dict with rec_texts, rec_scores + if isinstance(r, dict): + texts = r.get("rec_texts", []) + scores = r.get("rec_scores", []) + for text, conf in zip(texts, scores): + if text.strip(): + lines.append({"text": text, + "confidence": round(float(conf), 3), + "bbox": []}) + # 구버전 호환 (list of [bbox, (text, conf)]) + elif isinstance(r, list): + for item in r: + if item and len(item) == 2: + _, (text, conf) = item + if text.strip(): + lines.append({"text": text, + "confidence": round(float(conf), 3), + "bbox": []}) + + full_text = "\n".join(l["text"] for l in lines) + txt_file = f"{file_id}_ocr.txt" + with open(os.path.join(OUTPUT_DIR, txt_file), "w", encoding="utf-8") as f: + f.write(full_text) + return {"mode": "text", "backend": "paddle", + "full_text": full_text, "lines": lines, + "line_count": len(lines), "txt_file": txt_file, + "tables": [], "xlsx_file": None} + + +def _paddle_structure(task, file_id, img): + task.update_state(state="PROGRESS", meta={"progress": 20, "message": "레이아웃 분석 중..."}) + result = get_structure()(img) + task.update_state(state="PROGRESS", meta={"progress": 60, "message": "표 구조 추출 중..."}) + + text_blocks, tables_html, tables_data = [], [], [] + for region in result: + rtype = region.get("type", "").lower() + if rtype == "table": + html = region.get("res", {}).get("html", "") + if html: + tables_html.append(html) + tables_data.append(_html_table_to_list(html)) + elif rtype in ("text", "title", "figure_caption"): + for line in (region.get("res", []) or []): + if isinstance(line, (list, tuple)) and len(line) == 2: + _, (text, _conf) = line + text_blocks.append(text) + + full_text = "\n".join(text_blocks) + task.update_state(state="PROGRESS", meta={"progress": 80, "message": "Excel 생성 중..."}) + + xlsx_file = None + if tables_data: + xlsx_file = f"{file_id}_tables.xlsx" + _save_excel(tables_data, os.path.join(OUTPUT_DIR, xlsx_file)) + + txt_file = f"{file_id}_ocr.txt" + with open(os.path.join(OUTPUT_DIR, txt_file), "w", encoding="utf-8") as f: + f.write("# 텍스트\n\n" + full_text) + + lines = [{"text": t, "confidence": 1.0, "bbox": []} for t in text_blocks] + tables_meta = [{"html": h, "rows": len(d), + "cols": max(len(r) for r in d) if d else 0} + for h, d in zip(tables_html, tables_data)] + return {"mode": "structure", "backend": "paddle", + "full_text": full_text, "lines": lines, + "line_count": len(lines), "txt_file": txt_file, + "tables": tables_meta, "xlsx_file": xlsx_file} + + +# ════════════════════════════════════════════════════════════════ +# 공통 유틸 +# ════════════════════════════════════════════════════════════════ +def _parse_md_tables(text): + tables, current = [], [] + for line in text.splitlines(): + s = line.strip() + if s.startswith("|") and s.endswith("|"): + if all(c in "| -:" for c in s): continue + current.append([c.strip() for c in s.strip("|").split("|")]) + else: + if len(current) >= 2: tables.append(current) + current = [] + if len(current) >= 2: tables.append(current) + return tables + +def _md_table_to_html(table): + if not table: return "" + rows = "" + for i, row in enumerate(table): + tag = "th" if i == 0 else "td" + cells = "".join(f"<{tag}>{c}" for c in row) + rows += f"{cells}" + return f"{rows}
" + +def _html_table_to_list(html): + from html.parser import HTMLParser + class P(HTMLParser): + def __init__(self): + super().__init__() + self.rows, self._row, self._cell, self._in = [], [], [], False + def handle_starttag(self, tag, attrs): + if tag == "tr": self._row = [] + elif tag in ("td","th"): self._cell = []; self._in = True + def handle_endtag(self, tag): + if tag in ("td","th"): + self._row.append("".join(self._cell).strip()); self._in = False + elif tag == "tr": + if self._row: self.rows.append(self._row) + def handle_data(self, data): + if self._in: self._cell.append(data) + p = P(); p.feed(html); return p.rows + +def _save_excel(tables, path): + wb = openpyxl.Workbook() + wb.remove(wb.active) + for i, table in enumerate(tables, 1): + ws = wb.create_sheet(f"표 {i}") + thin = Side(style="thin", color="2A2A33") + bdr = Border(left=thin, right=thin, top=thin, bottom=thin) + for r_idx, row in enumerate(table, 1): + for c_idx, val in enumerate(row, 1): + cell = ws.cell(row=r_idx, column=c_idx, value=val) + cell.border = bdr + cell.alignment = Alignment(horizontal="center", + vertical="center", wrap_text=True) + if r_idx == 1: + cell.fill = PatternFill("solid", fgColor="1A1A2E") + cell.font = Font(color="00E5A0", bold=True, size=10) + else: + cell.font = Font(size=10) + for col in ws.columns: + w = max((len(str(c.value or "")) for c in col), default=8) + ws.column_dimensions[col[0].column_letter].width = min(w + 4, 40) + if not wb.sheetnames: wb.create_sheet("Sheet1") + wb.save(path) diff --git a/app/requirements.txt b/app/requirements.txt new file mode 100644 index 0000000..65bc35e --- /dev/null +++ b/app/requirements.txt @@ -0,0 +1,21 @@ +fastapi==0.115.0 +uvicorn[standard]==0.30.6 +python-multipart==0.0.9 +celery==5.4.0 +redis==5.0.8 +faster-whisper==1.0.3 +aiofiles==23.2.1 + +# 인증 (bcrypt 제거 — 직접 비교 방식 사용) +python-jose[cryptography]==3.3.0 + +# PaddleOCR 3.x +paddleocr>=3.0.0 +opencv-python-headless>=4.8.0 + +# Ollama API 호출 +httpx>=0.27.0 + +# Excel 출력 +openpyxl==3.1.2 +Pillow>=10.0.0 diff --git a/app/static/index.html b/app/static/index.html new file mode 100644 index 0000000..b3976a7 --- /dev/null +++ b/app/static/index.html @@ -0,0 +1,771 @@ + + + + + +VoiceScript — STT & OCR + + + + + + + +
+ +
+ + +
+
+

VoiceScript

+
+
+ + + + + +
+
+
+
파일 업로드
+
+ + 🎵 +
드래그하거나 클릭하여 선택
음성 또는 영상 파일
+
mp3 · wav · m4a · ogg · flac · aac · mp4 · webm · mkv
+
+
+
+
+ +
+
+ +
+
+ +
+
처리 중...0%
+
+
+
+
+
+
+
변환 결과
+
+
언어
+
길이
+
세그먼트
+
+
+ + +
+
+
📝
파일 업로드 후
변환을 시작하면
결과가 표시됩니다
+ +
+
+
+ + + +
+
+
+
+ + +
+
+ +
+
이미지 업로드
+
+ + 🖼 +
드래그하거나 클릭하여 선택
이미지 파일
+
jpg · png · bmp · tiff · webp · gif
+
+
+ + + +
+
OCR 엔진
+
+ + +
+
+ + +
+
모델 선택
+
+
+
granite3.2-vision
+
IBM · ~2GB
+ 문서/표 특화 +
+
+
deepseek-ocr:3b
+
DeepSeek · ~2GB
+ OCR 전용 +
+
+
llama3.2-vision:11b
+
Meta · ~8GB
+ 범용 고정확도 +
+
+
olmocr2:7b-q8
+
AllenAI · ~9GB
+ 최고 정확도 +
+
+ + +
+ 커스텀 프롬프트 직접 입력 +
+
+ +
비워두면 인식 모드에 맞는 기본 프롬프트가 사용됩니다
+
+
+ + +
+
인식 모드
+
+ + +
+
+ 일반 텍스트와 글자를 인식합니다 +
+
+ + +
+
+ +
+
+ + +
+
처리 중...0%
+
+ +
+
+
+ + +
+
인식 결과
+
+
줄 수
+
모드
+
엔진
+
+
+
+ + + +
+
+
🔍
이미지 업로드 후
인식을 시작하면
결과가 표시됩니다
+ +
+
+
+
+
📊
표 구조 분석 모드를
선택하면 표를
추출할 수 있습니다
+
+
+ + + + +
+
+
+
+ + + + diff --git a/app/tasks.py b/app/tasks.py new file mode 100644 index 0000000..5d01532 --- /dev/null +++ b/app/tasks.py @@ -0,0 +1,97 @@ +import os +from celery import Celery + +# ocr_task를 직접 import해서 worker에 등록 +from ocr_tasks import ocr_task # noqa: F401 + +REDIS_URL = os.getenv("REDIS_URL", "redis://redis:6379/0") +MODEL_SIZE = os.getenv("WHISPER_MODEL", "medium") +DEVICE = os.getenv("WHISPER_DEVICE", "cpu") +COMPUTE_TYPE = os.getenv("WHISPER_COMPUTE_TYPE", "int8") +LANGUAGE = os.getenv("WHISPER_LANGUAGE", "ko") or None +BEAM_SIZE = int(os.getenv("WHISPER_BEAM_SIZE", "5")) +INITIAL_PROMPT = os.getenv("WHISPER_INITIAL_PROMPT", "") or None +OUTPUT_DIR = os.getenv("OUTPUT_DIR", "/data/outputs") + +celery_app = Celery("whisper_tasks", broker=REDIS_URL, backend=REDIS_URL) +celery_app.conf.update( + task_serializer="json", + result_serializer="json", + accept_content=["json"], + task_track_started=True, + result_expires=3600, +) + +_model = None + +def get_model(): + global _model + if _model is None: + from faster_whisper import WhisperModel + print(f"[Whisper] 로딩: {MODEL_SIZE} / {DEVICE} / {COMPUTE_TYPE}") + _model = WhisperModel(MODEL_SIZE, device=DEVICE, compute_type=COMPUTE_TYPE) + print("[Whisper] 로드 완료") + return _model + + +@celery_app.task(bind=True, name="tasks.transcribe_task", queue="stt") +def transcribe_task(self, file_id: str, audio_path: str): + self.update_state(state="PROGRESS", meta={"progress": 5, "message": "모델 준비 중..."}) + try: + model = get_model() + self.update_state(state="PROGRESS", meta={"progress": 15, "message": "오디오 분석 중..."}) + + segments_gen, info = model.transcribe( + audio_path, + language=LANGUAGE, + beam_size=BEAM_SIZE, + initial_prompt=INITIAL_PROMPT, + vad_filter=True, + vad_parameters=dict(min_silence_duration_ms=500), + word_timestamps=False, + ) + + self.update_state(state="PROGRESS", meta={"progress": 30, "message": "텍스트 변환 중..."}) + + segments, full_text_parts = [], [] + duration = info.duration + + for seg in segments_gen: + segments.append({"start": round(seg.start, 2), + "end": round(seg.end, 2), + "text": seg.text.strip()}) + full_text_parts.append(seg.text.strip()) + if duration > 0: + pct = 30 + int((seg.end / duration) * 60) + self.update_state( + state="PROGRESS", + meta={"progress": min(pct, 90), + "message": f"변환 중... {seg.end:.0f}s / {duration:.0f}s"}, + ) + + full_text = "\n".join(full_text_parts) + + self.update_state(state="PROGRESS", meta={"progress": 95, "message": "파일 저장 중..."}) + os.makedirs(OUTPUT_DIR, exist_ok=True) + output_filename = f"{file_id}.txt" + with open(os.path.join(OUTPUT_DIR, output_filename), "w", encoding="utf-8") as f: + f.write(f"# 변환 결과\n# 언어: {info.language} | 재생 시간: {duration:.1f}초\n\n") + f.write("## 전체 텍스트\n\n" + full_text + "\n\n") + f.write("## 타임스탬프별 세그먼트\n\n") + for seg in segments: + f.write(f"[{_fmt(seg['start'])} → {_fmt(seg['end'])}] {seg['text']}\n") + + try: os.remove(audio_path) + except: pass + + return {"text": full_text, "segments": segments, + "language": info.language, "duration": round(duration, 1), + "output_file": output_filename} + + except Exception as e: + raise Exception(f"변환 실패: {str(e)}") + + +def _fmt(s): + m, sec = divmod(int(s), 60) + return f"{m:02d}:{sec:02d}" diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..8ff5d3f --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,109 @@ +services: + redis: + image: redis:7-alpine + container_name: whisper_redis + restart: unless-stopped + # RDB 스냅샷 저장 실패 시에도 쓰기 허용 (Celery 브로커 용도) + command: redis-server --stop-writes-on-bgsave-error no + environment: + - TZ=Asia/Seoul + volumes: + - redis_data:/data + networks: + - whisper_net + + app: + build: + context: ./app + dockerfile: Dockerfile + container_name: whisper_app + restart: unless-stopped + ports: + - "8800:8000" + environment: + - TZ=Asia/Seoul + + # ── 인증 (반드시 변경) ────────────────────────────── + - AUTH_USERNAME=admin + - AUTH_PASSWORD=changeme1234 + - JWT_SECRET=your-very-secret-key-change-this + - JWT_EXPIRE_HOURS=12 + + # ── Whisper STT ───────────────────────────────────── + - REDIS_URL=redis://redis:6379/0 + - UPLOAD_DIR=/data/uploads + - OUTPUT_DIR=/data/outputs + - WHISPER_MODEL=medium + - WHISPER_DEVICE=cpu + - WHISPER_COMPUTE_TYPE=int8 + - WHISPER_LANGUAGE=ko + - WHISPER_BEAM_SIZE=5 + - WHISPER_INITIAL_PROMPT= + + # ── 파일 관리 ──────────────────────────────────────── + - MAX_UPLOAD_MB=500 + - OUTPUT_KEEP_HOURS=48 + + # ── PaddleOCR ──────────────────────────────────────── + - OCR_LANG=korean + + # ── Ollama ─────────────────────────────────────────── + - OLLAMA_URL=http://192.168.0.126:11434 + - OLLAMA_TIMEOUT=180 + + volumes: + - stt_data:/data + - whisper_models:/root/.cache/huggingface + - paddle_models:/root/.paddlex + depends_on: + - redis + networks: + - whisper_net + + worker: + build: + context: ./app + dockerfile: Dockerfile + container_name: whisper_worker + restart: unless-stopped + command: > + celery -A tasks worker + --loglevel=info + --concurrency=1 + --max-tasks-per-child=50 + -Q stt,ocr + environment: + - TZ=Asia/Seoul + - REDIS_URL=redis://redis:6379/0 + - UPLOAD_DIR=/data/uploads + - OUTPUT_DIR=/data/outputs + - WHISPER_MODEL=medium + - WHISPER_DEVICE=cpu + - WHISPER_COMPUTE_TYPE=int8 + - WHISPER_LANGUAGE=ko + - WHISPER_BEAM_SIZE=5 + - WHISPER_INITIAL_PROMPT= + - MAX_UPLOAD_MB=500 + - OUTPUT_KEEP_HOURS=48 + - OCR_LANG=korean + - OLLAMA_URL=http://192.168.0.126:11434 + - OLLAMA_TIMEOUT=180 + - JWT_SECRET=your-very-secret-key-change-this + volumes: + - stt_data:/data + - whisper_models:/root/.cache/huggingface + - paddle_models:/root/.paddlex + depends_on: + - redis + networks: + - whisper_net + +volumes: + redis_data: + stt_data: + whisper_models: + paddle_models: + +networks: + whisper_net: + driver: bridge diff --git a/nginx/Dockerfile b/nginx/Dockerfile new file mode 100644 index 0000000..b846f21 --- /dev/null +++ b/nginx/Dockerfile @@ -0,0 +1,2 @@ +FROM nginx:alpine +COPY nginx.conf /etc/nginx/nginx.conf diff --git a/nginx/nginx.conf b/nginx/nginx.conf new file mode 100644 index 0000000..c888bc2 --- /dev/null +++ b/nginx/nginx.conf @@ -0,0 +1,43 @@ +worker_processes auto; + +events { + worker_connections 1024; +} + +http { + include /etc/nginx/mime.types; + default_type application/octet-stream; + sendfile on; + keepalive_timeout 65; + + # 업로드 파일 크기 제한 (통화 녹음 파일 고려) + client_max_body_size 500M; + client_body_timeout 300s; + proxy_read_timeout 300s; + proxy_send_timeout 300s; + + upstream fastapi { + server app:8000; + } + + server { + listen 80; + server_name _; + + # 큰 파일 업로드 버퍼 + client_body_buffer_size 10M; + + location / { + proxy_pass http://fastapi; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + # 대용량 업로드를 위한 타임아웃 + proxy_connect_timeout 60s; + proxy_read_timeout 600s; + proxy_send_timeout 600s; + } + } +}