Browse Source

Implement Low-Latency HLS (#938)

* add hlsVariant parameter

* hls: split muxer into variants

* hls: implement fmp4 segments

* hls muxer: implement low latency mode

* hls muxer: support audio with fmp4 mode

* hls muxer: rewrite file router

* hls muxer: implement preload hint

* hls muxer: add various error codes

* hls muxer: use explicit flags

* hls muxer: fix error in aac pts

* hls muxer: fix sudden freezes with video+audio

* hls muxer: skip empty parts

* hls muxer: fix video FPS

* hls muxer: add parameter hlsPartDuration

* hls muxer: refactor fmp4 muxer

* hls muxer: fix CAN-SKIP-UNTIL

* hls muxer: refactor code

* hls muxer: show only parts of last 2 segments

* hls muxer: implementa playlist delta updates

* hls muxer: change playlist content type

* hls muxer: improve video dts precision

* hls muxer: fix video sample flags

* hls muxer: improve iphone audio support

* hls muxer: improve mp4 timestamp precision

* hls muxer: add offset between pts and dts

* hls muxer: close muxer in case of error

* hls muxer: stop logging requests with the info level

* hls muxer: rename entry into sample

* hls muxer: compensate video dts error over time

* hls muxer: change default segment count

* hls muxer: add starting gap

* hls muxer: set default part duration to 200ms

* hls muxer: fix audio-only streams on ios

* hls muxer: add playsinline attribute to video tag of default web page

* hls muxer: keep mpegts as the default hls variant

* hls muxer: implement encryption

* hls muxer: rewrite dts estimation

* hls muxer: improve DTS precision

* hls muxer: use right SPS/PPS for each sample

* hls muxer: adjust part duration dynamically

* add comments

* update readme

* hls muxer: fix memory leak

* hls muxer: decrease ram consumption
pull/1003/head
Alessandro Ros 3 years ago committed by GitHub
parent
commit
e115983296
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 60
      README.md
  2. 10
      apidocs/openapi.yaml
  3. 13
      go.mod
  4. 44
      go.sum
  5. 35
      internal/conf/conf.go
  6. 64
      internal/conf/hlsvariant.go
  7. 5
      internal/core/api.go
  8. 10
      internal/core/core.go
  9. 121
      internal/core/hls_muxer.go
  10. 76
      internal/core/hls_server.go
  11. 2
      internal/core/hls_source_test.go
  12. 2
      internal/hls/client_test.go
  13. 94
      internal/hls/mp4writer.go
  14. 85
      internal/hls/muxer.go
  15. 34
      internal/hls/muxer_primary_playlist.go
  16. 130
      internal/hls/muxer_stream_playlist.go
  17. 62
      internal/hls/muxer_test.go
  18. 201
      internal/hls/muxer_ts_generator.go
  19. 22
      internal/hls/muxer_variant.go
  20. 247
      internal/hls/muxer_variant_fmp4.go
  21. 621
      internal/hls/muxer_variant_fmp4_init.go
  22. 390
      internal/hls/muxer_variant_fmp4_part.go
  23. 503
      internal/hls/muxer_variant_fmp4_playlist.go
  24. 196
      internal/hls/muxer_variant_fmp4_segment.go
  25. 344
      internal/hls/muxer_variant_fmp4_segmenter.go
  26. 52
      internal/hls/muxer_variant_mpegts.go
  27. 146
      internal/hls/muxer_variant_mpegts_playlist.go
  28. 72
      internal/hls/muxer_variant_mpegts_segment.go
  29. 169
      internal/hls/muxer_variant_mpegts_segmenter.go
  30. 27
      rtsp-simple-server.yml

60
README.md

@ -9,7 +9,7 @@ _rtsp-simple-server_ is a ready-to-use and zero-dependency server and proxy that @@ -9,7 +9,7 @@ _rtsp-simple-server_ is a ready-to-use and zero-dependency server and proxy that
|--------|-----------|-------|----|-----|
|RTSP|fastest way to publish and read streams|:heavy_check_mark:|:heavy_check_mark:|:heavy_check_mark:|
|RTMP|allows to interact with legacy software|:heavy_check_mark:|:heavy_check_mark:|:heavy_check_mark:|
|HLS|allows to embed streams into a web page|:x:|:heavy_check_mark:|:heavy_check_mark:|
|Low-Latency HLS|allows to embed streams into a web page|:x:|:heavy_check_mark:|:heavy_check_mark:|
Features:
@ -75,7 +75,8 @@ Features: @@ -75,7 +75,8 @@ Features:
* [HLS protocol](#hls-protocol)
* [HLS general usage](#hls-general-usage)
* [Embedding](#embedding)
* [Decrease delay](#decrease-delay)
* [Low-Latency variant](#low-latency-variant)
* [Decreasing latency](#decreasing-latency)
* [Links](#links)
## Installation
@ -672,7 +673,7 @@ vlc rtsp://localhost:8554/mystream?vlcmulticast @@ -672,7 +673,7 @@ vlc rtsp://localhost:8554/mystream?vlcmulticast
### Encryption
Incoming and outgoing RTSP streams can be encrypted with TLS (obtaining the RTSPS protocol). A self-signed TLS certificate is needed and can be generated with openSSL:
Incoming and outgoing RTSP streams can be encrypted with TLS (obtaining the RTSPS protocol). A TLS certificate is needed and can be generated with OpenSSL:
```
openssl genrsa -out server.key 2048
@ -778,7 +779,7 @@ ffmpeg -re -stream_loop -1 -i file.ts -c copy -f flv rtmp://localhost:8554/mystr @@ -778,7 +779,7 @@ ffmpeg -re -stream_loop -1 -i file.ts -c copy -f flv rtmp://localhost:8554/mystr
### HLS general usage
HLS is a media format that allows to embed live streams into web pages. Every stream published to the server can be accessed with a web browser by visiting:
HLS is a stream protocol that allows to embed live streams into web pages. It works by splitting the stream into segments and serving these segments with the HTTP protocol. Every stream published to the server can be accessed with a web browser by visiting:
```
http://localhost:8888/mystream
@ -802,14 +803,55 @@ Alternatively you can create a video tag that points directly to the stream play @@ -802,14 +803,55 @@ Alternatively you can create a video tag that points directly to the stream play
Please note that most browsers don't support HLS directly (except Safari); a Javascript library, like [hls.js](https://github.com/video-dev/hls.js), must be used to load the stream. You can find a working example by looking at the [source code of the HLS muxer](internal/core/hls_muxer.go).
### Decrease delay
### Low-Latency variant
HLS works by splitting the stream into segments and serving these segments with the standard HTTP protocol. Delay is introduced since a client must wait for the server to generate segments before downloading them. This delay amounts to 1-15 seconds depending on some factors:
Low-Latency HLS is a [recently standardized](https://datatracker.ietf.org/doc/html/draft-pantos-hls-rfc8216bis#section-3.1) variant of the protocol that allows to greatly reduce playback latency. It works by splitting segments into parts, that are served before the segment is complete.
* the number of segments
* the duration of each segment
LL-HLS is disabled by default. To enable it, a TLS certificate is needed and can be generated with OpenSSL:
To decrease the delay, it's possible to decrease the number of segments by editing the `hlsSegmentCount` parameter (decreasing stream stability) and decrease the duration of each segment. The duration of each segments depends on the `hlsSegmentDuration`, but also on the original stream, since the duration is prolonged to include at least one IDR frame (complete frame that can be decoded independently from the others) into each segment. Therefore, the stream must be tuned by either acting on the original hardware (for instance, there's a setting _Key-Frame Interval_ in most cameras, that must be reduced) or re-encoding the stream, setting a low IDR frame interval (`-g` option):
```
openssl genrsa -out server.key 2048
openssl req -new -x509 -sha256 -key server.key -out server.crt -days 3650
```
Set the `hlsVariant`, `hlsEncryption`, `hlsServerKey` and `hlsServerCert` parameters in the configuration file:
```yml
hlsVariant: lowLatency
hlsEncryption: yes
hlsServerKey: server.key
hlsServerCert: server.crt
```
Every stream published to the server can then be read with LL-HLS by visiting:
```
https://localhost:8888/mystream
```
If the stream is not shown correctly, try tuning the `hlsPartDuration` parameter, for instance:
```yml
hlsPartDuration: 500ms
```
### Decreasing latency
in HLS, latency is introduced since a client must wait for the server to generate segments before downloading them. This delay amounts to 1-15 seconds depending on the duration of each segment, and to 500ms-3s if the Low-Latency variant is enabled.
To decrease the latency, you can:
* enable the Low-Latency variant of the HLS protocol, as explained in the previous section;
* if Low-latency is enabled, try decreasing the `hlsPartDuration` parameter;
* try decreasing the `hlsSegmentDuration` parameter;
* The segment duration is influenced by the interval between the IDR frames of the video track. An IDR frame is a frame that can be decoded independently from the others. The server changes the segment duration in order to include at least one IDR frame into each segment. Therefore, you need to decrease the interval between the IDR frames. This can be done in two ways:
* if the stream is being hardware-generated (i.e. by a camera), there's usually a setting called _Key-Frame Interval_ in the camera configuration page
* otherwise, the stream must be re-encoded. It's possible to tune the IDR frame interval by using ffmpeg's `-g` option:
```
ffmpeg -i rtsp://original-stream -pix_fmt yuv420p -c:v libx264 -preset ultrafast -b:v 600k -max_muxing_queue_size 1024 -g 30 -f rtsp rtsp://localhost:$RTSP_PORT/compressed

10
apidocs/openapi.yaml

@ -95,14 +95,24 @@ components: @@ -95,14 +95,24 @@ components:
type: string
hlsAlwaysRemux:
type: boolean
hlsVariant:
type: string
hlsSegmentCount:
type: integer
hlsSegmentDuration:
type: string
hlsPartDuration:
type: string
hlsSegmentMaxSize:
type: string
hlsAllowOrigin:
type: string
hlsEncryption:
type: boolean
hlsServerKey:
type: string
hlsServerCert:
type: string
paths:
type: object

13
go.mod

@ -4,15 +4,18 @@ go 1.17 @@ -4,15 +4,18 @@ go 1.17
require (
code.cloudfoundry.org/bytefmt v0.0.0-20211005130812-5bb3c17173e5
github.com/aler9/gortsplib v0.0.0-20220523191550-7e8ec60aad70
github.com/abema/go-mp4 v0.7.2
github.com/aler9/gortsplib v0.0.0-20220529122539-7c2e3c03d1f4
github.com/asticode/go-astits v1.10.1-0.20220319093903-4abe66a9b757
github.com/fsnotify/fsnotify v1.4.9
github.com/gin-gonic/gin v1.7.2
github.com/gookit/color v1.4.2
github.com/grafov/m3u8 v0.11.1
github.com/icza/bitio v1.1.0
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51
github.com/notedit/rtmp v0.0.2
github.com/pion/rtp v1.7.9
github.com/orcaman/writerseeker v0.0.0-20200621085525-1d3f536ff85e
github.com/pion/rtp v1.7.13
github.com/stretchr/testify v1.7.1
golang.org/x/crypto v0.0.0-20201221181555-eec23a3978ad
gopkg.in/alecthomas/kingpin.v2 v2.2.6
@ -29,7 +32,7 @@ require ( @@ -29,7 +32,7 @@ require (
github.com/go-playground/universal-translator v0.17.0 // indirect
github.com/go-playground/validator/v10 v10.4.1 // indirect
github.com/golang/protobuf v1.5.2 // indirect
github.com/icza/bitio v1.0.0 // indirect
github.com/google/uuid v1.1.2 // indirect
github.com/json-iterator/go v1.1.9 // indirect
github.com/leodido/go-urn v1.2.0 // indirect
github.com/mattn/go-isatty v0.0.12 // indirect
@ -41,8 +44,8 @@ require ( @@ -41,8 +44,8 @@ require (
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/ugorji/go/codec v1.1.7 // indirect
github.com/xo/terminfo v0.0.0-20210125001918-ca9a967f8778 // indirect
golang.org/x/net v0.0.0-20210610132358-84b48f89b13b // indirect
golang.org/x/sys v0.0.0-20210423082822-04245dca01da // indirect
golang.org/x/net v0.0.0-20220526153639-5463443f8c37 // indirect
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a // indirect
google.golang.org/protobuf v1.26.0 // indirect
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c // indirect
)

44
go.sum

@ -1,11 +1,13 @@ @@ -1,11 +1,13 @@
code.cloudfoundry.org/bytefmt v0.0.0-20211005130812-5bb3c17173e5 h1:tM5+dn2C9xZw1RzgI6WTQW1rGqdUimKB3RFbyu4h6Hc=
code.cloudfoundry.org/bytefmt v0.0.0-20211005130812-5bb3c17173e5/go.mod h1:v4VVB6oBMz/c9fRY6vZrwr5xKRWOH5NPDjQZlPk0Gbs=
github.com/abema/go-mp4 v0.7.2 h1:ugTC8gfEmjyaDKpXs3vi2QzgJbDu9B8m6UMMIpbYbGg=
github.com/abema/go-mp4 v0.7.2/go.mod h1:vPl9t5ZK7K0x68jh12/+ECWBCXoWuIDtNgPtU2f04ws=
github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751 h1:JYp7IbQjafoB+tBA3gMyHYHrpOtNuDiK/uB5uXxq5wM=
github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
github.com/alecthomas/units v0.0.0-20190924025748-f65c72e2690d h1:UQZhZ2O0vMHr2cI+DC1Mbh0TJxzA3RcLoMsFw+aXw7E=
github.com/alecthomas/units v0.0.0-20190924025748-f65c72e2690d/go.mod h1:rBZYJk541a8SKzHPHnH3zbiI+7dagKZ0cgpgrD7Fyho=
github.com/aler9/gortsplib v0.0.0-20220523191550-7e8ec60aad70 h1:UwonlJGOAyBk0MiZWQ1v8Rs9RZJD3EQCvCmjnDES5gM=
github.com/aler9/gortsplib v0.0.0-20220523191550-7e8ec60aad70/go.mod h1:RhSyr7qfzBwUlTLpm3KOAK+IclOQAhxGmNZi6KNTyXA=
github.com/aler9/gortsplib v0.0.0-20220529122539-7c2e3c03d1f4 h1:5gd/tqApfqpfjCAlV4pxlwzWmRYMO+m4+RYGgSnSN3Q=
github.com/aler9/gortsplib v0.0.0-20220529122539-7c2e3c03d1f4/go.mod h1:i1e4CEs42IrbidMUNTSNOKmeGPCOHVX9P3BvPxzyMtI=
github.com/aler9/rtmp v0.0.0-20210403095203-3be4a5535927 h1:95mXJ5fUCYpBRdSOnLAQAdJHHKxxxJrVCiaqDi965YQ=
github.com/aler9/rtmp v0.0.0-20210403095203-3be4a5535927/go.mod h1:vzuE21rowz+lT1NGsWbreIvYulgBpCGnQyeTyFblUHc=
github.com/asticode/go-astikit v0.20.0 h1:+7N+J4E4lWx2QOkRdOf6DafWJMv6O4RRfgClwQokrH8=
@ -13,6 +15,7 @@ github.com/asticode/go-astikit v0.20.0/go.mod h1:h4ly7idim1tNhaVkdVBeXQZEE3L0xbl @@ -13,6 +15,7 @@ github.com/asticode/go-astikit v0.20.0/go.mod h1:h4ly7idim1tNhaVkdVBeXQZEE3L0xbl
github.com/asticode/go-astits v1.10.0/go.mod h1:DkOWmBNQpnr9mv24KfZjq4JawCFX1FCqjLVGvO0DygQ=
github.com/asticode/go-astits v1.10.1-0.20220319093903-4abe66a9b757 h1:mmwJPTHZbtBZQs9OOIQcN9K+TXaIDy0TVvbdtTzqVF0=
github.com/asticode/go-astits v1.10.1-0.20220319093903-4abe66a9b757/go.mod h1:DkOWmBNQpnr9mv24KfZjq4JawCFX1FCqjLVGvO0DygQ=
github.com/creack/pty v1.1.7/go.mod h1:lj5s0c3V2DBrqTV7llrYr5NG6My20zk30Fl46Y7DoTY=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
@ -49,19 +52,27 @@ github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/ @@ -49,19 +52,27 @@ github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/
github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/uuid v1.1.2 h1:EVhdT+1Kseyi1/pUmXKaFxYsDNy9RQYkMWRH68J/W7Y=
github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gookit/color v1.4.2 h1:tXy44JFSFkKnELV6WaMo/lLfu/meqITX3iAV52do7lk=
github.com/gookit/color v1.4.2/go.mod h1:fqRyamkC1W8uxl+lxCQxOT09l/vYfZ+QeiX3rKQHCoQ=
github.com/grafov/m3u8 v0.11.1 h1:igZ7EBIB2IAsPPazKwRKdbhxcoBKO3lO1UY57PZDeNA=
github.com/grafov/m3u8 v0.11.1/go.mod h1:nqzOkfBiZJENr52zTVd/Dcl03yzphIMbJqkXGu+u080=
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
github.com/icza/bitio v1.0.0 h1:squ/m1SHyFeCA6+6Gyol1AxV9nmPPlJFT8c2vKdj3U8=
github.com/icza/bitio v1.0.0/go.mod h1:0jGnlLAx8MKMr9VGnn/4YrvZiprkvBelsVIbA9Jjr9A=
github.com/icza/bitio v1.1.0 h1:ysX4vtldjdi3Ygai5m1cWy4oLkhWTAi+SyO6HC8L9T0=
github.com/icza/bitio v1.1.0/go.mod h1:0jGnlLAx8MKMr9VGnn/4YrvZiprkvBelsVIbA9Jjr9A=
github.com/icza/mighty v0.0.0-20180919140131-cfd07d671de6 h1:8UsGZ2rr2ksmEru6lToqnXgA8Mz1DP11X4zSJ159C3k=
github.com/icza/mighty v0.0.0-20180919140131-cfd07d671de6/go.mod h1:xQig96I1VNBDIWGCdTt54nHt6EeI639SmHycLYL7FkA=
github.com/json-iterator/go v1.1.9 h1:9yzud/Ht36ygwatGx56VwCZtlI/2AD15T1X2sjSuGns=
github.com/json-iterator/go v1.1.9/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs=
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8=
github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/pty v1.1.8/go.mod h1:O1sed60cT9XZ5uDucP5qwvh+TE3NnUj51EiZO/lmSfw=
github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/leodido/go-urn v1.2.0 h1:hpXL4XnriNwQ/ABnpepYM/1vCLWNDfUNts8dX3xTG6Y=
github.com/leodido/go-urn v1.2.0/go.mod h1:+8+nEpDfqqsY+g338gtMEUOtuK+4dEMhiQEgxpxOKII=
github.com/mattn/go-isatty v0.0.12 h1:wuysRhFDzyxgEmMf5xjvJ2M9dZoWAXNNr5LSBS7uHXY=
@ -81,12 +92,14 @@ github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7J @@ -81,12 +92,14 @@ github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7J
github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo=
github.com/onsi/gomega v1.16.0 h1:6gjqkI8iiRHMvdccRJM8rVKjCWk6ZIm6FTm3ddIe4/c=
github.com/onsi/gomega v1.16.0/go.mod h1:HnhC7FXeEQY45zxNK3PPoIUhzk/80Xly9PcubAlGdZY=
github.com/orcaman/writerseeker v0.0.0-20200621085525-1d3f536ff85e h1:s2RNOM/IGdY0Y6qfTeUKhDawdHDpK9RGBdx80qN4Ttw=
github.com/orcaman/writerseeker v0.0.0-20200621085525-1d3f536ff85e/go.mod h1:nBdnFKj15wFbf94Rwfq4m30eAcyY9V/IyKAGQFtqkW0=
github.com/pion/randutil v0.1.0 h1:CFG1UdESneORglEsnimhUjf33Rwjubwj6xfiOXBa3mA=
github.com/pion/randutil v0.1.0/go.mod h1:XcJrSMMbbMRhASFVOlj/5hQial/Y8oH/HVo7TBZq+j8=
github.com/pion/rtcp v1.2.9 h1:1ujStwg++IOLIEoOiIQ2s+qBuJ1VN81KW+9pMPsif+U=
github.com/pion/rtcp v1.2.9/go.mod h1:qVPhiCzAm4D/rxb6XzKeyZiQK69yJpbUDJSF7TgrqNo=
github.com/pion/rtp v1.7.9 h1:17W5Mt2IM3MVfOh7yRfzXbbKXYzBZxV8eG4KKAy+0bg=
github.com/pion/rtp v1.7.9/go.mod h1:bDb5n+BFZxXx0Ea7E5qe+klMuqiBrP+w8XSjiWtCUko=
github.com/pion/rtp v1.7.13 h1:qcHwlmtiI50t1XivvoawdCGTP4Uiypzfrsap+bijcoA=
github.com/pion/rtp v1.7.13/go.mod h1:bDb5n+BFZxXx0Ea7E5qe+klMuqiBrP+w8XSjiWtCUko=
github.com/pion/sdp/v3 v3.0.5 h1:ouvI7IgGl+V4CrqskVtr3AaTrPvPisEOxwgpdktctkU=
github.com/pion/sdp/v3 v3.0.5/go.mod h1:iiFWFpQO8Fy3S5ldclBkpXqmWy02ns78NOKoLLL0YQw=
github.com/pkg/profile v1.4.0/go.mod h1:NWz/XGvpEW1FyYQ7fCx4dqYBLlfTcE+A9FLAkNKqjFE=
@ -100,6 +113,7 @@ github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/ @@ -100,6 +113,7 @@ github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1 h1:5TQK59W5E3v0r2duFAb7P95B6hEeOyEnHRa8MjYSMTY=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/sunfish-shogi/bufseekio v0.0.0-20210207115823-a4185644b365/go.mod h1:dEzdXgvImkQ3WLI+0KQpmEx8T/C/ma9KeS3AfmU899I=
github.com/ugorji/go v1.1.7 h1:/68gy2h+1mWMrwZFeD1kQialdSzAb432dtpeJ42ovdo=
github.com/ugorji/go v1.1.7/go.mod h1:kZn38zHttfInRq0xu/PH0az30d+z6vm202qpg1oXVMw=
github.com/ugorji/go/codec v1.1.7 h1:2SvQaVZ1ouYrrKKwoSk2pzd4A9evlKJb9oTL+OaLUSs=
@ -119,14 +133,15 @@ golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLL @@ -119,14 +133,15 @@ golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLL
golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.0.0-20210428140749-89ef3d95e781/go.mod h1:OJAsFXCWl8Ukc7SiCT/9KSuxbyM7479/AVlXFRxuMCk=
golang.org/x/net v0.0.0-20210610132358-84b48f89b13b h1:k+E048sYJHyVnsr1GDrRZWQ32D2C7lWs9JRc0bel53A=
golang.org/x/net v0.0.0-20210610132358-84b48f89b13b/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20220526153639-5463443f8c37 h1:lUkvobShwKsOesNfWWlCS5q7fnbG1MEliIzwu886fn8=
golang.org/x/net v0.0.0-20220526153639-5463443f8c37/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190726091711-fc99dfbffb4e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@ -137,15 +152,19 @@ golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7w @@ -137,15 +152,19 @@ golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7w
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210112080510-489259a85091/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da h1:b3NXsE2LusjYGGjL5bxEVZZORm/YEFFrWFjR8eFrw/c=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a h1:dGzPydgVsqGcTRVwiLJ1jVbufYwmzD3LfVPLKsKg+0k=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6 h1:aRYxNxv6iGQlyVaZmk6ZgYEDa+Jg18DxebPSrd6bg1M=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7 h1:olpwvP2KacW1ZWvsR7uQhoyTYvKAupfQrRGBFM352Gk=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
@ -165,9 +184,12 @@ google.golang.org/protobuf v1.26.0 h1:bxAC2xTBsZGibn2RTntX0oH50xLsqy1OxA9tTL3p/l @@ -165,9 +184,12 @@ google.golang.org/protobuf v1.26.0 h1:bxAC2xTBsZGibn2RTntX0oH50xLsqy1OxA9tTL3p/l
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
gopkg.in/alecthomas/kingpin.v2 v2.2.6 h1:jMFz6MfLP0/4fUyZle81rXUoxOBFi19VUFKVDOQfozc=
gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=
gopkg.in/src-d/go-billy.v4 v4.3.2 h1:0SQA1pRztfTFx2miS8sA97XvooFeNOmvUenF4o0EcVg=
gopkg.in/src-d/go-billy.v4 v4.3.2/go.mod h1:nDjArDMp+XMs1aFAESLRjfGSgfvoYN0hDfzEk0GjC98=
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ=
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=

35
internal/conf/conf.go

@ -207,10 +207,15 @@ type Conf struct { @@ -207,10 +207,15 @@ type Conf struct {
HLSDisable bool `json:"hlsDisable"`
HLSAddress string `json:"hlsAddress"`
HLSAlwaysRemux bool `json:"hlsAlwaysRemux"`
HLSVariant HLSVariant `json:"hlsVariant"`
HLSSegmentCount int `json:"hlsSegmentCount"`
HLSSegmentDuration StringDuration `json:"hlsSegmentDuration"`
HLSPartDuration StringDuration `json:"hlsPartDuration"`
HLSSegmentMaxSize StringSize `json:"hlsSegmentMaxSize"`
HLSAllowOrigin string `json:"hlsAllowOrigin"`
HLSEncryption bool `json:"hlsEncryption"`
HLSServerKey string `json:"hlsServerKey"`
HLSServerCert string `json:"hlsServerCert"`
// paths
Paths map[string]*PathConf `json:"paths"`
@ -350,13 +355,17 @@ func (conf *Conf) CheckAndFillMissing() error { @@ -350,13 +355,17 @@ func (conf *Conf) CheckAndFillMissing() error {
}
if conf.HLSSegmentCount == 0 {
conf.HLSSegmentCount = 3
conf.HLSSegmentCount = 7
}
if conf.HLSSegmentDuration == 0 {
conf.HLSSegmentDuration = 1 * StringDuration(time.Second)
}
if conf.HLSPartDuration == 0 {
conf.HLSPartDuration = 200 * StringDuration(time.Millisecond)
}
if conf.HLSSegmentMaxSize == 0 {
conf.HLSSegmentMaxSize = 50 * 1024 * 1024
}
@ -365,6 +374,30 @@ func (conf *Conf) CheckAndFillMissing() error { @@ -365,6 +374,30 @@ func (conf *Conf) CheckAndFillMissing() error {
conf.HLSAllowOrigin = "*"
}
if conf.HLSServerKey == "" {
conf.HLSServerKey = "server.key"
}
if conf.HLSServerCert == "" {
conf.HLSServerCert = "server.crt"
}
switch conf.HLSVariant {
case HLSVariantLowLatency:
if conf.HLSSegmentCount < 7 {
return fmt.Errorf("Low-Latency HLS requires at least 7 segments")
}
if !conf.HLSEncryption {
return fmt.Errorf("Low-Latency HLS requires encryption")
}
default:
if conf.HLSSegmentCount < 3 {
return fmt.Errorf("The minimum number of HLS segments is 3")
}
}
// do not add automatically "all", since user may want to
// initialize all paths through API or hot reloading.
if conf.Paths == nil {

64
internal/conf/hlsvariant.go

@ -0,0 +1,64 @@ @@ -0,0 +1,64 @@
package conf
import (
"encoding/json"
"fmt"
"github.com/aler9/rtsp-simple-server/internal/hls"
)
// HLSVariant is the hlsVariant parameter.
type HLSVariant hls.MuxerVariant
// supported HLS variants.
const (
HLSVariantMPEGTS HLSVariant = HLSVariant(hls.MuxerVariantMPEGTS)
HLSVariantFMP4 HLSVariant = HLSVariant(hls.MuxerVariantFMP4)
HLSVariantLowLatency HLSVariant = HLSVariant(hls.MuxerVariantLowLatency)
)
// MarshalJSON marshals a HLSVariant into JSON.
func (d HLSVariant) MarshalJSON() ([]byte, error) {
var out string
switch d {
case HLSVariantMPEGTS:
out = "mpegts"
case HLSVariantFMP4:
out = "fmp4"
default:
out = "lowLatency"
}
return json.Marshal(out)
}
// UnmarshalJSON unmarshals a HLSVariant from JSON.
func (d *HLSVariant) UnmarshalJSON(b []byte) error {
var in string
if err := json.Unmarshal(b, &in); err != nil {
return err
}
switch in {
case "mpegts":
*d = HLSVariantMPEGTS
case "fmp4":
*d = HLSVariantFMP4
case "lowLatency":
*d = HLSVariantLowLatency
default:
return fmt.Errorf("invalid hlsVariant value: '%s'", in)
}
return nil
}
func (d *HLSVariant) unmarshalEnv(s string) error {
return d.UnmarshalJSON([]byte(`"` + s + `"`))
}

5
internal/core/api.go

@ -83,10 +83,15 @@ func loadConfData(ctx *gin.Context) (interface{}, error) { @@ -83,10 +83,15 @@ func loadConfData(ctx *gin.Context) (interface{}, error) {
HLSDisable *bool `json:"hlsDisable"`
HLSAddress *string `json:"hlsAddress"`
HLSAlwaysRemux *bool `json:"hlsAlwaysRemux"`
HLSVariant *conf.HLSVariant `json:"hlsVariant"`
HLSSegmentCount *int `json:"hlsSegmentCount"`
HLSSegmentDuration *conf.StringDuration `json:"hlsSegmentDuration"`
HLSPartDuration *conf.StringDuration `json:"hlsPartDuration"`
HLSSegmentMaxSize *conf.StringSize `json:"hlsSegmentMaxSize"`
HLSAllowOrigin *string `json:"hlsAllowOrigin"`
HLSEncryption *bool `json:"hlsEncryption"`
HLSServerKey *string `json:"hlsServerKey"`
HLSServerCert *string `json:"hlsServerCert"`
}
err := json.NewDecoder(ctx.Request.Body).Decode(&in)
if err != nil {

10
internal/core/core.go

@ -333,10 +333,15 @@ func (p *Core) createResources(initial bool) error { @@ -333,10 +333,15 @@ func (p *Core) createResources(initial bool) error {
p.conf.HLSAddress,
p.conf.ExternalAuthenticationURL,
p.conf.HLSAlwaysRemux,
p.conf.HLSVariant,
p.conf.HLSSegmentCount,
p.conf.HLSSegmentDuration,
p.conf.HLSPartDuration,
p.conf.HLSSegmentMaxSize,
p.conf.HLSAllowOrigin,
p.conf.HLSEncryption,
p.conf.HLSServerKey,
p.conf.HLSServerCert,
p.conf.ReadBufferCount,
p.pathManager,
p.metrics,
@ -476,10 +481,15 @@ func (p *Core) closeResources(newConf *conf.Conf, calledByAPI bool) { @@ -476,10 +481,15 @@ func (p *Core) closeResources(newConf *conf.Conf, calledByAPI bool) {
newConf.HLSAddress != p.conf.HLSAddress ||
newConf.ExternalAuthenticationURL != p.conf.ExternalAuthenticationURL ||
newConf.HLSAlwaysRemux != p.conf.HLSAlwaysRemux ||
newConf.HLSVariant != p.conf.HLSVariant ||
newConf.HLSSegmentCount != p.conf.HLSSegmentCount ||
newConf.HLSSegmentDuration != p.conf.HLSSegmentDuration ||
newConf.HLSPartDuration != p.conf.HLSPartDuration ||
newConf.HLSSegmentMaxSize != p.conf.HLSSegmentMaxSize ||
newConf.HLSAllowOrigin != p.conf.HLSAllowOrigin ||
newConf.HLSEncryption != p.conf.HLSEncryption ||
newConf.HLSServerKey != p.conf.HLSServerKey ||
newConf.HLSServerCert != p.conf.HLSServerCert ||
newConf.ReadBufferCount != p.conf.ReadBufferCount ||
closePathManager ||
closeMetrics {

121
internal/core/hls_muxer.go

@ -5,10 +5,8 @@ import ( @@ -5,10 +5,8 @@ import (
"context"
"errors"
"fmt"
"io"
"net"
"net/http"
"strings"
"sync"
"sync/atomic"
"time"
@ -16,6 +14,7 @@ import ( @@ -16,6 +14,7 @@ import (
"github.com/aler9/gortsplib"
"github.com/aler9/gortsplib/pkg/ringbuffer"
"github.com/aler9/gortsplib/pkg/rtpaac"
"github.com/gin-gonic/gin"
"github.com/aler9/rtsp-simple-server/internal/conf"
"github.com/aler9/rtsp-simple-server/internal/hls"
@ -46,7 +45,7 @@ html, body { @@ -46,7 +45,7 @@ html, body {
</head>
<body>
<video id="video" muted controls autoplay></video>
<video id="video" muted controls autoplay playsinline></video>
<script src="https://cdn.jsdelivr.net/npm/hls.js@1.1.5"></script>
@ -60,9 +59,6 @@ const create = () => { @@ -60,9 +59,6 @@ const create = () => {
// but doesn't support fMP4s.
if (Hls.isSupported()) {
const hls = new Hls({
progressive: true,
liveSyncDurationCount: 3,
liveMaxLatencyDurationCount: 4,
});
hls.on(Hls.Events.ERROR, (evt, data) => {
@ -97,17 +93,11 @@ window.addEventListener('DOMContentLoaded', create); @@ -97,17 +93,11 @@ window.addEventListener('DOMContentLoaded', create);
</html>
`
type hlsMuxerResponse struct {
status int
header map[string]string
body io.Reader
}
type hlsMuxerRequest struct {
dir string
file string
req *http.Request
res chan hlsMuxerResponse
ctx *gin.Context
res chan func() *hls.MuxerFileResponse
}
type hlsMuxerPathManager interface {
@ -123,8 +113,10 @@ type hlsMuxer struct { @@ -123,8 +113,10 @@ type hlsMuxer struct {
name string
externalAuthenticationURL string
hlsAlwaysRemux bool
hlsVariant conf.HLSVariant
hlsSegmentCount int
hlsSegmentDuration conf.StringDuration
hlsPartDuration conf.StringDuration
hlsSegmentMaxSize conf.StringSize
readBufferCount int
wg *sync.WaitGroup
@ -148,10 +140,13 @@ type hlsMuxer struct { @@ -148,10 +140,13 @@ type hlsMuxer struct {
func newHLSMuxer(
parentCtx context.Context,
name string,
remoteAddr string,
externalAuthenticationURL string,
hlsAlwaysRemux bool,
hlsVariant conf.HLSVariant,
hlsSegmentCount int,
hlsSegmentDuration conf.StringDuration,
hlsPartDuration conf.StringDuration,
hlsSegmentMaxSize conf.StringSize,
readBufferCount int,
wg *sync.WaitGroup,
@ -165,8 +160,10 @@ func newHLSMuxer( @@ -165,8 +160,10 @@ func newHLSMuxer(
name: name,
externalAuthenticationURL: externalAuthenticationURL,
hlsAlwaysRemux: hlsAlwaysRemux,
hlsVariant: hlsVariant,
hlsSegmentCount: hlsSegmentCount,
hlsSegmentDuration: hlsSegmentDuration,
hlsPartDuration: hlsPartDuration,
hlsSegmentMaxSize: hlsSegmentMaxSize,
readBufferCount: readBufferCount,
wg: wg,
@ -183,7 +180,12 @@ func newHLSMuxer( @@ -183,7 +180,12 @@ func newHLSMuxer(
hlsServerAPIMuxersList: make(chan hlsServerAPIMuxersListSubReq),
}
m.log(logger.Info, "created")
m.log(logger.Info, "created %s", func() string {
if remoteAddr == "" {
return "automatically"
}
return "(requested by " + remoteAddr + ")"
}())
m.wg.Add(1)
go m.run()
@ -254,7 +256,9 @@ func (m *hlsMuxer) run() { @@ -254,7 +256,9 @@ func (m *hlsMuxer) run() {
m.ctxCancel()
for _, req := range m.requests {
req.res <- hlsMuxerResponse{status: http.StatusNotFound}
req.res <- func() *hls.MuxerFileResponse {
return &hls.MuxerFileResponse{Status: http.StatusNotFound}
}
}
m.parent.onMuxerClose(m)
@ -317,14 +321,16 @@ func (m *hlsMuxer) runInner(innerCtx context.Context, innerReady chan struct{}) @@ -317,14 +321,16 @@ func (m *hlsMuxer) runInner(innerCtx context.Context, innerReady chan struct{})
var err error
m.muxer, err = hls.NewMuxer(
hls.MuxerVariant(m.hlsVariant),
m.hlsSegmentCount,
time.Duration(m.hlsSegmentDuration),
time.Duration(m.hlsPartDuration),
uint64(m.hlsSegmentMaxSize),
videoTrack,
audioTrack,
)
if err != nil {
return err
return fmt.Errorf("muxer error: %v", err)
}
defer m.muxer.Close()
@ -351,9 +357,6 @@ func (m *hlsMuxer) runInner(innerCtx context.Context, innerReady chan struct{}) @@ -351,9 +357,6 @@ func (m *hlsMuxer) runInner(innerCtx context.Context, innerReady chan struct{})
continue
}
// video is decoded in another routine,
// while audio is decoded in this routine:
// we have to sync their PTS.
if videoInitialPTS == nil {
v := data.h264PTS
videoInitialPTS = &v
@ -362,8 +365,7 @@ func (m *hlsMuxer) runInner(innerCtx context.Context, innerReady chan struct{}) @@ -362,8 +365,7 @@ func (m *hlsMuxer) runInner(innerCtx context.Context, innerReady chan struct{})
err = m.muxer.WriteH264(pts, data.h264NALUs)
if err != nil {
m.log(logger.Warn, "unable to write segment: %v", err)
continue
return fmt.Errorf("muxer error: %v", err)
}
} else if audioTrack != nil && data.trackID == audioTrackID {
aus, pts, err := aacDecoder.Decode(data.rtp)
@ -376,8 +378,7 @@ func (m *hlsMuxer) runInner(innerCtx context.Context, innerReady chan struct{}) @@ -376,8 +378,7 @@ func (m *hlsMuxer) runInner(innerCtx context.Context, innerReady chan struct{})
err = m.muxer.WriteAAC(pts, aus)
if err != nil {
m.log(logger.Warn, "unable to write segment: %v", err)
continue
return fmt.Errorf("muxer error: %v", err)
}
}
}
@ -408,70 +409,48 @@ func (m *hlsMuxer) runInner(innerCtx context.Context, innerReady chan struct{}) @@ -408,70 +409,48 @@ func (m *hlsMuxer) runInner(innerCtx context.Context, innerReady chan struct{})
}
}
func (m *hlsMuxer) handleRequest(req hlsMuxerRequest) hlsMuxerResponse {
func (m *hlsMuxer) handleRequest(req hlsMuxerRequest) func() *hls.MuxerFileResponse {
atomic.StoreInt64(m.lastRequestTime, time.Now().Unix())
err := m.authenticate(req.req)
err := m.authenticate(req.ctx.Request)
if err != nil {
if terr, ok := err.(pathErrAuthCritical); ok {
m.log(logger.Info, "authentication error: %s", terr.message)
return hlsMuxerResponse{
status: http.StatusUnauthorized,
return func() *hls.MuxerFileResponse {
return &hls.MuxerFileResponse{
Status: http.StatusUnauthorized,
}
}
}
return hlsMuxerResponse{
status: http.StatusUnauthorized,
header: map[string]string{
return func() *hls.MuxerFileResponse {
return &hls.MuxerFileResponse{
Status: http.StatusUnauthorized,
Header: map[string]string{
"WWW-Authenticate": `Basic realm="rtsp-simple-server"`,
},
}
}
switch {
case req.file == "index.m3u8":
return hlsMuxerResponse{
status: http.StatusOK,
header: map[string]string{
"Content-Type": `application/x-mpegURL`,
},
body: m.muxer.PrimaryPlaylist(),
}
case req.file == "stream.m3u8":
return hlsMuxerResponse{
status: http.StatusOK,
header: map[string]string{
"Content-Type": `application/x-mpegURL`,
if req.file == "" {
return func() *hls.MuxerFileResponse {
return &hls.MuxerFileResponse{
Status: http.StatusOK,
Header: map[string]string{
"Content-Type": `text/html`,
},
body: m.muxer.StreamPlaylist(),
Body: bytes.NewReader([]byte(index)),
}
case strings.HasSuffix(req.file, ".ts"):
r := m.muxer.Segment(req.file)
if r == nil {
return hlsMuxerResponse{status: http.StatusNotFound}
}
return hlsMuxerResponse{
status: http.StatusOK,
header: map[string]string{
"Content-Type": `video/MP2T`,
},
body: r,
}
case req.file == "":
return hlsMuxerResponse{
status: http.StatusOK,
header: map[string]string{
"Content-Type": `text/html`,
},
body: bytes.NewReader([]byte(index)),
}
default:
return hlsMuxerResponse{status: http.StatusNotFound}
return func() *hls.MuxerFileResponse {
return m.muxer.File(
req.file,
req.ctx.Query("_HLS_msn"),
req.ctx.Query("_HLS_part"),
req.ctx.Query("_HLS_skip"))
}
}
@ -533,7 +512,9 @@ func (m *hlsMuxer) onRequest(req hlsMuxerRequest) { @@ -533,7 +512,9 @@ func (m *hlsMuxer) onRequest(req hlsMuxerRequest) {
select {
case m.request <- req:
case <-m.ctx.Done():
req.res <- hlsMuxerResponse{status: http.StatusNotFound}
req.res <- func() *hls.MuxerFileResponse {
return &hls.MuxerFileResponse{Status: http.StatusInternalServerError}
}
}
}

76
internal/core/hls_server.go

@ -2,8 +2,10 @@ package core @@ -2,8 +2,10 @@ package core
import (
"context"
"crypto/tls"
"fmt"
"io"
"log"
"net"
"net/http"
"net/http/httputil"
@ -14,9 +16,16 @@ import ( @@ -14,9 +16,16 @@ import (
"github.com/gin-gonic/gin"
"github.com/aler9/rtsp-simple-server/internal/conf"
"github.com/aler9/rtsp-simple-server/internal/hls"
"github.com/aler9/rtsp-simple-server/internal/logger"
)
type nilWriter struct{}
func (nilWriter) Write(p []byte) (int, error) {
return len(p), nil
}
type hlsServerAPIMuxersListItem struct {
LastRequest string `json:"lastRequest"`
}
@ -47,8 +56,10 @@ type hlsServerParent interface { @@ -47,8 +56,10 @@ type hlsServerParent interface {
type hlsServer struct {
externalAuthenticationURL string
hlsAlwaysRemux bool
hlsVariant conf.HLSVariant
hlsSegmentCount int
hlsSegmentDuration conf.StringDuration
hlsPartDuration conf.StringDuration
hlsSegmentMaxSize conf.StringSize
hlsAllowOrigin string
readBufferCount int
@ -60,6 +71,7 @@ type hlsServer struct { @@ -60,6 +71,7 @@ type hlsServer struct {
ctxCancel func()
wg sync.WaitGroup
ln net.Listener
tlsConfig *tls.Config
muxers map[string]*hlsMuxer
// in
@ -74,10 +86,15 @@ func newHLSServer( @@ -74,10 +86,15 @@ func newHLSServer(
address string,
externalAuthenticationURL string,
hlsAlwaysRemux bool,
hlsVariant conf.HLSVariant,
hlsSegmentCount int,
hlsSegmentDuration conf.StringDuration,
hlsPartDuration conf.StringDuration,
hlsSegmentMaxSize conf.StringSize,
hlsAllowOrigin string,
hlsEncryption bool,
hlsServerKey string,
hlsServerCert string,
readBufferCount int,
pathManager *pathManager,
metrics *metrics,
@ -88,13 +105,28 @@ func newHLSServer( @@ -88,13 +105,28 @@ func newHLSServer(
return nil, err
}
var tlsConfig *tls.Config
if hlsEncryption {
crt, err := tls.LoadX509KeyPair(hlsServerCert, hlsServerKey)
if err != nil {
ln.Close()
return nil, err
}
tlsConfig = &tls.Config{
Certificates: []tls.Certificate{crt},
}
}
ctx, ctxCancel := context.WithCancel(parentCtx)
s := &hlsServer{
externalAuthenticationURL: externalAuthenticationURL,
hlsAlwaysRemux: hlsAlwaysRemux,
hlsVariant: hlsVariant,
hlsSegmentCount: hlsSegmentCount,
hlsSegmentDuration: hlsSegmentDuration,
hlsPartDuration: hlsPartDuration,
hlsSegmentMaxSize: hlsSegmentMaxSize,
hlsAllowOrigin: hlsAllowOrigin,
readBufferCount: readBufferCount,
@ -104,6 +136,7 @@ func newHLSServer( @@ -104,6 +136,7 @@ func newHLSServer(
ctx: ctx,
ctxCancel: ctxCancel,
ln: ln,
tlsConfig: tlsConfig,
muxers: make(map[string]*hlsMuxer),
pathSourceReady: make(chan *path),
request: make(chan hlsMuxerRequest),
@ -142,19 +175,28 @@ func (s *hlsServer) run() { @@ -142,19 +175,28 @@ func (s *hlsServer) run() {
router := gin.New()
router.NoRoute(s.onRequest)
hs := &http.Server{Handler: router}
hs := &http.Server{
Handler: router,
TLSConfig: s.tlsConfig,
ErrorLog: log.New(&nilWriter{}, "", 0),
}
if s.tlsConfig != nil {
go hs.ServeTLS(s.ln, "", "")
} else {
go hs.Serve(s.ln)
}
outer:
for {
select {
case pa := <-s.pathSourceReady:
if s.hlsAlwaysRemux {
s.findOrCreateMuxer(pa.Name())
s.findOrCreateMuxer(pa.Name(), "")
}
case req := <-s.request:
r := s.findOrCreateMuxer(req.dir)
r := s.findOrCreateMuxer(req.dir, req.ctx.Request.RemoteAddr)
r.onRequest(req)
case c := <-s.muxerClose:
@ -191,7 +233,7 @@ outer: @@ -191,7 +233,7 @@ outer:
}
func (s *hlsServer) onRequest(ctx *gin.Context) {
s.log(logger.Info, "[conn %v] %s %s", ctx.Request.RemoteAddr, ctx.Request.Method, ctx.Request.URL.Path)
s.log(logger.Debug, "[conn %v] %s %s", ctx.Request.RemoteAddr, ctx.Request.Method, ctx.Request.URL.Path)
byts, _ := httputil.DumpRequest(ctx.Request, true)
s.log(logger.Debug, "[conn %v] [c->s] %s", ctx.Request.RemoteAddr, string(byts))
@ -227,7 +269,9 @@ func (s *hlsServer) onRequest(ctx *gin.Context) { @@ -227,7 +269,9 @@ func (s *hlsServer) onRequest(ctx *gin.Context) {
}
dir, fname := func() (string, string) {
if strings.HasSuffix(pa, ".ts") || strings.HasSuffix(pa, ".m3u8") {
if strings.HasSuffix(pa, ".m3u8") ||
strings.HasSuffix(pa, ".ts") ||
strings.HasSuffix(pa, ".mp4") {
return gopath.Dir(pa), gopath.Base(pa)
}
return pa, ""
@ -241,25 +285,28 @@ func (s *hlsServer) onRequest(ctx *gin.Context) { @@ -241,25 +285,28 @@ func (s *hlsServer) onRequest(ctx *gin.Context) {
dir = strings.TrimSuffix(dir, "/")
cres := make(chan hlsMuxerResponse)
cres := make(chan func() *hls.MuxerFileResponse)
hreq := hlsMuxerRequest{
dir: dir,
file: fname,
req: ctx.Request,
ctx: ctx,
res: cres,
}
select {
case s.request <- hreq:
res := <-cres
cb := <-cres
for k, v := range res.header {
res := cb()
for k, v := range res.Header {
ctx.Writer.Header().Set(k, v)
}
ctx.Writer.WriteHeader(res.status)
if res.body != nil {
io.Copy(ctx.Writer, res.body)
ctx.Writer.WriteHeader(res.Status)
if res.Body != nil {
io.Copy(ctx.Writer, res.Body)
}
case <-s.ctx.Done():
@ -268,16 +315,19 @@ func (s *hlsServer) onRequest(ctx *gin.Context) { @@ -268,16 +315,19 @@ func (s *hlsServer) onRequest(ctx *gin.Context) {
s.log(logger.Debug, "[conn %v] [s->c] %s", ctx.Request.RemoteAddr, logw.dump())
}
func (s *hlsServer) findOrCreateMuxer(pathName string) *hlsMuxer {
func (s *hlsServer) findOrCreateMuxer(pathName string, remoteAddr string) *hlsMuxer {
r, ok := s.muxers[pathName]
if !ok {
r = newHLSMuxer(
s.ctx,
pathName,
remoteAddr,
s.externalAuthenticationURL,
s.hlsAlwaysRemux,
s.hlsVariant,
s.hlsSegmentCount,
s.hlsSegmentDuration,
s.hlsPartDuration,
s.hlsSegmentMaxSize,
s.readBufferCount,
&s.wg,

2
internal/core/hls_source_test.go

@ -53,7 +53,7 @@ func (ts *testHLSServer) onPlaylist(ctx *gin.Context) { @@ -53,7 +53,7 @@ func (ts *testHLSServer) onPlaylist(ctx *gin.Context) {
segment.ts
`
ctx.Writer.Header().Set("Content-Type", `application/x-mpegURL`)
ctx.Writer.Header().Set("Content-Type", `audio/mpegURL`)
io.Copy(ctx.Writer, bytes.NewReader([]byte(cnt)))
}

2
internal/hls/client_test.go

@ -123,7 +123,7 @@ func newTestHLSServer(ca string) (*testHLSServer, error) { @@ -123,7 +123,7 @@ func newTestHLSServer(ca string) (*testHLSServer, error) {
#EXTINF:2,
` + segment + "\n"
ctx.Writer.Header().Set("Content-Type", `application/x-mpegURL`)
ctx.Writer.Header().Set("Content-Type", `audio/mpegURL`)
io.Copy(ctx.Writer, bytes.NewReader([]byte(cnt)))
})

94
internal/hls/mp4writer.go

@ -0,0 +1,94 @@ @@ -0,0 +1,94 @@
package hls
import (
"io"
"github.com/abema/go-mp4"
"github.com/orcaman/writerseeker"
)
type mp4Writer struct {
buf *writerseeker.WriterSeeker
w *mp4.Writer
}
func newMP4Writer() *mp4Writer {
w := &mp4Writer{
buf: &writerseeker.WriterSeeker{},
}
w.w = mp4.NewWriter(w.buf)
return w
}
func (w *mp4Writer) writeBoxStart(box mp4.IImmutableBox) (int, error) {
bi := &mp4.BoxInfo{
Type: box.GetType(),
}
var err error
bi, err = w.w.StartBox(bi)
if err != nil {
return 0, err
}
_, err = mp4.Marshal(w.w, box, mp4.Context{})
if err != nil {
return 0, err
}
return int(bi.Offset), nil
}
func (w *mp4Writer) writeBoxEnd() error {
_, err := w.w.EndBox()
return err
}
func (w *mp4Writer) writeBox(box mp4.IImmutableBox) (int, error) {
off, err := w.writeBoxStart(box)
if err != nil {
return 0, err
}
err = w.writeBoxEnd()
if err != nil {
return 0, err
}
return off, nil
}
func (w *mp4Writer) rewriteBox(off int, box mp4.IImmutableBox) error {
prevOff, err := w.w.Seek(0, io.SeekCurrent)
if err != nil {
return err
}
_, err = w.w.Seek(int64(off), io.SeekStart)
if err != nil {
return err
}
_, err = w.writeBoxStart(box)
if err != nil {
return err
}
err = w.writeBoxEnd()
if err != nil {
return err
}
_, err = w.w.Seek(prevOff, io.SeekStart)
if err != nil {
return err
}
return nil
}
func (w *mp4Writer) bytes() []byte {
byts, _ := io.ReadAll(w.buf.Reader())
return byts
}

85
internal/hls/muxer.go

@ -7,68 +7,89 @@ import ( @@ -7,68 +7,89 @@ import (
"github.com/aler9/gortsplib"
)
// MuxerFileResponse is a response of the Muxer's File() func.
type MuxerFileResponse struct {
Status int
Header map[string]string
Body io.Reader
}
// Muxer is a HLS muxer.
type Muxer struct {
primaryPlaylist *muxerPrimaryPlaylist
streamPlaylist *muxerStreamPlaylist
tsGenerator *muxerTSGenerator
variant muxerVariant
}
// NewMuxer allocates a Muxer.
func NewMuxer(
hlsSegmentCount int,
hlsSegmentDuration time.Duration,
hlsSegmentMaxSize uint64,
variant MuxerVariant,
segmentCount int,
segmentDuration time.Duration,
partDuration time.Duration,
segmentMaxSize uint64,
videoTrack *gortsplib.TrackH264,
audioTrack *gortsplib.TrackAAC,
) (*Muxer, error) {
primaryPlaylist := newMuxerPrimaryPlaylist(videoTrack, audioTrack)
m := &Muxer{}
streamPlaylist := newMuxerStreamPlaylist(hlsSegmentCount)
switch variant {
case MuxerVariantMPEGTS:
m.variant = newMuxerVariantMPEGTS(
segmentCount,
segmentDuration,
segmentMaxSize,
videoTrack,
audioTrack,
)
tsGenerator := newMuxerTSGenerator(
hlsSegmentCount,
hlsSegmentDuration,
hlsSegmentMaxSize,
case MuxerVariantFMP4:
m.variant = newMuxerVariantFMP4(
false,
segmentCount,
segmentDuration,
partDuration,
segmentMaxSize,
videoTrack,
audioTrack,
streamPlaylist)
)
m := &Muxer{
primaryPlaylist: primaryPlaylist,
streamPlaylist: streamPlaylist,
tsGenerator: tsGenerator,
default: // MuxerVariantLowLatency
m.variant = newMuxerVariantFMP4(
true,
segmentCount,
segmentDuration,
partDuration,
segmentMaxSize,
videoTrack,
audioTrack,
)
}
m.primaryPlaylist = newMuxerPrimaryPlaylist(variant != MuxerVariantMPEGTS, videoTrack, audioTrack)
return m, nil
}
// Close closes a Muxer.
func (m *Muxer) Close() {
m.streamPlaylist.close()
m.variant.close()
}
// WriteH264 writes H264 NALUs, grouped by timestamp, into the muxer.
// WriteH264 writes H264 NALUs, grouped by timestamp.
func (m *Muxer) WriteH264(pts time.Duration, nalus [][]byte) error {
return m.tsGenerator.writeH264(pts, nalus)
return m.variant.writeH264(pts, nalus)
}
// WriteAAC writes AAC AUs, grouped by timestamp, into the muxer.
// WriteAAC writes AAC AUs, grouped by timestamp.
func (m *Muxer) WriteAAC(pts time.Duration, aus [][]byte) error {
return m.tsGenerator.writeAAC(pts, aus)
}
// PrimaryPlaylist returns a reader to read the primary playlist.
func (m *Muxer) PrimaryPlaylist() io.Reader {
return m.primaryPlaylist.reader()
return m.variant.writeAAC(pts, aus)
}
// StreamPlaylist returns a reader to read the stream playlist.
func (m *Muxer) StreamPlaylist() io.Reader {
return m.streamPlaylist.reader()
// File returns a file reader.
func (m *Muxer) File(name string, msn string, part string, skip string) *MuxerFileResponse {
if name == "index.m3u8" {
return m.primaryPlaylist.file()
}
// Segment returns a reader to read a segment listed in the stream playlist.
func (m *Muxer) Segment(fname string) io.Reader {
return m.streamPlaylist.segment(fname)
return m.variant.file(name, msn, part, skip)
}

34
internal/hls/muxer_primary_playlist.go

@ -1,8 +1,10 @@ @@ -1,8 +1,10 @@
package hls
import (
"bytes"
"encoding/hex"
"io"
"net/http"
"strconv"
"strings"
@ -10,22 +12,30 @@ import ( @@ -10,22 +12,30 @@ import (
)
type muxerPrimaryPlaylist struct {
fmp4 bool
videoTrack *gortsplib.TrackH264
audioTrack *gortsplib.TrackAAC
}
func newMuxerPrimaryPlaylist(
fmp4 bool,
videoTrack *gortsplib.TrackH264,
audioTrack *gortsplib.TrackAAC,
) *muxerPrimaryPlaylist {
return &muxerPrimaryPlaylist{
fmp4: fmp4,
videoTrack: videoTrack,
audioTrack: audioTrack,
}
}
func (p *muxerPrimaryPlaylist) reader() io.Reader {
return &asyncReader{generator: func() []byte {
func (p *muxerPrimaryPlaylist) file() *MuxerFileResponse {
return &MuxerFileResponse{
Status: http.StatusOK,
Header: map[string]string{
"Content-Type": `audio/mpegURL`,
},
Body: func() io.Reader {
var codecs []string
if p.videoTrack != nil {
@ -40,10 +50,24 @@ func (p *muxerPrimaryPlaylist) reader() io.Reader { @@ -40,10 +50,24 @@ func (p *muxerPrimaryPlaylist) reader() io.Reader {
codecs = append(codecs, "mp4a.40."+strconv.FormatInt(int64(p.audioTrack.Type()), 10))
}
return []byte("#EXTM3U\n" +
switch {
case !p.fmp4:
return bytes.NewReader([]byte("#EXTM3U\n" +
"#EXT-X-VERSION:3\n" +
"#EXT-X-INDEPENDENT-SEGMENTS\n" +
"\n" +
"#EXT-X-STREAM-INF:BANDWIDTH=200000,CODECS=\"" + strings.Join(codecs, ",") + "\"\n" +
"stream.m3u8\n")
}}
"stream.m3u8\n"))
default:
return bytes.NewReader([]byte("#EXTM3U\n" +
"#EXT-X-VERSION:9\n" +
"#EXT-X-INDEPENDENT-SEGMENTS\n" +
"\n" +
"#EXT-X-STREAM-INF:BANDWIDTH=200000,CODECS=\"" + strings.Join(codecs, ",") + "\"\n" +
"stream.m3u8\n" +
"\n"))
}
}(),
}
}

130
internal/hls/muxer_stream_playlist.go

@ -1,130 +0,0 @@ @@ -1,130 +0,0 @@
package hls
import (
"bytes"
"io"
"math"
"strconv"
"strings"
"sync"
)
type asyncReader struct {
generator func() []byte
reader *bytes.Reader
}
func (r *asyncReader) Read(buf []byte) (int, error) {
if r.reader == nil {
r.reader = bytes.NewReader(r.generator())
}
return r.reader.Read(buf)
}
type muxerStreamPlaylist struct {
hlsSegmentCount int
mutex sync.Mutex
cond *sync.Cond
closed bool
segments []*muxerTSSegment
segmentByName map[string]*muxerTSSegment
segmentDeleteCount int
}
func newMuxerStreamPlaylist(hlsSegmentCount int) *muxerStreamPlaylist {
p := &muxerStreamPlaylist{
hlsSegmentCount: hlsSegmentCount,
segmentByName: make(map[string]*muxerTSSegment),
}
p.cond = sync.NewCond(&p.mutex)
return p
}
func (p *muxerStreamPlaylist) close() {
func() {
p.mutex.Lock()
defer p.mutex.Unlock()
p.closed = true
}()
p.cond.Broadcast()
}
func (p *muxerStreamPlaylist) reader() io.Reader {
return &asyncReader{generator: func() []byte {
p.mutex.Lock()
defer p.mutex.Unlock()
if !p.closed && len(p.segments) == 0 {
p.cond.Wait()
}
if p.closed {
return nil
}
cnt := "#EXTM3U\n"
cnt += "#EXT-X-VERSION:3\n"
cnt += "#EXT-X-ALLOW-CACHE:NO\n"
targetDuration := func() uint {
ret := uint(0)
// EXTINF, when rounded to the nearest integer, must be <= EXT-X-TARGETDURATION
for _, s := range p.segments {
v2 := uint(math.Round(s.duration().Seconds()))
if v2 > ret {
ret = v2
}
}
return ret
}()
cnt += "#EXT-X-TARGETDURATION:" + strconv.FormatUint(uint64(targetDuration), 10) + "\n"
cnt += "#EXT-X-MEDIA-SEQUENCE:" + strconv.FormatInt(int64(p.segmentDeleteCount), 10) + "\n"
cnt += "#EXT-X-INDEPENDENT-SEGMENTS\n"
cnt += "\n"
for _, s := range p.segments {
cnt += "#EXT-X-PROGRAM-DATE-TIME:" + s.startTime.Format("2006-01-02T15:04:05.999Z07:00") + "\n" +
"#EXTINF:" + strconv.FormatFloat(s.duration().Seconds(), 'f', -1, 64) + ",\n" +
s.name + ".ts\n"
}
return []byte(cnt)
}}
}
func (p *muxerStreamPlaylist) segment(fname string) io.Reader {
base := strings.TrimSuffix(fname, ".ts")
p.mutex.Lock()
f, ok := p.segmentByName[base]
p.mutex.Unlock()
if !ok {
return nil
}
return f.reader()
}
func (p *muxerStreamPlaylist) pushSegment(t *muxerTSSegment) {
func() {
p.mutex.Lock()
defer p.mutex.Unlock()
p.segmentByName[t.name] = t
p.segments = append(p.segments, t)
if len(p.segments) > p.hlsSegmentCount {
delete(p.segmentByName, p.segments[0].name)
p.segments = p.segments[1:]
p.segmentDeleteCount++
}
}()
p.cond.Broadcast()
}

62
internal/hls/muxer_test.go

@ -20,7 +20,7 @@ func TestMuxerVideoAudio(t *testing.T) { @@ -20,7 +20,7 @@ func TestMuxerVideoAudio(t *testing.T) {
audioTrack, err := gortsplib.NewTrackAAC(97, 2, 44100, 2, nil, 13, 3, 3)
require.NoError(t, err)
m, err := NewMuxer(3, 1*time.Second, 50*1024*1024, videoTrack, audioTrack)
m, err := NewMuxer(MuxerVariantMPEGTS, 3, 1*time.Second, 0, 50*1024*1024, videoTrack, audioTrack)
require.NoError(t, err)
defer m.Close()
@ -60,16 +60,17 @@ func TestMuxerVideoAudio(t *testing.T) { @@ -60,16 +60,17 @@ func TestMuxerVideoAudio(t *testing.T) {
})
require.NoError(t, err)
byts, err := ioutil.ReadAll(m.PrimaryPlaylist())
byts, err := ioutil.ReadAll(m.File("index.m3u8", "", "", "").Body)
require.NoError(t, err)
require.Equal(t, "#EXTM3U\n"+
"#EXT-X-VERSION:3\n"+
"#EXT-X-INDEPENDENT-SEGMENTS\n"+
"\n"+
"#EXT-X-STREAM-INF:BANDWIDTH=200000,CODECS=\"avc1.010203,mp4a.40.2\"\n"+
"stream.m3u8\n", string(byts))
byts, err = ioutil.ReadAll(m.StreamPlaylist())
byts, err = ioutil.ReadAll(m.File("stream.m3u8", "", "", "").Body)
require.NoError(t, err)
re := regexp.MustCompile(`^#EXTM3U\n` +
@ -77,7 +78,6 @@ func TestMuxerVideoAudio(t *testing.T) { @@ -77,7 +78,6 @@ func TestMuxerVideoAudio(t *testing.T) {
`#EXT-X-ALLOW-CACHE:NO\n` +
`#EXT-X-TARGETDURATION:4\n` +
`#EXT-X-MEDIA-SEQUENCE:0\n` +
`#EXT-X-INDEPENDENT-SEGMENTS\n` +
`\n` +
`#EXT-X-PROGRAM-DATE-TIME:(.*?)\n` +
`#EXTINF:4,\n` +
@ -85,7 +85,7 @@ func TestMuxerVideoAudio(t *testing.T) { @@ -85,7 +85,7 @@ func TestMuxerVideoAudio(t *testing.T) {
ma := re.FindStringSubmatch(string(byts))
require.NotEqual(t, 0, len(ma))
dem := astits.NewDemuxer(context.Background(), m.Segment(ma[2]),
dem := astits.NewDemuxer(context.Background(), m.File(ma[2], "", "", "").Body,
astits.DemuxerOptPacketSize(188))
// PMT
@ -177,7 +177,7 @@ func TestMuxerVideoOnly(t *testing.T) { @@ -177,7 +177,7 @@ func TestMuxerVideoOnly(t *testing.T) {
videoTrack, err := gortsplib.NewTrackH264(96, []byte{0x07, 0x01, 0x02, 0x03}, []byte{0x08}, nil)
require.NoError(t, err)
m, err := NewMuxer(3, 1*time.Second, 50*1024*1024, videoTrack, nil)
m, err := NewMuxer(MuxerVariantMPEGTS, 3, 1*time.Second, 0, 50*1024*1024, videoTrack, nil)
require.NoError(t, err)
defer m.Close()
@ -196,16 +196,17 @@ func TestMuxerVideoOnly(t *testing.T) { @@ -196,16 +196,17 @@ func TestMuxerVideoOnly(t *testing.T) {
})
require.NoError(t, err)
byts, err := ioutil.ReadAll(m.PrimaryPlaylist())
byts, err := ioutil.ReadAll(m.File("index.m3u8", "", "", "").Body)
require.NoError(t, err)
require.Equal(t, "#EXTM3U\n"+
"#EXT-X-VERSION:3\n"+
"#EXT-X-INDEPENDENT-SEGMENTS\n"+
"\n"+
"#EXT-X-STREAM-INF:BANDWIDTH=200000,CODECS=\"avc1.010203\"\n"+
"stream.m3u8\n", string(byts))
byts, err = ioutil.ReadAll(m.StreamPlaylist())
byts, err = ioutil.ReadAll(m.File("stream.m3u8", "", "", "").Body)
require.NoError(t, err)
re := regexp.MustCompile(`^#EXTM3U\n` +
@ -213,7 +214,6 @@ func TestMuxerVideoOnly(t *testing.T) { @@ -213,7 +214,6 @@ func TestMuxerVideoOnly(t *testing.T) {
`#EXT-X-ALLOW-CACHE:NO\n` +
`#EXT-X-TARGETDURATION:4\n` +
`#EXT-X-MEDIA-SEQUENCE:0\n` +
`#EXT-X-INDEPENDENT-SEGMENTS\n` +
`\n` +
`#EXT-X-PROGRAM-DATE-TIME:(.*?)\n` +
`#EXTINF:4,\n` +
@ -221,7 +221,7 @@ func TestMuxerVideoOnly(t *testing.T) { @@ -221,7 +221,7 @@ func TestMuxerVideoOnly(t *testing.T) {
ma := re.FindStringSubmatch(string(byts))
require.NotEqual(t, 0, len(ma))
dem := astits.NewDemuxer(context.Background(), m.Segment(ma[2]),
dem := astits.NewDemuxer(context.Background(), m.File(ma[2], "", "", "").Body,
astits.DemuxerOptPacketSize(188))
// PMT
@ -261,7 +261,7 @@ func TestMuxerAudioOnly(t *testing.T) { @@ -261,7 +261,7 @@ func TestMuxerAudioOnly(t *testing.T) {
audioTrack, err := gortsplib.NewTrackAAC(97, 2, 44100, 2, nil, 13, 3, 3)
require.NoError(t, err)
m, err := NewMuxer(3, 1*time.Second, 50*1024*1024, nil, audioTrack)
m, err := NewMuxer(MuxerVariantMPEGTS, 3, 1*time.Second, 0, 50*1024*1024, nil, audioTrack)
require.NoError(t, err)
defer m.Close()
@ -284,16 +284,17 @@ func TestMuxerAudioOnly(t *testing.T) { @@ -284,16 +284,17 @@ func TestMuxerAudioOnly(t *testing.T) {
})
require.NoError(t, err)
byts, err := ioutil.ReadAll(m.PrimaryPlaylist())
byts, err := ioutil.ReadAll(m.File("index.m3u8", "", "", "").Body)
require.NoError(t, err)
require.Equal(t, "#EXTM3U\n"+
"#EXT-X-VERSION:3\n"+
"#EXT-X-INDEPENDENT-SEGMENTS\n"+
"\n"+
"#EXT-X-STREAM-INF:BANDWIDTH=200000,CODECS=\"mp4a.40.2\"\n"+
"stream.m3u8\n", string(byts))
byts, err = ioutil.ReadAll(m.StreamPlaylist())
byts, err = ioutil.ReadAll(m.File("stream.m3u8", "", "", "").Body)
require.NoError(t, err)
re := regexp.MustCompile(`^#EXTM3U\n` +
@ -301,7 +302,6 @@ func TestMuxerAudioOnly(t *testing.T) { @@ -301,7 +302,6 @@ func TestMuxerAudioOnly(t *testing.T) {
`#EXT-X-ALLOW-CACHE:NO\n` +
`#EXT-X-TARGETDURATION:1\n` +
`#EXT-X-MEDIA-SEQUENCE:0\n` +
`#EXT-X-INDEPENDENT-SEGMENTS\n` +
`\n` +
`#EXT-X-PROGRAM-DATE-TIME:(.*?)\n` +
`#EXTINF:1,\n` +
@ -309,7 +309,7 @@ func TestMuxerAudioOnly(t *testing.T) { @@ -309,7 +309,7 @@ func TestMuxerAudioOnly(t *testing.T) {
ma := re.FindStringSubmatch(string(byts))
require.NotEqual(t, 0, len(ma))
dem := astits.NewDemuxer(context.Background(), m.Segment(ma[2]),
dem := astits.NewDemuxer(context.Background(), m.File(ma[2], "", "", "").Body,
astits.DemuxerOptPacketSize(188))
// PMT
@ -345,11 +345,11 @@ func TestMuxerAudioOnly(t *testing.T) { @@ -345,11 +345,11 @@ func TestMuxerAudioOnly(t *testing.T) {
}, pkt)
}
func TestMuxerCloseBeforeFirstSegment(t *testing.T) {
func TestMuxerCloseBeforeFirstSegmentReader(t *testing.T) {
videoTrack, err := gortsplib.NewTrackH264(96, []byte{0x07, 0x01, 0x02, 0x03}, []byte{0x08}, nil)
require.NoError(t, err)
m, err := NewMuxer(3, 1*time.Second, 50*1024*1024, videoTrack, nil)
m, err := NewMuxer(MuxerVariantMPEGTS, 3, 1*time.Second, 0, 50*1024*1024, videoTrack, nil)
require.NoError(t, err)
// group with IDR
@ -363,16 +363,15 @@ func TestMuxerCloseBeforeFirstSegment(t *testing.T) { @@ -363,16 +363,15 @@ func TestMuxerCloseBeforeFirstSegment(t *testing.T) {
m.Close()
byts, err := ioutil.ReadAll(m.StreamPlaylist())
require.NoError(t, err)
require.Equal(t, []byte{}, byts)
b := m.File("stream.m3u8", "", "", "").Body
require.Equal(t, nil, b)
}
func TestMuxerMaxSegmentSize(t *testing.T) {
videoTrack, err := gortsplib.NewTrackH264(96, []byte{0x07, 0x01, 0x02, 0x03}, []byte{0x08}, nil)
require.NoError(t, err)
m, err := NewMuxer(3, 1*time.Second, 0, videoTrack, nil)
m, err := NewMuxer(MuxerVariantMPEGTS, 3, 1*time.Second, 0, 0, videoTrack, nil)
require.NoError(t, err)
defer m.Close()
@ -386,7 +385,7 @@ func TestMuxerDoubleRead(t *testing.T) { @@ -386,7 +385,7 @@ func TestMuxerDoubleRead(t *testing.T) {
videoTrack, err := gortsplib.NewTrackH264(96, []byte{0x07, 0x01, 0x02, 0x03}, []byte{0x08}, nil)
require.NoError(t, err)
m, err := NewMuxer(3, 1*time.Second, 50*1024*1024, videoTrack, nil)
m, err := NewMuxer(MuxerVariantMPEGTS, 3, 1*time.Second, 0, 50*1024*1024, videoTrack, nil)
require.NoError(t, err)
defer m.Close()
@ -402,10 +401,25 @@ func TestMuxerDoubleRead(t *testing.T) { @@ -402,10 +401,25 @@ func TestMuxerDoubleRead(t *testing.T) {
})
require.NoError(t, err)
byts1, err := ioutil.ReadAll(m.streamPlaylist.segments[0].reader())
byts, err := ioutil.ReadAll(m.File("stream.m3u8", "", "", "").Body)
require.NoError(t, err)
re := regexp.MustCompile(`^#EXTM3U\n` +
`#EXT-X-VERSION:3\n` +
`#EXT-X-ALLOW-CACHE:NO\n` +
`#EXT-X-TARGETDURATION:2\n` +
`#EXT-X-MEDIA-SEQUENCE:0\n` +
`\n` +
`#EXT-X-PROGRAM-DATE-TIME:(.*?)\n` +
`#EXTINF:2,\n` +
`([0-9]+\.ts)\n$`)
ma := re.FindStringSubmatch(string(byts))
require.NotEqual(t, 0, len(ma))
byts1, err := ioutil.ReadAll(m.File(ma[2], "", "", "").Body)
require.NoError(t, err)
byts2, err := ioutil.ReadAll(m.streamPlaylist.segments[0].reader())
byts2, err := ioutil.ReadAll(m.File(ma[2], "", "", "").Body)
require.NoError(t, err)
require.Equal(t, byts1, byts2)
}

201
internal/hls/muxer_ts_generator.go

@ -1,201 +0,0 @@ @@ -1,201 +0,0 @@
package hls
import (
"context"
"time"
"github.com/aler9/gortsplib"
"github.com/aler9/gortsplib/pkg/aac"
"github.com/aler9/gortsplib/pkg/h264"
"github.com/asticode/go-astits"
)
const (
segmentMinAUCount = 100
)
type writerFunc func(p []byte) (int, error)
func (f writerFunc) Write(p []byte) (int, error) {
return f(p)
}
type muxerTSGenerator struct {
hlsSegmentCount int
hlsSegmentDuration time.Duration
hlsSegmentMaxSize uint64
videoTrack *gortsplib.TrackH264
audioTrack *gortsplib.TrackAAC
streamPlaylist *muxerStreamPlaylist
writer *astits.Muxer
currentSegment *muxerTSSegment
videoDTSEst *h264.DTSEstimator
startPCR time.Time
startPTS time.Duration
}
func newMuxerTSGenerator(
hlsSegmentCount int,
hlsSegmentDuration time.Duration,
hlsSegmentMaxSize uint64,
videoTrack *gortsplib.TrackH264,
audioTrack *gortsplib.TrackAAC,
streamPlaylist *muxerStreamPlaylist,
) *muxerTSGenerator {
m := &muxerTSGenerator{
hlsSegmentCount: hlsSegmentCount,
hlsSegmentDuration: hlsSegmentDuration,
hlsSegmentMaxSize: hlsSegmentMaxSize,
videoTrack: videoTrack,
audioTrack: audioTrack,
streamPlaylist: streamPlaylist,
}
m.writer = astits.NewMuxer(
context.Background(),
writerFunc(func(p []byte) (int, error) {
return m.currentSegment.write(p)
}))
if videoTrack != nil {
m.writer.AddElementaryStream(astits.PMTElementaryStream{
ElementaryPID: 256,
StreamType: astits.StreamTypeH264Video,
})
}
if audioTrack != nil {
m.writer.AddElementaryStream(astits.PMTElementaryStream{
ElementaryPID: 257,
StreamType: astits.StreamTypeAACAudio,
})
}
if videoTrack != nil {
m.writer.SetPCRPID(256)
} else {
m.writer.SetPCRPID(257)
}
return m
}
func (m *muxerTSGenerator) writeH264(pts time.Duration, nalus [][]byte) error {
now := time.Now()
idrPresent := h264.IDRPresent(nalus)
if m.currentSegment == nil {
// skip groups silently until we find one with a IDR
if !idrPresent {
return nil
}
// create first segment
m.startPCR = now
m.currentSegment = newMuxerTSSegment(now, m.hlsSegmentMaxSize,
m.videoTrack, m.writer.WriteData)
m.videoDTSEst = h264.NewDTSEstimator()
m.startPTS = pts
pts = 0
} else {
pts -= m.startPTS
// switch segment
if idrPresent &&
m.currentSegment.startPTS != nil &&
(pts-*m.currentSegment.startPTS) >= m.hlsSegmentDuration {
m.currentSegment.endPTS = pts
m.streamPlaylist.pushSegment(m.currentSegment)
m.currentSegment = newMuxerTSSegment(now, m.hlsSegmentMaxSize,
m.videoTrack, m.writer.WriteData)
}
}
dts := m.videoDTSEst.Feed(pts)
// prepend an AUD. This is required by video.js and iOS
nalus = append([][]byte{{byte(h264.NALUTypeAccessUnitDelimiter), 240}}, nalus...)
enc, err := h264.AnnexBEncode(nalus)
if err != nil {
if m.currentSegment.buf.Len() > 0 {
m.streamPlaylist.pushSegment(m.currentSegment)
}
m.currentSegment = nil
return err
}
err = m.currentSegment.writeH264(now.Sub(m.startPCR), dts,
pts, idrPresent, enc)
if err != nil {
if m.currentSegment.buf.Len() > 0 {
m.streamPlaylist.pushSegment(m.currentSegment)
}
m.currentSegment = nil
return err
}
return nil
}
func (m *muxerTSGenerator) writeAAC(pts time.Duration, aus [][]byte) error {
now := time.Now()
if m.videoTrack == nil {
if m.currentSegment == nil {
// create first segment
m.startPCR = now
m.currentSegment = newMuxerTSSegment(now, m.hlsSegmentMaxSize,
m.videoTrack, m.writer.WriteData)
m.startPTS = pts
pts = 0
} else {
pts -= m.startPTS
// switch segment
if m.currentSegment.audioAUCount >= segmentMinAUCount &&
m.currentSegment.startPTS != nil &&
(pts-*m.currentSegment.startPTS) >= m.hlsSegmentDuration {
m.currentSegment.endPTS = pts
m.streamPlaylist.pushSegment(m.currentSegment)
m.currentSegment = newMuxerTSSegment(now, m.hlsSegmentMaxSize,
m.videoTrack, m.writer.WriteData)
}
}
} else {
// wait for the video track
if m.currentSegment == nil {
return nil
}
pts -= m.startPTS
}
pkts := make([]*aac.ADTSPacket, len(aus))
for i, au := range aus {
pkts[i] = &aac.ADTSPacket{
Type: m.audioTrack.Type(),
SampleRate: m.audioTrack.ClockRate(),
ChannelCount: m.audioTrack.ChannelCount(),
AU: au,
}
}
enc, err := aac.EncodeADTS(pkts)
if err != nil {
return err
}
err = m.currentSegment.writeAAC(now.Sub(m.startPCR), pts, enc, len(aus))
if err != nil {
if m.currentSegment.buf.Len() > 0 {
m.streamPlaylist.pushSegment(m.currentSegment)
}
m.currentSegment = nil
return err
}
return nil
}

22
internal/hls/muxer_variant.go

@ -0,0 +1,22 @@ @@ -0,0 +1,22 @@
package hls
import (
"time"
)
// MuxerVariant is a muxer variant.
type MuxerVariant int
// supported variants.
const (
MuxerVariantMPEGTS MuxerVariant = iota
MuxerVariantFMP4
MuxerVariantLowLatency
)
type muxerVariant interface {
close()
writeH264(pts time.Duration, nalus [][]byte) error
writeAAC(pts time.Duration, aus [][]byte) error
file(name string, msn string, part string, skip string) *MuxerFileResponse
}

247
internal/hls/muxer_variant_fmp4.go

@ -0,0 +1,247 @@ @@ -0,0 +1,247 @@
package hls
import (
"bytes"
"fmt"
"math"
"time"
"github.com/aler9/gortsplib"
"github.com/aler9/gortsplib/pkg/h264"
"github.com/icza/bitio"
)
const (
fmp4VideoTimescale = 90000
)
func readGolombUnsigned(br *bitio.Reader) (uint32, error) {
leadingZeroBits := uint32(0)
for {
b, err := br.ReadBits(1)
if err != nil {
return 0, err
}
if b != 0 {
break
}
leadingZeroBits++
}
codeNum := uint32(0)
for n := leadingZeroBits; n > 0; n-- {
b, err := br.ReadBits(1)
if err != nil {
return 0, err
}
codeNum |= uint32(b) << (n - 1)
}
codeNum = (1 << leadingZeroBits) - 1 + codeNum
return codeNum, nil
}
func getPOC(buf []byte, sps *h264.SPS) (uint32, error) {
buf = h264.AntiCompetitionRemove(buf[:10])
isIDR := h264.NALUType(buf[0]&0x1F) == h264.NALUTypeIDR
r := bytes.NewReader(buf[1:])
br := bitio.NewReader(r)
// first_mb_in_slice
_, err := readGolombUnsigned(br)
if err != nil {
return 0, err
}
// slice_type
_, err = readGolombUnsigned(br)
if err != nil {
return 0, err
}
// pic_parameter_set_id
_, err = readGolombUnsigned(br)
if err != nil {
return 0, err
}
// frame_num
_, err = br.ReadBits(uint8(sps.Log2MaxFrameNumMinus4 + 4))
if err != nil {
return 0, err
}
if !sps.FrameMbsOnlyFlag {
return 0, fmt.Errorf("unsupported")
}
if isIDR {
// idr_pic_id
_, err := readGolombUnsigned(br)
if err != nil {
return 0, err
}
}
var picOrderCntLsb uint64
switch {
case sps.PicOrderCntType == 0:
picOrderCntLsb, err = br.ReadBits(uint8(sps.Log2MaxPicOrderCntLsbMinus4 + 4))
if err != nil {
return 0, err
}
default:
return 0, fmt.Errorf("pic_order_cnt_type = 1 is unsupported")
}
return uint32(picOrderCntLsb), nil
}
func getNALUSPOC(nalus [][]byte, sps *h264.SPS) (uint32, error) {
for _, nalu := range nalus {
typ := h264.NALUType(nalu[0] & 0x1F)
if typ == h264.NALUTypeIDR || typ == h264.NALUTypeNonIDR {
poc, err := getPOC(nalu, sps)
if err != nil {
return 0, err
}
return poc, nil
}
}
return 0, fmt.Errorf("POC not found")
}
func getPOCDiff(poc uint32, expectedPOC uint32, sps *h264.SPS) int32 {
diff := int32(poc) - int32(expectedPOC)
switch {
case diff < -((1 << (sps.Log2MaxPicOrderCntLsbMinus4 + 3)) - 1):
diff += (1 << (sps.Log2MaxPicOrderCntLsbMinus4 + 4))
case diff > ((1 << (sps.Log2MaxPicOrderCntLsbMinus4 + 3)) - 1):
diff -= (1 << (sps.Log2MaxPicOrderCntLsbMinus4 + 4))
}
return diff
}
type fmp4VideoSample struct {
pts time.Duration
dts time.Duration
nalus [][]byte
avcc []byte
idrPresent bool
next *fmp4VideoSample
pocDiff int32
}
func (s *fmp4VideoSample) fillDTS(
prev *fmp4VideoSample,
sps *h264.SPS,
expectedPOC *uint32,
) error {
if s.idrPresent || sps.PicOrderCntType == 2 {
s.dts = s.pts
*expectedPOC = 0
} else {
*expectedPOC += 2
*expectedPOC &= ((1 << (sps.Log2MaxPicOrderCntLsbMinus4 + 4)) - 1)
poc, err := getNALUSPOC(s.nalus, sps)
if err != nil {
return err
}
s.pocDiff = getPOCDiff(poc, *expectedPOC, sps)
if s.pocDiff == 0 {
s.dts = s.pts
} else {
if prev.pocDiff == 0 {
if s.pocDiff == -2 {
return fmt.Errorf("invalid frame POC")
}
s.dts = prev.pts + time.Duration(math.Round(float64(s.pts-prev.pts)/float64(s.pocDiff/2+1)))
} else {
s.dts = s.pts + time.Duration(math.Round(float64(prev.dts-prev.pts)*float64(s.pocDiff)/float64(prev.pocDiff)))
}
}
}
return nil
}
func (s fmp4VideoSample) duration() time.Duration {
return s.next.dts - s.dts
}
type fmp4AudioSample struct {
pts time.Duration
au []byte
next *fmp4AudioSample
}
func (s fmp4AudioSample) duration() time.Duration {
return s.next.pts - s.pts
}
type muxerVariantFMP4 struct {
playlist *muxerVariantFMP4Playlist
segmenter *muxerVariantFMP4Segmenter
}
func newMuxerVariantFMP4(
lowLatency bool,
segmentCount int,
segmentDuration time.Duration,
partDuration time.Duration,
segmentMaxSize uint64,
videoTrack *gortsplib.TrackH264,
audioTrack *gortsplib.TrackAAC,
) *muxerVariantFMP4 {
v := &muxerVariantFMP4{}
v.playlist = newMuxerVariantFMP4Playlist(
lowLatency,
segmentCount,
videoTrack,
audioTrack,
)
v.segmenter = newMuxerVariantFMP4Segmenter(
lowLatency,
segmentCount,
segmentDuration,
partDuration,
segmentMaxSize,
videoTrack,
audioTrack,
v.playlist.onSegmentFinalized,
v.playlist.onPartFinalized,
)
return v
}
func (v *muxerVariantFMP4) close() {
v.playlist.close()
}
func (v *muxerVariantFMP4) writeH264(pts time.Duration, nalus [][]byte) error {
return v.segmenter.writeH264(pts, nalus)
}
func (v *muxerVariantFMP4) writeAAC(pts time.Duration, aus [][]byte) error {
return v.segmenter.writeAAC(pts, aus)
}
func (v *muxerVariantFMP4) file(name string, msn string, part string, skip string) *MuxerFileResponse {
return v.playlist.file(name, msn, part, skip)
}

621
internal/hls/muxer_variant_fmp4_init.go

@ -0,0 +1,621 @@ @@ -0,0 +1,621 @@
package hls
import (
"github.com/abema/go-mp4"
"github.com/aler9/gortsplib"
"github.com/aler9/gortsplib/pkg/aac"
"github.com/aler9/gortsplib/pkg/h264"
)
type myEsds struct {
mp4.FullBox `mp4:"0,extend"`
Data []byte `mp4:"1,size=8"`
}
func (*myEsds) GetType() mp4.BoxType {
return mp4.StrToBoxType("esds")
}
func init() { //nolint:gochecknoinits
mp4.AddBoxDef(&myEsds{}, 0)
}
func mp4InitGenerateVideoTrack(w *mp4Writer, trackID int, videoTrack *gortsplib.TrackH264) error {
/*
trak
- tkhd
- mdia
- mdhd
- hdlr
- minf
- vmhd
- dinf
- dref
- url
- stbl
- stsd
- avc1
- avcC
- pasp
- btrt
- stts
- stsc
- stsz
- stco
*/
_, err := w.writeBoxStart(&mp4.Trak{}) // <trak>
if err != nil {
return err
}
sps := videoTrack.SPS()
pps := videoTrack.PPS()
var spsp h264.SPS
err = spsp.Unmarshal(sps)
if err != nil {
return err
}
width := spsp.Width()
height := spsp.Height()
_, err = w.writeBox(&mp4.Tkhd{ // <tkhd/>
FullBox: mp4.FullBox{
Flags: [3]byte{0, 0, 3},
},
TrackID: uint32(trackID),
Width: uint32(width * 65536),
Height: uint32(height * 65536),
Matrix: [9]int32{0x00010000, 0, 0, 0, 0x00010000, 0, 0, 0, 0x40000000},
})
if err != nil {
return err
}
_, err = w.writeBoxStart(&mp4.Mdia{}) // <mdia>
if err != nil {
return err
}
_, err = w.writeBox(&mp4.Mdhd{ // <mdhd/>
Timescale: fmp4VideoTimescale, // the number of time units that pass per second
Language: [3]byte{'u', 'n', 'd'},
})
if err != nil {
return err
}
_, err = w.writeBox(&mp4.Hdlr{ // <hdlr/>
HandlerType: [4]byte{'v', 'i', 'd', 'e'},
Name: "VideoHandler",
})
if err != nil {
return err
}
_, err = w.writeBoxStart(&mp4.Minf{}) // <minf>
if err != nil {
return err
}
_, err = w.writeBox(&mp4.Vmhd{ // <vmhd/>
FullBox: mp4.FullBox{
Flags: [3]byte{0, 0, 1},
},
})
if err != nil {
return err
}
_, err = w.writeBoxStart(&mp4.Dinf{}) // <dinf>
if err != nil {
return err
}
_, err = w.writeBoxStart(&mp4.Dref{ // <dref>
EntryCount: 1,
})
if err != nil {
return err
}
_, err = w.writeBox(&mp4.Url{ // <url/>
FullBox: mp4.FullBox{
Flags: [3]byte{0, 0, 1},
},
})
if err != nil {
return err
}
err = w.writeBoxEnd() // </dref>
if err != nil {
return err
}
err = w.writeBoxEnd() // </dinf>
if err != nil {
return err
}
_, err = w.writeBoxStart(&mp4.Stbl{}) // <stbl>
if err != nil {
return err
}
_, err = w.writeBoxStart(&mp4.Stsd{ // <stsd>
EntryCount: 1,
})
if err != nil {
return err
}
_, err = w.writeBoxStart(&mp4.VisualSampleEntry{ // <avc1>
SampleEntry: mp4.SampleEntry{
AnyTypeBox: mp4.AnyTypeBox{
Type: mp4.BoxTypeAvc1(),
},
DataReferenceIndex: 1,
},
Width: uint16(width),
Height: uint16(height),
Horizresolution: 4718592,
Vertresolution: 4718592,
FrameCount: 1,
Depth: 24,
PreDefined3: -1,
})
if err != nil {
return err
}
_, err = w.writeBox(&mp4.AVCDecoderConfiguration{ // <avcc/>
AnyTypeBox: mp4.AnyTypeBox{
Type: mp4.BoxTypeAvcC(),
},
ConfigurationVersion: 1,
Profile: spsp.ProfileIdc,
ProfileCompatibility: sps[2],
Level: spsp.LevelIdc,
LengthSizeMinusOne: 3,
NumOfSequenceParameterSets: 1,
SequenceParameterSets: []mp4.AVCParameterSet{
{
Length: uint16(len(sps)),
NALUnit: sps,
},
},
NumOfPictureParameterSets: 1,
PictureParameterSets: []mp4.AVCParameterSet{
{
Length: uint16(len(pps)),
NALUnit: pps,
},
},
})
if err != nil {
return err
}
_, err = w.writeBox(&mp4.Btrt{ // <btrt/>
MaxBitrate: 1000000,
AvgBitrate: 1000000,
})
if err != nil {
return err
}
err = w.writeBoxEnd() // </avc1>
if err != nil {
return err
}
err = w.writeBoxEnd() // </stsd>
if err != nil {
return err
}
_, err = w.writeBox(&mp4.Stts{ // <stts>
})
if err != nil {
return err
}
_, err = w.writeBox(&mp4.Stsc{ // <stsc>
})
if err != nil {
return err
}
_, err = w.writeBox(&mp4.Stsz{ // <stsz>
})
if err != nil {
return err
}
_, err = w.writeBox(&mp4.Stco{ // <stco>
})
if err != nil {
return err
}
err = w.writeBoxEnd() // </stbl>
if err != nil {
return err
}
err = w.writeBoxEnd() // </minf>
if err != nil {
return err
}
err = w.writeBoxEnd() // </mdia>
if err != nil {
return err
}
err = w.writeBoxEnd() // </trak>
if err != nil {
return err
}
return nil
}
func mp4InitGenerateAudioTrack(w *mp4Writer, trackID int, audioTrack *gortsplib.TrackAAC) error {
/*
trak
- tkhd
- mdia
- mdhd
- hdlr
- minf
- smhd
- dinf
- dref
- url
- stbl
- stsd
- mp4a
- esds
- btrt
- stts
- stsc
- stsz
- stco
*/
_, err := w.writeBoxStart(&mp4.Trak{}) // <trak>
if err != nil {
return err
}
_, err = w.writeBox(&mp4.Tkhd{ // <tkhd/>
FullBox: mp4.FullBox{
Flags: [3]byte{0, 0, 3},
},
TrackID: uint32(trackID),
AlternateGroup: 1,
Volume: 256,
Matrix: [9]int32{0x00010000, 0, 0, 0, 0x00010000, 0, 0, 0, 0x40000000},
})
if err != nil {
return err
}
_, err = w.writeBoxStart(&mp4.Mdia{}) // <mdia>
if err != nil {
return err
}
_, err = w.writeBox(&mp4.Mdhd{ // <mdhd/>
Timescale: uint32(audioTrack.ClockRate()),
Language: [3]byte{'u', 'n', 'd'},
})
if err != nil {
return err
}
_, err = w.writeBox(&mp4.Hdlr{ // <hdlr/>
HandlerType: [4]byte{'s', 'o', 'u', 'n'},
Name: "SoundHandler",
})
if err != nil {
return err
}
_, err = w.writeBoxStart(&mp4.Minf{}) // <minf>
if err != nil {
return err
}
_, err = w.writeBox(&mp4.Smhd{ // <smhd/>
})
if err != nil {
return err
}
_, err = w.writeBoxStart(&mp4.Dinf{}) // <dinf>
if err != nil {
return err
}
_, err = w.writeBoxStart(&mp4.Dref{ // <dref>
EntryCount: 1,
})
if err != nil {
return err
}
_, err = w.writeBox(&mp4.Url{ // <url/>
FullBox: mp4.FullBox{
Flags: [3]byte{0, 0, 1},
},
})
if err != nil {
return err
}
err = w.writeBoxEnd() // </dref>
if err != nil {
return err
}
err = w.writeBoxEnd() // </dinf>
if err != nil {
return err
}
_, err = w.writeBoxStart(&mp4.Stbl{}) // <stbl>
if err != nil {
return err
}
_, err = w.writeBoxStart(&mp4.Stsd{ // <stsd>
EntryCount: 1,
})
if err != nil {
return err
}
_, err = w.writeBoxStart(&mp4.AudioSampleEntry{ // <mp4a>
SampleEntry: mp4.SampleEntry{
AnyTypeBox: mp4.AnyTypeBox{
Type: mp4.BoxTypeMp4a(),
},
DataReferenceIndex: 1,
},
ChannelCount: uint16(audioTrack.ChannelCount()),
SampleSize: 16,
SampleRate: uint32(audioTrack.ClockRate() * 65536),
})
if err != nil {
return err
}
c := aac.MPEG4AudioConfig{
Type: aac.MPEG4AudioType(audioTrack.Type()),
SampleRate: audioTrack.ClockRate(),
ChannelCount: audioTrack.ChannelCount(),
AOTSpecificConfig: audioTrack.AOTSpecificConfig(),
}
conf, _ := c.Encode()
decSpecificInfoTagSize := uint8(len(conf))
decSpecificInfoTag := append(
[]byte{
mp4.DecSpecificInfoTag,
0x80, 0x80, 0x80, decSpecificInfoTagSize, // size
},
conf...,
)
esDescrTag := []byte{
mp4.ESDescrTag,
0x80, 0x80, 0x80, 32 + decSpecificInfoTagSize, // size
0x00,
byte(trackID), // ES_ID
0x00,
}
decoderConfigDescrTag := []byte{
mp4.DecoderConfigDescrTag,
0x80, 0x80, 0x80, 18 + decSpecificInfoTagSize, // size
0x40, // object type indicator (MPEG-4 Audio)
0x15, 0x00,
0x00, 0x00, 0x00, 0x01,
0xf7, 0x39, 0x00, 0x01,
0xf7, 0x39,
}
slConfigDescrTag := []byte{
mp4.SLConfigDescrTag,
0x80, 0x80, 0x80, 0x01, // size (1)
0x02,
}
data := make([]byte, len(esDescrTag)+len(decoderConfigDescrTag)+len(decSpecificInfoTag)+len(slConfigDescrTag))
pos := 0
pos += copy(data[pos:], esDescrTag)
pos += copy(data[pos:], decoderConfigDescrTag)
pos += copy(data[pos:], decSpecificInfoTag)
copy(data[pos:], slConfigDescrTag)
_, err = w.writeBox(&myEsds{ // <esds/>
Data: data,
})
if err != nil {
return err
}
_, err = w.writeBox(&mp4.Btrt{ // <btrt/>
MaxBitrate: 128825,
AvgBitrate: 128825,
})
if err != nil {
return err
}
err = w.writeBoxEnd() // </mp4a>
if err != nil {
return err
}
err = w.writeBoxEnd() // </stsd>
if err != nil {
return err
}
_, err = w.writeBox(&mp4.Stts{ // <stts>
})
if err != nil {
return err
}
_, err = w.writeBox(&mp4.Stsc{ // <stsc>
})
if err != nil {
return err
}
_, err = w.writeBox(&mp4.Stsz{ // <stsz>
})
if err != nil {
return err
}
_, err = w.writeBox(&mp4.Stco{ // <stco>
})
if err != nil {
return err
}
err = w.writeBoxEnd() // </stbl>
if err != nil {
return err
}
err = w.writeBoxEnd() // </minf>
if err != nil {
return err
}
err = w.writeBoxEnd() // </mdia>
if err != nil {
return err
}
err = w.writeBoxEnd() // </trak>
if err != nil {
return err
}
return nil
}
func mp4InitGenerate(videoTrack *gortsplib.TrackH264, audioTrack *gortsplib.TrackAAC) ([]byte, error) {
/*
- ftyp
- moov
- mvhd
- trak (video)
- trak (audio)
- mvex
- trex (video)
- trex (audio)
*/
w := newMP4Writer()
_, err := w.writeBox(&mp4.Ftyp{ // <ftyp/>
MajorBrand: [4]byte{'m', 'p', '4', '2'},
MinorVersion: 1,
CompatibleBrands: []mp4.CompatibleBrandElem{
{CompatibleBrand: [4]byte{'m', 'p', '4', '1'}},
{CompatibleBrand: [4]byte{'m', 'p', '4', '2'}},
{CompatibleBrand: [4]byte{'i', 's', 'o', 'm'}},
{CompatibleBrand: [4]byte{'h', 'l', 's', 'f'}},
},
})
if err != nil {
return nil, err
}
_, err = w.writeBoxStart(&mp4.Moov{}) // <moov>
if err != nil {
return nil, err
}
_, err = w.writeBox(&mp4.Mvhd{ // <mvhd/>
Timescale: 1000,
Rate: 65536,
Volume: 256,
Matrix: [9]int32{0x00010000, 0, 0, 0, 0x00010000, 0, 0, 0, 0x40000000},
NextTrackID: 2,
})
if err != nil {
return nil, err
}
trackID := 1
if videoTrack != nil {
err := mp4InitGenerateVideoTrack(w, trackID, videoTrack)
if err != nil {
return nil, err
}
trackID++
}
if audioTrack != nil {
err := mp4InitGenerateAudioTrack(w, trackID, audioTrack)
if err != nil {
return nil, err
}
}
_, err = w.writeBoxStart(&mp4.Mvex{}) // <mvex>
if err != nil {
return nil, err
}
trackID = 1
if videoTrack != nil {
_, err = w.writeBox(&mp4.Trex{ // <trex/>
TrackID: uint32(trackID),
DefaultSampleDescriptionIndex: 1,
})
if err != nil {
return nil, err
}
trackID++
}
if audioTrack != nil {
_, err = w.writeBox(&mp4.Trex{ // <trex/>
TrackID: uint32(trackID),
DefaultSampleDescriptionIndex: 1,
})
if err != nil {
return nil, err
}
}
err = w.writeBoxEnd() // </mvex>
if err != nil {
return nil, err
}
err = w.writeBoxEnd() // </moov>
if err != nil {
return nil, err
}
return w.bytes(), nil
}

390
internal/hls/muxer_variant_fmp4_part.go

@ -0,0 +1,390 @@ @@ -0,0 +1,390 @@
package hls
import (
"bytes"
"io"
"math"
"strconv"
"time"
"github.com/abema/go-mp4"
"github.com/aler9/gortsplib"
"github.com/aler9/gortsplib/pkg/aac"
)
func durationGoToMp4(v time.Duration, timescale time.Duration) int64 {
return int64(math.Round(float64(v*timescale) / float64(time.Second)))
}
func mp4PartGenerateVideoTraf(
w *mp4Writer,
trackID int,
videoSamples []*fmp4VideoSample,
startDTS time.Duration,
) (*mp4.Trun, int, error) {
/*
traf
- tfhd
- tfdt
- trun
*/
_, err := w.writeBoxStart(&mp4.Traf{}) // <traf>
if err != nil {
return nil, 0, err
}
flags := 0
_, err = w.writeBox(&mp4.Tfhd{ // <tfhd/>
FullBox: mp4.FullBox{
Flags: [3]byte{2, byte(flags >> 8), byte(flags)},
},
TrackID: uint32(trackID),
})
if err != nil {
return nil, 0, err
}
_, err = w.writeBox(&mp4.Tfdt{ // <tfdt/>
FullBox: mp4.FullBox{
Version: 1,
},
// sum of decode durations of all earlier samples
BaseMediaDecodeTimeV1: uint64(durationGoToMp4(startDTS, fmp4VideoTimescale)),
})
if err != nil {
return nil, 0, err
}
flags = 0
flags |= 0x01 // data offset present
flags |= 0x100 // sample duration present
flags |= 0x200 // sample size present
flags |= 0x400 // sample flags present
flags |= 0x800 // sample composition time offset present or v1
trun := &mp4.Trun{ // <trun/>
FullBox: mp4.FullBox{
Version: 1,
Flags: [3]byte{0, byte(flags >> 8), byte(flags)},
},
SampleCount: uint32(len(videoSamples)),
}
for _, e := range videoSamples {
off := e.pts - e.dts
flags := uint32(0)
if !e.idrPresent {
flags |= 1 << 16 // sample_is_non_sync_sample
}
trun.Entries = append(trun.Entries, mp4.TrunEntry{
SampleDuration: uint32(durationGoToMp4(e.duration(), fmp4VideoTimescale)),
SampleSize: uint32(len(e.avcc)),
SampleFlags: flags,
SampleCompositionTimeOffsetV1: int32(durationGoToMp4(off, fmp4VideoTimescale)),
})
}
trunOffset, err := w.writeBox(trun)
if err != nil {
return nil, 0, err
}
err = w.writeBoxEnd() // </traf>
if err != nil {
return nil, 0, err
}
return trun, trunOffset, nil
}
func mp4PartGenerateAudioTraf(
w *mp4Writer,
trackID int,
audioTrack *gortsplib.TrackAAC,
audioSamples []*fmp4AudioSample,
) (*mp4.Trun, int, error) {
/*
traf
- tfhd
- tfdt
- trun
*/
if len(audioSamples) == 0 {
return nil, 0, nil
}
_, err := w.writeBoxStart(&mp4.Traf{}) // <traf>
if err != nil {
return nil, 0, err
}
flags := 0
_, err = w.writeBox(&mp4.Tfhd{ // <tfhd/>
FullBox: mp4.FullBox{
Flags: [3]byte{2, byte(flags >> 8), byte(flags)},
},
TrackID: uint32(trackID),
})
if err != nil {
return nil, 0, err
}
_, err = w.writeBox(&mp4.Tfdt{ // <tfdt/>
FullBox: mp4.FullBox{
Version: 1,
},
// sum of decode durations of all earlier samples
BaseMediaDecodeTimeV1: uint64(durationGoToMp4(audioSamples[0].pts, time.Duration(audioTrack.ClockRate()))),
})
if err != nil {
return nil, 0, err
}
flags = 0
flags |= 0x01 // data offset present
flags |= 0x100 // sample duration present
flags |= 0x200 // sample size present
trun := &mp4.Trun{ // <trun/>
FullBox: mp4.FullBox{
Version: 0,
Flags: [3]byte{0, byte(flags >> 8), byte(flags)},
},
SampleCount: uint32(len(audioSamples)),
}
for _, e := range audioSamples {
trun.Entries = append(trun.Entries, mp4.TrunEntry{
SampleDuration: uint32(durationGoToMp4(e.duration(), time.Duration(audioTrack.ClockRate()))),
SampleSize: uint32(len(e.au)),
})
}
trunOffset, err := w.writeBox(trun)
if err != nil {
return nil, 0, err
}
err = w.writeBoxEnd() // </traf>
if err != nil {
return nil, 0, err
}
return trun, trunOffset, nil
}
func mp4PartGenerate(
videoTrack *gortsplib.TrackH264,
audioTrack *gortsplib.TrackAAC,
videoSamples []*fmp4VideoSample,
audioSamples []*fmp4AudioSample,
startDTS time.Duration,
) ([]byte, error) {
/*
moof
- mfhd
- traf (video)
- traf (audio)
mdat
*/
w := newMP4Writer()
moofOffset, err := w.writeBoxStart(&mp4.Moof{}) // <moof>
if err != nil {
return nil, err
}
_, err = w.writeBox(&mp4.Mfhd{ // <mfhd/>
SequenceNumber: 0,
})
if err != nil {
return nil, err
}
trackID := 1
var videoTrun *mp4.Trun
var videoTrunOffset int
if videoTrack != nil {
var err error
videoTrun, videoTrunOffset, err = mp4PartGenerateVideoTraf(
w, trackID, videoSamples, startDTS)
if err != nil {
return nil, err
}
trackID++
}
var audioTrun *mp4.Trun
var audioTrunOffset int
if audioTrack != nil {
var err error
audioTrun, audioTrunOffset, err = mp4PartGenerateAudioTraf(w, trackID, audioTrack, audioSamples)
if err != nil {
return nil, err
}
}
err = w.writeBoxEnd() // </moof>
if err != nil {
return nil, err
}
mdat := &mp4.Mdat{} // <mdat/>
dataSize := 0
videoDataSize := 0
if videoTrack != nil {
for _, e := range videoSamples {
dataSize += len(e.avcc)
}
videoDataSize = dataSize
}
if audioTrack != nil {
for _, e := range audioSamples {
dataSize += len(e.au)
}
}
mdat.Data = make([]byte, dataSize)
pos := 0
if videoTrack != nil {
for _, e := range videoSamples {
pos += copy(mdat.Data[pos:], e.avcc)
}
}
if audioTrack != nil {
for _, e := range audioSamples {
pos += copy(mdat.Data[pos:], e.au)
}
}
mdatOffset, err := w.writeBox(mdat)
if err != nil {
return nil, err
}
if videoTrack != nil {
videoTrun.DataOffset = int32(mdatOffset - moofOffset + 8)
err = w.rewriteBox(videoTrunOffset, videoTrun)
if err != nil {
return nil, err
}
}
if audioTrack != nil && audioTrun != nil {
audioTrun.DataOffset = int32(videoDataSize + mdatOffset - moofOffset + 8)
err = w.rewriteBox(audioTrunOffset, audioTrun)
if err != nil {
return nil, err
}
}
return w.bytes(), nil
}
func fmp4PartName(id uint64) string {
return "part" + strconv.FormatUint(id, 10)
}
type muxerVariantFMP4Part struct {
videoTrack *gortsplib.TrackH264
audioTrack *gortsplib.TrackAAC
id uint64
startDTS time.Duration
isIndependent bool
videoSamples []*fmp4VideoSample
audioSamples []*fmp4AudioSample
renderedContent []byte
renderedDuration time.Duration
}
func newMuxerVariantFMP4Part(
videoTrack *gortsplib.TrackH264,
audioTrack *gortsplib.TrackAAC,
id uint64,
startDTS time.Duration,
) *muxerVariantFMP4Part {
p := &muxerVariantFMP4Part{
videoTrack: videoTrack,
audioTrack: audioTrack,
id: id,
startDTS: startDTS,
}
if videoTrack == nil {
p.isIndependent = true
}
return p
}
func (p *muxerVariantFMP4Part) name() string {
return fmp4PartName(p.id)
}
func (p *muxerVariantFMP4Part) reader() io.Reader {
return bytes.NewReader(p.renderedContent)
}
func (p *muxerVariantFMP4Part) duration() time.Duration {
if p.videoTrack != nil {
ret := time.Duration(0)
for _, e := range p.videoSamples {
ret += e.duration()
}
return ret
}
// use the sum of the default duration of all samples,
// not the real duration,
// otherwise on iPhone iOS the stream freezes.
return time.Duration(len(p.audioSamples)) * time.Second *
time.Duration(aac.SamplesPerAccessUnit) / time.Duration(p.audioTrack.ClockRate())
}
func (p *muxerVariantFMP4Part) finalize() error {
if len(p.videoSamples) > 0 || len(p.audioSamples) > 0 {
var err error
p.renderedContent, err = mp4PartGenerate(
p.videoTrack,
p.audioTrack,
p.videoSamples,
p.audioSamples,
p.startDTS)
if err != nil {
return err
}
p.renderedDuration = p.duration()
}
p.videoSamples = nil
p.audioSamples = nil
return nil
}
func (p *muxerVariantFMP4Part) writeH264(sample *fmp4VideoSample) {
if sample.idrPresent {
p.isIndependent = true
}
p.videoSamples = append(p.videoSamples, sample)
}
func (p *muxerVariantFMP4Part) writeAAC(sample *fmp4AudioSample) {
p.audioSamples = append(p.audioSamples, sample)
}

503
internal/hls/muxer_variant_fmp4_playlist.go

@ -0,0 +1,503 @@ @@ -0,0 +1,503 @@
package hls
import (
"bytes"
"io"
"math"
"net/http"
"strconv"
"strings"
"sync"
"time"
"github.com/aler9/gortsplib"
)
type muxerVariantFMP4SegmentOrGap interface {
getRenderedDuration() time.Duration
}
type muxerVariantFMP4Gap struct {
renderedDuration time.Duration
}
func (g muxerVariantFMP4Gap) getRenderedDuration() time.Duration {
return g.renderedDuration
}
func targetDuration(segments []muxerVariantFMP4SegmentOrGap) uint {
ret := uint(0)
// EXTINF, when rounded to the nearest integer, must be <= EXT-X-TARGETDURATION
for _, sog := range segments {
v := uint(math.Round(sog.getRenderedDuration().Seconds()))
if v > ret {
ret = v
}
}
return ret
}
func partTargetDuration(
segments []muxerVariantFMP4SegmentOrGap,
nextSegmentParts []*muxerVariantFMP4Part,
) time.Duration {
var ret time.Duration
for _, sog := range segments {
seg, ok := sog.(*muxerVariantFMP4Segment)
if !ok {
continue
}
for _, part := range seg.parts {
if part.renderedDuration > ret {
ret = part.renderedDuration
}
}
}
for _, part := range nextSegmentParts {
if part.renderedDuration > ret {
ret = part.renderedDuration
}
}
return ret
}
type muxerVariantFMP4Playlist struct {
lowLatency bool
segmentCount int
videoTrack *gortsplib.TrackH264
audioTrack *gortsplib.TrackAAC
mutex sync.Mutex
cond *sync.Cond
closed bool
segments []muxerVariantFMP4SegmentOrGap
segmentsByName map[string]*muxerVariantFMP4Segment
segmentDeleteCount int
parts []*muxerVariantFMP4Part
partsByName map[string]*muxerVariantFMP4Part
nextSegmentID uint64
nextSegmentParts []*muxerVariantFMP4Part
nextPartID uint64
}
func newMuxerVariantFMP4Playlist(
lowLatency bool,
segmentCount int,
videoTrack *gortsplib.TrackH264,
audioTrack *gortsplib.TrackAAC,
) *muxerVariantFMP4Playlist {
p := &muxerVariantFMP4Playlist{
lowLatency: lowLatency,
segmentCount: segmentCount,
videoTrack: videoTrack,
audioTrack: audioTrack,
segmentsByName: make(map[string]*muxerVariantFMP4Segment),
partsByName: make(map[string]*muxerVariantFMP4Part),
}
p.cond = sync.NewCond(&p.mutex)
return p
}
func (p *muxerVariantFMP4Playlist) close() {
func() {
p.mutex.Lock()
defer p.mutex.Unlock()
p.closed = true
}()
p.cond.Broadcast()
}
func (p *muxerVariantFMP4Playlist) hasContent() bool {
return len(p.segments) > 0
}
func (p *muxerVariantFMP4Playlist) hasPart(segmentID uint64, partID uint64) bool {
if !p.hasContent() {
return false
}
for _, sop := range p.segments {
seg, ok := sop.(*muxerVariantFMP4Segment)
if !ok {
continue
}
if segmentID != seg.id {
continue
}
// If the Client requests a Part Index greater than that of the final
// Partial Segment of the Parent Segment, the Server MUST treat the
// request as one for Part Index 0 of the following Parent Segment.
if partID >= uint64(len(seg.parts)) {
segmentID++
partID = 0
continue
}
return true
}
if segmentID != p.nextSegmentID {
return false
}
if partID >= uint64(len(p.nextSegmentParts)) {
return false
}
return true
}
func (p *muxerVariantFMP4Playlist) file(name string, msn string, part string, skip string) *MuxerFileResponse {
switch {
case name == "stream.m3u8":
return p.playlistReader(msn, part, skip)
case strings.HasSuffix(name, ".mp4"):
return p.segmentReader(name)
default:
return &MuxerFileResponse{Status: http.StatusNotFound}
}
}
func (p *muxerVariantFMP4Playlist) playlistReader(msn string, part string, skip string) *MuxerFileResponse {
isDeltaUpdate := false
if p.lowLatency {
isDeltaUpdate = skip == "YES" || skip == "v2"
var msnint uint64
if msn != "" {
var err error
msnint, err = strconv.ParseUint(msn, 10, 64)
if err != nil {
return &MuxerFileResponse{Status: http.StatusBadRequest}
}
}
var partint uint64
if part != "" {
var err error
partint, err = strconv.ParseUint(part, 10, 64)
if err != nil {
return &MuxerFileResponse{Status: http.StatusBadRequest}
}
}
if msn != "" {
p.mutex.Lock()
defer p.mutex.Unlock()
// If the _HLS_msn is greater than the Media Sequence Number of the last
// Media Segment in the current Playlist plus two, or if the _HLS_part
// exceeds the last Partial Segment in the current Playlist by the
// Advance Part Limit, then the server SHOULD immediately return Bad
// Request, such as HTTP 400.
if msnint > (p.nextSegmentID + 1) {
return &MuxerFileResponse{Status: http.StatusBadRequest}
}
for !p.closed && !p.hasPart(msnint, partint) {
p.cond.Wait()
}
if p.closed {
return &MuxerFileResponse{Status: http.StatusInternalServerError}
}
return &MuxerFileResponse{
Status: http.StatusOK,
Header: map[string]string{
"Content-Type": `audio/mpegURL`,
},
Body: p.fullPlaylist(isDeltaUpdate),
}
}
// part without msn is not supported.
if part != "" {
return &MuxerFileResponse{Status: http.StatusBadRequest}
}
}
p.mutex.Lock()
defer p.mutex.Unlock()
for !p.closed && !p.hasContent() {
p.cond.Wait()
}
if p.closed {
return &MuxerFileResponse{Status: http.StatusInternalServerError}
}
return &MuxerFileResponse{
Status: http.StatusOK,
Header: map[string]string{
"Content-Type": `audio/mpegURL`,
},
Body: p.fullPlaylist(isDeltaUpdate),
}
}
func (p *muxerVariantFMP4Playlist) fullPlaylist(isDeltaUpdate bool) io.Reader {
cnt := "#EXTM3U\n"
cnt += "#EXT-X-VERSION:9\n"
targetDuration := targetDuration(p.segments)
cnt += "#EXT-X-TARGETDURATION:" + strconv.FormatUint(uint64(targetDuration), 10) + "\n"
skipBoundary := float64(targetDuration * 6)
if p.lowLatency {
partTargetDuration := partTargetDuration(p.segments, p.nextSegmentParts)
// The value is an enumerated-string whose value is YES if the server
// supports Blocking Playlist Reload
cnt += "#EXT-X-SERVER-CONTROL:CAN-BLOCK-RELOAD=YES"
// The value is a decimal-floating-point number of seconds that
// indicates the server-recommended minimum distance from the end of
// the Playlist at which clients should begin to play or to which
// they should seek when playing in Low-Latency Mode. Its value MUST
// be at least twice the Part Target Duration. Its value SHOULD be
// at least three times the Part Target Duration.
cnt += ",PART-HOLD-BACK=" + strconv.FormatFloat((partTargetDuration).Seconds()*2.5, 'f', 5, 64)
// Indicates that the Server can produce Playlist Delta Updates in
// response to the _HLS_skip Delivery Directive. Its value is the
// Skip Boundary, a decimal-floating-point number of seconds. The
// Skip Boundary MUST be at least six times the Target Duration.
cnt += ",CAN-SKIP-UNTIL=" + strconv.FormatFloat(skipBoundary, 'f', -1, 64)
cnt += "\n"
cnt += "#EXT-X-PART-INF:PART-TARGET=" + strconv.FormatFloat(partTargetDuration.Seconds(), 'f', -1, 64) + "\n"
}
cnt += "#EXT-X-MEDIA-SEQUENCE:" + strconv.FormatInt(int64(p.segmentDeleteCount), 10) + "\n"
skipped := 0
if !isDeltaUpdate {
cnt += "#EXT-X-MAP:URI=\"init.mp4\"\n"
} else {
var curDuration time.Duration
shown := 0
for _, segment := range p.segments {
curDuration += segment.getRenderedDuration()
if curDuration.Seconds() >= skipBoundary {
break
}
shown++
}
skipped = len(p.segments) - shown
cnt += "#EXT-X-SKIP:SKIPPED-SEGMENTS=" + strconv.FormatInt(int64(skipped), 10) + "\n"
}
cnt += "\n"
for i, sog := range p.segments {
if i < skipped {
continue
}
switch seg := sog.(type) {
case *muxerVariantFMP4Segment:
if (len(p.segments) - i) <= 2 {
cnt += "#EXT-X-PROGRAM-DATE-TIME:" + seg.startTime.Format("2006-01-02T15:04:05.999Z07:00") + "\n"
}
if p.lowLatency && (len(p.segments)-i) <= 2 {
for _, part := range seg.parts {
cnt += "#EXT-X-PART:DURATION=" + strconv.FormatFloat(part.renderedDuration.Seconds(), 'f', 5, 64) +
",URI=\"" + part.name() + ".mp4\""
if part.isIndependent {
cnt += ",INDEPENDENT=YES"
}
cnt += "\n"
}
}
cnt += "#EXTINF:" + strconv.FormatFloat(seg.renderedDuration.Seconds(), 'f', 5, 64) + ",\n" +
seg.name() + ".mp4\n"
case *muxerVariantFMP4Gap:
cnt += "#EXT-X-GAP\n" +
"#EXTINF:" + strconv.FormatFloat(seg.renderedDuration.Seconds(), 'f', 5, 64) + ",\n" +
"gap.mp4\n"
}
}
if p.lowLatency {
for _, part := range p.nextSegmentParts {
cnt += "#EXT-X-PART:DURATION=" + strconv.FormatFloat(part.renderedDuration.Seconds(), 'f', 5, 64) +
",URI=\"" + part.name() + ".mp4\""
if part.isIndependent {
cnt += ",INDEPENDENT=YES"
}
cnt += "\n"
}
// preload hint must always be present
// otherwise hls.js goes into a loop
cnt += "#EXT-X-PRELOAD-HINT:TYPE=PART,URI=\"" + fmp4PartName(p.nextPartID) + ".mp4\"\n"
}
return bytes.NewReader([]byte(cnt))
}
func (p *muxerVariantFMP4Playlist) segmentReader(fname string) *MuxerFileResponse {
switch {
case fname == "init.mp4":
p.mutex.Lock()
defer p.mutex.Unlock()
byts, err := mp4InitGenerate(p.videoTrack, p.audioTrack)
if err != nil {
return &MuxerFileResponse{Status: http.StatusInternalServerError}
}
return &MuxerFileResponse{
Status: http.StatusOK,
Header: map[string]string{
"Content-Type": "video/mp4",
},
Body: bytes.NewReader(byts),
}
case strings.HasPrefix(fname, "seg"):
base := strings.TrimSuffix(fname, ".mp4")
p.mutex.Lock()
segment, ok := p.segmentsByName[base]
p.mutex.Unlock()
if !ok {
return &MuxerFileResponse{Status: http.StatusNotFound}
}
return &MuxerFileResponse{
Status: http.StatusOK,
Header: map[string]string{
"Content-Type": "video/mp4",
},
Body: segment.reader(),
}
case strings.HasPrefix(fname, "part"):
base := strings.TrimSuffix(fname, ".mp4")
p.mutex.Lock()
part, ok := p.partsByName[base]
nextPartID := p.nextPartID
p.mutex.Unlock()
if ok {
return &MuxerFileResponse{
Status: http.StatusOK,
Header: map[string]string{
"Content-Type": "video/mp4",
},
Body: part.reader(),
}
}
if fname == fmp4PartName(p.nextPartID) {
p.mutex.Lock()
defer p.mutex.Unlock()
for {
if p.closed {
break
}
if p.nextPartID > nextPartID {
break
}
p.cond.Wait()
}
if p.closed {
return &MuxerFileResponse{Status: http.StatusInternalServerError}
}
return &MuxerFileResponse{
Status: http.StatusOK,
Header: map[string]string{
"Content-Type": "video/mp4",
},
Body: p.partsByName[fmp4PartName(nextPartID)].reader(),
}
}
return &MuxerFileResponse{Status: http.StatusNotFound}
default:
return &MuxerFileResponse{Status: http.StatusNotFound}
}
}
func (p *muxerVariantFMP4Playlist) onSegmentFinalized(segment *muxerVariantFMP4Segment) {
func() {
p.mutex.Lock()
defer p.mutex.Unlock()
// create initial gap
if len(p.segments) == 0 {
for i := 0; i < p.segmentCount; i++ {
p.segments = append(p.segments, &muxerVariantFMP4Gap{
renderedDuration: segment.renderedDuration,
})
}
}
p.segmentsByName[segment.name()] = segment
p.segments = append(p.segments, segment)
p.nextSegmentID = segment.id + 1
p.nextSegmentParts = p.nextSegmentParts[:0]
if len(p.segments) > p.segmentCount {
toDelete := p.segments[0]
if toDeleteSeg, ok := toDelete.(*muxerVariantFMP4Segment); ok {
for _, part := range toDeleteSeg.parts {
delete(p.partsByName, part.name())
}
p.parts = p.parts[len(toDeleteSeg.parts):]
delete(p.segmentsByName, toDeleteSeg.name())
}
p.segments = p.segments[1:]
p.segmentDeleteCount++
}
}()
p.cond.Broadcast()
}
func (p *muxerVariantFMP4Playlist) onPartFinalized(part *muxerVariantFMP4Part) {
func() {
p.mutex.Lock()
defer p.mutex.Unlock()
p.partsByName[part.name()] = part
p.parts = append(p.parts, part)
p.nextSegmentParts = append(p.nextSegmentParts, part)
p.nextPartID = part.id + 1
}()
p.cond.Broadcast()
}

196
internal/hls/muxer_variant_fmp4_segment.go

@ -0,0 +1,196 @@ @@ -0,0 +1,196 @@
package hls
import (
"fmt"
"io"
"strconv"
"time"
"github.com/aler9/gortsplib"
)
type partsReader struct {
parts []*muxerVariantFMP4Part
curPart int
curPos int
}
func (mbr *partsReader) Read(p []byte) (int, error) {
n := 0
lenp := len(p)
for {
if mbr.curPart >= len(mbr.parts) {
return n, io.EOF
}
copied := copy(p[n:], mbr.parts[mbr.curPart].renderedContent[mbr.curPos:])
mbr.curPos += copied
n += copied
if mbr.curPos == len(mbr.parts[mbr.curPart].renderedContent) {
mbr.curPart++
mbr.curPos = 0
}
if n == lenp {
return n, nil
}
}
}
type muxerVariantFMP4Segment struct {
lowLatency bool
id uint64
startTime time.Time
startDTS time.Duration
segmentMaxSize uint64
videoTrack *gortsplib.TrackH264
audioTrack *gortsplib.TrackAAC
genPartID func() uint64
onPartFinalized func(*muxerVariantFMP4Part)
size uint64
parts []*muxerVariantFMP4Part
currentPart *muxerVariantFMP4Part
renderedDuration time.Duration
}
func newMuxerVariantFMP4Segment(
lowLatency bool,
id uint64,
startTime time.Time,
startDTS time.Duration,
segmentMaxSize uint64,
videoTrack *gortsplib.TrackH264,
audioTrack *gortsplib.TrackAAC,
genPartID func() uint64,
onPartFinalized func(*muxerVariantFMP4Part),
) *muxerVariantFMP4Segment {
s := &muxerVariantFMP4Segment{
lowLatency: lowLatency,
id: id,
startTime: startTime,
startDTS: startDTS,
segmentMaxSize: segmentMaxSize,
videoTrack: videoTrack,
audioTrack: audioTrack,
genPartID: genPartID,
onPartFinalized: onPartFinalized,
}
s.currentPart = newMuxerVariantFMP4Part(
s.videoTrack,
s.audioTrack,
s.genPartID(),
s.startDTS,
)
return s
}
func (s *muxerVariantFMP4Segment) name() string {
return "seg" + strconv.FormatUint(s.id, 10)
}
func (s *muxerVariantFMP4Segment) reader() io.Reader {
return &partsReader{parts: s.parts}
}
func (s *muxerVariantFMP4Segment) getRenderedDuration() time.Duration {
return s.renderedDuration
}
func (s *muxerVariantFMP4Segment) finalize(
nextVideoSample *fmp4VideoSample,
nextAudioSample *fmp4AudioSample,
) error {
err := s.currentPart.finalize()
if err != nil {
return err
}
if s.currentPart.renderedContent != nil {
s.onPartFinalized(s.currentPart)
s.parts = append(s.parts, s.currentPart)
}
s.currentPart = nil
if s.videoTrack != nil {
s.renderedDuration = nextVideoSample.pts - s.startDTS
} else {
s.renderedDuration = 0
for _, pa := range s.parts {
s.renderedDuration += pa.renderedDuration
}
}
return nil
}
func (s *muxerVariantFMP4Segment) writeH264(sample *fmp4VideoSample, adjustedPartDuration time.Duration) error {
size := uint64(len(sample.avcc))
if (s.size + size) > s.segmentMaxSize {
return fmt.Errorf("reached maximum segment size")
}
s.currentPart.writeH264(sample)
s.size += size
// switch part
if s.lowLatency &&
s.currentPart.duration() >= adjustedPartDuration {
err := s.currentPart.finalize()
if err != nil {
return err
}
s.parts = append(s.parts, s.currentPart)
s.onPartFinalized(s.currentPart)
s.currentPart = newMuxerVariantFMP4Part(
s.videoTrack,
s.audioTrack,
s.genPartID(),
sample.next.dts,
)
}
return nil
}
func (s *muxerVariantFMP4Segment) writeAAC(sample *fmp4AudioSample, adjustedPartDuration time.Duration) error {
size := uint64(len(sample.au))
if (s.size + size) > s.segmentMaxSize {
return fmt.Errorf("reached maximum segment size")
}
s.currentPart.writeAAC(sample)
s.size += size
// switch part
if s.lowLatency && s.videoTrack == nil &&
s.currentPart.duration() >= adjustedPartDuration {
err := s.currentPart.finalize()
if err != nil {
return err
}
s.parts = append(s.parts, s.currentPart)
s.onPartFinalized(s.currentPart)
s.currentPart = newMuxerVariantFMP4Part(
s.videoTrack,
s.audioTrack,
s.genPartID(),
sample.next.pts,
)
}
return nil
}

344
internal/hls/muxer_variant_fmp4_segmenter.go

@ -0,0 +1,344 @@ @@ -0,0 +1,344 @@
package hls
import (
"bytes"
"time"
"github.com/aler9/gortsplib"
"github.com/aler9/gortsplib/pkg/aac"
"github.com/aler9/gortsplib/pkg/h264"
)
func partDurationIsCompatible(partDuration time.Duration, sampleDuration time.Duration) bool {
if sampleDuration > partDuration {
return false
}
f := (partDuration / sampleDuration)
if (partDuration % sampleDuration) != 0 {
f++
}
f *= sampleDuration
return partDuration > ((f * 85) / 100)
}
func findCompatiblePartDuration(
minPartDuration time.Duration,
sampleDurations map[time.Duration]struct{},
) time.Duration {
i := minPartDuration
for ; i < 5*time.Second; i += 5 * time.Millisecond {
isCompatible := func() bool {
for sd := range sampleDurations {
if !partDurationIsCompatible(i, sd) {
return false
}
}
return true
}()
if isCompatible {
break
}
}
return i
}
type muxerVariantFMP4Segmenter struct {
lowLatency bool
segmentDuration time.Duration
partDuration time.Duration
segmentMaxSize uint64
videoTrack *gortsplib.TrackH264
audioTrack *gortsplib.TrackAAC
onSegmentFinalized func(*muxerVariantFMP4Segment)
onPartFinalized func(*muxerVariantFMP4Part)
currentSegment *muxerVariantFMP4Segment
startPTS time.Duration
videoSPSP *h264.SPS
videoSPS []byte
videoPPS []byte
videoNextSPSP *h264.SPS
videoNextSPS []byte
videoNextPPS []byte
nextSegmentID uint64
nextPartID uint64
nextVideoSample *fmp4VideoSample
nextAudioSample *fmp4AudioSample
videoExpectedPOC uint32
firstSegmentFinalized bool
sampleDurations map[time.Duration]struct{}
adjustedPartDuration time.Duration
}
func newMuxerVariantFMP4Segmenter(
lowLatency bool,
segmentCount int,
segmentDuration time.Duration,
partDuration time.Duration,
segmentMaxSize uint64,
videoTrack *gortsplib.TrackH264,
audioTrack *gortsplib.TrackAAC,
onSegmentFinalized func(*muxerVariantFMP4Segment),
onPartFinalized func(*muxerVariantFMP4Part),
) *muxerVariantFMP4Segmenter {
return &muxerVariantFMP4Segmenter{
lowLatency: lowLatency,
segmentDuration: segmentDuration,
partDuration: partDuration,
segmentMaxSize: segmentMaxSize,
videoTrack: videoTrack,
audioTrack: audioTrack,
onSegmentFinalized: onSegmentFinalized,
onPartFinalized: onPartFinalized,
nextSegmentID: uint64(segmentCount),
sampleDurations: make(map[time.Duration]struct{}),
}
}
func (m *muxerVariantFMP4Segmenter) genSegmentID() uint64 {
id := m.nextSegmentID
m.nextSegmentID++
return id
}
func (m *muxerVariantFMP4Segmenter) genPartID() uint64 {
id := m.nextPartID
m.nextPartID++
return id
}
func (m *muxerVariantFMP4Segmenter) adjustPartDuration(du time.Duration) {
if !m.lowLatency {
return
}
if m.firstSegmentFinalized {
return
}
// iPhone iOS fails if part durations are less than 85% of maximum part duration.
// find a part duration that is compatible with all received sample durations
if _, ok := m.sampleDurations[du]; !ok {
m.sampleDurations[du] = struct{}{}
m.adjustedPartDuration = findCompatiblePartDuration(
m.partDuration,
m.sampleDurations,
)
}
}
func (m *muxerVariantFMP4Segmenter) writeH264(pts time.Duration, nalus [][]byte) error {
avcc, err := h264.AVCCEncode(nalus)
if err != nil {
return err
}
idrPresent := h264.IDRPresent(nalus)
return m.writeH264Entry(&fmp4VideoSample{
pts: pts,
nalus: nalus,
avcc: avcc,
idrPresent: idrPresent,
})
}
func (m *muxerVariantFMP4Segmenter) writeH264Entry(sample *fmp4VideoSample) error {
// put SPS/PPS into a queue in order to sync them with the sample queue
m.videoSPSP = m.videoNextSPSP
m.videoSPS = m.videoNextSPS
m.videoPPS = m.videoNextPPS
spsChanged := false
if sample.idrPresent {
videoNextSPS := m.videoTrack.SPS()
videoNextPPS := m.videoTrack.PPS()
if m.videoSPS == nil ||
!bytes.Equal(m.videoNextSPS, videoNextSPS) ||
!bytes.Equal(m.videoNextPPS, videoNextPPS) {
spsChanged = true
var videoSPSP h264.SPS
err := videoSPSP.Unmarshal(videoNextSPS)
if err != nil {
return err
}
m.videoNextSPSP = &videoSPSP
m.videoNextSPS = videoNextSPS
m.videoNextPPS = videoNextPPS
}
}
sample.pts -= m.startPTS
// put samples into a queue in order to
// - allow to compute sample dts
// - allow to compute sample duration
// - check if next sample is IDR
sample, m.nextVideoSample = m.nextVideoSample, sample
if sample == nil {
return nil
}
sample.next = m.nextVideoSample
now := time.Now()
if m.currentSegment == nil {
// skip groups silently until we find one with a IDR
if !sample.idrPresent {
return nil
}
// create first segment
m.currentSegment = newMuxerVariantFMP4Segment(
m.lowLatency,
m.genSegmentID(),
now,
0,
m.segmentMaxSize,
m.videoTrack,
m.audioTrack,
m.genPartID,
m.onPartFinalized,
)
m.startPTS = sample.pts
sample.pts = 0
sample.next.pts -= m.startPTS
}
err := sample.next.fillDTS(sample, m.videoNextSPSP, &m.videoExpectedPOC)
if err != nil {
return err
}
sample.next.nalus = nil
m.adjustPartDuration(sample.duration())
err = m.currentSegment.writeH264(sample, m.adjustedPartDuration)
if err != nil {
return err
}
// switch segment
if sample.next.idrPresent {
if (sample.next.pts-m.currentSegment.startDTS) >= m.segmentDuration ||
spsChanged {
err := m.currentSegment.finalize(sample.next, nil)
if err != nil {
return err
}
m.onSegmentFinalized(m.currentSegment)
m.firstSegmentFinalized = true
m.currentSegment = newMuxerVariantFMP4Segment(
m.lowLatency,
m.genSegmentID(),
now,
sample.next.pts,
m.segmentMaxSize,
m.videoTrack,
m.audioTrack,
m.genPartID,
m.onPartFinalized,
)
// if SPS changed, reset adjusted part duration
if spsChanged {
m.firstSegmentFinalized = false
m.sampleDurations = make(map[time.Duration]struct{})
}
}
}
return nil
}
func (m *muxerVariantFMP4Segmenter) writeAAC(pts time.Duration, aus [][]byte) error {
for i, au := range aus {
err := m.writeAACEntry(&fmp4AudioSample{
pts: pts + time.Duration(i)*aac.SamplesPerAccessUnit*time.Second/time.Duration(m.audioTrack.ClockRate()),
au: au,
})
if err != nil {
return err
}
}
return nil
}
func (m *muxerVariantFMP4Segmenter) writeAACEntry(sample *fmp4AudioSample) error {
sample.pts -= m.startPTS
// put samples into a queue in order to
// allow to compute the sample duration
sample, m.nextAudioSample = m.nextAudioSample, sample
if sample == nil {
return nil
}
sample.next = m.nextAudioSample
now := time.Now()
if m.videoTrack == nil {
if m.currentSegment == nil {
// create first segment
m.currentSegment = newMuxerVariantFMP4Segment(
m.lowLatency,
m.genSegmentID(),
now,
0,
m.segmentMaxSize,
m.videoTrack,
m.audioTrack,
m.genPartID,
m.onPartFinalized,
)
m.startPTS = sample.pts
sample.pts = 0
sample.next.pts -= m.startPTS
}
} else {
// wait for the video track
if m.currentSegment == nil {
return nil
}
}
m.adjustPartDuration(sample.duration())
err := m.currentSegment.writeAAC(sample, m.adjustedPartDuration)
if err != nil {
return err
}
// switch segment
if m.videoTrack == nil &&
(sample.next.pts-m.currentSegment.startDTS) >= m.segmentDuration {
err := m.currentSegment.finalize(nil, sample.next)
if err != nil {
return err
}
m.onSegmentFinalized(m.currentSegment)
m.firstSegmentFinalized = true
m.currentSegment = newMuxerVariantFMP4Segment(
m.lowLatency,
m.genSegmentID(),
now,
sample.next.pts,
m.segmentMaxSize,
m.videoTrack,
m.audioTrack,
m.genPartID,
m.onPartFinalized,
)
}
return nil
}

52
internal/hls/muxer_variant_mpegts.go

@ -0,0 +1,52 @@ @@ -0,0 +1,52 @@
package hls
import (
"time"
"github.com/aler9/gortsplib"
)
type muxerVariantMPEGTS struct {
playlist *muxerVariantMPEGTSPlaylist
segmenter *muxerVariantMPEGTSSegmenter
}
func newMuxerVariantMPEGTS(
segmentCount int,
segmentDuration time.Duration,
segmentMaxSize uint64,
videoTrack *gortsplib.TrackH264,
audioTrack *gortsplib.TrackAAC,
) *muxerVariantMPEGTS {
v := &muxerVariantMPEGTS{}
v.playlist = newMuxerVariantMPEGTSPlaylist(segmentCount)
v.segmenter = newMuxerVariantMPEGTSSegmenter(
segmentDuration,
segmentMaxSize,
videoTrack,
audioTrack,
func(seg *muxerVariantMPEGTSSegment) {
v.playlist.pushSegment(seg)
},
)
return v
}
func (v *muxerVariantMPEGTS) close() {
v.playlist.close()
}
func (v *muxerVariantMPEGTS) writeH264(pts time.Duration, nalus [][]byte) error {
return v.segmenter.writeH264(pts, nalus)
}
func (v *muxerVariantMPEGTS) writeAAC(pts time.Duration, aus [][]byte) error {
return v.segmenter.writeAAC(pts, aus)
}
func (v *muxerVariantMPEGTS) file(name string, msn string, part string, skip string) *MuxerFileResponse {
return v.playlist.file(name)
}

146
internal/hls/muxer_variant_mpegts_playlist.go

@ -0,0 +1,146 @@ @@ -0,0 +1,146 @@
package hls
import (
"bytes"
"io"
"math"
"net/http"
"strconv"
"strings"
"sync"
)
type muxerVariantMPEGTSPlaylist struct {
segmentCount int
mutex sync.Mutex
cond *sync.Cond
closed bool
segments []*muxerVariantMPEGTSSegment
segmentByName map[string]*muxerVariantMPEGTSSegment
segmentDeleteCount int
}
func newMuxerVariantMPEGTSPlaylist(segmentCount int) *muxerVariantMPEGTSPlaylist {
p := &muxerVariantMPEGTSPlaylist{
segmentCount: segmentCount,
segmentByName: make(map[string]*muxerVariantMPEGTSSegment),
}
p.cond = sync.NewCond(&p.mutex)
return p
}
func (p *muxerVariantMPEGTSPlaylist) close() {
func() {
p.mutex.Lock()
defer p.mutex.Unlock()
p.closed = true
}()
p.cond.Broadcast()
}
func (p *muxerVariantMPEGTSPlaylist) file(name string) *MuxerFileResponse {
switch {
case name == "stream.m3u8":
return p.playlistReader()
case strings.HasSuffix(name, ".ts"):
return p.segmentReader(name)
default:
return &MuxerFileResponse{Status: http.StatusNotFound}
}
}
func (p *muxerVariantMPEGTSPlaylist) playlist() io.Reader {
cnt := "#EXTM3U\n"
cnt += "#EXT-X-VERSION:3\n"
cnt += "#EXT-X-ALLOW-CACHE:NO\n"
targetDuration := func() uint {
ret := uint(0)
// EXTINF, when rounded to the nearest integer, must be <= EXT-X-TARGETDURATION
for _, s := range p.segments {
v2 := uint(math.Round(s.duration().Seconds()))
if v2 > ret {
ret = v2
}
}
return ret
}()
cnt += "#EXT-X-TARGETDURATION:" + strconv.FormatUint(uint64(targetDuration), 10) + "\n"
cnt += "#EXT-X-MEDIA-SEQUENCE:" + strconv.FormatInt(int64(p.segmentDeleteCount), 10) + "\n"
cnt += "\n"
for _, s := range p.segments {
cnt += "#EXT-X-PROGRAM-DATE-TIME:" + s.startTime.Format("2006-01-02T15:04:05.999Z07:00") + "\n" +
"#EXTINF:" + strconv.FormatFloat(s.duration().Seconds(), 'f', -1, 64) + ",\n" +
s.name + ".ts\n"
}
return bytes.NewReader([]byte(cnt))
}
func (p *muxerVariantMPEGTSPlaylist) playlistReader() *MuxerFileResponse {
p.mutex.Lock()
defer p.mutex.Unlock()
if !p.closed && len(p.segments) == 0 {
p.cond.Wait()
}
if p.closed {
return &MuxerFileResponse{Status: http.StatusInternalServerError}
}
return &MuxerFileResponse{
Status: http.StatusOK,
Header: map[string]string{
"Content-Type": `audio/mpegURL`,
},
Body: p.playlist(),
}
}
func (p *muxerVariantMPEGTSPlaylist) segmentReader(fname string) *MuxerFileResponse {
base := strings.TrimSuffix(fname, ".ts")
p.mutex.Lock()
f, ok := p.segmentByName[base]
p.mutex.Unlock()
if !ok {
return &MuxerFileResponse{Status: http.StatusNotFound}
}
return &MuxerFileResponse{
Status: http.StatusOK,
Header: map[string]string{
"Content-Type": "video/MP2T",
},
Body: f.reader(),
}
}
func (p *muxerVariantMPEGTSPlaylist) pushSegment(t *muxerVariantMPEGTSSegment) {
func() {
p.mutex.Lock()
defer p.mutex.Unlock()
p.segmentByName[t.name] = t
p.segments = append(p.segments, t)
if len(p.segments) > p.segmentCount {
delete(p.segmentByName, p.segments[0].name)
p.segments = p.segments[1:]
p.segmentDeleteCount++
}
}()
p.cond.Broadcast()
}

72
internal/hls/muxer_ts_segment.go → internal/hls/muxer_variant_mpegts_segment.go

@ -8,6 +8,8 @@ import ( @@ -8,6 +8,8 @@ import (
"time"
"github.com/aler9/gortsplib"
"github.com/aler9/gortsplib/pkg/aac"
"github.com/aler9/gortsplib/pkg/h264"
"github.com/asticode/go-astits"
)
@ -16,9 +18,10 @@ const ( @@ -16,9 +18,10 @@ const (
pcrOffset = 500 * time.Millisecond
)
type muxerTSSegment struct {
hlsSegmentMaxSize uint64
type muxerVariantMPEGTSSegment struct {
segmentMaxSize uint64
videoTrack *gortsplib.TrackH264
audioTrack *gortsplib.TrackAAC
writeData func(*astits.MuxerData) (int, error)
startTime time.Time
@ -30,18 +33,20 @@ type muxerTSSegment struct { @@ -30,18 +33,20 @@ type muxerTSSegment struct {
audioAUCount int
}
func newMuxerTSSegment(
now time.Time,
hlsSegmentMaxSize uint64,
func newMuxerVariantMPEGTSSegment(
startTime time.Time,
segmentMaxSize uint64,
videoTrack *gortsplib.TrackH264,
audioTrack *gortsplib.TrackAAC,
writeData func(*astits.MuxerData) (int, error),
) *muxerTSSegment {
t := &muxerTSSegment{
hlsSegmentMaxSize: hlsSegmentMaxSize,
) *muxerVariantMPEGTSSegment {
t := &muxerVariantMPEGTSSegment{
segmentMaxSize: segmentMaxSize,
videoTrack: videoTrack,
audioTrack: audioTrack,
writeData: writeData,
startTime: now,
name: strconv.FormatInt(now.Unix(), 10),
startTime: startTime,
name: strconv.FormatInt(startTime.Unix(), 10),
}
// WriteTable() is called automatically when WriteData() is called with
@ -52,29 +57,37 @@ func newMuxerTSSegment( @@ -52,29 +57,37 @@ func newMuxerTSSegment(
return t
}
func (t *muxerTSSegment) duration() time.Duration {
func (t *muxerVariantMPEGTSSegment) duration() time.Duration {
return t.endPTS - *t.startPTS
}
func (t *muxerTSSegment) write(p []byte) (int, error) {
if uint64(len(p)+t.buf.Len()) > t.hlsSegmentMaxSize {
func (t *muxerVariantMPEGTSSegment) write(p []byte) (int, error) {
if uint64(len(p)+t.buf.Len()) > t.segmentMaxSize {
return 0, fmt.Errorf("reached maximum segment size")
}
return t.buf.Write(p)
}
func (t *muxerTSSegment) reader() io.Reader {
func (t *muxerVariantMPEGTSSegment) reader() io.Reader {
return bytes.NewReader(t.buf.Bytes())
}
func (t *muxerTSSegment) writeH264(
func (t *muxerVariantMPEGTSSegment) writeH264(
pcr time.Duration,
dts time.Duration,
pts time.Duration,
idrPresent bool,
enc []byte,
nalus [][]byte,
) error {
// prepend an AUD. This is required by video.js and iOS
nalus = append([][]byte{{byte(h264.NALUTypeAccessUnitDelimiter), 240}}, nalus...)
enc, err := h264.AnnexBEncode(nalus)
if err != nil {
return err
}
var af *astits.PacketAdaptationField
if idrPresent {
@ -106,7 +119,7 @@ func (t *muxerTSSegment) writeH264( @@ -106,7 +119,7 @@ func (t *muxerTSSegment) writeH264(
oh.PTS = &astits.ClockReference{Base: int64((pts + pcrOffset).Seconds() * 90000)}
}
_, err := t.writeData(&astits.MuxerData{
_, err = t.writeData(&astits.MuxerData{
PID: 256,
AdaptationField: af,
PES: &astits.PESData{
@ -132,12 +145,27 @@ func (t *muxerTSSegment) writeH264( @@ -132,12 +145,27 @@ func (t *muxerTSSegment) writeH264(
return nil
}
func (t *muxerTSSegment) writeAAC(
func (t *muxerVariantMPEGTSSegment) writeAAC(
pcr time.Duration,
pts time.Duration,
enc []byte,
ausLen int,
aus [][]byte,
) error {
pkts := make([]*aac.ADTSPacket, len(aus))
for i, au := range aus {
pkts[i] = &aac.ADTSPacket{
Type: t.audioTrack.Type(),
SampleRate: t.audioTrack.ClockRate(),
ChannelCount: t.audioTrack.ChannelCount(),
AU: au,
}
}
enc, err := aac.EncodeADTS(pkts)
if err != nil {
return err
}
af := &astits.PacketAdaptationField{
RandomAccessIndicator: true,
}
@ -152,7 +180,7 @@ func (t *muxerTSSegment) writeAAC( @@ -152,7 +180,7 @@ func (t *muxerTSSegment) writeAAC(
t.pcrSendCounter--
}
_, err := t.writeData(&astits.MuxerData{
_, err = t.writeData(&astits.MuxerData{
PID: 257,
AdaptationField: af,
PES: &astits.PESData{
@ -173,7 +201,7 @@ func (t *muxerTSSegment) writeAAC( @@ -173,7 +201,7 @@ func (t *muxerTSSegment) writeAAC(
}
if t.videoTrack == nil {
t.audioAUCount += ausLen
t.audioAUCount += len(aus)
}
if t.startPTS == nil {

169
internal/hls/muxer_variant_mpegts_segmenter.go

@ -0,0 +1,169 @@ @@ -0,0 +1,169 @@
package hls
import (
"context"
"time"
"github.com/aler9/gortsplib"
"github.com/aler9/gortsplib/pkg/h264"
"github.com/asticode/go-astits"
)
const (
mpegtsSegmentMinAUCount = 100
)
type writerFunc func(p []byte) (int, error)
func (f writerFunc) Write(p []byte) (int, error) {
return f(p)
}
type muxerVariantMPEGTSSegmenter struct {
segmentDuration time.Duration
segmentMaxSize uint64
videoTrack *gortsplib.TrackH264
audioTrack *gortsplib.TrackAAC
onSegmentReady func(*muxerVariantMPEGTSSegment)
writer *astits.Muxer
currentSegment *muxerVariantMPEGTSSegment
videoDTSEst *h264.DTSEstimator
startPCR time.Time
startPTS time.Duration
}
func newMuxerVariantMPEGTSSegmenter(
segmentDuration time.Duration,
segmentMaxSize uint64,
videoTrack *gortsplib.TrackH264,
audioTrack *gortsplib.TrackAAC,
onSegmentReady func(*muxerVariantMPEGTSSegment),
) *muxerVariantMPEGTSSegmenter {
m := &muxerVariantMPEGTSSegmenter{
segmentDuration: segmentDuration,
segmentMaxSize: segmentMaxSize,
videoTrack: videoTrack,
audioTrack: audioTrack,
onSegmentReady: onSegmentReady,
}
m.writer = astits.NewMuxer(
context.Background(),
writerFunc(func(p []byte) (int, error) {
return m.currentSegment.write(p)
}))
if videoTrack != nil {
m.writer.AddElementaryStream(astits.PMTElementaryStream{
ElementaryPID: 256,
StreamType: astits.StreamTypeH264Video,
})
}
if audioTrack != nil {
m.writer.AddElementaryStream(astits.PMTElementaryStream{
ElementaryPID: 257,
StreamType: astits.StreamTypeAACAudio,
})
}
if videoTrack != nil {
m.writer.SetPCRPID(256)
} else {
m.writer.SetPCRPID(257)
}
return m
}
func (m *muxerVariantMPEGTSSegmenter) writeH264(pts time.Duration, nalus [][]byte) error {
now := time.Now()
idrPresent := h264.IDRPresent(nalus)
if m.currentSegment == nil {
// skip groups silently until we find one with a IDR
if !idrPresent {
return nil
}
// create first segment
m.currentSegment = newMuxerVariantMPEGTSSegment(now, m.segmentMaxSize,
m.videoTrack, m.audioTrack, m.writer.WriteData)
m.startPCR = now
m.videoDTSEst = h264.NewDTSEstimator()
m.startPTS = pts
pts = 0
} else {
pts -= m.startPTS
// switch segment
if idrPresent &&
m.currentSegment.startPTS != nil &&
(pts-*m.currentSegment.startPTS) >= m.segmentDuration {
m.currentSegment.endPTS = pts
m.onSegmentReady(m.currentSegment)
m.currentSegment = newMuxerVariantMPEGTSSegment(now, m.segmentMaxSize,
m.videoTrack, m.audioTrack, m.writer.WriteData)
}
}
dts := m.videoDTSEst.Feed(pts)
err := m.currentSegment.writeH264(now.Sub(m.startPCR), dts,
pts, idrPresent, nalus)
if err != nil {
if m.currentSegment.buf.Len() > 0 {
m.onSegmentReady(m.currentSegment)
}
m.currentSegment = nil
return err
}
return nil
}
func (m *muxerVariantMPEGTSSegmenter) writeAAC(pts time.Duration, aus [][]byte) error {
now := time.Now()
if m.videoTrack == nil {
if m.currentSegment == nil {
// create first segment
m.currentSegment = newMuxerVariantMPEGTSSegment(now, m.segmentMaxSize,
m.videoTrack, m.audioTrack, m.writer.WriteData)
m.startPCR = now
m.startPTS = pts
pts = 0
} else {
pts -= m.startPTS
// switch segment
if m.currentSegment.audioAUCount >= mpegtsSegmentMinAUCount &&
m.currentSegment.startPTS != nil &&
(pts-*m.currentSegment.startPTS) >= m.segmentDuration {
m.currentSegment.endPTS = pts
m.onSegmentReady(m.currentSegment)
m.currentSegment = newMuxerVariantMPEGTSSegment(now, m.segmentMaxSize,
m.videoTrack, m.audioTrack, m.writer.WriteData)
}
}
} else {
// wait for the video track
if m.currentSegment == nil {
return nil
}
pts -= m.startPTS
}
err := m.currentSegment.writeAAC(now.Sub(m.startPCR), pts, aus)
if err != nil {
if m.currentSegment.buf.Len() > 0 {
m.onSegmentReady(m.currentSegment)
}
m.currentSegment = nil
return err
}
return nil
}

27
rtsp-simple-server.yml

@ -112,20 +112,43 @@ hlsAddress: :8888 @@ -112,20 +112,43 @@ hlsAddress: :8888
# By default, HLS is generated only when requested by a user.
# This option allows to generate it always, avoiding the delay between request and generation.
hlsAlwaysRemux: no
# Variant of the HLS protocol to use. Available options are:
# * mpegts - uses MPEG-TS segments, for maximum compatibility.
# * fmp4 - uses fragmented MP4 segments, more efficient.
# * lowLatency - uses Low-Latency HLS.
hlsVariant: mpegts
# Number of HLS segments to keep on the server.
# Segments allow to seek through the stream.
# Their number doesn't influence latency.
hlsSegmentCount: 3
hlsSegmentCount: 7
# Minimum duration of each segment.
# A player usually puts 3 segments in a buffer before reproducing the stream.
# The final segment duration is also influenced by the interval between IDR frames,
# since the server changes the segment duration to include at least a IDR frame in each one.
# since the server changes the duration in order to include at least one IDR frame
# in each segment.
hlsSegmentDuration: 1s
# Minimum duration of each part.
# A player usually puts 3 parts in a buffer before reproducing the stream.
# Parts are used in Low-Latency HLS in place of segments.
# Part duration is influenced by the distance between video/audio samples
# and is adjusted in order to produce segments with a similar duration.
hlsPartDuration: 200ms
# Maximum size of each segment.
# This prevents RAM exhaustion.
hlsSegmentMaxSize: 50M
# Value of the Access-Control-Allow-Origin header provided in every HTTP response.
# This allows to play the HLS stream from an external website.
hlsAllowOrigin: '*'
# Enable TLS/HTTPS on the HLS server.
# This is required for Low-Latency HLS.
hlsEncryption: no
# Path to the server key. This is needed only when encryption is yes.
# This can be generated with:
# openssl genrsa -out server.key 2048
# openssl req -new -x509 -sha256 -key server.key -out server.crt -days 3650
hlsServerKey: server.key
# Path to the server certificate.
hlsServerCert: server.crt
###############################################
# Path parameters

Loading…
Cancel
Save