From 1762b5530f7d18d4c7eefb044e833d36dea19a78 Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Thu, 5 Jan 2023 20:07:28 -0800 Subject: [PATCH] Refactor media classifiers. --- src/camera/frigate/engine-frigate.ts | 2 +- src/components/thumbnail.ts | 23 +++---- src/components/timeline-core.ts | 68 +++++++++---------- src/components/viewer.ts | 2 +- src/utils/basic.ts | 4 +- src/utils/media-to-view.ts | 39 ++++++----- src/view/media-classifier.ts | 51 +++++--------- src/view/media.ts | 99 +++++++++------------------- src/view/view.ts | 1 - 9 files changed, 115 insertions(+), 174 deletions(-) diff --git a/src/camera/frigate/engine-frigate.ts b/src/camera/frigate/engine-frigate.ts index 1260344c..6f48409c 100644 --- a/src/camera/frigate/engine-frigate.ts +++ b/src/camera/frigate/engine-frigate.ts @@ -89,7 +89,7 @@ export class FrigateCameraManagerEngine implements CameraManagerEngine { path = `/api/frigate/${cameraConfig.frigate.client_id}` + `/notifications/${media.getID()}/` + - `${media.isClip() ? 'clip.mp4' : 'snapshot.jpg'}` + + `${ViewMediaClassifier.isClip(media) ? 'clip.mp4' : 'snapshot.jpg'}` + `?download=true`; } else if (ViewMediaClassifier.isFrigateRecording(media)) { path = diff --git a/src/components/thumbnail.ts b/src/components/thumbnail.ts index eed00459..4bb51cfe 100644 --- a/src/components/thumbnail.ts +++ b/src/components/thumbnail.ts @@ -18,8 +18,9 @@ import type { MediaSeek } from './viewer.js'; import { TaskStatus } from '@lit-labs/task'; import type { CameraConfig, ExtendedHomeAssistant } from '../types.js'; -import { ViewMedia } from '../view/media.js'; +import { EventViewMedia, RecordingViewMedia, ViewMedia } from '../view/media.js'; import { CameraManager } from '../camera/manager.js'; +import { ViewMediaClassifier } from '../view/media-classifier.js'; // The minimum width of a thumbnail with details enabled. export const THUMBNAIL_DETAILS_WIDTH_MIN = 300; @@ -127,13 +128,13 @@ export class FrigateCardThumbnailFeatureRecording extends LitElement { @customElement('frigate-card-thumbnail-details-event') export class FrigateCardThumbnailDetailsEvent extends LitElement { @property({ attribute: false }) - public media?: ViewMedia; + public media?: EventViewMedia; @property({ attribute: false }) public mediaSeek?: MediaSeek; protected render(): TemplateResult | void { - if (!this.media || !this.media.isEvent()) { + if (!this.media) { return; } const score = this.media.getScore(); @@ -179,7 +180,7 @@ export class FrigateCardThumbnailDetailsEvent extends LitElement { @customElement('frigate-card-thumbnail-details-recording') export class FrigateCardThumbnailDetailsRecording extends LitElement { @property({ attribute: false }) - public media?: ViewMedia; + public media?: RecordingViewMedia; @property({ attribute: false }) public mediaSeek?: MediaSeek; @@ -266,19 +267,19 @@ export class FrigateCardThumbnail extends LitElement { const shouldShowTimelineControl = this.show_timeline_control && this.view && - (!this.media.isRecording() || + (!ViewMediaClassifier.isRecording(this.media) || // Only show timeline control if the recording has a start & end time. (this.media.getStartTime() && this.media.getEndTime())); const clientID = this.cameraConfig?.frigate.client_id; - return html` ${this.media.isEvent() + return html` ${ViewMediaClassifier.isEvent(this.media) ? html`` - : this.media.isRecording() + : ViewMediaClassifier.isRecording(this.media) ? html`` : ``} - ${this.details && this.media.isEvent() + ${this.details && ViewMediaClassifier.isEvent(this.media) ? html`` - : this.details && this.media.isRecording() + : this.details && ViewMediaClassifier.isRecording(this.media) ? html` - windowSeconds * 1000 - ) { + if (startTime && endTime) { + if (endTime.getTime() - startTime.getTime() > windowSeconds * 1000) { // If the event is larger than the configured window, only show the most // recent portion of the event that fits in the window. return { - start: sub(media.getEndTime(), { seconds: windowSeconds }), - end: media.getEndTime(), + start: sub(endTime, { seconds: windowSeconds }), + end: endTime, }; } else { // If the event is shorter than the configured window, center the event // in the window. - const gap = - windowSeconds - - (media.getEndTime().getTime() - media.getStartTime().getTime()) / 1000; + const gap = windowSeconds - (endTime.getTime() - startTime.getTime()) / 1000; return { - start: sub(media.getStartTime(), { seconds: gap / 2 }), - end: add(media.getEndTime(), { seconds: gap / 2 }), + start: sub(startTime, { seconds: gap / 2 }), + end: add(endTime, { seconds: gap / 2 }), }; } - } else { + } else if (startTime) { // If there's no end-time yet, place the start-time in the center of the // time window. return { - start: sub(media.getStartTime(), { seconds: windowSeconds / 2 }), - end: add(media.getStartTime(), { seconds: windowSeconds / 2 }), + start: sub(startTime, { seconds: windowSeconds / 2 }), + end: add(startTime, { seconds: windowSeconds / 2 }), }; } - } else if ( - media.isRecording() && - ViewMediaClassifier.isMediaWithStartEndTime(media) - ) { + } else if (ViewMediaClassifier.isRecording(media) && startTime && endTime) { return { - start: media.getStartTime(), - end: media.getEndTime(), + start: startTime, + end: endTime, }; } return null; @@ -816,6 +807,8 @@ export class FrigateCardTimelineCore extends LitElement { second.id != selectedId && !!firstMedia && !!secondMedia && + ViewMediaClassifier.isEvent(firstMedia) && + ViewMediaClassifier.isEvent(secondMedia) && firstMedia.isGroupableWith(secondMedia) ); }, @@ -882,9 +875,11 @@ export class FrigateCardTimelineCore extends LitElement { let desiredWindow = timelineWindow; const media = this.view.queryResults?.getSelectedResult(); + const mediaStartTime = media?.getStartTime(); + const mediaEndTime = media?.getEndTime(); const mediaWindow: TimelineWindow | null = - media && ViewMediaClassifier.isMediaWithStartEndTime(media) - ? { start: media.getStartTime(), end: media.getEndTime() } + media && mediaStartTime && mediaEndTime + ? { start: mediaStartTime, end: mediaEndTime } : null; const context = this.view.context?.timeline; @@ -907,12 +902,8 @@ export class FrigateCardTimelineCore extends LitElement { await this._timelineSource?.refresh(this.hass, this.cameras, prefetchedWindow); } - if ( - !this._pointerHeld && - media && - ViewMediaClassifier.isMediaWithID(media) && - this._isClustering() - ) { + const mediaID = media?.getID(); + if (!this._pointerHeld && media && mediaID && this._isClustering()) { // Hack: Clustering may not update unless the dataset changes, artifically // update the dataset to ensure the newly selected item cannot be included // in a cluster. Only do this when the pointer is not held to avoid @@ -920,10 +911,11 @@ export class FrigateCardTimelineCore extends LitElement { // Need to this rewrite prior to setting the selection (just below), or // the selection will be lost on rewrite. - this._timelineSource?.rewriteEvent(media.getID()); + this._timelineSource?.rewriteEvent(mediaID); } - const desiredId = !!media && !!media.isEvent() ? media.getID() : null; + const desiredId = + !!media && ViewMediaClassifier.isEvent(media) ? media.getID() : null; if (desiredId) { this._timeline?.setSelection([desiredId], { focus: false, diff --git a/src/components/viewer.ts b/src/components/viewer.ts index 56224076..84ea3802 100644 --- a/src/components/viewer.ts +++ b/src/components/viewer.ts @@ -666,7 +666,7 @@ export class FrigateCardViewerCarousel extends LitElement { // `_selectSlideMediaShowHandler` (and not used by the player itself). return html`
- ${media.isVideo() + ${ViewMediaClassifier.isVideo(media) ? html` = Omit & R; - /** * Dispatch a Frigate Card event. - * @param element The element to send the event. + * @param target The target from which send the event. * @param name The name of the Frigate card event to send. * @param detail An optional detail object to attach. */ diff --git a/src/utils/media-to-view.ts b/src/utils/media-to-view.ts index 164e3150..1aac8a47 100644 --- a/src/utils/media-to-view.ts +++ b/src/utils/media-to-view.ts @@ -3,13 +3,18 @@ import fromUnixTime from 'date-fns/fromUnixTime'; import startOfHour from 'date-fns/startOfHour'; import sub from 'date-fns/sub'; import { ViewContext } from 'view'; -import { CameraConfig, ClipsOrSnapshotsOrAll, FrigateCardView, RecordingSegment } from '../types'; +import { + CameraConfig, + ClipsOrSnapshotsOrAll, + FrigateCardView, + RecordingSegment, +} from '../types'; import { View } from '../view/view'; -import { EventMediaQueries, RecordingMediaQueries } from "../view/media-queries"; +import { EventMediaQueries, RecordingMediaQueries } from '../view/media-queries'; import { CameraManager } from '../camera/manager'; import { getAllDependentCameras } from './camera.js'; import { ViewMedia } from '../view/media'; -import { ViewMediaClassifier } from "../view/media-classifier"; +import { ViewMediaClassifier } from '../view/media-classifier'; import { HomeAssistant } from 'custom-card-helpers'; export const changeViewToRecentEventsForCameraAndDependents = async ( @@ -178,12 +183,12 @@ export const generateMediaViewerContext = async ( const hourStart = startOfHour(targetTime); for (const [index, child] of media.entries()) { - if (!ViewMediaClassifier.isMediaWithStartEndTime(child)) { + const start = child.getStartTime(); + const end = child.getEndTime(); + if (!start || !end) { continue; } - const start = child.getStartTime(); - const end = child.getEndTime(); let seekSeconds: number | null = null; if (targetTime >= start && targetTime <= end) { @@ -194,12 +199,14 @@ export const generateMediaViewerContext = async ( end: end, }, )[0]; - const segments = (await cameraManager.getRecordingSegments(hass, query)).get(query); + const segments = (await cameraManager.getRecordingSegments(hass, query)).get( + query, + ); if (segments) { seekSeconds = getSeekTimeInSegments( // Recordings start from the top of the hour. - child.isRecording() ? hourStart : start, + ViewMediaClassifier.isRecording(child) ? hourStart : start, targetTime, segments.segments, ); @@ -241,24 +248,20 @@ export const findClosestMediaIndex = ( for (let i = 0; i < mediaArray.length; ++i) { const media = mediaArray[i]; - if ( - !cameraIDs.has(media.getCameraID()) || - !ViewMediaClassifier.isMediaWithStartEndTime(media) - ) { + const start = media.getStartTime(); + const end = media.getEndTime(); + if (!cameraIDs.has(media.getCameraID()) || !start || !end) { continue; } - const startTime = media.getStartTime(); - const endTime = media.getEndTime(); - - if (startTime <= targetTime && endTime >= targetTime) { + if (start <= targetTime && end >= targetTime) { if (!refPoint) { return i; } const delta = refPoint === 'end' - ? endTime.getTime() - targetTime.getTime() - : targetTime.getTime() - startTime.getTime(); + ? end.getTime() - targetTime.getTime() + : targetTime.getTime() - start.getTime(); if (!bestMatch || delta < bestMatch.delta) { bestMatch = { index: i, delta: delta }; } diff --git a/src/view/media-classifier.ts b/src/view/media-classifier.ts index c5324bea..6f91af21 100644 --- a/src/view/media-classifier.ts +++ b/src/view/media-classifier.ts @@ -1,5 +1,10 @@ -import { ModifyInterface } from '../utils/basic.js'; -import { ViewMedia, FrigateEventViewMedia, FrigateRecordingViewMedia } from './media'; +import { + ViewMedia, + FrigateEventViewMedia, + FrigateRecordingViewMedia, + RecordingViewMedia, + EventViewMedia, +} from './media'; export class ViewMediaClassifier { public static isFrigateMedia( @@ -15,39 +20,19 @@ export class ViewMediaClassifier { ): media is FrigateRecordingViewMedia { return media instanceof FrigateRecordingViewMedia; } - - // Typescript conveniences. - public static isMediaWithStartEndTime(media: ViewMedia): media is ModifyInterface< - ViewMedia, - { - getStartTime(): Date; - getEndTime(): Date; - } - > { - return !!media.getStartTime() && !!media.getEndTime(); + public static isEvent(media: ViewMedia): media is EventViewMedia { + return this.isClip(media) || this.isSnapshot(media); } - public static isMediaWithStartTime(media: ViewMedia): media is ModifyInterface< - ViewMedia, - { - getStartTime(): Date; - } - > { - return !!media.getStartTime(); + public static isRecording(media: ViewMedia): media is RecordingViewMedia { + return media.getMediaType() === 'recording'; } - public static isMediaWithEndTime(media: ViewMedia): media is ModifyInterface< - ViewMedia, - { - getEndTime(): Date; - } - > { - return !!media.getEndTime(); + public static isClip(media: ViewMedia): boolean { + return media.getMediaType() === 'clip'; } - public static isMediaWithID(media: ViewMedia): media is ModifyInterface< - ViewMedia, - { - getID(): string; - } - > { - return !!media.getID(); + public static isSnapshot(media: ViewMedia): boolean { + return media.getMediaType() === 'snapshot'; + } + public static isVideo(media: ViewMedia): boolean { + return this.isClip(media) || this.isRecording(media); } } diff --git a/src/view/media.ts b/src/view/media.ts index 13347ff2..15633795 100644 --- a/src/view/media.ts +++ b/src/view/media.ts @@ -1,6 +1,6 @@ import fromUnixTime from 'date-fns/fromUnixTime'; import isEqual from 'lodash-es/isEqual'; -import { BrowseMediaSource, CameraConfig, MEDIA_TYPE_IMAGE } from '../types.js'; +import { CameraConfig } from '../types.js'; import { getEventMediaContentID, getEventThumbnailURL, @@ -9,9 +9,10 @@ import { getRecordingTitle, } from '../camera/frigate/util.js'; import { FrigateEvent, FrigateRecording } from '../camera/frigate/types.js'; +import { ViewMediaClassifier } from './media-classifier.js'; export type ViewMediaType = 'clip' | 'snapshot' | 'recording'; -export type ViewMediaSourceType = FrigateEvent | FrigateRecording | BrowseMediaSource; +export type ViewMediaSourceType = FrigateEvent | FrigateRecording; class ViewMediaBase { protected _mediaType: ViewMediaType; @@ -23,19 +24,6 @@ class ViewMediaBase { this._cameraID = cameraID; this._source = source; } - - public isEvent(): boolean { - return this._mediaType === 'clip' || this._mediaType === 'snapshot'; - } - public isRecording(): boolean { - return this._mediaType === 'recording'; - } - public isClip(): boolean { - return this._mediaType === 'clip'; - } - public isSnapshot(): boolean { - return this._mediaType === 'snapshot'; - } public getContentType(): 'image' | 'video' { return this._mediaType === 'snapshot' ? 'image' : 'video'; } @@ -45,9 +33,6 @@ class ViewMediaBase { public getMediaType(): ViewMediaType { return this._mediaType; } - public isVideo(): boolean { - return this.isClip() || this.isRecording(); - } public getSource(): T { return this._source; } @@ -73,14 +58,6 @@ class ViewMediaBase { public getThumbnail(_cameraConfig?: CameraConfig): string | null { return null; } - // eslint-disable-next-line @typescript-eslint/no-unused-vars - public isGroupableWith(that: ViewMedia): boolean { - return ( - this.getMediaType() === that.getMediaType() && - isEqual(this.getWhere(), that.getWhere()) && - isEqual(this.getWhat(), that.getWhat()) - ); - } public isFavorite(): boolean | null { return null; } @@ -91,18 +68,19 @@ class ViewMediaBase { public setFavorite(_favorite: boolean): void { return; } - public getWhat(): string[] | null { - return null; - } public getWhere(): string[] | null { return null; } - public getScore(): number | null { - return null; - } - public getEventCount(): number | null { - return null; - } +} + +export interface EventViewMedia extends ViewMedia { + getScore(): number | null; + getWhat(): string[] | null; + isGroupableWith(that: EventViewMedia): boolean; +} + +export interface RecordingViewMedia extends ViewMedia { + getEventCount(): number | null; } // Creates a 'public interface only' version of ViewMediaBase for use elsewhere @@ -112,26 +90,10 @@ export type ViewMedia = { [P in keyof ViewMediaBase]: ViewMediaBase[P]; }; -export class HomeAssistantBrowserViewMedia extends ViewMediaBase { - // eslint-disable-next-line @typescript-eslint/no-unused-vars - public getID(_cameraConfig?: CameraConfig): string | null { - return this._source.media_content_id; - } - // eslint-disable-next-line @typescript-eslint/no-unused-vars - public getContentID(_cameraConfig?: CameraConfig): string | null { - return this._source.media_content_id; - } - // eslint-disable-next-line @typescript-eslint/no-unused-vars - public getTitle(_cameraConfig?: CameraConfig): string | null { - return this._source.title; - } - // eslint-disable-next-line @typescript-eslint/no-unused-vars - public getThumbnail(_cameraConfig?: CameraConfig): string | null { - return this._source.thumbnail; - } -} - -export class FrigateEventViewMedia extends ViewMediaBase { +export class FrigateEventViewMedia + extends ViewMediaBase + implements EventViewMedia +{ public hasClip(): boolean { return !!this._source.has_clip; } @@ -167,7 +129,7 @@ export class FrigateEventViewMedia extends ViewMediaBase { cameraConfig.frigate.client_id, cameraConfig.frigate.camera_name, this._source, - this.isClip() ? 'clips' : 'snapshots', + ViewMediaClassifier.isClip(this) ? 'clips' : 'snapshots', ); } @@ -198,9 +160,21 @@ export class FrigateEventViewMedia extends ViewMediaBase { public getScore(): number | null { return this._source.top_score; } + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + public isGroupableWith(that: EventViewMedia): boolean { + return ( + this.getMediaType() === that.getMediaType() && + isEqual(this.getWhere(), that.getWhere()) && + isEqual(this.getWhat(), that.getWhat()) + ); + } } -export class FrigateRecordingViewMedia extends ViewMediaBase { +export class FrigateRecordingViewMedia + extends ViewMediaBase + implements RecordingViewMedia +{ public getID(cameraConfig?: CameraConfig): string | null { // ID name is derived from the real camera name (not CameraID) since the // recordings for the same camera across multiple zones will be the same and @@ -262,15 +236,4 @@ export class ViewMediaFactory { ): ViewMedia | null { return new FrigateRecordingViewMedia('recording', cameraID, recording); } - - static createViewMediaFromBrowseMediaSource( - cameraID: string, - browseMedia: BrowseMediaSource, - ): ViewMedia | null { - return new HomeAssistantBrowserViewMedia( - browseMedia.media_content_type === MEDIA_TYPE_IMAGE ? 'snapshot' : 'clip', - cameraID, - browseMedia, - ); - } } diff --git a/src/view/view.ts b/src/view/view.ts index 53b159bb..dd8df6bb 100644 --- a/src/view/view.ts +++ b/src/view/view.ts @@ -1,5 +1,4 @@ // Easy: -// - TODO: Are there elements of ViewMedia (e.g. getEventCount) that should be moved into subclasses (e.g. a recording subclass). // - TODO: In MediaQueriesBase, do we need to generic? Just have T be a MediaQuery? // - TODO: In the viewer @click handlers should I use this.selected instead of calling carouselScrollPrevious()