Frame counts lie. FFmpeg progress percentage is in out_time_us

The transcode ran fine on your laptop. Now it runs on a server for somebody else, and that somebody is watching a spinner with no idea whether the job has ten seconds left or ten minutes. FFmpeg will tell you how far along it is, just not in a shape any UI can use.
Quick answer: To compute an ffmpeg progress percentage, run FFmpeg with-progress pipe:1 -nostats, read theout_time_usvalue out of each key/value block it prints, and divide it by the input's duration in microseconds fromffprobe. That works for a single-pass encode on a machine you control. If the encode runs on a server behind a web app, and especially if the pipeline has more than one FFmpeg stage, the percentage resets at every stage boundary, which is why a hosted job API like FFmpeg Micro reports job status and fires a webhook on completion instead of handing you a stream to parse.
What `-progress pipe:1` actually prints
The -progress flag writes machine-readable key/value lines to a destination you choose, separate from the human-facing status line. Point it at pipe:1 for stdout, pipe:2 for stderr, or a file path or socket URL. FFmpeg emits a block roughly every half second, and each block ends with a progress= line:
frame=1281
fps=44.13
stream_0_0_q=28.0
bitrate=1185.7kbits/s
total_size=6324224
out_time_us=42672000
out_time_ms=42672000
out_time=00:00:42.672000
dup_frames=0
drop_frames=0
speed=1.47x
progress=continue
Only a few of those keys are worth reading, and one of them is a trap:
| Key | What it's good for |
|---|---|
| `out_time_us` | Output timestamp in microseconds. This is your numerator. |
| `out_time_ms` | Despite the name, also microseconds, not milliseconds. Long-standing quirk, kept for compatibility. |
| `speed` | Encode speed relative to realtime (`1.47x`). The only honest input to an ETA. |
| `progress` | `continue` on every block, `end` on the final one. Use it to terminate a block. |
| `frame` / `fps` | Diagnostics. Do not compute percent from these. |
That out_time_ms naming is the single most common bug in home-grown progress parsers. Divide it by 1000 expecting milliseconds and your bar crawls to 0.1% and stops.
Parse by accumulating lines into a dict until you hit a progress= key, then flush. Don't parse line by line and assume ordering, and don't regex the whole buffer, because you'll catch a half-written block.
Why percent comes from out_time_us, not frame=
The frame counter looks like the obvious numerator, and it lies more often than it tells the truth. To turn frame=1281 into a percentage you need the total frame count, and there is no reliable way to get it cheaply. ffprobe's nb_frames field is 0 or N/A for most MPEG-TS, WebM, and fragmented-MP4 inputs, because the container never stored it. For variable-frame-rate sources from iPhones and screen recorders, the number is wrong even when it's present. And any filter that changes the timeline (fps, trim, select, setpts) makes the input count meaningless against the output.
Duration survives all of that. Pull it once before you start:
ffprobe -v error -show_entries format=duration \
-of default=noprint_wrappers=1:nokey=1 input.mp4
Then the percentage is just out_time_us / (duration * 1000000) * 100. Because out_time_us is an output timestamp, it stays correct through most filter graphs, including scaling, watermarking, and subtitle burn-in.
Adjust the denominator when you trim. With -t 30 the job only ever reaches 30 seconds of output, so 30 is your total. With input seeking (-ss 60 -i in.mp4) out_time still starts near zero, so the total is duration minus 60.
The ETA only `speed=` can give you
Elapsed wall-clock time is a bad predictor of remaining time, because encode speed is not constant. The first seconds of an x264 encode are slow while the rate-control settles, keyframe-heavy scenes run slower than static ones, and a machine sharing CPU with other jobs drifts constantly. FFmpeg already measures this for you and publishes it as speed.
Remaining wall-clock seconds is (total_us - out_time_us) / 1000000 / speed. At speed=1.47x with 80 seconds of video left to encode, you have about 54 seconds to go. Recompute it on every block instead of smoothing it once at the start, and expect speed=N/A in the first block or two before FFmpeg has a sample.
A parser that survives real FFmpeg output
The version below handles the cases that break naive parsers: N/A values, the negative out_time_us=-1 FFmpeg sometimes emits on the first block, and stdout buffering in Python.
import subprocess
def duration_us(path):
out = subprocess.run(
["ffprobe", "-v", "error", "-show_entries", "format=duration",
"-of", "default=noprint_wrappers=1:nokey=1", path],
capture_output=True, text=True, check=True).stdout.strip()
return float(out) * 1_000_000
total = duration_us("input.mp4")
proc = subprocess.Popen(
["ffmpeg", "-y", "-i", "input.mp4", "-c:v", "libx264", "-crf", "23",
"-progress", "pipe:1", "-nostats", "-loglevel", "error", "out.mp4"],
stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, text=True, bufsize=1)
block = {}
for line in proc.stdout:
key, _, value = line.strip().partition("=")
block[key] = value
if key != "progress":
continue
raw = block.get("out_time_us", "0")
us = max(int(raw) if raw.lstrip("-").isdigit() else 0, 0)
pct = 100.0 if block["progress"] == "end" else min(99.9, us / total * 100)
speed = block.get("speed", "N/A").rstrip("x")
eta = (total - us) / 1_000_000 / float(speed) if speed.replace(".", "").isdigit() else None
print(f"{pct:5.1f}% eta {round(eta) if eta else '?'}s")
block = {}
proc.wait()
Two details matter more than the arithmetic. bufsize=1 with text=True gives line buffering, without which Python sits on a 4 KB buffer and your bar updates in bursts every twenty seconds. And progress=end is the only reliable "done" signal inside the stream, but it is not a success signal. Check proc.returncode too, because a killed process produces no end block at all.
Common pitfalls when parsing FFmpeg progress
Merging stderr into stdout is the one that costs the most debugging time. FFmpeg's default status line (frame= 1281 fps= 44 q=28.0 size=...) goes to stderr, and on its own it's harmless. The moment you write 2>&1 in a shell, or pass stderr=subprocess.STDOUT in Python, that line and every warning about non-monotonic DTS land in the same pipe as your key/value blocks and your parser chokes on them. Either send stderr somewhere else with 2>/dev/null, or add -nostats so the line is never produced. Keep -loglevel error so real failures still reach you on stderr.
Writing the encode to stdout and the progress to stdout is the other guaranteed failure. If you're muxing to pipe:1 for streaming, MP4 bytes and progress text interleave and both become garbage. Send progress to pipe:2 instead, or to a file or Unix socket: -progress unix:///tmp/ffprogress.sock.
Unknown duration kills percentage entirely. When FFmpeg reads from a pipe, an HLS playlist, or an RTMP ingest, there's no total to divide by, and ffprobe returns N/A. You can still show out_time and speed, but a bar isn't honest. Fall back to an indeterminate spinner rather than inventing a denominator.
The half-second update rate is often too chatty for a websocket fan-out. FFmpeg 4.4 added -stats_period 1 to slow it down, which cuts your event volume by more than half with no loss of usefulness.
And the stale -progress name matters if you're on a newer build. FFmpeg 9 removed several flags older scripts still carry, so verify your wrapper's full command line against the version you actually ship, not the one in a 2019 gist.
Where a parsed progress stream stops working
A parsed stream reports one FFmpeg process, and production video work is rarely one FFmpeg process. Run a two-pass x264 encode and your percentage climbs to 100, then drops back to 0 when pass two starts. The fix is to weight it yourself: with two passes, overall percent is (pass_index + fraction) / 2 * 100. That works right up until the pipeline is download, then normalize, then burn captions, then package, at which point you're maintaining a table of per-stage weights that is wrong whenever anyone edits the graph.
Here's the practical split:
| Parsing `-progress` yourself | Hosted job status and webhooks | |
|---|---|---|
| You need | A local FFmpeg binary, a live process handle, ffprobe for duration | An HTTP client |
| Multi-stage jobs | You maintain stage weights by hand | The job reports its own state |
| Process dies mid-encode | Your pipe closes and you infer failure | Terminal status with an error |
| Web app across a request boundary | Needs a worker, a pubsub channel, and reconnect logic | Poll the job, or take the webhook |
The second column is the reason hosted video APIs don't expose a progress stream. Once the encode lives behind an HTTP boundary, the useful unit is job state, not encoder telemetry. FFmpeg Micro runs the job on managed infrastructure and gives you two ways to follow it: poll the job for status, or register a webhook and take the completion event, both documented at ffmpeg-micro.com/docs. That's the same pattern as any reliable long-running video pipeline, and it's what automation tools expect: n8n disabled the Execute Command node by default in v2.0, so there's no local pipe to parse there anymore even if you wanted one.
FAQ
Why does my FFmpeg progress percentage go past 100%?
A percentage above 100 means the denominator is too small. The usual causes are a container whose declared format=duration is shorter than the real content (common with concatenated MPEG-TS), a filter like loop or tpad that extends the output past the input length, or an -ss offset you subtracted twice. Clamp the display at 99.9% until progress=end arrives.
Can I get FFmpeg progress without running ffprobe first?
Running ffprobe first is the reliable way to get a duration for the percentage, but you can skip it if you only need speed and elapsed output time. FFmpeg does print Duration: to stderr during input parsing, and plenty of wrapper libraries scrape it from there. That scrape breaks on inputs where the header lacks a duration, which is exactly the set of inputs where percentage is impossible anyway.
Does -progress work when FFmpeg reads from a pipe or a live stream?
The -progress output still works with piped and live inputs, and it still reports out_time_us, speed, bitrate, and total_size normally. What you lose is the total duration, so there's no percentage to compute. Show elapsed output time and encode speed instead of a bar.
What is the difference between out_time_us and out_time_ms?
There is no difference in value. FFmpeg's -progress output reports out_time_ms in microseconds despite the name, so out_time_us and out_time_ms carry the identical number. Use out_time_us, since its name matches its units.
How do I track FFmpeg job status across a web request?
Tracking FFmpeg job status across a web request needs a job record the client can query, because the HTTP request that started the encode is long gone by the time it finishes. Write progress updates from your worker into Redis or a database row keyed by job ID and poll that, or let a video API own the job and send you a webhook.
If you'd rather not build the worker, the pubsub channel, and the stage-weight table at all, sign up free and submit the same encode as one API call, then poll the job or take the webhook when it's done.
About Javid Jamae
Founder & CEO at FFmpeg Micro
Javid is a software engineer, author, and entrepreneur with over 25 years of professional software development experience across enterprise, startup, and consulting environments. He founded FFmpeg Micro to make video processing accessible to developers through a simple, automation-first REST API.
You might also like

scale_npp Removed in FFmpeg 9: Move GPU Scaling to scale_cuda
scale_npp removed in FFmpeg 9.0 along with all libnpp filters. The scale_cuda rewrite for every -vf chain, plus what a rented GPU actually costs you now.

How to Add Chapters to MP4 with FFmpeg (FFMETADATA)
To make FFmpeg add chapters you write an FFMETADATA file and remux with -c copy. The hard part is generating that file: TIMEBASE, escaping, and the last END.

Convert Animated WebP to MP4: FFmpeg 9 Finally Decodes It
FFmpeg 9.0 decodes animated WebP directly, so animated webp to mp4 is now one command. The alpha-channel gotcha, variable frame timing, and the API fallback.
Ready to process videos at scale?
Start using FFmpeg Micro's simple API today. No infrastructure required.
Get Started Free