Initial commit

This commit is contained in:
GyoungSu
2024-09-07 03:02:59 +09:00
commit af318643ae
37 changed files with 4016 additions and 0 deletions

28
.github/workflows/go.yml vendored Normal file
View File

@@ -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 ./...

30
Dockerfile Normal file
View File

@@ -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"]

21
LICENSE Normal file
View File

@@ -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.

0
Makefile Normal file
View File

71
README.md Normal file
View File

@@ -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.

8
config.toml Normal file
View File

@@ -0,0 +1,8 @@
[whep]
port = 5555
[rtmp]
port = 1930
[hls]
port = 8044
[docker]
mode = false

17
config/config.go Normal file
View File

@@ -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"`
}

19
docker-compose.yaml Normal file
View File

@@ -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

74
go.mod Normal file
View File

@@ -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
)

249
go.sum Normal file
View File

@@ -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=

1
handler.go Normal file
View File

@@ -0,0 +1 @@
package main

103
httpsrv/hlshandler.go Normal file
View File

@@ -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
}

125
index.html Normal file
View File

@@ -0,0 +1,125 @@
<html>
<!--
SPDX-FileCopyrightText: 2023 The Pion community <https://pion.ly>
SPDX-License-Identifier: MIT
-->
<head>
<title>whip-whep</title>
</head>
<body>
<button onclick="window.doWHIP()">Publish</button>
<button onclick="window.doWHEP()">Subscribe</button>
<!-- bearer token input -->
<input type="text" id="bearerToken" placeholder="Bearer Token" />
<h3> Video </h3>
<video id="videoPlayer" autoplay muted controls style="width: 500"> </video>
<h3> ICE Connection States </h3>
<div id="iceConnectionStates"></div> <br />
</body>
<script>
let peerConnection = new RTCPeerConnection()
peerConnection.oniceconnectionstatechange = () => {
let el = document.createElement('p')
el.appendChild(document.createTextNode(peerConnection.iceConnectionState))
document.getElementById('iceConnectionStates').appendChild(el);
}
window.doWHEP = () => {
peerConnection.addTransceiver('video', { direction: 'recvonly' })
peerConnection.addTransceiver('audio', { direction: 'recvonly' })
peerConnection.ontrack = function (event) {
document.getElementById('videoPlayer').srcObject = event.streams[0]
}
peerConnection.createOffer().then(offer => {
peerConnection.setLocalDescription(offer);
let bearerToken = document.getElementById('bearerToken').value;
fetch(`/whep`, {
method: 'POST',
body: offer.sdp,
headers: {
Authorization: `Bearer ${bearerToken}`,
'Content-Type': 'application/sdp'
}
}).then(r => r.text())
.then(answer => {
peerConnection.setRemoteDescription({
sdp: answer,
type: 'answer'
})
})
})
}
window.doWHIP = () => {
navigator.mediaDevices.getUserMedia({ video: true, audio: true })
.then(stream => {
document.getElementById('videoPlayer').srcObject = stream
stream.getTracks().forEach(track => {
let sender = peerConnection.addTrack(track, stream);
})
onChangeDefaultCodecs(peerConnection, "video/H264");
peerConnection.createOffer().then(offer => {
// Modify SDP to prefer VP8
// offer.sdp = offer.sdp.replace(
// /m=video\s(\d+)\s[A-Z/]+\s(\d+)/g,
// 'm=video $1 RTP/SAVPF 96\r\n' +
// 'a=rtpmap:96 VP8/90000\r\n'
// );
console.log("offer.sdp", offer.sdp);
peerConnection.setLocalDescription(offer);
let bearerToken = document.getElementById('bearerToken').value;
fetch(`/whip`, {
method: 'POST',
body: offer.sdp,
headers: {
Authorization: `Bearer ${bearerToken}`,
'Content-Type': 'application/sdp'
}
}).then(r => r.text())
.then(answer => {
peerConnection.setRemoteDescription({
sdp: answer,
type: 'answer'
})
})
})
})
}
function onChangeDefaultCodecs(pc, value) {
// 트랜시버 획득
// 0 : 오디오
// 1 : 비디오
const tcvr = pc.getTransceivers()[1];
// 현재 해당 브라우저에서 사용가능한 코덱종류
const codecs = RTCRtpReceiver.getCapabilities("video")?.codecs || [];
// 내가 새롭게 넣을 코덱 배열
const changeCodec = [];
console.log("codecs", codecs);
// 반복문을 돌면서 원하는 코덱의 이름 (예 : "video/H264")을 찾아 새롭게 넣을 코덱 배열에 추가
for (let i = 0; i < codecs.length; i++) {
if (codecs[i].mimeType === value) {
changeCodec.push(codecs[i]);
}
}
console.log(tcvr.setCodecPreferences);
if (tcvr.setCodecPreferences !== undefined) {
// 코덱 우선순위를 내가 새롭게 만든 배열로 설정해준다.
tcvr.setCodecPreferences(changeCodec);
}
};
</script>
</html>

41
install-ffmpeg.sh Normal file
View File

@@ -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."

140
log/log.go Normal file
View File

@@ -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...)
}

118
main.go Normal file
View File

@@ -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)
}

65
media/hlshub/hub.go Normal file
View File

@@ -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
}

135
media/hub/dto.go Normal file
View File

@@ -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
)

153
media/hub/hub.go Normal file
View File

@@ -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)
}
}

31
media/hub/slicetype.go Normal file
View File

@@ -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
)

View File

@@ -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
}

View File

@@ -0,0 +1 @@
package flv

View File

@@ -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),
})
}
}

View File

@@ -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
}

View File

@@ -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),
})
}
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -0,0 +1,6 @@
package fields
const (
StreamID = "liveflow_stream_id"
SourceName = "liveflow_source_name"
)

View File

@@ -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
}
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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)
}

View File

@@ -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
}

140
media/streamer/pipe/pipe.go Normal file
View File

@@ -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)
}

View File

@@ -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
}

View File

@@ -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)
}

View File

@@ -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
}