mirror of
				https://github.com/blakeblackshear/frigate.git
				synced 2025-10-27 10:52:11 +01:00 
			
		
		
		
	Create API to create gif from previews and show instead of still thumbnails (#9786)
* Start working on animation * Change output file * Create preview gif * Show animated gif for event thumb * Remove favorite * Cleanup
This commit is contained in:
		
							parent
							
								
									9de09f6cdf
								
							
						
					
					
						commit
						bcbea8da7d
					
				
							
								
								
									
										183
									
								
								frigate/http.py
									
									
									
									
									
								
							
							
						
						
									
										183
									
								
								frigate/http.py
									
									
									
									
									
								
							@ -593,6 +593,149 @@ def event_thumbnail(id, max_cache_age=2592000):
 | 
				
			|||||||
    return response
 | 
					    return response
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					@bp.route("/events/<id>/preview.gif")
 | 
				
			||||||
 | 
					def event_preview(id: str, max_cache_age=2592000):
 | 
				
			||||||
 | 
					    try:
 | 
				
			||||||
 | 
					        event: Event = Event.get(Event.id == id)
 | 
				
			||||||
 | 
					    except DoesNotExist:
 | 
				
			||||||
 | 
					        return make_response(
 | 
				
			||||||
 | 
					            jsonify({"success": False, "message": "Event not found"}), 404
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    start_ts = event.start_time
 | 
				
			||||||
 | 
					    end_ts = min(event.end_time - event.start_time, 20) if event.end_time else 20
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    if datetime.fromtimestamp(event.start_time) < datetime.now().replace(
 | 
				
			||||||
 | 
					        minute=0, second=0
 | 
				
			||||||
 | 
					    ):
 | 
				
			||||||
 | 
					        # has preview mp4
 | 
				
			||||||
 | 
					        preview: Previews = (
 | 
				
			||||||
 | 
					            Previews.select(
 | 
				
			||||||
 | 
					                Previews.camera,
 | 
				
			||||||
 | 
					                Previews.path,
 | 
				
			||||||
 | 
					                Previews.duration,
 | 
				
			||||||
 | 
					                Previews.start_time,
 | 
				
			||||||
 | 
					                Previews.end_time,
 | 
				
			||||||
 | 
					            )
 | 
				
			||||||
 | 
					            .where(
 | 
				
			||||||
 | 
					                Previews.start_time.between(start_ts, end_ts)
 | 
				
			||||||
 | 
					                | Previews.end_time.between(start_ts, end_ts)
 | 
				
			||||||
 | 
					                | ((start_ts > Previews.start_time) & (end_ts < Previews.end_time))
 | 
				
			||||||
 | 
					            )
 | 
				
			||||||
 | 
					            .where(Previews.camera == event.camera)
 | 
				
			||||||
 | 
					            .limit(1)
 | 
				
			||||||
 | 
					            .get()
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        if not preview:
 | 
				
			||||||
 | 
					            return make_response(
 | 
				
			||||||
 | 
					                jsonify({"success": False, "message": "Preview not found"}), 404
 | 
				
			||||||
 | 
					            )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        diff = event.start_time - preview.start_time
 | 
				
			||||||
 | 
					        minutes = int(diff / 60)
 | 
				
			||||||
 | 
					        seconds = int(diff % 60)
 | 
				
			||||||
 | 
					        ffmpeg_cmd = [
 | 
				
			||||||
 | 
					            "ffmpeg",
 | 
				
			||||||
 | 
					            "-hide_banner",
 | 
				
			||||||
 | 
					            "-loglevel",
 | 
				
			||||||
 | 
					            "warning",
 | 
				
			||||||
 | 
					            "-ss",
 | 
				
			||||||
 | 
					            f"00:{minutes}:{seconds}",
 | 
				
			||||||
 | 
					            "-t",
 | 
				
			||||||
 | 
					            f"{end_ts - start_ts}",
 | 
				
			||||||
 | 
					            "-i",
 | 
				
			||||||
 | 
					            preview.path,
 | 
				
			||||||
 | 
					            "-r",
 | 
				
			||||||
 | 
					            "8",
 | 
				
			||||||
 | 
					            "-vf",
 | 
				
			||||||
 | 
					            "setpts=0.12*PTS",
 | 
				
			||||||
 | 
					            "-loop",
 | 
				
			||||||
 | 
					            "0",
 | 
				
			||||||
 | 
					            "-c:v",
 | 
				
			||||||
 | 
					            "gif",
 | 
				
			||||||
 | 
					            "-f",
 | 
				
			||||||
 | 
					            "gif",
 | 
				
			||||||
 | 
					            "-",
 | 
				
			||||||
 | 
					        ]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        process = sp.run(
 | 
				
			||||||
 | 
					            ffmpeg_cmd,
 | 
				
			||||||
 | 
					            capture_output=True,
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					        gif_bytes = process.stdout
 | 
				
			||||||
 | 
					    else:
 | 
				
			||||||
 | 
					        # need to generate from existing images
 | 
				
			||||||
 | 
					        preview_dir = os.path.join(CACHE_DIR, "preview_frames")
 | 
				
			||||||
 | 
					        file_start = f"preview_{event.camera}"
 | 
				
			||||||
 | 
					        start_file = f"{file_start}-{start_ts}.jpg"
 | 
				
			||||||
 | 
					        end_file = f"{file_start}-{end_ts}.jpg"
 | 
				
			||||||
 | 
					        selected_previews = []
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        for file in sorted(os.listdir(preview_dir)):
 | 
				
			||||||
 | 
					            if not file.startswith(file_start):
 | 
				
			||||||
 | 
					                continue
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            if file < start_file:
 | 
				
			||||||
 | 
					                continue
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            if file > end_file:
 | 
				
			||||||
 | 
					                break
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            selected_previews.append(f"file '/tmp/cache/preview_frames/{file}'")
 | 
				
			||||||
 | 
					            selected_previews.append("duration 0.12")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        if not selected_previews:
 | 
				
			||||||
 | 
					            return make_response(
 | 
				
			||||||
 | 
					                jsonify({"success": False, "message": "Preview not found"}), 404
 | 
				
			||||||
 | 
					            )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        last_file = selected_previews[-2]
 | 
				
			||||||
 | 
					        selected_previews.append(last_file)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        ffmpeg_cmd = [
 | 
				
			||||||
 | 
					            "ffmpeg",
 | 
				
			||||||
 | 
					            "-hide_banner",
 | 
				
			||||||
 | 
					            "-loglevel",
 | 
				
			||||||
 | 
					            "warning",
 | 
				
			||||||
 | 
					            "-f",
 | 
				
			||||||
 | 
					            "concat",
 | 
				
			||||||
 | 
					            "-y",
 | 
				
			||||||
 | 
					            "-protocol_whitelist",
 | 
				
			||||||
 | 
					            "pipe,file",
 | 
				
			||||||
 | 
					            "-safe",
 | 
				
			||||||
 | 
					            "0",
 | 
				
			||||||
 | 
					            "-i",
 | 
				
			||||||
 | 
					            "/dev/stdin",
 | 
				
			||||||
 | 
					            "-loop",
 | 
				
			||||||
 | 
					            "0",
 | 
				
			||||||
 | 
					            "-c:v",
 | 
				
			||||||
 | 
					            "gif",
 | 
				
			||||||
 | 
					            "-f",
 | 
				
			||||||
 | 
					            "gif",
 | 
				
			||||||
 | 
					            "-",
 | 
				
			||||||
 | 
					        ]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        process = sp.run(
 | 
				
			||||||
 | 
					            ffmpeg_cmd,
 | 
				
			||||||
 | 
					            input=str.encode("\n".join(selected_previews)),
 | 
				
			||||||
 | 
					            capture_output=True,
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        if process.returncode != 0:
 | 
				
			||||||
 | 
					            return make_response(
 | 
				
			||||||
 | 
					                jsonify({"success": False, "message": "Unable to create preview gif"}),
 | 
				
			||||||
 | 
					                500,
 | 
				
			||||||
 | 
					            )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        gif_bytes = process.stdout
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    response = make_response(gif_bytes)
 | 
				
			||||||
 | 
					    response.headers["Content-Type"] = "image/gif"
 | 
				
			||||||
 | 
					    response.headers["Cache-Control"] = f"private, max-age={max_cache_age}"
 | 
				
			||||||
 | 
					    return response
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@bp.route("/timeline")
 | 
					@bp.route("/timeline")
 | 
				
			||||||
def timeline():
 | 
					def timeline():
 | 
				
			||||||
    camera = request.args.get("camera", "all")
 | 
					    camera = request.args.get("camera", "all")
 | 
				
			||||||
@ -2265,9 +2408,11 @@ def export_recording(camera_name: str, start_time, end_time):
 | 
				
			|||||||
        camera_name,
 | 
					        camera_name,
 | 
				
			||||||
        int(start_time),
 | 
					        int(start_time),
 | 
				
			||||||
        int(end_time),
 | 
					        int(end_time),
 | 
				
			||||||
        PlaybackFactorEnum[playback_factor]
 | 
					        (
 | 
				
			||||||
        if playback_factor in PlaybackFactorEnum.__members__.values()
 | 
					            PlaybackFactorEnum[playback_factor]
 | 
				
			||||||
        else PlaybackFactorEnum.realtime,
 | 
					            if playback_factor in PlaybackFactorEnum.__members__.values()
 | 
				
			||||||
 | 
					            else PlaybackFactorEnum.realtime
 | 
				
			||||||
 | 
					        ),
 | 
				
			||||||
    )
 | 
					    )
 | 
				
			||||||
    exporter.start()
 | 
					    exporter.start()
 | 
				
			||||||
    return make_response(
 | 
					    return make_response(
 | 
				
			||||||
@ -2423,12 +2568,16 @@ def ffprobe():
 | 
				
			|||||||
        output.append(
 | 
					        output.append(
 | 
				
			||||||
            {
 | 
					            {
 | 
				
			||||||
                "return_code": ffprobe.returncode,
 | 
					                "return_code": ffprobe.returncode,
 | 
				
			||||||
                "stderr": ffprobe.stderr.decode("unicode_escape").strip()
 | 
					                "stderr": (
 | 
				
			||||||
                if ffprobe.returncode != 0
 | 
					                    ffprobe.stderr.decode("unicode_escape").strip()
 | 
				
			||||||
                else "",
 | 
					                    if ffprobe.returncode != 0
 | 
				
			||||||
                "stdout": json.loads(ffprobe.stdout.decode("unicode_escape").strip())
 | 
					                    else ""
 | 
				
			||||||
                if ffprobe.returncode == 0
 | 
					                ),
 | 
				
			||||||
                else "",
 | 
					                "stdout": (
 | 
				
			||||||
 | 
					                    json.loads(ffprobe.stdout.decode("unicode_escape").strip())
 | 
				
			||||||
 | 
					                    if ffprobe.returncode == 0
 | 
				
			||||||
 | 
					                    else ""
 | 
				
			||||||
 | 
					                ),
 | 
				
			||||||
            }
 | 
					            }
 | 
				
			||||||
        )
 | 
					        )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@ -2441,12 +2590,16 @@ def vainfo():
 | 
				
			|||||||
    return jsonify(
 | 
					    return jsonify(
 | 
				
			||||||
        {
 | 
					        {
 | 
				
			||||||
            "return_code": vainfo.returncode,
 | 
					            "return_code": vainfo.returncode,
 | 
				
			||||||
            "stderr": vainfo.stderr.decode("unicode_escape").strip()
 | 
					            "stderr": (
 | 
				
			||||||
            if vainfo.returncode != 0
 | 
					                vainfo.stderr.decode("unicode_escape").strip()
 | 
				
			||||||
            else "",
 | 
					                if vainfo.returncode != 0
 | 
				
			||||||
            "stdout": vainfo.stdout.decode("unicode_escape").strip()
 | 
					                else ""
 | 
				
			||||||
            if vainfo.returncode == 0
 | 
					            ),
 | 
				
			||||||
            else "",
 | 
					            "stdout": (
 | 
				
			||||||
 | 
					                vainfo.stdout.decode("unicode_escape").strip()
 | 
				
			||||||
 | 
					                if vainfo.returncode == 0
 | 
				
			||||||
 | 
					                else ""
 | 
				
			||||||
 | 
					            ),
 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
    )
 | 
					    )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
				
			|||||||
							
								
								
									
										37
									
								
								web/src/components/image/AnimatedEventThumbnail.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										37
									
								
								web/src/components/image/AnimatedEventThumbnail.tsx
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,37 @@
 | 
				
			|||||||
 | 
					import { baseUrl } from "@/api/baseUrl";
 | 
				
			||||||
 | 
					import { Event as FrigateEvent } from "@/types/event";
 | 
				
			||||||
 | 
					import TimeAgo from "../dynamic/TimeAgo";
 | 
				
			||||||
 | 
					import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					type AnimatedEventThumbnailProps = {
 | 
				
			||||||
 | 
					  event: FrigateEvent;
 | 
				
			||||||
 | 
					};
 | 
				
			||||||
 | 
					export function AnimatedEventThumbnail({ event }: AnimatedEventThumbnailProps) {
 | 
				
			||||||
 | 
					  return (
 | 
				
			||||||
 | 
					    <Tooltip>
 | 
				
			||||||
 | 
					      <TooltipTrigger asChild>
 | 
				
			||||||
 | 
					        <div
 | 
				
			||||||
 | 
					          className="relative rounded bg-cover aspect-video h-24 bg-no-repeat bg-center mr-4"
 | 
				
			||||||
 | 
					          style={{
 | 
				
			||||||
 | 
					            backgroundImage: `url(${baseUrl}api/events/${event.id}/preview.gif)`,
 | 
				
			||||||
 | 
					          }}
 | 
				
			||||||
 | 
					        >
 | 
				
			||||||
 | 
					          <div className="absolute bottom-0 w-full h-6 bg-gradient-to-t from-slate-900/50 to-transparent rounded">
 | 
				
			||||||
 | 
					            <div className="absolute left-1 bottom-0 text-xs text-white w-full">
 | 
				
			||||||
 | 
					              <TimeAgo time={event.start_time * 1000} dense />
 | 
				
			||||||
 | 
					            </div>
 | 
				
			||||||
 | 
					          </div>
 | 
				
			||||||
 | 
					        </div>
 | 
				
			||||||
 | 
					      </TooltipTrigger>
 | 
				
			||||||
 | 
					      <TooltipContent>
 | 
				
			||||||
 | 
					        {`${event.label} ${
 | 
				
			||||||
 | 
					          event.sub_label ? `(${event.sub_label})` : ""
 | 
				
			||||||
 | 
					        } detected with score of ${(event.data.score * 100).toFixed(0)}% ${
 | 
				
			||||||
 | 
					          event.data.sub_label_score
 | 
				
			||||||
 | 
					            ? `(${event.data.sub_label_score * 100}%)`
 | 
				
			||||||
 | 
					            : ""
 | 
				
			||||||
 | 
					        }`}
 | 
				
			||||||
 | 
					      </TooltipContent>
 | 
				
			||||||
 | 
					    </Tooltip>
 | 
				
			||||||
 | 
					  );
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
@ -40,22 +40,23 @@ export default function LivePlayer({
 | 
				
			|||||||
  const { activeMotion, activeAudio, activeTracking } =
 | 
					  const { activeMotion, activeAudio, activeTracking } =
 | 
				
			||||||
    useCameraActivity(cameraConfig);
 | 
					    useCameraActivity(cameraConfig);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const cameraActive = useMemo(() => activeMotion || activeTracking, [activeMotion, activeTracking])
 | 
				
			||||||
  const liveMode = useCameraLiveMode(cameraConfig, preferredLiveMode);
 | 
					  const liveMode = useCameraLiveMode(cameraConfig, preferredLiveMode);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  const [liveReady, setLiveReady] = useState(false);
 | 
					  const [liveReady, setLiveReady] = useState(false);
 | 
				
			||||||
  useEffect(() => {
 | 
					  useEffect(() => {
 | 
				
			||||||
    if (!liveReady) {
 | 
					    if (!liveReady) {
 | 
				
			||||||
      if (activeMotion && liveMode == "jsmpeg") {
 | 
					      if (cameraActive && liveMode == "jsmpeg") {
 | 
				
			||||||
        setLiveReady(true);
 | 
					        setLiveReady(true);
 | 
				
			||||||
      }
 | 
					      }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      return;
 | 
					      return;
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    if (!activeMotion && !activeTracking) {
 | 
					    if (!cameraActive) {
 | 
				
			||||||
      setLiveReady(false);
 | 
					      setLiveReady(false);
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
  }, [activeMotion, activeTracking, liveReady]);
 | 
					  }, [cameraActive, liveReady]);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  const { payload: recording } = useRecordingsState(cameraConfig.name);
 | 
					  const { payload: recording } = useRecordingsState(cameraConfig.name);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@ -167,7 +168,7 @@ export default function LivePlayer({
 | 
				
			|||||||
          : "outline-0"
 | 
					          : "outline-0"
 | 
				
			||||||
      } transition-all duration-500 ${className}`}
 | 
					      } transition-all duration-500 ${className}`}
 | 
				
			||||||
    >
 | 
					    >
 | 
				
			||||||
      {(showStillWithoutActivity == false || activeMotion || activeTracking) &&
 | 
					      {(showStillWithoutActivity == false || cameraActive) &&
 | 
				
			||||||
        player}
 | 
					        player}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      <div
 | 
					      <div
 | 
				
			||||||
@ -179,7 +180,7 @@ export default function LivePlayer({
 | 
				
			|||||||
          className="w-full h-full"
 | 
					          className="w-full h-full"
 | 
				
			||||||
          camera={cameraConfig.name}
 | 
					          camera={cameraConfig.name}
 | 
				
			||||||
          showFps={false}
 | 
					          showFps={false}
 | 
				
			||||||
          reloadInterval={30000}
 | 
					          reloadInterval={(cameraActive && !liveReady) ? 200 : 30000}
 | 
				
			||||||
        />
 | 
					        />
 | 
				
			||||||
      </div>
 | 
					      </div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
				
			|||||||
@ -1,11 +1,10 @@
 | 
				
			|||||||
import { EventThumbnail } from "@/components/image/EventThumbnail";
 | 
					import { AnimatedEventThumbnail } from "@/components/image/AnimatedEventThumbnail";
 | 
				
			||||||
import LivePlayer from "@/components/player/LivePlayer";
 | 
					import LivePlayer from "@/components/player/LivePlayer";
 | 
				
			||||||
import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area";
 | 
					import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area";
 | 
				
			||||||
import { TooltipProvider } from "@/components/ui/tooltip";
 | 
					import { TooltipProvider } from "@/components/ui/tooltip";
 | 
				
			||||||
import { Event as FrigateEvent } from "@/types/event";
 | 
					import { Event as FrigateEvent } from "@/types/event";
 | 
				
			||||||
import { FrigateConfig } from "@/types/frigateConfig";
 | 
					import { FrigateConfig } from "@/types/frigateConfig";
 | 
				
			||||||
import axios from "axios";
 | 
					import { useMemo } from "react";
 | 
				
			||||||
import { useCallback, useMemo } from "react";
 | 
					 | 
				
			||||||
import useSWR from "swr";
 | 
					import useSWR from "swr";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
function Live() {
 | 
					function Live() {
 | 
				
			||||||
@ -13,7 +12,7 @@ function Live() {
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
  // recent events
 | 
					  // recent events
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  const { data: allEvents, mutate: updateEvents } = useSWR<FrigateEvent[]>(
 | 
					  const { data: allEvents } = useSWR<FrigateEvent[]>(
 | 
				
			||||||
    ["events", { limit: 10 }],
 | 
					    ["events", { limit: 10 }],
 | 
				
			||||||
    { refreshInterval: 60000 }
 | 
					    { refreshInterval: 60000 }
 | 
				
			||||||
  );
 | 
					  );
 | 
				
			||||||
@ -29,19 +28,6 @@ function Live() {
 | 
				
			|||||||
    return allEvents.filter((event) => event.start_time > cutoff);
 | 
					    return allEvents.filter((event) => event.start_time > cutoff);
 | 
				
			||||||
  }, [allEvents]);
 | 
					  }, [allEvents]);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  const onFavorite = useCallback(async (e: Event, event: FrigateEvent) => {
 | 
					 | 
				
			||||||
    e.stopPropagation();
 | 
					 | 
				
			||||||
    let response;
 | 
					 | 
				
			||||||
    if (!event.retain_indefinitely) {
 | 
					 | 
				
			||||||
      response = await axios.post(`events/${event.id}/retain`);
 | 
					 | 
				
			||||||
    } else {
 | 
					 | 
				
			||||||
      response = await axios.delete(`events/${event.id}/retain`);
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
    if (response.status === 200) {
 | 
					 | 
				
			||||||
      updateEvents();
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
  }, []);
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  // camera live views
 | 
					  // camera live views
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  const cameras = useMemo(() => {
 | 
					  const cameras = useMemo(() => {
 | 
				
			||||||
@ -61,13 +47,7 @@ function Live() {
 | 
				
			|||||||
          <TooltipProvider>
 | 
					          <TooltipProvider>
 | 
				
			||||||
            <div className="flex">
 | 
					            <div className="flex">
 | 
				
			||||||
              {events.map((event) => {
 | 
					              {events.map((event) => {
 | 
				
			||||||
                return (
 | 
					                return <AnimatedEventThumbnail key={event.id} event={event} />;
 | 
				
			||||||
                  <EventThumbnail
 | 
					 | 
				
			||||||
                    key={event.id}
 | 
					 | 
				
			||||||
                    event={event}
 | 
					 | 
				
			||||||
                    onFavorite={onFavorite}
 | 
					 | 
				
			||||||
                  />
 | 
					 | 
				
			||||||
                );
 | 
					 | 
				
			||||||
              })}
 | 
					              })}
 | 
				
			||||||
            </div>
 | 
					            </div>
 | 
				
			||||||
          </TooltipProvider>
 | 
					          </TooltipProvider>
 | 
				
			||||||
 | 
				
			|||||||
		Loading…
	
		Reference in New Issue
	
	Block a user