commit af318643aefae52ff49b5196d4568aea57b58126 Author: GyoungSu Date: Sat Sep 7 03:02:59 2024 +0900 Initial commit diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml new file mode 100644 index 0000000..3da906c --- /dev/null +++ b/.github/workflows/go.yml @@ -0,0 +1,28 @@ +# This workflow will build a golang project +# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-go + +name: Go + +on: + push: + branches: [ "master" ] + pull_request: + branches: [ "master" ] + +jobs: + + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v4 + with: + go-version: '1.20' + +# - name: Build +# run: go build -v ./... +# +# - name: Test +# run: go test -v ./... diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..25ca32c --- /dev/null +++ b/Dockerfile @@ -0,0 +1,30 @@ +# ubuntu +#FROM ubuntu:latest +FROM golang:1.21-bullseye +RUN apt-get update +RUN apt-get upgrade -y +RUN apt-get install -y build-essential git pkg-config libunistring-dev libaom-dev libdav1d-dev bzip2 nasm wget yasm ca-certificates +COPY install-ffmpeg.sh /install-ffmpeg.sh +RUN chmod +x /install-ffmpeg.sh && /install-ffmpeg.sh +# 로컬에서 테스트하기 위한 workspace container +# 1. ffmpeg build를 위한 환경 설정 +ENV PKG_CONFIG_PATH=/ffmpeg_build/lib/pkgconfig:${PKG_CONFIG_PATH} +#ENV LD_LIBRARY_PATH=/openh264/lib +# Install dependencies + +#COPY --from=build /usr/local/go /usr/local/go +ENV PATH="/usr/local/go/bin:${PATH}" +COPY ./ /app +WORKDIR /app +RUN ls . +RUN go mod download +RUN go build -o /app/bin/liveflow +RUN cp config.toml /app/bin/config.toml +RUN cp index.html /app/bin/index.html + +RUN mkdir /app/bin/videos +WORKDIR /app/bin +ENTRYPOINT ["/app/bin/liveflow"] + + +#ENTRYPOINT ["pkg-config", "--cflags", "--libs", "libavcodec", "libavformat", "libavutil", "libswscale"] \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..c5199ac --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 Han Gyoung-Su + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..e69de29 diff --git a/README.md b/README.md new file mode 100644 index 0000000..f231cfc --- /dev/null +++ b/README.md @@ -0,0 +1,71 @@ +# Liveflow + +**Liveflow** is a flexible and modular live streaming solution that supports various input and output formats. It is designed to handle real-time media streams efficiently, providing support for multiple input and output formats. + +## Features + +- **Modular Design:** Each input and output module is independent, making it easy to extend and add new formats. +- **Real-Time Processing:** Optimized for handling real-time media streams. +- **FFmpeg Dependency:** Leverages the power of the FFmpeg library for media processing. + +## Input and Output Formats + +| | **HLS** | **WHEP** | **MKV** | **MP4** | +|------------|---------|----------|---------|---------| +| **RTMP** | ✅ | ✅ | ✅ | ✅ | +| **WHIP** | ✅ | ✅ | ✅ | ✅ | + + + +## Requirements + +- **FFmpeg**: This repository depends on FFmpeg. Please ensure FFmpeg is installed on your system. + +## Installation + +0. Make sure you have FFmpeg installed on your system. You can install FFmpeg using the following commands: + +### MAC +```bash +brew install ffmpeg # version 7 +git clone https://github.com/hsnks100/liveflow.git +cd liveflow +go build && ./liveflow +``` +### Docker Compose +```bash +docker-compose up liveflow -d --force-recreate --build +``` + +## Usage + +To start processing a stream: + +You can select way to stream from the following options: + +### WHIP broadcast +OBS +- server: http://127.0.0.1:5555/whip +- bearer token: test + +### RTMP broadcast +OBS +- server: rtmp://127.0.0.1:1930/live +- stream key: test + +So, you can watch the stream from the following options: +### HLS +- url: http://127.0.0.1:8044/hls/test/master.m3u8 + +### WHEP +- url: http://127.0.0.1:5555/ +- bearer token: test +- click subscribe button. + +### MKV, MP4 +- docker: ~/.store +- local: $(repo)/videos + +## License + +This project is licensed under the MIT License. See the [LICENSE](LICENSE) file for more details. diff --git a/config.toml b/config.toml new file mode 100644 index 0000000..13df586 --- /dev/null +++ b/config.toml @@ -0,0 +1,8 @@ +[whep] +port = 5555 +[rtmp] +port = 1930 +[hls] +port = 8044 +[docker] +mode = false \ No newline at end of file diff --git a/config/config.go b/config/config.go new file mode 100644 index 0000000..7748959 --- /dev/null +++ b/config/config.go @@ -0,0 +1,17 @@ +package config + +// Struct to hold the configuration +type Config struct { + Whep ServerConfig `mapstructure:"whep"` + RTMP ServerConfig `mapstructure:"rtmp"` + HLS ServerConfig `mapstructure:"hls"` + Docker DockerConfig `mapstructure:"docker"` +} + +type ServerConfig struct { + Port int `mapstructure:"port"` +} + +type DockerConfig struct { + Mode bool `mapstructure:"mode"` +} diff --git a/docker-compose.yaml b/docker-compose.yaml new file mode 100644 index 0000000..ea74de6 --- /dev/null +++ b/docker-compose.yaml @@ -0,0 +1,19 @@ +# docker-compose +version: "3.4" +services: + liveflow: + image: liveflow_custom:latest + stdin_open: true # docker run -i + tty: true # docker run -t + volumes: + - "~/.store:/app/bin/videos" + ports: + - "8044:8044" + - "5555:5555" + - "1930:1930" + - "30000-31000:30000-31000/udp" + environment: + DOCKER_MODE: "true" + build: + context: ./ + dockerfile: Dockerfile diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..93fbcc8 --- /dev/null +++ b/go.mod @@ -0,0 +1,74 @@ +module liveflow + +go 1.21 + +require ( + github.com/asticode/go-astiav v0.19.0 + github.com/at-wat/ebml-go v0.17.1 + github.com/bluenviron/gohlslib v1.4.0 + github.com/deepch/vdk v0.0.27 + github.com/labstack/echo/v4 v4.12.0 + github.com/pion/interceptor v0.1.29 + github.com/pion/rtp v1.8.9 + github.com/pion/sdp/v3 v3.0.9 + github.com/pion/webrtc/v3 v3.3.0 + github.com/pkg/errors v0.9.1 + github.com/sirupsen/logrus v1.9.3 + github.com/spf13/viper v1.19.0 + github.com/yapingcat/gomedia v0.0.0-20231026175559-9269ffbdaadd + github.com/yutopp/go-flv v0.3.1 + github.com/yutopp/go-rtmp v0.0.7 + golang.org/x/exp v0.0.0-20230905200255-921286631fa9 +) + +require ( + github.com/abema/go-mp4 v1.2.0 // indirect + github.com/asticode/go-astikit v0.43.0 // indirect + github.com/asticode/go-astits v1.13.0 // indirect + github.com/bluenviron/mediacommon v1.11.1-0.20240525122142-20163863aa75 // indirect + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect + github.com/fsnotify/fsnotify v1.7.0 // indirect + github.com/google/uuid v1.4.0 // indirect + github.com/hashicorp/errwrap v1.1.0 // indirect + github.com/hashicorp/go-multierror v1.1.1 // indirect + github.com/hashicorp/hcl v1.0.0 // indirect + github.com/labstack/gommon v0.4.2 // indirect + github.com/magiconair/properties v1.8.7 // indirect + github.com/mattn/go-colorable v0.1.13 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mitchellh/mapstructure v1.5.0 // indirect + github.com/pelletier/go-toml/v2 v2.2.2 // indirect + github.com/pion/datachannel v1.5.8 // indirect + github.com/pion/dtls/v2 v2.2.12 // indirect + github.com/pion/ice/v2 v2.3.34 // indirect + github.com/pion/logging v0.2.2 // indirect + github.com/pion/mdns v0.0.12 // indirect + github.com/pion/randutil v0.1.0 // indirect + github.com/pion/rtcp v1.2.14 // indirect + github.com/pion/sctp v1.8.19 // indirect + github.com/pion/srtp/v2 v2.0.20 // indirect + github.com/pion/stun v0.6.1 // indirect + github.com/pion/transport/v2 v2.2.10 // indirect + github.com/pion/turn/v2 v2.1.6 // indirect + github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect + github.com/sagikazarmark/locafero v0.4.0 // indirect + github.com/sagikazarmark/slog-shim v0.1.0 // indirect + github.com/sourcegraph/conc v0.3.0 // indirect + github.com/spf13/afero v1.11.0 // indirect + github.com/spf13/cast v1.6.0 // indirect + github.com/spf13/pflag v1.0.5 // indirect + github.com/stretchr/testify v1.9.0 // indirect + github.com/subosito/gotenv v1.6.0 // indirect + github.com/valyala/bytebufferpool v1.0.0 // indirect + github.com/valyala/fasttemplate v1.2.2 // indirect + github.com/wlynxg/anet v0.0.3 // indirect + github.com/yutopp/go-amf0 v0.1.0 // indirect + go.uber.org/atomic v1.9.0 // indirect + go.uber.org/multierr v1.9.0 // indirect + golang.org/x/crypto v0.22.0 // indirect + golang.org/x/net v0.24.0 // indirect + golang.org/x/sys v0.19.0 // indirect + golang.org/x/text v0.14.0 // indirect + gopkg.in/ini.v1 v1.67.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..1fd9f6e --- /dev/null +++ b/go.sum @@ -0,0 +1,249 @@ +github.com/abema/go-mp4 v1.2.0 h1:gi4X8xg/m179N/J15Fn5ugywN9vtI6PLk6iLldHGLAk= +github.com/abema/go-mp4 v1.2.0/go.mod h1:vPl9t5ZK7K0x68jh12/+ECWBCXoWuIDtNgPtU2f04ws= +github.com/asticode/go-astiav v0.19.0 h1:tAyTiYCmwBuApfCZRBMdaOkyhfxN39ybvqXGZkw4OCk= +github.com/asticode/go-astiav v0.19.0/go.mod h1:K7D8UC6GeQt85FUxk2KVwYxHnotrxuEnp5evkkudc2s= +github.com/asticode/go-astikit v0.30.0/go.mod h1:h4ly7idim1tNhaVkdVBeXQZEE3L0xblP7fCWbgwipF0= +github.com/asticode/go-astikit v0.43.0 h1:BgWdgIqYxUtpZYx3E4mpRvGZdaE0IRtigv0nLvWBihU= +github.com/asticode/go-astikit v0.43.0/go.mod h1:h4ly7idim1tNhaVkdVBeXQZEE3L0xblP7fCWbgwipF0= +github.com/asticode/go-astits v1.13.0 h1:XOgkaadfZODnyZRR5Y0/DWkA9vrkLLPLeeOvDwfKZ1c= +github.com/asticode/go-astits v1.13.0/go.mod h1:QSHmknZ51pf6KJdHKZHJTLlMegIrhega3LPWz3ND/iI= +github.com/at-wat/ebml-go v0.17.1 h1:pWG1NOATCFu1hnlowCzrA1VR/3s8tPY6qpU+2FwW7X4= +github.com/at-wat/ebml-go v0.17.1/go.mod h1:w1cJs7zmGsb5nnSvhWGKLCxvfu4FVx5ERvYDIalj1ww= +github.com/bluenviron/gohlslib v1.4.0 h1:3a9W1x8eqlxJUKt1sJCunPGtti5ALIY2ik4GU0RVe7E= +github.com/bluenviron/gohlslib v1.4.0/go.mod h1:q5ZElzNw5GRbV1VEI45qkcPbKBco6BP58QEY5HyFsmo= +github.com/bluenviron/mediacommon v1.11.1-0.20240525122142-20163863aa75 h1:5P8Um+ySuwZApuVS9gI6U0MnrIFybTfLrZSqV2ie5lA= +github.com/bluenviron/mediacommon v1.11.1-0.20240525122142-20163863aa75/go.mod h1:HDyW2CzjvhYJXtdxstdFPio3G0qSocPhqkhUt/qffec= +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/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/deepch/vdk v0.0.27 h1:j/SHaTiZhA47wRpaue8NRp7P9xwOOO/lunxrDJBwcao= +github.com/deepch/vdk v0.0.27/go.mod h1:JlgGyR2ld6+xOIHa7XAxJh+stSDBAkdNvIPkUIdIywk= +github.com/fortytw2/leaktest v1.2.0 h1:cj6GCiwJDH7l3tMHLjZDo0QqPtrXJiWSI9JgpeQKw+Q= +github.com/fortytw2/leaktest v1.2.0/go.mod h1:jDsjWgpAGjm2CA7WthBh/CdZYEPF31XHquHwclZch5g= +github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= +github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= +github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA= +github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM= +github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= +github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/google/uuid v1.3.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/google/uuid v1.4.0 h1:MtMxsa51/r9yyhkyLsVeVt0B+BGQZzpQiTQ4eHZ8bc4= +github.com/google/uuid v1.4.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= +github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= +github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= +github.com/hashicorp/go-multierror v1.1.0/go.mod h1:spPvp8C1qA32ftKqdAHm4hHTbPw+vmowP0z+KUhOZdA= +github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= +github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= +github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= +github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +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/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/labstack/echo/v4 v4.12.0 h1:IKpw49IMryVB2p1a4dzwlhP1O2Tf2E0Ir/450lH+kI0= +github.com/labstack/echo/v4 v4.12.0/go.mod h1:UP9Cr2DJXbOK3Kr9ONYzNowSh7HP0aG0ShAyycHSJvM= +github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0= +github.com/labstack/gommon v0.4.2/go.mod h1:QlUFxVM+SNXhDL/Z7YhocGIBYOiwB0mXm1+1bAPHPyU= +github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY= +github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= +github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mitchellh/mapstructure v1.4.1/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= +github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= +github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= +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/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM= +github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs= +github.com/pion/datachannel v1.5.8 h1:ph1P1NsGkazkjrvyMfhRBUAWMxugJjq2HfQifaOoSNo= +github.com/pion/datachannel v1.5.8/go.mod h1:PgmdpoaNBLX9HNzNClmdki4DYW5JtI7Yibu8QzbL3tI= +github.com/pion/dtls/v2 v2.2.7/go.mod h1:8WiMkebSHFD0T+dIU+UeBaoV7kDhOW5oDCzZ7WZ/F9s= +github.com/pion/dtls/v2 v2.2.12 h1:KP7H5/c1EiVAAKUmXyCzPiQe5+bCJrpOeKg/L05dunk= +github.com/pion/dtls/v2 v2.2.12/go.mod h1:d9SYc9fch0CqK90mRk1dC7AkzzpwJj6u2GU3u+9pqFE= +github.com/pion/ice/v2 v2.3.34 h1:Ic1ppYCj4tUOcPAp76U6F3fVrlSw8A9JtRXLqw6BbUM= +github.com/pion/ice/v2 v2.3.34/go.mod h1:mBF7lnigdqgtB+YHkaY/Y6s6tsyRyo4u4rPGRuOjUBQ= +github.com/pion/interceptor v0.1.29 h1:39fsnlP1U8gw2JzOFWdfCU82vHvhW9o0rZnZF56wF+M= +github.com/pion/interceptor v0.1.29/go.mod h1:ri+LGNjRUc5xUNtDEPzfdkmSqISixVTBF/z/Zms/6T4= +github.com/pion/logging v0.2.2 h1:M9+AIj/+pxNsDfAT64+MAVgJO0rsyLnoJKCqf//DoeY= +github.com/pion/logging v0.2.2/go.mod h1:k0/tDVsRCX2Mb2ZEmTqNa7CWsQPc+YYCB7Q+5pahoms= +github.com/pion/mdns v0.0.12 h1:CiMYlY+O0azojWDmxdNr7ADGrnZ+V6Ilfner+6mSVK8= +github.com/pion/mdns v0.0.12/go.mod h1:VExJjv8to/6Wqm1FXK+Ii/Z9tsVk/F5sD/N70cnYFbk= +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.12/go.mod h1:sn6qjxvnwyAkkPzPULIbVqSKI5Dv54Rv7VG0kNxh9L4= +github.com/pion/rtcp v1.2.14 h1:KCkGV3vJ+4DAJmvP0vaQShsb0xkRfWkO540Gy102KyE= +github.com/pion/rtcp v1.2.14/go.mod h1:sn6qjxvnwyAkkPzPULIbVqSKI5Dv54Rv7VG0kNxh9L4= +github.com/pion/rtp v1.8.3/go.mod h1:pBGHaFt/yW7bf1jjWAoUjpSNoDnw98KTMg+jWWvziqU= +github.com/pion/rtp v1.8.9 h1:E2HX740TZKaqdcPmf4pw6ZZuG8u5RlMMt+l3dxeu6Wk= +github.com/pion/rtp v1.8.9/go.mod h1:pBGHaFt/yW7bf1jjWAoUjpSNoDnw98KTMg+jWWvziqU= +github.com/pion/sctp v1.8.19 h1:2CYuw+SQ5vkQ9t0HdOPccsCz1GQMDuVy5PglLgKVBW8= +github.com/pion/sctp v1.8.19/go.mod h1:P6PbDVA++OJMrVNg2AL3XtYHV4uD6dvfyOovCgMs0PE= +github.com/pion/sdp/v3 v3.0.9 h1:pX++dCHoHUwq43kuwf3PyJfHlwIj4hXA7Vrifiq0IJY= +github.com/pion/sdp/v3 v3.0.9/go.mod h1:B5xmvENq5IXJimIO4zfp6LAe1fD9N+kFv+V/1lOdz8M= +github.com/pion/srtp/v2 v2.0.20 h1:HNNny4s+OUmG280ETrCdgFndp4ufx3/uy85EawYEhTk= +github.com/pion/srtp/v2 v2.0.20/go.mod h1:0KJQjA99A6/a0DOVTu1PhDSw0CXF2jTkqOoMg3ODqdA= +github.com/pion/stun v0.6.1 h1:8lp6YejULeHBF8NmV8e2787BogQhduZugh5PdhDyyN4= +github.com/pion/stun v0.6.1/go.mod h1:/hO7APkX4hZKu/D0f2lHzNyvdkTGtIy3NDmLR7kSz/8= +github.com/pion/transport v0.14.1 h1:XSM6olwW+o8J4SCmOBb/BpwZypkHeyM0PGFCxNQBr40= +github.com/pion/transport/v2 v2.2.1/go.mod h1:cXXWavvCnFF6McHTft3DWS9iic2Mftcz1Aq29pGcU5g= +github.com/pion/transport/v2 v2.2.3/go.mod h1:q2U/tf9FEfnSBGSW6w5Qp5PFWRLRj3NjLhCCgpRK4p0= +github.com/pion/transport/v2 v2.2.4/go.mod h1:q2U/tf9FEfnSBGSW6w5Qp5PFWRLRj3NjLhCCgpRK4p0= +github.com/pion/transport/v2 v2.2.10 h1:ucLBLE8nuxiHfvkFKnkDQRYWYfp8ejf4YBOPfaQpw6Q= +github.com/pion/transport/v2 v2.2.10/go.mod h1:sq1kSLWs+cHW9E+2fJP95QudkzbK7wscs8yYgQToO5E= +github.com/pion/transport/v3 v3.0.1/go.mod h1:UY7kiITrlMv7/IKgd5eTUcaahZx5oUN3l9SzK5f5xE0= +github.com/pion/transport/v3 v3.0.2 h1:r+40RJR25S9w3jbA6/5uEPTzcdn7ncyU44RWCbHkLg4= +github.com/pion/transport/v3 v3.0.2/go.mod h1:nIToODoOlb5If2jF9y2Igfx3PFYWfuXi37m0IlWa/D0= +github.com/pion/turn/v2 v2.1.3/go.mod h1:huEpByKKHix2/b9kmTAM3YoX6MKP+/D//0ClgUYR2fY= +github.com/pion/turn/v2 v2.1.6 h1:Xr2niVsiPTB0FPtt+yAWKFUkU1eotQbGgpTIld4x1Gc= +github.com/pion/turn/v2 v2.1.6/go.mod h1:huEpByKKHix2/b9kmTAM3YoX6MKP+/D//0ClgUYR2fY= +github.com/pion/webrtc/v3 v3.3.0 h1:Rf4u6n6U5t5sUxhYPQk/samzU/oDv7jk6BA5hyO2F9I= +github.com/pion/webrtc/v3 v3.3.0/go.mod h1:hVmrDJvwhEertRWObeb1xzulzHGeVUoPlWvxdGzcfU0= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pkg/profile v1.4.0/go.mod h1:NWz/XGvpEW1FyYQ7fCx4dqYBLlfTcE+A9FLAkNKqjFE= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= +github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= +github.com/sagikazarmark/locafero v0.4.0 h1:HApY1R9zGo4DBgr7dqsTH/JJxLTTsOt7u6keLGt6kNQ= +github.com/sagikazarmark/locafero v0.4.0/go.mod h1:Pe1W6UlPYUk/+wc/6KFhbORCfqzgYEpgQ3O5fPuL3H4= +github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE= +github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWRIczQRv+GVI1AkeQ= +github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= +github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= +github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= +github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo= +github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0= +github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8= +github.com/spf13/afero v1.11.0/go.mod h1:GH9Y3pIexgf1MTIWtNGyogA5MwRIDXGUr+hbWNoBjkY= +github.com/spf13/cast v1.6.0 h1:GEiTHELF+vaR5dhz3VqZfFSzZjYbgeKDpBxQVS4GYJ0= +github.com/spf13/cast v1.6.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= +github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= +github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/viper v1.19.0 h1:RWq5SEjt8o25SROyN3z2OrDB9l7RPd3lwTWU8EcEdcI= +github.com/spf13/viper v1.19.0/go.mod h1:GQUN9bilAbhU/jgc1bKs99f/suXKeUMct8Adx5+Ntkg= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.8.3/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= +github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= +github.com/sunfish-shogi/bufseekio v0.0.0-20210207115823-a4185644b365/go.mod h1:dEzdXgvImkQ3WLI+0KQpmEx8T/C/ma9KeS3AfmU899I= +github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= +github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= +github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo= +github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ= +github.com/wlynxg/anet v0.0.3 h1:PvR53psxFXstc12jelG6f1Lv4MWqE0tI76/hHGjh9rg= +github.com/wlynxg/anet v0.0.3/go.mod h1:eay5PRQr7fIVAMbTbchTnO9gG65Hg/uYGdc7mguHxoA= +github.com/yapingcat/gomedia v0.0.0-20231026175559-9269ffbdaadd h1:TQZt/3SPlzpG5cNutsJBwQBbQSKB/EuuvEr9ddPJFno= +github.com/yapingcat/gomedia v0.0.0-20231026175559-9269ffbdaadd/go.mod h1:WSZ59bidJOO40JSJmLqlkBJrjZCtjbKKkygEMfzY/kc= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +github.com/yutopp/go-amf0 v0.1.0 h1:a3UeBZG7nRF0zfvmPn2iAfNo1RGzUpHz1VyJD2oGrik= +github.com/yutopp/go-amf0 v0.1.0/go.mod h1:QzDOBr9RV6sQh6E5GFEJROZbU0iQKijORBmprkb3FIk= +github.com/yutopp/go-flv v0.3.1 h1:4ILK6OgCJgUNm2WOjaucWM5lUHE0+sLNPdjq3L0Xtjk= +github.com/yutopp/go-flv v0.3.1/go.mod h1:pAlHPSVRMv5aCUKmGOS/dZn/ooTgnc09qOPmiUNMubs= +github.com/yutopp/go-rtmp v0.0.7 h1:sKKm1MVV3ANbJHZlf3Kq8ecq99y5U7XnDUDxSjuK7KU= +github.com/yutopp/go-rtmp v0.0.7/go.mod h1:KSwrC9Xj5Kf18EUlk1g7CScecjXfIqc0J5q+S0u6Irc= +go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE= +go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= +go.uber.org/multierr v1.9.0 h1:7fIwc/ZtS0q++VgcfqFDxSBZVv/Xo49/SYnDFupUwlI= +go.uber.org/multierr v1.9.0/go.mod h1:X2jQV1h+kxSjClGpnseKVIxpmcjrj7MNnI0bnlfKTVQ= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.8.0/go.mod h1:mRqEX+O9/h5TFCrQhkgjo2yKi0yYA+9ecGkdQoHrywE= +golang.org/x/crypto v0.12.0/go.mod h1:NF0Gs7EO5K4qLn+Ylc+fih8BSTeIjAP05siRnAh98yw= +golang.org/x/crypto v0.18.0/go.mod h1:R0j02AL6hcrfOiy9T4ZYp/rcWeMxM3L6QYxlOuEG1mg= +golang.org/x/crypto v0.22.0 h1:g1v0xeRhjcugydODzvb3mEM9SQ0HGp9s/nh3COQ/C30= +golang.org/x/crypto v0.22.0/go.mod h1:vr6Su+7cTlO45qkww3VDJlzDn0ctJvRgYbC2NvXHt+M= +golang.org/x/exp v0.0.0-20230905200255-921286631fa9 h1:GoHiUyI/Tp2nVkLI2mCxVkOjsbSXD66ic0XW0js0R9g= +golang.org/x/exp v0.0.0-20230905200255-921286631fa9/go.mod h1:S2oDrQGGwySpoQPVqRShND87VCbxmc6bL1Yd2oYrm6k= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.9.0/go.mod h1:d48xBJpPfHeWQsugry2m+kC02ZBRGRgulfHnEXEuWns= +golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= +golang.org/x/net v0.14.0/go.mod h1:PpSgVXXLK0OxS0F31C1/tv6XNguvCrnXIDrFMspZIUI= +golang.org/x/net v0.20.0/go.mod h1:z8BVo6PvndSri0LbOE3hAn0apkU+1YvI6E70E9jsnvY= +golang.org/x/net v0.24.0 h1:1PcaxkF854Fu3+lvBIx5SYn9wRlBzzcnHZSiaFFAb0w= +golang.org/x/net v0.24.0/go.mod h1:2Q7sJY5mzlzWjKtYUEXSlBWCdyaioyXzRB2RtU8KVE8= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190726091711-fc99dfbffb4e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/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/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.3.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.9.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.19.0 h1:q5f1RH2jigJ1MoAWp2KTp3gm5zAGFUTarQZ5U386+4o= +golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +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/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= +golang.org/x/term v0.7.0/go.mod h1:P32HKFT3hSsZrRxla30E9HqToFYAQPCMs/zFMBUFqPY= +golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= +golang.org/x/term v0.11.0/go.mod h1:zC9APTIj3jG3FdV/Ons+XE1riIZXG4aZ4GTHiPZJPIU= +golang.org/x/term v0.16.0/go.mod h1:yn7UURbUtPyrVJPGPq404EukNFxcm/foM+bV/bfcDsY= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= +golang.org/x/text v0.12.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= +golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +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.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA= +gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= +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/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/handler.go b/handler.go new file mode 100644 index 0000000..06ab7d0 --- /dev/null +++ b/handler.go @@ -0,0 +1 @@ +package main diff --git a/httpsrv/hlshandler.go b/httpsrv/hlshandler.go new file mode 100644 index 0000000..2f58f15 --- /dev/null +++ b/httpsrv/hlshandler.go @@ -0,0 +1,103 @@ +package httpsrv + +import ( + "context" + "fmt" + "net/http" + "path" + "path/filepath" + + "github.com/bluenviron/gohlslib/pkg/codecparams" + "github.com/bluenviron/gohlslib/pkg/playlist" + "github.com/labstack/echo/v4" + + "liveflow/log" + "liveflow/media/hlshub" +) + +const ( + cacheControl = "CDN-Cache-Control" +) + +type Handler struct { + endpoint *hlshub.HLSHub +} + +func NewHandler(hlsEndpoint *hlshub.HLSHub) *Handler { + return &Handler{ + endpoint: hlsEndpoint, + } +} + +func (h *Handler) HandleMasterM3U8(c echo.Context) error { + ctx := context.Background() + log.Info(ctx, "HandleMasterM3U8") + workID := c.Param("streamID") + muxers, err := h.endpoint.MuxersByWorkID(workID) + if err != nil { + log.Error(ctx, err, "get muxer failed") + return fmt.Errorf("get muxer failed: %w", err) + } + m3u8Version := 3 + pl := &playlist.Multivariant{ + Version: func() int { + return m3u8Version + }(), + IndependentSegments: true, + } + var variants []*playlist.MultivariantVariant + for name, muxer := range muxers { + // TODO: muxer.Bandwidth() is not implemented + //_, average, err := muxer.Bandwidth() + //if err != nil { + // continue + //} + average := 33033 + variant := &playlist.MultivariantVariant{ + Bandwidth: average, + FrameRate: nil, + URI: path.Join(name, "stream.m3u8"), + } + // TODO: muxer.ResolutionString() is not implemented + //resolution, err := muxer.ResolutionString() + //if err == nil { + // variant.Resolution = resolution + //} + variant.Codecs = []string{} + if muxer.VideoTrack != nil { + variant.Codecs = append(variant.Codecs, codecparams.Marshal(muxer.VideoTrack.Codec)) + } + if muxer.AudioTrack != nil { + variant.Codecs = append(variant.Codecs, codecparams.Marshal(muxer.AudioTrack.Codec)) + } + variants = append(variants, variant) + } + pl.Variants = variants + c.Response().Header().Set(cacheControl, "max-age=1") + masterM3u8Bytes, err := pl.Marshal() + if err != nil { + return err + } + return c.Blob(http.StatusOK, "application/vnd.apple.mpegurl", masterM3u8Bytes) +} + +func (h *Handler) HandleM3U8(c echo.Context) error { + ctx := context.Background() + log.Info(ctx, "HandleM3U8") + workID := c.Param("streamID") + playlistName := c.Param("playlistName") + muxer, err := h.endpoint.Muxer(workID, playlistName) + if err != nil { + log.Error(ctx, err, "no hls stream") + return c.NoContent(http.StatusNotFound) + } + extension := filepath.Ext(c.Request().URL.String()) + switch extension { + case ".m3u8": + c.Response().Header().Set(cacheControl, "max-age=1") + case ".ts", ".mp4": + c.Response().Header().Set(cacheControl, "max-age=3600") + } + muxer.Handle(c.Response(), c.Request()) + return nil +} diff --git a/index.html b/index.html new file mode 100644 index 0000000..54be29c --- /dev/null +++ b/index.html @@ -0,0 +1,125 @@ + + + + + whip-whep + + + + + + + +

