Skip to main content

HLS Transcoding Architecture

HyperStudy uses AWS MediaConvert for automatic video transcoding to HLS (HTTP Live Streaming) format, enabling adaptive bitrate streaming for optimal playback.

Overview

Why AWS MediaConvert?

The system migrated from Lambda + FFmpeg to AWS MediaConvert to solve critical limitations:

AspectOld (Lambda + FFmpeg)New (MediaConvert)
Timeout15-minute Lambda limitNo timeout
Max video length~1.5 hoursUnlimited
ScalingManual Lambda concurrencyAWS-managed
Output qualityCustom FFmpeg settingsOptimized presets
CostPay for Lambda executionPay per minute (~$0.015/min)

Architecture

┌─────────────────┐
│ User Upload │ (multipart for >100MB)
└────────┬────────┘

┌─────────────────┐ ┌─────────────────────┐
│ S3 │────────►│ EventBridge │
│ (raw video) │ │ (upload trigger) │
└─────────────────┘ └──────────┬──────────┘

┌─────────────────────┐
│ mediaconvert-submit │
│ (Lambda) │
│ - probe resolution │
│ - submit job │
└──────────┬──────────┘

┌─────────────────────┐
│ AWS MediaConvert │
│ (no timeout limit) │
│ - 360p/480p/720p/ │
│ 1080p outputs │
└──────────┬──────────┘

┌─────────────────────┐
│ EventBridge │
│ (completion event) │
└──────────┬──────────┘

┌─────────────────────┐
│ mediaconvert-webhook│
│ (Lambda) │
└──────────┬──────────┘

┌─────────────────────┐
│ Backend │
│ (update Firestore) │
└─────────────────────┘

Components

1. mediaconvert-submit Lambda

Triggered when a video is uploaded to S3. Probes the video resolution and submits a MediaConvert job.

// backend/aws-lambda/mediaconvert-submit/index.js

exports.handler = async (event) => {
const bucket = event.Records[0].s3.bucket.name;
const key = event.Records[0].s3.object.key;

// Extract video ID from key (e.g., "videos/vid_abc123/original.mp4")
const videoId = extractVideoId(key);

// Probe video resolution
const resolution = await probeVideoResolution(bucket, key);

// Determine output presets based on source resolution
const outputs = getOutputPresets(resolution);

// Submit MediaConvert job
const jobId = await submitMediaConvertJob({
inputBucket: bucket,
inputKey: key,
outputBucket: bucket,
outputPrefix: `videos/${videoId}/hls/`,
outputs,
videoId
});

// Update video status to "processing"
await updateVideoStatus(videoId, 'processing', { jobId });

return { statusCode: 200, jobId };
};

Resolution Detection

Only generates quality levels up to the source resolution:

function getOutputPresets(resolution) {
const { width, height } = resolution;
const outputs = [];

// Always include 360p
outputs.push({ preset: '360p', width: 640, height: 360, bitrate: 800000 });

// 480p if source is >= 480p
if (height >= 480) {
outputs.push({ preset: '480p', width: 854, height: 480, bitrate: 1400000 });
}

// 720p if source is >= 720p
if (height >= 720) {
outputs.push({ preset: '720p', width: 1280, height: 720, bitrate: 2800000 });
}

// 1080p if source is >= 1080p
if (height >= 1080) {
outputs.push({ preset: '1080p', width: 1920, height: 1080, bitrate: 5000000 });
}

return outputs;
}

2. AWS MediaConvert Job

The MediaConvert job configuration:

async function submitMediaConvertJob(params) {
const mediaConvert = new AWS.MediaConvert({
endpoint: process.env.MEDIACONVERT_ENDPOINT
});

const jobSettings = {
Role: process.env.MEDIACONVERT_ROLE_ARN,
Settings: {
Inputs: [{
FileInput: `s3://${params.inputBucket}/${params.inputKey}`,
AudioSelectors: {
'Audio Selector 1': { DefaultSelection: 'DEFAULT' }
}
}],
OutputGroups: [{
Name: 'HLS Group',
OutputGroupSettings: {
Type: 'HLS_GROUP_SETTINGS',
HlsGroupSettings: {
Destination: `s3://${params.outputBucket}/${params.outputPrefix}`,
SegmentLength: 6,
MinSegmentLength: 0,
// CRITICAL: Required for long videos
SegmentsPerSubdirectory: 10000
}
},
Outputs: params.outputs.map(output => ({
ContainerSettings: { Container: 'M3U8' },
VideoDescription: {
Width: output.width,
Height: output.height,
CodecSettings: {
Codec: 'H_264',
H264Settings: {
Bitrate: output.bitrate,
RateControlMode: 'CBR',
CodecProfile: 'MAIN',
CodecLevel: 'AUTO'
}
}
},
AudioDescriptions: [{
CodecSettings: {
Codec: 'AAC',
AacSettings: {
Bitrate: 128000,
SampleRate: 48000
}
}
}],
NameModifier: `_${output.preset}`
}))
}]
},
UserMetadata: {
videoId: params.videoId
}
};

const result = await mediaConvert.createJob(jobSettings).promise();
return result.Job.Id;
}
SegmentsPerSubdirectory

