1271 lines
25 KiB
JavaScript
1271 lines
25 KiB
JavaScript
/*
|
|
|
|
Ingest Metadata Layout:
|
|
|
|
data = {
|
|
version: "1.2.0",
|
|
meta: {
|
|
name: 'Livestream 1',
|
|
description: 'Live from earth. Powered by datarhei/restreamer.',
|
|
author: {
|
|
name: '',
|
|
description: '',
|
|
},
|
|
},
|
|
license: '',
|
|
player: {},
|
|
sources: [{
|
|
type: "network",
|
|
settings: {
|
|
mode: 'pull',
|
|
address: 'https://ch-fra-n4.livespotting.com:443/vpu/rm1naghi/85pwd6iv.m3u8',
|
|
udp: false,
|
|
},
|
|
inputs: [{
|
|
address: 'https://ch-fra-n4.livespotting.com:443/vpu/rm1naghi/85pwd6iv.m3u8',
|
|
options: ['-re'],
|
|
}],
|
|
streams: [{
|
|
"url": "https://ch-fra-n4.livespotting.com:443/vpu/rm1naghi/85pwd6iv.m3u8",
|
|
"format": "hls",
|
|
"index": 0,
|
|
"stream": 0,
|
|
"type": "video",
|
|
"codec": "h264",
|
|
"coder": "h264",
|
|
"bitrate_kbps": 0,
|
|
"fps": 0,
|
|
"pix_fmt": "yuvj420p",
|
|
"width": 320,
|
|
"height": 180,
|
|
"sampling_hz": 0,
|
|
"layout": "",
|
|
"channels": 0
|
|
},{
|
|
"url": "https://ch-fra-n4.livespotting.com:443/vpu/rm1naghi/85pwd6iv.m3u8",
|
|
"format": "hls",
|
|
"index": 0,
|
|
"stream": 1,
|
|
"type": "video",
|
|
"codec": "h264",
|
|
"coder": "h264",
|
|
"bitrate_kbps": 0,
|
|
"fps": 0,
|
|
"pix_fmt": "yuvj420p",
|
|
"width": 1280,
|
|
"height": 720,
|
|
"sampling_hz": 0,
|
|
"layout": "",
|
|
"channels": 0
|
|
}],
|
|
},{
|
|
type: "virtualaudio",
|
|
settings: {...},
|
|
inputs: [{
|
|
address: 'anullsrc=r=44100:cl=stereo',
|
|
options: [
|
|
'-f', 'lavfi',
|
|
],
|
|
}],
|
|
streams: [{
|
|
"url": "anullsrc=r=44100:cl=stereo",
|
|
"format": "lavfi",
|
|
"index": 0, <-- this is the index of the "inputs" array
|
|
"stream": 0, <-- this will be used for the -map parameter
|
|
"type": "audio",
|
|
"codec": "pcm_u8",
|
|
"coder": "pcm_u8",
|
|
"bitrate_kbps": 705,
|
|
"sampling_hz": 44100,
|
|
"layout": "stereo",
|
|
"channels": 2
|
|
}]
|
|
}],
|
|
profiles: [{
|
|
audio: {
|
|
source: 1, <-- this is the index of the "sources" array
|
|
stream: 0, <-- this is the index of the "streams" array in the referenced source
|
|
encoder: {
|
|
coder: 'aac',
|
|
codec: 'aac',
|
|
settings: {
|
|
bitrate: '64',
|
|
channels: '2',
|
|
sampling: '44100'
|
|
},
|
|
mapping: {
|
|
global: [],
|
|
local: [
|
|
'-codec:a', 'aac',
|
|
'-b:a', '64k',
|
|
'-bsf:a', 'aac_adtstoasc',
|
|
'-shortest'
|
|
]
|
|
}
|
|
},
|
|
decoder: null,
|
|
filter: {
|
|
graph: 'aresample=osr=44100:ocl=stereo',
|
|
settings: {
|
|
aresample: {
|
|
graph: 'aresample=osr=44100:ocl=stereo',
|
|
settings: {
|
|
channels: 2,
|
|
layout: 'stereo',
|
|
sampling: 44100
|
|
}
|
|
}
|
|
}
|
|
},
|
|
},
|
|
video: {
|
|
source: 0,
|
|
stream: 1,
|
|
encoder: {
|
|
coder: 'copy',
|
|
codec: 'h264',
|
|
settings: {},
|
|
mapping: {
|
|
global: [],
|
|
local: [
|
|
'-codec:v', 'copy',
|
|
]
|
|
}
|
|
},
|
|
decoder: null,
|
|
filter: null,
|
|
},
|
|
"or": {},
|
|
"video": {
|
|
source: 0,
|
|
stream: 1,
|
|
encoder: {
|
|
coder: 'libx264',
|
|
codec: 'h264',
|
|
settings: {
|
|
preset: 'ultrafast',
|
|
bitrate: '4096',
|
|
fps: '25',
|
|
profile: 'auto',
|
|
tune: 'zerolatency',
|
|
},
|
|
mapping: {
|
|
global: [],
|
|
local: [
|
|
'-codec:v', 'libx264',
|
|
'-preset:v', 'ultrafast',
|
|
'-b:v', '4096k',
|
|
'-maxrate', '4096k',
|
|
'-bufsize', '4096k',
|
|
'-r', '25',
|
|
'-g', '50',
|
|
'-pix_fmt', 'yuv420p',
|
|
'-profile:v', 'high',
|
|
'-tune:v', 'zerolatency',
|
|
]
|
|
}
|
|
},
|
|
decoder: {
|
|
coder: 'h264_cuvid',
|
|
settings: {},
|
|
mapping: [
|
|
'-c:v h264_cuvid'
|
|
]
|
|
}
|
|
}
|
|
}],
|
|
streams: [
|
|
{
|
|
index: 0,
|
|
stream: 0,
|
|
type: 'video',
|
|
codec: 'h264',
|
|
width: 1920,
|
|
height: 1080,
|
|
sampling_hz: 0,
|
|
layout: '',
|
|
channels: 0,
|
|
},
|
|
{
|
|
index: 0,
|
|
stream: 1,
|
|
type: 'audio',
|
|
codec: 'aac',
|
|
width: 0,
|
|
height: 0,
|
|
sampling_hz: 44100,
|
|
layout: 'stereo',
|
|
channels: 2,
|
|
}
|
|
],
|
|
control: {
|
|
hls: {
|
|
segmentDuration: 2,
|
|
listSize: 6,
|
|
},
|
|
process: {
|
|
autostart: true,
|
|
reconnect: true,
|
|
delay: 15,
|
|
staleTimeout: 30
|
|
},
|
|
snapshot: {
|
|
enable: true,
|
|
interval: 60,
|
|
},
|
|
},
|
|
};
|
|
|
|
Egress Metadata Layout:
|
|
|
|
data = {
|
|
version: "1.2.0",
|
|
name: "foobar",
|
|
control: {
|
|
process: {
|
|
autostart: true,
|
|
reconnect: true,
|
|
delay: 15,
|
|
staleTimeout: 30
|
|
},
|
|
},
|
|
output: {
|
|
address: "rtmp://...",
|
|
options: [],
|
|
},
|
|
settings: {
|
|
...
|
|
},
|
|
};
|
|
|
|
*/
|
|
|
|
import SemverGt from 'semver/functions/gt';
|
|
import SemverCompare from 'semver/functions/compare';
|
|
|
|
import * as Coders from '../misc/coders/Encoders';
|
|
import * as Filters from '../misc/filters';
|
|
import * as version from '../version';
|
|
|
|
const defaultMetadata = {
|
|
version: version.Version,
|
|
playersite: {},
|
|
bundle: {},
|
|
};
|
|
|
|
const defaultIngestMetadata = {
|
|
version: version.Version,
|
|
sources: [],
|
|
profiles: [{}],
|
|
streams: [],
|
|
control: {
|
|
hls: {
|
|
lhls: false,
|
|
segmentDuration: 2,
|
|
listSize: 6,
|
|
cleanup: true,
|
|
version: 3,
|
|
storage: 'memfs',
|
|
master_playlist: true,
|
|
},
|
|
rtmp: {
|
|
enable: false,
|
|
},
|
|
srt: {
|
|
enable: false,
|
|
},
|
|
process: {
|
|
autostart: true,
|
|
reconnect: true,
|
|
delay: 15,
|
|
staleTimeout: 30,
|
|
low_delay: false,
|
|
},
|
|
snapshot: {
|
|
enable: true,
|
|
interval: 60,
|
|
},
|
|
limits: {
|
|
cpu_usage: 0,
|
|
memory_mbytes: 0,
|
|
waitfor_seconds: 5,
|
|
},
|
|
},
|
|
player: {},
|
|
meta: {
|
|
name: '',
|
|
description: '',
|
|
author: {
|
|
name: '',
|
|
description: '',
|
|
},
|
|
},
|
|
license: 'CC BY 4.0',
|
|
};
|
|
|
|
const defaultEgressMetadata = {
|
|
version: version.Version,
|
|
name: '',
|
|
control: {
|
|
process: {
|
|
autostart: false,
|
|
reconnect: true,
|
|
delay: 15,
|
|
staleTimeout: 30,
|
|
},
|
|
source: {
|
|
source: 'hls+memfs',
|
|
},
|
|
limits: {
|
|
cpu_usage: 0,
|
|
memory_mbytes: 0,
|
|
waitfor_seconds: 5,
|
|
},
|
|
},
|
|
outputs: [],
|
|
settings: {},
|
|
profiles: [{}],
|
|
streams: [],
|
|
};
|
|
|
|
const getDefaultMetadata = () => {
|
|
// poor mans deep copy
|
|
return JSON.parse(JSON.stringify(defaultMetadata));
|
|
};
|
|
|
|
const getDefaultIngestMetadata = () => {
|
|
// poor mans deep copy
|
|
return JSON.parse(JSON.stringify(defaultIngestMetadata));
|
|
};
|
|
|
|
const getDefaultEgressMetadata = () => {
|
|
// poor mans deep copy
|
|
return JSON.parse(JSON.stringify(defaultEgressMetadata));
|
|
};
|
|
|
|
const initMetadata = (initialMetadata) => {
|
|
return mergeMetadata(initialMetadata);
|
|
};
|
|
|
|
const transformers = {};
|
|
|
|
const mergeMetadata = (metadata, base) => {
|
|
if (!metadata) {
|
|
metadata = {};
|
|
}
|
|
|
|
const defaultMetadata = getDefaultMetadata();
|
|
|
|
if (!base) {
|
|
base = getDefaultMetadata();
|
|
}
|
|
|
|
metadata = {
|
|
...base,
|
|
...metadata,
|
|
};
|
|
|
|
metadata.playersite = {
|
|
...base.playersite,
|
|
...metadata.playersite,
|
|
};
|
|
|
|
metadata.bundle = {
|
|
...base.bundle,
|
|
...metadata.bundle,
|
|
};
|
|
|
|
metadata = transformMetadata(metadata, defaultMetadata.version, transformers);
|
|
|
|
return metadata;
|
|
};
|
|
|
|
const initIngestMetadata = (initialMetadata) => {
|
|
return mergeIngestMetadata(initialMetadata);
|
|
};
|
|
|
|
const ingestTransformers = {
|
|
'1.2.0': (metadata) => {
|
|
for (let p = 0; p < metadata.profiles.length; p++) {
|
|
const profile = metadata.profiles[p];
|
|
|
|
if (profile.audio.encoder.coder === 'copy' || profile.audio.encoder.coder === 'none') {
|
|
continue;
|
|
}
|
|
|
|
const settings = profile.audio.encoder.settings;
|
|
|
|
profile.audio.filter = {
|
|
settings: {
|
|
aresample: {
|
|
settings: {
|
|
channels: settings.channels,
|
|
layout: settings.layout,
|
|
sampling: settings.sampling,
|
|
},
|
|
},
|
|
},
|
|
};
|
|
|
|
delete profile.audio.encoder.settings.channels;
|
|
delete profile.audio.encoder.settings.layout;
|
|
delete profile.audio.encoder.settings.sampling;
|
|
|
|
profile.audio.filter.settings.aresample.graph = Filters.Audio.Get('aresample').createGraph(profile.audio.filter.settings.aresample.settings);
|
|
profile.audio.filter.graph = profile.audio.filter.settings.aresample.graph;
|
|
}
|
|
|
|
metadata.version = '1.2.0';
|
|
|
|
return metadata;
|
|
},
|
|
};
|
|
|
|
const mergeIngestMetadata = (metadata, base) => {
|
|
if (!metadata) {
|
|
metadata = {};
|
|
}
|
|
|
|
const defaultMetadata = getDefaultIngestMetadata();
|
|
|
|
if (!base) {
|
|
base = getDefaultIngestMetadata();
|
|
}
|
|
|
|
metadata = {
|
|
...base,
|
|
...metadata,
|
|
};
|
|
|
|
metadata.meta = {
|
|
...base.meta,
|
|
...metadata.meta,
|
|
};
|
|
|
|
metadata.meta.author = {
|
|
...base.meta.author,
|
|
...metadata.meta.author,
|
|
};
|
|
|
|
metadata.player = {
|
|
...base.player,
|
|
...metadata.player,
|
|
};
|
|
|
|
metadata.control = {
|
|
...base.control,
|
|
...metadata.control,
|
|
};
|
|
|
|
metadata.control.hls = {
|
|
...base.control.hls,
|
|
...metadata.control.hls,
|
|
};
|
|
|
|
metadata.control.process = {
|
|
...base.control.process,
|
|
...metadata.control.process,
|
|
};
|
|
|
|
metadata.control.snapshot = {
|
|
...base.control.snapshot,
|
|
...metadata.control.snapshot,
|
|
};
|
|
|
|
if (!Array.isArray(metadata.sources)) {
|
|
metadata.sources = [];
|
|
} else {
|
|
for (let i = 0; i < metadata.sources.length; i++) {
|
|
metadata.sources[i] = initSource(metadata.sources[i].type, metadata.sources[i]);
|
|
}
|
|
}
|
|
|
|
if (!Array.isArray(metadata.profiles)) {
|
|
metadata.profiles = [initProfile({})];
|
|
} else {
|
|
for (let i = 0; i < metadata.profiles.length; i++) {
|
|
metadata.profiles[i] = initProfile(metadata.profiles[i]);
|
|
}
|
|
}
|
|
|
|
if (!Array.isArray(metadata.streams)) {
|
|
metadata.streams = [];
|
|
} else {
|
|
for (let i = 0; i < metadata.streams.length; i++) {
|
|
metadata.streams[i] = initStream(metadata.streams[i]);
|
|
}
|
|
}
|
|
|
|
metadata = transformMetadata(metadata, defaultMetadata.version, ingestTransformers);
|
|
|
|
return metadata;
|
|
};
|
|
|
|
const initEgressMetadata = (initialMetadata) => {
|
|
return mergeEgressMetadata(initialMetadata);
|
|
};
|
|
|
|
const egressTransformers = {};
|
|
|
|
const mergeEgressMetadata = (metadata, base) => {
|
|
if (!metadata) {
|
|
metadata = {};
|
|
}
|
|
|
|
const defaultMetadata = getDefaultEgressMetadata();
|
|
|
|
if (!base) {
|
|
base = getDefaultEgressMetadata();
|
|
}
|
|
|
|
metadata = {
|
|
...base,
|
|
...metadata,
|
|
};
|
|
|
|
metadata.control = {
|
|
...base.control,
|
|
...metadata.control,
|
|
};
|
|
|
|
metadata.control.process = {
|
|
...base.control.process,
|
|
...metadata.control.process,
|
|
};
|
|
|
|
metadata.control.source = {
|
|
...base.control.source,
|
|
...metadata.control.source,
|
|
};
|
|
|
|
if (!Array.isArray(metadata.outputs)) {
|
|
metadata.outputs = [];
|
|
} else {
|
|
for (let i = 0; i < metadata.outputs.length; i++) {
|
|
metadata.outputs[i] = initOutput(metadata.outputs[i]);
|
|
}
|
|
}
|
|
|
|
if (!Array.isArray(metadata.profiles)) {
|
|
metadata.profiles = [initProfile({})];
|
|
} else {
|
|
for (let i = 0; i < metadata.profiles.length; i++) {
|
|
metadata.profiles[i] = initProfile(metadata.profiles[i]);
|
|
}
|
|
}
|
|
|
|
if (!Array.isArray(metadata.streams)) {
|
|
metadata.streams = [];
|
|
} else {
|
|
for (let i = 0; i < metadata.streams.length; i++) {
|
|
metadata.streams[i] = initStream(metadata.streams[i]);
|
|
}
|
|
}
|
|
|
|
metadata = transformMetadata(metadata, defaultMetadata.version, egressTransformers);
|
|
|
|
return metadata;
|
|
};
|
|
|
|
const validateProfile = (sources, profile, requireVideo = true) => {
|
|
let validVideo = false;
|
|
|
|
profile = initProfile(profile);
|
|
|
|
if (profile.video.source !== -1 && profile.video.source < sources.length) {
|
|
const source = sources[profile.video.source];
|
|
|
|
if (profile.video.stream !== -1 && profile.video.stream < source.streams.length) {
|
|
const stream = source.streams[profile.video.stream];
|
|
|
|
if (stream.index < source.inputs.length) {
|
|
if (stream.type === 'video') {
|
|
validVideo = true;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
let validAudio = false;
|
|
|
|
if (profile.audio.source !== -1 && profile.audio.source < sources.length) {
|
|
const source = sources[profile.audio.source];
|
|
|
|
if (profile.audio.stream !== -1 && profile.audio.stream < source.streams.length) {
|
|
const stream = source.streams[profile.audio.stream];
|
|
|
|
if (stream.index < source.inputs.length) {
|
|
if (stream.type === 'audio') {
|
|
validAudio = true;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if (validVideo === false) {
|
|
profile.video.source = -1;
|
|
profile.video.stream = -1;
|
|
}
|
|
|
|
if (validAudio === false) {
|
|
profile.audio.source = -1;
|
|
profile.audio.stream = -1;
|
|
}
|
|
|
|
let complete = true;
|
|
|
|
if (requireVideo === true) {
|
|
if (profile.video.encoder.coder === 'none' || profile.video.source === -1 || profile.video.stream === -1) {
|
|
complete = false;
|
|
}
|
|
}
|
|
|
|
return complete;
|
|
};
|
|
|
|
const createInputsOutputs = (sources, profiles, requireVideo = true) => {
|
|
const source2inputMap = new Map();
|
|
|
|
let global = [];
|
|
const inputs = [];
|
|
const outputs = [];
|
|
|
|
// For each profile get the source and do the proper mapping
|
|
for (let profile of profiles) {
|
|
const complete = validateProfile(sources, profile, requireVideo);
|
|
if (complete === false) {
|
|
continue;
|
|
}
|
|
|
|
let index = -1;
|
|
|
|
global = [...global, ...profile.video.decoder.mapping.global];
|
|
|
|
const source = sources[profile.video.source];
|
|
const stream = source.streams[profile.video.stream];
|
|
const input = source.inputs[stream.index];
|
|
|
|
input.options = [...profile.video.decoder.mapping.local, ...input.options];
|
|
|
|
const id = profile.video.source + ':' + stream.index;
|
|
|
|
// Check if we already use this input. If not, add it to the final inputs and
|
|
// keep track of the mapping index.
|
|
if (source2inputMap.has(id) === false) {
|
|
const i = inputs.push(input);
|
|
source2inputMap.set(id, i - 1);
|
|
}
|
|
|
|
index = source2inputMap.get(id);
|
|
|
|
global = [...global, ...profile.video.encoder.mapping.global];
|
|
|
|
const local = profile.video.encoder.mapping.local.slice();
|
|
|
|
if (profile.video.encoder.coder !== 'copy' && profile.video.filter.graph.length !== 0) {
|
|
// Check if there's already a video filter in the local mapping
|
|
let filterIndex = local.indexOf('-filter:v');
|
|
if (filterIndex !== -1) {
|
|
local[filterIndex + 1] += ',' + profile.video.filter.graph;
|
|
} else {
|
|
local.unshift('-filter:v', profile.video.filter.graph);
|
|
}
|
|
}
|
|
|
|
const options = ['-map', index + ':' + stream.stream, ...local];
|
|
|
|
if (profile.audio.encoder.coder !== 'none' && profile.audio.source !== -1 && profile.audio.stream !== -1) {
|
|
global = [...global, ...profile.audio.decoder.mapping.global];
|
|
|
|
const source = sources[profile.audio.source];
|
|
const stream = source.streams[profile.audio.stream];
|
|
const input = source.inputs[stream.index];
|
|
|
|
input.options = [...profile.audio.decoder.mapping.local, ...input.options];
|
|
|
|
const id = profile.audio.source + ':' + stream.index;
|
|
|
|
if (source2inputMap.has(id) === false) {
|
|
const i = inputs.push(input);
|
|
source2inputMap.set(id, i - 1);
|
|
}
|
|
|
|
index = source2inputMap.get(id);
|
|
|
|
global = [...global, ...profile.audio.encoder.mapping.global];
|
|
|
|
const local = profile.audio.encoder.mapping.local.slice();
|
|
|
|
if (profile.audio.encoder.coder !== 'copy' && profile.audio.filter.graph.length !== 0) {
|
|
// Check if there's already a audio filter in the local mapping
|
|
let filterIndex = local.indexOf('-filter:a');
|
|
if (filterIndex !== -1) {
|
|
local[filterIndex + 1] += ',' + profile.audio.filter.graph;
|
|
} else {
|
|
local.unshift('-filter:a', profile.audio.filter.graph);
|
|
}
|
|
}
|
|
|
|
options.push('-map', index + ':' + stream.stream, ...local);
|
|
} else {
|
|
options.push('-an');
|
|
}
|
|
|
|
outputs.push({
|
|
address: '',
|
|
options: options,
|
|
});
|
|
}
|
|
|
|
// https://stackoverflow.com/questions/9229645/remove-duplicate-values-from-js-array
|
|
const uniqBy = (a, key) => {
|
|
return [...new Map(a.map((x) => [key(x), x])).values()];
|
|
};
|
|
|
|
// global is an array of arrays. Here we remove duplicates and flatten it.
|
|
global = uniqBy(global, (x) => JSON.stringify(x.sort()));
|
|
global = global.reduce((acc, val) => acc.concat(val), []);
|
|
|
|
return [global, inputs, outputs];
|
|
};
|
|
|
|
const createOutputStreams = (sources, profiles, requireVideo = true) => {
|
|
const streams = [];
|
|
|
|
// Generate a list of output streams from the profiles
|
|
for (let profile of profiles) {
|
|
const complete = validateProfile(sources, profile, requireVideo);
|
|
if (complete === false) {
|
|
continue;
|
|
}
|
|
|
|
if (profile.video.encoder.coder !== 'none' && profile.video.source !== -1 && profile.video.stream !== -1) {
|
|
const source = sources[profile.video.source];
|
|
const stream = source.streams[profile.video.stream];
|
|
|
|
const s = initStream({
|
|
index: 0,
|
|
stream: streams.length,
|
|
type: stream.type,
|
|
width: stream.width,
|
|
height: stream.height,
|
|
});
|
|
|
|
if (profile.video.encoder.coder !== 'copy') {
|
|
const encoder = Coders.Video.Get(profile.video.encoder.coder);
|
|
if (encoder) {
|
|
s.codec = encoder.codec;
|
|
}
|
|
} else {
|
|
s.codec = stream.codec;
|
|
}
|
|
|
|
streams.push(s);
|
|
}
|
|
|
|
if (profile.audio.encoder.coder !== 'none' && profile.audio.source !== -1 && profile.audio.stream !== -1) {
|
|
const source = sources[profile.audio.source];
|
|
const stream = source.streams[profile.audio.stream];
|
|
|
|
const s = initStream({
|
|
index: 0,
|
|
stream: streams.length,
|
|
type: stream.type,
|
|
sampling_hz: stream.sampling_hz,
|
|
layout: stream.layout,
|
|
channels: stream.channels,
|
|
});
|
|
|
|
if (profile.audio.encoder.coder !== 'copy') {
|
|
const encoder = Coders.Audio.Get(profile.audio.encoder.coder);
|
|
if (encoder) {
|
|
s.codec = encoder.codec;
|
|
}
|
|
} else {
|
|
s.codec = stream.codec;
|
|
}
|
|
|
|
streams.push(s);
|
|
}
|
|
}
|
|
|
|
return streams;
|
|
};
|
|
|
|
const initSource = (type, initialSource) => {
|
|
if (!initialSource) {
|
|
initialSource = {};
|
|
}
|
|
|
|
let source = {
|
|
type: '',
|
|
settings: {},
|
|
inputs: [],
|
|
streams: [],
|
|
};
|
|
|
|
source = {
|
|
...source,
|
|
...initialSource,
|
|
};
|
|
|
|
return source;
|
|
};
|
|
|
|
const initProfile = (initialProfile) => {
|
|
if (!initialProfile) {
|
|
initialProfile = {};
|
|
}
|
|
|
|
const profile = {
|
|
video: {},
|
|
audio: {},
|
|
...initialProfile,
|
|
};
|
|
|
|
profile.video = {
|
|
source: -1,
|
|
stream: -1,
|
|
encoder: {},
|
|
decoder: {},
|
|
filter: {},
|
|
...profile.video,
|
|
};
|
|
|
|
profile.video.encoder = {
|
|
coder: 'none',
|
|
settings: {},
|
|
mapping: {},
|
|
...profile.video.encoder,
|
|
};
|
|
|
|
// mapping used to be an array for input/output specific options
|
|
if (Array.isArray(profile.video.encoder.mapping)) {
|
|
profile.video.encoder.mapping = {
|
|
global: [],
|
|
local: profile.video.encoder.mapping,
|
|
};
|
|
} else {
|
|
profile.video.encoder.mapping = {
|
|
global: [],
|
|
local: [],
|
|
...profile.video.encoder.mapping,
|
|
};
|
|
}
|
|
|
|
profile.video.decoder = {
|
|
coder: 'default',
|
|
settings: {},
|
|
mapping: {},
|
|
...profile.video.decoder,
|
|
};
|
|
|
|
if (Array.isArray(profile.video.decoder.mapping)) {
|
|
profile.video.decoder.mapping = {
|
|
global: [],
|
|
local: profile.video.decoder.mapping,
|
|
};
|
|
} else {
|
|
profile.video.decoder.mapping = {
|
|
global: [],
|
|
local: [],
|
|
...profile.video.decoder.mapping,
|
|
};
|
|
}
|
|
|
|
profile.video.filter = {
|
|
graph: '',
|
|
settings: {},
|
|
...profile.video.filter,
|
|
};
|
|
|
|
profile.audio = {
|
|
source: -1,
|
|
stream: -1,
|
|
encoder: {},
|
|
decoder: {},
|
|
filter: {},
|
|
...profile.audio,
|
|
};
|
|
|
|
profile.audio.encoder = {
|
|
coder: 'none',
|
|
settings: {},
|
|
mapping: {},
|
|
...profile.audio.encoder,
|
|
};
|
|
|
|
if (Array.isArray(profile.audio.encoder.mapping)) {
|
|
profile.audio.encoder.mapping = {
|
|
global: [],
|
|
local: profile.audio.encoder.mapping,
|
|
};
|
|
} else {
|
|
profile.audio.encoder.mapping = {
|
|
global: [],
|
|
local: [],
|
|
...profile.audio.encoder.mapping,
|
|
};
|
|
}
|
|
|
|
profile.audio.decoder = {
|
|
coder: 'default',
|
|
settings: {},
|
|
mapping: {},
|
|
...profile.audio.decoder,
|
|
};
|
|
|
|
if (Array.isArray(profile.audio.decoder.mapping)) {
|
|
profile.audio.decoder.mapping = {
|
|
global: [],
|
|
local: profile.audio.decoder.mapping,
|
|
};
|
|
} else {
|
|
profile.audio.decoder.mapping = {
|
|
global: [],
|
|
local: [],
|
|
...profile.audio.decoder.mapping,
|
|
};
|
|
}
|
|
|
|
profile.audio.filter = {
|
|
graph: '',
|
|
settings: {},
|
|
...profile.audio.filter,
|
|
};
|
|
|
|
profile.custom = {
|
|
selected: profile.audio.source === 1,
|
|
stream: profile.audio.source === 1 ? -2 : profile.audio.stream,
|
|
...profile.custom,
|
|
};
|
|
|
|
return profile;
|
|
};
|
|
|
|
const initStream = (initialStream) => {
|
|
if (!initialStream) {
|
|
initialStream = {};
|
|
}
|
|
|
|
const stream = {
|
|
url: '',
|
|
index: 0,
|
|
stream: 0,
|
|
type: '',
|
|
codec: '',
|
|
width: 0,
|
|
height: 0,
|
|
pix_fmt: '',
|
|
sampling_hz: 0,
|
|
layout: '',
|
|
channels: 0,
|
|
...initialStream,
|
|
};
|
|
|
|
return stream;
|
|
};
|
|
|
|
const initOutput = (initialOutput) => {
|
|
if (!initialOutput) {
|
|
initialOutput = {};
|
|
}
|
|
|
|
const output = {
|
|
address: '',
|
|
options: [],
|
|
...initialOutput,
|
|
};
|
|
|
|
return output;
|
|
};
|
|
|
|
const analyzeStreams = (type, streams) => {
|
|
let video = null;
|
|
let audio = null;
|
|
|
|
for (let stream of streams) {
|
|
if (stream.type === 'video') {
|
|
if (video === null) {
|
|
video = stream;
|
|
continue;
|
|
}
|
|
} else if (stream.type === 'audio') {
|
|
if (audio === null) {
|
|
audio = stream;
|
|
continue;
|
|
}
|
|
}
|
|
|
|
if (video !== null && audio !== null) {
|
|
break;
|
|
}
|
|
}
|
|
|
|
let status = 'success';
|
|
|
|
if (video === null && audio === null) {
|
|
status = 'error';
|
|
} else if (type === 'video' && video === null) {
|
|
status = 'nostream';
|
|
} else if (type === 'audio' && audio === null) {
|
|
status = 'nostream';
|
|
}
|
|
|
|
return status;
|
|
};
|
|
|
|
/**
|
|
* Preselect a profile based on the available streams and encoders.
|
|
*
|
|
* @param {*} type Either 'video' or 'audio'
|
|
* @param {*} streams Array of streams
|
|
* @param {*} profile A profile
|
|
* @param {*} encoders Array of supported (by ffmpeg) encoders
|
|
* @param {*} preselectAudio Whether to preselect an audio profile if type == video
|
|
* @returns A profile
|
|
*/
|
|
const preselectProfile = (type, streams, profile, encoders, preselectAudio = true) => {
|
|
const preselectAudioProfile = (streams, audio) => {
|
|
audio.stream = -1;
|
|
audio.encoder.coder = 'none';
|
|
|
|
for (let i = 0; i < streams.length; i++) {
|
|
if (streams[i].type !== 'audio') {
|
|
continue;
|
|
}
|
|
|
|
audio.stream = i;
|
|
|
|
if (streams[i].codec === 'aac' || streams[i].codec === 'mp3') {
|
|
audio.encoder.coder = 'copy';
|
|
} else {
|
|
let coder = Coders.Audio.GetCoderForCodec('aac', encoders.audio);
|
|
if (coder === null) {
|
|
coder = Coders.Audio.GetCoderForCodec('mp3', encoders.audio);
|
|
if (coder === null) {
|
|
audio.encoder.coder = 'none';
|
|
} else {
|
|
audio.encoder.coder = coder.coder;
|
|
}
|
|
} else {
|
|
audio.encoder.coder = coder.coder;
|
|
}
|
|
}
|
|
|
|
break;
|
|
}
|
|
|
|
return audio;
|
|
};
|
|
|
|
const isVideoPlausible = (streams, video) => {
|
|
if (video.stream < 0) {
|
|
return false;
|
|
}
|
|
|
|
if (video.stream >= streams.length) {
|
|
return false;
|
|
}
|
|
|
|
if (streams[video.stream].type !== 'video') {
|
|
return false;
|
|
}
|
|
|
|
if (streams[video.stream].codec !== 'h264') {
|
|
if (video.encoder.coder === 'copy') {
|
|
return false;
|
|
}
|
|
} else {
|
|
if (video.encoder.coder === 'copy') {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
const coder = Coders.Video.Get(video.encoder.coder);
|
|
if (coder === null) {
|
|
return false;
|
|
}
|
|
|
|
if (coder.codec !== 'h264') {
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
};
|
|
|
|
const isAudioPlausible = (streams, audio) => {
|
|
if (audio.stream < 0) {
|
|
return false;
|
|
}
|
|
|
|
if (audio.stream >= streams.length) {
|
|
return false;
|
|
}
|
|
|
|
if (streams[audio.stream].type !== 'audio') {
|
|
return false;
|
|
}
|
|
|
|
if (streams[audio.stream].codec !== 'aac' && streams[audio.stream].codec !== 'mp3') {
|
|
if (audio.encoder.coder === 'copy') {
|
|
return false;
|
|
}
|
|
} else {
|
|
if (audio.encoder.coder === 'copy') {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
const coder = Coders.Audio.Get(audio.encoder.coder);
|
|
if (coder === null) {
|
|
return false;
|
|
}
|
|
|
|
if (coder.codec !== 'aac' && coder.codec !== 'mp3') {
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
};
|
|
|
|
if (type === 'video') {
|
|
if (isVideoPlausible(streams, profile.video) === false) {
|
|
const video = profile.video;
|
|
|
|
video.stream = -1;
|
|
video.encoder.coder = 'none';
|
|
|
|
for (let i = 0; i < streams.length; i++) {
|
|
if (streams[i].type !== 'video') {
|
|
continue;
|
|
}
|
|
|
|
video.source = 0;
|
|
video.stream = i;
|
|
|
|
if (streams[i].codec === 'h264') {
|
|
video.encoder.coder = 'copy';
|
|
} else {
|
|
let coder = Coders.Video.GetCoderForCodec('h264', encoders.video);
|
|
if (coder === null) {
|
|
video.encoder.coder = 'none';
|
|
} else {
|
|
video.encoder.coder = coder.coder;
|
|
}
|
|
}
|
|
|
|
break;
|
|
}
|
|
|
|
profile.video = video;
|
|
}
|
|
|
|
// Only select audio stream if explicitely asked to.
|
|
if (preselectAudio === true) {
|
|
if (isAudioPlausible(streams, profile.audio) === false) {
|
|
profile.audio = preselectAudioProfile(streams, profile.audio);
|
|
|
|
if (profile.audio.stream >= 0) {
|
|
profile.audio.source = 0;
|
|
|
|
profile.custom.selected = false;
|
|
profile.custom.stream = profile.audio.stream;
|
|
} else {
|
|
profile.custom.selected = false;
|
|
profile.custom.stream = -1;
|
|
}
|
|
}
|
|
}
|
|
} else if (type === 'audio') {
|
|
if (isAudioPlausible(streams, profile.audio) === false) {
|
|
profile.audio = preselectAudioProfile(streams, profile.audio);
|
|
}
|
|
|
|
profile.audio.source = 1;
|
|
}
|
|
|
|
return profile;
|
|
};
|
|
|
|
const cleanupSources = (sources) => {
|
|
return [sources.video, sources.audio];
|
|
};
|
|
|
|
const cleanupProfile = (profile) => {
|
|
profile.video.source = 0;
|
|
profile.audio.source = 0;
|
|
|
|
if (profile.custom.selected === true) {
|
|
profile.audio.source = 1;
|
|
}
|
|
|
|
if (profile.video.stream === -1) {
|
|
profile.video.source = -1;
|
|
}
|
|
|
|
if (profile.audio.stream === -1) {
|
|
profile.audio.source = -1;
|
|
}
|
|
|
|
return {
|
|
audio: profile.audio,
|
|
video: profile.video,
|
|
custom: profile.custom,
|
|
};
|
|
};
|
|
|
|
const transformMetadata = (metadata, targetVersion, transformers) => {
|
|
if (metadata.version === 1) {
|
|
metadata.version = '1.0.0';
|
|
}
|
|
|
|
if (targetVersion === 1) {
|
|
targetVersion = '1.0.0';
|
|
}
|
|
|
|
if (metadata.version === targetVersion) {
|
|
return metadata;
|
|
}
|
|
|
|
// Create a list of all transformers that are greater than the current version
|
|
// and sort them in ascending order.
|
|
const tlist = [];
|
|
|
|
for (let v in transformers) {
|
|
if (SemverGt(v, metadata.version)) {
|
|
tlist.push(v);
|
|
}
|
|
}
|
|
|
|
tlist.sort(SemverCompare);
|
|
|
|
// Apply all found transformers
|
|
for (let t of tlist) {
|
|
metadata = transformers[t](metadata);
|
|
}
|
|
|
|
return metadata;
|
|
};
|
|
|
|
export {
|
|
getDefaultMetadata,
|
|
getDefaultIngestMetadata,
|
|
getDefaultEgressMetadata,
|
|
initMetadata,
|
|
initIngestMetadata,
|
|
initEgressMetadata,
|
|
mergeMetadata,
|
|
mergeIngestMetadata,
|
|
mergeEgressMetadata,
|
|
validateProfile,
|
|
createInputsOutputs,
|
|
createOutputStreams,
|
|
initSource,
|
|
initProfile,
|
|
analyzeStreams,
|
|
preselectProfile,
|
|
cleanupProfile,
|
|
cleanupSources,
|
|
};
|