@scrypted/prebuffer-mixin 0.9.43 → 0.9.44
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/dist/plugin.zip +0 -0
- package/package.json +1 -1
- package/.vscode/launch.json +0 -24
- package/.vscode/settings.json +0 -3
- package/.vscode/tasks.json +0 -20
- package/dist/main.nodejs.js +0 -2
- package/src/file-rtsp-server.ts +0 -61
- package/src/main.ts +0 -1808
- package/src/rfc4571.ts +0 -224
- package/src/rtsp-session.ts +0 -302
- package/src/sps-resolution.ts +0 -50
- package/src/stream-settings.ts +0 -247
- package/src/transcode-settings.ts +0 -51
- package/tsconfig.json +0 -11
package/src/main.ts
DELETED
|
@@ -1,1808 +0,0 @@
|
|
|
1
|
-
|
|
2
|
-
import { AutoenableMixinProvider } from '@scrypted/common/src/autoenable-mixin-provider';
|
|
3
|
-
import { getDebugModeH264EncoderArgs, getH264EncoderArgs } from '@scrypted/common/src/ffmpeg-hardware-acceleration';
|
|
4
|
-
import { addVideoFilterArguments } from '@scrypted/common/src/ffmpeg-helpers';
|
|
5
|
-
import { handleRebroadcasterClient, ParserOptions, ParserSession, startParserSession } from '@scrypted/common/src/ffmpeg-rebroadcast';
|
|
6
|
-
import { closeQuiet, createBindZero, listenZeroSingleClient } from '@scrypted/common/src/listen-cluster';
|
|
7
|
-
import { readLength } from '@scrypted/common/src/read-stream';
|
|
8
|
-
import { createRtspParser, findH264NaluType, getNaluTypes, H264_NAL_TYPE_FU_B, H264_NAL_TYPE_IDR, H264_NAL_TYPE_MTAP16, H264_NAL_TYPE_MTAP32, H264_NAL_TYPE_SEI, H264_NAL_TYPE_STAP_B, RtspServer, RtspTrack } from '@scrypted/common/src/rtsp-server';
|
|
9
|
-
import { addTrackControls, parseSdp } from '@scrypted/common/src/sdp-utils';
|
|
10
|
-
import { StorageSettings } from '@scrypted/sdk/storage-settings';
|
|
11
|
-
import { SettingsMixinDeviceBase, SettingsMixinDeviceOptions } from "@scrypted/common/src/settings-mixin";
|
|
12
|
-
import { createFragmentedMp4Parser, createMpegTsParser, StreamChunk, StreamParser } from '@scrypted/common/src/stream-parser';
|
|
13
|
-
import sdk, { BufferConverter, DeviceProvider, DeviceState, FFmpegInput, H264Info, MediaObject, MediaStreamOptions, MixinProvider, PluginFork, RequestMediaStreamOptions, ResponseMediaStreamOptions, ScryptedDeviceType, ScryptedInterface, ScryptedMimeTypes, Setting, Settings, SettingValue, VideoCamera, VideoCameraConfiguration } from '@scrypted/sdk';
|
|
14
|
-
import crypto from 'crypto';
|
|
15
|
-
import { once } from 'events';
|
|
16
|
-
import net, { AddressInfo } from 'net';
|
|
17
|
-
import { Duplex } from 'stream';
|
|
18
|
-
import { FileRtspServer } from './file-rtsp-server';
|
|
19
|
-
import { connectRFC4571Parser, startRFC4571Parser } from './rfc4571';
|
|
20
|
-
import { RtspSessionParserSpecific, startRtspSession } from './rtsp-session';
|
|
21
|
-
import { createStreamSettings, getPrebufferedStreams } from './stream-settings';
|
|
22
|
-
import { getTranscodeMixinProviderId, REBROADCAST_MIXIN_INTERFACE_TOKEN, TranscodeMixinProvider, TRANSCODE_MIXIN_PROVIDER_NATIVE_ID } from './transcode-settings';
|
|
23
|
-
import semver from 'semver';
|
|
24
|
-
|
|
25
|
-
const { mediaManager, log, systemManager, deviceManager } = sdk;
|
|
26
|
-
|
|
27
|
-
const prebufferDurationMs = 10000;
|
|
28
|
-
const DEFAULT_AUDIO = 'Default';
|
|
29
|
-
const AAC_AUDIO = 'AAC or No Audio';
|
|
30
|
-
const AAC_AUDIO_DESCRIPTION = `${AAC_AUDIO} (Copy)`;
|
|
31
|
-
const COMPATIBLE_AUDIO = 'Compatible Audio'
|
|
32
|
-
const COMPATIBLE_AUDIO_DESCRIPTION = `${COMPATIBLE_AUDIO} (Copy)`;
|
|
33
|
-
const TRANSCODE_AUDIO = 'Other Audio';
|
|
34
|
-
const TRANSCODE_AUDIO_DESCRIPTION = `${TRANSCODE_AUDIO} (Transcode)`;
|
|
35
|
-
const COMPATIBLE_AUDIO_CODECS = ['aac', 'mp3', 'mp2', 'opus'];
|
|
36
|
-
const DEFAULT_FFMPEG_INPUT_ARGUMENTS = '-fflags +genpts';
|
|
37
|
-
|
|
38
|
-
const SCRYPTED_PARSER_TCP = 'Scrypted (TCP)';
|
|
39
|
-
const SCRYPTED_PARSER_UDP = 'Scrypted (UDP)';
|
|
40
|
-
const FFMPEG_PARSER_TCP = 'FFmpeg (TCP)';
|
|
41
|
-
const FFMPEG_PARSER_UDP = 'FFmpeg (UDP)';
|
|
42
|
-
const STRING_DEFAULT = 'Default';
|
|
43
|
-
|
|
44
|
-
const VALID_AUDIO_CONFIGS = [
|
|
45
|
-
AAC_AUDIO,
|
|
46
|
-
COMPATIBLE_AUDIO,
|
|
47
|
-
TRANSCODE_AUDIO,
|
|
48
|
-
];
|
|
49
|
-
|
|
50
|
-
interface PrebufferStreamChunk extends StreamChunk {
|
|
51
|
-
time?: number;
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
type Prebuffers<T extends string> = {
|
|
55
|
-
[key in T]: PrebufferStreamChunk[];
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
type PrebufferParsers = 'mpegts' | 'mp4' | 'rtsp';
|
|
59
|
-
const PrebufferParserValues: PrebufferParsers[] = ['mpegts', 'mp4', 'rtsp'];
|
|
60
|
-
|
|
61
|
-
class PrebufferSession {
|
|
62
|
-
|
|
63
|
-
parserSessionPromise: Promise<ParserSession<PrebufferParsers>>;
|
|
64
|
-
parserSession: ParserSession<PrebufferParsers>;
|
|
65
|
-
prebuffers: Prebuffers<PrebufferParsers> = {
|
|
66
|
-
mp4: [],
|
|
67
|
-
mpegts: [],
|
|
68
|
-
rtsp: [],
|
|
69
|
-
};
|
|
70
|
-
parsers: { [container: string]: StreamParser };
|
|
71
|
-
sdp: Promise<string>;
|
|
72
|
-
usingScryptedParser = false;
|
|
73
|
-
usingScryptedUdpParser = false;
|
|
74
|
-
|
|
75
|
-
audioDisabled = false;
|
|
76
|
-
|
|
77
|
-
mixinDevice: VideoCamera & VideoCameraConfiguration;
|
|
78
|
-
console: Console;
|
|
79
|
-
storage: Storage;
|
|
80
|
-
|
|
81
|
-
activeClients = 0;
|
|
82
|
-
inactivityTimeout: NodeJS.Timeout;
|
|
83
|
-
audioConfigurationKey: string;
|
|
84
|
-
ffmpegInputArgumentsKey: string;
|
|
85
|
-
lastDetectedAudioCodecKey: string;
|
|
86
|
-
lastH264ProbeKey: string;
|
|
87
|
-
rebroadcastModeKey: string;
|
|
88
|
-
rtspParserKey: string;
|
|
89
|
-
maxBitrateKey: string;
|
|
90
|
-
rtspServerPath: string;
|
|
91
|
-
needBitrateReset = false;
|
|
92
|
-
|
|
93
|
-
constructor(public mixin: PrebufferMixin, public advertisedMediaStreamOptions: ResponseMediaStreamOptions, public stopInactive: boolean) {
|
|
94
|
-
this.storage = mixin.storage;
|
|
95
|
-
this.console = mixin.console;
|
|
96
|
-
this.mixinDevice = mixin.mixinDevice;
|
|
97
|
-
this.audioConfigurationKey = 'audioConfiguration-' + this.streamId;
|
|
98
|
-
this.ffmpegInputArgumentsKey = 'ffmpegInputArguments-' + this.streamId;
|
|
99
|
-
this.rebroadcastModeKey = 'rebroadcastMode-' + this.streamId;
|
|
100
|
-
this.lastDetectedAudioCodecKey = 'lastDetectedAudioCodec-' + this.streamId;
|
|
101
|
-
this.lastH264ProbeKey = 'lastH264Probe-' + this.streamId;
|
|
102
|
-
this.rtspParserKey = 'rtspParser-' + this.streamId;
|
|
103
|
-
const rtspServerPathKey = 'rtspServerPathKey-' + this.streamId;
|
|
104
|
-
this.maxBitrateKey = 'maxBitrate-' + this.streamId;
|
|
105
|
-
|
|
106
|
-
this.rtspServerPath = this.storage.getItem(rtspServerPathKey);
|
|
107
|
-
if (!this.rtspServerPath) {
|
|
108
|
-
this.rtspServerPath = crypto.randomBytes(8).toString('hex');
|
|
109
|
-
this.storage.setItem(rtspServerPathKey, this.rtspServerPath);
|
|
110
|
-
}
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
get canPrebuffer() {
|
|
114
|
-
return this.advertisedMediaStreamOptions.container !== 'rawvideo' && this.advertisedMediaStreamOptions.container !== 'ffmpeg';
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
getLastH264Probe(): H264Info {
|
|
118
|
-
const str = this.storage.getItem(this.lastH264ProbeKey);
|
|
119
|
-
if (!str) {
|
|
120
|
-
return {};
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
try {
|
|
124
|
-
return JSON.parse(str);
|
|
125
|
-
}
|
|
126
|
-
catch (e) {
|
|
127
|
-
return {};
|
|
128
|
-
}
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
getLastH264Oddities() {
|
|
132
|
-
const lastProbe = this.getLastH264Probe();
|
|
133
|
-
const h264Oddities = lastProbe.fuab
|
|
134
|
-
|| lastProbe.mtap16
|
|
135
|
-
|| lastProbe.mtap32
|
|
136
|
-
|| lastProbe.sei
|
|
137
|
-
|| lastProbe.stapb;
|
|
138
|
-
return h264Oddities;
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
getDetectedIdrInterval() {
|
|
142
|
-
const durations: number[] = [];
|
|
143
|
-
if (this.prebuffers.mp4.length) {
|
|
144
|
-
let last: number;
|
|
145
|
-
|
|
146
|
-
for (const chunk of this.prebuffers.mp4) {
|
|
147
|
-
if (chunk.type === 'mdat') {
|
|
148
|
-
if (last)
|
|
149
|
-
durations.push(chunk.time - last);
|
|
150
|
-
last = chunk.time;
|
|
151
|
-
}
|
|
152
|
-
}
|
|
153
|
-
}
|
|
154
|
-
else if (this.prebuffers.rtsp.length) {
|
|
155
|
-
let last: number;
|
|
156
|
-
|
|
157
|
-
for (const chunk of this.prebuffers.rtsp) {
|
|
158
|
-
if (findH264NaluType(chunk, H264_NAL_TYPE_IDR)) {
|
|
159
|
-
if (last)
|
|
160
|
-
durations.push(chunk.time - last);
|
|
161
|
-
last = chunk.time;
|
|
162
|
-
}
|
|
163
|
-
}
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
if (!durations.length)
|
|
167
|
-
return;
|
|
168
|
-
|
|
169
|
-
const total = durations.reduce((prev, current) => prev + current, 0);
|
|
170
|
-
return total / durations.length;
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
get maxBitrate() {
|
|
174
|
-
let ret = parseInt(this.storage.getItem(this.maxBitrateKey));
|
|
175
|
-
if (!ret) {
|
|
176
|
-
ret = this.advertisedMediaStreamOptions?.video?.maxBitrate;
|
|
177
|
-
this.storage.setItem(this.maxBitrateKey, ret?.toString());
|
|
178
|
-
}
|
|
179
|
-
return ret || undefined;
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
async resetBitrate() {
|
|
183
|
-
this.console.log('Resetting bitrate after adaptive streaming session', this.maxBitrate);
|
|
184
|
-
this.needBitrateReset = false;
|
|
185
|
-
this.mixinDevice.setVideoStreamOptions({
|
|
186
|
-
id: this.streamId,
|
|
187
|
-
video: {
|
|
188
|
-
bitrate: this.maxBitrate,
|
|
189
|
-
}
|
|
190
|
-
});
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
get streamId() {
|
|
194
|
-
return this.advertisedMediaStreamOptions.id;
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
get streamName() {
|
|
198
|
-
return this.advertisedMediaStreamOptions.name || `Stream ${this.streamId}`;
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
clearPrebuffers() {
|
|
202
|
-
for (const prebuffer of PrebufferParserValues) {
|
|
203
|
-
this.prebuffers[prebuffer] = [];
|
|
204
|
-
}
|
|
205
|
-
}
|
|
206
|
-
|
|
207
|
-
ensurePrebufferSession() {
|
|
208
|
-
if (this.parserSessionPromise || this.mixin.released)
|
|
209
|
-
return;
|
|
210
|
-
this.console.log(this.streamName, 'prebuffer session started');
|
|
211
|
-
this.parserSessionPromise = this.startPrebufferSession();
|
|
212
|
-
this.parserSessionPromise.catch(e => this.parserSessionPromise = undefined);
|
|
213
|
-
this.parserSessionPromise.then(pso => pso.killed.finally(() => this.parserSessionPromise = undefined));
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
getAudioConfig(): {
|
|
217
|
-
isUsingDefaultAudioConfig: boolean,
|
|
218
|
-
aacAudio: boolean,
|
|
219
|
-
compatibleAudio: boolean,
|
|
220
|
-
reencodeAudio: boolean,
|
|
221
|
-
} {
|
|
222
|
-
let audioConfig = this.storage.getItem(this.audioConfigurationKey) || '';
|
|
223
|
-
if (!VALID_AUDIO_CONFIGS.find(config => audioConfig.startsWith(config)))
|
|
224
|
-
audioConfig = '';
|
|
225
|
-
const aacAudio = audioConfig.indexOf(AAC_AUDIO) !== -1;
|
|
226
|
-
const compatibleAudio = audioConfig.indexOf(COMPATIBLE_AUDIO) !== -1;
|
|
227
|
-
// reencode audio will be used if explicitly set.
|
|
228
|
-
const reencodeAudio = audioConfig.indexOf(TRANSCODE_AUDIO) !== -1;
|
|
229
|
-
return {
|
|
230
|
-
isUsingDefaultAudioConfig: !(aacAudio || compatibleAudio || reencodeAudio),
|
|
231
|
-
aacAudio,
|
|
232
|
-
compatibleAudio,
|
|
233
|
-
reencodeAudio,
|
|
234
|
-
}
|
|
235
|
-
}
|
|
236
|
-
|
|
237
|
-
canUseRtspParser(mediaStreamOptions: MediaStreamOptions) {
|
|
238
|
-
return mediaStreamOptions?.container?.startsWith('rtsp');
|
|
239
|
-
}
|
|
240
|
-
|
|
241
|
-
getParser(rtspMode: boolean, mediaStreamOptions: MediaStreamOptions) {
|
|
242
|
-
let parser: string;
|
|
243
|
-
const rtspParser = this.storage.getItem(this.rtspParserKey);
|
|
244
|
-
|
|
245
|
-
if (!this.canUseRtspParser(mediaStreamOptions)) {
|
|
246
|
-
parser = STRING_DEFAULT;
|
|
247
|
-
}
|
|
248
|
-
else {
|
|
249
|
-
|
|
250
|
-
if (rtspParser === FFMPEG_PARSER_TCP)
|
|
251
|
-
parser = FFMPEG_PARSER_TCP;
|
|
252
|
-
if (rtspParser === FFMPEG_PARSER_UDP)
|
|
253
|
-
parser = FFMPEG_PARSER_UDP;
|
|
254
|
-
|
|
255
|
-
// scrypted parser can only be used in rtsp mode.
|
|
256
|
-
if (rtspMode && !parser) {
|
|
257
|
-
if (!rtspParser || rtspParser === STRING_DEFAULT)
|
|
258
|
-
parser = SCRYPTED_PARSER_TCP;
|
|
259
|
-
if (rtspParser === SCRYPTED_PARSER_TCP)
|
|
260
|
-
parser = SCRYPTED_PARSER_TCP;
|
|
261
|
-
if (rtspParser === SCRYPTED_PARSER_UDP)
|
|
262
|
-
parser = SCRYPTED_PARSER_UDP;
|
|
263
|
-
}
|
|
264
|
-
|
|
265
|
-
// bad config, fall back to ffmpeg tcp parsing.
|
|
266
|
-
if (!parser)
|
|
267
|
-
parser = FFMPEG_PARSER_TCP;
|
|
268
|
-
}
|
|
269
|
-
|
|
270
|
-
return {
|
|
271
|
-
parser,
|
|
272
|
-
isDefault: !rtspParser || rtspParser === 'Default',
|
|
273
|
-
}
|
|
274
|
-
}
|
|
275
|
-
|
|
276
|
-
getRebroadcastContainer() {
|
|
277
|
-
let mode = this.storage.getItem(this.rebroadcastModeKey) || 'Default';
|
|
278
|
-
if (mode === 'Default')
|
|
279
|
-
mode = 'RTSP';
|
|
280
|
-
const rtspMode = mode?.startsWith('RTSP');
|
|
281
|
-
|
|
282
|
-
return {
|
|
283
|
-
rtspMode: mode?.startsWith('RTSP'),
|
|
284
|
-
muxingMp4: !rtspMode,
|
|
285
|
-
};
|
|
286
|
-
}
|
|
287
|
-
|
|
288
|
-
async getMixinSettings(): Promise<Setting[]> {
|
|
289
|
-
const settings: Setting[] = [];
|
|
290
|
-
|
|
291
|
-
const session = this.parserSession;
|
|
292
|
-
|
|
293
|
-
let total = 0;
|
|
294
|
-
let start = 0;
|
|
295
|
-
const { muxingMp4, rtspMode } = this.getRebroadcastContainer();
|
|
296
|
-
for (const prebuffer of (muxingMp4 ? this.prebuffers.mp4 : this.prebuffers.rtsp)) {
|
|
297
|
-
start = start || prebuffer.time;
|
|
298
|
-
for (const chunk of prebuffer.chunks) {
|
|
299
|
-
total += chunk.byteLength;
|
|
300
|
-
}
|
|
301
|
-
}
|
|
302
|
-
const elapsed = Date.now() - start;
|
|
303
|
-
const bitrate = Math.round(total / elapsed * 8);
|
|
304
|
-
|
|
305
|
-
const group = `Stream: ${this.streamName}`;
|
|
306
|
-
|
|
307
|
-
settings.push(
|
|
308
|
-
{
|
|
309
|
-
title: 'Rebroadcast Container',
|
|
310
|
-
group,
|
|
311
|
-
description: `The container format to use when rebroadcasting. The default mode for this camera is RTSP.`,
|
|
312
|
-
placeholder: 'RTSP',
|
|
313
|
-
choices: [
|
|
314
|
-
STRING_DEFAULT,
|
|
315
|
-
'MPEG-TS',
|
|
316
|
-
'RTSP',
|
|
317
|
-
],
|
|
318
|
-
key: this.rebroadcastModeKey,
|
|
319
|
-
value: this.storage.getItem(this.rebroadcastModeKey) || STRING_DEFAULT,
|
|
320
|
-
}
|
|
321
|
-
);
|
|
322
|
-
|
|
323
|
-
const addFFmpegAudioSettings = () => {
|
|
324
|
-
settings.push(
|
|
325
|
-
{
|
|
326
|
-
title: 'Audio Codec Transcoding',
|
|
327
|
-
group,
|
|
328
|
-
description: 'Configuring your camera to output Opus, PCM, or AAC is recommended.',
|
|
329
|
-
type: 'string',
|
|
330
|
-
key: this.audioConfigurationKey,
|
|
331
|
-
value: this.storage.getItem(this.audioConfigurationKey) || DEFAULT_AUDIO,
|
|
332
|
-
choices: [
|
|
333
|
-
DEFAULT_AUDIO,
|
|
334
|
-
AAC_AUDIO_DESCRIPTION,
|
|
335
|
-
COMPATIBLE_AUDIO_DESCRIPTION,
|
|
336
|
-
TRANSCODE_AUDIO_DESCRIPTION,
|
|
337
|
-
],
|
|
338
|
-
},
|
|
339
|
-
);
|
|
340
|
-
};
|
|
341
|
-
|
|
342
|
-
const addFFmpegInputSettings = () => {
|
|
343
|
-
settings.push(
|
|
344
|
-
{
|
|
345
|
-
title: 'FFmpeg Input Arguments Prefix',
|
|
346
|
-
group,
|
|
347
|
-
description: 'Optional/Advanced: Additional input arguments to pass to the ffmpeg command. These will be placed before the input arguments.',
|
|
348
|
-
key: this.ffmpegInputArgumentsKey,
|
|
349
|
-
value: this.storage.getItem(this.ffmpegInputArgumentsKey),
|
|
350
|
-
placeholder: DEFAULT_FFMPEG_INPUT_ARGUMENTS,
|
|
351
|
-
choices: [
|
|
352
|
-
DEFAULT_FFMPEG_INPUT_ARGUMENTS,
|
|
353
|
-
'-use_wallclock_as_timestamps 1',
|
|
354
|
-
'-v verbose',
|
|
355
|
-
],
|
|
356
|
-
combobox: true,
|
|
357
|
-
},
|
|
358
|
-
)
|
|
359
|
-
}
|
|
360
|
-
|
|
361
|
-
let usingFFmpeg = muxingMp4;
|
|
362
|
-
|
|
363
|
-
if (this.canUseRtspParser(this.advertisedMediaStreamOptions)) {
|
|
364
|
-
const canUseScryptedParser = rtspMode;
|
|
365
|
-
const defaultValue = canUseScryptedParser && !this.getLastH264Oddities() ?
|
|
366
|
-
SCRYPTED_PARSER_TCP : FFMPEG_PARSER_TCP;
|
|
367
|
-
|
|
368
|
-
const scryptedOptions = canUseScryptedParser ? [
|
|
369
|
-
SCRYPTED_PARSER_TCP,
|
|
370
|
-
SCRYPTED_PARSER_UDP,
|
|
371
|
-
] : [];
|
|
372
|
-
|
|
373
|
-
const currentParser = this.storage.getItem(this.rtspParserKey) || STRING_DEFAULT;
|
|
374
|
-
|
|
375
|
-
settings.push(
|
|
376
|
-
{
|
|
377
|
-
key: this.rtspParserKey,
|
|
378
|
-
group,
|
|
379
|
-
title: 'RTSP Parser',
|
|
380
|
-
description: `The RTSP Parser used to read the stream. The default is "${defaultValue}" for this container.`,
|
|
381
|
-
value: currentParser,
|
|
382
|
-
choices: [
|
|
383
|
-
STRING_DEFAULT,
|
|
384
|
-
...scryptedOptions,
|
|
385
|
-
FFMPEG_PARSER_TCP,
|
|
386
|
-
FFMPEG_PARSER_UDP,
|
|
387
|
-
],
|
|
388
|
-
}
|
|
389
|
-
);
|
|
390
|
-
|
|
391
|
-
if (!(currentParser === STRING_DEFAULT ? defaultValue : currentParser).includes('Scrypted')) {
|
|
392
|
-
usingFFmpeg = true;
|
|
393
|
-
}
|
|
394
|
-
}
|
|
395
|
-
|
|
396
|
-
if (muxingMp4) {
|
|
397
|
-
addFFmpegAudioSettings();
|
|
398
|
-
}
|
|
399
|
-
if (usingFFmpeg) {
|
|
400
|
-
addFFmpegInputSettings();
|
|
401
|
-
}
|
|
402
|
-
|
|
403
|
-
const addOddities = () => {
|
|
404
|
-
settings.push(
|
|
405
|
-
{
|
|
406
|
-
key: 'detectedOddities',
|
|
407
|
-
group,
|
|
408
|
-
title: 'Detected H264 Oddities',
|
|
409
|
-
readonly: true,
|
|
410
|
-
value: JSON.stringify(this.getLastH264Probe()),
|
|
411
|
-
description: 'Cameras with oddities in the H264 video stream may not function correctly with Scrypted RTSP Parsers or Senders.',
|
|
412
|
-
}
|
|
413
|
-
)
|
|
414
|
-
};
|
|
415
|
-
|
|
416
|
-
if (session) {
|
|
417
|
-
const resolution = session.inputVideoResolution?.width && session.inputVideoResolution?.height
|
|
418
|
-
? `${session.inputVideoResolution?.width}x${session.inputVideoResolution?.height}`
|
|
419
|
-
: 'unknown';
|
|
420
|
-
|
|
421
|
-
const idrInterval = this.getDetectedIdrInterval();
|
|
422
|
-
settings.push(
|
|
423
|
-
{
|
|
424
|
-
key: 'detectedResolution',
|
|
425
|
-
group,
|
|
426
|
-
title: 'Detected Resolution and Bitrate',
|
|
427
|
-
readonly: true,
|
|
428
|
-
value: `${resolution} @ ${bitrate || "unknown"} Kb/s`,
|
|
429
|
-
description: 'Configuring your camera to 1920x1080, 2000Kb/S, Variable Bit Rate, is recommended.',
|
|
430
|
-
},
|
|
431
|
-
{
|
|
432
|
-
key: 'detectedCodec',
|
|
433
|
-
group,
|
|
434
|
-
title: 'Detected Video/Audio Codecs',
|
|
435
|
-
readonly: true,
|
|
436
|
-
value: (session?.inputVideoCodec?.toString() || 'unknown') + '/' + (session?.inputAudioCodec?.toString() || 'unknown'),
|
|
437
|
-
description: 'Configuring your camera to H264 video and Opus, PCM, or AAC audio is recommended.'
|
|
438
|
-
},
|
|
439
|
-
{
|
|
440
|
-
key: 'detectedKeyframe',
|
|
441
|
-
group,
|
|
442
|
-
title: 'Detected Keyframe Interval',
|
|
443
|
-
description: "Configuring your camera to 4 seconds is recommended (IDR aka Frame Interval = FPS * 4 seconds).",
|
|
444
|
-
readonly: true,
|
|
445
|
-
value: (idrInterval || 0) / 1000 || 'unknown',
|
|
446
|
-
},
|
|
447
|
-
);
|
|
448
|
-
addOddities();
|
|
449
|
-
}
|
|
450
|
-
else {
|
|
451
|
-
settings.push(
|
|
452
|
-
{
|
|
453
|
-
title: 'Status',
|
|
454
|
-
group,
|
|
455
|
-
key: 'status',
|
|
456
|
-
description: 'Rebroadcast is currently idle and will be started automatically on demand.',
|
|
457
|
-
value: 'Idle',
|
|
458
|
-
readonly: true,
|
|
459
|
-
},
|
|
460
|
-
);
|
|
461
|
-
addOddities();
|
|
462
|
-
}
|
|
463
|
-
|
|
464
|
-
if (rtspMode) {
|
|
465
|
-
settings.push({
|
|
466
|
-
group,
|
|
467
|
-
key: 'rtspRebroadcastUrl',
|
|
468
|
-
title: 'RTSP Rebroadcast Url',
|
|
469
|
-
description: 'The RTSP URL of the rebroadcast stream. Substitute localhost as appropriate.',
|
|
470
|
-
readonly: true,
|
|
471
|
-
value: `rtsp://localhost:${this.mixin.streamSettings.storageSettings.values.rebroadcastPort}/${this.rtspServerPath}`,
|
|
472
|
-
});
|
|
473
|
-
}
|
|
474
|
-
|
|
475
|
-
if (this.mixin.mixinDeviceInterfaces.includes(ScryptedInterface.VideoCameraConfiguration)) {
|
|
476
|
-
settings.push({
|
|
477
|
-
group,
|
|
478
|
-
key: this.maxBitrateKey,
|
|
479
|
-
title: 'Max Bitrate',
|
|
480
|
-
description: 'This camera supports Adaptive Bitrate. Set the maximum bitrate to be allowed while using adaptive bitrate streaming. This will also serve as the default bitrate.',
|
|
481
|
-
type: 'number',
|
|
482
|
-
value: this.maxBitrate?.toString(),
|
|
483
|
-
});
|
|
484
|
-
}
|
|
485
|
-
|
|
486
|
-
return settings;
|
|
487
|
-
}
|
|
488
|
-
|
|
489
|
-
async startPrebufferSession() {
|
|
490
|
-
this.clearPrebuffers();
|
|
491
|
-
|
|
492
|
-
let mso: ResponseMediaStreamOptions;
|
|
493
|
-
try {
|
|
494
|
-
mso = (await this.mixinDevice.getVideoStreamOptions()).find(o => o.id === this.streamId);
|
|
495
|
-
}
|
|
496
|
-
catch (e) {
|
|
497
|
-
}
|
|
498
|
-
|
|
499
|
-
// audio codecs are determined by probing the camera to see what it reports.
|
|
500
|
-
// if the camera does not specify a codec, rebroadcast will force audio off
|
|
501
|
-
// to determine the codec without causing a parse failure.
|
|
502
|
-
// camera may explicity request that its audio stream be muted via a null.
|
|
503
|
-
// respect that setting.
|
|
504
|
-
const audioSoftMuted = mso?.audio === null;
|
|
505
|
-
const advertisedAudioCodec = mso?.audio?.codec;
|
|
506
|
-
|
|
507
|
-
const { isUsingDefaultAudioConfig, aacAudio, compatibleAudio, reencodeAudio } = this.getAudioConfig();
|
|
508
|
-
|
|
509
|
-
const { rtspMode, muxingMp4 } = this.getRebroadcastContainer();
|
|
510
|
-
|
|
511
|
-
let detectedAudioCodec = this.storage.getItem(this.lastDetectedAudioCodecKey) || undefined;
|
|
512
|
-
if (detectedAudioCodec === 'null')
|
|
513
|
-
detectedAudioCodec = null;
|
|
514
|
-
|
|
515
|
-
// only need to probe the audio under specific circumstances.
|
|
516
|
-
// rtsp only mode (ie, no mp4 mux) does not need probing.
|
|
517
|
-
let probingAudioCodec = false;
|
|
518
|
-
if (muxingMp4
|
|
519
|
-
&& !audioSoftMuted
|
|
520
|
-
&& !advertisedAudioCodec
|
|
521
|
-
&& isUsingDefaultAudioConfig
|
|
522
|
-
&& detectedAudioCodec === undefined) {
|
|
523
|
-
this.console.warn('Camera did not report an audio codec, muting the audio stream and probing the codec.');
|
|
524
|
-
probingAudioCodec = true;
|
|
525
|
-
}
|
|
526
|
-
|
|
527
|
-
// the assumed audio codec is the detected codec first and the reported codec otherwise.
|
|
528
|
-
const assumedAudioCodec = detectedAudioCodec === undefined
|
|
529
|
-
? advertisedAudioCodec?.toLowerCase()
|
|
530
|
-
: detectedAudioCodec?.toLowerCase();
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
// after probing the audio codec is complete, alert the user with appropriate instructions.
|
|
534
|
-
// assume the codec is user configurable unless the camera explictly reports otherwise.
|
|
535
|
-
const audioIncompatible = !COMPATIBLE_AUDIO_CODECS.includes(assumedAudioCodec);
|
|
536
|
-
if (muxingMp4 && !probingAudioCodec && mso?.userConfigurable !== false && !audioSoftMuted) {
|
|
537
|
-
if (audioIncompatible) {
|
|
538
|
-
// show an alert that rebroadcast needs an explicit setting by the user.
|
|
539
|
-
if (isUsingDefaultAudioConfig) {
|
|
540
|
-
log.a(`${this.mixin.name} is using the ${assumedAudioCodec} audio codec. Configuring your Camera to use Opus, PCM, or AAC audio is recommended. If this is not possible, Select 'Transcode Audio' in the camera stream's Rebroadcast settings to suppress this alert.`);
|
|
541
|
-
}
|
|
542
|
-
this.console.warn('Configure your camera to output Opus, PCM, or AAC audio. Suboptimal audio codec in use:', assumedAudioCodec);
|
|
543
|
-
}
|
|
544
|
-
else if (!audioSoftMuted && isUsingDefaultAudioConfig && advertisedAudioCodec === undefined && detectedAudioCodec !== undefined) {
|
|
545
|
-
// handling compatible codecs that were unspecified...
|
|
546
|
-
// if (detectedAudioCodec === 'aac') {
|
|
547
|
-
// log.a(`${this.mixin.name} did not report a codec and ${detectedAudioCodec} was found during probe. Select '${AAC_AUDIO}' in the camera stream's Rebroadcast settings to suppress this alert and improve startup time.`);
|
|
548
|
-
// }
|
|
549
|
-
// else {
|
|
550
|
-
// log.a(`${this.mixin.name} did not report a codec and ${detectedAudioCodec} was found during probe. Select '${COMPATIBLE_AUDIO}' in the camera stream's Rebroadcast settings to suppress this alert and improve startup time.`);
|
|
551
|
-
// }
|
|
552
|
-
}
|
|
553
|
-
}
|
|
554
|
-
|
|
555
|
-
// aac needs to have the adts header stripped for mpegts and mp4.
|
|
556
|
-
// use this filter sparingly as it prevents ffmpeg from starting on a mismatch.
|
|
557
|
-
// however, not using it on an aac stream also prevents ffmpeg from parsing.
|
|
558
|
-
// so only use it when the detected or probe codec reports aac.
|
|
559
|
-
const aacFilters = ['-bsf:a', 'aac_adtstoasc'];
|
|
560
|
-
// compatible audio like mp3, mp2, opus can be muxed without issue.
|
|
561
|
-
const compatibleFilters = [];
|
|
562
|
-
|
|
563
|
-
this.audioDisabled = false;
|
|
564
|
-
let acodec: string[];
|
|
565
|
-
|
|
566
|
-
const detectedNoAudio = detectedAudioCodec === null;
|
|
567
|
-
|
|
568
|
-
// if the camera reports audio is incompatible and the user can't do anything about it
|
|
569
|
-
// enable transcoding by default. however, still allow the user to change the settings
|
|
570
|
-
// in case something changed.
|
|
571
|
-
let mustTranscode = false;
|
|
572
|
-
if (muxingMp4 && !probingAudioCodec && isUsingDefaultAudioConfig && audioIncompatible) {
|
|
573
|
-
if (mso?.userConfigurable === false)
|
|
574
|
-
this.console.log('camera reports it is not user configurable. transcoding due to incompatible codec', assumedAudioCodec);
|
|
575
|
-
else
|
|
576
|
-
this.console.log('camera audio transcoding due to incompatible codec. configure the camera to use a compatible codec if possible.');
|
|
577
|
-
mustTranscode = true;
|
|
578
|
-
}
|
|
579
|
-
|
|
580
|
-
if (audioSoftMuted || probingAudioCodec) {
|
|
581
|
-
// no audio? explicitly disable it.
|
|
582
|
-
acodec = ['-an'];
|
|
583
|
-
this.audioDisabled = true;
|
|
584
|
-
}
|
|
585
|
-
else if (reencodeAudio || mustTranscode) {
|
|
586
|
-
acodec = [
|
|
587
|
-
'-bsf:a', 'aac_adtstoasc',
|
|
588
|
-
'-acodec', 'aac',
|
|
589
|
-
'-ar', `32k`,
|
|
590
|
-
'-b:a', `32k`,
|
|
591
|
-
'-ac', `1`,
|
|
592
|
-
'-profile:a', 'aac_low',
|
|
593
|
-
'-flags', '+global_header',
|
|
594
|
-
];
|
|
595
|
-
}
|
|
596
|
-
else if (aacAudio || detectedNoAudio) {
|
|
597
|
-
// NOTE: If there is no audio track, the aac filters will still work fine without complaints
|
|
598
|
-
// from ffmpeg. This is why AAC and No Audio can be grouped into a single setting.
|
|
599
|
-
// This is preferred, because failure and recovery is preferable to
|
|
600
|
-
// permanently muting camera audio due to erroneous detection.
|
|
601
|
-
acodec = [
|
|
602
|
-
'-acodec',
|
|
603
|
-
'copy',
|
|
604
|
-
];
|
|
605
|
-
acodec.push(...aacFilters);
|
|
606
|
-
}
|
|
607
|
-
else if (compatibleAudio) {
|
|
608
|
-
acodec = [
|
|
609
|
-
'-acodec',
|
|
610
|
-
'copy',
|
|
611
|
-
];
|
|
612
|
-
acodec.push(...compatibleFilters);
|
|
613
|
-
}
|
|
614
|
-
else {
|
|
615
|
-
acodec = [
|
|
616
|
-
'-acodec',
|
|
617
|
-
'copy',
|
|
618
|
-
];
|
|
619
|
-
|
|
620
|
-
const filters = assumedAudioCodec === 'aac' ? aacFilters : compatibleFilters;
|
|
621
|
-
|
|
622
|
-
acodec.push(...filters);
|
|
623
|
-
}
|
|
624
|
-
|
|
625
|
-
const vcodec = [
|
|
626
|
-
'-vcodec', 'copy',
|
|
627
|
-
// 3/6/2022
|
|
628
|
-
// Add SPS/PPS to all keyframes. Not all cameras do this!
|
|
629
|
-
// This isn't really necessary for a few reasons:
|
|
630
|
-
// MPEG-TS and MP4 will automatically do this, since there's no out of band
|
|
631
|
-
// way to get the SPS/PPS.
|
|
632
|
-
// RTSP mode may send the SPS/PPS out of band via the sdp, and then may not have
|
|
633
|
-
// SPS/PPS in the bit stream.
|
|
634
|
-
// Adding this argument isn't strictly necessary, but it normalizes the bitstream
|
|
635
|
-
// so consumers that expect the SPS/PPS will have it. Ran into an issue where
|
|
636
|
-
// the HomeKit plugin was blasting RTP packets out from RTSP mode,
|
|
637
|
-
// but the bitstream had no SPS/PPS information, resulting in the video never loading
|
|
638
|
-
// in the Home app.
|
|
639
|
-
// 3/7/2022
|
|
640
|
-
// I believe this is causing errors in recordings and possibly streaming as well
|
|
641
|
-
// for some users. This may need to be a homekit specific transcoding argument.
|
|
642
|
-
// '-bsf:v', 'dump_extra',
|
|
643
|
-
];
|
|
644
|
-
|
|
645
|
-
const rbo: ParserOptions<PrebufferParsers> = {
|
|
646
|
-
console: this.console,
|
|
647
|
-
timeout: 60000,
|
|
648
|
-
parsers: {
|
|
649
|
-
},
|
|
650
|
-
};
|
|
651
|
-
this.parsers = rbo.parsers;
|
|
652
|
-
|
|
653
|
-
this.console.log('rebroadcast mode:', rtspMode ? 'rtsp' : 'mpegts');
|
|
654
|
-
if (!rtspMode) {
|
|
655
|
-
rbo.parsers.mpegts = createMpegTsParser({
|
|
656
|
-
vcodec,
|
|
657
|
-
acodec,
|
|
658
|
-
});
|
|
659
|
-
}
|
|
660
|
-
else {
|
|
661
|
-
const parser = createRtspParser({
|
|
662
|
-
vcodec,
|
|
663
|
-
// the rtsp parser should always stream copy unless audio is soft muted.
|
|
664
|
-
acodec: audioSoftMuted ? acodec : ['-acodec', 'copy'],
|
|
665
|
-
});
|
|
666
|
-
this.sdp = parser.sdp;
|
|
667
|
-
rbo.parsers.rtsp = parser;
|
|
668
|
-
}
|
|
669
|
-
|
|
670
|
-
if (muxingMp4) {
|
|
671
|
-
rbo.parsers.mp4 = createFragmentedMp4Parser({
|
|
672
|
-
vcodec,
|
|
673
|
-
acodec,
|
|
674
|
-
});
|
|
675
|
-
}
|
|
676
|
-
|
|
677
|
-
const mo = await this.mixinDevice.getVideoStream(mso);
|
|
678
|
-
const isRfc4571 = mo.mimeType === 'x-scrypted/x-rfc4571';
|
|
679
|
-
|
|
680
|
-
let session: ParserSession<PrebufferParsers>;
|
|
681
|
-
let sessionMso: ResponseMediaStreamOptions;
|
|
682
|
-
|
|
683
|
-
// before launching the parser session, clear out the last detected codec.
|
|
684
|
-
// an erroneous cached codec could cause ffmpeg to fail to start.
|
|
685
|
-
this.storage.removeItem(this.lastDetectedAudioCodecKey);
|
|
686
|
-
this.usingScryptedParser = false;
|
|
687
|
-
|
|
688
|
-
const h264Oddities = this.getLastH264Oddities();
|
|
689
|
-
|
|
690
|
-
if (rtspMode && isRfc4571) {
|
|
691
|
-
this.usingScryptedParser = true;
|
|
692
|
-
this.console.log('bypassing ffmpeg: using scrypted rfc4571 parser')
|
|
693
|
-
const json = await mediaManager.convertMediaObjectToJSON<any>(mo, 'x-scrypted/x-rfc4571');
|
|
694
|
-
const { url, sdp, mediaStreamOptions } = json;
|
|
695
|
-
|
|
696
|
-
session = startRFC4571Parser(this.console, connectRFC4571Parser(url), sdp, mediaStreamOptions, rbo);
|
|
697
|
-
this.sdp = session.sdp.then(buffers => Buffer.concat(buffers).toString());
|
|
698
|
-
}
|
|
699
|
-
else {
|
|
700
|
-
const moBuffer = await mediaManager.convertMediaObjectToBuffer(mo, ScryptedMimeTypes.FFmpegInput);
|
|
701
|
-
const ffmpegInput = JSON.parse(moBuffer.toString()) as FFmpegInput;
|
|
702
|
-
sessionMso = ffmpegInput.mediaStreamOptions || this.advertisedMediaStreamOptions;
|
|
703
|
-
|
|
704
|
-
let { parser, isDefault } = this.getParser(rtspMode, sessionMso);
|
|
705
|
-
this.usingScryptedParser = parser === SCRYPTED_PARSER_TCP || parser === SCRYPTED_PARSER_UDP;
|
|
706
|
-
this.usingScryptedUdpParser = parser === SCRYPTED_PARSER_UDP;
|
|
707
|
-
|
|
708
|
-
// prefer ffmpeg if this is a prebuffered stream.
|
|
709
|
-
if (isDefault
|
|
710
|
-
&& this.usingScryptedParser
|
|
711
|
-
&& h264Oddities
|
|
712
|
-
&& !this.stopInactive
|
|
713
|
-
&& sessionMso.tool !== 'scrypted') {
|
|
714
|
-
this.console.warn('H264 oddities were detected in prebuffered video stream, the Default Scrypted RTSP Parser will not be used. Falling back to FFmpeg. This can be overriden by setting the RTSP Parser to Scrypted.');
|
|
715
|
-
this.usingScryptedParser = false;
|
|
716
|
-
parser = FFMPEG_PARSER_TCP;
|
|
717
|
-
}
|
|
718
|
-
|
|
719
|
-
if (this.usingScryptedParser) {
|
|
720
|
-
session = await startRtspSession(this.console, ffmpegInput.url, ffmpegInput.mediaStreamOptions, {
|
|
721
|
-
useUdp: parser === SCRYPTED_PARSER_UDP,
|
|
722
|
-
audioSoftMuted,
|
|
723
|
-
rtspRequestTimeout: 10000,
|
|
724
|
-
});
|
|
725
|
-
this.sdp = session.sdp.then(buffers => Buffer.concat(buffers).toString());
|
|
726
|
-
}
|
|
727
|
-
else {
|
|
728
|
-
if (parser === FFMPEG_PARSER_UDP)
|
|
729
|
-
ffmpegInput.inputArguments = ['-rtsp_transport', 'udp', '-i', ffmpegInput.url];
|
|
730
|
-
else if (parser === FFMPEG_PARSER_TCP)
|
|
731
|
-
ffmpegInput.inputArguments = ['-rtsp_transport', 'tcp', '-i', ffmpegInput.url];
|
|
732
|
-
// create missing pts from dts so mpegts and mp4 muxing does not fail
|
|
733
|
-
const extraInputArguments = this.storage.getItem(this.ffmpegInputArgumentsKey) || DEFAULT_FFMPEG_INPUT_ARGUMENTS;
|
|
734
|
-
ffmpegInput.inputArguments.unshift(...extraInputArguments.split(' '));
|
|
735
|
-
session = await startParserSession(ffmpegInput, rbo);
|
|
736
|
-
}
|
|
737
|
-
}
|
|
738
|
-
|
|
739
|
-
if (this.usingScryptedParser) {
|
|
740
|
-
// watch the stream for 10 seconds to see if an weird nalu is encountered.
|
|
741
|
-
// if one is found and using scrypted parser as default, will need to restart rebroadcast to prevent
|
|
742
|
-
// downstream issues.
|
|
743
|
-
const h264Probe: H264Info = {};
|
|
744
|
-
let reportedOddity = false;
|
|
745
|
-
const oddityProbe = (chunk: StreamChunk) => {
|
|
746
|
-
if (chunk.type !== 'h264')
|
|
747
|
-
return;
|
|
748
|
-
|
|
749
|
-
const types = getNaluTypes(chunk);
|
|
750
|
-
h264Probe.fuab ||= types.has(H264_NAL_TYPE_FU_B);
|
|
751
|
-
h264Probe.stapb ||= types.has(H264_NAL_TYPE_STAP_B);
|
|
752
|
-
h264Probe.mtap16 ||= types.has(H264_NAL_TYPE_MTAP16);
|
|
753
|
-
h264Probe.mtap32 ||= types.has(H264_NAL_TYPE_MTAP32);
|
|
754
|
-
h264Probe.sei ||= types.has(H264_NAL_TYPE_SEI);
|
|
755
|
-
const oddity = h264Probe.fuab || h264Probe.stapb || h264Probe.mtap16 || h264Probe.mtap32 || h264Probe.sei;
|
|
756
|
-
if (oddity && !reportedOddity) {
|
|
757
|
-
reportedOddity = true;
|
|
758
|
-
let { isDefault } = this.getParser(rtspMode, sessionMso);
|
|
759
|
-
this.console.warn('H264 oddity detected.');
|
|
760
|
-
if (!isDefault) {
|
|
761
|
-
this.console.warn('If there are issues streaming, consider using the Default parser.');
|
|
762
|
-
return;
|
|
763
|
-
}
|
|
764
|
-
|
|
765
|
-
if (sessionMso.tool === 'scrypted') {
|
|
766
|
-
this.console.warn('Stream tool is marked safe as "scrypted", ignoring oddity. If there are issues streaming, consider switching to FFmpeg parser.');
|
|
767
|
-
return;
|
|
768
|
-
}
|
|
769
|
-
|
|
770
|
-
if (!this.stopInactive) {
|
|
771
|
-
this.console.warn('Oddity in prebuffered stream. Restarting rebroadcast to use FFmpeg instead.');
|
|
772
|
-
session.kill(new Error('restarting due to H264 oddity detection'));
|
|
773
|
-
this.storage.setItem(this.lastH264ProbeKey, JSON.stringify(h264Probe));
|
|
774
|
-
removeOddityProbe();
|
|
775
|
-
this.startPrebufferSession();
|
|
776
|
-
return;
|
|
777
|
-
}
|
|
778
|
-
|
|
779
|
-
// this.console.warn('Oddity in non prebuffered stream. Next restart will use FFmpeg instead.');
|
|
780
|
-
}
|
|
781
|
-
}
|
|
782
|
-
const removeOddityProbe = () => session.removeListener('rtsp', oddityProbe);
|
|
783
|
-
session.killed.finally(() => clearTimeout(oddityTimeout));
|
|
784
|
-
session.on('rtsp', oddityProbe);
|
|
785
|
-
const oddityTimeout = setTimeout(() => {
|
|
786
|
-
removeOddityProbe();
|
|
787
|
-
this.storage.setItem(this.lastH264ProbeKey, JSON.stringify(h264Probe));
|
|
788
|
-
}, h264Oddities ? 60000 : 10000);
|
|
789
|
-
}
|
|
790
|
-
|
|
791
|
-
// complain to the user about the codec if necessary. upstream may send a audio
|
|
792
|
-
// stream but report none exists (to request muting).
|
|
793
|
-
if (!audioSoftMuted && advertisedAudioCodec && session.inputAudioCodec !== undefined
|
|
794
|
-
&& session.inputAudioCodec !== advertisedAudioCodec) {
|
|
795
|
-
this.console.warn('Audio codec plugin reported vs detected mismatch', advertisedAudioCodec, detectedAudioCodec);
|
|
796
|
-
}
|
|
797
|
-
|
|
798
|
-
const advertisedVideoCodec = mso?.video?.codec;
|
|
799
|
-
if (advertisedVideoCodec && session.inputVideoCodec !== undefined
|
|
800
|
-
&& session.inputVideoCodec !== advertisedVideoCodec) {
|
|
801
|
-
this.console.warn('Video codec plugin reported vs detected mismatch', advertisedVideoCodec, session.inputVideoCodec);
|
|
802
|
-
}
|
|
803
|
-
|
|
804
|
-
if (!session.inputAudioCodec) {
|
|
805
|
-
this.console.log('No audio stream detected.');
|
|
806
|
-
}
|
|
807
|
-
else if (!COMPATIBLE_AUDIO_CODECS.includes(session.inputAudioCodec?.toLowerCase())) {
|
|
808
|
-
this.console.log('Detected audio codec is not mp4/mpegts compatible.', session.inputAudioCodec);
|
|
809
|
-
}
|
|
810
|
-
else {
|
|
811
|
-
this.console.log('Detected audio codec is mp4/mpegts compatible.', session.inputAudioCodec);
|
|
812
|
-
}
|
|
813
|
-
|
|
814
|
-
// set/update the detected codec, set it to null if no audio was found.
|
|
815
|
-
this.storage.setItem(this.lastDetectedAudioCodecKey, session.inputAudioCodec || 'null');
|
|
816
|
-
|
|
817
|
-
if (session.inputVideoCodec !== 'h264') {
|
|
818
|
-
this.console.error(`Video codec is not h264. If there are errors, try changing your camera's encoder output.`);
|
|
819
|
-
}
|
|
820
|
-
|
|
821
|
-
if (probingAudioCodec) {
|
|
822
|
-
this.console.warn('Audio probe complete, ending rebroadcast session and restarting with detected codecs.');
|
|
823
|
-
session.kill(new Error('audio probe completed, restarting'));
|
|
824
|
-
return this.startPrebufferSession();
|
|
825
|
-
}
|
|
826
|
-
|
|
827
|
-
this.parserSession = session;
|
|
828
|
-
session.killed.finally(() => {
|
|
829
|
-
if (this.parserSession === session)
|
|
830
|
-
this.parserSession = undefined;
|
|
831
|
-
});
|
|
832
|
-
session.killed.finally(() => clearTimeout(this.inactivityTimeout));
|
|
833
|
-
|
|
834
|
-
// settings ui refresh
|
|
835
|
-
deviceManager.onMixinEvent(this.mixin.id, this.mixin.mixinProviderNativeId, ScryptedInterface.Settings, undefined);
|
|
836
|
-
|
|
837
|
-
// cloud streams need a periodic token refresh.
|
|
838
|
-
if (sessionMso?.refreshAt) {
|
|
839
|
-
let mso = sessionMso;
|
|
840
|
-
let refreshTimeout: NodeJS.Timeout;
|
|
841
|
-
|
|
842
|
-
const refreshStream = async () => {
|
|
843
|
-
if (!session.isActive)
|
|
844
|
-
return;
|
|
845
|
-
const mo = await this.mixinDevice.getVideoStream(mso);
|
|
846
|
-
const moBuffer = await mediaManager.convertMediaObjectToBuffer(mo, ScryptedMimeTypes.FFmpegInput);
|
|
847
|
-
const ffmpegInput = JSON.parse(moBuffer.toString()) as FFmpegInput;
|
|
848
|
-
mso = ffmpegInput.mediaStreamOptions;
|
|
849
|
-
|
|
850
|
-
scheduleRefresh(mso);
|
|
851
|
-
};
|
|
852
|
-
|
|
853
|
-
const scheduleRefresh = (refreshMso: ResponseMediaStreamOptions) => {
|
|
854
|
-
const when = refreshMso.refreshAt - Date.now() - 30000;
|
|
855
|
-
this.console.log('refreshing media stream in', when);
|
|
856
|
-
refreshTimeout = setTimeout(refreshStream, when);
|
|
857
|
-
}
|
|
858
|
-
|
|
859
|
-
scheduleRefresh(mso);
|
|
860
|
-
session.killed.finally(() => clearTimeout(refreshTimeout));
|
|
861
|
-
}
|
|
862
|
-
|
|
863
|
-
for (const container of PrebufferParserValues) {
|
|
864
|
-
let shifts = 0;
|
|
865
|
-
let prebufferContainer: PrebufferStreamChunk[] = this.prebuffers[container];
|
|
866
|
-
|
|
867
|
-
session.on(container, (chunk: PrebufferStreamChunk) => {
|
|
868
|
-
const now = Date.now();
|
|
869
|
-
|
|
870
|
-
chunk.time = now;
|
|
871
|
-
prebufferContainer.push(chunk);
|
|
872
|
-
|
|
873
|
-
while (prebufferContainer.length && prebufferContainer[0].time < now - prebufferDurationMs) {
|
|
874
|
-
prebufferContainer.shift();
|
|
875
|
-
shifts++;
|
|
876
|
-
}
|
|
877
|
-
|
|
878
|
-
if (shifts > 100000) {
|
|
879
|
-
prebufferContainer = prebufferContainer.slice();
|
|
880
|
-
this.prebuffers[container] = prebufferContainer;
|
|
881
|
-
shifts = 0;
|
|
882
|
-
}
|
|
883
|
-
});
|
|
884
|
-
}
|
|
885
|
-
|
|
886
|
-
session.start();
|
|
887
|
-
return session;
|
|
888
|
-
}
|
|
889
|
-
|
|
890
|
-
printActiveClients() {
|
|
891
|
-
this.console.log(this.streamName, 'active rebroadcast clients:', this.activeClients);
|
|
892
|
-
}
|
|
893
|
-
|
|
894
|
-
inactivityCheck(session: ParserSession<PrebufferParsers>, resetTimeout: boolean) {
|
|
895
|
-
if (this.activeClients)
|
|
896
|
-
return;
|
|
897
|
-
|
|
898
|
-
// should bitrate be reset immediately once the stream goes inactive?
|
|
899
|
-
if (this.needBitrateReset && this.mixin.mixinDeviceInterfaces.includes(ScryptedInterface.VideoCameraConfiguration)) {
|
|
900
|
-
this.resetBitrate();
|
|
901
|
-
}
|
|
902
|
-
|
|
903
|
-
if (!this.stopInactive) {
|
|
904
|
-
return;
|
|
905
|
-
}
|
|
906
|
-
|
|
907
|
-
// passive clients should not reset timeouts.
|
|
908
|
-
if (this.inactivityTimeout && !resetTimeout)
|
|
909
|
-
return;
|
|
910
|
-
|
|
911
|
-
clearTimeout(this.inactivityTimeout)
|
|
912
|
-
this.inactivityTimeout = setTimeout(() => {
|
|
913
|
-
this.inactivityTimeout = undefined;
|
|
914
|
-
if (this.activeClients) {
|
|
915
|
-
this.console.log('inactivity timeout found active clients.');
|
|
916
|
-
return;
|
|
917
|
-
}
|
|
918
|
-
this.console.log(this.streamName, 'terminating rebroadcast due to inactivity');
|
|
919
|
-
session.kill(new Error('stream inactivity'));
|
|
920
|
-
}, 30000);
|
|
921
|
-
}
|
|
922
|
-
|
|
923
|
-
async handleRebroadcasterClient(options: {
|
|
924
|
-
findSyncFrame: boolean,
|
|
925
|
-
isActiveClient: boolean,
|
|
926
|
-
container: PrebufferParsers,
|
|
927
|
-
session: ParserSession<PrebufferParsers>,
|
|
928
|
-
socketPromise: Promise<Duplex>,
|
|
929
|
-
requestedPrebuffer: number,
|
|
930
|
-
filter?: (chunk: StreamChunk, prebuffer: boolean) => StreamChunk,
|
|
931
|
-
}) {
|
|
932
|
-
const { isActiveClient, container, session, socketPromise, requestedPrebuffer } = options;
|
|
933
|
-
this.console.log('sending prebuffer', requestedPrebuffer);
|
|
934
|
-
|
|
935
|
-
// in case the client never connects, do an inactivity check.
|
|
936
|
-
socketPromise.catch(() => this.inactivityCheck(session, false));
|
|
937
|
-
socketPromise.then(socket => {
|
|
938
|
-
if (isActiveClient) {
|
|
939
|
-
this.activeClients++;
|
|
940
|
-
this.printActiveClients();
|
|
941
|
-
}
|
|
942
|
-
socket.once('close', () => {
|
|
943
|
-
if (isActiveClient) {
|
|
944
|
-
this.activeClients--;
|
|
945
|
-
this.printActiveClients();
|
|
946
|
-
}
|
|
947
|
-
this.inactivityCheck(session, isActiveClient);
|
|
948
|
-
})
|
|
949
|
-
});
|
|
950
|
-
|
|
951
|
-
handleRebroadcasterClient(socketPromise, {
|
|
952
|
-
// console: this.console,
|
|
953
|
-
connect: (connection) => {
|
|
954
|
-
const now = Date.now();
|
|
955
|
-
|
|
956
|
-
const safeWriteData = (chunk: StreamChunk, prebuffer?: boolean) => {
|
|
957
|
-
if (options.filter) {
|
|
958
|
-
chunk = options.filter(chunk, prebuffer);
|
|
959
|
-
if (!chunk)
|
|
960
|
-
return;
|
|
961
|
-
}
|
|
962
|
-
const buffered = connection.writeData(chunk);
|
|
963
|
-
if (buffered > 100000000) {
|
|
964
|
-
this.console.log('more than 100MB has been buffered, did downstream die? killing connection.', this.streamName);
|
|
965
|
-
cleanup();
|
|
966
|
-
}
|
|
967
|
-
}
|
|
968
|
-
|
|
969
|
-
const cleanup = () => {
|
|
970
|
-
session.removeListener(container, safeWriteData);
|
|
971
|
-
session.removeListener('killed', cleanup);
|
|
972
|
-
connection.destroy();
|
|
973
|
-
}
|
|
974
|
-
|
|
975
|
-
session.on(container, safeWriteData);
|
|
976
|
-
session.once('killed', cleanup);
|
|
977
|
-
|
|
978
|
-
const prebufferContainer: PrebufferStreamChunk[] = this.prebuffers[container];
|
|
979
|
-
// if the requested container or the source container is not rtsp, use an exact seek.
|
|
980
|
-
// this works better when the requested container is mp4, and rtsp is the source.
|
|
981
|
-
// if starting on a sync frame, ffmpeg will skip the first segment while initializing
|
|
982
|
-
// on live sources like rtsp. the buffer before the sync frame stream will be enough
|
|
983
|
-
// for ffmpeg to analyze and start up in time for the sync frame.
|
|
984
|
-
// may be worth considering playing with a few other things to avoid this:
|
|
985
|
-
// mpeg-ts as a container (would need to write a muxer)
|
|
986
|
-
// specifying the buffer before the sync frame with probesize.
|
|
987
|
-
// If h264 oddities are detected, assume ffmpeg will be used.
|
|
988
|
-
if (container !== 'rtsp' || !options.findSyncFrame || this.getLastH264Oddities()) {
|
|
989
|
-
for (const chunk of prebufferContainer) {
|
|
990
|
-
if (chunk.time < now - requestedPrebuffer)
|
|
991
|
-
continue;
|
|
992
|
-
|
|
993
|
-
safeWriteData(chunk, true);
|
|
994
|
-
}
|
|
995
|
-
}
|
|
996
|
-
else {
|
|
997
|
-
const parser = this.parsers[container];
|
|
998
|
-
const filtered = prebufferContainer.filter(pb => pb.time >= now - requestedPrebuffer);
|
|
999
|
-
let availablePrebuffers = parser.findSyncFrame(filtered);
|
|
1000
|
-
if (!availablePrebuffers) {
|
|
1001
|
-
this.console.warn('Unable to find sync frame in rtsp prebuffer.');
|
|
1002
|
-
availablePrebuffers = [];
|
|
1003
|
-
}
|
|
1004
|
-
else {
|
|
1005
|
-
this.console.log('Found sync frame in rtsp prebuffer.');
|
|
1006
|
-
}
|
|
1007
|
-
for (const prebuffer of availablePrebuffers) {
|
|
1008
|
-
safeWriteData(prebuffer, true);
|
|
1009
|
-
}
|
|
1010
|
-
}
|
|
1011
|
-
|
|
1012
|
-
return cleanup;
|
|
1013
|
-
}
|
|
1014
|
-
})
|
|
1015
|
-
}
|
|
1016
|
-
|
|
1017
|
-
async getVideoStream(findSyncFrame: boolean, options?: RequestMediaStreamOptions) {
|
|
1018
|
-
if (options?.refresh === false && !this.parserSessionPromise)
|
|
1019
|
-
throw new Error('Stream is currently unavailable and will not be started for this request. RequestMediaStreamOptions.refresh === false');
|
|
1020
|
-
|
|
1021
|
-
const startedParserSession = !this.parserSessionPromise;
|
|
1022
|
-
|
|
1023
|
-
this.ensurePrebufferSession();
|
|
1024
|
-
|
|
1025
|
-
const session = await this.parserSessionPromise;
|
|
1026
|
-
|
|
1027
|
-
let requestedPrebuffer = options?.prebuffer;
|
|
1028
|
-
// if no prebuffer was requested, try to find a sync frame in the prebuffer.
|
|
1029
|
-
// also do this if this request initiated the prebuffer: so, an explicit request for 0 prebuffer
|
|
1030
|
-
// will still send the initial sync frame in the stream start. it may otherwise be missed
|
|
1031
|
-
// if some time passes between the initial stream request and the actual pulling of the stream.
|
|
1032
|
-
if (requestedPrebuffer == null || startedParserSession) {
|
|
1033
|
-
// prebuffer search for remote streaming should be even more conservative than local network.
|
|
1034
|
-
const defaultPrebuffer = options?.destination === 'remote' ? 2000 : 4000;
|
|
1035
|
-
// try to gaurantee a sync frame, but don't search too much prebuffer to make it happen.
|
|
1036
|
-
requestedPrebuffer = Math.min(defaultPrebuffer, this.getDetectedIdrInterval() || defaultPrebuffer);;
|
|
1037
|
-
}
|
|
1038
|
-
|
|
1039
|
-
const { rtspMode, muxingMp4 } = this.getRebroadcastContainer();
|
|
1040
|
-
const defaultContainer = rtspMode ? 'rtsp' : 'mpegts';
|
|
1041
|
-
|
|
1042
|
-
let container: PrebufferParsers = this.parsers[options?.container] ? options?.container as PrebufferParsers : defaultContainer;
|
|
1043
|
-
|
|
1044
|
-
const mediaStreamOptions: ResponseMediaStreamOptions = session.negotiateMediaStream(options);
|
|
1045
|
-
let sdp = await this.sdp;
|
|
1046
|
-
if (!mediaStreamOptions.video?.h264Info && this.usingScryptedParser)
|
|
1047
|
-
mediaStreamOptions.video.h264Info = this.getLastH264Probe();
|
|
1048
|
-
|
|
1049
|
-
let socketPromise: Promise<Duplex>;
|
|
1050
|
-
let url: string;
|
|
1051
|
-
let filter: (chunk: StreamChunk, prebuffer: boolean) => StreamChunk;
|
|
1052
|
-
let interleavePassthrough = false;
|
|
1053
|
-
const interleavedMap = new Map<string, number>();
|
|
1054
|
-
const serverPortMap = new Map<string, RtspTrack>();
|
|
1055
|
-
let server: FileRtspServer;
|
|
1056
|
-
|
|
1057
|
-
if (container === 'rtsp') {
|
|
1058
|
-
const parsedSdp = parseSdp(sdp);
|
|
1059
|
-
const videoSection = parsedSdp.msections.find(msection => msection.codec && msection.codec === mediaStreamOptions.video?.codec) || parsedSdp.msections.find(msection => msection.type === 'video');
|
|
1060
|
-
let audioSection = parsedSdp.msections.find(msection => msection.codec && msection.codec === mediaStreamOptions.audio?.codec) || parsedSdp.msections.find(msection => msection.type === 'audio');
|
|
1061
|
-
if (mediaStreamOptions.audio === null)
|
|
1062
|
-
audioSection = undefined;
|
|
1063
|
-
parsedSdp.msections = parsedSdp.msections.filter(msection => msection === videoSection || msection === audioSection);
|
|
1064
|
-
const filterPrebufferAudio = options?.prebuffer === undefined;
|
|
1065
|
-
const videoCodec = parsedSdp.msections.find(msection => msection.type === 'video')?.codec;
|
|
1066
|
-
sdp = parsedSdp.toSdp();
|
|
1067
|
-
filter = (chunk, prebuffer) => {
|
|
1068
|
-
// if no prebuffer is explicitly requested, don't send prebuffer audio
|
|
1069
|
-
if (prebuffer && filterPrebufferAudio && chunk.type !== videoCodec)
|
|
1070
|
-
return;
|
|
1071
|
-
|
|
1072
|
-
const channel = interleavedMap.get(chunk.type);
|
|
1073
|
-
if (!interleavePassthrough) {
|
|
1074
|
-
if (channel == undefined) {
|
|
1075
|
-
const udp = serverPortMap.get(chunk.type);
|
|
1076
|
-
if (udp)
|
|
1077
|
-
server.sendTrack(udp.control, chunk.chunks[1], chunk.type.startsWith('rtcp-'));
|
|
1078
|
-
return;
|
|
1079
|
-
}
|
|
1080
|
-
|
|
1081
|
-
const chunks = chunk.chunks.slice();
|
|
1082
|
-
const header = Buffer.from(chunks[0]);
|
|
1083
|
-
header.writeUInt8(channel, 1);
|
|
1084
|
-
chunks[0] = header;
|
|
1085
|
-
chunk = {
|
|
1086
|
-
startStream: chunk.startStream,
|
|
1087
|
-
chunks,
|
|
1088
|
-
}
|
|
1089
|
-
}
|
|
1090
|
-
else if (channel === undefined) {
|
|
1091
|
-
return;
|
|
1092
|
-
}
|
|
1093
|
-
|
|
1094
|
-
if (server.writeStream) {
|
|
1095
|
-
server.writeRtpPayload(chunk.chunks[0], chunk.chunks[1]);
|
|
1096
|
-
return;
|
|
1097
|
-
}
|
|
1098
|
-
|
|
1099
|
-
return chunk;
|
|
1100
|
-
}
|
|
1101
|
-
|
|
1102
|
-
const client = await listenZeroSingleClient();
|
|
1103
|
-
socketPromise = client.clientPromise.then(async (socket) => {
|
|
1104
|
-
sdp = addTrackControls(sdp);
|
|
1105
|
-
server = new FileRtspServer(socket, sdp);
|
|
1106
|
-
server.writeConsole = this.console;
|
|
1107
|
-
if (session.parserSpecific) {
|
|
1108
|
-
const parserSpecific = session.parserSpecific as RtspSessionParserSpecific;
|
|
1109
|
-
server.resolveInterleaved = msection => {
|
|
1110
|
-
const channel = parserSpecific.interleaved.get(msection.codec);
|
|
1111
|
-
return [channel, channel + 1];
|
|
1112
|
-
}
|
|
1113
|
-
}
|
|
1114
|
-
// server.console = this.console;
|
|
1115
|
-
await server.handlePlayback();
|
|
1116
|
-
server.handleTeardown().finally(() => socket.destroy());
|
|
1117
|
-
for (const track of Object.values(server.setupTracks)) {
|
|
1118
|
-
if (track.protocol === 'udp') {
|
|
1119
|
-
serverPortMap.set(track.codec, track);
|
|
1120
|
-
serverPortMap.set(`rtcp-${track.codec}`, track);
|
|
1121
|
-
continue;
|
|
1122
|
-
}
|
|
1123
|
-
interleavedMap.set(track.codec, track.destination);
|
|
1124
|
-
interleavedMap.set(`rtcp-${track.codec}`, track.destination + 1);
|
|
1125
|
-
}
|
|
1126
|
-
|
|
1127
|
-
interleavePassthrough = session.parserSpecific && serverPortMap.size === 0;
|
|
1128
|
-
return socket;
|
|
1129
|
-
})
|
|
1130
|
-
url = client.url.replace('tcp://', 'rtsp://');
|
|
1131
|
-
}
|
|
1132
|
-
else {
|
|
1133
|
-
const client = await listenZeroSingleClient();
|
|
1134
|
-
socketPromise = client.clientPromise;
|
|
1135
|
-
url = client.url;
|
|
1136
|
-
}
|
|
1137
|
-
|
|
1138
|
-
mediaStreamOptions.sdp = sdp;
|
|
1139
|
-
|
|
1140
|
-
const isActiveClient = options?.refresh !== false;
|
|
1141
|
-
|
|
1142
|
-
this.handleRebroadcasterClient({
|
|
1143
|
-
findSyncFrame,
|
|
1144
|
-
isActiveClient,
|
|
1145
|
-
container,
|
|
1146
|
-
requestedPrebuffer,
|
|
1147
|
-
socketPromise,
|
|
1148
|
-
session,
|
|
1149
|
-
filter,
|
|
1150
|
-
});
|
|
1151
|
-
|
|
1152
|
-
mediaStreamOptions.prebuffer = requestedPrebuffer;
|
|
1153
|
-
|
|
1154
|
-
const { reencodeAudio } = this.getAudioConfig();
|
|
1155
|
-
|
|
1156
|
-
if (this.audioDisabled) {
|
|
1157
|
-
mediaStreamOptions.audio = null;
|
|
1158
|
-
}
|
|
1159
|
-
else if (reencodeAudio && muxingMp4) {
|
|
1160
|
-
mediaStreamOptions.audio = {
|
|
1161
|
-
codec: 'aac',
|
|
1162
|
-
encoder: 'aac',
|
|
1163
|
-
profile: 'aac_low',
|
|
1164
|
-
}
|
|
1165
|
-
}
|
|
1166
|
-
|
|
1167
|
-
if (session.inputVideoResolution?.width && session.inputVideoResolution?.height) {
|
|
1168
|
-
// this may be an audio only request.
|
|
1169
|
-
if (mediaStreamOptions.video)
|
|
1170
|
-
Object.assign(mediaStreamOptions.video, session.inputVideoResolution);
|
|
1171
|
-
}
|
|
1172
|
-
|
|
1173
|
-
const now = Date.now();
|
|
1174
|
-
let available = 0;
|
|
1175
|
-
const prebufferContainer: PrebufferStreamChunk[] = this.prebuffers[container];
|
|
1176
|
-
for (const prebuffer of prebufferContainer) {
|
|
1177
|
-
if (prebuffer.time < now - requestedPrebuffer)
|
|
1178
|
-
continue;
|
|
1179
|
-
for (const chunk of prebuffer.chunks) {
|
|
1180
|
-
available += chunk.length;
|
|
1181
|
-
}
|
|
1182
|
-
}
|
|
1183
|
-
mediaStreamOptions.prebufferBytes = available;
|
|
1184
|
-
|
|
1185
|
-
const length = Math.max(500000, available).toString();
|
|
1186
|
-
|
|
1187
|
-
const inputArguments = [
|
|
1188
|
-
'-analyzeduration', '0', '-probesize', length,
|
|
1189
|
-
];
|
|
1190
|
-
if (!this.usingScryptedUdpParser)
|
|
1191
|
-
inputArguments.push('-reorder_queue_size', '0');
|
|
1192
|
-
|
|
1193
|
-
const ffmpegInput: FFmpegInput = {
|
|
1194
|
-
url,
|
|
1195
|
-
container,
|
|
1196
|
-
inputArguments: [
|
|
1197
|
-
...inputArguments,
|
|
1198
|
-
...(this.parsers[container].inputArguments || []),
|
|
1199
|
-
'-f', this.parsers[container].container,
|
|
1200
|
-
'-i', url,
|
|
1201
|
-
],
|
|
1202
|
-
mediaStreamOptions,
|
|
1203
|
-
}
|
|
1204
|
-
|
|
1205
|
-
return ffmpegInput;
|
|
1206
|
-
}
|
|
1207
|
-
}
|
|
1208
|
-
|
|
1209
|
-
class PrebufferMixin extends SettingsMixinDeviceBase<VideoCamera & VideoCameraConfiguration> implements VideoCamera, Settings, VideoCameraConfiguration {
|
|
1210
|
-
released = false;
|
|
1211
|
-
sessions = new Map<string, PrebufferSession>();
|
|
1212
|
-
|
|
1213
|
-
streamSettings = createStreamSettings(this);
|
|
1214
|
-
rtspServer: net.Server;
|
|
1215
|
-
|
|
1216
|
-
constructor(public getTranscodeStorageSettings: () => Promise<any>, options: SettingsMixinDeviceOptions<VideoCamera & VideoCameraConfiguration>) {
|
|
1217
|
-
super(options);
|
|
1218
|
-
|
|
1219
|
-
this.delayStart();
|
|
1220
|
-
|
|
1221
|
-
this.startRtspServer();
|
|
1222
|
-
}
|
|
1223
|
-
|
|
1224
|
-
startRtspServer() {
|
|
1225
|
-
closeQuiet(this.rtspServer);
|
|
1226
|
-
|
|
1227
|
-
this.rtspServer = new net.Server(async (client) => {
|
|
1228
|
-
let prebufferSession: PrebufferSession;
|
|
1229
|
-
|
|
1230
|
-
const server = new RtspServer(client, undefined, false, async (method, url, headers, rawMessage) => {
|
|
1231
|
-
server.checkRequest = undefined;
|
|
1232
|
-
|
|
1233
|
-
const u = new URL(url);
|
|
1234
|
-
|
|
1235
|
-
for (const session of this.sessions.values()) {
|
|
1236
|
-
if (u.pathname.endsWith(session.rtspServerPath)) {
|
|
1237
|
-
server.console = session.console;
|
|
1238
|
-
prebufferSession = session;
|
|
1239
|
-
prebufferSession.ensurePrebufferSession();
|
|
1240
|
-
await prebufferSession.parserSessionPromise;
|
|
1241
|
-
server.sdp = await prebufferSession.sdp;
|
|
1242
|
-
return true;
|
|
1243
|
-
}
|
|
1244
|
-
}
|
|
1245
|
-
|
|
1246
|
-
return false;
|
|
1247
|
-
});
|
|
1248
|
-
|
|
1249
|
-
this.console.log('RTSP Rebroadcast connection started.')
|
|
1250
|
-
server.console = this.console;
|
|
1251
|
-
|
|
1252
|
-
try {
|
|
1253
|
-
await server.handlePlayback();
|
|
1254
|
-
const map = new Map<string, string>();
|
|
1255
|
-
for (const [id, track] of Object.entries(server.setupTracks)) {
|
|
1256
|
-
map.set(track.codec, id);
|
|
1257
|
-
}
|
|
1258
|
-
const session = await prebufferSession.parserSessionPromise;
|
|
1259
|
-
|
|
1260
|
-
const requestedPrebuffer = Math.max(4000, prebufferSession.getDetectedIdrInterval() || 4000);;
|
|
1261
|
-
|
|
1262
|
-
prebufferSession.handleRebroadcasterClient({
|
|
1263
|
-
findSyncFrame: true,
|
|
1264
|
-
isActiveClient: true,
|
|
1265
|
-
container: 'rtsp',
|
|
1266
|
-
session,
|
|
1267
|
-
socketPromise: Promise.resolve(client),
|
|
1268
|
-
requestedPrebuffer,
|
|
1269
|
-
filter: (chunk, prebuffer) => {
|
|
1270
|
-
const track = map.get(chunk.type);
|
|
1271
|
-
if (track)
|
|
1272
|
-
server.sendTrack(track, chunk.chunks[1], false);
|
|
1273
|
-
return undefined;
|
|
1274
|
-
}
|
|
1275
|
-
});
|
|
1276
|
-
|
|
1277
|
-
await server.handleTeardown();
|
|
1278
|
-
}
|
|
1279
|
-
catch (e) {
|
|
1280
|
-
client.destroy();
|
|
1281
|
-
}
|
|
1282
|
-
this.console.log('RTSP Rebroadcast connection finished.')
|
|
1283
|
-
});
|
|
1284
|
-
|
|
1285
|
-
this.rtspServer.listen(this.streamSettings.storageSettings.values.rebroadcastPort || 0);
|
|
1286
|
-
|
|
1287
|
-
once(this.rtspServer, 'listening').then(() => {
|
|
1288
|
-
const port = (this.rtspServer.address() as AddressInfo).port;
|
|
1289
|
-
this.streamSettings.storageSettings.values.rebroadcastPort = port;
|
|
1290
|
-
})
|
|
1291
|
-
}
|
|
1292
|
-
|
|
1293
|
-
delayStart() {
|
|
1294
|
-
this.console.log('prebuffer sessions starting in 5 seconds');
|
|
1295
|
-
// to prevent noisy startup/reload/shutdown, delay the prebuffer starting.
|
|
1296
|
-
setTimeout(() => this.ensurePrebufferSessions(), 5000);
|
|
1297
|
-
}
|
|
1298
|
-
|
|
1299
|
-
async getVideoStream(options?: RequestMediaStreamOptions): Promise<MediaObject> {
|
|
1300
|
-
if (options?.directMediaStream)
|
|
1301
|
-
return this.mixinDevice.getVideoStream(options);
|
|
1302
|
-
|
|
1303
|
-
await this.ensurePrebufferSessions();
|
|
1304
|
-
|
|
1305
|
-
let id = options?.id;
|
|
1306
|
-
if (!this.sessions.has(id))
|
|
1307
|
-
id = undefined;
|
|
1308
|
-
let h264EncoderArguments: string[];
|
|
1309
|
-
let videoFilterArguments: string;
|
|
1310
|
-
let destinationVideoBitrate: number;
|
|
1311
|
-
|
|
1312
|
-
const transcodingEnabled = this.mixins?.includes(getTranscodeMixinProviderId());
|
|
1313
|
-
|
|
1314
|
-
const msos = await this.mixinDevice.getVideoStreamOptions();
|
|
1315
|
-
let result: {
|
|
1316
|
-
stream: ResponseMediaStreamOptions,
|
|
1317
|
-
isDefault: boolean,
|
|
1318
|
-
title: string;
|
|
1319
|
-
};
|
|
1320
|
-
|
|
1321
|
-
const transcodeStorageSettings = await this.getTranscodeStorageSettings();
|
|
1322
|
-
const defaultLocalBitrate = 2000000;
|
|
1323
|
-
const defaultLowResolutionBitrate = 512000;
|
|
1324
|
-
if (!id) {
|
|
1325
|
-
switch (options?.destination) {
|
|
1326
|
-
case 'medium-resolution':
|
|
1327
|
-
case 'remote':
|
|
1328
|
-
result = this.streamSettings.getRemoteStream(msos);
|
|
1329
|
-
destinationVideoBitrate = transcodeStorageSettings.remoteStreamingBitrate;
|
|
1330
|
-
break;
|
|
1331
|
-
case 'low-resolution':
|
|
1332
|
-
result = this.streamSettings.getLowResolutionStream(msos);
|
|
1333
|
-
destinationVideoBitrate = defaultLowResolutionBitrate;
|
|
1334
|
-
break;
|
|
1335
|
-
case 'local-recorder':
|
|
1336
|
-
result = this.streamSettings.getRecordingStream(msos);
|
|
1337
|
-
destinationVideoBitrate = defaultLocalBitrate;
|
|
1338
|
-
break;
|
|
1339
|
-
case 'remote-recorder':
|
|
1340
|
-
result = this.streamSettings.getRemoteRecordingStream(msos);
|
|
1341
|
-
destinationVideoBitrate = defaultLocalBitrate;
|
|
1342
|
-
break;
|
|
1343
|
-
default:
|
|
1344
|
-
result = this.streamSettings.getDefaultStream(msos);
|
|
1345
|
-
destinationVideoBitrate = defaultLocalBitrate;
|
|
1346
|
-
break;
|
|
1347
|
-
}
|
|
1348
|
-
|
|
1349
|
-
id = result.stream.id;
|
|
1350
|
-
this.console.log('Selected stream', result.stream.name);
|
|
1351
|
-
// transcoding video should never happen transparently since it is CPU intensive.
|
|
1352
|
-
// encourage users at every step to configure proper codecs.
|
|
1353
|
-
// for this reason, do not automatically supply h264 encoder arguments
|
|
1354
|
-
// even if h264 is requested, to force a visible failure.
|
|
1355
|
-
if (transcodingEnabled && this.streamSettings.storageSettings.values.transcodeStreams?.includes(result.title)) {
|
|
1356
|
-
h264EncoderArguments = transcodeStorageSettings.h264EncoderArguments?.split(' ');
|
|
1357
|
-
if (this.streamSettings.storageSettings.values.videoFilterArguments)
|
|
1358
|
-
videoFilterArguments = this.streamSettings.storageSettings.values.videoFilterArguments;
|
|
1359
|
-
}
|
|
1360
|
-
}
|
|
1361
|
-
|
|
1362
|
-
let session = this.sessions.get(id);
|
|
1363
|
-
let ffmpegInput: FFmpegInput;
|
|
1364
|
-
if (!session.canPrebuffer) {
|
|
1365
|
-
this.console.log('Source container can not be prebuffered. Using a direct media stream.');
|
|
1366
|
-
session = undefined;
|
|
1367
|
-
}
|
|
1368
|
-
if (!session) {
|
|
1369
|
-
const mo = await this.mixinDevice.getVideoStream(options);
|
|
1370
|
-
if (!transcodingEnabled)
|
|
1371
|
-
return mo;
|
|
1372
|
-
ffmpegInput = await mediaManager.convertMediaObjectToJSON(mo, ScryptedMimeTypes.FFmpegInput);
|
|
1373
|
-
}
|
|
1374
|
-
else {
|
|
1375
|
-
// ffmpeg probing works better if the stream does NOT start on a sync frame. the pre-sps/pps data is used
|
|
1376
|
-
// as part of the stream analysis, and sync frame is immediately used. otherwise the sync frame is
|
|
1377
|
-
// read and tossed during rtsp analysis.
|
|
1378
|
-
// if ffmpeg is not in used (ie, not transcoding or implicitly rtsp),
|
|
1379
|
-
// trust that downstream is not using ffmpeg and start with a sync frame.
|
|
1380
|
-
const findSyncFrame = !transcodingEnabled
|
|
1381
|
-
&& (!options?.container || options?.container === 'rtsp')
|
|
1382
|
-
&& options?.tool !== 'ffmpeg';
|
|
1383
|
-
ffmpegInput = await session.getVideoStream(findSyncFrame, options);
|
|
1384
|
-
}
|
|
1385
|
-
|
|
1386
|
-
ffmpegInput.h264EncoderArguments = h264EncoderArguments;
|
|
1387
|
-
ffmpegInput.destinationVideoBitrate = destinationVideoBitrate;
|
|
1388
|
-
|
|
1389
|
-
if (transcodingEnabled && this.streamSettings.storageSettings.values.missingCodecParameters) {
|
|
1390
|
-
if (!ffmpegInput.mediaStreamOptions)
|
|
1391
|
-
ffmpegInput.mediaStreamOptions = { id };
|
|
1392
|
-
ffmpegInput.mediaStreamOptions.oobCodecParameters = true;
|
|
1393
|
-
}
|
|
1394
|
-
|
|
1395
|
-
if (ffmpegInput.h264FilterArguments && videoFilterArguments)
|
|
1396
|
-
addVideoFilterArguments(ffmpegInput.h264FilterArguments, videoFilterArguments)
|
|
1397
|
-
else if (videoFilterArguments)
|
|
1398
|
-
ffmpegInput.h264FilterArguments = ['-filter_complex', videoFilterArguments];
|
|
1399
|
-
|
|
1400
|
-
if (transcodingEnabled)
|
|
1401
|
-
ffmpegInput.videoDecoderArguments = this.streamSettings.storageSettings.values.videoDecoderArguments?.split(' ');
|
|
1402
|
-
return mediaManager.createFFmpegMediaObject(ffmpegInput, {
|
|
1403
|
-
sourceId: this.id,
|
|
1404
|
-
});
|
|
1405
|
-
}
|
|
1406
|
-
|
|
1407
|
-
async ensurePrebufferSessions() {
|
|
1408
|
-
const msos = await this.mixinDevice.getVideoStreamOptions();
|
|
1409
|
-
const enabled = this.getPrebufferedStreams(msos);
|
|
1410
|
-
const enabledIds = enabled ? enabled.map(mso => mso.id) : [undefined];
|
|
1411
|
-
const ids = msos?.map(mso => mso.id) || [undefined];
|
|
1412
|
-
|
|
1413
|
-
if (this.storage.getItem('warnedCloud') !== 'true') {
|
|
1414
|
-
const cloud = msos?.find(mso => mso.source === 'cloud');
|
|
1415
|
-
if (cloud) {
|
|
1416
|
-
this.storage.setItem('warnedCloud', 'true');
|
|
1417
|
-
log.a(`${this.name} is a cloud camera. Prebuffering maintains a persistent stream and will not enabled by default. You must enable the Prebuffer stream manually.`)
|
|
1418
|
-
}
|
|
1419
|
-
}
|
|
1420
|
-
|
|
1421
|
-
const isBatteryPowered = this.mixinDeviceInterfaces.includes(ScryptedInterface.Battery);
|
|
1422
|
-
|
|
1423
|
-
if (!enabledIds.length)
|
|
1424
|
-
this.online = true;
|
|
1425
|
-
|
|
1426
|
-
let active = 0;
|
|
1427
|
-
for (const id of ids) {
|
|
1428
|
-
let session = this.sessions.get(id);
|
|
1429
|
-
|
|
1430
|
-
if (session)
|
|
1431
|
-
continue;
|
|
1432
|
-
|
|
1433
|
-
const mso = msos?.find(mso => mso.id === id);
|
|
1434
|
-
if (mso?.prebuffer) {
|
|
1435
|
-
log.a(`Prebuffer is already available on ${this.name}. If this is a grouped device, disable the Rebroadcast extension.`)
|
|
1436
|
-
}
|
|
1437
|
-
const name = mso?.name;
|
|
1438
|
-
const enabled = enabledIds.includes(id);
|
|
1439
|
-
const stopInactive = isBatteryPowered || !enabled;
|
|
1440
|
-
session = new PrebufferSession(this, mso, stopInactive);
|
|
1441
|
-
this.sessions.set(id, session);
|
|
1442
|
-
|
|
1443
|
-
if (isBatteryPowered) {
|
|
1444
|
-
this.console.log('camera is battery powered, prebuffering and rebroadcasting will only work on demand.');
|
|
1445
|
-
continue;
|
|
1446
|
-
}
|
|
1447
|
-
|
|
1448
|
-
if (!enabled) {
|
|
1449
|
-
this.console.log('stream', name, 'will be rebroadcast on demand.');
|
|
1450
|
-
continue;
|
|
1451
|
-
}
|
|
1452
|
-
|
|
1453
|
-
(async () => {
|
|
1454
|
-
while (this.sessions.get(id) === session && !this.released) {
|
|
1455
|
-
session.ensurePrebufferSession();
|
|
1456
|
-
let wasActive = false;
|
|
1457
|
-
try {
|
|
1458
|
-
this.console.log('prebuffer session starting');
|
|
1459
|
-
const ps = await session.parserSessionPromise;
|
|
1460
|
-
this.console.log('prebuffer session started');
|
|
1461
|
-
active++;
|
|
1462
|
-
wasActive = true;
|
|
1463
|
-
this.online = !!active;
|
|
1464
|
-
await ps.killed;
|
|
1465
|
-
this.console.error('prebuffer session ended');
|
|
1466
|
-
}
|
|
1467
|
-
catch (e) {
|
|
1468
|
-
this.console.error('prebuffer session ended with error', e);
|
|
1469
|
-
}
|
|
1470
|
-
finally {
|
|
1471
|
-
if (wasActive)
|
|
1472
|
-
active--;
|
|
1473
|
-
wasActive = false;
|
|
1474
|
-
this.online = !!active;
|
|
1475
|
-
}
|
|
1476
|
-
this.console.log('restarting prebuffer session in 5 seconds');
|
|
1477
|
-
await new Promise(resolve => setTimeout(resolve, 5000));
|
|
1478
|
-
}
|
|
1479
|
-
this.console.log('exiting prebuffer session (released or restarted with new configuration)');
|
|
1480
|
-
})();
|
|
1481
|
-
}
|
|
1482
|
-
|
|
1483
|
-
if (!this.sessions.has(undefined)) {
|
|
1484
|
-
const defaultStreamName = this.streamSettings.storageSettings.values.defaultStream;
|
|
1485
|
-
let defaultSession = this.sessions.get(msos?.find(mso => mso.name === defaultStreamName)?.id);
|
|
1486
|
-
if (!defaultSession)
|
|
1487
|
-
defaultSession = this.sessions.get(msos?.find(mso => mso.id === enabledIds[0])?.id);
|
|
1488
|
-
if (!defaultSession)
|
|
1489
|
-
defaultSession = this.sessions.get(msos?.find(mso => mso.id === ids?.[0])?.id);
|
|
1490
|
-
|
|
1491
|
-
if (defaultSession) {
|
|
1492
|
-
this.sessions.set(undefined, defaultSession);
|
|
1493
|
-
this.console.log('Default Stream:', defaultSession.advertisedMediaStreamOptions.id, defaultSession.advertisedMediaStreamOptions.name);
|
|
1494
|
-
}
|
|
1495
|
-
else {
|
|
1496
|
-
this.console.warn('Unable to find Default Stream?');
|
|
1497
|
-
}
|
|
1498
|
-
}
|
|
1499
|
-
}
|
|
1500
|
-
|
|
1501
|
-
async getMixinSettings(): Promise<Setting[]> {
|
|
1502
|
-
const settings: Setting[] = [];
|
|
1503
|
-
|
|
1504
|
-
settings.push(...await this.streamSettings.storageSettings.getSettings());
|
|
1505
|
-
|
|
1506
|
-
for (const session of new Set([...this.sessions.values()])) {
|
|
1507
|
-
if (!session)
|
|
1508
|
-
continue;
|
|
1509
|
-
try {
|
|
1510
|
-
settings.push(...await session.getMixinSettings());
|
|
1511
|
-
}
|
|
1512
|
-
catch (e) {
|
|
1513
|
-
this.console.error('error in prebuffer session getMixinSettings', e);
|
|
1514
|
-
}
|
|
1515
|
-
}
|
|
1516
|
-
|
|
1517
|
-
return settings;
|
|
1518
|
-
}
|
|
1519
|
-
|
|
1520
|
-
async putMixinSetting(key: string, value: SettingValue): Promise<void> {
|
|
1521
|
-
if (this.streamSettings.storageSettings.settings[key])
|
|
1522
|
-
await this.streamSettings.storageSettings.putSetting(key, value);
|
|
1523
|
-
else
|
|
1524
|
-
this.storage.setItem(key, value?.toString());
|
|
1525
|
-
|
|
1526
|
-
// no prebuffer change necessary if the setting is a transcoding hint.
|
|
1527
|
-
if (this.streamSettings.storageSettings.settings[key]?.group === 'Transcoding')
|
|
1528
|
-
return;
|
|
1529
|
-
|
|
1530
|
-
const sessions = this.sessions;
|
|
1531
|
-
this.sessions = new Map();
|
|
1532
|
-
|
|
1533
|
-
// kill and reinitiate the prebuffers.
|
|
1534
|
-
for (const session of sessions.values()) {
|
|
1535
|
-
session?.parserSessionPromise?.then(session => session.kill(new Error('rebroadcast settings changed')));
|
|
1536
|
-
}
|
|
1537
|
-
this.ensurePrebufferSessions();
|
|
1538
|
-
}
|
|
1539
|
-
|
|
1540
|
-
getPrebufferedStreams(msos?: ResponseMediaStreamOptions[]) {
|
|
1541
|
-
return getPrebufferedStreams(this.streamSettings.storageSettings, msos);
|
|
1542
|
-
}
|
|
1543
|
-
|
|
1544
|
-
async getVideoStreamOptions(): Promise<ResponseMediaStreamOptions[]> {
|
|
1545
|
-
const ret: ResponseMediaStreamOptions[] = await this.mixinDevice.getVideoStreamOptions() || [];
|
|
1546
|
-
let enabledStreams = this.getPrebufferedStreams(ret);
|
|
1547
|
-
|
|
1548
|
-
for (const mso of ret) {
|
|
1549
|
-
const session = this.sessions.get(mso.id);
|
|
1550
|
-
if (session?.parserSession || enabledStreams.includes(mso))
|
|
1551
|
-
mso.prebuffer = prebufferDurationMs;
|
|
1552
|
-
if (session && !mso.video?.h264Info)
|
|
1553
|
-
mso.video.h264Info = session.getLastH264Probe();
|
|
1554
|
-
}
|
|
1555
|
-
|
|
1556
|
-
return ret;
|
|
1557
|
-
}
|
|
1558
|
-
|
|
1559
|
-
setVideoStreamOptions(options: MediaStreamOptions): Promise<void> {
|
|
1560
|
-
const session = this.sessions.get(options.id);
|
|
1561
|
-
if (session && options?.video?.bitrate) {
|
|
1562
|
-
session.needBitrateReset = true;
|
|
1563
|
-
const maxBitrate = session.maxBitrate;
|
|
1564
|
-
if (maxBitrate && options?.video?.bitrate > maxBitrate) {
|
|
1565
|
-
this.console.log('clamping max bitrate request', options.video.bitrate, maxBitrate);
|
|
1566
|
-
options.video.bitrate = maxBitrate;
|
|
1567
|
-
}
|
|
1568
|
-
}
|
|
1569
|
-
return this.mixinDevice.setVideoStreamOptions(options);
|
|
1570
|
-
}
|
|
1571
|
-
|
|
1572
|
-
async release() {
|
|
1573
|
-
this.online = true;
|
|
1574
|
-
super.release();
|
|
1575
|
-
this.console.log('prebuffer session releasing if started');
|
|
1576
|
-
this.released = true;
|
|
1577
|
-
for (const session of this.sessions.values()) {
|
|
1578
|
-
if (!session)
|
|
1579
|
-
continue;
|
|
1580
|
-
session.clearPrebuffers();
|
|
1581
|
-
session.parserSessionPromise?.then(parserSession => {
|
|
1582
|
-
this.console.log('prebuffer session released');
|
|
1583
|
-
parserSession.kill(new Error('rebroadcast disabled'));
|
|
1584
|
-
session.clearPrebuffers();
|
|
1585
|
-
});
|
|
1586
|
-
}
|
|
1587
|
-
}
|
|
1588
|
-
}
|
|
1589
|
-
|
|
1590
|
-
function millisUntilMidnight() {
|
|
1591
|
-
var midnight = new Date();
|
|
1592
|
-
midnight.setHours(24);
|
|
1593
|
-
midnight.setMinutes(0);
|
|
1594
|
-
midnight.setSeconds(0);
|
|
1595
|
-
midnight.setMilliseconds(0);
|
|
1596
|
-
return (midnight.getTime() - new Date().getTime());
|
|
1597
|
-
}
|
|
1598
|
-
|
|
1599
|
-
export class RebroadcastPlugin extends AutoenableMixinProvider implements MixinProvider, BufferConverter, Settings, DeviceProvider {
|
|
1600
|
-
// no longer in use, but kept for future use.
|
|
1601
|
-
storageSettings = new StorageSettings(this, {});
|
|
1602
|
-
transcodeStorageSettings = new StorageSettings(this, {
|
|
1603
|
-
remoteStreamingBitrate: {
|
|
1604
|
-
title: 'Remote Streaming Bitrate',
|
|
1605
|
-
type: 'number',
|
|
1606
|
-
defaultValue: 1000000,
|
|
1607
|
-
description: 'The bitrate to use when remote streaming. This setting will only be used when transcoding or adaptive bitrate is enabled on a camera.',
|
|
1608
|
-
},
|
|
1609
|
-
h264EncoderArguments: {
|
|
1610
|
-
title: 'H264 Encoder Arguments',
|
|
1611
|
-
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.',
|
|
1612
|
-
choices: Object.keys(getH264EncoderArgs()),
|
|
1613
|
-
defaultValue: getDebugModeH264EncoderArgs().join(' '),
|
|
1614
|
-
combobox: true,
|
|
1615
|
-
mapPut: (oldValue, newValue) => getH264EncoderArgs()[newValue]?.join(' ') || newValue || getDebugModeH264EncoderArgs().join(' '),
|
|
1616
|
-
}
|
|
1617
|
-
});
|
|
1618
|
-
currentMixins = new Map<string, {
|
|
1619
|
-
terminate(): Promise<number>,
|
|
1620
|
-
mixin: Promise<PrebufferMixin>,
|
|
1621
|
-
}>();
|
|
1622
|
-
|
|
1623
|
-
constructor(nativeId?: string) {
|
|
1624
|
-
super(nativeId);
|
|
1625
|
-
|
|
1626
|
-
this.fromMimeType = 'x-scrypted/x-rfc4571';
|
|
1627
|
-
this.toMimeType = ScryptedMimeTypes.FFmpegInput;
|
|
1628
|
-
|
|
1629
|
-
// trigger the prebuffer. do this on next tick
|
|
1630
|
-
// to allow the mixins to spin up from this provider.
|
|
1631
|
-
process.nextTick(() => {
|
|
1632
|
-
for (const id of Object.keys(systemManager.getSystemState())) {
|
|
1633
|
-
const device = systemManager.getDeviceById<VideoCamera>(id);
|
|
1634
|
-
if (!device.mixins?.includes(this.id))
|
|
1635
|
-
continue;
|
|
1636
|
-
try {
|
|
1637
|
-
device.getVideoStreamOptions();
|
|
1638
|
-
}
|
|
1639
|
-
catch (e) {
|
|
1640
|
-
this.console.error('error triggering prebuffer', device.name, e);
|
|
1641
|
-
}
|
|
1642
|
-
}
|
|
1643
|
-
});
|
|
1644
|
-
|
|
1645
|
-
// schedule restarts at 2am
|
|
1646
|
-
const midnight = millisUntilMidnight();
|
|
1647
|
-
const twoAM = midnight + 2 * 60 * 60 * 1000;
|
|
1648
|
-
this.log.i(`Rebroadcaster scheduled for restart at 2AM: ${Math.round(twoAM / 1000 / 60)} minutes`)
|
|
1649
|
-
setTimeout(() => deviceManager.requestRestart(), twoAM);
|
|
1650
|
-
|
|
1651
|
-
process.nextTick(() => {
|
|
1652
|
-
deviceManager.onDeviceDiscovered({
|
|
1653
|
-
nativeId: TRANSCODE_MIXIN_PROVIDER_NATIVE_ID,
|
|
1654
|
-
name: 'Transcoding',
|
|
1655
|
-
interfaces: [
|
|
1656
|
-
ScryptedInterface.Settings,
|
|
1657
|
-
ScryptedInterface.MixinProvider,
|
|
1658
|
-
],
|
|
1659
|
-
type: ScryptedDeviceType.API,
|
|
1660
|
-
});
|
|
1661
|
-
});
|
|
1662
|
-
}
|
|
1663
|
-
|
|
1664
|
-
getDevice(nativeId: string) {
|
|
1665
|
-
if (nativeId === TRANSCODE_MIXIN_PROVIDER_NATIVE_ID)
|
|
1666
|
-
return new TranscodeMixinProvider(this);
|
|
1667
|
-
}
|
|
1668
|
-
|
|
1669
|
-
getSettings(): Promise<Setting[]> {
|
|
1670
|
-
return this.storageSettings.getSettings();
|
|
1671
|
-
}
|
|
1672
|
-
|
|
1673
|
-
putSetting(key: string, value: SettingValue): Promise<void> {
|
|
1674
|
-
return this.storageSettings.putSetting(key, value);
|
|
1675
|
-
}
|
|
1676
|
-
|
|
1677
|
-
|
|
1678
|
-
async convert(data: Buffer, fromMimeType: string, toMimeType: string): Promise<Buffer> {
|
|
1679
|
-
const json = JSON.parse(data.toString());
|
|
1680
|
-
const { url, sdp } = json;
|
|
1681
|
-
|
|
1682
|
-
const parsedSdp = parseSdp(sdp);
|
|
1683
|
-
const trackLookups = new Map<number, string>();
|
|
1684
|
-
for (const msection of parsedSdp.msections) {
|
|
1685
|
-
for (const pt of msection.payloadTypes) {
|
|
1686
|
-
trackLookups.set(pt, msection.control);
|
|
1687
|
-
}
|
|
1688
|
-
}
|
|
1689
|
-
|
|
1690
|
-
const u = new URL(url);
|
|
1691
|
-
if (!u.protocol.startsWith('tcp'))
|
|
1692
|
-
throw new Error('rfc4751 url must be tcp');
|
|
1693
|
-
const { clientPromise, url: clientUrl } = await listenZeroSingleClient();
|
|
1694
|
-
const ffmpeg: FFmpegInput = {
|
|
1695
|
-
url: clientUrl,
|
|
1696
|
-
inputArguments: [
|
|
1697
|
-
"-rtsp_transport", "tcp",
|
|
1698
|
-
'-i', clientUrl.replace('tcp', 'rtsp'),
|
|
1699
|
-
]
|
|
1700
|
-
};
|
|
1701
|
-
|
|
1702
|
-
clientPromise.then(async (client) => {
|
|
1703
|
-
const rtsp = new RtspServer(client, sdp);
|
|
1704
|
-
rtsp.console = this.console;
|
|
1705
|
-
await rtsp.handlePlayback();
|
|
1706
|
-
const socket = net.connect(parseInt(u.port), u.hostname);
|
|
1707
|
-
|
|
1708
|
-
client.on('close', () => {
|
|
1709
|
-
socket.destroy();
|
|
1710
|
-
});
|
|
1711
|
-
socket.on('close', () => {
|
|
1712
|
-
client.destroy();
|
|
1713
|
-
})
|
|
1714
|
-
|
|
1715
|
-
while (true) {
|
|
1716
|
-
const header = await readLength(socket, 2);
|
|
1717
|
-
const length = header.readInt16BE(0);
|
|
1718
|
-
const data = await readLength(socket, length);
|
|
1719
|
-
const pt = data[1] & 0x7f;
|
|
1720
|
-
const track = trackLookups.get(pt);
|
|
1721
|
-
if (!track) {
|
|
1722
|
-
client.destroy();
|
|
1723
|
-
socket.destroy();
|
|
1724
|
-
throw new Error('unknown payload type ' + pt);
|
|
1725
|
-
}
|
|
1726
|
-
rtsp.sendTrack(track, data, false);
|
|
1727
|
-
}
|
|
1728
|
-
});
|
|
1729
|
-
|
|
1730
|
-
return Buffer.from(JSON.stringify(ffmpeg));
|
|
1731
|
-
}
|
|
1732
|
-
|
|
1733
|
-
async canMixin(type: ScryptedDeviceType, interfaces: string[]): Promise<string[]> {
|
|
1734
|
-
if (!interfaces.includes(ScryptedInterface.VideoCamera))
|
|
1735
|
-
return null;
|
|
1736
|
-
const ret = [ScryptedInterface.VideoCamera, ScryptedInterface.Settings, ScryptedInterface.Online, REBROADCAST_MIXIN_INTERFACE_TOKEN];
|
|
1737
|
-
if (interfaces.includes(ScryptedInterface.VideoCameraConfiguration))
|
|
1738
|
-
ret.push(ScryptedInterface.VideoCameraConfiguration);
|
|
1739
|
-
return ret;
|
|
1740
|
-
}
|
|
1741
|
-
|
|
1742
|
-
async getMixin(mixinDevice: any, mixinDeviceInterfaces: ScryptedInterface[], mixinDeviceState: DeviceState) {
|
|
1743
|
-
this.setHasEnabledMixin(mixinDeviceState.id);
|
|
1744
|
-
|
|
1745
|
-
// 8-11-2022
|
|
1746
|
-
// old scrypted had a bug where mixin device state was not exposing properties like id correctly
|
|
1747
|
-
// across rpc boundaries.
|
|
1748
|
-
let fork = false;
|
|
1749
|
-
try {
|
|
1750
|
-
const info = await systemManager.getComponent('info');
|
|
1751
|
-
const version = await info.getVersion();
|
|
1752
|
-
fork = semver.gte(version, '0.2.5');
|
|
1753
|
-
}
|
|
1754
|
-
catch (e) {
|
|
1755
|
-
}
|
|
1756
|
-
|
|
1757
|
-
if (fork && sdk.fork && typeof mixinDeviceState.id === 'string') {
|
|
1758
|
-
const forked = sdk.fork<RebroadcastPluginFork>();
|
|
1759
|
-
const result = await forked.result as RebroadcastPluginFork;
|
|
1760
|
-
const ret = result.newPrebufferMixin(async () => this.transcodeStorageSettings.values, mixinDevice, mixinDeviceInterfaces, mixinDeviceState);
|
|
1761
|
-
this.currentMixins.set(mixinDeviceState.id, {
|
|
1762
|
-
terminate: () => forked.worker.terminate(),
|
|
1763
|
-
mixin: ret,
|
|
1764
|
-
});
|
|
1765
|
-
return ret;
|
|
1766
|
-
}
|
|
1767
|
-
else {
|
|
1768
|
-
const ret = newPrebufferMixin(async () => this.transcodeStorageSettings.values, mixinDevice, mixinDeviceInterfaces, mixinDeviceState);
|
|
1769
|
-
this.currentMixins.set(mixinDeviceState.id, {
|
|
1770
|
-
mixin: Promise.resolve(ret),
|
|
1771
|
-
terminate: undefined,
|
|
1772
|
-
});
|
|
1773
|
-
return ret;
|
|
1774
|
-
}
|
|
1775
|
-
}
|
|
1776
|
-
|
|
1777
|
-
async releaseMixin(id: string, mixinDevice: PrebufferMixin) {
|
|
1778
|
-
const current = this.currentMixins.get(id);
|
|
1779
|
-
this.currentMixins.delete(id);
|
|
1780
|
-
try {
|
|
1781
|
-
await mixinDevice.release();
|
|
1782
|
-
}
|
|
1783
|
-
finally {
|
|
1784
|
-
current?.terminate?.();
|
|
1785
|
-
}
|
|
1786
|
-
}
|
|
1787
|
-
}
|
|
1788
|
-
|
|
1789
|
-
async function newPrebufferMixin(getTranscodeStorageSettings: () => Promise<any>, mixinDevice: any, mixinDeviceInterfaces: ScryptedInterface[], mixinDeviceState: DeviceState) {
|
|
1790
|
-
return new PrebufferMixin(getTranscodeStorageSettings, {
|
|
1791
|
-
mixinDevice,
|
|
1792
|
-
mixinDeviceState,
|
|
1793
|
-
mixinProviderNativeId: undefined,
|
|
1794
|
-
mixinDeviceInterfaces,
|
|
1795
|
-
group: "Stream Management",
|
|
1796
|
-
groupKey: "prebuffer",
|
|
1797
|
-
})
|
|
1798
|
-
}
|
|
1799
|
-
|
|
1800
|
-
class RebroadcastPluginFork {
|
|
1801
|
-
newPrebufferMixin = newPrebufferMixin;
|
|
1802
|
-
}
|
|
1803
|
-
|
|
1804
|
-
export async function fork() {
|
|
1805
|
-
return new RebroadcastPluginFork();
|
|
1806
|
-
}
|
|
1807
|
-
|
|
1808
|
-
export default RebroadcastPlugin;
|