mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-06-21 03:41:55 +03:00
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
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:
parent
fa07109a85
commit
28e3e1ec74
@ -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."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|||||||
@ -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(
|
||||||
|
|||||||
@ -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)}"
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user