Skip to content

Commit

Permalink
snapshot: improve caching
Browse files Browse the repository at this point in the history
  • Loading branch information
koush committed Apr 16, 2024
1 parent 584ea97 commit b4da52e
Show file tree
Hide file tree
Showing 3 changed files with 106 additions and 89 deletions.
4 changes: 2 additions & 2 deletions plugins/snapshot/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion plugins/snapshot/package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
189 changes: 103 additions & 86 deletions plugins/snapshot/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,10 @@ class SnapshotMixin extends SettingsMixinDeviceBase<Camera> implements Camera {
type: 'clippath',
},
});
snapshotDebouncer = createMapPromiseDebouncer<Buffer>();
snapshotDebouncer = createMapPromiseDebouncer<{
picture: Buffer;
pictureTime: number;
}>();
errorPicture: RefreshPromise<Buffer>;
timeoutPicture: RefreshPromise<Buffer>;
progressPicture: RefreshPromise<Buffer>;
Expand Down Expand Up @@ -268,123 +271,137 @@ class SnapshotMixin extends SettingsMixinDeviceBase<Camera> implements Camera {
}

async takePictureRaw(options?: RequestPictureOptions): Promise<Buffer> {
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<typeof rawPicturePromise>;
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<Image>(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<MediaObject> {
Expand Down

0 comments on commit b4da52e

Please sign in to comment.