mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-03-15 01:48:06 +00:00
Compare commits
4 Commits
fix/load-a
...
feat/math-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0e6bf4a287 | ||
|
|
ea57c7bffc | ||
|
|
57cb3b4756 | ||
|
|
823f4d3726 |
467
src/composables/node/useNodeEagerEval.test.ts
Normal file
467
src/composables/node/useNodeEagerEval.test.ts
Normal 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 })
|
||||
})
|
||||
})
|
||||
})
|
||||
389
src/composables/node/useNodeEagerEval.ts
Normal file
389
src/composables/node/useNodeEagerEval.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
216
src/extensions/core/eagerEval.ts
Normal file
216
src/extensions/core/eagerEval.ts
Normal 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)
|
||||
}
|
||||
})
|
||||
@@ -4,6 +4,7 @@ import './clipspace'
|
||||
import './contextMenuFilter'
|
||||
import './customWidgets'
|
||||
import './dynamicPrompts'
|
||||
import './eagerEval'
|
||||
import './editAttention'
|
||||
import './electronAdapter'
|
||||
import './groupNode'
|
||||
|
||||
@@ -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). */
|
||||
|
||||
Reference in New Issue
Block a user