Validating Video Uploads with ffprobe
Run ffprobe -v error -print_format json -show_format -show_streams on the stored upload inside a worker with a timeout, then check the result against a policy: an allowed container (mov,mp4,m4a,3gp, matroska,webm), at least one video stream with an allowed codec, a duration and resolution inside your limits, a sane frame rate, and no more streams than you expect. Probing only reads headers, so follow it with a short decode of a few seconds (ffmpeg -t 5 -f null -) to prove the frames are actually decodable before you spend minutes transcoding a file that will fail halfway.
A .mp4 extension and a video/mp4 content type tell you what the client claimed. What your transcoder needs to know is whether the file contains a video stream it can decode, how long it is, how large its frames are, and whether any of that is extreme enough to cost you money or hang a worker. ffprobe answers those questions from the file itself. This page belongs to server-side file validation in backend validation and cloud storage architecture; its output feeds post-upload media transcoding and the adaptive bitrate ladder.
When to use this approach
- Users upload video or audio that you transcode, thumbnail or stream.
- You pay per minute of processing and need to reject hour-long or 8K uploads before they start.
- Transcode failures surface late and confuse users; you want to fail fast with a clear reason.
Prerequisites
- FFmpeg 6.x or 7.x in the worker image, which includes
ffprobe. - Node 20+ (the code uses
node:child_process). - The upload in object storage; the worker reads it by URL or local copy.
- A policy: allowed containers and codecs, and maximum duration, pixels and file size for your product tier.
What ffprobe reports
Implementation
import { execFile, spawn } from "node:child_process";
import { promisify } from "node:util";
const run = promisify(execFile);
export const POLICY = {
containers: ["mov,mp4,m4a,3gp,3g2,mj2", "matroska,webm", "avi", "mpegts"],
videoCodecs: ["h264", "hevc", "vp8", "vp9", "av1", "mpeg4", "prores"],
audioCodecs: ["aac", "mp3", "opus", "vorbis", "pcm_s16le", "ac3", "eac3", "flac"],
minDuration: 1, maxDuration: 60 * 60, // seconds
maxPixels: 3840 * 2160, maxFps: 120, maxStreams: 8,
};
export interface VideoInfo {
container: string; duration: number; width: number; height: number; fps: number;
videoCodec: string; audioCodec?: string; rotation: number; bitRate: number;
}
export type Verdict = { ok: true; info: VideoInfo } | { ok: false; reason: string };
function parseRate(r?: string): number {
if (!r) return 0;
const [n, d] = r.split("/").map(Number);
return d ? n / d : n;
}
export async function probe(input: string): Promise<Verdict> {
let json: any;
try {
const { stdout } = await run("ffprobe", [
"-v", "error", "-print_format", "json", "-show_format", "-show_streams",
"-probesize", "50M", "-analyzeduration", "20M", input,
], { timeout: 15_000, maxBuffer: 8 * 1024 * 1024 });
json = JSON.parse(stdout);
} catch (e: any) {
return { ok: false, reason: e.killed ? "probe timed out" : "not a readable media file" };
}
const fmt = json.format ?? {};
const streams: any[] = json.streams ?? [];
const video = streams.find((s) => s.codec_type === "video" && s.disposition?.attached_pic !== 1);
const audio = streams.find((s) => s.codec_type === "audio");
if (!POLICY.containers.includes(fmt.format_name)) return { ok: false, reason: `container ${fmt.format_name} not supported` };
if (!video) return { ok: false, reason: "no video stream" };
if (!POLICY.videoCodecs.includes(video.codec_name)) return { ok: false, reason: `video codec ${video.codec_name} not supported` };
if (audio && !POLICY.audioCodecs.includes(audio.codec_name)) return { ok: false, reason: `audio codec ${audio.codec_name} not supported` };
if (streams.length > POLICY.maxStreams) return { ok: false, reason: "too many streams" };
const duration = Number(fmt.duration ?? video.duration ?? 0);
if (!(duration >= POLICY.minDuration)) return { ok: false, reason: "video is shorter than 1 second or has no duration" };
if (duration > POLICY.maxDuration) return { ok: false, reason: "video is longer than 60 minutes" };
const width = video.width ?? 0, height = video.height ?? 0;
if (width <= 0 || height <= 0 || width * height > POLICY.maxPixels) return { ok: false, reason: `resolution ${width}×${height} not supported` };
const fps = parseRate(video.avg_frame_rate) || parseRate(video.r_frame_rate);
if (!(fps >= 1 && fps <= POLICY.maxFps)) return { ok: false, reason: `frame rate ${fps.toFixed(1)} not supported` };
const rotation = Number(video.side_data_list?.find((d: any) => "rotation" in d)?.rotation ?? video.tags?.rotate ?? 0);
return { ok: true, info: {
container: fmt.format_name, duration, width, height, fps, rotation,
videoCodec: video.codec_name, audioCodec: audio?.codec_name, bitRate: Number(fmt.bit_rate ?? 0),
} };
}
/** Decode a few seconds at the start and near the end to prove frames are readable. */
export function sampleDecode(input: string, duration: number): Promise<boolean> {
const decodeAt = (start: number) => new Promise<boolean>((resolve) => {
const p = spawn("ffmpeg", ["-v", "error", "-xerror", "-ss", String(start), "-i", input, "-t", "3", "-f", "null", "-"]);
const timer = setTimeout(() => p.kill("SIGKILL"), 20_000);
p.on("close", (code) => { clearTimeout(timer); resolve(code === 0); });
});
return Promise.all([decodeAt(0), decodeAt(Math.max(0, duration - 4))]).then((r) => r.every(Boolean));
}
Line-by-line on the decisions that matter
format_nameis a list. ffprobe reports the demuxer family, so an MP4, a MOV and an M4A all come back asmov,mp4,m4a,3gp,3g2,mj2. Compare against that exact string, then use the codec checks to decide what is inside.- Ignoring attached pictures. Audio files and some MP4s carry cover art as a one-frame “video” stream with
disposition.attached_pic = 1. Without this filter, an MP3 with album art passes as a video. probesizeandanalyzeduration. Defaults are small; some files — MPEG-TS from cameras, MKVs with many tracks — need more data before ffprobe can report dimensions or frame rate. Raising both costs a little time and avoids false rejections. The timeout bounds the worst case.- Duration from format, then stream. Some containers only set one. A missing or zero duration is itself a signal of a broken or streaming-style file that will be hard to process.
- Rotation. Phone videos are usually stored landscape with a rotation flag. Record it so thumbnails and players show the right orientation; FFmpeg’s transcode applies it automatically, but width and height as reported are pre-rotation.
- Sample decode at both ends. Truncated uploads have a valid header and a missing tail. Decoding three seconds at the start and near the end catches the common corruptions in a fraction of the time a full decode takes;
-xerrormakes FFmpeg exit on the first decode error rather than logging and continuing.
Where validation sits in the pipeline
Probing without downloading the whole file
ffprobe accepts HTTP URLs and reads with range requests, so for a large upload you can probe a presigned GET URL instead of copying gigabytes to the worker first. For MP4 files whose moov atom is at the end (common for phone and camera recordings), ffprobe seeks to the tail with a range request and still only reads a few megabytes. Give the URL a short expiry and quote it properly; FFmpeg treats some characters in URLs specially.
The sample decode also works over HTTP, reading only the ranges it needs. When you will transcode the file anyway, copy it to local disk once and run everything against the local copy — repeated remote reads add up, and the transcoder needs the whole file regardless.
Policies per plan and per purpose
Keep the policy in configuration keyed by plan and use case. A free tier might allow ten minutes at 1080p; a paid tier an hour at 4K; a profile video field fifteen seconds. Keeping these as data rather than code lets you answer “why was my video rejected” by showing the limit the user hit, and lets product change limits without a deploy.
Check business limits before technical ones where you can. The client already knows duration and size from the browser’s <video> element metadata; validating those before upload — as in validating dropped files before upload — saves users from uploading a file that will be rejected. The server check remains the one that counts, because client metadata is only advisory.
Configuration gotchas
Variable frame rate phone video reports odd r_frame_rate values like 90000/1. Use avg_frame_rate first; r_frame_rate is the timebase-derived “real base” rate and can be meaningless for VFR files.
MKV files report no duration on the stream. Matroska stores duration at container level only. Read format.duration first, as the code does.
HEIC “live photos” and GIFs pass as video. A GIF is decoded by FFmpeg as a video stream with codec gif; exclude it from the codec allow-list unless you intend to accept animated GIFs as video.
ffprobe hangs on some network streams. Always set the timeout and run with -v error. Never probe user-supplied URLs pointing to arbitrary hosts — that is an SSRF vector — only your own storage.
Verification
ffprobe -v error -print_format json -show_format -show_streams fixtures/phone.mp4 | jq '{f:.format.format_name,d:.format.duration,v:[.streams[]|select(.codec_type=="video")|{codec_name,width,height,avg_frame_rate}]}'
# Truncate a good file and confirm the sample decode catches it.
head -c 3000000 fixtures/phone.mp4 > /tmp/truncated.mp4
ffmpeg -v error -xerror -ss 20 -i /tmp/truncated.mp4 -t 3 -f null - ; echo "exit $?" # non-zero
Frequently Asked Questions
Is ffprobe safe to run on hostile files?
It parses them, so it has the same exposure as any media parser. Run it in an isolated worker with no network access beyond storage, a timeout and a memory limit, and keep FFmpeg updated — media library vulnerabilities are patched regularly.
Should I transcode everything regardless of what ffprobe says?
Usually yes; transcoding normalises output. Validation decides whether to attempt it and what to tell the user, and the probe result tells the transcoder which ladder rungs make sense for the source resolution.
Can I skip the sample decode?
You can, at the cost of later, vaguer failures. For short clips the decode is cheap enough to run on the whole file; for long ones, sampling the start and end catches the majority of truncated uploads.