Add av filter components

This commit is contained in:
Jan Stabenow 2022-07-13 20:23:12 +02:00
parent 9d7431a4bd
commit ec90f6730f
No known key found for this signature in database
GPG Key ID: 9C22DD65A9AAF133
13 changed files with 837 additions and 3 deletions

View File

@ -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

View File

@ -373,7 +373,7 @@
<div class="col-xs-12 player-l2">
<div class="player-l3">
{{#ifEquals player "videojs"}}
<video id="player" class="vjs-public video-js player-l4" playsinline></video>
<video id="player" class="vjs-public video-js vjs-16-9 player-l4" playsinline></video>
{{else}}
<div id="player" class="player-l4"></div>
{{/ifEquals}}

127
src/misc/FilterSelect.js Normal file
View File

@ -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(
<Settings
key={c.filter}
settings={profile.filter.settings[c.filter] ? profile.filter.settings[c.filter].settings : []}
onChange={handleFilterSettingsChange(c.filter)}
/>
);
}
}
}
// No suitable filter found
if (filterSettings === null && !hwaccel) {
return (
<Grid container spacing={2}>
<Grid item xs={12}>
<Typography>
<Trans>No suitable filter found.</Trans>
</Typography>
</Grid>
</Grid>
);
// hwaccel requires further settings
} else if (hwaccel) {
return false;
}
return (
<Grid container spacing={2}>
<Grid item xs={12}>
<Typography>
<Trans>Select your filter settings (optional):</Trans>
</Typography>
</Grid>
{filterSettings}
</Grid>
);
}
FilterSelect.defaultProps = {
type: '',
filters: [],
availableFilters: [],
onChange: function (filter) {},
};

View File

@ -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;

View File

@ -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 <Checkbox label={<Trans>Loudness Normalization</Trans>} 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 (
<React.Fragment>
<Grid item>
<Loudness value={settings.value} onChange={update('value')} allowCustom />
</Grid>
</React.Fragment>
);
}
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 };

View File

@ -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 (
<Select label={<Trans>Volume</Trans>} value={props.value} onChange={props.onChange}>
<MenuItem value={false}>None</MenuItem>
<MenuItem value={0.1}>10%</MenuItem>
<MenuItem value={0.2}>20%</MenuItem>
<MenuItem value={0.3}>30%</MenuItem>
<MenuItem value={0.4}>40%</MenuItem>
<MenuItem value={0.5}>50%</MenuItem>
<MenuItem value={0.6}>60%</MenuItem>
<MenuItem value={0.7}>70%</MenuItem>
<MenuItem value={0.8}>80%</MenuItem>
<MenuItem value={0.9}>90%</MenuItem>
<MenuItem value={1.0}>100%</MenuItem>
<MenuItem value="custom">Custom</MenuItem>
</Select>
);
}
VolumeLevel.defaultProps = {
value: '',
onChange: function (event) {},
};
function VolumeDB(props) {
console.log(props);
return (
<TextField
variant="outlined"
fullWidth
label={<Trans>Decibels (dB)</Trans>}
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 (
<React.Fragment>
<Grid item xs={6}>
<VolumeLevel value={settings.level} onChange={update('level')} allowCustom />
</Grid>
<Grid item xs={6}>
<VolumeDB value={settings.db} onChange={update('db')} disabled={settings.level !== 'custom'} allowCustom />
</Grid>
</React.Fragment>
);
}
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 };

56
src/misc/filters/index.js Normal file
View File

@ -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 };

View File

@ -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 (
<Checkbox label={<Trans>Horizontal Flip</Trans>} 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 (
<Grid item>
<HFlip value={settings.value} onChange={update('value')} allowCustom />
</Grid>
);
}
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 };

View File

@ -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 (
<Select label={<Trans>Rotate</Trans>} value={props.value} onChange={props.onChange}>
<MenuItem value={false}>None</MenuItem>
<MenuItem value={1}>90°</MenuItem>
<MenuItem value={3}>180°</MenuItem>
<MenuItem value={2}>270°</MenuItem>
</Select>
);
}
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 (
<Grid item xs={12}>
<Rotate value={settings.value} onChange={update('value')} allowCustom />
</Grid>
);
}
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 };

View File

@ -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 (
<Checkbox label={<Trans>Vertical Flip</Trans>} 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 (
<Grid item>
<VFlip value={settings.value} onChange={update('value')} allowCustom />
</Grid>
);
}
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 };

View File

@ -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,

View File

@ -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: [],

View File

@ -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')}
/>
</Grid>
{($profile.video.encoder.coder !== 'none' && $profile.video.encoder.coder !== 'copy') && (
<Grid item xs={12}>
<FilterSelect
type="video"
profile={$profile.video}
videoProfile={$profile.video}
availableFilters={props.skills.filter}
onChange={handleFilter('video')}
/>
</Grid>
)}
</React.Fragment>
)}
</React.Fragment>
@ -457,6 +481,16 @@ export default function Source(props) {
onChange={handleEncoding('audio')}
/>
</Grid>
{($profile.audio.encoder.coder !== 'none' && $profile.audio.encoder.coder !== 'copy') && (
<Grid item xs={12}>
<FilterSelect
type="audio"
profile={$profile.audio}
availableFilters={props.skills.filter}
onChange={handleFilter('audio')}
/>
</Grid>
)}
</React.Fragment>
)}
{$profile.custom.selected === true && (
@ -524,6 +558,16 @@ export default function Source(props) {
onChange={handleEncoding('audio')}
/>
</Grid>
{($profile.audio.encoder.coder !== 'none' && $profile.audio.encoder.coder !== 'copy') && (
<Grid item xs={12}>
<FilterSelect
type="audio"
profile={$profile.audio}
availableFilters={props.skills.filter}
onChange={handleFilter('audio')}
/>
</Grid>
)}
</React.Fragment>
)}
</React.Fragment>