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
| Check | Example limit for short profile clips | When it is checked |
|---|---|---|
| Container | MP4 or MOV | Declared type at initiate |
| File size | 30 MB | Declared at initiate, real bytes at complete |
| Duration | 30 seconds | Declared at initiate, real duration from the transcoder |
| Codec | Anything the transcoder can read | At 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 original | Effect |
|---|---|
| iPhones record HEVC (H.265) by default | Plays 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 file | Playback waits for an extra request to the end of the file |
| A rotation flag instead of rotated frames | Sideways video in some players |
| Variable frame rate | Stutter in some players |
| Hidden metadata: GPS location, device model, capture time, extra tracks | Privacy risk. Data we never show and never check. |
Re-encoding into a fixed format fixes all of these at once.
The standard output
| Setting | Value | Why |
|---|---|---|
| Container | MP4 with moov at the front (fast start) | Playback starts at once |
| Video | H.264, High profile | Plays everywhere. Rekognition Video needs H.264. |
| Colour | 8-bit 4:2:0, SDR, BT.709, HDR tone-mapped | iPhone HDR looks right on every screen |
| Resolution | Same as source | Keep the quality |
| Rate control | Constant quality (QVBR) with a max bitrate cap | Looks like the source, never bigger than needed |
| Frame rate | From source, made constant | No stutter |
| Keyframe interval (GOP) | 2 seconds | Fast seeking |
| Rotation | Applied to the frames | No rotation flag left |
| Audio | AAC-LC, 48 kHz, stereo, 128 kbps | Plays everywhere |
| Tracks | Exactly one video and one audio | Extra metadata tracks are dropped |
| Metadata | None carried over | No GPS, device or timestamps |
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
| Choice | Good | Bad |
|---|---|---|
| Keep the original | You can re-convert later with better settings | You store hidden metadata you never checked. Double storage. |
| Delete it after conversion | Only 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 MediaConvert | ffmpeg on ECS Fargate | ffmpeg on Lambda | |
|---|---|---|---|
| What you run | Nothing. You send a job. | A container you build and patch | A function with an ffmpeg layer |
| Shape | Async job. Poll it, or listen for EventBridge job events. | Your own queue and workers | One invoke per file |
| Limits | Large files and long videos are fine | Your task size and disk | 15 minute timeout, up to 10 GB memory and 10 GB temp disk |
| Cost model | Per minute of output, scaled by resolution, frame rate, codec and quality mode | Task time | Invoke time |
| Good for | Most product video, HLS ladders, no ops | Odd formats, custom filters, very high volume | Short 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
MP4withMp4Settings.MoovPlacement: "PROGRESSIVE_DOWNLOAD"putsmoovfirst. ColorCorrector.ColorSpaceConversion: "FORCE_709"plus an HDR to SDR tone mapper.- H.264
RateControlMode: "QVBR"with aMaxBitrate, 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?
| Format | What it is | Plays on | Use when |
|---|---|---|---|
| Progressive MP4 | One file. The player fetches byte ranges. | Every browser and phone | Short clips. Simple, one object, easy to sign. |
| HLS | A 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 |
| DASH | Similar idea to HLS, different format | Needs 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
playsInlineso 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. SetResponseContentTypeon the signed GET so the header is right even if the object was stored with another type. - Expose
Content-Length,Content-RangeandAccept-Rangesin bucket CORS if a web page reads the video through JavaScript.
Rules
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.
Moderate the standard copy, not the original. The moderation service may not be able to read the original at all.
No stream URL until the conversion is done, not even for the owner. Show the local file as a preview while the server works.
A video that fails processing after the retries is rejected or sent to a human. Never approved by default.
Every delete path removes the original, the standard copy and the poster.
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: falseand 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,pngandpdfkeys. 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
moovcame first and stored a flag. Nothing acted on the flag. Transcoding withmoovfirst 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,
moovfirst, no metadata. - A new
ClientRequestTokenper 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.