Skip to main content

Overview

EaseLMS uses AWS MediaConvert to transcode uploaded videos into HLS (HTTP Live Streaming) format with multiple bitrates for adaptive streaming. This provides optimal playback quality across different network conditions and devices.

Video Processing Architecture

┌─────────────────────────────────────────────────────────────┐
│                 Video Processing Flow                        │
├─────────────────────────────────────────────────────────────┤
│                                                              │
│  1. Upload MP4 → S3                                          │
│  2. Trigger → MediaConvert Job                               │
│  3. Transcode → Multiple Bitrates (1080p, 720p, 480p)        │
│  4. Output → HLS Manifest + Segments                         │
│  5. Store → S3 (hls/ folder)                                 │
│  6. Deliver → CDN → Client Player                            │
│                                                              │
└─────────────────────────────────────────────────────────────┘

HLS Output Structure

MediaConvert generates HLS files in a structured format:
courses/course-18/lessons/lesson-42/
├── video-123-original.mp4              # Original upload
└── hls/
    └── video-123/                      # HLS folder
        ├── video-123.m3u8              # Master playlist
        ├── video-123_1080p.m3u8        # 1080p variant playlist
        ├── video-123_1080p00000.ts     # 1080p segments
        ├── video-123_1080p00001.ts
        ├── video-123_720p.m3u8         # 720p variant playlist
        ├── video-123_720p00000.ts      # 720p segments
        ├── video-123_720p00001.ts
        ├── video-123_480p.m3u8         # 480p variant playlist
        ├── video-123_480p00000.ts      # 480p segments
        └── video-123_480p00001.ts

MediaConvert Client Setup

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

const BUCKET_NAME = process.env.AWS_S3_BUCKET_NAME!
const AWS_REGION = process.env.AWS_REGION || 'us-east-1'

const mediaConvertClient = new MediaConvertClient({
  region: AWS_REGION,
  credentials: {
    accessKeyId: process.env.AWS_ACCESS_KEY_ID!,
    secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY!,
  },
})

Environment Variables

.env.local
AWS_REGION=us-east-1
AWS_ACCESS_KEY_ID=your_access_key
AWS_SECRET_ACCESS_KEY=your_secret_key
AWS_S3_BUCKET_NAME=your_bucket_name
AWS_MEDIACONVERT_ROLE_ARN=arn:aws:iam::account:role/MediaConvertRole
The MediaConvert role must have permissions to read from and write to your S3 bucket.

Creating MediaConvert Jobs

Job Configuration

The createMediaConvertJob function creates HLS transcoding jobs:
lib/aws/mediaconvert.ts
export async function createMediaConvertJob(videoKey: string): Promise<string> {
  const { hlsPath, baseName } = getHLSOutputPath(videoKey)
  const inputS3Url = `s3://${BUCKET_NAME}/${videoKey}`
  const outputS3Url = `s3://${BUCKET_NAME}/${hlsPath}/`

  const roleArn = process.env.AWS_MEDIACONVERT_ROLE_ARN
  if (!roleArn) {
    throw new Error("AWS_MEDIACONVERT_ROLE_ARN is required")
  }

  const jobSettings = {
    Role: roleArn,
    Settings: {
      Inputs: [{
        FileInput: inputS3Url,
        VideoSelector: {},
        AudioSelectors: {
          "Audio Selector 1": {
            DefaultSelection: "DEFAULT",
          },
        },
      }],
      OutputGroups: [{
        Name: "HLS Output Group",
        OutputGroupSettings: {
          Type: "HLS_GROUP_SETTINGS",
          HlsGroupSettings: {
            SegmentLength: 10,
            Destination: outputS3Url,
            ManifestDurationFormat: "INTEGER",
            StreamInfResolution: "INCLUDE",
          },
          DestinationSettings: {
            S3Settings: {
              AccessControl: {
                CannedAcl: "PUBLIC_READ",
              },
            },
          },
        },
        Outputs: [
          // 1080p, 720p, 480p outputs (see below)
        ],
      }],
    },
  }

  const command = new CreateJobCommand(jobSettings)
  const response = await mediaConvertClient.send(command)

  return response.Job!.Id!
}

Output Configurations

