diff --git a/docker-compose.yml b/docker-compose.yml index 84547503a..1dac6d49a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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: diff --git a/docker/Dockerfile b/docker/Dockerfile index 37b639520..2fa10cecb 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -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"] diff --git a/docker/rootfs/etc/services.d/go2rtc/finish b/docker/rootfs/etc/services.d/go2rtc/finish new file mode 100644 index 000000000..24482e77f --- /dev/null +++ b/docker/rootfs/etc/services.d/go2rtc/finish @@ -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 \ No newline at end of file diff --git a/docker/rootfs/etc/services.d/go2rtc/run b/docker/rootfs/etc/services.d/go2rtc/run new file mode 100644 index 000000000..55d206414 --- /dev/null +++ b/docker/rootfs/etc/services.d/go2rtc/run @@ -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" \ No newline at end of file diff --git a/docker/rootfs/usr/local/go2rtc/sbin/go2rtc.yaml b/docker/rootfs/usr/local/go2rtc/sbin/go2rtc.yaml new file mode 100644 index 000000000..ee155c80a --- /dev/null +++ b/docker/rootfs/usr/local/go2rtc/sbin/go2rtc.yaml @@ -0,0 +1,4 @@ +webrtc: + listen: ":8555" + candidates: + - stun:8555 diff --git a/docker/rootfs/usr/local/nginx/conf/nginx.conf b/docker/rootfs/usr/local/nginx/conf/nginx.conf index 881bbefdb..1363e3b75 100644 --- a/docker/rootfs/usr/local/nginx/conf/nginx.conf +++ b/docker/rootfs/usr/local/nginx/conf/nginx.conf @@ -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'; diff --git a/docs/docs/configuration/cameras.md b/docs/docs/configuration/cameras.md index d2feceeeb..9784d44e4 100644 --- a/docs/docs/configuration/cameras.md +++ b/docs/docs/configuration/cameras.md @@ -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: diff --git a/docs/docs/configuration/index.md b/docs/docs/configuration/index.md index 57bc815dd..6a401bb1e 100644 --- a/docs/docs/configuration/index.md +++ b/docs/docs/configuration/index.md @@ -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: diff --git a/docs/docs/configuration/live.md b/docs/docs/configuration/live.md new file mode 100644 index 000000000..e537c4ff1 --- /dev/null +++ b/docs/docs/configuration/live.md @@ -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: + - :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 +``` \ No newline at end of file diff --git a/docs/docs/configuration/restream.md b/docs/docs/configuration/restream.md new file mode 100644 index 000000000..b11f0ca92 --- /dev/null +++ b/docs/docs/configuration/restream.md @@ -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://:8554/`. 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. diff --git a/docs/docs/configuration/rtmp.md b/docs/docs/configuration/rtmp.md deleted file mode 100644 index 5c6b3b5a0..000000000 --- a/docs/docs/configuration/rtmp.md +++ /dev/null @@ -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:///live/`. 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). diff --git a/docs/sidebars.js b/docs/sidebars.js index e5192d2cd..51a157701 100644 --- a/docs/sidebars.js +++ b/docs/sidebars.js @@ -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", diff --git a/frigate/app.py b/frigate/app.py index b97f580a0..1ea64ef3b 100644 --- a/frigate/app.py +++ b/frigate/app.py @@ -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() diff --git a/frigate/config.py b/frigate/config.py index 1370dcf88..9c551892f 100644 --- a/frigate/config.py +++ b/frigate/config.py @@ -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( diff --git a/frigate/output.py b/frigate/output.py index 4676dac83..bb088ffff 100644 --- a/frigate/output.py +++ b/frigate/output.py @@ -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 diff --git a/frigate/restream.py b/frigate/restream.py new file mode 100644 index 000000000..3168bd922 --- /dev/null +++ b/frigate/restream.py @@ -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) diff --git a/frigate/test/test_config.py b/frigate/test/test_config.py index 66e232135..70b6ba03f 100644 --- a/frigate/test/test_config.py +++ b/frigate/test/test_config.py @@ -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): diff --git a/frigate/test/test_restream.py b/frigate/test/test_restream.py new file mode 100644 index 000000000..7170bf06b --- /dev/null +++ b/frigate/test/test_restream.py @@ -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) diff --git a/test.db-journal b/test.db-journal new file mode 100644 index 000000000..3649988aa Binary files /dev/null and b/test.db-journal differ diff --git a/web/config/handlers.js b/web/config/handlers.js index 0166d0c3a..c24fcd8d2 100644 --- a/web/config/handlers.js +++ b/web/config/handlers.js @@ -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 }, }, }, diff --git a/web/src/components/JSMpegPlayer.jsx b/web/src/components/JSMpegPlayer.jsx index 875f0c8ed..c77f2530d 100644 --- a/web/src/components/JSMpegPlayer.jsx +++ b/web/src/components/JSMpegPlayer.jsx @@ -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( diff --git a/web/src/components/MsePlayer.jsx b/web/src/components/MsePlayer.jsx new file mode 100644 index 000000000..cc5ba61f5 --- /dev/null +++ b/web/src/components/MsePlayer.jsx @@ -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 ( +
+
+ ); +} diff --git a/web/src/components/VideoPlayer.jsx b/web/src/components/VideoPlayer.jsx index 49a79d38f..8dc93fbff 100644 --- a/web/src/components/VideoPlayer.jsx +++ b/web/src/components/VideoPlayer.jsx @@ -98,4 +98,4 @@ export default function VideoPlayer({ children, options, seekOptions = {}, onRea {children} ); -} +} \ No newline at end of file diff --git a/web/src/components/WebRtcPlayer.jsx b/web/src/components/WebRtcPlayer.jsx new file mode 100644 index 000000000..051259589 --- /dev/null +++ b/web/src/components/WebRtcPlayer.jsx @@ -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 ( +
+
+ ); +} diff --git a/web/src/routes/Camera.jsx b/web/src/routes/Camera.jsx index ec3bc7038..57e7c9e7e 100644 --- a/web/src/routes/Camera.jsx +++ b/web/src/routes/Camera.jsx @@ -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 ; } @@ -93,13 +97,31 @@ export default function Camera({ camera }) { let player; if (viewMode === 'live') { - player = ( - -
- -
-
- ); + if (viewSource == 'mse') { + player = ( + +
+ +
+
+ ); + } else if (viewSource == 'webrtc') { + player = ( + +
+ +
+
+ ); + } else { + player = ( + +
+ +
+
+ ); + } } else if (viewMode === 'debug') { player = ( @@ -120,7 +142,23 @@ export default function Camera({ camera }) { return (
- {camera.replaceAll('_', ' ')} +
+ + {camera.replaceAll('_', ' ')} + + +
+ {player} diff --git a/web/src/routes/__tests__/Camera.test.jsx b/web/src/routes/__tests__/Camera.test.jsx index 26347a2bb..db5576d70 100644 --- a/web/src/routes/__tests__/Camera.test.jsx +++ b/web/src/routes/__tests__/Camera.test.jsx @@ -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
{searchParams.toString()}
; }); @@ -32,11 +32,12 @@ describe('Camera Route', () => { regions: false, }, mockSetOptions, + true, ]); render(); - 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(); - 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×tamp=1'); }); });