mirror of
https://github.com/koush/scrypted.git
synced 2026-03-07 19:42:05 +00:00
792 lines
30 KiB
TypeScript
792 lines
30 KiB
TypeScript
import { AutoenableMixinProvider } from "@scrypted/common/src/autoenable-mixin-provider";
|
|
import { AuthFetchCredentialState, authHttpFetch } from '@scrypted/common/src/http-auth-fetch';
|
|
import { RefreshPromise, TimeoutError, createMapPromiseDebouncer, singletonPromise, timeoutPromise } from "@scrypted/common/src/promise-utils";
|
|
import { SettingsMixinDeviceBase, SettingsMixinDeviceOptions } from "@scrypted/common/src/settings-mixin";
|
|
import sdk, { BufferConverter, Camera, DeviceManifest, DeviceProvider, FFmpegInput, HttpRequest, HttpRequestHandler, HttpResponse, MediaObject, MediaObjectOptions, MixinProvider, RequestMediaStreamOptions, RequestPictureOptions, ResponsePictureOptions, ScryptedDevice, ScryptedDeviceType, ScryptedInterface, ScryptedMimeTypes, Setting, SettingValue, Settings, VideoCamera, WritableDeviceState } from "@scrypted/sdk";
|
|
import { StorageSettings } from "@scrypted/sdk/storage-settings";
|
|
import https from 'https';
|
|
import os from 'os';
|
|
import path from 'path';
|
|
import url from 'url';
|
|
import { ffmpegFilterImage, ffmpegFilterImageBuffer } from './ffmpeg-image-filter';
|
|
import { ImageConverter, ImageConverterNativeId } from './image-converter';
|
|
import { ImageReader, ImageReaderNativeId, loadSharp, loadVipsImage } from './image-reader';
|
|
import { ImageWriter, ImageWriterNativeId } from './image-writer';
|
|
|
|
const { mediaManager, systemManager } = sdk;
|
|
if (os.cpus().find(cpu => cpu.model?.toLowerCase().includes('qemu'))) {
|
|
sdk.log.a('QEMU CPU detected. Set your CPU model to host.');
|
|
}
|
|
|
|
const httpsAgent = new https.Agent({
|
|
rejectUnauthorized: false
|
|
});
|
|
|
|
class NeverWaitError extends Error {
|
|
|
|
}
|
|
|
|
class PrebufferUnavailableError extends Error {
|
|
|
|
}
|
|
|
|
class SnapshotMixin extends SettingsMixinDeviceBase<Camera> implements Camera {
|
|
storageSettings = new StorageSettings(this, {
|
|
defaultSnapshotChannel: {
|
|
title: 'Default Snapshot Channel',
|
|
description: 'The default channel to use for snapshots.',
|
|
defaultValue: 'Camera Default',
|
|
hide: !this.mixinDeviceInterfaces.includes(ScryptedInterface.Camera),
|
|
onGet: async () => {
|
|
if (!this.mixinDeviceInterfaces.includes(ScryptedInterface.Camera)) {
|
|
return {
|
|
hide: true,
|
|
};
|
|
}
|
|
|
|
let psos: ResponsePictureOptions[];
|
|
try {
|
|
psos = await this.mixinDevice.getPictureOptions();
|
|
}
|
|
catch (e) {
|
|
}
|
|
|
|
if (!psos?.length) {
|
|
return {
|
|
hide: true,
|
|
};
|
|
}
|
|
|
|
return {
|
|
hide: false,
|
|
choices: [
|
|
'Camera Default',
|
|
...psos.map(pso => pso.name),
|
|
],
|
|
};
|
|
}
|
|
},
|
|
snapshotUrl: {
|
|
title: this.mixinDeviceInterfaces.includes(ScryptedInterface.Camera)
|
|
? 'Override Snapshot URL'
|
|
: 'Snapshot URL',
|
|
description: (this.mixinDeviceInterfaces.includes(ScryptedInterface.Camera)
|
|
? 'Optional: '
|
|
: '')
|
|
+ 'The http(s) URL that retrieves a jpeg image from your camera.',
|
|
placeholder: 'https://ip:1234/cgi-bin/snapshot.jpg',
|
|
},
|
|
snapshotsFromPrebuffer: {
|
|
title: 'Snapshots from Prebuffer',
|
|
description: 'Prefer snapshots from the Rebroadcast Plugin prebuffer when available. This setting uses considerable CPU to convert a video stream into a snapshot. The Default setting will use the camera snapshot and fall back to prebuffer on failure.',
|
|
choices: [
|
|
'Default',
|
|
'Enabled',
|
|
'Disabled',
|
|
],
|
|
defaultValue: 'Default',
|
|
},
|
|
snapshotResolution: {
|
|
title: 'Snapshot Resolution',
|
|
description: 'Set resolution of the snapshots requested from the camera.',
|
|
choices: [
|
|
'Default',
|
|
'Full Resolution',
|
|
'Requested Resolution',
|
|
],
|
|
defaultValue: 'Default',
|
|
hide: !this.mixinDeviceInterfaces.includes(ScryptedInterface.Camera),
|
|
},
|
|
snapshotCropScale: {
|
|
title: 'Crop and Scale',
|
|
description: 'Set the approximate region to crop and scale to 16:9 snapshots.',
|
|
type: 'clippath',
|
|
},
|
|
});
|
|
snapshotDebouncer = createMapPromiseDebouncer<{
|
|
picture: Buffer;
|
|
pictureTime: number;
|
|
}>();
|
|
errorPicture: RefreshPromise<Buffer>;
|
|
timeoutPicture: RefreshPromise<Buffer>;
|
|
progressPicture: RefreshPromise<Buffer>;
|
|
prebufferUnavailablePicture: RefreshPromise<Buffer>;
|
|
currentPicture: Buffer;
|
|
currentPictureTime = 0;
|
|
lastErrorImagesClear = 0;
|
|
static lastGeneratedErrorImageTime = 0;
|
|
lastAvailablePicture: Buffer;
|
|
psos: ResponsePictureOptions[];
|
|
|
|
constructor(public plugin: SnapshotPlugin, options: SettingsMixinDeviceOptions<Camera>) {
|
|
super(options);
|
|
}
|
|
|
|
get debugConsole() {
|
|
if (this.plugin.debugConsole)
|
|
return this.console;
|
|
}
|
|
|
|
async takePictureInternal(options?: RequestPictureOptions): Promise<Buffer> {
|
|
this.debugConsole?.log("Picture requested from camera", options);
|
|
const eventSnapshot = options?.reason === 'event';
|
|
const { snapshotsFromPrebuffer } = this.storageSettings.values;
|
|
let usePrebufferSnapshots: boolean;
|
|
switch (snapshotsFromPrebuffer) {
|
|
case 'true':
|
|
case 'Enabled':
|
|
usePrebufferSnapshots = true;
|
|
break;
|
|
case 'Disabled':
|
|
usePrebufferSnapshots = false;
|
|
break;
|
|
default:
|
|
// default behavior is to use a prebuffer snapshot if there's no camera interface and
|
|
// no explicit snapshot url.
|
|
if (!this.mixinDeviceInterfaces.includes(ScryptedInterface.Camera) && !this.storageSettings.values.snapshotUrl)
|
|
usePrebufferSnapshots = true;
|
|
break;
|
|
}
|
|
|
|
// unifi cameras send stale snapshots which are unusable for events,
|
|
// so force a prebuffer snapshot in this instance.
|
|
// if prebuffer is not available, it will fall back.
|
|
if (eventSnapshot && usePrebufferSnapshots !== false) {
|
|
try {
|
|
const psos = await this.getPictureOptions();
|
|
if (psos?.[0]?.staleDuration) {
|
|
usePrebufferSnapshots = true;
|
|
}
|
|
}
|
|
catch (e) {
|
|
}
|
|
}
|
|
|
|
let takePrebufferPicture: () => Promise<Buffer>;
|
|
const preparePrebufferSnapshot = async () => {
|
|
if (takePrebufferPicture)
|
|
return takePrebufferPicture;
|
|
const realDevice = systemManager.getDeviceById<VideoCamera>(this.id);
|
|
const msos = await realDevice.getVideoStreamOptions();
|
|
let prebufferChannel = msos?.find(mso => mso.prebuffer);
|
|
if (prebufferChannel || !this.lastAvailablePicture) {
|
|
prebufferChannel = prebufferChannel || {
|
|
id: undefined,
|
|
};
|
|
|
|
const request = prebufferChannel as RequestMediaStreamOptions;
|
|
// specify the prebuffer based on the usage. events shouldn't request
|
|
// lengthy prebuffers as it may not contain the image it needs.
|
|
request.prebuffer = eventSnapshot ? 1000 : 6000;
|
|
if (this.lastAvailablePicture)
|
|
request.refresh = false;
|
|
takePrebufferPicture = async () => {
|
|
// this.console.log('snapshotting active prebuffer');
|
|
const ffmpegInput = await sdk.mediaManager.convertMediaObjectToJSON<FFmpegInput>(await realDevice.getVideoStream(request), ScryptedMimeTypes.FFmpegInput);
|
|
return ffmpegFilterImage(ffmpegInput.inputArguments, {
|
|
console: this.debugConsole,
|
|
ffmpegPath: await mediaManager.getFFmpegPath(),
|
|
timeout: 10000,
|
|
});
|
|
};
|
|
return takePrebufferPicture;
|
|
}
|
|
}
|
|
|
|
if (usePrebufferSnapshots) {
|
|
const takePicture = await preparePrebufferSnapshot()
|
|
if (!takePicture)
|
|
throw new PrebufferUnavailableError();
|
|
return takePicture();
|
|
}
|
|
|
|
const retryWithPrebuffer = async (e: Error) => {
|
|
if (usePrebufferSnapshots === false)
|
|
throw e;
|
|
const takePicture = await preparePrebufferSnapshot()
|
|
if (!takePicture)
|
|
throw e;
|
|
this.console.error('Snapshot failed, falling back to prebuffer', e);
|
|
return takePicture();
|
|
}
|
|
|
|
if (this.storageSettings.values.snapshotUrl) {
|
|
let username: string;
|
|
let password: string;
|
|
|
|
if (this.mixinDeviceInterfaces.includes(ScryptedInterface.Settings)) {
|
|
const settings = await this.mixinDevice.getSettings();
|
|
username = settings?.find(setting => setting.key === 'username')?.value?.toString();
|
|
password = settings?.find(setting => setting.key === 'password')?.value?.toString();
|
|
}
|
|
|
|
let credential: AuthFetchCredentialState;
|
|
if (username && password) {
|
|
credential = {
|
|
username,
|
|
password,
|
|
};
|
|
}
|
|
|
|
try {
|
|
const response = await authHttpFetch({
|
|
rejectUnauthorized: false,
|
|
url: this.storageSettings.values.snapshotUrl,
|
|
credential,
|
|
timeout: 60000,
|
|
headers: {
|
|
'Accept': 'image/*',
|
|
},
|
|
});
|
|
|
|
return response.body;
|
|
}
|
|
catch (e) {
|
|
return retryWithPrebuffer(e);
|
|
}
|
|
}
|
|
|
|
if (this.mixinDeviceInterfaces.includes(ScryptedInterface.Camera)) {
|
|
let takePictureOptions: RequestPictureOptions;
|
|
if (!options?.id && this.storageSettings.values.defaultSnapshotChannel !== 'Camera Default') {
|
|
try {
|
|
const psos = await this.getPictureOptions();
|
|
const pso = psos.find(pso => pso.name === this.storageSettings.values.defaultSnapshotChannel);
|
|
takePictureOptions = {
|
|
id: pso?.id,
|
|
};
|
|
}
|
|
catch (e) {
|
|
}
|
|
}
|
|
try {
|
|
return await this.mixinDevice.takePicture(takePictureOptions).then(mo => mediaManager.convertMediaObjectToBuffer(mo, 'image/jpeg'))
|
|
}
|
|
catch (e) {
|
|
return retryWithPrebuffer(e);
|
|
}
|
|
}
|
|
|
|
throw new Error('Snapshot Unavailable (Snapshot URL empty)');
|
|
}
|
|
|
|
async takePictureRaw(options?: RequestPictureOptions): Promise<Buffer> {
|
|
const eventSnapshot = options?.reason === 'event';
|
|
const periodicSnapshot = options?.reason === 'periodic';
|
|
|
|
// clear out snapshots that are too old.
|
|
if (this.currentPictureTime < Date.now() - 1 * 60 * 60 * 1000)
|
|
this.currentPicture = undefined;
|
|
|
|
// always grab/debounce a snapshot
|
|
// event snapshot are special and should immediately expire.
|
|
// other snapshots may be debounced for 4s.
|
|
const debounced = this.snapshotDebouncer({
|
|
id: options?.id,
|
|
type: 'source',
|
|
event: options?.reason === 'event',
|
|
}, eventSnapshot ? 0 : 4000, 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 = pictureTime;
|
|
this.lastAvailablePicture = picture;
|
|
this.debugConsole?.debug(`Periodic snapshot took ${(this.currentPictureTime - snapshotTimer) / 1000} seconds to retrieve.`)
|
|
return {
|
|
picture,
|
|
pictureTime,
|
|
};
|
|
});
|
|
debounced.catch(() => { });
|
|
|
|
// prevent this from expiring
|
|
let availablePicture = this.currentPicture;
|
|
let availablePictureTime = this.currentPictureTime;
|
|
|
|
let rawPicture: Awaited<typeof debounced>;
|
|
try {
|
|
let pictureTimeout = options?.timeout;
|
|
if (!pictureTimeout) {
|
|
// determine a fetch timeout based on the reason and staleness
|
|
const allowedSnapshotStaleness = eventSnapshot ? 0 : periodicSnapshot ? 20000 : 10000;
|
|
if (!availablePicture) {
|
|
// none available so wait a while
|
|
pictureTimeout = 10000;
|
|
}
|
|
else {
|
|
if (availablePictureTime > Date.now() - 3000) {
|
|
// very recent, don't wait for too long
|
|
pictureTimeout = 1000;
|
|
}
|
|
else if (availablePictureTime > Date.now() - allowedSnapshotStaleness) {
|
|
// fairly recent so give it little time to get a fresh one
|
|
// idr interval is typically 4000 for reference
|
|
pictureTimeout = 3000;
|
|
}
|
|
else {
|
|
// stale so wait a while
|
|
pictureTimeout = 10000;
|
|
}
|
|
}
|
|
}
|
|
rawPicture = await timeoutPromise(pictureTimeout, debounced);
|
|
}
|
|
catch (e) {
|
|
// 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) {
|
|
// use the current picture if it is still available as it may be newer.
|
|
availablePicture = this.currentPicture;
|
|
availablePictureTime = this.currentPictureTime;
|
|
}
|
|
|
|
if (!availablePicture)
|
|
return this.createErrorImage(e);
|
|
|
|
this.console.warn('Snapshot failed, but recovered from cache', e);
|
|
rawPicture = {
|
|
picture: availablePicture,
|
|
pictureTime: availablePictureTime,
|
|
};
|
|
|
|
// gc
|
|
availablePicture = undefined;
|
|
}
|
|
|
|
const needSoftwareResize = !!(options?.picture?.width || options?.picture?.height) && this.storageSettings.values.snapshotResolution !== 'Full Resolution';
|
|
|
|
if (!needSoftwareResize)
|
|
return rawPicture.picture;
|
|
|
|
try {
|
|
const key = {
|
|
type: 'resize',
|
|
pictureTime: rawPicture.pictureTime,
|
|
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,
|
|
});
|
|
return {
|
|
picture: ret,
|
|
pictureTime: rawPicture.pictureTime,
|
|
};
|
|
});
|
|
|
|
return ret.picture;
|
|
}
|
|
catch (e) {
|
|
if (eventSnapshot)
|
|
throw e;
|
|
return this.createErrorImage(e);
|
|
}
|
|
}
|
|
|
|
async takePicture(options?: RequestPictureOptions): Promise<MediaObject> {
|
|
return this.createMediaObject(await this.takePictureRaw(options), 'image/jpeg');
|
|
}
|
|
|
|
async cropAndScale(picture: Buffer) {
|
|
if (!this.storageSettings.values.snapshotCropScale?.length)
|
|
return picture;
|
|
|
|
const xmin = Math.min(...this.storageSettings.values.snapshotCropScale.map(([x, y]) => x)) / 100;
|
|
const ymin = Math.min(...this.storageSettings.values.snapshotCropScale.map(([x, y]) => y)) / 100;
|
|
const xmax = Math.max(...this.storageSettings.values.snapshotCropScale.map(([x, y]) => x)) / 100;
|
|
const ymax = Math.max(...this.storageSettings.values.snapshotCropScale.map(([x, y]) => y)) / 100;
|
|
|
|
if (loadSharp()) {
|
|
const vips = await loadVipsImage(picture, this.id);
|
|
try {
|
|
const ret = await vips.toBuffer({
|
|
crop: {
|
|
left: xmin * vips.width,
|
|
top: ymin * vips.height,
|
|
width: (xmax - xmin) * vips.width,
|
|
height: (ymax - ymin) * vips.height,
|
|
},
|
|
format: 'jpg',
|
|
});
|
|
return ret;
|
|
}
|
|
finally {
|
|
vips.close();
|
|
}
|
|
}
|
|
|
|
// try {
|
|
// const mo = await mediaManager.createMediaObject(picture, 'image/jpeg');
|
|
// const image = await mediaManager.convertMediaObject<Image>(mo, ScryptedMimeTypes.Image);
|
|
// const left = image.width * xmin;
|
|
// const width = image.width * (xmax - xmin);
|
|
// const top = image.height * ymin;
|
|
// const height = image.height * (ymax - ymin);
|
|
|
|
// return await image.toBuffer({
|
|
// crop: {
|
|
// left,
|
|
// width,
|
|
// top,
|
|
// height,
|
|
// },
|
|
// format: 'jpg',
|
|
// });
|
|
// }
|
|
// catch (e) {
|
|
// if (!e.message?.includes('no converter found'))
|
|
// throw e;
|
|
// }
|
|
|
|
return ffmpegFilterImageBuffer(picture, {
|
|
console: this.debugConsole,
|
|
ffmpegPath: await mediaManager.getFFmpegPath(),
|
|
crop: {
|
|
fractional: true,
|
|
left: xmin,
|
|
top: ymin,
|
|
width: xmax - xmin,
|
|
height: ymax - ymin,
|
|
},
|
|
timeout: 10000,
|
|
});
|
|
}
|
|
|
|
clearErrorImages() {
|
|
this.errorPicture = undefined;
|
|
this.timeoutPicture = undefined;
|
|
this.progressPicture = undefined;
|
|
this.prebufferUnavailablePicture = undefined;
|
|
}
|
|
|
|
clearCachedPictures() {
|
|
// if previous error pictures were generated with the black background,
|
|
// clear it out to force a real blurred image.
|
|
if (!this.lastAvailablePicture)
|
|
this.clearErrorImages();
|
|
this.currentPicture = undefined;
|
|
}
|
|
|
|
maybeClearErrorImages() {
|
|
const now = Date.now();
|
|
|
|
// only clear the error images if they are at least an hour old
|
|
if (now - this.lastErrorImagesClear > 1 * 60 * 60 * 1000)
|
|
return;
|
|
|
|
// only clear error images generated once a per minute across all cameras
|
|
if (now - SnapshotMixin.lastGeneratedErrorImageTime < 60 * 1000)
|
|
return;
|
|
|
|
SnapshotMixin.lastGeneratedErrorImageTime = now;
|
|
this.lastErrorImagesClear = now;
|
|
this.clearErrorImages();
|
|
}
|
|
|
|
async createErrorImage(e: any) {
|
|
this.maybeClearErrorImages();
|
|
|
|
if (e instanceof TimeoutError) {
|
|
this.timeoutPicture = singletonPromise(this.timeoutPicture,
|
|
() => this.createTextErrorImage('Snapshot Timed Out'));
|
|
return this.timeoutPicture.promise;
|
|
}
|
|
else if (e instanceof PrebufferUnavailableError) {
|
|
this.prebufferUnavailablePicture = singletonPromise(this.prebufferUnavailablePicture,
|
|
() => this.createTextErrorImage('Snapshot Unavailable'));
|
|
return this.prebufferUnavailablePicture.promise;
|
|
}
|
|
else if (e instanceof NeverWaitError) {
|
|
this.progressPicture = singletonPromise(this.progressPicture,
|
|
() => this.createTextErrorImage('Snapshot In Progress'));
|
|
return this.progressPicture.promise;
|
|
}
|
|
else {
|
|
this.console.error('Snapshot failed', e);
|
|
this.errorPicture = singletonPromise(this.errorPicture,
|
|
() => this.createTextErrorImage('Snapshot Failed'));
|
|
return this.errorPicture.promise;
|
|
}
|
|
}
|
|
|
|
async createTextErrorImage(text: string) {
|
|
const errorBackground = this.currentPicture || this.lastAvailablePicture;
|
|
this.console.log('creating error image with background', text, !!errorBackground);
|
|
|
|
const pluginVolume = process.env.SCRYPTED_PLUGIN_VOLUME;
|
|
const unzippedFs = path.join(pluginVolume, 'zip/unzipped/fs');
|
|
const fontFile = path.join(unzippedFs, 'Lato-Bold.ttf');
|
|
|
|
if (!errorBackground) {
|
|
return ffmpegFilterImage([
|
|
'-f', 'lavfi',
|
|
'-i', 'color=black:size=1920x1080',
|
|
], {
|
|
console: this.debugConsole,
|
|
ffmpegPath: await mediaManager.getFFmpegPath(),
|
|
text: {
|
|
fontFile,
|
|
text,
|
|
},
|
|
timeout: 10000,
|
|
})
|
|
}
|
|
else {
|
|
return ffmpegFilterImageBuffer(errorBackground, {
|
|
console: this.debugConsole,
|
|
ffmpegPath: await mediaManager.getFFmpegPath(),
|
|
blur: true,
|
|
brightness: -.2,
|
|
text: {
|
|
fontFile,
|
|
text,
|
|
},
|
|
timeout: 10000,
|
|
});
|
|
}
|
|
}
|
|
|
|
async getPictureOptions() {
|
|
if (!this.psos)
|
|
this.psos = await this.mixinDevice.getPictureOptions();
|
|
return this.psos;
|
|
}
|
|
|
|
getMixinSettings(): Promise<Setting[]> {
|
|
return this.storageSettings.getSettings();
|
|
}
|
|
|
|
putMixinSetting(key: string, value: SettingValue) {
|
|
return this.storageSettings.putSetting(key, value);
|
|
}
|
|
}
|
|
|
|
export class SnapshotPlugin extends AutoenableMixinProvider implements MixinProvider, BufferConverter, Settings, DeviceProvider, HttpRequestHandler {
|
|
storageSettings = new StorageSettings(this, {
|
|
debugLogging: {
|
|
title: 'Debug Logging',
|
|
description: 'Debug logging for all cameras will be shown in the Snapshot Plugin Console.',
|
|
type: 'boolean',
|
|
},
|
|
});
|
|
mixinDevices = new Map<string, SnapshotMixin>();
|
|
authenticatedPath = sdk.endpointManager.getAuthenticatedPath(this.nativeId)
|
|
|
|
constructor(nativeId?: string) {
|
|
super(nativeId);
|
|
|
|
this.fromMimeType = ScryptedMimeTypes.SchemePrefix + 'scrypted-media' + ';converter-weight=0';
|
|
this.toMimeType = ScryptedMimeTypes.LocalUrl;
|
|
|
|
const manifest: DeviceManifest = {
|
|
devices: [
|
|
{
|
|
name: 'Image Writer',
|
|
interfaces: [
|
|
ScryptedInterface.BufferConverter,
|
|
],
|
|
type: ScryptedDeviceType.Builtin,
|
|
nativeId: ImageWriterNativeId,
|
|
},
|
|
{
|
|
name: 'Image Converter',
|
|
interfaces: [
|
|
ScryptedInterface.BufferConverter,
|
|
],
|
|
type: ScryptedDeviceType.Builtin,
|
|
nativeId: ImageConverterNativeId,
|
|
}
|
|
],
|
|
};
|
|
|
|
if (loadSharp()) {
|
|
manifest.devices.push(
|
|
{
|
|
name: 'Image Reader',
|
|
interfaces: [
|
|
ScryptedInterface.BufferConverter,
|
|
],
|
|
type: ScryptedDeviceType.Builtin,
|
|
nativeId: ImageReaderNativeId,
|
|
}
|
|
);
|
|
}
|
|
|
|
process.nextTick(() => {
|
|
sdk.deviceManager.onDevicesChanged(manifest)
|
|
});
|
|
}
|
|
|
|
async getDevice(nativeId: string): Promise<any> {
|
|
if (nativeId === ImageConverterNativeId)
|
|
return new ImageConverter(this, ImageConverterNativeId);
|
|
if (nativeId === ImageWriterNativeId)
|
|
return new ImageWriter(ImageWriterNativeId);
|
|
if (nativeId === ImageReaderNativeId)
|
|
return new ImageReader(ImageReaderNativeId);
|
|
}
|
|
|
|
async releaseDevice(id: string, nativeId: string): Promise<void> {
|
|
}
|
|
|
|
getSettings(): Promise<Setting[]> {
|
|
return this.storageSettings.getSettings();
|
|
}
|
|
|
|
putSetting(key: string, value: SettingValue): Promise<void> {
|
|
return this.storageSettings.putSetting(key, value);
|
|
}
|
|
|
|
get debugConsole() {
|
|
if (this.storageSettings.values.debugLogging)
|
|
return this.console;
|
|
}
|
|
|
|
async getLocalSnapshot(id: string, iface: string, search: string) {
|
|
const endpoint = await this.authenticatedPath;
|
|
const ret = url.resolve(path.join(endpoint, id, iface, `${Date.now()}.jpg`) + `${search}`, '');
|
|
return Buffer.from(ret);
|
|
}
|
|
|
|
async convert(data: any, fromMimeType: string, toMimeType: string, options?: MediaObjectOptions): Promise<any> {
|
|
const url = new URL(data.toString());
|
|
const id = url.hostname;
|
|
const path = url.pathname.split('/')[1];
|
|
|
|
if (path === ScryptedInterface.Camera) {
|
|
return this.getLocalSnapshot(id, path, url.search);
|
|
}
|
|
if (path === ScryptedInterface.VideoCamera) {
|
|
return this.getLocalSnapshot(id, path, url.search);
|
|
}
|
|
else {
|
|
throw new Error('Unrecognized Scrypted Media interface.')
|
|
}
|
|
}
|
|
|
|
async onRequest(request: HttpRequest, response: HttpResponse): Promise<void> {
|
|
if (request.isPublicEndpoint) {
|
|
response.send('', {
|
|
code: 404,
|
|
});
|
|
return;
|
|
}
|
|
|
|
const pathname = request.url.substring(request.rootPath.length);
|
|
const [_, id, iface] = pathname.split('/');
|
|
try {
|
|
if (iface !== ScryptedInterface.Camera && iface !== ScryptedInterface.VideoCamera)
|
|
throw new Error();
|
|
|
|
const search = new URLSearchParams(pathname.split('?')[1]);
|
|
const mixin = this.mixinDevices.get(id);
|
|
let buffer: Buffer;
|
|
let timeout = parseInt(search.get('timeout'));
|
|
// make web requests timeout after 5 seconds by default.
|
|
if (isNaN(timeout))
|
|
timeout = 5000;
|
|
const rpo: RequestPictureOptions = {
|
|
reason: search.get('reason') as 'event' | 'periodic',
|
|
timeout,
|
|
picture: {
|
|
width: parseInt(search.get('width')) || undefined,
|
|
height: parseInt(search.get('height')) || undefined,
|
|
}
|
|
};
|
|
|
|
if (mixin.storageSettings.values.snapshotResolution === 'Full Resolution')
|
|
delete rpo.picture;
|
|
|
|
if (mixin && iface === ScryptedInterface.Camera) {
|
|
buffer = await mixin.takePictureRaw(rpo)
|
|
}
|
|
else {
|
|
const device = systemManager.getDeviceById<Camera & VideoCamera>(id);
|
|
const picture = iface === ScryptedInterface.Camera ? await device.takePicture(rpo) : await device.getVideoStream();
|
|
buffer = await mediaManager.convertMediaObjectToBuffer(picture, 'image/jpeg');
|
|
}
|
|
|
|
response.send(buffer, {
|
|
headers: {
|
|
'Content-Type': 'image/jpeg',
|
|
'Cache-Control': 'max-age=10',
|
|
}
|
|
});
|
|
}
|
|
catch (e) {
|
|
this.debugConsole?.error('snapshot http request failed', e);
|
|
response.send('', {
|
|
code: 500,
|
|
});
|
|
}
|
|
}
|
|
|
|
async canMixin(type: ScryptedDeviceType, interfaces: string[]): Promise<string[]> {
|
|
if ((type === ScryptedDeviceType.Camera || type === ScryptedDeviceType.Doorbell) && interfaces.includes(ScryptedInterface.VideoCamera))
|
|
return [ScryptedInterface.Camera, ScryptedInterface.Settings];
|
|
return undefined;
|
|
}
|
|
|
|
async getMixin(mixinDevice: any, mixinDeviceInterfaces: ScryptedInterface[], mixinDeviceState: WritableDeviceState): Promise<any> {
|
|
const ret = new SnapshotMixin(this, {
|
|
mixinDevice,
|
|
mixinDeviceInterfaces,
|
|
mixinDeviceState,
|
|
mixinProviderNativeId: this.nativeId,
|
|
group: 'Snapshot',
|
|
groupKey: 'snapshot',
|
|
});
|
|
this.mixinDevices.set(ret.id, ret);
|
|
return ret;
|
|
}
|
|
|
|
async shouldEnableMixin(device: ScryptedDevice) {
|
|
const { type, interfaces } = device;
|
|
// auto enable this on VideoCameras that do not have snapshot capability.
|
|
if ((type === ScryptedDeviceType.Camera || type === ScryptedDeviceType.Doorbell)
|
|
&& interfaces.includes(ScryptedInterface.VideoCamera))
|
|
return true;
|
|
return false;
|
|
}
|
|
|
|
async releaseMixin(id: string, mixinDevice: any): Promise<void> {
|
|
if (this.mixinDevices.get(id) === mixinDevice)
|
|
this.mixinDevices.delete(id);
|
|
await mixinDevice.release()
|
|
}
|
|
}
|
|
|
|
export default SnapshotPlugin;
|