A script to validate videos for the Instagram API
If you are publishing videos via the Instagram API (as I do for my feed2gram gem and for Beckygram), one of the first things you notice is that it is a lot less forgiving than their app is.
From their docs that spell this out:
The following are the specifications for Reels:
- Container: MOV or MP4 (MPEG-4 Part 14), no edit lists, moov atom at the front of the file.
- Audio codec: AAC, 48khz sample rate maximum, 1 or 2 channels (mono or stereo).
- Video codec: HEVC or H264, progressive scan, closed GOP, 4:2:0 chroma subsampling.
- Frame rate: 23-60 FPS.
- Picture size:
- Maximum columns (horizontal pixels): 1920
- Required aspect ratio is between 0.01:1 and 10:1 but we recommend 9:16 to avoid cropping or blank space.
- Video bitrate: VBR, 25Mbps maximum
- Audio bitrate: 128kbps
- Duration: 15 mins maximum, 3 seconds minimum
- File size: 300MB maximum
If you get this wrong, you'll receive a mostly-unhelpful-but-better-than-nothing-error message that looks like this:
{
"message": "The video file you selected is in a format that we don't support.",
"type": "OAuthException",
"code": 352,
"error_subcode": 2207026,
"is_transient": false,
"error_user_title": "Unsupported format",
"error_user_msg": "The video format is not supported. Please check spec for supported CodedException format",
"fbtrace_id": "AvU9fEFKlA8Z7RLRlZ1j9w_"
}
I was sick of hobbling together the same half dozen ffprobe
commands and then eyeballing the results (which are typically inscrutable if you don't know what you're looking for), so I wrote a script to test this for me.
For example, a recent clip failed to syndicate to Instagram and I wondered why that was, so I ran this little script, which I put on my PATH
and named validate_video_for_instagram
. It output:
Validating video: /Users/justin/Documents/podcast/clips/v30-the-startup-shell-game.mp4
✅ container
✅ audio_codec
✅ max_audio_sample_rate
✅ video_codecs
✅ color_space
✅ min_frame_rate
✅ max_frame_rate
❌ max_horizontal_pixels - Maximum columns (horizontal pixels): 1920 required; got: 2160
✅ min_aspect_ratio
✅ max_aspect_ratio
✅ max_video_bitrate_mbps
❌ max_audio_bitrate_kbps - Audio bitrate: 128kbps maximum required; got: 256.073
✅ min_duration_seconds
✅ max_duration_seconds
✅ max_size_megabytes
❌ Video had 2 error(s) preventing API upload to Instagram.
Docs: https://developers.facebook.com/docs/instagram-platform/instagram-graph-api/reference/ig-user/media
Is it surprising that the Instagram API won't accept 4K video? Yes. Especially since the videos weighs in at less than 100MB.
Want this for yourself?
To run this, you'll need a modern Ruby installed and ffprobe installed (on a Mac with homebrew, brew install ffprobe
should do).
The script lives in my iCloud dotfiles repo and currently looks like this:
##!/usr/bin/env ruby
VALIDATIONS = {
container: {
spec: "Container: MOV or MP4 (MPEG-4 Part 14)",
command: ->(path) { `ffprobe -v error -show_entries format=format_name -of default=noprint_wrappers=1:nokey=1 "#{path}"`.strip.split(",") },
test: ->(result) { (result & ["mov", "mp4"]).any? }
},
audio_codec: {
spec: "Audio codec: AAC",
command: ->(path) { `ffprobe -v error -select_streams a:0 -show_entries stream=codec_name -of default=noprint_wrappers=1:nokey=1 "#{path}"`.strip },
test: ->(result) { result == "aac" }
},
max_audio_sample_rate: {
spec: "Audio codec: 48khz sample rate maximum",
command: ->(path) { `ffprobe -v error -select_streams a:0 -show_entries stream=sample_rate -of default=noprint_wrappers=1:nokey=1 "#{path}"`.to_i / 1000.0 },
test: ->(result) { result <= 48 }
},
video_codecs: {
spec: "Video codec: HEVC or H264",
command: ->(path) { `ffprobe -v error -select_streams v:0 -show_entries stream=codec_name -of default=noprint_wrappers=1:nokey=1 "#{path}"`.strip },
test: ->(result) { ["h264", "hevc"].include?(result) }
},
color_space: {
spec: "Video codec: progressive scan, 4:2:0 chroma subsampling",
command: ->(path) {
`ffprobe -v error -select_streams v:0 -show_entries stream=pix_fmt -of default=noprint_wrappers=1:nokey=1 "#{path}"`.strip
},
test: ->(result) { result == "yuv420p" }
},
min_frame_rate: {
spec: "Frame rate: minimum 23 FPS",
command: (fps = ->(path) { `ffprobe -v error -select_streams v:0 -show_entries stream=r_frame_rate -of default=noprint_wrappers=1:nokey=1 "#{path}"`[/\A\w+/].to_i }),
test: ->(result) { result >= 23 }
},
max_frame_rate: {
spec: "Frame rate: maximum 60 FPS",
command: fps,
test: ->(result) { result <= 60 }
},
max_horizontal_pixels: {
spec: "Maximum columns (horizontal pixels): 1920",
command: ->(path) { `ffprobe -v error -select_streams v:0 -show_entries stream=width -of default=noprint_wrappers=1:nokey=1 "#{path}"`.to_i },
test: ->(result) { result <= 1920 }
},
min_aspect_ratio: {
spec: "Aspect ratio: minimum 0.01:1",
command: (aspect_ratio = ->(path) {
w = `ffprobe -v error -select_streams v:0 -show_entries stream=width -of default=noprint_wrappers=1:nokey=1 "#{path}"`.to_f
h = `ffprobe -v error -select_streams v:0 -show_entries stream=height -of default=noprint_wrappers=1:nokey=1 "#{path}"`.to_f
w / h
}),
test: ->(result) { result >= 0.01 }
},
max_aspect_ratio: {
spec: "Aspect ratio: maximum 10:1",
command: aspect_ratio,
test: ->(result) { result <= 10.0 }
},
max_video_bitrate_mbps: {
spec: "Video bitrate: 25Mbps maximum",
command: ->(path) { `ffprobe -v error -select_streams v:0 -show_entries stream=bit_rate -of default=noprint_wrappers=1:nokey=1 "#{path}"`.to_i / 1_000_000.0 },
test: ->(result) { result <= 25 }
},
max_audio_bitrate_kbps: {
spec: "Audio bitrate: 128kbps maximum",
command: ->(path) { `ffprobe -v error -select_streams a:0 -show_entries stream=bit_rate -of default=noprint_wrappers=1:nokey=1 "#{path}"`.to_i / 1000.0 },
test: ->(result) { result <= 128 }
},
min_duration_seconds: {
spec: "Duration: minimum 3 seconds",
command: (duration_seconds = ->(path) { `ffprobe -v error -show_entries format=duration -of default=noprint_wrappers=1:nokey=1 "#{path}"`.to_f }),
test: ->(result) { result >= 3 }
},
max_duration_seconds: {
spec: "Duration: maximum 15 minutes",
command: duration_seconds,
test: ->(result) { result <= 900 }
},
max_size_megabytes: {
spec: "File size: maximum 300MB",
command: ->(path) { File.size(path).to_f / 1_000_000.0 },
test: ->(result) { result <= 300 }
}
}
DOCS_URL = "https://developers.facebook.com/docs/instagram-platform/instagram-graph-api/reference/ig-user/media"
video_path = ARGV[0]
if video_path.empty?
puts "Usage: #{$0} <video_path>"
exit 1
end
puts "Validating video: #{video_path}\n\n"
results = VALIDATIONS.map { |spec, details|
result = details[:command].call(video_path)
if details[:test].call(result)
puts "✅ #{spec}"
true
else
puts "❌ #{spec} - #{details[:spec]} required; got: #{result}"
false
end
}
if (error_count = results.count { |r| r == false }).zero?
puts "\n✅ Video is valid for Instagram!"
else
puts "\n❌ Video had #{error_count} error(s) preventing API upload to Instagram.\nDocs: #{DOCS_URL}"
end
As you can see, there's just a hash of specifications that encapsulate a corresponding ffprobe command and a test proc, so we can output pass/fail status, the rule for reference, and the actual result.
Anyway, you probably don't need this, but figured it'd be good to feed Google, since this is all a bit of a mystery when you're starting out.