diff --git a/src/composables/node/useNodePricing.ts b/src/composables/node/useNodePricing.ts index 2c7aae7e3f..c075361f29 100644 --- a/src/composables/node/useNodePricing.ts +++ b/src/composables/node/useNodePricing.ts @@ -1,6 +1,57 @@ +import { formatCreditsFromUsd } from '@/base/credits/comfyCredits' import type { INodeInputSlot, LGraphNode } from '@/lib/litegraph/src/litegraph' import type { IComboWidget } from '@/lib/litegraph/src/types/widgets' +const DEFAULT_NUMBER_OPTIONS: Intl.NumberFormatOptions = { + minimumFractionDigits: 0, + maximumFractionDigits: 0 +} + +type CreditFormatOptions = { + suffix?: string + note?: string + approximate?: boolean + separator?: string +} + +const formatCreditsValue = (usd: number): string => + formatCreditsFromUsd({ + usd, + numberOptions: DEFAULT_NUMBER_OPTIONS + }) + +const makePrefix = (approximate?: boolean) => (approximate ? '~' : '') + +const makeSuffix = (suffix?: string) => suffix ?? '/Run' + +const appendNote = (note?: string) => (note ? ` ${note}` : '') + +const formatCreditsLabel = ( + usd: number, + { suffix, note, approximate }: CreditFormatOptions = {} +): string => + `${makePrefix(approximate)}${formatCreditsValue(usd)} credits${makeSuffix(suffix)}${appendNote(note)}` + +const formatCreditsRangeLabel = ( + minUsd: number, + maxUsd: number, + { suffix, note, approximate }: CreditFormatOptions = {} +): string => { + const min = formatCreditsValue(minUsd) + const max = formatCreditsValue(maxUsd) + const rangeValue = min === max ? min : `${min}-${max}` + return `${makePrefix(approximate)}${rangeValue} credits${makeSuffix(suffix)}${appendNote(note)}` +} + +const formatCreditsListLabel = ( + usdValues: number[], + { suffix, note, approximate, separator }: CreditFormatOptions = {} +): string => { + const parts = usdValues.map((value) => formatCreditsValue(value)) + const value = parts.join(separator ?? '/') + return `${makePrefix(approximate)}${value} credits${makeSuffix(suffix)}${appendNote(note)}` +} + /** * Function that calculates dynamic pricing based on node widget values */ @@ -40,13 +91,13 @@ const calculateRunwayDurationPrice = (node: LGraphNode): string => { (w) => w.name === 'duration' ) as IComboWidget - if (!durationWidget) return '$0.0715/second' + if (!durationWidget) return formatCreditsLabel(0.0715, { suffix: '/second' }) const duration = Number(durationWidget.value) // If duration is 0 or NaN, don't fall back to 5 seconds - just use 0 const validDuration = isNaN(duration) ? 5 : duration - const cost = (0.0715 * validDuration).toFixed(2) - return `$${cost}/Run` + const cost = 0.0715 * validDuration + return formatCreditsLabel(cost) } const makeOmniProDurationCalculator = @@ -55,13 +106,15 @@ const makeOmniProDurationCalculator = const durationWidget = node.widgets?.find( (w) => w.name === 'duration' ) as IComboWidget - if (!durationWidget) return `$${pricePerSecond.toFixed(3)}/second` + if (!durationWidget) + return formatCreditsLabel(pricePerSecond, { suffix: '/second' }) const seconds = parseFloat(String(durationWidget.value)) - if (!Number.isFinite(seconds)) return `$${pricePerSecond.toFixed(3)}/second` + if (!Number.isFinite(seconds)) + return formatCreditsLabel(pricePerSecond, { suffix: '/second' }) const cost = pricePerSecond * seconds - return `$${cost.toFixed(2)}/Run` + return formatCreditsLabel(cost) } const pixversePricingCalculator = (node: LGraphNode): string => { @@ -76,7 +129,9 @@ const pixversePricingCalculator = (node: LGraphNode): string => { ) as IComboWidget if (!durationWidget || !qualityWidget) { - return '$0.45-1.2/Run (varies with duration, quality & motion mode)' + return formatCreditsRangeLabel(0.45, 1.2, { + note: '(varies with duration, quality & motion mode)' + }) } const duration = String(durationWidget.value) @@ -85,43 +140,43 @@ const pixversePricingCalculator = (node: LGraphNode): string => { // Basic pricing based on duration and quality if (duration.includes('5')) { - if (quality.includes('1080p')) return '$1.2/Run' + if (quality.includes('1080p')) return formatCreditsLabel(1.2) if (quality.includes('720p') && motionMode?.includes('fast')) - return '$1.2/Run' + return formatCreditsLabel(1.2) if (quality.includes('720p') && motionMode?.includes('normal')) - return '$0.6/Run' + return formatCreditsLabel(0.6) if (quality.includes('540p') && motionMode?.includes('fast')) - return '$0.9/Run' + return formatCreditsLabel(0.9) if (quality.includes('540p') && motionMode?.includes('normal')) - return '$0.45/Run' + return formatCreditsLabel(0.45) if (quality.includes('360p') && motionMode?.includes('fast')) - return '$0.9/Run' + return formatCreditsLabel(0.9) if (quality.includes('360p') && motionMode?.includes('normal')) - return '$0.45/Run' + return formatCreditsLabel(0.45) if (quality.includes('720p') && motionMode?.includes('fast')) - return '$1.2/Run' + return formatCreditsLabel(1.2) } else if (duration.includes('8')) { if (quality.includes('720p') && motionMode?.includes('normal')) - return '$1.2/Run' + return formatCreditsLabel(1.2) if (quality.includes('540p') && motionMode?.includes('normal')) - return '$0.9/Run' + return formatCreditsLabel(0.9) if (quality.includes('540p') && motionMode?.includes('fast')) - return '$1.2/Run' + return formatCreditsLabel(1.2) if (quality.includes('360p') && motionMode?.includes('normal')) - return '$0.9/Run' + return formatCreditsLabel(0.9) if (quality.includes('360p') && motionMode?.includes('fast')) - return '$1.2/Run' + return formatCreditsLabel(1.2) if (quality.includes('1080p') && motionMode?.includes('normal')) - return '$1.2/Run' + return formatCreditsLabel(1.2) if (quality.includes('1080p') && motionMode?.includes('fast')) - return '$1.2/Run' + return formatCreditsLabel(1.2) if (quality.includes('720p') && motionMode?.includes('normal')) - return '$1.2/Run' + return formatCreditsLabel(1.2) if (quality.includes('720p') && motionMode?.includes('fast')) - return '$1.2/Run' + return formatCreditsLabel(1.2) } - return '$0.9/Run' + return formatCreditsLabel(0.9) } const byteDanceVideoPricingCalculator = (node: LGraphNode): string => { @@ -183,12 +238,8 @@ const byteDanceVideoPricingCalculator = (node: LGraphNode): string => { const minCost = min10s * scale const maxCost = max10s * scale - const minStr = `$${minCost.toFixed(2)}/Run` - const maxStr = `$${maxCost.toFixed(2)}/Run` - - return minStr === maxStr - ? minStr - : `$${minCost.toFixed(2)}-$${maxCost.toFixed(2)}/Run` + if (minCost === maxCost) return formatCreditsLabel(minCost) + return formatCreditsRangeLabel(minCost, maxCost) } const ltxvPricingCalculator = (node: LGraphNode): string => { @@ -202,7 +253,9 @@ const ltxvPricingCalculator = (node: LGraphNode): string => { (w) => w.name === 'resolution' ) as IComboWidget - const fallback = '$0.04-0.24/second' + const fallback = formatCreditsRangeLabel(0.04, 0.24, { + suffix: '/second' + }) if (!modelWidget || !durationWidget || !resolutionWidget) return fallback const model = String(modelWidget.value).toLowerCase() @@ -227,8 +280,8 @@ const ltxvPricingCalculator = (node: LGraphNode): string => { const pps = modelTable[resolution] if (!pps) return fallback - const cost = (pps * seconds).toFixed(2) - return `$${cost}/Run` + const cost = pps * seconds + return formatCreditsLabel(cost) } // ---- constants ---- @@ -275,7 +328,7 @@ function perSecForSora2(modelRaw: string, sizeRaw: string): number { } function formatRunPrice(perSec: number, duration: number) { - return `$${(perSec * duration).toFixed(2)}/Run` + return formatCreditsLabel(Number((perSec * duration).toFixed(2))) } // ---- pricing calculator ---- @@ -305,25 +358,25 @@ const sora2PricingCalculator: PricingFunction = (node: LGraphNode): string => { const apiNodeCosts: Record = { FluxProCannyNode: { - displayPrice: '$0.05/Run' + displayPrice: formatCreditsLabel(0.05) }, FluxProDepthNode: { - displayPrice: '$0.05/Run' + displayPrice: formatCreditsLabel(0.05) }, FluxProExpandNode: { - displayPrice: '$0.05/Run' + displayPrice: formatCreditsLabel(0.05) }, FluxProFillNode: { - displayPrice: '$0.05/Run' + displayPrice: formatCreditsLabel(0.05) }, FluxProUltraImageNode: { - displayPrice: '$0.06/Run' + displayPrice: formatCreditsLabel(0.06) }, FluxProKontextProNode: { - displayPrice: '$0.04/Run' + displayPrice: formatCreditsLabel(0.04) }, FluxProKontextMaxNode: { - displayPrice: '$0.08/Run' + displayPrice: formatCreditsLabel(0.08) }, Flux2ProImageNode: { displayPrice: (node: LGraphNode): string => { @@ -338,7 +391,7 @@ const apiNodeCosts: Record = 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 '$0.03–$0.15/Run' + return formatCreditsRangeLabel(0.03, 0.15) } // Is the 'images' input connected? @@ -358,11 +411,13 @@ const apiNodeCosts: Record = // 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 `~$${parseFloat(minTotal.toFixed(3))}–$${parseFloat(maxTotal.toFixed(3))}/Run` + return formatCreditsRangeLabel(minTotal, maxTotal, { + approximate: true + }) } // Precise text-to-image price - return `$${parseFloat(outputCost.toFixed(3))}/Run` + return formatCreditsLabel(outputCost) } }, OpenAIVideoSora2: { @@ -377,13 +432,16 @@ const apiNodeCosts: Record = (w) => w.name === 'turbo' ) as IComboWidget - if (!numImagesWidget) return '$0.03-0.09 x num_images/Run' + 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 = (basePrice * numImages).toFixed(2) - return `$${cost}/Run` + const cost = Number((basePrice * numImages).toFixed(2)) + return formatCreditsLabel(cost) } }, IdeogramV2: { @@ -395,13 +453,16 @@ const apiNodeCosts: Record = (w) => w.name === 'turbo' ) as IComboWidget - if (!numImagesWidget) return '$0.07-0.11 x num_images/Run' + 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 = (basePrice * numImages).toFixed(2) - return `$${cost}/Run` + const cost = Number((basePrice * numImages).toFixed(2)) + return formatCreditsLabel(cost) } }, IdeogramV3: { @@ -420,7 +481,10 @@ const apiNodeCosts: Record = characterInput.link != null if (!renderingSpeedWidget) - return '$0.04-0.11 x num_images/Run (varies with rendering speed & num_images)' + 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 @@ -446,15 +510,15 @@ const apiNodeCosts: Record = } } - const totalCost = (basePrice * numImages).toFixed(2) - return `$${totalCost}/Run` + const totalCost = Number((basePrice * numImages).toFixed(2)) + return formatCreditsLabel(totalCost) } }, KlingCameraControlI2VNode: { - displayPrice: '$0.49/Run' + displayPrice: formatCreditsLabel(0.49) }, KlingCameraControlT2VNode: { - displayPrice: '$0.14/Run' + displayPrice: formatCreditsLabel(0.14) }, KlingDualCharacterVideoEffectNode: { displayPrice: (node: LGraphNode): string => { @@ -468,7 +532,9 @@ const apiNodeCosts: Record = (w) => w.name === 'duration' ) as IComboWidget if (!modeWidget || !modelWidget || !durationWidget) - return '$0.14-2.80/Run (varies with model, mode & duration)' + return formatCreditsRangeLabel(0.14, 2.8, { + note: '(varies with model, mode & duration)' + }) const modeValue = String(modeWidget.value) const durationValue = String(durationWidget.value) @@ -477,19 +543,27 @@ const apiNodeCosts: Record = // 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' + return durationValue.includes('10') + ? formatCreditsLabel(0.98) + : formatCreditsLabel(0.49) } else { - return durationValue.includes('10') ? '$0.56/Run' : '$0.28/Run' + return durationValue.includes('10') + ? formatCreditsLabel(0.56) + : formatCreditsLabel(0.28) } } else if (modelValue.includes('v1')) { if (modeValue.includes('pro')) { - return durationValue.includes('10') ? '$0.98/Run' : '$0.49/Run' + return durationValue.includes('10') + ? formatCreditsLabel(0.98) + : formatCreditsLabel(0.49) } else { - return durationValue.includes('10') ? '$0.28/Run' : '$0.14/Run' + return durationValue.includes('10') + ? formatCreditsLabel(0.28) + : formatCreditsLabel(0.14) } } - return '$0.14/Run' + return formatCreditsLabel(0.14) } }, KlingImage2VideoNode: { @@ -506,21 +580,23 @@ const apiNodeCosts: Record = if (!modeWidget) { if (!modelWidget) - return '$0.14-2.80/Run (varies with model, mode & duration)' + 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 '$1.40/Run' + return formatCreditsLabel(1.4) } else if ( modelValue.includes('v1-6') || modelValue.includes('v1-5') ) { - return '$0.28/Run' + return formatCreditsLabel(0.28) } - return '$0.14/Run' + return formatCreditsLabel(0.14) } const modeValue = String(modeWidget.value) @@ -530,36 +606,44 @@ const apiNodeCosts: Record = // Same pricing matrix as KlingTextToVideoNode if (modelValue.includes('v2-5-turbo')) { if (durationValue.includes('10')) { - return '$0.70/Run' + return formatCreditsLabel(0.7) } - return '$0.35/Run' // 5s default + return formatCreditsLabel(0.35) // 5s default } else if ( modelValue.includes('v2-1-master') || modelValue.includes('v2-master') ) { if (durationValue.includes('10')) { - return '$2.80/Run' + return formatCreditsLabel(2.8) } - return '$1.40/Run' // 5s default + 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') ? '$0.98/Run' : '$0.49/Run' + return durationValue.includes('10') + ? formatCreditsLabel(0.98) + : formatCreditsLabel(0.49) } else { - return durationValue.includes('10') ? '$0.56/Run' : '$0.28/Run' + return durationValue.includes('10') + ? formatCreditsLabel(0.56) + : formatCreditsLabel(0.28) } } else if (modelValue.includes('v1')) { if (modeValue.includes('pro')) { - return durationValue.includes('10') ? '$0.98/Run' : '$0.49/Run' + return durationValue.includes('10') + ? formatCreditsLabel(0.98) + : formatCreditsLabel(0.49) } else { - return durationValue.includes('10') ? '$0.28/Run' : '$0.14/Run' + return durationValue.includes('10') + ? formatCreditsLabel(0.28) + : formatCreditsLabel(0.14) } } - return '$0.14/Run' + return formatCreditsLabel(0.14) } }, KlingImageGenerationNode: { @@ -577,7 +661,10 @@ const apiNodeCosts: Record = ) as IComboWidget if (!modelWidget) - return '$0.0035-0.028 x n/Run (varies with modality & model)' + 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 @@ -597,15 +684,15 @@ const apiNodeCosts: Record = } } - const totalCost = (basePrice * n).toFixed(4) - return `$${totalCost}/Run` + const totalCost = basePrice * n + return formatCreditsLabel(totalCost) } }, KlingLipSyncAudioToVideoNode: { - displayPrice: '~$0.10/Run' + displayPrice: formatCreditsLabel(0.1, { approximate: true }) }, KlingLipSyncTextToVideoNode: { - displayPrice: '~$0.10/Run' + displayPrice: formatCreditsLabel(0.1, { approximate: true }) }, KlingSingleImageVideoEffectNode: { displayPrice: (node: LGraphNode): string => { @@ -614,23 +701,25 @@ const apiNodeCosts: Record = ) as IComboWidget if (!effectSceneWidget) - return '$0.28-0.49/Run (varies with effect scene)' + return formatCreditsRangeLabel(0.28, 0.49, { + note: '(varies with effect scene)' + }) const effectScene = String(effectSceneWidget.value) if ( effectScene.includes('fuzzyfuzzy') || effectScene.includes('squish') ) { - return '$0.28/Run' + return formatCreditsLabel(0.28) } else if (effectScene.includes('dizzydizzy')) { - return '$0.49/Run' + return formatCreditsLabel(0.49) } else if (effectScene.includes('bloombloom')) { - return '$0.49/Run' + return formatCreditsLabel(0.49) } else if (effectScene.includes('expansion')) { - return '$0.28/Run' + return formatCreditsLabel(0.28) } - return '$0.28/Run' + return formatCreditsLabel(0.28) } }, KlingStartEndFrameNode: { @@ -640,41 +729,51 @@ const apiNodeCosts: Record = (w) => w.name === 'mode' ) as IComboWidget if (!modeWidget) - return '$0.14-2.80/Run (varies with model, mode & duration)' + 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 '$0.70/Run' + return formatCreditsLabel(0.7) } - return '$0.35/Run' // 5s default + return formatCreditsLabel(0.35) // 5s default } else if (modeValue.includes('v2-1')) { if (modeValue.includes('10s')) { - return '$0.98/Run' // pro, 10s + return formatCreditsLabel(0.98) // pro, 10s } - return '$0.49/Run' // pro, 5s default + return formatCreditsLabel(0.49) // pro, 5s default } else if (modeValue.includes('v2-master')) { if (modeValue.includes('10s')) { - return '$2.80/Run' + return formatCreditsLabel(2.8) } - return '$1.40/Run' // 5s default + return formatCreditsLabel(1.4) // 5s default } else if (modeValue.includes('v1-6')) { if (modeValue.includes('pro')) { - return modeValue.includes('10s') ? '$0.98/Run' : '$0.49/Run' + return modeValue.includes('10s') + ? formatCreditsLabel(0.98) + : formatCreditsLabel(0.49) } else { - return modeValue.includes('10s') ? '$0.56/Run' : '$0.28/Run' + return modeValue.includes('10s') + ? formatCreditsLabel(0.56) + : formatCreditsLabel(0.28) } } else if (modeValue.includes('v1')) { if (modeValue.includes('pro')) { - return modeValue.includes('10s') ? '$0.98/Run' : '$0.49/Run' + return modeValue.includes('10s') + ? formatCreditsLabel(0.98) + : formatCreditsLabel(0.49) } else { - return modeValue.includes('10s') ? '$0.28/Run' : '$0.14/Run' + return modeValue.includes('10s') + ? formatCreditsLabel(0.28) + : formatCreditsLabel(0.14) } } - return '$0.14/Run' + return formatCreditsLabel(0.14) } }, KlingTextToVideoNode: { @@ -683,48 +782,58 @@ const apiNodeCosts: Record = (w) => w.name === 'mode' ) as IComboWidget if (!modeWidget) - return '$0.14-2.80/Run (varies with model, mode & duration)' + 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 '$0.70/Run' + return formatCreditsLabel(0.7) } - return '$0.35/Run' // 5s default + return formatCreditsLabel(0.35) // 5s default } else if (modeValue.includes('v2-1-master')) { if (modeValue.includes('10s')) { - return '$2.80/Run' // price is the same as for v2-master model + return formatCreditsLabel(2.8) // price is the same as for v2-master model } - return '$1.40/Run' // 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 '$2.80/Run' + return formatCreditsLabel(2.8) } - return '$1.40/Run' // 5s default + return formatCreditsLabel(1.4) // 5s default } else if (modeValue.includes('v1-6')) { if (modeValue.includes('pro')) { - return modeValue.includes('10s') ? '$0.98/Run' : '$0.49/Run' + return modeValue.includes('10s') + ? formatCreditsLabel(0.98) + : formatCreditsLabel(0.49) } else { - return modeValue.includes('10s') ? '$0.56/Run' : '$0.28/Run' + return modeValue.includes('10s') + ? formatCreditsLabel(0.56) + : formatCreditsLabel(0.28) } } else if (modeValue.includes('v1')) { if (modeValue.includes('pro')) { - return modeValue.includes('10s') ? '$0.98/Run' : '$0.49/Run' + return modeValue.includes('10s') + ? formatCreditsLabel(0.98) + : formatCreditsLabel(0.49) } else { - return modeValue.includes('10s') ? '$0.28/Run' : '$0.14/Run' + return modeValue.includes('10s') + ? formatCreditsLabel(0.28) + : formatCreditsLabel(0.14) } } - return '$0.14/Run' + return formatCreditsLabel(0.14) } }, KlingVideoExtendNode: { - displayPrice: '$0.28/Run' + displayPrice: formatCreditsLabel(0.28) }, KlingVirtualTryOnNode: { - displayPrice: '$0.07/Run' + displayPrice: formatCreditsLabel(0.07) }, KlingOmniProTextToVideoNode: { displayPrice: makeOmniProDurationCalculator(0.112) @@ -739,10 +848,10 @@ const apiNodeCosts: Record = displayPrice: makeOmniProDurationCalculator(0.168) }, KlingOmniProEditVideoNode: { - displayPrice: '$0.168/second' + displayPrice: formatCreditsLabel(0.168, { suffix: '/second' }) }, KlingOmniProImageNode: { - displayPrice: '$0.028/Run' + displayPrice: formatCreditsLabel(0.028) }, LumaImageToVideoNode: { displayPrice: (node: LGraphNode): string => { @@ -758,7 +867,9 @@ const apiNodeCosts: Record = ) as IComboWidget if (!modelWidget || !resolutionWidget || !durationWidget) { - return '$0.20-16.40/Run (varies with model, resolution & duration)' + return formatCreditsRangeLabel(0.2, 16.4, { + note: '(varies with model, resolution & duration)' + }) } const model = String(modelWidget.value) @@ -767,33 +878,33 @@ const apiNodeCosts: Record = if (model.includes('ray-flash-2')) { if (duration.includes('5s')) { - if (resolution.includes('4k')) return '$3.13/Run' - if (resolution.includes('1080p')) return '$0.79/Run' - if (resolution.includes('720p')) return '$0.34/Run' - if (resolution.includes('540p')) return '$0.20/Run' + 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 '$5.65/Run' - if (resolution.includes('1080p')) return '$1.42/Run' - if (resolution.includes('720p')) return '$0.61/Run' - if (resolution.includes('540p')) return '$0.36/Run' + 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 '$9.11/Run' - if (resolution.includes('1080p')) return '$2.27/Run' - if (resolution.includes('720p')) return '$1.02/Run' - if (resolution.includes('540p')) return '$0.57/Run' + 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 '$16.40/Run' - if (resolution.includes('1080p')) return '$4.10/Run' - if (resolution.includes('720p')) return '$1.83/Run' - if (resolution.includes('540p')) return '$1.03/Run' + 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 '$0.50/Run' + return formatCreditsLabel(0.5) } - return '$0.79/Run' + return formatCreditsLabel(0.79) } }, LumaVideoNode: { @@ -809,7 +920,9 @@ const apiNodeCosts: Record = ) as IComboWidget if (!modelWidget || !resolutionWidget || !durationWidget) { - return '$0.20-16.40/Run (varies with model, resolution & duration)' + return formatCreditsRangeLabel(0.2, 16.4, { + note: '(varies with model, resolution & duration)' + }) } const model = String(modelWidget.value) @@ -818,40 +931,40 @@ const apiNodeCosts: Record = if (model.includes('ray-flash-2')) { if (duration.includes('5s')) { - if (resolution.includes('4k')) return '$3.13/Run' - if (resolution.includes('1080p')) return '$0.79/Run' - if (resolution.includes('720p')) return '$0.34/Run' - if (resolution.includes('540p')) return '$0.20/Run' + 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 '$5.65/Run' - if (resolution.includes('1080p')) return '$1.42/Run' - if (resolution.includes('720p')) return '$0.61/Run' - if (resolution.includes('540p')) return '$0.36/Run' + 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 '$9.11/Run' - if (resolution.includes('1080p')) return '$2.27/Run' - if (resolution.includes('720p')) return '$1.02/Run' - if (resolution.includes('540p')) return '$0.57/Run' + 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 '$16.40/Run' - if (resolution.includes('1080p')) return '$4.10/Run' - if (resolution.includes('720p')) return '$1.83/Run' - if (resolution.includes('540p')) return '$1.03/Run' + 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 '$0.50/Run' + return formatCreditsLabel(0.5) } - return '$0.79/Run' + return formatCreditsLabel(0.79) } }, MinimaxImageToVideoNode: { - displayPrice: '$0.43/Run' + displayPrice: formatCreditsLabel(0.43) }, MinimaxTextToVideoNode: { - displayPrice: '$0.43/Run' + displayPrice: formatCreditsLabel(0.43) }, MinimaxHailuoVideoNode: { displayPrice: (node: LGraphNode): string => { @@ -863,20 +976,22 @@ const apiNodeCosts: Record = ) as IComboWidget if (!resolutionWidget || !durationWidget) { - return '$0.28-0.56/Run (varies with resolution & duration)' + 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 '$0.28/Run' - if (duration.includes('10')) return '$0.56/Run' + 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 '$0.49/Run' + if (duration.includes('6')) return formatCreditsLabel(0.49) } - return '$0.43/Run' // default median + return formatCreditsLabel(0.43) // default median } }, OpenAIDalle2: { @@ -888,7 +1003,11 @@ const apiNodeCosts: Record = (w) => w.name === 'n' ) as IComboWidget - if (!sizeWidget) return '$0.016-0.02 x n/Run (varies with size & n)' + 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 @@ -902,8 +1021,8 @@ const apiNodeCosts: Record = basePrice = 0.016 } - const totalCost = (basePrice * n).toFixed(3) - return `$${totalCost}/Run` + const totalCost = Number((basePrice * n).toFixed(3)) + return formatCreditsLabel(totalCost) } }, OpenAIDalle3: { @@ -917,20 +1036,26 @@ const apiNodeCosts: Record = ) as IComboWidget if (!sizeWidget || !qualityWidget) - return '$0.04-0.12/Run (varies with size & quality)' + 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') ? '$0.08/Run' : '$0.04/Run' + return quality.includes('hd') + ? formatCreditsLabel(0.08) + : formatCreditsLabel(0.04) } else if (size.includes('1792x1024') || size.includes('1024x1792')) { - return quality.includes('hd') ? '$0.12/Run' : '$0.08/Run' + return quality.includes('hd') + ? formatCreditsLabel(0.12) + : formatCreditsLabel(0.08) } // Default value - return '$0.04/Run' + return formatCreditsLabel(0.04) } }, OpenAIGPTImage1: { @@ -943,25 +1068,29 @@ const apiNodeCosts: Record = ) as IComboWidget if (!qualityWidget) - return '$0.011-0.30 x n/Run (varies with quality & n)' + 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 basePriceRange = '$0.046-0.07' // default medium + let range: [number, number] = [0.046, 0.07] // default medium if (quality.includes('high')) { - basePriceRange = '$0.167-0.30' + range = [0.167, 0.3] } else if (quality.includes('medium')) { - basePriceRange = '$0.046-0.07' + range = [0.046, 0.07] } else if (quality.includes('low')) { - basePriceRange = '$0.011-0.02' + range = [0.011, 0.02] } if (n === 1) { - return `${basePriceRange}/Run` - } else { - return `${basePriceRange} x ${n}/Run` + return formatCreditsRangeLabel(range[0], range[1]) } + return formatCreditsRangeLabel(range[0], range[1], { + suffix: ` x ${n}/Run` + }) } }, PikaImageToVideoNode2_2: { @@ -974,21 +1103,23 @@ const apiNodeCosts: Record = ) as IComboWidget if (!durationWidget || !resolutionWidget) { - return '$0.2-1.0/Run (varies with duration & resolution)' + return formatCreditsRangeLabel(0.2, 1.0, { + note: '(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' + if (resolution.includes('1080p')) return formatCreditsLabel(0.45) + if (resolution.includes('720p')) return formatCreditsLabel(0.2) } else if (duration.includes('10')) { - if (resolution.includes('1080p')) return '$1.0/Run' - if (resolution.includes('720p')) return '$0.6/Run' + if (resolution.includes('1080p')) return formatCreditsLabel(1.0) + if (resolution.includes('720p')) return formatCreditsLabel(0.6) } - return '$0.2/Run' + return formatCreditsLabel(0.2) } }, PikaScenesV2_2: { @@ -1001,21 +1132,23 @@ const apiNodeCosts: Record = ) as IComboWidget if (!durationWidget || !resolutionWidget) { - return '$0.2-1.0/Run (varies with duration & resolution)' + return formatCreditsRangeLabel(0.2, 1.0, { + note: '(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.5/Run' + if (resolution.includes('720p')) return formatCreditsLabel(0.3) + if (resolution.includes('1080p')) return formatCreditsLabel(0.5) } else if (duration.includes('10')) { - if (resolution.includes('720p')) return '$0.4/Run' - if (resolution.includes('1080p')) return '$1.5/Run' + if (resolution.includes('720p')) return formatCreditsLabel(0.4) + if (resolution.includes('1080p')) return formatCreditsLabel(1.5) } - return '$0.3/Run' + return formatCreditsLabel(0.3) } }, PikaStartEndFrameNode2_2: { @@ -1028,21 +1161,23 @@ const apiNodeCosts: Record = ) as IComboWidget if (!durationWidget || !resolutionWidget) { - return '$0.2-1.0/Run (varies with duration & resolution)' + return formatCreditsRangeLabel(0.2, 1.0, { + note: '(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.3/Run' + if (resolution.includes('720p')) return formatCreditsLabel(0.2) + if (resolution.includes('1080p')) return formatCreditsLabel(0.3) } else if (duration.includes('10')) { - if (resolution.includes('720p')) return '$0.25/Run' - if (resolution.includes('1080p')) return '$1.0/Run' + if (resolution.includes('720p')) return formatCreditsLabel(0.25) + if (resolution.includes('1080p')) return formatCreditsLabel(1.0) } - return '$0.2/Run' + return formatCreditsLabel(0.2) } }, PikaTextToVideoNode2_2: { @@ -1055,31 +1190,33 @@ const apiNodeCosts: Record = ) as IComboWidget if (!durationWidget || !resolutionWidget) { - return '$0.2-1.5/Run (varies with duration & resolution)' + return formatCreditsRangeLabel(0.2, 1.5, { + note: '(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' + if (resolution.includes('1080p')) return formatCreditsLabel(0.45) + if (resolution.includes('720p')) return formatCreditsLabel(0.2) } else if (duration.includes('10')) { - if (resolution.includes('1080p')) return '$1.0/Run' - if (resolution.includes('720p')) return '$0.6/Run' + if (resolution.includes('1080p')) return formatCreditsLabel(1.0) + if (resolution.includes('720p')) return formatCreditsLabel(0.6) } - return '$0.45/Run' + return formatCreditsLabel(0.45) } }, Pikadditions: { - displayPrice: '$0.3/Run' + displayPrice: formatCreditsLabel(0.3) }, Pikaffects: { - displayPrice: '$0.45/Run' + displayPrice: formatCreditsLabel(0.45) }, Pikaswaps: { - displayPrice: '$0.3/Run' + displayPrice: formatCreditsLabel(0.3) }, PixverseImageToVideoNode: { displayPrice: pixversePricingCalculator @@ -1091,21 +1228,21 @@ const apiNodeCosts: Record = displayPrice: pixversePricingCalculator }, RecraftCreativeUpscaleNode: { - displayPrice: '$0.25/Run' + displayPrice: formatCreditsLabel(0.25) }, RecraftCrispUpscaleNode: { - displayPrice: '$0.004/Run' + displayPrice: formatCreditsLabel(0.004) }, RecraftGenerateColorFromImageNode: { displayPrice: (node: LGraphNode): string => { const nWidget = node.widgets?.find( (w) => w.name === 'n' ) as IComboWidget - if (!nWidget) return '$0.04 x n/Run' + if (!nWidget) return formatCreditsLabel(0.04, { suffix: ' x n/Run' }) const n = Number(nWidget.value) || 1 - const cost = (0.04 * n).toFixed(2) - return `$${cost}/Run` + const cost = Number((0.04 * n).toFixed(2)) + return formatCreditsLabel(cost) } }, RecraftGenerateImageNode: { @@ -1113,11 +1250,11 @@ const apiNodeCosts: Record = const nWidget = node.widgets?.find( (w) => w.name === 'n' ) as IComboWidget - if (!nWidget) return '$0.04 x n/Run' + if (!nWidget) return formatCreditsLabel(0.04, { suffix: ' x n/Run' }) const n = Number(nWidget.value) || 1 - const cost = (0.04 * n).toFixed(2) - return `$${cost}/Run` + const cost = Number((0.04 * n).toFixed(2)) + return formatCreditsLabel(cost) } }, RecraftGenerateVectorImageNode: { @@ -1125,11 +1262,11 @@ const apiNodeCosts: Record = const nWidget = node.widgets?.find( (w) => w.name === 'n' ) as IComboWidget - if (!nWidget) return '$0.08 x n/Run' + if (!nWidget) return formatCreditsLabel(0.08, { suffix: ' x n/Run' }) const n = Number(nWidget.value) || 1 - const cost = (0.08 * n).toFixed(2) - return `$${cost}/Run` + const cost = Number((0.08 * n).toFixed(2)) + return formatCreditsLabel(cost) } }, RecraftImageInpaintingNode: { @@ -1137,11 +1274,11 @@ const apiNodeCosts: Record = const nWidget = node.widgets?.find( (w) => w.name === 'n' ) as IComboWidget - if (!nWidget) return '$0.04 x n/Run' + if (!nWidget) return formatCreditsLabel(0.04, { suffix: ' x n/Run' }) const n = Number(nWidget.value) || 1 - const cost = (0.04 * n).toFixed(2) - return `$${cost}/Run` + const cost = Number((0.04 * n).toFixed(2)) + return formatCreditsLabel(cost) } }, RecraftImageToImageNode: { @@ -1149,29 +1286,29 @@ const apiNodeCosts: Record = const nWidget = node.widgets?.find( (w) => w.name === 'n' ) as IComboWidget - if (!nWidget) return '$0.04 x n/Run' + if (!nWidget) return formatCreditsLabel(0.04, { suffix: ' x n/Run' }) const n = Number(nWidget.value) || 1 - const cost = (0.04 * n).toFixed(2) - return `$${cost}/Run` + const cost = Number((0.04 * n).toFixed(2)) + return formatCreditsLabel(cost) } }, RecraftRemoveBackgroundNode: { - displayPrice: '$0.01/Run' + displayPrice: formatCreditsLabel(0.01) }, RecraftReplaceBackgroundNode: { - displayPrice: '$0.04/Run' + displayPrice: formatCreditsLabel(0.04) }, RecraftTextToImageNode: { displayPrice: (node: LGraphNode): string => { const nWidget = node.widgets?.find( (w) => w.name === 'n' ) as IComboWidget - if (!nWidget) return '$0.04 x n/Run' + if (!nWidget) return formatCreditsLabel(0.04, { suffix: ' x n/Run' }) const n = Number(nWidget.value) || 1 - const cost = (0.04 * n).toFixed(2) - return `$${cost}/Run` + const cost = Number((0.04 * n).toFixed(2)) + return formatCreditsLabel(cost) } }, RecraftTextToVectorNode: { @@ -1179,11 +1316,11 @@ const apiNodeCosts: Record = const nWidget = node.widgets?.find( (w) => w.name === 'n' ) as IComboWidget - if (!nWidget) return '$0.08 x n/Run' + if (!nWidget) return formatCreditsLabel(0.08, { suffix: ' x n/Run' }) const n = Number(nWidget.value) || 1 - const cost = (0.08 * n).toFixed(2) - return `$${cost}/Run` + const cost = Number((0.08 * n).toFixed(2)) + return formatCreditsLabel(cost) } }, RecraftVectorizeImageNode: { @@ -1191,11 +1328,11 @@ const apiNodeCosts: Record = const nWidget = node.widgets?.find( (w) => w.name === 'n' ) as IComboWidget - if (!nWidget) return '$0.01 x n/Run' + if (!nWidget) return formatCreditsLabel(0.01, { suffix: ' x n/Run' }) const n = Number(nWidget.value) || 1 - const cost = (0.01 * n).toFixed(2) - return `$${cost}/Run` + const cost = Number((0.01 * n).toFixed(2)) + return formatCreditsLabel(cost) } }, StabilityStableImageSD_3_5Node: { @@ -1204,38 +1341,41 @@ const apiNodeCosts: Record = (w) => w.name === 'model' ) as IComboWidget - if (!modelWidget) return '$0.035-0.065/Run (varies with model)' + if (!modelWidget) + return formatCreditsRangeLabel(0.035, 0.065, { + note: '(varies with model)' + }) const model = String(modelWidget.value).toLowerCase() if (model.includes('large')) { - return '$0.065/Run' + return formatCreditsLabel(0.065) } else if (model.includes('medium')) { - return '$0.035/Run' + return formatCreditsLabel(0.035) } - return '$0.035/Run' + return formatCreditsLabel(0.035) } }, StabilityStableImageUltraNode: { - displayPrice: '$0.08/Run' + displayPrice: formatCreditsLabel(0.08) }, StabilityUpscaleConservativeNode: { - displayPrice: '$0.25/Run' + displayPrice: formatCreditsLabel(0.25) }, StabilityUpscaleCreativeNode: { - displayPrice: '$0.25/Run' + displayPrice: formatCreditsLabel(0.25) }, StabilityUpscaleFastNode: { - displayPrice: '$0.01/Run' + displayPrice: formatCreditsLabel(0.01) }, StabilityTextToAudio: { - displayPrice: '$0.20/Run' + displayPrice: formatCreditsLabel(0.2) }, StabilityAudioToAudio: { - displayPrice: '$0.20/Run' + displayPrice: formatCreditsLabel(0.2) }, StabilityAudioInpaint: { - displayPrice: '$0.20/Run' + displayPrice: formatCreditsLabel(0.2) }, VeoVideoGenerationNode: { displayPrice: (node: LGraphNode): string => { @@ -1243,10 +1383,13 @@ const apiNodeCosts: Record = (w) => w.name === 'duration_seconds' ) as IComboWidget - if (!durationWidget) return '$2.50-5.0/Run (varies with duration)' + if (!durationWidget) + return formatCreditsRangeLabel(2.5, 5.0, { + note: '(varies with duration)' + }) const price = 0.5 * Number(durationWidget.value) - return `$${price.toFixed(2)}/Run` + return formatCreditsLabel(price) } }, Veo3VideoGenerationNode: { @@ -1259,7 +1402,9 @@ const apiNodeCosts: Record = ) as IComboWidget if (!modelWidget || !generateAudioWidget) { - return '$0.80-3.20/Run (varies with model & audio generation)' + return formatCreditsRangeLabel(0.8, 3.2, { + note: '(varies with model & audio generation)' + }) } const model = String(modelWidget.value) @@ -1270,16 +1415,20 @@ const apiNodeCosts: Record = model.includes('veo-3.0-fast-generate-001') || model.includes('veo-3.1-fast-generate') ) { - return generateAudio ? '$1.20/Run' : '$0.80/Run' + 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 ? '$3.20/Run' : '$1.60/Run' + return generateAudio + ? formatCreditsLabel(3.2) + : formatCreditsLabel(1.6) } // Default fallback - return '$0.80-3.20/Run' + return formatCreditsRangeLabel(0.8, 3.2) } }, Veo3FirstLastFrameNode: { @@ -1295,7 +1444,9 @@ const apiNodeCosts: Record = ) as IComboWidget if (!modelWidget || !generateAudioWidget || !durationWidget) { - return '$0.40-3.20/Run (varies with model & audio generation)' + return formatCreditsRangeLabel(0.4, 3.2, { + note: '(varies with model & audio generation)' + }) } const model = String(modelWidget.value) @@ -1310,10 +1461,10 @@ const apiNodeCosts: Record = pricePerSecond = generateAudio ? 0.4 : 0.2 } if (pricePerSecond === null) { - return '$0.40-3.20/Run' + return formatCreditsRangeLabel(0.4, 3.2) } const cost = pricePerSecond * seconds - return `$${cost.toFixed(2)}/Run` + return formatCreditsLabel(cost) } }, LumaImageNode: { @@ -1326,18 +1477,20 @@ const apiNodeCosts: Record = ) as IComboWidget if (!modelWidget || !aspectRatioWidget) { - return '$0.0064-0.026/Run (varies with model & aspect ratio)' + return formatCreditsRangeLabel(0.0064, 0.026, { + note: '(varies with model & aspect ratio)' + }) } const model = String(modelWidget.value) if (model.includes('photon-flash-1')) { - return '$0.0027/Run' + return formatCreditsLabel(0.0027) } else if (model.includes('photon-1')) { - return '$0.0104/Run' + return formatCreditsLabel(0.0104) } - return '$0.0246/Run' + return formatCreditsLabel(0.0246) } }, LumaImageModifyNode: { @@ -1347,18 +1500,20 @@ const apiNodeCosts: Record = ) as IComboWidget if (!modelWidget) { - return '$0.0027-0.0104/Run (varies with model)' + return formatCreditsRangeLabel(0.0027, 0.0104, { + note: '(varies with model)' + }) } const model = String(modelWidget.value) if (model.includes('photon-flash-1')) { - return '$0.0027/Run' + return formatCreditsLabel(0.0027) } else if (model.includes('photon-1')) { - return '$0.0104/Run' + return formatCreditsLabel(0.0104) } - return '$0.0246/Run' + return formatCreditsLabel(0.0246) } }, MoonvalleyTxt2VideoNode: { @@ -1368,16 +1523,16 @@ const apiNodeCosts: Record = ) as IComboWidget // If no length widget exists, default to 5s pricing - if (!lengthWidget) return '$1.50/Run' + if (!lengthWidget) return formatCreditsLabel(1.5) const length = String(lengthWidget.value) if (length === '5s') { - return '$1.50/Run' + return formatCreditsLabel(1.5) } else if (length === '10s') { - return '$3.00/Run' + return formatCreditsLabel(3.0) } - return '$1.50/Run' + return formatCreditsLabel(1.5) } }, MoonvalleyImg2VideoNode: { @@ -1387,16 +1542,16 @@ const apiNodeCosts: Record = ) as IComboWidget // If no length widget exists, default to 5s pricing - if (!lengthWidget) return '$1.50/Run' + if (!lengthWidget) return formatCreditsLabel(1.5) const length = String(lengthWidget.value) if (length === '5s') { - return '$1.50/Run' + return formatCreditsLabel(1.5) } else if (length === '10s') { - return '$3.00/Run' + return formatCreditsLabel(3.0) } - return '$1.50/Run' + return formatCreditsLabel(1.5) } }, MoonvalleyVideo2VideoNode: { @@ -1406,21 +1561,21 @@ const apiNodeCosts: Record = ) as IComboWidget // If no length widget exists, default to 5s pricing - if (!lengthWidget) return '$2.25/Run' + if (!lengthWidget) return formatCreditsLabel(2.25) const length = String(lengthWidget.value) if (length === '5s') { - return '$2.25/Run' + return formatCreditsLabel(2.25) } else if (length === '10s') { - return '$4.00/Run' + return formatCreditsLabel(4.0) } - return '$2.25/Run' + return formatCreditsLabel(2.25) } }, // Runway nodes - using actual node names from ComfyUI RunwayTextToImageNode: { - displayPrice: '$0.11/Run' + displayPrice: formatCreditsLabel(0.11) }, RunwayImageToVideoNodeGen3a: { displayPrice: calculateRunwayDurationPrice @@ -1433,16 +1588,16 @@ const apiNodeCosts: Record = }, // Rodin nodes - all have the same pricing structure Rodin3D_Regular: { - displayPrice: '$0.4/Run' + displayPrice: formatCreditsLabel(0.4) }, Rodin3D_Detail: { - displayPrice: '$0.4/Run' + displayPrice: formatCreditsLabel(0.4) }, Rodin3D_Smooth: { - displayPrice: '$0.4/Run' + displayPrice: formatCreditsLabel(0.4) }, Rodin3D_Sketch: { - displayPrice: '$0.4/Run' + displayPrice: formatCreditsLabel(0.4) }, // Tripo nodes - using actual node names from ComfyUI TripoTextToModelNode: { @@ -1461,7 +1616,9 @@ const apiNodeCosts: Record = ) as IComboWidget if (!quadWidget || !styleWidget || !textureWidget) - return '$0.1-0.4/Run (varies with quad, style, texture & quality)' + return formatCreditsRangeLabel(0.1, 0.4, { + note: '(varies with quad, style, texture & quality)' + }) const quad = String(quadWidget.value).toLowerCase() === 'true' const style = String(styleWidget.value).toLowerCase() @@ -1473,29 +1630,29 @@ const apiNodeCosts: Record = // Pricing logic based on CSV data if (style.includes('none')) { if (!quad) { - if (!texture) return '$0.10/Run' - else return '$0.15/Run' + if (!texture) return formatCreditsLabel(0.1) + else return formatCreditsLabel(0.15) } else { if (textureQuality.includes('detailed')) { - if (!texture) return '$0.30/Run' - else return '$0.35/Run' + if (!texture) return formatCreditsLabel(0.3) + else return formatCreditsLabel(0.35) } else { - if (!texture) return '$0.20/Run' - else return '$0.25/Run' + if (!texture) return formatCreditsLabel(0.2) + else return formatCreditsLabel(0.25) } } } else { // any style if (!quad) { - if (!texture) return '$0.15/Run' - else return '$0.20/Run' + if (!texture) return formatCreditsLabel(0.15) + else return formatCreditsLabel(0.2) } else { if (textureQuality.includes('detailed')) { - if (!texture) return '$0.35/Run' - else return '$0.40/Run' + if (!texture) return formatCreditsLabel(0.35) + else return formatCreditsLabel(0.4) } else { - if (!texture) return '$0.25/Run' - else return '$0.30/Run' + if (!texture) return formatCreditsLabel(0.25) + else return formatCreditsLabel(0.3) } } } @@ -1517,7 +1674,9 @@ const apiNodeCosts: Record = ) as IComboWidget if (!quadWidget || !styleWidget || !textureWidget) - return '$0.2-0.5/Run (varies with quad, style, texture & quality)' + return formatCreditsRangeLabel(0.2, 0.5, { + note: '(varies with quad, style, texture & quality)' + }) const quad = String(quadWidget.value).toLowerCase() === 'true' const style = String(styleWidget.value).toLowerCase() @@ -1529,36 +1688,36 @@ const apiNodeCosts: Record = // Pricing logic based on CSV data for Image to Model if (style.includes('none')) { if (!quad) { - if (!texture) return '$0.20/Run' - else return '$0.25/Run' + if (!texture) return formatCreditsLabel(0.2) + else return formatCreditsLabel(0.25) } else { if (textureQuality.includes('detailed')) { - if (!texture) return '$0.40/Run' - else return '$0.45/Run' + if (!texture) return formatCreditsLabel(0.4) + else return formatCreditsLabel(0.45) } else { - if (!texture) return '$0.30/Run' - else return '$0.35/Run' + if (!texture) return formatCreditsLabel(0.3) + else return formatCreditsLabel(0.35) } } } else { // any style if (!quad) { - if (!texture) return '$0.25/Run' - else return '$0.30/Run' + if (!texture) return formatCreditsLabel(0.25) + else return formatCreditsLabel(0.3) } else { if (textureQuality.includes('detailed')) { - if (!texture) return '$0.45/Run' - else return '$0.50/Run' + if (!texture) return formatCreditsLabel(0.45) + else return formatCreditsLabel(0.5) } else { - if (!texture) return '$0.35/Run' - else return '$0.40/Run' + if (!texture) return formatCreditsLabel(0.35) + else return formatCreditsLabel(0.4) } } } } }, TripoRefineNode: { - displayPrice: '$0.3/Run' + displayPrice: formatCreditsLabel(0.3) }, TripoTextureNode: { displayPrice: (node: LGraphNode): string => { @@ -1566,17 +1725,22 @@ const apiNodeCosts: Record = (w) => w.name === 'texture_quality' ) as IComboWidget - if (!textureQualityWidget) return '$0.1-0.2/Run (varies with quality)' + if (!textureQualityWidget) + return formatCreditsRangeLabel(0.1, 0.2, { + note: '(varies with quality)' + }) const textureQuality = String(textureQualityWidget.value) - return textureQuality.includes('detailed') ? '$0.2/Run' : '$0.1/Run' + return textureQuality.includes('detailed') + ? formatCreditsLabel(0.2) + : formatCreditsLabel(0.1) } }, TripoConvertModelNode: { - displayPrice: '$0.10/Run' + displayPrice: formatCreditsLabel(0.1) }, TripoRetargetRiggedModelNode: { - displayPrice: '$0.10/Run' + displayPrice: formatCreditsLabel(0.1) }, TripoMultiviewToModelNode: { displayPrice: (node: LGraphNode): string => { @@ -1594,7 +1758,9 @@ const apiNodeCosts: Record = ) as IComboWidget if (!quadWidget || !styleWidget || !textureWidget) - return '$0.2-0.5/Run (varies with quad, style, texture & quality)' + return formatCreditsRangeLabel(0.2, 0.5, { + note: '(varies with quad, style, texture & quality)' + }) const quad = String(quadWidget.value).toLowerCase() === 'true' const style = String(styleWidget.value).toLowerCase() @@ -1606,29 +1772,29 @@ const apiNodeCosts: Record = // Pricing logic based on CSV data for Multiview to Model (same as Image to Model) if (style.includes('none')) { if (!quad) { - if (!texture) return '$0.20/Run' - else return '$0.25/Run' + if (!texture) return formatCreditsLabel(0.2) + else return formatCreditsLabel(0.25) } else { if (textureQuality.includes('detailed')) { - if (!texture) return '$0.40/Run' - else return '$0.45/Run' + if (!texture) return formatCreditsLabel(0.4) + else return formatCreditsLabel(0.45) } else { - if (!texture) return '$0.30/Run' - else return '$0.35/Run' + if (!texture) return formatCreditsLabel(0.3) + else return formatCreditsLabel(0.35) } } } else { // any style if (!quad) { - if (!texture) return '$0.25/Run' - else return '$0.30/Run' + if (!texture) return formatCreditsLabel(0.25) + else return formatCreditsLabel(0.3) } else { if (textureQuality.includes('detailed')) { - if (!texture) return '$0.45/Run' - else return '$0.50/Run' + if (!texture) return formatCreditsLabel(0.45) + else return formatCreditsLabel(0.5) } else { - if (!texture) return '$0.35/Run' - else return '$0.40/Run' + if (!texture) return formatCreditsLabel(0.35) + else return formatCreditsLabel(0.4) } } } @@ -1647,24 +1813,37 @@ const apiNodeCosts: Record = // Google Veo video generation if (model.includes('veo-2.0')) { - return '$0.5/second' + return formatCreditsLabel(0.5, { suffix: '/second' }) } else if (model.includes('gemini-2.5-flash-preview-04-17')) { - return '$0.0003/$0.0025 per 1K tokens' + return formatCreditsListLabel([0.0003, 0.0025], { + suffix: ' per 1K tokens' + }) } else if (model.includes('gemini-2.5-flash')) { - return '$0.0003/$0.0025 per 1K tokens' + return formatCreditsListLabel([0.0003, 0.0025], { + suffix: ' per 1K tokens' + }) } else if (model.includes('gemini-2.5-pro-preview-05-06')) { - return '$0.00125/$0.01 per 1K tokens' + return formatCreditsListLabel([0.00125, 0.01], { + suffix: ' per 1K tokens' + }) } else if (model.includes('gemini-2.5-pro')) { - return '$0.00125/$0.01 per 1K tokens' + return formatCreditsListLabel([0.00125, 0.01], { + suffix: ' per 1K tokens' + }) } else if (model.includes('gemini-3-pro-preview')) { - return '$0.002/$0.012 per 1K tokens' + return formatCreditsListLabel([0.002, 0.012], { + suffix: ' per 1K tokens' + }) } // For other Gemini models, show token-based pricing info return 'Token-based' } }, GeminiImageNode: { - displayPrice: '~$0.039/Image (1K)' + displayPrice: formatCreditsLabel(0.039, { + suffix: '/Image (1K)', + approximate: true + }) }, GeminiImage2Node: { displayPrice: (node: LGraphNode): string => { @@ -1676,11 +1855,20 @@ const apiNodeCosts: Record = const resolution = String(resolutionWidget.value) if (resolution.includes('1K')) { - return '~$0.134/Image' + return formatCreditsLabel(0.134, { + suffix: '/Image', + approximate: true + }) } else if (resolution.includes('2K')) { - return '~$0.134/Image' + return formatCreditsLabel(0.134, { + suffix: '/Image', + approximate: true + }) } else if (resolution.includes('4K')) { - return '~$0.24/Image' + return formatCreditsLabel(0.24, { + suffix: '/Image', + approximate: true + }) } return 'Token-based' } @@ -1698,44 +1886,68 @@ const apiNodeCosts: Record = // Specific pricing for exposed models based on official pricing data (converted to per 1K tokens) if (model.includes('o4-mini')) { - return '$0.0011/$0.0044 per 1K tokens' + return formatCreditsListLabel([0.0011, 0.0044], { + suffix: ' per 1K tokens' + }) } else if (model.includes('o1-pro')) { - return '$0.15/$0.60 per 1K tokens' + return formatCreditsListLabel([0.15, 0.6], { + suffix: ' per 1K tokens' + }) } else if (model.includes('o1')) { - return '$0.015/$0.06 per 1K tokens' + return formatCreditsListLabel([0.015, 0.06], { + suffix: ' per 1K tokens' + }) } else if (model.includes('o3-mini')) { - return '$0.0011/$0.0044 per 1K tokens' + return formatCreditsListLabel([0.0011, 0.0044], { + suffix: ' per 1K tokens' + }) } else if (model.includes('o3')) { - return '$0.01/$0.04 per 1K tokens' + return formatCreditsListLabel([0.01, 0.04], { + suffix: ' per 1K tokens' + }) } else if (model.includes('gpt-4o')) { - return '$0.0025/$0.01 per 1K tokens' + return formatCreditsListLabel([0.0025, 0.01], { + suffix: ' per 1K tokens' + }) } else if (model.includes('gpt-4.1-nano')) { - return '$0.0001/$0.0004 per 1K tokens' + return formatCreditsListLabel([0.0001, 0.0004], { + suffix: ' per 1K tokens' + }) } else if (model.includes('gpt-4.1-mini')) { - return '$0.0004/$0.0016 per 1K tokens' + return formatCreditsListLabel([0.0004, 0.0016], { + suffix: ' per 1K tokens' + }) } else if (model.includes('gpt-4.1')) { - return '$0.002/$0.008 per 1K tokens' + return formatCreditsListLabel([0.002, 0.008], { + suffix: ' per 1K tokens' + }) } else if (model.includes('gpt-5-nano')) { - return '$0.00005/$0.0004 per 1K tokens' + return formatCreditsListLabel([0.00005, 0.0004], { + suffix: ' per 1K tokens' + }) } else if (model.includes('gpt-5-mini')) { - return '$0.00025/$0.002 per 1K tokens' + return formatCreditsListLabel([0.00025, 0.002], { + suffix: ' per 1K tokens' + }) } else if (model.includes('gpt-5')) { - return '$0.00125/$0.01 per 1K tokens' + return formatCreditsListLabel([0.00125, 0.01], { + suffix: ' per 1K tokens' + }) } return 'Token-based' } }, ViduTextToVideoNode: { - displayPrice: '$0.4/Run' + displayPrice: formatCreditsLabel(0.4) }, ViduImageToVideoNode: { - displayPrice: '$0.4/Run' + displayPrice: formatCreditsLabel(0.4) }, ViduReferenceVideoNode: { - displayPrice: '$0.4/Run' + displayPrice: formatCreditsLabel(0.4) }, ViduStartEndToVideoNode: { - displayPrice: '$0.4/Run' + displayPrice: formatCreditsLabel(0.4) }, ByteDanceImageNode: { displayPrice: (node: LGraphNode): string => { @@ -1748,7 +1960,7 @@ const apiNodeCosts: Record = const model = String(modelWidget.value) if (model.includes('seedream-3-0-t2i')) { - return '$0.03/Run' + return formatCreditsLabel(0.03) } return 'Token-based' } @@ -1764,7 +1976,7 @@ const apiNodeCosts: Record = const model = String(modelWidget.value) if (model.includes('seededit-3-0-i2i')) { - return '$0.03/Run' + return formatCreditsLabel(0.03) } return 'Token-based' } @@ -1790,22 +2002,24 @@ const apiNodeCosts: Record = } if (!sequentialGenerationWidget || !maxImagesWidget) { - return `$${pricePerImage}/Run ($${pricePerImage} for one output image)` + const perImageLabel = formatCreditsValue(pricePerImage) + return `${formatCreditsLabel(pricePerImage)} (${perImageLabel} credits for one output image)` } const seqMode = String(sequentialGenerationWidget.value).toLowerCase() if (seqMode === 'disabled') { - return `$${pricePerImage}/Run` + return formatCreditsLabel(pricePerImage) } const maxImagesRaw = Number(maxImagesWidget.value) const maxImages = Number.isFinite(maxImagesRaw) && maxImagesRaw > 0 ? maxImagesRaw : 1 if (maxImages === 1) { - return `$${pricePerImage}/Run` + return formatCreditsLabel(pricePerImage) } - const totalCost = (pricePerImage * maxImages).toFixed(2) - return `$${totalCost}/Run ($${pricePerImage} for one output image)` + const totalCost = pricePerImage * maxImages + const perImageLabel = formatCreditsValue(pricePerImage) + return `${formatCreditsLabel(totalCost)} (${perImageLabel} credits for one output image)` } }, ByteDanceTextToVideoNode: { @@ -1829,7 +2043,8 @@ const apiNodeCosts: Record = (w) => w.name === 'size' ) as IComboWidget - if (!durationWidget || !resolutionWidget) return '$0.05-0.15/second' + if (!durationWidget || !resolutionWidget) + return formatCreditsRangeLabel(0.05, 0.15, { suffix: '/second' }) const seconds = parseFloat(String(durationWidget.value)) const resolutionStr = String(resolutionWidget.value).toLowerCase() @@ -1849,10 +2064,11 @@ const apiNodeCosts: Record = } const pps = pricePerSecond[resKey] - if (isNaN(seconds) || !pps) return '$0.05-0.15/second' + if (isNaN(seconds) || !pps) + return formatCreditsRangeLabel(0.05, 0.15, { suffix: '/second' }) - const cost = (pps * seconds).toFixed(2) - return `$${cost}/Run` + const cost = Number((pps * seconds).toFixed(2)) + return formatCreditsLabel(cost) } }, WanImageToVideoApi: { @@ -1864,7 +2080,8 @@ const apiNodeCosts: Record = (w) => w.name === 'resolution' ) as IComboWidget - if (!durationWidget || !resolutionWidget) return '$0.05-0.15/second' + if (!durationWidget || !resolutionWidget) + return formatCreditsRangeLabel(0.05, 0.15, { suffix: '/second' }) const seconds = parseFloat(String(durationWidget.value)) const resolution = String(resolutionWidget.value).trim().toLowerCase() @@ -1876,17 +2093,18 @@ const apiNodeCosts: Record = } const pps = pricePerSecond[resolution] - if (isNaN(seconds) || !pps) return '$0.05-0.15/second' + if (isNaN(seconds) || !pps) + return formatCreditsRangeLabel(0.05, 0.15, { suffix: '/second' }) - const cost = (pps * seconds).toFixed(2) - return `$${cost}/Run` + const cost = Number((pps * seconds).toFixed(2)) + return formatCreditsLabel(cost) } }, WanTextToImageApi: { - displayPrice: '$0.03/Run' + displayPrice: formatCreditsLabel(0.03) }, WanImageToImageApi: { - displayPrice: '$0.03/Run' + displayPrice: formatCreditsLabel(0.03) }, LtxvApiTextToVideo: { displayPrice: ltxvPricingCalculator diff --git a/tests-ui/tests/composables/node/useNodePricing.test.ts b/tests-ui/tests/composables/node/useNodePricing.test.ts index ae62699c52..02db4dd43c 100644 --- a/tests-ui/tests/composables/node/useNodePricing.test.ts +++ b/tests-ui/tests/composables/node/useNodePricing.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest' +import { formatCreditsFromUsd } 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' @@ -66,7 +67,7 @@ describe('useNodePricing', () => { const node = createMockNode('FluxProCannyNode') const price = getNodeDisplayPrice(node) - expect(price).toBe('$0.05/Run') + expect(price).toBe(creditsLabel(0.05)) }) it('should return static price for StabilityStableImageUltraNode', () => { @@ -74,7 +75,7 @@ describe('useNodePricing', () => { const node = createMockNode('StabilityStableImageUltraNode') const price = getNodeDisplayPrice(node) - expect(price).toBe('$0.08/Run') + expect(price).toBe(creditsLabel(0.08)) }) it('should return empty string for non-API nodes', () => { @@ -103,7 +104,7 @@ describe('useNodePricing', () => { ]) // 1024x1024 => 1 MP => $0.03 - expect(getNodeDisplayPrice(node)).toBe('$0.03/Run') + expect(getNodeDisplayPrice(node)).toBe(creditsLabel(0.03)) }) it('should return minimum estimate when refs are connected (1024x1024)', () => { @@ -120,13 +121,15 @@ describe('useNodePricing', () => { ) // 1024x1024 => 1 MP output = $0.03, min input add = $0.015 => ~$0.045 min - expect(getNodeDisplayPrice(node)).toBe('~$0.045–$0.15/Run') + expect(getNodeDisplayPrice(node)).toBe( + creditsRangeLabel(0.045, 0.15, { approximate: true }) + ) }) it('should show fallback when width/height are missing', () => { const { getNodeDisplayPrice } = useNodePricing() const node = createMockNode('Flux2ProImageNode', []) - expect(getNodeDisplayPrice(node)).toBe('$0.03–$0.15/Run') + expect(getNodeDisplayPrice(node)).toBe(creditsRangeLabel(0.03, 0.15)) }) }) @@ -138,7 +141,7 @@ describe('useNodePricing', () => { ]) const price = getNodeDisplayPrice(node) - expect(price).toBe('$1.40/Run') + expect(price).toBe(creditsLabel(1.4)) }) it('should return high price for kling-v2-master model', () => { @@ -148,7 +151,7 @@ describe('useNodePricing', () => { ]) const price = getNodeDisplayPrice(node) - expect(price).toBe('$1.40/Run') + expect(price).toBe(creditsLabel(1.4)) }) it('should return low price for kling-v2-turbo model', () => { @@ -158,7 +161,7 @@ describe('useNodePricing', () => { ]) const price = getNodeDisplayPrice(node) - expect(price).toBe('$0.35/Run') + expect(price).toBe(creditsLabel(0.35)) }) it('should return high price for kling-v2-turbo model', () => { @@ -168,7 +171,7 @@ describe('useNodePricing', () => { ]) const price = getNodeDisplayPrice(node) - expect(price).toBe('$0.70/Run') + expect(price).toBe(creditsLabel(0.7)) }) it('should return standard price for kling-v1-6 model', () => { @@ -178,7 +181,7 @@ describe('useNodePricing', () => { ]) const price = getNodeDisplayPrice(node) - expect(price).toBe('$0.28/Run') + expect(price).toBe(creditsLabel(0.28)) }) it('should return range when mode widget is missing', () => { @@ -186,7 +189,11 @@ describe('useNodePricing', () => { const node = createMockNode('KlingTextToVideoNode', []) const price = getNodeDisplayPrice(node) - expect(price).toBe('$0.14-2.80/Run (varies with model, mode & duration)') + expect(price).toBe( + creditsRangeLabel(0.14, 2.8, { + note: '(varies with model, mode & duration)' + }) + ) }) }) @@ -198,7 +205,7 @@ describe('useNodePricing', () => { ]) const price = getNodeDisplayPrice(node) - expect(price).toBe('$1.40/Run') + expect(price).toBe(creditsLabel(1.4)) }) it('should return high price for kling-v2-1-master model', () => { @@ -208,7 +215,7 @@ describe('useNodePricing', () => { ]) const price = getNodeDisplayPrice(node) - expect(price).toBe('$1.40/Run') + expect(price).toBe(creditsLabel(1.4)) }) it('should return high price for kling-v2-5-turbo model', () => { @@ -220,7 +227,7 @@ describe('useNodePricing', () => { ]) const price = getNodeDisplayPrice(node) - expect(price).toBe('$0.70/Run') + expect(price).toBe(creditsLabel(0.7)) }) it('should return standard price for kling-v1-6 model', () => { @@ -230,7 +237,7 @@ describe('useNodePricing', () => { ]) const price = getNodeDisplayPrice(node) - expect(price).toBe('$0.28/Run') + expect(price).toBe(creditsLabel(0.28)) }) it('should return range when model_name widget is missing', () => { @@ -238,7 +245,11 @@ describe('useNodePricing', () => { const node = createMockNode('KlingImage2VideoNode', []) const price = getNodeDisplayPrice(node) - expect(price).toBe('$0.14-2.80/Run (varies with model, mode & duration)') + expect(price).toBe( + creditsRangeLabel(0.14, 2.8, { + note: '(varies with model, mode & duration)' + }) + ) }) }) @@ -251,7 +262,7 @@ describe('useNodePricing', () => { ]) const price = getNodeDisplayPrice(node) - expect(price).toBe('$0.04/Run') + expect(price).toBe(creditsLabel(0.04)) }) it('should return $0.08 for 1024x1024 hd quality', () => { @@ -262,7 +273,7 @@ describe('useNodePricing', () => { ]) const price = getNodeDisplayPrice(node) - expect(price).toBe('$0.08/Run') + expect(price).toBe(creditsLabel(0.08)) }) it('should return $0.08 for 1792x1024 standard quality', () => { @@ -273,7 +284,7 @@ describe('useNodePricing', () => { ]) const price = getNodeDisplayPrice(node) - expect(price).toBe('$0.08/Run') + expect(price).toBe(creditsLabel(0.08)) }) it('should return $0.16 for 1792x1024 hd quality', () => { @@ -284,7 +295,7 @@ describe('useNodePricing', () => { ]) const price = getNodeDisplayPrice(node) - expect(price).toBe('$0.12/Run') + expect(price).toBe(creditsLabel(0.12)) }) it('should return $0.08 for 1024x1792 standard quality', () => { @@ -295,7 +306,7 @@ describe('useNodePricing', () => { ]) const price = getNodeDisplayPrice(node) - expect(price).toBe('$0.08/Run') + expect(price).toBe(creditsLabel(0.08)) }) it('should return $0.16 for 1024x1792 hd quality', () => { @@ -306,7 +317,7 @@ describe('useNodePricing', () => { ]) const price = getNodeDisplayPrice(node) - expect(price).toBe('$0.12/Run') + expect(price).toBe(creditsLabel(0.12)) }) it('should return range when widgets are missing', () => { @@ -314,7 +325,9 @@ describe('useNodePricing', () => { const node = createMockNode('OpenAIDalle3', []) const price = getNodeDisplayPrice(node) - expect(price).toBe('$0.04-0.12/Run (varies with size & quality)') + expect(price).toBe( + creditsRangeLabel(0.04, 0.12, { note: '(varies with size & quality)' }) + ) }) it('should return range when size widget is missing', () => { @@ -324,7 +337,9 @@ describe('useNodePricing', () => { ]) const price = getNodeDisplayPrice(node) - expect(price).toBe('$0.04-0.12/Run (varies with size & quality)') + expect(price).toBe( + creditsRangeLabel(0.04, 0.12, { note: '(varies with size & quality)' }) + ) }) it('should return range when quality widget is missing', () => { @@ -334,7 +349,9 @@ describe('useNodePricing', () => { ]) const price = getNodeDisplayPrice(node) - expect(price).toBe('$0.04-0.12/Run (varies with size & quality)') + expect(price).toBe( + creditsRangeLabel(0.04, 0.12, { note: '(varies with size & quality)' }) + ) }) }) // ============================== OpenAIVideoSora2 ============================== @@ -378,7 +395,7 @@ describe('useNodePricing', () => { { name: 'duration', value: 8 }, { name: 'size', value: '1024x1792' } ]) - expect(getNodeDisplayPrice(node)).toBe('$4.00/Run') // 0.5 * 8 + expect(getNodeDisplayPrice(node)).toBe(creditsLabel(4.0)) // 0.5 * 8 }) it('should compute pricing for sora-2-pro with 720x1280', () => { @@ -388,7 +405,7 @@ describe('useNodePricing', () => { { name: 'duration', value: 12 }, { name: 'size', value: '720x1280' } ]) - expect(getNodeDisplayPrice(node)).toBe('$3.60/Run') // 0.3 * 12 + expect(getNodeDisplayPrice(node)).toBe(creditsLabel(3.6)) // 0.3 * 12 }) it('should reject unsupported size for sora-2-pro', () => { @@ -410,7 +427,7 @@ describe('useNodePricing', () => { { name: 'duration', value: 10 }, { name: 'size', value: '720x1280' } ]) - expect(getNodeDisplayPrice(node)).toBe('$1.00/Run') // 0.1 * 10 + expect(getNodeDisplayPrice(node)).toBe(creditsLabel(1.0)) // 0.1 * 10 }) it('should reject non-720 sizes for sora-2', () => { @@ -431,7 +448,7 @@ describe('useNodePricing', () => { { name: 'duration_s', value: 4 }, { name: 'size', value: '1792x1024' } ]) - expect(getNodeDisplayPrice(node)).toBe('$2.00/Run') // 0.5 * 4 + expect(getNodeDisplayPrice(node)).toBe(creditsLabel(2.0)) // 0.5 * 4 }) it('should be case-insensitive for model and size', () => { @@ -441,7 +458,7 @@ describe('useNodePricing', () => { { name: 'duration', value: 12 }, { name: 'size', value: '1280x720' } ]) - expect(getNodeDisplayPrice(node)).toBe('$3.60/Run') // 0.3 * 12 + expect(getNodeDisplayPrice(node)).toBe(creditsLabel(3.6)) // 0.3 * 12 }) }) @@ -455,7 +472,7 @@ describe('useNodePricing', () => { ]) const price = getNodeDisplayPrice(node) - expect(price).toBe('$0.28/Run') + expect(price).toBe(creditsLabel(0.28)) }) it('should return $0.60 for 10s duration and 768P resolution', () => { @@ -466,7 +483,7 @@ describe('useNodePricing', () => { ]) const price = getNodeDisplayPrice(node) - expect(price).toBe('$0.56/Run') + expect(price).toBe(creditsLabel(0.56)) }) it('should return $0.49 for 6s duration and 1080P resolution', () => { @@ -477,7 +494,7 @@ describe('useNodePricing', () => { ]) const price = getNodeDisplayPrice(node) - expect(price).toBe('$0.49/Run') + expect(price).toBe(creditsLabel(0.49)) }) it('should return range when duration widget is missing', () => { @@ -485,7 +502,11 @@ describe('useNodePricing', () => { const node = createMockNode('MinimaxHailuoVideoNode', []) const price = getNodeDisplayPrice(node) - expect(price).toBe('$0.28-0.56/Run (varies with resolution & duration)') + expect(price).toBe( + creditsRangeLabel(0.28, 0.56, { + note: '(varies with resolution & duration)' + }) + ) }) }) @@ -497,7 +518,7 @@ describe('useNodePricing', () => { ]) const price = getNodeDisplayPrice(node) - expect(price).toBe('$0.020/Run') + expect(price).toBe(creditsLabel(0.02)) }) it('should return $0.018 for 512x512 size', () => { @@ -507,7 +528,7 @@ describe('useNodePricing', () => { ]) const price = getNodeDisplayPrice(node) - expect(price).toBe('$0.018/Run') + expect(price).toBe(creditsLabel(0.018)) }) it('should return $0.016 for 256x256 size', () => { @@ -517,7 +538,7 @@ describe('useNodePricing', () => { ]) const price = getNodeDisplayPrice(node) - expect(price).toBe('$0.016/Run') + expect(price).toBe(creditsLabel(0.016)) }) it('should return range when size widget is missing', () => { @@ -525,7 +546,12 @@ describe('useNodePricing', () => { const node = createMockNode('OpenAIDalle2', []) const price = getNodeDisplayPrice(node) - expect(price).toBe('$0.016-0.02 x n/Run (varies with size & n)') + expect(price).toBe( + creditsRangeLabel(0.016, 0.02, { + suffix: ' x n/Run', + note: '(varies with size & n)' + }) + ) }) }) @@ -537,7 +563,7 @@ describe('useNodePricing', () => { ]) const price = getNodeDisplayPrice(node) - expect(price).toBe('$0.167-0.30/Run') + expect(price).toBe(creditsRangeLabel(0.167, 0.3)) }) it('should return medium price range for medium quality', () => { @@ -547,7 +573,7 @@ describe('useNodePricing', () => { ]) const price = getNodeDisplayPrice(node) - expect(price).toBe('$0.046-0.07/Run') + expect(price).toBe(creditsRangeLabel(0.046, 0.07)) }) it('should return low price range for low quality', () => { @@ -557,7 +583,7 @@ describe('useNodePricing', () => { ]) const price = getNodeDisplayPrice(node) - expect(price).toBe('$0.011-0.02/Run') + expect(price).toBe(creditsRangeLabel(0.011, 0.02)) }) it('should return range when quality widget is missing', () => { @@ -565,7 +591,12 @@ describe('useNodePricing', () => { const node = createMockNode('OpenAIGPTImage1', []) const price = getNodeDisplayPrice(node) - expect(price).toBe('$0.011-0.30 x n/Run (varies with quality & n)') + expect(price).toBe( + creditsRangeLabel(0.011, 0.3, { + suffix: ' x n/Run', + note: '(varies with quality & n)' + }) + ) }) }) @@ -577,32 +608,32 @@ describe('useNodePricing', () => { { rendering_speed: 'Quality', character_image: false, - expected: '$0.13/Run' + expected: creditsLabel(0.13) }, { rendering_speed: 'Quality', character_image: true, - expected: '$0.29/Run' + expected: creditsLabel(0.29) }, { rendering_speed: 'Default', character_image: false, - expected: '$0.09/Run' + expected: creditsLabel(0.09) }, { rendering_speed: 'Default', character_image: true, - expected: '$0.21/Run' + expected: creditsLabel(0.21) }, { rendering_speed: 'Turbo', character_image: false, - expected: '$0.04/Run' + expected: creditsLabel(0.04) }, { rendering_speed: 'Turbo', character_image: true, - expected: '$0.14/Run' + expected: creditsLabel(0.14) } ] @@ -623,7 +654,10 @@ describe('useNodePricing', () => { const price = getNodeDisplayPrice(node) expect(price).toBe( - '$0.04-0.11 x num_images/Run (varies with rendering speed & num_images)' + creditsRangeLabel(0.04, 0.11, { + suffix: ' x num_images/Run', + note: '(varies with rendering speed & num_images)' + }) ) }) @@ -635,7 +669,7 @@ describe('useNodePricing', () => { ]) const price = getNodeDisplayPrice(node) - expect(price).toBe('$0.39/Run') // 0.09 * 3 * 1.43 + expect(price).toBe(creditsLabel(0.39)) // 0.09 * 3 * 1.43 }) it('should multiply price by num_images for Turbo rendering speed', () => { @@ -646,7 +680,7 @@ describe('useNodePricing', () => { ]) const price = getNodeDisplayPrice(node) - expect(price).toBe('$0.21/Run') // 0.03 * 5 * 1.43 + expect(price).toBe(creditsLabel(0.21)) // 0.03 * 5 * 1.43 }) }) @@ -658,7 +692,7 @@ describe('useNodePricing', () => { ]) const price = getNodeDisplayPrice(node) - expect(price).toBe('$5.00/Run') + expect(price).toBe(creditsLabel(5.0)) }) it('should return $2.50 for 5s duration', () => { @@ -668,7 +702,7 @@ describe('useNodePricing', () => { ]) const price = getNodeDisplayPrice(node) - expect(price).toBe('$2.50/Run') + expect(price).toBe(creditsLabel(2.5)) }) it('should return range when duration widget is missing', () => { @@ -676,7 +710,9 @@ describe('useNodePricing', () => { const node = createMockNode('VeoVideoGenerationNode', []) const price = getNodeDisplayPrice(node) - expect(price).toBe('$2.50-5.0/Run (varies with duration)') + expect(price).toBe( + creditsRangeLabel(2.5, 5.0, { note: '(varies with duration)' }) + ) }) }) @@ -689,7 +725,7 @@ describe('useNodePricing', () => { ]) const price = getNodeDisplayPrice(node) - expect(price).toBe('$0.80/Run') + expect(price).toBe(creditsLabel(0.8)) }) it('should return $1.20 for veo-3.0-fast-generate-001 with audio', () => { @@ -700,7 +736,7 @@ describe('useNodePricing', () => { ]) const price = getNodeDisplayPrice(node) - expect(price).toBe('$1.20/Run') + expect(price).toBe(creditsLabel(1.2)) }) it('should return $1.60 for veo-3.0-generate-001 without audio', () => { @@ -711,7 +747,7 @@ describe('useNodePricing', () => { ]) const price = getNodeDisplayPrice(node) - expect(price).toBe('$1.60/Run') + expect(price).toBe(creditsLabel(1.6)) }) it('should return $3.20 for veo-3.0-generate-001 with audio', () => { @@ -722,7 +758,7 @@ describe('useNodePricing', () => { ]) const price = getNodeDisplayPrice(node) - expect(price).toBe('$3.20/Run') + expect(price).toBe(creditsLabel(3.2)) }) it('should return range when widgets are missing', () => { @@ -731,7 +767,9 @@ describe('useNodePricing', () => { const price = getNodeDisplayPrice(node) expect(price).toBe( - '$0.80-3.20/Run (varies with model & audio generation)' + creditsRangeLabel(0.8, 3.2, { + note: 'varies with model & audio generation' + }) ) }) @@ -743,7 +781,9 @@ describe('useNodePricing', () => { const price = getNodeDisplayPrice(node) expect(price).toBe( - '$0.80-3.20/Run (varies with model & audio generation)' + creditsRangeLabel(0.8, 3.2, { + note: 'varies with model & audio generation' + }) ) }) @@ -755,7 +795,9 @@ describe('useNodePricing', () => { const price = getNodeDisplayPrice(node) expect(price).toBe( - '$0.80-3.20/Run (varies with model & audio generation)' + creditsRangeLabel(0.8, 3.2, { + note: 'varies with model & audio generation' + }) ) }) }) @@ -770,7 +812,7 @@ describe('useNodePricing', () => { ]) const price = getNodeDisplayPrice(node) - expect(price).toBe('$3.13/Run') + expect(price).toBe(creditsLabel(3.13)) }) it('should return $6.37 for ray-2 4K 5s', () => { @@ -782,7 +824,7 @@ describe('useNodePricing', () => { ]) const price = getNodeDisplayPrice(node) - expect(price).toBe('$9.11/Run') + expect(price).toBe(creditsLabel(9.11)) }) it('should return $0.35 for ray-1-6 model', () => { @@ -794,7 +836,7 @@ describe('useNodePricing', () => { ]) const price = getNodeDisplayPrice(node) - expect(price).toBe('$0.50/Run') + expect(price).toBe(creditsLabel(0.5)) }) it('should return range when widgets are missing', () => { @@ -803,7 +845,9 @@ describe('useNodePricing', () => { const price = getNodeDisplayPrice(node) expect(price).toBe( - '$0.20-16.40/Run (varies with model, resolution & duration)' + creditsRangeLabel(0.2, 16.4, { + note: 'varies with model, resolution & duration' + }) ) }) }) @@ -818,7 +862,9 @@ describe('useNodePricing', () => { const price = getNodeDisplayPrice(node) expect(price).toBe( - '$0.45-1.2/Run (varies with duration, quality & motion mode)' + creditsRangeLabel(0.45, 1.2, { + note: 'varies with duration, quality & motion mode' + }) ) }) @@ -832,7 +878,9 @@ describe('useNodePricing', () => { const price = getNodeDisplayPrice(node) expect(price).toBe( - '$0.45-1.2/Run (varies with duration, quality & motion mode)' + creditsRangeLabel(0.45, 1.2, { + note: 'varies with duration, quality & motion mode' + }) ) }) @@ -842,7 +890,9 @@ describe('useNodePricing', () => { const price = getNodeDisplayPrice(node) expect(price).toBe( - '$0.45-1.2/Run (varies with duration, quality & motion mode)' + creditsRangeLabel(0.45, 1.2, { + note: 'varies with duration, quality & motion mode' + }) ) }) }) @@ -855,7 +905,11 @@ describe('useNodePricing', () => { ]) const price = getNodeDisplayPrice(node) - expect(price).toBe('$0.14-2.80/Run (varies with model, mode & duration)') + expect(price).toBe( + creditsRangeLabel(0.14, 2.8, { + note: '(varies with model, mode & duration)' + }) + ) }) it('should return range for v1-6 5s mode', () => { @@ -865,7 +919,11 @@ describe('useNodePricing', () => { ]) const price = getNodeDisplayPrice(node) - expect(price).toBe('$0.14-2.80/Run (varies with model, mode & duration)') + expect(price).toBe( + creditsRangeLabel(0.14, 2.8, { + note: '(varies with model, mode & duration)' + }) + ) }) it('should return range when mode widget is missing', () => { @@ -873,7 +931,11 @@ describe('useNodePricing', () => { const node = createMockNode('KlingDualCharacterVideoEffectNode', []) const price = getNodeDisplayPrice(node) - expect(price).toBe('$0.14-2.80/Run (varies with model, mode & duration)') + expect(price).toBe( + creditsRangeLabel(0.14, 2.8, { + note: '(varies with model, mode & duration)' + }) + ) }) }) @@ -885,7 +947,7 @@ describe('useNodePricing', () => { ]) const price = getNodeDisplayPrice(node) - expect(price).toBe('$0.28/Run') + expect(price).toBe(creditsLabel(0.28)) }) it('should return $0.49 for dizzydizzy effect', () => { @@ -895,7 +957,7 @@ describe('useNodePricing', () => { ]) const price = getNodeDisplayPrice(node) - expect(price).toBe('$0.49/Run') + expect(price).toBe(creditsLabel(0.49)) }) it('should return range when effect_scene widget is missing', () => { @@ -903,7 +965,9 @@ describe('useNodePricing', () => { const node = createMockNode('KlingSingleImageVideoEffectNode', []) const price = getNodeDisplayPrice(node) - expect(price).toBe('$0.28-0.49/Run (varies with effect scene)') + expect(price).toBe( + creditsRangeLabel(0.28, 0.49, { note: '(varies with effect scene)' }) + ) }) }) @@ -916,7 +980,7 @@ describe('useNodePricing', () => { ]) const price = getNodeDisplayPrice(node) - expect(price).toBe('$0.45/Run') + expect(price).toBe(creditsLabel(0.45)) }) it('should return $0.2 for 5s 720p', () => { @@ -927,7 +991,7 @@ describe('useNodePricing', () => { ]) const price = getNodeDisplayPrice(node) - expect(price).toBe('$0.2/Run') + expect(price).toBe(creditsLabel(0.2)) }) it('should return $1.0 for 10s 1080p', () => { @@ -938,7 +1002,7 @@ describe('useNodePricing', () => { ]) const price = getNodeDisplayPrice(node) - expect(price).toBe('$1.0/Run') + expect(price).toBe(creditsLabel(1.0)) }) it('should return range when widgets are missing', () => { @@ -946,7 +1010,11 @@ describe('useNodePricing', () => { const node = createMockNode('PikaImageToVideoNode2_2', []) const price = getNodeDisplayPrice(node) - expect(price).toBe('$0.2-1.0/Run (varies with duration & resolution)') + expect(price).toBe( + creditsRangeLabel(0.2, 1.0, { + note: '(varies with duration & resolution)' + }) + ) }) }) @@ -959,7 +1027,7 @@ describe('useNodePricing', () => { ]) const price = getNodeDisplayPrice(node) - expect(price).toBe('$0.3/Run') + expect(price).toBe(creditsLabel(0.3)) }) it('should return $0.25 for 10s 720p', () => { @@ -970,7 +1038,7 @@ describe('useNodePricing', () => { ]) const price = getNodeDisplayPrice(node) - expect(price).toBe('$0.4/Run') + expect(price).toBe(creditsLabel(0.4)) }) it('should return range when widgets are missing', () => { @@ -978,7 +1046,11 @@ describe('useNodePricing', () => { const node = createMockNode('PikaScenesV2_2', []) const price = getNodeDisplayPrice(node) - expect(price).toBe('$0.2-1.0/Run (varies with duration & resolution)') + expect(price).toBe( + creditsRangeLabel(0.2, 1.0, { + note: '(varies with duration & resolution)' + }) + ) }) }) @@ -991,7 +1063,7 @@ describe('useNodePricing', () => { ]) const price = getNodeDisplayPrice(node) - expect(price).toBe('$0.2/Run') + expect(price).toBe(creditsLabel(0.2)) }) it('should return $1.0 for 10s 1080p', () => { @@ -1002,7 +1074,7 @@ describe('useNodePricing', () => { ]) const price = getNodeDisplayPrice(node) - expect(price).toBe('$1.0/Run') + expect(price).toBe(creditsLabel(1.0)) }) it('should return range when widgets are missing', () => { @@ -1010,7 +1082,11 @@ describe('useNodePricing', () => { const node = createMockNode('PikaStartEndFrameNode2_2', []) const price = getNodeDisplayPrice(node) - expect(price).toBe('$0.2-1.0/Run (varies with duration & resolution)') + expect(price).toBe( + creditsRangeLabel(0.2, 1.0, { + note: '(varies with duration & resolution)' + }) + ) }) }) @@ -1031,7 +1107,11 @@ describe('useNodePricing', () => { // 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)') + expect(price).toBe( + creditsRangeLabel(0.14, 2.8, { + note: '(varies with model, mode & duration)' + }) + ) }) it('should handle completely broken widget structure', () => { @@ -1050,7 +1130,9 @@ describe('useNodePricing', () => { // Should gracefully fall back to the default range const price = getNodeDisplayPrice(node) - expect(price).toBe('$0.04-0.12/Run (varies with size & quality)') + expect(price).toBe( + creditsRangeLabel(0.04, 0.12, { note: '(varies with size & quality)' }) + ) }) }) @@ -1192,7 +1274,7 @@ describe('useNodePricing', () => { ]) const price = getNodeDisplayPrice(node) - expect(price).toBe('$0.26/Run') // 0.06 * 3 * 1.43 + expect(price).toBe(creditsLabel(0.26)) // 0.06 * 3 * 1.43 }) it('should calculate dynamic pricing for IdeogramV2 based on num_images value', () => { @@ -1202,7 +1284,7 @@ describe('useNodePricing', () => { ]) const price = getNodeDisplayPrice(node) - expect(price).toBe('$0.46/Run') // 0.08 * 4 * 1.43 + 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', () => { @@ -1210,7 +1292,9 @@ describe('useNodePricing', () => { const node = createMockNode('IdeogramV1', []) const price = getNodeDisplayPrice(node) - expect(price).toBe('$0.03-0.09 x num_images/Run') + 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', () => { @@ -1218,7 +1302,9 @@ describe('useNodePricing', () => { const node = createMockNode('IdeogramV2', []) const price = getNodeDisplayPrice(node) - expect(price).toBe('$0.07-0.11 x num_images/Run') + 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', () => { @@ -1228,7 +1314,7 @@ describe('useNodePricing', () => { ]) const price = getNodeDisplayPrice(node) - expect(price).toBe('$0.09/Run') // 0.06 * 1 * 1.43 (turbo=false by default) + expect(price).toBe(creditsLabel(0.09)) // 0.06 * 1 * 1.43 (turbo=false by default) }) }) @@ -1240,7 +1326,7 @@ describe('useNodePricing', () => { ]) const price = getNodeDisplayPrice(node) - expect(price).toBe('$0.12/Run') // 0.04 * 3 + expect(price).toBe(creditsLabel(0.12)) // 0.04 * 3 }) it('should calculate dynamic pricing for RecraftTextToVectorNode based on n value', () => { @@ -1250,7 +1336,7 @@ describe('useNodePricing', () => { ]) const price = getNodeDisplayPrice(node) - expect(price).toBe('$0.16/Run') // 0.08 * 2 + expect(price).toBe(creditsLabel(0.16)) // 0.08 * 2 }) it('should fall back to static display when n widget is missing', () => { @@ -1258,7 +1344,7 @@ describe('useNodePricing', () => { const node = createMockNode('RecraftTextToImageNode', []) const price = getNodeDisplayPrice(node) - expect(price).toBe('$0.04 x n/Run') + expect(price).toBe(creditsLabel(0.04, { suffix: ' x n/Run' })) }) it('should handle edge case when n value is 1', () => { @@ -1268,7 +1354,7 @@ describe('useNodePricing', () => { ]) const price = getNodeDisplayPrice(node) - expect(price).toBe('$0.04/Run') // 0.04 * 1 + expect(price).toBe(creditsLabel(0.04)) // 0.04 * 1 }) }) }) @@ -1282,7 +1368,7 @@ describe('useNodePricing', () => { ]) const price = getNodeDisplayPrice(node) - expect(price).toBe('$0.060/Run') // 0.02 * 3 + expect(price).toBe(creditsLabel(0.06)) // 0.02 * 3 }) it('should calculate dynamic pricing for OpenAIGPTImage1 based on quality and n', () => { @@ -1293,7 +1379,7 @@ describe('useNodePricing', () => { ]) const price = getNodeDisplayPrice(node) - expect(price).toBe('$0.011-0.02 x 2/Run') + 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', () => { @@ -1303,7 +1389,7 @@ describe('useNodePricing', () => { ]) const price = getNodeDisplayPrice(node) - expect(price).toBe('$0.018/Run') // n defaults to 1 + expect(price).toBe(creditsLabel(0.018)) // n defaults to 1 }) }) @@ -1316,7 +1402,7 @@ describe('useNodePricing', () => { ]) const price = getNodeDisplayPrice(node) - expect(price).toBe('$0.0140/Run') // 0.0035 * 4 + expect(price).toBe(creditsLabel(0.014)) // 0.0035 * 4 }) it('should calculate dynamic pricing for text-to-image with kling-v1-5', () => { @@ -1328,7 +1414,7 @@ describe('useNodePricing', () => { ]) const price = getNodeDisplayPrice(node) - expect(price).toBe('$0.0280/Run') // For kling-v1-5 text-to-image: 0.014 * 2 + 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', () => { @@ -1336,7 +1422,12 @@ describe('useNodePricing', () => { const node = createMockNode('KlingImageGenerationNode', []) const price = getNodeDisplayPrice(node) - expect(price).toBe('$0.0035-0.028 x n/Run (varies with modality & model)') + expect(price).toBe( + creditsRangeLabel(0.0035, 0.028, { + suffix: ' x n/Run', + note: '(varies with modality & model)' + }) + ) }) }) @@ -1348,7 +1439,7 @@ describe('useNodePricing', () => { ]) const price = getNodeDisplayPrice(node) - expect(price).toBe('$0.12/Run') // 0.04 * 3 + expect(price).toBe(creditsLabel(0.12)) // 0.04 * 3 }) it('should calculate dynamic pricing for RecraftVectorizeImageNode', () => { @@ -1358,7 +1449,7 @@ describe('useNodePricing', () => { ]) const price = getNodeDisplayPrice(node) - expect(price).toBe('$0.05/Run') // 0.01 * 5 + expect(price).toBe(creditsLabel(0.05)) // 0.01 * 5 }) it('should calculate dynamic pricing for RecraftGenerateVectorImageNode', () => { @@ -1368,7 +1459,7 @@ describe('useNodePricing', () => { ]) const price = getNodeDisplayPrice(node) - expect(price).toBe('$0.16/Run') // 0.08 * 2 + expect(price).toBe(creditsLabel(0.16)) // 0.08 * 2 }) }) @@ -1435,7 +1526,7 @@ describe('useNodePricing', () => { const node = createMockNode('RunwayTextToImageNode') const price = getNodeDisplayPrice(node) - expect(price).toBe('$0.11/Run') + expect(price).toBe(creditsLabel(0.11)) }) it('should calculate dynamic pricing for RunwayImageToVideoNodeGen3a', () => { @@ -1445,7 +1536,7 @@ describe('useNodePricing', () => { ]) const price = getNodeDisplayPrice(node) - expect(price).toBe('$0.71/Run') // 0.05 * 10 * 1.43 + expect(price).toBe(creditsLabel(0.0715 * 10)) }) it('should return fallback for RunwayImageToVideoNodeGen3a without duration', () => { @@ -1453,7 +1544,7 @@ describe('useNodePricing', () => { const node = createMockNode('RunwayImageToVideoNodeGen3a', []) const price = getNodeDisplayPrice(node) - expect(price).toBe('$0.0715/second') + expect(price).toBe(creditsLabel(0.0715, { suffix: '/second' })) }) it('should handle zero duration for RunwayImageToVideoNodeGen3a', () => { @@ -1463,7 +1554,7 @@ describe('useNodePricing', () => { ]) const price = getNodeDisplayPrice(node) - expect(price).toBe('$0.00/Run') // 0.05 * 0 = 0 + expect(price).toBe(creditsLabel(0.0)) // 0.05 * 0 = 0 }) it('should handle NaN duration for RunwayImageToVideoNodeGen3a', () => { @@ -1473,7 +1564,7 @@ describe('useNodePricing', () => { ]) const price = getNodeDisplayPrice(node) - expect(price).toBe('$0.36/Run') // Falls back to 5 seconds: 0.05 * 5 * 1.43 + expect(price).toBe(creditsLabel(0.0715 * 5)) }) }) @@ -1483,7 +1574,7 @@ describe('useNodePricing', () => { const node = createMockNode('Rodin3D_Regular') const price = getNodeDisplayPrice(node) - expect(price).toBe('$0.4/Run') + expect(price).toBe(creditsLabel(0.4)) }) it('should return addon price for Rodin3D_Detail', () => { @@ -1491,7 +1582,7 @@ describe('useNodePricing', () => { const node = createMockNode('Rodin3D_Detail') const price = getNodeDisplayPrice(node) - expect(price).toBe('$0.4/Run') + expect(price).toBe(creditsLabel(0.4)) }) it('should return addon price for Rodin3D_Smooth', () => { @@ -1499,7 +1590,7 @@ describe('useNodePricing', () => { const node = createMockNode('Rodin3D_Smooth') const price = getNodeDisplayPrice(node) - expect(price).toBe('$0.4/Run') + expect(price).toBe(creditsLabel(0.4)) }) }) @@ -1514,7 +1605,7 @@ describe('useNodePricing', () => { ]) const price = getNodeDisplayPrice(node) - expect(price).toBe('$0.15/Run') // any style, no quad, no texture + expect(price).toBe(creditsLabel(0.15)) // any style, no quad, no texture }) it('should return v2.5 detailed pricing for TripoTextToModelNode', () => { @@ -1527,7 +1618,7 @@ describe('useNodePricing', () => { ]) const price = getNodeDisplayPrice(node) - expect(price).toBe('$0.35/Run') // any style, quad, no texture, detailed + expect(price).toBe(creditsLabel(0.35)) // any style, quad, no texture, detailed }) it('should return v2.0 detailed pricing for TripoImageToModelNode', () => { @@ -1540,7 +1631,7 @@ describe('useNodePricing', () => { ]) const price = getNodeDisplayPrice(node) - expect(price).toBe('$0.45/Run') // any style, quad, no texture, detailed + expect(price).toBe(creditsLabel(0.45)) // any style, quad, no texture, detailed }) it('should return legacy pricing for TripoTextToModelNode', () => { @@ -1553,7 +1644,7 @@ describe('useNodePricing', () => { ]) const price = getNodeDisplayPrice(node) - expect(price).toBe('$0.10/Run') // none style, no quad, no texture + expect(price).toBe(creditsLabel(0.1)) // none style, no quad, no texture }) it('should return static price for TripoRefineNode', () => { @@ -1561,7 +1652,7 @@ describe('useNodePricing', () => { const node = createMockNode('TripoRefineNode') const price = getNodeDisplayPrice(node) - expect(price).toBe('$0.3/Run') + expect(price).toBe(creditsLabel(0.3)) }) it('should return fallback for TripoTextToModelNode without model', () => { @@ -1570,7 +1661,9 @@ describe('useNodePricing', () => { const price = getNodeDisplayPrice(node) expect(price).toBe( - '$0.1-0.4/Run (varies with quad, style, texture & quality)' + creditsRangeLabel(0.1, 0.4, { + note: 'varies with quad, style, texture & quality' + }) ) }) @@ -1583,8 +1676,8 @@ describe('useNodePricing', () => { { name: 'texture_quality', value: 'detailed' } ]) - expect(getNodeDisplayPrice(standardNode)).toBe('$0.1/Run') - expect(getNodeDisplayPrice(detailedNode)).toBe('$0.2/Run') + expect(getNodeDisplayPrice(standardNode)).toBe(creditsLabel(0.1)) + expect(getNodeDisplayPrice(detailedNode)).toBe(creditsLabel(0.2)) }) it('should handle various Tripo parameter combinations', () => { @@ -1592,19 +1685,29 @@ describe('useNodePricing', () => { // Test different parameter combinations const testCases = [ - { quad: false, style: 'none', texture: false, expected: '$0.10/Run' }, + { + quad: false, + style: 'none', + texture: false, + expected: creditsLabel(0.1) + }, { quad: false, style: 'any style', texture: false, - expected: '$0.15/Run' + expected: creditsLabel(0.15) + }, + { + quad: true, + style: 'none', + texture: false, + expected: creditsLabel(0.2) }, - { quad: true, style: 'none', texture: false, expected: '$0.20/Run' }, { quad: true, style: 'any style', texture: false, - expected: '$0.25/Run' + expected: creditsLabel(0.25) } ] @@ -1624,7 +1727,7 @@ describe('useNodePricing', () => { const node = createMockNode('TripoConvertModelNode') const price = getNodeDisplayPrice(node) - expect(price).toBe('$0.10/Run') + expect(price).toBe(creditsLabel(0.1)) }) it('should return static price for TripoRetargetRiggedModelNode', () => { @@ -1632,7 +1735,7 @@ describe('useNodePricing', () => { const node = createMockNode('TripoRetargetRiggedModelNode') const price = getNodeDisplayPrice(node) - expect(price).toBe('$0.10/Run') + expect(price).toBe(creditsLabel(0.1)) }) it('should return dynamic pricing for TripoMultiviewToModelNode', () => { @@ -1645,7 +1748,7 @@ describe('useNodePricing', () => { { name: 'texture', value: false }, { name: 'texture_quality', value: 'standard' } ]) - expect(getNodeDisplayPrice(basicNode)).toBe('$0.20/Run') + expect(getNodeDisplayPrice(basicNode)).toBe(creditsLabel(0.2)) // Test high-end case - any style, quad, texture, detailed const highEndNode = createMockNode('TripoMultiviewToModelNode', [ @@ -1654,7 +1757,7 @@ describe('useNodePricing', () => { { name: 'texture', value: true }, { name: 'texture_quality', value: 'detailed' } ]) - expect(getNodeDisplayPrice(highEndNode)).toBe('$0.50/Run') + expect(getNodeDisplayPrice(highEndNode)).toBe(creditsLabel(0.5)) }) it('should return fallback for TripoMultiviewToModelNode without widgets', () => { @@ -1663,7 +1766,9 @@ describe('useNodePricing', () => { const price = getNodeDisplayPrice(node) expect(price).toBe( - '$0.2-0.5/Run (varies with quad, style, texture & quality)' + creditsRangeLabel(0.2, 0.5, { + note: '(varies with quad, style, texture & quality)' + }) ) }) }) @@ -1675,23 +1780,33 @@ describe('useNodePricing', () => { const testCases = [ { model: 'gemini-2.5-pro-preview-05-06', - expected: '$0.00125/$0.01 per 1K tokens' + expected: creditsListLabel([0.00125, 0.01], { + suffix: ' per 1K tokens' + }) }, { model: 'gemini-2.5-pro', - expected: '$0.00125/$0.01 per 1K tokens' + expected: creditsListLabel([0.00125, 0.01], { + suffix: ' per 1K tokens' + }) }, { model: 'gemini-3-pro-preview', - expected: '$0.002/$0.012 per 1K tokens' + expected: creditsListLabel([0.002, 0.012], { + suffix: ' per 1K tokens' + }) }, { model: 'gemini-2.5-flash-preview-04-17', - expected: '$0.0003/$0.0025 per 1K tokens' + expected: creditsListLabel([0.0003, 0.0025], { + suffix: ' per 1K tokens' + }) }, { model: 'gemini-2.5-flash', - expected: '$0.0003/$0.0025 per 1K tokens' + expected: creditsListLabel([0.0003, 0.0025], { + suffix: ' per 1K tokens' + }) }, { model: 'unknown-gemini-model', expected: 'Token-based' } ] @@ -1711,7 +1826,7 @@ describe('useNodePricing', () => { ]) const price = getNodeDisplayPrice(node) - expect(price).toBe('$0.5/second') + expect(price).toBe(creditsLabel(0.5, { suffix: '/second' })) }) it('should return fallback for GeminiNode without model widget', () => { @@ -1736,18 +1851,78 @@ describe('useNodePricing', () => { const { getNodeDisplayPrice } = useNodePricing() const testCases = [ - { model: 'o4-mini', expected: '$0.0011/$0.0044 per 1K tokens' }, - { model: 'o1-pro', expected: '$0.15/$0.60 per 1K tokens' }, - { model: 'o1', expected: '$0.015/$0.06 per 1K tokens' }, - { model: 'o3-mini', expected: '$0.0011/$0.0044 per 1K tokens' }, - { model: 'o3', expected: '$0.01/$0.04 per 1K tokens' }, - { model: 'gpt-4o', expected: '$0.0025/$0.01 per 1K tokens' }, - { model: 'gpt-4.1-nano', expected: '$0.0001/$0.0004 per 1K tokens' }, - { model: 'gpt-4.1-mini', expected: '$0.0004/$0.0016 per 1K tokens' }, - { model: 'gpt-4.1', expected: '$0.002/$0.008 per 1K tokens' }, - { model: 'gpt-5-nano', expected: '$0.00005/$0.0004 per 1K tokens' }, - { model: 'gpt-5-mini', expected: '$0.00025/$0.002 per 1K tokens' }, - { model: 'gpt-5', expected: '$0.00125/$0.01 per 1K tokens' } + { + model: 'o4-mini', + expected: creditsListLabel([0.0011, 0.0044], { + suffix: ' per 1K tokens' + }) + }, + { + model: 'o1-pro', + expected: creditsListLabel([0.15, 0.6], { + suffix: ' per 1K tokens' + }) + }, + { + model: 'o1', + expected: creditsListLabel([0.015, 0.06], { + suffix: ' per 1K tokens' + }) + }, + { + model: 'o3-mini', + expected: creditsListLabel([0.0011, 0.0044], { + suffix: ' per 1K tokens' + }) + }, + { + model: 'o3', + expected: creditsListLabel([0.01, 0.04], { + suffix: ' per 1K tokens' + }) + }, + { + model: 'gpt-4o', + expected: creditsListLabel([0.0025, 0.01], { + suffix: ' per 1K tokens' + }) + }, + { + model: 'gpt-4.1-nano', + expected: creditsListLabel([0.0001, 0.0004], { + suffix: ' per 1K tokens' + }) + }, + { + model: 'gpt-4.1-mini', + expected: creditsListLabel([0.0004, 0.0016], { + suffix: ' per 1K tokens' + }) + }, + { + model: 'gpt-4.1', + expected: creditsListLabel([0.002, 0.008], { + suffix: ' per 1K tokens' + }) + }, + { + model: 'gpt-5-nano', + expected: creditsListLabel([0.00005, 0.0004], { + suffix: ' per 1K tokens' + }) + }, + { + model: 'gpt-5-mini', + expected: creditsListLabel([0.00025, 0.002], { + suffix: ' per 1K tokens' + }) + }, + { + model: 'gpt-5', + expected: creditsListLabel([0.00125, 0.01], { + suffix: ' per 1K tokens' + }) + } ] testCases.forEach(({ model, expected }) => { @@ -1765,16 +1940,40 @@ describe('useNodePricing', () => { const testCases = [ { model: 'gpt-4.1-nano-test', - expected: '$0.0001/$0.0004 per 1K tokens' + expected: creditsListLabel([0.0001, 0.0004], { + suffix: ' per 1K tokens' + }) }, { model: 'gpt-4.1-mini-test', - expected: '$0.0004/$0.0016 per 1K tokens' + expected: creditsListLabel([0.0004, 0.0016], { + suffix: ' per 1K tokens' + }) + }, + { + model: 'gpt-4.1-test', + expected: creditsListLabel([0.002, 0.008], { + suffix: ' per 1K tokens' + }) + }, + { + model: 'o1-pro-test', + expected: creditsListLabel([0.15, 0.6], { + suffix: ' per 1K tokens' + }) + }, + { + model: 'o1-test', + expected: creditsListLabel([0.015, 0.06], { + suffix: ' per 1K tokens' + }) + }, + { + model: 'o3-mini-test', + expected: creditsListLabel([0.0011, 0.0044], { + suffix: ' per 1K tokens' + }) }, - { model: 'gpt-4.1-test', expected: '$0.002/$0.008 per 1K tokens' }, - { model: 'o1-pro-test', expected: '$0.15/$0.60 per 1K tokens' }, - { model: 'o1-test', expected: '$0.015/$0.06 per 1K tokens' }, - { model: 'o3-mini-test', expected: '$0.0011/$0.0044 per 1K tokens' }, { model: 'unknown-model', expected: 'Token-based' } ] @@ -1799,7 +1998,12 @@ describe('useNodePricing', () => { const node = createMockNode('GeminiImageNode') const price = getNodeDisplayPrice(node) - expect(price).toBe('~$0.039/Image (1K)') + expect(price).toBe( + creditsLabel(0.039, { + approximate: true, + suffix: '/Image (1K)' + }) + ) }) }) @@ -1808,10 +2012,11 @@ describe('useNodePricing', () => { const { getNodeDisplayPrice } = useNodePricing() // Test edge cases + const RATE_PER_SECOND = 0.0715 const testCases = [ - { duration: 0, expected: '$0.00/Run' }, // Now correctly handles 0 duration - { duration: 1, expected: '$0.07/Run' }, - { duration: 30, expected: '$2.15/Run' } + { duration: 0, expected: creditsLabel(0) }, + { duration: 1, expected: creditsLabel(RATE_PER_SECOND * 1) }, + { duration: 30, expected: creditsLabel(RATE_PER_SECOND * 30) } ] testCases.forEach(({ duration, expected }) => { @@ -1828,7 +2033,7 @@ describe('useNodePricing', () => { { name: 'duration', value: 'invalid-string' } ]) // When Number('invalid-string') returns NaN, it falls back to 5 seconds - expect(getNodeDisplayPrice(node)).toBe('$0.36/Run') + expect(getNodeDisplayPrice(node)).toBe(creditsLabel(0.0715 * 5)) }) it('should handle missing duration widget gracefully', () => { @@ -1841,7 +2046,9 @@ describe('useNodePricing', () => { nodes.forEach((nodeType) => { const node = createMockNode(nodeType, []) - expect(getNodeDisplayPrice(node)).toBe('$0.0715/second') + expect(getNodeDisplayPrice(node)).toBe( + creditsLabel(0.0715, { suffix: '/second' }) + ) }) }) }) @@ -1851,10 +2058,10 @@ describe('useNodePricing', () => { const { getNodeDisplayPrice } = useNodePricing() const testCases = [ - { nodeType: 'Rodin3D_Regular', expected: '$0.4/Run' }, - { nodeType: 'Rodin3D_Sketch', expected: '$0.4/Run' }, - { nodeType: 'Rodin3D_Detail', expected: '$0.4/Run' }, - { nodeType: 'Rodin3D_Smooth', expected: '$0.4/Run' } + { 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 }) => { @@ -1869,21 +2076,31 @@ describe('useNodePricing', () => { const { getNodeDisplayPrice } = useNodePricing() const testCases = [ - { quad: false, style: 'none', texture: false, expected: '$0.20/Run' }, - { quad: false, style: 'none', texture: true, expected: '$0.25/Run' }, + { + quad: false, + style: 'none', + texture: false, + expected: creditsLabel(0.2) + }, + { + quad: false, + style: 'none', + texture: true, + expected: creditsLabel(0.25) + }, { quad: true, style: 'any style', texture: true, textureQuality: 'detailed', - expected: '$0.50/Run' + expected: creditsLabel(0.5) }, { quad: true, style: 'any style', texture: false, textureQuality: 'standard', - expected: '$0.35/Run' + expected: creditsLabel(0.35) } ] @@ -1909,7 +2126,9 @@ describe('useNodePricing', () => { const price = getNodeDisplayPrice(node) expect(price).toBe( - '$0.2-0.5/Run (varies with quad, style, texture & quality)' + creditsRangeLabel(0.2, 0.5, { + note: 'varies with quad, style, texture & quality' + }) ) }) @@ -1919,7 +2138,9 @@ describe('useNodePricing', () => { const price = getNodeDisplayPrice(node) expect(price).toBe( - '$0.1-0.4/Run (varies with quad, style, texture & quality)' + creditsRangeLabel(0.1, 0.4, { + note: 'varies with quad, style, texture & quality' + }) ) }) @@ -1931,7 +2152,9 @@ describe('useNodePricing', () => { const price = getNodeDisplayPrice(node) expect(price).toBe( - '$0.1-0.4/Run (varies with quad, style, texture & quality)' + creditsRangeLabel(0.1, 0.4, { + note: 'varies with quad, style, texture & quality' + }) ) }) @@ -1942,12 +2165,12 @@ describe('useNodePricing', () => { { node_name: 'ByteDanceImageNode', model: 'seedream-3-0-t2i-250415', - expected: '$0.03/Run' + expected: creditsLabel(0.03) }, { node_name: 'ByteDanceImageEditNode', model: 'seededit-3-0-i2i-250628', - expected: '$0.03/Run' + expected: creditsLabel(0.03) } ] @@ -1967,7 +2190,9 @@ describe('useNodePricing', () => { const node = createMockNode('ByteDanceSeedreamNode', []) const price = getNodeDisplayPrice(node) - expect(price).toBe('$0.03/Run ($0.03 for one output image)') + expect(price).toBe( + `${creditsLabel(0.03)} (${creditValue(0.03)} credits for one output image)` + ) }) it('should return $0.03/Run when sequential generation is disabled', () => { @@ -1978,7 +2203,7 @@ describe('useNodePricing', () => { ]) const price = getNodeDisplayPrice(node) - expect(price).toBe('$0.03/Run') + expect(price).toBe(creditsLabel(0.03)) }) it('should multiply by max_images when sequential generation is enabled', () => { @@ -1989,7 +2214,9 @@ describe('useNodePricing', () => { ]) const price = getNodeDisplayPrice(node) - expect(price).toBe('$0.12/Run ($0.03 for one output image)') + expect(price).toBe( + `${creditsLabel(0.12)} (${creditValue(0.03)} credits for one output image)` + ) }) }) @@ -2003,7 +2230,7 @@ describe('useNodePricing', () => { ]) const price = getNodeDisplayPrice(node) - expect(price).toBe('$1.18-$1.22/Run') + expect(price).toBe(creditsRangeLabel(1.18, 1.22)) }) it('should scale to half for 5s PRO 1080p on ByteDanceTextToVideoNode', () => { @@ -2015,7 +2242,7 @@ describe('useNodePricing', () => { ]) const price = getNodeDisplayPrice(node) - expect(price).toBe('$0.59-$0.61/Run') + expect(price).toBe(creditsRangeLabel(0.59, 0.61)) }) it('should scale for 8s PRO 480p on ByteDanceImageToVideoNode', () => { @@ -2027,7 +2254,7 @@ describe('useNodePricing', () => { ]) const price = getNodeDisplayPrice(node) - expect(price).toBe('$0.18-$0.19/Run') + expect(price).toBe(creditsRangeLabel(0.23 * 0.8, 0.24 * 0.8)) }) it('should scale correctly for 12s PRO 720p on ByteDanceFirstLastFrameNode', () => { @@ -2039,7 +2266,7 @@ describe('useNodePricing', () => { ]) const price = getNodeDisplayPrice(node) - expect(price).toBe('$0.61-$0.67/Run') + expect(price).toBe(creditsRangeLabel(0.51 * 1.2, 0.56 * 1.2)) }) it('should collapse to a single value when min and max round equal for LITE 480p 3s on ByteDanceImageReferenceNode', () => { @@ -2051,7 +2278,7 @@ describe('useNodePricing', () => { ]) const price = getNodeDisplayPrice(node) - expect(price).toBe('$0.05/Run') // 0.17..0.18 scaled by 0.3 both round to 0.05 + 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', () => { @@ -2084,7 +2311,7 @@ describe('useNodePricing', () => { ]) const price = getNodeDisplayPrice(node) - expect(price).toBe('$1.50/Run') // 0.15 * 10 + expect(price).toBe(creditsLabel(1.5)) // 0.15 * 10 }) it('should return $0.50 for 5s at 720p', () => { @@ -2095,7 +2322,7 @@ describe('useNodePricing', () => { ]) const price = getNodeDisplayPrice(node) - expect(price).toBe('$0.50/Run') // 0.10 * 5 + expect(price).toBe(creditsLabel(0.5)) // 0.10 * 5 }) it('should return $0.15 for 3s at 480p', () => { @@ -2106,7 +2333,7 @@ describe('useNodePricing', () => { ]) const price = getNodeDisplayPrice(node) - expect(price).toBe('$0.15/Run') // 0.05 * 3 + expect(price).toBe(creditsLabel(0.15)) // 0.05 * 3 }) it('should fall back when widgets are missing', () => { @@ -2119,9 +2346,15 @@ describe('useNodePricing', () => { { name: 'size', value: '1080p' } ]) - expect(getNodeDisplayPrice(missingBoth)).toBe('$0.05-0.15/second') - expect(getNodeDisplayPrice(missingSize)).toBe('$0.05-0.15/second') - expect(getNodeDisplayPrice(missingDuration)).toBe('$0.05-0.15/second') + 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', () => { @@ -2132,7 +2365,7 @@ describe('useNodePricing', () => { ]) const price = getNodeDisplayPrice(node) - expect(price).toBe('$0.05-0.15/second') + expect(price).toBe(creditsRangeLabel(0.05, 0.15, { suffix: '/second' })) }) it('should fall back on unknown resolution', () => { @@ -2143,7 +2376,7 @@ describe('useNodePricing', () => { ]) const price = getNodeDisplayPrice(node) - expect(price).toBe('$0.05-0.15/second') + expect(price).toBe(creditsRangeLabel(0.05, 0.15, { suffix: '/second' })) }) }) @@ -2156,7 +2389,7 @@ describe('useNodePricing', () => { ]) const price = getNodeDisplayPrice(node) - expect(price).toBe('$0.80/Run') // 0.10 * 8 + expect(price).toBe(creditsLabel(0.8)) // 0.10 * 8 }) it('should return $0.60 for 12s at 480P', () => { @@ -2167,7 +2400,7 @@ describe('useNodePricing', () => { ]) const price = getNodeDisplayPrice(node) - expect(price).toBe('$0.60/Run') // 0.05 * 12 + expect(price).toBe(creditsLabel(0.6)) // 0.05 * 12 }) it('should return $1.50 for 10s at 1080p', () => { @@ -2178,7 +2411,7 @@ describe('useNodePricing', () => { ]) const price = getNodeDisplayPrice(node) - expect(price).toBe('$1.50/Run') // 0.15 * 10 + expect(price).toBe(creditsLabel(1.5)) // 0.15 * 10 }) it('should handle "5s" string duration at 1080P', () => { @@ -2189,7 +2422,7 @@ describe('useNodePricing', () => { ]) const price = getNodeDisplayPrice(node) - expect(price).toBe('$0.75/Run') // 0.15 * 5 + expect(price).toBe(creditsLabel(0.75)) // 0.15 * 5 }) it('should fall back when widgets are missing', () => { @@ -2202,9 +2435,15 @@ describe('useNodePricing', () => { { name: 'resolution', value: '1080p' } ]) - expect(getNodeDisplayPrice(missingBoth)).toBe('$0.05-0.15/second') - expect(getNodeDisplayPrice(missingRes)).toBe('$0.05-0.15/second') - expect(getNodeDisplayPrice(missingDuration)).toBe('$0.05-0.15/second') + 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', () => { @@ -2215,7 +2454,7 @@ describe('useNodePricing', () => { ]) const price = getNodeDisplayPrice(node) - expect(price).toBe('$0.05-0.15/second') + expect(price).toBe(creditsRangeLabel(0.05, 0.15, { suffix: '/second' })) }) it('should fall back on unknown resolution', () => { @@ -2226,7 +2465,7 @@ describe('useNodePricing', () => { ]) const price = getNodeDisplayPrice(node) - expect(price).toBe('$0.05-0.15/second') + expect(price).toBe(creditsRangeLabel(0.05, 0.15, { suffix: '/second' })) }) }) @@ -2240,7 +2479,7 @@ describe('useNodePricing', () => { ]) const price = getNodeDisplayPrice(node) - expect(price).toBe('$0.30/Run') // 0.06 * 5 + expect(price).toBe(creditsLabel(0.3)) // 0.06 * 5 }) it('should parse "10s" duration strings', () => { @@ -2252,7 +2491,7 @@ describe('useNodePricing', () => { ]) const price = getNodeDisplayPrice(node) - expect(price).toBe('$1.60/Run') // 0.16 * 10 + expect(price).toBe(creditsLabel(1.6)) // 0.16 * 10 }) it('should fall back when a required widget is missing (no resolution)', () => { @@ -2264,7 +2503,7 @@ describe('useNodePricing', () => { ]) const price = getNodeDisplayPrice(node) - expect(price).toBe('$0.04-0.24/second') + expect(price).toBe(creditsRangeLabel(0.04, 0.24, { suffix: '/second' })) }) it('should fall back for unknown model', () => { @@ -2276,7 +2515,63 @@ describe('useNodePricing', () => { ]) const price = getNodeDisplayPrice(node) - expect(price).toBe('$0.04-0.24/second') + 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)}` +}