Skip to main content

Video

Phones record video in many formats. If you serve the file exactly as the phone made it, playback depends on which phone recorded it and which device plays it. This page covers what to accept, how to convert every upload to one standard format, and how to stream it.

How it works​

The client does nothing new. It uploads the original, as before. The server converts it, deletes the original, and only then runs moderation. The video stays hidden until all of that is done.

What to accept​

CheckExample limit for short profile clipsWhen it is checked
ContainerMP4 or MOVDeclared type at initiate
File size30 MBDeclared at initiate, real bytes at complete
Duration30 secondsDeclared at initiate, real duration from the transcoder
CodecAnything the transcoder can readAt transcode. Unreadable means rejected.

Check each limit three times: the declared value (fast feedback), the real bytes (HeadObject), and the real duration (from the transcode result). The declared values come from the client and can be wrong.

Some browsers report an empty MIME type for a .mov file. Map the extension to video/quicktime on the client if file.type is empty.

Why convert every video​

Problem with the phone's originalEffect
iPhones record HEVC (H.265) by defaultPlays on Apple devices. Elsewhere it depends on the browser and the hardware. AWS Rekognition Video cannot read it at all.
iPhone HDR (10-bit HLG or Dolby Vision)Washed out or no playback on many non-Apple screens
The MP4 index (moov box) is at the end of the filePlayback waits for an extra request to the end of the file
A rotation flag instead of rotated framesSideways video in some players
Variable frame rateStutter in some players
Hidden metadata: GPS location, device model, capture time, extra tracksPrivacy risk. Data we never show and never check.

Re-encoding into a fixed format fixes all of these at once.

The standard output​

SettingValueWhy
ContainerMP4 with moov at the front (fast start)Playback starts at once
VideoH.264, High profilePlays everywhere. Rekognition Video needs H.264.
Colour8-bit 4:2:0, SDR, BT.709, HDR tone-mappediPhone HDR looks right on every screen
ResolutionSame as sourceKeep the quality
Rate controlConstant quality (QVBR) with a max bitrate capLooks like the source, never bigger than needed
Frame rateFrom source, made constantNo stutter
Keyframe interval (GOP)2 secondsFast seeking
RotationApplied to the framesNo rotation flag left
AudioAAC-LC, 48 kHz, stereo, 128 kbpsPlays everywhere
TracksExactly one video and one audioExtra metadata tracks are dropped
MetadataNone carried overNo GPS, device or timestamps
tip

Test the output with real clips before you trust it: an iPhone HDR portrait .MOV, an Android file with moov at the end, a variable-frame-rate clip, a 4K clip, a clip with no audio track, and a renamed non-video file. Then check with ffprobe and exiftool:

ffprobe -v error -show_entries stream=codec_name,profile,pix_fmt,width,height,r_frame_rate \
-of json out.mp4
exiftool -a -G1 out.mp4 | grep -i -E 'gps|location|make|model'

You want one h264 stream (High, yuv420p), one aac stream, and no GPS, make or model tags.

Keep the original or not​

ChoiceGoodBad
Keep the originalYou can re-convert later with better settingsYou store hidden metadata you never checked. Double storage.
Delete it after conversionOnly the clean copy exists. Less to erase on account deletion.You can never re-convert from source

For short clips we chose delete. Originals go to their own prefix (originals/...), and the clean copy to another (videos/...). Then a lifecycle rule can expire the originals prefix after a couple of days as a safety net, in case code misses a delete.

Every delete path (user delete, rejection, staff take-down, account erasure) must remove both keys. Write a test per path.

MediaConvert or ffmpeg?​

AWS Elemental MediaConvertffmpeg on ECS Fargateffmpeg on Lambda
What you runNothing. You send a job.A container you build and patchA function with an ffmpeg layer
ShapeAsync job. Poll it, or listen for EventBridge job events.Your own queue and workersOne invoke per file
LimitsLarge files and long videos are fineYour task size and disk15 minute timeout, up to 10 GB memory and 10 GB temp disk
Cost modelPer minute of output, scaled by resolution, frame rate, codec and quality modeTask timeInvoke time
Good forMost product video, HLS ladders, no opsOdd formats, custom filters, very high volumeShort clips, simple steps like a thumbnail

We picked MediaConvert. It is async, just like Rekognition Video, so it fits the same once-a-minute background job. And it keeps ffmpeg out of the API image.

MediaConvert pricing, checked October 2026 on the AWS pricing page: billed per output minute, in one-second steps, with a 10-second minimum per output. Higher resolution, frame rate and quality settings multiply the price. There are Basic and Professional on-demand tiers, and a Reserved option. Check the current page before you estimate a budget. Do not copy a number from an old doc.

Starting and polling a job​

import { CreateJobCommand, GetJobCommand, MediaConvertClient } from "@aws-sdk/client-mediaconvert";

const mc = new MediaConvertClient({});

export async function startTranscode(videoId: string, attempt: number, src: string, destNoExt: string) {
const res = await mc.send(
new CreateJobCommand({
Role: "arn:aws:iam::123456789012:role/example-mediaconvert",
// A NEW token per attempt. A reused token returns the old job.
ClientRequestToken: `${videoId}-t${attempt}`,
UserMetadata: { videoId },
Settings: buildSettings(src, destNoExt), // H.264 + AAC, MP4, moov first
}),
);
return res.Job?.Id;
}

export async function pollTranscode(jobId: string) {
const { Job } = await mc.send(new GetJobCommand({ Id: jobId }));
switch (Job?.Status) {
case "COMPLETE":
return { state: "done" as const };
case "ERROR":
case "CANCELED":
return { state: "failed" as const, error: Job.ErrorMessage ?? "unknown" };
default:
return { state: "running" as const };
}
}

