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:
ryzendigo 2026-03-21 16:40:49 +08:00
parent 717b878956
commit 17e5211991
4 changed files with 190 additions and 8 deletions

View File

@ -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

View File

@ -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

View 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)

View File

@ -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__":