Compare commits

...

1 Commits

Author SHA1 Message Date
Terry Jia
904acd0f5c Range editor 2026-02-26 08:20:45 -05:00
15 changed files with 893 additions and 1 deletions

View File

@@ -0,0 +1,125 @@
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(highlight.attributes('width')).toBe('0.6000000000000001')
})
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"]')
// 64/255 ≈ 25.1%, 192/255 ≈ 75.3%
expect(minHandle.attributes('style')).toContain('left: 25.0')
expect(maxHandle.attributes('style')).toContain('left: 75.')
})
})

View File

@@ -0,0 +1,283 @@
<template>
<div>
<div
ref="trackRef"
class="relative select-none"
@pointerdown.stop="handleTrackPointerDown"
@contextmenu.prevent.stop
>
<!-- Track -->
<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>
<!-- Min handle -->
<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>
<!-- Midpoint handle -->
<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>
<!-- Max handle -->
<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>
</div>
<!-- Value inputs -->
<div 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 '@/components/curve/curveUtils'
import { useRangeEditor } from '@/composables/useRangeEditor'
import type { ColorStop } from '@/lib/litegraph/src/interfaces'
import type { RangeValue } from '@/lib/litegraph/src/types/widgets'
import {
clamp,
clamp01,
gammaToPosition,
normalize,
positionToGamma
} from './rangeUtils'
const {
display = 'plain',
gradientStops,
showMidpoint = false,
midpointScale = 'linear',
histogram,
valueMin = 0,
valueMax = 1
} = defineProps<{
display?: 'plain' | 'gradient' | 'histogram'
gradientStops?: ColorStop[]
showMidpoint?: boolean
midpointScale?: 'linear' | 'gamma'
histogram?: Uint32Array | null
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)
})
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)
}
/** Normalize an actual value to 0-1 for SVG positioning */
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' ? clamp01(gammaToPosition(val)) : clamp01(val)
modelValue.value = { ...modelValue.value, midpoint: position }
}
})
const histogramPath = computed(() =>
histogram ? histogramToPath(histogram) : ''
)
</script>

View File

@@ -0,0 +1,30 @@
<template>
<RangeEditor
v-model="modelValue"
:display="widget?.options?.display"
:gradient-stops="widget?.options?.gradient_stops"
:show-midpoint="widget?.options?.show_midpoint"
:midpoint-scale="widget?.options?.midpoint_scale"
:histogram="widget?.options?.histogram"
:value-min="widget?.options?.value_min"
:value-max="widget?.options?.value_max"
/>
</template>
<script setup lang="ts">
import type {
IWidgetRangeOptions,
RangeValue
} from '@/lib/litegraph/src/types/widgets'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
import RangeEditor from './RangeEditor.vue'
defineProps<{
widget?: SimplifiedWidget<RangeValue, IWidgetRangeOptions>
}>()
const modelValue = defineModel<RangeValue>({
default: () => ({ min: 0, max: 1 })
})
</script>

View File

@@ -0,0 +1,131 @@
import { describe, expect, it } from 'vitest'
import {
clamp,
clamp01,
constrainRange,
denormalize,
formatMidpointLabel,
gammaToPosition,
normalize,
positionToGamma
} from './rangeUtils'
describe('clamp', () => {
it('clamps to arbitrary range', () => {
expect(clamp(128, 0, 255)).toBe(128)
expect(clamp(-10, 0, 255)).toBe(0)
expect(clamp(300, 0, 255)).toBe(255)
})
})
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('clamp01', () => {
it('returns value within bounds unchanged', () => {
expect(clamp01(0.5)).toBe(0.5)
})
it('clamps values below 0', () => {
expect(clamp01(-0.5)).toBe(0)
})
it('clamps values above 1', () => {
expect(clamp01(1.5)).toBe(1)
})
it('returns 0 for 0', () => {
expect(clamp01(0)).toBe(0)
})
it('returns 1 for 1', () => {
expect(clamp01(1)).toBe(1)
})
})
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('formatMidpointLabel', () => {
it('formats linear scale as decimal', () => {
expect(formatMidpointLabel(0.5, 'linear')).toBe('0.50')
})
it('formats gamma scale as gamma value', () => {
expect(formatMidpointLabel(0.5, 'gamma')).toBe('1.00')
})
})
describe('constrainRange', () => {
it('passes through valid range unchanged', () => {
const result = constrainRange({ min: 0.2, max: 0.8 })
expect(result).toEqual({ min: 0.2, max: 0.8, midpoint: undefined })
})
it('clamps values to [0, 1]', () => {
const result = constrainRange({ min: -0.5, max: 1.5 })
expect(result.min).toBe(0)
expect(result.max).toBe(1)
})
it('enforces min <= max', () => {
const result = constrainRange({ min: 0.8, max: 0.3 })
expect(result.min).toBe(0.8)
expect(result.max).toBe(0.8)
})
it('preserves midpoint when present', () => {
const result = constrainRange({ min: 0.2, max: 0.8, midpoint: 0.5 })
expect(result.midpoint).toBe(0.5)
})
it('clamps midpoint to [0, 1]', () => {
const result = constrainRange({ min: 0.2, max: 0.8, midpoint: 1.5 })
expect(result.midpoint).toBe(1)
})
})

