frigate/web/src/components/overlay/RecapDialog.tsx
ryzendigo 717b878956 feat: add daily recap video generation
Adds a new recap feature that composites detected people from throughout
the day onto a clean background, producing a short summary video of all
activity for a given camera.

How it works:
- Builds a clean background plate via median of sampled frames
- Extracts clip frames for each person event from recordings
- Uses per-event background subtraction (first frame of clip as reference)
  within a soft spotlight region to isolate the person
- Groups non-overlapping events to play simultaneously
- Balances groups by duration so the video stays even
- Renders at 2x speed, stitches groups into final output

New files:
- frigate/recap/ — core generation module
- frigate/api/recap.py — POST /recap/{camera}, GET /recap/{camera}
- frigate/config/recap.py — recap config section (enabled, fps, etc)
- frigate/test/test_recap.py — unit tests
- web/src/components/overlay/RecapDialog.tsx — UI component (not yet wired)

Config example:
  recap:
    enabled: true
    default_label: person
    output_fps: 10
    video_duration: 30
    background_samples: 30

Relates to #54
2026-03-21 16:36:39 +08:00

167 lines
5.1 KiB
TypeScript

import { useCallback, useState } from "react";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "../ui/dialog";
import { Button } from "../ui/button";
import { Label } from "../ui/label";
import { RadioGroup, RadioGroupItem } from "../ui/radio-group";
import { Input } from "../ui/input";
import { SelectSeparator } from "../ui/select";
import axios from "axios";
import { toast } from "sonner";
import { isDesktop } from "react-device-detect";
import { Drawer, DrawerContent } from "../ui/drawer";
import ActivityIndicator from "../indicators/activity-indicator";
const RECAP_PERIODS = ["24", "12", "8", "4", "1"] as const;
type RecapPeriod = (typeof RECAP_PERIODS)[number];
type RecapDialogProps = {
camera: string;
open: boolean;
onOpenChange: (open: boolean) => void;
};
export default function RecapDialog({
camera,
open,
onOpenChange,
}: RecapDialogProps) {
const [selectedPeriod, setSelectedPeriod] = useState<RecapPeriod>("24");
const [label, setLabel] = useState("person");
const [isGenerating, setIsGenerating] = useState(false);
const onGenerate = useCallback(() => {
const now = Date.now() / 1000;
const hours = parseInt(selectedPeriod);
const startTime = now - hours * 3600;
setIsGenerating(true);
axios
.post(`recap/${camera}`, null, {
params: {
start_time: startTime,
end_time: now,
label,
},
})
.then((response) => {
if (response.status === 200 && response.data.success) {
toast.success("Recap generation started", {
position: "top-center",
description: "Check Exports when it's done.",
});
onOpenChange(false);
}
})
.catch((error) => {
const msg =
error.response?.data?.message ||
error.response?.data?.detail ||
"Unknown error";
toast.error(`Recap failed: ${msg}`, { position: "top-center" });
})
.finally(() => {
setIsGenerating(false);
});
}, [camera, selectedPeriod, label, onOpenChange]);
const Overlay = isDesktop ? Dialog : Drawer;
const Content = isDesktop ? DialogContent : DrawerContent;
return (
<Overlay open={open} onOpenChange={onOpenChange}>
<Content
className={
isDesktop
? "sm:rounded-lg md:rounded-2xl"
: "mx-4 rounded-lg px-4 pb-4 md:rounded-2xl"
}
>
<div className="w-full">
{isDesktop && (
<>
<DialogHeader>
<DialogTitle>Generate Recap</DialogTitle>
</DialogHeader>
<SelectSeparator className="my-4 bg-secondary" />
</>
)}
<div className={`flex flex-col gap-4 ${isDesktop ? "" : "mt-4"}`}>
<Label className="text-sm font-medium">Time period</Label>
<RadioGroup
className="flex flex-col gap-3"
defaultValue="24"
onValueChange={(v) => setSelectedPeriod(v as RecapPeriod)}
>
{RECAP_PERIODS.map((period) => (
<div key={period} className="flex items-center gap-2">
<RadioGroupItem
className={
period === selectedPeriod
? "bg-selected from-selected/50 to-selected/90 text-selected"
: "bg-secondary from-secondary/50 to-secondary/90 text-secondary"
}
id={`recap-${period}`}
value={period}
/>
<Label
className="cursor-pointer"
htmlFor={`recap-${period}`}
>
Last {period} {parseInt(period) === 1 ? "hour" : "hours"}
</Label>
</div>
))}
</RadioGroup>
<div className="mt-2">
<Label className="text-sm text-secondary-foreground">
Object type
</Label>
<Input
className="text-md mt-2"
type="text"
value={label}
onChange={(e) => setLabel(e.target.value)}
placeholder="person"
/>
</div>
</div>
{isDesktop && <SelectSeparator className="my-4 bg-secondary" />}
<DialogFooter
className={isDesktop ? "" : "mt-6 flex flex-col-reverse gap-4"}
>
<div
className={`cursor-pointer p-2 text-center ${isDesktop ? "" : "w-full"}`}
onClick={() => onOpenChange(false)}
>
Cancel
</div>
<Button
className={isDesktop ? "" : "w-full"}
variant="select"
size="sm"
disabled={isGenerating}
onClick={onGenerate}
>
{isGenerating && (
<ActivityIndicator className="mr-2 h-4 w-4" />
)}
Generate Recap
</Button>
</DialogFooter>
</div>
</Content>
</Overlay>
);
}