mirror of
https://github.com/hsnks100/liveflow.git
synced 2025-09-26 20:21:12 +08:00
Initial commit
This commit is contained in:
28
.github/workflows/go.yml
vendored
Normal file
28
.github/workflows/go.yml
vendored
Normal 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
30
Dockerfile
Normal 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
21
LICENSE
Normal 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.
|
71
README.md
Normal file
71
README.md
Normal 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
8
config.toml
Normal file
@@ -0,0 +1,8 @@
|
||||
[whep]
|
||||
port = 5555
|
||||
[rtmp]
|
||||
port = 1930
|
||||
[hls]
|
||||
port = 8044
|
||||
[docker]
|
||||
mode = false
|
17
config/config.go
Normal file
17
config/config.go
Normal 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
19
docker-compose.yaml
Normal 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
74
go.mod
Normal 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
249
go.sum
Normal 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
1
handler.go
Normal file
@@ -0,0 +1 @@
|
||||
package main
|
103
httpsrv/hlshandler.go
Normal file
103
httpsrv/hlshandler.go
Normal 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
125
index.html
Normal 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
41
install-ffmpeg.sh
Normal 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
140
log/log.go
Normal 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
118
main.go
Normal 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
65
media/hlshub/hub.go
Normal 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
135
media/hub/dto.go
Normal 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
153
media/hub/hub.go
Normal 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
31
media/hub/slicetype.go
Normal 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
|
||||
)
|
179
media/streamer/egress/hls/handler.go
Normal file
179
media/streamer/egress/hls/handler.go
Normal 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
|
||||
}
|
1
media/streamer/egress/record/flv/handler.go
Normal file
1
media/streamer/egress/record/flv/handler.go
Normal file
@@ -0,0 +1 @@
|
||||
package flv
|
230
media/streamer/egress/record/mp4/handler.go
Normal file
230
media/streamer/egress/record/mp4/handler.go
Normal 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),
|
||||
})
|
||||
}
|
||||
}
|
21
media/streamer/egress/record/util.go
Normal file
21
media/streamer/egress/record/util.go
Normal 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
|
||||
}
|
136
media/streamer/egress/record/webm/handler.go
Normal file
136
media/streamer/egress/record/webm/handler.go
Normal 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),
|
||||
})
|
||||
}
|
||||
}
|
290
media/streamer/egress/record/webm/webm.go
Normal file
290
media/streamer/egress/record/webm/webm.go
Normal 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
|
||||
}
|
222
media/streamer/egress/whep/whep.go
Normal file
222
media/streamer/egress/whep/whep.go
Normal 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
|
||||
}
|
6
media/streamer/fields/field.go
Normal file
6
media/streamer/fields/field.go
Normal file
@@ -0,0 +1,6 @@
|
||||
package fields
|
||||
|
||||
const (
|
||||
StreamID = "liveflow_stream_id"
|
||||
SourceName = "liveflow_source_name"
|
||||
)
|
373
media/streamer/ingress/rtmp/handler.go
Normal file
373
media/streamer/ingress/rtmp/handler.go
Normal 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
|
||||
}
|
||||
}
|
74
media/streamer/ingress/rtmp/server.go
Normal file
74
media/streamer/ingress/rtmp/server.go
Normal 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
|
||||
}
|
379
media/streamer/ingress/whip/handler.go
Normal file
379
media/streamer/ingress/whip/handler.go
Normal 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
|
||||
}
|
202
media/streamer/ingress/whip/serve.go
Normal file
202
media/streamer/ingress/whip/serve.go
Normal 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)
|
||||
}
|
26
media/streamer/ingress/whip/timegen.go
Normal file
26
media/streamer/ingress/whip/timegen.go
Normal 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
140
media/streamer/pipe/pipe.go
Normal 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)
|
||||
}
|
69
media/streamer/processes/decoder.go
Normal file
69
media/streamer/processes/decoder.go
Normal 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
|
||||
}
|
60
media/streamer/processes/dump.go
Normal file
60
media/streamer/processes/dump.go
Normal 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)
|
||||
}
|
179
media/streamer/processes/transcoder.go
Normal file
179
media/streamer/processes/transcoder.go
Normal 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
|
||||
}
|
Reference in New Issue
Block a user