frigate/web/src/components/player/dynamic/DynamicVideoController.ts
3ricj e7684eddbf Added substream support, dynamic substream creation, and playback methods for
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.
2026-04-29 19:05:59 -07:00

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
);
}
}