From 22bd7359f8676fed4448f4992c9cba69038c8e94 Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Tue, 26 May 2026 12:51:36 -0500 Subject: [PATCH] fixes --- web/src/utils/cameraClone.ts | 523 +++++++++++++++++++++++++++-------- 1 file changed, 408 insertions(+), 115 deletions(-) diff --git a/web/src/utils/cameraClone.ts b/web/src/utils/cameraClone.ts index 35fe39489b..dad686e41e 100644 --- a/web/src/utils/cameraClone.ts +++ b/web/src/utils/cameraClone.ts @@ -1,32 +1,239 @@ import cloneDeep from "lodash/cloneDeep"; +import isEqual from "lodash/isEqual"; import type { RJSFSchema } from "@rjsf/utils"; import { + buildOverrides, cameraUpdateTopicMap, flattenOverrides, + getEffectiveAttributeLabels, + getSectionConfig, prepareSectionSavePayload, + resolveHiddenFieldEntries, + sanitizeSectionData, type SectionSavePayload, } from "@/utils/configUtil"; +import { applySchemaDefaults } from "@/lib/config-schema"; import type { SaveAllPreviewItem } from "@/components/overlay/detail/SaveAllPreviewPopover"; 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"; /** - * Static catalog of categories the clone dialog exposes. Each entry maps to - * one or more keys under `cameras.` and tells the dialog (and - * `buildClonedCameraPayloads`) how to render and treat the category. - * - * Most categories pair 1:1 with an existing section config (`record`, - * `motion`, `objects`, etc.) and flow through `prepareSectionSavePayload`. - * A handful are special cases handled directly in `cameraClone.ts`: - * - * - `motion_mask` / `object_masks`: carve-outs that merge into their parent - * section's payload when also selected, or emit a standalone mask-only - * payload otherwise. - * - `ffmpeg_live`: bundles `ffmpeg` + `live` for new-camera targets only. - * - `type` / `profiles`: bypass `prepareSectionSavePayload` because they - * are not edited through schema-driven form sections. + * Sections whose `filters` dict is auto-populated by the backend at parse + * time. `attributeBump` reflects the global-level `min_score=0.7` override + * the backend applies to attribute labels (face, license_plate, Frigate+ + * couriers) — see `frigate/config/config.py`. + */ +const FILTER_SECTION_DEFS: Record< + string, + { + listField: string; + filterDef: string; + attributeBump?: { min_score: number }; + } +> = { + 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 }).$defs ?? + (schema as { definitions?: Record }).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(); + + 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 = | "record" @@ -102,11 +309,8 @@ export const CLONE_CATEGORIES: readonly CloneCategory[] = [ ] as const; /** - * Compare detect dimensions exactly. Used to decide whether spatial-category - * defaults start on or off when targeting an existing camera. Exact match is - * 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. + * Exact-match detect dimensions. Aspect-ratio tolerance isn't safe because + * zone/mask coords may be stored as explicit pixels, not just 0-1 relative. */ export function resolutionsMatch( srcDetect: CameraConfig["detect"] | undefined, @@ -131,20 +335,10 @@ export function resolutionsMatch( } /** - * Compute the initial selection set for the dialog based on target type and - * resolution match. Pure function — the dialog re-runs this whenever the - * target or source changes to reset defaults sanely. - * - * - 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`. + * Initial selection set. Spatial categories default off on existing-camera + * targets when resolutions differ (polygons may not align); otherwise each + * category uses its `defaultOnExisting`. `forcedForNewCamera` is always on + * for new-camera targets. */ export function getCategoryDefaults( targetIsNew: boolean, @@ -177,26 +371,13 @@ type BuildClonedPayloadsArgs = { selectedKeys: Set; fullConfig: FrigateConfig; fullSchema: RJSFSchema; + rawPaths?: RawCameraPaths; }; /** - * Produce the ordered list of section-save payloads needed to clone the - * selected categories from `sourceCfg` to the target camera. The dialog - * iterates the returned array and issues one `PUT /api/config/set` per - * 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//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. + * Build the ordered payloads to PUT. Order: new-camera `…/add`, then + * `type` (LPR vs normal affects attribute resolution for later payloads), + * then per-section, then `profiles` (no hot-reload topic). */ export function buildClonedCameraPayloads({ sourceCfg, @@ -206,6 +387,7 @@ export function buildClonedCameraPayloads({ selectedKeys, fullConfig, fullSchema, + rawPaths, }: BuildClonedPayloadsArgs): SectionSavePayload[] { const payloads: SectionSavePayload[] = []; @@ -213,7 +395,7 @@ export function buildClonedCameraPayloads({ ? processCameraName(targetInput) : { finalCameraName: targetInput, friendlyName: undefined }; - // 1. New-camera establishing payload + // New-camera establishing payload (carries the `…/add` topic). if (targetIsNew) { const addOverrides: Record = { enabled: true, @@ -221,11 +403,31 @@ export function buildClonedCameraPayloads({ if (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) { - 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) { - 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({ 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")) { const srcType = (sourceCfg as { type?: string | null }).type; if (srcType !== undefined && srcType !== null) { @@ -250,9 +452,8 @@ export function buildClonedCameraPayloads({ } } - // 3. Section-backed categories — flow through the existing per-section - // save infrastructure so restart and update-topic behavior matches the - // rest of the settings UI exactly. + // Section-backed categories — flow through prepareSectionSavePayload + // for matching restart/update-topic behavior. const SECTION_KEYS: Array<{ key: CloneCategoryKey; section: string }> = [ { key: "record", section: "record" }, { key: "snapshots", section: "snapshots" }, @@ -274,18 +475,79 @@ export function buildClonedCameraPayloads({ { key: "zones", section: "zones" }, ]; - // Build a synthetic config that pretends the target camera exists with - // the source's section value as its current pending data. This lets us - // reuse prepareSectionSavePayload unchanged. + // Synthetic target so we can reuse prepareSectionSavePayload unchanged. + // For new-camera target, seed sections where camera schema accepts all + // 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 = { ...fullConfig, cameras: { ...fullConfig.cameras, - // If target is new, seed an empty camera entry so the helper can - // compute a diff against defaults instead of crashing. - [target]: - fullConfig.cameras?.[target] ?? - ({ enabled: true } as unknown as FrigateConfig["cameras"][string]), + [target]: syntheticTargetCamera, }, }; @@ -296,30 +558,62 @@ export function buildClonedCameraPayloads({ )[section]; 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, - // merge the mask fields into the motion payload (the motion section's - // hiddenFields would otherwise strip them before write). + if (FILTER_SECTION_DEFS[section]) { + pendingSectionValue = stripAutoDefaultFilters( + 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")) { - const srcMotion = sourceSectionValue as { - mask?: unknown; - raw_mask?: unknown; - }; - pendingSectionValue = { - ...(pendingSectionValue as object), - ...(srcMotion.mask !== undefined ? { mask: srcMotion.mask } : {}), - ...(srcMotion.raw_mask !== undefined - ? { raw_mask: srcMotion.raw_mask } - : {}), - }; + const srcMask = (sourceSectionValue as { mask?: unknown }).mask; + if (srcMask !== undefined) { + pendingSectionValue = { + ...(pendingSectionValue as object), + mask: stripDictEntryFields(srcMask, ["enabled_in_config"]), + }; + } } if (key === "objects" && selectedKeys.has("object_masks")) { - const srcObjects = sourceSectionValue as { mask?: unknown }; - pendingSectionValue = { - ...(pendingSectionValue as object), - ...(srcObjects.mask !== undefined ? { mask: srcObjects.mask } : {}), - }; + const srcMask = (sourceSectionValue as { mask?: unknown }).mask; + if (srcMask !== undefined) { + pendingSectionValue = { + ...(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({ @@ -333,27 +627,18 @@ export function buildClonedCameraPayloads({ } } - // 3a. Standalone mask payloads — emitted ONLY when the parent section's - // category is unselected. (When both are selected the masks were - // merged into the parent payload above.) + // Standalone mask payloads — only when the parent section isn't also + // selected (otherwise the masks were merged into its payload above). if (selectedKeys.has("motion_mask") && !selectedKeys.has("motion")) { - const srcMotion = sourceCfg.motion as - | { mask?: unknown; raw_mask?: unknown } - | undefined; - if ( - srcMotion && - (srcMotion.mask !== undefined || srcMotion.raw_mask !== undefined) - ) { + const srcMask = (sourceCfg.motion as { mask?: unknown } | undefined)?.mask; + if (srcMask !== undefined) { payloads.push({ basePath: `cameras.${target}.motion`, sanitizedOverrides: { - ...(srcMotion.mask !== undefined - ? { mask: srcMotion.mask as JsonValue } - : {}), - ...(srcMotion.raw_mask !== undefined - ? { raw_mask: srcMotion.raw_mask as JsonValue } - : {}), - } as JsonObject, + mask: stripDictEntryFields(srcMask, [ + "enabled_in_config", + ]) as JsonValue, + }, updateTopic: `config/cameras/${target}/${cameraUpdateTopicMap.motion}`, needsRestart: false, pendingDataKey: `${target}::motion.masks`, @@ -361,11 +646,15 @@ export function buildClonedCameraPayloads({ } } if (selectedKeys.has("object_masks") && !selectedKeys.has("objects")) { - const srcObjects = sourceCfg.objects as { mask?: unknown } | undefined; - if (srcObjects && srcObjects.mask !== undefined) { + const srcMask = (sourceCfg.objects as { mask?: unknown } | undefined)?.mask; + if (srcMask !== undefined) { payloads.push({ 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}`, needsRestart: false, pendingDataKey: `${target}::objects.masks`, @@ -373,7 +662,7 @@ export function buildClonedCameraPayloads({ } } - // 4. Profiles — wholesale replacement of cameras..profiles. + // Profiles — wholesale dict replacement; no hot-reload topic. if (selectedKeys.has("profiles")) { const srcProfiles = (sourceCfg as { profiles?: unknown }).profiles; if (srcProfiles && typeof srcProfiles === "object") { @@ -387,19 +676,23 @@ export function buildClonedCameraPayloads({ } } - // sourceName is currently unused but kept in the signature so the dialog - // can pass it explicitly (matches the helper's logical boundary — "clone - // FROM x TO y" — and lets future logging hook in without a signature change). - void sourceName; + // Reset markers are meaningless for a new camera; see stripResetMarkers. + if (targetIsNew) { + return payloads + .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; } /** - * Flatten clone payloads into the preview-popover item shape. Each item's - * `fieldPath` is rendered camera-relative (e.g., `record.retain.days`) - * rather than absolute (`cameras.front_door.record.retain.days`), matching - * the section-level Save All preview pattern in BaseSection. + * Flatten payloads to `SaveAllPreviewItem`s with camera-relative + * `fieldPath`s (matches BaseSection's per-section preview). */ export function buildClonePreviewItems( payloads: SectionSavePayload[],