A self-hosted FFmpeg API with a built-in web UI. Submit media URLs or upload binary files, process them server-side with FFmpeg, and get back a persistent URL — no CLI required.
Stack: Python 3.11 · FastAPI · FFmpeg · Alpine.js · Docker
Need a VPS to deploy this on? Spin one up here — this is a referral link that helps support the project at no extra cost to you.
| Endpoint | What it does | Output |
|---|---|---|
POST /merge |
Merge video + audio with volume control and length strategy | URL → MP4 |
POST /animate |
Apply Ken Burns, zoom, or pan effects to image or video | URL → MP4 |
POST /combine |
Concatenate multiple videos or audio files | URL → MP4 / MP3 |
POST /image-to-video |
Convert a static image to MP4 with optional animation | URL → MP4 |
POST /loop |
Repeat a video clip N times (stream copy, no re-encode) | URL → MP4 |
POST /concat-transitions |
Concatenate clips with xfade transitions between them | URL → MP4 |
POST /metadata |
Get duration, resolution, fps, bitrate, stream info from a URL | JSON |
POST /metadata/upload |
Get metadata by uploading a binary file directly | JSON |
POST /upload |
Upload any binary file and get back a persistent URL | JSON |
GET /folders |
List all named folders | JSON |
POST /folders |
Create a named folder | JSON |
GET /folders/{name} |
List files inside a folder | JSON |
DELETE /folders/{name} |
Delete a folder and all its files | JSON |
GET /folders/{name}/urls |
Get all file URLs in a folder as a /combine body | JSON |
DELETE /folders/{name}/{filename} |
Delete a single file from a folder | JSON |
GET /files |
List all job output files | JSON |
DELETE /files |
Purge all job output files | JSON |
DELETE /files/{job_id} |
Delete a single job's output | JSON |
GET /health |
Health check | JSON |
git clone https://github.com/leksautomate/ChromaFFmpeg.git
cd ChromaFFmpeg
cp .env.example .envEdit .env:
API_KEY=your-secret-key-here
BASE_URL=http://your-vps-ip:9000
mkdir -p /data/outputs /data/folders
docker compose up --build -dThe API and UI are live at http://your-vps-ip:9000
SSH into your server. First find where the repo lives if you're unsure:
find / -name "docker-compose.yml" -not -path "*/proc/*" 2>/dev/nullThen cd into that directory and run:
# Pull latest
git pull origin master
# Stop container and wipe build cache
docker compose down
docker system prune -af
# Rebuild and start fresh
docker compose up --build -d
# Watch logs
docker logs -f chromaffmpeg-chromaffmpeg-1Your .env and /data/ volumes are untouched — docker system prune -af only removes images and build cache, not volumes.
Open http://your-vps-ip:9000 in your browser. Enter your API key in the sidebar — it is saved to localStorage.
Processing:
- Merge — paste a video URL and audio URL, choose a length strategy, control volumes for both the added audio and the video's original audio
- Animate — apply Ken Burns zoom or pan animation to an image or video
- Combine — concatenate multiple files into one (optional re-encode for mixed codecs)
- Image to Video — convert a static image to MP4 with optional animation
- Loop — repeat a video clip N times
- Transitions — concatenate clips with animated xfade transitions
- Metadata — inspect any media URL
Storage:
- Upload — drag-and-drop or browse to upload any binary file; audio files go to the
audiofolder automatically, all others go toupload; optionally override with a custom folder name - Folders — browse named folders (
upload,audio,main, or custom), preview files inline, copy URLs, download, or delete - Storage — browse all job output files, click any file to preview inline, copy URL, download, or purge
All result panels include an inline media preview, a copyable URL, and a download button. If an error occurs, a ▼ Show FFmpeg log toggle reveals the raw FFmpeg stderr for diagnosis.
Several endpoints automatically route output to named folders when no folder is specified:
| Endpoint | Default folder | Notes |
|---|---|---|
POST /upload (audio file) |
audio |
.mp3 .wav .m4a .ogg .aac .flac .opus .wma |
POST /upload (any other file) |
upload |
Video, image, or unknown type |
POST /combine |
main |
Override with "folder" in the request body |
POST /merge |
(no default — returns job URL) | Set "folder" to save to a named folder |
| Other processing endpoints | (no default — returns job URL) | Set "folder" to save to a named folder |
All folders are created automatically if they don't exist. You never need to pre-create a folder before using it.
All endpoints require the header:
X-API-Key: your-secret-key
Interactive docs (Swagger UI): http://your-vps-ip:9000/docs
Processing endpoints return a /store/ URL when a folder is used, or a /files/ URL otherwise:
With folder:
{
"url": "http://your-vps-ip:9000/store/MyProject/a3f8b2c1.mp4",
"filename": "a3f8b2c1.mp4",
"folder": "MyProject"
}Without folder (job storage):
{
"url": "http://your-vps-ip:9000/files/uuid/a3f8b2c1.mp4",
"filename": "a3f8b2c1.mp4",
"job_id": "uuid"
}Output filenames are always randomized (8-character hex token + extension) to avoid collisions.
Some endpoints include an optional "warning" field when a fallback was applied (see /merge).
Merges a video and audio file. Controls length mismatch strategy and independent volume for each audio source.
Request body:
| Field | Type | Default | Description |
|---|---|---|---|
video_url |
string | required | URL of the video file |
audio_url |
string | required | URL of the audio file to add |
strategy |
string | "speed_match" |
How to handle length mismatch (see table below) |
audio_volume |
float | 1.0 |
Volume multiplier for the added audio (0.5 = half, 2.0 = double, max 4.0) |
video_audio_volume |
float | 0.0 |
Volume of the video's original audio to mix in (0.0 = ignore, 0.3 = 30%) |
folder |
string | none | Save output to this named folder (auto-created if missing) |
strategy values:
| Value | Video longer than audio | Audio longer than video |
|---|---|---|
trim_or_slow (default) |
Trim video to audio length — stream copy, fast | Slow video down to fill the audio duration |
speed_match |
Speed up video to match audio | Slow video down to match audio |
trim |
Cut at whichever stream ends first | Cut at whichever stream ends first |
Volume behaviour:
audio_volume=1.0, video_audio_volume=0.0(defaults) — replaces video audio entirely with the added audio at original volumeaudio_volume=1.5— boosts the added audio by 50%video_audio_volume=0.2— keeps the video's original audio at 20% and mixes it with the added audio- When any volume param is non-default, audio is re-encoded to AAC
Duration detection fallback: If duration cannot be determined for either file, trim (-shortest) is applied automatically and a "warning" field is added to the response.
curl:
# Basic merge — replace video audio with added audio
curl -X POST http://localhost:9000/merge \
-H "Content-Type: application/json" \
-H "X-API-Key: your-secret-key" \
-d '{
"video_url": "https://example.com/video.mp4",
"audio_url": "https://example.com/audio.mp3",
"strategy": "trim_or_slow"
}'
# Boost added audio, keep quiet background from video
curl -X POST http://localhost:9000/merge \
-H "Content-Type: application/json" \
-H "X-API-Key: your-secret-key" \
-d '{
"video_url": "https://example.com/video.mp4",
"audio_url": "https://example.com/audio.mp3",
"strategy": "trim_or_slow",
"audio_volume": 1.5,
"video_audio_volume": 0.2
}'
# Silence the video audio completely, use added audio at half volume
curl -X POST http://localhost:9000/merge \
-H "Content-Type: application/json" \
-H "X-API-Key: your-secret-key" \
-d '{
"video_url": "https://example.com/video.mp4",
"audio_url": "https://example.com/audio.mp3",
"strategy": "trim_or_slow",
"audio_volume": 0.5
}'
# speed_match — stretch or compress video speed to fit audio exactly
curl -X POST http://localhost:9000/merge \
-H "Content-Type: application/json" \
-H "X-API-Key: your-secret-key" \
-d '{
"video_url": "https://example.com/video.mp4",
"audio_url": "https://example.com/audio.mp3",
"strategy": "speed_match"
}'
# Save output to a named folder (auto-created if it doesn't exist)
curl -X POST http://localhost:9000/merge \
-H "Content-Type: application/json" \
-H "X-API-Key: your-secret-key" \
-d '{
"video_url": "https://example.com/video.mp4",
"audio_url": "https://example.com/audio.mp3",
"strategy": "trim_or_slow",
"folder": "roman"
}'Response (with folder):
{
"url": "http://your-vps-ip:9000/store/roman/a3f8b2c1.mp4",
"filename": "a3f8b2c1.mp4",
"folder": "roman"
}Applies a cinematic animation effect to an image or video.
Request body:
{
"media_url": "https://example.com/photo.jpg",
"media_type": "image",
"animation": "zoom_in",
"duration": 6,
"fps": 25,
"resolution": "1920x1080",
"folder": "MyProject"
}folder is optional — see folder output below.
animation values: zoom_in · zoom_out · pan_left · pan_right · pan_zoom
media_type values: image · video
curl:
# Animate an image
curl -X POST http://localhost:9000/animate \
-H "Content-Type: application/json" \
-H "X-API-Key: your-secret-key" \
-d '{
"media_url": "https://example.com/photo.jpg",
"media_type": "image",
"animation": "zoom_in",
"duration": 6,
"fps": 25,
"resolution": "1920x1080"
}'
# Save output to a named folder
curl -X POST http://localhost:9000/animate \
-H "Content-Type: application/json" \
-H "X-API-Key: your-secret-key" \
-d '{
"media_url": "https://example.com/photo.jpg",
"media_type": "image",
"animation": "pan_zoom",
"duration": 8,
"fps": 25,
"resolution": "1920x1080",
"folder": "MyProject"
}'Concatenates multiple video or audio files into a single output.
Output goes to the main folder by default. Override with "folder" in the request body.
Request body:
{
"type": "video",
"urls": [
"https://example.com/clip1.mp4",
"https://example.com/clip2.mp4"
],
"reencode": false,
"folder": "MyProject"
}Set "type": "audio" for MP3 output. Set "reencode": true for mixed-codec sources — slower but always compatible.
curl:
# Combine videos — output goes to "main" folder by default
curl -X POST http://localhost:9000/combine \
-H "Content-Type: application/json" \
-H "X-API-Key: your-secret-key" \
-d '{
"type": "video",
"urls": ["https://example.com/clip1.mp4", "https://example.com/clip2.mp4"],
"reencode": false
}'
# Combine audio files
curl -X POST http://localhost:9000/combine \
-H "Content-Type: application/json" \
-H "X-API-Key: your-secret-key" \
-d '{
"type": "audio",
"urls": ["https://example.com/part1.mp3", "https://example.com/part2.mp3"],
"reencode": false
}'
# Re-encode for mixed sources
curl -X POST http://localhost:9000/combine \
-H "Content-Type: application/json" \
-H "X-API-Key: your-secret-key" \
-d '{
"type": "video",
"urls": ["https://example.com/clip1.mp4", "https://example.com/clip2.mov"],
"reencode": true
}'
# Override output folder
curl -X POST http://localhost:9000/combine \
-H "Content-Type: application/json" \
-H "X-API-Key: your-secret-key" \
-d '{
"type": "video",
"urls": ["https://example.com/clip1.mp4", "https://example.com/clip2.mp4"],
"reencode": false,
"folder": "MyProject"
}'Response:
{
"url": "http://your-vps-ip:9000/store/main/a3f8b2c1.mp4",
"filename": "a3f8b2c1.mp4",
"folder": "main"
}Converts a static image to an MP4 video.
Request body:
{
"image_url": "https://example.com/photo.png",
"duration": 8,
"animation": "none",
"fps": 25,
"resolution": "1920x1080",
"folder": "MyProject"
}folder is optional — see folder output below.
animation values: none · zoom_in · zoom_out · pan_left · pan_right · pan_zoom
curl:
# Static image — no animation
curl -X POST http://localhost:9000/image-to-video \
-H "Content-Type: application/json" \
-H "X-API-Key: your-secret-key" \
-d '{
"image_url": "https://example.com/photo.png",
"duration": 8,
"animation": "none",
"fps": 25,
"resolution": "1920x1080"
}'
# Ken Burns effect
curl -X POST http://localhost:9000/image-to-video \
-H "Content-Type: application/json" \
-H "X-API-Key: your-secret-key" \
-d '{
"image_url": "https://example.com/photo.png",
"duration": 8,
"animation": "pan_zoom",
"fps": 25,
"resolution": "1920x1080"
}'
# Save output to a named folder
curl -X POST http://localhost:9000/image-to-video \
-H "Content-Type: application/json" \
-H "X-API-Key: your-secret-key" \
-d '{
"image_url": "https://example.com/photo.png",
"duration": 8,
"animation": "zoom_in",
"fps": 25,
"resolution": "1920x1080",
"folder": "MyProject"
}'Repeats a video clip N times by stream-copying — no re-encoding, near-instant.
Request body:
{
"video_url": "https://example.com/clip.mp4",
"loop_count": 3,
"folder": "MyProject"
}loop_count must be between 2 and 50. folder is optional — see folder output below.
curl:
curl -X POST http://localhost:9000/loop \
-H "Content-Type: application/json" \
-H "X-API-Key: your-secret-key" \
-d '{
"video_url": "https://example.com/clip.mp4",
"loop_count": 4
}'
# Save looped output to a named folder
curl -X POST http://localhost:9000/loop \
-H "Content-Type: application/json" \
-H "X-API-Key: your-secret-key" \
-d '{
"video_url": "https://example.com/clip.mp4",
"loop_count": 4,
"folder": "MyProject"
}'Concatenates video clips with smooth FFmpeg xfade transitions between them.
Request body:
{
"urls": [
"https://example.com/clip1.mp4",
"https://example.com/clip2.mp4",
"https://example.com/clip3.mp4"
],
"transition": "fade",
"transition_duration": 1.0,
"folder": "MyProject"
}folder is optional — see folder output below.
transition values: fade · fadeblack · fadewhite · dissolve · pixelize · wipeleft · wiperight · wipeup · wipedown · slideleft · slideright · smoothleft · smoothright · radial · circleopen · circleclose
Requirements:
- All clips must share the same resolution and frame rate
- Duration must be detectable for all clips — required to calculate xfade offsets
- If all clips have audio,
acrossfadetransitions are applied to audio automatically; if any clip lacks audio the output is video-only
curl:
# Fade transition between three clips
curl -X POST http://localhost:9000/concat-transitions \
-H "Content-Type: application/json" \
-H "X-API-Key: your-secret-key" \
-d '{
"urls": [
"https://example.com/clip1.mp4",
"https://example.com/clip2.mp4",
"https://example.com/clip3.mp4"
],
"transition": "fade",
"transition_duration": 1.0
}'
# Wipe left with a shorter transition
curl -X POST http://localhost:9000/concat-transitions \
-H "Content-Type: application/json" \
-H "X-API-Key: your-secret-key" \
-d '{
"urls": [
"https://example.com/clip1.mp4",
"https://example.com/clip2.mp4"
],
"transition": "wipeleft",
"transition_duration": 0.5
}'
# Save output to a named folder
curl -X POST http://localhost:9000/concat-transitions \
-H "Content-Type: application/json" \
-H "X-API-Key: your-secret-key" \
-d '{
"urls": [
"https://example.com/clip1.mp4",
"https://example.com/clip2.mp4",
"https://example.com/clip3.mp4"
],
"transition": "fade",
"transition_duration": 1.0,
"folder": "MyProject"
}'All processing endpoints accept an optional "folder" field. /combine defaults to "main" when omitted; all others return a /files/ job URL when no folder is specified.
When folder is set (or defaulted):
- The output is copied into
/data/folders/{folder}/(served at/store/{folder}/) - The response returns a
/store/URL and a"folder"key - The folder is created automatically if it does not exist
- Folder names are sanitized — only alphanumeric, hyphens, underscores (max 64 chars)
- Output filenames are randomized — collisions are impossible
Response shape (with folder):
{
"url": "http://your-vps-ip:9000/store/MyProject/a3f8b2c1.mp4",
"filename": "a3f8b2c1.mp4",
"folder": "MyProject"
}Response shape (without folder — job storage):
{
"url": "http://your-vps-ip:9000/files/uuid/a3f8b2c1.mp4",
"filename": "a3f8b2c1.mp4",
"job_id": "uuid"
}Returns duration, format, resolution, fps, bitrate, and stream counts for any media URL.
Request body:
{ "url": "https://example.com/video.mp4" }curl:
curl -X POST http://localhost:9000/metadata \
-H "Content-Type: application/json" \
-H "X-API-Key: your-secret-key" \
-d '{"url": "https://example.com/video.mp4"}'Response:
{
"duration_seconds": 47.32,
"duration_formatted": "0:00:47",
"format": "mov",
"size_bytes": 4823042,
"video_streams": 1,
"audio_streams": 1,
"width": 1920,
"height": 1080,
"fps": 25.0,
"bitrate_kbps": 2048
}Upload a binary file directly and get its metadata — no need to host the file first. The file is deleted from the server immediately after probing.
curl:
curl -X POST http://localhost:9000/metadata/upload \
-H "X-API-Key: your-secret-key" \
-F "file=@/path/to/video.mp4"Response: same shape as POST /metadata.
Converts any binary file (video, audio, image, or anything else) into a persistent URL. Accepts multipart form data.
Files are always stored in a named folder. The default folder is chosen automatically by file type:
| File type | Default folder |
|---|---|
.mp3 .wav .m4a .ogg .aac .flac .opus .wma |
audio |
| Everything else (video, image, etc.) | upload |
Pass folder=name to override the default. The folder is created automatically if it does not exist. Filenames are randomized (e.g. a3f8b2c1.mp3) — no collisions.
curl:
# Upload a video — goes to "upload" folder automatically
curl -X POST http://localhost:9000/upload \
-H "X-API-Key: your-secret-key" \
-F "file=@/path/to/video.mp4"
# Upload an audio file — goes to "audio" folder automatically
curl -X POST http://localhost:9000/upload \
-H "X-API-Key: your-secret-key" \
-F "file=@/path/to/track.mp3"
# Override folder explicitly
curl -X POST http://localhost:9000/upload \
-H "X-API-Key: your-secret-key" \
-F "file=@/path/to/photo.jpg" \
-F "folder=my-project"Response:
{
"url": "http://your-vps-ip:9000/store/audio/a3f8b2c1.mp3",
"filename": "a3f8b2c1.mp3",
"folder": "audio",
"size_bytes": 4823042
}Maximum file size: 500 MB.
List all named folders with file counts and total sizes.
curl:
curl http://localhost:9000/folders \
-H "X-API-Key: your-secret-key"Response:
{
"folders": [
{ "name": "audio", "file_count": 5, "total_size_bytes": 2097152 },
{ "name": "main", "file_count": 3, "total_size_bytes": 1048576 },
{ "name": "upload", "file_count": 2, "total_size_bytes": 524288 }
],
"count": 3
}Create a named folder.
curl:
curl -X POST http://localhost:9000/folders \
-H "Content-Type: application/json" \
-H "X-API-Key: your-secret-key" \
-d '{"name": "my-project"}'Response:
{ "name": "my-project", "created": true }Folder names are sanitized — only alphanumeric characters, hyphens, and underscores are kept (max 64 chars). Folders are also created automatically by upload and all processing endpoints when needed.
List all files inside a folder.
curl:
curl http://localhost:9000/folders/audio \
-H "X-API-Key: your-secret-key"Response:
{
"folder": "audio",
"files": [
{
"filename": "a3f8b2c1.mp3",
"url": "http://your-vps-ip:9000/store/audio/a3f8b2c1.mp3",
"size_bytes": 204800,
"created_at": "2026-03-14T12:00:00"
}
],
"count": 1,
"total_size_bytes": 204800
}Returns all file URLs in a folder formatted as a ready-to-use body for POST /combine.
Query parameters:
type: Eithervideo(default) oraudio.reencode: Boolean. Settrueif you plan to combine files with different codecs.
curl:
curl "http://localhost:9000/folders/main/urls?type=video&reencode=false" \
-H "X-API-Key: your-secret-key"Response:
{
"type": "video",
"urls": [
"http://your-vps-ip:9000/store/main/a3f8b2c1.mp4",
"http://your-vps-ip:9000/store/main/b4c9d2e5.mp4"
],
"reencode": false,
"count": 2
}Delete a folder and all files inside it permanently.
curl:
curl -X DELETE http://localhost:9000/folders/my-project \
-H "X-API-Key: your-secret-key"Response:
{ "deleted": "my-project" }Delete a single file from a folder.
curl:
curl -X DELETE http://localhost:9000/folders/audio/a3f8b2c1.mp3 \
-H "X-API-Key: your-secret-key"Response:
{ "deleted": "a3f8b2c1.mp3", "folder": "audio" }List all stored job output files.
curl:
curl http://localhost:9000/files \
-H "X-API-Key: your-secret-key"Response:
{
"files": [
{
"job_id": "uuid",
"filename": "a3f8b2c1.mp4",
"url": "http://your-vps-ip:9000/files/uuid/a3f8b2c1.mp4",
"size_bytes": 4823042,
"created_at": "2026-03-14T12:00:00"
}
],
"count": 1,
"total_size_bytes": 4823042
}Purge all stored job output files.
curl:
curl -X DELETE http://localhost:9000/files \
-H "X-API-Key: your-secret-key"Response:
{ "deleted_jobs": 12, "message": "Purged 12 job(s)" }Delete a single job's output directory.
curl:
curl -X DELETE http://localhost:9000/files/uuid \
-H "X-API-Key: your-secret-key"Response:
{ "deleted": "uuid" }curl http://localhost:9000/health{ "status": "ok" }All errors return a JSON body with a consistent shape:
{
"detail": {
"error": "FFmpeg processing failed",
"detail": "...raw FFmpeg stderr output..."
}
}The web UI surfaces the error message directly. A ▼ Show FFmpeg log toggle reveals the full stderr so you can diagnose the root cause without touching the server.
Common FFmpeg errors and fixes:
| Error | Cause | Fix |
|---|---|---|
No such file or directory |
Input URL unreachable or wrong path | Verify the URL is publicly accessible |
Invalid data found when processing input |
Corrupted or unsupported container | Re-download or convert the source file |
height not divisible by 2 |
Odd pixel dimensions | Add -vf scale=trunc(iw/2)*2:trunc(ih/2)*2 |
Encoder libx264 not found |
FFmpeg binary missing codec | Use the full FFmpeg Docker image |
Output file is empty |
Silent failure — usually a filter error | Expand the FFmpeg log in the UI |
xfade Could not determine duration |
Clip has no duration metadata | Pre-process with /metadata to verify, or re-encode the source |
HTTP 413 on /upload |
File exceeds 500 MB limit | Compress or split the file first |
File not found on server |
/store/ or /files/ URL points to a deleted file |
Re-upload the file and use the new URL |
All FFmpeg and ffprobe errors are logged server-side (Python logging) with the full command line and stderr tail for every failed job.
There are two independent storage areas:
| Area | Path (container) | Served at | Purpose |
|---|---|---|---|
| Job outputs | /data/outputs/{uuid}/ |
/files/{uuid}/{filename} |
Results from processing endpoints (no folder set) |
| Named folders | /data/folders/{name}/ |
/store/{name}/{filename} |
Uploads and processing outputs routed to a folder |
Built-in named folders (created automatically on first use):
| Folder | Used by |
|---|---|
audio |
Audio file uploads (.mp3, .wav, .m4a, etc.) |
upload |
Non-audio file uploads (video, image, etc.) |
main |
/combine output (default) |
Both storage areas are Docker volumes that persist across container restarts. Neither path requires authentication to read — the URL itself acts as the access token. Add a reverse proxy with authentication if you need access control.
When a processing endpoint uses a /store/ URL as input (e.g. feeding a merge output back into another merge), the server reads the file directly from disk — no HTTP round-trip needed.
Create both directories on the host before starting:
mkdir -p /data/outputs /data/folders| Variable | Description | Default |
|---|---|---|
API_KEY |
Required. Secret key sent in X-API-Key header |
— |
BASE_URL |
Public base URL embedded in returned file URLs | http://localhost:9000 |
OUTPUTS_DIR |
Directory for job output files inside the container | /data/outputs |
FOLDERS_DIR |
Directory for named folder files inside the container | /data/folders |
MAX_DOWNLOAD_BYTES |
Max size for a remote URL fetched by a processing endpoint | 2147483648 (2 GiB) |
ALLOW_PRIVATE_NETWORK_URLS |
Set true to allow fetching from private/internal addresses |
false |
A request that touches remote media crosses several trust boundaries. This section documents them explicitly for anyone reviewing the security posture of a self-hosted deployment; see SECURITY.md for how to report an issue with any of them.
1. Auth boundary — X-API-Key. Every route except /health, /, and the /files,
/store, /static mounts requires the header (app/auth.py, checked with
secrets.compare_digest). It's a single shared secret with no per-client scoping — anyone
holding it can process, list, and permanently delete everything the service has stored.
2. Remote-URL fetch boundary — SSRF surface. POST /merge, /animate, /combine,
/image-to-video, /loop, and /concat-transitions all accept URLs and fetch them via
app/utils/downloader.py. Before connecting (and again on every redirect hop, since redirects
are followed manually rather than via httpx's follow_redirects=True), the target hostname is
resolved and rejected if it points at a private, loopback, link-local, multicast, or reserved
address — this covers cloud metadata endpoints (169.254.169.254) as well as RFC1918 ranges.
Downloads are also capped at MAX_DOWNLOAD_BYTES (default 2 GiB, configurable), enforced during
streaming rather than trusting the Content-Length header, with the partial file removed on
failure. Set ALLOW_PRIVATE_NETWORK_URLS=true if you intentionally point this at an internal
media server. Residual risk: the hostname is resolved once via socket.getaddrinfo and then
connected to separately by httpx — a DNS answer that changes between those two steps (DNS
rebinding) isn't caught. Pinning the resolved IP for the actual connection would close that; it
hasn't been done here.
3. Direct-ffprobe boundary — POST /metadata. Unlike every other route, /metadata hands
its url field straight to the ffprobe subprocess instead of going through the downloader,
because it's only probing metadata, not producing output. That means FFmpeg's own protocol layer
(file:, concat:, subfile:, pipe:, etc.) would otherwise be reachable directly through this
one field. It's mitigated by rejecting any non-http(s) scheme at the Pydantic layer and setting
-protocol_whitelist http,https,tcp,tls on the ffprobe invocation (app/routes/metadata.py), so
a fetched playlist/manifest can't reference local files either. POST /metadata/upload doesn't
have this problem — it always probes a file this server just wrote to disk.
4. Same-origin short-circuit. When a supplied URL points back at this server's own /files/
or /store/ path, download_file() copies the file directly from disk instead of making an HTTP
request (app/utils/downloader.py: _local_path_for_url). This is a deliberate perf/reliability
optimization (avoids Docker loopback networking issues) — but it means a self-referencing URL is
trusted as-is, with no re-validation of what's already on disk.
5. Subprocess boundary — FFmpeg/ffprobe. Every processing and metadata endpoint eventually
runs ffmpeg/ffprobe against attacker-influenced bytes (app/utils/ffmpeg.py). Parser
vulnerabilities in FFmpeg itself are FFmpeg's attack surface, not this project's, but they're real
if you're processing media from untrusted sources — keep the FFmpeg build in the Docker image
current.
6. Output/storage boundary. /files/* and /store/* are unauthenticated StaticFiles
mounts (app/main.py) — the random 8-hex-char filename in the URL is the access token, not the
X-API-Key header. There's no TTL or auto-expiry; output accumulates until purged via the
DELETE endpoints. See Storage above for the two areas this covers.
ChromaFFmpeg/
├── app/
│ ├── main.py # FastAPI app, router registration, static mounts
│ ├── auth.py # API key header verification
│ ├── routes/
│ │ ├── merge.py # Merge video + audio (volume control, strategy)
│ │ ├── animate.py # Ken Burns / zoom / pan effects
│ │ ├── combine.py # Concatenate multiple files (default folder: main)
│ │ ├── image_to_video.py # Image → MP4 with optional animation
│ │ ├── loop.py # Repeat a clip N times via concat demuxer
│ │ ├── transitions.py # xfade + acrossfade transitions
│ │ ├── metadata.py # ffprobe media info
│ │ ├── upload.py # Binary upload → persistent URL (smart default folders)
│ │ ├── folders.py # Named folder CRUD
│ │ └── files.py # List / purge job outputs
│ └── utils/
│ ├── downloader.py # HTTP download + direct disk copy for self-referencing URLs
│ ├── ffmpeg.py # FFmpeg/ffprobe subprocess wrappers + duration probing
│ ├── cleanup.py # Job directory management
│ ├── folders.py # Folder utilities (sanitize, create, list, delete)
│ └── url.py # file_url() and folder_url() helpers
├── static/
│ ├── index.html # Web UI (Alpine.js)
│ └── app.js # Frontend state and API calls
├── Dockerfile
├── docker-compose.yml
├── requirements.txt
└── .env.example
- Output files and uploaded files are never deleted automatically — purge via the UI or the DELETE endpoints
/files/and/store/routes are unauthenticated — the URL acts as the access token; add a reverse proxy with auth if needed- All output filenames are randomized (8-character hex + extension) — collisions are impossible
- Folders are created automatically — you never need to pre-create a folder before using it
- When a processing endpoint downloads a
/store/or/files/URL from this server, it reads the file directly from disk (no HTTP round-trip) — this avoids Docker networking issues and is faster /mergewithaudio_volumeorvideo_audio_volumere-encodes audio to AAC; without volume params it stream-copies audio (faster, lossless)- The
zoompanfilter (/animate,/image-to-video) is CPU-intensive — a 6 s 1080p animation can take 30–90 s depending on hardware /combinewithreencode: falserequires all inputs to share the same codec, resolution, and sample rate; usereencode: truefor mixed sources/concat-transitionsrequires all clips to share the same resolution and fps — the xfade filter errors if dimensions differ/loopuses the concat demuxer with stream copy — output is generated near-instantly regardless of loop count/mergeduration probing tries the container header first, then falls back to stream-level headers; if both fail the response includes a"warning"and uses-shortestas a safe fallback/uploadchunks file reads at 1 MB and enforces a 500 MB hard limit