diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000000000000000000000000000000000000..bbc903ef960bc6d61d823518cb7669d112fab71c --- /dev/null +++ b/.dockerignore @@ -0,0 +1,16 @@ +.git +.venv +.github +.env +.pytest_cache +.ruff_cache +__pycache__ +*.py[cod] +*.log +*.zip +outputs/* +!outputs/.gitkeep +temp/* +!temp/.gitkeep +tests +requirements-dev.txt diff --git a/.env.example b/.env.example new file mode 100644 index 0000000000000000000000000000000000000000..68e0393bb61d52ab00c39a5f92a1478b24dfdca1 --- /dev/null +++ b/.env.example @@ -0,0 +1,16 @@ +TEMP_DIR=./temp +OUTPUT_DIR=./outputs +TEMPLATE_DIR=./app/templates/categories +MAX_UPLOAD_SIZE=1073741824 +WHISPER_MODEL=small +CLEANUP_MINUTES=60 +CLEANUP_INTERVAL_SECONDS=60 +MAX_WORKERS=2 +LOG_LEVEL=INFO +MAX_DURATION_SECONDS=21600 +MAX_RESOLUTION_PIXELS=33177600 +DOWNLOAD_TIMEOUT_SECONDS=300 +ALLOW_PRIVATE_URLS=false +BASE_URL= +FFMPEG_BINARY=ffmpeg +FFPROBE_BINARY=ffprobe diff --git a/.gitignore b/.gitignore index 21dee5891503fe797b847d5c4f00508858898fa6..9ae7a7c22d78dea157b16b4ec0e2c1f352eb222c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,16 @@ +.env +.venv/ +venv/ __pycache__/ *.py[cod] +*.log .pytest_cache/ -temp/ -.env +.ruff_cache/ +.mypy_cache/ +.coverage +htmlcov/ +outputs/* +!outputs/.gitkeep +temp/* +!temp/.gitkeep +*.zip diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000000000000000000000000000000000000..bed79e59ac47f24d66657b725c6a662bce38a9df --- /dev/null +++ b/Dockerfile @@ -0,0 +1,43 @@ +FROM python:3.10-slim + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + PIP_NO_CACHE_DIR=1 \ + PIP_DISABLE_PIP_VERSION_CHECK=1 \ + HF_HOME=/home/user/.cache/huggingface \ + TEMP_DIR=/app/temp \ + OUTPUT_DIR=/app/outputs \ + PORT=7860 + +RUN apt-get update \ + && apt-get install --no-install-recommends -y \ + ca-certificates \ + ffmpeg \ + imagemagick \ + libglib2.0-0 \ + libgomp1 \ + libsndfile1 \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /app + +COPY requirements.txt ./ +RUN python -m pip install --upgrade pip \ + && python -m pip install -r requirements.txt + +RUN useradd --create-home --uid 1000 user \ + && mkdir -p /app/temp /app/outputs /home/user/.cache/huggingface \ + && chown -R user:user /app /home/user + +COPY --chown=user:user app ./app +COPY --chown=user:user api services operations workers core models ./ +COPY --chown=user:user main.py README.md ./ + +USER user + +EXPOSE 7860 + +HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \ + CMD python -c "import urllib.request; urllib.request.urlopen('http://127.0.0.1:7860/health', timeout=4)" || exit 1 + +CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "7860", "--workers", "1", "--proxy-headers", "--forwarded-allow-ips", "*"] diff --git a/README.md b/README.md new file mode 100644 index 0000000000000000000000000000000000000000..4f1e75e1c713db1aadc8f87445839eeb844a0f8c --- /dev/null +++ b/README.md @@ -0,0 +1,995 @@ +--- +title: Enterprise Media Processing API +emoji: 🎬 +colorFrom: blue +colorTo: purple +sdk: docker +app_port: 7860 +pinned: false +--- + +# Enterprise Media Processing API + +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. + +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. + +## Architecture + +```text +REST /v1/* MCP stdio or /mcp/ + β”‚ β”‚ + β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + β–Ό + request-ID + JSON logging + β”‚ + InputResolver + β”œβ”€β”€ streamed multipart/raw upload + β”œβ”€β”€ Base64/n8n decoding + β”œβ”€β”€ HTTP(S) streaming downloader + SSRF guard + └── automatic yt-dlp extractor selection + β”‚ + β–Ό + normalized InputMedia + β”‚ + FFprobe validation/metadata + β”‚ + optional versioned Template Engine + β”‚ + operation (video/audio/image) + β”‚ + concurrency-limited FFmpeg/Whisper + β”‚ + published output + streaming URL + β”‚ + background expiry cleanup +``` + +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. + +## Project layout + +```text +media-api/ +β”œβ”€β”€ app/ +β”‚ β”œβ”€β”€ api/ # versioned, thin FastAPI routes +β”‚ β”œβ”€β”€ core/ # settings, errors, logging, response models +β”‚ β”œβ”€β”€ mcp/ # MCP server, registry, tools, resources, prompts +β”‚ β”œβ”€β”€ models/ # InputMedia and request/result models +β”‚ β”œβ”€β”€ operations/ # reusable FFmpeg operation functions +β”‚ β”œβ”€β”€ services/ # FFmpeg, FFprobe, resolver, downloads, Whisper +β”‚ β”œβ”€β”€ templates/ # YAML schemas, loader, registry, executor, categories +β”‚ β”œβ”€β”€ workers/ # asynchronous expiry cleanup worker +β”‚ └── container.py # dependency construction +β”œβ”€β”€ api/ # requested top-level import compatibility +β”œβ”€β”€ services/ # requested top-level import compatibility +β”œβ”€β”€ operations/ # requested top-level import compatibility +β”œβ”€β”€ workers/ # requested top-level import compatibility +β”œβ”€β”€ core/ # requested top-level import compatibility +β”œβ”€β”€ models/ # requested top-level import compatibility +β”œβ”€β”€ outputs/ # published, expiring request outputs +β”œβ”€β”€ temp/ # request/{uploads,outputs,logs} +β”œβ”€β”€ tests/ +β”œβ”€β”€ Dockerfile +β”œβ”€β”€ requirements.txt +β”œβ”€β”€ requirements-dev.txt +β”œβ”€β”€ .env.example +└── main.py +``` + +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. + +Each request creates `TEMP_DIR//{uploads,outputs,logs}`. Completed files are atomically moved to `OUTPUT_DIR/` so they can be streamed. Both locations expire after `CLEANUP_MINUTES`; active requests are protected from the cleanup worker. + +## Run locally + +FFmpeg, FFprobe, and ImageMagick must be installed on the host. + +```bash +python3.10 -m venv .venv +. .venv/bin/activate +pip install -r requirements.txt +cp .env.example .env +uvicorn main:app --host 0.0.0.0 --port 7860 +``` + +Open `http://localhost:7860/docs` for OpenAPI/Swagger or `http://localhost:7860/redoc` for ReDoc. + +### Docker + +```bash +docker build -t media-api . +docker run --rm -p 7860:7860 --env-file .env media-api +``` + +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. + +## Deploy to Hugging Face Spaces + +1. Create a new Space and select **Docker** as the SDK. +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`. +3. Add environment variables under **Settings β†’ Variables and secrets** if the defaults need changing. Do not store secrets in `.env` in Git. +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. +5. Check `https://-.hf.space/health`, then use `/docs` or call `/v1/*` from n8n. + +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. + +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. + +## Model Context Protocol (MCP) + +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: + +```text +FastAPI REST routes ─┐ + β”œβ”€ InputResolver β†’ MediaProcessor β†’ shared services/operations +MCP tools β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”œβ”€ FFmpeg / FFprobe + β”œβ”€ yt-dlp + └─ faster-whisper +``` + +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. + +### MCP transports + +The normal Docker command starts FastAPI on port `7860` and serves both REST and MCP: + +- REST and OpenAPI: `https://-.hf.space/v1/*` and `/docs` +- Streamable HTTP MCP: `https://-.hf.space/mcp/` + +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. + +For a local stdio client, run from the project directory: + +```bash +python -m app.mcp.server +``` + +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: + +```bash +python -m app.mcp.server --transport streamable-http +``` + +Use the normal `uvicorn main:app ...` command in Hugging Face because it exposes both interfaces together. + +### MCP client configuration + +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: + +```json +{ + "mcpServers": { + "enterprise-media": { + "command": "/absolute/path/media-api/.venv/bin/python", + "args": ["-m", "app.mcp.server"], + "cwd": "/absolute/path/media-api", + "env": { + "TEMP_DIR": "/absolute/path/media-api/temp", + "OUTPUT_DIR": "/absolute/path/media-api/outputs", + "WHISPER_MODEL": "small", + "MAX_WORKERS": "2" + } + } + } +} +``` + +Clients that support remote Streamable HTTP can use: + +```json +{ + "mcpServers": { + "enterprise-media": { + "url": "https://OWNER-SPACE.hf.space/mcp/" + } + } +} +``` + +Common client locations and connection choices are: + +| Client | Configuration | +|---|---| +| Claude Desktop | Add the stdio `mcpServers` entry to `claude_desktop_config.json`, then restart Claude. | +| ChatGPT | Add a remote custom connector in developer/connector settings with the Space `/mcp/` URL. The Space must be reachable from ChatGPT. | +| Cursor | Put the remote `mcpServers` entry in `.cursor/mcp.json`, or use the stdio entry for local files. | +| VS Code | Add an HTTP server under `servers` in `.vscode/mcp.json`: `{"type":"http","url":"https://OWNER-SPACE.hf.space/mcp/"}`. | +| Continue | Add an MCP server in Continue configuration using the Streamable HTTP URL or the stdio command above. | +| Cline | Open **MCP Servers β†’ Configure** and add the `mcpServers` JSON entry. | +| Windsurf | Add the same entry in Windsurf MCP settings (`mcp_config.json`). | + +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. + +For a Docker-based stdio client, override the image command and keep stdin open: + +```bash +docker run --rm -i \ + -v "$PWD/temp:/app/temp" \ + -v "$PWD/outputs:/app/outputs" \ + media-api python -m app.mcp.server +``` + +In stdio mode, consume the returned `output_file` locally. In Streamable HTTP mode, set `BASE_URL=https://-.hf.space` so `download_url` is absolute. + +### MCP media input + +Every MCP media tool delegates to the existing `InputResolver`. A `MediaInput` accepts exactly one source: + +```json +{"url": "https://cdn.example.com/video.mp4"} +``` + +```json +{ + "base64": "data:audio/wav;base64,UklGR...", + "filename": "speech.wav", + "mime_type": "audio/wav" +} +``` + +```json +{ + "binary": { + "data": "AAAAHGZ0eXBpc29t...", + "fileName": "clip.mp4", + "mimeType": "video/mp4" + } +} +``` + +```json +{"temp_path": "/app/outputs//converted.mp4"} +``` + +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. + +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. + +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`: + +```json +{ + "success": true, + "request_id": "43b32630-9b32-4b27-b038-a39fbfd4b803", + "processing_time": 1.52, + "output_file": "/app/outputs/43b32630-9b32-4b27-b038-a39fbfd4b803/compressed.mp4", + "download_url": "https://OWNER-SPACE.hf.space/v1/media/43b32630-4b27-b038-a39fbfd4b803/compressed.mp4", + "metadata": {} +} +``` + +Errors use the same safe application codes and never expose raw Python exceptions: + +```json +{ + "success": false, + "request_id": "43b32630-9b32-4b27-b038-a39fbfd4b803", + "processing_time": 0.01, + "error": { + "code": "INVALID_INPUT", + "message": "The managed temporary file does not exist", + "details": null + } +} +``` + +### Available MCP tools + +All tools return the structured envelope above. Defaults shown here match the callable schemas advertised to MCP clients. + +| Video tool | Purpose and principal options | +|---|---| +| `compress_video` | CPU-optimized compression; `crf=28`, `preset=medium`, `format=mp4`, optional `max_width` and `bitrate`. | +| `resize_video` | Resize using `width`, `height`, `fit` (`contain`, `cover`, `fill`), and `format`. | +| `crop_video` | Crop using required `width`/`height` and optional `x`/`y`. | +| `trim_video` | Trim with `start` plus `duration` or `end`. | +| `convert_video` | Convert to a supported video `format`. | +| `merge_videos` | Normalize and merge `inputs`; optional output `width`/`height`. | +| `concat_videos` | Concatenate `inputs`; optional `stream_copy` for already-compatible streams. | +| `watermark_video` | Watermark `video` with an image; `position`, `opacity`, and `watermark_scale`. | +| `overlay_video` | Overlay media on `video`; `position`, `opacity`, and `watermark_scale`. | +| `extract_audio` | Extract a video audio stream to `format` (default MP3). | +| `replace_audio` | Replace the audio in `video` with the supplied `audio`. | +| `remove_audio` | Remove all audio streams. | +| `generate_thumbnail` | Create a JPEG at `timestamp`; optional FFmpeg `quality`. | +| `extract_frames` | Extract at `fps`, optionally bounded by `max_frames`, and return a ZIP. | +| `burn_subtitles` | Burn SRT, VTT, ASS, or SSA `subtitles` into `video`; optional ASS `style`. | + +| Audio tool | Purpose and principal options | +|---|---| +| `convert_audio` | Convert to MP3, WAV, AAC, M4A, FLAC, OGG, or Opus. | +| `normalize_audio` | EBU loudness normalization with `target_lufs=-16`. | +| `trim_audio` | Trim with `start` plus `duration` or `end`. | +| `merge_audio` | Normalize and merge multiple `inputs`. | +| `remove_silence` | Remove silence using a threshold such as `-45dB`. | + +| Image tool | Purpose and principal options | +|---|---| +| `image_to_video` | Turn one still image into H.264 video; `duration` and `fps`. | +| `slideshow` | Create a video from `inputs`; `duration_per_image`, `width`, `height`, and `fps`. | +| `watermark_image` | Watermark an image; `position`, `opacity`, `watermark_scale`, and output `format`. | +| `resize_image` | Resize with `width`, `height`, `fit`, and output `format`. | + +| Whisper tool | Purpose and principal options | +|---|---| +| `transcribe` | Transcribe speech; optional `model`, `language`, `output_format`, `beam_size`, and `vad_filter`. | +| `translate` | Translate speech to English with the same options. | +| `detect_language` | Detect spoken language using an optional `model`. | +| `generate_srt` | Generate SRT with optional `model`, `language`, and `task`. | +| `generate_vtt` | Generate WebVTT with optional `model`, `language`, and `task`. | +| `generate_json` | Generate JSON transcript segments with optional `model`, `language`, and `task`. | + +| yt-dlp tool | Purpose and principal options | +|---|---| +| `download_video` | Download best video; optional yt-dlp `format_selector`. | +| `download_audio` | Download/convert audio; `audio_format=mp3` and optional `format_selector`. | +| `video_metadata` | Extract platform metadata without downloading media. | +| `playlist_metadata` | Extract flat playlist metadata, bounded by `max_entries` (1–1000). | +| `list_formats` | List normalized available audio/video formats. | + +| FFprobe tool | Purpose | +|---|---| +| `probe_media` | Complete normalized metadata. | +| `probe_video_metadata` | Video codec, dimensions, FPS, rotation, duration, and streams. | +| `audio_metadata` | Audio streams, duration, and bitrate. | +| `stream_info` | Video, audio, and subtitle stream summaries. | +| `container_info` | Container, tags, creation date, size, bitrate, and duration. | + +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. + +| Utility tool | Purpose | +|---|---| +| `health` | Application and dependency health without loading Whisper. | +| `cleanup_temp` | Run the existing expired-workspace cleanup pass. | +| `disk_usage` | Capacity and use for temp and output storage. | +| `system_info` | CPU, memory, Python, platform, and process details. | +| `supported_operations` | Tool names grouped by domain. | +| `supported_formats` | Media, transcription, model, and yt-dlp formats. | +| `ffmpeg_version` | Installed FFmpeg version banner. | +| `whisper_models` | Allowed/default models and CPU compute configuration. | +| `yt_dlp_version` | Installed yt-dlp package version. | + +| Template tool | Purpose | +|---|---| +| `list_templates` | Discover all dynamically loaded template versions, optionally filtered by category. | +| `template_details` | Return complete metadata and pipeline documentation for `id`, `id@version`, or `id@latest`. | +| `run_template` | Resolve one or more media inputs and execute a versioned YAML workflow. | +| `template_categories` | Return categories discovered from loaded YAML templates. | + +### Available MCP resources + +Resources return structured JSON, including a safe `success` indicator: + +| Resource URI | Contents | +|---|---| +| `media://operations` | Registered operations grouped by video, audio, image, Whisper, yt-dlp, probe, templates, and system. | +| `media://formats` | Supported containers, transcription formats, models, and audio download formats. | +| `media://codecs` | Installed FFmpeg codecs and encode/decode capabilities. | +| `media://health` | Application version and dependency availability. | +| `media://configuration` | Non-secret runtime limits and paths. | +| `media://version` | Application, MCP SDK, yt-dlp, and faster-whisper versions. | + +### Available MCP prompts + +| Prompt | Workflow | +|---|---| +| `compress_for_social_media` | Probe, resize as appropriate, and call `compress_video`. | +| `youtube_to_mp3` | Call `download_audio` as MP3. | +| `download_and_transcribe` | Download audio, then call `transcribe` on its managed output. | +| `generate_subtitles` | Call `generate_srt` or `generate_vtt`. | +| `extract_audio` | Call the `extract_audio` tool in the requested format. | +| `make_thumbnail` | Call `generate_thumbnail` at a timestamp. | +| `probe_media` | Call `probe_media` and summarize streams/container data. | +| `instagram_reel` | Probe, resize to 1080Γ—1920, then compress. | +| `tiktok_video` | Resize vertically and compress for TikTok. | +| `podcast_audio` | Normalize to -16 LUFS and optionally convert. | + +### Example MCP calls + +AI clients construct the JSON-RPC envelope automatically. The tool argument payloads are: + +Compress a remote video: + +```json +{ + "name": "compress_video", + "arguments": { + "input": {"url": "https://cdn.example.com/input.mp4"}, + "crf": 28, + "preset": "veryfast", + "format": "mp4", + "max_width": 1280 + } +} +``` + +Transcribe Base64 audio: + +```json +{ + "name": "transcribe", + "arguments": { + "input": { + "base64": "data:audio/mpeg;base64,SUQz...", + "filename": "meeting.mp3", + "mime_type": "audio/mpeg" + }, + "model": "small", + "output_format": "json" + } +} +``` + +Download a YouTube video: + +```json +{ + "name": "download_video", + "arguments": { + "url": "https://www.youtube.com/watch?v=VIDEO_ID" + } +} +``` + +Generate subtitles: + +```json +{ + "name": "generate_srt", + "arguments": { + "input": {"url": "https://cdn.example.com/interview.mp4"}, + "language": "en", + "task": "transcribe" + } +} +``` + +Probe media: + +```json +{ + "name": "probe_media", + "arguments": { + "input": {"url": "https://cdn.example.com/input.mp4"} + } +} +``` + +## Enterprise Template Engine + +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`: + +```text +REST /v1/templates/run ─┐ + β”œβ”€ InputResolver β†’ Template Registry/Validator +MCP run_template β”€β”€β”€β”€β”€β”€β”˜ β”‚ + β–Ό + parameter substitution + pipeline + β”‚ + β–Ό + existing operation functions and services +``` + +Intermediate artifacts stay inside `TEMP_DIR//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. + +### Template folders and built-ins + +```text +app/templates/ +β”œβ”€β”€ __init__.py +β”œβ”€β”€ loader.py +β”œβ”€β”€ registry.py +β”œβ”€β”€ executor.py +β”œβ”€β”€ schema.py +β”œβ”€β”€ validator.py +β”œβ”€β”€ models.py +└── categories/ + β”œβ”€β”€ social/ + β”œβ”€β”€ faceless/ + β”œβ”€β”€ motivation/ + β”œβ”€β”€ lyrics/ + β”œβ”€β”€ podcast/ + β”œβ”€β”€ subtitles/ + β”œβ”€β”€ youtube/ + β”œβ”€β”€ conversion/ + β”œβ”€β”€ branding/ + β”œβ”€β”€ utility/ + └── custom/ +``` + +The distribution contains 71 versioned workflows: + +| Category | Templates | +|---|---| +| Social | `youtube_shorts`, `tiktok_hd`, `facebook_reel`, `instagram_reel`, `linkedin_video`, `twitter_video`, `whatsapp_status` | +| 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` | +| Motivation | `motivational_video`, `morning_motivation`, `business_motivation`, `gym_motivation`, `success_quotes`, `stoic_quotes`, `daily_quotes`, `affirmations` | +| Lyrics | `lyrics_basic`, `karaoke`, `spotify_style`, `cinematic_lyrics`, `neon_lyrics`, `music_video` | +| Podcast | `podcast_video`, `podcast_short`, `audiogram`, `waveform_video` | +| Subtitles/AI | `auto_subtitles`, `translate_video`, `transcribe`, `transcribe_srt`, `transcribe_vtt`, `transcribe_json`, `youtube_to_transcript` | +| YouTube | `youtube_to_mp3`, `youtube_to_audio`, `youtube_to_shorts`, `youtube_to_podcast`, `download_only` | +| Branding | `company_branding`, `creator_branding`, `watermark`, `intro_outro`, `logo_animation` | +| Conversion | `mp4`, `mov`, `avi`, `webm`, `gif`, `mp3`, `wav`, `aac`, `flac` | +| Utility | `thumbnail_pack`, `extract_frames`, `extract_audio`, `merge_videos`, `concat_videos`, `compress_max`, `compress_balanced`, `compress_mobile` | + +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. + +### Template schema and parameters + +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: + +```yaml +id: instagram_reel_custom +name: Instagram Reel Custom +category: custom +description: Resize and compress video for a vertical Instagram Reel. +author: Your Team +version: 1 +tags: [instagram, vertical] +estimated_runtime: medium +supported_inputs: [video, url, ytdlp] +supported_outputs: [mp4] + +parameters: + crf: + type: integer + default: 23 + minimum: 0 + maximum: 51 + width: + type: integer + default: 1080 + minimum: 2 + +pipeline: + - operation: resize + width: "{{ width }}" + height: 1920 + fit: cover + - operation: fps + value: 30 + - operation: compress + crf: "{{ crf }}" + preset: veryfast + format: mp4 + +output: + format: mp4 + +examples: + - input: {url: "https://example.com/input.mp4"} + parameters: {crf: 23, width: 1080} +``` + +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. + +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`: + +```yaml +parameters: + add_logo: {type: boolean, default: false} +pipeline: + - operation: watermark_video + when: "{{ add_logo }}" + inputs: [current, original:1] +``` + +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: + +```yaml +pipeline: + - operation: transcribe + output_format: srt + save_as: captions + - operation: burn_subtitles + inputs: [original:0, artifact:captions] +``` + +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. + +### Validation and versioning + +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. + +References support stable version selection: + +- `youtube_shorts` resolves the highest installed version. +- `youtube_shorts@latest` explicitly resolves the highest installed version. +- `youtube_shorts@1` remains pinned to version 1 when version 2 is added. + +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. + +### Template REST API + +| Method | Endpoint | Purpose | +|---|---|---| +| `GET` | `/v1/templates` | List all versions with parameters, metadata, and examples; optional `?category=social`. | +| `GET` | `/v1/templates/categories` | List categories discovered from YAML. | +| `GET` | `/v1/templates/{reference}` | Get full details and pipeline for an ID/version reference. | +| `POST` | `/v1/templates/run` | Resolve media and execute a template. | + +List and inspect: + +```bash +curl http://localhost:7860/v1/templates +curl http://localhost:7860/v1/templates/instagram_reel@1 +``` + +Run with a direct or yt-dlp-supported URL: + +```bash +curl -X POST http://localhost:7860/v1/templates/run \ + -H 'Content-Type: application/json' \ + -d '{ + "template":"instagram_reel@latest", + "input":{"url":"https://example.com/input.mp4"}, + "parameters":{"crf":21} + }' +``` + +Run with multipart media; `parameters` is a JSON form field: + +```bash +curl -X POST http://localhost:7860/v1/templates/run \ + -F 'file=@input.mp4' \ + -F 'template=youtube_shorts@1' \ + -F 'parameters={"crf":23,"max_duration":60}' +``` + +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. + +### Template MCP examples + +Discover templates: + +```json +{"name":"list_templates","arguments":{"category":"social"}} +``` + +Inspect a pinned version: + +```json +{"name":"template_details","arguments":{"template":"youtube_shorts@1"}} +``` + +Execute a URL template: + +```json +{ + "name": "run_template", + "arguments": { + "template": "youtube_to_mp3@latest", + "input": {"url": "https://www.youtube.com/watch?v=VIDEO_ID"}, + "parameters": {} + } +} +``` + +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. + +### Template n8n examples + +For an n8n URL workflow, configure an **HTTP Request** node with `POST`, JSON body, and `/v1/templates/run`: + +```javascript +{ + "template": "compress_mobile@1", + "input": {"url": "{{$json.media_url}}"}, + "parameters": {"crf": 28} +} +``` + +For n8n binary JSON input: + +```javascript +{ + "template": "mp3@1", + "input": { + "binary": { + "data": "{{$binary.audio.data}}", + "fileName": "{{$binary.audio.fileName}}", + "mimeType": "{{$binary.audio.mimeType}}" + } + }, + "parameters": {} +} +``` + +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. + +### Adding custom templates + +1. Add a `.yaml` or `.yml` file below `app/templates/categories/custom/` or a mounted `TEMPLATE_DIR`. +2. Choose a unique `id` and positive integer `version`; never replace an old version used by automation. +3. Declare strict parameter types/defaults and complete metadata. +4. Compose allow-listed existing operations. Use `save_as` and input selectors for branched workflows. +5. Restart the process. Startup scanning automatically validates, registers, exposes, and documents the template through REST and MCPβ€”no Python registration change is required. +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. + +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. + +## Input contract + +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. + +### Multipart file + +```bash +curl -X POST http://localhost:7860/v1/video/compress \ + -F 'file=@input.mp4' \ + -F 'crf=28' \ + -F 'preset=veryfast' +``` + +Multiple-input operations accept repeated or differently named file fields; every uploaded file part is collected in form order. + +```bash +curl -X POST http://localhost:7860/v1/video/watermark \ + -F 'video=@input.mp4' \ + -F 'watermark=@logo.png' \ + -F 'position=bottom-right' \ + -F 'opacity=0.7' +``` + +### JSON URL + +```bash +curl -X POST http://localhost:7860/v1/video/resize \ + -H 'Content-Type: application/json' \ + -d '{"url":"https://cdn.example.com/video.mp4","width":1280,"height":720,"fit":"contain"}' +``` + +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`. + +For multiple remote inputs: + +```json +{ + "inputs": [ + {"url": "https://cdn.example.com/part-1.mp4"}, + {"url": "https://cdn.example.com/part-2.mp4"} + ], + "width": 1280, + "height": 720 +} +``` + +### JSON Base64 and n8n binary + +```json +{ + "base64": "data:audio/wav;base64,UklGR...", + "filename": "speech.wav", + "format": "mp3" +} +``` + +The resolver recognizes n8n properties named `binary.data`, `binary.file`, `binary.video`, `binary.audio`, and any other key under `binary`: + +```json +{ + "binary": { + "video": { + "data": "AAAAHGZ0eXBpc29t...", + "fileName": "clip.mp4", + "mimeType": "video/mp4" + } + }, + "crf": 26 +} +``` + +### Raw streamed bytes + +```bash +curl -X POST 'http://localhost:7860/v1/probe' \ + -H 'Content-Type: application/octet-stream' \ + -H 'X-Filename: input.mp4' \ + --data-binary '@input.mp4' +``` + +Raw and multipart uploads are written to disk in 1 MiB chunks. Downloads are also streamed; Base64 is size-checked before and after decoding. + +## Endpoints + +All processing routes use `POST`. Health and output downloads use `GET`. + +| Group | Endpoints | +|---|---| +| System | `/health`, `/v1/probe`, `/v1/media/{request_id}/{filename}` | +| Video basics | `/v1/video/compress`, `resize`, `crop`, `trim`, `rotate`, `reverse`, `convert`, `merge`, `concat` | +| Video composition | `/v1/video/overlay`, `watermark`, `replace-audio`, `subtitles/burn`, `subtitles/soft` | +| Video outputs | `/v1/video/frames`, `gif`, `thumbnail`, `remove-audio`, `mute` | +| Video timing/quality | `/v1/video/speed`, `speed-up`, `slow-motion`, `fps`, `bitrate`, `scale`, `pad`, `blur`, `sharpen`, `denoise`, `normalize` | +| Audio | `/v1/audio/extract`, `convert`, `normalize`, `trim`, `merge`, `concat`, `fade`, `volume`, `remove-silence`, `noise-reduction` | +| Image | `/v1/image/resize`, `crop`, `convert`, `slideshow`, `sequence`, `video`, `watermark`, `overlay` | +| yt-dlp | `/v1/ytdlp/download` | +| Whisper | `/v1/whisper/transcribe`, `subtitles`, `detect-language` | +| Templates | `GET /v1/templates`, `GET /v1/templates/categories`, `GET /v1/templates/{reference}`, `POST /v1/templates/run` | + +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. + +### Probe metadata + +`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. + +```bash +curl -X POST http://localhost:7860/v1/probe -F 'file=@input.mp4' +``` + +### yt-dlp + +`mode` is `video`, `audio`, `thumbnail`, or `metadata`. `format` accepts a yt-dlp format selector. Audio supports `mp3`, `m4a`, `wav`, `opus`, and `flac`. + +```bash +curl -X POST http://localhost:7860/v1/ytdlp/download \ + -H 'Content-Type: application/json' \ + -d '{"url":"https://www.youtube.com/watch?v=VIDEO_ID","mode":"audio","audio_format":"mp3"}' +``` + +```bash +curl -X POST http://localhost:7860/v1/ytdlp/download \ + -H 'Content-Type: application/json' \ + -d '{"url":"https://vimeo.com/VIDEO_ID","mode":"metadata"}' +``` + +### faster-whisper + +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`. + +```bash +curl -X POST http://localhost:7860/v1/whisper/transcribe \ + -F 'file=@meeting.mp3' \ + -F 'model=small' \ + -F 'task=transcribe' \ + -F 'output_format=json' \ + -F 'vad_filter=true' +``` + +```bash +curl -X POST http://localhost:7860/v1/whisper/subtitles \ + -F 'file=@interview.mp4' \ + -F 'task=translate' \ + -F 'output_format=vtt' +``` + +## Responses and downloads + +Processing responses are JSON: + +```json +{ + "success": true, + "request_id": "43b32630-9b32-4b27-b038-a39fbfd4b803", + "processing_time": 1.42, + "download_url": "/v1/media/43b32630-9b32-4b27-b038-a39fbfd4b803/compressed.mp4", + "metadata": {} +} +``` + +Failures never include Python exceptions or tracebacks: + +```json +{ + "success": false, + "request_id": "43b32630-9b32-4b27-b038-a39fbfd4b803", + "error": { + "code": "INVALID_INPUT", + "message": "The uploaded media is empty", + "details": null + } +} +``` + +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. + +Set `BASE_URL=https://-.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. + +## n8n recipes + +### Multipart upload + +In an **HTTP Request** node: + +- Method: `POST` +- URL: `https://.hf.space/v1/video/compress` +- Send Body: on +- Body Content Type: `Form-Data` +- Add a **n8n Binary File** parameter named `file`, selecting the incoming binary property (usually `data`) +- Add text parameters `crf=28` and `preset=veryfast` + +Use a second **HTTP Request** node with `{{$json.download_url}}`, enable **Download**, and store its response as binary. + +### JSON URL + +Set Body Content Type to JSON: + +```javascript +{ + "url": "{{$json.media_url}}", + "width": 1280, + "height": 720, + "fit": "contain" +} +``` + +### n8n binary property as JSON Base64 + +When the upstream binary property is `data`, use an expression body: + +```javascript +{ + "binary": { + "data": { + "data": "{{$binary.data.data}}", + "fileName": "{{$binary.data.fileName}}", + "mimeType": "{{$binary.data.mimeType}}" + } + }, + "format": "mp3" +} +``` + +For large files, prefer n8n's multipart binary option or raw binary body; JSON Base64 temporarily expands data by roughly 33%. + +## Configuration + +| Variable | Default | Purpose | +|---|---:|---| +| `TEMP_DIR` | `./temp` | Request workspaces and in-progress files | +| `OUTPUT_DIR` | `./outputs` | Published files served by download URLs | +| `TEMPLATE_DIR` | built-in `app/templates/categories` | Recursively scanned YAML workflow catalog | +| `MAX_UPLOAD_SIZE` | `1073741824` | Maximum bytes for each upload/download | +| `WHISPER_MODEL` | `small` | Default faster-whisper model | +| `CLEANUP_MINUTES` | `60` | TTL for request workspaces and outputs | +| `CLEANUP_INTERVAL_SECONDS` | `60` | Cleanup scan interval | +| `MAX_WORKERS` | `2` | Shared per-service CPU process/task concurrency | +| `LOG_LEVEL` | `INFO` | Structured log threshold | +| `MAX_DURATION_SECONDS` | `21600` | Maximum probed media duration | +| `MAX_RESOLUTION_PIXELS` | `33177600` | Maximum width Γ— height (8K default) | +| `DOWNLOAD_TIMEOUT_SECONDS` | `300` | Remote download timeout; FFmpeg gets 4Γ— this value | +| `ALLOW_PRIVATE_URLS` | `false` | Permit private/loopback URL downloads (normally unsafe) | +| `BASE_URL` | empty | Optional public origin for absolute download URLs | +| `FFMPEG_BINARY` | `ffmpeg` | FFmpeg executable name/path | +| `FFPROBE_BINARY` | `ffprobe` | FFprobe executable name/path | + +## Logging and safety + +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. + +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. + +## Testing and quality checks + +```bash +pip install -r requirements.txt -r requirements-dev.txt +pytest -q +ruff check app tests main.py +black --check app tests main.py +``` + +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. + +## Extending the API + +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`. +2. Validate every parameter before building arguments. Pass an argument list to `FFmpegService`; never use a shell or concatenate a command string. +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. +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. +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. + +This contract keeps new operations independent of multipart, URLs, Base64, n8n, storage, and response handling. + +## Troubleshooting + +- **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. +- **First transcription is slow:** the model is downloaded and initialized lazily. Use persistent Space storage or pre-warm with a short request after deployment. +- **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. +- **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. +- **Download URL returns 404:** the request TTL expired, the Space restarted without persistent storage, or the filename/request ID was changed. Download results promptly. +- **Upload receives 413/422:** increase `MAX_UPLOAD_SIZE` only after checking Space disk and RAM. Prefer multipart/raw streaming over Base64. +- **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. +- **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. +- **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. +- **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. diff --git a/api/__init__.py b/api/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..ed2cfe0e64914f6af144ece3753b2fd9692a0ab5 --- /dev/null +++ b/api/__init__.py @@ -0,0 +1 @@ +"""Compatibility exports for the canonical :mod:`app.api` package.""" diff --git a/api/audio.py b/api/audio.py new file mode 100644 index 0000000000000000000000000000000000000000..0c531356a4a70845ea27634a6db20f1afaf19519 --- /dev/null +++ b/api/audio.py @@ -0,0 +1 @@ +from app.api.audio import * # noqa diff --git a/api/health.py b/api/health.py new file mode 100644 index 0000000000000000000000000000000000000000..60ff55a53db34271249813dc52d03ad305778c93 --- /dev/null +++ b/api/health.py @@ -0,0 +1 @@ +from app.api.health import * # noqa diff --git a/api/image.py b/api/image.py new file mode 100644 index 0000000000000000000000000000000000000000..7e0460a1dea6c9b275ac284ccdeb2f57080568ea --- /dev/null +++ b/api/image.py @@ -0,0 +1 @@ +from app.api.image import * # noqa diff --git a/api/media.py b/api/media.py new file mode 100644 index 0000000000000000000000000000000000000000..dd21e8f676659d5953ebb126ab19a2a80efd2ab9 --- /dev/null +++ b/api/media.py @@ -0,0 +1 @@ +from app.api.media import * # noqa diff --git a/api/probe.py b/api/probe.py new file mode 100644 index 0000000000000000000000000000000000000000..958ef9292305cb313524473f9642c6973f380e79 --- /dev/null +++ b/api/probe.py @@ -0,0 +1 @@ +from app.api.probe import * # noqa diff --git a/api/video.py b/api/video.py new file mode 100644 index 0000000000000000000000000000000000000000..ca56e881b5a19a575f9932bca9831b54dd949f6a --- /dev/null +++ b/api/video.py @@ -0,0 +1 @@ +from app.api.video import * # noqa diff --git a/api/whisper.py b/api/whisper.py new file mode 100644 index 0000000000000000000000000000000000000000..a8ab21fde1d33fdcbf15810bbc88155b6f42c0c6 --- /dev/null +++ b/api/whisper.py @@ -0,0 +1 @@ +from app.api.whisper import * # noqa diff --git a/api/ytdlp.py b/api/ytdlp.py new file mode 100644 index 0000000000000000000000000000000000000000..088fad789271f073ffe45a589cafcf94f2b15164 --- /dev/null +++ b/api/ytdlp.py @@ -0,0 +1 @@ +from app.api.ytdlp import * # noqa diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..a9ad0c9f2cb9e87495f48e010bac8e8d99eebf40 --- /dev/null +++ b/app/__init__.py @@ -0,0 +1,3 @@ +"""Enterprise Media Processing API package.""" + +__version__ = "1.0.0" diff --git a/app/api/__init__.py b/app/api/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..33bdeed8ac7184121922d830237f62b753f9eb81 --- /dev/null +++ b/app/api/__init__.py @@ -0,0 +1 @@ +"""HTTP API routers.""" diff --git a/app/api/audio.py b/app/api/audio.py new file mode 100644 index 0000000000000000000000000000000000000000..3cb60d4bb8c642ea6e52f081145d98dd53d09ccf --- /dev/null +++ b/app/api/audio.py @@ -0,0 +1,43 @@ +from __future__ import annotations + +from fastapi import APIRouter, Request + +from app.api.media import execute_operation +from app.core.response import SuccessResponse +from app.operations.compress import normalize_audio +from app.operations.concat import concat_audio +from app.operations.convert import convert_audio +from app.operations.extract_audio import ( + extract_audio, + fade_audio, + noise_reduction, + remove_silence, + set_volume, +) +from app.operations.merge import merge_audio +from app.operations.trim import trim_audio +from app.services.media_service import Operation + +router = APIRouter(prefix="/v1/audio", tags=["audio"]) + + +def operation_route(path: str, name: str, operation: Operation) -> None: + async def endpoint(request: Request) -> SuccessResponse: + return await execute_operation(request, name, operation) + + endpoint.__name__ = name.replace(".", "_") + router.add_api_route( + path, endpoint, methods=["POST"], response_model=SuccessResponse, name=name + ) + + +operation_route("/extract", "audio.extract", extract_audio) +operation_route("/convert", "audio.convert", convert_audio) +operation_route("/normalize", "audio.normalize", normalize_audio) +operation_route("/trim", "audio.trim", trim_audio) +operation_route("/merge", "audio.merge", merge_audio) +operation_route("/concat", "audio.concat", concat_audio) +operation_route("/fade", "audio.fade", fade_audio) +operation_route("/volume", "audio.volume", set_volume) +operation_route("/remove-silence", "audio.remove_silence", remove_silence) +operation_route("/noise-reduction", "audio.noise_reduction", noise_reduction) diff --git a/app/api/health.py b/app/api/health.py new file mode 100644 index 0000000000000000000000000000000000000000..5fc12a9d9015ff0ee7a296143cde15e630775f3b --- /dev/null +++ b/app/api/health.py @@ -0,0 +1,37 @@ +from __future__ import annotations + +import importlib.util +import shutil +import time + +from fastapi import APIRouter, Request + +from app.core.response import SuccessResponse + +router = APIRouter(tags=["health"]) + + +async def _health(request: Request) -> SuccessResponse: + started = time.monotonic() + settings = request.app.state.container.settings + dependencies = { + "ffmpeg": shutil.which(settings.ffmpeg_binary) is not None, + "ffprobe": shutil.which(settings.ffprobe_binary) is not None, + "yt_dlp": importlib.util.find_spec("yt_dlp") is not None, + "faster_whisper": importlib.util.find_spec("faster_whisper") is not None, + } + return SuccessResponse( + request_id=request.state.request_id, + processing_time=round(time.monotonic() - started, 4), + metadata={ + "status": "healthy", + "version": settings.app_version, + "dependencies": dependencies, + }, + ) + + +router.add_api_route("/health", _health, methods=["GET"], response_model=SuccessResponse) +router.add_api_route( + "/v1/health", _health, methods=["GET"], response_model=SuccessResponse, include_in_schema=False +) diff --git a/app/api/image.py b/app/api/image.py new file mode 100644 index 0000000000000000000000000000000000000000..ebb14d55c89338600ee9fc5d16ef7a4aed3f661a --- /dev/null +++ b/app/api/image.py @@ -0,0 +1,34 @@ +from __future__ import annotations + +from fastapi import APIRouter, Request + +from app.api.media import execute_operation +from app.core.response import SuccessResponse +from app.operations.concat import image_sequence, image_slideshow, image_to_video +from app.operations.convert import convert_image +from app.operations.crop import crop_image +from app.operations.resize import resize_image +from app.operations.watermark import overlay_image, watermark_image +from app.services.media_service import Operation + +router = APIRouter(prefix="/v1/image", tags=["image"]) + + +def operation_route(path: str, name: str, operation: Operation) -> None: + async def endpoint(request: Request) -> SuccessResponse: + return await execute_operation(request, name, operation) + + endpoint.__name__ = name.replace(".", "_") + router.add_api_route( + path, endpoint, methods=["POST"], response_model=SuccessResponse, name=name + ) + + +operation_route("/resize", "image.resize", resize_image) +operation_route("/crop", "image.crop", crop_image) +operation_route("/convert", "image.convert", convert_image) +operation_route("/slideshow", "image.slideshow", image_slideshow) +operation_route("/sequence", "image.sequence", image_sequence) +operation_route("/video", "image.video", image_to_video) +operation_route("/watermark", "image.watermark", watermark_image) +operation_route("/overlay", "image.overlay", overlay_image) diff --git a/app/api/media.py b/app/api/media.py new file mode 100644 index 0000000000000000000000000000000000000000..3a175238700d02d642b0c317a8db04a755cc0595 --- /dev/null +++ b/app/api/media.py @@ -0,0 +1,44 @@ +from __future__ import annotations + +from collections.abc import AsyncIterator +from pathlib import Path + +import aiofiles +from fastapi import APIRouter, Request +from fastapi.responses import StreamingResponse + +from app.core.response import SuccessResponse +from app.services.media_service import MediaProcessor, Operation + +router = APIRouter(tags=["media"]) + + +def get_processor(request: Request) -> MediaProcessor: + return request.app.state.container.processor + + +async def execute_operation(request: Request, name: str, operation: Operation) -> SuccessResponse: + request.state.operation = name + processor = get_processor(request) + resolved = await processor.resolver.resolve(request) + return await processor.run(resolved, name, operation) + + +async def stream_file(path: Path) -> AsyncIterator[bytes]: + async with aiofiles.open(path, "rb") as media: + while chunk := await media.read(1024 * 1024): + yield chunk + + +@router.get("/v1/media/{request_id}/{filename}", name="download_media") +async def download_media(request: Request, request_id: str, filename: str) -> StreamingResponse: + request.state.operation = "media.download" + container = request.app.state.container + path = container.cleanup.resolve_download(request_id, filename) + media_type = container.validator.infer_mime(path) + headers = { + "Content-Disposition": f'attachment; filename="{path.name}"', + "Content-Length": str(path.stat().st_size), + "X-Request-ID": request_id, + } + return StreamingResponse(stream_file(path), media_type=media_type, headers=headers) diff --git a/app/api/probe.py b/app/api/probe.py new file mode 100644 index 0000000000000000000000000000000000000000..a2dfa6af8a67d15dd0ea3bdae6e533b911aa7f15 --- /dev/null +++ b/app/api/probe.py @@ -0,0 +1,16 @@ +from __future__ import annotations + +from fastapi import APIRouter, Request + +from app.api.media import get_processor +from app.core.response import SuccessResponse + +router = APIRouter(prefix="/v1", tags=["probe"]) + + +@router.post("/probe", response_model=SuccessResponse) +async def probe(request: Request) -> SuccessResponse: + request.state.operation = "probe" + processor = get_processor(request) + resolved = await processor.resolver.resolve(request) + return await processor.probe(resolved) diff --git a/app/api/templates.py b/app/api/templates.py new file mode 100644 index 0000000000000000000000000000000000000000..1b5030c36f1c1919e228747e8d826fa62744068a --- /dev/null +++ b/app/api/templates.py @@ -0,0 +1,60 @@ +from __future__ import annotations + +import time +from typing import Any + +from fastapi import APIRouter, Query, Request + +from app.core.response import SuccessResponse + +router = APIRouter(prefix="/v1/templates", tags=["templates"]) + + +def _response(request: Request, started: float, metadata: dict[str, Any]) -> SuccessResponse: + return SuccessResponse( + request_id=request.state.request_id, + processing_time=round(time.monotonic() - started, 4), + metadata=metadata, + ) + + +@router.get("", response_model=SuccessResponse) +async def list_templates( + request: Request, category: str | None = Query(default=None) +) -> SuccessResponse: + """List metadata, parameters, and examples for every template version.""" + started = time.monotonic() + request.state.operation = "templates.list" + templates = request.app.state.container.template_registry.list_templates(category) + return _response( + request, + started, + {"templates": templates, "count": len(templates), "category": category}, + ) + + +@router.get("/categories", response_model=SuccessResponse) +async def template_categories(request: Request) -> SuccessResponse: + """List categories discovered dynamically from YAML definitions.""" + started = time.monotonic() + request.state.operation = "templates.categories" + categories = request.app.state.container.template_registry.categories() + return _response(request, started, {"categories": categories}) + + +@router.post("/run", response_model=SuccessResponse) +async def run_template(request: Request) -> SuccessResponse: + """Resolve any supported media input and execute a versioned template.""" + request.state.operation = "templates.run" + container = request.app.state.container + resolved = await container.resolver.resolve(request) + return await container.template_executor.execute_request(resolved) + + +@router.get("/{template_id}", response_model=SuccessResponse) +async def template_details(request: Request, template_id: str) -> SuccessResponse: + """Return complete metadata and pipeline details for a template reference.""" + started = time.monotonic() + request.state.operation = "templates.details" + template = request.app.state.container.template_registry.template_details(template_id) + return _response(request, started, {"template": template}) diff --git a/app/api/video.py b/app/api/video.py new file mode 100644 index 0000000000000000000000000000000000000000..e4a80cd1714ed190abfa93295a80e4855f6f5acb --- /dev/null +++ b/app/api/video.py @@ -0,0 +1,77 @@ +from __future__ import annotations + +from fastapi import APIRouter, Request + +from app.api.media import execute_operation +from app.core.response import SuccessResponse +from app.operations.compress import compress_video, normalize_video +from app.operations.concat import concat_video +from app.operations.convert import convert_video +from app.operations.crop import crop_video +from app.operations.extract_audio import mute_video, remove_audio, replace_audio +from app.operations.merge import merge_video +from app.operations.resize import pad_video, resize_video, scale_video +from app.operations.rotate import ( + change_bitrate, + change_fps, + change_speed, + reverse_video, + rotate_video, + slow_motion, +) +from app.operations.subtitles import burn_subtitles, soft_subtitles +from app.operations.thumbnails import ( + blur_video, + denoise_video, + extract_frames, + generate_gif, + sharpen_video, + thumbnail, +) +from app.operations.trim import trim_video +from app.operations.watermark import overlay_video, watermark_video +from app.services.media_service import Operation + +router = APIRouter(prefix="/v1/video", tags=["video"]) + + +def operation_route(path: str, name: str, operation: Operation) -> None: + async def endpoint(request: Request) -> SuccessResponse: + return await execute_operation(request, name, operation) + + endpoint.__name__ = name.replace(".", "_") + router.add_api_route( + path, endpoint, methods=["POST"], response_model=SuccessResponse, name=name + ) + + +operation_route("/compress", "video.compress", compress_video) +operation_route("/resize", "video.resize", resize_video) +operation_route("/convert", "video.convert", convert_video) +operation_route("/crop", "video.crop", crop_video) +operation_route("/merge", "video.merge", merge_video) +operation_route("/trim", "video.trim", trim_video) +operation_route("/concat", "video.concat", concat_video) +operation_route("/rotate", "video.rotate", rotate_video) +operation_route("/reverse", "video.reverse", reverse_video) +operation_route("/overlay", "video.overlay", overlay_video) +operation_route("/watermark", "video.watermark", watermark_video) +operation_route("/frames", "video.extract_frames", extract_frames) +operation_route("/gif", "video.gif", generate_gif) +operation_route("/thumbnail", "video.thumbnail", thumbnail) +operation_route("/replace-audio", "video.replace_audio", replace_audio) +operation_route("/remove-audio", "video.remove_audio", remove_audio) +operation_route("/mute", "video.mute", mute_video) +operation_route("/speed", "video.speed", change_speed) +operation_route("/speed-up", "video.speed_up", change_speed) +operation_route("/slow-motion", "video.slow_motion", slow_motion) +operation_route("/fps", "video.fps", change_fps) +operation_route("/bitrate", "video.bitrate", change_bitrate) +operation_route("/subtitles/burn", "video.subtitles.burn", burn_subtitles) +operation_route("/subtitles/soft", "video.subtitles.soft", soft_subtitles) +operation_route("/scale", "video.scale", scale_video) +operation_route("/pad", "video.pad", pad_video) +operation_route("/blur", "video.blur", blur_video) +operation_route("/sharpen", "video.sharpen", sharpen_video) +operation_route("/denoise", "video.denoise", denoise_video) +operation_route("/normalize", "video.normalize", normalize_video) diff --git a/app/api/whisper.py b/app/api/whisper.py new file mode 100644 index 0000000000000000000000000000000000000000..86ed1f2ae2fe31acff465574b9523ccb0ab713e6 --- /dev/null +++ b/app/api/whisper.py @@ -0,0 +1,32 @@ +from __future__ import annotations + +from fastapi import APIRouter, Request + +from app.api.media import get_processor +from app.core.response import SuccessResponse + +router = APIRouter(prefix="/v1/whisper", tags=["whisper"]) + + +async def _transcribe(request: Request, default_format: str | None = None) -> SuccessResponse: + request.state.operation = "whisper.transcribe" + processor = get_processor(request) + resolved = await processor.resolver.resolve(request) + if default_format and "output_format" not in resolved.params: + resolved.params["output_format"] = default_format + return await processor.run_whisper(resolved) + + +@router.post("/transcribe", response_model=SuccessResponse) +async def transcribe(request: Request) -> SuccessResponse: + return await _transcribe(request) + + +@router.post("/subtitles", response_model=SuccessResponse) +async def subtitles(request: Request) -> SuccessResponse: + return await _transcribe(request, "srt") + + +@router.post("/detect-language", response_model=SuccessResponse) +async def detect_language(request: Request) -> SuccessResponse: + return await _transcribe(request, "json") diff --git a/app/api/ytdlp.py b/app/api/ytdlp.py new file mode 100644 index 0000000000000000000000000000000000000000..aaf6ad878608cae23f2de8f27b96e69e352ebb38 --- /dev/null +++ b/app/api/ytdlp.py @@ -0,0 +1,16 @@ +from __future__ import annotations + +from fastapi import APIRouter, Request + +from app.api.media import get_processor +from app.core.response import SuccessResponse + +router = APIRouter(prefix="/v1/ytdlp", tags=["yt-dlp"]) + + +@router.post("/download", response_model=SuccessResponse) +async def download(request: Request) -> SuccessResponse: + request.state.operation = "ytdlp.download" + processor = get_processor(request) + resolved = await processor.resolver.resolve(request) + return await processor.run_ytdlp(resolved) diff --git a/app/container.py b/app/container.py new file mode 100644 index 0000000000000000000000000000000000000000..2e2cac6c5f11d980b7912a1d03fd8aba4ad2553c --- /dev/null +++ b/app/container.py @@ -0,0 +1,68 @@ +from __future__ import annotations + +from dataclasses import dataclass + +from app.core.config import Settings +from app.services.cleanup import CleanupService +from app.services.downloader import Downloader +from app.services.ffmpeg_service import FFmpegService +from app.services.ffprobe_service import FFprobeService +from app.services.input_resolver import InputResolver +from app.services.media_service import MediaProcessor +from app.services.validator import MediaValidator +from app.services.whisper_service import WhisperService +from app.services.ytdlp_service import YTDLPService +from app.templates.executor import OperationExecutor, TemplateExecutor +from app.templates.loader import TemplateLoader +from app.templates.registry import TemplateRegistry +from app.templates.validator import TemplateValidator + + +@dataclass(slots=True) +class Container: + settings: Settings + cleanup: CleanupService + validator: MediaValidator + downloader: Downloader + ytdlp: YTDLPService + ffmpeg: FFmpegService + ffprobe: FFprobeService + whisper: WhisperService + resolver: InputResolver + processor: MediaProcessor + template_registry: TemplateRegistry + template_executor: TemplateExecutor + + +def build_container(settings: Settings) -> Container: + cleanup = CleanupService(settings) + validator = MediaValidator(settings) + downloader = Downloader(settings, validator) + ytdlp = YTDLPService(settings, validator) + ffmpeg = FFmpegService(settings) + ffprobe = FFprobeService(settings) + whisper = WhisperService(settings) + resolver = InputResolver(settings, cleanup, downloader, ytdlp, validator) + processor = MediaProcessor( + settings, resolver, cleanup, validator, ffmpeg, ffprobe, ytdlp, whisper + ) + operation_executor = OperationExecutor(processor) + template_validator = TemplateValidator(operation_executor.supported_operations) + template_registry = TemplateRegistry( + TemplateLoader(settings.template_dir, template_validator), template_validator + ) + template_executor = TemplateExecutor(template_registry, operation_executor, processor) + return Container( + settings, + cleanup, + validator, + downloader, + ytdlp, + ffmpeg, + ffprobe, + whisper, + resolver, + processor, + template_registry, + template_executor, + ) diff --git a/app/core/__init__.py b/app/core/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e5de332467f5d90458e6bc1db96827b22cd66a56 --- /dev/null +++ b/app/core/__init__.py @@ -0,0 +1 @@ +"""Core application infrastructure.""" diff --git a/app/core/config.py b/app/core/config.py new file mode 100644 index 0000000000000000000000000000000000000000..82880fd7ecce3d3b8613286d2ad6fe7a68e84869 --- /dev/null +++ b/app/core/config.py @@ -0,0 +1,66 @@ +from __future__ import annotations + +from functools import lru_cache +from pathlib import Path + +from pydantic import Field, field_validator +from pydantic_settings import BaseSettings, SettingsConfigDict + +DEFAULT_TEMPLATE_DIR = Path(__file__).resolve().parents[1] / "templates" / "categories" + + +class Settings(BaseSettings): + """Runtime configuration loaded from environment variables.""" + + model_config = SettingsConfigDict( + env_file=".env", env_file_encoding="utf-8", case_sensitive=False, extra="ignore" + ) + + app_name: str = "Enterprise Media Processing API" + app_version: str = "1.0.0" + host: str = "0.0.0.0" + port: int = 7860 + temp_dir: Path = Path("./temp") + output_dir: Path = Path("./outputs") + template_dir: Path = DEFAULT_TEMPLATE_DIR + max_upload_size: int = Field(default=1_073_741_824, ge=1_048_576) + max_duration_seconds: float = Field(default=21_600.0, gt=0) + max_resolution_pixels: int = Field(default=33_177_600, ge=1) + whisper_model: str = "small" + cleanup_minutes: int = Field(default=60, ge=1) + cleanup_interval_seconds: int = Field(default=60, ge=5) + max_workers: int = Field(default=2, ge=1, le=32) + log_level: str = "INFO" + download_timeout_seconds: float = Field(default=300.0, gt=0) + allow_private_urls: bool = False + base_url: str = "" + ffmpeg_binary: str = "ffmpeg" + ffprobe_binary: str = "ffprobe" + + @field_validator("whisper_model") + @classmethod + def validate_whisper_model(cls, value: str) -> str: + allowed = {"tiny", "base", "small", "medium", "large-v3"} + if value not in allowed: + raise ValueError(f"WHISPER_MODEL must be one of: {', '.join(sorted(allowed))}") + return value + + @field_validator("log_level") + @classmethod + def normalize_log_level(cls, value: str) -> str: + normalized = value.upper() + allowed = {"DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"} + if normalized not in allowed: + raise ValueError(f"LOG_LEVEL must be one of: {', '.join(sorted(allowed))}") + return normalized + + def ensure_directories(self) -> None: + self.temp_dir.mkdir(parents=True, exist_ok=True) + self.output_dir.mkdir(parents=True, exist_ok=True) + + +@lru_cache +def get_settings() -> Settings: + settings = Settings() + settings.ensure_directories() + return settings diff --git a/app/core/exceptions.py b/app/core/exceptions.py new file mode 100644 index 0000000000000000000000000000000000000000..ff89ac0564282b02d75aa488498e5977df61e23a --- /dev/null +++ b/app/core/exceptions.py @@ -0,0 +1,59 @@ +from __future__ import annotations + +from typing import Any + + +class MediaAPIError(Exception): + """A safe, client-facing application error.""" + + code = "MEDIA_API_ERROR" + status_code = 400 + + def __init__(self, message: str, details: Any | None = None) -> None: + super().__init__(message, details) + self.message = message + self.details = details + + def __str__(self) -> str: + return self.message + + +class InputError(MediaAPIError): + code = "INVALID_INPUT" + status_code = 422 + + +class DownloadError(MediaAPIError): + code = "DOWNLOAD_FAILED" + status_code = 400 + + +class ProcessingError(MediaAPIError): + code = "PROCESSING_FAILED" + status_code = 422 + + +class NotFoundError(MediaAPIError): + code = "NOT_FOUND" + status_code = 404 + + +class TemplateNotFoundError(MediaAPIError): + """Raised when a requested template reference is unavailable.""" + + code = "TEMPLATE_NOT_FOUND" + status_code = 404 + + +class TemplateValidationError(MediaAPIError): + """Raised when a template definition or runtime parameter is invalid.""" + + code = "INVALID_TEMPLATE" + status_code = 422 + + +class TemplateExecutionError(MediaAPIError): + """Raised when a validated template cannot produce its declared output.""" + + code = "TEMPLATE_EXECUTION_FAILED" + status_code = 422 diff --git a/app/core/logger.py b/app/core/logger.py new file mode 100644 index 0000000000000000000000000000000000000000..96f4005c2ce6d519c614537efdd3b9d5ffad3f58 --- /dev/null +++ b/app/core/logger.py @@ -0,0 +1,59 @@ +from __future__ import annotations + +import contextvars +import json +import logging +import sys +from datetime import datetime, timezone +from typing import Any, TextIO + +from app.core.config import get_settings + +request_id_context: contextvars.ContextVar[str] = contextvars.ContextVar("request_id", default="-") + + +class JsonFormatter(logging.Formatter): + """One-line structured JSON logs suitable for container log collectors.""" + + _standard = set(logging.makeLogRecord({}).__dict__) | {"message", "asctime"} + + def format(self, record: logging.LogRecord) -> str: + payload: dict[str, Any] = { + "timestamp": datetime.now(timezone.utc).isoformat(), + "level": record.levelname, + "logger": record.name, + "message": record.getMessage(), + "request_id": getattr(record, "request_id", request_id_context.get()), + } + for key, value in record.__dict__.items(): + if key not in self._standard and not key.startswith("_"): + payload[key] = self._json_safe(value) + if record.exc_info: + payload["exception"] = self.formatException(record.exc_info) + return json.dumps(payload, ensure_ascii=False, separators=(",", ":")) + + @staticmethod + def _json_safe(value: Any) -> Any: + try: + json.dumps(value) + return value + except (TypeError, ValueError): + return str(value) + + +def configure_logging(stream: TextIO | None = None) -> None: + """Configure structured logging, optionally targeting a stdio-safe stream.""" + settings = get_settings() + handler = logging.StreamHandler(stream or sys.stdout) + handler.setFormatter(JsonFormatter()) + root = logging.getLogger() + root.handlers.clear() + root.addHandler(handler) + root.setLevel(settings.log_level) + for name in ("uvicorn.access", "uvicorn.error"): + logging.getLogger(name).handlers.clear() + logging.getLogger(name).propagate = True + + +def get_logger(name: str) -> logging.Logger: + return logging.getLogger(name) diff --git a/app/core/response.py b/app/core/response.py new file mode 100644 index 0000000000000000000000000000000000000000..edf660bafcbf77397a5cc1cd52e1ec06ac1cfc71 --- /dev/null +++ b/app/core/response.py @@ -0,0 +1,27 @@ +from __future__ import annotations + +from typing import Any + +from pydantic import BaseModel, ConfigDict, Field + + +class ErrorBody(BaseModel): + code: str + message: str + details: Any | None = None + + +class ErrorResponse(BaseModel): + success: bool = False + request_id: str + error: ErrorBody + + +class SuccessResponse(BaseModel): + model_config = ConfigDict(extra="forbid") + + success: bool = True + request_id: str + processing_time: float = Field(ge=0) + download_url: str | None = None + metadata: dict[str, Any] = Field(default_factory=dict) diff --git a/app/mcp/__init__.py b/app/mcp/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..40b689415cf8c48a183249ce47194b4b27c7b23e --- /dev/null +++ b/app/mcp/__init__.py @@ -0,0 +1 @@ +"""Model Context Protocol interface for the media processing service.""" diff --git a/app/mcp/prompts.py b/app/mcp/prompts.py new file mode 100644 index 0000000000000000000000000000000000000000..54e946e39565afdb1e2e5965b10d371831cbc900 --- /dev/null +++ b/app/mcp/prompts.py @@ -0,0 +1,81 @@ +from __future__ import annotations + +from typing import Any + +from mcp.server.fastmcp import FastMCP + + +def register_prompts(server: FastMCP[Any]) -> None: + """Register reusable prompt templates that direct clients to MCP tools.""" + + @server.prompt(description="Compress a video for a social media platform.") + def compress_for_social_media(source: str, platform: str = "instagram") -> str: + """Guide the model through social-media compression.""" + return ( + f"Use probe_media on {source!r}, then call compress_video for {platform}. " + "Choose MP4, H.264-compatible settings, CRF 28, veryfast preset, and a platform-appropriate max width. " + "Return the download_url and summarized output metadata." + ) + + @server.prompt(description="Download a YouTube URL as MP3 audio.") + def youtube_to_mp3(url: str) -> str: + """Guide the model through a YouTube-to-MP3 workflow.""" + return f"Call download_audio with url={url!r} and audio_format='mp3'. Return the resulting download_url." + + @server.prompt(description="Download remote media and transcribe it.") + def download_and_transcribe(url: str, model: str = "small") -> str: + """Guide the model through download followed by transcription.""" + return ( + f"First call download_audio with url={url!r}. Then call transcribe with the returned output_file as " + f"temp_path and model={model!r}. Return the transcript text, language, and download_url." + ) + + @server.prompt(description="Generate subtitles from media.") + def generate_subtitles(source: str, format: str = "srt", language: str = "auto") -> str: + """Guide the model through subtitle generation.""" + tool = "generate_vtt" if format.lower() == "vtt" else "generate_srt" + language_instruction = ( + "omit language for automatic detection" + if language == "auto" + else f"language={language!r}" + ) + return f"Call {tool} for {source!r}; {language_instruction}. Return the subtitle download_url and detected language." + + @server.prompt(description="Extract audio from a video.") + def extract_audio(source: str, format: str = "mp3") -> str: + """Guide the model through audio extraction.""" + return f"Call extract_audio for {source!r} with format={format!r}, then return output metadata and download_url." + + @server.prompt(description="Create a representative video thumbnail.") + def make_thumbnail(source: str, timestamp: float = 1.0) -> str: + """Guide the model through thumbnail generation.""" + return f"Call generate_thumbnail for {source!r} at timestamp={timestamp}. Return the JPEG download_url." + + @server.prompt(description="Inspect complete media metadata.") + def probe_media(source: str) -> str: + """Guide the model through FFprobe analysis.""" + return f"Call probe_media for {source!r}. Summarize duration, resolution, FPS, codecs, streams, and container." + + @server.prompt(description="Prepare an Instagram Reel from source media.") + def instagram_reel(source: str) -> str: + """Guide the model through Reel preparation.""" + return ( + f"Use probe_media on {source!r}, then resize_video to 1080x1920 with fit='cover', " + "and compress_video as MP4 with CRF 27 and preset='veryfast'. Return the final download_url." + ) + + @server.prompt(description="Prepare a vertical TikTok video.") + def tiktok_video(source: str) -> str: + """Guide the model through TikTok preparation.""" + return ( + f"Use resize_video on {source!r} at 1080x1920 with fit='cover', then compress_video " + "with MP4, CRF 26, and preset='veryfast'. Return the final download_url." + ) + + @server.prompt(description="Normalize and prepare podcast audio.") + def podcast_audio(source: str, format: str = "mp3") -> str: + """Guide the model through podcast audio preparation.""" + return ( + f"Call normalize_audio for {source!r} with target_lufs=-16. If format is not WAV, " + f"call convert_audio with format={format!r} on the normalized output_file. Return the final download_url." + ) diff --git a/app/mcp/registry.py b/app/mcp/registry.py new file mode 100644 index 0000000000000000000000000000000000000000..a35dc9d1c1c578cbe442c08bbffdda9721da4040 --- /dev/null +++ b/app/mcp/registry.py @@ -0,0 +1,505 @@ +from __future__ import annotations + +import importlib.metadata +import importlib.util +import os +import platform +import shutil +import time +from collections.abc import Awaitable, Callable, Sequence +from pathlib import Path +from typing import Any, Literal +from urllib.parse import unquote, urlparse +from uuid import uuid4 + +from pydantic import BaseModel, ConfigDict, Field, model_validator + +from app.container import Container +from app.core.exceptions import InputError, MediaAPIError +from app.core.logger import get_logger, request_id_context +from app.core.response import SuccessResponse +from app.models.media import ResolvedRequest +from app.operations.common import AUDIO_FORMATS, IMAGE_FORMATS, VIDEO_FORMATS +from app.services.media_service import Operation + +logger = get_logger(__name__) + +VIDEO_TOOLS = [ + "compress_video", + "resize_video", + "crop_video", + "trim_video", + "convert_video", + "merge_videos", + "concat_videos", + "watermark_video", + "overlay_video", + "extract_audio", + "replace_audio", + "remove_audio", + "generate_thumbnail", + "extract_frames", + "burn_subtitles", +] +AUDIO_TOOLS = [ + "convert_audio", + "normalize_audio", + "trim_audio", + "merge_audio", + "remove_silence", +] +IMAGE_TOOLS = ["image_to_video", "slideshow", "watermark_image", "resize_image"] +WHISPER_TOOLS = [ + "transcribe", + "translate", + "detect_language", + "generate_srt", + "generate_vtt", + "generate_json", +] +YTDLP_TOOLS = [ + "download_video", + "download_audio", + "video_metadata", + "playlist_metadata", + "list_formats", +] +PROBE_TOOLS = [ + "probe_media", + "probe_video_metadata", + "audio_metadata", + "stream_info", + "container_info", +] +SYSTEM_TOOLS = [ + "health", + "cleanup_temp", + "disk_usage", + "system_info", + "supported_operations", + "supported_formats", + "ffmpeg_version", + "whisper_models", + "yt_dlp_version", +] +TEMPLATE_TOOLS = [ + "list_templates", + "template_details", + "run_template", + "template_categories", +] + + +class MediaInput(BaseModel): + """Transport-neutral media input accepted by all MCP media tools.""" + + model_config = ConfigDict(extra="forbid") + + url: str | None = Field(default=None, description="HTTP(S) or yt-dlp-supported URL") + base64: str | None = Field(default=None, description="Raw Base64 or a Base64 data URI") + binary: dict[str, Any] | None = Field( + default=None, description="n8n-style binary property object" + ) + temp_path: str | None = Field( + default=None, + description="Existing file below configured TEMP_DIR or OUTPUT_DIR", + ) + filename: str | None = Field(default=None, description="Original filename") + mime_type: str | None = Field(default=None, description="Declared media MIME type") + + @model_validator(mode="after") + def validate_source(self) -> MediaInput: + sources = [self.url, self.base64, self.binary, self.temp_path] + if sum(value is not None for value in sources) != 1: + raise ValueError("Exactly one of url, base64, binary, or temp_path is required") + return self + + def descriptor(self) -> dict[str, Any]: + """Return the existing InputResolver descriptor representation.""" + payload = self.model_dump(exclude_none=True) + if "mime_type" in payload: + payload["mimeType"] = payload.pop("mime_type") + return payload + + +class ToolError(BaseModel): + code: str + message: str + details: Any | None = None + + +class ToolResponse(BaseModel): + model_config = ConfigDict(extra="forbid") + + success: bool + request_id: str + processing_time: float = Field(ge=0) + output_file: str | None = None + download_url: str | None = None + metadata: dict[str, Any] = Field(default_factory=dict) + error: ToolError | None = None + + +Action = Callable[[str], Awaitable[SuccessResponse]] +MetadataAction = Callable[[], Awaitable[dict[str, Any]]] + + +class MCPRegistry: + """Shared MCP execution facade over the application's existing container.""" + + def __init__(self, container: Container) -> None: + self.container = container + + async def run_operation( + self, + tool_name: str, + media: Sequence[MediaInput], + params: dict[str, Any], + operation: Operation, + ) -> dict[str, Any]: + """Resolve input and run an existing FFmpeg operation through MediaProcessor.""" + + async def action(request_id: str) -> SuccessResponse: + resolved = await self._resolve(media, params, request_id) + return await self.container.processor.run(resolved, tool_name, operation) + + return await self._execute(tool_name, action) + + async def run_whisper( + self, + tool_name: str, + media: MediaInput, + params: dict[str, Any], + ) -> dict[str, Any]: + """Resolve input and invoke the shared faster-whisper service.""" + + async def action(request_id: str) -> SuccessResponse: + resolved = await self._resolve([media], params, request_id) + return await self.container.processor.run_whisper(resolved) + + return await self._execute(tool_name, action) + + async def run_ytdlp( + self, + tool_name: str, + media: MediaInput, + params: dict[str, Any], + ) -> dict[str, Any]: + """Invoke yt-dlp through InputResolver and the shared MediaProcessor.""" + + async def action(request_id: str) -> SuccessResponse: + resolved = await self._resolve([media], params, request_id, ytdlp_options=params) + return await self.container.processor.run_ytdlp(resolved) + + return await self._execute(tool_name, action) + + async def run_probe( + self, + tool_name: str, + media: MediaInput, + view: Literal["all", "video", "audio", "streams", "container"] = "all", + ) -> dict[str, Any]: + """Probe media once and return the requested structured metadata view.""" + + async def action(request_id: str) -> SuccessResponse: + resolved = await self._resolve([media], {}, request_id) + response = await self.container.processor.probe(resolved) + primary = (response.metadata.get("inputs") or [{}])[0] + response.metadata = self._probe_view(primary, view) + return response + + return await self._execute(tool_name, action) + + async def run_template( + self, + template: str, + input_media: MediaInput | None, + input_collection: Sequence[MediaInput] | None, + parameters: dict[str, Any] | None, + ) -> dict[str, Any]: + """Resolve MCP media through InputResolver and execute a shared template.""" + + async def action(request_id: str) -> SuccessResponse: + if (input_media is None) == (input_collection is None): + raise InputError("Provide exactly one of input or inputs") + media = list(input_collection or []) + if input_media is not None: + media = [input_media] + if not media: + raise InputError("At least one template media input is required") + resolved = await self._resolve(media, {}, request_id) + return await self.container.template_executor.execute(resolved, template, parameters) + + return await self._execute("run_template", action) + + async def run_metadata_tool(self, tool_name: str, action: MetadataAction) -> dict[str, Any]: + """Run a non-media utility with the same logging and error contract.""" + + async def wrapped(request_id: str) -> SuccessResponse: + started = time.monotonic() + metadata = await action() + return SuccessResponse( + request_id=request_id, + processing_time=round(time.monotonic() - started, 4), + metadata=metadata, + ) + + return await self._execute(tool_name, wrapped) + + async def health_data(self) -> dict[str, Any]: + """Return dependency health without loading the Whisper model.""" + settings = self.container.settings + return { + "status": "healthy", + "version": settings.app_version, + "dependencies": { + "ffmpeg": shutil.which(settings.ffmpeg_binary) is not None, + "ffprobe": shutil.which(settings.ffprobe_binary) is not None, + "yt_dlp": importlib.util.find_spec("yt_dlp") is not None, + "faster_whisper": importlib.util.find_spec("faster_whisper") is not None, + "mcp": importlib.util.find_spec("mcp") is not None, + }, + } + + async def disk_usage_data(self) -> dict[str, Any]: + """Return disk usage for temporary and published output locations.""" + return { + "temp": self._disk_usage(self.container.settings.temp_dir), + "outputs": self._disk_usage(self.container.settings.output_dir), + } + + async def system_info_data(self) -> dict[str, Any]: + """Return CPU, memory, platform, and process information.""" + data: dict[str, Any] = { + "platform": platform.platform(), + "python": platform.python_version(), + "cpu_count": os.cpu_count(), + "pid": os.getpid(), + } + try: + import psutil + + process = psutil.Process(os.getpid()) + data.update( + { + "cpu_percent": psutil.cpu_percent(interval=None), + "memory_bytes": process.memory_info().rss, + "system_memory": psutil.virtual_memory()._asdict(), + } + ) + except ImportError: + data.update({"cpu_percent": None, "memory_bytes": None}) + return data + + async def configuration_data(self) -> dict[str, Any]: + """Return non-secret runtime configuration.""" + settings = self.container.settings + return { + "temp_dir": str(settings.temp_dir), + "output_dir": str(settings.output_dir), + "template_dir": str(settings.template_dir), + "max_upload_size": settings.max_upload_size, + "max_duration_seconds": settings.max_duration_seconds, + "max_resolution_pixels": settings.max_resolution_pixels, + "whisper_model": settings.whisper_model, + "cleanup_minutes": settings.cleanup_minutes, + "max_workers": settings.max_workers, + "allow_private_urls": settings.allow_private_urls, + } + + async def supported_operations_data(self) -> dict[str, Any]: + """Return every registered operation grouped by domain.""" + return { + "video": VIDEO_TOOLS, + "audio": AUDIO_TOOLS, + "image": IMAGE_TOOLS, + "whisper": WHISPER_TOOLS, + "ytdlp": YTDLP_TOOLS, + "probe": PROBE_TOOLS, + "system": SYSTEM_TOOLS, + "templates": TEMPLATE_TOOLS, + } + + async def supported_formats_data(self) -> dict[str, Any]: + """Return supported output containers and transcription formats.""" + return { + "video": sorted(VIDEO_FORMATS), + "audio": sorted(AUDIO_FORMATS), + "image": sorted(IMAGE_FORMATS), + "whisper": sorted(self.container.whisper.ALLOWED_FORMATS), + "whisper_models": sorted(self.container.whisper.ALLOWED_MODELS), + "yt_dlp_audio": ["mp3", "m4a", "wav", "opus", "flac"], + } + + async def version_data(self) -> dict[str, Any]: + """Return application and protocol package versions.""" + return { + "application": self.container.settings.app_version, + "mcp": self._package_version("mcp"), + "yt_dlp": self._package_version("yt-dlp"), + "faster_whisper": self._package_version("faster-whisper"), + } + + async def codecs_data(self) -> dict[str, Any]: + """Return FFmpeg codec capabilities.""" + codecs = await self.container.ffmpeg.codecs() + return {"count": len(codecs), "codecs": codecs} + + async def safe_resource(self, name: str, action: MetadataAction) -> dict[str, Any]: + """Return resource data without exposing raw exceptions.""" + response = await self.run_metadata_tool(f"resource.{name}", action) + if response["success"]: + return {"success": True, **response["metadata"]} + return { + "success": False, + "error": response["error"], + "request_id": response["request_id"], + } + + async def _resolve( + self, + media: Sequence[MediaInput], + params: dict[str, Any], + request_id: str, + *, + ytdlp_options: dict[str, Any] | None = None, + ) -> ResolvedRequest: + payload: dict[str, Any] = { + "inputs": [item.descriptor() for item in media], + **params, + } + return await self.container.resolver.resolve_payload( + payload, + request_id, + ytdlp_options=ytdlp_options, + ) + + async def _execute(self, tool_name: str, action: Action) -> dict[str, Any]: + request_id = str(uuid4()) + token = request_id_context.set(request_id) + started = time.monotonic() + cpu_started = time.process_time() + try: + response = await action(request_id) + result = self._tool_success(response) + logger.info( + "MCP tool completed", + extra={ + "tool": tool_name, + "duration": round(time.monotonic() - started, 4), + "cpu_time": round(time.process_time() - cpu_started, 6), + "memory_bytes": self._memory_bytes(), + "output_size": response.metadata.get("output_size", 0), + }, + ) + return result + except MediaAPIError as exc: + logger.warning( + "MCP tool failed", + extra={ + "tool": tool_name, + "error_code": exc.code, + "duration": round(time.monotonic() - started, 4), + "cpu_time": round(time.process_time() - cpu_started, 6), + "memory_bytes": self._memory_bytes(), + }, + ) + return self._tool_failure(request_id, started, exc.code, exc.message, exc.details) + except Exception: + logger.exception("unhandled MCP tool error", extra={"tool": tool_name}) + return self._tool_failure( + request_id, + started, + "INTERNAL_ERROR", + "An unexpected internal error occurred", + None, + ) + finally: + try: + await self.container.cleanup.complete(request_id) + except Exception: + logger.exception( + "MCP request cleanup finalization failed", + extra={"tool": tool_name}, + ) + finally: + request_id_context.reset(token) + + def _tool_success(self, response: SuccessResponse) -> dict[str, Any]: + output_file: str | None = None + if response.download_url: + filename = Path(unquote(urlparse(response.download_url).path)).name + candidate = self.container.settings.output_dir / response.request_id / filename + if candidate.is_file(): + output_file = str(candidate.resolve()) + return ToolResponse( + success=True, + request_id=response.request_id, + processing_time=response.processing_time, + output_file=output_file, + download_url=response.download_url, + metadata=response.metadata, + ).model_dump(mode="json", exclude_none=True) + + @staticmethod + def _tool_failure( + request_id: str, + started: float, + code: str, + message: str, + details: Any | None, + ) -> dict[str, Any]: + return ToolResponse( + success=False, + request_id=request_id, + processing_time=round(time.monotonic() - started, 4), + error=ToolError(code=code, message=message, details=details), + ).model_dump(mode="json", exclude_none=True) + + @staticmethod + def _probe_view(primary: dict[str, Any], view: str) -> dict[str, Any]: + if view == "video": + keys = ( + "filename", + "duration", + "resolution", + "fps", + "codec", + "rotation", + "video_streams", + ) + elif view == "audio": + keys = ("filename", "duration", "bitrate", "audio_streams") + elif view == "streams": + keys = ("filename", "video_streams", "audio_streams", "subtitle_streams") + elif view == "container": + keys = ("filename", "duration", "bitrate", "container", "creation_date", "size", "tags") + else: + return {"media": primary} + return {key: primary.get(key) for key in keys} + + @staticmethod + def _disk_usage(path: Path) -> dict[str, Any]: + usage = shutil.disk_usage(path) + return { + "path": str(path), + "total": usage.total, + "used": usage.used, + "free": usage.free, + } + + @staticmethod + def _memory_bytes() -> int | None: + try: + import psutil + + return psutil.Process(os.getpid()).memory_info().rss + except ImportError: + return None + + @staticmethod + def _package_version(name: str) -> str | None: + try: + return importlib.metadata.version(name) + except importlib.metadata.PackageNotFoundError: + return None diff --git a/app/mcp/resources.py b/app/mcp/resources.py new file mode 100644 index 0000000000000000000000000000000000000000..aa547de5841bb7e1f49eeac9950ff487e8c9f264 --- /dev/null +++ b/app/mcp/resources.py @@ -0,0 +1,71 @@ +from __future__ import annotations + +from typing import Any + +from mcp.server.fastmcp import FastMCP + +from app.mcp.registry import MCPRegistry + + +def register_resources(server: FastMCP[Any], registry: MCPRegistry) -> None: + """Register structured media capability and runtime resources.""" + + @server.resource( + "media://operations", + name="media_operations", + description="All MCP media operations grouped by domain.", + mime_type="application/json", + ) + async def media_operations() -> dict[str, Any]: + """Return registered media operations.""" + return await registry.safe_resource("operations", registry.supported_operations_data) + + @server.resource( + "media://formats", + name="media_formats", + description="Supported media, transcription, and download formats.", + mime_type="application/json", + ) + async def media_formats() -> dict[str, Any]: + """Return supported formats.""" + return await registry.safe_resource("formats", registry.supported_formats_data) + + @server.resource( + "media://codecs", + name="media_codecs", + description="FFmpeg codec decode and encode capabilities.", + mime_type="application/json", + ) + async def media_codecs() -> dict[str, Any]: + """Return installed FFmpeg codecs.""" + return await registry.safe_resource("codecs", registry.codecs_data) + + @server.resource( + "media://health", + name="media_health", + description="Health of media processing dependencies.", + mime_type="application/json", + ) + async def media_health() -> dict[str, Any]: + """Return dependency health.""" + return await registry.safe_resource("health", registry.health_data) + + @server.resource( + "media://configuration", + name="media_configuration", + description="Non-secret media API runtime configuration.", + mime_type="application/json", + ) + async def media_configuration() -> dict[str, Any]: + """Return safe configuration values.""" + return await registry.safe_resource("configuration", registry.configuration_data) + + @server.resource( + "media://version", + name="media_version", + description="Application, MCP, yt-dlp, and Whisper package versions.", + mime_type="application/json", + ) + async def media_version() -> dict[str, Any]: + """Return version information.""" + return await registry.safe_resource("version", registry.version_data) diff --git a/app/mcp/server.py b/app/mcp/server.py new file mode 100644 index 0000000000000000000000000000000000000000..a35e424a4e90ec81eb548a26cc81eced17408cd7 --- /dev/null +++ b/app/mcp/server.py @@ -0,0 +1,90 @@ +from __future__ import annotations + +import argparse +import asyncio +import sys +from typing import Any, Literal, cast + +from mcp.server.fastmcp import FastMCP + +from app.container import Container, build_container +from app.core.config import get_settings +from app.core.logger import configure_logging +from app.mcp.prompts import register_prompts +from app.mcp.registry import MCPRegistry +from app.mcp.resources import register_resources +from app.mcp.tools.audio import register_audio_tools +from app.mcp.tools.image import register_image_tools +from app.mcp.tools.probe import register_probe_tools +from app.mcp.tools.system import register_system_tools +from app.mcp.tools.templates import register_template_tools +from app.mcp.tools.video import register_video_tools +from app.mcp.tools.whisper import register_whisper_tools +from app.mcp.tools.ytdlp import register_ytdlp_tools +from app.workers.cleanup_worker import CleanupWorker + +MCPLogLevel = Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] + + +def create_mcp_server(container: Container) -> FastMCP[Any]: + """Create a fully registered MCP server over an existing service container.""" + settings = container.settings + server: FastMCP[Any] = FastMCP( + name="Enterprise Media Processing API", + instructions=( + "Use the registered tools for safe media processing. All media inputs accept URL, Base64, " + "n8n binary objects, or managed temp_path values. Read media://operations for capabilities." + ), + host=settings.host, + port=settings.port, + streamable_http_path="/", + json_response=True, + stateless_http=True, + log_level=cast(MCPLogLevel, settings.log_level), + ) + registry = MCPRegistry(container) + register_video_tools(server, registry) + register_audio_tools(server, registry) + register_image_tools(server, registry) + register_whisper_tools(server, registry) + register_ytdlp_tools(server, registry) + register_probe_tools(server, registry) + register_system_tools(server, registry) + register_template_tools(server, registry) + register_resources(server, registry) + register_prompts(server) + return server + + +async def run_server(transport: Literal["stdio", "streamable-http"] = "stdio") -> None: + """Run MCP as a standalone stdio or Streamable HTTP server.""" + configure_logging(stream=sys.stderr if transport == "stdio" else None) + settings = get_settings() + container = build_container(settings) + worker = CleanupWorker(container.cleanup, settings.cleanup_interval_seconds) + server = create_mcp_server(container) + await worker.start() + try: + if transport == "stdio": + await server.run_stdio_async() + else: + await server.run_streamable_http_async() + finally: + await worker.stop() + + +def main() -> None: + """CLI entry point for local MCP clients.""" + parser = argparse.ArgumentParser(description="Enterprise Media API MCP server") + parser.add_argument( + "--transport", + choices=("stdio", "streamable-http"), + default="stdio", + help="MCP transport to run (default: stdio)", + ) + arguments = parser.parse_args() + asyncio.run(run_server(arguments.transport)) + + +if __name__ == "__main__": + main() diff --git a/app/mcp/tools/__init__.py b/app/mcp/tools/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..0709b359c46e2ebfdece2d5aa98fd72935e283a1 --- /dev/null +++ b/app/mcp/tools/__init__.py @@ -0,0 +1 @@ +"""MCP tool registration modules.""" diff --git a/app/mcp/tools/audio.py b/app/mcp/tools/audio.py new file mode 100644 index 0000000000000000000000000000000000000000..774353ed1e24d656d6529972304770fd48f1063f --- /dev/null +++ b/app/mcp/tools/audio.py @@ -0,0 +1,63 @@ +from __future__ import annotations + +from typing import Any + +from mcp.server.fastmcp import FastMCP + +from app.mcp.registry import MCPRegistry, MediaInput +from app.operations.compress import normalize_audio as normalize_audio_operation +from app.operations.convert import convert_audio as convert_audio_operation +from app.operations.extract_audio import remove_silence as remove_silence_operation +from app.operations.merge import merge_audio as merge_audio_operation +from app.operations.trim import trim_audio as trim_audio_operation + + +def register_audio_tools(server: FastMCP[Any], registry: MCPRegistry) -> None: + """Register audio MCP tools backed by existing operations.""" + + @server.tool(description="Convert audio to MP3, WAV, AAC, M4A, FLAC, OGG, or Opus.") + async def convert_audio(input: MediaInput, format: str = "mp3") -> dict[str, Any]: + """Convert one audio input.""" + return await registry.run_operation( + "convert_audio", [input], {"format": format}, convert_audio_operation + ) + + @server.tool(description="Normalize audio loudness to a target LUFS value.") + async def normalize_audio(input: MediaInput, target_lufs: float = -16) -> dict[str, Any]: + """Normalize one audio input.""" + return await registry.run_operation( + "normalize_audio", + [input], + {"target_lufs": target_lufs}, + normalize_audio_operation, + ) + + @server.tool(description="Trim audio by start, end, or duration.") + async def trim_audio( + input: MediaInput, + start: float = 0, + duration: float | None = None, + end: float | None = None, + ) -> dict[str, Any]: + """Trim one audio input.""" + params = {"start": start} + if duration is not None: + params["duration"] = duration + if end is not None: + params["end"] = end + return await registry.run_operation("trim_audio", [input], params, trim_audio_operation) + + @server.tool(description="Normalize and merge multiple audio files in sequence.") + async def merge_audio(inputs: list[MediaInput]) -> dict[str, Any]: + """Merge multiple audio inputs.""" + return await registry.run_operation("merge_audio", inputs, {}, merge_audio_operation) + + @server.tool(description="Remove leading, trailing, and internal silence from audio.") + async def remove_silence(input: MediaInput, threshold: str = "-45dB") -> dict[str, Any]: + """Remove silence through the existing audio filter operation.""" + return await registry.run_operation( + "remove_silence", + [input], + {"threshold": threshold}, + remove_silence_operation, + ) diff --git a/app/mcp/tools/image.py b/app/mcp/tools/image.py new file mode 100644 index 0000000000000000000000000000000000000000..c112877efcc915868d110bbf99b166b5968513c6 --- /dev/null +++ b/app/mcp/tools/image.py @@ -0,0 +1,86 @@ +from __future__ import annotations + +from typing import Any + +from mcp.server.fastmcp import FastMCP + +from app.mcp.registry import MCPRegistry, MediaInput +from app.operations.concat import image_slideshow +from app.operations.concat import image_to_video as image_to_video_operation +from app.operations.resize import resize_image as resize_image_operation +from app.operations.watermark import watermark_image as watermark_image_operation + + +def register_image_tools(server: FastMCP[Any], registry: MCPRegistry) -> None: + """Register image MCP tools backed by existing operations.""" + + @server.tool(description="Turn one still image into an H.264 video.") + async def image_to_video( + input: MediaInput, duration: float = 5, fps: int = 30 + ) -> dict[str, Any]: + """Create a video from one image.""" + return await registry.run_operation( + "image_to_video", + [input], + {"duration": duration, "fps": fps}, + image_to_video_operation, + ) + + @server.tool(description="Create a video slideshow from multiple images.") + async def slideshow( + inputs: list[MediaInput], + duration_per_image: float = 3, + width: int = 1280, + height: int = 720, + fps: int = 30, + ) -> dict[str, Any]: + """Create a slideshow through the shared image operation.""" + return await registry.run_operation( + "slideshow", + inputs, + { + "duration_per_image": duration_per_image, + "width": width, + "height": height, + "fps": fps, + }, + image_slideshow, + ) + + @server.tool(description="Apply an image watermark to another image.") + async def watermark_image( + image: MediaInput, + watermark: MediaInput, + position: str = "bottom-right", + opacity: float = 1.0, + watermark_scale: float = 1.0, + format: str = "png", + ) -> dict[str, Any]: + """Apply a watermark through the existing image operation.""" + return await registry.run_operation( + "watermark_image", + [image, watermark], + { + "position": position, + "opacity": opacity, + "watermark_scale": watermark_scale, + "format": format, + }, + watermark_image_operation, + ) + + @server.tool(description="Resize an image with contain, cover, or fill behavior.") + async def resize_image( + input: MediaInput, + width: int = 1280, + height: int = 720, + fit: str = "contain", + format: str = "png", + ) -> dict[str, Any]: + """Resize one image through the existing image operation.""" + return await registry.run_operation( + "resize_image", + [input], + {"width": width, "height": height, "fit": fit, "format": format}, + resize_image_operation, + ) diff --git a/app/mcp/tools/probe.py b/app/mcp/tools/probe.py new file mode 100644 index 0000000000000000000000000000000000000000..a7257b1cf7392f4047a4a3ca55263f6a12cfb444 --- /dev/null +++ b/app/mcp/tools/probe.py @@ -0,0 +1,39 @@ +from __future__ import annotations + +from typing import Any + +from mcp.server.fastmcp import FastMCP + +from app.mcp.registry import MCPRegistry, MediaInput + + +def register_probe_tools(server: FastMCP[Any], registry: MCPRegistry) -> None: + """Register FFprobe metadata views through MediaProcessor.probe.""" + + @server.tool(description="Return complete normalized FFprobe metadata.") + async def probe_media(input: MediaInput) -> dict[str, Any]: + """Probe all media streams and container metadata.""" + return await registry.run_probe("probe_media", input, "all") + + @server.tool( + name="probe_video_metadata", + description="Return FFprobe video stream, resolution, FPS, and rotation metadata.", + ) + async def ffprobe_video_metadata(input: MediaInput) -> dict[str, Any]: + """Return the video-specific FFprobe metadata view.""" + return await registry.run_probe("probe_video_metadata", input, "video") + + @server.tool(description="Return FFprobe audio stream metadata.") + async def audio_metadata(input: MediaInput) -> dict[str, Any]: + """Return audio streams, duration, and bitrate.""" + return await registry.run_probe("audio_metadata", input, "audio") + + @server.tool(description="Return all video, audio, and subtitle stream summaries.") + async def stream_info(input: MediaInput) -> dict[str, Any]: + """Return normalized stream information.""" + return await registry.run_probe("stream_info", input, "streams") + + @server.tool(description="Return container, tags, creation date, size, and duration.") + async def container_info(input: MediaInput) -> dict[str, Any]: + """Return normalized container information.""" + return await registry.run_probe("container_info", input, "container") diff --git a/app/mcp/tools/system.py b/app/mcp/tools/system.py new file mode 100644 index 0000000000000000000000000000000000000000..bb9b0ac47ed8ecd41caf01cc5059cdf3f7b41c33 --- /dev/null +++ b/app/mcp/tools/system.py @@ -0,0 +1,87 @@ +from __future__ import annotations + +import importlib.metadata +from typing import Any + +from mcp.server.fastmcp import FastMCP + +from app.mcp.registry import MCPRegistry + + +def register_system_tools(server: FastMCP[Any], registry: MCPRegistry) -> None: + """Register health, cleanup, capability, and runtime utility tools.""" + + @server.tool(description="Return media API and dependency health.") + async def health() -> dict[str, Any]: + """Return service health without loading expensive models.""" + return await registry.run_metadata_tool("health", registry.health_data) + + @server.tool(description="Run the existing expiry cleanup pass immediately.") + async def cleanup_temp() -> dict[str, Any]: + """Remove expired, inactive request directories.""" + + async def action() -> dict[str, Any]: + removed = await registry.container.cleanup.cleanup_expired() + return {"removed_directories": removed} + + return await registry.run_metadata_tool("cleanup_temp", action) + + @server.tool(description="Return disk capacity and usage for temp and output storage.") + async def disk_usage() -> dict[str, Any]: + """Return storage usage statistics.""" + return await registry.run_metadata_tool("disk_usage", registry.disk_usage_data) + + @server.tool(description="Return CPU, memory, Python, and platform information.") + async def system_info() -> dict[str, Any]: + """Return process and host runtime information.""" + return await registry.run_metadata_tool("system_info", registry.system_info_data) + + @server.tool(description="List all media MCP operations by domain.") + async def supported_operations() -> dict[str, Any]: + """Return registered operation names.""" + return await registry.run_metadata_tool( + "supported_operations", registry.supported_operations_data + ) + + @server.tool(description="List supported output, transcription, and model formats.") + async def supported_formats() -> dict[str, Any]: + """Return supported formats.""" + return await registry.run_metadata_tool( + "supported_formats", registry.supported_formats_data + ) + + @server.tool(description="Return the installed FFmpeg version banner.") + async def ffmpeg_version() -> dict[str, Any]: + """Return FFmpeg version information.""" + + async def action() -> dict[str, Any]: + return {"version": await registry.container.ffmpeg.version()} + + return await registry.run_metadata_tool("ffmpeg_version", action) + + @server.tool(description="List faster-whisper models accepted by this server.") + async def whisper_models() -> dict[str, Any]: + """Return allowed and default Whisper models.""" + + async def action() -> dict[str, Any]: + return { + "models": sorted(registry.container.whisper.ALLOWED_MODELS), + "default": registry.container.settings.whisper_model, + "device": "cpu", + "compute_type": "int8", + } + + return await registry.run_metadata_tool("whisper_models", action) + + @server.tool(description="Return the installed yt-dlp package version.") + async def yt_dlp_version() -> dict[str, Any]: + """Return yt-dlp version information.""" + + async def action() -> dict[str, Any]: + try: + version = importlib.metadata.version("yt-dlp") + except importlib.metadata.PackageNotFoundError: + version = None + return {"version": version} + + return await registry.run_metadata_tool("yt_dlp_version", action) diff --git a/app/mcp/tools/templates.py b/app/mcp/tools/templates.py new file mode 100644 index 0000000000000000000000000000000000000000..33e639a24b158a3b7e75974e093d2bf298b40bd6 --- /dev/null +++ b/app/mcp/tools/templates.py @@ -0,0 +1,49 @@ +from __future__ import annotations + +from typing import Any + +from mcp.server.fastmcp import FastMCP + +from app.mcp.registry import MCPRegistry, MediaInput + + +def register_template_tools(server: FastMCP[Any], registry: MCPRegistry) -> None: + """Register YAML template discovery and execution tools.""" + + @server.tool(description="List all dynamically loaded media template versions.") + async def list_templates(category: str | None = None) -> dict[str, Any]: + """Return template metadata, parameters, and examples.""" + + async def action() -> dict[str, Any]: + templates = registry.container.template_registry.list_templates(category) + return {"templates": templates, "count": len(templates)} + + return await registry.run_metadata_tool("list_templates", action) + + @server.tool(description="Return complete details for id, id@version, or id@latest.") + async def template_details(template: str) -> dict[str, Any]: + """Return one template's metadata and operation pipeline.""" + + async def action() -> dict[str, Any]: + return {"template": registry.container.template_registry.template_details(template)} + + return await registry.run_metadata_tool("template_details", action) + + @server.tool(description="Execute a versioned YAML template using the shared media resolver.") + async def run_template( + template: str, + input: MediaInput | None = None, + inputs: list[MediaInput] | None = None, + parameters: dict[str, Any] | None = None, + ) -> dict[str, Any]: + """Run a template with exactly one input object or an input list.""" + return await registry.run_template(template, input, inputs, parameters) + + @server.tool(description="List media template categories discovered from YAML.") + async def template_categories() -> dict[str, Any]: + """Return dynamic template category names.""" + + async def action() -> dict[str, Any]: + return {"categories": registry.container.template_registry.categories()} + + return await registry.run_metadata_tool("template_categories", action) diff --git a/app/mcp/tools/video.py b/app/mcp/tools/video.py new file mode 100644 index 0000000000000000000000000000000000000000..5ab3ece95b39a90481afbef8bafde88a3f9c0c84 --- /dev/null +++ b/app/mcp/tools/video.py @@ -0,0 +1,223 @@ +from __future__ import annotations + +from typing import Any + +from mcp.server.fastmcp import FastMCP + +from app.mcp.registry import MCPRegistry, MediaInput +from app.operations.compress import compress_video as compress_video_operation +from app.operations.concat import concat_video +from app.operations.convert import convert_video as convert_video_operation +from app.operations.crop import crop_video as crop_video_operation +from app.operations.extract_audio import extract_audio as extract_audio_operation +from app.operations.extract_audio import remove_audio as remove_audio_operation +from app.operations.extract_audio import replace_audio as replace_audio_operation +from app.operations.merge import merge_video +from app.operations.resize import resize_video as resize_video_operation +from app.operations.subtitles import burn_subtitles as burn_subtitles_operation +from app.operations.thumbnails import extract_frames as extract_frames_operation +from app.operations.thumbnails import thumbnail as thumbnail_operation +from app.operations.trim import trim_video as trim_video_operation +from app.operations.watermark import overlay_video as overlay_video_operation +from app.operations.watermark import watermark_video as watermark_video_operation + + +def register_video_tools(server: FastMCP[Any], registry: MCPRegistry) -> None: + """Register video MCP tools backed by existing operation functions.""" + + @server.tool(description="Compress a video with CPU-optimized FFmpeg settings.") + async def compress_video( + input: MediaInput, + crf: int = 28, + preset: str = "medium", + format: str = "mp4", + max_width: int | None = None, + bitrate: str | None = None, + ) -> dict[str, Any]: + """Compress one video and publish the output.""" + params = _without_none( + { + "crf": crf, + "preset": preset, + "format": format, + "max_width": max_width, + "bitrate": bitrate, + } + ) + return await registry.run_operation( + "compress_video", [input], params, compress_video_operation + ) + + @server.tool(description="Resize a video using contain, cover, or fill behavior.") + async def resize_video( + input: MediaInput, + width: int = 1280, + height: int = 720, + fit: str = "contain", + format: str = "mp4", + ) -> dict[str, Any]: + """Resize one video through the shared resize operation.""" + return await registry.run_operation( + "resize_video", + [input], + {"width": width, "height": height, "fit": fit, "format": format}, + resize_video_operation, + ) + + @server.tool(description="Crop a rectangular region from a video.") + async def crop_video( + input: MediaInput, + width: int, + height: int, + x: int = 0, + y: int = 0, + ) -> dict[str, Any]: + """Crop one video through the shared crop operation.""" + return await registry.run_operation( + "crop_video", + [input], + {"width": width, "height": height, "x": x, "y": y}, + crop_video_operation, + ) + + @server.tool(description="Trim a video by start, end, or duration.") + async def trim_video( + input: MediaInput, + start: float = 0, + duration: float | None = None, + end: float | None = None, + ) -> dict[str, Any]: + """Trim one video through the shared trim operation.""" + return await registry.run_operation( + "trim_video", + [input], + _without_none({"start": start, "duration": duration, "end": end}), + trim_video_operation, + ) + + @server.tool(description="Convert a video to another supported container.") + async def convert_video(input: MediaInput, format: str = "mp4") -> dict[str, Any]: + """Convert one video through the shared conversion operation.""" + return await registry.run_operation( + "convert_video", [input], {"format": format}, convert_video_operation + ) + + @server.tool(description="Normalize and merge multiple videos in sequence.") + async def merge_videos( + inputs: list[MediaInput], width: int = 1280, height: int = 720 + ) -> dict[str, Any]: + """Merge videos with normalized dimensions and stream layout.""" + return await registry.run_operation( + "merge_videos", + inputs, + {"width": width, "height": height}, + merge_video, + ) + + @server.tool(description="Concatenate videos, optionally using stream copy.") + async def concat_videos(inputs: list[MediaInput], stream_copy: bool = False) -> dict[str, Any]: + """Concatenate multiple videos through the shared concat operation.""" + return await registry.run_operation( + "concat_videos", inputs, {"stream_copy": stream_copy}, concat_video + ) + + @server.tool(description="Add an image watermark to a video.") + async def watermark_video( + video: MediaInput, + watermark: MediaInput, + position: str = "bottom-right", + opacity: float = 1.0, + watermark_scale: float = 1.0, + ) -> dict[str, Any]: + """Apply a watermark using the existing overlay operation.""" + return await registry.run_operation( + "watermark_video", + [video, watermark], + { + "position": position, + "opacity": opacity, + "watermark_scale": watermark_scale, + }, + watermark_video_operation, + ) + + @server.tool(description="Overlay an image or video layer on a video.") + async def overlay_video( + video: MediaInput, + overlay: MediaInput, + position: str = "center", + opacity: float = 1.0, + watermark_scale: float = 1.0, + ) -> dict[str, Any]: + """Overlay media through the shared video overlay operation.""" + return await registry.run_operation( + "overlay_video", + [video, overlay], + { + "position": position, + "opacity": opacity, + "watermark_scale": watermark_scale, + }, + overlay_video_operation, + ) + + @server.tool(description="Extract an audio track from video.") + async def extract_audio(input: MediaInput, format: str = "mp3") -> dict[str, Any]: + """Extract audio through the existing extraction operation.""" + return await registry.run_operation( + "extract_audio", [input], {"format": format}, extract_audio_operation + ) + + @server.tool(description="Replace a video's audio track with another media input.") + async def replace_audio(video: MediaInput, audio: MediaInput) -> dict[str, Any]: + """Replace audio through the shared operation.""" + return await registry.run_operation( + "replace_audio", [video, audio], {}, replace_audio_operation + ) + + @server.tool(description="Remove all audio streams from a video.") + async def remove_audio(input: MediaInput) -> dict[str, Any]: + """Remove audio through the shared operation.""" + return await registry.run_operation("remove_audio", [input], {}, remove_audio_operation) + + @server.tool(description="Generate a JPEG thumbnail from a video timestamp.") + async def generate_thumbnail( + input: MediaInput, timestamp: float = 0, quality: int = 3 + ) -> dict[str, Any]: + """Generate a thumbnail through the shared thumbnail operation.""" + return await registry.run_operation( + "generate_thumbnail", + [input], + {"timestamp": timestamp, "quality": quality}, + thumbnail_operation, + ) + + @server.tool(description="Extract video frames into a ZIP archive.") + async def extract_frames( + input: MediaInput, fps: float = 1, max_frames: int | None = None + ) -> dict[str, Any]: + """Extract frames through the shared frame operation.""" + return await registry.run_operation( + "extract_frames", + [input], + _without_none({"fps": fps, "max_frames": max_frames}), + extract_frames_operation, + ) + + @server.tool(description="Burn SRT, VTT, ASS, or SSA subtitles into video pixels.") + async def burn_subtitles( + video: MediaInput, + subtitles: MediaInput, + style: str | None = None, + ) -> dict[str, Any]: + """Burn subtitles through the existing subtitle operation.""" + return await registry.run_operation( + "burn_subtitles", + [video, subtitles], + _without_none({"style": style}), + burn_subtitles_operation, + ) + + +def _without_none(values: dict[str, Any]) -> dict[str, Any]: + return {key: value for key, value in values.items() if value is not None} diff --git a/app/mcp/tools/whisper.py b/app/mcp/tools/whisper.py new file mode 100644 index 0000000000000000000000000000000000000000..8650361945bc3a8f2d4ae954188088d6ebfe23bb --- /dev/null +++ b/app/mcp/tools/whisper.py @@ -0,0 +1,109 @@ +from __future__ import annotations + +from typing import Any + +from mcp.server.fastmcp import FastMCP + +from app.mcp.registry import MCPRegistry, MediaInput + + +def register_whisper_tools(server: FastMCP[Any], registry: MCPRegistry) -> None: + """Register faster-whisper MCP tools using the shared WhisperService.""" + + @server.tool(description="Transcribe speech with faster-whisper on CPU/int8.") + async def transcribe( + input: MediaInput, + model: str | None = None, + language: str | None = None, + output_format: str = "txt", + beam_size: int = 5, + vad_filter: bool = True, + ) -> dict[str, Any]: + """Transcribe media in its source language.""" + return await registry.run_whisper( + "transcribe", + input, + _options(model, language, "transcribe", output_format, beam_size, vad_filter), + ) + + @server.tool(description="Translate speech to English with faster-whisper.") + async def translate( + input: MediaInput, + model: str | None = None, + language: str | None = None, + output_format: str = "txt", + beam_size: int = 5, + vad_filter: bool = True, + ) -> dict[str, Any]: + """Translate and transcribe media into English.""" + return await registry.run_whisper( + "translate", + input, + _options(model, language, "translate", output_format, beam_size, vad_filter), + ) + + @server.tool(description="Detect spoken language and return transcription metadata.") + async def detect_language(input: MediaInput, model: str | None = None) -> dict[str, Any]: + """Detect language using Whisper's transcription information.""" + return await registry.run_whisper( + "detect_language", + input, + _options(model, None, "transcribe", "json", 1, True), + ) + + @server.tool(description="Generate SubRip (SRT) subtitles from speech.") + async def generate_srt( + input: MediaInput, + model: str | None = None, + language: str | None = None, + task: str = "transcribe", + ) -> dict[str, Any]: + """Generate an SRT subtitle file.""" + return await registry.run_whisper( + "generate_srt", input, _options(model, language, task, "srt", 5, True) + ) + + @server.tool(description="Generate WebVTT subtitles from speech.") + async def generate_vtt( + input: MediaInput, + model: str | None = None, + language: str | None = None, + task: str = "transcribe", + ) -> dict[str, Any]: + """Generate a WebVTT subtitle file.""" + return await registry.run_whisper( + "generate_vtt", input, _options(model, language, task, "vtt", 5, True) + ) + + @server.tool(description="Generate structured JSON transcript data.") + async def generate_json( + input: MediaInput, + model: str | None = None, + language: str | None = None, + task: str = "transcribe", + ) -> dict[str, Any]: + """Generate a JSON transcript with timestamped segments.""" + return await registry.run_whisper( + "generate_json", input, _options(model, language, task, "json", 5, True) + ) + + +def _options( + model: str | None, + language: str | None, + task: str, + output_format: str, + beam_size: int, + vad_filter: bool, +) -> dict[str, Any]: + options: dict[str, Any] = { + "task": task, + "output_format": output_format, + "beam_size": beam_size, + "vad_filter": vad_filter, + } + if model is not None: + options["model"] = model + if language is not None: + options["language"] = language + return options diff --git a/app/mcp/tools/ytdlp.py b/app/mcp/tools/ytdlp.py new file mode 100644 index 0000000000000000000000000000000000000000..2fbf62e286c389679d1562a401b410c505091987 --- /dev/null +++ b/app/mcp/tools/ytdlp.py @@ -0,0 +1,60 @@ +from __future__ import annotations + +from typing import Any + +from mcp.server.fastmcp import FastMCP + +from app.mcp.registry import MCPRegistry, MediaInput + + +def register_ytdlp_tools(server: FastMCP[Any], registry: MCPRegistry) -> None: + """Register yt-dlp tools through the shared YTDLPService and resolver.""" + + @server.tool(description="Download the best available video with yt-dlp.") + async def download_video(url: str, format_selector: str | None = None) -> dict[str, Any]: + """Download one video URL.""" + params: dict[str, Any] = {"mode": "video"} + if format_selector is not None: + params["format"] = format_selector + return await registry.run_ytdlp("download_video", MediaInput(url=url), params) + + @server.tool(description="Download and convert the best audio with yt-dlp.") + async def download_audio( + url: str, + audio_format: str = "mp3", + format_selector: str | None = None, + ) -> dict[str, Any]: + """Download one URL as audio.""" + params: dict[str, Any] = {"mode": "audio", "audio_format": audio_format} + if format_selector is not None: + params["format"] = format_selector + return await registry.run_ytdlp("download_audio", MediaInput(url=url), params) + + @server.tool( + name="video_metadata", description="Read yt-dlp metadata without downloading media." + ) + async def ytdlp_video_metadata(url: str) -> dict[str, Any]: + """Return platform metadata for one video URL.""" + return await registry.run_ytdlp("video_metadata", MediaInput(url=url), {"mode": "metadata"}) + + @server.tool(description="Read bounded playlist metadata without downloading entries.") + async def playlist_metadata(url: str, max_entries: int = 100) -> dict[str, Any]: + """Return flat metadata for up to max_entries playlist items.""" + return await registry.run_ytdlp( + "playlist_metadata", + MediaInput(url=url), + { + "mode": "metadata", + "playlist": True, + "max_entries": max_entries, + }, + ) + + @server.tool(description="List available yt-dlp video and audio formats for a URL.") + async def list_formats(url: str) -> dict[str, Any]: + """Return normalized yt-dlp format summaries.""" + return await registry.run_ytdlp( + "list_formats", + MediaInput(url=url), + {"mode": "metadata", "include_formats": True}, + ) diff --git a/app/models/__init__.py b/app/models/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..aff4435bb9eccd29bbcf1e573a82e43434378fc4 --- /dev/null +++ b/app/models/__init__.py @@ -0,0 +1,5 @@ +"""Pydantic domain and transport models.""" + +from app.models.media import InputMedia, MediaSource, OperationResult, ResolvedRequest + +__all__ = ["InputMedia", "MediaSource", "OperationResult", "ResolvedRequest"] diff --git a/app/models/media.py b/app/models/media.py new file mode 100644 index 0000000000000000000000000000000000000000..37dab24f594b8bc2acfef559a03d13c6765547a5 --- /dev/null +++ b/app/models/media.py @@ -0,0 +1,52 @@ +from __future__ import annotations + +from enum import Enum +from pathlib import Path +from typing import Any + +from pydantic import BaseModel, ConfigDict, Field + + +class MediaSource(str, Enum): + MULTIPART = "multipart" + JSON_URL = "json_url" + JSON_BASE64 = "json_base64" + N8N_BINARY = "n8n_binary" + OCTET_STREAM = "octet_stream" + YTDLP = "yt_dlp" + LOCAL_PATH = "local_path" + + +class InputMedia(BaseModel): + """Source-agnostic media passed to all processing operations.""" + + model_config = ConfigDict(arbitrary_types_allowed=True) + + source: MediaSource + filename: str + mime_type: str + temp_path: Path + size: int = Field(ge=0) + duration: float | None = None + metadata: dict[str, Any] = Field(default_factory=dict) + + +class ResolvedRequest(BaseModel): + model_config = ConfigDict(arbitrary_types_allowed=True) + + request_id: str + inputs: list[InputMedia] + params: dict[str, Any] = Field(default_factory=dict) + + @property + def primary(self) -> InputMedia: + return self.inputs[0] + + +class OperationResult(BaseModel): + model_config = ConfigDict(arbitrary_types_allowed=True) + + path: Path | None = None + filename: str | None = None + mime_type: str | None = None + metadata: dict[str, Any] = Field(default_factory=dict) diff --git a/app/models/requests.py b/app/models/requests.py new file mode 100644 index 0000000000000000000000000000000000000000..499e51f084fe5c7973dddacfb4c57854c0310ce9 --- /dev/null +++ b/app/models/requests.py @@ -0,0 +1,38 @@ +from __future__ import annotations + +from typing import Any, Literal + +from pydantic import BaseModel, ConfigDict, Field + + +class URLInput(BaseModel): + model_config = ConfigDict(extra="allow") + + url: str + filename: str | None = None + + +class Base64Input(BaseModel): + model_config = ConfigDict(extra="allow") + + base64: str + filename: str | None = None + mime_type: str | None = None + + +class WhisperOptions(BaseModel): + model: Literal["tiny", "base", "small", "medium", "large-v3"] | None = None + task: Literal["transcribe", "translate"] = "transcribe" + language: str | None = None + output_format: Literal["txt", "srt", "vtt", "json", "tsv"] = "json" + beam_size: int = Field(default=5, ge=1, le=20) + vad_filter: bool = True + + +class YTDLPOptions(BaseModel): + mode: Literal["video", "audio", "thumbnail", "metadata"] = "video" + format: str | None = None + audio_format: Literal["mp3", "m4a", "wav", "opus", "flac"] = "mp3" + + +JsonDict = dict[str, Any] diff --git a/app/operations/__init__.py b/app/operations/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..3f62e7e8b4508d1ce044683111a6a955bf27740a --- /dev/null +++ b/app/operations/__init__.py @@ -0,0 +1 @@ +"""Media operation implementations.""" diff --git a/app/operations/common.py b/app/operations/common.py new file mode 100644 index 0000000000000000000000000000000000000000..740cfe36530b47947707dd18832fa3a2ff13d5f7 --- /dev/null +++ b/app/operations/common.py @@ -0,0 +1,114 @@ +from __future__ import annotations + +import mimetypes +import re +from collections.abc import Sequence +from pathlib import Path +from typing import Any + +from app.core.exceptions import InputError, ProcessingError +from app.models.media import InputMedia, OperationResult +from app.services.ffmpeg_service import FFmpegService + +VIDEO_FORMATS = {"mp4", "mkv", "webm", "mov", "avi", "m4v", "mpeg", "ts"} +AUDIO_FORMATS = {"mp3", "wav", "aac", "m4a", "flac", "ogg", "opus"} +IMAGE_FORMATS = {"jpg", "jpeg", "png", "webp", "bmp", "tiff", "gif"} + + +def require_inputs(inputs: Sequence[InputMedia], count: int = 1) -> None: + if len(inputs) < count: + raise InputError(f"This operation requires at least {count} media input(s)") + + +def output_path(output_dir: Path, stem: str, extension: str) -> Path: + clean_extension = extension.lower().lstrip(".") + if not re.fullmatch(r"[a-z0-9]{2,5}", clean_extension): + raise InputError("Invalid output format") + output_dir.mkdir(parents=True, exist_ok=True) + return output_dir / f"{stem}.{clean_extension}" + + +def format_param(params: dict[str, Any], default: str, allowed: set[str]) -> str: + value = str(params.get("format", default)).lower().lstrip(".") + if value not in allowed: + raise InputError( + "Unsupported output format", details={"format": value, "allowed": sorted(allowed)} + ) + return value + + +def video_codecs(extension: str, *, crf: int = 23, preset: str = "medium") -> list[str]: + if extension == "webm": + return ["-c:v", "libvpx-vp9", "-crf", str(crf), "-b:v", "0", "-c:a", "libopus"] + if extension == "avi": + return ["-c:v", "mpeg4", "-q:v", "5", "-c:a", "libmp3lame"] + if extension in {"mpeg", "ts"}: + return ["-c:v", "mpeg2video", "-c:a", "mp2"] + return [ + "-c:v", + "libx264", + "-preset", + preset, + "-crf", + str(crf), + "-c:a", + "aac", + "-b:a", + "128k", + "-movflags", + "+faststart", + ] + + +def audio_codec(extension: str) -> list[str]: + return { + "mp3": ["-c:a", "libmp3lame", "-b:a", "192k"], + "wav": ["-c:a", "pcm_s16le"], + "aac": ["-c:a", "aac", "-b:a", "192k"], + "m4a": ["-c:a", "aac", "-b:a", "192k"], + "flac": ["-c:a", "flac"], + "ogg": ["-c:a", "libvorbis", "-q:a", "5"], + "opus": ["-c:a", "libopus", "-b:a", "128k"], + }[extension] + + +async def execute( + ffmpeg: FFmpegService, + args: Sequence[str | Path], + output: Path, + operation: str, + metadata: dict[str, Any] | None = None, +) -> OperationResult: + await ffmpeg.run(args, operation=operation) + if not output.is_file() or output.stat().st_size == 0: + raise ProcessingError("The operation did not produce an output file") + return OperationResult( + path=output, + filename=output.name, + mime_type=mimetypes.guess_type(output.name)[0] or "application/octet-stream", + metadata={"operation": operation, **(metadata or {})}, + ) + + +def even(value: int) -> int: + return max(2, value if value % 2 == 0 else value - 1) + + +def subtitle_filter_path(path: Path) -> str: + value = str(path.resolve()).replace("\\", "\\\\") + for character in (":", "'", "[", "]", ","): + value = value.replace(character, f"\\{character}") + return value + + +def atempo_chain(factor: float) -> str: + values: list[float] = [] + remaining = factor + while remaining > 2.0: + values.append(2.0) + remaining /= 2.0 + while remaining < 0.5: + values.append(0.5) + remaining /= 0.5 + values.append(remaining) + return ",".join(f"atempo={value:.6g}" for value in values) diff --git a/app/operations/compress.py b/app/operations/compress.py new file mode 100644 index 0000000000000000000000000000000000000000..ef6830ad9033bb3e4400b5d7c684021c1bfa26f8 --- /dev/null +++ b/app/operations/compress.py @@ -0,0 +1,90 @@ +from __future__ import annotations + +from collections.abc import Sequence +from pathlib import Path +from typing import Any + +from app.core.exceptions import InputError +from app.models.media import InputMedia, OperationResult +from app.operations.common import ( + VIDEO_FORMATS, + execute, + format_param, + output_path, + require_inputs, + video_codecs, +) +from app.services.ffmpeg_service import FFmpegService +from app.services.validator import bounded_number + + +async def compress_video( + ffmpeg: FFmpegService, + inputs: Sequence[InputMedia], + params: dict[str, Any], + output_dir: Path, +) -> OperationResult: + require_inputs(inputs) + extension = format_param(params, "mp4", VIDEO_FORMATS) + try: + crf = int(params.get("crf", 28)) + except (TypeError, ValueError) as exc: + raise InputError("'crf' must be an integer") from exc + if not 0 <= crf <= 51: + raise InputError("'crf' must be between 0 and 51") + preset = str(params.get("preset", "medium")) + if preset not in {"ultrafast", "superfast", "veryfast", "faster", "fast", "medium", "slow"}: + raise InputError("Unsupported x264 preset") + output = output_path(output_dir, "compressed", extension) + args: list[str | Path] = ["-i", inputs[0].temp_path] + scale = params.get("max_width") + if scale: + try: + max_width = int(scale) + except (TypeError, ValueError) as exc: + raise InputError("'max_width' must be an integer") from exc + args += ["-vf", f"scale='min({max_width},iw)':-2"] + args += video_codecs(extension, crf=crf, preset=preset) + if params.get("bitrate"): + bitrate = str(params["bitrate"]) + if not bitrate.replace("k", "").replace("M", "").isdigit(): + raise InputError("Invalid video bitrate") + args += ["-b:v", bitrate] + args += [output] + return await execute(ffmpeg, args, output, "video.compress", {"crf": crf, "format": extension}) + + +async def normalize_video( + ffmpeg: FFmpegService, + inputs: Sequence[InputMedia], + params: dict[str, Any], + output_dir: Path, +) -> OperationResult: + require_inputs(inputs) + output = output_path(output_dir, "normalized", "mp4") + args: list[str | Path] = ["-i", inputs[0].temp_path] + if inputs[0].metadata.get("audio_streams"): + args += ["-af", "loudnorm=I=-16:LRA=11:TP=-1.5"] + args += [*video_codecs("mp4", crf=23), output] + return await execute(ffmpeg, args, output, "video.normalize") + + +async def normalize_audio( + ffmpeg: FFmpegService, + inputs: Sequence[InputMedia], + params: dict[str, Any], + output_dir: Path, +) -> OperationResult: + require_inputs(inputs) + target = bounded_number(params, "target_lufs", -16, -70, -5) + output = output_path(output_dir, "normalized", "wav") + args = [ + "-i", + inputs[0].temp_path, + "-af", + f"loudnorm=I={target}:LRA=11:TP=-1.5", + "-c:a", + "pcm_s16le", + output, + ] + return await execute(ffmpeg, args, output, "audio.normalize", {"target_lufs": target}) diff --git a/app/operations/concat.py b/app/operations/concat.py new file mode 100644 index 0000000000000000000000000000000000000000..912ab66ab8da5e301ef75c95230bd5c41b5fbaa9 --- /dev/null +++ b/app/operations/concat.py @@ -0,0 +1,143 @@ +from __future__ import annotations + +from collections.abc import Sequence +from pathlib import Path +from typing import Any + +import aiofiles + +from app.models.media import InputMedia, OperationResult +from app.operations.common import even, execute, output_path, require_inputs +from app.operations.merge import merge_audio, merge_video +from app.services.ffmpeg_service import FFmpegService +from app.services.validator import as_bool, bounded_number, positive_int + + +async def concat_video( + ffmpeg: FFmpegService, inputs: Sequence[InputMedia], params: dict[str, Any], output_dir: Path +) -> OperationResult: + require_inputs(inputs, 2) + if not as_bool(params.get("stream_copy"), False): + return await merge_video( + ffmpeg, inputs, params, output_dir, operation="video.concat", stem="concatenated" + ) + manifest = output_dir / "concat.txt" + async with aiofiles.open(manifest, "w", encoding="utf-8") as output_file: + for media in inputs: + escaped = str(media.temp_path.resolve()).replace("'", "'\\''") + await output_file.write(f"file '{escaped}'\n") + output = output_path(output_dir, "concatenated", "mp4") + return await execute( + ffmpeg, + [ + "-f", + "concat", + "-safe", + "0", + "-i", + manifest, + "-c", + "copy", + "-movflags", + "+faststart", + output, + ], + output, + "video.concat", + {"inputs": len(inputs), "stream_copy": True}, + ) + + +async def concat_audio( + ffmpeg: FFmpegService, inputs: Sequence[InputMedia], params: dict[str, Any], output_dir: Path +) -> OperationResult: + return await merge_audio( + ffmpeg, inputs, params, output_dir, operation="audio.concat", stem="concatenated" + ) + + +async def image_slideshow( + ffmpeg: FFmpegService, inputs: Sequence[InputMedia], params: dict[str, Any], output_dir: Path +) -> OperationResult: + require_inputs(inputs) + duration = bounded_number(params, "duration_per_image", 3.0, 0.1, 300) + width = even(positive_int(params, "width", 1280)) + height = even(positive_int(params, "height", 720)) + fps = positive_int(params, "fps", 30) + command: list[str | Path] = [] + for media in inputs: + command += ["-loop", "1", "-t", str(duration), "-i", media.temp_path] + filters = [ + f"[{index}:v]scale={width}:{height}:force_original_aspect_ratio=decrease," + f"pad={width}:{height}:(ow-iw)/2:(oh-ih)/2,setsar=1,format=yuv420p[v{index}]" + for index in range(len(inputs)) + ] + links = "".join(f"[v{index}]" for index in range(len(inputs))) + filters.append(f"{links}concat=n={len(inputs)}:v=1:a=0[v]") + output = output_path(output_dir, "slideshow", "mp4") + command += [ + "-filter_complex", + ";".join(filters), + "-map", + "[v]", + "-r", + str(fps), + "-c:v", + "libx264", + "-pix_fmt", + "yuv420p", + "-movflags", + "+faststart", + output, + ] + return await execute( + ffmpeg, + command, + output, + "image.slideshow", + {"images": len(inputs), "duration_per_image": duration}, + ) + + +async def image_sequence( + ffmpeg: FFmpegService, inputs: Sequence[InputMedia], params: dict[str, Any], output_dir: Path +) -> OperationResult: + fps = positive_int(params, "fps", 25) + result = await image_slideshow( + ffmpeg, + inputs, + { + "duration_per_image": params.get("duration_per_image", 1 / fps), + **params, + }, + output_dir, + ) + result.metadata["operation"] = "image.sequence" + return result + + +async def image_to_video( + ffmpeg: FFmpegService, inputs: Sequence[InputMedia], params: dict[str, Any], output_dir: Path +) -> OperationResult: + require_inputs(inputs) + duration = bounded_number(params, "duration", 5, 0.1, 3600) + fps = positive_int(params, "fps", 30) + output = output_path(output_dir, "image-video", "mp4") + args = [ + "-loop", + "1", + "-i", + inputs[0].temp_path, + "-t", + str(duration), + "-vf", + "scale=trunc(iw/2)*2:trunc(ih/2)*2,format=yuv420p", + "-r", + str(fps), + "-c:v", + "libx264", + "-movflags", + "+faststart", + output, + ] + return await execute(ffmpeg, args, output, "image.video", {"duration": duration}) diff --git a/app/operations/convert.py b/app/operations/convert.py new file mode 100644 index 0000000000000000000000000000000000000000..9ee4d783e1a78bc42778d049e4bdb1354f0fbfb3 --- /dev/null +++ b/app/operations/convert.py @@ -0,0 +1,52 @@ +from __future__ import annotations + +from collections.abc import Sequence +from pathlib import Path +from typing import Any + +from app.models.media import InputMedia, OperationResult +from app.operations.common import ( + AUDIO_FORMATS, + IMAGE_FORMATS, + VIDEO_FORMATS, + audio_codec, + execute, + format_param, + output_path, + require_inputs, + video_codecs, +) +from app.services.ffmpeg_service import FFmpegService + + +async def convert_video( + ffmpeg: FFmpegService, inputs: Sequence[InputMedia], params: dict[str, Any], output_dir: Path +) -> OperationResult: + require_inputs(inputs) + extension = format_param(params, "mp4", VIDEO_FORMATS) + output = output_path(output_dir, "converted", extension) + args = ["-i", inputs[0].temp_path, *video_codecs(extension), output] + return await execute(ffmpeg, args, output, "video.convert", {"format": extension}) + + +async def convert_audio( + ffmpeg: FFmpegService, inputs: Sequence[InputMedia], params: dict[str, Any], output_dir: Path +) -> OperationResult: + require_inputs(inputs) + extension = format_param(params, "mp3", AUDIO_FORMATS) + output = output_path(output_dir, "converted", extension) + args = ["-i", inputs[0].temp_path, "-vn", *audio_codec(extension), output] + return await execute(ffmpeg, args, output, "audio.convert", {"format": extension}) + + +async def convert_image( + ffmpeg: FFmpegService, inputs: Sequence[InputMedia], params: dict[str, Any], output_dir: Path +) -> OperationResult: + require_inputs(inputs) + extension = format_param(params, "png", IMAGE_FORMATS) + output = output_path(output_dir, "converted", extension) + args: list[str | Path] = ["-i", inputs[0].temp_path, "-frames:v", "1"] + if extension in {"jpg", "jpeg"}: + args += ["-q:v", str(params.get("quality", 2))] + args += [output] + return await execute(ffmpeg, args, output, "image.convert", {"format": extension}) diff --git a/app/operations/crop.py b/app/operations/crop.py new file mode 100644 index 0000000000000000000000000000000000000000..a013eeb9bc58b122aca498b8d9ec6f6df12de317 --- /dev/null +++ b/app/operations/crop.py @@ -0,0 +1,60 @@ +from __future__ import annotations + +from collections.abc import Sequence +from pathlib import Path +from typing import Any + +from app.models.media import InputMedia, OperationResult +from app.operations.common import ( + IMAGE_FORMATS, + execute, + format_param, + output_path, + require_inputs, + video_codecs, +) +from app.services.ffmpeg_service import FFmpegService +from app.services.validator import positive_int + + +def _crop_filter(params: dict[str, Any]) -> tuple[str, dict[str, int]]: + width = positive_int(params, "width", 640) + height = positive_int(params, "height", 360) + x = int(params.get("x", 0)) + y = int(params.get("y", 0)) + if x < 0 or y < 0: + from app.core.exceptions import InputError + + raise InputError("Crop x and y must not be negative") + return f"crop={width}:{height}:{x}:{y}", {"width": width, "height": height, "x": x, "y": y} + + +async def crop_video( + ffmpeg: FFmpegService, inputs: Sequence[InputMedia], params: dict[str, Any], output_dir: Path +) -> OperationResult: + require_inputs(inputs) + vf, metadata = _crop_filter(params) + output = output_path(output_dir, "cropped", "mp4") + return await execute( + ffmpeg, + ["-i", inputs[0].temp_path, "-vf", vf, *video_codecs("mp4"), output], + output, + "video.crop", + metadata, + ) + + +async def crop_image( + ffmpeg: FFmpegService, inputs: Sequence[InputMedia], params: dict[str, Any], output_dir: Path +) -> OperationResult: + require_inputs(inputs) + vf, metadata = _crop_filter(params) + extension = format_param(params, "png", IMAGE_FORMATS) + output = output_path(output_dir, "cropped", extension) + return await execute( + ffmpeg, + ["-i", inputs[0].temp_path, "-vf", vf, "-frames:v", "1", output], + output, + "image.crop", + metadata, + ) diff --git a/app/operations/extract_audio.py b/app/operations/extract_audio.py new file mode 100644 index 0000000000000000000000000000000000000000..dffdff008e5950fff8ead3cf5d157313cd22c4c4 --- /dev/null +++ b/app/operations/extract_audio.py @@ -0,0 +1,153 @@ +from __future__ import annotations + +import re +from collections.abc import Sequence +from pathlib import Path +from typing import Any + +from app.core.exceptions import InputError +from app.models.media import InputMedia, OperationResult +from app.operations.common import audio_codec, execute, output_path, require_inputs +from app.services.ffmpeg_service import FFmpegService +from app.services.validator import bounded_number + + +async def extract_audio( + ffmpeg: FFmpegService, inputs: Sequence[InputMedia], params: dict[str, Any], output_dir: Path +) -> OperationResult: + require_inputs(inputs) + extension = str(params.get("format", "mp3")).lower().lstrip(".") + if extension not in {"mp3", "wav", "aac", "m4a", "flac", "ogg", "opus"}: + raise InputError("Unsupported extracted audio format") + output = output_path(output_dir, "audio", extension) + return await execute( + ffmpeg, + ["-i", inputs[0].temp_path, "-vn", *audio_codec(extension), output], + output, + "audio.extract", + {"format": extension}, + ) + + +async def replace_audio( + ffmpeg: FFmpegService, inputs: Sequence[InputMedia], params: dict[str, Any], output_dir: Path +) -> OperationResult: + require_inputs(inputs, 2) + output = output_path(output_dir, "replaced-audio", "mp4") + args = [ + "-i", + inputs[0].temp_path, + "-i", + inputs[1].temp_path, + "-map", + "0:v:0", + "-map", + "1:a:0", + "-c:v", + "copy", + "-c:a", + "aac", + "-shortest", + "-movflags", + "+faststart", + output, + ] + return await execute(ffmpeg, args, output, "video.replace_audio") + + +async def remove_audio( + ffmpeg: FFmpegService, inputs: Sequence[InputMedia], params: dict[str, Any], output_dir: Path +) -> OperationResult: + require_inputs(inputs) + output = output_path(output_dir, "muted", "mp4") + return await execute( + ffmpeg, + ["-i", inputs[0].temp_path, "-an", "-c:v", "copy", output], + output, + "video.remove_audio", + ) + + +async def mute_video( + ffmpeg: FFmpegService, inputs: Sequence[InputMedia], params: dict[str, Any], output_dir: Path +) -> OperationResult: + result = await remove_audio(ffmpeg, inputs, params, output_dir) + result.metadata["operation"] = "video.mute" + return result + + +async def fade_audio( + ffmpeg: FFmpegService, inputs: Sequence[InputMedia], params: dict[str, Any], output_dir: Path +) -> OperationResult: + require_inputs(inputs) + start = bounded_number(params, "start", 0, 0, 86_400) + duration = bounded_number(params, "duration", 3, 0.01, 86_400) + fade_type = str(params.get("type", "in")) + if fade_type not in {"in", "out"}: + raise InputError("Fade requires type in/out, non-negative start, and positive duration") + output = output_path(output_dir, "faded", "mp3") + filter_name = "afade=t=in" if fade_type == "in" else "afade=t=out" + return await execute( + ffmpeg, + [ + "-i", + inputs[0].temp_path, + "-af", + f"{filter_name}:st={start}:d={duration}", + *audio_codec("mp3"), + output, + ], + output, + "audio.fade", + ) + + +async def set_volume( + ffmpeg: FFmpegService, inputs: Sequence[InputMedia], params: dict[str, Any], output_dir: Path +) -> OperationResult: + require_inputs(inputs) + volume = bounded_number(params, "volume", 1, 0, 10) + output = output_path(output_dir, "volume", "mp3") + return await execute( + ffmpeg, + ["-i", inputs[0].temp_path, "-af", f"volume={volume}", *audio_codec("mp3"), output], + output, + "audio.volume", + {"volume": volume}, + ) + + +async def remove_silence( + ffmpeg: FFmpegService, inputs: Sequence[InputMedia], params: dict[str, Any], output_dir: Path +) -> OperationResult: + require_inputs(inputs) + threshold = str(params.get("threshold", "-45dB")) + if not re.fullmatch(r"-?\d+(?:\.\d+)?dB", threshold): + raise InputError("threshold must be a decibel value such as -45dB") + output = output_path(output_dir, "no-silence", "mp3") + return await execute( + ffmpeg, + [ + "-i", + inputs[0].temp_path, + "-af", + f"silenceremove=start_periods=1:start_duration=0.2:start_threshold={threshold}:stop_periods=-1:stop_duration=0.2:stop_threshold={threshold}", + *audio_codec("mp3"), + output, + ], + output, + "audio.remove_silence", + ) + + +async def noise_reduction( + ffmpeg: FFmpegService, inputs: Sequence[InputMedia], params: dict[str, Any], output_dir: Path +) -> OperationResult: + require_inputs(inputs) + output = output_path(output_dir, "denoised", "mp3") + return await execute( + ffmpeg, + ["-i", inputs[0].temp_path, "-af", "afftdn", *audio_codec("mp3"), output], + output, + "audio.noise_reduction", + ) diff --git a/app/operations/merge.py b/app/operations/merge.py new file mode 100644 index 0000000000000000000000000000000000000000..0ec62c81ec5391bdfe7ebe2e8cfa905891d9c0d7 --- /dev/null +++ b/app/operations/merge.py @@ -0,0 +1,91 @@ +from __future__ import annotations + +from collections.abc import Sequence +from pathlib import Path +from typing import Any + +from app.models.media import InputMedia, OperationResult +from app.operations.common import ( + audio_codec, + even, + execute, + output_path, + require_inputs, + video_codecs, +) +from app.services.ffmpeg_service import FFmpegService +from app.services.validator import positive_int + + +async def merge_video( + ffmpeg: FFmpegService, + inputs: Sequence[InputMedia], + params: dict[str, Any], + output_dir: Path, + *, + operation: str = "video.merge", + stem: str = "merged", +) -> OperationResult: + require_inputs(inputs, 2) + width = even(positive_int(params, "width", 1280)) + height = even(positive_int(params, "height", 720)) + command: list[str | Path] = [] + for media in inputs: + command += ["-i", media.temp_path] + filters = [ + f"[{index}:v]scale={width}:{height}:force_original_aspect_ratio=decrease," + f"pad={width}:{height}:(ow-iw)/2:(oh-ih)/2,setsar=1,setpts=PTS-STARTPTS[v{index}]" + for index in range(len(inputs)) + ] + all_audio = all(bool(media.metadata.get("audio_streams")) for media in inputs) + if all_audio: + filters.extend( + f"[{index}:a]aresample=48000,aformat=sample_fmts=fltp:channel_layouts=stereo," + f"asetpts=PTS-STARTPTS[a{index}]" + for index in range(len(inputs)) + ) + links = "".join(f"[v{index}][a{index}]" for index in range(len(inputs))) + filters.append(f"{links}concat=n={len(inputs)}:v=1:a=1[v][a]") + else: + links = "".join(f"[v{index}]" for index in range(len(inputs))) + filters.append(f"{links}concat=n={len(inputs)}:v=1:a=0[v]") + output = output_path(output_dir, stem, "mp4") + command += ["-filter_complex", ";".join(filters), "-map", "[v]"] + if all_audio: + command += ["-map", "[a]"] + else: + command += ["-an"] + command += [*video_codecs("mp4"), output] + return await execute(ffmpeg, command, output, operation, {"inputs": len(inputs)}) + + +async def merge_audio( + ffmpeg: FFmpegService, + inputs: Sequence[InputMedia], + params: dict[str, Any], + output_dir: Path, + *, + operation: str = "audio.merge", + stem: str = "merged", +) -> OperationResult: + require_inputs(inputs, 2) + command: list[str | Path] = [] + for media in inputs: + command += ["-i", media.temp_path] + filters = [ + f"[{index}:a]aresample=48000,aformat=sample_fmts=fltp:channel_layouts=stereo," + f"asetpts=PTS-STARTPTS[a{index}]" + for index in range(len(inputs)) + ] + links = "".join(f"[a{index}]" for index in range(len(inputs))) + filters.append(f"{links}concat=n={len(inputs)}:v=0:a=1[a]") + output = output_path(output_dir, stem, "mp3") + command += [ + "-filter_complex", + ";".join(filters), + "-map", + "[a]", + *audio_codec("mp3"), + output, + ] + return await execute(ffmpeg, command, output, operation, {"inputs": len(inputs)}) diff --git a/app/operations/resize.py b/app/operations/resize.py new file mode 100644 index 0000000000000000000000000000000000000000..b957353f482352d9d2e15ed0d377c0c70ab4ed4f --- /dev/null +++ b/app/operations/resize.py @@ -0,0 +1,100 @@ +from __future__ import annotations + +from collections.abc import Sequence +from pathlib import Path +from typing import Any + +from app.core.exceptions import InputError +from app.models.media import InputMedia, OperationResult +from app.operations.common import ( + IMAGE_FORMATS, + VIDEO_FORMATS, + even, + execute, + format_param, + output_path, + require_inputs, + video_codecs, +) +from app.services.ffmpeg_service import FFmpegService +from app.services.validator import positive_int + + +def _scale(params: dict[str, Any]) -> tuple[int, int, str]: + width = even(positive_int(params, "width", 1280)) + height = even(positive_int(params, "height", 720)) + if width < 2 or height < 2: + raise InputError("Output dimensions must be at least 2x2") + fit = str(params.get("fit", "contain")) + if fit == "fill": + expression = f"scale={width}:{height}" + elif fit == "cover": + expression = ( + f"scale={width}:{height}:force_original_aspect_ratio=increase,crop={width}:{height}" + ) + elif fit == "contain": + expression = f"scale={width}:{height}:force_original_aspect_ratio=decrease,pad={width}:{height}:(ow-iw)/2:(oh-ih)/2" + else: + raise InputError("'fit' must be contain, cover, or fill") + return width, height, expression + + +async def resize_video( + ffmpeg: FFmpegService, inputs: Sequence[InputMedia], params: dict[str, Any], output_dir: Path +) -> OperationResult: + require_inputs(inputs) + width, height, expression = _scale(params) + extension = format_param(params, "mp4", VIDEO_FORMATS) + output = output_path(output_dir, "resized", extension) + return await execute( + ffmpeg, + ["-i", inputs[0].temp_path, "-vf", expression, *video_codecs(extension), output], + output, + "video.resize", + {"width": width, "height": height}, + ) + + +async def scale_video( + ffmpeg: FFmpegService, inputs: Sequence[InputMedia], params: dict[str, Any], output_dir: Path +) -> OperationResult: + copied = {**params, "fit": params.get("fit", "fill")} + result = await resize_video(ffmpeg, inputs, copied, output_dir) + result.metadata["operation"] = "video.scale" + return result + + +async def pad_video( + ffmpeg: FFmpegService, inputs: Sequence[InputMedia], params: dict[str, Any], output_dir: Path +) -> OperationResult: + require_inputs(inputs) + width = even(positive_int(params, "width", 1920)) + height = even(positive_int(params, "height", 1080)) + color = str(params.get("color", "black")) + if not color.replace("#", "").isalnum(): + raise InputError("Invalid pad color") + output = output_path(output_dir, "padded", "mp4") + vf = f"scale={width}:{height}:force_original_aspect_ratio=decrease,pad={width}:{height}:(ow-iw)/2:(oh-ih)/2:{color}" + return await execute( + ffmpeg, + ["-i", inputs[0].temp_path, "-vf", vf, *video_codecs("mp4"), output], + output, + "video.pad", + {"width": width, "height": height}, + ) + + +async def resize_image( + ffmpeg: FFmpegService, inputs: Sequence[InputMedia], params: dict[str, Any], output_dir: Path +) -> OperationResult: + require_inputs(inputs) + width, height, expression = _scale(params) + extension = format_param(params, "png", IMAGE_FORMATS) + output = output_path(output_dir, "resized", extension) + return await execute( + ffmpeg, + ["-i", inputs[0].temp_path, "-vf", expression, "-frames:v", "1", output], + output, + "image.resize", + {"width": width, "height": height}, + ) diff --git a/app/operations/rotate.py b/app/operations/rotate.py new file mode 100644 index 0000000000000000000000000000000000000000..91743a1e91634d43fcceaf4499cf2e56b0ab771f --- /dev/null +++ b/app/operations/rotate.py @@ -0,0 +1,138 @@ +from __future__ import annotations + +from collections.abc import Sequence +from pathlib import Path +from typing import Any + +from app.core.exceptions import InputError +from app.models.media import InputMedia, OperationResult +from app.operations.common import execute, output_path, require_inputs, video_codecs +from app.services.ffmpeg_service import FFmpegService + + +async def rotate_video( + ffmpeg: FFmpegService, inputs: Sequence[InputMedia], params: dict[str, Any], output_dir: Path +) -> OperationResult: + require_inputs(inputs) + try: + angle = int(params.get("angle", 90)) % 360 + except (TypeError, ValueError) as exc: + raise InputError("'angle' must be an integer") from exc + filters = {90: "transpose=1", 180: "hflip,vflip", 270: "transpose=2", 0: "null"} + if angle not in filters: + raise InputError("Rotation angle must be 0, 90, 180, or 270 degrees") + output = output_path(output_dir, "rotated", "mp4") + return await execute( + ffmpeg, + [ + "-i", + inputs[0].temp_path, + "-vf", + filters[angle], + "-metadata:s:v:0", + "rotate=0", + *video_codecs("mp4"), + output, + ], + output, + "video.rotate", + {"angle": angle}, + ) + + +async def reverse_video( + ffmpeg: FFmpegService, inputs: Sequence[InputMedia], params: dict[str, Any], output_dir: Path +) -> OperationResult: + require_inputs(inputs) + output = output_path(output_dir, "reversed", "mp4") + has_audio = bool(inputs[0].metadata.get("audio_streams", True)) + args: list[str | Path] = ["-i", inputs[0].temp_path, "-vf", "reverse"] + if has_audio: + args += ["-af", "areverse"] + args += [*video_codecs("mp4"), output] + return await execute(ffmpeg, args, output, "video.reverse") + + +async def change_speed( + ffmpeg: FFmpegService, inputs: Sequence[InputMedia], params: dict[str, Any], output_dir: Path +) -> OperationResult: + from app.operations.common import atempo_chain + + require_inputs(inputs) + try: + factor = float(params.get("factor", 2.0)) + except (TypeError, ValueError) as exc: + raise InputError("'factor' must be a number") from exc + if not 0.125 <= factor <= 8: + raise InputError("Speed factor must be between 0.125 and 8") + output = output_path(output_dir, "speed", "mp4") + has_audio = bool(inputs[0].metadata.get("audio_streams", True)) + args: list[str | Path] = ["-i", inputs[0].temp_path, "-vf", f"setpts=PTS/{factor:.8g}"] + if has_audio: + args += ["-af", atempo_chain(factor)] + args += [*video_codecs("mp4"), output] + return await execute(ffmpeg, args, output, "video.speed", {"factor": factor}) + + +async def slow_motion( + ffmpeg: FFmpegService, + inputs: Sequence[InputMedia], + params: dict[str, Any], + output_dir: Path, +) -> OperationResult: + result = await change_speed( + ffmpeg, inputs, {"factor": params.get("factor", 0.5), **params}, output_dir + ) + result.metadata["operation"] = "video.slow_motion" + return result + + +async def change_fps( + ffmpeg: FFmpegService, inputs: Sequence[InputMedia], params: dict[str, Any], output_dir: Path +) -> OperationResult: + require_inputs(inputs) + try: + fps = float(params.get("fps", 30)) + except (TypeError, ValueError) as exc: + raise InputError("'fps' must be a number") from exc + if not 1 <= fps <= 240: + raise InputError("FPS must be between 1 and 240") + output = output_path(output_dir, "fps", "mp4") + return await execute( + ffmpeg, + ["-i", inputs[0].temp_path, "-vf", f"fps={fps:.6g}", *video_codecs("mp4"), output], + output, + "video.fps", + {"fps": fps}, + ) + + +async def change_bitrate( + ffmpeg: FFmpegService, inputs: Sequence[InputMedia], params: dict[str, Any], output_dir: Path +) -> OperationResult: + require_inputs(inputs) + bitrate = str(params.get("bitrate", "1500k")) + normalized = bitrate.lower().removesuffix("k").removesuffix("m") + if not normalized.replace(".", "", 1).isdigit(): + raise InputError("Invalid bitrate; examples: 1500k or 2M") + output = output_path(output_dir, "bitrate", "mp4") + args = [ + "-i", + inputs[0].temp_path, + "-c:v", + "libx264", + "-b:v", + bitrate, + "-maxrate", + bitrate, + "-bufsize", + bitrate, + "-c:a", + "aac", + "-b:a", + "128k", + "-movflags", + "+faststart", + output, + ] + return await execute(ffmpeg, args, output, "video.bitrate", {"bitrate": bitrate}) diff --git a/app/operations/subtitles.py b/app/operations/subtitles.py new file mode 100644 index 0000000000000000000000000000000000000000..44c5851ecaa36f1899c1133c946bc38147cd0515 --- /dev/null +++ b/app/operations/subtitles.py @@ -0,0 +1,69 @@ +from __future__ import annotations + +import re +from collections.abc import Sequence +from pathlib import Path +from typing import Any + +from app.models.media import InputMedia, OperationResult +from app.operations.common import ( + execute, + output_path, + require_inputs, + subtitle_filter_path, + video_codecs, +) +from app.services.ffmpeg_service import FFmpegService + + +async def burn_subtitles( + ffmpeg: FFmpegService, inputs: Sequence[InputMedia], params: dict[str, Any], output_dir: Path +) -> OperationResult: + require_inputs(inputs, 2) + output = output_path(output_dir, "subtitled", "mp4") + subtitle_path = subtitle_filter_path(inputs[1].temp_path) + force_style = params.get("style") + vf = f"subtitles=filename='{subtitle_path}'" + if force_style: + safe_style = str(force_style) + if len(safe_style) > 500 or not re.fullmatch(r"[A-Za-z0-9 ,=.&#+_-]+", safe_style): + from app.core.exceptions import InputError + + raise InputError("Subtitle style contains unsupported characters") + vf += f":force_style='{safe_style}'" + return await execute( + ffmpeg, + ["-i", inputs[0].temp_path, "-vf", vf, *video_codecs("mp4"), output], + output, + "video.subtitles.burn", + ) + + +async def soft_subtitles( + ffmpeg: FFmpegService, inputs: Sequence[InputMedia], params: dict[str, Any], output_dir: Path +) -> OperationResult: + require_inputs(inputs, 2) + output = output_path(output_dir, "soft-subtitled", "mkv") + language = str(params.get("language", "eng"))[:12] + args = [ + "-i", + inputs[0].temp_path, + "-i", + inputs[1].temp_path, + "-map", + "0:v?", + "-map", + "0:a?", + "-map", + "1:0", + "-c:v", + "copy", + "-c:a", + "copy", + "-c:s", + "srt", + "-metadata:s:s:0", + f"language={language}", + output, + ] + return await execute(ffmpeg, args, output, "video.subtitles.soft", {"language": language}) diff --git a/app/operations/thumbnails.py b/app/operations/thumbnails.py new file mode 100644 index 0000000000000000000000000000000000000000..8422fd02fd98254fa880e78bb2c36dcd2457cb6c --- /dev/null +++ b/app/operations/thumbnails.py @@ -0,0 +1,166 @@ +from __future__ import annotations + +import asyncio +import shutil +import zipfile +from collections.abc import Sequence +from pathlib import Path +from typing import Any + +from app.core.exceptions import ProcessingError +from app.models.media import InputMedia, OperationResult +from app.operations.common import execute, output_path, require_inputs, video_codecs +from app.services.ffmpeg_service import FFmpegService +from app.services.validator import bounded_number, positive_int + + +async def thumbnail( + ffmpeg: FFmpegService, inputs: Sequence[InputMedia], params: dict[str, Any], output_dir: Path +) -> OperationResult: + require_inputs(inputs) + timestamp = bounded_number(params, "timestamp", 0, 0, 86_400) + output = output_path(output_dir, "thumbnail", "jpg") + quality = min(31, positive_int(params, "quality", 3)) + return await execute( + ffmpeg, + [ + "-ss", + str(timestamp), + "-i", + inputs[0].temp_path, + "-frames:v", + "1", + "-q:v", + str(quality), + output, + ], + output, + "video.thumbnail", + {"timestamp": timestamp}, + ) + + +async def extract_frames( + ffmpeg: FFmpegService, inputs: Sequence[InputMedia], params: dict[str, Any], output_dir: Path +) -> OperationResult: + require_inputs(inputs) + fps = bounded_number(params, "fps", 1, 0.01, 60) + frame_dir = output_dir / "frames" + frame_dir.mkdir(parents=True, exist_ok=True) + frame_pattern = frame_dir / "frame_%06d.jpg" + args: list[str | Path] = ["-i", inputs[0].temp_path, "-vf", f"fps={fps}", "-q:v", "3"] + maximum = params.get("max_frames") + if maximum is not None: + maximum_value = positive_int(params, "max_frames", 1) + args += ["-frames:v", str(maximum_value)] + args += [frame_pattern] + await ffmpeg.run(args, operation="video.extract_frames") + frames = sorted(frame_dir.glob("frame_*.jpg")) + if not frames: + raise ProcessingError("No frames were extracted") + archive = output_path(output_dir, "frames", "zip") + await asyncio.to_thread(_archive_frames, archive, frames) + await asyncio.to_thread(shutil.rmtree, frame_dir, True) + return OperationResult( + path=archive, + filename=archive.name, + mime_type="application/zip", + metadata={"frames": len(frames), "fps": fps}, + ) + + +def _archive_frames(archive: Path, frames: list[Path]) -> None: + with zipfile.ZipFile(archive, "w", zipfile.ZIP_DEFLATED) as zip_file: + for frame in frames: + zip_file.write(frame, frame.name) + + +async def generate_gif( + ffmpeg: FFmpegService, inputs: Sequence[InputMedia], params: dict[str, Any], output_dir: Path +) -> OperationResult: + require_inputs(inputs) + fps = bounded_number(params, "fps", 10, 1, 30) + width = positive_int(params, "width", 480) + output = output_path(output_dir, "animation", "gif") + palette = output_dir / "palette.png" + await ffmpeg.run( + [ + "-i", + inputs[0].temp_path, + "-vf", + f"fps={fps},scale={width}:-1:flags=lanczos,palettegen", + palette, + ], + operation="video.gif.palette", + ) + await ffmpeg.run( + [ + "-i", + inputs[0].temp_path, + "-i", + palette, + "-lavfi", + f"fps={fps},scale={width}:-1:flags=lanczos[x];[x][1:v]paletteuse", + output, + ], + operation="video.gif", + ) + palette.unlink(missing_ok=True) + if not output.is_file() or output.stat().st_size == 0: + raise ProcessingError("GIF generation did not produce an output") + return OperationResult( + path=output, + filename=output.name, + mime_type="image/gif", + metadata={"fps": fps, "width": width}, + ) + + +async def blur_video( + ffmpeg: FFmpegService, inputs: Sequence[InputMedia], params: dict[str, Any], output_dir: Path +) -> OperationResult: + require_inputs(inputs) + strength = min(100, positive_int(params, "strength", 5)) + output = output_path(output_dir, "blurred", "mp4") + return await execute( + ffmpeg, + ["-i", inputs[0].temp_path, "-vf", f"boxblur={strength}:1", *video_codecs("mp4"), output], + output, + "video.blur", + {"strength": strength}, + ) + + +async def sharpen_video( + ffmpeg: FFmpegService, inputs: Sequence[InputMedia], params: dict[str, Any], output_dir: Path +) -> OperationResult: + require_inputs(inputs) + amount = bounded_number(params, "amount", 1.0, 0, 5) + output = output_path(output_dir, "sharpened", "mp4") + return await execute( + ffmpeg, + [ + "-i", + inputs[0].temp_path, + "-vf", + f"unsharp=5:5:{amount}:5:5:0", + *video_codecs("mp4"), + output, + ], + output, + "video.sharpen", + {"amount": amount}, + ) + + +async def denoise_video( + ffmpeg: FFmpegService, inputs: Sequence[InputMedia], params: dict[str, Any], output_dir: Path +) -> OperationResult: + require_inputs(inputs) + output = output_path(output_dir, "denoised", "mp4") + return await execute( + ffmpeg, + ["-i", inputs[0].temp_path, "-vf", "hqdn3d", *video_codecs("mp4"), output], + output, + "video.denoise", + ) diff --git a/app/operations/trim.py b/app/operations/trim.py new file mode 100644 index 0000000000000000000000000000000000000000..60c87969d00fa82dc489bb16601af38cdd2b0008 --- /dev/null +++ b/app/operations/trim.py @@ -0,0 +1,49 @@ +from __future__ import annotations + +from collections.abc import Sequence +from pathlib import Path +from typing import Any + +from app.core.exceptions import InputError +from app.models.media import InputMedia, OperationResult +from app.operations.common import audio_codec, execute, output_path, require_inputs, video_codecs +from app.services.ffmpeg_service import FFmpegService + + +def _times(params: dict[str, Any]) -> tuple[float, float | None]: + try: + start = float(params.get("start", 0)) + duration = float(params["duration"]) if params.get("duration") is not None else None + if duration is None and params.get("end") is not None: + duration = float(params["end"]) - start + except (TypeError, ValueError) as exc: + raise InputError("start, end, and duration must be numbers") from exc + if start < 0 or (duration is not None and duration <= 0): + raise InputError("start must be non-negative and duration/end must follow start") + return start, duration + + +async def trim_video( + ffmpeg: FFmpegService, inputs: Sequence[InputMedia], params: dict[str, Any], output_dir: Path +) -> OperationResult: + require_inputs(inputs) + start, duration = _times(params) + output = output_path(output_dir, "trimmed", "mp4") + args: list[str | Path] = ["-ss", str(start), "-i", inputs[0].temp_path] + if duration is not None: + args += ["-t", str(duration)] + args += [*video_codecs("mp4"), output] + return await execute(ffmpeg, args, output, "video.trim", {"start": start, "duration": duration}) + + +async def trim_audio( + ffmpeg: FFmpegService, inputs: Sequence[InputMedia], params: dict[str, Any], output_dir: Path +) -> OperationResult: + require_inputs(inputs) + start, duration = _times(params) + output = output_path(output_dir, "trimmed", "mp3") + args: list[str | Path] = ["-ss", str(start), "-i", inputs[0].temp_path] + if duration is not None: + args += ["-t", str(duration)] + args += [*audio_codec("mp3"), output] + return await execute(ffmpeg, args, output, "audio.trim", {"start": start, "duration": duration}) diff --git a/app/operations/watermark.py b/app/operations/watermark.py new file mode 100644 index 0000000000000000000000000000000000000000..2c17e246e56686d42fe836cfa0b989de3616b77a --- /dev/null +++ b/app/operations/watermark.py @@ -0,0 +1,101 @@ +from __future__ import annotations + +from collections.abc import Sequence +from pathlib import Path +from typing import Any + +from app.core.exceptions import InputError +from app.models.media import InputMedia, OperationResult +from app.operations.common import ( + IMAGE_FORMATS, + execute, + format_param, + output_path, + require_inputs, + video_codecs, +) +from app.services.ffmpeg_service import FFmpegService + + +def _overlay(params: dict[str, Any]) -> tuple[str, dict[str, Any]]: + position = str(params.get("position", "bottom-right")) + positions = { + "top-left": "10:10", + "top-right": "W-w-10:10", + "bottom-left": "10:H-h-10", + "bottom-right": "W-w-10:H-h-10", + "center": "(W-w)/2:(H-h)/2", + } + if position not in positions: + raise InputError(f"position must be one of: {', '.join(positions)}") + try: + opacity = float(params.get("opacity", 1.0)) + scale = float(params.get("watermark_scale", 1.0)) + except (TypeError, ValueError) as exc: + raise InputError("opacity and watermark_scale must be numbers") from exc + if not 0 <= opacity <= 1 or not 0.01 <= scale <= 10: + raise InputError("opacity must be 0..1 and watermark_scale must be 0.01..10") + filter_graph = f"[1:v]format=rgba,colorchannelmixer=aa={opacity},scale=iw*{scale}:ih*{scale}[wm];[0:v][wm]overlay={positions[position]}[v]" + return filter_graph, {"position": position, "opacity": opacity, "scale": scale} + + +async def watermark_video( + ffmpeg: FFmpegService, inputs: Sequence[InputMedia], params: dict[str, Any], output_dir: Path +) -> OperationResult: + require_inputs(inputs, 2) + graph, metadata = _overlay(params) + output = output_path(output_dir, "watermarked", "mp4") + args = [ + "-i", + inputs[0].temp_path, + "-i", + inputs[1].temp_path, + "-filter_complex", + graph, + "-map", + "[v]", + "-map", + "0:a?", + *video_codecs("mp4"), + output, + ] + return await execute(ffmpeg, args, output, "video.watermark", metadata) + + +async def overlay_video( + ffmpeg: FFmpegService, inputs: Sequence[InputMedia], params: dict[str, Any], output_dir: Path +) -> OperationResult: + result = await watermark_video(ffmpeg, inputs, params, output_dir) + result.metadata["operation"] = "video.overlay" + return result + + +async def watermark_image( + ffmpeg: FFmpegService, inputs: Sequence[InputMedia], params: dict[str, Any], output_dir: Path +) -> OperationResult: + require_inputs(inputs, 2) + graph, metadata = _overlay(params) + extension = format_param(params, "png", IMAGE_FORMATS) + output = output_path(output_dir, "watermarked", extension) + args = [ + "-i", + inputs[0].temp_path, + "-i", + inputs[1].temp_path, + "-filter_complex", + graph, + "-map", + "[v]", + "-frames:v", + "1", + output, + ] + return await execute(ffmpeg, args, output, "image.watermark", metadata) + + +async def overlay_image( + ffmpeg: FFmpegService, inputs: Sequence[InputMedia], params: dict[str, Any], output_dir: Path +) -> OperationResult: + result = await watermark_image(ffmpeg, inputs, params, output_dir) + result.metadata["operation"] = "image.overlay" + return result diff --git a/app/services/__init__.py b/app/services/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..16c7016306f99646ab83cb5aaf1fd7117dd023a7 --- /dev/null +++ b/app/services/__init__.py @@ -0,0 +1 @@ +"""Infrastructure and application services.""" diff --git a/app/services/cleanup.py b/app/services/cleanup.py new file mode 100644 index 0000000000000000000000000000000000000000..025bab43fca7dbec3a4049a05cfb8684cecd8cab --- /dev/null +++ b/app/services/cleanup.py @@ -0,0 +1,122 @@ +from __future__ import annotations + +import asyncio +import os +import shutil +import time +from dataclasses import dataclass +from pathlib import Path +from uuid import UUID + +from app.core.config import Settings +from app.core.exceptions import NotFoundError, ProcessingError +from app.core.logger import get_logger + +logger = get_logger(__name__) + + +@dataclass(frozen=True, slots=True) +class RequestWorkspace: + request_id: str + root: Path + uploads: Path + outputs: Path + logs: Path + + +class CleanupService: + """Owns per-request directories and expires completed or abandoned work.""" + + def __init__(self, settings: Settings) -> None: + self.settings = settings + self._active: set[str] = set() + self._lock = asyncio.Lock() + + async def create_workspace(self, request_id: str) -> RequestWorkspace: + self._validate_request_id(request_id) + root = self.settings.temp_dir / request_id + workspace = RequestWorkspace( + request_id=request_id, + root=root, + uploads=root / "uploads", + outputs=root / "outputs", + logs=root / "logs", + ) + for directory in (workspace.uploads, workspace.outputs, workspace.logs): + directory.mkdir(parents=True, exist_ok=True) + async with self._lock: + self._active.add(request_id) + return workspace + + async def complete(self, request_id: str) -> None: + async with self._lock: + self._active.discard(request_id) + for base in (self.settings.temp_dir, self.settings.output_dir): + path = base / request_id + if path.exists(): + await asyncio.to_thread(os.utime, path, None) + + async def publish(self, request_id: str, source: Path, filename: str) -> Path: + self._validate_request_id(request_id) + safe_name = Path(filename).name + if not safe_name or safe_name in {".", ".."}: + raise ProcessingError("The generated output filename is invalid") + destination_dir = self.settings.output_dir / request_id + destination_dir.mkdir(parents=True, exist_ok=True) + destination = destination_dir / safe_name + try: + await asyncio.to_thread(os.replace, source, destination) + except OSError: + await asyncio.to_thread(shutil.move, str(source), str(destination)) + return destination + + def resolve_download(self, request_id: str, filename: str) -> Path: + self._validate_request_id(request_id) + if filename != Path(filename).name: + raise NotFoundError("Output file not found") + root = (self.settings.output_dir / request_id).resolve() + candidate = (root / filename).resolve() + if candidate.parent != root or not candidate.is_file(): + raise NotFoundError("Output file not found") + return candidate + + async def cleanup_expired(self) -> int: + cutoff = time.time() - self.settings.cleanup_minutes * 60 + async with self._lock: + active = self._active.copy() + removed = 0 + for base in (self.settings.temp_dir, self.settings.output_dir): + if not base.exists(): + continue + for path in list(base.iterdir()): + if not path.is_dir() or path.name in active: + continue + try: + if path.stat().st_mtime < cutoff: + await asyncio.to_thread(shutil.rmtree, path) + removed += 1 + logger.info("expired workspace removed", extra={"path": str(path)}) + except FileNotFoundError: + continue + except OSError as exc: + logger.warning( + "workspace cleanup failed", + extra={"path": str(path), "error": str(exc)}, + ) + return removed + + async def remove_request(self, request_id: str) -> None: + self._validate_request_id(request_id) + async with self._lock: + self._active.discard(request_id) + for base in (self.settings.temp_dir, self.settings.output_dir): + path = base / request_id + if path.is_dir(): + await asyncio.to_thread(shutil.rmtree, path) + + @staticmethod + def _validate_request_id(request_id: str) -> None: + try: + UUID(request_id) + except (ValueError, AttributeError) as exc: + raise NotFoundError("Output file not found") from exc diff --git a/app/services/downloader.py b/app/services/downloader.py new file mode 100644 index 0000000000000000000000000000000000000000..5f460ce396addcbdef2f25014ea0dd62e8946091 --- /dev/null +++ b/app/services/downloader.py @@ -0,0 +1,123 @@ +from __future__ import annotations + +import asyncio +import ipaddress +import re +import socket +from pathlib import Path +from urllib.parse import unquote, urljoin, urlparse +from uuid import uuid4 + +import aiofiles +import httpx + +from app.core.config import Settings +from app.core.exceptions import DownloadError, InputError +from app.services.validator import MediaValidator + + +class Downloader: + """Size-limited streaming HTTP(S) downloader with SSRF protection.""" + + def __init__(self, settings: Settings, validator: MediaValidator) -> None: + self.settings = settings + self.validator = validator + + async def download( + self, url: str, destination_dir: Path, filename: str | None = None + ) -> tuple[Path, str]: + current_url = url + timeout = httpx.Timeout(self.settings.download_timeout_seconds, connect=30.0) + headers = {"User-Agent": "MediaAPI/1.0"} + async with httpx.AsyncClient(timeout=timeout, headers=headers) as client: + for _ in range(6): + await self.validate_url(current_url) + destination: Path | None = None + try: + async with client.stream( + "GET", current_url, follow_redirects=False + ) as response: + if response.status_code in {301, 302, 303, 307, 308}: + location = response.headers.get("location") + if not location: + raise DownloadError("Remote server returned an invalid redirect") + current_url = urljoin(current_url, location) + continue + response.raise_for_status() + declared = self._content_length(response.headers.get("content-length")) + if declared and declared > self.settings.max_upload_size: + raise InputError("Remote media exceeds MAX_UPLOAD_SIZE") + name = self.validator.safe_filename( + filename or self._response_filename(response, current_url), + "download.bin", + ) + unique_name = f"{uuid4().hex}_{name}" + destination = destination_dir / unique_name + size = 0 + async with aiofiles.open(destination, "wb") as output: + async for chunk in response.aiter_bytes(1024 * 1024): + size += len(chunk) + if size > self.settings.max_upload_size: + await output.close() + destination.unlink(missing_ok=True) + raise InputError("Remote media exceeds MAX_UPLOAD_SIZE") + await output.write(chunk) + mime_type = response.headers.get("content-type", "").split(";", 1)[0] + mime_type = mime_type or self.validator.infer_mime(destination) + self.validator.validate_declared(name, mime_type, size) + return destination, mime_type + except httpx.HTTPStatusError as exc: + if destination is not None: + destination.unlink(missing_ok=True) + raise DownloadError( + "Remote server rejected the download", + details={"status_code": exc.response.status_code}, + ) from exc + except httpx.HTTPError as exc: + if destination is not None: + destination.unlink(missing_ok=True) + raise DownloadError("Unable to download remote media") from exc + except InputError: + if destination is not None: + destination.unlink(missing_ok=True) + raise + except OSError as exc: + if destination is not None: + destination.unlink(missing_ok=True) + raise DownloadError("Unable to store downloaded media") from exc + raise DownloadError("Too many redirects while downloading media") + + async def validate_url(self, value: str) -> None: + parsed = urlparse(value) + if parsed.scheme not in {"http", "https"} or not parsed.hostname: + raise InputError("Only HTTP and HTTPS media URLs are supported") + if parsed.username or parsed.password: + raise InputError("URLs containing credentials are not allowed") + if self.settings.allow_private_urls: + return + try: + records = await asyncio.to_thread( + socket.getaddrinfo, parsed.hostname, parsed.port or 443, type=socket.SOCK_STREAM + ) + except socket.gaierror as exc: + raise DownloadError("The remote hostname could not be resolved") from exc + for record in records: + address = ipaddress.ip_address(record[4][0]) + if not address.is_global: + raise InputError("Private, loopback, and link-local URLs are not allowed") + + @staticmethod + def _response_filename(response: httpx.Response, url: str) -> str: + disposition = response.headers.get("content-disposition", "") + match = re.search(r"filename\*?=(?:UTF-8''|\")?([^\";]+)", disposition, re.I) + if match: + return unquote(match.group(1).strip()) + path_name = Path(unquote(urlparse(url).path)).name + return path_name or "download.bin" + + @staticmethod + def _content_length(value: str | None) -> int | None: + try: + return int(value) if value else None + except ValueError: + return None diff --git a/app/services/ffmpeg_service.py b/app/services/ffmpeg_service.py new file mode 100644 index 0000000000000000000000000000000000000000..f96e374f9c5fe206e816cc1bb27c31ea78319ee6 --- /dev/null +++ b/app/services/ffmpeg_service.py @@ -0,0 +1,150 @@ +from __future__ import annotations + +import asyncio +import logging +import time +from collections.abc import Sequence +from pathlib import Path + +from app.core.config import Settings +from app.core.exceptions import ProcessingError +from app.core.logger import get_logger + +logger = get_logger(__name__) + + +class FFmpegService: + """Concurrency-limited, shell-free FFmpeg process runner.""" + + def __init__(self, settings: Settings) -> None: + self.settings = settings + self._semaphore = asyncio.Semaphore(settings.max_workers) + + async def run( + self, + args: Sequence[str | Path], + *, + operation: str, + timeout: float | None = None, + ) -> None: + command = [self.settings.ffmpeg_binary, "-hide_banner", "-nostdin", "-y"] + [ + str(arg) for arg in args + ] + started = time.monotonic() + logger.info("ffmpeg started", extra={"operation": operation, "command": command}) + async with self._semaphore: + try: + process = await asyncio.create_subprocess_exec( + *command, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + stdout_task = asyncio.create_task(_read_limited(process.stdout, 4_000)) + stderr_task = asyncio.create_task(_read_limited(process.stderr, 16_000)) + try: + await asyncio.wait_for( + process.wait(), + timeout=timeout or self.settings.download_timeout_seconds * 4, + ) + except asyncio.TimeoutError: + process.kill() + await process.wait() + await asyncio.gather(stdout_task, stderr_task) + raise + stdout, stderr = await asyncio.gather(stdout_task, stderr_task) + except asyncio.TimeoutError as exc: + raise ProcessingError("FFmpeg processing timed out") from exc + except FileNotFoundError as exc: + raise ProcessingError("FFmpeg is not installed or not available") from exc + stderr_text = stderr.decode("utf-8", errors="replace")[-16_000:] + stdout_text = stdout.decode("utf-8", errors="replace")[-4_000:] + log_data = { + "operation": operation, + "command": command, + "duration": round(time.monotonic() - started, 4), + "return_code": process.returncode, + "stdout": stdout_text, + "stderr": stderr_text, + } + if process.returncode != 0: + logger.error("ffmpeg failed", extra=log_data) + raise ProcessingError( + "FFmpeg could not process the media", + details={"operation": operation}, + ) + logger.log(logging.INFO, "ffmpeg completed", extra=log_data) + + async def version(self) -> str: + """Return the installed FFmpeg version banner.""" + output = await self._capture(["-version"], operation="ffmpeg.version") + return output.splitlines()[0] if output else "unknown" + + async def codecs(self) -> list[dict[str, str | bool]]: + """Return structured codec capabilities reported by FFmpeg.""" + output = await self._capture(["-hide_banner", "-codecs"], operation="ffmpeg.codecs") + codecs: list[dict[str, str | bool]] = [] + for line in output.splitlines(): + if len(line) < 9 or line[0] != " ": + continue + flags = line[1:7] + if ( + flags[0] not in {"D", "."} + or flags[1] not in {"E", "."} + or flags[2] not in {"V", "A", "S", "D", "."} + ): + continue + remainder = line[8:].strip() + if not remainder or " " not in remainder: + continue + name, description = remainder.split(maxsplit=1) + if name == "=": + continue + codecs.append( + { + "name": name, + "description": description, + "decode": flags[0] == "D", + "encode": flags[1] == "E", + "type": { + "V": "video", + "A": "audio", + "S": "subtitle", + }.get(flags[2], "other"), + } + ) + return codecs + + async def _capture(self, args: Sequence[str], *, operation: str) -> str: + command = [self.settings.ffmpeg_binary, *args] + logger.info( + "ffmpeg information requested", extra={"operation": operation, "command": command} + ) + try: + async with self._semaphore: + process = await asyncio.create_subprocess_exec( + *command, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.STDOUT, + ) + stdout, _ = await asyncio.wait_for(process.communicate(), timeout=30) + except FileNotFoundError as exc: + raise ProcessingError("FFmpeg is not installed or not available") from exc + except asyncio.TimeoutError as exc: + if "process" in locals(): + process.kill() + await process.wait() + raise ProcessingError("FFmpeg information request timed out") from exc + if process.returncode != 0: + raise ProcessingError("FFmpeg information request failed") + return stdout.decode("utf-8", errors="replace")[-2_000_000:] + + +async def _read_limited(stream: asyncio.StreamReader | None, limit: int) -> bytes: + if stream is None: + return b"" + data = bytearray() + while chunk := await stream.read(64 * 1024): + data.extend(chunk) + if len(data) > limit: + del data[:-limit] + return bytes(data) diff --git a/app/services/ffprobe_service.py b/app/services/ffprobe_service.py new file mode 100644 index 0000000000000000000000000000000000000000..329523360d0c9b77d7e3676249ecb5a4599093cf --- /dev/null +++ b/app/services/ffprobe_service.py @@ -0,0 +1,132 @@ +from __future__ import annotations + +import asyncio +import json +from fractions import Fraction +from pathlib import Path +from typing import Any + +from app.core.config import Settings +from app.core.exceptions import ProcessingError +from app.core.logger import get_logger + +logger = get_logger(__name__) + + +class FFprobeService: + def __init__(self, settings: Settings) -> None: + self.settings = settings + self._semaphore = asyncio.Semaphore(settings.max_workers) + + async def probe(self, path: Path) -> dict[str, Any]: + command = [ + self.settings.ffprobe_binary, + "-v", + "error", + "-show_format", + "-show_streams", + "-print_format", + "json", + str(path), + ] + try: + async with self._semaphore: + process = await asyncio.create_subprocess_exec( + *command, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + stdout, stderr = await asyncio.wait_for(process.communicate(), timeout=120) + except FileNotFoundError as exc: + raise ProcessingError("FFprobe is not installed or not available") from exc + except asyncio.TimeoutError as exc: + if "process" in locals(): + process.kill() + await process.wait() + raise ProcessingError("FFprobe timed out") from exc + if process.returncode != 0: + message = stderr.decode("utf-8", errors="replace")[-2_000:] + logger.error("ffprobe failed", extra={"command": command, "stderr": message}) + raise ProcessingError("Unable to read media metadata") + try: + raw = json.loads(stdout) + except json.JSONDecodeError as exc: + raise ProcessingError("FFprobe returned invalid metadata") from exc + return self._normalize(raw) + + @classmethod + def _normalize(cls, raw: dict[str, Any]) -> dict[str, Any]: + streams = raw.get("streams", []) + fmt = raw.get("format", {}) + videos = [stream for stream in streams if stream.get("codec_type") == "video"] + audios = [stream for stream in streams if stream.get("codec_type") == "audio"] + subtitles = [stream for stream in streams if stream.get("codec_type") == "subtitle"] + video = videos[0] if videos else {} + tags = fmt.get("tags", {}) + video_tags = video.get("tags", {}) + rotation = video_tags.get("rotate") + for item in video.get("side_data_list", []): + if "rotation" in item: + rotation = item["rotation"] + duration = cls._float(fmt.get("duration")) + if duration is None: + duration = cls._float(video.get("duration")) + return { + "duration": duration, + "resolution": ( + { + "width": video.get("width"), + "height": video.get("height"), + } + if video + else None + ), + "fps": cls._fps(video.get("avg_frame_rate") or video.get("r_frame_rate")), + "bitrate": cls._int(fmt.get("bit_rate")), + "codec": video.get("codec_name") or (audios[0].get("codec_name") if audios else None), + "video_streams": [cls._stream_summary(stream) for stream in videos], + "audio_streams": [cls._stream_summary(stream) for stream in audios], + "subtitle_streams": [cls._stream_summary(stream) for stream in subtitles], + "rotation": cls._int(rotation), + "container": fmt.get("format_name"), + "creation_date": tags.get("creation_time") or video_tags.get("creation_time"), + "size": cls._int(fmt.get("size")), + "tags": tags, + } + + @classmethod + def _stream_summary(cls, stream: dict[str, Any]) -> dict[str, Any]: + return { + "index": stream.get("index"), + "codec": stream.get("codec_name"), + "profile": stream.get("profile"), + "bitrate": cls._int(stream.get("bit_rate")), + "sample_rate": cls._int(stream.get("sample_rate")), + "channels": stream.get("channels"), + "width": stream.get("width"), + "height": stream.get("height"), + "language": stream.get("tags", {}).get("language"), + } + + @staticmethod + def _fps(value: str | None) -> float | None: + if not value or value == "0/0": + return None + try: + return round(float(Fraction(value)), 4) + except (ValueError, ZeroDivisionError): + return None + + @staticmethod + def _int(value: Any) -> int | None: + try: + return int(value) + except (TypeError, ValueError): + return None + + @staticmethod + def _float(value: Any) -> float | None: + try: + return round(float(value), 6) + except (TypeError, ValueError): + return None diff --git a/app/services/input_resolver.py b/app/services/input_resolver.py new file mode 100644 index 0000000000000000000000000000000000000000..e466cf352b7d80782bd153752d4a2aba0ba3db30 --- /dev/null +++ b/app/services/input_resolver.py @@ -0,0 +1,472 @@ +from __future__ import annotations + +import asyncio +import base64 +import json +import mimetypes +from pathlib import Path +from typing import Any +from uuid import uuid4 + +import aiofiles +from fastapi import Request +from starlette.datastructures import FormData, UploadFile + +from app.core.config import Settings +from app.core.exceptions import InputError +from app.models.media import InputMedia, MediaSource, OperationResult, ResolvedRequest +from app.services.cleanup import CleanupService, RequestWorkspace +from app.services.downloader import Downloader +from app.services.validator import MediaValidator +from app.services.ytdlp_service import YTDLPService + + +class InputResolver: + """Normalizes multipart, JSON, Base64, n8n, URL, and raw-byte inputs.""" + + INPUT_KEYS = { + "url", + "urls", + "base64", + "binary", + "input", + "inputs", + "files", + "file", + "path", + "temp_path", + } + + def __init__( + self, + settings: Settings, + cleanup: CleanupService, + downloader: Downloader, + ytdlp: YTDLPService, + validator: MediaValidator, + ) -> None: + self.settings = settings + self.cleanup = cleanup + self.downloader = downloader + self.ytdlp = ytdlp + self.validator = validator + + async def resolve(self, request: Request, *, require_input: bool = True) -> ResolvedRequest: + request_id = request.state.request_id + workspace = await self.cleanup.create_workspace(request_id) + content_type = request.headers.get("content-type", "").lower() + is_ytdlp_request = request.url.path.startswith("/v1/ytdlp/") + try: + if content_type.startswith("multipart/form-data"): + inputs, params = await self._multipart(request, workspace, is_ytdlp_request) + elif "json" in content_type: + inputs, params = await self._json(request, workspace, is_ytdlp_request) + else: + inputs, params = await self._raw(request, workspace, content_type) + except Exception: + await self.cleanup.complete(request_id) + raise + if require_input and not inputs: + await self.cleanup.complete(request_id) + raise InputError( + "No media input found. Send multipart file(s), raw bytes, url, base64, or n8n binary data." + ) + params = {**dict(request.query_params), **params} + return ResolvedRequest(request_id=request_id, inputs=inputs, params=params) + + async def resolve_payload( + self, + payload: dict[str, Any], + request_id: str, + *, + require_input: bool = True, + ytdlp_options: dict[str, Any] | None = None, + ) -> ResolvedRequest: + """Resolve an in-process JSON payload through the same REST input pipeline. + + MCP and other non-HTTP transports use this method so URL, Base64, n8n + binary, managed temporary paths, validation, and workspace handling stay + centralized in one resolver. + """ + workspace = await self.cleanup.create_workspace(request_id) + try: + descriptors = self._collect_descriptors(payload) + params = self._params_from_payload(payload) + inputs = [ + await self._resolve_descriptor( + descriptor, + workspace, + ytdlp_options=ytdlp_options, + ) + for descriptor in descriptors + ] + except Exception: + await self.cleanup.complete(request_id) + raise + if require_input and not inputs: + await self.cleanup.complete(request_id) + raise InputError( + "No media input found. Provide url, base64, binary, temp_path, or inputs." + ) + return ResolvedRequest(request_id=request_id, inputs=inputs, params=params) + + async def _multipart( + self, + request: Request, + workspace: RequestWorkspace, + is_ytdlp_request: bool, + ) -> tuple[list[InputMedia], dict[str, Any]]: + try: + form: FormData = await request.form( + max_files=100, + max_fields=500, + max_part_size=self.settings.max_upload_size, + ) + except TypeError: + form = await request.form() + except Exception as exc: + raise InputError("Invalid multipart form data") from exc + inputs: list[InputMedia] = [] + fields: dict[str, Any] = {} + for key, value in form.multi_items(): + if isinstance(value, UploadFile): + inputs.append(await self._save_upload(value, workspace)) + continue + if key in fields: + current = fields[key] + fields[key] = current + [value] if isinstance(current, list) else [current, value] + else: + fields[key] = value + descriptors: list[dict[str, Any]] = [] + for key, value in fields.items(): + if key == "url": + descriptors.extend({"url": item} for item in self._as_list(value)) + elif key == "urls": + parsed = self._parse_field(value) + descriptors.extend({"url": item} for item in self._as_list(parsed)) + elif key == "base64" or key.startswith("binary."): + for item in self._as_list(value): + parsed = self._parse_field(item) + descriptors.append(parsed if isinstance(parsed, dict) else {"base64": parsed}) + elif key in {"input", "inputs", "files", "binary"}: + parsed = self._parse_field(value) + descriptors.extend(self._collect_descriptors(parsed, binary=key == "binary")) + params = self._params_from_payload(fields) + ytdlp_options = params if is_ytdlp_request else None + for descriptor in descriptors: + inputs.append( + await self._resolve_descriptor(descriptor, workspace, ytdlp_options=ytdlp_options) + ) + return inputs, params + + async def _json( + self, + request: Request, + workspace: RequestWorkspace, + is_ytdlp_request: bool, + ) -> tuple[list[InputMedia], dict[str, Any]]: + content_length = self._int(request.headers.get("content-length")) + maximum_json = int(self.settings.max_upload_size * 1.5) + 1_048_576 + if content_length and content_length > maximum_json: + raise InputError("JSON request body is too large") + body = await request.body() + if len(body) > maximum_json: + raise InputError("JSON request body is too large") + try: + payload = await asyncio.to_thread(json.loads, body) + except (json.JSONDecodeError, UnicodeDecodeError) as exc: + raise InputError("Request body is not valid JSON") from exc + if not isinstance(payload, dict): + raise InputError("JSON request body must be an object") + descriptors = self._collect_descriptors(payload) + params = self._params_from_payload(payload) + ytdlp_options = params if is_ytdlp_request else None + inputs = [ + await self._resolve_descriptor(descriptor, workspace, ytdlp_options=ytdlp_options) + for descriptor in descriptors + ] + return inputs, params + + async def _raw( + self, request: Request, workspace: RequestWorkspace, content_type: str + ) -> tuple[list[InputMedia], dict[str, Any]]: + declared = self._int(request.headers.get("content-length")) + if declared and declared > self.settings.max_upload_size: + raise InputError("The request body exceeds MAX_UPLOAD_SIZE") + filename = self.validator.safe_filename( + request.headers.get("x-filename") or request.query_params.get("filename"), + "input.bin", + ) + path = workspace.uploads / f"{uuid4().hex}_{filename}" + size = 0 + async with aiofiles.open(path, "wb") as output: + async for chunk in request.stream(): + size += len(chunk) + if size > self.settings.max_upload_size: + await output.close() + path.unlink(missing_ok=True) + raise InputError("The request body exceeds MAX_UPLOAD_SIZE") + await output.write(chunk) + if size == 0: + path.unlink(missing_ok=True) + return [], dict(request.query_params) + mime_type = content_type.split(";", 1)[0] or self.validator.infer_mime(path) + self.validator.validate_declared(filename, mime_type, size) + return [ + InputMedia( + source=MediaSource.OCTET_STREAM, + filename=filename, + mime_type=mime_type, + temp_path=path, + size=size, + ) + ], dict(request.query_params) + + async def _save_upload(self, upload: UploadFile, workspace: RequestWorkspace) -> InputMedia: + filename = self.validator.safe_filename(upload.filename, "upload.bin") + path = workspace.uploads / f"{uuid4().hex}_{filename}" + size = 0 + async with aiofiles.open(path, "wb") as output: + while chunk := await upload.read(1024 * 1024): + size += len(chunk) + if size > self.settings.max_upload_size: + await output.close() + path.unlink(missing_ok=True) + raise InputError("The uploaded media exceeds MAX_UPLOAD_SIZE") + await output.write(chunk) + await upload.close() + mime_type = upload.content_type or self.validator.infer_mime(path) + self.validator.validate_declared(filename, mime_type, size) + return InputMedia( + source=MediaSource.MULTIPART, + filename=filename, + mime_type=mime_type, + temp_path=path, + size=size, + ) + + async def _resolve_descriptor( + self, + descriptor: dict[str, Any], + workspace: RequestWorkspace, + *, + ytdlp_options: dict[str, Any] | None = None, + ) -> InputMedia: + if "temp_path" in descriptor or "path" in descriptor: + return await self._resolve_local_path(descriptor, workspace) + if "url" in descriptor: + url = descriptor["url"] + if not isinstance(url, str) or not url.strip(): + raise InputError("Media URL must be a non-empty string") + await self.downloader.validate_url(url) + if ytdlp_options is not None or await asyncio.to_thread(self.ytdlp.supports_url, url): + options = ytdlp_options or {} + mode = str(options.get("mode", "video")) + if options.get("playlist") or options.get("include_formats"): + max_entries = self._int(options.get("max_entries", 100)) + if max_entries is None: + raise InputError("max_entries must be an integer") + metadata = await self.ytdlp.extract_metadata( + url, + playlist=bool(options.get("playlist")), + include_formats=bool(options.get("include_formats")), + max_entries=max_entries, + ) + result = OperationResult(metadata=metadata) + mode = "metadata" + else: + result = await self.ytdlp.download( + url, + workspace.uploads, + mode=mode, + format_selector=options.get("format"), + audio_format=str(options.get("audio_format", "mp3")), + ) + if result.path is None: + if mode != "metadata": + raise InputError("yt-dlp did not return a media file") + result.path = workspace.uploads / "metadata.json" + encoded = json.dumps( + result.metadata, ensure_ascii=False, separators=(",", ":") + ).encode("utf-8") + async with aiofiles.open(result.path, "wb") as output: + await output.write(encoded) + result.filename = result.path.name + result.mime_type = "application/json" + return InputMedia( + source=MediaSource.YTDLP, + filename=result.filename or result.path.name, + mime_type=result.mime_type or self.validator.infer_mime(result.path), + temp_path=result.path, + size=result.path.stat().st_size, + duration=result.metadata.get("duration"), + metadata=result.metadata, + ) + path, mime_type = await self.downloader.download( + url, workspace.uploads, descriptor.get("filename") + ) + return InputMedia( + source=MediaSource.JSON_URL, + filename=self._original_name(path), + mime_type=mime_type, + temp_path=path, + size=path.stat().st_size, + metadata={"url": url}, + ) + data = descriptor.get("base64", descriptor.get("data")) + if not isinstance(data, str) or not data: + raise InputError("Input descriptor must contain a url or Base64 data") + return await self._decode_base64(data, descriptor, workspace) + + async def _resolve_local_path( + self, descriptor: dict[str, Any], workspace: RequestWorkspace + ) -> InputMedia: + raw_path = descriptor.get("temp_path", descriptor.get("path")) + if not isinstance(raw_path, str) or not raw_path.strip(): + raise InputError("temp_path must be a non-empty string") + candidate = Path(raw_path).expanduser().resolve() + allowed_roots = (self.settings.temp_dir.resolve(), self.settings.output_dir.resolve()) + if not any(candidate == root or root in candidate.parents for root in allowed_roots): + raise InputError("temp_path must be inside TEMP_DIR or OUTPUT_DIR") + if not candidate.is_file(): + raise InputError("The managed temporary file does not exist") + filename = self.validator.safe_filename(descriptor.get("filename"), candidate.name) + mime_type = str( + descriptor.get("mime_type") + or descriptor.get("mimeType") + or self.validator.infer_mime(candidate) + ) + size = candidate.stat().st_size + self.validator.validate_declared(filename, mime_type, size) + destination = workspace.uploads / f"{uuid4().hex}_{filename}" + async with ( + aiofiles.open(candidate, "rb") as source, + aiofiles.open(destination, "wb") as output, + ): + while chunk := await source.read(1024 * 1024): + await output.write(chunk) + return InputMedia( + source=MediaSource.LOCAL_PATH, + filename=filename, + mime_type=mime_type, + temp_path=destination, + size=size, + metadata={"managed_source": str(candidate)}, + ) + + async def _decode_base64( + self, value: str, descriptor: dict[str, Any], workspace: RequestWorkspace + ) -> InputMedia: + mime_type = str( + descriptor.get("mime_type") or descriptor.get("mimeType") or "application/octet-stream" + ) + if value.startswith("data:"): + try: + header, value = value.split(",", 1) + except ValueError as exc: + raise InputError("Invalid Base64 data URI") from exc + if ";base64" not in header: + raise InputError("Only Base64 data URIs are supported") + mime_type = header[5:].split(";", 1)[0] or mime_type + compact = "".join(value.split()) + estimated_size = len(compact) * 3 // 4 + if estimated_size > self.settings.max_upload_size: + raise InputError("Decoded Base64 media exceeds MAX_UPLOAD_SIZE") + try: + decoded = await asyncio.to_thread(base64.b64decode, compact, validate=True) + except ValueError as exc: + raise InputError("Media contains invalid Base64 data") from exc + extension = mimetypes.guess_extension(mime_type) or ".bin" + supplied_name = descriptor.get("filename") or descriptor.get("fileName") + filename = self.validator.safe_filename(supplied_name, f"decoded{extension}") + self.validator.validate_declared(filename, mime_type, len(decoded)) + path = workspace.uploads / f"{uuid4().hex}_{filename}" + async with aiofiles.open(path, "wb") as output: + await output.write(decoded) + source = MediaSource.N8N_BINARY if "data" in descriptor else MediaSource.JSON_BASE64 + return InputMedia( + source=source, + filename=filename, + mime_type=mime_type, + temp_path=path, + size=len(decoded), + ) + + def _collect_descriptors(self, value: Any, *, binary: bool = False) -> list[dict[str, Any]]: + if isinstance(value, list): + result: list[dict[str, Any]] = [] + for item in value: + result.extend(self._collect_descriptors(item, binary=binary)) + return result + if not isinstance(value, dict): + if binary and isinstance(value, str): + return [{"data": value}] + return [] + if any(key in value for key in ("url", "base64", "temp_path", "path")): + return [value] + if binary and "data" in value and isinstance(value["data"], str): + return [value] + result = [] + if not binary: + if "url" in value: + result.append({"url": value["url"], "filename": value.get("filename")}) + if "base64" in value: + result.append(value) + for key in ("input", "inputs", "files", "urls"): + if key in value: + items = value[key] + if key == "urls" and isinstance(items, list): + result.extend({"url": item} for item in items if isinstance(item, str)) + else: + result.extend(self._collect_descriptors(items)) + if "binary" in value: + result.extend(self._collect_descriptors(value["binary"], binary=True)) + for key, item in value.items(): + if key.startswith("binary."): + result.extend(self._collect_descriptors(item, binary=True)) + else: + for item in value.values(): + result.extend(self._collect_descriptors(item, binary=True)) + return result + + def _params_from_payload(self, payload: dict[str, Any]) -> dict[str, Any]: + params: dict[str, Any] = {} + options = payload.get("options") + if isinstance(options, str): + options = self._parse_field(options) + if isinstance(options, dict): + params.update(options) + for key, value in payload.items(): + if key not in self.INPUT_KEYS and not key.startswith("binary.") and key != "options": + params[key] = self._parse_field(value) + return params + + @staticmethod + def _parse_field(value: Any) -> Any: + if isinstance(value, list): + return [InputResolver._parse_field(item) for item in value] + if not isinstance(value, str): + return value + stripped = value.strip() + if stripped.startswith(("{", "[")) or stripped in {"true", "false", "null"}: + try: + return json.loads(stripped) + except json.JSONDecodeError: + return value + return value + + @staticmethod + def _as_list(value: Any) -> list[Any]: + return value if isinstance(value, list) else [value] + + @staticmethod + def _int(value: Any) -> int | None: + try: + return int(value) + except (TypeError, ValueError): + return None + + @staticmethod + def _original_name(path: Path) -> str: + parts = path.name.split("_", 1) + return parts[1] if len(parts) == 2 else path.name diff --git a/app/services/media_service.py b/app/services/media_service.py new file mode 100644 index 0000000000000000000000000000000000000000..bc89e2559986da9362e3b51758908dcad625c98e --- /dev/null +++ b/app/services/media_service.py @@ -0,0 +1,222 @@ +from __future__ import annotations + +import os +import time +from collections.abc import Awaitable, Callable, Sequence +from pathlib import Path +from typing import Any + +from app.core.config import Settings +from app.core.logger import get_logger +from app.core.response import SuccessResponse +from app.models.media import InputMedia, OperationResult, ResolvedRequest +from app.services.cleanup import CleanupService +from app.services.ffmpeg_service import FFmpegService +from app.services.ffprobe_service import FFprobeService +from app.services.input_resolver import InputResolver +from app.services.validator import MediaValidator +from app.services.whisper_service import WhisperService +from app.services.ytdlp_service import YTDLPService + +logger = get_logger(__name__) +Operation = Callable[ + [FFmpegService, Sequence[InputMedia], dict[str, Any], Path], Awaitable[OperationResult] +] + + +class MediaProcessor: + def __init__( + self, + settings: Settings, + resolver: InputResolver, + cleanup: CleanupService, + validator: MediaValidator, + ffmpeg: FFmpegService, + ffprobe: FFprobeService, + ytdlp: YTDLPService, + whisper: WhisperService, + ) -> None: + self.settings = settings + self.resolver = resolver + self.cleanup = cleanup + self.validator = validator + self.ffmpeg = ffmpeg + self.ffprobe = ffprobe + self.ytdlp = ytdlp + self.whisper = whisper + + async def run( + self, resolved: ResolvedRequest, operation_name: str, operation: Operation + ) -> SuccessResponse: + started = time.monotonic() + workspace = await self.cleanup.create_workspace(resolved.request_id) + probe_metadata = await self.probe_inputs(resolved.inputs) + result = await operation(self.ffmpeg, resolved.inputs, resolved.params, workspace.outputs) + return await self.finish_result(resolved, operation_name, result, started, probe_metadata) + + async def run_whisper(self, resolved: ResolvedRequest) -> SuccessResponse: + started = time.monotonic() + workspace = await self.cleanup.create_workspace(resolved.request_id) + probe_metadata = await self.probe_inputs(resolved.inputs) + result = await self.transcribe_result(resolved.inputs, resolved.params, workspace.outputs) + return await self.finish_result( + resolved, "whisper.transcribe", result, started, probe_metadata + ) + + async def transcribe_result( + self, + inputs: Sequence[InputMedia], + params: dict[str, Any], + output_dir: Path, + ) -> OperationResult: + """Run shared Whisper validation and return an unpublished result.""" + if not inputs: + from app.core.exceptions import InputError + + raise InputError("Whisper requires at least one media input") + try: + beam_size = int(params.get("beam_size", 5)) + except (TypeError, ValueError) as exc: + from app.core.exceptions import InputError + + raise InputError("beam_size must be an integer") from exc + if not 1 <= beam_size <= 20: + from app.core.exceptions import InputError + + raise InputError("beam_size must be between 1 and 20") + return await self.whisper.transcribe( + inputs[0].temp_path, + output_dir, + model_name=params.get("model"), + task=str(params.get("task", "transcribe")), + language=params.get("language"), + output_format=str(params.get("output_format", "json")), + beam_size=beam_size, + vad_filter=self._bool(params.get("vad_filter"), True), + ) + + async def run_ytdlp(self, resolved: ResolvedRequest) -> SuccessResponse: + started = time.monotonic() + params = resolved.params + mode = str(params.get("mode", "video")) + if mode == "metadata": + result = OperationResult(metadata=resolved.primary.metadata) + else: + result = OperationResult( + path=resolved.primary.temp_path, + filename=resolved.primary.filename, + mime_type=resolved.primary.mime_type, + metadata=resolved.primary.metadata, + ) + return await self.finish_result(resolved, "ytdlp.download", result, started, {}) + + async def probe(self, resolved: ResolvedRequest) -> SuccessResponse: + started = time.monotonic() + metadata = await self.probe_inputs(resolved.inputs) + await self.cleanup.complete(resolved.request_id) + return SuccessResponse( + request_id=resolved.request_id, + processing_time=round(time.monotonic() - started, 4), + metadata={"inputs": metadata}, + ) + + async def probe_inputs(self, inputs: Sequence[InputMedia]) -> list[dict[str, Any]]: + """Probe and validate normalized inputs for all transport layers.""" + metadata: list[dict[str, Any]] = [] + for media in inputs: + if ( + media.mime_type.startswith("text/") + or media.mime_type == "application/json" + or media.filename.lower().endswith((".srt", ".vtt", ".ass", ".ssa", ".json")) + ): + item = { + "filename": media.filename, + "size": media.size, + "mime_type": media.mime_type, + } + else: + item = await self.ffprobe.probe(media.temp_path) + self.validator.validate_probe(item) + media.duration = item.get("duration") + media.metadata = {**media.metadata, **item} + metadata.append( + { + "filename": media.filename, + "size": media.size, + "mime_type": media.mime_type, + **item, + } + ) + return metadata + + async def finish_result( + self, + resolved: ResolvedRequest, + operation_name: str, + result: OperationResult, + started: float, + probe_metadata: list[dict[str, Any]], + extra_metadata: dict[str, Any] | None = None, + ) -> SuccessResponse: + """Publish a result and build the shared structured success response.""" + download_url: str | None = None + output_size = 0 + if result.path is not None: + published = await self.cleanup.publish( + resolved.request_id, result.path, result.filename or result.path.name + ) + output_size = published.stat().st_size + base_url = self.settings.base_url.rstrip("/") + download_url = f"{base_url}/v1/media/{resolved.request_id}/{published.name}" + elapsed = round(time.monotonic() - started, 4) + metadata = { + "operation": operation_name, + **result.metadata, + "inputs": probe_metadata, + "output_size": output_size, + **(extra_metadata or {}), + } + logger.info( + "media operation completed", + extra={ + "operation": operation_name, + "input_size": sum(item.size for item in resolved.inputs), + "output_size": output_size, + "duration": elapsed, + "cpu_percent": self._cpu_percent(), + "memory_bytes": self._memory_bytes(), + }, + ) + await self.cleanup.complete(resolved.request_id) + return SuccessResponse( + request_id=resolved.request_id, + processing_time=elapsed, + download_url=download_url, + metadata=metadata, + ) + + @staticmethod + def _bool(value: Any, default: bool) -> bool: + if value is None: + return default + if isinstance(value, bool): + return value + return str(value).lower() in {"1", "true", "yes", "on"} + + @staticmethod + def _cpu_percent() -> float | None: + try: + import psutil + + return psutil.cpu_percent(interval=None) + except ImportError: + return None + + @staticmethod + def _memory_bytes() -> int | None: + try: + import psutil + + return psutil.Process(os.getpid()).memory_info().rss + except ImportError: + return None diff --git a/app/services/validator.py b/app/services/validator.py new file mode 100644 index 0000000000000000000000000000000000000000..576a361ab9f9147f91ebf602c22874e3039b52e2 --- /dev/null +++ b/app/services/validator.py @@ -0,0 +1,143 @@ +from __future__ import annotations + +import mimetypes +import re +from pathlib import Path +from typing import Any + +from app.core.config import Settings +from app.core.exceptions import InputError + +MEDIA_EXTENSIONS = { + ".3gp", + ".aac", + ".aiff", + ".apng", + ".avi", + ".bin", + ".bmp", + ".flac", + ".gif", + ".heic", + ".jpeg", + ".jpg", + ".m4a", + ".m4v", + ".mkv", + ".m3u", + ".m3u8", + ".mov", + ".mp3", + ".mp4", + ".mpeg", + ".mpg", + ".oga", + ".ogg", + ".opus", + ".png", + ".svg", + ".tif", + ".tiff", + ".ts", + ".vtt", + ".wav", + ".webm", + ".webp", + ".wmv", + ".srt", + ".ass", + ".ssa", +} + + +class MediaValidator: + def __init__(self, settings: Settings) -> None: + self.settings = settings + + def safe_filename(self, filename: str | None, fallback: str = "input.bin") -> str: + name = Path(filename or fallback).name.replace("\x00", "") + if not name or name in {".", ".."}: + raise InputError("Invalid filename") + return name[:240] + + def validate_declared(self, filename: str, mime_type: str, size: int) -> None: + if size <= 0: + raise InputError("The uploaded media is empty") + if size > self.settings.max_upload_size: + raise InputError( + "The media exceeds MAX_UPLOAD_SIZE", + details={"size": size, "maximum": self.settings.max_upload_size}, + ) + suffix = Path(filename).suffix.lower() + if suffix and suffix not in MEDIA_EXTENSIONS: + raise InputError("Unsupported media file extension", details={"extension": suffix}) + normalized_mime = mime_type.split(";", 1)[0].lower() + allowed = normalized_mime.startswith( + ("audio/", "video/", "image/", "text/") + ) or normalized_mime in { + "application/octet-stream", + "application/x-subrip", + "application/vnd.apple.mpegurl", + "application/x-mpegurl", + } + if normalized_mime and not allowed: + raise InputError("Unsupported media MIME type", details={"mime_type": mime_type}) + + def validate_probe(self, metadata: dict[str, Any]) -> None: + streams = [ + *(metadata.get("video_streams") or []), + *(metadata.get("audio_streams") or []), + ] + if not streams: + raise InputError("The input has no decodable video or audio stream") + for stream in streams: + codec = stream.get("codec") + if not isinstance(codec, str) or not re.fullmatch(r"[a-zA-Z0-9_.-]{1,64}", codec): + raise InputError("The input contains an invalid or unsupported codec") + duration = metadata.get("duration") + if duration and float(duration) > self.settings.max_duration_seconds: + raise InputError( + "Media duration exceeds the configured limit", + details={"duration": duration, "maximum": self.settings.max_duration_seconds}, + ) + resolution = metadata.get("resolution") or {} + width, height = resolution.get("width", 0), resolution.get("height", 0) + if width and height and width * height > self.settings.max_resolution_pixels: + raise InputError( + "Media resolution exceeds the configured limit", + details={"width": width, "height": height}, + ) + + @staticmethod + def infer_mime(path: Path, fallback: str = "application/octet-stream") -> str: + return mimetypes.guess_type(path.name)[0] or fallback + + +def as_bool(value: Any, default: bool = False) -> bool: + if value is None: + return default + if isinstance(value, bool): + return value + return str(value).strip().lower() in {"1", "true", "yes", "on"} + + +def bounded_number( + params: dict[str, Any], name: str, default: float, minimum: float, maximum: float +) -> float: + try: + value = float(params.get(name, default)) + except (TypeError, ValueError) as exc: + raise InputError(f"'{name}' must be a number") from exc + if not minimum <= value <= maximum: + raise InputError(f"'{name}' must be between {minimum} and {maximum}") + return value + + +def positive_int(params: dict[str, Any], name: str, default: int) -> int: + try: + value = int(params.get(name, default)) + except (TypeError, ValueError) as exc: + raise InputError(f"'{name}' must be an integer") from exc + if value <= 0: + raise InputError(f"'{name}' must be greater than zero") + return value diff --git a/app/services/whisper_service.py b/app/services/whisper_service.py new file mode 100644 index 0000000000000000000000000000000000000000..beb94f9146063182fe892eef88cb360df981f62a --- /dev/null +++ b/app/services/whisper_service.py @@ -0,0 +1,200 @@ +from __future__ import annotations + +import asyncio +from pathlib import Path +from typing import Any + +import aiofiles + +from app.core.config import Settings +from app.core.exceptions import InputError, ProcessingError +from app.core.logger import get_logger +from app.models.media import OperationResult + +logger = get_logger(__name__) + + +class WhisperService: + """Lazy-loading CPU/int8 faster-whisper transcription service.""" + + ALLOWED_MODELS = {"tiny", "base", "small", "medium", "large-v3"} + ALLOWED_FORMATS = {"txt", "srt", "vtt", "json", "tsv"} + + def __init__(self, settings: Settings) -> None: + self.settings = settings + self._models: dict[str, Any] = {} + self._model_lock = asyncio.Lock() + self._semaphore = asyncio.Semaphore(settings.max_workers) + + async def transcribe( + self, + input_path: Path, + output_dir: Path, + *, + model_name: str | None = None, + task: str = "transcribe", + language: str | None = None, + output_format: str = "json", + beam_size: int = 5, + vad_filter: bool = True, + ) -> OperationResult: + selected_model = model_name or self.settings.whisper_model + if selected_model not in self.ALLOWED_MODELS: + raise InputError("Unsupported Whisper model") + if output_format not in self.ALLOWED_FORMATS: + raise InputError("Whisper output format must be txt, srt, vtt, json, or tsv") + if task not in {"transcribe", "translate"}: + raise InputError("Whisper task must be transcribe or translate") + model = await self._get_model(selected_model) + try: + async with self._semaphore: + segments, info = await asyncio.to_thread( + self._transcribe_sync, + model, + input_path, + task, + language, + beam_size, + vad_filter, + ) + except Exception as exc: + logger.exception("whisper transcription failed") + raise ProcessingError("Whisper could not transcribe the media") from exc + output_dir.mkdir(parents=True, exist_ok=True) + output_path = output_dir / f"transcript.{output_format}" + await self._write_output(output_path, output_format, segments, info) + text = " ".join(item["text"].strip() for item in segments).strip() + metadata = { + "language": info["language"], + "language_probability": info["language_probability"], + "duration": info["duration"], + "task": task, + "model": selected_model, + "text": text, + "segments": segments if output_format == "json" else len(segments), + } + return OperationResult( + path=output_path, + filename=output_path.name, + mime_type=self._mime(output_format), + metadata=metadata, + ) + + async def _get_model(self, model_name: str) -> Any: + if model_name in self._models: + return self._models[model_name] + async with self._model_lock: + if model_name not in self._models: + try: + from faster_whisper import WhisperModel + + self._models[model_name] = await asyncio.to_thread( + WhisperModel, + model_name, + device="cpu", + compute_type="int8", + cpu_threads=max(1, self.settings.max_workers), + num_workers=1, + ) + except Exception as exc: + raise ProcessingError("Unable to load the Whisper model") from exc + logger.info( + "whisper model loaded", extra={"model": model_name, "compute_type": "int8"} + ) + return self._models[model_name] + + @staticmethod + def _transcribe_sync( + model: Any, + input_path: Path, + task: str, + language: str | None, + beam_size: int, + vad_filter: bool, + ) -> tuple[list[dict[str, Any]], dict[str, Any]]: + iterator, info = model.transcribe( + str(input_path), + task=task, + language=language, + beam_size=beam_size, + vad_filter=vad_filter, + ) + segments = [ + { + "id": segment.id, + "start": round(segment.start, 3), + "end": round(segment.end, 3), + "text": segment.text, + } + for segment in iterator + ] + return segments, { + "language": info.language, + "language_probability": round(info.language_probability, 6), + "duration": round(info.duration, 3), + } + + async def _write_output( + self, + path: Path, + output_format: str, + segments: list[dict[str, Any]], + info: dict[str, Any], + ) -> None: + if output_format == "json": + import json + + content = json.dumps( + { + "text": " ".join(s["text"].strip() for s in segments), + "segments": segments, + **info, + }, + ensure_ascii=False, + separators=(",", ":"), + ).encode("utf-8") + async with aiofiles.open(path, "wb") as output: + await output.write(content) + return + if output_format == "txt": + content = "\n".join(item["text"].strip() for item in segments) + "\n" + elif output_format == "tsv": + rows = ["start\tend\ttext"] + rows.extend( + f"{int(item['start'] * 1000)}\t{int(item['end'] * 1000)}\t{item['text'].strip()}" + for item in segments + ) + content = "\n".join(rows) + "\n" + elif output_format == "srt": + blocks = [ + f"{index}\n{self._timestamp(item['start'], ',')} --> {self._timestamp(item['end'], ',')}\n{item['text'].strip()}" + for index, item in enumerate(segments, 1) + ] + content = "\n\n".join(blocks) + "\n" + else: + blocks = ["WEBVTT", ""] + blocks.extend( + f"{self._timestamp(item['start'], '.')} --> {self._timestamp(item['end'], '.')}\n{item['text'].strip()}\n" + for item in segments + ) + content = "\n".join(blocks) + async with aiofiles.open(path, "w", encoding="utf-8") as output: + await output.write(content) + + @staticmethod + def _timestamp(seconds: float, separator: str) -> str: + milliseconds = round(seconds * 1000) + hours, remainder = divmod(milliseconds, 3_600_000) + minutes, remainder = divmod(remainder, 60_000) + secs, millis = divmod(remainder, 1_000) + return f"{hours:02d}:{minutes:02d}:{secs:02d}{separator}{millis:03d}" + + @staticmethod + def _mime(output_format: str) -> str: + return { + "json": "application/json", + "srt": "application/x-subrip", + "vtt": "text/vtt", + "tsv": "text/tab-separated-values", + "txt": "text/plain", + }[output_format] diff --git a/app/services/ytdlp_service.py b/app/services/ytdlp_service.py new file mode 100644 index 0000000000000000000000000000000000000000..b38ff8e7bffb610fd955663720c47f62f4ee7b44 --- /dev/null +++ b/app/services/ytdlp_service.py @@ -0,0 +1,233 @@ +from __future__ import annotations + +import asyncio +from functools import lru_cache +from pathlib import Path +from typing import Any + +from app.core.config import Settings +from app.core.exceptions import DownloadError, InputError +from app.core.logger import get_logger +from app.models.media import OperationResult +from app.services.validator import MediaValidator + +logger = get_logger(__name__) + + +class YTDLPService: + def __init__(self, settings: Settings, validator: MediaValidator) -> None: + self.settings = settings + self.validator = validator + self._semaphore = asyncio.Semaphore(settings.max_workers) + + @staticmethod + @lru_cache(maxsize=512) + def supports_url(url: str) -> bool: + try: + from yt_dlp.extractor import gen_extractors + + return any( + extractor.IE_NAME != "generic" and extractor.suitable(url) + for extractor in gen_extractors() + ) + except Exception: + return False + + async def download( + self, + url: str, + destination_dir: Path, + *, + mode: str = "video", + format_selector: str | None = None, + audio_format: str = "mp3", + ) -> OperationResult: + if mode not in {"video", "audio", "thumbnail", "metadata"}: + raise InputError("yt-dlp mode must be video, audio, thumbnail, or metadata") + if audio_format not in {"mp3", "m4a", "wav", "opus", "flac"}: + raise InputError("Unsupported yt-dlp audio format") + destination_dir.mkdir(parents=True, exist_ok=True) + try: + async with self._semaphore: + return await asyncio.to_thread( + self._download_sync, + url, + destination_dir, + mode, + format_selector, + audio_format, + ) + except InputError: + raise + except Exception as exc: + logger.error("yt-dlp failed", extra={"url": url, "mode": mode, "error": str(exc)}) + raise DownloadError("yt-dlp could not retrieve the media") from exc + + async def extract_metadata( + self, + url: str, + *, + playlist: bool = False, + include_formats: bool = False, + max_entries: int = 100, + ) -> dict[str, Any]: + """Extract bounded yt-dlp metadata without downloading media bytes.""" + if not 1 <= max_entries <= 1_000: + raise InputError("max_entries must be between 1 and 1000") + try: + async with self._semaphore: + return await asyncio.to_thread( + self._extract_metadata_sync, + url, + playlist, + include_formats, + max_entries, + ) + except InputError: + raise + except Exception as exc: + logger.error( + "yt-dlp metadata extraction failed", + extra={"url": url, "playlist": playlist, "error": str(exc)}, + ) + raise DownloadError("yt-dlp could not retrieve metadata") from exc + + def _download_sync( + self, + url: str, + destination_dir: Path, + mode: str, + format_selector: str | None, + audio_format: str, + ) -> OperationResult: + import yt_dlp + + before = {path.resolve() for path in destination_dir.iterdir() if path.is_file()} + + def progress(data: dict[str, Any]) -> None: + downloaded = data.get("downloaded_bytes") or 0 + if downloaded > self.settings.max_upload_size: + raise InputError("Downloaded media exceeds MAX_UPLOAD_SIZE") + + options: dict[str, Any] = { + "outtmpl": str(destination_dir / "%(title).120B-%(id)s.%(ext)s"), + "restrictfilenames": True, + "noplaylist": True, + "quiet": True, + "no_warnings": True, + "max_filesize": self.settings.max_upload_size, + "progress_hooks": [progress], + } + if mode == "video": + options.update( + { + "format": format_selector or "bv*+ba/b", + "merge_output_format": "mp4", + } + ) + elif mode == "audio": + options.update( + { + "format": format_selector or "bestaudio/best", + "postprocessors": [ + { + "key": "FFmpegExtractAudio", + "preferredcodec": audio_format, + "preferredquality": "192", + } + ], + } + ) + elif mode == "thumbnail": + options.update({"skip_download": True, "writethumbnail": True}) + else: + options.update({"skip_download": True}) + with yt_dlp.YoutubeDL(options) as ydl: + info = ydl.extract_info(url, download=mode != "metadata") + metadata = self._metadata(info) + if mode == "metadata": + return OperationResult(metadata=metadata) + candidates = [ + path + for path in destination_dir.iterdir() + if path.is_file() and path.resolve() not in before and not path.name.endswith(".part") + ] + if not candidates: + raise DownloadError("yt-dlp completed without creating an output file") + path = max(candidates, key=lambda item: item.stat().st_mtime) + if path.stat().st_size > self.settings.max_upload_size: + path.unlink(missing_ok=True) + raise InputError("Downloaded media exceeds MAX_UPLOAD_SIZE") + return OperationResult( + path=path, + filename=path.name, + mime_type=self.validator.infer_mime(path), + metadata=metadata, + ) + + def _extract_metadata_sync( + self, + url: str, + playlist: bool, + include_formats: bool, + max_entries: int, + ) -> dict[str, Any]: + import yt_dlp + + options: dict[str, Any] = { + "quiet": True, + "no_warnings": True, + "skip_download": True, + "noplaylist": not playlist, + } + if playlist: + options.update({"extract_flat": "in_playlist", "playlistend": max_entries}) + with yt_dlp.YoutubeDL(options) as ydl: + info = ydl.extract_info(url, download=False) + metadata = self._metadata(info) + if playlist: + entries = info.get("entries") or [] + metadata["entries"] = [self._metadata(entry) for entry in entries if entry] + metadata["entry_count"] = len(metadata["entries"]) + if include_formats: + metadata["formats"] = [ + self._format_summary(item) for item in (info.get("formats") or []) + ] + return metadata + + @staticmethod + def _metadata(info: dict[str, Any]) -> dict[str, Any]: + return { + "id": info.get("id"), + "title": info.get("title"), + "description": info.get("description"), + "uploader": info.get("uploader"), + "channel": info.get("channel"), + "duration": info.get("duration"), + "timestamp": info.get("timestamp"), + "webpage_url": info.get("webpage_url"), + "extractor": info.get("extractor"), + "thumbnail": info.get("thumbnail"), + "view_count": info.get("view_count"), + "like_count": info.get("like_count"), + "format": info.get("format"), + "ext": info.get("ext"), + } + + @staticmethod + def _format_summary(info: dict[str, Any]) -> dict[str, Any]: + return { + "format_id": info.get("format_id"), + "format": info.get("format"), + "ext": info.get("ext"), + "width": info.get("width"), + "height": info.get("height"), + "resolution": info.get("resolution"), + "fps": info.get("fps"), + "vcodec": info.get("vcodec"), + "acodec": info.get("acodec"), + "audio_channels": info.get("audio_channels"), + "tbr": info.get("tbr"), + "filesize": info.get("filesize") or info.get("filesize_approx"), + "protocol": info.get("protocol"), + } diff --git a/app/templates/__init__.py b/app/templates/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..0e9fdc2671eb81058163b949f71c129398a1e63d --- /dev/null +++ b/app/templates/__init__.py @@ -0,0 +1,8 @@ +"""Versioned YAML workflow templates built on the shared media operation layer.""" + +from app.templates.executor import TemplateExecutor +from app.templates.loader import TemplateLoader +from app.templates.registry import TemplateRegistry +from app.templates.validator import TemplateValidator + +__all__ = ["TemplateExecutor", "TemplateLoader", "TemplateRegistry", "TemplateValidator"] diff --git a/app/templates/categories/branding/templates.yaml b/app/templates/categories/branding/templates.yaml new file mode 100644 index 0000000000000000000000000000000000000000..891a1bb6557fd2a3e34410dac4d7d75cc63844e1 --- /dev/null +++ b/app/templates/categories/branding/templates.yaml @@ -0,0 +1,147 @@ +templates: + - id: company_branding + name: Company Branding + category: branding + description: Resize a company video, apply a supplied logo watermark, normalize audio, and encode MP4. + author: Enterprise Media API + version: 1 + tags: [company, branding, logo, watermark] + estimated_runtime: slow + supported_inputs: [video, image, url] + supported_outputs: [mp4] + parameters: + position: + type: string + default: bottom-right + enum: [top-left, top-right, bottom-left, bottom-right, center] + opacity: {type: number, default: 0.85, minimum: 0, maximum: 1} + logo_scale: {type: number, default: 0.2, minimum: 0.01, maximum: 10} + pipeline: + - {operation: resize, width: 1920, height: 1080, fit: contain} + - operation: watermark_video + inputs: [current, original:1] + position: "{{ position }}" + opacity: "{{ opacity }}" + watermark_scale: "{{ logo_scale }}" + - {operation: normalize_video} + - {operation: compress, crf: 22, preset: medium, format: mp4} + output: {format: mp4} + examples: + - inputs: + - {url: "https://example.com/company-video.mp4"} + - {url: "https://example.com/company-logo.png"} + parameters: {position: bottom-right, opacity: 0.85} + + - id: creator_branding + name: Creator Branding + category: branding + description: Apply a supplied creator logo to a vertical social video and produce a compact MP4. + author: Enterprise Media API + version: 1 + tags: [creator, branding, logo, vertical] + estimated_runtime: medium + supported_inputs: [video, image, url] + supported_outputs: [mp4] + parameters: + position: + type: string + default: top-right + enum: [top-left, top-right, bottom-left, bottom-right, center] + opacity: {type: number, default: 0.8, minimum: 0, maximum: 1} + logo_scale: {type: number, default: 0.15, minimum: 0.01, maximum: 10} + pipeline: + - {operation: resize, width: 1080, height: 1920, fit: cover} + - operation: watermark_video + inputs: [current, original:1] + position: "{{ position }}" + opacity: "{{ opacity }}" + watermark_scale: "{{ logo_scale }}" + - {operation: compress, crf: 24, preset: veryfast, format: mp4} + output: {format: mp4} + examples: + - inputs: + - {url: "https://example.com/creator-video.mp4"} + - {url: "https://example.com/creator-logo.png"} + parameters: {position: top-right} + + - id: watermark + name: Video Watermark + category: branding + description: Apply a supplied image watermark to video using configurable placement, scale, and opacity. + author: Enterprise Media API + version: 1 + tags: [watermark, logo, overlay, branding] + estimated_runtime: medium + supported_inputs: [video, image, url] + supported_outputs: [mp4] + parameters: + position: + type: string + default: bottom-right + enum: [top-left, top-right, bottom-left, bottom-right, center] + opacity: {type: number, default: 1, minimum: 0, maximum: 1} + scale: {type: number, default: 0.25, minimum: 0.01, maximum: 10} + pipeline: + - operation: watermark_video + inputs: [original:0, original:1] + position: "{{ position }}" + opacity: "{{ opacity }}" + watermark_scale: "{{ scale }}" + - {operation: compress, crf: 23, preset: veryfast, format: mp4} + output: {format: mp4} + examples: + - inputs: + - {url: "https://example.com/video.mp4"} + - {url: "https://example.com/logo.png"} + parameters: {position: bottom-right, opacity: 0.8, scale: 0.2} + + - id: intro_outro + name: Intro Main Outro + category: branding + description: Normalize and concatenate supplied intro, main, and outro videos in order. + author: Enterprise Media API + version: 1 + tags: [intro, outro, concat, branding] + estimated_runtime: slow + supported_inputs: [video, multiple] + supported_outputs: [mp4] + parameters: + width: {type: integer, default: 1920, minimum: 2, maximum: 7680} + height: {type: integer, default: 1080, minimum: 2, maximum: 4320} + pipeline: + - operation: concat_videos + inputs: [originals] + width: "{{ width }}" + height: "{{ height }}" + stream_copy: false + - {operation: normalize_video} + - {operation: compress, crf: 22, preset: medium, format: mp4} + output: {format: mp4} + examples: + - inputs: + - {url: "https://example.com/intro.mp4"} + - {url: "https://example.com/main.mp4"} + - {url: "https://example.com/outro.mp4"} + parameters: {width: 1920, height: 1080} + + - id: logo_animation + name: Logo Animation Clip + category: branding + description: Turn a supplied logo artwork into a duration-controlled H.264 branding clip. + author: Enterprise Media API + version: 1 + tags: [logo, animation, image-to-video, branding] + estimated_runtime: fast + supported_inputs: [image, url] + supported_outputs: [mp4] + parameters: + duration: {type: number, default: 3, minimum: 0.1, maximum: 30} + fps: {type: integer, default: 30, minimum: 1, maximum: 60} + pipeline: + - {operation: resize_image, width: 1920, height: 1080, fit: contain, format: png} + - {operation: image_to_video, duration: "{{ duration }}", fps: "{{ fps }}"} + - {operation: compress, crf: 20, preset: veryfast, format: mp4} + output: {format: mp4} + examples: + - input: {url: "https://example.com/logo.png"} + parameters: {duration: 3, fps: 30} diff --git a/app/templates/categories/conversion/templates.yaml b/app/templates/categories/conversion/templates.yaml new file mode 100644 index 0000000000000000000000000000000000000000..bb2c321fa8fd88363f1f387e4e8184fe7c6ee945 --- /dev/null +++ b/app/templates/categories/conversion/templates.yaml @@ -0,0 +1,139 @@ +templates: + - id: mp4 + name: Convert to MP4 + category: conversion + description: Convert video to broadly compatible H.264/AAC MP4. + author: Enterprise Media API + version: 1 + tags: [conversion, mp4, h264, video] + estimated_runtime: medium + supported_inputs: [video, url, ytdlp] + supported_outputs: [mp4] + parameters: {} + pipeline: [{operation: convert_video, format: mp4}] + output: {format: mp4} + examples: [{input: {url: "https://example.com/input.mov"}, parameters: {}}] + + - id: mov + name: Convert to MOV + category: conversion + description: Convert video to a MOV container using the shared video converter. + author: Enterprise Media API + version: 1 + tags: [conversion, mov, video, quicktime] + estimated_runtime: medium + supported_inputs: [video, url] + supported_outputs: [mov] + parameters: {} + pipeline: [{operation: convert_video, format: mov}] + output: {format: mov} + examples: [{input: {url: "https://example.com/input.mp4"}, parameters: {}}] + + - id: avi + name: Convert to AVI + category: conversion + description: Convert video to an AVI container using MPEG-4 video and MP3 audio. + author: Enterprise Media API + version: 1 + tags: [conversion, avi, video, legacy] + estimated_runtime: medium + supported_inputs: [video, url] + supported_outputs: [avi] + parameters: {} + pipeline: [{operation: convert_video, format: avi}] + output: {format: avi} + examples: [{input: {url: "https://example.com/input.mp4"}, parameters: {}}] + + - id: webm + name: Convert to WebM + category: conversion + description: Convert video to VP9/Opus WebM for modern web playback. + author: Enterprise Media API + version: 1 + tags: [conversion, webm, vp9, web] + estimated_runtime: slow + supported_inputs: [video, url] + supported_outputs: [webm] + parameters: {} + pipeline: [{operation: convert_video, format: webm}] + output: {format: webm} + examples: [{input: {url: "https://example.com/input.mp4"}, parameters: {}}] + + - id: gif + name: Convert to GIF + category: conversion + description: Generate a palette-optimized animated GIF from video. + author: Enterprise Media API + version: 1 + tags: [conversion, gif, animation, image] + estimated_runtime: medium + supported_inputs: [video, url] + supported_outputs: [gif] + parameters: + fps: {type: number, default: 10, minimum: 1, maximum: 30} + width: {type: integer, default: 480, minimum: 2, maximum: 3840} + pipeline: [{operation: generate_gif, fps: "{{ fps }}", width: "{{ width }}"}] + output: {format: gif} + examples: + - input: {url: "https://example.com/clip.mp4"} + parameters: {fps: 10, width: 480} + + - id: mp3 + name: Convert to MP3 + category: conversion + description: Extract or convert media audio to 192 kbps MP3. + author: Enterprise Media API + version: 1 + tags: [conversion, mp3, audio, extract] + estimated_runtime: fast + supported_inputs: [video, audio, url, ytdlp] + supported_outputs: [mp3] + parameters: {} + pipeline: [{operation: convert_audio, format: mp3}] + output: {format: mp3} + examples: [{input: {url: "https://example.com/input.wav"}, parameters: {}}] + + - id: wav + name: Convert to WAV + category: conversion + description: Convert media audio to uncompressed PCM WAV. + author: Enterprise Media API + version: 1 + tags: [conversion, wav, audio, pcm] + estimated_runtime: fast + supported_inputs: [video, audio, url] + supported_outputs: [wav] + parameters: {} + pipeline: [{operation: convert_audio, format: wav}] + output: {format: wav} + examples: [{input: {url: "https://example.com/input.mp3"}, parameters: {}}] + + - id: aac + name: Convert to AAC + category: conversion + description: Convert media audio to AAC at a practical streaming bitrate. + author: Enterprise Media API + version: 1 + tags: [conversion, aac, audio, streaming] + estimated_runtime: fast + supported_inputs: [video, audio, url] + supported_outputs: [aac] + parameters: {} + pipeline: [{operation: convert_audio, format: aac}] + output: {format: aac} + examples: [{input: {url: "https://example.com/input.wav"}, parameters: {}}] + + - id: flac + name: Convert to FLAC + category: conversion + description: Convert media audio to lossless FLAC. + author: Enterprise Media API + version: 1 + tags: [conversion, flac, audio, lossless] + estimated_runtime: fast + supported_inputs: [video, audio, url] + supported_outputs: [flac] + parameters: {} + pipeline: [{operation: convert_audio, format: flac}] + output: {format: flac} + examples: [{input: {url: "https://example.com/input.wav"}, parameters: {}}] diff --git a/app/templates/categories/custom/.gitkeep b/app/templates/categories/custom/.gitkeep new file mode 100644 index 0000000000000000000000000000000000000000..574e479bca3da644387f7c68ecf908c0c640ebb5 --- /dev/null +++ b/app/templates/categories/custom/.gitkeep @@ -0,0 +1 @@ +Custom YAML templates can be mounted or added in this directory. diff --git a/app/templates/categories/faceless/templates.yaml b/app/templates/categories/faceless/templates.yaml new file mode 100644 index 0000000000000000000000000000000000000000..9e46b97361c64a040ff36ad5515ef80471cdb8db --- /dev/null +++ b/app/templates/categories/faceless/templates.yaml @@ -0,0 +1,307 @@ +templates: + - id: reddit_story + name: Reddit Story Video + category: faceless + description: Format an assembled Reddit narration video with optional supplied captions for vertical feeds. + author: Enterprise Media API + version: 1 + tags: [reddit, story, faceless, captions] + estimated_runtime: medium + supported_inputs: [video, subtitles, url] + supported_outputs: [mp4] + parameters: + burn_captions: {type: boolean, default: false} + crf: {type: integer, default: 24, minimum: 0, maximum: 51} + pipeline: + - {operation: resize, width: 1080, height: 1920, fit: cover} + - operation: burn_subtitles + when: "{{ burn_captions }}" + inputs: [current, original:1] + style: "FontName=Arial,FontSize=18,Outline=2,Alignment=2,MarginV=90" + - {operation: normalize_video} + - {operation: compress, crf: "{{ crf }}", preset: veryfast, format: mp4} + output: {format: mp4} + examples: + - input: {url: "https://example.com/reddit-story.mp4"} + parameters: {burn_captions: false} + + - id: ai_story + name: AI Story Video + category: faceless + description: Prepare an assembled AI-narrated story as a polished vertical video. + author: Enterprise Media API + version: 1 + tags: [ai, story, faceless, vertical] + estimated_runtime: medium + supported_inputs: [video, subtitles, url] + supported_outputs: [mp4] + parameters: + burn_captions: {type: boolean, default: false} + crf: {type: integer, default: 23, minimum: 0, maximum: 51} + pipeline: + - {operation: resize, width: 1080, height: 1920, fit: cover} + - operation: burn_subtitles + when: "{{ burn_captions }}" + inputs: [current, original:1] + style: "FontName=Arial,FontSize=18,Outline=2,Alignment=2,MarginV=100" + - {operation: normalize_video} + - {operation: compress, crf: "{{ crf }}", preset: veryfast, format: mp4} + output: {format: mp4} + examples: + - input: {url: "https://example.com/ai-story.mp4"} + parameters: {crf: 23} + + - id: movie_recap + name: Movie Recap + category: faceless + description: Encode an edited movie recap in a caption-ready widescreen presentation. + author: Enterprise Media API + version: 1 + tags: [movie, recap, faceless, widescreen] + estimated_runtime: slow + supported_inputs: [video, subtitles, url] + supported_outputs: [mp4] + parameters: + burn_captions: {type: boolean, default: false} + crf: {type: integer, default: 22, minimum: 0, maximum: 51} + pipeline: + - {operation: resize, width: 1920, height: 1080, fit: contain} + - operation: burn_subtitles + when: "{{ burn_captions }}" + inputs: [current, original:1] + style: "FontName=Arial,FontSize=20,Outline=2,Alignment=2,MarginV=50" + - {operation: normalize_video} + - {operation: compress, crf: "{{ crf }}", preset: medium, format: mp4} + output: {format: mp4} + examples: + - input: {url: "https://example.com/movie-recap.mov"} + parameters: {crf: 22} + + - id: history_short + name: History Short + category: faceless + description: Convert an edited history narration into a compact vertical short. + author: Enterprise Media API + version: 1 + tags: [history, education, short, faceless] + estimated_runtime: medium + supported_inputs: [video, subtitles, url] + supported_outputs: [mp4] + parameters: + duration: {type: number, default: 60, minimum: 1, maximum: 180} + burn_captions: {type: boolean, default: false} + pipeline: + - {operation: trim, start: 0, duration: "{{ duration }}"} + - {operation: resize, width: 1080, height: 1920, fit: cover} + - operation: burn_subtitles + when: "{{ burn_captions }}" + inputs: [current, original:1] + - {operation: normalize_video} + - {operation: compress, crf: 24, preset: veryfast, format: mp4} + output: {format: mp4} + examples: + - input: {url: "https://example.com/history.mp4"} + parameters: {duration: 60} + + - id: true_crime + name: True Crime Story + category: faceless + description: Prepare a narrated true-crime edit with normalized audio and optional captions. + author: Enterprise Media API + version: 1 + tags: [true-crime, documentary, faceless, captions] + estimated_runtime: slow + supported_inputs: [video, subtitles, url] + supported_outputs: [mp4] + parameters: + burn_captions: {type: boolean, default: false} + crf: {type: integer, default: 23, minimum: 0, maximum: 51} + pipeline: + - {operation: resize, width: 1920, height: 1080, fit: contain} + - operation: burn_subtitles + when: "{{ burn_captions }}" + inputs: [current, original:1] + style: "FontName=Arial,FontSize=19,Outline=2,Alignment=2,MarginV=55" + - {operation: normalize_video} + - {operation: denoise} + - {operation: compress, crf: "{{ crf }}", preset: medium, format: mp4} + output: {format: mp4} + examples: + - input: {url: "https://example.com/true-crime.mp4"} + parameters: {burn_captions: false} + + - id: did_you_know + name: Did You Know + category: faceless + description: Produce a fast vertical fact video from an assembled narrated source. + author: Enterprise Media API + version: 1 + tags: [facts, education, short, faceless] + estimated_runtime: fast + supported_inputs: [video, subtitles, url] + supported_outputs: [mp4] + parameters: + duration: {type: number, default: 45, minimum: 1, maximum: 120} + burn_captions: {type: boolean, default: false} + pipeline: + - {operation: trim, start: 0, duration: "{{ duration }}"} + - {operation: resize, width: 1080, height: 1920, fit: cover} + - operation: burn_subtitles + when: "{{ burn_captions }}" + inputs: [current, original:1] + - {operation: fps, fps: 30} + - {operation: compress, crf: 25, preset: veryfast, format: mp4} + output: {format: mp4} + examples: + - input: {url: "https://example.com/fact.mp4"} + parameters: {duration: 45} + + - id: top10_video + name: Top 10 Video + category: faceless + description: Normalize a completed ranked-list video for reliable widescreen publishing. + author: Enterprise Media API + version: 1 + tags: [top10, listicle, faceless, youtube] + estimated_runtime: slow + supported_inputs: [video, subtitles, url] + supported_outputs: [mp4] + parameters: + burn_captions: {type: boolean, default: false} + crf: {type: integer, default: 23, minimum: 0, maximum: 51} + pipeline: + - {operation: resize, width: 1920, height: 1080, fit: contain} + - operation: burn_subtitles + when: "{{ burn_captions }}" + inputs: [current, original:1] + - {operation: normalize_video} + - {operation: compress, crf: "{{ crf }}", preset: medium, format: mp4} + output: {format: mp4} + examples: + - input: {url: "https://example.com/top-ten.mp4"} + parameters: {crf: 23} + + - id: facts_video + name: Facts Video + category: faceless + description: Format an assembled facts compilation for high-retention vertical viewing. + author: Enterprise Media API + version: 1 + tags: [facts, listicle, faceless, vertical] + estimated_runtime: medium + supported_inputs: [video, subtitles, url] + supported_outputs: [mp4] + parameters: + burn_captions: {type: boolean, default: false} + crf: {type: integer, default: 24, minimum: 0, maximum: 51} + pipeline: + - {operation: resize, width: 1080, height: 1920, fit: cover} + - operation: burn_subtitles + when: "{{ burn_captions }}" + inputs: [current, original:1] + - {operation: normalize_video} + - {operation: compress, crf: "{{ crf }}", preset: veryfast, format: mp4} + output: {format: mp4} + examples: + - input: {url: "https://example.com/facts.mp4"} + parameters: {crf: 24} + + - id: book_summary + name: Book Summary + category: faceless + description: Prepare an edited book-summary narration for widescreen educational delivery. + author: Enterprise Media API + version: 1 + tags: [books, summary, education, faceless] + estimated_runtime: slow + supported_inputs: [video, subtitles, url] + supported_outputs: [mp4] + parameters: + burn_captions: {type: boolean, default: false} + crf: {type: integer, default: 23, minimum: 0, maximum: 51} + pipeline: + - {operation: resize, width: 1920, height: 1080, fit: contain} + - operation: burn_subtitles + when: "{{ burn_captions }}" + inputs: [current, original:1] + - {operation: normalize_video} + - {operation: compress, crf: "{{ crf }}", preset: medium, format: mp4} + output: {format: mp4} + examples: + - input: {url: "https://example.com/book-summary.mp4"} + parameters: {crf: 23} + + - id: finance_short + name: Finance Short + category: faceless + description: Create a clean vertical finance explainer from an edited source. + author: Enterprise Media API + version: 1 + tags: [finance, business, short, faceless] + estimated_runtime: medium + supported_inputs: [video, subtitles, url] + supported_outputs: [mp4] + parameters: + burn_captions: {type: boolean, default: false} + crf: {type: integer, default: 23, minimum: 0, maximum: 51} + pipeline: + - {operation: resize, width: 1080, height: 1920, fit: contain} + - operation: burn_subtitles + when: "{{ burn_captions }}" + inputs: [current, original:1] + - {operation: normalize_video} + - {operation: compress, crf: "{{ crf }}", preset: veryfast, format: mp4} + output: {format: mp4} + examples: + - input: {url: "https://example.com/finance.mp4"} + parameters: {burn_captions: false} + + - id: crypto_news + name: Crypto News + category: faceless + description: Format a produced crypto-news segment for vertical social distribution. + author: Enterprise Media API + version: 1 + tags: [crypto, news, finance, vertical] + estimated_runtime: medium + supported_inputs: [video, subtitles, url] + supported_outputs: [mp4] + parameters: + burn_captions: {type: boolean, default: false} + crf: {type: integer, default: 24, minimum: 0, maximum: 51} + pipeline: + - {operation: resize, width: 1080, height: 1920, fit: cover} + - operation: burn_subtitles + when: "{{ burn_captions }}" + inputs: [current, original:1] + - {operation: normalize_video} + - {operation: compress, crf: "{{ crf }}", preset: veryfast, format: mp4} + output: {format: mp4} + examples: + - input: {url: "https://example.com/crypto-news.mp4"} + parameters: {crf: 24} + + - id: tech_news + name: Tech News + category: faceless + description: Prepare an edited technology news segment for clear HD playback. + author: Enterprise Media API + version: 1 + tags: [technology, news, faceless, hd] + estimated_runtime: medium + supported_inputs: [video, subtitles, url] + supported_outputs: [mp4] + parameters: + burn_captions: {type: boolean, default: false} + crf: {type: integer, default: 23, minimum: 0, maximum: 51} + pipeline: + - {operation: resize, width: 1920, height: 1080, fit: contain} + - operation: burn_subtitles + when: "{{ burn_captions }}" + inputs: [current, original:1] + - {operation: normalize_video} + - {operation: compress, crf: "{{ crf }}", preset: veryfast, format: mp4} + output: {format: mp4} + examples: + - input: {url: "https://example.com/tech-news.mp4"} + parameters: {crf: 23} diff --git a/app/templates/categories/lyrics/templates.yaml b/app/templates/categories/lyrics/templates.yaml new file mode 100644 index 0000000000000000000000000000000000000000..1f75e1498f2dce205623e174471e50dfea7ff0e4 --- /dev/null +++ b/app/templates/categories/lyrics/templates.yaml @@ -0,0 +1,154 @@ +templates: + - id: lyrics_basic + name: Basic Lyrics Video + category: lyrics + description: Burn supplied timed lyrics into a clean HD music video. + author: Enterprise Media API + version: 1 + tags: [lyrics, music, subtitles, hd] + estimated_runtime: medium + supported_inputs: [video, subtitles] + supported_outputs: [mp4] + parameters: + font_size: {type: integer, default: 20, minimum: 8, maximum: 72} + pipeline: + - {operation: resize, width: 1920, height: 1080, fit: contain} + - operation: burn_subtitles + inputs: [current, original:1] + style: "FontName=Arial,FontSize={{ font_size }},Outline=2,Alignment=2,MarginV=55" + - {operation: compress, crf: 22, preset: medium, format: mp4} + output: {format: mp4} + examples: + - inputs: + - {url: "https://example.com/song.mp4"} + - {url: "https://example.com/lyrics.srt"} + parameters: {font_size: 20} + + - id: karaoke + name: Karaoke Video + category: lyrics + description: Burn supplied word-timed ASS or subtitle lyrics into a karaoke-ready video. + author: Enterprise Media API + version: 1 + tags: [karaoke, lyrics, ass, music] + estimated_runtime: medium + supported_inputs: [video, subtitles] + supported_outputs: [mp4] + parameters: + font_size: {type: integer, default: 22, minimum: 8, maximum: 72} + pipeline: + - {operation: resize, width: 1920, height: 1080, fit: contain} + - operation: burn_subtitles + inputs: [current, original:1] + style: "FontName=Arial,FontSize={{ font_size }},Bold=1,Outline=2,Alignment=2,MarginV=60" + - {operation: normalize_video} + - {operation: compress, crf: 21, preset: medium, format: mp4} + output: {format: mp4} + examples: + - inputs: + - {url: "https://example.com/instrumental.mp4"} + - {url: "https://example.com/karaoke.ass"} + parameters: {font_size: 22} + + - id: spotify_style + name: Spotify Style Lyrics + category: lyrics + description: Render supplied timed lyrics with centered, bold styling for a Spotify-inspired layout. + author: Enterprise Media API + version: 1 + tags: [spotify, lyrics, centered, music] + estimated_runtime: medium + supported_inputs: [video, subtitles] + supported_outputs: [mp4] + parameters: + font_size: {type: integer, default: 24, minimum: 8, maximum: 72} + pipeline: + - {operation: resize, width: 1080, height: 1920, fit: cover} + - operation: burn_subtitles + inputs: [current, original:1] + style: "FontName=Arial,FontSize={{ font_size }},Bold=1,Outline=1,Alignment=5" + - {operation: compress, crf: 22, preset: veryfast, format: mp4} + output: {format: mp4} + examples: + - inputs: + - {url: "https://example.com/vertical-song.mp4"} + - {url: "https://example.com/lyrics.vtt"} + parameters: {font_size: 24} + + - id: cinematic_lyrics + name: Cinematic Lyrics + category: lyrics + description: Produce widescreen lyrics with restrained cinematic subtitle styling. + author: Enterprise Media API + version: 1 + tags: [cinematic, lyrics, widescreen, music] + estimated_runtime: slow + supported_inputs: [video, subtitles] + supported_outputs: [mp4] + parameters: + font_size: {type: integer, default: 21, minimum: 8, maximum: 72} + pipeline: + - {operation: resize, width: 1920, height: 1080, fit: cover} + - operation: burn_subtitles + inputs: [current, original:1] + style: "FontName=Serif,FontSize={{ font_size }},Outline=1,Shadow=1,Alignment=2,MarginV=70" + - {operation: normalize_video} + - {operation: compress, crf: 20, preset: medium, format: mp4} + output: {format: mp4} + examples: + - inputs: + - {url: "https://example.com/cinematic.mp4"} + - {url: "https://example.com/lyrics.srt"} + parameters: {font_size: 21} + + - id: neon_lyrics + name: Neon Lyrics + category: lyrics + description: Burn supplied lyrics with a bright neon-style centered treatment. + author: Enterprise Media API + version: 1 + tags: [neon, lyrics, vertical, music] + estimated_runtime: medium + supported_inputs: [video, subtitles] + supported_outputs: [mp4] + parameters: + font_size: {type: integer, default: 23, minimum: 8, maximum: 72} + pipeline: + - {operation: resize, width: 1080, height: 1920, fit: cover} + - operation: burn_subtitles + inputs: [current, original:1] + style: "FontName=Arial,FontSize={{ font_size }},Bold=1,PrimaryColour=&H00FFFF&,Outline=3,Alignment=5" + - {operation: sharpen, amount: 0.5} + - {operation: compress, crf: 22, preset: veryfast, format: mp4} + output: {format: mp4} + examples: + - inputs: + - {url: "https://example.com/neon.mp4"} + - {url: "https://example.com/lyrics.ass"} + parameters: {font_size: 23} + + - id: music_video + name: Music Video Master + category: lyrics + description: Normalize and encode a completed music video with optional supplied timed lyrics. + author: Enterprise Media API + version: 1 + tags: [music-video, lyrics, master, hd] + estimated_runtime: slow + supported_inputs: [video, subtitles, url] + supported_outputs: [mp4] + parameters: + burn_lyrics: {type: boolean, default: false} + crf: {type: integer, default: 20, minimum: 0, maximum: 51} + pipeline: + - {operation: resize, width: 1920, height: 1080, fit: contain} + - operation: burn_subtitles + when: "{{ burn_lyrics }}" + inputs: [current, original:1] + style: "FontName=Arial,FontSize=20,Outline=2,Alignment=2,MarginV=55" + - {operation: normalize_video} + - {operation: compress, crf: "{{ crf }}", preset: medium, format: mp4} + output: {format: mp4} + examples: + - input: {url: "https://example.com/music-video.mov"} + parameters: {burn_lyrics: false, crf: 20} diff --git a/app/templates/categories/motivation/templates.yaml b/app/templates/categories/motivation/templates.yaml new file mode 100644 index 0000000000000000000000000000000000000000..4333f3f0f07fe7a579f3fccc21a81f0879af62b7 --- /dev/null +++ b/app/templates/categories/motivation/templates.yaml @@ -0,0 +1,205 @@ +templates: + - id: motivational_video + name: Motivational Video + category: motivation + description: Polish an assembled motivational video with normalized audio and optional supplied captions. + author: Enterprise Media API + version: 1 + tags: [motivation, inspiration, captions, video] + estimated_runtime: medium + supported_inputs: [video, subtitles, url] + supported_outputs: [mp4] + parameters: + vertical: {type: boolean, default: true} + burn_captions: {type: boolean, default: false} + crf: {type: integer, default: 23, minimum: 0, maximum: 51} + pipeline: + - {operation: resize, width: 1080, height: 1920, fit: cover, when: "{{ vertical }}"} + - operation: burn_subtitles + when: "{{ burn_captions }}" + inputs: [current, original:1] + style: "FontName=Arial,FontSize=18,Bold=1,Outline=2,Alignment=2,MarginV=100" + - {operation: normalize_video} + - {operation: compress, crf: "{{ crf }}", preset: veryfast, format: mp4} + output: {format: mp4} + examples: + - input: {url: "https://example.com/motivation.mp4"} + parameters: {vertical: true, burn_captions: false} + + - id: morning_motivation + name: Morning Motivation + category: motivation + description: Prepare a short vertical morning-motivation edit for daily publishing. + author: Enterprise Media API + version: 1 + tags: [morning, motivation, daily, vertical] + estimated_runtime: fast + supported_inputs: [video, subtitles, url] + supported_outputs: [mp4] + parameters: + duration: {type: number, default: 60, minimum: 1, maximum: 180} + burn_captions: {type: boolean, default: false} + pipeline: + - {operation: trim, start: 0, duration: "{{ duration }}"} + - {operation: resize, width: 1080, height: 1920, fit: cover} + - operation: burn_subtitles + when: "{{ burn_captions }}" + inputs: [current, original:1] + - {operation: normalize_video} + - {operation: compress, crf: 24, preset: veryfast, format: mp4} + output: {format: mp4} + examples: + - input: {url: "https://example.com/morning.mp4"} + parameters: {duration: 60} + + - id: business_motivation + name: Business Motivation + category: motivation + description: Encode an edited business-motivation presentation for professional feeds. + author: Enterprise Media API + version: 1 + tags: [business, motivation, linkedin, landscape] + estimated_runtime: medium + supported_inputs: [video, subtitles, url] + supported_outputs: [mp4] + parameters: + burn_captions: {type: boolean, default: false} + crf: {type: integer, default: 23, minimum: 0, maximum: 51} + pipeline: + - {operation: resize, width: 1920, height: 1080, fit: contain} + - operation: burn_subtitles + when: "{{ burn_captions }}" + inputs: [current, original:1] + - {operation: normalize_video} + - {operation: compress, crf: "{{ crf }}", preset: veryfast, format: mp4} + output: {format: mp4} + examples: + - input: {url: "https://example.com/business.mp4"} + parameters: {crf: 23} + + - id: gym_motivation + name: Gym Motivation + category: motivation + description: Create a sharp, high-motion vertical gym edit with normalized audio. + author: Enterprise Media API + version: 1 + tags: [gym, fitness, motivation, vertical] + estimated_runtime: medium + supported_inputs: [video, subtitles, url] + supported_outputs: [mp4] + parameters: + burn_captions: {type: boolean, default: false} + crf: {type: integer, default: 21, minimum: 0, maximum: 51} + pipeline: + - {operation: resize, width: 1080, height: 1920, fit: cover} + - {operation: fps, fps: 30} + - {operation: sharpen, amount: 0.8} + - operation: burn_subtitles + when: "{{ burn_captions }}" + inputs: [current, original:1] + - {operation: normalize_video} + - {operation: compress, crf: "{{ crf }}", preset: fast, format: mp4} + output: {format: mp4} + examples: + - input: {url: "https://example.com/workout.mp4"} + parameters: {crf: 21} + + - id: success_quotes + name: Success Quotes + category: motivation + description: Format a produced success-quote montage as a concise vertical video. + author: Enterprise Media API + version: 1 + tags: [success, quotes, motivation, vertical] + estimated_runtime: fast + supported_inputs: [video, subtitles, url] + supported_outputs: [mp4] + parameters: + duration: {type: number, default: 45, minimum: 1, maximum: 120} + burn_captions: {type: boolean, default: false} + pipeline: + - {operation: trim, start: 0, duration: "{{ duration }}"} + - {operation: resize, width: 1080, height: 1920, fit: contain} + - operation: burn_subtitles + when: "{{ burn_captions }}" + inputs: [current, original:1] + - {operation: compress, crf: 24, preset: veryfast, format: mp4} + output: {format: mp4} + examples: + - input: {url: "https://example.com/success-quotes.mp4"} + parameters: {duration: 45} + + - id: stoic_quotes + name: Stoic Quotes + category: motivation + description: Prepare a subdued stoic-quote montage with optional caption burning. + author: Enterprise Media API + version: 1 + tags: [stoic, philosophy, quotes, motivation] + estimated_runtime: fast + supported_inputs: [video, subtitles, url] + supported_outputs: [mp4] + parameters: + burn_captions: {type: boolean, default: false} + pipeline: + - {operation: resize, width: 1080, height: 1920, fit: contain} + - operation: burn_subtitles + when: "{{ burn_captions }}" + inputs: [current, original:1] + style: "FontName=Serif,FontSize=18,Outline=1,Alignment=2,MarginV=110" + - {operation: normalize_video} + - {operation: compress, crf: 25, preset: veryfast, format: mp4} + output: {format: mp4} + examples: + - input: {url: "https://example.com/stoic.mp4"} + parameters: {burn_captions: false} + + - id: daily_quotes + name: Daily Quotes + category: motivation + description: Generate a publishing-ready vertical encode from a daily-quote video edit. + author: Enterprise Media API + version: 1 + tags: [daily, quotes, motivation, social] + estimated_runtime: fast + supported_inputs: [video, subtitles, url] + supported_outputs: [mp4] + parameters: + duration: {type: number, default: 30, minimum: 1, maximum: 90} + burn_captions: {type: boolean, default: false} + pipeline: + - {operation: trim, start: 0, duration: "{{ duration }}"} + - {operation: resize, width: 1080, height: 1920, fit: contain} + - operation: burn_subtitles + when: "{{ burn_captions }}" + inputs: [current, original:1] + - {operation: compress, crf: 25, preset: veryfast, format: mp4} + output: {format: mp4} + examples: + - input: {url: "https://example.com/daily-quote.mp4"} + parameters: {duration: 30} + + - id: affirmations + name: Affirmations Video + category: motivation + description: Normalize and encode an assembled affirmations video for calm daily playback. + author: Enterprise Media API + version: 1 + tags: [affirmations, wellness, daily, motivation] + estimated_runtime: medium + supported_inputs: [video, subtitles, url] + supported_outputs: [mp4] + parameters: + burn_captions: {type: boolean, default: false} + crf: {type: integer, default: 24, minimum: 0, maximum: 51} + pipeline: + - {operation: resize, width: 1080, height: 1920, fit: contain} + - operation: burn_subtitles + when: "{{ burn_captions }}" + inputs: [current, original:1] + - {operation: normalize_video} + - {operation: compress, crf: "{{ crf }}", preset: veryfast, format: mp4} + output: {format: mp4} + examples: + - input: {url: "https://example.com/affirmations.mp4"} + parameters: {crf: 24} diff --git a/app/templates/categories/podcast/templates.yaml b/app/templates/categories/podcast/templates.yaml new file mode 100644 index 0000000000000000000000000000000000000000..a54e594776a273b98ebc0bda231664a8c9ec2ed2 --- /dev/null +++ b/app/templates/categories/podcast/templates.yaml @@ -0,0 +1,99 @@ +templates: + - id: podcast_video + name: Podcast Video + category: podcast + description: Normalize and encode a recorded podcast video for full-length HD publishing. + author: Enterprise Media API + version: 1 + tags: [podcast, video, youtube, audio] + estimated_runtime: slow + supported_inputs: [video, url] + supported_outputs: [mp4] + parameters: + crf: {type: integer, default: 23, minimum: 0, maximum: 51} + pipeline: + - {operation: resize, width: 1920, height: 1080, fit: contain} + - {operation: normalize_video} + - {operation: compress, crf: "{{ crf }}", preset: medium, format: mp4} + output: {format: mp4} + examples: + - input: {url: "https://example.com/podcast-recording.mp4"} + parameters: {crf: 23} + + - id: podcast_short + name: Podcast Short + category: podcast + description: Trim a podcast excerpt and prepare it as a normalized vertical social clip. + author: Enterprise Media API + version: 1 + tags: [podcast, short, vertical, social] + estimated_runtime: medium + supported_inputs: [video, url] + supported_outputs: [mp4] + parameters: + start: {type: number, default: 0, minimum: 0, maximum: 86400} + duration: {type: number, default: 60, minimum: 1, maximum: 180} + crf: {type: integer, default: 24, minimum: 0, maximum: 51} + pipeline: + - {operation: trim, start: "{{ start }}", duration: "{{ duration }}"} + - {operation: resize, width: 1080, height: 1920, fit: cover} + - {operation: normalize_video} + - {operation: compress, crf: "{{ crf }}", preset: veryfast, format: mp4} + output: {format: mp4} + examples: + - input: {url: "https://example.com/episode.mp4"} + parameters: {start: 120, duration: 60} + + - id: audiogram + name: Podcast Audiogram + category: podcast + description: Combine a supplied audiogram artwork image and podcast audio into a shareable video. + author: Enterprise Media API + version: 1 + tags: [podcast, audiogram, image, audio] + estimated_runtime: medium + supported_inputs: [image, audio] + supported_outputs: [mp4] + parameters: + duration: {type: number, default: 30, minimum: 1, maximum: 3600} + fps: {type: integer, default: 30, minimum: 1, maximum: 60} + pipeline: + - {operation: image_to_video, duration: "{{ duration }}", fps: "{{ fps }}"} + - operation: replace_audio + inputs: [current, original:1] + - {operation: normalize_video} + - {operation: compress, crf: 24, preset: veryfast, format: mp4} + output: {format: mp4} + examples: + - inputs: + - {url: "https://example.com/audiogram.png"} + - {url: "https://example.com/clip.mp3"} + parameters: {duration: 30} + + - id: waveform_video + name: Waveform Video + category: podcast + description: Turn a supplied waveform artwork image and matching audio into an HD podcast visualization. + author: Enterprise Media API + version: 1 + tags: [podcast, waveform, visualization, audio] + estimated_runtime: medium + supported_inputs: [image, audio] + supported_outputs: [mp4] + parameters: + duration: {type: number, default: 60, minimum: 1, maximum: 3600} + width: {type: integer, default: 1920, minimum: 2, maximum: 7680} + height: {type: integer, default: 1080, minimum: 2, maximum: 4320} + pipeline: + - {operation: resize_image, width: "{{ width }}", height: "{{ height }}", fit: contain, format: png} + - {operation: image_to_video, duration: "{{ duration }}", fps: 30} + - operation: replace_audio + inputs: [current, original:1] + - {operation: normalize_video} + - {operation: compress, crf: 23, preset: veryfast, format: mp4} + output: {format: mp4} + examples: + - inputs: + - {url: "https://example.com/waveform.png"} + - {url: "https://example.com/podcast.mp3"} + parameters: {duration: 60} diff --git a/app/templates/categories/social/templates.yaml b/app/templates/categories/social/templates.yaml new file mode 100644 index 0000000000000000000000000000000000000000..061a7d0e060ebad686009e4588da0f115d514a2a --- /dev/null +++ b/app/templates/categories/social/templates.yaml @@ -0,0 +1,176 @@ +templates: + - id: youtube_shorts + name: YouTube Shorts + category: social + description: Convert a source video into a vertical, mobile-optimized YouTube Short. + author: Enterprise Media API + version: 1 + tags: [youtube, shorts, vertical, social] + estimated_runtime: medium + supported_inputs: [video, url, ytdlp] + supported_outputs: [mp4] + parameters: + crf: + type: integer + default: 23 + minimum: 0 + maximum: 51 + description: H.264 constant rate factor. + max_duration: + type: number + default: 60 + minimum: 1 + maximum: 180 + pipeline: + - operation: trim + start: 0 + duration: "{{ max_duration }}" + - operation: resize + width: 1080 + height: 1920 + fit: cover + - operation: fps + fps: 30 + - operation: compress + crf: "{{ crf }}" + preset: veryfast + format: mp4 + output: {format: mp4} + examples: + - input: {url: "https://example.com/source.mp4"} + parameters: {crf: 23, max_duration: 60} + + - id: tiktok_hd + name: TikTok HD + category: social + description: Prepare a full-height HD video with TikTok-friendly dimensions and frame rate. + author: Enterprise Media API + version: 1 + tags: [tiktok, vertical, hd, social] + estimated_runtime: medium + supported_inputs: [video, url, ytdlp] + supported_outputs: [mp4] + parameters: + crf: {type: integer, default: 22, minimum: 0, maximum: 51} + max_duration: {type: number, default: 180, minimum: 1, maximum: 600} + pipeline: + - {operation: trim, start: 0, duration: "{{ max_duration }}"} + - {operation: resize, width: 1080, height: 1920, fit: cover} + - {operation: fps, fps: 30} + - {operation: normalize_video} + - {operation: compress, crf: "{{ crf }}", preset: veryfast, format: mp4} + output: {format: mp4} + examples: + - input: {url: "https://example.com/clip.mp4"} + parameters: {crf: 22} + + - id: facebook_reel + name: Facebook Reel + category: social + description: Normalize and encode a vertical source for Facebook Reels playback. + author: Enterprise Media API + version: 1 + tags: [facebook, reels, vertical, social] + estimated_runtime: medium + supported_inputs: [video, url] + supported_outputs: [mp4] + parameters: + crf: {type: integer, default: 23, minimum: 0, maximum: 51} + pipeline: + - {operation: resize, width: 1080, height: 1920, fit: cover} + - {operation: fps, fps: 30} + - {operation: normalize_video} + - {operation: compress, crf: "{{ crf }}", preset: veryfast, format: mp4} + output: {format: mp4} + examples: + - input: {url: "https://example.com/reel.mov"} + parameters: {crf: 23} + + - id: instagram_reel + name: Instagram Reel + category: social + description: Optimize any video for a 1080 by 1920 Instagram Reel. + author: Enterprise Media API + version: 1 + tags: [instagram, reels, vertical, social] + estimated_runtime: medium + supported_inputs: [video, url, ytdlp] + supported_outputs: [mp4] + parameters: + crf: {type: integer, default: 23, minimum: 0, maximum: 51} + pipeline: + - {operation: resize, width: 1080, height: 1920, fit: cover} + - {operation: fps, value: 30} + - {operation: normalize_video} + - {operation: compress, crf: "{{ crf }}", preset: veryfast, format: mp4} + output: {format: mp4} + examples: + - input: {url: "https://example.com/video.mp4"} + parameters: {crf: 21} + + - id: linkedin_video + name: LinkedIn Video + category: social + description: Produce a presentation-friendly landscape video for LinkedIn feeds. + author: Enterprise Media API + version: 1 + tags: [linkedin, business, landscape, social] + estimated_runtime: medium + supported_inputs: [video, url] + supported_outputs: [mp4] + parameters: + crf: {type: integer, default: 24, minimum: 0, maximum: 51} + pipeline: + - {operation: resize, width: 1920, height: 1080, fit: contain} + - {operation: fps, fps: 30} + - {operation: normalize_video} + - {operation: compress, crf: "{{ crf }}", preset: veryfast, format: mp4} + output: {format: mp4} + examples: + - input: {url: "https://example.com/presentation.mp4"} + parameters: {crf: 24} + + - id: twitter_video + name: X Twitter Video + category: social + description: Create a bandwidth-conscious landscape video for X and Twitter timelines. + author: Enterprise Media API + version: 1 + tags: [twitter, x, landscape, social] + estimated_runtime: medium + supported_inputs: [video, url] + supported_outputs: [mp4] + parameters: + crf: {type: integer, default: 25, minimum: 0, maximum: 51} + bitrate: {type: string, default: 2500k, min_length: 2, max_length: 12} + pipeline: + - {operation: resize, width: 1280, height: 720, fit: contain} + - {operation: fps, fps: 30} + - {operation: compress, crf: "{{ crf }}", bitrate: "{{ bitrate }}", preset: veryfast, format: mp4} + output: {format: mp4} + examples: + - input: {url: "https://example.com/news.mp4"} + parameters: {bitrate: 2000k} + + - id: whatsapp_status + name: WhatsApp Status + category: social + description: Trim and compress a vertical video for quick WhatsApp Status delivery. + author: Enterprise Media API + version: 1 + tags: [whatsapp, status, mobile, vertical] + estimated_runtime: fast + supported_inputs: [video, url] + supported_outputs: [mp4] + parameters: + duration: {type: number, default: 30, minimum: 1, maximum: 60} + crf: {type: integer, default: 28, minimum: 0, maximum: 51} + pipeline: + - {operation: trim, start: 0, duration: "{{ duration }}"} + - {operation: resize, width: 720, height: 1280, fit: cover} + - {operation: fps, fps: 30} + - {operation: compress, crf: "{{ crf }}", preset: veryfast, max_width: 720, format: mp4} + output: {format: mp4} + examples: + - input: {url: "https://example.com/status.mp4"} + parameters: {duration: 30, crf: 28} diff --git a/app/templates/categories/subtitles/templates.yaml b/app/templates/categories/subtitles/templates.yaml new file mode 100644 index 0000000000000000000000000000000000000000..4e6d000e080fa77f1638d2695924126f94a39a3e --- /dev/null +++ b/app/templates/categories/subtitles/templates.yaml @@ -0,0 +1,163 @@ +templates: + - id: auto_subtitles + name: Automatic Subtitles + category: subtitles + description: Transcribe speech to SRT with faster-whisper and burn the generated subtitles into the original video. + author: Enterprise Media API + version: 1 + tags: [subtitles, whisper, captions, automation] + estimated_runtime: slow + supported_inputs: [video, audio, url, ytdlp] + supported_outputs: [mp4] + parameters: + model: + type: string + default: small + enum: [tiny, base, small, medium, large-v3] + crf: {type: integer, default: 23, minimum: 0, maximum: 51} + pipeline: + - {operation: transcribe, model: "{{ model }}", output_format: srt, save_as: captions} + - operation: burn_subtitles + inputs: [original:0, artifact:captions] + style: "FontName=Arial,FontSize=18,Outline=2,Alignment=2,MarginV=55" + - {operation: compress, crf: "{{ crf }}", preset: veryfast, format: mp4} + output: {format: mp4} + examples: + - input: {url: "https://example.com/interview.mp4"} + parameters: {model: small, crf: 23} + + - id: translate_video + name: Translate Video to English + category: subtitles + description: Translate speech to English subtitles with faster-whisper and burn them into the source video. + author: Enterprise Media API + version: 1 + tags: [translation, subtitles, whisper, english] + estimated_runtime: slow + supported_inputs: [video, audio, url, ytdlp] + supported_outputs: [mp4] + parameters: + model: + type: string + default: small + enum: [tiny, base, small, medium, large-v3] + pipeline: + - {operation: translate, model: "{{ model }}", output_format: srt, save_as: translated_captions} + - operation: burn_subtitles + inputs: [original:0, artifact:translated_captions] + style: "FontName=Arial,FontSize=18,Outline=2,Alignment=2,MarginV=55" + - {operation: compress, crf: 23, preset: veryfast, format: mp4} + output: {format: mp4} + examples: + - input: {url: "https://example.com/non-english.mp4"} + parameters: {model: small} + + - id: transcribe + name: Plain Text Transcription + category: subtitles + description: Transcribe speech from media and return a UTF-8 text document. + author: Enterprise Media API + version: 1 + tags: [transcription, text, whisper, speech] + estimated_runtime: slow + supported_inputs: [video, audio, url, ytdlp] + supported_outputs: [txt] + parameters: + model: + type: string + default: small + enum: [tiny, base, small, medium, large-v3] + pipeline: + - {operation: transcribe, model: "{{ model }}", output_format: txt} + output: {format: txt} + examples: + - input: {url: "https://example.com/meeting.mp3"} + parameters: {model: small} + + - id: transcribe_srt + name: SRT Transcription + category: subtitles + description: Transcribe media into a timestamped SubRip subtitle file. + author: Enterprise Media API + version: 1 + tags: [transcription, srt, whisper, subtitles] + estimated_runtime: slow + supported_inputs: [video, audio, url, ytdlp] + supported_outputs: [srt] + parameters: + model: + type: string + default: small + enum: [tiny, base, small, medium, large-v3] + pipeline: + - {operation: transcribe, model: "{{ model }}", output_format: srt} + output: {format: srt} + examples: + - input: {url: "https://example.com/lecture.mp4"} + parameters: {model: small} + + - id: transcribe_vtt + name: WebVTT Transcription + category: subtitles + description: Transcribe media into a browser-compatible WebVTT subtitle file. + author: Enterprise Media API + version: 1 + tags: [transcription, vtt, whisper, web] + estimated_runtime: slow + supported_inputs: [video, audio, url, ytdlp] + supported_outputs: [vtt] + parameters: + model: + type: string + default: small + enum: [tiny, base, small, medium, large-v3] + pipeline: + - {operation: transcribe, model: "{{ model }}", output_format: vtt} + output: {format: vtt} + examples: + - input: {url: "https://example.com/webinar.mp4"} + parameters: {model: small} + + - id: transcribe_json + name: JSON Transcription + category: subtitles + description: Transcribe media into structured JSON with timestamped segments and detected language. + author: Enterprise Media API + version: 1 + tags: [transcription, json, whisper, automation] + estimated_runtime: slow + supported_inputs: [video, audio, url, ytdlp] + supported_outputs: [json] + parameters: + model: + type: string + default: small + enum: [tiny, base, small, medium, large-v3] + pipeline: + - {operation: transcribe, model: "{{ model }}", output_format: json} + output: {format: json} + examples: + - input: {url: "https://example.com/call.mp3"} + parameters: {model: small} + + - id: youtube_to_transcript + name: YouTube to Transcript + category: subtitles + description: Download a supported YouTube URL through the shared resolver and return a plain-text transcript. + author: Enterprise Media API + version: 1 + tags: [youtube, transcription, whisper, ytdlp] + estimated_runtime: slow + supported_inputs: [ytdlp, url] + supported_outputs: [txt] + parameters: + model: + type: string + default: small + enum: [tiny, base, small, medium, large-v3] + pipeline: + - {operation: transcribe, model: "{{ model }}", output_format: txt} + output: {format: txt} + examples: + - input: {url: "https://www.youtube.com/watch?v=VIDEO_ID"} + parameters: {model: small} diff --git a/app/templates/categories/utility/templates.yaml b/app/templates/categories/utility/templates.yaml new file mode 100644 index 0000000000000000000000000000000000000000..e6388a34659539c3ebba1dbb93cb5c372148eee6 --- /dev/null +++ b/app/templates/categories/utility/templates.yaml @@ -0,0 +1,170 @@ +templates: + - id: thumbnail_pack + name: Thumbnail Pack + category: utility + description: Extract a bounded set of representative JPEG frames and return them as a ZIP archive. + author: Enterprise Media API + version: 1 + tags: [thumbnail, frames, zip, utility] + estimated_runtime: medium + supported_inputs: [video, url] + supported_outputs: [zip] + parameters: + fps: {type: number, default: 0.1, minimum: 0.01, maximum: 10} + max_frames: {type: integer, default: 12, minimum: 1, maximum: 500} + pipeline: [{operation: extract_frames, fps: "{{ fps }}", max_frames: "{{ max_frames }}"}] + output: {format: zip} + examples: + - input: {url: "https://example.com/video.mp4"} + parameters: {fps: 0.1, max_frames: 12} + + - id: extract_frames + name: Extract Frames + category: utility + description: Extract JPEG frames at a configurable rate and package them as a ZIP archive. + author: Enterprise Media API + version: 1 + tags: [frames, extraction, zip, video] + estimated_runtime: medium + supported_inputs: [video, url] + supported_outputs: [zip] + parameters: + fps: {type: number, default: 1, minimum: 0.01, maximum: 60} + max_frames: {type: integer, default: 100, minimum: 1, maximum: 10000} + pipeline: [{operation: extract_frames, fps: "{{ fps }}", max_frames: "{{ max_frames }}"}] + output: {format: zip} + examples: + - input: {url: "https://example.com/video.mp4"} + parameters: {fps: 1, max_frames: 100} + + - id: extract_audio + name: Extract Audio + category: utility + description: Extract a video's audio track into a selected supported audio format. + author: Enterprise Media API + version: 1 + tags: [audio, extraction, video, utility] + estimated_runtime: fast + supported_inputs: [video, url, ytdlp] + supported_outputs: [mp3, wav, aac, m4a, flac, ogg, opus] + parameters: + format: + type: string + default: mp3 + enum: [mp3, wav, aac, m4a, flac, ogg, opus] + pipeline: [{operation: extract_audio, format: "{{ format }}"}] + output: {format: "{{ format }}"} + examples: + - input: {url: "https://example.com/video.mp4"} + parameters: {format: mp3} + + - id: merge_videos + name: Merge Videos + category: utility + description: Normalize dimensions and merge two or more videos into one MP4. + author: Enterprise Media API + version: 1 + tags: [merge, videos, normalize, utility] + estimated_runtime: slow + supported_inputs: [video, multiple] + supported_outputs: [mp4] + parameters: + width: {type: integer, default: 1280, minimum: 2, maximum: 7680} + height: {type: integer, default: 720, minimum: 2, maximum: 4320} + pipeline: + - operation: merge_videos + inputs: [originals] + width: "{{ width }}" + height: "{{ height }}" + output: {format: mp4} + examples: + - inputs: + - {url: "https://example.com/part-1.mp4"} + - {url: "https://example.com/part-2.mp4"} + parameters: {width: 1280, height: 720} + + - id: concat_videos + name: Concatenate Videos + category: utility + description: Concatenate multiple videos safely, re-encoding by default for stream compatibility. + author: Enterprise Media API + version: 1 + tags: [concat, videos, sequence, utility] + estimated_runtime: slow + supported_inputs: [video, multiple] + supported_outputs: [mp4] + parameters: + stream_copy: {type: boolean, default: false} + width: {type: integer, default: 1280, minimum: 2, maximum: 7680} + height: {type: integer, default: 720, minimum: 2, maximum: 4320} + pipeline: + - operation: concat_videos + inputs: [originals] + stream_copy: "{{ stream_copy }}" + width: "{{ width }}" + height: "{{ height }}" + output: {format: mp4} + examples: + - inputs: + - {url: "https://example.com/part-1.mp4"} + - {url: "https://example.com/part-2.mp4"} + parameters: {stream_copy: false} + + - id: compress_max + name: Maximum Compression + category: utility + description: Produce a small MP4 using a high CRF and constrained width for bandwidth-sensitive delivery. + author: Enterprise Media API + version: 1 + tags: [compress, small, mobile, bandwidth] + estimated_runtime: fast + supported_inputs: [video, url] + supported_outputs: [mp4] + parameters: + max_width: {type: integer, default: 854, minimum: 2, maximum: 7680} + pipeline: + - {operation: compress, crf: 32, preset: veryfast, max_width: "{{ max_width }}", format: mp4} + output: {format: mp4} + examples: + - input: {url: "https://example.com/video.mp4"} + parameters: {max_width: 854} + + - id: compress_balanced + name: Balanced Compression + category: utility + description: Compress video with a balanced quality and output-size preset. + author: Enterprise Media API + version: 1 + tags: [compress, balanced, mp4, utility] + estimated_runtime: medium + supported_inputs: [video, url] + supported_outputs: [mp4] + parameters: + crf: {type: integer, default: 25, minimum: 0, maximum: 51} + max_width: {type: integer, default: 1920, minimum: 2, maximum: 7680} + pipeline: + - {operation: compress, crf: "{{ crf }}", preset: medium, max_width: "{{ max_width }}", format: mp4} + output: {format: mp4} + examples: + - input: {url: "https://example.com/video.mp4"} + parameters: {crf: 25, max_width: 1920} + + - id: compress_mobile + name: Mobile Compression + category: utility + description: Resize and compress video for efficient mobile playback. + author: Enterprise Media API + version: 1 + tags: [compress, mobile, 720p, bandwidth] + estimated_runtime: fast + supported_inputs: [video, url] + supported_outputs: [mp4] + parameters: + crf: {type: integer, default: 28, minimum: 0, maximum: 51} + pipeline: + - {operation: resize, width: 1280, height: 720, fit: contain} + - {operation: compress, crf: "{{ crf }}", preset: veryfast, max_width: 1280, format: mp4} + output: {format: mp4} + examples: + - input: {url: "https://example.com/video.mp4"} + parameters: {crf: 28} diff --git a/app/templates/categories/youtube/templates.yaml b/app/templates/categories/youtube/templates.yaml new file mode 100644 index 0000000000000000000000000000000000000000..de3ea1b47f6409d081602ef3ffc64cb3d89ea3ab --- /dev/null +++ b/app/templates/categories/youtube/templates.yaml @@ -0,0 +1,103 @@ +templates: + - id: youtube_to_mp3 + name: YouTube to MP3 + category: youtube + description: Download a supported YouTube URL and extract a 192 kbps MP3 through existing services. + author: Enterprise Media API + version: 1 + tags: [youtube, mp3, audio, ytdlp] + estimated_runtime: medium + supported_inputs: [ytdlp, url, video] + supported_outputs: [mp3] + parameters: {} + pipeline: + - {operation: extract_audio, format: mp3} + output: {format: mp3} + examples: + - input: {url: "https://www.youtube.com/watch?v=VIDEO_ID"} + parameters: {} + + - id: youtube_to_audio + name: YouTube to Audio + category: youtube + description: Download a supported YouTube URL and convert its audio to a selected format. + author: Enterprise Media API + version: 1 + tags: [youtube, audio, conversion, ytdlp] + estimated_runtime: medium + supported_inputs: [ytdlp, url, video] + supported_outputs: [mp3, wav, aac, m4a, flac, ogg, opus] + parameters: + format: + type: string + default: m4a + enum: [mp3, wav, aac, m4a, flac, ogg, opus] + pipeline: + - {operation: extract_audio, format: "{{ format }}"} + output: {format: "{{ format }}"} + examples: + - input: {url: "https://www.youtube.com/watch?v=VIDEO_ID"} + parameters: {format: m4a} + + - id: youtube_to_shorts + name: YouTube to Shorts + category: youtube + description: Download supported YouTube media and create a trimmed vertical Short. + author: Enterprise Media API + version: 1 + tags: [youtube, shorts, vertical, ytdlp] + estimated_runtime: slow + supported_inputs: [ytdlp, url, video] + supported_outputs: [mp4] + parameters: + start: {type: number, default: 0, minimum: 0, maximum: 86400} + duration: {type: number, default: 60, minimum: 1, maximum: 180} + crf: {type: integer, default: 24, minimum: 0, maximum: 51} + pipeline: + - {operation: trim, start: "{{ start }}", duration: "{{ duration }}"} + - {operation: resize, width: 1080, height: 1920, fit: cover} + - {operation: fps, fps: 30} + - {operation: compress, crf: "{{ crf }}", preset: veryfast, format: mp4} + output: {format: mp4} + examples: + - input: {url: "https://www.youtube.com/watch?v=VIDEO_ID"} + parameters: {start: 30, duration: 60} + + - id: youtube_to_podcast + name: YouTube to Podcast Audio + category: youtube + description: Download supported YouTube media, extract speech audio, normalize loudness, and encode MP3. + author: Enterprise Media API + version: 1 + tags: [youtube, podcast, normalize, mp3] + estimated_runtime: slow + supported_inputs: [ytdlp, url, video] + supported_outputs: [mp3] + parameters: + target_lufs: {type: number, default: -16, minimum: -70, maximum: -5} + pipeline: + - {operation: extract_audio, format: wav} + - {operation: normalize_audio, target_lufs: "{{ target_lufs }}"} + - {operation: convert_audio, format: mp3} + output: {format: mp3} + examples: + - input: {url: "https://www.youtube.com/watch?v=VIDEO_ID"} + parameters: {target_lufs: -16} + + - id: download_only + name: Download Only + category: youtube + description: Download supported yt-dlp media through the shared input resolver without re-encoding it. + author: Enterprise Media API + version: 1 + tags: [youtube, download, ytdlp, source] + estimated_runtime: fast + supported_inputs: [ytdlp, url] + supported_outputs: [source] + parameters: {} + pipeline: + - {operation: download} + output: {format: source} + examples: + - input: {url: "https://www.youtube.com/watch?v=VIDEO_ID"} + parameters: {} diff --git a/app/templates/executor.py b/app/templates/executor.py new file mode 100644 index 0000000000000000000000000000000000000000..608b99109c4538f2e5bd208d6650fd3ee9a837ae --- /dev/null +++ b/app/templates/executor.py @@ -0,0 +1,459 @@ +from __future__ import annotations + +import os +import time +from collections.abc import Mapping +from dataclasses import dataclass +from pathlib import Path +from types import MappingProxyType +from typing import Literal + +from app.core.exceptions import ( + MediaAPIError, + TemplateExecutionError, + TemplateValidationError, +) +from app.core.logger import get_logger +from app.core.response import SuccessResponse +from app.models.media import InputMedia, MediaSource, OperationResult, ResolvedRequest +from app.operations.compress import compress_video, normalize_audio, normalize_video +from app.operations.concat import ( + concat_audio, + concat_video, + image_sequence, + image_slideshow, + image_to_video, +) +from app.operations.convert import convert_audio, convert_image, convert_video +from app.operations.crop import crop_image, crop_video +from app.operations.extract_audio import ( + extract_audio, + fade_audio, + mute_video, + noise_reduction, + remove_audio, + remove_silence, + replace_audio, + set_volume, +) +from app.operations.merge import merge_audio, merge_video +from app.operations.resize import pad_video, resize_image, resize_video, scale_video +from app.operations.rotate import ( + change_bitrate, + change_fps, + change_speed, + reverse_video, + rotate_video, + slow_motion, +) +from app.operations.subtitles import burn_subtitles, soft_subtitles +from app.operations.thumbnails import ( + blur_video, + denoise_video, + extract_frames, + generate_gif, + sharpen_video, + thumbnail, +) +from app.operations.trim import trim_audio, trim_video +from app.operations.watermark import ( + overlay_image, + overlay_video, + watermark_image, + watermark_video, +) +from app.services.media_service import MediaProcessor, Operation +from app.templates.models import PreparedTemplate, ResolvedPipelineStep +from app.templates.registry import TemplateRegistry + +logger = get_logger(__name__) + +OperationKind = Literal["ffmpeg", "whisper", "passthrough"] +InputMode = Literal["current", "all"] + + +@dataclass(frozen=True, slots=True) +class OperationBinding: + """Binding from a safe YAML operation name to shared application behavior.""" + + kind: OperationKind + handler: Operation | None = None + input_mode: InputMode = "current" + whisper_task: Literal["transcribe", "translate"] = "transcribe" + aliases: tuple[tuple[str, str], ...] = () + + +def _ffmpeg( + handler: Operation, + *, + input_mode: InputMode = "current", + aliases: tuple[tuple[str, str], ...] = (), +) -> OperationBinding: + return OperationBinding(kind="ffmpeg", handler=handler, input_mode=input_mode, aliases=aliases) + + +OPERATION_BINDINGS: Mapping[str, OperationBinding] = MappingProxyType( + { + "compress": _ffmpeg(compress_video), + "compress_video": _ffmpeg(compress_video), + "resize": _ffmpeg(resize_video), + "resize_video": _ffmpeg(resize_video), + "crop": _ffmpeg(crop_video), + "crop_video": _ffmpeg(crop_video), + "trim": _ffmpeg(trim_video), + "trim_video": _ffmpeg(trim_video), + "rotate": _ffmpeg(rotate_video), + "reverse": _ffmpeg(reverse_video), + "merge": _ffmpeg(merge_video, input_mode="all"), + "merge_videos": _ffmpeg(merge_video, input_mode="all"), + "concat": _ffmpeg(concat_video, input_mode="all"), + "concat_videos": _ffmpeg(concat_video, input_mode="all"), + "convert": _ffmpeg(convert_video), + "convert_video": _ffmpeg(convert_video), + "overlay": _ffmpeg(overlay_video, input_mode="all"), + "overlay_video": _ffmpeg(overlay_video, input_mode="all"), + "watermark": _ffmpeg(watermark_video, input_mode="all"), + "watermark_video": _ffmpeg(watermark_video, input_mode="all"), + "extract_frames": _ffmpeg(extract_frames), + "generate_gif": _ffmpeg(generate_gif), + "thumbnail": _ffmpeg(thumbnail), + "replace_audio": _ffmpeg(replace_audio, input_mode="all"), + "remove_audio": _ffmpeg(remove_audio), + "mute": _ffmpeg(mute_video), + "speed": _ffmpeg(change_speed), + "slow_motion": _ffmpeg(slow_motion), + "fps": _ffmpeg(change_fps, aliases=(("value", "fps"),)), + "bitrate": _ffmpeg(change_bitrate, aliases=(("value", "bitrate"),)), + "burn_subtitles": _ffmpeg(burn_subtitles, input_mode="all"), + "soft_subtitles": _ffmpeg(soft_subtitles, input_mode="all"), + "scale": _ffmpeg(scale_video), + "pad": _ffmpeg(pad_video), + "blur": _ffmpeg(blur_video), + "sharpen": _ffmpeg(sharpen_video), + "denoise": _ffmpeg(denoise_video), + "normalize_video": _ffmpeg(normalize_video), + "extract_audio": _ffmpeg(extract_audio), + "convert_audio": _ffmpeg(convert_audio), + "normalize_audio": _ffmpeg(normalize_audio), + "trim_audio": _ffmpeg(trim_audio), + "merge_audio": _ffmpeg(merge_audio, input_mode="all"), + "concat_audio": _ffmpeg(concat_audio, input_mode="all"), + "fade_audio": _ffmpeg(fade_audio), + "volume": _ffmpeg(set_volume), + "remove_silence": _ffmpeg(remove_silence), + "noise_reduction": _ffmpeg(noise_reduction), + "resize_image": _ffmpeg(resize_image), + "crop_image": _ffmpeg(crop_image), + "convert_image": _ffmpeg(convert_image), + "slideshow": _ffmpeg(image_slideshow, input_mode="all"), + "image_sequence": _ffmpeg(image_sequence, input_mode="all"), + "image_to_video": _ffmpeg(image_to_video), + "watermark_image": _ffmpeg(watermark_image, input_mode="all"), + "overlay_image": _ffmpeg(overlay_image, input_mode="all"), + "transcribe": OperationBinding(kind="whisper", whisper_task="transcribe"), + "translate": OperationBinding(kind="whisper", whisper_task="translate"), + "download": OperationBinding(kind="passthrough"), + } +) + + +class OperationExecutor: + """Executes allow-listed YAML operations through existing implementations.""" + + def __init__(self, processor: MediaProcessor) -> None: + self.processor = processor + + @property + def supported_operations(self) -> set[str]: + """Return operation names that are safe for template YAML.""" + return set(OPERATION_BINDINGS) + + def input_mode(self, operation: str) -> InputMode: + """Return the default input selection mode for an operation.""" + return self._binding(operation).input_mode + + async def execute( + self, + operation: str, + inputs: list[InputMedia], + parameters: dict[str, object], + output_dir: Path, + ) -> OperationResult: + """Execute one pipeline step without publishing its intermediate output.""" + binding = self._binding(operation) + normalized_parameters = dict(parameters) + for source, destination in binding.aliases: + if source in normalized_parameters and destination not in normalized_parameters: + normalized_parameters[destination] = normalized_parameters[source] + if binding.kind == "passthrough": + if not inputs: + raise TemplateExecutionError("Download step requires an input") + media = inputs[0] + return OperationResult( + path=media.temp_path, + filename=media.filename, + mime_type=media.mime_type, + metadata={"operation": "download", **media.metadata}, + ) + if binding.kind == "whisper": + normalized_parameters["task"] = binding.whisper_task + return await self.processor.transcribe_result(inputs, normalized_parameters, output_dir) + if binding.handler is None: # pragma: no cover - guarded by static bindings + raise TemplateExecutionError("Template operation has no implementation") + return await binding.handler( + self.processor.ffmpeg, inputs, normalized_parameters, output_dir + ) + + @staticmethod + def _binding(operation: str) -> OperationBinding: + binding = OPERATION_BINDINGS.get(operation) + if binding is None: + raise TemplateValidationError(f"Unsupported template operation '{operation}'") + return binding + + +class TemplateExecutor: + """Runs validated template pipelines over normalized InputMedia instances.""" + + def __init__( + self, + registry: TemplateRegistry, + operation_executor: OperationExecutor, + processor: MediaProcessor, + ) -> None: + self.registry = registry + self.operation_executor = operation_executor + self.processor = processor + + async def execute_request(self, resolved: ResolvedRequest) -> SuccessResponse: + """Execute template controls parsed by the shared InputResolver.""" + reference = resolved.params.get("template") + if not isinstance(reference, str) or not reference.strip(): + raise TemplateValidationError("A non-empty 'template' reference is required") + parameters = resolved.params.get("parameters", {}) + if not isinstance(parameters, dict): + raise TemplateValidationError("Template 'parameters' must be an object") + return await self.execute(resolved, reference, parameters) + + async def execute( + self, + resolved: ResolvedRequest, + template_reference: str, + parameters: dict[str, object] | None = None, + ) -> SuccessResponse: + """Execute a versioned template and publish only its final artifact.""" + started = time.monotonic() + cpu_started = time.process_time() + prepared: PreparedTemplate | None = None + operations: list[str] = [] + try: + prepared = self.registry.prepare(template_reference, parameters) + workspace = await self.processor.cleanup.create_workspace(resolved.request_id) + initial_metadata = await self.processor.probe_inputs(resolved.inputs) + originals = list(resolved.inputs) + current = originals[0] + artifacts: dict[str, InputMedia] = {} + for index, step in enumerate(prepared.pipeline): + if not step.enabled: + continue + selected = self._select_inputs(step, current, originals, artifacts) + step_dir = workspace.outputs / f"{index + 1:03d}_{step.operation}" + result = await self.operation_executor.execute( + step.operation, selected, step.parameters, step_dir + ) + current = self._result_media(result) + if self._probeable(current): + await self.processor.probe_inputs([current]) + if step.save_as: + artifacts[step.save_as] = current + operations.append(step.operation) + if not operations: + raise TemplateExecutionError("All template pipeline operations were disabled") + self._validate_output(prepared, current) + result = OperationResult( + path=current.temp_path, + filename=prepared.output.filename or current.filename, + mime_type=current.mime_type, + metadata=current.metadata, + ) + response = await self.processor.finish_result( + resolved, + f"template.{prepared.definition.id}", + result, + started, + initial_metadata, + extra_metadata={ + "template": { + "id": prepared.definition.id, + "version": prepared.definition.version, + "reference": (f"{prepared.definition.id}@{prepared.definition.version}"), + "category": prepared.definition.category, + }, + "parameters": prepared.parameters, + "operations": operations, + }, + ) + logger.info( + "template execution completed", + extra=self._log_data( + prepared, + resolved, + operations, + started, + cpu_started, + response.metadata.get("output_size", 0), + template_reference, + parameters, + ), + ) + return response + except MediaAPIError as exc: + logger.warning( + "template execution failed", + extra={ + **self._log_data( + prepared, + resolved, + operations, + started, + cpu_started, + 0, + template_reference, + parameters, + ), + "error_code": exc.code, + "error": exc.message, + }, + ) + raise + except Exception: + logger.exception( + "unexpected template execution error", + extra=self._log_data( + prepared, + resolved, + operations, + started, + cpu_started, + 0, + template_reference, + parameters, + ), + ) + raise + finally: + try: + await self.processor.cleanup.complete(resolved.request_id) + except Exception: + logger.exception( + "template workspace completion failed", + extra={"request_id": resolved.request_id}, + ) + + def _select_inputs( + self, + step: ResolvedPipelineStep, + current: InputMedia, + originals: list[InputMedia], + artifacts: dict[str, InputMedia], + ) -> list[InputMedia]: + if step.inputs is None: + if self.operation_executor.input_mode(step.operation) == "all": + return [current, *originals[1:]] + return [current] + selected: list[InputMedia] = [] + for selector in step.inputs: + if selector == "current": + selected.append(current) + elif selector == "original": + selected.append(originals[0]) + elif selector == "originals": + selected.extend(originals) + elif selector.startswith("original:"): + index = int(selector.split(":", 1)[1]) + try: + selected.append(originals[index]) + except IndexError as exc: + raise TemplateExecutionError( + f"Template requires original input index {index}" + ) from exc + else: + name = selector.split(":", 1)[1] + try: + selected.append(artifacts[name]) + except KeyError as exc: # pragma: no cover - definition validation guards order + raise TemplateExecutionError( + f"Template artifact '{name}' is unavailable" + ) from exc + if not selected: + raise TemplateExecutionError("Template operation selected no inputs") + return selected + + def _result_media(self, result: OperationResult) -> InputMedia: + if result.path is None or not result.path.is_file(): + raise TemplateExecutionError("A template operation did not produce a file artifact") + filename = result.filename or result.path.name + mime_type = result.mime_type or self.processor.validator.infer_mime(result.path) + return InputMedia( + source=MediaSource.LOCAL_PATH, + filename=filename, + mime_type=mime_type, + temp_path=result.path, + size=result.path.stat().st_size, + metadata=result.metadata, + ) + + @staticmethod + def _probeable(media: InputMedia) -> bool: + return media.mime_type.startswith(("video/", "audio/", "image/")) + + @staticmethod + def _validate_output(prepared: PreparedTemplate, media: InputMedia) -> None: + if prepared.output.filename: + filename = prepared.output.filename + if Path(filename).name != filename or len(filename) > 255: + raise TemplateValidationError("Template output filename must be a safe basename") + expected = prepared.output.format.lower().lstrip(".") + if expected == "source": + return + actual = media.temp_path.suffix.lower().lstrip(".") + aliases = {"jpeg": "jpg", "m4a": "m4a"} + if aliases.get(actual, actual) != aliases.get(expected, expected): + raise TemplateExecutionError( + "Template output did not match its declared format", + details={"expected": expected, "actual": actual}, + ) + + @staticmethod + def _log_data( + prepared: PreparedTemplate | None, + resolved: ResolvedRequest, + operations: list[str], + started: float, + cpu_started: float, + output_size: object, + template_reference: str, + supplied_parameters: dict[str, object] | None, + ) -> dict[str, object]: + memory: int | None = None + cpu_percent: float | None = None + try: + import psutil + + memory = psutil.Process(os.getpid()).memory_info().rss + cpu_percent = psutil.cpu_percent(interval=None) + except ImportError: + pass + return { + "template_id": prepared.definition.id if prepared else template_reference, + "template_version": prepared.definition.version if prepared else None, + "template_reference": template_reference, + "parameters": prepared.parameters if prepared else supplied_parameters or {}, + "request_id": resolved.request_id, + "operations": operations, + "duration": round(time.monotonic() - started, 4), + "cpu_time": round(time.process_time() - cpu_started, 6), + "cpu_percent": cpu_percent, + "memory_bytes": memory, + "output_size": output_size, + } diff --git a/app/templates/loader.py b/app/templates/loader.py new file mode 100644 index 0000000000000000000000000000000000000000..19a0383f1cc2703dda4432de1d9b61bdbd5a6567 --- /dev/null +++ b/app/templates/loader.py @@ -0,0 +1,109 @@ +from __future__ import annotations + +from pathlib import Path +from typing import Any + +import yaml +from pydantic import ValidationError + +from app.core.exceptions import TemplateValidationError +from app.core.logger import get_logger +from app.templates.schema import TemplateDefinition +from app.templates.validator import TemplateValidator + +logger = get_logger(__name__) + + +class TemplateLoader: + """Recursively loads and validates safe YAML template documents.""" + + MAX_TEMPLATE_BYTES = 1_048_576 + + def __init__(self, root: Path, validator: TemplateValidator) -> None: + self.root = root.resolve() + self.validator = validator + + def load(self) -> list[TemplateDefinition]: + """Scan the configured directory and return every valid definition.""" + if not self.root.is_dir(): + raise TemplateValidationError( + "Template directory does not exist", details={"path": str(self.root)} + ) + definitions: list[TemplateDefinition] = [] + try: + paths = sorted((*self.root.rglob("*.yaml"), *self.root.rglob("*.yml"))) + except OSError as exc: + raise TemplateValidationError( + "Template directory could not be scanned", + details={"path": str(self.root)}, + ) from exc + for path in paths: + definitions.extend(self._load_file(path)) + if not definitions: + raise TemplateValidationError( + "No YAML templates were found", details={"path": str(self.root)} + ) + logger.info( + "templates loaded", + extra={"path": str(self.root), "templates": len(definitions)}, + ) + return definitions + + def _load_file(self, path: Path) -> list[TemplateDefinition]: + resolved_path = path.resolve() + if self.root not in resolved_path.parents: + raise TemplateValidationError( + "Template YAML path escapes TEMPLATE_DIR", + details={"path": str(path)}, + ) + try: + size = resolved_path.stat().st_size + except OSError as exc: + raise TemplateValidationError( + "Template YAML file could not be inspected", + details={"path": str(path)}, + ) from exc + if size > self.MAX_TEMPLATE_BYTES: + raise TemplateValidationError( + "Template YAML file is too large", details={"path": str(path)} + ) + try: + documents = list(yaml.safe_load_all(resolved_path.read_text(encoding="utf-8"))) + except (OSError, UnicodeError, yaml.YAMLError) as exc: + raise TemplateValidationError( + "Template YAML syntax is invalid", details={"path": str(path)} + ) from exc + raw_templates: list[Any] = [] + for document in documents: + if document is None: + continue + if isinstance(document, dict) and set(document) == {"templates"}: + collection = document["templates"] + if not isinstance(collection, list): + raise TemplateValidationError( + "The templates YAML key must contain a list", + details={"path": str(path)}, + ) + raw_templates.extend(collection) + elif isinstance(document, list): + raw_templates.extend(document) + else: + raw_templates.append(document) + definitions: list[TemplateDefinition] = [] + for index, raw in enumerate(raw_templates): + try: + definition = TemplateDefinition.model_validate(raw) + self.validator.validate_definition(definition) + except ValidationError as exc: + raise TemplateValidationError( + "Template YAML does not match the schema", + details={ + "path": str(path), + "document": index, + "errors": exc.errors( + include_url=False, include_context=False, include_input=False + ), + }, + ) from exc + definitions.append(definition) + return definitions diff --git a/app/templates/models.py b/app/templates/models.py new file mode 100644 index 0000000000000000000000000000000000000000..f61a147a0eba485f53f5b50bcad15efe79f76b8e --- /dev/null +++ b/app/templates/models.py @@ -0,0 +1,52 @@ +from __future__ import annotations + +import re +from dataclasses import dataclass +from typing import Any + +from pydantic import BaseModel, ConfigDict, Field + +from app.core.exceptions import TemplateValidationError +from app.templates.schema import OutputDefinition, TemplateDefinition + + +@dataclass(frozen=True, slots=True) +class TemplateReference: + """Parsed `template_id@version` or `template_id@latest` reference.""" + + template_id: str + version: int | None + + @classmethod + def parse(cls, value: str) -> TemplateReference: + normalized = value.strip().lower() + match = re.fullmatch(r"([a-z][a-z0-9_]*)(?:@([1-9][0-9]*|latest))?", normalized) + if not match: + raise TemplateValidationError( + "Template reference must use id, id@version, or id@latest" + ) + version_text = match.group(2) + version = None if version_text in {None, "latest"} else int(version_text) + return cls(template_id=match.group(1), version=version) + + +class ResolvedPipelineStep(BaseModel): + """Pipeline step after safe parameter substitution.""" + + model_config = ConfigDict(extra="forbid") + + operation: str + parameters: dict[str, Any] = Field(default_factory=dict) + enabled: bool = True + inputs: list[str] | None = None + save_as: str | None = None + + +@dataclass(frozen=True, slots=True) +class PreparedTemplate: + """Immutable runtime-ready template and resolved parameter values.""" + + definition: TemplateDefinition + parameters: dict[str, Any] + pipeline: tuple[ResolvedPipelineStep, ...] + output: OutputDefinition diff --git a/app/templates/registry.py b/app/templates/registry.py new file mode 100644 index 0000000000000000000000000000000000000000..43a6acd8c3d9f5f49f27e9d05863c6275f0bed69 --- /dev/null +++ b/app/templates/registry.py @@ -0,0 +1,95 @@ +from __future__ import annotations + +from collections.abc import Mapping +from types import MappingProxyType +from typing import Any + +from app.core.exceptions import TemplateNotFoundError, TemplateValidationError +from app.templates.loader import TemplateLoader +from app.templates.models import PreparedTemplate, TemplateReference +from app.templates.schema import TemplateDefinition +from app.templates.validator import TemplateValidator + + +class TemplateRegistry: + """Immutable version-aware registry populated exclusively from YAML files.""" + + def __init__(self, loader: TemplateLoader, validator: TemplateValidator) -> None: + self.validator = validator + versions: dict[str, dict[int, TemplateDefinition]] = {} + for definition in loader.load(): + by_version = versions.setdefault(definition.id, {}) + if definition.version in by_version: + raise TemplateValidationError( + f"Duplicate template '{definition.id}@{definition.version}'" + ) + by_version[definition.version] = definition + self._templates = MappingProxyType( + { + template_id: MappingProxyType(dict(sorted(by_version.items()))) + for template_id, by_version in sorted(versions.items()) + } + ) + + @property + def count(self) -> int: + """Return the number of registered template versions.""" + return sum(len(versions) for versions in self._templates.values()) + + def get(self, reference: str) -> TemplateDefinition: + """Resolve an ID, explicit version, or latest-version reference.""" + parsed = TemplateReference.parse(reference) + versions = self._templates.get(parsed.template_id) + if not versions: + raise TemplateNotFoundError(f"Template '{parsed.template_id}' was not found") + version = parsed.version if parsed.version is not None else max(versions) + template = versions.get(version) + if template is None: + raise TemplateNotFoundError(f"Template '{parsed.template_id}@{version}' was not found") + return template + + def prepare( + self, reference: str, parameters: Mapping[str, Any] | None = None + ) -> PreparedTemplate: + """Resolve a template and render its validated runtime pipeline.""" + return self.validator.prepare(self.get(reference), parameters) + + def list_templates(self, category: str | None = None) -> list[dict[str, Any]]: + """Return discoverable metadata for all registered versions.""" + items: list[dict[str, Any]] = [] + normalized_category = category.strip().lower() if category else None + for versions in self._templates.values(): + latest = max(versions) + for version, template in versions.items(): + if normalized_category and template.category != normalized_category: + continue + items.append(self._metadata(template, latest == version, details=False)) + return items + + def template_details(self, reference: str) -> dict[str, Any]: + """Return complete metadata and rendered-independent pipeline documentation.""" + template = self.get(reference) + latest = max(self._templates[template.id]) == template.version + return self._metadata(template, latest, details=True) + + def categories(self) -> list[str]: + """Return categories discovered from registered YAML definitions.""" + return sorted( + { + template.category + for versions in self._templates.values() + for template in versions.values() + } + ) + + @staticmethod + def _metadata(template: TemplateDefinition, latest: bool, *, details: bool) -> dict[str, Any]: + payload = template.model_dump(mode="json") + if not details: + payload.pop("pipeline", None) + payload.pop("output", None) + return { + **payload, + "reference": f"{template.id}@{template.version}", + "latest": latest, + } diff --git a/app/templates/schema.py b/app/templates/schema.py new file mode 100644 index 0000000000000000000000000000000000000000..1034abfdf9105c17740a65138eacdf0e1fc26f2b --- /dev/null +++ b/app/templates/schema.py @@ -0,0 +1,114 @@ +from __future__ import annotations + +import re +from enum import Enum +from typing import Any + +from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator + +IDENTIFIER_PATTERN = r"^[a-z][a-z0-9_]*$" + + +class ParameterType(str, Enum): + """Supported runtime parameter types for YAML templates.""" + + STRING = "string" + INTEGER = "integer" + NUMBER = "number" + BOOLEAN = "boolean" + ARRAY = "array" + OBJECT = "object" + + +class ParameterDefinition(BaseModel): + """Declarative validation contract for one runtime template parameter.""" + + model_config = ConfigDict(extra="forbid") + + type: ParameterType + description: str = "" + required: bool = Field(default=False, strict=True) + default: Any = None + enum: list[Any] | None = None + minimum: float | None = None + maximum: float | None = None + min_length: int | None = Field(default=None, ge=0) + max_length: int | None = Field(default=None, ge=0) + + @property + def has_default(self) -> bool: + """Return whether YAML explicitly supplied a default value.""" + return "default" in self.model_fields_set + + @model_validator(mode="after") + def validate_bounds(self) -> ParameterDefinition: + if self.minimum is not None and self.maximum is not None: + if self.minimum > self.maximum: + raise ValueError("minimum must not exceed maximum") + if self.min_length is not None and self.max_length is not None: + if self.min_length > self.max_length: + raise ValueError("min_length must not exceed max_length") + return self + + +class PipelineStep(BaseModel): + """One operation invocation in a template pipeline.""" + + model_config = ConfigDict(extra="allow") + + operation: str = Field(pattern=IDENTIFIER_PATTERN) + when: Any = True + inputs: list[str] | None = None + save_as: str | None = Field(default=None, pattern=IDENTIFIER_PATTERN) + + def operation_parameters(self) -> dict[str, Any]: + """Return operation arguments declared as extra YAML keys.""" + return dict(self.model_extra or {}) + + +class OutputDefinition(BaseModel): + """Declared output contract for a template.""" + + model_config = ConfigDict(extra="forbid") + + format: str + filename: str | None = None + + +class TemplateDefinition(BaseModel): + """Validated, versioned YAML media workflow definition.""" + + model_config = ConfigDict(extra="forbid") + + id: str = Field(pattern=IDENTIFIER_PATTERN) + name: str = Field(min_length=1, max_length=120) + category: str = Field(pattern=IDENTIFIER_PATTERN) + description: str = Field(min_length=1, max_length=1000) + author: str = Field(min_length=1, max_length=120) + version: int = Field(ge=1, strict=True) + tags: list[str] = Field(min_length=1) + estimated_runtime: str = Field(min_length=1, max_length=80) + supported_inputs: list[str] = Field(min_length=1) + supported_outputs: list[str] = Field(min_length=1) + parameters: dict[str, ParameterDefinition] = Field(default_factory=dict) + pipeline: list[PipelineStep] = Field(min_length=1) + output: OutputDefinition + examples: list[dict[str, Any]] = Field(default_factory=list) + + @field_validator("tags", "supported_inputs", "supported_outputs") + @classmethod + def normalize_string_list(cls, values: list[str]) -> list[str]: + normalized = [value.strip().lower() for value in values if value.strip()] + if not normalized: + raise ValueError("list must contain at least one non-empty value") + return list(dict.fromkeys(normalized)) + + @field_validator("parameters") + @classmethod + def validate_parameter_names( + cls, values: dict[str, ParameterDefinition] + ) -> dict[str, ParameterDefinition]: + for name in values: + if not re.fullmatch(IDENTIFIER_PATTERN, name): + raise ValueError(f"invalid parameter name: {name}") + return values diff --git a/app/templates/validator.py b/app/templates/validator.py new file mode 100644 index 0000000000000000000000000000000000000000..170addb238aa6cd7f9a2ce90648b5cfc5e9ad124 --- /dev/null +++ b/app/templates/validator.py @@ -0,0 +1,250 @@ +from __future__ import annotations + +import copy +import re +from collections.abc import Mapping +from pathlib import Path +from typing import Any + +from pydantic import ValidationError + +from app.core.exceptions import TemplateValidationError +from app.templates.models import PreparedTemplate, ResolvedPipelineStep +from app.templates.schema import ( + OutputDefinition, + ParameterDefinition, + ParameterType, + TemplateDefinition, +) + +VARIABLE_PATTERN = re.compile(r"{{\s*([a-z][a-z0-9_]*)\s*}}") +EXACT_VARIABLE_PATTERN = re.compile(r"^\s*{{\s*([a-z][a-z0-9_]*)\s*}}\s*$") +INPUT_SELECTOR_PATTERN = re.compile( + r"^(?:current|original|originals|original:[0-9]+|artifact:[a-z][a-z0-9_]*)$" +) + + +class TemplateValidator: + """Validates template definitions, parameters, variables, and operation names.""" + + def __init__(self, supported_operations: set[str]) -> None: + self.supported_operations = frozenset(supported_operations) + + def validate_definition(self, template: TemplateDefinition) -> None: + """Fail if a parsed YAML definition is unsafe or internally inconsistent.""" + for name, definition in template.parameters.items(): + if definition.has_default: + self._validate_value(name, definition.default, definition) + known_parameters = set(template.parameters) + saved_artifacts: set[str] = set() + for index, step in enumerate(template.pipeline): + if step.operation not in self.supported_operations: + raise TemplateValidationError( + f"Template '{template.id}' uses unsupported operation '{step.operation}'", + details={"step": index, "operation": step.operation}, + ) + referenced = self._variables(step.when) | self._variables(step.operation_parameters()) + self._validate_variables(referenced, known_parameters, template.id, index) + for parameter in referenced: + definition = template.parameters.get(parameter) + if definition and not definition.required and not definition.has_default: + raise TemplateValidationError( + f"Template '{template.id}' references optional parameter '{parameter}' without a default", + details={"step": index}, + ) + for selector in step.inputs or []: + if not INPUT_SELECTOR_PATTERN.fullmatch(selector): + raise TemplateValidationError( + f"Template '{template.id}' has invalid input selector '{selector}'", + details={"step": index}, + ) + if selector.startswith("artifact:"): + artifact = selector.split(":", 1)[1] + if artifact not in saved_artifacts: + raise TemplateValidationError( + f"Template '{template.id}' references artifact '{artifact}' before it is saved", + details={"step": index}, + ) + if step.save_as: + if step.save_as in saved_artifacts: + raise TemplateValidationError( + f"Template '{template.id}' saves duplicate artifact '{step.save_as}'" + ) + saved_artifacts.add(step.save_as) + output_variables = self._variables(template.output.model_dump()) + self._validate_variables(output_variables, known_parameters, template.id, -1) + for parameter in output_variables: + definition = template.parameters.get(parameter) + if definition and not definition.required and not definition.has_default: + raise TemplateValidationError( + f"Template '{template.id}' output references optional parameter '{parameter}' without a default" + ) + if "{{" not in template.output.format: + output_format = template.output.format.lower().lstrip(".") + if output_format not in template.supported_outputs: + raise TemplateValidationError( + f"Template '{template.id}' output format is not declared in supported_outputs" + ) + + def prepare( + self, template: TemplateDefinition, supplied: Mapping[str, Any] | None + ) -> PreparedTemplate: + """Resolve validated runtime parameters and render a safe pipeline.""" + parameters = self.resolve_parameters(template, supplied) + steps: list[ResolvedPipelineStep] = [] + for step in template.pipeline: + rendered_when = self._substitute(step.when, parameters) + if not isinstance(rendered_when, bool): + raise TemplateValidationError( + f"Template '{template.id}' step condition must resolve to a boolean" + ) + rendered_parameters = self._substitute(step.operation_parameters(), parameters) + steps.append( + ResolvedPipelineStep( + operation=step.operation, + parameters=rendered_parameters, + enabled=rendered_when, + inputs=step.inputs, + save_as=step.save_as, + ) + ) + output_data = self._substitute(template.output.model_dump(), parameters) + try: + output = OutputDefinition.model_validate(output_data) + except ValidationError as exc: + raise TemplateValidationError( + f"Template '{template.id}' output did not resolve to a valid contract" + ) from exc + output_format = output.format.strip().lower().lstrip(".") + if not re.fullmatch(r"[a-z0-9][a-z0-9_+-]*", output_format): + raise TemplateValidationError(f"Template '{template.id}' output format is invalid") + if output_format not in template.supported_outputs: + raise TemplateValidationError( + f"Template '{template.id}' output format is not declared in supported_outputs" + ) + if output.filename: + filename = output.filename + if Path(filename).name != filename or len(filename) > 255: + raise TemplateValidationError( + f"Template '{template.id}' output filename must be a safe basename" + ) + return PreparedTemplate( + definition=template, + parameters=parameters, + pipeline=tuple(steps), + output=output, + ) + + def resolve_parameters( + self, template: TemplateDefinition, supplied: Mapping[str, Any] | None + ) -> dict[str, Any]: + """Apply defaults and enforce declared parameter names and strict types.""" + provided = dict(supplied or {}) + unknown = sorted(set(provided) - set(template.parameters)) + if unknown: + raise TemplateValidationError( + "Unknown template parameter(s)", details={"parameters": unknown} + ) + resolved: dict[str, Any] = {} + for name, definition in template.parameters.items(): + if name in provided: + value = provided[name] + elif definition.has_default: + value = copy.deepcopy(definition.default) + elif definition.required: + raise TemplateValidationError(f"Required template parameter '{name}' is missing") + else: + continue + self._validate_value(name, value, definition) + resolved[name] = value + return resolved + + def _validate_value(self, name: str, value: Any, definition: ParameterDefinition) -> None: + expected = definition.type + valid = { + ParameterType.STRING: isinstance(value, str), + ParameterType.INTEGER: isinstance(value, int) and not isinstance(value, bool), + ParameterType.NUMBER: isinstance(value, (int, float)) and not isinstance(value, bool), + ParameterType.BOOLEAN: isinstance(value, bool), + ParameterType.ARRAY: isinstance(value, list), + ParameterType.OBJECT: isinstance(value, dict), + }[expected] + if not valid: + raise TemplateValidationError(f"Template parameter '{name}' must be {expected.value}") + if definition.enum is not None and value not in definition.enum: + raise TemplateValidationError( + f"Template parameter '{name}' must be one of the declared enum values" + ) + if isinstance(value, (int, float)) and not isinstance(value, bool): + if definition.minimum is not None and value < definition.minimum: + raise TemplateValidationError(f"Template parameter '{name}' is below its minimum") + if definition.maximum is not None and value > definition.maximum: + raise TemplateValidationError(f"Template parameter '{name}' exceeds its maximum") + if isinstance(value, (str, list, dict)): + if definition.min_length is not None and len(value) < definition.min_length: + raise TemplateValidationError( + f"Template parameter '{name}' is shorter than allowed" + ) + if definition.max_length is not None and len(value) > definition.max_length: + raise TemplateValidationError(f"Template parameter '{name}' is longer than allowed") + + def _validate_variables( + self, + variables: set[str], + known_parameters: set[str], + template_id: str, + step: int, + ) -> None: + for variable in variables: + if variable not in known_parameters: + raise TemplateValidationError( + f"Template '{template_id}' references unknown parameter '{variable}'", + details={"step": step}, + ) + + def _variables(self, value: Any) -> set[str]: + if isinstance(value, str): + remainder = VARIABLE_PATTERN.sub("", value) + if "{{" in remainder or "}}" in remainder: + raise TemplateValidationError( + "Template variables must use the exact '{{ parameter }}' syntax" + ) + return set(VARIABLE_PATTERN.findall(value)) + if isinstance(value, dict): + return ( + set().union(*(self._variables(item) for item in value.values())) if value else set() + ) + if isinstance(value, list): + return set().union(*(self._variables(item) for item in value)) if value else set() + return set() + + def _substitute(self, value: Any, parameters: Mapping[str, Any]) -> Any: + if isinstance(value, str): + exact = EXACT_VARIABLE_PATTERN.fullmatch(value) + if exact: + name = exact.group(1) + if name not in parameters: + raise TemplateValidationError( + f"Template parameter '{name}' has no resolved value" + ) + return copy.deepcopy(parameters[name]) + + def replace(match: re.Match[str]) -> str: + name = match.group(1) + if name not in parameters: + raise TemplateValidationError( + f"Template parameter '{name}' has no resolved value" + ) + replacement = parameters[name] + if isinstance(replacement, (dict, list)): + raise TemplateValidationError( + f"Structured parameter '{name}' cannot be embedded in a string" + ) + return str(replacement) + + return VARIABLE_PATTERN.sub(replace, value) + if isinstance(value, dict): + return {key: self._substitute(item, parameters) for key, item in value.items()} + if isinstance(value, list): + return [self._substitute(item, parameters) for item in value] + return copy.deepcopy(value) diff --git a/app/workers/__init__.py b/app/workers/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..70313c6261be6d710151c8e1befef0770b39b9ae --- /dev/null +++ b/app/workers/__init__.py @@ -0,0 +1 @@ +"""Background workers.""" diff --git a/app/workers/cleanup_worker.py b/app/workers/cleanup_worker.py new file mode 100644 index 0000000000000000000000000000000000000000..a4155dbcda7bae610807195fe0ad35dfd3d4a77c --- /dev/null +++ b/app/workers/cleanup_worker.py @@ -0,0 +1,45 @@ +from __future__ import annotations + +import asyncio + +from app.core.logger import get_logger +from app.services.cleanup import CleanupService + +logger = get_logger(__name__) + + +class CleanupWorker: + def __init__(self, service: CleanupService, interval_seconds: int) -> None: + self.service = service + self.interval_seconds = interval_seconds + self._task: asyncio.Task[None] | None = None + self._stop = asyncio.Event() + + async def start(self) -> None: + self._stop.clear() + self._task = asyncio.create_task(self._run(), name="media-cleanup-worker") + + async def stop(self) -> None: + self._stop.set() + if self._task: + self._task.cancel() + try: + await self._task + except asyncio.CancelledError: + logger.debug("cleanup worker task cancelled") + self._task = None + + async def _run(self) -> None: + while not self._stop.is_set(): + try: + removed = await self.service.cleanup_expired() + if removed: + logger.info("cleanup worker removed workspaces", extra={"count": removed}) + except asyncio.CancelledError: + raise + except Exception: + logger.exception("cleanup worker iteration failed") + try: + await asyncio.wait_for(self._stop.wait(), timeout=self.interval_seconds) + except asyncio.TimeoutError: + continue diff --git a/core/__init__.py b/core/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..bd3863139fadb8241362b7c098686e159d45fd32 --- /dev/null +++ b/core/__init__.py @@ -0,0 +1 @@ +"""Compatibility exports for the canonical :mod:`app.core` package.""" diff --git a/core/config.py b/core/config.py new file mode 100644 index 0000000000000000000000000000000000000000..51622ba9c30e621edf5e787caeea502d60d17f2f --- /dev/null +++ b/core/config.py @@ -0,0 +1 @@ +from app.core.config import * # noqa diff --git a/core/exceptions.py b/core/exceptions.py new file mode 100644 index 0000000000000000000000000000000000000000..545f78896e95eade156754424997a7b479cc102d --- /dev/null +++ b/core/exceptions.py @@ -0,0 +1 @@ +from app.core.exceptions import * # noqa diff --git a/core/logger.py b/core/logger.py new file mode 100644 index 0000000000000000000000000000000000000000..6ab232dd4c9848a888847153461c3cc11ef8720f --- /dev/null +++ b/core/logger.py @@ -0,0 +1 @@ +from app.core.logger import * # noqa diff --git a/core/response.py b/core/response.py new file mode 100644 index 0000000000000000000000000000000000000000..9e26ca6dfc9b2682ab07b7f876286268977c97d6 --- /dev/null +++ b/core/response.py @@ -0,0 +1 @@ +from app.core.response import * # noqa diff --git a/main.py b/main.py new file mode 100644 index 0000000000000000000000000000000000000000..ad8e270834c426de06d664d63ef609228899fd40 --- /dev/null +++ b/main.py @@ -0,0 +1,167 @@ +from __future__ import annotations + +import time +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager +from uuid import uuid4 + +from fastapi import FastAPI, HTTPException, Request +from fastapi.exceptions import RequestValidationError +from fastapi.responses import JSONResponse, ORJSONResponse +from starlette.middleware.base import RequestResponseEndpoint +from starlette.responses import Response + +from app.api import audio, health, image, media, probe, templates, video, whisper, ytdlp +from app.container import build_container +from app.core.config import Settings, get_settings +from app.core.exceptions import MediaAPIError +from app.core.logger import configure_logging, get_logger, request_id_context +from app.core.response import ErrorBody, ErrorResponse +from app.mcp.server import create_mcp_server +from app.workers.cleanup_worker import CleanupWorker + +configure_logging() +logger = get_logger(__name__) +try: + __import__("orjson") + + DefaultJSONResponse = ORJSONResponse +except ImportError: # pragma: no cover - production requirements always install orjson + DefaultJSONResponse = JSONResponse +try: + import psutil as _psutil +except ImportError: # pragma: no cover - production requirements always install psutil + _psutil = None # type: ignore[assignment] + + +def create_app(settings: Settings | None = None) -> FastAPI: + active_settings = settings or get_settings() + active_settings.ensure_directories() + container = build_container(active_settings) + cleanup_worker = CleanupWorker(container.cleanup, active_settings.cleanup_interval_seconds) + mcp_server = create_mcp_server(container) + mcp_http_app = mcp_server.streamable_http_app() + + @asynccontextmanager + async def lifespan(application: FastAPI) -> AsyncIterator[None]: + application.state.container = container + application.state.mcp_server = mcp_server + async with mcp_server.session_manager.run(): + await cleanup_worker.start() + logger.info( + "media API started", + extra={"version": active_settings.app_version, "port": active_settings.port}, + ) + try: + yield + finally: + await cleanup_worker.stop() + logger.info("media API stopped") + + application = FastAPI( + title=active_settings.app_name, + version=active_settings.app_version, + description=( + "CPU-optimized FFmpeg, yt-dlp, FFprobe, faster-whisper, MCP, and YAML template API." + ), + default_response_class=DefaultJSONResponse, + lifespan=lifespan, + docs_url="/docs", + redoc_url="/redoc", + openapi_url="/openapi.json", + ) + # State is also assigned eagerly so ASGI test clients without lifespan support work. + application.state.container = container + application.state.mcp_server = mcp_server + + @application.middleware("http") + async def request_context(request: Request, call_next: RequestResponseEndpoint) -> Response: + request_id = str(uuid4()) + request.state.request_id = request_id + request.state.operation = f"{request.method} {request.url.path}" + token = request_id_context.set(request_id) + started = time.monotonic() + process = _psutil.Process() if _psutil else None + status_code = 500 + try: + response = await call_next(request) + status_code = response.status_code + response.headers["X-Request-ID"] = request_id + return response + finally: + elapsed = round(time.monotonic() - started, 4) + logger.info( + "request completed", + extra={ + "operation": request.state.operation, + "method": request.method, + "path": request.url.path, + "status_code": status_code, + "duration": elapsed, + "cpu_percent": _psutil.cpu_percent(interval=None) if _psutil else None, + "memory_bytes": process.memory_info().rss if process else None, + }, + ) + await container.cleanup.complete(request_id) + request_id_context.reset(token) + + @application.exception_handler(MediaAPIError) + async def media_error_handler(request: Request, exc: MediaAPIError) -> JSONResponse: + logger.warning( + "request failed", + extra={"error_code": exc.code, "operation": request.state.operation}, + ) + body = ErrorResponse( + request_id=request.state.request_id, + error=ErrorBody(code=exc.code, message=exc.message, details=exc.details), + ) + return DefaultJSONResponse(body.model_dump(), status_code=exc.status_code) + + @application.exception_handler(RequestValidationError) + async def validation_error_handler( + request: Request, exc: RequestValidationError + ) -> JSONResponse: + body = ErrorResponse( + request_id=request.state.request_id, + error=ErrorBody( + code="VALIDATION_ERROR", + message="Request validation failed", + details=exc.errors(include_url=False, include_context=False), + ), + ) + return DefaultJSONResponse(body.model_dump(), status_code=422) + + @application.exception_handler(HTTPException) + async def http_error_handler(request: Request, exc: HTTPException) -> JSONResponse: + body = ErrorResponse( + request_id=request.state.request_id, + error=ErrorBody(code="HTTP_ERROR", message=str(exc.detail)), + ) + return DefaultJSONResponse(body.model_dump(), status_code=exc.status_code) + + @application.exception_handler(Exception) + async def unexpected_error_handler(request: Request, exc: Exception) -> JSONResponse: + logger.exception("unhandled request error", extra={"operation": request.state.operation}) + body = ErrorResponse( + request_id=request.state.request_id, + error=ErrorBody( + code="INTERNAL_ERROR", + message="An unexpected internal error occurred", + ), + ) + return DefaultJSONResponse(body.model_dump(), status_code=500) + + application.include_router(health.router) + application.include_router(media.router) + application.include_router(video.router) + application.include_router(audio.router) + application.include_router(image.router) + application.include_router(probe.router) + application.include_router(ytdlp.router) + application.include_router(whisper.router) + application.include_router(templates.router) + application.mount("/mcp", mcp_http_app, name="mcp") + return application + + +app = create_app() diff --git a/models/__init__.py b/models/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..3e3320ee5b8bdd882002a45c5bd33b1036ecbc89 --- /dev/null +++ b/models/__init__.py @@ -0,0 +1 @@ +"""Compatibility exports for the canonical :mod:`app.models` package.""" diff --git a/models/media.py b/models/media.py new file mode 100644 index 0000000000000000000000000000000000000000..31b379c74574412c3886a561312750358fe732e5 --- /dev/null +++ b/models/media.py @@ -0,0 +1 @@ +from app.models.media import * # noqa diff --git a/models/requests.py b/models/requests.py new file mode 100644 index 0000000000000000000000000000000000000000..ea69ccd7d2b7979e6c3ecd24268d1dc211667f1e --- /dev/null +++ b/models/requests.py @@ -0,0 +1 @@ +from app.models.requests import * # noqa diff --git a/operations/__init__.py b/operations/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..88769466fca84a24de040f25600dfb5a43ab175a --- /dev/null +++ b/operations/__init__.py @@ -0,0 +1 @@ +"""Compatibility exports for the canonical :mod:`app.operations` package.""" diff --git a/operations/compress.py b/operations/compress.py new file mode 100644 index 0000000000000000000000000000000000000000..19996f1516b342d7498ae69be166a85913b0ed78 --- /dev/null +++ b/operations/compress.py @@ -0,0 +1 @@ +from app.operations.compress import * # noqa diff --git a/operations/concat.py b/operations/concat.py new file mode 100644 index 0000000000000000000000000000000000000000..e106b18d9f2a687a09fee68e636e8940e0818dcf --- /dev/null +++ b/operations/concat.py @@ -0,0 +1 @@ +from app.operations.concat import * # noqa diff --git a/operations/convert.py b/operations/convert.py new file mode 100644 index 0000000000000000000000000000000000000000..b184c7e2a87903f0c0aef8b0dfead27b05d941cd --- /dev/null +++ b/operations/convert.py @@ -0,0 +1 @@ +from app.operations.convert import * # noqa diff --git a/operations/crop.py b/operations/crop.py new file mode 100644 index 0000000000000000000000000000000000000000..e5d7126f53a72f6ea1d4f493eed661c51d512061 --- /dev/null +++ b/operations/crop.py @@ -0,0 +1 @@ +from app.operations.crop import * # noqa diff --git a/operations/extract_audio.py b/operations/extract_audio.py new file mode 100644 index 0000000000000000000000000000000000000000..e0bd9069d5e6be431c27208e4e5d56d9968ff1f3 --- /dev/null +++ b/operations/extract_audio.py @@ -0,0 +1 @@ +from app.operations.extract_audio import * # noqa diff --git a/operations/merge.py b/operations/merge.py new file mode 100644 index 0000000000000000000000000000000000000000..68f23d40817f5910edfbea8410c29d3eaba0e35e --- /dev/null +++ b/operations/merge.py @@ -0,0 +1 @@ +from app.operations.merge import * # noqa diff --git a/operations/resize.py b/operations/resize.py new file mode 100644 index 0000000000000000000000000000000000000000..1e29104773440bf42098b7b286f5ca25b1bcf324 --- /dev/null +++ b/operations/resize.py @@ -0,0 +1 @@ +from app.operations.resize import * # noqa diff --git a/operations/rotate.py b/operations/rotate.py new file mode 100644 index 0000000000000000000000000000000000000000..4c0ab9e8bbb96ce5511c831cc1d70e457940bd4f --- /dev/null +++ b/operations/rotate.py @@ -0,0 +1 @@ +from app.operations.rotate import * # noqa diff --git a/operations/subtitles.py b/operations/subtitles.py new file mode 100644 index 0000000000000000000000000000000000000000..795c7cdff4ed51143a6dfdc9cac871ac41f68bb4 --- /dev/null +++ b/operations/subtitles.py @@ -0,0 +1 @@ +from app.operations.subtitles import * # noqa diff --git a/operations/thumbnails.py b/operations/thumbnails.py new file mode 100644 index 0000000000000000000000000000000000000000..be444285793fccc53c7e7df75d569bbadcb32b62 --- /dev/null +++ b/operations/thumbnails.py @@ -0,0 +1 @@ +from app.operations.thumbnails import * # noqa diff --git a/operations/trim.py b/operations/trim.py new file mode 100644 index 0000000000000000000000000000000000000000..6a9b7a2b6af2ada4ea78d8d25b432f526ad32e89 --- /dev/null +++ b/operations/trim.py @@ -0,0 +1 @@ +from app.operations.trim import * # noqa diff --git a/operations/watermark.py b/operations/watermark.py new file mode 100644 index 0000000000000000000000000000000000000000..4d5d54a8b3f7afe1081737e9f66ea2ece3f36cf9 --- /dev/null +++ b/operations/watermark.py @@ -0,0 +1 @@ +from app.operations.watermark import * # noqa diff --git a/outputs/.gitkeep b/outputs/.gitkeep new file mode 100644 index 0000000000000000000000000000000000000000..8b137891791fe96927ad78e64b0aad7bded08bdc --- /dev/null +++ b/outputs/.gitkeep @@ -0,0 +1 @@ + diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000000000000000000000000000000000000..f2cb81d9f6bf53a75de0b798093c85b5bde74e36 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,19 @@ +[tool.black] +line-length = 100 +target-version = ["py310"] + +[tool.isort] +profile = "black" +line_length = 100 + +[tool.ruff] +line-length = 100 +target-version = "py310" + +[tool.ruff.lint] +select = ["E", "F", "I", "UP", "B", "ASYNC"] +ignore = ["E501"] + +[tool.pytest.ini_options] +asyncio_mode = "auto" +testpaths = ["tests"] diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000000000000000000000000000000000000..b817c3c1fe427f1e7182702b242773e29f8f1453 --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,6 @@ +-r requirements.txt +pytest==8.3.5 +pytest-asyncio==0.26.0 +respx==0.22.0 +ruff==0.11.8 +black==25.1.0 diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000000000000000000000000000000000000..8e4a5f8abcd290ee38482f8de87d847f6464567d --- /dev/null +++ b/requirements.txt @@ -0,0 +1,14 @@ +fastapi==0.115.12 +uvicorn[standard]==0.34.2 +pydantic==2.11.4 +pydantic-settings==2.9.1 +python-multipart==0.0.20 +aiofiles==24.1.0 +httpx==0.28.1 +orjson==3.10.18 +psutil==7.0.0 +numpy==1.26.4 +yt-dlp==2026.7.4 +faster-whisper==1.2.1 +mcp==1.28.1 +PyYAML==6.0.3 diff --git a/services/__init__.py b/services/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..8b208a40f76f2a300c43d87b80305db9206d7eca --- /dev/null +++ b/services/__init__.py @@ -0,0 +1 @@ +"""Compatibility exports for the canonical :mod:`app.services` package.""" diff --git a/services/cleanup.py b/services/cleanup.py new file mode 100644 index 0000000000000000000000000000000000000000..1b885fedc1a6a7c457fb8ea4a7a23a9390618dac --- /dev/null +++ b/services/cleanup.py @@ -0,0 +1 @@ +from app.services.cleanup import * # noqa diff --git a/services/downloader.py b/services/downloader.py new file mode 100644 index 0000000000000000000000000000000000000000..dc73193a91ba456b3a411adeccc457b324d1a1ed --- /dev/null +++ b/services/downloader.py @@ -0,0 +1 @@ +from app.services.downloader import * # noqa diff --git a/services/ffmpeg_service.py b/services/ffmpeg_service.py new file mode 100644 index 0000000000000000000000000000000000000000..4145bb4ddc12b8373f722886b25453e6ac3821d6 --- /dev/null +++ b/services/ffmpeg_service.py @@ -0,0 +1 @@ +from app.services.ffmpeg_service import * # noqa diff --git a/services/ffprobe_service.py b/services/ffprobe_service.py new file mode 100644 index 0000000000000000000000000000000000000000..16577f2b4dbd1f3ee306cf4c5570a816076798d8 --- /dev/null +++ b/services/ffprobe_service.py @@ -0,0 +1 @@ +from app.services.ffprobe_service import * # noqa diff --git a/services/input_resolver.py b/services/input_resolver.py new file mode 100644 index 0000000000000000000000000000000000000000..97fa308554b717ddb4f9343fe459552f52122713 --- /dev/null +++ b/services/input_resolver.py @@ -0,0 +1 @@ +from app.services.input_resolver import * # noqa diff --git a/services/media_service.py b/services/media_service.py new file mode 100644 index 0000000000000000000000000000000000000000..411220290375601acdad0a505b747b91fb893605 --- /dev/null +++ b/services/media_service.py @@ -0,0 +1 @@ +from app.services.media_service import * # noqa diff --git a/services/validator.py b/services/validator.py new file mode 100644 index 0000000000000000000000000000000000000000..cb4640948b4fc268f475f3e0a2c3f4be9730df1d --- /dev/null +++ b/services/validator.py @@ -0,0 +1 @@ +from app.services.validator import * # noqa diff --git a/services/whisper_service.py b/services/whisper_service.py new file mode 100644 index 0000000000000000000000000000000000000000..8030ace437db40062578422adefd7b3d743628fb --- /dev/null +++ b/services/whisper_service.py @@ -0,0 +1 @@ +from app.services.whisper_service import * # noqa diff --git a/services/ytdlp_service.py b/services/ytdlp_service.py new file mode 100644 index 0000000000000000000000000000000000000000..e56d27450b12ef75c98d77acc6ce832b2b0d1bb4 --- /dev/null +++ b/services/ytdlp_service.py @@ -0,0 +1 @@ +from app.services.ytdlp_service import * # noqa diff --git a/temp/.gitkeep b/temp/.gitkeep new file mode 100644 index 0000000000000000000000000000000000000000..8b137891791fe96927ad78e64b0aad7bded08bdc --- /dev/null +++ b/temp/.gitkeep @@ -0,0 +1 @@ + diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000000000000000000000000000000000000..5e4a2cc949cb5f8577826ac830e7160acb8bf90e --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,22 @@ +from __future__ import annotations + +from pathlib import Path + +import pytest + +from app.core.config import Settings + + +@pytest.fixture +def settings(tmp_path: Path) -> Settings: + return Settings( + _env_file=None, + temp_dir=tmp_path / "temp", + output_dir=tmp_path / "outputs", + max_upload_size=10 * 1024 * 1024, + cleanup_minutes=1, + cleanup_interval_seconds=3600, + whisper_model="tiny", + max_workers=1, + allow_private_urls=True, + ) diff --git a/tests/test_cleanup_worker.py b/tests/test_cleanup_worker.py new file mode 100644 index 0000000000000000000000000000000000000000..2e150123f88c2496792c76bb644de1881316e0d0 --- /dev/null +++ b/tests/test_cleanup_worker.py @@ -0,0 +1,46 @@ +from __future__ import annotations + +import asyncio +import os +import time +from uuid import uuid4 + +from app.services.cleanup import CleanupService +from app.workers.cleanup_worker import CleanupWorker + + +async def test_cleanup_removes_expired_workspace(settings) -> None: + service = CleanupService(settings) + request_id = str(uuid4()) + workspace = await service.create_workspace(request_id) + await service.complete(request_id) + old = time.time() - 120 + os.utime(workspace.root, (old, old)) + removed = await service.cleanup_expired() + assert removed == 1 + assert not workspace.root.exists() + + +async def test_cleanup_keeps_active_workspace(settings) -> None: + service = CleanupService(settings) + workspace = await service.create_workspace(str(uuid4())) + old = time.time() - 120 + os.utime(workspace.root, (old, old)) + assert await service.cleanup_expired() == 0 + assert workspace.root.exists() + + +async def test_cleanup_worker_runs_and_stops() -> None: + class FakeCleanup: + def __init__(self) -> None: + self.called = asyncio.Event() + + async def cleanup_expired(self) -> int: + self.called.set() + return 0 + + service = FakeCleanup() + worker = CleanupWorker(service, interval_seconds=60) # type: ignore[arg-type] + await worker.start() + await asyncio.wait_for(service.called.wait(), timeout=1) + await worker.stop() diff --git a/tests/test_downloader.py b/tests/test_downloader.py new file mode 100644 index 0000000000000000000000000000000000000000..2f5292f2dd457101b4cf65a3273a44f259bfbefb --- /dev/null +++ b/tests/test_downloader.py @@ -0,0 +1,20 @@ +from unittest.mock import AsyncMock + +import respx +from httpx import Response + +from app.services.downloader import Downloader +from app.services.validator import MediaValidator + + +@respx.mock +async def test_url_download_streams_to_disk(settings, tmp_path) -> None: + url = "https://media.example.test/sample.mp3" + respx.get(url).mock( + return_value=Response(200, content=b"ID3data", headers={"content-type": "audio/mpeg"}) + ) + downloader = Downloader(settings, MediaValidator(settings)) + downloader.validate_url = AsyncMock() # type: ignore[method-assign] + path, mime_type = await downloader.download(url, tmp_path) + assert path.read_bytes() == b"ID3data" + assert mime_type == "audio/mpeg" diff --git a/tests/test_error_handling.py b/tests/test_error_handling.py new file mode 100644 index 0000000000000000000000000000000000000000..2fbbf7e2cd60e6817f0b308545e25028e78a8b49 --- /dev/null +++ b/tests/test_error_handling.py @@ -0,0 +1,27 @@ +import pytest +from fastapi.testclient import TestClient + +from app.core.exceptions import NotFoundError +from main import create_app + + +def test_errors_use_safe_standard_envelope(settings) -> None: + with TestClient(create_app(settings), raise_server_exceptions=False) as client: + response = client.post( + "/v1/probe", + json={"base64": "not-valid-base64!", "filename": "sample.mp3"}, + ) + assert response.status_code == 422 + payload = response.json() + assert payload["success"] is False + assert payload["request_id"] + assert payload["error"]["code"] == "INVALID_INPUT" + assert "traceback" not in response.text.lower() + + +def test_download_path_traversal_is_rejected(settings) -> None: + app = create_app(settings) + with pytest.raises(NotFoundError): + app.state.container.cleanup.resolve_download( + "00000000-0000-0000-0000-000000000000", "../secret.mp4" + ) diff --git a/tests/test_ffmpeg_operations.py b/tests/test_ffmpeg_operations.py new file mode 100644 index 0000000000000000000000000000000000000000..f06e8d3ecd9d8b10811f9f6d8a6aca840cdd0612 --- /dev/null +++ b/tests/test_ffmpeg_operations.py @@ -0,0 +1,65 @@ +from __future__ import annotations + +import shutil +import subprocess + +import pytest + +from app.models.media import InputMedia, MediaSource +from app.operations.convert import convert_audio +from app.services.ffmpeg_service import FFmpegService + + +@pytest.mark.skipif(shutil.which("ffmpeg") is None, reason="ffmpeg is not installed") +async def test_ffmpeg_audio_conversion(settings, tmp_path) -> None: + source = tmp_path / "tone.wav" + subprocess.run( + [ + "ffmpeg", + "-hide_banner", + "-loglevel", + "error", + "-f", + "lavfi", + "-i", + "sine=frequency=440:duration=0.2", + "-y", + str(source), + ], + check=True, + ) + media = InputMedia( + source=MediaSource.MULTIPART, + filename=source.name, + mime_type="audio/wav", + temp_path=source, + size=source.stat().st_size, + ) + result = await convert_audio( + FFmpegService(settings), [media], {"format": "mp3"}, tmp_path / "out" + ) + assert result.path is not None + assert result.path.is_file() + assert result.path.stat().st_size > 0 + + +async def test_ffmpeg_codec_listing_is_structured(settings, monkeypatch) -> None: + service = FFmpegService(settings) + + async def fake_capture(*args, **kwargs) -> str: + return """Codecs: + D..... = Decoding supported + .E.... = Encoding supported + ------- + DEV.LS h264 H.264 / AVC / MPEG-4 AVC + DEA.L. aac AAC (Advanced Audio Coding) +""" + + monkeypatch.setattr(service, "_capture", fake_capture) + + codecs = await service.codecs() + + assert [codec["name"] for codec in codecs] == ["h264", "aac"] + assert codecs[0]["decode"] is True + assert codecs[0]["encode"] is True + assert codecs[0]["type"] == "video" diff --git a/tests/test_ffprobe.py b/tests/test_ffprobe.py new file mode 100644 index 0000000000000000000000000000000000000000..172d2f5eb355505319dbf084404decfd3d11783c --- /dev/null +++ b/tests/test_ffprobe.py @@ -0,0 +1,21 @@ +from __future__ import annotations + +import shutil +import wave + +import pytest + +from app.services.ffprobe_service import FFprobeService + + +@pytest.mark.skipif(shutil.which("ffprobe") is None, reason="ffprobe is not installed") +async def test_ffprobe_returns_audio_metadata(settings, tmp_path) -> None: + audio = tmp_path / "tone.wav" + with wave.open(str(audio), "wb") as stream: + stream.setnchannels(1) + stream.setsampwidth(2) + stream.setframerate(8000) + stream.writeframes(b"\x00\x00" * 8000) + metadata = await FFprobeService(settings).probe(audio) + assert metadata["duration"] == pytest.approx(1.0, abs=0.01) + assert metadata["audio_streams"][0]["codec"] == "pcm_s16le" diff --git a/tests/test_health.py b/tests/test_health.py new file mode 100644 index 0000000000000000000000000000000000000000..a6df0c858fbd711e13b81321fd3b0c31623551b8 --- /dev/null +++ b/tests/test_health.py @@ -0,0 +1,13 @@ +from fastapi.testclient import TestClient + +from main import create_app + + +def test_health_endpoint(settings) -> None: + with TestClient(create_app(settings)) as client: + response = client.get("/health") + assert response.status_code == 200 + payload = response.json() + assert payload["success"] is True + assert payload["metadata"]["status"] == "healthy" + assert response.headers["x-request-id"] == payload["request_id"] diff --git a/tests/test_input_resolver.py b/tests/test_input_resolver.py new file mode 100644 index 0000000000000000000000000000000000000000..23713b72f9d186bf6b196e20a493a029e2cc75e7 --- /dev/null +++ b/tests/test_input_resolver.py @@ -0,0 +1,103 @@ +from __future__ import annotations + +import base64 +from uuid import uuid4 + +import pytest +from starlette.requests import Request + +from app.container import build_container +from app.core.exceptions import InputError +from app.models.media import MediaSource + + +def json_request(payload: bytes) -> Request: + sent = False + + async def receive(): + nonlocal sent + if sent: + return {"type": "http.disconnect"} + sent = True + return {"type": "http.request", "body": payload, "more_body": False} + + request = Request( + { + "type": "http", + "method": "POST", + "path": "/v1/probe", + "headers": [(b"content-type", b"application/json")], + "query_string": b"", + }, + receive, + ) + request.state.request_id = str(uuid4()) + return request + + +async def test_resolves_json_base64(settings) -> None: + container = build_container(settings) + encoded = base64.b64encode(b"ID3-not-real-audio").decode() + request = json_request( + ('{"base64":"%s","filename":"sample.mp3","format":"wav"}' % encoded).encode() + ) + resolved = await container.resolver.resolve(request) + assert resolved.primary.source is MediaSource.JSON_BASE64 + assert resolved.primary.filename == "sample.mp3" + assert resolved.primary.temp_path.read_bytes() == b"ID3-not-real-audio" + assert resolved.params["filename"] == "sample.mp3" + assert resolved.params["format"] == "wav" + + +async def test_resolves_n8n_binary_property(settings) -> None: + container = build_container(settings) + encoded = base64.b64encode(b"audio").decode() + payload = ( + '{"binary":{"audio":{"data":"%s","fileName":"voice.mp3",' + '"mimeType":"audio/mpeg"}}}' % encoded + ).encode() + resolved = await container.resolver.resolve(json_request(payload)) + assert resolved.primary.source is MediaSource.N8N_BINARY + assert resolved.primary.filename == "voice.mp3" + assert resolved.primary.temp_path.read_bytes() == b"audio" + + +async def test_resolves_nested_template_input(settings) -> None: + container = build_container(settings) + encoded = base64.b64encode(b"RIFF-template-audio").decode() + request = json_request( + ( + '{"template":"mp3","input":{"base64":"%s",' + '"filename":"source.wav","mime_type":"audio/wav"},"parameters":{}}' % encoded + ).encode() + ) + + resolved = await container.resolver.resolve(request) + + assert resolved.primary.source is MediaSource.JSON_BASE64 + assert resolved.primary.filename == "source.wav" + assert resolved.params["template"] == "mp3" + assert resolved.params["parameters"] == {} + + +async def test_resolve_payload_copies_managed_temp_file(settings) -> None: + settings.output_dir.mkdir(parents=True) + source = settings.output_dir / "previous" / "clip.mp3" + source.parent.mkdir() + source.write_bytes(b"ID3-managed-media") + container = build_container(settings) + + resolved = await container.resolver.resolve_payload({"temp_path": str(source)}, str(uuid4())) + + assert resolved.primary.source is MediaSource.LOCAL_PATH + assert resolved.primary.temp_path != source + assert resolved.primary.temp_path.read_bytes() == source.read_bytes() + + +async def test_resolve_payload_rejects_unmanaged_path(settings, tmp_path) -> None: + source = tmp_path / "outside.mp3" + source.write_bytes(b"ID3-unmanaged-media") + container = build_container(settings) + + with pytest.raises(InputError, match="TEMP_DIR or OUTPUT_DIR"): + await container.resolver.resolve_payload({"temp_path": str(source)}, str(uuid4())) diff --git a/tests/test_mcp_server.py b/tests/test_mcp_server.py new file mode 100644 index 0000000000000000000000000000000000000000..c413662d0e688fb0fa08911c8bad59f4eb4b836d --- /dev/null +++ b/tests/test_mcp_server.py @@ -0,0 +1,88 @@ +from __future__ import annotations + +from fastapi.testclient import TestClient + +from app.container import build_container +from app.mcp.registry import ( + AUDIO_TOOLS, + IMAGE_TOOLS, + PROBE_TOOLS, + SYSTEM_TOOLS, + TEMPLATE_TOOLS, + VIDEO_TOOLS, + WHISPER_TOOLS, + YTDLP_TOOLS, + MCPRegistry, + MediaInput, +) +from app.mcp.server import create_mcp_server +from main import create_app + + +async def test_mcp_registers_all_tools_resources_and_prompts(settings) -> None: + server = create_mcp_server(build_container(settings)) + + tools = {tool.name for tool in await server.list_tools()} + expected_tools = set( + VIDEO_TOOLS + + AUDIO_TOOLS + + IMAGE_TOOLS + + WHISPER_TOOLS + + YTDLP_TOOLS + + PROBE_TOOLS + + SYSTEM_TOOLS + + TEMPLATE_TOOLS + ) + assert tools == expected_tools + + resources = {str(resource.uri) for resource in await server.list_resources()} + assert resources == { + "media://operations", + "media://formats", + "media://codecs", + "media://health", + "media://configuration", + "media://version", + } + + prompts = {prompt.name for prompt in await server.list_prompts()} + assert prompts == { + "compress_for_social_media", + "youtube_to_mp3", + "download_and_transcribe", + "generate_subtitles", + "extract_audio", + "make_thumbnail", + "probe_media", + "instagram_reel", + "tiktok_video", + "podcast_audio", + } + + +async def test_mcp_errors_use_safe_structured_envelope(settings) -> None: + registry = MCPRegistry(build_container(settings)) + + response = await registry.run_probe( + "probe_media", + MediaInput(temp_path=str(settings.temp_dir / "missing.mp4")), + ) + + assert response["success"] is False + assert response["request_id"] + assert response["processing_time"] >= 0 + assert response["error"]["code"] == "INVALID_INPUT" + assert "traceback" not in str(response).lower() + + +def test_rest_and_mcp_coexist_in_one_application(settings) -> None: + application = create_app(settings) + mounts = {getattr(route, "path", None) for route in application.routes} + assert "/mcp" in mounts + + with TestClient(application) as client: + response = client.get("/health") + + assert response.status_code == 200 + assert response.json()["success"] is True + assert application.state.mcp_server is not None diff --git a/tests/test_templates.py b/tests/test_templates.py new file mode 100644 index 0000000000000000000000000000000000000000..2eed62e7b181a9457ac1434c117b81f1661b7745 --- /dev/null +++ b/tests/test_templates.py @@ -0,0 +1,265 @@ +from __future__ import annotations + +import base64 +from pathlib import Path + +import pytest +from fastapi.testclient import TestClient + +from app.container import build_container +from app.core.exceptions import TemplateValidationError +from app.models.media import InputMedia, MediaSource, ResolvedRequest +from app.templates.executor import OPERATION_BINDINGS +from app.templates.loader import TemplateLoader +from app.templates.registry import TemplateRegistry +from app.templates.validator import TemplateValidator +from main import create_app + +EXPECTED_CATEGORIES = { + "branding", + "conversion", + "faceless", + "lyrics", + "motivation", + "podcast", + "social", + "subtitles", + "utility", + "youtube", +} + + +def test_builtin_templates_are_loaded_dynamically(settings) -> None: + registry = build_container(settings).template_registry + + assert registry.count == 71 + assert set(registry.categories()) == EXPECTED_CATEGORIES + assert registry.get("instagram_reel").version == 1 + assert registry.get("instagram_reel@latest").version == 1 + assert registry.get("instagram_reel@1").name == "Instagram Reel" + + +def test_parameter_substitution_preserves_declared_types(settings) -> None: + registry = build_container(settings).template_registry + + prepared = registry.prepare("youtube_shorts@1", {"crf": 19, "max_duration": 42.5}) + + trim_step = prepared.pipeline[0] + compress_step = prepared.pipeline[-1] + assert trim_step.parameters["duration"] == 42.5 + assert isinstance(trim_step.parameters["duration"], float) + assert compress_step.parameters["crf"] == 19 + assert isinstance(compress_step.parameters["crf"], int) + + +def test_template_registry_keeps_old_versions(tmp_path: Path) -> None: + root = tmp_path / "templates" + root.mkdir() + (root / "versions.yaml").write_text( + """ +templates: + - id: sample + name: Sample One + category: custom + description: First stable workflow. + author: Tests + version: 1 + tags: [test] + estimated_runtime: fast + supported_inputs: [video] + supported_outputs: [source] + parameters: {} + pipeline: [{operation: download}] + output: {format: source} + examples: [] + - id: sample + name: Sample Two + category: custom + description: Second stable workflow. + author: Tests + version: 2 + tags: [test] + estimated_runtime: fast + supported_inputs: [video] + supported_outputs: [source] + parameters: {} + pipeline: [{operation: download}] + output: {format: source} + examples: [] +""", + encoding="utf-8", + ) + validator = TemplateValidator(set(OPERATION_BINDINGS)) + registry = TemplateRegistry(TemplateLoader(root, validator), validator) + + assert registry.get("sample@1").name == "Sample One" + assert registry.get("sample@2").name == "Sample Two" + assert registry.get("sample@latest").version == 2 + assert registry.get("sample").version == 2 + + +def test_invalid_yaml_operation_is_never_registered(tmp_path: Path) -> None: + root = tmp_path / "templates" + root.mkdir() + (root / "invalid.yaml").write_text( + """ +id: invalid +name: Invalid +category: custom +description: Invalid operation must fail loading. +author: Tests +version: 1 +tags: [test] +estimated_runtime: fast +supported_inputs: [video] +supported_outputs: [mp4] +parameters: {} +pipeline: [{operation: shell_command}] +output: {format: mp4} +examples: [] +""", + encoding="utf-8", + ) + validator = TemplateValidator(set(OPERATION_BINDINGS)) + + with pytest.raises(TemplateValidationError, match="unsupported operation"): + TemplateRegistry(TemplateLoader(root, validator), validator) + + +def test_invalid_yaml_syntax_is_never_loaded(tmp_path: Path) -> None: + root = tmp_path / "templates" + root.mkdir() + (root / "broken.yaml").write_text("id: broken\npipeline: [\n", encoding="utf-8") + validator = TemplateValidator(set(OPERATION_BINDINGS)) + + with pytest.raises(TemplateValidationError, match="syntax"): + TemplateLoader(root, validator).load() + + +def test_required_and_typed_parameters_are_enforced(tmp_path: Path) -> None: + root = tmp_path / "templates" + root.mkdir() + (root / "required.yaml").write_text( + """ +id: required_sample +name: Required Sample +category: custom +description: Exercise strict runtime parameter validation. +author: Tests +version: 1 +tags: [test] +estimated_runtime: fast +supported_inputs: [video] +supported_outputs: [mp4] +parameters: + width: {type: integer, required: true, minimum: 2} +pipeline: [{operation: resize, width: "{{ width }}", height: 720}] +output: {format: mp4} +examples: [] +""", + encoding="utf-8", + ) + validator = TemplateValidator(set(OPERATION_BINDINGS)) + registry = TemplateRegistry(TemplateLoader(root, validator), validator) + + with pytest.raises(TemplateValidationError, match="Required"): + registry.prepare("required_sample", {}) + with pytest.raises(TemplateValidationError, match="must be integer"): + registry.prepare("required_sample", {"width": "1080"}) + assert ( + registry.prepare("required_sample", {"width": 1080}).pipeline[0].parameters["width"] == 1080 + ) + + +async def test_template_executor_calls_existing_operation(settings, tmp_path, monkeypatch) -> None: + container = build_container(settings) + source = tmp_path / "source.wav" + source.write_bytes(b"RIFF-test-audio") + + async def fake_probe(inputs): + return [ + { + "filename": media.filename, + "mime_type": media.mime_type, + "size": media.size, + } + for media in inputs + ] + + async def fake_ffmpeg(args, *, operation, timeout=None): + output = Path(args[-1]) + output.parent.mkdir(parents=True, exist_ok=True) + output.write_bytes(b"ID3-template-output") + + monkeypatch.setattr(container.processor, "probe_inputs", fake_probe) + monkeypatch.setattr(container.ffmpeg, "run", fake_ffmpeg) + resolved = ResolvedRequest( + request_id="5fdbe750-4cb7-4f87-aa5c-3df50c3a6629", + inputs=[ + InputMedia( + source=MediaSource.MULTIPART, + filename=source.name, + mime_type="audio/wav", + temp_path=source, + size=source.stat().st_size, + ) + ], + ) + + response = await container.template_executor.execute(resolved, "mp3@1", {}) + + assert response.success is True + assert response.download_url is not None + assert response.metadata["template"]["id"] == "mp3" + assert response.metadata["operations"] == ["convert_audio"] + published = container.cleanup.resolve_download( + resolved.request_id, Path(response.download_url).name + ) + assert published.read_bytes() == b"ID3-template-output" + + +def test_template_rest_endpoints_and_nested_input(settings, monkeypatch) -> None: + application = create_app(settings) + container = application.state.container + + async def fake_probe(inputs): + return [ + { + "filename": media.filename, + "mime_type": media.mime_type, + "size": media.size, + } + for media in inputs + ] + + async def fake_ffmpeg(args, *, operation, timeout=None): + output = Path(args[-1]) + output.parent.mkdir(parents=True, exist_ok=True) + output.write_bytes(b"ID3-rest-template") + + monkeypatch.setattr(container.processor, "probe_inputs", fake_probe) + monkeypatch.setattr(container.ffmpeg, "run", fake_ffmpeg) + + with TestClient(application) as client: + listing = client.get("/v1/templates") + categories = client.get("/v1/templates/categories") + details = client.get("/v1/templates/instagram_reel@1") + execution = client.post( + "/v1/templates/run", + json={ + "template": "mp3@latest", + "input": { + "base64": base64.b64encode(b"RIFF-rest-audio").decode(), + "filename": "audio.wav", + "mime_type": "audio/wav", + }, + "parameters": {}, + }, + ) + + assert listing.status_code == 200 + assert listing.json()["metadata"]["count"] == 71 + assert set(categories.json()["metadata"]["categories"]) == EXPECTED_CATEGORIES + assert details.json()["metadata"]["template"]["version"] == 1 + assert execution.status_code == 200 + assert execution.json()["metadata"]["template"]["id"] == "mp3" diff --git a/tests/test_whisper_service.py b/tests/test_whisper_service.py new file mode 100644 index 0000000000000000000000000000000000000000..9ced8dca763fd8b1f2c95162f939f11dc012a323 --- /dev/null +++ b/tests/test_whisper_service.py @@ -0,0 +1,32 @@ +from __future__ import annotations + +from types import SimpleNamespace + +from app.services.whisper_service import WhisperService + + +class FakeWhisperModel: + def transcribe(self, path, **kwargs): + segments = iter( + [ + SimpleNamespace(id=0, start=0.0, end=1.25, text=" Hello"), + SimpleNamespace(id=1, start=1.25, end=2.0, text=" world"), + ] + ) + info = SimpleNamespace(language="en", language_probability=0.99, duration=2.0) + return segments, info + + +async def test_whisper_writes_srt_without_loading_real_model(settings, tmp_path) -> None: + service = WhisperService(settings) + service._models["tiny"] = FakeWhisperModel() + media = tmp_path / "audio.wav" + media.write_bytes(b"test") + result = await service.transcribe( + media, tmp_path / "out", model_name="tiny", output_format="srt" + ) + assert result.path is not None + content = result.path.read_text() + assert "00:00:00,000 --> 00:00:01,250" in content + assert "Hello" in content + assert result.metadata["language"] == "en" diff --git a/workers/__init__.py b/workers/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..208531ac0e86ec393554ec147cc80381813bfcad --- /dev/null +++ b/workers/__init__.py @@ -0,0 +1 @@ +"""Compatibility exports for the canonical :mod:`app.workers` package.""" diff --git a/workers/cleanup_worker.py b/workers/cleanup_worker.py new file mode 100644 index 0000000000000000000000000000000000000000..e18f1bc506c49ec237927eaa8d76b2b4a3be0426 --- /dev/null +++ b/workers/cleanup_worker.py @@ -0,0 +1 @@ +from app.workers.cleanup_worker import * # noqa