add overlays + api debugging option

This commit is contained in:
r3rex 2025-08-11 01:11:36 +04:00
parent 08b1dd0ba0
commit f703dbd01c
6 changed files with 415 additions and 8 deletions

1
.gitignore vendored
View File

@ -16,6 +16,7 @@
NONPUBLIC
.DS_Store
.VSCodeCounter
.env
.env.local
.env.development.local
.env.test.local

View File

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

View 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: '',
};

View File

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

View File

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

View File

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