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
@@ -10,7 +10,12 @@ import {
DebugReplayContent,
SaveDebugReplayOverlay,
} from "./DebugReplayDialog";
import { ExportMode, GeneralFilter } from "@/types/filter";
import {
DEFAULT_DRAWER_FEATURES,
DrawerFeatures,
ExportMode,
GeneralFilter,
} from "@/types/filter";
import ReviewActivityCalendar from "./ReviewActivityCalendar";
import { SelectSeparator } from "../ui/select";
import {
@@ -31,6 +36,14 @@ import { StartExportResponse } from "@/types/export";
import { ShareTimestampContent } from "./ShareTimestampDialog";
import { useIsAdmin } from "@/hooks/use-is-admin";
import { cn } from "@/lib/utils";
import { FaTriangleExclamation } from "react-icons/fa6";
import { MdHighQuality } from "react-icons/md";
import { QualitySelectorContent } from "../player/QualitySelector";
import {
AutoQualityReason,
PlaybackQuality,
RecordingCoverage,
} from "@/types/record";
type DrawerMode =
| "none"
@@ -39,25 +52,8 @@ type DrawerMode =
| "calendar"
| "filter"
| "debug-replay"
| "share-timestamp";
const DRAWER_FEATURES = [
"export",
"calendar",
"filter",
"debug-replay",
"share-timestamp",
"motion-search",
] as const;
export type DrawerFeatures = (typeof DRAWER_FEATURES)[number];
const DEFAULT_DRAWER_FEATURES: DrawerFeatures[] = [
"export",
"calendar",
"filter",
"debug-replay",
"share-timestamp",
"motion-search",
];
| "share-timestamp"
| "quality";
type MobileReviewSettingsDrawerProps = {
features?: DrawerFeatures[];
@@ -84,6 +80,12 @@ type MobileReviewSettingsDrawerProps = {
setRange: (range: TimeRange | undefined) => void;
setMode: (mode: ExportMode) => void;
setShowExportPreview: (showPreview: boolean) => void;
quality?: PlaybackQuality;
onSetQuality?: (quality: PlaybackQuality) => void;
qualityStreams?: RecordingCoverage["streams"];
qualityAutoLow?: boolean;
qualityAutoLowReason?: AutoQualityReason;
qualityMainUnsupported?: boolean;
};
export default function MobileReviewSettingsDrawer({
features = DEFAULT_DRAWER_FEATURES,
@@ -110,12 +112,19 @@ export default function MobileReviewSettingsDrawer({
setRange,
setMode,
setShowExportPreview,
quality,
onSetQuality,
qualityStreams,
qualityAutoLow,
qualityAutoLowReason,
qualityMainUnsupported,
}: MobileReviewSettingsDrawerProps) {
const { t } = useTranslation([
"views/recording",
"components/dialog",
"views/replay",
"views/events",
"components/player",
"common",
]);
const isAdmin = useIsAdmin();
@@ -395,6 +404,21 @@ export default function MobileReviewSettingsDrawer({
{t("filter")}
</Button>
)}
{features.includes("quality") && onSetQuality && (
<Button
className="flex w-full items-center justify-center gap-2"
aria-label={t("quality.label", { ns: "components/player" })}
onClick={() => setDrawerMode("quality")}
>
<div className="relative">
<MdHighQuality className="size-5 rounded-md bg-secondary-foreground fill-secondary p-1" />
{qualityAutoLow && (
<FaTriangleExclamation className="absolute -bottom-1 -right-1 size-2.5 text-danger" />
)}
</div>
{t("quality.label", { ns: "components/player" })}
</Button>
)}
{features.includes("share-timestamp") && (
<Button
className="flex w-full items-center justify-center gap-2"
@@ -623,6 +647,33 @@ export default function MobileReviewSettingsDrawer({
}}
/>
);
} else if (drawerMode == "quality") {
content = (
<div className="flex w-full flex-col">
<div className="relative mb-2 h-8 w-full">
<div
className="absolute left-0 text-selected"
onClick={() => setDrawerMode("select")}
>
{t("button.back", { ns: "common" })}
</div>
<div className="absolute left-1/2 -translate-x-1/2 text-muted-foreground">
{t("quality.label", { ns: "components/player" })}
</div>
</div>
<QualitySelectorContent
quality={quality ?? "auto"}
onSetQuality={(newQuality) => {
onSetQuality?.(newQuality);
setDrawerMode("none");
}}
streams={qualityStreams}
autoLow={qualityAutoLow}
autoLowReason={qualityAutoLowReason}
mainUnsupported={qualityMainUnsupported}
/>
</div>
);
} else if (drawerMode == "share-timestamp") {
content = (
<div className="w-full">