Compare commits

..

3 Commits

Author SHA1 Message Date
Terry Jia
cc406e7ecd fix: additional review feedback for Range editor 2026-04-09 08:12:19 -04:00
Terry Jia
2019704fc3 fix: address review feedback for Range editor 2026-04-08 21:19:02 -04:00
Terry Jia
b95636fc81 Range editor 2026-04-07 14:09:58 -04:00
32 changed files with 939 additions and 491 deletions

View File

@@ -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',

View File

@@ -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)
})
})

View File

@@ -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'

View File

@@ -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<typeof RangeEditor>['$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)
})
})

View File

@@ -0,0 +1,290 @@
<template>
<div>
<div
ref="trackRef"
class="relative select-none"
@pointerdown.stop="onTrackPointerDown"
@contextmenu.prevent.stop
>
<svg
viewBox="0 0 1 1"
preserveAspectRatio="none"
:class="
cn(
'block w-full rounded-sm bg-node-component-surface',
display === 'histogram' ? 'aspect-3/2' : 'h-8'
)
"
>
<defs v-if="display === 'gradient'">
<linearGradient :id="gradientId" x1="0" y1="0" x2="1" y2="0">
<stop
v-for="(stop, i) in computedStops"
:key="i"
:offset="stop.offset"
:stop-color="`rgb(${stop.color[0]},${stop.color[1]},${stop.color[2]})`"
/>
</linearGradient>
</defs>
<rect
v-if="display === 'gradient'"
data-testid="gradient-bg"
x="0"
y="0"
width="1"
height="1"
:fill="`url(#${gradientId})`"
/>
<path
v-if="display === 'histogram' && histogramPath"
data-testid="histogram-path"
:d="histogramPath"
fill="currentColor"
fill-opacity="0.3"
/>
<rect
v-if="display === 'plain'"
data-testid="range-highlight"
:x="minNorm"
y="0"
:width="Math.max(0, maxNorm - minNorm)"
height="1"
fill="white"
fill-opacity="0.15"
/>
<template v-if="display === 'histogram'">
<rect
v-if="minNorm > 0"
data-testid="range-dim-left"
x="0"
y="0"
:width="minNorm"
height="1"
fill="black"
fill-opacity="0.5"
/>
<rect
v-if="maxNorm < 1"
data-testid="range-dim-right"
:x="maxNorm"
y="0"
:width="1 - maxNorm"
height="1"
fill="black"
fill-opacity="0.5"
/>
</template>
</svg>
<template v-if="!disabled">
<div
data-testid="handle-min"
class="absolute -translate-x-1/2 cursor-grab"
:style="{ left: `${minNorm * 100}%`, bottom: '-10px' }"
@pointerdown.stop="startDrag('min', $event)"
>
<svg width="12" height="10" viewBox="0 0 12 10">
<polygon
points="6,0 0,10 12,10"
fill="#333"
stroke="#aaa"
stroke-width="0.5"
/>
</svg>
</div>
<div
v-if="showMidpoint && modelValue.midpoint !== undefined"
data-testid="handle-midpoint"
class="absolute -translate-x-1/2 cursor-grab"
:style="{ left: `${midpointPercent}%`, bottom: '-10px' }"
@pointerdown.stop="startDrag('midpoint', $event)"
>
<svg width="12" height="10" viewBox="0 0 12 10">
<polygon
points="6,0 0,10 12,10"
fill="#888"
stroke="#ccc"
stroke-width="0.5"
/>
</svg>
</div>
<div
data-testid="handle-max"
class="absolute -translate-x-1/2 cursor-grab"
:style="{ left: `${maxNorm * 100}%`, bottom: '-10px' }"
@pointerdown.stop="startDrag('max', $event)"
>
<svg width="12" height="10" viewBox="0 0 12 10">
<polygon
points="6,0 0,10 12,10"
fill="white"
stroke="#555"
stroke-width="0.5"
/>
</svg>
</div>
</template>
</div>
<div
v-if="!disabled"
class="mt-3 flex items-center justify-between"
@pointerdown.stop
>
<ScrubableNumberInput
v-model="minValue"
:display-value="formatValue(minValue)"
:min="valueMin"
:max="valueMax"
:step="step"
hide-buttons
class="w-16"
/>
<ScrubableNumberInput
v-if="showMidpoint && modelValue.midpoint !== undefined"
v-model="midpointValue"
:display-value="midpointValue.toFixed(2)"
:min="midpointScale === 'gamma' ? 0.01 : 0"
:max="midpointScale === 'gamma' ? 9.99 : 1"
:step="0.01"
hide-buttons
class="w-16"
/>
<ScrubableNumberInput
v-model="maxValue"
:display-value="formatValue(maxValue)"
:min="valueMin"
:max="valueMax"
:step="step"
hide-buttons
class="w-16"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, toRef, useId, useTemplateRef } from 'vue'
import ScrubableNumberInput from '@/components/common/ScrubableNumberInput.vue'
import { cn } from '@/utils/tailwindUtil'
import { histogramToPath } from '@/utils/histogramUtil'
import { useRangeEditor } from '@/composables/useRangeEditor'
import type { ColorStop } from '@/lib/litegraph/src/interfaces'
import type { RangeValue } from '@/lib/litegraph/src/types/widgets'
import { normalize } from '@/utils/mathUtil'
import { clamp } from 'es-toolkit'
import { gammaToPosition, positionToGamma } from './rangeUtils'
const {
display = 'plain',
gradientStops,
showMidpoint = false,
midpointScale = 'linear',
histogram,
disabled = false,
valueMin = 0,
valueMax = 1
} = defineProps<{
display?: 'plain' | 'gradient' | 'histogram'
gradientStops?: ColorStop[]
showMidpoint?: boolean
midpointScale?: 'linear' | 'gamma'
histogram?: Uint32Array | null
disabled?: boolean
valueMin?: number
valueMax?: number
}>()
const modelValue = defineModel<RangeValue>({ required: true })
const trackRef = useTemplateRef<HTMLDivElement>('trackRef')
const gradientId = useId()
const { handleTrackPointerDown, startDrag } = useRangeEditor({
trackRef,
modelValue,
valueMin: toRef(() => valueMin),
valueMax: toRef(() => valueMax),
showMidpoint: toRef(() => showMidpoint)
})
function onTrackPointerDown(e: PointerEvent) {
if (!disabled) handleTrackPointerDown(e)
}
const isIntegerRange = computed(() => valueMax - valueMin >= 2)
const step = computed(() => (isIntegerRange.value ? 1 : 0.01))
function formatValue(v: number): string {
return isIntegerRange.value ? Math.round(v).toString() : v.toFixed(2)
}
const minNorm = computed(() =>
normalize(modelValue.value.min, valueMin, valueMax)
)
const maxNorm = computed(() =>
normalize(modelValue.value.max, valueMin, valueMax)
)
const computedStops = computed(
() =>
gradientStops ?? [
{ offset: 0, color: [0, 0, 0] as const },
{ offset: 1, color: [255, 255, 255] as const }
]
)
const midpointPercent = computed(() => {
const { min, max, midpoint } = modelValue.value
if (midpoint === undefined) return 0
const midAbs = min + midpoint * (max - min)
return normalize(midAbs, valueMin, valueMax) * 100
})
const minValue = computed({
get: () => modelValue.value.min,
set: (min) => {
modelValue.value = {
...modelValue.value,
min: Math.min(clamp(min, valueMin, valueMax), modelValue.value.max)
}
}
})
const maxValue = computed({
get: () => modelValue.value.max,
set: (max) => {
modelValue.value = {
...modelValue.value,
max: Math.max(clamp(max, valueMin, valueMax), modelValue.value.min)
}
}
})
const midpointValue = computed({
get: () => {
const pos = modelValue.value.midpoint ?? 0.5
return midpointScale === 'gamma' ? positionToGamma(pos) : pos
},
set: (val) => {
const position =
midpointScale === 'gamma'
? clamp(gammaToPosition(val), 0, 1)
: clamp(val, 0, 1)
modelValue.value = { ...modelValue.value, midpoint: position }
}
})
const histogramPath = computed(() =>
histogram ? histogramToPath(histogram) : ''
)
</script>

