Support main+sub stream exports (#24193)

* Support multi resolution exports

* Fix decoder text

* Add dropdown and ability to select export stream selection

* Fix for review comments

* Fix mypy

* Cleanup wording
This commit is contained in:
Nicolas Mowen
2026-09-12 07:30:04 -06:00
parent 70ce193e09
commit 82be9fff5e
15 changed files with 1164 additions and 49 deletions
+6 -1
View File
@@ -490,6 +490,8 @@ export function ActiveExportJobCard({
return t("jobCard.encoding");
case "encoding_retry":
return t("jobCard.encodingRetry");
case "merging":
return t("jobCard.merging");
case "finalizing":
return t("jobCard.finalizing");
default:
@@ -498,7 +500,10 @@ export function ActiveExportJobCard({
}, [step, t]);
const hasDeterminateProgress =
step === "copying" || step === "encoding" || step === "encoding_retry";
step === "copying" ||
step === "encoding" ||
step === "encoding_retry" ||
step === "merging";
return (
<div
@@ -24,6 +24,7 @@ import {
BatchExportResponse,
CameraActivity,
ExportCase,
ExportStreamSelection,
StartExportResponse,
} from "@/types/export";
import {
@@ -117,6 +118,7 @@ export default function ExportDialog({
const [newCaseName, setNewCaseName] = useState("");
const [newCaseDescription, setNewCaseDescription] = useState("");
const [activeTab, setActiveTab] = useState<ExportTab>("export");
const [stream, setStream] = useState<ExportStreamSelection>("auto");
const [isStartingExport, setIsStartingExport] = useState(false);
const previousModeRef = useRef<ExportMode>(mode);
const preTimelineRangeRef = useRef<TimeRange | undefined>(undefined);
@@ -179,6 +181,7 @@ export default function ExportDialog({
source: "recordings",
name,
export_case_id: exportCaseId,
stream,
},
);
@@ -201,6 +204,7 @@ export default function ExportDialog({
setBatchCaseSelection("new");
setNewCaseName("");
setNewCaseDescription("");
setStream("auto");
setRange(undefined);
setMode("none");
return true;
@@ -230,6 +234,7 @@ export default function ExportDialog({
selectedCaseId,
singleNewCaseDescription,
singleNewCaseName,
stream,
setMode,
setRange,
t,
@@ -249,6 +254,7 @@ export default function ExportDialog({
setBatchCaseSelection("new");
setNewCaseName("");
setNewCaseDescription("");
setStream("auto");
setMode("none");
setRange(undefined);
setActiveTab("export");
@@ -334,6 +340,7 @@ export default function ExportDialog({
}
>
<ExportContent
camera={camera}
latestTime={latestTime}
earliestTime={earliestTime}
currentTime={currentTime}
@@ -346,9 +353,11 @@ export default function ExportDialog({
newCaseName={newCaseName}
newCaseDescription={newCaseDescription}
activeTab={activeTab}
stream={stream}
isStartingExport={isStartingExport}
onStartExport={onStartExport}
setActiveTab={setActiveTab}
setStream={setStream}
setName={setName}
setSelectedCaseId={setSelectedCaseId}
setSingleNewCaseName={setSingleNewCaseName}
@@ -368,6 +377,7 @@ export default function ExportDialog({
}
type ExportContentProps = {
camera: string;
latestTime: number;
earliestTime: number;
currentTime: number;
@@ -380,9 +390,11 @@ type ExportContentProps = {
newCaseName: string;
newCaseDescription: string;
activeTab: ExportTab;
stream: ExportStreamSelection;
isStartingExport: boolean;
onStartExport: () => Promise<boolean>;
setActiveTab: (tab: ExportTab) => void;
setStream: (stream: ExportStreamSelection) => void;
setName: (name: string) => void;
setSelectedCaseId: (caseId: string | undefined) => void;
setSingleNewCaseName: (name: string) => void;
@@ -397,6 +409,7 @@ type ExportContentProps = {
};
export function ExportContent({
camera,
latestTime,
earliestTime,
currentTime,
@@ -409,9 +422,11 @@ export function ExportContent({
newCaseName,
newCaseDescription,
activeTab,
stream,
isStartingExport,
onStartExport,
setActiveTab,
setStream,
setName,
setSelectedCaseId,
setSingleNewCaseName,
@@ -439,6 +454,64 @@ export function ExportContent({
const [cameraSearch, setCameraSearch] = useState("");
const [cameraMenuOpen, setCameraMenuOpen] = useState(false);
const cameraMenuRef = useRef<HTMLDivElement>(null);
// the stream choice is only meaningful where a sub stream is recorded:
// this camera on the single tab, any selected camera on the multi tab
const streamOptions = useMemo<ExportStreamSelection[]>(() => {
if (!config) {
return [];
}
const subEnabled = (cameraId: string) =>
config.cameras[cameraId]?.record.sub.enabled === true;
const cameras = activeTab === "multi" ? selectedCameraIds : [camera];
// no sub stream anywhere in the selection means there is no choice
if (!cameras.some(subEnabled)) {
return [];
}
// the pin applies to every item in a batch, so offering "sub" for a
// mixed selection would drop the cameras that cannot honor it. Main
// and auto are always exportable
return cameras.every(subEnabled)
? ["auto", "main", "sub"]
: ["auto", "main"];
}, [activeTab, camera, config, selectedCameraIds]);
const showStreamSelect = streamOptions.length > 0;
// a pin the current selection can no longer honor would silently change
// what the next export contains
useEffect(() => {
if (stream !== "auto" && !streamOptions.includes(stream)) {
setStream("auto");
}
}, [streamOptions, stream, setStream]);
const streamSelect = (
<div className="space-y-2">
<Label className="text-sm text-primary">{t("export.stream.label")}</Label>
<Select
value={stream}
onValueChange={(value) => setStream(value as ExportStreamSelection)}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{streamOptions.map((option) => (
<SelectItem key={option} value={option}>
{t(`export.stream.${option}`)}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-xs text-secondary-foreground">
{t(`export.stream.${stream}Desc`)}
</p>
</div>
);
const multiRangeKey = useMemo(() => {
if (activeTab !== "multi" || !range) {
return undefined;
@@ -746,6 +819,7 @@ export function ExportContent({
? `${name} - ${resolveCameraName(config, cameraId)}`
: undefined,
})),
stream,
};
if (isAdmin && batchCaseSelection !== "none") {
@@ -859,6 +933,7 @@ export function ExportContent({
newCaseName,
range,
selectedCameraIds,
stream,
setActiveTab,
setBatchCaseSelection,
setMode,
@@ -961,6 +1036,8 @@ export function ExportContent({
onChange={(e) => setName(e.target.value)}
/>
{showStreamSelect && streamSelect}
{isAdmin && (
<div className="space-y-2">
<Label className="text-sm text-primary">
@@ -1252,6 +1329,8 @@ export function ExportContent({
/>
</div>
{showStreamSelect && streamSelect}
{isAdmin && (
<div className="space-y-2">
<Label className="text-sm text-primary">
@@ -32,7 +32,7 @@ import SaveExportOverlay from "./SaveExportOverlay";
import { isMobile } from "react-device-detect";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
import { StartExportResponse } from "@/types/export";
import { ExportStreamSelection, StartExportResponse } from "@/types/export";
import { ShareTimestampContent } from "./ShareTimestampDialog";
import { useIsAdmin } from "@/hooks/use-is-admin";
import { cn } from "@/lib/utils";
@@ -156,6 +156,7 @@ export default function MobileReviewSettingsDrawer({
const [batchCaseSelection, setBatchCaseSelection] = useState("new");
const [newCaseName, setNewCaseName] = useState("");
const [newCaseDescription, setNewCaseDescription] = useState("");
const [stream, setStream] = useState<ExportStreamSelection>("auto");
const [isStartingExport, setIsStartingExport] = useState(false);
const preTimelineRangeRef = useRef<TimeRange | undefined>(undefined);
@@ -219,6 +220,7 @@ export default function MobileReviewSettingsDrawer({
source: "recordings",
name,
export_case_id: exportCaseId,
stream,
},
);
@@ -243,6 +245,7 @@ export default function MobileReviewSettingsDrawer({
setBatchCaseSelection("new");
setNewCaseName("");
setNewCaseDescription("");
setStream("auto");
setRange(undefined);
setMode("none");
return true;
@@ -275,6 +278,7 @@ export default function MobileReviewSettingsDrawer({
selectedCaseId,
singleNewCaseDescription,
singleNewCaseName,
stream,
setRange,
setMode,
t,
@@ -476,6 +480,7 @@ export default function MobileReviewSettingsDrawer({
} else if (drawerMode == "export") {
content = (
<ExportContent
camera={camera}
latestTime={latestTime}
earliestTime={earliestTime}
currentTime={currentTime}
@@ -488,9 +493,11 @@ export default function MobileReviewSettingsDrawer({
newCaseName={newCaseName}
newCaseDescription={newCaseDescription}
activeTab={exportTab}
stream={stream}
isStartingExport={isStartingExport}
onStartExport={onStartExport}
setActiveTab={setExportTab}
setStream={setStream}
setName={setName}
setSelectedCaseId={setSelectedCaseId}
setSingleNewCaseName={setSingleNewCaseName}
@@ -516,6 +523,7 @@ export default function MobileReviewSettingsDrawer({
setBatchCaseSelection("new");
setNewCaseName("");
setNewCaseDescription("");
setStream("auto");
setExportTab("export");
setDrawerMode("select");
}}