mirror of
https://github.com/koush/scrypted.git
synced 2026-07-09 08:30:39 +01:00
homekit/rebroadcast: refactor transcoding
This commit is contained in:
@@ -87,11 +87,14 @@ export function getH264DecoderArgs(): CodecArgs {
|
||||
return ret;
|
||||
}
|
||||
|
||||
export const LIBX264_ENCODER_TITLE = 'libx264 (Software)';
|
||||
|
||||
export function getH264EncoderArgs() {
|
||||
const encoders: { [type: string]: string } = {};
|
||||
|
||||
encoders['Copy Video, Transcode Audio'] = 'copy';
|
||||
|
||||
|
||||
if (isRaspberryPi()) {
|
||||
// encoders['Raspberry Pi OMX'] = 'h264_omx';
|
||||
// encoders[V4L2] = 'h264_v4l2m2m';
|
||||
@@ -128,5 +131,16 @@ export function getH264EncoderArgs() {
|
||||
'-pix_fmt', 'yuv420p', '-c:v', 'h264_v4l2m2m',
|
||||
]
|
||||
}
|
||||
|
||||
encoderArgs[LIBX264_ENCODER_TITLE] = getDebugModeH264EncoderArgs();
|
||||
|
||||
return encoderArgs;
|
||||
}
|
||||
|
||||
export function getDebugModeH264EncoderArgs() {
|
||||
return [
|
||||
"-c:v", "libx264",
|
||||
'-preset', 'ultrafast',
|
||||
"-bf", "0",
|
||||
];
|
||||
}
|
||||
|
||||
@@ -177,6 +177,9 @@ export function parseMSection(msection: string[]) {
|
||||
else if (rtpmap?.includes('h264')) {
|
||||
codec = 'h264';
|
||||
}
|
||||
else if (rtpmap?.includes('h265')) {
|
||||
codec = 'h265';
|
||||
}
|
||||
|
||||
let direction: string;
|
||||
for (const checkDirection of ['sendonly' , 'sendrecv', 'recvonly' , 'inactive']) {
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import sdk, { ObjectDetector, ScryptedDeviceType, ScryptedInterface, Setting, SettingValue, VideoCamera } from "@scrypted/sdk";
|
||||
import { getH264DecoderArgs, getH264EncoderArgs } from "../../../common/src/ffmpeg-hardware-acceleration";
|
||||
import { SettingsMixinDeviceOptions } from "../../../common/src/settings-mixin";
|
||||
import { SettingsMixinDeviceOptions } from "@scrypted/common/src/settings-mixin";
|
||||
import { HomekitMixin } from "./homekit-mixin";
|
||||
|
||||
const { systemManager, deviceManager } = sdk;
|
||||
@@ -44,146 +43,35 @@ export class CameraMixin extends HomekitMixin<any> {
|
||||
// type: 'boolean',
|
||||
// });
|
||||
|
||||
settings.push({
|
||||
title: 'HomeKit Transcoding',
|
||||
group: 'HomeKit Transcoding',
|
||||
key: 'transcodingNotices',
|
||||
value: 'WARNING',
|
||||
readonly: true,
|
||||
description: 'Transcoding audio and video for HomeKit is not recommended. Configure your camera using the camera web portal or app to output the correct HomeKit compatible codecs (h264/aac/2000kbps).',
|
||||
});
|
||||
|
||||
settings.push({
|
||||
group: 'HomeKit Transcoding',
|
||||
key: 'needsExtraData',
|
||||
title: 'Add H264 Extra Data',
|
||||
description: 'Some cameras do not include H264 extra data in the stream and this causes live streaming to always fail (but recordings may be working). This is a inexpensive video filter and does not perform a transcode. Enable this setting only as necessary.',
|
||||
value: (this.storage.getItem('needsExtraData') === 'true').toString(),
|
||||
type: 'boolean',
|
||||
});
|
||||
|
||||
let showTranscodeArgs = this.storage.getItem('transcodeStreaming') === 'true';
|
||||
|
||||
if (hasMotionSensor) {
|
||||
settings.push({
|
||||
title: 'Transcode Recording',
|
||||
group: 'HomeKit Transcoding',
|
||||
key: 'transcodeRecording',
|
||||
type: 'boolean',
|
||||
value: (this.storage.getItem('transcodeRecording') === 'true').toString(),
|
||||
description: 'Use FFmpeg to transcode recordings to a format supported by HomeKit Secure Video.',
|
||||
});
|
||||
|
||||
showTranscodeArgs = showTranscodeArgs || this.storage.getItem('transcodeRecording') === 'true';
|
||||
}
|
||||
|
||||
settings.push({
|
||||
title: 'Transcode Streaming',
|
||||
group: 'HomeKit Transcoding',
|
||||
type: 'boolean',
|
||||
key: 'transcodeStreaming',
|
||||
value: (this.storage.getItem('transcodeStreaming') === 'true').toString(),
|
||||
description: 'Use FFmpeg to transcode streaming to a format supported by HomeKit.',
|
||||
});
|
||||
|
||||
const transcodeEnabledValues = [
|
||||
'Transcode',
|
||||
'true',
|
||||
];
|
||||
|
||||
let hubStreamingMode = this.storage.getItem('hubStreamingMode');
|
||||
// 3/19/2022 migrate setting.
|
||||
if (this.storage.getItem('transcodeStreamingHub') === 'true') {
|
||||
if (!hubStreamingMode)
|
||||
hubStreamingMode = 'Transcode';
|
||||
this.storage.removeItem('transcodeStreamingHub');
|
||||
}
|
||||
|
||||
let watchStreamingMode = this.storage.getItem('watchStreamingMode');
|
||||
|
||||
showTranscodeArgs = showTranscodeArgs
|
||||
|| transcodeEnabledValues.includes(hubStreamingMode)
|
||||
|| transcodeEnabledValues.includes(watchStreamingMode);
|
||||
|
||||
if (this.interfaces.includes(ScryptedInterface.VideoCameraConfiguration)) {
|
||||
const choices = [
|
||||
'Disabled',
|
||||
'Transcode',
|
||||
'Adaptive Bitrate',
|
||||
];
|
||||
|
||||
if (hubStreamingMode === 'true')
|
||||
hubStreamingMode = 'Transcode';
|
||||
let adaptiveBitrate: string[] = [];
|
||||
try {
|
||||
adaptiveBitrate = JSON.parse(this.storage.getItem('adaptiveBitrate'));
|
||||
}
|
||||
catch (e) {
|
||||
}
|
||||
|
||||
settings.push({
|
||||
title: 'Transcode Remote Streaming',
|
||||
group: 'HomeKit Transcoding',
|
||||
key: 'hubStreamingMode',
|
||||
value: hubStreamingMode || 'Disabled',
|
||||
choices,
|
||||
description: 'The transcode options to use when remote streaming or streaming to limited capabilitity devices like Apple Watch. "Transcode" will use FFmpeg to stream a format supported by HomeKit. "Adaptive Bitrate" adjusts the bitrate of the native camera stream on demand to accomodate available bandwidth. Adaptive Bitrate should be used on secondary streams (sub streams), and not the main stream connected to an NVR, as it will reduce the recording quality.',
|
||||
});
|
||||
|
||||
|
||||
if (watchStreamingMode === 'true')
|
||||
watchStreamingMode = 'Transcode';
|
||||
|
||||
settings.push({
|
||||
title: 'Transcode Apple Watch Streaming',
|
||||
group: 'HomeKit Transcoding',
|
||||
key: 'watchStreamingMode',
|
||||
value: watchStreamingMode || 'Disabled',
|
||||
choices,
|
||||
description: 'The transcode options to use when streaming to Apple Watch. "Transcode" will use FFmpeg to stream a format supported by HomeKit. "Adaptive Bitrate" adjusts the bitrate of the native camera stream on demand to accomodate available bandwidth. Adaptive Bitrate should be used on secondary streams (sub streams), and not the main stream connected to an NVR, as it will reduce the recording quality.',
|
||||
});
|
||||
}
|
||||
else {
|
||||
|
||||
|
||||
settings.push({
|
||||
title: 'Transcode Remote Streaming',
|
||||
group: 'HomeKit Transcoding',
|
||||
type: 'boolean',
|
||||
key: 'hubStreamingMode',
|
||||
value: transcodeEnabledValues.includes(hubStreamingMode).toString(),
|
||||
description: 'Transcode when remote streaming.',
|
||||
});
|
||||
|
||||
settings.push({
|
||||
title: 'Transcode Apple Watch Streaming',
|
||||
group: 'HomeKit Transcoding',
|
||||
type: 'boolean',
|
||||
key: 'watchStreamingMode',
|
||||
value: transcodeEnabledValues.includes(this.storage.getItem('watchStreamingMode')).toString(),
|
||||
description: 'Transcode when streaming to Apple Watch.',
|
||||
});
|
||||
key: 'adaptiveBitrate',
|
||||
title: 'Adaptive Bitrate Streaming',
|
||||
description: 'Adaptive Bitrate adjusts the bitrate of the native camera stream on demand to accomodate available bandwidth. If the camera\'s primary stream is being recorded by an NVR, Adaptive Bitrate should be used on a secondary stream (sub stream), as it will reduce the recording quality.',
|
||||
choices: [
|
||||
'Local Stream',
|
||||
'Remote Stream',
|
||||
'Apple Watch',
|
||||
],
|
||||
multiple: true,
|
||||
value: adaptiveBitrate,
|
||||
})
|
||||
}
|
||||
|
||||
if (showTranscodeArgs) {
|
||||
const decoderArgs = getH264DecoderArgs();
|
||||
const encoderArgs = getH264EncoderArgs();
|
||||
|
||||
settings.push({
|
||||
title: 'Video Decoder Arguments',
|
||||
group: 'HomeKit Transcoding',
|
||||
key: "videoDecoderArguments",
|
||||
value: this.storage.getItem('videoDecoderArguments'),
|
||||
description: 'FFmpeg arguments used to decode input video.',
|
||||
placeholder: '-hwaccel auto',
|
||||
choices: Object.keys(decoderArgs),
|
||||
combobox: true,
|
||||
});
|
||||
settings.push({
|
||||
title: 'H264 Encoder Arguments',
|
||||
group: 'HomeKit Transcoding',
|
||||
key: "h264EncoderArguments",
|
||||
value: this.storage.getItem('h264EncoderArguments'),
|
||||
description: 'FFmpeg arguments used to encode h264 video.',
|
||||
placeholder: '-vcodec h264_omx',
|
||||
choices: Object.keys(encoderArgs),
|
||||
combobox: true,
|
||||
});
|
||||
}
|
||||
settings.push({
|
||||
title: 'Transcoding Debug Mode',
|
||||
key: 'transcodingDebugMode',
|
||||
description: 'Force transcoding on this camera for streaming and recording. This setting can be used to diagnose errors with HomeKit functionality. Enable the Rebroadcast plugin for more robust transcoding options.',
|
||||
type: 'boolean',
|
||||
value: (this.storage.getItem('transcodingDebugMode') === 'true').toString(),
|
||||
});
|
||||
|
||||
if (this.interfaces.includes(ScryptedInterface.AudioSensor)) {
|
||||
settings.push({
|
||||
@@ -249,28 +137,6 @@ export class CameraMixin extends HomekitMixin<any> {
|
||||
return super.putMixinSetting(key, value);
|
||||
}
|
||||
|
||||
if (key === 'videoDecoderArguments') {
|
||||
const decoderArgs = getH264DecoderArgs();
|
||||
value = decoderArgs[value.toString()]?.join(' ') || value;
|
||||
}
|
||||
|
||||
if (key === 'h264EncoderArguments') {
|
||||
const encoderArgs = getH264EncoderArgs();
|
||||
const args = encoderArgs[value.toString()];
|
||||
if (args) {
|
||||
// if default args were specified (ie, videotoolbox, quicksync, etc),
|
||||
// expand that into args that include bitrate and rescale.
|
||||
const extraEncoderArgs = [
|
||||
'-b:v', '${request.video.max_bit_rate * 2}k',
|
||||
'-vf', 'scale=${request.video.width}:${request.video.height}',
|
||||
'-r', '${request.video.fps}',
|
||||
];
|
||||
args.push(...extraEncoderArgs);
|
||||
}
|
||||
const substitute = args?.join(' ');
|
||||
value = substitute ? `\`${substitute}\`` : value;
|
||||
}
|
||||
|
||||
if (key === 'objectDetectionContactSensors') {
|
||||
this.storage.setItem(key, JSON.stringify(value));
|
||||
}
|
||||
|
||||
@@ -9,20 +9,21 @@ export const HOMEKIT_MIXIN = 'mixin:@scrypted/homekit';
|
||||
export class HomekitMixin<T> extends SettingsMixinDeviceBase<T> {
|
||||
storageSettings = new StorageSettings(this, {
|
||||
resetAccessory: {
|
||||
group: 'HomeKit Pairing',
|
||||
title: 'Reset Accessory',
|
||||
description: 'Bridged devices will automatically relink as a new device. Accessory devices must be manually removed from the Home app and re-paired.',
|
||||
type: 'button',
|
||||
// generate a new reset accessory random value.
|
||||
onPut: () => {
|
||||
// also reset the username/mac for standalone accessories
|
||||
this.storage.removeItem('mac');
|
||||
this.alertReload();
|
||||
title: 'Reset Pairing',
|
||||
description: 'Resetting the pairing will resync it to HomeKit as a new device. Bridged devices will automatically relink as a new device. Accessory devices must be manually removed from the Home app and re-paired. Enter RESET to reset the pairing.',
|
||||
placeholder: 'RESET',
|
||||
mapPut: (oldValue, newValue) => {
|
||||
if (newValue === 'RESET') {
|
||||
this.storage.removeItem('mac');
|
||||
this.alertReload();
|
||||
// generate a new reset accessory random value.
|
||||
return crypto.randomBytes(8).toString('hex');
|
||||
}
|
||||
throw new Error('HomeKit Accessory Reset cancelled.');
|
||||
},
|
||||
mapPut: () => crypto.randomBytes(8).toString('hex'),
|
||||
mapGet: () => '',
|
||||
},
|
||||
standalone: {
|
||||
group: 'HomeKit Pairing',
|
||||
title: 'Standalone Accessory',
|
||||
description: 'Experimental: Advertise this to HomeKit as a standalone accessory rather than through the Scrypted HomeKit bridge. Enabling this option will remove it from the bridge, and the accessory will then need to be re-paired to HomeKit.'
|
||||
+ (this.interfaces.includes(ScryptedInterface.VideoCamera)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
|
||||
import { getDebugModeH264EncoderArgs } from "@scrypted/common/src/ffmpeg-hardware-acceleration";
|
||||
import { FFmpegFragmentedMP4Session, parseFragmentedMP4, startFFMPegFragmentedMP4Session } from '@scrypted/common/src/ffmpeg-mp4-parser-session';
|
||||
import { safeKillFFmpeg } from '@scrypted/common/src/media-helpers';
|
||||
import sdk, { AudioSensor, FFmpegInput, MotionSensor, ScryptedDevice, ScryptedInterface, ScryptedMimeTypes, VideoCamera } from '@scrypted/sdk';
|
||||
@@ -9,7 +10,7 @@ import { Duplex, Writable } from 'stream';
|
||||
import { HomeKitSession } from '../../common';
|
||||
import { AudioRecordingCodecType, AudioRecordingSamplerateValues, CameraRecordingConfiguration } from '../../hap';
|
||||
import { getCameraRecordingFiles, HksvVideoClip, VIDEO_CLIPS_NATIVE_ID } from './camera-recording-files';
|
||||
import { evalRequest } from './camera-transcode';
|
||||
import { checkCompatibleCodec, transcodingDebugModeWarning } from './camera-utils';
|
||||
|
||||
const { log, mediaManager, deviceManager } = sdk;
|
||||
|
||||
@@ -43,10 +44,15 @@ export async function* handleFragmentsRequests(device: ScryptedDevice & VideoCam
|
||||
|
||||
const noAudio = ffmpegInput.mediaStreamOptions && ffmpegInput.mediaStreamOptions.audio === null;
|
||||
const audioCodec = ffmpegInput.mediaStreamOptions?.audio?.codec;
|
||||
const videoCodec = ffmpegInput.mediaStreamOptions?.video?.codec;
|
||||
const isDefinitelyNotAAC = !audioCodec || audioCodec.toLowerCase().indexOf('aac') === -1;
|
||||
const transcodeRecording = storage.getItem('transcodeRecording') === 'true';
|
||||
const transcodingDebugMode = storage.getItem('transcodingDebugMode') === 'true';
|
||||
const transcodeRecording = !!ffmpegInput.h264EncoderArguments?.length;
|
||||
const incompatibleStream = noAudio || transcodeRecording || isDefinitelyNotAAC;
|
||||
|
||||
if (transcodingDebugMode)
|
||||
transcodingDebugModeWarning();
|
||||
|
||||
let session: FFmpegFragmentedMP4Session & { socket?: Duplex };
|
||||
|
||||
if (ffmpegInput.container === 'mp4' && ffmpegInput.url.startsWith('tcp://') && !incompatibleStream) {
|
||||
@@ -70,12 +76,9 @@ export async function* handleFragmentsRequests(device: ScryptedDevice & VideoCam
|
||||
}
|
||||
}
|
||||
|
||||
if (transcodeRecording) {
|
||||
// decoder arguments
|
||||
const videoDecoderArguments = storage.getItem('videoDecoderArguments') || '';
|
||||
if (videoDecoderArguments) {
|
||||
inputArguments.push(...evalRequest(videoDecoderArguments, request));
|
||||
}
|
||||
// decoder arguments
|
||||
if (transcodeRecording && ffmpegInput.videoDecoderArguments?.length) {
|
||||
inputArguments.push(...ffmpegInput.videoDecoderArguments);
|
||||
}
|
||||
|
||||
inputArguments.push(...ffmpegInput.inputArguments)
|
||||
@@ -89,8 +92,8 @@ export async function* handleFragmentsRequests(device: ScryptedDevice & VideoCam
|
||||
}
|
||||
|
||||
let audioArgs: string[];
|
||||
if (noAudio || transcodeRecording || isDefinitelyNotAAC) {
|
||||
if (!(noAudio || transcodeRecording))
|
||||
if (noAudio || transcodeRecording || isDefinitelyNotAAC || transcodingDebugMode) {
|
||||
if (!(noAudio || transcodeRecording || transcodingDebugMode))
|
||||
console.warn('Recording audio is not explicitly AAC, forcing transcoding. Setting audio output to AAC is recommended.', audioCodec);
|
||||
|
||||
let aacLowEncoder = 'aac';
|
||||
@@ -120,32 +123,24 @@ export async function* handleFragmentsRequests(device: ScryptedDevice & VideoCam
|
||||
}
|
||||
|
||||
let videoArgs: string[];
|
||||
if (transcodeRecording) {
|
||||
const h264EncoderArguments = storage.getItem('h264EncoderArguments') || '';
|
||||
videoArgs = h264EncoderArguments
|
||||
? evalRequest(h264EncoderArguments, request) : [
|
||||
"-vcodec", "libx264",
|
||||
// '-preset', 'ultrafast', '-tune', 'zerolatency',
|
||||
'-pix_fmt', 'yuvj420p',
|
||||
// '-color_range', 'mpeg',
|
||||
"-bf", "0",
|
||||
// "-profile:v", profileToFfmpeg(request.video.profile),
|
||||
// '-level:v', levelToFfmpeg(request.video.level),
|
||||
'-b:v', `${configuration.videoCodec.bitrate}k`,
|
||||
"-bufsize", (2 * request.video.max_bit_rate).toString() + "k",
|
||||
"-maxrate", request.video.max_bit_rate.toString() + "k",
|
||||
"-filter:v", `fps=${request.video.fps},scale=w=${configuration.videoCodec.resolution[0]}:h=${configuration.videoCodec.resolution[1]}:force_original_aspect_ratio=1,pad=${configuration.videoCodec.resolution[0]}:${configuration.videoCodec.resolution[1]}:(ow-iw)/2:(oh-ih)/2`,
|
||||
'-force_key_frames', `expr:gte(t,n_forced*${iframeIntervalSeconds})`,
|
||||
];
|
||||
if (transcodingDebugMode) {
|
||||
videoArgs = getDebugModeH264EncoderArgs();
|
||||
}
|
||||
else if (transcodeRecording) {
|
||||
videoArgs = [
|
||||
'-b:v', `${configuration.videoCodec.bitrate}k`,
|
||||
"-bufsize", (2 * request.video.max_bit_rate).toString() + "k",
|
||||
"-maxrate", request.video.max_bit_rate.toString() + "k",
|
||||
"-filter:v", `fps=${request.video.fps},scale=w=${configuration.videoCodec.resolution[0]}:h=${configuration.videoCodec.resolution[1]}:force_original_aspect_ratio=1,pad=${configuration.videoCodec.resolution[0]}:${configuration.videoCodec.resolution[1]}:(ow-iw)/2:(oh-ih)/2`,
|
||||
'-force_key_frames', `expr:gte(t,n_forced*${iframeIntervalSeconds})`,
|
||||
|
||||
...ffmpegInput.h264EncoderArguments,
|
||||
];
|
||||
}
|
||||
else {
|
||||
checkCompatibleCodec(console, device, videoCodec)
|
||||
videoArgs = [
|
||||
'-vcodec', 'copy',
|
||||
// Ran into an issue where the RTSP source had SPS/PPS in the SDP,
|
||||
// and none in the bitstream. Codec copy will not add SPS/PPS before IDR frames
|
||||
// unless this flag is used.
|
||||
// I believe this is causing issues. I think inly FTYP/MOOV should have SPS/PPS?
|
||||
// "-bsf:v", "dump_extra",
|
||||
];
|
||||
}
|
||||
|
||||
@@ -224,7 +219,6 @@ export async function* handleFragmentsRequests(device: ScryptedDevice & VideoCam
|
||||
saveFragment(i, fragment);
|
||||
pending = [];
|
||||
console.log(`motion fragment #${++i} sent. size:`, fragment.length);
|
||||
// fs.writeFileSync(`/tmp/${device.id}-${i.toString().padStart(2, '0')}.mp4`, fragment);
|
||||
yield fragment;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
import { getDebugModeH264EncoderArgs } from '@scrypted/common/src/ffmpeg-hardware-acceleration';
|
||||
import { createBindZero } from '@scrypted/common/src/listen-cluster';
|
||||
import { ffmpegLogInitialOutput, safePrintFFmpegArguments } from '@scrypted/common/src/media-helpers';
|
||||
import sdk, { FFmpegInput, MediaStreamDestination, RequestMediaStreamOptions, ScryptedDevice, ScryptedMimeTypes, VideoCamera } from '@scrypted/sdk';
|
||||
import sdk, { FFmpegInput, ScryptedDevice, VideoCamera } from '@scrypted/sdk';
|
||||
import child_process from 'child_process';
|
||||
import { RtpPacket } from '../../../../../external/werift/packages/rtp/src/rtp/rtp';
|
||||
import { ProtectionProfileAes128CmHmacSha1_80 } from '../../../../../external/werift/packages/rtp/src/srtp/const';
|
||||
import { AudioStreamingCodecType, SRTPCryptoSuites, StartStreamRequest } from '../../hap';
|
||||
import { evalRequest } from '../camera/camera-transcode';
|
||||
import { CameraStreamingSession, KillCameraStreamingSession } from './camera-streaming-session';
|
||||
import { startCameraStreamSrtp } from './camera-streaming-srtp';
|
||||
import { createCameraStreamSender } from './camera-streaming-srtp-sender';
|
||||
import { checkCompatibleCodec, transcodingDebugModeWarning } from './camera-utils';
|
||||
|
||||
const { mediaManager } = sdk;
|
||||
const { mediaManager, log } = sdk;
|
||||
|
||||
export async function startCameraStreamFfmpeg(device: ScryptedDevice & VideoCamera, console: Console, storage: Storage, videoInput: FFmpegInput, transcodeStreaming: boolean, session: CameraStreamingSession, killSession: KillCameraStreamingSession) {
|
||||
export async function startCameraStreamFfmpeg(device: ScryptedDevice & VideoCamera, console: Console, storage: Storage, ffmpegInput: FFmpegInput, session: CameraStreamingSession, killSession: KillCameraStreamingSession) {
|
||||
const request = session.startRequest;
|
||||
|
||||
const videomtu = session.startRequest.video.mtu;
|
||||
@@ -30,17 +30,16 @@ export async function startCameraStreamFfmpeg(device: ScryptedDevice & VideoCame
|
||||
// any notable benefit with a prebuffer, which allows the ffmpeg analysis for key frame
|
||||
// to immediately finish. ffmpeg will only start sending on a key frame.
|
||||
// const audioInput = JSON.parse((await mediaManager.convertMediaObjectToBuffer(await device.getVideoStream(selectedStream), ScryptedMimeTypes.FFmpegInput)).toString()) as FFmpegInput;
|
||||
const audioInput = videoInput;
|
||||
const audioInput = ffmpegInput;
|
||||
|
||||
const videoKey = Buffer.concat([session.prepareRequest.video.srtp_key, session.prepareRequest.video.srtp_salt]);
|
||||
const audioKey = Buffer.concat([session.prepareRequest.audio.srtp_key, session.prepareRequest.audio.srtp_salt]);
|
||||
|
||||
const mso = videoInput.mediaStreamOptions;
|
||||
const mso = ffmpegInput.mediaStreamOptions;
|
||||
const noAudio = mso?.audio === null;
|
||||
const hideBanner = [
|
||||
'-hide_banner',
|
||||
];
|
||||
const decoderArgs: string[] = [];
|
||||
const videoArgs: string[] = [];
|
||||
const audioArgs: string[] = [];
|
||||
|
||||
@@ -53,36 +52,42 @@ export async function startCameraStreamFfmpeg(device: ScryptedDevice & VideoCame
|
||||
nullAudioInput.push('-f', 'lavfi', '-i', 'anullsrc=cl=1', '-shortest');
|
||||
}
|
||||
|
||||
// decoder args
|
||||
if (transcodeStreaming) {
|
||||
// decoder arguments
|
||||
const videoDecoderArguments = storage.getItem('videoDecoderArguments') || '';
|
||||
if (videoDecoderArguments) {
|
||||
decoderArgs.push(...evalRequest(videoDecoderArguments, request));
|
||||
}
|
||||
}
|
||||
const transcodingDebugMode = storage.getItem('transcodingDebugMode') === 'true';
|
||||
if (transcodingDebugMode)
|
||||
transcodingDebugModeWarning();
|
||||
|
||||
const videoCodec = ffmpegInput.mediaStreamOptions?.video?.codec;
|
||||
const needsFFmpeg = transcodingDebugMode || !!ffmpegInput.h264EncoderArguments?.length || !!ffmpegInput.h264FilterArguments?.length;
|
||||
|
||||
videoArgs.push(
|
||||
"-an", '-sn', '-dn',
|
||||
);
|
||||
|
||||
// encoder args
|
||||
if (transcodeStreaming) {
|
||||
const h264EncoderArguments = storage.getItem('h264EncoderArguments') || '';
|
||||
const videoCodec = h264EncoderArguments
|
||||
? evalRequest(h264EncoderArguments, request) :
|
||||
if (transcodingDebugMode) {
|
||||
const videoCodec =
|
||||
[
|
||||
"-vcodec", "libx264",
|
||||
// '-preset', 'ultrafast', '-tune', 'zerolatency',
|
||||
'-pix_fmt', 'yuvj420p',
|
||||
// '-color_range', 'mpeg',
|
||||
"-bf", "0",
|
||||
// "-profile:v", profileToFfmpeg(request.video.profile),
|
||||
// '-level:v', levelToFfmpeg(request.video.level),
|
||||
"-b:v", request.video.max_bit_rate.toString() + "k",
|
||||
"-bufsize", (2 * request.video.max_bit_rate).toString() + "k",
|
||||
"-maxrate", request.video.max_bit_rate.toString() + "k",
|
||||
"-filter:v", "fps=" + request.video.fps.toString(),
|
||||
|
||||
...getDebugModeH264EncoderArgs(),
|
||||
];
|
||||
|
||||
videoArgs.push(
|
||||
...videoCodec,
|
||||
)
|
||||
}
|
||||
else if (ffmpegInput.h264EncoderArguments?.length) {
|
||||
const videoCodec =
|
||||
[
|
||||
"-b:v", request.video.max_bit_rate.toString() + "k",
|
||||
"-bufsize", (2 * request.video.max_bit_rate).toString() + "k",
|
||||
"-maxrate", request.video.max_bit_rate.toString() + "k",
|
||||
"-filter:v", "fps=" + request.video.fps.toString(),
|
||||
|
||||
...ffmpegInput.h264EncoderArguments,
|
||||
];
|
||||
|
||||
videoArgs.push(
|
||||
@@ -90,19 +95,15 @@ export async function startCameraStreamFfmpeg(device: ScryptedDevice & VideoCame
|
||||
)
|
||||
}
|
||||
else {
|
||||
checkCompatibleCodec(console, device, videoCodec)
|
||||
|
||||
videoArgs.push(
|
||||
"-vcodec", "copy",
|
||||
);
|
||||
}
|
||||
|
||||
// 3/6/2022
|
||||
// Ran into an issue where the RTSP source had SPS/PPS in the SDP,
|
||||
// and none in the bitstream. Codec copy will not add SPS/PPS before IDR frames
|
||||
// unless this flag is used.
|
||||
// 3/7/2022
|
||||
// This flag was enabled by default, but I believe this is causing issues with some users.
|
||||
// Make it a setting.
|
||||
if (storage.getItem('needsExtraData') === 'true')
|
||||
videoArgs.push("-bsf:v", "dump_extra");
|
||||
if (ffmpegInput.h264FilterArguments?.length) {
|
||||
videoArgs.push(...ffmpegInput.h264FilterArguments);
|
||||
}
|
||||
|
||||
let videoOutput = `srtp://${session.prepareRequest.targetAddress}:${session.prepareRequest.video.port}?rtcpport=${session.prepareRequest.video.port}&pkt_size=${videomtu}`;
|
||||
@@ -137,7 +138,6 @@ export async function startCameraStreamFfmpeg(device: ScryptedDevice & VideoCame
|
||||
videoArgs.push(
|
||||
"-payload_type", (request as StartStreamRequest).video.pt.toString(),
|
||||
"-ssrc", session.videossrc.toString(),
|
||||
// '-fflags', '+flush_packets', '-flush_packets', '1',
|
||||
"-f", "rtp",
|
||||
"-srtp_out_suite", session.prepareRequest.video.srtpCryptoSuite === SRTPCryptoSuites.AES_CM_128_HMAC_SHA1_80 ?
|
||||
"AES_CM_128_HMAC_SHA1_80" : "AES_CM_256_HMAC_SHA1_80",
|
||||
@@ -166,37 +166,40 @@ export async function startCameraStreamFfmpeg(device: ScryptedDevice & VideoCame
|
||||
|
||||
let hasAudio = true;
|
||||
|
||||
if (!transcodeStreaming
|
||||
if (!needsFFmpeg
|
||||
&& perfectOpus
|
||||
&& mso?.tool === 'scrypted') {
|
||||
|
||||
await startCameraStreamSrtp(videoInput, console, session, killSession);
|
||||
await startCameraStreamSrtp(ffmpegInput, console, session, killSession);
|
||||
return;
|
||||
|
||||
audioArgs.push(
|
||||
"-acodec", "copy",
|
||||
);
|
||||
}
|
||||
else if (audioCodec === AudioStreamingCodecType.OPUS || audioCodec === AudioStreamingCodecType.AAC_ELD) {
|
||||
// by default opus encodes with a packet time of 20. however, homekit may request another value,
|
||||
// which we will respect by simply outputing frames of that duration, rather than packing
|
||||
// 20 ms frames to accomodate.
|
||||
opusFramesPerPacket = 1;
|
||||
if (!transcodingDebugMode && perfectOpus) {
|
||||
audioArgs.push(
|
||||
"-acodec", "copy",
|
||||
);
|
||||
}
|
||||
else {
|
||||
// by default opus encodes with a packet time of 20. however, homekit may request another value,
|
||||
// which we will respect by simply outputing frames of that duration, rather than packing
|
||||
// 20 ms frames to accomodate.
|
||||
opusFramesPerPacket = 1;
|
||||
|
||||
audioArgs.push(
|
||||
'-acodec', ...(requestedOpus ?
|
||||
[
|
||||
'libopus',
|
||||
'-application', 'lowdelay',
|
||||
'-frame_duration', (request as StartStreamRequest).audio.packet_time.toString(),
|
||||
] :
|
||||
['libfdk_aac', '-profile:a', 'aac_eld']),
|
||||
'-flags', '+global_header',
|
||||
'-ar', `${(request as StartStreamRequest).audio.sample_rate}k`,
|
||||
'-b:a', `${(request as StartStreamRequest).audio.max_bit_rate}k`,
|
||||
"-bufsize", `${(request as StartStreamRequest).audio.max_bit_rate * 4}k`,
|
||||
'-ac', `${(request as StartStreamRequest).audio.channel}`,
|
||||
)
|
||||
audioArgs.push(
|
||||
'-acodec', ...(requestedOpus ?
|
||||
[
|
||||
'libopus',
|
||||
'-application', 'lowdelay',
|
||||
'-frame_duration', (request as StartStreamRequest).audio.packet_time.toString(),
|
||||
] :
|
||||
['libfdk_aac', '-profile:a', 'aac_eld']),
|
||||
'-flags', '+global_header',
|
||||
'-ar', `${(request as StartStreamRequest).audio.sample_rate}k`,
|
||||
'-b:a', `${(request as StartStreamRequest).audio.max_bit_rate}k`,
|
||||
"-bufsize", `${(request as StartStreamRequest).audio.max_bit_rate * 4}k`,
|
||||
'-ac', `${(request as StartStreamRequest).audio.channel}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
else {
|
||||
hasAudio = false;
|
||||
@@ -240,8 +243,6 @@ export async function startCameraStreamFfmpeg(device: ScryptedDevice & VideoCame
|
||||
? "AES_CM_128_HMAC_SHA1_80"
|
||||
: "AES_CM_256_HMAC_SHA1_80",
|
||||
"-srtp_out_params", audioKey.toString('base64'),
|
||||
// not sure this has any effect? testing.
|
||||
// '-fflags', '+flush_packets', '-flush_packets', '1',
|
||||
`srtp://${session.prepareRequest.targetAddress}:${session.prepareRequest.audio.port}?rtcpport=${session.prepareRequest.audio.port}&pkt_size=${audiomtu}`
|
||||
)
|
||||
}
|
||||
@@ -255,14 +256,16 @@ export async function startCameraStreamFfmpeg(device: ScryptedDevice & VideoCame
|
||||
return;
|
||||
}
|
||||
|
||||
if (audioInput !== videoInput) {
|
||||
const videoDecoderArguments = ffmpegInput.videoDecoderArguments || [];
|
||||
|
||||
if (audioInput !== ffmpegInput) {
|
||||
safePrintFFmpegArguments(console, videoArgs);
|
||||
safePrintFFmpegArguments(console, audioArgs);
|
||||
|
||||
const vp = child_process.spawn(ffmpegPath, [
|
||||
...hideBanner,
|
||||
...decoderArgs,
|
||||
...videoInput.inputArguments,
|
||||
...videoDecoderArguments,
|
||||
...ffmpegInput.inputArguments,
|
||||
...videoArgs,
|
||||
]);
|
||||
session.videoProcess = vp;
|
||||
@@ -271,7 +274,6 @@ export async function startCameraStreamFfmpeg(device: ScryptedDevice & VideoCame
|
||||
|
||||
const ap = child_process.spawn(ffmpegPath, [
|
||||
...hideBanner,
|
||||
...decoderArgs,
|
||||
...audioInput.inputArguments,
|
||||
...nullAudioInput,
|
||||
...audioArgs,
|
||||
@@ -283,8 +285,8 @@ export async function startCameraStreamFfmpeg(device: ScryptedDevice & VideoCame
|
||||
else {
|
||||
const args = [
|
||||
...hideBanner,
|
||||
...decoderArgs,
|
||||
...videoInput.inputArguments,
|
||||
...videoDecoderArguments,
|
||||
...ffmpegInput.inputArguments,
|
||||
...nullAudioInput,
|
||||
...videoArgs,
|
||||
...audioArgs,
|
||||
|
||||
@@ -15,7 +15,6 @@ import { createSnapshotHandler } from '../camera/camera-snapshot';
|
||||
import { DynamicBitrateSession } from './camera-dynamic-bitrate';
|
||||
import { startCameraStreamFfmpeg } from './camera-streaming-ffmpeg';
|
||||
import { CameraStreamingSession } from './camera-streaming-session';
|
||||
import { startCameraStreamSrtp } from './camera-streaming-srtp';
|
||||
import { getStreamingConfiguration } from './camera-utils';
|
||||
|
||||
const { mediaManager } = sdk;
|
||||
@@ -184,7 +183,6 @@ export function createCameraStreamingDelegate(device: ScryptedDevice & VideoCame
|
||||
const {
|
||||
destination,
|
||||
dynamicBitrate,
|
||||
transcodeStreaming,
|
||||
isLowBandwidth,
|
||||
isWatch,
|
||||
} = await getStreamingConfiguration(device, storage, request)
|
||||
@@ -193,7 +191,6 @@ export function createCameraStreamingDelegate(device: ScryptedDevice & VideoCame
|
||||
dynamicBitrate,
|
||||
isLowBandwidth,
|
||||
isWatch,
|
||||
transcodeStreaming,
|
||||
destination,
|
||||
});
|
||||
|
||||
@@ -290,7 +287,6 @@ export function createCameraStreamingDelegate(device: ScryptedDevice & VideoCame
|
||||
console,
|
||||
storage,
|
||||
videoInput,
|
||||
transcodeStreaming,
|
||||
session,
|
||||
() => killSession(request.sessionID));
|
||||
}
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
// request is used by the eval, do not remove.
|
||||
export function evalRequest(value: string, request: any) {
|
||||
if (value.startsWith('`'))
|
||||
value = eval(value) as string;
|
||||
return value.split(' ');
|
||||
}
|
||||
@@ -1,5 +1,8 @@
|
||||
import { MediaStreamDestination, ScryptedDevice, ScryptedInterface, VideoCamera } from "@scrypted/sdk";
|
||||
import { H264Level, H264Profile, StartStreamRequest } from '../../hap';
|
||||
import sdk from '@scrypted/sdk';
|
||||
|
||||
const { log } = sdk;
|
||||
|
||||
export function profileToFfmpeg(profile: H264Profile): string {
|
||||
if (profile === H264Profile.HIGH)
|
||||
@@ -46,6 +49,13 @@ export const ntpTime = () => {
|
||||
};
|
||||
|
||||
export async function getStreamingConfiguration(device: ScryptedDevice & VideoCamera, storage: Storage, request: StartStreamRequest) {
|
||||
let adaptiveBitrate: string[] = [];
|
||||
try {
|
||||
adaptiveBitrate = JSON.parse(storage.getItem('adaptiveBitrate'));
|
||||
}
|
||||
catch (e) {
|
||||
}
|
||||
|
||||
// Have only ever seen 20 and 60 sent here. 60 is remote stream and watch.
|
||||
const isLowBandwidth = request.audio.packet_time > 20;
|
||||
|
||||
@@ -63,10 +73,8 @@ export async function getStreamingConfiguration(device: ScryptedDevice & VideoCa
|
||||
|
||||
// watch will/should also be a low bandwidth device.
|
||||
if (isWatch) {
|
||||
const watchStreamingMode = storage.getItem('watchStreamingMode');
|
||||
return {
|
||||
dynamicBitrate: canDynamicBitrate && watchStreamingMode === 'Adaptive Bitrate',
|
||||
transcodeStreaming: watchStreamingMode === 'Transcode',
|
||||
dynamicBitrate: canDynamicBitrate && adaptiveBitrate.includes('Apple Watch'),
|
||||
destination,
|
||||
isWatch,
|
||||
isLowBandwidth,
|
||||
@@ -74,31 +82,49 @@ export async function getStreamingConfiguration(device: ScryptedDevice & VideoCa
|
||||
}
|
||||
|
||||
if (isLowBandwidth) {
|
||||
let hubStreamingMode = storage.getItem('hubStreamingMode');
|
||||
|
||||
// 3/19/2022 migrate setting.
|
||||
if (storage.getItem('transcodeStreamingHub') === 'true') {
|
||||
if (!hubStreamingMode)
|
||||
hubStreamingMode = 'Transcode';
|
||||
storage.removeItem('transcodeStreamingHub');
|
||||
}
|
||||
|
||||
return {
|
||||
dynamicBitrate: canDynamicBitrate && hubStreamingMode === 'Adaptive Bitrate',
|
||||
transcodeStreaming: hubStreamingMode === 'Transcode',
|
||||
dynamicBitrate: canDynamicBitrate && adaptiveBitrate.includes('Remote Stream'),
|
||||
destination,
|
||||
isWatch,
|
||||
isLowBandwidth,
|
||||
}
|
||||
}
|
||||
|
||||
let transcodeStreaming = storage.getItem('transcodeStreaming');
|
||||
|
||||
return {
|
||||
dynamicBitrate: false,
|
||||
transcodeStreaming: transcodeStreaming === 'true',
|
||||
dynamicBitrate: canDynamicBitrate && adaptiveBitrate.includes('Local Stream'),
|
||||
destination,
|
||||
isWatch,
|
||||
isLowBandwidth,
|
||||
}
|
||||
}
|
||||
|
||||
export function transcodingDebugModeWarning() {
|
||||
console.warn('=================================================================================');
|
||||
console.warn('Transcoding Debug Mode is enabled on this camera.');
|
||||
console.warn('This setting is used to diagnose camera issues, and should not be used long term.');
|
||||
console.warn('The HomeKit Readme contains proper camera configuration to avoid transcoding.');
|
||||
console.warn('More robust transcoding options are available within the Rebroadcast Plugin.');
|
||||
console.warn('=================================================================================');
|
||||
}
|
||||
|
||||
export function checkCompatibleCodec(console: Console, device: ScryptedDevice, videoCodec: String) {
|
||||
if (!videoCodec) {
|
||||
console.warn('=============================================================================');
|
||||
console.warn('No video codec reported. This stream may fail. Enable the Rebroadcast Plugin.');
|
||||
console.warn('Stream compatibility can be diagnosed by enabling Transcoding Debug Mode.');
|
||||
console.warn('=============================================================================');
|
||||
return;
|
||||
}
|
||||
const isDefinitelyNotH264 = videoCodec && videoCodec.toLowerCase().indexOf('h264') === -1;
|
||||
if (isDefinitelyNotH264) {
|
||||
const str =
|
||||
console.error('=============================================================================');
|
||||
console.error(`${device.name} video codec must be h264 but is ${videoCodec}.`);
|
||||
console.error('This stream may fail. Read the instructions in the HomeKit Plugin');
|
||||
console.error('to properly configure your camera codec.');
|
||||
console.error('Stream compatibility can be diagnosed by enabling Transcoding Debug Mode.');
|
||||
console.error('=============================================================================');
|
||||
|
||||
log.a(`${device.name} video codec must be h264 but is ${videoCodec}. This stream may fail. Read the instructions in the HomeKit Plugin to properly configure your camera codec.`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,23 @@
|
||||
# Rebroadcast and Prebuffer for Scrypted
|
||||
|
||||
This plugin maintains connections to all connected cameras, and buffers a small amount of recent video for instant replays. This instant replay is used by HomeKit Secure Video, as well as speeding up initial live stream load times.
|
||||
|
||||
## Stream Setup
|
||||
|
||||
The Rebroadcast Plugin will automatically select the best stream depending on the use. For example, a Unifi Camera has 3 available streams: `High`, `Medium`, and `Low`. Rebroadcast will automatically Prebuffer `High` for HomeKit Secure Video, and the stream selection will use the following defaults:
|
||||
|
||||
High: `Local Stream` (HomeKit on LAN) and `Local Recording Stream` (NVR)
|
||||
Medium: `Remote (Medium Resolution) Stream` (HomeKit on cellular) and `Remote Recording Stream` (HomeKit Secure Video)
|
||||
Low: `Low Resolution Stream` for Apple Watch, Video Analysis
|
||||
|
||||
Most cameras have at least 2 streams available and should be set up as follows:
|
||||
|
||||
High: 1080p+ (2000 Kbps)
|
||||
Medium: 720p (500 Kbps)
|
||||
Low (if available): 320p (100 Kbps)
|
||||
|
||||
The `Key Frame (IDR) Interval` should be set to `4` seconds. This setting is usually configured in frames. So if the camera frame rate is `30`, the interval would be `120`. If the camera frame rate is `15` the interval would be `60`. The value can be calculated as `IDR Interval = FPS * 4`.
|
||||
|
||||
## Transcoding
|
||||
|
||||
Some cameras may not allow configuration of the video codec (h264) or IDR Interval. The camera may also only have a single high bitrate stream which will fail to stream when viewing on low bandwidth remote connections. In this case, Transcoding should be enabled for `Remote Stream` and `Remote Recording Stream` to ensure there isn't a bandwidth issue.
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
"singleInstance": true,
|
||||
"type": "API",
|
||||
"interfaces": [
|
||||
"Settings",
|
||||
"MixinProvider",
|
||||
"BufferConverter"
|
||||
]
|
||||
|
||||
@@ -3,7 +3,7 @@ import { AutoenableMixinProvider } from '@scrypted/common/src/autoenable-mixin-p
|
||||
import { startFFMPegFragmentedMP4Session } from '@scrypted/common/src/ffmpeg-mp4-parser-session';
|
||||
import { handleRebroadcasterClient, ParserOptions, ParserSession, setupActivityTimer, startParserSession } from '@scrypted/common/src/ffmpeg-rebroadcast';
|
||||
import { closeQuiet, createBindZero, listenZeroSingleClient } from '@scrypted/common/src/listen-cluster';
|
||||
import { safeKillFFmpeg } from '@scrypted/common/src/media-helpers';
|
||||
import { ffmpegLogInitialOutput, safeKillFFmpeg } from '@scrypted/common/src/media-helpers';
|
||||
import { readLength } from '@scrypted/common/src/read-stream';
|
||||
import { createRtspParser, H264_NAL_TYPE_IDR, findH264NaluType, RtspClient, RtspServer, RTSP_FRAME_MAGIC } from '@scrypted/common/src/rtsp-server';
|
||||
import { addTrackControls, parseSdp } from '@scrypted/common/src/sdp-utils';
|
||||
@@ -18,6 +18,7 @@ import net from 'net';
|
||||
import { Duplex } from 'stream';
|
||||
import { connectRFC4571Parser, RtspChannelCodecMapping, startRFC4571Parser } from './rfc4571';
|
||||
import { createStreamSettings, getPrebufferedStreams } from './stream-settings';
|
||||
import { getH264EncoderArgs, LIBX264_ENCODER_TITLE } from '@scrypted/common/src/ffmpeg-hardware-acceleration';
|
||||
|
||||
const { mediaManager, log, systemManager, deviceManager } = sdk;
|
||||
|
||||
@@ -235,7 +236,7 @@ class PrebufferSession {
|
||||
const elapsed = Date.now() - start;
|
||||
const bitrate = Math.round(total / elapsed * 8);
|
||||
|
||||
const group = this.streamName ? `Rebroadcast: ${this.streamName}` : 'Rebroadcast';
|
||||
const group = this.streamName ? `Stream: ${this.streamName}` : 'Stream';
|
||||
|
||||
settings.push(
|
||||
{
|
||||
@@ -261,7 +262,6 @@ class PrebufferSession {
|
||||
STRING_DEFAULT,
|
||||
'MPEG-TS',
|
||||
'RTSP',
|
||||
// 'RTSP+MP4',
|
||||
],
|
||||
key: this.rebroadcastModeKey,
|
||||
value: this.storage.getItem(this.rebroadcastModeKey) || STRING_DEFAULT,
|
||||
@@ -724,9 +724,8 @@ class PrebufferSession {
|
||||
id: this.streamId,
|
||||
refresh: false,
|
||||
})
|
||||
.then(async (stream) => {
|
||||
.then(async (ffmpegInput) => {
|
||||
const extraInputArguments = this.storage.getItem(this.ffmpegInputArgumentsKey) || DEFAULT_FFMPEG_INPUT_ARGUMENTS;
|
||||
const ffmpegInput = await mediaManager.convertMediaObjectToJSON<FFmpegInput>(stream, ScryptedMimeTypes.FFmpegInput);
|
||||
ffmpegInput.inputArguments.unshift(...extraInputArguments.split(' '));
|
||||
const mp4Session = await startFFMPegFragmentedMP4Session(ffmpegInput.inputArguments, acodec, vcodec, this.console);
|
||||
|
||||
@@ -853,11 +852,7 @@ class PrebufferSession {
|
||||
updateIdr();
|
||||
}
|
||||
else if (chunk.type === 'h264') {
|
||||
// only compute idr every 30 seconds
|
||||
if (Date.now() > prevIdr + 30000)
|
||||
prevIdr = undefined;
|
||||
if ((prevIdr === undefined || this.detectedIdrInterval === undefined)
|
||||
&& findH264NaluType(chunk, H264_NAL_TYPE_IDR)) {
|
||||
if (findH264NaluType(chunk, H264_NAL_TYPE_IDR)) {
|
||||
// only update the rtsp computed idr once a minute.
|
||||
// per packet bitscan is not great.
|
||||
updateIdr();
|
||||
@@ -994,7 +989,7 @@ class PrebufferSession {
|
||||
})
|
||||
}
|
||||
|
||||
async getVideoStream(options?: RequestMediaStreamOptions): Promise<MediaObject> {
|
||||
async getVideoStream(options?: RequestMediaStreamOptions) {
|
||||
if (options?.refresh === false && !this.parserSessionPromise)
|
||||
throw new Error('Stream is currently unavailable and will not be started for this request. RequestMediaStreamOptions.refresh === false');
|
||||
|
||||
@@ -1123,7 +1118,7 @@ class PrebufferSession {
|
||||
mediaStreamOptions,
|
||||
}
|
||||
|
||||
return this.mixin.createMediaObject(ffmpegInput, ScryptedMimeTypes.FFmpegInput);
|
||||
return ffmpegInput;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1133,7 +1128,7 @@ class PrebufferMixin extends SettingsMixinDeviceBase<VideoCamera & VideoCameraCo
|
||||
|
||||
streamSettings = createStreamSettings(this);
|
||||
|
||||
constructor(public plugin: PrebufferProvider, options: SettingsMixinDeviceOptions<VideoCamera & VideoCameraConfiguration>) {
|
||||
constructor(public plugin: RebroadcastPlugin, options: SettingsMixinDeviceOptions<VideoCamera & VideoCameraConfiguration>) {
|
||||
super(options);
|
||||
|
||||
this.delayStart();
|
||||
@@ -1152,13 +1147,16 @@ class PrebufferMixin extends SettingsMixinDeviceBase<VideoCamera & VideoCameraCo
|
||||
await this.ensurePrebufferSessions();
|
||||
|
||||
let id = options?.id;
|
||||
if (options?.destination) {
|
||||
const msos = await this.mixinDevice.getVideoStreamOptions();
|
||||
let result: {
|
||||
stream: ResponseMediaStreamOptions,
|
||||
isDefault: boolean,
|
||||
};
|
||||
let h264EncoderArguments: string[];
|
||||
|
||||
const msos = await this.mixinDevice.getVideoStreamOptions();
|
||||
let result: {
|
||||
stream: ResponseMediaStreamOptions,
|
||||
isDefault: boolean,
|
||||
title: string;
|
||||
};
|
||||
|
||||
if (!options.id) {
|
||||
switch (options.destination) {
|
||||
case 'medium-resolution':
|
||||
case 'remote':
|
||||
@@ -1178,19 +1176,33 @@ class PrebufferMixin extends SettingsMixinDeviceBase<VideoCamera & VideoCameraCo
|
||||
break;
|
||||
}
|
||||
|
||||
if (!result.isDefault || !id) {
|
||||
id = result.stream.id;
|
||||
this.console.log('Selected stream', result.stream.name);
|
||||
}
|
||||
else {
|
||||
this.console.log('Default stream overriden by legacy stream id setting for ', id);
|
||||
id = result.stream.id;
|
||||
this.console.log('Selected stream', result.stream.name);
|
||||
// transcoding video should never happen transparently since it is CPU intensive.
|
||||
// encourage users at every step to configure proper codecs.
|
||||
// for this reason, do not automatically supply h264 encoder arguments
|
||||
// even if h264 is requested, to force a visible failure.
|
||||
if (this.streamSettings.storageSettings.values.transcodeStreams?.includes(result.title)) {
|
||||
h264EncoderArguments = this.plugin.storageSettings.values.h264EncoderArguments?.split(' ');
|
||||
}
|
||||
}
|
||||
|
||||
const session = this.sessions.get(id);
|
||||
if (!session)
|
||||
return this.mixinDevice.getVideoStream(options);
|
||||
return session.getVideoStream(options);
|
||||
|
||||
const ffmpegInput = await session.getVideoStream(options);
|
||||
ffmpegInput.h264EncoderArguments = h264EncoderArguments;
|
||||
|
||||
if (this.streamSettings.storageSettings.values.missingCodecParameters) {
|
||||
ffmpegInput.h264FilterArguments = ffmpegInput.h264FilterArguments || [];
|
||||
ffmpegInput.h264FilterArguments.push("-bsf:v", "dump_extra");
|
||||
}
|
||||
|
||||
ffmpegInput.videoDecoderArguments = this.streamSettings.storageSettings.values.videoDecoderArguments?.split(' ');
|
||||
return mediaManager.createFFmpegMediaObject(ffmpegInput, {
|
||||
sourceId: this.id,
|
||||
});
|
||||
}
|
||||
|
||||
async ensurePrebufferSessions() {
|
||||
@@ -1309,6 +1321,11 @@ class PrebufferMixin extends SettingsMixinDeviceBase<VideoCamera & VideoCameraCo
|
||||
else
|
||||
this.storage.setItem(key, value?.toString());
|
||||
|
||||
// no prebuffer change necessary if the setting is a transcoding hint.
|
||||
if (this.streamSettings.storageSettings.settings[key]?.group === 'Transcoding')
|
||||
return;
|
||||
|
||||
// kill and reinitiate the prebuffers.
|
||||
for (const session of sessions.values()) {
|
||||
session?.parserSessionPromise?.then(session => session.kill());
|
||||
}
|
||||
@@ -1369,12 +1386,26 @@ function millisUntilMidnight() {
|
||||
return (midnight.getTime() - new Date().getTime());
|
||||
}
|
||||
|
||||
class PrebufferProvider extends AutoenableMixinProvider implements MixinProvider, BufferConverter {
|
||||
class RebroadcastPlugin extends AutoenableMixinProvider implements MixinProvider, BufferConverter, Settings {
|
||||
storageSettings = new StorageSettings(this, {
|
||||
rebroadcastPort: {
|
||||
title: 'Rebroadcast Port',
|
||||
description: 'The port of the RTSP server that will rebroadcast your streams.',
|
||||
type: 'number',
|
||||
},
|
||||
remoteStreamingBitrate: {
|
||||
title: 'Remote Streaming Bitrate',
|
||||
type: 'number',
|
||||
defaultValue: 500000,
|
||||
description: 'The bitrate to use when remote streaming. This setting will only be used when transcoding or adaptive bitrate is enabled on a camera.',
|
||||
},
|
||||
h264EncoderArguments: {
|
||||
title: 'H264 Encoder Arguments',
|
||||
description: 'FFmpeg arguments used to encode h264 video. This is not camera specific and is used to setup the hardware accelerated encoder on your Scrypted server. This setting will only be used when transcoding is enabled on a camera.',
|
||||
choices: Object.keys(getH264EncoderArgs()),
|
||||
defaultValue: getH264EncoderArgs()[LIBX264_ENCODER_TITLE].join(' '),
|
||||
combobox: true,
|
||||
mapPut: (oldValue, newValue) => getH264EncoderArgs()[newValue]?.join(' ') || newValue || getH264EncoderArgs()[LIBX264_ENCODER_TITLE]?.join(' '),
|
||||
}
|
||||
});
|
||||
rtspServer: net.Server;
|
||||
@@ -1411,6 +1442,14 @@ class PrebufferProvider extends AutoenableMixinProvider implements MixinProvider
|
||||
this.startRtspServer();
|
||||
}
|
||||
|
||||
getSettings(): Promise<Setting[]> {
|
||||
return this.storageSettings.getSettings();
|
||||
}
|
||||
|
||||
putSetting(key: string, value: SettingValue): Promise<void> {
|
||||
return this.storageSettings.putSetting(key, value);
|
||||
}
|
||||
|
||||
startRtspServer() {
|
||||
closeQuiet(this.rtspServer);
|
||||
|
||||
@@ -1555,4 +1594,4 @@ class PrebufferProvider extends AutoenableMixinProvider implements MixinProvider
|
||||
}
|
||||
}
|
||||
|
||||
export default new PrebufferProvider();
|
||||
export default new RebroadcastPlugin();
|
||||
|
||||
@@ -60,8 +60,8 @@ export async function startRFC4571Parser(console: Console, socket: Readable, sdp
|
||||
height: number;
|
||||
};
|
||||
|
||||
const sprop = parsedSdp.msections.find(msection => msection.codec === 'h264')
|
||||
.fmtp?.[0]?.parameters?.['sprop-parameter-sets'];
|
||||
const sprop = videoSection
|
||||
?.fmtp?.[0]?.parameters?.['sprop-parameter-sets'];
|
||||
const sdpSps = sprop?.split(',')?.[0];
|
||||
// const sdpPps = sprop?.split(',')?.[1];
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { getH264DecoderArgs } from "@scrypted/common/src/ffmpeg-hardware-acceleration";
|
||||
import { StorageSetting, StorageSettings } from "@scrypted/common/src/settings";
|
||||
import { MixinDeviceBase, ResponseMediaStreamOptions, VideoCamera } from "@scrypted/sdk";
|
||||
|
||||
@@ -54,7 +55,7 @@ export function createStreamSettings(device: MixinDeviceBase<VideoCamera>) {
|
||||
const streamTypes = getStreamTypes({
|
||||
defaultStream: {
|
||||
title: 'Local Stream',
|
||||
description: 'The media stream to use when streaming on your local network. This is the default stream. This stream should be prebuffered. Recommended resolution: 1920x1080 to 4K.',
|
||||
description: 'The media stream to use when streaming on your local network. This stream should be prebuffered. Recommended resolution: 1920x1080 to 4K.',
|
||||
hide: true,
|
||||
prefersPrebuffer: true,
|
||||
preferredResolution: 3840 * 2160,
|
||||
@@ -97,9 +98,37 @@ export function createStreamSettings(device: MixinDeviceBase<VideoCamera>) {
|
||||
hide: false,
|
||||
},
|
||||
...streamTypes,
|
||||
transcodeStreams: {
|
||||
group: 'Transcoding',
|
||||
title: 'Transcode Streams',
|
||||
description: 'The media streams to transcode. Transcoding audio and video is not recommended and should only be used when necessary. The Rebroadcast Plugin manages the system-wide Transcode settings and as well as a Readme for optimal configuration.',
|
||||
multiple: true,
|
||||
choices: Object.values(streamTypes).map(st => st.title),
|
||||
},
|
||||
// 3/6/2022
|
||||
// Ran into an issue where the RTSP source had SPS/PPS in the SDP,
|
||||
// and none in the bitstream. Codec copy will not add SPS/PPS before IDR frames
|
||||
// unless this flag is used.
|
||||
// 3/7/2022
|
||||
// This flag was enabled by default, but I believe this is causing issues with some users.
|
||||
// Make it a setting.
|
||||
missingCodecParameters: {
|
||||
group: 'Transcoding',
|
||||
title: 'Add H264 Extra Data',
|
||||
description: 'Some cameras do not include H264 extra data in the stream and this causes live streaming to always fail (but recordings may be working). This is a inexpensive video filter and does not perform a transcode. Enable this setting only as necessary.',
|
||||
type: 'boolean',
|
||||
},
|
||||
videoDecoderArguments: {
|
||||
group: 'Transcoding',
|
||||
title: 'Video Decoder Arguments',
|
||||
description: 'FFmpeg arguments used to decode input video when transcoding a stream.',
|
||||
placeholder: '-hwaccel auto',
|
||||
choices: Object.keys(getH264DecoderArgs()),
|
||||
combobox: true,
|
||||
mapPut: (oldValue, newValue) => getH264DecoderArgs()[newValue]?.join(' ') || newValue,
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
function getDefaultMediaStream(v: StreamStorageSetting, msos: ResponseMediaStreamOptions[]) {
|
||||
const enabledStreams = getPrebufferedStreams(storageSettings, msos);
|
||||
const prebufferPreferenceStreams = v.prefersPrebuffer && enabledStreams?.length > 0 ? enabledStreams : msos;
|
||||
@@ -116,6 +145,7 @@ export function createStreamSettings(device: MixinDeviceBase<VideoCamera>) {
|
||||
stream = getDefaultMediaStream(v, msos);
|
||||
}
|
||||
return {
|
||||
title: streamTypes[key].title,
|
||||
isDefault,
|
||||
stream,
|
||||
};
|
||||
|
||||
@@ -991,6 +991,9 @@ export interface MediaStreamUrl {
|
||||
}
|
||||
export interface FFmpegInput extends MediaStreamUrl {
|
||||
inputArguments?: string[];
|
||||
h264EncoderArguments?: string[];
|
||||
videoDecoderArguments?: string[];
|
||||
h264FilterArguments?: string[];
|
||||
}
|
||||
/**
|
||||
* DeviceManager is the interface used by DeviceProvider to report new devices, device states, and device events to Scrypted.
|
||||
|
||||
@@ -258,9 +258,12 @@ class EventListenerRegister(TypedDict):
|
||||
|
||||
class FFmpegInput(TypedDict):
|
||||
container: str
|
||||
h264EncoderArguments: list[str]
|
||||
h264FilterArguments: list[str]
|
||||
inputArguments: list[str]
|
||||
mediaStreamOptions: ResponseMediaStreamOptions
|
||||
url: str
|
||||
videoDecoderArguments: list[str]
|
||||
pass
|
||||
|
||||
class FanState(TypedDict):
|
||||
|
||||
3
sdk/types/index.d.ts
vendored
3
sdk/types/index.d.ts
vendored
@@ -1061,6 +1061,9 @@ export interface MediaStreamUrl {
|
||||
}
|
||||
export interface FFmpegInput extends MediaStreamUrl {
|
||||
inputArguments?: string[];
|
||||
h264EncoderArguments?: string[];
|
||||
videoDecoderArguments?: string[];
|
||||
h264FilterArguments?: string[];
|
||||
}
|
||||
/**
|
||||
* DeviceManager is the interface used by DeviceProvider to report new devices, device states, and device events to Scrypted.
|
||||
|
||||
@@ -1692,6 +1692,9 @@ export interface MediaStreamUrl {
|
||||
}
|
||||
export interface FFmpegInput extends MediaStreamUrl {
|
||||
inputArguments?: string[];
|
||||
h264EncoderArguments?: string[];
|
||||
videoDecoderArguments?: string[];
|
||||
h264FilterArguments?: string[];
|
||||
}
|
||||
/**
|
||||
* DeviceManager is the interface used by DeviceProvider to report new devices, device states, and device events to Scrypted.
|
||||
|
||||
@@ -258,9 +258,12 @@ class EventListenerRegister(TypedDict):
|
||||
|
||||
class FFmpegInput(TypedDict):
|
||||
container: str
|
||||
h264EncoderArguments: list[str]
|
||||
h264FilterArguments: list[str]
|
||||
inputArguments: list[str]
|
||||
mediaStreamOptions: ResponseMediaStreamOptions
|
||||
url: str
|
||||
videoDecoderArguments: list[str]
|
||||
pass
|
||||
|
||||
class FanState(TypedDict):
|
||||
|
||||
Reference in New Issue
Block a user