Validate video uploads with ffprobe, not extensions

Probe an uploaded file to confirm it is real media before you encode it, with four checks, a local Node validator and the error a renamed file returns.

Share

Run ffprobe on an uploaded file before you do anything else with it. If it does not parse, it is not media, whatever its name says, and nothing downstream will make it one. This post gives the four checks worth running, a local validator you can paste into a Node backend, and the exact error a renamed file produces.

I went through the probe jobs on Rendobar that did not complete, and they make the case:

  • About 70% could not fetch the input at all. Dead URL, wrong host, expired link.
  • About a quarter fetched the file and then failed to parse. Every one was named .mp4, and the bytes were not video.
  • The rest never started, which is an infrastructure failure, not a file problem.

The middle group is the one that matters. The filename said MP4, the upload succeeded, and nothing downstream would have caught it before an encoder did. The sample is small and comes from API traffic, not a public upload form, so expect your own split to differ.

The extension is a claim, not a fact

The file extension is part of a name the uploader chose. Renaming report.pdf to report.mp4 takes a keystroke, and users do it by accident, usually while trying to “convert” something.

The Content-Type header is set by the client. A browser guesses it from the extension, and a script sets it to whatever its author typed. Neither has looked at the bytes.

ffprobe reads the header, matches it against every demuxer it has, and reports what it found with a probe_score for how sure the match is. A well-formed MP4 comes back with "probe_score": 100. An HTML error page saved as page.mp4 comes back as an error. All local output in this post is from FFmpeg 8.0:

[mov,mp4,m4a,3gp,3g2,mj2 @ 0000022ced464640] moov atom not found
page.mp4: Invalid data found when processing input

The first line is FFmpeg guessing the format from the extension and finding no MP4 index. The second is the verdict.

The four checks

Everything you need to validate is a field in one probe:

Terminal window
ffprobe -v error -print_format json -show_format -show_streams input.mp4
  1. Does it parse. If the command exits non-zero, stop. There is no partial credit, and every bad upload in the sample above failed here.
  2. Does it have the stream you need. Walk streams for a codec_type of video or audio. Do not infer this from the container: an MP4 can be audio-only, and an MP3 with cover art reports a video stream that is one still image, marked by disposition.attached_pic set to 1.
  3. Is the duration present and usable. format.duration can be missing on a stream, and on a still image the image2 demuxer reports a nominal duration. A missing duration is not always a rejection, but it means you cannot estimate cost before running the job.
  4. Is it within what you will pay to encode. Resolution, frame rate and bitrate are all in the stream entry, and this is where you decide an 8K 120 fps input is not something your free tier will transcode.

Once a file passes, some of those fields still mislead, and ffprobe metadata gotchas covers which.

A local validator

Most backends want this check in-process, with FFmpeg installed next to the app. This runs the command above and applies the four checks:

import { execFile } from "node:child_process";
import { promisify } from "node:util";
const run = promisify(execFile);
export async function checkUpload(path) {
let stdout;
try {
({ stdout } = await run("ffprobe", [
"-v", "error", "-print_format", "json", "-show_format", "-show_streams", path,
]));
} catch (err) {
// 1. It does not parse. ffprobe's stderr says why.
return { ok: false, reason: err.stderr?.trim() || err.message };
}
const { format, streams } = JSON.parse(stdout);
// 2. It has the stream you need. Cover art is a video stream too, so skip it.
const video = streams.find(
(s) => s.codec_type === "video" && s.disposition?.attached_pic !== 1,
);
if (!video) return { ok: false, reason: "no video stream" };
// 3. The duration is present and positive.
const duration = Number(format.duration);
if (!(duration > 0)) return { ok: false, reason: "no usable duration" };
// 4. It is within what you will pay to encode.
if (video.width > 3840 || video.height > 2160) return { ok: false, reason: "larger than 4K" };
return { ok: true, duration, width: video.width, height: video.height, codec: video.codec_name };
}

Run against four local files, it returned:

in.mp4 {"ok":true,"duration":3,"width":1280,"height":720,"codec":"h264"}
page.mp4 {"ok":false,"reason":"... moov atom not found\npage.mp4: Invalid data found when processing input"}
a.mp3 {"ok":false,"reason":"no video stream"}
logo.png {"ok":false,"reason":"no usable duration"}

Note the PNG. ffprobe reads it happily as a one-frame video stream with no container duration, so check 3 is what rejects an image uploaded where you wanted a video.

What a probe does not tell you

A probe reads the header. It does not decode the file.

So it accepts a file whose header is fine and whose frames stop halfway. A 3-second MP4 with its index at the front, cut to 600,000 of its 1,087,474 bytes, still probed as 3.000000 seconds and exited 0.

-count_frames decodes every frame of the selected stream and reports how many it read, which catches that:

