@scrypted/prebuffer-mixin 0.1.224 → 0.1.227
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +20 -0
- package/dist/main.nodejs.js +1 -1
- package/dist/plugin.zip +0 -0
- package/package.json +3 -3
- package/src/main.ts +286 -159
- package/src/rfc4571.ts +132 -48
- package/src/sps-parser/bitstream.ts +75 -0
- package/src/sps-parser/index.ts +237 -0
- package/src/sps-parser/vui.ts +126 -0
- package/src/stream-settings.ts +47 -8
- package/vscode-profile-2022-04-17-16-14-09.cpuprofile +1 -0
package/src/main.ts
CHANGED
|
@@ -2,21 +2,23 @@
|
|
|
2
2
|
import { AutoenableMixinProvider } from '@scrypted/common/src/autoenable-mixin-provider';
|
|
3
3
|
import { startFFMPegFragmentedMP4Session } from '@scrypted/common/src/ffmpeg-mp4-parser-session';
|
|
4
4
|
import { handleRebroadcasterClient, ParserOptions, ParserSession, setupActivityTimer, startParserSession } from '@scrypted/common/src/ffmpeg-rebroadcast';
|
|
5
|
-
import { closeQuiet, listenZeroSingleClient } from '@scrypted/common/src/listen-cluster';
|
|
6
|
-
import { safeKillFFmpeg } from '@scrypted/common/src/media-helpers';
|
|
5
|
+
import { closeQuiet, createBindZero, listenZeroSingleClient } from '@scrypted/common/src/listen-cluster';
|
|
6
|
+
import { ffmpegLogInitialOutput, safeKillFFmpeg } from '@scrypted/common/src/media-helpers';
|
|
7
7
|
import { readLength } from '@scrypted/common/src/read-stream';
|
|
8
|
-
import { createRtspParser, RtspClient, RtspServer } from '@scrypted/common/src/rtsp-server';
|
|
9
|
-
import { addTrackControls,
|
|
8
|
+
import { createRtspParser, H264_NAL_TYPE_IDR, findH264NaluType, RtspClient, RtspServer, RTSP_FRAME_MAGIC, parseSemicolonDelimited } from '@scrypted/common/src/rtsp-server';
|
|
9
|
+
import { addTrackControls, parseSdp } from '@scrypted/common/src/sdp-utils';
|
|
10
10
|
import { StorageSettings } from '@scrypted/common/src/settings';
|
|
11
11
|
import { SettingsMixinDeviceBase, SettingsMixinDeviceOptions } from "@scrypted/common/src/settings-mixin";
|
|
12
12
|
import { sleep } from '@scrypted/common/src/sleep';
|
|
13
13
|
import { createFragmentedMp4Parser, createMpegTsParser, parseMp4StreamChunks, StreamChunk, StreamParser } from '@scrypted/common/src/stream-parser';
|
|
14
|
-
import sdk, { BufferConverter,
|
|
14
|
+
import sdk, { BufferConverter, FFmpegInput, MediaObject, MediaStreamOptions, MixinProvider, RequestMediaStreamOptions, ResponseMediaStreamOptions, ScryptedDeviceType, ScryptedInterface, ScryptedMimeTypes, Setting, Settings, SettingValue, VideoCamera, VideoCameraConfiguration } from '@scrypted/sdk';
|
|
15
15
|
import crypto from 'crypto';
|
|
16
|
+
import dgram from 'dgram';
|
|
16
17
|
import net from 'net';
|
|
17
18
|
import { Duplex } from 'stream';
|
|
18
|
-
import { connectRFC4571Parser, startRFC4571Parser } from './rfc4571';
|
|
19
|
+
import { connectRFC4571Parser, RtspChannelCodecMapping, startRFC4571Parser } from './rfc4571';
|
|
19
20
|
import { createStreamSettings, getPrebufferedStreams } from './stream-settings';
|
|
21
|
+
import { getH264EncoderArgs, LIBX264_ENCODER_TITLE } from '@scrypted/common/src/ffmpeg-hardware-acceleration';
|
|
20
22
|
|
|
21
23
|
const { mediaManager, log, systemManager, deviceManager } = sdk;
|
|
22
24
|
|
|
@@ -32,7 +34,8 @@ const COMPATIBLE_AUDIO_CODECS = ['aac', 'mp3', 'mp2', 'opus'];
|
|
|
32
34
|
const DEFAULT_FFMPEG_INPUT_ARGUMENTS = '-fflags +genpts';
|
|
33
35
|
|
|
34
36
|
const SCRYPTED_PARSER = 'Scrypted';
|
|
35
|
-
const
|
|
37
|
+
const FFMPEG_PARSER_TCP = 'FFmpeg (TCP)';
|
|
38
|
+
const FFMPEG_PARSER_UDP = 'FFmpeg (UDP)';
|
|
36
39
|
const STRING_DEFAULT = 'Default';
|
|
37
40
|
|
|
38
41
|
const VALID_AUDIO_CONFIGS = [
|
|
@@ -67,8 +70,7 @@ class PrebufferSession {
|
|
|
67
70
|
parsers: { [container: string]: StreamParser };
|
|
68
71
|
sdp: Promise<string>;
|
|
69
72
|
|
|
70
|
-
detectedIdrInterval
|
|
71
|
-
prevIdr = 0;
|
|
73
|
+
detectedIdrInterval: number;
|
|
72
74
|
audioDisabled = false;
|
|
73
75
|
|
|
74
76
|
mixinDevice: VideoCamera & VideoCameraConfiguration;
|
|
@@ -181,18 +183,20 @@ class PrebufferSession {
|
|
|
181
183
|
|
|
182
184
|
getParser(rtspMode: boolean, muxingMp4: boolean, mediaStreamOptions: MediaStreamOptions) {
|
|
183
185
|
if (!this.canUseRtspParser(muxingMp4, mediaStreamOptions))
|
|
184
|
-
return
|
|
186
|
+
return STRING_DEFAULT;
|
|
185
187
|
|
|
186
188
|
const defaultValue = rtspMode
|
|
187
189
|
&& mediaStreamOptions?.tool === 'scrypted' ?
|
|
188
|
-
SCRYPTED_PARSER :
|
|
190
|
+
SCRYPTED_PARSER : STRING_DEFAULT;
|
|
189
191
|
const rtspParser = this.storage.getItem(this.rtspParserKey);
|
|
190
192
|
if (!rtspParser || rtspParser === STRING_DEFAULT)
|
|
191
193
|
return defaultValue;
|
|
192
194
|
if (rtspParser === SCRYPTED_PARSER)
|
|
193
195
|
return SCRYPTED_PARSER;
|
|
194
|
-
if (rtspParser ===
|
|
195
|
-
return
|
|
196
|
+
if (rtspParser === FFMPEG_PARSER_TCP)
|
|
197
|
+
return FFMPEG_PARSER_TCP;
|
|
198
|
+
if (rtspParser === FFMPEG_PARSER_UDP)
|
|
199
|
+
return FFMPEG_PARSER_UDP;
|
|
196
200
|
return defaultValue;
|
|
197
201
|
}
|
|
198
202
|
|
|
@@ -232,7 +236,7 @@ class PrebufferSession {
|
|
|
232
236
|
const elapsed = Date.now() - start;
|
|
233
237
|
const bitrate = Math.round(total / elapsed * 8);
|
|
234
238
|
|
|
235
|
-
const group = this.streamName ? `
|
|
239
|
+
const group = this.streamName ? `Stream: ${this.streamName}` : 'Stream';
|
|
236
240
|
|
|
237
241
|
settings.push(
|
|
238
242
|
{
|
|
@@ -258,7 +262,6 @@ class PrebufferSession {
|
|
|
258
262
|
STRING_DEFAULT,
|
|
259
263
|
'MPEG-TS',
|
|
260
264
|
'RTSP',
|
|
261
|
-
// 'RTSP+MP4',
|
|
262
265
|
],
|
|
263
266
|
key: this.rebroadcastModeKey,
|
|
264
267
|
value: this.storage.getItem(this.rebroadcastModeKey) || STRING_DEFAULT,
|
|
@@ -290,17 +293,21 @@ class PrebufferSession {
|
|
|
290
293
|
&& this.advertisedMediaStreamOptions?.container === 'rtsp') {
|
|
291
294
|
|
|
292
295
|
const value = this.getParser(rtspMode, muxingMp4, this.advertisedMediaStreamOptions);
|
|
296
|
+
const defaultValue = rtspMode
|
|
297
|
+
&& this.advertisedMediaStreamOptions?.tool === 'scrypted' ?
|
|
298
|
+
SCRYPTED_PARSER : 'FFmpeg';
|
|
293
299
|
|
|
294
300
|
settings.push(
|
|
295
301
|
{
|
|
296
302
|
key: this.rtspParserKey,
|
|
297
303
|
group,
|
|
298
304
|
title: 'RTSP Parser',
|
|
299
|
-
description: `Experimental: The RTSP Parser used to read the stream. FFmpeg is stable. The Scrypted parser is lower latency. The Scrypted Parser is only available when the Audo Codec is not Transcoding and the Rebroadcast Container is RTSP. The default is "${
|
|
305
|
+
description: `Experimental: The RTSP Parser used to read the stream. FFmpeg is stable. The Scrypted parser is lower latency. The Scrypted Parser is only available when the Audo Codec is not Transcoding and the Rebroadcast Container is RTSP. The default is "${defaultValue}" for this camera.`,
|
|
300
306
|
value: this.storage.getItem(this.rtspParserKey) || STRING_DEFAULT,
|
|
301
307
|
choices: [
|
|
302
308
|
STRING_DEFAULT,
|
|
303
|
-
|
|
309
|
+
FFMPEG_PARSER_TCP,
|
|
310
|
+
FFMPEG_PARSER_UDP,
|
|
304
311
|
SCRYPTED_PARSER,
|
|
305
312
|
],
|
|
306
313
|
}
|
|
@@ -316,6 +323,9 @@ class PrebufferSession {
|
|
|
316
323
|
}
|
|
317
324
|
|
|
318
325
|
if (session) {
|
|
326
|
+
const resolution = session.inputVideoResolution?.width && session.inputVideoResolution?.height
|
|
327
|
+
? `${session.inputVideoResolution?.width}x${session.inputVideoResolution?.height}`
|
|
328
|
+
: 'unknown';
|
|
319
329
|
|
|
320
330
|
settings.push(
|
|
321
331
|
{
|
|
@@ -323,7 +333,7 @@ class PrebufferSession {
|
|
|
323
333
|
group,
|
|
324
334
|
title: 'Detected Resolution and Bitrate',
|
|
325
335
|
readonly: true,
|
|
326
|
-
value: `${
|
|
336
|
+
value: `${resolution} @ ${bitrate || "unknown"} Kb/s`,
|
|
327
337
|
description: 'Configuring your camera to 1920x1080, 2000Kb/S, Variable Bit Rate, is recommended.',
|
|
328
338
|
},
|
|
329
339
|
{
|
|
@@ -591,12 +601,12 @@ class PrebufferSession {
|
|
|
591
601
|
const json = await mediaManager.convertMediaObjectToJSON<any>(mo, 'x-scrypted/x-rfc4571');
|
|
592
602
|
const { url, sdp, mediaStreamOptions } = json;
|
|
593
603
|
|
|
594
|
-
session = await startRFC4571Parser(this.console, connectRFC4571Parser(url), sdp, mediaStreamOptions,
|
|
604
|
+
session = await startRFC4571Parser(this.console, connectRFC4571Parser(url), sdp, mediaStreamOptions, rbo);
|
|
595
605
|
this.sdp = session.sdp.then(buffers => Buffer.concat(buffers).toString());
|
|
596
606
|
}
|
|
597
607
|
else {
|
|
598
608
|
const moBuffer = await mediaManager.convertMediaObjectToBuffer(mo, ScryptedMimeTypes.FFmpegInput);
|
|
599
|
-
const ffmpegInput = JSON.parse(moBuffer.toString()) as
|
|
609
|
+
const ffmpegInput = JSON.parse(moBuffer.toString()) as FFmpegInput;
|
|
600
610
|
sessionMso = ffmpegInput.mediaStreamOptions;
|
|
601
611
|
|
|
602
612
|
const parser = this.getParser(rtspMode, muxingMp4, ffmpegInput.mediaStreamOptions);
|
|
@@ -604,6 +614,14 @@ class PrebufferSession {
|
|
|
604
614
|
usingScryptedParser = true;
|
|
605
615
|
this.console.log('bypassing ffmpeg: using scrypted rtsp/rfc4571 parser');
|
|
606
616
|
const rtspClient = new RtspClient(ffmpegInput.url, this.console);
|
|
617
|
+
|
|
618
|
+
let servers: dgram.Socket[] = [];
|
|
619
|
+
const cleanupServers = () => {
|
|
620
|
+
for (const server of servers) {
|
|
621
|
+
closeQuiet(server);
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
|
|
607
625
|
try {
|
|
608
626
|
rtspClient.requestTimeout = 10000;
|
|
609
627
|
await rtspClient.options();
|
|
@@ -612,50 +630,100 @@ class PrebufferSession {
|
|
|
612
630
|
this.console.log('sdp', sdp);
|
|
613
631
|
|
|
614
632
|
const parsedSdp = parseSdp(sdp);
|
|
615
|
-
|
|
633
|
+
let channel = 0;
|
|
634
|
+
const mapping: RtspChannelCodecMapping = {};
|
|
635
|
+
const useUdp = false;
|
|
636
|
+
|
|
637
|
+
const doSetup = async (control: string, codec: string) => {
|
|
638
|
+
let setupChannel = channel;
|
|
639
|
+
if (useUdp) {
|
|
640
|
+
const rtspChannel = channel;
|
|
641
|
+
const { port, server } = await createBindZero();
|
|
642
|
+
servers.push(server);
|
|
643
|
+
setupChannel = port;
|
|
644
|
+
server.on('message', data => {
|
|
645
|
+
const prefix = Buffer.alloc(4);
|
|
646
|
+
prefix.writeUInt8(RTSP_FRAME_MAGIC, 0);
|
|
647
|
+
prefix.writeUInt8(rtspChannel, 1);
|
|
648
|
+
prefix.writeUInt16BE(data.length, 2);
|
|
649
|
+
const chunk: StreamChunk = {
|
|
650
|
+
chunks: [prefix, data],
|
|
651
|
+
type: codec,
|
|
652
|
+
};
|
|
653
|
+
session?.emit('rtsp', chunk);
|
|
654
|
+
session?.resetActivityTimer?.();
|
|
655
|
+
})
|
|
656
|
+
}
|
|
657
|
+
await rtspClient.setup(setupChannel, control, useUdp);
|
|
658
|
+
mapping[channel] = codec;
|
|
659
|
+
channel += 2;
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
// grab all available audio sections
|
|
663
|
+
if (!audioSoftMuted) {
|
|
664
|
+
for (const audioSection of parsedSdp.msections.filter(msection => msection.type === 'audio')) {
|
|
665
|
+
await doSetup(audioSection.control, audioSection.codec)
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
if (channel === 0)
|
|
669
|
+
this.console.warn('sdp did not contain audio track and audio was not reported as missing.');
|
|
670
|
+
}
|
|
671
|
+
|
|
616
672
|
const videoSection = parsedSdp.msections.find(msection => msection.type === 'video');
|
|
617
673
|
|
|
618
|
-
// sdp may contain multiple audio/video sections. take only the first.
|
|
619
|
-
parsedSdp.msections = parsedSdp.msections.filter(msection => msection ===
|
|
674
|
+
// sdp may contain multiple audio/video sections. take only the first video section.
|
|
675
|
+
parsedSdp.msections = parsedSdp.msections.filter(msection => msection === videoSection || msection.type === 'audio');
|
|
620
676
|
sdp = [...parsedSdp.header.lines, ...parsedSdp.msections.map(msection => msection.lines).flat()].join('\r\n');
|
|
621
677
|
|
|
622
678
|
this.sdp = Promise.resolve(sdp);
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
679
|
+
await doSetup(videoSection.control, videoSection.codec);
|
|
680
|
+
rtspClient.writePlay();
|
|
681
|
+
|
|
682
|
+
session = await startRFC4571Parser(this.console, rtspClient.client, sdp, ffmpegInput.mediaStreamOptions, rbo, {
|
|
683
|
+
channelMap: mapping,
|
|
684
|
+
handleRTSP: async () => {
|
|
685
|
+
await rtspClient.readMessage();
|
|
686
|
+
},
|
|
687
|
+
onLoop: async () => {
|
|
688
|
+
if (rtspClient.needKeepAlive) {
|
|
689
|
+
rtspClient.needKeepAlive = false;
|
|
690
|
+
rtspClient.writeGetParameter();
|
|
691
|
+
}
|
|
631
692
|
}
|
|
632
|
-
}
|
|
633
|
-
await rtspClient.setup(channel, videoSection.control);
|
|
634
|
-
await rtspClient.play();
|
|
635
|
-
session = await startRFC4571Parser(this.console, rtspClient.rfc4571, sdp, ffmpegInput.mediaStreamOptions, true, rbo);
|
|
693
|
+
});
|
|
636
694
|
const sessionKill = session.kill.bind(session);
|
|
637
695
|
let issuedTeardown = false;
|
|
638
696
|
session.kill = async () => {
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
697
|
+
try {
|
|
698
|
+
cleanupServers();
|
|
699
|
+
// issue a teardown to upstream to close gracefully but don't rely on it responding.
|
|
700
|
+
if (!issuedTeardown) {
|
|
701
|
+
issuedTeardown = true;
|
|
702
|
+
rtspClient.writeTeardown();
|
|
703
|
+
}
|
|
704
|
+
await sleep(500);
|
|
705
|
+
}
|
|
706
|
+
finally {
|
|
707
|
+
rtspClient.client.destroy();
|
|
708
|
+
sessionKill();
|
|
643
709
|
}
|
|
644
|
-
await sleep(500);
|
|
645
|
-
rtspClient.client.destroy();
|
|
646
|
-
sessionKill();
|
|
647
710
|
}
|
|
648
711
|
if (!session.isActive)
|
|
649
712
|
throw new Error('parser was killed before rtsp client started');
|
|
650
713
|
|
|
651
|
-
rtspClient.
|
|
714
|
+
rtspClient.client.on('close', () => session.kill());
|
|
652
715
|
}
|
|
653
716
|
catch (e) {
|
|
717
|
+
cleanupServers();
|
|
654
718
|
rtspClient.client.destroy();
|
|
655
719
|
throw e;
|
|
656
720
|
}
|
|
657
721
|
}
|
|
658
722
|
else {
|
|
723
|
+
if (parser === FFMPEG_PARSER_UDP)
|
|
724
|
+
ffmpegInput.inputArguments = ['-rtsp_transport', 'udp', '-i', ffmpegInput.url];
|
|
725
|
+
else if (parser === FFMPEG_PARSER_TCP)
|
|
726
|
+
ffmpegInput.inputArguments = ['-rtsp_transport', 'tcp', '-i', ffmpegInput.url];
|
|
659
727
|
// create missing pts from dts so mpegts and mp4 muxing does not fail
|
|
660
728
|
const extraInputArguments = this.storage.getItem(this.ffmpegInputArgumentsKey) || DEFAULT_FFMPEG_INPUT_ARGUMENTS;
|
|
661
729
|
ffmpegInput.inputArguments.unshift(...extraInputArguments.split(' '));
|
|
@@ -670,9 +738,8 @@ class PrebufferSession {
|
|
|
670
738
|
id: this.streamId,
|
|
671
739
|
refresh: false,
|
|
672
740
|
})
|
|
673
|
-
.then(async (
|
|
741
|
+
.then(async (ffmpegInput) => {
|
|
674
742
|
const extraInputArguments = this.storage.getItem(this.ffmpegInputArgumentsKey) || DEFAULT_FFMPEG_INPUT_ARGUMENTS;
|
|
675
|
-
const ffmpegInput = await mediaManager.convertMediaObjectToJSON<FFMpegInput>(stream, ScryptedMimeTypes.FFmpegInput);
|
|
676
743
|
ffmpegInput.inputArguments.unshift(...extraInputArguments.split(' '));
|
|
677
744
|
const mp4Session = await startFFMPegFragmentedMP4Session(ffmpegInput.inputArguments, acodec, vcodec, this.console);
|
|
678
745
|
|
|
@@ -750,7 +817,7 @@ class PrebufferSession {
|
|
|
750
817
|
return;
|
|
751
818
|
const mo = await this.mixinDevice.getVideoStream(mso);
|
|
752
819
|
const moBuffer = await mediaManager.convertMediaObjectToBuffer(mo, ScryptedMimeTypes.FFmpegInput);
|
|
753
|
-
const ffmpegInput = JSON.parse(moBuffer.toString()) as
|
|
820
|
+
const ffmpegInput = JSON.parse(moBuffer.toString()) as FFmpegInput;
|
|
754
821
|
mso = ffmpegInput.mediaStreamOptions;
|
|
755
822
|
|
|
756
823
|
scheduleRefresh(mso);
|
|
@@ -776,33 +843,34 @@ class PrebufferSession {
|
|
|
776
843
|
for (const container of PrebufferParserValues) {
|
|
777
844
|
let shifts = 0;
|
|
778
845
|
|
|
846
|
+
let prevIdr: number;
|
|
847
|
+
|
|
848
|
+
const updateIdr = (now: number) => {
|
|
849
|
+
if (prevIdr) {
|
|
850
|
+
const sendEvent = typeof this.detectedIdrInterval !== 'number';
|
|
851
|
+
this.detectedIdrInterval = now - prevIdr;
|
|
852
|
+
// only on the first idr update should we send a settings refresh.
|
|
853
|
+
if (sendEvent)
|
|
854
|
+
deviceManager.onMixinEvent(this.mixin.id, this.mixin.mixinProviderNativeId, ScryptedInterface.Settings, undefined);
|
|
855
|
+
}
|
|
856
|
+
prevIdr = now;
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
this.detectedIdrInterval = undefined;
|
|
779
860
|
session.on(container, (chunk: StreamChunk) => {
|
|
780
861
|
const prebufferContainer: PrebufferStreamChunk[] = this.prebuffers[container];
|
|
781
862
|
const now = Date.now();
|
|
782
863
|
|
|
783
|
-
const updateIdr = () => {
|
|
784
|
-
if (this.prevIdr) {
|
|
785
|
-
const sendEvent = typeof this.detectedIdrInterval !== 'number';
|
|
786
|
-
this.detectedIdrInterval = now - this.prevIdr;
|
|
787
|
-
// only on the first idr update should we send a settings refresh.
|
|
788
|
-
if (sendEvent)
|
|
789
|
-
deviceManager.onMixinEvent(this.mixin.id, this.mixin.mixinProviderNativeId, ScryptedInterface.Settings, undefined);
|
|
790
|
-
}
|
|
791
|
-
this.prevIdr = now;
|
|
792
|
-
}
|
|
793
|
-
|
|
794
864
|
// this is only valid for mp4, so its no op for everything else
|
|
795
865
|
// used to detect idr interval.
|
|
796
866
|
if (chunk.type === 'mdat') {
|
|
797
|
-
updateIdr();
|
|
867
|
+
updateIdr(now);
|
|
798
868
|
}
|
|
799
|
-
if (chunk.type === '
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
if (((fragmentType === 28 || fragmentType === 29) && nalType === 5 && startBit == 128) || fragmentType == 5) {
|
|
805
|
-
updateIdr();
|
|
869
|
+
else if (chunk.type === 'h264') {
|
|
870
|
+
if (findH264NaluType(chunk, H264_NAL_TYPE_IDR)) {
|
|
871
|
+
// only update the rtsp computed idr once a minute.
|
|
872
|
+
// per packet bitscan is not great.
|
|
873
|
+
updateIdr(now);
|
|
806
874
|
}
|
|
807
875
|
}
|
|
808
876
|
|
|
@@ -865,6 +933,7 @@ class PrebufferSession {
|
|
|
865
933
|
session: ParserSession<PrebufferParsers>,
|
|
866
934
|
socketPromise: Promise<Duplex>,
|
|
867
935
|
requestedPrebuffer: number,
|
|
936
|
+
filter?: (chunk: StreamChunk) => StreamChunk,
|
|
868
937
|
}) {
|
|
869
938
|
const { isActiveClient, container, session, socketPromise, requestedPrebuffer } = options;
|
|
870
939
|
if (requestedPrebuffer)
|
|
@@ -884,6 +953,11 @@ class PrebufferSession {
|
|
|
884
953
|
const now = Date.now();
|
|
885
954
|
|
|
886
955
|
const safeWriteData = (chunk: StreamChunk) => {
|
|
956
|
+
if (options.filter) {
|
|
957
|
+
chunk = options.filter(chunk);
|
|
958
|
+
if (!chunk)
|
|
959
|
+
return;
|
|
960
|
+
}
|
|
887
961
|
const buffered = writeData(chunk);
|
|
888
962
|
if (buffered > 100000000) {
|
|
889
963
|
this.console.log('more than 100MB has been buffered, did downstream die? killing connection.', this.streamName);
|
|
@@ -901,7 +975,7 @@ class PrebufferSession {
|
|
|
901
975
|
session.once('killed', cleanup);
|
|
902
976
|
|
|
903
977
|
const prebufferContainer: PrebufferStreamChunk[] = this.prebuffers[container];
|
|
904
|
-
if (
|
|
978
|
+
if (container !== 'rtsp') {
|
|
905
979
|
for (const prebuffer of prebufferContainer) {
|
|
906
980
|
if (prebuffer.time < now - requestedPrebuffer)
|
|
907
981
|
continue;
|
|
@@ -930,7 +1004,7 @@ class PrebufferSession {
|
|
|
930
1004
|
})
|
|
931
1005
|
}
|
|
932
1006
|
|
|
933
|
-
async getVideoStream(options?: RequestMediaStreamOptions)
|
|
1007
|
+
async getVideoStream(options?: RequestMediaStreamOptions) {
|
|
934
1008
|
if (options?.refresh === false && !this.parserSessionPromise)
|
|
935
1009
|
throw new Error('Stream is currently unavailable and will not be started for this request. RequestMediaStreamOptions.refresh === false');
|
|
936
1010
|
|
|
@@ -955,50 +1029,70 @@ class PrebufferSession {
|
|
|
955
1029
|
requestedPrebuffer += (this.detectedIdrInterval || 4000) * 1.5;
|
|
956
1030
|
}
|
|
957
1031
|
|
|
958
|
-
const
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
1032
|
+
const mediaStreamOptions: ResponseMediaStreamOptions = session.negotiateMediaStream(options);
|
|
1033
|
+
let sdp = await this.sdp;
|
|
1034
|
+
|
|
1035
|
+
let socketPromise: Promise<Duplex>;
|
|
1036
|
+
let url: string;
|
|
1037
|
+
let filter: (chunk: StreamChunk) => StreamChunk;
|
|
1038
|
+
const codecMap = new Map<string, number>();
|
|
1039
|
+
|
|
1040
|
+
if (container === 'rtsp') {
|
|
1041
|
+
const parsedSdp = parseSdp(sdp);
|
|
1042
|
+
if (parsedSdp.msections.length > 2) {
|
|
1043
|
+
parsedSdp.msections = parsedSdp.msections.filter(msection => msection.codec === mediaStreamOptions.video?.codec || msection.codec === mediaStreamOptions.audio?.codec);
|
|
1044
|
+
sdp = parsedSdp.toSdp();
|
|
1045
|
+
filter = chunk => {
|
|
1046
|
+
const channel = codecMap.get(chunk.type);
|
|
1047
|
+
if (channel == undefined)
|
|
1048
|
+
return;
|
|
1049
|
+
const chunks = chunk.chunks.slice();
|
|
1050
|
+
const header = Buffer.from(chunks[0]);
|
|
1051
|
+
header.writeUInt8(channel, 1);
|
|
1052
|
+
chunks[0] = header;
|
|
1053
|
+
return {
|
|
1054
|
+
startStream: chunk.startStream,
|
|
1055
|
+
chunks,
|
|
1056
|
+
}
|
|
1057
|
+
}
|
|
979
1058
|
}
|
|
980
1059
|
|
|
981
|
-
const
|
|
1060
|
+
const client = await listenZeroSingleClient();
|
|
1061
|
+
socketPromise = client.clientPromise.then(async (socket) => {
|
|
1062
|
+
sdp = addTrackControls(sdp);
|
|
1063
|
+
const server = new RtspServer(socket, sdp);
|
|
1064
|
+
server.console = this.console;
|
|
1065
|
+
await server.handlePlayback();
|
|
1066
|
+
for (const track of Object.values(server.setupTracks)) {
|
|
1067
|
+
codecMap.set(track.codec, track.destination);
|
|
1068
|
+
}
|
|
1069
|
+
return socket;
|
|
1070
|
+
})
|
|
1071
|
+
url = client.url.replace('tcp://', 'rtsp://');
|
|
1072
|
+
}
|
|
1073
|
+
else {
|
|
1074
|
+
const client = await listenZeroSingleClient();
|
|
1075
|
+
socketPromise = client.clientPromise;
|
|
1076
|
+
url = `tcp://127.0.0.1:${client.port}`
|
|
1077
|
+
}
|
|
982
1078
|
|
|
983
|
-
|
|
984
|
-
isActiveClient,
|
|
985
|
-
container,
|
|
986
|
-
requestedPrebuffer,
|
|
987
|
-
socketPromise,
|
|
988
|
-
session,
|
|
989
|
-
});
|
|
1079
|
+
mediaStreamOptions.sdp = sdp;
|
|
990
1080
|
|
|
991
|
-
|
|
992
|
-
}
|
|
1081
|
+
const isActiveClient = options?.refresh !== false;
|
|
993
1082
|
|
|
994
|
-
|
|
995
|
-
|
|
1083
|
+
this.handleRebroadcasterClient({
|
|
1084
|
+
isActiveClient,
|
|
1085
|
+
container,
|
|
1086
|
+
requestedPrebuffer,
|
|
1087
|
+
socketPromise,
|
|
1088
|
+
session,
|
|
1089
|
+
filter,
|
|
1090
|
+
});
|
|
996
1091
|
|
|
997
1092
|
mediaStreamOptions.prebuffer = requestedPrebuffer;
|
|
998
1093
|
|
|
999
1094
|
const { reencodeAudio } = this.getAudioConfig();
|
|
1000
1095
|
|
|
1001
|
-
let codecCopy = false;
|
|
1002
1096
|
if (this.audioDisabled) {
|
|
1003
1097
|
mediaStreamOptions.audio = null;
|
|
1004
1098
|
}
|
|
@@ -1009,33 +1103,9 @@ class PrebufferSession {
|
|
|
1009
1103
|
profile: 'aac_low',
|
|
1010
1104
|
}
|
|
1011
1105
|
}
|
|
1012
|
-
else {
|
|
1013
|
-
codecCopy = true;
|
|
1014
|
-
}
|
|
1015
1106
|
|
|
1016
|
-
if (
|
|
1017
|
-
|
|
1018
|
-
// verify what was found.
|
|
1019
|
-
if (session?.mediaStreamOptions?.audio?.codec === session?.inputAudioCodec) {
|
|
1020
|
-
mediaStreamOptions.audio = session?.mediaStreamOptions?.audio;
|
|
1021
|
-
}
|
|
1022
|
-
else {
|
|
1023
|
-
mediaStreamOptions.audio = {
|
|
1024
|
-
codec: session?.inputAudioCodec,
|
|
1025
|
-
}
|
|
1026
|
-
}
|
|
1027
|
-
}
|
|
1028
|
-
|
|
1029
|
-
if (!mediaStreamOptions.video)
|
|
1030
|
-
mediaStreamOptions.video = {};
|
|
1031
|
-
|
|
1032
|
-
mediaStreamOptions.video.codec = session.inputVideoCodec;
|
|
1033
|
-
|
|
1034
|
-
if (session.inputVideoResolution?.[2] && session.inputVideoResolution?.[3]) {
|
|
1035
|
-
Object.assign(mediaStreamOptions.video, {
|
|
1036
|
-
width: parseInt(session.inputVideoResolution[2]),
|
|
1037
|
-
height: parseInt(session.inputVideoResolution[3]),
|
|
1038
|
-
});
|
|
1107
|
+
if (session.inputVideoResolution?.width && session.inputVideoResolution?.height) {
|
|
1108
|
+
Object.assign(mediaStreamOptions.video, session.inputVideoResolution);
|
|
1039
1109
|
}
|
|
1040
1110
|
|
|
1041
1111
|
const now = Date.now();
|
|
@@ -1051,8 +1121,7 @@ class PrebufferSession {
|
|
|
1051
1121
|
|
|
1052
1122
|
const length = Math.max(500000, available).toString();
|
|
1053
1123
|
|
|
1054
|
-
const
|
|
1055
|
-
const ffmpegInput: FFMpegInput = {
|
|
1124
|
+
const ffmpegInput: FFmpegInput = {
|
|
1056
1125
|
url,
|
|
1057
1126
|
container,
|
|
1058
1127
|
inputArguments: [
|
|
@@ -1064,7 +1133,7 @@ class PrebufferSession {
|
|
|
1064
1133
|
mediaStreamOptions,
|
|
1065
1134
|
}
|
|
1066
1135
|
|
|
1067
|
-
return
|
|
1136
|
+
return ffmpegInput;
|
|
1068
1137
|
}
|
|
1069
1138
|
}
|
|
1070
1139
|
|
|
@@ -1074,7 +1143,7 @@ class PrebufferMixin extends SettingsMixinDeviceBase<VideoCamera & VideoCameraCo
|
|
|
1074
1143
|
|
|
1075
1144
|
streamSettings = createStreamSettings(this);
|
|
1076
1145
|
|
|
1077
|
-
constructor(public plugin:
|
|
1146
|
+
constructor(public plugin: RebroadcastPlugin, options: SettingsMixinDeviceOptions<VideoCamera & VideoCameraConfiguration>) {
|
|
1078
1147
|
super(options);
|
|
1079
1148
|
|
|
1080
1149
|
this.delayStart();
|
|
@@ -1093,43 +1162,71 @@ class PrebufferMixin extends SettingsMixinDeviceBase<VideoCamera & VideoCameraCo
|
|
|
1093
1162
|
await this.ensurePrebufferSessions();
|
|
1094
1163
|
|
|
1095
1164
|
let id = options?.id;
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
let result: {
|
|
1099
|
-
stream: ResponseMediaStreamOptions,
|
|
1100
|
-
isDefault: boolean,
|
|
1101
|
-
};
|
|
1165
|
+
let h264EncoderArguments: string[];
|
|
1166
|
+
let destinationVideoBitrate: number;
|
|
1102
1167
|
|
|
1103
|
-
|
|
1168
|
+
const msos = await this.mixinDevice.getVideoStreamOptions();
|
|
1169
|
+
let result: {
|
|
1170
|
+
stream: ResponseMediaStreamOptions,
|
|
1171
|
+
isDefault: boolean,
|
|
1172
|
+
title: string;
|
|
1173
|
+
};
|
|
1174
|
+
|
|
1175
|
+
const defaultLocalBitrate = 2000000;
|
|
1176
|
+
const defaultLowResolutionBitrate = 512000;
|
|
1177
|
+
if (!id) {
|
|
1178
|
+
switch (options?.destination) {
|
|
1179
|
+
case 'medium-resolution':
|
|
1104
1180
|
case 'remote':
|
|
1105
1181
|
result = this.streamSettings.getRemoteStream(msos);
|
|
1182
|
+
destinationVideoBitrate = this.plugin.storageSettings.values.remoteStreamingBitrate;
|
|
1106
1183
|
break;
|
|
1107
1184
|
case 'low-resolution':
|
|
1108
1185
|
result = this.streamSettings.getLowResolutionStream(msos);
|
|
1186
|
+
destinationVideoBitrate = defaultLowResolutionBitrate;
|
|
1109
1187
|
break;
|
|
1110
1188
|
case 'local-recorder':
|
|
1111
1189
|
result = this.streamSettings.getRecordingStream(msos);
|
|
1190
|
+
destinationVideoBitrate = defaultLocalBitrate;
|
|
1112
1191
|
break;
|
|
1113
1192
|
case 'remote-recorder':
|
|
1114
1193
|
result = this.streamSettings.getRemoteRecordingStream(msos);
|
|
1194
|
+
destinationVideoBitrate = defaultLocalBitrate;
|
|
1115
1195
|
break;
|
|
1116
1196
|
default:
|
|
1117
1197
|
result = this.streamSettings.getDefaultStream(msos);
|
|
1198
|
+
destinationVideoBitrate = defaultLocalBitrate;
|
|
1118
1199
|
break;
|
|
1119
1200
|
}
|
|
1120
1201
|
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1202
|
+
id = result.stream.id;
|
|
1203
|
+
this.console.log('Selected stream', result.stream.name);
|
|
1204
|
+
// transcoding video should never happen transparently since it is CPU intensive.
|
|
1205
|
+
// encourage users at every step to configure proper codecs.
|
|
1206
|
+
// for this reason, do not automatically supply h264 encoder arguments
|
|
1207
|
+
// even if h264 is requested, to force a visible failure.
|
|
1208
|
+
if (this.streamSettings.storageSettings.values.transcodeStreams?.includes(result.title)) {
|
|
1209
|
+
h264EncoderArguments = this.plugin.storageSettings.values.h264EncoderArguments?.split(' ');
|
|
1126
1210
|
}
|
|
1127
1211
|
}
|
|
1128
1212
|
|
|
1129
1213
|
const session = this.sessions.get(id);
|
|
1130
1214
|
if (!session)
|
|
1131
1215
|
return this.mixinDevice.getVideoStream(options);
|
|
1132
|
-
|
|
1216
|
+
|
|
1217
|
+
const ffmpegInput = await session.getVideoStream(options);
|
|
1218
|
+
ffmpegInput.h264EncoderArguments = h264EncoderArguments;
|
|
1219
|
+
ffmpegInput.destinationVideoBitrate = destinationVideoBitrate;
|
|
1220
|
+
|
|
1221
|
+
if (this.streamSettings.storageSettings.values.missingCodecParameters) {
|
|
1222
|
+
ffmpegInput.h264FilterArguments = ffmpegInput.h264FilterArguments || [];
|
|
1223
|
+
ffmpegInput.h264FilterArguments.push("-bsf:v", "dump_extra");
|
|
1224
|
+
}
|
|
1225
|
+
|
|
1226
|
+
ffmpegInput.videoDecoderArguments = this.streamSettings.storageSettings.values.videoDecoderArguments?.split(' ');
|
|
1227
|
+
return mediaManager.createFFmpegMediaObject(ffmpegInput, {
|
|
1228
|
+
sourceId: this.id,
|
|
1229
|
+
});
|
|
1133
1230
|
}
|
|
1134
1231
|
|
|
1135
1232
|
async ensurePrebufferSessions() {
|
|
@@ -1241,13 +1338,19 @@ class PrebufferMixin extends SettingsMixinDeviceBase<VideoCamera & VideoCameraCo
|
|
|
1241
1338
|
}
|
|
1242
1339
|
|
|
1243
1340
|
async putMixinSetting(key: string, value: SettingValue): Promise<void> {
|
|
1244
|
-
const sessions = this.sessions;
|
|
1245
|
-
this.sessions = new Map();
|
|
1246
1341
|
if (this.streamSettings.storageSettings.settings[key])
|
|
1247
1342
|
await this.streamSettings.storageSettings.putSetting(key, value);
|
|
1248
1343
|
else
|
|
1249
1344
|
this.storage.setItem(key, value?.toString());
|
|
1250
1345
|
|
|
1346
|
+
// no prebuffer change necessary if the setting is a transcoding hint.
|
|
1347
|
+
if (this.streamSettings.storageSettings.settings[key]?.group === 'Transcoding')
|
|
1348
|
+
return;
|
|
1349
|
+
|
|
1350
|
+
const sessions = this.sessions;
|
|
1351
|
+
this.sessions = new Map();
|
|
1352
|
+
|
|
1353
|
+
// kill and reinitiate the prebuffers.
|
|
1251
1354
|
for (const session of sessions.values()) {
|
|
1252
1355
|
session?.parserSessionPromise?.then(session => session.kill());
|
|
1253
1356
|
}
|
|
@@ -1308,12 +1411,26 @@ function millisUntilMidnight() {
|
|
|
1308
1411
|
return (midnight.getTime() - new Date().getTime());
|
|
1309
1412
|
}
|
|
1310
1413
|
|
|
1311
|
-
class
|
|
1414
|
+
class RebroadcastPlugin extends AutoenableMixinProvider implements MixinProvider, BufferConverter, Settings {
|
|
1312
1415
|
storageSettings = new StorageSettings(this, {
|
|
1313
1416
|
rebroadcastPort: {
|
|
1314
1417
|
title: 'Rebroadcast Port',
|
|
1315
1418
|
description: 'The port of the RTSP server that will rebroadcast your streams.',
|
|
1316
1419
|
type: 'number',
|
|
1420
|
+
},
|
|
1421
|
+
remoteStreamingBitrate: {
|
|
1422
|
+
title: 'Remote Streaming Bitrate',
|
|
1423
|
+
type: 'number',
|
|
1424
|
+
defaultValue: 1000000,
|
|
1425
|
+
description: 'The bitrate to use when remote streaming. This setting will only be used when transcoding or adaptive bitrate is enabled on a camera.',
|
|
1426
|
+
},
|
|
1427
|
+
h264EncoderArguments: {
|
|
1428
|
+
title: 'H264 Encoder Arguments',
|
|
1429
|
+
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.',
|
|
1430
|
+
choices: Object.keys(getH264EncoderArgs()),
|
|
1431
|
+
defaultValue: getH264EncoderArgs()[LIBX264_ENCODER_TITLE].join(' '),
|
|
1432
|
+
combobox: true,
|
|
1433
|
+
mapPut: (oldValue, newValue) => getH264EncoderArgs()[newValue]?.join(' ') || newValue || getH264EncoderArgs()[LIBX264_ENCODER_TITLE]?.join(' '),
|
|
1317
1434
|
}
|
|
1318
1435
|
});
|
|
1319
1436
|
rtspServer: net.Server;
|
|
@@ -1350,6 +1467,14 @@ class PrebufferProvider extends AutoenableMixinProvider implements MixinProvider
|
|
|
1350
1467
|
this.startRtspServer();
|
|
1351
1468
|
}
|
|
1352
1469
|
|
|
1470
|
+
getSettings(): Promise<Setting[]> {
|
|
1471
|
+
return this.storageSettings.getSettings();
|
|
1472
|
+
}
|
|
1473
|
+
|
|
1474
|
+
putSetting(key: string, value: SettingValue): Promise<void> {
|
|
1475
|
+
return this.storageSettings.putSetting(key, value);
|
|
1476
|
+
}
|
|
1477
|
+
|
|
1353
1478
|
startRtspServer() {
|
|
1354
1479
|
closeQuiet(this.rtspServer);
|
|
1355
1480
|
|
|
@@ -1413,13 +1538,19 @@ class PrebufferProvider extends AutoenableMixinProvider implements MixinProvider
|
|
|
1413
1538
|
const json = JSON.parse(data.toString());
|
|
1414
1539
|
const { url, sdp } = json;
|
|
1415
1540
|
|
|
1416
|
-
const
|
|
1541
|
+
const parsedSdp = parseSdp(sdp);
|
|
1542
|
+
const trackLookups = new Map<number, string>();
|
|
1543
|
+
for (const msection of parsedSdp.msections) {
|
|
1544
|
+
for (const pt of msection.payloadTypes) {
|
|
1545
|
+
trackLookups.set(pt, msection.control);
|
|
1546
|
+
}
|
|
1547
|
+
}
|
|
1417
1548
|
|
|
1418
1549
|
const u = new URL(url);
|
|
1419
1550
|
if (!u.protocol.startsWith('tcp'))
|
|
1420
1551
|
throw new Error('rfc4751 url must be tcp');
|
|
1421
1552
|
const { clientPromise, url: clientUrl } = await listenZeroSingleClient();
|
|
1422
|
-
const ffmpeg:
|
|
1553
|
+
const ffmpeg: FFmpegInput = {
|
|
1423
1554
|
url: clientUrl,
|
|
1424
1555
|
inputArguments: [
|
|
1425
1556
|
"-rtsp_transport", "tcp",
|
|
@@ -1445,19 +1576,15 @@ class PrebufferProvider extends AutoenableMixinProvider implements MixinProvider
|
|
|
1445
1576
|
const length = header.readInt16BE(0);
|
|
1446
1577
|
const data = await readLength(socket, length);
|
|
1447
1578
|
const pt = data[1] & 0x7f;
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
}
|
|
1451
|
-
else if (videoPayloadTypes.has(pt)) {
|
|
1452
|
-
rtsp.sendVideo(data, false);
|
|
1453
|
-
}
|
|
1454
|
-
else {
|
|
1579
|
+
const track = trackLookups.get(pt);
|
|
1580
|
+
if (!track) {
|
|
1455
1581
|
client.destroy();
|
|
1456
1582
|
socket.destroy();
|
|
1457
1583
|
throw new Error('unknown payload type ' + pt);
|
|
1458
1584
|
}
|
|
1585
|
+
rtsp.sendTrack(track, data, false);
|
|
1459
1586
|
}
|
|
1460
|
-
})
|
|
1587
|
+
});
|
|
1461
1588
|
|
|
1462
1589
|
return Buffer.from(JSON.stringify(ffmpeg));
|
|
1463
1590
|
}
|
|
@@ -1478,7 +1605,7 @@ class PrebufferProvider extends AutoenableMixinProvider implements MixinProvider
|
|
|
1478
1605
|
mixinDeviceState,
|
|
1479
1606
|
mixinProviderNativeId: this.nativeId,
|
|
1480
1607
|
mixinDeviceInterfaces,
|
|
1481
|
-
group: "Stream
|
|
1608
|
+
group: "Stream Management",
|
|
1482
1609
|
groupKey: "prebuffer",
|
|
1483
1610
|
});
|
|
1484
1611
|
this.currentMixins.set(mixinDeviceState.id, ret);
|
|
@@ -1492,4 +1619,4 @@ class PrebufferProvider extends AutoenableMixinProvider implements MixinProvider
|
|
|
1492
1619
|
}
|
|
1493
1620
|
}
|
|
1494
1621
|
|
|
1495
|
-
export default new
|
|
1622
|
+
export default new RebroadcastPlugin();
|