Compare commits

...

11 Commits

Author SHA1 Message Date
ryzendigo
722ef6a1fe
fix: wrong index for FPS replacement in preset-http-jpeg-generic (#22465)
Some checks are pending
CI / AMD64 Extra Build (push) Blocked by required conditions
CI / ARM Extra Build (push) Blocked by required conditions
CI / Synaptics Build (push) Blocked by required conditions
CI / Assemble and push default build (push) Blocked by required conditions
CI / AMD64 Build (push) Waiting to run
CI / ARM Build (push) Waiting to run
CI / Jetson Jetpack 6 (push) Waiting to run
parse_preset_input() uses input[len(_user_agent_args) + 1] to find
the FPS placeholder, but preset-http-jpeg-generic does not include
_user_agent_args at the start of its list (only preset-http-mjpeg-generic
does). The FPS placeholder '{}' is at index 1, not index 3.

This means the detect_fps value overwrites '-1' (the stream_loop
argument) instead of the '{}' FPS placeholder, so the preset always
uses the literal string '{}' as the framerate.
2026-03-16 09:57:14 -06:00
ryzendigo
bd289f3146
fix: pass ffmpeg_path to birdseye encode preset format string (#22462)
When hwaccel_args is a list (not a preset string), the fallback in
parse_preset_hardware_acceleration_encode() calls
arg_map["default"].format(input, output) with only 2 positional args.
But PRESETS_HW_ACCEL_ENCODE_BIRDSEYE["default"] contains {0}, {1}, {2}
expecting ffmpeg_path as the first arg.

This causes IndexError: Replacement index 2 out of range for size 2
which crashes create_config.py on every go2rtc start, taking down
all camera streams.

Pass ffmpeg_path as the first argument to match the preset template.
2026-03-16 09:57:04 -06:00
ryzendigo
c08ec9652f
fix: swap shape indices in birdseye custom logo assignment (#22463)
In BirdsEyeFrameManager.__init__(), the numpy slice that copies the
custom logo (transparent_layer from custom.png alpha channel) onto
blank_frame has shape[0] and shape[1] swapped:

  blank_frame[y:y+shape[1], x:x+shape[0]] = transparent_layer

shape[0] is rows (height) and shape[1] is cols (width), so the row
range needs shape[0] and the column range needs shape[1]:

  blank_frame[y:y+shape[0], x:x+shape[1]] = transparent_layer

The bug is masked for square images where shape[0]==shape[1]. For
non-square images (e.g. 1920x1080), it produces:

  ValueError: could not broadcast input array from shape (1080,1920)
  into shape (1620,1080)

This silently kills the birdseye output process -- no frames are
written to the FIFO pipe, go2rtc exec ffmpeg times out, and the
birdseye restream shows a black screen with no errors in the UI.
2026-03-16 06:48:54 -06:00
ryzendigo
7485b48f0e
fix: iterator exhausted by debug log prevents event cleanup (#22469)
In both expire_snapshots() and expire_clips(), the expired_events
query uses .iterator() for lazy evaluation, but the very next line
calls list(expired_events) inside an f-string for debug logging.
This consumes the entire iterator, so the subsequent for loop that
deletes media files from disk iterates over an exhausted iterator
and processes zero events.

Snapshots and clips for removed cameras are never deleted from disk,
causing gradual disk space exhaustion.

Materialize the iterator into a list before logging so both the
debug message and the cleanup loop use the same data.
2026-03-16 06:48:35 -06:00
ryzendigo
6d7b1ce384
fix: inverted condition causes division by zero in velocity direction check (#22470)
The cosine similarity calculation is guarded by:
  if not np.any(np.linalg.norm(velocities, axis=1))

This enters the block when ALL velocity norms are zero, then divides
by those zero norms. The condition should check that all norms are
non-zero before computing cosine similarity:
  if np.all(np.linalg.norm(velocities, axis=1))

Also fixes debug log that shows average_velocity[0] for both x and y
velocity components (second should be average_velocity[1]).
2026-03-16 06:47:24 -06:00
ryzendigo
e80da2b297
fix: WebSocket connection leaked on WebRTC player cleanup (#22473)
The connect() function creates a WebSocket but never stores the
reference. The useEffect cleanup only closes the RTCPeerConnection
via pcRef, leaving the WebSocket open.

Each time the component re-renders with changed deps (camera switch,
playback toggle, microphone toggle), a new WebSocket is created
without closing the previous one. This leaks connections until the
browser garbage-collects them or the server times out.

Store the WebSocket in a ref and close it in the cleanup function.
2026-03-16 06:47:07 -06:00
ryzendigo
49ffd0b01a
fix: handle custom logo images without alpha channel (#22468)
cv2.imread with IMREAD_UNCHANGED loads the image as-is, but the code
unconditionally indexes channel 3 (birdseye_logo[:, :, 3]) assuming
RGBA format. This crashes with IndexError for:

- Grayscale PNGs (2D array, no channel dimension)
- RGB PNGs without alpha (3 channels, no index 3)
- Fully transparent PNGs saved as grayscale+alpha (2 channels)

Handle all image formats:
- 2D (grayscale): use directly as luminance
- 4+ channels (RGBA): extract alpha channel (existing behavior)
- 3 channels (RGB/BGR): convert to grayscale

Also fixes the shape[0]/shape[1] swap in the array slice that breaks
non-square images (related to #6802, #7863).
2026-03-16 06:46:31 -06:00
ryzendigo
bf2dcfd622
fix: reset active_cameras to set() not list in error handler (#22467)
In BirdsEyeFrameManager.update(), the exception handler on line 756
resets self.active_cameras to [] (a list), but it is initialized as
set() and compared as a set throughout the rest of the code.

Since set() \!= [] evaluates to True even though both are empty, the
next call to update_frame() will incorrectly detect a layout change
and trigger an unnecessary frame rebuild after every exception.
2026-03-16 06:44:26 -06:00
ryzendigo
09df43a675
fix: return ValueError should be raise ValueError (#22474)
escape_special_characters() returns a ValueError object instead of
raising it when the input path exceeds 1000 characters. The exception
object gets used as a string downstream instead of triggering error
handling.
2026-03-16 06:43:56 -06:00
ryzendigo
dfc6ff9202
fix: variable shadowing silently drops object label updates (#22472)
When an existing tracked object's label or stationary status changes
(e.g. sub_label assignment from face recognition), the update handler
declares a new const newObjects that shadows the outer let newObjects.

The label and stationary mutations apply to the inner copy, but
handleSetObjects on line 148 reads the outer variable which was never
mutated. The update is silently discarded.

Remove the inner declaration so mutations apply to the outer variable
that gets passed to handleSetObjects.
2026-03-16 06:43:25 -06:00
ryzendigo
bc29c4ba71
fix: off-by-one error in GpuSelector.get_gpu_arg (#22464)
gpu <= len(self._valid_gpus) should be gpu < len(self._valid_gpus).

The list is zero-indexed, so requesting gpu index equal to the list
length causes an IndexError. For example, with 2 valid GPUs (indices
0 and 1), requesting gpu=2 passes the check (2 <= 2) but
self._valid_gpus[2] is out of bounds.
2026-03-16 06:42:03 -06:00
7 changed files with 29 additions and 15 deletions

View File

@ -95,7 +95,8 @@ class EventCleanup(threading.Thread):
.namedtuples()
.iterator()
)
logger.debug(f"{len(list(expired_events))} events can be expired")
expired_events = list(expired_events)
logger.debug(f"{len(expired_events)} events can be expired")
# delete the media from disk
for expired in expired_events:
@ -220,7 +221,8 @@ class EventCleanup(threading.Thread):
.namedtuples()
.iterator()
)
logger.debug(f"{len(list(expired_events))} events can be expired")
expired_events = list(expired_events)
logger.debug(f"{len(expired_events)} events can be expired")
# delete the media from disk
for expired in expired_events:
media_name = f"{expired.camera}-{expired.id}"

View File

@ -63,7 +63,7 @@ class LibvaGpuSelector:
if not self._valid_gpus:
return ""
if gpu <= len(self._valid_gpus):
if gpu < len(self._valid_gpus):
return self._valid_gpus[gpu]
else:
logger.warning(f"Invalid GPU index {gpu}, using first valid GPU")
@ -278,7 +278,7 @@ def parse_preset_hardware_acceleration_encode(
arg_map = PRESETS_HW_ACCEL_ENCODE_TIMELAPSE
if not isinstance(arg, str):
return arg_map["default"].format(input, output)
return arg_map["default"].format(ffmpeg_path, input, output)
# Not all jetsons have HW encoders, so fall back to default SW encoder if not
if arg.startswith("preset-jetson-") and not os.path.exists("/dev/nvhost-msenc"):
@ -436,7 +436,7 @@ def parse_preset_input(arg: Any, detect_fps: int) -> list[str]:
if arg == "preset-http-jpeg-generic":
input = PRESETS_INPUT[arg].copy()
input[len(_user_agent_args) + 1] = str(detect_fps)
input[1] = str(detect_fps)
return input
return PRESETS_INPUT.get(arg, None)

View File

@ -303,12 +303,20 @@ class BirdsEyeFrameManager:
birdseye_logo = cv2.imread(logo_files[0], cv2.IMREAD_UNCHANGED)
if birdseye_logo is not None:
transparent_layer = birdseye_logo[:, :, 3]
if birdseye_logo.ndim == 2:
# Grayscale image (no channels) — use directly as luminance
transparent_layer = birdseye_logo
elif birdseye_logo.shape[2] >= 4:
# RGBA — use alpha channel as luminance
transparent_layer = birdseye_logo[:, :, 3]
else:
# RGB or other format without alpha — convert to grayscale
transparent_layer = cv2.cvtColor(birdseye_logo, cv2.COLOR_BGR2GRAY)
y_offset = height // 2 - transparent_layer.shape[0] // 2
x_offset = width // 2 - transparent_layer.shape[1] // 2
self.blank_frame[
y_offset : y_offset + transparent_layer.shape[1],
x_offset : x_offset + transparent_layer.shape[0],
y_offset : y_offset + transparent_layer.shape[0],
x_offset : x_offset + transparent_layer.shape[1],
] = transparent_layer
else:
logger.warning("Unable to read Frigate logo")
@ -753,7 +761,7 @@ class BirdsEyeFrameManager:
frame_changed, layout_changed = self.update_frame(frame)
except Exception:
frame_changed, layout_changed = False, False
self.active_cameras = []
self.active_cameras = set()
self.camera_layout = []
print(traceback.format_exc())

View File

@ -901,7 +901,7 @@ class PtzAutoTracker:
# Check direction difference
velocities = np.round(velocities)
invalid_dirs = False
if not np.any(np.linalg.norm(velocities, axis=1)):
if np.all(np.linalg.norm(velocities, axis=1)):
cosine_sim = np.dot(velocities[0], velocities[1]) / (
np.linalg.norm(velocities[0]) * np.linalg.norm(velocities[1])
)
@ -1067,7 +1067,7 @@ class PtzAutoTracker:
f"{camera}: Zoom test: below dimension threshold: {below_dimension_threshold} width: {bb_right - bb_left}, max width: {camera_width * (self.zoom_factor[camera] + 0.1)}, height: {bb_bottom - bb_top}, max height: {camera_height * (self.zoom_factor[camera] + 0.1)}"
)
logger.debug(
f"{camera}: Zoom test: below velocity threshold: {below_velocity_threshold} velocity x: {abs(average_velocity[0])}, x threshold: {velocity_threshold_x}, velocity y: {abs(average_velocity[0])}, y threshold: {velocity_threshold_y}"
f"{camera}: Zoom test: below velocity threshold: {below_velocity_threshold} velocity x: {abs(average_velocity[0])}, x threshold: {velocity_threshold_x}, velocity y: {abs(average_velocity[1])}, y threshold: {velocity_threshold_y}"
)
logger.debug(f"{camera}: Zoom test: at max zoom: {at_max_zoom}")
logger.debug(f"{camera}: Zoom test: at min zoom: {at_min_zoom}")

View File

@ -116,7 +116,7 @@ def clean_camera_user_pass(line: str) -> str:
def escape_special_characters(path: str) -> str:
"""Cleans reserved characters to encodings for ffmpeg."""
if len(path) > 1000:
return ValueError("Input too long to check")
raise ValueError("Input too long to check")
try:
found = re.search(REGEX_RTSP_CAMERA_USER_PASS, path).group(0)[3:-1]

View File

@ -52,6 +52,7 @@ export default function WebRtcPlayer({
// camera states
const pcRef = useRef<RTCPeerConnection | undefined>(undefined);
const wsRef = useRef<WebSocket | null>(null);
const videoRef = useRef<HTMLVideoElement | null>(null);
const [bufferTimeout, setBufferTimeout] = useState<NodeJS.Timeout>();
const videoLoadTimeoutRef = useRef<NodeJS.Timeout>(undefined);
@ -129,7 +130,8 @@ export default function WebRtcPlayer({
}
pcRef.current = await aPc;
const ws = new WebSocket(wsURL);
wsRef.current = new WebSocket(wsURL);
const ws = wsRef.current;
ws.addEventListener("open", () => {
pcRef.current?.addEventListener("icecandidate", (ev) => {
@ -183,6 +185,10 @@ export default function WebRtcPlayer({
connect(aPc);
return () => {
if (wsRef.current) {
wsRef.current.close();
wsRef.current = null;
}
if (pcRef.current) {
pcRef.current.close();
pcRef.current = undefined;

View File

@ -125,8 +125,6 @@ export function useCameraActivity(
newObjects = [...(objects ?? []), newActiveObject];
}
} else {
const newObjects = [...(objects ?? [])];
let label = updatedEvent.after.label;
if (updatedEvent.after.sub_label) {