Recursive AI

Convert MP4 to HLS in C#

All onemedia recipes

Media.PackageHls("in.mp4", "hls") cuts a video into HLS segments and writes the playlists beside them: CMAF (fragmented MP4) segments by default, one directory per track, a media playlist (index.m3u8) for each, and a multivariant playlist (master.m3u8) that points a player at them. Nothing is re-encoded: the segments carry the MP4’s own coded pictures and audio frames. Packaging is new in 0.7.0-preview.1 and is a preview.

A preview that no validator has passed. In the README’s words:

  • “No validator has passed the output. Apple mediastreamvalidator was not run (it needs an Apple ID sign-in). The DASH-IF conformance tool v3.1.0 ran once (two findings, both fixed) and then fails in its own base-URI handling on every run.”
  • “What did check it: packet equality with the input through ffprobe (28 CMAF and 19 transport-stream inputs, 0 unexplained), TSDuck continuity on 38 of 38 transport-stream segment sets, and playback in hls.js 1.7.4 and dash.js 5.2.1 in Chrome 155, Firefox 141 and Playwright WebKit 26 (one failure: HEVC in transport-stream segments through hls.js in Chrome, not investigated) plus native HLS in macOS WebKit (7 of 7).”
Terminal
dotnet new console -o Mp4ToHls && cd Mp4ToHls
dotnet add package RecursiveAI.OneMedia --version 0.7.0-preview.1
curl -L -o sintel.mp4 https://download.blender.org/durian/trailer/sintel_trailer-480p.mp4

The input is the Sintel trailer (© copyright Blender Foundation, durian.blender.org, CC BY 3.0): 52 seconds of H.264 video and AAC audio. Put this in Program.cs and run dotnet run:

C#
using RecursiveAI.OneMedia;
using RecursiveAI.OneMedia.Streaming;
 
// CMAF segments of about six seconds, cut on keyframes, and HLS playlists.
PackagingReport report = Media.PackageHls("sintel.mp4", "hls");
 
foreach (PackagedTrack track in report.Tracks)
    Console.WriteLine($"{track.Name}: {track.Kind} {track.Codecs}, {track.Segments} segments, " +
                      $"{track.Bytes:N0} bytes, {track.DurationSeconds:0.###} s");
foreach (string playlist in report.Manifests)
    Console.WriteLine($"wrote {playlist}");
foreach (var loss in report.Losses)
    Console.WriteLine($"not carried: {loss.Kind} {loss.Subject}: {loss.Reason}");
foreach (string note in report.Notes)
    Console.WriteLine($"note: {note}");
Text
video0: Video avc1.64001E, 6 segments, 3,525,834 bytes, 52.208 s
audio0: Audio mp4a.40.2, 6 segments, 842,751 bytes, 51.947 s
wrote video0/index.m3u8
wrote audio0/index.m3u8
wrote master.m3u8

The video and the audio each become six segments and a media playlist. To change the segment length, the segment format or the output, call Packager.Package(input, directory, new PackagingOptions { … }) with SegmentDuration, SegmentFormat or SingleFile.

The playlists and segments

Terminal
find hls -type f -printf '%8s  %p\n' | sort -k2
Text
     346  hls/audio0/index.m3u8
     604  hls/audio0/init.mp4
  152426  hls/audio0/seg-00001.m4s
  118240  hls/audio0/seg-00002.m4s
  118932  hls/audio0/seg-00003.m4s
  137160  hls/audio0/seg-00004.m4s
  172187  hls/audio0/seg-00005.m4s
  143806  hls/audio0/seg-00006.m4s
     316  hls/master.m3u8
     355  hls/video0/index.m3u8
     710  hls/video0/init.mp4
  623173  hls/video0/seg-00001.m4s
  331892  hls/video0/seg-00002.m4s
  776011  hls/video0/seg-00003.m4s
 1131306  hls/video0/seg-00004.m4s
  576401  hls/video0/seg-00005.m4s
   87051  hls/video0/seg-00006.m4s

The multivariant playlist, which is the URL a player is given:

Terminal
cat hls/master.m3u8
Text
#EXTM3U
#EXT-X-VERSION:7
#EXT-X-INDEPENDENT-SEGMENTS
#EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID="audio",NAME="Audio 1",DEFAULT=YES,AUTOSELECT=YES,CHANNELS="2",URI="audio0/index.m3u8"
#EXT-X-STREAM-INF:BANDWIDTH=1212695,AVERAGE-BANDWIDTH=670058,CODECS="avc1.64001E,mp4a.40.2",RESOLUTION=854x480,AUDIO="audio"
video0/index.m3u8

One rendition, with the audio as a separate group. OneMedia doesn’t encode, so it can’t make lower bit-rate copies itself; given several encodes of the same video (Packager.Package(new[] { "1080p.mp4", "720p.mp4" }, "hls")) it writes a bit-rate ladder, one EXT-X-STREAM-INF per input. The video’s media playlist:

Terminal
cat hls/video0/index.m3u8
Text
#EXTM3U
#EXT-X-VERSION:7
#EXT-X-TARGETDURATION:11
#EXT-X-MEDIA-SEQUENCE:0
#EXT-X-PLAYLIST-TYPE:VOD
#EXT-X-INDEPENDENT-SEGMENTS
#EXT-X-MAP:URI="init.mp4"
#EXTINF:9.333333,
seg-00001.m4s
#EXTINF:7.166667,
seg-00002.m4s
#EXTINF:7.291667,
seg-00003.m4s
#EXTINF:8.375,
seg-00004.m4s
#EXTINF:10.416667,
seg-00005.m4s
#EXTINF:9.625,
seg-00006.m4s
#EXT-X-ENDLIST