The SegmentsPerSubdirectory: 10000 setting is critical. Without it, MediaConvert creates subdirectories for segments, breaking HLS playback. This was a key fix during migration.

3. mediaconvert-webhook Lambda

Receives MediaConvert completion events via EventBridge:

// backend/aws-lambda/mediaconvert-webhook/index.js

exports.handler = async (event) => {
const detail = event.detail;
const videoId = detail.userMetadata.videoId;
const status = detail.status;

if (status === 'COMPLETE') {
// Get output file locations
const hlsManifestKey = `videos/${videoId}/hls/master.m3u8`;

// Notify backend
await notifyBackend(videoId, {
status: 'ready',
hlsManifestKey,
outputs: detail.outputGroupDetails
});

} else if (status === 'ERROR') {
const errorMessage = detail.errorMessage || 'Unknown error';

await notifyBackend(videoId, {
status: 'failed',
error: errorMessage
});
}

return { statusCode: 200 };
};

async function notifyBackend(videoId, data) {
await fetch(`${process.env.BACKEND_URL}/api/internal/videos/${videoId}/hls-complete`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Internal-Key': process.env.INTERNAL_API_KEY
},
body: JSON.stringify(data)
});
}

4. Backend Webhook Handler

Updates Firestore when transcoding completes:

// backend/src/routes/internal.js

router.post('/videos/:videoId/hls-complete', verifyInternalKey, async (req, res) => {
const { videoId } = req.params;
const { status, hlsManifestKey, error } = req.body;

await db.collection('videos').doc(videoId).update({
hlsStatus: status,
hlsManifestKey: status === 'ready' ? hlsManifestKey : null,
hlsError: status === 'failed' ? error : null,
hlsCompletedAt: admin.firestore.FieldValue.serverTimestamp()
});

res.json({ success: true });
});

Multipart Upload API

For large files (>100MB), the frontend uses multipart upload:

Initiate Multipart Upload

// POST /api/upload/video/multipart/initiate
router.post('/upload/video/multipart/initiate', async (req, res) => {
const { filename, contentType, fileSize } = req.body;

const videoId = generateVideoId();
const key = `videos/${videoId}/original/${filename}`;

// Create multipart upload
const upload = await s3.createMultipartUpload({
Bucket: process.env.VIDEO_BUCKET,
Key: key,
ContentType: contentType
}).promise();

// Generate presigned URLs for each part (max 10GB = ~100 parts of 100MB)
const partCount = Math.ceil(fileSize / (100 * 1024 * 1024));
const presignedUrls = [];

for (let partNumber = 1; partNumber <= partCount; partNumber++) {
const url = await s3.getSignedUrlPromise('uploadPart', {
Bucket: process.env.VIDEO_BUCKET,
Key: key,
UploadId: upload.UploadId,
PartNumber: partNumber,
Expires: 3600
});
presignedUrls.push({ partNumber, url });
}

res.json({
videoId,
uploadId: upload.UploadId,
key,
presignedUrls
});
});

Complete Multipart Upload

// POST /api/upload/video/multipart/complete
router.post('/upload/video/multipart/complete', async (req, res) => {
const { uploadId, key, parts } = req.body;

await s3.completeMultipartUpload({
Bucket: process.env.VIDEO_BUCKET,
Key: key,
UploadId: uploadId,
MultipartUpload: {
Parts: parts.map(p => ({
ETag: p.etag,
PartNumber: p.partNumber
}))
}
}).promise();

res.json({ success: true });
});

Abort Multipart Upload

// POST /api/upload/video/multipart/abort
router.post('/upload/video/multipart/abort', async (req, res) => {
const { uploadId, key } = req.body;

await s3.abortMultipartUpload({
Bucket: process.env.VIDEO_BUCKET,
Key: key,
UploadId: uploadId
}).promise();

res.json({ success: true });
});

