From 5c142275adfa2078ef9ab7a9164ff8205647f4d1 Mon Sep 17 00:00:00 2001 From: Alexander Piskun <13381981+bigcat88@users.noreply.github.com> Date: Thu, 22 Jan 2026 19:17:07 +0200 Subject: [PATCH 1/8] Move price badges to python nodes (#7816) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Backend part: https://github.com/Comfy-Org/ComfyUI/pull/11582 - Move API node pricing definitions from hardcoded frontend functions to backend-defined JSONata expressions - Add `price_badge` field to node definition schema containing JSONata expression and dependency declarations - Implement async JSONata evaluation with signature-based caching for efficient reactive updates - Show one decimal in credit badges when meaningful (e.g., 1.5 credits instead of 2 credits) ## Screenshots (if applicable) ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-7816-Move-price-badges-to-python-nodes-2da6d73d365081ec815ef61f7e3c65f7) by [Unito](https://www.unito.io) --- package.json | 1 + pnpm-lock.yaml | 12 + pnpm-workspace.yaml | 1 + src/composables/node/useNodeBadge.ts | 83 +- src/composables/node/useNodePricing.test.ts | 3161 ++++------------- src/composables/node/useNodePricing.ts | 3132 +++------------- .../vueNodes/components/NodeHeader.vue | 63 +- src/schemas/nodeDefSchema.ts | 52 +- src/stores/nodeDefStore.ts | 10 +- 9 files changed, 1546 insertions(+), 4969 deletions(-) diff --git a/package.json b/package.json index 8f240b1c5..810d73c9e 100644 --- a/package.json +++ b/package.json @@ -169,6 +169,7 @@ "firebase": "catalog:", "fuse.js": "^7.0.0", "glob": "^11.0.3", + "jsonata": "catalog:", "jsondiffpatch": "^0.6.0", "loglevel": "^1.9.2", "marked": "^15.0.11", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 15ccfa824..eced908c6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -186,6 +186,9 @@ catalogs: jsdom: specifier: ^27.4.0 version: 27.4.0 + jsonata: + specifier: ^2.1.0 + version: 2.1.0 knip: specifier: ^5.75.1 version: 5.75.1 @@ -449,6 +452,9 @@ importers: glob: specifier: ^11.0.3 version: 11.0.3 + jsonata: + specifier: 'catalog:' + version: 2.1.0 jsondiffpatch: specifier: ^0.6.0 version: 0.6.0 @@ -6045,6 +6051,10 @@ packages: engines: {node: '>=6'} hasBin: true + jsonata@2.1.0: + resolution: {integrity: sha512-OCzaRMK8HobtX8fp37uIVmL8CY1IGc/a6gLsDqz3quExFR09/U78HUzWYr7T31UEB6+Eu0/8dkVD5fFDOl9a8w==} + engines: {node: '>= 8'} + jsonc-eslint-parser@2.4.0: resolution: {integrity: sha512-WYDyuc/uFcGp6YtM2H0uKmUwieOuzeE/5YocFJLnLfclZ4inf3mRn8ZVy1s7Hxji7Jxm6Ss8gqpexD/GlKoGgg==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} @@ -14403,6 +14413,8 @@ snapshots: json5@2.2.3: {} + jsonata@2.1.0: {} + jsonc-eslint-parser@2.4.0: dependencies: acorn: 8.15.0 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 5856550a0..0c91ec424 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -62,6 +62,7 @@ catalog: happy-dom: ^20.0.11 husky: ^9.1.7 jiti: 2.6.1 + jsonata: ^2.1.0 jsdom: ^27.4.0 knip: ^5.75.1 lint-staged: ^16.2.7 diff --git a/src/composables/node/useNodeBadge.ts b/src/composables/node/useNodeBadge.ts index 2f31ad0ac..91fa1115d 100644 --- a/src/composables/node/useNodeBadge.ts +++ b/src/composables/node/useNodeBadge.ts @@ -73,6 +73,14 @@ export const useNodeBadge = () => { onMounted(() => { const nodePricing = useNodePricing() + watch( + () => nodePricing.pricingRevision.value, + () => { + if (!showApiPricingBadge.value) return + app.canvas?.setDirty(true, true) + } + ) + extensionStore.registerExtension({ name: 'Comfy.NodeBadge', nodeCreated(node: LGraphNode) { @@ -111,17 +119,16 @@ export const useNodeBadge = () => { node.badges.push(() => badge.value) if (node.constructor.nodeData?.api_node && showApiPricingBadge.value) { - // Get the pricing function to determine if this node has dynamic pricing + // JSONata rules are dynamic if they depend on any widgets/inputs/input_groups const pricingConfig = nodePricing.getNodePricingConfig(node) const hasDynamicPricing = - typeof pricingConfig?.displayPrice === 'function' - - let creditsBadge - const createBadge = () => { - const price = nodePricing.getNodeDisplayPrice(node) - return priceBadge.getCreditsBadge(price) - } + !!pricingConfig && + ((pricingConfig.depends_on?.widgets?.length ?? 0) > 0 || + (pricingConfig.depends_on?.inputs?.length ?? 0) > 0 || + (pricingConfig.depends_on?.input_groups?.length ?? 0) > 0) + // Keep the existing widget-watch wiring ONLY to trigger redraws on widget change. + // (We no longer rely on it to hold the current badge value.) if (hasDynamicPricing) { // For dynamic pricing nodes, use computed that watches widget changes const relevantWidgetNames = nodePricing.getRelevantWidgetNames( @@ -133,13 +140,63 @@ export const useNodeBadge = () => { triggerCanvasRedraw: true }) - creditsBadge = computedWithWidgetWatch(createBadge) - } else { - // For static pricing nodes, use regular computed - creditsBadge = computed(createBadge) + // Ensure watchers are installed; ignore the returned value. + // (This call is what registers the widget listeners in most implementations.) + computedWithWidgetWatch(() => 0) + + // Hook into connection changes to trigger price recalculation + // This handles both connect and disconnect in VueNodes mode + const relevantInputs = pricingConfig?.depends_on?.inputs ?? [] + const inputGroupPrefixes = + pricingConfig?.depends_on?.input_groups ?? [] + const hasRelevantInputs = + relevantInputs.length > 0 || inputGroupPrefixes.length > 0 + + if (hasRelevantInputs) { + const originalOnConnectionsChange = node.onConnectionsChange + node.onConnectionsChange = function ( + type, + slotIndex, + isConnected, + link, + ioSlot + ) { + originalOnConnectionsChange?.call( + this, + type, + slotIndex, + isConnected, + link, + ioSlot + ) + // Only trigger if this input affects pricing + const inputName = ioSlot?.name + if (!inputName) return + const isRelevantInput = + relevantInputs.includes(inputName) || + inputGroupPrefixes.some((prefix) => + inputName.startsWith(prefix + '.') + ) + if (isRelevantInput) { + nodePricing.triggerPriceRecalculation(node) + } + } + } } - node.badges.push(() => creditsBadge.value) + let lastLabel = nodePricing.getNodeDisplayPrice(node) + let lastBadge = priceBadge.getCreditsBadge(lastLabel) + + const creditsBadgeGetter: () => LGraphBadge = () => { + const label = nodePricing.getNodeDisplayPrice(node) + if (label !== lastLabel) { + lastLabel = label + lastBadge = priceBadge.getCreditsBadge(label) + } + return lastBadge + } + + node.badges.push(creditsBadgeGetter) } }, init() { diff --git a/src/composables/node/useNodePricing.test.ts b/src/composables/node/useNodePricing.test.ts index c811a4796..e1996579d 100644 --- a/src/composables/node/useNodePricing.test.ts +++ b/src/composables/node/useNodePricing.test.ts @@ -1,2478 +1,891 @@ import { describe, expect, it } from 'vitest' -import { formatCreditsFromUsd } from '@/base/credits/comfyCredits' +import { CREDITS_PER_USD, formatCredits } from '@/base/credits/comfyCredits' import { useNodePricing } from '@/composables/node/useNodePricing' import type { LGraphNode } from '@/lib/litegraph/src/litegraph' -import type { IComboWidget } from '@/lib/litegraph/src/types/widgets' +import type { PriceBadge } from '@/schemas/nodeDefSchema' -// Helper function to create a mock node -function createMockNode( +// ----------------------------------------------------------------------------- +// Test Types +// ----------------------------------------------------------------------------- + +interface MockNodeWidget { + name: string + value: unknown + type: string +} + +interface MockNodeInput { + name: string + link: number | null +} + +interface MockNodeData { + name: string + api_node: boolean + price_badge?: PriceBadge +} + +interface MockNode { + id: string + widgets: MockNodeWidget[] + inputs: MockNodeInput[] + constructor: { nodeData: MockNodeData } +} + +// ----------------------------------------------------------------------------- +// Test Helpers +// ----------------------------------------------------------------------------- + +/** + * Determine if a number should display 1 decimal place. + * Shows decimal only when the first decimal digit is non-zero. + */ +const shouldShowDecimal = (value: number): boolean => { + const rounded = Math.round(value * 10) / 10 + return rounded % 1 !== 0 +} + +const creditValue = (usd: number): string => { + const rawCredits = usd * CREDITS_PER_USD + return formatCredits({ + value: rawCredits, + numberOptions: { + minimumFractionDigits: 0, + maximumFractionDigits: shouldShowDecimal(rawCredits) ? 1 : 0 + } + }) +} + +const creditsLabel = (usd: number, suffix = '/Run'): string => + `${creditValue(usd)} credits${suffix}` + +/** + * Create a mock node with price_badge for testing JSONata-based pricing. + */ +function createMockNodeWithPriceBadge( nodeTypeName: string, - widgets: Array<{ name: string; value: any }> = [], - isApiNode = true, - inputs: Array<{ - name: string - connected?: boolean - useLinksArray?: boolean - }> = [] + priceBadge: PriceBadge, + widgets: Array<{ name: string; value: unknown }> = [], + inputs: Array<{ name: string; connected?: boolean }> = [] ): LGraphNode { const mockWidgets = widgets.map(({ name, value }) => ({ name, value, type: 'combo' - })) as IComboWidget[] + })) - const mockInputs = - inputs.length > 0 - ? inputs.map(({ name, connected, useLinksArray }) => - useLinksArray - ? { name, links: connected ? [1] : [] } - : { name, link: connected ? 1 : null } - ) - : undefined + const mockInputs: MockNodeInput[] = inputs.map(({ name, connected }) => ({ + name, + link: connected ? 1 : null + })) - const node: any = { + const node: MockNode = { id: Math.random().toString(), widgets: mockWidgets, + inputs: mockInputs, constructor: { nodeData: { name: nodeTypeName, - api_node: isApiNode + api_node: true, + price_badge: priceBadge } } } - if (mockInputs) { - node.inputs = mockInputs - // Provide the common helpers some frontend code may call - node.findInputSlot = function (portName: string) { - return this.inputs?.findIndex((i: any) => i.name === portName) ?? -1 - } - node.isInputConnected = function (idx: number) { - const port = this.inputs?.[idx] - if (!port) return false - if (typeof port.link !== 'undefined') return port.link != null - if (Array.isArray(port.links)) return port.links.length > 0 - return false - } - } - - return node as LGraphNode + return node as unknown as LGraphNode } -describe('useNodePricing', () => { - describe('static pricing', () => { - it('should return static price for FluxProCannyNode', () => { - const { getNodeDisplayPrice } = useNodePricing() - const node = createMockNode('FluxProCannyNode') +/** Helper to create a price badge with defaults */ +const priceBadge = ( + expr: string, + widgets: Array<{ name: string; type: string }> = [], + inputs: string[] = [], + inputGroups: string[] = [] +): PriceBadge => ({ + engine: 'jsonata', + expr, + depends_on: { widgets, inputs, input_groups: inputGroups } +}) +// ----------------------------------------------------------------------------- +// Tests +// ----------------------------------------------------------------------------- + +describe('useNodePricing', () => { + describe('static expressions', () => { + it('should evaluate simple static USD price', async () => { + const { getNodeDisplayPrice } = useNodePricing() + const node = createMockNodeWithPriceBadge( + 'TestStaticNode', + priceBadge('{"type":"usd","usd":0.05}') + ) + + getNodeDisplayPrice(node) + await new Promise((r) => setTimeout(r, 50)) const price = getNodeDisplayPrice(node) expect(price).toBe(creditsLabel(0.05)) }) - it('should return static price for StabilityStableImageUltraNode', () => { + it('should evaluate static text result', async () => { const { getNodeDisplayPrice } = useNodePricing() - const node = createMockNode('StabilityStableImageUltraNode') + const node = createMockNodeWithPriceBadge( + 'TestTextNode', + priceBadge('{"type":"text","text":"Free"}') + ) + getNodeDisplayPrice(node) + await new Promise((r) => setTimeout(r, 50)) const price = getNodeDisplayPrice(node) - expect(price).toBe(creditsLabel(0.08)) - }) - - it('should return empty string for non-API nodes', () => { - const { getNodeDisplayPrice } = useNodePricing() - const node = createMockNode('RegularNode', [], false) - - const price = getNodeDisplayPrice(node) - expect(price).toBe('') - }) - - it('should return empty string for unknown node types', () => { - const { getNodeDisplayPrice } = useNodePricing() - const node = createMockNode('UnknownAPINode') - - const price = getNodeDisplayPrice(node) - expect(price).toBe('') + expect(price).toBe('Free') }) }) - describe('dynamic pricing - Flux2ProImageNode', () => { - it('should return precise price for text-to-image 1024x1024 (no refs)', () => { + describe('widget value normalization', () => { + it('should handle INT widget as number', async () => { const { getNodeDisplayPrice } = useNodePricing() - const node = createMockNode('Flux2ProImageNode', [ - { name: 'width', value: 1024 }, - { name: 'height', value: 1024 } - ]) - - // 1024x1024 => 1 MP => $0.03 - expect(getNodeDisplayPrice(node)).toBe(creditsLabel(0.03)) - }) - - it('should return minimum estimate when refs are connected (1024x1024)', () => { - const { getNodeDisplayPrice } = useNodePricing() - const node = createMockNode( - 'Flux2ProImageNode', - [ - { name: 'width', value: 1024 }, - { name: 'height', value: 1024 } - ], - true, - // connect the 'images' input - [{ name: 'images', connected: true }] + const node = createMockNodeWithPriceBadge( + 'TestIntNode', + priceBadge('{"type":"usd","usd": widgets.count * 0.01}', [ + { name: 'count', type: 'INT' } + ]), + [{ name: 'count', value: 5 }] ) - // 1024x1024 => 1 MP output = $0.03, min input add = $0.015 => ~$0.045 min - expect(getNodeDisplayPrice(node)).toBe( - creditsRangeLabel(0.045, 0.15, { approximate: true }) + getNodeDisplayPrice(node) + await new Promise((r) => setTimeout(r, 50)) + const price = getNodeDisplayPrice(node) + expect(price).toBe(creditsLabel(0.05)) + }) + + it('should handle FLOAT widget as number', async () => { + const { getNodeDisplayPrice } = useNodePricing() + const node = createMockNodeWithPriceBadge( + 'TestFloatNode', + priceBadge('{"type":"usd","usd": widgets.rate * 10}', [ + { name: 'rate', type: 'FLOAT' } + ]), + [{ name: 'rate', value: 0.05 }] ) - }) - - it('should show fallback when width/height are missing', () => { - const { getNodeDisplayPrice } = useNodePricing() - const node = createMockNode('Flux2ProImageNode', []) - expect(getNodeDisplayPrice(node)).toBe(creditsRangeLabel(0.03, 0.15)) - }) - }) - - describe('dynamic pricing - KlingTextToVideoNode', () => { - it('should return high price for kling-v2-1-master model', () => { - const { getNodeDisplayPrice } = useNodePricing() - const node = createMockNode('KlingTextToVideoNode', [ - { name: 'mode', value: 'standard / 5s / v2-1-master' } - ]) - - const price = getNodeDisplayPrice(node) - expect(price).toBe(creditsLabel(1.4)) - }) - - it('should return high price for kling-v2-master model', () => { - const { getNodeDisplayPrice } = useNodePricing() - const node = createMockNode('KlingTextToVideoNode', [ - { name: 'mode', value: 'standard / 5s / v2-master' } - ]) - - const price = getNodeDisplayPrice(node) - expect(price).toBe(creditsLabel(1.4)) - }) - - it('should return low price for kling-v2-turbo model', () => { - const { getNodeDisplayPrice } = useNodePricing() - const node = createMockNode('KlingTextToVideoNode', [ - { name: 'mode', value: 'pro / 5s / v2-5-turbo' } - ]) - - const price = getNodeDisplayPrice(node) - expect(price).toBe(creditsLabel(0.35)) - }) - - it('should return high price for kling-v2-turbo model', () => { - const { getNodeDisplayPrice } = useNodePricing() - const node = createMockNode('KlingTextToVideoNode', [ - { name: 'mode', value: 'pro / 10s / v2-5-turbo' } - ]) - - const price = getNodeDisplayPrice(node) - expect(price).toBe(creditsLabel(0.7)) - }) - - it('should return standard price for kling-v1-6 model', () => { - const { getNodeDisplayPrice } = useNodePricing() - const node = createMockNode('KlingTextToVideoNode', [ - { name: 'mode', value: 'standard / 5s / v1-6' } - ]) - - const price = getNodeDisplayPrice(node) - expect(price).toBe(creditsLabel(0.28)) - }) - - it('should return range when mode widget is missing', () => { - const { getNodeDisplayPrice } = useNodePricing() - const node = createMockNode('KlingTextToVideoNode', []) - - const price = getNodeDisplayPrice(node) - expect(price).toBe( - creditsRangeLabel(0.14, 2.8, { - note: '(varies with model, mode & duration)' - }) - ) - }) - }) - - describe('dynamic pricing - KlingImage2VideoNode', () => { - it('should return high price for kling-v2-master model', () => { - const { getNodeDisplayPrice } = useNodePricing() - const node = createMockNode('KlingImage2VideoNode', [ - { name: 'model_name', value: 'v2-master' } - ]) - - const price = getNodeDisplayPrice(node) - expect(price).toBe(creditsLabel(1.4)) - }) - - it('should return high price for kling-v2-1-master model', () => { - const { getNodeDisplayPrice } = useNodePricing() - const node = createMockNode('KlingImage2VideoNode', [ - { name: 'model_name', value: 'v2-1-master' } - ]) - - const price = getNodeDisplayPrice(node) - expect(price).toBe(creditsLabel(1.4)) - }) - - it('should return high price for kling-v2-5-turbo model', () => { - const { getNodeDisplayPrice } = useNodePricing() - const node = createMockNode('KlingImage2VideoNode', [ - { name: 'model_name', value: 'v2-5-turbo' }, - { name: 'mode', value: 'pro mode / 10s duration / kling-v2-5-turbo' }, - { name: 'duration', value: '10' } - ]) - - const price = getNodeDisplayPrice(node) - expect(price).toBe(creditsLabel(0.7)) - }) - - it('should return standard price for kling-v1-6 model', () => { - const { getNodeDisplayPrice } = useNodePricing() - const node = createMockNode('KlingImage2VideoNode', [ - { name: 'model_name', value: 'v1-6' } - ]) - - const price = getNodeDisplayPrice(node) - expect(price).toBe(creditsLabel(0.28)) - }) - - it('should return range when model_name widget is missing', () => { - const { getNodeDisplayPrice } = useNodePricing() - const node = createMockNode('KlingImage2VideoNode', []) - - const price = getNodeDisplayPrice(node) - expect(price).toBe( - creditsRangeLabel(0.14, 2.8, { - note: '(varies with model, mode & duration)' - }) - ) - }) - }) - - describe('dynamic pricing - OpenAIDalle3', () => { - it('should return $0.04 for 1024x1024 standard quality', () => { - const { getNodeDisplayPrice } = useNodePricing() - const node = createMockNode('OpenAIDalle3', [ - { name: 'size', value: '1024x1024' }, - { name: 'quality', value: 'standard' } - ]) - - const price = getNodeDisplayPrice(node) - expect(price).toBe(creditsLabel(0.04)) - }) - - it('should return $0.08 for 1024x1024 hd quality', () => { - const { getNodeDisplayPrice } = useNodePricing() - const node = createMockNode('OpenAIDalle3', [ - { name: 'size', value: '1024x1024' }, - { name: 'quality', value: 'hd' } - ]) - - const price = getNodeDisplayPrice(node) - expect(price).toBe(creditsLabel(0.08)) - }) - - it('should return $0.08 for 1792x1024 standard quality', () => { - const { getNodeDisplayPrice } = useNodePricing() - const node = createMockNode('OpenAIDalle3', [ - { name: 'size', value: '1792x1024' }, - { name: 'quality', value: 'standard' } - ]) - - const price = getNodeDisplayPrice(node) - expect(price).toBe(creditsLabel(0.08)) - }) - - it('should return $0.16 for 1792x1024 hd quality', () => { - const { getNodeDisplayPrice } = useNodePricing() - const node = createMockNode('OpenAIDalle3', [ - { name: 'size', value: '1792x1024' }, - { name: 'quality', value: 'hd' } - ]) - - const price = getNodeDisplayPrice(node) - expect(price).toBe(creditsLabel(0.12)) - }) - - it('should return $0.08 for 1024x1792 standard quality', () => { - const { getNodeDisplayPrice } = useNodePricing() - const node = createMockNode('OpenAIDalle3', [ - { name: 'size', value: '1024x1792' }, - { name: 'quality', value: 'standard' } - ]) - - const price = getNodeDisplayPrice(node) - expect(price).toBe(creditsLabel(0.08)) - }) - - it('should return $0.16 for 1024x1792 hd quality', () => { - const { getNodeDisplayPrice } = useNodePricing() - const node = createMockNode('OpenAIDalle3', [ - { name: 'size', value: '1024x1792' }, - { name: 'quality', value: 'hd' } - ]) - - const price = getNodeDisplayPrice(node) - expect(price).toBe(creditsLabel(0.12)) - }) - - it('should return range when widgets are missing', () => { - const { getNodeDisplayPrice } = useNodePricing() - const node = createMockNode('OpenAIDalle3', []) - - const price = getNodeDisplayPrice(node) - expect(price).toBe( - creditsRangeLabel(0.04, 0.12, { note: '(varies with size & quality)' }) - ) - }) - - it('should return range when size widget is missing', () => { - const { getNodeDisplayPrice } = useNodePricing() - const node = createMockNode('OpenAIDalle3', [ - { name: 'quality', value: 'hd' } - ]) - - const price = getNodeDisplayPrice(node) - expect(price).toBe( - creditsRangeLabel(0.04, 0.12, { note: '(varies with size & quality)' }) - ) - }) - - it('should return range when quality widget is missing', () => { - const { getNodeDisplayPrice } = useNodePricing() - const node = createMockNode('OpenAIDalle3', [ - { name: 'size', value: '1024x1024' } - ]) - - const price = getNodeDisplayPrice(node) - expect(price).toBe( - creditsRangeLabel(0.04, 0.12, { note: '(varies with size & quality)' }) - ) - }) - }) - // ============================== OpenAIVideoSora2 ============================== - describe('dynamic pricing - OpenAIVideoSora2', () => { - it('should require model, duration & size when widgets are missing', () => { - const { getNodeDisplayPrice } = useNodePricing() - const node = createMockNode('OpenAIVideoSora2', []) - expect(getNodeDisplayPrice(node)).toBe('Set model, duration & size') - }) - - it('should require duration when duration is invalid or zero', () => { - const { getNodeDisplayPrice } = useNodePricing() - const nodeNaN = createMockNode('OpenAIVideoSora2', [ - { name: 'model', value: 'sora-2-pro' }, - { name: 'duration', value: 'oops' }, - { name: 'size', value: '720x1280' } - ]) - expect(getNodeDisplayPrice(nodeNaN)).toBe('Set model, duration & size') - - const nodeZero = createMockNode('OpenAIVideoSora2', [ - { name: 'model', value: 'sora-2-pro' }, - { name: 'duration', value: 0 }, - { name: 'size', value: '720x1280' } - ]) - expect(getNodeDisplayPrice(nodeZero)).toBe('Set model, duration & size') - }) - - it('should require size when size is missing', () => { - const { getNodeDisplayPrice } = useNodePricing() - const node = createMockNode('OpenAIVideoSora2', [ - { name: 'model', value: 'sora-2-pro' }, - { name: 'duration', value: 8 } - ]) - expect(getNodeDisplayPrice(node)).toBe('Set model, duration & size') - }) - - it('should compute pricing for sora-2-pro with 1024x1792', () => { - const { getNodeDisplayPrice } = useNodePricing() - const node = createMockNode('OpenAIVideoSora2', [ - { name: 'model', value: 'sora-2-pro' }, - { name: 'duration', value: 8 }, - { name: 'size', value: '1024x1792' } - ]) - expect(getNodeDisplayPrice(node)).toBe(creditsLabel(4.0)) // 0.5 * 8 - }) - - it('should compute pricing for sora-2-pro with 720x1280', () => { - const { getNodeDisplayPrice } = useNodePricing() - const node = createMockNode('OpenAIVideoSora2', [ - { name: 'model', value: 'sora-2-pro' }, - { name: 'duration', value: 12 }, - { name: 'size', value: '720x1280' } - ]) - expect(getNodeDisplayPrice(node)).toBe(creditsLabel(3.6)) // 0.3 * 12 - }) - - it('should reject unsupported size for sora-2-pro', () => { - const { getNodeDisplayPrice } = useNodePricing() - const node = createMockNode('OpenAIVideoSora2', [ - { name: 'model', value: 'sora-2-pro' }, - { name: 'duration', value: 8 }, - { name: 'size', value: '640x640' } - ]) - expect(getNodeDisplayPrice(node)).toBe( - 'Invalid size. Must be 720x1280, 1280x720, 1024x1792, or 1792x1024.' - ) - }) - - it('should compute pricing for sora-2 (720x1280 only)', () => { - const { getNodeDisplayPrice } = useNodePricing() - const node = createMockNode('OpenAIVideoSora2', [ - { name: 'model', value: 'sora-2' }, - { name: 'duration', value: 10 }, - { name: 'size', value: '720x1280' } - ]) - expect(getNodeDisplayPrice(node)).toBe(creditsLabel(1.0)) // 0.1 * 10 - }) - - it('should reject non-720 sizes for sora-2', () => { - const { getNodeDisplayPrice } = useNodePricing() - const node = createMockNode('OpenAIVideoSora2', [ - { name: 'model', value: 'sora-2' }, - { name: 'duration', value: 8 }, - { name: 'size', value: '1024x1792' } - ]) - expect(getNodeDisplayPrice(node)).toBe( - 'sora-2 supports only 720x1280 or 1280x720' - ) - }) - it('should accept duration_s alias for duration', () => { - const { getNodeDisplayPrice } = useNodePricing() - const node = createMockNode('OpenAIVideoSora2', [ - { name: 'model', value: 'sora-2-pro' }, - { name: 'duration_s', value: 4 }, - { name: 'size', value: '1792x1024' } - ]) - expect(getNodeDisplayPrice(node)).toBe(creditsLabel(2.0)) // 0.5 * 4 - }) - - it('should be case-insensitive for model and size', () => { - const { getNodeDisplayPrice } = useNodePricing() - const node = createMockNode('OpenAIVideoSora2', [ - { name: 'model', value: 'SoRa-2-PrO' }, - { name: 'duration', value: 12 }, - { name: 'size', value: '1280x720' } - ]) - expect(getNodeDisplayPrice(node)).toBe(creditsLabel(3.6)) // 0.3 * 12 - }) - }) - - // ============================== MinimaxHailuoVideoNode ============================== - describe('dynamic pricing - MinimaxHailuoVideoNode', () => { - it('should return $0.28 for 6s duration and 768P resolution', () => { - const { getNodeDisplayPrice } = useNodePricing() - const node = createMockNode('MinimaxHailuoVideoNode', [ - { name: 'duration', value: '6' }, - { name: 'resolution', value: '768P' } - ]) - - const price = getNodeDisplayPrice(node) - expect(price).toBe(creditsLabel(0.28)) - }) - - it('should return $0.60 for 10s duration and 768P resolution', () => { - const { getNodeDisplayPrice } = useNodePricing() - const node = createMockNode('MinimaxHailuoVideoNode', [ - { name: 'duration', value: '10' }, - { name: 'resolution', value: '768P' } - ]) - - const price = getNodeDisplayPrice(node) - expect(price).toBe(creditsLabel(0.56)) - }) - - it('should return $0.49 for 6s duration and 1080P resolution', () => { - const { getNodeDisplayPrice } = useNodePricing() - const node = createMockNode('MinimaxHailuoVideoNode', [ - { name: 'duration', value: '6' }, - { name: 'resolution', value: '1080P' } - ]) - - const price = getNodeDisplayPrice(node) - expect(price).toBe(creditsLabel(0.49)) - }) - - it('should return range when duration widget is missing', () => { - const { getNodeDisplayPrice } = useNodePricing() - const node = createMockNode('MinimaxHailuoVideoNode', []) - - const price = getNodeDisplayPrice(node) - expect(price).toBe( - creditsRangeLabel(0.28, 0.56, { - note: '(varies with resolution & duration)' - }) - ) - }) - }) - - describe('dynamic pricing - OpenAIDalle2', () => { - it('should return $0.02 for 1024x1024 size', () => { - const { getNodeDisplayPrice } = useNodePricing() - const node = createMockNode('OpenAIDalle2', [ - { name: 'size', value: '1024x1024' } - ]) - - const price = getNodeDisplayPrice(node) - expect(price).toBe(creditsLabel(0.02)) - }) - - it('should return $0.018 for 512x512 size', () => { - const { getNodeDisplayPrice } = useNodePricing() - const node = createMockNode('OpenAIDalle2', [ - { name: 'size', value: '512x512' } - ]) - - const price = getNodeDisplayPrice(node) - expect(price).toBe(creditsLabel(0.018)) - }) - - it('should return $0.016 for 256x256 size', () => { - const { getNodeDisplayPrice } = useNodePricing() - const node = createMockNode('OpenAIDalle2', [ - { name: 'size', value: '256x256' } - ]) - - const price = getNodeDisplayPrice(node) - expect(price).toBe(creditsLabel(0.016)) - }) - - it('should return range when size widget is missing', () => { - const { getNodeDisplayPrice } = useNodePricing() - const node = createMockNode('OpenAIDalle2', []) - - const price = getNodeDisplayPrice(node) - expect(price).toBe( - creditsRangeLabel(0.016, 0.02, { - suffix: ' x n/Run', - note: '(varies with size & n)' - }) - ) - }) - }) - - describe('dynamic pricing - OpenAIGPTImage1', () => { - it('should return high price range for high quality', () => { - const { getNodeDisplayPrice } = useNodePricing() - const node = createMockNode('OpenAIGPTImage1', [ - { name: 'quality', value: 'high' } - ]) - - const price = getNodeDisplayPrice(node) - expect(price).toBe(creditsRangeLabel(0.167, 0.3)) - }) - - it('should return medium price range for medium quality', () => { - const { getNodeDisplayPrice } = useNodePricing() - const node = createMockNode('OpenAIGPTImage1', [ - { name: 'quality', value: 'medium' } - ]) - - const price = getNodeDisplayPrice(node) - expect(price).toBe(creditsRangeLabel(0.046, 0.07)) - }) - - it('should return low price range for low quality', () => { - const { getNodeDisplayPrice } = useNodePricing() - const node = createMockNode('OpenAIGPTImage1', [ - { name: 'quality', value: 'low' } - ]) - - const price = getNodeDisplayPrice(node) - expect(price).toBe(creditsRangeLabel(0.011, 0.02)) - }) - - it('should return range when quality widget is missing', () => { - const { getNodeDisplayPrice } = useNodePricing() - const node = createMockNode('OpenAIGPTImage1', []) - - const price = getNodeDisplayPrice(node) - expect(price).toBe( - creditsRangeLabel(0.011, 0.3, { - suffix: ' x n/Run', - note: '(varies with quality & n)' - }) - ) - }) - }) - - describe('dynamic pricing - IdeogramV3', () => { - it('should return correct prices for IdeogramV3 node', () => { - const { getNodeDisplayPrice } = useNodePricing() - - const testCases = [ - { - rendering_speed: 'Quality', - character_image: false, - expected: creditsLabel(0.13) - }, - { - rendering_speed: 'Quality', - character_image: true, - expected: creditsLabel(0.29) - }, - { - rendering_speed: 'Default', - character_image: false, - expected: creditsLabel(0.09) - }, - { - rendering_speed: 'Default', - character_image: true, - expected: creditsLabel(0.21) - }, - { - rendering_speed: 'Turbo', - character_image: false, - expected: creditsLabel(0.04) - }, - { - rendering_speed: 'Turbo', - character_image: true, - expected: creditsLabel(0.14) - } - ] - - testCases.forEach(({ rendering_speed, character_image, expected }) => { - const node = createMockNode( - 'IdeogramV3', - [{ name: 'rendering_speed', value: rendering_speed }], - true, - [{ name: 'character_image', connected: character_image }] - ) - expect(getNodeDisplayPrice(node)).toBe(expected) - }) - }) - - it('should return range when rendering_speed widget is missing', () => { - const { getNodeDisplayPrice } = useNodePricing() - const node = createMockNode('IdeogramV3', []) - - const price = getNodeDisplayPrice(node) - expect(price).toBe( - creditsRangeLabel(0.04, 0.11, { - suffix: ' x num_images/Run', - note: '(varies with rendering speed & num_images)' - }) - ) - }) - - it('should multiply price by num_images for Quality rendering speed', () => { - const { getNodeDisplayPrice } = useNodePricing() - const node = createMockNode('IdeogramV3', [ - { name: 'rendering_speed', value: 'Quality' }, - { name: 'num_images', value: 3 } - ]) - - const price = getNodeDisplayPrice(node) - expect(price).toBe(creditsLabel(0.39)) // 0.09 * 3 * 1.43 - }) - - it('should multiply price by num_images for Turbo rendering speed', () => { - const { getNodeDisplayPrice } = useNodePricing() - const node = createMockNode('IdeogramV3', [ - { name: 'rendering_speed', value: 'Turbo' }, - { name: 'num_images', value: 5 } - ]) - - const price = getNodeDisplayPrice(node) - expect(price).toBe(creditsLabel(0.21)) // 0.03 * 5 * 1.43 - }) - }) - - describe('dynamic pricing - VeoVideoGenerationNode', () => { - it('should return $5.00 for 10s duration', () => { - const { getNodeDisplayPrice } = useNodePricing() - const node = createMockNode('VeoVideoGenerationNode', [ - { name: 'duration_seconds', value: '10' } - ]) - - const price = getNodeDisplayPrice(node) - expect(price).toBe(creditsLabel(5.0)) - }) - - it('should return $2.50 for 5s duration', () => { - const { getNodeDisplayPrice } = useNodePricing() - const node = createMockNode('VeoVideoGenerationNode', [ - { name: 'duration_seconds', value: '5' } - ]) - - const price = getNodeDisplayPrice(node) - expect(price).toBe(creditsLabel(2.5)) - }) - - it('should return range when duration widget is missing', () => { - const { getNodeDisplayPrice } = useNodePricing() - const node = createMockNode('VeoVideoGenerationNode', []) - - const price = getNodeDisplayPrice(node) - expect(price).toBe( - creditsRangeLabel(2.5, 5.0, { note: '(varies with duration)' }) - ) - }) - }) - - describe('dynamic pricing - Veo3VideoGenerationNode', () => { - it('should return $0.80 for veo-3.0-fast-generate-001 without audio', () => { - const { getNodeDisplayPrice } = useNodePricing() - const node = createMockNode('Veo3VideoGenerationNode', [ - { name: 'model', value: 'veo-3.0-fast-generate-001' }, - { name: 'generate_audio', value: false } - ]) - - const price = getNodeDisplayPrice(node) - expect(price).toBe(creditsLabel(0.8)) - }) - - it('should return $1.20 for veo-3.0-fast-generate-001 with audio', () => { - const { getNodeDisplayPrice } = useNodePricing() - const node = createMockNode('Veo3VideoGenerationNode', [ - { name: 'model', value: 'veo-3.0-fast-generate-001' }, - { name: 'generate_audio', value: true } - ]) - - const price = getNodeDisplayPrice(node) - expect(price).toBe(creditsLabel(1.2)) - }) - - it('should return $1.60 for veo-3.0-generate-001 without audio', () => { - const { getNodeDisplayPrice } = useNodePricing() - const node = createMockNode('Veo3VideoGenerationNode', [ - { name: 'model', value: 'veo-3.0-generate-001' }, - { name: 'generate_audio', value: false } - ]) - - const price = getNodeDisplayPrice(node) - expect(price).toBe(creditsLabel(1.6)) - }) - - it('should return $3.20 for veo-3.0-generate-001 with audio', () => { - const { getNodeDisplayPrice } = useNodePricing() - const node = createMockNode('Veo3VideoGenerationNode', [ - { name: 'model', value: 'veo-3.0-generate-001' }, - { name: 'generate_audio', value: true } - ]) - - const price = getNodeDisplayPrice(node) - expect(price).toBe(creditsLabel(3.2)) - }) - - it('should return range when widgets are missing', () => { - const { getNodeDisplayPrice } = useNodePricing() - const node = createMockNode('Veo3VideoGenerationNode', []) - - const price = getNodeDisplayPrice(node) - expect(price).toBe( - creditsRangeLabel(0.8, 3.2, { - note: 'varies with model & audio generation' - }) - ) - }) - - it('should return range when only model widget is present', () => { - const { getNodeDisplayPrice } = useNodePricing() - const node = createMockNode('Veo3VideoGenerationNode', [ - { name: 'model', value: 'veo-3.0-generate-001' } - ]) - - const price = getNodeDisplayPrice(node) - expect(price).toBe( - creditsRangeLabel(0.8, 3.2, { - note: 'varies with model & audio generation' - }) - ) - }) - - it('should return range when only generate_audio widget is present', () => { - const { getNodeDisplayPrice } = useNodePricing() - const node = createMockNode('Veo3VideoGenerationNode', [ - { name: 'generate_audio', value: true } - ]) - - const price = getNodeDisplayPrice(node) - expect(price).toBe( - creditsRangeLabel(0.8, 3.2, { - note: 'varies with model & audio generation' - }) - ) - }) - }) - - describe('dynamic pricing - LumaVideoNode', () => { - it('should return $2.19 for ray-flash-2 4K 5s', () => { - const { getNodeDisplayPrice } = useNodePricing() - const node = createMockNode('LumaVideoNode', [ - { name: 'model', value: 'ray-flash-2' }, - { name: 'resolution', value: '4K' }, - { name: 'duration', value: '5s' } - ]) - - const price = getNodeDisplayPrice(node) - expect(price).toBe(creditsLabel(3.13)) - }) - - it('should return $6.37 for ray-2 4K 5s', () => { - const { getNodeDisplayPrice } = useNodePricing() - const node = createMockNode('LumaVideoNode', [ - { name: 'model', value: 'ray-2' }, - { name: 'resolution', value: '4K' }, - { name: 'duration', value: '5s' } - ]) - - const price = getNodeDisplayPrice(node) - expect(price).toBe(creditsLabel(9.11)) - }) - - it('should return $0.35 for ray-1-6 model', () => { - const { getNodeDisplayPrice } = useNodePricing() - const node = createMockNode('LumaVideoNode', [ - { name: 'model', value: 'ray-1-6' }, - { name: 'resolution', value: '1080p' }, - { name: 'duration', value: '5s' } - ]) + getNodeDisplayPrice(node) + await new Promise((r) => setTimeout(r, 50)) const price = getNodeDisplayPrice(node) expect(price).toBe(creditsLabel(0.5)) }) - it('should return range when widgets are missing', () => { + it('should handle COMBO widget with numeric value', async () => { const { getNodeDisplayPrice } = useNodePricing() - const node = createMockNode('LumaVideoNode', []) - - const price = getNodeDisplayPrice(node) - expect(price).toBe( - creditsRangeLabel(0.2, 16.4, { - note: 'varies with model, resolution & duration' - }) + const node = createMockNodeWithPriceBadge( + 'TestComboNumericNode', + priceBadge('{"type":"usd","usd": widgets.duration * 0.07}', [ + { name: 'duration', type: 'COMBO' } + ]), + [{ name: 'duration', value: 5 }] ) + + getNodeDisplayPrice(node) + await new Promise((r) => setTimeout(r, 50)) + const price = getNodeDisplayPrice(node) + expect(price).toBe(creditsLabel(0.35)) + }) + + it('should handle COMBO widget with string value', async () => { + const { getNodeDisplayPrice } = useNodePricing() + const node = createMockNodeWithPriceBadge( + 'TestComboStringNode', + priceBadge( + '(widgets.mode = "pro") ? {"type":"usd","usd":0.10} : {"type":"usd","usd":0.05}', + [{ name: 'mode', type: 'COMBO' }] + ), + [{ name: 'mode', value: 'Pro' }] // Should be lowercased to "pro" + ) + + getNodeDisplayPrice(node) + await new Promise((r) => setTimeout(r, 50)) + const price = getNodeDisplayPrice(node) + expect(price).toBe(creditsLabel(0.1)) + }) + + it('should handle BOOLEAN widget', async () => { + const { getNodeDisplayPrice } = useNodePricing() + const node = createMockNodeWithPriceBadge( + 'TestBooleanNode', + priceBadge('{"type":"usd","usd": widgets.premium ? 0.10 : 0.05}', [ + { name: 'premium', type: 'BOOLEAN' } + ]), + [{ name: 'premium', value: true }] + ) + + getNodeDisplayPrice(node) + await new Promise((r) => setTimeout(r, 50)) + const price = getNodeDisplayPrice(node) + expect(price).toBe(creditsLabel(0.1)) + }) + + it('should handle STRING widget (lowercased)', async () => { + const { getNodeDisplayPrice } = useNodePricing() + const node = createMockNodeWithPriceBadge( + 'TestStringNode', + priceBadge( + '$contains(widgets.model, "pro") ? {"type":"usd","usd":0.10} : {"type":"usd","usd":0.05}', + [{ name: 'model', type: 'STRING' }] + ), + [{ name: 'model', value: 'ProModel' }] // Should be lowercased to "promodel" + ) + + getNodeDisplayPrice(node) + await new Promise((r) => setTimeout(r, 50)) + const price = getNodeDisplayPrice(node) + expect(price).toBe(creditsLabel(0.1)) }) }) - describe('dynamic pricing - PixverseTextToVideoNode', () => { - it('should return range for 5s 1080p quality', () => { + describe('complex expressions', () => { + it('should handle lookup tables', async () => { const { getNodeDisplayPrice } = useNodePricing() - const node = createMockNode('PixverseTextToVideoNode', [ - { name: 'duration', value: '5s' }, - { name: 'quality', value: '1080p' } - ]) - - const price = getNodeDisplayPrice(node) - expect(price).toBe( - creditsRangeLabel(0.45, 1.2, { - note: 'varies with duration, quality & motion mode' - }) + const node = createMockNodeWithPriceBadge( + 'TestLookupNode', + priceBadge( + `( + $rates := {"720p": 0.05, "1080p": 0.10}; + {"type":"usd","usd": $lookup($rates, widgets.resolution)} + )`, + [{ name: 'resolution', type: 'COMBO' }] + ), + [{ name: 'resolution', value: '1080p' }] ) + + getNodeDisplayPrice(node) + await new Promise((r) => setTimeout(r, 50)) + const price = getNodeDisplayPrice(node) + expect(price).toBe(creditsLabel(0.1)) }) - it('should return range for 5s 540p normal quality', () => { + it('should handle multiple widgets', async () => { const { getNodeDisplayPrice } = useNodePricing() - const node = createMockNode('PixverseTextToVideoNode', [ - { name: 'duration', value: '5s' }, - { name: 'quality', value: '540p' }, - { name: 'motion_mode', value: 'normal' } - ]) - - const price = getNodeDisplayPrice(node) - expect(price).toBe( - creditsRangeLabel(0.45, 1.2, { - note: 'varies with duration, quality & motion mode' - }) + const node = createMockNodeWithPriceBadge( + 'TestMultiWidgetNode', + priceBadge( + `( + $rate := (widgets.mode = "pro") ? 0.10 : 0.05; + {"type":"usd","usd": $rate * widgets.duration} + )`, + [ + { name: 'mode', type: 'COMBO' }, + { name: 'duration', type: 'INT' } + ] + ), + [ + { name: 'mode', value: 'pro' }, + { name: 'duration', value: 10 } + ] ) + + getNodeDisplayPrice(node) + await new Promise((r) => setTimeout(r, 50)) + const price = getNodeDisplayPrice(node) + expect(price).toBe(creditsLabel(1.0)) }) - it('should return range when widgets are missing', () => { + it('should handle conditional pricing based on widget values', async () => { const { getNodeDisplayPrice } = useNodePricing() - const node = createMockNode('PixverseTextToVideoNode', []) - - const price = getNodeDisplayPrice(node) - expect(price).toBe( - creditsRangeLabel(0.45, 1.2, { - note: 'varies with duration, quality & motion mode' - }) + const node = createMockNodeWithPriceBadge( + 'TestConditionalNode', + priceBadge( + `( + $mode := (widgets.resolution = "720p") ? "std" : "pro"; + $rates := {"std": 0.084, "pro": 0.112}; + {"type":"usd","usd": $lookup($rates, $mode) * widgets.duration} + )`, + [ + { name: 'resolution', type: 'COMBO' }, + { name: 'duration', type: 'COMBO' } + ] + ), + [ + { name: 'resolution', value: '1080p' }, + { name: 'duration', value: 5 } + ] ) + + getNodeDisplayPrice(node) + await new Promise((r) => setTimeout(r, 50)) + const price = getNodeDisplayPrice(node) + expect(price).toBe(creditsLabel(0.56)) }) }) - describe('dynamic pricing - KlingDualCharacterVideoEffectNode', () => { - it('should return range for v2-master 5s mode', () => { + describe('range and list results', () => { + it('should format range_usd result', async () => { const { getNodeDisplayPrice } = useNodePricing() - const node = createMockNode('KlingDualCharacterVideoEffectNode', [ - { name: 'mode', value: 'standard / 5s / v2-master' } - ]) - - const price = getNodeDisplayPrice(node) - expect(price).toBe( - creditsRangeLabel(0.14, 2.8, { - note: '(varies with model, mode & duration)' - }) + const node = createMockNodeWithPriceBadge( + 'TestRangeNode', + priceBadge('{"type":"range_usd","min_usd":0.05,"max_usd":0.10}') ) + + getNodeDisplayPrice(node) + await new Promise((r) => setTimeout(r, 50)) + const price = getNodeDisplayPrice(node) + expect(price).toMatch(/\d+\.?\d*-\d+\.?\d* credits\/Run/) }) - it('should return range for v1-6 5s mode', () => { + it('should format list_usd result', async () => { const { getNodeDisplayPrice } = useNodePricing() - const node = createMockNode('KlingDualCharacterVideoEffectNode', [ - { name: 'mode', value: 'standard / 5s / v1-6' } - ]) - - const price = getNodeDisplayPrice(node) - expect(price).toBe( - creditsRangeLabel(0.14, 2.8, { - note: '(varies with model, mode & duration)' - }) + const node = createMockNodeWithPriceBadge( + 'TestListNode', + priceBadge('{"type":"list_usd","usd":[0.05, 0.10, 0.15]}') ) + + getNodeDisplayPrice(node) + await new Promise((r) => setTimeout(r, 50)) + const price = getNodeDisplayPrice(node) + expect(price).toMatch(/\d+\.?\d*\/\d+\.?\d*\/\d+\.?\d* credits\/Run/) }) - it('should return range when mode widget is missing', () => { + it('should respect custom suffix in format options', async () => { const { getNodeDisplayPrice } = useNodePricing() - const node = createMockNode('KlingDualCharacterVideoEffectNode', []) - - const price = getNodeDisplayPrice(node) - expect(price).toBe( - creditsRangeLabel(0.14, 2.8, { - note: '(varies with model, mode & duration)' - }) + const node = createMockNodeWithPriceBadge( + 'TestSuffixNode', + priceBadge('{"type":"usd","usd":0.07,"format":{"suffix":"/second"}}') ) + + getNodeDisplayPrice(node) + await new Promise((r) => setTimeout(r, 50)) + const price = getNodeDisplayPrice(node) + expect(price).toBe(creditsLabel(0.07, '/second')) + }) + + it('should add approximate prefix when specified', async () => { + const { getNodeDisplayPrice } = useNodePricing() + const node = createMockNodeWithPriceBadge( + 'TestApproximateNode', + priceBadge('{"type":"usd","usd":0.05,"format":{"approximate":true}}') + ) + + getNodeDisplayPrice(node) + await new Promise((r) => setTimeout(r, 50)) + const price = getNodeDisplayPrice(node) + expect(price).toMatch(/^~\d+\.?\d* credits\/Run$/) + }) + + it('should add note suffix when specified', async () => { + const { getNodeDisplayPrice } = useNodePricing() + const node = createMockNodeWithPriceBadge( + 'TestNoteNode', + priceBadge('{"type":"usd","usd":0.05,"format":{"note":"(estimated)"}}') + ) + + getNodeDisplayPrice(node) + await new Promise((r) => setTimeout(r, 50)) + const price = getNodeDisplayPrice(node) + expect(price).toMatch(/credits\/Run \(estimated\)$/) + }) + + it('should combine approximate prefix and note suffix', async () => { + const { getNodeDisplayPrice } = useNodePricing() + const node = createMockNodeWithPriceBadge( + 'TestCombinedFormatNode', + priceBadge( + '{"type":"usd","usd":0.05,"format":{"approximate":true,"note":"(beta)","suffix":"/image"}}' + ) + ) + + getNodeDisplayPrice(node) + await new Promise((r) => setTimeout(r, 50)) + const price = getNodeDisplayPrice(node) + expect(price).toMatch(/^~\d+\.?\d* credits\/image \(beta\)$/) + }) + + it('should use custom separator for list_usd', async () => { + const { getNodeDisplayPrice } = useNodePricing() + const node = createMockNodeWithPriceBadge( + 'TestListSeparatorNode', + priceBadge( + '{"type":"list_usd","usd":[0.05, 0.10],"format":{"separator":" or "}}' + ) + ) + + getNodeDisplayPrice(node) + await new Promise((r) => setTimeout(r, 50)) + const price = getNodeDisplayPrice(node) + expect(price).toMatch(/\d+\.?\d* or \d+\.?\d* credits\/Run/) }) }) - describe('dynamic pricing - KlingSingleImageVideoEffectNode', () => { - it('should return $0.28 for fuzzyfuzzy effect', () => { + describe('input connectivity', () => { + it('should handle connected input check', async () => { const { getNodeDisplayPrice } = useNodePricing() - const node = createMockNode('KlingSingleImageVideoEffectNode', [ - { name: 'effect_scene', value: 'fuzzyfuzzy' } - ]) - - const price = getNodeDisplayPrice(node) - expect(price).toBe(creditsLabel(0.28)) - }) - - it('should return $0.49 for dizzydizzy effect', () => { - const { getNodeDisplayPrice } = useNodePricing() - const node = createMockNode('KlingSingleImageVideoEffectNode', [ - { name: 'effect_scene', value: 'dizzydizzy' } - ]) - - const price = getNodeDisplayPrice(node) - expect(price).toBe(creditsLabel(0.49)) - }) - - it('should return range when effect_scene widget is missing', () => { - const { getNodeDisplayPrice } = useNodePricing() - const node = createMockNode('KlingSingleImageVideoEffectNode', []) - - const price = getNodeDisplayPrice(node) - expect(price).toBe( - creditsRangeLabel(0.28, 0.49, { note: '(varies with effect scene)' }) + const node = createMockNodeWithPriceBadge( + 'TestInputNode', + priceBadge( + 'inputs.image.connected ? {"type":"usd","usd":0.10} : {"type":"usd","usd":0.05}', + [], + ['image'] + ), + [], + [{ name: 'image', connected: true }] ) + + getNodeDisplayPrice(node) + await new Promise((r) => setTimeout(r, 50)) + const price = getNodeDisplayPrice(node) + expect(price).toBe(creditsLabel(0.1)) + }) + + it('should handle disconnected input check', async () => { + const { getNodeDisplayPrice } = useNodePricing() + const node = createMockNodeWithPriceBadge( + 'TestInputDisconnectedNode', + priceBadge( + 'inputs.image.connected ? {"type":"usd","usd":0.10} : {"type":"usd","usd":0.05}', + [], + ['image'] + ), + [], + [{ name: 'image', connected: false }] + ) + + getNodeDisplayPrice(node) + await new Promise((r) => setTimeout(r, 50)) + const price = getNodeDisplayPrice(node) + expect(price).toBe(creditsLabel(0.05)) + }) + }) + + describe('edge cases', () => { + it('should return empty string for non-API nodes', () => { + const { getNodeDisplayPrice } = useNodePricing() + const node: MockNode = { + id: 'test', + widgets: [], + inputs: [], + constructor: { + nodeData: { + name: 'RegularNode', + api_node: false + } + } + } + + const price = getNodeDisplayPrice(node as unknown as LGraphNode) + expect(price).toBe('') + }) + + it('should return empty string for nodes without price_badge', () => { + const { getNodeDisplayPrice } = useNodePricing() + const node: MockNode = { + id: 'test', + widgets: [], + inputs: [], + constructor: { + nodeData: { + name: 'ApiNodeNoPricing', + api_node: true + } + } + } + + const price = getNodeDisplayPrice(node as unknown as LGraphNode) + expect(price).toBe('') + }) + + it('should handle null widget value gracefully', async () => { + const { getNodeDisplayPrice } = useNodePricing() + const node = createMockNodeWithPriceBadge( + 'TestNullWidgetNode', + priceBadge( + '{"type":"usd","usd": (widgets.count != null) ? widgets.count * 0.01 : 0.05}', + [{ name: 'count', type: 'INT' }] + ), + [{ name: 'count', value: null }] + ) + + getNodeDisplayPrice(node) + await new Promise((r) => setTimeout(r, 50)) + const price = getNodeDisplayPrice(node) + expect(price).toBe(creditsLabel(0.05)) + }) + + it('should handle missing widget gracefully', async () => { + const { getNodeDisplayPrice } = useNodePricing() + const node = createMockNodeWithPriceBadge( + 'TestMissingWidgetNode', + priceBadge( + '{"type":"usd","usd": (widgets.count != null) ? widgets.count * 0.01 : 0.05}', + [{ name: 'count', type: 'INT' }] + ), + [] + ) + + getNodeDisplayPrice(node) + await new Promise((r) => setTimeout(r, 50)) + const price = getNodeDisplayPrice(node) + expect(price).toBe(creditsLabel(0.05)) + }) + + it('should handle undefined widget value gracefully', async () => { + const { getNodeDisplayPrice } = useNodePricing() + const node = createMockNodeWithPriceBadge( + 'TestUndefinedWidgetNode', + priceBadge( + '{"type":"usd","usd": (widgets.count != null) ? widgets.count * 0.01 : 0.05}', + [{ name: 'count', type: 'INT' }] + ), + [{ name: 'count', value: undefined }] + ) + + getNodeDisplayPrice(node) + await new Promise((r) => setTimeout(r, 50)) + const price = getNodeDisplayPrice(node) + expect(price).toBe(creditsLabel(0.05)) + }) + }) + + describe('getNodePricingConfig', () => { + it('should return pricing config for nodes with price_badge', () => { + const { getNodePricingConfig } = useNodePricing() + const node = createMockNodeWithPriceBadge( + 'TestConfigNode', + priceBadge('{"type":"usd","usd":0.05}') + ) + + const config = getNodePricingConfig(node) + expect(config).toBeDefined() + expect(config?.engine).toBe('jsonata') + expect(config?.expr).toBe('{"type":"usd","usd":0.05}') + expect(config?.depends_on).toBeDefined() + }) + + it('should return undefined for nodes without price_badge', () => { + const { getNodePricingConfig } = useNodePricing() + const node: MockNode = { + id: 'test', + widgets: [], + inputs: [], + constructor: { + nodeData: { + name: 'NoPricingNode', + api_node: true + } + } + } + + const config = getNodePricingConfig(node as unknown as LGraphNode) + expect(config).toBeUndefined() + }) + + it('should return undefined for non-API nodes', () => { + const { getNodePricingConfig } = useNodePricing() + const node: MockNode = { + id: 'test', + widgets: [], + inputs: [], + constructor: { + nodeData: { + name: 'RegularNode', + api_node: false + } + } + } + + const config = getNodePricingConfig(node as unknown as LGraphNode) + expect(config).toBeUndefined() + }) + }) + + describe('getNodeRevisionRef', () => { + it('should return a ref for a node ID', () => { + const { getNodeRevisionRef } = useNodePricing() + const ref = getNodeRevisionRef('node-1') + + expect(ref).toBeDefined() + expect(ref.value).toBe(0) + }) + + it('should return the same ref for the same node ID', () => { + const { getNodeRevisionRef } = useNodePricing() + const ref1 = getNodeRevisionRef('node-same') + const ref2 = getNodeRevisionRef('node-same') + + expect(ref1).toBe(ref2) + }) + + it('should return different refs for different node IDs', () => { + const { getNodeRevisionRef } = useNodePricing() + const ref1 = getNodeRevisionRef('node-a') + const ref2 = getNodeRevisionRef('node-b') + + expect(ref1).not.toBe(ref2) + }) + + it('should handle both string and number node IDs', () => { + const { getNodeRevisionRef } = useNodePricing() + // Number ID gets stringified, so '123' and 123 should return the same ref + const refFromNumber = getNodeRevisionRef(123) + const refFromString = getNodeRevisionRef('123') + + expect(refFromNumber).toBe(refFromString) + }) + }) + + describe('triggerPriceRecalculation', () => { + it('should not throw for API nodes with price_badge', () => { + const { triggerPriceRecalculation } = useNodePricing() + const node = createMockNodeWithPriceBadge( + 'TestTriggerNode', + priceBadge('{"type":"usd","usd":0.05}') + ) + + expect(() => triggerPriceRecalculation(node)).not.toThrow() + }) + + it('should not throw for non-API nodes', () => { + const { triggerPriceRecalculation } = useNodePricing() + const node: MockNode = { + id: 'test', + widgets: [], + inputs: [], + constructor: { + nodeData: { + name: 'RegularNode', + api_node: false + } + } + } + + expect(() => + triggerPriceRecalculation(node as unknown as LGraphNode) + ).not.toThrow() }) }) describe('error handling', () => { - it('should gracefully handle errors in dynamic pricing functions', () => { + it('should return empty string for invalid JSONata expression', async () => { const { getNodeDisplayPrice } = useNodePricing() - // Create a node with malformed widget data that could cause errors - const node = { - id: 'test-node', - widgets: null, // This could cause errors when accessing .find() + const node = createMockNodeWithPriceBadge( + 'TestInvalidExprNode', + // Invalid JSONata syntax (unclosed parenthesis) + priceBadge('{"type":"usd","usd": (widgets.count * 0.01') + ) + + getNodeDisplayPrice(node) + await new Promise((r) => setTimeout(r, 50)) + const price = getNodeDisplayPrice(node) + // Should not crash, just return empty + expect(price).toBe('') + }) + + it('should return empty string for expression that throws at runtime', async () => { + const { getNodeDisplayPrice } = useNodePricing() + const node = createMockNodeWithPriceBadge( + 'TestRuntimeErrorNode', + // Expression that will fail at runtime (calling function on undefined) + priceBadge('$lookup(undefined, "key")') + ) + + getNodeDisplayPrice(node) + await new Promise((r) => setTimeout(r, 50)) + const price = getNodeDisplayPrice(node) + expect(price).toBe('') + }) + + it('should return empty string for invalid PricingResult type', async () => { + const { getNodeDisplayPrice } = useNodePricing() + const node = createMockNodeWithPriceBadge( + 'TestInvalidResultTypeNode', + // Returns object with invalid type field + priceBadge('{"type":"invalid_type","value":123}') + ) + + getNodeDisplayPrice(node) + await new Promise((r) => setTimeout(r, 50)) + const price = getNodeDisplayPrice(node) + expect(price).toBe('') + }) + + it('should return empty string for PricingResult missing type field', async () => { + const { getNodeDisplayPrice } = useNodePricing() + const node = createMockNodeWithPriceBadge( + 'TestMissingTypeNode', + // Returns object without type field + priceBadge('{"usd":0.05}') + ) + + getNodeDisplayPrice(node) + await new Promise((r) => setTimeout(r, 50)) + const price = getNodeDisplayPrice(node) + expect(price).toBe('') + }) + + it('should return empty string for non-object result', async () => { + const { getNodeDisplayPrice } = useNodePricing() + const node = createMockNodeWithPriceBadge( + 'TestNonObjectNode', + // Returns a plain number instead of PricingResult object + priceBadge('0.05') + ) + + getNodeDisplayPrice(node) + await new Promise((r) => setTimeout(r, 50)) + const price = getNodeDisplayPrice(node) + expect(price).toBe('') + }) + + it('should return empty string for null result', async () => { + const { getNodeDisplayPrice } = useNodePricing() + const node = createMockNodeWithPriceBadge( + 'TestNullResultNode', + priceBadge('null') + ) + + getNodeDisplayPrice(node) + await new Promise((r) => setTimeout(r, 50)) + const price = getNodeDisplayPrice(node) + expect(price).toBe('') + }) + }) + + describe('input_groups connectivity', () => { + it('should count connected inputs in a group', async () => { + const { getNodeDisplayPrice } = useNodePricing() + + // Create a node with autogrow-style inputs (group.input1, group.input2, etc.) + const node: MockNode = { + id: Math.random().toString(), + widgets: [], + inputs: [ + { name: 'videos.clip1', link: 1 }, // connected + { name: 'videos.clip2', link: 2 }, // connected + { name: 'videos.clip3', link: null }, // disconnected + { name: 'other_input', link: 3 } // connected but not in group + ], constructor: { nodeData: { - name: 'KlingTextToVideoNode', - api_node: true - } - } - } as unknown as LGraphNode - - // Should not throw an error and return empty string as fallback - const price = getNodeDisplayPrice(node) - expect(price).toBe( - creditsRangeLabel(0.14, 2.8, { - note: '(varies with model, mode & duration)' - }) - ) - }) - - it('should handle completely broken widget structure', () => { - const { getNodeDisplayPrice } = useNodePricing() - // Create a node with no widgets property at all - const node = { - id: 'test-node', - // No widgets property - constructor: { - nodeData: { - name: 'OpenAIDalle3', - api_node: true - } - } - } as unknown as LGraphNode - - // Should gracefully fall back to the default range - const price = getNodeDisplayPrice(node) - expect(price).toBe( - creditsRangeLabel(0.04, 0.12, { note: '(varies with size & quality)' }) - ) - }) - }) - - describe('helper methods', () => { - describe('getNodePricingConfig', () => { - it('should return pricing config for known API nodes', () => { - const { getNodePricingConfig } = useNodePricing() - const node = createMockNode('KlingTextToVideoNode') - - const config = getNodePricingConfig(node) - expect(config).toBeDefined() - expect(typeof config.displayPrice).toBe('function') - }) - - it('should return undefined for unknown nodes', () => { - const { getNodePricingConfig } = useNodePricing() - const node = createMockNode('UnknownNode') - - const config = getNodePricingConfig(node) - expect(config).toBeUndefined() - }) - }) - - describe('getRelevantWidgetNames', () => { - it('should return correct widget names for KlingTextToVideoNode', () => { - const { getRelevantWidgetNames } = useNodePricing() - - const widgetNames = getRelevantWidgetNames('KlingTextToVideoNode') - expect(widgetNames).toEqual(['mode', 'model_name', 'duration']) - }) - - it('should return correct widget names for KlingImage2VideoNode', () => { - const { getRelevantWidgetNames } = useNodePricing() - - const widgetNames = getRelevantWidgetNames('KlingImage2VideoNode') - expect(widgetNames).toEqual(['mode', 'model_name', 'duration']) - }) - - it('should return correct widget names for OpenAIDalle3', () => { - const { getRelevantWidgetNames } = useNodePricing() - - const widgetNames = getRelevantWidgetNames('OpenAIDalle3') - expect(widgetNames).toEqual(['size', 'quality']) - }) - - it('should return correct widget names for VeoVideoGenerationNode', () => { - const { getRelevantWidgetNames } = useNodePricing() - - const widgetNames = getRelevantWidgetNames('VeoVideoGenerationNode') - expect(widgetNames).toEqual(['duration_seconds']) - }) - - it('should return correct widget names for Veo3VideoGenerationNode', () => { - const { getRelevantWidgetNames } = useNodePricing() - - const widgetNames = getRelevantWidgetNames('Veo3VideoGenerationNode') - expect(widgetNames).toEqual(['model', 'generate_audio']) - }) - - it('should return correct widget names for LumaVideoNode', () => { - const { getRelevantWidgetNames } = useNodePricing() - - const widgetNames = getRelevantWidgetNames('LumaVideoNode') - expect(widgetNames).toEqual(['model', 'resolution', 'duration']) - }) - - it('should return correct widget names for KlingSingleImageVideoEffectNode', () => { - const { getRelevantWidgetNames } = useNodePricing() - - const widgetNames = getRelevantWidgetNames( - 'KlingSingleImageVideoEffectNode' - ) - expect(widgetNames).toEqual(['effect_scene']) - }) - - it('should return empty array for unknown node types', () => { - const { getRelevantWidgetNames } = useNodePricing() - - const widgetNames = getRelevantWidgetNames('UnknownNode') - expect(widgetNames).toEqual([]) - }) - - describe('Ideogram nodes with num_images parameter', () => { - it('should return correct widget names for IdeogramV1', () => { - const { getRelevantWidgetNames } = useNodePricing() - - const widgetNames = getRelevantWidgetNames('IdeogramV1') - expect(widgetNames).toEqual(['num_images', 'turbo']) - }) - - it('should return correct widget names for IdeogramV2', () => { - const { getRelevantWidgetNames } = useNodePricing() - - const widgetNames = getRelevantWidgetNames('IdeogramV2') - expect(widgetNames).toEqual(['num_images', 'turbo']) - }) - - it('should return correct widget names for IdeogramV3', () => { - const { getRelevantWidgetNames } = useNodePricing() - - const widgetNames = getRelevantWidgetNames('IdeogramV3') - expect(widgetNames).toEqual([ - 'rendering_speed', - 'num_images', - 'character_image' - ]) - }) - }) - - describe('Recraft nodes with n parameter', () => { - it('should return correct widget names for RecraftTextToImageNode', () => { - const { getRelevantWidgetNames } = useNodePricing() - - const widgetNames = getRelevantWidgetNames('RecraftTextToImageNode') - expect(widgetNames).toEqual(['n']) - }) - - it('should return correct widget names for RecraftTextToVectorNode', () => { - const { getRelevantWidgetNames } = useNodePricing() - - const widgetNames = getRelevantWidgetNames('RecraftTextToVectorNode') - expect(widgetNames).toEqual(['n']) - }) - }) - }) - - describe('Ideogram nodes dynamic pricing', () => { - it('should calculate dynamic pricing for IdeogramV1 based on num_images value', () => { - const { getNodeDisplayPrice } = useNodePricing() - const node = createMockNode('IdeogramV1', [ - { name: 'num_images', value: 3 } - ]) - - const price = getNodeDisplayPrice(node) - expect(price).toBe(creditsLabel(0.26)) // 0.06 * 3 * 1.43 - }) - - it('should calculate dynamic pricing for IdeogramV2 based on num_images value', () => { - const { getNodeDisplayPrice } = useNodePricing() - const node = createMockNode('IdeogramV2', [ - { name: 'num_images', value: 4 } - ]) - - const price = getNodeDisplayPrice(node) - expect(price).toBe(creditsLabel(0.46)) // 0.08 * 4 * 1.43 - }) - - it('should fall back to static display when num_images widget is missing for IdeogramV1', () => { - const { getNodeDisplayPrice } = useNodePricing() - const node = createMockNode('IdeogramV1', []) - - const price = getNodeDisplayPrice(node) - expect(price).toBe( - creditsRangeLabel(0.03, 0.09, { suffix: ' x num_images/Run' }) - ) - }) - - it('should fall back to static display when num_images widget is missing for IdeogramV2', () => { - const { getNodeDisplayPrice } = useNodePricing() - const node = createMockNode('IdeogramV2', []) - - const price = getNodeDisplayPrice(node) - expect(price).toBe( - creditsRangeLabel(0.07, 0.11, { suffix: ' x num_images/Run' }) - ) - }) - - it('should handle edge case when num_images value is 1 for IdeogramV1', () => { - const { getNodeDisplayPrice } = useNodePricing() - const node = createMockNode('IdeogramV1', [ - { name: 'num_images', value: 1 } - ]) - - const price = getNodeDisplayPrice(node) - expect(price).toBe(creditsLabel(0.09)) // 0.06 * 1 * 1.43 (turbo=false by default) - }) - }) - - describe('Recraft nodes dynamic pricing', () => { - it('should calculate dynamic pricing for RecraftTextToImageNode based on n value', () => { - const { getNodeDisplayPrice } = useNodePricing() - const node = createMockNode('RecraftTextToImageNode', [ - { name: 'n', value: 3 } - ]) - - const price = getNodeDisplayPrice(node) - expect(price).toBe(creditsLabel(0.12)) // 0.04 * 3 - }) - - it('should calculate dynamic pricing for RecraftTextToVectorNode based on n value', () => { - const { getNodeDisplayPrice } = useNodePricing() - const node = createMockNode('RecraftTextToVectorNode', [ - { name: 'n', value: 2 } - ]) - - const price = getNodeDisplayPrice(node) - expect(price).toBe(creditsLabel(0.16)) // 0.08 * 2 - }) - - it('should fall back to static display when n widget is missing', () => { - const { getNodeDisplayPrice } = useNodePricing() - const node = createMockNode('RecraftTextToImageNode', []) - - const price = getNodeDisplayPrice(node) - expect(price).toBe(creditsLabel(0.04, { suffix: ' x n/Run' })) - }) - - it('should handle edge case when n value is 1', () => { - const { getNodeDisplayPrice } = useNodePricing() - const node = createMockNode('RecraftImageInpaintingNode', [ - { name: 'n', value: 1 } - ]) - - const price = getNodeDisplayPrice(node) - expect(price).toBe(creditsLabel(0.04)) // 0.04 * 1 - }) - }) - }) - - describe('OpenAI nodes dynamic pricing with n parameter', () => { - it('should calculate dynamic pricing for OpenAIDalle2 based on size and n', () => { - const { getNodeDisplayPrice } = useNodePricing() - const node = createMockNode('OpenAIDalle2', [ - { name: 'size', value: '1024x1024' }, - { name: 'n', value: 3 } - ]) - - const price = getNodeDisplayPrice(node) - expect(price).toBe(creditsLabel(0.06)) // 0.02 * 3 - }) - - it('should calculate dynamic pricing for OpenAIGPTImage1 based on quality and n', () => { - const { getNodeDisplayPrice } = useNodePricing() - const node = createMockNode('OpenAIGPTImage1', [ - { name: 'quality', value: 'low' }, - { name: 'n', value: 2 } - ]) - - const price = getNodeDisplayPrice(node) - expect(price).toBe(creditsRangeLabel(0.011, 0.02, { suffix: ' x 2/Run' })) - }) - - it('should fall back to static display when n widget is missing for OpenAIDalle2', () => { - const { getNodeDisplayPrice } = useNodePricing() - const node = createMockNode('OpenAIDalle2', [ - { name: 'size', value: '512x512' } - ]) - - const price = getNodeDisplayPrice(node) - expect(price).toBe(creditsLabel(0.018)) // n defaults to 1 - }) - }) - - describe('KlingImageGenerationNode dynamic pricing with n parameter', () => { - it('should calculate dynamic pricing for text-to-image with kling-v1', () => { - const { getNodeDisplayPrice } = useNodePricing() - const node = createMockNode('KlingImageGenerationNode', [ - { name: 'model_name', value: 'kling-v1' }, - { name: 'n', value: 4 } - ]) - - const price = getNodeDisplayPrice(node) - expect(price).toBe(creditsLabel(0.014)) // 0.0035 * 4 - }) - - it('should calculate dynamic pricing for text-to-image with kling-v1-5', () => { - const { getNodeDisplayPrice } = useNodePricing() - // Mock node without image input (text-to-image mode) - const node = createMockNode('KlingImageGenerationNode', [ - { name: 'model_name', value: 'kling-v1-5' }, - { name: 'n', value: 2 } - ]) - - const price = getNodeDisplayPrice(node) - expect(price).toBe(creditsLabel(0.028)) // For kling-v1-5 text-to-image: 0.014 * 2 - }) - - it('should fall back to static display when model widget is missing', () => { - const { getNodeDisplayPrice } = useNodePricing() - const node = createMockNode('KlingImageGenerationNode', []) - - const price = getNodeDisplayPrice(node) - expect(price).toBe( - creditsRangeLabel(0.0035, 0.028, { - suffix: ' x n/Run', - note: '(varies with modality & model)' - }) - ) - }) - }) - - describe('New Recraft nodes dynamic pricing', () => { - it('should calculate dynamic pricing for RecraftGenerateImageNode', () => { - const { getNodeDisplayPrice } = useNodePricing() - const node = createMockNode('RecraftGenerateImageNode', [ - { name: 'n', value: 3 } - ]) - - const price = getNodeDisplayPrice(node) - expect(price).toBe(creditsLabel(0.12)) // 0.04 * 3 - }) - - it('should calculate dynamic pricing for RecraftVectorizeImageNode', () => { - const { getNodeDisplayPrice } = useNodePricing() - const node = createMockNode('RecraftVectorizeImageNode', [ - { name: 'n', value: 5 } - ]) - - const price = getNodeDisplayPrice(node) - expect(price).toBe(creditsLabel(0.05)) // 0.01 * 5 - }) - - it('should calculate dynamic pricing for RecraftGenerateVectorImageNode', () => { - const { getNodeDisplayPrice } = useNodePricing() - const node = createMockNode('RecraftGenerateVectorImageNode', [ - { name: 'n', value: 2 } - ]) - - const price = getNodeDisplayPrice(node) - expect(price).toBe(creditsLabel(0.16)) // 0.08 * 2 - }) - }) - - describe('Widget names for reactive updates', () => { - it('should include n parameter for OpenAI nodes', () => { - const { getRelevantWidgetNames } = useNodePricing() - - expect(getRelevantWidgetNames('OpenAIDalle2')).toEqual(['size', 'n']) - expect(getRelevantWidgetNames('OpenAIGPTImage1')).toEqual([ - 'quality', - 'n' - ]) - }) - - it('should include n parameter for Kling and new Recraft nodes', () => { - const { getRelevantWidgetNames } = useNodePricing() - - expect(getRelevantWidgetNames('KlingImageGenerationNode')).toEqual([ - 'modality', - 'model_name', - 'n' - ]) - expect(getRelevantWidgetNames('RecraftVectorizeImageNode')).toEqual(['n']) - expect(getRelevantWidgetNames('RecraftGenerateImageNode')).toEqual(['n']) - expect(getRelevantWidgetNames('RecraftGenerateVectorImageNode')).toEqual([ - 'n' - ]) - expect( - getRelevantWidgetNames('RecraftGenerateColorFromImageNode') - ).toEqual(['n']) - }) - - it('should include relevant widget names for new nodes', () => { - const { getRelevantWidgetNames } = useNodePricing() - - expect(getRelevantWidgetNames('RunwayImageToVideoNodeGen3a')).toEqual([ - 'duration' - ]) - expect(getRelevantWidgetNames('RunwayImageToVideoNodeGen4')).toEqual([ - 'duration' - ]) - expect(getRelevantWidgetNames('RunwayFirstLastFrameNode')).toEqual([ - 'duration' - ]) - expect(getRelevantWidgetNames('TripoTextToModelNode')).toEqual([ - 'model_version', - 'quad', - 'style', - 'texture', - 'pbr', - 'texture_quality', - 'geometry_quality' - ]) - expect(getRelevantWidgetNames('TripoImageToModelNode')).toEqual([ - 'model_version', - 'quad', - 'style', - 'texture', - 'pbr', - 'texture_quality', - 'geometry_quality' - ]) - }) - }) - - describe('New API nodes pricing', () => { - describe('RunwayML nodes', () => { - it('should return static price for RunwayTextToImageNode', () => { - const { getNodeDisplayPrice } = useNodePricing() - const node = createMockNode('RunwayTextToImageNode') - - const price = getNodeDisplayPrice(node) - expect(price).toBe(creditsLabel(0.11)) - }) - - it('should calculate dynamic pricing for RunwayImageToVideoNodeGen3a', () => { - const { getNodeDisplayPrice } = useNodePricing() - const node = createMockNode('RunwayImageToVideoNodeGen3a', [ - { name: 'duration', value: 10 } - ]) - - const price = getNodeDisplayPrice(node) - expect(price).toBe(creditsLabel(0.0715 * 10)) - }) - - it('should return fallback for RunwayImageToVideoNodeGen3a without duration', () => { - const { getNodeDisplayPrice } = useNodePricing() - const node = createMockNode('RunwayImageToVideoNodeGen3a', []) - - const price = getNodeDisplayPrice(node) - expect(price).toBe(creditsLabel(0.0715, { suffix: '/second' })) - }) - - it('should handle zero duration for RunwayImageToVideoNodeGen3a', () => { - const { getNodeDisplayPrice } = useNodePricing() - const node = createMockNode('RunwayImageToVideoNodeGen3a', [ - { name: 'duration', value: 0 } - ]) - - const price = getNodeDisplayPrice(node) - expect(price).toBe(creditsLabel(0.0)) // 0.05 * 0 = 0 - }) - - it('should handle NaN duration for RunwayImageToVideoNodeGen3a', () => { - const { getNodeDisplayPrice } = useNodePricing() - const node = createMockNode('RunwayImageToVideoNodeGen3a', [ - { name: 'duration', value: 'invalid' } - ]) - - const price = getNodeDisplayPrice(node) - expect(price).toBe(creditsLabel(0.0715 * 5)) - }) - }) - - describe('Rodin nodes', () => { - it('should return base price for Rodin3D_Regular', () => { - const { getNodeDisplayPrice } = useNodePricing() - const node = createMockNode('Rodin3D_Regular') - - const price = getNodeDisplayPrice(node) - expect(price).toBe(creditsLabel(0.4)) - }) - - it('should return addon price for Rodin3D_Detail', () => { - const { getNodeDisplayPrice } = useNodePricing() - const node = createMockNode('Rodin3D_Detail') - - const price = getNodeDisplayPrice(node) - expect(price).toBe(creditsLabel(0.4)) - }) - - it('should return addon price for Rodin3D_Smooth', () => { - const { getNodeDisplayPrice } = useNodePricing() - const node = createMockNode('Rodin3D_Smooth') - - const price = getNodeDisplayPrice(node) - expect(price).toBe(creditsLabel(0.4)) - }) - }) - - describe('Tripo nodes', () => { - it('should return v2.5 standard pricing for TripoTextToModelNode', () => { - const { getNodeDisplayPrice } = useNodePricing() - const node = createMockNode('TripoTextToModelNode', [ - { name: 'model_version', value: 'v2.5' }, - { name: 'quad', value: false }, - { name: 'style', value: 'any style' }, - { name: 'texture', value: false }, - { name: 'texture_quality', value: 'standard' } - ]) - - const price = getNodeDisplayPrice(node) - expect(price).toBe(creditsLabel(0.15)) // any style, no quad, no texture - }) - - it('should return v2.5 detailed pricing for TripoTextToModelNode', () => { - const { getNodeDisplayPrice } = useNodePricing() - const node = createMockNode('TripoTextToModelNode', [ - { name: 'model_version', value: 'v2.5' }, - { name: 'quad', value: true }, - { name: 'style', value: 'any style' }, - { name: 'texture', value: false }, - { name: 'texture_quality', value: 'detailed' } - ]) - - const price = getNodeDisplayPrice(node) - expect(price).toBe(creditsLabel(0.3)) // any style, quad, no texture, detailed - }) - - it('should return v2.0 detailed pricing for TripoImageToModelNode', () => { - const { getNodeDisplayPrice } = useNodePricing() - const node = createMockNode('TripoImageToModelNode', [ - { name: 'model_version', value: 'v2.0' }, - { name: 'quad', value: true }, - { name: 'style', value: 'any style' }, - { name: 'texture', value: false }, - { name: 'texture_quality', value: 'detailed' } - ]) - - const price = getNodeDisplayPrice(node) - expect(price).toBe(creditsLabel(0.4)) // any style, quad, no texture, detailed - }) - - it('should return legacy pricing for TripoTextToModelNode', () => { - const { getNodeDisplayPrice } = useNodePricing() - const node = createMockNode('TripoTextToModelNode', [ - { name: 'model_version', value: 'v2.0' }, - { name: 'quad', value: false }, - { name: 'style', value: 'none' }, - { name: 'texture', value: false }, - { name: 'texture_quality', value: 'standard' } - ]) - - const price = getNodeDisplayPrice(node) - expect(price).toBe(creditsLabel(0.1)) // none style, no quad, no texture - }) - - it('should return static price for TripoRefineNode', () => { - const { getNodeDisplayPrice } = useNodePricing() - const node = createMockNode('TripoRefineNode') - - const price = getNodeDisplayPrice(node) - expect(price).toBe(creditsLabel(0.3)) - }) - - it('should return fallback for TripoTextToModelNode without model', () => { - const { getNodeDisplayPrice } = useNodePricing() - const node = createMockNode('TripoTextToModelNode', []) - - const price = getNodeDisplayPrice(node) - expect(price).toBe( - creditsRangeLabel(0.1, 0.65, { - note: 'varies with quad, style, texture & quality' - }) - ) - }) - - it('should return texture-based pricing for TripoTextureNode', () => { - const { getNodeDisplayPrice } = useNodePricing() - const standardNode = createMockNode('TripoTextureNode', [ - { name: 'texture_quality', value: 'standard' } - ]) - const detailedNode = createMockNode('TripoTextureNode', [ - { name: 'texture_quality', value: 'detailed' } - ]) - - expect(getNodeDisplayPrice(standardNode)).toBe(creditsLabel(0.1)) - expect(getNodeDisplayPrice(detailedNode)).toBe(creditsLabel(0.2)) - }) - - it('should handle various Tripo parameter combinations', () => { - const { getNodeDisplayPrice } = useNodePricing() - - // Test different parameter combinations - const testCases = [ - { - model_version: 'v3.0', - quad: false, - style: 'none', - texture: false, - expected: creditsLabel(0.1) - }, - { - model_version: 'v3.0', - quad: false, - style: 'any style', - texture: false, - expected: creditsLabel(0.15) - }, - { - model_version: 'v3.0', - quad: true, - style: 'any style', - texture: false, - expected: creditsLabel(0.2) - }, - { - model_version: 'v3.0', - quad: true, - style: 'any style', - texture: true, - expected: creditsLabel(0.3) - } - ] - - testCases.forEach(({ quad, style, texture, expected }) => { - const node = createMockNode('TripoTextToModelNode', [ - { name: 'model_version', value: 'v2.0' }, - { name: 'quad', value: quad }, - { name: 'style', value: style }, - { name: 'texture', value: texture }, - { name: 'texture_quality', value: 'standard' } - ]) - expect(getNodeDisplayPrice(node)).toBe(expected) - }) - }) - - it('should return static price for TripoRetargetNode', () => { - const { getNodeDisplayPrice } = useNodePricing() - const node = createMockNode('TripoRetargetNode') - - const price = getNodeDisplayPrice(node) - expect(price).toBe(creditsLabel(0.1)) - }) - - it('should return dynamic pricing for TripoMultiviewToModelNode', () => { - const { getNodeDisplayPrice } = useNodePricing() - - // Test basic case - no style, no quad, no texture - const basicNode = createMockNode('TripoMultiviewToModelNode', [ - { name: 'model_version', value: 'v3.0' }, - { name: 'quad', value: false }, - { name: 'style', value: 'none' }, - { name: 'texture', value: false }, - { name: 'texture_quality', value: 'standard' } - ]) - expect(getNodeDisplayPrice(basicNode)).toBe(creditsLabel(0.2)) - - // Test high-end case - any style, quad, texture, detailed - const highEndNode = createMockNode('TripoMultiviewToModelNode', [ - { name: 'model_version', value: 'v3.0' }, - { name: 'quad', value: true }, - { name: 'style', value: 'stylized' }, - { name: 'texture', value: true }, - { name: 'texture_quality', value: 'detailed' } - ]) - expect(getNodeDisplayPrice(highEndNode)).toBe(creditsLabel(0.5)) - }) - - it('should return fallback for TripoMultiviewToModelNode without widgets', () => { - const { getNodeDisplayPrice } = useNodePricing() - const node = createMockNode('TripoMultiviewToModelNode', []) - - const price = getNodeDisplayPrice(node) - expect(price).toBe( - creditsRangeLabel(0.1, 0.65, { - note: '(varies with quad, style, texture & quality)' - }) - ) - }) - }) - - describe('Gemini and OpenAI Chat nodes', () => { - it('should return specific pricing for supported Gemini models', () => { - const { getNodeDisplayPrice } = useNodePricing() - - const testCases = [ - { - model: 'gemini-2.5-pro-preview-05-06', - expected: creditsListLabel([0.00125, 0.01], { - suffix: ' per 1K tokens', - approximate: true, - separator: '-' - }) - }, - { - model: 'gemini-2.5-pro', - expected: creditsListLabel([0.00125, 0.01], { - suffix: ' per 1K tokens', - approximate: true, - separator: '-' - }) - }, - { - model: 'gemini-3-pro-preview', - expected: creditsListLabel([0.002, 0.012], { - suffix: ' per 1K tokens', - approximate: true, - separator: '-' - }) - }, - { - model: 'gemini-2.5-flash-preview-04-17', - expected: creditsListLabel([0.0003, 0.0025], { - suffix: ' per 1K tokens', - approximate: true, - separator: '-' - }) - }, - { - model: 'gemini-2.5-flash', - expected: creditsListLabel([0.0003, 0.0025], { - suffix: ' per 1K tokens', - approximate: true, - separator: '-' - }) - }, - { model: 'unknown-gemini-model', expected: 'Token-based' } - ] - - testCases.forEach(({ model, expected }) => { - const node = createMockNode('GeminiNode', [ - { name: 'model', value: model } - ]) - expect(getNodeDisplayPrice(node)).toBe(expected) - }) - }) - - it('should return fallback for GeminiNode without model widget', () => { - const { getNodeDisplayPrice } = useNodePricing() - const node = createMockNode('GeminiNode', []) - - const price = getNodeDisplayPrice(node) - expect(price).toBe('Token-based') - }) - - it('should return token-based pricing for OpenAIChatNode', () => { - const { getNodeDisplayPrice } = useNodePricing() - const node = createMockNode('OpenAIChatNode', [ - { name: 'model', value: 'unknown-model' } - ]) - - const price = getNodeDisplayPrice(node) - expect(price).toBe('Token-based') - }) - - it('should return correct pricing for all exposed OpenAI models', () => { - const { getNodeDisplayPrice } = useNodePricing() - - const testCases = [ - { - model: 'o4-mini', - expected: creditsListLabel([0.0011, 0.0044], { - suffix: ' per 1K tokens', - approximate: true, - separator: '-' - }) - }, - { - model: 'o1-pro', - expected: creditsListLabel([0.15, 0.6], { - suffix: ' per 1K tokens', - approximate: true, - separator: '-' - }) - }, - { - model: 'o1', - expected: creditsListLabel([0.015, 0.06], { - suffix: ' per 1K tokens', - approximate: true, - separator: '-' - }) - }, - { - model: 'o3-mini', - expected: creditsListLabel([0.0011, 0.0044], { - suffix: ' per 1K tokens', - approximate: true, - separator: '-' - }) - }, - { - model: 'o3', - expected: creditsListLabel([0.01, 0.04], { - suffix: ' per 1K tokens', - approximate: true, - separator: '-' - }) - }, - { - model: 'gpt-4o', - expected: creditsListLabel([0.0025, 0.01], { - suffix: ' per 1K tokens', - approximate: true, - separator: '-' - }) - }, - { - model: 'gpt-4.1-nano', - expected: creditsListLabel([0.0001, 0.0004], { - suffix: ' per 1K tokens', - approximate: true, - separator: '-' - }) - }, - { - model: 'gpt-4.1-mini', - expected: creditsListLabel([0.0004, 0.0016], { - suffix: ' per 1K tokens', - approximate: true, - separator: '-' - }) - }, - { - model: 'gpt-4.1', - expected: creditsListLabel([0.002, 0.008], { - suffix: ' per 1K tokens', - approximate: true, - separator: '-' - }) - }, - { - model: 'gpt-5-nano', - expected: creditsListLabel([0.00005, 0.0004], { - suffix: ' per 1K tokens', - approximate: true, - separator: '-' - }) - }, - { - model: 'gpt-5-mini', - expected: creditsListLabel([0.00025, 0.002], { - suffix: ' per 1K tokens', - approximate: true, - separator: '-' - }) - }, - { - model: 'gpt-5', - expected: creditsListLabel([0.00125, 0.01], { - suffix: ' per 1K tokens', - approximate: true, - separator: '-' - }) - } - ] - - testCases.forEach(({ model, expected }) => { - const node = createMockNode('OpenAIChatNode', [ - { name: 'model', value: model } - ]) - expect(getNodeDisplayPrice(node)).toBe(expected) - }) - }) - - it('should handle model ordering correctly (specific before general)', () => { - const { getNodeDisplayPrice } = useNodePricing() - - // Test that more specific patterns are matched before general ones - const testCases = [ - { - model: 'gpt-4.1-nano-test', - expected: creditsListLabel([0.0001, 0.0004], { - suffix: ' per 1K tokens', - approximate: true, - separator: '-' - }) - }, - { - model: 'gpt-4.1-mini-test', - expected: creditsListLabel([0.0004, 0.0016], { - suffix: ' per 1K tokens', - approximate: true, - separator: '-' - }) - }, - { - model: 'gpt-4.1-test', - expected: creditsListLabel([0.002, 0.008], { - suffix: ' per 1K tokens', - approximate: true, - separator: '-' - }) - }, - { - model: 'o1-pro-test', - expected: creditsListLabel([0.15, 0.6], { - suffix: ' per 1K tokens', - approximate: true, - separator: '-' - }) - }, - { - model: 'o1-test', - expected: creditsListLabel([0.015, 0.06], { - suffix: ' per 1K tokens', - approximate: true, - separator: '-' - }) - }, - { - model: 'o3-mini-test', - expected: creditsListLabel([0.0011, 0.0044], { - suffix: ' per 1K tokens', - approximate: true, - separator: '-' - }) - }, - { model: 'unknown-model', expected: 'Token-based' } - ] - - testCases.forEach(({ model, expected }) => { - const node = createMockNode('OpenAIChatNode', [ - { name: 'model', value: model } - ]) - expect(getNodeDisplayPrice(node)).toBe(expected) - }) - }) - - it('should return fallback for OpenAIChatNode without model widget', () => { - const { getNodeDisplayPrice } = useNodePricing() - const node = createMockNode('OpenAIChatNode', []) - - const price = getNodeDisplayPrice(node) - expect(price).toBe('Token-based') - }) - - it('should return static price for GeminiImageNode', () => { - const { getNodeDisplayPrice } = useNodePricing() - const node = createMockNode('GeminiImageNode') - - const price = getNodeDisplayPrice(node) - expect(price).toBe( - creditsLabel(0.039, { - approximate: true, - suffix: '/Image (1K)' - }) - ) - }) - }) - - describe('Additional RunwayML edge cases', () => { - it('should handle edge cases for RunwayML duration-based pricing', () => { - const { getNodeDisplayPrice } = useNodePricing() - - // Test edge cases - const RATE_PER_SECOND = 0.0715 - const testCases = [ - { duration: 0, expected: creditsLabel(0) }, - { duration: 1, expected: creditsLabel(RATE_PER_SECOND) }, - { duration: 30, expected: creditsLabel(RATE_PER_SECOND * 30) } - ] - - testCases.forEach(({ duration, expected }) => { - const node = createMockNode('RunwayImageToVideoNodeGen3a', [ - { name: 'duration', value: duration } - ]) - expect(getNodeDisplayPrice(node)).toBe(expected) - }) - }) - - it('should handle invalid duration values gracefully', () => { - const { getNodeDisplayPrice } = useNodePricing() - const node = createMockNode('RunwayImageToVideoNodeGen3a', [ - { name: 'duration', value: 'invalid-string' } - ]) - // When Number('invalid-string') returns NaN, it falls back to 5 seconds - expect(getNodeDisplayPrice(node)).toBe(creditsLabel(0.0715 * 5)) - }) - - it('should handle missing duration widget gracefully', () => { - const { getNodeDisplayPrice } = useNodePricing() - const nodes = [ - 'RunwayImageToVideoNodeGen3a', - 'RunwayImageToVideoNodeGen4', - 'RunwayFirstLastFrameNode' - ] - - nodes.forEach((nodeType) => { - const node = createMockNode(nodeType, []) - expect(getNodeDisplayPrice(node)).toBe( - creditsLabel(0.0715, { suffix: '/second' }) - ) - }) - }) - }) - - describe('Complete Rodin node coverage', () => { - it('should return correct pricing for all Rodin variants', () => { - const { getNodeDisplayPrice } = useNodePricing() - - const testCases = [ - { nodeType: 'Rodin3D_Regular', expected: creditsLabel(0.4) }, - { nodeType: 'Rodin3D_Sketch', expected: creditsLabel(0.4) }, - { nodeType: 'Rodin3D_Detail', expected: creditsLabel(0.4) }, - { nodeType: 'Rodin3D_Smooth', expected: creditsLabel(0.4) } - ] - - testCases.forEach(({ nodeType, expected }) => { - const node = createMockNode(nodeType) - expect(getNodeDisplayPrice(node)).toBe(expected) - }) - }) - }) - - describe('Comprehensive Tripo edge case testing', () => { - it('should handle TripoImageToModelNode with various parameter combinations', () => { - const { getNodeDisplayPrice } = useNodePricing() - - const testCases = [ - { - quad: false, - style: 'none', - texture: false, - expected: creditsLabel(0.2) - }, - { - quad: false, - style: 'none', - texture: true, - expected: creditsLabel(0.3) - }, - { - quad: true, - style: 'any style', - texture: true, - textureQuality: 'detailed', - expected: creditsLabel(0.5) - }, - { - quad: false, - style: 'any style', - texture: true, - textureQuality: 'standard', - expected: creditsLabel(0.35) - } - ] - - testCases.forEach( - ({ quad, style, texture, textureQuality, expected }) => { - const widgets = [ - { name: 'model_version', value: 'v3.0' }, - { name: 'quad', value: quad }, - { name: 'style', value: style }, - { name: 'texture', value: texture } - ] - if (textureQuality) { - widgets.push({ name: 'texture_quality', value: textureQuality }) + name: 'TestInputGroupNode', + api_node: true, + price_badge: { + engine: 'jsonata', + expr: '{"type":"usd","usd": inputGroups.videos * 0.05}', + depends_on: { + widgets: [], + inputs: [], + input_groups: ['videos'] + } } - const node = createMockNode('TripoImageToModelNode', widgets) - expect(getNodeDisplayPrice(node)).toBe(expected) } - ) + } + } + + getNodeDisplayPrice(node as unknown as LGraphNode) + await new Promise((r) => setTimeout(r, 50)) + const price = getNodeDisplayPrice(node as unknown as LGraphNode) + // 2 connected inputs in 'videos' group * 0.05 = 0.10 + expect(price).toBe(creditsLabel(0.1)) + }) + }) + + describe('decimal formatting', () => { + describe('shouldShowDecimal helper', () => { + it('should return true when first decimal digit is non-zero', () => { + expect(shouldShowDecimal(10.5)).toBe(true) + expect(shouldShowDecimal(10.1)).toBe(true) + expect(shouldShowDecimal(10.9)).toBe(true) + expect(shouldShowDecimal(1.5)).toBe(true) }) - it('should return correct fallback for TripoImageToModelNode', () => { - const { getNodeDisplayPrice } = useNodePricing() - const node = createMockNode('TripoImageToModelNode', []) + it('should return false for whole numbers', () => { + expect(shouldShowDecimal(10)).toBe(false) + expect(shouldShowDecimal(10.0)).toBe(false) + expect(shouldShowDecimal(1)).toBe(false) + expect(shouldShowDecimal(100)).toBe(false) + }) + it('should return false when decimal rounds to zero', () => { + // 10.04 rounds to 10.0, so no decimal shown + expect(shouldShowDecimal(10.04)).toBe(false) + expect(shouldShowDecimal(10.049)).toBe(false) + }) + + it('should return true when decimal rounds to non-zero', () => { + // 10.05 rounds to 10.1, so decimal shown + expect(shouldShowDecimal(10.05)).toBe(true) + expect(shouldShowDecimal(10.06)).toBe(true) + // 10.45 rounds to 10.5 + expect(shouldShowDecimal(10.45)).toBe(true) + }) + }) + + describe('credit value formatting', () => { + it('should show decimal for USD values that result in fractional credits', () => { + // $0.05 * 211 = 10.55 credits → "10.6" + const value1 = creditValue(0.05) + expect(value1).toBe('10.6') + + // $0.10 * 211 = 21.1 credits → "21.1" + const value2 = creditValue(0.1) + expect(value2).toBe('21.1') + }) + + it('should not show decimal for USD values that result in whole credits', () => { + // $1.00 * 211 = 211 credits → "211" + const value = creditValue(1.0) + expect(value).toBe('211') + }) + + it('should not show decimal when credits round to whole number', () => { + // Find a USD value that results in credits close to a whole number + // $0.0473933... * 211 ≈ 10.0 credits + // Let's use a value that gives us ~10.02 credits which rounds to 10.0 + const usd = 10.02 / CREDITS_PER_USD // ~0.0475 USD → ~10.02 credits + const value = creditValue(usd) + expect(value).toBe('10') + }) + }) + + describe('integration with pricing display', () => { + it('should display decimal in badge for fractional credits', async () => { + const { getNodeDisplayPrice } = useNodePricing() + // $0.05 * 211 = 10.55 credits → "10.6 credits/Run" + const node = createMockNodeWithPriceBadge( + 'TestDecimalNode', + priceBadge('{"type":"usd","usd":0.05}') + ) + + getNodeDisplayPrice(node) + await new Promise((r) => setTimeout(r, 50)) const price = getNodeDisplayPrice(node) - expect(price).toBe( - creditsRangeLabel(0.1, 0.65, { - note: 'varies with quad, style, texture & quality' - }) - ) + expect(price).toBe('10.6 credits/Run') }) - it('should handle missing texture quality widget', () => { + it('should not display decimal in badge for whole credits', async () => { const { getNodeDisplayPrice } = useNodePricing() - const node = createMockNode('TripoTextToModelNode', []) + // $1.00 * 211 = 211 credits → "211 credits/Run" + const node = createMockNodeWithPriceBadge( + 'TestWholeCreditsNode', + priceBadge('{"type":"usd","usd":1.00}') + ) + getNodeDisplayPrice(node) + await new Promise((r) => setTimeout(r, 50)) const price = getNodeDisplayPrice(node) - expect(price).toBe( - creditsRangeLabel(0.1, 0.65, { - note: 'varies with quad, style, texture & quality' - }) - ) + expect(price).toBe('211 credits/Run') }) - it('should handle missing model version widget', () => { + it('should handle range with mixed decimal display', async () => { const { getNodeDisplayPrice } = useNodePricing() - const node = createMockNode('TripoTextToModelNode', [ - { name: 'texture_quality', value: 'detailed' } - ]) + // min: $0.05 * 211 = 10.55 → 10.6 + // max: $1.00 * 211 = 211 → 211 + const node = createMockNodeWithPriceBadge( + 'TestMixedRangeNode', + priceBadge('{"type":"range_usd","min_usd":0.05,"max_usd":1.00}') + ) + getNodeDisplayPrice(node) + await new Promise((r) => setTimeout(r, 50)) const price = getNodeDisplayPrice(node) - expect(price).toBe( - creditsRangeLabel(0.1, 0.65, { - note: 'varies with quad, style, texture & quality' - }) - ) + expect(price).toBe('10.6-211 credits/Run') }) - - it('should return correct pricing for exposed ByteDance models', () => { - const { getNodeDisplayPrice } = useNodePricing() - - const testCases = [ - { - node_name: 'ByteDanceImageNode', - model: 'seedream-3-0-t2i-250415', - expected: creditsLabel(0.03) - }, - { - node_name: 'ByteDanceImageEditNode', - model: 'seededit-3-0-i2i-250628', - expected: creditsLabel(0.03) - } - ] - - testCases.forEach(({ node_name, model, expected }) => { - const node = createMockNode(node_name, [ - { name: 'model', value: model } - ]) - expect(getNodeDisplayPrice(node)).toBe(expected) - }) - }) - }) - }) - - describe('dynamic pricing - ByteDanceSeedreamNode', () => { - it('should return $0.03 x images/Run', () => { - const { getNodeDisplayPrice } = useNodePricing() - const node = createMockNode('ByteDanceSeedreamNode', [ - { name: 'model', value: 'seedream-4-0-250828' } - ]) - - const price = getNodeDisplayPrice(node) - expect(price).toBe( - creditsLabel(0.03, { - suffix: ' x images/Run', - approximate: true - }) - ) - }) - }) - - describe('dynamic pricing - ByteDance Seedance video nodes', () => { - it('should return base 10s range for PRO 1080p on ByteDanceTextToVideoNode', () => { - const { getNodeDisplayPrice } = useNodePricing() - const node = createMockNode('ByteDanceTextToVideoNode', [ - { name: 'model', value: 'seedance-1-0-pro' }, - { name: 'duration', value: '10' }, - { name: 'resolution', value: '1080p' } - ]) - - const price = getNodeDisplayPrice(node) - expect(price).toBe(creditsRangeLabel(1.18, 1.22)) - }) - - it('should scale to half for 5s PRO 1080p on ByteDanceTextToVideoNode', () => { - const { getNodeDisplayPrice } = useNodePricing() - const node = createMockNode('ByteDanceTextToVideoNode', [ - { name: 'model', value: 'seedance-1-0-pro' }, - { name: 'duration', value: '5' }, - { name: 'resolution', value: '1080p' } - ]) - - const price = getNodeDisplayPrice(node) - expect(price).toBe(creditsRangeLabel(0.59, 0.61)) - }) - - it('should scale for 8s PRO 480p on ByteDanceImageToVideoNode', () => { - const { getNodeDisplayPrice } = useNodePricing() - const node = createMockNode('ByteDanceImageToVideoNode', [ - { name: 'model', value: 'seedance-1-0-pro' }, - { name: 'duration', value: '8' }, - { name: 'resolution', value: '480p' } - ]) - - const price = getNodeDisplayPrice(node) - expect(price).toBe(creditsRangeLabel(0.23 * 0.8, 0.24 * 0.8)) - }) - - it('should scale correctly for 12s PRO 720p on ByteDanceFirstLastFrameNode', () => { - const { getNodeDisplayPrice } = useNodePricing() - const node = createMockNode('ByteDanceFirstLastFrameNode', [ - { name: 'model', value: 'seedance-1-0-pro' }, - { name: 'duration', value: '10' }, - { name: 'resolution', value: '720p' } - ]) - - const price = getNodeDisplayPrice(node) - expect(price).toBe(creditsRangeLabel(0.51, 0.56)) - }) - - it('should collapse to a single value when min and max round equal for LITE 480p 3s on ByteDanceImageReferenceNode', () => { - const { getNodeDisplayPrice } = useNodePricing() - const node = createMockNode('ByteDanceImageReferenceNode', [ - { name: 'model', value: 'seedance-1-0-lite' }, - { name: 'duration', value: '3' }, - { name: 'resolution', value: '480p' } - ]) - - const price = getNodeDisplayPrice(node) - expect(price).toBe(creditsLabel(0.05)) // 0.17..0.18 scaled by 0.3 both round to 0.05 - }) - - it('should return Token-based when required widgets are missing', () => { - const { getNodeDisplayPrice } = useNodePricing() - const missingModel = createMockNode('ByteDanceFirstLastFrameNode', [ - { name: 'duration', value: '10' }, - { name: 'resolution', value: '1080p' } - ]) - const missingResolution = createMockNode('ByteDanceImageToVideoNode', [ - { name: 'model', value: 'seedance-1-0-pro' }, - { name: 'duration', value: '10' } - ]) - const missingDuration = createMockNode('ByteDanceTextToVideoNode', [ - { name: 'model', value: 'seedance-1-0-lite' }, - { name: 'resolution', value: '720p' } - ]) - - expect(getNodeDisplayPrice(missingModel)).toBe('Token-based') - expect(getNodeDisplayPrice(missingResolution)).toBe('Token-based') - expect(getNodeDisplayPrice(missingDuration)).toBe('Token-based') - }) - }) - - describe('dynamic pricing - WanTextToVideoApi', () => { - it('should return $1.50 for 10s at 1080p', () => { - const { getNodeDisplayPrice } = useNodePricing() - const node = createMockNode('WanTextToVideoApi', [ - { name: 'duration', value: '10' }, - { name: 'size', value: '1080p: 4:3 (1632x1248)' } - ]) - - const price = getNodeDisplayPrice(node) - expect(price).toBe(creditsLabel(1.5)) // 0.15 * 10 - }) - - it('should return $0.50 for 5s at 720p', () => { - const { getNodeDisplayPrice } = useNodePricing() - const node = createMockNode('WanTextToVideoApi', [ - { name: 'duration', value: 5 }, - { name: 'size', value: '720p: 16:9 (1280x720)' } - ]) - - const price = getNodeDisplayPrice(node) - expect(price).toBe(creditsLabel(0.5)) // 0.10 * 5 - }) - - it('should return $0.15 for 3s at 480p', () => { - const { getNodeDisplayPrice } = useNodePricing() - const node = createMockNode('WanTextToVideoApi', [ - { name: 'duration', value: '3' }, - { name: 'size', value: '480p: 1:1 (624x624)' } - ]) - - const price = getNodeDisplayPrice(node) - expect(price).toBe(creditsLabel(0.15)) // 0.05 * 3 - }) - - it('should fall back when widgets are missing', () => { - const { getNodeDisplayPrice } = useNodePricing() - const missingBoth = createMockNode('WanTextToVideoApi', []) - const missingSize = createMockNode('WanTextToVideoApi', [ - { name: 'duration', value: '5' } - ]) - const missingDuration = createMockNode('WanTextToVideoApi', [ - { name: 'size', value: '1080p' } - ]) - - expect(getNodeDisplayPrice(missingBoth)).toBe( - creditsRangeLabel(0.05, 0.15, { suffix: '/second' }) - ) - expect(getNodeDisplayPrice(missingSize)).toBe( - creditsRangeLabel(0.05, 0.15, { suffix: '/second' }) - ) - expect(getNodeDisplayPrice(missingDuration)).toBe( - creditsRangeLabel(0.05, 0.15, { suffix: '/second' }) - ) - }) - - it('should fall back on invalid duration', () => { - const { getNodeDisplayPrice } = useNodePricing() - const node = createMockNode('WanTextToVideoApi', [ - { name: 'duration', value: 'invalid' }, - { name: 'size', value: '1080p' } - ]) - - const price = getNodeDisplayPrice(node) - expect(price).toBe(creditsRangeLabel(0.05, 0.15, { suffix: '/second' })) - }) - - it('should fall back on unknown resolution', () => { - const { getNodeDisplayPrice } = useNodePricing() - const node = createMockNode('WanTextToVideoApi', [ - { name: 'duration', value: '10' }, - { name: 'size', value: '2K' } - ]) - - const price = getNodeDisplayPrice(node) - expect(price).toBe(creditsRangeLabel(0.05, 0.15, { suffix: '/second' })) - }) - }) - - describe('dynamic pricing - WanImageToVideoApi', () => { - it('should return $0.80 for 8s at 720p', () => { - const { getNodeDisplayPrice } = useNodePricing() - const node = createMockNode('WanImageToVideoApi', [ - { name: 'duration', value: 8 }, - { name: 'resolution', value: '720p' } - ]) - - const price = getNodeDisplayPrice(node) - expect(price).toBe(creditsLabel(0.8)) // 0.10 * 8 - }) - - it('should return $0.60 for 12s at 480P', () => { - const { getNodeDisplayPrice } = useNodePricing() - const node = createMockNode('WanImageToVideoApi', [ - { name: 'duration', value: '12' }, - { name: 'resolution', value: '480P' } - ]) - - const price = getNodeDisplayPrice(node) - expect(price).toBe(creditsLabel(0.6)) // 0.05 * 12 - }) - - it('should return $1.50 for 10s at 1080p', () => { - const { getNodeDisplayPrice } = useNodePricing() - const node = createMockNode('WanImageToVideoApi', [ - { name: 'duration', value: '10' }, - { name: 'resolution', value: '1080p' } - ]) - - const price = getNodeDisplayPrice(node) - expect(price).toBe(creditsLabel(1.5)) // 0.15 * 10 - }) - - it('should handle "5s" string duration at 1080P', () => { - const { getNodeDisplayPrice } = useNodePricing() - const node = createMockNode('WanImageToVideoApi', [ - { name: 'duration', value: '5s' }, - { name: 'resolution', value: '1080P' } - ]) - - const price = getNodeDisplayPrice(node) - expect(price).toBe(creditsLabel(0.75)) // 0.15 * 5 - }) - - it('should fall back when widgets are missing', () => { - const { getNodeDisplayPrice } = useNodePricing() - const missingBoth = createMockNode('WanImageToVideoApi', []) - const missingRes = createMockNode('WanImageToVideoApi', [ - { name: 'duration', value: '5' } - ]) - const missingDuration = createMockNode('WanImageToVideoApi', [ - { name: 'resolution', value: '1080p' } - ]) - - expect(getNodeDisplayPrice(missingBoth)).toBe( - creditsRangeLabel(0.05, 0.15, { suffix: '/second' }) - ) - expect(getNodeDisplayPrice(missingRes)).toBe( - creditsRangeLabel(0.05, 0.15, { suffix: '/second' }) - ) - expect(getNodeDisplayPrice(missingDuration)).toBe( - creditsRangeLabel(0.05, 0.15, { suffix: '/second' }) - ) - }) - - it('should fall back on invalid duration', () => { - const { getNodeDisplayPrice } = useNodePricing() - const node = createMockNode('WanImageToVideoApi', [ - { name: 'duration', value: 'invalid' }, - { name: 'resolution', value: '720p' } - ]) - - const price = getNodeDisplayPrice(node) - expect(price).toBe(creditsRangeLabel(0.05, 0.15, { suffix: '/second' })) - }) - - it('should fall back on unknown resolution', () => { - const { getNodeDisplayPrice } = useNodePricing() - const node = createMockNode('WanImageToVideoApi', [ - { name: 'duration', value: '10' }, - { name: 'resolution', value: 'weird-res' } - ]) - - const price = getNodeDisplayPrice(node) - expect(price).toBe(creditsRangeLabel(0.05, 0.15, { suffix: '/second' })) - }) - }) - - describe('dynamic pricing - LtxvApiTextToVideo', () => { - it('should return $0.30 for Pro 1080p 5s', () => { - const { getNodeDisplayPrice } = useNodePricing() - const node = createMockNode('LtxvApiTextToVideo', [ - { name: 'model', value: 'LTX-2 (Pro)' }, - { name: 'duration', value: '5' }, - { name: 'resolution', value: '1920x1080' } - ]) - - const price = getNodeDisplayPrice(node) - expect(price).toBe(creditsLabel(0.3)) // 0.06 * 5 - }) - - it('should parse "10s" duration strings', () => { - const { getNodeDisplayPrice } = useNodePricing() - const node = createMockNode('LtxvApiTextToVideo', [ - { name: 'model', value: 'LTX-2 (Fast)' }, - { name: 'duration', value: '10' }, - { name: 'resolution', value: '3840x2160' } - ]) - - const price = getNodeDisplayPrice(node) - expect(price).toBe(creditsLabel(1.6)) // 0.16 * 10 - }) - - it('should fall back when a required widget is missing (no resolution)', () => { - const { getNodeDisplayPrice } = useNodePricing() - const node = createMockNode('LtxvApiTextToVideo', [ - { name: 'model', value: 'LTX-2 (Pro)' }, - { name: 'duration', value: '5' } - // missing resolution - ]) - - const price = getNodeDisplayPrice(node) - expect(price).toBe(creditsRangeLabel(0.04, 0.24, { suffix: '/second' })) - }) - - it('should fall back for unknown model', () => { - const { getNodeDisplayPrice } = useNodePricing() - const node = createMockNode('LtxvApiTextToVideo', [ - { name: 'model', value: 'LTX-3 (Pro)' }, - { name: 'duration', value: 5 }, - { name: 'resolution', value: '1920x1080' } - ]) - - const price = getNodeDisplayPrice(node) - expect(price).toBe(creditsRangeLabel(0.04, 0.24, { suffix: '/second' })) }) }) }) -const CREDIT_NUMBER_OPTIONS: Intl.NumberFormatOptions = { - minimumFractionDigits: 0, - maximumFractionDigits: 0 -} - -type CreditFormatOptions = { - suffix?: string - note?: string - approximate?: boolean -} - -const creditValue = (usd: number): string => - formatCreditsFromUsd({ - usd, - numberOptions: CREDIT_NUMBER_OPTIONS - }) - -const prefix = (approximate?: boolean) => (approximate ? '~' : '') -const suffix = (value?: string) => value ?? '/Run' -const note = (value?: string) => { - if (!value) return '' - const trimmed = value.trim() - const hasParens = trimmed.startsWith('(') && trimmed.endsWith(')') - const content = hasParens ? trimmed : `(${trimmed})` - return ` ${content}` -} - -const creditsLabel = ( - usd: number, - { - suffix: suffixOverride, - note: noteOverride, - approximate - }: CreditFormatOptions = {} -): string => - `${prefix(approximate)}${creditValue(usd)} credits${suffix(suffixOverride)}${note(noteOverride)}` - -const creditsRangeLabel = ( - minUsd: number, - maxUsd: number, - options?: CreditFormatOptions -): string => { - const min = creditValue(minUsd) - const max = creditValue(maxUsd) - const value = min === max ? min : `${min}-${max}` - return `${prefix(options?.approximate)}${value} credits${suffix(options?.suffix)}${note(options?.note)}` -} - -const creditsListLabel = ( - usdValues: number[], - options?: CreditFormatOptions & { separator?: string } -): string => { - const parts = usdValues.map((value) => creditValue(value)) - const value = parts.join(options?.separator ?? '/') - return `${prefix(options?.approximate)}${value} credits${suffix(options?.suffix)}${note(options?.note)}` -} diff --git a/src/composables/node/useNodePricing.ts b/src/composables/node/useNodePricing.ts index 95c432747..5d08e5794 100644 --- a/src/composables/node/useNodePricing.ts +++ b/src/composables/node/useNodePricing.ts @@ -1,23 +1,45 @@ -import { formatCreditsFromUsd } from '@/base/credits/comfyCredits' -import type { INodeInputSlot, LGraphNode } from '@/lib/litegraph/src/litegraph' -import type { IComboWidget } from '@/lib/litegraph/src/types/widgets' +// JSONata-based pricing badge evaluation for API nodes. +// +// Pricing declarations are read from ComfyUI node definitions (price_badge field). +// The Frontend evaluates these declarations locally using a JSONata engine. +// +// JSONata v2.x NOTE: +// - jsonata(expression).evaluate(input) returns a Promise in JSONata 2.x. +// - Therefore, pricing evaluation is async. This file implements: +// - sync getter (returns cached label / last-known label), +// - async evaluation + cache, +// - reactive tick to update UI when async evaluation completes. + +import { readonly, ref } from 'vue' +import type { Ref } from 'vue' +import { CREDITS_PER_USD, formatCredits } from '@/base/credits/comfyCredits' +import { LiteGraph } from '@/lib/litegraph/src/litegraph' +import type { LGraphNode } from '@/lib/litegraph/src/litegraph' +import type { INodeInputSlot } from '@/lib/litegraph/src/interfaces' +import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets' +import type { + ComfyNodeDef, + PriceBadge, + WidgetDependency +} from '@/schemas/nodeDefSchema' +import { useNodeDefStore } from '@/stores/nodeDefStore' +import type { Expression } from 'jsonata' +import jsonata from 'jsonata' /** - * Meshy credit pricing constant. - * 1 Meshy credit = $0.04 USD - * Change this value to update all Meshy node prices. + * Determine if a number should display 1 decimal place. + * Shows decimal only when the first decimal digit is non-zero. */ -const MESHY_CREDIT_PRICE_USD = 0.04 - -/** Convert Meshy credits to USD */ -const meshyCreditsToUsd = (credits: number): number => - credits * MESHY_CREDIT_PRICE_USD - -const DEFAULT_NUMBER_OPTIONS: Intl.NumberFormatOptions = { - minimumFractionDigits: 0, - maximumFractionDigits: 0 +const shouldShowDecimal = (value: number): boolean => { + const rounded = Math.round(value * 10) / 10 + return rounded % 1 !== 0 } +const getNumberOptions = (credits: number): Intl.NumberFormatOptions => ({ + minimumFractionDigits: 0, + maximumFractionDigits: shouldShowDecimal(credits) ? 1 : 0 +}) + type CreditFormatOptions = { suffix?: string note?: string @@ -25,11 +47,14 @@ type CreditFormatOptions = { separator?: string } -const formatCreditsValue = (usd: number): string => - formatCreditsFromUsd({ - usd, - numberOptions: DEFAULT_NUMBER_OPTIONS +const formatCreditsValue = (usd: number): string => { + // Use raw credits value (before rounding) to determine decimal display + const rawCredits = usd * CREDITS_PER_USD + return formatCredits({ + value: rawCredits, + numberOptions: getNumberOptions(rawCredits) }) +} const makePrefix = (approximate?: boolean) => (approximate ? '~' : '') @@ -63,2616 +88,567 @@ const formatCreditsListLabel = ( return `${makePrefix(approximate)}${value} credits${makeSuffix(suffix)}${appendNote(note)}` } -/** - * Function that calculates dynamic pricing based on node widget values - */ -type PricingFunction = (node: LGraphNode) => string - -/** - * Safely executes a pricing function with error handling - * Returns null if the function throws an error, allowing the node to still render - */ -function safePricingExecution( - fn: PricingFunction, - node: LGraphNode, - fallback: string = '' -): string { - try { - return fn(node) - } catch (error) { - // Log error in development but don't throw to avoid breaking node rendering - if (process.env.NODE_ENV === 'development') { - console.warn( - 'Pricing calculation failed for node:', - node.constructor?.nodeData?.name, - error - ) +// ----------------------------- +// JSONata pricing types +// ----------------------------- +type PricingResult = + | { type: 'text'; text: string } + | { type: 'usd'; usd: number; format?: CreditFormatOptions } + | { + type: 'range_usd' + min_usd: number + max_usd: number + format?: CreditFormatOptions } - return fallback - } -} + | { type: 'list_usd'; usd: number[]; format?: CreditFormatOptions } -/** - * Helper function to calculate Runway duration-based pricing - * @param node - The LiteGraph node - * @returns Formatted price string - */ -const calculateRunwayDurationPrice = (node: LGraphNode): string => { - const durationWidget = node.widgets?.find( - (w) => w.name === 'duration' - ) as IComboWidget +const PRICING_RESULT_TYPES = ['text', 'usd', 'range_usd', 'list_usd'] as const - if (!durationWidget) return formatCreditsLabel(0.0715, { suffix: '/second' }) - - const duration = Number(durationWidget.value) - const validDuration = isNaN(duration) ? 5 : duration - const cost = 0.0715 * validDuration - return formatCreditsLabel(cost) -} - -const makeOmniProDurationCalculator = - (pricePerSecond: number): PricingFunction => - (node: LGraphNode): string => { - const durationWidget = node.widgets?.find( - (w) => w.name === 'duration' - ) as IComboWidget - if (!durationWidget) - return formatCreditsLabel(pricePerSecond, { suffix: '/second' }) - - const seconds = parseFloat(String(durationWidget.value)) - if (!Number.isFinite(seconds)) - return formatCreditsLabel(pricePerSecond, { suffix: '/second' }) - - const cost = pricePerSecond * seconds - return formatCreditsLabel(cost) - } - -const klingMotionControlPricingCalculator: PricingFunction = ( - node: LGraphNode -): string => { - const modeWidget = node.widgets?.find( - (w) => w.name === 'mode' - ) as IComboWidget - - if (!modeWidget) { - return formatCreditsListLabel([0.07, 0.112], { - suffix: '/second', - note: '(std/pro)' - }) - } - - const mode = String(modeWidget.value).toLowerCase() - - if (mode === 'pro') return formatCreditsLabel(0.112, { suffix: '/second' }) - if (mode === 'std') return formatCreditsLabel(0.07, { suffix: '/second' }) - - return formatCreditsListLabel([0.07, 0.112], { - suffix: '/second', - note: '(std/pro)' - }) -} - -const pixversePricingCalculator = (node: LGraphNode): string => { - const durationWidget = node.widgets?.find( - (w) => w.name === 'duration_seconds' - ) as IComboWidget - const qualityWidget = node.widgets?.find( - (w) => w.name === 'quality' - ) as IComboWidget - const motionModeWidget = node.widgets?.find( - (w) => w.name === 'motion_mode' - ) as IComboWidget - - if (!durationWidget || !qualityWidget) { - return formatCreditsRangeLabel(0.45, 1.2, { - note: '(varies with duration, quality & motion mode)' - }) - } - - const duration = String(durationWidget.value) - const quality = String(qualityWidget.value) - const motionMode = String(motionModeWidget?.value) - - // Basic pricing based on duration and quality - if (duration.includes('5')) { - if (quality.includes('1080p')) return formatCreditsLabel(1.2) - if (quality.includes('720p') && motionMode?.includes('fast')) - return formatCreditsLabel(1.2) - if (quality.includes('720p') && motionMode?.includes('normal')) - return formatCreditsLabel(0.6) - if (quality.includes('540p') && motionMode?.includes('fast')) - return formatCreditsLabel(0.9) - if (quality.includes('540p') && motionMode?.includes('normal')) - return formatCreditsLabel(0.45) - if (quality.includes('360p') && motionMode?.includes('fast')) - return formatCreditsLabel(0.9) - if (quality.includes('360p') && motionMode?.includes('normal')) - return formatCreditsLabel(0.45) - } else if (duration.includes('8')) { - if (quality.includes('540p') && motionMode?.includes('normal')) - return formatCreditsLabel(0.9) - if (quality.includes('540p') && motionMode?.includes('fast')) - return formatCreditsLabel(1.2) - if (quality.includes('360p') && motionMode?.includes('normal')) - return formatCreditsLabel(0.9) - if (quality.includes('360p') && motionMode?.includes('fast')) - return formatCreditsLabel(1.2) - if (quality.includes('1080p') && motionMode?.includes('normal')) - return formatCreditsLabel(1.2) - if (quality.includes('1080p') && motionMode?.includes('fast')) - return formatCreditsLabel(1.2) - if (quality.includes('720p') && motionMode?.includes('normal')) - return formatCreditsLabel(1.2) - if (quality.includes('720p') && motionMode?.includes('fast')) - return formatCreditsLabel(1.2) - } - - return formatCreditsLabel(0.9) -} - -const byteDanceVideoPricingCalculator = (node: LGraphNode): string => { - const modelWidget = node.widgets?.find( - (w) => w.name === 'model' - ) as IComboWidget - const durationWidget = node.widgets?.find( - (w) => w.name === 'duration' - ) as IComboWidget - const resolutionWidget = node.widgets?.find( - (w) => w.name === 'resolution' - ) as IComboWidget - const generateAudioWidget = node.widgets?.find( - (w) => w.name === 'generate_audio' - ) as IComboWidget | undefined - - if (!modelWidget || !durationWidget || !resolutionWidget) return 'Token-based' - - const model = String(modelWidget.value).toLowerCase() - const resolution = String(resolutionWidget.value).toLowerCase() - const seconds = parseFloat(String(durationWidget.value)) - const generateAudio = - generateAudioWidget && - String(generateAudioWidget.value).toLowerCase() === 'true' - const priceByModel: Record> = { - 'seedance-1-5-pro': { - '480p': [0.12, 0.12], - '720p': [0.26, 0.26], - '1080p': [0.58, 0.59] - }, - 'seedance-1-0-pro': { - '480p': [0.23, 0.24], - '720p': [0.51, 0.56], - '1080p': [1.18, 1.22] - }, - 'seedance-1-0-pro-fast': { - '480p': [0.09, 0.1], - '720p': [0.21, 0.23], - '1080p': [0.47, 0.49] - }, - 'seedance-1-0-lite': { - '480p': [0.17, 0.18], - '720p': [0.37, 0.41], - '1080p': [0.85, 0.88] - } - } - - const modelKey = model.includes('seedance-1-5-pro') - ? 'seedance-1-5-pro' - : model.includes('seedance-1-0-pro-fast') - ? 'seedance-1-0-pro-fast' - : model.includes('seedance-1-0-pro') - ? 'seedance-1-0-pro' - : model.includes('seedance-1-0-lite') - ? 'seedance-1-0-lite' - : '' - - const resKey = resolution.includes('1080') - ? '1080p' - : resolution.includes('720') - ? '720p' - : resolution.includes('480') - ? '480p' - : '' - - const baseRange = - modelKey && resKey ? priceByModel[modelKey]?.[resKey] : undefined - if (!baseRange) return 'Token-based' - - const [min10s, max10s] = baseRange - const scale = seconds / 10 - const audioMultiplier = - modelKey === 'seedance-1-5-pro' && generateAudio ? 2 : 1 - const minCost = min10s * scale * audioMultiplier - const maxCost = max10s * scale * audioMultiplier - - if (minCost === maxCost) return formatCreditsLabel(minCost) - return formatCreditsRangeLabel(minCost, maxCost) -} - -const ltxvPricingCalculator = (node: LGraphNode): string => { - const modelWidget = node.widgets?.find( - (w) => w.name === 'model' - ) as IComboWidget - const durationWidget = node.widgets?.find( - (w) => w.name === 'duration' - ) as IComboWidget - const resolutionWidget = node.widgets?.find( - (w) => w.name === 'resolution' - ) as IComboWidget - - const fallback = formatCreditsRangeLabel(0.04, 0.24, { - suffix: '/second' - }) - if (!modelWidget || !durationWidget || !resolutionWidget) return fallback - - const model = String(modelWidget.value).toLowerCase() - const resolution = String(resolutionWidget.value).toLowerCase() - const seconds = parseFloat(String(durationWidget.value)) - const priceByModel: Record> = { - 'ltx-2 (pro)': { - '1920x1080': 0.06, - '2560x1440': 0.12, - '3840x2160': 0.24 - }, - 'ltx-2 (fast)': { - '1920x1080': 0.04, - '2560x1440': 0.08, - '3840x2160': 0.16 - } - } - - const modelTable = priceByModel[model] - if (!modelTable) return fallback - - const pps = modelTable[resolution] - if (!pps) return fallback - - const cost = pps * seconds - return formatCreditsLabel(cost) -} - -const klingVideoWithAudioPricingCalculator: PricingFunction = ( - node: LGraphNode -): string => { - const durationWidget = node.widgets?.find( - (w) => w.name === 'duration' - ) as IComboWidget - const generateAudioWidget = node.widgets?.find( - (w) => w.name === 'generate_audio' - ) as IComboWidget - - if (!durationWidget || !generateAudioWidget) { - return formatCreditsRangeLabel(0.35, 1.4, { - note: '(varies with duration & audio)' - }) - } - - const duration = String(durationWidget.value) - const generateAudio = - String(generateAudioWidget.value).toLowerCase() === 'true' - - if (duration === '5') { - return generateAudio ? formatCreditsLabel(0.7) : formatCreditsLabel(0.35) - } - - if (duration === '10') { - return generateAudio ? formatCreditsLabel(1.4) : formatCreditsLabel(0.7) - } - - // Fallback for unexpected duration values - return formatCreditsRangeLabel(0.35, 1.4, { - note: '(varies with duration & audio)' - }) -} - -// ---- constants ---- -const SORA_SIZES = { - BASIC: new Set(['720x1280', '1280x720']), - PRO: new Set(['1024x1792', '1792x1024']) -} -const ALL_SIZES = new Set([...SORA_SIZES.BASIC, ...SORA_SIZES.PRO]) - -// ---- sora-2 pricing helpers ---- -function validateSora2Selection( - modelRaw: string, - duration: number, - sizeRaw: string -): string | undefined { - const model = modelRaw?.toLowerCase() ?? '' - const size = sizeRaw?.toLowerCase() ?? '' - - if (!duration || Number.isNaN(duration)) return 'Set duration (4s / 8s / 12s)' - if (!size) return 'Set size (720x1280, 1280x720, 1024x1792, 1792x1024)' - if (!ALL_SIZES.has(size)) - return 'Invalid size. Must be 720x1280, 1280x720, 1024x1792, or 1792x1024.' - - if (model.includes('sora-2-pro')) return undefined - - if (model.includes('sora-2') && !SORA_SIZES.BASIC.has(size)) - return 'sora-2 supports only 720x1280 or 1280x720' - - if (!model.includes('sora-2')) return 'Unsupported model' - - return undefined -} - -function perSecForSora2(modelRaw: string, sizeRaw: string): number { - const model = modelRaw?.toLowerCase() ?? '' - const size = sizeRaw?.toLowerCase() ?? '' - - if (model.includes('sora-2-pro')) { - return SORA_SIZES.PRO.has(size) ? 0.5 : 0.3 - } - if (model.includes('sora-2')) return 0.1 - - return SORA_SIZES.PRO.has(size) ? 0.5 : 0.1 -} - -function formatRunPrice(perSec: number, duration: number) { - return formatCreditsLabel(Number((perSec * duration).toFixed(2))) -} - -// ---- pricing calculator ---- -const sora2PricingCalculator: PricingFunction = (node: LGraphNode): string => { - const getWidgetValue = (name: string) => - String(node.widgets?.find((w) => w.name === name)?.value ?? '') - - const model = getWidgetValue('model') - const size = getWidgetValue('size') - const duration = Number( - node.widgets?.find((w) => ['duration', 'duration_s'].includes(w.name)) - ?.value +/** Type guard to validate that a value is a PricingResult. */ +const isPricingResult = (value: unknown): value is PricingResult => + typeof value === 'object' && + value !== null && + 'type' in value && + typeof (value as { type: unknown }).type === 'string' && + PRICING_RESULT_TYPES.includes( + (value as { type: string }).type as (typeof PRICING_RESULT_TYPES)[number] ) - if (!model || !size || !duration) return 'Set model, duration & size' +/** + * Widget values are normalized based on their declared type: + * - INT/FLOAT → number (or null if not parseable) + * - BOOLEAN → boolean (or null if not parseable) + * - STRING/COMBO/other → string (lowercased, trimmed) + */ +type NormalizedWidgetValue = string | number | boolean | null - const validationError = validateSora2Selection(model, duration, size) - if (validationError) return validationError +type JsonataPricingRule = { + engine: 'jsonata' + depends_on: { + widgets: WidgetDependency[] + inputs: string[] + input_groups: string[] + } + expr: string + result_defaults?: CreditFormatOptions +} - const perSec = perSecForSora2(model, size) - return formatRunPrice(perSec, duration) +type CompiledJsonataPricingRule = JsonataPricingRule & { + _compiled: Expression | null } /** - * Pricing for Tripo 3D generation nodes (Text / Image / Multiview) - * based on Tripo credits: - * - * Turbo / V3 / V2.5 / V2.0: - * Text -> 10 (no texture) / 20 (standard texture) - * Image -> 20 (no texture) / 30 (standard texture) - * Multiview -> 20 (no texture) / 30 (standard texture) - * - * V1.4: - * Text -> 20 - * Image -> 30 - * (Multiview treated same as Image if used) - * - * Advanced extras (added on top of generation credits): - * quad -> +5 credits - * style -> +5 credits (if style != "None") - * HD texture -> +10 credits (texture_quality = "detailed") - * detailed geometry -> +20 credits (geometry_quality = "detailed") - * - * 1 credit = $0.01 + * Shape of nodeData attached to LGraphNode constructor for API nodes. + * Uses Pick from schema type to ensure consistency. */ -const calculateTripo3DGenerationPrice = ( +type NodeConstructorData = Partial< + Pick +> + +/** + * Extract nodeData from an LGraphNode's constructor. + * Centralizes the `as any` cast needed to access this runtime property. + */ +const getNodeConstructorData = ( + node: LGraphNode +): NodeConstructorData | undefined => + (node.constructor as { nodeData?: NodeConstructorData }).nodeData + +type JsonataEvalContext = { + widgets: Record + inputs: Record + /** Count of connected inputs per autogrow group */ + inputGroups: Record +} + +// ----------------------------- +// Normalization helpers +// ----------------------------- +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 + } + + // Do not coerce booleans/objects into numbers for pricing purposes. + return null +} + +/** + * Normalize widget value based on its declared type. + * Returns the value in its natural type for simpler JSONata expressions. + */ +const normalizeWidgetValue = ( + raw: unknown, + declaredType: string +): NormalizedWidgetValue => { + if (raw === undefined || raw === null) { + return null + } + + const upperType = declaredType.toUpperCase() + + // Numeric types + if (upperType === 'INT' || upperType === 'FLOAT') { + return asFiniteNumber(raw) + } + + // Boolean type + if (upperType === 'BOOLEAN') { + if (typeof raw === 'boolean') return raw + if (typeof raw === 'string') { + const ls = raw.trim().toLowerCase() + if (ls === 'true') return true + if (ls === 'false') return false + } + return null + } + + // COMBO type - preserve string/numeric values (for options like [5, "10"]) + if (upperType === 'COMBO') { + if (typeof raw === 'number') return raw + if (typeof raw === 'boolean') return raw + return String(raw).trim().toLowerCase() + } + + // String/other types - return as lowercase trimmed string + return String(raw).trim().toLowerCase() +} + +const buildJsonataContext = ( node: LGraphNode, - task: 'text' | 'image' | 'multiview' + rule: JsonataPricingRule +): JsonataEvalContext => { + const widgets: Record = {} + for (const dep of rule.depends_on.widgets) { + const widget = node.widgets?.find((x: IBaseWidget) => x.name === dep.name) + widgets[dep.name] = normalizeWidgetValue(widget?.value, dep.type) + } + + const inputs: Record = {} + for (const name of rule.depends_on.inputs) { + const slot = node.inputs?.find((x: INodeInputSlot) => x.name === name) + inputs[name] = { connected: slot?.link != null } + } + + // Count connected inputs per autogrow group + const inputGroups: Record = {} + for (const groupName of rule.depends_on.input_groups) { + const prefix = groupName + '.' + inputGroups[groupName] = + node.inputs?.filter( + (inp: INodeInputSlot) => + inp.name?.startsWith(prefix) && inp.link != null + ).length ?? 0 + } + + return { widgets, inputs, inputGroups } +} + +const safeValueForSig = (v: unknown): string => { + if (v === null || v === undefined) return '' + if (typeof v === 'string' || typeof v === 'number' || typeof v === 'boolean') + return String(v) + try { + return JSON.stringify(v) + } catch { + return String(v) + } +} + +// Signature determines whether we need to re-evaluate when widgets/inputs change. +const buildSignature = ( + ctx: JsonataEvalContext, + rule: JsonataPricingRule ): string => { - const getWidget = (name: string): IComboWidget | undefined => - node.widgets?.find((w) => w.name === name) as IComboWidget | undefined + const parts: string[] = [] + for (const dep of rule.depends_on.widgets) { + parts.push(`w:${dep.name}=${safeValueForSig(ctx.widgets[dep.name])}`) + } + for (const name of rule.depends_on.inputs) { + parts.push(`i:${name}=${ctx.inputs[name]?.connected ? '1' : '0'}`) + } + for (const name of rule.depends_on.input_groups) { + parts.push(`g:${name}=${ctx.inputGroups[name] ?? 0}`) + } + return parts.join('|') +} - const getString = (name: string, defaultValue: string): string => { - const widget = getWidget(name) - if (!widget || widget.value === undefined || widget.value === null) { - return defaultValue +// ----------------------------- +// Result formatting +// ----------------------------- +const formatPricingResult = ( + result: unknown, + defaults: CreditFormatOptions = {} +): string => { + if (!isPricingResult(result)) { + if (result !== undefined && result !== null) { + console.warn('[pricing/jsonata] invalid result format:', result) } - return String(widget.value) + return '' } - const getBool = (name: string, defaultValue: boolean): boolean => { - const widget = getWidget(name) - if (!widget || widget.value === undefined || widget.value === null) { - return defaultValue - } - - const v = widget.value - if (typeof v === 'number') return v !== 0 - const lower = String(v).toLowerCase() - if (lower === 'true') return true - if (lower === 'false') return false - - return defaultValue + if (result.type === 'text') { + return result.text ?? '' } - // ---- read widget values with sensible defaults (mirroring backend) ---- - const modelVersionRaw = getString('model_version', '').toLowerCase() - if (modelVersionRaw === '') - return formatCreditsRangeLabel(0.1, 0.65, { - note: '(varies with quad, style, texture & quality)' + if (result.type === 'usd') { + const usd = asFiniteNumber(result.usd) + if (usd === null) return '' + const fmt = { ...defaults, ...(result.format ?? {}) } + return formatCreditsLabel(usd, fmt) + } + + if (result.type === 'range_usd') { + const minUsd = asFiniteNumber(result.min_usd) + const maxUsd = asFiniteNumber(result.max_usd) + if (minUsd === null || maxUsd === null) return '' + const fmt = { ...defaults, ...(result.format ?? {}) } + return formatCreditsRangeLabel(minUsd, maxUsd, fmt) + } + + if (result.type === 'list_usd') { + const arr = Array.isArray(result.usd) ? result.usd : null + if (!arr) return '' + + const usdValues = arr + .map(asFiniteNumber) + .filter((x): x is number => x != null) + + if (usdValues.length === 0) return '' + + const fmt = { ...defaults, ...(result.format ?? {}) } + return formatCreditsListLabel(usdValues, fmt) + } + + return '' +} + +// ----------------------------- +// Compile rules (non-fatal) +// ----------------------------- +const compileRule = (rule: JsonataPricingRule): CompiledJsonataPricingRule => { + try { + return { ...rule, _compiled: jsonata(rule.expr) } + } catch (e) { + // Do not crash app on bad expressions; just disable rule. + console.error('[pricing/jsonata] failed to compile expr:', rule.expr, e) + return { ...rule, _compiled: null } + } +} + +// ----------------------------- +// Rule cache (per-node-type) +// ----------------------------- +// Cache compiled rules by node type name to avoid recompiling on every evaluation. +const compiledRulesCache = new Map() + +/** + * Convert a PriceBadge from node definition to a JsonataPricingRule. + */ +const priceBadgeToRule = (priceBadge: PriceBadge): JsonataPricingRule => ({ + engine: priceBadge.engine ?? 'jsonata', + depends_on: { + widgets: priceBadge.depends_on?.widgets ?? [], + inputs: priceBadge.depends_on?.inputs ?? [], + input_groups: priceBadge.depends_on?.input_groups ?? [] + }, + expr: priceBadge.expr +}) + +/** + * Get or compile a pricing rule for a node type. + */ +const getCompiledRuleForNodeType = ( + nodeName: string, + priceBadge: PriceBadge | undefined +): CompiledJsonataPricingRule | null => { + if (!priceBadge) return null + + // Check cache first + if (compiledRulesCache.has(nodeName)) { + return compiledRulesCache.get(nodeName) ?? null + } + + // Compile and cache + const rule = priceBadgeToRule(priceBadge) + const compiled = compileRule(rule) + compiledRulesCache.set(nodeName, compiled) + return compiled +} + +// ----------------------------- +// Async evaluation + cache (JSONata 2.x) +// ----------------------------- + +// Reactive tick to force UI updates when async evaluations resolve. +// We purposely read pricingTick.value inside getNodeDisplayPrice to create a dependency. +const pricingTick = ref(0) + +// Per-node revision tracking for VueNodes mode (more efficient than global tick) +// Uses plain Map with individual refs per node for fine-grained reactivity +// Keys are stringified node IDs to handle both string and number ID types +const nodeRevisions = new Map>() + +/** + * Get or create a revision ref for a specific node. + * Each node has its own independent ref, so updates to one won't trigger others. + */ +const getNodeRevisionRef = (nodeId: string | number): Ref => { + const key = String(nodeId) + let rev = nodeRevisions.get(key) + if (!rev) { + rev = ref(0) + nodeRevisions.set(key, rev) + } + return rev +} + +// WeakMaps avoid memory leaks when nodes are removed. +type CacheEntry = { sig: string; label: string } +type InflightEntry = { sig: string; promise: Promise } + +const cache = new WeakMap() +const desiredSig = new WeakMap() +const inflight = new WeakMap() + +const DEBUG_JSONATA_PRICING = false + +const scheduleEvaluation = ( + node: LGraphNode, + rule: CompiledJsonataPricingRule, + ctx: JsonataEvalContext, + sig: string +) => { + desiredSig.set(node, sig) + + const running = inflight.get(node) + if (running && running.sig === sig) return + + if (!rule._compiled) return + + const nodeName = getNodeConstructorData(node)?.name ?? '' + + const promise = Promise.resolve(rule._compiled.evaluate(ctx)) + .then((res) => { + const label = formatPricingResult(res, rule.result_defaults ?? {}) + + // Ignore stale results: if the node changed while we were evaluating, + // desiredSig will no longer match. + if (desiredSig.get(node) !== sig) return + + cache.set(node, { sig, label }) + + if (DEBUG_JSONATA_PRICING) { + console.warn('[pricing/jsonata] resolved', nodeName, { + sig, + res, + label + }) + } }) - const styleRaw = getString('style', 'None') - const hasStyle = styleRaw.toLowerCase() !== 'none' + .catch((err) => { + if (process.env.NODE_ENV === 'development') { + console.warn('[pricing/jsonata] evaluation failed', nodeName, err) + } - // Backend defaults: texture=true, pbr=true, quad=false, qualities="standard" - const hasTexture = getBool('texture', false) - const hasPbr = getBool('pbr', false) - const quad = getBool('quad', false) + // Cache empty to avoid retry-spam for same signature + if (desiredSig.get(node) === sig) { + cache.set(node, { sig, label: '' }) + } + }) + .finally(() => { + const cur = inflight.get(node) + if (cur && cur.sig === sig) inflight.delete(node) - const textureQualityRaw = getString( - 'texture_quality', - 'standard' - ).toLowerCase() - const geometryQualityRaw = getString( - 'geometry_quality', - 'standard' - ).toLowerCase() - - const isHdTexture = textureQualityRaw === 'detailed' - const isDetailedGeometry = geometryQualityRaw === 'detailed' - - const withTexture = hasTexture || hasPbr - - let baseCredits: number - - if (modelVersionRaw.includes('v1.4')) { - // V1.4 model: Text=20, Image=30, Refine=30 - if (task === 'text') { - baseCredits = 20 - } else { - // treat Multiview same as Image if V1.4 is ever used there - baseCredits = 30 - } - } else { - // V3.0, V2.5, V2.0 models - if (!withTexture) { - if (task === 'text') { - baseCredits = 10 // Text to 3D without texture + if (LiteGraph.vueNodesMode) { + // VueNodes mode: bump per-node revision (only this node re-renders) + getNodeRevisionRef(node.id).value++ } else { - baseCredits = 20 // Image/Multiview to 3D without texture + // Nodes 1.0 mode: bump global tick to trigger setDirtyCanvas + pricingTick.value++ } - } else { - if (task === 'text') { - baseCredits = 20 // Text to 3D with standard texture - } else { - baseCredits = 30 // Image/Multiview to 3D with standard texture - } - } - } + }) - // ---- advanced extras on top of base generation ---- - let credits = baseCredits - - if (hasStyle) credits += 5 // Style - if (quad) credits += 5 // Quad Topology - if (isHdTexture) credits += 10 // HD Texture - if (isDetailedGeometry) credits += 20 // Detailed Geometry Quality - - const dollars = credits * 0.01 - return formatCreditsLabel(dollars) + inflight.set(node, { sig, promise }) } /** - * Meshy Image to 3D pricing calculator. - * Pricing based on should_texture widget: - * - Without texture: 20 credits - * - With texture: 30 credits + * Get the pricing rule for a node from its nodeData.price_badge field. */ -const calculateMeshyImageToModelPrice = (node: LGraphNode): string => { - const shouldTextureWidget = node.widgets?.find( - (w) => w.name === 'should_texture' - ) as IComboWidget +const getRuleForNode = ( + node: LGraphNode +): CompiledJsonataPricingRule | undefined => { + const nodeData = getNodeConstructorData(node) + if (!nodeData?.api_node) return undefined - if (!shouldTextureWidget) { - return formatCreditsRangeLabel( - meshyCreditsToUsd(20), - meshyCreditsToUsd(30), - { note: '(varies with texture)' } - ) - } + const nodeName = nodeData?.name ?? '' + const priceBadge = nodeData?.price_badge - const shouldTexture = String(shouldTextureWidget.value).toLowerCase() - const credits = shouldTexture === 'true' ? 30 : 20 - return formatCreditsLabel(meshyCreditsToUsd(credits)) + if (!priceBadge) return undefined + + const compiled = getCompiledRuleForNodeType(nodeName, priceBadge) + return compiled ?? undefined } -/** - * Meshy Multi-Image to 3D pricing calculator. - * Pricing based on should_texture widget: - * - Without texture: 5 credits - * - With texture: 15 credits - */ -const calculateMeshyMultiImageToModelPrice = (node: LGraphNode): string => { - const shouldTextureWidget = node.widgets?.find( - (w) => w.name === 'should_texture' - ) as IComboWidget - - if (!shouldTextureWidget) { - return formatCreditsRangeLabel( - meshyCreditsToUsd(5), - meshyCreditsToUsd(15), - { note: '(varies with texture)' } - ) - } - - const shouldTexture = String(shouldTextureWidget.value).toLowerCase() - const credits = shouldTexture === 'true' ? 15 : 5 - return formatCreditsLabel(meshyCreditsToUsd(credits)) -} - -/** - * Static pricing data for API nodes, now supporting both strings and functions - */ -const apiNodeCosts: Record = - { - FluxProCannyNode: { - displayPrice: formatCreditsLabel(0.05) - }, - FluxProDepthNode: { - displayPrice: formatCreditsLabel(0.05) - }, - FluxProExpandNode: { - displayPrice: formatCreditsLabel(0.05) - }, - FluxProFillNode: { - displayPrice: formatCreditsLabel(0.05) - }, - FluxProUltraImageNode: { - displayPrice: formatCreditsLabel(0.06) - }, - FluxProKontextProNode: { - displayPrice: formatCreditsLabel(0.04) - }, - FluxProKontextMaxNode: { - displayPrice: formatCreditsLabel(0.08) - }, - Flux2ProImageNode: { - displayPrice: (node: LGraphNode): string => { - const widthW = node.widgets?.find( - (w) => w.name === 'width' - ) as IComboWidget - const heightW = node.widgets?.find( - (w) => w.name === 'height' - ) as IComboWidget - - const w = Number(widthW?.value) - const h = Number(heightW?.value) - if (!Number.isFinite(w) || !Number.isFinite(h) || w <= 0 || h <= 0) { - // global min/max for this node given schema bounds (1MP..4MP output) - return formatCreditsRangeLabel(0.03, 0.15) - } - - // Is the 'images' input connected? - const imagesInput = node.inputs?.find( - (i) => i.name === 'images' - ) as INodeInputSlot - const hasRefs = - typeof imagesInput?.link !== 'undefined' && imagesInput.link != null - - // Output cost: ceil((w*h)/MP); first MP $0.03, each additional $0.015 - const MP = 1024 * 1024 - const outMP = Math.max(1, Math.floor((w * h + MP - 1) / MP)) - const outputCost = 0.03 + 0.015 * Math.max(outMP - 1, 0) - - if (hasRefs) { - // Unknown ref count/size on the frontend: - // min extra is $0.015, max extra is $0.120 (8 MP cap / 8 refs) - const minTotal = outputCost + 0.015 - const maxTotal = outputCost + 0.12 - return formatCreditsRangeLabel(minTotal, maxTotal, { - approximate: true - }) - } - - // Precise text-to-image price - return formatCreditsLabel(outputCost) - } - }, - Flux2MaxImageNode: { - displayPrice: (node: LGraphNode): string => { - const widthW = node.widgets?.find( - (w) => w.name === 'width' - ) as IComboWidget - const heightW = node.widgets?.find( - (w) => w.name === 'height' - ) as IComboWidget - - const w = Number(widthW?.value) - const h = Number(heightW?.value) - if (!Number.isFinite(w) || !Number.isFinite(h) || w <= 0 || h <= 0) { - // global min/max for this node given schema bounds (1MP..4MP output) - return formatCreditsRangeLabel(0.07, 0.35) - } - - // Is the 'images' input connected? - const imagesInput = node.inputs?.find( - (i) => i.name === 'images' - ) as INodeInputSlot - const hasRefs = - typeof imagesInput?.link !== 'undefined' && imagesInput.link != null - - // Output cost: ceil((w*h)/MP); first MP $0.07, each additional $0.03 - const MP = 1024 * 1024 - const outMP = Math.max(1, Math.floor((w * h + MP - 1) / MP)) - const outputCost = 0.07 + 0.03 * Math.max(outMP - 1, 0) - - if (hasRefs) { - // Unknown ref count/size on the frontend: - // min extra is $0.03, max extra is $0.24 (8 MP cap / 8 refs) - const minTotal = outputCost + 0.03 - const maxTotal = outputCost + 0.24 - return formatCreditsRangeLabel(minTotal, maxTotal) - } - - return formatCreditsLabel(outputCost) - } - }, - OpenAIVideoSora2: { - displayPrice: sora2PricingCalculator - }, - IdeogramV1: { - displayPrice: (node: LGraphNode): string => { - const numImagesWidget = node.widgets?.find( - (w) => w.name === 'num_images' - ) as IComboWidget - const turboWidget = node.widgets?.find( - (w) => w.name === 'turbo' - ) as IComboWidget - - if (!numImagesWidget) - return formatCreditsRangeLabel(0.03, 0.09, { - suffix: ' x num_images/Run' - }) - - const numImages = Number(numImagesWidget.value) || 1 - const turbo = String(turboWidget?.value).toLowerCase() === 'true' - const basePrice = turbo ? 0.0286 : 0.0858 - const cost = Number((basePrice * numImages).toFixed(2)) - return formatCreditsLabel(cost) - } - }, - IdeogramV2: { - displayPrice: (node: LGraphNode): string => { - const numImagesWidget = node.widgets?.find( - (w) => w.name === 'num_images' - ) as IComboWidget - const turboWidget = node.widgets?.find( - (w) => w.name === 'turbo' - ) as IComboWidget - - if (!numImagesWidget) - return formatCreditsRangeLabel(0.07, 0.11, { - suffix: ' x num_images/Run' - }) - - const numImages = Number(numImagesWidget.value) || 1 - const turbo = String(turboWidget?.value).toLowerCase() === 'true' - const basePrice = turbo ? 0.0715 : 0.1144 - const cost = Number((basePrice * numImages).toFixed(2)) - return formatCreditsLabel(cost) - } - }, - IdeogramV3: { - displayPrice: (node: LGraphNode): string => { - const renderingSpeedWidget = node.widgets?.find( - (w) => w.name === 'rendering_speed' - ) as IComboWidget - const numImagesWidget = node.widgets?.find( - (w) => w.name === 'num_images' - ) as IComboWidget - const characterInput = node.inputs?.find( - (i) => i.name === 'character_image' - ) as INodeInputSlot - const hasCharacter = - typeof characterInput?.link !== 'undefined' && - characterInput.link != null - - if (!renderingSpeedWidget) - return formatCreditsRangeLabel(0.04, 0.11, { - suffix: ' x num_images/Run', - note: '(varies with rendering speed & num_images)' - }) - - const numImages = Number(numImagesWidget?.value) || 1 - let basePrice = 0.0858 // default balanced price - - const renderingSpeed = String(renderingSpeedWidget.value) - if (renderingSpeed.toLowerCase().includes('quality')) { - if (hasCharacter) { - basePrice = 0.286 - } else { - basePrice = 0.1287 - } - } else if (renderingSpeed.toLowerCase().includes('default')) { - if (hasCharacter) { - basePrice = 0.2145 - } else { - basePrice = 0.0858 - } - } else if (renderingSpeed.toLowerCase().includes('turbo')) { - if (hasCharacter) { - basePrice = 0.143 - } else { - basePrice = 0.0429 - } - } - - const totalCost = Number((basePrice * numImages).toFixed(2)) - return formatCreditsLabel(totalCost) - } - }, - KlingCameraControlI2VNode: { - displayPrice: formatCreditsLabel(0.49) - }, - KlingCameraControlT2VNode: { - displayPrice: formatCreditsLabel(0.14) - }, - KlingDualCharacterVideoEffectNode: { - displayPrice: (node: LGraphNode): string => { - const modeWidget = node.widgets?.find( - (w) => w.name === 'mode' - ) as IComboWidget - const modelWidget = node.widgets?.find( - (w) => w.name === 'model_name' - ) as IComboWidget - const durationWidget = node.widgets?.find( - (w) => w.name === 'duration' - ) as IComboWidget - if (!modeWidget || !modelWidget || !durationWidget) - return formatCreditsRangeLabel(0.14, 2.8, { - note: '(varies with model, mode & duration)' - }) - - const modeValue = String(modeWidget.value) - const durationValue = String(durationWidget.value) - const modelValue = String(modelWidget.value) - - // Same pricing matrix as KlingTextToVideoNode - if (modelValue.includes('v1-6') || modelValue.includes('v1-5')) { - if (modeValue.includes('pro')) { - return durationValue.includes('10') - ? formatCreditsLabel(0.98) - : formatCreditsLabel(0.49) - } else { - return durationValue.includes('10') - ? formatCreditsLabel(0.56) - : formatCreditsLabel(0.28) - } - } else if (modelValue.includes('v1')) { - if (modeValue.includes('pro')) { - return durationValue.includes('10') - ? formatCreditsLabel(0.98) - : formatCreditsLabel(0.49) - } else { - return durationValue.includes('10') - ? formatCreditsLabel(0.28) - : formatCreditsLabel(0.14) - } - } - - return formatCreditsLabel(0.14) - } - }, - KlingImage2VideoNode: { - displayPrice: (node: LGraphNode): string => { - const modeWidget = node.widgets?.find( - (w) => w.name === 'mode' - ) as IComboWidget - const modelWidget = node.widgets?.find( - (w) => w.name === 'model_name' - ) as IComboWidget - const durationWidget = node.widgets?.find( - (w) => w.name === 'duration' - ) as IComboWidget - - if (!modeWidget) { - if (!modelWidget) - return formatCreditsRangeLabel(0.14, 2.8, { - note: '(varies with model, mode & duration)' - }) - - const modelValue = String(modelWidget.value) - if ( - modelValue.includes('v2-1-master') || - modelValue.includes('v2-master') - ) { - return formatCreditsLabel(1.4) - } else if ( - modelValue.includes('v1-6') || - modelValue.includes('v1-5') - ) { - return formatCreditsLabel(0.28) - } - return formatCreditsLabel(0.14) - } - - const modeValue = String(modeWidget.value) - const durationValue = String(durationWidget.value) - const modelValue = String(modelWidget.value) - - // Same pricing matrix as KlingTextToVideoNode - if (modelValue.includes('v2-5-turbo')) { - if (durationValue.includes('10')) { - return formatCreditsLabel(0.7) - } - return formatCreditsLabel(0.35) // 5s default - } else if ( - modelValue.includes('v2-1-master') || - modelValue.includes('v2-master') - ) { - if (durationValue.includes('10')) { - return formatCreditsLabel(2.8) - } - return formatCreditsLabel(1.4) // 5s default - } else if ( - modelValue.includes('v2-1') || - modelValue.includes('v1-6') || - modelValue.includes('v1-5') - ) { - if (modeValue.includes('pro')) { - return durationValue.includes('10') - ? formatCreditsLabel(0.98) - : formatCreditsLabel(0.49) - } else { - return durationValue.includes('10') - ? formatCreditsLabel(0.56) - : formatCreditsLabel(0.28) - } - } else if (modelValue.includes('v1')) { - if (modeValue.includes('pro')) { - return durationValue.includes('10') - ? formatCreditsLabel(0.98) - : formatCreditsLabel(0.49) - } else { - return durationValue.includes('10') - ? formatCreditsLabel(0.28) - : formatCreditsLabel(0.14) - } - } - - return formatCreditsLabel(0.14) - } - }, - KlingImageGenerationNode: { - displayPrice: (node: LGraphNode): string => { - const imageInputWidget = node.inputs?.find((i) => i.name === 'image') - // If link is not null => image is connected => modality is image to image - const modality = imageInputWidget?.link - ? 'image to image' - : 'text to image' - const modelWidget = node.widgets?.find( - (w) => w.name === 'model_name' - ) as IComboWidget - const nWidget = node.widgets?.find( - (w) => w.name === 'n' - ) as IComboWidget - - if (!modelWidget) - return formatCreditsRangeLabel(0.0035, 0.028, { - suffix: ' x n/Run', - note: '(varies with modality & model)' - }) - - const model = String(modelWidget.value) - const n = Number(nWidget?.value) || 1 - let basePrice = 0.014 // default - - if (modality.includes('text to image')) { - if (model.includes('kling-v1-5') || model.includes('kling-v2')) { - basePrice = 0.014 - } else if (model.includes('kling-v1')) { - basePrice = 0.0035 - } - } else if (modality.includes('image to image')) { - if (model.includes('kling-v1-5')) { - basePrice = 0.028 - } else if (model.includes('kling-v1')) { - basePrice = 0.0035 - } - } - - const totalCost = basePrice * n - return formatCreditsLabel(totalCost) - } - }, - KlingLipSyncAudioToVideoNode: { - displayPrice: formatCreditsLabel(0.1, { approximate: true }) - }, - KlingLipSyncTextToVideoNode: { - displayPrice: formatCreditsLabel(0.1, { approximate: true }) - }, - KlingSingleImageVideoEffectNode: { - displayPrice: (node: LGraphNode): string => { - const effectSceneWidget = node.widgets?.find( - (w) => w.name === 'effect_scene' - ) as IComboWidget - - if (!effectSceneWidget) - return formatCreditsRangeLabel(0.28, 0.49, { - note: '(varies with effect scene)' - }) - - const effectScene = String(effectSceneWidget.value) - if ( - effectScene.includes('fuzzyfuzzy') || - effectScene.includes('squish') - ) { - return formatCreditsLabel(0.28) - } else if (effectScene.includes('dizzydizzy')) { - return formatCreditsLabel(0.49) - } else if (effectScene.includes('bloombloom')) { - return formatCreditsLabel(0.49) - } else if (effectScene.includes('expansion')) { - return formatCreditsLabel(0.28) - } - - return formatCreditsLabel(0.28) - } - }, - KlingStartEndFrameNode: { - displayPrice: (node: LGraphNode): string => { - // Same pricing as KlingTextToVideoNode per CSV ("Same as text to video") - const modeWidget = node.widgets?.find( - (w) => w.name === 'mode' - ) as IComboWidget - if (!modeWidget) - return formatCreditsRangeLabel(0.14, 2.8, { - note: '(varies with model, mode & duration)' - }) - - const modeValue = String(modeWidget.value) - - // Same pricing matrix as KlingTextToVideoNode - if (modeValue.includes('v2-5-turbo')) { - if (modeValue.includes('10')) { - return formatCreditsLabel(0.7) - } - return formatCreditsLabel(0.35) // 5s default - } else if (modeValue.includes('v2-1')) { - if (modeValue.includes('10s')) { - return formatCreditsLabel(0.98) // pro, 10s - } - return formatCreditsLabel(0.49) // pro, 5s default - } else if (modeValue.includes('v2-master')) { - if (modeValue.includes('10s')) { - return formatCreditsLabel(2.8) - } - return formatCreditsLabel(1.4) // 5s default - } else if (modeValue.includes('v1-6')) { - if (modeValue.includes('pro')) { - return modeValue.includes('10s') - ? formatCreditsLabel(0.98) - : formatCreditsLabel(0.49) - } else { - return modeValue.includes('10s') - ? formatCreditsLabel(0.56) - : formatCreditsLabel(0.28) - } - } else if (modeValue.includes('v1')) { - if (modeValue.includes('pro')) { - return modeValue.includes('10s') - ? formatCreditsLabel(0.98) - : formatCreditsLabel(0.49) - } else { - return modeValue.includes('10s') - ? formatCreditsLabel(0.28) - : formatCreditsLabel(0.14) - } - } - - return formatCreditsLabel(0.14) - } - }, - KlingTextToVideoNode: { - displayPrice: (node: LGraphNode): string => { - const modeWidget = node.widgets?.find( - (w) => w.name === 'mode' - ) as IComboWidget - if (!modeWidget) - return formatCreditsRangeLabel(0.14, 2.8, { - note: '(varies with model, mode & duration)' - }) - - const modeValue = String(modeWidget.value) - - // Pricing matrix from CSV data based on mode string content - if (modeValue.includes('v2-5-turbo')) { - if (modeValue.includes('10')) { - return formatCreditsLabel(0.7) - } - return formatCreditsLabel(0.35) // 5s default - } else if (modeValue.includes('v2-1-master')) { - if (modeValue.includes('10s')) { - return formatCreditsLabel(2.8) // price is the same as for v2-master model - } - return formatCreditsLabel(1.4) // price is the same as for v2-master model - } else if (modeValue.includes('v2-master')) { - if (modeValue.includes('10s')) { - return formatCreditsLabel(2.8) - } - return formatCreditsLabel(1.4) // 5s default - } else if (modeValue.includes('v1-6')) { - if (modeValue.includes('pro')) { - return modeValue.includes('10s') - ? formatCreditsLabel(0.98) - : formatCreditsLabel(0.49) - } else { - return modeValue.includes('10s') - ? formatCreditsLabel(0.56) - : formatCreditsLabel(0.28) - } - } else if (modeValue.includes('v1')) { - if (modeValue.includes('pro')) { - return modeValue.includes('10s') - ? formatCreditsLabel(0.98) - : formatCreditsLabel(0.49) - } else { - return modeValue.includes('10s') - ? formatCreditsLabel(0.28) - : formatCreditsLabel(0.14) - } - } - - return formatCreditsLabel(0.14) - } - }, - KlingVideoExtendNode: { - displayPrice: formatCreditsLabel(0.28) - }, - KlingVirtualTryOnNode: { - displayPrice: formatCreditsLabel(0.07) - }, - KlingOmniProTextToVideoNode: { - displayPrice: makeOmniProDurationCalculator(0.112) - }, - KlingOmniProFirstLastFrameNode: { - displayPrice: makeOmniProDurationCalculator(0.112) - }, - KlingOmniProImageToVideoNode: { - displayPrice: makeOmniProDurationCalculator(0.112) - }, - KlingOmniProVideoToVideoNode: { - displayPrice: makeOmniProDurationCalculator(0.168) - }, - KlingMotionControl: { - displayPrice: klingMotionControlPricingCalculator - }, - KlingOmniProEditVideoNode: { - displayPrice: formatCreditsLabel(0.168, { suffix: '/second' }) - }, - KlingOmniProImageNode: { - displayPrice: formatCreditsLabel(0.028) - }, - KlingTextToVideoWithAudio: { - displayPrice: klingVideoWithAudioPricingCalculator - }, - KlingImageToVideoWithAudio: { - displayPrice: klingVideoWithAudioPricingCalculator - }, - LumaImageToVideoNode: { - displayPrice: (node: LGraphNode): string => { - // Same pricing as LumaVideoNode per CSV - const modelWidget = node.widgets?.find( - (w) => w.name === 'model' - ) as IComboWidget - const resolutionWidget = node.widgets?.find( - (w) => w.name === 'resolution' - ) as IComboWidget - const durationWidget = node.widgets?.find( - (w) => w.name === 'duration' - ) as IComboWidget - - if (!modelWidget || !resolutionWidget || !durationWidget) { - return formatCreditsRangeLabel(0.2, 16.4, { - note: '(varies with model, resolution & duration)' - }) - } - - const model = String(modelWidget.value) - const resolution = String(resolutionWidget.value).toLowerCase() - const duration = String(durationWidget.value) - - if (model.includes('ray-flash-2')) { - if (duration.includes('5s')) { - if (resolution.includes('4k')) return formatCreditsLabel(3.13) - if (resolution.includes('1080p')) return formatCreditsLabel(0.79) - if (resolution.includes('720p')) return formatCreditsLabel(0.34) - if (resolution.includes('540p')) return formatCreditsLabel(0.2) - } else if (duration.includes('9s')) { - if (resolution.includes('4k')) return formatCreditsLabel(5.65) - if (resolution.includes('1080p')) return formatCreditsLabel(1.42) - if (resolution.includes('720p')) return formatCreditsLabel(0.61) - if (resolution.includes('540p')) return formatCreditsLabel(0.36) - } - } else if (model.includes('ray-2')) { - if (duration.includes('5s')) { - if (resolution.includes('4k')) return formatCreditsLabel(9.11) - if (resolution.includes('1080p')) return formatCreditsLabel(2.27) - if (resolution.includes('720p')) return formatCreditsLabel(1.02) - if (resolution.includes('540p')) return formatCreditsLabel(0.57) - } else if (duration.includes('9s')) { - if (resolution.includes('4k')) return formatCreditsLabel(16.4) - if (resolution.includes('1080p')) return formatCreditsLabel(4.1) - if (resolution.includes('720p')) return formatCreditsLabel(1.83) - if (resolution.includes('540p')) return formatCreditsLabel(1.03) - } - } else if (model.includes('ray-1-6')) { - return formatCreditsLabel(0.5) - } - - return formatCreditsLabel(0.79) - } - }, - LumaVideoNode: { - displayPrice: (node: LGraphNode): string => { - const modelWidget = node.widgets?.find( - (w) => w.name === 'model' - ) as IComboWidget - const resolutionWidget = node.widgets?.find( - (w) => w.name === 'resolution' - ) as IComboWidget - const durationWidget = node.widgets?.find( - (w) => w.name === 'duration' - ) as IComboWidget - - if (!modelWidget || !resolutionWidget || !durationWidget) { - return formatCreditsRangeLabel(0.2, 16.4, { - note: '(varies with model, resolution & duration)' - }) - } - - const model = String(modelWidget.value) - const resolution = String(resolutionWidget.value).toLowerCase() - const duration = String(durationWidget.value) - - if (model.includes('ray-flash-2')) { - if (duration.includes('5s')) { - if (resolution.includes('4k')) return formatCreditsLabel(3.13) - if (resolution.includes('1080p')) return formatCreditsLabel(0.79) - if (resolution.includes('720p')) return formatCreditsLabel(0.34) - if (resolution.includes('540p')) return formatCreditsLabel(0.2) - } else if (duration.includes('9s')) { - if (resolution.includes('4k')) return formatCreditsLabel(5.65) - if (resolution.includes('1080p')) return formatCreditsLabel(1.42) - if (resolution.includes('720p')) return formatCreditsLabel(0.61) - if (resolution.includes('540p')) return formatCreditsLabel(0.36) - } - } else if (model.includes('ray-2')) { - if (duration.includes('5s')) { - if (resolution.includes('4k')) return formatCreditsLabel(9.11) - if (resolution.includes('1080p')) return formatCreditsLabel(2.27) - if (resolution.includes('720p')) return formatCreditsLabel(1.02) - if (resolution.includes('540p')) return formatCreditsLabel(0.57) - } else if (duration.includes('9s')) { - if (resolution.includes('4k')) return formatCreditsLabel(16.4) - if (resolution.includes('1080p')) return formatCreditsLabel(4.1) - if (resolution.includes('720p')) return formatCreditsLabel(1.83) - if (resolution.includes('540p')) return formatCreditsLabel(1.03) - } - } else if (model.includes('ray-1-6')) { - return formatCreditsLabel(0.5) - } - - return formatCreditsLabel(0.79) - } - }, - MinimaxImageToVideoNode: { - displayPrice: formatCreditsLabel(0.43) - }, - MinimaxTextToVideoNode: { - displayPrice: formatCreditsLabel(0.43) - }, - MinimaxHailuoVideoNode: { - displayPrice: (node: LGraphNode): string => { - const resolutionWidget = node.widgets?.find( - (w) => w.name === 'resolution' - ) as IComboWidget - const durationWidget = node.widgets?.find( - (w) => w.name === 'duration' - ) as IComboWidget - - if (!resolutionWidget || !durationWidget) { - return formatCreditsRangeLabel(0.28, 0.56, { - note: '(varies with resolution & duration)' - }) - } - - const resolution = String(resolutionWidget.value) - const duration = String(durationWidget.value) - - if (resolution.includes('768P')) { - if (duration.includes('6')) return formatCreditsLabel(0.28) - if (duration.includes('10')) return formatCreditsLabel(0.56) - } else if (resolution.includes('1080P')) { - if (duration.includes('6')) return formatCreditsLabel(0.49) - } - - return formatCreditsLabel(0.43) // default median - } - }, - OpenAIDalle2: { - displayPrice: (node: LGraphNode): string => { - const sizeWidget = node.widgets?.find( - (w) => w.name === 'size' - ) as IComboWidget - const nWidget = node.widgets?.find( - (w) => w.name === 'n' - ) as IComboWidget - - if (!sizeWidget) - return formatCreditsRangeLabel(0.016, 0.02, { - suffix: ' x n/Run', - note: '(varies with size & n)' - }) - - const size = String(sizeWidget.value) - const n = Number(nWidget?.value) || 1 - let basePrice = 0.02 // default - - if (size.includes('1024x1024')) { - basePrice = 0.02 - } else if (size.includes('512x512')) { - basePrice = 0.018 - } else if (size.includes('256x256')) { - basePrice = 0.016 - } - - const totalCost = Number((basePrice * n).toFixed(3)) - return formatCreditsLabel(totalCost) - } - }, - OpenAIDalle3: { - displayPrice: (node: LGraphNode): string => { - // Get size and quality widgets - const sizeWidget = node.widgets?.find( - (w) => w.name === 'size' - ) as IComboWidget - const qualityWidget = node.widgets?.find( - (w) => w.name === 'quality' - ) as IComboWidget - - if (!sizeWidget || !qualityWidget) - return formatCreditsRangeLabel(0.04, 0.12, { - note: '(varies with size & quality)' - }) - - const size = String(sizeWidget.value) - const quality = String(qualityWidget.value) - - // Pricing matrix based on CSV data - if (size.includes('1024x1024')) { - return quality.includes('hd') - ? formatCreditsLabel(0.08) - : formatCreditsLabel(0.04) - } else if (size.includes('1792x1024') || size.includes('1024x1792')) { - return quality.includes('hd') - ? formatCreditsLabel(0.12) - : formatCreditsLabel(0.08) - } - - // Default value - return formatCreditsLabel(0.04) - } - }, - OpenAIGPTImage1: { - displayPrice: (node: LGraphNode): string => { - const qualityWidget = node.widgets?.find( - (w) => w.name === 'quality' - ) as IComboWidget - const nWidget = node.widgets?.find( - (w) => w.name === 'n' - ) as IComboWidget - - if (!qualityWidget) - return formatCreditsRangeLabel(0.011, 0.3, { - suffix: ' x n/Run', - note: '(varies with quality & n)' - }) - - const quality = String(qualityWidget.value) - const n = Number(nWidget?.value) || 1 - let range: [number, number] = [0.046, 0.07] // default medium - - if (quality.includes('high')) { - range = [0.167, 0.3] - } else if (quality.includes('medium')) { - range = [0.046, 0.07] - } else if (quality.includes('low')) { - range = [0.011, 0.02] - } - - if (n === 1) { - return formatCreditsRangeLabel(range[0], range[1]) - } - return formatCreditsRangeLabel(range[0], range[1], { - suffix: ` x ${n}/Run` - }) - } - }, - PixverseImageToVideoNode: { - displayPrice: pixversePricingCalculator - }, - PixverseTextToVideoNode: { - displayPrice: pixversePricingCalculator - }, - PixverseTransitionVideoNode: { - displayPrice: pixversePricingCalculator - }, - RecraftCreativeUpscaleNode: { - displayPrice: formatCreditsLabel(0.25) - }, - RecraftCrispUpscaleNode: { - displayPrice: formatCreditsLabel(0.004) - }, - RecraftGenerateColorFromImageNode: { - displayPrice: (node: LGraphNode): string => { - const nWidget = node.widgets?.find( - (w) => w.name === 'n' - ) as IComboWidget - if (!nWidget) return formatCreditsLabel(0.04, { suffix: ' x n/Run' }) - - const n = Number(nWidget.value) || 1 - const cost = Number((0.04 * n).toFixed(2)) - return formatCreditsLabel(cost) - } - }, - RecraftGenerateImageNode: { - displayPrice: (node: LGraphNode): string => { - const nWidget = node.widgets?.find( - (w) => w.name === 'n' - ) as IComboWidget - if (!nWidget) return formatCreditsLabel(0.04, { suffix: ' x n/Run' }) - - const n = Number(nWidget.value) || 1 - const cost = Number((0.04 * n).toFixed(2)) - return formatCreditsLabel(cost) - } - }, - RecraftGenerateVectorImageNode: { - displayPrice: (node: LGraphNode): string => { - const nWidget = node.widgets?.find( - (w) => w.name === 'n' - ) as IComboWidget - if (!nWidget) return formatCreditsLabel(0.08, { suffix: ' x n/Run' }) - - const n = Number(nWidget.value) || 1 - const cost = Number((0.08 * n).toFixed(2)) - return formatCreditsLabel(cost) - } - }, - RecraftImageInpaintingNode: { - displayPrice: (node: LGraphNode): string => { - const nWidget = node.widgets?.find( - (w) => w.name === 'n' - ) as IComboWidget - if (!nWidget) return formatCreditsLabel(0.04, { suffix: ' x n/Run' }) - - const n = Number(nWidget.value) || 1 - const cost = Number((0.04 * n).toFixed(2)) - return formatCreditsLabel(cost) - } - }, - RecraftImageToImageNode: { - displayPrice: (node: LGraphNode): string => { - const nWidget = node.widgets?.find( - (w) => w.name === 'n' - ) as IComboWidget - if (!nWidget) return formatCreditsLabel(0.04, { suffix: ' x n/Run' }) - - const n = Number(nWidget.value) || 1 - const cost = Number((0.04 * n).toFixed(2)) - return formatCreditsLabel(cost) - } - }, - RecraftRemoveBackgroundNode: { - displayPrice: formatCreditsLabel(0.01) - }, - RecraftReplaceBackgroundNode: { - displayPrice: formatCreditsLabel(0.04) - }, - RecraftTextToImageNode: { - displayPrice: (node: LGraphNode): string => { - const nWidget = node.widgets?.find( - (w) => w.name === 'n' - ) as IComboWidget - if (!nWidget) return formatCreditsLabel(0.04, { suffix: ' x n/Run' }) - - const n = Number(nWidget.value) || 1 - const cost = Number((0.04 * n).toFixed(2)) - return formatCreditsLabel(cost) - } - }, - RecraftTextToVectorNode: { - displayPrice: (node: LGraphNode): string => { - const nWidget = node.widgets?.find( - (w) => w.name === 'n' - ) as IComboWidget - if (!nWidget) return formatCreditsLabel(0.08, { suffix: ' x n/Run' }) - - const n = Number(nWidget.value) || 1 - const cost = Number((0.08 * n).toFixed(2)) - return formatCreditsLabel(cost) - } - }, - RecraftVectorizeImageNode: { - displayPrice: (node: LGraphNode): string => { - const nWidget = node.widgets?.find( - (w) => w.name === 'n' - ) as IComboWidget - if (!nWidget) return formatCreditsLabel(0.01, { suffix: ' x n/Run' }) - - const n = Number(nWidget.value) || 1 - const cost = Number((0.01 * n).toFixed(2)) - return formatCreditsLabel(cost) - } - }, - StabilityStableImageSD_3_5Node: { - displayPrice: (node: LGraphNode): string => { - const modelWidget = node.widgets?.find( - (w) => w.name === 'model' - ) as IComboWidget - - if (!modelWidget) - return formatCreditsRangeLabel(0.035, 0.065, { - note: '(varies with model)' - }) - - const model = String(modelWidget.value).toLowerCase() - if (model.includes('large')) { - return formatCreditsLabel(0.065) - } else if (model.includes('medium')) { - return formatCreditsLabel(0.035) - } - - return formatCreditsLabel(0.035) - } - }, - StabilityStableImageUltraNode: { - displayPrice: formatCreditsLabel(0.08) - }, - StabilityUpscaleConservativeNode: { - displayPrice: formatCreditsLabel(0.25) - }, - StabilityUpscaleCreativeNode: { - displayPrice: formatCreditsLabel(0.25) - }, - StabilityUpscaleFastNode: { - displayPrice: formatCreditsLabel(0.01) - }, - StabilityTextToAudio: { - displayPrice: formatCreditsLabel(0.2) - }, - StabilityAudioToAudio: { - displayPrice: formatCreditsLabel(0.2) - }, - StabilityAudioInpaint: { - displayPrice: formatCreditsLabel(0.2) - }, - VeoVideoGenerationNode: { - displayPrice: (node: LGraphNode): string => { - const durationWidget = node.widgets?.find( - (w) => w.name === 'duration_seconds' - ) as IComboWidget - - if (!durationWidget) - return formatCreditsRangeLabel(2.5, 5.0, { - note: '(varies with duration)' - }) - - const price = 0.5 * Number(durationWidget.value) - return formatCreditsLabel(price) - } - }, - Veo3VideoGenerationNode: { - displayPrice: (node: LGraphNode): string => { - const modelWidget = node.widgets?.find( - (w) => w.name === 'model' - ) as IComboWidget - const generateAudioWidget = node.widgets?.find( - (w) => w.name === 'generate_audio' - ) as IComboWidget - - if (!modelWidget || !generateAudioWidget) { - return formatCreditsRangeLabel(0.8, 3.2, { - note: '(varies with model & audio generation)' - }) - } - - const model = String(modelWidget.value) - const generateAudio = - String(generateAudioWidget.value).toLowerCase() === 'true' - - if ( - model.includes('veo-3.0-fast-generate-001') || - model.includes('veo-3.1-fast-generate') - ) { - return generateAudio - ? formatCreditsLabel(1.2) - : formatCreditsLabel(0.8) - } else if ( - model.includes('veo-3.0-generate-001') || - model.includes('veo-3.1-generate') - ) { - return generateAudio - ? formatCreditsLabel(3.2) - : formatCreditsLabel(1.6) - } - - // Default fallback - return formatCreditsRangeLabel(0.8, 3.2) - } - }, - Veo3FirstLastFrameNode: { - displayPrice: (node: LGraphNode): string => { - const modelWidget = node.widgets?.find( - (w) => w.name === 'model' - ) as IComboWidget - const generateAudioWidget = node.widgets?.find( - (w) => w.name === 'generate_audio' - ) as IComboWidget - const durationWidget = node.widgets?.find( - (w) => w.name === 'duration' - ) as IComboWidget - - if (!modelWidget || !generateAudioWidget || !durationWidget) { - return formatCreditsRangeLabel(0.4, 3.2, { - note: '(varies with model & audio generation)' - }) - } - - const model = String(modelWidget.value) - const generateAudio = - String(generateAudioWidget.value).toLowerCase() === 'true' - const seconds = parseFloat(String(durationWidget.value)) - - let pricePerSecond: number | null = null - if (model.includes('veo-3.1-fast-generate')) { - pricePerSecond = generateAudio ? 0.15 : 0.1 - } else if (model.includes('veo-3.1-generate')) { - pricePerSecond = generateAudio ? 0.4 : 0.2 - } - if (pricePerSecond === null) { - return formatCreditsRangeLabel(0.4, 3.2) - } - const cost = pricePerSecond * seconds - return formatCreditsLabel(cost) - } - }, - LumaImageNode: { - displayPrice: (node: LGraphNode): string => { - const modelWidget = node.widgets?.find( - (w) => w.name === 'model' - ) as IComboWidget - const aspectRatioWidget = node.widgets?.find( - (w) => w.name === 'aspect_ratio' - ) as IComboWidget - - if (!modelWidget || !aspectRatioWidget) { - return formatCreditsRangeLabel(0.0064, 0.026, { - note: '(varies with model & aspect ratio)' - }) - } - - const model = String(modelWidget.value) - - if (model.includes('photon-flash-1')) { - return formatCreditsLabel(0.0027) - } else if (model.includes('photon-1')) { - return formatCreditsLabel(0.0104) - } - - return formatCreditsLabel(0.0246) - } - }, - LumaImageModifyNode: { - displayPrice: (node: LGraphNode): string => { - const modelWidget = node.widgets?.find( - (w) => w.name === 'model' - ) as IComboWidget - - if (!modelWidget) { - return formatCreditsRangeLabel(0.0027, 0.0104, { - note: '(varies with model)' - }) - } - - const model = String(modelWidget.value) - - if (model.includes('photon-flash-1')) { - return formatCreditsLabel(0.0027) - } else if (model.includes('photon-1')) { - return formatCreditsLabel(0.0104) - } - - return formatCreditsLabel(0.0246) - } - }, - MoonvalleyTxt2VideoNode: { - displayPrice: (node: LGraphNode): string => { - const lengthWidget = node.widgets?.find( - (w) => w.name === 'length' - ) as IComboWidget - - // If no length widget exists, default to 5s pricing - if (!lengthWidget) return formatCreditsLabel(1.5) - - const length = String(lengthWidget.value) - if (length === '5s') { - return formatCreditsLabel(1.5) - } else if (length === '10s') { - return formatCreditsLabel(3.0) - } - - return formatCreditsLabel(1.5) - } - }, - MoonvalleyImg2VideoNode: { - displayPrice: (node: LGraphNode): string => { - const lengthWidget = node.widgets?.find( - (w) => w.name === 'length' - ) as IComboWidget - - // If no length widget exists, default to 5s pricing - if (!lengthWidget) return formatCreditsLabel(1.5) - - const length = String(lengthWidget.value) - if (length === '5s') { - return formatCreditsLabel(1.5) - } else if (length === '10s') { - return formatCreditsLabel(3.0) - } - - return formatCreditsLabel(1.5) - } - }, - MoonvalleyVideo2VideoNode: { - displayPrice: (node: LGraphNode): string => { - const lengthWidget = node.widgets?.find( - (w) => w.name === 'length' - ) as IComboWidget - - // If no length widget exists, default to 5s pricing - if (!lengthWidget) return formatCreditsLabel(2.25) - - const length = String(lengthWidget.value) - if (length === '5s') { - return formatCreditsLabel(2.25) - } else if (length === '10s') { - return formatCreditsLabel(4.0) - } - - return formatCreditsLabel(2.25) - } - }, - // Runway nodes - using actual node names from ComfyUI - RunwayTextToImageNode: { - displayPrice: formatCreditsLabel(0.11) - }, - RunwayImageToVideoNodeGen3a: { - displayPrice: calculateRunwayDurationPrice - }, - RunwayImageToVideoNodeGen4: { - displayPrice: calculateRunwayDurationPrice - }, - RunwayFirstLastFrameNode: { - displayPrice: calculateRunwayDurationPrice - }, - // Rodin nodes - all have the same pricing structure - Rodin3D_Regular: { - displayPrice: formatCreditsLabel(0.4) - }, - Rodin3D_Detail: { - displayPrice: formatCreditsLabel(0.4) - }, - Rodin3D_Smooth: { - displayPrice: formatCreditsLabel(0.4) - }, - Rodin3D_Sketch: { - displayPrice: formatCreditsLabel(0.4) - }, - // Tripo nodes - using actual node names from ComfyUI - TripoTextToModelNode: { - displayPrice: (node: LGraphNode): string => - calculateTripo3DGenerationPrice(node, 'text') - }, - TripoImageToModelNode: { - displayPrice: (node: LGraphNode): string => - calculateTripo3DGenerationPrice(node, 'image') - }, - TripoMultiviewToModelNode: { - displayPrice: (node: LGraphNode): string => - calculateTripo3DGenerationPrice(node, 'multiview') - }, - TripoTextureNode: { - displayPrice: (node: LGraphNode): string => { - const textureQualityWidget = node.widgets?.find( - (w) => w.name === 'texture_quality' - ) as IComboWidget - - if (!textureQualityWidget) - return formatCreditsRangeLabel(0.1, 0.2, { - note: '(varies with quality)' - }) - - const textureQuality = String(textureQualityWidget.value) - return textureQuality.includes('detailed') - ? formatCreditsLabel(0.2) - : formatCreditsLabel(0.1) - } - }, - TripoRigNode: { - displayPrice: '$0.25/Run' - }, - TripoConversionNode: { - displayPrice: (node: LGraphNode): string => { - const getWidgetValue = (name: string) => - node.widgets?.find((w) => w.name === name)?.value - - const getNumber = (name: string, defaultValue: number): number => { - const raw = getWidgetValue(name) - if (raw === undefined || raw === null || raw === '') - return defaultValue - if (typeof raw === 'number') - return Number.isFinite(raw) ? raw : defaultValue - const n = Number(raw) - return Number.isFinite(n) ? n : defaultValue - } - - const getBool = (name: string, defaultValue: boolean): boolean => { - const v = getWidgetValue(name) - if (v === undefined || v === null) return defaultValue - - if (typeof v === 'number') return v !== 0 - const lower = String(v).toLowerCase() - if (lower === 'true') return true - if (lower === 'false') return false - return defaultValue - } - - let hasAdvancedParam = false - - // ---- booleans that trigger advanced when true ---- - if (getBool('quad', false)) hasAdvancedParam = true - if (getBool('force_symmetry', false)) hasAdvancedParam = true - if (getBool('flatten_bottom', false)) hasAdvancedParam = true - if (getBool('pivot_to_center_bottom', false)) hasAdvancedParam = true - if (getBool('with_animation', false)) hasAdvancedParam = true - if (getBool('pack_uv', false)) hasAdvancedParam = true - if (getBool('bake', false)) hasAdvancedParam = true - if (getBool('export_vertex_colors', false)) hasAdvancedParam = true - if (getBool('animate_in_place', false)) hasAdvancedParam = true - - // ---- numeric params with special default sentinels ---- - const faceLimit = getNumber('face_limit', -1) - if (faceLimit !== -1) hasAdvancedParam = true - - const textureSize = getNumber('texture_size', 4096) - if (textureSize !== 4096) hasAdvancedParam = true - - const flattenBottomThreshold = getNumber( - 'flatten_bottom_threshold', - 0.0 - ) - if (flattenBottomThreshold !== 0.0) hasAdvancedParam = true - - const scaleFactor = getNumber('scale_factor', 1.0) - if (scaleFactor !== 1.0) hasAdvancedParam = true - - // ---- string / combo params with non-default values ---- - const textureFormatRaw = String( - getWidgetValue('texture_format') ?? 'JPEG' - ).toUpperCase() - if (textureFormatRaw !== 'JPEG') hasAdvancedParam = true - - const partNamesRaw = String(getWidgetValue('part_names') ?? '') - if (partNamesRaw.trim().length > 0) hasAdvancedParam = true - - const fbxPresetRaw = String( - getWidgetValue('fbx_preset') ?? 'blender' - ).toLowerCase() - if (fbxPresetRaw !== 'blender') hasAdvancedParam = true - - const exportOrientationRaw = String( - getWidgetValue('export_orientation') ?? 'default' - ).toLowerCase() - if (exportOrientationRaw !== 'default') hasAdvancedParam = true - - const credits = hasAdvancedParam ? 10 : 5 - return formatCreditsLabel(credits * 0.01) - } - }, - TripoRetargetNode: { - displayPrice: formatCreditsLabel(0.1) - }, - TripoRefineNode: { - displayPrice: formatCreditsLabel(0.3) - }, - MeshyTextToModelNode: { - displayPrice: formatCreditsLabel(meshyCreditsToUsd(20)) - }, - MeshyRefineNode: { - displayPrice: formatCreditsLabel(meshyCreditsToUsd(10)) - }, - MeshyImageToModelNode: { - displayPrice: calculateMeshyImageToModelPrice - }, - MeshyMultiImageToModelNode: { - displayPrice: calculateMeshyMultiImageToModelPrice - }, - MeshyRigModelNode: { - displayPrice: formatCreditsLabel(meshyCreditsToUsd(5)) - }, - MeshyAnimateModelNode: { - displayPrice: formatCreditsLabel(meshyCreditsToUsd(3)) - }, - MeshyTextureNode: { - displayPrice: formatCreditsLabel(meshyCreditsToUsd(10)) - }, - // Google/Gemini nodes - GeminiNode: { - displayPrice: (node: LGraphNode): string => { - const modelWidget = node.widgets?.find( - (w) => w.name === 'model' - ) as IComboWidget - - if (!modelWidget) return 'Token-based' - - const model = String(modelWidget.value) - - if (model.includes('gemini-2.5-flash-preview-04-17')) { - return formatCreditsListLabel([0.0003, 0.0025], { - suffix: ' per 1K tokens', - approximate: true, - separator: '-' - }) - } else if (model.includes('gemini-2.5-flash')) { - return formatCreditsListLabel([0.0003, 0.0025], { - suffix: ' per 1K tokens', - approximate: true, - separator: '-' - }) - } else if (model.includes('gemini-2.5-pro-preview-05-06')) { - return formatCreditsListLabel([0.00125, 0.01], { - suffix: ' per 1K tokens', - approximate: true, - separator: '-' - }) - } else if (model.includes('gemini-2.5-pro')) { - return formatCreditsListLabel([0.00125, 0.01], { - suffix: ' per 1K tokens', - approximate: true, - separator: '-' - }) - } else if (model.includes('gemini-3-pro-preview')) { - return formatCreditsListLabel([0.002, 0.012], { - suffix: ' per 1K tokens', - approximate: true, - separator: '-' - }) - } - // For other Gemini models, show token-based pricing info - return 'Token-based' - } - }, - GeminiImageNode: { - displayPrice: formatCreditsLabel(0.039, { - suffix: '/Image (1K)', - approximate: true - }) - }, - GeminiImage2Node: { - displayPrice: (node: LGraphNode): string => { - const resolutionWidget = node.widgets?.find( - (w) => w.name === 'resolution' - ) as IComboWidget - - if (!resolutionWidget) return 'Token-based' - - const resolution = String(resolutionWidget.value) - if (resolution.includes('1K')) { - return formatCreditsLabel(0.134, { - suffix: '/Image', - approximate: true - }) - } else if (resolution.includes('2K')) { - return formatCreditsLabel(0.134, { - suffix: '/Image', - approximate: true - }) - } else if (resolution.includes('4K')) { - return formatCreditsLabel(0.24, { - suffix: '/Image', - approximate: true - }) - } - return 'Token-based' - } - }, - // OpenAI nodes - OpenAIChatNode: { - displayPrice: (node: LGraphNode): string => { - const modelWidget = node.widgets?.find( - (w) => w.name === 'model' - ) as IComboWidget - - if (!modelWidget) return 'Token-based' - - const model = String(modelWidget.value) - - // Specific pricing for exposed models based on official pricing data (converted to per 1K tokens) - if (model.includes('o4-mini')) { - return formatCreditsListLabel([0.0011, 0.0044], { - suffix: ' per 1K tokens', - approximate: true, - separator: '-' - }) - } else if (model.includes('o1-pro')) { - return formatCreditsListLabel([0.15, 0.6], { - suffix: ' per 1K tokens', - approximate: true, - separator: '-' - }) - } else if (model.includes('o1')) { - return formatCreditsListLabel([0.015, 0.06], { - suffix: ' per 1K tokens', - approximate: true, - separator: '-' - }) - } else if (model.includes('o3-mini')) { - return formatCreditsListLabel([0.0011, 0.0044], { - suffix: ' per 1K tokens', - approximate: true, - separator: '-' - }) - } else if (model.includes('o3')) { - return formatCreditsListLabel([0.01, 0.04], { - suffix: ' per 1K tokens', - approximate: true, - separator: '-' - }) - } else if (model.includes('gpt-4o')) { - return formatCreditsListLabel([0.0025, 0.01], { - suffix: ' per 1K tokens', - approximate: true, - separator: '-' - }) - } else if (model.includes('gpt-4.1-nano')) { - return formatCreditsListLabel([0.0001, 0.0004], { - suffix: ' per 1K tokens', - approximate: true, - separator: '-' - }) - } else if (model.includes('gpt-4.1-mini')) { - return formatCreditsListLabel([0.0004, 0.0016], { - suffix: ' per 1K tokens', - approximate: true, - separator: '-' - }) - } else if (model.includes('gpt-4.1')) { - return formatCreditsListLabel([0.002, 0.008], { - suffix: ' per 1K tokens', - approximate: true, - separator: '-' - }) - } else if (model.includes('gpt-5-nano')) { - return formatCreditsListLabel([0.00005, 0.0004], { - suffix: ' per 1K tokens', - approximate: true, - separator: '-' - }) - } else if (model.includes('gpt-5-mini')) { - return formatCreditsListLabel([0.00025, 0.002], { - suffix: ' per 1K tokens', - approximate: true, - separator: '-' - }) - } else if (model.includes('gpt-5')) { - return formatCreditsListLabel([0.00125, 0.01], { - suffix: ' per 1K tokens', - approximate: true, - separator: '-' - }) - } - return 'Token-based' - } - }, - ViduTextToVideoNode: { - displayPrice: formatCreditsLabel(0.4) - }, - ViduImageToVideoNode: { - displayPrice: formatCreditsLabel(0.4) - }, - ViduReferenceVideoNode: { - displayPrice: formatCreditsLabel(0.4) - }, - ViduStartEndToVideoNode: { - displayPrice: formatCreditsLabel(0.4) - }, - ByteDanceImageNode: { - displayPrice: (node: LGraphNode): string => { - const modelWidget = node.widgets?.find( - (w) => w.name === 'model' - ) as IComboWidget - - if (!modelWidget) return 'Token-based' - - const model = String(modelWidget.value) - - if (model.includes('seedream-3-0-t2i')) { - return formatCreditsLabel(0.03) - } - return 'Token-based' - } - }, - ByteDanceImageEditNode: { - displayPrice: (node: LGraphNode): string => { - const modelWidget = node.widgets?.find( - (w) => w.name === 'model' - ) as IComboWidget - - if (!modelWidget) return 'Token-based' - - const model = String(modelWidget.value) - - if (model.includes('seededit-3-0-i2i')) { - return formatCreditsLabel(0.03) - } - return 'Token-based' - } - }, - ByteDanceSeedreamNode: { - displayPrice: (node: LGraphNode): string => { - const modelWidget = node.widgets?.find( - (w) => w.name === 'model' - ) as IComboWidget - - const model = String(modelWidget?.value ?? '').toLowerCase() - let pricePerImage = 0.03 // default for seedream-4-0-250828 and fallback - if (model.includes('seedream-4-5-251128')) { - pricePerImage = 0.04 - } else if (model.includes('seedream-4-0-250828')) { - pricePerImage = 0.03 - } - return formatCreditsLabel(pricePerImage, { - suffix: ' x images/Run', - approximate: true - }) - } - }, - ByteDanceTextToVideoNode: { - displayPrice: byteDanceVideoPricingCalculator - }, - ByteDanceImageToVideoNode: { - displayPrice: byteDanceVideoPricingCalculator - }, - ByteDanceFirstLastFrameNode: { - displayPrice: byteDanceVideoPricingCalculator - }, - ByteDanceImageReferenceNode: { - displayPrice: byteDanceVideoPricingCalculator - }, - WanTextToVideoApi: { - displayPrice: (node: LGraphNode): string => { - const durationWidget = node.widgets?.find( - (w) => w.name === 'duration' - ) as IComboWidget - const resolutionWidget = node.widgets?.find( - (w) => w.name === 'size' - ) as IComboWidget - - if (!durationWidget || !resolutionWidget) - return formatCreditsRangeLabel(0.05, 0.15, { suffix: '/second' }) - - const seconds = parseFloat(String(durationWidget.value)) - const resolutionStr = String(resolutionWidget.value).toLowerCase() - - const resKey = resolutionStr.includes('1080') - ? '1080p' - : resolutionStr.includes('720') - ? '720p' - : resolutionStr.includes('480') - ? '480p' - : (resolutionStr.match(/^\s*(\d{3,4}p)/)?.[1] ?? '') - - const pricePerSecond: Record = { - '480p': 0.05, - '720p': 0.1, - '1080p': 0.15 - } - - const pps = pricePerSecond[resKey] - if (isNaN(seconds) || !pps) - return formatCreditsRangeLabel(0.05, 0.15, { suffix: '/second' }) - - const cost = Number((pps * seconds).toFixed(2)) - return formatCreditsLabel(cost) - } - }, - WanImageToVideoApi: { - displayPrice: (node: LGraphNode): string => { - const durationWidget = node.widgets?.find( - (w) => w.name === 'duration' - ) as IComboWidget - const resolutionWidget = node.widgets?.find( - (w) => w.name === 'resolution' - ) as IComboWidget - - if (!durationWidget || !resolutionWidget) - return formatCreditsRangeLabel(0.05, 0.15, { suffix: '/second' }) - - const seconds = parseFloat(String(durationWidget.value)) - const resolution = String(resolutionWidget.value).trim().toLowerCase() - - const pricePerSecond: Record = { - '480p': 0.05, - '720p': 0.1, - '1080p': 0.15 - } - - const pps = pricePerSecond[resolution] - if (isNaN(seconds) || !pps) - return formatCreditsRangeLabel(0.05, 0.15, { suffix: '/second' }) - - const cost = Number((pps * seconds).toFixed(2)) - return formatCreditsLabel(cost) - } - }, - WanTextToImageApi: { - displayPrice: formatCreditsLabel(0.03) - }, - WanImageToImageApi: { - displayPrice: formatCreditsLabel(0.03) - }, - LtxvApiTextToVideo: { - displayPrice: ltxvPricingCalculator - }, - LtxvApiImageToVideo: { - displayPrice: ltxvPricingCalculator - }, - WanReferenceVideoApi: { - displayPrice: (node: LGraphNode): string => { - const durationWidget = node.widgets?.find( - (w) => w.name === 'duration' - ) as IComboWidget - const sizeWidget = node.widgets?.find( - (w) => w.name === 'size' - ) as IComboWidget - - if (!durationWidget || !sizeWidget) { - return formatCreditsRangeLabel(0.7, 1.5, { - note: '(varies with size & duration)' - }) - } - - const seconds = parseFloat(String(durationWidget.value)) - const sizeStr = String(sizeWidget.value).toLowerCase() - - const rate = sizeStr.includes('1080p') ? 0.15 : 0.1 - const inputMin = 2 * rate - const inputMax = 5 * rate - const outputPrice = seconds * rate - - const minTotal = inputMin + outputPrice - const maxTotal = inputMax + outputPrice - - return formatCreditsRangeLabel(minTotal, maxTotal) - } - }, - Vidu2TextToVideoNode: { - displayPrice: (node: LGraphNode): string => { - const durationWidget = node.widgets?.find( - (w) => w.name === 'duration' - ) as IComboWidget - const resolutionWidget = node.widgets?.find( - (w) => w.name === 'resolution' - ) as IComboWidget - - if (!durationWidget || !resolutionWidget) { - return formatCreditsRangeLabel(0.075, 0.6, { - note: '(varies with duration & resolution)' - }) - } - - const duration = parseFloat(String(durationWidget.value)) - const resolution = String(resolutionWidget.value).toLowerCase() - - // Text-to-Video uses Q2 model only - // 720P: Starts at $0.075, +$0.025/sec - // 1080P: Starts at $0.10, +$0.05/sec - let basePrice: number - let pricePerSecond: number - - if (resolution.includes('1080')) { - basePrice = 0.1 - pricePerSecond = 0.05 - } else { - // 720P default - basePrice = 0.075 - pricePerSecond = 0.025 - } - - if (!Number.isFinite(duration) || duration <= 0) { - return formatCreditsRangeLabel(0.075, 0.6, { - note: '(varies with duration & resolution)' - }) - } - - const cost = basePrice + pricePerSecond * (duration - 1) - return formatCreditsLabel(cost) - } - }, - Vidu2ImageToVideoNode: { - displayPrice: (node: LGraphNode): string => { - const modelWidget = node.widgets?.find( - (w) => w.name === 'model' - ) as IComboWidget - const durationWidget = node.widgets?.find( - (w) => w.name === 'duration' - ) as IComboWidget - const resolutionWidget = node.widgets?.find( - (w) => w.name === 'resolution' - ) as IComboWidget - - if (!modelWidget || !durationWidget || !resolutionWidget) { - return formatCreditsRangeLabel(0.04, 1.0, { - note: '(varies with model, duration & resolution)' - }) - } - - const model = String(modelWidget.value).toLowerCase() - const duration = parseFloat(String(durationWidget.value)) - const resolution = String(resolutionWidget.value).toLowerCase() - const is1080p = resolution.includes('1080') - - let basePrice: number - let pricePerSecond: number - - if (model.includes('q2-pro-fast')) { - // Q2-pro-fast: 720P $0.04+$0.01/sec, 1080P $0.08+$0.02/sec - basePrice = is1080p ? 0.08 : 0.04 - pricePerSecond = is1080p ? 0.02 : 0.01 - } else if (model.includes('q2-pro')) { - // Q2-pro: 720P $0.075+$0.05/sec, 1080P $0.275+$0.075/sec - basePrice = is1080p ? 0.275 : 0.075 - pricePerSecond = is1080p ? 0.075 : 0.05 - } else if (model.includes('q2-turbo')) { - // Q2-turbo: 720P special pricing, 1080P $0.175+$0.05/sec - if (is1080p) { - basePrice = 0.175 - pricePerSecond = 0.05 - } else { - // 720P: $0.04 at 1s, $0.05 at 2s, +$0.05/sec beyond 2s - if (duration <= 1) { - return formatCreditsLabel(0.04) - } - if (duration <= 2) { - return formatCreditsLabel(0.05) - } - const cost = 0.05 + 0.05 * (duration - 2) - return formatCreditsLabel(cost) - } - } else { - return formatCreditsRangeLabel(0.04, 1.0, { - note: '(varies with model, duration & resolution)' - }) - } - - if (!Number.isFinite(duration) || duration <= 0) { - return formatCreditsRangeLabel(0.04, 1.0, { - note: '(varies with model, duration & resolution)' - }) - } - - const cost = basePrice + pricePerSecond * (duration - 1) - return formatCreditsLabel(cost) - } - }, - Vidu2ReferenceVideoNode: { - displayPrice: (node: LGraphNode): string => { - const durationWidget = node.widgets?.find( - (w) => w.name === 'duration' - ) as IComboWidget - const resolutionWidget = node.widgets?.find( - (w) => w.name === 'resolution' - ) as IComboWidget - const audioWidget = node.widgets?.find( - (w) => w.name === 'audio' - ) as IComboWidget - - if (!durationWidget) { - return formatCreditsRangeLabel(0.125, 1.5, { - note: '(varies with duration, resolution & audio)' - }) - } - - const duration = parseFloat(String(durationWidget.value)) - const resolution = String(resolutionWidget?.value ?? '').toLowerCase() - const is1080p = resolution.includes('1080') - - // Check if audio is enabled (adds $0.75) - const audioValue = audioWidget?.value - const hasAudio = - audioValue !== undefined && - audioValue !== null && - String(audioValue).toLowerCase() !== 'false' && - String(audioValue).toLowerCase() !== 'none' && - audioValue !== '' - - // Reference-to-Video uses Q2 model - // 720P: Starts at $0.125, +$0.025/sec - // 1080P: Starts at $0.375, +$0.05/sec - let basePrice: number - let pricePerSecond: number - - if (is1080p) { - basePrice = 0.375 - pricePerSecond = 0.05 - } else { - // 720P default - basePrice = 0.125 - pricePerSecond = 0.025 - } - - let cost = basePrice - if (Number.isFinite(duration) && duration > 0) { - cost = basePrice + pricePerSecond * (duration - 1) - } - - // Audio adds $0.75 on top - if (hasAudio) { - cost += 0.075 - } - - return formatCreditsLabel(cost) - } - }, - Vidu2StartEndToVideoNode: { - displayPrice: (node: LGraphNode): string => { - const modelWidget = node.widgets?.find( - (w) => w.name === 'model' - ) as IComboWidget - const durationWidget = node.widgets?.find( - (w) => w.name === 'duration' - ) as IComboWidget - const resolutionWidget = node.widgets?.find( - (w) => w.name === 'resolution' - ) as IComboWidget - - if (!modelWidget || !durationWidget || !resolutionWidget) { - return formatCreditsRangeLabel(0.04, 1.0, { - note: '(varies with model, duration & resolution)' - }) - } - - const model = String(modelWidget.value).toLowerCase() - const duration = parseFloat(String(durationWidget.value)) - const resolution = String(resolutionWidget.value).toLowerCase() - const is1080p = resolution.includes('1080') - - let basePrice: number - let pricePerSecond: number - - if (model.includes('q2-pro-fast')) { - // Q2-pro-fast: 720P $0.04+$0.01/sec, 1080P $0.08+$0.02/sec - basePrice = is1080p ? 0.08 : 0.04 - pricePerSecond = is1080p ? 0.02 : 0.01 - } else if (model.includes('q2-pro')) { - // Q2-pro: 720P $0.075+$0.05/sec, 1080P $0.275+$0.075/sec - basePrice = is1080p ? 0.275 : 0.075 - pricePerSecond = is1080p ? 0.075 : 0.05 - } else if (model.includes('q2-turbo')) { - // Q2-turbo: 720P special pricing, 1080P $0.175+$0.05/sec - if (is1080p) { - basePrice = 0.175 - pricePerSecond = 0.05 - } else { - // 720P: $0.04 at 1s, $0.05 at 2s, +$0.05/sec beyond 2s - if (!Number.isFinite(duration) || duration <= 1) { - return formatCreditsLabel(0.04) - } - if (duration <= 2) { - return formatCreditsLabel(0.05) - } - const cost = 0.05 + 0.05 * (duration - 2) - return formatCreditsLabel(cost) - } - } else { - return formatCreditsRangeLabel(0.04, 1.0, { - note: '(varies with model, duration & resolution)' - }) - } - - if (!Number.isFinite(duration) || duration <= 0) { - return formatCreditsLabel(basePrice) - } - - const cost = basePrice + pricePerSecond * (duration - 1) - return formatCreditsLabel(cost) - } - } - } - -/** - * Composable to get node pricing information for API nodes - */ +// ----------------------------- +// Public composable API +// ----------------------------- export const useNodePricing = () => { /** - * Get the price display for a node + * Sync getter: + * - returns cached label for the current node signature when available + * - schedules async evaluation when needed + * - remains non-fatal on errors (returns safe fallback '') */ const getNodeDisplayPrice = (node: LGraphNode): string => { - if (!node.constructor?.nodeData?.api_node) return '' + // Make this function reactive: when async evaluation completes, we bump pricingTick, + // which causes this getter to recompute in Vue render/computed contexts. + void pricingTick.value - const nodeName = node.constructor.nodeData.name - const priceConfig = apiNodeCosts[nodeName] + const nodeData = getNodeConstructorData(node) + if (!nodeData?.api_node) return '' - if (!priceConfig) return '' + const rule = getRuleForNode(node) + if (!rule) return '' + if (rule.engine !== 'jsonata') return '' + if (!rule._compiled) return '' - // If it's a function, call it with the node to get dynamic pricing - if (typeof priceConfig.displayPrice === 'function') { - return safePricingExecution(priceConfig.displayPrice, node, '') + const ctx = buildJsonataContext(node, rule) + const sig = buildSignature(ctx, rule) + + const cached = cache.get(node) + if (cached && cached.sig === sig) { + return cached.label } - // Otherwise return the static price - return priceConfig.displayPrice + // Cache miss: start async evaluation. + // Return last-known label (if any) to avoid flicker; otherwise return empty. + scheduleEvaluation(node, rule, ctx, sig) + return cached?.label ?? '' } - const getNodePricingConfig = (node: LGraphNode) => - apiNodeCosts[node.constructor.nodeData?.name ?? ''] + /** + * Expose raw pricing config for tooling/debug UI. + * (Strips compiled expression from returned object.) + */ + const getNodePricingConfig = (node: LGraphNode) => { + const rule = getRuleForNode(node) + if (!rule) return undefined + const { _compiled, ...config } = rule + return config + } + /** + * Caller compatibility helper: + * returns union of widget dependencies + input dependencies for a node type. + */ const getRelevantWidgetNames = (nodeType: string): string[] => { - const widgetMap: Record = { - KlingTextToVideoNode: ['mode', 'model_name', 'duration'], - KlingImage2VideoNode: ['mode', 'model_name', 'duration'], - KlingImageGenerationNode: ['modality', 'model_name', 'n'], - KlingDualCharacterVideoEffectNode: ['mode', 'model_name', 'duration'], - KlingSingleImageVideoEffectNode: ['effect_scene'], - KlingStartEndFrameNode: ['mode', 'model_name', 'duration'], - KlingTextToVideoWithAudio: ['duration', 'generate_audio'], - KlingImageToVideoWithAudio: ['duration', 'generate_audio'], - KlingOmniProTextToVideoNode: ['duration'], - KlingOmniProFirstLastFrameNode: ['duration'], - KlingOmniProImageToVideoNode: ['duration'], - KlingOmniProVideoToVideoNode: ['duration'], - KlingMotionControl: ['mode'], - MinimaxHailuoVideoNode: ['resolution', 'duration'], - OpenAIDalle3: ['size', 'quality'], - OpenAIDalle2: ['size', 'n'], - OpenAIVideoSora2: ['model', 'size', 'duration'], - OpenAIGPTImage1: ['quality', 'n'], - IdeogramV1: ['num_images', 'turbo'], - IdeogramV2: ['num_images', 'turbo'], - IdeogramV3: ['rendering_speed', 'num_images', 'character_image'], - FluxProKontextProNode: [], - FluxProKontextMaxNode: [], - Flux2ProImageNode: ['width', 'height', 'images'], - Flux2MaxImageNode: ['width', 'height', 'images'], - VeoVideoGenerationNode: ['duration_seconds'], - Veo3VideoGenerationNode: ['model', 'generate_audio'], - Veo3FirstLastFrameNode: ['model', 'generate_audio', 'duration'], - LumaVideoNode: ['model', 'resolution', 'duration'], - LumaImageToVideoNode: ['model', 'resolution', 'duration'], - LumaImageNode: ['model', 'aspect_ratio'], - LumaImageModifyNode: ['model', 'aspect_ratio'], - PixverseTextToVideoNode: ['duration_seconds', 'quality', 'motion_mode'], - PixverseTransitionVideoNode: [ - 'duration_seconds', - 'motion_mode', - 'quality' - ], - PixverseImageToVideoNode: ['duration_seconds', 'quality', 'motion_mode'], - StabilityStableImageSD_3_5Node: ['model'], - RecraftTextToImageNode: ['n'], - RecraftImageToImageNode: ['n'], - RecraftImageInpaintingNode: ['n'], - RecraftTextToVectorNode: ['n'], - RecraftVectorizeImageNode: ['n'], - RecraftGenerateColorFromImageNode: ['n'], - RecraftGenerateImageNode: ['n'], - RecraftGenerateVectorImageNode: ['n'], - MoonvalleyTxt2VideoNode: ['length'], - MoonvalleyImg2VideoNode: ['length'], - MoonvalleyVideo2VideoNode: ['length'], - // Runway nodes - RunwayImageToVideoNodeGen3a: ['duration'], - RunwayImageToVideoNodeGen4: ['duration'], - RunwayFirstLastFrameNode: ['duration'], - // Tripo nodes - TripoTextToModelNode: [ - 'model_version', - 'quad', - 'style', - 'texture', - 'pbr', - 'texture_quality', - 'geometry_quality' - ], - TripoImageToModelNode: [ - 'model_version', - 'quad', - 'style', - 'texture', - 'pbr', - 'texture_quality', - 'geometry_quality' - ], - TripoMultiviewToModelNode: [ - 'model_version', - 'quad', - 'texture', - 'pbr', - 'texture_quality', - 'geometry_quality' - ], - TripoConversionNode: [ - 'quad', - 'face_limit', - 'texture_size', - 'texture_format', - 'force_symmetry', - 'flatten_bottom', - 'flatten_bottom_threshold', - 'pivot_to_center_bottom', - 'scale_factor', - 'with_animation', - 'pack_uv', - 'bake', - 'part_names', - 'fbx_preset', - 'export_vertex_colors', - 'export_orientation', - 'animate_in_place' - ], - TripoTextureNode: ['texture_quality'], - // Meshy nodes - MeshyImageToModelNode: ['should_texture'], - MeshyMultiImageToModelNode: ['should_texture'], - // Google/Gemini nodes - GeminiNode: ['model'], - GeminiImage2Node: ['resolution'], - // OpenAI nodes - OpenAIChatNode: ['model'], - // ByteDance - ByteDanceImageNode: ['model'], - ByteDanceImageEditNode: ['model'], - ByteDanceSeedreamNode: [ - 'model', - 'sequential_image_generation', - 'max_images' - ], - ByteDanceTextToVideoNode: [ - 'model', - 'duration', - 'resolution', - 'generate_audio' - ], - ByteDanceImageToVideoNode: [ - 'model', - 'duration', - 'resolution', - 'generate_audio' - ], - ByteDanceFirstLastFrameNode: [ - 'model', - 'duration', - 'resolution', - 'generate_audio' - ], - ByteDanceImageReferenceNode: ['model', 'duration', 'resolution'], - WanTextToVideoApi: ['duration', 'size'], - WanImageToVideoApi: ['duration', 'resolution'], - WanReferenceVideoApi: ['duration', 'size'], - LtxvApiTextToVideo: ['model', 'duration', 'resolution'], - LtxvApiImageToVideo: ['model', 'duration', 'resolution'], - Vidu2TextToVideoNode: ['model', 'duration', 'resolution'], - Vidu2ImageToVideoNode: ['model', 'duration', 'resolution'], - Vidu2ReferenceVideoNode: ['audio', 'duration', 'resolution'], - Vidu2StartEndToVideoNode: ['model', 'duration', 'resolution'] + const nodeDefStore = useNodeDefStore() + const nodeDef = nodeDefStore.nodeDefsByName[nodeType] + if (!nodeDef) return [] + + const priceBadge = nodeDef.price_badge + if (!priceBadge) return [] + + const dependsOn = priceBadge.depends_on ?? { + widgets: [], + inputs: [], + input_groups: [] } - return widgetMap[nodeType] || [] + + // Extract widget names + const widgetNames = (dependsOn.widgets ?? []).map((w) => w.name) + + // Keep stable output (dedupe while preserving order) + const out: string[] = [] + for (const n of [ + ...widgetNames, + ...(dependsOn.inputs ?? []), + ...(dependsOn.input_groups ?? []) + ]) { + if (!out.includes(n)) out.push(n) + } + return out + } + + /** + * Check if a node type has dynamic pricing (depends on widgets, inputs, or input_groups). + */ + const hasDynamicPricing = (nodeType: string): boolean => { + const nodeDefStore = useNodeDefStore() + const nodeDef = nodeDefStore.nodeDefsByName[nodeType] + if (!nodeDef) return false + + const priceBadge = nodeDef.price_badge + if (!priceBadge) return false + + const dependsOn = priceBadge.depends_on + if (!dependsOn) return false + + return ( + (dependsOn.widgets?.length ?? 0) > 0 || + (dependsOn.inputs?.length ?? 0) > 0 || + (dependsOn.input_groups?.length ?? 0) > 0 + ) + } + + /** + * Get input_groups prefixes for a node type (for watching connection changes). + */ + const getInputGroupPrefixes = (nodeType: string): string[] => { + const nodeDefStore = useNodeDefStore() + const nodeDef = nodeDefStore.nodeDefsByName[nodeType] + if (!nodeDef) return [] + + const priceBadge = nodeDef.price_badge + if (!priceBadge) return [] + + return priceBadge.depends_on?.input_groups ?? [] + } + + /** + * Get regular input names for a node type (for watching connection changes). + */ + const getInputNames = (nodeType: string): string[] => { + const nodeDefStore = useNodeDefStore() + const nodeDef = nodeDefStore.nodeDefsByName[nodeType] + if (!nodeDef) return [] + + const priceBadge = nodeDef.price_badge + if (!priceBadge) return [] + + return priceBadge.depends_on?.inputs ?? [] + } + + /** + * Trigger price recalculation for a node (call when inputs change). + * Forces re-evaluation by calling getNodeDisplayPrice which will detect + * the signature change and schedule a new evaluation. + */ + const triggerPriceRecalculation = (node: LGraphNode): void => { + const nodeData = getNodeConstructorData(node) + if (!nodeData?.api_node) return + + // Call getNodeDisplayPrice to trigger evaluation if signature changed + getNodeDisplayPrice(node) } return { getNodeDisplayPrice, getNodePricingConfig, - getRelevantWidgetNames + getRelevantWidgetNames, + hasDynamicPricing, + getInputGroupPrefixes, + getInputNames, + getNodeRevisionRef, // Each node has its own independent ref, so updates to one won't trigger others + triggerPriceRecalculation, + pricingRevision: readonly(pricingTick) // reactive invalidation signal } } diff --git a/src/renderer/extensions/vueNodes/components/NodeHeader.vue b/src/renderer/extensions/vueNodes/components/NodeHeader.vue index 0f24cf873..bb1bf3831 100644 --- a/src/renderer/extensions/vueNodes/components/NodeHeader.vue +++ b/src/renderer/extensions/vueNodes/components/NodeHeader.vue @@ -99,6 +99,7 @@ import { computed, onErrorCaptured, ref, toValue, watch } from 'vue' import EditableText from '@/components/common/EditableText.vue' import Button from '@/components/ui/button/Button.vue' import type { VueNodeData } from '@/composables/graph/useGraphNodeManager' +import { useNodePricing } from '@/composables/node/useNodePricing' import { useErrorHandling } from '@/composables/useErrorHandling' import { st } from '@/i18n' import { LGraphEventMode, RenderShape } from '@/lib/litegraph/src/litegraph' @@ -183,9 +184,67 @@ const statusBadge = computed((): NodeBadgeProps | undefined => : undefined ) -const nodeBadges = computed(() => - [...(nodeData?.badges ?? [])].map(toValue) +// Use per-node pricing revision to re-compute badges only when this node's pricing updates +const { + getRelevantWidgetNames, + hasDynamicPricing, + getInputGroupPrefixes, + getInputNames, + getNodeRevisionRef +} = useNodePricing() +// Cache pricing metadata (won't change during node lifetime) +const isDynamicPricing = computed(() => + nodeData?.apiNode ? hasDynamicPricing(nodeData.type) : false ) +const relevantPricingWidgets = computed(() => + nodeData?.apiNode ? getRelevantWidgetNames(nodeData.type) : [] +) +const inputGroupPrefixes = computed(() => + nodeData?.apiNode ? getInputGroupPrefixes(nodeData.type) : [] +) +const relevantInputNames = computed(() => + nodeData?.apiNode ? getInputNames(nodeData.type) : [] +) +const nodeBadges = computed(() => { + // For ALL API nodes: access per-node revision ref to detect when async pricing evaluation completes + // This is needed even for static pricing because JSONata 2.x evaluation is async + if (nodeData?.apiNode && nodeData?.id != null) { + // Access per-node revision ref to establish dependency (each node has its own ref) + void getNodeRevisionRef(nodeData.id).value + + // For dynamic pricing, also track widget values and input connections + if (isDynamicPricing.value) { + // Access only the widget values that affect pricing + const relevantNames = relevantPricingWidgets.value + if (relevantNames.length > 0) { + nodeData?.widgets?.forEach((w) => { + if (relevantNames.includes(w.name)) w.value + }) + } + // Access input connections for regular inputs + const inputNames = relevantInputNames.value + if (inputNames.length > 0) { + nodeData?.inputs?.forEach((inp) => { + if (inp.name && inputNames.includes(inp.name)) { + void inp.link // Access link to create reactive dependency + } + }) + } + // Access input connections for input_groups (e.g., autogrow inputs) + const groupPrefixes = inputGroupPrefixes.value + if (groupPrefixes.length > 0) { + nodeData?.inputs?.forEach((inp) => { + if ( + groupPrefixes.some((prefix) => inp.name?.startsWith(prefix + '.')) + ) { + void inp.link // Access link to create reactive dependency + } + }) + } + } + } + return [...(nodeData?.badges ?? [])].map(toValue) +}) const isPinned = computed(() => Boolean(nodeData?.flags?.pinned)) const isApiNode = computed(() => Boolean(nodeData?.apiNode)) diff --git a/src/schemas/nodeDefSchema.ts b/src/schemas/nodeDefSchema.ts index bb36da228..25f23697a 100644 --- a/src/schemas/nodeDefSchema.ts +++ b/src/schemas/nodeDefSchema.ts @@ -197,6 +197,50 @@ const zComfyOutputTypesSpec = z.array( z.union([zComfyNodeDataType, zComfyComboOutput]) ) +/** + * Widget dependency with type information. + * Provides strong type enforcement for JSONata evaluation context. + */ +const zWidgetDependency = z.object({ + name: z.string(), + type: z.string() +}) + +export type WidgetDependency = z.infer + +/** + * Schema for price badge depends_on field. + * Specifies which widgets and inputs the pricing expression depends on. + * Widgets must be specified as objects with name and type. + */ +const zPriceBadgeDepends = z.object({ + widgets: z.array(zWidgetDependency).optional().default([]), + inputs: z.array(z.string()).optional().default([]), + /** + * Autogrow input group names to track. + * For each group, the count of connected inputs will be available in the + * JSONata context as `g.`. + * Example: `input_groups: ["reference_videos"]` makes `g.reference_videos` + * available with the count of connected inputs like `reference_videos.character1`, etc. + */ + input_groups: z.array(z.string()).optional().default([]) +}) + +/** + * Schema for price badge definition. + * Used to calculate and display pricing information for API nodes. + * The `expr` field contains a JSONata expression that returns a PricingResult. + */ +const zPriceBadge = z.object({ + engine: z.literal('jsonata').optional().default('jsonata'), + depends_on: zPriceBadgeDepends + .optional() + .default({ widgets: [], inputs: [], input_groups: [] }), + expr: z.string() +}) + +export type PriceBadge = z.infer + export const zComfyNodeDef = z.object({ input: zComfyInputsSpec.optional(), output: zComfyOutputTypesSpec.optional(), @@ -224,7 +268,13 @@ export const zComfyNodeDef = z.object({ * Used to ensure consistent widget ordering regardless of JSON serialization. * Keys are 'required', 'optional', etc., values are arrays of input names. */ - input_order: z.record(z.array(z.string())).optional() + input_order: z.record(z.array(z.string())).optional(), + /** + * Price badge definition for API nodes. + * Contains a JSONata expression to calculate pricing based on widget values + * and input connectivity. + */ + price_badge: zPriceBadge.optional() }) export const zAutogrowOptions = z.object({ diff --git a/src/stores/nodeDefStore.ts b/src/stores/nodeDefStore.ts index 27a46ad21..217b784ca 100644 --- a/src/stores/nodeDefStore.ts +++ b/src/stores/nodeDefStore.ts @@ -14,7 +14,8 @@ import type { import type { ComfyInputsSpec as ComfyInputSpecV1, ComfyNodeDef as ComfyNodeDefV1, - ComfyOutputTypesSpec as ComfyOutputSpecV1 + ComfyOutputTypesSpec as ComfyOutputSpecV1, + PriceBadge } from '@/schemas/nodeDefSchema' import { NodeSearchService } from '@/services/nodeSearchService' import { useSubgraphStore } from '@/stores/subgraphStore' @@ -66,6 +67,12 @@ export class ComfyNodeDefImpl * Order of inputs for each category (required, optional, hidden) */ readonly input_order?: Record + /** + * Price badge definition for API nodes. + * Contains a JSONata expression to calculate pricing based on widget values + * and input connectivity. + */ + readonly price_badge?: PriceBadge // V2 fields readonly inputs: Record @@ -134,6 +141,7 @@ export class ComfyNodeDefImpl this.output_name = obj.output_name this.output_tooltips = obj.output_tooltips this.input_order = obj.input_order + this.price_badge = obj.price_badge // Initialize V2 fields const defV2 = transformNodeDefV1ToV2(obj) From a08ccb55c17e58838a7335da6661de7783c7b998 Mon Sep 17 00:00:00 2001 From: Simula_r <18093452+simula-r@users.noreply.github.com> Date: Thu, 22 Jan 2026 13:54:41 -0800 Subject: [PATCH 2/8] Workspaces 3 create a workspace (#8221) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Add workspace creation and management (create, edit, delete, leave, switch workspaces). Follow-up PR will add invite and membership flow. ## Changes - Workspace CRUD dialogs - Workspace switcher popover in topbar - Workspace settings panel - Subscription panel for workspace context ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-8221-Workspaces-3-create-a-workspace-2ef6d73d36508155975ffa6e315971ec) by [Unito](https://www.unito.io) --------- Co-authored-by: Claude Opus 4.5 --- src/components/common/WorkspaceProfilePic.vue | 43 ++ src/components/dialog/GlobalDialog.vue | 38 +- .../dialog/content/setting/WorkspacePanel.vue | 11 + .../content/setting/WorkspacePanelContent.vue | 163 +++++++ .../content/setting/WorkspaceSidebarItem.vue | 19 + .../CreateWorkspaceDialogContent.vue | 113 +++++ .../DeleteWorkspaceDialogContent.vue | 89 ++++ .../workspace/EditWorkspaceDialogContent.vue | 104 +++++ .../workspace/LeaveWorkspaceDialogContent.vue | 78 ++++ src/components/topbar/CurrentUserButton.vue | 48 +- .../topbar/CurrentUserPopoverWorkspace.vue | 337 ++++++++++++++ .../topbar/WorkspaceSwitcherPopover.vue | 166 +++++++ src/locales/en/main.json | 64 ++- .../components/SubscribeButton.vue | 6 +- .../components/SubscriptionPanel.vue | 381 +-------------- .../SubscriptionPanelContentLegacy.vue | 357 ++++++++++++++ .../SubscriptionPanelContentWorkspace.vue | 435 ++++++++++++++++++ .../composables/useSubscription.test.ts | 2 +- .../composables/useSubscription.ts | 6 +- .../components/SettingDialogContent.vue | 61 ++- .../settings/composables/useSettingUI.ts | 106 ++++- src/platform/workspace/api/workspaceApi.ts | 4 +- .../workspace/composables/useWorkspaceUI.ts | 125 +++++ .../stores/teamWorkspaceStore.test.ts | 23 +- .../workspace/stores/teamWorkspaceStore.ts | 152 ++++-- src/services/dialogService.ts | 76 ++- src/stores/firebaseAuthStore.ts | 3 +- src/views/GraphView.vue | 27 +- 28 files changed, 2595 insertions(+), 442 deletions(-) create mode 100644 src/components/common/WorkspaceProfilePic.vue create mode 100644 src/components/dialog/content/setting/WorkspacePanel.vue create mode 100644 src/components/dialog/content/setting/WorkspacePanelContent.vue create mode 100644 src/components/dialog/content/setting/WorkspaceSidebarItem.vue create mode 100644 src/components/dialog/content/workspace/CreateWorkspaceDialogContent.vue create mode 100644 src/components/dialog/content/workspace/DeleteWorkspaceDialogContent.vue create mode 100644 src/components/dialog/content/workspace/EditWorkspaceDialogContent.vue create mode 100644 src/components/dialog/content/workspace/LeaveWorkspaceDialogContent.vue create mode 100644 src/components/topbar/CurrentUserPopoverWorkspace.vue create mode 100644 src/components/topbar/WorkspaceSwitcherPopover.vue create mode 100644 src/platform/cloud/subscription/components/SubscriptionPanelContentLegacy.vue create mode 100644 src/platform/cloud/subscription/components/SubscriptionPanelContentWorkspace.vue create mode 100644 src/platform/workspace/composables/useWorkspaceUI.ts diff --git a/src/components/common/WorkspaceProfilePic.vue b/src/components/common/WorkspaceProfilePic.vue new file mode 100644 index 000000000..642317267 --- /dev/null +++ b/src/components/common/WorkspaceProfilePic.vue @@ -0,0 +1,43 @@ + + + diff --git a/src/components/dialog/GlobalDialog.vue b/src/components/dialog/GlobalDialog.vue index afc056d61..2a1f0ef3d 100644 --- a/src/components/dialog/GlobalDialog.vue +++ b/src/components/dialog/GlobalDialog.vue @@ -4,7 +4,12 @@ v-for="item in dialogStore.dialogStack" :key="item.key" v-model:visible="item.visible" - class="global-dialog" + :class="[ + 'global-dialog', + item.key === 'global-settings' && teamWorkspacesEnabled + ? 'settings-dialog-workspace' + : '' + ]" v-bind="item.dialogComponentProps" :pt="item.dialogComponentProps.pt" :aria-labelledby="item.key" @@ -38,7 +43,15 @@ @@ -55,4 +68,27 @@ const dialogStore = useDialogStore() @apply p-2 2xl:p-[var(--p-dialog-content-padding)]; @apply pt-0; } + +/* Workspace mode: wider settings dialog */ +.settings-dialog-workspace { + width: 100%; + max-width: 1440px; +} + +.settings-dialog-workspace .p-dialog-content { + width: 100%; +} + +.manager-dialog { + height: 80vh; + max-width: 1724px; + max-height: 1026px; +} + +@media (min-width: 3000px) { + .manager-dialog { + max-width: 2200px; + max-height: 1320px; + } +} diff --git a/src/components/dialog/content/setting/WorkspacePanel.vue b/src/components/dialog/content/setting/WorkspacePanel.vue new file mode 100644 index 000000000..aff8f3733 --- /dev/null +++ b/src/components/dialog/content/setting/WorkspacePanel.vue @@ -0,0 +1,11 @@ + + + diff --git a/src/components/dialog/content/setting/WorkspacePanelContent.vue b/src/components/dialog/content/setting/WorkspacePanelContent.vue new file mode 100644 index 000000000..9366a573f --- /dev/null +++ b/src/components/dialog/content/setting/WorkspacePanelContent.vue @@ -0,0 +1,163 @@ + + + diff --git a/src/components/dialog/content/setting/WorkspaceSidebarItem.vue b/src/components/dialog/content/setting/WorkspaceSidebarItem.vue new file mode 100644 index 000000000..cab92c7a8 --- /dev/null +++ b/src/components/dialog/content/setting/WorkspaceSidebarItem.vue @@ -0,0 +1,19 @@ + + + diff --git a/src/components/dialog/content/workspace/CreateWorkspaceDialogContent.vue b/src/components/dialog/content/workspace/CreateWorkspaceDialogContent.vue new file mode 100644 index 000000000..b9444ce58 --- /dev/null +++ b/src/components/dialog/content/workspace/CreateWorkspaceDialogContent.vue @@ -0,0 +1,113 @@ + + + diff --git a/src/components/dialog/content/workspace/DeleteWorkspaceDialogContent.vue b/src/components/dialog/content/workspace/DeleteWorkspaceDialogContent.vue new file mode 100644 index 000000000..dea2da18d --- /dev/null +++ b/src/components/dialog/content/workspace/DeleteWorkspaceDialogContent.vue @@ -0,0 +1,89 @@ + + + diff --git a/src/components/dialog/content/workspace/EditWorkspaceDialogContent.vue b/src/components/dialog/content/workspace/EditWorkspaceDialogContent.vue new file mode 100644 index 000000000..62b650a4e --- /dev/null +++ b/src/components/dialog/content/workspace/EditWorkspaceDialogContent.vue @@ -0,0 +1,104 @@ + + + diff --git a/src/components/dialog/content/workspace/LeaveWorkspaceDialogContent.vue b/src/components/dialog/content/workspace/LeaveWorkspaceDialogContent.vue new file mode 100644 index 000000000..6a3d16c36 --- /dev/null +++ b/src/components/dialog/content/workspace/LeaveWorkspaceDialogContent.vue @@ -0,0 +1,78 @@ + + + diff --git a/src/components/topbar/CurrentUserButton.vue b/src/components/topbar/CurrentUserButton.vue index 151dfd405..849a9ba25 100644 --- a/src/components/topbar/CurrentUserButton.vue +++ b/src/components/topbar/CurrentUserButton.vue @@ -1,4 +1,4 @@ - + diff --git a/src/components/topbar/WorkspaceSwitcherPopover.vue b/src/components/topbar/WorkspaceSwitcherPopover.vue new file mode 100644 index 000000000..c8236535d --- /dev/null +++ b/src/components/topbar/WorkspaceSwitcherPopover.vue @@ -0,0 +1,166 @@ + + + diff --git a/src/locales/en/main.json b/src/locales/en/main.json index fbb3b965f..e7daacbd5 100644 --- a/src/locales/en/main.json +++ b/src/locales/en/main.json @@ -1286,7 +1286,10 @@ "VueNodes": "Nodes 2.0", "Nodes 2_0": "Nodes 2.0", "Execution": "Execution", - "PLY": "PLY" + "PLY": "PLY", + "Workspace": "Workspace", + "General": "General", + "Other": "Other" }, "serverConfigItems": { "listen": { @@ -2010,6 +2013,8 @@ "renewsDate": "Renews {date}", "expiresDate": "Expires {date}", "manageSubscription": "Manage subscription", + "managePayment": "Manage Payment", + "cancelSubscription": "Cancel Subscription", "partnerNodesBalance": "\"Partner Nodes\" Credit Balance", "partnerNodesDescription": "For running commercial/proprietary models", "totalCredits": "Total credits", @@ -2064,6 +2069,9 @@ "subscribeToRunFull": "Subscribe to Run", "subscribeNow": "Subscribe Now", "subscribeToComfyCloud": "Subscribe to Comfy Cloud", + "workspaceNotSubscribed": "This workspace is not on a subscription", + "subscriptionRequiredMessage": "A subscription is required for members to run workflows on Cloud", + "contactOwnerToSubscribe": "Contact the workspace owner to subscribe", "description": "Choose the best plan for you", "haveQuestions": "Have questions or wondering about enterprise?", "contactUs": "Contact us", @@ -2099,12 +2107,64 @@ "userSettings": { "title": "My Account Settings", "accountSettings": "Account settings", + "workspaceSettings": "Workspace settings", "name": "Name", "email": "Email", "provider": "Sign-in Provider", "notSet": "Not set", "updatePassword": "Update Password" }, + "workspacePanel": { + "tabs": { + "planCredits": "Plan & Credits" + }, + "menu": { + "editWorkspace": "Edit workspace details", + "leaveWorkspace": "Leave Workspace", + "deleteWorkspace": "Delete Workspace", + "deleteWorkspaceDisabledTooltip": "Cancel your workspace's active subscription first" + }, + "editWorkspaceDialog": { + "title": "Edit workspace details", + "nameLabel": "Workspace name", + "save": "Save" + }, + "leaveDialog": { + "title": "Leave this workspace?", + "message": "You won't be able to join again unless you contact the workspace owner.", + "leave": "Leave" + }, + "deleteDialog": { + "title": "Delete this workspace?", + "message": "Any unused credits or unsaved assets will be lost. This action cannot be undone.", + "messageWithName": "Delete \"{name}\"? Any unused credits or unsaved assets will be lost. This action cannot be undone." + }, + "createWorkspaceDialog": { + "title": "Create a new workspace", + "message": "Workspaces let members share a single credits pool. You'll become the owner after creating this.", + "nameLabel": "Workspace name*", + "namePlaceholder": "Enter workspace name", + "create": "Create" + }, + "toast": { + "workspaceUpdated": { + "title": "Workspace updated", + "message": "Workspace details have been saved." + }, + "failedToUpdateWorkspace": "Failed to update workspace", + "failedToCreateWorkspace": "Failed to create workspace", + "failedToDeleteWorkspace": "Failed to delete workspace", + "failedToLeaveWorkspace": "Failed to leave workspace" + } + }, + "workspaceSwitcher": { + "switchWorkspace": "Switch workspace", + "subscribe": "Subscribe", + "roleOwner": "Owner", + "roleMember": "Member", + "createWorkspace": "Create new workspace", + "maxWorkspacesReached": "You can only own 10 workspaces. Delete one to create a new one." + }, "selectionToolbox": { "executeButton": { "tooltip": "Execute to selected output nodes (Highlighted with orange border)", @@ -2667,4 +2727,4 @@ "tooltip": "You are using a nightly version of ComfyUI. Please use the feedback button to share your thoughts about these features." } } -} \ No newline at end of file +} diff --git a/src/platform/cloud/subscription/components/SubscribeButton.vue b/src/platform/cloud/subscription/components/SubscribeButton.vue index 1cf5096ce..8b7f05e79 100644 --- a/src/platform/cloud/subscription/components/SubscribeButton.vue +++ b/src/platform/cloud/subscription/components/SubscribeButton.vue @@ -2,7 +2,7 @@ - - - - - - -
-
-
-
- - -
-
- {{ $t('subscription.totalCredits') }} -
- -
- {{ totalCredits }} -
-
- - - - - - - - - - - - - -
- - {{ includedCreditsDisplay }} - - {{ creditsRemainingLabel }} -
- - {{ prepaidCredits }} - - {{ $t('subscription.creditsYouveAdded') }} -
- -
- - {{ $t('subscription.viewUsageHistory') }} - - -
-
-
-
- -
-
- {{ $t('subscription.yourPlanIncludes') }} -
- -
-
- - - {{ benefit.value }} - - - {{ benefit.label }} - -
-
-
-
- - - - - + + + +
- - diff --git a/src/platform/cloud/subscription/components/SubscriptionPanelContentLegacy.vue b/src/platform/cloud/subscription/components/SubscriptionPanelContentLegacy.vue new file mode 100644 index 000000000..a6d5f063b --- /dev/null +++ b/src/platform/cloud/subscription/components/SubscriptionPanelContentLegacy.vue @@ -0,0 +1,357 @@ + + + + + diff --git a/src/platform/cloud/subscription/components/SubscriptionPanelContentWorkspace.vue b/src/platform/cloud/subscription/components/SubscriptionPanelContentWorkspace.vue new file mode 100644 index 000000000..7605849e8 --- /dev/null +++ b/src/platform/cloud/subscription/components/SubscriptionPanelContentWorkspace.vue @@ -0,0 +1,435 @@ + + + + + diff --git a/src/platform/cloud/subscription/composables/useSubscription.test.ts b/src/platform/cloud/subscription/composables/useSubscription.test.ts index 9409709d3..ffa370a90 100644 --- a/src/platform/cloud/subscription/composables/useSubscription.test.ts +++ b/src/platform/cloud/subscription/composables/useSubscription.test.ts @@ -64,7 +64,7 @@ vi.mock('@/services/dialogService', () => ({ vi.mock('@/stores/firebaseAuthStore', () => ({ useFirebaseAuthStore: vi.fn(() => ({ - getAuthHeader: mockGetAuthHeader + getFirebaseAuthHeader: mockGetAuthHeader })), FirebaseAuthStoreError: class extends Error {} })) diff --git a/src/platform/cloud/subscription/composables/useSubscription.ts b/src/platform/cloud/subscription/composables/useSubscription.ts index e80c3642e..f5948ee69 100644 --- a/src/platform/cloud/subscription/composables/useSubscription.ts +++ b/src/platform/cloud/subscription/composables/useSubscription.ts @@ -38,7 +38,7 @@ function useSubscriptionInternal() { const { reportError, accessBillingPortal } = useFirebaseAuthActions() const { showSubscriptionRequiredDialog } = useDialogService() - const { getAuthHeader } = useFirebaseAuthStore() + const { getFirebaseAuthHeader } = useFirebaseAuthStore() const { wrapWithErrorHandlingAsync } = useErrorHandling() const { isLoggedIn } = useCurrentUser() @@ -168,7 +168,7 @@ function useSubscriptionInternal() { * @returns Subscription status or null if no subscription exists */ async function fetchSubscriptionStatus(): Promise { - const authHeader = await getAuthHeader() + const authHeader = await getFirebaseAuthHeader() if (!authHeader) { throw new FirebaseAuthStoreError(t('toastMessages.userNotAuthenticated')) } @@ -217,7 +217,7 @@ function useSubscriptionInternal() { const initiateSubscriptionCheckout = async (): Promise => { - const authHeader = await getAuthHeader() + const authHeader = await getFirebaseAuthHeader() if (!authHeader) { throw new FirebaseAuthStoreError( t('toastMessages.userNotAuthenticated') diff --git a/src/platform/settings/components/SettingDialogContent.vue b/src/platform/settings/components/SettingDialogContent.vue index 13da10437..628301735 100644 --- a/src/platform/settings/components/SettingDialogContent.vue +++ b/src/platform/settings/components/SettingDialogContent.vue @@ -1,6 +1,18 @@