Spaces:
Running
Running
Upload 142 files
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- .dockerignore +16 -0
- .env.example +16 -0
- .gitignore +13 -2
- Dockerfile +43 -0
- README.md +995 -0
- api/__init__.py +1 -0
- api/audio.py +1 -0
- api/health.py +1 -0
- api/image.py +1 -0
- api/media.py +1 -0
- api/probe.py +1 -0
- api/video.py +1 -0
- api/whisper.py +1 -0
- api/ytdlp.py +1 -0
- app/__init__.py +3 -0
- app/api/__init__.py +1 -0
- app/api/audio.py +43 -0
- app/api/health.py +37 -0
- app/api/image.py +34 -0
- app/api/media.py +44 -0
- app/api/probe.py +16 -0
- app/api/templates.py +60 -0
- app/api/video.py +77 -0
- app/api/whisper.py +32 -0
- app/api/ytdlp.py +16 -0
- app/container.py +68 -0
- app/core/__init__.py +1 -0
- app/core/config.py +66 -0
- app/core/exceptions.py +59 -0
- app/core/logger.py +59 -0
- app/core/response.py +27 -0
- app/mcp/__init__.py +1 -0
- app/mcp/prompts.py +81 -0
- app/mcp/registry.py +505 -0
- app/mcp/resources.py +71 -0
- app/mcp/server.py +90 -0
- app/mcp/tools/__init__.py +1 -0
- app/mcp/tools/audio.py +63 -0
- app/mcp/tools/image.py +86 -0
- app/mcp/tools/probe.py +39 -0
- app/mcp/tools/system.py +87 -0
- app/mcp/tools/templates.py +49 -0
- app/mcp/tools/video.py +223 -0
- app/mcp/tools/whisper.py +109 -0
- app/mcp/tools/ytdlp.py +60 -0
- app/models/__init__.py +5 -0
- app/models/media.py +52 -0
- app/models/requests.py +38 -0
- app/operations/__init__.py +1 -0
- app/operations/common.py +114 -0
.dockerignore
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
.git
|
| 2 |
+
.venv
|
| 3 |
+
.github
|
| 4 |
+
.env
|
| 5 |
+
.pytest_cache
|
| 6 |
+
.ruff_cache
|
| 7 |
+
__pycache__
|
| 8 |
+
*.py[cod]
|
| 9 |
+
*.log
|
| 10 |
+
*.zip
|
| 11 |
+
outputs/*
|
| 12 |
+
!outputs/.gitkeep
|
| 13 |
+
temp/*
|
| 14 |
+
!temp/.gitkeep
|
| 15 |
+
tests
|
| 16 |
+
requirements-dev.txt
|
.env.example
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
TEMP_DIR=./temp
|
| 2 |
+
OUTPUT_DIR=./outputs
|
| 3 |
+
TEMPLATE_DIR=./app/templates/categories
|
| 4 |
+
MAX_UPLOAD_SIZE=1073741824
|
| 5 |
+
WHISPER_MODEL=small
|
| 6 |
+
CLEANUP_MINUTES=60
|
| 7 |
+
CLEANUP_INTERVAL_SECONDS=60
|
| 8 |
+
MAX_WORKERS=2
|
| 9 |
+
LOG_LEVEL=INFO
|
| 10 |
+
MAX_DURATION_SECONDS=21600
|
| 11 |
+
MAX_RESOLUTION_PIXELS=33177600
|
| 12 |
+
DOWNLOAD_TIMEOUT_SECONDS=300
|
| 13 |
+
ALLOW_PRIVATE_URLS=false
|
| 14 |
+
BASE_URL=
|
| 15 |
+
FFMPEG_BINARY=ffmpeg
|
| 16 |
+
FFPROBE_BINARY=ffprobe
|
.gitignore
CHANGED
|
@@ -1,5 +1,16 @@
|
|
|
|
|
|
|
|
|
|
|
| 1 |
__pycache__/
|
| 2 |
*.py[cod]
|
|
|
|
| 3 |
.pytest_cache/
|
| 4 |
-
|
| 5 |
-
.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
.env
|
| 2 |
+
.venv/
|
| 3 |
+
venv/
|
| 4 |
__pycache__/
|
| 5 |
*.py[cod]
|
| 6 |
+
*.log
|
| 7 |
.pytest_cache/
|
| 8 |
+
.ruff_cache/
|
| 9 |
+
.mypy_cache/
|
| 10 |
+
.coverage
|
| 11 |
+
htmlcov/
|
| 12 |
+
outputs/*
|
| 13 |
+
!outputs/.gitkeep
|
| 14 |
+
temp/*
|
| 15 |
+
!temp/.gitkeep
|
| 16 |
+
*.zip
|
Dockerfile
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
FROM python:3.10-slim
|
| 2 |
+
|
| 3 |
+
ENV PYTHONDONTWRITEBYTECODE=1 \
|
| 4 |
+
PYTHONUNBUFFERED=1 \
|
| 5 |
+
PIP_NO_CACHE_DIR=1 \
|
| 6 |
+
PIP_DISABLE_PIP_VERSION_CHECK=1 \
|
| 7 |
+
HF_HOME=/home/user/.cache/huggingface \
|
| 8 |
+
TEMP_DIR=/app/temp \
|
| 9 |
+
OUTPUT_DIR=/app/outputs \
|
| 10 |
+
PORT=7860
|
| 11 |
+
|
| 12 |
+
RUN apt-get update \
|
| 13 |
+
&& apt-get install --no-install-recommends -y \
|
| 14 |
+
ca-certificates \
|
| 15 |
+
ffmpeg \
|
| 16 |
+
imagemagick \
|
| 17 |
+
libglib2.0-0 \
|
| 18 |
+
libgomp1 \
|
| 19 |
+
libsndfile1 \
|
| 20 |
+
&& rm -rf /var/lib/apt/lists/*
|
| 21 |
+
|
| 22 |
+
WORKDIR /app
|
| 23 |
+
|
| 24 |
+
COPY requirements.txt ./
|
| 25 |
+
RUN python -m pip install --upgrade pip \
|
| 26 |
+
&& python -m pip install -r requirements.txt
|
| 27 |
+
|
| 28 |
+
RUN useradd --create-home --uid 1000 user \
|
| 29 |
+
&& mkdir -p /app/temp /app/outputs /home/user/.cache/huggingface \
|
| 30 |
+
&& chown -R user:user /app /home/user
|
| 31 |
+
|
| 32 |
+
COPY --chown=user:user app ./app
|
| 33 |
+
COPY --chown=user:user api services operations workers core models ./
|
| 34 |
+
COPY --chown=user:user main.py README.md ./
|
| 35 |
+
|
| 36 |
+
USER user
|
| 37 |
+
|
| 38 |
+
EXPOSE 7860
|
| 39 |
+
|
| 40 |
+
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
|
| 41 |
+
CMD python -c "import urllib.request; urllib.request.urlopen('http://127.0.0.1:7860/health', timeout=4)" || exit 1
|
| 42 |
+
|
| 43 |
+
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "7860", "--workers", "1", "--proxy-headers", "--forwarded-allow-ips", "*"]
|
README.md
ADDED
|
@@ -0,0 +1,995 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
title: Enterprise Media Processing API
|
| 3 |
+
emoji: 🎬
|
| 4 |
+
colorFrom: blue
|
| 5 |
+
colorTo: purple
|
| 6 |
+
sdk: docker
|
| 7 |
+
app_port: 7860
|
| 8 |
+
pinned: false
|
| 9 |
+
---
|
| 10 |
+
|
| 11 |
+
# Enterprise Media Processing API
|
| 12 |
+
|
| 13 |
+
A production-oriented, CPU-optimized REST, Model Context Protocol (MCP), and YAML workflow API for FFmpeg, FFprobe, yt-dlp, and faster-whisper. It is designed to run unchanged as a Hugging Face Docker Space and to act as a reusable media backend for AI clients, n8n, and other automation systems.
|
| 14 |
+
|
| 15 |
+
The service accepts multipart uploads, JSON URLs, JSON Base64, n8n binary objects, and streamed raw request bodies. Every input becomes an `InputMedia` before it enters the operation layer, so operations never need to know how media arrived.
|
| 16 |
+
|
| 17 |
+
## Architecture
|
| 18 |
+
|
| 19 |
+
```text
|
| 20 |
+
REST /v1/* MCP stdio or /mcp/
|
| 21 |
+
│ │
|
| 22 |
+
├───────────────┬──────────────────┘
|
| 23 |
+
▼
|
| 24 |
+
request-ID + JSON logging
|
| 25 |
+
│
|
| 26 |
+
InputResolver
|
| 27 |
+
├── streamed multipart/raw upload
|
| 28 |
+
├── Base64/n8n decoding
|
| 29 |
+
├── HTTP(S) streaming downloader + SSRF guard
|
| 30 |
+
└── automatic yt-dlp extractor selection
|
| 31 |
+
│
|
| 32 |
+
▼
|
| 33 |
+
normalized InputMedia
|
| 34 |
+
│
|
| 35 |
+
FFprobe validation/metadata
|
| 36 |
+
│
|
| 37 |
+
optional versioned Template Engine
|
| 38 |
+
│
|
| 39 |
+
operation (video/audio/image)
|
| 40 |
+
│
|
| 41 |
+
concurrency-limited FFmpeg/Whisper
|
| 42 |
+
│
|
| 43 |
+
published output + streaming URL
|
| 44 |
+
│
|
| 45 |
+
background expiry cleanup
|
| 46 |
+
```
|
| 47 |
+
|
| 48 |
+
REST routes and MCP tools contain transport concerns only. Both use the same dependency container, `InputResolver`, `MediaProcessor`, operation functions, and infrastructure services. `MediaProcessor` orchestrates resolution, probing, validation, publishing, and metrics; operation modules build safe FFmpeg argument arrays; infrastructure services own subprocesses, downloads, model loading, and storage. No command is executed through a shell.
|
| 49 |
+
|
| 50 |
+
## Project layout
|
| 51 |
+
|
| 52 |
+
```text
|
| 53 |
+
media-api/
|
| 54 |
+
├── app/
|
| 55 |
+
│ ├── api/ # versioned, thin FastAPI routes
|
| 56 |
+
│ ├── core/ # settings, errors, logging, response models
|
| 57 |
+
│ ├── mcp/ # MCP server, registry, tools, resources, prompts
|
| 58 |
+
│ ├── models/ # InputMedia and request/result models
|
| 59 |
+
│ ├── operations/ # reusable FFmpeg operation functions
|
| 60 |
+
│ ├── services/ # FFmpeg, FFprobe, resolver, downloads, Whisper
|
| 61 |
+
│ ├── templates/ # YAML schemas, loader, registry, executor, categories
|
| 62 |
+
│ ├── workers/ # asynchronous expiry cleanup worker
|
| 63 |
+
│ └── container.py # dependency construction
|
| 64 |
+
├── api/ # requested top-level import compatibility
|
| 65 |
+
├── services/ # requested top-level import compatibility
|
| 66 |
+
├── operations/ # requested top-level import compatibility
|
| 67 |
+
├── workers/ # requested top-level import compatibility
|
| 68 |
+
├── core/ # requested top-level import compatibility
|
| 69 |
+
├── models/ # requested top-level import compatibility
|
| 70 |
+
├── outputs/ # published, expiring request outputs
|
| 71 |
+
├── temp/ # request/{uploads,outputs,logs}
|
| 72 |
+
├── tests/
|
| 73 |
+
├── Dockerfile
|
| 74 |
+
├── requirements.txt
|
| 75 |
+
├── requirements-dev.txt
|
| 76 |
+
├── .env.example
|
| 77 |
+
└── main.py
|
| 78 |
+
```
|
| 79 |
+
|
| 80 |
+
The top-level `api/`, `services/`, `operations/`, `workers/`, `core/`, and `models/` packages mirror the canonical `app/` modules as import-compatible entry points for integrations that use the requested layout. Runtime composition uses the single implementation under `app/`, so business logic is not duplicated.
|
| 81 |
+
|
| 82 |
+
Each request creates `TEMP_DIR/<uuid>/{uploads,outputs,logs}`. Completed files are atomically moved to `OUTPUT_DIR/<uuid>` so they can be streamed. Both locations expire after `CLEANUP_MINUTES`; active requests are protected from the cleanup worker.
|
| 83 |
+
|
| 84 |
+
## Run locally
|
| 85 |
+
|
| 86 |
+
FFmpeg, FFprobe, and ImageMagick must be installed on the host.
|
| 87 |
+
|
| 88 |
+
```bash
|
| 89 |
+
python3.10 -m venv .venv
|
| 90 |
+
. .venv/bin/activate
|
| 91 |
+
pip install -r requirements.txt
|
| 92 |
+
cp .env.example .env
|
| 93 |
+
uvicorn main:app --host 0.0.0.0 --port 7860
|
| 94 |
+
```
|
| 95 |
+
|
| 96 |
+
Open `http://localhost:7860/docs` for OpenAPI/Swagger or `http://localhost:7860/redoc` for ReDoc.
|
| 97 |
+
|
| 98 |
+
### Docker
|
| 99 |
+
|
| 100 |
+
```bash
|
| 101 |
+
docker build -t media-api .
|
| 102 |
+
docker run --rm -p 7860:7860 --env-file .env media-api
|
| 103 |
+
```
|
| 104 |
+
|
| 105 |
+
The image uses `python:3.10-slim`, installs FFmpeg, FFprobe, ImageMagick and the system libraries needed by CTranslate2/faster-whisper, clears apt and pip caches, runs as UID 1000, exposes port 7860, and includes a container health check.
|
| 106 |
+
|
| 107 |
+
## Deploy to Hugging Face Spaces
|
| 108 |
+
|
| 109 |
+
1. Create a new Space and select **Docker** as the SDK.
|
| 110 |
+
2. Push the contents of this directory to the Space repository. Keep the YAML block at the top of this README; `app_port` is already `7860`.
|
| 111 |
+
3. Add environment variables under **Settings → Variables and secrets** if the defaults need changing. Do not store secrets in `.env` in Git.
|
| 112 |
+
4. Wait for the Docker build. The first Whisper call downloads the selected model to the Hugging Face cache. Persistent storage is optional, but avoids downloading models again after a cold rebuild.
|
| 113 |
+
5. Check `https://<owner>-<space>.hf.space/health`, then use `/docs` or call `/v1/*` from n8n.
|
| 114 |
+
|
| 115 |
+
If using the supplied `media-api-huggingface.zip`, extract it first and push the extracted files so `Dockerfile` and this `README.md` are at the Space repository root. Do not commit the ZIP as the only repository file.
|
| 116 |
+
|
| 117 |
+
Use one Uvicorn process in a CPU Space. FFmpeg and Whisper concurrency is managed in-process by `MAX_WORKERS`; multiple Uvicorn workers duplicate Whisper models and memory.
|
| 118 |
+
|
| 119 |
+
## Model Context Protocol (MCP)
|
| 120 |
+
|
| 121 |
+
MCP is an open protocol that lets an AI client discover and call typed tools, read application resources, and use reusable prompts. This project uses the official Python MCP SDK and exposes the same processing implementation through both interfaces:
|
| 122 |
+
|
| 123 |
+
```text
|
| 124 |
+
FastAPI REST routes ─┐
|
| 125 |
+
├─ InputResolver → MediaProcessor → shared services/operations
|
| 126 |
+
MCP tools ───────────┘ ├─ FFmpeg / FFprobe
|
| 127 |
+
├─ yt-dlp
|
| 128 |
+
└─ faster-whisper
|
| 129 |
+
```
|
| 130 |
+
|
| 131 |
+
No FFmpeg command, download implementation, transcription implementation, input parser, logger, or cleanup implementation is duplicated in `app/mcp`. The MCP registry creates a UUID for each tool call and reuses the existing request workspace, validation, structured logging, publication, and expiry cleanup behavior. MCP has no authentication, as requested; use a private Space or an external gateway if access control or quotas are required.
|
| 132 |
+
|
| 133 |
+
### MCP transports
|
| 134 |
+
|
| 135 |
+
The normal Docker command starts FastAPI on port `7860` and serves both REST and MCP:
|
| 136 |
+
|
| 137 |
+
- REST and OpenAPI: `https://<owner>-<space>.hf.space/v1/*` and `/docs`
|
| 138 |
+
- Streamable HTTP MCP: `https://<owner>-<space>.hf.space/mcp/`
|
| 139 |
+
|
| 140 |
+
The trailing slash on `/mcp/` is recommended. Streamable HTTP is stateless and returns JSON responses while remaining compliant with the MCP transport. The application owns the MCP session-manager lifespan, so it works correctly even though the MCP ASGI application is mounted under FastAPI.
|
| 141 |
+
|
| 142 |
+
For a local stdio client, run from the project directory:
|
| 143 |
+
|
| 144 |
+
```bash
|
| 145 |
+
python -m app.mcp.server
|
| 146 |
+
```
|
| 147 |
+
|
| 148 |
+
Structured logs are sent to stderr in stdio mode so JSON-RPC messages on stdout are never corrupted. A standalone MCP-only HTTP process is also available for development:
|
| 149 |
+
|
| 150 |
+
```bash
|
| 151 |
+
python -m app.mcp.server --transport streamable-http
|
| 152 |
+
```
|
| 153 |
+
|
| 154 |
+
Use the normal `uvicorn main:app ...` command in Hugging Face because it exposes both interfaces together.
|
| 155 |
+
|
| 156 |
+
### MCP client configuration
|
| 157 |
+
|
| 158 |
+
Claude Desktop and other clients that accept the conventional `mcpServers` JSON can launch stdio directly. Replace the paths with absolute paths on the client machine:
|
| 159 |
+
|
| 160 |
+
```json
|
| 161 |
+
{
|
| 162 |
+
"mcpServers": {
|
| 163 |
+
"enterprise-media": {
|
| 164 |
+
"command": "/absolute/path/media-api/.venv/bin/python",
|
| 165 |
+
"args": ["-m", "app.mcp.server"],
|
| 166 |
+
"cwd": "/absolute/path/media-api",
|
| 167 |
+
"env": {
|
| 168 |
+
"TEMP_DIR": "/absolute/path/media-api/temp",
|
| 169 |
+
"OUTPUT_DIR": "/absolute/path/media-api/outputs",
|
| 170 |
+
"WHISPER_MODEL": "small",
|
| 171 |
+
"MAX_WORKERS": "2"
|
| 172 |
+
}
|
| 173 |
+
}
|
| 174 |
+
}
|
| 175 |
+
}
|
| 176 |
+
```
|
| 177 |
+
|
| 178 |
+
Clients that support remote Streamable HTTP can use:
|
| 179 |
+
|
| 180 |
+
```json
|
| 181 |
+
{
|
| 182 |
+
"mcpServers": {
|
| 183 |
+
"enterprise-media": {
|
| 184 |
+
"url": "https://OWNER-SPACE.hf.space/mcp/"
|
| 185 |
+
}
|
| 186 |
+
}
|
| 187 |
+
}
|
| 188 |
+
```
|
| 189 |
+
|
| 190 |
+
Common client locations and connection choices are:
|
| 191 |
+
|
| 192 |
+
| Client | Configuration |
|
| 193 |
+
|---|---|
|
| 194 |
+
| Claude Desktop | Add the stdio `mcpServers` entry to `claude_desktop_config.json`, then restart Claude. |
|
| 195 |
+
| ChatGPT | Add a remote custom connector in developer/connector settings with the Space `/mcp/` URL. The Space must be reachable from ChatGPT. |
|
| 196 |
+
| Cursor | Put the remote `mcpServers` entry in `.cursor/mcp.json`, or use the stdio entry for local files. |
|
| 197 |
+
| VS Code | Add an HTTP server under `servers` in `.vscode/mcp.json`: `{"type":"http","url":"https://OWNER-SPACE.hf.space/mcp/"}`. |
|
| 198 |
+
| Continue | Add an MCP server in Continue configuration using the Streamable HTTP URL or the stdio command above. |
|
| 199 |
+
| Cline | Open **MCP Servers → Configure** and add the `mcpServers` JSON entry. |
|
| 200 |
+
| Windsurf | Add the same entry in Windsurf MCP settings (`mcp_config.json`). |
|
| 201 |
+
|
| 202 |
+
Client configuration keys can vary between releases; select **Streamable HTTP**, not legacy SSE, when a client asks for the transport. No authorization header is needed.
|
| 203 |
+
|
| 204 |
+
For a Docker-based stdio client, override the image command and keep stdin open:
|
| 205 |
+
|
| 206 |
+
```bash
|
| 207 |
+
docker run --rm -i \
|
| 208 |
+
-v "$PWD/temp:/app/temp" \
|
| 209 |
+
-v "$PWD/outputs:/app/outputs" \
|
| 210 |
+
media-api python -m app.mcp.server
|
| 211 |
+
```
|
| 212 |
+
|
| 213 |
+
In stdio mode, consume the returned `output_file` locally. In Streamable HTTP mode, set `BASE_URL=https://<owner>-<space>.hf.space` so `download_url` is absolute.
|
| 214 |
+
|
| 215 |
+
### MCP media input
|
| 216 |
+
|
| 217 |
+
Every MCP media tool delegates to the existing `InputResolver`. A `MediaInput` accepts exactly one source:
|
| 218 |
+
|
| 219 |
+
```json
|
| 220 |
+
{"url": "https://cdn.example.com/video.mp4"}
|
| 221 |
+
```
|
| 222 |
+
|
| 223 |
+
```json
|
| 224 |
+
{
|
| 225 |
+
"base64": "data:audio/wav;base64,UklGR...",
|
| 226 |
+
"filename": "speech.wav",
|
| 227 |
+
"mime_type": "audio/wav"
|
| 228 |
+
}
|
| 229 |
+
```
|
| 230 |
+
|
| 231 |
+
```json
|
| 232 |
+
{
|
| 233 |
+
"binary": {
|
| 234 |
+
"data": "AAAAHGZ0eXBpc29t...",
|
| 235 |
+
"fileName": "clip.mp4",
|
| 236 |
+
"mimeType": "video/mp4"
|
| 237 |
+
}
|
| 238 |
+
}
|
| 239 |
+
```
|
| 240 |
+
|
| 241 |
+
```json
|
| 242 |
+
{"temp_path": "/app/outputs/<request-uuid>/converted.mp4"}
|
| 243 |
+
```
|
| 244 |
+
|
| 245 |
+
The `binary` object accepts n8n properties such as `data`, `file`, `video`, or `audio`. `temp_path` is copied into the new request workspace and is accepted only when it resolves below configured `TEMP_DIR` or `OUTPUT_DIR`; arbitrary host paths and traversal are rejected. URLs use the same SSRF protection and automatic yt-dlp detection as REST.
|
| 246 |
+
|
| 247 |
+
MCP tool arguments are JSON by protocol, so a client file attachment must be represented as a URL, Base64/data URI, n8n binary object, or a managed `temp_path`. Native `multipart/form-data` and streamed `application/octet-stream` remain available on every corresponding REST endpoint and enter the same resolver and service layer.
|
| 248 |
+
|
| 249 |
+
Single-input tools use an `input` argument. Multi-input tools use `inputs`; composition tools use descriptive arguments such as `video`, `audio`, `watermark`, `overlay`, or `subtitles`. A successful file-producing call returns both the managed server `output_file` and normal REST `download_url`:
|
| 250 |
+
|
| 251 |
+
```json
|
| 252 |
+
{
|
| 253 |
+
"success": true,
|
| 254 |
+
"request_id": "43b32630-9b32-4b27-b038-a39fbfd4b803",
|
| 255 |
+
"processing_time": 1.52,
|
| 256 |
+
"output_file": "/app/outputs/43b32630-9b32-4b27-b038-a39fbfd4b803/compressed.mp4",
|
| 257 |
+
"download_url": "https://OWNER-SPACE.hf.space/v1/media/43b32630-4b27-b038-a39fbfd4b803/compressed.mp4",
|
| 258 |
+
"metadata": {}
|
| 259 |
+
}
|
| 260 |
+
```
|
| 261 |
+
|
| 262 |
+
Errors use the same safe application codes and never expose raw Python exceptions:
|
| 263 |
+
|
| 264 |
+
```json
|
| 265 |
+
{
|
| 266 |
+
"success": false,
|
| 267 |
+
"request_id": "43b32630-9b32-4b27-b038-a39fbfd4b803",
|
| 268 |
+
"processing_time": 0.01,
|
| 269 |
+
"error": {
|
| 270 |
+
"code": "INVALID_INPUT",
|
| 271 |
+
"message": "The managed temporary file does not exist",
|
| 272 |
+
"details": null
|
| 273 |
+
}
|
| 274 |
+
}
|
| 275 |
+
```
|
| 276 |
+
|
| 277 |
+
### Available MCP tools
|
| 278 |
+
|
| 279 |
+
All tools return the structured envelope above. Defaults shown here match the callable schemas advertised to MCP clients.
|
| 280 |
+
|
| 281 |
+
| Video tool | Purpose and principal options |
|
| 282 |
+
|---|---|
|
| 283 |
+
| `compress_video` | CPU-optimized compression; `crf=28`, `preset=medium`, `format=mp4`, optional `max_width` and `bitrate`. |
|
| 284 |
+
| `resize_video` | Resize using `width`, `height`, `fit` (`contain`, `cover`, `fill`), and `format`. |
|
| 285 |
+
| `crop_video` | Crop using required `width`/`height` and optional `x`/`y`. |
|
| 286 |
+
| `trim_video` | Trim with `start` plus `duration` or `end`. |
|
| 287 |
+
| `convert_video` | Convert to a supported video `format`. |
|
| 288 |
+
| `merge_videos` | Normalize and merge `inputs`; optional output `width`/`height`. |
|
| 289 |
+
| `concat_videos` | Concatenate `inputs`; optional `stream_copy` for already-compatible streams. |
|
| 290 |
+
| `watermark_video` | Watermark `video` with an image; `position`, `opacity`, and `watermark_scale`. |
|
| 291 |
+
| `overlay_video` | Overlay media on `video`; `position`, `opacity`, and `watermark_scale`. |
|
| 292 |
+
| `extract_audio` | Extract a video audio stream to `format` (default MP3). |
|
| 293 |
+
| `replace_audio` | Replace the audio in `video` with the supplied `audio`. |
|
| 294 |
+
| `remove_audio` | Remove all audio streams. |
|
| 295 |
+
| `generate_thumbnail` | Create a JPEG at `timestamp`; optional FFmpeg `quality`. |
|
| 296 |
+
| `extract_frames` | Extract at `fps`, optionally bounded by `max_frames`, and return a ZIP. |
|
| 297 |
+
| `burn_subtitles` | Burn SRT, VTT, ASS, or SSA `subtitles` into `video`; optional ASS `style`. |
|
| 298 |
+
|
| 299 |
+
| Audio tool | Purpose and principal options |
|
| 300 |
+
|---|---|
|
| 301 |
+
| `convert_audio` | Convert to MP3, WAV, AAC, M4A, FLAC, OGG, or Opus. |
|
| 302 |
+
| `normalize_audio` | EBU loudness normalization with `target_lufs=-16`. |
|
| 303 |
+
| `trim_audio` | Trim with `start` plus `duration` or `end`. |
|
| 304 |
+
| `merge_audio` | Normalize and merge multiple `inputs`. |
|
| 305 |
+
| `remove_silence` | Remove silence using a threshold such as `-45dB`. |
|
| 306 |
+
|
| 307 |
+
| Image tool | Purpose and principal options |
|
| 308 |
+
|---|---|
|
| 309 |
+
| `image_to_video` | Turn one still image into H.264 video; `duration` and `fps`. |
|
| 310 |
+
| `slideshow` | Create a video from `inputs`; `duration_per_image`, `width`, `height`, and `fps`. |
|
| 311 |
+
| `watermark_image` | Watermark an image; `position`, `opacity`, `watermark_scale`, and output `format`. |
|
| 312 |
+
| `resize_image` | Resize with `width`, `height`, `fit`, and output `format`. |
|
| 313 |
+
|
| 314 |
+
| Whisper tool | Purpose and principal options |
|
| 315 |
+
|---|---|
|
| 316 |
+
| `transcribe` | Transcribe speech; optional `model`, `language`, `output_format`, `beam_size`, and `vad_filter`. |
|
| 317 |
+
| `translate` | Translate speech to English with the same options. |
|
| 318 |
+
| `detect_language` | Detect spoken language using an optional `model`. |
|
| 319 |
+
| `generate_srt` | Generate SRT with optional `model`, `language`, and `task`. |
|
| 320 |
+
| `generate_vtt` | Generate WebVTT with optional `model`, `language`, and `task`. |
|
| 321 |
+
| `generate_json` | Generate JSON transcript segments with optional `model`, `language`, and `task`. |
|
| 322 |
+
|
| 323 |
+
| yt-dlp tool | Purpose and principal options |
|
| 324 |
+
|---|---|
|
| 325 |
+
| `download_video` | Download best video; optional yt-dlp `format_selector`. |
|
| 326 |
+
| `download_audio` | Download/convert audio; `audio_format=mp3` and optional `format_selector`. |
|
| 327 |
+
| `video_metadata` | Extract platform metadata without downloading media. |
|
| 328 |
+
| `playlist_metadata` | Extract flat playlist metadata, bounded by `max_entries` (1–1000). |
|
| 329 |
+
| `list_formats` | List normalized available audio/video formats. |
|
| 330 |
+
|
| 331 |
+
| FFprobe tool | Purpose |
|
| 332 |
+
|---|---|
|
| 333 |
+
| `probe_media` | Complete normalized metadata. |
|
| 334 |
+
| `probe_video_metadata` | Video codec, dimensions, FPS, rotation, duration, and streams. |
|
| 335 |
+
| `audio_metadata` | Audio streams, duration, and bitrate. |
|
| 336 |
+
| `stream_info` | Video, audio, and subtitle stream summaries. |
|
| 337 |
+
| `container_info` | Container, tags, creation date, size, bitrate, and duration. |
|
| 338 |
+
|
| 339 |
+
MCP tool names must be unique. The yt-dlp metadata tool retains the requested `video_metadata` name; the FFprobe view is exposed as `probe_video_metadata` to disambiguate it.
|
| 340 |
+
|
| 341 |
+
| Utility tool | Purpose |
|
| 342 |
+
|---|---|
|
| 343 |
+
| `health` | Application and dependency health without loading Whisper. |
|
| 344 |
+
| `cleanup_temp` | Run the existing expired-workspace cleanup pass. |
|
| 345 |
+
| `disk_usage` | Capacity and use for temp and output storage. |
|
| 346 |
+
| `system_info` | CPU, memory, Python, platform, and process details. |
|
| 347 |
+
| `supported_operations` | Tool names grouped by domain. |
|
| 348 |
+
| `supported_formats` | Media, transcription, model, and yt-dlp formats. |
|
| 349 |
+
| `ffmpeg_version` | Installed FFmpeg version banner. |
|
| 350 |
+
| `whisper_models` | Allowed/default models and CPU compute configuration. |
|
| 351 |
+
| `yt_dlp_version` | Installed yt-dlp package version. |
|
| 352 |
+
|
| 353 |
+
| Template tool | Purpose |
|
| 354 |
+
|---|---|
|
| 355 |
+
| `list_templates` | Discover all dynamically loaded template versions, optionally filtered by category. |
|
| 356 |
+
| `template_details` | Return complete metadata and pipeline documentation for `id`, `id@version`, or `id@latest`. |
|
| 357 |
+
| `run_template` | Resolve one or more media inputs and execute a versioned YAML workflow. |
|
| 358 |
+
| `template_categories` | Return categories discovered from loaded YAML templates. |
|
| 359 |
+
|
| 360 |
+
### Available MCP resources
|
| 361 |
+
|
| 362 |
+
Resources return structured JSON, including a safe `success` indicator:
|
| 363 |
+
|
| 364 |
+
| Resource URI | Contents |
|
| 365 |
+
|---|---|
|
| 366 |
+
| `media://operations` | Registered operations grouped by video, audio, image, Whisper, yt-dlp, probe, templates, and system. |
|
| 367 |
+
| `media://formats` | Supported containers, transcription formats, models, and audio download formats. |
|
| 368 |
+
| `media://codecs` | Installed FFmpeg codecs and encode/decode capabilities. |
|
| 369 |
+
| `media://health` | Application version and dependency availability. |
|
| 370 |
+
| `media://configuration` | Non-secret runtime limits and paths. |
|
| 371 |
+
| `media://version` | Application, MCP SDK, yt-dlp, and faster-whisper versions. |
|
| 372 |
+
|
| 373 |
+
### Available MCP prompts
|
| 374 |
+
|
| 375 |
+
| Prompt | Workflow |
|
| 376 |
+
|---|---|
|
| 377 |
+
| `compress_for_social_media` | Probe, resize as appropriate, and call `compress_video`. |
|
| 378 |
+
| `youtube_to_mp3` | Call `download_audio` as MP3. |
|
| 379 |
+
| `download_and_transcribe` | Download audio, then call `transcribe` on its managed output. |
|
| 380 |
+
| `generate_subtitles` | Call `generate_srt` or `generate_vtt`. |
|
| 381 |
+
| `extract_audio` | Call the `extract_audio` tool in the requested format. |
|
| 382 |
+
| `make_thumbnail` | Call `generate_thumbnail` at a timestamp. |
|
| 383 |
+
| `probe_media` | Call `probe_media` and summarize streams/container data. |
|
| 384 |
+
| `instagram_reel` | Probe, resize to 1080×1920, then compress. |
|
| 385 |
+
| `tiktok_video` | Resize vertically and compress for TikTok. |
|
| 386 |
+
| `podcast_audio` | Normalize to -16 LUFS and optionally convert. |
|
| 387 |
+
|
| 388 |
+
### Example MCP calls
|
| 389 |
+
|
| 390 |
+
AI clients construct the JSON-RPC envelope automatically. The tool argument payloads are:
|
| 391 |
+
|
| 392 |
+
Compress a remote video:
|
| 393 |
+
|
| 394 |
+
```json
|
| 395 |
+
{
|
| 396 |
+
"name": "compress_video",
|
| 397 |
+
"arguments": {
|
| 398 |
+
"input": {"url": "https://cdn.example.com/input.mp4"},
|
| 399 |
+
"crf": 28,
|
| 400 |
+
"preset": "veryfast",
|
| 401 |
+
"format": "mp4",
|
| 402 |
+
"max_width": 1280
|
| 403 |
+
}
|
| 404 |
+
}
|
| 405 |
+
```
|
| 406 |
+
|
| 407 |
+
Transcribe Base64 audio:
|
| 408 |
+
|
| 409 |
+
```json
|
| 410 |
+
{
|
| 411 |
+
"name": "transcribe",
|
| 412 |
+
"arguments": {
|
| 413 |
+
"input": {
|
| 414 |
+
"base64": "data:audio/mpeg;base64,SUQz...",
|
| 415 |
+
"filename": "meeting.mp3",
|
| 416 |
+
"mime_type": "audio/mpeg"
|
| 417 |
+
},
|
| 418 |
+
"model": "small",
|
| 419 |
+
"output_format": "json"
|
| 420 |
+
}
|
| 421 |
+
}
|
| 422 |
+
```
|
| 423 |
+
|
| 424 |
+
Download a YouTube video:
|
| 425 |
+
|
| 426 |
+
```json
|
| 427 |
+
{
|
| 428 |
+
"name": "download_video",
|
| 429 |
+
"arguments": {
|
| 430 |
+
"url": "https://www.youtube.com/watch?v=VIDEO_ID"
|
| 431 |
+
}
|
| 432 |
+
}
|
| 433 |
+
```
|
| 434 |
+
|
| 435 |
+
Generate subtitles:
|
| 436 |
+
|
| 437 |
+
```json
|
| 438 |
+
{
|
| 439 |
+
"name": "generate_srt",
|
| 440 |
+
"arguments": {
|
| 441 |
+
"input": {"url": "https://cdn.example.com/interview.mp4"},
|
| 442 |
+
"language": "en",
|
| 443 |
+
"task": "transcribe"
|
| 444 |
+
}
|
| 445 |
+
}
|
| 446 |
+
```
|
| 447 |
+
|
| 448 |
+
Probe media:
|
| 449 |
+
|
| 450 |
+
```json
|
| 451 |
+
{
|
| 452 |
+
"name": "probe_media",
|
| 453 |
+
"arguments": {
|
| 454 |
+
"input": {"url": "https://cdn.example.com/input.mp4"}
|
| 455 |
+
}
|
| 456 |
+
}
|
| 457 |
+
```
|
| 458 |
+
|
| 459 |
+
## Enterprise Template Engine
|
| 460 |
+
|
| 461 |
+
The Template Engine is a versioned orchestration layer above the existing media operations. At startup, `TemplateLoader` recursively scans `TEMPLATE_DIR` for `.yaml` and `.yml` files, parses them with `yaml.safe_load_all`, validates every definition, and builds an immutable `TemplateRegistry`. REST and MCP resolve media through the same `InputResolver`, then `TemplateExecutor` chains allow-listed operation functions through `OperationExecutor`:
|
| 462 |
+
|
| 463 |
+
```text
|
| 464 |
+
REST /v1/templates/run ─┐
|
| 465 |
+
├─ InputResolver → Template Registry/Validator
|
| 466 |
+
MCP run_template ──────┘ │
|
| 467 |
+
▼
|
| 468 |
+
parameter substitution + pipeline
|
| 469 |
+
│
|
| 470 |
+
▼
|
| 471 |
+
existing operation functions and services
|
| 472 |
+
```
|
| 473 |
+
|
| 474 |
+
Intermediate artifacts stay inside `TEMP_DIR/<request-id>/outputs`; only the final result is published. Every generated media artifact is FFprobed before the next step when applicable. Logs include the template ID/version, resolved parameters, request ID, operations, wall and CPU time, memory, and output bytes. Existing FFmpeg logging still records commands and bounded stdout/stderr.
|
| 475 |
+
|
| 476 |
+
### Template folders and built-ins
|
| 477 |
+
|
| 478 |
+
```text
|
| 479 |
+
app/templates/
|
| 480 |
+
├── __init__.py
|
| 481 |
+
├── loader.py
|
| 482 |
+
├── registry.py
|
| 483 |
+
├── executor.py
|
| 484 |
+
├── schema.py
|
| 485 |
+
├── validator.py
|
| 486 |
+
├── models.py
|
| 487 |
+
└── categories/
|
| 488 |
+
├── social/
|
| 489 |
+
├── faceless/
|
| 490 |
+
├── motivation/
|
| 491 |
+
├── lyrics/
|
| 492 |
+
├── podcast/
|
| 493 |
+
├── subtitles/
|
| 494 |
+
├── youtube/
|
| 495 |
+
├── conversion/
|
| 496 |
+
├── branding/
|
| 497 |
+
├── utility/
|
| 498 |
+
└── custom/
|
| 499 |
+
```
|
| 500 |
+
|
| 501 |
+
The distribution contains 71 versioned workflows:
|
| 502 |
+
|
| 503 |
+
| Category | Templates |
|
| 504 |
+
|---|---|
|
| 505 |
+
| Social | `youtube_shorts`, `tiktok_hd`, `facebook_reel`, `instagram_reel`, `linkedin_video`, `twitter_video`, `whatsapp_status` |
|
| 506 |
+
| Faceless | `reddit_story`, `ai_story`, `movie_recap`, `history_short`, `true_crime`, `did_you_know`, `top10_video`, `facts_video`, `book_summary`, `finance_short`, `crypto_news`, `tech_news` |
|
| 507 |
+
| Motivation | `motivational_video`, `morning_motivation`, `business_motivation`, `gym_motivation`, `success_quotes`, `stoic_quotes`, `daily_quotes`, `affirmations` |
|
| 508 |
+
| Lyrics | `lyrics_basic`, `karaoke`, `spotify_style`, `cinematic_lyrics`, `neon_lyrics`, `music_video` |
|
| 509 |
+
| Podcast | `podcast_video`, `podcast_short`, `audiogram`, `waveform_video` |
|
| 510 |
+
| Subtitles/AI | `auto_subtitles`, `translate_video`, `transcribe`, `transcribe_srt`, `transcribe_vtt`, `transcribe_json`, `youtube_to_transcript` |
|
| 511 |
+
| YouTube | `youtube_to_mp3`, `youtube_to_audio`, `youtube_to_shorts`, `youtube_to_podcast`, `download_only` |
|
| 512 |
+
| Branding | `company_branding`, `creator_branding`, `watermark`, `intro_outro`, `logo_animation` |
|
| 513 |
+
| Conversion | `mp4`, `mov`, `avi`, `webm`, `gif`, `mp3`, `wav`, `aac`, `flac` |
|
| 514 |
+
| Utility | `thumbnail_pack`, `extract_frames`, `extract_audio`, `merge_videos`, `concat_videos`, `compress_max`, `compress_balanced`, `compress_mobile` |
|
| 515 |
+
|
| 516 |
+
Faceless and motivation templates format already assembled source media; they do not pretend to generate narration, images, or copyrighted source content. Lyrics templates consume supplied timed subtitle files. `waveform_video` consumes a pre-rendered waveform artwork image plus audio, reusing the existing image/video and audio replacement operations.
|
| 517 |
+
|
| 518 |
+
### Template schema and parameters
|
| 519 |
+
|
| 520 |
+
Each template includes the required metadata (`id`, `name`, `description`, `category`, `author`, `version`, `tags`, `estimated_runtime`, `supported_inputs`, and `supported_outputs`), parameter documentation, at least one pipeline operation, an output contract, and examples:
|
| 521 |
+
|
| 522 |
+
```yaml
|
| 523 |
+
id: instagram_reel_custom
|
| 524 |
+
name: Instagram Reel Custom
|
| 525 |
+
category: custom
|
| 526 |
+
description: Resize and compress video for a vertical Instagram Reel.
|
| 527 |
+
author: Your Team
|
| 528 |
+
version: 1
|
| 529 |
+
tags: [instagram, vertical]
|
| 530 |
+
estimated_runtime: medium
|
| 531 |
+
supported_inputs: [video, url, ytdlp]
|
| 532 |
+
supported_outputs: [mp4]
|
| 533 |
+
|
| 534 |
+
parameters:
|
| 535 |
+
crf:
|
| 536 |
+
type: integer
|
| 537 |
+
default: 23
|
| 538 |
+
minimum: 0
|
| 539 |
+
maximum: 51
|
| 540 |
+
width:
|
| 541 |
+
type: integer
|
| 542 |
+
default: 1080
|
| 543 |
+
minimum: 2
|
| 544 |
+
|
| 545 |
+
pipeline:
|
| 546 |
+
- operation: resize
|
| 547 |
+
width: "{{ width }}"
|
| 548 |
+
height: 1920
|
| 549 |
+
fit: cover
|
| 550 |
+
- operation: fps
|
| 551 |
+
value: 30
|
| 552 |
+
- operation: compress
|
| 553 |
+
crf: "{{ crf }}"
|
| 554 |
+
preset: veryfast
|
| 555 |
+
format: mp4
|
| 556 |
+
|
| 557 |
+
output:
|
| 558 |
+
format: mp4
|
| 559 |
+
|
| 560 |
+
examples:
|
| 561 |
+
- input: {url: "https://example.com/input.mp4"}
|
| 562 |
+
parameters: {crf: 23, width: 1080}
|
| 563 |
+
```
|
| 564 |
+
|
| 565 |
+
Parameter types are `string`, `integer`, `number`, `boolean`, `array`, and `object`. Definitions can use `required`, `default`, `enum`, `minimum`, `maximum`, `min_length`, and `max_length`. Unknown parameters and type coercion are rejected: the string `"23"` is not accepted for an integer parameter.
|
| 566 |
+
|
| 567 |
+
Variables use only `{{ parameter_name }}` syntax—expressions and executable template code are not supported. When a YAML value consists only of a variable, substitution preserves the declared type, so an integer remains an integer and a boolean can control `when`:
|
| 568 |
+
|
| 569 |
+
```yaml
|
| 570 |
+
parameters:
|
| 571 |
+
add_logo: {type: boolean, default: false}
|
| 572 |
+
pipeline:
|
| 573 |
+
- operation: watermark_video
|
| 574 |
+
when: "{{ add_logo }}"
|
| 575 |
+
inputs: [current, original:1]
|
| 576 |
+
```
|
| 577 |
+
|
| 578 |
+
Multiple-input and multi-artifact workflows can select `current`, `original`, `original:N`, `originals`, or `artifact:name`. A step can save its result for a later step:
|
| 579 |
+
|
| 580 |
+
```yaml
|
| 581 |
+
pipeline:
|
| 582 |
+
- operation: transcribe
|
| 583 |
+
output_format: srt
|
| 584 |
+
save_as: captions
|
| 585 |
+
- operation: burn_subtitles
|
| 586 |
+
inputs: [original:0, artifact:captions]
|
| 587 |
+
```
|
| 588 |
+
|
| 589 |
+
Supported YAML operation names are allow-listed aliases over existing implementations. They cover video compression/resizing/cropping/trimming/rotation/merging/concatenation/conversion/overlays/subtitles/effects, audio extraction/conversion/normalization/trimming/merging/filters, image conversion/composition, Whisper `transcribe`/`translate`, and resolver-backed `download`. No YAML value is used as a command name or shell string.
|
| 590 |
+
|
| 591 |
+
### Validation and versioning
|
| 592 |
+
|
| 593 |
+
The application refuses to start when a configured YAML file has invalid syntax, missing metadata, duplicate ID/version, invalid defaults, undeclared variables, unsafe selectors, unsupported operations, or an output format outside `supported_outputs`. Runtime parameters are validated again before any operation runs.
|
| 594 |
+
|
| 595 |
+
References support stable version selection:
|
| 596 |
+
|
| 597 |
+
- `youtube_shorts` resolves the highest installed version.
|
| 598 |
+
- `youtube_shorts@latest` explicitly resolves the highest installed version.
|
| 599 |
+
- `youtube_shorts@1` remains pinned to version 1 when version 2 is added.
|
| 600 |
+
|
| 601 |
+
Keep old YAML documents when introducing a new version so existing automations remain reproducible. A YAML file may contain one template, multiple `---` documents, or a top-level `templates` list.
|
| 602 |
+
|
| 603 |
+
### Template REST API
|
| 604 |
+
|
| 605 |
+
| Method | Endpoint | Purpose |
|
| 606 |
+
|---|---|---|
|
| 607 |
+
| `GET` | `/v1/templates` | List all versions with parameters, metadata, and examples; optional `?category=social`. |
|
| 608 |
+
| `GET` | `/v1/templates/categories` | List categories discovered from YAML. |
|
| 609 |
+
| `GET` | `/v1/templates/{reference}` | Get full details and pipeline for an ID/version reference. |
|
| 610 |
+
| `POST` | `/v1/templates/run` | Resolve media and execute a template. |
|
| 611 |
+
|
| 612 |
+
List and inspect:
|
| 613 |
+
|
| 614 |
+
```bash
|
| 615 |
+
curl http://localhost:7860/v1/templates
|
| 616 |
+
curl http://localhost:7860/v1/templates/instagram_reel@1
|
| 617 |
+
```
|
| 618 |
+
|
| 619 |
+
Run with a direct or yt-dlp-supported URL:
|
| 620 |
+
|
| 621 |
+
```bash
|
| 622 |
+
curl -X POST http://localhost:7860/v1/templates/run \
|
| 623 |
+
-H 'Content-Type: application/json' \
|
| 624 |
+
-d '{
|
| 625 |
+
"template":"instagram_reel@latest",
|
| 626 |
+
"input":{"url":"https://example.com/input.mp4"},
|
| 627 |
+
"parameters":{"crf":21}
|
| 628 |
+
}'
|
| 629 |
+
```
|
| 630 |
+
|
| 631 |
+
Run with multipart media; `parameters` is a JSON form field:
|
| 632 |
+
|
| 633 |
+
```bash
|
| 634 |
+
curl -X POST http://localhost:7860/v1/templates/run \
|
| 635 |
+
-F 'file=@input.mp4' \
|
| 636 |
+
-F 'template=youtube_shorts@1' \
|
| 637 |
+
-F 'parameters={"crf":23,"max_duration":60}'
|
| 638 |
+
```
|
| 639 |
+
|
| 640 |
+
Multiple-input templates collect uploaded files in multipart order or use an `inputs` JSON array. For `company_branding`, input 0 is video and input 1 is the logo; for `intro_outro`, send intro, main, and outro in that order.
|
| 641 |
+
|
| 642 |
+
### Template MCP examples
|
| 643 |
+
|
| 644 |
+
Discover templates:
|
| 645 |
+
|
| 646 |
+
```json
|
| 647 |
+
{"name":"list_templates","arguments":{"category":"social"}}
|
| 648 |
+
```
|
| 649 |
+
|
| 650 |
+
Inspect a pinned version:
|
| 651 |
+
|
| 652 |
+
```json
|
| 653 |
+
{"name":"template_details","arguments":{"template":"youtube_shorts@1"}}
|
| 654 |
+
```
|
| 655 |
+
|
| 656 |
+
Execute a URL template:
|
| 657 |
+
|
| 658 |
+
```json
|
| 659 |
+
{
|
| 660 |
+
"name": "run_template",
|
| 661 |
+
"arguments": {
|
| 662 |
+
"template": "youtube_to_mp3@latest",
|
| 663 |
+
"input": {"url": "https://www.youtube.com/watch?v=VIDEO_ID"},
|
| 664 |
+
"parameters": {}
|
| 665 |
+
}
|
| 666 |
+
}
|
| 667 |
+
```
|
| 668 |
+
|
| 669 |
+
For multiple inputs, omit `input` and provide `inputs`. MCP returns the same structured request ID, `output_file`, `download_url`, metadata, and safe error contract as the other tools.
|
| 670 |
+
|
| 671 |
+
### Template n8n examples
|
| 672 |
+
|
| 673 |
+
For an n8n URL workflow, configure an **HTTP Request** node with `POST`, JSON body, and `/v1/templates/run`:
|
| 674 |
+
|
| 675 |
+
```javascript
|
| 676 |
+
{
|
| 677 |
+
"template": "compress_mobile@1",
|
| 678 |
+
"input": {"url": "{{$json.media_url}}"},
|
| 679 |
+
"parameters": {"crf": 28}
|
| 680 |
+
}
|
| 681 |
+
```
|
| 682 |
+
|
| 683 |
+
For n8n binary JSON input:
|
| 684 |
+
|
| 685 |
+
```javascript
|
| 686 |
+
{
|
| 687 |
+
"template": "mp3@1",
|
| 688 |
+
"input": {
|
| 689 |
+
"binary": {
|
| 690 |
+
"data": "{{$binary.audio.data}}",
|
| 691 |
+
"fileName": "{{$binary.audio.fileName}}",
|
| 692 |
+
"mimeType": "{{$binary.audio.mimeType}}"
|
| 693 |
+
}
|
| 694 |
+
},
|
| 695 |
+
"parameters": {}
|
| 696 |
+
}
|
| 697 |
+
```
|
| 698 |
+
|
| 699 |
+
For large n8n files, send the binary property as multipart under any file field, plus text fields `template` and JSON `parameters`. The existing resolver supports `binary.data`, `binary.file`, `binary.video`, `binary.audio`, Base64/data URIs, raw bytes, direct HTTP(S), and automatic yt-dlp selection for templates exactly as it does for normal operations.
|
| 700 |
+
|
| 701 |
+
### Adding custom templates
|
| 702 |
+
|
| 703 |
+
1. Add a `.yaml` or `.yml` file below `app/templates/categories/custom/` or a mounted `TEMPLATE_DIR`.
|
| 704 |
+
2. Choose a unique `id` and positive integer `version`; never replace an old version used by automation.
|
| 705 |
+
3. Declare strict parameter types/defaults and complete metadata.
|
| 706 |
+
4. Compose allow-listed existing operations. Use `save_as` and input selectors for branched workflows.
|
| 707 |
+
5. Restart the process. Startup scanning automatically validates, registers, exposes, and documents the template through REST and MCP—no Python registration change is required.
|
| 708 |
+
6. If a genuinely new media primitive is needed, implement it once in `app/operations`, validate its arguments, add it to `OPERATION_BINDINGS`, and then reference it from any number of YAML workflows.
|
| 709 |
+
|
| 710 |
+
Set `TEMPLATE_DIR` to an external mounted directory to operate a private workflow catalog without modifying application code. Invalid catalogs fail closed and are never partially executed.
|
| 711 |
+
|
| 712 |
+
## Input contract
|
| 713 |
+
|
| 714 |
+
All media-processing and probe endpoints use the same resolver. Operation parameters can be top-level JSON fields, multipart text fields, an `options` JSON object, or query parameters. Body/form values take precedence over query parameters.
|
| 715 |
+
|
| 716 |
+
### Multipart file
|
| 717 |
+
|
| 718 |
+
```bash
|
| 719 |
+
curl -X POST http://localhost:7860/v1/video/compress \
|
| 720 |
+
-F 'file=@input.mp4' \
|
| 721 |
+
-F 'crf=28' \
|
| 722 |
+
-F 'preset=veryfast'
|
| 723 |
+
```
|
| 724 |
+
|
| 725 |
+
Multiple-input operations accept repeated or differently named file fields; every uploaded file part is collected in form order.
|
| 726 |
+
|
| 727 |
+
```bash
|
| 728 |
+
curl -X POST http://localhost:7860/v1/video/watermark \
|
| 729 |
+
-F 'video=@input.mp4' \
|
| 730 |
+
-F 'watermark=@logo.png' \
|
| 731 |
+
-F 'position=bottom-right' \
|
| 732 |
+
-F 'opacity=0.7'
|
| 733 |
+
```
|
| 734 |
+
|
| 735 |
+
### JSON URL
|
| 736 |
+
|
| 737 |
+
```bash
|
| 738 |
+
curl -X POST http://localhost:7860/v1/video/resize \
|
| 739 |
+
-H 'Content-Type: application/json' \
|
| 740 |
+
-d '{"url":"https://cdn.example.com/video.mp4","width":1280,"height":720,"fit":"contain"}'
|
| 741 |
+
```
|
| 742 |
+
|
| 743 |
+
URLs supported by a non-generic yt-dlp extractor automatically use yt-dlp. This covers YouTube, TikTok, Instagram, Facebook, X/Twitter, Reddit, Vimeo, SoundCloud, and the other sites supported by the installed yt-dlp release. Ordinary direct HTTP(S) media is downloaded in bounded chunks. Redirect targets are revalidated, credentials in URLs are rejected, and private/loopback/link-local addresses are blocked unless `ALLOW_PRIVATE_URLS=true`.
|
| 744 |
+
|
| 745 |
+
For multiple remote inputs:
|
| 746 |
+
|
| 747 |
+
```json
|
| 748 |
+
{
|
| 749 |
+
"inputs": [
|
| 750 |
+
{"url": "https://cdn.example.com/part-1.mp4"},
|
| 751 |
+
{"url": "https://cdn.example.com/part-2.mp4"}
|
| 752 |
+
],
|
| 753 |
+
"width": 1280,
|
| 754 |
+
"height": 720
|
| 755 |
+
}
|
| 756 |
+
```
|
| 757 |
+
|
| 758 |
+
### JSON Base64 and n8n binary
|
| 759 |
+
|
| 760 |
+
```json
|
| 761 |
+
{
|
| 762 |
+
"base64": "data:audio/wav;base64,UklGR...",
|
| 763 |
+
"filename": "speech.wav",
|
| 764 |
+
"format": "mp3"
|
| 765 |
+
}
|
| 766 |
+
```
|
| 767 |
+
|
| 768 |
+
The resolver recognizes n8n properties named `binary.data`, `binary.file`, `binary.video`, `binary.audio`, and any other key under `binary`:
|
| 769 |
+
|
| 770 |
+
```json
|
| 771 |
+
{
|
| 772 |
+
"binary": {
|
| 773 |
+
"video": {
|
| 774 |
+
"data": "AAAAHGZ0eXBpc29t...",
|
| 775 |
+
"fileName": "clip.mp4",
|
| 776 |
+
"mimeType": "video/mp4"
|
| 777 |
+
}
|
| 778 |
+
},
|
| 779 |
+
"crf": 26
|
| 780 |
+
}
|
| 781 |
+
```
|
| 782 |
+
|
| 783 |
+
### Raw streamed bytes
|
| 784 |
+
|
| 785 |
+
```bash
|
| 786 |
+
curl -X POST 'http://localhost:7860/v1/probe' \
|
| 787 |
+
-H 'Content-Type: application/octet-stream' \
|
| 788 |
+
-H 'X-Filename: input.mp4' \
|
| 789 |
+
--data-binary '@input.mp4'
|
| 790 |
+
```
|
| 791 |
+
|
| 792 |
+
Raw and multipart uploads are written to disk in 1 MiB chunks. Downloads are also streamed; Base64 is size-checked before and after decoding.
|
| 793 |
+
|
| 794 |
+
## Endpoints
|
| 795 |
+
|
| 796 |
+
All processing routes use `POST`. Health and output downloads use `GET`.
|
| 797 |
+
|
| 798 |
+
| Group | Endpoints |
|
| 799 |
+
|---|---|
|
| 800 |
+
| System | `/health`, `/v1/probe`, `/v1/media/{request_id}/{filename}` |
|
| 801 |
+
| Video basics | `/v1/video/compress`, `resize`, `crop`, `trim`, `rotate`, `reverse`, `convert`, `merge`, `concat` |
|
| 802 |
+
| Video composition | `/v1/video/overlay`, `watermark`, `replace-audio`, `subtitles/burn`, `subtitles/soft` |
|
| 803 |
+
| Video outputs | `/v1/video/frames`, `gif`, `thumbnail`, `remove-audio`, `mute` |
|
| 804 |
+
| Video timing/quality | `/v1/video/speed`, `speed-up`, `slow-motion`, `fps`, `bitrate`, `scale`, `pad`, `blur`, `sharpen`, `denoise`, `normalize` |
|
| 805 |
+
| Audio | `/v1/audio/extract`, `convert`, `normalize`, `trim`, `merge`, `concat`, `fade`, `volume`, `remove-silence`, `noise-reduction` |
|
| 806 |
+
| Image | `/v1/image/resize`, `crop`, `convert`, `slideshow`, `sequence`, `video`, `watermark`, `overlay` |
|
| 807 |
+
| yt-dlp | `/v1/ytdlp/download` |
|
| 808 |
+
| Whisper | `/v1/whisper/transcribe`, `subtitles`, `detect-language` |
|
| 809 |
+
| Templates | `GET /v1/templates`, `GET /v1/templates/categories`, `GET /v1/templates/{reference}`, `POST /v1/templates/run` |
|
| 810 |
+
|
| 811 |
+
Common video parameters include `format`, `width`, `height`, `fit`, `crf`, `preset`, `start`, `end`, `duration`, `fps`, `bitrate`, `factor`, `position`, and `opacity`. Swagger lists every route; the defaults and bounds are enforced by the operation that consumes each field.
|
| 812 |
+
|
| 813 |
+
### Probe metadata
|
| 814 |
+
|
| 815 |
+
`POST /v1/probe` returns normalized duration, resolution, FPS, bitrate, primary codec, video/audio/subtitle stream summaries, rotation, container, creation date, size, and tags. The same probe data is included under `metadata.inputs` for processing responses.
|
| 816 |
+
|
| 817 |
+
```bash
|
| 818 |
+
curl -X POST http://localhost:7860/v1/probe -F 'file=@input.mp4'
|
| 819 |
+
```
|
| 820 |
+
|
| 821 |
+
### yt-dlp
|
| 822 |
+
|
| 823 |
+
`mode` is `video`, `audio`, `thumbnail`, or `metadata`. `format` accepts a yt-dlp format selector. Audio supports `mp3`, `m4a`, `wav`, `opus`, and `flac`.
|
| 824 |
+
|
| 825 |
+
```bash
|
| 826 |
+
curl -X POST http://localhost:7860/v1/ytdlp/download \
|
| 827 |
+
-H 'Content-Type: application/json' \
|
| 828 |
+
-d '{"url":"https://www.youtube.com/watch?v=VIDEO_ID","mode":"audio","audio_format":"mp3"}'
|
| 829 |
+
```
|
| 830 |
+
|
| 831 |
+
```bash
|
| 832 |
+
curl -X POST http://localhost:7860/v1/ytdlp/download \
|
| 833 |
+
-H 'Content-Type: application/json' \
|
| 834 |
+
-d '{"url":"https://vimeo.com/VIDEO_ID","mode":"metadata"}'
|
| 835 |
+
```
|
| 836 |
+
|
| 837 |
+
### faster-whisper
|
| 838 |
+
|
| 839 |
+
Models: `tiny`, `base`, `small`, `medium`, and `large-v3`. The default is `small`. Models load only on their first request and always use `device="cpu"` and `compute_type="int8"`. Tasks are `transcribe` and `translate`; omit `language` for automatic language detection. Formats are `txt`, `srt`, `vtt`, `json`, and `tsv`.
|
| 840 |
+
|
| 841 |
+
```bash
|
| 842 |
+
curl -X POST http://localhost:7860/v1/whisper/transcribe \
|
| 843 |
+
-F 'file=@meeting.mp3' \
|
| 844 |
+
-F 'model=small' \
|
| 845 |
+
-F 'task=transcribe' \
|
| 846 |
+
-F 'output_format=json' \
|
| 847 |
+
-F 'vad_filter=true'
|
| 848 |
+
```
|
| 849 |
+
|
| 850 |
+
```bash
|
| 851 |
+
curl -X POST http://localhost:7860/v1/whisper/subtitles \
|
| 852 |
+
-F 'file=@interview.mp4' \
|
| 853 |
+
-F 'task=translate' \
|
| 854 |
+
-F 'output_format=vtt'
|
| 855 |
+
```
|
| 856 |
+
|
| 857 |
+
## Responses and downloads
|
| 858 |
+
|
| 859 |
+
Processing responses are JSON:
|
| 860 |
+
|
| 861 |
+
```json
|
| 862 |
+
{
|
| 863 |
+
"success": true,
|
| 864 |
+
"request_id": "43b32630-9b32-4b27-b038-a39fbfd4b803",
|
| 865 |
+
"processing_time": 1.42,
|
| 866 |
+
"download_url": "/v1/media/43b32630-9b32-4b27-b038-a39fbfd4b803/compressed.mp4",
|
| 867 |
+
"metadata": {}
|
| 868 |
+
}
|
| 869 |
+
```
|
| 870 |
+
|
| 871 |
+
Failures never include Python exceptions or tracebacks:
|
| 872 |
+
|
| 873 |
+
```json
|
| 874 |
+
{
|
| 875 |
+
"success": false,
|
| 876 |
+
"request_id": "43b32630-9b32-4b27-b038-a39fbfd4b803",
|
| 877 |
+
"error": {
|
| 878 |
+
"code": "INVALID_INPUT",
|
| 879 |
+
"message": "The uploaded media is empty",
|
| 880 |
+
"details": null
|
| 881 |
+
}
|
| 882 |
+
}
|
| 883 |
+
```
|
| 884 |
+
|
| 885 |
+
Fetch `download_url` before `CLEANUP_MINUTES` expires. The download endpoint uses `StreamingResponse`, reads in 1 MiB chunks, and supplies `Content-Length` and `Content-Disposition`; output files are never loaded into RAM. Streaming file bodies are necessarily binary rather than JSON envelopes.
|
| 886 |
+
|
| 887 |
+
Set `BASE_URL=https://<owner>-<space>.hf.space` when clients require absolute download URLs. If it is empty, URLs are relative, which works well in n8n when joined to the request host.
|
| 888 |
+
|
| 889 |
+
## n8n recipes
|
| 890 |
+
|
| 891 |
+
### Multipart upload
|
| 892 |
+
|
| 893 |
+
In an **HTTP Request** node:
|
| 894 |
+
|
| 895 |
+
- Method: `POST`
|
| 896 |
+
- URL: `https://<space>.hf.space/v1/video/compress`
|
| 897 |
+
- Send Body: on
|
| 898 |
+
- Body Content Type: `Form-Data`
|
| 899 |
+
- Add a **n8n Binary File** parameter named `file`, selecting the incoming binary property (usually `data`)
|
| 900 |
+
- Add text parameters `crf=28` and `preset=veryfast`
|
| 901 |
+
|
| 902 |
+
Use a second **HTTP Request** node with `{{$json.download_url}}`, enable **Download**, and store its response as binary.
|
| 903 |
+
|
| 904 |
+
### JSON URL
|
| 905 |
+
|
| 906 |
+
Set Body Content Type to JSON:
|
| 907 |
+
|
| 908 |
+
```javascript
|
| 909 |
+
{
|
| 910 |
+
"url": "{{$json.media_url}}",
|
| 911 |
+
"width": 1280,
|
| 912 |
+
"height": 720,
|
| 913 |
+
"fit": "contain"
|
| 914 |
+
}
|
| 915 |
+
```
|
| 916 |
+
|
| 917 |
+
### n8n binary property as JSON Base64
|
| 918 |
+
|
| 919 |
+
When the upstream binary property is `data`, use an expression body:
|
| 920 |
+
|
| 921 |
+
```javascript
|
| 922 |
+
{
|
| 923 |
+
"binary": {
|
| 924 |
+
"data": {
|
| 925 |
+
"data": "{{$binary.data.data}}",
|
| 926 |
+
"fileName": "{{$binary.data.fileName}}",
|
| 927 |
+
"mimeType": "{{$binary.data.mimeType}}"
|
| 928 |
+
}
|
| 929 |
+
},
|
| 930 |
+
"format": "mp3"
|
| 931 |
+
}
|
| 932 |
+
```
|
| 933 |
+
|
| 934 |
+
For large files, prefer n8n's multipart binary option or raw binary body; JSON Base64 temporarily expands data by roughly 33%.
|
| 935 |
+
|
| 936 |
+
## Configuration
|
| 937 |
+
|
| 938 |
+
| Variable | Default | Purpose |
|
| 939 |
+
|---|---:|---|
|
| 940 |
+
| `TEMP_DIR` | `./temp` | Request workspaces and in-progress files |
|
| 941 |
+
| `OUTPUT_DIR` | `./outputs` | Published files served by download URLs |
|
| 942 |
+
| `TEMPLATE_DIR` | built-in `app/templates/categories` | Recursively scanned YAML workflow catalog |
|
| 943 |
+
| `MAX_UPLOAD_SIZE` | `1073741824` | Maximum bytes for each upload/download |
|
| 944 |
+
| `WHISPER_MODEL` | `small` | Default faster-whisper model |
|
| 945 |
+
| `CLEANUP_MINUTES` | `60` | TTL for request workspaces and outputs |
|
| 946 |
+
| `CLEANUP_INTERVAL_SECONDS` | `60` | Cleanup scan interval |
|
| 947 |
+
| `MAX_WORKERS` | `2` | Shared per-service CPU process/task concurrency |
|
| 948 |
+
| `LOG_LEVEL` | `INFO` | Structured log threshold |
|
| 949 |
+
| `MAX_DURATION_SECONDS` | `21600` | Maximum probed media duration |
|
| 950 |
+
| `MAX_RESOLUTION_PIXELS` | `33177600` | Maximum width × height (8K default) |
|
| 951 |
+
| `DOWNLOAD_TIMEOUT_SECONDS` | `300` | Remote download timeout; FFmpeg gets 4× this value |
|
| 952 |
+
| `ALLOW_PRIVATE_URLS` | `false` | Permit private/loopback URL downloads (normally unsafe) |
|
| 953 |
+
| `BASE_URL` | empty | Optional public origin for absolute download URLs |
|
| 954 |
+
| `FFMPEG_BINARY` | `ffmpeg` | FFmpeg executable name/path |
|
| 955 |
+
| `FFPROBE_BINARY` | `ffprobe` | FFprobe executable name/path |
|
| 956 |
+
|
| 957 |
+
## Logging and safety
|
| 958 |
+
|
| 959 |
+
Logs are one JSON object per line. Request completion and operation records include UUID, operation, input/output bytes, wall time, CPU percentage, RSS memory, status, and errors. FFmpeg records include the exact argument array, bounded stdout/stderr, exit status, and duration.
|
| 960 |
+
|
| 961 |
+
Safety controls include filename normalization, path containment checks, extension/MIME/size validation, duration and pixel limits, HTTP scheme and redirect validation, SSRF address filtering, bounded concurrency, bounded output capture, and subprocess arrays with `shell=False` semantics. User filenames never select output paths.
|
| 962 |
+
|
| 963 |
+
## Testing and quality checks
|
| 964 |
+
|
| 965 |
+
```bash
|
| 966 |
+
pip install -r requirements.txt -r requirements-dev.txt
|
| 967 |
+
pytest -q
|
| 968 |
+
ruff check app tests main.py
|
| 969 |
+
black --check app tests main.py
|
| 970 |
+
```
|
| 971 |
+
|
| 972 |
+
The suite covers health, Base64/n8n resolution, streamed URL download, real FFprobe metadata, a real FFmpeg conversion, lazy mocked Whisper transcription, MCP registration, YAML loading and versioning, parameter substitution, template execution, cleanup behavior, and safe error envelopes. Binary integration tests skip only when the respective system executable is absent.
|
| 973 |
+
|
| 974 |
+
## Extending the API
|
| 975 |
+
|
| 976 |
+
1. Add an async operation to the most relevant module in `app/operations`. Its inputs are `FFmpegService`, `Sequence[InputMedia]`, parsed parameters, and the request output directory; return an `OperationResult`.
|
| 977 |
+
2. Validate every parameter before building arguments. Pass an argument list to `FFmpegService`; never use a shell or concatenate a command string.
|
| 978 |
+
3. Register the operation in the appropriate API router with `operation_route`. The shared executor supplies input resolution, FFprobe validation, publication, metrics, errors, and cleanup.
|
| 979 |
+
4. To expose it over MCP, add a thin typed function to the matching `app/mcp/tools/` registration module and call `MCPRegistry.run_operation` with the same operation function. Do not build FFmpeg arguments in the MCP module.
|
| 980 |
+
5. Add unit coverage plus a small FFmpeg integration test when the operation changes media bytes, and update the expected MCP tool set when applicable.
|
| 981 |
+
|
| 982 |
+
This contract keeps new operations independent of multipart, URLs, Base64, n8n, storage, and response handling.
|
| 983 |
+
|
| 984 |
+
## Troubleshooting
|
| 985 |
+
|
| 986 |
+
- **Space is out of memory:** use `tiny`, `base`, or `small`; set `MAX_WORKERS=1`; do not launch multiple Uvicorn workers. `medium` and `large-v3` require substantially more RAM even with int8.
|
| 987 |
+
- **First transcription is slow:** the model is downloaded and initialized lazily. Use persistent Space storage or pre-warm with a short request after deployment.
|
| 988 |
+
- **FFmpeg reports incompatible streams during concat:** leave `stream_copy` false (the default), which normalizes dimensions and re-encodes. Enable it only for files with identical stream layout, codec, time base, and parameters.
|
| 989 |
+
- **Remote URL is rejected:** private and non-global addresses are blocked intentionally. Set `ALLOW_PRIVATE_URLS=true` only in a trusted network and never on a public Space.
|
| 990 |
+
- **Download URL returns 404:** the request TTL expired, the Space restarted without persistent storage, or the filename/request ID was changed. Download results promptly.
|
| 991 |
+
- **Upload receives 413/422:** increase `MAX_UPLOAD_SIZE` only after checking Space disk and RAM. Prefer multipart/raw streaming over Base64.
|
| 992 |
+
- **ImageMagick policy error:** media operations use FFmpeg for image transforms; ImageMagick is installed for extension use but is not required by the built-in image routes.
|
| 993 |
+
- **No audio after merging videos:** if any input lacks an audio stream, the merge deliberately emits video-only output instead of failing the whole request. Add silent audio before merging if a continuous audio track is required.
|
| 994 |
+
- **MCP client cannot connect:** use the trailing-slash URL `/mcp/`, select Streamable HTTP rather than legacy SSE, and verify `/health` first. Stdio clients must launch the command with the project directory as their working directory.
|
| 995 |
+
- **MCP output URL is relative:** set `BASE_URL` to the public Space origin. A managed `output_file` can also be passed directly into a later MCP call as `temp_path` before cleanup expires.
|
api/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
"""Compatibility exports for the canonical :mod:`app.api` package."""
|
api/audio.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
from app.api.audio import * # noqa
|
api/health.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
from app.api.health import * # noqa
|
api/image.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
from app.api.image import * # noqa
|
api/media.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
from app.api.media import * # noqa
|
api/probe.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
from app.api.probe import * # noqa
|
api/video.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
from app.api.video import * # noqa
|
api/whisper.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
from app.api.whisper import * # noqa
|
api/ytdlp.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
from app.api.ytdlp import * # noqa
|
app/__init__.py
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Enterprise Media Processing API package."""
|
| 2 |
+
|
| 3 |
+
__version__ = "1.0.0"
|
app/api/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
"""HTTP API routers."""
|
app/api/audio.py
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from fastapi import APIRouter, Request
|
| 4 |
+
|
| 5 |
+
from app.api.media import execute_operation
|
| 6 |
+
from app.core.response import SuccessResponse
|
| 7 |
+
from app.operations.compress import normalize_audio
|
| 8 |
+
from app.operations.concat import concat_audio
|
| 9 |
+
from app.operations.convert import convert_audio
|
| 10 |
+
from app.operations.extract_audio import (
|
| 11 |
+
extract_audio,
|
| 12 |
+
fade_audio,
|
| 13 |
+
noise_reduction,
|
| 14 |
+
remove_silence,
|
| 15 |
+
set_volume,
|
| 16 |
+
)
|
| 17 |
+
from app.operations.merge import merge_audio
|
| 18 |
+
from app.operations.trim import trim_audio
|
| 19 |
+
from app.services.media_service import Operation
|
| 20 |
+
|
| 21 |
+
router = APIRouter(prefix="/v1/audio", tags=["audio"])
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def operation_route(path: str, name: str, operation: Operation) -> None:
|
| 25 |
+
async def endpoint(request: Request) -> SuccessResponse:
|
| 26 |
+
return await execute_operation(request, name, operation)
|
| 27 |
+
|
| 28 |
+
endpoint.__name__ = name.replace(".", "_")
|
| 29 |
+
router.add_api_route(
|
| 30 |
+
path, endpoint, methods=["POST"], response_model=SuccessResponse, name=name
|
| 31 |
+
)
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
operation_route("/extract", "audio.extract", extract_audio)
|
| 35 |
+
operation_route("/convert", "audio.convert", convert_audio)
|
| 36 |
+
operation_route("/normalize", "audio.normalize", normalize_audio)
|
| 37 |
+
operation_route("/trim", "audio.trim", trim_audio)
|
| 38 |
+
operation_route("/merge", "audio.merge", merge_audio)
|
| 39 |
+
operation_route("/concat", "audio.concat", concat_audio)
|
| 40 |
+
operation_route("/fade", "audio.fade", fade_audio)
|
| 41 |
+
operation_route("/volume", "audio.volume", set_volume)
|
| 42 |
+
operation_route("/remove-silence", "audio.remove_silence", remove_silence)
|
| 43 |
+
operation_route("/noise-reduction", "audio.noise_reduction", noise_reduction)
|
app/api/health.py
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import importlib.util
|
| 4 |
+
import shutil
|
| 5 |
+
import time
|
| 6 |
+
|
| 7 |
+
from fastapi import APIRouter, Request
|
| 8 |
+
|
| 9 |
+
from app.core.response import SuccessResponse
|
| 10 |
+
|
| 11 |
+
router = APIRouter(tags=["health"])
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
async def _health(request: Request) -> SuccessResponse:
|
| 15 |
+
started = time.monotonic()
|
| 16 |
+
settings = request.app.state.container.settings
|
| 17 |
+
dependencies = {
|
| 18 |
+
"ffmpeg": shutil.which(settings.ffmpeg_binary) is not None,
|
| 19 |
+
"ffprobe": shutil.which(settings.ffprobe_binary) is not None,
|
| 20 |
+
"yt_dlp": importlib.util.find_spec("yt_dlp") is not None,
|
| 21 |
+
"faster_whisper": importlib.util.find_spec("faster_whisper") is not None,
|
| 22 |
+
}
|
| 23 |
+
return SuccessResponse(
|
| 24 |
+
request_id=request.state.request_id,
|
| 25 |
+
processing_time=round(time.monotonic() - started, 4),
|
| 26 |
+
metadata={
|
| 27 |
+
"status": "healthy",
|
| 28 |
+
"version": settings.app_version,
|
| 29 |
+
"dependencies": dependencies,
|
| 30 |
+
},
|
| 31 |
+
)
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
router.add_api_route("/health", _health, methods=["GET"], response_model=SuccessResponse)
|
| 35 |
+
router.add_api_route(
|
| 36 |
+
"/v1/health", _health, methods=["GET"], response_model=SuccessResponse, include_in_schema=False
|
| 37 |
+
)
|
app/api/image.py
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from fastapi import APIRouter, Request
|
| 4 |
+
|
| 5 |
+
from app.api.media import execute_operation
|
| 6 |
+
from app.core.response import SuccessResponse
|
| 7 |
+
from app.operations.concat import image_sequence, image_slideshow, image_to_video
|
| 8 |
+
from app.operations.convert import convert_image
|
| 9 |
+
from app.operations.crop import crop_image
|
| 10 |
+
from app.operations.resize import resize_image
|
| 11 |
+
from app.operations.watermark import overlay_image, watermark_image
|
| 12 |
+
from app.services.media_service import Operation
|
| 13 |
+
|
| 14 |
+
router = APIRouter(prefix="/v1/image", tags=["image"])
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
def operation_route(path: str, name: str, operation: Operation) -> None:
|
| 18 |
+
async def endpoint(request: Request) -> SuccessResponse:
|
| 19 |
+
return await execute_operation(request, name, operation)
|
| 20 |
+
|
| 21 |
+
endpoint.__name__ = name.replace(".", "_")
|
| 22 |
+
router.add_api_route(
|
| 23 |
+
path, endpoint, methods=["POST"], response_model=SuccessResponse, name=name
|
| 24 |
+
)
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
operation_route("/resize", "image.resize", resize_image)
|
| 28 |
+
operation_route("/crop", "image.crop", crop_image)
|
| 29 |
+
operation_route("/convert", "image.convert", convert_image)
|
| 30 |
+
operation_route("/slideshow", "image.slideshow", image_slideshow)
|
| 31 |
+
operation_route("/sequence", "image.sequence", image_sequence)
|
| 32 |
+
operation_route("/video", "image.video", image_to_video)
|
| 33 |
+
operation_route("/watermark", "image.watermark", watermark_image)
|
| 34 |
+
operation_route("/overlay", "image.overlay", overlay_image)
|
app/api/media.py
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from collections.abc import AsyncIterator
|
| 4 |
+
from pathlib import Path
|
| 5 |
+
|
| 6 |
+
import aiofiles
|
| 7 |
+
from fastapi import APIRouter, Request
|
| 8 |
+
from fastapi.responses import StreamingResponse
|
| 9 |
+
|
| 10 |
+
from app.core.response import SuccessResponse
|
| 11 |
+
from app.services.media_service import MediaProcessor, Operation
|
| 12 |
+
|
| 13 |
+
router = APIRouter(tags=["media"])
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
def get_processor(request: Request) -> MediaProcessor:
|
| 17 |
+
return request.app.state.container.processor
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
async def execute_operation(request: Request, name: str, operation: Operation) -> SuccessResponse:
|
| 21 |
+
request.state.operation = name
|
| 22 |
+
processor = get_processor(request)
|
| 23 |
+
resolved = await processor.resolver.resolve(request)
|
| 24 |
+
return await processor.run(resolved, name, operation)
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
async def stream_file(path: Path) -> AsyncIterator[bytes]:
|
| 28 |
+
async with aiofiles.open(path, "rb") as media:
|
| 29 |
+
while chunk := await media.read(1024 * 1024):
|
| 30 |
+
yield chunk
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
@router.get("/v1/media/{request_id}/{filename}", name="download_media")
|
| 34 |
+
async def download_media(request: Request, request_id: str, filename: str) -> StreamingResponse:
|
| 35 |
+
request.state.operation = "media.download"
|
| 36 |
+
container = request.app.state.container
|
| 37 |
+
path = container.cleanup.resolve_download(request_id, filename)
|
| 38 |
+
media_type = container.validator.infer_mime(path)
|
| 39 |
+
headers = {
|
| 40 |
+
"Content-Disposition": f'attachment; filename="{path.name}"',
|
| 41 |
+
"Content-Length": str(path.stat().st_size),
|
| 42 |
+
"X-Request-ID": request_id,
|
| 43 |
+
}
|
| 44 |
+
return StreamingResponse(stream_file(path), media_type=media_type, headers=headers)
|
app/api/probe.py
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from fastapi import APIRouter, Request
|
| 4 |
+
|
| 5 |
+
from app.api.media import get_processor
|
| 6 |
+
from app.core.response import SuccessResponse
|
| 7 |
+
|
| 8 |
+
router = APIRouter(prefix="/v1", tags=["probe"])
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
@router.post("/probe", response_model=SuccessResponse)
|
| 12 |
+
async def probe(request: Request) -> SuccessResponse:
|
| 13 |
+
request.state.operation = "probe"
|
| 14 |
+
processor = get_processor(request)
|
| 15 |
+
resolved = await processor.resolver.resolve(request)
|
| 16 |
+
return await processor.probe(resolved)
|
app/api/templates.py
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import time
|
| 4 |
+
from typing import Any
|
| 5 |
+
|
| 6 |
+
from fastapi import APIRouter, Query, Request
|
| 7 |
+
|
| 8 |
+
from app.core.response import SuccessResponse
|
| 9 |
+
|
| 10 |
+
router = APIRouter(prefix="/v1/templates", tags=["templates"])
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
def _response(request: Request, started: float, metadata: dict[str, Any]) -> SuccessResponse:
|
| 14 |
+
return SuccessResponse(
|
| 15 |
+
request_id=request.state.request_id,
|
| 16 |
+
processing_time=round(time.monotonic() - started, 4),
|
| 17 |
+
metadata=metadata,
|
| 18 |
+
)
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
@router.get("", response_model=SuccessResponse)
|
| 22 |
+
async def list_templates(
|
| 23 |
+
request: Request, category: str | None = Query(default=None)
|
| 24 |
+
) -> SuccessResponse:
|
| 25 |
+
"""List metadata, parameters, and examples for every template version."""
|
| 26 |
+
started = time.monotonic()
|
| 27 |
+
request.state.operation = "templates.list"
|
| 28 |
+
templates = request.app.state.container.template_registry.list_templates(category)
|
| 29 |
+
return _response(
|
| 30 |
+
request,
|
| 31 |
+
started,
|
| 32 |
+
{"templates": templates, "count": len(templates), "category": category},
|
| 33 |
+
)
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
@router.get("/categories", response_model=SuccessResponse)
|
| 37 |
+
async def template_categories(request: Request) -> SuccessResponse:
|
| 38 |
+
"""List categories discovered dynamically from YAML definitions."""
|
| 39 |
+
started = time.monotonic()
|
| 40 |
+
request.state.operation = "templates.categories"
|
| 41 |
+
categories = request.app.state.container.template_registry.categories()
|
| 42 |
+
return _response(request, started, {"categories": categories})
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
@router.post("/run", response_model=SuccessResponse)
|
| 46 |
+
async def run_template(request: Request) -> SuccessResponse:
|
| 47 |
+
"""Resolve any supported media input and execute a versioned template."""
|
| 48 |
+
request.state.operation = "templates.run"
|
| 49 |
+
container = request.app.state.container
|
| 50 |
+
resolved = await container.resolver.resolve(request)
|
| 51 |
+
return await container.template_executor.execute_request(resolved)
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
@router.get("/{template_id}", response_model=SuccessResponse)
|
| 55 |
+
async def template_details(request: Request, template_id: str) -> SuccessResponse:
|
| 56 |
+
"""Return complete metadata and pipeline details for a template reference."""
|
| 57 |
+
started = time.monotonic()
|
| 58 |
+
request.state.operation = "templates.details"
|
| 59 |
+
template = request.app.state.container.template_registry.template_details(template_id)
|
| 60 |
+
return _response(request, started, {"template": template})
|
app/api/video.py
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from fastapi import APIRouter, Request
|
| 4 |
+
|
| 5 |
+
from app.api.media import execute_operation
|
| 6 |
+
from app.core.response import SuccessResponse
|
| 7 |
+
from app.operations.compress import compress_video, normalize_video
|
| 8 |
+
from app.operations.concat import concat_video
|
| 9 |
+
from app.operations.convert import convert_video
|
| 10 |
+
from app.operations.crop import crop_video
|
| 11 |
+
from app.operations.extract_audio import mute_video, remove_audio, replace_audio
|
| 12 |
+
from app.operations.merge import merge_video
|
| 13 |
+
from app.operations.resize import pad_video, resize_video, scale_video
|
| 14 |
+
from app.operations.rotate import (
|
| 15 |
+
change_bitrate,
|
| 16 |
+
change_fps,
|
| 17 |
+
change_speed,
|
| 18 |
+
reverse_video,
|
| 19 |
+
rotate_video,
|
| 20 |
+
slow_motion,
|
| 21 |
+
)
|
| 22 |
+
from app.operations.subtitles import burn_subtitles, soft_subtitles
|
| 23 |
+
from app.operations.thumbnails import (
|
| 24 |
+
blur_video,
|
| 25 |
+
denoise_video,
|
| 26 |
+
extract_frames,
|
| 27 |
+
generate_gif,
|
| 28 |
+
sharpen_video,
|
| 29 |
+
thumbnail,
|
| 30 |
+
)
|
| 31 |
+
from app.operations.trim import trim_video
|
| 32 |
+
from app.operations.watermark import overlay_video, watermark_video
|
| 33 |
+
from app.services.media_service import Operation
|
| 34 |
+
|
| 35 |
+
router = APIRouter(prefix="/v1/video", tags=["video"])
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
def operation_route(path: str, name: str, operation: Operation) -> None:
|
| 39 |
+
async def endpoint(request: Request) -> SuccessResponse:
|
| 40 |
+
return await execute_operation(request, name, operation)
|
| 41 |
+
|
| 42 |
+
endpoint.__name__ = name.replace(".", "_")
|
| 43 |
+
router.add_api_route(
|
| 44 |
+
path, endpoint, methods=["POST"], response_model=SuccessResponse, name=name
|
| 45 |
+
)
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
operation_route("/compress", "video.compress", compress_video)
|
| 49 |
+
operation_route("/resize", "video.resize", resize_video)
|
| 50 |
+
operation_route("/convert", "video.convert", convert_video)
|
| 51 |
+
operation_route("/crop", "video.crop", crop_video)
|
| 52 |
+
operation_route("/merge", "video.merge", merge_video)
|
| 53 |
+
operation_route("/trim", "video.trim", trim_video)
|
| 54 |
+
operation_route("/concat", "video.concat", concat_video)
|
| 55 |
+
operation_route("/rotate", "video.rotate", rotate_video)
|
| 56 |
+
operation_route("/reverse", "video.reverse", reverse_video)
|
| 57 |
+
operation_route("/overlay", "video.overlay", overlay_video)
|
| 58 |
+
operation_route("/watermark", "video.watermark", watermark_video)
|
| 59 |
+
operation_route("/frames", "video.extract_frames", extract_frames)
|
| 60 |
+
operation_route("/gif", "video.gif", generate_gif)
|
| 61 |
+
operation_route("/thumbnail", "video.thumbnail", thumbnail)
|
| 62 |
+
operation_route("/replace-audio", "video.replace_audio", replace_audio)
|
| 63 |
+
operation_route("/remove-audio", "video.remove_audio", remove_audio)
|
| 64 |
+
operation_route("/mute", "video.mute", mute_video)
|
| 65 |
+
operation_route("/speed", "video.speed", change_speed)
|
| 66 |
+
operation_route("/speed-up", "video.speed_up", change_speed)
|
| 67 |
+
operation_route("/slow-motion", "video.slow_motion", slow_motion)
|
| 68 |
+
operation_route("/fps", "video.fps", change_fps)
|
| 69 |
+
operation_route("/bitrate", "video.bitrate", change_bitrate)
|
| 70 |
+
operation_route("/subtitles/burn", "video.subtitles.burn", burn_subtitles)
|
| 71 |
+
operation_route("/subtitles/soft", "video.subtitles.soft", soft_subtitles)
|
| 72 |
+
operation_route("/scale", "video.scale", scale_video)
|
| 73 |
+
operation_route("/pad", "video.pad", pad_video)
|
| 74 |
+
operation_route("/blur", "video.blur", blur_video)
|
| 75 |
+
operation_route("/sharpen", "video.sharpen", sharpen_video)
|
| 76 |
+
operation_route("/denoise", "video.denoise", denoise_video)
|
| 77 |
+
operation_route("/normalize", "video.normalize", normalize_video)
|
app/api/whisper.py
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from fastapi import APIRouter, Request
|
| 4 |
+
|
| 5 |
+
from app.api.media import get_processor
|
| 6 |
+
from app.core.response import SuccessResponse
|
| 7 |
+
|
| 8 |
+
router = APIRouter(prefix="/v1/whisper", tags=["whisper"])
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
async def _transcribe(request: Request, default_format: str | None = None) -> SuccessResponse:
|
| 12 |
+
request.state.operation = "whisper.transcribe"
|
| 13 |
+
processor = get_processor(request)
|
| 14 |
+
resolved = await processor.resolver.resolve(request)
|
| 15 |
+
if default_format and "output_format" not in resolved.params:
|
| 16 |
+
resolved.params["output_format"] = default_format
|
| 17 |
+
return await processor.run_whisper(resolved)
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
@router.post("/transcribe", response_model=SuccessResponse)
|
| 21 |
+
async def transcribe(request: Request) -> SuccessResponse:
|
| 22 |
+
return await _transcribe(request)
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
@router.post("/subtitles", response_model=SuccessResponse)
|
| 26 |
+
async def subtitles(request: Request) -> SuccessResponse:
|
| 27 |
+
return await _transcribe(request, "srt")
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
@router.post("/detect-language", response_model=SuccessResponse)
|
| 31 |
+
async def detect_language(request: Request) -> SuccessResponse:
|
| 32 |
+
return await _transcribe(request, "json")
|
app/api/ytdlp.py
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from fastapi import APIRouter, Request
|
| 4 |
+
|
| 5 |
+
from app.api.media import get_processor
|
| 6 |
+
from app.core.response import SuccessResponse
|
| 7 |
+
|
| 8 |
+
router = APIRouter(prefix="/v1/ytdlp", tags=["yt-dlp"])
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
@router.post("/download", response_model=SuccessResponse)
|
| 12 |
+
async def download(request: Request) -> SuccessResponse:
|
| 13 |
+
request.state.operation = "ytdlp.download"
|
| 14 |
+
processor = get_processor(request)
|
| 15 |
+
resolved = await processor.resolver.resolve(request)
|
| 16 |
+
return await processor.run_ytdlp(resolved)
|
app/container.py
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from dataclasses import dataclass
|
| 4 |
+
|
| 5 |
+
from app.core.config import Settings
|
| 6 |
+
from app.services.cleanup import CleanupService
|
| 7 |
+
from app.services.downloader import Downloader
|
| 8 |
+
from app.services.ffmpeg_service import FFmpegService
|
| 9 |
+
from app.services.ffprobe_service import FFprobeService
|
| 10 |
+
from app.services.input_resolver import InputResolver
|
| 11 |
+
from app.services.media_service import MediaProcessor
|
| 12 |
+
from app.services.validator import MediaValidator
|
| 13 |
+
from app.services.whisper_service import WhisperService
|
| 14 |
+
from app.services.ytdlp_service import YTDLPService
|
| 15 |
+
from app.templates.executor import OperationExecutor, TemplateExecutor
|
| 16 |
+
from app.templates.loader import TemplateLoader
|
| 17 |
+
from app.templates.registry import TemplateRegistry
|
| 18 |
+
from app.templates.validator import TemplateValidator
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
@dataclass(slots=True)
|
| 22 |
+
class Container:
|
| 23 |
+
settings: Settings
|
| 24 |
+
cleanup: CleanupService
|
| 25 |
+
validator: MediaValidator
|
| 26 |
+
downloader: Downloader
|
| 27 |
+
ytdlp: YTDLPService
|
| 28 |
+
ffmpeg: FFmpegService
|
| 29 |
+
ffprobe: FFprobeService
|
| 30 |
+
whisper: WhisperService
|
| 31 |
+
resolver: InputResolver
|
| 32 |
+
processor: MediaProcessor
|
| 33 |
+
template_registry: TemplateRegistry
|
| 34 |
+
template_executor: TemplateExecutor
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
def build_container(settings: Settings) -> Container:
|
| 38 |
+
cleanup = CleanupService(settings)
|
| 39 |
+
validator = MediaValidator(settings)
|
| 40 |
+
downloader = Downloader(settings, validator)
|
| 41 |
+
ytdlp = YTDLPService(settings, validator)
|
| 42 |
+
ffmpeg = FFmpegService(settings)
|
| 43 |
+
ffprobe = FFprobeService(settings)
|
| 44 |
+
whisper = WhisperService(settings)
|
| 45 |
+
resolver = InputResolver(settings, cleanup, downloader, ytdlp, validator)
|
| 46 |
+
processor = MediaProcessor(
|
| 47 |
+
settings, resolver, cleanup, validator, ffmpeg, ffprobe, ytdlp, whisper
|
| 48 |
+
)
|
| 49 |
+
operation_executor = OperationExecutor(processor)
|
| 50 |
+
template_validator = TemplateValidator(operation_executor.supported_operations)
|
| 51 |
+
template_registry = TemplateRegistry(
|
| 52 |
+
TemplateLoader(settings.template_dir, template_validator), template_validator
|
| 53 |
+
)
|
| 54 |
+
template_executor = TemplateExecutor(template_registry, operation_executor, processor)
|
| 55 |
+
return Container(
|
| 56 |
+
settings,
|
| 57 |
+
cleanup,
|
| 58 |
+
validator,
|
| 59 |
+
downloader,
|
| 60 |
+
ytdlp,
|
| 61 |
+
ffmpeg,
|
| 62 |
+
ffprobe,
|
| 63 |
+
whisper,
|
| 64 |
+
resolver,
|
| 65 |
+
processor,
|
| 66 |
+
template_registry,
|
| 67 |
+
template_executor,
|
| 68 |
+
)
|
app/core/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
"""Core application infrastructure."""
|
app/core/config.py
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from functools import lru_cache
|
| 4 |
+
from pathlib import Path
|
| 5 |
+
|
| 6 |
+
from pydantic import Field, field_validator
|
| 7 |
+
from pydantic_settings import BaseSettings, SettingsConfigDict
|
| 8 |
+
|
| 9 |
+
DEFAULT_TEMPLATE_DIR = Path(__file__).resolve().parents[1] / "templates" / "categories"
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
class Settings(BaseSettings):
|
| 13 |
+
"""Runtime configuration loaded from environment variables."""
|
| 14 |
+
|
| 15 |
+
model_config = SettingsConfigDict(
|
| 16 |
+
env_file=".env", env_file_encoding="utf-8", case_sensitive=False, extra="ignore"
|
| 17 |
+
)
|
| 18 |
+
|
| 19 |
+
app_name: str = "Enterprise Media Processing API"
|
| 20 |
+
app_version: str = "1.0.0"
|
| 21 |
+
host: str = "0.0.0.0"
|
| 22 |
+
port: int = 7860
|
| 23 |
+
temp_dir: Path = Path("./temp")
|
| 24 |
+
output_dir: Path = Path("./outputs")
|
| 25 |
+
template_dir: Path = DEFAULT_TEMPLATE_DIR
|
| 26 |
+
max_upload_size: int = Field(default=1_073_741_824, ge=1_048_576)
|
| 27 |
+
max_duration_seconds: float = Field(default=21_600.0, gt=0)
|
| 28 |
+
max_resolution_pixels: int = Field(default=33_177_600, ge=1)
|
| 29 |
+
whisper_model: str = "small"
|
| 30 |
+
cleanup_minutes: int = Field(default=60, ge=1)
|
| 31 |
+
cleanup_interval_seconds: int = Field(default=60, ge=5)
|
| 32 |
+
max_workers: int = Field(default=2, ge=1, le=32)
|
| 33 |
+
log_level: str = "INFO"
|
| 34 |
+
download_timeout_seconds: float = Field(default=300.0, gt=0)
|
| 35 |
+
allow_private_urls: bool = False
|
| 36 |
+
base_url: str = ""
|
| 37 |
+
ffmpeg_binary: str = "ffmpeg"
|
| 38 |
+
ffprobe_binary: str = "ffprobe"
|
| 39 |
+
|
| 40 |
+
@field_validator("whisper_model")
|
| 41 |
+
@classmethod
|
| 42 |
+
def validate_whisper_model(cls, value: str) -> str:
|
| 43 |
+
allowed = {"tiny", "base", "small", "medium", "large-v3"}
|
| 44 |
+
if value not in allowed:
|
| 45 |
+
raise ValueError(f"WHISPER_MODEL must be one of: {', '.join(sorted(allowed))}")
|
| 46 |
+
return value
|
| 47 |
+
|
| 48 |
+
@field_validator("log_level")
|
| 49 |
+
@classmethod
|
| 50 |
+
def normalize_log_level(cls, value: str) -> str:
|
| 51 |
+
normalized = value.upper()
|
| 52 |
+
allowed = {"DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"}
|
| 53 |
+
if normalized not in allowed:
|
| 54 |
+
raise ValueError(f"LOG_LEVEL must be one of: {', '.join(sorted(allowed))}")
|
| 55 |
+
return normalized
|
| 56 |
+
|
| 57 |
+
def ensure_directories(self) -> None:
|
| 58 |
+
self.temp_dir.mkdir(parents=True, exist_ok=True)
|
| 59 |
+
self.output_dir.mkdir(parents=True, exist_ok=True)
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
@lru_cache
|
| 63 |
+
def get_settings() -> Settings:
|
| 64 |
+
settings = Settings()
|
| 65 |
+
settings.ensure_directories()
|
| 66 |
+
return settings
|
app/core/exceptions.py
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from typing import Any
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
class MediaAPIError(Exception):
|
| 7 |
+
"""A safe, client-facing application error."""
|
| 8 |
+
|
| 9 |
+
code = "MEDIA_API_ERROR"
|
| 10 |
+
status_code = 400
|
| 11 |
+
|
| 12 |
+
def __init__(self, message: str, details: Any | None = None) -> None:
|
| 13 |
+
super().__init__(message, details)
|
| 14 |
+
self.message = message
|
| 15 |
+
self.details = details
|
| 16 |
+
|
| 17 |
+
def __str__(self) -> str:
|
| 18 |
+
return self.message
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
class InputError(MediaAPIError):
|
| 22 |
+
code = "INVALID_INPUT"
|
| 23 |
+
status_code = 422
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
class DownloadError(MediaAPIError):
|
| 27 |
+
code = "DOWNLOAD_FAILED"
|
| 28 |
+
status_code = 400
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
class ProcessingError(MediaAPIError):
|
| 32 |
+
code = "PROCESSING_FAILED"
|
| 33 |
+
status_code = 422
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
class NotFoundError(MediaAPIError):
|
| 37 |
+
code = "NOT_FOUND"
|
| 38 |
+
status_code = 404
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
class TemplateNotFoundError(MediaAPIError):
|
| 42 |
+
"""Raised when a requested template reference is unavailable."""
|
| 43 |
+
|
| 44 |
+
code = "TEMPLATE_NOT_FOUND"
|
| 45 |
+
status_code = 404
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
class TemplateValidationError(MediaAPIError):
|
| 49 |
+
"""Raised when a template definition or runtime parameter is invalid."""
|
| 50 |
+
|
| 51 |
+
code = "INVALID_TEMPLATE"
|
| 52 |
+
status_code = 422
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
class TemplateExecutionError(MediaAPIError):
|
| 56 |
+
"""Raised when a validated template cannot produce its declared output."""
|
| 57 |
+
|
| 58 |
+
code = "TEMPLATE_EXECUTION_FAILED"
|
| 59 |
+
status_code = 422
|
app/core/logger.py
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import contextvars
|
| 4 |
+
import json
|
| 5 |
+
import logging
|
| 6 |
+
import sys
|
| 7 |
+
from datetime import datetime, timezone
|
| 8 |
+
from typing import Any, TextIO
|
| 9 |
+
|
| 10 |
+
from app.core.config import get_settings
|
| 11 |
+
|
| 12 |
+
request_id_context: contextvars.ContextVar[str] = contextvars.ContextVar("request_id", default="-")
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
class JsonFormatter(logging.Formatter):
|
| 16 |
+
"""One-line structured JSON logs suitable for container log collectors."""
|
| 17 |
+
|
| 18 |
+
_standard = set(logging.makeLogRecord({}).__dict__) | {"message", "asctime"}
|
| 19 |
+
|
| 20 |
+
def format(self, record: logging.LogRecord) -> str:
|
| 21 |
+
payload: dict[str, Any] = {
|
| 22 |
+
"timestamp": datetime.now(timezone.utc).isoformat(),
|
| 23 |
+
"level": record.levelname,
|
| 24 |
+
"logger": record.name,
|
| 25 |
+
"message": record.getMessage(),
|
| 26 |
+
"request_id": getattr(record, "request_id", request_id_context.get()),
|
| 27 |
+
}
|
| 28 |
+
for key, value in record.__dict__.items():
|
| 29 |
+
if key not in self._standard and not key.startswith("_"):
|
| 30 |
+
payload[key] = self._json_safe(value)
|
| 31 |
+
if record.exc_info:
|
| 32 |
+
payload["exception"] = self.formatException(record.exc_info)
|
| 33 |
+
return json.dumps(payload, ensure_ascii=False, separators=(",", ":"))
|
| 34 |
+
|
| 35 |
+
@staticmethod
|
| 36 |
+
def _json_safe(value: Any) -> Any:
|
| 37 |
+
try:
|
| 38 |
+
json.dumps(value)
|
| 39 |
+
return value
|
| 40 |
+
except (TypeError, ValueError):
|
| 41 |
+
return str(value)
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
def configure_logging(stream: TextIO | None = None) -> None:
|
| 45 |
+
"""Configure structured logging, optionally targeting a stdio-safe stream."""
|
| 46 |
+
settings = get_settings()
|
| 47 |
+
handler = logging.StreamHandler(stream or sys.stdout)
|
| 48 |
+
handler.setFormatter(JsonFormatter())
|
| 49 |
+
root = logging.getLogger()
|
| 50 |
+
root.handlers.clear()
|
| 51 |
+
root.addHandler(handler)
|
| 52 |
+
root.setLevel(settings.log_level)
|
| 53 |
+
for name in ("uvicorn.access", "uvicorn.error"):
|
| 54 |
+
logging.getLogger(name).handlers.clear()
|
| 55 |
+
logging.getLogger(name).propagate = True
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
def get_logger(name: str) -> logging.Logger:
|
| 59 |
+
return logging.getLogger(name)
|
app/core/response.py
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from typing import Any
|
| 4 |
+
|
| 5 |
+
from pydantic import BaseModel, ConfigDict, Field
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
class ErrorBody(BaseModel):
|
| 9 |
+
code: str
|
| 10 |
+
message: str
|
| 11 |
+
details: Any | None = None
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
class ErrorResponse(BaseModel):
|
| 15 |
+
success: bool = False
|
| 16 |
+
request_id: str
|
| 17 |
+
error: ErrorBody
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
class SuccessResponse(BaseModel):
|
| 21 |
+
model_config = ConfigDict(extra="forbid")
|
| 22 |
+
|
| 23 |
+
success: bool = True
|
| 24 |
+
request_id: str
|
| 25 |
+
processing_time: float = Field(ge=0)
|
| 26 |
+
download_url: str | None = None
|
| 27 |
+
metadata: dict[str, Any] = Field(default_factory=dict)
|
app/mcp/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
"""Model Context Protocol interface for the media processing service."""
|
app/mcp/prompts.py
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from typing import Any
|
| 4 |
+
|
| 5 |
+
from mcp.server.fastmcp import FastMCP
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
def register_prompts(server: FastMCP[Any]) -> None:
|
| 9 |
+
"""Register reusable prompt templates that direct clients to MCP tools."""
|
| 10 |
+
|
| 11 |
+
@server.prompt(description="Compress a video for a social media platform.")
|
| 12 |
+
def compress_for_social_media(source: str, platform: str = "instagram") -> str:
|
| 13 |
+
"""Guide the model through social-media compression."""
|
| 14 |
+
return (
|
| 15 |
+
f"Use probe_media on {source!r}, then call compress_video for {platform}. "
|
| 16 |
+
"Choose MP4, H.264-compatible settings, CRF 28, veryfast preset, and a platform-appropriate max width. "
|
| 17 |
+
"Return the download_url and summarized output metadata."
|
| 18 |
+
)
|
| 19 |
+
|
| 20 |
+
@server.prompt(description="Download a YouTube URL as MP3 audio.")
|
| 21 |
+
def youtube_to_mp3(url: str) -> str:
|
| 22 |
+
"""Guide the model through a YouTube-to-MP3 workflow."""
|
| 23 |
+
return f"Call download_audio with url={url!r} and audio_format='mp3'. Return the resulting download_url."
|
| 24 |
+
|
| 25 |
+
@server.prompt(description="Download remote media and transcribe it.")
|
| 26 |
+
def download_and_transcribe(url: str, model: str = "small") -> str:
|
| 27 |
+
"""Guide the model through download followed by transcription."""
|
| 28 |
+
return (
|
| 29 |
+
f"First call download_audio with url={url!r}. Then call transcribe with the returned output_file as "
|
| 30 |
+
f"temp_path and model={model!r}. Return the transcript text, language, and download_url."
|
| 31 |
+
)
|
| 32 |
+
|
| 33 |
+
@server.prompt(description="Generate subtitles from media.")
|
| 34 |
+
def generate_subtitles(source: str, format: str = "srt", language: str = "auto") -> str:
|
| 35 |
+
"""Guide the model through subtitle generation."""
|
| 36 |
+
tool = "generate_vtt" if format.lower() == "vtt" else "generate_srt"
|
| 37 |
+
language_instruction = (
|
| 38 |
+
"omit language for automatic detection"
|
| 39 |
+
if language == "auto"
|
| 40 |
+
else f"language={language!r}"
|
| 41 |
+
)
|
| 42 |
+
return f"Call {tool} for {source!r}; {language_instruction}. Return the subtitle download_url and detected language."
|
| 43 |
+
|
| 44 |
+
@server.prompt(description="Extract audio from a video.")
|
| 45 |
+
def extract_audio(source: str, format: str = "mp3") -> str:
|
| 46 |
+
"""Guide the model through audio extraction."""
|
| 47 |
+
return f"Call extract_audio for {source!r} with format={format!r}, then return output metadata and download_url."
|
| 48 |
+
|
| 49 |
+
@server.prompt(description="Create a representative video thumbnail.")
|
| 50 |
+
def make_thumbnail(source: str, timestamp: float = 1.0) -> str:
|
| 51 |
+
"""Guide the model through thumbnail generation."""
|
| 52 |
+
return f"Call generate_thumbnail for {source!r} at timestamp={timestamp}. Return the JPEG download_url."
|
| 53 |
+
|
| 54 |
+
@server.prompt(description="Inspect complete media metadata.")
|
| 55 |
+
def probe_media(source: str) -> str:
|
| 56 |
+
"""Guide the model through FFprobe analysis."""
|
| 57 |
+
return f"Call probe_media for {source!r}. Summarize duration, resolution, FPS, codecs, streams, and container."
|
| 58 |
+
|
| 59 |
+
@server.prompt(description="Prepare an Instagram Reel from source media.")
|
| 60 |
+
def instagram_reel(source: str) -> str:
|
| 61 |
+
"""Guide the model through Reel preparation."""
|
| 62 |
+
return (
|
| 63 |
+
f"Use probe_media on {source!r}, then resize_video to 1080x1920 with fit='cover', "
|
| 64 |
+
"and compress_video as MP4 with CRF 27 and preset='veryfast'. Return the final download_url."
|
| 65 |
+
)
|
| 66 |
+
|
| 67 |
+
@server.prompt(description="Prepare a vertical TikTok video.")
|
| 68 |
+
def tiktok_video(source: str) -> str:
|
| 69 |
+
"""Guide the model through TikTok preparation."""
|
| 70 |
+
return (
|
| 71 |
+
f"Use resize_video on {source!r} at 1080x1920 with fit='cover', then compress_video "
|
| 72 |
+
"with MP4, CRF 26, and preset='veryfast'. Return the final download_url."
|
| 73 |
+
)
|
| 74 |
+
|
| 75 |
+
@server.prompt(description="Normalize and prepare podcast audio.")
|
| 76 |
+
def podcast_audio(source: str, format: str = "mp3") -> str:
|
| 77 |
+
"""Guide the model through podcast audio preparation."""
|
| 78 |
+
return (
|
| 79 |
+
f"Call normalize_audio for {source!r} with target_lufs=-16. If format is not WAV, "
|
| 80 |
+
f"call convert_audio with format={format!r} on the normalized output_file. Return the final download_url."
|
| 81 |
+
)
|
app/mcp/registry.py
ADDED
|
@@ -0,0 +1,505 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import importlib.metadata
|
| 4 |
+
import importlib.util
|
| 5 |
+
import os
|
| 6 |
+
import platform
|
| 7 |
+
import shutil
|
| 8 |
+
import time
|
| 9 |
+
from collections.abc import Awaitable, Callable, Sequence
|
| 10 |
+
from pathlib import Path
|
| 11 |
+
from typing import Any, Literal
|
| 12 |
+
from urllib.parse import unquote, urlparse
|
| 13 |
+
from uuid import uuid4
|
| 14 |
+
|
| 15 |
+
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
| 16 |
+
|
| 17 |
+
from app.container import Container
|
| 18 |
+
from app.core.exceptions import InputError, MediaAPIError
|
| 19 |
+
from app.core.logger import get_logger, request_id_context
|
| 20 |
+
from app.core.response import SuccessResponse
|
| 21 |
+
from app.models.media import ResolvedRequest
|
| 22 |
+
from app.operations.common import AUDIO_FORMATS, IMAGE_FORMATS, VIDEO_FORMATS
|
| 23 |
+
from app.services.media_service import Operation
|
| 24 |
+
|
| 25 |
+
logger = get_logger(__name__)
|
| 26 |
+
|
| 27 |
+
VIDEO_TOOLS = [
|
| 28 |
+
"compress_video",
|
| 29 |
+
"resize_video",
|
| 30 |
+
"crop_video",
|
| 31 |
+
"trim_video",
|
| 32 |
+
"convert_video",
|
| 33 |
+
"merge_videos",
|
| 34 |
+
"concat_videos",
|
| 35 |
+
"watermark_video",
|
| 36 |
+
"overlay_video",
|
| 37 |
+
"extract_audio",
|
| 38 |
+
"replace_audio",
|
| 39 |
+
"remove_audio",
|
| 40 |
+
"generate_thumbnail",
|
| 41 |
+
"extract_frames",
|
| 42 |
+
"burn_subtitles",
|
| 43 |
+
]
|
| 44 |
+
AUDIO_TOOLS = [
|
| 45 |
+
"convert_audio",
|
| 46 |
+
"normalize_audio",
|
| 47 |
+
"trim_audio",
|
| 48 |
+
"merge_audio",
|
| 49 |
+
"remove_silence",
|
| 50 |
+
]
|
| 51 |
+
IMAGE_TOOLS = ["image_to_video", "slideshow", "watermark_image", "resize_image"]
|
| 52 |
+
WHISPER_TOOLS = [
|
| 53 |
+
"transcribe",
|
| 54 |
+
"translate",
|
| 55 |
+
"detect_language",
|
| 56 |
+
"generate_srt",
|
| 57 |
+
"generate_vtt",
|
| 58 |
+
"generate_json",
|
| 59 |
+
]
|
| 60 |
+
YTDLP_TOOLS = [
|
| 61 |
+
"download_video",
|
| 62 |
+
"download_audio",
|
| 63 |
+
"video_metadata",
|
| 64 |
+
"playlist_metadata",
|
| 65 |
+
"list_formats",
|
| 66 |
+
]
|
| 67 |
+
PROBE_TOOLS = [
|
| 68 |
+
"probe_media",
|
| 69 |
+
"probe_video_metadata",
|
| 70 |
+
"audio_metadata",
|
| 71 |
+
"stream_info",
|
| 72 |
+
"container_info",
|
| 73 |
+
]
|
| 74 |
+
SYSTEM_TOOLS = [
|
| 75 |
+
"health",
|
| 76 |
+
"cleanup_temp",
|
| 77 |
+
"disk_usage",
|
| 78 |
+
"system_info",
|
| 79 |
+
"supported_operations",
|
| 80 |
+
"supported_formats",
|
| 81 |
+
"ffmpeg_version",
|
| 82 |
+
"whisper_models",
|
| 83 |
+
"yt_dlp_version",
|
| 84 |
+
]
|
| 85 |
+
TEMPLATE_TOOLS = [
|
| 86 |
+
"list_templates",
|
| 87 |
+
"template_details",
|
| 88 |
+
"run_template",
|
| 89 |
+
"template_categories",
|
| 90 |
+
]
|
| 91 |
+
|
| 92 |
+
|
| 93 |
+
class MediaInput(BaseModel):
|
| 94 |
+
"""Transport-neutral media input accepted by all MCP media tools."""
|
| 95 |
+
|
| 96 |
+
model_config = ConfigDict(extra="forbid")
|
| 97 |
+
|
| 98 |
+
url: str | None = Field(default=None, description="HTTP(S) or yt-dlp-supported URL")
|
| 99 |
+
base64: str | None = Field(default=None, description="Raw Base64 or a Base64 data URI")
|
| 100 |
+
binary: dict[str, Any] | None = Field(
|
| 101 |
+
default=None, description="n8n-style binary property object"
|
| 102 |
+
)
|
| 103 |
+
temp_path: str | None = Field(
|
| 104 |
+
default=None,
|
| 105 |
+
description="Existing file below configured TEMP_DIR or OUTPUT_DIR",
|
| 106 |
+
)
|
| 107 |
+
filename: str | None = Field(default=None, description="Original filename")
|
| 108 |
+
mime_type: str | None = Field(default=None, description="Declared media MIME type")
|
| 109 |
+
|
| 110 |
+
@model_validator(mode="after")
|
| 111 |
+
def validate_source(self) -> MediaInput:
|
| 112 |
+
sources = [self.url, self.base64, self.binary, self.temp_path]
|
| 113 |
+
if sum(value is not None for value in sources) != 1:
|
| 114 |
+
raise ValueError("Exactly one of url, base64, binary, or temp_path is required")
|
| 115 |
+
return self
|
| 116 |
+
|
| 117 |
+
def descriptor(self) -> dict[str, Any]:
|
| 118 |
+
"""Return the existing InputResolver descriptor representation."""
|
| 119 |
+
payload = self.model_dump(exclude_none=True)
|
| 120 |
+
if "mime_type" in payload:
|
| 121 |
+
payload["mimeType"] = payload.pop("mime_type")
|
| 122 |
+
return payload
|
| 123 |
+
|
| 124 |
+
|
| 125 |
+
class ToolError(BaseModel):
|
| 126 |
+
code: str
|
| 127 |
+
message: str
|
| 128 |
+
details: Any | None = None
|
| 129 |
+
|
| 130 |
+
|
| 131 |
+
class ToolResponse(BaseModel):
|
| 132 |
+
model_config = ConfigDict(extra="forbid")
|
| 133 |
+
|
| 134 |
+
success: bool
|
| 135 |
+
request_id: str
|
| 136 |
+
processing_time: float = Field(ge=0)
|
| 137 |
+
output_file: str | None = None
|
| 138 |
+
download_url: str | None = None
|
| 139 |
+
metadata: dict[str, Any] = Field(default_factory=dict)
|
| 140 |
+
error: ToolError | None = None
|
| 141 |
+
|
| 142 |
+
|
| 143 |
+
Action = Callable[[str], Awaitable[SuccessResponse]]
|
| 144 |
+
MetadataAction = Callable[[], Awaitable[dict[str, Any]]]
|
| 145 |
+
|
| 146 |
+
|
| 147 |
+
class MCPRegistry:
|
| 148 |
+
"""Shared MCP execution facade over the application's existing container."""
|
| 149 |
+
|
| 150 |
+
def __init__(self, container: Container) -> None:
|
| 151 |
+
self.container = container
|
| 152 |
+
|
| 153 |
+
async def run_operation(
|
| 154 |
+
self,
|
| 155 |
+
tool_name: str,
|
| 156 |
+
media: Sequence[MediaInput],
|
| 157 |
+
params: dict[str, Any],
|
| 158 |
+
operation: Operation,
|
| 159 |
+
) -> dict[str, Any]:
|
| 160 |
+
"""Resolve input and run an existing FFmpeg operation through MediaProcessor."""
|
| 161 |
+
|
| 162 |
+
async def action(request_id: str) -> SuccessResponse:
|
| 163 |
+
resolved = await self._resolve(media, params, request_id)
|
| 164 |
+
return await self.container.processor.run(resolved, tool_name, operation)
|
| 165 |
+
|
| 166 |
+
return await self._execute(tool_name, action)
|
| 167 |
+
|
| 168 |
+
async def run_whisper(
|
| 169 |
+
self,
|
| 170 |
+
tool_name: str,
|
| 171 |
+
media: MediaInput,
|
| 172 |
+
params: dict[str, Any],
|
| 173 |
+
) -> dict[str, Any]:
|
| 174 |
+
"""Resolve input and invoke the shared faster-whisper service."""
|
| 175 |
+
|
| 176 |
+
async def action(request_id: str) -> SuccessResponse:
|
| 177 |
+
resolved = await self._resolve([media], params, request_id)
|
| 178 |
+
return await self.container.processor.run_whisper(resolved)
|
| 179 |
+
|
| 180 |
+
return await self._execute(tool_name, action)
|
| 181 |
+
|
| 182 |
+
async def run_ytdlp(
|
| 183 |
+
self,
|
| 184 |
+
tool_name: str,
|
| 185 |
+
media: MediaInput,
|
| 186 |
+
params: dict[str, Any],
|
| 187 |
+
) -> dict[str, Any]:
|
| 188 |
+
"""Invoke yt-dlp through InputResolver and the shared MediaProcessor."""
|
| 189 |
+
|
| 190 |
+
async def action(request_id: str) -> SuccessResponse:
|
| 191 |
+
resolved = await self._resolve([media], params, request_id, ytdlp_options=params)
|
| 192 |
+
return await self.container.processor.run_ytdlp(resolved)
|
| 193 |
+
|
| 194 |
+
return await self._execute(tool_name, action)
|
| 195 |
+
|
| 196 |
+
async def run_probe(
|
| 197 |
+
self,
|
| 198 |
+
tool_name: str,
|
| 199 |
+
media: MediaInput,
|
| 200 |
+
view: Literal["all", "video", "audio", "streams", "container"] = "all",
|
| 201 |
+
) -> dict[str, Any]:
|
| 202 |
+
"""Probe media once and return the requested structured metadata view."""
|
| 203 |
+
|
| 204 |
+
async def action(request_id: str) -> SuccessResponse:
|
| 205 |
+
resolved = await self._resolve([media], {}, request_id)
|
| 206 |
+
response = await self.container.processor.probe(resolved)
|
| 207 |
+
primary = (response.metadata.get("inputs") or [{}])[0]
|
| 208 |
+
response.metadata = self._probe_view(primary, view)
|
| 209 |
+
return response
|
| 210 |
+
|
| 211 |
+
return await self._execute(tool_name, action)
|
| 212 |
+
|
| 213 |
+
async def run_template(
|
| 214 |
+
self,
|
| 215 |
+
template: str,
|
| 216 |
+
input_media: MediaInput | None,
|
| 217 |
+
input_collection: Sequence[MediaInput] | None,
|
| 218 |
+
parameters: dict[str, Any] | None,
|
| 219 |
+
) -> dict[str, Any]:
|
| 220 |
+
"""Resolve MCP media through InputResolver and execute a shared template."""
|
| 221 |
+
|
| 222 |
+
async def action(request_id: str) -> SuccessResponse:
|
| 223 |
+
if (input_media is None) == (input_collection is None):
|
| 224 |
+
raise InputError("Provide exactly one of input or inputs")
|
| 225 |
+
media = list(input_collection or [])
|
| 226 |
+
if input_media is not None:
|
| 227 |
+
media = [input_media]
|
| 228 |
+
if not media:
|
| 229 |
+
raise InputError("At least one template media input is required")
|
| 230 |
+
resolved = await self._resolve(media, {}, request_id)
|
| 231 |
+
return await self.container.template_executor.execute(resolved, template, parameters)
|
| 232 |
+
|
| 233 |
+
return await self._execute("run_template", action)
|
| 234 |
+
|
| 235 |
+
async def run_metadata_tool(self, tool_name: str, action: MetadataAction) -> dict[str, Any]:
|
| 236 |
+
"""Run a non-media utility with the same logging and error contract."""
|
| 237 |
+
|
| 238 |
+
async def wrapped(request_id: str) -> SuccessResponse:
|
| 239 |
+
started = time.monotonic()
|
| 240 |
+
metadata = await action()
|
| 241 |
+
return SuccessResponse(
|
| 242 |
+
request_id=request_id,
|
| 243 |
+
processing_time=round(time.monotonic() - started, 4),
|
| 244 |
+
metadata=metadata,
|
| 245 |
+
)
|
| 246 |
+
|
| 247 |
+
return await self._execute(tool_name, wrapped)
|
| 248 |
+
|
| 249 |
+
async def health_data(self) -> dict[str, Any]:
|
| 250 |
+
"""Return dependency health without loading the Whisper model."""
|
| 251 |
+
settings = self.container.settings
|
| 252 |
+
return {
|
| 253 |
+
"status": "healthy",
|
| 254 |
+
"version": settings.app_version,
|
| 255 |
+
"dependencies": {
|
| 256 |
+
"ffmpeg": shutil.which(settings.ffmpeg_binary) is not None,
|
| 257 |
+
"ffprobe": shutil.which(settings.ffprobe_binary) is not None,
|
| 258 |
+
"yt_dlp": importlib.util.find_spec("yt_dlp") is not None,
|
| 259 |
+
"faster_whisper": importlib.util.find_spec("faster_whisper") is not None,
|
| 260 |
+
"mcp": importlib.util.find_spec("mcp") is not None,
|
| 261 |
+
},
|
| 262 |
+
}
|
| 263 |
+
|
| 264 |
+
async def disk_usage_data(self) -> dict[str, Any]:
|
| 265 |
+
"""Return disk usage for temporary and published output locations."""
|
| 266 |
+
return {
|
| 267 |
+
"temp": self._disk_usage(self.container.settings.temp_dir),
|
| 268 |
+
"outputs": self._disk_usage(self.container.settings.output_dir),
|
| 269 |
+
}
|
| 270 |
+
|
| 271 |
+
async def system_info_data(self) -> dict[str, Any]:
|
| 272 |
+
"""Return CPU, memory, platform, and process information."""
|
| 273 |
+
data: dict[str, Any] = {
|
| 274 |
+
"platform": platform.platform(),
|
| 275 |
+
"python": platform.python_version(),
|
| 276 |
+
"cpu_count": os.cpu_count(),
|
| 277 |
+
"pid": os.getpid(),
|
| 278 |
+
}
|
| 279 |
+
try:
|
| 280 |
+
import psutil
|
| 281 |
+
|
| 282 |
+
process = psutil.Process(os.getpid())
|
| 283 |
+
data.update(
|
| 284 |
+
{
|
| 285 |
+
"cpu_percent": psutil.cpu_percent(interval=None),
|
| 286 |
+
"memory_bytes": process.memory_info().rss,
|
| 287 |
+
"system_memory": psutil.virtual_memory()._asdict(),
|
| 288 |
+
}
|
| 289 |
+
)
|
| 290 |
+
except ImportError:
|
| 291 |
+
data.update({"cpu_percent": None, "memory_bytes": None})
|
| 292 |
+
return data
|
| 293 |
+
|
| 294 |
+
async def configuration_data(self) -> dict[str, Any]:
|
| 295 |
+
"""Return non-secret runtime configuration."""
|
| 296 |
+
settings = self.container.settings
|
| 297 |
+
return {
|
| 298 |
+
"temp_dir": str(settings.temp_dir),
|
| 299 |
+
"output_dir": str(settings.output_dir),
|
| 300 |
+
"template_dir": str(settings.template_dir),
|
| 301 |
+
"max_upload_size": settings.max_upload_size,
|
| 302 |
+
"max_duration_seconds": settings.max_duration_seconds,
|
| 303 |
+
"max_resolution_pixels": settings.max_resolution_pixels,
|
| 304 |
+
"whisper_model": settings.whisper_model,
|
| 305 |
+
"cleanup_minutes": settings.cleanup_minutes,
|
| 306 |
+
"max_workers": settings.max_workers,
|
| 307 |
+
"allow_private_urls": settings.allow_private_urls,
|
| 308 |
+
}
|
| 309 |
+
|
| 310 |
+
async def supported_operations_data(self) -> dict[str, Any]:
|
| 311 |
+
"""Return every registered operation grouped by domain."""
|
| 312 |
+
return {
|
| 313 |
+
"video": VIDEO_TOOLS,
|
| 314 |
+
"audio": AUDIO_TOOLS,
|
| 315 |
+
"image": IMAGE_TOOLS,
|
| 316 |
+
"whisper": WHISPER_TOOLS,
|
| 317 |
+
"ytdlp": YTDLP_TOOLS,
|
| 318 |
+
"probe": PROBE_TOOLS,
|
| 319 |
+
"system": SYSTEM_TOOLS,
|
| 320 |
+
"templates": TEMPLATE_TOOLS,
|
| 321 |
+
}
|
| 322 |
+
|
| 323 |
+
async def supported_formats_data(self) -> dict[str, Any]:
|
| 324 |
+
"""Return supported output containers and transcription formats."""
|
| 325 |
+
return {
|
| 326 |
+
"video": sorted(VIDEO_FORMATS),
|
| 327 |
+
"audio": sorted(AUDIO_FORMATS),
|
| 328 |
+
"image": sorted(IMAGE_FORMATS),
|
| 329 |
+
"whisper": sorted(self.container.whisper.ALLOWED_FORMATS),
|
| 330 |
+
"whisper_models": sorted(self.container.whisper.ALLOWED_MODELS),
|
| 331 |
+
"yt_dlp_audio": ["mp3", "m4a", "wav", "opus", "flac"],
|
| 332 |
+
}
|
| 333 |
+
|
| 334 |
+
async def version_data(self) -> dict[str, Any]:
|
| 335 |
+
"""Return application and protocol package versions."""
|
| 336 |
+
return {
|
| 337 |
+
"application": self.container.settings.app_version,
|
| 338 |
+
"mcp": self._package_version("mcp"),
|
| 339 |
+
"yt_dlp": self._package_version("yt-dlp"),
|
| 340 |
+
"faster_whisper": self._package_version("faster-whisper"),
|
| 341 |
+
}
|
| 342 |
+
|
| 343 |
+
async def codecs_data(self) -> dict[str, Any]:
|
| 344 |
+
"""Return FFmpeg codec capabilities."""
|
| 345 |
+
codecs = await self.container.ffmpeg.codecs()
|
| 346 |
+
return {"count": len(codecs), "codecs": codecs}
|
| 347 |
+
|
| 348 |
+
async def safe_resource(self, name: str, action: MetadataAction) -> dict[str, Any]:
|
| 349 |
+
"""Return resource data without exposing raw exceptions."""
|
| 350 |
+
response = await self.run_metadata_tool(f"resource.{name}", action)
|
| 351 |
+
if response["success"]:
|
| 352 |
+
return {"success": True, **response["metadata"]}
|
| 353 |
+
return {
|
| 354 |
+
"success": False,
|
| 355 |
+
"error": response["error"],
|
| 356 |
+
"request_id": response["request_id"],
|
| 357 |
+
}
|
| 358 |
+
|
| 359 |
+
async def _resolve(
|
| 360 |
+
self,
|
| 361 |
+
media: Sequence[MediaInput],
|
| 362 |
+
params: dict[str, Any],
|
| 363 |
+
request_id: str,
|
| 364 |
+
*,
|
| 365 |
+
ytdlp_options: dict[str, Any] | None = None,
|
| 366 |
+
) -> ResolvedRequest:
|
| 367 |
+
payload: dict[str, Any] = {
|
| 368 |
+
"inputs": [item.descriptor() for item in media],
|
| 369 |
+
**params,
|
| 370 |
+
}
|
| 371 |
+
return await self.container.resolver.resolve_payload(
|
| 372 |
+
payload,
|
| 373 |
+
request_id,
|
| 374 |
+
ytdlp_options=ytdlp_options,
|
| 375 |
+
)
|
| 376 |
+
|
| 377 |
+
async def _execute(self, tool_name: str, action: Action) -> dict[str, Any]:
|
| 378 |
+
request_id = str(uuid4())
|
| 379 |
+
token = request_id_context.set(request_id)
|
| 380 |
+
started = time.monotonic()
|
| 381 |
+
cpu_started = time.process_time()
|
| 382 |
+
try:
|
| 383 |
+
response = await action(request_id)
|
| 384 |
+
result = self._tool_success(response)
|
| 385 |
+
logger.info(
|
| 386 |
+
"MCP tool completed",
|
| 387 |
+
extra={
|
| 388 |
+
"tool": tool_name,
|
| 389 |
+
"duration": round(time.monotonic() - started, 4),
|
| 390 |
+
"cpu_time": round(time.process_time() - cpu_started, 6),
|
| 391 |
+
"memory_bytes": self._memory_bytes(),
|
| 392 |
+
"output_size": response.metadata.get("output_size", 0),
|
| 393 |
+
},
|
| 394 |
+
)
|
| 395 |
+
return result
|
| 396 |
+
except MediaAPIError as exc:
|
| 397 |
+
logger.warning(
|
| 398 |
+
"MCP tool failed",
|
| 399 |
+
extra={
|
| 400 |
+
"tool": tool_name,
|
| 401 |
+
"error_code": exc.code,
|
| 402 |
+
"duration": round(time.monotonic() - started, 4),
|
| 403 |
+
"cpu_time": round(time.process_time() - cpu_started, 6),
|
| 404 |
+
"memory_bytes": self._memory_bytes(),
|
| 405 |
+
},
|
| 406 |
+
)
|
| 407 |
+
return self._tool_failure(request_id, started, exc.code, exc.message, exc.details)
|
| 408 |
+
except Exception:
|
| 409 |
+
logger.exception("unhandled MCP tool error", extra={"tool": tool_name})
|
| 410 |
+
return self._tool_failure(
|
| 411 |
+
request_id,
|
| 412 |
+
started,
|
| 413 |
+
"INTERNAL_ERROR",
|
| 414 |
+
"An unexpected internal error occurred",
|
| 415 |
+
None,
|
| 416 |
+
)
|
| 417 |
+
finally:
|
| 418 |
+
try:
|
| 419 |
+
await self.container.cleanup.complete(request_id)
|
| 420 |
+
except Exception:
|
| 421 |
+
logger.exception(
|
| 422 |
+
"MCP request cleanup finalization failed",
|
| 423 |
+
extra={"tool": tool_name},
|
| 424 |
+
)
|
| 425 |
+
finally:
|
| 426 |
+
request_id_context.reset(token)
|
| 427 |
+
|
| 428 |
+
def _tool_success(self, response: SuccessResponse) -> dict[str, Any]:
|
| 429 |
+
output_file: str | None = None
|
| 430 |
+
if response.download_url:
|
| 431 |
+
filename = Path(unquote(urlparse(response.download_url).path)).name
|
| 432 |
+
candidate = self.container.settings.output_dir / response.request_id / filename
|
| 433 |
+
if candidate.is_file():
|
| 434 |
+
output_file = str(candidate.resolve())
|
| 435 |
+
return ToolResponse(
|
| 436 |
+
success=True,
|
| 437 |
+
request_id=response.request_id,
|
| 438 |
+
processing_time=response.processing_time,
|
| 439 |
+
output_file=output_file,
|
| 440 |
+
download_url=response.download_url,
|
| 441 |
+
metadata=response.metadata,
|
| 442 |
+
).model_dump(mode="json", exclude_none=True)
|
| 443 |
+
|
| 444 |
+
@staticmethod
|
| 445 |
+
def _tool_failure(
|
| 446 |
+
request_id: str,
|
| 447 |
+
started: float,
|
| 448 |
+
code: str,
|
| 449 |
+
message: str,
|
| 450 |
+
details: Any | None,
|
| 451 |
+
) -> dict[str, Any]:
|
| 452 |
+
return ToolResponse(
|
| 453 |
+
success=False,
|
| 454 |
+
request_id=request_id,
|
| 455 |
+
processing_time=round(time.monotonic() - started, 4),
|
| 456 |
+
error=ToolError(code=code, message=message, details=details),
|
| 457 |
+
).model_dump(mode="json", exclude_none=True)
|
| 458 |
+
|
| 459 |
+
@staticmethod
|
| 460 |
+
def _probe_view(primary: dict[str, Any], view: str) -> dict[str, Any]:
|
| 461 |
+
if view == "video":
|
| 462 |
+
keys = (
|
| 463 |
+
"filename",
|
| 464 |
+
"duration",
|
| 465 |
+
"resolution",
|
| 466 |
+
"fps",
|
| 467 |
+
"codec",
|
| 468 |
+
"rotation",
|
| 469 |
+
"video_streams",
|
| 470 |
+
)
|
| 471 |
+
elif view == "audio":
|
| 472 |
+
keys = ("filename", "duration", "bitrate", "audio_streams")
|
| 473 |
+
elif view == "streams":
|
| 474 |
+
keys = ("filename", "video_streams", "audio_streams", "subtitle_streams")
|
| 475 |
+
elif view == "container":
|
| 476 |
+
keys = ("filename", "duration", "bitrate", "container", "creation_date", "size", "tags")
|
| 477 |
+
else:
|
| 478 |
+
return {"media": primary}
|
| 479 |
+
return {key: primary.get(key) for key in keys}
|
| 480 |
+
|
| 481 |
+
@staticmethod
|
| 482 |
+
def _disk_usage(path: Path) -> dict[str, Any]:
|
| 483 |
+
usage = shutil.disk_usage(path)
|
| 484 |
+
return {
|
| 485 |
+
"path": str(path),
|
| 486 |
+
"total": usage.total,
|
| 487 |
+
"used": usage.used,
|
| 488 |
+
"free": usage.free,
|
| 489 |
+
}
|
| 490 |
+
|
| 491 |
+
@staticmethod
|
| 492 |
+
def _memory_bytes() -> int | None:
|
| 493 |
+
try:
|
| 494 |
+
import psutil
|
| 495 |
+
|
| 496 |
+
return psutil.Process(os.getpid()).memory_info().rss
|
| 497 |
+
except ImportError:
|
| 498 |
+
return None
|
| 499 |
+
|
| 500 |
+
@staticmethod
|
| 501 |
+
def _package_version(name: str) -> str | None:
|
| 502 |
+
try:
|
| 503 |
+
return importlib.metadata.version(name)
|
| 504 |
+
except importlib.metadata.PackageNotFoundError:
|
| 505 |
+
return None
|
app/mcp/resources.py
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from typing import Any
|
| 4 |
+
|
| 5 |
+
from mcp.server.fastmcp import FastMCP
|
| 6 |
+
|
| 7 |
+
from app.mcp.registry import MCPRegistry
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
def register_resources(server: FastMCP[Any], registry: MCPRegistry) -> None:
|
| 11 |
+
"""Register structured media capability and runtime resources."""
|
| 12 |
+
|
| 13 |
+
@server.resource(
|
| 14 |
+
"media://operations",
|
| 15 |
+
name="media_operations",
|
| 16 |
+
description="All MCP media operations grouped by domain.",
|
| 17 |
+
mime_type="application/json",
|
| 18 |
+
)
|
| 19 |
+
async def media_operations() -> dict[str, Any]:
|
| 20 |
+
"""Return registered media operations."""
|
| 21 |
+
return await registry.safe_resource("operations", registry.supported_operations_data)
|
| 22 |
+
|
| 23 |
+
@server.resource(
|
| 24 |
+
"media://formats",
|
| 25 |
+
name="media_formats",
|
| 26 |
+
description="Supported media, transcription, and download formats.",
|
| 27 |
+
mime_type="application/json",
|
| 28 |
+
)
|
| 29 |
+
async def media_formats() -> dict[str, Any]:
|
| 30 |
+
"""Return supported formats."""
|
| 31 |
+
return await registry.safe_resource("formats", registry.supported_formats_data)
|
| 32 |
+
|
| 33 |
+
@server.resource(
|
| 34 |
+
"media://codecs",
|
| 35 |
+
name="media_codecs",
|
| 36 |
+
description="FFmpeg codec decode and encode capabilities.",
|
| 37 |
+
mime_type="application/json",
|
| 38 |
+
)
|
| 39 |
+
async def media_codecs() -> dict[str, Any]:
|
| 40 |
+
"""Return installed FFmpeg codecs."""
|
| 41 |
+
return await registry.safe_resource("codecs", registry.codecs_data)
|
| 42 |
+
|
| 43 |
+
@server.resource(
|
| 44 |
+
"media://health",
|
| 45 |
+
name="media_health",
|
| 46 |
+
description="Health of media processing dependencies.",
|
| 47 |
+
mime_type="application/json",
|
| 48 |
+
)
|
| 49 |
+
async def media_health() -> dict[str, Any]:
|
| 50 |
+
"""Return dependency health."""
|
| 51 |
+
return await registry.safe_resource("health", registry.health_data)
|
| 52 |
+
|
| 53 |
+
@server.resource(
|
| 54 |
+
"media://configuration",
|
| 55 |
+
name="media_configuration",
|
| 56 |
+
description="Non-secret media API runtime configuration.",
|
| 57 |
+
mime_type="application/json",
|
| 58 |
+
)
|
| 59 |
+
async def media_configuration() -> dict[str, Any]:
|
| 60 |
+
"""Return safe configuration values."""
|
| 61 |
+
return await registry.safe_resource("configuration", registry.configuration_data)
|
| 62 |
+
|
| 63 |
+
@server.resource(
|
| 64 |
+
"media://version",
|
| 65 |
+
name="media_version",
|
| 66 |
+
description="Application, MCP, yt-dlp, and Whisper package versions.",
|
| 67 |
+
mime_type="application/json",
|
| 68 |
+
)
|
| 69 |
+
async def media_version() -> dict[str, Any]:
|
| 70 |
+
"""Return version information."""
|
| 71 |
+
return await registry.safe_resource("version", registry.version_data)
|
app/mcp/server.py
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import argparse
|
| 4 |
+
import asyncio
|
| 5 |
+
import sys
|
| 6 |
+
from typing import Any, Literal, cast
|
| 7 |
+
|
| 8 |
+
from mcp.server.fastmcp import FastMCP
|
| 9 |
+
|
| 10 |
+
from app.container import Container, build_container
|
| 11 |
+
from app.core.config import get_settings
|
| 12 |
+
from app.core.logger import configure_logging
|
| 13 |
+
from app.mcp.prompts import register_prompts
|
| 14 |
+
from app.mcp.registry import MCPRegistry
|
| 15 |
+
from app.mcp.resources import register_resources
|
| 16 |
+
from app.mcp.tools.audio import register_audio_tools
|
| 17 |
+
from app.mcp.tools.image import register_image_tools
|
| 18 |
+
from app.mcp.tools.probe import register_probe_tools
|
| 19 |
+
from app.mcp.tools.system import register_system_tools
|
| 20 |
+
from app.mcp.tools.templates import register_template_tools
|
| 21 |
+
from app.mcp.tools.video import register_video_tools
|
| 22 |
+
from app.mcp.tools.whisper import register_whisper_tools
|
| 23 |
+
from app.mcp.tools.ytdlp import register_ytdlp_tools
|
| 24 |
+
from app.workers.cleanup_worker import CleanupWorker
|
| 25 |
+
|
| 26 |
+
MCPLogLevel = Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def create_mcp_server(container: Container) -> FastMCP[Any]:
|
| 30 |
+
"""Create a fully registered MCP server over an existing service container."""
|
| 31 |
+
settings = container.settings
|
| 32 |
+
server: FastMCP[Any] = FastMCP(
|
| 33 |
+
name="Enterprise Media Processing API",
|
| 34 |
+
instructions=(
|
| 35 |
+
"Use the registered tools for safe media processing. All media inputs accept URL, Base64, "
|
| 36 |
+
"n8n binary objects, or managed temp_path values. Read media://operations for capabilities."
|
| 37 |
+
),
|
| 38 |
+
host=settings.host,
|
| 39 |
+
port=settings.port,
|
| 40 |
+
streamable_http_path="/",
|
| 41 |
+
json_response=True,
|
| 42 |
+
stateless_http=True,
|
| 43 |
+
log_level=cast(MCPLogLevel, settings.log_level),
|
| 44 |
+
)
|
| 45 |
+
registry = MCPRegistry(container)
|
| 46 |
+
register_video_tools(server, registry)
|
| 47 |
+
register_audio_tools(server, registry)
|
| 48 |
+
register_image_tools(server, registry)
|
| 49 |
+
register_whisper_tools(server, registry)
|
| 50 |
+
register_ytdlp_tools(server, registry)
|
| 51 |
+
register_probe_tools(server, registry)
|
| 52 |
+
register_system_tools(server, registry)
|
| 53 |
+
register_template_tools(server, registry)
|
| 54 |
+
register_resources(server, registry)
|
| 55 |
+
register_prompts(server)
|
| 56 |
+
return server
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
async def run_server(transport: Literal["stdio", "streamable-http"] = "stdio") -> None:
|
| 60 |
+
"""Run MCP as a standalone stdio or Streamable HTTP server."""
|
| 61 |
+
configure_logging(stream=sys.stderr if transport == "stdio" else None)
|
| 62 |
+
settings = get_settings()
|
| 63 |
+
container = build_container(settings)
|
| 64 |
+
worker = CleanupWorker(container.cleanup, settings.cleanup_interval_seconds)
|
| 65 |
+
server = create_mcp_server(container)
|
| 66 |
+
await worker.start()
|
| 67 |
+
try:
|
| 68 |
+
if transport == "stdio":
|
| 69 |
+
await server.run_stdio_async()
|
| 70 |
+
else:
|
| 71 |
+
await server.run_streamable_http_async()
|
| 72 |
+
finally:
|
| 73 |
+
await worker.stop()
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
def main() -> None:
|
| 77 |
+
"""CLI entry point for local MCP clients."""
|
| 78 |
+
parser = argparse.ArgumentParser(description="Enterprise Media API MCP server")
|
| 79 |
+
parser.add_argument(
|
| 80 |
+
"--transport",
|
| 81 |
+
choices=("stdio", "streamable-http"),
|
| 82 |
+
default="stdio",
|
| 83 |
+
help="MCP transport to run (default: stdio)",
|
| 84 |
+
)
|
| 85 |
+
arguments = parser.parse_args()
|
| 86 |
+
asyncio.run(run_server(arguments.transport))
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
if __name__ == "__main__":
|
| 90 |
+
main()
|
app/mcp/tools/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
"""MCP tool registration modules."""
|
app/mcp/tools/audio.py
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from typing import Any
|
| 4 |
+
|
| 5 |
+
from mcp.server.fastmcp import FastMCP
|
| 6 |
+
|
| 7 |
+
from app.mcp.registry import MCPRegistry, MediaInput
|
| 8 |
+
from app.operations.compress import normalize_audio as normalize_audio_operation
|
| 9 |
+
from app.operations.convert import convert_audio as convert_audio_operation
|
| 10 |
+
from app.operations.extract_audio import remove_silence as remove_silence_operation
|
| 11 |
+
from app.operations.merge import merge_audio as merge_audio_operation
|
| 12 |
+
from app.operations.trim import trim_audio as trim_audio_operation
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
def register_audio_tools(server: FastMCP[Any], registry: MCPRegistry) -> None:
|
| 16 |
+
"""Register audio MCP tools backed by existing operations."""
|
| 17 |
+
|
| 18 |
+
@server.tool(description="Convert audio to MP3, WAV, AAC, M4A, FLAC, OGG, or Opus.")
|
| 19 |
+
async def convert_audio(input: MediaInput, format: str = "mp3") -> dict[str, Any]:
|
| 20 |
+
"""Convert one audio input."""
|
| 21 |
+
return await registry.run_operation(
|
| 22 |
+
"convert_audio", [input], {"format": format}, convert_audio_operation
|
| 23 |
+
)
|
| 24 |
+
|
| 25 |
+
@server.tool(description="Normalize audio loudness to a target LUFS value.")
|
| 26 |
+
async def normalize_audio(input: MediaInput, target_lufs: float = -16) -> dict[str, Any]:
|
| 27 |
+
"""Normalize one audio input."""
|
| 28 |
+
return await registry.run_operation(
|
| 29 |
+
"normalize_audio",
|
| 30 |
+
[input],
|
| 31 |
+
{"target_lufs": target_lufs},
|
| 32 |
+
normalize_audio_operation,
|
| 33 |
+
)
|
| 34 |
+
|
| 35 |
+
@server.tool(description="Trim audio by start, end, or duration.")
|
| 36 |
+
async def trim_audio(
|
| 37 |
+
input: MediaInput,
|
| 38 |
+
start: float = 0,
|
| 39 |
+
duration: float | None = None,
|
| 40 |
+
end: float | None = None,
|
| 41 |
+
) -> dict[str, Any]:
|
| 42 |
+
"""Trim one audio input."""
|
| 43 |
+
params = {"start": start}
|
| 44 |
+
if duration is not None:
|
| 45 |
+
params["duration"] = duration
|
| 46 |
+
if end is not None:
|
| 47 |
+
params["end"] = end
|
| 48 |
+
return await registry.run_operation("trim_audio", [input], params, trim_audio_operation)
|
| 49 |
+
|
| 50 |
+
@server.tool(description="Normalize and merge multiple audio files in sequence.")
|
| 51 |
+
async def merge_audio(inputs: list[MediaInput]) -> dict[str, Any]:
|
| 52 |
+
"""Merge multiple audio inputs."""
|
| 53 |
+
return await registry.run_operation("merge_audio", inputs, {}, merge_audio_operation)
|
| 54 |
+
|
| 55 |
+
@server.tool(description="Remove leading, trailing, and internal silence from audio.")
|
| 56 |
+
async def remove_silence(input: MediaInput, threshold: str = "-45dB") -> dict[str, Any]:
|
| 57 |
+
"""Remove silence through the existing audio filter operation."""
|
| 58 |
+
return await registry.run_operation(
|
| 59 |
+
"remove_silence",
|
| 60 |
+
[input],
|
| 61 |
+
{"threshold": threshold},
|
| 62 |
+
remove_silence_operation,
|
| 63 |
+
)
|
app/mcp/tools/image.py
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from typing import Any
|
| 4 |
+
|
| 5 |
+
from mcp.server.fastmcp import FastMCP
|
| 6 |
+
|
| 7 |
+
from app.mcp.registry import MCPRegistry, MediaInput
|
| 8 |
+
from app.operations.concat import image_slideshow
|
| 9 |
+
from app.operations.concat import image_to_video as image_to_video_operation
|
| 10 |
+
from app.operations.resize import resize_image as resize_image_operation
|
| 11 |
+
from app.operations.watermark import watermark_image as watermark_image_operation
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
def register_image_tools(server: FastMCP[Any], registry: MCPRegistry) -> None:
|
| 15 |
+
"""Register image MCP tools backed by existing operations."""
|
| 16 |
+
|
| 17 |
+
@server.tool(description="Turn one still image into an H.264 video.")
|
| 18 |
+
async def image_to_video(
|
| 19 |
+
input: MediaInput, duration: float = 5, fps: int = 30
|
| 20 |
+
) -> dict[str, Any]:
|
| 21 |
+
"""Create a video from one image."""
|
| 22 |
+
return await registry.run_operation(
|
| 23 |
+
"image_to_video",
|
| 24 |
+
[input],
|
| 25 |
+
{"duration": duration, "fps": fps},
|
| 26 |
+
image_to_video_operation,
|
| 27 |
+
)
|
| 28 |
+
|
| 29 |
+
@server.tool(description="Create a video slideshow from multiple images.")
|
| 30 |
+
async def slideshow(
|
| 31 |
+
inputs: list[MediaInput],
|
| 32 |
+
duration_per_image: float = 3,
|
| 33 |
+
width: int = 1280,
|
| 34 |
+
height: int = 720,
|
| 35 |
+
fps: int = 30,
|
| 36 |
+
) -> dict[str, Any]:
|
| 37 |
+
"""Create a slideshow through the shared image operation."""
|
| 38 |
+
return await registry.run_operation(
|
| 39 |
+
"slideshow",
|
| 40 |
+
inputs,
|
| 41 |
+
{
|
| 42 |
+
"duration_per_image": duration_per_image,
|
| 43 |
+
"width": width,
|
| 44 |
+
"height": height,
|
| 45 |
+
"fps": fps,
|
| 46 |
+
},
|
| 47 |
+
image_slideshow,
|
| 48 |
+
)
|
| 49 |
+
|
| 50 |
+
@server.tool(description="Apply an image watermark to another image.")
|
| 51 |
+
async def watermark_image(
|
| 52 |
+
image: MediaInput,
|
| 53 |
+
watermark: MediaInput,
|
| 54 |
+
position: str = "bottom-right",
|
| 55 |
+
opacity: float = 1.0,
|
| 56 |
+
watermark_scale: float = 1.0,
|
| 57 |
+
format: str = "png",
|
| 58 |
+
) -> dict[str, Any]:
|
| 59 |
+
"""Apply a watermark through the existing image operation."""
|
| 60 |
+
return await registry.run_operation(
|
| 61 |
+
"watermark_image",
|
| 62 |
+
[image, watermark],
|
| 63 |
+
{
|
| 64 |
+
"position": position,
|
| 65 |
+
"opacity": opacity,
|
| 66 |
+
"watermark_scale": watermark_scale,
|
| 67 |
+
"format": format,
|
| 68 |
+
},
|
| 69 |
+
watermark_image_operation,
|
| 70 |
+
)
|
| 71 |
+
|
| 72 |
+
@server.tool(description="Resize an image with contain, cover, or fill behavior.")
|
| 73 |
+
async def resize_image(
|
| 74 |
+
input: MediaInput,
|
| 75 |
+
width: int = 1280,
|
| 76 |
+
height: int = 720,
|
| 77 |
+
fit: str = "contain",
|
| 78 |
+
format: str = "png",
|
| 79 |
+
) -> dict[str, Any]:
|
| 80 |
+
"""Resize one image through the existing image operation."""
|
| 81 |
+
return await registry.run_operation(
|
| 82 |
+
"resize_image",
|
| 83 |
+
[input],
|
| 84 |
+
{"width": width, "height": height, "fit": fit, "format": format},
|
| 85 |
+
resize_image_operation,
|
| 86 |
+
)
|
app/mcp/tools/probe.py
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from typing import Any
|
| 4 |
+
|
| 5 |
+
from mcp.server.fastmcp import FastMCP
|
| 6 |
+
|
| 7 |
+
from app.mcp.registry import MCPRegistry, MediaInput
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
def register_probe_tools(server: FastMCP[Any], registry: MCPRegistry) -> None:
|
| 11 |
+
"""Register FFprobe metadata views through MediaProcessor.probe."""
|
| 12 |
+
|
| 13 |
+
@server.tool(description="Return complete normalized FFprobe metadata.")
|
| 14 |
+
async def probe_media(input: MediaInput) -> dict[str, Any]:
|
| 15 |
+
"""Probe all media streams and container metadata."""
|
| 16 |
+
return await registry.run_probe("probe_media", input, "all")
|
| 17 |
+
|
| 18 |
+
@server.tool(
|
| 19 |
+
name="probe_video_metadata",
|
| 20 |
+
description="Return FFprobe video stream, resolution, FPS, and rotation metadata.",
|
| 21 |
+
)
|
| 22 |
+
async def ffprobe_video_metadata(input: MediaInput) -> dict[str, Any]:
|
| 23 |
+
"""Return the video-specific FFprobe metadata view."""
|
| 24 |
+
return await registry.run_probe("probe_video_metadata", input, "video")
|
| 25 |
+
|
| 26 |
+
@server.tool(description="Return FFprobe audio stream metadata.")
|
| 27 |
+
async def audio_metadata(input: MediaInput) -> dict[str, Any]:
|
| 28 |
+
"""Return audio streams, duration, and bitrate."""
|
| 29 |
+
return await registry.run_probe("audio_metadata", input, "audio")
|
| 30 |
+
|
| 31 |
+
@server.tool(description="Return all video, audio, and subtitle stream summaries.")
|
| 32 |
+
async def stream_info(input: MediaInput) -> dict[str, Any]:
|
| 33 |
+
"""Return normalized stream information."""
|
| 34 |
+
return await registry.run_probe("stream_info", input, "streams")
|
| 35 |
+
|
| 36 |
+
@server.tool(description="Return container, tags, creation date, size, and duration.")
|
| 37 |
+
async def container_info(input: MediaInput) -> dict[str, Any]:
|
| 38 |
+
"""Return normalized container information."""
|
| 39 |
+
return await registry.run_probe("container_info", input, "container")
|
app/mcp/tools/system.py
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import importlib.metadata
|
| 4 |
+
from typing import Any
|
| 5 |
+
|
| 6 |
+
from mcp.server.fastmcp import FastMCP
|
| 7 |
+
|
| 8 |
+
from app.mcp.registry import MCPRegistry
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
def register_system_tools(server: FastMCP[Any], registry: MCPRegistry) -> None:
|
| 12 |
+
"""Register health, cleanup, capability, and runtime utility tools."""
|
| 13 |
+
|
| 14 |
+
@server.tool(description="Return media API and dependency health.")
|
| 15 |
+
async def health() -> dict[str, Any]:
|
| 16 |
+
"""Return service health without loading expensive models."""
|
| 17 |
+
return await registry.run_metadata_tool("health", registry.health_data)
|
| 18 |
+
|
| 19 |
+
@server.tool(description="Run the existing expiry cleanup pass immediately.")
|
| 20 |
+
async def cleanup_temp() -> dict[str, Any]:
|
| 21 |
+
"""Remove expired, inactive request directories."""
|
| 22 |
+
|
| 23 |
+
async def action() -> dict[str, Any]:
|
| 24 |
+
removed = await registry.container.cleanup.cleanup_expired()
|
| 25 |
+
return {"removed_directories": removed}
|
| 26 |
+
|
| 27 |
+
return await registry.run_metadata_tool("cleanup_temp", action)
|
| 28 |
+
|
| 29 |
+
@server.tool(description="Return disk capacity and usage for temp and output storage.")
|
| 30 |
+
async def disk_usage() -> dict[str, Any]:
|
| 31 |
+
"""Return storage usage statistics."""
|
| 32 |
+
return await registry.run_metadata_tool("disk_usage", registry.disk_usage_data)
|
| 33 |
+
|
| 34 |
+
@server.tool(description="Return CPU, memory, Python, and platform information.")
|
| 35 |
+
async def system_info() -> dict[str, Any]:
|
| 36 |
+
"""Return process and host runtime information."""
|
| 37 |
+
return await registry.run_metadata_tool("system_info", registry.system_info_data)
|
| 38 |
+
|
| 39 |
+
@server.tool(description="List all media MCP operations by domain.")
|
| 40 |
+
async def supported_operations() -> dict[str, Any]:
|
| 41 |
+
"""Return registered operation names."""
|
| 42 |
+
return await registry.run_metadata_tool(
|
| 43 |
+
"supported_operations", registry.supported_operations_data
|
| 44 |
+
)
|
| 45 |
+
|
| 46 |
+
@server.tool(description="List supported output, transcription, and model formats.")
|
| 47 |
+
async def supported_formats() -> dict[str, Any]:
|
| 48 |
+
"""Return supported formats."""
|
| 49 |
+
return await registry.run_metadata_tool(
|
| 50 |
+
"supported_formats", registry.supported_formats_data
|
| 51 |
+
)
|
| 52 |
+
|
| 53 |
+
@server.tool(description="Return the installed FFmpeg version banner.")
|
| 54 |
+
async def ffmpeg_version() -> dict[str, Any]:
|
| 55 |
+
"""Return FFmpeg version information."""
|
| 56 |
+
|
| 57 |
+
async def action() -> dict[str, Any]:
|
| 58 |
+
return {"version": await registry.container.ffmpeg.version()}
|
| 59 |
+
|
| 60 |
+
return await registry.run_metadata_tool("ffmpeg_version", action)
|
| 61 |
+
|
| 62 |
+
@server.tool(description="List faster-whisper models accepted by this server.")
|
| 63 |
+
async def whisper_models() -> dict[str, Any]:
|
| 64 |
+
"""Return allowed and default Whisper models."""
|
| 65 |
+
|
| 66 |
+
async def action() -> dict[str, Any]:
|
| 67 |
+
return {
|
| 68 |
+
"models": sorted(registry.container.whisper.ALLOWED_MODELS),
|
| 69 |
+
"default": registry.container.settings.whisper_model,
|
| 70 |
+
"device": "cpu",
|
| 71 |
+
"compute_type": "int8",
|
| 72 |
+
}
|
| 73 |
+
|
| 74 |
+
return await registry.run_metadata_tool("whisper_models", action)
|
| 75 |
+
|
| 76 |
+
@server.tool(description="Return the installed yt-dlp package version.")
|
| 77 |
+
async def yt_dlp_version() -> dict[str, Any]:
|
| 78 |
+
"""Return yt-dlp version information."""
|
| 79 |
+
|
| 80 |
+
async def action() -> dict[str, Any]:
|
| 81 |
+
try:
|
| 82 |
+
version = importlib.metadata.version("yt-dlp")
|
| 83 |
+
except importlib.metadata.PackageNotFoundError:
|
| 84 |
+
version = None
|
| 85 |
+
return {"version": version}
|
| 86 |
+
|
| 87 |
+
return await registry.run_metadata_tool("yt_dlp_version", action)
|
app/mcp/tools/templates.py
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from typing import Any
|
| 4 |
+
|
| 5 |
+
from mcp.server.fastmcp import FastMCP
|
| 6 |
+
|
| 7 |
+
from app.mcp.registry import MCPRegistry, MediaInput
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
def register_template_tools(server: FastMCP[Any], registry: MCPRegistry) -> None:
|
| 11 |
+
"""Register YAML template discovery and execution tools."""
|
| 12 |
+
|
| 13 |
+
@server.tool(description="List all dynamically loaded media template versions.")
|
| 14 |
+
async def list_templates(category: str | None = None) -> dict[str, Any]:
|
| 15 |
+
"""Return template metadata, parameters, and examples."""
|
| 16 |
+
|
| 17 |
+
async def action() -> dict[str, Any]:
|
| 18 |
+
templates = registry.container.template_registry.list_templates(category)
|
| 19 |
+
return {"templates": templates, "count": len(templates)}
|
| 20 |
+
|
| 21 |
+
return await registry.run_metadata_tool("list_templates", action)
|
| 22 |
+
|
| 23 |
+
@server.tool(description="Return complete details for id, id@version, or id@latest.")
|
| 24 |
+
async def template_details(template: str) -> dict[str, Any]:
|
| 25 |
+
"""Return one template's metadata and operation pipeline."""
|
| 26 |
+
|
| 27 |
+
async def action() -> dict[str, Any]:
|
| 28 |
+
return {"template": registry.container.template_registry.template_details(template)}
|
| 29 |
+
|
| 30 |
+
return await registry.run_metadata_tool("template_details", action)
|
| 31 |
+
|
| 32 |
+
@server.tool(description="Execute a versioned YAML template using the shared media resolver.")
|
| 33 |
+
async def run_template(
|
| 34 |
+
template: str,
|
| 35 |
+
input: MediaInput | None = None,
|
| 36 |
+
inputs: list[MediaInput] | None = None,
|
| 37 |
+
parameters: dict[str, Any] | None = None,
|
| 38 |
+
) -> dict[str, Any]:
|
| 39 |
+
"""Run a template with exactly one input object or an input list."""
|
| 40 |
+
return await registry.run_template(template, input, inputs, parameters)
|
| 41 |
+
|
| 42 |
+
@server.tool(description="List media template categories discovered from YAML.")
|
| 43 |
+
async def template_categories() -> dict[str, Any]:
|
| 44 |
+
"""Return dynamic template category names."""
|
| 45 |
+
|
| 46 |
+
async def action() -> dict[str, Any]:
|
| 47 |
+
return {"categories": registry.container.template_registry.categories()}
|
| 48 |
+
|
| 49 |
+
return await registry.run_metadata_tool("template_categories", action)
|
app/mcp/tools/video.py
ADDED
|
@@ -0,0 +1,223 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from typing import Any
|
| 4 |
+
|
| 5 |
+
from mcp.server.fastmcp import FastMCP
|
| 6 |
+
|
| 7 |
+
from app.mcp.registry import MCPRegistry, MediaInput
|
| 8 |
+
from app.operations.compress import compress_video as compress_video_operation
|
| 9 |
+
from app.operations.concat import concat_video
|
| 10 |
+
from app.operations.convert import convert_video as convert_video_operation
|
| 11 |
+
from app.operations.crop import crop_video as crop_video_operation
|
| 12 |
+
from app.operations.extract_audio import extract_audio as extract_audio_operation
|
| 13 |
+
from app.operations.extract_audio import remove_audio as remove_audio_operation
|
| 14 |
+
from app.operations.extract_audio import replace_audio as replace_audio_operation
|
| 15 |
+
from app.operations.merge import merge_video
|
| 16 |
+
from app.operations.resize import resize_video as resize_video_operation
|
| 17 |
+
from app.operations.subtitles import burn_subtitles as burn_subtitles_operation
|
| 18 |
+
from app.operations.thumbnails import extract_frames as extract_frames_operation
|
| 19 |
+
from app.operations.thumbnails import thumbnail as thumbnail_operation
|
| 20 |
+
from app.operations.trim import trim_video as trim_video_operation
|
| 21 |
+
from app.operations.watermark import overlay_video as overlay_video_operation
|
| 22 |
+
from app.operations.watermark import watermark_video as watermark_video_operation
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def register_video_tools(server: FastMCP[Any], registry: MCPRegistry) -> None:
|
| 26 |
+
"""Register video MCP tools backed by existing operation functions."""
|
| 27 |
+
|
| 28 |
+
@server.tool(description="Compress a video with CPU-optimized FFmpeg settings.")
|
| 29 |
+
async def compress_video(
|
| 30 |
+
input: MediaInput,
|
| 31 |
+
crf: int = 28,
|
| 32 |
+
preset: str = "medium",
|
| 33 |
+
format: str = "mp4",
|
| 34 |
+
max_width: int | None = None,
|
| 35 |
+
bitrate: str | None = None,
|
| 36 |
+
) -> dict[str, Any]:
|
| 37 |
+
"""Compress one video and publish the output."""
|
| 38 |
+
params = _without_none(
|
| 39 |
+
{
|
| 40 |
+
"crf": crf,
|
| 41 |
+
"preset": preset,
|
| 42 |
+
"format": format,
|
| 43 |
+
"max_width": max_width,
|
| 44 |
+
"bitrate": bitrate,
|
| 45 |
+
}
|
| 46 |
+
)
|
| 47 |
+
return await registry.run_operation(
|
| 48 |
+
"compress_video", [input], params, compress_video_operation
|
| 49 |
+
)
|
| 50 |
+
|
| 51 |
+
@server.tool(description="Resize a video using contain, cover, or fill behavior.")
|
| 52 |
+
async def resize_video(
|
| 53 |
+
input: MediaInput,
|
| 54 |
+
width: int = 1280,
|
| 55 |
+
height: int = 720,
|
| 56 |
+
fit: str = "contain",
|
| 57 |
+
format: str = "mp4",
|
| 58 |
+
) -> dict[str, Any]:
|
| 59 |
+
"""Resize one video through the shared resize operation."""
|
| 60 |
+
return await registry.run_operation(
|
| 61 |
+
"resize_video",
|
| 62 |
+
[input],
|
| 63 |
+
{"width": width, "height": height, "fit": fit, "format": format},
|
| 64 |
+
resize_video_operation,
|
| 65 |
+
)
|
| 66 |
+
|
| 67 |
+
@server.tool(description="Crop a rectangular region from a video.")
|
| 68 |
+
async def crop_video(
|
| 69 |
+
input: MediaInput,
|
| 70 |
+
width: int,
|
| 71 |
+
height: int,
|
| 72 |
+
x: int = 0,
|
| 73 |
+
y: int = 0,
|
| 74 |
+
) -> dict[str, Any]:
|
| 75 |
+
"""Crop one video through the shared crop operation."""
|
| 76 |
+
return await registry.run_operation(
|
| 77 |
+
"crop_video",
|
| 78 |
+
[input],
|
| 79 |
+
{"width": width, "height": height, "x": x, "y": y},
|
| 80 |
+
crop_video_operation,
|
| 81 |
+
)
|
| 82 |
+
|
| 83 |
+
@server.tool(description="Trim a video by start, end, or duration.")
|
| 84 |
+
async def trim_video(
|
| 85 |
+
input: MediaInput,
|
| 86 |
+
start: float = 0,
|
| 87 |
+
duration: float | None = None,
|
| 88 |
+
end: float | None = None,
|
| 89 |
+
) -> dict[str, Any]:
|
| 90 |
+
"""Trim one video through the shared trim operation."""
|
| 91 |
+
return await registry.run_operation(
|
| 92 |
+
"trim_video",
|
| 93 |
+
[input],
|
| 94 |
+
_without_none({"start": start, "duration": duration, "end": end}),
|
| 95 |
+
trim_video_operation,
|
| 96 |
+
)
|
| 97 |
+
|
| 98 |
+
@server.tool(description="Convert a video to another supported container.")
|
| 99 |
+
async def convert_video(input: MediaInput, format: str = "mp4") -> dict[str, Any]:
|
| 100 |
+
"""Convert one video through the shared conversion operation."""
|
| 101 |
+
return await registry.run_operation(
|
| 102 |
+
"convert_video", [input], {"format": format}, convert_video_operation
|
| 103 |
+
)
|
| 104 |
+
|
| 105 |
+
@server.tool(description="Normalize and merge multiple videos in sequence.")
|
| 106 |
+
async def merge_videos(
|
| 107 |
+
inputs: list[MediaInput], width: int = 1280, height: int = 720
|
| 108 |
+
) -> dict[str, Any]:
|
| 109 |
+
"""Merge videos with normalized dimensions and stream layout."""
|
| 110 |
+
return await registry.run_operation(
|
| 111 |
+
"merge_videos",
|
| 112 |
+
inputs,
|
| 113 |
+
{"width": width, "height": height},
|
| 114 |
+
merge_video,
|
| 115 |
+
)
|
| 116 |
+
|
| 117 |
+
@server.tool(description="Concatenate videos, optionally using stream copy.")
|
| 118 |
+
async def concat_videos(inputs: list[MediaInput], stream_copy: bool = False) -> dict[str, Any]:
|
| 119 |
+
"""Concatenate multiple videos through the shared concat operation."""
|
| 120 |
+
return await registry.run_operation(
|
| 121 |
+
"concat_videos", inputs, {"stream_copy": stream_copy}, concat_video
|
| 122 |
+
)
|
| 123 |
+
|
| 124 |
+
@server.tool(description="Add an image watermark to a video.")
|
| 125 |
+
async def watermark_video(
|
| 126 |
+
video: MediaInput,
|
| 127 |
+
watermark: MediaInput,
|
| 128 |
+
position: str = "bottom-right",
|
| 129 |
+
opacity: float = 1.0,
|
| 130 |
+
watermark_scale: float = 1.0,
|
| 131 |
+
) -> dict[str, Any]:
|
| 132 |
+
"""Apply a watermark using the existing overlay operation."""
|
| 133 |
+
return await registry.run_operation(
|
| 134 |
+
"watermark_video",
|
| 135 |
+
[video, watermark],
|
| 136 |
+
{
|
| 137 |
+
"position": position,
|
| 138 |
+
"opacity": opacity,
|
| 139 |
+
"watermark_scale": watermark_scale,
|
| 140 |
+
},
|
| 141 |
+
watermark_video_operation,
|
| 142 |
+
)
|
| 143 |
+
|
| 144 |
+
@server.tool(description="Overlay an image or video layer on a video.")
|
| 145 |
+
async def overlay_video(
|
| 146 |
+
video: MediaInput,
|
| 147 |
+
overlay: MediaInput,
|
| 148 |
+
position: str = "center",
|
| 149 |
+
opacity: float = 1.0,
|
| 150 |
+
watermark_scale: float = 1.0,
|
| 151 |
+
) -> dict[str, Any]:
|
| 152 |
+
"""Overlay media through the shared video overlay operation."""
|
| 153 |
+
return await registry.run_operation(
|
| 154 |
+
"overlay_video",
|
| 155 |
+
[video, overlay],
|
| 156 |
+
{
|
| 157 |
+
"position": position,
|
| 158 |
+
"opacity": opacity,
|
| 159 |
+
"watermark_scale": watermark_scale,
|
| 160 |
+
},
|
| 161 |
+
overlay_video_operation,
|
| 162 |
+
)
|
| 163 |
+
|
| 164 |
+
@server.tool(description="Extract an audio track from video.")
|
| 165 |
+
async def extract_audio(input: MediaInput, format: str = "mp3") -> dict[str, Any]:
|
| 166 |
+
"""Extract audio through the existing extraction operation."""
|
| 167 |
+
return await registry.run_operation(
|
| 168 |
+
"extract_audio", [input], {"format": format}, extract_audio_operation
|
| 169 |
+
)
|
| 170 |
+
|
| 171 |
+
@server.tool(description="Replace a video's audio track with another media input.")
|
| 172 |
+
async def replace_audio(video: MediaInput, audio: MediaInput) -> dict[str, Any]:
|
| 173 |
+
"""Replace audio through the shared operation."""
|
| 174 |
+
return await registry.run_operation(
|
| 175 |
+
"replace_audio", [video, audio], {}, replace_audio_operation
|
| 176 |
+
)
|
| 177 |
+
|
| 178 |
+
@server.tool(description="Remove all audio streams from a video.")
|
| 179 |
+
async def remove_audio(input: MediaInput) -> dict[str, Any]:
|
| 180 |
+
"""Remove audio through the shared operation."""
|
| 181 |
+
return await registry.run_operation("remove_audio", [input], {}, remove_audio_operation)
|
| 182 |
+
|
| 183 |
+
@server.tool(description="Generate a JPEG thumbnail from a video timestamp.")
|
| 184 |
+
async def generate_thumbnail(
|
| 185 |
+
input: MediaInput, timestamp: float = 0, quality: int = 3
|
| 186 |
+
) -> dict[str, Any]:
|
| 187 |
+
"""Generate a thumbnail through the shared thumbnail operation."""
|
| 188 |
+
return await registry.run_operation(
|
| 189 |
+
"generate_thumbnail",
|
| 190 |
+
[input],
|
| 191 |
+
{"timestamp": timestamp, "quality": quality},
|
| 192 |
+
thumbnail_operation,
|
| 193 |
+
)
|
| 194 |
+
|
| 195 |
+
@server.tool(description="Extract video frames into a ZIP archive.")
|
| 196 |
+
async def extract_frames(
|
| 197 |
+
input: MediaInput, fps: float = 1, max_frames: int | None = None
|
| 198 |
+
) -> dict[str, Any]:
|
| 199 |
+
"""Extract frames through the shared frame operation."""
|
| 200 |
+
return await registry.run_operation(
|
| 201 |
+
"extract_frames",
|
| 202 |
+
[input],
|
| 203 |
+
_without_none({"fps": fps, "max_frames": max_frames}),
|
| 204 |
+
extract_frames_operation,
|
| 205 |
+
)
|
| 206 |
+
|
| 207 |
+
@server.tool(description="Burn SRT, VTT, ASS, or SSA subtitles into video pixels.")
|
| 208 |
+
async def burn_subtitles(
|
| 209 |
+
video: MediaInput,
|
| 210 |
+
subtitles: MediaInput,
|
| 211 |
+
style: str | None = None,
|
| 212 |
+
) -> dict[str, Any]:
|
| 213 |
+
"""Burn subtitles through the existing subtitle operation."""
|
| 214 |
+
return await registry.run_operation(
|
| 215 |
+
"burn_subtitles",
|
| 216 |
+
[video, subtitles],
|
| 217 |
+
_without_none({"style": style}),
|
| 218 |
+
burn_subtitles_operation,
|
| 219 |
+
)
|
| 220 |
+
|
| 221 |
+
|
| 222 |
+
def _without_none(values: dict[str, Any]) -> dict[str, Any]:
|
| 223 |
+
return {key: value for key, value in values.items() if value is not None}
|
app/mcp/tools/whisper.py
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from typing import Any
|
| 4 |
+
|
| 5 |
+
from mcp.server.fastmcp import FastMCP
|
| 6 |
+
|
| 7 |
+
from app.mcp.registry import MCPRegistry, MediaInput
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
def register_whisper_tools(server: FastMCP[Any], registry: MCPRegistry) -> None:
|
| 11 |
+
"""Register faster-whisper MCP tools using the shared WhisperService."""
|
| 12 |
+
|
| 13 |
+
@server.tool(description="Transcribe speech with faster-whisper on CPU/int8.")
|
| 14 |
+
async def transcribe(
|
| 15 |
+
input: MediaInput,
|
| 16 |
+
model: str | None = None,
|
| 17 |
+
language: str | None = None,
|
| 18 |
+
output_format: str = "txt",
|
| 19 |
+
beam_size: int = 5,
|
| 20 |
+
vad_filter: bool = True,
|
| 21 |
+
) -> dict[str, Any]:
|
| 22 |
+
"""Transcribe media in its source language."""
|
| 23 |
+
return await registry.run_whisper(
|
| 24 |
+
"transcribe",
|
| 25 |
+
input,
|
| 26 |
+
_options(model, language, "transcribe", output_format, beam_size, vad_filter),
|
| 27 |
+
)
|
| 28 |
+
|
| 29 |
+
@server.tool(description="Translate speech to English with faster-whisper.")
|
| 30 |
+
async def translate(
|
| 31 |
+
input: MediaInput,
|
| 32 |
+
model: str | None = None,
|
| 33 |
+
language: str | None = None,
|
| 34 |
+
output_format: str = "txt",
|
| 35 |
+
beam_size: int = 5,
|
| 36 |
+
vad_filter: bool = True,
|
| 37 |
+
) -> dict[str, Any]:
|
| 38 |
+
"""Translate and transcribe media into English."""
|
| 39 |
+
return await registry.run_whisper(
|
| 40 |
+
"translate",
|
| 41 |
+
input,
|
| 42 |
+
_options(model, language, "translate", output_format, beam_size, vad_filter),
|
| 43 |
+
)
|
| 44 |
+
|
| 45 |
+
@server.tool(description="Detect spoken language and return transcription metadata.")
|
| 46 |
+
async def detect_language(input: MediaInput, model: str | None = None) -> dict[str, Any]:
|
| 47 |
+
"""Detect language using Whisper's transcription information."""
|
| 48 |
+
return await registry.run_whisper(
|
| 49 |
+
"detect_language",
|
| 50 |
+
input,
|
| 51 |
+
_options(model, None, "transcribe", "json", 1, True),
|
| 52 |
+
)
|
| 53 |
+
|
| 54 |
+
@server.tool(description="Generate SubRip (SRT) subtitles from speech.")
|
| 55 |
+
async def generate_srt(
|
| 56 |
+
input: MediaInput,
|
| 57 |
+
model: str | None = None,
|
| 58 |
+
language: str | None = None,
|
| 59 |
+
task: str = "transcribe",
|
| 60 |
+
) -> dict[str, Any]:
|
| 61 |
+
"""Generate an SRT subtitle file."""
|
| 62 |
+
return await registry.run_whisper(
|
| 63 |
+
"generate_srt", input, _options(model, language, task, "srt", 5, True)
|
| 64 |
+
)
|
| 65 |
+
|
| 66 |
+
@server.tool(description="Generate WebVTT subtitles from speech.")
|
| 67 |
+
async def generate_vtt(
|
| 68 |
+
input: MediaInput,
|
| 69 |
+
model: str | None = None,
|
| 70 |
+
language: str | None = None,
|
| 71 |
+
task: str = "transcribe",
|
| 72 |
+
) -> dict[str, Any]:
|
| 73 |
+
"""Generate a WebVTT subtitle file."""
|
| 74 |
+
return await registry.run_whisper(
|
| 75 |
+
"generate_vtt", input, _options(model, language, task, "vtt", 5, True)
|
| 76 |
+
)
|
| 77 |
+
|
| 78 |
+
@server.tool(description="Generate structured JSON transcript data.")
|
| 79 |
+
async def generate_json(
|
| 80 |
+
input: MediaInput,
|
| 81 |
+
model: str | None = None,
|
| 82 |
+
language: str | None = None,
|
| 83 |
+
task: str = "transcribe",
|
| 84 |
+
) -> dict[str, Any]:
|
| 85 |
+
"""Generate a JSON transcript with timestamped segments."""
|
| 86 |
+
return await registry.run_whisper(
|
| 87 |
+
"generate_json", input, _options(model, language, task, "json", 5, True)
|
| 88 |
+
)
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
def _options(
|
| 92 |
+
model: str | None,
|
| 93 |
+
language: str | None,
|
| 94 |
+
task: str,
|
| 95 |
+
output_format: str,
|
| 96 |
+
beam_size: int,
|
| 97 |
+
vad_filter: bool,
|
| 98 |
+
) -> dict[str, Any]:
|
| 99 |
+
options: dict[str, Any] = {
|
| 100 |
+
"task": task,
|
| 101 |
+
"output_format": output_format,
|
| 102 |
+
"beam_size": beam_size,
|
| 103 |
+
"vad_filter": vad_filter,
|
| 104 |
+
}
|
| 105 |
+
if model is not None:
|
| 106 |
+
options["model"] = model
|
| 107 |
+
if language is not None:
|
| 108 |
+
options["language"] = language
|
| 109 |
+
return options
|
app/mcp/tools/ytdlp.py
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from typing import Any
|
| 4 |
+
|
| 5 |
+
from mcp.server.fastmcp import FastMCP
|
| 6 |
+
|
| 7 |
+
from app.mcp.registry import MCPRegistry, MediaInput
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
def register_ytdlp_tools(server: FastMCP[Any], registry: MCPRegistry) -> None:
|
| 11 |
+
"""Register yt-dlp tools through the shared YTDLPService and resolver."""
|
| 12 |
+
|
| 13 |
+
@server.tool(description="Download the best available video with yt-dlp.")
|
| 14 |
+
async def download_video(url: str, format_selector: str | None = None) -> dict[str, Any]:
|
| 15 |
+
"""Download one video URL."""
|
| 16 |
+
params: dict[str, Any] = {"mode": "video"}
|
| 17 |
+
if format_selector is not None:
|
| 18 |
+
params["format"] = format_selector
|
| 19 |
+
return await registry.run_ytdlp("download_video", MediaInput(url=url), params)
|
| 20 |
+
|
| 21 |
+
@server.tool(description="Download and convert the best audio with yt-dlp.")
|
| 22 |
+
async def download_audio(
|
| 23 |
+
url: str,
|
| 24 |
+
audio_format: str = "mp3",
|
| 25 |
+
format_selector: str | None = None,
|
| 26 |
+
) -> dict[str, Any]:
|
| 27 |
+
"""Download one URL as audio."""
|
| 28 |
+
params: dict[str, Any] = {"mode": "audio", "audio_format": audio_format}
|
| 29 |
+
if format_selector is not None:
|
| 30 |
+
params["format"] = format_selector
|
| 31 |
+
return await registry.run_ytdlp("download_audio", MediaInput(url=url), params)
|
| 32 |
+
|
| 33 |
+
@server.tool(
|
| 34 |
+
name="video_metadata", description="Read yt-dlp metadata without downloading media."
|
| 35 |
+
)
|
| 36 |
+
async def ytdlp_video_metadata(url: str) -> dict[str, Any]:
|
| 37 |
+
"""Return platform metadata for one video URL."""
|
| 38 |
+
return await registry.run_ytdlp("video_metadata", MediaInput(url=url), {"mode": "metadata"})
|
| 39 |
+
|
| 40 |
+
@server.tool(description="Read bounded playlist metadata without downloading entries.")
|
| 41 |
+
async def playlist_metadata(url: str, max_entries: int = 100) -> dict[str, Any]:
|
| 42 |
+
"""Return flat metadata for up to max_entries playlist items."""
|
| 43 |
+
return await registry.run_ytdlp(
|
| 44 |
+
"playlist_metadata",
|
| 45 |
+
MediaInput(url=url),
|
| 46 |
+
{
|
| 47 |
+
"mode": "metadata",
|
| 48 |
+
"playlist": True,
|
| 49 |
+
"max_entries": max_entries,
|
| 50 |
+
},
|
| 51 |
+
)
|
| 52 |
+
|
| 53 |
+
@server.tool(description="List available yt-dlp video and audio formats for a URL.")
|
| 54 |
+
async def list_formats(url: str) -> dict[str, Any]:
|
| 55 |
+
"""Return normalized yt-dlp format summaries."""
|
| 56 |
+
return await registry.run_ytdlp(
|
| 57 |
+
"list_formats",
|
| 58 |
+
MediaInput(url=url),
|
| 59 |
+
{"mode": "metadata", "include_formats": True},
|
| 60 |
+
)
|
app/models/__init__.py
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Pydantic domain and transport models."""
|
| 2 |
+
|
| 3 |
+
from app.models.media import InputMedia, MediaSource, OperationResult, ResolvedRequest
|
| 4 |
+
|
| 5 |
+
__all__ = ["InputMedia", "MediaSource", "OperationResult", "ResolvedRequest"]
|
app/models/media.py
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from enum import Enum
|
| 4 |
+
from pathlib import Path
|
| 5 |
+
from typing import Any
|
| 6 |
+
|
| 7 |
+
from pydantic import BaseModel, ConfigDict, Field
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
class MediaSource(str, Enum):
|
| 11 |
+
MULTIPART = "multipart"
|
| 12 |
+
JSON_URL = "json_url"
|
| 13 |
+
JSON_BASE64 = "json_base64"
|
| 14 |
+
N8N_BINARY = "n8n_binary"
|
| 15 |
+
OCTET_STREAM = "octet_stream"
|
| 16 |
+
YTDLP = "yt_dlp"
|
| 17 |
+
LOCAL_PATH = "local_path"
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
class InputMedia(BaseModel):
|
| 21 |
+
"""Source-agnostic media passed to all processing operations."""
|
| 22 |
+
|
| 23 |
+
model_config = ConfigDict(arbitrary_types_allowed=True)
|
| 24 |
+
|
| 25 |
+
source: MediaSource
|
| 26 |
+
filename: str
|
| 27 |
+
mime_type: str
|
| 28 |
+
temp_path: Path
|
| 29 |
+
size: int = Field(ge=0)
|
| 30 |
+
duration: float | None = None
|
| 31 |
+
metadata: dict[str, Any] = Field(default_factory=dict)
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
class ResolvedRequest(BaseModel):
|
| 35 |
+
model_config = ConfigDict(arbitrary_types_allowed=True)
|
| 36 |
+
|
| 37 |
+
request_id: str
|
| 38 |
+
inputs: list[InputMedia]
|
| 39 |
+
params: dict[str, Any] = Field(default_factory=dict)
|
| 40 |
+
|
| 41 |
+
@property
|
| 42 |
+
def primary(self) -> InputMedia:
|
| 43 |
+
return self.inputs[0]
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
class OperationResult(BaseModel):
|
| 47 |
+
model_config = ConfigDict(arbitrary_types_allowed=True)
|
| 48 |
+
|
| 49 |
+
path: Path | None = None
|
| 50 |
+
filename: str | None = None
|
| 51 |
+
mime_type: str | None = None
|
| 52 |
+
metadata: dict[str, Any] = Field(default_factory=dict)
|
app/models/requests.py
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from typing import Any, Literal
|
| 4 |
+
|
| 5 |
+
from pydantic import BaseModel, ConfigDict, Field
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
class URLInput(BaseModel):
|
| 9 |
+
model_config = ConfigDict(extra="allow")
|
| 10 |
+
|
| 11 |
+
url: str
|
| 12 |
+
filename: str | None = None
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
class Base64Input(BaseModel):
|
| 16 |
+
model_config = ConfigDict(extra="allow")
|
| 17 |
+
|
| 18 |
+
base64: str
|
| 19 |
+
filename: str | None = None
|
| 20 |
+
mime_type: str | None = None
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
class WhisperOptions(BaseModel):
|
| 24 |
+
model: Literal["tiny", "base", "small", "medium", "large-v3"] | None = None
|
| 25 |
+
task: Literal["transcribe", "translate"] = "transcribe"
|
| 26 |
+
language: str | None = None
|
| 27 |
+
output_format: Literal["txt", "srt", "vtt", "json", "tsv"] = "json"
|
| 28 |
+
beam_size: int = Field(default=5, ge=1, le=20)
|
| 29 |
+
vad_filter: bool = True
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
class YTDLPOptions(BaseModel):
|
| 33 |
+
mode: Literal["video", "audio", "thumbnail", "metadata"] = "video"
|
| 34 |
+
format: str | None = None
|
| 35 |
+
audio_format: Literal["mp3", "m4a", "wav", "opus", "flac"] = "mp3"
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
JsonDict = dict[str, Any]
|
app/operations/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
"""Media operation implementations."""
|
app/operations/common.py
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import mimetypes
|
| 4 |
+
import re
|
| 5 |
+
from collections.abc import Sequence
|
| 6 |
+
from pathlib import Path
|
| 7 |
+
from typing import Any
|
| 8 |
+
|
| 9 |
+
from app.core.exceptions import InputError, ProcessingError
|
| 10 |
+
from app.models.media import InputMedia, OperationResult
|
| 11 |
+
from app.services.ffmpeg_service import FFmpegService
|
| 12 |
+
|
| 13 |
+
VIDEO_FORMATS = {"mp4", "mkv", "webm", "mov", "avi", "m4v", "mpeg", "ts"}
|
| 14 |
+
AUDIO_FORMATS = {"mp3", "wav", "aac", "m4a", "flac", "ogg", "opus"}
|
| 15 |
+
IMAGE_FORMATS = {"jpg", "jpeg", "png", "webp", "bmp", "tiff", "gif"}
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
def require_inputs(inputs: Sequence[InputMedia], count: int = 1) -> None:
|
| 19 |
+
if len(inputs) < count:
|
| 20 |
+
raise InputError(f"This operation requires at least {count} media input(s)")
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def output_path(output_dir: Path, stem: str, extension: str) -> Path:
|
| 24 |
+
clean_extension = extension.lower().lstrip(".")
|
| 25 |
+
if not re.fullmatch(r"[a-z0-9]{2,5}", clean_extension):
|
| 26 |
+
raise InputError("Invalid output format")
|
| 27 |
+
output_dir.mkdir(parents=True, exist_ok=True)
|
| 28 |
+
return output_dir / f"{stem}.{clean_extension}"
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def format_param(params: dict[str, Any], default: str, allowed: set[str]) -> str:
|
| 32 |
+
value = str(params.get("format", default)).lower().lstrip(".")
|
| 33 |
+
if value not in allowed:
|
| 34 |
+
raise InputError(
|
| 35 |
+
"Unsupported output format", details={"format": value, "allowed": sorted(allowed)}
|
| 36 |
+
)
|
| 37 |
+
return value
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def video_codecs(extension: str, *, crf: int = 23, preset: str = "medium") -> list[str]:
|
| 41 |
+
if extension == "webm":
|
| 42 |
+
return ["-c:v", "libvpx-vp9", "-crf", str(crf), "-b:v", "0", "-c:a", "libopus"]
|
| 43 |
+
if extension == "avi":
|
| 44 |
+
return ["-c:v", "mpeg4", "-q:v", "5", "-c:a", "libmp3lame"]
|
| 45 |
+
if extension in {"mpeg", "ts"}:
|
| 46 |
+
return ["-c:v", "mpeg2video", "-c:a", "mp2"]
|
| 47 |
+
return [
|
| 48 |
+
"-c:v",
|
| 49 |
+
"libx264",
|
| 50 |
+
"-preset",
|
| 51 |
+
preset,
|
| 52 |
+
"-crf",
|
| 53 |
+
str(crf),
|
| 54 |
+
"-c:a",
|
| 55 |
+
"aac",
|
| 56 |
+
"-b:a",
|
| 57 |
+
"128k",
|
| 58 |
+
"-movflags",
|
| 59 |
+
"+faststart",
|
| 60 |
+
]
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
def audio_codec(extension: str) -> list[str]:
|
| 64 |
+
return {
|
| 65 |
+
"mp3": ["-c:a", "libmp3lame", "-b:a", "192k"],
|
| 66 |
+
"wav": ["-c:a", "pcm_s16le"],
|
| 67 |
+
"aac": ["-c:a", "aac", "-b:a", "192k"],
|
| 68 |
+
"m4a": ["-c:a", "aac", "-b:a", "192k"],
|
| 69 |
+
"flac": ["-c:a", "flac"],
|
| 70 |
+
"ogg": ["-c:a", "libvorbis", "-q:a", "5"],
|
| 71 |
+
"opus": ["-c:a", "libopus", "-b:a", "128k"],
|
| 72 |
+
}[extension]
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
async def execute(
|
| 76 |
+
ffmpeg: FFmpegService,
|
| 77 |
+
args: Sequence[str | Path],
|
| 78 |
+
output: Path,
|
| 79 |
+
operation: str,
|
| 80 |
+
metadata: dict[str, Any] | None = None,
|
| 81 |
+
) -> OperationResult:
|
| 82 |
+
await ffmpeg.run(args, operation=operation)
|
| 83 |
+
if not output.is_file() or output.stat().st_size == 0:
|
| 84 |
+
raise ProcessingError("The operation did not produce an output file")
|
| 85 |
+
return OperationResult(
|
| 86 |
+
path=output,
|
| 87 |
+
filename=output.name,
|
| 88 |
+
mime_type=mimetypes.guess_type(output.name)[0] or "application/octet-stream",
|
| 89 |
+
metadata={"operation": operation, **(metadata or {})},
|
| 90 |
+
)
|
| 91 |
+
|
| 92 |
+
|
| 93 |
+
def even(value: int) -> int:
|
| 94 |
+
return max(2, value if value % 2 == 0 else value - 1)
|
| 95 |
+
|
| 96 |
+
|
| 97 |
+
def subtitle_filter_path(path: Path) -> str:
|
| 98 |
+
value = str(path.resolve()).replace("\\", "\\\\")
|
| 99 |
+
for character in (":", "'", "[", "]", ","):
|
| 100 |
+
value = value.replace(character, f"\\{character}")
|
| 101 |
+
return value
|
| 102 |
+
|
| 103 |
+
|
| 104 |
+
def atempo_chain(factor: float) -> str:
|
| 105 |
+
values: list[float] = []
|
| 106 |
+
remaining = factor
|
| 107 |
+
while remaining > 2.0:
|
| 108 |
+
values.append(2.0)
|
| 109 |
+
remaining /= 2.0
|
| 110 |
+
while remaining < 0.5:
|
| 111 |
+
values.append(0.5)
|
| 112 |
+
remaining /= 0.5
|
| 113 |
+
values.append(remaining)
|
| 114 |
+
return ",".join(f"atempo={value:.6g}" for value in values)
|