Export improvements (#22867)
CI / AMD64 Build (push) Waiting to run
CI / ARM Build (push) Waiting to run
CI / Jetson Jetpack 6 (push) Waiting to run
CI / AMD64 Extra Build (push) Blocked by required conditions
CI / ARM Extra Build (push) Blocked by required conditions
CI / Synaptics Build (push) Blocked by required conditions
CI / Assemble and push default build (push) Blocked by required conditions

* backend

* frontend + i18n

* tests + api spec

* tweak backend to use Job infrastructure for exports

* frontend tweaks and Job infrastructure

* tests

* tweaks

- add ability to remove from case
- change location of counts in case card

* add stale export reaper on startup

* fix toaster close button color

* improve add dialog

* formatting

* hide max_concurrent from camera config export settings

* remove border

* refactor batch endpoint for multiple review items

* frontend

* tests and fastapi spec

* fix deletion of in-progress exports in a case

* tweaks

- hide cases when filtering cameras that have no exports from those cameras
- remove description from case card
- use textarea instead of input for case description in add new case dialog

* add auth exceptions for exports

* add e2e test for deleting cases with exports

* refactor delete and case endpoints

allow bulk deleting and reassigning

* frontend

- bulk selection like Review
- gate admin-only actions
- consolidate dialogs
- spacing/padding tweaks

* i18n and tests

* update openapi spec

* tweaks

- add None to case selection list
- allow new case creation from single cam export dialog

* fix codeql

* fix i18n

* remove unused

* fix frontend tests
This commit is contained in:
Josh Hawkins
2026-04-14 08:19:50 -06:00
committed by GitHub
parent 18c068a3f9
commit e7e6f87682
31 changed files with 6789 additions and 733 deletions
+124 -11
View File
@@ -1,6 +1,6 @@
import ActivityIndicator from "../indicators/activity-indicator";
import { Button } from "../ui/button";
import { useCallback, useMemo, useState } from "react";
import { useCallback, useMemo, useRef, useState } from "react";
import { isMobile } from "react-device-detect";
import { FiMoreVertical } from "react-icons/fi";
import { Skeleton } from "../ui/skeleton";
@@ -13,7 +13,7 @@ import {
} from "../ui/dialog";
import { Input } from "../ui/input";
import useKeyboardListener from "@/hooks/use-keyboard-listener";
import { DeleteClipType, Export, ExportCase } from "@/types/export";
import { DeleteClipType, Export, ExportCase, ExportJob } from "@/types/export";
import { baseUrl } from "@/api/baseUrl";
import { cn } from "@/lib/utils";
import { shareOrCopy } from "@/utils/browserUtil";
@@ -27,7 +27,10 @@ import {
DropdownMenuItem,
DropdownMenuTrigger,
} from "../ui/dropdown-menu";
import { FaFolder } from "react-icons/fa";
import { FaFolder, FaVideo } from "react-icons/fa";
import { HiSquare2Stack } from "react-icons/hi2";
import { useCameraFriendlyName } from "@/hooks/use-camera-friendly-name";
import useContextMenu from "@/hooks/use-contextmenu";
type CaseCardProps = {
className: string;
@@ -41,10 +44,15 @@ export function CaseCard({
exports,
onSelect,
}: CaseCardProps) {
const { t } = useTranslation(["views/exports"]);
const firstExport = useMemo(
() => exports.find((exp) => exp.thumb_path && exp.thumb_path.length > 0),
[exports],
);
const cameraCount = useMemo(
() => new Set(exports.map((exp) => exp.camera)).size,
[exports],
);
return (
<div
@@ -61,10 +69,30 @@ export function CaseCard({
alt=""
/>
)}
{!firstExport && (
<div className="absolute inset-0 bg-gradient-to-br from-secondary via-secondary/80 to-muted" />
)}
<div className="pointer-events-none absolute inset-x-0 bottom-0 z-10 h-16 bg-gradient-to-t from-black/60 to-transparent" />
<div className="absolute bottom-2 left-2 z-20 flex items-center justify-start gap-2 text-white">
<FaFolder />
<div className="capitalize">{exportCase.name}</div>
<div className="absolute right-1 top-1 z-40 flex items-center gap-2 rounded-lg bg-black/50 px-2 py-1 text-xs text-white">
<div className="flex items-center gap-1">
<HiSquare2Stack className="size-3" />
<div>{exports.length}</div>
</div>
<div className="flex items-center gap-1">
<FaVideo className="size-3" />
<div>{cameraCount}</div>
</div>
</div>
<div className="absolute inset-x-2 bottom-2 z-20 text-white">
<div className="flex items-center justify-start gap-2">
<FaFolder />
<div className="truncate smart-capitalize">{exportCase.name}</div>
</div>
{exports.length === 0 && (
<div className="mt-1 text-xs text-white/80">
{t("caseCard.emptyCase")}
</div>
)}
</div>
</div>
);
@@ -73,18 +101,26 @@ export function CaseCard({
type ExportCardProps = {
className: string;
exportedRecording: Export;
isSelected?: boolean;
selectionMode?: boolean;
onSelect: (selected: Export) => void;
onContextSelect?: (selected: Export) => void;
onRename: (original: string, update: string) => void;
onDelete: ({ file, exportName }: DeleteClipType) => void;
onAssignToCase?: (selected: Export) => void;
onRemoveFromCase?: (selected: Export) => void;
};
export function ExportCard({
className,
exportedRecording,
isSelected,
selectionMode,
onSelect,
onContextSelect,
onRename,
onDelete,
onAssignToCase,
onRemoveFromCase,
}: ExportCardProps) {
const { t } = useTranslation(["views/exports"]);
const isAdmin = useIsAdmin();
@@ -92,6 +128,15 @@ export function ExportCard({
exportedRecording.thumb_path.length > 0,
);
// selection
const cardRef = useRef<HTMLDivElement | null>(null);
useContextMenu(cardRef, () => {
if (!exportedRecording.in_progress && onContextSelect) {
onContextSelect(exportedRecording);
}
});
// editing name
const [editName, setEditName] = useState<{
@@ -180,13 +225,18 @@ export function ExportCard({
</Dialog>
<div
ref={cardRef}
className={cn(
"relative flex aspect-video cursor-pointer items-center justify-center rounded-lg bg-black md:rounded-2xl",
className,
)}
onClick={() => {
onClick={(e) => {
if (!exportedRecording.in_progress) {
onSelect(exportedRecording);
if ((selectionMode || e.ctrlKey || e.metaKey) && onContextSelect) {
onContextSelect(exportedRecording);
} else {
onSelect(exportedRecording);
}
}
}}
>
@@ -205,7 +255,7 @@ export function ExportCard({
)}
</>
)}
{!exportedRecording.in_progress && (
{!exportedRecording.in_progress && !selectionMode && (
<div className="absolute bottom-2 right-3 z-40">
<DropdownMenu modal={false}>
<DropdownMenuTrigger>
@@ -254,6 +304,18 @@ export function ExportCard({
{t("tooltip.assignToCase")}
</DropdownMenuItem>
)}
{isAdmin && onRemoveFromCase && (
<DropdownMenuItem
className="cursor-pointer"
aria-label={t("tooltip.removeFromCase")}
onClick={(e) => {
e.stopPropagation();
onRemoveFromCase(exportedRecording);
}}
>
{t("tooltip.removeFromCase")}
</DropdownMenuItem>
)}
{isAdmin && (
<DropdownMenuItem
className="cursor-pointer"
@@ -292,10 +354,61 @@ export function ExportCard({
<Skeleton className="absolute inset-0 aspect-video rounded-lg md:rounded-2xl" />
)}
<ImageShadowOverlay />
<div className="absolute bottom-2 left-3 flex items-end text-white smart-capitalize">
{exportedRecording.name.replaceAll("_", " ")}
<div
className={cn(
"pointer-events-none absolute inset-0 z-10 size-full rounded-lg outline outline-[3px] -outline-offset-[2.8px] md:rounded-2xl",
isSelected
? "shadow-selected outline-selected"
: "outline-transparent duration-500",
)}
/>
<div className="absolute bottom-2 left-3 right-12 z-30 text-white">
<div className="truncate smart-capitalize">
{exportedRecording.name.replaceAll("_", " ")}
</div>
</div>
</div>
</>
);
}
type ActiveExportJobCardProps = {
className?: string;
job: ExportJob;
};
export function ActiveExportJobCard({
className = "",
job,
}: ActiveExportJobCardProps) {
const { t } = useTranslation(["views/exports", "common"]);
const cameraName = useCameraFriendlyName(job.camera);
const displayName = useMemo(() => {
if (job.name && job.name.length > 0) {
return job.name.replaceAll("_", " ");
}
return t("jobCard.defaultName", {
camera: cameraName,
});
}, [cameraName, job.name, t]);
const statusLabel =
job.status === "queued" ? t("jobCard.queued") : t("jobCard.running");
return (
<div
className={cn(
"relative flex aspect-video items-center justify-center overflow-hidden rounded-lg border border-dashed border-border bg-secondary/40 md:rounded-2xl",
className,
)}
>
<div className="absolute right-3 top-3 z-30 rounded-full bg-selected/90 px-2 py-1 text-xs text-selected-foreground">
{statusLabel}
</div>
<div className="flex flex-col items-center gap-3 px-6 text-center">
<ActivityIndicator />
<div className="text-sm font-medium text-primary">{displayName}</div>
</div>
</div>
);
}
+1 -1
View File
@@ -81,7 +81,7 @@ export default function ReviewCard({
axios
.post(
`export/${event.camera}/start/${event.start_time + REVIEW_PADDING}/end/${endTime}`,
`export/${event.camera}/start/${event.start_time - REVIEW_PADDING}/end/${endTime}`,
{ playback: "realtime" },
)
.then((response) => {
@@ -56,6 +56,11 @@ const record: SectionConfigOverrides = {
},
camera: {
restartRequired: [],
hiddenFields: [
"enabled_in_config",
"sync_recordings",
"export.max_concurrent",
],
},
};
@@ -0,0 +1,384 @@
import { useCallback, useMemo, useState } from "react";
import axios from "axios";
import { Button, buttonVariants } from "../ui/button";
import { isDesktop } from "react-device-detect";
import { HiTrash } from "react-icons/hi";
import { LuFolderPlus, LuFolderX } from "react-icons/lu";
import { Export, ExportCase } from "@/types/export";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "../ui/alert-dialog";
import { Label } from "../ui/label";
import { Switch } from "../ui/switch";
import useKeyboardListener from "@/hooks/use-keyboard-listener";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { useIsAdmin } from "@/hooks/use-is-admin";
import OptionAndInputDialog from "../overlay/dialog/OptionAndInputDialog";
type ExportActionGroupProps = {
selectedExports: Export[];
setSelectedExports: (exports: Export[]) => void;
context: "uncategorized" | "case";
cases?: ExportCase[];
currentCaseId?: string;
mutate: () => void;
};
export default function ExportActionGroup({
selectedExports,
setSelectedExports,
context,
cases,
currentCaseId,
mutate,
}: ExportActionGroupProps) {
const { t } = useTranslation(["views/exports", "common"]);
const isAdmin = useIsAdmin();
const onClearSelected = useCallback(() => {
setSelectedExports([]);
}, [setSelectedExports]);
// ── Delete ──────────────────────────────────────────────────────
const onDelete = useCallback(() => {
const ids = selectedExports.map((e) => e.id);
axios
.post("exports/delete", { ids })
.then((resp) => {
if (resp.status === 200) {
toast.success(t("bulkToast.success.delete"), {
position: "top-center",
});
setSelectedExports([]);
mutate();
}
})
.catch((error) => {
const errorMessage =
error.response?.data?.message ||
error.response?.data?.detail ||
"Unknown error";
toast.error(t("bulkToast.error.deleteFailed", { errorMessage }), {
position: "top-center",
});
});
}, [selectedExports, setSelectedExports, mutate, t]);
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [bypassDialog, setBypassDialog] = useState(false);
useKeyboardListener(["Shift"], (_, modifiers) => {
setBypassDialog(modifiers.shift);
return false;
});
const handleDelete = useCallback(() => {
if (bypassDialog) {
onDelete();
} else {
setDeleteDialogOpen(true);
}
}, [bypassDialog, onDelete]);
// ── Remove from case ────────────────────────────────────────────
const [removeDialogOpen, setRemoveDialogOpen] = useState(false);
const [deleteExportsOnRemove, setDeleteExportsOnRemove] = useState(false);
const handleRemoveFromCase = useCallback(() => {
const ids = selectedExports.map((e) => e.id);
const request = deleteExportsOnRemove
? axios.post("exports/delete", { ids })
: axios.post("exports/reassign", { ids, export_case_id: null });
request
.then((resp) => {
if (resp.status === 200) {
toast.success(t("bulkToast.success.remove"), {
position: "top-center",
});
setSelectedExports([]);
mutate();
setRemoveDialogOpen(false);
setDeleteExportsOnRemove(false);
}
})
.catch((error) => {
const errorMessage =
error.response?.data?.message ||
error.response?.data?.detail ||
"Unknown error";
toast.error(t("bulkToast.error.reassignFailed", { errorMessage }), {
position: "top-center",
});
});
}, [selectedExports, deleteExportsOnRemove, setSelectedExports, mutate, t]);
// ── Case picker ─────────────────────────────────────────────────
const [casePickerOpen, setCasePickerOpen] = useState(false);
const caseOptions = useMemo(
() => [
...(cases ?? [])
.filter((c) => c.id !== currentCaseId)
.map((c) => ({
value: c.id,
label: c.name,
}))
.sort((a, b) => a.label.localeCompare(b.label)),
{
value: "new",
label: t("caseDialog.newCaseOption"),
},
],
[cases, currentCaseId, t],
);
const handleAssignToCase = useCallback(
async (caseId: string) => {
const ids = selectedExports.map((e) => e.id);
try {
await axios.post("exports/reassign", {
ids,
export_case_id: caseId,
});
toast.success(t("bulkToast.success.reassign"), {
position: "top-center",
});
setSelectedExports([]);
mutate();
} catch (error) {
const apiError = error as {
response?: { data?: { message?: string; detail?: string } };
};
const errorMessage =
apiError.response?.data?.message ||
apiError.response?.data?.detail ||
"Unknown error";
toast.error(t("bulkToast.error.reassignFailed", { errorMessage }), {
position: "top-center",
});
throw error;
}
},
[selectedExports, setSelectedExports, mutate, t],
);
const handleCreateNewCase = useCallback(
async (name: string, description: string) => {
const ids = selectedExports.map((e) => e.id);
try {
const createResp = await axios.post("cases", { name, description });
const newCaseId: string | undefined = createResp.data?.id;
if (newCaseId) {
await axios.post("exports/reassign", {
ids,
export_case_id: newCaseId,
});
}
toast.success(t("bulkToast.success.reassign"), {
position: "top-center",
});
setSelectedExports([]);
mutate();
} catch (error) {
const apiError = error as {
response?: { data?: { message?: string; detail?: string } };
};
const errorMessage =
apiError.response?.data?.message ||
apiError.response?.data?.detail ||
"Unknown error";
toast.error(t("bulkToast.error.reassignFailed", { errorMessage }), {
position: "top-center",
});
throw error;
}
},
[selectedExports, setSelectedExports, mutate, t],
);
return (
<>
{/* Delete confirmation dialog */}
<AlertDialog
open={deleteDialogOpen}
onOpenChange={() => setDeleteDialogOpen(!deleteDialogOpen)}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t("bulkDelete.title")}</AlertDialogTitle>
</AlertDialogHeader>
<AlertDialogDescription>
{t("bulkDelete.desc", { count: selectedExports.length })}
</AlertDialogDescription>
<AlertDialogFooter>
<AlertDialogCancel>
{t("button.cancel", { ns: "common" })}
</AlertDialogCancel>
<AlertDialogAction
className={buttonVariants({ variant: "destructive" })}
onClick={onDelete}
>
{t("button.delete", { ns: "common" })}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{/* Remove from case dialog */}
{context === "case" && (
<AlertDialog
open={removeDialogOpen}
onOpenChange={(open) => {
if (!open) {
setRemoveDialogOpen(false);
setDeleteExportsOnRemove(false);
}
}}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{t("bulkRemoveFromCase.title")}
</AlertDialogTitle>
<AlertDialogDescription>
{t("bulkRemoveFromCase.desc", {
count: selectedExports.length,
})}{" "}
{deleteExportsOnRemove
? t("bulkRemoveFromCase.descDeleteExports")
: t("bulkRemoveFromCase.descKeepExports")}
</AlertDialogDescription>
</AlertDialogHeader>
<div className="flex items-center justify-start gap-6">
<Label
htmlFor="bulk-delete-exports-switch"
className="cursor-pointer text-sm"
>
{t("bulkRemoveFromCase.deleteExports")}
</Label>
<Switch
id="bulk-delete-exports-switch"
checked={deleteExportsOnRemove}
onCheckedChange={setDeleteExportsOnRemove}
/>
</div>
<AlertDialogFooter>
<AlertDialogCancel>
{t("button.cancel", { ns: "common" })}
</AlertDialogCancel>
<AlertDialogAction
className={buttonVariants({ variant: "destructive" })}
onClick={handleRemoveFromCase}
>
{t("button.delete", { ns: "common" })}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)}
{/* Case picker dialog */}
<OptionAndInputDialog
open={casePickerOpen}
title={t("caseDialog.title")}
description={t("caseDialog.description")}
setOpen={setCasePickerOpen}
options={caseOptions}
nameLabel={t("caseDialog.nameLabel")}
descriptionLabel={t("caseDialog.descriptionLabel")}
initialValue={caseOptions[0]?.value}
newValueKey="new"
onSave={handleAssignToCase}
onCreateNew={handleCreateNewCase}
/>
{/* Action bar */}
<div className="flex w-full items-center justify-end gap-2">
<div className="mx-1 flex items-center justify-center text-sm text-muted-foreground">
<div className="p-1">
{t("selected", { count: selectedExports.length })}
</div>
<div className="p-1">{"|"}</div>
<div
className="cursor-pointer p-2 text-primary hover:rounded-lg hover:bg-secondary"
onClick={onClearSelected}
>
{t("button.unselect", { ns: "common" })}
</div>
</div>
{isAdmin && (
<div className="flex items-center gap-1 md:gap-2">
{/* Add to Case / Move to Case */}
<Button
className="flex items-center gap-2 p-2"
aria-label={
context === "case"
? t("bulkActions.moveToCase")
: t("bulkActions.addToCase")
}
size="sm"
onClick={() => setCasePickerOpen(true)}
>
<LuFolderPlus className="text-secondary-foreground" />
{isDesktop && (
<div className="text-primary">
{context === "case"
? t("bulkActions.moveToCase")
: t("bulkActions.addToCase")}
</div>
)}
</Button>
{/* Remove from Case (case context only) */}
{context === "case" && (
<Button
className="flex items-center gap-2 p-2"
aria-label={t("bulkActions.removeFromCase")}
size="sm"
onClick={() => setRemoveDialogOpen(true)}
>
<LuFolderX className="text-secondary-foreground" />
{isDesktop && (
<div className="text-primary">
{t("bulkActions.removeFromCase")}
</div>
)}
</Button>
)}
{/* Delete */}
<Button
className="flex items-center gap-2 p-2"
aria-label={t("button.delete", { ns: "common" })}
size="sm"
onClick={handleDelete}
>
<HiTrash className="text-secondary-foreground" />
{isDesktop && (
<div className="text-primary">
{bypassDialog
? t("bulkActions.deleteNow")
: t("bulkActions.delete")}
</div>
)}
</Button>
</div>
)}
</div>
</>
);
}
@@ -6,6 +6,7 @@ import { isDesktop } from "react-device-detect";
import { FaCompactDisc } from "react-icons/fa";
import { HiTrash } from "react-icons/hi";
import { ReviewSegment } from "@/types/review";
import { MAX_BATCH_EXPORT_ITEMS } from "@/types/export";
import {
AlertDialog,
AlertDialogAction,
@@ -20,6 +21,7 @@ import useKeyboardListener from "@/hooks/use-keyboard-listener";
import { Trans, useTranslation } from "react-i18next";
import { toast } from "sonner";
import { useIsAdmin } from "@/hooks/use-is-admin";
import MultiExportDialog from "../overlay/MultiExportDialog";
type ReviewActionGroupProps = {
selectedReviews: ReviewSegment[];
@@ -164,6 +166,29 @@ export default function ReviewActionGroup({
)}
</Button>
)}
{selectedReviews.length >= 2 &&
selectedReviews.length <= MAX_BATCH_EXPORT_ITEMS && (
<MultiExportDialog
selectedReviews={selectedReviews}
onStarted={() => {
onClearSelected();
pullLatestData();
}}
>
<Button
className="flex items-center gap-2 p-2"
aria-label={t("recording.button.export")}
size="sm"
>
<FaCompactDisc className="text-secondary-foreground" />
{isDesktop && (
<div className="text-primary">
{t("recording.button.export")}
</div>
)}
</Button>
</MultiExportDialog>
)}
<Button
className="flex items-center gap-2 p-2"
aria-label={
File diff suppressed because it is too large Load Diff
@@ -4,7 +4,7 @@ import { Button } from "../ui/button";
import { FaArrowDown, FaCalendarAlt, FaCog, FaFilter } from "react-icons/fa";
import { LuBug } from "react-icons/lu";
import { TimeRange } from "@/types/timeline";
import { ExportContent, ExportPreviewDialog } from "./ExportDialog";
import { ExportContent, ExportPreviewDialog, ExportTab } from "./ExportDialog";
import {
DebugReplayContent,
SaveDebugReplayOverlay,
@@ -26,6 +26,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";
type DrawerMode =
| "none"
@@ -102,6 +103,7 @@ export default function MobileReviewSettingsDrawer({
]);
const navigate = useNavigate();
const [drawerMode, setDrawerMode] = useState<DrawerMode>("none");
const [exportTab, setExportTab] = useState<ExportTab>("export");
const [selectedReplayOption, setSelectedReplayOption] = useState<
"1" | "5" | "custom" | "timeline"
>("1");
@@ -113,67 +115,112 @@ export default function MobileReviewSettingsDrawer({
const [selectedCaseId, setSelectedCaseId] = useState<string | undefined>(
undefined,
);
const onStartExport = useCallback(() => {
const [singleNewCaseName, setSingleNewCaseName] = useState("");
const [singleNewCaseDescription, setSingleNewCaseDescription] = useState("");
const [isStartingExport, setIsStartingExport] = useState(false);
const onStartExport = useCallback(async () => {
if (isStartingExport) {
return false;
}
if (!range) {
toast.error(t("toast.error.noValidTimeSelected"), {
position: "top-center",
});
return;
toast.error(
t("export.toast.error.noVaildTimeSelected", {
ns: "components/dialog",
}),
{
position: "top-center",
},
);
return false;
}
if (range.before < range.after) {
toast.error(t("toast.error.endTimeMustAfterStartTime"), {
position: "top-center",
});
return;
toast.error(
t("export.toast.error.endTimeMustAfterStartTime", {
ns: "components/dialog",
}),
{
position: "top-center",
},
);
return false;
}
axios
.post(
setIsStartingExport(true);
try {
let exportCaseId: string | undefined = selectedCaseId;
if (selectedCaseId === "new" && singleNewCaseName.trim().length > 0) {
const caseResp = await axios.post("cases", {
name: singleNewCaseName.trim(),
description: singleNewCaseDescription.trim() || undefined,
});
exportCaseId = caseResp.data?.id;
} else if (selectedCaseId === "new" || selectedCaseId === "none") {
exportCaseId = undefined;
}
await axios.post<StartExportResponse>(
`export/${camera}/start/${Math.round(range.after)}/end/${Math.round(range.before)}`,
{
playback: "realtime",
source: "recordings",
name,
export_case_id: selectedCaseId || undefined,
export_case_id: exportCaseId,
},
)
.then((response) => {
if (response.status == 200) {
toast.success(
t("export.toast.success", { ns: "components/dialog" }),
{
position: "top-center",
action: (
<a href="/export" target="_blank" rel="noopener noreferrer">
<Button>
{t("export.toast.view", { ns: "components/dialog" })}
</Button>
</a>
),
},
);
setName("");
setSelectedCaseId(undefined);
setRange(undefined);
setMode("none");
}
})
.catch((error) => {
const errorMessage =
error.response?.data?.message ||
error.response?.data?.detail ||
"Unknown error";
toast.error(
t("export.toast.error.failed", {
ns: "components/dialog",
errorMessage,
}),
{
position: "top-center",
},
);
);
toast.success(t("export.toast.queued", { ns: "components/dialog" }), {
position: "top-center",
action: (
<a href="/export" target="_blank" rel="noopener noreferrer">
<Button>
{t("export.toast.view", { ns: "components/dialog" })}
</Button>
</a>
),
});
}, [camera, name, range, selectedCaseId, setRange, setName, setMode, t]);
setName("");
setSelectedCaseId(undefined);
setSingleNewCaseName("");
setSingleNewCaseDescription("");
setRange(undefined);
setMode("none");
return true;
} catch (error) {
const apiError = error as {
response?: { data?: { message?: string; detail?: string } };
};
const errorMessage =
apiError.response?.data?.message ||
apiError.response?.data?.detail ||
"Unknown error";
toast.error(
t("export.toast.error.failed", {
ns: "components/dialog",
error: errorMessage,
}),
{
position: "top-center",
},
);
return false;
} finally {
setIsStartingExport(false);
}
}, [
camera,
isStartingExport,
name,
range,
selectedCaseId,
singleNewCaseDescription,
singleNewCaseName,
setRange,
setMode,
t,
]);
const onStartDebugReplay = useCallback(async () => {
if (
@@ -267,6 +314,7 @@ export default function MobileReviewSettingsDrawer({
className="flex w-full items-center justify-center gap-2"
aria-label={t("export")}
onClick={() => {
setExportTab("export");
setDrawerMode("export");
setMode("select");
}}
@@ -331,14 +379,21 @@ export default function MobileReviewSettingsDrawer({
range={range}
name={name}
selectedCaseId={selectedCaseId}
singleNewCaseName={singleNewCaseName}
singleNewCaseDescription={singleNewCaseDescription}
activeTab={exportTab}
isStartingExport={isStartingExport}
onStartExport={onStartExport}
setActiveTab={setExportTab}
setName={setName}
setSelectedCaseId={setSelectedCaseId}
setSingleNewCaseName={setSingleNewCaseName}
setSingleNewCaseDescription={setSingleNewCaseDescription}
setRange={setRange}
setMode={(mode) => {
setMode(mode);
if (mode == "timeline") {
if (mode == "timeline" || mode == "timeline_multi") {
setDrawerMode("none");
}
}}
@@ -346,6 +401,9 @@ export default function MobileReviewSettingsDrawer({
setMode("none");
setRange(undefined);
setSelectedCaseId(undefined);
setSingleNewCaseName("");
setSingleNewCaseDescription("");
setExportTab("export");
setDrawerMode("select");
}}
/>
@@ -483,9 +541,29 @@ export default function MobileReviewSettingsDrawer({
<>
<SaveExportOverlay
className="pointer-events-none absolute left-1/2 top-8 z-50 -translate-x-1/2"
show={mode == "timeline"}
onSave={() => onStartExport()}
onCancel={() => setMode("none")}
show={mode == "timeline" || mode == "timeline_multi"}
hidePreview={mode == "timeline_multi"}
isSaving={isStartingExport}
saveLabel={
mode == "timeline_multi"
? t("export.fromTimeline.useThisRange", { ns: "components/dialog" })
: undefined
}
onSave={() => {
if (mode == "timeline_multi") {
setExportTab("multi");
setDrawerMode("export");
setMode("select");
return;
}
void onStartExport();
}}
onCancel={() => {
setExportTab("export");
setRange(undefined);
setMode("none");
}}
onPreview={() => setShowExportPreview(true)}
/>
<SaveDebugReplayOverlay
@@ -0,0 +1,403 @@
import { useCallback, useMemo, useState } from "react";
import { isDesktop } from "react-device-detect";
import axios from "axios";
import { toast } from "sonner";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
import useSWR from "swr";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "../ui/dialog";
import {
Drawer,
DrawerContent,
DrawerDescription,
DrawerHeader,
DrawerTitle,
DrawerTrigger,
} from "../ui/drawer";
import { Button } from "../ui/button";
import { Input } from "../ui/input";
import { Label } from "../ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectSeparator,
SelectTrigger,
SelectValue,
} from "../ui/select";
import { Textarea } from "../ui/textarea";
import {
BatchExportBody,
BatchExportResponse,
BatchExportResult,
ExportCase,
} from "@/types/export";
import { FrigateConfig } from "@/types/frigateConfig";
import { REVIEW_PADDING, ReviewSegment } from "@/types/review";
import { resolveCameraName } from "@/hooks/use-camera-friendly-name";
import { useDateLocale } from "@/hooks/use-date-locale";
import { useIsAdmin } from "@/hooks/use-is-admin";
import { formatUnixTimestampToDateTime } from "@/utils/dateUtil";
type MultiExportDialogProps = {
selectedReviews: ReviewSegment[];
onStarted: () => void;
children: React.ReactNode;
};
const NONE_CASE_OPTION = "none";
const NEW_CASE_OPTION = "new";
export default function MultiExportDialog({
selectedReviews,
onStarted,
children,
}: MultiExportDialogProps) {
const { t } = useTranslation(["components/dialog", "common"]);
const locale = useDateLocale();
const navigate = useNavigate();
const isAdmin = useIsAdmin();
const { data: config } = useSWR<FrigateConfig>("config");
// Only admins can attach exports to an existing case (enforced server-side
// by POST /exports/batch). Skip fetching the case list entirely for
// non-admins — they can only ever use the "Create new case" branch.
const { data: cases } = useSWR<ExportCase[]>(isAdmin ? "cases" : null);
const [open, setOpen] = useState(false);
const [caseSelection, setCaseSelection] = useState<string>(NONE_CASE_OPTION);
const [newCaseName, setNewCaseName] = useState("");
const [newCaseDescription, setNewCaseDescription] = useState("");
const [isExporting, setIsExporting] = useState(false);
const count = selectedReviews.length;
// Resolve a failed batch result back to a human-readable label via the
// client-provided review id when available. Falls back to item_index and
// finally camera name for defensive compatibility.
const formatFailureLabel = useCallback(
(result: BatchExportResult): string => {
const cameraName = resolveCameraName(config, result.camera);
if (result.client_item_id) {
const review = selectedReviews.find(
(item) => item.id === result.client_item_id,
);
if (review) {
const time = formatUnixTimestampToDateTime(review.start_time, {
date_style: "short",
time_style: "short",
locale,
});
return `${cameraName}${time}`;
}
}
if (
typeof result.item_index === "number" &&
result.item_index >= 0 &&
result.item_index < selectedReviews.length
) {
const review = selectedReviews[result.item_index];
const time = formatUnixTimestampToDateTime(review.start_time, {
date_style: "short",
time_style: "short",
locale,
});
return `${cameraName}${time}`;
}
return cameraName;
},
[config, locale, selectedReviews],
);
const defaultCaseName = useMemo(() => {
const formattedDate = formatUnixTimestampToDateTime(Date.now() / 1000, {
date_style: "medium",
time_style: "short",
locale,
});
return t("export.multi.caseNamePlaceholder", {
ns: "components/dialog",
date: formattedDate,
});
}, [t, locale]);
const resetState = useCallback(() => {
setCaseSelection(NONE_CASE_OPTION);
setNewCaseName("");
setNewCaseDescription("");
setIsExporting(false);
}, []);
const handleOpenChange = useCallback(
(next: boolean) => {
if (!next) {
resetState();
} else {
// Freshly reset each time so the default name reflects "now"
setCaseSelection(NONE_CASE_OPTION);
setNewCaseName(defaultCaseName);
setNewCaseDescription("");
setIsExporting(false);
}
setOpen(next);
},
[defaultCaseName, resetState],
);
const existingCases = useMemo(() => {
return (cases ?? []).slice().sort((a, b) => a.name.localeCompare(b.name));
}, [cases]);
const isNewCase = caseSelection === NEW_CASE_OPTION;
const canSubmit = useMemo(() => {
if (isExporting) return false;
if (count === 0) return false;
if (!isAdmin) return true;
if (isNewCase) {
return newCaseName.trim().length > 0;
}
return caseSelection.length > 0;
}, [caseSelection, count, isAdmin, isExporting, isNewCase, newCaseName]);
const handleSubmit = useCallback(async () => {
if (!canSubmit) return;
const items = selectedReviews.map((review) => ({
camera: review.camera,
start_time: review.start_time - REVIEW_PADDING,
end_time: (review.end_time ?? Date.now() / 1000) + REVIEW_PADDING,
image_path: review.thumb_path || undefined,
client_item_id: review.id,
}));
const payload: BatchExportBody = { items };
if (isAdmin && caseSelection !== NONE_CASE_OPTION) {
if (isNewCase) {
payload.new_case_name = newCaseName.trim();
payload.new_case_description = newCaseDescription.trim() || undefined;
} else {
payload.export_case_id = caseSelection;
}
}
setIsExporting(true);
try {
const response = await axios.post<BatchExportResponse>(
"exports/batch",
payload,
);
const results = response.data.results ?? [];
const successful = results.filter((r) => r.success);
const failed = results.filter((r) => !r.success);
if (successful.length > 0 && failed.length === 0) {
toast.success(
t(
isAdmin
? "export.multi.toast.started"
: "export.multi.toast.startedNoCase",
{
ns: "components/dialog",
count: successful.length,
},
),
{ position: "top-center" },
);
} else if (successful.length > 0 && failed.length > 0) {
// Resolve each failure to its review via item_index so same-camera
// items are disambiguated by time. Falls back to camera-only if the
// server didn't populate item_index.
const failedLabels = failed.map(formatFailureLabel).join(", ");
toast.success(
t("export.multi.toast.partial", {
ns: "components/dialog",
successful: successful.length,
total: results.length,
failedItems: failedLabels,
}),
{ position: "top-center" },
);
} else {
const failedLabels = failed.map(formatFailureLabel).join(", ");
toast.error(
t("export.multi.toast.failed", {
ns: "components/dialog",
total: results.length,
failedItems: failedLabels,
}),
{ position: "top-center" },
);
}
if (successful.length > 0) {
onStarted();
setOpen(false);
resetState();
if (response.data.export_case_id) {
navigate(`/export?caseId=${response.data.export_case_id}`);
}
}
} catch (error) {
const apiError = error as {
response?: { data?: { message?: string; detail?: string } };
};
const errorMessage =
apiError.response?.data?.message ||
apiError.response?.data?.detail ||
"Unknown error";
toast.error(
t("export.toast.error.failed", {
ns: "components/dialog",
error: errorMessage,
}),
{ position: "top-center" },
);
} finally {
setIsExporting(false);
}
}, [
canSubmit,
caseSelection,
formatFailureLabel,
isAdmin,
isNewCase,
navigate,
newCaseDescription,
newCaseName,
onStarted,
resetState,
selectedReviews,
t,
]);
// New-case inputs: rendered below the Select when caseSelection === "new",
// or rendered standalone for non-admins (who never see the Select since
// they cannot attach to an existing case).
const newCaseInputs = (
<div className="space-y-2 pt-1">
<Input
className="text-md"
placeholder={t("export.case.newCaseNamePlaceholder")}
value={newCaseName}
onChange={(event) => setNewCaseName(event.target.value)}
maxLength={100}
autoFocus={isDesktop}
/>
<Textarea
className="text-md"
placeholder={t("export.case.newCaseDescriptionPlaceholder")}
value={newCaseDescription}
onChange={(event) => setNewCaseDescription(event.target.value)}
rows={2}
/>
</div>
);
const body = (
<div className="flex flex-col gap-4">
{isAdmin && (
<div className="space-y-2">
<Label className="text-sm text-secondary-foreground">
{t("export.case.label")}
</Label>
<Select
value={caseSelection}
onValueChange={(value) => setCaseSelection(value)}
>
<SelectTrigger>
<SelectValue placeholder={t("export.case.placeholder")} />
</SelectTrigger>
<SelectContent>
<SelectItem value={NONE_CASE_OPTION}>
{t("label.none", { ns: "common" })}
</SelectItem>
{existingCases.map((caseItem) => (
<SelectItem key={caseItem.id} value={caseItem.id}>
{caseItem.name}
</SelectItem>
))}
<SelectSeparator />
<SelectItem value={NEW_CASE_OPTION}>
{t("export.case.newCaseOption")}
</SelectItem>
</SelectContent>
</Select>
{isNewCase && newCaseInputs}
</div>
)}
</div>
);
const footer = (
<>
<Button
variant="outline"
onClick={() => handleOpenChange(false)}
disabled={isExporting}
>
{t("button.cancel", { ns: "common" })}
</Button>
<Button
variant="select"
onClick={handleSubmit}
disabled={!canSubmit}
aria-label={t("export.multi.exportButton", { count })}
>
{isExporting
? t("export.multi.exportingButton")
: t("export.multi.exportButton", { count })}
</Button>
</>
);
if (isDesktop) {
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogTrigger asChild>{children}</DialogTrigger>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>{t("export.multi.title", { count })}</DialogTitle>
<DialogDescription>
{isAdmin
? t("export.multi.description")
: t("export.multi.descriptionNoCase")}
</DialogDescription>
</DialogHeader>
{body}
<DialogFooter className="gap-2">{footer}</DialogFooter>
</DialogContent>
</Dialog>
);
}
return (
<Drawer open={open} onOpenChange={handleOpenChange}>
<DrawerTrigger asChild>{children}</DrawerTrigger>
<DrawerContent className="px-4 pb-6">
<DrawerHeader className="px-0">
<DrawerTitle>{t("export.multi.title", { count })}</DrawerTitle>
<DrawerDescription>
{isAdmin
? t("export.multi.description")
: t("export.multi.descriptionNoCase")}
</DrawerDescription>
</DrawerHeader>
{body}
<div className="mt-4 flex flex-col-reverse gap-2">{footer}</div>
</DrawerContent>
</Drawer>
);
}
@@ -7,6 +7,9 @@ import { useTranslation } from "react-i18next";
type SaveExportOverlayProps = {
className: string;
show: boolean;
hidePreview?: boolean;
saveLabel?: string;
isSaving?: boolean;
onPreview: () => void;
onSave: () => void;
onCancel: () => void;
@@ -14,6 +17,9 @@ type SaveExportOverlayProps = {
export default function SaveExportOverlay({
className,
show,
hidePreview = false,
saveLabel,
isSaving = false,
onPreview,
onSave,
onCancel,
@@ -32,29 +38,36 @@ export default function SaveExportOverlay({
className="flex items-center gap-1 text-primary"
aria-label={t("button.cancel", { ns: "common" })}
size="sm"
disabled={isSaving}
onClick={onCancel}
>
<LuX />
{t("button.cancel", { ns: "common" })}
</Button>
{!hidePreview && (
<Button
className="flex items-center gap-1"
aria-label={t("export.fromTimeline.previewExport")}
size="sm"
disabled={isSaving}
onClick={onPreview}
>
<LuVideo />
{t("export.fromTimeline.previewExport")}
</Button>
)}
<Button
className="flex items-center gap-1"
aria-label={t("export.fromTimeline.previewExport")}
size="sm"
onClick={onPreview}
>
<LuVideo />
{t("export.fromTimeline.previewExport")}
</Button>
<Button
className="flex items-center gap-1"
aria-label={t("export.fromTimeline.saveExport")}
aria-label={saveLabel || t("export.fromTimeline.saveExport")}
variant="select"
size="sm"
disabled={isSaving}
onClick={onSave}
>
<FaCompactDisc />
{t("export.fromTimeline.saveExport")}
{isSaving
? t("export.fromTimeline.queueingExport")
: saveLabel || t("export.fromTimeline.saveExport")}
</Button>
</div>
</div>
@@ -8,6 +8,7 @@ import {
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import {
Select,
SelectContent,
@@ -15,9 +16,10 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import ActivityIndicator from "@/components/indicators/activity-indicator";
import { cn } from "@/lib/utils";
import { isMobile } from "react-device-detect";
import { useEffect, useMemo, useState } from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
type Option = {
@@ -35,8 +37,8 @@ type OptionAndInputDialogProps = {
nameLabel: string;
descriptionLabel: string;
setOpen: (open: boolean) => void;
onSave: (value: string) => void;
onCreateNew: (name: string, description: string) => void;
onSave: (value: string) => Promise<void>;
onCreateNew: (name: string, description: string) => Promise<void>;
};
export default function OptionAndInputDialog({
@@ -69,10 +71,12 @@ export default function OptionAndInputDialog({
}
}, [open, initialValue, firstOption]);
const [isLoading, setIsLoading] = useState(false);
const isNew = selectedValue === newValueKey;
const disableSave = !selectedValue || (isNew && name.trim().length === 0);
const disableSave =
!selectedValue || (isNew && name.trim().length === 0) || isLoading;
const handleSave = () => {
const handleSave = useCallback(async () => {
if (!selectedValue) {
return;
}
@@ -80,13 +84,26 @@ export default function OptionAndInputDialog({
const trimmedName = name.trim();
const trimmedDescription = descriptionValue.trim();
if (isNew) {
onCreateNew(trimmedName, trimmedDescription);
} else {
onSave(selectedValue);
setIsLoading(true);
try {
if (isNew) {
await onCreateNew(trimmedName, trimmedDescription);
} else {
await onSave(selectedValue);
}
setOpen(false);
} finally {
setIsLoading(false);
}
setOpen(false);
};
}, [
selectedValue,
name,
descriptionValue,
isNew,
onCreateNew,
onSave,
setOpen,
]);
return (
<Dialog open={open} defaultOpen={false} onOpenChange={setOpen}>
@@ -127,15 +144,21 @@ export default function OptionAndInputDialog({
<label className="text-sm font-medium text-secondary-foreground">
{nameLabel}
</label>
<Input value={name} onChange={(e) => setName(e.target.value)} />
<Input
className="text-md"
value={name}
onChange={(e) => setName(e.target.value)}
/>
</div>
<div className="space-y-1">
<label className="text-sm font-medium text-secondary-foreground">
{descriptionLabel}
</label>
<Input
<Textarea
className="text-md"
value={descriptionValue}
onChange={(e) => setDescriptionValue(e.target.value)}
rows={2}
/>
</div>
</div>
@@ -145,6 +168,7 @@ export default function OptionAndInputDialog({
<Button
type="button"
variant="outline"
disabled={isLoading}
onClick={() => {
setOpen(false);
}}
@@ -155,9 +179,13 @@ export default function OptionAndInputDialog({
type="button"
variant="select"
disabled={disableSave}
onClick={handleSave}
onClick={() => void handleSave()}
>
{t("button.save")}
{isLoading ? (
<ActivityIndicator className="size-4" />
) : (
t("button.save")
)}
</Button>
</DialogFooter>
</DialogContent>
+1 -1
View File
@@ -19,7 +19,7 @@ const Toaster = ({ ...props }: ToasterProps) => {
cancelButton:
"group-[.toast]:bg-muted group-[.toast]:text-muted-foreground",
closeButton:
"group-[.toast]:bg-secondary border-primary border-[1px]",
"group-[.toast]:bg-secondary group-[.toast]:text-primary group-[.toast]:border-primary group-[.toast]:border-[1px]",
success:
"group toast group-[.toaster]:bg-success group-[.toaster]:text-foreground group-[.toaster]:border-border group-[.toaster]:shadow-lg",
error:
+875 -75
View File
File diff suppressed because it is too large Load Diff
+77 -1
View File
@@ -6,7 +6,8 @@ export type Export = {
video_path: string;
thumb_path: string;
in_progress: boolean;
export_case?: string;
export_case?: string | null;
export_case_id?: string | null;
};
export type ExportCase = {
@@ -17,6 +18,81 @@ export type ExportCase = {
updated_at: number;
};
export type BatchExportBody = {
items: BatchExportItem[];
export_case_id?: string;
new_case_name?: string;
new_case_description?: string;
};
export const MAX_BATCH_EXPORT_ITEMS = 50;
export type BatchExportItem = {
camera: string;
start_time: number;
end_time: number;
image_path?: string;
friendly_name?: string;
client_item_id?: string;
};
export type BatchExportResult = {
camera: string;
export_id?: string | null;
success: boolean;
status?: string | null;
error?: string | null;
item_index?: number | null;
client_item_id?: string | null;
};
export type BatchExportResponse = {
export_case_id?: string | null;
export_ids: string[];
results: BatchExportResult[];
};
export type StartExportResponse = {
success: boolean;
message: string;
export_id?: string | null;
status?: string | null;
};
export type ExportJob = {
id: string;
job_type: string;
status: string;
camera: string;
name?: string | null;
export_case_id?: string | null;
request_start_time: number;
request_end_time: number;
start_time?: number | null;
end_time?: number | null;
error_message?: string | null;
results?: {
export_id?: string;
export_case_id?: string | null;
video_path?: string;
thumb_path?: string;
} | null;
};
export type CameraActivitySegment = {
/** Fractional start position within the time range, 0-1 inclusive. */
start: number;
/** Fractional end position within the time range, 0-1 inclusive. */
end: number;
};
export type CameraActivity = {
camera: string;
count: number;
hasDetections: boolean;
segments: CameraActivitySegment[];
};
export type DeleteClipType = {
file: string;
exportName: string;
+1 -1
View File
@@ -2,7 +2,7 @@
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export type FilterType = { [searchKey: string]: any };
export type ExportMode = "select" | "timeline" | "none";
export type ExportMode = "select" | "timeline" | "timeline_multi" | "none";
export type FilterList = {
labels?: string[];
@@ -270,7 +270,10 @@ export default function MotionSearchView({
);
useEffect(() => {
if (exportMode !== "timeline" || exportRange) {
if (
(exportMode !== "timeline" && exportMode !== "timeline_multi") ||
exportRange
) {
return;
}
@@ -955,9 +958,25 @@ export default function MotionSearchView({
<SaveExportOverlay
className="pointer-events-none absolute inset-x-0 top-0 z-30"
show={exportMode === "timeline" && Boolean(exportRange)}
show={
(exportMode === "timeline" || exportMode === "timeline_multi") &&
Boolean(exportRange)
}
hidePreview={exportMode === "timeline_multi"}
saveLabel={
exportMode === "timeline_multi"
? t("export.fromTimeline.useThisRange", { ns: "components/dialog" })
: undefined
}
onPreview={handleExportPreview}
onSave={handleExportSave}
onSave={() => {
if (exportMode === "timeline_multi") {
setExportMode("select");
return;
}
handleExportSave();
}}
onCancel={handleExportCancel}
/>
@@ -976,7 +995,10 @@ export default function MotionSearchView({
noRecordingRanges={noRecordings ?? []}
contentRef={contentRef}
onHandlebarDraggingChange={(dragging) => setScrubbing(dragging)}
showExportHandles={exportMode === "timeline" && Boolean(exportRange)}
showExportHandles={
(exportMode === "timeline" || exportMode === "timeline_multi") &&
Boolean(exportRange)
}
exportStartTime={exportRange?.after}
exportEndTime={exportRange?.before}
setExportStartTime={setExportStartTime}
@@ -1408,7 +1430,11 @@ export default function MotionSearchView({
onControllerReady={(controller) => {
mainControllerRef.current = controller;
}}
isScrubbing={scrubbing || exportMode == "timeline"}
isScrubbing={
scrubbing ||
exportMode == "timeline" ||
exportMode == "timeline_multi"
}
supportsFullscreen={supportsFullScreen}
setFullResolution={setFullResolution}
toggleFullscreen={toggleFullscreen}
+2 -1
View File
@@ -833,6 +833,7 @@ export function RecordingView({
isScrubbing={
scrubbing ||
exportMode == "timeline" ||
exportMode == "timeline_multi" ||
debugReplayMode == "timeline"
}
supportsFullscreen={supportsFullScreen}
@@ -911,7 +912,7 @@ export function RecordingView({
activeReviewItem={activeReviewItem}
currentTime={currentTime}
exportRange={
exportMode == "timeline"
exportMode == "timeline" || exportMode == "timeline_multi"
? exportRange
: debugReplayMode == "timeline"
? debugReplayRange