View File

@@ -0,0 +1,48 @@
import type { RangeValue } from '@/lib/litegraph/src/types/widgets'
export function clamp(value: number, min: number, max: number): number {
return Math.max(min, Math.min(max, value))
}
export function clamp01(value: number): number {
return clamp(value, 0, 1)
}
export function normalize(value: number, min: number, max: number): number {
return max === min ? 0 : (value - min) / (max - min)
}
export function denormalize(
normalized: number,
min: number,
max: number
): number {
return min + normalized * (max - min)
}
export function positionToGamma(position: number): number {
const clamped = Math.max(0.001, Math.min(0.999, position))
return -Math.log2(clamped)
}
export function gammaToPosition(gamma: number): number {
return Math.pow(2, -gamma)
}
export function formatMidpointLabel(
position: number,
scale: 'linear' | 'gamma'
): string {
if (scale === 'gamma') {
return positionToGamma(position).toFixed(2)
}
return position.toFixed(2)
}
export function constrainRange(value: RangeValue): RangeValue {
const min = clamp01(value.min)
const max = clamp01(Math.max(min, value.max))
const midpoint =
value.midpoint !== undefined ? clamp01(value.midpoint) : undefined
return { min, max, midpoint }
}

View File

@@ -0,0 +1,114 @@
import { onBeforeUnmount, ref } from 'vue'
import type { Ref } from 'vue'
import { clamp, denormalize, normalize } from '@/components/range/rangeUtils'
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>
}
export function useRangeEditor({
trackRef,
modelValue,
valueMin,
valueMax
}: UseRangeEditorOptions) {
const activeHandle = ref<HandleType | null>(null)
let cleanupDrag: (() => void) | null = null
/** Convert pointer event to actual value in [valueMin, valueMax] */
function pointerToValue(e: PointerEvent): number {
const el = trackRef.value
if (!el) return valueMin.value
const rect = el.getBoundingClientRect()
const normalized = Math.max(
0,
Math.min(1, (e.clientX - rect.left) / rect.width)
)
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) {
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 = Math.max(0, Math.min(1, midNorm))
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 {
activeHandle,
handleTrackPointerDown,
startDrag
}
}

View File

@@ -20,6 +20,7 @@ if (!isCloud) {
}
import './noteNode'
import './painter'
import './rangeHistogram'
import './previewAny'
import './rerouteNode'
import './saveImageExtraOutput'

View File

@@ -0,0 +1,37 @@
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import type { RangeValue } from '@/lib/litegraph/src/types/widgets'
import { useExtensionService } from '@/services/extensionService'
const HISTOGRAM_KEY_PREFIX = 'range_histogram_'
useExtensionService().registerExtension({
name: 'Comfy.RangeHistogram',
async nodeCreated(node: LGraphNode) {
const hasRangeWidget = node.widgets?.some((w) => w.type === 'range')
if (!hasRangeWidget) return
const onExecuted = node.onExecuted
node.onExecuted = function (output: Record<string, unknown>) {
onExecuted?.call(this, output)
for (const widget of node.widgets ?? []) {
if (widget.type !== 'range') continue
const data = output[HISTOGRAM_KEY_PREFIX + widget.name]
if (!Array.isArray(data)) continue
if (widget.options) {
;(widget.options as Record<string, unknown>).histogram =
new Uint32Array(data as number[])
// Force reactive update: widget.options is not reactive, but
// widget.value is (via BaseWidget._state). Re-assigning value
// triggers processedWidgets recomputation in NodeWidgets.vue,
// which then reads the updated options from the store proxy.
widget.value = { ...(widget.value as RangeValue) }
}
}
}
}
})

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,31 @@ 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
histogram?: Uint32Array | null
}
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

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

@@ -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
}
]
]
@@ -233,7 +244,8 @@ const EXPANDING_TYPES = [
'markdown',
'load3D',
'curve',
'painter'
'painter',
'range'
] as const
export function shouldExpand(type: string): boolean {

View File

@@ -135,6 +135,30 @@ const zCurveInputSpec = zBaseInputOptions.extend({
default: z.array(zCurvePoint).optional()
})
const zRangeValue = z.object({
min: z.number(),
max: z.number(),
midpoint: z.number().optional()
})
const zColorStop = z.object({
offset: z.number(),
color: z.tuple([z.number(), z.number(), z.number()])
})
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(),
@@ -156,6 +180,7 @@ const zInputSpec = z.union([
zGalleriaInputSpec,
zTextareaInputSpec,
zCurveInputSpec,
zRangeInputSpec,
zCustomInputSpec
])
@@ -201,6 +226,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

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