homebridge-nest-accfactory
Version:
Homebridge support for Nest/Google devices including HomeKit Secure Video (HKSV) support for doorbells and cameras
416 lines (338 loc) • 12.8 kB
JavaScript
// MediaTimeline
// Part of homebridge-nest-accfactory
//
// Shared ordered media timeline used by Streamer.
//
// Maintains a single RingBuffer containing all retained media items
// (video, audio, talkback, metadata) in timeline order while exposing
// lightweight media-specific indexes for fast lookup.
//
// Design goals:
// - Preserve a single ordered media timeline
// - Avoid duplicated video/audio buffers
// - Provide O(log n) media-specific lookup via indexed search
// - Maintain stable logical indexes during trimming
// - Keep retention and timeline ordering consistent across all media
//
// Architecture:
// - One shared RingBuffer stores all retained media items
// - Media-specific indexes provide efficient lookup:
// - video index
// - audio index
// - keyframe index
// - Output sessions maintain independent cursors into the shared timeline
// - Trimming remains protected by the earliest active output cursor
//
// This preserves Streamer's existing timing and playout model:
// - one shared media timeline
// - one global monotonically increasing item index
// - independent video/audio output cursors
// - protected-cursor based retention trimming
//
// Benefits:
// - No duplicated media storage
// - Faster video/audio lookup without buffer walking
// - Efficient keyframe discovery for decoder-safe startup
// - Retains timeline-based A/V ordering
// - Minimal memory overhead from lightweight indexes
//
// Code version 2026.05.18
// Mark Hulskamp
'use strict';
// Import our modules
import RingBuffer from './ringbuffer.js';
// Define constants
const MEDIA_TIMELINE_DEFAULT_CAPACITY = 1024;
const MEDIA_TIMELINE_MAX_CAPACITY = 8192;
const MEDIA_TIMELINE_INDEX_COMPACT_THRESHOLD = 256;
const MEDIA_TIMELINE_TYPE_VIDEO = 'video';
const MEDIA_TIMELINE_TYPE_AUDIO = 'audio';
// MediaTimeline object
export default class MediaTimeline {
#buffer = undefined; // Shared ordered media buffer
#itemIndex = 0; // Next logical media item index
#videoIndexes = []; // Logical indexes for video items
#audioIndexes = []; // Logical indexes for audio items
#keyframeIndexes = []; // Logical indexes for video keyframes
#videoIndexOffset = 0; // First valid entry in #videoIndexes
#audioIndexOffset = 0; // First valid entry in #audioIndexes
#keyframeIndexOffset = 0; // First valid entry in #keyframeIndexes
#trimmedItems = 0; // Total items removed by retention trimming
#droppedItems = 0; // Total items rejected because retained capacity was full
constructor(startIndex = 0, capacity = MEDIA_TIMELINE_DEFAULT_CAPACITY, maxCapacity = MEDIA_TIMELINE_MAX_CAPACITY) {
// Initialise the logical item index.
this.#itemIndex = Number.isInteger(startIndex) === true && startIndex >= 0 ? startIndex : 0;
// Create one shared RingBuffer so video/audio retain a single ordered timeline.
// Capacity and maximum capacity are caller-tunable so Streamer can preserve
// its historical 8192 item ceiling while RingBuffer remains generic.
this.#buffer = new RingBuffer(this.#itemIndex, capacity, maxCapacity);
}
get startIndex() {
// First retained logical media index.
return this.#buffer.startIndex;
}
get size() {
// Number of retained media items.
return this.#buffer.size;
}
get nextIndex() {
// Next logical media index that will be assigned.
return this.#itemIndex;
}
get endIndex() {
// Logical index immediately after the retained window.
return this.#buffer.startIndex + this.#buffer.size;
}
get empty() {
// Convenience helper for callers.
return this.#buffer.size === 0;
}
get stats() {
// Lightweight timeline diagnostics.
// These are safe to call from support/debug logging without scanning buffer data.
return {
startIndex: this.#buffer.startIndex,
nextIndex: this.#itemIndex,
size: this.#buffer.size,
capacity: this.#buffer.capacity,
maxCapacity: this.#buffer.maxCapacity,
videoIndexes: this.#videoIndexes.length - this.#videoIndexOffset,
audioIndexes: this.#audioIndexes.length - this.#audioIndexOffset,
keyframeIndexes: this.#keyframeIndexes.length - this.#keyframeIndexOffset,
trimmedItems: this.#trimmedItems,
droppedItems: this.#droppedItems,
};
}
clear(resetStartIndex = 0) {
// Reset logical indexing to a known position.
this.#itemIndex = Number.isInteger(resetStartIndex) === true && resetStartIndex >= 0 ? resetStartIndex : 0;
// Clear the underlying shared buffer.
this.#buffer.clear(this.#itemIndex);
// Clear indexes and reset their logical heads.
this.#videoIndexes = [];
this.#audioIndexes = [];
this.#keyframeIndexes = [];
this.#videoIndexOffset = 0;
this.#audioIndexOffset = 0;
this.#keyframeIndexOffset = 0;
this.#trimmedItems = 0;
this.#droppedItems = 0;
}
add(item) {
// Validate media item before assigning an index.
if (typeof item !== 'object' || item === null) {
return undefined;
}
// Assign the global logical timeline index.
item.index = this.#itemIndex;
// Store in the shared ordered buffer.
if (this.#buffer.push(item) !== true) {
this.#droppedItems++;
return undefined;
}
// Maintain video and keyframe indexes.
if (item.type === MEDIA_TIMELINE_TYPE_VIDEO) {
this.#videoIndexes.push(item.index);
if (item.keyFrame === true) {
this.#keyframeIndexes.push(item.index);
}
}
// Maintain audio index.
if (item.type === MEDIA_TIMELINE_TYPE_AUDIO) {
this.#audioIndexes.push(item.index);
}
// Advance only after the item was accepted.
this.#itemIndex++;
return item.index;
}
get(index) {
// Validate logical index.
if (Number.isInteger(index) !== true) {
return undefined;
}
// Reject anything outside the retained media window.
if (index < this.#buffer.startIndex || index >= this.#buffer.startIndex + this.#buffer.size) {
return undefined;
}
// Convert logical index to RingBuffer offset.
return this.#buffer.getByOffset(index - this.#buffer.startIndex);
}
first() {
// Return oldest retained item.
return this.#buffer.getByOffset(0);
}
last() {
// Return newest retained item.
if (this.#buffer.size === 0) {
return undefined;
}
return this.#buffer.getByOffset(this.#buffer.size - 1);
}
latestTime() {
let offset = this.#buffer.size - 1;
let item = undefined;
// Return the newest retained media timestamp, skipping untimed metadata.
while (offset >= 0) {
item = this.#buffer.getByOffset(offset);
if (typeof item?.time === 'number' && Number.isFinite(item.time) === true) {
return item.time;
}
offset--;
}
return undefined;
}
nextVideoFrom(index) {
// Resolve next video item without scanning mixed media.
return this.get(this.#nextIndexFrom(this.#videoIndexes, this.#videoIndexOffset, index));
}
nextAudioFrom(index) {
// Resolve next audio item without scanning mixed media.
return this.get(this.#nextIndexFrom(this.#audioIndexes, this.#audioIndexOffset, index));
}
nextKeyFrameFrom(index) {
// Resolve next keyframe for decoder-safe startup.
return this.get(this.#nextIndexFrom(this.#keyframeIndexes, this.#keyframeIndexOffset, index));
}
trim(cutoffTime, protectedIndex = undefined) {
let trimCount = 0;
let item = undefined;
let protectedOffset = -1;
// Nothing to trim if no retained media exists.
if (this.#buffer.size === 0 || Number.isFinite(cutoffTime) !== true) {
return 0;
}
// Convert protected logical index into retained buffer offset.
if (Number.isInteger(protectedIndex) === true && protectedIndex >= this.#buffer.startIndex) {
protectedOffset = protectedIndex - this.#buffer.startIndex;
}
// Count expired, unprotected items from the front.
while (trimCount < this.#buffer.size) {
if (protectedOffset !== -1 && trimCount >= protectedOffset) {
break;
}
item = this.#buffer.getByOffset(trimCount);
// Untimed items cannot be retained by age, so allow them to be trimmed.
if (typeof item?.time !== 'number') {
trimCount++;
continue;
}
if (item.time >= cutoffTime) {
break;
}
trimCount++;
}
if (trimCount === 0) {
return 0;
}
// Trim the shared buffer.
this.#buffer.shift(trimCount, this.#itemIndex);
this.#trimmedItems += trimCount;
// Move index heads forward without Array.shift().
this.#trimIndexes();
return trimCount;
}
closestToTime(time) {
let index = 0;
let item = undefined;
let closestItem = undefined;
let closestDelta = Number.POSITIVE_INFINITY;
let itemDelta = 0;
// Recording/session start selection requires a valid timestamp.
if (Number.isFinite(time) !== true || this.#buffer.size === 0) {
return undefined;
}
// The shared timeline is ordered by arrival/index, not guaranteed by media time.
// Audio and video timestamps are normalised per media type, so the mixed
// timeline may not be globally monotonic. Use a full scan here for correctness.
//
// This only happens when creating an output, not on every scheduler tick.
while (index < this.#buffer.size) {
item = this.#buffer.getByOffset(index);
if (typeof item?.time === 'number') {
itemDelta = Math.abs(item.time - time);
if (itemDelta < closestDelta) {
closestDelta = itemDelta;
closestItem = item;
}
}
index++;
}
return closestItem;
}
protectedStart(outputs) {
let protectedIndex = this.#itemIndex;
// No outputs means no active protected cursor.
if (outputs instanceof Map !== true || outputs.size === 0) {
return protectedIndex;
}
// Find the oldest valid cursor still required by any output.
for (let output of outputs.values()) {
if (Number.isInteger(output?.cursor) === true && output.cursor >= this.#buffer.startIndex && output.cursor < protectedIndex) {
protectedIndex = output.cursor;
}
}
return protectedIndex;
}
#nextIndexFrom(indexes, offset, index) {
let left = offset;
let right = indexes.length - 1;
let middle = 0;
let found = undefined;
// Default invalid cursor requests to retained start.
if (Number.isInteger(index) !== true) {
index = this.#buffer.startIndex;
}
// Clamp before binary search.
if (index < this.#buffer.startIndex) {
index = this.#buffer.startIndex;
}
// Binary search for first indexed media item >= requested index.
while (left <= right) {
middle = Math.floor((left + right) / 2);
if (indexes[middle] >= index) {
found = indexes[middle];
right = middle - 1;
} else {
left = middle + 1;
}
}
return found;
}
#trimIndexes() {
// Advance video index head past trimmed media.
while (this.#videoIndexOffset < this.#videoIndexes.length && this.#videoIndexes[this.#videoIndexOffset] < this.#buffer.startIndex) {
this.#videoIndexOffset++;
}
// Advance audio index head past trimmed media.
while (this.#audioIndexOffset < this.#audioIndexes.length && this.#audioIndexes[this.#audioIndexOffset] < this.#buffer.startIndex) {
this.#audioIndexOffset++;
}
// Advance keyframe index head past trimmed media.
while (
this.#keyframeIndexOffset < this.#keyframeIndexes.length &&
this.#keyframeIndexes[this.#keyframeIndexOffset] < this.#buffer.startIndex
) {
this.#keyframeIndexOffset++;
}
// Compact occasionally so stale index entries do not accumulate forever.
this.#compactIndexes();
}
#compactIndexes() {
// Compact video index array only after enough stale entries accumulate.
// This avoids doing O(n) array work on every small trim.
if (this.#videoIndexOffset >= MEDIA_TIMELINE_INDEX_COMPACT_THRESHOLD) {
this.#videoIndexes = this.#videoIndexes.slice(this.#videoIndexOffset);
this.#videoIndexOffset = 0;
}
// Compact audio index array only after enough stale entries accumulate.
if (this.#audioIndexOffset >= MEDIA_TIMELINE_INDEX_COMPACT_THRESHOLD) {
this.#audioIndexes = this.#audioIndexes.slice(this.#audioIndexOffset);
this.#audioIndexOffset = 0;
}
// Compact keyframe index array only after enough stale entries accumulate.
if (this.#keyframeIndexOffset >= MEDIA_TIMELINE_INDEX_COMPACT_THRESHOLD) {
this.#keyframeIndexes = this.#keyframeIndexes.slice(this.#keyframeIndexOffset);
this.#keyframeIndexOffset = 0;
}
}
}