mirror of
https://github.com/AlexxIT/go2rtc.git
synced 2025-10-26 17:50:28 +08:00
Compare commits
137 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e3f6c459c7 | ||
|
|
91399d3194 | ||
|
|
338da2a747 | ||
|
|
bb5df24ecf | ||
|
|
adb424033f | ||
|
|
70c415a1d8 | ||
|
|
9fd783793e | ||
|
|
665545903c | ||
|
|
830baafffe | ||
|
|
a22c33fd4e | ||
|
|
1ad09f48cc | ||
|
|
5b1ec08341 | ||
|
|
3f22c010ce | ||
|
|
df5f585064 | ||
|
|
c1b810a5fe | ||
|
|
e43b1e4ab6 | ||
|
|
a8612fca43 | ||
|
|
0d18c23cc2 | ||
|
|
c22ede2396 | ||
|
|
6b3a2652b2 | ||
|
|
4bf5034ce7 | ||
|
|
b57027441c | ||
|
|
d3b62d82cf | ||
|
|
836701cb68 | ||
|
|
3aee438e37 | ||
|
|
116e2f739b | ||
|
|
47371fbdcf | ||
|
|
6e62c442f8 | ||
|
|
57b49d735e | ||
|
|
a72fa7fb23 | ||
|
|
b2029d1004 | ||
|
|
3f338c83b7 | ||
|
|
00b445a170 | ||
|
|
4cbbb5407c | ||
|
|
80f77d28c8 | ||
|
|
f60b55b6fa | ||
|
|
c42413866d | ||
|
|
b137eb66d0 | ||
|
|
6a40039645 | ||
|
|
2e4b28d871 | ||
|
|
58146b7e7e | ||
|
|
23db40220b | ||
|
|
557aac185d | ||
|
|
9ed4d4cedb | ||
|
|
b05cbdf3d3 | ||
|
|
497594f53f | ||
|
|
73cdb39335 | ||
|
|
a388002b12 | ||
|
|
6d1c0a2459 | ||
|
|
da3137b6f0 | ||
|
|
d21ce3d27d | ||
|
|
8cee4179f2 | ||
|
|
1153ee3652 | ||
|
|
3240301f27 | ||
|
|
2a20251dbd | ||
|
|
5a2d7de56b | ||
|
|
38ea8b56b8 | ||
|
|
08c2174e94 | ||
|
|
b48f1c1a0b | ||
|
|
cf58a6f952 | ||
|
|
350e677838 | ||
|
|
7b3505f4f4 | ||
|
|
98af8c3dbf | ||
|
|
762edf157a | ||
|
|
4a633cd9b5 | ||
|
|
f4d2c801f0 | ||
|
|
fb4b609914 | ||
|
|
56633229ed | ||
|
|
2d49cfd4b6 | ||
|
|
0f934be9b6 | ||
|
|
c1d6adc189 | ||
|
|
500b8720d5 | ||
|
|
b7391f58a5 | ||
|
|
bef8e6454d | ||
|
|
5243aca8e9 | ||
|
|
69dd4d26ec | ||
|
|
e93d89ec96 | ||
|
|
ec56227900 | ||
|
|
decd3af941 | ||
|
|
e8e43f9d68 | ||
|
|
a1fec1c6f6 | ||
|
|
073acdfec9 | ||
|
|
d05ab79f88 | ||
|
|
e295bc4eaf | ||
|
|
2f436bba4e | ||
|
|
0e28b0c797 | ||
|
|
3acea1ed5a | ||
|
|
3fb8d9af66 | ||
|
|
9bbaf41d54 | ||
|
|
c43530fbd3 | ||
|
|
15777a3d94 | ||
|
|
6e61ac6d2f | ||
|
|
6d7d5f53d8 | ||
|
|
d2bca8d461 | ||
|
|
94b089d1e3 | ||
|
|
b3d16c9fcc | ||
|
|
f0def68482 | ||
|
|
9ddbb326b4 | ||
|
|
a2e58d928e | ||
|
|
3c48fb8bea | ||
|
|
4b0cbb5a73 | ||
|
|
e28b49ea86 | ||
|
|
5c17d8fcb6 | ||
|
|
e040fb591f | ||
|
|
140014f2a6 | ||
|
|
23f72d111e | ||
|
|
f9d5ab9d0a | ||
|
|
8628c48db8 | ||
|
|
6e49d51c33 | ||
|
|
6a61b5234e | ||
|
|
7a0091777d | ||
|
|
d23d2a7eff | ||
|
|
cecbe4166c | ||
|
|
dcb457235c | ||
|
|
bc4e032830 | ||
|
|
8218cda149 | ||
|
|
d1e56feeb6 | ||
|
|
463d05dfd3 | ||
|
|
a1a73f7b45 | ||
|
|
39662e10af | ||
|
|
1c830d6e60 | ||
|
|
2039aa60b3 | ||
|
|
b7016e798f | ||
|
|
0b291f5185 | ||
|
|
395304654a | ||
|
|
e472397705 | ||
|
|
7c1f48e0ad | ||
|
|
f4346a104f | ||
|
|
030972b436 | ||
|
|
efddefa123 | ||
|
|
3c1bdd0dab | ||
|
|
7e7e15d7c8 | ||
|
|
a1a9f77535 | ||
|
|
a06462729d | ||
|
|
331c5bbcad | ||
|
|
58a76efc8a | ||
|
|
5e0f010885 |
@@ -1,4 +1,4 @@
|
||||
name: ci
|
||||
name: docker
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
@@ -19,7 +19,7 @@ jobs:
|
||||
id: meta
|
||||
uses: docker/metadata-action@v4
|
||||
with:
|
||||
images: alexxit/go2rtc
|
||||
images: ${{ github.repository }}
|
||||
tags: |
|
||||
type=ref,event=branch
|
||||
type=semver,pattern={{version}},enable=false
|
||||
@@ -29,7 +29,7 @@ jobs:
|
||||
id: meta-hw
|
||||
uses: docker/metadata-action@v4
|
||||
with:
|
||||
images: alexxit/go2rtc
|
||||
images: ${{ github.repository }}
|
||||
flavor: |
|
||||
suffix=-hardware
|
||||
latest=false
|
||||
99
.github/workflows/release.yml
vendored
Normal file
99
.github/workflows/release.yml
vendored
Normal file
@@ -0,0 +1,99 @@
|
||||
name: release
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
# push:
|
||||
# tags:
|
||||
# - 'v*'
|
||||
|
||||
jobs:
|
||||
build-and-release:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
- name: Generate changelog
|
||||
run: |
|
||||
echo -e "$(git log $(git describe --tags --abbrev=0)..HEAD --oneline | awk '{print "- "$0}')" > CHANGELOG.md
|
||||
- name: install lipo
|
||||
run: |
|
||||
curl -L -o /tmp/lipo https://github.com/konoui/lipo/releases/latest/download/lipo_Linux_amd64
|
||||
chmod +x /tmp/lipo
|
||||
mv /tmp/lipo /usr/local/bin
|
||||
- name: Build Go binaries
|
||||
run: |
|
||||
#!/bin/bash
|
||||
|
||||
export CGO_ENABLED=0
|
||||
|
||||
mkdir -p artifacts
|
||||
|
||||
export GOOS=windows
|
||||
export GOARCH=amd64
|
||||
export FILENAME=artifacts/go2rtc_win64.zip
|
||||
go build -ldflags "-s -w" -trimpath && 7z a -mx9 -sdel "$FILENAME" go2rtc.exe
|
||||
|
||||
export GOOS=windows
|
||||
export GOARCH=386
|
||||
export FILENAME=artifacts/go2rtc_win32.zip
|
||||
go build -ldflags "-s -w" -trimpath && 7z a -mx9 -sdel "$FILENAME" go2rtc.exe
|
||||
|
||||
export GOOS=windows
|
||||
export GOARCH=arm64
|
||||
export FILENAME=artifacts/go2rtc_win_arm64.zip
|
||||
go build -ldflags "-s -w" -trimpath && 7z a -mx9 -sdel "$FILENAME" go2rtc.exe
|
||||
|
||||
export GOOS=linux
|
||||
export GOARCH=amd64
|
||||
export FILENAME=artifacts/go2rtc_linux_amd64
|
||||
go build -ldflags "-s -w" -trimpath -o "$FILENAME"
|
||||
|
||||
export GOOS=linux
|
||||
export GOARCH=386
|
||||
export FILENAME=artifacts/go2rtc_linux_i386
|
||||
go build -ldflags "-s -w" -trimpath -o "$FILENAME"
|
||||
|
||||
export GOOS=linux
|
||||
export GOARCH=arm64
|
||||
export FILENAME=artifacts/go2rtc_linux_arm64
|
||||
go build -ldflags "-s -w" -trimpath -o "$FILENAME"
|
||||
|
||||
export GOOS=linux
|
||||
export GOARCH=arm
|
||||
export GOARM=7
|
||||
export FILENAME=artifacts/go2rtc_linux_arm
|
||||
go build -ldflags "-s -w" -trimpath -o "$FILENAME"
|
||||
|
||||
export GOOS=linux
|
||||
export GOARCH=mipsle
|
||||
export FILENAME=artifacts/go2rtc_linux_mipsel
|
||||
go build -ldflags "-s -w" -trimpath -o "$FILENAME"
|
||||
|
||||
export GOOS=darwin
|
||||
export GOARCH=amd64
|
||||
go build -ldflags "-s -w" -trimpath -o go2rtc.amd64
|
||||
|
||||
export GOOS=darwin
|
||||
export GOARCH=arm64
|
||||
go build -ldflags "-s -w" -trimpath -o go2rtc.arm64
|
||||
|
||||
export FILENAME=artifacts/go2rtc_mac_universal.zip
|
||||
lipo -output go2rtc -create go2rtc.arm64 go2rtc.amd64 && 7z a -mx9 -sdel "$FILENAME" go2rtc
|
||||
|
||||
parallel --jobs $(nproc) "upx {}" ::: artifacts/go2rtc_linux_*
|
||||
- name: Setup tmate session
|
||||
uses: mxschmitt/action-tmate@v3
|
||||
if: ${{ failure() }}
|
||||
- name: Set env
|
||||
run: echo "RELEASE_VERSION=${GITHUB_REF#refs/*/}" >> $GITHUB_ENV
|
||||
- name: Create GitHub release
|
||||
uses: softprops/action-gh-release@v1
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
files: artifacts/*
|
||||
generate_release_notes: true
|
||||
name: Release ${{ env.RELEASE_VERSION }}
|
||||
body_path: CHANGELOG.md
|
||||
draft: false
|
||||
prerelease: false
|
||||
@@ -33,13 +33,12 @@ FROM scratch AS rootfs
|
||||
|
||||
COPY --from=build /build/go2rtc /usr/local/bin/
|
||||
COPY --from=ngrok /bin/ngrok /usr/local/bin/
|
||||
COPY ./build/docker/run.sh /
|
||||
|
||||
|
||||
# 3. Final image
|
||||
FROM base
|
||||
|
||||
# Install ffmpeg, bash (for run.sh), tini (for signal handling),
|
||||
# Install ffmpeg, tini (for signal handling),
|
||||
# and other common tools for the echo source.
|
||||
RUN apk add --no-cache tini ffmpeg bash curl jq
|
||||
|
||||
@@ -55,8 +54,8 @@ RUN if [ "${TARGETARCH}" = "amd64" ]; then apk add --no-cache libva-intel-driver
|
||||
|
||||
COPY --from=rootfs / /
|
||||
|
||||
RUN chmod a+x /run.sh && mkdir -p /config
|
||||
|
||||
ENTRYPOINT ["/sbin/tini", "--"]
|
||||
VOLUME /config
|
||||
WORKDIR /config
|
||||
|
||||
CMD ["/run.sh"]
|
||||
CMD ["go2rtc", "-config", "/config/go2rtc.yaml"]
|
||||
|
||||
278
README.md
278
README.md
@@ -7,9 +7,9 @@ Ultimate camera streaming application with support RTSP, WebRTC, HomeKit, FFmpeg
|
||||
- zero-dependency and zero-config [small app](#go2rtc-binary) for all OS (Windows, macOS, Linux, ARM)
|
||||
- zero-delay for many supported protocols (lowest possible streaming latency)
|
||||
- streaming from [RTSP](#source-rtsp), [RTMP](#source-rtmp), [HTTP](#source-http) (FLV/MJPEG/JPEG), [FFmpeg](#source-ffmpeg), [USB Cameras](#source-ffmpeg-device) and [other sources](#module-streams)
|
||||
- streaming to [RTSP](#module-rtsp), [WebRTC](#module-webrtc), [MSE/MP4](#module-mp4) or [MJPEG](#module-mjpeg)
|
||||
- streaming to [RTSP](#module-rtsp), [WebRTC](#module-webrtc), [MSE/MP4](#module-mp4), [HLS](#module-hls) or [MJPEG](#module-mjpeg)
|
||||
- first project in the World with support streaming from [HomeKit Cameras](#source-homekit)
|
||||
- first project in the World with support H265 for WebRTC in browser ([read more](https://github.com/AlexxIT/Blog/issues/5))
|
||||
- first project in the World with support H265 for WebRTC in browser (Safari only, [read more](https://github.com/AlexxIT/Blog/issues/5))
|
||||
- on the fly transcoding for unsupported codecs via [FFmpeg](#source-ffmpeg)
|
||||
- multi-source 2-way [codecs negotiation](#codecs-negotiation)
|
||||
- mixing tracks from different sources to single stream
|
||||
@@ -27,17 +27,52 @@ Ultimate camera streaming application with support RTSP, WebRTC, HomeKit, FFmpeg
|
||||
- [MediaSoup](https://mediasoup.org/) framework routing idea
|
||||
- HomeKit Accessory Protocol from [@brutella](https://github.com/brutella/hap)
|
||||
|
||||
---
|
||||
|
||||
* [Fast start](#fast-start)
|
||||
* [go2rtc: Binary](#go2rtc-binary)
|
||||
* [go2rtc: Docker](#go2rtc-docker)
|
||||
* [go2rtc: Home Assistant Add-on](#go2rtc-home-assistant-add-on)
|
||||
* [go2rtc: Home Assistant Integration](#go2rtc-home-assistant-integration)
|
||||
* [Configuration](#configuration)
|
||||
* [Module: Streams](#module-streams)
|
||||
* [Source: RTSP](#source-rtsp)
|
||||
* [Source: RTMP](#source-rtmp)
|
||||
* [Source: HTTP](#source-http)
|
||||
* [Source: FFmpeg](#source-ffmpeg)
|
||||
* [Source: FFmpeg Device](#source-ffmpeg-device)
|
||||
* [Source: Exec](#source-exec)
|
||||
* [Source: Echo](#source-echo)
|
||||
* [Source: HomeKit](#source-homekit)
|
||||
* [Source: Ivideon](#source-ivideon)
|
||||
* [Source: Hass](#source-hass)
|
||||
* [Module: API](#module-api)
|
||||
* [Module: RTSP](#module-rtsp)
|
||||
* [Module: WebRTC](#module-webrtc)
|
||||
* [Module: Ngrok](#module-ngrok)
|
||||
* [Module: Hass](#module-hass)
|
||||
* [Module: MP4](#module-mp4)
|
||||
* [Module: HLS](#module-hls)
|
||||
* [Module: MJPEG](#module-mjpeg)
|
||||
* [Module: Log](#module-log)
|
||||
* [Security](#security)
|
||||
* [Codecs filters](#codecs-filters)
|
||||
* [Codecs madness](#codecs-madness)
|
||||
* [Codecs negotiation](#codecs-negotiation)
|
||||
* [Projects using go2rtc](#projects-using-go2rtc)
|
||||
* [Camera experience](#cameras-experience)
|
||||
* [TIPS](#tips)
|
||||
* [FAQ](#faq)
|
||||
|
||||
## Fast start
|
||||
|
||||
1. Download [binary](#go2rtc-binary) or use [Docker](#go2rtc-docker) or [Home Assistant Add-on](#go2rtc-home-assistant-add-on)
|
||||
1. Download [binary](#go2rtc-binary) or use [Docker](#go2rtc-docker) or Home Assistant [Add-on](#go2rtc-home-assistant-add-on) or [Integration](#go2rtc-home-assistant-integration)
|
||||
2. Open web interface: `http://localhost:1984/`
|
||||
|
||||
**Optionally:**
|
||||
|
||||
- add your [streams](#module-streams) to [config](#configuration) file
|
||||
- setup [external access](#module-webrtc) to webrtc
|
||||
- setup [external access](#module-ngrok) to web interface
|
||||
- install [ffmpeg](#source-ffmpeg) for transcoding
|
||||
|
||||
**Developers:**
|
||||
|
||||
@@ -50,7 +85,6 @@ Download binary for your OS from [latest release](https://github.com/AlexxIT/go2
|
||||
|
||||
- `go2rtc_win64.zip` - Windows 64-bit
|
||||
- `go2rtc_win32.zip` - Windows 32-bit
|
||||
- `go2rtc_win_arm64.zip` - Windows ARM 64-bit
|
||||
- `go2rtc_linux_amd64` - Linux 64-bit
|
||||
- `go2rtc_linux_i386` - Linux 32-bit
|
||||
- `go2rtc_linux_arm64` - Linux ARM 64-bit (ex. Raspberry 64-bit OS)
|
||||
@@ -61,6 +95,10 @@ Download binary for your OS from [latest release](https://github.com/AlexxIT/go2
|
||||
|
||||
Don't forget to fix the rights `chmod +x go2rtc_xxx_xxx` on Linux and Mac.
|
||||
|
||||
### go2rtc: Docker
|
||||
|
||||
Container [alexxit/go2rtc](https://hub.docker.com/r/alexxit/go2rtc) with support `amd64`, `386`, `arm64`, `arm`. This container is the same as [Home Assistant Add-on](#go2rtc-home-assistant-add-on), but can be used separately from Home Assistant. Container has preinstalled [FFmpeg](#source-ffmpeg), [Ngrok](#module-ngrok) and [Python](#source-echo).
|
||||
|
||||
### go2rtc: Home Assistant Add-on
|
||||
|
||||
[](https://my.home-assistant.io/redirect/supervisor_addon/?addon=a889bffc_go2rtc&repository_url=https%3A%2F%2Fgithub.com%2FAlexxIT%2Fhassio-addons)
|
||||
@@ -70,29 +108,19 @@ Don't forget to fix the rights `chmod +x go2rtc_xxx_xxx` on Linux and Mac.
|
||||
- go2rtc > Install > Start
|
||||
2. Setup [Integration](#module-hass)
|
||||
|
||||
### go2rtc: Docker
|
||||
### go2rtc: Home Assistant Integration
|
||||
|
||||
Container [alexxit/go2rtc](https://hub.docker.com/r/alexxit/go2rtc) with support `amd64`, `386`, `arm64`, `arm`. This container same as [Home Assistant Add-on](#go2rtc-home-assistant-add-on), but can be used separately from the Home Assistant. Container has preinstalled [FFmpeg](#source-ffmpeg), [Ngrok](#module-ngrok) and [Python](#source-echo).
|
||||
|
||||
```yaml
|
||||
services:
|
||||
go2rtc:
|
||||
image: alexxit/go2rtc
|
||||
network_mode: host
|
||||
restart: always
|
||||
volumes:
|
||||
- "~/go2rtc.yaml:/config/go2rtc.yaml"
|
||||
```
|
||||
[WebRTC Camera](https://github.com/AlexxIT/WebRTC) custom component can be used on any [Home Assistant installation](https://www.home-assistant.io/installation/), including [HassWP](https://github.com/AlexxIT/HassWP) on Windows. It can automatically download and use the latest version of go2rtc. Or it can connect to an existing version of go2rtc. Addon installation in this case is optional.
|
||||
|
||||
## Configuration
|
||||
|
||||
Create file `go2rtc.yaml` next to the app.
|
||||
- by default go2rtc will search `go2rtc.yaml` in the current work dirrectory
|
||||
- `api` server will start on default **1984 port** (TCP)
|
||||
- `rtsp` server will start on default **8554 port** (TCP)
|
||||
- `webrtc` will use port **8555** (TCP/UDP) for connections
|
||||
- `ffmpeg` will use default transcoding options
|
||||
|
||||
- by default, you need to config only your `streams` links
|
||||
- `api` server will start on default **1984 port**
|
||||
- `rtsp` server will start on default **8554 port**
|
||||
- `webrtc` will use random UDP port for each connection
|
||||
- `ffmpeg` will use default transcoding options (you may install it [manually](https://ffmpeg.org/))
|
||||
Configuration options and a complete list of settings can be found in [the wiki](https://github.com/AlexxIT/go2rtc/wiki/Configuration).
|
||||
|
||||
Available modules:
|
||||
|
||||
@@ -101,6 +129,7 @@ Available modules:
|
||||
- [rtsp](#module-rtsp) - RTSP Server (important for FFmpeg support)
|
||||
- [webrtc](#module-webrtc) - WebRTC Server
|
||||
- [mp4](#module-mp4) - MSE, MP4 stream and MP4 shapshot Server
|
||||
- [hls](#module-hls) - HLS TS or fMP4 stream Server
|
||||
- [mjpeg](#module-mjpeg) - MJPEG Server
|
||||
- [ffmpeg](#source-ffmpeg) - FFmpeg integration
|
||||
- [ngrok](#module-ngrok) - Ngrok integration (external access for private network)
|
||||
@@ -216,7 +245,7 @@ But you can override them via YAML config. You can also add your own formats to
|
||||
|
||||
```yaml
|
||||
ffmpeg:
|
||||
bin: ffmpeg # path to ffmpeg binary
|
||||
bin: ffmpeg # path to ffmpeg binary
|
||||
h264: "-codec:v libx264 -g:v 30 -preset:v superfast -tune:v zerolatency -profile:v main -level:v 4.1"
|
||||
mycodec: "-any args that support ffmpeg..."
|
||||
```
|
||||
@@ -224,8 +253,11 @@ ffmpeg:
|
||||
- You can use `video` and `audio` params multiple times (ex. `#video=copy#audio=copy#audio=pcmu`)
|
||||
- You can use go2rtc stream name as ffmpeg input (ex. `ffmpeg:camera1#video=h264`)
|
||||
- You can use `rotate` params with `90`, `180`, `270` or `-90` values, important with transcoding (ex. `#video=h264#rotate=90`)
|
||||
- You can use `width` and/or `height` params, important with transcoding (ex. `#video=h264#width=1280`)
|
||||
- You can use `raw` param for any additional FFmpeg arguments (ex. `#raw=-vf transpose=1`).
|
||||
|
||||
Read more about encoding [hardware acceleration](https://github.com/AlexxIT/go2rtc/wiki/Hardware-acceleration).
|
||||
|
||||
#### Source: FFmpeg Device
|
||||
|
||||
You can get video from any USB-camera or Webcam as RTSP or WebRTC stream. This is part of FFmpeg integration.
|
||||
@@ -366,80 +398,84 @@ go2rtc has simple HTML page (`stream.html`) with support params in URL:
|
||||
```yaml
|
||||
api:
|
||||
listen: ":1984" # default ":1984", HTTP API port ("" - disabled)
|
||||
username: "admin" # default "", Basic auth for WebUI
|
||||
password: "pass" # default "", Basic auth for WebUI
|
||||
base_path: "/rtc" # default "", API prefix for serve on suburl (/api => /rtc/api)
|
||||
static_dir: "www" # default "", folder for static files (custom web interface)
|
||||
origin: "*" # default "", allow CORS requests (only * supported)
|
||||
```
|
||||
|
||||
**PS. go2rtc** doesn't provide HTTPS or password protection. Use [Nginx](https://nginx.org/) or [Ngrok](#module-ngrok) or [Home Assistant Add-on](#go2rtc-home-assistant-add-on) for this tasks.
|
||||
**PS:**
|
||||
|
||||
**PS2.** You can access microphone (for 2-way audio) only with HTTPS ([read more](https://stackoverflow.com/questions/52759992/how-to-access-camera-and-microphone-in-chrome-without-https)).
|
||||
|
||||
**PS3.** MJPEG over WebSocket plays better than native MJPEG because Chrome [bug](https://bugs.chromium.org/p/chromium/issues/detail?id=527446).
|
||||
|
||||
**PS4.** MP4 over WebSocket was created only for Apple iOS because it doesn't support MSE and native MP4.
|
||||
- go2rtc doesn't provide HTTPS. Use [Nginx](https://nginx.org/) or [Ngrok](#module-ngrok) or [Home Assistant Add-on](#go2rtc-home-assistant-add-on) for this tasks
|
||||
- you can access microphone (for 2-way audio) only with HTTPS ([read more](https://stackoverflow.com/questions/52759992/how-to-access-camera-and-microphone-in-chrome-without-https))
|
||||
- MJPEG over WebSocket plays better than native MJPEG because Chrome [bug](https://bugs.chromium.org/p/chromium/issues/detail?id=527446)
|
||||
- MP4 over WebSocket was created only for Apple iOS because it doesn't support MSE and native MP4
|
||||
|
||||
### Module: RTSP
|
||||
|
||||
You can get any stream as RTSP-stream: `rtsp://192.168.1.123:8554/{stream_name}`
|
||||
|
||||
- you can omit the codec filters, so one first video and one first audio will be selected
|
||||
- you can set `?video=copy` or just `?video`, so only one first video without audio will be selected
|
||||
- you can set multiple video or audio, so all of them will be selected
|
||||
- you can enable external password protection for your RTSP streams
|
||||
|
||||
Password protection always disabled for localhost calls (ex. FFmpeg or Hass on same server)
|
||||
You can enable external password protection for your RTSP streams. Password protection always disabled for localhost calls (ex. FFmpeg or Hass on same server).
|
||||
|
||||
```yaml
|
||||
rtsp:
|
||||
listen: ":8554" # RTSP Server TCP port, default - 8554
|
||||
username: admin # optional, default - disabled
|
||||
password: pass # optional, default - disabled
|
||||
listen: ":8554" # RTSP Server TCP port, default - 8554
|
||||
username: "admin" # optional, default - disabled
|
||||
password: "pass" # optional, default - disabled
|
||||
default_query: "video&audio" # optional, default codecs filters
|
||||
```
|
||||
|
||||
By default go2rtc provide RTSP-stream with only one first video and only one first audio. You can change it with the `default_query` setting:
|
||||
|
||||
- `default_query: "mp4"` - MP4 compatible codecs (H264, H265, AAC)
|
||||
- `default_query: "video=all&audio=all"` - all tracks from all source (not all players can handle this)
|
||||
- `default_query: "video=h264,h265"` - only one video track (H264 or H265)
|
||||
- `default_query: "video&audio=all"` - only one first any video and all audio as separate tracks
|
||||
|
||||
Read more about [codecs filters](#codecs-filters).
|
||||
|
||||
### Module: WebRTC
|
||||
|
||||
WebRTC usually works without problems in the local network. But external access may require additional settings. It depends on what type of Internet do you have.
|
||||
|
||||
- by default, WebRTC use two random UDP ports for each connection (video and audio)
|
||||
- you can enable one additional TCP port for all connections and use it for external access
|
||||
- by default, WebRTC uses both TCP and UDP on port 8555 for connections
|
||||
- you can use this port for external access
|
||||
- you can change the port in YAML config:
|
||||
|
||||
```yaml
|
||||
webrtc:
|
||||
listen: ":8555" # address of your local server and port (TCP/UDP)
|
||||
```
|
||||
|
||||
**Static public IP**
|
||||
|
||||
- add some TCP port to YAML config (ex. 8555)
|
||||
- forward this port on your router (you can use same 8555 port or any other)
|
||||
- forward the port 8555 on your router (you can use same 8555 port or any other as external port)
|
||||
- add your external IP-address and external port to YAML config
|
||||
|
||||
```yaml
|
||||
webrtc:
|
||||
listen: ":8555" # address of your local server (TCP)
|
||||
candidates:
|
||||
- 216.58.210.174:8555 # if you have static public IP-address
|
||||
```
|
||||
|
||||
**Dynamic public IP**
|
||||
|
||||
- add some TCP port to YAML config (ex. 8555)
|
||||
- forward this port on your router (you can use same 8555 port or any other)
|
||||
- forward the port 8555 on your router (you can use same 8555 port or any other as the external port)
|
||||
- add `stun` word and external port to YAML config
|
||||
- go2rtc automatically detects your external address with STUN-server
|
||||
|
||||
```yaml
|
||||
webrtc:
|
||||
listen: ":8555" # address of your local server (TCP)
|
||||
candidates:
|
||||
- stun:8555 # if you have dynamic public IP-address
|
||||
```
|
||||
|
||||
**Private IP**
|
||||
|
||||
- add some TCP port to YAML config (ex. 8555)
|
||||
- setup integration with [Ngrok service](#module-ngrok)
|
||||
|
||||
```yaml
|
||||
webrtc:
|
||||
listen: ":8555" # address of your local server (TCP)
|
||||
|
||||
ngrok:
|
||||
command: ...
|
||||
```
|
||||
@@ -517,24 +553,29 @@ tunnels:
|
||||
|
||||
### Module: Hass
|
||||
|
||||
If you install **go2rtc** as [Hass Add-on](#go2rtc-home-assistant-add-on) - you need to use localhost IP-address. In other cases you need to use IP-address of server with **go2rtc** application.
|
||||
The best and easiest way to use go2rtc inside the Home Assistant is to install the custom integration [WebRTC Camera](#go2rtc-home-assistant-integration) and custom lovelace card.
|
||||
|
||||
#### From go2rtc to Hass
|
||||
But go2rtc is also compatible and can be used with [RTSPtoWebRTC](https://www.home-assistant.io/integrations/rtsp_to_webrtc/) built-in integration.
|
||||
|
||||
Add any supported [stream source](#module-streams) as [Generic Camera](https://www.home-assistant.io/integrations/generic/) and view stream with built-in [Stream](https://www.home-assistant.io/integrations/stream/) integration. Technology `HLS`, supported codecs: `H264`, poor latency.
|
||||
You have several options on how to add a camera to Home Assistant:
|
||||
|
||||
1. Add your stream to [go2rtc config](#configuration)
|
||||
2. Hass > Settings > Integrations > Add Integration > [Generic Camera](https://my.home-assistant.io/redirect/config_flow_start/?domain=generic) > `rtsp://127.0.0.1:8554/camera1`
|
||||
1. Camera RTSP source => [Generic Camera](https://www.home-assistant.io/integrations/generic/)
|
||||
2. Camera [any source](#module-streams) => [go2rtc config](#configuration) => [Generic Camera](https://www.home-assistant.io/integrations/generic/)
|
||||
- Install any [go2rtc](#fast-start)
|
||||
- Add your stream to [go2rtc config](#configuration)
|
||||
- Hass > Settings > Integrations > Add Integration > [Generic Camera](https://my.home-assistant.io/redirect/config_flow_start/?domain=generic) > `rtsp://127.0.0.1:8554/camera1` (change to your stream name)
|
||||
|
||||
#### From Hass to go2rtc
|
||||
You have several options on how to watch the stream from the cameras in Home Assistant:
|
||||
|
||||
View almost any Hass camera using `WebRTC` technology, supported codecs `H264`/`PCMU`/`PCMA`/`OPUS`, best latency.
|
||||
|
||||
When the stream starts - the camera `entity_id` will be added to go2rtc "on the fly". You don't need to add cameras manually to [go2rtc config](#configuration). Some cameras (like [Nest](https://www.home-assistant.io/integrations/nest/)) have a dynamic link to the stream, it will be updated each time a stream is started from the Hass interface.
|
||||
|
||||
1. Hass > Settings > Integrations > Add Integration > [RTSPtoWebRTC](https://my.home-assistant.io/redirect/config_flow_start/?domain=rtsp_to_webrtc) > `http://127.0.0.1:1984/`
|
||||
2. RTSPtoWebRTC > Configure > STUN server: `stun.l.google.com:19302`
|
||||
3. Use Picture Entity or Picture Glance lovelace card
|
||||
1. `Camera Entity` => `Picture Entity Card` => Technology `HLS`, codecs: `H264/H265/AAC`, poor latency.
|
||||
2. `Camera Entity` => [RTSPtoWebRTC](https://www.home-assistant.io/integrations/rtsp_to_webrtc/) => `Picture Entity Card` => Technology `WebRTC`, codecs: `H264/PCMU/PCMA/OPUS`, best latency.
|
||||
- Install any [go2rtc](#fast-start)
|
||||
- Hass > Settings > Integrations > Add Integration > [RTSPtoWebRTC](https://my.home-assistant.io/redirect/config_flow_start/?domain=rtsp_to_webrtc) > `http://127.0.0.1:1984/`
|
||||
- RTSPtoWebRTC > Configure > STUN server: `stun.l.google.com:19302`
|
||||
- Use Picture Entity or Picture Glance lovelace card
|
||||
3. `Camera Entity` or `Camera URL` => [WebRTC Camera](https://github.com/AlexxIT/WebRTC) => Technology: `WebRTC/MSE/MP4/MJPEG`, codecs: `H264/H265/AAC/PCMU/PCMA/OPUS`, best latency, best compatibility.
|
||||
- Install and add [WebRTC Camera](https://github.com/AlexxIT/WebRTC) custom integration
|
||||
- Use WebRTC Camera custom lovelace card
|
||||
|
||||
You can add camera `entity_id` to [go2rtc config](#configuration) if you need transcoding:
|
||||
|
||||
@@ -550,8 +591,28 @@ PS. Default Home Assistant lovelace cards don't support 2-way audio. You can use
|
||||
Provides several features:
|
||||
|
||||
1. MSE stream (fMP4 over WebSocket)
|
||||
2. Camera snapshots in MP4 format (single frame), can be sent to [Telegram](https://www.telegram.org/)
|
||||
3. MP4 "file stream" - bad format for streaming because of high latency, doesn't work in Safari
|
||||
2. Camera snapshots in MP4 format (single frame), can be sent to [Telegram](https://github.com/AlexxIT/go2rtc/wiki/Snapshot-to-Telegram)
|
||||
3. MP4 "file stream" - bad format for streaming because of high start delay. This format doesn't work in all Safari browsers, but go2rtc will automatically redirect it to HLS/fMP4 it this case.
|
||||
|
||||
API examples:
|
||||
|
||||
- MP4 stream: `http://192.168.1.123:1984/api/stream.mp4?src=camera1`
|
||||
- MP4 snapshot: `http://192.168.1.123:1984/api/frame.mp4?src=camera1`
|
||||
|
||||
Read more about [codecs filters](#codecs-filters).
|
||||
|
||||
### Module: HLS
|
||||
|
||||
[HLS](https://en.wikipedia.org/wiki/HTTP_Live_Streaming) is the worst technology for real-time streaming. It can only be useful on devices that do not support more modern technology, like [WebRTC](#module-webrtc), [MSE/MP4](#module-mp4).
|
||||
|
||||
The go2rtc implementation differs from the standards and may not work with all players.
|
||||
|
||||
API examples:
|
||||
|
||||
- HLS/TS stream: `http://192.168.1.123:1984/api/stream.m3u8?src=camera1` (H264)
|
||||
- HLS/fMP4 stream: `http://192.168.1.123:1984/api/stream.m3u8?src=camera1&mp4` (H264, H265, AAC)
|
||||
|
||||
Read more about [codecs filters](#codecs-filters).
|
||||
|
||||
### Module: MJPEG
|
||||
|
||||
@@ -595,7 +656,7 @@ log:
|
||||
|
||||
## Security
|
||||
|
||||
By default `go2rtc` start Web interface on port `1984` and RTSP on port `8554`. Both ports are accessible from your local network. So anyone on your local network can watch video from your cameras without authorization. The same rule applies to the Home Assistant Add-on.
|
||||
By default `go2rtc` starts the Web interface on port `1984` and RTSP on port `8554`, as well as use port `8555` for WebRTC connections. The three ports are accessible from your local network. So anyone on your local network can watch video from your cameras without authorization. The same rule applies to the Home Assistant Add-on.
|
||||
|
||||
This is not a problem if you trust your local network as much as I do. But you can change this behaviour with a `go2rtc.yaml` config:
|
||||
|
||||
@@ -607,7 +668,7 @@ rtsp:
|
||||
listen: "127.0.0.1:8554" # localhost
|
||||
|
||||
webrtc:
|
||||
listen: ":8555" # external TCP port
|
||||
listen: ":8555" # external TCP/UDP port
|
||||
```
|
||||
|
||||
- local access to RTSP is not a problem for [FFmpeg](#source-ffmpeg) integration, because it runs locally on your server
|
||||
@@ -617,23 +678,44 @@ webrtc:
|
||||
|
||||
If you need Web interface protection without Home Assistant Add-on - you need to use reverse proxy, like [Nginx](https://nginx.org/), [Caddy](https://caddyserver.com/), [Ngrok](https://ngrok.com/), etc.
|
||||
|
||||
PS. Additionally WebRTC opens a lot of random UDP ports for transmit encrypted media. They work without problems on the local network. And sometimes work for external access, even if you haven't opened ports on your router. But for stable external WebRTC access, you need to configure the TCP port.
|
||||
PS. Additionally WebRTC will try to use the 8555 UDP port for transmit encrypted media. It works without problems on the local network. And sometimes also works for external access, even if you haven't opened this port on your router ([read more](https://en.wikipedia.org/wiki/UDP_hole_punching)). But for stable external WebRTC access, you need to open the 8555 port on your router for both TCP and UDP.
|
||||
|
||||
## Codecs filters
|
||||
|
||||
go2rtc can automatically detect which codecs your device supports for [WebRTC](#module-webrtc) and [MSE](#module-mp4) technologies.
|
||||
|
||||
But it cannot be done for [RTSP](#module-rtsp), [stream.mp4](#module-mp4), [HLS](#module-hls) technologies. You can manually add a codec filter when you create a link to a stream. The filters work the same for all three technologies. Filters do not create a new codec. They only select the suitable codec from existing sources. You can add new codecs to the stream using the [FFmpeg transcoding](#source-ffmpeg).
|
||||
|
||||
Without filters:
|
||||
|
||||
- RTSP will provide only the first video and only the first audio
|
||||
- MP4 will include only compatible codecs (H264, H265, AAC)
|
||||
- HLS will output in the legacy TS format (H264 without audio)
|
||||
|
||||
Some examples:
|
||||
|
||||
- `rtsp://192.168.1.123:8554/camera1?mp4` - useful for recording as MP4 files (e.g. Hass or Frigate)
|
||||
- `rtsp://192.168.1.123:8554/camera1?video=h264,h265&audio=aac` - full version of the filter above
|
||||
- `rtsp://192.168.1.123:8554/camera1?video=h264&audio=aac&audio=opus` - H264 video codec and two separate audio tracks
|
||||
- `rtsp://192.168.1.123:8554/camera1?video&audio=all` - any video codec and all audio codecs as separate tracks
|
||||
- `http://192.168.1.123:1984/api/stream.m3u8?src=camera1&mp4` - HLS stream with MP4 compatible codecs (HLS/fMP4)
|
||||
- `http://192.168.1.123:1984/api/stream.mp4?src=camera1&video=h264,h265&audio=aac,opus,mp3,pcma,pcmu` - MP4 file with non standard audio codecs, does not work in some players
|
||||
|
||||
## Codecs madness
|
||||
|
||||
`AVC/H.264` codec can be played almost anywhere. But `HEVC/H.265` has a lot of limitations in supporting with different devices and browsers. It's all about patents and money, you can't do anything about it.
|
||||
`AVC/H.264` video can be played almost anywhere. But `HEVC/H.265` has a lot of limitations in supporting with different devices and browsers. It's all about patents and money, you can't do anything about it.
|
||||
|
||||
| Device | WebRTC | MSE | MP4 |
|
||||
|---------------------|-------------|-------------|-------------|
|
||||
| *latency* | best | medium | bad |
|
||||
| Desktop Chrome 107+ | H264 | H264, H265* | H264, H265* |
|
||||
| Desktop Edge | H264 | H264, H265* | H264, H265* |
|
||||
| Desktop Safari | H264, H265* | H264, H265 | **no!** |
|
||||
| Desktop Firefox | H264 | H264 | H264 |
|
||||
| Android Chrome 107+ | H264 | H264, H265* | H264 |
|
||||
| iPad Safari 13+ | H264, H265* | H264, H265 | **no!** |
|
||||
| iPhone Safari 13+ | H264, H265* | **no!** | **no!** |
|
||||
| masOS Hass App | no | no | no |
|
||||
| Device | WebRTC | MSE | stream.mp4 |
|
||||
|---------------------|-------------------------------|------------------------|-----------------------------------------|
|
||||
| *latency* | best | medium | bad |
|
||||
| Desktop Chrome 107+ | H264, OPUS, PCMU, PCMA | H264, H265*, AAC, OPUS | H264, H265*, AAC, OPUS, PCMU, PCMA, MP3 |
|
||||
| Desktop Edge | H264, OPUS, PCMU, PCMA | H264, H265*, AAC, OPUS | H264, H265*, AAC, OPUS, PCMU, PCMA, MP3 |
|
||||
| Desktop Safari | H264, H265*, OPUS, PCMU, PCMA | H264, H265, AAC | **no!** |
|
||||
| Desktop Firefox | H264, OPUS, PCMU, PCMA | H264, AAC, OPUS | H264, AAC, OPUS |
|
||||
| Android Chrome 107+ | H264, OPUS, PCMU, PCMA | H264, H265*, AAC, OPUS | H264, ?, AAC, OPUS, PCMU, PCMA, MP3 |
|
||||
| iPad Safari 13+ | H264, H265*, OPUS, PCMU, PCMA | H264, H265, AAC | **no!** |
|
||||
| iPhone Safari 13+ | H264, H265*, OPUS, PCMU, PCMA | **no!** | **no!** |
|
||||
| masOS Hass App | no | no | no |
|
||||
|
||||
- Chrome H265: [read this](https://chromestatus.com/feature/5186511939567616) and [read this](https://github.com/StaZhu/enable-chromium-hevc-hardware-decoding)
|
||||
- Edge H265: [read this](https://www.reddit.com/r/MicrosoftEdge/comments/v9iw8k/enable_hevc_support_in_edge/)
|
||||
@@ -642,8 +724,9 @@ PS. Additionally WebRTC opens a lot of random UDP ports for transmit encrypted m
|
||||
|
||||
**Audio**
|
||||
|
||||
- WebRTC audio codecs: `PCMU/8000`, `PCMA/8000`, `OPUS/48000/2`
|
||||
- MSE/MP4 audio codecs: `AAC`
|
||||
- **WebRTC** audio codecs: `PCMU/8000`, `PCMA/8000`, `OPUS/48000/2`
|
||||
- `OPUS` and `MP3` inside **MP4** is part of the standard, but some players do not support them anyway (especially Apple)
|
||||
- `PCMU` and `PCMA` inside **MP4** isn't a standard, but some players support them, for example Chromium browsers
|
||||
|
||||
**Apple devices**
|
||||
|
||||
@@ -680,6 +763,21 @@ streams:
|
||||
|
||||
**PS.** You can select `PCMU` or `PCMA` codec in camera setting and don't use transcoding at all. Or you can select `AAC` codec for main stream and `PCMU` codec for second stream and add both RTSP to YAML config, this also will work fine.
|
||||
|
||||
## Projects using go2rtc
|
||||
|
||||
- [Frigate 12+](https://frigate.video/) - open source NVR built around real-time AI object detection
|
||||
- [ring-mqtt](https://github.com/tsightler/ring-mqtt) - Ring devices to MQTT Bridge
|
||||
- [EufyP2PStream](https://github.com/oischinger/eufyp2pstream) - A small project that provides a Video/Audio Stream from Eufy cameras that don't directly support RTSP
|
||||
|
||||
## Cameras experience
|
||||
|
||||
- [Dahua](https://www.dahuasecurity.com/) - reference implementation streaming protocols, a lot of settings, high stream quality, multiple streaming clients
|
||||
- [Hikvision](https://www.hikvision.com/) - a lot of proprietary streaming technologies
|
||||
- [Reolink](https://reolink.com/) - some models has awful unusable RTSP realisation and not best HTTP-FLV alternative (I recommend that you contact Reolink support for new firmware), few settings
|
||||
- [Sonoff](https://sonoff.tech/) - very low stream quality, no settings, not best protocol implementation
|
||||
- [TP-Link](https://www.tp-link.com/) - few streaming clients, packet loss?
|
||||
- Chinese cheap noname cameras, Wyze Cams, Xiaomi cameras with hacks (usual has `/live/ch00_1` in RTSP URL) - awful but usable RTSP protocol realisation, low stream quality, few settings, packet loss?
|
||||
|
||||
## TIPS
|
||||
|
||||
**Using apps for low RTSP delay**
|
||||
@@ -687,23 +785,27 @@ streams:
|
||||
- `ffplay -fflags nobuffer -flags low_delay "rtsp://192.168.1.123:8554/camera1"`
|
||||
- VLC > Preferences > Input / Codecs > Default Caching Level: Lowest Latency
|
||||
|
||||
**Snapshots to Telegram**
|
||||
|
||||
[read more](https://github.com/AlexxIT/go2rtc/wiki/Snapshot-to-Telegram)
|
||||
|
||||
## FAQ
|
||||
|
||||
**Q. What's the difference between go2rtc, WebRTC Camera and RTSPtoWebRTC?**
|
||||
|
||||
**go2rtc** is a new version of the server-side [WebRTC Camera](https://github.com/AlexxIT/WebRTC) integration, completely rewritten from scratch, with a number of fixes and a huge number of new features. It is compatible with native Home Assistant [RTSPtoWebRTC](https://www.home-assistant.io/integrations/rtsp_to_webrtc/) integration. So you [can use](#module-hass) default lovelace Picture Entity or Picture Glance.
|
||||
|
||||
**Q. Why go2rtc is an addon and not an integration?**
|
||||
**Q. Should I use go2rtc addon or WebRTC Camera integration?**
|
||||
|
||||
Because **go2rtc** is more than just viewing your stream online with WebRTC. You can use it all the time for your various tasks. But every time the Hass is rebooted - all integrations are also rebooted. So your streams may be interrupted if you use them in additional tasks.
|
||||
**go2rtc** is more than just viewing your stream online with WebRTC/MSE/HLS/etc. You can use it all the time for your various tasks. But every time the Hass is rebooted - all integrations are also rebooted. So your streams may be interrupted if you use them in additional tasks.
|
||||
|
||||
When **go2rtc** is released, the **WebRTC Camera** integration will be updated. And you can decide whether to use the integration or the addon.
|
||||
Basic users can use **WebRTC Camera** integration. Advanced users can use go2rtc addon or Frigate 12+ addon.
|
||||
|
||||
**Q. Which RTSP link should I use inside Hass?**
|
||||
|
||||
You can use direct link to your cameras there (as you always do). **go2rtc** support zero-config feature. You may leave `streams` config section empty. And your streams will be created on the fly on first start from Hass. And your cameras will have multiple connections. Some from Hass directly and one from **go2rtc**.
|
||||
|
||||
Also you can specify your streams in **go2rtc** [config file](#configuration) and use RTSP links to this addon. With additional features: multi-source [codecs negotiation](#codecs-negotiation) or FFmpeg [transcoding](#source-ffmpeg) for unsupported codecs. Or use them as source for Frigate. And your cameras will have one connection from **go2rtc**. And **go2rtc** will have multiple connection - some from Hass via RTSP protocol, some from your browser via WebRTC protocol.
|
||||
Also you can specify your streams in **go2rtc** [config file](#configuration) and use RTSP links to this addon. With additional features: multi-source [codecs negotiation](#codecs-negotiation) or FFmpeg [transcoding](#source-ffmpeg) for unsupported codecs. Or use them as source for Frigate. And your cameras will have one connection from **go2rtc**. And **go2rtc** will have multiple connection - some from Hass via RTSP protocol, some from your browser via WebRTC/MSE/HLS protocols.
|
||||
|
||||
Use any config what you like.
|
||||
|
||||
|
||||
@@ -3,16 +3,21 @@ package api
|
||||
import (
|
||||
"encoding/json"
|
||||
"github.com/AlexxIT/go2rtc/cmd/app"
|
||||
"github.com/AlexxIT/go2rtc/cmd/streams"
|
||||
"github.com/rs/zerolog"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
func Init() {
|
||||
var cfg struct {
|
||||
Mod struct {
|
||||
Listen string `yaml:"listen"`
|
||||
Username string `yaml:"username"`
|
||||
Password string `yaml:"password"`
|
||||
BasePath string `yaml:"base_path"`
|
||||
StaticDir string `yaml:"static_dir"`
|
||||
Origin string `yaml:"origin"`
|
||||
@@ -35,7 +40,9 @@ func Init() {
|
||||
initStatic(cfg.Mod.StaticDir)
|
||||
initWS(cfg.Mod.Origin)
|
||||
|
||||
HandleFunc("api/streams", streamsHandler)
|
||||
HandleFunc("api", apiHandler)
|
||||
HandleFunc("api/config", configHandler)
|
||||
HandleFunc("api/exit", exitHandler)
|
||||
HandleFunc("api/ws", apiWS)
|
||||
|
||||
// ensure we can listen without errors
|
||||
@@ -48,14 +55,18 @@ func Init() {
|
||||
log.Info().Str("addr", cfg.Mod.Listen).Msg("[api] listen")
|
||||
|
||||
s := http.Server{}
|
||||
s.Handler = http.DefaultServeMux
|
||||
|
||||
if log.Trace().Enabled() {
|
||||
s.Handler = middlewareLog(s.Handler)
|
||||
}
|
||||
s.Handler = http.DefaultServeMux // 4th
|
||||
|
||||
if cfg.Mod.Origin == "*" {
|
||||
s.Handler = middlewareCORS(s.Handler)
|
||||
s.Handler = middlewareCORS(s.Handler) // 3rd
|
||||
}
|
||||
|
||||
if cfg.Mod.Username != "" {
|
||||
s.Handler = middlewareAuth(cfg.Mod.Username, cfg.Mod.Password, s.Handler) // 2nd
|
||||
}
|
||||
|
||||
if log.Trace().Enabled() {
|
||||
s.Handler = middlewareLog(s.Handler) // 1st
|
||||
}
|
||||
|
||||
go func() {
|
||||
@@ -83,7 +94,22 @@ var log zerolog.Logger
|
||||
|
||||
func middlewareLog(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
log.Trace().Msgf("[api] %s %s", r.Method, r.URL)
|
||||
log.Trace().Msgf("[api] %s %s %s", r.Method, r.URL, r.RemoteAddr)
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
func middlewareAuth(username, password string, next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if !strings.HasPrefix(r.RemoteAddr, "127.") && !strings.HasPrefix(r.RemoteAddr, "[::1]") {
|
||||
user, pass, ok := r.BasicAuth()
|
||||
if !ok || user != username || pass != password {
|
||||
w.Header().Set("Www-Authenticate", `Basic realm="go2rtc"`)
|
||||
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
@@ -96,31 +122,25 @@ func middlewareCORS(next http.Handler) http.Handler {
|
||||
})
|
||||
}
|
||||
|
||||
func streamsHandler(w http.ResponseWriter, r *http.Request) {
|
||||
src := r.URL.Query().Get("src")
|
||||
name := r.URL.Query().Get("name")
|
||||
var mu sync.Mutex
|
||||
|
||||
if name == "" {
|
||||
name = src
|
||||
func apiHandler(w http.ResponseWriter, r *http.Request) {
|
||||
mu.Lock()
|
||||
app.Info["host"] = r.Host
|
||||
mu.Unlock()
|
||||
|
||||
if err := json.NewEncoder(w).Encode(app.Info); err != nil {
|
||||
log.Warn().Err(err).Caller().Send()
|
||||
}
|
||||
|
||||
switch r.Method {
|
||||
case "PUT":
|
||||
streams.New(name, src)
|
||||
return
|
||||
case "DELETE":
|
||||
streams.Delete(src)
|
||||
return
|
||||
}
|
||||
|
||||
var v interface{}
|
||||
if src != "" {
|
||||
v = streams.Get(src)
|
||||
} else {
|
||||
v = streams.All()
|
||||
}
|
||||
|
||||
e := json.NewEncoder(w)
|
||||
e.SetIndent("", " ")
|
||||
_ = e.Encode(v)
|
||||
}
|
||||
|
||||
func exitHandler(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != "POST" {
|
||||
http.Error(w, "", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
s := r.URL.Query().Get("code")
|
||||
code, _ := strconv.Atoi(s)
|
||||
os.Exit(code)
|
||||
}
|
||||
|
||||
102
cmd/api/config.go
Normal file
102
cmd/api/config.go
Normal file
@@ -0,0 +1,102 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"github.com/AlexxIT/go2rtc/cmd/app"
|
||||
"gopkg.in/yaml.v3"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
)
|
||||
|
||||
func configHandler(w http.ResponseWriter, r *http.Request) {
|
||||
if app.ConfigPath == "" {
|
||||
http.Error(w, "", http.StatusGone)
|
||||
return
|
||||
}
|
||||
|
||||
switch r.Method {
|
||||
case "GET":
|
||||
data, err := os.ReadFile(app.ConfigPath)
|
||||
if err != nil {
|
||||
http.Error(w, "", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
if _, err = w.Write(data); err != nil {
|
||||
log.Warn().Err(err).Caller().Send()
|
||||
}
|
||||
|
||||
case "POST", "PATCH":
|
||||
data, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if r.Method == "PATCH" {
|
||||
// no need to validate after merge
|
||||
data, err = mergeYAML(app.ConfigPath, data)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
} else {
|
||||
// validate config
|
||||
var tmp struct{}
|
||||
if err = yaml.Unmarshal(data, &tmp); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if err = os.WriteFile(app.ConfigPath, data, 0644); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func mergeYAML(file1 string, yaml2 []byte) ([]byte, error) {
|
||||
// Read the contents of the first YAML file
|
||||
data1, err := os.ReadFile(file1)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Unmarshal the first YAML file into a map
|
||||
var config1 map[string]interface{}
|
||||
if err = yaml.Unmarshal(data1, &config1); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Unmarshal the second YAML document into a map
|
||||
var config2 map[string]interface{}
|
||||
if err = yaml.Unmarshal(yaml2, &config2); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Merge the two maps
|
||||
config1 = merge(config1, config2)
|
||||
|
||||
// Marshal the merged map into YAML
|
||||
return yaml.Marshal(&config1)
|
||||
}
|
||||
|
||||
func merge(dst, src map[string]interface{}) map[string]interface{} {
|
||||
for k, v := range src {
|
||||
if vv, ok := dst[k]; ok {
|
||||
switch vv := vv.(type) {
|
||||
case map[string]interface{}:
|
||||
v := v.(map[string]interface{})
|
||||
dst[k] = merge(vv, v)
|
||||
case []interface{}:
|
||||
v := v.([]interface{})
|
||||
dst[k] = v
|
||||
default:
|
||||
dst[k] = v
|
||||
}
|
||||
} else {
|
||||
dst[k] = v
|
||||
}
|
||||
}
|
||||
return dst
|
||||
}
|
||||
@@ -81,7 +81,9 @@ func apiWS(w http.ResponseWriter, r *http.Request) {
|
||||
for {
|
||||
msg := new(Message)
|
||||
if err = ws.ReadJSON(msg); err != nil {
|
||||
log.Trace().Err(err).Caller().Send()
|
||||
if !websocket.IsCloseError(err, websocket.CloseNoStatusReceived) {
|
||||
log.Trace().Err(err).Caller().Send()
|
||||
}
|
||||
_ = ws.Close()
|
||||
break
|
||||
}
|
||||
|
||||
@@ -2,46 +2,76 @@ package app
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"github.com/AlexxIT/go2rtc/pkg/shell"
|
||||
"github.com/rs/zerolog"
|
||||
"github.com/rs/zerolog/log"
|
||||
"gopkg.in/yaml.v3"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
var Version = "0.1-rc.8"
|
||||
var Version = "1.2.0"
|
||||
var UserAgent = "go2rtc/" + Version
|
||||
|
||||
func Init() {
|
||||
config := flag.String(
|
||||
"config",
|
||||
"go2rtc.yaml",
|
||||
"Path to go2rtc configuration file",
|
||||
)
|
||||
var ConfigPath string
|
||||
var Info = map[string]interface{}{
|
||||
"version": Version,
|
||||
}
|
||||
|
||||
func Init() {
|
||||
var confs Config
|
||||
|
||||
flag.Var(&confs, "config", "go2rtc config (path to file or raw text), support multiple")
|
||||
flag.Parse()
|
||||
|
||||
data, _ = os.ReadFile(*config)
|
||||
if confs == nil {
|
||||
confs = []string{"go2rtc.yaml"}
|
||||
}
|
||||
|
||||
for _, conf := range confs {
|
||||
if conf[0] != '{' {
|
||||
// config as file
|
||||
if ConfigPath == "" {
|
||||
ConfigPath = conf
|
||||
}
|
||||
|
||||
data, _ := os.ReadFile(conf)
|
||||
if data == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
data = []byte(shell.ReplaceEnvVars(string(data)))
|
||||
configs = append(configs, data)
|
||||
} else {
|
||||
// config as raw YAML
|
||||
configs = append(configs, []byte(conf))
|
||||
}
|
||||
}
|
||||
|
||||
if ConfigPath != "" {
|
||||
if !filepath.IsAbs(ConfigPath) {
|
||||
if cwd, err := os.Getwd(); err == nil {
|
||||
ConfigPath = filepath.Join(cwd, ConfigPath)
|
||||
}
|
||||
}
|
||||
Info["config_path"] = ConfigPath
|
||||
}
|
||||
|
||||
var cfg struct {
|
||||
Mod map[string]string `yaml:"log"`
|
||||
}
|
||||
|
||||
if data != nil {
|
||||
if err := yaml.Unmarshal(data, &cfg); err != nil {
|
||||
println("ERROR: " + err.Error())
|
||||
}
|
||||
}
|
||||
LoadConfig(&cfg)
|
||||
|
||||
log.Logger = NewLogger(cfg.Mod["format"], cfg.Mod["level"])
|
||||
|
||||
modules = cfg.Mod
|
||||
|
||||
log.Info().Msgf("go2rtc version %s %s/%s", Version, runtime.GOOS, runtime.GOARCH)
|
||||
|
||||
path, _ := os.Getwd()
|
||||
log.Debug().Str("cwd", path).Send()
|
||||
}
|
||||
|
||||
func NewLogger(format string, level string) zerolog.Logger {
|
||||
@@ -54,7 +84,7 @@ func NewLogger(format string, level string) zerolog.Logger {
|
||||
}
|
||||
}
|
||||
|
||||
zerolog.TimeFieldFormat = zerolog.TimeFormatUnixMs
|
||||
zerolog.TimeFieldFormat = time.RFC3339Nano
|
||||
|
||||
lvl, err := zerolog.ParseLevel(level)
|
||||
if err != nil || lvl == zerolog.NoLevel {
|
||||
@@ -65,7 +95,7 @@ func NewLogger(format string, level string) zerolog.Logger {
|
||||
}
|
||||
|
||||
func LoadConfig(v interface{}) {
|
||||
if data != nil {
|
||||
for _, data := range configs {
|
||||
if err := yaml.Unmarshal(data, v); err != nil {
|
||||
log.Warn().Err(err).Msg("[app] read config")
|
||||
}
|
||||
@@ -86,8 +116,18 @@ func GetLogger(module string) zerolog.Logger {
|
||||
|
||||
// internal
|
||||
|
||||
// data - config content
|
||||
var data []byte
|
||||
type Config []string
|
||||
|
||||
func (c *Config) String() string {
|
||||
return strings.Join(*c, " ")
|
||||
}
|
||||
|
||||
func (c *Config) Set(value string) error {
|
||||
*c = append(*c, value)
|
||||
return nil
|
||||
}
|
||||
|
||||
var configs [][]byte
|
||||
|
||||
// modules log levels
|
||||
var modules map[string]string
|
||||
|
||||
@@ -4,24 +4,14 @@ import (
|
||||
"github.com/AlexxIT/go2rtc/cmd/api"
|
||||
"github.com/AlexxIT/go2rtc/cmd/streams"
|
||||
"github.com/AlexxIT/go2rtc/pkg/streamer"
|
||||
"net/http"
|
||||
"os"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
func Init() {
|
||||
api.HandleFunc("api/stack", stackHandler)
|
||||
api.HandleFunc("api/exit", exitHandler)
|
||||
|
||||
streams.HandleFunc("null", nullHandler)
|
||||
}
|
||||
|
||||
func exitHandler(_ http.ResponseWriter, r *http.Request) {
|
||||
s := r.URL.Query().Get("code")
|
||||
code, _ := strconv.Atoi(s)
|
||||
os.Exit(code)
|
||||
}
|
||||
|
||||
func nullHandler(string) (streamer.Producer, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
25
cmd/dvrip/dvrip.go
Normal file
25
cmd/dvrip/dvrip.go
Normal file
@@ -0,0 +1,25 @@
|
||||
package dvrip
|
||||
|
||||
import (
|
||||
"github.com/AlexxIT/go2rtc/cmd/streams"
|
||||
"github.com/AlexxIT/go2rtc/pkg/dvrip"
|
||||
"github.com/AlexxIT/go2rtc/pkg/streamer"
|
||||
)
|
||||
|
||||
func Init() {
|
||||
streams.HandleFunc("dvrip", handle)
|
||||
}
|
||||
|
||||
func handle(url string) (streamer.Producer, error) {
|
||||
conn := dvrip.NewClient(url)
|
||||
if err := conn.Dial(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := conn.Play(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := conn.Handle(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return conn, nil
|
||||
}
|
||||
@@ -15,11 +15,11 @@ func deviceInputSuffix(videoIdx, audioIdx int) string {
|
||||
audio := findMedia(streamer.KindAudio, audioIdx)
|
||||
switch {
|
||||
case video != nil && audio != nil:
|
||||
return `"` + video.Title + `:` + audio.Title + `"`
|
||||
return `"` + video.MID + `:` + audio.MID + `"`
|
||||
case video != nil:
|
||||
return `"` + video.Title + `"`
|
||||
return `"` + video.MID + `"`
|
||||
case audio != nil:
|
||||
return `"` + audio.Title + `"`
|
||||
return `"` + audio.MID + `"`
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -57,7 +57,5 @@ process:
|
||||
}
|
||||
|
||||
func loadMedia(kind, name string) *streamer.Media {
|
||||
return &streamer.Media{
|
||||
Kind: kind, Title: name,
|
||||
}
|
||||
return &streamer.Media{Kind: kind, MID: name}
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ const deviceInputPrefix = "-f v4l2"
|
||||
|
||||
func deviceInputSuffix(videoIdx, audioIdx int) string {
|
||||
video := findMedia(streamer.KindVideo, videoIdx)
|
||||
return video.Title
|
||||
return video.MID
|
||||
}
|
||||
|
||||
func loadMedias() {
|
||||
@@ -44,7 +44,5 @@ func loadMedia(kind, name string) *streamer.Media {
|
||||
return nil
|
||||
}
|
||||
|
||||
return &streamer.Media{
|
||||
Kind: kind, Title: name,
|
||||
}
|
||||
return &streamer.Media{Kind: kind, MID: name}
|
||||
}
|
||||
|
||||
@@ -15,11 +15,11 @@ func deviceInputSuffix(videoIdx, audioIdx int) string {
|
||||
audio := findMedia(streamer.KindAudio, audioIdx)
|
||||
switch {
|
||||
case video != nil && audio != nil:
|
||||
return `video="` + video.Title + `":audio=` + audio.Title + `"`
|
||||
return `video="` + video.MID + `":audio=` + audio.MID + `"`
|
||||
case video != nil:
|
||||
return `video="` + video.Title + `"`
|
||||
return `video="` + video.MID + `"`
|
||||
case audio != nil:
|
||||
return `audio="` + audio.Title + `"`
|
||||
return `audio="` + audio.MID + `"`
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -53,7 +53,5 @@ func loadMedias() {
|
||||
}
|
||||
|
||||
func loadMedia(kind, name string) *streamer.Media {
|
||||
return &streamer.Media{
|
||||
Kind: kind, Title: name,
|
||||
}
|
||||
return &streamer.Media{Kind: kind, MID: name}
|
||||
}
|
||||
|
||||
@@ -44,10 +44,12 @@ var defaults = map[string]string{
|
||||
"global": "-hide_banner",
|
||||
|
||||
// inputs
|
||||
"file": "-re -stream_loop -1 -i {input}",
|
||||
"file": "-re -i {input}",
|
||||
"http": "-fflags nobuffer -flags low_delay -i {input}",
|
||||
"rtsp": "-fflags nobuffer -flags low_delay -timeout 5000000 -user_agent go2rtc/ffmpeg -rtsp_transport tcp -i {input}",
|
||||
|
||||
"rtsp/udp": "-fflags nobuffer -flags low_delay -timeout 5000000 -user_agent go2rtc/ffmpeg -i {input}",
|
||||
|
||||
// output
|
||||
"output": "-user_agent ffmpeg/go2rtc -rtsp_transport tcp -f rtsp {output}",
|
||||
|
||||
@@ -67,6 +69,7 @@ var defaults = map[string]string{
|
||||
"pcma/48000": "-c:a pcm_alaw -ar:a 48000 -ac:a 1",
|
||||
"aac": "-c:a aac", // keep sample rate and channels
|
||||
"aac/16000": "-c:a aac -ar:a 16000 -ac:a 1",
|
||||
"mp3": "-c:a libmp3lame -q:a 8",
|
||||
|
||||
// hardware Intel and AMD on Linux
|
||||
// better not to set `-async_depth:v 1` like for QSV, because framedrops
|
||||
@@ -94,6 +97,21 @@ var defaults = map[string]string{
|
||||
"h265/videotoolbox": "-c:v hevc_videotoolbox -g 50 -bf 0 -profile:v high -level:v 5.1",
|
||||
}
|
||||
|
||||
// inputTemplate - select input template from YAML config by template name
|
||||
// if query has input param - select another tempalte by this name
|
||||
// if there is no another template - use input param as template
|
||||
func inputTemplate(name, s string, query url.Values) string {
|
||||
var template string
|
||||
if input := query.Get("input"); input != "" {
|
||||
if template = defaults[input]; template == "" {
|
||||
template = input
|
||||
}
|
||||
} else {
|
||||
template = defaults[name]
|
||||
}
|
||||
return strings.Replace(template, "{input}", s, 1)
|
||||
}
|
||||
|
||||
func parseArgs(s string) *Args {
|
||||
// init FFmpeg arguments
|
||||
args := &Args{
|
||||
@@ -117,7 +135,7 @@ func parseArgs(s string) *Args {
|
||||
if i := strings.Index(s, "://"); i > 0 {
|
||||
switch s[:i] {
|
||||
case "http", "https", "rtmp":
|
||||
args.input = strings.Replace(defaults["http"], "{input}", s, 1)
|
||||
args.input = inputTemplate("http", s, query)
|
||||
case "rtsp", "rtsps":
|
||||
// https://ffmpeg.org/ffmpeg-protocols.html#rtsp
|
||||
// skip unnecessary input tracks
|
||||
@@ -130,7 +148,7 @@ func parseArgs(s string) *Args {
|
||||
args.input = "-allowed_media_types audio "
|
||||
}
|
||||
|
||||
args.input += strings.Replace(defaults["rtsp"], "{input}", s, 1)
|
||||
args.input += inputTemplate("rtsp", s, query)
|
||||
default:
|
||||
args.input = "-i " + s
|
||||
}
|
||||
@@ -141,8 +159,10 @@ func parseArgs(s string) *Args {
|
||||
s += "?video"
|
||||
case args.audio > 0 && args.video == 0:
|
||||
s += "?audio"
|
||||
default:
|
||||
s += "?video&audio"
|
||||
}
|
||||
args.input = strings.Replace(defaults["rtsp"], "{input}", s, 1)
|
||||
args.input = inputTemplate("rtsp", s, query)
|
||||
} else if strings.HasPrefix(s, "device?") {
|
||||
var err error
|
||||
args.input, err = device.GetInput(s)
|
||||
@@ -150,7 +170,7 @@ func parseArgs(s string) *Args {
|
||||
return nil
|
||||
}
|
||||
} else {
|
||||
args.input = strings.Replace(defaults["file"], "{input}", s, 1)
|
||||
args.input = inputTemplate("file", s, query)
|
||||
}
|
||||
|
||||
if query["async"] != nil {
|
||||
@@ -205,7 +225,11 @@ func parseArgs(s string) *Args {
|
||||
if args.video > 0 {
|
||||
for _, video := range query["video"] {
|
||||
if video != "copy" {
|
||||
args.AddCodec(defaults[video])
|
||||
if codec := defaults[video]; codec != "" {
|
||||
args.AddCodec(codec)
|
||||
} else {
|
||||
args.AddCodec(video)
|
||||
}
|
||||
} else {
|
||||
args.AddCodec("-c:v copy")
|
||||
}
|
||||
@@ -218,7 +242,11 @@ func parseArgs(s string) *Args {
|
||||
if args.audio > 0 {
|
||||
for _, audio := range query["audio"] {
|
||||
if audio != "copy" {
|
||||
args.AddCodec(defaults[audio])
|
||||
if codec := defaults[audio]; codec != "" {
|
||||
args.AddCodec(codec)
|
||||
} else {
|
||||
args.AddCodec(audio)
|
||||
}
|
||||
} else {
|
||||
args.AddCodec("-c:a copy")
|
||||
}
|
||||
|
||||
261
cmd/hls/hls.go
Normal file
261
cmd/hls/hls.go
Normal file
@@ -0,0 +1,261 @@
|
||||
package hls
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/AlexxIT/go2rtc/cmd/api"
|
||||
"github.com/AlexxIT/go2rtc/cmd/streams"
|
||||
"github.com/AlexxIT/go2rtc/pkg/mp4"
|
||||
"github.com/AlexxIT/go2rtc/pkg/mpegts"
|
||||
"github.com/AlexxIT/go2rtc/pkg/streamer"
|
||||
"github.com/rs/zerolog/log"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
func Init() {
|
||||
api.HandleFunc("api/stream.m3u8", handlerStream)
|
||||
api.HandleFunc("api/hls/playlist.m3u8", handlerPlaylist)
|
||||
|
||||
// HLS (TS)
|
||||
api.HandleFunc("api/hls/segment.ts", handlerSegmentTS)
|
||||
|
||||
// HLS (fMP4)
|
||||
api.HandleFunc("api/hls/init.mp4", handlerInit)
|
||||
api.HandleFunc("api/hls/segment.m4s", handlerSegmentMP4)
|
||||
}
|
||||
|
||||
type Consumer interface {
|
||||
streamer.Consumer
|
||||
Init() ([]byte, error)
|
||||
MimeCodecs() string
|
||||
Start()
|
||||
}
|
||||
|
||||
type Session struct {
|
||||
cons Consumer
|
||||
playlist string
|
||||
init []byte
|
||||
segment []byte
|
||||
seq int
|
||||
alive *time.Timer
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
const keepalive = 5 * time.Second
|
||||
|
||||
var sessions = map[string]*Session{}
|
||||
|
||||
func handlerStream(w http.ResponseWriter, r *http.Request) {
|
||||
// CORS important for Chromecast
|
||||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||
w.Header().Set("Content-Type", "application/vnd.apple.mpegurl")
|
||||
|
||||
if r.Method == "OPTIONS" {
|
||||
w.Header().Set("Access-Control-Allow-Methods", "GET")
|
||||
return
|
||||
}
|
||||
|
||||
src := r.URL.Query().Get("src")
|
||||
stream := streams.GetOrNew(src)
|
||||
if stream == nil {
|
||||
http.Error(w, api.StreamNotFound, http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
var cons Consumer
|
||||
|
||||
// use fMP4 with codecs filter and TS without
|
||||
medias := mp4.ParseQuery(r.URL.Query())
|
||||
if medias != nil {
|
||||
cons = &mp4.Consumer{
|
||||
RemoteAddr: r.RemoteAddr,
|
||||
UserAgent: r.UserAgent(),
|
||||
Medias: medias,
|
||||
}
|
||||
} else {
|
||||
cons = &mpegts.Consumer{
|
||||
RemoteAddr: r.RemoteAddr,
|
||||
UserAgent: r.UserAgent(),
|
||||
}
|
||||
}
|
||||
|
||||
session := &Session{cons: cons}
|
||||
|
||||
cons.Listen(func(msg interface{}) {
|
||||
if data, ok := msg.([]byte); ok {
|
||||
session.mu.Lock()
|
||||
session.segment = append(session.segment, data...)
|
||||
session.mu.Unlock()
|
||||
}
|
||||
})
|
||||
|
||||
if err := stream.AddConsumer(cons); err != nil {
|
||||
log.Error().Err(err).Caller().Send()
|
||||
return
|
||||
}
|
||||
|
||||
session.alive = time.AfterFunc(keepalive, func() {
|
||||
stream.RemoveConsumer(cons)
|
||||
})
|
||||
session.init, _ = cons.Init()
|
||||
|
||||
cons.Start()
|
||||
|
||||
sid := strconv.FormatInt(time.Now().UnixNano(), 10)
|
||||
|
||||
// two segments important for Chromecast
|
||||
if medias != nil {
|
||||
session.playlist = `#EXTM3U
|
||||
#EXT-X-VERSION:6
|
||||
#EXT-X-TARGETDURATION:1
|
||||
#EXT-X-MEDIA-SEQUENCE:%d
|
||||
#EXT-X-MAP:URI="init.mp4?id=` + sid + `"
|
||||
#EXTINF:0.500,
|
||||
segment.m4s?id=` + sid + `&n=%d
|
||||
#EXTINF:0.500,
|
||||
segment.m4s?id=` + sid + `&n=%d`
|
||||
} else {
|
||||
session.playlist = `#EXTM3U
|
||||
#EXT-X-VERSION:3
|
||||
#EXT-X-TARGETDURATION:1
|
||||
#EXT-X-MEDIA-SEQUENCE:%d
|
||||
#EXTINF:0.500,
|
||||
segment.ts?id=` + sid + `&n=%d
|
||||
#EXTINF:0.500,
|
||||
segment.ts?id=` + sid + `&n=%d`
|
||||
}
|
||||
|
||||
sessions[sid] = session
|
||||
|
||||
// bandwidth important for Safari, codecs useful for smooth playback
|
||||
data := []byte(`#EXTM3U
|
||||
#EXT-X-STREAM-INF:BANDWIDTH=1000000,CODECS="` + cons.MimeCodecs() + `"
|
||||
hls/playlist.m3u8?id=` + sid)
|
||||
|
||||
if _, err := w.Write(data); err != nil {
|
||||
log.Error().Err(err).Caller().Send()
|
||||
}
|
||||
}
|
||||
|
||||
func handlerPlaylist(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||
w.Header().Set("Content-Type", "application/vnd.apple.mpegurl")
|
||||
|
||||
if r.Method == "OPTIONS" {
|
||||
w.Header().Set("Access-Control-Allow-Methods", "GET")
|
||||
return
|
||||
}
|
||||
|
||||
sid := r.URL.Query().Get("id")
|
||||
session := sessions[sid]
|
||||
if session == nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
s := fmt.Sprintf(session.playlist, session.seq, session.seq, session.seq+1)
|
||||
|
||||
if _, err := w.Write([]byte(s)); err != nil {
|
||||
log.Error().Err(err).Caller().Send()
|
||||
}
|
||||
}
|
||||
|
||||
func handlerSegmentTS(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||
w.Header().Set("Content-Type", "video/mp2t")
|
||||
|
||||
if r.Method == "OPTIONS" {
|
||||
w.Header().Set("Access-Control-Allow-Methods", "GET")
|
||||
return
|
||||
}
|
||||
|
||||
sid := r.URL.Query().Get("id")
|
||||
session := sessions[sid]
|
||||
if session == nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
session.alive.Reset(keepalive)
|
||||
|
||||
var i byte
|
||||
for len(session.segment) == 0 {
|
||||
if i++; i > 10 {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
time.Sleep(time.Millisecond * 100)
|
||||
}
|
||||
|
||||
session.mu.Lock()
|
||||
data := session.segment
|
||||
// important to start new segment with init
|
||||
session.segment = session.init
|
||||
session.seq++
|
||||
session.mu.Unlock()
|
||||
|
||||
if _, err := w.Write(data); err != nil {
|
||||
log.Error().Err(err).Caller().Send()
|
||||
}
|
||||
}
|
||||
|
||||
func handlerInit(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||
w.Header().Add("Content-Type", "video/mp4")
|
||||
|
||||
if r.Method == "OPTIONS" {
|
||||
w.Header().Set("Access-Control-Allow-Methods", "GET")
|
||||
return
|
||||
}
|
||||
|
||||
sid := r.URL.Query().Get("id")
|
||||
session := sessions[sid]
|
||||
if session == nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
if _, err := w.Write(session.init); err != nil {
|
||||
log.Error().Err(err).Caller().Send()
|
||||
}
|
||||
}
|
||||
|
||||
func handlerSegmentMP4(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||
w.Header().Add("Content-Type", "video/iso.segment")
|
||||
|
||||
if r.Method == "OPTIONS" {
|
||||
w.Header().Set("Access-Control-Allow-Methods", "GET, OPTIONS")
|
||||
return
|
||||
}
|
||||
|
||||
sid := r.URL.Query().Get("id")
|
||||
session := sessions[sid]
|
||||
if session == nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
session.alive.Reset(keepalive)
|
||||
|
||||
var i byte
|
||||
for len(session.segment) == 0 {
|
||||
if i++; i > 10 {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
time.Sleep(time.Millisecond * 100)
|
||||
}
|
||||
|
||||
session.mu.Lock()
|
||||
data := session.segment
|
||||
session.segment = nil
|
||||
session.seq++
|
||||
session.mu.Unlock()
|
||||
|
||||
if _, err := w.Write(data); err != nil {
|
||||
log.Error().Err(err).Caller().Send()
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"fmt"
|
||||
"github.com/AlexxIT/go2rtc/cmd/streams"
|
||||
"github.com/AlexxIT/go2rtc/pkg/mjpeg"
|
||||
"github.com/AlexxIT/go2rtc/pkg/mpegts"
|
||||
"github.com/AlexxIT/go2rtc/pkg/rtmp"
|
||||
"github.com/AlexxIT/go2rtc/pkg/streamer"
|
||||
"github.com/AlexxIT/go2rtc/pkg/tcp"
|
||||
@@ -41,6 +42,7 @@ func handle(url string) (streamer.Producer, error) {
|
||||
switch ct {
|
||||
case "image/jpeg", "multipart/x-mixed-replace":
|
||||
return mjpeg.NewClient(res), nil
|
||||
|
||||
case "video/x-flv":
|
||||
var conn *rtmp.Client
|
||||
if conn, err = rtmp.Accept(res); err != nil {
|
||||
@@ -50,6 +52,13 @@ func handle(url string) (streamer.Producer, error) {
|
||||
return nil, err
|
||||
}
|
||||
return conn, nil
|
||||
|
||||
case "video/mpeg":
|
||||
client := mpegts.NewClient(res)
|
||||
if err = client.Handle(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return client, nil
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("unsupported Content-Type: %s", ct)
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"github.com/AlexxIT/go2rtc/cmd/streams"
|
||||
"github.com/AlexxIT/go2rtc/pkg/mjpeg"
|
||||
"github.com/rs/zerolog/log"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
)
|
||||
@@ -27,7 +28,10 @@ func handlerKeyframe(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
exit := make(chan []byte)
|
||||
|
||||
cons := &mjpeg.Consumer{}
|
||||
cons := &mjpeg.Consumer{
|
||||
RemoteAddr: r.RemoteAddr,
|
||||
UserAgent: r.UserAgent(),
|
||||
}
|
||||
cons.Listen(func(msg interface{}) {
|
||||
switch msg := msg.(type) {
|
||||
case []byte:
|
||||
@@ -59,6 +63,14 @@ func handlerKeyframe(w http.ResponseWriter, r *http.Request) {
|
||||
const header = "--frame\r\nContent-Type: image/jpeg\r\nContent-Length: "
|
||||
|
||||
func handlerStream(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != "POST" {
|
||||
outputMjpeg(w, r)
|
||||
} else {
|
||||
inputMjpeg(w, r)
|
||||
}
|
||||
}
|
||||
|
||||
func outputMjpeg(w http.ResponseWriter, r *http.Request) {
|
||||
src := r.URL.Query().Get("src")
|
||||
stream := streams.GetOrNew(src)
|
||||
if stream == nil {
|
||||
@@ -68,7 +80,10 @@ func handlerStream(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
flusher := w.(http.Flusher)
|
||||
|
||||
cons := &mjpeg.Consumer{}
|
||||
cons := &mjpeg.Consumer{
|
||||
RemoteAddr: r.RemoteAddr,
|
||||
UserAgent: r.UserAgent(),
|
||||
}
|
||||
cons.Listen(func(msg interface{}) {
|
||||
switch msg := msg.(type) {
|
||||
case []byte:
|
||||
@@ -102,6 +117,27 @@ func handlerStream(w http.ResponseWriter, r *http.Request) {
|
||||
//log.Trace().Msg("[api.mjpeg] close")
|
||||
}
|
||||
|
||||
func inputMjpeg(w http.ResponseWriter, r *http.Request) {
|
||||
dst := r.URL.Query().Get("dst")
|
||||
stream := streams.Get(dst)
|
||||
if stream == nil {
|
||||
http.Error(w, api.StreamNotFound, http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
res := &http.Response{Body: r.Body, Header: r.Header, Request: r}
|
||||
res.Header.Set("Content-Type", "multipart/mixed;boundary=")
|
||||
|
||||
client := mjpeg.NewClient(res)
|
||||
stream.AddProducer(client)
|
||||
|
||||
if err := client.Start(); err != nil && err != io.EOF {
|
||||
log.Warn().Err(err).Caller().Send()
|
||||
}
|
||||
|
||||
stream.RemoveProducer(client)
|
||||
}
|
||||
|
||||
func handlerWS(tr *api.Transport, _ *api.Message) error {
|
||||
src := tr.Request.URL.Query().Get("src")
|
||||
stream := streams.GetOrNew(src)
|
||||
@@ -109,7 +145,10 @@ func handlerWS(tr *api.Transport, _ *api.Message) error {
|
||||
return errors.New(api.StreamNotFound)
|
||||
}
|
||||
|
||||
cons := &mjpeg.Consumer{}
|
||||
cons := &mjpeg.Consumer{
|
||||
RemoteAddr: tr.Request.RemoteAddr,
|
||||
UserAgent: tr.Request.UserAgent(),
|
||||
}
|
||||
cons.Listen(func(msg interface{}) {
|
||||
if data, ok := msg.([]byte); ok {
|
||||
tr.Write(data)
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"github.com/AlexxIT/go2rtc/cmd/app"
|
||||
"github.com/AlexxIT/go2rtc/cmd/streams"
|
||||
"github.com/AlexxIT/go2rtc/pkg/mp4"
|
||||
"github.com/AlexxIT/go2rtc/pkg/streamer"
|
||||
"github.com/rs/zerolog"
|
||||
"net/http"
|
||||
"strconv"
|
||||
@@ -25,8 +26,14 @@ func Init() {
|
||||
var log zerolog.Logger
|
||||
|
||||
func handlerKeyframe(w http.ResponseWriter, r *http.Request) {
|
||||
if isChromeFirst(w, r) {
|
||||
return
|
||||
// Chrome 105 does two requests: without Range and with `Range: bytes=0-`
|
||||
ua := r.UserAgent()
|
||||
if strings.Contains(ua, " Chrome/") {
|
||||
if r.Header.Values("Range") == nil {
|
||||
w.Header().Set("Content-Type", "video/mp4")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
src := r.URL.Query().Get("src")
|
||||
@@ -67,7 +74,22 @@ func handlerKeyframe(w http.ResponseWriter, r *http.Request) {
|
||||
func handlerMP4(w http.ResponseWriter, r *http.Request) {
|
||||
log.Trace().Msgf("[mp4] %s %+v", r.Method, r.Header)
|
||||
|
||||
if isChromeFirst(w, r) || isSafari(w, r) {
|
||||
// Chrome has Safari in UA, so check first Chrome and later Safari
|
||||
ua := r.UserAgent()
|
||||
if strings.Contains(ua, " Chrome/") {
|
||||
if r.Header.Values("Range") == nil {
|
||||
w.Header().Set("Content-Type", "video/mp4")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
return
|
||||
}
|
||||
} else if strings.Contains(ua, " Safari/") {
|
||||
// auto redirect to HLS/fMP4 format, because Safari not support MP4 stream
|
||||
url := "stream.m3u8?" + r.URL.RawQuery
|
||||
if !r.URL.Query().Has("mp4") {
|
||||
url += "&mp4"
|
||||
}
|
||||
|
||||
http.Redirect(w, r, url, http.StatusMovedPermanently)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -80,7 +102,12 @@ func handlerMP4(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
exit := make(chan error)
|
||||
|
||||
cons := &mp4.Consumer{}
|
||||
cons := &mp4.Consumer{
|
||||
RemoteAddr: r.RemoteAddr,
|
||||
UserAgent: r.UserAgent(),
|
||||
Medias: streamer.ParseQuery(r.URL.Query()),
|
||||
}
|
||||
|
||||
cons.Listen(func(msg interface{}) {
|
||||
if data, ok := msg.([]byte); ok {
|
||||
if _, err := w.Write(data); err != nil && exit != nil {
|
||||
@@ -132,23 +159,3 @@ func handlerMP4(w http.ResponseWriter, r *http.Request) {
|
||||
duration.Stop()
|
||||
}
|
||||
}
|
||||
|
||||
func isChromeFirst(w http.ResponseWriter, r *http.Request) bool {
|
||||
// Chrome 105 does two requests: without Range and with `Range: bytes=0-`
|
||||
if strings.Contains(r.UserAgent(), " Chrome/") {
|
||||
if r.Header.Values("Range") == nil {
|
||||
w.Header().Set("Content-Type", "video/mp4")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func isSafari(w http.ResponseWriter, r *http.Request) bool {
|
||||
if r.Header.Get("Range") == "bytes=0-1" {
|
||||
handlerKeyframe(w, r)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -18,9 +18,10 @@ func handlerWSMSE(tr *api.Transport, msg *api.Message) error {
|
||||
return errors.New(api.StreamNotFound)
|
||||
}
|
||||
|
||||
cons := &mp4.Consumer{}
|
||||
cons.UserAgent = tr.Request.UserAgent()
|
||||
cons.RemoteAddr = tr.Request.RemoteAddr
|
||||
cons := &mp4.Consumer{
|
||||
RemoteAddr: tr.Request.RemoteAddr,
|
||||
UserAgent: tr.Request.UserAgent(),
|
||||
}
|
||||
|
||||
if codecs, ok := msg.Value.(string); ok {
|
||||
log.Trace().Str("codecs", codecs).Msgf("[mp4] new WS/MSE consumer")
|
||||
@@ -68,7 +69,11 @@ func handlerWSMP4(tr *api.Transport, msg *api.Message) error {
|
||||
return errors.New(api.StreamNotFound)
|
||||
}
|
||||
|
||||
cons := &mp4.Segment{}
|
||||
cons := &mp4.Segment{
|
||||
RemoteAddr: tr.Request.RemoteAddr,
|
||||
UserAgent: tr.Request.UserAgent(),
|
||||
OnlyKeyframe: true,
|
||||
}
|
||||
|
||||
if codecs, ok := msg.Value.(string); ok {
|
||||
log.Trace().Str("codecs", codecs).Msgf("[mp4] new WS/MP4 consumer")
|
||||
@@ -101,15 +106,18 @@ func parseMedias(codecs string, parseAudio bool) (medias []*streamer.Media) {
|
||||
|
||||
for _, name := range strings.Split(codecs, ",") {
|
||||
switch name {
|
||||
case "avc1.640029":
|
||||
case mp4.MimeH264:
|
||||
codec := &streamer.Codec{Name: streamer.CodecH264}
|
||||
videos = append(videos, codec)
|
||||
case "hvc1.1.6.L153.B0":
|
||||
case mp4.MimeH265:
|
||||
codec := &streamer.Codec{Name: streamer.CodecH265}
|
||||
videos = append(videos, codec)
|
||||
case "mp4a.40.2":
|
||||
case mp4.MimeAAC:
|
||||
codec := &streamer.Codec{Name: streamer.CodecAAC}
|
||||
audios = append(audios, codec)
|
||||
case mp4.MimeOpus:
|
||||
codec := &streamer.Codec{Name: streamer.CodecOpus}
|
||||
audios = append(audios, codec)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
43
cmd/mpegts/mpegts.go
Normal file
43
cmd/mpegts/mpegts.go
Normal file
@@ -0,0 +1,43 @@
|
||||
package mpegts
|
||||
|
||||
import (
|
||||
"github.com/AlexxIT/go2rtc/cmd/api"
|
||||
"github.com/AlexxIT/go2rtc/cmd/streams"
|
||||
"github.com/AlexxIT/go2rtc/pkg/mpegts"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
func Init() {
|
||||
api.HandleFunc("api/stream.ts", apiHandle)
|
||||
}
|
||||
|
||||
func apiHandle(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != "POST" {
|
||||
http.Error(w, "", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
dst := r.URL.Query().Get("dst")
|
||||
stream := streams.Get(dst)
|
||||
if stream == nil {
|
||||
http.Error(w, api.StreamNotFound, http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
res := &http.Response{Body: r.Body, Request: r}
|
||||
client := mpegts.NewClient(res)
|
||||
|
||||
if err := client.Handle(); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
stream.AddProducer(client)
|
||||
|
||||
if err := client.Handle(); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
stream.RemoveProducer(client)
|
||||
}
|
||||
@@ -1,16 +1,22 @@
|
||||
package rtmp
|
||||
|
||||
import (
|
||||
"github.com/AlexxIT/go2rtc/cmd/api"
|
||||
"github.com/AlexxIT/go2rtc/cmd/streams"
|
||||
"github.com/AlexxIT/go2rtc/pkg/rtmp"
|
||||
"github.com/AlexxIT/go2rtc/pkg/streamer"
|
||||
"github.com/rs/zerolog/log"
|
||||
"io"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
func Init() {
|
||||
streams.HandleFunc("rtmp", handle)
|
||||
streams.HandleFunc("rtmp", streamsHandle)
|
||||
|
||||
api.HandleFunc("api/stream.flv", apiHandle)
|
||||
}
|
||||
|
||||
func handle(url string) (streamer.Producer, error) {
|
||||
func streamsHandle(url string) (streamer.Producer, error) {
|
||||
conn := rtmp.NewClient(url)
|
||||
if err := conn.Dial(); err != nil {
|
||||
return nil, err
|
||||
@@ -20,3 +26,37 @@ func handle(url string) (streamer.Producer, error) {
|
||||
}
|
||||
return conn, nil
|
||||
}
|
||||
|
||||
func apiHandle(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != "POST" {
|
||||
http.Error(w, "", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
dst := r.URL.Query().Get("dst")
|
||||
stream := streams.Get(dst)
|
||||
if stream == nil {
|
||||
http.Error(w, api.StreamNotFound, http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
res := &http.Response{Body: r.Body, Request: r}
|
||||
client, err := rtmp.Accept(res)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
if err = client.Describe(); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
stream.AddProducer(client)
|
||||
|
||||
if err = client.Start(); err != nil && err != io.EOF {
|
||||
log.Warn().Err(err).Caller().Send()
|
||||
}
|
||||
|
||||
stream.RemoveProducer(client)
|
||||
}
|
||||
|
||||
@@ -3,27 +3,32 @@ package rtsp
|
||||
import (
|
||||
"github.com/AlexxIT/go2rtc/cmd/app"
|
||||
"github.com/AlexxIT/go2rtc/cmd/streams"
|
||||
"github.com/AlexxIT/go2rtc/pkg/mp4"
|
||||
"github.com/AlexxIT/go2rtc/pkg/rtsp"
|
||||
"github.com/AlexxIT/go2rtc/pkg/streamer"
|
||||
"github.com/AlexxIT/go2rtc/pkg/tcp"
|
||||
"github.com/rs/zerolog"
|
||||
"net"
|
||||
"net/url"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func Init() {
|
||||
var conf struct {
|
||||
Mod struct {
|
||||
Listen string `yaml:"listen"`
|
||||
Username string `yaml:"username"`
|
||||
Password string `yaml:"password"`
|
||||
Listen string `yaml:"listen" json:"listen"`
|
||||
Username string `yaml:"username" json:"-"`
|
||||
Password string `yaml:"password" json:"-"`
|
||||
DefaultQuery string `yaml:"default_query" json:"default_query"`
|
||||
} `yaml:"rtsp"`
|
||||
}
|
||||
|
||||
// default config
|
||||
conf.Mod.Listen = ":8554"
|
||||
conf.Mod.DefaultQuery = "video&audio"
|
||||
|
||||
app.LoadConfig(&conf)
|
||||
app.Info["rtsp"] = conf.Mod
|
||||
|
||||
log = app.GetLogger("rtsp")
|
||||
|
||||
@@ -48,6 +53,10 @@ func Init() {
|
||||
|
||||
log.Info().Str("addr", address).Msg("[rtsp] listen")
|
||||
|
||||
if query, err := url.ParseQuery(conf.Mod.DefaultQuery); err == nil {
|
||||
defaultMedias = mp4.ParseQuery(query)
|
||||
}
|
||||
|
||||
go func() {
|
||||
for {
|
||||
conn, err := ln.Accept()
|
||||
@@ -77,6 +86,7 @@ var Port string
|
||||
|
||||
var log zerolog.Logger
|
||||
var handlers []Handler
|
||||
var defaultMedias []*streamer.Media
|
||||
|
||||
func rtspHandler(url string) (streamer.Producer, error) {
|
||||
backchannel := true
|
||||
@@ -102,6 +112,8 @@ func rtspHandler(url string) (streamer.Producer, error) {
|
||||
log.Trace().Msgf("[rtsp] client request:\n%s", msg)
|
||||
case *tcp.Response:
|
||||
log.Trace().Msgf("[rtsp] client response:\n%s", msg)
|
||||
case string:
|
||||
log.Trace().Msgf("[rtsp] client msg: %s", msg)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -161,7 +173,14 @@ func tcpHandler(conn *rtsp.Conn) {
|
||||
|
||||
log.Debug().Str("stream", name).Msg("[rtsp] new consumer")
|
||||
|
||||
initMedias(conn)
|
||||
conn.SessionName = app.UserAgent
|
||||
|
||||
conn.Medias = mp4.ParseQuery(conn.URL.Query())
|
||||
if conn.Medias == nil {
|
||||
for _, media := range defaultMedias {
|
||||
conn.Medias = append(conn.Medias, media.Clone())
|
||||
}
|
||||
}
|
||||
|
||||
if err := stream.AddConsumer(conn); err != nil {
|
||||
log.Warn().Err(err).Str("stream", name).Msg("[rtsp]")
|
||||
@@ -225,45 +244,3 @@ func tcpHandler(conn *rtsp.Conn) {
|
||||
|
||||
_ = conn.Close()
|
||||
}
|
||||
|
||||
func initMedias(conn *rtsp.Conn) {
|
||||
// set media candidates from query list
|
||||
for key, value := range conn.URL.Query() {
|
||||
switch key {
|
||||
case streamer.KindVideo, streamer.KindAudio:
|
||||
for _, name := range value {
|
||||
name = strings.ToUpper(name)
|
||||
|
||||
// check aliases
|
||||
switch name {
|
||||
case "COPY":
|
||||
name = "" // pass empty codecs list
|
||||
case "MJPEG":
|
||||
name = streamer.CodecJPEG
|
||||
case "AAC":
|
||||
name = streamer.CodecAAC
|
||||
}
|
||||
|
||||
media := &streamer.Media{
|
||||
Kind: key, Direction: streamer.DirectionRecvonly,
|
||||
}
|
||||
|
||||
// empty codecs match all codecs
|
||||
if name != "" {
|
||||
// empty clock rate and channels match any values
|
||||
media.Codecs = []*streamer.Codec{{Name: name}}
|
||||
}
|
||||
|
||||
conn.Medias = append(conn.Medias, media)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// set default media candidates if query is empty
|
||||
if conn.Medias == nil {
|
||||
conn.Medias = []*streamer.Media{
|
||||
{Kind: streamer.KindVideo, Direction: streamer.DirectionRecvonly},
|
||||
{Kind: streamer.KindAudio, Direction: streamer.DirectionRecvonly},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
15
cmd/streams/consumer.go
Normal file
15
cmd/streams/consumer.go
Normal file
@@ -0,0 +1,15 @@
|
||||
package streams
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"github.com/AlexxIT/go2rtc/pkg/streamer"
|
||||
)
|
||||
|
||||
type Consumer struct {
|
||||
element streamer.Consumer
|
||||
tracks []*streamer.Track
|
||||
}
|
||||
|
||||
func (c *Consumer) MarshalJSON() ([]byte, error) {
|
||||
return json.Marshal(c.element)
|
||||
}
|
||||
116
cmd/streams/play.go
Normal file
116
cmd/streams/play.go
Normal file
@@ -0,0 +1,116 @@
|
||||
package streams
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"github.com/AlexxIT/go2rtc/pkg/streamer"
|
||||
)
|
||||
|
||||
func (s *Stream) Play(source string) error {
|
||||
s.mu.Lock()
|
||||
for _, producer := range s.producers {
|
||||
if producer.state == stateInternal && producer.element != nil {
|
||||
_ = producer.element.Stop()
|
||||
}
|
||||
}
|
||||
s.mu.Unlock()
|
||||
|
||||
if source == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
for _, producer := range s.producers {
|
||||
// start new client
|
||||
client, err := GetProducer(producer.url)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// check if client support consumer interface
|
||||
cons, ok := client.(streamer.Consumer)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
// start new producer
|
||||
prod, err := GetProducer(source)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if !matchMedia(prod, cons) {
|
||||
return errors.New("can't match media")
|
||||
}
|
||||
|
||||
s.AddInternalProducer(prod)
|
||||
s.AddInternalConsumer(cons)
|
||||
|
||||
go func() {
|
||||
_ = prod.Start()
|
||||
_ = client.Stop()
|
||||
s.RemoveProducer(prod)
|
||||
}()
|
||||
|
||||
go func() {
|
||||
_ = client.Start()
|
||||
_ = prod.Stop()
|
||||
s.RemoveInternalConsumer(cons)
|
||||
}()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
return errors.New("can't find consumer")
|
||||
}
|
||||
|
||||
func (s *Stream) AddInternalProducer(prod streamer.Producer) {
|
||||
producer := &Producer{element: prod, state: stateInternal}
|
||||
s.mu.Lock()
|
||||
s.producers = append(s.producers, producer)
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
func (s *Stream) AddInternalConsumer(cons streamer.Consumer) {
|
||||
consumer := &Consumer{element: cons}
|
||||
s.mu.Lock()
|
||||
s.consumers = append(s.consumers, consumer)
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
func (s *Stream) RemoveInternalConsumer(cons streamer.Consumer) {
|
||||
s.mu.Lock()
|
||||
for i, consumer := range s.consumers {
|
||||
if consumer.element == cons {
|
||||
s.removeConsumer(i)
|
||||
break
|
||||
}
|
||||
}
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
func matchMedia(prod streamer.Producer, cons streamer.Consumer) bool {
|
||||
for _, consMedia := range cons.GetMedias() {
|
||||
for _, prodMedia := range prod.GetMedias() {
|
||||
// codec negotiation
|
||||
prodCodec := prodMedia.MatchMedia(consMedia)
|
||||
if prodCodec == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
// setup producer track
|
||||
prodTrack := prod.GetTrack(prodMedia, prodCodec)
|
||||
if prodTrack == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
// setup consumer track
|
||||
consTrack := cons.AddTrack(consMedia, prodTrack)
|
||||
if consTrack == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package streams
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"github.com/AlexxIT/go2rtc/pkg/streamer"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -15,6 +16,7 @@ const (
|
||||
stateTracks
|
||||
stateStart
|
||||
stateExternal
|
||||
stateInternal
|
||||
)
|
||||
|
||||
type Producer struct {
|
||||
@@ -91,6 +93,15 @@ func (p *Producer) GetTrack(media *streamer.Media, codec *streamer.Codec) *strea
|
||||
return track
|
||||
}
|
||||
|
||||
func (p *Producer) MarshalJSON() ([]byte, error) {
|
||||
if p.element != nil {
|
||||
return json.Marshal(p.element)
|
||||
}
|
||||
|
||||
info := streamer.Info{URL: p.url}
|
||||
return json.Marshal(info)
|
||||
}
|
||||
|
||||
// internals
|
||||
|
||||
func (p *Producer) start() {
|
||||
|
||||
@@ -10,11 +10,6 @@ import (
|
||||
"sync/atomic"
|
||||
)
|
||||
|
||||
type Consumer struct {
|
||||
element streamer.Consumer
|
||||
tracks []*streamer.Track
|
||||
}
|
||||
|
||||
type Stream struct {
|
||||
producers []*Producer
|
||||
consumers []*Consumer
|
||||
@@ -96,7 +91,9 @@ func (s *Stream) AddConsumer(cons streamer.Consumer) (err error) {
|
||||
|
||||
consumer.tracks = append(consumer.tracks, consTrack)
|
||||
producers = append(producers, prod)
|
||||
break producers
|
||||
if !consMedia.MatchAll() {
|
||||
break producers
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -199,24 +196,19 @@ producers:
|
||||
func (s *Stream) MarshalJSON() ([]byte, error) {
|
||||
if !s.mu.TryLock() {
|
||||
log.Warn().Msgf("[streams] json locked")
|
||||
return []byte(`null`), nil
|
||||
return json.Marshal(nil)
|
||||
}
|
||||
|
||||
var v []interface{}
|
||||
for _, prod := range s.producers {
|
||||
if prod.element != nil {
|
||||
v = append(v, prod.element)
|
||||
}
|
||||
}
|
||||
for _, cons := range s.consumers {
|
||||
// cons.element always not nil
|
||||
v = append(v, cons.element)
|
||||
var info struct {
|
||||
Producers []*Producer `json:"producers"`
|
||||
Consumers []*Consumer `json:"consumers"`
|
||||
}
|
||||
info.Producers = s.producers
|
||||
info.Consumers = s.consumers
|
||||
|
||||
s.mu.Unlock()
|
||||
if len(v) == 0 {
|
||||
v = nil
|
||||
}
|
||||
return json.Marshal(v)
|
||||
|
||||
return json.Marshal(info)
|
||||
}
|
||||
|
||||
func (s *Stream) removeConsumer(i int) {
|
||||
|
||||
@@ -1,135 +0,0 @@
|
||||
package streams
|
||||
|
||||
import (
|
||||
"github.com/AlexxIT/go2rtc/pkg/fake"
|
||||
"github.com/AlexxIT/go2rtc/pkg/rtsp"
|
||||
"github.com/AlexxIT/go2rtc/pkg/streamer"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Google Chrome 104.0.5112.79
|
||||
const chrome = `v=0
|
||||
o=- 0 0 IN IP4 0.0.0.0
|
||||
s=-
|
||||
t=0 0
|
||||
m=audio 9 UDP/TLS/RTP/SAVPF 111 63 103 104 9 0 8 110 112 113 126
|
||||
a=sendrecv
|
||||
a=rtpmap:111 opus/48000/2
|
||||
a=rtpmap:63 red/48000/2
|
||||
a=rtpmap:103 ISAC/16000
|
||||
a=rtpmap:104 ISAC/32000
|
||||
a=rtpmap:9 G722/8000
|
||||
a=rtpmap:0 PCMU/8000
|
||||
a=rtpmap:8 PCMA/8000
|
||||
a=rtpmap:110 telephone-event/48000
|
||||
a=rtpmap:112 telephone-event/32000
|
||||
a=rtpmap:113 telephone-event/16000
|
||||
a=rtpmap:126 telephone-event/8000
|
||||
m=video 9 UDP/TLS/RTP/SAVPF 96 97 98 99 100 101 102 122 127 121 125 107 108 109 124 120 123 119 35 36 37 38 39 40 41 42 114 115 116 117 118 43
|
||||
a=recvonly
|
||||
a=rtpmap:96 VP8/90000
|
||||
a=rtpmap:97 rtx/90000
|
||||
a=rtpmap:98 VP9/90000
|
||||
a=rtpmap:99 rtx/90000
|
||||
a=rtpmap:100 VP9/90000
|
||||
a=rtpmap:101 rtx/90000
|
||||
a=rtpmap:102 VP9/90000
|
||||
a=rtpmap:122 rtx/90000
|
||||
a=rtpmap:127 H264/90000
|
||||
a=fmtp:127 level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=42001f
|
||||
a=rtpmap:121 rtx/90000
|
||||
a=rtpmap:125 H264/90000
|
||||
a=fmtp:125 level-asymmetry-allowed=1;packetization-mode=0;profile-level-id=42001f
|
||||
a=rtpmap:107 rtx/90000
|
||||
a=rtpmap:108 H264/90000
|
||||
a=fmtp:108 level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=42e01f
|
||||
a=rtpmap:109 rtx/90000
|
||||
a=rtpmap:124 H264/90000
|
||||
a=fmtp:124 level-asymmetry-allowed=1;packetization-mode=0;profile-level-id=42e01f
|
||||
a=rtpmap:120 rtx/90000
|
||||
a=rtpmap:123 H264/90000
|
||||
a=fmtp:123 level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=4d001f
|
||||
a=rtpmap:119 rtx/90000
|
||||
a=rtpmap:35 H264/90000
|
||||
a=fmtp:35 level-asymmetry-allowed=1;packetization-mode=0;profile-level-id=4d001f
|
||||
a=rtpmap:36 rtx/90000
|
||||
a=rtpmap:37 H264/90000
|
||||
a=fmtp:37 level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=f4001f
|
||||
a=rtpmap:38 rtx/90000
|
||||
a=rtpmap:39 H264/90000
|
||||
a=fmtp:39 level-asymmetry-allowed=1;packetization-mode=0;profile-level-id=f4001f
|
||||
a=rtpmap:40 rtx/90000
|
||||
a=rtpmap:41 AV1/90000
|
||||
a=rtpmap:42 rtx/90000
|
||||
a=rtpmap:114 H264/90000
|
||||
a=fmtp:114 level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=64001f
|
||||
a=rtpmap:115 rtx/90000
|
||||
a=rtpmap:116 red/90000
|
||||
a=rtpmap:117 rtx/90000
|
||||
a=rtpmap:118 ulpfec/90000
|
||||
a=rtpmap:43 flexfec-03/90000
|
||||
`
|
||||
|
||||
const dahuaSimple = `v=0
|
||||
o=- 0 0 IN IP4 0.0.0.0
|
||||
s=-
|
||||
t=0 0
|
||||
m=video 0 RTP/AVP 96
|
||||
a=control:trackID=0
|
||||
a=rtpmap:96 H264/90000
|
||||
a=fmtp:96 packetization-mode=1;profile-level-id=42401E;sprop-parameter-sets=Z0JAHqaAoD2QAA==,aM48gAA=
|
||||
a=recvonly
|
||||
m=audio 0 RTP/AVP 97
|
||||
a=control:trackID=1
|
||||
a=rtpmap:97 MPEG4-GENERIC/16000
|
||||
a=fmtp:97 streamtype=5;profile-level-id=1;mode=AAC-hbr;sizelength=13;indexlength=3;indexdeltalength=3;config=1408
|
||||
a=recvonly
|
||||
m=audio 0 RTP/AVP 8
|
||||
a=control:trackID=5
|
||||
a=rtpmap:8 PCMA/8000
|
||||
a=sendonly
|
||||
`
|
||||
|
||||
const ffmpegPCMU48000 = `v=0
|
||||
o=- 0 0 IN IP4 127.0.0.1
|
||||
s=-
|
||||
t=0 0
|
||||
m=audio 0 RTP/AVP 96
|
||||
b=AS:384
|
||||
a=rtpmap:96 PCMU/48000/1
|
||||
a=control:streamid=0
|
||||
`
|
||||
|
||||
func TestRouting(t *testing.T) {
|
||||
prod := &fake.Producer{}
|
||||
prod.Medias, _ = rtsp.UnmarshalSDP([]byte(dahuaSimple))
|
||||
assert.Len(t, prod.Medias, 3)
|
||||
|
||||
HandleFunc("fake", func(url string) (streamer.Producer, error) {
|
||||
return prod, nil
|
||||
})
|
||||
|
||||
cons := &fake.Consumer{}
|
||||
cons.Medias, _ = streamer.UnmarshalSDP([]byte(chrome))
|
||||
assert.Len(t, cons.Medias, 3)
|
||||
|
||||
// setup stream with one producer
|
||||
stream := NewStream("fake:")
|
||||
|
||||
// main check:
|
||||
err := stream.AddConsumer(cons)
|
||||
assert.Nil(t, err)
|
||||
|
||||
assert.Len(t, prod.Tracks, 2)
|
||||
assert.Len(t, cons.Tracks, 2)
|
||||
|
||||
time.Sleep(time.Second)
|
||||
|
||||
assert.Greater(t, prod.SendPackets,0)
|
||||
assert.Greater(t, cons.RecvPackets,0)
|
||||
|
||||
assert.Greater(t, prod.RecvPackets,0)
|
||||
assert.Greater(t, cons.SendPackets,0)
|
||||
}
|
||||
@@ -1,9 +1,12 @@
|
||||
package streams
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"github.com/AlexxIT/go2rtc/cmd/api"
|
||||
"github.com/AlexxIT/go2rtc/cmd/app"
|
||||
"github.com/AlexxIT/go2rtc/cmd/app/store"
|
||||
"github.com/rs/zerolog"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
func Init() {
|
||||
@@ -22,6 +25,8 @@ func Init() {
|
||||
for name, item := range store.GetDict("streams") {
|
||||
streams[name] = NewStream(item)
|
||||
}
|
||||
|
||||
api.HandleFunc("api/streams", streamsHandler)
|
||||
}
|
||||
|
||||
func Get(name string) *Stream {
|
||||
@@ -48,19 +53,63 @@ func GetOrNew(src string) *Stream {
|
||||
return New(src, src)
|
||||
}
|
||||
|
||||
func Delete(name string) {
|
||||
delete(streams, name)
|
||||
}
|
||||
func streamsHandler(w http.ResponseWriter, r *http.Request) {
|
||||
query := r.URL.Query()
|
||||
src := query.Get("src")
|
||||
|
||||
func All() map[string]interface{} {
|
||||
all := map[string]interface{}{}
|
||||
for name, stream := range streams {
|
||||
all[name] = stream
|
||||
//if stream.Active() {
|
||||
// all[name] = stream
|
||||
//}
|
||||
// without source - return all streams list
|
||||
if src == "" && r.Method != "POST" {
|
||||
_ = json.NewEncoder(w).Encode(streams)
|
||||
return
|
||||
}
|
||||
|
||||
// Not sure about all this API. Should be rewrited...
|
||||
switch r.Method {
|
||||
case "GET":
|
||||
e := json.NewEncoder(w)
|
||||
e.SetIndent("", " ")
|
||||
_ = e.Encode(streams[src])
|
||||
|
||||
case "PUT":
|
||||
name := query.Get("name")
|
||||
if name == "" {
|
||||
name = src
|
||||
}
|
||||
|
||||
New(name, src)
|
||||
|
||||
case "PATCH":
|
||||
name := query.Get("name")
|
||||
if name == "" {
|
||||
http.Error(w, "", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if stream := Get(name); stream != nil {
|
||||
stream.SetSource(src)
|
||||
} else {
|
||||
New(name, src)
|
||||
}
|
||||
|
||||
case "POST":
|
||||
// with dst - redirect source to dst
|
||||
if dst := query.Get("dst"); dst != "" {
|
||||
if stream := Get(dst); stream != nil {
|
||||
if err := stream.Play(src); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
} else {
|
||||
_ = json.NewEncoder(w).Encode(stream)
|
||||
}
|
||||
} else {
|
||||
http.Error(w, "", http.StatusNotFound)
|
||||
}
|
||||
} else {
|
||||
http.Error(w, "", http.StatusBadRequest)
|
||||
}
|
||||
|
||||
case "DELETE":
|
||||
delete(streams, src)
|
||||
}
|
||||
return all
|
||||
}
|
||||
|
||||
var log zerolog.Logger
|
||||
|
||||
19
cmd/tapo/tapo.go
Normal file
19
cmd/tapo/tapo.go
Normal file
@@ -0,0 +1,19 @@
|
||||
package tapo
|
||||
|
||||
import (
|
||||
"github.com/AlexxIT/go2rtc/cmd/streams"
|
||||
"github.com/AlexxIT/go2rtc/pkg/streamer"
|
||||
"github.com/AlexxIT/go2rtc/pkg/tapo"
|
||||
)
|
||||
|
||||
func Init() {
|
||||
streams.HandleFunc("tapo", handle)
|
||||
}
|
||||
|
||||
func handle(url string) (streamer.Producer, error) {
|
||||
conn := tapo.NewClient(url)
|
||||
if err := conn.Dial(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return conn, nil
|
||||
}
|
||||
23
main.go
23
main.go
@@ -4,20 +4,24 @@ import (
|
||||
"github.com/AlexxIT/go2rtc/cmd/api"
|
||||
"github.com/AlexxIT/go2rtc/cmd/app"
|
||||
"github.com/AlexxIT/go2rtc/cmd/debug"
|
||||
"github.com/AlexxIT/go2rtc/cmd/dvrip"
|
||||
"github.com/AlexxIT/go2rtc/cmd/echo"
|
||||
"github.com/AlexxIT/go2rtc/cmd/exec"
|
||||
"github.com/AlexxIT/go2rtc/cmd/ffmpeg"
|
||||
"github.com/AlexxIT/go2rtc/cmd/hass"
|
||||
"github.com/AlexxIT/go2rtc/cmd/hls"
|
||||
"github.com/AlexxIT/go2rtc/cmd/homekit"
|
||||
"github.com/AlexxIT/go2rtc/cmd/http"
|
||||
"github.com/AlexxIT/go2rtc/cmd/ivideon"
|
||||
"github.com/AlexxIT/go2rtc/cmd/mjpeg"
|
||||
"github.com/AlexxIT/go2rtc/cmd/mp4"
|
||||
"github.com/AlexxIT/go2rtc/cmd/mpegts"
|
||||
"github.com/AlexxIT/go2rtc/cmd/ngrok"
|
||||
"github.com/AlexxIT/go2rtc/cmd/rtmp"
|
||||
"github.com/AlexxIT/go2rtc/cmd/rtsp"
|
||||
"github.com/AlexxIT/go2rtc/cmd/srtp"
|
||||
"github.com/AlexxIT/go2rtc/cmd/streams"
|
||||
"github.com/AlexxIT/go2rtc/cmd/tapo"
|
||||
"github.com/AlexxIT/go2rtc/cmd/webrtc"
|
||||
"os"
|
||||
"os/signal"
|
||||
@@ -26,27 +30,28 @@ import (
|
||||
|
||||
func main() {
|
||||
app.Init() // init config and logs
|
||||
api.Init() // init HTTP API server
|
||||
streams.Init() // load streams list
|
||||
|
||||
api.Init() // init HTTP API server
|
||||
|
||||
echo.Init()
|
||||
|
||||
rtsp.Init() // add support RTSP client and RTSP server
|
||||
rtmp.Init() // add support RTMP client
|
||||
exec.Init() // add support exec scheme (depends on RTSP server)
|
||||
ffmpeg.Init() // add support ffmpeg scheme (depends on exec scheme)
|
||||
hass.Init() // add support hass scheme
|
||||
|
||||
webrtc.Init()
|
||||
mp4.Init()
|
||||
mjpeg.Init()
|
||||
echo.Init()
|
||||
ivideon.Init()
|
||||
http.Init()
|
||||
dvrip.Init()
|
||||
tapo.Init()
|
||||
mpegts.Init()
|
||||
|
||||
srtp.Init()
|
||||
homekit.Init()
|
||||
|
||||
ivideon.Init()
|
||||
webrtc.Init()
|
||||
mp4.Init()
|
||||
hls.Init()
|
||||
mjpeg.Init()
|
||||
|
||||
ngrok.Init()
|
||||
debug.Init()
|
||||
|
||||
@@ -17,9 +17,14 @@ func RTPDepay(track *streamer.Track) streamer.WrapperFunc {
|
||||
|
||||
//log.Printf("[RTP/AAC] units: %d, size: %4d, ts: %10d, %t", headersSize/2, len(packet.Payload), packet.Timestamp, packet.Marker)
|
||||
|
||||
data := packet.Payload[2+headersSize:]
|
||||
if IsADTS(data) {
|
||||
data = data[7:]
|
||||
}
|
||||
|
||||
clone := *packet
|
||||
clone.Version = RTPPacketVersionAAC
|
||||
clone.Payload = packet.Payload[2+headersSize:]
|
||||
clone.Payload = data
|
||||
return push(&clone)
|
||||
}
|
||||
}
|
||||
@@ -55,3 +60,7 @@ func RTPPay(mtu uint16) streamer.WrapperFunc {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func IsADTS(b []byte) bool {
|
||||
return len(b) > 7 && b[0] == 0xFF && b[1]&0xF0 == 0xF0
|
||||
}
|
||||
|
||||
427
pkg/dvrip/client.go
Normal file
427
pkg/dvrip/client.go
Normal file
@@ -0,0 +1,427 @@
|
||||
package dvrip
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"crypto/md5"
|
||||
"encoding/base64"
|
||||
"encoding/binary"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/AlexxIT/go2rtc/pkg/h264"
|
||||
"github.com/AlexxIT/go2rtc/pkg/h265"
|
||||
"github.com/AlexxIT/go2rtc/pkg/streamer"
|
||||
"github.com/pion/rtp"
|
||||
"io"
|
||||
"net"
|
||||
"net/url"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Client struct {
|
||||
streamer.Element
|
||||
|
||||
uri string
|
||||
conn net.Conn
|
||||
reader *bufio.Reader
|
||||
session uint32
|
||||
seq uint32
|
||||
stream string
|
||||
|
||||
medias []*streamer.Media
|
||||
videoTrack *streamer.Track
|
||||
audioTrack *streamer.Track
|
||||
|
||||
videoTS uint32
|
||||
videoDT uint32
|
||||
audioTS uint32
|
||||
audioSeq uint16
|
||||
}
|
||||
|
||||
type Response map[string]interface{}
|
||||
|
||||
const Login = uint16(1000)
|
||||
const OPMonitorClaim = uint16(1413)
|
||||
const OPMonitorStart = uint16(1410)
|
||||
|
||||
func NewClient(url string) *Client {
|
||||
return &Client{uri: url}
|
||||
}
|
||||
|
||||
func (c *Client) Dial() (err error) {
|
||||
u, err := url.Parse(c.uri)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
if u.Port() == "" {
|
||||
// add default TCP port
|
||||
u.Host += ":34567"
|
||||
}
|
||||
|
||||
c.conn, err = net.DialTimeout("tcp", u.Host, time.Second*3)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
c.reader = bufio.NewReader(c.conn)
|
||||
|
||||
query := u.Query()
|
||||
channel := query.Get("channel")
|
||||
if channel == "" {
|
||||
channel = "0"
|
||||
}
|
||||
|
||||
subtype := query.Get("subtype")
|
||||
switch subtype {
|
||||
case "", "0":
|
||||
subtype = "Main"
|
||||
case "1":
|
||||
subtype = "Extra1"
|
||||
}
|
||||
|
||||
c.stream = fmt.Sprintf(
|
||||
`{"Channel":%s,"CombinMode":"NONE","StreamType":"%s","TransMode":"TCP"}`,
|
||||
channel, subtype,
|
||||
)
|
||||
|
||||
if u.User != nil {
|
||||
pass, _ := u.User.Password()
|
||||
return c.Login(u.User.Username(), pass)
|
||||
} else {
|
||||
return c.Login("admin", "admin")
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) Login(user, pass string) (err error) {
|
||||
data := fmt.Sprintf(
|
||||
`{"EncryptType":"MD5","LoginType":"DVRIP-Web","PassWord":"%s","UserName":"%s"}`,
|
||||
SofiaHash(pass), user,
|
||||
)
|
||||
|
||||
if err = c.Request(Login, data); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
_, err = c.ResponseJSON()
|
||||
return
|
||||
}
|
||||
|
||||
func (c *Client) Play() (err error) {
|
||||
format := `{"Name":"OPMonitor","SessionID":"0x%08X","OPMonitor":{"Action":"%s","Parameter":%s}}`
|
||||
|
||||
data := fmt.Sprintf(format, c.session, "Claim", c.stream)
|
||||
if err = c.Request(OPMonitorClaim, data); err != nil {
|
||||
return
|
||||
}
|
||||
if _, err = c.ResponseJSON(); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
data = fmt.Sprintf(format, c.session, "Start", c.stream)
|
||||
return c.Request(OPMonitorStart, data)
|
||||
}
|
||||
|
||||
func (c *Client) Handle() error {
|
||||
var buf []byte
|
||||
var size int
|
||||
|
||||
var probe byte
|
||||
if c.medias == nil {
|
||||
probe = 1
|
||||
}
|
||||
|
||||
for {
|
||||
b, err := c.Response()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// collect data from multiple packets
|
||||
if size > 0 {
|
||||
buf = append(buf, b...)
|
||||
if len(buf) < size {
|
||||
continue
|
||||
}
|
||||
if len(buf) > size {
|
||||
return errors.New("wrong size")
|
||||
}
|
||||
b = buf
|
||||
}
|
||||
|
||||
dataType := binary.BigEndian.Uint32(b)
|
||||
switch dataType {
|
||||
case 0x1FC, 0x1FE:
|
||||
size = int(binary.LittleEndian.Uint32(b[12:])) + 16
|
||||
case 0x1FD: // PFrame
|
||||
size = int(binary.LittleEndian.Uint32(b[4:])) + 8
|
||||
case 0x1FA, 0x1F9:
|
||||
size = int(binary.LittleEndian.Uint16(b[6:])) + 8
|
||||
default:
|
||||
return fmt.Errorf("unknown type: %X", dataType)
|
||||
}
|
||||
|
||||
if len(b) < size {
|
||||
buf = b
|
||||
continue // need to collect data from next packets
|
||||
}
|
||||
|
||||
//log.Printf("[DVR] type: %d, len: %d", dataType, len(b))
|
||||
|
||||
switch dataType {
|
||||
case 0x1FC, 0x1FE: // video IFrame
|
||||
payload := h264.AnnexB2AVC(b[16:])
|
||||
|
||||
if c.videoTrack == nil {
|
||||
fps := b[5]
|
||||
//width := uint16(b[6]) * 8
|
||||
//height := uint16(b[7]) * 8
|
||||
//println(width, height)
|
||||
ts := b[8:]
|
||||
|
||||
// the exact value of the start TS does not matter
|
||||
c.videoTS = binary.LittleEndian.Uint32(ts)
|
||||
c.videoDT = 90000 / uint32(fps)
|
||||
|
||||
c.AddVideoTrack(b[4], payload)
|
||||
}
|
||||
|
||||
if c.videoTrack != nil {
|
||||
c.videoTS += c.videoDT
|
||||
|
||||
packet := &rtp.Packet{
|
||||
Header: rtp.Header{Timestamp: c.videoTS},
|
||||
Payload: payload,
|
||||
}
|
||||
|
||||
//log.Printf("[AVC] %v, len: %d, ts: %10d", h265.Types(payload), len(payload), packet.Timestamp)
|
||||
|
||||
_ = c.videoTrack.WriteRTP(packet)
|
||||
}
|
||||
|
||||
case 0x1FD: // PFrame
|
||||
if c.videoTrack != nil {
|
||||
c.videoTS += c.videoDT
|
||||
|
||||
packet := &rtp.Packet{
|
||||
Header: rtp.Header{Timestamp: c.videoTS},
|
||||
Payload: h264.AnnexB2AVC(b[8:]),
|
||||
}
|
||||
|
||||
//log.Printf("[DVR] %v, len: %d, ts: %10d", h265.Types(packet.Payload), len(packet.Payload), packet.Timestamp)
|
||||
|
||||
_ = c.videoTrack.WriteRTP(packet)
|
||||
}
|
||||
|
||||
case 0x1FA, 0x1F9: // audio
|
||||
if c.audioTrack == nil {
|
||||
// the exact value of the start TS does not matter
|
||||
c.audioTS = c.videoTS
|
||||
|
||||
c.AddAudioTrack(b[4], b[5])
|
||||
}
|
||||
|
||||
if c.audioTrack != nil {
|
||||
for b != nil {
|
||||
payload := b[8:size]
|
||||
if len(b) > size {
|
||||
b = b[size:]
|
||||
} else {
|
||||
b = nil
|
||||
}
|
||||
|
||||
c.audioTS += uint32(len(payload))
|
||||
c.audioSeq++
|
||||
|
||||
packet := &rtp.Packet{
|
||||
Header: rtp.Header{
|
||||
Version: 2,
|
||||
Marker: true,
|
||||
SequenceNumber: c.audioSeq,
|
||||
Timestamp: c.audioTS,
|
||||
},
|
||||
Payload: payload,
|
||||
}
|
||||
|
||||
//log.Printf("[DVR] len: %d, ts: %10d", len(packet.Payload), packet.Timestamp)
|
||||
|
||||
_ = c.audioTrack.WriteRTP(packet)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if probe != 0 {
|
||||
probe++
|
||||
if (c.videoTS > 0 && c.audioTS > 0) || probe == 20 {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
size = 0
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) Close() error {
|
||||
return c.conn.Close()
|
||||
}
|
||||
|
||||
func (c *Client) Request(cmd uint16, data string) (err error) {
|
||||
b := make([]byte, 20, 128)
|
||||
b[0] = 255
|
||||
binary.LittleEndian.PutUint32(b[4:], c.session)
|
||||
binary.LittleEndian.PutUint32(b[8:], c.seq)
|
||||
binary.LittleEndian.PutUint16(b[14:], cmd)
|
||||
binary.LittleEndian.PutUint32(b[16:], uint32(len(data))+2)
|
||||
b = append(b, data...)
|
||||
b = append(b, 0x0A, 0x00)
|
||||
|
||||
c.seq++
|
||||
|
||||
if err = c.conn.SetWriteDeadline(time.Now().Add(time.Second * 5)); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
_, err = c.conn.Write(b)
|
||||
return
|
||||
}
|
||||
|
||||
func (c *Client) Response() (b []byte, err error) {
|
||||
if err = c.conn.SetReadDeadline(time.Now().Add(time.Second * 5)); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
b = make([]byte, 20)
|
||||
if _, err = io.ReadFull(c.reader, b); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
if b[0] != 255 {
|
||||
return nil, errors.New("read error")
|
||||
}
|
||||
|
||||
c.session = binary.LittleEndian.Uint32(b[4:])
|
||||
size := binary.LittleEndian.Uint32(b[16:])
|
||||
|
||||
b = make([]byte, size)
|
||||
if _, err = io.ReadFull(c.reader, b); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func (c *Client) ResponseJSON() (res Response, err error) {
|
||||
b, err := c.Response()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
res = Response{}
|
||||
if err = json.Unmarshal(b[:len(b)-2], &res); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
if v, ok := res["Ret"].(float64); !ok || (v != 100 && v != 515) {
|
||||
err = fmt.Errorf("wrong response: %s", b)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func (c *Client) AddVideoTrack(mediaCode byte, payload []byte) {
|
||||
var codec *streamer.Codec
|
||||
switch mediaCode {
|
||||
case 2:
|
||||
codec = &streamer.Codec{
|
||||
Name: streamer.CodecH264,
|
||||
ClockRate: 90000,
|
||||
PayloadType: streamer.PayloadTypeRAW,
|
||||
FmtpLine: h264.GetFmtpLine(payload),
|
||||
}
|
||||
|
||||
case 0x03, 0x13:
|
||||
codec = &streamer.Codec{
|
||||
Name: streamer.CodecH265,
|
||||
ClockRate: 90000,
|
||||
PayloadType: streamer.PayloadTypeRAW,
|
||||
FmtpLine: "profile-id=1",
|
||||
}
|
||||
|
||||
for {
|
||||
size := 4 + int(binary.BigEndian.Uint32(payload))
|
||||
|
||||
switch h265.NALUType(payload) {
|
||||
case h265.NALUTypeVPS:
|
||||
codec.FmtpLine += ";sprop-vps=" + base64.StdEncoding.EncodeToString(payload[4:size])
|
||||
case h265.NALUTypeSPS:
|
||||
codec.FmtpLine += ";sprop-sps=" + base64.StdEncoding.EncodeToString(payload[4:size])
|
||||
case h265.NALUTypePPS:
|
||||
codec.FmtpLine += ";sprop-pps=" + base64.StdEncoding.EncodeToString(payload[4:size])
|
||||
}
|
||||
|
||||
if size < len(payload) {
|
||||
payload = payload[size:]
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
default:
|
||||
println("[DVRIP] unsupported video codec:", mediaCode)
|
||||
return
|
||||
}
|
||||
|
||||
media := &streamer.Media{
|
||||
Kind: streamer.KindVideo,
|
||||
Direction: streamer.DirectionSendonly,
|
||||
Codecs: []*streamer.Codec{codec},
|
||||
}
|
||||
c.medias = append(c.medias, media)
|
||||
|
||||
c.videoTrack = streamer.NewTrack(codec, media.Direction)
|
||||
}
|
||||
|
||||
var sampleRates = []uint32{4000, 8000, 11025, 16000, 20000, 22050, 32000, 44100, 48000}
|
||||
|
||||
func (c *Client) AddAudioTrack(mediaCode byte, sampleRate byte) {
|
||||
// https://github.com/vigoss30611/buildroot-ltc/blob/master/system/qm/ipc/ProtocolService/src/ZhiNuo/inc/zn_dh_base_type.h
|
||||
// PCM8 = 7, G729, IMA_ADPCM, G711U, G721, PCM8_VWIS, MS_ADPCM, G711A, PCM16
|
||||
var codec *streamer.Codec
|
||||
switch mediaCode {
|
||||
case 10: // G711U
|
||||
codec = &streamer.Codec{
|
||||
Name: streamer.CodecPCMU,
|
||||
}
|
||||
case 14: // G711A
|
||||
codec = &streamer.Codec{
|
||||
Name: streamer.CodecPCMA,
|
||||
}
|
||||
default:
|
||||
println("[DVRIP] unsupported audio codec:", mediaCode)
|
||||
return
|
||||
}
|
||||
|
||||
if sampleRate <= byte(len(sampleRates)) {
|
||||
codec.ClockRate = sampleRates[sampleRate-1]
|
||||
}
|
||||
|
||||
media := &streamer.Media{
|
||||
Kind: streamer.KindAudio,
|
||||
Direction: streamer.DirectionSendonly,
|
||||
Codecs: []*streamer.Codec{codec},
|
||||
}
|
||||
c.medias = append(c.medias, media)
|
||||
|
||||
c.audioTrack = streamer.NewTrack(codec, media.Direction)
|
||||
}
|
||||
|
||||
func SofiaHash(password string) string {
|
||||
const chars = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
|
||||
|
||||
sofia := make([]byte, 0, 8)
|
||||
hash := md5.Sum([]byte(password))
|
||||
for i := 0; i < md5.Size; i += 2 {
|
||||
j := uint16(hash[i]) + uint16(hash[i+1])
|
||||
sofia = append(sofia, chars[j%62])
|
||||
}
|
||||
|
||||
return string(sofia)
|
||||
}
|
||||
25
pkg/dvrip/producer.go
Normal file
25
pkg/dvrip/producer.go
Normal file
@@ -0,0 +1,25 @@
|
||||
package dvrip
|
||||
|
||||
import "github.com/AlexxIT/go2rtc/pkg/streamer"
|
||||
|
||||
func (c *Client) GetMedias() []*streamer.Media {
|
||||
return c.medias
|
||||
}
|
||||
|
||||
func (c *Client) GetTrack(media *streamer.Media, codec *streamer.Codec) *streamer.Track {
|
||||
if c.videoTrack != nil && c.videoTrack.Codec == codec {
|
||||
return c.videoTrack
|
||||
}
|
||||
if c.audioTrack != nil && c.audioTrack.Codec == codec {
|
||||
return c.audioTrack
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Client) Start() error {
|
||||
return c.Handle()
|
||||
}
|
||||
|
||||
func (c *Client) Stop() error {
|
||||
return c.Close()
|
||||
}
|
||||
@@ -1,38 +1,6 @@
|
||||
# H264
|
||||
|
||||
Access Unit (AU) can contain one or multiple NAL Unit:
|
||||
|
||||
1. [SEI,] SPS, PPS, IFrame, [IFrame...]
|
||||
2. BFrame, [BFrame...]
|
||||
3. IFrame, [IFrame...]
|
||||
|
||||
## RTP H264
|
||||
|
||||
Camera | NALu
|
||||
-------|-----
|
||||
EZVIZ C3S | 7f, 8f, 28:28:28 -> 5t, 28:28:28 -> 1t, 1t, 1t, 1t
|
||||
Sonoff GK-200MP2-B | 28:28:28 -> 5t, 1t, 1t, 1t
|
||||
Dahua IPC-K42 | 7f, 8f, 28:28:28 -> 5t, 28:28:28 -> 1t, 28:28:28 -> 1t
|
||||
FFmpeg copy | 5t, 1t, 1t, 28:28:28 -> 1t, 28:28:28 -> 1t
|
||||
FFmpeg h264 | 24 -> 6:5:5:5:5t, 24 -> 1:1:1:1t, 28:28:28 -> 5f, 28:28:28 -> 5f, 28:28:28 -> 5t
|
||||
FFmpeg resize | 6f, 28:28:28 -> 5f, 28... -> 5t, 24 -> 1:1f, 24 -> 1:1t
|
||||
|
||||
## WebRTC
|
||||
|
||||
Video codec | Media string | Device
|
||||
----------------|--------------|-------
|
||||
H.264/baseline! | avc1.42E0xx | Chromecast
|
||||
H.264/baseline! | avc1.42E0xx | Chrome/Safari WebRTC
|
||||
H.264/baseline! | avc1.42C0xx | FFmpeg ultrafast
|
||||
H.264/baseline! | avc1.4240xx | Dahua H264B
|
||||
H.264/baseline | avc1.4200xx | Chrome WebRTC
|
||||
H.264/main! | avc1.4D40xx | Chromecast
|
||||
H.264/main! | avc1.4D40xx | FFmpeg superfast main
|
||||
H.264/main! | avc1.4D40xx | Dahua H264
|
||||
H.264/main | avc1.4D00xx | Chrome WebRTC
|
||||
H.264/high! | avc1.640Cxx | Safari WebRTC
|
||||
H.264/high | avc1.6400xx | Chromecast
|
||||
H.264/high | avc1.6400xx | FFmpeg superfast
|
||||
Payloader code taken from [pion](https://github.com/pion/rtp) library. And changed to AVC packets support.
|
||||
|
||||
## Useful Links
|
||||
|
||||
|
||||
@@ -1,11 +1,31 @@
|
||||
package h264
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/binary"
|
||||
"github.com/AlexxIT/go2rtc/pkg/streamer"
|
||||
"github.com/pion/rtp"
|
||||
)
|
||||
|
||||
func AnnexB2AVC(b []byte) []byte {
|
||||
for i := 0; i < len(b); {
|
||||
if i+4 >= len(b) {
|
||||
break
|
||||
}
|
||||
|
||||
size := bytes.Index(b[i+4:], []byte{0, 0, 0, 1})
|
||||
if size < 0 {
|
||||
size = len(b) - (i + 4)
|
||||
}
|
||||
|
||||
binary.BigEndian.PutUint32(b[i:], uint32(size))
|
||||
|
||||
i += size + 4
|
||||
}
|
||||
|
||||
return b
|
||||
}
|
||||
|
||||
func EncodeAVC(nals ...[]byte) (avc []byte) {
|
||||
var i, n int
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ package h264
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/binary"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"github.com/AlexxIT/go2rtc/pkg/streamer"
|
||||
"strings"
|
||||
@@ -48,21 +49,39 @@ func Join(ps, iframe []byte) []byte {
|
||||
return b
|
||||
}
|
||||
|
||||
// GetProfileLevelID - get profile from fmtp line
|
||||
// Some devices won't play video with high level, so limit max profile and max level.
|
||||
// And return some profile even if fmtp line is empty.
|
||||
func GetProfileLevelID(fmtp string) string {
|
||||
if fmtp == "" {
|
||||
return ""
|
||||
}
|
||||
// avc1.640029 - H.264 high 4.1 (Chromecast 1st and 2nd Gen)
|
||||
profile := byte(0x64)
|
||||
capab := byte(0)
|
||||
level := byte(0x29)
|
||||
|
||||
// some cameras has wrong profile-level-id
|
||||
// https://github.com/AlexxIT/go2rtc/issues/155
|
||||
if s := streamer.Between(fmtp, "sprop-parameter-sets=", ","); s != "" {
|
||||
sps, _ := base64.StdEncoding.DecodeString(s)
|
||||
if len(sps) >= 4 {
|
||||
return fmt.Sprintf("%06X", sps[1:4])
|
||||
if fmtp != "" {
|
||||
var conf []byte
|
||||
// some cameras has wrong profile-level-id
|
||||
// https://github.com/AlexxIT/go2rtc/issues/155
|
||||
if s := streamer.Between(fmtp, "sprop-parameter-sets=", ","); s != "" {
|
||||
if sps, _ := base64.StdEncoding.DecodeString(s); len(sps) >= 4 {
|
||||
conf = sps[1:4]
|
||||
}
|
||||
} else if s = streamer.Between(fmtp, "profile-level-id=", ";"); s != "" {
|
||||
conf, _ = hex.DecodeString(s)
|
||||
}
|
||||
|
||||
if conf != nil {
|
||||
if conf[0] < profile {
|
||||
profile = conf[0]
|
||||
capab = conf[1]
|
||||
}
|
||||
if conf[2] < level {
|
||||
level = conf[2]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return streamer.Between(fmtp, "profile-level-id=", ";")
|
||||
return fmt.Sprintf("%02X%02X%02X", profile, capab, level)
|
||||
}
|
||||
|
||||
func GetParameterSet(fmtp string) (sps, pps []byte) {
|
||||
@@ -85,3 +104,26 @@ func GetParameterSet(fmtp string) (sps, pps []byte) {
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// GetFmtpLine from SPS+PPS+IFrame in AVC format
|
||||
func GetFmtpLine(avc []byte) string {
|
||||
s := "packetization-mode=1"
|
||||
|
||||
for {
|
||||
size := 4 + int(binary.BigEndian.Uint32(avc))
|
||||
|
||||
switch NALUType(avc) {
|
||||
case NALUTypeSPS:
|
||||
s += ";profile-level-id=" + hex.EncodeToString(avc[5:8])
|
||||
s += ";sprop-parameter-sets=" + base64.StdEncoding.EncodeToString(avc[4:size])
|
||||
case NALUTypePPS:
|
||||
s += "," + base64.StdEncoding.EncodeToString(avc[4:size])
|
||||
}
|
||||
|
||||
if size < len(avc) {
|
||||
avc = avc[size:]
|
||||
} else {
|
||||
return s
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,7 +31,7 @@ const (
|
||||
|
||||
//func annexbNALUStartCode() []byte { return []byte{0x00, 0x00, 0x00, 0x01} }
|
||||
|
||||
func emitNalus(nals []byte, isAVC bool, emit func([]byte)) {
|
||||
func EmitNalus(nals []byte, isAVC bool, emit func([]byte)) {
|
||||
if !isAVC {
|
||||
nextInd := func(nalu []byte, start int) (indStart int, indLen int) {
|
||||
zeroCount := 0
|
||||
@@ -84,7 +84,7 @@ func (p *Payloader) Payload(mtu uint16, payload []byte) [][]byte {
|
||||
return payloads
|
||||
}
|
||||
|
||||
emitNalus(payload, p.IsAVC, func(nalu []byte) {
|
||||
EmitNalus(payload, p.IsAVC, func(nalu []byte) {
|
||||
if len(nalu) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -9,6 +9,8 @@ import (
|
||||
|
||||
const RTPPacketVersionAVC = 0
|
||||
|
||||
const PSMaxSize = 128 // the biggest SPS I've seen is 48 (EZVIZ CS-CV210)
|
||||
|
||||
func RTPDepay(track *streamer.Track) streamer.WrapperFunc {
|
||||
depack := &codecs.H264Packet{IsAVC: true}
|
||||
|
||||
@@ -27,11 +29,16 @@ func RTPDepay(track *streamer.Track) streamer.WrapperFunc {
|
||||
}
|
||||
|
||||
// Fix TP-Link Tapo TC70: sends SPS and PPS with packet.Marker = true
|
||||
if packet.Marker {
|
||||
// Reolink Duo 2: sends SPS with Marker and PPS without
|
||||
if packet.Marker && len(payload) < PSMaxSize {
|
||||
switch NALUType(payload) {
|
||||
case NALUTypeSPS, NALUTypePPS:
|
||||
buf = append(buf, payload...)
|
||||
return nil
|
||||
case NALUTypeSEI:
|
||||
// RtspServer https://github.com/AlexxIT/go2rtc/issues/244
|
||||
// sends, marked SPS, marked PPS, marked SEI, marked IFrame
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,7 +77,15 @@ func RTPDepay(track *streamer.Track) streamer.WrapperFunc {
|
||||
buf = buf[:0]
|
||||
}
|
||||
|
||||
//log.Printf("[AVC] %v, len: %d", Types(payload), len(payload))
|
||||
// should not be that huge SPS
|
||||
if NALUType(payload) == NALUTypeSPS && binary.BigEndian.Uint32(payload) >= PSMaxSize {
|
||||
// some Chinese buggy cameras has single packet with SPS+PPS+IFrame separated by 00 00 00 01
|
||||
// https://github.com/AlexxIT/WebRTC/issues/391
|
||||
// https://github.com/AlexxIT/WebRTC/issues/392
|
||||
AnnexB2AVC(payload)
|
||||
}
|
||||
|
||||
//log.Printf("[AVC] %v, len: %d, ts: %10d, seq: %d", Types(payload), len(payload), packet.Timestamp, packet.SequenceNumber)
|
||||
|
||||
clone := *packet
|
||||
clone.Version = RTPPacketVersionAVC
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
# H265
|
||||
|
||||
Payloader code taken from [pion](https://github.com/pion/rtp) library branch [h265](https://github.com/pion/rtp/tree/h265). Because it's still not in release. Thanks to [@kevmo314](https://github.com/kevmo314).
|
||||
|
||||
## Useful links
|
||||
|
||||
- https://datatracker.ietf.org/doc/html/rfc7798
|
||||
|
||||
@@ -7,14 +7,16 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
NALUTypePFrame = 1
|
||||
NALUTypeIFrame = 19
|
||||
NALUTypeIFrame2 = 20
|
||||
NALUTypeIFrame3 = 21
|
||||
NALUTypeVPS = 32
|
||||
NALUTypeSPS = 33
|
||||
NALUTypePPS = 34
|
||||
NALUTypeFU = 49
|
||||
NALUTypePFrame = 1
|
||||
NALUTypeIFrame = 19
|
||||
NALUTypeIFrame2 = 20
|
||||
NALUTypeIFrame3 = 21
|
||||
NALUTypeVPS = 32
|
||||
NALUTypeSPS = 33
|
||||
NALUTypePPS = 34
|
||||
NALUTypePrefixSEI = 39
|
||||
NALUTypeSuffixSEI = 40
|
||||
NALUTypeFU = 49
|
||||
)
|
||||
|
||||
func NALUType(b []byte) byte {
|
||||
|
||||
300
pkg/h265/payloader.go
Normal file
300
pkg/h265/payloader.go
Normal file
@@ -0,0 +1,300 @@
|
||||
package h265
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"github.com/AlexxIT/go2rtc/pkg/h264"
|
||||
"math"
|
||||
)
|
||||
|
||||
//
|
||||
// Network Abstraction Unit Header implementation
|
||||
//
|
||||
|
||||
const (
|
||||
// sizeof(uint16)
|
||||
h265NaluHeaderSize = 2
|
||||
// https://datatracker.ietf.org/doc/html/rfc7798#section-4.4.2
|
||||
h265NaluAggregationPacketType = 48
|
||||
// https://datatracker.ietf.org/doc/html/rfc7798#section-4.4.3
|
||||
h265NaluFragmentationUnitType = 49
|
||||
// https://datatracker.ietf.org/doc/html/rfc7798#section-4.4.4
|
||||
h265NaluPACIPacketType = 50
|
||||
)
|
||||
|
||||
// H265NALUHeader is a H265 NAL Unit Header
|
||||
// https://datatracker.ietf.org/doc/html/rfc7798#section-1.1.4
|
||||
// +---------------+---------------+
|
||||
//
|
||||
// |0|1|2|3|4|5|6|7|0|1|2|3|4|5|6|7|
|
||||
// +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|
||||
// |F| Type | LayerID | TID |
|
||||
// +-------------+-----------------+
|
||||
type H265NALUHeader uint16
|
||||
|
||||
func newH265NALUHeader(highByte, lowByte uint8) H265NALUHeader {
|
||||
return H265NALUHeader((uint16(highByte) << 8) | uint16(lowByte))
|
||||
}
|
||||
|
||||
// F is the forbidden bit, should always be 0.
|
||||
func (h H265NALUHeader) F() bool {
|
||||
return (uint16(h) >> 15) != 0
|
||||
}
|
||||
|
||||
// Type of NAL Unit.
|
||||
func (h H265NALUHeader) Type() uint8 {
|
||||
// 01111110 00000000
|
||||
const mask = 0b01111110 << 8
|
||||
return uint8((uint16(h) & mask) >> (8 + 1))
|
||||
}
|
||||
|
||||
// IsTypeVCLUnit returns whether or not the NAL Unit type is a VCL NAL unit.
|
||||
func (h H265NALUHeader) IsTypeVCLUnit() bool {
|
||||
// Type is coded on 6 bits
|
||||
const msbMask = 0b00100000
|
||||
return (h.Type() & msbMask) == 0
|
||||
}
|
||||
|
||||
// LayerID should always be 0 in non-3D HEVC context.
|
||||
func (h H265NALUHeader) LayerID() uint8 {
|
||||
// 00000001 11111000
|
||||
const mask = (0b00000001 << 8) | 0b11111000
|
||||
return uint8((uint16(h) & mask) >> 3)
|
||||
}
|
||||
|
||||
// TID is the temporal identifier of the NAL unit +1.
|
||||
func (h H265NALUHeader) TID() uint8 {
|
||||
const mask = 0b00000111
|
||||
return uint8(uint16(h) & mask)
|
||||
}
|
||||
|
||||
// IsAggregationPacket returns whether or not the packet is an Aggregation packet.
|
||||
func (h H265NALUHeader) IsAggregationPacket() bool {
|
||||
return h.Type() == h265NaluAggregationPacketType
|
||||
}
|
||||
|
||||
// IsFragmentationUnit returns whether or not the packet is a Fragmentation Unit packet.
|
||||
func (h H265NALUHeader) IsFragmentationUnit() bool {
|
||||
return h.Type() == h265NaluFragmentationUnitType
|
||||
}
|
||||
|
||||
// IsPACIPacket returns whether or not the packet is a PACI packet.
|
||||
func (h H265NALUHeader) IsPACIPacket() bool {
|
||||
return h.Type() == h265NaluPACIPacketType
|
||||
}
|
||||
|
||||
//
|
||||
// Fragmentation Unit implementation
|
||||
//
|
||||
|
||||
const (
|
||||
// sizeof(uint8)
|
||||
h265FragmentationUnitHeaderSize = 1
|
||||
)
|
||||
|
||||
// H265FragmentationUnitHeader is a H265 FU Header
|
||||
// +---------------+
|
||||
// |0|1|2|3|4|5|6|7|
|
||||
// +-+-+-+-+-+-+-+-+
|
||||
// |S|E| FuType |
|
||||
// +---------------+
|
||||
type H265FragmentationUnitHeader uint8
|
||||
|
||||
// S represents the start of a fragmented NAL unit.
|
||||
func (h H265FragmentationUnitHeader) S() bool {
|
||||
const mask = 0b10000000
|
||||
return ((h & mask) >> 7) != 0
|
||||
}
|
||||
|
||||
// E represents the end of a fragmented NAL unit.
|
||||
func (h H265FragmentationUnitHeader) E() bool {
|
||||
const mask = 0b01000000
|
||||
return ((h & mask) >> 6) != 0
|
||||
}
|
||||
|
||||
// FuType MUST be equal to the field Type of the fragmented NAL unit.
|
||||
func (h H265FragmentationUnitHeader) FuType() uint8 {
|
||||
const mask = 0b00111111
|
||||
return uint8(h) & mask
|
||||
}
|
||||
|
||||
// Payloader payloads H265 packets
|
||||
type Payloader struct {
|
||||
AddDONL bool
|
||||
SkipAggregation bool
|
||||
donl uint16
|
||||
}
|
||||
|
||||
// Payload fragments a H265 packet across one or more byte arrays
|
||||
func (p *Payloader) Payload(mtu uint16, payload []byte) [][]byte {
|
||||
var payloads [][]byte
|
||||
if len(payload) == 0 {
|
||||
return payloads
|
||||
}
|
||||
|
||||
bufferedNALUs := make([][]byte, 0)
|
||||
aggregationBufferSize := 0
|
||||
|
||||
flushBufferedNals := func() {
|
||||
if len(bufferedNALUs) == 0 {
|
||||
return
|
||||
}
|
||||
if len(bufferedNALUs) == 1 {
|
||||
// emit this as a single NALU packet
|
||||
nalu := bufferedNALUs[0]
|
||||
|
||||
if p.AddDONL {
|
||||
buf := make([]byte, len(nalu)+2)
|
||||
|
||||
// copy the NALU header to the payload header
|
||||
copy(buf[0:h265NaluHeaderSize], nalu[0:h265NaluHeaderSize])
|
||||
|
||||
// copy the DONL into the header
|
||||
binary.BigEndian.PutUint16(buf[h265NaluHeaderSize:h265NaluHeaderSize+2], p.donl)
|
||||
|
||||
// write the payload
|
||||
copy(buf[h265NaluHeaderSize+2:], nalu[h265NaluHeaderSize:])
|
||||
|
||||
p.donl++
|
||||
|
||||
payloads = append(payloads, buf)
|
||||
} else {
|
||||
// write the nalu directly to the payload
|
||||
payloads = append(payloads, nalu)
|
||||
}
|
||||
} else {
|
||||
// construct an aggregation packet
|
||||
aggregationPacketSize := aggregationBufferSize + 2
|
||||
buf := make([]byte, aggregationPacketSize)
|
||||
|
||||
layerID := uint8(math.MaxUint8)
|
||||
tid := uint8(math.MaxUint8)
|
||||
for _, nalu := range bufferedNALUs {
|
||||
header := newH265NALUHeader(nalu[0], nalu[1])
|
||||
headerLayerID := header.LayerID()
|
||||
headerTID := header.TID()
|
||||
if headerLayerID < layerID {
|
||||
layerID = headerLayerID
|
||||
}
|
||||
if headerTID < tid {
|
||||
tid = headerTID
|
||||
}
|
||||
}
|
||||
|
||||
binary.BigEndian.PutUint16(buf[0:2], (uint16(h265NaluAggregationPacketType)<<9)|(uint16(layerID)<<3)|uint16(tid))
|
||||
|
||||
index := 2
|
||||
for i, nalu := range bufferedNALUs {
|
||||
if p.AddDONL {
|
||||
if i == 0 {
|
||||
binary.BigEndian.PutUint16(buf[index:index+2], p.donl)
|
||||
index += 2
|
||||
} else {
|
||||
buf[index] = byte(i - 1)
|
||||
index++
|
||||
}
|
||||
}
|
||||
binary.BigEndian.PutUint16(buf[index:index+2], uint16(len(nalu)))
|
||||
index += 2
|
||||
index += copy(buf[index:], nalu)
|
||||
}
|
||||
payloads = append(payloads, buf)
|
||||
}
|
||||
// clear the buffered NALUs
|
||||
bufferedNALUs = make([][]byte, 0)
|
||||
aggregationBufferSize = 0
|
||||
}
|
||||
|
||||
h264.EmitNalus(payload, true, func(nalu []byte) {
|
||||
if len(nalu) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
if len(nalu) <= int(mtu) {
|
||||
// this nalu fits into a single packet, either it can be emitted as
|
||||
// a single nalu or appended to the previous aggregation packet
|
||||
|
||||
marginalAggregationSize := len(nalu) + 2
|
||||
if p.AddDONL {
|
||||
marginalAggregationSize += 1
|
||||
}
|
||||
|
||||
if aggregationBufferSize+marginalAggregationSize > int(mtu) {
|
||||
flushBufferedNals()
|
||||
}
|
||||
bufferedNALUs = append(bufferedNALUs, nalu)
|
||||
aggregationBufferSize += marginalAggregationSize
|
||||
if p.SkipAggregation {
|
||||
// emit this immediately.
|
||||
flushBufferedNals()
|
||||
}
|
||||
} else {
|
||||
// if this nalu doesn't fit in the current mtu, it needs to be fragmented
|
||||
fuPacketHeaderSize := h265FragmentationUnitHeaderSize + 2 /* payload header size */
|
||||
if p.AddDONL {
|
||||
fuPacketHeaderSize += 2
|
||||
}
|
||||
|
||||
// then, fragment the nalu
|
||||
maxFUPayloadSize := int(mtu) - fuPacketHeaderSize
|
||||
|
||||
naluHeader := newH265NALUHeader(nalu[0], nalu[1])
|
||||
|
||||
// the nalu header is omitted from the fragmentation packet payload
|
||||
nalu = nalu[h265NaluHeaderSize:]
|
||||
|
||||
if maxFUPayloadSize == 0 || len(nalu) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
// flush any buffered aggregation packets.
|
||||
flushBufferedNals()
|
||||
|
||||
fullNALUSize := len(nalu)
|
||||
for len(nalu) > 0 {
|
||||
curentFUPayloadSize := len(nalu)
|
||||
if curentFUPayloadSize > maxFUPayloadSize {
|
||||
curentFUPayloadSize = maxFUPayloadSize
|
||||
}
|
||||
|
||||
out := make([]byte, fuPacketHeaderSize+curentFUPayloadSize)
|
||||
|
||||
// write the payload header
|
||||
binary.BigEndian.PutUint16(out[0:2], uint16(naluHeader))
|
||||
out[0] = (out[0] & 0b10000001) | h265NaluFragmentationUnitType<<1
|
||||
|
||||
// write the fragment header
|
||||
out[2] = byte(H265FragmentationUnitHeader(naluHeader.Type()))
|
||||
if len(nalu) == fullNALUSize {
|
||||
// Set start bit
|
||||
out[2] |= 1 << 7
|
||||
} else if len(nalu)-curentFUPayloadSize == 0 {
|
||||
// Set end bit
|
||||
out[2] |= 1 << 6
|
||||
}
|
||||
|
||||
if p.AddDONL {
|
||||
// write the DONL header
|
||||
binary.BigEndian.PutUint16(out[3:5], p.donl)
|
||||
|
||||
p.donl++
|
||||
|
||||
// copy the fragment payload
|
||||
copy(out[5:], nalu[0:curentFUPayloadSize])
|
||||
} else {
|
||||
// copy the fragment payload
|
||||
copy(out[3:], nalu[0:curentFUPayloadSize])
|
||||
}
|
||||
|
||||
// append the fragment to the payload
|
||||
payloads = append(payloads, out)
|
||||
|
||||
// advance the nalu data pointer
|
||||
nalu = nalu[curentFUPayloadSize:]
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
flushBufferedNals()
|
||||
|
||||
return payloads
|
||||
}
|
||||
@@ -20,6 +20,16 @@ func RTPDepay(track *streamer.Track) streamer.WrapperFunc {
|
||||
nuType := (data[0] >> 1) & 0x3F
|
||||
//log.Printf("[RTP] codec: %s, nalu: %2d, size: %6d, ts: %10d, pt: %2d, ssrc: %d, seq: %d, %v", track.Codec.Name, nuType, len(packet.Payload), packet.Timestamp, packet.PayloadType, packet.SSRC, packet.SequenceNumber, packet.Marker)
|
||||
|
||||
// Fix for RtspServer https://github.com/AlexxIT/go2rtc/issues/244
|
||||
if packet.Marker && len(data) < h264.PSMaxSize {
|
||||
switch nuType {
|
||||
case NALUTypeVPS, NALUTypeSPS, NALUTypePPS:
|
||||
packet.Marker = false
|
||||
case NALUTypePrefixSEI, NALUTypeSuffixSEI:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
if nuType == NALUTypeFU {
|
||||
switch data[2] >> 6 {
|
||||
case 2: // begin
|
||||
@@ -67,6 +77,39 @@ func RTPDepay(track *streamer.Track) streamer.WrapperFunc {
|
||||
}
|
||||
}
|
||||
|
||||
func RTPPay(mtu uint16) streamer.WrapperFunc {
|
||||
payloader := &Payloader{}
|
||||
sequencer := rtp.NewRandomSequencer()
|
||||
mtu -= 12 // rtp.Header size
|
||||
|
||||
return func(push streamer.WriterFunc) streamer.WriterFunc {
|
||||
return func(packet *rtp.Packet) error {
|
||||
if packet.Version != h264.RTPPacketVersionAVC {
|
||||
return push(packet)
|
||||
}
|
||||
|
||||
payloads := payloader.Payload(mtu, packet.Payload)
|
||||
last := len(payloads) - 1
|
||||
for i, payload := range payloads {
|
||||
clone := rtp.Packet{
|
||||
Header: rtp.Header{
|
||||
Version: 2,
|
||||
Marker: i == last,
|
||||
SequenceNumber: sequencer.NextSequenceNumber(),
|
||||
Timestamp: packet.Timestamp,
|
||||
},
|
||||
Payload: payload,
|
||||
}
|
||||
if err := push(&clone); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// SafariPay - generate Safari friendly payload for H265
|
||||
// https://github.com/AlexxIT/Blog/issues/5
|
||||
func SafariPay(mtu uint16) streamer.WrapperFunc {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package homekit
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/AlexxIT/go2rtc/pkg/hap"
|
||||
@@ -11,6 +12,7 @@ import (
|
||||
"github.com/brutella/hap/rtp"
|
||||
"net"
|
||||
"net/url"
|
||||
"sync/atomic"
|
||||
)
|
||||
|
||||
type Client struct {
|
||||
@@ -263,3 +265,19 @@ func (c *Client) getMedias() []*streamer.Media {
|
||||
|
||||
return medias
|
||||
}
|
||||
|
||||
func (c *Client) MarshalJSON() ([]byte, error) {
|
||||
var recv uint32
|
||||
for _, session := range c.sessions {
|
||||
recv += atomic.LoadUint32(&session.Recv)
|
||||
}
|
||||
|
||||
info := &streamer.Info{
|
||||
Type: "HomeKit source",
|
||||
URL: c.conn.URL(),
|
||||
Medias: c.medias,
|
||||
Tracks: c.tracks,
|
||||
Recv: recv,
|
||||
}
|
||||
return json.Marshal(info)
|
||||
}
|
||||
|
||||
165
pkg/httpflv/amf0.go
Normal file
165
pkg/httpflv/amf0.go
Normal file
@@ -0,0 +1,165 @@
|
||||
package httpflv
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"math"
|
||||
)
|
||||
|
||||
const (
|
||||
TypeNumber byte = iota
|
||||
TypeBoolean
|
||||
TypeString
|
||||
TypeObject
|
||||
TypeEcmaArray = 8
|
||||
TypeObjectEnd = 9
|
||||
)
|
||||
|
||||
var Err = errors.New("amf0 read error")
|
||||
|
||||
// AMF0 spec: http://download.macromedia.com/pub/labs/amf/amf0_spec_121207.pdf
|
||||
type AMF0 struct {
|
||||
buf []byte
|
||||
pos int
|
||||
}
|
||||
|
||||
func NewReader(b []byte) *AMF0 {
|
||||
return &AMF0{buf: b}
|
||||
}
|
||||
|
||||
func (a *AMF0) ReadMetaData() map[string]interface{} {
|
||||
if b, _ := a.ReadByte(); b != TypeString {
|
||||
return nil
|
||||
}
|
||||
if s, _ := a.ReadString(); s != "onMetaData" {
|
||||
return nil
|
||||
}
|
||||
|
||||
b, _ := a.ReadByte()
|
||||
switch b {
|
||||
case TypeObject:
|
||||
v, _ := a.ReadObject()
|
||||
return v
|
||||
case TypeEcmaArray:
|
||||
v, _ := a.ReadEcmaArray()
|
||||
return v
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *AMF0) ReadMap() (map[interface{}]interface{}, error) {
|
||||
dict := make(map[interface{}]interface{})
|
||||
|
||||
for a.pos < len(a.buf) {
|
||||
k, err := a.ReadItem()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
v, err := a.ReadItem()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
dict[k] = v
|
||||
}
|
||||
|
||||
return dict, nil
|
||||
}
|
||||
|
||||
func (a *AMF0) ReadItem() (interface{}, error) {
|
||||
dataType, err := a.ReadByte()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
switch dataType {
|
||||
case TypeNumber:
|
||||
return a.ReadNumber()
|
||||
|
||||
case TypeBoolean:
|
||||
v, err := a.ReadByte()
|
||||
return v != 0, err
|
||||
|
||||
case TypeString:
|
||||
return a.ReadString()
|
||||
|
||||
case TypeObject:
|
||||
return a.ReadObject()
|
||||
|
||||
case TypeObjectEnd:
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
return nil, Err
|
||||
}
|
||||
|
||||
func (a *AMF0) ReadByte() (byte, error) {
|
||||
if a.pos >= len(a.buf) {
|
||||
return 0, Err
|
||||
}
|
||||
|
||||
v := a.buf[a.pos]
|
||||
a.pos++
|
||||
return v, nil
|
||||
}
|
||||
|
||||
func (a *AMF0) ReadNumber() (float64, error) {
|
||||
if a.pos+8 >= len(a.buf) {
|
||||
return 0, Err
|
||||
}
|
||||
|
||||
v := binary.BigEndian.Uint64(a.buf[a.pos : a.pos+8])
|
||||
a.pos += 8
|
||||
return math.Float64frombits(v), nil
|
||||
}
|
||||
|
||||
func (a *AMF0) ReadString() (string, error) {
|
||||
if a.pos+2 >= len(a.buf) {
|
||||
return "", Err
|
||||
}
|
||||
|
||||
size := int(binary.BigEndian.Uint16(a.buf[a.pos:]))
|
||||
a.pos += 2
|
||||
|
||||
if a.pos+size >= len(a.buf) {
|
||||
return "", Err
|
||||
}
|
||||
|
||||
s := string(a.buf[a.pos : a.pos+size])
|
||||
a.pos += size
|
||||
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func (a *AMF0) ReadObject() (map[string]interface{}, error) {
|
||||
obj := make(map[string]interface{})
|
||||
|
||||
for {
|
||||
k, err := a.ReadString()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
v, err := a.ReadItem()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if k == "" {
|
||||
break
|
||||
}
|
||||
|
||||
obj[k] = v
|
||||
}
|
||||
|
||||
return obj, nil
|
||||
}
|
||||
|
||||
func (a *AMF0) ReadEcmaArray() (map[string]interface{}, error) {
|
||||
if a.pos+4 >= len(a.buf) {
|
||||
return nil, Err
|
||||
}
|
||||
a.pos += 4 // skip size
|
||||
|
||||
return a.ReadObject()
|
||||
}
|
||||
97
pkg/httpflv/flvio.go
Normal file
97
pkg/httpflv/flvio.go
Normal file
@@ -0,0 +1,97 @@
|
||||
package httpflv
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/deepch/vdk/format/flv/flvio"
|
||||
"github.com/deepch/vdk/utils/bits/pio"
|
||||
"io"
|
||||
)
|
||||
|
||||
// TODO: rewrite all of this someday
|
||||
|
||||
func ReadTag(r io.Reader, b []byte) (tag flvio.Tag, ts int32, err error) {
|
||||
if _, err = io.ReadFull(r, b[:flvio.TagHeaderLength]); err != nil {
|
||||
return
|
||||
}
|
||||
var datalen int
|
||||
if tag, ts, datalen, err = flvio.ParseTagHeader(b); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
data := make([]byte, datalen)
|
||||
if _, err = io.ReadFull(r, data); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
n, err := ParseHeader(&tag, data)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
tag.Data = data[n:]
|
||||
|
||||
if _, err = io.ReadFull(r, b[:4]); err != nil {
|
||||
return
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func ParseHeader(self *flvio.Tag, b []byte) (n int, err error) {
|
||||
switch self.Type {
|
||||
case flvio.TAG_AUDIO:
|
||||
return audioParseHeader(self, b)
|
||||
|
||||
case flvio.TAG_VIDEO:
|
||||
return videoParseHeader(self, b)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func audioParseHeader(tag *flvio.Tag, b []byte) (n int, err error) {
|
||||
if len(b) < n+1 {
|
||||
err = fmt.Errorf("audiodata: parse invalid")
|
||||
return
|
||||
}
|
||||
|
||||
flags := b[n]
|
||||
n++
|
||||
tag.SoundFormat = flags >> 4
|
||||
tag.SoundRate = (flags >> 2) & 0x3
|
||||
tag.SoundSize = (flags >> 1) & 0x1
|
||||
tag.SoundType = flags & 0x1
|
||||
|
||||
switch tag.SoundFormat {
|
||||
case flvio.SOUND_AAC:
|
||||
if len(b) < n+1 {
|
||||
err = fmt.Errorf("audiodata: parse invalid")
|
||||
return
|
||||
}
|
||||
tag.AACPacketType = b[n]
|
||||
n++
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func videoParseHeader(tag *flvio.Tag, b []byte) (n int, err error) {
|
||||
if len(b) < n+1 {
|
||||
err = fmt.Errorf("videodata: parse invalid")
|
||||
return
|
||||
}
|
||||
flags := b[n]
|
||||
tag.FrameType = flags >> 4
|
||||
tag.CodecID = flags & 0xf
|
||||
n++
|
||||
|
||||
if len(b) < n+4 {
|
||||
err = fmt.Errorf("videodata: parse invalid")
|
||||
return
|
||||
}
|
||||
tag.AVCPacketType = b[n]
|
||||
n++
|
||||
|
||||
tag.CompositionTime = pio.I24BE(b[n:])
|
||||
n += 3
|
||||
|
||||
return
|
||||
}
|
||||
@@ -2,8 +2,9 @@ package httpflv
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"errors"
|
||||
"bytes"
|
||||
"github.com/deepch/vdk/av"
|
||||
"github.com/deepch/vdk/codec/aacparser"
|
||||
"github.com/deepch/vdk/codec/h264parser"
|
||||
"github.com/deepch/vdk/format/flv/flvio"
|
||||
"github.com/deepch/vdk/utils/bits/pio"
|
||||
@@ -41,8 +42,12 @@ func Accept(res *http.Response) (*Conn, error) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if flags&flvio.FILE_HAS_VIDEO == 0 {
|
||||
return nil, errors.New("not supported")
|
||||
if flags&flvio.FILE_HAS_VIDEO != 0 {
|
||||
c.videoIdx = -1
|
||||
}
|
||||
|
||||
if flags&flvio.FILE_HAS_AUDIO != 0 {
|
||||
c.audioIdx = -1
|
||||
}
|
||||
|
||||
if _, err = c.reader.Discard(n); err != nil {
|
||||
@@ -56,49 +61,154 @@ type Conn struct {
|
||||
conn io.ReadCloser
|
||||
reader *bufio.Reader
|
||||
buf []byte
|
||||
|
||||
videoIdx int8
|
||||
audioIdx int8
|
||||
}
|
||||
|
||||
func (c *Conn) Streams() ([]av.CodecData, error) {
|
||||
for {
|
||||
var video, audio av.CodecData
|
||||
|
||||
// Normal software sends:
|
||||
// 1. Video/audio flag in header
|
||||
// 2. MetaData as first tag (with video/audio codec info)
|
||||
// 3. Video/audio headers in 2nd and 3rd tag
|
||||
|
||||
// Reolink camera sends:
|
||||
// 1. Empty video/audio flag
|
||||
// 2. MedaData without stereo key for AAC
|
||||
// 3. Audio header after Video keyframe tag
|
||||
|
||||
waitVideo := c.videoIdx != 0
|
||||
waitAudio := c.audioIdx != 0
|
||||
|
||||
for i := 0; i < 20; i++ {
|
||||
tag, _, err := flvio.ReadTag(c.reader, c.buf)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if tag.Type != flvio.TAG_VIDEO || tag.AVCPacketType != flvio.AAC_SEQHDR {
|
||||
continue
|
||||
//log.Printf("[FLV] type=%d avc=%d aac=%d video=%t audio=%t", tag.Type, tag.AVCPacketType, tag.AACPacketType, video != nil, audio != nil)
|
||||
|
||||
switch tag.Type {
|
||||
case flvio.TAG_SCRIPTDATA:
|
||||
if meta := NewReader(tag.Data).ReadMetaData(); meta != nil {
|
||||
waitVideo = meta["videocodecid"] != nil
|
||||
|
||||
// don't wait audio tag because parse all info from MetaData
|
||||
waitAudio = false
|
||||
|
||||
audio = parseAudioConfig(meta)
|
||||
} else {
|
||||
waitVideo = bytes.Contains(tag.Data, []byte("videocodecid"))
|
||||
waitAudio = bytes.Contains(tag.Data, []byte("audiocodecid"))
|
||||
}
|
||||
|
||||
case flvio.TAG_VIDEO:
|
||||
if tag.AVCPacketType == flvio.AVC_SEQHDR {
|
||||
video, _ = h264parser.NewCodecDataFromAVCDecoderConfRecord(tag.Data)
|
||||
}
|
||||
waitVideo = false
|
||||
|
||||
case flvio.TAG_AUDIO:
|
||||
if tag.SoundFormat == flvio.SOUND_AAC && tag.AACPacketType == flvio.AAC_SEQHDR {
|
||||
audio, _ = aacparser.NewCodecDataFromMPEG4AudioConfigBytes(tag.Data)
|
||||
}
|
||||
waitAudio = false
|
||||
}
|
||||
|
||||
stream, err := h264parser.NewCodecDataFromAVCDecoderConfRecord(tag.Data)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
if !waitVideo && !waitAudio {
|
||||
break
|
||||
}
|
||||
|
||||
return []av.CodecData{stream}, nil
|
||||
}
|
||||
|
||||
if video != nil && audio != nil {
|
||||
c.videoIdx = 0
|
||||
c.audioIdx = 1
|
||||
return []av.CodecData{video, audio}, nil
|
||||
} else if video != nil {
|
||||
c.videoIdx = 0
|
||||
c.audioIdx = -1
|
||||
return []av.CodecData{video}, nil
|
||||
} else if audio != nil {
|
||||
c.videoIdx = -1
|
||||
c.audioIdx = 0
|
||||
return []av.CodecData{audio}, nil
|
||||
}
|
||||
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (c *Conn) ReadPacket() (av.Packet, error) {
|
||||
for {
|
||||
tag, ts, err := flvio.ReadTag(c.reader, c.buf)
|
||||
tag, ts, err := ReadTag(c.reader, c.buf)
|
||||
if err != nil {
|
||||
return av.Packet{}, err
|
||||
}
|
||||
|
||||
if tag.Type != flvio.TAG_VIDEO || tag.AVCPacketType != flvio.AVC_NALU {
|
||||
continue
|
||||
}
|
||||
switch tag.Type {
|
||||
case flvio.TAG_VIDEO:
|
||||
if c.videoIdx < 0 || tag.AVCPacketType != flvio.AVC_NALU {
|
||||
continue
|
||||
}
|
||||
|
||||
return av.Packet{
|
||||
Idx: 0,
|
||||
Data: tag.Data,
|
||||
CompositionTime: flvio.TsToTime(tag.CompositionTime),
|
||||
IsKeyFrame: tag.FrameType == flvio.FRAME_KEY,
|
||||
Time: flvio.TsToTime(ts),
|
||||
}, nil
|
||||
//log.Printf("[FLV] %v, len: %d, ts: %10d", h264.Types(tag.Data), len(tag.Data), flvio.TsToTime(ts))
|
||||
|
||||
return av.Packet{
|
||||
Idx: c.videoIdx,
|
||||
Data: tag.Data,
|
||||
CompositionTime: flvio.TsToTime(tag.CompositionTime),
|
||||
IsKeyFrame: tag.FrameType == flvio.FRAME_KEY,
|
||||
Time: flvio.TsToTime(ts),
|
||||
}, nil
|
||||
|
||||
case flvio.TAG_AUDIO:
|
||||
if c.audioIdx < 0 || tag.SoundFormat != flvio.SOUND_AAC || tag.AACPacketType != flvio.AAC_RAW {
|
||||
continue
|
||||
}
|
||||
|
||||
return av.Packet{Idx: c.audioIdx, Data: tag.Data, Time: flvio.TsToTime(ts)}, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Conn) Close() (err error) {
|
||||
return c.conn.Close()
|
||||
}
|
||||
|
||||
func parseAudioConfig(meta map[string]interface{}) av.CodecData {
|
||||
if meta["audiocodecid"] != float64(10) {
|
||||
return nil
|
||||
}
|
||||
|
||||
config := aacparser.MPEG4AudioConfig{
|
||||
ObjectType: aacparser.AOT_AAC_LC,
|
||||
}
|
||||
|
||||
switch v := meta["audiosamplerate"].(type) {
|
||||
case float64:
|
||||
config.SampleRate = int(v)
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
|
||||
switch meta["stereo"] {
|
||||
case true:
|
||||
config.ChannelConfig = 2
|
||||
config.ChannelLayout = av.CH_STEREO
|
||||
default:
|
||||
// Reolink doesn't have this setting
|
||||
config.ChannelConfig = 1
|
||||
config.ChannelLayout = av.CH_MONO
|
||||
}
|
||||
|
||||
buf := &bytes.Buffer{}
|
||||
if err := aacparser.WriteMPEG4AudioConfig(buf, config); err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
return aacparser.CodecData{
|
||||
Config: config,
|
||||
ConfigBytes: buf.Bytes(),
|
||||
}
|
||||
}
|
||||
|
||||
318
pkg/iso/atoms.go
Normal file
318
pkg/iso/atoms.go
Normal file
@@ -0,0 +1,318 @@
|
||||
package iso
|
||||
|
||||
const (
|
||||
Ftyp = "ftyp"
|
||||
Moov = "moov"
|
||||
MoovMvhd = "mvhd"
|
||||
MoovTrak = "trak"
|
||||
MoovTrakTkhd = "tkhd"
|
||||
MoovTrakMdia = "mdia"
|
||||
MoovTrakMdiaMdhd = "mdhd"
|
||||
MoovTrakMdiaHdlr = "hdlr"
|
||||
MoovTrakMdiaMinf = "minf"
|
||||
MoovTrakMdiaMinfVmhd = "vmhd"
|
||||
MoovTrakMdiaMinfSmhd = "smhd"
|
||||
MoovTrakMdiaMinfDinf = "dinf"
|
||||
MoovTrakMdiaMinfDinfDref = "dref"
|
||||
MoovTrakMdiaMinfDinfDrefUrl = "url "
|
||||
MoovTrakMdiaMinfStbl = "stbl"
|
||||
MoovTrakMdiaMinfStblStsd = "stsd"
|
||||
MoovTrakMdiaMinfStblStts = "stts"
|
||||
MoovTrakMdiaMinfStblStsc = "stsc"
|
||||
MoovTrakMdiaMinfStblStsz = "stsz"
|
||||
MoovTrakMdiaMinfStblStco = "stco"
|
||||
MoovMvex = "mvex"
|
||||
MoovMvexTrex = "trex"
|
||||
Moof = "moof"
|
||||
MoofMfhd = "mfhd"
|
||||
MoofTraf = "traf"
|
||||
MoofTrafTfhd = "tfhd"
|
||||
MoofTrafTfdt = "tfdt"
|
||||
MoofTrafTrun = "trun"
|
||||
Mdat = "mdat"
|
||||
)
|
||||
|
||||
func (m *Movie) WriteFileType() {
|
||||
m.StartAtom(Ftyp)
|
||||
m.WriteString("iso5")
|
||||
m.WriteUint32(512)
|
||||
m.WriteString("iso5")
|
||||
m.WriteString("iso6")
|
||||
m.WriteString("mp41")
|
||||
m.EndAtom()
|
||||
}
|
||||
|
||||
func (m *Movie) WriteMovieHeader() {
|
||||
m.StartAtom(MoovMvhd)
|
||||
m.Skip(1) // version
|
||||
m.Skip(3) // flags
|
||||
m.Skip(4) // create time
|
||||
m.Skip(4) // modify time
|
||||
m.WriteUint32(1000) // time scale
|
||||
m.Skip(4) // duration
|
||||
m.WriteFloat32(1) // preferred rate
|
||||
m.WriteFloat16(1) // preferred volume
|
||||
m.Skip(10) // reserved
|
||||
m.WriteMatrix()
|
||||
m.Skip(6 * 4) // predefined?
|
||||
m.WriteUint32(0xFFFFFFFF) // next track ID
|
||||
m.EndAtom()
|
||||
}
|
||||
|
||||
func (m *Movie) WriteTrackHeader(id uint32, width, height uint16) {
|
||||
const (
|
||||
TkhdTrackEnabled = 0x0001
|
||||
TkhdTrackInMovie = 0x0002
|
||||
TkhdTrackInPreview = 0x0004
|
||||
TkhdTrackInPoster = 0x0008
|
||||
)
|
||||
|
||||
// https://developer.apple.com/library/archive/documentation/QuickTime/QTFF/QTFFChap2/qtff2.html#//apple_ref/doc/uid/TP40000939-CH204-32963
|
||||
m.StartAtom(MoovTrakTkhd)
|
||||
m.Skip(1) // version
|
||||
m.WriteUint24(TkhdTrackEnabled | TkhdTrackInMovie)
|
||||
m.Skip(4) // create time
|
||||
m.Skip(4) // modify time
|
||||
m.WriteUint32(id) // trackID
|
||||
m.Skip(4) // reserved
|
||||
m.Skip(4) // duration
|
||||
m.Skip(8) // reserved
|
||||
m.Skip(2) // layer
|
||||
if width > 0 {
|
||||
m.Skip(2)
|
||||
m.Skip(2)
|
||||
} else {
|
||||
m.WriteUint16(1) // alternate group
|
||||
m.WriteFloat16(1) // volume
|
||||
}
|
||||
m.Skip(2) // reserved
|
||||
m.WriteMatrix()
|
||||
if width > 0 {
|
||||
m.WriteFloat32(float64(width))
|
||||
m.WriteFloat32(float64(height))
|
||||
} else {
|
||||
m.Skip(4)
|
||||
m.Skip(4)
|
||||
}
|
||||
m.EndAtom()
|
||||
}
|
||||
|
||||
func (m *Movie) WriteMediaHeader(timescale uint32) {
|
||||
// https://developer.apple.com/library/archive/documentation/QuickTime/QTFF/QTFFChap2/qtff2.html#//apple_ref/doc/uid/TP40000939-CH204-32999
|
||||
m.StartAtom(MoovTrakMdiaMdhd)
|
||||
m.Skip(1) // version
|
||||
m.Skip(3) // flags
|
||||
m.Skip(4) // creation time
|
||||
m.Skip(4) // modification time
|
||||
m.WriteUint32(timescale) // timescale
|
||||
m.Skip(4) // duration
|
||||
m.WriteUint16(0x55C4) // language (Unspecified)
|
||||
m.Skip(2) // quality
|
||||
m.EndAtom()
|
||||
}
|
||||
|
||||
func (m *Movie) WriteMediaHandler(s, name string) {
|
||||
// https://developer.apple.com/library/archive/documentation/QuickTime/QTFF/QTFFChap2/qtff2.html#//apple_ref/doc/uid/TP40000939-CH204-33004
|
||||
m.StartAtom(MoovTrakMdiaHdlr)
|
||||
m.Skip(1) // version
|
||||
m.Skip(3) // flags
|
||||
m.Skip(4)
|
||||
m.WriteString(s) // handler type (4 byte!)
|
||||
m.Skip(3 * 4) // reserved
|
||||
m.WriteString(name) // handler name (any len)
|
||||
m.Skip(1) // end string
|
||||
m.EndAtom()
|
||||
}
|
||||
|
||||
func (m *Movie) WriteVideoMediaInfo() {
|
||||
// https://developer.apple.com/library/archive/documentation/QuickTime/QTFF/QTFFChap2/qtff2.html#//apple_ref/doc/uid/TP40000939-CH204-33012
|
||||
m.StartAtom(MoovTrakMdiaMinfVmhd)
|
||||
m.Skip(1) // version
|
||||
m.WriteUint24(1) // flags (You should always set this flag to 1)
|
||||
m.Skip(2) // graphics mode
|
||||
m.Skip(3 * 2) // op color
|
||||
m.EndAtom()
|
||||
}
|
||||
|
||||
func (m *Movie) WriteAudioMediaInfo() {
|
||||
m.StartAtom(MoovTrakMdiaMinfSmhd)
|
||||
m.Skip(1) // version
|
||||
m.Skip(3) // flags
|
||||
m.Skip(4) // balance
|
||||
m.EndAtom()
|
||||
}
|
||||
|
||||
func (m *Movie) WriteDataInfo() {
|
||||
// https://developer.apple.com/library/archive/documentation/QuickTime/QTFF/QTFFChap2/qtff2.html#//apple_ref/doc/uid/TP40000939-CH204-25680
|
||||
m.StartAtom(MoovTrakMdiaMinfDinf)
|
||||
m.StartAtom(MoovTrakMdiaMinfDinfDref)
|
||||
m.Skip(1) // version
|
||||
m.Skip(3) // flags
|
||||
m.WriteUint32(1) // childrens
|
||||
|
||||
m.StartAtom(MoovTrakMdiaMinfDinfDrefUrl)
|
||||
m.Skip(1) // version
|
||||
m.WriteUint24(1) // flags (self reference)
|
||||
m.EndAtom()
|
||||
|
||||
m.EndAtom() // DREF
|
||||
m.EndAtom() // DINF
|
||||
}
|
||||
|
||||
func (m *Movie) WriteSampleTable(writeSampleDesc func()) {
|
||||
// https://developer.apple.com/library/archive/documentation/QuickTime/QTFF/QTFFChap2/qtff2.html#//apple_ref/doc/uid/TP40000939-CH204-33040
|
||||
m.StartAtom(MoovTrakMdiaMinfStbl)
|
||||
|
||||
m.StartAtom(MoovTrakMdiaMinfStblStsd)
|
||||
m.Skip(1) // version
|
||||
m.Skip(3) // flags
|
||||
m.WriteUint32(1) // entry count
|
||||
writeSampleDesc()
|
||||
m.EndAtom()
|
||||
|
||||
m.StartAtom(MoovTrakMdiaMinfStblStts)
|
||||
m.Skip(1) // version
|
||||
m.Skip(3) // flags
|
||||
m.Skip(4) // entry count
|
||||
m.EndAtom()
|
||||
|
||||
m.StartAtom(MoovTrakMdiaMinfStblStsc)
|
||||
m.Skip(1) // version
|
||||
m.Skip(3) // flags
|
||||
m.Skip(4) // entry count
|
||||
m.EndAtom()
|
||||
|
||||
m.StartAtom(MoovTrakMdiaMinfStblStsz)
|
||||
m.Skip(1) // version
|
||||
m.Skip(3) // flags
|
||||
m.Skip(4) // sample size
|
||||
m.Skip(4) // entry count
|
||||
m.EndAtom()
|
||||
|
||||
m.StartAtom(MoovTrakMdiaMinfStblStco)
|
||||
m.Skip(1) // version
|
||||
m.Skip(3) // flags
|
||||
m.Skip(4) // entry count
|
||||
m.EndAtom()
|
||||
|
||||
m.EndAtom()
|
||||
}
|
||||
|
||||
func (m *Movie) WriteTrackExtend(id uint32) {
|
||||
m.StartAtom(MoovMvexTrex)
|
||||
m.Skip(1) // version
|
||||
m.Skip(3) // flags
|
||||
m.WriteUint32(id) // trackID
|
||||
m.WriteUint32(1) // default sample description index
|
||||
m.Skip(4) // default sample duration
|
||||
m.Skip(4) // default sample size
|
||||
m.Skip(4) // default sample flags
|
||||
m.EndAtom()
|
||||
}
|
||||
|
||||
func (m *Movie) WriteVideoTrack(id uint32, codec string, timescale uint32, width, height uint16, conf []byte) {
|
||||
m.StartAtom(MoovTrak)
|
||||
m.WriteTrackHeader(id, width, height)
|
||||
|
||||
m.StartAtom(MoovTrakMdia)
|
||||
m.WriteMediaHeader(timescale)
|
||||
m.WriteMediaHandler("vide", "VideoHandler")
|
||||
|
||||
m.StartAtom(MoovTrakMdiaMinf)
|
||||
m.WriteVideoMediaInfo()
|
||||
m.WriteDataInfo()
|
||||
m.WriteSampleTable(func() {
|
||||
m.WriteVideo(codec, width, height, conf)
|
||||
})
|
||||
m.EndAtom() // MINF
|
||||
|
||||
m.EndAtom() // MDIA
|
||||
m.EndAtom() // TRAK
|
||||
}
|
||||
|
||||
func (m *Movie) WriteAudioTrack(id uint32, codec string, timescale uint32, channels uint16, conf []byte) {
|
||||
m.StartAtom(MoovTrak)
|
||||
m.WriteTrackHeader(id, 0, 0)
|
||||
|
||||
m.StartAtom(MoovTrakMdia)
|
||||
m.WriteMediaHeader(timescale)
|
||||
m.WriteMediaHandler("soun", "SoundHandler")
|
||||
|
||||
m.StartAtom(MoovTrakMdiaMinf)
|
||||
m.WriteAudioMediaInfo()
|
||||
m.WriteDataInfo()
|
||||
m.WriteSampleTable(func() {
|
||||
m.WriteAudio(codec, channels, timescale, conf)
|
||||
})
|
||||
m.EndAtom() // MINF
|
||||
|
||||
m.EndAtom() // MDIA
|
||||
m.EndAtom() // TRAK
|
||||
}
|
||||
|
||||
func (m *Movie) WriteMovieFragment(seq, tid, duration, size uint32, time uint64) {
|
||||
m.StartAtom(Moof)
|
||||
|
||||
m.StartAtom(MoofMfhd)
|
||||
m.Skip(1) // version
|
||||
m.Skip(3) // flags
|
||||
m.WriteUint32(seq) // sequence number
|
||||
m.EndAtom()
|
||||
|
||||
m.StartAtom(MoofTraf)
|
||||
|
||||
const (
|
||||
TfhdDefaultSampleDuration = 0x000008
|
||||
TfhdDefaultSampleSize = 0x000010
|
||||
TfhdDefaultSampleFlags = 0x000020
|
||||
TfhdDefaultBaseIsMoof = 0x020000
|
||||
)
|
||||
|
||||
m.StartAtom(MoofTrafTfhd)
|
||||
m.Skip(1) // version
|
||||
m.WriteUint24(
|
||||
TfhdDefaultSampleDuration |
|
||||
TfhdDefaultSampleSize |
|
||||
TfhdDefaultSampleFlags |
|
||||
TfhdDefaultBaseIsMoof,
|
||||
)
|
||||
m.WriteUint32(tid) // track id
|
||||
m.WriteUint32(duration) // default sample duration
|
||||
m.WriteUint32(size) // default sample size
|
||||
m.WriteUint32(0x2000000) // default sample flags
|
||||
m.EndAtom()
|
||||
|
||||
m.StartAtom(MoofTrafTfdt)
|
||||
m.WriteBytes(1) // version
|
||||
m.Skip(3) // flags
|
||||
m.WriteUint64(time) // base media decode time
|
||||
m.EndAtom()
|
||||
|
||||
const (
|
||||
TrunDataOffset = 0x000001
|
||||
TrunFirstSampleFlags = 0x000004
|
||||
TrunSampleDuration = 0x0000100
|
||||
TrunSampleSize = 0x0000200
|
||||
TrunSampleFlags = 0x0000400
|
||||
TrunSampleCTS = 0x0000800
|
||||
)
|
||||
|
||||
m.StartAtom(MoofTrafTrun)
|
||||
m.Skip(1) // version
|
||||
m.WriteUint24(TrunDataOffset) // flags
|
||||
m.WriteUint32(1) // sample count
|
||||
// data offset: current pos + uint32 len + MDAT header len
|
||||
m.WriteUint32(uint32(len(m.b)) + 4 + 8)
|
||||
m.EndAtom() // TRUN
|
||||
|
||||
m.EndAtom() // TRAF
|
||||
|
||||
m.EndAtom() // MOOF
|
||||
}
|
||||
|
||||
func (m *Movie) WriteData(b []byte) {
|
||||
m.StartAtom(Mdat)
|
||||
m.Write(b)
|
||||
m.EndAtom()
|
||||
|
||||
}
|
||||
151
pkg/iso/codecs.go
Normal file
151
pkg/iso/codecs.go
Normal file
@@ -0,0 +1,151 @@
|
||||
package iso
|
||||
|
||||
import "github.com/AlexxIT/go2rtc/pkg/streamer"
|
||||
|
||||
func (m *Movie) WriteVideo(codec string, width, height uint16, conf []byte) {
|
||||
// https://developer.apple.com/library/archive/documentation/QuickTime/QTFF/QTFFChap3/qtff3.html
|
||||
switch codec {
|
||||
case streamer.CodecH264:
|
||||
m.StartAtom("avc1")
|
||||
case streamer.CodecH265:
|
||||
m.StartAtom("hev1")
|
||||
default:
|
||||
panic("unsupported iso video: " + codec)
|
||||
}
|
||||
m.Skip(6)
|
||||
m.WriteUint16(1) // data_reference_index
|
||||
m.Skip(2) // version
|
||||
m.Skip(2) // revision
|
||||
m.Skip(4) // vendor
|
||||
m.Skip(4) // temporal quality
|
||||
m.Skip(4) // spatial quality
|
||||
m.WriteUint16(width) // width
|
||||
m.WriteUint16(height) // height
|
||||
m.WriteFloat32(72) // horizontal resolution
|
||||
m.WriteFloat32(72) // vertical resolution
|
||||
m.Skip(4) // reserved
|
||||
m.WriteUint16(1) // frame count
|
||||
m.Skip(32) // compressor name
|
||||
m.WriteUint16(24) // depth
|
||||
m.WriteUint16(0xFFFF) // color table id (-1)
|
||||
|
||||
switch codec {
|
||||
case streamer.CodecH264:
|
||||
m.StartAtom("avcC")
|
||||
case streamer.CodecH265:
|
||||
m.StartAtom("hvcC")
|
||||
}
|
||||
m.Write(conf)
|
||||
m.EndAtom() // AVCC
|
||||
|
||||
m.EndAtom() // AVC1
|
||||
}
|
||||
|
||||
func (m *Movie) WriteAudio(codec string, channels uint16, sampleRate uint32, conf []byte) {
|
||||
switch codec {
|
||||
case streamer.CodecAAC, streamer.CodecMP3:
|
||||
m.StartAtom("mp4a")
|
||||
case streamer.CodecOpus:
|
||||
m.StartAtom("Opus")
|
||||
case streamer.CodecPCMU:
|
||||
m.StartAtom("ulaw")
|
||||
case streamer.CodecPCMA:
|
||||
m.StartAtom("alaw")
|
||||
default:
|
||||
panic("unsupported iso audio: " + codec)
|
||||
}
|
||||
m.Skip(6)
|
||||
m.WriteUint16(1) // data_reference_index
|
||||
m.Skip(2) // version
|
||||
m.Skip(2) // revision
|
||||
m.Skip(4) // vendor
|
||||
m.WriteUint16(channels) // channel_count
|
||||
m.WriteUint16(16) // sample_size
|
||||
m.Skip(2) // compression id
|
||||
m.Skip(2) // reserved
|
||||
m.WriteFloat32(float64(sampleRate)) // sample_rate
|
||||
|
||||
switch codec {
|
||||
case streamer.CodecAAC:
|
||||
m.WriteEsdsAAC(conf)
|
||||
case streamer.CodecMP3:
|
||||
m.WriteEsdsMP3()
|
||||
case streamer.CodecOpus:
|
||||
// don't know what means this magic
|
||||
m.StartAtom("dOps")
|
||||
m.WriteBytes(0, 0x02, 0x01, 0x38, 0, 0, 0xBB, 0x80, 0, 0, 0)
|
||||
m.EndAtom()
|
||||
case streamer.CodecPCMU, streamer.CodecPCMA:
|
||||
// don't know what means this magic
|
||||
m.StartAtom("chan")
|
||||
m.WriteBytes(0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 4, 0, 0, 0, 0)
|
||||
m.EndAtom()
|
||||
}
|
||||
|
||||
m.EndAtom() // MP4A/OPUS
|
||||
}
|
||||
|
||||
func (m *Movie) WriteEsdsAAC(conf []byte) {
|
||||
m.StartAtom("esds")
|
||||
m.Skip(1) // version
|
||||
m.Skip(3) // flags
|
||||
|
||||
// MP4ESDescrTag[3]:
|
||||
// - MP4DecConfigDescrTag[4]:
|
||||
// - MP4DecSpecificDescrTag[5]: conf
|
||||
// - Other[6]
|
||||
const header = 5
|
||||
const size3 = 3
|
||||
const size4 = 13
|
||||
size5 := byte(len(conf))
|
||||
const size6 = 1
|
||||
|
||||
m.WriteBytes(3, 0x80, 0x80, 0x80, size3+header+size4+header+size5+header+size6)
|
||||
m.Skip(2) // es id
|
||||
m.Skip(1) // es flags
|
||||
|
||||
m.WriteBytes(4, 0x80, 0x80, 0x80, size4+header+size5)
|
||||
m.WriteBytes(0x40) // object id
|
||||
m.WriteBytes(0x15) // stream type
|
||||
m.Skip(3) // buffer size db
|
||||
m.Skip(4) // max bitraga
|
||||
m.Skip(4) // avg bitraga
|
||||
|
||||
m.WriteBytes(5, 0x80, 0x80, 0x80, size5)
|
||||
m.Write(conf)
|
||||
|
||||
m.WriteBytes(6, 0x80, 0x80, 0x80, 1)
|
||||
m.WriteBytes(2) // ?
|
||||
|
||||
m.EndAtom() // ESDS
|
||||
}
|
||||
|
||||
func (m *Movie) WriteEsdsMP3() {
|
||||
m.StartAtom("esds")
|
||||
m.Skip(1) // version
|
||||
m.Skip(3) // flags
|
||||
|
||||
// MP4ESDescrTag[3]:
|
||||
// - MP4DecConfigDescrTag[4]:
|
||||
// - Other[6]
|
||||
const header = 5
|
||||
const size3 = 3
|
||||
const size4 = 13
|
||||
const size6 = 1
|
||||
|
||||
m.WriteBytes(3, 0x80, 0x80, 0x80, size3+header+size4+header+size6)
|
||||
m.Skip(2) // es id
|
||||
m.Skip(1) // es flags
|
||||
|
||||
m.WriteBytes(4, 0x80, 0x80, 0x80, size4)
|
||||
m.WriteBytes(0x6B) // object id
|
||||
m.WriteBytes(0x15) // stream type
|
||||
m.Skip(3) // buffer size db
|
||||
m.Skip(4) // max bitraga
|
||||
m.Skip(4) // avg bitraga
|
||||
|
||||
m.WriteBytes(6, 0x80, 0x80, 0x80, 1)
|
||||
m.WriteBytes(2) // ?
|
||||
|
||||
m.EndAtom() // ESDS
|
||||
}
|
||||
91
pkg/iso/iso.go
Normal file
91
pkg/iso/iso.go
Normal file
@@ -0,0 +1,91 @@
|
||||
package iso
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"math"
|
||||
)
|
||||
|
||||
type Movie struct {
|
||||
b []byte
|
||||
start []int
|
||||
}
|
||||
|
||||
func NewMovie(size int) *Movie {
|
||||
return &Movie{b: make([]byte, 0, size)}
|
||||
}
|
||||
|
||||
func (m *Movie) Bytes() []byte {
|
||||
return m.b
|
||||
}
|
||||
|
||||
func (m *Movie) StartAtom(name string) {
|
||||
m.start = append(m.start, len(m.b))
|
||||
m.b = append(m.b, 0, 0, 0, 0)
|
||||
m.b = append(m.b, name...)
|
||||
}
|
||||
|
||||
func (m *Movie) EndAtom() {
|
||||
n := len(m.start) - 1
|
||||
|
||||
i := m.start[n]
|
||||
size := uint32(len(m.b) - i)
|
||||
binary.BigEndian.PutUint32(m.b[i:], size)
|
||||
|
||||
m.start = m.start[:n]
|
||||
}
|
||||
|
||||
func (m *Movie) Write(b []byte) {
|
||||
m.b = append(m.b, b...)
|
||||
}
|
||||
|
||||
func (m *Movie) WriteBytes(b ...byte) {
|
||||
m.b = append(m.b, b...)
|
||||
}
|
||||
|
||||
func (m *Movie) WriteString(s string) {
|
||||
m.b = append(m.b, s...)
|
||||
}
|
||||
|
||||
func (m *Movie) Skip(n int) {
|
||||
m.b = append(m.b, make([]byte, n)...)
|
||||
}
|
||||
|
||||
func (m *Movie) WriteUint16(v uint16) {
|
||||
m.b = append(m.b, byte(v>>8), byte(v))
|
||||
}
|
||||
|
||||
func (m *Movie) WriteUint24(v uint32) {
|
||||
m.b = append(m.b, byte(v>>16), byte(v>>8), byte(v))
|
||||
}
|
||||
|
||||
func (m *Movie) WriteUint32(v uint32) {
|
||||
m.b = append(m.b, byte(v>>24), byte(v>>16), byte(v>>8), byte(v))
|
||||
}
|
||||
|
||||
func (m *Movie) WriteUint64(v uint64) {
|
||||
m.b = append(m.b, byte(v>>56), byte(v>>48), byte(v>>40), byte(v>>32), byte(v>>24), byte(v>>16), byte(v>>8), byte(v))
|
||||
}
|
||||
|
||||
func (m *Movie) WriteFloat16(f float64) {
|
||||
i, f := math.Modf(f)
|
||||
f *= 256
|
||||
m.b = append(m.b, byte(i), byte(f))
|
||||
}
|
||||
|
||||
func (m *Movie) WriteFloat32(f float64) {
|
||||
i, f := math.Modf(f)
|
||||
f *= 65536
|
||||
m.b = append(m.b, byte(uint16(i)>>8), byte(i), byte(uint16(f)>>8), byte(f))
|
||||
}
|
||||
|
||||
func (m *Movie) WriteMatrix() {
|
||||
m.WriteUint32(0x00010000)
|
||||
m.Skip(4)
|
||||
m.Skip(4)
|
||||
m.Skip(4)
|
||||
m.WriteUint32(0x00010000)
|
||||
m.Skip(4)
|
||||
m.Skip(4)
|
||||
m.Skip(4)
|
||||
m.WriteUint32(0x40000000)
|
||||
}
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
)
|
||||
|
||||
@@ -41,6 +42,8 @@ type Client struct {
|
||||
buffer chan []byte
|
||||
state State
|
||||
mu sync.Mutex
|
||||
|
||||
recv uint32
|
||||
}
|
||||
|
||||
func NewClient(id string) *Client {
|
||||
@@ -109,6 +112,7 @@ func (c *Client) Handle() error {
|
||||
c.mu.Lock()
|
||||
if c.state == StateHandle {
|
||||
c.buffer <- data
|
||||
atomic.AddUint32(&c.recv, uint32(len(data)))
|
||||
}
|
||||
c.mu.Unlock()
|
||||
}
|
||||
@@ -140,6 +144,7 @@ func (c *Client) Handle() error {
|
||||
c.mu.Lock()
|
||||
if c.state == StateHandle {
|
||||
c.buffer <- data
|
||||
atomic.AddUint32(&c.recv, uint32(len(data)))
|
||||
}
|
||||
c.mu.Unlock()
|
||||
}
|
||||
|
||||
49
pkg/ivideon/producer.go
Normal file
49
pkg/ivideon/producer.go
Normal file
@@ -0,0 +1,49 @@
|
||||
package ivideon
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"github.com/AlexxIT/go2rtc/pkg/streamer"
|
||||
"sync/atomic"
|
||||
)
|
||||
|
||||
func (c *Client) GetMedias() []*streamer.Media {
|
||||
return c.medias
|
||||
}
|
||||
|
||||
func (c *Client) GetTrack(media *streamer.Media, codec *streamer.Codec) *streamer.Track {
|
||||
for _, track := range c.tracks {
|
||||
if track.Codec == codec {
|
||||
return track
|
||||
}
|
||||
}
|
||||
panic(fmt.Sprintf("wrong media/codec: %+v %+v", media, codec))
|
||||
}
|
||||
|
||||
func (c *Client) Start() error {
|
||||
err := c.Handle()
|
||||
if c.buffer == nil {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (c *Client) Stop() error {
|
||||
return c.Close()
|
||||
}
|
||||
|
||||
func (c *Client) MarshalJSON() ([]byte, error) {
|
||||
var tracks []*streamer.Track
|
||||
for _, track := range c.tracks {
|
||||
tracks = append(tracks, track)
|
||||
}
|
||||
|
||||
info := &streamer.Info{
|
||||
Type: "Ivideon source",
|
||||
URL: c.ID,
|
||||
Medias: c.medias,
|
||||
Tracks: tracks,
|
||||
Recv: atomic.LoadUint32(&c.recv),
|
||||
}
|
||||
return json.Marshal(info)
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"net/textproto"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
)
|
||||
|
||||
@@ -23,51 +24,13 @@ type Client struct {
|
||||
closed bool
|
||||
res *http.Response
|
||||
|
||||
track *streamer.Track
|
||||
medias []*streamer.Media
|
||||
track *streamer.Track
|
||||
recv uint32
|
||||
}
|
||||
|
||||
func NewClient(res *http.Response) *Client {
|
||||
codec := &streamer.Codec{
|
||||
Name: streamer.CodecJPEG, ClockRate: 90000, PayloadType: streamer.PayloadTypeRAW,
|
||||
}
|
||||
return &Client{
|
||||
res: res,
|
||||
track: streamer.NewTrack(codec, streamer.DirectionSendonly),
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) GetMedias() []*streamer.Media {
|
||||
return []*streamer.Media{{
|
||||
Kind: streamer.KindVideo,
|
||||
Direction: streamer.DirectionSendonly,
|
||||
Codecs: []*streamer.Codec{c.track.Codec},
|
||||
}}
|
||||
}
|
||||
|
||||
func (c *Client) GetTrack(media *streamer.Media, codec *streamer.Codec) *streamer.Track {
|
||||
return c.track
|
||||
}
|
||||
|
||||
func (c *Client) Start() error {
|
||||
ct := c.res.Header.Get("Content-Type")
|
||||
|
||||
if ct == "image/jpeg" {
|
||||
return c.startJPEG()
|
||||
}
|
||||
|
||||
// added in go1.18
|
||||
if _, s, ok := strings.Cut(ct, "boundary="); ok {
|
||||
return c.startMJPEG(s)
|
||||
}
|
||||
|
||||
return errors.New("wrong Content-Type: " + ct)
|
||||
}
|
||||
|
||||
func (c *Client) Stop() error {
|
||||
// important for close reader/writer gorutines
|
||||
_ = c.res.Body.Close()
|
||||
c.closed = true
|
||||
return nil
|
||||
return &Client{res: res}
|
||||
}
|
||||
|
||||
func (c *Client) startJPEG() error {
|
||||
@@ -79,6 +42,8 @@ func (c *Client) startJPEG() error {
|
||||
packet := &rtp.Packet{Header: rtp.Header{Timestamp: now()}, Payload: buf}
|
||||
_ = c.track.WriteRTP(packet)
|
||||
|
||||
atomic.AddUint32(&c.recv, uint32(len(buf)))
|
||||
|
||||
req := c.res.Request
|
||||
|
||||
for !c.closed {
|
||||
@@ -98,13 +63,19 @@ func (c *Client) startJPEG() error {
|
||||
|
||||
packet = &rtp.Packet{Header: rtp.Header{Timestamp: now()}, Payload: buf}
|
||||
_ = c.track.WriteRTP(packet)
|
||||
|
||||
atomic.AddUint32(&c.recv, uint32(len(buf)))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Client) startMJPEG(boundary string) error {
|
||||
boundary = "--" + boundary
|
||||
// some cameras add prefix to boundary header:
|
||||
// https://github.com/TheTimeWalker/wallpanel-android
|
||||
if !strings.HasPrefix(boundary, "--") {
|
||||
boundary = "--" + boundary
|
||||
}
|
||||
|
||||
r := bufio.NewReader(c.res.Body)
|
||||
tp := textproto.NewReader(r)
|
||||
@@ -114,7 +85,7 @@ func (c *Client) startMJPEG(boundary string) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if s != boundary {
|
||||
if !strings.HasPrefix(s, boundary) {
|
||||
return errors.New("wrong boundary: " + s)
|
||||
}
|
||||
|
||||
@@ -141,6 +112,8 @@ func (c *Client) startMJPEG(boundary string) error {
|
||||
packet := &rtp.Packet{Header: rtp.Header{Timestamp: now()}, Payload: buf}
|
||||
_ = c.track.WriteRTP(packet)
|
||||
|
||||
atomic.AddUint32(&c.recv, uint32(len(buf)))
|
||||
|
||||
if _, err = r.Discard(2); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
package mjpeg
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"github.com/AlexxIT/go2rtc/pkg/streamer"
|
||||
"github.com/pion/rtp"
|
||||
"sync/atomic"
|
||||
)
|
||||
|
||||
type Consumer struct {
|
||||
@@ -14,7 +16,7 @@ type Consumer struct {
|
||||
codecs []*streamer.Codec
|
||||
start bool
|
||||
|
||||
send int
|
||||
send uint32
|
||||
}
|
||||
|
||||
func (c *Consumer) GetMedias() []*streamer.Media {
|
||||
@@ -28,6 +30,7 @@ func (c *Consumer) GetMedias() []*streamer.Media {
|
||||
func (c *Consumer) AddTrack(media *streamer.Media, track *streamer.Track) *streamer.Track {
|
||||
push := func(packet *rtp.Packet) error {
|
||||
c.Fire(packet.Payload)
|
||||
atomic.AddUint32(&c.send, uint32(len(packet.Payload)))
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -38,3 +41,13 @@ func (c *Consumer) AddTrack(media *streamer.Media, track *streamer.Track) *strea
|
||||
|
||||
return track.Bind(push)
|
||||
}
|
||||
|
||||
func (c *Consumer) MarshalJSON() ([]byte, error) {
|
||||
info := &streamer.Info{
|
||||
Type: "MJPEG client",
|
||||
RemoteAddr: c.RemoteAddr,
|
||||
UserAgent: c.UserAgent,
|
||||
Send: atomic.LoadUint32(&c.send),
|
||||
}
|
||||
return json.Marshal(info)
|
||||
}
|
||||
|
||||
64
pkg/mjpeg/producer.go
Normal file
64
pkg/mjpeg/producer.go
Normal file
@@ -0,0 +1,64 @@
|
||||
package mjpeg
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"github.com/AlexxIT/go2rtc/pkg/streamer"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
)
|
||||
|
||||
func (c *Client) GetMedias() []*streamer.Media {
|
||||
if c.medias == nil {
|
||||
c.medias = []*streamer.Media{{
|
||||
Kind: streamer.KindVideo,
|
||||
Direction: streamer.DirectionSendonly,
|
||||
Codecs: []*streamer.Codec{
|
||||
{
|
||||
Name: streamer.CodecJPEG, ClockRate: 90000, PayloadType: streamer.PayloadTypeRAW,
|
||||
},
|
||||
},
|
||||
}}
|
||||
}
|
||||
return c.medias
|
||||
}
|
||||
|
||||
func (c *Client) GetTrack(media *streamer.Media, codec *streamer.Codec) *streamer.Track {
|
||||
if c.track == nil {
|
||||
c.track = streamer.NewTrack(codec, streamer.DirectionSendonly)
|
||||
}
|
||||
return c.track
|
||||
}
|
||||
|
||||
func (c *Client) Start() error {
|
||||
ct := c.res.Header.Get("Content-Type")
|
||||
|
||||
if ct == "image/jpeg" {
|
||||
return c.startJPEG()
|
||||
}
|
||||
|
||||
// added in go1.18
|
||||
if _, s, ok := strings.Cut(ct, "boundary="); ok {
|
||||
return c.startMJPEG(s)
|
||||
}
|
||||
|
||||
return errors.New("wrong Content-Type: " + ct)
|
||||
}
|
||||
|
||||
func (c *Client) Stop() error {
|
||||
// important for close reader/writer gorutines
|
||||
_ = c.res.Body.Close()
|
||||
c.closed = true
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Client) MarshalJSON() ([]byte, error) {
|
||||
info := &streamer.Info{
|
||||
Type: "MJPEG source",
|
||||
URL: c.res.Request.URL.String(),
|
||||
RemoteAddr: c.RemoteAddr,
|
||||
UserAgent: c.UserAgent,
|
||||
Recv: atomic.LoadUint32(&c.recv),
|
||||
}
|
||||
return json.Marshal(info)
|
||||
}
|
||||
277
pkg/mjpeg/rtp.go
277
pkg/mjpeg/rtp.go
@@ -1,8 +1,12 @@
|
||||
package mjpeg
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/binary"
|
||||
"github.com/AlexxIT/go2rtc/pkg/streamer"
|
||||
"github.com/pion/rtp"
|
||||
"image"
|
||||
"image/jpeg"
|
||||
)
|
||||
|
||||
func RTPDepay(track *streamer.Track) streamer.WrapperFunc {
|
||||
@@ -43,9 +47,18 @@ func RTPDepay(track *streamer.Track) streamer.WrapperFunc {
|
||||
w := uint16(packet.Payload[6]) << 3
|
||||
h := uint16(packet.Payload[7]) << 3
|
||||
|
||||
// fix 2560x1920 and 2560x1440
|
||||
if w == 512 && (h == 1920 || h == 1440) {
|
||||
// fix sizes more than 2040
|
||||
switch {
|
||||
// 512x1920 512x1440
|
||||
case w == cutSize(2560) && (h == 1920 || h == 1440):
|
||||
w = 2560
|
||||
// 1792x112
|
||||
case w == cutSize(3840) && h == cutSize(2160):
|
||||
w = 3840
|
||||
h = 2160
|
||||
// 256x1296
|
||||
case w == cutSize(2304) && h == 1296:
|
||||
w = 2304
|
||||
}
|
||||
|
||||
//fmt.Printf("t: %d, q: %d, w: %d, h: %d\n", t, q, w, h)
|
||||
@@ -73,140 +86,142 @@ func RTPDepay(track *streamer.Track) streamer.WrapperFunc {
|
||||
}
|
||||
}
|
||||
|
||||
func cutSize(size uint16) uint16 {
|
||||
return ((size >> 3) & 0xFF) << 3
|
||||
}
|
||||
|
||||
func RTPPay() streamer.WrapperFunc {
|
||||
const packetSize = 1436
|
||||
|
||||
sequencer := rtp.NewRandomSequencer()
|
||||
|
||||
return func(push streamer.WriterFunc) streamer.WriterFunc {
|
||||
return func(packet *rtp.Packet) error {
|
||||
// reincode image to more common form
|
||||
p, err := Transcode(packet.Payload)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
h1 := make([]byte, 8)
|
||||
h1[4] = 1 // Type
|
||||
h1[5] = 255 // Q
|
||||
|
||||
// MBZ=0, Precision=0, Length=128
|
||||
h2 := make([]byte, 4, 132)
|
||||
h2[3] = 128
|
||||
|
||||
var jpgData []byte
|
||||
for jpgData == nil {
|
||||
// 2 bytes h1
|
||||
if p[0] != 0xFF {
|
||||
return nil
|
||||
}
|
||||
|
||||
size := binary.BigEndian.Uint16(p[2:]) + 2
|
||||
|
||||
// 2 bytes payload size (include 2 bytes)
|
||||
switch p[1] {
|
||||
case 0xD8: // 0. Start Of Image (size=0)
|
||||
p = p[2:]
|
||||
continue
|
||||
case 0xDB: // 1. Define Quantization Table (size=130)
|
||||
for i := uint16(4 + 1); i < size; i += 1 + 64 {
|
||||
h2 = append(h2, p[i:i+64]...)
|
||||
}
|
||||
case 0xC0: // 2. Start Of Frame (size=15)
|
||||
if p[4] != 8 {
|
||||
return nil
|
||||
}
|
||||
h := binary.BigEndian.Uint16(p[5:])
|
||||
w := binary.BigEndian.Uint16(p[7:])
|
||||
h1[6] = uint8(w >> 3)
|
||||
h1[7] = uint8(h >> 3)
|
||||
case 0xC4: // 3. Define Huffman Table (size=416)
|
||||
case 0xDA: // 4. Start Of Scan (size=10)
|
||||
jpgData = p[size:]
|
||||
}
|
||||
|
||||
p = p[size:]
|
||||
}
|
||||
|
||||
offset := 0
|
||||
p = make([]byte, 0)
|
||||
|
||||
for jpgData != nil {
|
||||
p = p[:0]
|
||||
|
||||
if offset > 0 {
|
||||
h1[1] = byte(offset >> 16)
|
||||
h1[2] = byte(offset >> 8)
|
||||
h1[3] = byte(offset)
|
||||
p = append(p, h1...)
|
||||
} else {
|
||||
p = append(p, h1...)
|
||||
p = append(p, h2...)
|
||||
}
|
||||
|
||||
dataLen := packetSize - len(p)
|
||||
if dataLen < len(jpgData) {
|
||||
p = append(p, jpgData[:dataLen]...)
|
||||
jpgData = jpgData[dataLen:]
|
||||
offset += dataLen
|
||||
} else {
|
||||
p = append(p, jpgData...)
|
||||
jpgData = nil
|
||||
}
|
||||
|
||||
clone := rtp.Packet{
|
||||
Header: rtp.Header{
|
||||
Version: 2,
|
||||
Marker: jpgData == nil,
|
||||
SequenceNumber: sequencer.NextSequenceNumber(),
|
||||
Timestamp: packet.Timestamp,
|
||||
},
|
||||
Payload: p,
|
||||
}
|
||||
if err := push(&clone); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
//func RTPPay() streamer.WrapperFunc {
|
||||
// const packetSize = 1436
|
||||
//
|
||||
// sequencer := rtp.NewRandomSequencer()
|
||||
//
|
||||
// return func(push streamer.WriterFunc) streamer.WriterFunc {
|
||||
// return func(packet *rtp.Packet) error {
|
||||
// // reincode image to more common form
|
||||
// img, err := jpeg.Decode(bytes.NewReader(packet.Payload))
|
||||
// if err != nil {
|
||||
// return err
|
||||
// }
|
||||
//
|
||||
// wh := img.Bounds().Size()
|
||||
// w := wh.X
|
||||
// h := wh.Y
|
||||
//
|
||||
// if w > 2040 {
|
||||
// w = 2040
|
||||
// } else if w&3 > 0 {
|
||||
// w &= 3
|
||||
// }
|
||||
// if h > 2040 {
|
||||
// h = 2040
|
||||
// } else if h&3 > 0 {
|
||||
// h &= 3
|
||||
// }
|
||||
//
|
||||
// if w != wh.X || h != wh.Y {
|
||||
// x0 := (wh.X - w) / 2
|
||||
// y0 := (wh.Y - h) / 2
|
||||
// rect := image.Rect(x0, y0, x0+w, y0+h)
|
||||
// img = img.(*image.YCbCr).SubImage(rect)
|
||||
// }
|
||||
//
|
||||
// buf := bytes.NewBuffer(nil)
|
||||
// if err = jpeg.Encode(buf, img, nil); err != nil {
|
||||
// return err
|
||||
// }
|
||||
//
|
||||
// h1 := make([]byte, 8)
|
||||
// h1[4] = 1 // Type
|
||||
// h1[5] = 255 // Q
|
||||
//
|
||||
// // MBZ=0, Precision=0, Length=128
|
||||
// h2 := make([]byte, 4, 132)
|
||||
// h2[3] = 128
|
||||
//
|
||||
// var jpgData []byte
|
||||
//
|
||||
// p := buf.Bytes()
|
||||
//
|
||||
// for jpgData == nil {
|
||||
// // 2 bytes h1
|
||||
// if p[0] != 0xFF {
|
||||
// return nil
|
||||
// }
|
||||
//
|
||||
// size := binary.BigEndian.Uint16(p[2:]) + 2
|
||||
//
|
||||
// // 2 bytes payload size (include 2 bytes)
|
||||
// switch p[1] {
|
||||
// case 0xD8: // 0. Start Of Image (size=0)
|
||||
// p = p[2:]
|
||||
// continue
|
||||
// case 0xDB: // 1. Define Quantization Table (size=130)
|
||||
// for i := uint16(4 + 1); i < size; i += 1 + 64 {
|
||||
// h2 = append(h2, p[i:i+64]...)
|
||||
// }
|
||||
// case 0xC0: // 2. Start Of Frame (size=15)
|
||||
// if p[4] != 8 {
|
||||
// return nil
|
||||
// }
|
||||
// h := binary.BigEndian.Uint16(p[5:])
|
||||
// w := binary.BigEndian.Uint16(p[7:])
|
||||
// h1[6] = uint8(w >> 3)
|
||||
// h1[7] = uint8(h >> 3)
|
||||
// case 0xC4: // 3. Define Huffman Table (size=416)
|
||||
// case 0xDA: // 4. Start Of Scan (size=10)
|
||||
// jpgData = p[size:]
|
||||
// }
|
||||
//
|
||||
// p = p[size:]
|
||||
// }
|
||||
//
|
||||
// offset := 0
|
||||
// p = make([]byte, 0)
|
||||
//
|
||||
// for jpgData != nil {
|
||||
// p = p[:0]
|
||||
//
|
||||
// if offset > 0 {
|
||||
// h1[1] = byte(offset >> 16)
|
||||
// h1[2] = byte(offset >> 8)
|
||||
// h1[3] = byte(offset)
|
||||
// p = append(p, h1...)
|
||||
// } else {
|
||||
// p = append(p, h1...)
|
||||
// p = append(p, h2...)
|
||||
// }
|
||||
//
|
||||
// dataLen := packetSize - len(p)
|
||||
// if dataLen < len(jpgData) {
|
||||
// p = append(p, jpgData[:dataLen]...)
|
||||
// jpgData = jpgData[dataLen:]
|
||||
// offset += dataLen
|
||||
// } else {
|
||||
// p = append(p, jpgData...)
|
||||
// jpgData = nil
|
||||
// }
|
||||
//
|
||||
// clone := rtp.Packet{
|
||||
// Header: rtp.Header{
|
||||
// Version: 2,
|
||||
// Marker: jpgData == nil,
|
||||
// SequenceNumber: sequencer.NextSequenceNumber(),
|
||||
// Timestamp: packet.Timestamp,
|
||||
// },
|
||||
// Payload: p,
|
||||
// }
|
||||
// if err := push(&clone); err != nil {
|
||||
// return err
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// return nil
|
||||
// }
|
||||
// }
|
||||
//}
|
||||
func Transcode(b []byte) ([]byte, error) {
|
||||
img, err := jpeg.Decode(bytes.NewReader(b))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
wh := img.Bounds().Size()
|
||||
w := wh.X
|
||||
h := wh.Y
|
||||
|
||||
if w > 2040 {
|
||||
w = 2040
|
||||
} else if w&3 > 0 {
|
||||
w &= 3
|
||||
}
|
||||
if h > 2040 {
|
||||
h = 2040
|
||||
} else if h&3 > 0 {
|
||||
h &= 3
|
||||
}
|
||||
|
||||
if w != wh.X || h != wh.Y {
|
||||
x0 := (wh.X - w) / 2
|
||||
y0 := (wh.Y - h) / 2
|
||||
rect := image.Rect(x0, y0, x0+w, y0+h)
|
||||
img = img.(*image.YCbCr).SubImage(rect)
|
||||
}
|
||||
|
||||
buf := bytes.NewBuffer(nil)
|
||||
if err = jpeg.Encode(buf, img, nil); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
@@ -1,19 +1,30 @@
|
||||
## Fragmented MP4
|
||||
|
||||
```
|
||||
ffmpeg -i "rtsp://..." -movflags +frag_keyframe+separate_moof+default_base_moof+empty_moov -frag_duration 1 -c copy -t 5 sample.mp4
|
||||
```
|
||||
|
||||
- movflags frag_keyframe
|
||||
Start a new fragment at each video keyframe.
|
||||
- frag_duration duration
|
||||
Create fragments that are duration microseconds long.
|
||||
- movflags separate_moof
|
||||
Write a separate moof (movie fragment) atom for each track.
|
||||
- movflags default_base_moof
|
||||
Similarly to the omit_tfhd_offset, this flag avoids writing the absolute base_data_offset field in tfhd atoms, but does so by using the new default-base-is-moof flag instead.
|
||||
|
||||
https://ffmpeg.org/ffmpeg-formats.html#Options-13
|
||||
|
||||
## HEVC
|
||||
|
||||
Browser | avc1 | hvc1 | hev1
|
||||
------------|------|------|---
|
||||
Mac Chrome | + | - | +
|
||||
Mac Safari | + | + | -
|
||||
iOS 15? | + | + | -
|
||||
Mac Firefox | + | - | -
|
||||
iOS 12 | + | - | -
|
||||
Android 13 | + | - | -
|
||||
|
||||
```
|
||||
ffmpeg -i input-hev1.mp4 -c:v copy -tag:v hvc1 -c:a copy output-hvc1.mp4
|
||||
Stream #0:0(eng): Video: hevc (Main) (hev1 / 0x31766568), yuv420p(tv, progressive), 720x404, 164 kb/s, 29.97 fps,
|
||||
Stream #0:0(eng): Video: hevc (Main) (hvc1 / 0x31637668), yuv420p(tv, progressive), 720x404, 164 kb/s, 29.97 fps,
|
||||
```
|
||||
| Browser | avc1 | hvc1 | hev1 |
|
||||
|-------------|------|------|------|
|
||||
| Mac Chrome | + | - | + |
|
||||
| Mac Safari | + | + | - |
|
||||
| iOS 15? | + | + | - |
|
||||
| Mac Firefox | + | - | - |
|
||||
| iOS 12 | + | - | - |
|
||||
| Android 13 | + | - | - |
|
||||
|
||||
## Useful links
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"github.com/AlexxIT/go2rtc/pkg/h265"
|
||||
"github.com/AlexxIT/go2rtc/pkg/streamer"
|
||||
"github.com/pion/rtp"
|
||||
"sync/atomic"
|
||||
)
|
||||
|
||||
type Consumer struct {
|
||||
@@ -20,7 +21,17 @@ type Consumer struct {
|
||||
codecs []*streamer.Codec
|
||||
wait byte
|
||||
|
||||
send int
|
||||
send uint32
|
||||
}
|
||||
|
||||
// ParseQuery - like usual parse, but with mp4 param handler
|
||||
func ParseQuery(query map[string][]string) []*streamer.Media {
|
||||
if query["mp4"] != nil {
|
||||
cons := Consumer{}
|
||||
return cons.GetMedias()
|
||||
}
|
||||
|
||||
return streamer.ParseQuery(query)
|
||||
}
|
||||
|
||||
const (
|
||||
@@ -76,7 +87,7 @@ func (c *Consumer) AddTrack(media *streamer.Media, track *streamer.Track) *strea
|
||||
}
|
||||
|
||||
buf := c.muxer.Marshal(trackID, packet)
|
||||
c.send += len(buf)
|
||||
atomic.AddUint32(&c.send, uint32(len(buf)))
|
||||
c.Fire(buf)
|
||||
|
||||
return nil
|
||||
@@ -108,7 +119,7 @@ func (c *Consumer) AddTrack(media *streamer.Media, track *streamer.Track) *strea
|
||||
}
|
||||
|
||||
buf := c.muxer.Marshal(trackID, packet)
|
||||
c.send += len(buf)
|
||||
atomic.AddUint32(&c.send, uint32(len(buf)))
|
||||
c.Fire(buf)
|
||||
|
||||
return nil
|
||||
@@ -128,7 +139,7 @@ func (c *Consumer) AddTrack(media *streamer.Media, track *streamer.Track) *strea
|
||||
}
|
||||
|
||||
buf := c.muxer.Marshal(trackID, packet)
|
||||
c.send += len(buf)
|
||||
atomic.AddUint32(&c.send, uint32(len(buf)))
|
||||
c.Fire(buf)
|
||||
|
||||
return nil
|
||||
@@ -139,14 +150,33 @@ func (c *Consumer) AddTrack(media *streamer.Media, track *streamer.Track) *strea
|
||||
push = wrapper(push)
|
||||
}
|
||||
|
||||
return track.Bind(push)
|
||||
|
||||
case streamer.CodecOpus, streamer.CodecMP3, streamer.CodecPCMU, streamer.CodecPCMA:
|
||||
push := func(packet *rtp.Packet) error {
|
||||
if c.wait != waitNone {
|
||||
return nil
|
||||
}
|
||||
|
||||
buf := c.muxer.Marshal(trackID, packet)
|
||||
atomic.AddUint32(&c.send, uint32(len(buf)))
|
||||
c.Fire(buf)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
return track.Bind(push)
|
||||
}
|
||||
|
||||
panic("unsupported codec")
|
||||
}
|
||||
|
||||
func (c *Consumer) MimeCodecs() string {
|
||||
return c.muxer.MimeCodecs(c.codecs)
|
||||
}
|
||||
|
||||
func (c *Consumer) MimeType() string {
|
||||
return c.muxer.MimeType(c.codecs)
|
||||
return `video/mp4; codecs="` + c.MimeCodecs() + `"`
|
||||
}
|
||||
|
||||
func (c *Consumer) Init() ([]byte, error) {
|
||||
@@ -163,12 +193,11 @@ func (c *Consumer) Start() {
|
||||
//
|
||||
|
||||
func (c *Consumer) MarshalJSON() ([]byte, error) {
|
||||
v := map[string]interface{}{
|
||||
"type": "MP4 server consumer",
|
||||
"send": c.send,
|
||||
"remote_addr": c.RemoteAddr,
|
||||
"user_agent": c.UserAgent,
|
||||
info := &streamer.Info{
|
||||
Type: "MP4 client",
|
||||
RemoteAddr: c.RemoteAddr,
|
||||
UserAgent: c.UserAgent,
|
||||
Send: atomic.LoadUint32(&c.send),
|
||||
}
|
||||
|
||||
return json.Marshal(v)
|
||||
return json.Marshal(info)
|
||||
}
|
||||
|
||||
221
pkg/mp4/muxer.go
221
pkg/mp4/muxer.go
@@ -1,17 +1,13 @@
|
||||
package mp4
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"encoding/hex"
|
||||
"github.com/AlexxIT/go2rtc/pkg/h264"
|
||||
"github.com/AlexxIT/go2rtc/pkg/h265"
|
||||
"github.com/AlexxIT/go2rtc/pkg/iso"
|
||||
"github.com/AlexxIT/go2rtc/pkg/streamer"
|
||||
"github.com/deepch/vdk/av"
|
||||
"github.com/deepch/vdk/codec/h264parser"
|
||||
"github.com/deepch/vdk/codec/h265parser"
|
||||
"github.com/deepch/vdk/format/fmp4/fmp4io"
|
||||
"github.com/deepch/vdk/format/mp4/mp4io"
|
||||
"github.com/deepch/vdk/format/mp4f/mp4fio"
|
||||
"github.com/pion/rtp"
|
||||
)
|
||||
|
||||
@@ -21,8 +17,15 @@ type Muxer struct {
|
||||
pts []uint32
|
||||
}
|
||||
|
||||
func (m *Muxer) MimeType(codecs []*streamer.Codec) string {
|
||||
s := `video/mp4; codecs="`
|
||||
const (
|
||||
MimeH264 = "avc1.640029"
|
||||
MimeH265 = "hvc1.1.6.L153.B0"
|
||||
MimeAAC = "mp4a.40.2"
|
||||
MimeOpus = "opus"
|
||||
)
|
||||
|
||||
func (m *Muxer) MimeCodecs(codecs []*streamer.Codec) string {
|
||||
var s string
|
||||
|
||||
for i, codec := range codecs {
|
||||
if i > 0 {
|
||||
@@ -35,17 +38,23 @@ func (m *Muxer) MimeType(codecs []*streamer.Codec) string {
|
||||
case streamer.CodecH265:
|
||||
// H.265 profile=main level=5.1
|
||||
// hvc1 - supported in Safari, hev1 - doesn't, both supported in Chrome
|
||||
s += "hvc1.1.6.L153.B0"
|
||||
s += MimeH265
|
||||
case streamer.CodecAAC:
|
||||
s += "mp4a.40.2"
|
||||
s += MimeAAC
|
||||
case streamer.CodecOpus:
|
||||
s += MimeOpus
|
||||
}
|
||||
}
|
||||
|
||||
return s + `"`
|
||||
return s
|
||||
}
|
||||
|
||||
func (m *Muxer) GetInit(codecs []*streamer.Codec) ([]byte, error) {
|
||||
moov := MOOV()
|
||||
mv := iso.NewMovie(1024)
|
||||
mv.WriteFileType()
|
||||
|
||||
mv.StartAtom(iso.Moov)
|
||||
mv.WriteMovieHeader()
|
||||
|
||||
for i, codec := range codecs {
|
||||
switch codec.Name {
|
||||
@@ -62,35 +71,11 @@ func (m *Muxer) GetInit(codecs []*streamer.Codec) ([]byte, error) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
width := codecData.Width()
|
||||
height := codecData.Height()
|
||||
|
||||
trak := TRAK(i + 1)
|
||||
trak.Header.TrackWidth = float64(width)
|
||||
trak.Header.TrackHeight = float64(height)
|
||||
trak.Media.Header.TimeScale = int32(codec.ClockRate)
|
||||
trak.Media.Handler = &mp4io.HandlerRefer{
|
||||
SubType: [4]byte{'v', 'i', 'd', 'e'},
|
||||
Name: []byte{0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 'm', 'a', 'i', 'n', 0},
|
||||
}
|
||||
trak.Media.Info.Video = &mp4io.VideoMediaInfo{
|
||||
Flags: 0x000001,
|
||||
}
|
||||
trak.Media.Info.Sample.SampleDesc.AVC1Desc = &mp4io.AVC1Desc{
|
||||
DataRefIdx: 1,
|
||||
HorizontalResolution: 72,
|
||||
VorizontalResolution: 72,
|
||||
Width: int16(width),
|
||||
Height: int16(height),
|
||||
FrameCount: 1,
|
||||
Depth: 24,
|
||||
ColorTableId: -1,
|
||||
Conf: &mp4io.AVC1Conf{
|
||||
Data: codecData.AVCDecoderConfRecordBytes(),
|
||||
},
|
||||
}
|
||||
|
||||
moov.Tracks = append(moov.Tracks, trak)
|
||||
mv.WriteVideoTrack(
|
||||
uint32(i+1), codec.Name, codec.ClockRate,
|
||||
uint16(codecData.Width()), uint16(codecData.Height()),
|
||||
codecData.AVCDecoderConfRecordBytes(),
|
||||
)
|
||||
|
||||
case streamer.CodecH265:
|
||||
vps, sps, pps := h265.GetParameterSet(codec.FmtpLine)
|
||||
@@ -106,35 +91,11 @@ func (m *Muxer) GetInit(codecs []*streamer.Codec) ([]byte, error) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
width := codecData.Width()
|
||||
height := codecData.Height()
|
||||
|
||||
trak := TRAK(i + 1)
|
||||
trak.Header.TrackWidth = float64(width)
|
||||
trak.Header.TrackHeight = float64(height)
|
||||
trak.Media.Header.TimeScale = int32(codec.ClockRate)
|
||||
trak.Media.Handler = &mp4io.HandlerRefer{
|
||||
SubType: [4]byte{'v', 'i', 'd', 'e'},
|
||||
Name: []byte{0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 'm', 'a', 'i', 'n', 0},
|
||||
}
|
||||
trak.Media.Info.Video = &mp4io.VideoMediaInfo{
|
||||
Flags: 0x000001,
|
||||
}
|
||||
trak.Media.Info.Sample.SampleDesc.HV1Desc = &mp4io.HV1Desc{
|
||||
DataRefIdx: 1,
|
||||
HorizontalResolution: 72,
|
||||
VorizontalResolution: 72,
|
||||
Width: int16(width),
|
||||
Height: int16(height),
|
||||
FrameCount: 1,
|
||||
Depth: 24,
|
||||
ColorTableId: -1,
|
||||
Conf: &mp4io.HV1Conf{
|
||||
Data: codecData.AVCDecoderConfRecordBytes(),
|
||||
},
|
||||
}
|
||||
|
||||
moov.Tracks = append(moov.Tracks, trak)
|
||||
mv.WriteVideoTrack(
|
||||
uint32(i+1), codec.Name, codec.ClockRate,
|
||||
uint16(codecData.Width()), uint16(codecData.Height()),
|
||||
codecData.AVCDecoderConfRecordBytes(),
|
||||
)
|
||||
|
||||
case streamer.CodecAAC:
|
||||
s := streamer.Between(codec.FmtpLine, "config=", ";")
|
||||
@@ -143,44 +104,29 @@ func (m *Muxer) GetInit(codecs []*streamer.Codec) ([]byte, error) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
trak := TRAK(i + 1)
|
||||
trak.Header.AlternateGroup = 1
|
||||
trak.Header.Duration = 0
|
||||
trak.Header.Volume = 1
|
||||
trak.Media.Header.TimeScale = int32(codec.ClockRate)
|
||||
mv.WriteAudioTrack(
|
||||
uint32(i+1), codec.Name, codec.ClockRate, codec.Channels, b,
|
||||
)
|
||||
|
||||
trak.Media.Handler = &mp4io.HandlerRefer{
|
||||
SubType: [4]byte{'s', 'o', 'u', 'n'},
|
||||
Name: []byte{0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 'm', 'a', 'i', 'n', 0},
|
||||
}
|
||||
trak.Media.Info.Sound = &mp4io.SoundMediaInfo{}
|
||||
|
||||
trak.Media.Info.Sample.SampleDesc.MP4ADesc = &mp4io.MP4ADesc{
|
||||
DataRefIdx: 1,
|
||||
NumberOfChannels: int16(codec.Channels),
|
||||
SampleSize: int16(av.FLTP.BytesPerSample() * 4),
|
||||
SampleRate: float64(codec.ClockRate),
|
||||
Unknowns: []mp4io.Atom{ESDS(b)},
|
||||
}
|
||||
|
||||
moov.Tracks = append(moov.Tracks, trak)
|
||||
case streamer.CodecOpus, streamer.CodecMP3, streamer.CodecPCMU, streamer.CodecPCMA:
|
||||
mv.WriteAudioTrack(
|
||||
uint32(i+1), codec.Name, codec.ClockRate, codec.Channels, nil,
|
||||
)
|
||||
}
|
||||
|
||||
trex := &mp4io.TrackExtend{
|
||||
TrackId: uint32(i + 1),
|
||||
DefaultSampleDescIdx: 1,
|
||||
DefaultSampleDuration: 0,
|
||||
}
|
||||
moov.MovieExtend.Tracks = append(moov.MovieExtend.Tracks, trex)
|
||||
|
||||
m.pts = append(m.pts, 0)
|
||||
m.dts = append(m.dts, 0)
|
||||
}
|
||||
|
||||
data := make([]byte, moov.Len())
|
||||
moov.Marshal(data)
|
||||
mv.StartAtom(iso.MoovMvex)
|
||||
for i := range codecs {
|
||||
mv.WriteTrackExtend(uint32(i + 1))
|
||||
}
|
||||
mv.EndAtom() // MVEX
|
||||
|
||||
return append(FTYP(), data...), nil
|
||||
mv.EndAtom() // MOOV
|
||||
|
||||
return mv.Bytes(), nil
|
||||
}
|
||||
|
||||
func (m *Muxer) Reset() {
|
||||
@@ -192,65 +138,28 @@ func (m *Muxer) Reset() {
|
||||
}
|
||||
|
||||
func (m *Muxer) Marshal(trackID byte, packet *rtp.Packet) []byte {
|
||||
run := &mp4fio.TrackFragRun{
|
||||
Flags: 0x000b05,
|
||||
FirstSampleFlags: uint32(fmp4io.SampleNoDependencies),
|
||||
DataOffset: 0,
|
||||
Entries: []mp4io.TrackFragRunEntry{},
|
||||
}
|
||||
|
||||
moof := &mp4fio.MovieFrag{
|
||||
Header: &mp4fio.MovieFragHeader{
|
||||
Seqnum: m.fragIndex + 1,
|
||||
},
|
||||
Tracks: []*mp4fio.TrackFrag{
|
||||
{
|
||||
Header: &mp4fio.TrackFragHeader{
|
||||
Data: []byte{0x00, 0x02, 0x00, 0x20, 0x00, 0x00, 0x00, trackID + 1, 0x01, 0x01, 0x00, 0x00},
|
||||
},
|
||||
DecodeTime: &mp4fio.TrackFragDecodeTime{
|
||||
Version: 1,
|
||||
Flags: 0,
|
||||
Time: m.dts[trackID],
|
||||
},
|
||||
Run: run,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
entry := mp4io.TrackFragRunEntry{
|
||||
Size: uint32(len(packet.Payload)),
|
||||
}
|
||||
|
||||
newTime := packet.Timestamp
|
||||
if m.pts[trackID] > 0 {
|
||||
entry.Duration = newTime - m.pts[trackID]
|
||||
m.dts[trackID] += uint64(entry.Duration)
|
||||
} else {
|
||||
// important, or Safari will fail with first frame
|
||||
entry.Duration = 1
|
||||
}
|
||||
m.pts[trackID] = newTime
|
||||
|
||||
// important before moof.Len()
|
||||
run.Entries = append(run.Entries, entry)
|
||||
|
||||
moofLen := moof.Len()
|
||||
mdatLen := 8 + len(packet.Payload)
|
||||
|
||||
// important after moof.Len()
|
||||
run.DataOffset = uint32(moofLen + 8)
|
||||
|
||||
buf := make([]byte, moofLen+mdatLen)
|
||||
moof.Marshal(buf)
|
||||
|
||||
binary.BigEndian.PutUint32(buf[moofLen:], uint32(mdatLen))
|
||||
copy(buf[moofLen+4:], "mdat")
|
||||
copy(buf[moofLen+8:], packet.Payload)
|
||||
// important before increment
|
||||
time := m.dts[trackID]
|
||||
|
||||
m.fragIndex++
|
||||
|
||||
//m.total += moofLen + mdatLen
|
||||
var duration uint32
|
||||
newTime := packet.Timestamp
|
||||
if m.pts[trackID] > 0 {
|
||||
duration = newTime - m.pts[trackID]
|
||||
m.dts[trackID] += uint64(duration)
|
||||
} else {
|
||||
// important, or Safari will fail with first frame
|
||||
duration = 1
|
||||
}
|
||||
m.pts[trackID] = newTime
|
||||
|
||||
return buf
|
||||
mv := iso.NewMovie(1024 + len(packet.Payload))
|
||||
mv.WriteMovieFragment(
|
||||
m.fragIndex, uint32(trackID+1), duration,
|
||||
uint32(len(packet.Payload)), time,
|
||||
)
|
||||
mv.WriteData(packet.Payload)
|
||||
|
||||
return mv.Bytes()
|
||||
}
|
||||
|
||||
@@ -1,18 +1,25 @@
|
||||
package mp4
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"github.com/AlexxIT/go2rtc/pkg/h264"
|
||||
"github.com/AlexxIT/go2rtc/pkg/h265"
|
||||
"github.com/AlexxIT/go2rtc/pkg/streamer"
|
||||
"github.com/pion/rtp"
|
||||
"sync/atomic"
|
||||
)
|
||||
|
||||
type Segment struct {
|
||||
streamer.Element
|
||||
|
||||
Medias []*streamer.Media
|
||||
Medias []*streamer.Media
|
||||
UserAgent string
|
||||
RemoteAddr string
|
||||
|
||||
MimeType string
|
||||
OnlyKeyframe bool
|
||||
|
||||
send uint32
|
||||
}
|
||||
|
||||
func (c *Segment) GetMedias() []*streamer.Media {
|
||||
@@ -43,7 +50,7 @@ func (c *Segment) AddTrack(media *streamer.Media, track *streamer.Track) *stream
|
||||
return nil
|
||||
}
|
||||
|
||||
c.MimeType = muxer.MimeType(codecs)
|
||||
c.MimeType = `video/mp4; codecs="` + muxer.MimeCodecs(codecs) + `"`
|
||||
|
||||
switch track.Codec.Name {
|
||||
case streamer.CodecH264:
|
||||
@@ -56,6 +63,7 @@ func (c *Segment) AddTrack(media *streamer.Media, track *streamer.Track) *stream
|
||||
}
|
||||
|
||||
buf := muxer.Marshal(0, packet)
|
||||
atomic.AddUint32(&c.send, uint32(len(buf)))
|
||||
c.Fire(append(init, buf...))
|
||||
|
||||
return nil
|
||||
@@ -73,6 +81,7 @@ func (c *Segment) AddTrack(media *streamer.Media, track *streamer.Track) *stream
|
||||
buf = append(buf, b...)
|
||||
}
|
||||
|
||||
atomic.AddUint32(&c.send, uint32(len(buf)))
|
||||
c.Fire(buf)
|
||||
|
||||
buf = buf[:0]
|
||||
@@ -106,6 +115,7 @@ func (c *Segment) AddTrack(media *streamer.Media, track *streamer.Track) *stream
|
||||
}
|
||||
|
||||
buf := muxer.Marshal(0, packet)
|
||||
atomic.AddUint32(&c.send, uint32(len(buf)))
|
||||
c.Fire(append(init, buf...))
|
||||
|
||||
return nil
|
||||
@@ -121,3 +131,13 @@ func (c *Segment) AddTrack(media *streamer.Media, track *streamer.Track) *stream
|
||||
|
||||
panic("unsupported codec")
|
||||
}
|
||||
|
||||
func (c *Segment) MarshalJSON() ([]byte, error) {
|
||||
info := &streamer.Info{
|
||||
Type: "WS/MP4 client",
|
||||
RemoteAddr: c.RemoteAddr,
|
||||
UserAgent: c.UserAgent,
|
||||
Send: atomic.LoadUint32(&c.send),
|
||||
}
|
||||
return json.Marshal(info)
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
package mp4f
|
||||
package mp4
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"encoding/hex"
|
||||
"github.com/AlexxIT/go2rtc/pkg/aac"
|
||||
"github.com/AlexxIT/go2rtc/pkg/h264"
|
||||
"github.com/AlexxIT/go2rtc/pkg/streamer"
|
||||
"github.com/deepch/vdk/av"
|
||||
@@ -15,6 +16,7 @@ import (
|
||||
type Consumer struct {
|
||||
streamer.Element
|
||||
|
||||
Medias []*streamer.Media
|
||||
UserAgent string
|
||||
RemoteAddr string
|
||||
|
||||
@@ -27,6 +29,10 @@ type Consumer struct {
|
||||
}
|
||||
|
||||
func (c *Consumer) GetMedias() []*streamer.Media {
|
||||
if c.Medias != nil {
|
||||
return c.Medias
|
||||
}
|
||||
|
||||
return []*streamer.Media{
|
||||
{
|
||||
Kind: streamer.KindVideo,
|
||||
@@ -97,7 +103,17 @@ func (c *Consumer) AddTrack(media *streamer.Media, track *streamer.Track) *strea
|
||||
return track.Bind(push)
|
||||
|
||||
case streamer.CodecAAC:
|
||||
stream, _ := aacparser.NewCodecDataFromMPEG4AudioConfigBytes([]byte{20, 8})
|
||||
s := streamer.Between(codec.FmtpLine, "config=", ";")
|
||||
|
||||
b, err := hex.DecodeString(s)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
stream, err := aacparser.NewCodecDataFromMPEG4AudioConfigBytes(b)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
c.mimeType += ",mp4a.40.2"
|
||||
c.streams = append(c.streams, stream)
|
||||
@@ -127,6 +143,11 @@ func (c *Consumer) AddTrack(media *streamer.Media, track *streamer.Track) *strea
|
||||
return nil
|
||||
}
|
||||
|
||||
if codec.IsRTP() {
|
||||
wrapper := aac.RTPDepay(track)
|
||||
push = wrapper(push)
|
||||
}
|
||||
|
||||
return track.Bind(push)
|
||||
}
|
||||
|
||||
@@ -149,16 +170,3 @@ func (c *Consumer) Init() ([]byte, error) {
|
||||
func (c *Consumer) Start() {
|
||||
c.start = true
|
||||
}
|
||||
|
||||
//
|
||||
|
||||
func (c *Consumer) MarshalJSON() ([]byte, error) {
|
||||
v := map[string]interface{}{
|
||||
"type": "MSE server consumer",
|
||||
"send": c.send,
|
||||
"remote_addr": c.RemoteAddr,
|
||||
"user_agent": c.UserAgent,
|
||||
}
|
||||
|
||||
return json.Marshal(v)
|
||||
}
|
||||
174
pkg/mp4/v2/consumer.go
Normal file
174
pkg/mp4/v2/consumer.go
Normal file
@@ -0,0 +1,174 @@
|
||||
package mp4
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"github.com/AlexxIT/go2rtc/pkg/aac"
|
||||
"github.com/AlexxIT/go2rtc/pkg/h264"
|
||||
"github.com/AlexxIT/go2rtc/pkg/h265"
|
||||
"github.com/AlexxIT/go2rtc/pkg/streamer"
|
||||
"github.com/pion/rtp"
|
||||
"sync/atomic"
|
||||
)
|
||||
|
||||
type Consumer struct {
|
||||
streamer.Element
|
||||
|
||||
Medias []*streamer.Media
|
||||
UserAgent string
|
||||
RemoteAddr string
|
||||
|
||||
muxer *Muxer
|
||||
codecs []*streamer.Codec
|
||||
wait byte
|
||||
|
||||
send uint32
|
||||
}
|
||||
|
||||
const (
|
||||
waitNone byte = iota
|
||||
waitKeyframe
|
||||
waitInit
|
||||
)
|
||||
|
||||
func (c *Consumer) GetMedias() []*streamer.Media {
|
||||
if c.Medias != nil {
|
||||
return c.Medias
|
||||
}
|
||||
|
||||
// default medias
|
||||
return []*streamer.Media{
|
||||
{
|
||||
Kind: streamer.KindVideo,
|
||||
Direction: streamer.DirectionRecvonly,
|
||||
Codecs: []*streamer.Codec{
|
||||
{Name: streamer.CodecH264},
|
||||
{Name: streamer.CodecH265},
|
||||
},
|
||||
},
|
||||
{
|
||||
Kind: streamer.KindAudio,
|
||||
Direction: streamer.DirectionRecvonly,
|
||||
Codecs: []*streamer.Codec{
|
||||
{Name: streamer.CodecAAC},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Consumer) AddTrack(media *streamer.Media, track *streamer.Track) *streamer.Track {
|
||||
trackID := byte(len(c.codecs))
|
||||
c.codecs = append(c.codecs, track.Codec)
|
||||
|
||||
codec := track.Codec
|
||||
switch codec.Name {
|
||||
case streamer.CodecH264:
|
||||
c.wait = waitInit
|
||||
|
||||
push := func(packet *rtp.Packet) error {
|
||||
if packet.Version != h264.RTPPacketVersionAVC {
|
||||
return nil
|
||||
}
|
||||
|
||||
if c.wait != waitNone {
|
||||
if c.wait == waitInit || !h264.IsKeyframe(packet.Payload) {
|
||||
return nil
|
||||
}
|
||||
c.wait = waitNone
|
||||
}
|
||||
|
||||
buf := c.muxer.Marshal(trackID, packet)
|
||||
atomic.AddUint32(&c.send, uint32(len(buf)))
|
||||
c.Fire(buf)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
var wrapper streamer.WrapperFunc
|
||||
if codec.IsRTP() {
|
||||
wrapper = h264.RTPDepay(track)
|
||||
} else {
|
||||
wrapper = h264.RepairAVC(track)
|
||||
}
|
||||
push = wrapper(push)
|
||||
|
||||
return track.Bind(push)
|
||||
|
||||
case streamer.CodecH265:
|
||||
c.wait = waitInit
|
||||
|
||||
push := func(packet *rtp.Packet) error {
|
||||
if packet.Version != h264.RTPPacketVersionAVC {
|
||||
return nil
|
||||
}
|
||||
|
||||
if c.wait != waitNone {
|
||||
if c.wait == waitInit || !h265.IsKeyframe(packet.Payload) {
|
||||
return nil
|
||||
}
|
||||
c.wait = waitNone
|
||||
}
|
||||
|
||||
buf := c.muxer.Marshal(trackID, packet)
|
||||
atomic.AddUint32(&c.send, uint32(len(buf)))
|
||||
c.Fire(buf)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
if codec.IsRTP() {
|
||||
wrapper := h265.RTPDepay(track)
|
||||
push = wrapper(push)
|
||||
}
|
||||
|
||||
return track.Bind(push)
|
||||
|
||||
case streamer.CodecAAC:
|
||||
push := func(packet *rtp.Packet) error {
|
||||
if c.wait != waitNone {
|
||||
return nil
|
||||
}
|
||||
|
||||
buf := c.muxer.Marshal(trackID, packet)
|
||||
atomic.AddUint32(&c.send, uint32(len(buf)))
|
||||
c.Fire(buf)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
if codec.IsRTP() {
|
||||
wrapper := aac.RTPDepay(track)
|
||||
push = wrapper(push)
|
||||
}
|
||||
|
||||
return track.Bind(push)
|
||||
}
|
||||
|
||||
panic("unsupported codec")
|
||||
}
|
||||
|
||||
func (c *Consumer) MimeType() string {
|
||||
return c.muxer.MimeType(c.codecs)
|
||||
}
|
||||
|
||||
func (c *Consumer) Init() ([]byte, error) {
|
||||
c.muxer = &Muxer{}
|
||||
return c.muxer.GetInit(c.codecs)
|
||||
}
|
||||
|
||||
func (c *Consumer) Start() {
|
||||
if c.wait == waitInit {
|
||||
c.wait = waitKeyframe
|
||||
}
|
||||
}
|
||||
|
||||
//
|
||||
|
||||
func (c *Consumer) MarshalJSON() ([]byte, error) {
|
||||
info := &streamer.Info{
|
||||
Type: "MP4 client",
|
||||
RemoteAddr: c.RemoteAddr,
|
||||
UserAgent: c.UserAgent,
|
||||
Send: atomic.LoadUint32(&c.send),
|
||||
}
|
||||
return json.Marshal(info)
|
||||
}
|
||||
256
pkg/mp4/v2/muxer.go
Normal file
256
pkg/mp4/v2/muxer.go
Normal file
@@ -0,0 +1,256 @@
|
||||
package mp4
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"encoding/hex"
|
||||
"github.com/AlexxIT/go2rtc/pkg/h264"
|
||||
"github.com/AlexxIT/go2rtc/pkg/h265"
|
||||
"github.com/AlexxIT/go2rtc/pkg/streamer"
|
||||
"github.com/deepch/vdk/av"
|
||||
"github.com/deepch/vdk/codec/h264parser"
|
||||
"github.com/deepch/vdk/codec/h265parser"
|
||||
"github.com/deepch/vdk/format/fmp4/fmp4io"
|
||||
"github.com/deepch/vdk/format/mp4/mp4io"
|
||||
"github.com/deepch/vdk/format/mp4f/mp4fio"
|
||||
"github.com/pion/rtp"
|
||||
)
|
||||
|
||||
type Muxer struct {
|
||||
fragIndex uint32
|
||||
dts []uint64
|
||||
pts []uint32
|
||||
}
|
||||
|
||||
func (m *Muxer) MimeType(codecs []*streamer.Codec) string {
|
||||
s := `video/mp4; codecs="`
|
||||
|
||||
for i, codec := range codecs {
|
||||
if i > 0 {
|
||||
s += ","
|
||||
}
|
||||
|
||||
switch codec.Name {
|
||||
case streamer.CodecH264:
|
||||
s += "avc1." + h264.GetProfileLevelID(codec.FmtpLine)
|
||||
case streamer.CodecH265:
|
||||
// H.265 profile=main level=5.1
|
||||
// hvc1 - supported in Safari, hev1 - doesn't, both supported in Chrome
|
||||
s += "hvc1.1.6.L153.B0"
|
||||
case streamer.CodecAAC:
|
||||
s += "mp4a.40.2"
|
||||
}
|
||||
}
|
||||
|
||||
return s + `"`
|
||||
}
|
||||
|
||||
func (m *Muxer) GetInit(codecs []*streamer.Codec) ([]byte, error) {
|
||||
moov := MOOV()
|
||||
|
||||
for i, codec := range codecs {
|
||||
switch codec.Name {
|
||||
case streamer.CodecH264:
|
||||
sps, pps := h264.GetParameterSet(codec.FmtpLine)
|
||||
if sps == nil {
|
||||
// some dummy SPS and PPS not a problem
|
||||
sps = []byte{0x67, 0x42, 0x00, 0x0a, 0xf8, 0x41, 0xa2}
|
||||
pps = []byte{0x68, 0xce, 0x38, 0x80}
|
||||
}
|
||||
|
||||
codecData, err := h264parser.NewCodecDataFromSPSAndPPS(sps, pps)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
width := codecData.Width()
|
||||
height := codecData.Height()
|
||||
|
||||
trak := TRAK(i + 1)
|
||||
trak.Header.TrackWidth = float64(width)
|
||||
trak.Header.TrackHeight = float64(height)
|
||||
trak.Media.Header.TimeScale = int32(codec.ClockRate)
|
||||
trak.Media.Handler = &mp4io.HandlerRefer{
|
||||
SubType: [4]byte{'v', 'i', 'd', 'e'},
|
||||
Name: []byte{0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 'm', 'a', 'i', 'n', 0},
|
||||
}
|
||||
trak.Media.Info.Video = &mp4io.VideoMediaInfo{
|
||||
Flags: 0x000001,
|
||||
}
|
||||
trak.Media.Info.Sample.SampleDesc.AVC1Desc = &mp4io.AVC1Desc{
|
||||
DataRefIdx: 1,
|
||||
HorizontalResolution: 72,
|
||||
VorizontalResolution: 72,
|
||||
Width: int16(width),
|
||||
Height: int16(height),
|
||||
FrameCount: 1,
|
||||
Depth: 24,
|
||||
ColorTableId: -1,
|
||||
Conf: &mp4io.AVC1Conf{
|
||||
Data: codecData.AVCDecoderConfRecordBytes(),
|
||||
},
|
||||
}
|
||||
|
||||
moov.Tracks = append(moov.Tracks, trak)
|
||||
|
||||
case streamer.CodecH265:
|
||||
vps, sps, pps := h265.GetParameterSet(codec.FmtpLine)
|
||||
if sps == nil {
|
||||
// some dummy SPS and PPS not a problem
|
||||
vps = []byte{0x40, 0x01, 0x0c, 0x01, 0xff, 0xff, 0x01, 0x40, 0x00, 0x00, 0x03, 0x00, 0x00, 0x03, 0x00, 0x00, 0x03, 0x00, 0x00, 0x03, 0x00, 0x99, 0xac, 0x09}
|
||||
sps = []byte{0x42, 0x01, 0x01, 0x01, 0x40, 0x00, 0x00, 0x03, 0x00, 0x00, 0x03, 0x00, 0x00, 0x03, 0x00, 0x00, 0x03, 0x00, 0x99, 0xa0, 0x01, 0x40, 0x20, 0x05, 0xa1, 0xfe, 0x5a, 0xee, 0x46, 0xc1, 0xae, 0x55, 0x04}
|
||||
pps = []byte{0x44, 0x01, 0xc0, 0x73, 0xc0, 0x4c, 0x90}
|
||||
}
|
||||
|
||||
codecData, err := h265parser.NewCodecDataFromVPSAndSPSAndPPS(vps, sps, pps)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
width := codecData.Width()
|
||||
height := codecData.Height()
|
||||
|
||||
trak := TRAK(i + 1)
|
||||
trak.Header.TrackWidth = float64(width)
|
||||
trak.Header.TrackHeight = float64(height)
|
||||
trak.Media.Header.TimeScale = int32(codec.ClockRate)
|
||||
trak.Media.Handler = &mp4io.HandlerRefer{
|
||||
SubType: [4]byte{'v', 'i', 'd', 'e'},
|
||||
Name: []byte{0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 'm', 'a', 'i', 'n', 0},
|
||||
}
|
||||
trak.Media.Info.Video = &mp4io.VideoMediaInfo{
|
||||
Flags: 0x000001,
|
||||
}
|
||||
trak.Media.Info.Sample.SampleDesc.HV1Desc = &mp4io.HV1Desc{
|
||||
DataRefIdx: 1,
|
||||
HorizontalResolution: 72,
|
||||
VorizontalResolution: 72,
|
||||
Width: int16(width),
|
||||
Height: int16(height),
|
||||
FrameCount: 1,
|
||||
Depth: 24,
|
||||
ColorTableId: -1,
|
||||
Conf: &mp4io.HV1Conf{
|
||||
Data: codecData.AVCDecoderConfRecordBytes(),
|
||||
},
|
||||
}
|
||||
|
||||
moov.Tracks = append(moov.Tracks, trak)
|
||||
|
||||
case streamer.CodecAAC:
|
||||
s := streamer.Between(codec.FmtpLine, "config=", ";")
|
||||
b, err := hex.DecodeString(s)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
trak := TRAK(i + 1)
|
||||
trak.Header.AlternateGroup = 1
|
||||
trak.Header.Duration = 0
|
||||
trak.Header.Volume = 1
|
||||
trak.Media.Header.TimeScale = int32(codec.ClockRate)
|
||||
|
||||
trak.Media.Handler = &mp4io.HandlerRefer{
|
||||
SubType: [4]byte{'s', 'o', 'u', 'n'},
|
||||
Name: []byte{0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 'm', 'a', 'i', 'n', 0},
|
||||
}
|
||||
trak.Media.Info.Sound = &mp4io.SoundMediaInfo{}
|
||||
|
||||
trak.Media.Info.Sample.SampleDesc.MP4ADesc = &mp4io.MP4ADesc{
|
||||
DataRefIdx: 1,
|
||||
NumberOfChannels: int16(codec.Channels),
|
||||
SampleSize: int16(av.FLTP.BytesPerSample() * 4),
|
||||
SampleRate: float64(codec.ClockRate),
|
||||
Unknowns: []mp4io.Atom{ESDS(b)},
|
||||
}
|
||||
|
||||
moov.Tracks = append(moov.Tracks, trak)
|
||||
}
|
||||
|
||||
trex := &mp4io.TrackExtend{
|
||||
TrackId: uint32(i + 1),
|
||||
DefaultSampleDescIdx: 1,
|
||||
DefaultSampleDuration: 0,
|
||||
}
|
||||
moov.MovieExtend.Tracks = append(moov.MovieExtend.Tracks, trex)
|
||||
|
||||
m.pts = append(m.pts, 0)
|
||||
m.dts = append(m.dts, 0)
|
||||
}
|
||||
|
||||
data := make([]byte, moov.Len())
|
||||
moov.Marshal(data)
|
||||
|
||||
return append(FTYP(), data...), nil
|
||||
}
|
||||
|
||||
func (m *Muxer) Reset() {
|
||||
m.fragIndex = 0
|
||||
for i := range m.dts {
|
||||
m.dts[i] = 0
|
||||
m.pts[i] = 0
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Muxer) Marshal(trackID byte, packet *rtp.Packet) []byte {
|
||||
run := &mp4fio.TrackFragRun{
|
||||
Flags: 0x000b05,
|
||||
FirstSampleFlags: uint32(fmp4io.SampleNoDependencies),
|
||||
DataOffset: 0,
|
||||
Entries: []mp4io.TrackFragRunEntry{},
|
||||
}
|
||||
|
||||
moof := &mp4fio.MovieFrag{
|
||||
Header: &mp4fio.MovieFragHeader{
|
||||
Seqnum: m.fragIndex + 1,
|
||||
},
|
||||
Tracks: []*mp4fio.TrackFrag{
|
||||
{
|
||||
Header: &mp4fio.TrackFragHeader{
|
||||
Data: []byte{0x00, 0x02, 0x00, 0x20, 0x00, 0x00, 0x00, trackID + 1, 0x01, 0x01, 0x00, 0x00},
|
||||
},
|
||||
DecodeTime: &mp4fio.TrackFragDecodeTime{
|
||||
Version: 1,
|
||||
Flags: 0,
|
||||
Time: m.dts[trackID],
|
||||
},
|
||||
Run: run,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
entry := mp4io.TrackFragRunEntry{
|
||||
Size: uint32(len(packet.Payload)),
|
||||
}
|
||||
|
||||
newTime := packet.Timestamp
|
||||
if m.pts[trackID] > 0 {
|
||||
entry.Duration = newTime - m.pts[trackID]
|
||||
m.dts[trackID] += uint64(entry.Duration)
|
||||
} else {
|
||||
// important, or Safari will fail with first frame
|
||||
entry.Duration = 1
|
||||
}
|
||||
m.pts[trackID] = newTime
|
||||
|
||||
// important before moof.Len()
|
||||
run.Entries = append(run.Entries, entry)
|
||||
|
||||
moofLen := moof.Len()
|
||||
mdatLen := 8 + len(packet.Payload)
|
||||
|
||||
// important after moof.Len()
|
||||
run.DataOffset = uint32(moofLen + 8)
|
||||
|
||||
buf := make([]byte, moofLen+mdatLen)
|
||||
moof.Marshal(buf)
|
||||
|
||||
binary.BigEndian.PutUint32(buf[moofLen:], uint32(mdatLen))
|
||||
copy(buf[moofLen+4:], "mdat")
|
||||
copy(buf[moofLen+8:], packet.Payload)
|
||||
|
||||
m.fragIndex++
|
||||
|
||||
//m.total += moofLen + mdatLen
|
||||
|
||||
return buf
|
||||
}
|
||||
143
pkg/mp4/v2/segment.go
Normal file
143
pkg/mp4/v2/segment.go
Normal file
@@ -0,0 +1,143 @@
|
||||
package mp4
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"github.com/AlexxIT/go2rtc/pkg/h264"
|
||||
"github.com/AlexxIT/go2rtc/pkg/h265"
|
||||
"github.com/AlexxIT/go2rtc/pkg/streamer"
|
||||
"github.com/pion/rtp"
|
||||
"sync/atomic"
|
||||
)
|
||||
|
||||
type Segment struct {
|
||||
streamer.Element
|
||||
|
||||
Medias []*streamer.Media
|
||||
UserAgent string
|
||||
RemoteAddr string
|
||||
|
||||
MimeType string
|
||||
OnlyKeyframe bool
|
||||
|
||||
send uint32
|
||||
}
|
||||
|
||||
func (c *Segment) GetMedias() []*streamer.Media {
|
||||
if c.Medias != nil {
|
||||
return c.Medias
|
||||
}
|
||||
|
||||
// default medias
|
||||
return []*streamer.Media{
|
||||
{
|
||||
Kind: streamer.KindVideo,
|
||||
Direction: streamer.DirectionRecvonly,
|
||||
Codecs: []*streamer.Codec{
|
||||
{Name: streamer.CodecH264},
|
||||
{Name: streamer.CodecH265},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Segment) AddTrack(media *streamer.Media, track *streamer.Track) *streamer.Track {
|
||||
muxer := &Muxer{}
|
||||
|
||||
codecs := []*streamer.Codec{track.Codec}
|
||||
|
||||
init, err := muxer.GetInit(codecs)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
c.MimeType = muxer.MimeType(codecs)
|
||||
|
||||
switch track.Codec.Name {
|
||||
case streamer.CodecH264:
|
||||
var push streamer.WriterFunc
|
||||
|
||||
if c.OnlyKeyframe {
|
||||
push = func(packet *rtp.Packet) error {
|
||||
if !h264.IsKeyframe(packet.Payload) {
|
||||
return nil
|
||||
}
|
||||
|
||||
buf := muxer.Marshal(0, packet)
|
||||
atomic.AddUint32(&c.send, uint32(len(buf)))
|
||||
c.Fire(append(init, buf...))
|
||||
|
||||
return nil
|
||||
}
|
||||
} else {
|
||||
var buf []byte
|
||||
|
||||
push = func(packet *rtp.Packet) error {
|
||||
if h264.IsKeyframe(packet.Payload) {
|
||||
// fist frame - send only IFrame
|
||||
// other frames - send IFrame and all PFrames
|
||||
if buf == nil {
|
||||
buf = append(buf, init...)
|
||||
b := muxer.Marshal(0, packet)
|
||||
buf = append(buf, b...)
|
||||
}
|
||||
|
||||
atomic.AddUint32(&c.send, uint32(len(buf)))
|
||||
c.Fire(buf)
|
||||
|
||||
buf = buf[:0]
|
||||
buf = append(buf, init...)
|
||||
muxer.Reset()
|
||||
}
|
||||
|
||||
if buf != nil {
|
||||
b := muxer.Marshal(0, packet)
|
||||
buf = append(buf, b...)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
var wrapper streamer.WrapperFunc
|
||||
if track.Codec.IsRTP() {
|
||||
wrapper = h264.RTPDepay(track)
|
||||
} else {
|
||||
wrapper = h264.RepairAVC(track)
|
||||
}
|
||||
push = wrapper(push)
|
||||
|
||||
return track.Bind(push)
|
||||
|
||||
case streamer.CodecH265:
|
||||
push := func(packet *rtp.Packet) error {
|
||||
if !h265.IsKeyframe(packet.Payload) {
|
||||
return nil
|
||||
}
|
||||
|
||||
buf := muxer.Marshal(0, packet)
|
||||
atomic.AddUint32(&c.send, uint32(len(buf)))
|
||||
c.Fire(append(init, buf...))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
if track.Codec.IsRTP() {
|
||||
wrapper := h265.RTPDepay(track)
|
||||
push = wrapper(push)
|
||||
}
|
||||
|
||||
return track.Bind(push)
|
||||
}
|
||||
|
||||
panic("unsupported codec")
|
||||
}
|
||||
|
||||
func (c *Segment) MarshalJSON() ([]byte, error) {
|
||||
info := &streamer.Info{
|
||||
Type: "WS/MP4 client",
|
||||
RemoteAddr: c.RemoteAddr,
|
||||
UserAgent: c.UserAgent,
|
||||
Send: atomic.LoadUint32(&c.send),
|
||||
}
|
||||
return json.Marshal(info)
|
||||
}
|
||||
5
pkg/mpegts/README.md
Normal file
5
pkg/mpegts/README.md
Normal file
@@ -0,0 +1,5 @@
|
||||
## Useful links
|
||||
|
||||
- https://github.com/theREDspace/video-onboarding/blob/main/MPEGTS%20Knowledge.md
|
||||
- https://en.wikipedia.org/wiki/MPEG_transport_stream
|
||||
- https://en.wikipedia.org/wiki/Program-specific_information
|
||||
54
pkg/mpegts/checksum.go
Normal file
54
pkg/mpegts/checksum.go
Normal file
@@ -0,0 +1,54 @@
|
||||
package mpegts
|
||||
|
||||
var ieeeCrc32Tbl = []uint32{
|
||||
0x00000000, 0xB71DC104, 0x6E3B8209, 0xD926430D, 0xDC760413, 0x6B6BC517,
|
||||
0xB24D861A, 0x0550471E, 0xB8ED0826, 0x0FF0C922, 0xD6D68A2F, 0x61CB4B2B,
|
||||
0x649B0C35, 0xD386CD31, 0x0AA08E3C, 0xBDBD4F38, 0x70DB114C, 0xC7C6D048,
|
||||
0x1EE09345, 0xA9FD5241, 0xACAD155F, 0x1BB0D45B, 0xC2969756, 0x758B5652,
|
||||
0xC836196A, 0x7F2BD86E, 0xA60D9B63, 0x11105A67, 0x14401D79, 0xA35DDC7D,
|
||||
0x7A7B9F70, 0xCD665E74, 0xE0B62398, 0x57ABE29C, 0x8E8DA191, 0x39906095,
|
||||
0x3CC0278B, 0x8BDDE68F, 0x52FBA582, 0xE5E66486, 0x585B2BBE, 0xEF46EABA,
|
||||
0x3660A9B7, 0x817D68B3, 0x842D2FAD, 0x3330EEA9, 0xEA16ADA4, 0x5D0B6CA0,
|
||||
0x906D32D4, 0x2770F3D0, 0xFE56B0DD, 0x494B71D9, 0x4C1B36C7, 0xFB06F7C3,
|
||||
0x2220B4CE, 0x953D75CA, 0x28803AF2, 0x9F9DFBF6, 0x46BBB8FB, 0xF1A679FF,
|
||||
0xF4F63EE1, 0x43EBFFE5, 0x9ACDBCE8, 0x2DD07DEC, 0x77708634, 0xC06D4730,
|
||||
0x194B043D, 0xAE56C539, 0xAB068227, 0x1C1B4323, 0xC53D002E, 0x7220C12A,
|
||||
0xCF9D8E12, 0x78804F16, 0xA1A60C1B, 0x16BBCD1F, 0x13EB8A01, 0xA4F64B05,
|
||||
0x7DD00808, 0xCACDC90C, 0x07AB9778, 0xB0B6567C, 0x69901571, 0xDE8DD475,
|
||||
0xDBDD936B, 0x6CC0526F, 0xB5E61162, 0x02FBD066, 0xBF469F5E, 0x085B5E5A,
|
||||
0xD17D1D57, 0x6660DC53, 0x63309B4D, 0xD42D5A49, 0x0D0B1944, 0xBA16D840,
|
||||
0x97C6A5AC, 0x20DB64A8, 0xF9FD27A5, 0x4EE0E6A1, 0x4BB0A1BF, 0xFCAD60BB,
|
||||
0x258B23B6, 0x9296E2B2, 0x2F2BAD8A, 0x98366C8E, 0x41102F83, 0xF60DEE87,
|
||||
0xF35DA999, 0x4440689D, 0x9D662B90, 0x2A7BEA94, 0xE71DB4E0, 0x500075E4,
|
||||
0x892636E9, 0x3E3BF7ED, 0x3B6BB0F3, 0x8C7671F7, 0x555032FA, 0xE24DF3FE,
|
||||
0x5FF0BCC6, 0xE8ED7DC2, 0x31CB3ECF, 0x86D6FFCB, 0x8386B8D5, 0x349B79D1,
|
||||
0xEDBD3ADC, 0x5AA0FBD8, 0xEEE00C69, 0x59FDCD6D, 0x80DB8E60, 0x37C64F64,
|
||||
0x3296087A, 0x858BC97E, 0x5CAD8A73, 0xEBB04B77, 0x560D044F, 0xE110C54B,
|
||||
0x38368646, 0x8F2B4742, 0x8A7B005C, 0x3D66C158, 0xE4408255, 0x535D4351,
|
||||
0x9E3B1D25, 0x2926DC21, 0xF0009F2C, 0x471D5E28, 0x424D1936, 0xF550D832,
|
||||
0x2C769B3F, 0x9B6B5A3B, 0x26D61503, 0x91CBD407, 0x48ED970A, 0xFFF0560E,
|
||||
0xFAA01110, 0x4DBDD014, 0x949B9319, 0x2386521D, 0x0E562FF1, 0xB94BEEF5,
|
||||
0x606DADF8, 0xD7706CFC, 0xD2202BE2, 0x653DEAE6, 0xBC1BA9EB, 0x0B0668EF,
|
||||
0xB6BB27D7, 0x01A6E6D3, 0xD880A5DE, 0x6F9D64DA, 0x6ACD23C4, 0xDDD0E2C0,
|
||||
0x04F6A1CD, 0xB3EB60C9, 0x7E8D3EBD, 0xC990FFB9, 0x10B6BCB4, 0xA7AB7DB0,
|
||||
0xA2FB3AAE, 0x15E6FBAA, 0xCCC0B8A7, 0x7BDD79A3, 0xC660369B, 0x717DF79F,
|
||||
0xA85BB492, 0x1F467596, 0x1A163288, 0xAD0BF38C, 0x742DB081, 0xC3307185,
|
||||
0x99908A5D, 0x2E8D4B59, 0xF7AB0854, 0x40B6C950, 0x45E68E4E, 0xF2FB4F4A,
|
||||
0x2BDD0C47, 0x9CC0CD43, 0x217D827B, 0x9660437F, 0x4F460072, 0xF85BC176,
|
||||
0xFD0B8668, 0x4A16476C, 0x93300461, 0x242DC565, 0xE94B9B11, 0x5E565A15,
|
||||
0x87701918, 0x306DD81C, 0x353D9F02, 0x82205E06, 0x5B061D0B, 0xEC1BDC0F,
|
||||
0x51A69337, 0xE6BB5233, 0x3F9D113E, 0x8880D03A, 0x8DD09724, 0x3ACD5620,
|
||||
0xE3EB152D, 0x54F6D429, 0x7926A9C5, 0xCE3B68C1, 0x171D2BCC, 0xA000EAC8,
|
||||
0xA550ADD6, 0x124D6CD2, 0xCB6B2FDF, 0x7C76EEDB, 0xC1CBA1E3, 0x76D660E7,
|
||||
0xAFF023EA, 0x18EDE2EE, 0x1DBDA5F0, 0xAAA064F4, 0x738627F9, 0xC49BE6FD,
|
||||
0x09FDB889, 0xBEE0798D, 0x67C63A80, 0xD0DBFB84, 0xD58BBC9A, 0x62967D9E,
|
||||
0xBBB03E93, 0x0CADFF97, 0xB110B0AF, 0x060D71AB, 0xDF2B32A6, 0x6836F3A2,
|
||||
0x6D66B4BC, 0xDA7B75B8, 0x035D36B5, 0xB440F7B1, 0x00000001,
|
||||
}
|
||||
|
||||
func calcCRC32(crc uint32, data []byte) uint32 {
|
||||
for _, b := range data {
|
||||
crc = ieeeCrc32Tbl[b^byte(crc)] ^ (crc >> 8)
|
||||
}
|
||||
return crc
|
||||
}
|
||||
73
pkg/mpegts/client.go
Normal file
73
pkg/mpegts/client.go
Normal file
@@ -0,0 +1,73 @@
|
||||
package mpegts
|
||||
|
||||
import (
|
||||
"github.com/AlexxIT/go2rtc/pkg/streamer"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
type Client struct {
|
||||
streamer.Element
|
||||
|
||||
medias []*streamer.Media
|
||||
tracks map[byte]*streamer.Track
|
||||
|
||||
res *http.Response
|
||||
}
|
||||
|
||||
func NewClient(res *http.Response) *Client {
|
||||
return &Client{res: res}
|
||||
}
|
||||
|
||||
func (c *Client) Handle() error {
|
||||
if c.tracks == nil {
|
||||
c.tracks = map[byte]*streamer.Track{}
|
||||
}
|
||||
|
||||
reader := NewReader()
|
||||
|
||||
b := make([]byte, 1024*1024*256) // 256K
|
||||
|
||||
probe := streamer.NewProbe(c.medias == nil)
|
||||
for probe == nil || probe.Active() {
|
||||
n, err := c.res.Body.Read(b)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
reader.AppendBuffer(b[:n])
|
||||
|
||||
for {
|
||||
packet := reader.GetPacket()
|
||||
if packet == nil {
|
||||
break
|
||||
}
|
||||
|
||||
track := c.tracks[packet.PayloadType]
|
||||
if track == nil {
|
||||
// count track on probe state even if not support it
|
||||
probe.Append(packet.PayloadType)
|
||||
|
||||
media := GetMedia(packet)
|
||||
if media == nil {
|
||||
continue // unsupported codec
|
||||
}
|
||||
|
||||
track = streamer.NewTrack2(media, nil)
|
||||
|
||||
c.medias = append(c.medias, media)
|
||||
c.tracks[packet.PayloadType] = track
|
||||
}
|
||||
|
||||
_ = track.WriteRTP(packet)
|
||||
|
||||
//log.Printf("[AVC] %v, len: %d, pts: %d ts: %10d", h264.Types(packet.Payload), len(packet.Payload), pkt.PTS, packet.Timestamp)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Client) Close() error {
|
||||
_ = c.res.Body.Close()
|
||||
return nil
|
||||
}
|
||||
251
pkg/mpegts/helpers.go
Normal file
251
pkg/mpegts/helpers.go
Normal file
@@ -0,0 +1,251 @@
|
||||
package mpegts
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"github.com/AlexxIT/go2rtc/pkg/h264"
|
||||
"github.com/AlexxIT/go2rtc/pkg/streamer"
|
||||
"github.com/pion/rtp"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
PacketSize = 188
|
||||
SyncByte = 0x47
|
||||
)
|
||||
|
||||
const (
|
||||
StreamTypePrivate = 0x06 // PCMU or PCMA from FFmpeg
|
||||
StreamTypeAAC = 0x0F
|
||||
StreamTypeH264 = 0x1B
|
||||
StreamTypePCMATapo = 0x90
|
||||
)
|
||||
|
||||
type Packet struct {
|
||||
StreamType byte
|
||||
PTS time.Duration
|
||||
DTS time.Duration
|
||||
Payload []byte
|
||||
}
|
||||
|
||||
// PES - Packetized Elementary Stream
|
||||
type PES struct {
|
||||
StreamType byte
|
||||
StreamID byte
|
||||
Payload []byte
|
||||
Mode byte
|
||||
Size int
|
||||
|
||||
Sequence uint16
|
||||
Timestamp uint32
|
||||
}
|
||||
|
||||
const (
|
||||
ModeUnknown = iota
|
||||
ModeSize
|
||||
ModeStream
|
||||
)
|
||||
|
||||
// parse Optional PES header
|
||||
const minHeaderSize = 3
|
||||
|
||||
func (p *PES) SetBuffer(size uint16, b []byte) {
|
||||
if size == 0 {
|
||||
optSize := b[2] // optional fields
|
||||
b = b[minHeaderSize+optSize:]
|
||||
|
||||
if p.StreamType == StreamTypeH264 {
|
||||
if bytes.HasPrefix(b, []byte{0, 0, 0, 1, h264.NALUTypeAUD}) {
|
||||
p.Mode = ModeStream
|
||||
b = b[5:]
|
||||
}
|
||||
}
|
||||
|
||||
if p.Mode == ModeUnknown {
|
||||
println("WARNING: mpegts: unknown zero-size stream")
|
||||
}
|
||||
} else {
|
||||
p.Mode = ModeSize
|
||||
p.Size = int(size)
|
||||
}
|
||||
|
||||
p.Payload = make([]byte, 0, size)
|
||||
p.Payload = append(p.Payload, b...)
|
||||
}
|
||||
|
||||
func (p *PES) AppendBuffer(b []byte) {
|
||||
p.Payload = append(p.Payload, b...)
|
||||
}
|
||||
|
||||
func (p *PES) GetPacket() (pkt *rtp.Packet) {
|
||||
switch p.Mode {
|
||||
case ModeSize:
|
||||
left := p.Size - len(p.Payload)
|
||||
if left > 0 {
|
||||
return
|
||||
}
|
||||
|
||||
if left < 0 {
|
||||
println("WARNING: mpegts: buffer overflow")
|
||||
p.Payload = nil
|
||||
return
|
||||
}
|
||||
|
||||
// fist byte also flags
|
||||
flags := p.Payload[1]
|
||||
optSize := p.Payload[2] // optional fields
|
||||
|
||||
payload := p.Payload[minHeaderSize+optSize:]
|
||||
|
||||
switch p.StreamType {
|
||||
case StreamTypeH264:
|
||||
var ts uint32
|
||||
|
||||
const hasPTS = 0b1000_0000
|
||||
if flags&hasPTS != 0 {
|
||||
ts = ParseTime(p.Payload[minHeaderSize:])
|
||||
}
|
||||
|
||||
pkt = &rtp.Packet{
|
||||
Header: rtp.Header{
|
||||
PayloadType: p.StreamType,
|
||||
Timestamp: ts,
|
||||
},
|
||||
Payload: h264.AnnexB2AVC(payload),
|
||||
}
|
||||
|
||||
case StreamTypePCMATapo:
|
||||
p.Sequence++
|
||||
p.Timestamp += uint32(len(payload))
|
||||
|
||||
pkt = &rtp.Packet{
|
||||
Header: rtp.Header{
|
||||
Version: 2,
|
||||
PayloadType: p.StreamType,
|
||||
SequenceNumber: p.Sequence,
|
||||
Timestamp: p.Timestamp,
|
||||
},
|
||||
Payload: payload,
|
||||
}
|
||||
}
|
||||
|
||||
p.Payload = nil
|
||||
|
||||
case ModeStream:
|
||||
i := bytes.Index(p.Payload, []byte{0, 0, 0, 1, h264.NALUTypeAUD})
|
||||
if i < 0 {
|
||||
return
|
||||
}
|
||||
if i2 := IndexFrom(p.Payload, []byte{0, 0, 1}, i); i2 < 0 && i2 > 9 {
|
||||
return
|
||||
}
|
||||
|
||||
pkt = &rtp.Packet{
|
||||
Header: rtp.Header{
|
||||
PayloadType: p.StreamType,
|
||||
Timestamp: uint32(time.Duration(time.Now().UnixNano()) * 90000 / time.Second),
|
||||
},
|
||||
Payload: DecodeAnnex3B(p.Payload[:i]),
|
||||
}
|
||||
|
||||
p.Payload = p.Payload[i+5:]
|
||||
|
||||
default:
|
||||
p.Payload = nil
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func ParseTime(b []byte) uint32 {
|
||||
return (uint32(b[0]&0x0E) << 29) | (uint32(b[1]) << 22) | (uint32(b[2]&0xFE) << 14) | (uint32(b[3]) << 7) | (uint32(b[4]) >> 1)
|
||||
}
|
||||
|
||||
func GetMedia(pkt *rtp.Packet) *streamer.Media {
|
||||
var codec *streamer.Codec
|
||||
var kind string
|
||||
|
||||
switch pkt.PayloadType {
|
||||
case StreamTypeH264:
|
||||
codec = &streamer.Codec{
|
||||
Name: streamer.CodecH264,
|
||||
ClockRate: 90000,
|
||||
PayloadType: streamer.PayloadTypeRAW,
|
||||
FmtpLine: h264.GetFmtpLine(pkt.Payload),
|
||||
}
|
||||
kind = streamer.KindVideo
|
||||
|
||||
case StreamTypePCMATapo:
|
||||
codec = &streamer.Codec{
|
||||
Name: streamer.CodecPCMA,
|
||||
ClockRate: 8000,
|
||||
}
|
||||
kind = streamer.KindAudio
|
||||
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
|
||||
return &streamer.Media{
|
||||
Kind: kind,
|
||||
Direction: streamer.DirectionSendonly,
|
||||
Codecs: []*streamer.Codec{codec},
|
||||
}
|
||||
}
|
||||
|
||||
func DecodeAnnex3B(annexb []byte) (avc []byte) {
|
||||
// depends on AU delimeter size
|
||||
i0 := bytes.Index(annexb, []byte{0, 0, 1})
|
||||
if i0 < 0 || i0 > 9 {
|
||||
return nil
|
||||
}
|
||||
|
||||
annexb = annexb[i0+3:] // skip first separator
|
||||
i0 = 0
|
||||
|
||||
for {
|
||||
// search next separato
|
||||
iN := IndexFrom(annexb, []byte{0, 0, 1}, i0)
|
||||
if iN < 0 {
|
||||
break
|
||||
}
|
||||
|
||||
// move i0 to next AU
|
||||
if i0 = iN + 3; i0 >= len(annexb) {
|
||||
break
|
||||
}
|
||||
|
||||
// check if AU type valid
|
||||
octet := annexb[i0]
|
||||
const forbiddenZeroBit = 0x80
|
||||
if octet&forbiddenZeroBit == 0 {
|
||||
const nalUnitType = 0x1F
|
||||
switch octet & nalUnitType {
|
||||
case h264.NALUTypePFrame, h264.NALUTypeIFrame, h264.NALUTypeSPS, h264.NALUTypePPS:
|
||||
// add AU in AVC format
|
||||
avc = append(avc, byte(iN>>24), byte(iN>>16), byte(iN>>8), byte(iN))
|
||||
avc = append(avc, annexb[:iN]...)
|
||||
|
||||
// cut search to next AU start
|
||||
annexb = annexb[i0:]
|
||||
i0 = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
size := len(annexb)
|
||||
avc = append(avc, byte(size>>24), byte(size>>16), byte(size>>8), byte(size))
|
||||
return append(avc, annexb...)
|
||||
}
|
||||
|
||||
func IndexFrom(b []byte, sep []byte, from int) int {
|
||||
if from > 0 {
|
||||
if from < len(b) {
|
||||
if i := bytes.Index(b[from:], sep); i >= 0 {
|
||||
return from + i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
return bytes.Index(b, sep)
|
||||
}
|
||||
14
pkg/mpegts/mpegts_test.go
Normal file
14
pkg/mpegts/mpegts_test.go
Normal file
@@ -0,0 +1,14 @@
|
||||
package mpegts
|
||||
|
||||
import (
|
||||
"github.com/stretchr/testify/assert"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestTime(t *testing.T) {
|
||||
w := NewWriter()
|
||||
w.WriteTime(0xFFFFFFFF)
|
||||
assert.Equal(t, []byte{0x27, 0xFF, 0xFF, 0xFF, 0xFF}, w.Bytes())
|
||||
ts := ParseTime(w.Bytes())
|
||||
assert.Equal(t, uint32(0xFFFFFFFF), ts)
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
package ivideon
|
||||
package mpegts
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/AlexxIT/go2rtc/pkg/streamer"
|
||||
)
|
||||
|
||||
@@ -15,15 +14,11 @@ func (c *Client) GetTrack(media *streamer.Media, codec *streamer.Codec) *streame
|
||||
return track
|
||||
}
|
||||
}
|
||||
panic(fmt.Sprintf("wrong media/codec: %+v %+v", media, codec))
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Client) Start() error {
|
||||
err := c.Handle()
|
||||
if c.buffer == nil {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
return c.Handle()
|
||||
}
|
||||
|
||||
func (c *Client) Stop() error {
|
||||
201
pkg/mpegts/reader.go
Normal file
201
pkg/mpegts/reader.go
Normal file
@@ -0,0 +1,201 @@
|
||||
package mpegts
|
||||
|
||||
import "github.com/pion/rtp"
|
||||
|
||||
type Reader struct {
|
||||
b []byte // packets buffer
|
||||
i byte // read position
|
||||
s byte // end position
|
||||
|
||||
pmt uint16 // Program Map Table (PMT) PID
|
||||
pes map[uint16]*PES
|
||||
}
|
||||
|
||||
func NewReader() *Reader {
|
||||
return &Reader{}
|
||||
}
|
||||
|
||||
func (r *Reader) SetBuffer(b []byte) {
|
||||
r.b = b
|
||||
r.i = 0
|
||||
r.s = PacketSize
|
||||
}
|
||||
|
||||
func (r *Reader) AppendBuffer(b []byte) {
|
||||
r.b = append(r.b, b...)
|
||||
}
|
||||
|
||||
func (r *Reader) GetPacket() *rtp.Packet {
|
||||
for r.Sync() {
|
||||
r.Skip(1) // Sync byte
|
||||
|
||||
pid := r.ReadUint16() & 0x1FFF // PID
|
||||
flag := r.ReadByte() // flags...
|
||||
|
||||
const pidNullPacket = 0x1FFF
|
||||
if pid == pidNullPacket {
|
||||
continue
|
||||
}
|
||||
|
||||
const hasAdaptionField = 0b0010_0000
|
||||
if flag&hasAdaptionField != 0 {
|
||||
adSize := r.ReadByte() // Adaptation field length
|
||||
if adSize > PacketSize-6 {
|
||||
println("WARNING: mpegts: wrong adaptation size")
|
||||
continue
|
||||
}
|
||||
r.Skip(adSize)
|
||||
}
|
||||
|
||||
// PAT: Program Association Table
|
||||
const pidPAT = 0
|
||||
if pid == pidPAT {
|
||||
// already processed
|
||||
if r.pmt != 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
r.ReadPSIHeader()
|
||||
|
||||
const CRCSize = 4
|
||||
for r.Left() > CRCSize {
|
||||
pNum := r.ReadUint16()
|
||||
pPID := r.ReadUint16() & 0x1FFF
|
||||
if pNum != 0 {
|
||||
r.pmt = pPID
|
||||
}
|
||||
}
|
||||
|
||||
r.Skip(4) // CRC32
|
||||
continue
|
||||
}
|
||||
|
||||
// PMT : Program Map Table
|
||||
if pid == r.pmt {
|
||||
// already processed
|
||||
if r.pes != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
r.ReadPSIHeader()
|
||||
|
||||
pesPID := r.ReadUint16() & 0x1FFF // ? PCR PID
|
||||
pSize := r.ReadUint16() & 0x03FF // ? 0x0FFF
|
||||
r.Skip(byte(pSize))
|
||||
|
||||
r.pes = map[uint16]*PES{}
|
||||
|
||||
const CRCSize = 4
|
||||
for r.Left() > CRCSize {
|
||||
streamType := r.ReadByte()
|
||||
pesPID = r.ReadUint16() & 0x1FFF // Elementary PID
|
||||
iSize := r.ReadUint16() & 0x03FF // ? 0x0FFF
|
||||
r.Skip(byte(iSize))
|
||||
|
||||
r.pes[pesPID] = &PES{StreamType: streamType}
|
||||
}
|
||||
|
||||
r.Skip(4) // ? CRC32
|
||||
continue
|
||||
}
|
||||
|
||||
if r.pes == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
pes := r.pes[pid]
|
||||
if pes == nil {
|
||||
continue // unknown PID
|
||||
}
|
||||
|
||||
if pes.Payload == nil {
|
||||
// PES Packet start code prefix
|
||||
if r.ReadByte() != 0 || r.ReadByte() != 0 || r.ReadByte() != 1 {
|
||||
continue
|
||||
}
|
||||
|
||||
// read stream ID and total payload size
|
||||
pes.StreamID = r.ReadByte()
|
||||
pes.SetBuffer(r.ReadUint16(), r.Bytes())
|
||||
} else {
|
||||
pes.AppendBuffer(r.Bytes())
|
||||
}
|
||||
|
||||
if pkt := pes.GetPacket(); pkt != nil {
|
||||
return pkt
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Sync - search sync byte
|
||||
func (r *Reader) Sync() bool {
|
||||
// drop previous readed packet
|
||||
if r.i != 0 {
|
||||
r.b = r.b[PacketSize:]
|
||||
r.i = 0
|
||||
r.s = PacketSize
|
||||
}
|
||||
|
||||
// if packet available
|
||||
if len(r.b) < PacketSize {
|
||||
return false
|
||||
}
|
||||
|
||||
// if data starts from sync byte
|
||||
if r.b[0] == SyncByte {
|
||||
return true
|
||||
}
|
||||
|
||||
for len(r.b) >= PacketSize {
|
||||
if r.b[0] == SyncByte {
|
||||
return true
|
||||
}
|
||||
r.b = r.b[1:]
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func (r *Reader) ReadPSIHeader() {
|
||||
pointer := r.ReadByte() // Pointer field
|
||||
r.Skip(pointer) // Pointer filler bytes
|
||||
|
||||
r.Skip(1) // Table ID
|
||||
size := r.ReadUint16() & 0x03FF // Section length
|
||||
r.SetSize(byte(size))
|
||||
|
||||
r.Skip(2) // Table ID extension
|
||||
r.Skip(1) // flags...
|
||||
r.Skip(1) // Section number
|
||||
r.Skip(1) // Last section number
|
||||
}
|
||||
|
||||
func (r *Reader) Skip(i byte) {
|
||||
r.i += i
|
||||
}
|
||||
|
||||
func (r *Reader) ReadByte() byte {
|
||||
b := r.b[r.i]
|
||||
r.i++
|
||||
return b
|
||||
}
|
||||
|
||||
func (r *Reader) ReadUint16() uint16 {
|
||||
i := (uint16(r.b[r.i]) << 8) | uint16(r.b[r.i+1])
|
||||
r.i += 2
|
||||
return i
|
||||
}
|
||||
|
||||
func (r *Reader) Bytes() []byte {
|
||||
return r.b[r.i:PacketSize]
|
||||
}
|
||||
|
||||
func (r *Reader) Left() byte {
|
||||
return r.s - r.i
|
||||
}
|
||||
|
||||
func (r *Reader) SetSize(size byte) {
|
||||
r.s = r.i + size
|
||||
}
|
||||
194
pkg/mpegts/ts.go
Normal file
194
pkg/mpegts/ts.go
Normal file
@@ -0,0 +1,194 @@
|
||||
package mpegts
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/hex"
|
||||
"github.com/AlexxIT/go2rtc/pkg/aac"
|
||||
"github.com/AlexxIT/go2rtc/pkg/h264"
|
||||
"github.com/AlexxIT/go2rtc/pkg/streamer"
|
||||
"github.com/deepch/vdk/av"
|
||||
"github.com/deepch/vdk/codec/aacparser"
|
||||
"github.com/deepch/vdk/codec/h264parser"
|
||||
"github.com/deepch/vdk/format/ts"
|
||||
"github.com/pion/rtp"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Consumer struct {
|
||||
streamer.Element
|
||||
|
||||
UserAgent string
|
||||
RemoteAddr string
|
||||
|
||||
buf *bytes.Buffer
|
||||
muxer *ts.Muxer
|
||||
mimeType string
|
||||
streams []av.CodecData
|
||||
start bool
|
||||
init []byte
|
||||
|
||||
send uint32
|
||||
}
|
||||
|
||||
func (c *Consumer) GetMedias() []*streamer.Media {
|
||||
return []*streamer.Media{
|
||||
{
|
||||
Kind: streamer.KindVideo,
|
||||
Direction: streamer.DirectionRecvonly,
|
||||
Codecs: []*streamer.Codec{
|
||||
{Name: streamer.CodecH264},
|
||||
},
|
||||
},
|
||||
//{
|
||||
// Kind: streamer.KindAudio,
|
||||
// Direction: streamer.DirectionRecvonly,
|
||||
// Codecs: []*streamer.Codec{
|
||||
// {Name: streamer.CodecAAC},
|
||||
// },
|
||||
//},
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Consumer) AddTrack(media *streamer.Media, track *streamer.Track) *streamer.Track {
|
||||
codec := track.Codec
|
||||
trackID := int8(len(c.streams))
|
||||
|
||||
switch codec.Name {
|
||||
case streamer.CodecH264:
|
||||
sps, pps := h264.GetParameterSet(codec.FmtpLine)
|
||||
stream, err := h264parser.NewCodecDataFromSPSAndPPS(sps, pps)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
if len(c.mimeType) > 0 {
|
||||
c.mimeType += ","
|
||||
}
|
||||
|
||||
c.mimeType += "avc1." + h264.GetProfileLevelID(codec.FmtpLine)
|
||||
|
||||
c.streams = append(c.streams, stream)
|
||||
|
||||
pkt := av.Packet{Idx: trackID, CompositionTime: time.Millisecond}
|
||||
|
||||
ts2time := time.Second / time.Duration(codec.ClockRate)
|
||||
|
||||
push := func(packet *rtp.Packet) error {
|
||||
if packet.Version != h264.RTPPacketVersionAVC {
|
||||
return nil
|
||||
}
|
||||
|
||||
if !c.start {
|
||||
return nil
|
||||
}
|
||||
|
||||
pkt.Data = packet.Payload
|
||||
newTime := time.Duration(packet.Timestamp) * ts2time
|
||||
if pkt.Time > 0 {
|
||||
pkt.Duration = newTime - pkt.Time
|
||||
}
|
||||
pkt.Time = newTime
|
||||
|
||||
if err = c.muxer.WritePacket(pkt); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// clone bytes from buffer, so next packet won't overwrite it
|
||||
buf := append([]byte{}, c.buf.Bytes()...)
|
||||
atomic.AddUint32(&c.send, uint32(len(buf)))
|
||||
c.Fire(buf)
|
||||
|
||||
c.buf.Reset()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
if codec.IsRTP() {
|
||||
wrapper := h264.RTPDepay(track)
|
||||
push = wrapper(push)
|
||||
}
|
||||
|
||||
return track.Bind(push)
|
||||
|
||||
case streamer.CodecAAC:
|
||||
s := streamer.Between(codec.FmtpLine, "config=", ";")
|
||||
|
||||
b, err := hex.DecodeString(s)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
stream, err := aacparser.NewCodecDataFromMPEG4AudioConfigBytes(b)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
if len(c.mimeType) > 0 {
|
||||
c.mimeType += ","
|
||||
}
|
||||
|
||||
c.mimeType += "mp4a.40.2"
|
||||
c.streams = append(c.streams, stream)
|
||||
|
||||
pkt := av.Packet{Idx: trackID, CompositionTime: time.Millisecond}
|
||||
|
||||
ts2time := time.Second / time.Duration(codec.ClockRate)
|
||||
|
||||
push := func(packet *rtp.Packet) error {
|
||||
if !c.start {
|
||||
return nil
|
||||
}
|
||||
|
||||
pkt.Data = packet.Payload
|
||||
newTime := time.Duration(packet.Timestamp) * ts2time
|
||||
if pkt.Time > 0 {
|
||||
pkt.Duration = newTime - pkt.Time
|
||||
}
|
||||
pkt.Time = newTime
|
||||
|
||||
if err := c.muxer.WritePacket(pkt); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// clone bytes from buffer, so next packet won't overwrite it
|
||||
buf := append([]byte{}, c.buf.Bytes()...)
|
||||
atomic.AddUint32(&c.send, uint32(len(buf)))
|
||||
c.Fire(buf)
|
||||
|
||||
c.buf.Reset()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
if codec.IsRTP() {
|
||||
wrapper := aac.RTPDepay(track)
|
||||
push = wrapper(push)
|
||||
}
|
||||
|
||||
return track.Bind(push)
|
||||
}
|
||||
|
||||
panic("unsupported codec")
|
||||
}
|
||||
|
||||
func (c *Consumer) MimeCodecs() string {
|
||||
return c.mimeType
|
||||
}
|
||||
|
||||
func (c *Consumer) Init() ([]byte, error) {
|
||||
c.buf = bytes.NewBuffer(nil)
|
||||
c.muxer = ts.NewMuxer(c.buf)
|
||||
|
||||
// first packet will be with header, it's ok
|
||||
if err := c.muxer.WriteHeader(c.streams); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
data := append([]byte{}, c.buf.Bytes()...)
|
||||
|
||||
return data, nil
|
||||
}
|
||||
|
||||
func (c *Consumer) Start() {
|
||||
c.start = true
|
||||
}
|
||||
219
pkg/mpegts/writer.go
Normal file
219
pkg/mpegts/writer.go
Normal file
@@ -0,0 +1,219 @@
|
||||
package mpegts
|
||||
|
||||
type Writer struct {
|
||||
b []byte // packets buffer
|
||||
m int // crc start
|
||||
|
||||
pid []uint16
|
||||
counter []byte
|
||||
streamType []byte
|
||||
timestamp []uint32
|
||||
}
|
||||
|
||||
func NewWriter() *Writer {
|
||||
return &Writer{}
|
||||
}
|
||||
|
||||
func (w *Writer) AddPES(pid uint16, streamType byte) {
|
||||
w.pid = append(w.pid, pid)
|
||||
w.streamType = append(w.streamType, streamType)
|
||||
w.counter = append(w.counter, 0)
|
||||
w.timestamp = append(w.timestamp, 0)
|
||||
}
|
||||
|
||||
func (w *Writer) WriteByte(b byte) {
|
||||
w.b = append(w.b, b)
|
||||
}
|
||||
|
||||
func (w *Writer) WriteUint16(i uint16) {
|
||||
w.b = append(w.b, byte(i>>8), byte(i))
|
||||
}
|
||||
|
||||
func (w *Writer) WriteTime(t uint32) {
|
||||
const onlyPTS = 0x20
|
||||
// [>>32 <<3] [>>24 <<2] [>>16 <<2] [>>8 <<1] [<<1]
|
||||
w.b = append(w.b, onlyPTS|byte(t>>29)|1, byte(t>>22), byte(t>>14)|1, byte(t>>7), byte(t<<1)|1)
|
||||
}
|
||||
|
||||
func (w *Writer) WriteBytes(b []byte) {
|
||||
w.b = append(w.b, b...)
|
||||
}
|
||||
|
||||
func (w *Writer) MarkChecksum() {
|
||||
w.m = len(w.b)
|
||||
}
|
||||
|
||||
func (w *Writer) WriteChecksum() {
|
||||
crc := calcCRC32(0xFFFFFFFF, w.b[w.m:])
|
||||
w.b = append(w.b, byte(crc), byte(crc>>8), byte(crc>>16), byte(crc>>24))
|
||||
}
|
||||
|
||||
func (w *Writer) FinishPacket() {
|
||||
if n := len(w.b) % PacketSize; n != 0 {
|
||||
w.b = append(w.b, make([]byte, PacketSize-n)...)
|
||||
}
|
||||
}
|
||||
|
||||
func (w *Writer) Bytes() []byte {
|
||||
if len(w.b)%PacketSize != 0 {
|
||||
panic("wrong packet size")
|
||||
}
|
||||
return w.b
|
||||
}
|
||||
|
||||
func (w *Writer) Reset() {
|
||||
w.b = nil
|
||||
}
|
||||
|
||||
const isUnitStart = 0x4000
|
||||
const flagHasAdaptation = 0x20
|
||||
const flagHasPayload = 0x10
|
||||
const lenIsProgramTable = 0xB000
|
||||
const tableFlags = 0xC1
|
||||
const tableHeader = 0xE000
|
||||
const tableLength = 0xF000
|
||||
|
||||
const patPID = 0
|
||||
const patTableID = 0
|
||||
const patTableExtID = 1
|
||||
|
||||
func (w *Writer) WritePAT() {
|
||||
w.WriteByte(SyncByte)
|
||||
w.WriteUint16(isUnitStart | patPID) // PAT PID
|
||||
w.WriteByte(flagHasPayload) // flags...
|
||||
|
||||
w.WriteByte(0) // Pointer field
|
||||
|
||||
w.MarkChecksum()
|
||||
w.WriteByte(patTableID) // Table ID
|
||||
w.WriteUint16(lenIsProgramTable | 13) // Section length
|
||||
w.WriteUint16(patTableExtID) // Table ID extension
|
||||
w.WriteByte(tableFlags) // flags...
|
||||
w.WriteByte(0) // Section number
|
||||
w.WriteByte(0) // Last section number
|
||||
|
||||
w.WriteUint16(1) // Program num (usual 1)
|
||||
w.WriteUint16(tableHeader + pmtPID)
|
||||
|
||||
w.WriteChecksum()
|
||||
|
||||
w.FinishPacket()
|
||||
}
|
||||
|
||||
const pmtPID = 18
|
||||
const pmtTableID = 2
|
||||
const pmtTableExtID = 1
|
||||
|
||||
func (w *Writer) WritePMT() {
|
||||
w.WriteByte(SyncByte)
|
||||
w.WriteUint16(isUnitStart | pmtPID) // PMT PID
|
||||
w.WriteByte(flagHasPayload) // flags...
|
||||
|
||||
w.WriteByte(0) // Pointer field
|
||||
|
||||
tableLen := 13 + uint16(len(w.pid))*5
|
||||
|
||||
w.MarkChecksum()
|
||||
w.WriteByte(pmtTableID) // Table ID
|
||||
w.WriteUint16(lenIsProgramTable | tableLen) // Section length
|
||||
w.WriteUint16(pmtTableExtID) // Table ID extension
|
||||
w.WriteByte(tableFlags) // flags...
|
||||
w.WriteByte(0) // Section number
|
||||
w.WriteByte(0) // Last section number
|
||||
|
||||
w.WriteUint16(tableHeader | w.pid[0]) // PID
|
||||
w.WriteUint16(tableLength | 0) // Info length
|
||||
|
||||
for i, pid := range w.pid {
|
||||
w.WriteByte(w.streamType[i])
|
||||
w.WriteUint16(tableHeader | pid) // PID
|
||||
w.WriteUint16(tableLength | 0) // Info len
|
||||
}
|
||||
|
||||
w.WriteChecksum()
|
||||
|
||||
w.FinishPacket()
|
||||
}
|
||||
|
||||
const pesHeaderSize = PacketSize - 18
|
||||
|
||||
func (w *Writer) WritePES(pid uint16, streamID byte, payload []byte) {
|
||||
w.WriteByte(SyncByte)
|
||||
w.WriteUint16(isUnitStart | pid)
|
||||
|
||||
// check if payload lower then max first packet size
|
||||
if len(payload) < PacketSize-18 {
|
||||
w.WriteByte(flagHasAdaptation | flagHasPayload)
|
||||
|
||||
// for 183 payload will be zero
|
||||
adSize := PacketSize - 18 - 1 - byte(len(payload))
|
||||
w.WriteByte(adSize)
|
||||
w.WriteBytes(make([]byte, adSize))
|
||||
} else {
|
||||
w.WriteByte(flagHasPayload)
|
||||
}
|
||||
|
||||
w.WriteByte(0)
|
||||
w.WriteByte(0)
|
||||
w.WriteByte(1)
|
||||
|
||||
w.WriteByte(streamID)
|
||||
w.WriteUint16(uint16(8 + len(payload)))
|
||||
|
||||
w.WriteByte(0x80)
|
||||
w.WriteByte(0x80) // only PTS
|
||||
w.WriteByte(5) // optional size
|
||||
|
||||
switch w.streamType[0] {
|
||||
case StreamTypePCMATapo:
|
||||
w.timestamp[0] += uint32(len(payload) * 45 / 8)
|
||||
}
|
||||
|
||||
w.WriteTime(w.timestamp[0])
|
||||
|
||||
if len(payload) < PacketSize-18 {
|
||||
w.WriteBytes(payload)
|
||||
return
|
||||
}
|
||||
|
||||
w.WriteBytes(payload[:pesHeaderSize])
|
||||
|
||||
payload = payload[pesHeaderSize:]
|
||||
var counter byte
|
||||
|
||||
for {
|
||||
counter++
|
||||
|
||||
if len(payload) > PacketSize-4 {
|
||||
// payload more then maximum size
|
||||
w.WriteByte(SyncByte)
|
||||
w.WriteUint16(pid)
|
||||
w.WriteByte(flagHasPayload | counter&0xF)
|
||||
w.WriteBytes(payload[:PacketSize-4])
|
||||
|
||||
payload = payload[PacketSize-4:]
|
||||
} else if len(payload) == PacketSize-4 {
|
||||
// payload equal maximum size (last packet)
|
||||
w.WriteByte(SyncByte)
|
||||
w.WriteUint16(pid)
|
||||
w.WriteByte(flagHasPayload | counter&0xF)
|
||||
w.WriteBytes(payload)
|
||||
|
||||
break
|
||||
} else {
|
||||
// payload lower than maximum size (last packet)
|
||||
w.WriteByte(SyncByte)
|
||||
w.WriteUint16(pid)
|
||||
w.WriteByte(flagHasAdaptation | flagHasPayload | counter&0xF)
|
||||
|
||||
// for 183 payload will be zero
|
||||
adSize := PacketSize - 4 - 1 - byte(len(payload))
|
||||
w.WriteByte(adSize)
|
||||
w.WriteBytes(make([]byte, adSize))
|
||||
|
||||
w.WriteBytes(payload)
|
||||
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"github.com/deepch/vdk/format/rtmp"
|
||||
"github.com/pion/rtp"
|
||||
"net/http"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
)
|
||||
|
||||
@@ -33,7 +34,7 @@ type Client struct {
|
||||
conn Conn
|
||||
closed bool
|
||||
|
||||
receive int
|
||||
recv uint32
|
||||
}
|
||||
|
||||
func NewClient(uri string) *Client {
|
||||
@@ -138,7 +139,7 @@ func (c *Client) Handle() (err error) {
|
||||
return
|
||||
}
|
||||
|
||||
c.receive += len(pkt.Data)
|
||||
atomic.AddUint32(&c.recv, uint32(len(pkt.Data)))
|
||||
|
||||
track := c.tracks[int(pkt.Idx)]
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"github.com/AlexxIT/go2rtc/pkg/streamer"
|
||||
"strconv"
|
||||
"sync/atomic"
|
||||
)
|
||||
|
||||
func (c *Client) GetMedias() []*streamer.Media {
|
||||
@@ -29,19 +29,12 @@ func (c *Client) Stop() error {
|
||||
}
|
||||
|
||||
func (c *Client) MarshalJSON() ([]byte, error) {
|
||||
v := map[string]interface{}{
|
||||
streamer.JSONReceive: c.receive,
|
||||
streamer.JSONType: "RTMP client producer",
|
||||
//streamer.JSONRemoteAddr: c.conn.NetConn().RemoteAddr().String(),
|
||||
"url": c.URI,
|
||||
info := &streamer.Info{
|
||||
Type: "RTMP source",
|
||||
URL: c.URI,
|
||||
Medias: c.medias,
|
||||
Tracks: c.tracks,
|
||||
Recv: atomic.LoadUint32(&c.recv),
|
||||
}
|
||||
for i, media := range c.medias {
|
||||
k := "media:" + strconv.Itoa(i)
|
||||
v[k] = media.String()
|
||||
}
|
||||
for i, track := range c.tracks {
|
||||
k := "track:" + strconv.Itoa(i)
|
||||
v[k] = track.String()
|
||||
}
|
||||
return json.Marshal(v)
|
||||
return json.Marshal(info)
|
||||
}
|
||||
|
||||
@@ -2,14 +2,10 @@ package rtsp
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"crypto/tls"
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/AlexxIT/go2rtc/pkg/aac"
|
||||
"github.com/AlexxIT/go2rtc/pkg/h264"
|
||||
"github.com/AlexxIT/go2rtc/pkg/mjpeg"
|
||||
"github.com/AlexxIT/go2rtc/pkg/streamer"
|
||||
"github.com/AlexxIT/go2rtc/pkg/tcp"
|
||||
"github.com/pion/rtcp"
|
||||
@@ -39,11 +35,11 @@ const (
|
||||
type Mode byte
|
||||
|
||||
const (
|
||||
ModeUnknown Mode = iota
|
||||
ModeClientProducer
|
||||
ModeUnknown Mode = iota
|
||||
ModeClientProducer // conn act as RTSP client that receive data from RTSP server (ex. camera)
|
||||
ModeServerUnknown
|
||||
ModeServerProducer
|
||||
ModeServerConsumer
|
||||
ModeServerProducer // conn act as RTSP server that reseive data from RTSP client (ex. ffmpeg output)
|
||||
ModeServerConsumer // conn act as RTSP server that send data to RTSP client (ex. ffmpeg input)
|
||||
)
|
||||
|
||||
type State byte
|
||||
@@ -78,6 +74,7 @@ type Conn struct {
|
||||
// public
|
||||
|
||||
Backchannel bool
|
||||
SessionName string
|
||||
|
||||
Medias []*streamer.Media
|
||||
Session string
|
||||
@@ -111,19 +108,6 @@ func NewClient(uri string) (*Conn, error) {
|
||||
return c, c.parseURI()
|
||||
}
|
||||
|
||||
func NewServer(conn net.Conn) *Conn {
|
||||
c := new(Conn)
|
||||
c.conn = conn
|
||||
c.mode = ModeServerUnknown
|
||||
c.reader = bufio.NewReader(conn)
|
||||
return c
|
||||
}
|
||||
|
||||
func (c *Conn) Auth(username, password string) {
|
||||
info := url.UserPassword(username, password)
|
||||
c.auth = tcp.NewAuth(info)
|
||||
}
|
||||
|
||||
func (c *Conn) parseURI() (err error) {
|
||||
c.URL, err = url.Parse(c.uri)
|
||||
if err != nil {
|
||||
@@ -220,12 +204,15 @@ func (c *Conn) Do(req *tcp.Request) (*tcp.Response, error) {
|
||||
if res.StatusCode == http.StatusUnauthorized {
|
||||
switch c.auth.Method {
|
||||
case tcp.AuthNone:
|
||||
if c.auth.ReadNone(res) {
|
||||
return c.Do(req)
|
||||
}
|
||||
return nil, errors.New("user/pass not provided")
|
||||
case tcp.AuthUnknown:
|
||||
if c.auth.Read(res) {
|
||||
return c.Do(req)
|
||||
}
|
||||
case tcp.AuthBasic, tcp.AuthDigest:
|
||||
default:
|
||||
return nil, errors.New("wrong user/pass")
|
||||
}
|
||||
}
|
||||
@@ -280,7 +267,7 @@ func (c *Conn) Options() error {
|
||||
}
|
||||
|
||||
if val := res.Header.Get("Content-Base"); val != "" {
|
||||
c.URL, err = url.Parse(val)
|
||||
c.URL, err = urlParse(val)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -304,13 +291,19 @@ func (c *Conn) Describe() error {
|
||||
req.Header.Set("Require", "www.onvif.org/ver20/backchannel")
|
||||
}
|
||||
|
||||
if c.UserAgent != "" {
|
||||
// this camera will answer with 401 on DESCRIBE without User-Agent
|
||||
// https://github.com/AlexxIT/go2rtc/issues/235
|
||||
req.Header.Set("User-Agent", c.UserAgent)
|
||||
}
|
||||
|
||||
res, err := c.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if val := res.Header.Get("Content-Base"); val != "" {
|
||||
c.URL, err = url.Parse(val)
|
||||
c.URL, err = urlParse(val)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -326,28 +319,30 @@ func (c *Conn) Describe() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
//func (c *Conn) Announce() (err error) {
|
||||
// req := &tcp.Request{
|
||||
// Method: MethodAnnounce,
|
||||
// URL: c.URL,
|
||||
// Header: map[string][]string{
|
||||
// "Content-Type": {"application/sdp"},
|
||||
// },
|
||||
// }
|
||||
//
|
||||
// //req.Body, err = c.sdp.Marshal()
|
||||
// if err != nil {
|
||||
// return
|
||||
// }
|
||||
//
|
||||
// _, err = c.Do(req)
|
||||
//
|
||||
// return
|
||||
//}
|
||||
func (c *Conn) Announce() (err error) {
|
||||
req := &tcp.Request{
|
||||
Method: MethodAnnounce,
|
||||
URL: c.URL,
|
||||
Header: map[string][]string{
|
||||
"Content-Type": {"application/sdp"},
|
||||
},
|
||||
}
|
||||
|
||||
req.Body, err = streamer.MarshalSDP(c.SessionName, c.Medias)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
res, err := c.Do(req)
|
||||
|
||||
_ = res
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func (c *Conn) Setup() error {
|
||||
for _, media := range c.Medias {
|
||||
_, err := c.SetupMedia(media, media.Codecs[0])
|
||||
_, err := c.SetupMedia(media, media.Codecs[0], true)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -356,11 +351,12 @@ func (c *Conn) Setup() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Conn) SetupMedia(
|
||||
media *streamer.Media, codec *streamer.Codec,
|
||||
) (*streamer.Track, error) {
|
||||
c.stateMu.Lock()
|
||||
defer c.stateMu.Unlock()
|
||||
func (c *Conn) SetupMedia(media *streamer.Media, codec *streamer.Codec, first bool) (*streamer.Track, error) {
|
||||
// TODO: rewrite recoonection and first flag
|
||||
if first {
|
||||
c.stateMu.Lock()
|
||||
defer c.stateMu.Unlock()
|
||||
}
|
||||
|
||||
if c.state != StateConn && c.state != StateSetup {
|
||||
return nil, fmt.Errorf("RTSP SETUP from wrong state: %s", c.state)
|
||||
@@ -379,7 +375,7 @@ func (c *Conn) SetupMedia(
|
||||
}
|
||||
rawURL += media.Control
|
||||
}
|
||||
trackURL, err := url.Parse(rawURL)
|
||||
trackURL, err := urlParse(rawURL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -412,7 +408,7 @@ func (c *Conn) SetupMedia(
|
||||
|
||||
for _, newMedia := range c.Medias {
|
||||
if newMedia.Control == media.Control {
|
||||
return c.SetupMedia(newMedia, newMedia.Codecs[0])
|
||||
return c.SetupMedia(newMedia, newMedia.Codecs[0], false)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -521,145 +517,6 @@ func (c *Conn) Close() error {
|
||||
return c.conn.Close()
|
||||
}
|
||||
|
||||
const transport = "RTP/AVP/TCP;unicast;interleaved="
|
||||
|
||||
func (c *Conn) Accept() error {
|
||||
for {
|
||||
req, err := tcp.ReadRequest(c.reader)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if c.URL == nil {
|
||||
c.URL = req.URL
|
||||
c.UserAgent = req.Header.Get("User-Agent")
|
||||
}
|
||||
|
||||
c.Fire(req)
|
||||
|
||||
if !c.auth.Validate(req) {
|
||||
res := &tcp.Response{
|
||||
Status: "401 Unauthorized",
|
||||
Header: map[string][]string{"Www-Authenticate": {`Basic realm="go2rtc"`}},
|
||||
}
|
||||
if err = c.Response(res); err != nil {
|
||||
return err
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// Receiver: OPTIONS > DESCRIBE > SETUP... > PLAY > TEARDOWN
|
||||
// Sender: OPTIONS > ANNOUNCE > SETUP... > RECORD > TEARDOWN
|
||||
switch req.Method {
|
||||
case MethodOptions:
|
||||
res := &tcp.Response{
|
||||
Header: map[string][]string{
|
||||
"Public": {"OPTIONS, SETUP, TEARDOWN, DESCRIBE, PLAY, PAUSE, ANNOUNCE, RECORD"},
|
||||
},
|
||||
Request: req,
|
||||
}
|
||||
if err = c.Response(res); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
case MethodAnnounce:
|
||||
if req.Header.Get("Content-Type") != "application/sdp" {
|
||||
return errors.New("wrong content type")
|
||||
}
|
||||
|
||||
c.Medias, err = UnmarshalSDP(req.Body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// TODO: fix someday...
|
||||
c.channels = map[byte]*streamer.Track{}
|
||||
for i, media := range c.Medias {
|
||||
track := streamer.NewTrack(media.Codecs[0], media.Direction)
|
||||
c.tracks = append(c.tracks, track)
|
||||
c.channels[byte(i<<1)] = track
|
||||
}
|
||||
|
||||
c.mode = ModeServerProducer
|
||||
c.Fire(MethodAnnounce)
|
||||
|
||||
res := &tcp.Response{Request: req}
|
||||
if err = c.Response(res); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
case MethodDescribe:
|
||||
c.mode = ModeServerConsumer
|
||||
c.Fire(MethodDescribe)
|
||||
|
||||
if c.tracks == nil {
|
||||
res := &tcp.Response{
|
||||
Status: "404 Not Found",
|
||||
Request: req,
|
||||
}
|
||||
return c.Response(res)
|
||||
}
|
||||
|
||||
res := &tcp.Response{
|
||||
Header: map[string][]string{
|
||||
"Content-Type": {"application/sdp"},
|
||||
},
|
||||
Request: req,
|
||||
}
|
||||
|
||||
// convert tracks to real output medias medias
|
||||
var medias []*streamer.Media
|
||||
for _, track := range c.tracks {
|
||||
media := &streamer.Media{
|
||||
Kind: streamer.GetKind(track.Codec.Name),
|
||||
Direction: streamer.DirectionSendonly,
|
||||
Codecs: []*streamer.Codec{track.Codec},
|
||||
}
|
||||
medias = append(medias, media)
|
||||
}
|
||||
|
||||
res.Body, err = streamer.MarshalSDP(medias)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err = c.Response(res); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
case MethodSetup:
|
||||
tr := req.Header.Get("Transport")
|
||||
|
||||
res := &tcp.Response{
|
||||
Header: map[string][]string{},
|
||||
Request: req,
|
||||
}
|
||||
|
||||
if strings.HasPrefix(tr, transport) {
|
||||
c.Session = "1" // TODO: fixme
|
||||
c.state = StateSetup
|
||||
res.Header.Set("Transport", tr[:len(transport)+3])
|
||||
} else {
|
||||
res.Status = "461 Unsupported transport"
|
||||
}
|
||||
|
||||
if err = c.Response(res); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
case MethodRecord, MethodPlay:
|
||||
res := &tcp.Response{Request: req}
|
||||
if err = c.Response(res); err == nil {
|
||||
c.state = StatePlay
|
||||
}
|
||||
return err
|
||||
|
||||
default:
|
||||
return fmt.Errorf("unsupported method: %s", req.Method)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Conn) Handle() (err error) {
|
||||
c.stateMu.Lock()
|
||||
|
||||
@@ -702,9 +559,16 @@ func (c *Conn) Handle() (err error) {
|
||||
switch c.mode {
|
||||
case ModeClientProducer:
|
||||
// polling frames from remote RTSP Server (ex Camera)
|
||||
timeout = time.Second * 5
|
||||
go c.keepalive()
|
||||
|
||||
if c.HasSendTracks() {
|
||||
// if we receiving video/audio from camera
|
||||
timeout = time.Second * 5
|
||||
} else {
|
||||
// if we only send audio to camera
|
||||
timeout = time.Second * 30
|
||||
}
|
||||
|
||||
case ModeServerProducer:
|
||||
// polling frames from remote RTSP Client (ex FFmpeg)
|
||||
timeout = time.Second * 15
|
||||
@@ -736,6 +600,9 @@ func (c *Conn) Handle() (err error) {
|
||||
return
|
||||
}
|
||||
|
||||
var channelID byte
|
||||
var size uint16
|
||||
|
||||
if buf4[0] != '$' {
|
||||
switch string(buf4) {
|
||||
case "RTSP":
|
||||
@@ -744,26 +611,62 @@ func (c *Conn) Handle() (err error) {
|
||||
return
|
||||
}
|
||||
c.Fire(res)
|
||||
continue
|
||||
|
||||
case "OPTI", "TEAR", "DESC", "SETU", "PLAY", "PAUS", "RECO", "ANNO", "GET_", "SET_":
|
||||
var req *tcp.Request
|
||||
if req, err = tcp.ReadRequest(c.reader); err != nil {
|
||||
return
|
||||
}
|
||||
c.Fire(req)
|
||||
continue
|
||||
|
||||
default:
|
||||
return fmt.Errorf("RTSP wrong input")
|
||||
for i := 0; ; i++ {
|
||||
// search next start symbol
|
||||
if _, err = c.reader.ReadBytes('$'); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if channelID, err = c.reader.ReadByte(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// check if channel ID exists
|
||||
if c.channels[channelID] == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
buf4 = make([]byte, 2)
|
||||
if _, err = io.ReadFull(c.reader, buf4); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// check if size good for RTP
|
||||
size = binary.BigEndian.Uint16(buf4)
|
||||
if size <= 1500 {
|
||||
break
|
||||
}
|
||||
|
||||
// 10 tries to find good packet
|
||||
if i >= 10 {
|
||||
return fmt.Errorf("RTSP wrong input")
|
||||
}
|
||||
}
|
||||
|
||||
c.Fire("RTSP wrong input")
|
||||
}
|
||||
continue
|
||||
}
|
||||
} else {
|
||||
// hope that the odd channels are always RTCP
|
||||
channelID = buf4[1]
|
||||
|
||||
// hope that the odd channels are always RTCP
|
||||
channelID := buf4[1]
|
||||
// get data size
|
||||
size = binary.BigEndian.Uint16(buf4[2:])
|
||||
|
||||
// get data size
|
||||
size := int(binary.BigEndian.Uint16(buf4[2:]))
|
||||
|
||||
if _, err = c.reader.Discard(4); err != nil {
|
||||
return
|
||||
// skip 4 bytes from c.reader.Peek
|
||||
if _, err = c.reader.Discard(4); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// init memory for data
|
||||
@@ -772,7 +675,7 @@ func (c *Conn) Handle() (err error) {
|
||||
return
|
||||
}
|
||||
|
||||
c.receive += size
|
||||
c.receive += int(size)
|
||||
|
||||
if channelID&1 == 0 {
|
||||
packet := &rtp.Packet{}
|
||||
@@ -783,21 +686,19 @@ func (c *Conn) Handle() (err error) {
|
||||
track := c.channels[channelID]
|
||||
if track != nil {
|
||||
_ = track.WriteRTP(packet)
|
||||
//return fmt.Errorf("wrong channelID: %d", channelID)
|
||||
} else {
|
||||
continue // TODO: maybe fix this
|
||||
//panic("wrong channelID")
|
||||
//c.Fire("wrong channelID: " + strconv.Itoa(int(channelID)))
|
||||
}
|
||||
} else {
|
||||
msg := &RTCP{Channel: channelID}
|
||||
|
||||
if err = msg.Header.Unmarshal(buf); err != nil {
|
||||
return
|
||||
continue
|
||||
}
|
||||
|
||||
msg.Packets, err = rtcp.Unmarshal(buf)
|
||||
if err != nil {
|
||||
return
|
||||
continue
|
||||
}
|
||||
|
||||
c.Fire(msg)
|
||||
@@ -828,89 +729,11 @@ func (c *Conn) GetChannel(media *streamer.Media) int {
|
||||
return -1
|
||||
}
|
||||
|
||||
func (c *Conn) bindTrack(
|
||||
track *streamer.Track, channel uint8, payloadType uint8,
|
||||
) *streamer.Track {
|
||||
push := func(packet *rtp.Packet) error {
|
||||
if c.state == StateNone {
|
||||
return nil
|
||||
}
|
||||
packet.Header.PayloadType = payloadType
|
||||
|
||||
size := packet.MarshalSize()
|
||||
|
||||
//log.Printf("[RTP] codec: %s, size: %6d, ts: %10d, pt: %2d, ssrc: %d, seq: %d, mark: %v", track.Codec.Name, len(packet.Payload), packet.Timestamp, packet.PayloadType, packet.SSRC, packet.SequenceNumber, packet.Marker)
|
||||
|
||||
data := make([]byte, 4+size)
|
||||
data[0] = '$'
|
||||
data[1] = channel
|
||||
binary.BigEndian.PutUint16(data[2:], uint16(size))
|
||||
|
||||
if _, err := packet.MarshalTo(data[4:]); err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
if _, err := c.conn.Write(data); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
c.send += size
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
if !track.Codec.IsRTP() {
|
||||
switch track.Codec.Name {
|
||||
case streamer.CodecH264:
|
||||
wrapper := h264.RTPPay(1500)
|
||||
push = wrapper(push)
|
||||
case streamer.CodecAAC:
|
||||
wrapper := aac.RTPPay(1500)
|
||||
push = wrapper(push)
|
||||
case streamer.CodecJPEG:
|
||||
wrapper := mjpeg.RTPPay()
|
||||
push = wrapper(push)
|
||||
func (c *Conn) HasSendTracks() bool {
|
||||
for _, track := range c.tracks {
|
||||
if track.Direction == streamer.DirectionSendonly {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return track.Bind(push)
|
||||
}
|
||||
|
||||
type RTCP struct {
|
||||
Channel byte
|
||||
Header rtcp.Header
|
||||
Packets []rtcp.Packet
|
||||
}
|
||||
|
||||
const sdpHeader = `v=0
|
||||
o=- 0 0 IN IP4 0.0.0.0
|
||||
s=-
|
||||
t=0 0`
|
||||
|
||||
func UnmarshalSDP(rawSDP []byte) ([]*streamer.Media, error) {
|
||||
medias, err := streamer.UnmarshalSDP(rawSDP)
|
||||
if err != nil {
|
||||
// fix SDP header for some cameras
|
||||
i := bytes.Index(rawSDP, []byte("\nm="))
|
||||
if i > 0 {
|
||||
rawSDP = append([]byte(sdpHeader), rawSDP[i:]...)
|
||||
medias, err = streamer.UnmarshalSDP(rawSDP)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
// fix bug in ONVIF spec
|
||||
// https://www.onvif.org/specs/stream/ONVIF-Streaming-Spec-v241.pdf
|
||||
for _, media := range medias {
|
||||
switch media.Direction {
|
||||
case streamer.DirectionRecvonly, "":
|
||||
media.Direction = streamer.DirectionSendonly
|
||||
case streamer.DirectionSendonly:
|
||||
media.Direction = streamer.DirectionRecvonly
|
||||
}
|
||||
}
|
||||
|
||||
return medias, nil
|
||||
return false
|
||||
}
|
||||
112
pkg/rtsp/consumer.go
Normal file
112
pkg/rtsp/consumer.go
Normal file
@@ -0,0 +1,112 @@
|
||||
package rtsp
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"github.com/AlexxIT/go2rtc/pkg/aac"
|
||||
"github.com/AlexxIT/go2rtc/pkg/h264"
|
||||
"github.com/AlexxIT/go2rtc/pkg/h265"
|
||||
"github.com/AlexxIT/go2rtc/pkg/mjpeg"
|
||||
"github.com/AlexxIT/go2rtc/pkg/streamer"
|
||||
"github.com/pion/rtp"
|
||||
)
|
||||
|
||||
func (c *Conn) AddTrack(media *streamer.Media, track *streamer.Track) *streamer.Track {
|
||||
switch c.mode {
|
||||
// send our track to RTSP consumer (ex. FFmpeg)
|
||||
case ModeServerConsumer:
|
||||
i := len(c.tracks)
|
||||
channelID := byte(i << 1)
|
||||
|
||||
codec := track.Codec.Clone()
|
||||
codec.PayloadType = uint8(96 + i)
|
||||
|
||||
if media.MatchAll() {
|
||||
// fill consumer medias list
|
||||
c.Medias = append(c.Medias, &streamer.Media{
|
||||
Kind: media.Kind, Direction: media.Direction,
|
||||
Codecs: []*streamer.Codec{codec},
|
||||
})
|
||||
} else {
|
||||
// find consumer media and replace codec with right one
|
||||
for i, m := range c.Medias {
|
||||
if m == media {
|
||||
media.Codecs = []*streamer.Codec{codec}
|
||||
c.Medias[i] = media
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
track = c.bindTrack(track, channelID, codec.PayloadType)
|
||||
track.Codec = codec
|
||||
c.tracks = append(c.tracks, track)
|
||||
|
||||
return track
|
||||
|
||||
// camera with backchannel support
|
||||
case ModeClientProducer:
|
||||
consCodec := media.MatchCodec(track.Codec)
|
||||
consTrack := c.GetTrack(media, consCodec)
|
||||
if consTrack == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
return track.Bind(func(packet *rtp.Packet) error {
|
||||
return consTrack.WriteRTP(packet)
|
||||
})
|
||||
}
|
||||
|
||||
println("WARNING: rtsp: AddTrack to wrong mode")
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Conn) bindTrack(
|
||||
track *streamer.Track, channel uint8, payloadType uint8,
|
||||
) *streamer.Track {
|
||||
push := func(packet *rtp.Packet) error {
|
||||
if c.state == StateNone {
|
||||
return nil
|
||||
}
|
||||
packet.Header.PayloadType = payloadType
|
||||
|
||||
size := packet.MarshalSize()
|
||||
|
||||
//log.Printf("[RTP] codec: %s, size: %6d, ts: %10d, pt: %2d, ssrc: %d, seq: %d, mark: %v", track.Codec.Name, len(packet.Payload), packet.Timestamp, packet.PayloadType, packet.SSRC, packet.SequenceNumber, packet.Marker)
|
||||
|
||||
data := make([]byte, 4+size)
|
||||
data[0] = '$'
|
||||
data[1] = channel
|
||||
binary.BigEndian.PutUint16(data[2:], uint16(size))
|
||||
|
||||
if _, err := packet.MarshalTo(data[4:]); err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
if _, err := c.conn.Write(data); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
c.send += size
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
if !track.Codec.IsRTP() {
|
||||
switch track.Codec.Name {
|
||||
case streamer.CodecH264:
|
||||
wrapper := h264.RTPPay(1500)
|
||||
push = wrapper(push)
|
||||
case streamer.CodecH265:
|
||||
wrapper := h265.RTPPay(1500)
|
||||
push = wrapper(push)
|
||||
case streamer.CodecAAC:
|
||||
wrapper := aac.RTPPay(1500)
|
||||
push = wrapper(push)
|
||||
case streamer.CodecJPEG:
|
||||
wrapper := mjpeg.RTPPay()
|
||||
push = wrapper(push)
|
||||
}
|
||||
}
|
||||
|
||||
return track.Bind(push)
|
||||
}
|
||||
108
pkg/rtsp/helpers.go
Normal file
108
pkg/rtsp/helpers.go
Normal file
@@ -0,0 +1,108 @@
|
||||
package rtsp
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"github.com/AlexxIT/go2rtc/pkg/streamer"
|
||||
"github.com/pion/rtcp"
|
||||
"github.com/pion/sdp/v3"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type RTCP struct {
|
||||
Channel byte
|
||||
Header rtcp.Header
|
||||
Packets []rtcp.Packet
|
||||
}
|
||||
|
||||
const sdpHeader = `v=0
|
||||
o=- 0 0 IN IP4 0.0.0.0
|
||||
s=-
|
||||
t=0 0`
|
||||
|
||||
func UnmarshalSDP(rawSDP []byte) ([]*streamer.Media, error) {
|
||||
// fix bug from Reolink Doorbell
|
||||
if i := bytes.Index(rawSDP, []byte("a=sendonlym=")); i > 0 {
|
||||
rawSDP = append(rawSDP[:i+11], rawSDP[i+10:]...)
|
||||
rawSDP[i+10] = '\n'
|
||||
}
|
||||
|
||||
// fix bug from Ezviz C6N
|
||||
if i := bytes.Index(rawSDP, []byte("H265/90000\r\na=fmtp:96 profile-level-id=420029;")); i > 0 {
|
||||
rawSDP[i+3] = '4'
|
||||
}
|
||||
|
||||
sd := &sdp.SessionDescription{}
|
||||
if err := sd.Unmarshal(rawSDP); err != nil {
|
||||
// fix multiple `s=` https://github.com/AlexxIT/WebRTC/issues/417
|
||||
re, _ := regexp.Compile("\ns=[^\n]+")
|
||||
rawSDP = re.ReplaceAll(rawSDP, nil)
|
||||
|
||||
// fix SDP header for some cameras
|
||||
if i := bytes.Index(rawSDP, []byte("\nm=")); i > 0 {
|
||||
rawSDP = append([]byte(sdpHeader), rawSDP[i:]...)
|
||||
sd = &sdp.SessionDescription{}
|
||||
err = sd.Unmarshal(rawSDP)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
medias := streamer.UnmarshalMedias(sd.MediaDescriptions)
|
||||
|
||||
for _, media := range medias {
|
||||
// Check buggy SDP with fmtp for H264 on another track
|
||||
// https://github.com/AlexxIT/WebRTC/issues/419
|
||||
for _, codec := range media.Codecs {
|
||||
if codec.Name == streamer.CodecH264 && codec.FmtpLine == "" {
|
||||
codec.FmtpLine = findFmtpLine(codec.PayloadType, sd.MediaDescriptions)
|
||||
}
|
||||
}
|
||||
|
||||
// fix bug in ONVIF spec
|
||||
// https://www.onvif.org/specs/stream/ONVIF-Streaming-Spec-v241.pdf
|
||||
switch media.Direction {
|
||||
case streamer.DirectionRecvonly, "":
|
||||
media.Direction = streamer.DirectionSendonly
|
||||
case streamer.DirectionSendonly:
|
||||
media.Direction = streamer.DirectionRecvonly
|
||||
}
|
||||
}
|
||||
|
||||
return medias, nil
|
||||
}
|
||||
|
||||
func findFmtpLine(payloadType uint8, descriptions []*sdp.MediaDescription) string {
|
||||
s := strconv.Itoa(int(payloadType))
|
||||
for _, md := range descriptions {
|
||||
codec := streamer.UnmarshalCodec(md, s)
|
||||
if codec.FmtpLine != "" {
|
||||
return codec.FmtpLine
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// urlParse fix bugs:
|
||||
// 1. Content-Base: rtsp://::ffff:192.168.1.123/onvif/profile.1/
|
||||
// 2. Content-Base: rtsp://rtsp://turret2-cam.lan:554/stream1/
|
||||
func urlParse(rawURL string) (*url.URL, error) {
|
||||
if strings.HasPrefix(rawURL, "rtsp://rtsp://") {
|
||||
rawURL = rawURL[7:]
|
||||
}
|
||||
|
||||
u, err := url.Parse(rawURL)
|
||||
if err != nil && strings.HasSuffix(err.Error(), "after host") {
|
||||
if i1 := strings.Index(rawURL, "://"); i1 > 0 {
|
||||
if i2 := strings.IndexByte(rawURL[i1+3:], '/'); i2 > 0 {
|
||||
return urlParse(rawURL[:i1+3+i2] + ":" + rawURL[i1+3+i2:])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return u, err
|
||||
}
|
||||
106
pkg/rtsp/producer.go
Normal file
106
pkg/rtsp/producer.go
Normal file
@@ -0,0 +1,106 @@
|
||||
package rtsp
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"github.com/AlexxIT/go2rtc/pkg/streamer"
|
||||
)
|
||||
|
||||
func (c *Conn) GetMedias() []*streamer.Media {
|
||||
if c.Medias != nil {
|
||||
return c.Medias
|
||||
}
|
||||
|
||||
return []*streamer.Media{
|
||||
{
|
||||
Kind: streamer.KindVideo,
|
||||
Direction: streamer.DirectionRecvonly,
|
||||
Codecs: []*streamer.Codec{
|
||||
{Name: streamer.CodecAll},
|
||||
},
|
||||
},
|
||||
{
|
||||
Kind: streamer.KindAudio,
|
||||
Direction: streamer.DirectionRecvonly,
|
||||
Codecs: []*streamer.Codec{
|
||||
{Name: streamer.CodecAll},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Conn) GetTrack(media *streamer.Media, codec *streamer.Codec) *streamer.Track {
|
||||
for _, track := range c.tracks {
|
||||
if track.Codec == codec {
|
||||
return track
|
||||
}
|
||||
}
|
||||
|
||||
// can't setup new tracks from play state - forcing a reconnection feature
|
||||
switch c.state {
|
||||
case StatePlay, StateHandle:
|
||||
go c.Close()
|
||||
return streamer.NewTrack(codec, media.Direction)
|
||||
}
|
||||
|
||||
track, err := c.SetupMedia(media, codec, true)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return track
|
||||
}
|
||||
|
||||
func (c *Conn) Start() error {
|
||||
switch c.mode {
|
||||
case ModeClientProducer:
|
||||
if err := c.Play(); err != nil {
|
||||
return err
|
||||
}
|
||||
case ModeServerProducer:
|
||||
default:
|
||||
return fmt.Errorf("start wrong mode: %d", c.mode)
|
||||
}
|
||||
|
||||
return c.Handle()
|
||||
}
|
||||
|
||||
func (c *Conn) Stop() error {
|
||||
return c.Close()
|
||||
}
|
||||
|
||||
func (c *Conn) MarshalJSON() ([]byte, error) {
|
||||
info := &streamer.Info{
|
||||
UserAgent: c.UserAgent,
|
||||
Medias: c.Medias,
|
||||
Tracks: c.tracks,
|
||||
Recv: uint32(c.receive),
|
||||
Send: uint32(c.send),
|
||||
}
|
||||
|
||||
switch c.mode {
|
||||
case ModeUnknown:
|
||||
info.Type = "RTSP unknown"
|
||||
case ModeClientProducer, ModeServerProducer:
|
||||
info.Type = "RTSP source"
|
||||
case ModeServerConsumer:
|
||||
info.Type = "RTSP client"
|
||||
}
|
||||
|
||||
if c.URL != nil {
|
||||
info.URL = c.URL.String()
|
||||
}
|
||||
if c.conn != nil {
|
||||
info.RemoteAddr = c.conn.RemoteAddr().String()
|
||||
}
|
||||
|
||||
//for i, track := range c.tracks {
|
||||
// k := "track:" + strconv.Itoa(i+1)
|
||||
// if track.MimeType() == streamer.MimeTypeH264 {
|
||||
// v[k] = h264.Describe(track.Caps())
|
||||
// } else {
|
||||
// v[k] = track.MimeType()
|
||||
// }
|
||||
//}
|
||||
|
||||
return json.Marshal(info)
|
||||
}
|
||||
141
pkg/rtsp/rtsp_test.go
Normal file
141
pkg/rtsp/rtsp_test.go
Normal file
@@ -0,0 +1,141 @@
|
||||
package rtsp
|
||||
|
||||
import (
|
||||
"github.com/AlexxIT/go2rtc/pkg/h264"
|
||||
"github.com/AlexxIT/go2rtc/pkg/streamer"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestURLParse(t *testing.T) {
|
||||
// https://github.com/AlexxIT/WebRTC/issues/395
|
||||
base := "rtsp://::ffff:192.168.1.123/onvif/profile.1/"
|
||||
u, err := urlParse(base)
|
||||
assert.Empty(t, err)
|
||||
assert.Equal(t, "::ffff:192.168.1.123:", u.Host)
|
||||
|
||||
// https://github.com/AlexxIT/go2rtc/issues/208
|
||||
base = "rtsp://rtsp://turret2-cam.lan:554/stream1/"
|
||||
u, err = urlParse(base)
|
||||
assert.Empty(t, err)
|
||||
assert.Equal(t, "turret2-cam.lan:554", u.Host)
|
||||
}
|
||||
|
||||
func TestBugSDP1(t *testing.T) {
|
||||
// https://github.com/AlexxIT/WebRTC/issues/417
|
||||
s := `v=0
|
||||
o=- 91674849066 1 IN IP4 192.168.1.123
|
||||
s=RtspServer
|
||||
i=live
|
||||
t=0 0
|
||||
a=control:*
|
||||
a=range:npt=0-
|
||||
m=video 0 RTP/AVP 96
|
||||
c=IN IP4 0.0.0.0
|
||||
s=RtspServer
|
||||
i=live
|
||||
a=control:track0
|
||||
a=range:npt=0-
|
||||
a=rtpmap:96 H264/90000
|
||||
a=fmtp:96 packetization-mode=1;profile-level-id=42001E;sprop-parameter-sets=Z0IAHvQCgC3I,aM48gA==
|
||||
a=control:track0
|
||||
m=audio 0 RTP/AVP 97
|
||||
c=IN IP4 0.0.0.0
|
||||
s=RtspServer
|
||||
i=live
|
||||
a=control:track1
|
||||
a=range:npt=0-
|
||||
a=rtpmap:97 MPEG4-GENERIC/8000/1
|
||||
a=fmtp:97 profile-level-id=1;mode=AAC-hbr;sizelength=13;indexlength=3;indexdeltalength=3;config=1588
|
||||
a=control:track1
|
||||
`
|
||||
medias, err := UnmarshalSDP([]byte(s))
|
||||
assert.Nil(t, err)
|
||||
assert.NotNil(t, medias)
|
||||
}
|
||||
|
||||
func TestBugSDP2(t *testing.T) {
|
||||
// https://github.com/AlexxIT/WebRTC/issues/419
|
||||
s := `v=0
|
||||
o=- 1675628282 1675628283 IN IP4 192.168.1.123
|
||||
s=streamed by the RTSP server
|
||||
t=0 0
|
||||
m=video 0 RTP/AVP 96
|
||||
a=rtpmap:96 H264/90000
|
||||
a=control:track0
|
||||
m=audio 0 RTP/AVP 8
|
||||
a=rtpmap:0 pcma/8000/1
|
||||
a=control:track1
|
||||
a=framerate:25
|
||||
a=range:npt=now-
|
||||
a=fmtp:96 packetization-mode=1;profile-level-id=64001F;sprop-parameter-sets=Z0IAH5WoFAFuQA==,aM48gA==
|
||||
`
|
||||
medias, err := UnmarshalSDP([]byte(s))
|
||||
assert.Nil(t, err)
|
||||
assert.NotNil(t, medias)
|
||||
assert.NotEqual(t, "", medias[0].Codecs[0].FmtpLine)
|
||||
}
|
||||
|
||||
func TestBugSDP3(t *testing.T) {
|
||||
s := `v=0
|
||||
o=- 1675775048103026 1 IN IP4 192.168.1.123
|
||||
s=Session streamed by "preview"
|
||||
t=0 0
|
||||
a=tool:LIVE555 Streaming Media v2020.08.12
|
||||
a=type:broadcast
|
||||
a=control:*
|
||||
a=range:npt=0-
|
||||
a=x-qt-text-nam:Session streamed by "preview"
|
||||
m=video 0 RTP/AVP 96
|
||||
c=IN IP4 0.0.0.0
|
||||
b=AS:8192
|
||||
a=rtpmap:96 H264/90000
|
||||
a=fmtp:96 packetization-mode=1;profile-level-id=640033;sprop-parameter-sets=Z2QAM6wVFKAoAPGQ,aO48sA==
|
||||
a=recvonly
|
||||
a=control:track1
|
||||
m=audio 0 RTP/AVP 8
|
||||
a=control:track2
|
||||
a=rtpmap:8 PCMA/8000
|
||||
a=sendonlym=audio 0 RTP/AVP 98
|
||||
c=IN IP4 0.0.0.0
|
||||
b=AS:8192
|
||||
a=rtpmap:98 MPEG4-GENERIC/16000
|
||||
a=fmtp:98 streamtype=5;profile-level-id=1;mode=AAC-hbr;sizelength=13;indexlength=3;indexdeltalength=3;config=1408;
|
||||
a=recvonly
|
||||
a=control:track3
|
||||
`
|
||||
medias, err := UnmarshalSDP([]byte(s))
|
||||
assert.Nil(t, err)
|
||||
assert.Len(t, medias, 3)
|
||||
}
|
||||
|
||||
func TestBugSDP4(t *testing.T) {
|
||||
s := `v=0
|
||||
o=- 1676583297494652 1676583297494652 IN IP4 192.168.1.58
|
||||
s=Media Presentation
|
||||
e=NONE
|
||||
b=AS:5050
|
||||
t=0 0
|
||||
a=control:rtsp://192.168.1.58:554/h264_stream/
|
||||
m=video 0 RTP/AVP 96
|
||||
b=AS:5000
|
||||
a=control:rtsp://192.168.1.58:554/h264_stream/trackID=1
|
||||
a=rtpmap:96 H265/90000
|
||||
a=fmtp:96 profile-level-id=420029; packetization-mode=1; sprop-parameter-sets=
|
||||
a=Media_header:MEDIAINFO=494D4B48010100000400050000000000000000000000000000000000000000000000000000000000;
|
||||
a=appversion:1.0
|
||||
`
|
||||
s = strings.ReplaceAll(s, "\n", "\r\n")
|
||||
medias, err := UnmarshalSDP([]byte(s))
|
||||
assert.Nil(t, err)
|
||||
|
||||
codec := medias[0].Codecs[0]
|
||||
assert.Equal(t, streamer.CodecH264, codec.Name)
|
||||
|
||||
sps, _ := h264.GetParameterSet(codec.FmtpLine)
|
||||
assert.Nil(t, sps)
|
||||
|
||||
profile := h264.GetProfileLevelID(codec.FmtpLine)
|
||||
assert.Equal(t, "420029", profile)
|
||||
}
|
||||
170
pkg/rtsp/server.go
Normal file
170
pkg/rtsp/server.go
Normal file
@@ -0,0 +1,170 @@
|
||||
package rtsp
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/AlexxIT/go2rtc/pkg/streamer"
|
||||
"github.com/AlexxIT/go2rtc/pkg/tcp"
|
||||
"net"
|
||||
"net/url"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func NewServer(conn net.Conn) *Conn {
|
||||
c := new(Conn)
|
||||
c.conn = conn
|
||||
c.mode = ModeServerUnknown
|
||||
c.reader = bufio.NewReader(conn)
|
||||
return c
|
||||
}
|
||||
|
||||
func (c *Conn) Auth(username, password string) {
|
||||
info := url.UserPassword(username, password)
|
||||
c.auth = tcp.NewAuth(info)
|
||||
}
|
||||
|
||||
const transport = "RTP/AVP/TCP;unicast;interleaved="
|
||||
|
||||
func (c *Conn) Accept() error {
|
||||
for {
|
||||
req, err := tcp.ReadRequest(c.reader)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if c.URL == nil {
|
||||
c.URL = req.URL
|
||||
c.UserAgent = req.Header.Get("User-Agent")
|
||||
}
|
||||
|
||||
c.Fire(req)
|
||||
|
||||
if !c.auth.Validate(req) {
|
||||
res := &tcp.Response{
|
||||
Status: "401 Unauthorized",
|
||||
Header: map[string][]string{"Www-Authenticate": {`Basic realm="go2rtc"`}},
|
||||
}
|
||||
if err = c.Response(res); err != nil {
|
||||
return err
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// Receiver: OPTIONS > DESCRIBE > SETUP... > PLAY > TEARDOWN
|
||||
// Sender: OPTIONS > ANNOUNCE > SETUP... > RECORD > TEARDOWN
|
||||
switch req.Method {
|
||||
case MethodOptions:
|
||||
res := &tcp.Response{
|
||||
Header: map[string][]string{
|
||||
"Public": {"OPTIONS, SETUP, TEARDOWN, DESCRIBE, PLAY, PAUSE, ANNOUNCE, RECORD"},
|
||||
},
|
||||
Request: req,
|
||||
}
|
||||
if err = c.Response(res); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
case MethodAnnounce:
|
||||
if req.Header.Get("Content-Type") != "application/sdp" {
|
||||
return errors.New("wrong content type")
|
||||
}
|
||||
|
||||
c.Medias, err = UnmarshalSDP(req.Body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// TODO: fix someday...
|
||||
c.channels = map[byte]*streamer.Track{}
|
||||
for i, media := range c.Medias {
|
||||
track := streamer.NewTrack(media.Codecs[0], media.Direction)
|
||||
c.tracks = append(c.tracks, track)
|
||||
c.channels[byte(i<<1)] = track
|
||||
}
|
||||
|
||||
c.mode = ModeServerProducer
|
||||
c.Fire(MethodAnnounce)
|
||||
|
||||
res := &tcp.Response{Request: req}
|
||||
if err = c.Response(res); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
case MethodDescribe:
|
||||
c.mode = ModeServerConsumer
|
||||
c.Fire(MethodDescribe)
|
||||
|
||||
if c.tracks == nil {
|
||||
res := &tcp.Response{
|
||||
Status: "404 Not Found",
|
||||
Request: req,
|
||||
}
|
||||
return c.Response(res)
|
||||
}
|
||||
|
||||
res := &tcp.Response{
|
||||
Header: map[string][]string{
|
||||
"Content-Type": {"application/sdp"},
|
||||
},
|
||||
Request: req,
|
||||
}
|
||||
|
||||
// convert tracks to real output medias medias
|
||||
var medias []*streamer.Media
|
||||
for _, track := range c.tracks {
|
||||
media := &streamer.Media{
|
||||
Kind: streamer.GetKind(track.Codec.Name),
|
||||
Direction: streamer.DirectionSendonly,
|
||||
Codecs: []*streamer.Codec{track.Codec},
|
||||
}
|
||||
medias = append(medias, media)
|
||||
}
|
||||
|
||||
res.Body, err = streamer.MarshalSDP(c.SessionName, medias)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err = c.Response(res); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
case MethodSetup:
|
||||
tr := req.Header.Get("Transport")
|
||||
|
||||
res := &tcp.Response{
|
||||
Header: map[string][]string{},
|
||||
Request: req,
|
||||
}
|
||||
|
||||
if strings.HasPrefix(tr, transport) {
|
||||
c.Session = "1" // TODO: fixme
|
||||
c.state = StateSetup
|
||||
res.Header.Set("Transport", tr[:len(transport)+3])
|
||||
} else {
|
||||
res.Status = "461 Unsupported transport"
|
||||
}
|
||||
|
||||
if err = c.Response(res); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
case MethodRecord, MethodPlay:
|
||||
res := &tcp.Response{Request: req}
|
||||
if err = c.Response(res); err == nil {
|
||||
c.state = StatePlay
|
||||
}
|
||||
return err
|
||||
|
||||
case MethodTeardown:
|
||||
res := &tcp.Response{Request: req}
|
||||
_ = c.Response(res)
|
||||
c.state = StateNone
|
||||
return c.conn.Close()
|
||||
|
||||
default:
|
||||
return fmt.Errorf("unsupported method: %s", req.Method)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,134 +0,0 @@
|
||||
package rtsp
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"github.com/AlexxIT/go2rtc/pkg/streamer"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
// Element Producer
|
||||
|
||||
func (c *Conn) GetMedias() []*streamer.Media {
|
||||
return c.Medias
|
||||
}
|
||||
|
||||
func (c *Conn) GetTrack(media *streamer.Media, codec *streamer.Codec) *streamer.Track {
|
||||
for _, track := range c.tracks {
|
||||
if track.Codec == codec {
|
||||
return track
|
||||
}
|
||||
}
|
||||
|
||||
// can't setup new tracks from play state - forcing a reconnection feature
|
||||
switch c.state {
|
||||
case StatePlay, StateHandle:
|
||||
go c.Close()
|
||||
return streamer.NewTrack(codec, media.Direction)
|
||||
}
|
||||
|
||||
track, err := c.SetupMedia(media, codec)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return track
|
||||
}
|
||||
|
||||
func (c *Conn) Start() error {
|
||||
switch c.mode {
|
||||
case ModeClientProducer:
|
||||
if err := c.Play(); err != nil {
|
||||
return err
|
||||
}
|
||||
case ModeServerProducer:
|
||||
default:
|
||||
return fmt.Errorf("start wrong mode: %d", c.mode)
|
||||
}
|
||||
|
||||
return c.Handle()
|
||||
}
|
||||
|
||||
func (c *Conn) Stop() error {
|
||||
return c.Close()
|
||||
}
|
||||
|
||||
// Consumer
|
||||
|
||||
func (c *Conn) AddTrack(media *streamer.Media, track *streamer.Track) *streamer.Track {
|
||||
switch track.Direction {
|
||||
// send our track to RTSP consumer (ex. FFmpeg)
|
||||
case streamer.DirectionSendonly:
|
||||
i := len(c.tracks)
|
||||
channelID := byte(i << 1)
|
||||
|
||||
codec := track.Codec.Clone()
|
||||
codec.PayloadType = uint8(96 + i)
|
||||
|
||||
for i, m := range c.Medias {
|
||||
if m == media {
|
||||
media.Codecs = []*streamer.Codec{codec}
|
||||
c.Medias[i] = media
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
track = c.bindTrack(track, channelID, codec.PayloadType)
|
||||
track.Codec = codec
|
||||
c.tracks = append(c.tracks, track)
|
||||
|
||||
return track
|
||||
|
||||
case streamer.DirectionRecvonly:
|
||||
panic("not implemented")
|
||||
}
|
||||
|
||||
panic("wrong direction")
|
||||
}
|
||||
|
||||
//
|
||||
|
||||
func (c *Conn) MarshalJSON() ([]byte, error) {
|
||||
v := map[string]interface{}{
|
||||
streamer.JSONReceive: c.receive,
|
||||
streamer.JSONSend: c.send,
|
||||
}
|
||||
switch c.mode {
|
||||
case ModeUnknown:
|
||||
v[streamer.JSONType] = "RTSP unknown"
|
||||
case ModeClientProducer:
|
||||
v[streamer.JSONType] = "RTSP client producer"
|
||||
case ModeServerProducer:
|
||||
v[streamer.JSONType] = "RTSP server producer"
|
||||
case ModeServerConsumer:
|
||||
v[streamer.JSONType] = "RTSP server consumer"
|
||||
}
|
||||
//if c.URI != "" {
|
||||
// v["uri"] = c.URI
|
||||
//}
|
||||
if c.URL != nil {
|
||||
v["url"] = c.URL.String()
|
||||
}
|
||||
if c.conn != nil {
|
||||
v[streamer.JSONRemoteAddr] = c.conn.RemoteAddr().String()
|
||||
}
|
||||
if c.UserAgent != "" {
|
||||
v[streamer.JSONUserAgent] = c.UserAgent
|
||||
}
|
||||
for i, media := range c.Medias {
|
||||
k := "media:" + strconv.Itoa(i)
|
||||
v[k] = media.String()
|
||||
}
|
||||
for i, track := range c.tracks {
|
||||
k := "track:" + strconv.Itoa(i)
|
||||
v[k] = track.String()
|
||||
}
|
||||
//for i, track := range c.tracks {
|
||||
// k := "track:" + strconv.Itoa(i+1)
|
||||
// if track.MimeType() == streamer.MimeTypeH264 {
|
||||
// v[k] = h264.Describe(track.Caps())
|
||||
// } else {
|
||||
// v[k] = track.MimeType()
|
||||
// }
|
||||
//}
|
||||
return json.Marshal(v)
|
||||
}
|
||||
32
pkg/shell/env.go
Normal file
32
pkg/shell/env.go
Normal file
@@ -0,0 +1,32 @@
|
||||
package shell
|
||||
|
||||
import (
|
||||
"os"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func ReplaceEnvVars(text string) string {
|
||||
re := regexp.MustCompile(`\${([^}{]+)}`)
|
||||
return re.ReplaceAllStringFunc(text, func(match string) string {
|
||||
key := match[2 : len(match)-1]
|
||||
|
||||
var def string
|
||||
var dok bool
|
||||
|
||||
if i := strings.IndexByte(key, ':'); i > 0 {
|
||||
key, def = key[:i], key[i+1:]
|
||||
dok = true
|
||||
}
|
||||
|
||||
if value, vok := os.LookupEnv(key); vok {
|
||||
return value
|
||||
}
|
||||
|
||||
if dok {
|
||||
return def
|
||||
}
|
||||
|
||||
return match
|
||||
})
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package srtp
|
||||
import (
|
||||
"encoding/binary"
|
||||
"net"
|
||||
"sync/atomic"
|
||||
)
|
||||
|
||||
// Server using same UDP port for SRTP and for SRTCP as the iPhone does
|
||||
@@ -55,6 +56,8 @@ func (s *Server) Serve(conn net.PacketConn) error {
|
||||
}
|
||||
}
|
||||
|
||||
atomic.AddUint32(&session.Recv, uint32(n))
|
||||
|
||||
if err = session.HandleRTP(buf[:n]); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ type Session struct {
|
||||
|
||||
Write func(b []byte) (int, error)
|
||||
Track *streamer.Track
|
||||
Recv uint32
|
||||
|
||||
lastSequence uint32
|
||||
lastTimestamp uint32
|
||||
|
||||
@@ -2,15 +2,19 @@ package streamer
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
JSONType = "type"
|
||||
JSONRemoteAddr = "remote_addr"
|
||||
JSONUserAgent = "user_agent"
|
||||
JSONReceive = "receive"
|
||||
JSONSend = "send"
|
||||
)
|
||||
type Info struct {
|
||||
Type string `json:"type,omitempty"`
|
||||
URL string `json:"url,omitempty"`
|
||||
RemoteAddr string `json:"remote_addr,omitempty"`
|
||||
UserAgent string `json:"user_agent,omitempty"`
|
||||
Medias []*Media `json:"medias,omitempty"`
|
||||
Tracks []*Track `json:"tracks,omitempty"`
|
||||
Recv uint32 `json:"recv,omitempty"`
|
||||
Send uint32 `json:"send,omitempty"`
|
||||
}
|
||||
|
||||
func Between(s, sub1, sub2 string) string {
|
||||
i := strings.Index(s, sub1)
|
||||
@@ -47,3 +51,31 @@ func Contains(medias []*Media, media *Media, codec *Codec) bool {
|
||||
}
|
||||
return ok1 && ok2
|
||||
}
|
||||
|
||||
type Probe struct {
|
||||
deadline time.Time
|
||||
items map[interface{}]struct{}
|
||||
}
|
||||
|
||||
func NewProbe(enable bool) *Probe {
|
||||
if enable {
|
||||
return &Probe{
|
||||
deadline: time.Now().Add(time.Second * 3),
|
||||
items: map[interface{}]struct{}{},
|
||||
}
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// Active return true if probe enabled and not finish
|
||||
func (p *Probe) Active() bool {
|
||||
return len(p.items) < 2 && time.Now().Before(p.deadline)
|
||||
}
|
||||
|
||||
// Append safe to run if Probe is nil
|
||||
func (p *Probe) Append(v interface{}) {
|
||||
if p != nil {
|
||||
p.items[v] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package streamer
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"github.com/pion/sdp/v3"
|
||||
"strconv"
|
||||
@@ -32,9 +33,12 @@ const (
|
||||
CodecAAC = "MPEG4-GENERIC"
|
||||
CodecOpus = "OPUS" // payloadType: 111
|
||||
CodecG722 = "G722"
|
||||
CodecMPA = "MPA" // payload: 14
|
||||
CodecMP3 = "MPA" // payload: 14, aka MPEG-1 Layer III
|
||||
|
||||
CodecELD = "ELD" // AAC-ELD
|
||||
|
||||
CodecAll = "ALL"
|
||||
CodecAny = "ANY"
|
||||
)
|
||||
|
||||
const PayloadTypeRAW byte = 255
|
||||
@@ -43,7 +47,7 @@ func GetKind(name string) string {
|
||||
switch name {
|
||||
case CodecH264, CodecH265, CodecVP8, CodecVP9, CodecAV1, CodecJPEG:
|
||||
return KindVideo
|
||||
case CodecPCMU, CodecPCMA, CodecAAC, CodecOpus, CodecG722, CodecMPA, CodecELD:
|
||||
case CodecPCMU, CodecPCMA, CodecAAC, CodecOpus, CodecG722, CodecMP3, CodecELD:
|
||||
return KindAudio
|
||||
}
|
||||
return ""
|
||||
@@ -59,7 +63,6 @@ type Media struct {
|
||||
|
||||
MID string `json:"mid,omitempty"` // TODO: fixme?
|
||||
Control string `json:"control,omitempty"` // TODO: fixme?
|
||||
Title string `json:"title,omitempty"` // TODO: fixme?
|
||||
}
|
||||
|
||||
func (m *Media) String() string {
|
||||
@@ -70,6 +73,10 @@ func (m *Media) String() string {
|
||||
return s
|
||||
}
|
||||
|
||||
func (m *Media) MarshalJSON() ([]byte, error) {
|
||||
return json.Marshal(m.String())
|
||||
}
|
||||
|
||||
func (m *Media) Clone() *Media {
|
||||
clone := *m
|
||||
return &clone
|
||||
@@ -107,10 +114,6 @@ func (m *Media) MatchMedia(media *Media) *Codec {
|
||||
}
|
||||
|
||||
for _, localCodec := range m.Codecs {
|
||||
if media.Codecs == nil {
|
||||
return localCodec
|
||||
}
|
||||
|
||||
for _, remoteCodec := range media.Codecs {
|
||||
if localCodec.Match(remoteCodec) {
|
||||
return localCodec
|
||||
@@ -120,6 +123,10 @@ func (m *Media) MatchMedia(media *Media) *Codec {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Media) MatchAll() bool {
|
||||
return len(m.Codecs) > 0 && m.Codecs[0].Name == CodecAll
|
||||
}
|
||||
|
||||
// Codec take best from:
|
||||
// - deepch/vdk/av.CodecData
|
||||
// - pion/webrtc.RTPCodecCapability
|
||||
@@ -149,19 +156,18 @@ func (c *Codec) Clone() *Codec {
|
||||
}
|
||||
|
||||
func (c *Codec) Match(codec *Codec) bool {
|
||||
switch codec.Name {
|
||||
case CodecAll, CodecAny:
|
||||
return true
|
||||
}
|
||||
|
||||
return c.Name == codec.Name &&
|
||||
(c.ClockRate == codec.ClockRate || codec.ClockRate == 0) &&
|
||||
(c.Channels == codec.Channels || codec.Channels == 0)
|
||||
}
|
||||
|
||||
func UnmarshalSDP(rawSDP []byte) ([]*Media, error) {
|
||||
sd := &sdp.SessionDescription{}
|
||||
if err := sd.Unmarshal(rawSDP); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var medias []*Media
|
||||
for _, md := range sd.MediaDescriptions {
|
||||
func UnmarshalMedias(descriptions []*sdp.MediaDescription) (medias []*Media) {
|
||||
for _, md := range descriptions {
|
||||
media := UnmarshalMedia(md)
|
||||
|
||||
if media.Direction == DirectionSendRecv {
|
||||
@@ -175,11 +181,25 @@ func UnmarshalSDP(rawSDP []byte) ([]*Media, error) {
|
||||
medias = append(medias, media)
|
||||
}
|
||||
|
||||
return medias, nil
|
||||
return
|
||||
}
|
||||
|
||||
func MarshalSDP(medias []*Media) ([]byte, error) {
|
||||
sd := &sdp.SessionDescription{}
|
||||
func MarshalSDP(name string, medias []*Media) ([]byte, error) {
|
||||
sd := &sdp.SessionDescription{
|
||||
Origin: sdp.Origin{
|
||||
Username: "-", SessionID: 1, SessionVersion: 1,
|
||||
NetworkType: "IN", AddressType: "IP4", UnicastAddress: "0.0.0.0",
|
||||
},
|
||||
SessionName: sdp.SessionName(name),
|
||||
ConnectionInformation: &sdp.ConnectionInformation{
|
||||
NetworkType: "IN", AddressType: "IP4", Address: &sdp.Address{
|
||||
Address: "0.0.0.0",
|
||||
},
|
||||
},
|
||||
TimeDescriptions: []sdp.TimeDescription{
|
||||
{Timing: sdp.Timing{}},
|
||||
},
|
||||
}
|
||||
|
||||
payloadType := uint8(96)
|
||||
|
||||
@@ -267,7 +287,7 @@ func UnmarshalCodec(md *sdp.MediaDescription, payloadType string) *Codec {
|
||||
c.Name = CodecPCMA
|
||||
c.ClockRate = 8000
|
||||
case "14":
|
||||
c.Name = CodecMPA
|
||||
c.Name = CodecMP3
|
||||
c.ClockRate = 44100
|
||||
case "26":
|
||||
c.Name = CodecJPEG
|
||||
@@ -280,6 +300,40 @@ func UnmarshalCodec(md *sdp.MediaDescription, payloadType string) *Codec {
|
||||
return c
|
||||
}
|
||||
|
||||
func ParseQuery(query map[string][]string) (medias []*Media) {
|
||||
// set media candidates from query list
|
||||
for key, values := range query {
|
||||
switch key {
|
||||
case KindVideo, KindAudio:
|
||||
for _, value := range values {
|
||||
media := &Media{Kind: key, Direction: DirectionRecvonly}
|
||||
|
||||
for _, name := range strings.Split(value, ",") {
|
||||
name = strings.ToUpper(name)
|
||||
|
||||
// check aliases
|
||||
switch name {
|
||||
case "", "COPY":
|
||||
name = CodecAny
|
||||
case "MJPEG":
|
||||
name = CodecJPEG
|
||||
case "AAC":
|
||||
name = CodecAAC
|
||||
case "MP3":
|
||||
name = CodecMP3
|
||||
}
|
||||
|
||||
media.Codecs = append(media.Codecs, &Codec{Name: name})
|
||||
}
|
||||
|
||||
medias = append(medias, media)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func atoi(s string) (i int) {
|
||||
i, _ = strconv.Atoi(s)
|
||||
return
|
||||
|
||||
42
pkg/streamer/media_test.go
Normal file
42
pkg/streamer/media_test.go
Normal file
@@ -0,0 +1,42 @@
|
||||
package streamer
|
||||
|
||||
import (
|
||||
"github.com/pion/sdp/v3"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"net/url"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSDP(t *testing.T) {
|
||||
medias := []*Media{{
|
||||
Kind: KindAudio, Direction: DirectionSendonly,
|
||||
Codecs: []*Codec{
|
||||
{Name: CodecPCMU, ClockRate: 8000},
|
||||
},
|
||||
}}
|
||||
|
||||
data, err := MarshalSDP("go2rtc/1.0.0", medias)
|
||||
assert.Empty(t, err)
|
||||
|
||||
sd := &sdp.SessionDescription{}
|
||||
err = sd.Unmarshal(data)
|
||||
assert.Empty(t, err)
|
||||
}
|
||||
|
||||
func TestParseQuery(t *testing.T) {
|
||||
u, _ := url.Parse("rtsp://localhost:8554/camera1")
|
||||
medias := ParseQuery(u.Query())
|
||||
assert.Nil(t, medias)
|
||||
|
||||
for _, rawULR := range []string{
|
||||
"rtsp://localhost:8554/camera1?video",
|
||||
"rtsp://localhost:8554/camera1?video=copy",
|
||||
"rtsp://localhost:8554/camera1?video=any",
|
||||
} {
|
||||
u, _ = url.Parse(rawULR)
|
||||
medias = ParseQuery(u.Query())
|
||||
assert.Equal(t, []*Media{
|
||||
{Kind: KindVideo, Direction: DirectionRecvonly, Codecs: []*Codec{{Name: CodecAny}}},
|
||||
}, medias)
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package streamer
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"github.com/pion/rtp"
|
||||
"sync"
|
||||
@@ -20,14 +21,28 @@ func NewTrack(codec *Codec, direction string) *Track {
|
||||
return &Track{Codec: codec, Direction: direction, sinkMu: new(sync.RWMutex)}
|
||||
}
|
||||
|
||||
func NewTrack2(media *Media, codec *Codec) *Track {
|
||||
if codec == nil {
|
||||
codec = media.Codecs[0]
|
||||
}
|
||||
return &Track{Codec: codec, Direction: media.Direction, sinkMu: new(sync.RWMutex)}
|
||||
}
|
||||
|
||||
func (t *Track) String() string {
|
||||
s := t.Codec.String()
|
||||
t.sinkMu.RLock()
|
||||
s += fmt.Sprintf(", sinks=%d", len(t.sink))
|
||||
t.sinkMu.RUnlock()
|
||||
if t.sinkMu.TryRLock() {
|
||||
s += fmt.Sprintf(", sinks=%d", len(t.sink))
|
||||
t.sinkMu.RUnlock()
|
||||
} else {
|
||||
s += fmt.Sprintf(", sinks=?")
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func (t *Track) MarshalJSON() ([]byte, error) {
|
||||
return json.Marshal(t.String())
|
||||
}
|
||||
|
||||
func (t *Track) WriteRTP(p *rtp.Packet) error {
|
||||
t.sinkMu.RLock()
|
||||
for _, f := range t.sink {
|
||||
|
||||
53
pkg/tapo/backchannel.go
Normal file
53
pkg/tapo/backchannel.go
Normal file
@@ -0,0 +1,53 @@
|
||||
package tapo
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"github.com/AlexxIT/go2rtc/pkg/mpegts"
|
||||
"github.com/AlexxIT/go2rtc/pkg/streamer"
|
||||
"github.com/pion/rtp"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
func (c *Client) backchannelWriter() streamer.WriterFunc {
|
||||
w := mpegts.NewWriter()
|
||||
w.AddPES(68, mpegts.StreamTypePCMATapo)
|
||||
w.WritePAT()
|
||||
w.WritePMT()
|
||||
|
||||
return func(packet *rtp.Packet) (err error) {
|
||||
// don't know why 68 and 192
|
||||
w.WritePES(68, 192, packet.Payload)
|
||||
err = c.WriteBackchannel(w.Bytes())
|
||||
w.Reset()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) SetupBackchannel() (err error) {
|
||||
// if conn1 is not used - we will use it for backchannel
|
||||
// or we need to start another conn for session2
|
||||
if c.session1 != "" {
|
||||
if c.conn2, err = c.newConn(); err != nil {
|
||||
return
|
||||
}
|
||||
} else {
|
||||
c.conn2 = c.conn1
|
||||
}
|
||||
|
||||
c.session2, err = c.Request(c.conn2, []byte(`{"params":{"talk":{"mode":"aec"},"method":"get"},"seq":3,"type":"request"}`))
|
||||
return
|
||||
}
|
||||
|
||||
func (c *Client) WriteBackchannel(body []byte) (err error) {
|
||||
// TODO: fixme (size)
|
||||
buf := bytes.NewBuffer(nil)
|
||||
buf.WriteString("----client-stream-boundary--\r\n")
|
||||
buf.WriteString("Content-Type: audio/mp2t\r\n")
|
||||
buf.WriteString("X-If-Encrypt: 0\r\n")
|
||||
buf.WriteString("X-Session-Id: " + c.session2 + "\r\n")
|
||||
buf.WriteString("Content-Length: " + strconv.Itoa(len(body)) + "\r\n\r\n")
|
||||
buf.Write(body)
|
||||
|
||||
_, err = buf.WriteTo(c.conn2)
|
||||
return
|
||||
}
|
||||
229
pkg/tapo/client.go
Normal file
229
pkg/tapo/client.go
Normal file
@@ -0,0 +1,229 @@
|
||||
package tapo
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/md5"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/AlexxIT/go2rtc/pkg/mpegts"
|
||||
"github.com/AlexxIT/go2rtc/pkg/streamer"
|
||||
"github.com/AlexxIT/go2rtc/pkg/tcp"
|
||||
"mime/multipart"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
type Client struct {
|
||||
streamer.Element
|
||||
|
||||
url string
|
||||
|
||||
medias []*streamer.Media
|
||||
tracks map[byte]*streamer.Track
|
||||
|
||||
conn1 net.Conn
|
||||
conn2 net.Conn
|
||||
|
||||
decrypt func(b []byte) []byte
|
||||
|
||||
session1 string
|
||||
session2 string
|
||||
}
|
||||
|
||||
// block ciphers using cipher block chaining.
|
||||
type cbcMode interface {
|
||||
cipher.BlockMode
|
||||
SetIV([]byte)
|
||||
}
|
||||
|
||||
func NewClient(url string) *Client {
|
||||
return &Client{url: url}
|
||||
}
|
||||
|
||||
func (c *Client) Dial() (err error) {
|
||||
c.conn1, err = c.newConn()
|
||||
return
|
||||
}
|
||||
|
||||
func (c *Client) newConn() (net.Conn, error) {
|
||||
u, err := url.Parse(c.url)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// support raw username/password
|
||||
username := u.User.Username()
|
||||
password, _ := u.User.Password()
|
||||
|
||||
// or cloud password in place of username
|
||||
if password == "" {
|
||||
password = fmt.Sprintf("%16X", md5.Sum([]byte(username)))
|
||||
username = "admin"
|
||||
u.User = url.UserPassword(username, password)
|
||||
}
|
||||
|
||||
u.Scheme = "http"
|
||||
u.Path = "/stream"
|
||||
if u.Port() == "" {
|
||||
u.Host += ":8800"
|
||||
}
|
||||
|
||||
req, err := http.NewRequest("POST", u.String(), nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
req.Header.Set("Content-Type", "multipart/mixed; boundary=--client-stream-boundary--")
|
||||
|
||||
res, err := tcp.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if res.StatusCode != http.StatusOK {
|
||||
return nil, errors.New(res.Status)
|
||||
}
|
||||
|
||||
if c.decrypt == nil {
|
||||
c.newDectypter(res, username, password)
|
||||
}
|
||||
|
||||
return res.Body.(net.Conn), nil
|
||||
}
|
||||
|
||||
func (c *Client) newDectypter(res *http.Response, username, password string) {
|
||||
// extract nonce from response
|
||||
// cipher="AES_128_CBC" username="admin" padding="PKCS7_16" algorithm="MD5" nonce="***"
|
||||
nonce := res.Header.Get("Key-Exchange")
|
||||
nonce = streamer.Between(nonce, `nonce="`, `"`)
|
||||
|
||||
key := md5.Sum([]byte(nonce + ":" + password))
|
||||
iv := md5.Sum([]byte(username + ":" + nonce))
|
||||
|
||||
block, err := aes.NewCipher(key[:])
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
cbc := cipher.NewCBCDecrypter(block, iv[:]).(cbcMode)
|
||||
|
||||
c.decrypt = func(b []byte) []byte {
|
||||
// restore IV
|
||||
cbc.SetIV(iv[:])
|
||||
|
||||
// decrypt
|
||||
cbc.CryptBlocks(b, b)
|
||||
|
||||
// unpad
|
||||
padSize := int(b[len(b)-1])
|
||||
return b[:len(b)-padSize]
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) SetupStream() (err error) {
|
||||
if c.session1 != "" {
|
||||
return
|
||||
}
|
||||
|
||||
// audio: default, disable, enable
|
||||
c.session1, err = c.Request(c.conn1, []byte(`{"params":{"preview":{"audio":["default"],"channels":[0],"resolutions":["HD"]},"method":"get"},"seq":1,"type":"request"}`))
|
||||
return
|
||||
}
|
||||
|
||||
// Handle - first run will be in probe state
|
||||
func (c *Client) Handle() error {
|
||||
mpReader := multipart.NewReader(c.conn1, "--device-stream-boundary--")
|
||||
tsReader := mpegts.NewReader()
|
||||
|
||||
for {
|
||||
p, err := mpReader.NextRawPart()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if ct := p.Header.Get("Content-Type"); ct != "video/mp2t" {
|
||||
continue
|
||||
}
|
||||
|
||||
cl := p.Header.Get("Content-Length")
|
||||
size, err := strconv.Atoi(cl)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
body := make([]byte, size)
|
||||
|
||||
b := body
|
||||
for {
|
||||
if n, err2 := p.Read(b); err2 == nil {
|
||||
b = b[n:]
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
body = c.decrypt(body)
|
||||
tsReader.SetBuffer(body)
|
||||
|
||||
for {
|
||||
pkt := tsReader.GetPacket()
|
||||
if pkt == nil {
|
||||
break
|
||||
}
|
||||
|
||||
if track := c.tracks[pkt.PayloadType]; track != nil {
|
||||
_ = track.WriteRTP(pkt)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) Close() (err error) {
|
||||
if c.conn1 != nil {
|
||||
err = c.conn1.Close()
|
||||
}
|
||||
if c.conn2 != nil {
|
||||
_ = c.conn2.Close()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func (c *Client) Request(conn net.Conn, body []byte) (string, error) {
|
||||
// TODO: fixme (size)
|
||||
buf := bytes.NewBuffer(nil)
|
||||
buf.WriteString("----client-stream-boundary--\r\n")
|
||||
buf.WriteString("Content-Type: application/json\r\n")
|
||||
buf.WriteString("Content-Length: " + strconv.Itoa(len(body)) + "\r\n\r\n")
|
||||
buf.Write(body)
|
||||
buf.WriteString("\r\n")
|
||||
|
||||
if _, err := buf.WriteTo(conn); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
mpReader := multipart.NewReader(conn, "--device-stream-boundary--")
|
||||
|
||||
for {
|
||||
p, err := mpReader.NextRawPart()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
var v struct {
|
||||
Params struct {
|
||||
SessionID string `json:"session_id"`
|
||||
} `json:"params"`
|
||||
}
|
||||
|
||||
if err = json.NewDecoder(p).Decode(&v); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return v.Params.SessionID, nil
|
||||
}
|
||||
}
|
||||
18
pkg/tapo/consumer.go
Normal file
18
pkg/tapo/consumer.go
Normal file
@@ -0,0 +1,18 @@
|
||||
package tapo
|
||||
|
||||
import (
|
||||
"github.com/AlexxIT/go2rtc/pkg/streamer"
|
||||
"github.com/pion/rtp"
|
||||
)
|
||||
|
||||
func (c *Client) AddTrack(media *streamer.Media, track *streamer.Track) *streamer.Track {
|
||||
consCodec := media.MatchCodec(track.Codec)
|
||||
consTrack := c.GetTrack(media, consCodec)
|
||||
if consTrack == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
return track.Bind(func(packet *rtp.Packet) error {
|
||||
return consTrack.WriteRTP(packet)
|
||||
})
|
||||
}
|
||||
88
pkg/tapo/producer.go
Normal file
88
pkg/tapo/producer.go
Normal file
@@ -0,0 +1,88 @@
|
||||
package tapo
|
||||
|
||||
import (
|
||||
"github.com/AlexxIT/go2rtc/pkg/mpegts"
|
||||
"github.com/AlexxIT/go2rtc/pkg/streamer"
|
||||
)
|
||||
|
||||
func (c *Client) GetMedias() []*streamer.Media {
|
||||
// producer should have persistent medias
|
||||
if c.medias == nil {
|
||||
// don't know if all Tapo has this capabilities...
|
||||
c.medias = []*streamer.Media{
|
||||
{
|
||||
Kind: streamer.KindVideo,
|
||||
Direction: streamer.DirectionSendonly,
|
||||
Codecs: []*streamer.Codec{
|
||||
{Name: streamer.CodecH264, ClockRate: 90000, PayloadType: streamer.PayloadTypeRAW},
|
||||
},
|
||||
},
|
||||
{
|
||||
Kind: streamer.KindAudio,
|
||||
Direction: streamer.DirectionSendonly,
|
||||
Codecs: []*streamer.Codec{
|
||||
{Name: streamer.CodecPCMA, ClockRate: 8000, PayloadType: 8},
|
||||
},
|
||||
},
|
||||
{
|
||||
Kind: streamer.KindAudio,
|
||||
Direction: streamer.DirectionRecvonly,
|
||||
Codecs: []*streamer.Codec{
|
||||
{Name: streamer.CodecPCMA, ClockRate: 8000, PayloadType: 8},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
return c.medias
|
||||
}
|
||||
|
||||
func (c *Client) GetTrack(media *streamer.Media, codec *streamer.Codec) (track *streamer.Track) {
|
||||
for _, track := range c.tracks {
|
||||
if track.Codec == codec {
|
||||
return track
|
||||
}
|
||||
}
|
||||
|
||||
if c.tracks == nil {
|
||||
c.tracks = map[byte]*streamer.Track{}
|
||||
}
|
||||
|
||||
if media.Kind == streamer.KindVideo {
|
||||
if err := c.SetupStream(); err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
track = streamer.NewTrack2(media, codec)
|
||||
c.tracks[mpegts.StreamTypeH264] = track
|
||||
} else {
|
||||
if media.Direction == streamer.DirectionSendonly {
|
||||
if err := c.SetupStream(); err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
track = streamer.NewTrack2(media, codec)
|
||||
c.tracks[mpegts.StreamTypePCMATapo] = track
|
||||
} else {
|
||||
if err := c.SetupBackchannel(); err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
if w := c.backchannelWriter(); w != nil {
|
||||
track = streamer.NewTrack2(media, codec)
|
||||
track.Bind(w)
|
||||
c.tracks[0] = track
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func (c *Client) Start() error {
|
||||
return c.Handle()
|
||||
}
|
||||
|
||||
func (c *Client) Stop() error {
|
||||
return c.Close()
|
||||
}
|
||||
@@ -22,6 +22,7 @@ const (
|
||||
AuthUnknown
|
||||
AuthBasic
|
||||
AuthDigest
|
||||
AuthTPLink // https://drmnsamoliu.github.io/video.html
|
||||
)
|
||||
|
||||
func NewAuth(user *url.Userinfo) *Auth {
|
||||
@@ -70,13 +71,17 @@ func (a *Auth) Write(req *Request) {
|
||||
case AuthBasic:
|
||||
req.Header.Set("Authorization", a.header)
|
||||
case AuthDigest:
|
||||
uri := req.URL.RequestURI()
|
||||
// important to use String except RequestURL for RtspServer:
|
||||
// https://github.com/AlexxIT/go2rtc/issues/244
|
||||
uri := req.URL.String()
|
||||
h2 := HexMD5(req.Method, uri)
|
||||
response := HexMD5(a.h1nonce, h2)
|
||||
header := a.header + fmt.Sprintf(
|
||||
`, uri="%s", response="%s"`, uri, response,
|
||||
)
|
||||
req.Header.Set("Authorization", header)
|
||||
case AuthTPLink:
|
||||
req.URL.Host = "127.0.0.1"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -98,6 +103,15 @@ func (a *Auth) Validate(req *Request) bool {
|
||||
return header == a.header
|
||||
}
|
||||
|
||||
func (a *Auth) ReadNone(res *Response) bool {
|
||||
auth := res.Header.Get("WWW-Authenticate")
|
||||
if strings.Contains(auth, "TP-LINK Streaming Media") {
|
||||
a.Method = AuthTPLink
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func Between(s, sub1, sub2 string) string {
|
||||
i := strings.Index(s, sub1)
|
||||
if i < 0 {
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
package tcp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -10,8 +12,22 @@ import (
|
||||
|
||||
// Do - http.Client with support Digest Authorization
|
||||
func Do(req *http.Request) (*http.Response, error) {
|
||||
// need to create new client each time to reset timeout
|
||||
var conn net.Conn
|
||||
|
||||
client := http.Client{Timeout: time.Second * 5000}
|
||||
|
||||
// for multipart requests return conn as Body (for write support)
|
||||
if ct := req.Header.Get("Content-Type"); strings.HasPrefix(ct, "multipart/mixed") {
|
||||
var d net.Dialer
|
||||
client.Transport = &http.Transport{
|
||||
DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
|
||||
var err error
|
||||
conn, err = d.DialContext(ctx, network, addr)
|
||||
return conn, err
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
res, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -64,5 +80,9 @@ func Do(req *http.Request) (*http.Response, error) {
|
||||
}
|
||||
}
|
||||
|
||||
if conn != nil {
|
||||
res.Body = conn
|
||||
}
|
||||
|
||||
return res, nil
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package webrtc
|
||||
|
||||
import (
|
||||
"github.com/AlexxIT/go2rtc/pkg/streamer"
|
||||
"github.com/pion/sdp/v3"
|
||||
"github.com/pion/webrtc/v3"
|
||||
)
|
||||
|
||||
@@ -90,12 +91,15 @@ func (c *Conn) SetOffer(offer string) (err error) {
|
||||
if err = c.Conn.SetRemoteDescription(sdOffer); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
rawSDP := []byte(c.Conn.RemoteDescription().SDP)
|
||||
medias, err := streamer.UnmarshalSDP(rawSDP)
|
||||
if err != nil {
|
||||
sd := &sdp.SessionDescription{}
|
||||
if err = sd.Unmarshal(rawSDP); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
medias := streamer.UnmarshalMedias(sd.MediaDescriptions)
|
||||
|
||||
// sort medias, so video will always be before audio
|
||||
// and ignore application media from Hass default lovelace card
|
||||
for _, media := range medias {
|
||||
@@ -150,6 +154,10 @@ func (c *Conn) GetCompleteAnswer() (answer string, err error) {
|
||||
return c.Conn.LocalDescription().SDP, nil
|
||||
}
|
||||
|
||||
func (c *Conn) AddCandidate(candidate string) {
|
||||
_ = c.Conn.AddICECandidate(webrtc.ICECandidateInit{Candidate: candidate})
|
||||
}
|
||||
|
||||
func (c *Conn) remote() string {
|
||||
if c.Conn == nil {
|
||||
return ""
|
||||
|
||||
@@ -106,27 +106,13 @@ func (c *Conn) AddTrack(media *streamer.Media, track *streamer.Track) *streamer.
|
||||
panic("wrong direction")
|
||||
}
|
||||
|
||||
//
|
||||
|
||||
func (c *Conn) AddCandidate(candidate string) {
|
||||
_ = c.Conn.AddICECandidate(webrtc.ICECandidateInit{Candidate: candidate})
|
||||
}
|
||||
|
||||
func (c *Conn) MarshalJSON() ([]byte, error) {
|
||||
v := map[string]interface{}{
|
||||
streamer.JSONType: "WebRTC server consumer",
|
||||
streamer.JSONRemoteAddr: c.remote(),
|
||||
info := &streamer.Info{
|
||||
Type: "WebRTC client",
|
||||
RemoteAddr: c.remote(),
|
||||
UserAgent: c.UserAgent,
|
||||
Recv: uint32(c.receive),
|
||||
Send: uint32(c.send),
|
||||
}
|
||||
|
||||
if c.receive > 0 {
|
||||
v[streamer.JSONReceive] = c.receive
|
||||
}
|
||||
if c.send > 0 {
|
||||
v[streamer.JSONSend] = c.send
|
||||
}
|
||||
if c.UserAgent != "" {
|
||||
v[streamer.JSONUserAgent] = c.UserAgent
|
||||
}
|
||||
|
||||
return json.Marshal(v)
|
||||
return json.Marshal(info)
|
||||
}
|
||||
|
||||
@@ -25,13 +25,18 @@ func NewCandidate(network, address string) (string, error) {
|
||||
return "", err
|
||||
}
|
||||
|
||||
cand, err := ice.NewCandidateHost(&ice.CandidateHostConfig{
|
||||
config := &ice.CandidateHostConfig{
|
||||
Network: network,
|
||||
Address: host,
|
||||
Port: i,
|
||||
Component: ice.ComponentRTP,
|
||||
TCPType: ice.TCPTypePassive,
|
||||
})
|
||||
}
|
||||
|
||||
if network == "tcp" {
|
||||
config.TCPType = ice.TCPTypePassive
|
||||
}
|
||||
|
||||
cand, err := ice.NewCandidateHost(config)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
@@ -48,6 +48,18 @@ pc.ontrack = ev => {
|
||||
}
|
||||
```
|
||||
|
||||
## Chromecast 1
|
||||
|
||||
2023-02-02. Error:
|
||||
|
||||
```
|
||||
InvalidStateError: Failed to execute 'addTransceiver' on 'RTCPeerConnection': This operation is only supported in 'unified-plan'. 'unified-plan' will become the default behavior in the future, but it is currently experimental. To try it out, construct the RTCPeerConnection with sdpSemantics:'unified-plan' present in the RTCConfiguration argument.
|
||||
```
|
||||
|
||||
User-Agent: `Mozilla/5.0 (X11; Linux armv7l) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/70.0.3538.47 Safari/537.36 CrKey/1.36.159268`
|
||||
|
||||
https://webrtc.org/getting-started/unified-plan-transition-guide?hl=en
|
||||
|
||||
## Useful links
|
||||
|
||||
- https://www.webrtc-experiment.com/DetectRTC/
|
||||
@@ -56,3 +68,6 @@ pc.ontrack = ev => {
|
||||
- https://web.dev/i18n/en/fast-playback-with-preload/#manual_buffering
|
||||
- https://developer.mozilla.org/en-US/docs/Web/API/Media_Source_Extensions_API
|
||||
- https://chromium.googlesource.com/external/w3c/web-platform-tests/+/refs/heads/master/media-source/mediasource-is-type-supported.html
|
||||
- https://googlechrome.github.io/samples/media/sourcebuffer-changetype.html
|
||||
- https://chromestatus.com/feature/5100845653819392
|
||||
- https://developer.apple.com/documentation/webkit/delivering_video_content_for_safari
|
||||
|
||||
@@ -53,7 +53,7 @@
|
||||
const video = document.createElement("video");
|
||||
out.innerText += "video.canPlayType\n";
|
||||
types.forEach(type => {
|
||||
out.innerText += type + "=" + (video.canPlayType(type) ? "true" : "false") + "\n";
|
||||
out.innerText += `${type} = ${MediaSource.isTypeSupported(type)} / ${video.canPlayType(type)}\n`;
|
||||
})
|
||||
|
||||
</script>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user