Every FFmpeg project starts small. You need to turn a MOV into an MP4, so you type one command, it finishes in four seconds, and you move on with your life.
Then the requests start arriving.
"Can we put the logo in the corner? Actually, top-right, and make it fade in over the first second." Then: "We need the same video in 1080p, 720p, and 480p for HLS." Then: "The podcast clips need the music ducked under the voice." Then: "Can you stitch the three camera angles together and crossfade between them?"
Suddenly you're not running a conversion. You're running a filter graph — a directed pipeline of scale, overlay, xfade, concat, loudnorm, sidechaincompress, and half a dozen other filters wired together with labels like [v1] and [aout]. It's one -filter_complex string, and it's the most powerful thing in the FFmpeg toolbox.
It's also the point where your infrastructure starts to hurt.
A single 1080p filter graph with four parallel encode branches will happily eat every core on your laptop for a minute. Run five of those at once on a $20 VPS and the machine falls over. Run fifty and you're not building a product anymore — you're running a job queue, a worker fleet, and an on-call rotation for video encoding.
This article is about breaking that connection. Specifically, it's about how to run filter_complex jobs — the full, gnarly, arbitrary-command version — without owning or managing the servers that execute them.
Why filter_complex Is a Different Beast Than -vf
Most FFmpeg tutorials stop at -vf, the simple video filter chain. One input, one output, filters applied left to right:
ffmpeg -i in.mp4 -vf "scale=1280:-2,unsharp=5:5:0.8" out.mp4
That's fine as far as it goes. But -vf can only describe a straight line. It can't branch, it can't merge, and it can't reference two inputs at once.
-filter_complex removes all three limits.
Inside a filter graph you can:
- Split a stream into multiple copies with
splitand process each one differently. - Combine multiple inputs — video over video, audio mixed with audio, a PNG logo overlaid on a moving frame.
- Merge streams with
concat,overlay,amix,xfade,acrossfade,sidechaincompress,hstack,vstack,xstack. - Time-shift and retime with
setpts,fps,tpad,atrim. - Reuse a label across the graph, so one decode feeds five encode branches.
Here's a realistic one. Take a source video, scale it once, then use that scaled version for three downstream paths — a watermark composite, a blurred background plate, and a sharp foreground:
ffmpeg -i input.mp4 -i logo.png \
-filter_complex "\
[0:v]scale=1920:1080:force_original_aspect_ratio=decrease,\
pad=1920:1080:(ow-iw)/2:(oh-ih)/2,setsar=1[base];\
[base]split=2[b1][b2];\
[1:v]scale=180:-1[wm];\
[b1][wm]overlay=W-w-40:40:format=auto[withlogo];\
[b2]boxblur=24:2[blurred];\
[withlogo][blurred]blend=all_mode=screen:all_opacity=0.15[vout];\
[0:a]loudnorm=I=-16:TP=-1.5:LRA=11[aout]" \
-map "[vout]" -map "[aout]" \
-c:v libx264 -crf 20 -preset medium -pix_fmt yuv420p \
-c:a aac -b:a 160k -movflags +faststart out.mp4
That's one command. It decodes two inputs, splits a stream, composites two overlays, applies a blend mode, and normalizes loudness to broadcast-ish target levels. It will run on your laptop. It will also pin four cores for the duration, allocate a few hundred megabytes of frame buffers, and take roughly as long as the video is long multiplied by a factor that depends on preset, resolution, and how many branches you spawned.
Now imagine a user uploads a 12-minute recording and your app needs to do that plus a 9:16 vertical version. Two graphs, twenty-four minutes of source, maybe three to six minutes of wall-clock compute on a decent machine — and that's if only one person does it at a time.
That's the shape of the problem.
Where the "Server Limits" Actually Come From
People say "FFmpeg is CPU-heavy" and leave it there. The reality is more specific, and the specifics matter because they determine what a managed API actually has to solve for you.
CPU: the obvious one, but not the only one
libx264 at preset medium on 1080p typically runs somewhere between 1.5x and 4x realtime on a modern desktop core-complex, depending on content complexity and how many filter branches you've added. Bump to preset slow or veryslow and you can drop below realtime. Move to 4K and you can drop a lot below realtime.
Now add filters. boxblur, unsharp, nlmeans, minterpolate, and zscale all cost real cycles. A split with five branches means five encoders competing for the same cores. FFmpeg will use as many threads as you let it, which is great for a single job and terrible for ten concurrent jobs on the same box.
Memory: the quiet killer
Each branch in a filter graph holds frames in memory. At 1080p with yuv420p, a single frame is about 3 MB. A graph with a split into four branches, each holding a short queue, can easily hold 20–40 frames of buffers in flight — plus decoder and encoder working sets. That's hundreds of megabytes before you count the OS page cache.
At 4K, a frame is roughly 12 MB. Do the arithmetic and you'll see why "just add more concurrency" stops working long before you run out of CPU.
Disk and network I/O
Most filter_complex jobs aren't pure transcodes. They have multiple inputs. A logo PNG, a music bed, an SRT subtitle file, a second camera angle. Those inputs come from somewhere, and if that somewhere is object storage, you're paying download time on every single job. On a self-managed box you also need scratch space for intermediate files, which fills up fast and is a genuine source of 3 AM pages.
Concurrency: the part that actually breaks apps
Here's the failure mode I've seen most often. The app works fine in staging. Then a batch of uploads lands, ten transcodes start simultaneously on a 4-core server, and now every single job is four times slower. Latency spikes, HTTP timeouts fire, clients retry, and the retries pile more jobs onto the already-saturated box. A queue fixes this, but a queue is another service to run, monitor, and tune.
The honest summary: the "server limit" isn't one number. It's an interaction between core count, memory bandwidth, scratch I/O, and how many graphs you're running at the same moment.
What a Serverless FFmpeg API Actually Changes
A serverless FFmpeg API takes that entire cluster of concerns and moves it off your plate. You send a request describing the job. Somewhere else, a machine spins up, runs your exact FFmpeg arguments, writes the result, and hands you back a URL. Then that machine goes away.
The important word there is exact. A lot of "video APIs" are not this. They're preset machines — you pick from a menu (convert to mp4, compress for web, extract thumbnail) and the API translates your choice into some FFmpeg invocation you never see. That's genuinely useful for simple jobs, but it collapses the moment you need an xfade with a custom offset or a sidechaincompress with specific attack and release values. You're back to writing your own service.
FFmpeGo takes the other route. You send the arguments. All of them. The service runs them.
The model is a single endpoint, /v2/run, and a JSON payload that contains your input URLs plus the FFmpeg argument list you want executed. That's it. No DSL to learn, no filter subset to work around, no "we don't support that codec" support ticket.
A few things follow from that design, and they're the reason it's worth considering rather than just renting a bigger VM:
Multi-input jobs are first-class. Your graph can reference [0:v], [1:v], [2:a] exactly as it would on your laptop. Logos, music beds, second angles, subtitle files — pass them as inputs and build the graph normally.
filter_complex graphs run unmodified. Because the arguments go through untouched, a graph you developed locally is a graph that runs in the cloud. That sounds obvious. In practice it's the whole ballgame.
Compute-second billing is transparent. More on this below, but the short version is that you're billed for wall-clock execution time of the FFmpeg process. A 40-second job costs 40 compute seconds. A graph that takes 90 seconds costs 90.
Only successful jobs are billed. Failed encodes — bad filter syntax, unsupported codec, malformed input URL — return a non-2xx response and aren't charged. For development, where you'll run a graph twenty times with small tweaks, this matters a lot.
Turning a Terminal Command Into an API Call
The mental translation from local command to API request is smaller than you'd expect. Let's walk through it.
Start with a command you've already tested locally:
ffmpeg -i source.mp4 -i watermark.png \
-filter_complex "[0:v]scale=1280:720[bg];\
[1:v]scale=140:-1,format=rgba,\
colorchannelmixer=aa=0.7[wm];\
[bg][wm]overlay=W-w-24:24[vout];\
[0:a]aclnorm[aout]" \
-map "[vout]" -map "[aout]" \
-c:v libx264 -crf 21 -preset fast \
-c:a aac -b:a 128k -movflags +faststart result.mp4
Two things to notice before you touch the API. First, -i source.mp4 and -i watermark.png become input URLs — the service fetches them, so they need to be reachable over HTTP(S). Second, the output path result.mp4 isn't something you control; the service handles where the file lands and returns a URL.
The payload, then, is roughly:
{
"inputs": [
"https://storage.example.com/uploads/source.mp4",
"https://cdn.example.com/brand/watermark.png"
],
"args": [
"-filter_complex",
"[0:v]scale=1280:720[bg];[1:v]scale=140:-1,format=rgba,colorchannelmixer=aa=0.7[wm];[bg][wm]overlay=W-w-24:24[vout];[0:a]aclnorm[aout]",
"-map", "[vout]",
"-map", "[aout]",
"-c:v", "libx264",
"-crf", "21",
"-preset", "fast",
"-c:a", "aac",
"-b:a", "128k",
"-movflags", "+faststart"
]
}
And the response gives you a job status and, on completion, a URL for the output. Check the current docs for the precise field names and status semantics — schemas evolve, and I'd rather you read the live spec than trust a blog post.
The step-by-step version
- Get the graph working locally first. Don't debug filter syntax through an API. Run it in your terminal against a small sample. Confirm the output looks right.
- Move your inputs to accessible URLs. S3, R2, GCS, a signed URL from your own app, whatever. The service needs to fetch them.
- Convert the
-iflags into theinputsarray. Order matters.inputs[0]is[0:v],inputs[1]is[1:v]. - Copy the rest of the arguments verbatim into
args. Keep the-mapflags. Keep the codec flags. Keep everything that isn't an input or output path. - Drop the output filename. The service decides that.
- Fire one test request against a short, small file. A ten-second 480p clip tells you whether the graph parses in seconds rather than minutes.
- Inspect the returned media before wiring it into your app. Streams, duration, pixel format, audio channels.
That's the whole migration. If you've been putting off moving a media pipeline to the cloud because you assumed you'd have to rewrite your filter graphs in some proprietary format, that assumption is wrong here.
Five filter_complex Jobs Worth Offloading
To make this concrete, here are five graphs that come up constantly in real products, with notes on what makes each one awkward to run on your own box.
1. Multi-resolution HLS ladder in a single pass
ffmpeg -i input.mp4 \
-filter_complex "\
[0:v]split=3[v1][v2][v3];\
[v1]scale=1920:1080[v1080];\
[v2]scale=1280:720[v720];\
[v3]scale=854:480[v480]" \
-map "[v1080]" -map 0:a -c:v:0 libx264 -b:v:0 5000k -preset medium \
-map "[v720]" -map 0:a -c:v:1 libx264 -b:v:1 2800k -preset medium \
-map "[v480]" -map 0:a -c:v:2 libx264 -b:v:2 1400k -preset medium \
-g 48 -keyint_min 48 -sc_threshold 0 \
-c:a aac -b:a 128k -ac 2 \
-f hls -hls_time 6 -hls_playlist_type vod \
-var_stream_map "v:0,a:0 v:1,a:0 v:2,a:0" \
"stream_%v.m3u8"
This is the classic one-pass ladder. Three encoders running simultaneously over the same decoded frames. On a 4-core machine it'll work, just slowly, and it'll hog the box while it does. As a cloud job it's a single request that returns when the ladder is ready.
2. Picture-in-picture with a branded frame
ffmpeg -i main.mp4 -i guest.mp4 -i frame.png \
-filter_complex "\
[0:v]scale=1280:720[bg];\
[1:v]scale=360:-1[pip];\
[pip]format=rgba,colorchannelmixer=aa=0.95[pipa];\
[bg][pipa]overlay=W-w-32:H-h-32[withpip];\
[2:v]scale=1280:720[fr];\
[withpip][fr]overlay=0:0[vout];\
[0:a]volume=1.0[ma];\
[1:a]volume=1.0[ga];\
[ma][ga]amix=inputs=2:duration=longest:dropout_transition=2,\
loudnorm=I=-16:TP=-1.5:LRA=11[aout]" \
-map "[vout]" -map "[aout]" \
-c:v libx264 -crf 20 -preset medium -pix_fmt yuv420p \
-c:a aac -b:a 160k out.mp4
Three inputs, two overlay composites, an audio mix, and a loudness pass. Note the format=rgba before colorchannelmixer — alpha manipulation only works on a pixel format that carries alpha, and forgetting that step is a very common source of "my logo is a black box."
3. Concatenating clips with mismatched resolutions
ffmpeg -i a.mp4 -i b.mp4 -i c.mp4 \
-filter_complex "\
[0:v]scale=1920:1080:force_original_aspect_ratio=decrease,\
pad=1920:1080:(ow-iw)/2:(oh-ih)/2,setsar=1,fps=30[v0];\
[1:v]scale=1920:1080:force_original_aspect_ratio=decrease,\
pad=1920:1080:(ow-iw)/2:(oh-ih)/2,setsar=1,fps=30[v1];\
[2:v]scale=1920:1080:force_original_aspect_ratio=decrease,\
pad=1920:1080:(ow-iw)/2:(oh-ih)/2,setsar=1,fps=30[v2];\
[0:a]aresample=48000,aformat=sample_fmts=fltp:channel_layouts=stereo[a0];\
[1:a]aresample=48000,aformat=sample_fmts=fltp:channel_layouts=stereo[a1];\
[2:a]aresample=48000,aformat=sample_fmts=fltp:channel_layouts=stereo[a2];\
[v0][a0][v1][a1][v2][a2]concat=n=3:v=1:a=1[vout][aout]" \
-map "[vout]" -map "[aout]" \
-c:v libx264 -crf 20 -preset medium -c:a aac -b:a 160k out.mp4
concat demands that every segment agree on resolution, SAR, frame rate, sample format, sample rate, and channel layout. Any mismatch produces a hard error or, worse, subtle A/V drift. Normalizing in the graph is the fix, and it's tedious to get right — which is exactly why you want to iterate on it against a managed endpoint rather than a fragile local box.
4. Crossfading between two clips
ffmpeg -i clip1.mp4 -i clip2.mp4 \
-filter_complex "\
[0:v]scale=1920:1080,setsar=1,fps=30[v0];\
[1:v]scale=1920:1080,setsar=1,fps=30[v1];\
[v0][v1]xfade=transition=fade:duration=1:offset=9[vout];\
[0:a]aresample=48000[a0];\
[1:a]aresample=48000[a1];\
[a0][a1]acrossfade=d=1:c1=tri:c2=tri[aout]" \
-map "[vout]" -map "[aout]" \
-c:v libx264 -crf 20 -preset medium -c:a aac -b:a 160k out.mp4
The offset is where the transition begins, measured in seconds from the start of the first clip. Get it wrong and you'll either cut off the end of clip one or produce a frozen tail. If clip one is 10 seconds long and you want a one-second crossfade, offset=9 is correct.
5. Vertical 9:16 reframe with a blurred background
ffmpeg -i input.mp4 \
-filter_complex "\
[0:v]split=2[bg][fg];\
[bg]scale=1080:1920:force_original_aspect_ratio=increase,\
crop=1080:1920,boxblur=40:4[bgb];\
[fg]scale=1080:-2[fgs];\
[bgb][fgs]overlay=(W-w)/2:(H-h)/2[vout];\
[0:a]loudnorm=I=-16:TP=-1.5:LRA=11[aout]" \
-map "[vout]" -map "[aout]" \
-c:v libx264 -crf 21 -preset fast -c:a aac -b:a 128k out.mp4
The standard trick for turning a 16:9 clip into a TikTok/Reels/Shorts-shaped one. Two branches, a blur, an overlay. Cheap per frame, but the boxblur on a 1080x1920 plate is not free, and it runs for the whole duration.
Compute Seconds: Why Billing by Wall Clock Is the Right Unit
Most cloud services bill in units you can't predict. vCPU-hours. GB-months. Transcode-minutes rounded up to the nearest minute. You end up building a spreadsheet to guess what a job will cost.
Compute seconds is a different approach. One compute second is one second of wall-clock time that the FFmpeg process was running. A graph that finishes in 47 seconds costs 47 compute seconds. Nothing is rounded up to a minute. Nothing is multiplied by a vCPU count you can't see.
Why does that matter for filter_complex specifically?
Because filter graphs have wildly variable cost. The same source file, run through -vf scale and nothing else, might take 30 seconds. Run through a three-branch ladder with blurred backgrounds and loudness normalization, it might take four minutes. Under a per-minute, round-up billing model, you're paying the same rate for both — which means you either overpay on the cheap jobs or you build elaborate logic to batch and amortize.
With compute seconds, the cost tracks the work. A graph that's three times as expensive to run costs three times as much. That's the honest relationship, and it makes capacity planning dramatically easier. You can measure a job locally, get a rough ratio, and predict the cloud cost within a reasonable margin.
Two more properties of the pricing that are worth calling out, because they're unusual:
Failed jobs aren't billed. Only responses in the 2xx range count. If your filter graph has a typo and FFmpeg exits with an error, you don't pay for the seconds spent discovering that. During development, when you might tweak a single filter parameter and rerun twenty times, this removes the small nagging anxiety that every experiment costs money.
Monthly caps are hard. You set a ceiling. When you approach it, the service stops accepting work rather than quietly continuing and surprising you with an invoice. For anyone who has been burned by a runaway batch job on a cloud account, that's a meaningful difference.
Concurrency Without Building a Queue
Here's a scenario. Your app lets users upload video. During a typical hour you get 200 uploads. Each one needs a 1080p transcode with a logo overlay plus a 9:16 version. That's 400 filter graphs.
On your own infrastructure, this is a queue problem. You need to decide how many workers to run. Too few and the backlog grows. Too many and you're paying for idle capacity most of the day and still hitting a wall at peak. You need retry logic, dead-letter handling, and monitoring. And when a worker dies mid-job, you need to detect that and requeue.
With a serverless API, this collapses to: send 400 requests, handle 400 responses. Concurrency is the provider's problem. You're not managing workers, you're not tuning a queue depth, and you're not paying for a machine that sits idle at 3 AM waiting for uploads that aren't coming.
That said, you should still write your client-side code defensively. A few habits that save grief:
- Set generous timeouts. A ten-minute source file with an elaborate graph can legitimately take several minutes. A 30-second HTTP timeout will produce false failures and wasted retries.
- Retry on 5xx and network errors, not on 4xx. A malformed filter graph will fail identically every time. Retrying it just burns time.
- Log the full argument list on failure. When a graph works in staging and fails in production, the diff is almost always an input URL or a filter parameter, and having the exact args in your logs turns a two-hour investigation into a two-minute one.
- Validate URLs before you submit. Signed URLs expire. A job submitted with a URL that expires in five seconds will fail at fetch time, and the error message won't always be obvious about it.
Debugging Filter Graphs Before You Ship Them
Filter graph errors are notoriously unhelpful. Cannot find a matching stream for unlabeled input pad 2 on filter Parsed_concat_0 tells you something is wrong but not what. Here's a workflow that shortens the loop.
Build incrementally. Don't write a 12-filter graph and run it. Write three filters, run it, confirm the output. Add two more, run again. The error, when it comes, belongs to the change you just made.
Use tiny inputs. Generate a 5-second test clip with testsrc and a sine wave:
ffmpeg -f lavfi -i testsrc2=size=1280x720:rate=30 -f lavfi -i sine=frequency=440 \
-t 5 -c:v libx264 -preset ultrafast -c:a aac test.mp4
A 5-second 720p test runs in about a second. You can iterate on the graph dozens of times without waiting.
Label everything, even when it's optional. Unlabeled pads in a complex graph are where errors hide. Give every intermediate stream a name like [v_scaled] or [a_norm]. It costs nothing and makes error messages legible.
Check stream specs with ffprobe. Before you concat or xfade, confirm both inputs actually have the resolution, frame rate, sample rate, and pixel format you assume:
ffprobe -v error -show_entries stream=index,codec_type,width,height,r_frame_rate,pix_fmt,sample_rate,channels \
-of default=noprint_wrappers=1 input.mp4
Ninety percent of filter_complex failures come down to one input not matching what the filter expects.
Test the API path early. Your first cloud request should be a trivial one — a scale, a copy, nothing fancy — just to confirm the plumbing works end to end. Once inputs, args, and output retrieval are proven, swap in the real graph.
Common Mistakes With filter_complex in the Cloud
A short list of things that bite people, drawn from the patterns that come up again and again.
Forgetting -map. If you build a graph but don't map its outputs, FFmpeg may pick a stream automatically and produce an output that ignores your entire graph. Always map explicitly.
Leaving out -pix_fmt yuv420p. Many players and platforms won't touch yuv444p or yuv422p output. libx264 will happily produce them if the input is in that format. Add the flag.
Assuming alpha survives. Most video codecs don't carry an alpha channel. If you composite something over a transparent background expecting it to stay transparent, it won't, unless you're writing to a format that supports it (ProRes 4444, VP9 with alpha, or PNG/WebP sequences).
Mismatching frame rates before xfade. xfade expects both inputs to have the same timebase and frame rate. Put fps=30 and settb=AVTB on both branches. Skipping this produces frames that look glued together at the seam or a transition that jumps.
Ignoring SAR. A source with non-square pixels will produce stretched output after scaling unless you add setsar=1. This is subtle and easy to miss until someone points at the video and says "why does everyone look slightly wide?"
Hardcoding absolute file paths. /tmp/input.mp4 doesn't exist in the cloud. Everything is a URL.
Not controlling the encoder preset. Default libx264 preset is medium. If your job is taking too long, preset fast or preset veryfast often cuts 40–60% of the encode time for a small quality cost. Measure before you assume you need slow.
Leaving -movflags +faststart off for web delivery. Without it, the moov atom sits at the end of the file and the video won't start playing until it's fully downloaded. On a large file that's a visible, annoying delay.
DIY Servers vs. Preset Video APIs vs. an FFmpeg API
Three approaches, and they're genuinely different tools. Here's how they line up.
| Self-managed FFmpeg servers | Preset video APIs | FFmpeGo | |
|---|---|---|---|
| Filter flexibility | Full | Limited to offered presets | Full — arbitrary arguments |
| filter_complex support | Yes | Rare or partial | Yes, unmodified |
| Multi-input jobs | Yes | Sometimes | Yes |
| Infrastructure to run | Servers, queues, autoscaling, monitoring | None | None |
| Pricing model | Instance hours + bandwidth | Per-minute, often rounded up | Compute seconds (wall clock) |
| Billed on failure | You pay for the machine either way | Varies | No — 2xx only |
| Spend protection | You build it | Varies | Hard monthly caps |
| Time to first job | Hours to days | Minutes | Minutes |
| Getting unstuck | You debug FFmpeg yourself | Support ticket to request a feature | You're already running real FFmpeg |
The middle column isn't a straw man. Preset APIs are legitimately great if your needs really are "convert to MP4" and "make a thumbnail." The trouble is that requirements drift. Someone asks for a watermark. Then a chapter-marker burn-in. Then audio ducking. Each new ask either fits a preset or forces you to build your own pipeline — and by the time you've built it, you've spent more effort than you'd have spent learning filter_complex in the first place.
Frequently Asked Questions
Does FFmpeGo support the full FFmpeg filter set, or just a subset?
Full, arbitrary arguments. The service passes your argument list to FFmpeg as-is. If a filter is compiled into the FFmpeg build being used, it runs. If you hit a specific filter or codec that isn't available, that's worth raising, but the design intent is "everything FFmpeg can do."
How do I handle multi-input jobs where inputs need to be referenced by index?
Your inputs array maps positionally to FFmpeg's input indices. inputs[0] becomes [0:v] / [0:a], inputs[1] becomes [1:v] / [1:a], and so on. Order matters, so build the array in the same order you'd write the -i flags.
What happens if my filter graph has a syntax error?
FFmpeg exits non-zero, the API returns a non-2xx response, and you aren't billed. You get the stderr output back, which is where the useful error message lives. Read it carefully — the "Cannot find a matching stream" family of errors usually means a label typo or an unlabeled pad.
Can I pass a subtitle file or a font for drawtext?
Yes, as another input URL. Reference it by index in the graph. For subtitles, you'll typically fetch the SRT as an input and reference the path in the filter. For drawtext with a custom font, the font needs to be reachable too.
Is there a job duration limit?
Check the current documentation for the specific limits — they can change. Practically, for very long jobs you'll want to consider whether a single graph is the right approach versus splitting work across multiple requests. A two-hour 4K ladder is a lot of compute seconds; a two-hour 4K ladder that fails at minute 110 is a lot of wasted compute seconds.
How do I control costs?
Set a monthly cap. Then measure. Run your real graph against a representative file, note the compute seconds, and multiply by your expected volume. If the number is uncomfortable, look at encoder preset first — that's usually the biggest lever — then at whether you need every output rendition on every job.
Does this work for audio-only jobs?
Yes. filter_complex handles audio graphs the same way — amix, sidechaincompress, loudnorm, atempo, aresample, concat with v=0:a=1. Podcast processing, music normalization, and multi-track mixing all fit the same request shape.
Can I run the same graph on many files in parallel?
Yes. Submit one request per file. There's no queue for you to manage. Just be sensible about client-side rate limiting so you don't spike your own outbound connections.
Where to Go From Here
The gap between "FFmpeg works on my laptop" and "FFmpeg works for my product" is almost entirely an infrastructure gap. The filter graphs themselves don't change. Your overlay, your xfade, your sidechaincompress, your three-branch HLS ladder — all of that is portable, and it stays portable because a real FFmpeg API runs real FFmpeg.
So here's a reasonable plan if you've got a filter_complex job that's outgrown its current home:
- Get the graph clean locally. Use a 5-second test clip. Label every pad. Add
setsar=1andfpsnormalization where you're combining streams. - Confirm the specs. Run
ffprobeon every input. Know the resolution, frame rate, pixel format, sample rate, and channel layout you're feeding in. - Move inputs to URLs. Object storage or signed URLs. Make sure they stay valid for the duration of the job.
- Send one request with a trivial graph. Prove the plumbing.
- Send the real graph. Compare the output against your local run, frame for frame.
- Measure compute seconds on a representative file. Multiply by volume. Set your cap.
- Wire it into your app and delete whatever server you were about to provision.
The part people underestimate is how much time gets returned. Not the encoding time — the time spent on queues, worker health checks, autoscaling policies, spot instance interruptions, and the slow creep of "why is the transcode box at 100% CPU again." That's the actual payoff. You write filter graphs, you send them somewhere, you get video back. The rest stops being your problem.
If you want to see what your current pipeline looks like as an API call, the docs at ffmpego.com walk through the request format and the free tier is enough to run a real job end to end. Start with the graph you already have. One request. See what comes back.