Compare commits

..

14 Commits

Author SHA1 Message Date
GuoQing Liu
39fd108334
Merge 9e72a825f3 into ba4a6a53d7 2026-05-01 08:24:34 -06:00
ZhaiSoul
9e72a825f3
docs: RTSP password is optional 2026-05-01 22:24:27 +08:00
ZhaiSoul
bb5e01ac45
docs: add hailo and memryX mx3 driver tips 2026-05-01 22:19:53 +08:00
ZhaiSoul
43e90d6de4
docs: timezone change to select 2026-05-01 22:13:00 +08:00
ZhaiSoul
b893a4f851
docs: Adjust the position of the RTSP password variable option 2026-05-01 21:59:19 +08:00
GuoQing Liu
1b0816db87
Update docs/src/components/DockerComposeGenerator/config/config.yaml
Co-authored-by: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com>
2026-05-01 21:52:41 +08:00
GuoQing Liu
4b81a82a32
Update docs/src/components/DockerComposeGenerator/components/StoragePaths.tsx
Co-authored-by: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com>
2026-05-01 21:52:32 +08:00
GuoQing Liu
feda1c791c
Update docs/src/components/DockerComposeGenerator/components/StoragePaths.tsx
Co-authored-by: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com>
2026-05-01 21:52:25 +08:00
GuoQing Liu
f75e9c30d0
Update docs/src/components/DockerComposeGenerator/config/config.yaml
Co-authored-by: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com>
2026-05-01 21:52:15 +08:00
GuoQing Liu
8c9259a973
Update docs/src/components/DockerComposeGenerator/config/config.yaml
Co-authored-by: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com>
2026-05-01 21:52:06 +08:00
GuoQing Liu
1df11f3ddd
Update docs/src/components/DockerComposeGenerator/components/OtherOptions.tsx
Co-authored-by: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com>
2026-05-01 21:51:58 +08:00
GuoQing Liu
845a813651
Update docs/src/components/DockerComposeGenerator/config/config.yaml
Co-authored-by: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com>
2026-05-01 21:51:44 +08:00
Josh Hawkins
ba4a6a53d7
Miscellaneous fixes (#23053)
Some checks are pending
CI / AMD64 Build (push) Waiting to run
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
* don't exempt draft PRs from stalebot

* Fix import

* ensure toast shows when export API returns 20n (202, accepted)

---------

Co-authored-by: Nicolas Mowen <nickmowen213@gmail.com>
2026-04-30 17:19:53 -06:00
Nicolas Mowen
e90079ab2f
Include chapters for review items in exports (#23052) 2026-04-30 18:16:24 -05:00
12 changed files with 248 additions and 78 deletions

View File

@ -18,7 +18,7 @@ jobs:
close-issue-message: ""
days-before-stale: 30
days-before-close: 3
exempt-draft-pr: true
exempt-draft-pr: false
exempt-issue-labels: "planned,security"
exempt-pr-labels: "planned,security,dependencies"
operations-per-run: 120

View File

@ -1,7 +1,24 @@
import React from "react";
import React, { useMemo } from "react";
import CodeInline from "@theme/CodeInline";
import styles from "../styles.module.css";
const AUTO_TIMEZONE_VALUE = "__auto__";
function getTimezoneList(): string[] {
if (typeof Intl !== "undefined") {
const intl = Intl as typeof Intl & {
supportedValuesOf?: (key: string) => string[];
};
const supported = intl.supportedValuesOf?.("timeZone");
if (supported && supported.length > 0) {
return [...supported].sort();
}
}
const fallback = Intl.DateTimeFormat().resolvedOptions().timeZone;
return fallback ? [fallback] : ["UTC"];
}
interface Props {
rtspPassword: string;
timezone: string;
@ -21,41 +38,38 @@ export default function OtherOptions({
onTimezoneChange,
onShmSizeChange,
}: Props) {
const timezones = useMemo(() => getTimezoneList(), []);
const systemTimezone =
Intl.DateTimeFormat().resolvedOptions().timeZone || "Etc/UTC";
const selectedValue = timezone || AUTO_TIMEZONE_VALUE;
return (
<div className={styles.formSection}>
<h4>Other Options</h4>
<div className={styles.formGrid}>
<div className={styles.formGroup}>
<label htmlFor="dcg-rtsp-password" className={styles.label}>
RTSP password:
</label>
<input
id="dcg-rtsp-password"
type="text"
className={styles.input}
value={rtspPassword}
placeholder="password"
onChange={(e) => onRtspPasswordChange(e.target.value)}
/>
<p className={styles.helpText}>
Used as{" "}
<CodeInline>{"{FRIGATE_RTSP_PASSWORD}"}</CodeInline>{" "}
in the config file to reference camera stream passwords. This is NOT
the Frigate login password.
</p>
</div>
<div className={styles.formGroup}>
<label htmlFor="dcg-timezone" className={styles.label}>
Timezone:
</label>
<input
<select
id="dcg-timezone"
type="text"
className={styles.input}
value={timezone}
placeholder={Intl.DateTimeFormat().resolvedOptions().timeZone || "Etc/UTC"}
onChange={(e) => onTimezoneChange(e.target.value)}
/>
className={`${styles.input} ${styles.select}`}
value={selectedValue}
onChange={(e) =>
onTimezoneChange(
e.target.value === AUTO_TIMEZONE_VALUE ? "" : e.target.value
)
}
>
<option value={AUTO_TIMEZONE_VALUE}>
Use browser timezone ({systemTimezone})
</option>
{timezones.map((tz) => (
<option key={tz} value={tz}>
{tz}
</option>
))}
</select>
</div>
<div className={styles.formGroup}>
<label htmlFor="dcg-shm-size" className={styles.label}>
@ -83,6 +97,25 @@ export default function OtherOptions({
</p>
)}
</div>
<div className={styles.formGroup}>
<label htmlFor="dcg-rtsp-password" className={styles.label}>
RTSP password:
</label>
<input
id="dcg-rtsp-password"
type="text"
className={styles.input}
value={rtspPassword}
placeholder="password"
onChange={(e) => onRtspPasswordChange(e.target.value)}
/>
<p className={styles.helpText}>
Optional. You can specify{" "}
<CodeInline>{"{FRIGATE_RTSP_PASSWORD}"}</CodeInline>{" "}
in the config file to reference camera stream passwords. This is NOT
the Frigate login password.
</p>
</div>
</div>
</div>
);

View File

@ -24,7 +24,7 @@ export default function StoragePaths({
<div className={styles.formGrid}>
<div className={styles.formGroup}>
<label htmlFor="dcg-config-path" className={styles.label}>
Config / DB / model cache directory:
Config / DB / model cache directory (on your host):
</label>
<input
id="dcg-config-path"
@ -43,7 +43,7 @@ export default function StoragePaths({
</div>
<div className={styles.formGroup}>
<label htmlFor="dcg-media-path" className={styles.label}>
Recording storage directory:
Recording storage directory (on your host):
</label>
<input
id="dcg-media-path"

View File

@ -172,7 +172,7 @@ hardware:
comment: "PCIe Coral — follow driver instructions at https://github.com/jnicolson/gasket-builder"
- id: "gpu"
label: "GPU Acceleration (/dev/dri)"
label: "Intel/AMD GPU (/dev/dri)"
description: "Pass through /dev/dri for GPU hardware acceleration (Intel/AMD)."
disabledWhen:
- "stable-tensorrt-jp6"
@ -198,7 +198,7 @@ hardware:
- id: "hailo"
label: "Hailo NPU (/dev/hailo0)"
description: "Pass through /dev/hailo0 for Hailo-8 / Hailo-8L NPU acceleration."
description: "Pass through /dev/hailo0 for Hailo-8 / Hailo-8L NPU acceleration. You also need to [install the driver](#hailo-8)."
disabledWhen:
- "apple-silicon"
- "stable-synaptics"
@ -208,7 +208,7 @@ hardware:
- id: "memryx"
label: "MemryX MX3 (/dev/memx0)"
description: "Pass through /dev/memx0 for MemryX MX3 NPU acceleration."
description: "Pass through /dev/memx0 for MemryX MX3 NPU acceleration. You also need to [install the driver](#memryx-mx3)."
disabledWhen:
- "apple-silicon"
- "stable-synaptics"
@ -242,7 +242,7 @@ hardware:
comment: "AXERA libraries"
- id: "video11"
label: "Raspberry Pi video11"
label: "Raspberry Pi (/dev/video11)"
description: "Pass through /dev/video11 for Raspberry Pi 4B hardware acceleration."
disabledWhen:
- "stable-tensorrt"
@ -272,7 +272,7 @@ ports:
host: 8554
container: 8554
protocol: "tcp"
description: "RTSP feeds"
description: "Access RTSP feeds from go2rtc"
defaultEnabled: true
- id: "8555-tcp"
@ -293,5 +293,5 @@ ports:
host: 1984
container: 1984
protocol: "tcp"
description: "Go2RTC Web UIport"
description: "Go2RTC Web UI"
defaultEnabled: false

View File

@ -15,7 +15,7 @@ export interface GeneratorInput {
enabledPorts: string[];
configPath: string;
mediaPath: string;
rtspPassword: string;
rtspPassword?: string;
timezone: string;
shmSize: string;
nvidiaGpuCount?: string;
@ -99,7 +99,7 @@ function buildPorts(enabledPorts: string[]): string[] {
function buildEnvironment(
device: DeviceConfig,
hwEnv: Record<string, string>,
rtspPassword: string,
rtspPassword: string | undefined,
timezone: string
): string[] {
const allEnv: Record<string, string> = {
@ -107,11 +107,15 @@ function buildEnvironment(
...(device.env ?? {}),
};
const lines: string[] = [
" environment:",
` FRIGATE_RTSP_PASSWORD: "${rtspPassword}" # RTSP password — change to your own`,
` TZ: "${timezone}" # Timezone`,
];
const lines: string[] = [" environment:"];
if (rtspPassword) {
lines.push(
` FRIGATE_RTSP_PASSWORD: "${rtspPassword}" # RTSP password — change to your own`
);
}
lines.push(` TZ: "${timezone}" # Timezone`);
for (const [key, value] of Object.entries(allEnv)) {
lines.push(` ${key}: "${value}"`);

View File

@ -33,10 +33,8 @@ export function useConfigGenerator() {
const [nvidiaGpuDeviceId, setNvidiaGpuDeviceId] = useState("");
const [configPath, setConfigPath] = useState("");
const [mediaPath, setMediaPath] = useState("");
const [rtspPassword, setRtspPassword] = useState("password");
const [timezone, setTimezone] = useState(
() => Intl.DateTimeFormat().resolvedOptions().timeZone || "Etc/UTC"
);
const [rtspPassword, setRtspPassword] = useState("");
const [timezone, setTimezone] = useState("");
const [shmSize, setShmSize] = useState("512mb");
const [shmSizeError, setShmSizeError] = useState(false);
const [gpuDeviceIdError, setGpuDeviceIdError] = useState(false);
@ -168,7 +166,7 @@ export function useConfigGenerator() {
enabledPorts: enabledPortLines,
configPath: configPath || "/path/to/your/config",
mediaPath: mediaPath || "/path/to/your/storage",
rtspPassword: rtspPassword || "password",
rtspPassword,
timezone: timezone || Intl.DateTimeFormat().resolvedOptions().timeZone || "Etc/UTC",
shmSize: shmSize || "512mb",
nvidiaGpuCount,

View File

@ -103,6 +103,21 @@
}
}
/* --- Select dropdown --- */
.select {
cursor: pointer;
appearance: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%23666' d='M6 8L1 3h10z'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 0.75rem center;
padding-right: 2rem;
}
[data-theme="light"] .select {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%23555' d='M6 8L1 3h10z'/%3E%3C/svg%3E");
}
.helpText {
margin: 0.5rem 0 0 0;
font-size: 0.85rem;

View File

@ -8,7 +8,6 @@ import os
import queue
import subprocess as sp
import threading
import time
import traceback
from multiprocessing.synchronize import Event as MpEvent
from typing import Any, Optional

View File

@ -28,7 +28,7 @@ from frigate.ffmpeg_presets import (
EncodeTypeEnum,
parse_preset_hardware_acceleration_encode,
)
from frigate.models import Export, Previews, Recordings
from frigate.models import Export, Previews, Recordings, ReviewSegment
from frigate.util.time import is_current_hour
logger = logging.getLogger(__name__)
@ -347,6 +347,122 @@ class RecordingExporter(threading.Thread):
# return in iso format
return datetime.datetime.fromtimestamp(timestamp).strftime("%Y-%m-%d %H:%M:%S")
def _chapter_metadata_path(self) -> str:
return os.path.join(CACHE_DIR, f"export_chapters_{self.export_id}.txt")
def _build_chapter_metadata_file(self, recordings: list) -> Optional[str]:
"""Write an FFmpeg metadata file with chapters for review items in range.
Chapter offsets are computed in *output time*: the VOD endpoint
concatenates recording clips back-to-back, so wall-clock gaps
between recordings collapse in the produced video. We walk the
same recording rows that feed the playlist and convert each
review item's wall-clock boundaries into output-time offsets.
Returns ``None`` when there are no recordings, no review items,
or any chapter would have zero output duration.
"""
if not recordings:
return None
windows: list[tuple[float, float, float]] = []
output_offset = 0.0
for rec in recordings:
clipped_start = max(float(rec.start_time), float(self.start_time))
clipped_end = min(float(rec.end_time), float(self.end_time))
if clipped_end <= clipped_start:
continue
windows.append((clipped_start, clipped_end, output_offset))
output_offset += clipped_end - clipped_start
if not windows:
return None
try:
review_rows = list(
ReviewSegment.select(
ReviewSegment.start_time,
ReviewSegment.end_time,
ReviewSegment.severity,
ReviewSegment.data,
)
.where(
ReviewSegment.start_time.between(self.start_time, self.end_time)
| ReviewSegment.end_time.between(self.start_time, self.end_time)
| (
(self.start_time > ReviewSegment.start_time)
& (self.end_time < ReviewSegment.end_time)
)
)
.where(ReviewSegment.camera == self.camera)
.order_by(ReviewSegment.start_time.asc())
.iterator()
)
except Exception:
logger.exception(
"Failed to query review segments for export %s", self.export_id
)
return None
if not review_rows:
return None
total_output = windows[-1][2] + (windows[-1][1] - windows[-1][0])
def wall_to_output(t: float) -> float:
t = max(float(self.start_time), min(float(self.end_time), t))
for w_start, w_end, w_offset in windows:
if t < w_start:
return w_offset
if t <= w_end:
return w_offset + (t - w_start)
return total_output
chapter_blocks: list[str] = []
for review in review_rows:
start_out = wall_to_output(float(review.start_time))
end_out = wall_to_output(float(review.end_time))
# Drop chapters that fall entirely in a recording gap, or are
# too short to be navigable in a player.
if end_out - start_out < 1.0:
continue
data = review.data or {}
labels: list[str] = []
for obj in data.get("objects") or []:
label = str(obj).split("-")[0]
if label and label not in labels:
labels.append(label)
title = str(review.severity).capitalize()
if labels:
title = f"{title}: {', '.join(labels)}"
chapter_blocks.append(
"[CHAPTER]\n"
"TIMEBASE=1/1000\n"
f"START={int(start_out * 1000)}\n"
f"END={int(end_out * 1000)}\n"
f"title={title}"
)
if not chapter_blocks:
return None
meta_path = self._chapter_metadata_path()
try:
with open(meta_path, "w", encoding="utf-8") as f:
f.write(";FFMETADATA1\n")
f.write("\n".join(chapter_blocks))
f.write("\n")
except OSError:
logger.exception(
"Failed to write chapter metadata file for export %s", self.export_id
)
return None
return meta_path
def save_thumbnail(self, id: str) -> str:
thumb_path = os.path.join(CLIPS_DIR, f"export/{id}.webp")
@ -451,6 +567,24 @@ class RecordingExporter(threading.Thread):
if type(internal_port) is str:
internal_port = int(internal_port.split(":")[-1])
recordings = list(
Recordings.select(
Recordings.start_time,
Recordings.end_time,
)
.where(
Recordings.start_time.between(self.start_time, self.end_time)
| Recordings.end_time.between(self.start_time, self.end_time)
| (
(self.start_time > Recordings.start_time)
& (self.end_time < Recordings.end_time)
)
)
.where(Recordings.camera == self.camera)
.order_by(Recordings.start_time.asc())
.iterator()
)
playlist_lines: list[str] = []
if (self.end_time - self.start_time) <= MAX_PLAYLIST_SECONDS:
playlist_url = f"http://127.0.0.1:{internal_port}/vod/{self.camera}/start/{self.start_time}/end/{self.end_time}/index.m3u8"
@ -458,32 +592,13 @@ class RecordingExporter(threading.Thread):
f"-y -protocol_whitelist pipe,file,http,tcp -i {playlist_url}"
)
else:
# get full set of recordings
export_recordings = (
Recordings.select(
Recordings.start_time,
Recordings.end_time,
)
.where(
Recordings.start_time.between(self.start_time, self.end_time)
| Recordings.end_time.between(self.start_time, self.end_time)
| (
(self.start_time > Recordings.start_time)
& (self.end_time < Recordings.end_time)
)
)
.where(Recordings.camera == self.camera)
.order_by(Recordings.start_time.asc())
)
# Use pagination to process records in chunks
# Chunk the recording rows into pages so each playlist line
# references a bounded sub-range rather than the full export.
page_size = 1000
num_pages = (export_recordings.count() + page_size - 1) // page_size
for page in range(1, num_pages + 1):
playlist = export_recordings.paginate(page, page_size)
for i in range(0, len(recordings), page_size):
chunk = recordings[i : i + page_size]
playlist_lines.append(
f"file 'http://127.0.0.1:{internal_port}/vod/{self.camera}/start/{float(playlist[0].start_time)}/end/{float(playlist[-1].end_time)}/index.m3u8'"
f"file 'http://127.0.0.1:{internal_port}/vod/{self.camera}/start/{float(chunk[0].start_time)}/end/{float(chunk[-1].end_time)}/index.m3u8'"
)
ffmpeg_input = "-y -protocol_whitelist pipe,file,http,tcp -f concat -safe 0 -i /dev/stdin"
@ -504,8 +619,12 @@ class RecordingExporter(threading.Thread):
)
).split(" ")
else:
chapters_path = self._build_chapter_metadata_file(recordings)
chapter_args = (
f" -i {chapters_path} -map 0 -map_metadata 1" if chapters_path else ""
)
ffmpeg_cmd = (
f"{self.config.ffmpeg.ffmpeg_path} -hide_banner {ffmpeg_input} -c copy -movflags +faststart"
f"{self.config.ffmpeg.ffmpeg_path} -hide_banner {ffmpeg_input}{chapter_args} -c copy -movflags +faststart"
).split(" ")
# add metadata
@ -691,6 +810,8 @@ class RecordingExporter(threading.Thread):
ffmpeg_cmd, playlist_lines, step="encoding_retry"
)
Path(self._chapter_metadata_path()).unlink(missing_ok=True)
if returncode != 0:
logger.error(
f"Failed to export {self.playback_source.value} for command {' '.join(ffmpeg_cmd)}"

View File

@ -85,7 +85,7 @@ export default function ReviewCard({
{ playback: "realtime" },
)
.then((response) => {
if (response.status == 200) {
if (response.status < 300) {
toast.success(t("export.toast.success"), {
position: "top-center",
action: (

View File

@ -278,7 +278,7 @@ export default function EventView({
{ playback: "realtime", image_path: review.thumb_path },
)
.then((response) => {
if (response.status == 200) {
if (response.status < 300) {
toast.success(
t("export.toast.success", { ns: "components/dialog" }),
{

View File

@ -357,7 +357,7 @@ export default function MotionSearchView({
},
)
.then((response) => {
if (response.status == 200) {
if (response.status < 300) {
toast.success(
t("export.toast.success", { ns: "components/dialog" }),
{