diff --git a/frigate/api/media.py b/frigate/api/media.py index 911e13f7e..52045b160 100644 --- a/frigate/api/media.py +++ b/frigate/api/media.py @@ -179,14 +179,20 @@ def latest_frame(camera_name): ) -@MediaBp.route("//recordings//snapshot.png") -def get_snapshot_from_recording(camera_name: str, frame_time: str): +@MediaBp.route("//recordings//snapshot.") +def get_snapshot_from_recording(camera_name: str, frame_time: str, format: str): if camera_name not in current_app.frigate_config.cameras: return make_response( jsonify({"success": False, "message": "Camera not found"}), 404, ) + if format not in ["png", "jpg"]: + return make_response( + jsonify({"success": False, "message": "Invalid format"}), + 400, + ) + frame_time = float(frame_time) recording_query = ( Recordings.select( @@ -207,7 +213,41 @@ def get_snapshot_from_recording(camera_name: str, frame_time: str): try: recording: Recordings = recording_query.get() 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: return make_response( @@ -221,7 +261,7 @@ def get_snapshot_from_recording(camera_name: str, frame_time: str): ) response = make_response(image_data) - response.headers["Content-Type"] = "image/png" + response.headers["Content-Type"] = f"image/{format}" return response except DoesNotExist: return make_response( diff --git a/frigate/util/builtin.py b/frigate/util/builtin.py index 6a0c706a7..2c3051fc0 100644 --- a/frigate/util/builtin.py +++ b/frigate/util/builtin.py @@ -1,5 +1,6 @@ """Utilities for builtin types manipulation.""" +import ast import copy import datetime import logging @@ -210,10 +211,16 @@ def update_yaml_from_url(file_path, url): if len(new_value_list) > 1: update_yaml_file(file_path, key_path, new_value_list) else: - value = str(new_value_list[0]) - - if value.isnumeric(): - value = int(value) + value = new_value_list[0] + if "," in value: + # Skip conversion if we're a mask or zone string + 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) diff --git a/web/src/components/overlay/detail/ObjectLifecycle.tsx b/web/src/components/overlay/detail/ObjectLifecycle.tsx index 2dc5d2033..4aef59fb3 100644 --- a/web/src/components/overlay/detail/ObjectLifecycle.tsx +++ b/web/src/components/overlay/detail/ObjectLifecycle.tsx @@ -159,15 +159,21 @@ export default function ObjectLifecycle({ // 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); useEffect(() => { - const newSrc = `${apiHost}api/${event.camera}/recordings/${timeIndex + annotationOffset / 1000}/snapshot.png`; - setSrc(newSrc); + if (timeIndex) { + const newSrc = `${apiHost}api/${event.camera}/recordings/${timeIndex + annotationOffset / 1000}/snapshot.jpg?height=500`; + setSrc(newSrc); + } setImgLoaded(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 @@ -310,10 +316,8 @@ export default function ObjectLifecycle({ -
- - Object Lifecycle - +
+ Object Lifecycle
@@ -464,7 +468,7 @@ export default function ObjectLifecycle({ opts={{ align: "center", }} - className="w-full max-w-[75%] md:max-w-[85%]" + className="w-full max-w-[72%] md:max-w-[85%]" setApi={setThumbnailApi} > diff --git a/web/src/components/overlay/detail/ReviewDetailDialog.tsx b/web/src/components/overlay/detail/ReviewDetailDialog.tsx index 0d81f043b..1742c1fa2 100644 --- a/web/src/components/overlay/detail/ReviewDetailDialog.tsx +++ b/web/src/components/overlay/detail/ReviewDetailDialog.tsx @@ -99,7 +99,7 @@ export default function ReviewDetailDialog({ ? pane == "overview" ? "sm:max-w-xl" : "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" && ( @@ -178,7 +178,7 @@ export default function ReviewDetailDialog({ )} {pane == "details" && selectedEvent && ( -
+
setHovered(true) : undefined} onMouseLeave={isDesktop ? () => setHovered(false) : undefined} key={event.id} @@ -252,11 +255,7 @@ function EventItem({ {hovered && (
diff --git a/web/src/components/settings/ZoneEditPane.tsx b/web/src/components/settings/ZoneEditPane.tsx index f1c23c705..3b9f0dd7e 100644 --- a/web/src/components/settings/ZoneEditPane.tsx +++ b/web/src/components/settings/ZoneEditPane.tsx @@ -114,7 +114,10 @@ export default function ZoneEditPane({ { 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 .number() .min(1, { diff --git a/web/src/views/settings/MotionTunerView.tsx b/web/src/views/settings/MotionTunerView.tsx index 7ff89d04b..d2eb93089 100644 --- a/web/src/views/settings/MotionTunerView.tsx +++ b/web/src/views/settings/MotionTunerView.tsx @@ -112,7 +112,7 @@ export default function MotionTunerView({ axios .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 }, ) .then((res) => {