From 398dc6d8a670c3085cbbe8a54f7e2bc94e586ce2 Mon Sep 17 00:00:00 2001 From: Christian Byrne Date: Wed, 4 Jun 2025 02:04:24 -0700 Subject: [PATCH] [feat] Add dynamic pricing for API nodes with real-time updates (#3963) Co-authored-by: Claude --- src/composables/node/useNodeBadge.ts | 33 +- src/composables/node/useNodePricing.ts | 1319 ++++++++++++----- src/composables/node/useWatchWidget.ts | 85 ++ .../composables/node/useNodePricing.test.ts | 802 ++++++++++ .../tests/composables/useWatchWidget.test.ts | 183 +++ 5 files changed, 2010 insertions(+), 412 deletions(-) create mode 100644 src/composables/node/useWatchWidget.ts create mode 100644 tests-ui/tests/composables/node/useNodePricing.test.ts create mode 100644 tests-ui/tests/composables/useWatchWidget.test.ts diff --git a/src/composables/node/useNodeBadge.ts b/src/composables/node/useNodeBadge.ts index b34da8ed9..736349853 100644 --- a/src/composables/node/useNodeBadge.ts +++ b/src/composables/node/useNodeBadge.ts @@ -7,6 +7,7 @@ import _ from 'lodash' import { computed, onMounted, watch } from 'vue' import { useNodePricing } from '@/composables/node/useNodePricing' +import { useComputedWithWidgetWatch } from '@/composables/node/useWatchWidget' import { app } from '@/scripts/app' import { useExtensionStore } from '@/stores/extensionStore' import { ComfyNodeDefImpl, useNodeDefStore } from '@/stores/nodeDefStore' @@ -111,10 +112,15 @@ export const useNodeBadge = () => { node.badges.push(() => badge.value) if (node.constructor.nodeData?.api_node && showApiPricingBadge.value) { - const price = nodePricing.getNodeDisplayPrice(node) - // Always add the badge for API nodes, with or without price text - const creditsBadge = computed(() => { - // Use dynamic background color based on the theme + // Get the pricing function to determine if this node has dynamic pricing + const pricingConfig = nodePricing.getNodePricingConfig(node) + const hasDynamicPricing = + typeof pricingConfig?.displayPrice === 'function' + + let creditsBadge + const createBadge = () => { + const price = nodePricing.getNodeDisplayPrice(node) + const isLightTheme = colorPaletteStore.completedActivePalette.light_theme return new LGraphBadge({ @@ -137,7 +143,24 @@ export const useNodeBadge = () => { ? adjustColor('#8D6932', { lightness: 0.5 }) : '#8D6932' }) - }) + } + + if (hasDynamicPricing) { + // For dynamic pricing nodes, use computed that watches widget changes + const relevantWidgetNames = nodePricing.getRelevantWidgetNames( + node.constructor.nodeData?.name + ) + + const computedWithWidgetWatch = useComputedWithWidgetWatch(node, { + widgetNames: relevantWidgetNames, + triggerCanvasRedraw: true + }) + + creditsBadge = computedWithWidgetWatch(createBadge) + } else { + // For static pricing nodes, use regular computed + creditsBadge = computed(createBadge) + } node.badges.push(() => creditsBadge.value) } diff --git a/src/composables/node/useNodePricing.ts b/src/composables/node/useNodePricing.ts index 7bf92d928..3e8891fd8 100644 --- a/src/composables/node/useNodePricing.ts +++ b/src/composables/node/useNodePricing.ts @@ -1,426 +1,931 @@ import type { LGraphNode } from '@comfyorg/litegraph' +import type { IComboWidget } from '@comfyorg/litegraph/dist/types/widgets' -import { ApiNodeCostRecord } from '@/types/apiNodeTypes' +/** + * Function that calculates dynamic pricing based on node widget values + */ +type PricingFunction = (node: LGraphNode) => string -const apiNodeCosts: ApiNodeCostRecord = { - FluxProCannyNode: { - vendor: 'BFL', - nodeName: 'Flux 1: Canny Control Image', - pricingParams: '-', - pricePerRunRange: '$0.05', - displayPrice: '$0.05/Run' - }, - FluxProDepthNode: { - vendor: 'BFL', - nodeName: 'Flux 1: Depth Control Image', - pricingParams: '-', - pricePerRunRange: '$0.05', - displayPrice: '$0.05/Run' - }, - FluxProExpandNode: { - vendor: 'BFL', - nodeName: 'Flux 1: Expand Image', - pricingParams: '-', - pricePerRunRange: '$0.05', - rateDocumentationUrl: 'https://docs.bfl.ml/pricing/', - displayPrice: '$0.05/Run' - }, - FluxProFillNode: { - vendor: 'BFL', - nodeName: 'Flux 1: Fill Image', - pricingParams: '-', - pricePerRunRange: '$0.05', - displayPrice: '$0.05/Run' - }, - FluxProUltraImageNode: { - vendor: 'BFL', - nodeName: 'Flux 1.1: [pro] Ultra Image', - pricingParams: '-', - pricePerRunRange: '$0.06', - displayPrice: '$0.06/Run' - }, - IdeogramV1: { - vendor: 'Ideogram', - nodeName: 'Ideogram V1', - pricingParams: '-', - pricePerRunRange: '$0.06', - rateDocumentationUrl: 'https://about.ideogram.ai/api-pricing', - displayPrice: '$0.06/Run' - }, - IdeogramV2: { - vendor: 'Ideogram', - nodeName: 'Ideogram V2', - pricingParams: '-', - pricePerRunRange: '$0.08', - displayPrice: '$0.08/Run' - }, - IdeogramV3: { - vendor: 'Ideogram', - nodeName: 'Ideogram V3', - pricingParams: 'rendering_speed', - pricePerRunRange: 'dynamic', - displayPrice: 'Variable pricing (low to medium)' - }, - KlingCameraControlI2VNode: { - vendor: 'Kling', - nodeName: 'Kling Image to Video (Camera Control)', - pricingParams: '-', - pricePerRunRange: '$0.49', - displayPrice: '$0.49/Run' - }, - KlingCameraControlT2VNode: { - vendor: 'Kling', - nodeName: 'Kling Text to Video (Camera Control)', - pricingParams: '-', - pricePerRunRange: '$0.14', - displayPrice: '$0.14/Run' - }, - KlingDualCharacterVideoEffectNode: { - vendor: 'Kling', - nodeName: 'Kling Dual Character Video Effects', - pricingParams: 'Priced the same as t2v based on mode, model, and duration.', - pricePerRunRange: 'dynamic', - displayPrice: 'Variable pricing (medium)' - }, - KlingImage2VideoNode: { - vendor: 'Kling', - nodeName: 'Kling Image to Video', - pricingParams: 'Same as Text to Video', - pricePerRunRange: 'dynamic', - displayPrice: 'Variable pricing (medium)' - }, - KlingImageGenerationNode: { - vendor: 'Kling', - nodeName: 'Kling Image Generation', - pricingParams: 'modality | model', - pricePerRunRange: 'dynamic', - displayPrice: 'Variable pricing (low)' - }, - KlingLipSyncAudioToVideoNode: { - vendor: 'Kling', - nodeName: 'Kling Lip Sync Video with Audio', - pricingParams: 'duration of input video', - pricePerRunRange: '$0.07', - displayPrice: '$0.07/Run' - }, - KlingLipSyncTextToVideoNode: { - vendor: 'Kling', - nodeName: 'Kling Lip Sync Video with Text', - pricingParams: 'duration of input video', - pricePerRunRange: '$0.07', - displayPrice: '$0.07/Run' - }, - KlingSingleImageVideoEffectNode: { - vendor: 'Kling', - nodeName: 'Kling Video Effects', - pricingParams: 'effect_scene', - pricePerRunRange: 'dynamic', - displayPrice: 'Variable pricing (medium)' - }, - KlingStartEndFrameNode: { - vendor: 'Kling', - nodeName: 'Kling Start-End Frame to Video', - pricingParams: 'Same as text to video', - pricePerRunRange: 'dynamic', - displayPrice: 'Variable pricing (medium)' - }, - KlingTextToVideoNode: { - vendor: 'Kling', - nodeName: 'Kling Text to Video', - pricingParams: 'model | duration | mode', - pricePerRunRange: 'dynamic', - displayPrice: 'Variable pricing (medium to high)' - }, - KlingVideoExtendNode: { - vendor: 'Kling', - nodeName: 'Kling Video Extend', - pricingParams: '-', - pricePerRunRange: '$0.28', - displayPrice: '$0.28/Run' - }, - KlingVirtualTryOnNode: { - vendor: 'Kling', - nodeName: 'Kling Virtual Try On', - pricingParams: '-', - pricePerRunRange: '$0.07', - displayPrice: '$0.07/Run' - }, - LumaImageToVideoNode: { - vendor: 'Luma', - nodeName: 'Luma Image to Video', - pricingParams: 'Same as Text to Video', - pricePerRunRange: 'dynamic', - rateDocumentationUrl: 'https://lumalabs.ai/api/pricing', - displayPrice: 'Variable pricing (medium to high)' - }, - LumaVideoNode: { - vendor: 'Luma', - nodeName: 'Luma Text to Video', - pricingParams: 'model | resolution | duration', - pricePerRunRange: 'dynamic', - rateDocumentationUrl: 'https://lumalabs.ai/api/pricing', - displayPrice: 'Variable pricing (medium to high)' - }, - MinimaxImageToVideoNode: { - vendor: 'Minimax', - nodeName: 'MiniMax Image to Video', - pricingParams: '-', - pricePerRunRange: '$0.43', - rateDocumentationUrl: 'https://www.minimax.io/price', - displayPrice: '$0.43/Run' - }, - MinimaxTextToVideoNode: { - vendor: 'Minimax', - nodeName: 'MiniMax Text to Video', - pricingParams: '-', - pricePerRunRange: '$0.43', - rateDocumentationUrl: 'https://www.minimax.io/price', - displayPrice: '$0.43/Run' - }, - OpenAIDalle2: { - vendor: 'OpenAI', - nodeName: 'dall-e-2', - pricingParams: 'size', - pricePerRunRange: 'dynamic', - rateDocumentationUrl: 'https://platform.openai.com/docs/pricing', - displayPrice: 'Variable pricing (low)' - }, - OpenAIDalle3: { - vendor: 'OpenAI', - nodeName: 'dall-e-3', - pricingParams: 'size | quality', - pricePerRunRange: 'dynamic', - rateDocumentationUrl: 'https://platform.openai.com/docs/pricing', - displayPrice: 'Variable pricing (medium)' - }, - OpenAIGPTImage1: { - vendor: 'OpenAI', - nodeName: 'gpt-image-1', - pricingParams: 'quality', - pricePerRunRange: 'dynamic', - rateDocumentationUrl: 'https://platform.openai.com/docs/pricing', - displayPrice: 'Variable pricing (low to high)' - }, - PikaImageToVideoNode2_2: { - vendor: 'Pika', - nodeName: 'Pika Image to Video', - pricingParams: 'duration | resolution', - pricePerRunRange: 'dynamic', - displayPrice: 'Variable pricing (medium)' - }, - PikaScenesV2_2: { - vendor: 'Pika', - nodeName: 'Pika Scenes (Video Image Composition)', - pricingParams: 'duration | resolution', - pricePerRunRange: 'dynamic', - displayPrice: 'Variable pricing (medium)' - }, - PikaStartEndFrameNode2_2: { - vendor: 'Pika', - nodeName: 'Pika Start and End Frame to Video', - pricingParams: 'duration | resolution', - pricePerRunRange: 'dynamic', - displayPrice: 'Variable pricing (medium)' - }, - PikaTextToVideoNode2_2: { - vendor: 'Pika', - nodeName: 'Pika Text to Video', - pricingParams: 'duration | resolution', - pricePerRunRange: 'dynamic', - displayPrice: 'Variable pricing (medium)' - }, - Pikadditions: { - vendor: 'Pika', - nodeName: 'Pikadditions (Video Object Insertion)', - pricingParams: '-', - pricePerRunRange: '$0.3', - displayPrice: '$0.3/Run' - }, - Pikaffects: { - vendor: 'Pika', - nodeName: 'Pikaffects (Video Effects)', - pricingParams: '-', - pricePerRunRange: '$0.45', - displayPrice: '$0.45/Run' - }, - Pikaswaps: { - vendor: 'Pika', - nodeName: 'Pika Swaps (Video Object Replacement)', - pricingParams: '-', - pricePerRunRange: '$0.3', - displayPrice: '$0.3/Run' - }, - PixverseImageToVideoNode: { - vendor: 'Pixverse', - nodeName: 'PixVerse Image to Video', - pricingParams: 'same as text to video', - pricePerRunRange: '$0.9', - displayPrice: '$0.9/Run' - }, - PixverseTextToVideoNode: { - vendor: 'Pixverse', - nodeName: 'PixVerse Text to Video', - pricingParams: 'duration | quality | motion_mode', - pricePerRunRange: 'dynamic', - displayPrice: 'Variable pricing (medium to high)' - }, - PixverseTransitionVideoNode: { - vendor: 'Pixverse', - nodeName: 'PixVerse Transition Video', - pricingParams: 'same as text to video', - pricePerRunRange: '$0.9', - displayPrice: '$0.9/Run' - }, - RecraftCreativeUpscaleNode: { - vendor: 'Recraft', - nodeName: 'Recraft Creative Upscale Image', - pricingParams: '-', - pricePerRunRange: '$0.25', - rateDocumentationUrl: 'https://www.recraft.ai/docs#pricing', - displayPrice: '$0.25/Run' - }, - RecraftCrispUpscaleNode: { - vendor: 'Recraft', - nodeName: 'Recraft Crisp Upscale Image', - pricingParams: '-', - pricePerRunRange: '$0.004', - rateDocumentationUrl: 'https://www.recraft.ai/docs#pricing', - displayPrice: '$0.004/Run' - }, - RecraftImageInpaintingNode: { - vendor: 'Recraft', - nodeName: 'Recraft Image Inpainting', - pricingParams: 'n', - pricePerRunRange: '$$0.04 x n', - rateDocumentationUrl: 'https://www.recraft.ai/docs#pricing', - displayPrice: '$0.04 x n/Run' - }, - RecraftImageToImageNode: { - vendor: 'Recraft', - nodeName: 'Recraft Image to Image', - pricingParams: 'n', - pricePerRunRange: '$0.04 x n', - rateDocumentationUrl: 'https://www.recraft.ai/docs#pricing', - displayPrice: '$0.04 x n/Run' - }, - RecraftRemoveBackgroundNode: { - vendor: 'Recraft', - nodeName: 'Recraft Remove Background', - pricingParams: '-', - pricePerRunRange: '$0.01', - rateDocumentationUrl: 'https://www.recraft.ai/docs#pricing', - displayPrice: '$0.01/Run' - }, - RecraftReplaceBackgroundNode: { - vendor: 'Recraft', - nodeName: 'Recraft Replace Background', - pricingParams: 'n', - pricePerRunRange: '$0.04', - rateDocumentationUrl: 'https://www.recraft.ai/docs#pricing', - displayPrice: '$0.04/Run' - }, - RecraftTextToImageNode: { - vendor: 'Recraft', - nodeName: 'Recraft Text to Image', - pricingParams: 'model | n', - pricePerRunRange: '$0.04 x n', - rateDocumentationUrl: 'https://www.recraft.ai/docs#pricing', - displayPrice: '$0.04 x n/Run' - }, - RecraftTextToVectorNode: { - vendor: 'Recraft', - nodeName: 'Recraft Text to Vector', - pricingParams: 'model | n', - pricePerRunRange: '$0.08 x n', - rateDocumentationUrl: 'https://www.recraft.ai/docs#pricing', - displayPrice: '$0.08 x n/Run' - }, - RecraftVectorizeImageNode: { - vendor: 'Recraft', - nodeName: 'Recraft Vectorize Image', - pricingParams: '-', - pricePerRunRange: '$0.01', - rateDocumentationUrl: 'https://www.recraft.ai/docs#pricing', - displayPrice: '$0.01/Run' - }, - StabilityStableImageSD_3_5Node: { - vendor: 'Stability', - nodeName: 'Stability AI Stable Diffusion 3.5 Image', - pricingParams: 'model', - pricePerRunRange: 'dynamic', - displayPrice: 'Variable pricing (low)' - }, - StabilityStableImageUltraNode: { - vendor: 'Stability', - nodeName: 'Stability AI Stable Image Ultra', - pricingParams: '-', - pricePerRunRange: '$0.08', - displayPrice: '$0.08/Run' - }, - StabilityUpscaleConservativeNode: { - vendor: 'Stability', - nodeName: 'Stability AI Upscale Conservative', - pricingParams: '-', - pricePerRunRange: '$0.25', - displayPrice: '$0.25/Run' - }, - StabilityUpscaleCreativeNode: { - vendor: 'Stability', - nodeName: 'Stability AI Upscale Creative', - pricingParams: '-', - pricePerRunRange: '$0.25', - displayPrice: '$0.25/Run' - }, - StabilityUpscaleFastNode: { - vendor: 'Stability', - nodeName: 'Stability AI Upscale Fast', - pricingParams: '-', - pricePerRunRange: '$0.01', - displayPrice: '$0.01/Run' - }, - VeoVideoGenerationNode: { - vendor: 'Veo', - nodeName: 'Google Veo2 Video Generation', - pricingParams: 'duration_seconds', - pricePerRunRange: 'dynamic', - rateDocumentationUrl: - 'https://cloud.google.com/vertex-ai/generative-ai/pricing', - displayPrice: 'Variable pricing (high)' - }, - LumaTextToImageNode: { - vendor: 'Luma', - nodeName: 'Luma Text to Image', - pricingParams: 'model | aspect_ratio', - pricePerRunRange: 'dynamic', - rateDocumentationUrl: 'https://lumalabs.ai/api/pricing', - displayPrice: 'Variable pricing (low to medium)' - }, - LumaImageToImageNode: { - vendor: 'Luma', - nodeName: 'Luma Image to Image', - pricingParams: 'Same as Text to Image', - pricePerRunRange: 'dynamic', - rateDocumentationUrl: 'https://lumalabs.ai/api/pricing', - displayPrice: 'Variable pricing (low to medium)' +/** + * 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 + ) + } + return fallback } } +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 '$0.45-1.2/Run (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 '$1.2/Run' + if (quality.includes('720p') && motionMode?.includes('fast')) + return '$1.2/Run' + if (quality.includes('720p') && motionMode?.includes('normal')) + return '$0.6/Run' + if (quality.includes('540p') && motionMode?.includes('fast')) + return '$0.9/Run' + if (quality.includes('540p') && motionMode?.includes('normal')) + return '$0.45/Run' + if (quality.includes('360p') && motionMode?.includes('fast')) + return '$0.9/Run' + if (quality.includes('360p') && motionMode?.includes('normal')) + return '$0.45/Run' + if (quality.includes('720p') && motionMode?.includes('fast')) + return '$1.2/Run' + } else if (duration.includes('8')) { + if (quality.includes('720p') && motionMode?.includes('normal')) + return '$1.2/Run' + if (quality.includes('540p') && motionMode?.includes('normal')) + return '$0.9/Run' + if (quality.includes('540p') && motionMode?.includes('fast')) + return '$1.2/Run' + if (quality.includes('360p') && motionMode?.includes('normal')) + return '$0.9/Run' + if (quality.includes('360p') && motionMode?.includes('fast')) + return '$1.2/Run' + if (quality.includes('1080p') && motionMode?.includes('normal')) + return '$1.2/Run' + if (quality.includes('1080p') && motionMode?.includes('fast')) + return '$1.2/Run' + if (quality.includes('720p') && motionMode?.includes('normal')) + return '$1.2/Run' + if (quality.includes('720p') && motionMode?.includes('fast')) + return '$1.2/Run' + } + + return '$0.9/Run' +} + +/** + * Static pricing data for API nodes, now supporting both strings and functions + */ +const apiNodeCosts: Record = + { + FluxProCannyNode: { + displayPrice: '$0.05/Run' + }, + FluxProDepthNode: { + displayPrice: '$0.05/Run' + }, + FluxProExpandNode: { + displayPrice: '$0.05/Run' + }, + FluxProFillNode: { + displayPrice: '$0.05/Run' + }, + FluxProUltraImageNode: { + displayPrice: '$0.06/Run' + }, + IdeogramV1: { + displayPrice: '$0.06/Run' + }, + IdeogramV2: { + displayPrice: '$0.08/Run' + }, + IdeogramV3: { + displayPrice: (node: LGraphNode): string => { + const renderingSpeedWidget = node.widgets?.find( + (w) => w.name === 'rendering_speed' + ) as IComboWidget + + if (!renderingSpeedWidget) + return '$0.03-0.08/Run (varies with rendering speed)' + + const renderingSpeed = String(renderingSpeedWidget.value) + if (renderingSpeed.toLowerCase().includes('quality')) { + return '$0.08/Run' + } else if (renderingSpeed.toLowerCase().includes('balanced')) { + return '$0.06/Run' + } else if (renderingSpeed.toLowerCase().includes('turbo')) { + return '$0.03/Run' + } + + return '$0.06/Run' + } + }, + KlingCameraControlI2VNode: { + displayPrice: '$0.49/Run' + }, + KlingCameraControlT2VNode: { + displayPrice: '$0.14/Run' + }, + 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 '$0.14-2.80/Run (varies with model, mode & duration)' + + const modeValue = String(modeWidget.value) + const durationValue = String(durationWidget.value) + const modelValue = String(modelWidget.value) + console.log('modelValue', modelValue) + console.log('modeValue', modeValue) + console.log('durationValue', durationValue) + + // Same pricing matrix as KlingTextToVideoNode + if (modelValue.includes('v1-6') || modelValue.includes('v1-5')) { + if (modeValue.includes('pro')) { + return durationValue.includes('10') ? '$0.98/Run' : '$0.49/Run' + } else { + return durationValue.includes('10') ? '$0.56/Run' : '$0.28/Run' + } + } else if (modelValue.includes('v1')) { + if (modeValue.includes('pro')) { + return durationValue.includes('10') ? '$0.98/Run' : '$0.49/Run' + } else { + return durationValue.includes('10') ? '$0.28/Run' : '$0.14/Run' + } + } + + return '$0.14/Run' + } + }, + 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 '$0.14-2.80/Run (varies with model, mode & duration)' + + const modelValue = String(modelWidget.value) + if (modelValue.includes('v2-master')) { + return '$1.40/Run' + } else if ( + modelValue.includes('v1-6') || + modelValue.includes('v1-5') + ) { + return '$0.28/Run' + } + return '$0.14/Run' + } + + const modeValue = String(modeWidget.value) + const durationValue = String(durationWidget.value) + const modelValue = String(modelWidget.value) + console.log('modelValue', modelValue) + console.log('modeValue', modeValue) + console.log('durationValue', durationValue) + + // Same pricing matrix as KlingTextToVideoNode + if (modelValue.includes('v2-master')) { + if (durationValue.includes('10')) { + return '$2.80/Run' + } + return '$1.40/Run' // 5s default + } else if (modelValue.includes('v1-6') || modelValue.includes('v1-5')) { + if (modeValue.includes('pro')) { + return durationValue.includes('10') ? '$0.98/Run' : '$0.49/Run' + } else { + return durationValue.includes('10') ? '$0.56/Run' : '$0.28/Run' + } + } else if (modelValue.includes('v1')) { + if (modeValue.includes('pro')) { + return durationValue.includes('10') ? '$0.98/Run' : '$0.49/Run' + } else { + return durationValue.includes('10') ? '$0.28/Run' : '$0.14/Run' + } + } + + return '$0.14/Run' + } + }, + 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 + + if (!modelWidget) + return '$0.0035-0.028/Run (varies with modality & model)' + + const model = String(modelWidget.value) + + if (modality.includes('text to image')) { + if (model.includes('kling-v1')) { + return '$0.0035/Run' + } else if ( + model.includes('kling-v1-5') || + model.includes('kling-v2') + ) { + return '$0.014/Run' + } + } else if (modality.includes('image to image')) { + if (model.includes('kling-v1')) { + return '$0.0035/Run' + } else if (model.includes('kling-v1-5')) { + return '$0.028/Run' + } + } + + return '$0.014/Run' + } + }, + KlingLipSyncAudioToVideoNode: { + displayPrice: '~$0.10/Run' + }, + KlingLipSyncTextToVideoNode: { + displayPrice: '~$0.10/Run' + }, + KlingSingleImageVideoEffectNode: { + displayPrice: (node: LGraphNode): string => { + const effectSceneWidget = node.widgets?.find( + (w) => w.name === 'effect_scene' + ) as IComboWidget + + if (!effectSceneWidget) + return '$0.28-0.49/Run (varies with effect scene)' + + const effectScene = String(effectSceneWidget.value) + if ( + effectScene.includes('fuzzyfuzzy') || + effectScene.includes('squish') || + effectScene.includes('expansion') + ) { + return '$0.28/Run' + } else if ( + effectScene.includes('dizzydizzy') || + effectScene.includes('bloombloom') + ) { + return '$0.49/Run' + } + + return '$0.28/Run' + } + }, + 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 '$0.14-2.80/Run (varies with model, mode & duration)' + + const modeValue = String(modeWidget.value) + + // Same pricing matrix as KlingTextToVideoNode + if (modeValue.includes('v2-master')) { + if (modeValue.includes('10s')) { + return '$2.80/Run' + } + return '$1.40/Run' // 5s default + } else if (modeValue.includes('v1-6')) { + if (modeValue.includes('pro')) { + return modeValue.includes('10s') ? '$0.98/Run' : '$0.49/Run' + } else { + return modeValue.includes('10s') ? '$0.56/Run' : '$0.28/Run' + } + } else if (modeValue.includes('v1')) { + if (modeValue.includes('pro')) { + return modeValue.includes('10s') ? '$0.98/Run' : '$0.49/Run' + } else { + return modeValue.includes('10s') ? '$0.28/Run' : '$0.14/Run' + } + } + + return '$0.14/Run' + } + }, + KlingTextToVideoNode: { + displayPrice: (node: LGraphNode): string => { + const modeWidget = node.widgets?.find( + (w) => w.name === 'mode' + ) as IComboWidget + if (!modeWidget) + return '$0.14-2.80/Run (varies with model, mode & duration)' + + const modeValue = String(modeWidget.value) + + // Pricing matrix from CSV data based on mode string content + if (modeValue.includes('v2-master')) { + if (modeValue.includes('10s')) { + return '$2.80/Run' + } + return '$1.40/Run' // 5s default + } else if (modeValue.includes('v1-6')) { + if (modeValue.includes('pro')) { + return modeValue.includes('10s') ? '$0.98/Run' : '$0.49/Run' + } else { + return modeValue.includes('10s') ? '$0.56/Run' : '$0.28/Run' + } + } else if (modeValue.includes('v1')) { + if (modeValue.includes('pro')) { + return modeValue.includes('10s') ? '$0.98/Run' : '$0.49/Run' + } else { + return modeValue.includes('10s') ? '$0.28/Run' : '$0.14/Run' + } + } + + return '$0.14/Run' + } + }, + KlingVideoExtendNode: { + displayPrice: '$0.28/Run' + }, + KlingVirtualTryOnNode: { + displayPrice: '$0.07/Run' + }, + 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 '$0.14-11.47/Run (varies with model, resolution & duration)' + } + + const model = String(modelWidget.value) + const resolution = String(resolutionWidget.value).toLowerCase() + const duration = String(durationWidget.value) + console.log('model', model) + console.log('resolution', resolution) + console.log('duration', duration) + + if (model.includes('ray-flash-2')) { + if (duration.includes('5s')) { + if (resolution.includes('4k')) return '$2.19/Run' + if (resolution.includes('1080p')) return '$0.55/Run' + if (resolution.includes('720p')) return '$0.24/Run' + if (resolution.includes('540p')) return '$0.14/Run' + } else if (duration.includes('9s')) { + if (resolution.includes('4k')) return '$3.95/Run' + if (resolution.includes('1080p')) return '$0.99/Run' + if (resolution.includes('720p')) return '$0.43/Run' + if (resolution.includes('540p')) return '$0.252/Run' + } + } else if (model.includes('ray-2')) { + if (duration.includes('5s')) { + if (resolution.includes('4k')) return '$6.37/Run' + if (resolution.includes('1080p')) return '$2.30/Run' + if (resolution.includes('720p')) return '$0.71/Run' + if (resolution.includes('540p')) return '$0.40/Run' + } else if (duration.includes('9s')) { + if (resolution.includes('4k')) return '$11.47/Run' + if (resolution.includes('1080p')) return '$4.14/Run' + if (resolution.includes('720p')) return '$1.28/Run' + if (resolution.includes('540p')) return '$0.72/Run' + } + } else if (model.includes('ray-1.6')) { + return '$0.35/Run' + } + + return '$0.55/Run' + } + }, + 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 '$0.14-11.47/Run (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 '$2.19/Run' + if (resolution.includes('1080p')) return '$0.55/Run' + if (resolution.includes('720p')) return '$0.24/Run' + if (resolution.includes('540p')) return '$0.14/Run' + } else if (duration.includes('9s')) { + if (resolution.includes('4k')) return '$3.95/Run' + if (resolution.includes('1080p')) return '$0.99/Run' + if (resolution.includes('720p')) return '$0.43/Run' + if (resolution.includes('540p')) return '$0.252/Run' + } + } else if (model.includes('ray-2')) { + if (duration.includes('5s')) { + if (resolution.includes('4k')) return '$6.37/Run' + if (resolution.includes('1080p')) return '$2.30/Run' + if (resolution.includes('720p')) return '$0.71/Run' + if (resolution.includes('540p')) return '$0.40/Run' + } else if (duration.includes('9s')) { + if (resolution.includes('4k')) return '$11.47/Run' + if (resolution.includes('1080p')) return '$4.14/Run' + if (resolution.includes('720p')) return '$1.28/Run' + if (resolution.includes('540p')) return '$0.72/Run' + } + } else if (model.includes('ray-1-6')) { + return '$0.35/Run' + } + + return '$0.55/Run' + } + }, + MinimaxImageToVideoNode: { + displayPrice: '$0.43/Run' + }, + MinimaxTextToVideoNode: { + displayPrice: '$0.43/Run' + }, + OpenAIDalle2: { + displayPrice: (node: LGraphNode): string => { + const sizeWidget = node.widgets?.find( + (w) => w.name === 'size' + ) as IComboWidget + + if (!sizeWidget) return '$0.016-0.02/Run (varies with size)' + + const size = String(sizeWidget.value) + if (size.includes('1024x1024')) { + return '$0.02/Run' + } else if (size.includes('512x512')) { + return '$0.018/Run' + } else if (size.includes('256x256')) { + return '$0.016/Run' + } + + return '$0.02/Run' + } + }, + 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 '$0.04-0.12/Run (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') ? '$0.08/Run' : '$0.04/Run' + } else if (size.includes('1792x1024') || size.includes('1024x1792')) { + return quality.includes('hd') ? '$0.12/Run' : '$0.08/Run' + } + + // Default value + return '$0.04/Run' + } + }, + OpenAIGPTImage1: { + displayPrice: (node: LGraphNode): string => { + const qualityWidget = node.widgets?.find( + (w) => w.name === 'quality' + ) as IComboWidget + + if (!qualityWidget) return '$0.011-0.30/Run (varies with quality)' + + const quality = String(qualityWidget.value) + if (quality.includes('high')) { + return '$0.167-0.30/Run' + } else if (quality.includes('medium')) { + return '$0.046-0.07/Run' + } else if (quality.includes('low')) { + return '$0.011-0.02/Run' + } + + return '$0.046-0.07/Run' + } + }, + PikaImageToVideoNode2_2: { + 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 '$0.2-1.0/Run (varies with duration & resolution)' + } + + const duration = String(durationWidget.value) + const resolution = String(resolutionWidget.value) + + if (duration.includes('5')) { + if (resolution.includes('1080p')) return '$0.45/Run' + if (resolution.includes('720p')) return '$0.2/Run' + } else if (duration.includes('10')) { + if (resolution.includes('1080p')) return '$1.0/Run' + if (resolution.includes('720p')) return '$0.6/Run' + } + + return '$0.2/Run' + } + }, + PikaScenesV2_2: { + 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 '$0.2-1.0/Run (varies with duration & resolution)' + } + + const duration = String(durationWidget.value) + const resolution = String(resolutionWidget.value) + + if (duration.includes('5')) { + if (resolution.includes('720p')) return '$0.3/Run' + if (resolution.includes('1080p')) return '~$0.3/Run' + } else if (duration.includes('10')) { + if (resolution.includes('720p')) return '$0.25/Run' + if (resolution.includes('1080p')) return '$1.0/Run' + } + + return '$0.3/Run' + } + }, + PikaStartEndFrameNode2_2: { + 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 '$0.2-1.0/Run (varies with duration & resolution)' + } + + const duration = String(durationWidget.value) + const resolution = String(resolutionWidget.value) + + if (duration.includes('5')) { + if (resolution.includes('720p')) return '$0.2/Run' + if (resolution.includes('1080p')) return '~$0.45/Run' + } else if (duration.includes('10')) { + if (resolution.includes('720p')) return '$0.6/Run' + if (resolution.includes('1080p')) return '$1.0/Run' + } + + return '$0.2/Run' + } + }, + PikaTextToVideoNode2_2: { + 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 '$0.2-1.5/Run (varies with duration & resolution)' + } + + const duration = String(durationWidget.value) + const resolution = String(resolutionWidget.value) + + if (duration.includes('5')) { + if (resolution.includes('1080p')) return '$0.45/Run' + if (resolution.includes('720p')) return '$0.2/Run' + } else if (duration.includes('10')) { + if (resolution.includes('1080p')) return '$1.0/Run' + if (resolution.includes('720p')) return '$0.6/Run' + } + + return '$0.45/Run' + } + }, + Pikadditions: { + displayPrice: '$0.3/Run' + }, + Pikaffects: { + displayPrice: '$0.45/Run' + }, + Pikaswaps: { + displayPrice: '$0.3/Run' + }, + PixverseImageToVideoNode: { + displayPrice: pixversePricingCalculator + }, + PixverseTextToVideoNode: { + displayPrice: pixversePricingCalculator + }, + PixverseTransitionVideoNode: { + displayPrice: pixversePricingCalculator + }, + RecraftCreativeUpscaleNode: { + displayPrice: '$0.25/Run' + }, + RecraftCrispUpscaleNode: { + displayPrice: '$0.004/Run' + }, + RecraftImageInpaintingNode: { + displayPrice: (node: LGraphNode): string => { + const nWidget = node.widgets?.find( + (w) => w.name === 'n' + ) as IComboWidget + if (!nWidget) return '$0.04 x n/Run' + + const n = Number(nWidget.value) || 1 + const cost = (0.04 * n).toFixed(2) + return `$${cost}/Run` + } + }, + RecraftImageToImageNode: { + displayPrice: (node: LGraphNode): string => { + const nWidget = node.widgets?.find( + (w) => w.name === 'n' + ) as IComboWidget + if (!nWidget) return '$0.04 x n/Run' + + const n = Number(nWidget.value) || 1 + const cost = (0.04 * n).toFixed(2) + return `$${cost}/Run` + } + }, + RecraftRemoveBackgroundNode: { + displayPrice: '$0.01/Run' + }, + RecraftReplaceBackgroundNode: { + displayPrice: '$0.04/Run' + }, + RecraftTextToImageNode: { + displayPrice: (node: LGraphNode): string => { + const nWidget = node.widgets?.find( + (w) => w.name === 'n' + ) as IComboWidget + if (!nWidget) return '$0.04 x n/Run' + + const n = Number(nWidget.value) || 1 + const cost = (0.04 * n).toFixed(2) + return `$${cost}/Run` + } + }, + RecraftTextToVectorNode: { + displayPrice: (node: LGraphNode): string => { + const nWidget = node.widgets?.find( + (w) => w.name === 'n' + ) as IComboWidget + if (!nWidget) return '$0.08 x n/Run' + + const n = Number(nWidget.value) || 1 + const cost = (0.08 * n).toFixed(2) + return `$${cost}/Run` + } + }, + RecraftVectorizeImageNode: { + displayPrice: '$0.01/Run' + }, + StabilityStableImageSD_3_5Node: { + displayPrice: (node: LGraphNode): string => { + const modelWidget = node.widgets?.find( + (w) => w.name === 'model' + ) as IComboWidget + + if (!modelWidget) return '$0.035-0.065/Run (varies with model)' + + const model = String(modelWidget.value).toLowerCase() + if (model.includes('large')) { + return '$0.065/Run' + } else if (model.includes('medium')) { + return '$0.035/Run' + } + + return '$0.035/Run' + } + }, + StabilityStableImageUltraNode: { + displayPrice: '$0.08/Run' + }, + StabilityUpscaleConservativeNode: { + displayPrice: '$0.25/Run' + }, + StabilityUpscaleCreativeNode: { + displayPrice: '$0.25/Run' + }, + StabilityUpscaleFastNode: { + displayPrice: '$0.01/Run' + }, + VeoVideoGenerationNode: { + displayPrice: (node: LGraphNode): string => { + const durationWidget = node.widgets?.find( + (w) => w.name === 'duration_seconds' + ) as IComboWidget + + if (!durationWidget) return '$2.50-5.0/Run (varies with duration)' + + const price = 0.5 * Number(durationWidget.value) + return `$${price.toFixed(2)}/Run` + } + }, + 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 '$0.0045-0.0182/Run (varies with model & aspect ratio)' + } + + const model = String(modelWidget.value) + const aspectRatio = String(aspectRatioWidget.value) + + if (model.includes('photon-flash-1')) { + if (aspectRatio.includes('1:1')) return '$0.0045/Run' + if (aspectRatio.includes('16:9')) return '$0.0045/Run' + if (aspectRatio.includes('4:3')) return '$0.0046/Run' + if (aspectRatio.includes('21:9')) return '$0.0047/Run' + } else if (model.includes('photon-1')) { + if (aspectRatio.includes('1:1')) return '$0.0172/Run' + if (aspectRatio.includes('16:9')) return '$0.0172/Run' + if (aspectRatio.includes('4:3')) return '$0.0176/Run' + if (aspectRatio.includes('21:9')) return '$0.0182/Run' + } + + return '$0.0172/Run' + } + }, + LumaImageModifyNode: { + 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) { + return '$0.0045-0.0182/Run (varies with model & aspect ratio)' + } + + const model = String(modelWidget.value) + const aspectRatio = aspectRatioWidget + ? String(aspectRatioWidget.value) + : null + + if (model.includes('photon-flash-1')) { + if (!aspectRatio) return '$0.0045/Run' + if (aspectRatio.includes('1:1')) return '~$0.0045/Run' + if (aspectRatio.includes('16:9')) return '~$0.0045/Run' + if (aspectRatio.includes('4:3')) return '~$0.0046/Run' + if (aspectRatio.includes('21:9')) return '~$0.0047/Run' + } else if (model.includes('photon-1')) { + if (!aspectRatio) return '$0.0172/Run' + if (aspectRatio.includes('1:1')) return '~$0.0172/Run' + if (aspectRatio.includes('16:9')) return '~$0.0172/Run' + if (aspectRatio.includes('4:3')) return '~$0.0176/Run' + if (aspectRatio.includes('21:9')) return '~$0.0182/Run' + } + + return '$0.0172/Run' + } + } + } + /** * Composable to get node pricing information for API nodes */ export const useNodePricing = () => { - const getNodePrice = (nodeName: string): string => - apiNodeCosts[nodeName]?.displayPrice || '' - /** * Get the price display for a node */ const getNodeDisplayPrice = (node: LGraphNode): string => { - if (!node.constructor.nodeData?.api_node) return '' - return getNodePrice(node.constructor.nodeData.name) + if (!node.constructor?.nodeData?.api_node) return '' + + const nodeName = node.constructor.nodeData.name + const priceConfig = apiNodeCosts[nodeName] + + if (!priceConfig) 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, '') + } + + // Otherwise return the static price + return priceConfig.displayPrice + } + + const getNodePricingConfig = (node: LGraphNode) => + apiNodeCosts[node.constructor.nodeData?.name ?? ''] + + const getRelevantWidgetNames = (nodeType: string): string[] => { + const widgetMap: Record = { + KlingTextToVideoNode: ['mode', 'model_name', 'duration'], + KlingImage2VideoNode: ['mode', 'model_name', 'duration'], + KlingImageGenerationNode: ['modality', 'model_name'], + KlingDualCharacterVideoEffectNode: ['mode', 'model_name', 'duration'], + KlingSingleImageVideoEffectNode: ['effect_scene'], + KlingStartEndFrameNode: ['mode', 'model_name', 'duration'], + OpenAIDalle3: ['size', 'quality'], + OpenAIDalle2: ['size'], + OpenAIGPTImage1: ['quality'], + IdeogramV3: ['rendering_speed'], + VeoVideoGenerationNode: ['duration_seconds'], + LumaVideoNode: ['model', 'resolution', 'duration'], + LumaImageToVideoNode: ['model', 'resolution', 'duration'], + LumaImageNode: ['model', 'aspect_ratio'], + LumaImageModifyNode: ['model', 'aspect_ratio'], + PikaTextToVideoNode2_2: ['duration', 'resolution'], + PikaImageToVideoNode2_2: ['duration', 'resolution'], + PikaScenesV2_2: ['duration', 'resolution'], + PikaStartEndFrameNode2_2: ['duration', 'resolution'], + 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'] + } + return widgetMap[nodeType] || [] } return { - getNodeDisplayPrice + getNodeDisplayPrice, + getNodePricingConfig, + getRelevantWidgetNames } } diff --git a/src/composables/node/useWatchWidget.ts b/src/composables/node/useWatchWidget.ts new file mode 100644 index 000000000..e8827d6ec --- /dev/null +++ b/src/composables/node/useWatchWidget.ts @@ -0,0 +1,85 @@ +import type { LGraphNode } from '@comfyorg/litegraph' +import { computedWithControl } from '@vueuse/core' +import { type ComputedRef, ref } from 'vue' + +import { useChainCallback } from '@/composables/functional/useChainCallback' + +export interface UseComputedWithWidgetWatchOptions { + /** + * Names of widgets to observe for changes. + * If not provided, all widgets will be observed. + */ + widgetNames?: string[] + + /** + * Whether to trigger a canvas redraw when widget values change. + * @default false + */ + triggerCanvasRedraw?: boolean +} + +/** + * A composable that creates a computed that has a node's widget values as a dependencies. + * Essentially `computedWithControl` (https://vueuse.org/shared/computedWithControl/) where + * the explicitly defined extra dependencies are LGraphNode widgets. + * + * @param node - The LGraphNode whose widget values are to be watched + * @param options - Configuration options for the watcher + * @returns A function to create computed that responds to widget changes + * + * @example + * ```ts + * const computedWithWidgetWatch = useComputedWithWidgetWatch(node, { + * widgetNames: ['width', 'height'], + * triggerCanvasRedraw: true + * }) + * + * const dynamicPrice = computedWithWidgetWatch(() => { + * return calculatePrice(node) + * }) + * ``` + */ +export const useComputedWithWidgetWatch = ( + node: LGraphNode, + options: UseComputedWithWidgetWatchOptions = {} +) => { + const { widgetNames, triggerCanvasRedraw = false } = options + + // Create a reactive trigger based on widget values + const widgetValues = ref>({}) + + // Initialize widget observers + if (node.widgets) { + const widgetsToObserve = widgetNames + ? node.widgets.filter((widget) => widgetNames.includes(widget.name)) + : node.widgets + + // Initialize current values + const currentValues: Record = {} + widgetsToObserve.forEach((widget) => { + currentValues[widget.name] = widget.value + }) + widgetValues.value = currentValues + + widgetsToObserve.forEach((widget) => { + widget.callback = useChainCallback(widget.callback, () => { + // Update the reactive widget values + widgetValues.value = { + ...widgetValues.value, + [widget.name]: widget.value + } + + // Optionally trigger a canvas redraw + if (triggerCanvasRedraw) { + node.graph?.setDirtyCanvas(true, true) + } + }) + }) + } + + // Returns a function that creates a computed that responds to widget changes. + // The computed will be re-evaluated whenever any observed widget changes. + return (computeFn: () => T): ComputedRef => { + return computedWithControl(widgetValues, computeFn) + } +} diff --git a/tests-ui/tests/composables/node/useNodePricing.test.ts b/tests-ui/tests/composables/node/useNodePricing.test.ts new file mode 100644 index 000000000..70ae5c621 --- /dev/null +++ b/tests-ui/tests/composables/node/useNodePricing.test.ts @@ -0,0 +1,802 @@ +import type { LGraphNode } from '@comfyorg/litegraph' +import type { IComboWidget } from '@comfyorg/litegraph/dist/types/widgets' +import { describe, expect, it } from 'vitest' + +import { useNodePricing } from '@/composables/node/useNodePricing' + +// Helper function to create a mock node +function createMockNode( + nodeTypeName: string, + widgets: Array<{ name: string; value: any }> = [], + isApiNode = true +): LGraphNode { + const mockWidgets = widgets.map(({ name, value }) => ({ + name, + value, + type: 'combo' + })) as IComboWidget[] + + return { + id: Math.random().toString(), + widgets: mockWidgets, + constructor: { + nodeData: { + name: nodeTypeName, + api_node: isApiNode + } + } + } as unknown as LGraphNode +} + +describe('useNodePricing', () => { + describe('static pricing', () => { + it('should return static price for FluxProCannyNode', () => { + const { getNodeDisplayPrice } = useNodePricing() + const node = createMockNode('FluxProCannyNode') + + const price = getNodeDisplayPrice(node) + expect(price).toBe('$0.05/Run') + }) + + it('should return static price for StabilityStableImageUltraNode', () => { + const { getNodeDisplayPrice } = useNodePricing() + const node = createMockNode('StabilityStableImageUltraNode') + + const price = getNodeDisplayPrice(node) + expect(price).toBe('$0.08/Run') + }) + + 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('') + }) + }) + + describe('dynamic pricing - KlingTextToVideoNode', () => { + 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('$1.40/Run') + }) + + 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('$0.28/Run') + }) + + it('should return range when mode widget is missing', () => { + const { getNodeDisplayPrice } = useNodePricing() + const node = createMockNode('KlingTextToVideoNode', []) + + const price = getNodeDisplayPrice(node) + expect(price).toBe('$0.14-2.80/Run (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('$1.40/Run') + }) + + 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('$0.28/Run') + }) + + it('should return range when model_name widget is missing', () => { + const { getNodeDisplayPrice } = useNodePricing() + const node = createMockNode('KlingImage2VideoNode', []) + + const price = getNodeDisplayPrice(node) + expect(price).toBe('$0.14-2.80/Run (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('$0.04/Run') + }) + + 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('$0.08/Run') + }) + + 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('$0.08/Run') + }) + + 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('$0.12/Run') + }) + + 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('$0.08/Run') + }) + + 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('$0.12/Run') + }) + + it('should return range when widgets are missing', () => { + const { getNodeDisplayPrice } = useNodePricing() + const node = createMockNode('OpenAIDalle3', []) + + const price = getNodeDisplayPrice(node) + expect(price).toBe('$0.04-0.12/Run (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('$0.04-0.12/Run (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('$0.04-0.12/Run (varies with size & quality)') + }) + }) + + 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('$0.02/Run') + }) + + 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('$0.018/Run') + }) + + 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('$0.016/Run') + }) + + it('should return range when size widget is missing', () => { + const { getNodeDisplayPrice } = useNodePricing() + const node = createMockNode('OpenAIDalle2', []) + + const price = getNodeDisplayPrice(node) + expect(price).toBe('$0.016-0.02/Run (varies with size)') + }) + }) + + 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('$0.167-0.30/Run') + }) + + 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('$0.046-0.07/Run') + }) + + 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('$0.011-0.02/Run') + }) + + it('should return range when quality widget is missing', () => { + const { getNodeDisplayPrice } = useNodePricing() + const node = createMockNode('OpenAIGPTImage1', []) + + const price = getNodeDisplayPrice(node) + expect(price).toBe('$0.011-0.30/Run (varies with quality)') + }) + }) + + describe('dynamic pricing - IdeogramV3', () => { + it('should return $0.08 for Quality rendering speed', () => { + const { getNodeDisplayPrice } = useNodePricing() + const node = createMockNode('IdeogramV3', [ + { name: 'rendering_speed', value: 'Quality' } + ]) + + const price = getNodeDisplayPrice(node) + expect(price).toBe('$0.08/Run') + }) + + it('should return $0.06 for Balanced rendering speed', () => { + const { getNodeDisplayPrice } = useNodePricing() + const node = createMockNode('IdeogramV3', [ + { name: 'rendering_speed', value: 'Balanced' } + ]) + + const price = getNodeDisplayPrice(node) + expect(price).toBe('$0.06/Run') + }) + + it('should return $0.03 for Turbo rendering speed', () => { + const { getNodeDisplayPrice } = useNodePricing() + const node = createMockNode('IdeogramV3', [ + { name: 'rendering_speed', value: 'Turbo' } + ]) + + const price = getNodeDisplayPrice(node) + expect(price).toBe('$0.03/Run') + }) + + it('should return range when rendering_speed widget is missing', () => { + const { getNodeDisplayPrice } = useNodePricing() + const node = createMockNode('IdeogramV3', []) + + const price = getNodeDisplayPrice(node) + expect(price).toBe('$0.03-0.08/Run (varies with rendering speed)') + }) + }) + + 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('$5.00/Run') + }) + + 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('$2.50/Run') + }) + + it('should return range when duration widget is missing', () => { + const { getNodeDisplayPrice } = useNodePricing() + const node = createMockNode('VeoVideoGenerationNode', []) + + const price = getNodeDisplayPrice(node) + expect(price).toBe('$2.50-5.0/Run (varies with duration)') + }) + }) + + 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('$2.19/Run') + }) + + 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('$6.37/Run') + }) + + 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' } + ]) + + const price = getNodeDisplayPrice(node) + expect(price).toBe('$0.35/Run') + }) + + it('should return range when widgets are missing', () => { + const { getNodeDisplayPrice } = useNodePricing() + const node = createMockNode('LumaVideoNode', []) + + const price = getNodeDisplayPrice(node) + expect(price).toBe( + '$0.14-11.47/Run (varies with model, resolution & duration)' + ) + }) + }) + + describe('dynamic pricing - PixverseTextToVideoNode', () => { + it('should return range for 5s 1080p quality', () => { + const { getNodeDisplayPrice } = useNodePricing() + const node = createMockNode('PixverseTextToVideoNode', [ + { name: 'duration', value: '5s' }, + { name: 'quality', value: '1080p' } + ]) + + const price = getNodeDisplayPrice(node) + expect(price).toBe( + '$0.45-1.2/Run (varies with duration, quality & motion mode)' + ) + }) + + it('should return range for 5s 540p normal quality', () => { + 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( + '$0.45-1.2/Run (varies with duration, quality & motion mode)' + ) + }) + + it('should return range when widgets are missing', () => { + const { getNodeDisplayPrice } = useNodePricing() + const node = createMockNode('PixverseTextToVideoNode', []) + + const price = getNodeDisplayPrice(node) + expect(price).toBe( + '$0.45-1.2/Run (varies with duration, quality & motion mode)' + ) + }) + }) + + describe('dynamic pricing - KlingDualCharacterVideoEffectNode', () => { + it('should return range for v2-master 5s mode', () => { + const { getNodeDisplayPrice } = useNodePricing() + const node = createMockNode('KlingDualCharacterVideoEffectNode', [ + { name: 'mode', value: 'standard / 5s / v2-master' } + ]) + + const price = getNodeDisplayPrice(node) + expect(price).toBe('$0.14-2.80/Run (varies with model, mode & duration)') + }) + + it('should return range for v1-6 5s mode', () => { + const { getNodeDisplayPrice } = useNodePricing() + const node = createMockNode('KlingDualCharacterVideoEffectNode', [ + { name: 'mode', value: 'standard / 5s / v1-6' } + ]) + + const price = getNodeDisplayPrice(node) + expect(price).toBe('$0.14-2.80/Run (varies with model, mode & duration)') + }) + + it('should return range when mode widget is missing', () => { + const { getNodeDisplayPrice } = useNodePricing() + const node = createMockNode('KlingDualCharacterVideoEffectNode', []) + + const price = getNodeDisplayPrice(node) + expect(price).toBe('$0.14-2.80/Run (varies with model, mode & duration)') + }) + }) + + describe('dynamic pricing - KlingSingleImageVideoEffectNode', () => { + it('should return $0.28 for fuzzyfuzzy effect', () => { + const { getNodeDisplayPrice } = useNodePricing() + const node = createMockNode('KlingSingleImageVideoEffectNode', [ + { name: 'effect_scene', value: 'fuzzyfuzzy' } + ]) + + const price = getNodeDisplayPrice(node) + expect(price).toBe('$0.28/Run') + }) + + 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('$0.49/Run') + }) + + it('should return range when effect_scene widget is missing', () => { + const { getNodeDisplayPrice } = useNodePricing() + const node = createMockNode('KlingSingleImageVideoEffectNode', []) + + const price = getNodeDisplayPrice(node) + expect(price).toBe('$0.28-0.49/Run (varies with effect scene)') + }) + }) + + describe('dynamic pricing - PikaImageToVideoNode2_2', () => { + it('should return $0.45 for 5s 1080p', () => { + const { getNodeDisplayPrice } = useNodePricing() + const node = createMockNode('PikaImageToVideoNode2_2', [ + { name: 'duration', value: '5s' }, + { name: 'resolution', value: '1080p' } + ]) + + const price = getNodeDisplayPrice(node) + expect(price).toBe('$0.45/Run') + }) + + it('should return $0.2 for 5s 720p', () => { + const { getNodeDisplayPrice } = useNodePricing() + const node = createMockNode('PikaImageToVideoNode2_2', [ + { name: 'duration', value: '5s' }, + { name: 'resolution', value: '720p' } + ]) + + const price = getNodeDisplayPrice(node) + expect(price).toBe('$0.2/Run') + }) + + it('should return $1.0 for 10s 1080p', () => { + const { getNodeDisplayPrice } = useNodePricing() + const node = createMockNode('PikaImageToVideoNode2_2', [ + { name: 'duration', value: '10s' }, + { name: 'resolution', value: '1080p' } + ]) + + const price = getNodeDisplayPrice(node) + expect(price).toBe('$1.0/Run') + }) + + it('should return range when widgets are missing', () => { + const { getNodeDisplayPrice } = useNodePricing() + const node = createMockNode('PikaImageToVideoNode2_2', []) + + const price = getNodeDisplayPrice(node) + expect(price).toBe('$0.2-1.0/Run (varies with duration & resolution)') + }) + }) + + describe('dynamic pricing - PikaScenesV2_2', () => { + it('should return $0.3 for 5s 720p', () => { + const { getNodeDisplayPrice } = useNodePricing() + const node = createMockNode('PikaScenesV2_2', [ + { name: 'duration', value: '5s' }, + { name: 'resolution', value: '720p' } + ]) + + const price = getNodeDisplayPrice(node) + expect(price).toBe('$0.3/Run') + }) + + it('should return $0.25 for 10s 720p', () => { + const { getNodeDisplayPrice } = useNodePricing() + const node = createMockNode('PikaScenesV2_2', [ + { name: 'duration', value: '10s' }, + { name: 'resolution', value: '720p' } + ]) + + const price = getNodeDisplayPrice(node) + expect(price).toBe('$0.25/Run') + }) + + it('should return range when widgets are missing', () => { + const { getNodeDisplayPrice } = useNodePricing() + const node = createMockNode('PikaScenesV2_2', []) + + const price = getNodeDisplayPrice(node) + expect(price).toBe('$0.2-1.0/Run (varies with duration & resolution)') + }) + }) + + describe('dynamic pricing - PikaStartEndFrameNode2_2', () => { + it('should return $0.2 for 5s 720p', () => { + const { getNodeDisplayPrice } = useNodePricing() + const node = createMockNode('PikaStartEndFrameNode2_2', [ + { name: 'duration', value: '5s' }, + { name: 'resolution', value: '720p' } + ]) + + const price = getNodeDisplayPrice(node) + expect(price).toBe('$0.2/Run') + }) + + it('should return $1.0 for 10s 1080p', () => { + const { getNodeDisplayPrice } = useNodePricing() + const node = createMockNode('PikaStartEndFrameNode2_2', [ + { name: 'duration', value: '10s' }, + { name: 'resolution', value: '1080p' } + ]) + + const price = getNodeDisplayPrice(node) + expect(price).toBe('$1.0/Run') + }) + + it('should return range when widgets are missing', () => { + const { getNodeDisplayPrice } = useNodePricing() + const node = createMockNode('PikaStartEndFrameNode2_2', []) + + const price = getNodeDisplayPrice(node) + expect(price).toBe('$0.2-1.0/Run (varies with duration & resolution)') + }) + }) + + describe('error handling', () => { + it('should gracefully handle errors in dynamic pricing functions', () => { + 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() + 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('$0.14-2.80/Run (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('$0.04-0.12/Run (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 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 correct widget names for PikaImageToVideoNode2_2', () => { + const { getRelevantWidgetNames } = useNodePricing() + + const widgetNames = getRelevantWidgetNames('PikaImageToVideoNode2_2') + expect(widgetNames).toEqual(['duration', 'resolution']) + }) + + it('should return empty array for unknown node types', () => { + const { getRelevantWidgetNames } = useNodePricing() + + const widgetNames = getRelevantWidgetNames('UnknownNode') + expect(widgetNames).toEqual([]) + }) + + 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('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('$0.12/Run') // 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('$0.16/Run') // 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('$0.04 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('$0.04/Run') // 0.04 * 1 + }) + }) + }) +}) diff --git a/tests-ui/tests/composables/useWatchWidget.test.ts b/tests-ui/tests/composables/useWatchWidget.test.ts new file mode 100644 index 000000000..b31aaf625 --- /dev/null +++ b/tests-ui/tests/composables/useWatchWidget.test.ts @@ -0,0 +1,183 @@ +import type { LGraphNode } from '@comfyorg/litegraph' +import { describe, expect, it, vi } from 'vitest' +import { nextTick } from 'vue' + +import { useComputedWithWidgetWatch } from '@/composables/node/useWatchWidget' + +// Mock useChainCallback +vi.mock('@/composables/functional/useChainCallback', () => ({ + useChainCallback: vi.fn((original, newCallback) => { + return function (this: any, ...args: any[]) { + original?.call(this, ...args) + newCallback.call(this, ...args) + } + }) +})) + +describe('useComputedWithWidgetWatch', () => { + const createMockNode = ( + widgets: Array<{ + name: string + value: any + callback?: (...args: any[]) => void + }> = [] + ) => { + const mockNode = { + widgets: widgets.map((widget) => ({ + name: widget.name, + value: widget.value, + callback: widget.callback + })), + graph: { + setDirtyCanvas: vi.fn() + } + } as unknown as LGraphNode + + return mockNode + } + + it('should create a reactive computed that responds to widget changes', async () => { + const mockNode = createMockNode([ + { name: 'width', value: 100 }, + { name: 'height', value: 200 } + ]) + + const computedWithWidgetWatch = useComputedWithWidgetWatch(mockNode) + + const computedValue = computedWithWidgetWatch(() => { + const width = + (mockNode.widgets?.find((w) => w.name === 'width')?.value as number) || + 0 + const height = + (mockNode.widgets?.find((w) => w.name === 'height')?.value as number) || + 0 + return width * height + }) + + // Initial value should be computed correctly + expect(computedValue.value).toBe(20000) + + // Change widget value and trigger callback + const widthWidget = mockNode.widgets?.find((w) => w.name === 'width') + if (widthWidget) { + widthWidget.value = 150 + ;(widthWidget.callback as any)?.() + } + + await nextTick() + + // Computed should update + expect(computedValue.value).toBe(30000) + }) + + it('should only observe specific widgets when widgetNames is provided', async () => { + const mockNode = createMockNode([ + { name: 'width', value: 100 }, + { name: 'height', value: 200 }, + { name: 'depth', value: 50 } + ]) + + const computedWithWidgetWatch = useComputedWithWidgetWatch(mockNode, { + widgetNames: ['width', 'height'] + }) + + const computedValue = computedWithWidgetWatch(() => { + return mockNode.widgets?.map((w) => w.value).join('-') || '' + }) + + expect(computedValue.value).toBe('100-200-50') + + // Change observed widget + const widthWidget = mockNode.widgets?.find((w) => w.name === 'width') + if (widthWidget) { + widthWidget.value = 150 + ;(widthWidget.callback as any)?.() + } + + await nextTick() + expect(computedValue.value).toBe('150-200-50') + + // Change non-observed widget - should not trigger update + const depthWidget = mockNode.widgets?.find((w) => w.name === 'depth') + if (depthWidget) { + depthWidget.value = 75 + // Depth widget callback should not have been modified + expect(depthWidget.callback).toBeUndefined() + } + }) + + it('should trigger canvas redraw when triggerCanvasRedraw is true', async () => { + const mockNode = createMockNode([{ name: 'value', value: 10 }]) + + const computedWithWidgetWatch = useComputedWithWidgetWatch(mockNode, { + triggerCanvasRedraw: true + }) + + computedWithWidgetWatch(() => mockNode.widgets?.[0]?.value || 0) + + // Change widget value + const widget = mockNode.widgets?.[0] + if (widget) { + widget.value = 20 + ;(widget.callback as any)?.() + } + + await nextTick() + + // Canvas redraw should have been triggered + expect(mockNode.graph?.setDirtyCanvas).toHaveBeenCalledWith(true, true) + }) + + it('should not trigger canvas redraw when triggerCanvasRedraw is false', async () => { + const mockNode = createMockNode([{ name: 'value', value: 10 }]) + + const computedWithWidgetWatch = useComputedWithWidgetWatch(mockNode, { + triggerCanvasRedraw: false + }) + + computedWithWidgetWatch(() => mockNode.widgets?.[0]?.value || 0) + + // Change widget value + const widget = mockNode.widgets?.[0] + if (widget) { + widget.value = 20 + ;(widget.callback as any)?.() + } + + await nextTick() + + // Canvas redraw should not have been triggered + expect(mockNode.graph?.setDirtyCanvas).not.toHaveBeenCalled() + }) + + it('should handle nodes without widgets gracefully', () => { + const mockNode = createMockNode([]) + + const computedWithWidgetWatch = useComputedWithWidgetWatch(mockNode) + + const computedValue = computedWithWidgetWatch(() => 'no widgets') + + expect(computedValue.value).toBe('no widgets') + }) + + it('should chain with existing widget callbacks', async () => { + const existingCallback = vi.fn() + const mockNode = createMockNode([ + { name: 'value', value: 10, callback: existingCallback } + ]) + + const computedWithWidgetWatch = useComputedWithWidgetWatch(mockNode) + computedWithWidgetWatch(() => mockNode.widgets?.[0]?.value || 0) + + // Trigger widget callback + const widget = mockNode.widgets?.[0] + if (widget) { + ;(widget.callback as any)?.() + } + + await nextTick() + + // Both existing callback and our callback should have been called + expect(existingCallback).toHaveBeenCalled() + }) +})