Miscellaneous fixes (#23335)
Some checks are pending
CI / AMD64 Build (push) Waiting to run
CI / ARM Build (push) Waiting to run
CI / Jetson Jetpack 6 (push) Waiting to run
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

* stabilize chart options to stop ApexCharts updateOptions running on every stats tick

* constrain height of export dialog

* stop audio maintainer when deleting a camera

* run face register and recognize API handlers in threadpool
This commit is contained in:
Josh Hawkins 2026-05-29 07:53:17 -05:00 committed by GitHub
parent bc65713ae4
commit 4b6fa49449
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 96 additions and 41 deletions

View File

@ -280,7 +280,7 @@ async def create_face(request: Request, name: str):
success response with details about the registration, or an error if face recognition success response with details about the registration, or an error if face recognition
is not enabled or the image cannot be processed.""", is not enabled or the image cannot be processed.""",
) )
async def register_face(request: Request, name: str, file: UploadFile): def register_face(request: Request, name: str, file: UploadFile):
if not request.app.frigate_config.face_recognition.enabled: if not request.app.frigate_config.face_recognition.enabled:
return JSONResponse( return JSONResponse(
status_code=400, status_code=400,
@ -288,7 +288,7 @@ async def register_face(request: Request, name: str, file: UploadFile):
) )
context: EmbeddingsContext = request.app.embeddings context: EmbeddingsContext = request.app.embeddings
result = None if context is None else context.register_face(name, await file.read()) result = None if context is None else context.register_face(name, file.file.read())
if not isinstance(result, dict): if not isinstance(result, dict):
return JSONResponse( return JSONResponse(
@ -313,7 +313,7 @@ async def register_face(request: Request, name: str, file: UploadFile):
registered faces in the system. Returns the recognized face name and confidence score, registered faces in the system. Returns the recognized face name and confidence score,
or an error if face recognition is not enabled or the image cannot be processed.""", or an error if face recognition is not enabled or the image cannot be processed.""",
) )
async def recognize_face(request: Request, file: UploadFile): def recognize_face(request: Request, file: UploadFile):
if not request.app.frigate_config.face_recognition.enabled: if not request.app.frigate_config.face_recognition.enabled:
return JSONResponse( return JSONResponse(
status_code=400, status_code=400,
@ -321,7 +321,7 @@ async def recognize_face(request: Request, file: UploadFile):
) )
context: EmbeddingsContext = request.app.embeddings context: EmbeddingsContext = request.app.embeddings
result = context.recognize_face(await file.read()) result = context.recognize_face(file.file.read())
if not isinstance(result, dict): if not isinstance(result, dict):
return JSONResponse( return JSONResponse(

View File

@ -94,9 +94,21 @@ class AudioProcessor(FrigateProcess):
self.camera_metrics = camera_metrics self.camera_metrics = camera_metrics
self.config = config self.config = config
def __stop_audio_thread(self, camera: str) -> None:
thread = self.audio_threads.pop(camera, None)
if thread is None:
return
thread.stop()
thread.join(10)
if thread.is_alive():
self.logger.warning(f"Audio maintainer thread for {camera} is still alive")
else:
self.logger.info(f"Audio maintainer stopped for {camera}")
def run(self) -> None: def run(self) -> None:
self.pre_run_setup(self.config.logger) self.pre_run_setup(self.config.logger)
audio_threads: dict[str, AudioEventMaintainer] = {} self.audio_threads: dict[str, AudioEventMaintainer] = {}
threading.current_thread().name = "process:audio_manager" threading.current_thread().name = "process:audio_manager"
@ -120,12 +132,13 @@ class AudioProcessor(FrigateProcess):
CameraConfigUpdateEnum.add, CameraConfigUpdateEnum.add,
CameraConfigUpdateEnum.audio, CameraConfigUpdateEnum.audio,
CameraConfigUpdateEnum.ffmpeg, CameraConfigUpdateEnum.ffmpeg,
CameraConfigUpdateEnum.remove,
], ],
) )
def spawn_if_needed(camera: CameraConfig) -> None: def spawn_if_needed(camera: CameraConfig) -> None:
name = camera.name name = camera.name
if name is None or name in audio_threads: if name is None or name in self.audio_threads:
return return
if not camera.enabled or not camera.audio.enabled: if not camera.enabled or not camera.audio.enabled:
return return
@ -139,7 +152,7 @@ class AudioProcessor(FrigateProcess):
self.transcription_model_runner, self.transcription_model_runner,
self.stop_event, # type: ignore[arg-type] self.stop_event, # type: ignore[arg-type]
) )
audio_threads[name] = thread self.audio_threads[name] = thread
thread.start() thread.start()
self.logger.info(f"Audio maintainer started for {name}") self.logger.info(f"Audio maintainer started for {name}")
@ -148,21 +161,31 @@ class AudioProcessor(FrigateProcess):
self.logger.info(f"Audio processor started (pid: {self.pid})") self.logger.info(f"Audio processor started (pid: {self.pid})")
# poll for newly added cameras or cameras flipped to audio.enabled at runtime # poll for newly added/removed cameras or cameras flipped to
# audio.enabled at runtime
while not self.stop_event.wait(timeout=1.0): while not self.stop_event.wait(timeout=1.0):
config_subscriber.check_for_updates() updated_topics = config_subscriber.check_for_updates()
# stop maintainers for removed cameras so their ffmpeg process is
# torn down and they stop touching camera_metrics (which the camera
# maintainer has already popped for the removed camera)
for removed_camera in updated_topics.get(
CameraConfigUpdateEnum.remove.name, []
):
self.__stop_audio_thread(removed_camera)
for camera in self.config.cameras.values(): for camera in self.config.cameras.values():
spawn_if_needed(camera) spawn_if_needed(camera)
config_subscriber.stop() config_subscriber.stop()
for thread in audio_threads.values(): for thread in self.audio_threads.values():
thread.join(1) thread.join(1)
if thread.is_alive(): if thread.is_alive():
self.logger.info(f"Waiting for thread {thread.name:s} to exit") self.logger.info(f"Waiting for thread {thread.name:s} to exit")
thread.join(10) thread.join(10)
for thread in audio_threads.values(): for thread in self.audio_threads.values():
if thread.is_alive(): if thread.is_alive():
self.logger.warning(f"Thread {thread.name} is still alive") self.logger.warning(f"Thread {thread.name} is still alive")
@ -184,6 +207,9 @@ class AudioEventMaintainer(threading.Thread):
self.camera_config = camera self.camera_config = camera
self.camera_metrics = camera_metrics self.camera_metrics = camera_metrics
self.stop_event = stop_event self.stop_event = stop_event
# per-camera stop signal so a single maintainer can be torn down at
# runtime (e.g. on camera removal) without stopping the whole process
self.camera_stop_event = threading.Event()
self.detector = AudioTfl(stop_event, self.camera_config.audio.num_threads) self.detector = AudioTfl(stop_event, self.camera_config.audio.num_threads)
self.shape = (int(round(AUDIO_DURATION * AUDIO_SAMPLE_RATE)),) self.shape = (int(round(AUDIO_DURATION * AUDIO_SAMPLE_RATE)),)
self.chunk_size = int(round(AUDIO_DURATION * AUDIO_SAMPLE_RATE * 2)) self.chunk_size = int(round(AUDIO_DURATION * AUDIO_SAMPLE_RATE * 2))
@ -233,7 +259,11 @@ class AudioEventMaintainer(threading.Thread):
self.was_audio_enabled = camera.audio.enabled self.was_audio_enabled = camera.audio.enabled
def detect_audio(self, audio: np.ndarray) -> None: def detect_audio(self, audio: np.ndarray) -> None:
if not self.camera_config.audio.enabled or self.stop_event.is_set(): if (
not self.camera_config.audio.enabled
or self.stop_event.is_set()
or self.camera_stop_event.is_set()
):
return return
audio_as_float: np.ndarray = audio.astype(np.float32) audio_as_float: np.ndarray = audio.astype(np.float32)
@ -352,11 +382,15 @@ class AudioEventMaintainer(threading.Thread):
self.logger.error(f"Error reading audio data from ffmpeg process: {e}") self.logger.error(f"Error reading audio data from ffmpeg process: {e}")
log_and_restart() log_and_restart()
def stop(self) -> None:
"""Signal this maintainer to exit its run loop and clean up."""
self.camera_stop_event.set()
def run(self) -> None: def run(self) -> None:
if self.camera_config.enabled: if self.camera_config.enabled:
self.start_or_restart_ffmpeg() self.start_or_restart_ffmpeg()
while not self.stop_event.is_set(): while not self.stop_event.is_set() and not self.camera_stop_event.is_set():
# check if there is an updated config # check if there is an updated config
self.config_subscriber.check_for_updates() self.config_subscriber.check_for_updates()

