This document covers encoding short-form vertical video into HLS format for use with react-native-hls-cache.
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.
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/packagerSingle ffmpeg pass. Encodes all renditions and packages HLS in one command.
./encode.sh input.mp4 output_diroutput/
├── master.m3u8
├── 720p/
│ ├── init.mp4
│ ├── 000.m4s, 001.m4s ...
│ └── rendition.m3u8
├── 480p/ ...
├── 270p/ ...
└── audio/
├── init.mp4
└── rendition.m3u8
| 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 | — | — |
ffmpeg's -var_stream_map sometimes omits AUDIO= from #EXT-X-STREAM-INF lines, causing no audio on playback. Use shaka.sh for production.
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 --llinput.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
| 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 |
#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"
Audio is stored separately from video (demuxed) for three reasons:
- No duplication — one audio track shared across all video renditions instead of embedded in each
- Seamless ABR switching — player switches video quality without interrupting the audio buffer
- Multi-language ready — add more
#EXT-X-MEDIAentries for additional languages without re-encoding video
| 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 |
After encoding, upload with correct Content-Type and Cache-Control headers:
./upload.sh ./output my-bucket hls/my-video| 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.
# 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' }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.