* tweak language

* show validation errors in json response

* fix export hwaccel args field in UI

* increase annotation offset consts

* fix save button race conditions, add reset spinner, and fix enrichments profile leak

- Disable both Save and SaveAll buttons while either operation is in progress so users cannot trigger concurrent saves
- Show activity indicator on Reset to Default/Global button during the API call
- Enrichments panes (semantic search, genai, face recognition) now always show base config fields regardless of profile selection in the header dropdown

* fix genai additional_concerns validation error with textarea array widget

The additional_concerns field is list[str] in the backend but was using the textarea widget which produces a string value, causing validation errors.
Created a TextareaArrayWidget that converts between array (one item per line) and textarea display, and switched additional_concerns to use it

* populate and sort global audio filters for all audio labels

* add column labels in profiles view

* enforce a minimum value of 2 for min_initialized

* reuse widget and refactor for multiline

* fix

* change record copy preset to transcode audio to aac
This commit is contained in:
Josh Hawkins
2026-03-26 13:47:24 -05:00
committed by GitHub
parent 909b40ba96
commit 4772e6a2ab
17 changed files with 264 additions and 69 deletions
@@ -23,7 +23,7 @@ const record: SectionConfigOverrides = {
uiSchema: {
export: {
hwaccel_args: {
"ui:options": { size: "lg" },
"ui:options": { suppressMultiSchema: true, size: "lg" },
},
},
},
@@ -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: {
@@ -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({
<Button
onClick={() => setIsResetDialogOpen(true)}
variant="outline"
disabled={isSaving || disabled}
disabled={isSaving || isResettingToDefault || disabled}
className="flex flex-1 gap-2"
>
{isResettingToDefault && (
<ActivityIndicator className="h-4 w-4" />
)}
{effectiveLevel === "global"
? t("button.resetToDefault", {
ns: "common",
@@ -980,7 +996,7 @@ export function ConfigSection({
<Button
onClick={handleReset}
variant="outline"
disabled={isSaving || disabled}
disabled={isSaving || isSavingAll || disabled}
className="flex min-w-36 flex-1 gap-2"
>
{t("button.undo", { ns: "common", defaultValue: "Undo" })}
@@ -990,7 +1006,11 @@ export function ConfigSection({
onClick={handleSave}
variant="select"
disabled={
!hasChanges || hasValidationErrors || isSaving || disabled
!hasChanges ||
hasValidationErrors ||
isSaving ||
isSavingAll ||
disabled
}
className="flex min-w-36 flex-1 gap-2"
>
@@ -1,33 +1,101 @@
// Widget that displays an array as a concatenated text string
// Widget that displays an array as editable text.
// Single-line mode (default): space-separated in an Input.
// Multiline mode (options.multiline): one item per line in a Textarea.
import type { WidgetProps } from "@rjsf/utils";
import { Input } from "@/components/ui/input";
import { useCallback } from "react";
import { Textarea } from "@/components/ui/textarea";
import { cn } from "@/lib/utils";
import { getSizedFieldClassName } from "../utils";
import { useCallback, useEffect, useState } from "react";
function arrayToText(value: unknown, multiline: boolean): string {
const sep = multiline ? "\n" : " ";
if (Array.isArray(value) && value.length > 0) {
return value.join(sep);
}
if (typeof value === "string") {
return value;
}
return "";
}
function textToArray(text: string, multiline: boolean): string[] {
if (text.trim() === "") {
return [];
}
return multiline
? text.split("\n").filter((line) => line.trim() !== "")
: text.trim().split(/\s+/);
}
export function ArrayAsTextWidget(props: WidgetProps) {
const { value, onChange, disabled, readonly, placeholder } = props;
const {
id,
value,
disabled,
readonly,
onChange,
onBlur,
onFocus,
placeholder,
schema,
options,
} = props;
// Convert array or string to text
let textValue = "";
if (typeof value === "string" && value.length > 0) {
textValue = value;
} else if (Array.isArray(value) && value.length > 0) {
textValue = value.join(" ");
}
const multiline = !!(options.multiline as boolean);
const handleChange = useCallback(
(event: React.ChangeEvent<HTMLInputElement>) => {
const newText = event.target.value;
// Convert space-separated string back to array
const newArray = newText.trim() ? newText.trim().split(/\s+/) : [];
onChange(newArray);
// Local state keeps raw text so newlines aren't stripped mid-typing
const [text, setText] = useState(() => arrayToText(value, multiline));
useEffect(() => {
setText(arrayToText(value, multiline));
}, [value, multiline]);
const fieldClassName = multiline
? getSizedFieldClassName(options, "md")
: undefined;
const handleInputChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
const raw = e.target.value;
setText(raw);
onChange(textToArray(raw, multiline));
},
[onChange],
[onChange, multiline],
);
const handleBlur = useCallback(
(e: React.FocusEvent<HTMLInputElement | HTMLTextAreaElement>) => {
// Clean up: strip empty entries and sync
const cleaned = textToArray(e.target.value, multiline);
onChange(cleaned);
setText(arrayToText(cleaned, multiline));
onBlur?.(id, e.target.value);
},
[id, onChange, onBlur, multiline],
);
if (multiline) {
return (
<Textarea
id={id}
className={cn("text-md", fieldClassName)}
value={text}
disabled={disabled || readonly}
rows={(options.rows as number) || 3}
onChange={handleInputChange}
onBlur={handleBlur}
onFocus={(e) => onFocus?.(id, e.target.value)}
aria-label={schema.title}
/>
);
}
return (
<Input
value={textValue}
onChange={handleChange}
value={text}
onChange={handleInputChange}
onBlur={handleBlur}
disabled={disabled}
readOnly={readonly}
placeholder={placeholder}
@@ -9,15 +9,16 @@ import { toast } from "sonner";
import { Trans, useTranslation } from "react-i18next";
import { LuExternalLink, LuInfo, LuMinus, LuPlus } from "react-icons/lu";
import { cn } from "@/lib/utils";
import {
ANNOTATION_OFFSET_MAX,
ANNOTATION_OFFSET_MIN,
ANNOTATION_OFFSET_STEP,
} from "@/lib/const";
import { isMobile } from "react-device-detect";
import { useIsAdmin } from "@/hooks/use-is-admin";
import { useDocDomain } from "@/hooks/use-doc-domain";
import { Link } from "react-router-dom";
const OFFSET_MIN = -2500;
const OFFSET_MAX = 2500;
const OFFSET_STEP = 50;
type Props = {
className?: string;
};
@@ -43,7 +44,10 @@ export default function AnnotationOffsetSlider({ className }: Props) {
(delta: number) => {
setAnnotationOffset((prev) => {
const next = prev + delta;
return Math.max(OFFSET_MIN, Math.min(OFFSET_MAX, next));
return Math.max(
ANNOTATION_OFFSET_MIN,
Math.min(ANNOTATION_OFFSET_MAX, next),
);
});
},
[setAnnotationOffset],
@@ -114,17 +118,17 @@ export default function AnnotationOffsetSlider({ className }: Props) {
size="icon"
className="size-8 shrink-0"
aria-label="-50ms"
onClick={() => stepOffset(-OFFSET_STEP)}
disabled={annotationOffset <= OFFSET_MIN}
onClick={() => stepOffset(-ANNOTATION_OFFSET_STEP)}
disabled={annotationOffset <= ANNOTATION_OFFSET_MIN}
>
<LuMinus className="size-4" />
</Button>
<div className="w-full flex-1 landscape:flex">
<Slider
value={[annotationOffset]}
min={OFFSET_MIN}
max={OFFSET_MAX}
step={OFFSET_STEP}
min={ANNOTATION_OFFSET_MIN}
max={ANNOTATION_OFFSET_MAX}
step={ANNOTATION_OFFSET_STEP}
onValueChange={handleChange}
/>
</div>
@@ -134,8 +138,8 @@ export default function AnnotationOffsetSlider({ className }: Props) {
size="icon"
className="size-8 shrink-0"
aria-label="+50ms"
onClick={() => stepOffset(OFFSET_STEP)}
disabled={annotationOffset >= OFFSET_MAX}
onClick={() => stepOffset(ANNOTATION_OFFSET_STEP)}
disabled={annotationOffset >= ANNOTATION_OFFSET_MAX}
>
<LuPlus className="size-4" />
</Button>
@@ -13,10 +13,11 @@ import { Slider } from "@/components/ui/slider";
import { Trans, useTranslation } from "react-i18next";
import { useDocDomain } from "@/hooks/use-doc-domain";
import { useIsAdmin } from "@/hooks/use-is-admin";
const OFFSET_MIN = -2500;
const OFFSET_MAX = 2500;
const OFFSET_STEP = 50;
import {
ANNOTATION_OFFSET_MAX,
ANNOTATION_OFFSET_MIN,
ANNOTATION_OFFSET_STEP,
} from "@/lib/const";
type AnnotationSettingsPaneProps = {
event: Event;
@@ -49,7 +50,10 @@ export function AnnotationSettingsPane({
(delta: number) => {
setAnnotationOffset((prev) => {
const next = prev + delta;
return Math.max(OFFSET_MIN, Math.min(OFFSET_MAX, next));
return Math.max(
ANNOTATION_OFFSET_MIN,
Math.min(ANNOTATION_OFFSET_MAX, next),
);
});
},
[setAnnotationOffset],
@@ -128,16 +132,16 @@ export function AnnotationSettingsPane({
size="icon"
className="size-8 shrink-0"
aria-label="-50ms"
onClick={() => stepOffset(-OFFSET_STEP)}
disabled={annotationOffset <= OFFSET_MIN}
onClick={() => stepOffset(-ANNOTATION_OFFSET_STEP)}
disabled={annotationOffset <= ANNOTATION_OFFSET_MIN}
>
<LuMinus className="size-4" />
</Button>
<Slider
value={[annotationOffset]}
min={OFFSET_MIN}
max={OFFSET_MAX}
step={OFFSET_STEP}
min={ANNOTATION_OFFSET_MIN}
max={ANNOTATION_OFFSET_MAX}
step={ANNOTATION_OFFSET_STEP}
onValueChange={handleSliderChange}
className="flex-1"
/>
@@ -147,8 +151,8 @@ export function AnnotationSettingsPane({
size="icon"
className="size-8 shrink-0"
aria-label="+50ms"
onClick={() => stepOffset(OFFSET_STEP)}
disabled={annotationOffset >= OFFSET_MAX}
onClick={() => stepOffset(ANNOTATION_OFFSET_STEP)}
disabled={annotationOffset >= ANNOTATION_OFFSET_MAX}
>
<LuPlus className="size-4" />
</Button>