From f703dbd01c763ce07753c436c01027a0324d6e49 Mon Sep 17 00:00:00 2001
From: r3rex <18644035+BlinkStrike@users.noreply.github.com>
Date: Mon, 11 Aug 2025 01:11:36 +0400
Subject: [PATCH] add overlays + api debugging option
---
.gitignore | 1 +
src/index.js | 7 ++
src/misc/controls/Overlays.js | 224 ++++++++++++++++++++++++++++++++++
src/utils/api.js | 41 ++++++-
src/utils/metadata.js | 131 +++++++++++++++++++-
src/views/Edit/index.js | 19 ++-
6 files changed, 415 insertions(+), 8 deletions(-)
create mode 100644 src/misc/controls/Overlays.js
diff --git a/.gitignore b/.gitignore
index cedfce8..5de6021 100644
--- a/.gitignore
+++ b/.gitignore
@@ -16,6 +16,7 @@
NONPUBLIC
.DS_Store
.VSCodeCounter
+.env
.env.local
.env.development.local
.env.test.local
diff --git a/src/index.js b/src/index.js
index 4958457..dffcee6 100644
--- a/src/index.js
+++ b/src/index.js
@@ -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');
diff --git a/src/misc/controls/Overlays.js b/src/misc/controls/Overlays.js
new file mode 100644
index 0000000..a63d7f3
--- /dev/null
+++ b/src/misc/controls/Overlays.js
@@ -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 (
+
+
+
+ Overlays
+
+
+
+
+ Upload images and position them on the video. Order defines layering from bottom to top.
+
+
+
+
+
+
+ {items.map((ov, idx) => (
+
+
+
+
+
+
+
+ Overlay #{idx + 1}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Upload image}
+ acceptTypes={acceptTypes}
+ onStart={handleUploadStart}
+ onError={handleUploadError}
+ onUpload={handleUpload(idx)}
+ />
+
+
+
+ Path}
+ value={ov.path}
+ onChange={handleField(idx, 'path')}
+ />
+
+
+
+ Width (-1 keep)}
+ value={ov.width}
+ onChange={handleField(idx, 'width')}
+ />
+
+
+ Height (-1 keep)}
+ value={ov.height}
+ onChange={handleField(idx, 'height')}
+ />
+
+
+ X} value={ov.x} onChange={handleField(idx, 'x')} />
+
+
+ Y} value={ov.y} onChange={handleField(idx, 'y')} />
+
+
+
+ Framerate}
+ value={ov.framerate}
+ onChange={handleField(idx, 'framerate')}
+ />
+
+
+ Loop} checked={!!ov.loop} onChange={handleField(idx, 'loop')} />
+
+
+ Order}
+ value={ov.order}
+ onChange={handleField(idx, 'order')}
+ />
+
+
+ ))}
+
+ );
+}
+
+Overlays.defaultProps = {
+ settings: [],
+ onChange: function (settings, automatic) {},
+ restreamer: null,
+ channelid: '',
+};
diff --git a/src/utils/api.js b/src/utils/api.js
index a5f4f41..164ff5e 100644
--- a/src/utils/api.js
+++ b/src/utils/api.js
@@ -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;
}
diff --git a/src/utils/metadata.js b/src/utils/metadata.js
index fc0eea0..e3f28de 100644
--- a/src/utils/metadata.js
+++ b/src/utils/metadata.js
@@ -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];
};
diff --git a/src/views/Edit/index.js b/src/views/Edit/index.js
index a5fa17c..d0b2e51 100644
--- a/src/views/Edit/index.js
+++ b/src/views/Edit/index.js
@@ -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) {
+
+
+
+
+
+ Overlays
+
+
+
+
+