Compare commits

..

No commits in common. "7e83d5de906315631de7419cfaa3a9bd4fb490f8" and "3f0ebb35778ae5943ab53ea0bfd85ea9b4f8b749" have entirely different histories.

14 changed files with 64 additions and 152 deletions

View File

@ -265,8 +265,8 @@ ENV PATH="/usr/local/go2rtc/bin:/usr/local/tempio/bin:/usr/local/nginx/sbin:${PA
RUN --mount=type=bind,source=docker/main/install_deps.sh,target=/deps/install_deps.sh \
/deps/install_deps.sh
ENV DEFAULT_FFMPEG_VERSION="8.0"
ENV INCLUDED_FFMPEG_VERSIONS="${DEFAULT_FFMPEG_VERSION}:7.0:5.0"
ENV DEFAULT_FFMPEG_VERSION="7.0"
ENV INCLUDED_FFMPEG_VERSIONS="${DEFAULT_FFMPEG_VERSION}:5.0"
RUN wget -q https://bootstrap.pypa.io/get-pip.py -O get-pip.py \
&& sed -i 's/args.append("setuptools")/args.append("setuptools==77.0.3")/' get-pip.py \

View File

@ -52,13 +52,9 @@ if [[ "${TARGETARCH}" == "amd64" ]]; then
tar -xf ffmpeg.tar.xz -C /usr/lib/ffmpeg/5.0 --strip-components 1 amd64/bin/ffmpeg amd64/bin/ffprobe
rm -rf ffmpeg.tar.xz
mkdir -p /usr/lib/ffmpeg/7.0
wget -qO ffmpeg.tar.xz "https://github.com/NickM-27/FFmpeg-Builds/releases/download/autobuild-2024-09-19-12-51/ffmpeg-n7.0.2-18-g3e6cec1286-linux64-gpl-7.0.tar.xz"
wget -qO ffmpeg.tar.xz "https://github.com/NickM-27/FFmpeg-Builds/releases/download/autobuild-2026-03-19-13-03/ffmpeg-n7.1.3-43-g5a1f107b4c-linux64-gpl-7.1.tar.xz"
tar -xf ffmpeg.tar.xz -C /usr/lib/ffmpeg/7.0 --strip-components 1 amd64/bin/ffmpeg amd64/bin/ffprobe
rm -rf ffmpeg.tar.xz
mkdir -p /usr/lib/ffmpeg/8.0
wget -qO ffmpeg.tar.xz "https://github.com/NickM-27/FFmpeg-Builds/releases/download/autobuild-2026-06-02-14-20/ffmpeg-n8.1.1-9-g58d4114d36-linux64-gpl-8.1.tar.xz"
tar -xf ffmpeg.tar.xz -C /usr/lib/ffmpeg/8.0 --strip-components 1 amd64/bin/ffmpeg amd64/bin/ffprobe
rm -rf ffmpeg.tar.xz
fi
# ffmpeg -> arm64
@ -68,13 +64,9 @@ if [[ "${TARGETARCH}" == "arm64" ]]; then
tar -xf ffmpeg.tar.xz -C /usr/lib/ffmpeg/5.0 --strip-components 1 arm64/bin/ffmpeg arm64/bin/ffprobe
rm -f ffmpeg.tar.xz
mkdir -p /usr/lib/ffmpeg/7.0
wget -qO ffmpeg.tar.xz "https://github.com/NickM-27/FFmpeg-Builds/releases/download/autobuild-2024-09-19-12-51/ffmpeg-n7.0.2-18-g3e6cec1286-linuxarm64-gpl-7.0.tar.xz"
wget -qO ffmpeg.tar.xz "https://github.com/NickM-27/FFmpeg-Builds/releases/download/autobuild-2026-03-19-13-03/ffmpeg-n7.1.3-43-g5a1f107b4c-linuxarm64-gpl-7.1.tar.xz"
tar -xf ffmpeg.tar.xz -C /usr/lib/ffmpeg/7.0 --strip-components 1 arm64/bin/ffmpeg arm64/bin/ffprobe
rm -f ffmpeg.tar.xz
mkdir -p /usr/lib/ffmpeg/8.0
wget -qO ffmpeg.tar.xz "https://github.com/NickM-27/FFmpeg-Builds/releases/download/autobuild-2026-06-02-14-20/ffmpeg-n8.1.1-9-g58d4114d36-linuxarm64-gpl-8.1.tar.xz"
tar -xf ffmpeg.tar.xz -C /usr/lib/ffmpeg/8.0 --strip-components 1 arm64/bin/ffmpeg arm64/bin/ffprobe
rm -f ffmpeg.tar.xz
fi
# arch specific packages

