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

  1. @aws-sdk/client-mediaconvert v3 (3.600+) in a Node 20 Lambda or container.
  2. An IAM role MediaConvert can assume, with s3:GetObject on the uploads prefix and s3:PutObject on the output prefix. The trust policy’s principal is mediaconvert.amazonaws.com.
  3. iam:PassRole for that role on whatever calls CreateJob — the most commonly missed permission.
  4. 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

MediaConvert job lifecycle driven by events An S3 object-created event triggers a submitter Lambda, which calls CreateJob with a job template. MediaConvert reads the upload from S3, writes the HLS package to an output bucket, and emits a job state change event to EventBridge, which triggers a completion handler that updates the database. Submit on one event, finish on another S3 uploads/ Object Created submitter λ CreateJob MediaConvert job template S3 media/ HLS package EventBridge Job State Change completion λ COMPLETE / ERROR assets table status = ready UserMetadata carries assetId from the submitter to the completion handler — no lookup table needed.
Neither Lambda waits: the submitter returns in about 200 ms, and the completion handler only runs when there is something to record.

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

  • JobTemplate plus a minimal Settings. 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.
  • ClientRequestToken makes CreateJob idempotent 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.
  • UserMetadata is echoed back verbatim in every state change event. Putting assetId there 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 to FileInput fails with Unable to open input file.
  • StatusUpdateInterval: "SECONDS_60" controls how often STATUS_UPDATE events 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.

Monthly cost of on-demand versus one reserved slot by utilisation On-demand cost rises linearly with output minutes processed. A reserved slot is a flat line. They cross at roughly 55 percent utilisation of the slot; below that on-demand is cheaper, above it the reserved slot is. Illustrative: one slot, monthly cost vs utilisation break-even ≈ 55% on-demand reserved slot 0% 50% 100% share of the month the slot spends transcoding
Spiky user uploads rarely keep a slot above half busy, so on-demand usually wins until volume is steady.

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.

Timeline of a MediaConvert job for a three-minute upload Submitted at zero seconds, queued for 6 seconds, probing input for 4 seconds, transcoding for 48 seconds, uploading outputs for 5 seconds, then the COMPLETE event arrives about 2 seconds later at 65 seconds. 3-minute 1080p upload, four HLS outputs TRANSCODING 48 s queued 6 s probe 4 s upload 5 s event 2 s About 65 s end to end — show "processing" in the UI, and push the result rather than poll for it.
Transcoding dominates; everything else is a few seconds of overhead the event-driven design never has to wait on.

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.