Compare commits

..

1 Commits

Author SHA1 Message Date
GuoQing Liu
1f9cfcd952
Merge 46a5eb4647 into edcf0b0d2c 2026-04-30 11:57:40 -07:00
12 changed files with 78 additions and 248 deletions

View File

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

View File

@ -1,24 +1,7 @@
import React, { useMemo } from "react"; import React from "react";
import CodeInline from "@theme/CodeInline"; import CodeInline from "@theme/CodeInline";
import styles from "../styles.module.css"; 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 { interface Props {
rtspPassword: string; rtspPassword: string;
timezone: string; timezone: string;
@ -38,38 +21,41 @@ export default function OtherOptions({
onTimezoneChange, onTimezoneChange,
onShmSizeChange, onShmSizeChange,
}: Props) { }: Props) {
const timezones = useMemo(() => getTimezoneList(), []);
const systemTimezone =
Intl.DateTimeFormat().resolvedOptions().timeZone || "Etc/UTC";
const selectedValue = timezone || AUTO_TIMEZONE_VALUE;
return ( return (
<div className={styles.formSection}> <div className={styles.formSection}>
<h4>Other Options</h4> <h4>Other Options</h4>
<div className={styles.formGrid}> <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}> <div className={styles.formGroup}>
<label htmlFor="dcg-timezone" className={styles.label}> <label htmlFor="dcg-timezone" className={styles.label}>
Timezone: Timezone:
</label> </label>
<select <input
id="dcg-timezone" id="dcg-timezone"
className={`${styles.input} ${styles.select}`} type="text"
value={selectedValue} className={styles.input}
onChange={(e) => value={timezone}
onTimezoneChange( placeholder={Intl.DateTimeFormat().resolvedOptions().timeZone || "Etc/UTC"}
e.target.value === AUTO_TIMEZONE_VALUE ? "" : e.target.value onChange={(e) => onTimezoneChange(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>
<div className={styles.formGroup}> <div className={styles.formGroup}>
<label htmlFor="dcg-shm-size" className={styles.label}> <label htmlFor="dcg-shm-size" className={styles.label}>
@ -97,25 +83,6 @@ export default function OtherOptions({
</p> </p>
)} )}
</div> </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>
</div> </div>
); );

View File

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

View File

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

View File

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

View File

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

View File

@ -103,21 +103,6 @@
} }
} }
/* --- 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 { .helpText {
margin: 0.5rem 0 0 0; margin: 0.5rem 0 0 0;
font-size: 0.85rem; font-size: 0.85rem;

View File

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

View File

@ -28,7 +28,7 @@ from frigate.ffmpeg_presets import (
EncodeTypeEnum, EncodeTypeEnum,
parse_preset_hardware_acceleration_encode, parse_preset_hardware_acceleration_encode,
) )
from frigate.models import Export, Previews, Recordings, ReviewSegment from frigate.models import Export, Previews, Recordings
from frigate.util.time import is_current_hour from frigate.util.time import is_current_hour
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -347,122 +347,6 @@ class RecordingExporter(threading.Thread):
# return in iso format # return in iso format
return datetime.datetime.fromtimestamp(timestamp).strftime("%Y-%m-%d %H:%M:%S") 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: def save_thumbnail(self, id: str) -> str:
thumb_path = os.path.join(CLIPS_DIR, f"export/{id}.webp") thumb_path = os.path.join(CLIPS_DIR, f"export/{id}.webp")
@ -567,7 +451,15 @@ class RecordingExporter(threading.Thread):
if type(internal_port) is str: if type(internal_port) is str:
internal_port = int(internal_port.split(":")[-1]) internal_port = int(internal_port.split(":")[-1])
recordings = list( 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"
ffmpeg_input = (
f"-y -protocol_whitelist pipe,file,http,tcp -i {playlist_url}"
)
else:
# get full set of recordings
export_recordings = (
Recordings.select( Recordings.select(
Recordings.start_time, Recordings.start_time,
Recordings.end_time, Recordings.end_time,
@ -582,23 +474,16 @@ class RecordingExporter(threading.Thread):
) )
.where(Recordings.camera == self.camera) .where(Recordings.camera == self.camera)
.order_by(Recordings.start_time.asc()) .order_by(Recordings.start_time.asc())
.iterator()
) )
playlist_lines: list[str] = [] # Use pagination to process records in chunks
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"
ffmpeg_input = (
f"-y -protocol_whitelist pipe,file,http,tcp -i {playlist_url}"
)
else:
# Chunk the recording rows into pages so each playlist line
# references a bounded sub-range rather than the full export.
page_size = 1000 page_size = 1000
for i in range(0, len(recordings), page_size): num_pages = (export_recordings.count() + page_size - 1) // page_size
chunk = recordings[i : i + page_size]
for page in range(1, num_pages + 1):
playlist = export_recordings.paginate(page, page_size)
playlist_lines.append( playlist_lines.append(
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'" 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'"
) )
ffmpeg_input = "-y -protocol_whitelist pipe,file,http,tcp -f concat -safe 0 -i /dev/stdin" ffmpeg_input = "-y -protocol_whitelist pipe,file,http,tcp -f concat -safe 0 -i /dev/stdin"
@ -619,12 +504,8 @@ class RecordingExporter(threading.Thread):
) )
).split(" ") ).split(" ")
else: 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 = ( ffmpeg_cmd = (
f"{self.config.ffmpeg.ffmpeg_path} -hide_banner {ffmpeg_input}{chapter_args} -c copy -movflags +faststart" f"{self.config.ffmpeg.ffmpeg_path} -hide_banner {ffmpeg_input} -c copy -movflags +faststart"
).split(" ") ).split(" ")
# add metadata # add metadata
@ -810,8 +691,6 @@ class RecordingExporter(threading.Thread):
ffmpeg_cmd, playlist_lines, step="encoding_retry" ffmpeg_cmd, playlist_lines, step="encoding_retry"
) )
Path(self._chapter_metadata_path()).unlink(missing_ok=True)
if returncode != 0: if returncode != 0:
logger.error( logger.error(
f"Failed to export {self.playback_source.value} for command {' '.join(ffmpeg_cmd)}" 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" }, { playback: "realtime" },
) )
.then((response) => { .then((response) => {
if (response.status < 300) { if (response.status == 200) {
toast.success(t("export.toast.success"), { toast.success(t("export.toast.success"), {
position: "top-center", position: "top-center",
action: ( action: (

View File

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

View File

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