diff --git a/frigate/api/app.py b/frigate/api/app.py index 498094ff8..0126e5d03 100644 --- a/frigate/api/app.py +++ b/frigate/api/app.py @@ -613,6 +613,34 @@ def config_set(request: Request, body: AppConfigSetBody): try: config = FrigateConfig.parse(new_raw_config) + except ValidationError as e: + with open(config_file, "w") as f: + f.write(old_raw_config) + f.close() + logger.error( + f"Config Validation Error:\n\n{str(traceback.format_exc())}" + ) + error_messages = [] + for err in e.errors(): + msg = err.get("msg", "") + # Strip pydantic "Value error, " prefix for cleaner display + if msg.startswith("Value error, "): + msg = msg[len("Value error, ") :] + error_messages.append(msg) + message = ( + "; ".join(error_messages) + if error_messages + else "Check logs for error message." + ) + return JSONResponse( + content=( + { + "success": False, + "message": f"Error saving config: {message}", + } + ), + status_code=400, + ) except Exception: with open(config_file, "w") as f: f.write(old_raw_config) diff --git a/frigate/config/camera/detect.py b/frigate/config/camera/detect.py index c0a2e7036..35c337bc8 100644 --- a/frigate/config/camera/detect.py +++ b/frigate/config/camera/detect.py @@ -71,6 +71,7 @@ class DetectConfig(FrigateBaseModel): default=None, title="Minimum initialization frames", description="Number of consecutive detection hits required before creating a tracked object. Increase to reduce false initializations. Default value is fps divided by 2.", + ge=2, ) max_disappeared: Optional[int] = Field( default=None, diff --git a/frigate/config/config.py b/frigate/config/config.py index d1b037509..1d09016f6 100644 --- a/frigate/config/config.py +++ b/frigate/config/config.py @@ -614,6 +614,21 @@ class FrigateConfig(FrigateBaseModel): if self.ffmpeg.hwaccel_args == "auto": self.ffmpeg.hwaccel_args = auto_detect_hwaccel() + # Populate global audio filters for all audio labels + all_audio_labels = { + label + for label in load_labels("/audio-labelmap.txt", prefill=521).values() + if label + } + + if self.audio.filters is None: + self.audio.filters = {} + + for key in sorted(all_audio_labels - self.audio.filters.keys()): + self.audio.filters[key] = AudioFilterConfig() + + self.audio.filters = dict(sorted(self.audio.filters.items())) + # Global config to propagate down to camera level global_config = self.model_dump( include={ @@ -672,12 +687,6 @@ class FrigateConfig(FrigateBaseModel): detector_config.model = model self.detectors[key] = detector_config - all_audio_labels = { - label - for label in load_labels("/audio-labelmap.txt", prefill=521).values() - if label - } - for name, camera in self.cameras.items(): modified_global_config = global_config.copy() @@ -755,7 +764,7 @@ class FrigateConfig(FrigateBaseModel): ) # Default min_initialized configuration - min_initialized = int(camera_config.detect.fps / 2) + min_initialized = max(int(camera_config.detect.fps / 2), 2) if camera_config.detect.min_initialized is None: camera_config.detect.min_initialized = min_initialized @@ -801,11 +810,13 @@ class FrigateConfig(FrigateBaseModel): if camera_config.audio.filters is None: camera_config.audio.filters = {} - audio_keys = all_audio_labels - audio_keys = audio_keys - camera_config.audio.filters.keys() - for key in audio_keys: + for key in sorted(all_audio_labels - camera_config.audio.filters.keys()): camera_config.audio.filters[key] = AudioFilterConfig() + camera_config.audio.filters = dict( + sorted(camera_config.audio.filters.items()) + ) + # Add default filters object_keys = camera_config.objects.track if camera_config.objects.filters is None: diff --git a/frigate/detectors/detector_config.py b/frigate/detectors/detector_config.py index 22623c7d7..5071e3a74 100644 --- a/frigate/detectors/detector_config.py +++ b/frigate/detectors/detector_config.py @@ -47,7 +47,7 @@ class ModelTypeEnum(str, Enum): class ModelConfig(BaseModel): path: Optional[str] = Field( None, - title="Custom Object detection model path", + title="Custom object detector model path", description="Path to a custom detection model file (or plus:// for Frigate+ models).", ) labelmap_path: Optional[str] = Field( diff --git a/frigate/ffmpeg_presets.py b/frigate/ffmpeg_presets.py index 4ebbd6c80..c314b30ea 100644 --- a/frigate/ffmpeg_presets.py +++ b/frigate/ffmpeg_presets.py @@ -465,6 +465,16 @@ PRESETS_RECORD_OUTPUT = { "-c:a", "aac", ], + # NOTE: This preset originally used "-c:a copy" to pass through audio + # without re-encoding. FFmpeg 7.x introduced a threaded pipeline where + # demuxing, encoding, and muxing run in parallel via a Scheduler. This + # broke audio streamcopy from RTSP sources: packets are demuxed correctly + # but silently dropped before reaching the muxer (0 bytes written). The + # issue is specific to RTSP + streamcopy; file inputs and transcoding both + # work. Transcoding AAC audio is very lightweight (~30KiB per 10s segment) + # and adds negligible CPU overhead, so this is an acceptable workaround. + # The benefits of FFmpeg 7.x — particularly the removal of gamma correction + # hacks required by earlier versions — outweigh this trade-off. "preset-record-generic-audio-copy": [ "-f", "segment", @@ -476,8 +486,10 @@ PRESETS_RECORD_OUTPUT = { "1", "-strftime", "1", - "-c", + "-c:v", "copy", + "-c:a", + "aac", ], "preset-record-mjpeg": [ "-f", diff --git a/web/public/locales/en/config/global.json b/web/public/locales/en/config/global.json index 7ca564ee6..e5235f9cb 100644 --- a/web/public/locales/en/config/global.json +++ b/web/public/locales/en/config/global.json @@ -293,7 +293,7 @@ "label": "Detector specific model configuration", "description": "Detector-specific model configuration options (path, input size, etc.).", "path": { - "label": "Custom Object detection model path", + "label": "Custom object detector model path", "description": "Path to a custom detection model file (or plus:// for Frigate+ models)." }, "labelmap_path": { @@ -466,7 +466,7 @@ "label": "Detection model", "description": "Settings to configure a custom object detection model and its input shape.", "path": { - "label": "Custom Object detection model path", + "label": "Custom object detector model path", "description": "Path to a custom detection model file (or plus:// for Frigate+ models)." }, "labelmap_path": { diff --git a/web/public/locales/en/views/settings.json b/web/public/locales/en/views/settings.json index e5c2c7851..d9abb427d 100644 --- a/web/public/locales/en/views/settings.json +++ b/web/public/locales/en/views/settings.json @@ -1521,6 +1521,8 @@ "noOverrides": "No overrides", "cameraCount_one": "{{count}} camera", "cameraCount_other": "{{count}} cameras", + "columnCamera": "Camera", + "columnOverrides": "Profile Overrides", "baseConfig": "Base Config", "addProfile": "Add Profile", "newProfile": "New Profile", diff --git a/web/src/components/config-form/section-configs/record.ts b/web/src/components/config-form/section-configs/record.ts index 9cfc92127..05a21f224 100644 --- a/web/src/components/config-form/section-configs/record.ts +++ b/web/src/components/config-form/section-configs/record.ts @@ -23,7 +23,7 @@ const record: SectionConfigOverrides = { uiSchema: { export: { hwaccel_args: { - "ui:options": { size: "lg" }, + "ui:options": { suppressMultiSchema: true, size: "lg" }, }, }, }, diff --git a/web/src/components/config-form/section-configs/review.ts b/web/src/components/config-form/section-configs/review.ts index 7d7a84756..d1909e926 100644 --- a/web/src/components/config-form/section-configs/review.ts +++ b/web/src/components/config-form/section-configs/review.ts @@ -29,9 +29,10 @@ const review: SectionConfigOverrides = { }, genai: { additional_concerns: { - "ui:widget": "textarea", + "ui:widget": "ArrayAsTextWidget", "ui:options": { size: "full", + multiline: true, }, }, activity_context_prompt: { diff --git a/web/src/components/config-form/sections/BaseSection.tsx b/web/src/components/config-form/sections/BaseSection.tsx index 8ff5daa4f..8a35f4dff 100644 --- a/web/src/components/config-form/sections/BaseSection.tsx +++ b/web/src/components/config-form/sections/BaseSection.tsx @@ -152,6 +152,10 @@ export interface BaseSectionProps { profileBorderColor?: string; /** Callback to delete the current profile's overrides for this section */ onDeleteProfileSection?: () => void; + /** Whether a SaveAll operation is in progress (disables individual Save) */ + isSavingAll?: boolean; + /** Callback when this section's saving state changes */ + onSavingChange?: (isSaving: boolean) => void; } export interface CreateSectionOptions { @@ -186,6 +190,8 @@ export function ConfigSection({ profileFriendlyName, profileBorderColor, onDeleteProfileSection, + isSavingAll = false, + onSavingChange, }: ConfigSectionProps) { // For replay level, treat as camera-level config access const effectiveLevel = level === "replay" ? "camera" : level; @@ -246,6 +252,7 @@ export function ConfigSection({ [onPendingDataChange, effectiveSectionPath, cameraName], ); const [isSaving, setIsSaving] = useState(false); + const [isResettingToDefault, setIsResettingToDefault] = useState(false); const [hasValidationErrors, setHasValidationErrors] = useState(false); const [extraHasChanges, setExtraHasChanges] = useState(false); const [formKey, setFormKey] = useState(0); @@ -577,6 +584,7 @@ export function ConfigSection({ if (!pendingData) return; setIsSaving(true); + onSavingChange?.(true); try { const basePath = effectiveLevel === "camera" && cameraName @@ -699,6 +707,7 @@ export function ConfigSection({ } } finally { setIsSaving(false); + onSavingChange?.(false); } }, [ sectionPath, @@ -718,12 +727,14 @@ export function ConfigSection({ setPendingData, requiresRestartForOverrides, skipSave, + onSavingChange, ]); // Handle reset to global/defaults - removes camera-level override or resets global to defaults const handleResetToGlobal = useCallback(async () => { if (effectiveLevel === "camera" && !cameraName) return; + setIsResettingToDefault(true); try { const basePath = effectiveLevel === "camera" && cameraName @@ -758,6 +769,8 @@ export function ConfigSection({ defaultValue: "Failed to reset settings", }), ); + } finally { + setIsResettingToDefault(false); } }, [ effectiveSectionPath, @@ -945,9 +958,12 @@ export function ConfigSection({