Video

+ + + +

ICE Connection States

+

+ + + + \ No newline at end of file diff --git a/install-ffmpeg.sh b/install-ffmpeg.sh new file mode 100644 index 0000000..3f86ba4 --- /dev/null +++ b/install-ffmpeg.sh @@ -0,0 +1,41 @@ +#!/bin/sh + +set -ex + +# Create a directory for the build +mkdir -p /ffmpeg_build +cd /ffmpeg_build + +git config --global http.sslVerify false +# Download and compile x264 +git clone --depth 1 https://code.videolan.org/videolan/x264.git +cd x264 +./configure --prefix="/ffmpeg_build" --enable-static --disable-opencl +make +make install +cd .. + +# Download and extract FFmpeg +wget --no-check-certificate -O ffmpeg-7.0.1.tar.bz2 https://ffmpeg.org/releases/ffmpeg-7.0.1.tar.bz2 +tar xjf ffmpeg-7.0.1.tar.bz2 +cd ffmpeg-7.0.1 + +# Configure and compile FFmpeg with x264 +PKG_CONFIG_PATH="/ffmpeg_build/lib/pkgconfig" ./configure \ + --prefix="/ffmpeg_build" \ + --pkg-config-flags="--static" \ + --extra-cflags="-I/ffmpeg_build/include" \ + --extra-ldflags="-L/ffmpeg_build/lib" \ + --extra-libs="-lpthread -lm" \ + --bindir="/usr/local/bin" \ + --enable-gpl \ + --enable-libx264 \ + --enable-nonfree +make -j8 +make install +cd .. + +# Clean up +rm -rf /ffmpeg_build/src /ffmpeg_build/*.tar.bz2 + +echo "FFmpeg 7.0.1 with x264 has been successfully installed to /ffmpeg_build." \ No newline at end of file diff --git a/log/log.go b/log/log.go new file mode 100644 index 0000000..e308640 --- /dev/null +++ b/log/log.go @@ -0,0 +1,140 @@ +package log + +import ( + "context" + "fmt" + "runtime" + + "github.com/sirupsen/logrus" +) + +type ctxKey string + +const loggerKey ctxKey = "logger" + +type SkipHook struct { + skip int +} + +func (hook *SkipHook) Levels() []logrus.Level { + return logrus.AllLevels +} + +func (hook *SkipHook) Fire(entry *logrus.Entry) error { + pc, file, line, ok := runtime.Caller(hook.skip) + if ok { + entry.Caller = &runtime.Frame{ + PC: pc, + File: file, + Line: line, + Function: runtime.FuncForPC(pc).Name(), + } + } + return nil +} + +func SetLevel(ctx context.Context, level logrus.Level) { + logrus.SetLevel(level) +} + +func Init() { + //logrus.AddHook(&SkipHook{skip: 7}) +} + +func SetFormatter(ctx context.Context, formatter logrus.Formatter) { + logrus.SetFormatter(formatter) +} + +func SetCaller(ctx context.Context, flag bool) { + //logrus.SetReportCaller(flag) + //getLogger(ctx).Logger.SetReportCaller(flag) +} + +func getLogger(ctx context.Context) *logrus.Entry { + logger, ok := ctx.Value(loggerKey).(*logrus.Entry) + if !ok { + // 기본 로거를 반환하거나 오류 처리 + return logrus.NewEntry(logrus.StandardLogger()) + } + return logger +} + +func WithFields(ctx context.Context, fields map[string]interface{}) context.Context { + logger := getLogger(ctx).WithFields(fields) + return context.WithValue(ctx, loggerKey, logger) +} + +func CallerFileLine() string { + _, file, line, ok := runtime.Caller(3) + if ok { + return fmt.Sprintf("%s:%d", file, line) + } + return "" +} + +func CallerFunc() string { + pc, _, _, ok := runtime.Caller(3) + if ok { + return runtime.FuncForPC(pc).Name() + } + return "" +} + +func getLoggerWithStack(ctx context.Context) *logrus.Entry { + return getLogger(ctx).WithFields(logrus.Fields{ + "file": CallerFileLine(), + "func": CallerFunc(), + }) +} +func Info(ctx context.Context, args ...interface{}) { + getLoggerWithStack(ctx).Info(args...) +} + +func Infof(ctx context.Context, format string, args ...interface{}) { + getLoggerWithStack(ctx).Infof(format, args...) +} + +func Debug(ctx context.Context, args ...interface{}) { + getLoggerWithStack(ctx).Debug(args...) +} + +func Debugf(ctx context.Context, format string, args ...interface{}) { + getLoggerWithStack(ctx).Debugf(format, args...) +} + +func Warn(ctx context.Context, args ...interface{}) { + getLoggerWithStack(ctx).Warn(args...) +} + +func Warnf(ctx context.Context, format string, args ...interface{}) { + getLoggerWithStack(ctx).Warnf(format, args...) +} + +func Error(ctx context.Context, args ...interface{}) { + getLoggerWithStack(ctx).Error(args...) +} + +func Errorf(ctx context.Context, format string, args ...interface{}) { + getLoggerWithStack(ctx).Errorf(format, args...) +} + +func Fatal(ctx context.Context, args ...interface{}) { + getLoggerWithStack(ctx).Fatal(args...) + +} + +func Fatalf(ctx context.Context, format string, args ...interface{}) { + getLoggerWithStack(ctx).Fatalf(format, args...) +} + +func Panic(ctx context.Context, args ...interface{}) { + getLoggerWithStack(ctx).Panic(args...) +} + +func Panicf(ctx context.Context, format string, args ...interface{}) { + getLoggerWithStack(ctx).Panicf(format, args...) +} + +func Print(ctx context.Context, args ...interface{}) { + getLoggerWithStack(ctx).Print(args...) +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..285d988 --- /dev/null +++ b/main.go @@ -0,0 +1,118 @@ +package main + +import ( + "context" + "fmt" + "liveflow/config" + "liveflow/media/streamer/egress/record/mp4" + "liveflow/media/streamer/egress/record/webm" + "strconv" + + "github.com/labstack/echo/v4" + "github.com/pion/webrtc/v3" + "github.com/sirupsen/logrus" + "github.com/spf13/viper" + + "liveflow/httpsrv" + "liveflow/log" + "liveflow/media/hlshub" + "liveflow/media/hub" + "liveflow/media/streamer/egress/hls" + "liveflow/media/streamer/egress/whep" + "liveflow/media/streamer/ingress/rtmp" + "liveflow/media/streamer/ingress/whip" +) + +// RTMP 받으면 자동으로 HLS 서비스 동작, 녹화 서비스까지~? +func main() { + ctx := context.Background() + viper.SetConfigName("config") // name of config file (without extension) + viper.SetConfigType("toml") // REQUIRED if the config file does not have the extension in the name + viper.AddConfigPath(".") // optionally look for config in the working directory + viper.BindEnv("docker.mode", "DOCKER_MODE") + err := viper.ReadInConfig() // Find and read the config file + if err != nil { // Handle errors reading the config file + panic(fmt.Errorf("fatal error config file: %w", err)) + } + var conf config.Config + err = viper.Unmarshal(&conf) + if err != nil { + panic(fmt.Errorf("failed to unmarshal config: %w", err)) + } + fmt.Printf("Config: %+v\n", conf) + + log.Init() + //log.SetCaller(ctx, true) + //log.SetFormatter(ctx, &logrus.JSONFormatter{ + // TimestampFormat: "2006-01-02 15:04:05", + //}) + ctx = log.WithFields(ctx, logrus.Fields{ + "app": "liveflow", + }) + log.Info(ctx, "liveflow is started") + hub := hub.NewHub() + var tracks map[string][]*webrtc.TrackLocalStaticRTP + tracks = make(map[string][]*webrtc.TrackLocalStaticRTP) + // ingress + // Egress 서비스는 streamID 알림을 구독하여 처리 시작 + go func() { + api := echo.New() + api.HideBanner = true + hlsHub := hlshub.NewHLSHub() + hlsHandler := httpsrv.NewHandler(hlsHub) + api.GET("/hls/:streamID/master.m3u8", hlsHandler.HandleMasterM3U8) + api.GET("/hls/:streamID/:playlistName/stream.m3u8", hlsHandler.HandleM3U8) + api.GET("/hls/:streamID/:playlistName/:resourceName", hlsHandler.HandleM3U8) + go func() { + api.Start("0.0.0.0:" + strconv.Itoa(conf.HLS.Port)) + }() + // ingress 의 rtmp, whip 서비스로부터 streamID를 받아 HLS, ContainerMP4, WHEP 서비스 시작 + for source := range hub.SubscribeToStreamID() { + log.Infof(ctx, "New streamID received: %s", source.StreamID()) + hls := hls.NewHLS(hls.HLSArgs{ + Hub: hub, + HLSHub: hlsHub, + Port: conf.HLS.Port, + }) + err := hls.Start(ctx, source) + if err != nil { + log.Errorf(ctx, "failed to start hls: %v", err) + } + mp4 := mp4.NewMP4(mp4.MP4Args{ + Hub: hub, + }) + err = mp4.Start(ctx, source) + if err != nil { + log.Errorf(ctx, "failed to start mp4: %v", err) + } + whep := whep.NewWHEP(whep.WHEPArgs{ + Tracks: tracks, + Hub: hub, + }) + err = whep.Start(ctx, source) + if err != nil { + log.Errorf(ctx, "failed to start whep: %v", err) + } + webmStarter := webm.NewWEBM(webm.WebMArgs{ + Hub: hub, + }) + err = webmStarter.Start(ctx, source) + if err != nil { + log.Errorf(ctx, "failed to start webm: %v", err) + } + } + }() + + whipServer := whip.NewWHIP(whip.WHIPArgs{ + Hub: hub, + Tracks: tracks, + DockerMode: conf.Docker.Mode, + Port: conf.Whep.Port, + }) + go whipServer.Serve() + rtmpServer := rtmp.NewRTMP(rtmp.RTMPArgs{ + Hub: hub, + Port: conf.RTMP.Port, + }) + rtmpServer.Serve(ctx) +} diff --git a/media/hlshub/hub.go b/media/hlshub/hub.go new file mode 100644 index 0000000..4c29feb --- /dev/null +++ b/media/hlshub/hub.go @@ -0,0 +1,65 @@ +package hlshub + +import ( + "errors" + "sync" + + "github.com/bluenviron/gohlslib" +) + +var ( + errNotFoundStream = errors.New("no HLS stream") +) + +type HLSHub struct { + mu *sync.RWMutex + // [workID][name(low|pass)]muxer + hlsMuxers map[string]map[string]*gohlslib.Muxer +} + +func NewHLSHub() *HLSHub { + return &HLSHub{ + mu: &sync.RWMutex{}, + hlsMuxers: map[string]map[string]*gohlslib.Muxer{}, + } +} + +func (s *HLSHub) StoreMuxer(workID string, name string, muxer *gohlslib.Muxer) { + s.mu.Lock() + defer s.mu.Unlock() + if s.hlsMuxers[workID] == nil { + s.hlsMuxers[workID] = map[string]*gohlslib.Muxer{} + } + s.hlsMuxers[workID][name] = muxer +} + +func (s *HLSHub) DeleteMuxer(workID string) { + s.mu.Lock() + defer s.mu.Unlock() + delete(s.hlsMuxers, workID) +} + +func (s *HLSHub) Muxer(workID string, name string) (*gohlslib.Muxer, error) { + s.mu.RLock() + defer s.mu.RUnlock() + muxers, prs := s.hlsMuxers[workID] + if !prs { + return nil, errNotFoundStream + } + for n, muxer := range muxers { + if n == name { + return muxer, nil + } + } + return nil, errNotFoundStream +} + +func (s *HLSHub) MuxersByWorkID(workID string) (map[string]*gohlslib.Muxer, error) { + s.mu.RLock() + defer s.mu.RUnlock() + muxers, prs := s.hlsMuxers[workID] + if !prs { + return nil, errNotFoundStream + } + return muxers, nil +} diff --git a/media/hub/dto.go b/media/hub/dto.go new file mode 100644 index 0000000..831a5dc --- /dev/null +++ b/media/hub/dto.go @@ -0,0 +1,135 @@ +package hub + +import ( + "github.com/deepch/vdk/codec/aacparser" +) + +type FrameData struct { + H264Video *H264Video + AACAudio *AACAudio + OPUSAudio *OPUSAudio +} + +type H264Video struct { + PTS int64 + DTS int64 + VideoClockRate uint32 + Data []byte + SPS []byte + PPS []byte + SliceType SliceType + CodecData []byte +} + +func (h *H264Video) RawTimestamp() int64 { + if h.VideoClockRate == 0 { + return h.PTS + } else { + return int64(float64(h.PTS) / float64(h.VideoClockRate) * 1000) + } +} + +func (h *H264Video) RawPTS() int64 { + if h.VideoClockRate == 0 { + return h.PTS + } else { + return int64(float64(h.PTS) / float64(h.VideoClockRate/1000.0)) + } +} +func (h *H264Video) RawDTS() int64 { + if h.VideoClockRate == 0 { + return h.DTS + } else { + return int64(float64(h.DTS) / float64(h.VideoClockRate/1000.0)) + } +} + +type OPUSAudio struct { + PTS int64 + DTS int64 + AudioClockRate uint32 + Data []byte +} + +func (a *OPUSAudio) RawTimestamp() int64 { + if a.AudioClockRate == 0 { + return a.PTS + } else { + return int64(float64(a.PTS) / float64(a.AudioClockRate) * 1000) + } +} + +func (a *OPUSAudio) RawPTS() int64 { + if a.AudioClockRate == 0 { + return a.PTS + } else { + return int64(float64(a.PTS) / float64(a.AudioClockRate/1000.0)) + } +} + +func (a *OPUSAudio) RawDTS() int64 { + if a.AudioClockRate == 0 { + return a.DTS + } else { + return int64(float64(a.DTS) / float64(a.AudioClockRate/1000.0)) + } +} + +type AACAudio struct { + Data []byte + SequenceHeader bool + MPEG4AudioConfigBytes []byte + MPEG4AudioConfig *aacparser.MPEG4AudioConfig + PTS int64 + DTS int64 + AudioClockRate uint32 +} + +func (a *AACAudio) RawTimestamp() int64 { + if a.AudioClockRate == 0 { + return a.PTS + } else { + return int64(float64(a.PTS) / float64(a.AudioClockRate) * 1000) + } +} + +func (a *AACAudio) RawPTS() int64 { + if a.AudioClockRate == 0 { + return a.PTS + } else { + return int64(float64(a.PTS) / float64(a.AudioClockRate/1000.0)) + } +} + +func (a *AACAudio) RawDTS() int64 { + if a.AudioClockRate == 0 { + return a.DTS + } else { + return int64(float64(a.DTS) / float64(a.AudioClockRate/1000.0)) + } +} + +type AudioCodecData struct { + Timestamp uint32 + Data []byte +} + +type VideoCodecData struct { + Timestamp uint32 + Data []byte +} +type MetaData struct { + Timestamp uint32 + Data []byte +} + +type MediaInfo struct { + VCodec VideoCodecType +} + +type VideoCodecType int + +const ( + H264 VideoCodecType = iota + VP8 +) diff --git a/media/hub/hub.go b/media/hub/hub.go new file mode 100644 index 0000000..6c451aa --- /dev/null +++ b/media/hub/hub.go @@ -0,0 +1,153 @@ +package hub + +import ( + "context" + "fmt" + "sync" + "time" + + "liveflow/log" +) + +var ( + ErrNotFoundAudioClockRate = fmt.Errorf("audio clock rate not found") + ErrNotFoundVideoClockRate = fmt.Errorf("video clock rate not found") +) + +type MediaType int + +const ( + Video MediaType = 1 + Audio = 2 +) + +type CodecType string + +const ( + CodecTypeVP8 CodecType = "vp8" + CodecTypeH264 CodecType = "h264" + CodecTypeOpus CodecType = "opus" + CodecTypeAAC CodecType = "aac" +) + +type MediaSpec struct { + MediaType MediaType + ClockRate uint32 + CodecType CodecType +} + +type Source interface { + Name() string + MediaSpecs() []MediaSpec + StreamID() string + Depth() int +} + +func HasCodecType(specs []MediaSpec, codecType CodecType) bool { + for _, spec := range specs { + if spec.CodecType == codecType { + return true + } + } + return false +} + +func AudioClockRate(specs []MediaSpec) (uint32, error) { + for _, spec := range specs { + if spec.MediaType == Audio { + return spec.ClockRate, nil + } + } + return 0, ErrNotFoundAudioClockRate +} + +func VideoClockRate(specs []MediaSpec) (uint32, error) { + for _, spec := range specs { + if spec.MediaType == Video { + return spec.ClockRate, nil + } + } + return 0, ErrNotFoundVideoClockRate +} + +// Hub struct: Manages data independently for each streamID and supports Pub/Sub mechanism. +type Hub struct { + streams map[string][]chan *FrameData // Stores channels for each streamID + notifyChan chan Source // Channel for notifying when streamID is determined + mu sync.RWMutex // Mutex for concurrency +} + +// NewHub : Hub constructor +func NewHub() *Hub { + return &Hub{ + streams: make(map[string][]chan *FrameData), + notifyChan: make(chan Source, 1024), // Buffer size can be adjusted. + } +} + +func (h *Hub) Notify(ctx context.Context, streamID Source) { + log.Info(ctx, "Notify", streamID.Name(), streamID.MediaSpecs()) + h.notifyChan <- streamID +} + +// Publish : Publishes data to the given streamID. +func (h *Hub) Publish(streamID string, data *FrameData) { + h.mu.Lock() + defer h.mu.Unlock() + + if _, exists := h.streams[streamID]; !exists { + h.streams[streamID] = make([]chan *FrameData, 0) + } + + ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second) + defer cancel() + for _, ch := range h.streams[streamID] { + select { + case ch <- data: + case <-ctx.Done(): + log.Warn(ctx, "publish timeout") + } + } +} + +func (h *Hub) Unpublish(streamID string) { + h.mu.Lock() + defer h.mu.Unlock() + + if _, exists := h.streams[streamID]; !exists { + return + } + + for _, ch := range h.streams[streamID] { + close(ch) + } + delete(h.streams, streamID) +} + +// Subscribe : Subscribes to the given streamID. +func (h *Hub) Subscribe(streamID string) <-chan *FrameData { + h.mu.RLock() + defer h.mu.RUnlock() + + ch := make(chan *FrameData) + h.streams[streamID] = append(h.streams[streamID], ch) + return ch +} + +// SubscribeToStreamID : Returns a channel that subscribes to notifications when a stream ID is determined. +func (h *Hub) SubscribeToStreamID() <-chan Source { + return h.notifyChan +} + +// RemoveStream : Function to remove unused streams (releases resources) +func (h *Hub) RemoveStream(streamID string) { + h.mu.Lock() + defer h.mu.Unlock() + + if chs, exists := h.streams[streamID]; exists { + for _, ch := range chs { + close(ch) + } + delete(h.streams, streamID) + } +} diff --git a/media/hub/slicetype.go b/media/hub/slicetype.go new file mode 100644 index 0000000..92de910 --- /dev/null +++ b/media/hub/slicetype.go @@ -0,0 +1,31 @@ +package hub + +import "fmt" + +type SliceType int + +func (s SliceType) String() string { + switch s { + case SliceI: + return "I" + case SliceP: + return "P" + case SliceB: + return "B" + case SliceSPS: + return "SPS" + case SlicePPS: + return "PPS" + default: + return fmt.Sprintf("Unknown SliceType: %d", s) + } +} + +const ( + SliceI SliceType = 0 + SliceP SliceType = 1 + SliceB SliceType = 2 + SliceSPS SliceType = 3 + SlicePPS SliceType = 4 + SliceUnknown SliceType = 5 +) diff --git a/media/streamer/egress/hls/handler.go b/media/streamer/egress/hls/handler.go new file mode 100644 index 0000000..4551b1b --- /dev/null +++ b/media/streamer/egress/hls/handler.go @@ -0,0 +1,179 @@ +package hls + +import ( + "context" + "errors" + "fmt" + "liveflow/media/streamer/processes" + "time" + + "github.com/asticode/go-astiav" + "github.com/bluenviron/gohlslib" + "github.com/bluenviron/gohlslib/pkg/codecs" + "github.com/deepch/vdk/codec/aacparser" + "github.com/deepch/vdk/codec/h264parser" + "github.com/sirupsen/logrus" + + "liveflow/log" + "liveflow/media/hlshub" + "liveflow/media/hub" + "liveflow/media/streamer/fields" +) + +var ( + ErrNotContainAudioOrVideo = errors.New("media spec does not contain audio or video") + ErrUnsupportedCodec = errors.New("unsupported codec") +) + +const ( + audioSampleRate = 48000 +) + +type HLS struct { + hub *hub.Hub + hlsHub *hlshub.HLSHub + port int + muxer *gohlslib.Muxer + mpeg4AudioConfigBytes []byte + mpeg4AudioConfig *aacparser.MPEG4AudioConfig +} + +type HLSArgs struct { + Hub *hub.Hub + HLSHub *hlshub.HLSHub + Port int +} + +func NewHLS(args HLSArgs) *HLS { + return &HLS{ + hub: args.Hub, + hlsHub: args.HLSHub, + port: args.Port, + } +} + +func (h *HLS) Start(ctx context.Context, source hub.Source) error { + if !hub.HasCodecType(source.MediaSpecs(), hub.CodecTypeAAC) && !hub.HasCodecType(source.MediaSpecs(), hub.CodecTypeOpus) { + return ErrUnsupportedCodec + } + if !hub.HasCodecType(source.MediaSpecs(), hub.CodecTypeH264) { + return ErrUnsupportedCodec + } + ctx = log.WithFields(ctx, logrus.Fields{ + fields.StreamID: source.StreamID(), + fields.SourceName: source.Name(), + }) + audioTranscodingProcess := processes.NewTranscodingProcess(astiav.CodecIDOpus, astiav.CodecIDAac, audioSampleRate) + if hub.HasCodecType(source.MediaSpecs(), hub.CodecTypeOpus) { + audioTranscodingProcess = processes.NewTranscodingProcess(astiav.CodecIDOpus, astiav.CodecIDAac, audioSampleRate) + audioTranscodingProcess.Init() + h.mpeg4AudioConfigBytes = audioTranscodingProcess.ExtraData() + tmpAudioCodec, err := aacparser.NewCodecDataFromMPEG4AudioConfigBytes(h.mpeg4AudioConfigBytes) + if err != nil { + return err + } + h.mpeg4AudioConfig = &tmpAudioCodec.Config + } + log.Info(ctx, "start hls") + log.Info(ctx, "view url: ", fmt.Sprintf("http://127.0.0.1:%d/hls/%s/master.m3u8", h.port, source.StreamID())) + + sub := h.hub.Subscribe(source.StreamID()) + go func() { + for data := range sub { + if data.OPUSAudio != nil { + h.onOPUSAudio(ctx, source, audioTranscodingProcess, data.OPUSAudio) + } else { + if data.AACAudio != nil { + h.onAudio(ctx, source, data.AACAudio) + } + } + if data.H264Video != nil { + h.onVideo(ctx, data.H264Video) + } + } + log.Info(ctx, "[HLS] end of streamID: ", source.StreamID()) + }() + return nil +} + +func (h *HLS) onAudio(ctx context.Context, source hub.Source, aacAudio *hub.AACAudio) { + if len(aacAudio.MPEG4AudioConfigBytes) > 0 { + if h.muxer == nil { + muxer, err := h.makeMuxer(aacAudio.MPEG4AudioConfigBytes) + if err != nil { + log.Error(ctx, err) + } + h.hlsHub.StoreMuxer(source.StreamID(), "pass", muxer) + err = muxer.Start() + if err != nil { + log.Error(ctx, err) + } + h.muxer = muxer + } + } + if h.muxer != nil { + audioData := make([]byte, len(aacAudio.Data)) + copy(audioData, aacAudio.Data) + h.muxer.WriteMPEG4Audio(time.Now(), time.Duration(aacAudio.RawDTS())*time.Millisecond, [][]byte{audioData}) + } +} + +func (h *HLS) onVideo(ctx context.Context, h264Video *hub.H264Video) { + if h.muxer != nil { + au, _ := h264parser.SplitNALUs(h264Video.Data) + err := h.muxer.WriteH264(time.Now(), time.Duration(h264Video.RawDTS())*time.Millisecond, au) + if err != nil { + log.Errorf(ctx, "failed to write h264: %v", err) + } + } +} + +func (h *HLS) onOPUSAudio(ctx context.Context, source hub.Source, audioTranscodingProcess *processes.AudioTranscodingProcess, opusAudio *hub.OPUSAudio) { + packets, err := audioTranscodingProcess.Process(&processes.MediaPacket{ + Data: opusAudio.Data, + PTS: opusAudio.PTS, + DTS: opusAudio.DTS, + }) + if err != nil { + fmt.Println(err) + } + for _, packet := range packets { + h.onAudio(ctx, source, &hub.AACAudio{ + Data: packet.Data, + SequenceHeader: false, + MPEG4AudioConfigBytes: h.mpeg4AudioConfigBytes, + MPEG4AudioConfig: h.mpeg4AudioConfig, + PTS: packet.PTS, + DTS: packet.DTS, + AudioClockRate: uint32(packet.SampleRate), + }) + } +} +func (h *HLS) makeMuxer(extraData []byte) (*gohlslib.Muxer, error) { + var audioTrack *gohlslib.Track + if len(extraData) > 0 { + mpeg4Audio := &codecs.MPEG4Audio{} + err := mpeg4Audio.Unmarshal(extraData) + if err != nil { + return nil, errors.New("failed to unmarshal mpeg4 audio") + } + audioTrack = &gohlslib.Track{ + Codec: mpeg4Audio, + } + } + muxer := &gohlslib.Muxer{ + VideoTrack: &gohlslib.Track{ + Codec: &codecs.H264{}, + }, + AudioTrack: audioTrack, + } + llHLS := false + if llHLS { + muxer.Variant = gohlslib.MuxerVariantLowLatency + muxer.PartDuration = 500 * time.Millisecond + } else { + muxer.Variant = gohlslib.MuxerVariantMPEGTS + muxer.SegmentDuration = 1 * time.Second + } + return muxer, nil +} diff --git a/media/streamer/egress/record/flv/handler.go b/media/streamer/egress/record/flv/handler.go new file mode 100644 index 0000000..37f8506 --- /dev/null +++ b/media/streamer/egress/record/flv/handler.go @@ -0,0 +1 @@ +package flv diff --git a/media/streamer/egress/record/mp4/handler.go b/media/streamer/egress/record/mp4/handler.go new file mode 100644 index 0000000..dd5fb41 --- /dev/null +++ b/media/streamer/egress/record/mp4/handler.go @@ -0,0 +1,230 @@ +package mp4 + +import "C" +import ( + "context" + "errors" + "fmt" + "io" + "liveflow/media/streamer/egress/record" + "liveflow/media/streamer/processes" + "math/rand" + "os" + + astiav "github.com/asticode/go-astiav" + + "github.com/deepch/vdk/codec/aacparser" + "github.com/sirupsen/logrus" + gomp4 "github.com/yapingcat/gomedia/go-mp4" + + "liveflow/log" + "liveflow/media/hub" + "liveflow/media/streamer/fields" +) + +var ( + ErrNotContainAudioOrVideo = errors.New("media spec does not contain audio or video") + ErrUnsupportedCodec = errors.New("unsupported codec") +) + +const ( + audioSampleRate = 48000 +) + +type cacheWriterSeeker struct { + buf []byte + offset int +} + +func newCacheWriterSeeker(capacity int) *cacheWriterSeeker { + return &cacheWriterSeeker{ + buf: make([]byte, 0, capacity), + offset: 0, + } +} + +func (ws *cacheWriterSeeker) Write(p []byte) (n int, err error) { + if cap(ws.buf)-ws.offset >= len(p) { + if len(ws.buf) < ws.offset+len(p) { + ws.buf = ws.buf[:ws.offset+len(p)] + } + copy(ws.buf[ws.offset:], p) + ws.offset += len(p) + return len(p), nil + } + tmp := make([]byte, len(ws.buf), cap(ws.buf)+len(p)*2) + copy(tmp, ws.buf) + if len(ws.buf) < ws.offset+len(p) { + tmp = tmp[:ws.offset+len(p)] + } + copy(tmp[ws.offset:], p) + ws.buf = tmp + ws.offset += len(p) + return len(p), nil +} + +func (ws *cacheWriterSeeker) Seek(offset int64, whence int) (int64, error) { + if whence == io.SeekCurrent { + if ws.offset+int(offset) > len(ws.buf) { + return -1, errors.New(fmt.Sprint("SeekCurrent out of range", len(ws.buf), offset, ws.offset)) + } + ws.offset += int(offset) + return int64(ws.offset), nil + } else if whence == io.SeekStart { + if offset > int64(len(ws.buf)) { + return -1, errors.New(fmt.Sprint("SeekStart out of range", len(ws.buf), offset, ws.offset)) + } + ws.offset = int(offset) + return offset, nil + } else { + return 0, errors.New("unsupport SeekEnd") + } +} + +type MP4 struct { + hub *hub.Hub + muxer *gomp4.Movmuxer + tempFile *os.File + hasVideo bool + videoIndex uint32 + hasAudio bool + audioIndex uint32 + mpeg4AudioConfigBytes []byte + mpeg4AudioConfig *aacparser.MPEG4AudioConfig +} + +type MP4Args struct { + Hub *hub.Hub +} + +func NewMP4(args MP4Args) *MP4 { + return &MP4{ + hub: args.Hub, + } +} + +func (m *MP4) Start(ctx context.Context, source hub.Source) error { + if !hub.HasCodecType(source.MediaSpecs(), hub.CodecTypeAAC) && !hub.HasCodecType(source.MediaSpecs(), hub.CodecTypeOpus) { + return ErrUnsupportedCodec + } + if !hub.HasCodecType(source.MediaSpecs(), hub.CodecTypeH264) { + return ErrUnsupportedCodec + } + ctx = log.WithFields(ctx, logrus.Fields{ + fields.StreamID: source.StreamID(), + fields.SourceName: source.Name(), + }) + var audioTranscodingProcess *processes.AudioTranscodingProcess + if hub.HasCodecType(source.MediaSpecs(), hub.CodecTypeOpus) { + audioTranscodingProcess = processes.NewTranscodingProcess(astiav.CodecIDOpus, astiav.CodecIDAac, audioSampleRate) + audioTranscodingProcess.Init() + m.mpeg4AudioConfigBytes = audioTranscodingProcess.ExtraData() + tmpAudioCodec, err := aacparser.NewCodecDataFromMPEG4AudioConfigBytes(m.mpeg4AudioConfigBytes) + if err != nil { + return err + } + m.mpeg4AudioConfig = &tmpAudioCodec.Config + } + log.Info(ctx, "start mp4") + sub := m.hub.Subscribe(source.StreamID()) + go func() { + var err error + mp4File, err := record.CreateFileInDir(fmt.Sprintf("videos/%d.mp4", rand.Int())) + if err != nil { + fmt.Println(err) + return + } + defer func() { + err := mp4File.Close() + if err != nil { + log.Error(ctx, err, "failed to close mp4 file") + } + }() + muxer, err := gomp4.CreateMp4Muxer(mp4File) + if err != nil { + fmt.Println(err) + return + } + m.muxer = muxer + + for data := range sub { + if data.H264Video != nil { + m.onVideo(ctx, data.H264Video) + } + if data.OPUSAudio != nil { + m.onOPUSAudio(ctx, audioTranscodingProcess, data.OPUSAudio) + } else { + if data.AACAudio != nil { + m.onAudio(ctx, data.AACAudio) + } + } + } + err = muxer.WriteTrailer() + if err != nil { + log.Error(ctx, err, "failed to write trailer") + } + }() + return nil +} + +func (m *MP4) onVideo(ctx context.Context, h264Video *hub.H264Video) { + if !m.hasVideo { + m.hasVideo = true + m.videoIndex = m.muxer.AddVideoTrack(gomp4.MP4_CODEC_H264) + } + videoData := make([]byte, len(h264Video.Data)) + copy(videoData, h264Video.Data) + err := m.muxer.Write(m.videoIndex, videoData, uint64(h264Video.RawPTS()), uint64(h264Video.RawDTS())) + if err != nil { + log.Error(ctx, err, "failed to write video") + } +} + +func (m *MP4) onAudio(ctx context.Context, aacAudio *hub.AACAudio) { + if !m.hasAudio { + m.hasAudio = true + m.audioIndex = m.muxer.AddAudioTrack(gomp4.MP4_CODEC_AAC) + } + if len(aacAudio.MPEG4AudioConfigBytes) > 0 { + m.mpeg4AudioConfigBytes = aacAudio.MPEG4AudioConfigBytes + } + if aacAudio.MPEG4AudioConfig != nil { + m.mpeg4AudioConfig = aacAudio.MPEG4AudioConfig + } + if len(aacAudio.Data) > 0 && m.mpeg4AudioConfig != nil { + var audioData []byte + const ( + aacSamples = 1024 + adtsHeaderSize = 7 + ) + adtsHeader := make([]byte, adtsHeaderSize) + aacparser.FillADTSHeader(adtsHeader, *m.mpeg4AudioConfig, aacSamples, len(aacAudio.Data)) + audioData = append(adtsHeader, aacAudio.Data...) + err := m.muxer.Write(m.audioIndex, audioData, uint64(aacAudio.RawPTS()), uint64(aacAudio.RawDTS())) + if err != nil { + log.Error(ctx, err, "failed to write audio") + } + } +} + +func (m *MP4) onOPUSAudio(ctx context.Context, audioTranscodingProcess *processes.AudioTranscodingProcess, opusAudio *hub.OPUSAudio) { + packets, err := audioTranscodingProcess.Process(&processes.MediaPacket{ + Data: opusAudio.Data, + PTS: opusAudio.PTS, + DTS: opusAudio.DTS, + }) + if err != nil { + fmt.Println(err) + } + for _, packet := range packets { + m.onAudio(ctx, &hub.AACAudio{ + Data: packet.Data, + SequenceHeader: false, + MPEG4AudioConfigBytes: m.mpeg4AudioConfigBytes, + MPEG4AudioConfig: m.mpeg4AudioConfig, + PTS: packet.PTS, + DTS: packet.DTS, + AudioClockRate: uint32(packet.SampleRate), + }) + } +} diff --git a/media/streamer/egress/record/util.go b/media/streamer/egress/record/util.go new file mode 100644 index 0000000..9ff8a82 --- /dev/null +++ b/media/streamer/egress/record/util.go @@ -0,0 +1,21 @@ +package record + +import ( + "os" + "path/filepath" +) + +func CreateFileInDir(path string) (*os.File, error) { + dirPath := filepath.Dir(path) + if _, err := os.Stat(dirPath); os.IsNotExist(err) { + err = os.MkdirAll(dirPath, 0755) + if err != nil { + return nil, err + } + } + file, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY, 0644) + if err != nil { + return nil, err + } + return file, nil +} diff --git a/media/streamer/egress/record/webm/handler.go b/media/streamer/egress/record/webm/handler.go new file mode 100644 index 0000000..93b26f5 --- /dev/null +++ b/media/streamer/egress/record/webm/handler.go @@ -0,0 +1,136 @@ +package webm + +import ( + "context" + "errors" + "fmt" + "liveflow/log" + "liveflow/media/hub" + "liveflow/media/streamer/fields" + "liveflow/media/streamer/processes" + + astiav "github.com/asticode/go-astiav" + + "github.com/deepch/vdk/codec/aacparser" + "github.com/pion/webrtc/v3" + "github.com/sirupsen/logrus" +) + +var ( + ErrUnsupportedCodec = errors.New("unsupported codec") +) + +const ( + audioSampleRate = 48000 +) + +type WebMArgs struct { + Tracks map[string][]*webrtc.TrackLocalStaticRTP + Hub *hub.Hub +} + +type WebM struct { + hub *hub.Hub + webmMuxer *EBMLMuxer + samples int +} + +func NewWEBM(args WebMArgs) *WebM { + return &WebM{ + hub: args.Hub, + } +} + +func (w *WebM) Start(ctx context.Context, source hub.Source) error { + if !hub.HasCodecType(source.MediaSpecs(), hub.CodecTypeAAC) && !hub.HasCodecType(source.MediaSpecs(), hub.CodecTypeOpus) { + return ErrUnsupportedCodec + } + if !hub.HasCodecType(source.MediaSpecs(), hub.CodecTypeH264) { + return ErrUnsupportedCodec + } + audioClockRate, err := hub.AudioClockRate(source.MediaSpecs()) + if err != nil { + return err + } + + ctx = log.WithFields(ctx, logrus.Fields{ + fields.StreamID: source.StreamID(), + fields.SourceName: source.Name(), + }) + muxer := NewEBMLMuxer(int(audioClockRate), 2, ContainerMKV) + err = muxer.Init(ctx) + if err != nil { + return err + } + log.Info(ctx, "start webm") + sub := w.hub.Subscribe(source.StreamID()) + audioTranscodingProcess := processes.NewTranscodingProcess(astiav.CodecIDAac, astiav.CodecIDOpus, audioSampleRate) + audioTranscodingProcess.Init() + go func() { + for data := range sub { + if data.H264Video != nil { + w.onVideo(ctx, muxer, data.H264Video) + } + if data.AACAudio != nil { + w.onAACAudio(ctx, muxer, data.AACAudio, audioTranscodingProcess) + } else if data.OPUSAudio != nil { + w.onAudio(ctx, muxer, data.OPUSAudio) + } + } + err = muxer.Finalize(ctx) + if err != nil { + log.Error(ctx, err, "failed to finalize") + } + }() + return nil +} + +func (w *WebM) onVideo(ctx context.Context, muxer *EBMLMuxer, data *hub.H264Video) { + keyFrame := data.SliceType == hub.SliceI + err := muxer.WriteVideo(data.Data, keyFrame, uint64(data.RawPTS()), uint64(data.RawDTS())) + if err != nil { + log.Error(ctx, err, "failed to write video") + } +} + +func (w *WebM) onAudio(ctx context.Context, muxer *EBMLMuxer, data *hub.OPUSAudio) { + err := muxer.WriteAudio(data.Data, false, uint64(data.RawPTS()), uint64(data.RawDTS())) + if err != nil { + log.Error(ctx, err, "failed to write audio") + } +} + +func (w *WebM) onAACAudio(ctx context.Context, muxer *EBMLMuxer, aac *hub.AACAudio, transcodingProcess *processes.AudioTranscodingProcess) { + if len(aac.Data) == 0 { + log.Warn(ctx, "no data") + return + } + if aac.MPEG4AudioConfig == nil { + log.Warn(ctx, "no config") + return + } + const ( + aacSamples = 1024 + adtsHeaderSize = 7 + ) + adtsHeader := make([]byte, adtsHeaderSize) + + aacparser.FillADTSHeader(adtsHeader, *aac.MPEG4AudioConfig, aacSamples, len(aac.Data)) + audioDataWithADTS := append(adtsHeader, aac.Data...) + packets, err := transcodingProcess.Process(&processes.MediaPacket{ + Data: audioDataWithADTS, + PTS: aac.PTS, + DTS: aac.DTS, + }) + if err != nil { + fmt.Println(err) + } + for _, packet := range packets { + w.onAudio(ctx, muxer, &hub.OPUSAudio{ + Data: packet.Data, + PTS: packet.PTS, + DTS: packet.DTS, + AudioClockRate: uint32(packet.SampleRate), + }) + } +} diff --git a/media/streamer/egress/record/webm/webm.go b/media/streamer/egress/record/webm/webm.go new file mode 100644 index 0000000..cb0b0ae --- /dev/null +++ b/media/streamer/egress/record/webm/webm.go @@ -0,0 +1,290 @@ +package webm + +import ( + "context" + "encoding/binary" + "errors" + "fmt" + "liveflow/log" + "liveflow/media/streamer/egress/record" + "math" + "math/rand" + "os" + + "github.com/at-wat/ebml-go" + "github.com/at-wat/ebml-go/mkv" + "github.com/at-wat/ebml-go/mkvcore" + "github.com/at-wat/ebml-go/webm" +) + +var errInitFailed = errors.New("init failed") + +const ( + webmAudioTrackNumber = 1 + webmVideoTrackNumber = 2 + trackNameVideo = "Video" + trackNameAudio = "Audio" + defaultDuration = 600_000 + defaultTimecode = 1_000_000 +) + +const ( + codecIDVP8 = "V_VP8" + codecIDH264 = "V_MPEG4/ISO/AVC" + codecIDOPUS = "A_OPUS" + codecIDAAC = "A_AAC" +) + +type Name string + +const ( + ContainerWebM Name = "webm" + ContainerMKV Name = "mkv" +) + +type EBMLMuxer struct { + writers []mkvcore.BlockWriteCloser + container Name + tempFileName string + audioSampleRate float64 + audioChannels uint64 + durationPos uint64 + duration int64 + audioStreamIndex int + videoStreamIndex int +} + +func NewEBMLMuxer(sampleRate int, channels int, container Name) *EBMLMuxer { + return &EBMLMuxer{ + writers: nil, + tempFileName: "", + audioSampleRate: float64(sampleRate), + audioChannels: uint64(channels), + durationPos: 0, + duration: 0, + container: container, + } +} + +func (w *EBMLMuxer) makeWebmWriters() ([]mkvcore.BlockWriteCloser, error) { + const ( + trackTypeVideo = 1 + trackTypeAudio = 2 + ) + tempFile, err := record.CreateFileInDir(fmt.Sprintf("videos/%d.webm", rand.Int())) + if err != nil { + return nil, err + } + w.audioStreamIndex = 0 + w.videoStreamIndex = 1 + trackEntries := []webm.TrackEntry{ + { + Name: trackNameAudio, + TrackNumber: webmAudioTrackNumber, + TrackUID: webmAudioTrackNumber, + CodecID: codecIDOPUS, + TrackType: trackTypeAudio, + Audio: &webm.Audio{ + SamplingFrequency: w.audioSampleRate, + Channels: w.audioChannels, + }, + }, + { + Name: trackNameVideo, + TrackNumber: webmVideoTrackNumber, + TrackUID: webmVideoTrackNumber, + CodecID: codecIDVP8, + TrackType: trackTypeVideo, + Video: &webm.Video{ + PixelWidth: 1280, + PixelHeight: 720, + }, + }, + } + writers, err := webm.NewSimpleBlockWriter(tempFile, trackEntries, + mkvcore.WithSeekHead(true), + mkvcore.WithOnErrorHandler(func(err error) { + log.Error(context.Background(), err, "failed to construct webm writer (error)") + }), + mkvcore.WithOnFatalHandler(func(err error) { + log.Error(context.Background(), err, "failed to construct webm writer (fatal)") + }), + mkvcore.WithSegmentInfo(&webm.Info{ + TimecodeScale: defaultTimecode, // 1ms + MuxingApp: "mrw-v4.ebml-go.webm", + WritingApp: "mrw-v4.ebml-go.webm", + Duration: defaultDuration, // Arbitrarily set to default videoSplitIntervalMs, final value is adjusted in writeTrailer. + }), + mkvcore.WithMarshalOptions(ebml.WithElementWriteHooks(func(e *ebml.Element) { + switch e.Name { + case "Duration": + w.durationPos = e.Position + 4 // Duration header size = 3, SegmentInfo header size delta = 1 + } + })), + ) + if err != nil { + return nil, err + } + w.tempFileName = tempFile.Name() + var mkvWriters []mkvcore.BlockWriteCloser + for _, writer := range writers { + mkvWriters = append(mkvWriters, writer) + } + return mkvWriters, nil +} + +func (w *EBMLMuxer) makeMKVWriters() ([]mkvcore.BlockWriteCloser, error) { + const ( + trackTypeVideo = 1 + trackTypeAudio = 2 + ) + tempFile, err := record.CreateFileInDir(fmt.Sprintf("videos/%d.mkv", rand.Int())) + if err != nil { + return nil, err + } + var mkvTrackDesc []mkvcore.TrackDescription + w.audioStreamIndex = 0 + w.videoStreamIndex = 1 + mkvTrackDesc = append(mkvTrackDesc, mkvcore.TrackDescription{ + TrackNumber: 1, + TrackEntry: webm.TrackEntry{ + Name: trackNameAudio, + TrackNumber: 1, + TrackUID: 1, + CodecID: codecIDOPUS, + TrackType: trackTypeAudio, + Audio: &webm.Audio{ + SamplingFrequency: w.audioSampleRate, + Channels: 2, + }, + }, + }) + mkvTrackDesc = append(mkvTrackDesc, mkvcore.TrackDescription{ + TrackNumber: webmVideoTrackNumber, + TrackEntry: webm.TrackEntry{ + TrackNumber: webmVideoTrackNumber, + TrackUID: webmVideoTrackNumber, + TrackType: trackTypeVideo, + DefaultDuration: 0, + Name: trackNameVideo, + CodecID: codecIDH264, + SeekPreRoll: 0, + // TODO: The resolution may need to be written later, but it works fine without it for now. + //Video: &webm.Video{ + // PixelWidth: 1280, + // PixelHeight: 720, + //}, + }, + }) + var mkvWriters []mkvcore.BlockWriteCloser + mkvWriters, err = mkvcore.NewSimpleBlockWriter(tempFile, mkvTrackDesc, + mkvcore.WithSeekHead(true), + mkvcore.WithEBMLHeader(mkv.DefaultEBMLHeader), + mkvcore.WithSegmentInfo(&webm.Info{ + TimecodeScale: defaultTimecode, // 1ms + MuxingApp: "mrw-v4.ebml-go.mkv", + WritingApp: "mrw-v4.ebml-go.mkv", + Duration: defaultDuration, // Arbitrarily set to default videoSplitIntervalMs, final value is adjusted in writeTrailer. + }), + mkvcore.WithBlockInterceptor(webm.DefaultBlockInterceptor), + mkvcore.WithMarshalOptions(ebml.WithElementWriteHooks(func(e *ebml.Element) { + switch e.Name { + case "Duration": + w.durationPos = e.Position + 4 // Duration header size = 3, SegmentInfo header size delta = 1 + } + })), + ) + if err != nil { + return nil, err + } + w.tempFileName = tempFile.Name() + return mkvWriters, nil +} + +func (w *EBMLMuxer) Init(_ context.Context) error { + var err error + switch w.container { + case ContainerWebM: + w.writers, err = w.makeWebmWriters() + case ContainerMKV: + w.writers, err = w.makeMKVWriters() + default: + return errInitFailed + } + return err +} + +func (w *EBMLMuxer) Initialized() bool { + return len(w.writers) > 0 +} + +func (w *EBMLMuxer) WriteVideo(data []byte, keyframe bool, pts uint64, _ uint64) error { + if _, err := w.writers[w.videoStreamIndex].Write(keyframe, int64(pts), data); err != nil { + return err + } + if w.duration < int64(pts) { + w.duration = int64(pts) + } + return nil +} + +func (w *EBMLMuxer) WriteAudio(data []byte, keyframe bool, pts uint64, _ uint64) error { + if _, err := w.writers[w.audioStreamIndex].Write(keyframe, int64(pts), data); err != nil { + return err + } + if w.duration < int64(pts) { + w.duration = int64(pts) + } + return nil +} + +func (w *EBMLMuxer) Finalize(ctx context.Context) error { + defer func() { + w.cleanup() + }() + log.Info(ctx, "finalize webm muxer") + fileName := w.tempFileName + for _, writer := range w.writers { + if err := writer.Close(); err != nil { + log.Error(ctx, err, "failed to close writer") + } + } + if err := w.overwritePTS(ctx, fileName); err != nil { + return err + } + return nil +} + +func (w *EBMLMuxer) ContainerName() string { + return string(w.container) +} + +func (w *EBMLMuxer) overwritePTS(ctx context.Context, fileName string) error { + tempFile, err := os.OpenFile(fileName, os.O_RDWR, 0o600) + if err != nil { + return err + } + defer func() { + if err := tempFile.Close(); err != nil { + log.Error(ctx, err, "failed to close temp file") + } + }() + ptsBytes, _ := EncodeFloat64(float64(w.duration)) + if _, err := tempFile.WriteAt(ptsBytes, int64(w.durationPos)); err != nil { + return err + } + return nil +} + +func (w *EBMLMuxer) cleanup() { + w.writers = nil + w.tempFileName = "" + w.duration = 0 + w.durationPos = 0 +} + +func EncodeFloat64(i float64) ([]byte, error) { + b := make([]byte, 8) + binary.BigEndian.PutUint64(b, math.Float64bits(i)) + return b, nil +} diff --git a/media/streamer/egress/whep/whep.go b/media/streamer/egress/whep/whep.go new file mode 100644 index 0000000..f7c4710 --- /dev/null +++ b/media/streamer/egress/whep/whep.go @@ -0,0 +1,222 @@ +package whep + +import ( + "context" + "errors" + "liveflow/media/streamer/processes" + + astiav "github.com/asticode/go-astiav" + + "github.com/deepch/vdk/codec/aacparser" + "github.com/pion/rtp" + "github.com/pion/rtp/codecs" + "github.com/pion/webrtc/v3" + "github.com/sirupsen/logrus" + + "liveflow/log" + "liveflow/media/hub" + "liveflow/media/streamer/fields" +) + +var ( + ErrUnsupportedCodec = errors.New("unsupported codec") +) + +const ( + audioSampleRate = 48000 +) + +type WHEPArgs struct { + Tracks map[string][]*webrtc.TrackLocalStaticRTP + Hub *hub.Hub +} +type packetWithTimestamp struct { + packet *rtp.Packet + timestamp uint32 +} + +// WHEP represents a WebRTC to HLS conversion pipeline. +type WHEP struct { + hub *hub.Hub + tracks map[string][]*webrtc.TrackLocalStaticRTP + audioTrack *webrtc.TrackLocalStaticRTP + videoTrack *webrtc.TrackLocalStaticRTP + audioPacketizer rtp.Packetizer + videoPacketizer rtp.Packetizer + lastAudioTimestamp int64 + lastVideoTimestamp int64 + + videoBuffer []*packetWithTimestamp + audioBuffer []*packetWithTimestamp +} + +func NewWHEP(args WHEPArgs) *WHEP { + return &WHEP{ + hub: args.Hub, + tracks: args.Tracks, + } +} + +func (w *WHEP) Start(ctx context.Context, source hub.Source) error { + if !hub.HasCodecType(source.MediaSpecs(), hub.CodecTypeOpus) && !hub.HasCodecType(source.MediaSpecs(), hub.CodecTypeAAC) { + return ErrUnsupportedCodec + } + if !hub.HasCodecType(source.MediaSpecs(), hub.CodecTypeH264) { + return ErrUnsupportedCodec + } + ctx = log.WithFields(ctx, logrus.Fields{ + fields.StreamID: source.StreamID(), + fields.SourceName: source.Name(), + }) + log.Info(ctx, "start whep") + sub := w.hub.Subscribe(source.StreamID()) + aProcess := processes.NewTranscodingProcess(astiav.CodecIDAac, astiav.CodecIDOpus, audioSampleRate) + aProcess.Init() + go func() { + for data := range sub { + if data.H264Video != nil { + err := w.onVideo(source, data.H264Video) + if err != nil { + log.Error(ctx, err, "failed to process video") + } + } + if data.AACAudio != nil { + err := w.onAACAudio(ctx, source, data.AACAudio, aProcess) + if err != nil { + log.Error(ctx, err, "failed to process AAC audio") + } + } else { + if data.OPUSAudio != nil { + err := w.onAudio(source, data.OPUSAudio) + if err != nil { + log.Error(ctx, err, "failed to process OPUS audio") + } + } + } + } + }() + return nil +} + +func (w *WHEP) onVideo(source hub.Source, h264Video *hub.H264Video) error { + if w.videoTrack == nil { + var err error + w.videoTrack, err = webrtc.NewTrackLocalStaticRTP(webrtc.RTPCodecCapability{MimeType: webrtc.MimeTypeH264}, "video", "pion") + if err != nil { + return err + } + w.tracks[source.StreamID()] = append(w.tracks[source.StreamID()], w.videoTrack) + ssrc := uint32(110) + const ( + h264PayloadType = 96 + mtu = 1400 + ) + w.videoPacketizer = rtp.NewPacketizer(mtu, h264PayloadType, ssrc, &codecs.H264Payloader{}, rtp.NewRandomSequencer(), h264Video.VideoClockRate) + } + + videoDuration := h264Video.DTS - w.lastVideoTimestamp + videoPackets := w.videoPacketizer.Packetize(h264Video.Data, uint32(videoDuration)) + + for _, packet := range videoPackets { + w.videoBuffer = append(w.videoBuffer, &packetWithTimestamp{packet: packet, timestamp: uint32(h264Video.RawDTS())}) + } + + w.lastVideoTimestamp = h264Video.DTS + w.syncAndSendPackets() + return nil +} + +func (w *WHEP) onAudio(source hub.Source, opusAudio *hub.OPUSAudio) error { + if w.audioTrack == nil { + var err error + w.audioTrack, err = webrtc.NewTrackLocalStaticRTP(webrtc.RTPCodecCapability{MimeType: webrtc.MimeTypeOpus}, "audio", "pion") + if err != nil { + return err + } + w.tracks[source.StreamID()] = append(w.tracks[source.StreamID()], w.audioTrack) + ssrc := uint32(111) + const ( + opusPayloadType = 111 + mtu = 1400 + ) + w.audioPacketizer = rtp.NewPacketizer(mtu, opusPayloadType, ssrc, &codecs.OpusPayloader{}, rtp.NewRandomSequencer(), opusAudio.AudioClockRate) + } + + audioDuration := opusAudio.DTS - w.lastAudioTimestamp + audioPackets := w.audioPacketizer.Packetize(opusAudio.Data, uint32(audioDuration)) + + for _, packet := range audioPackets { + w.audioBuffer = append(w.audioBuffer, &packetWithTimestamp{packet: packet, timestamp: uint32(opusAudio.RawDTS())}) + } + + w.lastAudioTimestamp = opusAudio.DTS + err := w.syncAndSendPackets() + if err != nil { + return err + } + return nil +} + +func (w *WHEP) syncAndSendPackets() error { + for len(w.videoBuffer) > 0 && len(w.audioBuffer) > 0 { + videoPacket := w.videoBuffer[0] + audioPacket := w.audioBuffer[0] + // Remove lagging packet from buffer + if videoPacket.timestamp <= audioPacket.timestamp { + // If audio is ahead, remove video from buffer + w.videoBuffer = w.videoBuffer[1:] + if err := w.videoTrack.WriteRTP(videoPacket.packet); err != nil { + return err + } + } else { + // If video is ahead, remove audio from buffer + w.audioBuffer = w.audioBuffer[1:] + if err := w.audioTrack.WriteRTP(audioPacket.packet); err != nil { + return err + } + } + } + return nil +} + +func abs(x int64) int64 { + if x < 0 { + return -x + } + return x +} +func (w *WHEP) onAACAudio(ctx context.Context, source hub.Source, aac *hub.AACAudio, transcodingProcess *processes.AudioTranscodingProcess) error { + if len(aac.Data) == 0 { + log.Warn(ctx, "no data") + return nil + } + if aac.MPEG4AudioConfig == nil { + log.Warn(ctx, "no config") + return nil + } + const ( + aacSamples = 1024 + adtsHeaderSize = 7 + ) + adtsHeader := make([]byte, adtsHeaderSize) + + aacparser.FillADTSHeader(adtsHeader, *aac.MPEG4AudioConfig, aacSamples, len(aac.Data)) + audioDataWithADTS := append(adtsHeader, aac.Data...) + packets, err := transcodingProcess.Process(&processes.MediaPacket{ + Data: audioDataWithADTS, + PTS: aac.PTS, + DTS: aac.DTS, + }) + if err != nil { + return err + } + for _, packet := range packets { + w.onAudio(source, &hub.OPUSAudio{ + Data: packet.Data, + PTS: packet.PTS, + DTS: packet.DTS, + AudioClockRate: uint32(packet.SampleRate), + }) + } + return nil +} diff --git a/media/streamer/fields/field.go b/media/streamer/fields/field.go new file mode 100644 index 0000000..02186dd --- /dev/null +++ b/media/streamer/fields/field.go @@ -0,0 +1,6 @@ +package fields + +const ( + StreamID = "liveflow_stream_id" + SourceName = "liveflow_source_name" +) diff --git a/media/streamer/ingress/rtmp/handler.go b/media/streamer/ingress/rtmp/handler.go new file mode 100644 index 0000000..04437e1 --- /dev/null +++ b/media/streamer/ingress/rtmp/handler.go @@ -0,0 +1,373 @@ +package rtmp + +import ( + "bytes" + "context" + "fmt" + "io" + "os" + "path/filepath" + + "github.com/deepch/vdk/codec/aacparser" + "github.com/deepch/vdk/codec/h264parser" + "github.com/pkg/errors" + "github.com/yutopp/go-flv" + flvtag "github.com/yutopp/go-flv/tag" + "github.com/yutopp/go-rtmp" + rtmpmsg "github.com/yutopp/go-rtmp/message" + + "liveflow/log" + "liveflow/media/hub" +) + +type Handler struct { + hub *hub.Hub + streamID string + rtmp.DefaultHandler + flvFile *os.File + flvEnc *flv.Encoder + + width int + height int + sps []byte + pps []byte + hasSPS bool + + mediaSpecs []hub.MediaSpec + notifiedSource bool + + MPEG4AudioConfigBytes []byte + MPEG4AudioConfig *aacparser.MPEG4AudioConfig +} + +func (h *Handler) Depth() int { + return 0 +} + +func (h *Handler) Name() string { + return "rtmp" +} + +func (h *Handler) MediaSpecs() []hub.MediaSpec { + return h.mediaSpecs +} + +func (h *Handler) StreamID() string { + return h.streamID +} + +func (h *Handler) OnServe(conn *rtmp.Conn) { +} + +func (h *Handler) OnConnect(timestamp uint32, cmd *rtmpmsg.NetConnectionConnect) error { + log.Infof(context.Background(), "OnConnect: %#v", cmd) + return nil +} + +func (h *Handler) OnCreateStream(timestamp uint32, cmd *rtmpmsg.NetConnectionCreateStream) error { + log.Infof(context.Background(), "OnCreateStream: %#v", cmd) + return nil +} + +func (h *Handler) OnPublish(_ *rtmp.StreamContext, timestamp uint32, cmd *rtmpmsg.NetStreamPublish) error { + ctx := context.Background() + log.Infof(ctx, "OnPublish: %#v", cmd) + + // (example) Reject a connection when PublishingName is empty + if cmd.PublishingName == "" { + return errors.New("PublishingName is empty") + } + + // Record streams as FLV! + p := filepath.Join( + os.TempDir(), + filepath.Clean(filepath.Join("/", fmt.Sprintf("%s.flv", cmd.PublishingName))), + ) + f, err := os.OpenFile(p, os.O_CREATE|os.O_WRONLY, 0666) + if err != nil { + return errors.Wrap(err, "Failed to create flv file") + } + h.flvFile = f + + enc, err := flv.NewEncoder(f, flv.FlagsAudio|flv.FlagsVideo) + if err != nil { + _ = f.Close() + return errors.Wrap(err, "Failed to create flv encoder") + } + h.flvEnc = enc + + h.streamID = cmd.PublishingName + h.mediaSpecs = []hub.MediaSpec{ + { + MediaType: hub.Video, + ClockRate: 90000, + CodecType: "h264", + }, + { + MediaType: hub.Audio, + ClockRate: aacDefaultSampleRate, + CodecType: "aac", + }, + } + + if !h.notifiedSource && len(h.mediaSpecs) == 2 { + h.hub.Notify(ctx, h) + h.notifiedSource = true + } + return nil +} + +func (h *Handler) OnSetDataFrame(timestamp uint32, data *rtmpmsg.NetStreamSetDataFrame) error { + r := bytes.NewReader(data.Payload) + + var script flvtag.ScriptData + if err := flvtag.DecodeScriptData(r, &script); err != nil { + log.Infof(context.Background(), "Failed to decode script data: Err = %+v", err) + return nil // ignore + } + + log.Infof(context.Background(), "SetDataFrame: Script = %#v", script) + + if err := h.flvEnc.Encode(&flvtag.FlvTag{ + TagType: flvtag.TagTypeScriptData, + Timestamp: timestamp, + Data: &script, + }); err != nil { + log.Infof(context.Background(), "Failed to write script data: Err = %+v", err) + } + + return nil +} + +func (h *Handler) onAudio(timestamp uint32, payload io.Reader) error { + ctx := context.Background() + var buf bytes.Buffer + _, err := io.Copy(&buf, payload) + if err != nil { + log.Error(ctx, err, "failed to read audio") + return err + } + var audio flvtag.AudioData + if err := flvtag.DecodeAudioData(bytes.NewBuffer(buf.Bytes()), &audio); err != nil { + return err + } + + flvBody := new(bytes.Buffer) + if _, err := io.Copy(flvBody, audio.Data); err != nil { + return err + } + audio.Data = flvBody + + audioClockRate := float64(flvSampleRate(audio.SoundRate)) + frameData := hub.FrameData{ + AACAudio: &hub.AACAudio{ + AudioClockRate: uint32(audioClockRate), + DTS: int64(float64(timestamp) * (audioClockRate / 1000.0)), + PTS: int64(float64(timestamp) * (audioClockRate / 1000.0)), + }, + } + switch audio.AACPacketType { + case flvtag.AACPacketTypeSequenceHeader: + //log.Infof(ctx, "AACAudio Sequence Header: %s", hex.Dump(flvBody.Bytes())) + codecData, err := aacparser.NewCodecDataFromMPEG4AudioConfigBytes(flvBody.Bytes()) + if err != nil { + log.Error(ctx, err, "failed to NewCodecDataFromMPEG4AudioConfigBytes") + return err + } + h.MPEG4AudioConfig = &codecData.Config + h.MPEG4AudioConfigBytes = codecData.MPEG4AudioConfigBytes() + frameData.AACAudio.MPEG4AudioConfig = &codecData.Config + frameData.AACAudio.MPEG4AudioConfigBytes = codecData.MPEG4AudioConfigBytes() + frameData.AACAudio.SequenceHeader = true + case flvtag.AACPacketTypeRaw: + frameData.AACAudio.Data = flvBody.Bytes() + frameData.AACAudio.MPEG4AudioConfig = h.MPEG4AudioConfig + frameData.AACAudio.MPEG4AudioConfigBytes = h.MPEG4AudioConfigBytes + frameData.AACAudio.SequenceHeader = false + } + h.hub.Publish(h.streamID, &frameData) + return nil +} + +func (h *Handler) onVideo(timestamp uint32, payload io.Reader) error { + ctx := context.Background() + + // Read the payload data into a buffer + payloadBuffer, err := h.readPayload(payload) + if err != nil { + log.Error(ctx, err, "Failed to read video payload") + return err + } + + // Decode the payload data into a VideoData struct + videoData, err := h.decodeVideoData(payloadBuffer) + if err != nil { + return err + } + if videoData.CodecID == flvtag.CodecIDAVC { + } + + // Process the FLV body data and perform corresponding tasks + return h.processVideoData(ctx, timestamp, videoData) +} + +// Function to read payload data into a buffer +func (h *Handler) readPayload(payload io.Reader) (*bytes.Buffer, error) { + var payloadBuffer bytes.Buffer + _, err := io.Copy(&payloadBuffer, payload) + if err != nil { + return nil, err + } + return &payloadBuffer, nil +} + +// Function to decode payload data into a VideoData struct +func (h *Handler) decodeVideoData(payloadBuffer *bytes.Buffer) (*flvtag.VideoData, error) { + var videoData flvtag.VideoData + err := flvtag.DecodeVideoData(bytes.NewBuffer(payloadBuffer.Bytes()), &videoData) + if err != nil { + return nil, err + } + return &videoData, nil +} + +// Function to process data based on VideoData +func (h *Handler) processVideoData(ctx context.Context, timestamp uint32, videoData *flvtag.VideoData) error { + flvBodyBuffer := new(bytes.Buffer) + if _, err := io.Copy(flvBodyBuffer, videoData.Data); err != nil { + return err + } + videoData.Data = flvBodyBuffer + + switch videoData.AVCPacketType { + case flvtag.AVCPacketTypeSequenceHeader: + return h.handleSequenceHeader(ctx, flvBodyBuffer) + + case flvtag.AVCPacketTypeNALU: + return h.handleNALU(ctx, timestamp, videoData.CompositionTime, flvBodyBuffer) + } + + return nil +} + +// Function to handle AVCPacketTypeSequenceHeader +func (h *Handler) handleSequenceHeader(ctx context.Context, flvBodyBuffer *bytes.Buffer) error { + seqHeader, err := h264parser.NewCodecDataFromAVCDecoderConfRecord(flvBodyBuffer.Bytes()) + if err != nil { + log.Error(ctx, err, "Failed to parse AVCDecoderConfigurationRecord") + return err + } + + h.width = seqHeader.Width() + h.height = seqHeader.Height() + h.sps = append([]byte{}, seqHeader.SPS()...) + h.pps = append([]byte{}, seqHeader.PPS()...) + h.hasSPS = true + + log.Info(ctx, "Received AVCPacketTypeSequenceHeader") + return nil +} + +// Function to handle AVCPacketTypeNALU +func (h *Handler) handleNALU(ctx context.Context, timestamp uint32, compositionTime int32, flvBodyBuffer *bytes.Buffer) error { + h.updateSPSPPS(flvBodyBuffer.Bytes()) + + videoDataToSend := h.prepareVideoData(flvBodyBuffer.Bytes()) + if len(videoDataToSend) == 0 { + return nil + } + + h.publishVideoData(timestamp, compositionTime, videoDataToSend) + return nil +} + +// Function to analyze NALU data and update SPS, PPS information +func (h *Handler) updateSPSPPS(naluData []byte) { + nalus, _ := h264parser.SplitNALUs(naluData) + for _, nalu := range nalus { + if len(nalu) < 1 { + continue + } + nalUnitType := nalu[0] & 0x1f + switch nalUnitType { + case h264parser.NALU_SPS: + h.sps = append([]byte{}, nalu...) + case h264parser.NALU_PPS: + h.pps = append([]byte{}, nalu...) + } + } +} + +// Function to prepare video data for transmission by generating the data to send +func (h *Handler) prepareVideoData(naluData []byte) []byte { + var videoDataToSend []byte + hasSPSInData := false + startCode := []byte{0, 0, 0, 1} + + nalus, _ := h264parser.SplitNALUs(naluData) + for _, nalu := range nalus { + if len(nalu) < 1 { + continue + } + sliceType, _ := h264parser.ParseSliceHeaderFromNALU(nalu) + nalUnitType := nalu[0] & 0x1f + switch nalUnitType { + case h264parser.NALU_SPS, h264parser.NALU_PPS: + // SPS and PPS are already handled + default: + // Add SPS and PPS when it's an I-frame + if sliceType == h264parser.SLICE_I && !hasSPSInData { + videoDataToSend = append(videoDataToSend, startCode...) + videoDataToSend = append(videoDataToSend, h.sps...) + videoDataToSend = append(videoDataToSend, startCode...) + videoDataToSend = append(videoDataToSend, h.pps...) + hasSPSInData = true + } + videoDataToSend = append(videoDataToSend, startCode...) + videoDataToSend = append(videoDataToSend, nalu...) + } + } + return videoDataToSend +} + +// Function to send video data to the Hub +func (h *Handler) publishVideoData(timestamp uint32, compositionTime int32, videoDataToSend []byte) { + dts := int64(timestamp) + pts := int64(compositionTime) + dts + + h.hub.Publish(h.streamID, &hub.FrameData{ + H264Video: &hub.H264Video{ + VideoClockRate: 90000, + DTS: dts * 90, + PTS: pts * 90, + Data: videoDataToSend, + SPS: h.sps, + PPS: h.pps, + CodecData: nil, + }, + }) +} + +func (h *Handler) OnClose() { + log.Infof(context.Background(), "OnClose") + + if h.flvFile != nil { + _ = h.flvFile.Close() + } + h.hub.Unpublish(h.streamID) +} + +func flvSampleRate(soundRate flvtag.SoundRate) uint32 { + switch soundRate { + case flvtag.SoundRate5_5kHz: + return 5500 + case flvtag.SoundRate11kHz: + return 11000 + case flvtag.SoundRate22kHz: + return 22000 + case flvtag.SoundRate44kHz: + return 44000 + default: + return aacDefaultSampleRate + } +} diff --git a/media/streamer/ingress/rtmp/server.go b/media/streamer/ingress/rtmp/server.go new file mode 100644 index 0000000..044a7b2 --- /dev/null +++ b/media/streamer/ingress/rtmp/server.go @@ -0,0 +1,74 @@ +package rtmp + +import ( + "context" + "io" + "net" + "strconv" + + "github.com/yutopp/go-rtmp" + + "liveflow/log" + "liveflow/media/hub" +) + +const ( + aacDefaultSampleRate = 44100 +) + +type RTMP struct { + serverConfig *rtmp.ServerConfig + hub *hub.Hub + port int +} + +type RTMPArgs struct { + ServerConfig *rtmp.ServerConfig + Hub *hub.Hub + Port int +} + +func NewRTMP(args RTMPArgs) *RTMP { + return &RTMP{ + //serverConfig: args.ServerConfig, + hub: args.Hub, + port: args.Port, + } +} + +func (r *RTMP) Serve(ctx context.Context) error { + tcpAddr, err := net.ResolveTCPAddr("tcp", ":"+strconv.Itoa(r.port)) + if err != nil { + log.Errorf(ctx, "Failed: %+v", err) + } + listener, err := net.ListenTCP("tcp", tcpAddr) + if err != nil { + log.Errorf(ctx, "Failed: %+v", err) + } + srv := rtmp.NewServer(&rtmp.ServerConfig{ + OnConnect: func(conn net.Conn) (io.ReadWriteCloser, *rtmp.ConnConfig) { + h := &Handler{ + hub: r.hub, + } + return conn, &rtmp.ConnConfig{ + Handler: h, + //ControlState: rtmp.StreamControlStateConfig{ + // DefaultBandwidthWindowSize: 6 * 1024 * 1024 / 8, + //}, + SkipHandshakeVerification: false, + IgnoreMessagesOnNotExistStream: false, + IgnoreMessagesOnNotExistStreamThreshold: 0, + ReaderBufferSize: 0, + WriterBufferSize: 0, + ControlState: rtmp.StreamControlStateConfig{DefaultChunkSize: 0, MaxChunkSize: 0, MaxChunkStreams: 0, DefaultAckWindowSize: 0, MaxAckWindowSize: 0, DefaultBandwidthWindowSize: 6 * 1024 * 1024 / 8, DefaultBandwidthLimitType: 0, MaxBandwidthWindowSize: 0, MaxMessageSize: 0, MaxMessageStreams: 0}, + Logger: nil, + RPreset: nil, + } + }, + }) + log.Info(ctx, "RTMP server started") + if err := srv.Serve(listener); err != nil { + log.Errorf(ctx, "Failed: %+v", err) + } + return nil +} diff --git a/media/streamer/ingress/whip/handler.go b/media/streamer/ingress/whip/handler.go new file mode 100644 index 0000000..306f815 --- /dev/null +++ b/media/streamer/ingress/whip/handler.go @@ -0,0 +1,379 @@ +package whip + +import ( + "context" + "fmt" + "io" + "net/http" + "strings" + "time" + + "github.com/deepch/vdk/codec/h264parser" + "github.com/labstack/echo/v4" + "github.com/pion/rtp" + "github.com/pion/rtp/codecs" + "github.com/pion/webrtc/v3" + + "liveflow/log" + "liveflow/media/hub" +) + +var ( + ErrMissingTrack = fmt.Errorf("missing track") + ErrTrackWaitTimeOut = fmt.Errorf("track wait timeout") +) + +type WebRTCHandler struct { + hub *hub.Hub + pc *webrtc.PeerConnection + streamID string + audioTimestampGen TimestampGenerator[int64] + videoTimestampGen TimestampGenerator[int64] + notifiedSource bool + + mediaArgs []hub.MediaSpec + expectedTrackCount int +} + +func (w *WebRTCHandler) Depth() int { + return 0 +} + +type WebRTCHandlerArgs struct { + Hub *hub.Hub + PeerConnection *webrtc.PeerConnection + StreamID string + Tracks map[string][]*webrtc.TrackLocalStaticRTP + ExpectedTrackCount int +} + +func NewWebRTCHandler(hub *hub.Hub, args *WebRTCHandlerArgs) *WebRTCHandler { + ret := &WebRTCHandler{ + hub: hub, + streamID: args.StreamID, + audioTimestampGen: TimestampGenerator[int64]{}, + videoTimestampGen: TimestampGenerator[int64]{}, + pc: args.PeerConnection, + expectedTrackCount: args.ExpectedTrackCount, + } + return ret +} +func (w *WebRTCHandler) StreamID() string { + return w.streamID +} + +func (w *WebRTCHandler) Name() string { + return "webrtc" +} + +func (w *WebRTCHandler) MediaSpecs() []hub.MediaSpec { + var ret []hub.MediaSpec + for _, arg := range w.mediaArgs { + ret = append(ret, arg) + } + return ret +} + +func (w *WebRTCHandler) WaitTrackArgs(ctx context.Context, timeout time.Duration, trackArgCh <-chan TrackArgs) error { + ctx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + + for { + select { + case <-ctx.Done(): + if len(w.mediaArgs) == 0 { + return ErrMissingTrack + } + return ErrTrackWaitTimeOut + case args := <-trackArgCh: + audioSplits := strings.Split(args.MimeType, "audio/") + videoSplits := strings.Split(args.MimeType, "video/") + if len(audioSplits) > 1 { + w.mediaArgs = append(w.mediaArgs, hub.MediaSpec{ + MediaType: hub.Audio, + ClockRate: args.ClockRate, + CodecType: hub.CodecType(strings.ToLower(audioSplits[1])), + }) + } + if len(videoSplits) > 1 { + w.mediaArgs = append(w.mediaArgs, hub.MediaSpec{ + MediaType: hub.Video, + ClockRate: args.ClockRate, + CodecType: hub.CodecType(strings.ToLower(videoSplits[1])), + }) + } + if len(w.mediaArgs) == w.expectedTrackCount { + w.hub.Notify(ctx, w) + w.notifiedSource = true + return nil + } + } + } +} + +func (w *WebRTCHandler) OnICEConnectionStateChange(connectionState webrtc.ICEConnectionState, trackArgCh <-chan TrackArgs) { + ctx := context.Background() + switch connectionState { + case webrtc.ICEConnectionStateConnected: + log.Info(ctx, "ICE Connection State Connected") + go func() { + err := w.WaitTrackArgs(ctx, 3*time.Second, trackArgCh) + if err != nil { + log.Error(ctx, err, "failed to wait track args") + return + } + }() + case webrtc.ICEConnectionStateDisconnected: + w.OnClose(ctx) + //delete(w.tracks, streamKey) + log.Info(ctx, "ICE Connection State Disconnected") + case webrtc.ICEConnectionStateFailed: + log.Info(ctx, "ICE Connection State Failed") + _ = w.pc.Close() + } +} + +type TrackArgs struct { + MimeType string + ClockRate uint32 + Channels uint16 +} + +func (w *WebRTCHandler) OnTrack(track *webrtc.TrackRemote, receiver *webrtc.RTPReceiver, trackArgCh chan<- TrackArgs) { + ctx := context.Background() + fmt.Printf("Track has started, of type %s %s\n", track.Kind(), track.Codec().MimeType) + var videoPackets []*rtp.Packet + var audioPackets []*rtp.Packet + var videoPacketsQueue [][]*rtp.Packet + var audioPacketsQueue [][]*rtp.Packet + currentVideoTimestamp := uint32(0) + currentAudioTimestamp := uint32(0) + trackArgCh <- TrackArgs{ + MimeType: track.Codec().MimeType, + ClockRate: track.Codec().ClockRate, + Channels: track.Codec().Channels, + } + for { + pkt, _, err := track.ReadRTP() + if err != nil { + log.Error(ctx, err, "failed to read rtp") + break + } + + switch track.Kind() { + case webrtc.RTPCodecTypeVideo: + if len(videoPackets) > 0 && currentVideoTimestamp != pkt.Timestamp { + videoPacketsQueue = append(videoPacketsQueue, videoPackets) + videoPackets = nil + } + + videoPackets = append(videoPackets, pkt) + currentVideoTimestamp = pkt.Timestamp + if pkt.Marker { + videoPacketsQueue = append(videoPacketsQueue, videoPackets) + videoPackets = nil + } + case webrtc.RTPCodecTypeAudio: + if len(audioPackets) > 0 && currentAudioTimestamp != pkt.Timestamp { + audioPacketsQueue = append(audioPacketsQueue, audioPackets) + audioPackets = nil + } + audioPackets = append(audioPackets, pkt) + currentAudioTimestamp = pkt.Timestamp + if pkt.Marker { + audioPacketsQueue = append(audioPacketsQueue, audioPackets) + audioPackets = nil + } + } + if len(videoPacketsQueue) > 0 || len(audioPacketsQueue) > 0 { + if !w.notifiedSource { + log.Warn(ctx, "not yet notified source") + } + } + if w.notifiedSource { + for _, videoPackets := range videoPacketsQueue { + w.onVideo(ctx, videoPackets) + } + videoPacketsQueue = nil + for _, audioPackets := range audioPacketsQueue { + w.onAudio(ctx, track.Codec().ClockRate, audioPackets) + } + audioPacketsQueue = nil + } + } + +} +func (w *WebRTCHandler) OnClose(ctx context.Context) error { + w.hub.Unpublish(w.streamID) + log.Info(ctx, "OnClose") + return nil +} + +func (w *WebRTCHandler) onVideo(ctx context.Context, packets []*rtp.Packet) error { + var h264RTPParser = &codecs.H264Packet{} + payload := make([]byte, 0) + for _, pkt := range packets { + if len(pkt.Payload) == 0 { + continue + } + b, err := h264RTPParser.Unmarshal(pkt.Payload) + if err != nil { + log.Error(ctx, err, "failed to unmarshal h264") + } + payload = append(payload, b...) + } + + if len(payload) == 0 { + return nil + } + pts := w.videoTimestampGen.Generate(int64(packets[0].Timestamp)) + nalus, _ := h264parser.SplitNALUs(payload) + var slice hub.SliceType + for _, nalu := range nalus { + if len(nalu) < 1 { + continue + } + nalUnitType := nalu[0] & 0x1f + switch nalUnitType { + case h264parser.NALU_SPS: + slice = hub.SliceSPS + case h264parser.NALU_PPS: + slice = hub.SlicePPS + default: + sliceType, _ := h264parser.ParseSliceHeaderFromNALU(nalu) + switch sliceType { + case h264parser.SLICE_I: + slice = hub.SliceI + case h264parser.SLICE_P: + slice = hub.SliceP + case h264parser.SLICE_B: + slice = hub.SliceB + } + } + } + w.hub.Publish(w.streamID, &hub.FrameData{ + H264Video: &hub.H264Video{ + PTS: pts, + DTS: pts, + VideoClockRate: 90000, + Data: payload, + SPS: nil, + PPS: nil, + SliceType: slice, + CodecData: nil, + }, + AACAudio: nil, + }) + + return nil +} + +func (w *WebRTCHandler) onAudio(ctx context.Context, clockRate uint32, packets []*rtp.Packet) error { + var opusRTPParser = &codecs.OpusPacket{} + payload := make([]byte, 0) + for _, pkt := range packets { + if len(pkt.Payload) == 0 { + continue + } + b, err := opusRTPParser.Unmarshal(pkt.Payload) + if err != nil { + log.Error(ctx, err, "failed to unmarshal opus") + } + payload = append(payload, b...) + } + + if len(payload) == 0 { + return nil + } + pts := w.audioTimestampGen.Generate(int64(packets[0].Timestamp)) + w.hub.Publish(w.streamID, &hub.FrameData{ + OPUSAudio: &hub.OPUSAudio{ + PTS: pts, + DTS: pts, + AudioClockRate: clockRate, + Data: payload, + }, + }) + return nil +} + +func (r *WHIP) whepHandler(c echo.Context) error { + // Read the offer from HTTP Request + offer, err := io.ReadAll(c.Request().Body) + if err != nil { + return c.JSON(http.StatusInternalServerError, err.Error()) + } + streamKey, err := r.bearerToken(c) + if err != nil { + return c.JSON(http.StatusInternalServerError, err.Error()) + } + + // Create a MediaEngine object to configure the supported codec + m := &webrtc.MediaEngine{} + err = registerCodec(m) + if err != nil { + return c.JSON(http.StatusInternalServerError, err.Error()) + } + + se := webrtc.SettingEngine{} + se.SetEphemeralUDPPortRange(30000, 30500) + if r.dockerMode { + se.SetNAT1To1IPs([]string{"127.0.0.1"}, webrtc.ICECandidateTypeHost) + } + // Create a new RTCPeerConnection + api := webrtc.NewAPI(webrtc.WithMediaEngine(m), webrtc.WithSettingEngine(se)) + peerConnection, err := api.NewPeerConnection(peerConnectionConfiguration) + if err != nil { + return c.JSON(http.StatusInternalServerError, err.Error()) + } + + var rtpSenders []*webrtc.RTPSender + fmt.Println("tracks: ", len(r.tracks)) + for _, track := range r.tracks[streamKey] { + sender, err := peerConnection.AddTrack(track) + if err != nil { + return c.JSON(http.StatusInternalServerError, err.Error()) + } + rtpSenders = append(rtpSenders, sender) + } + + // Read incoming RTCP packets + go func() { + rtcpBuf := make([]byte, 1500) + for { + for _, rtpSender := range rtpSenders { + _, _, rtcpErr := rtpSender.Read(rtcpBuf) + if rtcpErr != nil { + return + } + } + } + }() + peerConnection.OnICEConnectionStateChange(func(connectionState webrtc.ICEConnectionState) { + fmt.Printf("ICE Connection State has changed: %s\n", connectionState.String()) + + if connectionState == webrtc.ICEConnectionStateFailed { + _ = peerConnection.Close() + } + }) + // Send answer via HTTP Response + return writeAnswer3(c, peerConnection, offer, "/whep") +} + +func registerCodec(m *webrtc.MediaEngine) error { + // Setup the codecs you want to use. + var err error + if err = m.RegisterCodec(webrtc.RTPCodecParameters{ + RTPCodecCapability: webrtc.RTPCodecCapability{MimeType: webrtc.MimeTypeH264, ClockRate: 90000, Channels: 0, SDPFmtpLine: "", RTCPFeedback: nil}, + PayloadType: 96, + }, webrtc.RTPCodecTypeVideo); err != nil { + return err + } + if err = m.RegisterCodec(webrtc.RTPCodecParameters{ + RTPCodecCapability: webrtc.RTPCodecCapability{MimeType: webrtc.MimeTypeOpus, ClockRate: 48000, Channels: 2, SDPFmtpLine: "", RTCPFeedback: nil}, + PayloadType: 111, + }, webrtc.RTPCodecTypeAudio); err != nil { + return err + } + return nil +} diff --git a/media/streamer/ingress/whip/serve.go b/media/streamer/ingress/whip/serve.go new file mode 100644 index 0000000..9bb0919 --- /dev/null +++ b/media/streamer/ingress/whip/serve.go @@ -0,0 +1,202 @@ +package whip + +import ( + "context" + "fmt" + "io" + "liveflow/log" + "net/http" + "strconv" + "strings" + + "github.com/labstack/echo/v4" + "github.com/pion/interceptor" + "github.com/pion/interceptor/pkg/intervalpli" + "github.com/pion/sdp/v3" + "github.com/pion/webrtc/v3" + + "liveflow/media/hub" +) + +var ( + errNoStreamKey = echo.NewHTTPError(http.StatusUnauthorized, "No stream key provided") +) + +var ( + peerConnectionConfiguration = webrtc.Configuration{ + ICEServers: []webrtc.ICEServer{ + { + URLs: []string{"stun:stun.l.google.com:19302"}, + }, + }, + } +) + +type WHIP struct { + hub *hub.Hub + tracks map[string][]*webrtc.TrackLocalStaticRTP + dockerMode bool + port int +} + +type WHIPArgs struct { + Hub *hub.Hub + Tracks map[string][]*webrtc.TrackLocalStaticRTP + DockerMode bool + Port int +} + +func NewWHIP(args WHIPArgs) *WHIP { + return &WHIP{ + hub: args.Hub, + tracks: args.Tracks, + dockerMode: args.DockerMode, + port: args.Port, + } +} + +func (r *WHIP) Serve() { + whipServer := echo.New() + whipServer.HideBanner = true + whipServer.Static("/", ".") + whipServer.POST("/whip", r.whipHandler) + whipServer.POST("/whep", r.whepHandler) + //whipServer.PATCH("/whip", whipHandler) + whipServer.Start(":" + strconv.Itoa(r.port)) +} + +func (r *WHIP) bearerToken(c echo.Context) (string, error) { + bearerToken := c.Request().Header.Get("Authorization") + if len(bearerToken) == 0 { + return "", errNoStreamKey + } + authHeaderParts := strings.Split(bearerToken, " ") + if len(authHeaderParts) != 2 { + return "", errNoStreamKey + } + return authHeaderParts[1], nil +} + +func (r *WHIP) whipHandler(c echo.Context) error { + ctx := context.Background() + // Read the offer from HTTP Request + offer, err := io.ReadAll(c.Request().Body) + if err != nil { + return c.JSON(http.StatusInternalServerError, err.Error()) + } + + // Parse the SDP + parsedSDP := sdp.SessionDescription{} + if err := parsedSDP.Unmarshal([]byte(offer)); err != nil { + return c.JSON(http.StatusInternalServerError, err.Error()) + } + + // Count the number of media tracks + trackCount := 0 + for _, media := range parsedSDP.MediaDescriptions { + if media.MediaName.Media == "audio" || media.MediaName.Media == "video" { + trackCount++ + } + } + + streamKey, err := r.bearerToken(c) + if err != nil { + return c.JSON(http.StatusInternalServerError, err.Error()) + } + fmt.Println("streamkey: ", streamKey) + + // Create a MediaEngine object to configure the supported codec + m := &webrtc.MediaEngine{} + + err = registerCodec(m) + if err != nil { + return c.JSON(http.StatusInternalServerError, err.Error()) + } + // Create a InterceptorRegistry + i := &interceptor.Registry{} + + // Register a intervalpli factory + intervalPliFactory, err := intervalpli.NewReceiverInterceptor() + if err != nil { + return c.JSON(http.StatusInternalServerError, err.Error()) + } + i.Add(intervalPliFactory) + + // Use the default set of Interceptors + if err = webrtc.RegisterDefaultInterceptors(m, i); err != nil { + return c.JSON(http.StatusInternalServerError, err.Error()) + } + + // Create the API object with the MediaEngine + se := webrtc.SettingEngine{} + se.SetEphemeralUDPPortRange(30000, 30500) + if r.dockerMode { + se.SetNAT1To1IPs([]string{"127.0.0.1"}, webrtc.ICECandidateTypeHost) + se.SetNetworkTypes([]webrtc.NetworkType{webrtc.NetworkTypeUDP4}) + } + api := webrtc.NewAPI(webrtc.WithMediaEngine(m), webrtc.WithInterceptorRegistry(i), webrtc.WithSettingEngine(se)) + + // Create a new RTCPeerConnection + peerConnection, err := api.NewPeerConnection(peerConnectionConfiguration) + if err != nil { + return c.JSON(http.StatusInternalServerError, err.Error()) + } + + // Allow us to receive 1 video track + if _, err = peerConnection.AddTransceiverFromKind(webrtc.RTPCodecTypeVideo); err != nil { + return c.JSON(http.StatusInternalServerError, err.Error()) + } + // Allow us to receive 1 audio track + if _, err = peerConnection.AddTransceiverFromKind(webrtc.RTPCodecTypeAudio); err != nil { + return c.JSON(http.StatusInternalServerError, err.Error()) + } + + whipHandler := NewWebRTCHandler(r.hub, &WebRTCHandlerArgs{ + Hub: r.hub, + PeerConnection: peerConnection, + StreamID: streamKey, + ExpectedTrackCount: trackCount, + }) + trackArgCh := make(chan TrackArgs) + peerConnection.OnICEConnectionStateChange(func(connectionState webrtc.ICEConnectionState) { + whipHandler.OnICEConnectionStateChange(connectionState, trackArgCh) + fmt.Printf("ICE Connection State has changed: %s\n", connectionState.String()) + }) + peerConnection.OnTrack(func(track *webrtc.TrackRemote, receiver *webrtc.RTPReceiver) { + for _, t := range receiver.GetParameters().Codecs { + log.Info(ctx, "OnTrack", "Codec: ", t.MimeType) + } + whipHandler.OnTrack(track, receiver, trackArgCh) + }) + // Send answer via HTTP Response + return writeAnswer3(c, peerConnection, offer, "/whip") +} + +func writeAnswer3(c echo.Context, peerConnection *webrtc.PeerConnection, offer []byte, path string) error { + // Set the handler for ICE connection state + + if err := peerConnection.SetRemoteDescription(webrtc.SessionDescription{Type: webrtc.SDPTypeOffer, SDP: string(offer)}); err != nil { + return c.JSON(http.StatusInternalServerError, err.Error()) + } + + // Create channel that is blocked until ICE Gathering is complete + gatherComplete := webrtc.GatheringCompletePromise(peerConnection) + + // Create answer + answer, err := peerConnection.CreateAnswer(nil) + if err != nil { + return c.JSON(http.StatusInternalServerError, err.Error()) + } else if err = peerConnection.SetLocalDescription(answer); err != nil { + return c.JSON(http.StatusInternalServerError, err.Error()) + } + + // Block until ICE Gathering is complete, disabling trickle ICE + <-gatherComplete + + // WHIP+WHEP expects a Location header and a HTTP Status Code of 201 + c.Response().Header().Add("Location", path) + c.Response().WriteHeader(http.StatusCreated) + + // Write Answer with Candidates as HTTP Response + return c.String(http.StatusOK, peerConnection.LocalDescription().SDP) +} diff --git a/media/streamer/ingress/whip/timegen.go b/media/streamer/ingress/whip/timegen.go new file mode 100644 index 0000000..77028de --- /dev/null +++ b/media/streamer/ingress/whip/timegen.go @@ -0,0 +1,26 @@ +package whip + +import ( + "golang.org/x/exp/constraints" +) + +type TimestampGenerator[T constraints.Unsigned | constraints.Signed] struct { + initialTimestamp T + initialized bool +} + +func (g *TimestampGenerator[T]) IsInitialized() bool { + return g.initialized +} + +func (g *TimestampGenerator[T]) Generate(timestamp T) T { + if !g.initialized { + g.initialTimestamp = timestamp + g.initialized = true + } + return timestamp - g.initialTimestamp +} + +func (g *TimestampGenerator[T]) Reset() { + g.initialized = false +} diff --git a/media/streamer/pipe/pipe.go b/media/streamer/pipe/pipe.go new file mode 100644 index 0000000..3db492b --- /dev/null +++ b/media/streamer/pipe/pipe.go @@ -0,0 +1,140 @@ +package pipe + +import ( + "fmt" + "sync" + "time" +) + +type ProcessInterface[T any, U any] interface { + SetTimeout(timeout time.Duration) + Link(target func(U)) + GenTarget(processFunc func(T) (U, error), initFunc func() error) func(T) + Process(data T) (U, error) + Init() error +} + +// BaseProcess struct that provides common functionality +type BaseProcess[T any, U any] struct { + nextTargets []func(U) + timeout time.Duration + initialized bool + initOnce sync.Once + resultChan chan U +} + +func (bp *BaseProcess[T, U]) ResultChan() chan U { + if bp.resultChan == nil { + bp.resultChan = make(chan U, 1) + } + return bp.resultChan +} + +func (bp *BaseProcess[T, U]) SetTimeout(timeout time.Duration) { + bp.timeout = timeout +} + +func (bp *BaseProcess[T, U]) Link(target func(U)) { + bp.nextTargets = append(bp.nextTargets, target) +} + +func (bp *BaseProcess[T, U]) GenTarget(processFunc func(T) (U, error), initFunc func() error) func(T) { + return func(data T) { + var err error + bp.initOnce.Do(func() { + err = initFunc() // Initialize only once + }) + if err != nil { + fmt.Println(err) + return + } + resultChan := make(chan U, 1) + errChan := make(chan error, 1) + go func() { + result, err := processFunc(data) + if err != nil { + errChan <- err + return + } + resultChan <- result + }() + + select { + case processedData := <-resultChan: + //fmt.Println(processedData) + for _, target := range bp.nextTargets { + if target != nil { + target(processedData) + } + } + case err := <-errChan: + fmt.Println(err) + case <-time.After(bp.timeout): + fmt.Println("Timeout occurred") + } + } +} + +// PipeExecutor struct for executing the pipeline +type PipeExecutor[T any] struct { + start func(T) + lastFlow time.Time + mu sync.Mutex + stopChan chan struct{} + idleTimeout time.Duration + startMonitorOnce sync.Once +} + +func NewPipeExecutor[T any, U any](starter ProcessInterface[T, U], idleTimeout time.Duration) *PipeExecutor[T] { + start := MakeStarter(starter) + executor := &PipeExecutor[T]{ + start: start, + idleTimeout: idleTimeout, + stopChan: make(chan struct{}), + startMonitorOnce: sync.Once{}, + } + return executor +} + +func (pe *PipeExecutor[T]) Execute(data T) { + pe.startMonitorOnce.Do(func() { + go pe.startMonitoring() + }) + + pe.mu.Lock() + pe.lastFlow = time.Now() + pe.mu.Unlock() + pe.start(data) +} + +func (pe *PipeExecutor[T]) startMonitoring() { + ticker := time.NewTicker(100 * time.Millisecond) + defer ticker.Stop() + for { + select { + case <-ticker.C: + pe.mu.Lock() + if time.Since(pe.lastFlow) > pe.idleTimeout { + fmt.Println("lastFlow", pe.lastFlow, "idleTimeout", pe.idleTimeout) + fmt.Println("No data flow detected in the pipeline for", pe.idleTimeout) + pe.lastFlow = time.Now() // Reset lastFlow to avoid repeated logs + pe.StopMonitoring() + } + pe.mu.Unlock() + case <-pe.stopChan: + return + } + } +} + +func (pe *PipeExecutor[T]) StopMonitoring() { + close(pe.stopChan) +} + +func LinkProcesses[T any, U any, V any](a ProcessInterface[T, U], b ProcessInterface[U, V]) { + a.Link(b.GenTarget(b.Process, b.Init)) +} + +func MakeStarter[T any, U any](start ProcessInterface[T, U]) func(T) { + return start.GenTarget(start.Process, start.Init) +} diff --git a/media/streamer/processes/decoder.go b/media/streamer/processes/decoder.go new file mode 100644 index 0000000..f6ecada --- /dev/null +++ b/media/streamer/processes/decoder.go @@ -0,0 +1,69 @@ +package processes + +import ( + "context" + "errors" + "fmt" + "liveflow/log" + "liveflow/media/hub" + "liveflow/media/streamer/pipe" + + astiav "github.com/asticode/go-astiav" +) + +type VideoDecodingProcess struct { + pipe.BaseProcess[hub.H264Video, []*astiav.Frame] + + codecID astiav.CodecID + decCodec *astiav.Codec + decCodecContext *astiav.CodecContext +} + +func NewVideoDecodingProcess(codecID astiav.CodecID) *VideoDecodingProcess { + return &VideoDecodingProcess{ + codecID: codecID, + } +} + +func (v *VideoDecodingProcess) Init() error { + // Create a new codec + v.decCodec = astiav.FindDecoder(v.codecID) + + // Create a new codec context + v.decCodecContext = astiav.AllocCodecContext(v.decCodec) + + // Open codec context + if err := v.decCodecContext.Open(v.decCodec, nil); err != nil { + return err + } + + return nil +} +func (v *VideoDecodingProcess) Process(data hub.H264Video) ([]*astiav.Frame, error) { + // Decode data + ctx := context.Background() + packet := astiav.AllocPacket() + //defer packet.Free() + err := packet.FromData(data.Data) + if err != nil { + log.Error(ctx, err, "failed to create packet") + } + err = v.decCodecContext.SendPacket(packet) + if err != nil { + log.Error(ctx, err, "failed to send packet") + } + var frames []*astiav.Frame + for { + frame := astiav.AllocFrame() + err := v.decCodecContext.ReceiveFrame(frame) + if errors.Is(err, astiav.ErrEof) { + fmt.Println("EOF: ", err.Error()) + break + } else if errors.Is(err, astiav.ErrEagain) { + break + } + frames = append(frames, frame) + } + + return frames, nil +} diff --git a/media/streamer/processes/dump.go b/media/streamer/processes/dump.go new file mode 100644 index 0000000..796670b --- /dev/null +++ b/media/streamer/processes/dump.go @@ -0,0 +1,60 @@ +package processes + +import ( + "context" + "fmt" + "image" + "image/jpeg" + "liveflow/media/streamer/pipe" + "os" + + astiav "github.com/asticode/go-astiav" +) + +type DumpProcess struct { + pipe.BaseProcess[[]*astiav.Frame, interface{}] + index int + i image.Image +} + +func NewDumpProcess() *DumpProcess { + return &DumpProcess{} +} + +func (v *DumpProcess) Init() error { + return nil +} +func (v *DumpProcess) Process(data []*astiav.Frame) (interface{}, error) { + // Decode data + ctx := context.Background() + _ = ctx + for _, frame := range data { + filename := fmt.Sprintf("frame_%d.jpg", v.index) + v.index++ + fd := frame.Data() + if v.i == nil { + var err error + v.i, err = fd.GuessImageFormat() + if err != nil { + fmt.Println(err) + return nil, err + } + } + fd.ToImage(v.i) + saveAsJPEG(v.i, filename) + } + + return nil, nil +} +func saveAsJPEG(img image.Image, filename string) error { + file, err := os.Create(filename) + if err != nil { + return err + } + defer file.Close() + + options := &jpeg.Options{ + Quality: 90, // JPEG 품질 (1~100) + } + return jpeg.Encode(file, img, options) +} diff --git a/media/streamer/processes/transcoder.go b/media/streamer/processes/transcoder.go new file mode 100644 index 0000000..e57f769 --- /dev/null +++ b/media/streamer/processes/transcoder.go @@ -0,0 +1,179 @@ +package processes + +import ( + "context" + "errors" + "fmt" + "liveflow/log" + "liveflow/media/streamer/pipe" + + astiav "github.com/asticode/go-astiav" +) + +type MediaPacket struct { + Data []byte + PTS int64 + DTS int64 + SampleRate int +} +type AudioTranscodingProcess struct { + pipe.BaseProcess[*MediaPacket, []*MediaPacket] + decCodecID astiav.CodecID + encCodecID astiav.CodecID + decCodec *astiav.Codec + decCodecContext *astiav.CodecContext + encCodec *astiav.Codec + encCodecContext *astiav.CodecContext + encSampleRate int + + audioFifo *astiav.AudioFifo + lastPts int64 + //nbSamples int +} + +func NewTranscodingProcess(decCodecID astiav.CodecID, encCodecID astiav.CodecID, encSampleRate int) *AudioTranscodingProcess { + return &AudioTranscodingProcess{ + decCodecID: decCodecID, + encCodecID: encCodecID, + encSampleRate: encSampleRate, + } +} + +func (t *AudioTranscodingProcess) ExtraData() []byte { + return t.encCodecContext.ExtraData() +} + +func (t *AudioTranscodingProcess) Init() error { + t.decCodec = astiav.FindDecoder(t.decCodecID) + if t.decCodec == nil { + return errors.New("codec is nil") + } + t.decCodecContext = astiav.AllocCodecContext(t.decCodec) + if t.decCodecContext == nil { + return errors.New("codec context is nil") + } + if err := t.decCodecContext.Open(t.decCodec, nil); err != nil { + return err + } + + if t.encCodecID == astiav.CodecIDOpus { + t.encCodec = astiav.FindEncoderByName("opus") + } else { + t.encCodec = astiav.FindEncoder(t.encCodecID) + } + if t.encCodec == nil { + return errors.New("codec is nil") + } + t.encCodecContext = astiav.AllocCodecContext(t.encCodec) + if t.encCodecContext == nil { + return errors.New("codec context is nil") + } + if t.decCodecContext.MediaType() == astiav.MediaTypeAudio { + t.encCodecContext.SetChannelLayout(astiav.ChannelLayoutStereo) + t.encCodecContext.SetSampleRate(t.encSampleRate) + t.encCodecContext.SetSampleFormat(astiav.SampleFormatFltp) // t.encCodec.SampleFormats()[0]) + t.encCodecContext.SetBitRate(64000) + //t.encCodecContext.SetTimeBase(astiav.NewRational(1, t.encCodecContext.SampleRate())) + } else { + t.encCodecContext.SetHeight(t.decCodecContext.Height()) + if v := t.encCodec.PixelFormats(); len(v) > 0 { + t.encCodecContext.SetPixelFormat(v[0]) + } else { + t.encCodecContext.SetPixelFormat(t.decCodecContext.PixelFormat()) + } + t.encCodecContext.SetSampleAspectRatio(t.decCodecContext.SampleAspectRatio()) + frameRate := t.decCodecContext.Framerate() + t.encCodecContext.SetTimeBase(astiav.NewRational(frameRate.Den(), frameRate.Num())) + t.encCodecContext.SetWidth(t.decCodecContext.Width()) + } + dict := astiav.NewDictionary() + dict.Set("strict", "-2", 0) + if err := t.encCodecContext.Open(t.encCodec, dict); err != nil { + return err + } + return nil +} + +func (t *AudioTranscodingProcess) Process(data *MediaPacket) ([]*MediaPacket, error) { + ctx := context.Background() + packet := astiav.AllocPacket() + //defer packet.Free() + err := packet.FromData(data.Data) + if err != nil { + log.Error(ctx, err, "failed to create packet") + } + packet.SetPts(data.PTS) + packet.SetDts(data.DTS) + err = t.decCodecContext.SendPacket(packet) + if err != nil { + log.Error(ctx, err, "failed to send packet") + } + if t.audioFifo == nil { + t.audioFifo = astiav.AllocAudioFifo( + t.encCodecContext.SampleFormat(), + t.encCodecContext.ChannelLayout().Channels(), + t.encCodecContext.SampleRate()) + } + var opusAudio []*MediaPacket + for { + frame := astiav.AllocFrame() + defer frame.Free() + err := t.decCodecContext.ReceiveFrame(frame) + if errors.Is(err, astiav.ErrEof) { + fmt.Println("EOF: ", err.Error()) + break + } else if errors.Is(err, astiav.ErrEagain) { + break + } + t.audioFifo.Write(frame) + nbSamples := 0 + for t.audioFifo.Size() >= t.encCodecContext.FrameSize() { + frameToSend := astiav.AllocFrame() + frameToSend.SetNbSamples(t.encCodecContext.FrameSize()) + frameToSend.SetChannelLayout(t.encCodecContext.ChannelLayout()) // t.encCodecContext.ChannelLayout()) + frameToSend.SetSampleFormat(t.encCodecContext.SampleFormat()) + frameToSend.SetSampleRate(t.encCodecContext.SampleRate()) + frameToSend.SetPts(t.lastPts + int64(t.encCodecContext.FrameSize())) + t.lastPts = frameToSend.Pts() + nbSamples += frame.NbSamples() + err := frameToSend.AllocBuffer(0) + if err != nil { + log.Error(ctx, err, "failed to alloc buffer") + } + read, err := t.audioFifo.Read(frameToSend) + if err != nil { + log.Error(ctx, err, "failed to read fifo") + } + if read < frameToSend.NbSamples() { + log.Error(ctx, err, "failed to read fifo") + } + // Encode the frame + err = t.encCodecContext.SendFrame(frameToSend) + if err != nil { + log.Error(ctx, err, "failed to send frame") + } + for { + pkt := astiav.AllocPacket() + defer pkt.Free() + err := t.encCodecContext.ReceivePacket(pkt) + if errors.Is(err, astiav.ErrEof) { + fmt.Println("EOF: ", err.Error()) + break + } else if errors.Is(err, astiav.ErrEagain) { + break + } + opusAudio = append(opusAudio, &MediaPacket{ + Data: pkt.Data(), + PTS: pkt.Pts(), + DTS: pkt.Dts(), + SampleRate: t.encCodecContext.SampleRate(), + }) + } + } + } + select { + case t.ResultChan() <- opusAudio: + default: + } + return opusAudio, nil +}