{
  NameModifier: "_1080p",
  VideoDescription: {
    CodecSettings: {
      Codec: "H_264",
      H264Settings: {
        RateControlMode: "QVBR",
        QvbrSettings: {
          QvbrQualityLevel: 7,
        },
        MaxBitrate: 5000000,
        GopSize: 60,
        InterlaceMode: "PROGRESSIVE",
        CodecProfile: "HIGH",
        CodecLevel: "LEVEL_4",
      },
    },
    Width: 1920,
    Height: 1080,
  },
  AudioDescriptions: [{
    AudioSelectorName: "Audio Selector 1",
    CodecSettings: {
      Codec: "AAC",
      AacSettings: {
        Bitrate: 192000,
        CodingMode: "CODING_MODE_2_0",
        SampleRate: 48000,
      },
    },
  }],
  ContainerSettings: {
    Container: "M3U8",
  },
}

HLS Path Generation

The system generates HLS paths based on the original video key:
lib/aws/mediaconvert.ts
function getHLSOutputPath(videoKey: string): { 
  hlsPath: string
  baseName: string 
} {
  const lastSlashIndex = videoKey.lastIndexOf('/')
  const videoPath = lastSlashIndex >= 0 
    ? videoKey.substring(0, lastSlashIndex) 
    : ''
  const filename = lastSlashIndex >= 0 
    ? videoKey.substring(lastSlashIndex + 1) 
    : videoKey
  const baseName = filename.replace(/\.[^/.]+$/, '')
  const hlsPath = videoPath 
    ? `${videoPath}/hls/${baseName}` 
    : `hls/${baseName}`
  
  return { hlsPath, baseName }
}

Example Path Transformation

// Input
const videoKey = "courses/course-18/preview-video-123.mp4"

// Output
const { hlsPath, baseName } = getHLSOutputPath(videoKey)
// hlsPath: "courses/course-18/hls/preview-video-123"
// baseName: "preview-video-123"

// Master playlist URL
const masterPlaylist = `${hlsPath}/${baseName}.m3u8`
// "courses/course-18/hls/preview-video-123/preview-video-123.m3u8"

Job Status Monitoring

Check Job Status

lib/aws/mediaconvert.ts
export async function getMediaConvertJobStatus(jobId: string): Promise<{
  status: JobStatus | string
  progress?: number
  errorMessage?: string
}> {
  const command = new GetJobCommand({ Id: jobId })
  const response = await mediaConvertClient.send(command)

  return {
    status: response.Job?.Status || "UNKNOWN",
    progress: response.Job?.JobPercentComplete,
    errorMessage: response.Job?.ErrorMessage,
  }
}

Job Status Values

Job has been submitted to MediaConvert queue.
{ status: "SUBMITTED", progress: 0 }

Video Player Integration

The frontend uses Media Chrome for HLS playback:
lib/hooks/useHLS.ts
import Hls from 'hls.js'

export function useHLS(videoUrl: string) {
  useEffect(() => {
    if (!videoRef.current) return
    
    // Check if HLS is supported
    if (Hls.isSupported()) {
      const hls = new Hls({
        maxBufferLength: 30,
        maxMaxBufferLength: 60,
        enableWorker: true,
      })
      
      // Get HLS manifest URL
      const hlsUrl = getHLSVideoUrl(videoUrl)
      
      hls.loadSource(hlsUrl)
      hls.attachMedia(videoRef.current)
      
      hls.on(Hls.Events.MANIFEST_PARSED, () => {
        // Auto-play or wait for user interaction
      })
      
      hls.on(Hls.Events.ERROR, (event, data) => {
        if (data.fatal) {
          // Fallback to original MP4
          videoRef.current.src = videoUrl
        }
      })
      
      return () => hls.destroy()
    } else if (videoRef.current.canPlayType('application/vnd.apple.mpegurl')) {
      // Native HLS support (Safari)
      videoRef.current.src = getHLSVideoUrl(videoUrl)
    } else {
      // Fallback to original video
      videoRef.current.src = videoUrl
    }
  }, [videoUrl])
}

Adaptive Bitrate Selection

HLS.js automatically selects the optimal bitrate based on:
  • Network bandwidth: Measured during playback
  • Screen size: Higher quality for larger screens
  • Buffer health: Switches to lower quality if buffering occurs

URL Generation for Playback

Get HLS Manifest URL

lib/aws/s3.ts
export function getHLSVideoUrl(originalVideoKey: string): string {
  const lastSlashIndex = originalVideoKey.lastIndexOf('/')
  const path = lastSlashIndex >= 0 
    ? originalVideoKey.substring(0, lastSlashIndex) 
    : ''
  const filename = lastSlashIndex >= 0 
    ? originalVideoKey.substring(lastSlashIndex + 1) 
    : originalVideoKey
  
  const baseName = filename.replace(/\.[^/.]+$/, '')
  const hlsKey = path 
    ? `${path}/hls/${baseName}/${baseName}.m3u8` 
    : `hls/${baseName}/${baseName}.m3u8`
  
  // Use CDN for delivery
  return getPublicUrl(hlsKey, true)
}

