Add sub stream recording with adaptive quality playback (#24009)

* add sub stream recording with adaptive quality playback

Optionally record a second, lower bitrate stream alongside the main
recording stream via a `record_sub` input role and `record.sub` config block, with its own retention windows.
Recordings rows now carry the stream type plus the media details needed to serve both streams from one manifest: video codec, audio presence, audio codec and rate, and a record-time keyframe index.

Playback resolves coverage across both streams and merges them into a single VOD sequence, falling back to a discontinuity manifest with per-clip init segments when the media signatures differ. The player exposes a quality selector, and an auto governor picks the stream from stall time, bandwidth, codec support, and the save-data hint.

* fix tests and i18n
This commit is contained in:
Josh Hawkins
2026-09-12 07:30:04 -06:00
committed by Nicolas Mowen
parent f7c5500ea8
commit 1498231eb9
68 changed files with 6794 additions and 602 deletions
@@ -28,7 +28,6 @@ export class DynamicVideoController {
private timeRange: TimeRange = { after: 0, before: 0 };
private inpointOffset: number = 0;
private annotationOffset: number;
private timeToStart: number | undefined = undefined;
constructor(
camera: string,
@@ -55,11 +54,6 @@ export class DynamicVideoController {
this.timeRange.after,
this.recordings[0],
);
if (this.timeToStart) {
this.seekToTimestamp(this.timeToStart);
this.timeToStart = undefined;
}
}
play() {
@@ -75,8 +69,11 @@ export class DynamicVideoController {
}
seekToTimestamp(time: number, play: boolean = false) {
// a seek outside the current playback window is a no-op: the view
// moves its anchor and chunk on such seeks, and the rebuilt source
// resumes at the anchor (startPosition plus the post-load seek).
// Seeking here would only reposition the outgoing source's media
if (time < this.timeRange.after || time > this.timeRange.before) {
this.timeToStart = time;
return;
}
@@ -95,16 +92,24 @@ export class DynamicVideoController {
return;
}
if (seekSeconds != 0) {
this.playerController.currentTime = seekSeconds;
if (this.playerController.currentTime === seekSeconds) {
// seeking to the current position fires no seeked event, so apply
// the play intent directly (this includes position 0, which the
// player sits at before its first seek)
if (play) {
this.waitAndPlay();
playWithTemporaryMuteFallback(this.playerController);
} else {
this.playerController.pause();
}
return;
}
this.playerController.currentTime = seekSeconds;
if (play) {
this.waitAndPlay();
} else {
// no op
this.playerController.pause();
}
}
@@ -139,20 +144,26 @@ export class DynamicVideoController {
getProgress(playerTime: number): number {
// take a player time in seconds and convert to timestamp in timeline
let timestamp = 0;
const recordings = this.recordings || [];
let totalTime = 0;
(this.recordings || []).every((segment) => {
for (const segment of recordings) {
if (totalTime + segment.duration > playerTime) {
// segment is here
timestamp = segment.start_time + (playerTime - totalTime);
return false;
} else {
totalTime += segment.duration;
return true;
// playlist media from before the span's wall start (keyframe
// back-snap lead-in) clamps to the span start
const wallLength = segment.end_time - segment.start_time;
const leadIn = Math.max(0, segment.duration - wallLength);
return (
segment.start_time + Math.max(0, playerTime - totalTime - leadIn)
);
}
});
totalTime += segment.duration;
}
return timestamp;
// past the modeled total: clamp to the covered end rather than
// reporting wall-clock zero
return recordings.length > 0
? recordings[recordings.length - 1].end_time
: 0;
}
scrubToTimestamp(time: number, saveIfNotReady: boolean = false) {
@@ -162,7 +173,10 @@ export class DynamicVideoController {
this.previewController.setNewPreviewStartTime(time);
}
if (scrubResult && this.playerMode != "scrubbing") {
// pause even when no preview can render this range: a hidden player
// left running reports stale times once the drag releases, bouncing
// the handlebar back and sometimes swallowing the release seek
if (this.playerMode != "scrubbing") {
this.playerMode = "scrubbing";
this.playerController.pause();
}