A self-hosted web app to merge, trim, and crop videos — right on your own machine. Drag and drop your clips, arrange them in any order, trim or crop any of them, and download the result. A single video can also be trimmed or cropped on its own.
- Smart merging — when all inputs share the same codec, resolution, frame rate, and audio format, the merge is a near-instant stream copy with no quality loss. Mismatched inputs are automatically normalized (letterboxed to the largest canvas, re-encoded to H.264/AAC).
- Trimming — set a start/end point per clip before merging, or trim a single video standalone. Trims are frame-accurate: any job containing a trim is re-encoded so the cut lands exactly where you set it.
- Cropping — drag a crop rectangle on any clip (with 16:9 / 9:16 / 1:1 / 4:3 aspect presets or free-form). Like trims, any crop forces a re-encode; untouched jobs keep the fast stream-copy path.
- Fully offline — no CDNs, no external fonts, no outbound requests. Designed to run in air-gapped environments.
- Any machine — ships as a single Docker container.
Drag & drop your clips:
Arrange, reorder, and edit clips before merging — the timeline strip shows how they combine:
Trim and crop in one dialog — drag the crop box (with aspect presets) and set start/end on the filmstrip:
Preview and download the result:
Requires Docker with the compose plugin.
git clone https://github.com/mkamranr/videomerger.git
cd videomerger
docker compose up --build -dThen open http://localhost:8000 — drop in your videos and go.
To stop: docker compose down. Uploaded files and outputs live in a named Docker
volume (videomerger-data) and are cleaned up automatically after each job expires.
The Docker image contains only the runtime: Python, FFmpeg, and all Python
packages, installed at build time. The application code (app/ and static/) is
not baked into the image — it is volume-mounted into the container at run time.
This means you can update the app on a server by copying new files, without
rebuilding or re-transferring the image. The container never downloads anything
at runtime. See DEPLOYMENT.md for the update procedure.
Running without compose:
docker build -t videomerger:1.0 .
docker run -d -p 8000:8000 \
-v "$PWD/app:/app/app:ro" \
-v "$PWD/static:/app/static:ro" \
-v videomerger-data:/data \
--name videomerger videomerger:1.0Build the image once on an internet-connected machine, then move it as a tar archive:
# On the connected machine
git clone https://github.com/mkamranr/videomerger.git
cd videomerger
docker build -t videomerger:1.0 .
docker save videomerger:1.0 -o videomerger-1.0.tarCopy to the air-gapped host (USB drive, internal file share, ...):
videomerger-1.0.tar— the runtime image- The project's
app/andstatic/directories (anddocker-compose.airgap_v1.yml)
# On the air-gapped host, from the directory containing app/ and static/
docker load -i videomerger-1.0.tar
docker compose -f docker-compose.airgap_v1.yml up -d(The air-gapped compose file has no build: entry, so an offline rebuild can never
be attempted; the loaded image is used as-is.)
To update the app later, just replace the files in app/ or static/ and restart
the container (docker restart videomerger) — no rebuild, no internet. Full
procedure in DEPLOYMENT.md.
The app makes no outbound network requests at runtime: FFmpeg and Python packages live in the image, and all frontend assets (including SortableJS) are served locally.
Requires Python 3.10+ and ffmpeg/ffprobe on PATH.
git clone https://github.com/mkamranr/videomerger.git
cd videomerger
python -m venv .venv
source .venv/bin/activate # Windows: .venv\Scripts\Activate.ps1
pip install -r requirements.txt
uvicorn app.main:app --reload --port 8000| Environment variable | Default | Description |
|---|---|---|
DATA_DIR |
./data (/data in Docker) |
Where uploads and outputs are stored |
JOB_TTL_SECONDS |
3600 |
How long finished jobs are kept before cleanup |
MAX_FILES |
10 |
Maximum number of videos per merge |
| Method | Path | Description |
|---|---|---|
| POST | /api/merge |
Multipart upload, repeated files field in merge order; optional trims field — JSON list parallel to files, each entry null or {"start": s, "end": e} in seconds; optional crops field — JSON list parallel to files, each entry null or {"x", "y", "w", "h"} in source pixels; a single file requires at least one trim or crop; returns {job_id} |
| GET | /api/jobs/{id} |
Job status: state, mode, progress, message |
| GET | /api/jobs/{id}/download |
Download the finished MP4 |
Example:
curl -F "files=@clip1.mp4" -F "files=@clip2.mp4" \
-F 'trims=[{"start":2,"end":10},null]' \
-F 'crops=[null,{"x":0,"y":0,"w":1280,"h":720}]' \
http://localhost:8000/api/mergeapp/ FastAPI backend: routes (main.py), ffprobe planning (probe.py),
ffmpeg execution (merge.py)
static/ Frontend: single page, vanilla JS/CSS, vendored SortableJS
Dockerfile, docker-compose.yml, docker-compose.airgap_v1.yml