@rosepetal/node-red-contrib-utils 1.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/workflows/publish.yml +54 -0
- package/assets/nodes/io/array-in-demo.gif +0 -0
- package/assets/nodes/io/array-out-demo.gif +0 -0
- package/assets/nodes/io/array-select-demo.gif +0 -0
- package/docs/nodes/io/array-in.md +119 -0
- package/docs/nodes/io/array-out.md +150 -0
- package/docs/nodes/io/array-select.md +157 -0
- package/docs/nodes/io/queue.md +57 -0
- package/docs/nodes/io/save-file.md +30 -0
- package/docs/nodes/promise-reader/promise-reader.md +60 -0
- package/docs/nodes/util/block-detect.md +46 -0
- package/docs/nodes/util/clean-debug.md +58 -0
- package/lib/node-utils.js +429 -0
- package/nodes/io/array-in.html +110 -0
- package/nodes/io/array-in.js +113 -0
- package/nodes/io/array-out.html +125 -0
- package/nodes/io/array-out.js +213 -0
- package/nodes/io/array-select.html +156 -0
- package/nodes/io/array-select.js +187 -0
- package/nodes/io/queue.html +119 -0
- package/nodes/io/queue.js +191 -0
- package/nodes/io/save-file.html +243 -0
- package/nodes/io/save-file.js +440 -0
- package/nodes/promise-reader/promise-reader.html +100 -0
- package/nodes/promise-reader/promise-reader.js +118 -0
- package/nodes/util/block-detect.html +85 -0
- package/nodes/util/block-detect.js +136 -0
- package/nodes/util/clean-debug.html +201 -0
- package/nodes/util/clean-debug.js +393 -0
- package/package.json +55 -0
- package/readme.md +72 -0
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# Clean Debug Node
|
|
2
|
+
|
|
3
|
+
## Purpose & Use Cases
|
|
4
|
+
|
|
5
|
+
The `clean-debug` node is a drop-in replacement for the core Debug node that keeps the editor sidebar responsive by sanitizing heavy payloads (buffers, images, base64 strings) before display.
|
|
6
|
+
|
|
7
|
+
**Typical scenarios:**
|
|
8
|
+
- Inspecting image or binary flows without freezing the sidebar
|
|
9
|
+
- Logging large messages safely during development
|
|
10
|
+
- Keeping debug output readable in production flows
|
|
11
|
+
|
|
12
|
+
## Input/Output Specification
|
|
13
|
+
|
|
14
|
+
### Inputs
|
|
15
|
+
- **Incoming Message**: Any Node-RED message.
|
|
16
|
+
|
|
17
|
+
### Outputs
|
|
18
|
+
- **No message output**: The node publishes to the debug sidebar and/or console only.
|
|
19
|
+
|
|
20
|
+
## Configuration Options
|
|
21
|
+
|
|
22
|
+
### Output
|
|
23
|
+
- **Type**: Typed input (msg/flow/global/env/jsonata/etc.)
|
|
24
|
+
- **Default**: `msg.payload`
|
|
25
|
+
- **Purpose**: Selects the value to display. Choose the full message object if needed.
|
|
26
|
+
|
|
27
|
+
### Send to debug sidebar
|
|
28
|
+
- **Type**: Boolean
|
|
29
|
+
- **Default**: Enabled
|
|
30
|
+
- **Purpose**: Shows the sanitized value in the editor sidebar.
|
|
31
|
+
|
|
32
|
+
### Also log to console
|
|
33
|
+
- **Type**: Boolean
|
|
34
|
+
- **Default**: Disabled
|
|
35
|
+
- **Purpose**: Logs the value to the Node-RED runtime log.
|
|
36
|
+
|
|
37
|
+
### Clean heavy payloads
|
|
38
|
+
- **Type**: Boolean
|
|
39
|
+
- **Default**: Enabled
|
|
40
|
+
- **Purpose**: Replaces large buffers, typed arrays, data URLs, and long base64 strings with short placeholders.
|
|
41
|
+
|
|
42
|
+
### Enabled
|
|
43
|
+
- **Type**: Boolean (button toggle)
|
|
44
|
+
- **Purpose**: Temporarily disable the node without removing it from the flow.
|
|
45
|
+
|
|
46
|
+
## Behavior Notes
|
|
47
|
+
|
|
48
|
+
- Cleaning only affects what is displayed; the original message is not modified.
|
|
49
|
+
- When cleaning is disabled, the node behaves like the standard Debug node.
|
|
50
|
+
- Large arrays/objects are truncated for safety; circular references are handled gracefully.
|
|
51
|
+
|
|
52
|
+
## Example
|
|
53
|
+
|
|
54
|
+
```
|
|
55
|
+
[Image-In] -> [clean-debug: Output=msg.payload, Clean=on]
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
Use clean mode to inspect image payloads without dumping full binary data.
|
|
@@ -0,0 +1,429 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file Contains generic helper functions for the Node-RED nodes.
|
|
3
|
+
* This module is pure JavaScript and has no dependency on the RED object.
|
|
4
|
+
* @author Rosepetal
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
const sharp = require('sharp'); // Ensure sharp is installed in your project
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
module.exports = function(RED) {
|
|
11
|
+
const utils = {};
|
|
12
|
+
|
|
13
|
+
// Configuration constants
|
|
14
|
+
const CONSTANTS = {
|
|
15
|
+
DEFAULT_JPEG_QUALITY: 90,
|
|
16
|
+
DEFAULT_ARRAY_POSITION: 0,
|
|
17
|
+
SUPPORTED_DTYPES: ['uint8'],
|
|
18
|
+
SUPPORTED_COLOR_SPACES: ['GRAY', 'RGB', 'RGBA', 'BGR', 'BGRA'],
|
|
19
|
+
CHANNEL_MAP: { 'GRAY': 1, 'RGB': 3, 'RGBA': 4, 'BGR': 3, 'BGRA': 4 }
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Validates and normalizes an image structure
|
|
24
|
+
* Supports structure: {data, width, height, channels, colorSpace, dtype}
|
|
25
|
+
*/
|
|
26
|
+
utils.validateImageStructure = function(image, node) {
|
|
27
|
+
if (!image) {
|
|
28
|
+
node.warn("Input is null or undefined");
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
switch (true) {
|
|
33
|
+
// New Rosepetal bitmap structure
|
|
34
|
+
case (image.hasOwnProperty('width') &&
|
|
35
|
+
image.hasOwnProperty('height') &&
|
|
36
|
+
image.hasOwnProperty('data')):
|
|
37
|
+
|
|
38
|
+
// Validate dtype (only uint8 supported for now)
|
|
39
|
+
if (image.dtype && !CONSTANTS.SUPPORTED_DTYPES.includes(image.dtype)) {
|
|
40
|
+
node.warn(`Unsupported dtype: ${image.dtype}. Supported values: ${CONSTANTS.SUPPORTED_DTYPES.join(', ')}`);
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Infer channels if not provided
|
|
45
|
+
let channels = image.channels;
|
|
46
|
+
if (!channels) {
|
|
47
|
+
const calculatedChannels = image.data.length / (image.width * image.height);
|
|
48
|
+
if (!Number.isInteger(calculatedChannels)) {
|
|
49
|
+
node.warn(`Cannot infer channels: data.length (${image.data.length}) is not divisible by width*height (${image.width * image.height})`);
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
channels = calculatedChannels;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Validate channels is a number
|
|
56
|
+
if (typeof channels !== 'number') {
|
|
57
|
+
node.warn(`Channels must be a number, got: ${typeof channels}`);
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Validate data length matches dimensions
|
|
62
|
+
if (image.width * image.height * channels !== image.data.length) {
|
|
63
|
+
node.warn(`Data length mismatch: expected ${image.width * image.height * channels} bytes (${image.width}x${image.height}x${channels}), got ${image.data.length} bytes`);
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Handle colorSpace with defaults
|
|
68
|
+
let colorSpace = image.colorSpace;
|
|
69
|
+
if (!colorSpace) {
|
|
70
|
+
// Default based on channel count
|
|
71
|
+
switch (channels) {
|
|
72
|
+
case 1: colorSpace = "GRAY"; break;
|
|
73
|
+
case 3: colorSpace = "RGB"; break;
|
|
74
|
+
case 4: colorSpace = "RGBA"; break;
|
|
75
|
+
default:
|
|
76
|
+
node.warn(`Cannot determine default colorSpace for ${channels} channels`);
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Validate colorSpace matches channel count
|
|
82
|
+
if (!CONSTANTS.CHANNEL_MAP.hasOwnProperty(colorSpace)) {
|
|
83
|
+
node.warn(`Unsupported colorSpace: ${colorSpace}. Supported values: ${CONSTANTS.SUPPORTED_COLOR_SPACES.join(', ')}`);
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (CONSTANTS.CHANNEL_MAP[colorSpace] !== channels) {
|
|
88
|
+
node.warn(`ColorSpace mismatch: ${colorSpace} expects ${CONSTANTS.CHANNEL_MAP[colorSpace]} channels, got ${channels} channels`);
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Return normalized structure
|
|
93
|
+
return {
|
|
94
|
+
data: image.data,
|
|
95
|
+
width: image.width,
|
|
96
|
+
height: image.height,
|
|
97
|
+
channels: channels,
|
|
98
|
+
colorSpace: colorSpace,
|
|
99
|
+
dtype: image.dtype || 'uint8'
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
default:
|
|
103
|
+
// Not our format? Pass to C++ and let OpenCV handle it
|
|
104
|
+
return image;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
utils.validateSingleImage = function(image, node) {
|
|
109
|
+
const normalized = utils.validateImageStructure(image, node);
|
|
110
|
+
return normalized !== null;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
utils.validateListImage = function(list, node) {
|
|
114
|
+
if (!list || !Array.isArray(list) || list.length === 0) {
|
|
115
|
+
node.warn("Input is not a valid image list. Expected a non-empty Array.");
|
|
116
|
+
return false;
|
|
117
|
+
}
|
|
118
|
+
// Validate each item in the list
|
|
119
|
+
for (const image of list) {
|
|
120
|
+
if (!utils.validateSingleImage(image, node)) {
|
|
121
|
+
// The warning message is already sent by validateSingleImage
|
|
122
|
+
return false;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
return true;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
utils.resolveDimension = function(node, type, value, msg) {
|
|
129
|
+
if (!value || String(value).trim() === '') return null;
|
|
130
|
+
let resolvedValue;
|
|
131
|
+
if (type === 'msg' || type === 'flow' || type === 'global') {
|
|
132
|
+
resolvedValue = RED.util.evaluateNodeProperty(value, type, node, msg);
|
|
133
|
+
} else {
|
|
134
|
+
resolvedValue = value;
|
|
135
|
+
}
|
|
136
|
+
const numericValue = parseFloat(resolvedValue);
|
|
137
|
+
if (numericValue === undefined || isNaN(numericValue)) {
|
|
138
|
+
throw new Error(`Value "${resolvedValue}" from property "${value}" is not a valid number.`);
|
|
139
|
+
}
|
|
140
|
+
return numericValue;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Resolves array position value from various sources (msg, flow, global, or direct value)
|
|
145
|
+
* @param {Object} node - The Node-RED node instance
|
|
146
|
+
* @param {string} type - The type of source ('msg', 'flow', 'global', or 'num')
|
|
147
|
+
* @param {*} value - The value or property path to resolve
|
|
148
|
+
* @param {Object} msg - The message object for msg property resolution
|
|
149
|
+
* @returns {number} The resolved array position as a non-negative integer
|
|
150
|
+
* @throws {Error} If the resolved value is not a valid non-negative integer
|
|
151
|
+
*/
|
|
152
|
+
utils.resolveArrayPosition = function(node, type, value, msg) {
|
|
153
|
+
if (value === null || value === undefined || String(value).trim() === '') {
|
|
154
|
+
return CONSTANTS.DEFAULT_ARRAY_POSITION;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
let resolvedValue;
|
|
158
|
+
if (type === 'msg' || type === 'flow' || type === 'global') {
|
|
159
|
+
resolvedValue = RED.util.evaluateNodeProperty(value, type, node, msg);
|
|
160
|
+
} else {
|
|
161
|
+
resolvedValue = value;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const numericValue = parseInt(resolvedValue);
|
|
165
|
+
if (numericValue === undefined || isNaN(numericValue) || numericValue < 0) {
|
|
166
|
+
throw new Error(`Array position "${resolvedValue}" from property "${value}" must be a non-negative integer.`);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
return numericValue;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
utils.rawToJpeg = async function (image, quality = CONSTANTS.DEFAULT_JPEG_QUALITY) {
|
|
173
|
+
const normalized = utils.validateImageStructure(image, { warn: () => {} });
|
|
174
|
+
if (!normalized)
|
|
175
|
+
throw new Error('Invalid raw image object supplied to rawToJpeg');
|
|
176
|
+
|
|
177
|
+
const colorSpace = normalized.colorSpace;
|
|
178
|
+
const channels = normalized.channels;
|
|
179
|
+
let data = normalized.data;
|
|
180
|
+
|
|
181
|
+
// BGR/BGRA → RGB/RGBA for Sharp
|
|
182
|
+
if (colorSpace === 'BGR' || colorSpace === 'BGRA') {
|
|
183
|
+
data = Buffer.from(data); // copy so we don't mutate shared memory
|
|
184
|
+
for (let i = 0; i < data.length; i += channels) {
|
|
185
|
+
const t = data[i];
|
|
186
|
+
data[i] = data[i + 2];
|
|
187
|
+
data[i + 2] = t;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const sh = sharp(data, {
|
|
192
|
+
raw: { width: normalized.width, height: normalized.height, channels }
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
if (colorSpace === 'GRAY') sh.toColourspace('b-w');
|
|
196
|
+
|
|
197
|
+
return sh.jpeg({ quality }).toBuffer();
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Standardized error handling for all nodes
|
|
202
|
+
* @param {object} node - Node-RED node instance
|
|
203
|
+
* @param {Error} error - The error that occurred
|
|
204
|
+
* @param {object} msg - The message object
|
|
205
|
+
* @param {function} done - The done callback
|
|
206
|
+
* @param {string} operation - Optional operation name for context
|
|
207
|
+
*/
|
|
208
|
+
utils.handleNodeError = function(node, error, msg, done, operation = 'processing') {
|
|
209
|
+
const errorMessage = error.message || `Unknown error during ${operation}.`;
|
|
210
|
+
node.error(errorMessage, msg);
|
|
211
|
+
node.status({ fill: "red", shape: "ring", text: "Error" });
|
|
212
|
+
if (done) {
|
|
213
|
+
done(error);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Standardized success status formatting
|
|
219
|
+
* @param {object} node - Node-RED node instance
|
|
220
|
+
* @param {number} count - Number of items processed
|
|
221
|
+
* @param {number} totalTime - Total processing time in ms
|
|
222
|
+
* @param {object} timing - Timing breakdown object
|
|
223
|
+
*/
|
|
224
|
+
utils.setSuccessStatus = function(node, count, totalTime, timing = {}) {
|
|
225
|
+
const { convertMs = 0, taskMs = 0, encodeMs = 0 } = timing;
|
|
226
|
+
node.status({
|
|
227
|
+
fill: 'green',
|
|
228
|
+
shape: 'dot',
|
|
229
|
+
text: `OK: ${count} img in ${totalTime.toFixed(2)} ms ` +
|
|
230
|
+
`(conv ${(convertMs + encodeMs).toFixed(2)} ms | ` +
|
|
231
|
+
`task ${taskMs.toFixed(2)} ms)`
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Debug image display utility for inline node debugging
|
|
237
|
+
* Converts images to displayable format and returns data URL with format message
|
|
238
|
+
* @param {object|Buffer} image - Image data (raw image object or encoded buffer)
|
|
239
|
+
* @param {string} outputFormat - The output format selected ('raw', 'jpg', 'png', 'webp')
|
|
240
|
+
* @param {number} quality - JPEG quality for raw conversion (default: 90)
|
|
241
|
+
* @param {object} node - Node-RED node instance for error reporting
|
|
242
|
+
* @param {boolean} debugEnabled - Whether debugging is enabled
|
|
243
|
+
* @param {number} debugWidth - Desired width for debug image display (default: 200)
|
|
244
|
+
* @returns {Promise<object|null>} Debug result object with dataUrl and formatMessage, or null if disabled
|
|
245
|
+
*/
|
|
246
|
+
utils.debugImageDisplay = async function(image, outputFormat, quality, node, debugEnabled, debugWidth) {
|
|
247
|
+
if (!debugEnabled) return null;
|
|
248
|
+
|
|
249
|
+
// Validate and set default debug width
|
|
250
|
+
debugWidth = Math.max(1, parseInt(debugWidth) || 200);
|
|
251
|
+
|
|
252
|
+
try {
|
|
253
|
+
let imageBuffer, formatMessage;
|
|
254
|
+
|
|
255
|
+
if (outputFormat === 'raw') {
|
|
256
|
+
// Convert raw image to JPG for display using existing utility
|
|
257
|
+
imageBuffer = await utils.rawToJpeg(image, quality || CONSTANTS.DEFAULT_JPEG_QUALITY);
|
|
258
|
+
formatMessage = 'jpg default';
|
|
259
|
+
} else {
|
|
260
|
+
// Reuse existing converted buffer
|
|
261
|
+
imageBuffer = Buffer.isBuffer(image) ? image : image.data;
|
|
262
|
+
formatMessage = outputFormat; // 'jpg', 'png', 'webp'
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
if (!Buffer.isBuffer(imageBuffer)) {
|
|
266
|
+
node.warn('Debug display: Invalid image buffer format');
|
|
267
|
+
return null;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// Resize image for debug display using Sharp
|
|
271
|
+
let actualWidth = debugWidth;
|
|
272
|
+
let actualHeight = debugWidth; // Default fallback
|
|
273
|
+
try {
|
|
274
|
+
let sharpInstance = sharp(imageBuffer)
|
|
275
|
+
.resize(debugWidth, null, {
|
|
276
|
+
withoutEnlargement: false,
|
|
277
|
+
fit: 'inside'
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
// Only convert format if output is raw, otherwise preserve the existing format
|
|
281
|
+
if (outputFormat === 'raw') {
|
|
282
|
+
// For raw format, convert to JPEG for display
|
|
283
|
+
sharpInstance = sharpInstance.jpeg({ quality: quality || 90 });
|
|
284
|
+
formatMessage = 'jpg default';
|
|
285
|
+
} else {
|
|
286
|
+
// For other formats (jpg, png, webp), just resize without format conversion
|
|
287
|
+
// The C++ backend has already converted to the desired format
|
|
288
|
+
formatMessage = outputFormat + ' resized';
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
imageBuffer = await sharpInstance.toBuffer();
|
|
292
|
+
|
|
293
|
+
// Get actual dimensions of resized image
|
|
294
|
+
const metadata = await sharp(imageBuffer).metadata();
|
|
295
|
+
actualWidth = metadata.width || debugWidth;
|
|
296
|
+
actualHeight = metadata.height || debugWidth;
|
|
297
|
+
|
|
298
|
+
} catch (resizeError) {
|
|
299
|
+
node.warn(`Debug image resize error: ${resizeError.message}`);
|
|
300
|
+
// Continue with original image if resize fails
|
|
301
|
+
// Try to get original dimensions as fallback
|
|
302
|
+
try {
|
|
303
|
+
const originalMetadata = await sharp(imageBuffer).metadata();
|
|
304
|
+
actualWidth = originalMetadata.width || debugWidth;
|
|
305
|
+
actualHeight = originalMetadata.height || debugWidth;
|
|
306
|
+
} catch (metadataError) {
|
|
307
|
+
// Use debug width as fallback
|
|
308
|
+
actualWidth = debugWidth;
|
|
309
|
+
actualHeight = debugWidth;
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// Convert to base64 for WebSocket transmission
|
|
314
|
+
const base64 = imageBuffer.toString('base64');
|
|
315
|
+
const mimeType = formatMessage.replace(' default', '').replace(' resized', '');
|
|
316
|
+
const dataUrl = `data:image/${mimeType};base64,${base64}`;
|
|
317
|
+
|
|
318
|
+
// Send image to frontend via WebSocket for inline display
|
|
319
|
+
try {
|
|
320
|
+
RED.comms.publish("debug-image", {
|
|
321
|
+
id: node.id,
|
|
322
|
+
data: base64,
|
|
323
|
+
format: formatMessage,
|
|
324
|
+
mimeType: mimeType,
|
|
325
|
+
size: imageBuffer.length,
|
|
326
|
+
debugWidth: actualWidth,
|
|
327
|
+
debugHeight: actualHeight
|
|
328
|
+
});
|
|
329
|
+
} catch (wsError) {
|
|
330
|
+
node.warn(`Debug WebSocket error: ${wsError.message}`);
|
|
331
|
+
// Continue anyway - status display will still work
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
return {
|
|
335
|
+
dataUrl: dataUrl,
|
|
336
|
+
formatMessage: formatMessage,
|
|
337
|
+
size: imageBuffer.length
|
|
338
|
+
};
|
|
339
|
+
|
|
340
|
+
} catch (error) {
|
|
341
|
+
node.warn(`Debug display error: ${error.message}`);
|
|
342
|
+
return null;
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/**
|
|
347
|
+
* Enhanced success status formatting with debug information
|
|
348
|
+
* @param {object} node - Node-RED node instance
|
|
349
|
+
* @param {number} count - Number of items processed
|
|
350
|
+
* @param {number} totalTime - Total processing time in ms
|
|
351
|
+
* @param {object} timing - Timing breakdown object
|
|
352
|
+
* @param {string} debugFormat - Optional debug format message to include in status
|
|
353
|
+
*/
|
|
354
|
+
utils.setSuccessStatusWithDebug = function(node, count, totalTime, timing = {}, debugFormat = null) {
|
|
355
|
+
const { convertMs = 0, taskMs = 0, encodeMs = 0 } = timing;
|
|
356
|
+
let statusText = `OK: ${count} img in ${totalTime.toFixed(2)} ms ` +
|
|
357
|
+
`(conv ${(convertMs + encodeMs).toFixed(2)} ms | ` +
|
|
358
|
+
`task ${taskMs.toFixed(2)} ms)`;
|
|
359
|
+
|
|
360
|
+
if (debugFormat) {
|
|
361
|
+
statusText += ` | ${debugFormat}`;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
node.status({
|
|
365
|
+
fill: 'green',
|
|
366
|
+
shape: 'dot',
|
|
367
|
+
text: statusText
|
|
368
|
+
});
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/**
|
|
372
|
+
* Normalizes the performance metric key based on the node's display name.
|
|
373
|
+
* Falls back to the node type when the custom name is missing.
|
|
374
|
+
* @param {object} node - Node-RED node instance
|
|
375
|
+
* @returns {string} sanitized key
|
|
376
|
+
*/
|
|
377
|
+
utils.getPerformanceKey = function(node) {
|
|
378
|
+
const base = (node && node.name && String(node.name).trim()) ||
|
|
379
|
+
(node && node.type) ||
|
|
380
|
+
'node';
|
|
381
|
+
return String(base)
|
|
382
|
+
.trim()
|
|
383
|
+
.toLowerCase()
|
|
384
|
+
.replace(/[^a-z0-9]+/g, '_')
|
|
385
|
+
.replace(/^_+|_+$/g, '') || 'node';
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/**
|
|
389
|
+
* Records performance metrics on the outgoing message under
|
|
390
|
+
* msg.performance.rpimage.{nodeNameKey}.
|
|
391
|
+
* @param {object} node - Node-RED node instance
|
|
392
|
+
* @param {object} msg - The message object being emitted
|
|
393
|
+
* @param {object} timings - Timing breakdown { convertMs, encodeMs, taskMs, conversion, task }
|
|
394
|
+
* @param {number|null} totalTime - Total processing time in ms
|
|
395
|
+
*/
|
|
396
|
+
utils.recordPerformanceMetrics = function(node, msg, timings = {}, totalTime = null) {
|
|
397
|
+
if (!msg || !node) {
|
|
398
|
+
return;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
const key = utils.getPerformanceKey(node);
|
|
402
|
+
const path = `performance.rpimage.${key}`;
|
|
403
|
+
|
|
404
|
+
const convertMs = typeof timings.conversion === 'number'
|
|
405
|
+
? timings.conversion
|
|
406
|
+
: (typeof timings.convertMs === 'number' ? timings.convertMs : 0) +
|
|
407
|
+
(typeof timings.encodeMs === 'number' ? timings.encodeMs : 0);
|
|
408
|
+
const taskMs = typeof timings.task === 'number'
|
|
409
|
+
? timings.task
|
|
410
|
+
: (typeof timings.taskMs === 'number' ? timings.taskMs : null);
|
|
411
|
+
const totalMs = typeof totalTime === 'number'
|
|
412
|
+
? totalTime
|
|
413
|
+
: (typeof timings.total === 'number' ? timings.total : null);
|
|
414
|
+
|
|
415
|
+
const payload = {
|
|
416
|
+
conversion: Number.isFinite(convertMs) ? convertMs : null,
|
|
417
|
+
task: Number.isFinite(taskMs) ? taskMs : null,
|
|
418
|
+
total: Number.isFinite(totalMs) ? totalMs : null
|
|
419
|
+
};
|
|
420
|
+
|
|
421
|
+
try {
|
|
422
|
+
RED.util.setMessageProperty(msg, path, payload, true);
|
|
423
|
+
} catch (err) {
|
|
424
|
+
node.warn(`Failed to record performance metrics: ${err.message}`);
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
return utils;
|
|
429
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
<script type="text/javascript">
|
|
2
|
+
RED.nodes.registerType('rp-array-in', {
|
|
3
|
+
category: 'RP Utils',
|
|
4
|
+
color: '#87CEEB',
|
|
5
|
+
defaults: {
|
|
6
|
+
name: { value: "" },
|
|
7
|
+
inputPath: { value: "payload" },
|
|
8
|
+
inputPathType: { value: "msg" },
|
|
9
|
+
arrayPositionType: { value: "num" },
|
|
10
|
+
arrayPositionValue: { value: 0 }
|
|
11
|
+
},
|
|
12
|
+
inputs: 1,
|
|
13
|
+
outputs: 1,
|
|
14
|
+
icon: "font-awesome/fa-sign-in",
|
|
15
|
+
label: function() {
|
|
16
|
+
if (this.name) return this.name;
|
|
17
|
+
|
|
18
|
+
const posDisplay = this.arrayPositionType === 'num'
|
|
19
|
+
? this.arrayPositionValue
|
|
20
|
+
: `${this.arrayPositionType}.${this.arrayPositionValue}`;
|
|
21
|
+
return `Array In [${posDisplay}]`;
|
|
22
|
+
},
|
|
23
|
+
oneditprepare: function() {
|
|
24
|
+
// Set up TypedInput for input path
|
|
25
|
+
$("#node-input-inputPath").typedInput({
|
|
26
|
+
default: 'msg',
|
|
27
|
+
types: ['msg', 'flow', 'global'],
|
|
28
|
+
typeField: "#node-input-inputPathType"
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
// Set up TypedInput for array position
|
|
32
|
+
$("#node-input-arrayPositionValue").typedInput({
|
|
33
|
+
default: 'num',
|
|
34
|
+
types: ['num', 'msg', 'flow', 'global'],
|
|
35
|
+
typeField: "#node-input-arrayPositionType"
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
});
|
|
39
|
+
</script>
|
|
40
|
+
|
|
41
|
+
<script type="text/x-red" data-template-name="rp-array-in">
|
|
42
|
+
<div class="form-row">
|
|
43
|
+
<label for="node-input-name"><i class="fa fa-tag"></i> Name</label>
|
|
44
|
+
<input type="text" id="node-input-name" placeholder="Name">
|
|
45
|
+
</div>
|
|
46
|
+
|
|
47
|
+
<hr>
|
|
48
|
+
|
|
49
|
+
<div class="form-row">
|
|
50
|
+
<label for="node-input-inputPath"><i class="fa fa-sign-in"></i> Input from</label>
|
|
51
|
+
<input type="text" id="node-input-inputPath" style="width: 70%;">
|
|
52
|
+
<input type="hidden" id="node-input-inputPathType">
|
|
53
|
+
</div>
|
|
54
|
+
|
|
55
|
+
<div class="form-row">
|
|
56
|
+
<label for="node-input-arrayPositionValue"><i class="fa fa-sort-numeric-asc"></i> Array Position</label>
|
|
57
|
+
<input type="text" id="node-input-arrayPositionValue" style="width: 70%;" placeholder="0">
|
|
58
|
+
<input type="hidden" id="node-input-arrayPositionType">
|
|
59
|
+
</div>
|
|
60
|
+
|
|
61
|
+
<div class="form-tips">
|
|
62
|
+
<b>Tip:</b> Array position can be a fixed number (starting from 0) or dynamically resolved from msg, flow, or global context. This determines where this data will be placed in the output array.
|
|
63
|
+
</div>
|
|
64
|
+
</script>
|
|
65
|
+
|
|
66
|
+
<script type="text/x-red" data-help-name="rp-array-in">
|
|
67
|
+
<p>Collects data from a specified input path and tags it for ordered array assembly with a position index.</p>
|
|
68
|
+
|
|
69
|
+
<h3>Details</h3>
|
|
70
|
+
<p>This node is designed to work with the rp-array-out node to create ordered arrays from multiple data sources. It extracts data from the specified input path and tags it with a position index, allowing multiple rp-array-in nodes to feed into a single rp-array-out node for ordered collection.</p>
|
|
71
|
+
|
|
72
|
+
<h3>Properties</h3>
|
|
73
|
+
<dl class="message-properties">
|
|
74
|
+
<dt>Input from <span class="property-type">string</span></dt>
|
|
75
|
+
<dd>The location to read data from. You can select between <code>msg</code>, <code>flow</code>, or <code>global</code> context. Defaults to <code>msg.payload</code>.</dd>
|
|
76
|
+
<dt>Array Position <span class="property-type">number | msg | flow | global</span></dt>
|
|
77
|
+
<dd>The index position (0, 1, 2...) where this data should be placed in the final array. Can be a fixed number or dynamically resolved from message properties, flow context, or global context. Must resolve to a non-negative integer.</dd>
|
|
78
|
+
</dl>
|
|
79
|
+
|
|
80
|
+
<h3>Inputs</h3>
|
|
81
|
+
<dl class="message-properties">
|
|
82
|
+
<dt>payload <span class="property-type">any</span></dt>
|
|
83
|
+
<dd>Input message containing the data to be extracted. The actual data location is determined by the "Input from" property. The payload is only replaced when the input is set to <code>msg.payload</code>; otherwise it is left untouched.</dd>
|
|
84
|
+
</dl>
|
|
85
|
+
|
|
86
|
+
<h3>Outputs</h3>
|
|
87
|
+
<p>The node passes through the original message with added metadata for array assembly. When the input path is <code>msg.payload</code> the payload is mirrored; for all other inputs the existing payload is preserved and only the metadata is updated.</p>
|
|
88
|
+
<dl class="message-properties">
|
|
89
|
+
<dt>meta.arrayPosition <span class="property-type">number</span></dt>
|
|
90
|
+
<dd>The configured array position index.</dd>
|
|
91
|
+
<dt>meta.arrayData <span class="property-type">any</span></dt>
|
|
92
|
+
<dd>The data extracted from the specified input path.</dd>
|
|
93
|
+
</dl>
|
|
94
|
+
|
|
95
|
+
<h3>Examples</h3>
|
|
96
|
+
<ul>
|
|
97
|
+
<li><strong>Basic Array Assembly:</strong> Three rp-array-in nodes with positions 0, 1, 2 feeding into an rp-array-out will create: <code>[data0, data1, data2]</code></li>
|
|
98
|
+
<li><strong>Dynamic Positioning:</strong> Set array position from <code>msg.index</code> to dynamically determine where data should be placed based on message content</li>
|
|
99
|
+
<li><strong>Image Collection:</strong> Multiple image-in nodes → rp-array-in nodes (positions 0, 1, 2...) → rp-array-out → transform nodes that support arrays</li>
|
|
100
|
+
<li><strong>Mixed Data Sources:</strong> Combine data from different context levels (msg, flow, global) into a single ordered array</li>
|
|
101
|
+
<li><strong>Conditional Assembly:</strong> Use flow variables to set positions based on processing conditions or user preferences</li>
|
|
102
|
+
</ul>
|
|
103
|
+
|
|
104
|
+
<h3>Node Interactions</h3>
|
|
105
|
+
<p><strong>Works with:</strong> Must be used with rp-array-out node for array assembly. Can receive data from any node that outputs to the specified input path.</p>
|
|
106
|
+
<p><strong>Best Practice:</strong> Use sequential positions starting from 0, ensure each rp-array-in node connected to the same rp-array-out has a unique position.</p>
|
|
107
|
+
|
|
108
|
+
<h3>Error Handling</h3>
|
|
109
|
+
<p>If the input path contains no data or is invalid, the node will set <code>meta.arrayData</code> to <code>null</code> and display a warning.</p>
|
|
110
|
+
</script>
|