Transcoding Video with AWS Elemental MediaConvert
Create a job template holding the ladder once, then for each upload call CreateJobCommand with only the input URI, the output destination, a Role MediaConvert can assume, and UserMetadata carrying your asset ID — and learn the result from the MediaConvert Job State Change event on EventBridge, not by polling.
Running FFmpeg on your own workers is cheap per minute but expensive in operations: capacity planning, patching, spot interruptions, the one 4K ProRes file that takes a worker down. MediaConvert trades that for a per-output-minute price and a service that scales from zero. This page sits under adaptive bitrate video streaming in media processing and delivery pipelines. It replaces the worker from queueing transcode jobs with SQS and Lambda with a managed service while keeping the same event-driven shape.
When to use this approach
- Upload volume is spiky — quiet most of the week, then a few thousand videos after an event — and you do not want to size a worker fleet for the peak.
- You need broadcast-grade features cheaply: HDR10 or Dolby Vision passthrough, caption conversion, audio loudness normalisation, or QVBR rate control that picks bitrates for you.
- You are already on AWS with uploads landing in S3, so input and output never leave the region.
Prerequisites
@aws-sdk/client-mediaconvertv3 (3.600+) in a Node 20 Lambda or container.- An IAM role MediaConvert can assume, with
s3:GetObjecton the uploads prefix ands3:PutObjecton the output prefix. The trust policy’s principal ismediaconvert.amazonaws.com. iam:PassRolefor that role on whatever callsCreateJob— the most commonly missed permission.- Uploads arriving through an event — ideally routing S3 upload events with EventBridge, which also receives MediaConvert’s own status events.
{
"Version": "2012-10-17",
"Statement": [
{ "Effect": "Allow", "Action": "mediaconvert:CreateJob", "Resource": "*" },
{
"Effect": "Allow",
"Action": "iam:PassRole",
"Resource": "arn:aws:iam::123456789012:role/MediaConvertS3Access",
"Condition": { "StringEquals": { "iam:PassedToService": "mediaconvert.amazonaws.com" } }
}
]
}
The event-driven shape
Implementation
Two handlers. The submitter reacts to an S3 event delivered through EventBridge; the completion handler reacts to MediaConvert’s state change.
import {
MediaConvertClient,
CreateJobCommand,
type CreateJobCommandInput,
} from "@aws-sdk/client-mediaconvert";
import { createHash } from "node:crypto";
// Since 2024 the regional endpoint is discovered automatically; no DescribeEndpoints call needed.
const mc = new MediaConvertClient({ region: process.env.AWS_REGION });
const ROLE_ARN = process.env.MC_ROLE_ARN!; // arn:aws:iam::…:role/MediaConvertS3Access
const TEMPLATE = process.env.MC_TEMPLATE ?? "ugc-hls-qvbr";
const OUTPUT_BUCKET = process.env.OUTPUT_BUCKET!;
const QUEUE_ARN = process.env.MC_QUEUE_ARN; // optional reserved or dedicated queue
interface S3CreatedEvent {
detail: { bucket: { name: string }; object: { key: string; size: number; etag: string } };
}
export async function submit(event: S3CreatedEvent): Promise<{ jobId: string }> {
const { bucket, object } = event.detail;
const key = decodeURIComponent(object.key.replace(/\+/g, " "));
const assetId = key.split("/")[1]; // uploads/<assetId>/original.mov
// Deterministic token: a redelivered event with the same etag submits no second job.
const clientRequestToken = createHash("sha256")
.update(`${bucket.name}/${key}#${object.etag}`)
.digest("hex")
.slice(0, 64);
const input: CreateJobCommandInput = {
Role: ROLE_ARN,
JobTemplate: TEMPLATE,
Queue: QUEUE_ARN,
ClientRequestToken: clientRequestToken,
UserMetadata: { assetId, sourceKey: key },
StatusUpdateInterval: "SECONDS_60",
Settings: {
Inputs: [{
FileInput: `s3://${bucket.name}/${key}`,
AudioSelectors: { "Audio Selector 1": { DefaultSelection: "DEFAULT" } },
VideoSelector: { Rotate: "AUTO" }, // honour phone rotation metadata
TimecodeSource: "ZEROBASED",
}],
OutputGroups: [{
// Only the destination varies per job; codecs and ladder live in the template.
OutputGroupSettings: {
Type: "HLS_GROUP_SETTINGS",
HlsGroupSettings: {
Destination: `s3://${OUTPUT_BUCKET}/media/${assetId}/hls/`,
SegmentLength: 4,
MinSegmentLength: 0,
},
},
}],
},
};
const res = await mc.send(new CreateJobCommand(input));
const jobId = res.Job?.Id;
if (!jobId) throw new Error("CreateJob returned no job id");
console.log(JSON.stringify({ msg: "submitted", assetId, jobId }));
return { jobId };
}
interface JobStateEvent {
detail: {
status: "PROGRESSING" | "COMPLETE" | "ERROR" | "CANCELED" | "INPUT_INFORMATION";
jobId: string;
userMetadata?: { assetId?: string };
errorCode?: number;
errorMessage?: string;
outputGroupDetails?: { playlistFilePaths?: string[] }[];
};
}
export async function complete(event: JobStateEvent): Promise<void> {
const { status, jobId, userMetadata } = event.detail;
const assetId = userMetadata?.assetId;
if (!assetId) return; // not one of ours
if (status === "COMPLETE") {
const master = event.detail.outputGroupDetails?.[0]?.playlistFilePaths?.[0];
await markAsset(assetId, { status: "ready", masterPlaylist: master, jobId });
} else if (status === "ERROR") {
await markAsset(assetId, {
status: "failed",
jobId,
reason: `${event.detail.errorCode}: ${event.detail.errorMessage}`,
});
}
}
async function markAsset(assetId: string, fields: Record<string, unknown>): Promise<void> {
// Replace with a conditional UPDATE in your store; log keeps the example self-contained.
console.log(JSON.stringify({ msg: "asset update", assetId, ...fields }));
}
The EventBridge rule for the completion handler matches only terminal states:
{
"source": ["aws.mediaconvert"],
"detail-type": ["MediaConvert Job State Change"],
"detail": { "status": ["COMPLETE", "ERROR", "CANCELED"] }
}
Line-by-line on the fields that matter
JobTemplateplus a minimalSettings. Settings in the request merge over the template. Keeping codec, ladder and audio in the template means changing a bitrate is a console or IaC change, not a deploy, and every job in flight uses a consistent definition.ClientRequestTokenmakesCreateJobidempotent for its lifetime. S3 events are delivered at least once; hashing the key and ETag means a duplicate event submits nothing new, while a genuinely new upload to the same key (different ETag) does. The same idea at the application level is in making media jobs idempotent with content-hash keys.UserMetadatais echoed back verbatim in every state change event. PuttingassetIdthere removes the need for a job-ID-to-asset lookup table.Rotate: "AUTO"applies the rotation flag phones write. Without it, portrait videos come out sideways — MediaConvert’s default is to ignore the flag.decodeURIComponent(key.replace(/\+/g, " ")). S3 event keys are URL-encoded with+for spaces. Passing the raw key toFileInputfails withUnable to open input file.StatusUpdateInterval: "SECONDS_60"controls how oftenSTATUS_UPDATEevents report percent complete. Useful if you relay progress to the uploader; otherwise leave it at the default.
Configuration gotchas
AccessDeniedException: User … is not authorized to perform: iam:PassRole. The caller can create jobs but cannot hand MediaConvert the role. Add iam:PassRole scoped to exactly that role ARN, with the iam:PassedToService condition above.
Job ERROR with 1030: Unable to open input file [s3://…]: [Failed probe/open: [Can't read input stream: [Failed to read data: AssumeRole failed]]]. The role’s trust policy does not allow mediaconvert.amazonaws.com, or the bucket policy denies the role, or the object is SSE-KMS encrypted and the role lacks kms:Decrypt on the key.
1401: Unable to write to output file. The role lacks s3:PutObject on the destination, or the bucket enforces a x-amz-server-side-encryption header condition the job does not send. Set encryption in the output group’s DestinationSettings.S3Settings.Encryption to match.
Portrait uploads come out 1920 wide and letterboxed. The template uses fixed Width/Height. For user uploads, set only Height on each output and enable ScalingBehavior: "DEFAULT" with Rotate: "AUTO", so MediaConvert derives the width from the rotated source.
On-demand versus reserved pricing
MediaConvert bills per normalised output minute, where a 1080p output counts double a SD one and the Professional tier costs more than Basic. The break-even against a reserved queue — a fixed monthly price per transcoding slot — depends only on how busy you keep that slot.
Where the job spends its time
A job’s wall-clock time is mostly queue wait and transcoding; the numbers below are typical for a 3-minute 1080p phone upload on an on-demand queue with a four-output HLS template.
Pushing that completion to the browser is the job of notifying clients when processing finishes.
MediaConvert or your own FFmpeg workers
The decision is rarely about output quality — both produce excellent H.264 and HEVC — and mostly about how your volume behaves and which operational burden you would rather carry.
| Factor | MediaConvert | Self-hosted FFmpeg |
|---|---|---|
| Idle cost | Zero on-demand; reserved slots bill whether used or not | Whatever minimum fleet you keep warm |
| Peak handling | Scales per job with no capacity planning | Autoscaling lag of one to three minutes per new instance |
| Per-minute cost at steady volume | Higher; you pay for the managed service | Lower on spot or reserved instances |
| Exotic inputs | ProRes, DNxHD, IMF, HDR metadata handled | Depends on your FFmpeg build and flags |
| Custom processing | Only what the job settings expose | Anything you can script: watermark overlays, AI upscaling, custom filters |
| Failure surface | Job-level error codes, no host to patch | Out-of-memory kills, disk exhaustion, spot interruptions |
| Latency to first output | Queue wait of seconds; no cold host | Near zero on a warm worker, minutes on a cold one |
A common split is to send everything through MediaConvert until the monthly bill makes a warm fleet obviously cheaper, then move the steady baseline to self-hosted workers and keep MediaConvert as the overflow for spikes. Because both designs are driven by the same upload event and write to the same output prefix, switching which one handles a given job is a routing rule change, not a rewrite. Keep the assetId in the output path rather than the job ID so the rest of the system never needs to know which engine produced a package.
Whichever engine runs, record the engine name, its version or template revision, and the job ID alongside the asset. When a player bug appears months later on a subset of videos, that column is how you find every asset produced by the same configuration.
Verification
# 1. Submit a job by hand with the same template and confirm it reaches COMPLETE.
aws mediaconvert create-job --role "$MC_ROLE_ARN" --job-template ugc-hls-qvbr \
--settings '{"Inputs":[{"FileInput":"s3://uploads-bucket/uploads/test/original.mov","VideoSelector":{"Rotate":"AUTO"},"AudioSelectors":{"Audio Selector 1":{"DefaultSelection":"DEFAULT"}}}],"OutputGroups":[{"OutputGroupSettings":{"Type":"HLS_GROUP_SETTINGS","HlsGroupSettings":{"Destination":"s3://media-bucket/media/test/hls/","SegmentLength":4}}}]}' \
--query 'Job.Id' --output text
aws mediaconvert get-job --id "$JOB_ID" --query 'Job.[Status,ErrorMessage]'
# 2. Replay the same S3 event twice and confirm only one job exists for that token.
aws mediaconvert list-jobs --max-results 5 --query 'Jobs[].[Id,Status,CreatedAt]'
# 3. The package exists and the master lists every rung.
aws s3 cp s3://media-bucket/media/test/hls/original.m3u8 - | grep -c EXT-X-STREAM-INF
Frequently Asked Questions
Should I use QVBR or CBR for user uploads?
QVBR (quality-defined variable bitrate) with a quality level around 7 and a max bitrate per rung is the better default: easy scenes use far fewer bits, busy scenes get what they need up to the cap. CBR only makes sense for live contribution or players that insist on a flat rate. QVBR also removes most of the need to hand-tune a ladder per upload.
How do I show progress to the uploader?
Subscribe the completion handler, or a second rule, to STATUS_UPDATE events, which carry jobPercentComplete, and relay them over your existing progress channel. For most user uploads a simple “processing” state followed by a push on COMPLETE is enough.
Can MediaConvert write the thumbnails too?
Yes: add a FILE_GROUP_SETTINGS output with a FRAME_CAPTURE container to the template to write JPEG frames at an interval. For a single poster frame, generating video thumbnails with FFmpeg in Node.js is cheaper and lets you pick a non-black frame.