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