View File

@ -5,7 +5,11 @@ from typing import Any
from ruamel.yaml import YAML
sys.path.insert(0, "/opt/frigate")
from frigate.util.config import find_config_file, resolve_ffmpeg_path
from frigate.const import (
DEFAULT_FFMPEG_VERSION,
INCLUDED_FFMPEG_VERSIONS,
)
from frigate.util.config import find_config_file
sys.path.remove("/opt/frigate")
@ -25,4 +29,9 @@ except FileNotFoundError:
config: dict[str, Any] = {}
path = config.get("ffmpeg", {}).get("path", "default")
print(resolve_ffmpeg_path(path, "ffmpeg"))
if path == "default":
print(f"/usr/lib/ffmpeg/{DEFAULT_FFMPEG_VERSION}/bin/ffmpeg")
elif path in INCLUDED_FFMPEG_VERSIONS:
print(f"/usr/lib/ffmpeg/{path}/bin/ffmpeg")
else:
print(f"{path}/bin/ffmpeg")

View File

@ -11,10 +11,12 @@ sys.path.insert(0, "/opt/frigate")
from frigate.config.env import substitute_frigate_vars
from frigate.const import (
BIRDSEYE_PIPE,
DEFAULT_FFMPEG_VERSION,
INCLUDED_FFMPEG_VERSIONS,
LIBAVFORMAT_VERSION_MAJOR,
)
from frigate.ffmpeg_presets import parse_preset_hardware_acceleration_encode
from frigate.util.config import find_config_file, resolve_ffmpeg_path
from frigate.util.config import find_config_file
from frigate.util.services import is_restricted_go2rtc_source
sys.path.remove("/opt/frigate")
@ -79,7 +81,12 @@ if go2rtc_config.get("rtsp", {}).get("password") is not None:
# ensure ffmpeg path is set correctly
path = config.get("ffmpeg", {}).get("path", "default")
ffmpeg_path = resolve_ffmpeg_path(path, "ffmpeg")
if path == "default":
ffmpeg_path = f"/usr/lib/ffmpeg/{DEFAULT_FFMPEG_VERSION}/bin/ffmpeg"
elif path in INCLUDED_FFMPEG_VERSIONS:
ffmpeg_path = f"/usr/lib/ffmpeg/{path}/bin/ffmpeg"
else:
ffmpeg_path = f"{path}/bin/ffmpeg"
if go2rtc_config.get("ffmpeg") is None:
go2rtc_config["ffmpeg"] = {"bin": ffmpeg_path}

View File

@ -257,7 +257,7 @@ birdseye:
# More information about presets at https://docs.frigate.video/configuration/ffmpeg_presets
ffmpeg:
# Optional: ffmpeg binary path (default: shown below)
# can also be set to `8.0` or `5.0` to specify one of the included versions
# can also be set to `7.0` or `5.0` to specify one of the included versions
# or can be set to any path that holds `bin/ffmpeg` & `bin/ffprobe`
path: "default"
# Optional: global ffmpeg args (default: shown below)

View File

