Add go2rtc and add restream role / live source (#4082)

* Pull go2rtc dependency

* Add go2rtc to local services and add to s6

* Add relay controller for go2rtc

* Add restream role

* Add restream role

* Add restream to nginx

* Add camera live source config

* Disable RTMP by default and use restream

* Use go2rtc for camera config

* Fix go2rtc move

* Start restream on frigate start

* Send restream to camera level

* Fix restream

* Make sure jsmpeg works as expected

* Make view rspect live size config

* Tweak player options to fit live view

* Adjust VideoPlayer to accept live option which disables irrelevant controls

* Add multiple options from restream live view

* Add base for webrtc option

* Setup specific restream modules

* Make mp4 the default streaming for now

* Expose 8554 for rtsp relay from go2rtc

* Formatting

* Update docs to suggest new restream method.

* Update docs to reflect restream role

* Update docs to reflect restream role

* Add webrtc player

* Improvements to webRTC

* Support webrtc

* Cleanup

* Adjust rtmp test and add restream test

* Fix tests

* Add restream tests

* Add live view docs and show different options

* Small docs tweak

* Support all stream types

* Update to beta 9 of go2rtc

* Formatting

* Make jsmpeg the default

* Support wss if made from https

* Support wss if made from https

* Use onEffect

* Set url outside onEffect

* Fix passed deps

* Update docs about required host mode

* Try memo instead

* Close websocket on changing camera

* Formatting

* Close pc connection

* Set video source to null on cleanup

* Use full path since go2rtc can't see PATH var

* Adjust audio codec to enable browser audio by default

* Cleanup stream creation

* Add restream tests

* Format tests

* Mock requests

* Adjust paths

* Move stream configs to restream

* Remove live source

* Remove live config

* Use live persistence for which view to use on each camera

* Fix live sizes

* Only use jsmpeg sizes for jsmpeg live

* Set max live size

* Remove access of live config

* Add selector for live view source in web view

* Remove RTMP from default list of roles

* Update docs

* Fix tests

* Fix docs for live view modes

* make default undefined to avoid race condition

* Wait until camera source is loaded to avoid race condition

* Fix tests

* Add config to go2rtc

* Work with config

* Set full path for config

* Set to use stun

* Check for mounted file

* Look for frigate-go2rtc

* Update docs to reflect webRTC configuration.

* Add link to go2rtc config

* Update docs to be more clear

* Update docs to be more clear

* Update format

Co-authored-by: Felipe Santos <felipecassiors@gmail.com>

* Update live docs

* Improve bash startup script

* Add option to force audio compatibility

* Formatting

* Fix mapping

* Fix broken link

* Update go2rtc version

* Get go2rtc webui working

* Add support for mse

* Remove mp4 option

* Undo changes to video player

* Update docs for new live view options

* Make separate path for mse

* Remove unused

* Remove mp4 path

* Try to get go2rtc proxy working

* Try to get go2rtc proxy working

* Remove unused callback

* Allow websocket on restrea dashboard

* Make mse default stream option

* Fix mse sizing

* don't assume roles is defined

* Remove nginx mapping to go2rtc ui

Co-authored-by: Felipe Santos <felipecassiors@gmail.com>
Co-authored-by: Blake Blackshear <blakeb@blakeshome.com>
This commit is contained in:
Nicolas Mowen 2022-11-02 05:36:09 -06:00 committed by GitHub
parent b4d4adb75b
commit d8123d2497
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
26 changed files with 614 additions and 86 deletions

View File

@ -28,6 +28,7 @@ services:
- "5000:5000"
- "5001:5001"
- "8080:8080"
- "8554:8554"
entrypoint: ["sudo", "/init"]
command: /bin/sh -c "while sleep 1000; do :; done"
mqtt:

View File

@ -117,6 +117,12 @@ RUN apt-get -qq update \
ENV PATH=$PATH:/usr/lib/btbn-ffmpeg/bin
# install go2rtc
RUN wget -O go2rtc "https://github.com/AlexxIT/go2rtc/releases/download/v0.1-beta.10/go2rtc_linux_${TARGETARCH}" \
&& chmod +x go2rtc \
&& mkdir -p /usr/local/go2rtc/sbin/ \
&& mv go2rtc /usr/local/go2rtc/sbin/go2rtc
COPY --from=nginx /usr/local/nginx/ /usr/local/nginx/
# get model and labels
@ -142,6 +148,8 @@ RUN S6_ARCH="${TARGETARCH}" \
EXPOSE 5000
EXPOSE 1935
EXPOSE 8554
EXPOSE 8555
ENTRYPOINT ["/init"]

View File

@ -0,0 +1,5 @@
#!/usr/bin/execlineb -S1
if { s6-test ${1} -ne 0 }
if { s6-test ${1} -ne 256 }
s6-svscanctl -t /var/run/s6/services

View File

@ -0,0 +1,12 @@
#!/bin/bash
# https://gist.github.com/mohanpedala/1e2ff5661761d3abd0385e8223e16425?permalink_comment_id=3945021
set -euo pipefail
if [[ -f "/config/frigate-go2rtc.yaml" ]]; then
CONFIG_PATH=/config/frigate-go2rtc.yaml
else
CONFIG_PATH=/usr/local/go2rtc/sbin/go2rtc.yaml
fi
exec /usr/local/go2rtc/sbin/go2rtc -config="$CONFIG_PATH"

View File

@ -0,0 +1,4 @@
webrtc:
listen: ":8555"
candidates:
- stun:8555

View File

@ -44,6 +44,11 @@ http {
keepalive 1024;
}
upstream go2rtc {
server 127.0.0.1:1984;
keepalive 1024;
}
server {
listen 5000;
@ -165,7 +170,7 @@ http {
proxy_set_header Host $host;
}
location /live/ {
location /live/jsmpeg/ {
proxy_pass http://jsmpeg/;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
@ -173,6 +178,22 @@ http {
proxy_set_header Host $host;
}
location /live/mse/ {
proxy_pass http://go2rtc/;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "Upgrade";
proxy_set_header Host $host;
}
location /live/webrtc/ {
proxy_pass http://go2rtc/;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "Upgrade";
proxy_set_header Host $host;
}
location ~* /api/.*\.(jpg|jpeg|png)$ {
add_header 'Access-Control-Allow-Origin' '*';
add_header 'Access-Control-Allow-Methods' 'GET, POST, PUT, DELETE, OPTIONS';

View File

@ -9,11 +9,12 @@ Several inputs can be configured for each camera and the role of each input can
Each role can only be assigned to one input per camera. The options for roles are as follows:
| Role | Description |
| -------- | ----------------------------------------------------------------------------------------------- |
| `detect` | Main feed for object detection |
| `record` | Saves segments of the video feed based on configuration settings. [docs](/configuration/record) |
| `rtmp` | Broadcast as an RTMP feed for other services to consume. [docs](/configuration/rtmp) |
| Role | Description |
| ---------- | ---------------------------------------------------------------------------------------------------- |
| `detect` | Main feed for object detection |
| `record` | Saves segments of the video feed based on configuration settings. [docs](/configuration/record) |
| `restream` | Broadcast as RTSP feed and use the full res stream for live view. [docs](/configuration/restream) |
| `rtmp` | Deprecated: Broadcast as an RTMP feed for other services to consume. [docs](/configuration/restream) |
```yaml
mqtt:

View File

@ -19,7 +19,7 @@ cameras:
- path: rtsp://viewer:{FRIGATE_RTSP_PASSWORD}@10.0.10.10:554/cam/realmonitor?channel=1&subtype=2
roles:
- detect
- rtmp
- restream
detect:
width: 1280
height: 720
@ -336,21 +336,28 @@ snapshots:
person: 15
# Optional: RTMP configuration
# NOTE: RTMP is deprecated in favor of restream
# NOTE: Can be overridden at the camera level
rtmp:
# Optional: Enable the RTMP stream (default: True)
enabled: True
# Optional: Enable the RTMP stream (default: False)
enabled: False
# Optional: Live stream configuration for WebUI
# Optional: Restream configuration
# NOTE: Can be overridden at the camera level
live:
# Optional: Set the height of the live stream. (default: 720)
# This must be less than or equal to the height of the detect stream. Lower resolutions
# reduce bandwidth required for viewing the live stream. Width is computed to match known aspect ratio.
height: 720
# Optional: Set the encode quality of the live stream (default: shown below)
# 1 is the highest quality, and 31 is the lowest. Lower quality feeds utilize less CPU resources.
quality: 8
restream:
# Optional: Enable the restream (default: True)
enabled: True
# Optional: Force audio compatibility with browsers (default: shown below)
force_audio: False
# Optional: jsmpeg stream configuration for WebUI
jsmpeg:
# Optional: Set the height of the jsmpeg stream. (default: 720)
# This must be less than or equal to the height of the detect stream. Lower resolutions
# reduce bandwidth required for viewing the jsmpeg stream. Width is computed to match known aspect ratio.
height: 720
# Optional: Set the encode quality of the jsmpeg stream (default: shown below)
# 1 is the highest quality, and 31 is the lowest. Lower quality feeds utilize less CPU resources.
quality: 8
# Optional: in-feed timestamp style configuration
# NOTE: Can be overridden at the camera level
@ -387,11 +394,12 @@ cameras:
# Required: the path to the stream
# NOTE: path may include environment variables, which must begin with 'FRIGATE_' and be referenced in {}
- path: rtsp://viewer:{FRIGATE_RTSP_PASSWORD}@10.0.10.10:554/cam/realmonitor?channel=1&subtype=2
# Required: list of roles for this stream. valid values are: detect,record,rtmp
# NOTICE: In addition to assigning the record, and rtmp roles,
# Required: list of roles for this stream. valid values are: detect,record,restream,rtmp
# NOTICE: In addition to assigning the record, restream, and rtmp roles,
# they must also be enabled in the camera config.
roles:
- detect
- restream
- rtmp
# Optional: stream specific global args (default: inherit)
# global_args:

View File

@ -0,0 +1,41 @@
---
id: live
title: Live View
---
Frigate has different live view options, some of which require [restream](restream.md) to be enabled.
## Live View Options
Live view options can be selected while viewing the live stream. The options are:
| Source | Latency | Frame Rate | Resolution | Audio | Requires Restream | Other Limitations |
| ------ | ------- | -------------------------------------- | -------------- | ---------------------------- | ----------------- | --------------------- |
| jsmpeg | low | same as `detect -> fps`, capped at 10 | same as detect | no | no | none |
| mse | low | native | native | yes (depends on audio codec) | yes | none |
| webrtc | lowest | native | native | yes (depends on audio codec) | yes | requires extra config |
### WebRTC extra configuration:
webRTC works by creating a websocket connection on extra ports. One of the following is required for webRTC to work:
* Frigate is run with `network_mode: host` to support automatic UDP port pass through locally and remotely. See https://github.com/AlexxIT/go2rtc#module-webrtc for more details
* Frigate is run with `network_mode: bridge` and has:
* Router setup to forward port `8555` to port `8555` on the frigate device.
* For local webRTC, you will need to create your own go2rtc config:
```yaml
webrtc:
listen: ":8555"
candidates:
- <frigate host ip address>:8555 # <--- enter frigate host IP here
- stun:8555
```
and pass that config to frigate via docker or `frigate-go2rtc.yaml` for addon users:
See https://github.com/AlexxIT/go2rtc#module-webrtc for more details
```yaml
volumes:
- /path/to/your/go2rtc.yaml:/config/frigate-go2rtc.yaml:ro
```

View File

@ -0,0 +1,12 @@
---
id: restream
title: Restream
---
### RTSP
Frigate can restream your video feed as an RTSP feed for other applications such as Home Assistant to utilize it at `rtsp://<frigate_host>:8554/<camera_name>`. Port 8554 must be open. This allows you to use a video feed for detection in frigate and Home Assistant live view at the same time without having to make two separate connections to the camera. The video feed is copied from the original video feed directly to avoid re-encoding. This feed does not include any annotation by Frigate.
### RTMP (Deprecated)
In previous Frigate versions RTMP was used for re-streaming. RTMP has disadvantages however including being incompatible with H.265, high bitrates, and certain audio codecs. RTMP is deprecated and it is recommended to move to the new restream role.

View File

@ -1,8 +0,0 @@
---
id: rtmp
title: RTMP
---
Frigate can re-stream your video feed as a RTMP feed for other applications such as Home Assistant to utilize it at `rtmp://<frigate_host>/live/<camera_name>`. Port 1935 must be open. This allows you to use a video feed for detection in frigate and Home Assistant live view at the same time without having to make two separate connections to the camera. The video feed is copied from the original video feed directly to avoid re-encoding. This feed does not include any annotation by Frigate.
Some video feeds are not compatible with RTMP. If you are experiencing issues, check to make sure your camera feed is h264 with AAC audio. If your camera doesn't support a compatible format for RTMP, you can use the ffmpeg args to re-encode it on the fly at the expense of increased CPU utilization. Some more information about it can be found [here](/faqs#audio-in-recordings).

View File

@ -17,7 +17,8 @@ module.exports = {
"configuration/record",
"configuration/snapshots",
"configuration/objects",
"configuration/rtmp",
"configuration/restream",
"configuration/live",
"configuration/zones",
"configuration/birdseye",
"configuration/stationary_objects",

View File

@ -25,6 +25,7 @@ from frigate.object_processing import TrackedObjectProcessor
from frigate.output import output_frames
from frigate.plus import PlusApi
from frigate.record import RecordingCleanup, RecordingMaintainer
from frigate.restream import RestreamApi
from frigate.stats import StatsEmitter, stats_init
from frigate.storage import StorageMaintainer
from frigate.version import VERSION
@ -163,6 +164,10 @@ class FrigateApp:
self.plus_api,
)
def init_restream(self) -> None:
self.restream = RestreamApi(self.config)
self.restream.add_cameras()
def init_mqtt(self) -> None:
self.mqtt_client = create_mqtt_client(self.config, self.camera_metrics)
@ -363,6 +368,7 @@ class FrigateApp:
self.start_camera_capture_processes()
self.init_stats()
self.init_web_server()
self.init_restream()
self.start_mqtt_relay()
self.start_event_processor()
self.start_event_cleanup()

View File

@ -403,6 +403,7 @@ class FfmpegConfig(FrigateBaseModel):
class CameraRoleEnum(str, Enum):
record = "record"
restream = "restream"
rtmp = "rtmp"
detect = "detect"
@ -513,12 +514,22 @@ class CameraMqttConfig(FrigateBaseModel):
class RtmpConfig(FrigateBaseModel):
enabled: bool = Field(default=True, title="RTMP restreaming enabled.")
enabled: bool = Field(default=False, title="RTMP restreaming enabled.")
class CameraLiveConfig(FrigateBaseModel):
height: int = Field(default=720, title="Live camera view height")
quality: int = Field(default=8, ge=1, le=31, title="Live camera view quality")
class JsmpegStreamConfig(FrigateBaseModel):
height: int = Field(default=720, title="Live camera view height.")
quality: int = Field(default=8, ge=1, le=31, title="Live camera view quality.")
class RestreamConfig(FrigateBaseModel):
enabled: bool = Field(default=True, title="Restreaming enabled.")
force_audio: bool = Field(
default=False, title="Force audio compatibility with the browser."
)
jsmpeg: JsmpegStreamConfig = Field(
default_factory=JsmpegStreamConfig, title="Jsmpeg Stream Configuration."
)
class CameraUiConfig(FrigateBaseModel):
@ -544,8 +555,8 @@ class CameraConfig(FrigateBaseModel):
rtmp: RtmpConfig = Field(
default_factory=RtmpConfig, title="RTMP restreaming configuration."
)
live: CameraLiveConfig = Field(
default_factory=CameraLiveConfig, title="Live playback settings."
restream: RestreamConfig = Field(
default_factory=RestreamConfig, title="Restreaming configuration."
)
snapshots: SnapshotsConfig = Field(
default_factory=SnapshotsConfig, title="Snapshot configuration."
@ -582,7 +593,16 @@ class CameraConfig(FrigateBaseModel):
# add roles to the input if there is only one
if len(config["ffmpeg"]["inputs"]) == 1:
config["ffmpeg"]["inputs"][0]["roles"] = ["record", "rtmp", "detect"]
has_rtmp = "rtmp" in config["ffmpeg"]["inputs"][0].get("roles", [])
config["ffmpeg"]["inputs"][0]["roles"] = [
"record",
"detect",
"restream",
]
if has_rtmp:
config["ffmpeg"]["inputs"][0]["roles"].append("rtmp")
super().__init__(**config)
@ -763,12 +783,12 @@ class FrigateConfig(FrigateBaseModel):
snapshots: SnapshotsConfig = Field(
default_factory=SnapshotsConfig, title="Global snapshots configuration."
)
live: CameraLiveConfig = Field(
default_factory=CameraLiveConfig, title="Global live configuration."
)
rtmp: RtmpConfig = Field(
default_factory=RtmpConfig, title="Global RTMP restreaming configuration."
)
restream: RestreamConfig = Field(
default_factory=RestreamConfig, title="Global restream configuration."
)
birdseye: BirdseyeConfig = Field(
default_factory=BirdseyeConfig, title="Birdseye configuration."
)
@ -805,8 +825,8 @@ class FrigateConfig(FrigateBaseModel):
"birdseye": ...,
"record": ...,
"snapshots": ...,
"live": ...,
"rtmp": ...,
"restream": ...,
"objects": ...,
"motion": ...,
"detect": ...,
@ -893,6 +913,11 @@ class FrigateConfig(FrigateBaseModel):
f"Camera {name} has rtmp enabled, but rtmp is not assigned to an input."
)
if camera_config.restream.enabled and not "restream" in assigned_roles:
raise ValueError(
f"Camera {name} has restream enabled, but restream is not assigned to an input."
)
# backwards compatibility for retain_days
if not camera_config.record.retain_days is None:
logger.warning(

View File

@ -366,15 +366,15 @@ def output_frames(config: FrigateConfig, video_output_queue):
for camera, cam_config in config.cameras.items():
width = int(
cam_config.live.height
cam_config.restream.jsmpeg.height
* (cam_config.frame_shape[1] / cam_config.frame_shape[0])
)
converters[camera] = FFMpegConverter(
cam_config.frame_shape[1],
cam_config.frame_shape[0],
width,
cam_config.live.height,
cam_config.live.quality,
cam_config.restream.jsmpeg.height,
cam_config.restream.jsmpeg.quality,
)
broadcasters[camera] = BroadcastThread(
camera, converters[camera], websocket_server

44
frigate/restream.py Normal file
View File

@ -0,0 +1,44 @@
"""Controls go2rtc restream."""
import logging
import requests
from frigate.config import FrigateConfig
logger = logging.getLogger(__name__)
def get_manual_go2rtc_stream(camera_url: str) -> str:
"""Get a manual stream for go2rtc."""
return f"exec: /usr/lib/btbn-ffmpeg/bin/ffmpeg -i {camera_url} -c:v copy -c:a libopus -rtsp_transport tcp -f rtsp {{output}}"
class RestreamApi:
"""Control go2rtc relay API."""
def __init__(self, config: FrigateConfig) -> None:
self.config: FrigateConfig = config
def add_cameras(self) -> None:
"""Add cameras to go2rtc."""
self.relays: dict[str, str] = {}
for cam_name, camera in self.config.cameras.items():
if not camera.restream.enabled:
continue
for input in camera.ffmpeg.inputs:
if "restream" in input.roles:
if (
input.path.startswith("rtsp")
and not camera.restream.force_audio
):
self.relays[cam_name] = input.path
else:
# go2rtc only supports rtsp for direct relay, otherwise ffmpeg is used
self.relays[cam_name] = get_manual_go2rtc_stream(input.path)
for name, path in self.relays.items():
params = {"src": path, "name": name}
requests.put("http://127.0.0.1:1984/api/streams", params=params)

View File

@ -575,7 +575,7 @@ class TestConfig(unittest.TestCase):
"inputs": [
{
"path": "rtsp://10.0.0.1:554/video",
"roles": ["detect", "rtmp"],
"roles": ["detect", "rtmp", "restream"],
},
{"path": "rtsp://10.0.0.1:554/record", "roles": ["record"]},
]
@ -837,7 +837,7 @@ class TestConfig(unittest.TestCase):
config = {
"mqtt": {"host": "mqtt"},
"rtmp": {"enabled": False},
"restream": {"enabled": False},
"cameras": {
"back": {
"ffmpeg": {
@ -1050,11 +1050,11 @@ class TestConfig(unittest.TestCase):
assert runtime_config.cameras["back"].snapshots.height == 150
assert runtime_config.cameras["back"].snapshots.enabled
def test_global_rtmp(self):
def test_global_restream(self):
config = {
"mqtt": {"host": "mqtt"},
"rtmp": {"enabled": True},
"restream": {"enabled": True},
"cameras": {
"back": {
"ffmpeg": {
@ -1072,9 +1072,32 @@ class TestConfig(unittest.TestCase):
assert config == frigate_config.dict(exclude_unset=True)
runtime_config = frigate_config.runtime_config
assert runtime_config.cameras["back"].rtmp.enabled
assert runtime_config.cameras["back"].restream.enabled
def test_default_rtmp(self):
def test_global_rtmp_disabled(self):
config = {
"mqtt": {"host": "mqtt"},
"cameras": {
"back": {
"ffmpeg": {
"inputs": [
{
"path": "rtsp://10.0.0.1:554/video",
"roles": ["detect"],
},
]
},
}
},
}
frigate_config = FrigateConfig(**config)
assert config == frigate_config.dict(exclude_unset=True)
runtime_config = frigate_config.runtime_config
assert not runtime_config.cameras["back"].rtmp.enabled
def test_default_not_rtmp(self):
config = {
"mqtt": {"host": "mqtt"},
@ -1095,7 +1118,57 @@ class TestConfig(unittest.TestCase):
assert config == frigate_config.dict(exclude_unset=True)
runtime_config = frigate_config.runtime_config
assert runtime_config.cameras["back"].rtmp.enabled
assert not runtime_config.cameras["back"].rtmp.enabled
def test_default_restream(self):
config = {
"mqtt": {"host": "mqtt"},
"cameras": {
"back": {
"ffmpeg": {
"inputs": [
{
"path": "rtsp://10.0.0.1:554/video",
"roles": ["detect"],
},
]
}
}
},
}
frigate_config = FrigateConfig(**config)
assert config == frigate_config.dict(exclude_unset=True)
runtime_config = frigate_config.runtime_config
assert runtime_config.cameras["back"].restream.enabled
def test_global_restream_merge(self):
config = {
"mqtt": {"host": "mqtt"},
"restream": {"enabled": False},
"cameras": {
"back": {
"ffmpeg": {
"inputs": [
{
"path": "rtsp://10.0.0.1:554/video",
"roles": ["detect"],
},
]
},
"restream": {
"enabled": True,
},
}
},
}
frigate_config = FrigateConfig(**config)
assert config == frigate_config.dict(exclude_unset=True)
runtime_config = frigate_config.runtime_config
assert runtime_config.cameras["back"].restream.enabled
def test_global_rtmp_merge(self):
@ -1108,7 +1181,7 @@ class TestConfig(unittest.TestCase):
"inputs": [
{
"path": "rtsp://10.0.0.1:554/video",
"roles": ["detect"],
"roles": ["detect", "rtmp"],
},
]
},
@ -1128,7 +1201,7 @@ class TestConfig(unittest.TestCase):
config = {
"mqtt": {"host": "mqtt"},
"rtmp": {"enabled": False},
"restream": {"enabled": False},
"cameras": {
"back": {
"ffmpeg": {
@ -1152,11 +1225,11 @@ class TestConfig(unittest.TestCase):
runtime_config = frigate_config.runtime_config
assert not runtime_config.cameras["back"].rtmp.enabled
def test_global_live(self):
def test_global_jsmpeg(self):
config = {
"mqtt": {"host": "mqtt"},
"live": {"quality": 4},
"restream": {"jsmpeg": {"quality": 4}},
"cameras": {
"back": {
"ffmpeg": {
@ -1174,7 +1247,7 @@ class TestConfig(unittest.TestCase):
assert config == frigate_config.dict(exclude_unset=True)
runtime_config = frigate_config.runtime_config
assert runtime_config.cameras["back"].live.quality == 4
assert runtime_config.cameras["back"].restream.jsmpeg.quality == 4
def test_default_live(self):
@ -1197,13 +1270,13 @@ class TestConfig(unittest.TestCase):
assert config == frigate_config.dict(exclude_unset=True)
runtime_config = frigate_config.runtime_config
assert runtime_config.cameras["back"].live.quality == 8
assert runtime_config.cameras["back"].restream.jsmpeg.quality == 8
def test_global_live_merge(self):
config = {
"mqtt": {"host": "mqtt"},
"live": {"quality": 4, "height": 480},
"restream": {"jsmpeg": {"quality": 4, "height": 480}},
"cameras": {
"back": {
"ffmpeg": {
@ -1214,8 +1287,10 @@ class TestConfig(unittest.TestCase):
},
]
},
"live": {
"quality": 7,
"restream": {
"jsmpeg": {
"quality": 7,
}
},
}
},
@ -1224,8 +1299,8 @@ class TestConfig(unittest.TestCase):
assert config == frigate_config.dict(exclude_unset=True)
runtime_config = frigate_config.runtime_config
assert runtime_config.cameras["back"].live.quality == 7
assert runtime_config.cameras["back"].live.height == 480
assert runtime_config.cameras["back"].restream.jsmpeg.quality == 7
assert runtime_config.cameras["back"].restream.jsmpeg.height == 480
def test_global_timestamp_style(self):

View File

@ -0,0 +1,64 @@
"""Test restream.py."""
from unittest import TestCase, main
from unittest.mock import patch
from frigate.config import FrigateConfig
from frigate.restream import RestreamApi
class TestRestream(TestCase):
def setUp(self) -> None:
"""Setup the tests."""
self.config = {
"mqtt": {"host": "mqtt"},
"restream": {"enabled": False},
"cameras": {
"back": {
"ffmpeg": {
"inputs": [
{
"path": "rtsp://10.0.0.1:554/video",
"roles": ["detect", "restream"],
},
]
},
"restream": {
"enabled": True,
},
},
"front": {
"ffmpeg": {
"inputs": [
{
"path": "http://10.0.0.1:554/video/stream",
"roles": ["detect", "restream"],
},
]
},
"restream": {
"enabled": True,
},
},
},
}
@patch("frigate.restream.requests")
def test_rtsp_stream(self, mock_requests) -> None:
"""Test that the normal rtsp stream is sent plainly."""
frigate_config = FrigateConfig(**self.config)
restream = RestreamApi(frigate_config)
restream.add_cameras()
assert restream.relays["back"].startswith("rtsp")
@patch("frigate.restream.requests")
def test_http_stream(self, mock_requests) -> None:
"""Test that the http stream is sent via ffmpeg."""
frigate_config = FrigateConfig(**self.config)
restream = RestreamApi(frigate_config)
restream.add_cameras()
assert not restream.relays["front"].startswith("rtsp")
if __name__ == "__main__":
main(verbosity=2)

BIN
test.db-journal Normal file

Binary file not shown.

View File

@ -19,7 +19,7 @@ export const handlers = [
record: { enabled: true },
detect: { width: 1280, height: 720 },
snapshots: {},
live: { height: 720 },
restream: { enabled: true, jsmpeg: { height: 720 } },
ui: { dashboard: true, order: 0 },
},
side: {
@ -28,7 +28,7 @@ export const handlers = [
record: { enabled: false },
detect: { width: 1280, height: 720 },
snapshots: {},
live: { height: 720 },
restream: { enabled: true, jsmpeg: { height: 720 } },
ui: { dashboard: true, order: 1 },
},
},

View File

@ -5,7 +5,7 @@ import JSMpeg from '@cycjimmy/jsmpeg-player';
export default function JSMpegPlayer({ camera, width, height }) {
const playerRef = useRef();
const url = `${baseUrl.replace(/^http/, 'ws')}live/${camera}`;
const url = `${baseUrl.replace(/^http/, 'ws')}live/jsmpeg/${camera}`;
useEffect(() => {
const video = new JSMpeg.VideoElement(

View File

@ -0,0 +1,93 @@
import { h } from 'preact';
import { baseUrl } from '../api/baseUrl';
import { useEffect } from 'preact/hooks';
export default function MsePlayer({ camera, width, height }) {
const url = `${baseUrl.replace(/^http/, 'ws')}live/mse/api/ws?src=${camera}`;
useEffect(() => {
const video = document.querySelector('#video');
// support api_path
const ws = new WebSocket(url);
ws.binaryType = 'arraybuffer';
let mediaSource;
ws.onopen = () => {
// https://web.dev/i18n/en/fast-playback-with-preload/#manual_buffering
// https://developer.mozilla.org/en-US/docs/Web/API/Media_Source_Extensions_API
mediaSource = new MediaSource();
video.src = URL.createObjectURL(mediaSource);
mediaSource.onsourceopen = () => {
mediaSource.onsourceopen = null;
URL.revokeObjectURL(video.src);
ws.send(JSON.stringify({ type: 'mse' }));
};
};
let sourceBuffer,
queueBuffer = [];
ws.onmessage = (ev) => {
if (typeof ev.data === 'string') {
const data = JSON.parse(ev.data);
if (data.type === 'mse') {
sourceBuffer = mediaSource.addSourceBuffer(data.value);
// important: segments supports TrackFragDecodeTime
// sequence supports only TrackFragRunEntry Duration
sourceBuffer.mode = 'segments';
sourceBuffer.onupdateend = () => {
if (!sourceBuffer.updating && queueBuffer.length > 0) {
sourceBuffer.appendBuffer(queueBuffer.shift());
}
};
}
} else if (sourceBuffer.updating) {
queueBuffer.push(ev.data);
} else {
sourceBuffer.appendBuffer(ev.data);
}
};
let offsetTime = 1,
noWaiting = 0;
setInterval(() => {
if (video.paused || video.seekable.length === 0) return;
if (noWaiting < 0) {
offsetTime = Math.min(offsetTime * 1.1, 5);
} else if (noWaiting >= 30) {
noWaiting = 0;
offsetTime = Math.max(offsetTime * 0.9, 0.5);
}
noWaiting += 1;
const endTime = video.seekable.end(video.seekable.length - 1);
let playbackRate = (endTime - video.currentTime) / offsetTime;
if (playbackRate < 0.1) {
// video.currentTime = endTime - offsetTime;
playbackRate = 0.1;
} else if (playbackRate > 10) {
// video.currentTime = endTime - offsetTime;
playbackRate = 10;
}
// https://github.com/GoogleChrome/developer.chrome.com/issues/135
video.playbackRate = playbackRate;
}, 1000);
video.onwaiting = () => {
const endTime = video.seekable.end(video.seekable.length - 1);
video.currentTime = endTime - offsetTime;
noWaiting = -1;
};
}, [url]);
return (
<div>
<video id="video" autoplay playsinline controls muted width={width} height={height} />
</div>
);
}

View File

@ -0,0 +1,72 @@
import { h } from 'preact';
import { baseUrl } from '../api/baseUrl';
import { useEffect } from 'preact/hooks';
export default function WebRtcPlayer({ camera, width, height }) {
const url = `${baseUrl.replace(/^http/, 'ws')}live/webrtc/api/ws?src=${camera}`;
useEffect(() => {
const ws = new WebSocket(url);
ws.onopen = () => {
pc.createOffer().then((offer) => {
pc.setLocalDescription(offer).then(() => {
const msg = { type: 'webrtc/offer', value: pc.localDescription.sdp };
ws.send(JSON.stringify(msg));
});
});
};
ws.onmessage = (ev) => {
const msg = JSON.parse(ev.data);
if (msg.type === 'webrtc/candidate') {
pc.addIceCandidate({ candidate: msg.value, sdpMid: '' });
} else if (msg.type === 'webrtc/answer') {
pc.setRemoteDescription({ type: 'answer', sdp: msg.value });
}
};
const pc = new RTCPeerConnection({
iceServers: [{ urls: 'stun:stun.l.google.com:19302' }],
});
pc.onicecandidate = (ev) => {
if (ev.candidate !== null) {
ws.send(
JSON.stringify({
type: 'webrtc/candidate',
value: ev.candidate.toJSON().candidate,
})
);
}
};
pc.ontrack = (ev) => {
const video = document.getElementById('video');
// when audio track not exist in Chrome
if (ev.streams.length === 0) return;
// when audio track not exist in Firefox
if (ev.streams[0].id[0] === '{') return;
// when stream already init
if (video.srcObject !== null) return;
video.srcObject = ev.streams[0];
};
// Safari don't support "offerToReceiveVideo"
// so need to create transeivers manually
pc.addTransceiver('video', { direction: 'recvonly' });
pc.addTransceiver('audio', { direction: 'recvonly' });
return () => {
const video = document.getElementById('video');
video.srcObject = null;
pc.close();
ws.close();
};
}, [url]);
return (
<div>
<video id="video" autoplay playsinline controls muted width={width} height={height} />
</div>
);
}

View File

@ -13,6 +13,8 @@ import { usePersistence } from '../context';
import { useCallback, useMemo, useState } from 'preact/hooks';
import { useApiHost } from '../api';
import useSWR from 'swr';
import WebRtcPlayer from '../components/WebRtcPlayer';
import MsePlayer from '../components/MsePlayer';
const emptyObject = Object.freeze({});
@ -23,9 +25,11 @@ export default function Camera({ camera }) {
const [viewMode, setViewMode] = useState('live');
const cameraConfig = config?.cameras[camera];
const liveWidth = cameraConfig
? Math.round(cameraConfig.live.height * (cameraConfig.detect.width / cameraConfig.detect.height))
const jsmpegWidth = cameraConfig
? Math.round(cameraConfig.restream.jsmpeg.height * (cameraConfig.detect.width / cameraConfig.detect.height))
: 0;
const [viewSource, setViewSource, sourceIsLoaded] = usePersistence(`${camera}-source`, 'mse');
const sourceValues = cameraConfig && cameraConfig.restream.enabled ? ['mse', 'webrtc', 'jsmpeg'] : ['mse'];
const [options, setOptions] = usePersistence(`${camera}-feed`, emptyObject);
const handleSetOption = useCallback(
@ -51,7 +55,7 @@ export default function Camera({ camera }) {
setShowSettings(!showSettings);
}, [showSettings, setShowSettings]);
if (!cameraConfig) {
if (!cameraConfig || !sourceIsLoaded) {
return <ActivityIndicator />;
}
@ -93,13 +97,31 @@ export default function Camera({ camera }) {
let player;
if (viewMode === 'live') {
player = (
<Fragment>
<div>
<JSMpegPlayer camera={camera} width={liveWidth} height={cameraConfig.live.height} />
</div>
</Fragment>
);
if (viewSource == 'mse') {
player = (
<Fragment>
<div className="max-w-5xl">
<MsePlayer camera={camera} />
</div>
</Fragment>
);
} else if (viewSource == 'webrtc') {
player = (
<Fragment>
<div className="max-w-5xl">
<WebRtcPlayer camera={camera} />
</div>
</Fragment>
);
} else {
player = (
<Fragment>
<div>
<JSMpegPlayer camera={camera} width={jsmpegWidth} height={cameraConfig.restream.jsmpeg.height} />
</div>
</Fragment>
);
}
} else if (viewMode === 'debug') {
player = (
<Fragment>
@ -120,7 +142,23 @@ export default function Camera({ camera }) {
return (
<div className="space-y-4 p-2 px-4">
<Heading size="2xl">{camera.replaceAll('_', ' ')}</Heading>
<div className="flex justify-between">
<Heading className="p-2" size="2xl">
{camera.replaceAll('_', ' ')}
</Heading>
<select
className="basis-1/8 cursor-pointer rounded dark:bg-slate-800"
value={viewSource}
onChange={(e) => setViewSource(e.target.value)}
>
{sourceValues.map((item) => (
<option key={item} value={item}>
{item}
</option>
))}
</select>
</div>
<ButtonsTabbed viewModes={['live', 'debug']} setViewMode={setViewMode} />
{player}

View File

@ -11,7 +11,7 @@ describe('Camera Route', () => {
beforeEach(() => {
mockSetOptions = jest.fn();
mockUsePersistence = jest.spyOn(Context, 'usePersistence').mockImplementation(() => [{}, mockSetOptions]);
mockUsePersistence = jest.spyOn(Context, 'usePersistence').mockImplementation(() => [{}, mockSetOptions, true]);
jest.spyOn(AutoUpdatingCameraImage, 'default').mockImplementation(({ searchParams }) => {
return <div data-testid="mock-image">{searchParams.toString()}</div>;
});
@ -32,11 +32,12 @@ describe('Camera Route', () => {
regions: false,
},
mockSetOptions,
true,
]);
render(<Camera camera="front" />);
await waitForElementToBeRemoved(() => screen.queryByLabelText('Loading…'));
await waitForElementToBeRemoved(() => screen.queryByLabelText('Loading…'), { timeout: 10 });
fireEvent.click(screen.queryByText('Debug'));
fireEvent.click(screen.queryByText('Show Options'));
@ -47,15 +48,20 @@ describe('Camera Route', () => {
test('updates camera feed options to persistence', async () => {
mockUsePersistence
.mockReturnValueOnce([{}, mockSetOptions])
.mockReturnValueOnce([{}, mockSetOptions])
.mockReturnValueOnce([{}, mockSetOptions])
.mockReturnValueOnce([{ bbox: true }, mockSetOptions])
.mockReturnValueOnce([{ bbox: true, timestamp: true }, mockSetOptions]);
.mockReturnValueOnce([{}, mockSetOptions, true])
.mockReturnValueOnce([{}, mockSetOptions, true])
.mockReturnValueOnce([{}, mockSetOptions, true])
.mockReturnValueOnce([{}, mockSetOptions, true])
.mockReturnValueOnce([{}, mockSetOptions, true])
.mockReturnValueOnce([{}, mockSetOptions, true])
.mockReturnValueOnce([{}, mockSetOptions, true])
.mockReturnValueOnce([{}, mockSetOptions, true])
.mockReturnValueOnce([{ bbox: true }, mockSetOptions, true])
.mockReturnValueOnce([{ bbox: true, timestamp: true }, mockSetOptions, true]);
render(<Camera camera="front" />);
await waitForElementToBeRemoved(() => screen.queryByLabelText('Loading…'));
await waitForElementToBeRemoved(() => screen.queryByLabelText('Loading…'), { timeout: 10 });
fireEvent.click(screen.queryByText('Debug'));
fireEvent.click(screen.queryByText('Show Options'));
@ -63,9 +69,8 @@ describe('Camera Route', () => {
fireEvent.change(screen.queryByTestId('timestamp-input'), { target: { checked: true } });
fireEvent.click(screen.queryByText('Hide Options'));
expect(mockUsePersistence).toHaveBeenCalledTimes(5);
expect(mockUsePersistence).toHaveBeenCalledTimes(10);
expect(mockSetOptions).toHaveBeenCalledTimes(2);
expect(mockSetOptions).toHaveBeenCalledWith({ bbox: true, timestamp: true });
expect(screen.queryByTestId('mock-image')).toHaveTextContent('bbox=1&timestamp=1');
});
});