mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-05-01 19:17:41 +03:00
This change adds first-class adaptive recording playback using main and sub recording variants. Frigate can now store multiple recording variants per camera, expose those variants through the recordings API, and serve variant-specific VOD playlists through routes such as /vod/variant/sub/....
The UI now uses the available recording variants and browser playback capability to choose an appropriate playback source, with a user-selectable Auto, Main, and Sub preference. This is applied across timeline playback, export preview, and object detail playback.
The backend also includes a fallback path for sub playback: when a native sub recording is not available for a requested time range, Frigate can generate a lower-resolution sub recording from the main segment, store it under the standard sub variant, and mark it with transcoded_from_main.
Additional changes include recording metadata for codec, resolution, bitrate, and variant; database migrations for recording variants and generated-sub tracking; tests for variant VOD selection and fallback behavior; improved storage graph sorting; and a small MQTT TLS guard so tls_insecure is only applied when TLS is configured.
Substream Configuration Examples
Record the main stream as the normal full-resolution recording and also record the camera substream as the sub variant:
cameras:
front_door:
ffmpeg:
inputs:
- path: rtsp://user:password@192.168.1.10:554/main
roles:
- record
record_variant: main
- path: rtsp://user:password@192.168.1.10:554/sub
roles:
- detect
- record
record_variant: sub
detect:
width: 640
height: 360
fps: 5
record:
enabled: true
Using go2rtc restreams:
go2rtc:
streams:
front_door:
- rtsp://user:password@192.168.1.10:554/main
front_door_sub:
- rtsp://user:password@192.168.1.10:554/sub
cameras:
front_door:
ffmpeg:
inputs:
- path: rtsp://127.0.0.1:8554/front_door
input_args: preset-rtsp-restream
roles:
- record
record_variant: main
- path: rtsp://127.0.0.1:8554/front_door_sub
input_args: preset-rtsp-restream
roles:
- detect
- record
record_variant: sub
detect:
width: 640
height: 360
fps: 5
record:
enabled: true
If record_variant is omitted on a record input, it defaults to main. Each camera can only use a given recording variant once, so the main and sub recording inputs should use distinct variant names.
199 lines
5.3 KiB
TypeScript
199 lines
5.3 KiB
TypeScript
import { Recording } from "@/types/record";
|
|
import { DynamicPlayback } from "@/types/playback";
|
|
import { PreviewController } from "../PreviewPlayer";
|
|
import { TimeRange, TrackingDetailsSequence } from "@/types/timeline";
|
|
import {
|
|
calculateInpointOffset,
|
|
calculateSeekPosition,
|
|
} from "@/utils/videoUtil";
|
|
|
|
type PlayerMode = "playback" | "scrubbing";
|
|
const RECORDING_SEEK_CLAMP_GAP_SECONDS = 45;
|
|
|
|
export class DynamicVideoController {
|
|
// main state
|
|
public camera = "";
|
|
private playerController: HTMLVideoElement;
|
|
private previewController: PreviewController;
|
|
private setNoRecording: (noRecs: boolean) => void;
|
|
private setFocusedItem: (timeline: TrackingDetailsSequence) => void;
|
|
private playerMode: PlayerMode = "playback";
|
|
|
|
// playback
|
|
private recordings: Recording[] = [];
|
|
private timeRange: TimeRange = { after: 0, before: 0 };
|
|
private inpointOffset: number = 0;
|
|
private annotationOffset: number;
|
|
private timeToStart: number | undefined = undefined;
|
|
|
|
constructor(
|
|
camera: string,
|
|
playerController: HTMLVideoElement,
|
|
previewController: PreviewController,
|
|
annotationOffset: number,
|
|
defaultMode: PlayerMode,
|
|
setNoRecording: (noRecs: boolean) => void,
|
|
setFocusedItem: (timeline: TrackingDetailsSequence) => void,
|
|
) {
|
|
this.camera = camera;
|
|
this.playerController = playerController;
|
|
this.previewController = previewController;
|
|
this.annotationOffset = annotationOffset;
|
|
this.playerMode = defaultMode;
|
|
this.setNoRecording = setNoRecording;
|
|
this.setFocusedItem = setFocusedItem;
|
|
}
|
|
|
|
newPlayback(newPlayback: DynamicPlayback) {
|
|
this.recordings = newPlayback.recordings;
|
|
this.timeRange = newPlayback.timeRange;
|
|
this.inpointOffset = calculateInpointOffset(
|
|
this.timeRange.after,
|
|
this.recordings[0],
|
|
);
|
|
|
|
if (this.timeToStart) {
|
|
this.seekToTimestamp(this.timeToStart);
|
|
this.timeToStart = undefined;
|
|
}
|
|
}
|
|
|
|
play() {
|
|
this.playerController.play();
|
|
}
|
|
|
|
pause() {
|
|
this.playerController.pause();
|
|
}
|
|
|
|
isPlaying(): boolean {
|
|
return !this.playerController.paused && !this.playerController.ended;
|
|
}
|
|
|
|
seekToTimestamp(time: number, play: boolean = false) {
|
|
if (time < this.timeRange.after || time > this.timeRange.before) {
|
|
this.timeToStart = time;
|
|
return;
|
|
}
|
|
|
|
if (this.playerMode != "playback") {
|
|
this.playerMode = "playback";
|
|
}
|
|
|
|
const playableTime = this.getPlayableTimestamp(time);
|
|
if (playableTime === undefined) {
|
|
this.setNoRecording(true);
|
|
return;
|
|
}
|
|
|
|
const seekSeconds = calculateSeekPosition(
|
|
playableTime,
|
|
this.recordings,
|
|
this.inpointOffset,
|
|
);
|
|
|
|
if (seekSeconds === undefined) {
|
|
this.setNoRecording(true);
|
|
return;
|
|
}
|
|
|
|
if (seekSeconds != 0) {
|
|
this.playerController.currentTime = seekSeconds;
|
|
|
|
if (play) {
|
|
this.waitAndPlay();
|
|
} else {
|
|
this.playerController.pause();
|
|
}
|
|
} else {
|
|
// no op
|
|
}
|
|
}
|
|
|
|
private getPlayableTimestamp(time: number): number | undefined {
|
|
if (!this.recordings.length) {
|
|
return undefined;
|
|
}
|
|
|
|
const directSeek = calculateSeekPosition(time, this.recordings, this.inpointOffset);
|
|
if (directSeek !== undefined) {
|
|
return time;
|
|
}
|
|
|
|
// Some review items start a few seconds before the first saved segment.
|
|
// Clamp short gaps to the next recording so playback still opens.
|
|
const nextRecording = this.recordings.find((segment) => segment.start_time > time);
|
|
if (
|
|
nextRecording &&
|
|
nextRecording.start_time - time <= RECORDING_SEEK_CLAMP_GAP_SECONDS
|
|
) {
|
|
return nextRecording.start_time;
|
|
}
|
|
|
|
return undefined;
|
|
}
|
|
|
|
waitAndPlay() {
|
|
return new Promise((resolve) => {
|
|
const onSeekedHandler = () => {
|
|
this.playerController.removeEventListener("seeked", onSeekedHandler);
|
|
this.playerController.play();
|
|
resolve(undefined);
|
|
};
|
|
|
|
this.playerController.addEventListener("seeked", onSeekedHandler, {
|
|
once: true,
|
|
});
|
|
});
|
|
}
|
|
|
|
seekToTimelineItem(timeline: TrackingDetailsSequence) {
|
|
this.playerController.pause();
|
|
this.seekToTimestamp(timeline.timestamp + this.annotationOffset);
|
|
this.setFocusedItem(timeline);
|
|
}
|
|
|
|
getProgress(playerTime: number): number {
|
|
// take a player time in seconds and convert to timestamp in timeline
|
|
let timestamp = 0;
|
|
let totalTime = 0;
|
|
(this.recordings || []).every((segment) => {
|
|
if (totalTime + segment.duration > playerTime) {
|
|
// segment is here
|
|
timestamp = segment.start_time + (playerTime - totalTime);
|
|
return false;
|
|
} else {
|
|
totalTime += segment.duration;
|
|
return true;
|
|
}
|
|
});
|
|
|
|
return timestamp;
|
|
}
|
|
|
|
scrubToTimestamp(time: number, saveIfNotReady: boolean = false) {
|
|
const scrubResult = this.previewController.scrubToTimestamp(time);
|
|
|
|
if (!scrubResult && saveIfNotReady) {
|
|
this.previewController.setNewPreviewStartTime(time);
|
|
}
|
|
|
|
if (scrubResult && this.playerMode != "scrubbing") {
|
|
this.playerMode = "scrubbing";
|
|
this.playerController.pause();
|
|
}
|
|
}
|
|
|
|
hasRecordingAtTime(time: number): boolean {
|
|
if (!this.recordings || this.recordings.length == 0) {
|
|
return false;
|
|
}
|
|
|
|
return (
|
|
this.recordings.find(
|
|
(segment) => segment.start_time <= time && segment.end_time >= time,
|
|
) != undefined
|
|
);
|
|
}
|
|
}
|