mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-10-02 12:56:51 +03:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a529656a90 | ||
|
|
c1b56b51b0 | ||
|
|
d37dff1b49 |
@@ -197,7 +197,7 @@ For cameras that support two-way talk, go2rtc will automatically establish an au
|
||||
To prevent this, you must configure two separate stream instances:
|
||||
|
||||
1. One stream instance with `#backchannel=0` for Frigate's viewing, recording, and detection (prevents go2rtc from establishing the blocking backchannel)
|
||||
2. A second stream instance without `#backchannel=0` for two-way talk functionality (can be used by Frigate's WebRTC viewer or other applications)
|
||||
2. A second stream instance with no `#` parameters at all for two-way talk functionality (can be used by Frigate's WebRTC viewer or other applications)
|
||||
|
||||
Configuration example:
|
||||
|
||||
@@ -215,6 +215,8 @@ In this configuration:
|
||||
- `front_door` stream is used by Frigate for viewing, recording, and detection. The `#backchannel=0` parameter prevents go2rtc from establishing the audio output backchannel, so it won't block two-way talk access.
|
||||
- `front_door_twoway` stream is used for two-way talk functionality. This stream can be used by Frigate's WebRTC viewer when two-way talk is enabled, or by other applications (like Home Assistant Advanced Camera Card) that need access to the camera's audio output channel.
|
||||
|
||||
Any `#` parameter on a bare `rtsp://` source disables the backchannel unless the URL explicitly contains `#backchannel=1`. A two-way talk stream with something like `#video=h264` on it silently loses two-way audio, and Frigate will report that two-way talk is unavailable for that stream.
|
||||
|
||||
## Security: Restricted Stream Sources
|
||||
|
||||
For security reasons, the `echo:`, `expr:`, and `exec:` stream sources are disabled by default in go2rtc. These sources allow arbitrary command execution and can pose security risks if misconfigured.
|
||||
|
||||
@@ -39,6 +39,20 @@ To do this efficiently the following setup is required:
|
||||
|
||||
When this is done correctly, the GPU will do the decoding and scaling which will result in a small increase in CPU usage but with better results.
|
||||
|
||||
### How can I rotate my camera's video feed?
|
||||
|
||||
Rotation is best done in the camera's firmware settings (usually called rotate, flip, or corridor mode) so the video arrives already rotated and no extra processing is needed. Check there first.
|
||||
|
||||
If your camera does not support rotation, go2rtc's ffmpeg module can rotate the stream with the `#rotate` parameter (`90`, `180`, `270`, or `-90`), but this is not recommended: rotation requires transcoding (re-encoding) the video, which significantly increases CPU usage, especially for high resolution streams.
|
||||
|
||||
```yaml
|
||||
go2rtc:
|
||||
streams:
|
||||
my_camera: "ffmpeg:rtsp://user:password@192.168.1.10:554/stream#video=h264#hardware#rotate=90"
|
||||
```
|
||||
|
||||
Point the camera's inputs at the restream as described in the [restream docs](/configuration/restream.md), and swap `detect -> width` and `detect -> height` to match the rotated resolution.
|
||||
|
||||
### My mjpeg stream or snapshots look green and crazy
|
||||
|
||||
This almost always means that the width/height defined for your camera are not correct. Double check the resolution with VLC or another player. Also make sure you don't have the width and height values backwards.
|
||||
|
||||
@@ -78,7 +78,9 @@ go2rtc:
|
||||
|
||||
:::warning
|
||||
|
||||
The `#`-modifiers (`#video=`, `#audio=`, `#hardware`, `#backchannel=0`, …) **only take effect on a source that is prefixed with `ffmpeg:`**. Adding them to a bare `rtsp://…#audio=opus` source does nothing: go2rtc ignores them. Likewise, when a source references another stream by name (e.g. `ffmpeg:back#audio=aac`), the name must match the stream key **exactly** (it is case sensitive), or the transcode is silently never produced. This is the single most common configuration mistake. In the Frigate UI, the **Use compatibility mode (ffmpeg)** toggle adds the `ffmpeg:` prefix for you.
|
||||
The transcoding modifiers (`#video=`, `#audio=`, `#hardware`, …) **only take effect on a source that is prefixed with `ffmpeg:`**. Adding them to a bare `rtsp://…#audio=opus` source does nothing: go2rtc ignores them. Likewise, when a source references another stream by name (e.g. `ffmpeg:back#audio=aac`), the name must match the stream key **exactly** (it is case sensitive), or the transcode is silently never produced. This is the single most common configuration mistake. In the Frigate UI, the **Use compatibility mode (ffmpeg)** toggle adds the `ffmpeg:` prefix for you.
|
||||
|
||||
A bare `rtsp://` source reads a different set of modifiers: `#backchannel=`, `#media=`, `#timeout=`, and `#transport=`. These do nothing on an `ffmpeg:` source. Adding **any** modifier to a bare `rtsp://` source also disables the camera's backchannel unless the URL explicitly contains `#backchannel=1`, so a stream dedicated to two-way talk should carry no modifiers at all.
|
||||
|
||||
:::
|
||||
|
||||
@@ -153,7 +155,7 @@ WebRTC is only attempted when MSE fails or when using a camera's two-way talk fe
|
||||
|
||||
- **Codec mismatch**: WebRTC cannot carry H.265 or AAC. The stream backing the WebRTC view must provide Opus (or PCMA/PCMU) audio and H.264 video. Add an `ffmpeg:back#audio=opus` source as shown above.
|
||||
- **Port `8555` not reachable, or no candidates set**: WebRTC needs port `8555` (both TCP and UDP) open and a reachable candidate advertised. On Docker installs running on a custom/overlay network, go2rtc may advertise unreachable container IPs as ICE candidates; setting `webrtc.filters.candidates: []` and supplying only your host's LAN IP resolves this. See [WebRTC extra configuration](/configuration/live#webrtc-extra-configuration).
|
||||
- **Two-way talk** additionally requires a secure context (HTTPS or the authenticated port `8971`, because browsers block microphone access on plain HTTP). The camera's RTSP backchannel must also be handled correctly: go2rtc seizes the backchannel by default, which blocks two-way audio for other consumers and can inject static. Disable it on the primary stream with `#backchannel=0` and use a separate dedicated stream for talk, as documented in [preventing go2rtc from blocking two-way audio](/configuration/restream#two-way-talk-restream).
|
||||
- **Two-way talk** additionally requires a secure context (HTTPS or the authenticated port `8971`, because browsers block microphone access on plain HTTP). The camera's RTSP backchannel must also be handled correctly: go2rtc seizes the backchannel by default, which blocks two-way audio for other consumers and can inject static. Disable it on the primary stream with `#backchannel=0` and use a separate dedicated stream for talk, carrying no `#` modifiers of any kind, as documented in [preventing go2rtc from blocking two-way audio](/configuration/restream#two-way-talk-restream).
|
||||
|
||||
## High CPU usage
|
||||
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
/**
|
||||
* Debug Replay range selection from History -- HIGH tier.
|
||||
*
|
||||
* Covers the "Select from Timeline" flow that Debug Replay shares with
|
||||
* Export. The other half of the same report, a loading spinner latched
|
||||
* after Cancel, needs real media: the vod mock serves an empty playlist.
|
||||
*/
|
||||
|
||||
import { test, expect, type FrigateApp } from "../fixtures/frigate-test";
|
||||
|
||||
// the selection is seeded around the playback position, so land near the
|
||||
// live edge
|
||||
const playbackTime = Math.floor(Date.now() / 1000) - 300;
|
||||
|
||||
async function openRecordingView(frigateApp: FrigateApp) {
|
||||
// The recording view pulls these while the timeline renders; the preview
|
||||
// server 500s on them, which the error collector would flag.
|
||||
await frigateApp.page.route("**/api/*/recordings**", (route) =>
|
||||
route.fulfill({ json: [] }),
|
||||
);
|
||||
await frigateApp.page.route("**/api/recordings/unavailable**", (route) =>
|
||||
route.fulfill({ json: [] }),
|
||||
);
|
||||
// inert here; the 0.19 recording view fetches coverage and needs an
|
||||
// object, so this has to follow the broad recordings route to win
|
||||
await frigateApp.page.route("**/api/*/recordings/coverage**", (route) =>
|
||||
route.fulfill({
|
||||
json: {
|
||||
spans: [
|
||||
{
|
||||
start_time: playbackTime - 3600,
|
||||
end_time: playbackTime + 600,
|
||||
streams: ["main"],
|
||||
},
|
||||
],
|
||||
codecs_compatible: true,
|
||||
streams: {
|
||||
main: {
|
||||
video_codec: "h264",
|
||||
audio_rate: null,
|
||||
audio_codec: null,
|
||||
has_audio: false,
|
||||
bitrate: 2_000_000,
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
await frigateApp.goto(`/review?timestamp=front_door_${playbackTime}`);
|
||||
}
|
||||
|
||||
// desktop reaches Debug Replay through the Actions menu, mobile through
|
||||
// the settings drawer; both render the same form
|
||||
async function openDebugReplayForm(frigateApp: FrigateApp) {
|
||||
if (frigateApp.isMobile) {
|
||||
await frigateApp.page
|
||||
.getByRole("button", { name: /filters/i })
|
||||
.first()
|
||||
.click({ timeout: 15_000 });
|
||||
await frigateApp.page
|
||||
.getByRole("button", { name: /^debug replay$/i })
|
||||
.click();
|
||||
} else {
|
||||
await frigateApp.page
|
||||
.getByRole("button", { name: /actions/i })
|
||||
.click({ timeout: 15_000 });
|
||||
await frigateApp.page
|
||||
.getByRole("menuitem", { name: /debug replay/i })
|
||||
.click();
|
||||
}
|
||||
|
||||
const form = frigateApp.page.getByRole("dialog");
|
||||
await expect(form).toBeVisible({ timeout: 5_000 });
|
||||
return form;
|
||||
}
|
||||
|
||||
async function selectRangeFromTimeline(frigateApp: FrigateApp) {
|
||||
const form = await openDebugReplayForm(frigateApp);
|
||||
await form.getByText("From Timeline").click();
|
||||
await form.getByRole("button", { name: "Select", exact: true }).click();
|
||||
await expect(form).toBeHidden({ timeout: 5_000 });
|
||||
|
||||
await expect(frigateApp.page.locator(".export-start")).toHaveText(
|
||||
/\d{1,2}:\d{2}/,
|
||||
{ timeout: 5_000 },
|
||||
);
|
||||
}
|
||||
|
||||
// moving the playhead between the two selections is what makes the second
|
||||
// range differ from the first
|
||||
async function reselectRange(frigateApp: FrigateApp) {
|
||||
await selectRangeFromTimeline(frigateApp);
|
||||
|
||||
await frigateApp.page
|
||||
.getByRole("button", { name: /^cancel$/i })
|
||||
.click({ timeout: 5_000 });
|
||||
await expect(frigateApp.page.locator(".export-start")).toHaveCount(0);
|
||||
|
||||
const segments = frigateApp.page.locator(".segment[data-segment-id]");
|
||||
const count = await segments.count();
|
||||
await segments.nth(Math.min(20, count - 1)).click({ force: true });
|
||||
|
||||
await selectRangeFromTimeline(frigateApp);
|
||||
}
|
||||
|
||||
// the loop flipped this label between the two ranges ~25 times a second
|
||||
async function countHandleLabelChanges(frigateApp: FrigateApp) {
|
||||
return frigateApp.page.evaluate(async () => {
|
||||
const handle = document.querySelector(".export-start");
|
||||
if (!handle) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
let changes = 0;
|
||||
let last = handle.textContent;
|
||||
const observer = new MutationObserver(() => {
|
||||
if (handle.textContent !== last) {
|
||||
changes += 1;
|
||||
last = handle.textContent;
|
||||
}
|
||||
});
|
||||
observer.observe(handle, {
|
||||
subtree: true,
|
||||
childList: true,
|
||||
characterData: true,
|
||||
});
|
||||
await new Promise((resolve) => setTimeout(resolve, 1500));
|
||||
observer.disconnect();
|
||||
return changes;
|
||||
});
|
||||
}
|
||||
|
||||
test.describe("Debug Replay from History @high", () => {
|
||||
test("a reselected range lands once and stays put", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
if (frigateApp.isMobile) {
|
||||
test.skip();
|
||||
return;
|
||||
}
|
||||
|
||||
const pageErrors: string[] = [];
|
||||
frigateApp.page.on("pageerror", (err) => pageErrors.push(err.message));
|
||||
|
||||
await frigateApp.installDefaults();
|
||||
await openRecordingView(frigateApp);
|
||||
await reselectRange(frigateApp);
|
||||
|
||||
expect(await countHandleLabelChanges(frigateApp)).toBe(0);
|
||||
expect(
|
||||
pageErrors.filter((message) => /Maximum update depth/i.test(message)),
|
||||
).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("dragging a handle after a reselect moves it", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
if (frigateApp.isMobile) {
|
||||
test.skip();
|
||||
return;
|
||||
}
|
||||
|
||||
await frigateApp.installDefaults();
|
||||
await openRecordingView(frigateApp);
|
||||
await reselectRange(frigateApp);
|
||||
|
||||
const start = frigateApp.page.locator(".export-start");
|
||||
const before = (await start.textContent()) ?? "";
|
||||
const box = await start.boundingBox();
|
||||
if (!box) {
|
||||
throw new Error("export start handle has no bounding box");
|
||||
}
|
||||
|
||||
const x = box.x + box.width / 2;
|
||||
const y = box.y + box.height / 2;
|
||||
await frigateApp.page.mouse.move(x, y);
|
||||
await frigateApp.page.mouse.down();
|
||||
await frigateApp.page.mouse.move(x, y - 90, { steps: 12 });
|
||||
await frigateApp.page.mouse.up();
|
||||
|
||||
await expect(start).not.toHaveText(before, { timeout: 5_000 });
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Debug Replay from History — mobile @high @mobile", () => {
|
||||
test("a reselected range lands once and stays put", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
if (!frigateApp.isMobile) {
|
||||
test.skip();
|
||||
return;
|
||||
}
|
||||
|
||||
const pageErrors: string[] = [];
|
||||
frigateApp.page.on("pageerror", (err) => pageErrors.push(err.message));
|
||||
|
||||
await frigateApp.installDefaults();
|
||||
await openRecordingView(frigateApp);
|
||||
await reselectRange(frigateApp);
|
||||
|
||||
expect(await countHandleLabelChanges(frigateApp)).toBe(0);
|
||||
expect(
|
||||
pageErrors.filter((message) => /Maximum update depth/i.test(message)),
|
||||
).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -490,6 +490,12 @@ export default function HlsVideoPlayer({
|
||||
clearTimeout(mobileCtrlTimeout);
|
||||
}
|
||||
}}
|
||||
onSeeking={() => {
|
||||
// iOS ManagedMediaSource gates hls.js fragment loading off
|
||||
// while paused and never resumes it on seek, so a seek
|
||||
// into unbuffered media would never complete
|
||||
hlsRef.current?.resumeBuffering();
|
||||
}}
|
||||
onWaiting={() => {
|
||||
if (onError != undefined) {
|
||||
if (videoRef.current?.paused) {
|
||||
|
||||
@@ -10,6 +10,10 @@ import { playWithTemporaryMuteFallback } from "@/utils/videoUtil.ts";
|
||||
|
||||
type PlayerMode = "playback" | "scrubbing";
|
||||
|
||||
// how long a seek may wait for its `seeked` event before playback starts
|
||||
// anyway; long enough that a normally completing seek always wins
|
||||
const SEEK_PLAY_FALLBACK_MS = 1000;
|
||||
|
||||
export class DynamicVideoController {
|
||||
// main state
|
||||
public camera = "";
|
||||
@@ -106,7 +110,10 @@ export class DynamicVideoController {
|
||||
|
||||
waitAndPlay() {
|
||||
return new Promise((resolve) => {
|
||||
let fallback: NodeJS.Timeout | undefined;
|
||||
|
||||
const onSeekedHandler = () => {
|
||||
clearTimeout(fallback);
|
||||
this.playerController.removeEventListener("seeked", onSeekedHandler);
|
||||
playWithTemporaryMuteFallback(this.playerController);
|
||||
resolve(undefined);
|
||||
@@ -115,6 +122,12 @@ export class DynamicVideoController {
|
||||
this.playerController.addEventListener("seeked", onSeekedHandler, {
|
||||
once: true,
|
||||
});
|
||||
|
||||
// iOS ManagedMediaSource pauses hls.js buffering, so `seeked` may
|
||||
// never fire; playing is what prompts WebKit to resume streaming
|
||||
if ("ManagedMediaSource" in window) {
|
||||
fallback = setTimeout(onSeekedHandler, SEEK_PLAY_FALLBACK_MS);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1038,7 +1038,7 @@ type TimelineProps = {
|
||||
setCurrentTime: React.Dispatch<React.SetStateAction<number>>;
|
||||
manuallySetCurrentTime: (time: number, force: boolean) => void;
|
||||
setScrubbing: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
setExportRange: (range: TimeRange) => void;
|
||||
setExportRange: React.Dispatch<React.SetStateAction<TimeRange | undefined>>;
|
||||
onAnalysisOpen: (open: boolean) => void;
|
||||
};
|
||||
function Timeline({
|
||||
@@ -1131,22 +1131,50 @@ function Timeline({
|
||||
},
|
||||
]);
|
||||
|
||||
const [exportStart, setExportStartTime] = useState<number>(0);
|
||||
const [exportEnd, setExportEndTime] = useState<number>(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (exportRange && exportStart != 0 && exportEnd != 0) {
|
||||
if (exportRange.after != exportStart) {
|
||||
setCurrentTime(exportStart);
|
||||
} else if (exportRange?.before != exportEnd) {
|
||||
setCurrentTime(exportEnd);
|
||||
// a local mirror of the range fights a reseed: the position effect
|
||||
// echoes it back and the two rewrite each other forever
|
||||
const setExportStartTime = useCallback(
|
||||
(value: React.SetStateAction<number>) => {
|
||||
if (!exportRange) {
|
||||
return;
|
||||
}
|
||||
|
||||
setExportRange({ after: exportStart, before: exportEnd });
|
||||
}
|
||||
// we only want to update when the export parts change
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [exportStart, exportEnd, setExportRange, setCurrentTime]);
|
||||
const next =
|
||||
typeof value === "function" ? value(exportRange.after) : value;
|
||||
|
||||
// the position effect re-reports the current time on every reposition
|
||||
if (next == exportRange.after) {
|
||||
return;
|
||||
}
|
||||
|
||||
setCurrentTime(next);
|
||||
setExportRange((prev) =>
|
||||
prev ? { after: next, before: prev.before } : prev,
|
||||
);
|
||||
},
|
||||
[exportRange, setExportRange, setCurrentTime],
|
||||
);
|
||||
|
||||
const setExportEndTime = useCallback(
|
||||
(value: React.SetStateAction<number>) => {
|
||||
if (!exportRange) {
|
||||
return;
|
||||
}
|
||||
|
||||
const next =
|
||||
typeof value === "function" ? value(exportRange.before) : value;
|
||||
|
||||
if (next == exportRange.before) {
|
||||
return;
|
||||
}
|
||||
|
||||
setCurrentTime(next);
|
||||
setExportRange((prev) =>
|
||||
prev ? { after: prev.after, before: next } : prev,
|
||||
);
|
||||
},
|
||||
[exportRange, setExportRange, setCurrentTime],
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
|
||||
Reference in New Issue
Block a user