Frontend Upload Service

// frontend/src/lib/services/videoService.js

export async function uploadVideo(file, onProgress) {
const MULTIPART_THRESHOLD = 100 * 1024 * 1024; // 100MB

if (file.size > MULTIPART_THRESHOLD) {
return uploadMultipart(file, onProgress);
} else {
return uploadDirect(file, onProgress);
}
}

async function uploadMultipart(file, onProgress) {
// 1. Initiate multipart upload
const { videoId, uploadId, key, presignedUrls } = await initiateMultipartUpload(file);

// 2. Upload parts (4 concurrent)
const PART_SIZE = 100 * 1024 * 1024;
const parts = [];
const concurrency = 4;

for (let i = 0; i < presignedUrls.length; i += concurrency) {
const batch = presignedUrls.slice(i, i + concurrency);

const batchResults = await Promise.all(
batch.map(async ({ partNumber, url }) => {
const start = (partNumber - 1) * PART_SIZE;
const end = Math.min(start + PART_SIZE, file.size);
const chunk = file.slice(start, end);

const response = await fetch(url, {
method: 'PUT',
body: chunk
});

return {
partNumber,
etag: response.headers.get('etag')
};
})
);

parts.push(...batchResults);

// Report progress
onProgress((i + batch.length) / presignedUrls.length);
}

// 3. Complete multipart upload
await completeMultipartUpload(uploadId, key, parts);

return { videoId };
}

Manual Retry Endpoint

For admin-triggered retry of failed transcodes:

// POST /api/videos/:videoId/retry-hls
router.post('/videos/:videoId/retry-hls', verifyAdmin, async (req, res) => {
const { videoId } = req.params;

// Get video document
const video = await db.collection('videos').doc(videoId).get();
if (!video.exists) {
return res.status(404).json({ error: 'Video not found' });
}

// Reset HLS status
await db.collection('videos').doc(videoId).update({
hlsStatus: 'pending',
hlsError: null
});

// Re-trigger Lambda by copying the object to itself
// (This fires the S3 event that triggers mediaconvert-submit)
const key = video.data().storageKey;
await s3.copyObject({
Bucket: process.env.VIDEO_BUCKET,
CopySource: `${process.env.VIDEO_BUCKET}/${key}`,
Key: key
}).promise();

res.json({ success: true, message: 'Retry initiated' });
});

EventBridge Retry Mechanism

EventBridge provides automatic retry for failed Lambda invocations:

# CloudFormation/SAM template
MediaConvertCompleteRule:
Type: AWS::Events::Rule
Properties:
EventPattern:
source:
- aws.mediaconvert
detail-type:
- MediaConvert Job State Change
detail:
status:
- COMPLETE
- ERROR
Targets:
- Id: WebhookLambda
Arn: !GetAtt MediaConvertWebhookFunction.Arn
RetryPolicy:
MaximumRetryAttempts: 3
MaximumEventAgeInSeconds: 3600

Environment Variables

VariableDescription
MEDIACONVERT_ENDPOINTRegional MediaConvert endpoint
MEDIACONVERT_ROLE_ARNIAM role for MediaConvert jobs
VIDEO_BUCKETS3 bucket for video storage
BACKEND_URLBackend API URL for webhooks
INTERNAL_API_KEYShared secret for internal APIs

Cost Considerations

MediaConvert pricing (~$0.015/minute for standard queue):

Video LengthApproximate Cost
5 minutes$0.08
30 minutes$0.45
1 hour$0.90
2 hours$1.80

Tips for cost optimization:

  • Use on-demand queue for typical videos
  • Consider reserved queue for high volume
  • Only generate quality levels up to source resolution

Key Implementation Files

FilePurpose
backend/aws-lambda/mediaconvert-submit/S3 trigger, job submission
backend/aws-lambda/mediaconvert-webhook/Completion handler
backend/src/services/s3Service.jsMultipart upload functions
frontend/src/lib/services/videoService.jsFrontend upload logic
backend/src/routes/videos.jsVideo API endpoints

Troubleshooting

Job Stuck in "Processing"

  1. Check MediaConvert console for job status
  2. Review CloudWatch logs for Lambda errors
  3. Verify IAM role permissions

"Access Denied" Errors

  1. Verify MediaConvert role has S3 access
  2. Check bucket policy allows MediaConvert
  3. Verify Lambda execution role

HLS Playback Issues

  1. Verify SegmentsPerSubdirectory: 10000 is set
  2. Check master playlist exists
  3. Verify CloudFront distribution (if using CDN)