Add sub stream recording with adaptive quality playback (#24009)

* add sub stream recording with adaptive quality playback

Optionally record a second, lower bitrate stream alongside the main
recording stream via a `record_sub` input role and `record.sub` config block, with its own retention windows.
Recordings rows now carry the stream type plus the media details needed to serve both streams from one manifest: video codec, audio presence, audio codec and rate, and a record-time keyframe index.

Playback resolves coverage across both streams and merges them into a single VOD sequence, falling back to a discontinuity manifest with per-clip init segments when the media signatures differ. The player exposes a quality selector, and an auto governor picks the stream from stall time, bandwidth, codec support, and the save-data hint.

* fix tests and i18n
This commit is contained in:
Josh Hawkins
2026-09-12 07:30:04 -06:00
committed by Nicolas Mowen
parent f7c5500ea8
commit 1498231eb9
68 changed files with 6794 additions and 602 deletions
@@ -20,6 +20,13 @@ const ffmpegArgsWidget = (
},
});
const recordSubArgsWidget = () =>
ffmpegArgsWidget("output_args.record_sub", {
allowInherit: true,
forceSplitLayout: true,
unsetLabelKey: "configForm.ffmpegArgs.sameAsRecord",
});
const ffmpeg: SectionConfigOverrides = {
base: {
sectionDocs: "/configuration/ffmpeg_presets",
@@ -75,6 +82,8 @@ const ffmpeg: SectionConfigOverrides = {
output_args: "/configuration/ffmpeg_presets#output-args-presets",
"inputs.output_args": "/configuration/ffmpeg_presets#output-args-presets",
"output_args.record": "/configuration/ffmpeg_presets#output-args-presets",
"output_args.record_sub":
"/configuration/ffmpeg_presets#output-args-presets",
"inputs.roles": "/configuration/cameras/#setting-up-camera-inputs",
apple_compatibility:
"/configuration/camera_specific#h265-cameras-via-safari",
@@ -112,9 +121,11 @@ const ffmpeg: SectionConfigOverrides = {
output_args: {
detect: arrayAsTextWidget,
record: ffmpegArgsWidget("output_args.record"),
record_sub: recordSubArgsWidget(),
items: {
detect: arrayAsTextWidget,
record: ffmpegArgsWidget("output_args.record"),
record_sub: recordSubArgsWidget(),
},
},
inputs: {
@@ -148,6 +159,7 @@ const ffmpeg: SectionConfigOverrides = {
items: {
detect: arrayAsTextWidget,
record: ffmpegArgsWidget("output_args.record"),
record_sub: recordSubArgsWidget(),
},
},
},
@@ -176,6 +188,7 @@ const ffmpeg: SectionConfigOverrides = {
output_args: {
detect: arrayAsTextWidget,
record: ffmpegArgsWidget("output_args.record"),
record_sub: recordSubArgsWidget(),
},
},
},
@@ -15,6 +15,19 @@ const record: SectionConfigOverrides = {
);
},
},
{
key: "no-record-sub-role",
messageKey: "configMessages.record.noRecordSubRole",
severity: "warning",
condition: (ctx) => {
if (ctx.level !== "camera" || !ctx.fullCameraConfig) return false;
const sub = ctx.formData?.sub as Record<string, unknown> | undefined;
if (!sub?.enabled) return false;
return !ctx.fullCameraConfig.ffmpeg?.inputs?.some((i) =>
i.roles?.includes("record_sub"),
);
},
},
],
fieldDocs: {
"alerts.pre_capture":
@@ -34,6 +47,7 @@ const record: SectionConfigOverrides = {
"motion",
"alerts",
"detections",
"sub",
"preview",
"export",
],
@@ -67,6 +81,12 @@ const record: SectionConfigOverrides = {
"detections.retain.mode": {
"ui:options": { enumI18nPrefix: "retainMode" },
},
"sub.alerts.mode": {
"ui:options": { enumI18nPrefix: "retainMode" },
},
"sub.detections.mode": {
"ui:options": { enumI18nPrefix: "retainMode" },
},
preview: {
"ui:options": { defaultOpen: true, disableCollapsible: true },
quality: {
@@ -193,6 +193,25 @@ export function CameraInputsField(props: FieldProps) {
}
}, [fieldPathId.path, inputs, onChange]);
const getRolesUsedByOtherInputs = useCallback(
(index: number): string[] => {
const used = new Set<string>();
inputs.forEach((input, currentIndex) => {
if (currentIndex === index || !Array.isArray(input.roles)) {
return;
}
input.roles.forEach((role) => {
if (typeof role === "string") {
used.add(role);
}
});
});
return [...used];
},
[inputs],
);
const handleFieldValueChange = useCallback(
(index: number, fieldName: string, nextValue: unknown) => {
const nextInputs = cloneDeep(inputs);
@@ -466,7 +485,16 @@ export function CameraInputsField(props: FieldProps) {
/>
</div>
<div className="w-full">{renderField(index, "roles")}</div>
<div className="w-full">
{renderField(index, "roles", {
extraUiSchema: {
"ui:options": {
rolesUsedByOtherInputs:
getRolesUsedByOtherInputs(index),
},
},
})}
</div>
{renderField(index, "input_args")}
@@ -30,6 +30,7 @@ type PresetField =
| "hwaccel_args"
| "input_args"
| "output_args.record"
| "output_args.record_sub"
| "output_args.detect";
const getPresetOptions = (
@@ -49,7 +50,10 @@ const getPresetOptions = (
}
if (field.startsWith("output_args.")) {
const key = field.split(".")[1] as "record" | "detect";
const key =
field === "output_args.record_sub"
? "record"
: (field.split(".")[1] as "record" | "detect");
return data.output_args?.[key] ?? [];
}
@@ -127,6 +131,7 @@ export function FfmpegArgsWidget(props: WidgetProps) {
const globalFieldPath =
(options?.ffmpegGlobalFieldPath as string | undefined) ?? presetField;
const allowInherit = options?.allowInherit === true;
const unsetLabelKey = options?.unsetLabelKey as string | undefined;
const hideDescription = options?.hideDescription === true;
const useSplitLayout = options?.splitLayout !== false;
@@ -287,6 +292,12 @@ export function FfmpegArgsWidget(props: WidgetProps) {
: "ffmpeg.output_args.record.description";
}
if (presetField === "output_args.record_sub") {
return isInputScoped
? "ffmpeg.inputs.output_args.record_sub.description"
: "ffmpeg.output_args.record_sub.description";
}
if (presetField === "output_args.detect") {
return isInputScoped
? "ffmpeg.inputs.output_args.detect.description"
@@ -345,7 +356,9 @@ export function FfmpegArgsWidget(props: WidgetProps) {
}
/>
<label htmlFor={`${id}-inherit`} className="cursor-pointer text-sm">
{t("configForm.ffmpegArgs.inherit", { ns: "views/settings" })}
{t(unsetLabelKey ?? "configForm.ffmpegArgs.inherit", {
ns: "views/settings",
})}
</label>
</div>
) : (
@@ -361,7 +374,9 @@ export function FfmpegArgsWidget(props: WidgetProps) {
}
/>
<label htmlFor={`${id}-none`} className="cursor-pointer text-sm">
{t("configForm.ffmpegArgs.none", { ns: "views/settings" })}
{t(unsetLabelKey ?? "configForm.ffmpegArgs.none", {
ns: "views/settings",
})}
</label>
</div>
)}
@@ -3,7 +3,14 @@ import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import { Switch } from "@/components/ui/switch";
const INPUT_ROLES = ["detect", "record", "audio"] as const;
const INPUT_ROLES = ["detect", "record", "record_sub", "audio"] as const;
// Recording the sub stream from the same input as record would just
// re-record the main stream, so the two roles are mutually exclusive.
const CONFLICTING_ROLES: Partial<Record<string, string>> = {
record: "record_sub",
record_sub: "record",
};
function normalizeValue(value: unknown): string[] {
if (Array.isArray(value)) {
@@ -18,11 +25,18 @@ function normalizeValue(value: unknown): string[] {
}
export function InputRolesWidget(props: WidgetProps) {
const { id, value, disabled, readonly, onChange } = props;
const { id, value, disabled, readonly, onChange, options } = props;
const { t } = useTranslation(["views/settings"]);
const selectedRoles = useMemo(() => normalizeValue(value), [value]);
// Each role may only be assigned to a single input, so roles already
// used by sibling inputs are locked.
const rolesUsedByOtherInputs = useMemo(
() => normalizeValue(options?.rolesUsedByOtherInputs),
[options],
);
const toggleRole = (role: string, enabled: boolean) => {
if (enabled) {
if (!selectedRoles.includes(role)) {
@@ -39,6 +53,20 @@ export function InputRolesWidget(props: WidgetProps) {
<div className="grid gap-2">
{INPUT_ROLES.map((role) => {
const checked = selectedRoles.includes(role);
const usedByOtherInput =
!checked && rolesUsedByOtherInputs.includes(role);
const conflictingRole = CONFLICTING_ROLES[role];
const hasConflict =
!checked &&
conflictingRole !== undefined &&
selectedRoles.includes(conflictingRole);
const hint = usedByOtherInput
? t("configForm.inputRoles.roleInUse", { ns: "views/settings" })
: hasConflict
? t("configForm.inputRoles.recordSubConflict", {
ns: "views/settings",
})
: undefined;
const label = t(`configForm.inputRoles.options.${role}`, {
ns: "views/settings",
defaultValue: role,
@@ -49,13 +77,20 @@ export function InputRolesWidget(props: WidgetProps) {
key={role}
className="flex items-center justify-between rounded-md px-3 py-0"
>
<label htmlFor={`${id}-${role}`} className="text-sm">
{label}
</label>
<div className="flex flex-col">
<label htmlFor={`${id}-${role}`} className="text-sm">
{label}
</label>
{hint ? (
<span className="text-xs text-muted-foreground">{hint}</span>
) : null}
</div>
<Switch
id={`${id}-${role}`}
checked={checked}
disabled={disabled || readonly}
disabled={
disabled || readonly || usedByOtherInput || hasConflict
}
onCheckedChange={(enabled) => toggleRole(role, !!enabled)}
/>
</div>