View File

@ -56,18 +56,25 @@ export function CameraLineGraph({
}); });
}, [t, timeFormat]); }, [t, timeFormat]);
const updateTimesRef = useRef(updateTimes);
useEffect(() => {
updateTimesRef.current = updateTimes;
}, [updateTimes]);
const formatTime = useCallback( const formatTime = useCallback(
(val: unknown) => { (val: unknown) => {
return formatUnixTimestampToDateTime( const times = updateTimesRef.current;
updateTimes[Math.round(val as number)], const ts = times[Math.round(val as number)];
{ if (isNaN(ts)) {
timezone: config?.ui.timezone, return "";
date_format: format, }
locale, return formatUnixTimestampToDateTime(ts, {
}, timezone: config?.ui.timezone,
); date_format: format,
locale,
});
}, },
[config?.ui.timezone, format, locale, updateTimes], [config?.ui.timezone, format, locale],
); );
const options = useMemo(() => { const options = useMemo(() => {
@ -211,18 +218,25 @@ export function EventsPerSecondsLineGraph({
}); });
}, [t, timeFormat]); }, [t, timeFormat]);
const updateTimesRef = useRef(updateTimes);
useEffect(() => {
updateTimesRef.current = updateTimes;
}, [updateTimes]);
const formatTime = useCallback( const formatTime = useCallback(
(val: unknown) => { (val: unknown) => {
return formatUnixTimestampToDateTime( const times = updateTimesRef.current;
updateTimes[Math.round(val as number) - 1], const ts = times[Math.round(val as number) - 1];
{ if (isNaN(ts)) {
timezone: config?.ui.timezone, return "";
date_format: format, }
locale, return formatUnixTimestampToDateTime(ts, {
}, timezone: config?.ui.timezone,
); date_format: format,
locale,
});
}, },
[config?.ui.timezone, format, locale, updateTimes], [config?.ui.timezone, format, locale],
); );
const options = useMemo(() => { const options = useMemo(() => {

View File

@ -61,6 +61,11 @@ export function ThresholdBarGraph({
}); });
}, [t, timeFormat]); }, [t, timeFormat]);
const updateTimesRef = useRef(updateTimes);
useEffect(() => {
updateTimesRef.current = updateTimes;
}, [updateTimes]);
const formatTime = useCallback( const formatTime = useCallback(
(val: unknown) => { (val: unknown) => {
const dateIndex = Math.round(val as number); const dateIndex = Math.round(val as number);
@ -69,16 +74,18 @@ export function ThresholdBarGraph({
if (dateIndex < 0) { if (dateIndex < 0) {
timeOffset = 5 * Math.abs(dateIndex); timeOffset = 5 * Math.abs(dateIndex);
} }
return formatUnixTimestampToDateTime( const times = updateTimesRef.current;
updateTimes[Math.max(1, dateIndex) - 1] - timeOffset, const ts = times[Math.max(1, dateIndex) - 1] - timeOffset;
{ if (isNaN(ts)) {
timezone: config?.ui.timezone, return "";
date_format: format, }
locale, return formatUnixTimestampToDateTime(ts, {
}, timezone: config?.ui.timezone,
); date_format: format,
locale,
});
}, },
[config?.ui.timezone, format, locale, updateTimes], [config?.ui.timezone, format, locale],
); );
const options = useMemo(() => { const options = useMemo(() => {

View File

@ -287,7 +287,7 @@ export default function ExportDialog({
<Content <Content
className={ className={
isDesktop isDesktop
? "sm:rounded-lg md:rounded-2xl" ? "scrollbar-container max-h-[90dvh] overflow-y-auto sm:rounded-lg md:rounded-2xl"
: "mx-4 rounded-lg px-4 pb-4 md:rounded-2xl" : "mx-4 rounded-lg px-4 pb-4 md:rounded-2xl"
} }
> >