diff --git a/CHANGELOG.md b/CHANGELOG.md index 7fc074b..c7e476d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ #### v1.1.0 > v1.2.0 +- Add video rotation filter ([#347](https://github.com/datarhei/restreamer/discussions/347)) +- Add video h/v flip filter +- Add audio volume filter ([#313](https://github.com/datarhei/restreamer/issues/313)) +- Add audio loudness normalization filter - Add HLS Master playlist (requires FFmpeg hlsbitrate.patch) (thx Dwaynarang, Electra Player compatibility) - Add linkedIn & Azure Media Services to publication services (thx kalashnikov) - Add AirPlay support with silvermine videojs plugin @@ -14,6 +18,7 @@ - Add Polish translations (thx Robert Rykała) - Mod extends the datarhei Core publication service with srt streaming - Mod Allow decoders and encoders to set global options +- Fix player problem with different stream formats (9:16) - Mod Allow trailing slash on Core address - Fix process report naming - Fix publication service icon styles diff --git a/public/_playersite/index.html b/public/_playersite/index.html index b0afefd..6a9649e 100644 --- a/public/_playersite/index.html +++ b/public/_playersite/index.html @@ -379,7 +379,7 @@
{{#ifEquals player "videojs"}} - + {{else}}
{{/ifEquals}} diff --git a/src/misc/EncodingSelect.js b/src/misc/EncodingSelect.js index e15be0c..0ef6838 100644 --- a/src/misc/EncodingSelect.js +++ b/src/misc/EncodingSelect.js @@ -239,5 +239,5 @@ EncodingSelect.defaultProps = { codecs: [], availableEncoders: [], availableDecoders: [], - onChange: function (encoder, decoder) {}, + onChange: function (encoder, decoder, automatic) {}, }; diff --git a/src/misc/FilterSelect.js b/src/misc/FilterSelect.js new file mode 100644 index 0000000..516a40e --- /dev/null +++ b/src/misc/FilterSelect.js @@ -0,0 +1,134 @@ +import React from 'react'; + +import { Trans } from '@lingui/macro'; +import Grid from '@mui/material/Grid'; +import Typography from '@mui/material/Typography'; + +// Import all filters (audio/video) +import * as Filters from './filters'; + +// Import all encoders (audio/video) +import * as Encoders from './coders/Encoders'; + +export default function FilterSelect(props) { + const profile = props.profile; + + // handleFilterChange + // what: Filter name + // settings (component settings): {Key: Value} + // mapping (FFmpeg -af/-vf args): ['String', ...] + const handleFilterSettingsChange = (what) => (settings, graph, automatic) => { + const filter = profile.filter; + + // Store mapping/settings per component + filter.settings[what] = { + settings: settings, + graph: graph, + }; + + // Get the order of the filters + let filterOrder = []; + if (props.type === 'video') { + filterOrder = Filters.Video.Filters(); + } else { + filterOrder = Filters.Audio.Filters(); + } + + // Create the filter graph in the order as the filters are registered + const graphs = []; + for (let f of filterOrder) { + if (!(f in filter.settings)) { + continue; + } + + if (filter.settings[f].graph.length !== 0) { + graphs.push(filter.settings[f].graph); + } + } + + filter.graph = graphs.join(','); + + props.onChange(filter, automatic); + }; + + // Set filterRegistry by type + let filterRegistry = null; + if (props.type === 'video') { + filterRegistry = Filters.Video; + } else if (props.type === 'audio') { + filterRegistry = Filters.Audio; + } else { + return null; + } + + // Checks the state of hwaccel (gpu encoding) + let encoderRegistry = null; + let hwaccel = false; + if (props.type === 'video') { + encoderRegistry = Encoders.Video; + for (let encoder of encoderRegistry.List()) { + if (encoder.codec === props.profile.encoder.coder && encoder.hwaccel) { + hwaccel = true; + } + } + } + + // Creates filter components + let filterSettings = []; + if (!hwaccel) { + for (let c of filterRegistry.List()) { + // Checks FFmpeg skills (filter is available) + if (props.availableFilters.includes(c.filter)) { + const Settings = c.component; + + if (!(c.filter in profile.filter.settings)) { + profile.filter.settings[c.filter] = c.defaults(); + } else { + profile.filter.settings[c.filter] = { + ...c.defaults(), + ...profile.filter.settings[c.filter], + }; + } + + filterSettings.push( + + ); + } + } + } + + // No suitable filter found + if (filterSettings === null && !hwaccel) { + return ( + + + + No suitable filter found. + + + + ); + + // hwaccel requires further settings + } else if (hwaccel) { + return false; + } + + return ( + + + + Select your filter settings (optional): + + + {filterSettings} + + ); +} + +FilterSelect.defaultProps = { + type: '', + profile: {}, + availableFilters: [], + onChange: function (filter, automatic) {}, +}; diff --git a/src/misc/Player/videojs.js b/src/misc/Player/videojs.js index 7fcedd9..668f142 100644 --- a/src/misc/Player/videojs.js +++ b/src/misc/Player/videojs.js @@ -31,6 +31,7 @@ export default function VideoJS(props) { player.addClass('vjs-internal'); } player.addClass('video-js'); + player.addClass('vjs-16-9'); } else { // you can update player here [update player through props] // const player = playerRef.current; diff --git a/src/misc/coders/Encoders/audio/AAC.js b/src/misc/coders/Encoders/audio/AAC.js index a3eea58..98687d5 100644 --- a/src/misc/coders/Encoders/audio/AAC.js +++ b/src/misc/coders/Encoders/audio/AAC.js @@ -7,9 +7,6 @@ import Audio from '../../settings/Audio'; function init(initialState) { const state = { bitrate: '64', - channels: '2', - layout: 'stereo', - sampling: '44100', ...initialState, }; @@ -17,18 +14,7 @@ function init(initialState) { } function createMapping(settings, stream) { - let sampling = settings.sampling; - let layout = settings.layout; - - if (sampling === 'inherit') { - sampling = stream.sampling_hz; - } - - if (layout === 'inherit') { - layout = stream.layout; - } - - const local = ['-codec:a', 'aac', '-b:a', `${settings.bitrate}k`, '-shortest', '-af', `aresample=osr=${sampling}:ocl=${layout}`]; + const local = ['-codec:a', 'aac', '-b:a', `${settings.bitrate}k`, '-shortest']; if (stream.codec === 'aac') { local.push('-bsf:a', 'aac_adtstoasc'); @@ -64,23 +50,6 @@ function Coder(props) { [what]: value, }; - if (what === 'layout') { - let channels = stream.channels; - - switch (value) { - case 'mono': - channels = 1; - break; - case 'stereo': - channels = 2; - break; - default: - break; - } - - newSettings.channels = channels; - } - handleChange(newSettings); }; @@ -94,12 +63,6 @@ function Coder(props) { - - - - - - ); } @@ -117,7 +80,7 @@ const type = 'audio'; const hwaccel = false; function summarize(settings) { - return `${name}, ${settings.bitrate} kbit/s, ${settings.layout}, ${settings.sampling}Hz`; + return `${name}, ${settings.bitrate} kbit/s`; } function defaults(stream) { diff --git a/src/misc/coders/Encoders/audio/AACAudioToolbox.js b/src/misc/coders/Encoders/audio/AACAudioToolbox.js index f8e0525..63e4c90 100644 --- a/src/misc/coders/Encoders/audio/AACAudioToolbox.js +++ b/src/misc/coders/Encoders/audio/AACAudioToolbox.js @@ -7,9 +7,6 @@ import Audio from '../../settings/Audio'; function init(initialState) { const state = { bitrate: '64', - channels: '2', - layout: 'stereo', - sampling: '44100', ...initialState, }; @@ -17,18 +14,7 @@ function init(initialState) { } function createMapping(settings, stream) { - let sampling = settings.sampling; - let layout = settings.layout; - - if (sampling === 'inherit') { - sampling = stream.sampling_hz; - } - - if (layout === 'inherit') { - layout = stream.layout; - } - - const local = ['-codec:a', 'aac_at', '-b:a', `${settings.bitrate}k`, '-shortest', '-af', `aresample=osr=${sampling}:ocl=${layout}`]; + const local = ['-codec:a', 'aac_at', '-b:a', `${settings.bitrate}k`, '-shortest']; if (stream.codec === 'aac') { local.push('-bsf:a', 'aac_adtstoasc'); @@ -64,23 +50,6 @@ function Coder(props) { [what]: value, }; - if (what === 'layout') { - let channels = stream.channels; - - switch (value) { - case 'mono': - channels = 1; - break; - case 'stereo': - channels = 2; - break; - default: - break; - } - - newSettings.channels = channels; - } - handleChange(newSettings); }; @@ -94,12 +63,6 @@ function Coder(props) { - - - - - - ); } @@ -117,7 +80,7 @@ const type = 'audio'; const hwaccel = true; function summarize(settings) { - return `${name}, ${settings.bitrate} kbit/s, ${settings.layout}, ${settings.sampling}Hz`; + return `${name}, ${settings.bitrate} kbit/s`; } function defaults(stream) { diff --git a/src/misc/coders/Encoders/audio/Libopus.js b/src/misc/coders/Encoders/audio/Libopus.js index bb6d9d9..6ab9b25 100644 --- a/src/misc/coders/Encoders/audio/Libopus.js +++ b/src/misc/coders/Encoders/audio/Libopus.js @@ -7,9 +7,6 @@ import Audio from '../../settings/Audio'; function init(initialState) { const state = { bitrate: '64', - channels: '2', - layout: 'stereo', - sampling: '44100', ...initialState, }; @@ -17,18 +14,7 @@ function init(initialState) { } function createMapping(settings, stream) { - let sampling = settings.sampling; - let layout = settings.layout; - - if (sampling === 'inherit') { - sampling = stream.sampling_hz; - } - - if (layout === 'inherit') { - layout = stream.layout; - } - - const local = ['-codec:a', 'libopus', '-b:a', `${settings.bitrate}k`, '-shortest', '-af', `aresample=osr=${sampling}:ocl=${layout}`]; + const local = ['-codec:a', 'libopus', '-b:a', `${settings.bitrate}k`, '-shortest']; const mapping = { global: [], @@ -60,23 +46,6 @@ function Coder(props) { [what]: value, }; - if (what === 'layout') { - let channels = stream.channels; - - switch (value) { - case 'mono': - channels = 1; - break; - case 'stereo': - channels = 2; - break; - default: - break; - } - - newSettings.channels = channels; - } - handleChange(newSettings); }; @@ -90,12 +59,6 @@ function Coder(props) { - - - - - - ); } @@ -113,7 +76,7 @@ const type = 'audio'; const hwaccel = false; function summarize(settings) { - return `${name}, ${settings.bitrate} kbit/s, ${settings.layout}, ${settings.sampling}Hz`; + return `${name}, ${settings.bitrate} kbit/s`; } function defaults(stream) { diff --git a/src/misc/coders/Encoders/audio/Libvorbis.js b/src/misc/coders/Encoders/audio/Libvorbis.js index 4ceafbc..42e3b46 100644 --- a/src/misc/coders/Encoders/audio/Libvorbis.js +++ b/src/misc/coders/Encoders/audio/Libvorbis.js @@ -7,9 +7,6 @@ import Audio from '../../settings/Audio'; function init(initialState) { const state = { bitrate: '64', - channels: '2', - layout: 'stereo', - sampling: '44100', ...initialState, }; @@ -17,18 +14,7 @@ function init(initialState) { } function createMapping(settings, stream) { - let sampling = settings.sampling; - let layout = settings.layout; - - if (sampling === 'inherit') { - sampling = stream.sampling_hz; - } - - if (layout === 'inherit') { - layout = stream.layout; - } - - const local = ['-codec:a', 'libvorbis', '-b:a', `${settings.bitrate}k`, '-shortest', '-af', `aresample=osr=${sampling}:ocl=${layout}`]; + const local = ['-codec:a', 'libvorbis', '-b:a', `${settings.bitrate}k`, '-shortest']; const mapping = { global: [], @@ -60,23 +46,6 @@ function Coder(props) { [what]: value, }; - if (what === 'layout') { - let channels = stream.channels; - - switch (value) { - case 'mono': - channels = 1; - break; - case 'stereo': - channels = 2; - break; - default: - break; - } - - newSettings.channels = channels; - } - handleChange(newSettings); }; @@ -90,12 +59,6 @@ function Coder(props) { - - - - - - ); } @@ -113,7 +76,7 @@ const type = 'audio'; const hwaccel = false; function summarize(settings) { - return `${name}, ${settings.bitrate} kbit/s, ${settings.layout}, ${settings.sampling}Hz`; + return `${name}, ${settings.bitrate} kbit/s`; } function defaults(stream) { diff --git a/src/misc/coders/Encoders/audio/MP3.js b/src/misc/coders/Encoders/audio/MP3.js index 7a394c7..5543eef 100644 --- a/src/misc/coders/Encoders/audio/MP3.js +++ b/src/misc/coders/Encoders/audio/MP3.js @@ -7,9 +7,6 @@ import Audio from '../../settings/Audio'; function init(initialState) { const state = { bitrate: '64', - channels: '2', - layout: 'stereo', - sampling: '44100', ...initialState, }; @@ -17,19 +14,8 @@ function init(initialState) { } function createMapping(settings, stream) { - let sampling = settings.sampling; - let layout = settings.layout; - - if (sampling === 'inherit') { - sampling = stream.sampling_hz; - } - - if (layout === 'inherit') { - layout = stream.layout; - } - // '-qscale:a', '6' - const local = ['-codec:a', 'libmp3lame', '-b:a', `${settings.bitrate}k`, '-shortest', '-af', `aresample=osr=${sampling}:ocl=${layout}`]; + const local = ['-codec:a', 'libmp3lame', '-b:a', `${settings.bitrate}k`, '-shortest']; const mapping = { global: [], @@ -61,23 +47,6 @@ function Coder(props) { [what]: value, }; - if (what === 'layout') { - let channels = stream.channels; - - switch (value) { - case 'mono': - channels = 1; - break; - case 'stereo': - channels = 2; - break; - default: - break; - } - - newSettings.channels = channels; - } - handleChange(newSettings); }; @@ -91,12 +60,6 @@ function Coder(props) { - - - - - - ); } @@ -114,7 +77,7 @@ const type = 'audio'; const hwaccel = false; function summarize(settings) { - return `${name}, ${settings.bitrate} kbit/s, ${settings.layout}, ${settings.sampling}Hz`; + return `${name}, ${settings.bitrate} kbit/s`; } function defaults(stream) { diff --git a/src/misc/coders/Encoders/audio/Opus.js b/src/misc/coders/Encoders/audio/Opus.js index 17f62ed..863daef 100644 --- a/src/misc/coders/Encoders/audio/Opus.js +++ b/src/misc/coders/Encoders/audio/Opus.js @@ -13,9 +13,6 @@ function init(initialState) { const state = { bitrate: '64', delay: 'auto', - channels: '2', - layout: 'stereo', - sampling: '44100', ...initialState, }; @@ -34,7 +31,7 @@ function createMapping(settings, stream) { layout = stream.layout; } - const local = ['-codec:a', 'opus', '-b:a', `${settings.bitrate}k`, '-vbr', 'on', '-shortest', '-af', `aresample=osr=${sampling}:ocl=${layout}`]; + const local = ['-codec:a', 'opus', '-b:a', `${settings.bitrate}k`, '-vbr', 'on', '-shortest']; if (settings.delay !== 'auto') { local.push('opus_delay', settings.delay); @@ -113,23 +110,6 @@ function Coder(props) { [what]: value, }; - if (what === 'layout') { - let channels = stream.channels; - - switch (value) { - case 'mono': - channels = 1; - break; - case 'stereo': - channels = 2; - break; - default: - break; - } - - newSettings.channels = channels; - } - handleChange(newSettings); }; @@ -146,12 +126,6 @@ function Coder(props) { - - - - - - ); } @@ -169,7 +143,7 @@ const type = 'audio'; const hwaccel = false; function summarize(settings) { - return `${name}, ${settings.bitrate} kbit/s, ${settings.layout}, ${settings.sampling}Hz`; + return `${name}, ${settings.bitrate} kbit/s`; } function defaults(stream) { diff --git a/src/misc/coders/Encoders/audio/Vorbis.js b/src/misc/coders/Encoders/audio/Vorbis.js index c484720..872dd83 100644 --- a/src/misc/coders/Encoders/audio/Vorbis.js +++ b/src/misc/coders/Encoders/audio/Vorbis.js @@ -7,9 +7,6 @@ import Audio from '../../settings/Audio'; function init(initialState) { const state = { bitrate: '64', - channels: '2', - layout: 'stereo', - sampling: '44100', ...initialState, }; @@ -17,18 +14,7 @@ function init(initialState) { } function createMapping(settings, stream) { - let sampling = settings.sampling; - let layout = settings.layout; - - if (sampling === 'inherit') { - sampling = stream.sampling_hz; - } - - if (layout === 'inherit') { - layout = stream.layout; - } - - const local = ['-codec:a', 'vorbis', '-b:a', `${settings.bitrate}k`, '-qscale:a', '3', '-shortest', '-af', `aresample=osr=${sampling}:ocl=${layout}`]; + const local = ['-codec:a', 'vorbis', '-b:a', `${settings.bitrate}k`, '-qscale:a', '3', '-shortest']; const mapping = { global: [], @@ -60,23 +46,6 @@ function Coder(props) { [what]: value, }; - if (what === 'layout') { - let channels = stream.channels; - - switch (value) { - case 'mono': - channels = 1; - break; - case 'stereo': - channels = 2; - break; - default: - break; - } - - newSettings.channels = channels; - } - handleChange(newSettings); }; @@ -90,12 +59,6 @@ function Coder(props) { - - - - - - ); } @@ -113,7 +76,7 @@ const type = 'audio'; const hwaccel = false; function summarize(settings) { - return `${name}, ${settings.bitrate} kbit/s, ${settings.layout}, ${settings.sampling}Hz`; + return `${name}, ${settings.bitrate} kbit/s`; } function defaults(stream) { diff --git a/src/misc/filters/audio/Loudnorm.js b/src/misc/filters/audio/Loudnorm.js new file mode 100644 index 0000000..b808ee7 --- /dev/null +++ b/src/misc/filters/audio/Loudnorm.js @@ -0,0 +1,95 @@ +import React from 'react'; + +import { Trans } from '@lingui/macro'; +import Grid from '@mui/material/Grid'; + +import Checkbox from '../../Checkbox'; + +// Loudnorm Filter +// http://ffmpeg.org/ffmpeg-all.html#loudnorm + +function init(initialState) { + const state = { + enabled: false, + ...initialState, + }; + + return state; +} + +function createGraph(settings) { + settings = init(settings); + + const mapping = []; + + if (settings.enabled) { + mapping.push('loudnorm'); + } + + return mapping.join(','); +} + +function Filter(props) { + const settings = init(props.settings); + + const handleChange = (newSettings) => { + let automatic = false; + if (!newSettings) { + newSettings = settings; + automatic = true; + } + + props.onChange(newSettings, createGraph(newSettings), automatic); + }; + + const update = (what) => (event) => { + const newSettings = { + ...settings, + }; + if (['enabled'].includes(what)) { + newSettings[what] = !settings.enabled; + } else { + newSettings[what] = event.target.value; + } + + handleChange(newSettings); + }; + + React.useEffect(() => { + handleChange(null); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + return ( + + + Loudness Normalization} checked={settings.enabled} onChange={update('enabled')} /> + + + ); +} + +Filter.defaultProps = { + settings: {}, + onChange: function (settings, graph, automatic) {}, +}; + +const filter = 'loudnorm'; +const name = 'Loudness Normalization'; +const type = 'audio'; +const hwaccel = false; + +function summarize(settings) { + return `${name}`; +} + +function defaults() { + const settings = init({}); + + return { + settings: settings, + graph: createGraph(settings), + }; +} + +export { name, filter, type, hwaccel, summarize, defaults, createGraph, Filter as component }; diff --git a/src/misc/filters/audio/Resample.js b/src/misc/filters/audio/Resample.js new file mode 100644 index 0000000..86575d2 --- /dev/null +++ b/src/misc/filters/audio/Resample.js @@ -0,0 +1,226 @@ +import React from 'react'; + +import { useLingui } from '@lingui/react'; +import { Trans, t } from '@lingui/macro'; +import Grid from '@mui/material/Grid'; +import Typography from '@mui/material/Typography'; + +import SelectCustom from '../../../misc/SelectCustom'; + +// Resample Filter +// https://ffmpeg.org/ffmpeg-filters.html#toc-aresample-1 + +function init(initialState) { + const state = { + channels: '2', + layout: 'stereo', + sampling: '44100', + ...initialState, + }; + + return state; +} + +function createGraph(settings) { + settings = init(settings); + + const mapping = []; + + const sampling = settings.sampling; + const layout = settings.layout; + + if (sampling !== 'inherit') { + mapping.push(`osr=${sampling}`); + } + + if (layout !== 'inherit') { + mapping.push(`ocl=${layout}`); + } + + if (mapping.length === 0) { + return ''; + } + + return 'aresample=' + mapping.join(':'); +} + +function Layout(props) { + const { i18n } = useLingui(); + const options = [ + { value: 'mono', label: 'mono' }, + { value: 'stereo', label: 'stereo' }, + ]; + + if (props.allowAuto === true) { + options.unshift({ value: 'auto', label: 'auto' }); + } + + if (props.allowInherit === true) { + options.unshift({ value: 'inherit', label: i18n._(t`Inherit`) }); + } + + if (props.allowCustom === true) { + options.push({ value: 'custom', label: i18n._(t`Custom ...`) }); + } + + return ( + + + + The layout of the audio stream. + + + ); +} + +Layout.defaultProps = { + variant: 'outlined', + allowAuto: false, + allowInherit: false, + allowCustom: false, + label: Layout, + customLabel: Custom layout, + onChange: function () {}, +}; + +function Sampling(props) { + const { i18n } = useLingui(); + const options = [ + { value: '96000', label: '96000 Hz' }, + { value: '88200', label: '88200 Hz' }, + { value: '48000', label: '48000 Hz' }, + { value: '44100', label: '44100 Hz' }, + { value: '22050', label: '22050 Hz' }, + { value: '8000', label: '8000 Hz' }, + ]; + + if (props.allowAuto === true) { + options.unshift({ value: 'auto', label: 'auto' }); + } + + if (props.allowInherit === true) { + options.unshift({ value: 'inherit', label: i18n._(t`Inherit`) }); + } + + if (props.allowCustom === true) { + options.push({ value: 'custom', label: i18n._(t`Custom ...`) }); + } + + return ( + + + + The sample rate of the audio stream. + + + ); +} + +Sampling.defaultProps = { + variant: 'outlined', + allowAuto: false, + allowInherit: false, + allowCustom: false, + label: Sampling, + customLabel: Custom sampling (Hz), + onChange: function () {}, +}; + +function Filter(props) { + const settings = init(props.settings); + + const handleChange = (newSettings) => { + let automatic = false; + if (!newSettings) { + newSettings = settings; + automatic = true; + } + + props.onChange(newSettings, createGraph(newSettings), automatic); + }; + + const update = (what) => (event) => { + const value = event.target.value; + + const newSettings = { + ...settings, + [what]: value, + }; + + if (what === 'layout') { + let channels = 2; + + switch (value) { + case 'mono': + channels = 1; + break; + case 'stereo': + channels = 2; + break; + default: + break; + } + + newSettings.channels = channels; + } + + handleChange(newSettings); + }; + + React.useEffect(() => { + handleChange(null); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + return ( + + + + + + + + + ); +} + +Filter.defaultProps = { + settings: {}, + onChange: function (settings, graph, automatic) {}, +}; + +const filter = 'aresample'; +const name = 'Resample'; +const type = 'audio'; +const hwaccel = false; + +function summarize(settings) { + return `${name} (${settings.layout}, ${settings.sampling}Hz)`; +} + +function defaults() { + const settings = init({}); + + return { + settings: settings, + graph: createGraph(settings), + }; +} + +export { name, filter, type, hwaccel, summarize, defaults, createGraph, Filter as component }; diff --git a/src/misc/filters/audio/Volume.js b/src/misc/filters/audio/Volume.js new file mode 100644 index 0000000..145e44f --- /dev/null +++ b/src/misc/filters/audio/Volume.js @@ -0,0 +1,162 @@ +import React from 'react'; + +import { Trans } from '@lingui/macro'; +import Grid from '@mui/material/Grid'; +import MenuItem from '@mui/material/MenuItem'; +import TextField from '@mui/material/TextField'; + +import Select from '../../Select'; + +// Volume Filter +// http://ffmpeg.org/ffmpeg-all.html#volume + +function init(initialState) { + const state = { + level: 'none', + db: 0, + ...initialState, + }; + + return state; +} + +function createGraph(settings) { + settings = init(settings); + + const mapping = []; + + switch (settings.level) { + case 'none': + break; + case 'custom': + mapping.push(`volume=volume=${settings.db}dB`); + break; + default: + mapping.push(`volume=volume=${parseInt(settings.level) / 100}`); + break; + } + + return mapping.join(','); +} + +function VolumeLevel(props) { + return ( + + ); +} + +VolumeLevel.defaultProps = { + value: '', + onChange: function (event) {}, +}; + +function VolumeDB(props) { + return ( + Decibels (dB)} + type="number" + value={props.value} + disabled={props.disabled} + onChange={props.onChange} + /> + ); +} + +VolumeDB.defaultProps = { + value: '', + disabled: false, + onChange: function (event) {}, +}; + +function Filter(props) { + const settings = init(props.settings); + + const handleChange = (newSettings) => { + let automatic = false; + if (!newSettings) { + newSettings = settings; + automatic = true; + } + + props.onChange(newSettings, createGraph(newSettings), automatic); + }; + + const update = (what) => (event) => { + const newSettings = { + ...settings, + [what]: event.target.value, + }; + + handleChange(newSettings); + }; + + React.useEffect(() => { + handleChange(null); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + return ( + + + + + + + + + ); +} + +Filter.defaultProps = { + settings: {}, + onChange: function (settings, graph, automatic) {}, +}; + +const filter = 'volume'; +const name = 'Volume'; +const type = 'audio'; +const hwaccel = false; + +function summarize(settings) { + let summary = `${name} (`; + + if (settings.level === 'custom') { + summary += `${settings.db}dB`; + } else { + summary += `${settings.level}%`; + } + + summary += ')'; + + return summary; +} + +function defaults() { + const settings = init({}); + + return { + settings: settings, + graph: createGraph(settings), + }; +} + +export { name, filter, type, hwaccel, summarize, defaults, createGraph, Filter as component }; diff --git a/src/misc/filters/index.js b/src/misc/filters/index.js new file mode 100644 index 0000000..eebc322 --- /dev/null +++ b/src/misc/filters/index.js @@ -0,0 +1,57 @@ +// Audio Filter +import * as AResample from './audio/Resample'; +import * as Volume from './audio/Volume'; +import * as Loudnorm from './audio/Loudnorm'; + +// Video Filter +import * as Transpose from './video/Transpose'; +import * as HFlip from './video/HFlip'; +import * as VFlip from './video/VFlip'; + +// Register filters type: audio/video +class Registry { + constructor(type) { + this.type = type; + this.services = new Map(); + } + + Register(service) { + if (service.type !== this.type) { + return; + } + + this.services.set(service.filter, service); + } + + Get(filter) { + const service = this.services.get(filter); + if (service) { + return service; + } + + return null; + } + + Filters() { + return Array.from(this.services.keys()); + } + + List() { + return Array.from(this.services.values()); + } +} + +// Audio Filters +const audioRegistry = new Registry('audio'); +audioRegistry.Register(AResample); +audioRegistry.Register(Volume); +audioRegistry.Register(Loudnorm); + +// Video Filters +const videoRegistry = new Registry('video'); +videoRegistry.Register(Transpose); +videoRegistry.Register(HFlip); +videoRegistry.Register(VFlip); + +// Export registrys for ../SelectFilters.js +export { audioRegistry as Audio, videoRegistry as Video }; diff --git a/src/misc/filters/video/HFlip.js b/src/misc/filters/video/HFlip.js new file mode 100644 index 0000000..813fafb --- /dev/null +++ b/src/misc/filters/video/HFlip.js @@ -0,0 +1,93 @@ +import React from 'react'; + +import { Trans } from '@lingui/macro'; +import Grid from '@mui/material/Grid'; + +import Checkbox from '../../Checkbox'; + +// HFlip Filter +// http://ffmpeg.org/ffmpeg-all.html#hflip + +function init(initialState) { + const state = { + enabled: false, + ...initialState, + }; + + return state; +} + +function createGraph(settings) { + settings = init(settings); + + const mapping = []; + + if (settings.enabled) { + mapping.push('hflip'); + } + + return mapping.join(','); +} + +function Filter(props) { + const settings = init(props.settings); + + const handleChange = (newSettings) => { + let automatic = false; + if (!newSettings) { + newSettings = settings; + automatic = true; + } + + props.onChange(newSettings, createGraph(newSettings), automatic); + }; + + const update = (what) => (event) => { + const newSettings = { + ...settings, + }; + if (['enabled'].includes(what)) { + newSettings[what] = !settings.enabled; + } else { + newSettings[what] = event.target.value; + } + + handleChange(newSettings); + }; + + React.useEffect(() => { + handleChange(null); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + return ( + + Horizontal Flip} checked={settings.enabled} onChange={update('enabled')} /> + + ); +} + +Filter.defaultProps = { + settings: {}, + onChange: function (settings, graph, automatic) {}, +}; + +const filter = 'hflip'; +const name = 'Horizonal Flip'; +const type = 'video'; +const hwaccel = false; + +function summarize(settings) { + return `${name}`; +} + +function defaults() { + const settings = init({}); + + return { + settings: settings, + graph: createGraph(settings), + }; +} + +export { name, filter, type, hwaccel, summarize, defaults, createGraph, Filter as component }; diff --git a/src/misc/filters/video/Transpose.js b/src/misc/filters/video/Transpose.js new file mode 100644 index 0000000..17e5f75 --- /dev/null +++ b/src/misc/filters/video/Transpose.js @@ -0,0 +1,117 @@ +import React from 'react'; + +import { Trans } from '@lingui/macro'; +import Grid from '@mui/material/Grid'; +import MenuItem from '@mui/material/MenuItem'; + +import Select from '../../Select'; + +// Transpose Filter +// http://ffmpeg.org/ffmpeg-all.html#transpose-1 + +function init(initialState) { + const state = { + value: 'none', + ...initialState, + }; + + return state; +} + +function createGraph(settings) { + settings = init(settings); + + const mapping = []; + + switch (settings.value) { + case '90': + mapping.push('transpose=dir=clock:passthrough=none'); + break; + case '180': + mapping.push('transpose=dir=clock:passthrough=none', 'transpose=dir=clock:passthrough=none'); + break; + case '270': + mapping.push('transpose=dir=cclock:passthrough=none'); + break; + default: + break; + } + + return mapping.join(','); +} + +// filter +function Rotate(props) { + return ( + + ); +} + +Rotate.defaultProps = { + value: '', + onChange: function (event) {}, +}; + +function Filter(props) { + const settings = init(props.settings); + + const handleChange = (newSettings) => { + let automatic = false; + if (!newSettings) { + newSettings = settings; + automatic = true; + } + + props.onChange(newSettings, createGraph(newSettings), automatic); + }; + + const update = (what) => (event) => { + const newSettings = { + ...settings, + [what]: event.target.value, + }; + + handleChange(newSettings); + }; + + React.useEffect(() => { + handleChange(null); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + return ( + + + + ); +} + +Filter.defaultProps = { + settings: {}, + onChange: function (settings, mapping) {}, +}; + +const filter = 'transpose'; +const name = 'Transpose'; +const type = 'video'; +const hwaccel = false; + +function summarize(settings) { + return `${name} (${settings.value}° clockwise)`; +} + +function defaults() { + const settings = init({}); + + return { + settings: settings, + graph: createGraph(settings), + }; +} + +export { name, filter, type, hwaccel, summarize, defaults, createGraph, Filter as component }; diff --git a/src/misc/filters/video/VFlip.js b/src/misc/filters/video/VFlip.js new file mode 100644 index 0000000..96fa74b --- /dev/null +++ b/src/misc/filters/video/VFlip.js @@ -0,0 +1,93 @@ +import React from 'react'; + +import { Trans } from '@lingui/macro'; +import Grid from '@mui/material/Grid'; + +import Checkbox from '../../Checkbox'; + +// VFlip Filter +// http://ffmpeg.org/ffmpeg-all.html#vflip + +function init(initialState) { + const state = { + enabled: false, + ...initialState, + }; + + return state; +} + +function createGraph(settings) { + settings = init(settings); + + const mapping = []; + + if (settings.enabled) { + mapping.push('vflip'); + } + + return mapping.join(','); +} + +function Filter(props) { + const settings = init(props.settings); + + const handleChange = (newSettings) => { + let automatic = false; + if (!newSettings) { + newSettings = settings; + automatic = true; + } + + props.onChange(newSettings, createGraph(newSettings), automatic); + }; + + const update = (what) => (event) => { + const newSettings = { + ...settings, + }; + if (['enabled'].includes(what)) { + newSettings[what] = !settings.enabled; + } else { + newSettings[what] = event.target.value; + } + + handleChange(newSettings); + }; + + React.useEffect(() => { + handleChange(null); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + return ( + + Vertical Flip} checked={settings.enabled} onChange={update('enabled')} /> + + ); +} + +Filter.defaultProps = { + settings: {}, + onChange: function (settings, graph, automatic) {}, +}; + +const filter = 'vflip'; +const name = 'Vertical Flip'; +const type = 'video'; +const hwaccel = false; + +function summarize(settings) { + return `${name}`; +} + +function defaults() { + const settings = init({}); + + return { + settings: settings, + graph: createGraph(settings), + }; +} + +export { name, filter, type, hwaccel, summarize, defaults, createGraph, Filter as component }; diff --git a/src/utils/metadata.js b/src/utils/metadata.js index 18cf5b0..bfc218c 100644 --- a/src/utils/metadata.js +++ b/src/utils/metadata.js @@ -3,7 +3,7 @@ Ingest Metadata Layout: data = { - version: 1, + version: "1.2.0", meta: { name: 'Livestream 1', description: 'Live from earth. Powered by datarhei/restreamer.', @@ -99,12 +99,24 @@ data = { '-codec:a', 'aac', '-b:a', '64k', '-bsf:a', 'aac_adtstoasc', - '-shortest', - '-af', 'aresample=osr=44100:ocl=2' + '-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, @@ -124,6 +136,7 @@ data = { } }, decoder: null, + filter: null, }, "or": {}, "video": { @@ -210,7 +223,7 @@ data = { Egress Metadata Layout: data = { - version: 1, + version: "1.2.0", name: "foobar", control: { process: { @@ -231,15 +244,20 @@ data = { */ +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: 1, + version: version.Version, playersite: {}, }; const defaultIngestMetadata = { - version: 1, + version: version.Version, sources: [], profiles: [{}], streams: [], @@ -283,7 +301,7 @@ const defaultIngestMetadata = { }; const defaultEgressMetadata = { - version: 1, + version: version.Version, name: '', control: { process: { @@ -317,6 +335,12 @@ const getDefaultEgressMetadata = () => { return JSON.parse(JSON.stringify(defaultEgressMetadata)); }; +const initMetadata = (initialMetadata) => { + return mergeMetadata(initialMetadata); +}; + +const transformers = {}; + const mergeMetadata = (metadata, base) => { if (!metadata) { metadata = {}; @@ -333,28 +357,57 @@ const mergeMetadata = (metadata, base) => { ...metadata, }; - if (metadata.version !== defaultMetadata.version) { - metadata = { - ...defaultMetadata, - }; - } - metadata.playersite = { ...base.playersite, ...metadata.playersite, }; - return metadata; -}; + metadata = transformMetadata(metadata, defaultMetadata.version, transformers); -const initMetadata = (initialMetadata) => { - return mergeMetadata(initialMetadata); + 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 = {}; @@ -371,12 +424,6 @@ const mergeIngestMetadata = (metadata, base) => { ...metadata, }; - if (metadata.version !== defaultMetadata.version) { - metadata = { - ...defaultMetadata, - }; - } - metadata.meta = { ...base.meta, ...metadata.meta, @@ -436,6 +483,8 @@ const mergeIngestMetadata = (metadata, base) => { } } + metadata = transformMetadata(metadata, defaultMetadata.version, ingestTransformers); + return metadata; }; @@ -443,6 +492,8 @@ const initEgressMetadata = (initialMetadata) => { return mergeEgressMetadata(initialMetadata); }; +const egressTransformers = {}; + const mergeEgressMetadata = (metadata, base) => { if (!metadata) { metadata = {}; @@ -459,12 +510,6 @@ const mergeEgressMetadata = (metadata, base) => { ...metadata, }; - if (metadata.version !== defaultMetadata.version) { - metadata = { - ...defaultMetadata, - }; - } - metadata.control = { ...base.control, ...metadata.control, @@ -504,6 +549,8 @@ const mergeEgressMetadata = (metadata, base) => { } } + metadata = transformMetadata(metadata, defaultMetadata.version, egressTransformers); + return metadata; }; @@ -598,7 +645,19 @@ const createInputsOutputs = (sources, profiles) => { global = [...global, ...profile.video.encoder.mapping.global]; - const options = ['-map', index + ':' + stream.stream, ...profile.video.encoder.mapping.local]; + const local = profile.video.encoder.mapping.local.slice(); + + if (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]; @@ -620,7 +679,19 @@ const createInputsOutputs = (sources, profiles) => { global = [...global, ...profile.audio.encoder.mapping.global]; - options.push('-map', index + ':' + stream.stream, ...profile.audio.encoder.mapping.local); + const local = profile.audio.encoder.mapping.local.slice(); + + if (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'); } @@ -746,6 +817,7 @@ const initProfile = (initialProfile) => { stream: -1, encoder: {}, decoder: {}, + filter: {}, ...profile.video, }; @@ -790,11 +862,18 @@ const initProfile = (initialProfile) => { }; } + profile.video.filter = { + graph: '', + settings: {}, + ...profile.video.filter, + }; + profile.audio = { source: -1, stream: -1, encoder: {}, decoder: {}, + filter: {}, ...profile.audio, }; @@ -838,6 +917,12 @@ const initProfile = (initialProfile) => { }; } + profile.audio.filter = { + graph: '', + settings: {}, + ...profile.audio.filter, + }; + profile.custom = { selected: profile.audio.source === 1, stream: profile.audio.source === 1 ? -2 : profile.audio.stream, @@ -1113,6 +1198,39 @@ const cleanupProfile = (profile) => { }; }; +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, diff --git a/src/utils/restreamer.js b/src/utils/restreamer.js index c09d8d5..a96eb7d 100644 --- a/src/utils/restreamer.js +++ b/src/utils/restreamer.js @@ -502,6 +502,7 @@ class Restreamer { input: [], output: [], }, + filter: [], sources: { network: [], virtualaudio: [], @@ -523,6 +524,7 @@ class Restreamer { formats: {}, protocols: {}, devices: {}, + filter: [], ...val, }; @@ -565,6 +567,10 @@ class Restreamer { skills.decoders.video.push(hwaccel.id); } + for (let filter of val.filter) { + skills.filter.push(filter.id); + } + val.formats = { demuxers: [], muxers: [], diff --git a/src/version.js b/src/version.js index 1dedd3d..adccb4b 100644 --- a/src/version.js +++ b/src/version.js @@ -3,5 +3,6 @@ import { name, version, bundle } from '../package.json'; const Core = '^16.9.0'; const FFmpeg = '^4.1.0 || ^5.0.0'; const UI = bundle ? bundle : name + ' v' + version; +const Version = version; -export { Core, FFmpeg, UI }; +export { Core, FFmpeg, UI, Version }; diff --git a/src/views/Edit/Profile.js b/src/views/Edit/Profile.js index 3a3d756..02f644c 100644 --- a/src/views/Edit/Profile.js +++ b/src/views/Edit/Profile.js @@ -18,6 +18,8 @@ import ProbeModal from '../../misc/modals/Probe'; import SourceSelect from './SourceSelect'; import StreamSelect from './StreamSelect'; +import FilterSelect from '../../misc/FilterSelect'; + export default function Source(props) { const [$sources, setSources] = React.useState({ video: M.initSource('video', props.sources[0]), @@ -212,6 +214,17 @@ export default function Source(props) { }); }; + const handleFilter = (type) => (filter) => { + const profile = $profile[type]; + + profile.filter = filter; + + setProfile({ + ...$profile, + [type]: profile, + }); + }; + const handleDone = () => { const sources = M.cleanupSources($sources); const profile = M.cleanupProfile($profile); @@ -395,6 +408,16 @@ export default function Source(props) { onChange={handleEncoding('video')} /> + {$profile.video.encoder.coder !== 'none' && $profile.video.encoder.coder !== 'copy' && ( + + + + )} )} @@ -457,6 +480,16 @@ export default function Source(props) { onChange={handleEncoding('audio')} /> + {$profile.audio.encoder.coder !== 'none' && $profile.audio.encoder.coder !== 'copy' && ( + + + + )} )} {$profile.custom.selected === true && ( @@ -524,6 +557,16 @@ export default function Source(props) { onChange={handleEncoding('audio')} /> + {$profile.audio.encoder.coder !== 'none' && $profile.audio.encoder.coder !== 'copy' && ( + + + + )} )} diff --git a/src/views/Edit/Summary.js b/src/views/Edit/Summary.js index c9c9fd5..b910421 100644 --- a/src/views/Edit/Summary.js +++ b/src/views/Edit/Summary.js @@ -6,6 +6,7 @@ import Typography from '@mui/material/Typography'; import Grid from '@mui/material/Grid'; import * as Coders from '../../misc/coders/Encoders'; +import * as Filters from '../../misc/filters'; import BoxText from '../../misc/BoxText'; import Sources from './Sources'; @@ -28,6 +29,7 @@ export default function Summary(props) { let name = i18n._(t`No source selected`); let address = ''; let encodingSummary = i18n._(t`None`); + let filterSummary = []; let showEncoding = false; @@ -51,6 +53,30 @@ export default function Summary(props) { if (coder !== null) { encodingSummary = coder.summarize(profile.encoder.settings); } + + if (profile.filter.graph.length !== 0) { + let filters = null; + + if (props.type === 'video') { + filters = Filters.Video; + } else if (props.type === 'audio') { + filters = Filters.Audio; + } + + for (let filter of filters.List()) { + const name = filter.filter; + + if (!(name in profile.filter.settings)) { + continue; + } + + if (profile.filter.settings[name].graph.length === 0) { + continue; + } + + filterSummary.push(filter.summarize(profile.filter.settings[name].settings)); + } + } } return ( @@ -61,12 +87,26 @@ export default function Summary(props) { {address} {showEncoding === true && ( - - - Encoding - - {encodingSummary} - + + + + Encoding + + {encodingSummary} + + + + Filter + + {filterSummary.length ? ( + {filterSummary.join(', ')} + ) : ( + + None + + )} + + )} diff --git a/src/views/Edit/index.js b/src/views/Edit/index.js index 5bae071..aee62bf 100644 --- a/src/views/Edit/index.js +++ b/src/views/Edit/index.js @@ -97,12 +97,12 @@ export default function Edit(props) { setProcess(proc); let metadata = await props.restreamer.GetIngestMetadata(_channelid); - if (metadata.version && metadata.version === 1) { - setData({ - ...$data, - ...metadata, - }); - } + setData({ + ...$data, + ...metadata, + }); + + console.log(metadata); const skills = await props.restreamer.Skills(); setSkills(skills); diff --git a/src/views/Main/index.js b/src/views/Main/index.js index e951d55..68d14ef 100644 --- a/src/views/Main/index.js +++ b/src/views/Main/index.js @@ -101,12 +101,10 @@ export default function Main(props) { setConfig(config); const metadata = await props.restreamer.GetIngestMetadata(_channelid); - if (metadata.version && metadata.version === 1) { - setMetadata({ - ...$metadata, - ...metadata, - }); - } + setMetadata({ + ...$metadata, + ...metadata, + }); await update(); };