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,
This commit is contained in:
dante01yoon
2026-02-27 21:31:58 +09:00
parent 3984408d05
commit 823f4d3726
3 changed files with 864 additions and 0 deletions

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,375 @@
// 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> }
export type EagerEvalResult = {
value: unknown
error?: string
}
// ---------------------
// Helpers
// ---------------------
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, ...
// Only assign if the letter 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 letter = String.fromCharCode(97 + letterIdx) // a, b, c...
if (!(letter in ctx)) {
ctx[letter] = 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

@@ -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). */