mirror of
https://github.com/koush/scrypted.git
synced 2026-02-06 23:42:19 +00:00
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7614d12363 | ||
|
|
3189317b2d | ||
|
|
410d11248f |
4
plugins/snapshot/package-lock.json
generated
4
plugins/snapshot/package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@scrypted/snapshot",
|
||||
"version": "0.0.43",
|
||||
"version": "0.0.45",
|
||||
"lockfileVersion": 2,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@scrypted/snapshot",
|
||||
"version": "0.0.43",
|
||||
"version": "0.0.45",
|
||||
"dependencies": {
|
||||
"@koush/axios-digest-auth": "^0.8.5",
|
||||
"@types/node": "^16.6.1",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@scrypted/snapshot",
|
||||
"version": "0.0.43",
|
||||
"version": "0.0.45",
|
||||
"description": "Snapshot Plugin for Scrypted",
|
||||
"scripts": {
|
||||
"scrypted-setup-project": "scrypted-setup-project",
|
||||
@@ -22,6 +22,7 @@
|
||||
"camera"
|
||||
],
|
||||
"scrypted": {
|
||||
"realfs": true,
|
||||
"name": "Snapshot Plugin",
|
||||
"type": "API",
|
||||
"interfaces": [
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import fs from 'fs';
|
||||
import { addVideoFilterArguments } from '@scrypted/common/src/ffmpeg-helpers';
|
||||
import { ffmpegLogInitialOutput, safeKillFFmpeg, safePrintFFmpegArguments } from '@scrypted/common/src/media-helpers';
|
||||
import { sleep } from '@scrypted/common/src/sleep';
|
||||
@@ -115,7 +116,11 @@ export async function ffmpegFilterImageBuffer(inputJpeg: Buffer, options: FFmpeg
|
||||
input.write(inputJpeg);
|
||||
input.end();
|
||||
|
||||
return ffmpegFilterImageInternal(cp, options);
|
||||
return ffmpegFilterImageInternal(cp, options)
|
||||
.catch(e => {
|
||||
fs.writeFileSync("/tmp/test.jpg", inputJpeg);
|
||||
throw e;
|
||||
})
|
||||
}
|
||||
|
||||
export async function ffmpegFilterImage(inputArguments: string[], options: FFmpegImageFilterOptions) {
|
||||
@@ -168,14 +173,16 @@ export async function ffmpegFilterImageInternal(cp: ChildProcess, options: FFmpe
|
||||
cp.stdio[3].on('data', data => buffers.push(data));
|
||||
|
||||
const to = options.timeout ? setTimeout(() => {
|
||||
console.log('ffmpeg stream to image conversion timed out.');
|
||||
console.log('ffmpeg input to image conversion timed out.');
|
||||
safeKillFFmpeg(cp);
|
||||
}, 10000) : undefined;
|
||||
|
||||
const [exitCode] = await once(cp, 'exit');
|
||||
const exit = once(cp, 'exit');
|
||||
await once(cp.stdio[3], 'end').catch(() => {});
|
||||
const [exitCode] = await exit;
|
||||
clearTimeout(to);
|
||||
if (exitCode && !buffers.length)
|
||||
throw new Error(`ffmpeg stream to image conversion failed with exit code: ${exitCode}`);
|
||||
throw new Error(`ffmpeg input to image conversion failed with exit code: ${exitCode}, ${cp.spawnargs.join(' ')}`);
|
||||
|
||||
return Buffer.concat(buffers);
|
||||
}
|
||||
@@ -207,7 +214,7 @@ export async function ffmpegFilterImageStream(cp: ChildProcess, options: FFmpegI
|
||||
if (last)
|
||||
resolve(last);
|
||||
else
|
||||
reject(new Error(`ffmpeg stream to image conversion failed with exit code: ${exitCode}`));
|
||||
reject(new Error(`ffmpeg stream to image conversion failed with exit code: ${exitCode}, ${cp.spawnargs.join(' ')}`));
|
||||
})
|
||||
});
|
||||
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import AxiosDigestAuth from '@koush/axios-digest-auth';
|
||||
import { AutoenableMixinProvider } from "@scrypted/common/src/autoenable-mixin-provider";
|
||||
import { createMapPromiseDebouncer, RefreshPromise, singletonPromise, TimeoutError, timeoutPromise } from "@scrypted/common/src/promise-utils";
|
||||
import { StorageSettings } from "@scrypted/sdk/storage-settings";
|
||||
import { createMapPromiseDebouncer, RefreshPromise, singletonPromise, TimeoutError } from "@scrypted/common/src/promise-utils";
|
||||
import { SettingsMixinDeviceBase, SettingsMixinDeviceOptions } from "@scrypted/common/src/settings-mixin";
|
||||
import sdk, { BufferConverter, BufferConvertorOptions, Camera, FFmpegInput, MediaObject, MixinProvider, RequestMediaStreamOptions, RequestPictureOptions, ResponsePictureOptions, ScryptedDevice, ScryptedDeviceType, ScryptedInterface, ScryptedMimeTypes, Setting, Settings, SettingValue, VideoCamera } from "@scrypted/sdk";
|
||||
import axios, { Axios } from "axios";
|
||||
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';
|
||||
@@ -72,22 +72,12 @@ class SnapshotMixin extends SettingsMixinDeviceBase<Camera> implements Camera {
|
||||
},
|
||||
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.',
|
||||
type: 'boolean',
|
||||
defaultValue: !this.mixinDeviceInterfaces.includes(ScryptedInterface.Camera),
|
||||
},
|
||||
snapshotMode: {
|
||||
title: 'Snapshot Mode',
|
||||
description: 'Set the snapshot mode to accomodate cameras with slow snapshots that may hang HomeKit.\nSetting the mode to "Never Wait" will only use recently available snapshots.\nSetting the mode to "Timeout" will cancel slow snapshots.',
|
||||
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',
|
||||
'Never Wait',
|
||||
'Timeout',
|
||||
'Enabled',
|
||||
'Disabled',
|
||||
],
|
||||
mapGet(value) {
|
||||
// renamed the setting value.
|
||||
return value === 'Normal' ? 'Default' : value;
|
||||
},
|
||||
defaultValue: 'Default',
|
||||
},
|
||||
snapshotResolution: {
|
||||
@@ -107,7 +97,6 @@ class SnapshotMixin extends SettingsMixinDeviceBase<Camera> implements Camera {
|
||||
type: 'clippath',
|
||||
},
|
||||
});
|
||||
axiosClient: Axios | AxiosDigestAuth;
|
||||
snapshotDebouncer = createMapPromiseDebouncer<Buffer>();
|
||||
errorPicture: RefreshPromise<Buffer>;
|
||||
timeoutPicture: RefreshPromise<Buffer>;
|
||||
@@ -117,6 +106,7 @@ class SnapshotMixin extends SettingsMixinDeviceBase<Camera> implements Camera {
|
||||
lastErrorImagesClear = 0;
|
||||
static lastGeneratedErrorImageTime = 0;
|
||||
lastAvailablePicture: Buffer;
|
||||
psos: ResponsePictureOptions[];
|
||||
|
||||
constructor(public plugin: SnapshotPlugin, options: SettingsMixinDeviceOptions<Camera>) {
|
||||
super(options);
|
||||
@@ -132,7 +122,40 @@ class SnapshotMixin extends SettingsMixinDeviceBase<Camera> implements Camera {
|
||||
let needSoftwareResize = !!(options?.picture?.width || options?.picture?.height);
|
||||
|
||||
let takePicture: (options?: RequestPictureOptions) => Promise<Buffer>;
|
||||
if (this.storageSettings.values.snapshotsFromPrebuffer) {
|
||||
const { snapshotsFromPrebuffer } = this.storageSettings.values;
|
||||
let usePrebufferSnapshots: boolean;
|
||||
switch (snapshotsFromPrebuffer) {
|
||||
case 'true':
|
||||
case 'Enabled':
|
||||
usePrebufferSnapshots = true;
|
||||
break;
|
||||
case 'Disabled':
|
||||
usePrebufferSnapshots = false;
|
||||
break;
|
||||
default:
|
||||
if (!this.mixinDeviceInterfaces.includes(ScryptedInterface.Camera))
|
||||
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;
|
||||
try {
|
||||
const realDevice = systemManager.getDeviceById<VideoCamera>(this.id);
|
||||
const msos = await realDevice.getVideoStreamOptions();
|
||||
@@ -148,114 +171,124 @@ class SnapshotMixin extends SettingsMixinDeviceBase<Camera> implements Camera {
|
||||
request.prebuffer = eventSnapshot ? 1000 : 6000;
|
||||
if (this.lastAvailablePicture)
|
||||
request.refresh = false;
|
||||
takePicture = async () => mediaManager.convertMediaObjectToBuffer(await realDevice.getVideoStream(request), 'image/jpeg');
|
||||
this.console.log('snapshotting active prebuffer');
|
||||
takePrebufferPicture = async () => {
|
||||
// this.console.log('snapshotting active prebuffer');
|
||||
return mediaManager.convertMediaObjectToBuffer(await realDevice.getVideoStream(request), 'image/jpeg');
|
||||
};
|
||||
return takePrebufferPicture;
|
||||
}
|
||||
}
|
||||
catch (e) {
|
||||
}
|
||||
}
|
||||
|
||||
if (usePrebufferSnapshots) {
|
||||
takePicture = await preparePrebufferSnapshot();
|
||||
}
|
||||
|
||||
if (!takePicture) {
|
||||
if (!this.storageSettings.values.snapshotUrl) {
|
||||
if (this.mixinDeviceInterfaces.includes(ScryptedInterface.Camera)) {
|
||||
takePicture = async (options?: RequestPictureOptions) => {
|
||||
const internalTakePicture = async () => {
|
||||
if (!options?.id && this.storageSettings.values.defaultSnapshotChannel !== 'Camera Default') {
|
||||
try {
|
||||
if (!psos)
|
||||
psos = await this.mixinDevice.getPictureOptions();
|
||||
const pso = psos.find(pso => pso.name === this.storageSettings.values.defaultSnapshotChannel);
|
||||
if (!options)
|
||||
options = {};
|
||||
options.id = pso.id;
|
||||
}
|
||||
catch (e) {
|
||||
}
|
||||
}
|
||||
return this.mixinDevice.takePicture(options).then(mo => mediaManager.convertMediaObjectToBuffer(mo, 'image/jpeg'))
|
||||
}
|
||||
if (this.storageSettings.values.snapshotUrl) {
|
||||
let username: string;
|
||||
let password: string;
|
||||
|
||||
// full resolution setging ignores resize.
|
||||
if (this.storageSettings.values.snapshotResolution === 'Full Resolution') {
|
||||
if (options)
|
||||
options.picture = undefined;
|
||||
return internalTakePicture();
|
||||
}
|
||||
|
||||
// if resize wasn't requested, continue as normal.
|
||||
const resizeRequested = !!options?.picture;
|
||||
if (!resizeRequested)
|
||||
return internalTakePicture();
|
||||
|
||||
// resize was requested
|
||||
|
||||
// crop and scale needs to operate on the full resolution image.
|
||||
if (this.storageSettings.values.snapshotCropScale?.length) {
|
||||
options.picture = undefined;
|
||||
// resize after the cop and scale.
|
||||
needSoftwareResize = resizeRequested;
|
||||
return internalTakePicture();
|
||||
}
|
||||
|
||||
// determine see if that can be handled by camera hardware
|
||||
let psos: ResponsePictureOptions[];
|
||||
try {
|
||||
if (!psos)
|
||||
psos = await this.mixinDevice.getPictureOptions();
|
||||
if (!psos?.[0]?.canResize) {
|
||||
needSoftwareResize = true;
|
||||
}
|
||||
}
|
||||
catch (e) {
|
||||
}
|
||||
|
||||
if (needSoftwareResize)
|
||||
options.picture = undefined;
|
||||
|
||||
return internalTakePicture();
|
||||
};
|
||||
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();
|
||||
}
|
||||
else if (this.storageSettings.values.snapshotsFromPrebuffer) {
|
||||
takePicture = async () => {
|
||||
throw new PrebufferUnavailableError();
|
||||
}
|
||||
|
||||
let axiosClient: AxiosDigestAuth | AxiosInstance;
|
||||
if (username && password) {
|
||||
axiosClient = new AxiosDigestAuth({
|
||||
username,
|
||||
password,
|
||||
});
|
||||
}
|
||||
else {
|
||||
takePicture = () => {
|
||||
throw new Error('Snapshot Unavailable (snapshotUrl empty)');
|
||||
}
|
||||
}
|
||||
}
|
||||
else {
|
||||
if (!this.axiosClient) {
|
||||
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();
|
||||
}
|
||||
|
||||
if (username && password) {
|
||||
this.axiosClient = new AxiosDigestAuth({
|
||||
username,
|
||||
password,
|
||||
});
|
||||
}
|
||||
else {
|
||||
this.axiosClient = axios;
|
||||
}
|
||||
axiosClient = axios;
|
||||
}
|
||||
|
||||
takePicture = () => this.axiosClient.request({
|
||||
takePicture = () => axiosClient.request({
|
||||
httpsAgent,
|
||||
method: "GET",
|
||||
responseType: 'arraybuffer',
|
||||
url: this.storageSettings.values.snapshotUrl,
|
||||
}).then(async (response: { data: any; }) => response.data);
|
||||
}
|
||||
else if (this.mixinDeviceInterfaces.includes(ScryptedInterface.Camera)) {
|
||||
takePicture = async (options?: RequestPictureOptions) => {
|
||||
const internalTakePicture = async () => {
|
||||
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);
|
||||
if (!options)
|
||||
options = {};
|
||||
options.id = pso.id;
|
||||
}
|
||||
catch (e) {
|
||||
}
|
||||
}
|
||||
return this.mixinDevice.takePicture(options).then(mo => mediaManager.convertMediaObjectToBuffer(mo, 'image/jpeg'))
|
||||
}
|
||||
|
||||
// full resolution setging ignores resize.
|
||||
if (this.storageSettings.values.snapshotResolution === 'Full Resolution') {
|
||||
if (options)
|
||||
options.picture = undefined;
|
||||
return internalTakePicture();
|
||||
}
|
||||
|
||||
// if resize wasn't requested, continue as normal.
|
||||
const resizeRequested = !!options?.picture;
|
||||
if (!resizeRequested)
|
||||
return internalTakePicture();
|
||||
|
||||
// resize was requested
|
||||
|
||||
// crop and scale needs to operate on the full resolution image.
|
||||
if (this.storageSettings.values.snapshotCropScale?.length) {
|
||||
options.picture = undefined;
|
||||
// resize after the cop and scale.
|
||||
needSoftwareResize = resizeRequested;
|
||||
return internalTakePicture();
|
||||
}
|
||||
|
||||
// determine see if that can be handled by camera hardware
|
||||
try {
|
||||
const psos = await this.getPictureOptions();
|
||||
if (!psos?.[0]?.canResize) {
|
||||
needSoftwareResize = true;
|
||||
}
|
||||
}
|
||||
catch (e) {
|
||||
}
|
||||
|
||||
if (needSoftwareResize)
|
||||
options.picture = undefined;
|
||||
|
||||
return internalTakePicture()
|
||||
.catch(async e => {
|
||||
// the camera snapshot failed, try to fallback to prebuffer snapshot.
|
||||
if (usePrebufferSnapshots === false)
|
||||
throw e;
|
||||
const fallback = await preparePrebufferSnapshot();
|
||||
if (!fallback)
|
||||
throw e;
|
||||
return fallback();
|
||||
})
|
||||
};
|
||||
}
|
||||
else if (usePrebufferSnapshots) {
|
||||
takePicture = async () => {
|
||||
throw new PrebufferUnavailableError();
|
||||
}
|
||||
}
|
||||
else {
|
||||
takePicture = () => {
|
||||
throw new Error('Snapshot Unavailable (snapshotUrl empty)');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const pendingPicture = this.snapshotDebouncer(options, async () => {
|
||||
@@ -286,34 +319,20 @@ class SnapshotMixin extends SettingsMixinDeviceBase<Camera> implements Camera {
|
||||
}, 60000);
|
||||
}
|
||||
catch (e) {
|
||||
this.console.error('Snapshot failed', e);
|
||||
// do not mask event snapshots, as they're used for detections and not
|
||||
// user facing display.
|
||||
if (eventSnapshot)
|
||||
throw e;
|
||||
// allow reusing the current picture to mask errors
|
||||
picture = await this.createErrorImage(e);
|
||||
}
|
||||
return picture;
|
||||
});
|
||||
|
||||
let { snapshotMode } = this.storageSettings.values;
|
||||
if (eventSnapshot) {
|
||||
// event snapshots must be fulfilled
|
||||
snapshotMode = 'Default';
|
||||
}
|
||||
else if (snapshotMode === 'Never Wait' && !options?.periodicRequest) {
|
||||
// non periodic snapshots should use a short timeout.
|
||||
snapshotMode = 'Timeout';
|
||||
}
|
||||
|
||||
let data: Buffer;
|
||||
try {
|
||||
switch (snapshotMode) {
|
||||
case 'Never Wait':
|
||||
throw new NeverWaitError();
|
||||
case 'Timeout':
|
||||
data = await timeoutPromise(1000, pendingPicture);
|
||||
break;
|
||||
default:
|
||||
data = await pendingPicture;
|
||||
break;
|
||||
}
|
||||
data = await pendingPicture;
|
||||
}
|
||||
catch (e) {
|
||||
// allow reusing the current picture to mask errors
|
||||
@@ -443,7 +462,9 @@ class SnapshotMixin extends SettingsMixinDeviceBase<Camera> implements Camera {
|
||||
}
|
||||
|
||||
async getPictureOptions() {
|
||||
return this.mixinDevice.getPictureOptions();
|
||||
if (!this.psos)
|
||||
this.psos = await this.mixinDevice.getPictureOptions();
|
||||
return this.psos;
|
||||
}
|
||||
|
||||
getMixinSettings(): Promise<Setting[]> {
|
||||
|
||||
4
server/package-lock.json
generated
4
server/package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@scrypted/server",
|
||||
"version": "0.6.22",
|
||||
"version": "0.6.23",
|
||||
"lockfileVersion": 2,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@scrypted/server",
|
||||
"version": "0.6.22",
|
||||
"version": "0.6.23",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@ffmpeg-installer/ffmpeg": "^1.1.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@scrypted/server",
|
||||
"version": "0.6.23",
|
||||
"version": "0.6.24",
|
||||
"description": "",
|
||||
"dependencies": {
|
||||
"@ffmpeg-installer/ffmpeg": "^1.1.0",
|
||||
|
||||
@@ -196,7 +196,7 @@ async function start() {
|
||||
const checkToken = (token: string) => {
|
||||
if (process.env.SCRYPTED_ADMIN_USERNAME && process.env.SCRYPTED_ADMIN_TOKEN === token) {
|
||||
res.locals.username = process.env.SCRYPTED_ADMIN_USERNAME;
|
||||
res.locals.aclId = process.env.SCRYPTED_ADMIN_TOKEN;
|
||||
res.locals.aclId = undefined;
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -550,8 +550,31 @@ async function start() {
|
||||
await checkResetLogin();
|
||||
|
||||
const hostname = os.hostname()?.split('.')?.[0];
|
||||
|
||||
const addresses = ((await scrypted.addressSettings.getLocalAddresses()) || getHostAddresses(true, true)).map(address => `https://${address}:${SCRYPTED_SECURE_PORT}`);
|
||||
|
||||
// env/header based admin login
|
||||
if (res.locals.username && res.locals.username === process.env.SCRYPTED_ADMIN_USERNAME) {
|
||||
res.send({
|
||||
username: res.locals.username,
|
||||
token: process.env.SCRYPTED_ADMIN_TOKEN,
|
||||
addresses,
|
||||
hostname,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// env based anon admin login
|
||||
if (process.env.SCRYPTED_DISABLE_AUTHENTICATION === 'true') {
|
||||
res.send({
|
||||
expiration: ONE_DAY_MILLISECONDS,
|
||||
username: 'anonymous',
|
||||
addresses,
|
||||
hostname,
|
||||
})
|
||||
return;
|
||||
}
|
||||
|
||||
// basic auth
|
||||
if (req.protocol === 'https' && req.headers.authorization) {
|
||||
const username = await new Promise(resolve => {
|
||||
const basicChecker = basicAuth.check((req) => {
|
||||
@@ -576,16 +599,7 @@ async function start() {
|
||||
return;
|
||||
}
|
||||
|
||||
if (process.env.SCRYPTED_DISABLE_AUTHENTICATION === 'true') {
|
||||
res.send({
|
||||
expiration: ONE_DAY_MILLISECONDS,
|
||||
username: 'anonymous',
|
||||
addresses,
|
||||
hostname,
|
||||
})
|
||||
return;
|
||||
}
|
||||
|
||||
// cookie auth
|
||||
try {
|
||||
const login_user_token = getSignedLoginUserTokenRawValue(req);
|
||||
if (!login_user_token)
|
||||
|
||||
Reference in New Issue
Block a user