video-transcodingffmpegapi

Transcode every user uploaded video? Normalize to one house spec

··10 min read
Transcode every user uploaded video? Normalize to one house spec

You do not have to transcode user uploaded video on the way in. You have to normalize it, which is a different job: pick one house spec, probe every accepted file against it, remux the files that already match, and send only the misses to an encoder. One ffprobe call per upload tells you which branch a file takes, and the conforming files cost you a container rewrite instead of a full re-encode.

The guides answering this question come from companies that want to hold the file. Vimeo's help center is the clearest: every upload gets re-encoded, the step is unavoidable, and there's no guaranteed processing time (Vimeo transcoding FAQ). That's correct if you're building a player and a CDN. It's the wrong default when you're accepting files into a product or a client workflow, where the real symptom is step three breaking on the odd one: the concat that drifts out of sync, the portrait clip that renders sideways, the iPhone clip that comes back grey, the publish call that returns an opaque error code. Step three assumes every file has the same shape, and the uploads don't. A transcoding ladder just makes more renditions of the same mismatch. Normalize-on-ingest fixes the assumption in one place.

Pick one house spec, then write it down

A house spec is six or seven decisions, made once, that every file downstream relies on. Each line below exists because something breaks without it, not because it's a nicer encode.

PropertyHouse valueWhy this value
Video codecH.264, High profileWidest decoder support; what publishing APIs ask for
Pixel format`yuv420p`, 8-bit10-bit H.264 fails in browsers
Frame rateConstant, pinned with `-r`VFR breaks concat and timestamp math
Color`bt709` primaries and transferHDR sources wash out without a conversion
AudioAAC LC, 48 kHz, stereoInstagram and others reject other codecs
RotationBaked into pixelsMetadata rotation is honored inconsistently
ContainerMP4, `-movflags +faststart`A trailing `moov` atom makes readers fail

The 8-bit line is the one people argue with. 10-bit H.264 (High 10, yuv420p10le) plays fine in VLC and looks valid. Firefox has carried an open bug on 10-bit H.264 playback for years (Bugzilla 1743824), and Chromium-based browsers hand H.264 to a platform decoder that doesn't do 10-bit at all, which is why Jellyfin's web client reports outright playback failure (jellyfin-web issue 6306). If anything in your pipeline ends in a <video> tag, 8-bit isn't a preference.

Audio matters as soon as you publish. Instagram's content publishing reference requires H.264 or HEVC video with AAC audio at 48 kHz or less, 1 to 2 channels, 23 to 60 fps, no edit lists, the moov atom at the front, and no HDR (Instagram Platform docs). A file with Opus audio in an MP4 is legal and will never post. We went through the same list for X in X (Twitter) video specs that survive the upload (and the API); the platform overlap is close enough that one house spec serves all of them.

Probe before you re-encode, then branch

ffprobe returns codec, profile, pixel format, frame rate, color tags and container fields as JSON in a single call, which is everything the branch needs (ffprobe documentation):

ffprobe -v error -print_format json \
  -show_streams -show_format upload.mov

Read six fields off the video stream and three off the audio stream: codec_name, profile, pix_fmt, r_frame_rate against avg_frame_rate, color_transfer, and side_data_list for a display matrix; then codec_name, sample_rate and channels. r_frame_rate and avg_frame_rate disagreeing means variable frame rate. A rotation in side_data_list means the pixels aren't oriented the way the file claims. A color_transfer of arib-std-b67 or smpte2084 means HLG or PQ HDR.

If all nine fields match the house spec, no encoder needs to run. Rewrite the container and move on:

ffmpeg -i upload.mp4 -c copy -movflags +faststart house.mp4

That's I/O, not compute. A 500 MB file finishes in a couple of seconds, bit-identical in both streams, with the moov atom moved to the front. AWS documents MediaConvert failing with "no moov box found in file" when the index sits at the end and never got fetched (AWS re:Post), and the same error shows up in any reader doing ranged reads.

If a field misses, run the real transcode:

ffmpeg -i upload.mov \
  -c:v libx264 -profile:v high -preset medium -crf 20 \
  -pix_fmt yuv420p -fps_mode cfr -r 30 \
  -c:a aac -b:a 128k -ar 48000 -ac 2 \
  -movflags +faststart house.mp4

-fps_mode cfr with an explicit -r is the current spelling for forcing constant frame rate. The older -vsync form is gone in FFmpeg 9, which quietly broke a lot of frame-extraction scripts (FFmpeg 9.1: what to re-test before you upgrade).

The branch is the part platform guides never hand you, because skipping a transcode only saves their queue time. A per-output-minute meter saves you nothing either: Google Cloud Transcoder bills $0.015 per minute for SD, $0.030 for HD and $0.060 for UHD, charged per encoded stream (Transcoder API pricing). The charge tracks output count, not the work the input needed.

What a normalize step actually fixes

Four breakages account for most of the "it works on my file" tickets, and all four die at ingest.

Variable frame rate screen recordings desync after a concat. OBS, QuickTime screen capture and Loom write frames only when the screen changes, so a 10-minute recording might average 24 fps with gaps of a full second. Join two with -c copy and the audio drifts audibly within a minute, because the timestamps don't match the frame count the next tool assumes. Forcing CFR at ingest removes the drift.

