Support main+sub stream exports (#24193)

* Support multi resolution exports

* Fix decoder text

* Add dropdown and ability to select export stream selection

* Fix for review comments

* Fix mypy

* Cleanup wording
This commit is contained in:
Nicolas Mowen
2026-09-12 07:30:04 -06:00
parent 70ce193e09
commit 82be9fff5e
15 changed files with 1164 additions and 49 deletions
+39 -21
View File
@@ -73,6 +73,7 @@ from frigate.record.export import (
DEFAULT_TIME_LAPSE_FFMPEG_ARGS,
DEFAULT_TIME_LAPSE_FFMPEG_INPUT_ARGS,
ChaptersEnum,
ExportStreamEnum,
PlaybackSourceEnum,
export_video_path,
validate_ffmpeg_args,
@@ -149,16 +150,23 @@ def _sanitize_existing_image(
return existing_image, None
def _no_recordings_message(stream: ExportStreamEnum) -> str:
if stream == ExportStreamEnum.auto:
return "No recordings found for time range"
return f"No {stream.value} stream recordings found for time range"
def _validate_export_source(
camera_name: str,
start_time: float,
end_time: float,
playback_source: PlaybackSourceEnum,
stream: ExportStreamEnum = ExportStreamEnum.auto,
) -> str | None:
if playback_source == PlaybackSourceEnum.recordings:
recordings_count = (
Recordings.select()
.where(
query = Recordings.select().where(
(
Recordings.start_time.between(start_time, end_time)
| Recordings.end_time.between(start_time, end_time)
| (
@@ -166,12 +174,16 @@ def _validate_export_source(
& (end_time < Recordings.end_time)
)
)
.where(Recordings.camera == camera_name)
.count()
& (Recordings.camera == camera_name)
)
if recordings_count <= 0:
return "No recordings found for time range"
# a pinned export reads only that stream, so the other stream's
# coverage must not make the range look exportable
if stream != ExportStreamEnum.auto:
query = query.where(Recordings.stream_type == stream.value)
if query.count() <= 0:
return _no_recordings_message(stream)
return None
@@ -195,6 +207,7 @@ def _validate_export_source(
def _get_item_recording_export_errors(
request: Request,
items: list[BatchExportItem],
stream: ExportStreamEnum = ExportStreamEnum.auto,
) -> dict[int, str]:
"""Return {item_index: error message} for items with invalid state.
@@ -224,20 +237,20 @@ def _get_item_recording_export_errors(
min_start = min(r[1] for r in indexed_ranges)
max_end = max(r[2] for r in indexed_ranges)
recording_ranges = list(
Recordings.select(Recordings.start_time, Recordings.end_time)
.where(
Recordings.camera == camera_name,
Recordings.start_time.between(min_start, max_end)
| Recordings.end_time.between(min_start, max_end)
| (
(min_start > Recordings.start_time)
& (max_end < Recordings.end_time)
),
)
.iterator()
query = Recordings.select(Recordings.start_time, Recordings.end_time).where(
Recordings.camera == camera_name,
Recordings.start_time.between(min_start, max_end)
| Recordings.end_time.between(min_start, max_end)
| ((min_start > Recordings.start_time) & (max_end < Recordings.end_time)),
)
# a pinned batch reads only that stream, so the other stream's
# coverage must not make an item look exportable
if stream != ExportStreamEnum.auto:
query = query.where(Recordings.stream_type == stream.value)
recording_ranges = list(query.iterator())
for index, start_time, end_time in indexed_ranges:
has_recording = any(
(
@@ -248,7 +261,7 @@ def _get_item_recording_export_errors(
for rec in recording_ranges
)
if not has_recording:
errors[index] = "No recordings found for time range"
errors[index] = _no_recordings_message(stream)
return errors
@@ -265,6 +278,7 @@ def _build_export_job(
ffmpeg_output_args: str | None = None,
cpu_fallback: bool = False,
chapters: ChaptersEnum | None = None,
stream: ExportStreamEnum = ExportStreamEnum.auto,
) -> ExportJob:
return ExportJob(
id=_generate_export_id(camera_name),
@@ -279,6 +293,7 @@ def _build_export_job(
ffmpeg_output_args=ffmpeg_output_args,
cpu_fallback=cpu_fallback,
chapters=chapters,
stream=stream,
)
@@ -692,7 +707,7 @@ def export_recordings_batch(
return image_validation_error
sanitized_images.append(existing_image)
item_errors = _get_item_recording_export_errors(request, body.items)
item_errors = _get_item_recording_export_errors(request, body.items, body.stream)
queueable_indexes = [
index for index in range(len(body.items)) if index not in item_errors
@@ -761,6 +776,7 @@ def export_recordings_batch(
chapters=request.app.frigate_config.cameras[
item.camera
].record.export.chapters,
stream=body.stream,
)
try:
start_export_job(request.app.frigate_config, export_job)
@@ -868,6 +884,7 @@ def export_recording(
start_time,
end_time,
playback_source,
body.stream,
)
if source_error is not None:
return JSONResponse(
@@ -884,6 +901,7 @@ def export_recording(
playback_source,
export_case_id,
chapters=chapters,
stream=body.stream,
)
try:
start_export_job(request.app.frigate_config, export_job)