mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-07-11 14:31:14 +03:00
fixes
This commit is contained in:
parent
6f02310b21
commit
22bd7359f8
@ -1,32 +1,239 @@
|
|||||||
import cloneDeep from "lodash/cloneDeep";
|
import cloneDeep from "lodash/cloneDeep";
|
||||||
|
import isEqual from "lodash/isEqual";
|
||||||
import type { RJSFSchema } from "@rjsf/utils";
|
import type { RJSFSchema } from "@rjsf/utils";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
buildOverrides,
|
||||||
cameraUpdateTopicMap,
|
cameraUpdateTopicMap,
|
||||||
flattenOverrides,
|
flattenOverrides,
|
||||||
|
getEffectiveAttributeLabels,
|
||||||
|
getSectionConfig,
|
||||||
prepareSectionSavePayload,
|
prepareSectionSavePayload,
|
||||||
|
resolveHiddenFieldEntries,
|
||||||
|
sanitizeSectionData,
|
||||||
type SectionSavePayload,
|
type SectionSavePayload,
|
||||||
} from "@/utils/configUtil";
|
} from "@/utils/configUtil";
|
||||||
|
import { applySchemaDefaults } from "@/lib/config-schema";
|
||||||
import type { SaveAllPreviewItem } from "@/components/overlay/detail/SaveAllPreviewPopover";
|
import type { SaveAllPreviewItem } from "@/components/overlay/detail/SaveAllPreviewPopover";
|
||||||
import type { CameraConfig, FrigateConfig } from "@/types/frigateConfig";
|
import type { CameraConfig, FrigateConfig } from "@/types/frigateConfig";
|
||||||
import type { JsonObject, JsonValue } from "@/types/configForm";
|
import type {
|
||||||
|
ConfigSectionData,
|
||||||
|
JsonObject,
|
||||||
|
JsonValue,
|
||||||
|
} from "@/types/configForm";
|
||||||
import { processCameraName } from "@/utils/cameraUtil";
|
import { processCameraName } from "@/utils/cameraUtil";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Static catalog of categories the clone dialog exposes. Each entry maps to
|
* Sections whose `filters` dict is auto-populated by the backend at parse
|
||||||
* one or more keys under `cameras.<name>` and tells the dialog (and
|
* time. `attributeBump` reflects the global-level `min_score=0.7` override
|
||||||
* `buildClonedCameraPayloads`) how to render and treat the category.
|
* the backend applies to attribute labels (face, license_plate, Frigate+
|
||||||
*
|
* couriers) — see `frigate/config/config.py`.
|
||||||
* Most categories pair 1:1 with an existing section config (`record`,
|
*/
|
||||||
* `motion`, `objects`, etc.) and flow through `prepareSectionSavePayload`.
|
const FILTER_SECTION_DEFS: Record<
|
||||||
* A handful are special cases handled directly in `cameraClone.ts`:
|
string,
|
||||||
*
|
{
|
||||||
* - `motion_mask` / `object_masks`: carve-outs that merge into their parent
|
listField: string;
|
||||||
* section's payload when also selected, or emit a standalone mask-only
|
filterDef: string;
|
||||||
* payload otherwise.
|
attributeBump?: { min_score: number };
|
||||||
* - `ffmpeg_live`: bundles `ffmpeg` + `live` for new-camera targets only.
|
}
|
||||||
* - `type` / `profiles`: bypass `prepareSectionSavePayload` because they
|
> = {
|
||||||
* are not edited through schema-driven form sections.
|
objects: {
|
||||||
|
listField: "track",
|
||||||
|
filterDef: "FilterConfig",
|
||||||
|
attributeBump: { min_score: 0.7 },
|
||||||
|
},
|
||||||
|
audio: { listField: "listen", filterDef: "AudioFilterConfig" },
|
||||||
|
};
|
||||||
|
|
||||||
|
function resolveDef(schema: RJSFSchema, name: string): RJSFSchema | undefined {
|
||||||
|
const defs =
|
||||||
|
(schema as { $defs?: Record<string, RJSFSchema> }).$defs ??
|
||||||
|
(schema as { definitions?: Record<string, RJSFSchema> }).definitions;
|
||||||
|
return defs ? defs[name] : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Remove filter entries that exactly match the backend's auto-default. */
|
||||||
|
function stripAutoDefaultFilters(
|
||||||
|
section: string,
|
||||||
|
sourceSection: JsonObject,
|
||||||
|
fullSchema: RJSFSchema,
|
||||||
|
fullConfig: FrigateConfig,
|
||||||
|
fullCameraConfig: CameraConfig,
|
||||||
|
): JsonObject {
|
||||||
|
const def = FILTER_SECTION_DEFS[section];
|
||||||
|
if (!def) return sourceSection;
|
||||||
|
const filters = sourceSection.filters;
|
||||||
|
if (!filters || typeof filters !== "object" || Array.isArray(filters)) {
|
||||||
|
return sourceSection;
|
||||||
|
}
|
||||||
|
const filterDef = resolveDef(fullSchema, def.filterDef);
|
||||||
|
if (!filterDef) return sourceSection;
|
||||||
|
const baseDefaults = applySchemaDefaults(filterDef, {}) as JsonObject;
|
||||||
|
const attributeDefaults = def.attributeBump
|
||||||
|
? ({ ...baseDefaults, ...def.attributeBump } as JsonObject)
|
||||||
|
: baseDefaults;
|
||||||
|
const attributeSet =
|
||||||
|
section === "objects"
|
||||||
|
? new Set(
|
||||||
|
getEffectiveAttributeLabels(fullConfig, fullCameraConfig, "camera"),
|
||||||
|
)
|
||||||
|
: new Set<string>();
|
||||||
|
|
||||||
|
const cleaned: JsonObject = {};
|
||||||
|
for (const [label, value] of Object.entries(filters as JsonObject)) {
|
||||||
|
const expected = attributeSet.has(label) ? attributeDefaults : baseDefaults;
|
||||||
|
if (isEqual(value, expected)) continue;
|
||||||
|
cleaned[label] = value as JsonValue;
|
||||||
|
}
|
||||||
|
return { ...sourceSection, filters: cleaned };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Strip named runtime-only fields from each entry in a dict-of-objects
|
||||||
|
* (mask `enabled_in_config`, zone `color`). The settings UI doesn't need
|
||||||
|
* this because BaseSection's form never exposes these sub-collections;
|
||||||
|
* we do because clone re-injects them from the API response.
|
||||||
|
*/
|
||||||
|
function stripDictEntryFields(
|
||||||
|
dict: unknown,
|
||||||
|
fieldsToStrip: readonly string[],
|
||||||
|
): unknown {
|
||||||
|
if (!dict || typeof dict !== "object" || Array.isArray(dict)) return dict;
|
||||||
|
const result: JsonObject = {};
|
||||||
|
for (const [key, value] of Object.entries(dict as JsonObject)) {
|
||||||
|
if (value && typeof value === "object" && !Array.isArray(value)) {
|
||||||
|
const cleaned = { ...(value as JsonObject) };
|
||||||
|
for (const field of fieldsToStrip) {
|
||||||
|
delete cleaned[field];
|
||||||
|
}
|
||||||
|
result[key] = cleaned as JsonValue;
|
||||||
|
} else {
|
||||||
|
result[key] = value as JsonValue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drop `""` (Reset) markers — meaningless for a new camera and unsafe
|
||||||
|
* (backend `update_yaml` raises KeyError trying to `del` a missing key).
|
||||||
|
*/
|
||||||
|
function stripResetMarkers(
|
||||||
|
value: JsonValue | undefined,
|
||||||
|
): JsonValue | undefined {
|
||||||
|
if (value === undefined || value === "") return undefined;
|
||||||
|
if (value === null || typeof value !== "object" || Array.isArray(value)) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
const result: JsonObject = {};
|
||||||
|
for (const [key, child] of Object.entries(value as JsonObject)) {
|
||||||
|
const cleaned = stripResetMarkers(child);
|
||||||
|
if (cleaned !== undefined) result[key] = cleaned;
|
||||||
|
}
|
||||||
|
return Object.keys(result).length > 0 ? result : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drop empty `*_args` arrays from ffmpeg inputs. Mirrors
|
||||||
|
* `sanitizeOverridesForSection`'s ffmpeg cleanup, which we don't go
|
||||||
|
* through here because the establishing payload uses `buildOverrides`
|
||||||
|
* directly.
|
||||||
|
*/
|
||||||
|
function cleanupFfmpegInputArgs(
|
||||||
|
ffmpeg: JsonValue | undefined,
|
||||||
|
): JsonValue | undefined {
|
||||||
|
if (!ffmpeg || typeof ffmpeg !== "object" || Array.isArray(ffmpeg)) {
|
||||||
|
return ffmpeg;
|
||||||
|
}
|
||||||
|
const obj = ffmpeg as JsonObject;
|
||||||
|
const inputs = obj.inputs;
|
||||||
|
if (!Array.isArray(inputs)) return ffmpeg;
|
||||||
|
const cleanedInputs = inputs.map((input) => {
|
||||||
|
if (!input || typeof input !== "object" || Array.isArray(input))
|
||||||
|
return input;
|
||||||
|
const cleaned = { ...(input as JsonObject) };
|
||||||
|
for (const argsKey of ["global_args", "hwaccel_args", "input_args"]) {
|
||||||
|
const v = cleaned[argsKey];
|
||||||
|
if (Array.isArray(v) && v.length === 0) delete cleaned[argsKey];
|
||||||
|
}
|
||||||
|
return cleaned as JsonValue;
|
||||||
|
});
|
||||||
|
return { ...obj, inputs: cleanedInputs as JsonValue };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Subset of `/api/config/raw_paths` used to unmask source credentials. */
|
||||||
|
export type RawCameraPaths = {
|
||||||
|
cameras?: Record<
|
||||||
|
string,
|
||||||
|
{ ffmpeg?: { inputs?: Array<{ path?: string; roles?: string[] }> } }
|
||||||
|
>;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replace each ffmpeg input's `path` with the unmasked value from
|
||||||
|
* `rawInputs` at the same index. Mirrors `_restore_masked_camera_paths`.
|
||||||
|
*/
|
||||||
|
function restoreFfmpegPaths(
|
||||||
|
ffmpeg: unknown,
|
||||||
|
rawInputs: Array<{ path?: string }> | undefined,
|
||||||
|
): unknown {
|
||||||
|
if (!ffmpeg || typeof ffmpeg !== "object" || Array.isArray(ffmpeg)) {
|
||||||
|
return ffmpeg;
|
||||||
|
}
|
||||||
|
const obj = cloneDeep(ffmpeg) as JsonObject;
|
||||||
|
const inputs = obj.inputs;
|
||||||
|
if (!Array.isArray(inputs) || !rawInputs) return obj;
|
||||||
|
inputs.forEach((input, i) => {
|
||||||
|
if (!input || typeof input !== "object" || Array.isArray(input)) return;
|
||||||
|
const rawPath = rawInputs[i]?.path;
|
||||||
|
if (typeof rawPath !== "string") return;
|
||||||
|
(input as JsonObject).path = rawPath;
|
||||||
|
});
|
||||||
|
return obj;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replay the backend's per-camera detect-field formulas (`frigate/config/
|
||||||
|
* config.py`) on the synthetic side so they cancel out of the diff. The
|
||||||
|
* global config doesn't get per-camera derivation, so without this the
|
||||||
|
* source's computed values surface as overrides.
|
||||||
|
*/
|
||||||
|
function applyDetectComputedDefaults(
|
||||||
|
detect: JsonObject,
|
||||||
|
fpsOverride?: number,
|
||||||
|
): JsonObject {
|
||||||
|
const result = { ...detect };
|
||||||
|
const fps =
|
||||||
|
typeof fpsOverride === "number"
|
||||||
|
? fpsOverride
|
||||||
|
: typeof result.fps === "number"
|
||||||
|
? result.fps
|
||||||
|
: 5;
|
||||||
|
if (result.min_initialized == null) {
|
||||||
|
result.min_initialized = Math.max(Math.floor(fps / 2), 2);
|
||||||
|
}
|
||||||
|
if (result.max_disappeared == null) {
|
||||||
|
result.max_disappeared = fps * 5;
|
||||||
|
}
|
||||||
|
const threshold = fps * 10;
|
||||||
|
const stationary = result.stationary;
|
||||||
|
const stat: JsonObject =
|
||||||
|
stationary && typeof stationary === "object" && !Array.isArray(stationary)
|
||||||
|
? { ...(stationary as JsonObject) }
|
||||||
|
: {};
|
||||||
|
if (stat.threshold == null) stat.threshold = threshold;
|
||||||
|
if (stat.interval == null) stat.interval = threshold;
|
||||||
|
result.stationary = stat as JsonValue;
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Categories the dialog exposes. Most map 1:1 to a section config and flow
|
||||||
|
* through `prepareSectionSavePayload`. Special cases:
|
||||||
|
* - `motion_mask`/`object_masks`: carve-outs merged into the parent
|
||||||
|
* section's payload, or emitted standalone if the parent is unselected.
|
||||||
|
* - `ffmpeg_live`: new-camera target only.
|
||||||
|
* - `type`/`profiles`: not schema-driven; built directly below.
|
||||||
*/
|
*/
|
||||||
export type CloneCategoryKey =
|
export type CloneCategoryKey =
|
||||||
| "record"
|
| "record"
|
||||||
@ -102,11 +309,8 @@ export const CLONE_CATEGORIES: readonly CloneCategory[] = [
|
|||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Compare detect dimensions exactly. Used to decide whether spatial-category
|
* Exact-match detect dimensions. Aspect-ratio tolerance isn't safe because
|
||||||
* defaults start on or off when targeting an existing camera. Exact match is
|
* zone/mask coords may be stored as explicit pixels, not just 0-1 relative.
|
||||||
* the only always-safe default — zone/mask coordinates can be stored as
|
|
||||||
* relative (0-1) OR explicit pixels, and aspect-ratio-only tolerance still
|
|
||||||
* distorts explicit polygons.
|
|
||||||
*/
|
*/
|
||||||
export function resolutionsMatch(
|
export function resolutionsMatch(
|
||||||
srcDetect: CameraConfig["detect"] | undefined,
|
srcDetect: CameraConfig["detect"] | undefined,
|
||||||
@ -131,20 +335,10 @@ export function resolutionsMatch(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Compute the initial selection set for the dialog based on target type and
|
* Initial selection set. Spatial categories default off on existing-camera
|
||||||
* resolution match. Pure function — the dialog re-runs this whenever the
|
* targets when resolutions differ (polygons may not align); otherwise each
|
||||||
* target or source changes to reset defaults sanely.
|
* category uses its `defaultOnExisting`. `forcedForNewCamera` is always on
|
||||||
*
|
* for new-camera targets.
|
||||||
* - new-camera target: every non-`newCameraOnly` category that has
|
|
||||||
* `defaultOnExisting: true` is on, plus every `forcedForNewCamera`.
|
|
||||||
* Spatial categories are all on (the cloned detect dims become the new
|
|
||||||
* camera's, so the source's polygons are internally consistent).
|
|
||||||
*
|
|
||||||
* - existing-camera target with matching resolution: each category uses
|
|
||||||
* its `defaultOnExisting`.
|
|
||||||
*
|
|
||||||
* - existing-camera target with mismatched resolution: every spatial
|
|
||||||
* category defaults off; non-spatial uses `defaultOnExisting`.
|
|
||||||
*/
|
*/
|
||||||
export function getCategoryDefaults(
|
export function getCategoryDefaults(
|
||||||
targetIsNew: boolean,
|
targetIsNew: boolean,
|
||||||
@ -177,26 +371,13 @@ type BuildClonedPayloadsArgs = {
|
|||||||
selectedKeys: Set<CloneCategoryKey>;
|
selectedKeys: Set<CloneCategoryKey>;
|
||||||
fullConfig: FrigateConfig;
|
fullConfig: FrigateConfig;
|
||||||
fullSchema: RJSFSchema;
|
fullSchema: RJSFSchema;
|
||||||
|
rawPaths?: RawCameraPaths;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Produce the ordered list of section-save payloads needed to clone the
|
* Build the ordered payloads to PUT. Order: new-camera `…/add`, then
|
||||||
* selected categories from `sourceCfg` to the target camera. The dialog
|
* `type` (LPR vs normal affects attribute resolution for later payloads),
|
||||||
* iterates the returned array and issues one `PUT /api/config/set` per
|
* then per-section, then `profiles` (no hot-reload topic).
|
||||||
* payload, in array order.
|
|
||||||
*
|
|
||||||
* Order rules:
|
|
||||||
* 1. For new-camera targets, the first payload establishes the camera
|
|
||||||
* (enabled + friendly_name + ffmpeg + live) and uses
|
|
||||||
* `update_topic: config/cameras/<target>/add`.
|
|
||||||
* 2. The `type` payload (camera type lpr/normal) goes before all other
|
|
||||||
* per-section updates because it influences how the backend resolves
|
|
||||||
* camera-level overrides.
|
|
||||||
* 3. All `prepareSectionSavePayload`-derived sections come next.
|
|
||||||
* 4. The `profiles` payload (wholesale dict replacement) comes last —
|
|
||||||
* it doesn't dispatch a hot-reload topic, so ordering relative to
|
|
||||||
* other sections doesn't matter, but keeping it last keeps the
|
|
||||||
* restart-prompt logic readable.
|
|
||||||
*/
|
*/
|
||||||
export function buildClonedCameraPayloads({
|
export function buildClonedCameraPayloads({
|
||||||
sourceCfg,
|
sourceCfg,
|
||||||
@ -206,6 +387,7 @@ export function buildClonedCameraPayloads({
|
|||||||
selectedKeys,
|
selectedKeys,
|
||||||
fullConfig,
|
fullConfig,
|
||||||
fullSchema,
|
fullSchema,
|
||||||
|
rawPaths,
|
||||||
}: BuildClonedPayloadsArgs): SectionSavePayload[] {
|
}: BuildClonedPayloadsArgs): SectionSavePayload[] {
|
||||||
const payloads: SectionSavePayload[] = [];
|
const payloads: SectionSavePayload[] = [];
|
||||||
|
|
||||||
@ -213,7 +395,7 @@ export function buildClonedCameraPayloads({
|
|||||||
? processCameraName(targetInput)
|
? processCameraName(targetInput)
|
||||||
: { finalCameraName: targetInput, friendlyName: undefined };
|
: { finalCameraName: targetInput, friendlyName: undefined };
|
||||||
|
|
||||||
// 1. New-camera establishing payload
|
// New-camera establishing payload (carries the `…/add` topic).
|
||||||
if (targetIsNew) {
|
if (targetIsNew) {
|
||||||
const addOverrides: Record<string, unknown> = {
|
const addOverrides: Record<string, unknown> = {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
@ -221,11 +403,31 @@ export function buildClonedCameraPayloads({
|
|||||||
if (friendlyName) {
|
if (friendlyName) {
|
||||||
addOverrides.friendly_name = friendlyName;
|
addOverrides.friendly_name = friendlyName;
|
||||||
}
|
}
|
||||||
|
// Diff ffmpeg/live against the global config so fields matching
|
||||||
|
// inherited defaults drop out. Required fields (ffmpeg.inputs) come
|
||||||
|
// along because the source differs from global there.
|
||||||
if (selectedKeys.has("ffmpeg_live") && sourceCfg.ffmpeg) {
|
if (selectedKeys.has("ffmpeg_live") && sourceCfg.ffmpeg) {
|
||||||
addOverrides.ffmpeg = cloneDeep(sourceCfg.ffmpeg);
|
// /api/config masks `user:pass` as `*:*`; backend's restoration
|
||||||
|
// only handles existing cameras, so we unmask here for new ones.
|
||||||
|
const ffmpegWithRealPaths = restoreFfmpegPaths(
|
||||||
|
sourceCfg.ffmpeg,
|
||||||
|
rawPaths?.cameras?.[sourceName]?.ffmpeg?.inputs,
|
||||||
|
);
|
||||||
|
const diff = buildOverrides(
|
||||||
|
ffmpegWithRealPaths,
|
||||||
|
undefined,
|
||||||
|
fullConfig.ffmpeg,
|
||||||
|
);
|
||||||
|
const cleaned = cleanupFfmpegInputArgs(diff as JsonValue | undefined);
|
||||||
|
if (cleaned !== undefined) addOverrides.ffmpeg = cleaned;
|
||||||
}
|
}
|
||||||
if (selectedKeys.has("ffmpeg_live") && sourceCfg.live) {
|
if (selectedKeys.has("ffmpeg_live") && sourceCfg.live) {
|
||||||
addOverrides.live = cloneDeep(sourceCfg.live);
|
const diff = buildOverrides(
|
||||||
|
sourceCfg.live,
|
||||||
|
undefined,
|
||||||
|
(fullConfig as unknown as JsonObject).live,
|
||||||
|
);
|
||||||
|
if (diff !== undefined) addOverrides.live = diff;
|
||||||
}
|
}
|
||||||
payloads.push({
|
payloads.push({
|
||||||
basePath: `cameras.${target}`,
|
basePath: `cameras.${target}`,
|
||||||
@ -236,7 +438,7 @@ export function buildClonedCameraPayloads({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Camera type (top-level scalar — bypasses prepareSectionSavePayload)
|
// Camera type — top-level scalar, no schema-driven section.
|
||||||
if (selectedKeys.has("type")) {
|
if (selectedKeys.has("type")) {
|
||||||
const srcType = (sourceCfg as { type?: string | null }).type;
|
const srcType = (sourceCfg as { type?: string | null }).type;
|
||||||
if (srcType !== undefined && srcType !== null) {
|
if (srcType !== undefined && srcType !== null) {
|
||||||
@ -250,9 +452,8 @@ export function buildClonedCameraPayloads({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Section-backed categories — flow through the existing per-section
|
// Section-backed categories — flow through prepareSectionSavePayload
|
||||||
// save infrastructure so restart and update-topic behavior matches the
|
// for matching restart/update-topic behavior.
|
||||||
// rest of the settings UI exactly.
|
|
||||||
const SECTION_KEYS: Array<{ key: CloneCategoryKey; section: string }> = [
|
const SECTION_KEYS: Array<{ key: CloneCategoryKey; section: string }> = [
|
||||||
{ key: "record", section: "record" },
|
{ key: "record", section: "record" },
|
||||||
{ key: "snapshots", section: "snapshots" },
|
{ key: "snapshots", section: "snapshots" },
|
||||||
@ -274,18 +475,79 @@ export function buildClonedCameraPayloads({
|
|||||||
{ key: "zones", section: "zones" },
|
{ key: "zones", section: "zones" },
|
||||||
];
|
];
|
||||||
|
|
||||||
// Build a synthetic config that pretends the target camera exists with
|
// Synthetic target so we can reuse prepareSectionSavePayload unchanged.
|
||||||
// the source's section value as its current pending data. This lets us
|
// For new-camera target, seed sections where camera schema accepts all
|
||||||
// reuse prepareSectionSavePayload unchanged.
|
// global fields — gives buildOverrides the right inheritance baseline.
|
||||||
|
// Sections with divergent per-camera Pydantic classes (mqtt, birdseye,
|
||||||
|
// lpr, face_recognition, semantic_search, audio_transcription, genai)
|
||||||
|
// are left unset so prepareSectionSavePayload's schema defaults handle
|
||||||
|
// filtering instead — seeding from global would emit its extra fields
|
||||||
|
// as Reset markers.
|
||||||
|
const GLOBAL_INHERITED_SECTIONS = [
|
||||||
|
"detect",
|
||||||
|
"objects",
|
||||||
|
"motion",
|
||||||
|
"record",
|
||||||
|
"snapshots",
|
||||||
|
"review",
|
||||||
|
"audio",
|
||||||
|
"notifications",
|
||||||
|
"ffmpeg",
|
||||||
|
"live",
|
||||||
|
"timestamp_style",
|
||||||
|
];
|
||||||
|
const syntheticTargetCamera = targetIsNew
|
||||||
|
? ({
|
||||||
|
enabled: true,
|
||||||
|
...Object.fromEntries(
|
||||||
|
GLOBAL_INHERITED_SECTIONS.map((s) => [
|
||||||
|
s,
|
||||||
|
cloneDeep((fullConfig as unknown as JsonObject)[s]),
|
||||||
|
]).filter(([, value]) => value !== undefined && value !== null),
|
||||||
|
),
|
||||||
|
} as unknown as FrigateConfig["cameras"][string])
|
||||||
|
: (fullConfig.cameras?.[target] ??
|
||||||
|
({ enabled: true } as unknown as FrigateConfig["cameras"][string]));
|
||||||
|
|
||||||
|
// Symmetric filter strip: same treatment as the per-section source
|
||||||
|
// strip below, so default-only entries cancel out of the diff.
|
||||||
|
for (const section of Object.keys(FILTER_SECTION_DEFS)) {
|
||||||
|
const syntheticSection = (syntheticTargetCamera as unknown as JsonObject)[
|
||||||
|
section
|
||||||
|
];
|
||||||
|
if (syntheticSection && typeof syntheticSection === "object") {
|
||||||
|
(syntheticTargetCamera as unknown as JsonObject)[section] =
|
||||||
|
stripAutoDefaultFilters(
|
||||||
|
section,
|
||||||
|
syntheticSection as JsonObject,
|
||||||
|
fullSchema,
|
||||||
|
fullConfig,
|
||||||
|
syntheticTargetCamera as CameraConfig,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// New-camera: synthetic's detect is from global (no per-camera derive),
|
||||||
|
// so apply the formulas using source's fps to keep both sides aligned.
|
||||||
|
// Existing-camera target already has the values from its own parse.
|
||||||
|
if (targetIsNew && sourceCfg.detect) {
|
||||||
|
const syntheticCameraObj = syntheticTargetCamera as unknown as JsonObject;
|
||||||
|
const syntheticDetect = syntheticCameraObj.detect;
|
||||||
|
if (syntheticDetect && typeof syntheticDetect === "object") {
|
||||||
|
syntheticCameraObj.detect = applyDetectComputedDefaults(
|
||||||
|
syntheticDetect as JsonObject,
|
||||||
|
typeof sourceCfg.detect.fps === "number"
|
||||||
|
? sourceCfg.detect.fps
|
||||||
|
: undefined,
|
||||||
|
) as JsonValue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const syntheticConfig: FrigateConfig = {
|
const syntheticConfig: FrigateConfig = {
|
||||||
...fullConfig,
|
...fullConfig,
|
||||||
cameras: {
|
cameras: {
|
||||||
...fullConfig.cameras,
|
...fullConfig.cameras,
|
||||||
// If target is new, seed an empty camera entry so the helper can
|
[target]: syntheticTargetCamera,
|
||||||
// compute a diff against defaults instead of crashing.
|
|
||||||
[target]:
|
|
||||||
fullConfig.cameras?.[target] ??
|
|
||||||
({ enabled: true } as unknown as FrigateConfig["cameras"][string]),
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -296,30 +558,62 @@ export function buildClonedCameraPayloads({
|
|||||||
)[section];
|
)[section];
|
||||||
if (sourceSectionValue == null) continue;
|
if (sourceSectionValue == null) continue;
|
||||||
|
|
||||||
let pendingSectionValue = cloneDeep(sourceSectionValue);
|
// Sanitize the source the same way BaseSection's form does
|
||||||
|
// implicitly: strips runtime/derived fields and function-resolved
|
||||||
|
// hidden paths (e.g. `hideAttributeFilters` removing untracked-
|
||||||
|
// attribute entries based on source's track list).
|
||||||
|
const sectionConfig = getSectionConfig(section, "camera");
|
||||||
|
const resolvedHiddenFields = resolveHiddenFieldEntries(
|
||||||
|
sectionConfig.hiddenFields,
|
||||||
|
{
|
||||||
|
fullConfig,
|
||||||
|
fullCameraConfig: sourceCfg,
|
||||||
|
level: "camera",
|
||||||
|
formData: sourceSectionValue as ConfigSectionData,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
let pendingSectionValue: unknown = sanitizeSectionData(
|
||||||
|
cloneDeep(sourceSectionValue) as ConfigSectionData,
|
||||||
|
resolvedHiddenFields,
|
||||||
|
);
|
||||||
|
|
||||||
// Carve-out: when both Motion sensitivity and Motion mask are selected,
|
if (FILTER_SECTION_DEFS[section]) {
|
||||||
// merge the mask fields into the motion payload (the motion section's
|
pendingSectionValue = stripAutoDefaultFilters(
|
||||||
// hiddenFields would otherwise strip them before write).
|
section,
|
||||||
|
pendingSectionValue as JsonObject,
|
||||||
|
fullSchema,
|
||||||
|
fullConfig,
|
||||||
|
syntheticTargetCamera as CameraConfig,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-inject masks the parent section's hiddenFields just stripped,
|
||||||
|
// when the mask category is also selected. `raw_mask` is never in
|
||||||
|
// the API response; `enabled_in_config` is runtime-only.
|
||||||
if (key === "motion" && selectedKeys.has("motion_mask")) {
|
if (key === "motion" && selectedKeys.has("motion_mask")) {
|
||||||
const srcMotion = sourceSectionValue as {
|
const srcMask = (sourceSectionValue as { mask?: unknown }).mask;
|
||||||
mask?: unknown;
|
if (srcMask !== undefined) {
|
||||||
raw_mask?: unknown;
|
pendingSectionValue = {
|
||||||
};
|
...(pendingSectionValue as object),
|
||||||
pendingSectionValue = {
|
mask: stripDictEntryFields(srcMask, ["enabled_in_config"]),
|
||||||
...(pendingSectionValue as object),
|
};
|
||||||
...(srcMotion.mask !== undefined ? { mask: srcMotion.mask } : {}),
|
}
|
||||||
...(srcMotion.raw_mask !== undefined
|
|
||||||
? { raw_mask: srcMotion.raw_mask }
|
|
||||||
: {}),
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
if (key === "objects" && selectedKeys.has("object_masks")) {
|
if (key === "objects" && selectedKeys.has("object_masks")) {
|
||||||
const srcObjects = sourceSectionValue as { mask?: unknown };
|
const srcMask = (sourceSectionValue as { mask?: unknown }).mask;
|
||||||
pendingSectionValue = {
|
if (srcMask !== undefined) {
|
||||||
...(pendingSectionValue as object),
|
pendingSectionValue = {
|
||||||
...(srcObjects.mask !== undefined ? { mask: srcObjects.mask } : {}),
|
...(pendingSectionValue as object),
|
||||||
};
|
mask: stripDictEntryFields(srcMask, ["enabled_in_config"]),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// `color` is a Pydantic PrivateAttr (runtime-only).
|
||||||
|
if (key === "zones") {
|
||||||
|
pendingSectionValue = stripDictEntryFields(pendingSectionValue, [
|
||||||
|
"color",
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
const payload = prepareSectionSavePayload({
|
const payload = prepareSectionSavePayload({
|
||||||
@ -333,27 +627,18 @@ export function buildClonedCameraPayloads({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3a. Standalone mask payloads — emitted ONLY when the parent section's
|
// Standalone mask payloads — only when the parent section isn't also
|
||||||
// category is unselected. (When both are selected the masks were
|
// selected (otherwise the masks were merged into its payload above).
|
||||||
// merged into the parent payload above.)
|
|
||||||
if (selectedKeys.has("motion_mask") && !selectedKeys.has("motion")) {
|
if (selectedKeys.has("motion_mask") && !selectedKeys.has("motion")) {
|
||||||
const srcMotion = sourceCfg.motion as
|
const srcMask = (sourceCfg.motion as { mask?: unknown } | undefined)?.mask;
|
||||||
| { mask?: unknown; raw_mask?: unknown }
|
if (srcMask !== undefined) {
|
||||||
| undefined;
|
|
||||||
if (
|
|
||||||
srcMotion &&
|
|
||||||
(srcMotion.mask !== undefined || srcMotion.raw_mask !== undefined)
|
|
||||||
) {
|
|
||||||
payloads.push({
|
payloads.push({
|
||||||
basePath: `cameras.${target}.motion`,
|
basePath: `cameras.${target}.motion`,
|
||||||
sanitizedOverrides: {
|
sanitizedOverrides: {
|
||||||
...(srcMotion.mask !== undefined
|
mask: stripDictEntryFields(srcMask, [
|
||||||
? { mask: srcMotion.mask as JsonValue }
|
"enabled_in_config",
|
||||||
: {}),
|
]) as JsonValue,
|
||||||
...(srcMotion.raw_mask !== undefined
|
},
|
||||||
? { raw_mask: srcMotion.raw_mask as JsonValue }
|
|
||||||
: {}),
|
|
||||||
} as JsonObject,
|
|
||||||
updateTopic: `config/cameras/${target}/${cameraUpdateTopicMap.motion}`,
|
updateTopic: `config/cameras/${target}/${cameraUpdateTopicMap.motion}`,
|
||||||
needsRestart: false,
|
needsRestart: false,
|
||||||
pendingDataKey: `${target}::motion.masks`,
|
pendingDataKey: `${target}::motion.masks`,
|
||||||
@ -361,11 +646,15 @@ export function buildClonedCameraPayloads({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (selectedKeys.has("object_masks") && !selectedKeys.has("objects")) {
|
if (selectedKeys.has("object_masks") && !selectedKeys.has("objects")) {
|
||||||
const srcObjects = sourceCfg.objects as { mask?: unknown } | undefined;
|
const srcMask = (sourceCfg.objects as { mask?: unknown } | undefined)?.mask;
|
||||||
if (srcObjects && srcObjects.mask !== undefined) {
|
if (srcMask !== undefined) {
|
||||||
payloads.push({
|
payloads.push({
|
||||||
basePath: `cameras.${target}.objects`,
|
basePath: `cameras.${target}.objects`,
|
||||||
sanitizedOverrides: { mask: srcObjects.mask as JsonValue },
|
sanitizedOverrides: {
|
||||||
|
mask: stripDictEntryFields(srcMask, [
|
||||||
|
"enabled_in_config",
|
||||||
|
]) as JsonValue,
|
||||||
|
},
|
||||||
updateTopic: `config/cameras/${target}/${cameraUpdateTopicMap.objects}`,
|
updateTopic: `config/cameras/${target}/${cameraUpdateTopicMap.objects}`,
|
||||||
needsRestart: false,
|
needsRestart: false,
|
||||||
pendingDataKey: `${target}::objects.masks`,
|
pendingDataKey: `${target}::objects.masks`,
|
||||||
@ -373,7 +662,7 @@ export function buildClonedCameraPayloads({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. Profiles — wholesale replacement of cameras.<target>.profiles.
|
// Profiles — wholesale dict replacement; no hot-reload topic.
|
||||||
if (selectedKeys.has("profiles")) {
|
if (selectedKeys.has("profiles")) {
|
||||||
const srcProfiles = (sourceCfg as { profiles?: unknown }).profiles;
|
const srcProfiles = (sourceCfg as { profiles?: unknown }).profiles;
|
||||||
if (srcProfiles && typeof srcProfiles === "object") {
|
if (srcProfiles && typeof srcProfiles === "object") {
|
||||||
@ -387,19 +676,23 @@ export function buildClonedCameraPayloads({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// sourceName is currently unused but kept in the signature so the dialog
|
// Reset markers are meaningless for a new camera; see stripResetMarkers.
|
||||||
// can pass it explicitly (matches the helper's logical boundary — "clone
|
if (targetIsNew) {
|
||||||
// FROM x TO y" — and lets future logging hook in without a signature change).
|
return payloads
|
||||||
void sourceName;
|
.map((p) => {
|
||||||
|
const cleaned = stripResetMarkers(p.sanitizedOverrides as JsonValue);
|
||||||
|
if (cleaned === undefined) return null;
|
||||||
|
return { ...p, sanitizedOverrides: cleaned as JsonObject };
|
||||||
|
})
|
||||||
|
.filter((p): p is SectionSavePayload => p !== null);
|
||||||
|
}
|
||||||
|
|
||||||
return payloads;
|
return payloads;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Flatten clone payloads into the preview-popover item shape. Each item's
|
* Flatten payloads to `SaveAllPreviewItem`s with camera-relative
|
||||||
* `fieldPath` is rendered camera-relative (e.g., `record.retain.days`)
|
* `fieldPath`s (matches BaseSection's per-section preview).
|
||||||
* rather than absolute (`cameras.front_door.record.retain.days`), matching
|
|
||||||
* the section-level Save All preview pattern in BaseSection.
|
|
||||||
*/
|
*/
|
||||||
export function buildClonePreviewItems(
|
export function buildClonePreviewItems(
|
||||||
payloads: SectionSavePayload[],
|
payloads: SectionSavePayload[],
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user