From d8123d249764c8004df2b9a9b37576445ea88b46 Mon Sep 17 00:00:00 2001 From: Nicolas Mowen Date: Wed, 2 Nov 2022 05:36:09 -0600 Subject: [PATCH] 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 * 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 Co-authored-by: Blake Blackshear --- docker-compose.yml | 1 + docker/Dockerfile | 8 ++ docker/rootfs/etc/services.d/go2rtc/finish | 5 + docker/rootfs/etc/services.d/go2rtc/run | 12 ++ .../rootfs/usr/local/go2rtc/sbin/go2rtc.yaml | 4 + docker/rootfs/usr/local/nginx/conf/nginx.conf | 23 +++- docs/docs/configuration/cameras.md | 11 +- docs/docs/configuration/index.md | 36 +++--- docs/docs/configuration/live.md | 41 +++++++ docs/docs/configuration/restream.md | 12 ++ docs/docs/configuration/rtmp.md | 8 -- docs/sidebars.js | 3 +- frigate/app.py | 6 + frigate/config.py | 47 ++++++-- frigate/output.py | 6 +- frigate/restream.py | 44 +++++++ frigate/test/test_config.py | 111 +++++++++++++++--- frigate/test/test_restream.py | 64 ++++++++++ test.db-journal | Bin 0 -> 29240 bytes web/config/handlers.js | 4 +- web/src/components/JSMpegPlayer.jsx | 2 +- web/src/components/MsePlayer.jsx | 93 +++++++++++++++ web/src/components/VideoPlayer.jsx | 2 +- web/src/components/WebRtcPlayer.jsx | 72 ++++++++++++ web/src/routes/Camera.jsx | 60 ++++++++-- web/src/routes/__tests__/Camera.test.jsx | 25 ++-- 26 files changed, 614 insertions(+), 86 deletions(-) create mode 100644 docker/rootfs/etc/services.d/go2rtc/finish create mode 100644 docker/rootfs/etc/services.d/go2rtc/run create mode 100644 docker/rootfs/usr/local/go2rtc/sbin/go2rtc.yaml create mode 100644 docs/docs/configuration/live.md create mode 100644 docs/docs/configuration/restream.md delete mode 100644 docs/docs/configuration/rtmp.md create mode 100644 frigate/restream.py create mode 100644 frigate/test/test_restream.py create mode 100644 test.db-journal create mode 100644 web/src/components/MsePlayer.jsx create mode 100644 web/src/components/WebRtcPlayer.jsx 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 0000000000000000000000000000000000000000..3649988aaf8590e0b99c00b63e90214a145aebbb GIT binary patch literal 29240 zcmeI5&u`mg7{~3zO`0@m1{YsAxE@2Z*w)0(kCthO&_yM*%UGJida|6vZOsz9i`{A4 z1$9H>I3e*5a7E%z;ItDr1R99zIB?n@up5tm#cASJMdEqsR3t zH_x9h=3Wcm_x@lQCjBb$>rZsfGfaX`7pUNP0|>BNcQy@27j~`DP;&%@R-R>t_EH zY5#DfIfiA50!?FcQqSsssrMXP6q>D0W4+O)u7;N1G^mjlerLqf*Sd8n#b4rC%P{-; zL;ZnnI;uZe?>ZTB4cFB0-#*Z)MbmOpQ9fB80t5{kPKm`(#RH z+7x$bBlT=B_I9gzXS+e*JuH!2^*zBQ6IHr+yn3m3A35Z^lnT2~)v@9F{%?UieS9&^^E~^_mKT#ev%Qa8FrOe2d3FCH2>G)dhpVX=6goy2&8EbW*Pk^A zQ8;m|1J$O8N`Y%S`0Q!}TUQS(+i;8rBo89aWPEyTnL0(MPq)Zd!?@~px_>zAnwl{X zg-+uxh1($Hl4xJE)!x83i0#f5+NO43?^{l6gOR@H#mVUbyVk>5E3}bL@jS-rk?v@Q zsk#oiYnT+i29ILf*@sD17VthV59RN5_9l9CwG~!q6|K zEu6YBnRH`1`OXt`*;h3pHgoHdUL{_1$o1W4eD`pl>F21p&-qCzI6wddKmY_l00ck) z1V8`;KmY_l00hnvf$;s0tUD*X{&9|07E=KM5C8!X009sH0T2KI5C8!X0D)No!9GCF zeeoar0JD5h0|Fob0w4eaAOHd&00JNY0w4ea=ZQeD55VDlfb+Dfm9ny^Auvfmp@LmvLdg@%Jme-tdnZh zQ}wf=s9MiWRiFlGQ(~3n@@l2HGRHA>Qm&{&?Vdi)K%$z51Ny@MfgYz>uGQqyYL(-d z8>CtGhV4?~0o6}Rpt|V>V)aTDxma9H`ZHD39<`&4DN%k3)k`A~r>a!SN?B1798;qq sWp79~dBQkJLab78MXppUEXQ0YrBXPamx{tZc`Uh7tW{TEC4N)>0q|*!i2wiq literal 0 HcmV?d00001 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'); }); });