Rotation metadata makes a portrait clip render sideways. iPhone and Android cameras record landscape pixels plus a display matrix that says "rotate 90". QuickTime honors it. A drawtext filter, a browser canvas, or a service reading dimensions off the stream header may not. Re-encoding writes upright pixels, so a side_data_list rotation routes a file to the transcode branch even when every other field matches.

HDR sources come out washed out. Convert a BT.2020 HEVC file to H.264 without converting the transfer curve and you get grey, flat footage: the pixels still assume a PQ or HLG curve the player no longer applies (ffmpeg-user thread). The fix is a tonemap chain through zscale, which needs an FFmpeg built with libzimg, a build flag nobody wants to own. We hit this from the publishing end in Kling 4.0 washed out on TikTok? Fix the tag, then the 9:16.

Audio codecs get otherwise fine files rejected. Opus in MP4, AC-3 from a screen capture, 44.1 kHz from an older recorder, 5.1 from a camera: each plays locally and fails a publish call with a numeric error code and no explanation.

The operating system won't do it for you either. Android 12 and up auto-converts captured HEVC to AVC, but only HEVC 8-bit, only for clips up to about a minute, and only when an app declares in its media-capabilities XML that it can't handle the format (Android developer docs). Everything arriving from a browser upload, a Zapier step or a client's Dropbox folder is yours.

Should you normalize every upload, branch, or leave the files alone?

Normalize every accepted file when the output feeds code you wrote: a concat, a caption burn, a watermark overlay, a thumbnail grab, a model that reads frames. Those steps have assumptions baked in, so the cheapest contract is "everything past ingest is house spec."

Branch on a probe when your uploads skew conforming already. Users exporting from CapCut or Premiere send mostly H.264 High, yuv420p, AAC at 48 kHz, and re-encoding those pays twice for a file that was already right, plus a generation of quality loss. Probe and remux instead.

Skip normalization in three cases. Archiving masters where the original is the deliverable: keep the original and normalize a working copy. Adaptive streaming to a player: a rendition ladder with packaging is the right tool. A file that never gets processed, only downloaded by the person who uploaded it, gains nothing from a normalize step.

Two honest limits on doing this through an API. FFmpeg Micro's free tier caps input files at 100 MB, fine for phone clips and short screen recordings, too small for a 20-minute 4K export; Starter at $19.99 a month raises that to 1,024 MB and Pro to 2,048 MB (pricing). Jobs are asynchronous too: submit, poll the job endpoint, fetch a download URL. No completion callbacks, so your ingest worker owns the poll loop.

One API call for the files that fail the probe

The transcode half is the part worth not hosting. You keep the ffprobe branch in your own code (it's milliseconds and it's free), and submit only the non-conforming files:

curl -X POST https://api.ffmpeg-micro.com/v1/transcodes \
  -H "Authorization: Bearer $FFMPEG_MICRO_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "inputs": [{"url": "gs://your-bucket/1730-upload.mov"}],
    "outputFormat": "mp4",
    "preset": {"quality": "medium", "resolution": "1080p"}
  }'

You get back a job id and "status": "pending", poll GET /v1/transcodes/{id} until it reads completed or failed, then call /download for a signed URL. Malformed requests fail loudly: send "fileSize": "12345678" as a string during the presigned upload and you get a 400 saying it must be a number; an input the toolkit can't decode returns 400 Unsupported input format instead of a half-written output. Billing is one token per 10 seconds of input, metered per second, and a failed job is never billed. The quickstart runs the whole upload, submit, poll, download cycle with a temporary key.

That split is why the same spec works whether your ingest worker is a Laravel queue job, an n8n branch, or an agent loop. We took the same line for model inputs in Don't send raw video input to an AI model. Prep it in FFmpeg.. Over 700 developers and automation builders run jobs this way, more than 300 from Make or n8n rather than code.

FAQ about normalizing video uploads

Do I need to transcode video that's already H.264?

An H.264 file still needs checking on profile, pixel format, frame-rate mode, rotation and audio before you can call it conforming. If all of those match your house spec, don't transcode it: remux with -c copy -movflags +faststart, which rewrites the container in seconds and leaves both streams untouched.

Can I normalize uploads without moving my media library onto a video platform?

Normalize-on-ingest works entirely on files you own: submit the upload, get an MP4 back, store it wherever you already store things. FFmpeg Micro bills once on the input duration and doesn't meter stored minutes or delivered views, so keeping the normalized file costs nothing ongoing.

Why does my concat of screen recordings drift out of sync?

Screen recorders write variable frame rate video, so the per-packet timestamps in two recordings don't line up when you join them with a stream copy, and the audio slides against the picture. Forcing constant frame rate with -fps_mode cfr -r 30 at ingest removes the drift before the concat runs.

Write down your seven-line house spec, put one ffprobe call in front of your ingest queue, and the only files reaching an encoder are the ones that need one. Sign up free to run that encode half as a job instead of a service you maintain: 200 tokens at sign-up covers roughly 33 minutes of input video with no card, inputs up to 100 MB on the free tier, and the same call works from your own code, from n8n, Make and Zapier, or from an AI agent.

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.

Software EngineeringVideo ProcessingFFmpegCloud ArchitectureAPI DesignAutomation

Ready to process videos at scale?

Start using FFmpeg Micro's simple API today. No infrastructure required.

Get Started Free