Skip to content

Commit 9688840

Browse files
committed
* Initial Version
1 parent a920ae5 commit 9688840

File tree

4 files changed

+280
-0
lines changed

4 files changed

+280
-0
lines changed

Dockerfile

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
# Use the AWS Lambda Python 3.13 base image (Amazon Linux 2023)
2+
FROM public.ecr.aws/lambda/python:3.13
3+
4+
# Install dependencies via microdnf
5+
RUN microdnf update -y && \
6+
microdnf install -y tar xz && \
7+
microdnf clean all
8+
9+
# Download static build of FFmpeg
10+
RUN curl -L https://johnvansickle.com/ffmpeg/releases/ffmpeg-release-amd64-static.tar.xz \
11+
-o /tmp/ffmpeg.tar.xz && \
12+
tar -xJf /tmp/ffmpeg.tar.xz -C /tmp && \
13+
mkdir -p /opt/bin && \
14+
cp /tmp/ffmpeg-*-static/ffmpeg /opt/bin/ && \
15+
cp /tmp/ffmpeg-*-static/ffprobe /opt/bin/ && \
16+
chmod +x /opt/bin/ffmpeg /opt/bin/ffprobe && \
17+
rm -rf /tmp/*
18+
19+
# Copy your application code into the Lambda task root
20+
COPY src/transcoder/app.py ${LAMBDA_TASK_ROOT}/app.py
21+
22+
# Set the Lambda handler
23+
CMD ["app.lambda_handler"]

requirements.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
-r src/transcoder/requirements.txt

src/transcoder/app.py

Lines changed: 254 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,254 @@
1+
import base64
2+
import json
3+
import mimetypes
4+
import os
5+
import subprocess
6+
import tempfile
7+
import time
8+
9+
import boto3
10+
11+
s3 = boto3.client("s3")
12+
transcribe = boto3.client("transcribe")
13+
14+
# Paths for binaries in Lambda Layer
15+
FFMPEG = "/opt/bin/ffmpeg"
16+
FFPROBE = "/opt/bin/ffprobe"
17+
18+
# Video presets ordered highest→lowest
19+
ALL_PRESETS = [
20+
{"name": "2160p", "width": 3840, "height": 2160, "bitrate": "8000k"},
21+
{"name": "1440p", "width": 2560, "height": 1440, "bitrate": "5000k"},
22+
{"name": "1080p", "width": 1920, "height": 1080, "bitrate": "3500k"},
23+
{"name": "720p", "width": 1280, "height": 720, "bitrate": "2500k"},
24+
{"name": "480p", "width": 854, "height": 480, "bitrate": "1200k"},
25+
{"name": "360p", "width": 640, "height": 360, "bitrate": "800k"},
26+
]
27+
28+
# Sprite config
29+
SPRITE_FPS = 1
30+
SPRITE_SCALE_W = 320
31+
SPRITE_COLUMNS = 5
32+
33+
# Transcription language
34+
LANGUAGE_CODE = "en-US"
35+
36+
37+
def lambda_handler(event, context):
38+
if "Records" in event and event["Records"][0].get("s3"):
39+
return process_video(event)
40+
else:
41+
return stream_handler(event)
42+
43+
44+
def probe_resolution(path):
45+
cmd = [
46+
FFPROBE,
47+
"-v",
48+
"error",
49+
"-select_streams",
50+
"v:0",
51+
"-show_entries",
52+
"stream=width,height",
53+
"-of",
54+
"json",
55+
path,
56+
]
57+
info = json.loads(subprocess.check_output(cmd))
58+
stream = info["streams"][0]
59+
return int(stream["width"]), int(stream["height"])
60+
61+
62+
def select_presets(src_w, src_h):
63+
return [p for p in ALL_PRESETS if p["width"] <= src_w and p["height"] <= src_h]
64+
65+
66+
def process_video(event):
67+
record = event["Records"][0]["s3"]
68+
bucket = record["bucket"]["name"]
69+
key = record["object"]["key"]
70+
71+
# Download source to temp
72+
tmp_in = tempfile.mktemp(suffix=os.path.basename(key))
73+
s3.download_file(bucket, key, tmp_in)
74+
75+
# Probe and select
76+
src_w, src_h = probe_resolution(tmp_in)
77+
presets = select_presets(src_w, src_h)
78+
79+
base_name, ext = os.path.splitext(os.path.basename(key))
80+
output_prefix = f"processed/{base_name}/"
81+
82+
# HLS generation
83+
for p in presets:
84+
out_hls = tempfile.mkdtemp()
85+
playlist = os.path.join(out_hls, f"{p['name']}.m3u8")
86+
cmd_hls = [
87+
FFMPEG,
88+
"-i",
89+
tmp_in,
90+
"-vf",
91+
f"scale=w={p['width']}:h={p['height']}",
92+
"-c:v",
93+
"h264",
94+
"-profile:v",
95+
"main",
96+
"-crf",
97+
"20",
98+
"-b:v",
99+
p["bitrate"],
100+
"-maxrate",
101+
p["bitrate"],
102+
"-bufsize",
103+
"1200k",
104+
"-c:a",
105+
"aac",
106+
"-b:a",
107+
"128k",
108+
"-hls_time",
109+
"6",
110+
"-hls_list_size",
111+
"0",
112+
"-hls_segment_filename",
113+
os.path.join(out_hls, f"seg_%03d_{p['name']}.ts"),
114+
playlist,
115+
]
116+
subprocess.check_call(cmd_hls)
117+
# upload
118+
for root, _, files in os.walk(out_hls):
119+
for f in files:
120+
local = os.path.join(root, f)
121+
s3.upload_file(
122+
local,
123+
bucket,
124+
output_prefix + f"hls/{p['name']}/{f}",
125+
ExtraArgs={"ContentType": _guess_mime(f)},
126+
)
127+
128+
# Master HLS playlist
129+
master_hls = "#EXTM3U\n"
130+
for p in presets:
131+
master_hls += f"#EXT-X-STREAM-INF:BANDWIDTH={int(p['bitrate'].rstrip('k'))*1000},RESOLUTION={p['width']}x{p['height']}\n"
132+
master_hls += f"hls/{p['name']}/{p['name']}.m3u8\n"
133+
s3.put_object(
134+
Bucket=bucket,
135+
Key=output_prefix + "hls/master.m3u8",
136+
Body=master_hls,
137+
ContentType="application/vnd.apple.mpegurl",
138+
)
139+
140+
# DASH generation using ffmpeg dash muxer
141+
dash_dir = tempfile.mkdtemp()
142+
dash_mpd = os.path.join(dash_dir, "stream.mpd")
143+
map_cmd = []
144+
for p in presets:
145+
map_cmd += [
146+
"-map",
147+
"0:v:0",
148+
"-b:v:" + str(presets.index(p)),
149+
p["bitrate"],
150+
"-filter:v:" + str(presets.index(p)),
151+
f"scale={p['width']}:{p['height']}",
152+
]
153+
cmd_dash = [
154+
FFMPEG,
155+
"-i",
156+
tmp_in,
157+
*map_cmd,
158+
"-c:a",
159+
"aac",
160+
"-use_template",
161+
"1",
162+
"-use_timeline",
163+
"1",
164+
"-adaptation_sets",
165+
"id=0,streams=v id=1,streams=a",
166+
"-f",
167+
"dash",
168+
dash_mpd,
169+
]
170+
subprocess.check_call(cmd_dash)
171+
# upload DASH files
172+
for root, _, files in os.walk(dash_dir):
173+
for f in files:
174+
path = os.path.join(root, f)
175+
s3.upload_file(
176+
path,
177+
bucket,
178+
output_prefix + f"dash/{f}",
179+
ExtraArgs={"ContentType": _guess_mime(f)},
180+
)
181+
182+
# Sprite sheet
183+
sprite = tempfile.mktemp(suffix=".png")
184+
subprocess.check_call(
185+
[
186+
FFMPEG,
187+
"-i",
188+
tmp_in,
189+
"-vf",
190+
f"fps={SPRITE_FPS},scale={SPRITE_SCALE_W}:-1,tile={SPRITE_COLUMNS}x",
191+
sprite,
192+
]
193+
)
194+
s3.upload_file(
195+
sprite,
196+
bucket,
197+
output_prefix + "sprite.png",
198+
ExtraArgs={"ContentType": "image/png"},
199+
)
200+
201+
# Start transcription
202+
job = f"{base_name}-{int(time.time())}"
203+
transcribe.start_transcription_job(
204+
TranscriptionJobName=job,
205+
Media={"MediaFileUri": f"s3://{bucket}/{key}"},
206+
MediaFormat=ext.lstrip("."),
207+
LanguageCode=LANGUAGE_CODE,
208+
OutputBucketName=bucket,
209+
OutputKey=f"{output_prefix}{job}.json",
210+
)
211+
212+
return {
213+
"status": "done",
214+
"presets": [p["name"] for p in presets],
215+
"sprite": output_prefix + "sprite.png",
216+
"hls": "hls/master.m3u8",
217+
"dash": "dash/stream.mpd",
218+
"transcription_job": job,
219+
}
220+
221+
222+
def stream_handler(event):
223+
params = event.get("queryStringParameters") or {}
224+
bucket, key = params.get("bucket"), params.get("key")
225+
rng = event.get("headers", {}).get("Range")
226+
if not bucket or not key:
227+
return {"statusCode": 400, "body": "Missing bucket/key"}
228+
229+
g = {"Bucket": bucket, "Key": key}
230+
if rng:
231+
g["Range"] = rng
232+
233+
obj = s3.get_object(**g)
234+
data = obj["Body"].read()
235+
headers = {
236+
"Content-Type": obj.get("ContentType") or mimetypes.guess_type(key)[0],
237+
"Accept-Ranges": "bytes",
238+
"Content-Length": str(obj["ContentLength"]),
239+
}
240+
code = 206 if rng else 200
241+
if rng:
242+
headers["Content-Range"] = obj["ResponseMetadata"]["HTTPHeaders"].get(
243+
"content-range"
244+
)
245+
return {
246+
"statusCode": code,
247+
"headers": headers,
248+
"body": base64.b64encode(data).decode(),
249+
"isBase64Encoded": True,
250+
}
251+
252+
253+
def _guess_mime(f):
254+
return mimetypes.guess_type(f)[0] or "application/octet-stream"

src/transcoder/requirements.txt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
requests==2.32.4
2+
boto3==1.38.37

0 commit comments

Comments
 (0)