@scrypted/prebuffer-mixin 0.1.224 → 0.1.225
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 +251 -140
- package/src/rfc4571.ts +113 -37
- 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/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,30 +630,60 @@ class PrebufferSession {
|
|
|
612
630
|
this.console.log('sdp', sdp);
|
|
613
631
|
|
|
614
632
|
const parsedSdp = parseSdp(sdp);
|
|
615
|
-
const audioSection = parsedSdp.msections.find(msection => msection.type === 'audio');
|
|
616
|
-
const videoSection = parsedSdp.msections.find(msection => msection.type === 'video');
|
|
617
|
-
|
|
618
|
-
// sdp may contain multiple audio/video sections. take only the first.
|
|
619
|
-
parsedSdp.msections = parsedSdp.msections.filter(msection => msection === audioSection || msection === videoSection);
|
|
620
|
-
sdp = [...parsedSdp.header.lines, ...parsedSdp.msections.map(msection => msection.lines).flat()].join('\r\n');
|
|
621
|
-
|
|
622
|
-
this.sdp = Promise.resolve(sdp);
|
|
623
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
|
|
624
663
|
if (!audioSoftMuted) {
|
|
625
|
-
|
|
626
|
-
await
|
|
627
|
-
channel += 2;
|
|
664
|
+
for (const audioSection of parsedSdp.msections.filter(msection => msection.type === 'audio')) {
|
|
665
|
+
await doSetup(audioSection.control, audioSection.codec)
|
|
628
666
|
}
|
|
629
|
-
|
|
667
|
+
|
|
668
|
+
if (channel === 0)
|
|
630
669
|
this.console.warn('sdp did not contain audio track and audio was not reported as missing.');
|
|
631
|
-
}
|
|
632
670
|
}
|
|
633
|
-
|
|
671
|
+
|
|
672
|
+
const videoSection = parsedSdp.msections.find(msection => msection.type === 'video');
|
|
673
|
+
|
|
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');
|
|
676
|
+
sdp = [...parsedSdp.header.lines, ...parsedSdp.msections.map(msection => msection.lines).flat()].join('\r\n');
|
|
677
|
+
|
|
678
|
+
this.sdp = Promise.resolve(sdp);
|
|
679
|
+
await doSetup(videoSection.control, videoSection.codec);
|
|
634
680
|
await rtspClient.play();
|
|
635
|
-
|
|
681
|
+
|
|
682
|
+
session = await startRFC4571Parser(this.console, rtspClient.rfc4571, sdp, ffmpegInput.mediaStreamOptions, rbo, mapping);
|
|
636
683
|
const sessionKill = session.kill.bind(session);
|
|
637
684
|
let issuedTeardown = false;
|
|
638
685
|
session.kill = async () => {
|
|
686
|
+
cleanupServers();
|
|
639
687
|
// issue a teardown to upstream to close gracefully but don't rely on it responding.
|
|
640
688
|
if (!issuedTeardown) {
|
|
641
689
|
issuedTeardown = true;
|
|
@@ -651,11 +699,16 @@ class PrebufferSession {
|
|
|
651
699
|
rtspClient.readLoop().finally(() => session.kill());
|
|
652
700
|
}
|
|
653
701
|
catch (e) {
|
|
702
|
+
cleanupServers();
|
|
654
703
|
rtspClient.client.destroy();
|
|
655
704
|
throw e;
|
|
656
705
|
}
|
|
657
706
|
}
|
|
658
707
|
else {
|
|
708
|
+
if (parser === FFMPEG_PARSER_UDP)
|
|
709
|
+
ffmpegInput.inputArguments = ['-rtsp_transport', 'udp', '-i', ffmpegInput.url];
|
|
710
|
+
else if (parser === FFMPEG_PARSER_TCP)
|
|
711
|
+
ffmpegInput.inputArguments = ['-rtsp_transport', 'tcp', '-i', ffmpegInput.url];
|
|
659
712
|
// create missing pts from dts so mpegts and mp4 muxing does not fail
|
|
660
713
|
const extraInputArguments = this.storage.getItem(this.ffmpegInputArgumentsKey) || DEFAULT_FFMPEG_INPUT_ARGUMENTS;
|
|
661
714
|
ffmpegInput.inputArguments.unshift(...extraInputArguments.split(' '));
|
|
@@ -670,9 +723,8 @@ class PrebufferSession {
|
|
|
670
723
|
id: this.streamId,
|
|
671
724
|
refresh: false,
|
|
672
725
|
})
|
|
673
|
-
.then(async (
|
|
726
|
+
.then(async (ffmpegInput) => {
|
|
674
727
|
const extraInputArguments = this.storage.getItem(this.ffmpegInputArgumentsKey) || DEFAULT_FFMPEG_INPUT_ARGUMENTS;
|
|
675
|
-
const ffmpegInput = await mediaManager.convertMediaObjectToJSON<FFMpegInput>(stream, ScryptedMimeTypes.FFmpegInput);
|
|
676
728
|
ffmpegInput.inputArguments.unshift(...extraInputArguments.split(' '));
|
|
677
729
|
const mp4Session = await startFFMPegFragmentedMP4Session(ffmpegInput.inputArguments, acodec, vcodec, this.console);
|
|
678
730
|
|
|
@@ -750,7 +802,7 @@ class PrebufferSession {
|
|
|
750
802
|
return;
|
|
751
803
|
const mo = await this.mixinDevice.getVideoStream(mso);
|
|
752
804
|
const moBuffer = await mediaManager.convertMediaObjectToBuffer(mo, ScryptedMimeTypes.FFmpegInput);
|
|
753
|
-
const ffmpegInput = JSON.parse(moBuffer.toString()) as
|
|
805
|
+
const ffmpegInput = JSON.parse(moBuffer.toString()) as FFmpegInput;
|
|
754
806
|
mso = ffmpegInput.mediaStreamOptions;
|
|
755
807
|
|
|
756
808
|
scheduleRefresh(mso);
|
|
@@ -776,19 +828,21 @@ class PrebufferSession {
|
|
|
776
828
|
for (const container of PrebufferParserValues) {
|
|
777
829
|
let shifts = 0;
|
|
778
830
|
|
|
831
|
+
let prevIdr: number;
|
|
832
|
+
this.detectedIdrInterval = undefined;
|
|
779
833
|
session.on(container, (chunk: StreamChunk) => {
|
|
780
834
|
const prebufferContainer: PrebufferStreamChunk[] = this.prebuffers[container];
|
|
781
835
|
const now = Date.now();
|
|
782
836
|
|
|
783
837
|
const updateIdr = () => {
|
|
784
|
-
if (
|
|
838
|
+
if (prevIdr) {
|
|
785
839
|
const sendEvent = typeof this.detectedIdrInterval !== 'number';
|
|
786
|
-
this.detectedIdrInterval = now -
|
|
840
|
+
this.detectedIdrInterval = now - prevIdr;
|
|
787
841
|
// only on the first idr update should we send a settings refresh.
|
|
788
842
|
if (sendEvent)
|
|
789
843
|
deviceManager.onMixinEvent(this.mixin.id, this.mixin.mixinProviderNativeId, ScryptedInterface.Settings, undefined);
|
|
790
844
|
}
|
|
791
|
-
|
|
845
|
+
prevIdr = now;
|
|
792
846
|
}
|
|
793
847
|
|
|
794
848
|
// this is only valid for mp4, so its no op for everything else
|
|
@@ -796,12 +850,10 @@ class PrebufferSession {
|
|
|
796
850
|
if (chunk.type === 'mdat') {
|
|
797
851
|
updateIdr();
|
|
798
852
|
}
|
|
799
|
-
if (chunk.type === '
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
const startBit = second & 0x80;
|
|
804
|
-
if (((fragmentType === 28 || fragmentType === 29) && nalType === 5 && startBit == 128) || fragmentType == 5) {
|
|
853
|
+
else if (chunk.type === 'h264') {
|
|
854
|
+
if (findH264NaluType(chunk, H264_NAL_TYPE_IDR)) {
|
|
855
|
+
// only update the rtsp computed idr once a minute.
|
|
856
|
+
// per packet bitscan is not great.
|
|
805
857
|
updateIdr();
|
|
806
858
|
}
|
|
807
859
|
}
|
|
@@ -865,6 +917,7 @@ class PrebufferSession {
|
|
|
865
917
|
session: ParserSession<PrebufferParsers>,
|
|
866
918
|
socketPromise: Promise<Duplex>,
|
|
867
919
|
requestedPrebuffer: number,
|
|
920
|
+
filter?: (chunk: StreamChunk) => StreamChunk,
|
|
868
921
|
}) {
|
|
869
922
|
const { isActiveClient, container, session, socketPromise, requestedPrebuffer } = options;
|
|
870
923
|
if (requestedPrebuffer)
|
|
@@ -884,6 +937,11 @@ class PrebufferSession {
|
|
|
884
937
|
const now = Date.now();
|
|
885
938
|
|
|
886
939
|
const safeWriteData = (chunk: StreamChunk) => {
|
|
940
|
+
if (options.filter) {
|
|
941
|
+
chunk = options.filter(chunk);
|
|
942
|
+
if (!chunk)
|
|
943
|
+
return;
|
|
944
|
+
}
|
|
887
945
|
const buffered = writeData(chunk);
|
|
888
946
|
if (buffered > 100000000) {
|
|
889
947
|
this.console.log('more than 100MB has been buffered, did downstream die? killing connection.', this.streamName);
|
|
@@ -901,7 +959,7 @@ class PrebufferSession {
|
|
|
901
959
|
session.once('killed', cleanup);
|
|
902
960
|
|
|
903
961
|
const prebufferContainer: PrebufferStreamChunk[] = this.prebuffers[container];
|
|
904
|
-
if (
|
|
962
|
+
if (container !== 'rtsp') {
|
|
905
963
|
for (const prebuffer of prebufferContainer) {
|
|
906
964
|
if (prebuffer.time < now - requestedPrebuffer)
|
|
907
965
|
continue;
|
|
@@ -930,7 +988,7 @@ class PrebufferSession {
|
|
|
930
988
|
})
|
|
931
989
|
}
|
|
932
990
|
|
|
933
|
-
async getVideoStream(options?: RequestMediaStreamOptions)
|
|
991
|
+
async getVideoStream(options?: RequestMediaStreamOptions) {
|
|
934
992
|
if (options?.refresh === false && !this.parserSessionPromise)
|
|
935
993
|
throw new Error('Stream is currently unavailable and will not be started for this request. RequestMediaStreamOptions.refresh === false');
|
|
936
994
|
|
|
@@ -955,50 +1013,70 @@ class PrebufferSession {
|
|
|
955
1013
|
requestedPrebuffer += (this.detectedIdrInterval || 4000) * 1.5;
|
|
956
1014
|
}
|
|
957
1015
|
|
|
958
|
-
const
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
1016
|
+
const mediaStreamOptions: ResponseMediaStreamOptions = session.negotiateMediaStream(options);
|
|
1017
|
+
let sdp = await this.sdp;
|
|
1018
|
+
|
|
1019
|
+
let socketPromise: Promise<Duplex>;
|
|
1020
|
+
let url: string;
|
|
1021
|
+
let filter: (chunk: StreamChunk) => StreamChunk;
|
|
1022
|
+
const codecMap = new Map<string, number>();
|
|
1023
|
+
|
|
1024
|
+
if (container === 'rtsp') {
|
|
1025
|
+
const parsedSdp = parseSdp(sdp);
|
|
1026
|
+
if (parsedSdp.msections.length > 2) {
|
|
1027
|
+
parsedSdp.msections = parsedSdp.msections.filter(msection => msection.codec === mediaStreamOptions.video?.codec || msection.codec === mediaStreamOptions.audio?.codec);
|
|
1028
|
+
sdp = parsedSdp.toSdp();
|
|
1029
|
+
filter = chunk => {
|
|
1030
|
+
const channel = codecMap.get(chunk.type);
|
|
1031
|
+
if (channel == undefined)
|
|
1032
|
+
return;
|
|
1033
|
+
const chunks = chunk.chunks.slice();
|
|
1034
|
+
const header = Buffer.from(chunks[0]);
|
|
1035
|
+
header.writeUInt8(channel, 1);
|
|
1036
|
+
chunks[0] = header;
|
|
1037
|
+
return {
|
|
1038
|
+
startStream: chunk.startStream,
|
|
1039
|
+
chunks,
|
|
1040
|
+
}
|
|
1041
|
+
}
|
|
979
1042
|
}
|
|
980
1043
|
|
|
981
|
-
const
|
|
1044
|
+
const client = await listenZeroSingleClient();
|
|
1045
|
+
socketPromise = client.clientPromise.then(async (socket) => {
|
|
1046
|
+
sdp = addTrackControls(sdp);
|
|
1047
|
+
const server = new RtspServer(socket, sdp);
|
|
1048
|
+
server.console = this.console;
|
|
1049
|
+
await server.handlePlayback();
|
|
1050
|
+
for (const track of Object.values(server.setupTracks)) {
|
|
1051
|
+
codecMap.set(track.codec, track.destination);
|
|
1052
|
+
}
|
|
1053
|
+
return socket;
|
|
1054
|
+
})
|
|
1055
|
+
url = client.url.replace('tcp://', 'rtsp://');
|
|
1056
|
+
}
|
|
1057
|
+
else {
|
|
1058
|
+
const client = await listenZeroSingleClient();
|
|
1059
|
+
socketPromise = client.clientPromise;
|
|
1060
|
+
url = `tcp://127.0.0.1:${client.port}`
|
|
1061
|
+
}
|
|
982
1062
|
|
|
983
|
-
|
|
984
|
-
isActiveClient,
|
|
985
|
-
container,
|
|
986
|
-
requestedPrebuffer,
|
|
987
|
-
socketPromise,
|
|
988
|
-
session,
|
|
989
|
-
});
|
|
1063
|
+
mediaStreamOptions.sdp = sdp;
|
|
990
1064
|
|
|
991
|
-
|
|
992
|
-
}
|
|
1065
|
+
const isActiveClient = options?.refresh !== false;
|
|
993
1066
|
|
|
994
|
-
|
|
995
|
-
|
|
1067
|
+
this.handleRebroadcasterClient({
|
|
1068
|
+
isActiveClient,
|
|
1069
|
+
container,
|
|
1070
|
+
requestedPrebuffer,
|
|
1071
|
+
socketPromise,
|
|
1072
|
+
session,
|
|
1073
|
+
filter,
|
|
1074
|
+
});
|
|
996
1075
|
|
|
997
1076
|
mediaStreamOptions.prebuffer = requestedPrebuffer;
|
|
998
1077
|
|
|
999
1078
|
const { reencodeAudio } = this.getAudioConfig();
|
|
1000
1079
|
|
|
1001
|
-
let codecCopy = false;
|
|
1002
1080
|
if (this.audioDisabled) {
|
|
1003
1081
|
mediaStreamOptions.audio = null;
|
|
1004
1082
|
}
|
|
@@ -1009,33 +1087,9 @@ class PrebufferSession {
|
|
|
1009
1087
|
profile: 'aac_low',
|
|
1010
1088
|
}
|
|
1011
1089
|
}
|
|
1012
|
-
else {
|
|
1013
|
-
codecCopy = true;
|
|
1014
|
-
}
|
|
1015
1090
|
|
|
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
|
-
});
|
|
1091
|
+
if (session.inputVideoResolution?.width && session.inputVideoResolution?.height) {
|
|
1092
|
+
Object.assign(mediaStreamOptions.video, session.inputVideoResolution);
|
|
1039
1093
|
}
|
|
1040
1094
|
|
|
1041
1095
|
const now = Date.now();
|
|
@@ -1051,8 +1105,7 @@ class PrebufferSession {
|
|
|
1051
1105
|
|
|
1052
1106
|
const length = Math.max(500000, available).toString();
|
|
1053
1107
|
|
|
1054
|
-
const
|
|
1055
|
-
const ffmpegInput: FFMpegInput = {
|
|
1108
|
+
const ffmpegInput: FFmpegInput = {
|
|
1056
1109
|
url,
|
|
1057
1110
|
container,
|
|
1058
1111
|
inputArguments: [
|
|
@@ -1064,7 +1117,7 @@ class PrebufferSession {
|
|
|
1064
1117
|
mediaStreamOptions,
|
|
1065
1118
|
}
|
|
1066
1119
|
|
|
1067
|
-
return
|
|
1120
|
+
return ffmpegInput;
|
|
1068
1121
|
}
|
|
1069
1122
|
}
|
|
1070
1123
|
|
|
@@ -1074,7 +1127,7 @@ class PrebufferMixin extends SettingsMixinDeviceBase<VideoCamera & VideoCameraCo
|
|
|
1074
1127
|
|
|
1075
1128
|
streamSettings = createStreamSettings(this);
|
|
1076
1129
|
|
|
1077
|
-
constructor(public plugin:
|
|
1130
|
+
constructor(public plugin: RebroadcastPlugin, options: SettingsMixinDeviceOptions<VideoCamera & VideoCameraConfiguration>) {
|
|
1078
1131
|
super(options);
|
|
1079
1132
|
|
|
1080
1133
|
this.delayStart();
|
|
@@ -1093,43 +1146,71 @@ class PrebufferMixin extends SettingsMixinDeviceBase<VideoCamera & VideoCameraCo
|
|
|
1093
1146
|
await this.ensurePrebufferSessions();
|
|
1094
1147
|
|
|
1095
1148
|
let id = options?.id;
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
let result: {
|
|
1099
|
-
stream: ResponseMediaStreamOptions,
|
|
1100
|
-
isDefault: boolean,
|
|
1101
|
-
};
|
|
1149
|
+
let h264EncoderArguments: string[];
|
|
1150
|
+
let destinationVideoBitrate: number;
|
|
1102
1151
|
|
|
1152
|
+
const msos = await this.mixinDevice.getVideoStreamOptions();
|
|
1153
|
+
let result: {
|
|
1154
|
+
stream: ResponseMediaStreamOptions,
|
|
1155
|
+
isDefault: boolean,
|
|
1156
|
+
title: string;
|
|
1157
|
+
};
|
|
1158
|
+
|
|
1159
|
+
const defaultLocalBitrate = 2000000;
|
|
1160
|
+
const defaultLowResolutionBitrate = 512000;
|
|
1161
|
+
if (!options.id) {
|
|
1103
1162
|
switch (options.destination) {
|
|
1163
|
+
case 'medium-resolution':
|
|
1104
1164
|
case 'remote':
|
|
1105
1165
|
result = this.streamSettings.getRemoteStream(msos);
|
|
1166
|
+
destinationVideoBitrate = this.plugin.storageSettings.values.remoteStreamingBitrate;
|
|
1106
1167
|
break;
|
|
1107
1168
|
case 'low-resolution':
|
|
1108
1169
|
result = this.streamSettings.getLowResolutionStream(msos);
|
|
1170
|
+
destinationVideoBitrate = defaultLowResolutionBitrate;
|
|
1109
1171
|
break;
|
|
1110
1172
|
case 'local-recorder':
|
|
1111
1173
|
result = this.streamSettings.getRecordingStream(msos);
|
|
1174
|
+
destinationVideoBitrate = defaultLocalBitrate;
|
|
1112
1175
|
break;
|
|
1113
1176
|
case 'remote-recorder':
|
|
1114
1177
|
result = this.streamSettings.getRemoteRecordingStream(msos);
|
|
1178
|
+
destinationVideoBitrate = defaultLocalBitrate;
|
|
1115
1179
|
break;
|
|
1116
1180
|
default:
|
|
1117
1181
|
result = this.streamSettings.getDefaultStream(msos);
|
|
1182
|
+
destinationVideoBitrate = defaultLocalBitrate;
|
|
1118
1183
|
break;
|
|
1119
1184
|
}
|
|
1120
1185
|
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1186
|
+
id = result.stream.id;
|
|
1187
|
+
this.console.log('Selected stream', result.stream.name);
|
|
1188
|
+
// transcoding video should never happen transparently since it is CPU intensive.
|
|
1189
|
+
// encourage users at every step to configure proper codecs.
|
|
1190
|
+
// for this reason, do not automatically supply h264 encoder arguments
|
|
1191
|
+
// even if h264 is requested, to force a visible failure.
|
|
1192
|
+
if (this.streamSettings.storageSettings.values.transcodeStreams?.includes(result.title)) {
|
|
1193
|
+
h264EncoderArguments = this.plugin.storageSettings.values.h264EncoderArguments?.split(' ');
|
|
1126
1194
|
}
|
|
1127
1195
|
}
|
|
1128
1196
|
|
|
1129
1197
|
const session = this.sessions.get(id);
|
|
1130
1198
|
if (!session)
|
|
1131
1199
|
return this.mixinDevice.getVideoStream(options);
|
|
1132
|
-
|
|
1200
|
+
|
|
1201
|
+
const ffmpegInput = await session.getVideoStream(options);
|
|
1202
|
+
ffmpegInput.h264EncoderArguments = h264EncoderArguments;
|
|
1203
|
+
ffmpegInput.destinationVideoBitrate = destinationVideoBitrate;
|
|
1204
|
+
|
|
1205
|
+
if (this.streamSettings.storageSettings.values.missingCodecParameters) {
|
|
1206
|
+
ffmpegInput.h264FilterArguments = ffmpegInput.h264FilterArguments || [];
|
|
1207
|
+
ffmpegInput.h264FilterArguments.push("-bsf:v", "dump_extra");
|
|
1208
|
+
}
|
|
1209
|
+
|
|
1210
|
+
ffmpegInput.videoDecoderArguments = this.streamSettings.storageSettings.values.videoDecoderArguments?.split(' ');
|
|
1211
|
+
return mediaManager.createFFmpegMediaObject(ffmpegInput, {
|
|
1212
|
+
sourceId: this.id,
|
|
1213
|
+
});
|
|
1133
1214
|
}
|
|
1134
1215
|
|
|
1135
1216
|
async ensurePrebufferSessions() {
|
|
@@ -1241,13 +1322,19 @@ class PrebufferMixin extends SettingsMixinDeviceBase<VideoCamera & VideoCameraCo
|
|
|
1241
1322
|
}
|
|
1242
1323
|
|
|
1243
1324
|
async putMixinSetting(key: string, value: SettingValue): Promise<void> {
|
|
1244
|
-
const sessions = this.sessions;
|
|
1245
|
-
this.sessions = new Map();
|
|
1246
1325
|
if (this.streamSettings.storageSettings.settings[key])
|
|
1247
1326
|
await this.streamSettings.storageSettings.putSetting(key, value);
|
|
1248
1327
|
else
|
|
1249
1328
|
this.storage.setItem(key, value?.toString());
|
|
1250
1329
|
|
|
1330
|
+
// no prebuffer change necessary if the setting is a transcoding hint.
|
|
1331
|
+
if (this.streamSettings.storageSettings.settings[key]?.group === 'Transcoding')
|
|
1332
|
+
return;
|
|
1333
|
+
|
|
1334
|
+
const sessions = this.sessions;
|
|
1335
|
+
this.sessions = new Map();
|
|
1336
|
+
|
|
1337
|
+
// kill and reinitiate the prebuffers.
|
|
1251
1338
|
for (const session of sessions.values()) {
|
|
1252
1339
|
session?.parserSessionPromise?.then(session => session.kill());
|
|
1253
1340
|
}
|
|
@@ -1308,12 +1395,26 @@ function millisUntilMidnight() {
|
|
|
1308
1395
|
return (midnight.getTime() - new Date().getTime());
|
|
1309
1396
|
}
|
|
1310
1397
|
|
|
1311
|
-
class
|
|
1398
|
+
class RebroadcastPlugin extends AutoenableMixinProvider implements MixinProvider, BufferConverter, Settings {
|
|
1312
1399
|
storageSettings = new StorageSettings(this, {
|
|
1313
1400
|
rebroadcastPort: {
|
|
1314
1401
|
title: 'Rebroadcast Port',
|
|
1315
1402
|
description: 'The port of the RTSP server that will rebroadcast your streams.',
|
|
1316
1403
|
type: 'number',
|
|
1404
|
+
},
|
|
1405
|
+
remoteStreamingBitrate: {
|
|
1406
|
+
title: 'Remote Streaming Bitrate',
|
|
1407
|
+
type: 'number',
|
|
1408
|
+
defaultValue: 1000000,
|
|
1409
|
+
description: 'The bitrate to use when remote streaming. This setting will only be used when transcoding or adaptive bitrate is enabled on a camera.',
|
|
1410
|
+
},
|
|
1411
|
+
h264EncoderArguments: {
|
|
1412
|
+
title: 'H264 Encoder Arguments',
|
|
1413
|
+
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.',
|
|
1414
|
+
choices: Object.keys(getH264EncoderArgs()),
|
|
1415
|
+
defaultValue: getH264EncoderArgs()[LIBX264_ENCODER_TITLE].join(' '),
|
|
1416
|
+
combobox: true,
|
|
1417
|
+
mapPut: (oldValue, newValue) => getH264EncoderArgs()[newValue]?.join(' ') || newValue || getH264EncoderArgs()[LIBX264_ENCODER_TITLE]?.join(' '),
|
|
1317
1418
|
}
|
|
1318
1419
|
});
|
|
1319
1420
|
rtspServer: net.Server;
|
|
@@ -1350,6 +1451,14 @@ class PrebufferProvider extends AutoenableMixinProvider implements MixinProvider
|
|
|
1350
1451
|
this.startRtspServer();
|
|
1351
1452
|
}
|
|
1352
1453
|
|
|
1454
|
+
getSettings(): Promise<Setting[]> {
|
|
1455
|
+
return this.storageSettings.getSettings();
|
|
1456
|
+
}
|
|
1457
|
+
|
|
1458
|
+
putSetting(key: string, value: SettingValue): Promise<void> {
|
|
1459
|
+
return this.storageSettings.putSetting(key, value);
|
|
1460
|
+
}
|
|
1461
|
+
|
|
1353
1462
|
startRtspServer() {
|
|
1354
1463
|
closeQuiet(this.rtspServer);
|
|
1355
1464
|
|
|
@@ -1413,13 +1522,19 @@ class PrebufferProvider extends AutoenableMixinProvider implements MixinProvider
|
|
|
1413
1522
|
const json = JSON.parse(data.toString());
|
|
1414
1523
|
const { url, sdp } = json;
|
|
1415
1524
|
|
|
1416
|
-
const
|
|
1525
|
+
const parsedSdp = parseSdp(sdp);
|
|
1526
|
+
const trackLookups = new Map<number, string>();
|
|
1527
|
+
for (const msection of parsedSdp.msections) {
|
|
1528
|
+
for (const pt of msection.payloadTypes) {
|
|
1529
|
+
trackLookups.set(pt, msection.control);
|
|
1530
|
+
}
|
|
1531
|
+
}
|
|
1417
1532
|
|
|
1418
1533
|
const u = new URL(url);
|
|
1419
1534
|
if (!u.protocol.startsWith('tcp'))
|
|
1420
1535
|
throw new Error('rfc4751 url must be tcp');
|
|
1421
1536
|
const { clientPromise, url: clientUrl } = await listenZeroSingleClient();
|
|
1422
|
-
const ffmpeg:
|
|
1537
|
+
const ffmpeg: FFmpegInput = {
|
|
1423
1538
|
url: clientUrl,
|
|
1424
1539
|
inputArguments: [
|
|
1425
1540
|
"-rtsp_transport", "tcp",
|
|
@@ -1445,19 +1560,15 @@ class PrebufferProvider extends AutoenableMixinProvider implements MixinProvider
|
|
|
1445
1560
|
const length = header.readInt16BE(0);
|
|
1446
1561
|
const data = await readLength(socket, length);
|
|
1447
1562
|
const pt = data[1] & 0x7f;
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
}
|
|
1451
|
-
else if (videoPayloadTypes.has(pt)) {
|
|
1452
|
-
rtsp.sendVideo(data, false);
|
|
1453
|
-
}
|
|
1454
|
-
else {
|
|
1563
|
+
const track = trackLookups.get(pt);
|
|
1564
|
+
if (!track) {
|
|
1455
1565
|
client.destroy();
|
|
1456
1566
|
socket.destroy();
|
|
1457
1567
|
throw new Error('unknown payload type ' + pt);
|
|
1458
1568
|
}
|
|
1569
|
+
rtsp.sendTrack(track, data, false);
|
|
1459
1570
|
}
|
|
1460
|
-
})
|
|
1571
|
+
});
|
|
1461
1572
|
|
|
1462
1573
|
return Buffer.from(JSON.stringify(ffmpeg));
|
|
1463
1574
|
}
|
|
@@ -1478,7 +1589,7 @@ class PrebufferProvider extends AutoenableMixinProvider implements MixinProvider
|
|
|
1478
1589
|
mixinDeviceState,
|
|
1479
1590
|
mixinProviderNativeId: this.nativeId,
|
|
1480
1591
|
mixinDeviceInterfaces,
|
|
1481
|
-
group: "Stream
|
|
1592
|
+
group: "Stream Management",
|
|
1482
1593
|
groupKey: "prebuffer",
|
|
1483
1594
|
});
|
|
1484
1595
|
this.currentMixins.set(mixinDeviceState.id, ret);
|
|
@@ -1492,4 +1603,4 @@ class PrebufferProvider extends AutoenableMixinProvider implements MixinProvider
|
|
|
1492
1603
|
}
|
|
1493
1604
|
}
|
|
1494
1605
|
|
|
1495
|
-
export default new
|
|
1606
|
+
export default new RebroadcastPlugin();
|