A standalone, headless media-processing engine: 19 ffmpeg recipes for video, audio, and still images, defined once in a single operations registry and driven through three interchangeable faces — a CLI, an MCP server, and a drag-and-drop web UI. The engine and two of the three faces are pure Python stdlib; ffmpeg is the only external binary. Everything runs locally — your files never leave your machine.
The web UI running video_to_gif — drag a file, tune the auto-generated form, watch ffmpeg run live. Same page, hue knob rotated: every accent is one CSS variable and shader/canvas uniform, so the whole palette and the animated background recolor instantly (and the motion can be paused to save resources).
Dogfood note: these screenshots were downscaled for the repo with media-engine itself —
python3 cli.py run resize_image -i shot.png -o assets/screenshot.png --imageResizePreset manual --imageCustomWidth 1200 --imageCustomHeight 550
┌──────────────────────────────┐
cli.py ────────────►│ │
server.py (MCP) ───►│ OPERATIONS registry (19) ├──► ffmpeg
webserver.py ──────►│ validate → probe → build │
└──────────────────────────────┘
Add an operation once and it appears in all three faces automatically — as a CLI subcommand, as a typed MCP tool, and as an auto-generated web form. Zero per-face code.
- One registry, three faces. Every operation is a declarative
Operationentry (typed params, an ffmpeg command builder, an output-extension rule). The CLI, the MCP server, and the web UI are thin projections of that one registry. - Zero dependencies. The engine, the CLI, and the web UI need nothing but Python and
ffmpeg — no pip install, no venv, no build step. Only the MCP face pulls in one package
(
fastmcp). - Runs on old and new ffmpeg. A version probe picks
-fps_mode(ffmpeg ≥ 5) or the legacy-vsyncautomatically, so the same code runs on a stock Ubuntu ffmpeg 4.4 and on current builds. All 19 operations are verified end-to-end on ffmpeg 4.4.2. - Honest progress. Progress comes from ffmpeg's machine-readable
-progress pipe:1stream, not from scraping locale-dependent stderr — it feeds the CLI progress line and the web UI's live SSE updates alike. - Local and private. No network calls, no telemetry, no cloud. The web UI binds
127.0.0.1by default and processes uploads in throwaway temp workdirs.
git clone https://github.com/constant-itis/media-engine.git
cd media-engine
ffmpeg -version # any reasonably recent build; 4.4+ verified
# CLI — no install at all
python3 cli.py list
python3 cli.py run video_to_gif -i clip.mp4 -o clip.gif
# Web UI — no install either
python3 webserver.py # then open http://127.0.0.1:8765
# MCP server — the one face with a dependency
pip install -r requirements.txt
python3 server.pyIf ffmpeg/ffprobe aren't on PATH, point at them with the FFMPEG_BIN / FFPROBE_BIN
environment variables.
python3 cli.py list # all operations, one line each
python3 cli.py info <op> # params, types, ranges, defaults
python3 cli.py run <op> -i IN -o OUT [--param value ...]
Real examples:
# two-pass palette GIF at 15 fps, 480p
python3 cli.py run video_to_gif -i clip.mp4 -o clip.gif --gifFpsPreset 15 --gifResolutionPreset 480p
# 50% faster, video and audio kept in sync
python3 cli.py run video_speed -i clip.mp4 -o fast.mp4 --speed 50
# transcode to opus with EBU R128 loudness normalization
python3 cli.py run convert_audio -i song.wav -o song.opus --audioFormat opus --normalizeLoudness true
# watermark, bottom-right, 18% of frame width
python3 cli.py run add_watermark -i clip.mp4 -o marked.mp4 --coverImage logo.png --watermarkPosition bottom-right
# print the exact ffmpeg command(s) without running them
python3 cli.py run video_to_gif -i clip.mp4 -o clip.gif --dry-runEvery param takes a value (booleans included: --gifExportWebp true). run also accepts
--threads N, --quiet, and --dry-run — dry-run prints the fully-assembled ffmpeg
invocations, which doubles as a recipe reference.
server.py exposes the registry over the Model Context Protocol as a stdio server —
one typed tool per operation, auto-generated from the same OPERATIONS registry.
Choice params become enum-constrained schema fields, numeric ranges and defaults carry
over, output_path is optional (derived next to the input as {stem}_{op}.{ext}), and
every tool accepts dry_run. A list_operations tool covers discovery.
pip install -r requirements.txt # fastmcp — the engine itself stays stdlib
python3 server.py # stdio entrypoint your MCP client launchesRegister it with any MCP-compatible client:
{
"mcpServers": {
"media-engine": {
"command": "python3",
"args": ["/absolute/path/to/media-engine/server.py"]
}
}
}Tool results return the output path, the ffmpeg command(s) run, and pass counts;
failures come back as {"ok": false, "error": ...} with the ffmpeg stderr tail.
webserver.py serves a single-page drag-and-drop UI over stdlib http.server — no
frameworks, no build step, no dependencies. The whole frontend is one self-contained
web/index.html (vanilla JS, inline styles, embedded font and icons).
python3 webserver.py # http://127.0.0.1:8765
python3 webserver.py --host 0.0.0.0 --port 9000Drop in a file, pick an operation (deep-linkable as /#video_to_gif), tweak the
auto-generated parameter form, run. The op executes in a background thread inside a
temp workdir, progress streams live over Server-Sent Events, and the result previews
in-browser (image/video) with a download button. Abandoned jobs are reaped after 30
minutes.
The HTTP API underneath is plain and scriptable:
| Route | Method | Purpose |
|---|---|---|
/ |
GET | the UI (web/index.html) |
/api/operations |
GET | full registry as JSON (params, types, ranges, defaults) |
/api/run/<op> |
POST | multipart upload (file field __input__ + param fields) → {job_id} |
/api/progress/<job_id> |
GET | live progress as an SSE stream |
/api/result/<job_id> |
GET | download the output (one-shot; workdir cleaned after) |
/health |
GET | liveness check |
Security note: the server runs ffmpeg on uploaded input and binds localhost by default. Don't expose it to an untrusted network.
19 operations — 10 video, 5 audio, 4 image. Full parameter details for any op:
python3 cli.py info <op> (or /api/operations, or the MCP tool schemas — same data).
| Operation | Description | Params |
|---|---|---|
video_to_gif |
Video to animated GIF (two-pass palette) or WebP | gifFpsPreset gifResolutionPreset gifExportWebp |
video_speed |
Speed up / slow down video + audio in sync (atempo-chained) | speed (−100…100 %) |
add_watermark |
Overlay a scaled, alpha-blended image watermark | coverImage watermarkPosition watermarkSize watermarkOpacity watermarkX watermarkY |
convert_video |
Change container format, codec, and bitrate | videoFormat videoCodec videoBitrate |
resize_video |
Fit into a preset resolution/orientation frame with letterbox padding | resolutionPreset resizeVideoFormat |
trim_video |
Cut a segment starting at startTime for duration (re-encodes) |
startTime duration |
extract_audio |
Extract the audio track (optionally transcoding), or strip audio entirely | extractAudioAction extractAudioFormat extractAudioBitrate |
rotate_video |
Rotate 90° CW/CCW, 180°, or mirror horizontally | rotation |
grayscale_video |
Named color-grade preset plus manual brightness/contrast/saturation trim | styleMode styleIntensity brightnessAdjust contrastAdjust saturationAdjust |
burn_subtitles |
Hardcode SRT/VTT/ASS subtitles into the video frame (irreversible) | subtitles |
| Operation | Description | Params |
|---|---|---|
convert_audio |
Transcode to a target format with optional loudness normalize / volume boost | audioFormat normalizeLoudness volumeBoostPercent outputSampleRate bitrate |
trim_audio |
Cut a segment from an audio file (start + duration) | startTime duration |
audio_settings |
Channel-layout change, full reverse, and/or fade in/out in one pass | audioChannelMode reverseAudioEnabled fadeInDuration fadeOutDuration |
add_metadata |
Write/remove ID3-style text metadata and embed/strip cover art, per container support | coverImage metadataArtist metadataTitle metadataReleaseDate metadataGenre removeCover removeAllMetadata |
audio_speed |
Change audio tempo (atempo-chained; same percent scale as video_speed) |
speed (−100…100 %) |
| Operation | Description | Params |
|---|---|---|
resize_image |
Resize/reformat a still image to a target aspect ratio and size | imageAspectRatio imageResizePreset imageCustomWidth imageCustomHeight imageOutputFormat |
rotate_image |
Rotate 90°/180° and/or flip horizontally/vertically | rotation flipHorizontal flipVertical |
grayscale_image |
Black-and-white via intensity-blended color mixer plus preset contrast/brightness | blackWhiteMode blackWhiteIntensity blackWhiteContrast blackWhiteBrightness |
sharpness_image |
Box blur and/or unsharp-mask sharpening | sharpenAmount blurStrength |
engine/
opmodel.py the model: Param (typed, validated) · Context · Operation
ops/
video.py 10 video operations
audio.py 5 audio operations
image.py 4 image operations
operations.py registry aggregator + run_operation (validate → probe → build → run)
ffmpeg.py process layer: binary resolution, -progress pipe:1 parsing,
bounded stderr tails, version capability probe, per-OS null device
probe.py ffprobe → MediaInfo (dimensions, duration, bitrates, audio presence)
cli.py CLI face
server.py MCP face (one typed tool per op, generated from the registry)
webserver.py web face (stdlib http.server + SSE job streaming)
web/index.html the entire frontend — one self-contained page, no build step
recon/ upstream recipe inventory + porting assessment
An operation is data plus one function:
Operation(
id="video_to_gif", category="video",
description="Video to animated GIF (two-pass palette) or WebP.",
params=[Param("gifFpsPreset", "choice", 12, choices=[8, 10, 12, 15, 24, 25, 30, 50, 60]), ...],
build=_build_gif, # Context -> [Pass, ...] (the ffmpeg invocations)
output_ext=lambda p: "webp" if p.get("gifExportWebp") else "gif",
needs_probe=False, # True = run ffprobe first, results in ctx.info
)run_operation validates params against the declared specs, probes the input if the op
asks for it, calls build to get one or more ffmpeg Passes (multi-pass recipes like
palette-gen GIF just return two), and executes them with live progress. Temp files
created via ctx.tempfile() are cleaned up automatically.
To add an operation: write a _build_<op>(ctx) function and append an
Operation(...) entry in the right engine/ops/*.py module. That's the whole job — the
CLI subcommand, the typed MCP tool, and the web form all materialize from the entry.
- ffmpeg version probe —
-fps_mode passthroughonly exists on ffmpeg ≥ 5.0;engine/ffmpeg.pyparsesffmpeg -versiononce and falls back to the legacy-vsync passthroughon older builds (verified end-to-end on 4.4.2; the modern-flag path is the same one the upstream recipes used against ffmpeg 6.x). - Per-OS null device —
/dev/nullvsNULchosen at runtime. - Binary resolution —
PATHlookup withFFMPEG_BIN/FFPROBE_BINenv overrides; no.exeassumptions. - Machine-readable progress —
-progress pipe:1key-value stream instead of parsing locale/version-dependent stderrtime=lines. - Python — 3.9+, stdlib only (dataclasses,
argparse,http.server,subprocess). The MCP face additionally followsfastmcp's Python requirement.
The recipes were ported and adapted from media-by-outlaw2082 (GPL-3.0), an Electron GUI over ffmpeg — credit to Outlaw2082 for the original operation set and command recipes. This project reimplements them as a headless, dependency-free engine, keeping the recipes' behavior while fixing a few issues found during the port:
rotate_video"Mirror" was exposed upstream but had no filter mapping (silent no-op); it now emitshflip.trim_audio/audio_settingsrelied on ffmpeg's default-encoder-by-extension guess; the codec is now set explicitly.audio_speedlabeled its input "percent" but clamped a raw 0.5–2.0 factor; it now uses the same percent scale asvideo_speedwith an atempo chain (no 2× ceiling).sharpness_imagemapped its slider tounsharpamounts up to 20, outside ffmpeg's valid[-2, 5]range (errored on every build); remapped to 0–5.
The recon/ directory documents the original operation inventory and the porting
assessment.
GPL-3.0 — inherited from the upstream project the recipes derive from, and kept gladly.

