Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
42 commits
Select commit Hold shift + click to select a range
19d2952
fetch more from ffprobe
hawkeye217 Oct 10, 2025
1bb3538
add detailed param to ffprobe endpoint
hawkeye217 Oct 10, 2025
ef3e9bc
add dots variant to step indicator
hawkeye217 Oct 10, 2025
4a34451
add classname
hawkeye217 Oct 10, 2025
c068282
tweak colors for dark mode to match figma
hawkeye217 Oct 10, 2025
e7f0cf5
add step 1 form
hawkeye217 Oct 10, 2025
c96ee0c
add helper function for ffmpeg snapshot
hawkeye217 Oct 10, 2025
617d2a2
add go2rtc stream add and ffprobe snapshot endpoints
hawkeye217 Oct 10, 2025
be89678
add camera image and stream details on successful test
hawkeye217 Oct 10, 2025
9bcbd9a
step 1 tweaks
hawkeye217 Oct 11, 2025
71a7a94
step 2 and i18n
hawkeye217 Oct 11, 2025
78f126b
types
hawkeye217 Oct 11, 2025
c80ecc7
step 1 and 2 tweaks
hawkeye217 Oct 11, 2025
16d7d06
add wizard to camera settings view
hawkeye217 Oct 11, 2025
c01db9a
add data unit i18n keys
hawkeye217 Oct 11, 2025
4f33f4f
restream tweak
hawkeye217 Oct 11, 2025
56e7ffd
fix type
hawkeye217 Oct 11, 2025
c3e5728
implement rough idea for step 3
hawkeye217 Oct 11, 2025
c79b188
add api endpoint to delete stream from go2rtc
hawkeye217 Oct 11, 2025
3c86363
add main wizard dialog component
hawkeye217 Oct 11, 2025
84ac8d3
extract logic for friendly_name and use in wizard
hawkeye217 Oct 12, 2025
c2183bd
add i18n and popover for brand url
hawkeye217 Oct 12, 2025
e9459c5
add camera name to top
hawkeye217 Oct 12, 2025
44ff205
consolidate validation logic
hawkeye217 Oct 12, 2025
d113537
prevent dialog from closing when clicking outside
hawkeye217 Oct 12, 2025
07340b8
center camera name on mobile
hawkeye217 Oct 12, 2025
f3bde69
add help/docs link popovers
hawkeye217 Oct 12, 2025
e9119f8
keep spaces in friendly name
hawkeye217 Oct 12, 2025
ad3676f
add stream details to overlay like stats in liveplayer
hawkeye217 Oct 12, 2025
59b7dea
add validation results pane to step 3
hawkeye217 Oct 12, 2025
71e0ead
ensure test is invalidated if stream is changed
hawkeye217 Oct 12, 2025
6f7c32a
only display validation results and enable save button if all streams…
hawkeye217 Oct 12, 2025
bedba7a
tweaks
hawkeye217 Oct 12, 2025
a246b71
normalize camera name to lower case and improve hash generation
hawkeye217 Oct 12, 2025
2a6ba37
move wizard to subfolder
hawkeye217 Oct 12, 2025
858bbd1
tweaks
hawkeye217 Oct 13, 2025
cb5af80
match look of camera edit form to wizard
hawkeye217 Oct 13, 2025
2c6a2e6
move wizard and edit form to its own component
hawkeye217 Oct 13, 2025
1275d7a
move enabled/disabled switch to management section
hawkeye217 Oct 13, 2025
1ad2098
clean up
hawkeye217 Oct 13, 2025
d4bbda9
fixes
hawkeye217 Oct 13, 2025
1532583
fix mobile
hawkeye217 Oct 13, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
213 changes: 197 additions & 16 deletions frigate/api/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@
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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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()
Expand Down
76 changes: 51 additions & 25 deletions frigate/util/image.py
Original file line number Diff line number Diff line change
Expand Up @@ -943,46 +943,72 @@ def add_mask(mask: str, mask_img: np.ndarray):
cv2.fillPoly(mask_img, pts=[contour], color=(0))


def get_image_from_recording(
ffmpeg, # Ffmpeg Config
file_path: str,
relative_frame_time: float,
def run_ffmpeg_snapshot(
ffmpeg,
input_path: str,
codec: str,
seek_time: Optional[float] = None,
height: Optional[int] = None,
) -> Optional[Any]:
"""retrieve a frame from given time in recording file."""

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",
"-ss",
f"00:00:{relative_frame_time}",
"-i",
file_path,
"-frames:v",
"1",
"-c:v",
codec,
"-f",
"image2pipe",
"-",
]

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}")

process = sp.run(
ffmpeg_cmd,
capture_output=True,
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,
relative_frame_time: float,
codec: str,
height: Optional[int] = None,
) -> Optional[Any]:
"""retrieve a frame from given time in recording file."""

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):
Expand Down
25 changes: 20 additions & 5 deletions frigate/util/services.py
Original file line number Diff line number Diff line change
Expand Up @@ -515,21 +515,36 @@ 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",
"1000000",
"-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)


Expand Down
Loading