fix preview inputs and markup preview

This commit is contained in:
2026-03-27 21:34:51 -07:00
parent 66f1bca046
commit 63bdc70456
13 changed files with 501 additions and 316 deletions

View File

@@ -35,6 +35,7 @@ import {
isTrackedNodeRequestCurrent,
resolveLoadNodeChannelPath,
} from './loadNodeOutputs.js';
import { buildDefaultWidgetValues } from './nodeWidgetDefaults.js';
import {
DATA_TYPES, SOCKET_COMPATIBILITY, TYPE_COLORS, CAT_COLORS, CANVAS_COLORS,
@@ -1593,19 +1594,7 @@ function Flow() {
y: contextMenu.y,
});
// Build default widget values
const widgetValues = {};
const required = def.input.required || {};
for (const [name, spec] of Object.entries(required)) {
const [type, opts] = Array.isArray(spec) ? spec : [spec, {}];
if (DATA_TYPES.has(type)) continue;
if (type === 'BUTTON') continue;
if (Array.isArray(type)) {
widgetValues[name] = type[0]; // combo default = first option
} else {
widgetValues[name] = opts?.default ?? '';
}
}
const widgetValues = buildDefaultWidgetValues(def);
const newNodeId = String(nextIdRef.current++);
const newNode = {

View File

@@ -1533,7 +1533,7 @@ function WidgetControl({ widget, nodeId, value, widgetValues, onChange, openFile
if (type === 'STRING' && opts?.color_picker) {
const normalized = typeof val === 'string' && /^#[0-9a-fA-F]{6}$/.test(val)
? val
: 'var(--shape-default)';
: '#ff0000';
return (
<>
{!hideLabel && <label>{label}</label>}

View File

@@ -1,4 +1,13 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
getArrowGeometry,
MARKUP_DEFAULT_COLOR,
MARKUP_DEFAULT_SHAPE,
getMarkupPreviewStrokeWidth,
parseMarkupShapes,
sanitizeMarkupColor,
sanitizeMarkupShape,
} from './markupShapeGeometry.js';
function clampFraction(value) {
const numeric = Number(value);
@@ -6,83 +15,6 @@ function clampFraction(value) {
return Math.max(0, Math.min(1, numeric));
}
const SHAPE_DEFAULT_COLOR = '#ffd54f';
function sanitizeColor(color, fallback = SHAPE_DEFAULT_COLOR) {
if (typeof color !== 'string') return fallback;
const value = color.trim();
return /^#[0-9a-fA-F]{6}$/.test(value) ? value.toLowerCase() : fallback;
}
function sanitizeShape(shape, fallbackShape, fallbackColor, fallbackWidth) {
if (!shape || typeof shape !== 'object') return null;
const kind = ['line', 'rectangle', 'circle', 'arrow'].includes(shape.kind) ? shape.kind : fallbackShape;
const x1 = clampFraction(shape.x1);
const y1 = clampFraction(shape.y1);
const x2 = clampFraction(shape.x2);
const y2 = clampFraction(shape.y2);
const width = Math.max(1, Math.min(64, Math.round(Number(shape.width) || fallbackWidth || 1)));
return {
kind,
x1: Number(x1.toFixed(4)),
y1: Number(y1.toFixed(4)),
x2: Number(x2.toFixed(4)),
y2: Number(y2.toFixed(4)),
width,
color: sanitizeColor(shape.color, fallbackColor),
};
}
function parseMarkupShapes(markupShapes, fallbackShape, fallbackColor, fallbackWidth) {
if (Array.isArray(markupShapes)) {
return markupShapes
.map((shape) => sanitizeShape(shape, fallbackShape, fallbackColor, fallbackWidth))
.filter(Boolean);
}
if (typeof markupShapes !== 'string' || !markupShapes.trim()) return [];
try {
const parsed = JSON.parse(markupShapes);
if (!Array.isArray(parsed)) return [];
return parsed
.map((shape) => sanitizeShape(shape, fallbackShape, fallbackColor, fallbackWidth))
.filter(Boolean);
} catch {
return [];
}
}
function arrowPoints(shape, imageWidth, imageHeight) {
const x1 = shape.x1 * imageWidth;
const y1 = shape.y1 * imageHeight;
const x2 = shape.x2 * imageWidth;
const y2 = shape.y2 * imageHeight;
const dx = x2 - x1;
const dy = y2 - y1;
const length = Math.hypot(dx, dy) || 1;
const ux = dx / length;
const uy = dy / length;
const strokeWidth = Math.max(1, shape.width);
const headLength = Math.max(10, strokeWidth * 4);
const headWidth = Math.max(8, strokeWidth * 3);
const overlap = Math.max(1, strokeWidth * 0.75);
const shaftX = x2 - ux * Math.max(0, headLength - overlap);
const shaftY = y2 - uy * Math.max(0, headLength - overlap);
const headBaseX = x2 - ux * headLength;
const headBaseY = y2 - uy * headLength;
const px = -uy;
const py = ux;
const leftX = headBaseX + px * headWidth * 0.5;
const leftY = headBaseY + py * headWidth * 0.5;
const rightX = headBaseX - px * headWidth * 0.5;
const rightY = headBaseY - py * headWidth * 0.5;
return {
line: `${x1},${y1} ${shaftX},${shaftY}`,
head: `${x2},${y2} ${leftX},${leftY} ${rightX},${rightY}`,
};
}
function ShapeElement({ shape, imageWidth, imageHeight }) {
const x1 = shape.x1 * imageWidth;
const y1 = shape.y1 * imageHeight;
@@ -92,14 +24,14 @@ function ShapeElement({ shape, imageWidth, imageHeight }) {
const top = Math.min(y1, y2);
const width = Math.abs(x2 - x1);
const height = Math.abs(y2 - y1);
const strokeWidth = Math.max(1, shape.width);
const strokeWidth = getMarkupPreviewStrokeWidth(shape.width, imageWidth, imageHeight);
const renderShape = { ...shape, width: strokeWidth };
const common = {
fill: 'none',
stroke: shape.color,
strokeWidth,
strokeLinecap: 'round',
strokeLinecap: shape.kind === 'arrow' ? 'square' : 'round',
strokeLinejoin: 'round',
vectorEffect: 'non-scaling-stroke',
};
if (shape.kind === 'line') {
@@ -122,7 +54,7 @@ function ShapeElement({ shape, imageWidth, imageHeight }) {
);
}
const arrow = arrowPoints(shape, imageWidth, imageHeight);
const arrow = getArrowGeometry(renderShape, imageWidth, imageHeight);
return (
<>
<polyline points={arrow.line} {...common} />
@@ -151,10 +83,13 @@ export default function MarkupOverlay({
const [imageSize, setImageSize] = useState({ width: 1, height: 1 });
const normalizedShape = useMemo(
() => (['line', 'rectangle', 'circle', 'arrow'].includes(shape) ? shape : 'line'),
() => (['line', 'rectangle', 'circle', 'arrow'].includes(shape) ? shape : MARKUP_DEFAULT_SHAPE),
[shape],
);
const normalizedColor = useMemo(() => sanitizeColor(strokeColor, '#ffd54f'), [strokeColor]);
const normalizedColor = useMemo(
() => sanitizeMarkupColor(strokeColor, MARKUP_DEFAULT_COLOR),
[strokeColor],
);
const normalizedWidth = useMemo(
() => Math.max(1, Math.min(64, Math.round(Number(strokeWidth) || 3))),
[strokeWidth],
@@ -231,7 +166,7 @@ export default function MarkupOverlay({
setDrawing(false);
return;
}
const nextShape = sanitizeShape(draftShape, normalizedShape, normalizedColor, normalizedWidth);
const nextShape = sanitizeMarkupShape(draftShape, normalizedShape, normalizedColor, normalizedWidth);
setDraftShape(null);
setDrawing(false);
if (!nextShape) return;

View File

@@ -0,0 +1,98 @@
export const MARKUP_DEFAULT_SHAPE = 'arrow';
export const MARKUP_DEFAULT_COLOR = '#ff0000';
export const MARKUP_PREVIEW_REFERENCE_DIM = 512;
function clampFraction(value) {
const numeric = Number(value);
if (!Number.isFinite(numeric)) return 0;
return Math.max(0, Math.min(1, numeric));
}
export function sanitizeMarkupColor(color, fallback = MARKUP_DEFAULT_COLOR) {
if (typeof color !== 'string') return fallback;
const value = color.trim();
return /^#[0-9a-fA-F]{6}$/.test(value) ? value.toLowerCase() : fallback;
}
export function sanitizeMarkupShape(
shape,
fallbackShape = MARKUP_DEFAULT_SHAPE,
fallbackColor = MARKUP_DEFAULT_COLOR,
fallbackWidth = 3,
) {
if (!shape || typeof shape !== 'object') return null;
const kind = ['line', 'rectangle', 'circle', 'arrow'].includes(shape.kind) ? shape.kind : fallbackShape;
const x1 = clampFraction(shape.x1);
const y1 = clampFraction(shape.y1);
const x2 = clampFraction(shape.x2);
const y2 = clampFraction(shape.y2);
const width = Math.max(1, Math.min(64, Math.round(Number(shape.width) || fallbackWidth || 1)));
return {
kind,
x1: Number(x1.toFixed(4)),
y1: Number(y1.toFixed(4)),
x2: Number(x2.toFixed(4)),
y2: Number(y2.toFixed(4)),
width,
color: sanitizeMarkupColor(shape.color, fallbackColor),
};
}
export function parseMarkupShapes(
markupShapes,
fallbackShape = MARKUP_DEFAULT_SHAPE,
fallbackColor = MARKUP_DEFAULT_COLOR,
fallbackWidth = 3,
) {
if (Array.isArray(markupShapes)) {
return markupShapes
.map((shape) => sanitizeMarkupShape(shape, fallbackShape, fallbackColor, fallbackWidth))
.filter(Boolean);
}
if (typeof markupShapes !== 'string' || !markupShapes.trim()) return [];
try {
const parsed = JSON.parse(markupShapes);
if (!Array.isArray(parsed)) return [];
return parsed
.map((shape) => sanitizeMarkupShape(shape, fallbackShape, fallbackColor, fallbackWidth))
.filter(Boolean);
} catch {
return [];
}
}
export function getArrowGeometry(shape, imageWidth, imageHeight) {
const x1 = shape.x1 * imageWidth;
const y1 = shape.y1 * imageHeight;
const x2 = shape.x2 * imageWidth;
const y2 = shape.y2 * imageHeight;
const dx = x2 - x1;
const dy = y2 - y1;
const length = Math.hypot(dx, dy) || 1;
const ux = dx / length;
const uy = dy / length;
const strokeWidth = Math.max(1, shape.width);
const headLength = Math.max(10, strokeWidth * 4);
const headWidth = Math.max(8, strokeWidth * 3);
const shaftX = x2 - ux * headLength;
const shaftY = y2 - uy * headLength;
const px = -uy;
const py = ux;
const leftX = shaftX + px * headWidth * 0.5;
const leftY = shaftY + py * headWidth * 0.5;
const rightX = shaftX - px * headWidth * 0.5;
const rightY = shaftY - py * headWidth * 0.5;
return {
line: `${x1},${y1} ${shaftX},${shaftY}`,
head: `${x2},${y2} ${leftX},${leftY} ${rightX},${rightY}`,
};
}
export function getMarkupPreviewStrokeWidth(width, imageWidth, imageHeight) {
const normalizedWidth = Math.max(1, Math.round(Number(width) || 1));
const longestDim = Math.max(1, Number(imageWidth) || 0, Number(imageHeight) || 0);
const scale = Math.max(1, longestDim / MARKUP_PREVIEW_REFERENCE_DIM);
return Math.max(1, Math.round(normalizedWidth * scale));
}

View File

@@ -0,0 +1,26 @@
import { DATA_TYPES } from './constants.js';
export function getDefaultWidgetValue(spec) {
const [type, opts] = Array.isArray(spec) ? spec : [spec, {}];
if (DATA_TYPES.has(type)) return undefined;
if (type === 'BUTTON') return undefined;
if (Array.isArray(type)) {
if (typeof opts?.default === 'string' && type.includes(opts.default)) {
return opts.default;
}
return type[0];
}
return opts?.default ?? '';
}
export function buildDefaultWidgetValues(definition) {
const widgetValues = {};
const required = definition?.input?.required || {};
for (const [name, spec] of Object.entries(required)) {
const value = getDefaultWidgetValue(spec);
if (value !== undefined) {
widgetValues[name] = value;
}
}
return widgetValues;
}

View File

@@ -115,7 +115,7 @@
--crop-inset: rgba(255, 255, 255, 0.22);
/* Shape default */
--shape-default: #ffd54f;
--shape-default: #ff0000;
/* Dynamic-lookup fallbacks */
--fallback-type: #999;

View File

@@ -25,7 +25,7 @@ test('serializeExecutionGraph excludes isolated nodes from the backend prompt',
data: {
className: 'PreviewImage',
definition: {
input: { required: { field: ['DATA_FIELD', {}] }, optional: {} },
input: { required: {}, optional: { input: ['ANNOTATION_SOURCE', {}] } },
manual_trigger: false,
},
widgetValues: {},
@@ -48,7 +48,7 @@ test('serializeExecutionGraph excludes isolated nodes from the backend prompt',
source: '1',
sourceHandle: 'output::0::DATA_FIELD',
target: '2',
targetHandle: 'input::field::DATA_FIELD',
targetHandle: 'input::input::ANNOTATION_SOURCE',
},
];
@@ -61,7 +61,7 @@ test('serializeExecutionGraph excludes isolated nodes from the backend prompt',
},
'2': {
class_type: 'PreviewImage',
inputs: { field: ['1', 0] },
inputs: { input: ['1', 0] },
},
});
assert.equal('3' in prompt, false);
@@ -85,7 +85,7 @@ test('serializeExecutionGraph includes isolated preview-load nodes alongside con
data: {
className: 'PreviewImage',
definition: {
input: { required: { field: ['DATA_FIELD', {}] }, optional: {} },
input: { required: {}, optional: { input: ['ANNOTATION_SOURCE', {}] } },
manual_trigger: false,
},
widgetValues: {},
@@ -119,7 +119,7 @@ test('serializeExecutionGraph includes isolated preview-load nodes alongside con
source: '1',
sourceHandle: 'output::0::DATA_FIELD',
target: '2',
targetHandle: 'input::field::DATA_FIELD',
targetHandle: 'input::input::ANNOTATION_SOURCE',
},
];
@@ -132,7 +132,7 @@ test('serializeExecutionGraph includes isolated preview-load nodes alongside con
},
'2': {
class_type: 'PreviewImage',
inputs: { field: ['1', 0] },
inputs: { input: ['1', 0] },
},
'3': {
class_type: 'ImageDemo',
@@ -220,7 +220,7 @@ test('serializeExecutionGraph ignores group shells and resolves collapsed proxy
data: {
className: 'PreviewImage',
definition: {
input: { required: { field: ['DATA_FIELD', {}] }, optional: {} },
input: { required: {}, optional: { input: ['ANNOTATION_SOURCE', {}] } },
manual_trigger: false,
},
widgetValues: {},
@@ -233,12 +233,12 @@ test('serializeExecutionGraph ignores group shells and resolves collapsed proxy
source: '1',
sourceHandle: 'output::0::DATA_FIELD',
target: '10',
targetHandle: 'group-proxy::in::2::DATA_FIELD::input%3A%3Afield%3A%3ADATA_FIELD',
targetHandle: 'group-proxy::in::2::ANNOTATION_SOURCE::input%3A%3Ainput%3A%3AANNOTATION_SOURCE',
data: {
groupProxyOwner: '10',
groupProxyOriginal: {
target: '2',
targetHandle: 'input::field::DATA_FIELD',
targetHandle: 'input::input::ANNOTATION_SOURCE',
},
},
},
@@ -253,7 +253,7 @@ test('serializeExecutionGraph ignores group shells and resolves collapsed proxy
},
'2': {
class_type: 'PreviewImage',
inputs: { field: ['1', 0] },
inputs: { input: ['1', 0] },
},
});
assert.equal('10' in prompt, false);
@@ -365,7 +365,7 @@ test('getAutoRunnableNodes includes isolated preview-load nodes with selections'
source: '1',
sourceHandle: 'output::0::DATA_FIELD',
target: '2',
targetHandle: 'input::field::DATA_FIELD',
targetHandle: 'input::input::ANNOTATION_SOURCE',
},
];

View File

@@ -0,0 +1,67 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
MARKUP_DEFAULT_COLOR,
MARKUP_DEFAULT_SHAPE,
getArrowGeometry,
getMarkupPreviewStrokeWidth,
sanitizeMarkupColor,
sanitizeMarkupShape,
} from '../src/markupShapeGeometry.js';
test('markup defaults use arrow and red', () => {
assert.equal(MARKUP_DEFAULT_SHAPE, 'arrow');
assert.equal(MARKUP_DEFAULT_COLOR, '#ff0000');
assert.equal(sanitizeMarkupColor(undefined), '#ff0000');
});
test('sanitizeMarkupShape falls back to arrow and red', () => {
assert.deepEqual(
sanitizeMarkupShape(
{
kind: 'triangle',
x1: 0.1,
y1: 0.2,
x2: 0.9,
y2: 0.8,
width: 5,
color: 'not-a-color',
},
MARKUP_DEFAULT_SHAPE,
MARKUP_DEFAULT_COLOR,
3,
),
{
kind: 'arrow',
x1: 0.1,
y1: 0.2,
x2: 0.9,
y2: 0.8,
width: 5,
color: '#ff0000',
},
);
});
test('getArrowGeometry keeps the shaft at the head base with no rounded overlap', () => {
const arrow = getArrowGeometry(
{
x1: 0,
y1: 0,
x2: 1,
y2: 0,
width: 4,
},
100,
100,
);
assert.equal(arrow.line, '0,0 84,0');
assert.equal(arrow.head, '100,0 84,6 84,-6');
});
test('getMarkupPreviewStrokeWidth matches backend preview scaling', () => {
assert.equal(getMarkupPreviewStrokeWidth(10, 256, 256), 10);
assert.equal(getMarkupPreviewStrokeWidth(10, 1024, 768), 20);
});

View File

@@ -0,0 +1,31 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { buildDefaultWidgetValues, getDefaultWidgetValue } from '../src/nodeWidgetDefaults.js';
test('enum widget defaults honor opts.default instead of the first option', () => {
assert.equal(
getDefaultWidgetValue([['line', 'rectangle', 'circle', 'arrow'], { default: 'arrow' }]),
'arrow',
);
});
test('buildDefaultWidgetValues keeps non-data required widget defaults', () => {
assert.deepEqual(
buildDefaultWidgetValues({
input: {
required: {
input: ['ANNOTATION_SOURCE', { label: 'Input' }],
shape: [['line', 'rectangle', 'circle', 'arrow'], { default: 'arrow' }],
stroke_color: ['STRING', { default: '#ff0000', color_picker: true }],
stroke_width: ['INT', { default: 3 }],
},
},
}),
{
shape: 'arrow',
stroke_color: '#ff0000',
stroke_width: 3,
},
);
});