Add Camera Wizard (#20461)
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
CI / AMD64 Build (push) Waiting to run

* fetch more from ffprobe

* add detailed param to ffprobe endpoint

* add dots variant to step indicator

* add classname

* tweak colors for dark mode to match figma

* add step 1 form

* add helper function for ffmpeg snapshot

* add go2rtc stream add and ffprobe snapshot endpoints

* add camera image and stream details on successful test

* step 1 tweaks

* step 2 and i18n

* types

* step 1 and 2 tweaks

* add wizard to camera settings view

* add data unit i18n keys

* restream tweak

* fix type

* implement rough idea for step 3

* add api endpoint to delete stream from go2rtc

* add main wizard dialog component

* extract logic for friendly_name and use in wizard

* add i18n and popover for brand url

* add camera name to top

* consolidate validation logic

* prevent dialog from closing when clicking outside

* center camera name on mobile

* add help/docs link popovers

* keep spaces in friendly name

* add stream details to overlay like stats in liveplayer

* add validation results pane to step 3

* ensure test is invalidated if stream is changed

* only display validation results and enable save button if all streams have been tested

* tweaks

* normalize camera name to lower case and improve hash generation

* move wizard to subfolder

* tweaks

* match look of camera edit form to wizard

* move wizard and edit form to its own component

* move enabled/disabled switch to management section

* clean up

* fixes

* fix mobile
This commit is contained in:
Josh Hawkins
2025-10-13 10:52:08 -06:00
committed by GitHub
parent 423693d14d
commit 9d85136f8f
19 changed files with 3571 additions and 429 deletions
+197 -16
View File
@@ -43,6 +43,7 @@ from frigate.util.builtin import (
update_yaml_file_bulk,
)
from frigate.util.config import find_config_file
from frigate.util.image import run_ffmpeg_snapshot
from frigate.util.services import (
ffprobe_stream,
get_nvidia_driver_info,
@@ -107,6 +108,80 @@ def go2rtc_camera_stream(request: Request, camera_name: str):
return JSONResponse(content=stream_data)
@router.put(
"/go2rtc/streams/{stream_name}", dependencies=[Depends(require_role(["admin"]))]
)
def go2rtc_add_stream(request: Request, stream_name: str, src: str = ""):
"""Add or update a go2rtc stream configuration."""
try:
params = {"name": stream_name}
if src:
params["src"] = src
r = requests.put(
"http://127.0.0.1:1984/api/streams",
params=params,
timeout=10,
)
if not r.ok:
logger.error(f"Failed to add go2rtc stream {stream_name}: {r.text}")
return JSONResponse(
content=(
{"success": False, "message": f"Failed to add stream: {r.text}"}
),
status_code=r.status_code,
)
return JSONResponse(
content={"success": True, "message": "Stream added successfully"}
)
except requests.RequestException as e:
logger.error(f"Error communicating with go2rtc: {e}")
return JSONResponse(
content=(
{
"success": False,
"message": "Error communicating with go2rtc",
}
),
status_code=500,
)
@router.delete(
"/go2rtc/streams/{stream_name}", dependencies=[Depends(require_role(["admin"]))]
)
def go2rtc_delete_stream(stream_name: str):
"""Delete a go2rtc stream."""
try:
r = requests.delete(
"http://127.0.0.1:1984/api/streams",
params={"src": stream_name},
timeout=10,
)
if not r.ok:
logger.error(f"Failed to delete go2rtc stream {stream_name}: {r.text}")
return JSONResponse(
content=(
{"success": False, "message": f"Failed to delete stream: {r.text}"}
),
status_code=r.status_code,
)
return JSONResponse(
content={"success": True, "message": "Stream deleted successfully"}
)
except requests.RequestException as e:
logger.error(f"Error communicating with go2rtc: {e}")
return JSONResponse(
content=(
{
"success": False,
"message": "Error communicating with go2rtc",
}
),
status_code=500,
)
@router.get("/version", response_class=PlainTextResponse)
def version():
return VERSION
@@ -453,7 +528,7 @@ def config_set(request: Request, body: AppConfigSetBody):
@router.get("/ffprobe")
def ffprobe(request: Request, paths: str = ""):
def ffprobe(request: Request, paths: str = "", detailed: bool = False):
path_param = paths
if not path_param:
@@ -492,26 +567,132 @@ def ffprobe(request: Request, paths: str = ""):
output = []
for path in paths:
ffprobe = ffprobe_stream(request.app.frigate_config.ffmpeg, path.strip())
output.append(
{
"return_code": ffprobe.returncode,
"stderr": (
ffprobe.stderr.decode("unicode_escape").strip()
if ffprobe.returncode != 0
else ""
),
"stdout": (
json.loads(ffprobe.stdout.decode("unicode_escape").strip())
if ffprobe.returncode == 0
else ""
),
}
ffprobe = ffprobe_stream(
request.app.frigate_config.ffmpeg, path.strip(), detailed=detailed
)
result = {
"return_code": ffprobe.returncode,
"stderr": (
ffprobe.stderr.decode("unicode_escape").strip()
if ffprobe.returncode != 0
else ""
),
"stdout": (
json.loads(ffprobe.stdout.decode("unicode_escape").strip())
if ffprobe.returncode == 0
else ""
),
}
# Add detailed metadata if requested and probe was successful
if detailed and ffprobe.returncode == 0 and result["stdout"]:
try:
probe_data = result["stdout"]
metadata = {}
# Extract video stream information
video_stream = None
audio_stream = None
for stream in probe_data.get("streams", []):
if stream.get("codec_type") == "video":
video_stream = stream
elif stream.get("codec_type") == "audio":
audio_stream = stream
# Video metadata
if video_stream:
metadata["video"] = {
"codec": video_stream.get("codec_name"),
"width": video_stream.get("width"),
"height": video_stream.get("height"),
"fps": _extract_fps(video_stream.get("r_frame_rate")),
"pixel_format": video_stream.get("pix_fmt"),
"profile": video_stream.get("profile"),
"level": video_stream.get("level"),
}
# Calculate resolution string
if video_stream.get("width") and video_stream.get("height"):
metadata["video"]["resolution"] = (
f"{video_stream['width']}x{video_stream['height']}"
)
# Audio metadata
if audio_stream:
metadata["audio"] = {
"codec": audio_stream.get("codec_name"),
"channels": audio_stream.get("channels"),
"sample_rate": audio_stream.get("sample_rate"),
"channel_layout": audio_stream.get("channel_layout"),
}
# Container/format metadata
if probe_data.get("format"):
format_info = probe_data["format"]
metadata["container"] = {
"format": format_info.get("format_name"),
"duration": format_info.get("duration"),
"size": format_info.get("size"),
}
result["metadata"] = metadata
except Exception as e:
logger.warning(f"Failed to extract detailed metadata: {e}")
# Continue without metadata if parsing fails
output.append(result)
return JSONResponse(content=output)
@router.get("/ffprobe/snapshot", dependencies=[Depends(require_role(["admin"]))])
def ffprobe_snapshot(request: Request, url: str = "", timeout: int = 10):
"""Get a snapshot from a stream URL using ffmpeg."""
if not url:
return JSONResponse(
content={"success": False, "message": "URL parameter is required"},
status_code=400,
)
config: FrigateConfig = request.app.frigate_config
image_data, error = run_ffmpeg_snapshot(
config.ffmpeg, url, "mjpeg", timeout=timeout
)
if image_data:
return Response(
image_data,
media_type="image/jpeg",
headers={"Cache-Control": "no-store"},
)
elif error == "timeout":
return JSONResponse(
content={"success": False, "message": "Timeout capturing snapshot"},
status_code=408,
)
else:
logger.error(f"ffmpeg failed: {error}")
return JSONResponse(
content={"success": False, "message": "Failed to capture snapshot"},
status_code=500,
)
def _extract_fps(r_frame_rate: str) -> float | None:
"""Extract FPS from ffprobe r_frame_rate string (e.g., '30/1' -> 30.0)"""
if not r_frame_rate:
return None
try:
num, den = r_frame_rate.split("/")
return round(float(num) / float(den), 2)
except (ValueError, ZeroDivisionError):
return None
@router.get("/vainfo")
def vainfo():
vainfo = vainfo_hwaccel()
+55 -29
View File
@@ -943,6 +943,58 @@ def add_mask(mask: str, mask_img: np.ndarray):
cv2.fillPoly(mask_img, pts=[contour], color=(0))
def run_ffmpeg_snapshot(
ffmpeg,
input_path: str,
codec: str,
seek_time: Optional[float] = None,
height: Optional[int] = None,
timeout: Optional[int] = None,
) -> tuple[Optional[bytes], str]:
"""Run ffmpeg to extract a snapshot/image from a video source."""
ffmpeg_cmd = [
ffmpeg.ffmpeg_path,
"-hide_banner",
"-loglevel",
"warning",
]
if seek_time is not None:
ffmpeg_cmd.extend(["-ss", f"00:00:{seek_time}"])
ffmpeg_cmd.extend(
[
"-i",
input_path,
"-frames:v",
"1",
"-c:v",
codec,
"-f",
"image2pipe",
"-",
]
)
if height is not None:
ffmpeg_cmd.insert(-3, "-vf")
ffmpeg_cmd.insert(-3, f"scale=-1:{height}")
try:
process = sp.run(
ffmpeg_cmd,
capture_output=True,
timeout=timeout,
)
if process.returncode == 0 and process.stdout:
return process.stdout, ""
else:
return None, process.stderr.decode() if process.stderr else "ffmpeg failed"
except sp.TimeoutExpired:
return None, "timeout"
def get_image_from_recording(
ffmpeg, # Ffmpeg Config
file_path: str,
@@ -952,37 +1004,11 @@ def get_image_from_recording(
) -> Optional[Any]:
"""retrieve a frame from given time in recording file."""
ffmpeg_cmd = [
ffmpeg.ffmpeg_path,
"-hide_banner",
"-loglevel",
"warning",
"-ss",
f"00:00:{relative_frame_time}",
"-i",
file_path,
"-frames:v",
"1",
"-c:v",
codec,
"-f",
"image2pipe",
"-",
]
if height is not None:
ffmpeg_cmd.insert(-3, "-vf")
ffmpeg_cmd.insert(-3, f"scale=-1:{height}")
process = sp.run(
ffmpeg_cmd,
capture_output=True,
image_data, _ = run_ffmpeg_snapshot(
ffmpeg, file_path, codec, seek_time=relative_frame_time, height=height
)
if process.returncode == 0:
return process.stdout
else:
return None
return image_data
def get_histogram(image, x_min, y_min, x_max, y_max):
+20 -5
View File
@@ -515,9 +515,20 @@ def get_jetson_stats() -> Optional[dict[int, dict]]:
return results
def ffprobe_stream(ffmpeg, path: str) -> sp.CompletedProcess:
def ffprobe_stream(ffmpeg, path: str, detailed: bool = False) -> sp.CompletedProcess:
"""Run ffprobe on stream."""
clean_path = escape_special_characters(path)
# Base entries that are always included
stream_entries = "codec_long_name,width,height,bit_rate,duration,display_aspect_ratio,avg_frame_rate"
# Additional detailed entries
if detailed:
stream_entries += ",codec_name,profile,level,pix_fmt,channels,sample_rate,channel_layout,r_frame_rate"
format_entries = "format_name,size,bit_rate,duration"
else:
format_entries = None
ffprobe_cmd = [
ffmpeg.ffprobe_path,
"-timeout",
@@ -525,11 +536,15 @@ def ffprobe_stream(ffmpeg, path: str) -> sp.CompletedProcess:
"-print_format",
"json",
"-show_entries",
"stream=codec_long_name,width,height,bit_rate,duration,display_aspect_ratio,avg_frame_rate",
"-loglevel",
"quiet",
clean_path,
f"stream={stream_entries}",
]
# Add format entries for detailed mode
if detailed and format_entries:
ffprobe_cmd.extend(["-show_entries", f"format={format_entries}"])
ffprobe_cmd.extend(["-loglevel", "quiet", clean_path])
return sp.run(ffprobe_cmd, capture_output=True)