Key parts of buildSettings:

  • Input VideoSelector.Rotate: "AUTO" rotates the frames.
  • Output container MP4 with Mp4Settings.MoovPlacement: "PROGRESSIVE_DOWNLOAD" puts moov first.
  • ColorCorrector.ColorSpaceConversion: "FORCE_709" plus an HDR to SDR tone mapper.
  • H.264 RateControlMode: "QVBR" with a MaxBitrate, GOP of 2 seconds.
  • AAC-LC, 48 kHz, stereo.
  • MediaConvert adds the file extension to the destination, so pass the destination without .mp4.

Put every tunable number (quality level, max bitrate, audio bitrate) in config, not in code.

Map errors into two groups. Unreadable input (corrupt or unsupported file): reject at once and delete the original. Throttling or internal errors: retry with a new token, and after a few attempts treat it as failed. Never auto-approve a video that could not be processed.

The service role that MediaConvert runs as needs s3:GetObject on the originals prefix and s3:PutObject on the output prefix (plus KMS if the bucket uses a customer key). The API's role needs CreateJob, GetJob and iam:PassRole on that one service role.

Streaming: MP4, HLS or DASH?​

FormatWhat it isPlays onUse when
Progressive MP4One file. The player fetches byte ranges.Every browser and phoneShort clips. Simple, one object, easy to sign.
HLSA playlist (.m3u8) plus many short segment files, at several qualities (a ladder)Native on Safari and Apple devices. Elsewhere check native support, or use a player library such as hls.js.Long videos, mixed networks, live streams
DASHSimilar idea to HLS, different formatNeeds a JavaScript player. No native support in Safari on iPhone.Rarely worth it if you already have HLS

For clips of 30 seconds or less we serve one MP4. An HLS ladder means many files per video, a playlist to sign, and more to delete. It is not worth it for short clips.

If you do use HLS for private videos, use CloudFront signed cookies. A playlist points at many segment files, and you cannot sign each segment URL inside a static playlist easily.

Fast start and the moov box​

An MP4 file is made of boxes. The moov box is the index: where every frame is. The mdat box holds the frames.

If moov is at the end, the player must fetch the end of the file before it can show anything. You can check the order by reading the first 64 KB with a ranged GET and walking the top-level boxes, but the simplest fix is to transcode every upload with moov placed first.

Posters (thumbnails)​

The player shows a poster image before playback starts. Two ways to get one:

  • The client makes it. It grabs a frame while recording or picking the file, and uploads a small JPEG or WebP with a presigned POST (exact key, size range, exact type). It is ready at once.
  • The transcoder makes it. MediaConvert can write a frame capture output in the same job. It is always present and comes from the clean copy.

If there is no poster, you can render the video element itself, muted, so its first frame stands in. Browsers differ on when they paint that frame, so a real poster is better.

iOS and Safari quirks​

  • Use playsInline so iPhone does not jump to fullscreen.
  • Autoplay needs muted.
  • Safari fetches video with Range requests. S3 and CloudFront support them. If you put your own proxy in front of S3, it must support them too.
  • Serve Content-Type: video/mp4. Set ResponseContentType on the signed GET so the header is right even if the object was stored with another type.
  • Expose Content-Length, Content-Range and Accept-Ranges in bucket CORS if a web page reads the video through JavaScript.

Rules​

Rule

Convert every user video on the server to one standard format, for every client. Do not rely on the client to convert. A note in the API saying "the client converts HEVC" is not proof that any client does.

Rule

Moderate the standard copy, not the original. The moderation service may not be able to read the original at all.

Rule

No stream URL until the conversion is done, not even for the owner. Show the local file as a preview while the server works.

Rule

A video that fails processing after the retries is rejected or sent to a human. Never approved by default.

Rule

Every delete path removes the original, the standard copy and the poster.

tip

We looked at converting in the browser and ruled it out. WebCodecs cannot decode HEVC in many browsers, and ffmpeg compiled to WebAssembly is too slow and crashes iOS Safari. Convert on the server.

Mistakes we made​

  • iPhone videos were rejected as unreadable. The first version served and moderated the phone's original file. iPhones record HEVC by default, and the video moderation service only reads H.264. Every such clip failed moderation. The fix was to transcode on the server and moderate the clean copy.
  • The docs said the client converted HEVC. No client did. The API notes claimed it. The web app sent clientTranscoded: false and did no conversion. Check the actual client before trusting a note.
  • Staff could not play videos. The signer that made staff URLs only allowed jpg, jpeg, png and pdf keys. Every video key failed with "not found". When a new file type joins a feature, update every key pattern that guards it.
  • Fast start was recorded, not fixed. The first version sniffed whether moov came first and stored a flag. Nothing acted on the flag. Transcoding with moov first fixes it for every file.

Checklist​

  • Accept MP4 and MOV. Limit size and duration. Check declared, real bytes, and real duration.
  • Originals and clean copies live in separate prefixes.
  • A lifecycle rule expires the originals prefix as a safety net.
  • Output is H.264 High, AAC-LC, SDR BT.709, constant frame rate, moov first, no metadata.
  • A new ClientRequestToken per retry.
  • Unreadable input is rejected. Repeated failure is never auto-approved.
  • Moderation runs on the clean copy.
  • Stream URL is a signed GET with ResponseContentType: video/mp4.
  • Every delete path removes all keys for the video.
  • Tested with an iPhone HDR clip, a clip with no audio, a 4K clip and a corrupt file.