From 119ce4b0392d0070db958729899e3d5ae687b5fb Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Sun, 1 Mar 2026 07:13:14 -0600 Subject: [PATCH] add ability to edit motion and object config for debug replay --- frigate/api/app.py | 94 ++++++++- frigate/api/defs/request/app_body.py | 1 + frigate/util/config.py | 76 ++++++++ web/public/locales/en/common.json | 1 + web/public/locales/en/views/replay.json | 3 +- web/public/locales/en/views/settings.json | 1 + .../config-form/section-configs/motion.ts | 24 +++ .../config-form/section-configs/objects.ts | 22 +++ .../config-form/section-configs/types.ts | 1 + .../config-form/sections/BaseSection.tsx | 123 +++++++----- web/src/pages/Replay.tsx | 183 ++++++++++++------ web/src/utils/configUtil.ts | 9 +- 12 files changed, 429 insertions(+), 109 deletions(-) diff --git a/frigate/api/app.py b/frigate/api/app.py index 04d1c2238..a28f174de 100644 --- a/frigate/api/app.py +++ b/frigate/api/app.py @@ -49,12 +49,13 @@ from frigate.stats.prometheus import get_metrics, update_metrics from frigate.types import JobStatusTypesEnum from frigate.util.builtin import ( clean_camera_user_pass, + deep_merge, flatten_config_data, load_labels, process_config_query_string, update_yaml_file_bulk, ) -from frigate.util.config import find_config_file +from frigate.util.config import apply_section_update, find_config_file from frigate.util.schema import get_config_schema from frigate.util.services import ( get_nvidia_driver_info, @@ -422,9 +423,100 @@ def config_save(save_option: str, body: Any = Body(media_type="text/plain")): ) +def _config_set_in_memory(request: Request, body: AppConfigSetBody) -> JSONResponse: + """Apply config changes in-memory only, without writing to YAML. + + Used for temporary config changes like debug replay camera tuning. + Updates the in-memory Pydantic config and publishes ZMQ updates, + bypassing YAML parsing entirely. + """ + try: + updates = {} + if body.config_data: + updates = flatten_config_data(body.config_data) + updates = {k: ("" if v is None else v) for k, v in updates.items()} + + if not updates: + return JSONResponse( + content={"success": False, "message": "No configuration data provided"}, + status_code=400, + ) + + config: FrigateConfig = request.app.frigate_config + + # Group flat key paths into nested per-camera, per-section dicts + grouped: dict[str, dict[str, dict]] = {} + for key_path, value in updates.items(): + parts = key_path.split(".") + if len(parts) < 3 or parts[0] != "cameras": + continue + + cam, section = parts[1], parts[2] + grouped.setdefault(cam, {}).setdefault(section, {}) + + # Build nested dict from remaining path (e.g. "filters.person.threshold") + target = grouped[cam][section] + for part in parts[3:-1]: + target = target.setdefault(part, {}) + if len(parts) > 3: + target[parts[-1]] = value + elif isinstance(value, dict): + grouped[cam][section] = deep_merge( + grouped[cam][section], value, override=True + ) + else: + grouped[cam][section] = value + + # Apply each section update + for cam_name, sections in grouped.items(): + camera_config = config.cameras.get(cam_name) + if not camera_config: + return JSONResponse( + content={ + "success": False, + "message": f"Camera '{cam_name}' not found", + }, + status_code=400, + ) + + for section_name, update in sections.items(): + err = apply_section_update(camera_config, section_name, update) + if err is not None: + return JSONResponse( + content={"success": False, "message": err}, + status_code=400, + ) + + # Publish ZMQ updates so processing threads pick up changes + if body.update_topic and body.update_topic.startswith("config/cameras/"): + _, _, camera, field = body.update_topic.split("/") + settings = getattr(config.cameras.get(camera, None), field, None) + + if settings is not None: + request.app.config_publisher.publish_update( + CameraConfigUpdateTopic(CameraConfigUpdateEnum[field], camera), + settings, + ) + + return JSONResponse( + content={"success": True, "message": "Config applied in-memory"}, + status_code=200, + ) + except Exception as e: + logger.error(f"Error applying config in-memory: {e}") + return JSONResponse( + content={"success": False, "message": "Error applying config"}, + status_code=500, + ) + + @router.put("/config/set", dependencies=[Depends(require_role(["admin"]))]) def config_set(request: Request, body: AppConfigSetBody): config_file = find_config_file() + + if body.skip_save: + return _config_set_in_memory(request, body) + lock = FileLock(f"{config_file}.lock", timeout=5) try: diff --git a/frigate/api/defs/request/app_body.py b/frigate/api/defs/request/app_body.py index 6059daf6e..3d2ab5961 100644 --- a/frigate/api/defs/request/app_body.py +++ b/frigate/api/defs/request/app_body.py @@ -7,6 +7,7 @@ class AppConfigSetBody(BaseModel): requires_restart: int = 1 update_topic: str | None = None config_data: Optional[Dict[str, Any]] = None + skip_save: bool = False class AppPutPasswordBody(BaseModel): diff --git a/frigate/util/config.py b/frigate/util/config.py index c689d16e4..238671563 100644 --- a/frigate/util/config.py +++ b/frigate/util/config.py @@ -9,6 +9,7 @@ from typing import Any, Optional, Union from ruamel.yaml import YAML from frigate.const import CONFIG_DIR, EXPORT_DIR +from frigate.util.builtin import deep_merge from frigate.util.services import get_video_properties logger = logging.getLogger(__name__) @@ -688,3 +689,78 @@ class StreamInfoRetriever: info = asyncio.run(get_video_properties(ffmpeg, path)) self.stream_cache[path] = info return info + + +def apply_section_update(camera_config, section: str, update: dict) -> Optional[str]: + """Merge an update dict into a camera config section and rebuild runtime variants. + + For motion and object filter sections, the plain Pydantic models are rebuilt + as RuntimeMotionConfig / RuntimeFilterConfig so that rasterized numpy masks + are recomputed. This mirrors the logic in FrigateConfig.post_validation. + + Args: + camera_config: The CameraConfig instance to update. + section: Config section name (e.g. "motion", "objects"). + update: Nested dict of field updates to merge. + + Returns: + None on success, or an error message string on failure. + """ + from frigate.config.config import RuntimeFilterConfig, RuntimeMotionConfig + + current = getattr(camera_config, section, None) + if current is None: + return f"Section '{section}' not found on camera '{camera_config.name}'" + + try: + frame_shape = camera_config.frame_shape + + if section == "motion": + merged = deep_merge( + current.model_dump(exclude_unset=True, exclude={"rasterized_mask"}), + update, + override=True, + ) + camera_config.motion = RuntimeMotionConfig( + frame_shape=frame_shape, **merged + ) + + elif section == "objects": + merged = deep_merge( + current.model_dump( + exclude={"filters": {"__all__": {"rasterized_mask"}}} + ), + update, + override=True, + ) + new_objects = current.__class__.model_validate(merged) + + # Preserve private _all_objects from original config + try: + new_objects._all_objects = current._all_objects + except AttributeError: + pass + + # Rebuild RuntimeFilterConfig with merged global + per-object masks + for obj_name, filt in new_objects.filters.items(): + merged_mask = dict(filt.mask) + if new_objects.mask: + for gid, gmask in new_objects.mask.items(): + merged_mask[f"global_{gid}"] = gmask + + new_objects.filters[obj_name] = RuntimeFilterConfig( + frame_shape=frame_shape, + mask=merged_mask, + **filt.model_dump(exclude_unset=True, exclude={"mask", "raw_mask"}), + ) + camera_config.objects = new_objects + + else: + merged = deep_merge(current.model_dump(), update, override=True) + setattr(camera_config, section, current.__class__.model_validate(merged)) + + except Exception: + logger.exception("Config validation error") + return "Validation error. Check logs for details." + + return None diff --git a/web/public/locales/en/common.json b/web/public/locales/en/common.json index b28b88218..37566117a 100644 --- a/web/public/locales/en/common.json +++ b/web/public/locales/en/common.json @@ -117,6 +117,7 @@ "button": { "add": "Add", "apply": "Apply", + "applying": "Applying…", "reset": "Reset", "undo": "Undo", "done": "Done", diff --git a/web/public/locales/en/views/replay.json b/web/public/locales/en/views/replay.json index a1fdeedad..a966626f5 100644 --- a/web/public/locales/en/views/replay.json +++ b/web/public/locales/en/views/replay.json @@ -48,6 +48,7 @@ "noActivity": "No activity detected", "activeTracking": "Active tracking", "noActiveTracking": "No active tracking", - "configuration": "Configuration" + "configuration": "Configuration", + "configurationDesc": "Fine tune motion detection and object tracking settings for the debug replay camera. No changes are saved to your Frigate configuration file." } } diff --git a/web/public/locales/en/views/settings.json b/web/public/locales/en/views/settings.json index ca772377b..0b61b278b 100644 --- a/web/public/locales/en/views/settings.json +++ b/web/public/locales/en/views/settings.json @@ -1392,6 +1392,7 @@ }, "toast": { "success": "Settings saved successfully", + "applied": "Settings applied successfully", "successRestartRequired": "Settings saved successfully. Restart Frigate to apply your changes.", "error": "Failed to save settings", "validationError": "Validation failed: {{message}}", diff --git a/web/src/components/config-form/section-configs/motion.ts b/web/src/components/config-form/section-configs/motion.ts index 0acdc0d99..41b5738d3 100644 --- a/web/src/components/config-form/section-configs/motion.ts +++ b/web/src/components/config-form/section-configs/motion.ts @@ -44,6 +44,30 @@ const motion: SectionConfigOverrides = { camera: { restartRequired: ["frame_height"], }, + replay: { + restartRequired: [], + fieldOrder: [ + "threshold", + "contour_area", + "lightning_threshold", + "improve_contrast", + ], + fieldGroups: { + sensitivity: ["threshold", "contour_area"], + algorithm: ["improve_contrast"], + }, + hiddenFields: [ + "enabled", + "enabled_in_config", + "mask", + "raw_mask", + "mqtt_off_delay", + "delta_alpha", + "frame_alpha", + "frame_height", + ], + advancedFields: ["lightning_threshold"], + }, }; export default motion; diff --git a/web/src/components/config-form/section-configs/objects.ts b/web/src/components/config-form/section-configs/objects.ts index 1dfb31053..a70746c49 100644 --- a/web/src/components/config-form/section-configs/objects.ts +++ b/web/src/components/config-form/section-configs/objects.ts @@ -99,6 +99,28 @@ const objects: SectionConfigOverrides = { camera: { restartRequired: [], }, + replay: { + restartRequired: [], + fieldOrder: ["track", "filters"], + fieldGroups: { + tracking: ["track"], + filtering: ["filters"], + }, + hiddenFields: [ + "enabled_in_config", + "alert", + "detect", + "mask", + "raw_mask", + "genai", + "genai.enabled_in_config", + "filters.*.mask", + "filters.*.raw_mask", + "filters.mask", + "filters.raw_mask", + ], + advancedFields: [], + }, }; export default objects; diff --git a/web/src/components/config-form/section-configs/types.ts b/web/src/components/config-form/section-configs/types.ts index 600a3ca50..e2b308e08 100644 --- a/web/src/components/config-form/section-configs/types.ts +++ b/web/src/components/config-form/section-configs/types.ts @@ -4,4 +4,5 @@ export type SectionConfigOverrides = { base?: SectionConfig; global?: Partial; camera?: Partial; + replay?: Partial; }; diff --git a/web/src/components/config-form/sections/BaseSection.tsx b/web/src/components/config-form/sections/BaseSection.tsx index 606919dcc..821857ae6 100644 --- a/web/src/components/config-form/sections/BaseSection.tsx +++ b/web/src/components/config-form/sections/BaseSection.tsx @@ -95,9 +95,9 @@ export interface SectionConfig { } export interface BaseSectionProps { - /** Whether this is at global or camera level */ - level: "global" | "camera"; - /** Camera name (required if level is "camera") */ + /** Whether this is at global, camera, or replay level */ + level: "global" | "camera" | "replay"; + /** Camera name (required if level is "camera" or "replay") */ cameraName?: string; /** Whether to show override indicator badge */ showOverrideIndicator?: boolean; @@ -117,6 +117,10 @@ export interface BaseSectionProps { defaultCollapsed?: boolean; /** Whether to show the section title (default: false for global, true for camera) */ showTitle?: boolean; + /** If true, apply config in-memory only without writing to YAML */ + skipSave?: boolean; + /** If true, buttons are not sticky at the bottom */ + noStickyButtons?: boolean; /** Callback when section status changes */ onStatusChange?: (status: { hasChanges: boolean; @@ -156,12 +160,16 @@ export function ConfigSection({ collapsible = false, defaultCollapsed = true, showTitle, + skipSave = false, + noStickyButtons = false, onStatusChange, pendingDataBySection, onPendingDataChange, }: ConfigSectionProps) { + // For replay level, treat as camera-level config access + const effectiveLevel = level === "replay" ? "camera" : level; const { t, i18n } = useTranslation([ - level === "camera" ? "config/cameras" : "config/global", + effectiveLevel === "camera" ? "config/cameras" : "config/global", "config/cameras", "views/settings", "common", @@ -174,10 +182,10 @@ export function ConfigSection({ // Create a key for this section's pending data const pendingDataKey = useMemo( () => - level === "camera" && cameraName + effectiveLevel === "camera" && cameraName ? `${cameraName}::${sectionPath}` : sectionPath, - [level, cameraName, sectionPath], + [effectiveLevel, cameraName, sectionPath], ); // Use pending data from parent if available, otherwise use local state @@ -222,20 +230,20 @@ export function ConfigSection({ const lastPendingDataKeyRef = useRef(null); const updateTopic = - level === "camera" && cameraName + effectiveLevel === "camera" && cameraName ? cameraUpdateTopicMap[sectionPath] ? `config/cameras/${cameraName}/${cameraUpdateTopicMap[sectionPath]}` : undefined : `config/${sectionPath}`; // Default: show title for camera level (since it might be collapsible), hide for global - const shouldShowTitle = showTitle ?? level === "camera"; + const shouldShowTitle = showTitle ?? effectiveLevel === "camera"; // Fetch config const { data: config, mutate: refreshConfig } = useSWR("config"); // Get section schema using cached hook - const sectionSchema = useSectionSchema(sectionPath, level); + const sectionSchema = useSectionSchema(sectionPath, effectiveLevel); // Apply special case handling for sections with problematic schema defaults const modifiedSchema = useMemo( @@ -247,7 +255,7 @@ export function ConfigSection({ // Get override status const { isOverridden, globalValue, cameraValue } = useConfigOverride({ config, - cameraName: level === "camera" ? cameraName : undefined, + cameraName: effectiveLevel === "camera" ? cameraName : undefined, sectionPath, compareFields: sectionConfig.overrideFields, }); @@ -256,12 +264,12 @@ export function ConfigSection({ const rawSectionValue = useMemo(() => { if (!config) return undefined; - if (level === "camera" && cameraName) { + if (effectiveLevel === "camera" && cameraName) { return get(config.cameras?.[cameraName], sectionPath); } return get(config, sectionPath); - }, [config, level, cameraName, sectionPath]); + }, [config, cameraName, sectionPath, effectiveLevel]); const rawFormData = useMemo(() => { if (!config) return {}; @@ -328,9 +336,10 @@ export function ConfigSection({ [rawFormData, sanitizeSectionData], ); - // Clear pendingData whenever formData changes (e.g., from server refresh) - // This prevents RJSF's initial onChange call from being treated as a user edit - // Only clear if pendingData is managed locally (not by parent) + // Clear pendingData whenever the section/camera key changes (e.g., switching + // cameras) or when there is no pending data yet (initialization). + // This prevents RJSF's initial onChange call from being treated as a user edit. + // Only clear if pendingData is managed locally (not by parent). useEffect(() => { const pendingKeyChanged = lastPendingDataKeyRef.current !== pendingDataKey; @@ -339,15 +348,16 @@ export function ConfigSection({ isInitializingRef.current = true; setPendingOverrides(undefined); setDirtyOverrides(undefined); + + // Reset local pending data when switching sections/cameras + if (onPendingDataChange === undefined) { + setPendingData(null); + } } else if (!pendingData) { isInitializingRef.current = true; setPendingOverrides(undefined); setDirtyOverrides(undefined); } - - if (onPendingDataChange === undefined) { - setPendingData(null); - } }, [ onPendingDataChange, pendingData, @@ -484,7 +494,7 @@ export function ConfigSection({ setIsSaving(true); try { const basePath = - level === "camera" && cameraName + effectiveLevel === "camera" && cameraName ? `cameras.${cameraName}.${sectionPath}` : sectionPath; const rawData = sanitizeSectionData(rawFormData); @@ -495,7 +505,7 @@ export function ConfigSection({ ); const sanitizedOverrides = sanitizeOverridesForSection( sectionPath, - level, + effectiveLevel, overrides, ); @@ -508,16 +518,26 @@ export function ConfigSection({ return; } - const needsRestart = requiresRestartForOverrides(sanitizedOverrides); + const needsRestart = skipSave + ? false + : requiresRestartForOverrides(sanitizedOverrides); const configData = buildConfigDataForPath(basePath, sanitizedOverrides); await axios.put("config/set", { requires_restart: needsRestart ? 1 : 0, update_topic: updateTopic, config_data: configData, + ...(skipSave ? { skip_save: true } : {}), }); - if (needsRestart) { + if (skipSave) { + toast.success( + t("toast.applied", { + ns: "views/settings", + defaultValue: "Settings applied successfully", + }), + ); + } else if (needsRestart) { statusBar?.addMessage( "config_restart_required", t("configForm.restartRequiredFooter", { @@ -596,7 +616,7 @@ export function ConfigSection({ }, [ sectionPath, pendingData, - level, + effectiveLevel, cameraName, t, refreshConfig, @@ -608,15 +628,16 @@ export function ConfigSection({ updateTopic, setPendingData, requiresRestartForOverrides, + skipSave, ]); // Handle reset to global/defaults - removes camera-level override or resets global to defaults const handleResetToGlobal = useCallback(async () => { - if (level === "camera" && !cameraName) return; + if (effectiveLevel === "camera" && !cameraName) return; try { const basePath = - level === "camera" && cameraName + effectiveLevel === "camera" && cameraName ? `cameras.${cameraName}.${sectionPath}` : sectionPath; @@ -632,7 +653,7 @@ export function ConfigSection({ t("toast.resetSuccess", { ns: "views/settings", defaultValue: - level === "global" + effectiveLevel === "global" ? "Reset to defaults" : "Reset to global defaults", }), @@ -651,7 +672,7 @@ export function ConfigSection({ } }, [ sectionPath, - level, + effectiveLevel, cameraName, requiresRestart, t, @@ -661,8 +682,8 @@ export function ConfigSection({ ]); const sectionValidation = useMemo( - () => getSectionValidation({ sectionPath, level, t }), - [sectionPath, level, t], + () => getSectionValidation({ sectionPath, level: effectiveLevel, t }), + [sectionPath, effectiveLevel, t], ); const customValidate = useMemo(() => { @@ -733,7 +754,7 @@ export function ConfigSection({ // nested under the section name (e.g., `audio.label`). For global-level // sections, keys are nested under the section name in `config/global`. const configNamespace = - level === "camera" ? "config/cameras" : "config/global"; + effectiveLevel === "camera" ? "config/cameras" : "config/global"; const title = t(`${sectionPath}.label`, { ns: configNamespace, defaultValue: defaultTitle, @@ -769,7 +790,7 @@ export function ConfigSection({ i18nNamespace={configNamespace} customValidate={customValidate} formContext={{ - level, + level: effectiveLevel, cameraName, globalValue, cameraValue, @@ -784,7 +805,7 @@ export function ConfigSection({ onFormDataChange: (data: ConfigSectionData) => handleChange(data), // For widgets that need access to full camera config (e.g., zone names) fullCameraConfig: - level === "camera" && cameraName + effectiveLevel === "camera" && cameraName ? config?.cameras?.[cameraName] : undefined, fullConfig: config, @@ -804,7 +825,12 @@ export function ConfigSection({ }} /> -
+
)}
- {((level === "camera" && isOverridden) || level === "global") && - !hasChanges && ( + {((effectiveLevel === "camera" && isOverridden) || + effectiveLevel === "global") && + !hasChanges && + !skipSave && ( - - - - - - - {t("page.confirmStop.title")} - - {t("page.confirmStop.description")} - - - - - {t("page.confirmStop.cancel")} - - + + + + + + + {t("page.confirmStop.title")} + + + {t("page.confirmStop.description")} + + + + + {t("page.confirmStop.cancel")} + + + {t("page.confirmStop.confirm")} + + + + +
{/* Main content */} -
+
{/* Camera feed */} -
+
{isStopping ? (
@@ -334,11 +347,22 @@ export default function Replay() { {/* Side panel */}
- - {t("title")} - -
-

{t("description")}

+
+ + {t("title")} + +
+ {status.source_camera} + {timeRangeDisplay && ( + <> + + {timeRangeDisplay} + + )} +
+
+

{t("description")}

+
@@ -444,7 +468,7 @@ export default function Replay() { >
@@ -453,6 +477,43 @@ export default function Replay() {
+ + + + + {t("page.configuration")} + + {t("page.configurationDesc")} + + +
+ + +
+
+
); } @@ -507,7 +568,7 @@ function ObjectList({ cameraConfig, objects, config }: ObjectListProps) { : getColorForObjectName(obj.label), }} > - {getIconForLabel(obj.label, "size-4 text-white")} + {getIconForLabel(obj.label, "object", "size-4 text-white")}
{getTranslatedLabel(obj.label)} diff --git a/web/src/utils/configUtil.ts b/web/src/utils/configUtil.ts index c4c5afad1..9d2327cb3 100644 --- a/web/src/utils/configUtil.ts +++ b/web/src/utils/configUtil.ts @@ -514,13 +514,18 @@ const mergeSectionConfig = ( export function getSectionConfig( sectionKey: string, - level: "global" | "camera", + level: "global" | "camera" | "replay", ): SectionConfig { const entry = sectionConfigs[sectionKey]; if (!entry) { return {}; } - const overrides = level === "global" ? entry.global : entry.camera; + const overrides = + level === "global" + ? entry.global + : level === "replay" + ? entry.replay + : entry.camera; return mergeSectionConfig(entry.base, overrides); }