Skip to content

Latest commit

 

History

History
205 lines (146 loc) · 5.7 KB

File metadata and controls

205 lines (146 loc) · 5.7 KB

HLS Encoding Guide

This document covers encoding short-form vertical video into HLS format for use with react-native-hls-cache.


Overview

Two scripts are provided:

Script Tool Best for
encode.sh ffmpeg only Quick encode, simple pipeline
shaka.sh ffmpeg + Shaka Packager Production quality, correct master playlist

Both produce VOD HLS — pre-encoded static files suitable for TikTok-style feeds.


Prerequisites

brew install ffmpeg

# For shaka.sh only:
curl -L https://github.com/shaka-project/shaka-packager/releases/latest/download/packager-osx-arm64 \
  -o /usr/local/bin/packager
chmod +x /usr/local/bin/packager

encode.sh — ffmpeg only

Single ffmpeg pass. Encodes all renditions and packages HLS in one command.

./encode.sh input.mp4 output_dir

Output structure

output/
├── master.m3u8
├── 720p/
│   ├── init.mp4
│   ├── 000.m4s, 001.m4s ...
│   └── rendition.m3u8
├── 480p/  ...
├── 270p/  ...
└── audio/
    ├── init.mp4
    └── rendition.m3u8

Rendition ladder (portrait 9:16)

Rendition Resolution Bitrate Profile Level
720p 720×1280 1800k High 4.0
480p 480×854 800k High 3.1
270p 270×480 350k High 3.0
audio 128k AAC

Known limitation

ffmpeg's -var_stream_map sometimes omits AUDIO= from #EXT-X-STREAM-INF lines, causing no audio on playback. Use shaka.sh for production.


shaka.sh — ffmpeg + Shaka Packager (recommended)

Two-step pipeline: ffmpeg encodes each rendition to a separate MP4, then Shaka Packager packages everything into HLS.

# Standard VOD (6s segments)
./shaka.sh input.mp4 output_dir

# Standard VOD with shorter segments (2s, faster startup)
./shaka.sh input.mp4 output_dir --ll

Pipeline

input.mp4
    → ffmpeg → 720p.mp4 (video only)
    → ffmpeg → 480p.mp4 (video only)
    → ffmpeg → 270p.mp4 (video only)
    → ffmpeg → audio.mp4 (audio only)
    → Shaka Packager → fMP4 segments + playlists
    → master.m3u8

Why Shaka over ffmpeg-only

Feature encode.sh shaka.sh
AUDIO= correctly linked Sometimes missing Always correct
AVERAGE-BANDWIDTH
FRAME-RATE declared
VIDEO-RANGE=SDR
CLOSED-CAPTIONS=NONE
iFrame playlists (fast seek)
Spec compliance Basic Production

Example master.m3u8 output

#EXTM3U
#EXT-X-INDEPENDENT-SEGMENTS

#EXT-X-MEDIA:TYPE=AUDIO,URI="audio/rendition.m3u8",GROUP-ID="audio-hi-0",
  LANGUAGE="en",NAME="Default",DEFAULT=NO,AUTOSELECT=YES,CHANNELS="2"

#EXT-X-STREAM-INF:BANDWIDTH=1691216,AVERAGE-BANDWIDTH=1096138,
  CODECS="avc1.640028,mp4a.40.2",RESOLUTION=720x1280,FRAME-RATE=30.000,
  VIDEO-RANGE=SDR,AUDIO="audio-hi-0",CLOSED-CAPTIONS=NONE
720p/rendition.m3u8

#EXT-X-I-FRAME-STREAM-INF:BANDWIDTH=603572,CODECS="avc1.640028",
  RESOLUTION=720x1280,VIDEO-RANGE=SDR,URI="720p/iframe.m3u8"

Why demuxed audio?

Audio is stored separately from video (demuxed) for three reasons:

  1. No duplication — one audio track shared across all video renditions instead of embedded in each
  2. Seamless ABR switching — player switches video quality without interrupting the audio buffer
  3. Multi-language ready — add more #EXT-X-MEDIA entries for additional languages without re-encoding video

Encoding settings explained

Setting Value Why
-g 60 -keyint_min 60 GOP = 60 frames (2s at 30fps) Keyframe alignment across renditions — required for seamless ABR switching
-sc_threshold 0 Disable scene change detection Prevents random keyframes that break segment alignment
-hls_segment_type fmp4 fMP4 Modern format, faster parse than MPEG-TS, required for #EXT-X-MAP
-hls_time 6 6 second segments Balance between startup latency and request count
force_original_aspect_ratio=decrease Scale filter Fits source into target resolution without stretching
pad=W:H:(ow-iw)/2:(oh-ih)/2 Pad filter Centers video with black bars if aspect ratio differs

Upload to Cloudflare R2

After encoding, upload with correct Content-Type and Cache-Control headers:

./upload.sh ./output my-bucket hls/my-video

Content-Type per file type

File Content-Type Cache-Control
.m3u8 application/vnd.apple.mpegurl no-cache
init.mp4 video/mp4 public, max-age=31536000, immutable
.m4s video/iso.segment public, max-age=31536000, immutable

Manifests are no-cache because players re-fetch them frequently. Segments are immutable because they never change once written.


Full pipeline

# 1. Encode
./shaka.sh input.mp4 ./output

# 2. Upload
export CF_ACCOUNT_ID="..."
export AWS_ACCESS_KEY_ID="..."
export AWS_SECRET_ACCESS_KEY="..."
./upload.sh ./output my-bucket hls/my-video

# 3. Verify
curl -s https://pub-xxx.r2.dev/hls/my-video/master.m3u8

# 4. Add to app
# In example/src/data/videos.ts:
# { url: 'https://pub-xxx.r2.dev/hls/my-video/master.m3u8' }

VOD vs Live

These scripts produce VOD (Video On Demand) — all segments exist before anyone watches.

VOD (these scripts) Live
Segments Pre-encoded, static files Written every 2-6 seconds
Manifest Never changes Updates constantly
Caching Cache forever Never cache manifests
LL-HLS tags Not applicable Required
Use case TikTok-style feed ✅ Sports broadcast, live stream

For live streaming, use Mux Live or Shaka Packager with a live RTMP source.