diff --git a/plugins/snapshot/package-lock.json b/plugins/snapshot/package-lock.json index 093464edd3..2b923666e9 100644 --- a/plugins/snapshot/package-lock.json +++ b/plugins/snapshot/package-lock.json @@ -1,12 +1,12 @@ { "name": "@scrypted/snapshot", - "version": "0.2.48", + "version": "0.2.50", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "@scrypted/snapshot", - "version": "0.2.48", + "version": "0.2.50", "dependencies": { "@types/node": "^20.10.6", "sharp": "^0.33.1", diff --git a/plugins/snapshot/package.json b/plugins/snapshot/package.json index a49c8bafc1..9c1d3c51c4 100644 --- a/plugins/snapshot/package.json +++ b/plugins/snapshot/package.json @@ -1,6 +1,6 @@ { "name": "@scrypted/snapshot", - "version": "0.2.48", + "version": "0.2.50", "description": "Snapshot Plugin for Scrypted", "scripts": { "scrypted-setup-project": "scrypted-setup-project", diff --git a/plugins/snapshot/src/main.ts b/plugins/snapshot/src/main.ts index 3a1b1bbec9..810c8f6ac5 100644 --- a/plugins/snapshot/src/main.ts +++ b/plugins/snapshot/src/main.ts @@ -103,7 +103,10 @@ class SnapshotMixin extends SettingsMixinDeviceBase implements Camera { type: 'clippath', }, }); - snapshotDebouncer = createMapPromiseDebouncer(); + snapshotDebouncer = createMapPromiseDebouncer<{ + picture: Buffer; + pictureTime: number; + }>(); errorPicture: RefreshPromise; timeoutPicture: RefreshPromise; progressPicture: RefreshPromise; @@ -268,123 +271,137 @@ class SnapshotMixin extends SettingsMixinDeviceBase implements Camera { } async takePictureRaw(options?: RequestPictureOptions): Promise { - let picture: Buffer; + let rawPicturePromise: Promise<{ + picture: Buffer; + pictureTime: number; + }>; const eventSnapshot = options?.reason === 'event'; const periodicSnapshot = options?.reason === 'periodic'; - try { + // clear out snapshots that are too old. + if (this.currentPictureTime < Date.now() - 1 * 60 * 60 * 1000) + this.currentPicture = undefined; + + const allowedSnapshotStaleness = eventSnapshot ? 0 : periodicSnapshot ? 20000 : 10000; + + let needRefresh = true; + if (this.currentPicture && this.currentPictureTime > Date.now() - allowedSnapshotStaleness) { + this.debugConsole?.log('Using cached snapshot for', options?.reason); + rawPicturePromise = Promise.resolve({ + picture: this.currentPicture, + pictureTime: this.currentPictureTime, + }); + needRefresh = this.currentPictureTime < Date.now() - allowedSnapshotStaleness / 2; + } + + if (needRefresh) { const debounced = this.snapshotDebouncer({ id: options?.id, reason: options?.reason, - }, eventSnapshot ? 0 : 2000, async () => { + }, eventSnapshot ? 0 : 10000, async () => { const snapshotTimer = Date.now(); let picture = await this.takePictureInternal(); picture = await this.cropAndScale(picture); this.clearCachedPictures(); + const pictureTime = Date.now(); this.currentPicture = picture; - this.currentPictureTime = Date.now(); + this.currentPictureTime = pictureTime; this.lastAvailablePicture = picture; this.debugConsole?.debug(`Periodic snapshot took ${(this.currentPictureTime - snapshotTimer) / 1000} seconds to retrieve.`) - return picture; + return { + picture, + pictureTime, + }; }); + debounced.catch(() => { }); - // periodic snapshot should get the immediately available picture. - // the debounce has already triggered a refresh for the next go around. - if (periodicSnapshot && this.currentPicture) { - const cp = this.currentPicture; - debounced.catch(() => { }); - const timeout = options.timeout || 1000; - try { - picture = await timeoutPromise(timeout, debounced); - } - catch (e) { - if (e instanceof TimeoutError) - this.debugConsole?.log(`Periodic snapshot took longer than ${timeout} seconds to retrieve, falling back to cached picture.`) + rawPicturePromise ||= debounced; + } - picture = cp; - } - } - else { - picture = await debounced; - } + // prevent this from expiring + let availablePicture = this.currentPicture; + let availablePictureTime = this.currentPictureTime; + + let rawPicture: Awaited; + try { + const pictureTimeout = options?.timeout || (periodicSnapshot && availablePicture ? 1000 : 10000) || 10000; + rawPicture = await timeoutPromise(pictureTimeout, rawPicturePromise); } catch (e) { - // use the fallback cached picture if it is somewhat recent. - if (this.currentPictureTime < Date.now() - 1 * 60 * 60 * 1000) - this.currentPicture = undefined; + // a best effort was made to get a recent snapshot from cache or from a camera request, + // the cache request will never fail, but if the camera request fails, + // it may be ok to use a somewhat stale snapshot depending on reason. + // event snapshot requests must not use cache since they're for realtime processing by homekit and nvr. if (eventSnapshot) throw e; - if (!this.currentPicture) + availablePicture = this.currentPicture || availablePicture; + + if (!availablePicture) return this.createErrorImage(e); this.console.warn('Snapshot failed, but recovered from cache', e); - picture = this.currentPicture; + rawPicture = { + picture: availablePicture, + pictureTime: availablePictureTime, + }; + + // gc + availablePicture = undefined; } const needSoftwareResize = !!(options?.picture?.width || options?.picture?.height) && this.storageSettings.values.snapshotResolution !== 'Full Resolution'; - if (needSoftwareResize) { - try { - picture = await this.snapshotDebouncer({ - needSoftwareResize: true, - picture: options.picture, - }, eventSnapshot ? 0 : 2000, async () => { - this.debugConsole?.log("Resizing picture from camera", options?.picture); - - if (loadSharp()) { - const vips = await loadVipsImage(picture, this.id); - try { - const ret = await vips.toBuffer({ - resize: options?.picture, - format: 'jpg', - }); - return ret; - } - finally { - vips.close(); - } - } - // try { - // const mo = await mediaManager.createMediaObject(picture, 'image/jpeg', { - // sourceId: this.id, - // }); - // const image = await mediaManager.convertMediaObject(mo, ScryptedMimeTypes.Image); - // let { width, height } = options.picture; - // if (!width) - // width = height / image.height * image.width; - // if (!height) - // height = width / image.width * image.height; - // return await image.toBuffer({ - // resize: { - // width, - // height, - // }, - // format: 'jpg', - // }); - // } - // catch (e) { - // if (!e.message?.includes('no converter found')) - // throw e; - // } - - return ffmpegFilterImageBuffer(picture, { - console: this.debugConsole, - ffmpegPath: await mediaManager.getFFmpegPath(), - resize: options?.picture, - timeout: 10000, - }); + if (!needSoftwareResize) + return rawPicture.picture; + + try { + const key = { + pictureTime: rawPicture.pictureTime, + reason: options?.reason, + needSoftwareResize: true, + picture: options.picture, + }; + const ret = await this.snapshotDebouncer(key, 10000, async () => { + this.debugConsole?.log("Resizing picture from camera", key); + + if (loadSharp()) { + const vips = await loadVipsImage(rawPicture.picture, this.id); + try { + const ret = await vips.toBuffer({ + resize: options?.picture, + format: 'jpg', + }); + return { + picture: ret, + pictureTime: rawPicture.pictureTime, + }; + } + finally { + vips.close(); + } + } + const ret = await ffmpegFilterImageBuffer(rawPicture.picture, { + console: this.debugConsole, + ffmpegPath: await mediaManager.getFFmpegPath(), + resize: options?.picture, + timeout: 10000, }); - } - catch (e) { - if (eventSnapshot) - throw e; - return this.createErrorImage(e); - } + return { + picture: ret, + pictureTime: rawPicture.pictureTime, + }; + }); + + return ret.picture; + } + catch (e) { + if (eventSnapshot) + throw e; + return this.createErrorImage(e); } - return picture; } async takePicture(options?: RequestPictureOptions): Promise {