From b033d24451ad49f27eb506a985f90c262aabd4ee Mon Sep 17 00:00:00 2001 From: Koushik Dutta Date: Fri, 3 Jan 2025 23:15:45 -0800 Subject: [PATCH] rebroadcast: implement synthetic streams --- plugins/prebuffer-mixin/README.md | 4 - plugins/prebuffer-mixin/package-lock.json | 4 +- plugins/prebuffer-mixin/package.json | 2 +- plugins/prebuffer-mixin/src/main.ts | 87 ++++++++++++++----- .../prebuffer-mixin/src/stream-settings.ts | 25 +++++- 5 files changed, 89 insertions(+), 33 deletions(-) diff --git a/plugins/prebuffer-mixin/README.md b/plugins/prebuffer-mixin/README.md index 863f4036b..f4a7f68fa 100644 --- a/plugins/prebuffer-mixin/README.md +++ b/plugins/prebuffer-mixin/README.md @@ -17,7 +17,3 @@ Medium: 720p (500 Kbps) Low (if available): 320p (100 Kbps) The `Key Frame (IDR) Interval` should be set to `4` seconds. This setting is usually configured in frames. So if the camera frame rate is `30`, the interval would be `120`. If the camera frame rate is `15` the interval would be `60`. The value can be calculated as `IDR Interval = FPS * 4`. - -## Transcoding - -Some cameras may not allow configuration of the video codec (h264) or IDR Interval. The camera may also only have a single high bitrate stream which will fail to stream when viewing on low bandwidth remote connections. In this case, Transcoding should be enabled for `Remote Stream` and `Remote Recording Stream` to ensure there isn't a bandwidth issue. diff --git a/plugins/prebuffer-mixin/package-lock.json b/plugins/prebuffer-mixin/package-lock.json index 8a99d52a1..6f3f53dca 100644 --- a/plugins/prebuffer-mixin/package-lock.json +++ b/plugins/prebuffer-mixin/package-lock.json @@ -1,12 +1,12 @@ { "name": "@scrypted/prebuffer-mixin", - "version": "0.10.41", + "version": "0.10.42", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "@scrypted/prebuffer-mixin", - "version": "0.10.41", + "version": "0.10.42", "license": "Apache-2.0", "dependencies": { "@scrypted/common": "file:../../common", diff --git a/plugins/prebuffer-mixin/package.json b/plugins/prebuffer-mixin/package.json index 99bc7ba0a..10b8f4b95 100644 --- a/plugins/prebuffer-mixin/package.json +++ b/plugins/prebuffer-mixin/package.json @@ -1,6 +1,6 @@ { "name": "@scrypted/prebuffer-mixin", - "version": "0.10.41", + "version": "0.10.42", "description": "Video Stream Rebroadcast, Prebuffer, and Management Plugin for Scrypted.", "author": "Scrypted", "license": "Apache-2.0", diff --git a/plugins/prebuffer-mixin/src/main.ts b/plugins/prebuffer-mixin/src/main.ts index 3f2d02d05..bc85b0226 100644 --- a/plugins/prebuffer-mixin/src/main.ts +++ b/plugins/prebuffer-mixin/src/main.ts @@ -1,5 +1,4 @@ import { AutoenableMixinProvider } from '@scrypted/common/src/autoenable-mixin-provider'; -import { getDebugModeH264EncoderArgs, getH264EncoderArgs } from '@scrypted/common/src/ffmpeg-hardware-acceleration'; import { addVideoFilterArguments } from '@scrypted/common/src/ffmpeg-helpers'; import { ListenZeroSingleClientTimeoutError, closeQuiet, listenZeroSingleClient } from '@scrypted/common/src/listen-cluster'; import { readLength } from '@scrypted/common/src/read-stream'; @@ -72,7 +71,7 @@ class PrebufferSession { activeClients = 0; inactivityTimeout: NodeJS.Timeout; - audioConfigurationKey: string; + syntheticInputIdKey: string; ffmpegInputArgumentsKey: string; ffmpegOutputArgumentsKey: string; lastDetectedAudioCodecKey: string; @@ -88,7 +87,7 @@ class PrebufferSession { this.storage = mixin.storage; this.console = mixin.console; this.mixinDevice = mixin.mixinDevice; - this.audioConfigurationKey = 'audioConfiguration-' + this.streamId; + this.syntheticInputIdKey = 'syntheticInputIdKey-' + this.streamId; this.ffmpegInputArgumentsKey = 'ffmpegInputArguments-' + this.streamId; this.ffmpegOutputArgumentsKey = 'ffmpegOutputArguments-' + this.streamId; this.lastDetectedAudioCodecKey = 'lastDetectedAudioCodec-' + this.streamId; @@ -227,12 +226,15 @@ class PrebufferSession { let parser: string; let rtspParser = this.storage.getItem(this.rtspParserKey); + let isDefault = !rtspParser || rtspParser === 'Default'; + if (!this.canUseRtspParser(mediaStreamOptions)) { parser = STRING_DEFAULT; + isDefault = true; rtspParser = undefined; } else { - if (!rtspParser || rtspParser === STRING_DEFAULT) { + if (isDefault) { // use the plugin default rtspParser = localStorage.getItem('defaultRtspParser'); } @@ -251,7 +253,7 @@ class PrebufferSession { return { parser, - isDefault: !rtspParser || rtspParser === 'Default', + isDefault, } } @@ -326,6 +328,19 @@ class PrebufferSession { const group = "Streams"; const subgroup = `Stream: ${this.streamName}`; + if (this.mixin.streamSettings.storageSettings.values.synthenticStreams.includes(this.streamId)) { + const nonSynthetic = [...this.mixin.sessions.keys()].filter(s => s && !s.startsWith('synthetic:')); + settings.push({ + group, + subgroup, + key: this.syntheticInputIdKey, + title: 'Synthetic Stream Source', + description: 'The source stream to transcode.', + choices: nonSynthetic, + value: this.storage.getItem(this.syntheticInputIdKey), + }); + } + const addFFmpegInputSettings = () => { settings.push( { @@ -514,7 +529,19 @@ class PrebufferSession { }; this.parsers = rbo.parsers; - const mo = await this.mixinDevice.getVideoStream(mso); + let mo: MediaObject; + if (this.mixin.streamSettings.storageSettings.values.synthenticStreams.includes(this.streamId)) { + const syntheticInputId = this.storage.getItem(this.syntheticInputIdKey); + if (!syntheticInputId) + throw new Error('synthetic stream has not been configured with an input'); + const realDevice = systemManager.getDeviceById(this.mixin.id); + mo = await realDevice.getVideoStream({ + id: syntheticInputId, + }); + } + else { + mo = await this.mixinDevice.getVideoStream(mso); + } const isRfc4571 = mo.mimeType === 'x-scrypted/x-rfc4571'; let session: ParserSession; @@ -1445,6 +1472,22 @@ class PrebufferMixin extends SettingsMixinDeviceBase implements Vid })(); } + for (const synthetic of this.streamSettings.storageSettings.values.synthenticStreams) { + const id = `synthetic:${synthetic}`; + toRemove.delete(id); + + let session = this.sessions.get(id); + + if (session) + continue; + + session = new PrebufferSession(this, { + id: synthetic, + }, false, false); + this.sessions.set(id, session); + this.console.log('stream', synthetic, 'is synthetic and will be rebroadcast on demand.'); + } + if (!this.sessions.has(undefined)) { const defaultStreamName = this.streamSettings.storageSettings.values.defaultStream; let defaultSession = this.sessions.get(msos?.find(mso => mso.name === defaultStreamName)?.id); @@ -1594,27 +1637,18 @@ export class RebroadcastPlugin extends AutoenableMixinProvider implements MixinP } } }); + transcodeStorageSettings = new StorageSettings(this, { remoteStreamingBitrate: { + group: 'Advanced', title: 'Remote Streaming Bitrate', type: 'number', - defaultValue: 1000000, + defaultValue: 500000, description: 'The bitrate to use when remote streaming. This setting will only be used when transcoding or adaptive bitrate is enabled on a camera.', onPut() { sdk.deviceManager.onDeviceEvent('transcode', ScryptedInterface.Settings, undefined); }, }, - h264EncoderArguments: { - title: 'H264 Encoder Arguments', - description: 'FFmpeg arguments used to encode h264 video. This is not camera specific and is used to setup the hardware accelerated encoder on your Scrypted server. This setting will only be used when transcoding is enabled on a camera.', - choices: Object.keys(getH264EncoderArgs()), - defaultValue: getDebugModeH264EncoderArgs().join(' '), - combobox: true, - mapPut: (oldValue, newValue) => getH264EncoderArgs()[newValue]?.join(' ') || newValue || getDebugModeH264EncoderArgs().join(' '), - onPut() { - sdk.deviceManager.onDeviceEvent('transcode', ScryptedInterface.Settings, undefined); - }, - } }); currentMixins = new Map { - deviceManager.onDeviceRemoved('transcode'); - }); + if (sdk.deviceManager.getNativeIds().includes('transcode')) { + process.nextTick(() => { + deviceManager.onDeviceRemoved('transcode'); + }); + } } - getSettings(): Promise { - return this.storageSettings.getSettings(); + async getSettings(): Promise { + return [ + ...await this.storageSettings.getSettings(), + ...await this.transcodeStorageSettings.getSettings(), + ]; } putSetting(key: string, value: SettingValue): Promise { + if (this.transcodeStorageSettings.keys[key]) + return this.transcodeStorageSettings.putSetting(key, value); return this.storageSettings.putSetting(key, value); } diff --git a/plugins/prebuffer-mixin/src/stream-settings.ts b/plugins/prebuffer-mixin/src/stream-settings.ts index f18c75f07..254ed98f8 100644 --- a/plugins/prebuffer-mixin/src/stream-settings.ts +++ b/plugins/prebuffer-mixin/src/stream-settings.ts @@ -101,6 +101,16 @@ export function createStreamSettings(device: MixinDeviceBase) { type: 'number', hide: false, }, + synthenticStreams: { + subgroup, + title: 'Synthetic Streams', + description: 'Create additional streams by transcoding the existing streams. This can be useful for creating streams with different resolutions or bitrates.', + immediate: true, + multiple: true, + combobox: true, + choices: [], + defaultValue: [], + } }); function getDefaultPrebufferedStreams(msos: ResponseMediaStreamOptions[]) { @@ -137,10 +147,18 @@ export function createStreamSettings(device: MixinDeviceBase) { const v: StreamStorageSetting = storageSettings.settings[key]; const value = storageSettings.values[key]; let isDefault = value === 'Default'; + let stream = msos?.find(mso => mso.name === value); - if (isDefault || !stream) { - isDefault = true; - stream = getDefaultMediaStream(v, msos); + if (storageSettings.values.synthenticStreams.includes(value)) { + stream = { + id: `synthetic:${value}`, + }; + } + else { + if (isDefault || !stream) { + isDefault = true; + stream = getDefaultMediaStream(v, msos); + } } return { title: streamTypes[key].title, @@ -153,6 +171,7 @@ export function createStreamSettings(device: MixinDeviceBase) { const choices = [ 'Default', ...msos.map(mso => mso.name), + ...storageSettings.values.synthenticStreams, ]; const defaultValue = getDefaultMediaStream(v, msos).name;