diff --git a/plugins/snapshot/package-lock.json b/plugins/snapshot/package-lock.json index 4bce51e98..079cbc009 100644 --- a/plugins/snapshot/package-lock.json +++ b/plugins/snapshot/package-lock.json @@ -1,12 +1,12 @@ { "name": "@scrypted/snapshot", - "version": "0.2.15", + "version": "0.2.16", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "@scrypted/snapshot", - "version": "0.2.15", + "version": "0.2.16", "dependencies": { "@koush/axios-digest-auth": "^0.8.5", "@types/node": "^18.16.18", diff --git a/plugins/snapshot/package.json b/plugins/snapshot/package.json index bafecdba5..9b7d32174 100644 --- a/plugins/snapshot/package.json +++ b/plugins/snapshot/package.json @@ -1,6 +1,6 @@ { "name": "@scrypted/snapshot", - "version": "0.2.15", + "version": "0.2.16", "description": "Snapshot Plugin for Scrypted", "scripts": { "scrypted-setup-project": "scrypted-setup-project", @@ -29,7 +29,8 @@ "Settings", "MixinProvider", "BufferConverter", - "DeviceProvider" + "DeviceProvider", + "HttpRequestHandler" ] }, "dependencies": { diff --git a/plugins/snapshot/src/image-converter.ts b/plugins/snapshot/src/image-converter.ts new file mode 100644 index 000000000..34a0d6f76 --- /dev/null +++ b/plugins/snapshot/src/image-converter.ts @@ -0,0 +1,24 @@ +import { BufferConverter, FFmpegInput, MediaObjectOptions, ScryptedDeviceBase, ScryptedMimeTypes, ScryptedNativeId } from '@scrypted/sdk'; +import MIMEType from 'whatwg-mimetype'; +import type { SnapshotPlugin } from './main'; +import { parseImageOp, processImageOp } from './parse-dims'; + +export const ImageConverterNativeId = 'imageconverter'; + +export class ImageConverter extends ScryptedDeviceBase implements BufferConverter { + constructor(public plugin: SnapshotPlugin, nativeId: ScryptedNativeId) { + super(nativeId); + + this.fromMimeType = ScryptedMimeTypes.FFmpegInput; + this.toMimeType = 'image/jpeg'; + } + + async convert(data: any, fromMimeType: string, toMimeType: string, options?: MediaObjectOptions): Promise { + const mime = new MIMEType(toMimeType); + + const op = parseImageOp(mime.parameters); + const ffmpegInput = JSON.parse(data.toString()) as FFmpegInput; + + return processImageOp(ffmpegInput, op, parseFloat(mime.parameters.get('time')), options?.sourceId, this.plugin.debugConsole); + } +} diff --git a/plugins/snapshot/src/main.ts b/plugins/snapshot/src/main.ts index ffe7162d6..ceb79ca72 100644 --- a/plugins/snapshot/src/main.ts +++ b/plugins/snapshot/src/main.ts @@ -2,16 +2,16 @@ import AxiosDigestAuth from '@koush/axios-digest-auth'; import { AutoenableMixinProvider } from "@scrypted/common/src/autoenable-mixin-provider"; import { createMapPromiseDebouncer, RefreshPromise, singletonPromise, TimeoutError } from "@scrypted/common/src/promise-utils"; import { SettingsMixinDeviceBase, SettingsMixinDeviceOptions } from "@scrypted/common/src/settings-mixin"; -import sdk, { BufferConverter, Camera, DeviceManifest, DeviceProvider, FFmpegInput, MediaObject, MediaObjectOptions, MixinProvider, RequestMediaStreamOptions, RequestPictureOptions, ResponsePictureOptions, ScryptedDevice, ScryptedDeviceType, ScryptedInterface, ScryptedMimeTypes, Setting, Settings, SettingValue, VideoCamera } from "@scrypted/sdk"; +import sdk, { BufferConverter, Camera, DeviceManifest, DeviceProvider, FFmpegInput, HttpRequest, HttpRequestHandler, HttpResponse, MediaObject, MediaObjectOptions, MixinProvider, RequestMediaStreamOptions, RequestPictureOptions, ResponsePictureOptions, ScryptedDevice, ScryptedDeviceType, ScryptedInterface, ScryptedMimeTypes, Setting, Settings, SettingValue, VideoCamera } from "@scrypted/sdk"; import { StorageSettings } from "@scrypted/sdk/storage-settings"; import axios, { AxiosInstance } from "axios"; import https from 'https'; import path from 'path'; -import MimeType from 'whatwg-mimetype'; +import url from 'url'; import { ffmpegFilterImage, ffmpegFilterImageBuffer } from './ffmpeg-image-filter'; -import { ImageReader, ImageReaderNativeId, loadVipsImage, loadSharp } from './image-reader'; +import { ImageConverter, ImageConverterNativeId } from './image-converter'; +import { ImageReader, ImageReaderNativeId, loadSharp, loadVipsImage } from './image-reader'; import { ImageWriter, ImageWriterNativeId } from './image-writer'; -import { parseDims, parseImageOp, processImageOp } from './parse-dims'; const { mediaManager, systemManager } = sdk; @@ -538,20 +538,21 @@ class SnapshotMixin extends SettingsMixinDeviceBase implements Camera { } } -class SnapshotPlugin extends AutoenableMixinProvider implements MixinProvider, BufferConverter, Settings, DeviceProvider { +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(); constructor(nativeId?: string) { super(nativeId); - this.fromMimeType = ScryptedMimeTypes.FFmpegInput; - this.toMimeType = 'image/jpeg'; + this.fromMimeType = ScryptedMimeTypes.SchemePrefix + 'scrypted-media' + ';converter-weight=0'; + this.toMimeType = ScryptedMimeTypes.LocalUrl; const manifest: DeviceManifest = { devices: [ @@ -562,8 +563,16 @@ class SnapshotPlugin extends AutoenableMixinProvider implements MixinProvider, B ], type: ScryptedDeviceType.Builtin, nativeId: ImageWriterNativeId, + }, + { + name: 'Image Converter', + interfaces: [ + ScryptedInterface.BufferConverter, + ], + type: ScryptedDeviceType.Builtin, + nativeId: ImageConverterNativeId, } - ] + ], }; if (loadSharp()) { @@ -585,6 +594,8 @@ class SnapshotPlugin extends AutoenableMixinProvider implements MixinProvider, B } async getDevice(nativeId: string): Promise { + if (nativeId === ImageConverterNativeId) + return new ImageConverter(this, ImageConverterNativeId); if (nativeId === ImageWriterNativeId) return new ImageWriter(ImageWriterNativeId); if (nativeId === ImageReaderNativeId) @@ -607,13 +618,74 @@ class SnapshotPlugin extends AutoenableMixinProvider implements MixinProvider, B return this.console; } + + async getLocalSnapshot(id: string, iface: string, search: string) { + const endpoint = await sdk.endpointManager.getAuthenticatedPath(this.nativeId); + 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 { - const mime = new MimeType(toMimeType); + const url = new URL(data.toString()); + const id = url.hostname; + const path = url.pathname.split('/')[1]; - const op = parseImageOp(mime.parameters); - const ffmpegInput = JSON.parse(data.toString()) as FFmpegInput; + 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.') + } + } - return processImageOp(ffmpegInput, op, parseFloat(mime.parameters.get('time')), options?.sourceId, this.debugConsole); + async onRequest(request: HttpRequest, response: HttpResponse): Promise { + 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; + const rpo: RequestPictureOptions = { + picture: { + width: parseInt(search.get('width')) || undefined, + height: parseInt(search.get('height')) || undefined, + } + }; + + if (mixin && iface === ScryptedInterface.Camera) { + buffer = await mixin.takePictureInternal(rpo) + } + else { + const device = systemManager.getDeviceById(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) { + response.send('', { + code: 500, + }); + } } async canMixin(type: ScryptedDeviceType, interfaces: string[]): Promise { @@ -621,8 +693,9 @@ class SnapshotPlugin extends AutoenableMixinProvider implements MixinProvider, B return [ScryptedInterface.Camera, ScryptedInterface.Settings]; return undefined; } + async getMixin(mixinDevice: any, mixinDeviceInterfaces: ScryptedInterface[], mixinDeviceState: { [key: string]: any; }): Promise { - return new SnapshotMixin(this, { + const ret = new SnapshotMixin(this, { mixinDevice, mixinDeviceInterfaces, mixinDeviceState, @@ -630,6 +703,8 @@ class SnapshotPlugin extends AutoenableMixinProvider implements MixinProvider, B group: 'Snapshot', groupKey: 'snapshot', }); + this.mixinDevices.set(ret.id, ret); + return ret; } async shouldEnableMixin(device: ScryptedDevice) { @@ -642,6 +717,8 @@ class SnapshotPlugin extends AutoenableMixinProvider implements MixinProvider, B } async releaseMixin(id: string, mixinDevice: any): Promise { + if (this.mixinDevices.get(id) === mixinDevice) + this.mixinDevices.delete(id); await mixinDevice.release() } }