diff --git a/CHANGELOG.md b/CHANGELOG.md index 96f161d..c036cd2 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 stream distribution across multiple internal servers - Add SRT settings - Add HLS version selection (Dwaynarang, Electra Player compatibility) @@ -9,6 +13,7 @@ - Add Telegram to publication services (thx Martin Held) - Add Polish translations (thx Robert Rykała) - Mod Allow decoders and encoders to set global options +- Fix player problem with different stream formats (9:16) - Fix process report naming - Fix publication service icon styles - Fix VAAPI encoder diff --git a/public/_playersite/index.html b/public/_playersite/index.html index 77a34cc..c57075d 100644 --- a/public/_playersite/index.html +++ b/public/_playersite/index.html @@ -373,7 +373,7 @@
{{#ifEquals player "videojs"}} - + {{else}}
{{/ifEquals}} diff --git a/src/misc/FilterSelect.js b/src/misc/FilterSelect.js new file mode 100644 index 0000000..0d6ccc8 --- /dev/null +++ b/src/misc/FilterSelect.js @@ -0,0 +1,127 @@ +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, mapping, automatic) => { + const filter = profile.filter; + + // Store mapping/settings per component + filter.settings[what] = { + mapping: mapping, + settings: settings, + }; + + // Combine FFmpeg args + let settings_mapping = []; + for (let i in filter.settings) { + if (filter.settings[i].mapping.length !== 0) { + settings_mapping.push(filter.settings[i].mapping); + } + } + + // Create the real filter mapping + // ['-af/-vf', 'args,args'] + if (settings_mapping.length !== 0) { + if (props.type === 'video') { + filter.mapping = ['-vf', settings_mapping.join(',')]; + } else if (props.type === 'audio') { + filter.mapping = ['-af', settings_mapping.join(',')]; + } + } else { + filter.mapping = []; + } + + props.onChange(profile.filter, 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 i in encoderRegistry.List()) { + if (encoderRegistry.List()[i].codec === props.videoProfile.encoder.coder && encoderRegistry.List()[i].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; + + 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: '', + filters: [], + availableFilters: [], + onChange: function (filter) {}, +}; 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/filters/audio/Loudnorm.js b/src/misc/filters/audio/Loudnorm.js new file mode 100644 index 0000000..a9f32c1 --- /dev/null +++ b/src/misc/filters/audio/Loudnorm.js @@ -0,0 +1,104 @@ +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 = { + value: false, + ...initialState, + }; + + return state; +} + +function createMapping(settings) { + const mapping = []; + + if (settings.value) { + mapping.push('loudnorm'); + } + + console.log(mapping); + + return mapping; +} + +function Loudness(props) { + return Loudness Normalization} checked={props.value} onChange={props.onChange} />; +} + +Loudness.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, createMapping(newSettings), automatic); + }; + + const update = (what) => (event) => { + const newSettings = { + ...settings, + }; + if (['value'].includes(what)) { + newSettings[what] = !settings.value; + } else { + newSettings[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 = 'loudnorm'; +const name = 'Loudness Normalization'; +const type = 'audio'; +const hwaccel = false; + +function summarize(settings) { + return `${name}`; +} + +function defaults() { + const settings = init({}); + + return { + settings: settings, + mapping: createMapping(settings), + }; +} + +export { name, filter, type, hwaccel, summarize, defaults, 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..2b9abbc --- /dev/null +++ b/src/misc/filters/audio/Volume.js @@ -0,0 +1,146 @@ +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: false, + db: 0, + ...initialState, + }; + + return state; +} + +function createMapping(settings) { + const mapping = []; + + if (settings.level) { + if (settings.level !== 'custom') { + mapping.push(`volume=volume=${settings.level}`); + } else { + mapping.push(`volume=volume=${settings.db}dB`); + } + } + + console.log(mapping); + + return mapping; +} + +function VolumeLevel(props) { + return ( + + ); +} + +VolumeLevel.defaultProps = { + value: '', + onChange: function (event) {}, +}; + +function VolumeDB(props) { + console.log(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, createMapping(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 = 'volume'; +const name = 'Volume level'; +const type = 'audio'; +const hwaccel = false; + +function summarize(settings) { + return `${name}`; +} + +function defaults() { + const settings = init({}); + + return { + settings: settings, + mapping: createMapping(settings), + }; +} + +export { name, filter, type, hwaccel, summarize, defaults, Filter as component }; diff --git a/src/misc/filters/index.js b/src/misc/filters/index.js new file mode 100644 index 0000000..10874df --- /dev/null +++ b/src/misc/filters/index.js @@ -0,0 +1,56 @@ +// Audio Filter +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'; + +// Registrate Filters by: +// 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(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..78798d1 --- /dev/null +++ b/src/misc/filters/video/HFlip.js @@ -0,0 +1,102 @@ +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 = { + value: false, + ...initialState, + }; + + return state; +} + +function createMapping(settings) { + const mapping = []; + + if (settings.value) { + mapping.push('hflip'); + } + + return mapping; +} + +function HFlip(props) { + return ( + Horizontal Flip} checked={props.value} onChange={props.onChange} /> + ); +} + +HFlip.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, createMapping(newSettings), automatic); + }; + + const update = (what) => (event) => { + const newSettings = { + ...settings, + }; + if (['value'].includes(what)) { + newSettings[what] = !settings.value; + } else { + newSettings[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 = 'hflip'; +const name = 'Horizonal Flip'; +const type = 'video'; +const hwaccel = false; + +function summarize(settings) { + return `${name}`; +} + +function defaults() { + const settings = init({}); + + return { + settings: settings, + mapping: createMapping(settings), + }; +} + +export { name, filter, type, hwaccel, summarize, defaults, 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..ad48501 --- /dev/null +++ b/src/misc/filters/video/Transpose.js @@ -0,0 +1,109 @@ +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: false, + ...initialState, + }; + + return state; +} + +function createMapping(settings) { + const mapping = []; + + if (settings.value) { + if (settings.value === 3) { + mapping.push('transpose=2', 'transpose=2'); + } else { + mapping.push(`transpose=${settings.value}`); + } + } + + return mapping; +} + +// 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, createMapping(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 = 'Filter (transpose)'; +const type = 'video'; +const hwaccel = false; + +function summarize(settings) { + return `${name}`; +} + +function defaults() { + const settings = init({}); + + return { + settings: settings, + mapping: createMapping(settings), + }; +} + +export { name, filter, type, hwaccel, summarize, defaults, 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..741fadd --- /dev/null +++ b/src/misc/filters/video/VFlip.js @@ -0,0 +1,102 @@ +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 = { + value: false, + ...initialState, + }; + + return state; +} + +function createMapping(settings) { + const mapping = []; + + if (settings.value) { + mapping.push('vflip'); + } + + return mapping; +} + +function VFlip(props) { + return ( + Vertical Flip} checked={props.value} onChange={props.onChange} /> + ); +} + +VFlip.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, createMapping(newSettings), automatic); + }; + + const update = (what) => (event) => { + const newSettings = { + ...settings, + }; + if (['value'].includes(what)) { + newSettings[what] = !settings.value; + } else { + newSettings[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 = 'vflip'; +const name = 'Vertical Flip'; +const type = 'video'; +const hwaccel = false; + +function summarize(settings) { + return `${name}`; +} + +function defaults() { + const settings = init({}); + + return { + settings: settings, + mapping: createMapping(settings), + }; +} + +export { name, filter, type, hwaccel, summarize, defaults, Filter as component }; diff --git a/src/utils/metadata.js b/src/utils/metadata.js index 844ce97..e99be7b 100644 --- a/src/utils/metadata.js +++ b/src/utils/metadata.js @@ -597,7 +597,15 @@ const createInputsOutputs = (sources, profiles) => { global = [...global, ...profile.video.encoder.mapping.global]; - const options = ['-map', index + ':' + stream.stream, ...profile.video.encoder.mapping.local]; + // Merge video filters + for (let i = 0; i < profile.video.encoder.mapping.local.length; i++) { + if (profile.video.encoder.mapping.local[i] === '-vf' && profile.video.filter.mapping.length !== 0) { + profile.video.encoder.mapping.local[i + 1] = profile.video.encoder.mapping.local[i + 1] + ',' + profile.video.filter.mapping[1]; + profile.video.filter.mapping = []; + } + } + + const options = ['-map', index + ':' + stream.stream, ...profile.video.filter.mapping, ...profile.video.encoder.mapping.local]; if (profile.audio.encoder.coder !== 'none' && profile.audio.source !== -1 && profile.audio.stream !== -1) { global = [...global, ...profile.audio.decoder.mapping.global]; @@ -619,7 +627,15 @@ const createInputsOutputs = (sources, profiles) => { global = [...global, ...profile.audio.encoder.mapping.global]; - options.push('-map', index + ':' + stream.stream, ...profile.audio.encoder.mapping.local); + // Merge audio filters + for (let i = 0; i < profile.audio.encoder.mapping.local.length; i++) { + if (profile.audio.encoder.mapping.local[i] === '-af' && profile.audio.filter.mapping.length !== 0) { + profile.audio.encoder.mapping.local[i + 1] = profile.audio.encoder.mapping.local[i + 1] + ',' + profile.audio.filter.mapping[1]; + profile.audio.filter.mapping = []; + } + } + + options.push('-map', index + ':' + stream.stream, ...profile.audio.filter.mapping, ...profile.audio.encoder.mapping.local); } else { options.push('-an'); } @@ -745,6 +761,7 @@ const initProfile = (initialProfile) => { stream: -1, encoder: {}, decoder: {}, + filter: {}, ...profile.video, }; @@ -789,11 +806,19 @@ const initProfile = (initialProfile) => { }; } + profile.video.filter = { + filter: 'default', + settings: {}, + mapping: {}, + ...profile.video.filter, + }; + profile.audio = { source: -1, stream: -1, encoder: {}, decoder: {}, + filter: {}, ...profile.audio, }; @@ -837,6 +862,13 @@ const initProfile = (initialProfile) => { }; } + profile.audio.filter = { + filter: 'default', + settings: {}, + mapping: {}, + ...profile.audio.filter, + }; + profile.custom = { selected: profile.audio.source === 1, stream: profile.audio.source === 1 ? -2 : profile.audio.stream, diff --git a/src/utils/restreamer.js b/src/utils/restreamer.js index df84723..c176633 100644 --- a/src/utils/restreamer.js +++ b/src/utils/restreamer.js @@ -495,6 +495,7 @@ class Restreamer { input: [], output: [], }, + filter: [], sources: { network: [], virtualaudio: [], @@ -516,6 +517,7 @@ class Restreamer { formats: {}, protocols: {}, devices: {}, + filter: [], ...val, }; @@ -558,6 +560,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/views/Edit/Profile.js b/src/views/Edit/Profile.js index 3a3d756..4afb778 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,17 @@ export default function Source(props) { onChange={handleEncoding('video')} /> + {($profile.video.encoder.coder !== 'none' && $profile.video.encoder.coder !== 'copy') && ( + + + + )} )} @@ -457,6 +481,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 +558,16 @@ export default function Source(props) { onChange={handleEncoding('audio')} /> + {($profile.audio.encoder.coder !== 'none' && $profile.audio.encoder.coder !== 'copy') && ( + + + + )} )}