diff --git a/src/utils/metadata.js b/src/utils/metadata.js index 0662a3a..eb4b28e 100644 --- a/src/utils/metadata.js +++ b/src/utils/metadata.js @@ -557,7 +557,7 @@ const mergeEgressMetadata = (metadata, base) => { return metadata; }; -const validateProfile = (sources, profile) => { +const validateProfile = (sources, profile, requireVideo = true) => { let validVideo = false; profile = initProfile(profile); @@ -604,14 +604,16 @@ const validateProfile = (sources, profile) => { let complete = true; - if (profile.video.encoder.coder === 'none' || profile.video.source === -1 || profile.video.stream === -1) { - complete = false; + 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) => { +const createInputsOutputs = (sources, profiles, requireVideo = true) => { const source2inputMap = new Map(); let global = []; @@ -620,7 +622,7 @@ const createInputsOutputs = (sources, profiles) => { // For each profile get the source and do the proper mapping for (let profile of profiles) { - const complete = validateProfile(sources, profile); + const complete = validateProfile(sources, profile, requireVideo); if (complete === false) { continue; } @@ -717,37 +719,39 @@ const createInputsOutputs = (sources, profiles) => { return [global, inputs, outputs]; }; -const createOutputStreams = (sources, profiles) => { +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); + const complete = validateProfile(sources, profile, requireVideo); if (complete === false) { continue; } - const source = sources[profile.video.source]; - const stream = source.streams[profile.video.stream]; + 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, - }); + 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; + 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; } - } else { - s.codec = stream.codec; - } - streams.push(s); + streams.push(s); + } if (profile.audio.encoder.coder !== 'none' && profile.audio.source !== -1 && profile.audio.stream !== -1) { const source = sources[profile.audio.source]; diff --git a/src/views/Edit/Wizard/index.js b/src/views/Edit/Wizard/index.js index 5ab577d..5917aab 100644 --- a/src/views/Edit/Wizard/index.js +++ b/src/views/Edit/Wizard/index.js @@ -170,7 +170,7 @@ export default function Wizard(props) { const profiles = data.profiles; const control = data.control; - const [global, inputs, outputs] = M.createInputsOutputs(sources, profiles); + const [global, inputs, outputs] = M.createInputsOutputs(sources, profiles, true); if (inputs.length === 0 || outputs.length === 0) { notify.Dispatch('error', 'save:ingest', i18n._(t`The input profile is not complete. Please define a video and audio source.`)); diff --git a/src/views/Edit/index.js b/src/views/Edit/index.js index 820a710..25ab7ef 100644 --- a/src/views/Edit/index.js +++ b/src/views/Edit/index.js @@ -291,7 +291,7 @@ export default function Edit(props) { const profiles = $data.profiles; const control = $data.control; - const [global, inputs, outputs] = M.createInputsOutputs(sources, profiles); + const [global, inputs, outputs] = M.createInputsOutputs(sources, profiles, true); if (inputs.length === 0 || outputs.length === 0) { notify.Dispatch('error', 'save:ingest', i18n._(t`The input profile is not complete. Please define a video and audio source.`)); diff --git a/src/views/Publication/Add.js b/src/views/Publication/Add.js index 364f901..bd9c4bc 100644 --- a/src/views/Publication/Add.js +++ b/src/views/Publication/Add.js @@ -146,7 +146,7 @@ export default function Add(props) { ...$settings, name: s.name, profiles: profiles, - streams: M.createOutputStreams($sources, profiles), + streams: M.createOutputStreams($sources, profiles, false), }); setTab('general'); @@ -179,17 +179,35 @@ export default function Add(props) { profiles[0][type].encoder = encoder; profiles[0][type].decoder = decoder; + const streams = M.createOutputStreams($sources, profiles, false); + + let outputs = $settings.outputs; + + service = Services.Get($service); + if (service !== null) { + if ('createOutputs' in service) { + const serviceSkills = helper.conflateServiceSkills(service.requires, $skills); + outputs = service.createOutputs($settings.settings, serviceSkills, $metadata, streams); + } + } + setSettings({ ...$settings, profiles: profiles, - streams: M.createOutputStreams($sources, profiles), + streams: streams, + outputs: outputs, }); }; const handleServiceDone = async () => { setSaving(true); - const [global, inputs, outputs] = helper.createInputsOutputs($sources, $settings.profiles, $settings.outputs); + const [global, inputs, outputs] = helper.createInputsOutputs($sources, $settings.profiles, $settings.outputs, false); + if (inputs.length === 0 || outputs.length === 0) { + setSaving(false); + notify.Dispatch('error', 'save:egress:' + $service, i18n._(t`The input profile is not complete. Please define a video and/or audio source.`)); + return; + } const [id, err] = await props.restreamer.CreateEgress(_channelid, $service, global, inputs, outputs, $settings.control); if (err !== null) { diff --git a/src/views/Publication/Edit.js b/src/views/Publication/Edit.js index 1c59011..b866805 100644 --- a/src/views/Publication/Edit.js +++ b/src/views/Publication/Edit.js @@ -166,7 +166,7 @@ export default function Edit(props) { profiles[0].audio = helper.preselectProfile(profiles[0].audio, 'audio', ingest.streams, serviceSkills.codecs.audio, skills); settings.profiles = profiles; - settings.streams = M.createOutputStreams(sources, profiles); + settings.streams = M.createOutputStreams(sources, profiles, false); setSettings(settings); @@ -215,10 +215,19 @@ export default function Edit(props) { profiles[0][type].encoder = encoder; profiles[0][type].decoder = decoder; + const streams = M.createOutputStreams($sources, profiles, false); + + let outputs = $settings.outputs; + + if ('createOutputs' in $service) { + outputs = $service.createOutputs($settings.settings, $serviceSkills, $metadata, streams); + } + setSettings({ ...$settings, profiles: profiles, - streams: M.createOutputStreams($sources, profiles), + streams: streams, + outputs: outputs, }); if (!automatic) { @@ -229,7 +238,12 @@ export default function Edit(props) { const handleServiceDone = async () => { setSaving(true); - const [global, inputs, outputs] = helper.createInputsOutputs($sources, $settings.profiles, $settings.outputs); + const [global, inputs, outputs] = helper.createInputsOutputs($sources, $settings.profiles, $settings.outputs, false); + if (inputs.length === 0 || outputs.length === 0) { + setSaving(false); + notify.Dispatch('error', 'save:egress:' + _service, i18n._(t`The input profile is not complete. Please define a video and audio source.`)); + return; + } const [, err] = await props.restreamer.UpdateEgress(_channelid, id, global, inputs, outputs, $settings.control); if (err !== null) { @@ -410,7 +424,13 @@ export default function Edit(props) { /> - + diff --git a/src/views/Publication/Services/Dummy.js b/src/views/Publication/Services/Dummy.js index ff0a681..5d04c05 100644 --- a/src/views/Publication/Services/Dummy.js +++ b/src/views/Publication/Services/Dummy.js @@ -146,6 +146,38 @@ function init(settings) { return initSettings; } +// createOutput creates the FFmpeg output options based on the settings, +// skills, metadata, and/or streams. It returns an array of arrays of options +// for each output. +// +// This function should be exported, such that it can be called from the +// outside in case e.g. the streams changed (due to changes in the encoding +// settings) and the output options depend on that information. +function createOutputs(settings, skills, metadata, streams) { + settings = init(settings); + const outputs = []; + + if (settings.stream_key.length === 0) { + return outputs; + } + + if (settings.rtmp_primary) { + outputs.push({ + address: 'rtmp://a.rtmp.youtube.com/live2/' + settings.stream_key, + options: ['-codec', 'copy', '-f', 'flv'], + }); + } + + if (settings.rtmp_backup) { + outputs.push({ + address: 'rtmp://b.rtmp.youtube.com/live2?backup=1/' + settings.stream_key, + options: ['-codec', 'copy', '-f', 'flv'], + }); + } + + return outputs; +} + // Service is a React component that implements a service. // // A service receives these props: @@ -223,8 +255,6 @@ function init(settings) { // service. Its state is managed by the parent React component. function Service(props) { const settings = init(props.settings); - const skills = props.skills; - const metadata = props.metadata; const handleChange = (what) => (event) => { const value = event.target.value; @@ -235,36 +265,12 @@ function Service(props) { settings[what] = value; } - const outputs = createOutput(settings); + const outputs = createOutputs(settings, props.skills, props.metadata, props.streams); props.onChange(outputs, settings); }; - const createOutput = (settings) => { - const outputs = []; - - if (settings.stream_key.length === 0) { - return outputs; - } - - if (settings.rtmp_primary) { - outputs.push({ - address: 'rtmp://a.rtmp.youtube.com/live2/' + settings.stream_key, - options: ['-codec', 'copy', '-f', 'flv'], - }); - } - - if (settings.rtmp_backup) { - outputs.push({ - address: 'rtmp://b.rtmp.youtube.com/live2?backup=1/' + settings.stream_key, - options: ['-codec', 'copy', '-f', 'flv'], - }); - } - - return outputs; - }; - - if (skills === null) { + if (props.skills === null) { return null; } @@ -297,4 +303,17 @@ Service.propTypes = { streams: PropTypes.array.isRequired, }; -export { id, name, version, stream_key_link, description, image_copyright, author, category, requires, ServiceIcon as icon, Service as component }; +export { + id, + name, + version, + stream_key_link, + description, + image_copyright, + author, + category, + requires, + ServiceIcon as icon, + Service as component, + createOutputs, +}; diff --git a/src/views/Publication/Services/Icecast.js b/src/views/Publication/Services/Icecast.js index d9d8a49..8d8904f 100644 --- a/src/views/Publication/Services/Icecast.js +++ b/src/views/Publication/Services/Icecast.js @@ -56,7 +56,6 @@ function init(settings, metadata) { protocol: 'icecast://', address: '', options: {}, - profiles: {}, ...settings, }; @@ -75,6 +74,61 @@ function init(settings, metadata) { return initSettings; } +function createOutputs(settings, skills, metadata, streams) { + settings = init(settings, metadata); + let hasVideo = false; + let audioCodec = ''; + + for (let i = 0; i < streams.length; i++) { + if (streams[i].type === 'video') { + hasVideo = true; + } else if (streams[i].type === 'audio') { + audioCodec = streams[i].codec; + } + } + + const options = []; + + for (let key in settings.options) { + if (settings.options[key].length === 0) { + continue; + } + + options.push('-' + key, String(settings.options[key])); + } + + // https://gist.github.com/keiya/c8a5cbd4fe2594ddbb3390d9cf7dcac9#file-readme-md + // https://wiki.xiph.org/Icecast_Server/Streaming_WebM_to_Icecast_with_FFmpeg + + if (hasVideo === true) { + options.push('-f', 'webm', '-cluster_size_limit', '2', '-cluster_time_limit', '5100', '-content_type', 'video/webm'); + } else { + switch (audioCodec) { + case 'aac': + options.push('-content_type', 'audio/aac', '-f', 'adts'); + break; + case 'vorbis': + options.push('-content_type', 'audio/ogg', '-f', 'ogg'); + break; + case 'opus': + options.push('-content_type', 'audio/ogg', '-f', 'opus'); + break; + case 'mp3': + options.push('-content_type', 'audio/mpeg', '-f', 'mp3'); + break; + default: + break; + } + } + + const output = { + address: settings.protocol + settings.address, + options: options, + }; + + return [output]; +} + function Service(props) { const settings = init(props.settings, props.metadata); @@ -91,63 +145,9 @@ function Service(props) { settings[what] = value; } - const output = createOutput(settings); + const outputs = createOutputs(settings, props.skills, props.metadata, props.streams); - props.onChange([output], settings); - }; - - const createOutput = (settings) => { - let hasVideo = false; - let audioCodec = ''; - - for (let i = 0; i < props.streams.length; i++) { - if (props.streams[i].type === 'video') { - hasVideo = true; - } else if (props.streams[i].type === 'audio') { - audioCodec = props.streams[i].codec; - } - } - - const options = []; - - for (let key in settings.options) { - if (settings.options[key].length === 0) { - continue; - } - - options.push('-' + key, String(settings.options[key])); - } - - // https://gist.github.com/keiya/c8a5cbd4fe2594ddbb3390d9cf7dcac9#file-readme-md - // https://wiki.xiph.org/Icecast_Server/Streaming_WebM_to_Icecast_with_FFmpeg - - if (hasVideo === true) { - options.push('-f', 'webm', '-cluster_size_limit', '2', '-cluster_time_limit', '5100', '-content_type', 'video/webm'); - } else { - switch (audioCodec) { - case 'aac': - options.push('-content_type', 'audio/aac', '-f', 'adts'); - break; - case 'vorbis': - options.push('-content_type', 'audio/ogg', '-f', 'ogg'); - break; - case 'opus': - options.push('-content_type', 'audio/ogg', '-f', 'opus'); - break; - case 'mp3': - options.push('-content_type', 'audio/mpeg', '-f', 'mp3'); - break; - default: - break; - } - } - - const output = { - address: settings.protocol + settings.address, - options: options, - }; - - return output; + props.onChange(outputs, settings); }; return ( @@ -259,4 +259,17 @@ Service.defaultProps = { onChange: function (output, settings) {}, }; -export { id, name, version, stream_key_link, description, image_copyright, author, category, requires, ServiceIcon as icon, Service as component }; +export { + id, + name, + version, + stream_key_link, + description, + image_copyright, + author, + category, + requires, + ServiceIcon as icon, + Service as component, + createOutputs, +}; diff --git a/src/views/Publication/helper.js b/src/views/Publication/helper.js index c39f5ae..88e4e4c 100644 --- a/src/views/Publication/helper.js +++ b/src/views/Publication/helper.js @@ -34,8 +34,12 @@ export function createSourcesFromStreams(streams) { * @param {*} outputs service outputs (format options and address) * @returns */ -export function createInputsOutputs(sources, profiles, outputs) { - const [global, inpts, outpts] = M.createInputsOutputs(sources, profiles); +export function createInputsOutputs(sources, profiles, outputs, requireVideo = true) { + const [global, inpts, outpts] = M.createInputsOutputs(sources, profiles, requireVideo); + + if (inpts.length === 0 || outpts.length === 0) { + return [global, [], []]; + } const out = [];