A segment ends at the first keyframe at or after six seconds, so the segments here run 7 to 10 seconds, and EXT-X-TARGETDURATION is the longest, rounded up.

Check it with ffprobe

A CMAF segment plays after its initialization segment. The third video segment, with init.mp4 in front:

Terminal
cat hls/video0/init.mp4 hls/video0/seg-00003.m4s > seg3.mp4
ffprobe -v error -count_packets -show_entries stream=codec_name,width,height,nb_read_packets -of compact seg3.mp4
ffprobe -v error -show_entries packet=pts_time,flags -of compact seg3.mp4 | head -n 3
Text
stream|codec_name=h264|width=854|height=480|nb_read_packets=175
packet|pts_time=16.500000|flags=K__
packet|pts_time=16.625000|flags=___
packet|pts_time=16.583333|flags=___

The segment starts at 16.5 s, where the first two segments end, on a keyframe (K). Through the multivariant playlist, every packet of both tracks has the time, keyframe flag, size and bytes (MD5) it has in the MP4:

Terminal
for s in v:0 a:0; do
  for f in sintel.mp4 hls/master.m3u8; do
    ffprobe -v error -select_streams $s -show_entries packet=pts_time,flags,size,data_hash -show_data_hash MD5 -of csv=p=0 $f > $(basename $f).csv
  done
  cmp -s sintel.mp4.csv master.m3u8.csv && echo "$s: $(wc -l < master.m3u8.csv) packets equal to the MP4's"
done
Text
v:0: 1253 packets equal to the MP4's
a:0: 2435 packets equal to the MP4's

Play it in a browser

Serve the hls folder from any web server and give the player master.m3u8. Safari and iOS play HLS natively; in Chrome and Firefox, hls.js feeds the segments to the <video> element:

HTML
<video id="video" controls muted></video>
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/hls.min.js"></script>
<script>
  const video = document.getElementById("video");
  if (video.canPlayType("application/vnd.apple.mpegurl")) video.src = "hls/master.m3u8";
  else { const hls = new Hls(); hls.loadSource("hls/master.m3u8"); hls.attachMedia(video); }
</script>

Served from a local web server, hls.js 1.7.4 in headless Chrome 149 played this output with no errors: 854 × 480, duration 52.208 s, readyState 4, the time advancing from 0.52 s to 2.52 s over two seconds, then a seek to 30 s that landed at 30.000 and went on playing (31.96 s two seconds later). Native HLS in Safari wasn’t run here; the project reports it in macOS WebKit (above).

Transport-stream segments, from the command line

The onemedia tool does the same with onemedia package. --ts writes MPEG-TS segments instead of CMAF, with the video and audio in one stream, for players and devices that want .ts:

Terminal
dotnet tool install --global RecursiveAI.OneMedia.Tool --version 0.7.0-preview.1
Terminal
onemedia package sintel.mp4 hls-ts --hls --ts
Text
stream0  Video    avc1.64001E,mp4a.40.2 6 segments, 5084460 bytes, 52.208 s
wrote stream0/index.m3u8
wrote master.m3u8
not carried: Disposition NotRepresentable A transport stream has no field for track dispositions.
not carried: Disposition NotRepresentable A transport stream has no field for track dispositions.
Terminal
cat hls-ts/stream0/index.m3u8 | head -n 9
ffprobe -v error -count_packets -show_entries stream=codec_name,nb_read_packets:format=format_name -of compact hls-ts/stream0/seg-00003.ts
Text
#EXTM3U
#EXT-X-VERSION:7
#EXT-X-TARGETDURATION:11
#EXT-X-MEDIA-SEQUENCE:0
#EXT-X-PLAYLIST-TYPE:VOD
#EXT-X-INDEPENDENT-SEGMENTS
#EXTINF:9.333333,
seg-00001.ts
#EXTINF:7.166667,
program|stream|codec_name=h264|nb_read_packets=175
stream|codec_name=aac|nb_read_packets=342
 
stream|codec_name=h264|nb_read_packets=175
stream|codec_name=aac|nb_read_packets=342
format|format_name=mpegts

The same cuts as the CMAF segments, each .ts starting with PAT and PMT. A TS segment holds the video as Annex B and the audio with ADTS headers, so its packet bytes differ from the MP4’s by design. hls.js 1.7.4 in headless Chrome 149 played these too, with no errors: duration 52.29 s, the time advancing, and a seek to 30 s that kept playing. Leave out --hls to write a DASH manifest as well (Create a DASH MPD from MP4), and add --live to package a stream as it arrives.

Limits

  • A preview that no validator has passed. It was checked by packet equality, TSDuck and playback in hls.js and dash.js, and in the README’s words “HEVC in transport-stream segments through hls.js in Chrome” failed and was “not investigated”.
  • No encryption. From the README: “encryption is not written; live mode takes one input and holds the current segment in memory”.
  • No re-encoding. Segments can only start on the input’s keyframes, so a long keyframe interval gives long segments. A bit-rate ladder needs inputs already encoded at each rate, and “Renditions switch cleanly only when their keyframes coincide” (Packaging).
  • TS segments. HLS only; Opus, VP9 and AV1 have no transport-stream mapping the muxer writes and are refused.
  • Reading HLS back. Media.Open("hls/master.m3u8") reads the presentation as one file (Read an HLS stream).