@ -3,7 +3,7 @@ from typing import Union
from pydantic import Field, field_validator
from frigate.util.config import resolve_ffmpeg_path
from frigate.const import DEFAULT_FFMPEG_VERSION, INCLUDED_FFMPEG_VERSIONS
from ..base import FrigateBaseModel
from ..env import EnvString
@ -49,7 +49,7 @@ class FfmpegConfig(FrigateBaseModel):
path: str = Field(
default="default",
title="FFmpeg path",
description='Path to the FFmpeg binary to use or a version alias ("5.0" or "8.0").',
description='Path to the FFmpeg binary to use or a version alias ("5.0" or "7.0").',
)
global_args: Union[str, list[str]] = Field(
default=FFMPEG_GLOBAL_ARGS_DEFAULT,
@ -90,11 +90,21 @@ class FfmpegConfig(FrigateBaseModel):
@property
def ffmpeg_path(self) -> str:
return resolve_ffmpeg_path(self.path, "ffmpeg")
if self.path == "default":
return f"/usr/lib/ffmpeg/{DEFAULT_FFMPEG_VERSION}/bin/ffmpeg"
elif self.path in INCLUDED_FFMPEG_VERSIONS:
return f"/usr/lib/ffmpeg/{self.path}/bin/ffmpeg"
else:
return f"{self.path}/bin/ffmpeg"
@property
def ffprobe_path(self) -> str:
return resolve_ffmpeg_path(self.path, "ffprobe")
if self.path == "default":
return f"/usr/lib/ffmpeg/{DEFAULT_FFMPEG_VERSION}/bin/ffprobe"
elif self.path in INCLUDED_FFMPEG_VERSIONS:
return f"/usr/lib/ffmpeg/{self.path}/bin/ffprobe"
else:
return f"{self.path}/bin/ffprobe"
class CameraRoleEnum(str, Enum):

View File

@ -465,6 +465,16 @@ PRESETS_RECORD_OUTPUT = {
"-c:a",
"aac",
],
# NOTE: This preset originally used "-c:a copy" to pass through audio
# without re-encoding. FFmpeg 7.x introduced a threaded pipeline where
# demuxing, encoding, and muxing run in parallel via a Scheduler. This
# broke audio streamcopy from RTSP sources: packets are demuxed correctly
# but silently dropped before reaching the muxer (0 bytes written). The
# issue is specific to RTSP + streamcopy; file inputs and transcoding both
# work. Transcoding AAC audio is very lightweight (~30KiB per 10s segment)
# and adds negligible CPU overhead, so this is an acceptable workaround.
# The benefits of FFmpeg 7.x — particularly the removal of gamma correction
# hacks required by earlier versions — outweigh this trade-off.
"preset-record-generic-audio-copy": [
"-f",
"segment",
@ -476,8 +486,10 @@ PRESETS_RECORD_OUTPUT = {
"1",
"-strftime",
"1",
"-c",
"-c:v",
"copy",
"-c:a",
"aac",
],
"preset-record-mjpeg": [
"-f",

View File

@ -456,7 +456,7 @@ class RecordingExporter(threading.Thread):
diff = max(0.0, float(self.start_time) - float(preview.start_time))
ffmpeg_cmd = [
"/usr/lib/ffmpeg/8.0/bin/ffmpeg", # hardcode path for exports thumbnail due to missing libwebp support
"/usr/lib/ffmpeg/7.0/bin/ffmpeg", # hardcode path for exports thumbnail due to missing libwebp support
"-hide_banner",
"-loglevel",
"warning",

View File

@ -394,7 +394,7 @@ def collect_state_classification_examples(
# Step 3: Extract keyframes from recordings with crops applied
keyframes = _extract_keyframes(
"/usr/lib/ffmpeg/8.0/bin/ffmpeg", timestamps, temp_dir, cameras
"/usr/lib/ffmpeg/7.0/bin/ffmpeg", timestamps, temp_dir, cameras
)
# Step 4: Select 24 most visually distinct images (they're already cropped)
@ -566,7 +566,7 @@ def _extract_keyframes(
relative_time = timestamp - recording.start_time
try:
config = FfmpegConfig(path="/usr/lib/ffmpeg/8.0")
config = FfmpegConfig(path="/usr/lib/ffmpeg/7.0")
image_data = get_image_from_recording(
config,
recording.path,

View File

@ -8,13 +8,7 @@ from typing import Any, Optional, Union
from ruamel.yaml import YAML
from frigate.const import (
CONFIG_DIR,
DEFAULT_FFMPEG_VERSION,
EXPORT_DIR,
INCLUDED_FFMPEG_VERSIONS,
REDACTED_CREDENTIAL_SENTINEL,
)
from frigate.const import CONFIG_DIR, EXPORT_DIR, REDACTED_CREDENTIAL_SENTINEL
from frigate.util.builtin import deep_merge
from frigate.util.services import get_video_properties
@ -24,26 +18,6 @@ CURRENT_CONFIG_VERSION = "0.18-0"
DEFAULT_CONFIG_FILE = os.path.join(CONFIG_DIR, "config.yml")
def resolve_ffmpeg_path(path: str, binary: str = "ffmpeg") -> str:
"""Resolve an ffmpeg version alias or custom path to a binary path.
A bare version alias that is no longer bundled (for example one that was
dropped when the default version changed) falls back to the default
bundled version so existing configs keep working across an upgrade or a
revert. Custom install paths (anything absolute) are used as-is.
"""
if path == "default" or (
not path.startswith("/") and path not in INCLUDED_FFMPEG_VERSIONS
):
version = DEFAULT_FFMPEG_VERSION
elif path in INCLUDED_FFMPEG_VERSIONS:
version = path
else:
return f"{path}/bin/{binary}"
return f"/usr/lib/ffmpeg/{version}/bin/{binary}"
def redact_credential(obj: dict[str, Any], key: str) -> None:
"""Replace obj[key] with the redaction sentinel if a value is saved, else drop.

View File

@ -54,7 +54,6 @@ type HlsVideoPlayerProps = {
setFullResolution?: React.Dispatch<React.SetStateAction<VideoResolutionType>>;
onUploadFrame?: (playTime: number) => Promise<AxiosResponse> | undefined;
getSnapshotUrl?: (playTime: number) => string | undefined;
onSnapshot?: (playTime: number) => Promise<void> | void;
toggleFullscreen?: () => void;
onError?: (error: RecordingPlayerError) => void;
isDetailMode?: boolean;
@ -81,7 +80,6 @@ export default function HlsVideoPlayer({
setFullResolution,
onUploadFrame,
getSnapshotUrl,
onSnapshot,
toggleFullscreen,
onError,
isDetailMode = false,
@ -234,7 +232,6 @@ export default function HlsVideoPlayer({
const [mobileCtrlTimeout, setMobileCtrlTimeout] = useState<NodeJS.Timeout>();
const [controls, setControls] = useState(isMobile);
const [controlsOpen, setControlsOpen] = useState(false);
const [isSnapshotLoading, setIsSnapshotLoading] = useState(false);
const [zoomScale, setZoomScale] = useState(1.0);
const [videoDimensions, setVideoDimensions] = useState<{
width: number;
@ -290,21 +287,6 @@ export default function HlsVideoPlayer({
return currentTime + inpointOffset;
}, [videoRef, inpointOffset]);
const handleSnapshot = useCallback(async () => {
const frameTime = getVideoTime();
if (!frameTime || !onSnapshot) {
return;
}
setIsSnapshotLoading(true);
try {
await onSnapshot(frameTime);
} finally {
setIsSnapshotLoading(false);
}
}, [getVideoTime, onSnapshot]);
return (
<TransformWrapper
minScale={1.0}
@ -328,7 +310,6 @@ export default function HlsVideoPlayer({
seek: true,
playbackRate: true,
plusUpload: isAdmin && config?.plus?.enabled == true,
snapshot: !!onSnapshot,
fullscreen: supportsFullscreen,
}}
setControlsOpen={setControlsOpen}
@ -376,8 +357,6 @@ export default function HlsVideoPlayer({
}
}
}}
onSnapshot={onSnapshot ? handleSnapshot : undefined}
snapshotLoading={isSnapshotLoading}
fullscreen={fullscreen}
toggleFullscreen={toggleFullscreen}
containerRef={containerRef}

View File

@ -34,7 +34,6 @@ import {
} from "../ui/alert-dialog";
import { cn } from "@/lib/utils";
import { FaCompress, FaExpand } from "react-icons/fa";
import { TbCameraDown } from "react-icons/tb";
import { useTranslation } from "react-i18next";
type VideoControls = {
@ -42,7 +41,6 @@ type VideoControls = {
seek?: boolean;
playbackRate?: boolean;
plusUpload?: boolean;
snapshot?: boolean;
fullscreen?: boolean;
};
@ -51,7 +49,6 @@ const CONTROLS_DEFAULT: VideoControls = {
seek: true,
playbackRate: true,
plusUpload: false,
snapshot: false,
fullscreen: false,
};
const PLAYBACK_RATE_DEFAULT = isSafari ? [0.5, 1, 2] : [0.5, 1, 2, 4, 8, 16];
@ -76,8 +73,6 @@ type VideoControlsProps = {
onSetPlaybackRate: (rate: number) => void;
onUploadFrame?: () => void;
getSnapshotUrl?: () => string | undefined;
onSnapshot?: () => void;
snapshotLoading?: boolean;
toggleFullscreen?: () => void;
containerRef?: React.MutableRefObject<HTMLDivElement | null>;
};
@ -100,8 +95,6 @@ export default function VideoControls({
onSetPlaybackRate,
onUploadFrame,
getSnapshotUrl,
onSnapshot,
snapshotLoading = false,
toggleFullscreen,
containerRef,
}: VideoControlsProps) {
@ -302,25 +295,6 @@ export default function VideoControls({
fullscreen={fullscreen}
/>
)}
{features.snapshot && onSnapshot && (
<TbCameraDown
className={cn(
"size-5",
snapshotLoading
? "cursor-not-allowed opacity-50"
: "cursor-pointer",
)}
onClick={(e: React.MouseEvent<SVGElement>) => {
e.stopPropagation();
if (snapshotLoading) {
return;
}
onSnapshot();
}}
/>
)}
{features.fullscreen && toggleFullscreen && (
<div className="cursor-pointer" onClick={toggleFullscreen}>
{fullscreen ? <FaCompress /> : <FaExpand />}

View File

@ -19,18 +19,12 @@ import { TimeRange } from "@/types/timeline";
import ActivityIndicator from "@/components/indicators/activity-indicator";
import { VideoResolutionType } from "@/types/live";
import axios from "axios";
import { toast } from "sonner";
import { cn } from "@/lib/utils";
import { useTranslation } from "react-i18next";
import {
calculateInpointOffset,
calculateSeekPosition,
} from "@/utils/videoUtil";
import {
downloadSnapshot,
generateSnapshotFilename,
grabVideoSnapshot,
} from "@/utils/snapshotUtil";
import { isFirefox } from "react-device-detect";
/**
@ -74,7 +68,7 @@ export default function DynamicVideoPlayer({
containerRef,
transformedOverlay,
}: DynamicVideoPlayerProps) {
const { t } = useTranslation(["components/player", "views/live"]);
const { t } = useTranslation(["components/player"]);
const apiHost = useApiHost();
const { data: config } = useSWR<FrigateConfig>("config");
@ -202,34 +196,6 @@ export default function DynamicVideoPlayer({
[apiHost, camera, controller],
);
const onDownloadSnapshot = useCallback(
async (playTime: number) => {
if (!controller || !playerRef.current) {
return;
}
// map the player time back to the timeline timestamp so the filename
// reflects the moment being viewed rather than the current time
const frameTime = controller.getProgress(playTime);
const result = await grabVideoSnapshot(playerRef.current);
if (result.success) {
downloadSnapshot(
result.data.dataUrl,
generateSnapshotFilename(camera, frameTime),
);
toast.success(t("snapshot.downloadStarted", { ns: "views/live" }), {
position: "top-center",
});
} else {
toast.error(t("snapshot.captureFailed", { ns: "views/live" }), {
position: "top-center",
});
}
},
[camera, controller, t],
);
// state of playback player
const recordingParams = useMemo(
@ -362,7 +328,6 @@ export default function DynamicVideoPlayer({
setFullResolution={setFullResolution}
onUploadFrame={onUploadFrameToPlus}
getSnapshotUrl={getSnapshotUrlForPlus}
onSnapshot={onDownloadSnapshot}
toggleFullscreen={toggleFullscreen}
onError={(error) => {
if (error == "stalled" && !isScrubbing) {

View File

@ -97,27 +97,17 @@ export function downloadSnapshot(dataUrl: string, filename: string): void {
}
}
export function generateSnapshotFilename(
cameraName: string,
timestampSeconds?: number,
): string {
// Live snapshots use the current time, while History snapshots pass the
// playback timestamp so the filename matches the moment being viewed.
const date =
typeof timestampSeconds === "number" && Number.isFinite(timestampSeconds)
? new Date(timestampSeconds * 1000)
: new Date();
const timestamp = date.toISOString().replace(/[:.]/g, "-").slice(0, -5);
export function generateSnapshotFilename(cameraName: string): string {
const timestamp = new Date().toISOString().replace(/[:.]/g, "-").slice(0, -5);
return `${cameraName}_snapshot_${timestamp}.jpg`;
}
export async function grabVideoSnapshot(
targetVideo?: HTMLVideoElement | null,
): Promise<SnapshotResult> {
export async function grabVideoSnapshot(): Promise<SnapshotResult> {
try {
const videoElement =
targetVideo ??
(document.querySelector("#player-container video") as HTMLVideoElement);
// Find the video element in the player
const videoElement = document.querySelector(
"#player-container video",
) as HTMLVideoElement;
if (!videoElement) {
return {