diff --git a/Data/WebUI/src/nodes/Image Processing/Node_Adjust_Contrast.jsx b/Data/WebUI/src/nodes/Image Processing/Node_Adjust_Contrast.jsx new file mode 100644 index 0000000..2881db9 --- /dev/null +++ b/Data/WebUI/src/nodes/Image Processing/Node_Adjust_Contrast.jsx @@ -0,0 +1,111 @@ +import React, { useEffect, useState, useRef } from "react"; +import { Handle, Position, useStore } from "reactflow"; + +if (!window.BorealisValueBus) window.BorealisValueBus = {}; +if (!window.BorealisUpdateRate) window.BorealisUpdateRate = 100; + +const ContrastNode = ({ id }) => { + const edges = useStore((state) => state.edges); + const [contrast, setContrast] = useState(100); + const valueRef = useRef(""); + const [renderValue, setRenderValue] = useState(""); + + const applyContrast = (base64Data, contrastVal) => { + return new Promise((resolve) => { + const img = new Image(); + img.crossOrigin = "anonymous"; + + img.onload = () => { + const canvas = document.createElement("canvas"); + canvas.width = img.width; + canvas.height = img.height; + const ctx = canvas.getContext("2d"); + ctx.drawImage(img, 0, 0); + + const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height); + const data = imageData.data; + + const factor = (259 * (contrastVal + 255)) / (255 * (259 - contrastVal)); + + for (let i = 0; i < data.length; i += 4) { + data[i] = factor * (data[i] - 128) + 128; + data[i + 1] = factor * (data[i + 1] - 128) + 128; + data[i + 2] = factor * (data[i + 2] - 128) + 128; + } + + ctx.putImageData(imageData, 0, 0); + resolve(canvas.toDataURL("image/png").replace(/^data:image\/png;base64,/, "")); + }; + + img.onerror = () => resolve(base64Data); + img.src = `data:image/png;base64,${base64Data}`; + }); + }; + + useEffect(() => { + const inputEdge = edges.find(e => e.target === id); + if (!inputEdge?.source) return; + + const input = window.BorealisValueBus[inputEdge.source] ?? ""; + if (!input) return; + + applyContrast(input, contrast).then((output) => { + setRenderValue(output); + window.BorealisValueBus[id] = output; + }); + }, [contrast, edges, id]); + + useEffect(() => { + let interval = null; + const tick = async () => { + const edge = edges.find(e => e.target === id); + const input = edge ? window.BorealisValueBus[edge.source] : ""; + + if (input && input !== valueRef.current) { + const result = await applyContrast(input, contrast); + valueRef.current = input; + setRenderValue(result); + window.BorealisValueBus[id] = result; + } + }; + + interval = setInterval(tick, window.BorealisUpdateRate); + return () => clearInterval(interval); + }, [id, contrast, edges]); + + return ( +
+ +
Adjust Contrast
+
+ + setContrast(parseInt(e.target.value) || 100)} + style={{ + width: "100%", + fontSize: "9px", + padding: "4px", + background: "#1e1e1e", + color: "#ccc", + border: "1px solid #444", + borderRadius: "2px", + marginTop: "4px" + }} + /> +
+ +
+ ); +}; + +export default { + type: "ContrastNode", + label: "Adjust Contrast", + description: "Modify contrast of base64 image using a contrast multiplier.", + content: "Adjusts contrast of image using canvas pixel transform.", + component: ContrastNode +}; diff --git a/Data/WebUI/src/nodes/Image Processing/Node_BW_Threshold.jsx b/Data/WebUI/src/nodes/Image Processing/Node_BW_Threshold.jsx index 4014c1f..a09d0f9 100644 --- a/Data/WebUI/src/nodes/Image Processing/Node_BW_Threshold.jsx +++ b/Data/WebUI/src/nodes/Image Processing/Node_BW_Threshold.jsx @@ -1,161 +1,34 @@ -/** - * ================================================================= - * Borealis Threshold Node - * with Per-Node Unique ID & BorealisValueBus Persistence - * ================================================================= - * - * GOALS: - * 1) Generate a unique ID for this node instance (the "threshold node instance ID"). - * 2) Store that ID in node.data._thresholdId if it’s not already there. - * 3) Use that ID to read/write the threshold from window.BorealisValueBus, - * so that if the node is forcibly unmounted & remounted but re-uses the - * same node.data._thresholdId, we restore the slider. - * - * HOW IT WORKS: - * - On mount, we check if node.data._thresholdId exists. - * - If yes, we use that as our "instance ID". - * - If no, we create a new random ID (like a GUID) and store it in node.data._thresholdId - * and also do a one-time `setNodes()` call to update the node data so it persists. - * - Once we have that instance ID, we look up - * `window.BorealisValueBus["th|" + instanceId]` for a saved threshold. - * - If found, we initialize the slider with that. Otherwise 128. - * - On slider change, we update that bus key with the new threshold. - * - On each unmount/mount cycle, if the node’s data still has `_thresholdId`, - * we reload from the same bus key, preventing a “rubber-banding” reset. - * - * NOTE: - * - We do call setNodes() once if we have to embed the newly generated ID into node.data. - * But we do it carefully, minimal, so we don't forcibly re-create the node. - * If your parent code overwrites node.data, we lose the ID. - * - * WARNING: - * - If the parent changes the node’s ID or data every time, there's no fix. This is the - * best we can do entirely inside this node’s code. - */ - import React, { useEffect, useRef, useState } from "react"; -import { Handle, Position, useStore, useReactFlow } from "reactflow"; +import { Handle, Position, useStore } from "reactflow"; // Ensure BorealisValueBus exists if (!window.BorealisValueBus) { window.BorealisValueBus = {}; } -// Default global update rate if (!window.BorealisUpdateRate) { window.BorealisUpdateRate = 100; } -/** - * Utility to generate a random GUID-like string - */ -function generateGUID() { - // Very simple random hex approach - return "xxxx-4xxx-yxxx-xxxx".replace(/[xy]/g, function (c) { - const r = Math.random() * 16 | 0; - const v = c === "x" ? r : (r & 0x3 | 0x8); - return v.toString(16); - }); -} - -const PersistentThresholdNode = ({ id, data }) => { +const BWThresholdNode = ({ id, data }) => { const edges = useStore((state) => state.edges); - const { setNodes } = useReactFlow(); // so we can store our unique ID in node.data if needed - - // We'll store: - // 1) The node’s unique threshold instance ID (guid) - // 2) The slider threshold in local React state - const [instanceId, setInstanceId] = useState(() => { - // See if we already have an ID in data._thresholdId - const existing = data?._thresholdId; - return existing || ""; // If not found, empty string for now - }); - - const [threshold, setThreshold] = useState(128); - - // For checking upstream changes + const [threshold, setThreshold] = useState(() => parseInt(data?.value, 10) || 128); const [renderValue, setRenderValue] = useState(""); - const valueRef = useRef(renderValue); + const valueRef = useRef(""); + const lastUpstreamRef = useRef(""); - // MOUNT / UNMOUNT debug - useEffect(() => { - console.log(`[ThresholdNode:${id}] MOUNTED (instanceId=${instanceId || "none"})`); - return () => { - console.log(`[ThresholdNode:${id}] UNMOUNTED (instanceId=${instanceId || "none"})`); - }; - }, [id, instanceId]); + const handleThresholdInput = (e) => { + let val = parseInt(e.target.value, 10); + if (isNaN(val)) val = 128; + val = Math.max(0, Math.min(255, val)); - /** - * On first mount, we see if we have an instanceId in node.data. - * If not, we create one, call setNodes() to store it in data._thresholdId. - */ - useEffect(() => { - if (!instanceId) { - // Generate a new ID - const newId = generateGUID(); - console.log(`[ThresholdNode:${id}] Generating new instanceId=${newId}`); - - // Insert it into this node’s data - setNodes((prevNodes) => - prevNodes.map((n) => { - if (n.id === id) { - return { - ...n, - data: { - ...n.data, - _thresholdId: newId - } - }; - } - return n; - }) - ); - setInstanceId(newId); - } else { - console.log(`[ThresholdNode:${id}] Found existing instanceId=${instanceId}`); - } - }, [id, instanceId, setNodes]); - - /** - * Once we have an instanceId (existing or new), load the threshold from BorealisValueBus - * We skip if we haven't assigned instanceId yet. - */ - useEffect(() => { - if (!instanceId) return; // wait for the ID to be set - - // Look for a previously saved threshold in the bus - const savedKey = `th|${instanceId}`; - let savedVal = window.BorealisValueBus[savedKey]; - if (typeof savedVal !== "number") { - // default to 128 - savedVal = 128; - } - console.log(`[ThresholdNode:${id}] init threshold from bus key=${savedKey} => ${savedVal}`); - setThreshold(savedVal); - }, [id, instanceId]); - - /** - * Threshold slider handle - */ - const handleSliderChange = (e) => { - const newVal = parseInt(e.target.value, 10); - setThreshold(newVal); - console.log(`[ThresholdNode:${id}] Slider => ${newVal}`); - - // Immediately store in BorealisValueBus - if (instanceId) { - const savedKey = `th|${instanceId}`; - window.BorealisValueBus[savedKey] = newVal; - } + setThreshold(val); + window.BorealisValueBus[id] = val; }; - /** - * Helper function to apply threshold to base64 - */ const applyThreshold = async (base64Data, cutoff) => { - if (!base64Data || typeof base64Data !== "string") { - return ""; - } + if (!base64Data || typeof base64Data !== "string") return ""; + return new Promise((resolve) => { const img = new Image(); img.crossOrigin = "anonymous"; @@ -177,59 +50,51 @@ const PersistentThresholdNode = ({ id, data }) => { dataArr[i + 1] = color; dataArr[i + 2] = color; } - ctx.putImageData(imageData, 0, 0); - resolve(canvas.toDataURL("image/png")); + ctx.putImageData(imageData, 0, 0); + resolve(canvas.toDataURL("image/png").replace(/^data:image\/png;base64,/, "")); }; img.onerror = () => resolve(base64Data); - img.src = base64Data; + img.src = `data:image/png;base64,${base64Data}`; }); }; - /** - * Main logic loop (polling) - */ + // Main polling logic useEffect(() => { - let currentRate = window.BorealisUpdateRate || 100; + let currentRate = window.BorealisUpdateRate; let intervalId = null; const runNodeLogic = async () => { - // find upstream edge - const inputEdge = edges.find((e) => e.target === id); - if (inputEdge && inputEdge.source) { + const inputEdge = edges.find(e => e.target === id); + if (inputEdge?.source) { const upstreamValue = window.BorealisValueBus[inputEdge.source] ?? ""; - if (upstreamValue !== valueRef.current) { - const thresholded = await applyThreshold(upstreamValue, threshold); - valueRef.current = thresholded; - setRenderValue(thresholded); - window.BorealisValueBus[id] = thresholded; + + if (upstreamValue !== lastUpstreamRef.current) { + const transformed = await applyThreshold(upstreamValue, threshold); + lastUpstreamRef.current = upstreamValue; + valueRef.current = transformed; + setRenderValue(transformed); + window.BorealisValueBus[id] = transformed; } } else { - // No upstream - if (valueRef.current) { - console.log(`[ThresholdNode:${id}] no upstream => clear`); - } + lastUpstreamRef.current = ""; valueRef.current = ""; setRenderValue(""); window.BorealisValueBus[id] = ""; } }; - const startInterval = () => { - intervalId = setInterval(runNodeLogic, currentRate); - }; - startInterval(); + intervalId = setInterval(runNodeLogic, currentRate); - // watch for update rate changes const monitor = setInterval(() => { - const newRate = window.BorealisUpdateRate || 100; + const newRate = window.BorealisUpdateRate; if (newRate !== currentRate) { - currentRate = newRate; clearInterval(intervalId); - startInterval(); + currentRate = newRate; + intervalId = setInterval(runNodeLogic, currentRate); } - }, 500); + }, 250); return () => { clearInterval(intervalId); @@ -237,67 +102,65 @@ const PersistentThresholdNode = ({ id, data }) => { }; }, [id, edges, threshold]); - /** - * If threshold changes, re-apply to upstream immediately (if we have upstream) - */ + // Reapply when threshold changes (even if image didn't) useEffect(() => { - const inputEdge = edges.find((e) => e.target === id); - if (!inputEdge) { - return; - } - const upstreamVal = window.BorealisValueBus[inputEdge.source] ?? ""; - if (!upstreamVal) { - return; - } - applyThreshold(upstreamVal, threshold).then((transformed) => { - valueRef.current = transformed; - setRenderValue(transformed); - window.BorealisValueBus[id] = transformed; + const inputEdge = edges.find(e => e.target === id); + if (!inputEdge?.source) return; + + const upstreamValue = window.BorealisValueBus[inputEdge.source] ?? ""; + if (!upstreamValue) return; + + applyThreshold(upstreamValue, threshold).then((result) => { + valueRef.current = result; + setRenderValue(result); + window.BorealisValueBus[id] = result; }); }, [threshold, edges, id]); - // Render the node return (
-
- Threshold Node -
+ +
BW Threshold
- Node ID: {id}
- Instance ID: {instanceId || "(none)"}
- (Slider persists across re-mount if data._thresholdId is preserved) + Threshold Strength (0–255):
- -
+
); }; -// Export as a React Flow Node export default { - type: "PersistentThresholdNode", - label: "Persistent Threshold Node", + type: "BWThresholdNode", + label: "BW Threshold", description: ` -Stores a unique ID in node.data._thresholdId and uses it to track the slider threshold -in BorealisValueBus, so the slider doesn't reset if the node is re-mounted with the same data. -`, - content: "Convert incoming base64 image to black & white thresholded image, with node-level persistence.", - component: PersistentThresholdNode +Black & White Threshold (Stateless) + +- Converts a base64 image to black & white using a user-defined threshold value +- Reapplies threshold when the number changes, even if image stays the same +- Outputs a new base64 PNG with BW transformation +`.trim(), + content: "Applies black & white threshold to base64 image input.", + component: BWThresholdNode }; diff --git a/Data/WebUI/src/nodes/Image Processing/Node_Convert_to_Grayscale.jsx b/Data/WebUI/src/nodes/Image Processing/Node_Convert_to_Grayscale.jsx new file mode 100644 index 0000000..88ec374 --- /dev/null +++ b/Data/WebUI/src/nodes/Image Processing/Node_Convert_to_Grayscale.jsx @@ -0,0 +1,133 @@ +import React, { useEffect, useState, useRef } from "react"; +import { Handle, Position, useStore } from "reactflow"; + +if (!window.BorealisValueBus) window.BorealisValueBus = {}; +if (!window.BorealisUpdateRate) window.BorealisUpdateRate = 100; + +const GrayscaleNode = ({ id }) => { + const edges = useStore((state) => state.edges); + const [grayscaleLevel, setGrayscaleLevel] = useState(100); // percentage (0–100) + const [renderValue, setRenderValue] = useState(""); + const valueRef = useRef(""); + + const applyGrayscale = (base64Data, level) => { + return new Promise((resolve) => { + const img = new Image(); + img.crossOrigin = "anonymous"; + + img.onload = () => { + const canvas = document.createElement("canvas"); + canvas.width = img.width; + canvas.height = img.height; + const ctx = canvas.getContext("2d"); + ctx.drawImage(img, 0, 0); + + const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height); + const data = imageData.data; + const alpha = level / 100; + + for (let i = 0; i < data.length; i += 4) { + const r = data[i]; + const g = data[i + 1]; + const b = data[i + 2]; + const avg = (r + g + b) / 3; + + data[i] = r * (1 - alpha) + avg * alpha; + data[i + 1] = g * (1 - alpha) + avg * alpha; + data[i + 2] = b * (1 - alpha) + avg * alpha; + } + + ctx.putImageData(imageData, 0, 0); + resolve(canvas.toDataURL("image/png").replace(/^data:image\/png;base64,/, "")); + }; + + img.onerror = () => resolve(base64Data); + img.src = `data:image/png;base64,${base64Data}`; + }); + }; + + useEffect(() => { + const inputEdge = edges.find(e => e.target === id); + if (!inputEdge?.source) return; + + const input = window.BorealisValueBus[inputEdge.source] ?? ""; + if (!input) return; + + applyGrayscale(input, grayscaleLevel).then((output) => { + valueRef.current = input; + setRenderValue(output); + window.BorealisValueBus[id] = output; + }); + }, [grayscaleLevel, edges, id]); + + useEffect(() => { + let interval = null; + + const run = async () => { + const edge = edges.find(e => e.target === id); + const input = edge ? window.BorealisValueBus[edge.source] : ""; + + if (input && input !== valueRef.current) { + const result = await applyGrayscale(input, grayscaleLevel); + valueRef.current = input; + setRenderValue(result); + window.BorealisValueBus[id] = result; + } + }; + + interval = setInterval(run, window.BorealisUpdateRate); + return () => clearInterval(interval); + }, [id, edges, grayscaleLevel]); + + const handleLevelChange = (e) => { + let val = parseInt(e.target.value, 10); + if (isNaN(val)) val = 100; + val = Math.min(100, Math.max(0, val)); + setGrayscaleLevel(val); + }; + + return ( +
+ +
Convert to Grayscale
+
+ + +
+ +
+ ); +}; + +export default { + type: "GrayscaleNode", + label: "Convert to Grayscale", + description: ` +Adjustable Grayscale Conversion + +- Accepts base64 image input +- Applies grayscale effect using a % level +- 0% = no change, 100% = full grayscale +- Outputs result downstream as base64 +`.trim(), + content: "Convert image to grayscale with adjustable intensity.", + component: GrayscaleNode +}; diff --git a/Data/WebUI/src/nodes/Image Processing/Node_Export_Image.jsx b/Data/WebUI/src/nodes/Image Processing/Node_Export_Image.jsx new file mode 100644 index 0000000..1070e3b --- /dev/null +++ b/Data/WebUI/src/nodes/Image Processing/Node_Export_Image.jsx @@ -0,0 +1,88 @@ +import React, { useEffect, useState } from "react"; +import { Handle, Position, useReactFlow } from "reactflow"; + +const ExportImageNode = ({ id }) => { + const { getEdges } = useReactFlow(); + const [imageData, setImageData] = useState(""); + + useEffect(() => { + const interval = setInterval(() => { + const edges = getEdges(); + const inputEdge = edges.find(e => e.target === id); + if (inputEdge) { + const base64 = window.BorealisValueBus?.[inputEdge.source]; + if (typeof base64 === "string") { + setImageData(base64); + } + } + }, 1000); + return () => clearInterval(interval); + }, [id, getEdges]); + + const handleDownload = async () => { + const blob = await (async () => { + const res = await fetch(`data:image/png;base64,${imageData}`); + return await res.blob(); + })(); + + if (window.showSaveFilePicker) { + try { + const fileHandle = await window.showSaveFilePicker({ + suggestedName: "image.png", + types: [{ + description: "PNG Image", + accept: { "image/png": [".png"] } + }] + }); + const writable = await fileHandle.createWritable(); + await writable.write(blob); + await writable.close(); + } catch (e) { + console.warn("Save cancelled:", e); + } + } else { + const a = document.createElement("a"); + a.href = URL.createObjectURL(blob); + a.download = "image.png"; + a.style.display = "none"; + document.body.appendChild(a); + a.click(); + URL.revokeObjectURL(a.href); + document.body.removeChild(a); + } + }; + + return ( +
+ +
Export Image
+
+ Export upstream base64-encoded image data as a PNG on-disk. + +
+
+ ); +}; + +export default { + type: "ExportImageNode", + label: "Export Image", + description: "Lets the user download the base64 PNG image to disk.", + content: "Save base64 PNG to disk as a file.", + component: ExportImageNode +}; diff --git a/Data/WebUI/src/nodes/Image Processing/Node_Image_Viewer.jsx b/Data/WebUI/src/nodes/Image Processing/Node_Image_Viewer.jsx index e0197d4..ba551c3 100644 --- a/Data/WebUI/src/nodes/Image Processing/Node_Image_Viewer.jsx +++ b/Data/WebUI/src/nodes/Image Processing/Node_Image_Viewer.jsx @@ -1,12 +1,15 @@ import React, { useEffect, useState } from "react"; import { Handle, Position, useReactFlow } from "reactflow"; +if (!window.BorealisValueBus) window.BorealisValueBus = {}; +if (!window.BorealisUpdateRate) window.BorealisUpdateRate = 100; + const ImageViewerNode = ({ id, data }) => { const { getEdges } = useReactFlow(); const [imageBase64, setImageBase64] = useState(""); const [selectedType, setSelectedType] = useState("base64"); - // Watch upstream value + // Monitor upstream input and propagate to ValueBus useEffect(() => { const interval = setInterval(() => { const edges = getEdges(); @@ -17,12 +20,48 @@ const ImageViewerNode = ({ id, data }) => { const value = valueBus[sourceId]; if (typeof value === "string") { setImageBase64(value); + window.BorealisValueBus[id] = value; } + } else { + setImageBase64(""); + window.BorealisValueBus[id] = ""; } - }, 1000); + }, window.BorealisUpdateRate || 100); + return () => clearInterval(interval); }, [id, getEdges]); + const handleDownload = async () => { + if (!imageBase64) return; + const blob = await (await fetch(`data:image/png;base64,${imageBase64}`)).blob(); + + if (window.showSaveFilePicker) { + try { + const fileHandle = await window.showSaveFilePicker({ + suggestedName: "image.png", + types: [{ + description: "PNG Image", + accept: { "image/png": [".png"] } + }] + }); + const writable = await fileHandle.createWritable(); + await writable.write(blob); + await writable.close(); + } catch (e) { + console.warn("Save cancelled:", e); + } + } else { + const a = document.createElement("a"); + a.href = URL.createObjectURL(blob); + a.download = "image.png"; + a.style.display = "none"; + document.body.appendChild(a); + a.click(); + URL.revokeObjectURL(a.href); + document.body.removeChild(a); + } + }; + return (
@@ -38,15 +77,39 @@ const ImageViewerNode = ({ id, data }) => { {imageBase64 ? ( - Live + <> + Live + + ) : ( -
Waiting for image...
+
+ Waiting for image... +
)}
+ ); }; @@ -54,7 +117,14 @@ const ImageViewerNode = ({ id, data }) => { export default { type: "Image_Viewer", label: "Image Viewer", - description: "Displays base64 image pulled from ValueBus of upstream node.", - content: "Visual preview of base64 image", + description: ` +Displays base64 image and exports it + +- Accepts upstream base64 image +- Shows preview +- Provides "Export to PNG" button +- Outputs the same base64 to downstream +`.trim(), + content: "Visual preview of base64 image with optional PNG export.", component: ImageViewerNode };