mirror of
https://github.com/AlexxIT/go2rtc.git
synced 2025-09-27 04:36:12 +08:00
Compare commits
92 Commits
Author | SHA1 | Date | |
---|---|---|---|
![]() |
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 | ||
![]() |
4ae733aa11 | ||
![]() |
27d8b33b62 | ||
![]() |
ff8b0fbb9c | ||
![]() |
c6ad7ac39f | ||
![]() |
7a3adf17be | ||
![]() |
94f6c07b28 | ||
![]() |
7b326d4753 | ||
![]() |
5407a3bc4b | ||
![]() |
6b24421722 | ||
![]() |
d12775a2d7 | ||
![]() |
6151593c08 | ||
![]() |
dba0989c54 | ||
![]() |
ba0c7d911d | ||
![]() |
09fefca712 | ||
![]() |
b3f177e2ec | ||
![]() |
228abb8fbe | ||
![]() |
eee70c07b7 | ||
![]() |
d92b0f29af | ||
![]() |
fca6c87b2c | ||
![]() |
0601091772 | ||
![]() |
89eb653d67 | ||
![]() |
0e49ffdfff | ||
![]() |
bd2fc1252d | ||
![]() |
78ac88448c | ||
![]() |
4cd9757e53 | ||
![]() |
f9cb6fd670 | ||
![]() |
57fa6a5530 | ||
![]() |
6906b56524 | ||
![]() |
c9b0806c84 | ||
![]() |
a9d1e64f88 | ||
![]() |
9e9f07f3f7 | ||
![]() |
b51aabd3d9 | ||
![]() |
368562c540 | ||
![]() |
6d6e7010b4 | ||
![]() |
4157a53dd8 | ||
![]() |
bdf5654c01 | ||
![]() |
66f729aa0e | ||
![]() |
96d1ef2d2c | ||
![]() |
9739f7f416 | ||
![]() |
654fa32b3a | ||
![]() |
db2263c7fe | ||
![]() |
e6c36f1cf7 | ||
![]() |
110f90cb34 | ||
![]() |
aca3bab238 | ||
![]() |
4df44645d7 | ||
![]() |
097fdfbbb8 | ||
![]() |
dc21a04da7 | ||
![]() |
db255b476a | ||
![]() |
464ea417ef | ||
![]() |
c1fac66329 | ||
![]() |
a6057a2eca | ||
![]() |
7c69ba13b0 | ||
![]() |
2b8bfe8bd9 | ||
![]() |
0bd54da456 | ||
![]() |
9f6af1c9e4 | ||
![]() |
c9dd0e37e4 | ||
![]() |
562872beb8 | ||
![]() |
46a278c067 | ||
![]() |
270fc7c1b6 | ||
![]() |
6feb635522 | ||
![]() |
6f48131e4d | ||
![]() |
f120db71a3 | ||
![]() |
72823af9d0 | ||
![]() |
15d9d4ebf4 | ||
![]() |
b09bbd79c4 | ||
![]() |
1830273f02 | ||
![]() |
07f3972794 |
59
.github/workflows/builder.yml
vendored
59
.github/workflows/builder.yml
vendored
@@ -1,59 +0,0 @@
|
||||
# https://github.com/home-assistant/builder
|
||||
name: 'Builder'
|
||||
|
||||
on:
|
||||
push:
|
||||
tags: [ 'v*' ]
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
hassio:
|
||||
name: Hassio Addon
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout the repository
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Login to DockerHub
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Branch name
|
||||
run: |
|
||||
VERSION="${GITHUB_REF#refs/tags/v}"
|
||||
echo "REPO=alexxit/go2rtc" >> $GITHUB_ENV
|
||||
echo "TAG=${VERSION}" >> $GITHUB_ENV
|
||||
echo "IMAGE=alexxit/go2rtc:${VERSION}" >> $GITHUB_ENV
|
||||
|
||||
- name: Build amd64
|
||||
uses: home-assistant/builder@master
|
||||
with:
|
||||
args: --amd64 --target build/hassio --version $TAG-amd64 --no-latest --docker-hub-check
|
||||
|
||||
- name: Build i386
|
||||
uses: home-assistant/builder@master
|
||||
with:
|
||||
args: --i386 --target build/hassio --version $TAG-i386 --no-latest --docker-hub-check
|
||||
|
||||
- name: Build aarch64
|
||||
uses: home-assistant/builder@master
|
||||
with:
|
||||
args: --aarch64 --target build/hassio --version $TAG-aarch64 --no-latest --docker-hub-check
|
||||
|
||||
- name: Build armv7
|
||||
uses: home-assistant/builder@master
|
||||
with:
|
||||
args: --armv7 --target build/hassio --version $TAG-armv7 --no-latest --docker-hub-check
|
||||
|
||||
- name: Docker manifest
|
||||
run: |
|
||||
# thanks to https://github.com/aler9/rtsp-simple-server/blob/main/Makefile
|
||||
docker manifest create "${IMAGE}" \
|
||||
"${IMAGE}-amd64" "${IMAGE}-i386" "${IMAGE}-aarch64" "${IMAGE}-armv7"
|
||||
docker manifest push "${IMAGE}"
|
||||
|
||||
docker manifest create "${REPO}:latest" \
|
||||
"${IMAGE}-amd64" "${IMAGE}-i386" "${IMAGE}-aarch64" "${IMAGE}-armv7"
|
||||
docker manifest push "${REPO}:latest"
|
75
.github/workflows/docker.yml
vendored
Normal file
75
.github/workflows/docker.yml
vendored
Normal file
@@ -0,0 +1,75 @@
|
||||
name: docker
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches:
|
||||
- 'master'
|
||||
tags:
|
||||
- 'v*'
|
||||
|
||||
jobs:
|
||||
build-and-push:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@v4
|
||||
with:
|
||||
images: ${{ github.repository }}
|
||||
tags: |
|
||||
type=ref,event=branch
|
||||
type=semver,pattern={{version}},enable=false
|
||||
type=match,pattern=v(.*),group=1
|
||||
|
||||
- name: Docker meta Hardware
|
||||
id: meta-hw
|
||||
uses: docker/metadata-action@v4
|
||||
with:
|
||||
images: ${{ github.repository }}
|
||||
flavor: |
|
||||
suffix=-hardware
|
||||
latest=false
|
||||
tags: |
|
||||
type=ref,event=branch
|
||||
type=semver,pattern={{version}},enable=false
|
||||
type=match,pattern=v(.*),group=1
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v2
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v2
|
||||
|
||||
- name: Login to DockerHub
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v3
|
||||
with:
|
||||
context: .
|
||||
platforms: |
|
||||
linux/amd64
|
||||
linux/386
|
||||
linux/arm/v7
|
||||
linux/arm64/v8
|
||||
push: ${{ github.event_name != 'pull_request' }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
|
||||
- name: Build and push Hardware
|
||||
uses: docker/build-push-action@v3
|
||||
with:
|
||||
context: .
|
||||
file: hardware.Dockerfile
|
||||
platforms: linux/amd64
|
||||
push: ${{ github.event_name != 'pull_request' }}
|
||||
tags: ${{ steps.meta-hw.outputs.tags }}
|
||||
labels: ${{ steps.meta-hw.outputs.labels }}
|
90
.github/workflows/release.yml
vendored
Normal file
90
.github/workflows/release.yml
vendored
Normal file
@@ -0,0 +1,90 @@
|
||||
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: Build Go binaries
|
||||
run: |
|
||||
#!/bin/bash
|
||||
|
||||
mkdir 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
|
||||
export FILENAME=go2rtc_mac_amd64.zip
|
||||
go build -ldflags "-s -w" -trimpath && 7z a -mx9 -sdel "$FILENAME" go2rtc
|
||||
|
||||
export GOOS=darwin
|
||||
export GOARCH=arm64
|
||||
export FILENAME=go2rtc_mac_arm64.zip
|
||||
go build -ldflags "-s -w" -trimpath && 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
|
62
Dockerfile
Normal file
62
Dockerfile
Normal file
@@ -0,0 +1,62 @@
|
||||
# syntax=docker/dockerfile:labs
|
||||
|
||||
# 0. Prepare images
|
||||
ARG PYTHON_VERSION="3.11"
|
||||
ARG GO_VERSION="1.19"
|
||||
ARG NGROK_VERSION="3"
|
||||
|
||||
FROM python:${PYTHON_VERSION}-alpine AS base
|
||||
FROM ngrok/ngrok:${NGROK_VERSION}-alpine AS ngrok
|
||||
|
||||
|
||||
# 1. Build go2rtc binary
|
||||
FROM --platform=$BUILDPLATFORM golang:${GO_VERSION}-alpine AS build
|
||||
ARG TARGETPLATFORM
|
||||
ARG TARGETOS
|
||||
ARG TARGETARCH
|
||||
|
||||
ENV GOOS=${TARGETOS}
|
||||
ENV GOARCH=${TARGETARCH}
|
||||
|
||||
WORKDIR /build
|
||||
|
||||
# Cache dependencies
|
||||
COPY go.mod go.sum ./
|
||||
RUN --mount=type=cache,target=/root/.cache/go-build go mod download
|
||||
|
||||
COPY . .
|
||||
RUN --mount=type=cache,target=/root/.cache/go-build CGO_ENABLED=0 go build -ldflags "-s -w" -trimpath
|
||||
|
||||
|
||||
# 2. Collect all files
|
||||
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),
|
||||
# and other common tools for the echo source.
|
||||
RUN apk add --no-cache tini ffmpeg bash curl jq
|
||||
|
||||
# Hardware Acceleration for Intel CPU (+50MB)
|
||||
ARG TARGETARCH
|
||||
|
||||
RUN if [ "${TARGETARCH}" = "amd64" ]; then apk add --no-cache libva-intel-driver intel-media-driver; fi
|
||||
|
||||
# Hardware: AMD and NVidia VAAPI (not sure about this)
|
||||
# RUN libva-glx mesa-va-gallium
|
||||
# Hardware: AMD and NVidia VDPAU (not sure about this)
|
||||
# RUN libva-vdpau-driver mesa-vdpau-gallium (+150MB total)
|
||||
|
||||
COPY --from=rootfs / /
|
||||
|
||||
RUN chmod a+x /run.sh && mkdir -p /config
|
||||
|
||||
ENTRYPOINT ["/sbin/tini", "--"]
|
||||
|
||||
CMD ["/run.sh"]
|
128
README.md
128
README.md
@@ -6,10 +6,10 @@ 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), [MJPEG](#source-ffmpeg), [HLS/HTTP](#source-ffmpeg), [USB Cameras](#source-ffmpeg-device) and [other sources](#module-streams)
|
||||
- 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)
|
||||
- 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
|
||||
@@ -37,7 +37,6 @@ Ultimate camera streaming application with support RTSP, WebRTC, HomeKit, FFmpeg
|
||||
- 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:**
|
||||
|
||||
@@ -54,9 +53,9 @@ Download binary for your OS from [latest release](https://github.com/AlexxIT/go2
|
||||
- `go2rtc_linux_i386` - Linux 32-bit
|
||||
- `go2rtc_linux_arm64` - Linux ARM 64-bit (ex. Raspberry 64-bit OS)
|
||||
- `go2rtc_linux_arm` - Linux ARM 32-bit (ex. Raspberry 32-bit OS)
|
||||
- `go2rtc_linux_mipsel` - Linux on MIPS (ex. [Xiaomi Gateway 3](https://github.com/AlexxIT/XiaomiGateway3))
|
||||
- `go2rtc_mac_amd64` - Mac with Intel
|
||||
- `go2rtc_mac_arm64` - Mac with M1
|
||||
- `go2rtc_linux_mipsel` - Linux MIPS (ex. [Xiaomi Gateway 3](https://github.com/AlexxIT/XiaomiGateway3))
|
||||
- `go2rtc_mac_amd64.zip` - Mac Intel 64-bit
|
||||
- `go2rtc_mac_arm64.zip` - Mac ARM 64-bit
|
||||
|
||||
Don't forget to fix the rights `chmod +x go2rtc_xxx_xxx` on Linux and Mac.
|
||||
|
||||
@@ -71,27 +70,17 @@ 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 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"
|
||||
```
|
||||
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).
|
||||
|
||||
## Configuration
|
||||
|
||||
Create file `go2rtc.yaml` next to the app.
|
||||
Create file `go2rtc.yaml`. go2rtc will search this file in current work dirrectory by default.
|
||||
|
||||
- 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/))
|
||||
- `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
|
||||
|
||||
Available modules:
|
||||
|
||||
@@ -106,6 +95,8 @@ Available modules:
|
||||
- [hass](#module-hass) - Home Assistant integration
|
||||
- [log](#module-log) - logs config
|
||||
|
||||
Full default config [example](https://github.com/AlexxIT/go2rtc/wiki/Configuration).
|
||||
|
||||
### Module: Streams
|
||||
|
||||
**go2rtc** support different stream source types. You can config one or multiple links of any type as stream source.
|
||||
@@ -215,7 +206,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..."
|
||||
```
|
||||
@@ -223,8 +214,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.
|
||||
@@ -329,7 +323,34 @@ More cameras, like [Tuya](https://www.home-assistant.io/integrations/tuya/), [ON
|
||||
|
||||
The HTTP API is the main part for interacting with the application. Default address: `http://127.0.0.1:1984/`.
|
||||
|
||||
- you can use WebRTC only when HTTP API enabled
|
||||
go2rtc has its own JS video player (`video-rtc.js`) with:
|
||||
|
||||
- support technologies:
|
||||
- WebRTC over UDP or TCP
|
||||
- MSE or MP4 or MJPEG over WebSocket
|
||||
- automatic selection best technology according on:
|
||||
- codecs inside your stream
|
||||
- current browser capabilities
|
||||
- current network configuration
|
||||
- automatic stop stream while browser or page not active
|
||||
- automatic stop stream while player not inside page viewport
|
||||
- automatic reconnection
|
||||
|
||||
Technology selection based on priorities:
|
||||
|
||||
1. Video and Audio better than just Video
|
||||
2. H265 better than H264
|
||||
3. WebRTC better than MSE, than MP4, than MJPEG
|
||||
|
||||
go2rtc has simple HTML page (`stream.html`) with support params in URL:
|
||||
|
||||
- multiple streams on page `src=camera1&src=camera2...`
|
||||
- stream technology autoselection `mode=webrtc,mse,mp4,mjpeg`
|
||||
- stream technology comparison `src=camera1&mode=webrtc&mode=mse&mode=mp4`
|
||||
- player width setting in pixels `width=320px` or percents `width=50%`
|
||||
|
||||
**Module config**
|
||||
|
||||
- you can disable HTTP API with `listen: ""` and use, for example, only RTSP client/server protocol
|
||||
- you can enable HTTP API only on localhost with `listen: "127.0.0.1:1984"` setting
|
||||
- you can change API `base_path` and host go2rtc on your main app webserver suburl
|
||||
@@ -337,15 +358,18 @@ The HTTP API is the main part for interacting with the application. Default addr
|
||||
|
||||
```yaml
|
||||
api:
|
||||
listen: ":1984" # HTTP API port ("" - disabled)
|
||||
base_path: "/rtc" # API prefix for serve on suburl (/api => /rtc/api)
|
||||
static_dir: "www" # folder for static files (custom web interface)
|
||||
origin: "*" # allow CORS requests (only * supported)
|
||||
listen: ":1984" # default ":1984", HTTP API port ("" - disabled)
|
||||
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)).
|
||||
- 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
|
||||
- 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
|
||||
|
||||
@@ -369,45 +393,43 @@ rtsp:
|
||||
|
||||
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: ...
|
||||
```
|
||||
@@ -501,7 +523,8 @@ View almost any Hass camera using `WebRTC` technology, supported codecs `H264`/`
|
||||
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. Use Picture Entity or Picture Glance lovelace card
|
||||
2. RTSPtoWebRTC > Configure > STUN server: `stun.l.google.com:19302`
|
||||
3. Use Picture Entity or Picture Glance lovelace card
|
||||
|
||||
You can add camera `entity_id` to [go2rtc config](#configuration) if you need transcoding:
|
||||
|
||||
@@ -517,8 +540,13 @@ 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, doesn't work in Safari
|
||||
|
||||
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`
|
||||
|
||||
### Module: MJPEG
|
||||
|
||||
@@ -562,7 +590,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:
|
||||
|
||||
@@ -574,7 +602,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
|
||||
@@ -584,7 +612,7 @@ 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 madness
|
||||
|
||||
@@ -654,6 +682,10 @@ 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?**
|
||||
|
19
build/docker/run.sh
Normal file
19
build/docker/run.sh
Normal file
@@ -0,0 +1,19 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
echo "Starting go2rtc..." >&2
|
||||
|
||||
readonly config_path="/config"
|
||||
|
||||
if [[ -x "${config_path}/go2rtc" ]]; then
|
||||
readonly binary_path="${config_path}/go2rtc"
|
||||
echo "Using go2rtc binary from '${binary_path}' instead of the embedded one" >&2
|
||||
else
|
||||
readonly binary_path="/usr/local/bin/go2rtc"
|
||||
fi
|
||||
|
||||
# set cwd for go2rtc (for config file, Hass integration, etc)
|
||||
cd "${config_path}" || echo "Could not change working directory to '${config_path}'" >&2
|
||||
|
||||
exec "${binary_path}"
|
@@ -1,41 +0,0 @@
|
||||
ARG BUILD_FROM
|
||||
|
||||
FROM $BUILD_FROM as build
|
||||
|
||||
# 1. Build go2rtc
|
||||
RUN apk add --no-cache git go
|
||||
|
||||
RUN git clone https://github.com/AlexxIT/go2rtc \
|
||||
&& cd go2rtc \
|
||||
&& CGO_ENABLED=0 go build -ldflags "-s -w" -trimpath
|
||||
|
||||
# 2. Download ngrok
|
||||
ARG BUILD_ARCH
|
||||
|
||||
# https://github.com/home-assistant/docker-base/blob/master/alpine/Dockerfile
|
||||
RUN if [ "${BUILD_ARCH}" = "aarch64" ]; then BUILD_ARCH="arm64"; \
|
||||
elif [ "${BUILD_ARCH}" = "armv7" ]; then BUILD_ARCH="arm"; fi \
|
||||
&& cd go2rtc \
|
||||
&& curl $(curl -s "https://raw.githubusercontent.com/ngrok/docker-ngrok/main/releases.json" | jq -r ".${BUILD_ARCH}.url") -o ngrok.zip \
|
||||
&& unzip ngrok
|
||||
|
||||
|
||||
|
||||
# https://devopscube.com/reduce-docker-image-size/
|
||||
FROM $BUILD_FROM
|
||||
|
||||
# 3. Copy go2rtc and ngrok to release
|
||||
COPY --from=build /go2rtc/go2rtc /usr/local/bin
|
||||
COPY --from=build /go2rtc/ngrok /usr/local/bin
|
||||
|
||||
# 4. Install ffmpeg
|
||||
# apk base OK: 22 MiB in 40 packages
|
||||
# ffmpeg OK: 113 MiB in 110 packages
|
||||
# python3 OK: 161 MiB in 114 packages
|
||||
RUN apk add --no-cache ffmpeg python3
|
||||
|
||||
# 5. Copy run to release
|
||||
COPY run.sh /
|
||||
RUN chmod a+x /run.sh
|
||||
|
||||
CMD [ "/run.sh" ]
|
@@ -1,6 +0,0 @@
|
||||
# https://github.com/home-assistant/builder/blob/master/builder.sh
|
||||
name: go2rtc
|
||||
description: Ultimate camera streaming application
|
||||
url: https://github.com/AlexxIT/go2rtc
|
||||
image: alexxit/go2rtc
|
||||
arch: [ amd64, aarch64, i386, armv7 ]
|
@@ -1,14 +0,0 @@
|
||||
#!/usr/bin/with-contenv bashio
|
||||
|
||||
set +e
|
||||
|
||||
# set cwd for go2rtc (for config file, Hass integration, etc)
|
||||
cd /config
|
||||
|
||||
# add the feature to override go2rtc binary from Hass config folder
|
||||
export PATH="/config:$PATH"
|
||||
|
||||
while true; do
|
||||
go2rtc
|
||||
sleep 5
|
||||
done
|
@@ -3,10 +3,12 @@ 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"
|
||||
"sync"
|
||||
)
|
||||
|
||||
func Init() {
|
||||
@@ -35,7 +37,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
|
||||
@@ -96,31 +100,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)
|
||||
}
|
||||
|
97
cmd/api/config.go
Normal file
97
cmd/api/config.go
Normal file
@@ -0,0 +1,97 @@
|
||||
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) {
|
||||
switch r.Method {
|
||||
case "GET":
|
||||
data, err := os.ReadFile(app.ConfigPath)
|
||||
if err != nil {
|
||||
http.NotFound(w, r)
|
||||
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
|
||||
}
|
@@ -6,6 +6,7 @@ import (
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Message - struct for data exchange in Web API
|
||||
@@ -25,7 +26,7 @@ var wsHandlers = make(map[string]WSHandler)
|
||||
func initWS(origin string) {
|
||||
wsUp = &websocket.Upgrader{
|
||||
ReadBufferSize: 1024,
|
||||
WriteBufferSize: 512000,
|
||||
WriteBufferSize: 2028,
|
||||
}
|
||||
|
||||
switch origin {
|
||||
@@ -68,6 +69,8 @@ func apiWS(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
tr := &Transport{Request: r}
|
||||
tr.OnWrite(func(msg interface{}) {
|
||||
_ = ws.SetWriteDeadline(time.Now().Add(time.Second * 5))
|
||||
|
||||
if data, ok := msg.([]byte); ok {
|
||||
_ = ws.WriteMessage(websocket.BinaryMessage, data)
|
||||
} else {
|
||||
@@ -101,7 +104,9 @@ type Transport struct {
|
||||
Request *http.Request
|
||||
Consumer interface{} // TODO: rewrite
|
||||
|
||||
mx sync.Mutex
|
||||
closed bool
|
||||
mx sync.Mutex
|
||||
wrmx sync.Mutex
|
||||
|
||||
onChange func()
|
||||
onWrite func(msg interface{})
|
||||
@@ -118,21 +123,32 @@ func (t *Transport) OnWrite(f func(msg interface{})) {
|
||||
}
|
||||
|
||||
func (t *Transport) Write(msg interface{}) {
|
||||
t.mx.Lock()
|
||||
t.wrmx.Lock()
|
||||
t.onWrite(msg)
|
||||
t.mx.Unlock()
|
||||
t.wrmx.Unlock()
|
||||
}
|
||||
|
||||
func (t *Transport) Close() {
|
||||
t.mx.Lock()
|
||||
for _, f := range t.onClose {
|
||||
f()
|
||||
}
|
||||
t.closed = true
|
||||
t.mx.Unlock()
|
||||
}
|
||||
|
||||
func (t *Transport) OnChange(f func()) {
|
||||
t.mx.Lock()
|
||||
t.onChange = f
|
||||
t.mx.Unlock()
|
||||
}
|
||||
|
||||
func (t *Transport) OnClose(f func()) {
|
||||
t.onClose = append(t.onClose, f)
|
||||
t.mx.Lock()
|
||||
if t.closed {
|
||||
f()
|
||||
} else {
|
||||
t.onClose = append(t.onClose, f)
|
||||
}
|
||||
t.mx.Unlock()
|
||||
}
|
||||
|
@@ -2,46 +2,73 @@ 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"
|
||||
"runtime"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var Version = "0.1-rc.5"
|
||||
var Version = "0.1-rc.9"
|
||||
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 cwd, err := os.Getwd(); err == nil {
|
||||
ConfigPath = path.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 {
|
||||
@@ -65,7 +92,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 +113,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,6 +25,7 @@ var stackSkip = [][]byte{
|
||||
|
||||
// webrtc/api.go
|
||||
[]byte("created by github.com/pion/ice/v2.NewTCPMuxDefault"),
|
||||
[]byte("created by github.com/pion/ice/v2.NewUDPMuxDefault"),
|
||||
}
|
||||
|
||||
func stackHandler(w http.ResponseWriter, r *http.Request) {
|
||||
|
@@ -34,8 +34,13 @@ func Init() {
|
||||
return false
|
||||
}
|
||||
|
||||
waiter <- conn
|
||||
return true
|
||||
// unblocking write to channel
|
||||
select {
|
||||
case waiter <- conn:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
})
|
||||
|
||||
streams.HandleFunc("exec", Handle)
|
||||
@@ -86,7 +91,13 @@ func Handle(url string) (streamer.Producer, error) {
|
||||
chErr := make(chan error)
|
||||
|
||||
go func() {
|
||||
chErr <- cmd.Wait()
|
||||
err := cmd.Wait()
|
||||
// unblocking write to channel
|
||||
select {
|
||||
case chErr <- err:
|
||||
default:
|
||||
log.Trace().Str("url", url).Msg("[exec] close")
|
||||
}
|
||||
}()
|
||||
|
||||
select {
|
||||
|
@@ -1,6 +1,8 @@
|
||||
package ffmpeg
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"github.com/AlexxIT/go2rtc/cmd/app"
|
||||
"github.com/AlexxIT/go2rtc/cmd/exec"
|
||||
"github.com/AlexxIT/go2rtc/cmd/ffmpeg/device"
|
||||
@@ -17,190 +19,226 @@ func Init() {
|
||||
Mod map[string]string `yaml:"ffmpeg"`
|
||||
}
|
||||
|
||||
// defaults
|
||||
|
||||
cfg.Mod = map[string]string{
|
||||
"bin": "ffmpeg",
|
||||
|
||||
// inputs
|
||||
"file": "-re -stream_loop -1 -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}",
|
||||
|
||||
// output
|
||||
"output": "-user_agent ffmpeg/go2rtc -rtsp_transport tcp -f rtsp {output}",
|
||||
|
||||
// `-g 30` - group of picture, GOP, keyframe interval
|
||||
// `-preset superfast` - we can't use ultrafast because it doesn't support `-profile main -level 4.1`
|
||||
// `-tune zerolatency` - for minimal latency
|
||||
// `-profile main -level 4.1` - most used streaming profile
|
||||
// `-pix_fmt yuv420p` - if input pix format 4:2:2
|
||||
"h264": "-c:v libx264 -g:v 30 -preset:v superfast -tune:v zerolatency -profile:v main -level:v 4.1 -pix_fmt:v yuv420p",
|
||||
"h264/ultra": "-c:v libx264 -g:v 30 -preset:v ultrafast -tune:v zerolatency",
|
||||
"h264/high": "-c:v libx264 -g:v 30 -preset:v superfast -tune:v zerolatency",
|
||||
"h265": "-c:v libx265 -g:v 30 -preset:v superfast -tune:v zerolatency -profile:v main -level:v 5.1 -pix_fmt:v yuv420p",
|
||||
"mjpeg": "-c:v mjpeg -force_duplicated_matrix:v 1 -huffman:v 0 -pix_fmt:v yuvj420p",
|
||||
"opus": "-c:a libopus -ar:a 48000 -ac:a 2",
|
||||
"pcmu": "-c:a pcm_mulaw -ar:a 8000 -ac:a 1",
|
||||
"pcmu/16000": "-c:a pcm_mulaw -ar:a 16000 -ac:a 1",
|
||||
"pcmu/48000": "-c:a pcm_mulaw -ar:a 48000 -ac:a 1",
|
||||
"pcma": "-c:a pcm_alaw -ar:a 8000 -ac:a 1",
|
||||
"pcma/16000": "-c:a pcm_alaw -ar:a 16000 -ac:a 1",
|
||||
"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",
|
||||
}
|
||||
cfg.Mod = defaults // will be overriden from yaml
|
||||
|
||||
app.LoadConfig(&cfg)
|
||||
|
||||
tpl := cfg.Mod
|
||||
|
||||
cmd := "exec:" + tpl["bin"] + " -hide_banner "
|
||||
|
||||
if app.GetLogger("exec").GetLevel() >= 0 {
|
||||
cmd += "-v error "
|
||||
defaults["global"] += " -v error"
|
||||
}
|
||||
|
||||
streams.HandleFunc("ffmpeg", func(s string) (streamer.Producer, error) {
|
||||
s = s[7:] // remove `ffmpeg:`
|
||||
|
||||
var query url.Values
|
||||
var queryVideo, queryAudio bool
|
||||
|
||||
if i := strings.IndexByte(s, '#'); i > 0 {
|
||||
query = parseQuery(s[i+1:])
|
||||
queryVideo = query["video"] != nil
|
||||
queryAudio = query["audio"] != nil
|
||||
s = s[:i]
|
||||
} else {
|
||||
// by default query both video and audio
|
||||
queryVideo = true
|
||||
queryAudio = true
|
||||
streams.HandleFunc("ffmpeg", func(url string) (streamer.Producer, error) {
|
||||
args := parseArgs(url[7:]) // remove `ffmpeg:`
|
||||
if args == nil {
|
||||
return nil, errors.New("can't generate ffmpeg command")
|
||||
}
|
||||
|
||||
var input string
|
||||
if i := strings.Index(s, "://"); i > 0 {
|
||||
switch s[:i] {
|
||||
case "http", "https", "rtmp":
|
||||
input = strings.Replace(tpl["http"], "{input}", s, 1)
|
||||
case "rtsp", "rtsps":
|
||||
// https://ffmpeg.org/ffmpeg-protocols.html#rtsp
|
||||
// skip unnecessary input tracks
|
||||
switch {
|
||||
case queryVideo && queryAudio:
|
||||
input = "-allowed_media_types video+audio "
|
||||
case queryVideo:
|
||||
input = "-allowed_media_types video "
|
||||
case queryAudio:
|
||||
input = "-allowed_media_types audio "
|
||||
}
|
||||
|
||||
input += strings.Replace(tpl["rtsp"], "{input}", s, 1)
|
||||
default:
|
||||
input = "-i " + s
|
||||
}
|
||||
} else if streams.Get(s) != nil {
|
||||
s = "rtsp://localhost:" + rtsp.Port + "/" + s
|
||||
switch {
|
||||
case queryVideo && !queryAudio:
|
||||
s += "?video"
|
||||
case queryAudio && !queryVideo:
|
||||
s += "?audio"
|
||||
}
|
||||
input = strings.Replace(tpl["rtsp"], "{input}", s, 1)
|
||||
} else if strings.HasPrefix(s, "device?") {
|
||||
var err error
|
||||
input, err = device.GetInput(s)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
} else {
|
||||
input = strings.Replace(tpl["file"], "{input}", s, 1)
|
||||
}
|
||||
|
||||
if _, ok := query["async"]; ok {
|
||||
input = "-use_wallclock_as_timestamps 1 -async 1 " + input
|
||||
}
|
||||
|
||||
s = cmd + input
|
||||
|
||||
if query != nil {
|
||||
for _, raw := range query["raw"] {
|
||||
s += " " + raw
|
||||
}
|
||||
|
||||
for _, rotate := range query["rotate"] {
|
||||
switch rotate {
|
||||
case "90":
|
||||
s += " -vf transpose=1" // 90 degrees clockwise
|
||||
case "180":
|
||||
s += " -vf transpose=1,transpose=1"
|
||||
case "-90", "270":
|
||||
s += " -vf transpose=2" // 90 degrees counterclockwise
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
switch len(query["video"]) {
|
||||
case 0:
|
||||
s += " -vn"
|
||||
case 1:
|
||||
if len(query["audio"]) > 1 {
|
||||
s += " -map 0:v:0?"
|
||||
}
|
||||
for _, video := range query["video"] {
|
||||
if video == "copy" {
|
||||
s += " -c:v copy"
|
||||
} else {
|
||||
s += " " + tpl[video]
|
||||
}
|
||||
}
|
||||
default:
|
||||
for i, video := range query["video"] {
|
||||
if video == "copy" {
|
||||
s += " -map 0:v:0? -c:v:" + strconv.Itoa(i) + " copy"
|
||||
} else {
|
||||
s += " -map 0:v:0? " + strings.ReplaceAll(tpl[video], ":v ", ":v:"+strconv.Itoa(i)+" ")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
switch len(query["audio"]) {
|
||||
case 0:
|
||||
s += " -an"
|
||||
case 1:
|
||||
if len(query["video"]) > 1 {
|
||||
s += " -map 0:a:0?"
|
||||
}
|
||||
for _, audio := range query["audio"] {
|
||||
if audio == "copy" {
|
||||
s += " -c:a copy"
|
||||
} else {
|
||||
s += " " + tpl[audio]
|
||||
}
|
||||
}
|
||||
default:
|
||||
for i, audio := range query["audio"] {
|
||||
if audio == "copy" {
|
||||
s += " -map 0:a:0? -c:a:" + strconv.Itoa(i) + " copy"
|
||||
} else {
|
||||
s += " -map 0:a:0? " + strings.ReplaceAll(tpl[audio], ":a ", ":a:"+strconv.Itoa(i)+" ")
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
s += " -c copy"
|
||||
}
|
||||
|
||||
s += " " + tpl["output"]
|
||||
|
||||
return exec.Handle(s)
|
||||
return exec.Handle("exec:" + args.String())
|
||||
})
|
||||
|
||||
device.Bin = cfg.Mod["bin"]
|
||||
device.Bin = defaults["bin"]
|
||||
device.Init()
|
||||
}
|
||||
|
||||
var defaults = map[string]string{
|
||||
"bin": "ffmpeg",
|
||||
"global": "-hide_banner",
|
||||
|
||||
// inputs
|
||||
"file": "-re -stream_loop -1 -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}",
|
||||
|
||||
// output
|
||||
"output": "-user_agent ffmpeg/go2rtc -rtsp_transport tcp -f rtsp {output}",
|
||||
|
||||
// `-preset superfast` - we can't use ultrafast because it doesn't support `-profile main -level 4.1`
|
||||
// `-tune zerolatency` - for minimal latency
|
||||
// `-profile high -level 4.1` - most used streaming profile
|
||||
"h264": "-c:v libx264 -g 50 -profile:v high -level:v 4.1 -preset:v superfast -tune:v zerolatency",
|
||||
"h265": "-c:v libx265 -g 50 -profile:v high -level:v 5.1 -preset:v superfast -tune:v zerolatency",
|
||||
"mjpeg": "-c:v mjpeg -force_duplicated_matrix:v 1 -huffman:v 0 -pix_fmt:v yuvj420p",
|
||||
|
||||
"opus": "-c:a libopus -ar:a 48000 -ac:a 2",
|
||||
"pcmu": "-c:a pcm_mulaw -ar:a 8000 -ac:a 1",
|
||||
"pcmu/16000": "-c:a pcm_mulaw -ar:a 16000 -ac:a 1",
|
||||
"pcmu/48000": "-c:a pcm_mulaw -ar:a 48000 -ac:a 1",
|
||||
"pcma": "-c:a pcm_alaw -ar:a 8000 -ac:a 1",
|
||||
"pcma/16000": "-c:a pcm_alaw -ar:a 16000 -ac:a 1",
|
||||
"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",
|
||||
|
||||
// hardware Intel and AMD on Linux
|
||||
// better not to set `-async_depth:v 1` like for QSV, because framedrops
|
||||
// `-bf 0` - disable B-frames is very important
|
||||
"h264/vaapi": "-c:v h264_vaapi -g 50 -bf 0 -profile:v high -level:v 4.1 -sei:v 0",
|
||||
"h265/vaapi": "-c:v hevc_vaapi -g 50 -bf 0 -profile:v high -level:v 5.1 -sei:v 0",
|
||||
"mjpeg/vaapi": "-c:v mjpeg_vaapi",
|
||||
|
||||
// hardware Raspberry
|
||||
"h264/v4l2m2m": "-c:v h264_v4l2m2m -g 50 -bf 0",
|
||||
"h265/v4l2m2m": "-c:v hevc_v4l2m2m -g 50 -bf 0",
|
||||
|
||||
// hardware NVidia on Linux and Windows
|
||||
// preset=p2 - faster, tune=ll - low latency
|
||||
"h264/cuda": "-c:v h264_nvenc -g 50 -profile:v high -level:v auto -preset:v p2 -tune:v ll",
|
||||
"h265/cuda": "-c:v hevc_nvenc -g 50 -profile:v high -level:v auto",
|
||||
|
||||
// hardware Intel on Windows
|
||||
"h264/dxva2": "-c:v h264_qsv -g 50 -bf 0 -profile:v high -level:v 4.1 -async_depth:v 1",
|
||||
"h265/dxva2": "-c:v hevc_qsv -g 50 -bf 0 -profile:v high -level:v 5.1 -async_depth:v 1",
|
||||
"mjpeg/dxva2": "-c:v mjpeg_qsv -profile:v high -level:v 5.1",
|
||||
|
||||
// hardware macOS
|
||||
"h264/videotoolbox": "-c:v h264_videotoolbox -g 50 -bf 0 -profile:v high -level:v 4.1",
|
||||
"h265/videotoolbox": "-c:v hevc_videotoolbox -g 50 -bf 0 -profile:v high -level:v 5.1",
|
||||
}
|
||||
|
||||
func parseArgs(s string) *Args {
|
||||
// init FFmpeg arguments
|
||||
args := &Args{
|
||||
bin: defaults["bin"],
|
||||
global: defaults["global"],
|
||||
output: defaults["output"],
|
||||
}
|
||||
|
||||
var query url.Values
|
||||
if i := strings.IndexByte(s, '#'); i > 0 {
|
||||
query = parseQuery(s[i+1:])
|
||||
args.video = len(query["video"])
|
||||
args.audio = len(query["audio"])
|
||||
s = s[:i]
|
||||
}
|
||||
|
||||
// Parse input:
|
||||
// 1. Input as xxxx:// link (http or rtsp or any other)
|
||||
// 2. Input as stream name
|
||||
// 3. Input as FFmpeg device (local USB camera)
|
||||
if i := strings.Index(s, "://"); i > 0 {
|
||||
switch s[:i] {
|
||||
case "http", "https", "rtmp":
|
||||
args.input = strings.Replace(defaults["http"], "{input}", s, 1)
|
||||
case "rtsp", "rtsps":
|
||||
// https://ffmpeg.org/ffmpeg-protocols.html#rtsp
|
||||
// skip unnecessary input tracks
|
||||
switch {
|
||||
case (args.video > 0 && args.audio > 0) || (args.video == 0 && args.audio == 0):
|
||||
args.input = "-allowed_media_types video+audio "
|
||||
case args.video > 0:
|
||||
args.input = "-allowed_media_types video "
|
||||
case args.audio > 0:
|
||||
args.input = "-allowed_media_types audio "
|
||||
}
|
||||
|
||||
args.input += strings.Replace(defaults["rtsp"], "{input}", s, 1)
|
||||
default:
|
||||
args.input = "-i " + s
|
||||
}
|
||||
} else if streams.Get(s) != nil {
|
||||
s = "rtsp://localhost:" + rtsp.Port + "/" + s
|
||||
switch {
|
||||
case args.video > 0 && args.audio == 0:
|
||||
s += "?video"
|
||||
case args.audio > 0 && args.video == 0:
|
||||
s += "?audio"
|
||||
}
|
||||
args.input = strings.Replace(defaults["rtsp"], "{input}", s, 1)
|
||||
} else if strings.HasPrefix(s, "device?") {
|
||||
var err error
|
||||
args.input, err = device.GetInput(s)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
} else {
|
||||
args.input = strings.Replace(defaults["file"], "{input}", s, 1)
|
||||
}
|
||||
|
||||
if query["async"] != nil {
|
||||
args.input = "-use_wallclock_as_timestamps 1 -async 1 " + args.input
|
||||
}
|
||||
|
||||
// Parse query params:
|
||||
// 1. `width`/`height` params
|
||||
// 2. `rotate` param
|
||||
// 3. `video` params (support multiple)
|
||||
// 4. `audio` params (support multiple)
|
||||
// 5. `hardware` param
|
||||
if query != nil {
|
||||
// 1. Process raw params for FFmpeg
|
||||
for _, raw := range query["raw"] {
|
||||
args.AddCodec(raw)
|
||||
}
|
||||
|
||||
// 2. Process video filters (resize and rotation)
|
||||
if query["width"] != nil || query["height"] != nil {
|
||||
filter := "scale="
|
||||
if query["width"] != nil {
|
||||
filter += query["width"][0]
|
||||
} else {
|
||||
filter += "-1"
|
||||
}
|
||||
filter += ":"
|
||||
if query["height"] != nil {
|
||||
filter += query["height"][0]
|
||||
} else {
|
||||
filter += "-1"
|
||||
}
|
||||
args.AddFilter(filter)
|
||||
}
|
||||
|
||||
if query["rotate"] != nil {
|
||||
var filter string
|
||||
switch query["rotate"][0] {
|
||||
case "90":
|
||||
filter = "transpose=1" // 90 degrees clockwise
|
||||
case "180":
|
||||
filter = "transpose=1,transpose=1"
|
||||
case "-90", "270":
|
||||
filter = "transpose=2" // 90 degrees counterclockwise
|
||||
}
|
||||
if filter != "" {
|
||||
args.AddFilter(filter)
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Process video codecs
|
||||
if args.video > 0 {
|
||||
for _, video := range query["video"] {
|
||||
if video != "copy" {
|
||||
args.AddCodec(defaults[video])
|
||||
} else {
|
||||
args.AddCodec("-c:v copy")
|
||||
}
|
||||
}
|
||||
} else {
|
||||
args.AddCodec("-vn")
|
||||
}
|
||||
|
||||
// 4. Process audio codecs
|
||||
if args.audio > 0 {
|
||||
for _, audio := range query["audio"] {
|
||||
if audio != "copy" {
|
||||
args.AddCodec(defaults[audio])
|
||||
} else {
|
||||
args.AddCodec("-c:a copy")
|
||||
}
|
||||
}
|
||||
} else {
|
||||
args.AddCodec("-an")
|
||||
}
|
||||
|
||||
if query["hardware"] != nil {
|
||||
MakeHardware(args, query["hardware"][0])
|
||||
}
|
||||
}
|
||||
|
||||
if args.codecs == nil {
|
||||
args.AddCodec("-c copy")
|
||||
}
|
||||
|
||||
return args
|
||||
}
|
||||
|
||||
func parseQuery(s string) map[string][]string {
|
||||
query := map[string][]string{}
|
||||
for _, key := range strings.Split(s, "#") {
|
||||
@@ -213,3 +251,76 @@ func parseQuery(s string) map[string][]string {
|
||||
}
|
||||
return query
|
||||
}
|
||||
|
||||
type Args struct {
|
||||
bin string // ffmpeg
|
||||
global string // -hide_banner -v error
|
||||
input string // -re -stream_loop -1 -i /media/bunny.mp4
|
||||
codecs []string // -c:v libx264 -g:v 30 -preset:v ultrafast -tune:v zerolatency
|
||||
filters []string // scale=1920:1080
|
||||
output string // -f rtsp {output}
|
||||
|
||||
video, audio int // count of video and audio params
|
||||
}
|
||||
|
||||
func (a *Args) AddCodec(codec string) {
|
||||
a.codecs = append(a.codecs, codec)
|
||||
}
|
||||
|
||||
func (a *Args) AddFilter(filter string) {
|
||||
a.filters = append(a.filters, filter)
|
||||
}
|
||||
|
||||
func (a *Args) InsertFilter(filter string) {
|
||||
a.filters = append([]string{filter}, a.filters...)
|
||||
}
|
||||
|
||||
func (a *Args) String() string {
|
||||
b := bytes.NewBuffer(make([]byte, 0, 512))
|
||||
|
||||
b.WriteString(a.bin)
|
||||
|
||||
if a.global != "" {
|
||||
b.WriteByte(' ')
|
||||
b.WriteString(a.global)
|
||||
}
|
||||
|
||||
b.WriteByte(' ')
|
||||
b.WriteString(a.input)
|
||||
|
||||
multimode := a.video > 1 || a.audio > 1
|
||||
var iv, ia int
|
||||
|
||||
for _, codec := range a.codecs {
|
||||
// support multiple video and/or audio codecs
|
||||
if multimode && len(codec) >= 5 {
|
||||
switch codec[:5] {
|
||||
case "-c:v ":
|
||||
codec = "-map 0:v:0? " + strings.ReplaceAll(codec, ":v ", ":v:"+strconv.Itoa(iv)+" ")
|
||||
iv++
|
||||
case "-c:a ":
|
||||
codec = "-map 0:a:0? " + strings.ReplaceAll(codec, ":a ", ":a:"+strconv.Itoa(ia)+" ")
|
||||
ia++
|
||||
}
|
||||
}
|
||||
|
||||
b.WriteByte(' ')
|
||||
b.WriteString(codec)
|
||||
}
|
||||
|
||||
if a.filters != nil {
|
||||
for i, filter := range a.filters {
|
||||
if i == 0 {
|
||||
b.WriteString(" -vf ")
|
||||
} else {
|
||||
b.WriteByte(',')
|
||||
}
|
||||
b.WriteString(filter)
|
||||
}
|
||||
}
|
||||
|
||||
b.WriteByte(' ')
|
||||
b.WriteString(a.output)
|
||||
|
||||
return b.String()
|
||||
}
|
||||
|
112
cmd/ffmpeg/hardware.go
Normal file
112
cmd/ffmpeg/hardware.go
Normal file
@@ -0,0 +1,112 @@
|
||||
package ffmpeg
|
||||
|
||||
import (
|
||||
"github.com/rs/zerolog/log"
|
||||
"os/exec"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
EngineSoftware = "software"
|
||||
EngineVAAPI = "vaapi" // Intel iGPU and AMD GPU
|
||||
EngineV4L2M2M = "v4l2m2m" // Raspberry Pi 3 and 4
|
||||
EngineCUDA = "cuda" // NVidia on Windows and Linux
|
||||
EngineDXVA2 = "dxva2" // Intel on Windows
|
||||
EngineVideoToolbox = "videotoolbox" // macOS
|
||||
)
|
||||
|
||||
var cache = map[string]string{}
|
||||
|
||||
// MakeHardware converts software FFmpeg args to hardware args
|
||||
// empty engine for autoselect
|
||||
func MakeHardware(args *Args, engine string) {
|
||||
for i, codec := range args.codecs {
|
||||
if len(codec) < 12 {
|
||||
continue // skip short line (-c:v libx264...)
|
||||
}
|
||||
|
||||
// get current codec name
|
||||
name := cut(codec, ' ', 1)
|
||||
switch name {
|
||||
case "libx264":
|
||||
name = "h264"
|
||||
case "libx265":
|
||||
name = "h265"
|
||||
case "mjpeg":
|
||||
default:
|
||||
continue // skip unsupported codec
|
||||
}
|
||||
|
||||
// temporary disable probe for H265 and MJPEG
|
||||
if engine == "" && name == "h264" {
|
||||
if engine = cache[name]; engine == "" {
|
||||
engine = ProbeHardware(name)
|
||||
cache[name] = engine
|
||||
}
|
||||
}
|
||||
|
||||
switch engine {
|
||||
case EngineVAAPI:
|
||||
args.input = "-hwaccel vaapi -hwaccel_output_format vaapi " + args.input
|
||||
args.codecs[i] = defaults[name+"/"+engine]
|
||||
|
||||
for i, filter := range args.filters {
|
||||
if strings.HasPrefix(filter, "scale=") {
|
||||
args.filters[i] = "scale_vaapi=" + filter[6:]
|
||||
}
|
||||
}
|
||||
|
||||
// fix if input doesn't support hwaccel, do nothing when support
|
||||
args.InsertFilter("format=vaapi|nv12,hwupload")
|
||||
|
||||
case EngineCUDA:
|
||||
args.input = "-hwaccel cuda -hwaccel_output_format cuda -extra_hw_frames 2 " + args.input
|
||||
args.codecs[i] = defaults[name+"/"+engine]
|
||||
|
||||
for i, filter := range args.filters {
|
||||
if strings.HasPrefix(filter, "scale=") {
|
||||
args.filters[i] = "scale_cuda=" + filter[6:]
|
||||
}
|
||||
}
|
||||
|
||||
case EngineDXVA2:
|
||||
args.input = "-hwaccel dxva2 -hwaccel_output_format dxva2_vld " + args.input
|
||||
args.codecs[i] = defaults[name+"/"+engine]
|
||||
|
||||
for i, filter := range args.filters {
|
||||
if strings.HasPrefix(filter, "scale=") {
|
||||
args.filters[i] = "scale_qsv=" + filter[6:]
|
||||
}
|
||||
}
|
||||
|
||||
args.InsertFilter("hwmap=derive_device=qsv,format=qsv")
|
||||
|
||||
case EngineVideoToolbox:
|
||||
args.input = "-hwaccel videotoolbox -hwaccel_output_format videotoolbox_vld " + args.input
|
||||
args.codecs[i] = defaults[name+"/"+engine]
|
||||
|
||||
case EngineV4L2M2M:
|
||||
args.codecs[i] = defaults[name+"/"+engine]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func run(arg ...string) bool {
|
||||
err := exec.Command(defaults["bin"], arg...).Run()
|
||||
log.Printf("%v %v", arg, err)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func cut(s string, sep byte, pos int) string {
|
||||
for n := 0; n < pos; n++ {
|
||||
if i := strings.IndexByte(s, sep); i > 0 {
|
||||
s = s[i+1:]
|
||||
} else {
|
||||
return ""
|
||||
}
|
||||
}
|
||||
if i := strings.IndexByte(s, sep); i > 0 {
|
||||
return s[:i]
|
||||
}
|
||||
return s
|
||||
}
|
21
cmd/ffmpeg/hardware_darwin.go
Normal file
21
cmd/ffmpeg/hardware_darwin.go
Normal file
@@ -0,0 +1,21 @@
|
||||
package ffmpeg
|
||||
|
||||
func ProbeHardware(name string) string {
|
||||
switch name {
|
||||
case "h264":
|
||||
if run(
|
||||
"-f", "lavfi", "-i", "testsrc2", "-t", "1",
|
||||
"-c", "h264_videotoolbox", "-f", "null", "-") {
|
||||
return EngineVideoToolbox
|
||||
}
|
||||
|
||||
case "h265":
|
||||
if run(
|
||||
"-f", "lavfi", "-i", "testsrc2", "-t", "1",
|
||||
"-c", "hevc_videotoolbox", "-f", "null", "-") {
|
||||
return EngineVideoToolbox
|
||||
}
|
||||
}
|
||||
|
||||
return EngineSoftware
|
||||
}
|
67
cmd/ffmpeg/hardware_linux.go
Normal file
67
cmd/ffmpeg/hardware_linux.go
Normal file
@@ -0,0 +1,67 @@
|
||||
package ffmpeg
|
||||
|
||||
import (
|
||||
"runtime"
|
||||
)
|
||||
|
||||
func ProbeHardware(name string) string {
|
||||
if runtime.GOARCH == "arm64" || runtime.GOARCH == "arm" {
|
||||
switch name {
|
||||
case "h264":
|
||||
if run(
|
||||
"-f", "lavfi", "-i", "testsrc2", "-t", "1",
|
||||
"-c", "h264_v4l2m2m", "-f", "null", "-") {
|
||||
return EngineV4L2M2M
|
||||
}
|
||||
|
||||
case "h265":
|
||||
if run(
|
||||
"-f", "lavfi", "-i", "testsrc2", "-t", "1",
|
||||
"-c", "hevc_v4l2m2m", "-f", "null", "-") {
|
||||
return EngineV4L2M2M
|
||||
}
|
||||
}
|
||||
|
||||
return EngineSoftware
|
||||
}
|
||||
|
||||
switch name {
|
||||
case "h264":
|
||||
if run("-init_hw_device", "cuda",
|
||||
"-f", "lavfi", "-i", "testsrc2", "-t", "1",
|
||||
"-c", "h264_nvenc", "-f", "null", "-") {
|
||||
return EngineCUDA
|
||||
}
|
||||
|
||||
if run("-init_hw_device", "vaapi",
|
||||
"-f", "lavfi", "-i", "testsrc2", "-t", "1",
|
||||
"-vf", "format=nv12,hwupload",
|
||||
"-c", "h264_vaapi", "-f", "null", "-") {
|
||||
return EngineVAAPI
|
||||
}
|
||||
|
||||
case "h265":
|
||||
if run("-init_hw_device", "cuda",
|
||||
"-f", "lavfi", "-i", "testsrc2", "-t", "1",
|
||||
"-c", "hevc_nvenc", "-f", "null", "-") {
|
||||
return EngineCUDA
|
||||
}
|
||||
|
||||
if run("-init_hw_device", "vaapi",
|
||||
"-f", "lavfi", "-i", "testsrc2", "-t", "1",
|
||||
"-vf", "format=nv12,hwupload",
|
||||
"-c", "hevc_vaapi", "-f", "null", "-") {
|
||||
return EngineVAAPI
|
||||
}
|
||||
|
||||
case "mjpeg":
|
||||
if run("-init_hw_device", "vaapi",
|
||||
"-f", "lavfi", "-i", "testsrc2", "-t", "1",
|
||||
"-vf", "format=nv12,hwupload",
|
||||
"-c", "mjpeg_vaapi", "-f", "null", "-") {
|
||||
return EngineVAAPI
|
||||
}
|
||||
}
|
||||
|
||||
return EngineSoftware
|
||||
}
|
40
cmd/ffmpeg/hardware_windows.go
Normal file
40
cmd/ffmpeg/hardware_windows.go
Normal file
@@ -0,0 +1,40 @@
|
||||
package ffmpeg
|
||||
|
||||
func ProbeHardware(name string) string {
|
||||
switch name {
|
||||
case "h264":
|
||||
if run("-init_hw_device", "cuda",
|
||||
"-f", "lavfi", "-i", "testsrc2", "-t", "1",
|
||||
"-c", "h264_nvenc", "-f", "null", "-") {
|
||||
return EngineCUDA
|
||||
}
|
||||
|
||||
if run("-init_hw_device", "dxva2",
|
||||
"-f", "lavfi", "-i", "testsrc2", "-t", "1",
|
||||
"-c", "h264_qsv", "-f", "null", "-") {
|
||||
return EngineDXVA2
|
||||
}
|
||||
|
||||
case "h265":
|
||||
if run("-init_hw_device", "cuda",
|
||||
"-f", "lavfi", "-i", "testsrc2", "-t", "1",
|
||||
"-c", "hevc_nvenc", "-f", "null", "-") {
|
||||
return EngineCUDA
|
||||
}
|
||||
|
||||
if run("-init_hw_device", "dxva2",
|
||||
"-f", "lavfi", "-i", "testsrc2", "-t", "1",
|
||||
"-c", "hevc_qsv", "-f", "null", "-") {
|
||||
return EngineDXVA2
|
||||
}
|
||||
|
||||
case "mjpeg":
|
||||
if run("-init_hw_device", "dxva2",
|
||||
"-f", "lavfi", "-i", "testsrc2", "-t", "1",
|
||||
"-c", "mjpeg_qsv", "-f", "null", "-") {
|
||||
return EngineDXVA2
|
||||
}
|
||||
}
|
||||
|
||||
return EngineSoftware
|
||||
}
|
@@ -27,7 +27,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:
|
||||
@@ -68,7 +71,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:
|
||||
@@ -109,7 +115,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)
|
||||
@@ -121,6 +130,8 @@ func handlerWS(tr *api.Transport, _ *api.Message) error {
|
||||
return err
|
||||
}
|
||||
|
||||
tr.Write(&api.Message{Type: "mjpeg"})
|
||||
|
||||
tr.OnClose(func() {
|
||||
stream.RemoveConsumer(cons)
|
||||
})
|
||||
|
@@ -80,7 +80,10 @@ func handlerMP4(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
exit := make(chan error)
|
||||
|
||||
cons := &mp4.Consumer{}
|
||||
cons := &mp4.Consumer{
|
||||
RemoteAddr: r.RemoteAddr,
|
||||
UserAgent: r.UserAgent(),
|
||||
}
|
||||
cons.Listen(func(msg interface{}) {
|
||||
if data, ok := msg.([]byte); ok {
|
||||
if _, err := w.Write(data); err != nil && exit != nil {
|
||||
|
@@ -9,7 +9,7 @@ import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
const packetSize = 8192
|
||||
const packetSize = 1400
|
||||
|
||||
func handlerWSMSE(tr *api.Transport, msg *api.Message) error {
|
||||
src := tr.Request.URL.Query().Get("src")
|
||||
@@ -18,7 +18,10 @@ func handlerWSMSE(tr *api.Transport, msg *api.Message) error {
|
||||
return errors.New(api.StreamNotFound)
|
||||
}
|
||||
|
||||
cons := &mp4.Consumer{}
|
||||
cons := &mp4.Consumer{
|
||||
RemoteAddr: tr.Request.RemoteAddr,
|
||||
UserAgent: tr.Request.UserAgent(),
|
||||
}
|
||||
cons.UserAgent = tr.Request.UserAgent()
|
||||
cons.RemoteAddr = tr.Request.RemoteAddr
|
||||
|
||||
@@ -38,7 +41,7 @@ func handlerWSMSE(tr *api.Transport, msg *api.Message) error {
|
||||
})
|
||||
|
||||
if err := stream.AddConsumer(cons); err != nil {
|
||||
log.Warn().Err(err).Caller().Send()
|
||||
log.Debug().Err(err).Msg("[mp4] add consumer")
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -68,7 +71,7 @@ func handlerWSMP4(tr *api.Transport, msg *api.Message) error {
|
||||
return errors.New(api.StreamNotFound)
|
||||
}
|
||||
|
||||
cons := &mp4.Segment{}
|
||||
cons := &mp4.Segment{OnlyKeyframe: true}
|
||||
|
||||
if codecs, ok := msg.Value.(string); ok {
|
||||
log.Trace().Str("codecs", codecs).Msgf("[mp4] new WS/MP4 consumer")
|
||||
@@ -86,6 +89,8 @@ func handlerWSMP4(tr *api.Transport, msg *api.Message) error {
|
||||
return err
|
||||
}
|
||||
|
||||
tr.Write(&api.Message{Type: "mp4", Value: cons.MimeType})
|
||||
|
||||
tr.OnClose(func() {
|
||||
stream.RemoveConsumer(cons)
|
||||
})
|
||||
|
@@ -14,9 +14,9 @@ import (
|
||||
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:"-"`
|
||||
} `yaml:"rtsp"`
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ func Init() {
|
||||
conf.Mod.Listen = ":8554"
|
||||
|
||||
app.LoadConfig(&conf)
|
||||
app.Info["rtsp"] = conf.Mod
|
||||
|
||||
log = app.GetLogger("rtsp")
|
||||
|
||||
@@ -200,6 +201,9 @@ func tcpHandler(conn *rtsp.Conn) {
|
||||
|
||||
if err := conn.Accept(); err != nil {
|
||||
log.Warn().Err(err).Caller().Send()
|
||||
if closer != nil {
|
||||
closer()
|
||||
}
|
||||
_ = conn.Close()
|
||||
return
|
||||
}
|
||||
@@ -212,7 +216,7 @@ func tcpHandler(conn *rtsp.Conn) {
|
||||
|
||||
if closer != nil {
|
||||
if err := conn.Handle(); err != nil {
|
||||
log.Debug().Err(err).Caller().Send()
|
||||
log.Debug().Msgf("[rtsp] handle=%s", err)
|
||||
}
|
||||
|
||||
closer()
|
||||
|
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)
|
||||
}
|
@@ -1,6 +1,7 @@
|
||||
package streams
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"github.com/AlexxIT/go2rtc/pkg/streamer"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -24,11 +25,12 @@ type Producer struct {
|
||||
template string
|
||||
|
||||
element streamer.Producer
|
||||
lastErr error
|
||||
tracks []*streamer.Track
|
||||
|
||||
state state
|
||||
mu sync.Mutex
|
||||
restart *time.Timer
|
||||
state state
|
||||
mu sync.Mutex
|
||||
workerID int
|
||||
}
|
||||
|
||||
func (p *Producer) SetSource(s string) {
|
||||
@@ -45,16 +47,20 @@ func (p *Producer) GetMedias() []*streamer.Media {
|
||||
if p.state == stateNone {
|
||||
log.Debug().Msgf("[streams] probe producer url=%s", p.url)
|
||||
|
||||
var err error
|
||||
p.element, err = GetProducer(p.url)
|
||||
if err != nil || p.element == nil {
|
||||
log.Error().Err(err).Caller().Send()
|
||||
p.element, p.lastErr = GetProducer(p.url)
|
||||
if p.lastErr != nil || p.element == nil {
|
||||
log.Error().Err(p.lastErr).Str("url", p.url).Caller().Send()
|
||||
return nil
|
||||
}
|
||||
|
||||
p.state = stateMedias
|
||||
}
|
||||
|
||||
// if element in reconnect state
|
||||
if p.element == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
return p.element.GetMedias()
|
||||
}
|
||||
|
||||
@@ -86,6 +92,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() {
|
||||
@@ -99,32 +114,45 @@ func (p *Producer) start() {
|
||||
log.Debug().Msgf("[streams] start producer url=%s", p.url)
|
||||
|
||||
p.state = stateStart
|
||||
go func() {
|
||||
// safe read element while mu locked
|
||||
if err := p.element.Start(); err != nil {
|
||||
log.Warn().Err(err).Caller().Send()
|
||||
}
|
||||
p.reconnect()
|
||||
}()
|
||||
p.workerID++
|
||||
|
||||
go p.worker(p.element, p.workerID)
|
||||
}
|
||||
|
||||
func (p *Producer) reconnect() {
|
||||
func (p *Producer) worker(element streamer.Producer, workerID int) {
|
||||
if err := element.Start(); err != nil {
|
||||
p.mu.Lock()
|
||||
closed := p.workerID != workerID
|
||||
p.mu.Unlock()
|
||||
|
||||
if closed {
|
||||
return
|
||||
}
|
||||
|
||||
log.Warn().Err(err).Str("url", p.url).Caller().Send()
|
||||
}
|
||||
|
||||
p.reconnect(workerID)
|
||||
}
|
||||
|
||||
func (p *Producer) reconnect(workerID int) {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
|
||||
if p.state != stateStart {
|
||||
if p.workerID != workerID {
|
||||
log.Trace().Msgf("[streams] stop reconnect url=%s", p.url)
|
||||
return
|
||||
}
|
||||
|
||||
log.Debug().Msgf("[streams] reconnect to url=%s", p.url)
|
||||
|
||||
var err error
|
||||
p.element, err = GetProducer(p.url)
|
||||
if err != nil || p.element == nil {
|
||||
log.Debug().Err(err).Caller().Send()
|
||||
p.element, p.lastErr = GetProducer(p.url)
|
||||
if p.lastErr != nil || p.element == nil {
|
||||
log.Debug().Msgf("[streams] producer=%s", p.lastErr)
|
||||
// TODO: dynamic timeout
|
||||
p.restart = time.AfterFunc(30*time.Second, p.reconnect)
|
||||
time.AfterFunc(30*time.Second, func() {
|
||||
p.reconnect(workerID)
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -148,12 +176,7 @@ func (p *Producer) reconnect() {
|
||||
}
|
||||
}
|
||||
|
||||
go func() {
|
||||
if err = p.element.Start(); err != nil {
|
||||
log.Debug().Err(err).Caller().Send()
|
||||
}
|
||||
p.reconnect()
|
||||
}()
|
||||
go p.worker(p.element, workerID)
|
||||
}
|
||||
|
||||
func (p *Producer) stop() {
|
||||
@@ -167,6 +190,8 @@ func (p *Producer) stop() {
|
||||
case stateNone:
|
||||
log.Debug().Msgf("[streams] can't stop none producer")
|
||||
return
|
||||
case stateStart:
|
||||
p.workerID++
|
||||
}
|
||||
|
||||
log.Debug().Msgf("[streams] stop producer url=%s", p.url)
|
||||
@@ -175,10 +200,6 @@ func (p *Producer) stop() {
|
||||
_ = p.element.Stop()
|
||||
p.element = nil
|
||||
}
|
||||
if p.restart != nil {
|
||||
p.restart.Stop()
|
||||
p.restart = nil
|
||||
}
|
||||
|
||||
p.state = stateNone
|
||||
p.tracks = nil
|
||||
|
@@ -7,17 +7,14 @@ import (
|
||||
"github.com/AlexxIT/go2rtc/pkg/streamer"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
)
|
||||
|
||||
type Consumer struct {
|
||||
element streamer.Consumer
|
||||
tracks []*streamer.Track
|
||||
}
|
||||
|
||||
type Stream struct {
|
||||
producers []*Producer
|
||||
consumers []*Consumer
|
||||
mu sync.Mutex
|
||||
requests int32
|
||||
}
|
||||
|
||||
func NewStream(source interface{}) *Stream {
|
||||
@@ -52,6 +49,9 @@ func (s *Stream) SetSource(source string) {
|
||||
}
|
||||
|
||||
func (s *Stream) AddConsumer(cons streamer.Consumer) (err error) {
|
||||
// support for multiple simultaneous requests from different consumers
|
||||
atomic.AddInt32(&s.requests, 1)
|
||||
|
||||
ic := len(s.consumers)
|
||||
|
||||
consumer := &Consumer{element: cons}
|
||||
@@ -82,7 +82,7 @@ func (s *Stream) AddConsumer(cons streamer.Consumer) (err error) {
|
||||
// Step 4. Get producer track
|
||||
prodTrack := prod.GetTrack(prodMedia, prodCodec)
|
||||
if prodTrack == nil {
|
||||
log.Warn().Msg("[stream] can't get track")
|
||||
log.Warn().Str("url", prod.url).Msg("[streams] can't get track")
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -97,13 +97,22 @@ func (s *Stream) AddConsumer(cons streamer.Consumer) (err error) {
|
||||
}
|
||||
}
|
||||
|
||||
if len(producers) == 0 {
|
||||
if atomic.AddInt32(&s.requests, -1) == 0 {
|
||||
s.stopProducers()
|
||||
}
|
||||
|
||||
if len(producers) == 0 {
|
||||
if len(codecs) > 0 {
|
||||
return errors.New("codecs not match: " + codecs)
|
||||
} else {
|
||||
return fmt.Errorf("sources unavailable: %d", len(s.producers))
|
||||
}
|
||||
|
||||
for i, producer := range s.producers {
|
||||
if producer.lastErr != nil {
|
||||
return fmt.Errorf("source %d error: %w", i, producer.lastErr)
|
||||
}
|
||||
}
|
||||
|
||||
return fmt.Errorf("sources unavailable: %d", len(s.producers))
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
@@ -183,22 +192,21 @@ producers:
|
||||
//}
|
||||
|
||||
func (s *Stream) MarshalJSON() ([]byte, error) {
|
||||
var v []interface{}
|
||||
s.mu.Lock()
|
||||
for _, prod := range s.producers {
|
||||
if prod.element != nil {
|
||||
v = append(v, prod.element)
|
||||
}
|
||||
if !s.mu.TryLock() {
|
||||
log.Warn().Msgf("[streams] json locked")
|
||||
return json.Marshal(nil)
|
||||
}
|
||||
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) {
|
||||
@@ -228,6 +236,10 @@ func (s *Stream) removeProducer(i int) {
|
||||
}
|
||||
|
||||
func collectCodecs(media *streamer.Media, codecs *string) {
|
||||
if media.Direction == streamer.DirectionRecvonly {
|
||||
return
|
||||
}
|
||||
|
||||
for _, codec := range media.Codecs {
|
||||
name := codec.Name
|
||||
if name == streamer.CodecAAC {
|
||||
|
@@ -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,29 @@ func GetOrNew(src string) *Stream {
|
||||
return New(src, src)
|
||||
}
|
||||
|
||||
func Delete(name string) {
|
||||
delete(streams, name)
|
||||
}
|
||||
func streamsHandler(w http.ResponseWriter, r *http.Request) {
|
||||
src := r.URL.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
|
||||
//}
|
||||
switch r.Method {
|
||||
case "PUT":
|
||||
name := r.URL.Query().Get("name")
|
||||
if name == "" {
|
||||
name = src
|
||||
}
|
||||
New(name, src)
|
||||
return
|
||||
case "DELETE":
|
||||
delete(streams, src)
|
||||
return
|
||||
}
|
||||
|
||||
if src != "" {
|
||||
e := json.NewEncoder(w)
|
||||
e.SetIndent("", " ")
|
||||
_ = e.Encode(streams[src])
|
||||
} else {
|
||||
_ = json.NewEncoder(w).Encode(streams)
|
||||
}
|
||||
return all
|
||||
}
|
||||
|
||||
var log zerolog.Logger
|
||||
|
@@ -7,6 +7,7 @@ import (
|
||||
)
|
||||
|
||||
var candidates []string
|
||||
var networks = []string{"udp", "tcp"}
|
||||
|
||||
func AddCandidate(address string) {
|
||||
candidates = append(candidates, address)
|
||||
@@ -20,15 +21,17 @@ func asyncCandidates(tr *api.Transport) {
|
||||
continue
|
||||
}
|
||||
|
||||
cand, err := webrtc.NewCandidate(address)
|
||||
if err != nil {
|
||||
log.Warn().Err(err).Caller().Send()
|
||||
continue
|
||||
for _, network := range networks {
|
||||
cand, err := webrtc.NewCandidate(network, address)
|
||||
if err != nil {
|
||||
log.Warn().Err(err).Caller().Send()
|
||||
continue
|
||||
}
|
||||
|
||||
log.Trace().Str("candidate", cand).Msg("[webrtc] config")
|
||||
|
||||
tr.Write(&api.Message{Type: "webrtc/candidate", Value: cand})
|
||||
}
|
||||
|
||||
log.Trace().Str("candidate", cand).Msg("[webrtc] config")
|
||||
|
||||
tr.Write(&api.Message{Type: "webrtc/candidate", Value: cand})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,13 +60,15 @@ func syncCanditates(answer string) (string, error) {
|
||||
continue
|
||||
}
|
||||
|
||||
cand, err := webrtc.NewCandidate(address)
|
||||
if err != nil {
|
||||
log.Warn().Err(err).Msg("[webrtc] candidate")
|
||||
continue
|
||||
}
|
||||
for _, network := range networks {
|
||||
cand, err := webrtc.NewCandidate(network, address)
|
||||
if err != nil {
|
||||
log.Warn().Err(err).Msg("[webrtc] candidate")
|
||||
continue
|
||||
}
|
||||
|
||||
md.WithPropertyAttribute(cand)
|
||||
md.WithPropertyAttribute(cand)
|
||||
}
|
||||
}
|
||||
|
||||
if end {
|
||||
|
@@ -22,6 +22,7 @@ func Init() {
|
||||
} `yaml:"webrtc"`
|
||||
}
|
||||
|
||||
cfg.Mod.Listen = ":8555"
|
||||
cfg.Mod.IceServers = []pion.ICEServer{
|
||||
{URLs: []string{"stun:stun.l.google.com:19302"}},
|
||||
}
|
||||
@@ -68,7 +69,7 @@ var NewPConn func() (*pion.PeerConnection, error)
|
||||
|
||||
func asyncHandler(tr *api.Transport, msg *api.Message) error {
|
||||
src := tr.Request.URL.Query().Get("src")
|
||||
stream := streams.Get(src)
|
||||
stream := streams.GetOrNew(src)
|
||||
if stream == nil {
|
||||
return errors.New(api.StreamNotFound)
|
||||
}
|
||||
@@ -112,7 +113,7 @@ func asyncHandler(tr *api.Transport, msg *api.Message) error {
|
||||
|
||||
// 2. AddConsumer, so we get new tracks
|
||||
if err = stream.AddConsumer(conn); err != nil {
|
||||
log.Warn().Err(err).Caller().Send()
|
||||
log.Debug().Err(err).Msg("[webrtc] add consumer")
|
||||
_ = conn.Conn.Close()
|
||||
return err
|
||||
}
|
||||
|
52
hardware.Dockerfile
Normal file
52
hardware.Dockerfile
Normal file
@@ -0,0 +1,52 @@
|
||||
# 0. Prepare images
|
||||
# only debian 12 (bookworm) has latest ffmpeg
|
||||
ARG DEBIAN_VERSION="bookworm-slim"
|
||||
ARG GO_VERSION="1.19-buster"
|
||||
ARG NGROK_VERSION="3"
|
||||
|
||||
FROM debian:${DEBIAN_VERSION} AS base
|
||||
FROM golang:${GO_VERSION} AS go
|
||||
FROM ngrok/ngrok:${NGROK_VERSION} AS ngrok
|
||||
|
||||
|
||||
# 1. Build go2rtc binary
|
||||
FROM go AS build
|
||||
|
||||
WORKDIR /build
|
||||
|
||||
# Cache dependencies
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
|
||||
COPY . .
|
||||
RUN CGO_ENABLED=0 go build -ldflags "-s -w" -trimpath
|
||||
|
||||
|
||||
# 2. Collect all files
|
||||
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),
|
||||
# and other common tools for the echo source.
|
||||
# non-free for Intel QSV support (not used by go2rtc, just for tests)
|
||||
RUN echo 'deb http://deb.debian.org/debian bookworm non-free' > /etc/apt/sources.list.d/debian-non-free.list && \
|
||||
apt-get -y update && apt-get -y install tini ffmpeg python3 curl jq intel-media-va-driver-non-free
|
||||
|
||||
COPY --from=rootfs / /
|
||||
|
||||
RUN chmod a+x /run.sh && mkdir -p /config
|
||||
|
||||
ENTRYPOINT ["/usr/bin/tini", "--"]
|
||||
|
||||
# https://github.com/NVIDIA/nvidia-docker/wiki/Installation-(Native-GPU-Support)
|
||||
ENV NVIDIA_VISIBLE_DEVICES all
|
||||
ENV NVIDIA_DRIVER_CAPABILITIES compute,video,utility
|
||||
|
||||
CMD ["/run.sh"]
|
@@ -3,6 +3,7 @@ package h264
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"github.com/AlexxIT/go2rtc/pkg/streamer"
|
||||
"strings"
|
||||
)
|
||||
@@ -51,6 +52,16 @@ func GetProfileLevelID(fmtp string) string {
|
||||
if fmtp == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
// 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])
|
||||
}
|
||||
}
|
||||
|
||||
return streamer.Between(fmtp, "profile-level-id=", ";")
|
||||
}
|
||||
|
||||
|
@@ -36,26 +36,26 @@ func RTPDepay(track *streamer.Track) streamer.WrapperFunc {
|
||||
}
|
||||
|
||||
if len(buf) == 0 {
|
||||
// Amcrest IP4M-1051: 9, 7, 8, 6, 28...
|
||||
// Amcrest IP4M-1051: 9, 6, 1
|
||||
switch NALUType(payload) {
|
||||
case NALUTypeIFrame:
|
||||
// fix IFrame without SPS,PPS
|
||||
buf = append(buf, ps...)
|
||||
case NALUTypeSEI, NALUTypeAUD:
|
||||
// fix ffmpeg with transcoding first frame
|
||||
i := int(4 + binary.BigEndian.Uint32(payload))
|
||||
|
||||
// check if only one NAL (fix ffmpeg transcoding for Reolink RLC-510A)
|
||||
if i == len(payload) {
|
||||
return nil
|
||||
}
|
||||
|
||||
payload = payload[i:]
|
||||
|
||||
if NALUType(payload) == NALUTypeIFrame {
|
||||
for {
|
||||
// Amcrest IP4M-1051: 9, 7, 8, 6, 28...
|
||||
// Amcrest IP4M-1051: 9, 6, 1
|
||||
switch NALUType(payload) {
|
||||
case NALUTypeIFrame:
|
||||
// fix IFrame without SPS,PPS
|
||||
buf = append(buf, ps...)
|
||||
case NALUTypeSEI, NALUTypeAUD:
|
||||
// fix ffmpeg with transcoding first frame
|
||||
i := int(4 + binary.BigEndian.Uint32(payload))
|
||||
|
||||
// check if only one NAL (fix ffmpeg transcoding for Reolink RLC-510A)
|
||||
if i == len(payload) {
|
||||
return nil
|
||||
}
|
||||
|
||||
payload = payload[i:]
|
||||
continue
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,7 +70,7 @@ func RTPDepay(track *streamer.Track) streamer.WrapperFunc {
|
||||
buf = buf[:0]
|
||||
}
|
||||
|
||||
//log.Printf("[AVC] %v, len: %d", Types(payload), len(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,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)
|
||||
}
|
||||
|
@@ -14,9 +14,19 @@ import (
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
)
|
||||
|
||||
type State byte
|
||||
|
||||
const (
|
||||
StateNone State = iota
|
||||
StateConn
|
||||
StateHandle
|
||||
)
|
||||
|
||||
type Client struct {
|
||||
streamer.Element
|
||||
|
||||
@@ -26,12 +36,14 @@ type Client struct {
|
||||
medias []*streamer.Media
|
||||
tracks map[byte]*streamer.Track
|
||||
|
||||
closed bool
|
||||
|
||||
msg *message
|
||||
t0 time.Time
|
||||
|
||||
buffer chan []byte
|
||||
state State
|
||||
mu sync.Mutex
|
||||
|
||||
recv uint32
|
||||
}
|
||||
|
||||
func NewClient(id string) *Client {
|
||||
@@ -69,16 +81,26 @@ func (c *Client) Dial() (err error) {
|
||||
return err
|
||||
}
|
||||
|
||||
c.state = StateConn
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Client) Handle() error {
|
||||
c.buffer = make(chan []byte, 5)
|
||||
// add delay to the stream for smooth playing (not a best solution)
|
||||
c.t0 = time.Now().Add(time.Second)
|
||||
|
||||
// processing stream in separate thread for lower delay between packets
|
||||
go c.worker()
|
||||
c.mu.Lock()
|
||||
|
||||
if c.state == StateConn {
|
||||
c.buffer = make(chan []byte, 5)
|
||||
c.state = StateHandle
|
||||
|
||||
// processing stream in separate thread for lower delay between packets
|
||||
go c.worker(c.buffer)
|
||||
}
|
||||
|
||||
c.mu.Unlock()
|
||||
|
||||
_, data, err := c.conn.ReadMessage()
|
||||
if err != nil {
|
||||
@@ -87,7 +109,12 @@ func (c *Client) Handle() error {
|
||||
|
||||
track := c.tracks[c.msg.Track]
|
||||
if track != nil {
|
||||
c.buffer <- data
|
||||
c.mu.Lock()
|
||||
if c.state == StateHandle {
|
||||
c.buffer <- data
|
||||
atomic.AddUint32(&c.recv, uint32(len(data)))
|
||||
}
|
||||
c.mu.Unlock()
|
||||
}
|
||||
|
||||
// we have one unprocessed msg after getTracks
|
||||
@@ -114,7 +141,12 @@ func (c *Client) Handle() error {
|
||||
|
||||
track = c.tracks[msg.Track]
|
||||
if track != nil {
|
||||
c.buffer <- data
|
||||
c.mu.Lock()
|
||||
if c.state == StateHandle {
|
||||
c.buffer <- data
|
||||
atomic.AddUint32(&c.recv, uint32(len(data)))
|
||||
}
|
||||
c.mu.Unlock()
|
||||
}
|
||||
|
||||
default:
|
||||
@@ -124,11 +156,19 @@ func (c *Client) Handle() error {
|
||||
}
|
||||
|
||||
func (c *Client) Close() error {
|
||||
if c.conn == nil {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
switch c.state {
|
||||
case StateNone:
|
||||
return nil
|
||||
case StateConn:
|
||||
case StateHandle:
|
||||
close(c.buffer)
|
||||
}
|
||||
close(c.buffer)
|
||||
c.closed = true
|
||||
|
||||
c.state = StateNone
|
||||
|
||||
return c.conn.Close()
|
||||
}
|
||||
|
||||
@@ -208,13 +248,13 @@ func (c *Client) getTracks() error {
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) worker() {
|
||||
func (c *Client) worker(buffer chan []byte) {
|
||||
var track *streamer.Track
|
||||
for _, track = range c.tracks {
|
||||
break
|
||||
}
|
||||
|
||||
for data := range c.buffer {
|
||||
for data := range buffer {
|
||||
moof := &fmp4io.MovieFrag{}
|
||||
if _, err := moof.Unmarshal(data, 0); err != nil {
|
||||
continue
|
||||
|
@@ -1,8 +1,10 @@
|
||||
package ivideon
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"github.com/AlexxIT/go2rtc/pkg/streamer"
|
||||
"sync/atomic"
|
||||
)
|
||||
|
||||
func (c *Client) GetMedias() []*streamer.Media {
|
||||
@@ -20,7 +22,7 @@ func (c *Client) GetTrack(media *streamer.Media, codec *streamer.Codec) *streame
|
||||
|
||||
func (c *Client) Start() error {
|
||||
err := c.Handle()
|
||||
if c.closed {
|
||||
if c.buffer == nil {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
@@ -29,3 +31,19 @@ func (c *Client) Start() error {
|
||||
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)
|
||||
}
|
||||
|
@@ -2,6 +2,7 @@ package mjpeg
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"github.com/AlexxIT/go2rtc/pkg/streamer"
|
||||
"github.com/AlexxIT/go2rtc/pkg/tcp"
|
||||
@@ -11,6 +12,7 @@ import (
|
||||
"net/textproto"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
)
|
||||
|
||||
@@ -24,6 +26,7 @@ type Client struct {
|
||||
res *http.Response
|
||||
|
||||
track *streamer.Track
|
||||
recv uint32
|
||||
}
|
||||
|
||||
func NewClient(res *http.Response) *Client {
|
||||
@@ -64,10 +67,23 @@ func (c *Client) Start() error {
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
func (c *Client) startJPEG() error {
|
||||
buf, err := io.ReadAll(c.res.Body)
|
||||
if err != nil {
|
||||
@@ -77,6 +93,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 {
|
||||
@@ -96,6 +114,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)))
|
||||
}
|
||||
|
||||
return nil
|
||||
@@ -139,6 +159,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)
|
||||
}
|
||||
|
@@ -138,9 +138,9 @@ var chm_ac_symbols = []byte{
|
||||
0xf9, 0xfa,
|
||||
}
|
||||
|
||||
func MakeHeaders(t byte, w, h uint16, lqt, cqt []byte) []byte {
|
||||
func MakeHeaders(p []byte, t byte, w, h uint16, lqt, cqt []byte) []byte {
|
||||
// Appendix A from https://www.rfc-editor.org/rfc/rfc2435
|
||||
p := []byte{0xFF, 0xD8}
|
||||
p = append(p, 0xFF, 0xD8)
|
||||
|
||||
p = MakeQuantHeader(p, lqt, 0)
|
||||
p = MakeQuantHeader(p, cqt, 1)
|
||||
|
@@ -6,7 +6,7 @@ import (
|
||||
)
|
||||
|
||||
func RTPDepay(track *streamer.Track) streamer.WrapperFunc {
|
||||
var header, payload []byte
|
||||
buf := make([]byte, 0, 512*1024) // 512K
|
||||
|
||||
return func(push streamer.WriterFunc) streamer.WriterFunc {
|
||||
return func(packet *rtp.Packet) error {
|
||||
@@ -25,7 +25,7 @@ func RTPDepay(track *streamer.Track) streamer.WrapperFunc {
|
||||
b = b[8:]
|
||||
}
|
||||
|
||||
if header == nil {
|
||||
if len(buf) == 0 {
|
||||
var lqt, cqt []byte
|
||||
|
||||
// 3.1.8. Quantization Table header
|
||||
@@ -49,26 +49,26 @@ func RTPDepay(track *streamer.Track) streamer.WrapperFunc {
|
||||
}
|
||||
|
||||
//fmt.Printf("t: %d, q: %d, w: %d, h: %d\n", t, q, w, h)
|
||||
header = MakeHeaders(t, w, h, lqt, cqt)
|
||||
buf = MakeHeaders(buf, t, w, h, lqt, cqt)
|
||||
}
|
||||
|
||||
// 3.1.9. JPEG Payload
|
||||
payload = append(payload, b...)
|
||||
buf = append(buf, b...)
|
||||
|
||||
if !packet.Marker {
|
||||
return nil
|
||||
}
|
||||
|
||||
b = append(header, payload...)
|
||||
if end := b[len(b)-2:]; end[0] != 0xFF && end[1] != 0xD9 {
|
||||
b = append(b, 0xFF, 0xD9)
|
||||
if end := buf[len(buf)-2:]; end[0] != 0xFF && end[1] != 0xD9 {
|
||||
buf = append(buf, 0xFF, 0xD9)
|
||||
}
|
||||
|
||||
header = nil
|
||||
payload = nil
|
||||
clone := *packet
|
||||
clone.Payload = buf
|
||||
|
||||
packet.Payload = b
|
||||
return push(packet)
|
||||
buf = buf[:0] // clear buffer
|
||||
|
||||
return push(&clone)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -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,7 @@ type Consumer struct {
|
||||
codecs []*streamer.Codec
|
||||
wait byte
|
||||
|
||||
send int
|
||||
send uint32
|
||||
}
|
||||
|
||||
const (
|
||||
@@ -76,7 +77,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 +109,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 +129,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
|
||||
@@ -163,12 +164,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)
|
||||
}
|
||||
|
@@ -1,7 +1,6 @@
|
||||
package mp4f
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"github.com/AlexxIT/go2rtc/pkg/h264"
|
||||
"github.com/AlexxIT/go2rtc/pkg/streamer"
|
||||
"github.com/deepch/vdk/av"
|
||||
@@ -89,7 +88,7 @@ func (c *Consumer) AddTrack(media *streamer.Media, track *streamer.Track) *strea
|
||||
return nil
|
||||
}
|
||||
|
||||
if !codec.IsRAW() {
|
||||
if codec.IsRTP() {
|
||||
wrapper := h264.RTPDepay(track)
|
||||
push = wrapper(push)
|
||||
}
|
||||
@@ -149,16 +148,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)
|
||||
}
|
||||
|
@@ -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)
|
||||
}
|
||||
|
@@ -20,6 +20,7 @@ import (
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
@@ -47,11 +48,28 @@ const (
|
||||
|
||||
type State byte
|
||||
|
||||
func (s State) String() string {
|
||||
switch s {
|
||||
case StateNone:
|
||||
return "NONE"
|
||||
case StateConn:
|
||||
return "CONN"
|
||||
case StateSetup:
|
||||
return "SETUP"
|
||||
case StatePlay:
|
||||
return "PLAY"
|
||||
case StateHandle:
|
||||
return "HANDLE"
|
||||
}
|
||||
return strconv.Itoa(int(s))
|
||||
}
|
||||
|
||||
const (
|
||||
StateNone State = iota
|
||||
StateConn
|
||||
StateSetup
|
||||
StatePlay
|
||||
StateHandle
|
||||
)
|
||||
|
||||
type Conn struct {
|
||||
@@ -72,6 +90,7 @@ type Conn struct {
|
||||
conn net.Conn
|
||||
mode Mode
|
||||
state State
|
||||
stateMu sync.Mutex
|
||||
reader *bufio.Reader
|
||||
sequence int
|
||||
uri string
|
||||
@@ -340,6 +359,13 @@ func (c *Conn) Setup() error {
|
||||
func (c *Conn) SetupMedia(
|
||||
media *streamer.Media, codec *streamer.Codec,
|
||||
) (*streamer.Track, error) {
|
||||
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)
|
||||
}
|
||||
|
||||
ch := c.GetChannel(media)
|
||||
if ch < 0 {
|
||||
return nil, fmt.Errorf("wrong media: %v", media)
|
||||
@@ -461,12 +487,19 @@ func (c *Conn) SetupMedia(
|
||||
}
|
||||
|
||||
func (c *Conn) Play() (err error) {
|
||||
c.stateMu.Lock()
|
||||
defer c.stateMu.Unlock()
|
||||
|
||||
if c.state != StateSetup {
|
||||
return fmt.Errorf("RTSP PLAY from wrong state: %s", c.state)
|
||||
}
|
||||
|
||||
req := &tcp.Request{Method: MethodPlay, URL: c.URL}
|
||||
return c.Request(req)
|
||||
if err = c.Request(req); err == nil {
|
||||
c.state = StatePlay
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func (c *Conn) Teardown() (err error) {
|
||||
@@ -476,12 +509,14 @@ func (c *Conn) Teardown() (err error) {
|
||||
}
|
||||
|
||||
func (c *Conn) Close() error {
|
||||
c.stateMu.Lock()
|
||||
defer c.stateMu.Unlock()
|
||||
|
||||
if c.state == StateNone {
|
||||
return nil
|
||||
}
|
||||
if err := c.Teardown(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_ = c.Teardown()
|
||||
c.state = StateNone
|
||||
return c.conn.Close()
|
||||
}
|
||||
@@ -614,7 +649,10 @@ func (c *Conn) Accept() error {
|
||||
|
||||
case MethodRecord, MethodPlay:
|
||||
res := &tcp.Response{Request: req}
|
||||
return c.Response(res)
|
||||
if err = c.Response(res); err == nil {
|
||||
c.state = StatePlay
|
||||
}
|
||||
return err
|
||||
|
||||
default:
|
||||
return fmt.Errorf("unsupported method: %s", req.Method)
|
||||
@@ -623,13 +661,31 @@ func (c *Conn) Accept() error {
|
||||
}
|
||||
|
||||
func (c *Conn) Handle() (err error) {
|
||||
if c.state != StateSetup {
|
||||
return fmt.Errorf("RTSP Handle from wrong state: %d", c.state)
|
||||
c.stateMu.Lock()
|
||||
|
||||
switch c.state {
|
||||
case StateNone: // Close after PLAY and before Handle is OK (because SETUP after PLAY)
|
||||
case StatePlay:
|
||||
c.state = StateHandle
|
||||
default:
|
||||
err = fmt.Errorf("RTSP HANDLE from wrong state: %s", c.state)
|
||||
|
||||
c.state = StateNone
|
||||
_ = c.conn.Close()
|
||||
}
|
||||
|
||||
c.state = StatePlay
|
||||
ok := c.state == StateHandle
|
||||
|
||||
c.stateMu.Unlock()
|
||||
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
defer func() {
|
||||
c.stateMu.Lock()
|
||||
defer c.stateMu.Unlock()
|
||||
|
||||
if c.state == StateNone {
|
||||
err = nil
|
||||
return
|
||||
|
@@ -4,7 +4,6 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"github.com/AlexxIT/go2rtc/pkg/streamer"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
// Element Producer
|
||||
@@ -21,7 +20,8 @@ func (c *Conn) GetTrack(media *streamer.Media, codec *streamer.Codec) *streamer.
|
||||
}
|
||||
|
||||
// can't setup new tracks from play state - forcing a reconnection feature
|
||||
if c.state == StatePlay {
|
||||
switch c.state {
|
||||
case StatePlay, StateHandle:
|
||||
go c.Close()
|
||||
return streamer.NewTrack(codec, media.Direction)
|
||||
}
|
||||
@@ -87,40 +87,30 @@ func (c *Conn) AddTrack(media *streamer.Media, track *streamer.Track) *streamer.
|
||||
//
|
||||
|
||||
func (c *Conn) MarshalJSON() ([]byte, error) {
|
||||
v := map[string]interface{}{
|
||||
streamer.JSONReceive: c.receive,
|
||||
streamer.JSONSend: c.send,
|
||||
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:
|
||||
v[streamer.JSONType] = "RTSP unknown"
|
||||
case ModeClientProducer:
|
||||
v[streamer.JSONType] = "RTSP client producer"
|
||||
case ModeServerProducer:
|
||||
v[streamer.JSONType] = "RTSP server producer"
|
||||
info.Type = "RTSP unknown"
|
||||
case ModeClientProducer, ModeServerProducer:
|
||||
info.Type = "RTSP source"
|
||||
case ModeServerConsumer:
|
||||
v[streamer.JSONType] = "RTSP server consumer"
|
||||
info.Type = "RTSP client"
|
||||
}
|
||||
//if c.URI != "" {
|
||||
// v["uri"] = c.URI
|
||||
//}
|
||||
|
||||
if c.URL != nil {
|
||||
v["url"] = c.URL.String()
|
||||
info.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(int(i>>1))
|
||||
v[k] = track.String()
|
||||
info.RemoteAddr = c.conn.RemoteAddr().String()
|
||||
}
|
||||
|
||||
//for i, track := range c.tracks {
|
||||
// k := "track:" + strconv.Itoa(i+1)
|
||||
// if track.MimeType() == streamer.MimeTypeH264 {
|
||||
@@ -129,5 +119,6 @@ func (c *Conn) MarshalJSON() ([]byte, error) {
|
||||
// v[k] = track.MimeType()
|
||||
// }
|
||||
//}
|
||||
return json.Marshal(v)
|
||||
|
||||
return json.Marshal(info)
|
||||
}
|
||||
|
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
|
||||
|
@@ -4,13 +4,16 @@ import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
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)
|
||||
|
@@ -1,6 +1,7 @@
|
||||
package streamer
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"github.com/pion/sdp/v3"
|
||||
"strconv"
|
||||
@@ -70,6 +71,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
|
||||
|
@@ -1,6 +1,7 @@
|
||||
package streamer
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"github.com/pion/rtp"
|
||||
"sync"
|
||||
@@ -22,12 +23,19 @@ func NewTrack(codec *Codec, direction string) *Track {
|
||||
|
||||
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 {
|
||||
|
@@ -35,13 +35,17 @@ func NewAPI(address string) (*webrtc.API, error) {
|
||||
s.SetICEMulticastDNSMode(ice.MulticastDNSModeDisabled)
|
||||
|
||||
if address != "" {
|
||||
ln, err := net.Listen("tcp", address)
|
||||
if err == nil {
|
||||
s.SetNetworkTypes([]webrtc.NetworkType{
|
||||
webrtc.NetworkTypeUDP4, webrtc.NetworkTypeUDP6,
|
||||
webrtc.NetworkTypeTCP4, webrtc.NetworkTypeTCP6,
|
||||
})
|
||||
s.SetNetworkTypes([]webrtc.NetworkType{
|
||||
webrtc.NetworkTypeUDP4, webrtc.NetworkTypeUDP6,
|
||||
webrtc.NetworkTypeTCP4, webrtc.NetworkTypeTCP6,
|
||||
})
|
||||
|
||||
if ln, err := net.ListenPacket("udp", address); err == nil {
|
||||
udpMux := webrtc.NewICEUDPMux(nil, ln)
|
||||
s.SetICEUDPMux(udpMux)
|
||||
}
|
||||
|
||||
if ln, err := net.Listen("tcp", address); err == nil {
|
||||
tcpMux := webrtc.NewICETCPMux(nil, ln, 8)
|
||||
s.SetICETCPMux(tcpMux)
|
||||
}
|
||||
|
@@ -113,20 +113,12 @@ func (c *Conn) AddCandidate(candidate string) {
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
@@ -1,6 +1,7 @@
|
||||
package webrtc
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/AlexxIT/go2rtc/pkg/streamer"
|
||||
"github.com/pion/ice/v2"
|
||||
@@ -12,24 +13,30 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
func NewCandidate(address string) (string, error) {
|
||||
host, port, err := net.SplitHostPort(address)
|
||||
if err != nil {
|
||||
return "", err
|
||||
func NewCandidate(network, address string) (string, error) {
|
||||
i := strings.LastIndexByte(address, ':')
|
||||
if i < 0 {
|
||||
return "", errors.New("wrong candidate: " + address)
|
||||
}
|
||||
host, port := address[:i], address[i+1:]
|
||||
|
||||
i, err := strconv.Atoi(port)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
cand, err := ice.NewCandidateHost(&ice.CandidateHostConfig{
|
||||
Network: "tcp",
|
||||
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
|
||||
}
|
||||
|
@@ -3,17 +3,17 @@
|
||||
@SET GOOS=windows
|
||||
@SET GOARCH=amd64
|
||||
@SET FILENAME=go2rtc_win64.zip
|
||||
go build -ldflags "-s -w" -trimpath && 7z a -sdel %FILENAME% go2rtc.exe
|
||||
go build -ldflags "-s -w" -trimpath && 7z a -mx9 -sdel %FILENAME% go2rtc.exe
|
||||
|
||||
@SET GOOS=windows
|
||||
@SET GOARCH=386
|
||||
@SET FILENAME=go2rtc_win32.zip
|
||||
go build -ldflags "-s -w" -trimpath && 7z a -sdel %FILENAME% go2rtc.exe
|
||||
go build -ldflags "-s -w" -trimpath && 7z a -mx9 -sdel %FILENAME% go2rtc.exe
|
||||
|
||||
@SET GOOS=windows
|
||||
@SET GOARCH=arm64
|
||||
@SET FILENAME=go2rtc_win_arm64.zip
|
||||
go build -ldflags "-s -w" -trimpath && 7z a -sdel %FILENAME% go2rtc.exe
|
||||
go build -ldflags "-s -w" -trimpath && 7z a -mx9 -sdel %FILENAME% go2rtc.exe
|
||||
|
||||
@SET GOOS=linux
|
||||
@SET GOARCH=amd64
|
||||
@@ -44,9 +44,9 @@ go build -ldflags "-s -w" -trimpath -o %FILENAME% && upx %FILENAME%
|
||||
@SET GOOS=darwin
|
||||
@SET GOARCH=amd64
|
||||
@SET FILENAME=go2rtc_mac_amd64.zip
|
||||
go build -ldflags "-s -w" -trimpath && 7z a -sdel %FILENAME% go2rtc
|
||||
go build -ldflags "-s -w" -trimpath && 7z a -mx9 -sdel %FILENAME% go2rtc
|
||||
|
||||
@SET GOOS=darwin
|
||||
@SET GOARCH=arm64
|
||||
@SET FILENAME=go2rtc_mac_arm64.zip
|
||||
go build -ldflags "-s -w" -trimpath && 7z a -sdel %FILENAME% go2rtc
|
||||
go build -ldflags "-s -w" -trimpath && 7z a -mx9 -sdel %FILENAME% go2rtc
|
||||
|
59
www/editor.html
Normal file
59
www/editor.html
Normal file
@@ -0,0 +1,59 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>File Editor</title>
|
||||
<meta name="viewport" content="width=device-width, user-scalable=yes, initial-scale=1, maximum-scale=1">
|
||||
<meta http-equiv="X-UA-Compatible" content="ie=edge">
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/ace/1.14.0/ace.min.js"></script>
|
||||
<style>
|
||||
body {
|
||||
font-family: Arial, Helvetica, sans-serif;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
html, body, #config {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<script src="main.js"></script>
|
||||
<div>
|
||||
<button id="save">Save & Restart</button>
|
||||
</div>
|
||||
<br>
|
||||
<div id="config"></div>
|
||||
<script>
|
||||
ace.config.set('basePath', 'https://cdnjs.cloudflare.com/ajax/libs/ace/1.14.0/');
|
||||
const editor = ace.edit("config", {
|
||||
mode: "ace/mode/yaml",
|
||||
});
|
||||
|
||||
document.getElementById('save').addEventListener('click', () => {
|
||||
fetch('api/config', {
|
||||
method: 'POST', body: editor.getValue()
|
||||
}).then(r => {
|
||||
if (r.ok) {
|
||||
alert("OK");
|
||||
fetch('api/exit', {method: 'POST'});
|
||||
} else {
|
||||
r.text().then(alert);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
window.addEventListener('load', () => {
|
||||
fetch('api/config').then(r => r.text()).then(data => {
|
||||
editor.setValue(data);
|
||||
});
|
||||
})
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
@@ -10,6 +10,7 @@
|
||||
<style>
|
||||
body {
|
||||
font-family: Arial, Helvetica, sans-serif;
|
||||
background-color: white;
|
||||
}
|
||||
|
||||
table {
|
||||
@@ -61,6 +62,7 @@
|
||||
</head>
|
||||
<body>
|
||||
<script src="main.js"></script>
|
||||
<div class="info"></div>
|
||||
<div class="header">
|
||||
<input id="src" type="text" placeholder="url">
|
||||
<a id="add" href="#">add</a>
|
||||
@@ -89,7 +91,6 @@
|
||||
'<a href="webrtc.html?src={name}">2-way-aud</a>',
|
||||
'<a href="api/stream.mp4?src={name}">mp4</a>',
|
||||
'<a href="api/stream.mjpeg?src={name}">mjpeg</a>',
|
||||
`<a href="rtsp://${location.hostname}:8554/{name}">rtsp</a>`,
|
||||
'<a href="api/streams?src={name}">info</a>',
|
||||
'<a href="#" data-name="{name}">delete</a>',
|
||||
];
|
||||
@@ -136,7 +137,7 @@
|
||||
tbody.innerHTML = "";
|
||||
|
||||
for (const [name, value] of Object.entries(data)) {
|
||||
const online = value ? value.length : 0;
|
||||
const online = value && value.consumers ? value.consumers.length : 0;
|
||||
const links = templates.map(link => {
|
||||
return link.replace("{name}", encodeURIComponent(name));
|
||||
}).join(" ");
|
||||
@@ -151,7 +152,21 @@
|
||||
});
|
||||
}
|
||||
|
||||
reload();
|
||||
const url = new URL("api", location.href);
|
||||
fetch(url).then(r => r.json()).then(data => {
|
||||
const info = document.querySelector(".info");
|
||||
info.innerText = `Version: ${data.version}, Config: ${data.config_path}`;
|
||||
|
||||
try {
|
||||
const host = data.host.match(/^[^:]+/)[0];
|
||||
const port = data.rtsp.listen.match(/[0-9]+$/)[0];
|
||||
templates.splice(4, 0, `<a href="rtsp://${host}:${port}/{name}">rtsp</a>`);
|
||||
} catch (e) {
|
||||
templates.splice(4, 0, `<a href="rtsp://${location.host}:8554/{name}">rtsp</a>`);
|
||||
}
|
||||
|
||||
reload();
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
@@ -47,6 +47,7 @@ nav li {
|
||||
<li><a href="index.html">Streams</a></li>
|
||||
<li><a href="devices.html">Devices</a></li>
|
||||
<li><a href="homekit.html">HomeKit</a></li>
|
||||
<li><a href="editor.html">Config</a></li>
|
||||
</ul>
|
||||
</nav>
|
||||
` + document.body.innerHTML;
|
||||
|
@@ -3,13 +3,13 @@
|
||||
<head>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>go2rtc - Stream</title>
|
||||
<script src="video-rtc.js"></script>
|
||||
<style>
|
||||
body {
|
||||
background: black;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
font-family: Arial, Helvetica, sans-serif;
|
||||
}
|
||||
|
||||
html, body {
|
||||
@@ -25,7 +25,8 @@
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<script>
|
||||
<script type="module" src="./video-stream.js"></script>
|
||||
<script type="module">
|
||||
const params = new URLSearchParams(location.search);
|
||||
|
||||
// support multiple streams and multiple modes
|
||||
@@ -44,16 +45,16 @@
|
||||
document.body.className = "flex";
|
||||
}
|
||||
|
||||
const background = params.get("background") === "true";
|
||||
const background = params.get("background") !== "false";
|
||||
const width = "1 0 " + (params.get("width") || "320px");
|
||||
|
||||
for (let i = 0; i < streams.length; i++) {
|
||||
/** @type {VideoRTC} */
|
||||
const video = document.createElement("video-rtc");
|
||||
/** @type {VideoStream} */
|
||||
const video = document.createElement("video-stream");
|
||||
video.background = background;
|
||||
video.mode = modes[i] || video.mode;
|
||||
video.style.flex = width;
|
||||
video.src = new URL("api/ws?src=" + streams[i], location.href);
|
||||
video.src = new URL("api/ws?src=" + encodeURIComponent(streams[i]), location.href);
|
||||
document.body.appendChild(video);
|
||||
}
|
||||
</script>
|
||||
|
505
www/video-rtc.js
505
www/video-rtc.js
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* Video player for MSE and WebRTC connections.
|
||||
* Video player for go2rtc streaming application.
|
||||
*
|
||||
* All modern web technologies are supported in almost any browser except Apple Safari.
|
||||
*
|
||||
@@ -12,7 +12,7 @@
|
||||
* - Customized built-in elements (extends HTMLVideoElement) because all Safari
|
||||
* - Public class fields because old Safari (before 14.0)
|
||||
*/
|
||||
class VideoRTC extends HTMLElement {
|
||||
export class VideoRTC extends HTMLElement {
|
||||
constructor() {
|
||||
super();
|
||||
|
||||
@@ -31,32 +31,50 @@ class VideoRTC extends HTMLElement {
|
||||
];
|
||||
|
||||
/**
|
||||
* Supported modes (webrtc, mse, mp4, mjpeg).
|
||||
* [config] Supported modes (webrtc, mse, mp4, mjpeg).
|
||||
* @type {string}
|
||||
*/
|
||||
this.mode = "webrtc,mse,mp4,mjpeg";
|
||||
|
||||
/**
|
||||
* Run stream when not displayed on the screen. Default `false`.
|
||||
* [config] Run stream when not displayed on the screen. Default `false`.
|
||||
* @type {boolean}
|
||||
*/
|
||||
this.background = false;
|
||||
|
||||
/**
|
||||
* Run stream only when player in the viewport. Stop when user scroll out player.
|
||||
* [config] Run stream only when player in the viewport. Stop when user scroll out player.
|
||||
* Value is percentage of visibility from `0` (not visible) to `1` (full visible).
|
||||
* Default `0` - disable;
|
||||
* @type {number}
|
||||
*/
|
||||
this.intersectionThreshold = 0;
|
||||
this.visibilityThreshold = 0;
|
||||
|
||||
/**
|
||||
* Run stream only when browser page on the screen. Stop when user change browser
|
||||
* [config] Run stream only when browser page on the screen. Stop when user change browser
|
||||
* tab or minimise browser windows.
|
||||
* @type {boolean}
|
||||
*/
|
||||
this.visibilityCheck = true;
|
||||
|
||||
/**
|
||||
* [config] WebRTC configuration
|
||||
* @type {RTCConfiguration}
|
||||
*/
|
||||
this.pcConfig = {iceServers: [{urls: "stun:stun.l.google.com:19302"}]};
|
||||
|
||||
/**
|
||||
* [info] WebSocket connection state. Values: CONNECTING, OPEN, CLOSED
|
||||
* @type {number}
|
||||
*/
|
||||
this.wsState = WebSocket.CLOSED;
|
||||
|
||||
/**
|
||||
* [info] WebRTC connection state.
|
||||
* @type {number}
|
||||
*/
|
||||
this.pcState = WebSocket.CLOSED;
|
||||
|
||||
/**
|
||||
* @type {HTMLVideoElement}
|
||||
*/
|
||||
@@ -68,16 +86,9 @@ class VideoRTC extends HTMLElement {
|
||||
this.ws = null;
|
||||
|
||||
/**
|
||||
* Internal WebSocket connection state. Values: CONNECTING, OPEN, CLOSED
|
||||
* @type {number}
|
||||
*/
|
||||
this.wsState = WebSocket.CLOSED;
|
||||
|
||||
/**
|
||||
* Internal WebSocket URL.
|
||||
* @type {string|URL}
|
||||
*/
|
||||
this.url = "";
|
||||
this.wsURL = "";
|
||||
|
||||
/**
|
||||
* @type {RTCPeerConnection}
|
||||
@@ -87,37 +98,38 @@ class VideoRTC extends HTMLElement {
|
||||
/**
|
||||
* @type {number}
|
||||
*/
|
||||
this.pcState = WebSocket.CLOSED;
|
||||
|
||||
this.pcConfig = {iceServers: [{urls: "stun:stun.l.google.com:19302"}]};
|
||||
this.connectTS = 0;
|
||||
|
||||
/**
|
||||
* Internal disconnect TimeoutID.
|
||||
* @type {string}
|
||||
*/
|
||||
this.mseCodecs = "";
|
||||
|
||||
/**
|
||||
* [internal] Disconnect TimeoutID.
|
||||
* @type {number}
|
||||
*/
|
||||
this.disconnectTimeout = 0;
|
||||
this.disconnectTID = 0;
|
||||
|
||||
/**
|
||||
* Internal reconnect TimeoutID.
|
||||
* [internal] Reconnect TimeoutID.
|
||||
* @type {number}
|
||||
*/
|
||||
this.reconnectTimeout = 0;
|
||||
this.reconnectTID = 0;
|
||||
|
||||
/**
|
||||
* Handler for receiving Binary from WebSocket
|
||||
* [internal] Handler for receiving Binary from WebSocket.
|
||||
* @type {Function}
|
||||
*/
|
||||
this.ondata = null;
|
||||
|
||||
/**
|
||||
* Handlers list for receiving JSON from WebSocket
|
||||
* [internal] Handlers list for receiving JSON from WebSocket
|
||||
* @type {Object.<string,Function>}}
|
||||
*/
|
||||
this.onmessage = null;
|
||||
}
|
||||
|
||||
/** public properties **/
|
||||
|
||||
/**
|
||||
* Set video source (WebSocket URL). Support relative path.
|
||||
* @param {string|URL} value
|
||||
@@ -130,9 +142,9 @@ class VideoRTC extends HTMLElement {
|
||||
value = "ws" + location.origin.substring(4) + value;
|
||||
}
|
||||
|
||||
this.url = value;
|
||||
this.wsURL = value;
|
||||
|
||||
if (this.isConnected) this.connectedCallback();
|
||||
this.onconnect();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -156,10 +168,6 @@ class VideoRTC extends HTMLElement {
|
||||
if (this.ws) this.ws.send(JSON.stringify(value));
|
||||
}
|
||||
|
||||
get closed() {
|
||||
return this.wsState === WebSocket.CLOSED && this.pcState === WebSocket.CLOSED;
|
||||
}
|
||||
|
||||
codecs(type) {
|
||||
const test = type === "mse"
|
||||
? codec => MediaSource.isTypeSupported(`video/mp4; codecs="${codec}"`)
|
||||
@@ -172,11 +180,9 @@ class VideoRTC extends HTMLElement {
|
||||
* document-connected element.
|
||||
*/
|
||||
connectedCallback() {
|
||||
console.debug("VideoRTC.connectedCallback", this.wsState, this.pcState);
|
||||
|
||||
if (this.disconnectTimeout) {
|
||||
clearTimeout(this.disconnectTimeout);
|
||||
this.disconnectTimeout = 0;
|
||||
if (this.disconnectTID) {
|
||||
clearTimeout(this.disconnectTID);
|
||||
this.disconnectTID = 0;
|
||||
}
|
||||
|
||||
// because video autopause on disconnected from DOM
|
||||
@@ -186,15 +192,11 @@ class VideoRTC extends HTMLElement {
|
||||
this.video.currentTime = seek.end(seek.length - 1);
|
||||
this.play();
|
||||
}
|
||||
} else {
|
||||
this.oninit();
|
||||
}
|
||||
|
||||
if (!this.url || !this.closed) return;
|
||||
|
||||
// CLOSED => CONNECTING
|
||||
this.wsState = WebSocket.CONNECTING;
|
||||
|
||||
this.internalInit();
|
||||
this.internalWS();
|
||||
this.onconnect();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -202,50 +204,36 @@ class VideoRTC extends HTMLElement {
|
||||
* document's DOM.
|
||||
*/
|
||||
disconnectedCallback() {
|
||||
console.debug("VideoRTC.disconnectedCallback", this.wsState, this.pcState);
|
||||
if (this.background || this.disconnectTID) return;
|
||||
if (this.wsState === WebSocket.CLOSED && this.pcState === WebSocket.CLOSED) return;
|
||||
|
||||
if (this.background || this.disconnectTimeout || this.closed) return;
|
||||
|
||||
this.disconnectTimeout = setTimeout(() => {
|
||||
if (this.reconnectTimeout) {
|
||||
clearTimeout(this.reconnectTimeout);
|
||||
this.reconnectTimeout = 0;
|
||||
this.disconnectTID = setTimeout(() => {
|
||||
if (this.reconnectTID) {
|
||||
clearTimeout(this.reconnectTID);
|
||||
this.reconnectTID = 0;
|
||||
}
|
||||
|
||||
this.disconnectTimeout = 0;
|
||||
this.disconnectTID = 0;
|
||||
|
||||
this.wsState = WebSocket.CLOSED;
|
||||
if (this.ws) {
|
||||
this.ws.close();
|
||||
this.ws = null;
|
||||
}
|
||||
|
||||
this.pcState = WebSocket.CLOSED;
|
||||
if (this.pc) {
|
||||
this.pc.close();
|
||||
this.pc = null;
|
||||
}
|
||||
this.ondisconnect();
|
||||
}, this.DISCONNECT_TIMEOUT);
|
||||
}
|
||||
|
||||
internalInit() {
|
||||
if (this.childElementCount) return;
|
||||
|
||||
/**
|
||||
* Creates child DOM elements. Called automatically once on `connectedCallback`.
|
||||
*/
|
||||
oninit() {
|
||||
this.video = document.createElement("video");
|
||||
this.video.controls = true;
|
||||
this.video.playsInline = true;
|
||||
this.video.preload = "auto";
|
||||
|
||||
this.appendChild(this.video);
|
||||
|
||||
// important for second video for mode MP4
|
||||
this.style.display = "block";
|
||||
this.style.position = "relative";
|
||||
|
||||
this.video.style.display = "block"; // fix bottom margin 4px
|
||||
this.video.style.width = "100%";
|
||||
this.video.style.height = "100%"
|
||||
|
||||
this.appendChild(this.video);
|
||||
|
||||
if (this.background) return;
|
||||
|
||||
if ("hidden" in document && this.visibilityCheck) {
|
||||
@@ -258,7 +246,7 @@ class VideoRTC extends HTMLElement {
|
||||
})
|
||||
}
|
||||
|
||||
if ("IntersectionObserver" in window && this.intersectionThreshold) {
|
||||
if ("IntersectionObserver" in window && this.visibilityThreshold) {
|
||||
const observer = new IntersectionObserver(entries => {
|
||||
entries.forEach(entry => {
|
||||
if (!entry.isIntersecting) {
|
||||
@@ -267,92 +255,120 @@ class VideoRTC extends HTMLElement {
|
||||
this.connectedCallback();
|
||||
}
|
||||
});
|
||||
}, {threshold: this.intersectionThreshold});
|
||||
}, {threshold: this.visibilityThreshold});
|
||||
observer.observe(this);
|
||||
}
|
||||
}
|
||||
|
||||
internalWS() {
|
||||
if (this.wsState !== WebSocket.CONNECTING) return;
|
||||
if (this.ws) throw "connect with non null WebSocket";
|
||||
/**
|
||||
* Connect to WebSocket. Called automatically on `connectedCallback`.
|
||||
* @return {boolean} true if the connection has started.
|
||||
*/
|
||||
onconnect() {
|
||||
if (!this.isConnected || !this.wsURL || this.ws || this.pc) return false;
|
||||
|
||||
const ts = Date.now();
|
||||
// CLOSED or CONNECTING => CONNECTING
|
||||
this.wsState = WebSocket.CONNECTING;
|
||||
|
||||
this.ws = new WebSocket(this.url);
|
||||
this.connectTS = Date.now();
|
||||
|
||||
this.ws = new WebSocket(this.wsURL);
|
||||
this.ws.binaryType = "arraybuffer";
|
||||
this.ws.addEventListener("open", ev => this.onopen(ev));
|
||||
this.ws.addEventListener("close", ev => this.onclose(ev));
|
||||
|
||||
this.ws.addEventListener("open", () => {
|
||||
console.debug("VideoRTC.ws.open", this.wsState);
|
||||
|
||||
// CONNECTING => OPEN
|
||||
this.wsState = WebSocket.OPEN;
|
||||
|
||||
this.ws.addEventListener("message", ev => {
|
||||
if (typeof ev.data === "string") {
|
||||
const msg = JSON.parse(ev.data);
|
||||
for (const mode in this.onmessage) {
|
||||
this.onmessage[mode](msg);
|
||||
}
|
||||
} else {
|
||||
this.ondata(ev.data);
|
||||
}
|
||||
});
|
||||
|
||||
this.ondata = null;
|
||||
this.onmessage = {};
|
||||
|
||||
let firstMode = "";
|
||||
|
||||
if (this.mode.indexOf("mse") >= 0 && "MediaSource" in window) { // iPhone
|
||||
firstMode ||= "mse";
|
||||
this.internalMSE();
|
||||
} else if (this.mode.indexOf("mp4") >= 0) {
|
||||
firstMode ||= "mp4";
|
||||
this.internalMP4();
|
||||
}
|
||||
|
||||
if (this.mode.indexOf("webrtc") >= 0 && "RTCPeerConnection" in window) { // macOS Desktop app
|
||||
firstMode ||= "webrtc";
|
||||
this.internalRTC();
|
||||
}
|
||||
|
||||
if (this.mode.indexOf("mjpeg") >= 0) {
|
||||
if (firstMode) {
|
||||
this.onmessage["mjpeg"] = msg => {
|
||||
if (msg.type !== "error" || msg.value.indexOf(firstMode) !== 0) return;
|
||||
this.internalMJPEG();
|
||||
}
|
||||
} else {
|
||||
this.internalMJPEG();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
this.ws.addEventListener("close", () => {
|
||||
console.debug("VideoRTC.ws.close", this.wsState);
|
||||
|
||||
if (this.wsState === WebSocket.CLOSED) return;
|
||||
|
||||
// CONNECTING, OPEN => CONNECTING
|
||||
this.wsState = WebSocket.CONNECTING;
|
||||
this.ws = null;
|
||||
|
||||
// reconnect no more than once every X seconds
|
||||
const delay = Math.max(this.RECONNECT_TIMEOUT - (Date.now() - ts), 0);
|
||||
|
||||
this.reconnectTimeout = setTimeout(() => {
|
||||
this.reconnectTimeout = 0;
|
||||
this.internalWS();
|
||||
}, delay);
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
internalMSE() {
|
||||
console.debug("VideoRTC.internalMSE");
|
||||
ondisconnect() {
|
||||
this.wsState = WebSocket.CLOSED;
|
||||
if (this.ws) {
|
||||
this.ws.close();
|
||||
this.ws = null;
|
||||
}
|
||||
|
||||
this.pcState = WebSocket.CLOSED;
|
||||
if (this.pc) {
|
||||
this.pc.close();
|
||||
this.pc = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {Array.<string>} of modes (mse, webrtc, etc.)
|
||||
*/
|
||||
onopen() {
|
||||
// CONNECTING => OPEN
|
||||
this.wsState = WebSocket.OPEN;
|
||||
|
||||
this.ws.addEventListener("message", ev => {
|
||||
if (typeof ev.data === "string") {
|
||||
const msg = JSON.parse(ev.data);
|
||||
for (const mode in this.onmessage) {
|
||||
this.onmessage[mode](msg);
|
||||
}
|
||||
} else {
|
||||
this.ondata(ev.data);
|
||||
}
|
||||
});
|
||||
|
||||
this.ondata = null;
|
||||
this.onmessage = {};
|
||||
|
||||
const modes = [];
|
||||
|
||||
if (this.mode.indexOf("mse") >= 0 && "MediaSource" in window) { // iPhone
|
||||
modes.push("mse");
|
||||
this.onmse();
|
||||
} else if (this.mode.indexOf("mp4") >= 0) {
|
||||
modes.push("mp4");
|
||||
this.onmp4();
|
||||
}
|
||||
|
||||
if (this.mode.indexOf("webrtc") >= 0 && "RTCPeerConnection" in window) { // macOS Desktop app
|
||||
modes.push("webrtc");
|
||||
this.onwebrtc();
|
||||
}
|
||||
|
||||
if (this.mode.indexOf("mjpeg") >= 0) {
|
||||
if (modes.length) {
|
||||
this.onmessage["mjpeg"] = msg => {
|
||||
if (msg.type !== "error" || msg.value.indexOf(modes[0]) !== 0) return;
|
||||
this.onmjpeg();
|
||||
}
|
||||
} else {
|
||||
modes.push("mjpeg");
|
||||
this.onmjpeg();
|
||||
}
|
||||
}
|
||||
|
||||
return modes;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {boolean} true if reconnection has started.
|
||||
*/
|
||||
onclose() {
|
||||
if (this.wsState === WebSocket.CLOSED) return false;
|
||||
|
||||
// CONNECTING, OPEN => CONNECTING
|
||||
this.wsState = WebSocket.CONNECTING;
|
||||
this.ws = null;
|
||||
|
||||
// reconnect no more than once every X seconds
|
||||
const delay = Math.max(this.RECONNECT_TIMEOUT - (Date.now() - this.connectTS), 0);
|
||||
|
||||
this.reconnectTID = setTimeout(() => {
|
||||
this.reconnectTID = 0;
|
||||
this.onconnect();
|
||||
}, delay);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
onmse() {
|
||||
const ms = new MediaSource();
|
||||
ms.addEventListener("sourceopen", () => {
|
||||
console.debug("VideoRTC.ms.sourceopen");
|
||||
URL.revokeObjectURL(this.video.src);
|
||||
this.send({type: "mse", value: this.codecs("mse")});
|
||||
}, {once: true});
|
||||
@@ -361,28 +377,34 @@ class VideoRTC extends HTMLElement {
|
||||
this.video.srcObject = null;
|
||||
this.play();
|
||||
|
||||
this.mseCodecs = "";
|
||||
|
||||
this.onmessage["mse"] = msg => {
|
||||
if (msg.type !== "mse") return;
|
||||
|
||||
this.mseCodecs = msg.value;
|
||||
|
||||
const sb = ms.addSourceBuffer(msg.value);
|
||||
sb.mode = "segments"; // segments or sequence
|
||||
sb.addEventListener("updateend", () => {
|
||||
if (sb.updating) return;
|
||||
if (bufLen > 0) {
|
||||
try {
|
||||
sb.appendBuffer(buf.slice(0, bufLen));
|
||||
} catch (e) {
|
||||
console.debug(e);
|
||||
|
||||
try {
|
||||
if (bufLen > 0) {
|
||||
const data = buf.slice(0, bufLen);
|
||||
bufLen = 0;
|
||||
sb.appendBuffer(data);
|
||||
} else if (sb.buffered && sb.buffered.length) {
|
||||
const end = sb.buffered.end(sb.buffered.length - 1) - 5;
|
||||
const start = sb.buffered.start(0);
|
||||
if (end > start) {
|
||||
sb.remove(start, end);
|
||||
ms.setLiveSeekableRange(end, end + 5);
|
||||
}
|
||||
// console.debug("VideoRTC.buffered", start, end);
|
||||
}
|
||||
bufLen = 0;
|
||||
} else if (sb.buffered.length) {
|
||||
const end = sb.buffered.end(sb.buffered.length - 1) - 5;
|
||||
const start = sb.buffered.start(0);
|
||||
if (end > start) {
|
||||
sb.remove(start, end);
|
||||
ms.setLiveSeekableRange(end, end + 5);
|
||||
}
|
||||
// console.debug("VideoRTC.buffered", start, end);
|
||||
} catch (e) {
|
||||
// console.debug(e);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -406,55 +428,12 @@ class VideoRTC extends HTMLElement {
|
||||
}
|
||||
}
|
||||
|
||||
internalRTC() {
|
||||
console.debug("VideoRTC.internalRTC");
|
||||
|
||||
onwebrtc() {
|
||||
const pc = new RTCPeerConnection(this.pcConfig);
|
||||
|
||||
let mseCodecs = "";
|
||||
|
||||
/** @type {HTMLVideoElement} */
|
||||
const video2 = document.createElement("video");
|
||||
video2.addEventListener("loadeddata", () => {
|
||||
console.debug("VideoRTC.video.loadeddata", video2.readyState, pc.connectionState);
|
||||
|
||||
if (pc.connectionState === "connected" || pc.connectionState === "connecting") {
|
||||
// Video+Audio > Video, H265 > H264, Video > Audio, WebRTC > MSE
|
||||
let rtcPriority = 0, msePriority = 0;
|
||||
|
||||
/** @type {MediaStream} */
|
||||
const rtc = video2.srcObject;
|
||||
if (rtc.getVideoTracks().length > 0) rtcPriority += 0x220;
|
||||
if (rtc.getAudioTracks().length > 0) rtcPriority += 0x102;
|
||||
|
||||
if (mseCodecs.indexOf("hvc1.") >= 0) msePriority += 0x230;
|
||||
if (mseCodecs.indexOf("avc1.") >= 0) msePriority += 0x210;
|
||||
if (mseCodecs.indexOf("mp4a.") >= 0) msePriority += 0x101;
|
||||
|
||||
if (rtcPriority >= msePriority) {
|
||||
console.debug("VideoRTC.select RTC mode", rtcPriority, msePriority);
|
||||
|
||||
this.video.controls = true;
|
||||
this.video.srcObject = rtc;
|
||||
this.play();
|
||||
|
||||
this.pcState = WebSocket.OPEN;
|
||||
|
||||
this.wsState = WebSocket.CLOSED;
|
||||
this.ws.close();
|
||||
this.ws = null;
|
||||
} else {
|
||||
console.debug("VideoRTC.select MSE mode", rtcPriority, msePriority);
|
||||
|
||||
pc.close();
|
||||
|
||||
this.pcState = WebSocket.CLOSED;
|
||||
this.pc = null;
|
||||
}
|
||||
}
|
||||
|
||||
video2.srcObject = null;
|
||||
}, {once: true});
|
||||
video2.addEventListener("loadeddata", ev => this.onpcvideo(ev), {once: true});
|
||||
|
||||
pc.addEventListener("icecandidate", ev => {
|
||||
const candidate = ev.candidate ? ev.candidate.toJSON().candidate : "";
|
||||
@@ -462,8 +441,6 @@ class VideoRTC extends HTMLElement {
|
||||
});
|
||||
|
||||
pc.addEventListener("track", ev => {
|
||||
console.debug("VideoRTC.pc.track", ev.streams.length);
|
||||
|
||||
// when stream already init
|
||||
if (video2.srcObject !== null) return;
|
||||
|
||||
@@ -477,30 +454,29 @@ class VideoRTC extends HTMLElement {
|
||||
});
|
||||
|
||||
pc.addEventListener("connectionstatechange", () => {
|
||||
console.debug("VideoRTC.pc.connectionstatechange", this.pc.connectionState);
|
||||
|
||||
if (pc.connectionState === "failed" || pc.connectionState === "disconnected") {
|
||||
pc.close(); // stop next events
|
||||
|
||||
this.pcState = WebSocket.CLOSED;
|
||||
this.pc = null;
|
||||
|
||||
if (this.wsState === WebSocket.CLOSED && this.isConnected) {
|
||||
this.connectedCallback();
|
||||
}
|
||||
this.onconnect();
|
||||
}
|
||||
});
|
||||
|
||||
this.onmessage["webrtc"] = msg => {
|
||||
switch (msg.type) {
|
||||
case "webrtc/candidate":
|
||||
pc.addIceCandidate({candidate: msg.value, sdpMid: ""}).catch(() => console.debug);
|
||||
pc.addIceCandidate({
|
||||
candidate: msg.value,
|
||||
sdpMid: "0"
|
||||
}).catch(() => console.debug);
|
||||
break;
|
||||
case "webrtc/answer":
|
||||
pc.setRemoteDescription({type: "answer", sdp: msg.value}).catch(() => console.debug);
|
||||
break;
|
||||
case "mse":
|
||||
mseCodecs = msg.value;
|
||||
pc.setRemoteDescription({
|
||||
type: "answer",
|
||||
sdp: msg.value
|
||||
}).catch(() => console.debug);
|
||||
break;
|
||||
case "error":
|
||||
if (msg.value.indexOf("webrtc/offer") < 0) return;
|
||||
@@ -522,52 +498,87 @@ class VideoRTC extends HTMLElement {
|
||||
this.pc = pc;
|
||||
}
|
||||
|
||||
internalMJPEG() {
|
||||
console.debug("VideoRTC.internalMJPEG");
|
||||
/**
|
||||
* @param ev {Event}
|
||||
*/
|
||||
onpcvideo(ev) {
|
||||
if (!this.pc) return;
|
||||
|
||||
/** @type {HTMLVideoElement} */
|
||||
const video2 = ev.target;
|
||||
const state = this.pc.connectionState;
|
||||
|
||||
// Firefox doesn't support pc.connectionState
|
||||
if (state === "connected" || state === "connecting" || !state) {
|
||||
// Video+Audio > Video, H265 > H264, Video > Audio, WebRTC > MSE
|
||||
let rtcPriority = 0, msePriority = 0;
|
||||
|
||||
/** @type {MediaStream} */
|
||||
const ms = video2.srcObject;
|
||||
if (ms.getVideoTracks().length > 0) rtcPriority += 0x220;
|
||||
if (ms.getAudioTracks().length > 0) rtcPriority += 0x102;
|
||||
|
||||
if (this.mseCodecs.indexOf("hvc1.") >= 0) msePriority += 0x230;
|
||||
if (this.mseCodecs.indexOf("avc1.") >= 0) msePriority += 0x210;
|
||||
if (this.mseCodecs.indexOf("mp4a.") >= 0) msePriority += 0x101;
|
||||
|
||||
if (rtcPriority >= msePriority) {
|
||||
this.video.srcObject = ms;
|
||||
this.play();
|
||||
|
||||
this.pcState = WebSocket.OPEN;
|
||||
|
||||
this.wsState = WebSocket.CLOSED;
|
||||
this.ws.close();
|
||||
this.ws = null;
|
||||
} else {
|
||||
this.pcState = WebSocket.CLOSED;
|
||||
this.pc.close();
|
||||
this.pc = null;
|
||||
}
|
||||
}
|
||||
|
||||
video2.srcObject = null;
|
||||
}
|
||||
|
||||
onmjpeg() {
|
||||
this.ondata = data => {
|
||||
this.video.controls = false;
|
||||
this.video.poster = "data:image/jpeg;base64," + VideoRTC.btoa(data);
|
||||
};
|
||||
|
||||
this.send({type: "mjpeg"});
|
||||
this.video.controls = false;
|
||||
}
|
||||
|
||||
internalMP4() {
|
||||
console.debug("VideoRTC.internalMP4");
|
||||
onmp4() {
|
||||
/** @type {HTMLCanvasElement} **/
|
||||
const canvas = document.createElement("canvas");
|
||||
/** @type {CanvasRenderingContext2D} */
|
||||
let context;
|
||||
|
||||
/** @type {HTMLVideoElement} */
|
||||
let video2;
|
||||
const video2 = document.createElement("video");
|
||||
video2.autoplay = true;
|
||||
video2.muted = true;
|
||||
|
||||
this.ondata = data => {
|
||||
// first video with default position (set container size)
|
||||
// second video with position=absolute and top=0px
|
||||
if (video2) {
|
||||
this.removeChild(this.video);
|
||||
this.video.src = "";
|
||||
this.video = video2;
|
||||
video2.style.position = "";
|
||||
video2.style.top = "";
|
||||
video2.addEventListener("loadeddata", ev => {
|
||||
if (!context) {
|
||||
canvas.width = video2.videoWidth;
|
||||
canvas.height = video2.videoHeight;
|
||||
context = canvas.getContext('2d');
|
||||
}
|
||||
|
||||
video2 = this.video.cloneNode();
|
||||
video2.style.position = "absolute";
|
||||
video2.style.top = "0px";
|
||||
this.appendChild(video2);
|
||||
context.drawImage(video2, 0, 0, canvas.width, canvas.height);
|
||||
|
||||
video2.src = "data:video/mp4;base64," + VideoRTC.btoa(data);
|
||||
video2.play().catch(() => console.log);
|
||||
};
|
||||
|
||||
this.ws.addEventListener("close", () => {
|
||||
if (!video2) return;
|
||||
|
||||
this.removeChild(video2);
|
||||
video2.src = "";
|
||||
this.video.controls = false;
|
||||
this.video.poster = canvas.toDataURL("image/jpeg");
|
||||
});
|
||||
|
||||
this.ondata = data => {
|
||||
video2.src = "data:video/mp4;base64," + VideoRTC.btoa(data);
|
||||
};
|
||||
|
||||
this.send({type: "mp4", value: this.codecs("mp4")});
|
||||
this.video.controls = false;
|
||||
}
|
||||
|
||||
static btoa(buffer) {
|
||||
@@ -580,5 +591,3 @@ class VideoRTC extends HTMLElement {
|
||||
return window.btoa(binary);
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define("video-rtc", VideoRTC);
|
||||
|
98
www/video-stream.js
Normal file
98
www/video-stream.js
Normal file
@@ -0,0 +1,98 @@
|
||||
import {VideoRTC} from "./video-rtc.js";
|
||||
|
||||
class VideoStream extends VideoRTC {
|
||||
set divMode(value) {
|
||||
this.querySelector(".mode").innerText = value;
|
||||
this.querySelector(".status").innerText = "";
|
||||
}
|
||||
|
||||
set divError(value) {
|
||||
const state = this.querySelector(".mode").innerText;
|
||||
if (state !== "loading") return;
|
||||
this.querySelector(".mode").innerText = "error";
|
||||
this.querySelector(".status").innerText = value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom GUI
|
||||
*/
|
||||
oninit() {
|
||||
console.debug("stream.oninit");
|
||||
super.oninit();
|
||||
|
||||
this.innerHTML = `
|
||||
<style>
|
||||
video-stream {
|
||||
position: relative;
|
||||
}
|
||||
.info {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
padding: 12px;
|
||||
color: white;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
pointer-events: none;
|
||||
}
|
||||
</style>
|
||||
<div class="info">
|
||||
<div class="status"></div>
|
||||
<div class="mode"></div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const info = this.querySelector(".info")
|
||||
this.insertBefore(this.video, info);
|
||||
}
|
||||
|
||||
onconnect() {
|
||||
console.debug("stream.onconnect");
|
||||
const result = super.onconnect();
|
||||
if (result) this.divMode = "loading";
|
||||
return result;
|
||||
}
|
||||
|
||||
ondisconnect() {
|
||||
console.debug("stream.ondisconnect");
|
||||
super.ondisconnect();
|
||||
}
|
||||
|
||||
onopen() {
|
||||
console.debug("stream.onopen");
|
||||
const result = super.onopen();
|
||||
|
||||
this.onmessage["stream"] = msg => {
|
||||
console.debug("stream.onmessge", msg);
|
||||
switch (msg.type) {
|
||||
case "error":
|
||||
this.divError = msg.value;
|
||||
break;
|
||||
case "mse":
|
||||
case "mp4":
|
||||
case "mjpeg":
|
||||
this.divMode = msg.type.toUpperCase();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
onclose() {
|
||||
console.debug("stream.onclose");
|
||||
return super.onclose();
|
||||
}
|
||||
|
||||
onpcvideo(ev) {
|
||||
console.debug("stream.onpcvideo");
|
||||
super.onpcvideo(ev);
|
||||
|
||||
if (this.pcState !== WebSocket.CLOSED) {
|
||||
this.divMode = "RTC";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define("video-stream", VideoStream);
|
@@ -48,7 +48,7 @@
|
||||
console.debug('ws.onmessage', msg);
|
||||
|
||||
if (msg.type === 'webrtc/candidate') {
|
||||
pc.addIceCandidate({candidate: msg.value, sdpMid: ''});
|
||||
pc.addIceCandidate({candidate: msg.value, sdpMid: '0'});
|
||||
} else if (msg.type === 'webrtc/answer') {
|
||||
pc.setRemoteDescription({type: 'answer', sdp: msg.value});
|
||||
}
|
||||
|
Reference in New Issue
Block a user