Terminal window
ffprobe -v error -count_frames -select_streams v:0 \
-show_entries stream=nb_read_frames -of csv=p=0 input.mp4

On the intact file it printed 75. On the cut one it printed 42, after partial file and Invalid NAL unit size errors on stderr, and still exited 0. Compare the count against duration times frame rate, and treat any stderr output as a failure. -count_packets is the cheaper sibling: it counts packets without decoding them, so it catches a file that ends early but not corrupt data inside it.

Decoding is slower because it walks the whole file. Reserve it for cases where accepting a broken file costs more than the check. It also cannot see a video that is entirely black or audio that is pure silence, which are content problems, not container ones.

There is also a security boundary this does not cross. ffprobe parsing a file means a demuxer touched attacker-controlled bytes. Probing is a validation step, not a sandbox, and running it isolated from your application is a separate decision.

Probing without FFmpeg in your container

If you would rather not ship FFmpeg with your app, the same command runs as a Rendobar job. Both tabs send the identical ffprobe command:

job.ts
import { createClient } from "@rendobar/sdk";
const rb = createClient({ apiKey: process.env.RENDOBAR_API_KEY });
// output.data is typed unknown in the SDK. These are the summary fields
// the ffprobe job returns that the four checks need.
type ProbeSummary = {
kind: "video" | "audio" | "image" | "other";
durationSec: number | null;
video?: { width: number | null; height: number | null };
};
export async function isUsableVideo(url: string) {
const job = await rb.jobs.run(
{
type: "ffprobe",
params: { command: `ffprobe -v error -print_format json -show_format -show_streams ${url}` },
},
{ throwOnFailure: false },
);
// 1. Did it parse. A failed probe names the stage: fetch or parse.
if (job.status === "failed") return { ok: false, reason: job.error.message };
if (job.status !== "complete") return { ok: false, reason: job.status };
// The shape above is what the ffprobe job documents for output.data.
const { summary } = job.output.data as { summary: ProbeSummary };
// 2. Does it carry a video stream. 3. Is the duration usable.
if (summary.kind !== "video" || !summary.video) return { ok: false, reason: "no video stream" };
if (!summary.durationSec) return { ok: false, reason: "no duration" };
// 4. Is it inside what we are willing to encode.
if ((summary.video.width ?? 0) > 3840) return { ok: false, reason: "larger than 4K" };
return { ok: true, summary };
}

Install with npm i @rendobar/sdk. jobs.run() submits and waits, so it returns the finished job in one call.

terminal
curl -X POST https://api.rendobar.com/jobs \
-H "Authorization: Bearer $RENDOBAR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"type": "ffprobe",
"params": { "command": "ffprobe -v error -print_format json -show_format -show_streams https://cdn.rendobar.com/assets/examples/sample.mp4" }
}'

Returns immediately with a job id. Poll GET /jobs/{id} or register a webhook rather than blocking on the request.

The response carries the raw ffprobe report plus a summary with the container, duration, resolution, fps, rotation, HDR signalling and audio layout already resolved, so the four checks map onto four of its fields.

A failed probe says which stage failed. In our failures the messages split into “could not fetch input for probing” for unreachable URLs and “ffprobe could not read the input” for files that were not media. Those are different problems with different fixes, and telling a user “we could not download your file” when the truth is “your file is not a video” wastes everyone’s time.

What it costs

A probe on Rendobar bills a flat $0.0010, whatever the file size, and that price has not moved on any probe since the flat rate was introduced. Across completed probes the median took 171 ms, the fastest 94 ms and the 90th percentile 411 ms, so a probe adds about a fifth of a second to the common case.

A median FFmpeg job bills $0.0025.

In money alone, probing every upload pays for itself only when more than 2 in 5 uploads are bad, and a bad file often fails fast and cheaply in the encoder anyway. I still put a probe in front of every upload, because what it buys is the answer, not the saving: a specific reason to show the uploader before the file reaches the rest of your pipeline.

Frequently asked questions

Does ffprobe detect a corrupted video?

Only if the damage is in the header. For damage inside the stream, decode the whole file with ffmpeg -v error -i input.mp4 -f null - and treat any output as a failure. The exit code can still be 0 on a partial file, so check stderr, not only the exit status.

How do I detect a video with no audio track?

Look for a stream whose codec_type is audio in the ffprobe output. Do not infer it from the container. Also note that a present audio track can be pure silence, which a probe cannot see.

Sources

Tags #ffprobe#ffmpeg#validation#uploads#media
All posts
Share
  1. How we benchmark FFmpeg Engineering blog
  2. Custom fonts in a video API fail silently Engineering blog
  3. Opus vs AAC vs MP3, requested vs delivered bitrate Engineering blog