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
|
from .base import FrigateBaseModel
|
||||||
|
|
||||||
@ -11,15 +11,44 @@ class RecapConfig(FrigateBaseModel):
|
|||||||
title="Enable recaps",
|
title="Enable recaps",
|
||||||
description="Allow generation of time-stacked recap videos that composite detected objects onto a clean background.",
|
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_label: str = Field(
|
||||||
default="person",
|
default="person",
|
||||||
title="Default object label",
|
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(
|
ghost_duration: float = Field(
|
||||||
default=3.0,
|
default=3.0,
|
||||||
title="Ghost visibility duration",
|
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,
|
ge=0.5,
|
||||||
le=30.0,
|
le=30.0,
|
||||||
)
|
)
|
||||||
@ -32,8 +61,8 @@ class RecapConfig(FrigateBaseModel):
|
|||||||
)
|
)
|
||||||
video_duration: int = Field(
|
video_duration: int = Field(
|
||||||
default=30,
|
default=30,
|
||||||
title="Video duration",
|
title="Minimum video duration",
|
||||||
description="Target length in seconds for the output video. The full time range is compressed into this duration.",
|
description="Minimum length in seconds for the output video. Actual length depends on event count and durations.",
|
||||||
ge=5,
|
ge=5,
|
||||||
le=300,
|
le=300,
|
||||||
)
|
)
|
||||||
@ -44,3 +73,17 @@ class RecapConfig(FrigateBaseModel):
|
|||||||
ge=5,
|
ge=5,
|
||||||
le=100,
|
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
|
recap_cfg = config.recap
|
||||||
self.output_fps = recap_cfg.output_fps
|
self.output_fps = recap_cfg.output_fps
|
||||||
self.speed = 2
|
self.speed = recap_cfg.speed
|
||||||
self.max_per_group = 3
|
self.max_per_group = recap_cfg.max_per_group
|
||||||
self.video_duration = recap_cfg.video_duration
|
self.video_duration = recap_cfg.video_duration
|
||||||
self.background_samples = recap_cfg.background_samples
|
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()
|
cfg = RecapConfig()
|
||||||
self.assertFalse(cfg.enabled)
|
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.default_label, "person")
|
||||||
|
self.assertEqual(cfg.speed, 2)
|
||||||
|
self.assertEqual(cfg.max_per_group, 3)
|
||||||
self.assertEqual(cfg.video_duration, 30)
|
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 pydantic import ValidationError
|
||||||
|
|
||||||
from frigate.config.recap import RecapConfig
|
from frigate.config.recap import RecapConfig
|
||||||
@ -215,6 +237,29 @@ class TestRecapConfig(unittest.TestCase):
|
|||||||
RecapConfig(video_duration=2)
|
RecapConfig(video_duration=2)
|
||||||
with self.assertRaises(ValidationError):
|
with self.assertRaises(ValidationError):
|
||||||
RecapConfig(background_samples=2)
|
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__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user