Compare commits

..

4 Commits

Author SHA1 Message Date
dante01yoon
0e6bf4a287 fix: use spreadsheet-style positional aliases beyond z
Match backend _positional_alias logic so expressions with 27+
inputs use aa, ab, etc. instead of invalid characters.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 10:01:04 +09:00
dante01yoon
ea57c7bffc fix: remove unused export EagerEvalResult 2026-02-27 21:37:29 +09:00
dante01yoon
57cb3b4756 feat: register eager eval extension with badge display
Wire up eager eval to node lifecycle with result badges, input value
labels, backend result caching, and expression re-evaluation.
2026-02-27 21:35:00 +09:00
dante01yoon
823f4d3726 feat: add eager eval composable for frontend-side node evaluation
Add EagerEval schema and useNodeEagerEval composable that evaluates
JSONata expressions client-side for instant preview on nodes.
파일: nodeDefSchema.ts, useNodeEagerEval.ts,
2026-02-27 21:31:58 +09:00
46 changed files with 1326 additions and 1140 deletions

View File

@@ -22,9 +22,7 @@ const extraFileExtensions = ['.vue']
const commonGlobals = {
...globals.browser,
__COMFYUI_FRONTEND_VERSION__: 'readonly',
__DISTRIBUTION__: 'readonly',
__IS_NIGHTLY__: 'readonly'
__COMFYUI_FRONTEND_VERSION__: 'readonly'
} as const
const settings = {

View File

@@ -51,6 +51,7 @@ onMounted(() => {
// See: https://vite.dev/guide/build#load-error-handling
window.addEventListener('vite:preloadError', (event) => {
event.preventDefault()
// eslint-disable-next-line no-undef
if (__DISTRIBUTION__ === 'cloud') {
captureException(event.payload, {
tags: { error_type: 'vite_preload_error' }

View File

@@ -1,7 +1,7 @@
import { mount } from '@vue/test-utils'
import { describe, expect, it } from 'vitest'
import type { CurvePoint } from './types'
import type { CurvePoint } from '@/lib/litegraph/src/types/widgets'
import CurveEditor from './CurveEditor.vue'

View File

@@ -77,8 +77,7 @@
import { computed, useTemplateRef } from 'vue'
import { useCurveEditor } from '@/composables/useCurveEditor'
import type { CurvePoint } from './types'
import type { CurvePoint } from '@/lib/litegraph/src/types/widgets'
import { histogramToPath } from './curveUtils'

View File

@@ -3,7 +3,7 @@
</template>
<script setup lang="ts">
import type { CurvePoint } from './types'
import type { CurvePoint } from '@/lib/litegraph/src/types/widgets'
import CurveEditor from './CurveEditor.vue'

View File

@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest'
import type { CurvePoint } from './types'
import type { CurvePoint } from '@/lib/litegraph/src/types/widgets'
import {
createMonotoneInterpolator,

View File

@@ -1,4 +1,4 @@
import type { CurvePoint } from './types'
import type { CurvePoint } from '@/lib/litegraph/src/types/widgets'
/**
* Monotone cubic Hermite interpolation.
@@ -95,15 +95,15 @@ export function histogramToPath(histogram: Uint32Array): string {
const max = sorted[Math.floor(255 * 0.995)]
if (max === 0) return ''
const invMax = 1 / max
const parts: string[] = ['M0,1']
const step = 1 / 255
let d = 'M0,1'
for (let i = 0; i < 256; i++) {
const x = i / 255
const y = 1 - Math.min(1, histogram[i] * invMax)
parts.push(`L${x},${y}`)
const x = i * step
const y = 1 - Math.min(1, histogram[i] / max)
d += ` L${x.toFixed(4)},${y.toFixed(4)}`
}
parts.push('L1,1 Z')
return parts.join(' ')
d += ' L1,1 Z'
return d
}
export function curvesToLUT(points: CurvePoint[]): Uint8Array {

View File

@@ -1 +0,0 @@
export type CurvePoint = [x: number, y: number]

View File

@@ -438,6 +438,7 @@ onMounted(() => {
const systemStatsStore = useSystemStatsStore()
const distributions = computed(() => {
// eslint-disable-next-line no-undef
switch (__DISTRIBUTION__) {
case 'cloud':
return [TemplateIncludeOnDistributionEnum.Cloud]

View File

@@ -131,11 +131,11 @@ export const Queued: Story = {
const exec = useExecutionStore()
const jobId = 'job-queued-1'
const priority = 104
const queueIndex = 104
// Current job in pending
queue.pendingTasks = [
makePendingTask(jobId, priority, Date.now() - 90_000)
makePendingTask(jobId, queueIndex, Date.now() - 90_000)
]
// Add some other pending jobs to give context
queue.pendingTasks.push(
@@ -179,13 +179,13 @@ export const QueuedParallel: Story = {
const exec = useExecutionStore()
const jobId = 'job-queued-parallel'
const priority = 210
const queueIndex = 210
// Current job in pending with some ahead
queue.pendingTasks = [
makePendingTask('job-ahead-1', 200, Date.now() - 180_000),
makePendingTask('job-ahead-2', 205, Date.now() - 150_000),
makePendingTask(jobId, priority, Date.now() - 120_000)
makePendingTask(jobId, queueIndex, Date.now() - 120_000)
]
// Seen 2 minutes ago - set via prompt metadata above
@@ -238,9 +238,9 @@ export const Running: Story = {
const exec = useExecutionStore()
const jobId = 'job-running-1'
const priority = 300
const queueIndex = 300
queue.runningTasks = [
makeRunningTask(jobId, priority, Date.now() - 65_000)
makeRunningTask(jobId, queueIndex, Date.now() - 65_000)
]
queue.historyTasks = [
makeHistoryTask('hist-r1', 250, 30, true),
@@ -279,10 +279,10 @@ export const QueuedZeroAheadSingleRunning: Story = {
const exec = useExecutionStore()
const jobId = 'job-queued-zero-ahead-single'
const priority = 510
const queueIndex = 510
queue.pendingTasks = [
makePendingTask(jobId, priority, Date.now() - 45_000)
makePendingTask(jobId, queueIndex, Date.now() - 45_000)
]
queue.historyTasks = [
@@ -324,10 +324,10 @@ export const QueuedZeroAheadMultiRunning: Story = {
const exec = useExecutionStore()
const jobId = 'job-queued-zero-ahead-multi'
const priority = 520
const queueIndex = 520
queue.pendingTasks = [
makePendingTask(jobId, priority, Date.now() - 20_000)
makePendingTask(jobId, queueIndex, Date.now() - 20_000)
]
queue.historyTasks = [
@@ -380,8 +380,8 @@ export const Completed: Story = {
const queue = useQueueStore()
const jobId = 'job-completed-1'
const priority = 400
queue.historyTasks = [makeHistoryTask(jobId, priority, 37, true)]
const queueIndex = 400
queue.historyTasks = [makeHistoryTask(jobId, queueIndex, 37, true)]
return { args: { ...args, jobId } }
},
@@ -401,11 +401,11 @@ export const Failed: Story = {
const queue = useQueueStore()
const jobId = 'job-failed-1'
const priority = 410
const queueIndex = 410
queue.historyTasks = [
makeHistoryTask(
jobId,
priority,
queueIndex,
12,
false,
'Example error: invalid inputs for node X'

View File

@@ -168,14 +168,14 @@ const queuedAtValue = computed(() =>
const currentQueueIndex = computed<number | null>(() => {
const task = taskForJob.value
return task ? Number(task.job.priority) : null
return task ? Number(task.queueIndex) : null
})
const jobsAhead = computed<number | null>(() => {
const idx = currentQueueIndex.value
if (idx == null) return null
const ahead = queueStore.pendingTasks.filter(
(t: TaskItemImpl) => Number(t.job.priority) < idx
(t: TaskItemImpl) => Number(t.queueIndex) < idx
)
return ahead.length
})

View File

@@ -1,125 +0,0 @@
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

@@ -1,283 +0,0 @@
<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

@@ -1,30 +0,0 @@
<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

@@ -1,131 +0,0 @@
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

@@ -1,48 +0,0 @@
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

@@ -2,11 +2,7 @@ import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { useImagePreviewWidget } from '@/renderer/extensions/vueNodes/widgets/composables/useImagePreviewWidget'
export const CANVAS_IMAGE_PREVIEW_WIDGET = '$$canvas-image-preview'
const CANVAS_IMAGE_PREVIEW_NODE_TYPES = new Set([
'PreviewImage',
'SaveImage',
'GLSLShader'
])
const CANVAS_IMAGE_PREVIEW_NODE_TYPES = new Set(['PreviewImage', 'SaveImage'])
export function supportsVirtualCanvasImagePreview(node: LGraphNode): boolean {
return CANVAS_IMAGE_PREVIEW_NODE_TYPES.has(node.type)

View File

@@ -0,0 +1,467 @@
import { describe, expect, it } from 'vitest'
import {
buildEagerEvalContext,
useNodeEagerEval
} from '@/composables/node/useNodeEagerEval'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { EagerEval } from '@/schemas/nodeDefSchema'
import {
createMockLGraphNode,
createMockLLink
} from '@/utils/__tests__/litegraphTestUtils'
// ---------------------
// Test helpers
// ---------------------
function createMockEagerNode(
config: Partial<EagerEval>,
widgets: Array<{ name: string; value: unknown }> = [],
inputs: Array<{ name: string; link: number | null }> = []
): LGraphNode {
const fullConfig: EagerEval = { engine: 'jsonata', ...config }
const mockWidgets = widgets.map(({ name, value }) => ({
name,
value,
type: 'number'
}))
const baseNode = createMockLGraphNode()
return Object.assign(baseNode, {
widgets: mockWidgets,
inputs,
constructor: {
nodeData: {
name: 'TestMathNode',
eager_eval: fullConfig
}
}
})
}
// ---------------------
// buildEagerEvalContext
// ---------------------
describe('buildEagerEvalContext', () => {
it('maps disconnected input widgets to context by name', () => {
const node = createMockEagerNode(
{ expr: 'a + b' },
[
{ name: 'a', value: 5 },
{ name: 'b', value: 3 }
],
[
{ name: 'a', link: null },
{ name: 'b', link: null }
]
)
const ctx = buildEagerEvalContext(node)
expect(ctx.a).toBe(5)
expect(ctx.b).toBe(3)
})
it('skips connected inputs when linked node has no output data yet', () => {
const node = createMockEagerNode(
{ expr: 'a + b' },
[
{ name: 'a', value: 5 },
{ name: 'b', value: 3 }
],
[
{ name: 'a', link: null },
{ name: 'b', link: 42 }
]
)
const ctx = buildEagerEvalContext(node)
expect(ctx.a).toBe(5)
expect('b' in ctx).toBe(false)
})
it('uses connected node output value when data is available', () => {
const sourceNode = createMockLGraphNode()
Object.assign(sourceNode, {
outputs: [{ _data: 7, name: 'result' }]
})
const mockLink = createMockLLink({
id: 42,
origin_id: 99,
origin_slot: 0,
target_id: 1,
target_slot: 1
})
const node = createMockEagerNode(
{ expr: 'a + b' },
[{ name: 'a', value: 5 }],
[
{ name: 'a', link: null },
{ name: 'b', link: 42 }
]
)
Object.assign(node, {
graph: {
getLink: (id: number) => (id === 42 ? mockLink : undefined),
getNodeById: (id: number) => (id === 99 ? sourceNode : null)
}
})
const ctx = buildEagerEvalContext(node)
expect(ctx.a).toBe(5)
expect(ctx.b).toBe(7)
})
it('assigns positional letters to connected inputs with data', () => {
const sourceNode = createMockLGraphNode()
Object.assign(sourceNode, {
outputs: [{ _data: 12, name: 'value' }]
})
const mockLink = createMockLLink({
id: 10,
origin_id: 50,
origin_slot: 0,
target_id: 1,
target_slot: 0
})
const node = createMockEagerNode(
{ expr: 'a * 2' },
[],
[{ name: 'x_input', link: 10 }]
)
Object.assign(node, {
graph: {
getLink: (id: number) => (id === 10 ? mockLink : undefined),
getNodeById: (id: number) => (id === 50 ? sourceNode : null)
}
})
const ctx = buildEagerEvalContext(node)
expect(ctx.x_input).toBe(12)
expect(ctx.a).toBe(12)
})
it('exposes autogrow base names (e.g. "value0") alongside full names ("values.value0")', () => {
const node = createMockEagerNode(
{ expr_widget: 'expression' },
[
{ name: 'expression', value: 'value0 + value1' },
{ name: 'values.value0', value: 3 },
{ name: 'values.value1', value: 7 }
],
[
{ name: 'expression', link: null },
{ name: 'values.value0', link: null },
{ name: 'values.value1', link: null }
]
)
const ctx = buildEagerEvalContext(node)
expect(ctx['values.value0']).toBe(3)
expect(ctx['values.value1']).toBe(7)
expect(ctx['value0']).toBe(3)
expect(ctx['value1']).toBe(7)
expect(ctx.a).toBe(3)
expect(ctx.b).toBe(7)
expect((ctx as Record<string, unknown>).values).toEqual([3, 7])
})
it('includes values array for aggregate functions', () => {
const node = createMockEagerNode(
{ expr: '$sum(values)' },
[
{ name: 'value0', value: 1 },
{ name: 'value1', value: 2 },
{ name: 'value2', value: 3 }
],
[
{ name: 'value0', link: null },
{ name: 'value1', link: null },
{ name: 'value2', link: null }
]
)
const ctx = buildEagerEvalContext(node)
expect((ctx as Record<string, unknown>).values).toEqual([1, 2, 3])
})
it('assigns positional letters (a, b, c) to disconnected inputs', () => {
const node = createMockEagerNode(
{ expr: 'a * b' },
[
{ name: 'x_input', value: 4 },
{ name: 'y_input', value: 7 }
],
[
{ name: 'x_input', link: null },
{ name: 'y_input', link: null }
]
)
const ctx = buildEagerEvalContext(node)
expect(ctx.a).toBe(4)
expect(ctx.b).toBe(7)
expect(ctx.x_input).toBe(4)
expect(ctx.y_input).toBe(7)
})
it('parses string widget values as numbers', () => {
const node = createMockEagerNode(
{ expr: 'a + b' },
[
{ name: 'a', value: '10' },
{ name: 'b', value: '3.5' }
],
[
{ name: 'a', link: null },
{ name: 'b', link: null }
]
)
const ctx = buildEagerEvalContext(node)
expect(ctx.a).toBe(10)
expect(ctx.b).toBe(3.5)
})
it('returns null for non-numeric widget values', () => {
const node = createMockEagerNode(
{ expr: 'a' },
[{ name: 'a', value: 'not a number' }],
[{ name: 'a', link: null }]
)
const ctx = buildEagerEvalContext(node)
expect(ctx.a).toBeNull()
})
it('includes standalone widgets not tied to inputs', () => {
const node = createMockEagerNode(
{ expr: 'a', expr_widget: 'expression' },
[
{ name: 'expression', value: 'a + 1' },
{ name: 'a', value: 5 }
],
[{ name: 'a', link: null }]
)
const ctx = buildEagerEvalContext(node)
expect(ctx.a).toBe(5)
})
it('excludes non-numeric values from the values array', () => {
const node = createMockEagerNode(
{ expr: '$sum(values)' },
[
{ name: 'v0', value: 1 },
{ name: 'v1', value: 'not a number' },
{ name: 'v2', value: 3 }
],
[
{ name: 'v0', link: null },
{ name: 'v1', link: null },
{ name: 'v2', link: null }
]
)
const ctx = buildEagerEvalContext(node)
expect((ctx as Record<string, unknown>).values).toEqual([1, 3])
})
it('omits values array when all inputs are non-numeric', () => {
const node = createMockEagerNode(
{ expr: 'a' },
[{ name: 'a', value: 'hello' }],
[{ name: 'a', link: null }]
)
const ctx = buildEagerEvalContext(node)
expect('values' in ctx).toBe(false)
})
it('excludes expr_widget input from context and positional mapping', () => {
const node = createMockEagerNode(
{ expr_widget: 'expression' },
[
{ name: 'expression', value: 'a + b' },
{ name: 'a', value: 5 },
{ name: 'b', value: 2 },
{ name: 'c', value: 3 }
],
[
{ name: 'expression', link: null },
{ name: 'a', link: null },
{ name: 'b', link: null },
{ name: 'c', link: null }
]
)
const ctx = buildEagerEvalContext(node)
expect('expression' in ctx).toBe(false)
expect(ctx.a).toBe(5)
expect(ctx.b).toBe(2)
expect(ctx.c).toBe(3)
})
it('does not overwrite named inputs with positional letters', () => {
const node = createMockEagerNode(
{ expr: 'a + b' },
[
{ name: 'a', value: 10 },
{ name: 'b', value: 20 }
],
[
{ name: 'a', link: null },
{ name: 'b', link: null }
]
)
const ctx = buildEagerEvalContext(node)
// Named inputs 'a' and 'b' should keep their original values
expect(ctx.a).toBe(10)
expect(ctx.b).toBe(20)
})
})
// ---------------------
// useNodeEagerEval
// ---------------------
describe('useNodeEagerEval', () => {
describe('hasEagerEval', () => {
it('returns true for nodes with eager_eval config', () => {
const node = createMockEagerNode({ expr: 'a + b' })
const { hasEagerEval } = useNodeEagerEval()
expect(hasEagerEval(node)).toBe(true)
})
it('returns false for nodes without eager_eval', () => {
const node = createMockLGraphNode()
Object.assign(node, {
constructor: { nodeData: { name: 'RegularNode' } }
})
const { hasEagerEval } = useNodeEagerEval()
expect(hasEagerEval(node)).toBe(false)
})
it('returns false for nodes with no constructor data', () => {
const node = createMockLGraphNode()
const { hasEagerEval } = useNodeEagerEval()
expect(hasEagerEval(node)).toBe(false)
})
})
describe('formatEagerResult', () => {
it('formats integer result', () => {
const { formatEagerResult } = useNodeEagerEval()
expect(formatEagerResult({ value: 42 })).toBe('42')
})
it('formats float result with trimmed zeros', () => {
const { formatEagerResult } = useNodeEagerEval()
expect(formatEagerResult({ value: 3.14 })).toBe('3.14')
})
it('trims trailing zeros', () => {
const { formatEagerResult } = useNodeEagerEval()
expect(formatEagerResult({ value: 3.1 })).toBe('3.1')
})
it('formats whole float as integer', () => {
const { formatEagerResult } = useNodeEagerEval()
expect(formatEagerResult({ value: 5.0 })).toBe('5')
})
it('returns error message for errors', () => {
const { formatEagerResult } = useNodeEagerEval()
expect(formatEagerResult({ value: null, error: 'bad expr' })).toBe(
'bad expr'
)
})
it('returns empty string for null value', () => {
const { formatEagerResult } = useNodeEagerEval()
expect(formatEagerResult({ value: null })).toBe('')
})
it('stringifies non-number values', () => {
const { formatEagerResult } = useNodeEagerEval()
expect(formatEagerResult({ value: true })).toBe('true')
})
})
describe('getNodeEagerResult', () => {
it('returns null for nodes without eager_eval', () => {
const node = createMockLGraphNode()
const { getNodeEagerResult } = useNodeEagerEval()
expect(getNodeEagerResult(node)).toBeNull()
})
it('returns error for invalid expression', () => {
const node = createMockEagerNode(
{ expr_widget: 'expression' },
[{ name: 'expression', value: '(((' }],
[]
)
const { getNodeEagerResult } = useNodeEagerEval()
const result = getNodeEagerResult(node)
expect(result).toEqual({ value: null, error: 'Invalid expression' })
})
it('returns null when no expression configured', () => {
const node = createMockEagerNode({}, [], [])
const { getNodeEagerResult } = useNodeEagerEval()
expect(getNodeEagerResult(node)).toBeNull()
})
it('returns null when expr_widget references missing widget', () => {
const node = createMockEagerNode(
{ expr_widget: 'missing_widget' },
[],
[]
)
const { getNodeEagerResult } = useNodeEagerEval()
expect(getNodeEagerResult(node)).toBeNull()
})
it('returns null when expr_widget value is empty', () => {
const node = createMockEagerNode(
{ expr_widget: 'expression' },
[{ name: 'expression', value: '' }],
[]
)
const { getNodeEagerResult } = useNodeEagerEval()
expect(getNodeEagerResult(node)).toBeNull()
})
it('returns cached result on subsequent calls with same inputs', async () => {
const node = createMockEagerNode(
{ expr: 'a + b' },
[
{ name: 'a', value: 2 },
{ name: 'b', value: 3 }
],
[
{ name: 'a', link: null },
{ name: 'b', link: null }
]
)
const { getNodeEagerResult } = useNodeEagerEval()
// First call schedules async eval
const first = getNodeEagerResult(node)
expect(first).toBeNull() // no cached result yet
// Wait for async eval to complete
await new Promise((r) => setTimeout(r, 50))
// Second call returns cached result
const second = getNodeEagerResult(node)
expect(second).toEqual({ value: 5 })
})
})
})

View File

@@ -0,0 +1,389 @@
// Frontend eager evaluation for pure-computation nodes (e.g., math expression).
//
// Nodes declare `eager_eval` in their definition to opt in. The frontend
// evaluates a JSONata expression against the node's widget values whenever
// they change, displaying the result without a backend round-trip.
//
// Follows the same async-eval + cache pattern as useNodePricing.ts.
import { readonly, ref } from 'vue'
import type { Ref } from 'vue'
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import type { ComfyNodeDef, EagerEval } from '@/schemas/nodeDefSchema'
import type { Expression } from 'jsonata'
import jsonata from 'jsonata'
// ---------------------
// Types
// ---------------------
type CompiledEagerEval = EagerEval & {
_compiled: Expression | null
}
type NodeConstructorData = Partial<Pick<ComfyNodeDef, 'name' | 'eager_eval'>>
type EagerEvalContext = Record<string, number | null>
type CacheEntry = { sig: string; result: EagerEvalResult }
type InflightEntry = { sig: string; promise: Promise<void> }
type EagerEvalResult = {
value: unknown
error?: string
}
// ---------------------
// Helpers
// ---------------------
/** Convert 0-based index to spreadsheet-style alias: a..z, aa..az, ba... */
function positionalAlias(index: number): string {
let s = ''
let n = index
while (true) {
const rem = n % 26
s = String.fromCharCode(97 + rem) + s
n = Math.floor(n / 26)
if (n === 0) break
n -= 1
}
return s
}
const getNodeConstructorData = (
node: LGraphNode
): NodeConstructorData | undefined =>
(node.constructor as { nodeData?: NodeConstructorData }).nodeData
const asFiniteNumber = (v: unknown): number | null => {
if (v === null || v === undefined) return null
if (typeof v === 'number') return Number.isFinite(v) ? v : null
if (typeof v === 'string') {
const t = v.trim()
if (t === '') return null
const n = Number(t)
return Number.isFinite(n) ? n : null
}
return null
}
/**
* Retrieve the numeric output value from the node connected to the given link.
* Returns undefined when the link/source node cannot be resolved or has no data yet.
*
* Checks output._data first (set by eager eval or backend execution), then
* falls back to reading the source node's widget value for simple pass-through
* nodes (e.g. INT, FLOAT) where output equals widget value.
*/
function getLinkedOutputValue(
node: LGraphNode,
linkId: number
): number | null | undefined {
const link = node.graph?.getLink(linkId)
if (!link) return undefined
const sourceNode = node.graph?.getNodeById(link.origin_id)
if (!sourceNode) return undefined
const output = sourceNode.outputs?.[link.origin_slot]
if (output?._data !== undefined) return asFiniteNumber(output._data)
// Fallback: for single-output nodes (e.g. Int, Float primitives),
// read the "value" widget directly. This enables eager eval without
// requiring a backend round-trip.
if (sourceNode.outputs?.length === 1 && sourceNode.widgets) {
const valueWidget = sourceNode.widgets.find(
(w: IBaseWidget) => w.name === 'value'
)
if (valueWidget) return asFiniteNumber(valueWidget.value)
}
return undefined
}
// ---------------------
// Compile cache
// ---------------------
const compiledCache = new Map<string, CompiledEagerEval | null>()
function compileEagerEval(config: EagerEval): CompiledEagerEval {
const expr = config.expr
if (!expr) return { ...config, _compiled: null }
try {
return { ...config, _compiled: jsonata(expr) }
} catch (e) {
console.error('[eager-eval] failed to compile expr:', expr, e)
return { ...config, _compiled: null }
}
}
function getCompiledForNodeType(
nodeName: string,
config: EagerEval
): CompiledEagerEval | null {
const cacheKey = `${nodeName}:${config.expr ?? ''}`
if (compiledCache.has(cacheKey)) return compiledCache.get(cacheKey) ?? null
const compiled = compileEagerEval(config)
compiledCache.set(cacheKey, compiled)
return compiled
}
// ---------------------
// Context building
// ---------------------
/**
* Build evaluation context from node widget values.
* Maps input widgets to named variables: first input → "a", second → "b", etc.
* Also includes original widget names for direct reference.
*/
export function buildEagerEvalContext(node: LGraphNode): EagerEvalContext {
const ctx: EagerEvalContext = {}
const values: (number | null)[] = []
// Determine which widget holds the expression (should be excluded from context)
const eagerConfig = getNodeConstructorData(node)?.eager_eval
const exprWidgetName = eagerConfig?.expr_widget
// Collect values from input slots, using widget values when disconnected
// and connected node output data when connected.
if (node.inputs) {
for (const input of node.inputs) {
if (input.name === exprWidgetName) continue
let numVal: number | null
if (input.link != null) {
const linked = getLinkedOutputValue(node, input.link)
if (linked === undefined) continue // connected but no data yet
numVal = linked
} else {
const widget = node.widgets?.find(
(w: IBaseWidget) => w.name === input.name
)
if (!widget) continue
numVal = asFiniteNumber(widget.value)
}
ctx[input.name] = numVal
values.push(numVal)
// Autogrow inputs are named "group.baseName" (e.g. "values.value0").
// Also expose the baseName alone so expressions can use "value0" directly,
// matching the context the backend builds from autogrow dict keys.
if (input.name.includes('.')) {
const baseName = input.name.slice(input.name.indexOf('.') + 1)
if (baseName && !(baseName in ctx)) ctx[baseName] = numVal
}
}
}
// Also collect from standalone widgets (not tied to inputs)
if (node.widgets) {
for (const widget of node.widgets) {
if (widget.name in ctx) continue
if (widget.name === exprWidgetName) continue
const isInputWidget = node.inputs?.some((inp) => inp.name === widget.name)
if (isInputWidget) continue
ctx[widget.name] = asFiniteNumber(widget.value)
}
}
// Map positional variables: a, b, c, ... aa, ab, ...
// Only assign if the alias doesn't already exist as a named input
let letterIdx = 0
if (node.inputs) {
for (const input of node.inputs) {
if (input.name === exprWidgetName) continue
if (!(input.name in ctx)) continue
const alias = positionalAlias(letterIdx)
if (!(alias in ctx)) {
ctx[alias] = ctx[input.name]
}
letterIdx++
}
}
// Add values array for aggregate functions ($sum, $max, etc.)
const numericValues = values.filter((v): v is number => v !== null)
if (numericValues.length > 0) {
;(ctx as Record<string, unknown>)['values'] = numericValues
}
return ctx
}
function buildSignature(ctx: EagerEvalContext, expr: string): string {
const parts: string[] = [`e:${expr}`]
for (const [key, val] of Object.entries(ctx)) {
parts.push(`${key}=${val === null ? '' : String(val)}`)
}
return parts.join('|')
}
// ---------------------
// Async eval + cache
// ---------------------
const evalTick = ref(0)
const nodeRevisions = new WeakMap<LGraphNode, Ref<number>>()
function getNodeRevisionRef(node: LGraphNode): Ref<number> {
let rev = nodeRevisions.get(node)
if (!rev) {
rev = ref(0)
nodeRevisions.set(node, rev)
}
return rev
}
const cache = new WeakMap<LGraphNode, CacheEntry>()
const desiredSig = new WeakMap<LGraphNode, string>()
const inflight = new WeakMap<LGraphNode, InflightEntry>()
function scheduleEvaluation(
node: LGraphNode,
compiled: Expression,
ctx: EagerEvalContext,
sig: string
) {
desiredSig.set(node, sig)
const running = inflight.get(node)
if (running && running.sig === sig) return
const promise = Promise.resolve(compiled.evaluate(ctx))
.then((res) => {
if (desiredSig.get(node) !== sig) return
cache.set(node, { sig, result: { value: res } })
// Write result to output._data so downstream nodes (and input label
// display) can read it without requiring a backend round-trip.
if (node.outputs?.[0]) node.outputs[0]._data = res
})
.catch((err: unknown) => {
if (desiredSig.get(node) === sig) {
const message = err instanceof Error ? err.message : 'Evaluation error'
cache.set(node, { sig, result: { value: null, error: message } })
}
})
.finally(() => {
const cur = inflight.get(node)
if (cur && cur.sig === sig) inflight.delete(node)
// Only bump revision if this eval is still the desired one
if (desiredSig.get(node) === sig) {
if (LiteGraph.vueNodesMode) {
getNodeRevisionRef(node).value++
} else {
evalTick.value++
}
}
})
inflight.set(node, { sig, promise })
}
// ---------------------
// Expression resolution
// ---------------------
/**
* Resolve the JSONata expression for a node.
* If `expr_widget` is set, reads the expression from the widget value.
* If `expr` is set, uses the static expression.
*/
function resolveExpression(node: LGraphNode, config: EagerEval): string | null {
if (config.expr_widget) {
const widget = node.widgets?.find(
(w: IBaseWidget) => w.name === config.expr_widget
)
return widget ? String(widget.value ?? '') : null
}
return config.expr ?? null
}
// ---------------------
// Public composable
// ---------------------
export function useNodeEagerEval() {
/**
* Get the eager evaluation result for a node.
* Returns cached result synchronously; schedules async evaluation on cache miss.
*/
function getNodeEagerResult(node: LGraphNode): EagerEvalResult | null {
void evalTick.value
const nodeData = getNodeConstructorData(node)
if (!nodeData?.eager_eval) return null
const config = nodeData.eager_eval
const expr = resolveExpression(node, config)
if (!expr) return null
// Build context and check cache before compiling
const ctx = buildEagerEvalContext(node)
const sig = buildSignature(ctx, expr)
const cached = cache.get(node)
if (cached && cached.sig === sig) return cached.result
// Compile expression only on cache miss
let compiled: Expression | null = null
if (config.expr_widget) {
try {
compiled = jsonata(expr)
} catch {
return { value: null, error: 'Invalid expression' }
}
} else {
const cachedCompiled = getCompiledForNodeType(nodeData.name ?? '', config)
compiled = cachedCompiled?._compiled ?? null
}
if (!compiled) return null
scheduleEvaluation(node, compiled, ctx, sig)
return null
}
/**
* Format an eager eval result for display as a badge.
*/
function formatEagerResult(result: EagerEvalResult): string {
if (result.error) return result.error
if (result.value === null || result.value === undefined) return ''
if (typeof result.value === 'number') {
return Number.isInteger(result.value)
? String(result.value)
: result.value.toFixed(4).replace(/0+$/, '').replace(/\.$/, '')
}
return String(result.value)
}
/**
* Check if a node supports eager evaluation.
*/
function hasEagerEval(node: LGraphNode): boolean {
return !!getNodeConstructorData(node)?.eager_eval
}
/**
* Trigger re-evaluation for a node (call when inputs/widgets change).
*/
function triggerEagerEval(node: LGraphNode): void {
getNodeEagerResult(node)
}
return {
getNodeEagerResult,
formatEagerResult,
hasEagerEval,
triggerEagerEval,
getNodeRevisionRef,
evalRevision: readonly(evalTick)
}
}

View File

@@ -11,7 +11,7 @@ import type { TaskItemImpl } from '@/stores/queueStore'
type TestTask = {
jobId: string
job: { priority: number }
queueIndex: number
mockState: JobState
executionTime?: number
executionEndTimestamp?: number
@@ -174,7 +174,7 @@ const createTask = (
overrides: Partial<TestTask> & { mockState?: JobState } = {}
): TestTask => ({
jobId: overrides.jobId ?? `task-${Math.random().toString(36).slice(2, 7)}`,
job: overrides.job ?? { priority: 0 },
queueIndex: overrides.queueIndex ?? 0,
mockState: overrides.mockState ?? 'pending',
executionTime: overrides.executionTime,
executionEndTimestamp: overrides.executionEndTimestamp,
@@ -258,7 +258,7 @@ describe('useJobList', () => {
it('tracks recently added pending jobs and clears the hint after expiry', async () => {
vi.useFakeTimers()
queueStoreMock.pendingTasks = [
createTask({ jobId: '1', job: { priority: 1 }, mockState: 'pending' })
createTask({ jobId: '1', queueIndex: 1, mockState: 'pending' })
]
const { jobItems } = initComposable()
@@ -287,7 +287,7 @@ describe('useJobList', () => {
vi.useFakeTimers()
const taskId = '2'
queueStoreMock.pendingTasks = [
createTask({ jobId: taskId, job: { priority: 1 }, mockState: 'pending' })
createTask({ jobId: taskId, queueIndex: 1, mockState: 'pending' })
]
const { jobItems } = initComposable()
@@ -300,7 +300,7 @@ describe('useJobList', () => {
vi.mocked(buildJobDisplay).mockClear()
queueStoreMock.pendingTasks = [
createTask({ jobId: taskId, job: { priority: 2 }, mockState: 'pending' })
createTask({ jobId: taskId, queueIndex: 2, mockState: 'pending' })
]
await flush()
jobItems.value
@@ -314,7 +314,7 @@ describe('useJobList', () => {
it('cleans up timeouts on unmount', async () => {
vi.useFakeTimers()
queueStoreMock.pendingTasks = [
createTask({ jobId: '3', job: { priority: 1 }, mockState: 'pending' })
createTask({ jobId: '3', queueIndex: 1, mockState: 'pending' })
]
initComposable()
@@ -331,7 +331,7 @@ describe('useJobList', () => {
queueStoreMock.pendingTasks = [
createTask({
jobId: 'p',
job: { priority: 1 },
queueIndex: 1,
mockState: 'pending',
createTime: 3000
})
@@ -339,7 +339,7 @@ describe('useJobList', () => {
queueStoreMock.runningTasks = [
createTask({
jobId: 'r',
job: { priority: 5 },
queueIndex: 5,
mockState: 'running',
createTime: 2000
})
@@ -347,7 +347,7 @@ describe('useJobList', () => {
queueStoreMock.historyTasks = [
createTask({
jobId: 'h',
job: { priority: 3 },
queueIndex: 3,
mockState: 'completed',
createTime: 1000,
executionEndTimestamp: 5000
@@ -366,9 +366,9 @@ describe('useJobList', () => {
it('filters by job tab and resets failed tab when failures disappear', async () => {
queueStoreMock.historyTasks = [
createTask({ jobId: 'c', job: { priority: 3 }, mockState: 'completed' }),
createTask({ jobId: 'f', job: { priority: 2 }, mockState: 'failed' }),
createTask({ jobId: 'p', job: { priority: 1 }, mockState: 'pending' })
createTask({ jobId: 'c', queueIndex: 3, mockState: 'completed' }),
createTask({ jobId: 'f', queueIndex: 2, mockState: 'failed' }),
createTask({ jobId: 'p', queueIndex: 1, mockState: 'pending' })
]
const instance = initComposable()
@@ -384,7 +384,7 @@ describe('useJobList', () => {
expect(instance.hasFailedJobs.value).toBe(true)
queueStoreMock.historyTasks = [
createTask({ jobId: 'c', job: { priority: 3 }, mockState: 'completed' })
createTask({ jobId: 'c', queueIndex: 3, mockState: 'completed' })
]
await flush()
@@ -396,13 +396,13 @@ describe('useJobList', () => {
queueStoreMock.pendingTasks = [
createTask({
jobId: 'wf-1',
job: { priority: 2 },
queueIndex: 2,
mockState: 'pending',
workflowId: 'workflow-1'
}),
createTask({
jobId: 'wf-2',
job: { priority: 1 },
queueIndex: 1,
mockState: 'pending',
workflowId: 'workflow-2'
})
@@ -426,14 +426,14 @@ describe('useJobList', () => {
queueStoreMock.historyTasks = [
createTask({
jobId: 'alpha',
job: { priority: 2 },
queueIndex: 2,
mockState: 'completed',
createTime: 2000,
executionEndTimestamp: 2000
}),
createTask({
jobId: 'beta',
job: { priority: 1 },
queueIndex: 1,
mockState: 'failed',
createTime: 1000,
executionEndTimestamp: 1000
@@ -471,13 +471,13 @@ describe('useJobList', () => {
queueStoreMock.runningTasks = [
createTask({
jobId: 'active',
job: { priority: 3 },
queueIndex: 3,
mockState: 'running',
executionTime: 7_200_000
}),
createTask({
jobId: 'other',
job: { priority: 2 },
queueIndex: 2,
mockState: 'running',
executionTime: 3_600_000
})
@@ -507,7 +507,7 @@ describe('useJobList', () => {
queueStoreMock.runningTasks = [
createTask({
jobId: 'live-preview',
job: { priority: 1 },
queueIndex: 1,
mockState: 'running'
})
]
@@ -526,7 +526,7 @@ describe('useJobList', () => {
queueStoreMock.runningTasks = [
createTask({
jobId: 'disabled-preview',
job: { priority: 1 },
queueIndex: 1,
mockState: 'running'
})
]
@@ -567,28 +567,28 @@ describe('useJobList', () => {
queueStoreMock.historyTasks = [
createTask({
jobId: 'today-small',
job: { priority: 4 },
queueIndex: 4,
mockState: 'completed',
executionEndTimestamp: Date.now(),
executionTime: 2_000
}),
createTask({
jobId: 'today-large',
job: { priority: 3 },
queueIndex: 3,
mockState: 'completed',
executionEndTimestamp: Date.now(),
executionTime: 5_000
}),
createTask({
jobId: 'yesterday',
job: { priority: 2 },
queueIndex: 2,
mockState: 'failed',
executionEndTimestamp: Date.now() - 86_400_000,
executionTime: 1_000
}),
createTask({
jobId: 'undated',
job: { priority: 1 },
queueIndex: 1,
mockState: 'pending'
})
]

View File

@@ -4,32 +4,64 @@ import { useToast } from 'primevue/usetoast'
import { t } from '@/i18n'
export function useCopyToClipboard() {
const { copy, copied } = useClipboard({ legacy: true })
const { copy, copied } = useClipboard()
const toast = useToast()
const showSuccessToast = () => {
toast.add({
severity: 'success',
summary: t('g.success'),
detail: t('clipboard.successMessage'),
life: 3000
})
}
const showErrorToast = () => {
toast.add({
severity: 'error',
summary: t('g.error'),
detail: t('clipboard.errorMessage')
})
}
async function copyToClipboard(text: string) {
function fallbackCopy(text: string) {
const textarea = document.createElement('textarea')
textarea.setAttribute('readonly', '')
textarea.value = text
textarea.style.position = 'absolute'
textarea.style.left = '-9999px'
textarea.setAttribute('aria-hidden', 'true')
textarea.setAttribute('tabindex', '-1')
textarea.style.width = '1px'
textarea.style.height = '1px'
document.body.appendChild(textarea)
textarea.select()
try {
// using legacy document.execCommand for fallback for old and linux browsers
const successful = document.execCommand('copy')
if (successful) {
showSuccessToast()
} else {
showErrorToast()
}
} catch (err) {
showErrorToast()
} finally {
textarea.remove()
}
}
const copyToClipboard = async (text: string) => {
try {
await copy(text)
if (copied.value) {
toast.add({
severity: 'success',
summary: t('g.success'),
detail: t('clipboard.successMessage'),
life: 3000
})
showSuccessToast()
} else {
toast.add({
severity: 'error',
summary: t('g.error'),
detail: t('clipboard.errorMessage')
})
// If VueUse copy failed, try fallback
fallbackCopy(text)
}
} catch {
toast.add({
severity: 'error',
summary: t('g.error'),
detail: t('clipboard.errorMessage')
})
} catch (err) {
// VueUse copy failed, try fallback
fallbackCopy(text)
}
}

View File

@@ -2,7 +2,7 @@ import { computed, onBeforeUnmount, ref } from 'vue'
import type { Ref } from 'vue'
import { createMonotoneInterpolator } from '@/components/curve/curveUtils'
import type { CurvePoint } from '@/components/curve/types'
import type { CurvePoint } from '@/lib/litegraph/src/types/widgets'
interface UseCurveEditorOptions {
svgRef: Ref<SVGSVGElement | null>
@@ -21,12 +21,11 @@ export function useCurveEditor({ svgRef, modelValue }: UseCurveEditorOptions) {
const xMin = points[0][0]
const xMax = points[points.length - 1][0]
const segments = 128
const range = xMax - xMin
const parts: string[] = []
for (let i = 0; i <= segments; i++) {
const x = xMin + range * (i / segments)
const x = xMin + (xMax - xMin) * (i / segments)
const y = 1 - interpolate(x)
parts.push(`${i === 0 ? 'M' : 'L'}${x},${y}`)
parts.push(`${i === 0 ? 'M' : 'L'}${x.toFixed(4)},${y.toFixed(4)}`)
}
return parts.join('')
})

View File

@@ -1,114 +0,0 @@
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

@@ -201,8 +201,7 @@ class PromotedWidgetView implements IPromotedWidgetView {
projected.drawWidget(ctx, {
width: widgetWidth,
showText: !lowQuality,
suppressPromotedOutline: true,
previewImages: resolved.node.imgs
suppressPromotedOutline: true
})
projected.y = originalY

View File

@@ -184,17 +184,6 @@ describe('getPromotableWidgets', () => {
).toBe(true)
})
it('adds virtual canvas preview widget for GLSLShader nodes', () => {
const node = new LGraphNode('GLSLShader')
node.type = 'GLSLShader'
const widgets = getPromotableWidgets(node)
expect(
widgets.some((widget) => widget.name === CANVAS_IMAGE_PREVIEW_WIDGET)
).toBe(true)
})
it('does not add virtual canvas preview widget for non-image nodes', () => {
const node = new LGraphNode('TextNode')
node.addOutput('TEXT', 'STRING')
@@ -243,25 +232,4 @@ describe('promoteRecommendedWidgets', () => {
expect(updatePreviewsMock).not.toHaveBeenCalled()
})
it('eagerly promotes virtual preview widget for CANVAS_IMAGE_PREVIEW nodes', () => {
const subgraph = createTestSubgraph()
const subgraphNode = createTestSubgraphNode(subgraph)
const glslNode = new LGraphNode('GLSLShader')
glslNode.type = 'GLSLShader'
subgraph.add(glslNode)
promoteRecommendedWidgets(subgraphNode)
const store = usePromotionStore()
expect(
store.isPromoted(
subgraphNode.rootGraph.id,
subgraphNode.id,
String(glslNode.id),
CANVAS_IMAGE_PREVIEW_WIDGET
)
).toBe(true)
expect(updatePreviewsMock).not.toHaveBeenCalled()
})
})

View File

@@ -227,29 +227,6 @@ export function promoteRecommendedWidgets(subgraphNode: SubgraphNode) {
// defer. Core $$ preview widgets are the lazy path that needs updatePreviews.
if (hasPreviewWidget()) continue
// Nodes in CANVAS_IMAGE_PREVIEW_NODE_TYPES support a virtual $$
// preview widget. Eagerly promote it so getPseudoWidgetPreviewTargets
// includes this node and onDrawBackground can call updatePreviews on it
// once execution outputs arrive.
if (supportsVirtualCanvasImagePreview(node)) {
if (
!store.isPromoted(
subgraphNode.rootGraph.id,
subgraphNode.id,
String(node.id),
CANVAS_IMAGE_PREVIEW_WIDGET
)
) {
store.promote(
subgraphNode.rootGraph.id,
subgraphNode.id,
String(node.id),
CANVAS_IMAGE_PREVIEW_WIDGET
)
}
continue
}
// Also schedule a deferred check: core $$ widgets are created lazily by
// updatePreviews when node outputs are first loaded.
requestAnimationFrame(() => updatePreviews(node, promotePreviewWidget))

View File

@@ -0,0 +1,216 @@
// Extension that enables frontend-side eager evaluation for nodes
// that declare `eager_eval` in their definition.
//
// When a node's widget values change, the extension evaluates the JSONata
// expression and displays the result as a badge on the node.
import { watch } from 'vue'
import jsonata from 'jsonata'
import {
buildEagerEvalContext,
useNodeEagerEval
} from '@/composables/node/useNodeEagerEval'
import { useComputedWithWidgetWatch } from '@/composables/node/useWatchWidget'
import { useChainCallback } from '@/composables/functional/useChainCallback'
import { LGraphBadge } from '@/lib/litegraph/src/litegraph'
import type { INodeInputSlot, LGraphNode } from '@/lib/litegraph/src/litegraph'
import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
import { useExtensionStore } from '@/stores/extensionStore'
function inputDisplayName(input: INodeInputSlot): string {
return input.name.includes('.')
? input.name.slice(input.name.indexOf('.') + 1)
: input.name
}
function formatNum(v: number): string {
return Number.isInteger(v)
? String(v)
: v.toFixed(4).replace(/0+$/, '').replace(/\.$/, '')
}
const extensionStore = useExtensionStore()
extensionStore.registerExtension({
name: 'Comfy.EagerEval',
nodeCreated(node: LGraphNode) {
const eagerEval = useNodeEagerEval()
if (!eagerEval.hasEagerEval(node)) return
const colorPaletteStore = useColorPaletteStore()
function updateInputValueLabels() {
if (!node.inputs) return
const ctx = buildEagerEvalContext(node) as Record<string, unknown>
for (const input of node.inputs) {
const displayName = inputDisplayName(input)
if (input.link != null) {
let val = ctx[input.name]
// Fall back to cached backend context when eager eval
// can't resolve values (e.g. inputs from non-primitive nodes)
if (val === undefined && backendContext) {
const baseName = inputDisplayName(input)
val = backendContext[baseName] ?? backendContext[input.name]
}
input.label =
typeof val === 'number'
? `${displayName}: ${formatNum(val)}`
: displayName
} else {
input.label = inputDisplayName(input)
}
}
}
// Watch all widgets for changes to trigger re-evaluation
const widgetNames = node.widgets?.map((w) => w.name) ?? []
const computedWithWidgetWatch = useComputedWithWidgetWatch(node, {
widgetNames,
triggerCanvasRedraw: true
})
computedWithWidgetWatch(() => 0)
// When async evaluation completes, redraw the canvas so the badge updates
watch(eagerEval.evalRevision, () => {
node.graph?.setDirtyCanvas(true, true)
})
// Watch connection changes for input-dependent re-evaluation
node.onConnectionsChange = useChainCallback(
node.onConnectionsChange,
() => {
backendContext = {}
contextEvalCache = { expr: '', result: NaN }
eagerEval.triggerEagerEval(node)
node.graph?.setDirtyCanvas(true, true)
}
)
const emptyBadge = new LGraphBadge({ text: '' })
let lastLabel = ''
let lastBadge = emptyBadge
function makeBadge(label: string, isError = false): LGraphBadge {
if (label === lastLabel) return lastBadge
lastLabel = label
lastBadge = new LGraphBadge({
text: isError ? label : `= ${label}`,
fgColor: isError
? '#ff6b6b'
: (colorPaletteStore.completedActivePalette.colors.litegraph_base
.BADGE_FG_COLOR ?? '#fff'),
bgColor: isError ? '#4a1a1a' : '#1a4a2a'
})
return lastBadge
}
// Track backend execution result separately from eager eval.
// backendBadge is shown when eager eval can't compute (e.g. inputs
// come from non-primitive nodes like Get Image Size).
let backendBadge: LGraphBadge = emptyBadge
let backendExpr = ''
// Cached backend context for re-evaluating changed expressions
let backendContext: Record<string, unknown> = {}
let contextEvalCache = { expr: '', result: NaN }
let contextEvalInFlight = ''
node.onExecuted = useChainCallback(
node.onExecuted,
(output: Record<string, unknown>) => {
const exprWidget = node.widgets?.find((w) => w.name === 'expression')
backendExpr = exprWidget ? String(exprWidget.value) : ''
// Cache context for re-evaluation with changed expressions
const ctxArr = output.context
if (
Array.isArray(ctxArr) &&
ctxArr[0] &&
typeof ctxArr[0] === 'object'
) {
backendContext = ctxArr[0] as Record<string, unknown>
contextEvalCache = { expr: '', result: NaN }
}
const resultArr = output.result
if (Array.isArray(resultArr)) {
const raw = resultArr[0]
if (typeof raw === 'number' && node.outputs?.[0]) {
node.outputs[0]._data = raw
backendBadge = new LGraphBadge({
text: `= ${formatNum(raw)}`,
fgColor:
colorPaletteStore.completedActivePalette.colors.litegraph_base
.BADGE_FG_COLOR ?? '#fff',
bgColor: '#1a4a2a'
})
node.graph?.setDirtyCanvas(true, true)
}
}
}
)
const badgeGetter: () => LGraphBadge = () => {
updateInputValueLabels()
const result = eagerEval.getNodeEagerResult(node)
// Eager eval succeeded — use it directly
if (result && result.value != null) {
return makeBadge(eagerEval.formatEagerResult(result), !!result.error)
}
const exprWidget = node.widgets?.find((w) => w.name === 'expression')
const currentExpr = exprWidget ? String(exprWidget.value) : ''
// Backend result for the same expression
if (backendBadge !== emptyBadge && currentExpr === backendExpr) {
return backendBadge
}
// Re-evaluate with cached backend context when expression changed
if (Object.keys(backendContext).length > 0 && currentExpr) {
if (
currentExpr === contextEvalCache.expr &&
!Number.isNaN(contextEvalCache.result)
) {
return makeBadge(formatNum(contextEvalCache.result))
}
if (currentExpr !== contextEvalInFlight) {
contextEvalInFlight = currentExpr
const capturedExpr = currentExpr
try {
Promise.resolve(jsonata(currentExpr).evaluate(backendContext))
.then((val: unknown) => {
if (typeof val === 'number') {
contextEvalCache = { expr: capturedExpr, result: val }
if (node.outputs?.[0]) node.outputs[0]._data = val
node.graph?.setDirtyCanvas(true, true)
}
})
.catch(() => {
contextEvalCache = { expr: capturedExpr, result: NaN }
})
.finally(() => {
if (contextEvalInFlight === capturedExpr) {
contextEvalInFlight = ''
}
})
} catch {
contextEvalCache = { expr: currentExpr, result: NaN }
contextEvalInFlight = ''
}
}
}
// Eager eval has an error and no backend/context fallback
if (result?.error) {
return makeBadge(result.error, true)
}
return emptyBadge
}
node.badges.push(badgeGetter)
}
})

View File

@@ -4,6 +4,7 @@ import './clipspace'
import './contextMenuFilter'
import './customWidgets'
import './dynamicPrompts'
import './eagerEval'
import './editAttention'
import './electronAdapter'
import './groupNode'
@@ -20,7 +21,6 @@ if (!isCloud) {
}
import './noteNode'
import './painter'
import './rangeHistogram'
import './previewAny'
import './rerouteNode'
import './saveImageExtraOutput'

View File

@@ -1,37 +0,0 @@
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

@@ -1,5 +1,4 @@
import type { Bounds } from '@/renderer/core/layout/types'
import type { CurvePoint } from '@/components/curve/types'
import type {
CanvasColour,
@@ -139,7 +138,6 @@ export type IWidget =
| IBoundingBoxWidget
| ICurveWidget
| IPainterWidget
| IRangeWidget
export interface IBooleanWidget extends IBaseWidget<boolean, 'toggle'> {
type: 'toggle'
@@ -332,6 +330,8 @@ export interface IBoundingBoxWidget extends IBaseWidget<Bounds, 'boundingbox'> {
value: Bounds
}
export type CurvePoint = [x: number, y: number]
export interface ICurveWidget extends IBaseWidget<CurvePoint[], 'curve'> {
type: 'curve'
value: CurvePoint[]
@@ -342,31 +342,6 @@ 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

@@ -27,8 +27,6 @@ export interface DrawWidgetOptions {
showText?: boolean
/** When true, suppresses the promoted outline color (e.g. for projected copies on SubgraphNode). */
suppressPromotedOutline?: boolean
/** Transient image source for preview widgets rendered on behalf of another node (e.g. subgraph promotion). */
previewImages?: HTMLImageElement[]
}
interface DrawTruncatingTextOptions extends DrawWidgetOptions {

View File

@@ -1,16 +0,0 @@
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,7 +22,6 @@ 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'
@@ -61,7 +60,6 @@ export type WidgetTypeMap = {
boundingbox: BoundingBoxWidget
curve: CurveWidget
painter: PainterWidget
range: RangeWidget
[key: string]: BaseWidget
}
@@ -142,8 +140,6 @@ 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

@@ -282,6 +282,7 @@ const getCheckoutTier = (
const getCheckoutAttributionForCloud =
async (): Promise<CheckoutAttributionMetadata> => {
// eslint-disable-next-line no-undef
if (__DISTRIBUTION__ !== 'cloud') {
return {}
}

View File

@@ -189,36 +189,25 @@ export function useNodeResize(
}
}
const cleanup = () => {
if (!isResizing.value) return
isResizing.value = false
layoutStore.isResizingVueNodes.value = false
resizeStartPointer.value = null
resizeStartSize.value = null
resizeStartPosition.value = null
// Stop tracking shift key state
stopShiftSync()
stopMoveListen()
stopUpListen()
stopCancelListen()
}
const handlePointerUp = (upEvent: PointerEvent) => {
if (isResizing.value) {
try {
target.releasePointerCapture(upEvent.pointerId)
} catch {
// Pointer capture may already be released
}
cleanup()
isResizing.value = false
layoutStore.isResizingVueNodes.value = false
resizeStartPointer.value = null
resizeStartSize.value = null
resizeStartPosition.value = null
// Stop tracking shift key state
stopShiftSync()
target.releasePointerCapture(upEvent.pointerId)
stopMoveListen()
stopUpListen()
}
}
const stopMoveListen = useEventListener('pointermove', handlePointerMove)
const stopUpListen = useEventListener('pointerup', handlePointerUp)
const stopCancelListen = useEventListener('pointercancel', cleanup)
}
return {

View File

@@ -92,15 +92,9 @@ watch([elementX, elementWidth, isOutside], ([x, width, outside]) => {
}
})
function isSingleImage(
value: ImageCompareValue | string | undefined
): value is string {
return typeof value === 'string'
}
const parsedValue = computed(() => {
const value = props.widget.value
return isSingleImage(value) ? null : value
return typeof value === 'string' ? null : value
})
const beforeBatchCount = computed(
@@ -132,26 +126,26 @@ watch(
const beforeImage = computed(() => {
const value = props.widget.value
if (isSingleImage(value)) return value
if (typeof value === 'string') return value
return value?.beforeImages?.[beforeIndex.value] ?? ''
})
const afterImage = computed(() => {
const value = props.widget.value
if (isSingleImage(value)) return ''
if (typeof value === 'string') return ''
return value?.afterImages?.[afterIndex.value] ?? ''
})
const beforeAlt = computed(() => {
const value = props.widget.value
return !isSingleImage(value) && value?.beforeAlt
return typeof value === 'object' && value?.beforeAlt
? value.beforeAlt
: 'Before image'
})
const afterAlt = computed(() => {
const value = props.widget.value
return !isSingleImage(value) && value?.afterAlt
return typeof value === 'object' && value?.afterAlt
? value.afterAlt
: 'After image'
})

View File

@@ -4,7 +4,6 @@ import type {
IBaseWidget,
IWidgetOptions
} from '@/lib/litegraph/src/types/widgets'
import type { DrawWidgetOptions } from '@/lib/litegraph/src/widgets/BaseWidget'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
@@ -78,9 +77,7 @@ const renderPreview = (
ctx: CanvasRenderingContext2D,
node: LGraphNode,
shiftY: number,
computedHeight: number | undefined,
imgs: HTMLImageElement[],
width: number
computedHeight: number | undefined
) => {
if (!node.size) return
@@ -102,6 +99,7 @@ const renderPreview = (
node.pointerDown = null
}
const imgs = node.imgs ?? []
if (imgs.length === 0) return
let { imageIndex } = node
@@ -114,7 +112,7 @@ const renderPreview = (
const settingStore = useSettingStore()
const allowImageSizeDraw = settingStore.get('Comfy.Node.AllowImageSizeDraw')
const IMAGE_TEXT_SIZE_TEXT_HEIGHT = allowImageSizeDraw ? 15 : 0
const dw = width
const dw = node.size[0]
const dh = computedHeight ? computedHeight - IMAGE_TEXT_SIZE_TEXT_HEIGHT : 0
if (imageIndex == null) {
@@ -360,29 +358,8 @@ class ImagePreviewWidget extends BaseWidget {
this.serialize = false
}
override drawWidget(
ctx: CanvasRenderingContext2D,
options: DrawWidgetOptions
): void {
const imgs = options.previewImages ?? this.node.imgs ?? []
renderPreview(
ctx,
this.node,
this.y,
this.computedHeight,
imgs,
options.width
)
}
override createCopyForNode(node: LGraphNode): this {
const copy = new ImagePreviewWidget(
node,
this.name,
this.options as IWidgetOptions<string | object>
) as this
copy.value = this.value
return copy
override drawWidget(ctx: CanvasRenderingContext2D): void {
renderPreview(ctx, this.node, this.y, this.computedHeight)
}
override onPointerDown(pointer: CanvasPointer, node: LGraphNode): boolean {

View File

@@ -1,37 +0,0 @@
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,9 +63,6 @@ const WidgetCurve = defineAsyncComponent(
const WidgetPainter = defineAsyncComponent(
() => import('@/components/painter/WidgetPainter.vue')
)
const WidgetRange = defineAsyncComponent(
() => import('@/components/range/WidgetRange.vue')
)
export const FOR_TESTING = {
WidgetButton,
@@ -200,14 +197,6 @@ const coreWidgetDefinitions: Array<[string, WidgetDefinition]> = [
aliases: ['PAINTER'],
essential: false
}
],
[
'range',
{
component: WidgetRange,
aliases: ['RANGE'],
essential: false
}
]
]
@@ -244,8 +233,7 @@ const EXPANDING_TYPES = [
'markdown',
'load3D',
'curve',
'painter',
'range'
'painter'
] as const
export function shouldExpand(type: string): boolean {

View File

@@ -135,30 +135,6 @@ 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(),
@@ -180,7 +156,6 @@ const zInputSpec = z.union([
zGalleriaInputSpec,
zTextareaInputSpec,
zCurveInputSpec,
zRangeInputSpec,
zCustomInputSpec
])
@@ -226,7 +201,6 @@ 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

@@ -270,6 +270,21 @@ const zPriceBadge = z.object({
export type PriceBadge = z.infer<typeof zPriceBadge>
/**
* Schema for eager evaluation definition.
* Allows nodes to be evaluated on the frontend without a backend round-trip.
* Used for math expression nodes and similar pure-computation nodes.
*/
const zEagerEval = z.object({
engine: z.literal('jsonata').optional().default('jsonata'),
/** Static JSONata expression (e.g., "$sum($values)" for an Add blueprint). */
expr: z.string().optional(),
/** Widget name containing a user-editable JSONata expression. */
expr_widget: z.string().optional()
})
export type EagerEval = z.infer<typeof zEagerEval>
export const zComfyNodeDef = z.object({
input: zComfyInputsSpec.optional(),
output: zComfyOutputTypesSpec.optional(),
@@ -311,6 +326,13 @@ export const zComfyNodeDef = z.object({
* and input connectivity.
*/
price_badge: zPriceBadge.optional(),
/**
* Eager evaluation definition for frontend-side computation.
* When present, the frontend evaluates the node's expression client-side
* using JSONata whenever input values change, displaying the result
* without requiring a backend round-trip.
*/
eager_eval: zEagerEval.optional(),
/** Category for the Essentials tab. If set, the node appears in Essentials. */
essentials_category: z.string().optional(),
/** Whether the blueprint is a global/installed blueprint (not user-created). */

View File

@@ -20,7 +20,6 @@ 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'
@@ -311,7 +310,6 @@ export const ComfyWidgets = {
PAINTER: transformWidgetConstructorV2ToV1(usePainterWidget()),
TEXTAREA: transformWidgetConstructorV2ToV1(useTextareaWidget()),
CURVE: transformWidgetConstructorV2ToV1(useCurveWidget()),
RANGE: transformWidgetConstructorV2ToV1(useRangeWidget()),
...dynamicWidgets
} as const

View File

@@ -190,28 +190,78 @@ describe('useModelToNodeStore', () => {
expect(provider?.key).toBe('')
})
it.each([
['sam2', 'DownloadAndLoadSAM2Model', 'model'],
['sams', 'SAMLoader', 'model_name'],
['ipadapter', 'IPAdapterModelLoader', 'ipadapter_file'],
['depthanything', 'DownloadAndLoadDepthAnythingV2Model', 'model'],
['ultralytics/bbox', 'UltralyticsDetectorProvider', 'model_name'],
['ultralytics/segm', 'UltralyticsDetectorProvider', 'model_name'],
['FlashVSR', 'FlashVSRNode', ''],
['FlashVSR-v1.1', 'FlashVSRNode', ''],
['segformer_b2_clothes', 'LS_LoadSegformerModel', 'model_name'],
['segformer_b3_fashion', 'LS_LoadSegformerModel', 'model_name']
])(
'should return correct provider for %s',
(modelType, expectedNodeName, expectedKey) => {
const modelToNodeStore = useModelToNodeStore()
modelToNodeStore.registerDefaults()
it('should return provider for new extension model types', () => {
const modelToNodeStore = useModelToNodeStore()
modelToNodeStore.registerDefaults()
const provider = modelToNodeStore.getNodeProvider(modelType)
expect(provider?.nodeDef?.name).toBe(expectedNodeName)
expect(provider?.key).toBe(expectedKey)
}
)
// SAM2
const sam2Provider = modelToNodeStore.getNodeProvider('sam2')
expect(sam2Provider?.nodeDef?.name).toBe('DownloadAndLoadSAM2Model')
expect(sam2Provider?.key).toBe('model')
// SAMLoader (original SAM)
const samsProvider = modelToNodeStore.getNodeProvider('sams')
expect(samsProvider?.nodeDef?.name).toBe('SAMLoader')
expect(samsProvider?.key).toBe('model_name')
// IP-Adapter
const ipadapterProvider = modelToNodeStore.getNodeProvider('ipadapter')
expect(ipadapterProvider?.nodeDef?.name).toBe('IPAdapterModelLoader')
expect(ipadapterProvider?.key).toBe('ipadapter_file')
// DepthAnything
const depthProvider = modelToNodeStore.getNodeProvider('depthanything')
expect(depthProvider?.nodeDef?.name).toBe(
'DownloadAndLoadDepthAnythingV2Model'
)
expect(depthProvider?.key).toBe('model')
})
it('should use hierarchical fallback for ultralytics subcategories', () => {
const modelToNodeStore = useModelToNodeStore()
modelToNodeStore.registerDefaults()
// ultralytics/bbox should fall back to ultralytics
const bboxProvider = modelToNodeStore.getNodeProvider('ultralytics/bbox')
expect(bboxProvider?.nodeDef?.name).toBe('UltralyticsDetectorProvider')
expect(bboxProvider?.key).toBe('model_name')
// ultralytics/segm should also fall back to ultralytics
const segmProvider = modelToNodeStore.getNodeProvider('ultralytics/segm')
expect(segmProvider?.nodeDef?.name).toBe('UltralyticsDetectorProvider')
})
it('should return provider for FlashVSR nodes with empty key (auto-load)', () => {
const modelToNodeStore = useModelToNodeStore()
modelToNodeStore.registerDefaults()
const flashVSRProvider = modelToNodeStore.getNodeProvider('FlashVSR')
expect(flashVSRProvider?.nodeDef?.name).toBe('FlashVSRNode')
expect(flashVSRProvider?.key).toBe('')
const flashVSR11Provider =
modelToNodeStore.getNodeProvider('FlashVSR-v1.1')
expect(flashVSR11Provider?.nodeDef?.name).toBe('FlashVSRNode')
expect(flashVSR11Provider?.key).toBe('')
})
it('should return provider for segformer models', () => {
const modelToNodeStore = useModelToNodeStore()
modelToNodeStore.registerDefaults()
const segformerB2Provider = modelToNodeStore.getNodeProvider(
'segformer_b2_clothes'
)
expect(segformerB2Provider?.nodeDef?.name).toBe('LS_LoadSegformerModel')
expect(segformerB2Provider?.key).toBe('model_name')
const segformerB3FashionProvider = modelToNodeStore.getNodeProvider(
'segformer_b3_fashion'
)
expect(segformerB3FashionProvider?.nodeDef?.name).toBe(
'LS_LoadSegformerModel'
)
})
})
describe('getAllNodeProviders', () => {

View File

@@ -334,7 +334,7 @@ describe('useQueueStore', () => {
})
describe('update() - sorting', () => {
it('should sort tasks by job.priority descending', async () => {
it('should sort tasks by queueIndex descending', async () => {
const job1 = createHistoryJob(1, 'hist-1')
const job2 = createHistoryJob(5, 'hist-2')
const job3 = createHistoryJob(3, 'hist-3')
@@ -344,9 +344,9 @@ describe('useQueueStore', () => {
await store.update()
expect(store.historyTasks[0].job.priority).toBe(5)
expect(store.historyTasks[1].job.priority).toBe(3)
expect(store.historyTasks[2].job.priority).toBe(1)
expect(store.historyTasks[0].queueIndex).toBe(5)
expect(store.historyTasks[1].queueIndex).toBe(3)
expect(store.historyTasks[2].queueIndex).toBe(1)
})
it('should preserve API sort order for pending tasks', async () => {
@@ -363,14 +363,14 @@ describe('useQueueStore', () => {
await store.update()
expect(store.pendingTasks[0].job.priority).toBe(15)
expect(store.pendingTasks[1].job.priority).toBe(12)
expect(store.pendingTasks[2].job.priority).toBe(10)
expect(store.pendingTasks[0].queueIndex).toBe(15)
expect(store.pendingTasks[1].queueIndex).toBe(12)
expect(store.pendingTasks[2].queueIndex).toBe(10)
})
})
describe('update() - queue index collision (THE BUG FIX)', () => {
it('should NOT confuse different prompts with same job.priority', async () => {
it('should NOT confuse different prompts with same queueIndex', async () => {
const hist1 = createHistoryJob(50, 'prompt-uuid-aaa')
mockGetQueue.mockResolvedValue({ Running: [], Pending: [] })
@@ -387,10 +387,10 @@ describe('useQueueStore', () => {
expect(store.historyTasks).toHaveLength(1)
expect(store.historyTasks[0].jobId).toBe('prompt-uuid-bbb')
expect(store.historyTasks[0].job.priority).toBe(51)
expect(store.historyTasks[0].queueIndex).toBe(51)
})
it('should correctly reconcile when job.priority is reused', async () => {
it('should correctly reconcile when queueIndex is reused', async () => {
const hist1 = createHistoryJob(100, 'first-prompt-at-100')
const hist2 = createHistoryJob(99, 'prompt-at-99')
@@ -412,7 +412,7 @@ describe('useQueueStore', () => {
expect(jobIds).not.toContain('first-prompt-at-100')
})
it('should handle multiple job.priority collisions simultaneously', async () => {
it('should handle multiple queueIndex collisions simultaneously', async () => {
const hist1 = createHistoryJob(10, 'old-at-10')
const hist2 = createHistoryJob(20, 'old-at-20')
const hist3 = createHistoryJob(30, 'keep-at-30')
@@ -563,9 +563,9 @@ describe('useQueueStore', () => {
await store.update()
expect(store.historyTasks).toHaveLength(3)
expect(store.historyTasks[0].job.priority).toBe(10)
expect(store.historyTasks[1].job.priority).toBe(9)
expect(store.historyTasks[2].job.priority).toBe(8)
expect(store.historyTasks[0].queueIndex).toBe(10)
expect(store.historyTasks[1].queueIndex).toBe(9)
expect(store.historyTasks[2].queueIndex).toBe(8)
})
it('should respect maxHistoryItems when combining new and existing', async () => {
@@ -589,7 +589,7 @@ describe('useQueueStore', () => {
await store.update()
expect(store.historyTasks).toHaveLength(5)
expect(store.historyTasks[0].job.priority).toBe(23)
expect(store.historyTasks[0].queueIndex).toBe(23)
})
it('should handle maxHistoryItems = 0', async () => {
@@ -619,7 +619,7 @@ describe('useQueueStore', () => {
await store.update()
expect(store.historyTasks).toHaveLength(1)
expect(store.historyTasks[0].job.priority).toBe(10)
expect(store.historyTasks[0].queueIndex).toBe(10)
})
it('should dynamically adjust when maxHistoryItems changes', async () => {

View File

@@ -312,6 +312,10 @@ export class TaskItemImpl {
return this.jobId + this.displayStatus
}
get queueIndex() {
return this.job.priority
}
get jobId() {
return this.job.id
}
@@ -496,7 +500,7 @@ export const useQueueStore = defineStore('queue', () => {
)
const lastHistoryQueueIndex = computed<number>(() =>
historyTasks.value.length ? historyTasks.value[0].job.priority : -1
historyTasks.value.length ? historyTasks.value[0].queueIndex : -1
)
const hasPendingTasks = computed<boolean>(() => pendingTasks.value.length > 0)

View File

@@ -44,7 +44,7 @@ export const iconForJobState = (state: JobState): string => {
const buildTitle = (task: TaskItemImpl, t: (k: string) => string): string => {
const prefix = t('g.job')
const shortId = String(task.jobId ?? '').split('-')[0]
const idx = task.job.priority
const idx = task.queueIndex
if (typeof idx === 'number') return `${prefix} #${idx}`
if (shortId) return `${prefix} ${shortId}`
return prefix