snapshot endpoint height and format, yaml types, bugfixes

This commit is contained in:
Josh Hawkins 2024-09-04 07:56:40 -05:00
parent 281ddde15c
commit 86c770038d
6 changed files with 80 additions and 27 deletions

View File

@ -179,14 +179,20 @@ def latest_frame(camera_name):
) )
@MediaBp.route("/<camera_name>/recordings/<frame_time>/snapshot.png") @MediaBp.route("/<camera_name>/recordings/<frame_time>/snapshot.<format>")
def get_snapshot_from_recording(camera_name: str, frame_time: str): def get_snapshot_from_recording(camera_name: str, frame_time: str, format: str):
if camera_name not in current_app.frigate_config.cameras: if camera_name not in current_app.frigate_config.cameras:
return make_response( return make_response(
jsonify({"success": False, "message": "Camera not found"}), jsonify({"success": False, "message": "Camera not found"}),
404, 404,
) )
if format not in ["png", "jpg"]:
return make_response(
jsonify({"success": False, "message": "Invalid format"}),
400,
)
frame_time = float(frame_time) frame_time = float(frame_time)
recording_query = ( recording_query = (
Recordings.select( Recordings.select(
@ -207,7 +213,41 @@ def get_snapshot_from_recording(camera_name: str, frame_time: str):
try: try:
recording: Recordings = recording_query.get() recording: Recordings = recording_query.get()
time_in_segment = frame_time - recording.start_time time_in_segment = frame_time - recording.start_time
image_data = get_image_from_recording(recording.path, time_in_segment)
height = request.args.get("height", type=int)
codec = "png" if format == "png" else "mjpeg"
ffmpeg_cmd = [
"ffmpeg",
"-hide_banner",
"-loglevel",
"warning",
"-ss",
f"00:00:{time_in_segment}",
"-i",
recording.path,
"-frames:v",
"1",
"-c:v",
codec,
"-f",
"image2pipe",
"-",
]
if height:
ffmpeg_cmd.insert(-3, "-vf")
ffmpeg_cmd.insert(-3, f"scale=-1:{height}")
process = sp.run(
ffmpeg_cmd,
capture_output=True,
)
if process.returncode == 0:
image_data = process.stdout
else:
image_data = None
if not image_data: if not image_data:
return make_response( return make_response(
@ -221,7 +261,7 @@ def get_snapshot_from_recording(camera_name: str, frame_time: str):
) )
response = make_response(image_data) response = make_response(image_data)
response.headers["Content-Type"] = "image/png" response.headers["Content-Type"] = f"image/{format}"
return response return response
except DoesNotExist: except DoesNotExist:
return make_response( return make_response(

View File

@ -1,5 +1,6 @@
"""Utilities for builtin types manipulation.""" """Utilities for builtin types manipulation."""
import ast
import copy import copy
import datetime import datetime
import logging import logging
@ -210,10 +211,16 @@ def update_yaml_from_url(file_path, url):
if len(new_value_list) > 1: if len(new_value_list) > 1:
update_yaml_file(file_path, key_path, new_value_list) update_yaml_file(file_path, key_path, new_value_list)
else: else:
value = str(new_value_list[0]) value = new_value_list[0]
if "," in value:
if value.isnumeric(): # Skip conversion if we're a mask or zone string
value = int(value) update_yaml_file(file_path, key_path, value)
else:
try:
value = ast.literal_eval(value)
except (ValueError, SyntaxError):
pass
update_yaml_file(file_path, key_path, value)
update_yaml_file(file_path, key_path, value) update_yaml_file(file_path, key_path, value)

View File

@ -159,15 +159,21 @@ export default function ObjectLifecycle({
// image // image
const [src, setSrc] = useState(""); const [src, setSrc] = useState(
`${apiHost}api/${event.camera}/recordings/${event.start_time + annotationOffset / 1000}/snapshot.jpg?height=500`,
);
const [hasError, setHasError] = useState(false); const [hasError, setHasError] = useState(false);
useEffect(() => { useEffect(() => {
const newSrc = `${apiHost}api/${event.camera}/recordings/${timeIndex + annotationOffset / 1000}/snapshot.png`; if (timeIndex) {
setSrc(newSrc); const newSrc = `${apiHost}api/${event.camera}/recordings/${timeIndex + annotationOffset / 1000}/snapshot.jpg?height=500`;
setSrc(newSrc);
}
setImgLoaded(false); setImgLoaded(false);
setHasError(false); setHasError(false);
}, [timeIndex, annotationOffset, apiHost, event.camera]); // we know that these deps are correct
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [timeIndex, annotationOffset]);
// carousels // carousels
@ -310,10 +316,8 @@ export default function ObjectLifecycle({
</div> </div>
</div> </div>
<div className="flex flex-row items-center justify-between"> <div className="mt-3 flex flex-row items-center justify-between">
<Heading as="h4" className="mt-3"> <Heading as="h4">Object Lifecycle</Heading>
Object Lifecycle
</Heading>
<div className="flex flex-row gap-2"> <div className="flex flex-row gap-2">
<Tooltip> <Tooltip>
@ -464,7 +468,7 @@ export default function ObjectLifecycle({
opts={{ opts={{
align: "center", align: "center",
}} }}
className="w-full max-w-[75%] md:max-w-[85%]" className="w-full max-w-[72%] md:max-w-[85%]"
setApi={setThumbnailApi} setApi={setThumbnailApi}
> >
<CarouselContent className="flex flex-row justify-center"> <CarouselContent className="flex flex-row justify-center">

View File

@ -99,7 +99,7 @@ export default function ReviewDetailDialog({
? pane == "overview" ? pane == "overview"
? "sm:max-w-xl" ? "sm:max-w-xl"
: "pt-2 sm:max-w-4xl" : "pt-2 sm:max-w-4xl"
: "max-h-[75dvh] overflow-hidden p-2 pb-4", : "max-h-[80dvh] overflow-hidden p-2 pb-4",
)} )}
> >
{pane == "overview" && ( {pane == "overview" && (
@ -178,7 +178,7 @@ export default function ReviewDetailDialog({
)} )}
{pane == "details" && selectedEvent && ( {pane == "details" && selectedEvent && (
<div className="scrollbar-container overflow-x-none mt-0 flex size-full flex-col gap-2 overflow-y-auto"> <div className="scrollbar-container overflow-x-none mt-0 flex size-full flex-col gap-2 overflow-y-auto overflow-x-hidden">
<ObjectLifecycle <ObjectLifecycle
review={review} review={review}
event={selectedEvent} event={selectedEvent}
@ -218,7 +218,10 @@ function EventItem({
return ( return (
<> <>
<div <div
className="relative" className={cn(
"relative",
!event.has_snapshot && "flex flex-row items-center justify-center",
)}
onMouseEnter={isDesktop ? () => setHovered(true) : undefined} onMouseEnter={isDesktop ? () => setHovered(true) : undefined}
onMouseLeave={isDesktop ? () => setHovered(false) : undefined} onMouseLeave={isDesktop ? () => setHovered(false) : undefined}
key={event.id} key={event.id}
@ -252,11 +255,7 @@ function EventItem({
{hovered && ( {hovered && (
<div> <div>
<div <div
className={cn( className={cn("absolute right-1 top-1 flex items-center gap-2")}
"absolute",
event.has_snapshot ? "right-1" : "left-1",
"top-1 flex items-center gap-2",
)}
> >
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>

View File

@ -114,7 +114,10 @@ export default function ZoneEditPane({
{ {
message: "Zone name must not contain a period.", message: "Zone name must not contain a period.",
}, },
), )
.refine((value: string) => /^[a-zA-Z0-9_-]+$/.test(value), {
message: "Zone name has an illegal character.",
}),
inertia: z.coerce inertia: z.coerce
.number() .number()
.min(1, { .min(1, {

View File

@ -112,7 +112,7 @@ export default function MotionTunerView({
axios axios
.put( .put(
`config/set?cameras.${selectedCamera}.motion.threshold=${motionSettings.threshold}&cameras.${selectedCamera}.motion.contour_area=${motionSettings.contour_area}&cameras.${selectedCamera}.motion.improve_contrast=${motionSettings.improve_contrast}`, `config/set?cameras.${selectedCamera}.motion.threshold=${motionSettings.threshold}&cameras.${selectedCamera}.motion.contour_area=${motionSettings.contour_area}&cameras.${selectedCamera}.motion.improve_contrast=${motionSettings.improve_contrast ? "True" : "False"}`,
{ requires_restart: 0 }, { requires_restart: 0 },
) )
.then((res) => { .then((res) => {