diff --git a/src/components/curve/CurveEditor.vue b/src/components/curve/CurveEditor.vue index ea8d216c91b..b623da9a3e8 100644 --- a/src/components/curve/CurveEditor.vue +++ b/src/components/curve/CurveEditor.vue @@ -89,7 +89,7 @@ import { cn } from '@/utils/tailwindUtil' import type { CurveInterpolation, CurvePoint } from './types' -import { histogramToPath } from './curveUtils' +import { histogramToPath } from '@/utils/histogramUtil' const { curveColor = 'white', diff --git a/src/components/curve/curveUtils.test.ts b/src/components/curve/curveUtils.test.ts index e959651f81a..a20852d1f10 100644 --- a/src/components/curve/curveUtils.test.ts +++ b/src/components/curve/curveUtils.test.ts @@ -5,8 +5,7 @@ import type { CurvePoint } from './types' import { createLinearInterpolator, createMonotoneInterpolator, - curvesToLUT, - histogramToPath + curvesToLUT } from './curveUtils' describe('createMonotoneInterpolator', () => { @@ -164,37 +163,3 @@ describe('curvesToLUT', () => { } }) }) - -describe('histogramToPath', () => { - it('returns empty string for empty histogram', () => { - expect(histogramToPath(new Uint32Array(0))).toBe('') - }) - - it('returns empty string when all bins are zero', () => { - expect(histogramToPath(new Uint32Array(256))).toBe('') - }) - - it('returns a closed SVG path for valid histogram', () => { - const histogram = new Uint32Array(256) - for (let i = 0; i < 256; i++) histogram[i] = i + 1 - const path = histogramToPath(histogram) - expect(path).toMatch(/^M0,1/) - expect(path).toMatch(/L1,1 Z$/) - }) - - it('normalizes using 99.5th percentile to suppress outliers', () => { - const histogram = new Uint32Array(256) - for (let i = 0; i < 256; i++) histogram[i] = 100 - histogram[255] = 100000 - const path = histogramToPath(histogram) - // Most bins should map to y=0 (1 - 100/100 = 0) since - // the 99.5th percentile is 100, not the outlier 100000 - const yValues = path - .split(/[ML]/) - .filter(Boolean) - .map((s) => parseFloat(s.split(',')[1])) - .filter((y) => !isNaN(y)) - const nearZero = yValues.filter((y) => Math.abs(y) < 0.01) - expect(nearZero.length).toBeGreaterThan(200) - }) -}) diff --git a/src/components/curve/curveUtils.ts b/src/components/curve/curveUtils.ts index ebf2cde2a77..6bbd5073117 100644 --- a/src/components/curve/curveUtils.ts +++ b/src/components/curve/curveUtils.ts @@ -149,34 +149,6 @@ export function createMonotoneInterpolator( } } -/** - * Convert a histogram (arbitrary number of bins) into an SVG path string. - * Applies square-root scaling and normalizes using the 99.5th percentile - * to avoid outlier spikes. - */ -export function histogramToPath(histogram: Uint32Array): string { - const len = histogram.length - if (len === 0) return '' - - const sqrtValues = new Float32Array(len) - for (let i = 0; i < len; i++) sqrtValues[i] = Math.sqrt(histogram[i]) - - const sorted = Array.from(sqrtValues).sort((a, b) => a - b) - const max = sorted[Math.floor((len - 1) * 0.995)] - if (max === 0) return '' - - const invMax = 1 / max - const lastIdx = len - 1 - const parts: string[] = ['M0,1'] - for (let i = 0; i < len; i++) { - const x = lastIdx === 0 ? 0.5 : i / lastIdx - const y = 1 - Math.min(1, sqrtValues[i] * invMax) - parts.push(`L${x},${y}`) - } - parts.push('L1,1 Z') - return parts.join(' ') -} - export function curvesToLUT( points: CurvePoint[], interpolation: CurveInterpolation = 'monotone_cubic' diff --git a/src/components/range/RangeEditor.test.ts b/src/components/range/RangeEditor.test.ts new file mode 100644 index 00000000000..b4900ef715b --- /dev/null +++ b/src/components/range/RangeEditor.test.ts @@ -0,0 +1,131 @@ +import { mount } from '@vue/test-utils' +import { describe, expect, it } from 'vitest' +import { createI18n } from 'vue-i18n' + +import RangeEditor from './RangeEditor.vue' + +const i18n = createI18n({ legacy: false, locale: 'en', messages: { en: {} } }) + +function mountEditor(props: InstanceType['$props']) { + return mount(RangeEditor, { + props, + global: { plugins: [i18n] } + }) +} + +describe('RangeEditor', () => { + it('renders with min and max handles', () => { + const wrapper = mountEditor({ modelValue: { min: 0.2, max: 0.8 } }) + + expect(wrapper.find('svg').exists()).toBe(true) + expect(wrapper.find('[data-testid="handle-min"]').exists()).toBe(true) + expect(wrapper.find('[data-testid="handle-max"]').exists()).toBe(true) + }) + + it('highlights selected range in plain mode', () => { + const wrapper = mountEditor({ modelValue: { min: 0.2, max: 0.8 } }) + + const highlight = wrapper.find('[data-testid="range-highlight"]') + expect(highlight.attributes('x')).toBe('0.2') + expect( + Number.parseFloat(highlight.attributes('width') ?? 'NaN') + ).toBeCloseTo(0.6, 6) + }) + + it('dims area outside the range in histogram mode', () => { + const histogram = new Uint32Array(256) + for (let i = 0; i < 256; i++) + histogram[i] = Math.floor(50 + 50 * Math.sin(i / 20)) + + const wrapper = mountEditor({ + modelValue: { min: 0.2, max: 0.8 }, + display: 'histogram', + histogram + }) + + const left = wrapper.find('[data-testid="range-dim-left"]') + const right = wrapper.find('[data-testid="range-dim-right"]') + expect(left.attributes('width')).toBe('0.2') + expect(right.attributes('x')).toBe('0.8') + }) + + it('hides midpoint handle by default', () => { + const wrapper = mountEditor({ + modelValue: { min: 0, max: 1, midpoint: 0.5 } + }) + + expect(wrapper.find('[data-testid="handle-midpoint"]').exists()).toBe(false) + }) + + it('shows midpoint handle when showMidpoint is true', () => { + const wrapper = mountEditor({ + modelValue: { min: 0, max: 1, midpoint: 0.5 }, + showMidpoint: true + }) + + expect(wrapper.find('[data-testid="handle-midpoint"]').exists()).toBe(true) + }) + + it('renders gradient background when display is gradient', () => { + const wrapper = mountEditor({ + modelValue: { min: 0, max: 1 }, + display: 'gradient', + gradientStops: [ + { offset: 0, color: [0, 0, 0] as const }, + { offset: 1, color: [255, 255, 255] as const } + ] + }) + + expect(wrapper.find('[data-testid="gradient-bg"]').exists()).toBe(true) + expect(wrapper.find('linearGradient').exists()).toBe(true) + }) + + it('renders histogram path when display is histogram with data', () => { + const histogram = new Uint32Array(256) + for (let i = 0; i < 256; i++) + histogram[i] = Math.floor(50 + 50 * Math.sin(i / 20)) + + const wrapper = mountEditor({ + modelValue: { min: 0, max: 1 }, + display: 'histogram', + histogram + }) + + expect(wrapper.find('[data-testid="histogram-path"]').exists()).toBe(true) + }) + + it('renders inputs for min and max', () => { + const wrapper = mountEditor({ modelValue: { min: 0.2, max: 0.8 } }) + + const inputs = wrapper.findAll('input') + expect(inputs).toHaveLength(2) + }) + + it('renders midpoint input when showMidpoint is true', () => { + const wrapper = mountEditor({ + modelValue: { min: 0, max: 1, midpoint: 0.5 }, + showMidpoint: true + }) + + const inputs = wrapper.findAll('input') + expect(inputs).toHaveLength(3) + }) + + it('normalizes handle positions with custom value range', () => { + const wrapper = mountEditor({ + modelValue: { min: 64, max: 192 }, + valueMin: 0, + valueMax: 255 + }) + + const minHandle = wrapper.find('[data-testid="handle-min"]') + const maxHandle = wrapper.find('[data-testid="handle-max"]') + + expect( + Number.parseFloat((minHandle.element as HTMLElement).style.left) + ).toBeCloseTo(25, 0) + expect( + Number.parseFloat((maxHandle.element as HTMLElement).style.left) + ).toBeCloseTo(75, 0) + }) +}) diff --git a/src/components/range/RangeEditor.vue b/src/components/range/RangeEditor.vue new file mode 100644 index 00000000000..c88ee6120a7 --- /dev/null +++ b/src/components/range/RangeEditor.vue @@ -0,0 +1,290 @@ + + + diff --git a/src/components/range/WidgetRange.vue b/src/components/range/WidgetRange.vue new file mode 100644 index 00000000000..f0c19b15fc4 --- /dev/null +++ b/src/components/range/WidgetRange.vue @@ -0,0 +1,74 @@ + + + diff --git a/src/components/range/rangeUtils.test.ts b/src/components/range/rangeUtils.test.ts new file mode 100644 index 00000000000..c1dbee7c093 --- /dev/null +++ b/src/components/range/rangeUtils.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from 'vitest' + +import { gammaToPosition, isRangeValue, positionToGamma } from './rangeUtils' + +describe('positionToGamma', () => { + it('converts 0.5 to gamma 1.0', () => { + expect(positionToGamma(0.5)).toBeCloseTo(1.0) + }) + + it('converts 0.25 to gamma 2.0', () => { + expect(positionToGamma(0.25)).toBeCloseTo(2.0) + }) +}) + +describe('gammaToPosition', () => { + it('converts gamma 1.0 to position 0.5', () => { + expect(gammaToPosition(1.0)).toBeCloseTo(0.5) + }) + + it('converts gamma 2.0 to position 0.25', () => { + expect(gammaToPosition(2.0)).toBeCloseTo(0.25) + }) + + it('round-trips with positionToGamma', () => { + for (const pos of [0.1, 0.3, 0.5, 0.7, 0.9]) { + expect(gammaToPosition(positionToGamma(pos))).toBeCloseTo(pos) + } + }) +}) + +describe('isRangeValue', () => { + it('returns true for valid range', () => { + expect(isRangeValue({ min: 0, max: 1 })).toBe(true) + expect(isRangeValue({ min: 0, max: 1, midpoint: 0.5 })).toBe(true) + }) + + it('returns false for non-objects', () => { + expect(isRangeValue(null)).toBe(false) + expect(isRangeValue(42)).toBe(false) + expect(isRangeValue('foo')).toBe(false) + expect(isRangeValue([0, 1])).toBe(false) + }) + + it('returns false for objects missing min or max', () => { + expect(isRangeValue({ min: 0 })).toBe(false) + expect(isRangeValue({ max: 1 })).toBe(false) + expect(isRangeValue({ min: 'a', max: 1 })).toBe(false) + }) +}) diff --git a/src/components/range/rangeUtils.ts b/src/components/range/rangeUtils.ts new file mode 100644 index 00000000000..999245f0247 --- /dev/null +++ b/src/components/range/rangeUtils.ts @@ -0,0 +1,23 @@ +import { clamp } from 'es-toolkit' + +import type { RangeValue } from '@/lib/litegraph/src/types/widgets' + +export function positionToGamma(position: number): number { + // Avoid log2(0) = -Infinity and log2(1) = 0 (division by zero in gamma) + const clamped = clamp(position, 0.001, 0.999) + return -Math.log2(clamped) +} + +export function gammaToPosition(gamma: number): number { + return Math.pow(2, -gamma) +} + +export function isRangeValue(value: unknown): value is RangeValue { + if (typeof value !== 'object' || value === null || Array.isArray(value)) + return false + const v = value as Record + const hasFiniteBounds = Number.isFinite(v.min) && Number.isFinite(v.max) + const hasValidMidpoint = + v.midpoint === undefined || Number.isFinite(v.midpoint) + return hasFiniteBounds && hasValidMidpoint +} diff --git a/src/composables/useRangeEditor.ts b/src/composables/useRangeEditor.ts new file mode 100644 index 00000000000..6ee483ad8aa --- /dev/null +++ b/src/composables/useRangeEditor.ts @@ -0,0 +1,113 @@ +import { onBeforeUnmount, ref } from 'vue' +import type { Ref } from 'vue' + +import { clamp } from 'es-toolkit' + +import { denormalize, normalize } from '@/utils/mathUtil' +import type { RangeValue } from '@/lib/litegraph/src/types/widgets' + +type HandleType = 'min' | 'max' | 'midpoint' + +interface UseRangeEditorOptions { + trackRef: Ref + modelValue: Ref + valueMin: Ref + valueMax: Ref + showMidpoint: Ref +} + +export function useRangeEditor({ + trackRef, + modelValue, + valueMin, + valueMax, + showMidpoint +}: UseRangeEditorOptions) { + const activeHandle = ref(null) + let cleanupDrag: (() => void) | null = null + + function pointerToValue(e: PointerEvent): number { + const el = trackRef.value + if (!el) return valueMin.value + const rect = el.getBoundingClientRect() + const normalized = clamp((e.clientX - rect.left) / rect.width, 0, 1) + return denormalize(normalized, valueMin.value, valueMax.value) + } + + function nearestHandle(value: number): HandleType { + const { min, max, midpoint } = modelValue.value + const dMin = Math.abs(value - min) + const dMax = Math.abs(value - max) + let best: HandleType = dMin <= dMax ? 'min' : 'max' + const bestDist = Math.min(dMin, dMax) + if (midpoint !== undefined && showMidpoint.value) { + const midAbs = min + midpoint * (max - min) + if (Math.abs(value - midAbs) < bestDist) { + best = 'midpoint' + } + } + return best + } + + function updateValue(handle: HandleType, value: number) { + const current = modelValue.value + const clamped = clamp(value, valueMin.value, valueMax.value) + + if (handle === 'min') { + modelValue.value = { ...current, min: Math.min(clamped, current.max) } + } else if (handle === 'max') { + modelValue.value = { ...current, max: Math.max(clamped, current.min) } + } else { + const range = current.max - current.min + const midNorm = + range > 0 ? normalize(clamped, current.min, current.max) : 0 + const midpoint = clamp(midNorm, 0, 1) + modelValue.value = { ...current, midpoint } + } + } + + function handleTrackPointerDown(e: PointerEvent) { + if (e.button !== 0) return + startDrag(nearestHandle(pointerToValue(e)), e) + } + + function startDrag(handle: HandleType, e: PointerEvent) { + if (e.button !== 0) return + cleanupDrag?.() + + activeHandle.value = handle + const el = trackRef.value + if (!el) return + + el.setPointerCapture(e.pointerId) + + const onMove = (ev: PointerEvent) => { + if (!activeHandle.value) return + updateValue(activeHandle.value, pointerToValue(ev)) + } + + const endDrag = () => { + if (!activeHandle.value) return + activeHandle.value = null + el.removeEventListener('pointermove', onMove) + el.removeEventListener('pointerup', endDrag) + el.removeEventListener('lostpointercapture', endDrag) + cleanupDrag = null + } + + cleanupDrag = endDrag + + el.addEventListener('pointermove', onMove) + el.addEventListener('pointerup', endDrag) + el.addEventListener('lostpointercapture', endDrag) + } + + onBeforeUnmount(() => { + cleanupDrag?.() + }) + + return { + handleTrackPointerDown, + startDrag + } +} diff --git a/src/lib/litegraph/src/types/widgets.ts b/src/lib/litegraph/src/types/widgets.ts index c7ce79d7a2f..f0c2cf8efff 100644 --- a/src/lib/litegraph/src/types/widgets.ts +++ b/src/lib/litegraph/src/types/widgets.ts @@ -139,6 +139,7 @@ export type IWidget = | IBoundingBoxWidget | ICurveWidget | IPainterWidget + | IRangeWidget export interface IBooleanWidget extends IBaseWidget { type: 'toggle' @@ -341,6 +342,30 @@ export interface IPainterWidget extends IBaseWidget { value: string } +export interface RangeValue { + min: number + max: number + midpoint?: number +} + +export interface IWidgetRangeOptions extends IWidgetOptions { + display?: 'plain' | 'gradient' | 'histogram' + gradient_stops?: ColorStop[] + show_midpoint?: boolean + midpoint_scale?: 'linear' | 'gamma' + value_min?: number + value_max?: number +} + +export interface IRangeWidget extends IBaseWidget< + RangeValue, + 'range', + IWidgetRangeOptions +> { + type: 'range' + value: RangeValue +} + /** * Valid widget types. TS cannot provide easily extensible type safety for this at present. * Override linkedWidgets[] diff --git a/src/lib/litegraph/src/widgets/RangeWidget.ts b/src/lib/litegraph/src/widgets/RangeWidget.ts new file mode 100644 index 00000000000..4bdc469cb61 --- /dev/null +++ b/src/lib/litegraph/src/widgets/RangeWidget.ts @@ -0,0 +1,16 @@ +import type { IRangeWidget } from '../types/widgets' +import { BaseWidget } from './BaseWidget' +import type { DrawWidgetOptions, WidgetEventOptions } from './BaseWidget' + +export class RangeWidget + extends BaseWidget + implements IRangeWidget +{ + override type = 'range' as const + + drawWidget(ctx: CanvasRenderingContext2D, options: DrawWidgetOptions): void { + this.drawVueOnlyWarning(ctx, options, 'Range') + } + + onClick(_options: WidgetEventOptions): void {} +} diff --git a/src/lib/litegraph/src/widgets/widgetMap.ts b/src/lib/litegraph/src/widgets/widgetMap.ts index cdd24308d31..86fe73d9e06 100644 --- a/src/lib/litegraph/src/widgets/widgetMap.ts +++ b/src/lib/litegraph/src/widgets/widgetMap.ts @@ -22,6 +22,7 @@ import { GalleriaWidget } from './GalleriaWidget' import { GradientSliderWidget } from './GradientSliderWidget' import { ImageCompareWidget } from './ImageCompareWidget' import { PainterWidget } from './PainterWidget' +import { RangeWidget } from './RangeWidget' import { ImageCropWidget } from './ImageCropWidget' import { KnobWidget } from './KnobWidget' import { LegacyWidget } from './LegacyWidget' @@ -60,6 +61,7 @@ export type WidgetTypeMap = { boundingbox: BoundingBoxWidget curve: CurveWidget painter: PainterWidget + range: RangeWidget [key: string]: BaseWidget } @@ -140,6 +142,8 @@ export function toConcreteWidget( return toClass(CurveWidget, narrowedWidget, node) case 'painter': return toClass(PainterWidget, narrowedWidget, node) + case 'range': + return toClass(RangeWidget, narrowedWidget, node) default: { if (wrapLegacyWidgets) return toClass(LegacyWidget, widget, node) } diff --git a/src/renderer/extensions/vueNodes/widgets/composables/useRangeWidget.ts b/src/renderer/extensions/vueNodes/widgets/composables/useRangeWidget.ts new file mode 100644 index 00000000000..db8a3365ff6 --- /dev/null +++ b/src/renderer/extensions/vueNodes/widgets/composables/useRangeWidget.ts @@ -0,0 +1,37 @@ +import type { LGraphNode } from '@/lib/litegraph/src/litegraph' +import type { + IRangeWidget, + IWidgetRangeOptions +} from '@/lib/litegraph/src/types/widgets' +import type { RangeInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2' +import type { ComfyWidgetConstructorV2 } from '@/scripts/widgets' + +export const useRangeWidget = (): ComfyWidgetConstructorV2 => { + return (node: LGraphNode, inputSpec): IRangeWidget => { + const spec = inputSpec as RangeInputSpec + const defaultValue = spec.default ?? { min: 0.0, max: 1.0 } + + const options: IWidgetRangeOptions = { + display: spec.display, + gradient_stops: spec.gradient_stops, + show_midpoint: spec.show_midpoint, + midpoint_scale: spec.midpoint_scale, + value_min: spec.value_min, + value_max: spec.value_max + } + + const rawWidget = node.addWidget( + 'range', + spec.name, + { ...defaultValue }, + () => {}, + options + ) + + if (rawWidget.type !== 'range') { + throw new Error(`Unexpected widget type: ${rawWidget.type}`) + } + + return rawWidget as IRangeWidget + } +} diff --git a/src/renderer/extensions/vueNodes/widgets/registry/widgetRegistry.ts b/src/renderer/extensions/vueNodes/widgets/registry/widgetRegistry.ts index a066742c680..4b14b41ac19 100644 --- a/src/renderer/extensions/vueNodes/widgets/registry/widgetRegistry.ts +++ b/src/renderer/extensions/vueNodes/widgets/registry/widgetRegistry.ts @@ -63,6 +63,9 @@ const WidgetCurve = defineAsyncComponent( const WidgetPainter = defineAsyncComponent( () => import('@/components/painter/WidgetPainter.vue') ) +const WidgetRange = defineAsyncComponent( + () => import('@/components/range/WidgetRange.vue') +) export const FOR_TESTING = { WidgetButton, @@ -197,6 +200,14 @@ const coreWidgetDefinitions: Array<[string, WidgetDefinition]> = [ aliases: ['PAINTER'], essential: false } + ], + [ + 'range', + { + component: WidgetRange, + aliases: ['RANGE'], + essential: false + } ] ] @@ -234,7 +245,8 @@ const EXPANDING_TYPES = [ 'load3D', 'curve', 'painter', - 'imagecompare' + 'imagecompare', + 'range' ] as const export function shouldExpand(type: string): boolean { diff --git a/src/schemas/nodeDef/nodeDefSchemaV2.ts b/src/schemas/nodeDef/nodeDefSchemaV2.ts index 3adc93aa037..a783f2edf09 100644 --- a/src/schemas/nodeDef/nodeDefSchemaV2.ts +++ b/src/schemas/nodeDef/nodeDefSchemaV2.ts @@ -3,6 +3,7 @@ import { z } from 'zod' import { zBaseInputOptions, zBooleanInputOptions, + zColorStop, zComboInputOptions, zFloatInputOptions, zIntInputOptions, @@ -140,6 +141,25 @@ const zCurveInputSpec = zBaseInputOptions.extend({ default: zCurveData.optional() }) +const zRangeValue = z.object({ + min: z.number(), + max: z.number(), + midpoint: z.number().optional() +}) + +const zRangeInputSpec = zBaseInputOptions.extend({ + type: z.literal('RANGE'), + name: z.string(), + isOptional: z.boolean().optional(), + default: zRangeValue.optional(), + display: z.enum(['plain', 'gradient', 'histogram']).optional(), + gradient_stops: z.array(zColorStop).optional(), + show_midpoint: z.boolean().optional(), + midpoint_scale: z.enum(['linear', 'gamma']).optional(), + value_min: z.number().optional(), + value_max: z.number().optional() +}) + const zCustomInputSpec = zBaseInputOptions.extend({ type: z.string(), name: z.string(), @@ -161,6 +181,7 @@ const zInputSpec = z.union([ zGalleriaInputSpec, zTextareaInputSpec, zCurveInputSpec, + zRangeInputSpec, zCustomInputSpec ]) @@ -206,6 +227,7 @@ export type ChartInputSpec = z.infer export type GalleriaInputSpec = z.infer export type TextareaInputSpec = z.infer export type CurveInputSpec = z.infer +export type RangeInputSpec = z.infer export type CustomInputSpec = z.infer export type InputSpec = z.infer diff --git a/src/schemas/nodeDefSchema.ts b/src/schemas/nodeDefSchema.ts index 6120641af29..14135ab1213 100644 --- a/src/schemas/nodeDefSchema.ts +++ b/src/schemas/nodeDefSchema.ts @@ -56,16 +56,14 @@ export const zIntInputOptions = zNumericInputOptions.extend({ .optional() }) +export const zColorStop = z.object({ + offset: z.number(), + color: z.tuple([z.number(), z.number(), z.number()]) +}) + export const zFloatInputOptions = zNumericInputOptions.extend({ round: z.union([z.number(), z.literal(false)]).optional(), - gradient_stops: z - .array( - z.object({ - offset: z.number(), - color: z.tuple([z.number(), z.number(), z.number()]) - }) - ) - .optional() + gradient_stops: z.array(zColorStop).optional() }) export const zBooleanInputOptions = zBaseInputOptions.extend({ diff --git a/src/scripts/widgets.ts b/src/scripts/widgets.ts index 517e6f0e836..ee338b851c4 100644 --- a/src/scripts/widgets.ts +++ b/src/scripts/widgets.ts @@ -20,6 +20,7 @@ import { useImageUploadWidget } from '@/renderer/extensions/vueNodes/widgets/com import { useIntWidget } from '@/renderer/extensions/vueNodes/widgets/composables/useIntWidget' import { useMarkdownWidget } from '@/renderer/extensions/vueNodes/widgets/composables/useMarkdownWidget' import { usePainterWidget } from '@/renderer/extensions/vueNodes/widgets/composables/usePainterWidget' +import { useRangeWidget } from '@/renderer/extensions/vueNodes/widgets/composables/useRangeWidget' import { useStringWidget } from '@/renderer/extensions/vueNodes/widgets/composables/useStringWidget' import { useTextareaWidget } from '@/renderer/extensions/vueNodes/widgets/composables/useTextareaWidget' import { transformInputSpecV1ToV2 } from '@/schemas/nodeDef/migration' @@ -310,6 +311,7 @@ export const ComfyWidgets = { PAINTER: transformWidgetConstructorV2ToV1(usePainterWidget()), TEXTAREA: transformWidgetConstructorV2ToV1(useTextareaWidget()), CURVE: transformWidgetConstructorV2ToV1(useCurveWidget()), + RANGE: transformWidgetConstructorV2ToV1(useRangeWidget()), ...dynamicWidgets } as const diff --git a/src/utils/histogramUtil.test.ts b/src/utils/histogramUtil.test.ts new file mode 100644 index 00000000000..7d1ae4a4c48 --- /dev/null +++ b/src/utils/histogramUtil.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from 'vitest' + +import { histogramToPath } from './histogramUtil' + +describe('histogramToPath', () => { + it('returns empty string for empty histogram', () => { + expect(histogramToPath(new Uint32Array(0))).toBe('') + }) + + it('returns empty string when all bins are zero', () => { + expect(histogramToPath(new Uint32Array(256))).toBe('') + }) + + it('returns a closed SVG path for valid histogram', () => { + const histogram = new Uint32Array(256) + for (let i = 0; i < 256; i++) histogram[i] = i + 1 + const path = histogramToPath(histogram) + expect(path).toMatch(/^M0,1/) + expect(path).toMatch(/L1,1 Z$/) + }) + + it('normalizes using 99.5th percentile to suppress outliers', () => { + const histogram = new Uint32Array(256) + for (let i = 0; i < 256; i++) histogram[i] = 100 + histogram[255] = 100000 + const path = histogramToPath(histogram) + const yValues = path + .split(/[ML]/) + .filter(Boolean) + .map((s) => parseFloat(s.split(',')[1])) + .filter((y) => !isNaN(y)) + const nearZero = yValues.filter((y) => Math.abs(y) < 0.01) + expect(nearZero.length).toBeGreaterThan(200) + }) +}) diff --git a/src/utils/histogramUtil.ts b/src/utils/histogramUtil.ts new file mode 100644 index 00000000000..a6661d9fd40 --- /dev/null +++ b/src/utils/histogramUtil.ts @@ -0,0 +1,27 @@ +/** + * Convert a histogram (arbitrary number of bins) into an SVG path string. + * Applies square-root scaling and normalizes using the 99.5th percentile + * to avoid outlier spikes. + */ +export function histogramToPath(histogram: Uint32Array): string { + const len = histogram.length + if (len === 0) return '' + + const sqrtValues = new Float32Array(len) + for (let i = 0; i < len; i++) sqrtValues[i] = Math.sqrt(histogram[i]) + + const sorted = Array.from(sqrtValues).sort((a, b) => a - b) + const max = sorted[Math.floor((len - 1) * 0.995)] + if (max === 0) return '' + + const invMax = 1 / max + const lastIdx = len - 1 + const parts: string[] = ['M0,1'] + for (let i = 0; i < len; i++) { + const x = lastIdx === 0 ? 0.5 : i / lastIdx + const y = 1 - Math.min(1, sqrtValues[i] * invMax) + parts.push(`L${x},${y}`) + } + parts.push('L1,1 Z') + return parts.join(' ') +} diff --git a/src/utils/mathUtil.test.ts b/src/utils/mathUtil.test.ts index 47a70083d68..3d0d6021a74 100644 --- a/src/utils/mathUtil.test.ts +++ b/src/utils/mathUtil.test.ts @@ -1,9 +1,39 @@ import { describe, expect, it } from 'vitest' import type { ReadOnlyRect } from '@/lib/litegraph/src/interfaces' -import { computeUnionBounds, gcd, lcm } from '@/utils/mathUtil' +import { + computeUnionBounds, + denormalize, + gcd, + lcm, + normalize +} from '@/utils/mathUtil' describe('mathUtil', () => { + describe('normalize', () => { + it('normalizes value to 0-1', () => { + expect(normalize(128, 0, 256)).toBe(0.5) + expect(normalize(0, 0, 255)).toBe(0) + expect(normalize(255, 0, 255)).toBe(1) + }) + + it('returns 0 when min equals max', () => { + expect(normalize(5, 5, 5)).toBe(0) + }) + }) + + describe('denormalize', () => { + it('converts normalized value back to range', () => { + expect(denormalize(0.5, 0, 256)).toBe(128) + expect(denormalize(0, 0, 255)).toBe(0) + expect(denormalize(1, 0, 255)).toBe(255) + }) + + it('round-trips with normalize', () => { + expect(denormalize(normalize(100, 0, 255), 0, 255)).toBeCloseTo(100) + }) + }) + describe('gcd', () => { it('should compute greatest common divisor correctly', () => { expect(gcd(48, 18)).toBe(6) diff --git a/src/utils/mathUtil.ts b/src/utils/mathUtil.ts index e13d26fe911..fd840abb9d3 100644 --- a/src/utils/mathUtil.ts +++ b/src/utils/mathUtil.ts @@ -1,6 +1,25 @@ import type { ReadOnlyRect } from '@/lib/litegraph/src/interfaces' import type { Bounds } from '@/renderer/core/layout/types' +/** + * Linearly maps a value from [min, max] to [0, 1]. + * Returns 0 when min equals max to avoid division by zero. + */ +export function normalize(value: number, min: number, max: number): number { + return max === min ? 0 : (value - min) / (max - min) +} + +/** + * Linearly maps a normalized value from [0, 1] back to [min, max]. + */ +export function denormalize( + normalized: number, + min: number, + max: number +): number { + return min + normalized * (max - min) +} + /** Simple 2D point or size as [x, y] or [width, height] */ type Vec2 = readonly [number, number]