diff --git a/common/src/autoconfigure-codecs.ts b/common/src/autoconfigure-codecs.ts new file mode 100644 index 000000000..a06c07065 --- /dev/null +++ b/common/src/autoconfigure-codecs.ts @@ -0,0 +1,163 @@ +import { MediaStreamConfiguration, MediaStreamDestination, MediaStreamOptions, Setting } from "@scrypted/sdk"; + +export const automaticallyConfigureSettings: Setting = { + key: 'autoconfigure', + title: 'Automatically Configure Settings', + description: 'Automatically configure and valdiate the camera codecs and other settings for optimal Scrypted performance. Some settings will require manual configuration via the camera web admin.', + type: 'boolean', + value: true, +}; + +const MEGABIT = 1024 * 1000; + +function getBitrateForResolution(resolution: number) { + if (resolution >= 3840 * 2160) + return 8 * MEGABIT; + if (resolution >= 2688 * 1520) + return 3 * MEGABIT; + if (resolution >= 1920 * 1080) + return 2 * MEGABIT; + if (resolution >= 1280 * 720) + return MEGABIT; + if (resolution >= 640 * 480) + return MEGABIT / 2; + return MEGABIT / 4; +} + +export async function autoconfigureCodecs(getCodecs: () => Promise, configureCodecs: (options: MediaStreamOptions) => Promise) { + const codecs = await getCodecs(); + const configurable: MediaStreamConfiguration[] = []; + for (const codec of codecs) { + const config = await configureCodecs( { + id: codec.id, + }); + configurable.push(config); + } + + const used: MediaStreamConfiguration[] = []; + + for (const _ of ['local', 'remote', 'low-resolution'] as MediaStreamDestination[]) { + // find stream with the highest configurable resolution. + let highest: [MediaStreamConfiguration, number] = [undefined, 0]; + for (const codec of configurable) { + if (used.includes(codec)) + continue; + for (const resolution of codec.video.resolutions) { + if (resolution[0] * resolution[1] > highest[1]) { + highest = [codec, resolution[0] * resolution[1]]; + } + } + } + + const config = highest[0]; + if (!config) + break; + + used.push(config); + } + + const findResolutionTarget = (config: MediaStreamConfiguration, width: number, height: number) => { + let diff = 999999999; + let ret: [number, number]; + + for (const res of config.video.resolutions) { + const d = Math.abs(res[0] - width) + Math.abs(res[1] - height); + if (d < diff) { + diff = d; + ret = res; + } + } + + return ret; + } + + // find the highest resolution + const l = used[0]; + const resolution = findResolutionTarget(l, 8192, 8192); + + // get the fps of 20 or highest available + let fps = Math.min(20, Math.max(...l.video.fpsRange)); + + await configureCodecs({ + id: l.id, + video: { + width: resolution[0], + height: resolution[1], + bitrateControl: 'variable', + codec: 'h264', + bitrate: getBitrateForResolution(resolution[0] * resolution[1]), + fps, + keyframeInterval: fps * 4, + quality: 5, + profile: 'main', + }, + }); + + if (used.length === 3) { + // find remote and low + const r = used[1]; + const l = used[2]; + + const rResolution = findResolutionTarget(r, 1280, 720); + const lResolution = findResolutionTarget(l, 640, 360); + + fps = Math.min(20, Math.max(...r.video.fpsRange)); + await configureCodecs({ + id: r.id, + video: { + width: rResolution[0], + height: rResolution[1], + bitrateControl: 'variable', + codec: 'h264', + bitrate: 1 * MEGABIT, + fps, + keyframeInterval: fps * 4, + quality: 5, + profile: 'main', + }, + }); + + fps = Math.min(20, Math.max(...l.video.fpsRange)); + await configureCodecs( { + id: l.id, + video: { + width: lResolution[0], + height: lResolution[1], + bitrateControl: 'variable', + codec: 'h264', + bitrate: MEGABIT / 2, + fps, + keyframeInterval: fps * 4, + quality: 5, + profile: 'main', + }, + }); + } + else if (used.length == 2) { + let target: [number, number]; + if (resolution[0] * resolution[1] > 1920 * 1080) + target = [1280, 720]; + else + target = [640, 360]; + + const rResolution = findResolutionTarget(used[1], target[0], target[1]); + const fps = Math.min(20, Math.max(...used[1].video.fpsRange)); + await configureCodecs({ + id: used[1].id, + video: { + width: rResolution[0], + height: rResolution[1], + bitrateControl: 'variable', + codec: 'h264', + bitrate: getBitrateForResolution(rResolution[0] * rResolution[1]), + fps, + keyframeInterval: fps * 4, + quality: 5, + profile: 'main', + }, + }); + } + else if (used.length === 1) { + // no nop + } +} \ No newline at end of file diff --git a/plugins/amcrest/src/main.ts b/plugins/amcrest/src/main.ts index 861909ba3..f694cfed5 100644 --- a/plugins/amcrest/src/main.ts +++ b/plugins/amcrest/src/main.ts @@ -110,11 +110,11 @@ class AmcrestCamera extends RtspSmartCamera implements VideoCameraConfiguration, this.info = deviceInfo; } - async setVideoStreamOptions(options: MediaStreamOptions): Promise { + async setVideoStreamOptions(options: MediaStreamOptions) { if (!options.id?.startsWith('channel')) throw new Error('invalid id'); const channel = parseInt(this.getRtspChannel()) || 1; - const formatNumber = parseInt(options.id?.substring('channel'.length)) - 1; + const formatNumber = Math.max(0, parseInt(options.id?.substring('channel'.length)) - 1); const format = options.id === 'channel0' ? 'MainFormat' : 'ExtraFormat'; const encode = `Encode[${channel - 1}].${format}[${formatNumber}]`; const params = new URLSearchParams(); @@ -128,6 +128,15 @@ class AmcrestCamera extends RtspSmartCamera implements VideoCameraConfiguration, if (options.video?.codec === 'h264') { params.set(`${encode}.Video.Compression`, 'H.264'); } + if (options.video?.profile) { + let profile = 'Main'; + if (options.video.profile === 'high') + profile = 'High'; + else if (options.video.profile === 'baseline') + profile = 'Baseline'; + params.set(`${encode}.Video.Profile`, profile); + + } if (options.video?.codec === 'h265') { params.set(`${encode}.Video.Compression`, 'H.265'); } @@ -136,12 +145,12 @@ class AmcrestCamera extends RtspSmartCamera implements VideoCameraConfiguration, } if (options.video?.fps) { params.set(`${encode}.Video.FPS`, options.video.fps.toString()); - if (options.video?.idrIntervalMillis) { - params.set(`${encode}.Video.GOP`, (options.video.fps * options.video?.idrIntervalMillis / 1000).toString()); - } + } + if (options.video?.keyframeInterval) { + params.set(`${encode}.Video.GOP`, options.video?.keyframeInterval.toString()); } if (options.video?.bitrateControl) { - params.set(`${encode}.Video.BitRateControl`, options.video.bitrateControl === 'variable' ? 'VBR' : 'CBR'); + params.set(`${encode}.Video.BitRateControl`, options.video.bitrateControl === 'constant' ? 'CBR' : 'VBR'); } if (![...params.keys()].length) @@ -152,6 +161,7 @@ class AmcrestCamera extends RtspSmartCamera implements VideoCameraConfiguration, responseType: 'text', }); this.console.log('reconfigure result', response.body); + return undefined; } getClient() { @@ -284,6 +294,7 @@ class AmcrestCamera extends RtspSmartCamera implements VideoCameraConfiguration, const ret = await super.getOtherSettings(); ret.push( { + subgroup: 'Advanced', title: 'Doorbell Type', choices: [ 'Not a Doorbell', @@ -354,6 +365,7 @@ class AmcrestCamera extends RtspSmartCamera implements VideoCameraConfiguration, ret.push( { + subgroup: 'Advanced', title: 'Two Way Audio', value: twoWayAudio, key: 'twoWayAudio', diff --git a/plugins/amcrest/tsconfig.json b/plugins/amcrest/tsconfig.json index 34a847ad8..ba9b4d395 100644 --- a/plugins/amcrest/tsconfig.json +++ b/plugins/amcrest/tsconfig.json @@ -1,6 +1,6 @@ { "compilerOptions": { - "module": "commonjs", + "module": "Node16", "target": "ES2021", "resolveJsonModule": true, "moduleResolution": "Node16", diff --git a/plugins/ffmpeg-camera/src/common.ts b/plugins/ffmpeg-camera/src/common.ts index 9432eb06b..bc46d1136 100644 --- a/plugins/ffmpeg-camera/src/common.ts +++ b/plugins/ffmpeg-camera/src/common.ts @@ -149,6 +149,7 @@ export abstract class CameraProviderBase e getInterfaces() { return [ + ScryptedInterface.VideoCameraConfiguration, ScryptedInterface.VideoCamera, ScryptedInterface.Settings, ...this.getAdditionalInterfaces() diff --git a/plugins/ffmpeg-camera/src/main.ts b/plugins/ffmpeg-camera/src/main.ts index d34fd49aa..17c9e881c 100644 --- a/plugins/ffmpeg-camera/src/main.ts +++ b/plugins/ffmpeg-camera/src/main.ts @@ -124,6 +124,21 @@ class FFmpegCamera extends CameraBase { return mediaManager.createFFmpegMediaObject(ret); } + isAudioDisabled() { + return this.storage.getItem('noAudio') === 'true'; + } + + async getOtherSettings(): Promise { + return [ + { + key: 'noAudio', + title: 'No Audio', + description: 'Enable this setting if the camera does not have audio or to mute audio.', + type: 'boolean', + value: (this.isAudioDisabled()).toString(), + }, + ] + } } class FFmpegProvider extends CameraProviderBase { diff --git a/plugins/ffmpeg-camera/tsconfig.json b/plugins/ffmpeg-camera/tsconfig.json index 34a847ad8..ba9b4d395 100644 --- a/plugins/ffmpeg-camera/tsconfig.json +++ b/plugins/ffmpeg-camera/tsconfig.json @@ -1,6 +1,6 @@ { "compilerOptions": { - "module": "commonjs", + "module": "Node16", "target": "ES2021", "resolveJsonModule": true, "moduleResolution": "Node16", diff --git a/plugins/hikvision/src/hikvision-api-capabilities.ts b/plugins/hikvision/src/hikvision-api-capabilities.ts new file mode 100644 index 000000000..20cf9dac4 --- /dev/null +++ b/plugins/hikvision/src/hikvision-api-capabilities.ts @@ -0,0 +1,401 @@ +export interface CapabiltiesResponse { + StreamingChannel: StreamingChannel +} + +export interface StreamingChannel { + $: GeneratedType + id: Id[] + channelName: ChannelName[] + enabled: Enabled[] + Transport: Transport[] + Video: Video[] + Audio: Audio[] + isSpportDynamicCapWithCondition: string[] +} + +export interface GeneratedType { + version: string + xmlns: string +} + +export interface Id { + _: string + $: GeneratedType2 +} + +export interface GeneratedType2 { + opt: string +} + +export interface ChannelName { + _: string + $: GeneratedType3 +} + +export interface GeneratedType3 { + min: string + max: string +} + +export interface Enabled { + _: string + $: GeneratedType4 +} + +export interface GeneratedType4 { + opt: string +} + +export interface Transport { + maxPacketSize: MaxPacketSize[] + ControlProtocolList: ControlProtocolList[] + Multicast: Multicast[] + Unicast: Unicast[] + Security: Security[] +} + +export interface MaxPacketSize { + _: string + $: GeneratedType5 +} + +export interface GeneratedType5 { + opt: string +} + +export interface ControlProtocolList { + ControlProtocol: ControlProtocol[] +} + +export interface ControlProtocol { + streamingTransport: StreamingTransport[] +} + +export interface StreamingTransport { + _: string + $: GeneratedType6 +} + +export interface GeneratedType6 { + opt: string +} + +export interface Multicast { + enabled: Enabled2[] + videoDestPortNo: VideoDestPortNo[] + audioDestPortNo: AudioDestPortNo[] +} + +export interface Enabled2 { + $: GeneratedType7 +} + +export interface GeneratedType7 { + opt: string +} + +export interface VideoDestPortNo { + $: GeneratedType8 +} + +export interface GeneratedType8 { + min: string + max: string + default: string +} + +export interface AudioDestPortNo { + $: GeneratedType9 +} + +export interface GeneratedType9 { + min: string + max: string + default: string +} + +export interface Unicast { + enabled: Enabled3[] + rtpTransportType: RtpTransportType[] +} + +export interface Enabled3 { + _: string + $: GeneratedType10 +} + +export interface GeneratedType10 { + opt: string +} + +export interface RtpTransportType { + _: string + $: GeneratedType11 +} + +export interface GeneratedType11 { + opt: string +} + +export interface Security { + enabled: Enabled4[] + certificateType: CertificateType[] + SecurityAlgorithm: SecurityAlgorithm[] +} + +export interface Enabled4 { + _: string + $: GeneratedType12 +} + +export interface GeneratedType12 { + opt: string +} + +export interface CertificateType { + _: string + $: GeneratedType13 +} + +export interface GeneratedType13 { + opt: string +} + +export interface SecurityAlgorithm { + algorithmType: AlgorithmType[] +} + +export interface AlgorithmType { + $: GeneratedType14 +} + +export interface GeneratedType14 { + opt: string +} + +export interface Video { + enabled: Enabled5[] + videoInputChannelID: VideoInputChannelId[] + videoCodecType: VideoCodecType[] + videoScanType: VideoScanType[] + videoResolutionWidth: VideoResolutionWidth[] + videoResolutionHeight: VideoResolutionHeight[] + videoQualityControlType: VideoQualityControlType[] + constantBitRate: ConstantBitRate[] + fixedQuality: FixedQuality[] + vbrUpperCap: VbrUpperCap[] + vbrLowerCap: string[] + maxFrameRate: MaxFrameRate[] + keyFrameInterval: KeyFrameInterval[] + snapShotImageType: SnapShotImageType[] + H264Profile: H264Profile[] + GovLength: GovLength[] + SVC: Svc[] + smoothing: Smoothing[] + H265Profile: H265Profile[] +} + +export interface Enabled5 { + _: string + $: GeneratedType15 +} + +export interface GeneratedType15 { + opt: string +} + +export interface VideoInputChannelId { + _: string + $: GeneratedType16 +} + +export interface GeneratedType16 { + opt: string +} + +export interface VideoCodecType { + _: string + $: GeneratedType17 +} + +export interface GeneratedType17 { + opt: string +} + +export interface VideoScanType { + _: string + $: GeneratedType18 +} + +export interface GeneratedType18 { + opt: string +} + +export interface VideoResolutionWidth { + _: string + $: GeneratedType19 +} + +export interface GeneratedType19 { + opt: string +} + +export interface VideoResolutionHeight { + _: string + $: GeneratedType20 +} + +export interface GeneratedType20 { + opt: string +} + +export interface VideoQualityControlType { + _: string + $: GeneratedType21 +} + +export interface GeneratedType21 { + opt: string +} + +export interface ConstantBitRate { + _: string + $: GeneratedType22 +} + +export interface GeneratedType22 { + min: string + max: string +} + +export interface FixedQuality { + _: string + $: GeneratedType23 +} + +export interface GeneratedType23 { + opt: string +} + +export interface VbrUpperCap { + _: string + $: GeneratedType24 +} + +export interface GeneratedType24 { + min: string + max: string +} + +export interface MaxFrameRate { + _: string + $: GeneratedType25 +} + +export interface GeneratedType25 { + opt: string +} + +export interface KeyFrameInterval { + _: string + $: GeneratedType26 +} + +export interface GeneratedType26 { + min: string + max: string +} + +export interface SnapShotImageType { + _: string + $: GeneratedType27 +} + +export interface GeneratedType27 { + opt: string +} + +export interface H264Profile { + _: string + $: GeneratedType28 +} + +export interface GeneratedType28 { + opt: string +} + +export interface GovLength { + _: string + $: GeneratedType29 +} + +export interface GeneratedType29 { + min: string + max: string +} + +export interface Svc { + enabled: Enabled6[] + SVCMode: Svcmode[] +} + +export interface Enabled6 { + _: string + $: GeneratedType30 +} + +export interface GeneratedType30 { + opt: string +} + +export interface Svcmode { + _: string + $: GeneratedType31 +} + +export interface GeneratedType31 { + opt: string +} + +export interface Smoothing { + _: string + $: GeneratedType32 +} + +export interface GeneratedType32 { + min: string + max: string +} + +export interface H265Profile { + _: string + $: GeneratedType33 +} + +export interface GeneratedType33 { + opt: string +} + +export interface Audio { + enabled: Enabled7[] + audioInputChannelID: string[] + audioCompressionType: AudioCompressionType[] +} + +export interface Enabled7 { + _: string + $: GeneratedType34 +} + +export interface GeneratedType34 { + opt: string +} + +export interface AudioCompressionType { + _: string + $: GeneratedType35 +} + +export interface GeneratedType35 { + opt: string +} diff --git a/plugins/hikvision/src/hikvision-api-interfaces.ts b/plugins/hikvision/src/hikvision-api-channels.ts similarity index 74% rename from plugins/hikvision/src/hikvision-api-interfaces.ts rename to plugins/hikvision/src/hikvision-api-channels.ts index 88d1fd346..ff5776b2f 100644 --- a/plugins/hikvision/src/hikvision-api-interfaces.ts +++ b/plugins/hikvision/src/hikvision-api-channels.ts @@ -1,4 +1,5 @@ import { HttpFetchOptions } from '@scrypted/common/src/http-auth-fetch'; +import { MediaStreamConfiguration, MediaStreamOptions } from '@scrypted/sdk'; import { Readable } from 'stream'; import { Destroyable } from '../../rtsp/src/rtsp'; @@ -18,4 +19,6 @@ export interface HikvisionAPI { checkStreamSetup(channel: string, isOld: boolean): Promise; jpegSnapshot(channel: string, timeout: number): Promise; listenEvents(): Promise; + getCodecs(camNumber: string): Promise; + configureCodecs(camNumber: string, channelNumber: string, options: MediaStreamOptions): Promise; } \ No newline at end of file diff --git a/plugins/hikvision/src/hikvision-autoconfigure.ts b/plugins/hikvision/src/hikvision-autoconfigure.ts new file mode 100644 index 000000000..6793108b6 --- /dev/null +++ b/plugins/hikvision/src/hikvision-autoconfigure.ts @@ -0,0 +1,12 @@ +import { autoconfigureCodecs as ac } from '../../../common/src/autoconfigure-codecs'; +import { HikvisionAPI } from './hikvision-api-channels'; + +export async function autoconfigureSettings(client: HikvisionAPI, camNumber: string) { + return ac( + () => client.getCodecs(camNumber), + (options) => { + const channelNumber = options.id.substring(1); + return client.configureCodecs(camNumber, channelNumber, options) + } + ); +} diff --git a/plugins/hikvision/src/hikvision-camera-api.ts b/plugins/hikvision/src/hikvision-camera-api.ts index 89cb4cdbe..de3799f0f 100644 --- a/plugins/hikvision/src/hikvision-camera-api.ts +++ b/plugins/hikvision/src/hikvision-camera-api.ts @@ -1,13 +1,16 @@ -import { HikvisionCameraStreamSetup, HikvisionAPI } from "./hikvision-api-interfaces" import { AuthFetchCredentialState, HttpFetchOptions, authHttpFetch } from '@scrypted/common/src/http-auth-fetch'; import { readLine } from '@scrypted/common/src/read-stream'; import { parseHeaders, readBody, readMessage } from '@scrypted/common/src/rtsp-server'; +import { MediaStreamConfiguration, MediaStreamOptions } from "@scrypted/sdk"; import contentType from 'content-type'; import { IncomingMessage } from 'http'; import { EventEmitter, Readable } from 'stream'; +import xml2js from 'xml2js'; import { Destroyable } from '../../rtsp/src/rtsp'; +import { CapabiltiesResponse } from './hikvision-api-capabilities'; +import { HikvisionAPI, HikvisionCameraStreamSetup } from "./hikvision-api-channels"; +import { ChannelResponse, ChannelsResponse } from './hikvision-xml-types'; import { getDeviceInfo } from './probe'; -import { sleep } from "@scrypted/common/src/sleep"; export const detectionMap = { human: 'person', @@ -156,15 +159,15 @@ export class HikvisionCameraAPI implements HikvisionAPI { `${resource}\r\n` + '\r\n'; - const response = await this.request({ - body: xml, - method: 'PUT', - url: `http://${this.ip}/ISAPI/System/Video/inputs/channels/${getChannel(channel)}/VCAResource`, - responseType: 'text', - headers: { - 'Content-Type': 'application/xml', - }, - }); + const response = await this.request({ + body: xml, + method: 'PUT', + url: `http://${this.ip}/ISAPI/System/Video/inputs/channels/${getChannel(channel)}/VCAResource`, + responseType: 'text', + headers: { + 'Content-Type': 'application/xml', + }, + }); // need to reboot after this change. await this.reboot(); @@ -278,4 +281,170 @@ export class HikvisionCameraAPI implements HikvisionAPI { return this.listenerPromise; } + + async configureCodecs(camNumber: string, channelNumber: string, options: MediaStreamOptions): Promise { + const cameraChannel = `${camNumber}${channelNumber}`; + let vsos = await this.getCodecs(camNumber); + + const response = await this.request({ + url: `http://${this.ip}/ISAPI/Streaming/channels/${cameraChannel}`, + responseType: 'text', + }); + const channel: ChannelResponse = await xml2js.parseStringPromise(response.body); + const vc = channel.StreamingChannel.Video[0]; + + const { video: videoOptions, audio: audioOptions } = options; + + if (videoOptions?.codec) { + let videoCodecType: string; + switch (videoOptions.codec) { + case 'h264': + videoCodecType = 'H.264'; + break; + case 'h265': + videoCodecType = 'H.265'; + break; + } + if (videoCodecType) { + vc.videoCodecType = [videoCodecType]; + vc.SmartCodec = [{ + enabled: ['false'], + }]; + vc.SVC = [{ + enabled: ['false'], + }]; + } + } + + if (videoOptions?.keyframeInterval) + vc.GovLength = [videoOptions.keyframeInterval.toString()]; + + if (videoOptions?.profile) { + let profile: string; + switch (videoOptions.profile) { + case 'baseline': + profile = 'Baseline'; + break; + case 'main': + profile = 'Main'; + break; + case 'high': + profile = 'High'; + break; + } + if (profile) { + vc.H264Profile = [profile]; + vc.H265Profile = [profile]; + } + } + + if (videoOptions?.width && videoOptions?.height) { + vc.videoResolutionWidth = [videoOptions?.width.toString()]; + vc.videoResolutionHeight = [videoOptions?.height.toString()]; + } + + + // can't be set by hikvision. But see if it is settable and doesn't match to direct user. + if (videoOptions?.bitrateControl && vc.videoQualityControlType?.[0]) { + const constant = videoOptions?.bitrateControl === 'constant'; + if ((vc.videoQualityControlType[0] === 'CBR' && !constant) || (vc.videoQualityControlType[0] === 'VBR' && constant)) + throw new Error(options.id + ': The camera video Bitrate Type must be manually set to ' + videoOptions?.bitrateControl + ' in the camera web admin.'); + } + + if (videoOptions?.bitrateControl) { + if (videoOptions?.bitrateControl === 'constant') + vc.videoQualityControlType = ['CBR']; + else if (videoOptions?.bitrateControl === 'variable') + vc.videoQualityControlType = ['VBR']; + } + + if (videoOptions?.bitrate) { + const br = Math.round(videoOptions?.bitrate / 1000); + vc.vbrUpperCap = [br.toString()]; + vc.constantBitRate = [br.toString()]; + } + + if (videoOptions?.fps) { + // weird calculation here per docs + const fps = videoOptions.fps * 100; + vc.maxFrameRate = [fps.toString()]; + // not sure if this is necessary. + const gov = parseInt(vc.GovLength[0]); + vc.keyFrameInterval = [(gov / videoOptions.fps * 100).toString()]; + } + + const builder = new xml2js.Builder(); + const put = builder.buildObject(vc); + + await this.request({ + method: 'PUT', + url: `http://${this.ip}/ISAPI/Streaming/channels/${cameraChannel}`, + responseType: 'text', + body: put, + headers: { + 'Content-Type': 'application/xml', + } + }); + + const response2 = await this.request({ + url: `http://${this.ip}/ISAPI/Streaming/channels/${cameraChannel}/capabilities`, + responseType: 'text', + }); + + vsos = await this.getCodecs(camNumber); + const vso: MediaStreamConfiguration = vsos.find(vso => vso.id === cameraChannel); + + const capabilities: CapabiltiesResponse = await xml2js.parseStringPromise(response2.body); + const v = capabilities.StreamingChannel.Video[0]; + vso.video.bitrateRange = [parseInt(v.vbrUpperCap[0].$.min) * 1000, parseInt(v.vbrUpperCap[0].$.max) * 1000]; + const fpsRange = v.maxFrameRate[0].$.opt.split(',').map(fps => parseInt(fps) / 1000); + vso.video.fpsRange = [Math.min(...fpsRange), Math.max(...fpsRange)]; + + vso.video.bitrateControls = ['constant', 'variable']; + vso.video.keyframeIntervalRange = [parseInt(v.GovLength[0].$.min), parseInt(v.GovLength[0].$.max)]; + const videoResolutionWidths = v.videoResolutionWidth[0].$.opt.split(',').map(w => parseInt(w)); + const videoResolutionHeights = v.videoResolutionHeight[0].$.opt.split(',').map(h => parseInt(h)); + vso.video.resolutions = videoResolutionWidths.map((w, i) => ([w, videoResolutionHeights[i]])); + + return vso; + } + + async getCodecs(camNumber: string) { + const defaultMap = new Map(); + defaultMap.set(camNumber + '01', undefined); + defaultMap.set(camNumber + '02', undefined); + + try { + const response = await this.request({ + url: `http://${this.ip}/ISAPI/Streaming/channels`, + responseType: 'text', + }); + const xml = response.body; + const parsedXml: ChannelsResponse = await xml2js.parseStringPromise(xml); + + const vsos: MediaStreamOptions[] = []; + for (const streamingChannel of parsedXml.StreamingChannelList.StreamingChannel) { + const [id] = streamingChannel.id; + const width = parseInt(streamingChannel?.Video?.[0]?.videoResolutionWidth?.[0]) || undefined; + const height = parseInt(streamingChannel?.Video?.[0]?.videoResolutionHeight?.[0]) || undefined; + let codec = streamingChannel?.Video?.[0]?.videoCodecType?.[0] as string; + codec = codec?.toLowerCase()?.replaceAll('.', ''); + const vso: MediaStreamOptions = { + id, + video: { + width, + height, + codec, + } + } + vsos.push(vso); + } + + return vsos; + } + catch (e) { + this.console.error('error retrieving channel ids', e); + return [...defaultMap.values()]; + } + } } diff --git a/plugins/hikvision/src/hikvision-xml-types.ts b/plugins/hikvision/src/hikvision-xml-types.ts new file mode 100644 index 000000000..642d0b860 --- /dev/null +++ b/plugins/hikvision/src/hikvision-xml-types.ts @@ -0,0 +1,175 @@ +export interface ChannelsResponse { + StreamingChannelList: StreamingChannelList; +} + +export interface StreamingChannelList { + $: Empty; + StreamingChannel: StreamingChannel[]; +} + +export interface Empty { + version: string; + xmlns: string; +} + +export interface StreamingChannel { + $: Empty; + id: string[]; + channelName: string[]; + enabled: string[]; + Transport: Transport[]; + Video: Video[]; + Audio: Audio[]; +} + +export interface Audio { + enabled: string[]; + audioInputChannelID: string[]; + audioCompressionType: string[]; +} + +export interface Transport { + maxPacketSize: string[]; + ControlProtocolList: ControlProtocolList[]; + Unicast: Unicast[]; + Multicast: Multicast[]; + Security: Security[]; +} + +export interface ControlProtocolList { + ControlProtocol: ControlProtocol[]; +} + +export interface ControlProtocol { + streamingTransport: string[]; +} + +export interface Multicast { + enabled: string[]; + destIPAddress: string[]; + videoDestPortNo: string[]; + audioDestPortNo: string[]; +} + +export interface Security { + enabled: string[]; + certificateType: string[]; + SecurityAlgorithm: SecurityAlgorithm[]; +} + +export interface SecurityAlgorithm { + algorithmType: string[]; +} + +export interface Unicast { + enabled: string[]; + rtpTransportType: string[]; +} + +export interface Video { + enabled: string[]; + videoInputChannelID: string[]; + videoCodecType: string[]; + videoScanType: string[]; + videoResolutionWidth: string[]; + videoResolutionHeight: string[]; + videoQualityControlType: string[]; + constantBitRate: string[]; + fixedQuality: string[]; + vbrUpperCap: string[]; + vbrLowerCap: string[]; + maxFrameRate: string[]; + keyFrameInterval: string[]; + snapShotImageType: string[]; + H264Profile: string[]; + GovLength: string[]; + SVC: SVC[]; + PacketType: string[]; + smoothing: string[]; + H265Profile: string[]; + SmartCodec?: SVC[]; +} + +export interface SVC { + enabled: string[]; +} + +export interface ChannelResponse { + StreamingChannel: StreamingChannel; +} + + +// { +// enabled: [ +// "true", +// ], +// videoInputChannelID: [ +// "1", +// ], +// videoCodecType: [ +// "H.264", +// ], +// videoScanType: [ +// "progressive", +// ], +// videoResolutionWidth: [ +// "3840", +// ], +// videoResolutionHeight: [ +// "2160", +// ], +// videoQualityControlType: [ +// "VBR", +// ], +// constantBitRate: [ +// "8192", +// ], +// fixedQuality: [ +// "100", +// ], +// vbrUpperCap: [ +// "8192", +// ], +// vbrLowerCap: [ +// "32", +// ], +// maxFrameRate: [ +// "2000", +// ], +// keyFrameInterval: [ +// "4000", +// ], +// snapShotImageType: [ +// "JPEG", +// ], +// H264Profile: [ +// "Main", +// ], +// GovLength: [ +// "80", +// ], +// SVC: [ +// { +// enabled: [ +// "false", +// ], +// }, +// ], +// PacketType: [ +// "PS", +// "RTP", +// ], +// smoothing: [ +// "50", +// ], +// H265Profile: [ +// "Main", +// ], +// SmartCodec: [ +// { +// enabled: [ +// "false", +// ], +// }, +// ], +// } \ No newline at end of file diff --git a/plugins/hikvision/src/main.ts b/plugins/hikvision/src/main.ts index 82bc5518b..1ab1ca176 100644 --- a/plugins/hikvision/src/main.ts +++ b/plugins/hikvision/src/main.ts @@ -4,12 +4,20 @@ import { PassThrough } from "stream"; import xml2js from 'xml2js'; import { RtpPacket } from '../../../external/werift/packages/rtp/src/rtp/rtp'; import { connectCameraAPI } from '../../onvif/src/onvif-api'; -import { autoconfigureCodecs, automaticallyConfigureSettings, configureCodecs } from '../../onvif/src/onvif-configure'; import { OnvifIntercom } from "../../onvif/src/onvif-intercom"; import { RtspProvider, RtspSmartCamera, UrlMediaStreamOptions } from "../../rtsp/src/rtsp"; import { startRtpForwarderProcess } from '../../webrtc/src/rtp-forwarders'; -import { HikvisionAPI } from "./hikvision-api-interfaces"; +import { HikvisionAPI } from "./hikvision-api-channels"; import { HikvisionCameraAPI, HikvisionCameraEvent, detectionMap } from "./hikvision-camera-api"; +import { automaticallyConfigureSettings } from "@scrypted/common/src/autoconfigure-codecs"; +import { autoconfigureSettings } from "./hikvision-autoconfigure"; + +const rtspChannelSetting: Setting = { + key: 'rtspChannel', + title: 'Channel Number', + description: "Optional: The channel number to use for snapshots. E.g., 101, 201, etc. The camera portion, e.g., 1, 2, etc, will be used to construct the RTSP stream.", + placeholder: '101', +}; const { mediaManager } = sdk; @@ -41,10 +49,10 @@ export class HikvisionCamera extends RtspSmartCamera implements Camera, Intercom } async setVideoStreamOptions(options: MediaStreamOptions) { - this.detectedChannels = undefined; - const client = await connectCameraAPI(this.getHttpAddress(), this.getUsername(), this.getPassword(), this.console, undefined); - const ret = await configureCodecs(this.console, client, options); - return ret; + let vsos = await this.getVideoStreamOptions(); + const index = vsos.findIndex(vso => vso.id === options.id); + const client = this.getClient(); + return client.configureCodecs(this.getCameraNumber() || '1', (index + 1).toString().padStart(2, '0'), options) } async updateDeviceInfo() { @@ -264,15 +272,14 @@ export class HikvisionCamera extends RtspSmartCamera implements Camera, Intercom } async getUrlSettings(): Promise { + const rtspSetting = { + ...rtspChannelSetting, + subgroup: 'Advanced', + value: this.storage.getItem('rtspChannel'), + }; + return [ - { - subgroup: 'Advanced', - key: 'rtspChannel', - title: 'Channel Number', - description: "Optional: The channel number to use for snapshots. E.g., 101, 201, etc. The camera portion, e.g., 1, 2, etc, will be used to construct the RTSP stream.", - placeholder: '101', - value: this.storage.getItem('rtspChannel'), - }, + rtspSetting, ...await super.getUrlSettings(), ] } @@ -316,49 +323,32 @@ export class HikvisionCamera extends RtspSmartCamera implements Camera, Intercom if (isOld) { this.console.error('Old NVR. Defaulting to two camera configuration'); return defaultMap; - } else { + } + + try { + let channels: MediaStreamOptions[]; try { - let xml: string; - try { - const response = await client.request({ - url: `http://${this.getHttpAddress()}/ISAPI/Streaming/channels`, - responseType: 'text', - }); - xml = response.body; - this.storage.setItem('channels', xml); - } - catch (e) { - xml = this.storage.getItem('channels'); - if (!xml) - throw e; - } - const parsedXml = await xml2js.parseStringPromise(xml); - - const ret = new Map(); - for (const streamingChannel of parsedXml.StreamingChannelList.StreamingChannel) { - const [id] = streamingChannel.id; - const width = parseInt(streamingChannel?.Video?.[0]?.videoResolutionWidth?.[0]) || undefined; - const height = parseInt(streamingChannel?.Video?.[0]?.videoResolutionHeight?.[0]) || undefined; - let codec = streamingChannel?.Video?.[0]?.videoCodecType?.[0] as string; - codec = codec?.toLowerCase()?.replaceAll('.', ''); - const vso: MediaStreamOptions = { - id, - video: { - width, - height, - codec, - } - } - ret.set(id, vso); - } - - return ret; + channels = await client.getCodecs(camNumber); + this.storage.setItem('channelsJSON', JSON.stringify(channels)); } catch (e) { - this.console.error('error retrieving channel ids', e); - this.detectedChannels = undefined; - return defaultMap; + const raw = this.storage.getItem('channelsJSON'); + if (!raw) + throw e; + channels = JSON.parse(raw); } + const ret = new Map(); + for (const streamingChannel of channels) { + const channel = streamingChannel.id; + ret.set(channel, streamingChannel); + } + + return ret; + } + catch (e) { + this.console.error('error retrieving channel ids', e); + this.detectedChannels = undefined; + return defaultMap; } })(); } @@ -413,7 +403,8 @@ export class HikvisionCamera extends RtspSmartCamera implements Camera, Intercom async putSetting(key: string, value: string) { if (key === automaticallyConfigureSettings.key) { - autoconfigureCodecs(this.console, await connectCameraAPI(this.getHttpAddress(), this.getUsername(), this.getPassword(), this.console, undefined)) + const client = this.getClient(); + autoconfigureSettings(client, this.getCameraNumber() || '1') .then(() => { this.log.a('Successfully configured settings.'); }) @@ -660,15 +651,16 @@ class HikvisionProvider extends RtspProvider { const username = settings.username?.toString(); const password = settings.password?.toString(); + const api = new HikvisionCameraAPI(httpAddress, username, password, this.console); + if (settings.autoconfigure) { - const client = await connectCameraAPI(httpAddress, username, password, this.console, undefined); - await autoconfigureCodecs(this.console, client); + const cameraNumber = (settings.rtspChannel as string)?.substring(0, 1) || '1'; + await autoconfigureSettings(api, cameraNumber); } const skipValidate = settings.skipValidate?.toString() === 'true'; let twoWayAudio: string; if (!skipValidate) { - const api = new HikvisionCameraAPI(httpAddress, username, password, this.console); try { const deviceInfo = await api.getDeviceInfo(); @@ -702,6 +694,8 @@ class HikvisionProvider extends RtspProvider { device.putSetting('username', username); device.putSetting('password', password); device.setIPAddress(settings.ip?.toString()); + if (settings.rtspChannel) + device.putSetting('rtspChannel', settings.rtspChannel as string); device.setHttpPortOverride(settings.httpPort?.toString()); if (twoWayAudio) device.putSetting('twoWayAudio', twoWayAudio); @@ -720,6 +714,7 @@ class HikvisionProvider extends RtspProvider { title: 'Password', type: 'password', }, + rtspChannelSetting, { key: 'ip', title: 'IP Address', diff --git a/plugins/onvif/src/main.ts b/plugins/onvif/src/main.ts index 2004935ff..a3e93b689 100644 --- a/plugins/onvif/src/main.ts +++ b/plugins/onvif/src/main.ts @@ -5,10 +5,11 @@ import { Stream } from "stream"; import xml2js from 'xml2js'; import { RtspProvider, RtspSmartCamera, UrlMediaStreamOptions } from "../../rtsp/src/rtsp"; import { connectCameraAPI, OnvifCameraAPI } from "./onvif-api"; -import { autoconfigureCodecs, automaticallyConfigureSettings, configureCodecs, getCodecs } from "./onvif-configure"; +import { autoconfigureSettings, configureCodecs, getCodecs } from "./onvif-configure"; import { listenEvents } from "./onvif-events"; import { OnvifIntercom } from "./onvif-intercom"; import { OnvifPTZMixinProvider } from "./onvif-ptz"; +import { automaticallyConfigureSettings } from "@scrypted/common/src/autoconfigure-codecs"; const { mediaManager, systemManager, deviceManager } = sdk; @@ -254,7 +255,7 @@ class OnvifCamera extends RtspSmartCamera implements ObjectDetector, Intercom, V async putSetting(key: string, value: any) { if (key === automaticallyConfigureSettings.key) { - autoconfigureCodecs(this.console, await this.getClient()) + autoconfigureSettings(this.console, await this.getClient()) .then(() => { this.log.a('Successfully configured settings.'); }) @@ -426,7 +427,7 @@ class OnvifProvider extends RtspProvider implements DeviceDiscovery { if (settings.autoconfigure) { const client = await connectCameraAPI(httpAddress, username, password, this.console, undefined); - await autoconfigureCodecs(this.console, client); + await autoconfigureSettings(this.console, client); } const skipValidate = settings.skipValidate?.toString() === 'true'; @@ -559,7 +560,7 @@ class OnvifProvider extends RtspProvider implements DeviceDiscovery { adopt.settings.httpPort = entry.port; if (adopt.settings.autoconfigure) { const client = await connectCameraAPI(`${entry.host}:${entry.port || 80}`, adopt.settings.username as string, adopt.settings.password as string, this.console, undefined); - await autoconfigureCodecs(this.console, client); + await autoconfigureSettings(this.console, client); } await this.createDevice(adopt.settings, adopt.nativeId); this.discoveredDevices.delete(adopt.nativeId); diff --git a/plugins/onvif/src/onvif-configure.ts b/plugins/onvif/src/onvif-configure.ts index d17c52b49..8f1d1c996 100644 --- a/plugins/onvif/src/onvif-configure.ts +++ b/plugins/onvif/src/onvif-configure.ts @@ -1,14 +1,7 @@ -import { MediaStreamConfiguration, MediaStreamDestination, MediaStreamOptions, Setting, VideoStreamConfiguration } from "@scrypted/sdk"; -import { OnvifCameraAPI } from "./onvif-api"; +import { MediaStreamConfiguration, MediaStreamOptions, Setting } from "@scrypted/sdk"; +import { autoconfigureCodecs as ac } from '../../../common/src/autoconfigure-codecs'; import { UrlMediaStreamOptions } from "../../ffmpeg-camera/src/common"; - -export const automaticallyConfigureSettings: Setting = { - key: 'autoconfigure', - title: 'Automatically Configure Settings', - description: 'Automatically configure and valdiate the camera codecs and other settings for optimal Scrypted performance. Some settings will require manual configuration via the camera web admin.', - type: 'boolean', - value: true, -}; +import { OnvifCameraAPI } from "./onvif-api"; export function computeInterval(fps: number, govLength: number) { if (!fps || !govLength) @@ -16,22 +9,6 @@ export function computeInterval(fps: number, govLength: number) { return govLength / fps * 1000; } -const MEGABIT = 1024 * 1000; - -function getBitrateForResolution(resolution: number) { - if (resolution >= 3840 * 2160) - return 8 * MEGABIT; - if (resolution >= 2688 * 1520) - return 3 * MEGABIT; - if (resolution >= 1920 * 1080) - return 2 * MEGABIT; - if (resolution >= 1280 * 720) - return MEGABIT; - if (resolution >= 640 * 480) - return MEGABIT / 2; - return MEGABIT / 4; -} - const onvifToFfmpegVideoCodecMap = { 'h264': 'h264', 'h265': 'h265', @@ -77,142 +54,11 @@ export function computeBitrate(bitrate: number) { return bitrate * 1000; } -export async function autoconfigureCodecs(console: Console, client: OnvifCameraAPI) { - const codecs = await getCodecs(console, client); - const configurable: MediaStreamConfiguration[] = []; - for (const codec of codecs) { - const config = await configureCodecs(console, client, { - id: codec.id, - }); - configurable.push(config); - } - - const used: MediaStreamConfiguration[] = []; - - for (const destination of ['local', 'remote', 'low-resolution'] as MediaStreamDestination[]) { - // find stream with the highest configurable resolution. - let highest: [MediaStreamConfiguration, number] = [undefined, 0]; - for (const codec of configurable) { - if (used.includes(codec)) - continue; - for (const resolution of codec.video.resolutions) { - if (resolution[0] * resolution[1] > highest[1]) { - highest = [codec, resolution[0] * resolution[1]]; - } - } - } - - const config = highest[0]; - if (!config) - break; - - used.push(config); - } - - const findResolutionTarget = (config: MediaStreamConfiguration, width: number, height: number) => { - let diff = 999999999; - let ret: [number, number]; - - for (const res of config.video.resolutions) { - const d = Math.abs(res[0] - width) + Math.abs(res[1] - height); - if (d < diff) { - diff = d; - ret = res; - } - } - - return ret; - } - - // find the highest resolution - const l = used[0]; - const resolution = findResolutionTarget(l, 8192, 8192); - - // get the fps of 20 or highest available - let fps = Math.min(20, Math.max(...l.video.fpsRange)); - - await configureCodecs(console, client, { - id: l.id, - video: { - width: resolution[0], - height: resolution[1], - bitrateControl: 'variable', - codec: 'h264', - bitrate: getBitrateForResolution(resolution[0] * resolution[1]), - fps, - keyframeInterval: fps * 4, - quality: 5, - profile: 'main', - }, - }); - - if (used.length === 3) { - // find remote and low - const r = used[1]; - const l = used[2]; - - const rResolution = findResolutionTarget(r, 1280, 720); - const lResolution = findResolutionTarget(l, 640, 360); - - fps = Math.min(20, Math.max(...r.video.fpsRange)); - await configureCodecs(console, client, { - id: r.id, - video: { - width: rResolution[0], - height: rResolution[1], - bitrateControl: 'variable', - codec: 'h264', - bitrate: 1 * MEGABIT, - fps, - keyframeInterval: fps * 4, - quality: 5, - profile: 'main', - }, - }); - - fps = Math.min(20, Math.max(...l.video.fpsRange)); - await configureCodecs(console, client, { - id: l.id, - video: { - width: lResolution[0], - height: lResolution[1], - bitrateControl: 'variable', - codec: 'h264', - bitrate: MEGABIT / 2, - fps, - keyframeInterval: fps * 4, - quality: 5, - profile: 'main', - }, - }); - } - else if (used.length == 2) { - let target: [number, number]; - if (resolution[0] * resolution[1] > 1920 * 1080) - target = [1280, 720]; - else - target = [640, 360]; - - const rResolution = findResolutionTarget(used[1], target[0], target[1]); - const fps = Math.min(20, Math.max(...used[1].video.fpsRange)); - await configureCodecs(console, client, { - id: used[1].id, - video: { - width: rResolution[0], - height: rResolution[1], - bitrateControl: 'variable', - codec: 'h264', - bitrate: getBitrateForResolution(rResolution[0] * rResolution[1]), - fps, - keyframeInterval: fps * 4, - quality: 5, - profile: 'main', - }, - }); - } - else if (used.length === 1) { - // no nop - } +export async function autoconfigureSettings(console: Console, client: OnvifCameraAPI) { + return ac( + () => getCodecs(console, client), + (options) => configureCodecs(console, client, options) + ); } export async function configureCodecs(console: Console, client: OnvifCameraAPI, options: MediaStreamOptions): Promise { @@ -277,7 +123,7 @@ export async function configureCodecs(console: Console, client: OnvifCameraAPI, if (videoOptions?.bitrateControl && vc.rateControl?.$?.ConstantBitRate !== undefined) { const constant = videoOptions?.bitrateControl === 'constant'; if (vc.rateControl.$.ConstantBitRate !== constant) - throw new Error(options.id + ': The camera video Bitrate Type must be set to ' + videoOptions?.bitrateControl + ' in the camera web admin.'); + throw new Error(options.id + ': camera video Bitrate Type must be manually set to ' + videoOptions?.bitrateControl + ' in the camera web admin.'); } if (videoOptions?.fps) { diff --git a/plugins/onvif/xml-dumps/profiles.xml b/plugins/onvif/xml-dumps/profiles.xml new file mode 100644 index 000000000..111b63034 --- /dev/null +++ b/plugins/onvif/xml-dumps/profiles.xml @@ -0,0 +1,367 @@ + + + + + + mainStream + + + VideoSourceConfig + 3 + VideoSource_1 + + + + AudioSourceConfig + 3 + AudioSourceChannel + + + VideoEncoder_1 + 1 + H264 + + 3840 + 2160 + + + 20.000000 + 2048 + + + + IPv4 + 0.0.0.0 + + 8600 + 128 + false + + 5.000000 + + + AudioEncoderConfig + 3 + PCMU + + + IPv4 + 0.0.0.0 + + 8602 + 128 + false + + 64 + 8 + + + VideoAnalyticsName + 3 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + subStream + + + VideoSourceConfig + 3 + VideoSource_1 + + + + AudioSourceConfig + 3 + AudioSourceChannel + + + VideoEncoder_2 + 1 + H264 + + 640 + 360 + + + 30.000000 + 256 + + + + IPv4 + 0.0.0.0 + + 8606 + 128 + false + + 3.000000 + + + AudioEncoderConfig + 3 + PCMU + + + IPv4 + 0.0.0.0 + + 8602 + 128 + false + + 64 + 8 + + + VideoAnalyticsName + 3 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + thirdStream + + + VideoSourceConfig + 3 + VideoSource_1 + + + + AudioSourceConfig + 3 + AudioSourceChannel + + + VideoEncoder_3 + 1 + H264 + + 1280 + 720 + + + 30.000000 + 512 + + + + IPv4 + 0.0.0.0 + + 8612 + 128 + false + + 3.000000 + + + AudioEncoderConfig + 3 + PCMU + + + IPv4 + 0.0.0.0 + + 8602 + 128 + false + + 64 + 8 + + + VideoAnalyticsName + 3 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/plugins/rtsp/src/rtsp.ts b/plugins/rtsp/src/rtsp.ts index 989c67aea..db16cd8a5 100644 --- a/plugins/rtsp/src/rtsp.ts +++ b/plugins/rtsp/src/rtsp.ts @@ -26,10 +26,6 @@ export class RtspCamera extends CameraBase { }; } - getChannelFromMediaStreamOptionsId(id: string) { - return id.substring('channel'.length); - } - getRawVideoStreamOptions(): UrlMediaStreamOptions[] { let urls: string[] = []; try { @@ -234,7 +230,7 @@ export abstract class RtspSmartCamera extends RtspCamera { async putSetting(key: string, value: SettingValue) { this.putSettingBase(key, value); - this.listener.then(l => l.emit('error', new Error("new settings"))); + this.listener?.then(l => l.emit('error', new Error("new settings"))); } async takePicture(options?: RequestPictureOptions) {