add overlays + api debugging option
This commit is contained in:
parent
08b1dd0ba0
commit
f703dbd01c
1
.gitignore
vendored
1
.gitignore
vendored
@ -16,6 +16,7 @@
|
||||
NONPUBLIC
|
||||
.DS_Store
|
||||
.VSCodeCounter
|
||||
.env
|
||||
.env.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
|
||||
@ -9,11 +9,18 @@ import CssBaseline from '@mui/material/CssBaseline';
|
||||
import theme from './theme';
|
||||
import RestreamerUI from './RestreamerUI';
|
||||
|
||||
const ENV_ADDRESS = process.env.REACT_APP_RESTREAMER_ADDRESS;
|
||||
|
||||
let address = window.location.protocol + '//' + window.location.host;
|
||||
if (window.location.pathname.endsWith('/ui/')) {
|
||||
address += window.location.pathname.replace(/ui\/$/, '');
|
||||
}
|
||||
|
||||
// Override with env var if provided at build-time
|
||||
if (ENV_ADDRESS && ENV_ADDRESS.length > 0) {
|
||||
address = ENV_ADDRESS;
|
||||
}
|
||||
|
||||
const urlParams = new URLSearchParams(window.location.search.substring(1));
|
||||
if (urlParams.has('address') === true) {
|
||||
address = urlParams.get('address');
|
||||
|
||||
224
src/misc/controls/Overlays.js
Normal file
224
src/misc/controls/Overlays.js
Normal file
@ -0,0 +1,224 @@
|
||||
import React from 'react';
|
||||
|
||||
import { Trans } from '@lingui/macro';
|
||||
import Grid from '@mui/material/Grid';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import TextField from '@mui/material/TextField';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
import Button from '@mui/material/Button';
|
||||
import Divider from '@mui/material/Divider';
|
||||
import Box from '@mui/material/Box';
|
||||
import ArrowUpwardIcon from '@mui/icons-material/ArrowUpward';
|
||||
import ArrowDownwardIcon from '@mui/icons-material/ArrowDownward';
|
||||
import DeleteIcon from '@mui/icons-material/Delete';
|
||||
|
||||
import Checkbox from '../Checkbox';
|
||||
import UploadButton from '../UploadButton';
|
||||
|
||||
function init(initial) {
|
||||
if (Array.isArray(initial)) {
|
||||
return initial.map((o) => ({
|
||||
path: '',
|
||||
width: '',
|
||||
height: '',
|
||||
x: '',
|
||||
y: '',
|
||||
framerate: 30,
|
||||
loop: true,
|
||||
order: 0,
|
||||
...o,
|
||||
}));
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
export default function Overlays(props) {
|
||||
const [items, setItems] = React.useState(init(props.settings));
|
||||
|
||||
// Set defaults on mount
|
||||
React.useEffect(() => {
|
||||
props.onChange(items, true);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const acceptTypes = [
|
||||
{ mimetype: 'image/png', extension: 'png', maxSize: 20 * 1024 * 1024 },
|
||||
{ mimetype: 'image/jpeg', extension: 'jpg', maxSize: 20 * 1024 * 1024 },
|
||||
{ mimetype: 'image/webp', extension: 'webp', maxSize: 20 * 1024 * 1024 },
|
||||
{ mimetype: 'image/gif', extension: 'gif', maxSize: 20 * 1024 * 1024 },
|
||||
];
|
||||
|
||||
const update = (next) => {
|
||||
setItems(next);
|
||||
props.onChange(next, false);
|
||||
};
|
||||
|
||||
const addOverlay = () => {
|
||||
const next = items.slice();
|
||||
next.push({ path: '', width: '', height: '', x: '', y: '', framerate: 30, loop: true, order: next.length });
|
||||
update(next);
|
||||
};
|
||||
|
||||
const removeOverlay = (idx) => () => {
|
||||
const next = items.filter((_, i) => i !== idx).map((o, i) => ({ ...o, order: i }));
|
||||
update(next);
|
||||
};
|
||||
|
||||
const moveOverlay = (idx, dir) => () => {
|
||||
const next = items.slice();
|
||||
const j = idx + dir;
|
||||
if (j < 0 || j >= next.length) return;
|
||||
const tmp = next[idx];
|
||||
next[idx] = next[j];
|
||||
next[j] = tmp;
|
||||
next.forEach((o, i) => (o.order = i));
|
||||
update(next);
|
||||
};
|
||||
|
||||
const handleField = (idx, field) => (event) => {
|
||||
const value = event?.target?.type === 'checkbox' ? event.target.checked : event.target.value;
|
||||
const next = items.slice();
|
||||
next[idx] = { ...next[idx], [field]: value };
|
||||
update(next);
|
||||
};
|
||||
|
||||
const handleUploadStart = () => {};
|
||||
const handleUploadError = () => {};
|
||||
|
||||
const handleUpload = (idx) => async (data, extension /*, mimetype */) => {
|
||||
try {
|
||||
if (props.restreamer) {
|
||||
const name = `overlay_${Date.now()}.${extension}`;
|
||||
const path = await props.restreamer.UploadData(props.channelid || '', name, data);
|
||||
const newPath = "/core/data" + path;
|
||||
console.log('path', newPath);
|
||||
const next = items.slice();
|
||||
next[idx] = { ...next[idx], path: newPath };
|
||||
update(next);
|
||||
}
|
||||
} catch (e) {
|
||||
// swallow
|
||||
// Optionally, you can integrate NotifyContext in parent to surface errors
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Grid container spacing={2}>
|
||||
<Grid item xs={12}>
|
||||
<Typography variant="h3">
|
||||
<Trans>Overlays</Trans>
|
||||
</Typography>
|
||||
</Grid>
|
||||
<Grid item xs={12}>
|
||||
<Typography variant="body2">
|
||||
<Trans>Upload images and position them on the video. Order defines layering from bottom to top.</Trans>
|
||||
</Typography>
|
||||
</Grid>
|
||||
<Grid item xs={12}>
|
||||
<Button variant="outlined" color="primary" onClick={addOverlay}>
|
||||
<Trans>Add overlay</Trans>
|
||||
</Button>
|
||||
</Grid>
|
||||
|
||||
{items.map((ov, idx) => (
|
||||
<React.Fragment key={`overlay-${idx}`}>
|
||||
<Grid item xs={12}>
|
||||
<Divider />
|
||||
</Grid>
|
||||
<Grid item xs={12}>
|
||||
<Box display="flex" alignItems="center" justifyContent="space-between">
|
||||
<Typography variant="h4">
|
||||
<Trans>Overlay #{idx + 1}</Trans>
|
||||
</Typography>
|
||||
<Box>
|
||||
<IconButton aria-label="move up" onClick={moveOverlay(idx, -1)} size="large">
|
||||
<ArrowUpwardIcon />
|
||||
</IconButton>
|
||||
<IconButton aria-label="move down" onClick={moveOverlay(idx, +1)} size="large">
|
||||
<ArrowDownwardIcon />
|
||||
</IconButton>
|
||||
<IconButton aria-label="delete" onClick={removeOverlay(idx)} size="large">
|
||||
<DeleteIcon />
|
||||
</IconButton>
|
||||
</Box>
|
||||
</Box>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12} md={6}>
|
||||
<UploadButton
|
||||
label={<Trans>Upload image</Trans>}
|
||||
acceptTypes={acceptTypes}
|
||||
onStart={handleUploadStart}
|
||||
onError={handleUploadError}
|
||||
onUpload={handleUpload(idx)}
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12} md={6}>
|
||||
<TextField
|
||||
variant="outlined"
|
||||
fullWidth
|
||||
label={<Trans>Path</Trans>}
|
||||
value={ov.path}
|
||||
onChange={handleField(idx, 'path')}
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12} md={3}>
|
||||
<TextField
|
||||
variant="outlined"
|
||||
fullWidth
|
||||
label={<Trans>Width (-1 keep)</Trans>}
|
||||
value={ov.width}
|
||||
onChange={handleField(idx, 'width')}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12} md={3}>
|
||||
<TextField
|
||||
variant="outlined"
|
||||
fullWidth
|
||||
label={<Trans>Height (-1 keep)</Trans>}
|
||||
value={ov.height}
|
||||
onChange={handleField(idx, 'height')}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12} md={3}>
|
||||
<TextField variant="outlined" fullWidth label={<Trans>X</Trans>} value={ov.x} onChange={handleField(idx, 'x')} />
|
||||
</Grid>
|
||||
<Grid item xs={12} md={3}>
|
||||
<TextField variant="outlined" fullWidth label={<Trans>Y</Trans>} value={ov.y} onChange={handleField(idx, 'y')} />
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12} md={3}>
|
||||
<TextField
|
||||
variant="outlined"
|
||||
fullWidth
|
||||
label={<Trans>Framerate</Trans>}
|
||||
value={ov.framerate}
|
||||
onChange={handleField(idx, 'framerate')}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12} md={3}>
|
||||
<Checkbox label={<Trans>Loop</Trans>} checked={!!ov.loop} onChange={handleField(idx, 'loop')} />
|
||||
</Grid>
|
||||
<Grid item xs={12} md={3}>
|
||||
<TextField
|
||||
variant="outlined"
|
||||
fullWidth
|
||||
label={<Trans>Order</Trans>}
|
||||
value={ov.order}
|
||||
onChange={handleField(idx, 'order')}
|
||||
/>
|
||||
</Grid>
|
||||
</React.Fragment>
|
||||
))}
|
||||
</Grid>
|
||||
);
|
||||
}
|
||||
|
||||
Overlays.defaultProps = {
|
||||
settings: [],
|
||||
onChange: function (settings, automatic) {},
|
||||
restreamer: null,
|
||||
channelid: '',
|
||||
};
|
||||
@ -5,10 +5,15 @@ class API {
|
||||
this.token = '';
|
||||
|
||||
this.cache = new Map();
|
||||
|
||||
// Debug logging for API requests/responses (opt-in via REACT_APP_API_DEBUG=true)
|
||||
this.debugRequests = (String(process.env.REACT_APP_API_DEBUG || '')).toLowerCase() === 'true';
|
||||
}
|
||||
|
||||
_debug(message) {
|
||||
//console.log(`[CoreAPI] ${message}`);
|
||||
if (this.debugRequests) {
|
||||
console.log(`[CoreAPI] ${message}`);
|
||||
}
|
||||
}
|
||||
|
||||
_error(message) {
|
||||
@ -94,6 +99,31 @@ class API {
|
||||
|
||||
this._debug(`calling ${options.method} ${this.address + path}`);
|
||||
|
||||
// Debug: print request details with masked Authorization and trimmed body
|
||||
if (this.debugRequests) {
|
||||
const dbgHeaders = { ...options.headers };
|
||||
if (dbgHeaders.Authorization) {
|
||||
dbgHeaders.Authorization = 'Bearer ***';
|
||||
}
|
||||
let bodyPreview = undefined;
|
||||
if (typeof options.body === 'string') {
|
||||
try {
|
||||
const parsed = JSON.parse(options.body);
|
||||
const str = JSON.stringify(parsed);
|
||||
bodyPreview = str.length > 2000 ? str.slice(0, 2000) + '…' : str;
|
||||
} catch (_) {
|
||||
bodyPreview = options.body.length > 2000 ? options.body.slice(0, 2000) + '…' : options.body;
|
||||
}
|
||||
}
|
||||
console.log('[CoreAPI] Request', {
|
||||
method: options.method,
|
||||
url: this.address + path,
|
||||
headers: dbgHeaders,
|
||||
body: bodyPreview,
|
||||
expect: options.expect,
|
||||
});
|
||||
}
|
||||
|
||||
const res = {
|
||||
err: null,
|
||||
val: null,
|
||||
@ -144,6 +174,15 @@ class API {
|
||||
|
||||
this._error(res.err.message);
|
||||
|
||||
if (this.debugRequests) {
|
||||
console.log('[CoreAPI] Response (error)', {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
url: this.address + path,
|
||||
err: res.err,
|
||||
});
|
||||
}
|
||||
|
||||
return res;
|
||||
}
|
||||
|
||||
|
||||
@ -623,13 +623,16 @@ const validateProfile = (sources, profile, requireVideo = true) => {
|
||||
return complete;
|
||||
};
|
||||
|
||||
const createInputsOutputs = (sources, profiles, requireVideo = true) => {
|
||||
const createInputsOutputs = (sources, profiles, requireVideo = true, overlays = []) => {
|
||||
const source2inputMap = new Map();
|
||||
|
||||
let global = [];
|
||||
const inputs = [];
|
||||
const outputs = [];
|
||||
|
||||
// Check if we need to use filter_complex for overlays
|
||||
const useFilterComplex = overlays && overlays.length > 0;
|
||||
|
||||
// For each profile get the source and do the proper mapping
|
||||
for (let profile of profiles) {
|
||||
const complete = validateProfile(sources, profile, requireVideo);
|
||||
@ -662,17 +665,27 @@ const createInputsOutputs = (sources, profiles, requireVideo = true) => {
|
||||
|
||||
const local = profile.video.encoder.mapping.local.slice();
|
||||
|
||||
// Prepare video filter graph
|
||||
let videoPreFilter = '';
|
||||
if (profile.video.encoder.coder !== 'copy' && (profile.video.filter.graph.length !== 0 || profile.video.encoder.mapping.filter.length !== 0)) {
|
||||
let filter = profile.video.filter.graph;
|
||||
videoPreFilter = profile.video.filter.graph;
|
||||
if (profile.video.encoder.mapping.filter.length !== 0) {
|
||||
if (filter.length !== 0) {
|
||||
filter += ',';
|
||||
if (videoPreFilter.length !== 0) {
|
||||
videoPreFilter += ',';
|
||||
}
|
||||
|
||||
filter += profile.video.encoder.mapping.filter.join(',');
|
||||
videoPreFilter += profile.video.encoder.mapping.filter.join(',');
|
||||
}
|
||||
}
|
||||
|
||||
local.unshift('-filter:v', filter);
|
||||
// Handle filter complex for overlays
|
||||
if (useFilterComplex) {
|
||||
// We'll handle this after collecting all inputs
|
||||
} else {
|
||||
// Use simple video filter if no overlays
|
||||
if (videoPreFilter.length !== 0 && profile.video.encoder.coder !== 'copy') {
|
||||
local.unshift('-filter:v', videoPreFilter);
|
||||
}
|
||||
}
|
||||
|
||||
const options = ['-map', index + ':' + stream.stream, ...local];
|
||||
@ -732,6 +745,112 @@ const createInputsOutputs = (sources, profiles, requireVideo = true) => {
|
||||
global = uniqBy(global, (x) => JSON.stringify(x.sort()));
|
||||
global = global.reduce((acc, val) => acc.concat(val), []);
|
||||
|
||||
// Process overlays and create filter_complex if needed
|
||||
if (useFilterComplex && profiles.length > 0 && inputs.length > 0) {
|
||||
// We'll use the first profile and its video stream as the main input
|
||||
const profile = profiles[0];
|
||||
const videoSource = sources[profile.video.source];
|
||||
const videoStream = videoSource.streams[profile.video.stream];
|
||||
const videoInputIndex = source2inputMap.get(profile.video.source + ':' + videoStream.index);
|
||||
|
||||
// Create filter complex parts
|
||||
const filterParts = [];
|
||||
|
||||
// Add overlays as inputs
|
||||
const validOverlays = overlays.filter(overlay => overlay.path);
|
||||
validOverlays.forEach((overlay, i) => {
|
||||
// Create input options for overlay
|
||||
const overlayOptions = [];
|
||||
|
||||
// Apply loop if enabled (always use -loop 1 for images)
|
||||
overlayOptions.push('-loop', '1');
|
||||
|
||||
// Apply framerate if specified
|
||||
if (overlay.framerate) {
|
||||
overlayOptions.push('-framerate', overlay.framerate.toString());
|
||||
} else {
|
||||
overlayOptions.push('-framerate', '30');
|
||||
}
|
||||
|
||||
// Add the overlay as an input
|
||||
inputs.push({
|
||||
address: overlay.path,
|
||||
options: overlayOptions,
|
||||
});
|
||||
});
|
||||
|
||||
// Now build the filter complex string similar to the working example
|
||||
// First, create labels for each overlay
|
||||
validOverlays.forEach((overlay, i) => {
|
||||
const overlayIndex = i + 1;
|
||||
const inputIndex = videoInputIndex + overlayIndex; // Main video + overlay index
|
||||
let scaleWidth = overlay.width || '320';
|
||||
|
||||
// Create scale filter for the overlay
|
||||
filterParts.push(`[${inputIndex}:v]scale=${scaleWidth}:-1[logo${overlayIndex}]`);
|
||||
});
|
||||
|
||||
// Add scale filter for the main video if needed
|
||||
if (profile.video.filter.graph.length !== 0 && profile.video.filter.graph.includes('scale')) {
|
||||
// Use the existing scale filter from the profile
|
||||
filterParts.push(`[${videoInputIndex}:${videoStream.stream}]${profile.video.filter.graph}[bg]`);
|
||||
} else {
|
||||
// Add a default scale filter if none exists
|
||||
filterParts.push(`[${videoInputIndex}:${videoStream.stream}]scale=1280:720[bg]`);
|
||||
}
|
||||
|
||||
// Now chain the overlays together
|
||||
let currentInput = '[bg]';
|
||||
validOverlays.forEach((overlay, i) => {
|
||||
const overlayIndex = i + 1;
|
||||
const isLastOverlay = i === validOverlays.length - 1;
|
||||
|
||||
// Determine position
|
||||
let x = overlay.x || '(W-w)/2';
|
||||
let y = overlay.y || '(H-h)/2';
|
||||
|
||||
// For vertical positioning at bottom with padding
|
||||
if (y === '0' || !overlay.y) {
|
||||
// If it's the first overlay, position at bottom with padding
|
||||
if (i === 0) {
|
||||
y = 'H-h-24';
|
||||
} else {
|
||||
y = '(H-h)/2';
|
||||
}
|
||||
}
|
||||
|
||||
// Create output label
|
||||
const outputLabel = isLastOverlay ? '[v]' : `[with_logo${overlayIndex}]`;
|
||||
|
||||
// Add overlay filter
|
||||
filterParts.push(`${currentInput}[logo${overlayIndex}]overlay=x=${x}:y=${y}${outputLabel}`);
|
||||
|
||||
// Update for next iteration
|
||||
currentInput = outputLabel;
|
||||
});
|
||||
|
||||
// Add format filter at the end if we have overlays
|
||||
if (validOverlays.length > 0) {
|
||||
// Add format=yuv420p to the final output
|
||||
filterParts.push(`[v]format=yuv420p[vout]`);
|
||||
|
||||
// Add filter_complex to global options
|
||||
global.push('-filter_complex', filterParts.join(';'));
|
||||
|
||||
// Update the first output to map from the filter complex output
|
||||
if (outputs.length > 0) {
|
||||
// Find and replace the video map in the first output
|
||||
const output = outputs[0];
|
||||
const mapIndex = output.options.indexOf('-map');
|
||||
|
||||
if (mapIndex !== -1 && mapIndex + 1 < output.options.length) {
|
||||
// Replace the video map with our filter complex output
|
||||
output.options.splice(mapIndex, 2, '-map', '[vout]');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return [global, inputs, outputs];
|
||||
};
|
||||
|
||||
|
||||
@ -37,6 +37,7 @@ import SnapshotControl from '../../misc/controls/Snapshot';
|
||||
import SRTControl from '../../misc/controls/SRT';
|
||||
import TabPanel from '../../misc/TabPanel';
|
||||
import TabsVerticalGrid from '../../misc/TabsVerticalGrid';
|
||||
import OverlaysControl from '../../misc/controls/Overlays';
|
||||
|
||||
const useStyles = makeStyles((theme) => ({
|
||||
wizardButtonElement: {
|
||||
@ -296,7 +297,7 @@ export default function Edit(props) {
|
||||
const profiles = $data.profiles;
|
||||
const control = $data.control;
|
||||
|
||||
const [global, inputs, outputs] = M.createInputsOutputs(sources, profiles, true);
|
||||
const [global, inputs, outputs] = M.createInputsOutputs(sources, profiles, true, control.overlays || []);
|
||||
|
||||
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.`));
|
||||
@ -568,6 +569,22 @@ export default function Edit(props) {
|
||||
<Grid item xs={12}>
|
||||
<LimitsControl settings={$data.control.limits} onChange={handleControlChange('limits')} />
|
||||
</Grid>
|
||||
<Grid item xs={12}>
|
||||
<Divider />
|
||||
</Grid>
|
||||
<Grid item xs={12}>
|
||||
<Typography variant="h3">
|
||||
<Trans>Overlays</Trans>
|
||||
</Typography>
|
||||
</Grid>
|
||||
<Grid item xs={12}>
|
||||
<OverlaysControl
|
||||
settings={$data.control.overlays}
|
||||
onChange={handleControlChange('overlays')}
|
||||
restreamer={props.restreamer}
|
||||
channelid={_channelid}
|
||||
/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</TabPanel>
|
||||
<TabPanel value={$tab} index="meta" className="panel">
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user