View File

@@ -0,0 +1,74 @@
<template>
<RangeEditor
:model-value="effectiveValue"
:display="widget?.options?.display"
:gradient-stops="widget?.options?.gradient_stops"
:show-midpoint="widget?.options?.show_midpoint"
:midpoint-scale="widget?.options?.midpoint_scale"
:histogram="histogram"
:disabled="isDisabled"
:value-min="widget?.options?.value_min"
:value-max="widget?.options?.value_max"
@update:model-value="onValueChange"
/>
</template>
<script setup lang="ts">
import { computed, watch } from 'vue'
import {
singleValueExtractor,
useUpstreamValue
} from '@/composables/useUpstreamValue'
import type {
IWidgetRangeOptions,
RangeValue
} from '@/lib/litegraph/src/types/widgets'
import { useNodeOutputStore } from '@/stores/nodeOutputStore'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
import RangeEditor from './RangeEditor.vue'
import { isRangeValue } from './rangeUtils'
const { widget } = defineProps<{
widget: SimplifiedWidget<RangeValue, IWidgetRangeOptions>
}>()
const modelValue = defineModel<RangeValue>({
default: () => ({ min: 0, max: 1 })
})
const isDisabled = computed(() => !!widget.options?.disabled)
const nodeOutputStore = useNodeOutputStore()
const histogram = computed(() => {
const locatorId = widget.nodeLocatorId
if (!locatorId) return null
const output = nodeOutputStore.nodeOutputs[locatorId]
const key = `histogram_${widget.name}`
const data = (output as Record<string, unknown>)?.[key]
if (!Array.isArray(data) || data.length === 0) return null
return new Uint32Array(data)
})
const upstreamValue = useUpstreamValue(
() => widget.linkedUpstream,
singleValueExtractor(isRangeValue)
)
watch(upstreamValue, (upstream) => {
if (isDisabled.value && upstream) {
modelValue.value = upstream
}
})
const effectiveValue = computed(() =>
isDisabled.value && upstreamValue.value
? upstreamValue.value
: modelValue.value
)
function onValueChange(value: RangeValue) {
modelValue.value = value
}
</script>

