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
@@ -42,6 +42,7 @@ export type MotionReviewTimelineProps = {
events: ReviewSegment[];
motion_events: MotionData[];
noRecordingRanges?: RecordingSegment[];
subOnlyRanges?: Pick<RecordingSegment, "start_time" | "end_time">[];
contentRef: RefObject<HTMLDivElement | null>;
timelineRef?: RefObject<HTMLDivElement | null>;
onHandlebarDraggingChange?: (isDragging: boolean) => void;
@@ -76,6 +77,7 @@ export function MotionReviewTimeline({
events,
motion_events,
noRecordingRanges,
subOnlyRanges,
contentRef,
timelineRef,
onHandlebarDraggingChange,
@@ -122,6 +124,17 @@ export function MotionReviewTimeline({
[noRecordingRanges],
);
const getIsSubOnly = useCallback(
(time: number): boolean => {
if (subOnlyRanges == undefined) return false;
return subOnlyRanges.some(
(range) => time >= range.start_time && time < range.end_time,
);
},
[subOnlyRanges],
);
const segmentTimes = useMemo(() => {
const segments = [];
let segmentTime = timelineStartAligned;
@@ -245,6 +258,7 @@ export function MotionReviewTimeline({
motionOnly={motionOnly}
getMotionSegmentValue={getMotionSegmentValue}
getRecordingAvailability={getRecordingAvailability}
getIsSubOnly={getIsSubOnly}
alwaysShowMotionLine={alwaysShowMotionLine}
/>
</ReviewTimeline>
@@ -5,6 +5,7 @@ import React, { useCallback, useEffect, useMemo, useRef } from "react";
import { MinimapBounds, Tick, Timestamp } from "./segment-metadata";
import { useMotionSegmentUtils } from "@/hooks/use-motion-segment-utils";
import { isMobile } from "react-device-detect";
import { useTranslation } from "react-i18next";
import useTapUtils from "@/hooks/use-tap-utils";
import { cn } from "@/lib/utils";
@@ -16,6 +17,7 @@ type MotionSegmentProps = {
firstHalfMotionValue: number;
secondHalfMotionValue: number;
hasRecording?: boolean;
isSubOnly?: boolean;
prevIsNoRecording?: boolean;
nextIsNoRecording?: boolean;
motionOnly: boolean;
@@ -36,6 +38,7 @@ export function MotionSegment({
firstHalfMotionValue,
secondHalfMotionValue,
hasRecording,
isSubOnly,
prevIsNoRecording,
nextIsNoRecording,
motionOnly,
@@ -47,6 +50,7 @@ export function MotionSegment({
dense,
alwaysShowMotionLine = false,
}: MotionSegmentProps) {
const { t } = useTranslation("views/events");
const severityType = "all";
const { getSeverity, getReviewed, displaySeverityType } =
useEventSegmentUtils(segmentDuration, events, severityType);
@@ -194,8 +198,10 @@ export function MotionSegment({
segmentClasses,
severity[0] && "bg-gradient-to-r",
severity[0] && severityColorsBg[severity[0]],
isSubOnly && "bg-background/50",
hasRecording == false && "bg-background",
)}
title={isSubOnly ? t("subOnlyQuality") : undefined}
onClick={segmentClick}
onTouchEnd={(event) => handleTouchStart(event, segmentClick)}
>
@@ -25,6 +25,7 @@ type VirtualizedMotionSegmentsProps = {
motionOnly: boolean;
getMotionSegmentValue: (timestamp: number) => number;
getRecordingAvailability: (timestamp: number) => boolean | undefined;
getIsSubOnly: (timestamp: number) => boolean;
alwaysShowMotionLine: boolean;
};
@@ -58,6 +59,7 @@ export const VirtualizedMotionSegments = forwardRef<
motionOnly,
getMotionSegmentValue,
getRecordingAvailability,
getIsSubOnly,
alwaysShowMotionLine,
},
ref,
@@ -161,6 +163,7 @@ export const VirtualizedMotionSegments = forwardRef<
);
const hasRecording = getRecordingAvailability(segmentTime);
const isSubOnly = getIsSubOnly(segmentTime);
// Check if previous and next segments have recordings
// This is important because in motionOnly mode, the segments array is filtered
@@ -195,6 +198,7 @@ export const VirtualizedMotionSegments = forwardRef<
firstHalfMotionValue={firstHalfMotionValue}
secondHalfMotionValue={secondHalfMotionValue}
hasRecording={hasRecording}
isSubOnly={isSubOnly}
prevIsNoRecording={prevIsNoRecording}
nextIsNoRecording={nextIsNoRecording}
segmentDuration={segmentDuration}
@@ -216,6 +220,7 @@ export const VirtualizedMotionSegments = forwardRef<
events,
getMotionSegmentValue,
getRecordingAvailability,
getIsSubOnly,
motionOnly,
segmentDuration,
showMinimap,