Add ability to control chapters set on MP4 Export (#23310)
Some checks failed
CI / AMD64 Build (push) Has been cancelled
CI / ARM Build (push) Has been cancelled
CI / Jetson Jetpack 6 (push) Has been cancelled
CI / AMD64 Extra Build (push) Has been cancelled
CI / ARM Extra Build (push) Has been cancelled
CI / Synaptics Build (push) Has been cancelled
CI / Assemble and push default build (push) Has been cancelled

This commit is contained in:
Nicolas Mowen 2026-05-25 12:06:16 -06:00 committed by GitHub
parent fa07109a85
commit 28e3e1ec74
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 128 additions and 1 deletions

View File

@ -4,6 +4,7 @@ from pydantic import BaseModel, Field
from pydantic.json_schema import SkipJsonSchema from pydantic.json_schema import SkipJsonSchema
from frigate.record.export import ( from frigate.record.export import (
ChaptersEnum,
PlaybackFactorEnum, PlaybackFactorEnum,
PlaybackSourceEnum, PlaybackSourceEnum,
) )
@ -18,3 +19,11 @@ class ExportRecordingsBody(BaseModel):
) )
name: Optional[str] = Field(title="Friendly name", default=None, max_length=256) name: Optional[str] = Field(title="Friendly name", default=None, max_length=256)
image_path: Union[str, SkipJsonSchema[None]] = None image_path: Union[str, SkipJsonSchema[None]] = None
chapters: Optional[ChaptersEnum] = Field(
default=None,
title="Chapter mode",
description=(
"Optional chapter metadata to embed in the export. When omitted, "
"no chapter track is added."
),
)

View File

@ -31,6 +31,7 @@ from frigate.api.defs.tags import Tags
from frigate.const import CLIPS_DIR, EXPORT_DIR from frigate.const import CLIPS_DIR, EXPORT_DIR
from frigate.models import Export, Previews, Recordings from frigate.models import Export, Previews, Recordings
from frigate.record.export import ( from frigate.record.export import (
ChaptersEnum,
PlaybackFactorEnum, PlaybackFactorEnum,
PlaybackSourceEnum, PlaybackSourceEnum,
RecordingExporter, RecordingExporter,
@ -161,6 +162,7 @@ def export_recording(
if playback_source in PlaybackSourceEnum.__members__.values() if playback_source in PlaybackSourceEnum.__members__.values()
else PlaybackSourceEnum.recordings else PlaybackSourceEnum.recordings
), ),
chapters=ChaptersEnum(body.chapters) if body.chapters else None,
) )
exporter.start() exporter.start()
return JSONResponse( return JSONResponse(

View File

@ -12,6 +12,7 @@ from enum import Enum
from pathlib import Path from pathlib import Path
from typing import Optional from typing import Optional
import pytz
from peewee import DoesNotExist from peewee import DoesNotExist
from frigate.config import FfmpegConfig, FrigateConfig from frigate.config import FfmpegConfig, FrigateConfig
@ -50,6 +51,14 @@ class PlaybackSourceEnum(str, Enum):
preview = "preview" preview = "preview"
class ChaptersEnum(str, Enum):
# One chapter per recording segment, titled with the segment's
# wallclock start time in strict ISO 8601 form. Lets viewers map
# output playback time back to wallclock without reading a timestamp
# overlay via OCR.
recording_segments = "recording_segments"
class RecordingExporter(threading.Thread): class RecordingExporter(threading.Thread):
"""Exports a specific set of recordings for a camera to storage as a single file.""" """Exports a specific set of recordings for a camera to storage as a single file."""
@ -64,6 +73,7 @@ class RecordingExporter(threading.Thread):
end_time: int, end_time: int,
playback_factor: PlaybackFactorEnum, playback_factor: PlaybackFactorEnum,
playback_source: PlaybackSourceEnum, playback_source: PlaybackSourceEnum,
chapters: Optional[ChaptersEnum] = None,
) -> None: ) -> None:
super().__init__() super().__init__()
self.config = config self.config = config
@ -75,6 +85,7 @@ class RecordingExporter(threading.Thread):
self.end_time = end_time self.end_time = end_time
self.playback_factor = playback_factor self.playback_factor = playback_factor
self.playback_source = playback_source self.playback_source = playback_source
self.chapters = chapters
# ensure export thumb dir # ensure export thumb dir
Path(os.path.join(CLIPS_DIR, "export")).mkdir(exist_ok=True) Path(os.path.join(CLIPS_DIR, "export")).mkdir(exist_ok=True)
@ -83,6 +94,77 @@ 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_recording_segment_chapter_metadata_file(
self, recordings: list
) -> Optional[str]:
"""Write an FFmpeg metadata file with one chapter per recording segment.
Each chapter's title is the segment's wallclock start time in
strict ISO 8601 form so a viewer can map any point in the
export's playback timeline back to real-world time without
OCR-ing a burnt-in timestamp. 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. Returns ``None`` when there are no
recordings or every segment is empty after clipping.
"""
if not recordings:
return None
tz_name = self.config.ui.timezone
tz: Optional[datetime.tzinfo] = None
if tz_name:
try:
tz = pytz.timezone(tz_name)
except pytz.UnknownTimeZoneError:
tz = None
if tz is None:
tz = datetime.timezone.utc
chapter_blocks: list[str] = []
output_offset_ms = 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
duration_ms = int(round((clipped_end - clipped_start) * 1000))
if duration_ms <= 0:
continue
title = datetime.datetime.fromtimestamp(clipped_start, tz=tz).isoformat(
timespec="seconds"
)
chapter_blocks.append(
"[CHAPTER]\n"
"TIMEBASE=1/1000\n"
f"START={output_offset_ms}\n"
f"END={output_offset_ms + duration_ms}\n"
f"title={title}"
)
output_offset_ms += duration_ms
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")
@ -218,9 +300,41 @@ class RecordingExporter(threading.Thread):
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"
# When chapters are requested, query the per-segment recording rows
# and write an FFmpeg metadata sidecar. Timelapse playback rescales
# time so chapter offsets would no longer match wallclock — restrict
# chapter injection to realtime playback.
chapter_args = ""
if (
self.chapters == ChaptersEnum.recording_segments
and self.playback_factor == PlaybackFactorEnum.realtime
):
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()
)
chapters_path = self._build_recording_segment_chapter_metadata_file(
recordings
)
if chapters_path:
chapter_args = f" -i {chapters_path} -map 0 -dn -map_metadata 1"
if self.playback_factor == PlaybackFactorEnum.realtime: if self.playback_factor == PlaybackFactorEnum.realtime:
ffmpeg_cmd = ( 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(" ") ).split(" ")
elif self.playback_factor == PlaybackFactorEnum.timelapse_25x: elif self.playback_factor == PlaybackFactorEnum.timelapse_25x:
ffmpeg_cmd = ( ffmpeg_cmd = (
@ -396,6 +510,8 @@ class RecordingExporter(threading.Thread):
capture_output=True, capture_output=True,
) )
Path(self._chapter_metadata_path()).unlink(missing_ok=True)
if p.returncode != 0: if p.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)}"