mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-09-27 22:08:58 +03:00
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:
committed by
Nicolas Mowen
parent
f7c5500ea8
commit
1498231eb9
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user