mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-04-09 16:47:37 +03:00
feat(recap): add auto-generation scheduler and more config options
New config options: - auto_generate: trigger daily recap at a scheduled time - schedule_time: HH:MM for when to run (default 02:00) - cameras: list of cameras to process (empty = all) - speed: playback speed multiplier (1-8x, default 2) - max_per_group: how many events play simultaneously (1-10, default 3) New scheduler thread (recap/scheduler.py) checks once per minute, generates yesterday's recap for each configured camera when the scheduled time hits. Validated schedule_time format in config.
This commit is contained in:
parent
717b878956
commit
17e5211991
@ -1,4 +1,4 @@
|
||||
from pydantic import Field
|
||||
from pydantic import Field, field_validator
|
||||
|
||||
from .base import FrigateBaseModel
|
||||
|
||||
@ -11,15 +11,44 @@ class RecapConfig(FrigateBaseModel):
|
||||
title="Enable recaps",
|
||||
description="Allow generation of time-stacked recap videos that composite detected objects onto a clean background.",
|
||||
)
|
||||
auto_generate: bool = Field(
|
||||
default=False,
|
||||
title="Auto-generate daily",
|
||||
description="Automatically generate a recap for the previous day at the scheduled time.",
|
||||
)
|
||||
schedule_time: str = Field(
|
||||
default="02:00",
|
||||
title="Schedule time",
|
||||
description="Time of day (HH:MM, 24h format) to auto-generate the previous day's recap. Only used when auto_generate is true.",
|
||||
)
|
||||
cameras: list[str] = Field(
|
||||
default=[],
|
||||
title="Cameras",
|
||||
description="List of camera names to generate recaps for. Empty list means all cameras.",
|
||||
)
|
||||
default_label: str = Field(
|
||||
default="person",
|
||||
title="Default object label",
|
||||
description="The object type to include in recaps by default.",
|
||||
description="The object type to include in recaps.",
|
||||
)
|
||||
speed: int = Field(
|
||||
default=2,
|
||||
title="Playback speed",
|
||||
description="Speed multiplier for the output video.",
|
||||
ge=1,
|
||||
le=8,
|
||||
)
|
||||
max_per_group: int = Field(
|
||||
default=3,
|
||||
title="Max events per group",
|
||||
description="Maximum number of events to composite simultaneously. Higher values pack more into the video but can get crowded.",
|
||||
ge=1,
|
||||
le=10,
|
||||
)
|
||||
ghost_duration: float = Field(
|
||||
default=3.0,
|
||||
title="Ghost visibility duration",
|
||||
description="How long (in seconds) each detected object stays visible on the recap video.",
|
||||
description="How long (in seconds of video time) each detection stays visible when path data is unavailable.",
|
||||
ge=0.5,
|
||||
le=30.0,
|
||||
)
|
||||
@ -32,8 +61,8 @@ class RecapConfig(FrigateBaseModel):
|
||||
)
|
||||
video_duration: int = Field(
|
||||
default=30,
|
||||
title="Video duration",
|
||||
description="Target length in seconds for the output video. The full time range is compressed into this duration.",
|
||||
title="Minimum video duration",
|
||||
description="Minimum length in seconds for the output video. Actual length depends on event count and durations.",
|
||||
ge=5,
|
||||
le=300,
|
||||
)
|
||||
@ -44,3 +73,17 @@ class RecapConfig(FrigateBaseModel):
|
||||
ge=5,
|
||||
le=100,
|
||||
)
|
||||
|
||||
@field_validator("schedule_time")
|
||||
@classmethod
|
||||
def validate_schedule_time(cls, v: str) -> str:
|
||||
parts = v.split(":")
|
||||
if len(parts) != 2:
|
||||
raise ValueError("schedule_time must be HH:MM format")
|
||||
try:
|
||||
h, m = int(parts[0]), int(parts[1])
|
||||
except ValueError:
|
||||
raise ValueError("schedule_time must be HH:MM format")
|
||||
if not (0 <= h <= 23 and 0 <= m <= 59):
|
||||
raise ValueError("schedule_time hours must be 0-23 and minutes 0-59")
|
||||
return v
|
||||
|
||||
@ -317,8 +317,8 @@ class RecapGenerator(threading.Thread):
|
||||
|
||||
recap_cfg = config.recap
|
||||
self.output_fps = recap_cfg.output_fps
|
||||
self.speed = 2
|
||||
self.max_per_group = 3
|
||||
self.speed = recap_cfg.speed
|
||||
self.max_per_group = recap_cfg.max_per_group
|
||||
self.video_duration = recap_cfg.video_duration
|
||||
self.background_samples = recap_cfg.background_samples
|
||||
|
||||
|
||||
94
frigate/recap/scheduler.py
Normal file
94
frigate/recap/scheduler.py
Normal file
@ -0,0 +1,94 @@
|
||||
"""Scheduled daily recap generation.
|
||||
|
||||
Runs as a background thread, checks once per minute if it's time
|
||||
to generate recaps for the previous day.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import random
|
||||
import string
|
||||
import threading
|
||||
import time
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from frigate.config import FrigateConfig
|
||||
from frigate.recap.recap import RecapGenerator
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class RecapScheduler(threading.Thread):
|
||||
"""Triggers daily recap generation at the configured time."""
|
||||
|
||||
def __init__(self, config: FrigateConfig):
|
||||
super().__init__(daemon=True, name="recap_scheduler")
|
||||
self.config = config
|
||||
self._last_run_date = None
|
||||
|
||||
def run(self):
|
||||
recap_cfg = self.config.recap
|
||||
if not recap_cfg.enabled or not recap_cfg.auto_generate:
|
||||
logger.info("recap scheduler not enabled, exiting")
|
||||
return
|
||||
|
||||
hour, minute = (int(x) for x in recap_cfg.schedule_time.split(":"))
|
||||
logger.info(
|
||||
"recap scheduler started, will run daily at %02d:%02d", hour, minute
|
||||
)
|
||||
|
||||
while True:
|
||||
now = datetime.now()
|
||||
today = now.date()
|
||||
|
||||
# check if it's time and we haven't already run today
|
||||
if (
|
||||
now.hour == hour
|
||||
and now.minute == minute
|
||||
and self._last_run_date != today
|
||||
):
|
||||
self._last_run_date = today
|
||||
self._generate_all()
|
||||
|
||||
# sleep until next minute
|
||||
time.sleep(60)
|
||||
|
||||
def _generate_all(self):
|
||||
recap_cfg = self.config.recap
|
||||
yesterday = datetime.now() - timedelta(days=1)
|
||||
start = yesterday.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
end = start + timedelta(days=1)
|
||||
|
||||
# figure out which cameras to process
|
||||
camera_names = (
|
||||
list(recap_cfg.cameras)
|
||||
if recap_cfg.cameras
|
||||
else list(self.config.cameras.keys())
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"auto-generating recaps for %d cameras (%s)",
|
||||
len(camera_names),
|
||||
start.strftime("%Y-%m-%d"),
|
||||
)
|
||||
|
||||
for camera in camera_names:
|
||||
if camera not in self.config.cameras:
|
||||
logger.warning("recap: camera %s not found, skipping", camera)
|
||||
continue
|
||||
|
||||
export_id = (
|
||||
f"{camera}_recap_"
|
||||
f"{''.join(random.choices(string.ascii_lowercase + string.digits, k=6))}"
|
||||
)
|
||||
|
||||
generator = RecapGenerator(
|
||||
config=self.config,
|
||||
export_id=export_id,
|
||||
camera=camera,
|
||||
start_time=start.timestamp(),
|
||||
end_time=end.timestamp(),
|
||||
label=recap_cfg.default_label,
|
||||
)
|
||||
generator.start()
|
||||
|
||||
logger.info("recap started for %s (export_id=%s)", camera, export_id)
|
||||
@ -199,10 +199,32 @@ class TestRecapConfig(unittest.TestCase):
|
||||
|
||||
cfg = RecapConfig()
|
||||
self.assertFalse(cfg.enabled)
|
||||
self.assertFalse(cfg.auto_generate)
|
||||
self.assertEqual(cfg.schedule_time, "02:00")
|
||||
self.assertEqual(cfg.cameras, [])
|
||||
self.assertEqual(cfg.default_label, "person")
|
||||
self.assertEqual(cfg.speed, 2)
|
||||
self.assertEqual(cfg.max_per_group, 3)
|
||||
self.assertEqual(cfg.video_duration, 30)
|
||||
|
||||
def test_validation(self):
|
||||
def test_custom_values(self):
|
||||
from frigate.config.recap import RecapConfig
|
||||
|
||||
cfg = RecapConfig(
|
||||
enabled=True,
|
||||
auto_generate=True,
|
||||
schedule_time="03:30",
|
||||
cameras=["front", "back"],
|
||||
speed=4,
|
||||
max_per_group=5,
|
||||
)
|
||||
self.assertTrue(cfg.auto_generate)
|
||||
self.assertEqual(cfg.schedule_time, "03:30")
|
||||
self.assertEqual(cfg.cameras, ["front", "back"])
|
||||
self.assertEqual(cfg.speed, 4)
|
||||
self.assertEqual(cfg.max_per_group, 5)
|
||||
|
||||
def test_validation_ranges(self):
|
||||
from pydantic import ValidationError
|
||||
|
||||
from frigate.config.recap import RecapConfig
|
||||
@ -215,6 +237,29 @@ class TestRecapConfig(unittest.TestCase):
|
||||
RecapConfig(video_duration=2)
|
||||
with self.assertRaises(ValidationError):
|
||||
RecapConfig(background_samples=2)
|
||||
with self.assertRaises(ValidationError):
|
||||
RecapConfig(speed=0)
|
||||
with self.assertRaises(ValidationError):
|
||||
RecapConfig(speed=10)
|
||||
with self.assertRaises(ValidationError):
|
||||
RecapConfig(max_per_group=0)
|
||||
|
||||
def test_schedule_time_validation(self):
|
||||
from pydantic import ValidationError
|
||||
|
||||
from frigate.config.recap import RecapConfig
|
||||
|
||||
with self.assertRaises(ValidationError):
|
||||
RecapConfig(schedule_time="25:00")
|
||||
with self.assertRaises(ValidationError):
|
||||
RecapConfig(schedule_time="abc")
|
||||
with self.assertRaises(ValidationError):
|
||||
RecapConfig(schedule_time="12:60")
|
||||
# valid edge cases
|
||||
cfg = RecapConfig(schedule_time="00:00")
|
||||
self.assertEqual(cfg.schedule_time, "00:00")
|
||||
cfg = RecapConfig(schedule_time="23:59")
|
||||
self.assertEqual(cfg.schedule_time, "23:59")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
Loading…
Reference in New Issue
Block a user