Prefer HLS with MP4 Fallback

lib/aws/s3.ts
export function getPreferredVideoUrl(originalVideoUrl: string): string {
  if (!originalVideoUrl) return originalVideoUrl
  
  // If already HLS, return as-is
  if (originalVideoUrl.includes('.m3u8')) {
    return transformToCDNUrl(originalVideoUrl, false)
  }
  
  // Return original URL - player will try HLS automatically
  return transformToCDNUrl(originalVideoUrl, false)
}

Deleting Videos with HLS

When deleting videos, clean up both original and HLS files:
lib/aws/s3.ts
export async function deleteVideoWithHLS(videoKey: string): Promise<{ 
  deleted: number
  errors: string[]
}> {
  const errors: string[] = []
  let deleted = 0

  // Delete original video
  try {
    await deleteFileFromS3(videoKey)
    deleted++
  } catch (error: any) {
    if (!error.message?.includes('NoSuchKey')) {
      errors.push(`Failed to delete video: ${error.message}`)
    }
  }

  // Delete HLS folder
  try {
    const hlsDeletedCount = await deleteHLSFolder(videoKey)
    deleted += hlsDeletedCount
  } catch (error: any) {
    errors.push(`Failed to delete HLS: ${error.message}`)
  }

  return { deleted, errors }
}

export async function deleteHLSFolder(videoKey: string): Promise<number> {
  const hlsFolderPath = getHLSFolderPath(videoKey)
  let deletedCount = 0

  const listCommand = new ListObjectsV2Command({
    Bucket: BUCKET_NAME,
    Prefix: hlsFolderPath,
  })

  const listResponse = await s3Client.send(listCommand)
  const objects = listResponse.Contents || []

  for (const object of objects) {
    if (object.Key) {
      const deleteCommand = new DeleteObjectCommand({
        Bucket: BUCKET_NAME,
        Key: object.Key,
      })
      await s3Client.send(deleteCommand)
      deletedCount++
    }
  }

  return deletedCount
}

Complete Upload & Transcode Flow

1

Upload Original Video

Upload MP4 to S3 using presigned URL:
const videoKey = await uploadVideoToS3(file)
// "courses/course-18/preview-video-123.mp4"
2

Trigger MediaConvert Job

Create transcoding job:
const jobId = await createMediaConvertJob(videoKey)
// "1234567890123-abcdef"
3

Monitor Job Progress

Poll job status until complete:
const interval = setInterval(async () => {
  const { status, progress } = await getMediaConvertJobStatus(jobId)
  
  if (status === "COMPLETE") {
    clearInterval(interval)
    // HLS files are ready
  } else if (status === "ERROR") {
    clearInterval(interval)
    // Handle error
  }
}, 5000)
4

Get HLS URL

Generate HLS manifest URL:
const hlsUrl = getHLSVideoUrl(videoKey)
// "https://cdn.example.com/courses/course-18/hls/preview-video-123/preview-video-123.m3u8"
5

Play Video

Use HLS.js or native player:
<video-player src={hlsUrl} />

IAM Role Configuration

The MediaConvert role requires these permissions:
MediaConvert IAM Role Policy
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": [
        "s3:GetObject",
        "s3:PutObject",
        "s3:PutObjectAcl"
      ],
      "Resource": "arn:aws:s3:::your-bucket-name/*"
    },
    {
      "Effect": "Allow",
      "Action": "s3:ListBucket",
      "Resource": "arn:aws:s3:::your-bucket-name"
    }
  ]
}

Best Practices

QVBR provides better quality-to-file-size ratio than constant bitrate:
RateControlMode: "QVBR",
QvbrSettings: {
  QvbrQualityLevel: 7, // 1-10 scale
}
10-second segments balance startup time and seek performance:
HlsGroupSettings: {
  SegmentLength: 10,
}
Use accelerated transcoding for time-sensitive content (higher cost):
AccelerationSettings: {
  Mode: "PREFERRED", // or "ENABLED"
}
Save MediaConvert job IDs in your database to track processing status and handle failures.

Troubleshooting

Job Fails Immediately

Check that the MediaConvert role has S3 read/write permissions and the input file exists.

HLS Playback Fails

Verify CORS is configured on S3 and the master playlist exists at the expected path.

Low Quality Output

Increase QVBR quality level (7-9) and max bitrate for higher quality output.

Slow Processing

Enable acceleration mode or reduce output resolutions for faster transcoding.

Next Steps