View File

@@ -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)
})
})

View File

@@ -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<string, unknown>
const hasFiniteBounds = Number.isFinite(v.min) && Number.isFinite(v.max)
const hasValidMidpoint =
v.midpoint === undefined || Number.isFinite(v.midpoint)
return hasFiniteBounds && hasValidMidpoint
}

View File

@@ -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<HTMLElement | null>
modelValue: Ref<RangeValue>
valueMin: Ref<number>
valueMax: Ref<number>
showMidpoint: Ref<boolean>
}
export function useRangeEditor({
trackRef,
modelValue,
valueMin,
valueMax,
showMidpoint
}: UseRangeEditorOptions) {
const activeHandle = ref<HandleType | null>(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
}
}

View File

@@ -139,6 +139,7 @@ export type IWidget =
| IBoundingBoxWidget
| ICurveWidget
| IPainterWidget
| IRangeWidget
export interface IBooleanWidget extends IBaseWidget<boolean, 'toggle'> {
type: 'toggle'
@@ -341,6 +342,30 @@ export interface IPainterWidget extends IBaseWidget<string, 'painter'> {
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[]

View File

@@ -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<IRangeWidget>
implements IRangeWidget
{
override type = 'range' as const
drawWidget(ctx: CanvasRenderingContext2D, options: DrawWidgetOptions): void {
this.drawVueOnlyWarning(ctx, options, 'Range')
}
onClick(_options: WidgetEventOptions): void {}
}

View File

@@ -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<TWidget extends IWidget | IBaseWidget>(
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)
}

View File

@@ -1,184 +0,0 @@
<script setup lang="ts">
import { computed, onMounted, provide, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import type { FilterOption } from '@/platform/assets/types/filterTypes'
import { isComboInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
import FormDropdown from './form/dropdown/FormDropdown.vue'
import type { FormDropdownItem, LayoutMode } from './form/dropdown/types'
import { AssetKindKey } from './form/dropdown/types'
import {
buildSearchText,
extractFilterValues,
getByPath,
mapToDropdownItem
} from '../utils/resolveItemSchema'
import { fetchRemoteRoute } from '../utils/resolveRemoteRoute'
const props = defineProps<{
modelValue?: string
widget: SimplifiedWidget<string | undefined>
}>()
const emit = defineEmits<{
'update:modelValue': [value: string]
}>()
const { t } = useI18n()
const comboSpec = computed(() => {
if (props.widget.spec && isComboInputSpec(props.widget.spec)) {
return props.widget.spec
}
return undefined
})
const remoteConfig = computed(() => comboSpec.value?.remote!)
const itemSchema = computed(() => remoteConfig.value?.item_schema!)
const rawItems = ref<unknown[]>([])
const loading = ref(false)
async function fetchItems() {
loading.value = true
try {
const res = await fetchRemoteRoute(remoteConfig.value.route, {
params: remoteConfig.value.query_params,
timeout: remoteConfig.value.timeout ?? 30000,
useComfyApi: remoteConfig.value.use_comfy_api
})
const data = remoteConfig.value.response_key
? res.data[remoteConfig.value.response_key]
: res.data
rawItems.value = Array.isArray(data) ? data : []
} catch (err) {
console.error('RichComboWidget: fetch error', err)
} finally {
loading.value = false
}
}
onMounted(() => {
void fetchItems()
})
const assetKind = computed(() => {
const pt = itemSchema.value.preview_type ?? 'image'
return pt as 'image' | 'video' | 'audio'
})
provide(AssetKindKey, assetKind)
const items = computed<FormDropdownItem[]>(() =>
rawItems.value.map((raw) => mapToDropdownItem(raw, itemSchema.value))
)
const searchIndex = computed(() => {
const schema = itemSchema.value
const fields = schema.search_fields ?? [schema.label_field]
const index = new Map<string, string>()
for (const raw of rawItems.value) {
const id = String(getByPath(raw, schema.value_field) ?? '')
index.set(id, buildSearchText(raw, fields))
}
return index
})
const filterOptions = computed<FilterOption[]>(() => {
const schema = itemSchema.value
if (!schema.filter_field) return []
const values = extractFilterValues(rawItems.value, schema.filter_field)
return [
{ name: 'All', value: 'all' },
...values.map((v) => ({ name: v, value: v }))
]
})
const filterSelected = ref('all')
const layoutMode = ref<LayoutMode>('list')
const selectedSet = ref<Set<string>>(new Set())
const filteredItems = computed<FormDropdownItem[]>(() => {
const schema = itemSchema.value
if (filterSelected.value === 'all' || !schema.filter_field) {
return items.value
}
const filterField = schema.filter_field
return rawItems.value
.filter(
(raw) =>
String(getByPath(raw, filterField) ?? '') === filterSelected.value
)
.map((raw) => mapToDropdownItem(raw, schema))
})
async function searcher(
query: string,
searchItems: FormDropdownItem[],
_onCleanup: (cleanupFn: () => void) => void
): Promise<FormDropdownItem[]> {
if (!query.trim()) return searchItems
const q = query.toLowerCase()
return searchItems.filter((item) => {
const text = searchIndex.value.get(item.id) ?? item.name.toLowerCase()
return text.includes(q)
})
}
watch(
[() => props.modelValue, items],
([val]) => {
selectedSet.value.clear()
if (val) {
const item = items.value.find((i) => i.id === val)
if (item) selectedSet.value.add(item.id)
}
},
{ immediate: true }
)
function handleRefresh() {
void fetchItems()
}
function handleSelection(selected: Set<string>) {
const id = selected.values().next().value
if (id) {
emit('update:modelValue', id)
}
}
</script>
<template>
<div class="flex w-full items-center gap-1">
<FormDropdown
v-model:selected="selectedSet"
v-model:filter-selected="filterSelected"
v-model:layout-mode="layoutMode"
:items="filteredItems"
:placeholder="loading ? 'Loading...' : t('widgets.uploadSelect.placeholder')"
:multiple="false"
:filter-options="[]"
:show-sort="false"
:show-layout-switcher="false"
:searcher="searcher"
class="flex-1"
@update:selected="handleSelection"
/>
<button
v-if="remoteConfig?.refresh_button !== false"
class="flex size-7 shrink-0 items-center justify-center rounded text-secondary hover:bg-component-node-widget-background-hovered"
title="Refresh"
@pointerdown.stop
@click.stop="handleRefresh"
>
<i
:class="[
'icon-[lucide--refresh-cw] size-3.5',
loading && 'animate-spin'
]"
/>
</button>
</div>
</template>

View File

@@ -1,11 +1,6 @@
<template>
<RichComboWidget
v-if="hasItemSchema"
v-model="modelValue"
:widget
/>
<WidgetSelectDropdown
v-else-if="isDropdownUIWidget"
v-if="isDropdownUIWidget"
v-model="modelValue"
:widget
:node-type="widget.nodeType ?? nodeType"
@@ -29,7 +24,6 @@
import { computed } from 'vue'
import { assetService } from '@/platform/assets/services/assetService'
import RichComboWidget from '@/renderer/extensions/vueNodes/widgets/components/RichComboWidget.vue'
import WidgetSelectDefault from '@/renderer/extensions/vueNodes/widgets/components/WidgetSelectDefault.vue'
import WidgetSelectDropdown from '@/renderer/extensions/vueNodes/widgets/components/WidgetSelectDropdown.vue'
import WidgetWithControl from '@/renderer/extensions/vueNodes/widgets/components/WidgetWithControl.vue'
@@ -59,10 +53,6 @@ const comboSpec = computed<ComboInputSpec | undefined>(() => {
return undefined
})
const hasItemSchema = computed(
() => !!comboSpec.value?.remote?.item_schema
)
const specDescriptor = computed<{
kind: AssetKind
allowUpload: boolean

View File

@@ -34,8 +34,6 @@ interface Props {
accept?: string
filterOptions?: FilterOption[]
sortOptions?: SortOption[]
showSort?: boolean
showLayoutSwitcher?: boolean
showOwnershipFilter?: boolean
ownershipOptions?: OwnershipFilterOption[]
showBaseModelFilter?: boolean
@@ -63,8 +61,6 @@ const {
accept,
filterOptions = [],
sortOptions = getDefaultSortOptions(),
showSort = true,
showLayoutSwitcher = true,
showOwnershipFilter,
ownershipOptions,
showBaseModelFilter,
@@ -236,8 +232,6 @@ function handleSelection(item: FormDropdownItem, index: number) {
v-model:base-model-selected="baseModelSelected"
:filter-options
:sort-options
:show-sort
:show-layout-switcher="showLayoutSwitcher"
:show-ownership-filter
:ownership-options
:show-base-model-filter

View File

@@ -20,8 +20,6 @@ interface Props {
isSelected: (item: FormDropdownItem, index: number) => boolean
filterOptions: FilterOption[]
sortOptions: SortOption[]
showSort?: boolean
showLayoutSwitcher?: boolean
showOwnershipFilter?: boolean
ownershipOptions?: OwnershipFilterOption[]
showBaseModelFilter?: boolean
@@ -33,8 +31,6 @@ const {
isSelected,
filterOptions,
sortOptions,
showSort = true,
showLayoutSwitcher = true,
showOwnershipFilter,
ownershipOptions,
showBaseModelFilter,
@@ -116,8 +112,6 @@ const virtualItems = computed<VirtualDropdownItem[]>(() =>
v-model:ownership-selected="ownershipSelected"
v-model:base-model-selected="baseModelSelected"
:sort-options
:show-sort
:show-layout-switcher="showLayoutSwitcher"
:show-ownership-filter
:ownership-options
:show-base-model-filter
@@ -151,7 +145,6 @@ const virtualItems = computed<VirtualDropdownItem[]>(() =>
:preview-url="item.preview_url ?? ''"
:name="item.name"
:label="item.label"
:description="item.description"
:layout="layoutMode"
@click="emit('item-click', item, index)"
/>

View File

@@ -18,13 +18,8 @@ import type { LayoutMode, SortOption } from './types'
const { t } = useI18n()
const overlayProps = useTransformCompatOverlayProps()
const {
showSort = true,
showLayoutSwitcher = true
} = defineProps<{
defineProps<{
sortOptions: SortOption[]
showSort?: boolean
showLayoutSwitcher?: boolean
showOwnershipFilter?: boolean
ownershipOptions?: OwnershipFilterOption[]
showBaseModelFilter?: boolean
@@ -119,7 +114,6 @@ function toggleBaseModelSelection(item: FilterOption) {
/>
<Button
v-if="showSort"
ref="sortTriggerRef"
variant="textonly"
size="icon"
@@ -138,7 +132,6 @@ function toggleBaseModelSelection(item: FilterOption) {
<i class="icon-[lucide--arrow-up-down] size-4" />
</Button>
<Popover
v-if="showSort"
ref="sortPopoverRef"
:dismissable="true"
:close-on-escape="true"
@@ -316,7 +309,6 @@ function toggleBaseModelSelection(item: FilterOption) {
</Popover>
<div
v-if="showLayoutSwitcher"
:class="
cn(
actionButtonStyle,

View File

@@ -12,7 +12,6 @@ interface Props {
previewUrl: string
name: string
label?: string
description?: string
layout?: LayoutMode
}
@@ -28,31 +27,11 @@ const actualDimensions = ref<string | null>(null)
const assetKind = inject(AssetKindKey)
const isVideo = computed(() => assetKind?.value === 'video')
const isAudio = computed(() => assetKind?.value === 'audio')
const audioRef = ref<HTMLAudioElement | null>(null)
const isPlayingAudio = ref(false)
function handleClick() {
emit('click', props.index)
}
function toggleAudioPreview(event: Event) {
event.stopPropagation()
if (!audioRef.value) return
if (isPlayingAudio.value) {
audioRef.value.pause()
isPlayingAudio.value = false
} else {
void audioRef.value.play()
isPlayingAudio.value = true
}
}
function handleAudioEnded() {
isPlayingAudio.value = false
}
function handleImageLoad(event: Event) {
emit('mediaLoad', event)
if (!event.target || !(event.target instanceof HTMLImageElement)) return
@@ -128,25 +107,6 @@ function handleVideoLoad(event: Event) {
muted
@loadeddata="handleVideoLoad"
/>
<div
v-else-if="previewUrl && isAudio"
class="flex size-full cursor-pointer items-center justify-center bg-gradient-to-tr from-violet-500 via-purple-500 to-fuchsia-400"
@click.stop="toggleAudioPreview"
>
<audio
ref="audioRef"
:src="previewUrl"
preload="none"
@ended="handleAudioEnded"
/>
<i
:class="
isPlayingAudio
? 'icon-[lucide--pause] size-5 text-white'
: 'icon-[lucide--play] size-5 text-white'
"
/>
</div>
<img
v-else-if="previewUrl"
:src="previewUrl"
@@ -184,13 +144,6 @@ function handleVideoLoad(event: Event) {
>
{{ label ?? name }}
</span>
<!-- Description -->
<span
v-if="description && layout !== 'grid'"
class="text-secondary line-clamp-1 block overflow-hidden text-xs"
>
{{ description }}
</span>
<!-- Meta Data -->
<span v-if="actualDimensions" class="text-secondary block text-xs">
{{ actualDimensions }}

View File

@@ -12,9 +12,7 @@ export interface FormDropdownItem {
name: string
/** Original/alternate label (e.g., original filename) */
label?: string
/** Short description shown below the name in list view */
description?: string
/** Preview image/video/audio URL */
/** Preview image/video URL */
preview_url?: string
/** Whether the item is immutable (public model) - used for ownership filtering */
is_immutable?: boolean

View File

@@ -214,9 +214,7 @@ const addComboWidget = (
}
)
if (inputSpec.remote && !inputSpec.remote.item_schema) {
// Skip useRemoteWidget when item_schema is present —
// RichComboWidget handles its own data fetching and rendering.
if (inputSpec.remote) {
if (!isComboWidget(widget)) {
throw new Error(`Expected combo widget but received ${widget.type}`)
}

View File

@@ -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
}
}

View File

@@ -2,12 +2,10 @@ import axios from 'axios'
import { useChainCallback } from '@/composables/functional/useChainCallback'
import type { IWidget, LGraphNode } from '@/lib/litegraph/src/litegraph'
import { isCloud } from '@/platform/distribution/types'
import type { RemoteWidgetConfig } from '@/schemas/nodeDefSchema'
import { api } from '@/scripts/api'
import {
getRemoteAuthHeaders,
resolveRoute
} from '../utils/resolveRemoteRoute'
import { useAuthStore } from '@/stores/authStore'
const MAX_RETRIES = 5
const TIMEOUT = 4096
@@ -23,6 +21,17 @@ interface CacheEntry<T> {
failed?: boolean
}
async function getAuthHeaders() {
if (isCloud) {
const authStore = useAuthStore()
const authHeader = await authStore.getAuthHeader()
return {
...(authHeader && { headers: authHeader })
}
}
return {}
}
const dataCache = new Map<string, CacheEntry<unknown>>()
const createCacheKey = (config: RemoteWidgetConfig): string => {
@@ -64,10 +73,9 @@ const fetchData = async (
) => {
const { route, response_key, query_params, timeout = TIMEOUT } = config
const url = resolveRoute(route, config.use_comfy_api)
const authHeaders = await getRemoteAuthHeaders(config.use_comfy_api)
const authHeaders = await getAuthHeaders()
const res = await axios.get(url, {
const res = await axios.get(route, {
params: query_params,
signal: controller.signal,
timeout,

View File

@@ -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 {

View File

@@ -1,70 +0,0 @@
import type { RemoteItemSchema } from '@/schemas/nodeDefSchema'
import type { FormDropdownItem } from '@/renderer/extensions/vueNodes/widgets/components/form/dropdown/types'
/** Traverse an object by dot-path, treating numeric segments as array indices */
export function getByPath(obj: unknown, path: string): unknown {
return path.split('.').reduce((acc: unknown, key: string) => {
if (acc == null) return undefined
const idx = Number(key)
if (Number.isInteger(idx) && idx >= 0 && Array.isArray(acc)) return acc[idx]
return (acc as Record<string, unknown>)[key]
}, obj)
}
/** Resolve a label — either dot-path or template with {field.path} placeholders */
export function resolveLabel(template: string, item: unknown): string {
if (!template.includes('{')) {
return String(getByPath(item, template) ?? '')
}
return template.replace(/\{([^}]+)\}/g, (_, path: string) =>
String(getByPath(item, path) ?? '')
)
}
/** Map a raw API object to a FormDropdownItem using the item_schema */
export function mapToDropdownItem(
raw: unknown,
schema: RemoteItemSchema
): FormDropdownItem {
return {
id: String(getByPath(raw, schema.value_field) ?? ''),
name: resolveLabel(schema.label_field, raw),
description: schema.description_field
? resolveLabel(schema.description_field, raw)
: undefined,
preview_url: schema.preview_url_field
? String(getByPath(raw, schema.preview_url_field) ?? '')
: undefined
}
}
/** Extract items array from full API response using response_key */
export function extractItems(
response: unknown,
responseKey?: string
): unknown[] {
const data = responseKey ? getByPath(response, responseKey) : response
return Array.isArray(data) ? data : []
}
/** Build search text for an item from the specified search fields */
export function buildSearchText(raw: unknown, searchFields: string[]): string {
return searchFields
.map((field) => String(getByPath(raw, field) ?? ''))
.filter(Boolean)
.join(' ')
.toLowerCase()
}
/** Extract unique filter values from items */
export function extractFilterValues(
items: unknown[],
filterField: string
): string[] {
const values = new Set<string>()
for (const item of items) {
const value = getByPath(item, filterField)
if (value != null) values.add(String(value))
}
return Array.from(values).sort()
}

View File

@@ -1,55 +0,0 @@
import axios from 'axios'
import { getComfyApiBaseUrl } from '@/config/comfyApi'
import { useAuthStore } from '@/stores/authStore'
/**
* Resolve a RemoteOptions route to a full URL.
* - useComfyApi=true → prepend getComfyApiBaseUrl()
* - Otherwise → use as-is
*/
export function resolveRoute(
route: string,
useComfyApi?: boolean
): string {
if (useComfyApi) {
return getComfyApiBaseUrl() + route
}
return route
}
/**
* Get auth headers for a remote request.
* - useComfyApi=true → inject auth headers (comfy-api requires it)
* - Otherwise → no auth headers injected
*/
export async function getRemoteAuthHeaders(
useComfyApi?: boolean
): Promise<Record<string, any>> {
if (useComfyApi) {
const authStore = useAuthStore()
const authHeader = await authStore.getAuthHeader()
if (authHeader) {
return { headers: authHeader }
}
}
return {}
}
/**
* Convenience: make an authenticated GET request to a remote route.
*/
export async function fetchRemoteRoute(
route: string,
options: {
params?: Record<string, string>
timeout?: number
signal?: AbortSignal
useComfyApi?: boolean
} = {}
) {
const { useComfyApi, ...requestOptions } = options
const url = resolveRoute(route, useComfyApi)
const authHeaders = await getRemoteAuthHeaders(useComfyApi)
return axios.get(url, { ...requestOptions, ...authHeaders })
}

View File

@@ -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<typeof zChartInputSpec>
export type GalleriaInputSpec = z.infer<typeof zGalleriaInputSpec>
export type TextareaInputSpec = z.infer<typeof zTextareaInputSpec>
export type CurveInputSpec = z.infer<typeof zCurveInputSpec>
export type RangeInputSpec = z.infer<typeof zRangeInputSpec>
export type CustomInputSpec = z.infer<typeof zCustomInputSpec>
export type InputSpec = z.infer<typeof zInputSpec>

View File

@@ -5,16 +5,6 @@ import { resultItemType } from '@/schemas/apiSchema'
import { CONTROL_OPTIONS } from '@/types/simplifiedWidget'
const zComboOption = z.union([z.string(), z.number()])
const zRemoteItemSchema = z.object({
value_field: z.string(),
label_field: z.string(),
preview_url_field: z.string().optional(),
preview_type: z.enum(['image', 'video', 'audio']).default('image'),
description_field: z.string().optional(),
search_fields: z.array(z.string()).optional(),
filter_field: z.string().optional()
})
const zRemoteWidgetConfig = z.object({
route: z.string().url().or(z.string().startsWith('/')),
refresh: z.number().gte(128).safe().or(z.number().lte(0).safe()).optional(),
@@ -23,9 +13,7 @@ const zRemoteWidgetConfig = z.object({
refresh_button: z.boolean().optional(),
control_after_refresh: z.enum(['first', 'last']).optional(),
timeout: z.number().gte(0).optional(),
max_retries: z.number().gte(0).optional(),
item_schema: zRemoteItemSchema.optional(),
use_comfy_api: z.boolean().optional()
max_retries: z.number().gte(0).optional()
})
const zMultiSelectOption = z.object({
placeholder: z.string().optional(),
@@ -68,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({
@@ -366,7 +352,6 @@ export const zMatchTypeOptions = z.object({
export type ComfyInputsSpec = z.infer<typeof zComfyInputsSpec>
export type ComfyOutputTypesSpec = z.infer<typeof zComfyOutputTypesSpec>
export type ComfyNodeDef = z.infer<typeof zComfyNodeDef>
export type RemoteItemSchema = z.infer<typeof zRemoteItemSchema>
export type RemoteWidgetConfig = z.infer<typeof zRemoteWidgetConfig>
export type ComboInputOptions = z.infer<typeof zComboInputOptions>

View File

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

View File

@@ -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)
})
})

View File

@@ -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(' ')
}

View File

@@ -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)

View File

@@ -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]