fix(api-nodes-pricing): adjust prices for Tripo3D (#6828)

This commit is contained in:
Alexander Piskun
2025-12-16 09:04:20 +02:00
committed by GitHub
parent 3ee6d53423
commit 5d1bf6dfb3
2 changed files with 297 additions and 194 deletions

View File

@@ -329,6 +329,123 @@ const sora2PricingCalculator: PricingFunction = (node: LGraphNode): string => {
return formatRunPrice(perSec, duration)
}
/**
* Pricing for Tripo 3D generation nodes (Text / Image / Multiview)
* based on Tripo credits:
*
* Turbo / V3 / V2.5 / V2.0:
* Text -> 10 (no texture) / 20 (standard texture)
* Image -> 20 (no texture) / 30 (standard texture)
* Multiview -> 20 (no texture) / 30 (standard texture)
*
* V1.4:
* Text -> 20
* Image -> 30
* (Multiview treated same as Image if used)
*
* Advanced extras (added on top of generation credits):
* quad -> +5 credits
* style -> +5 credits (if style != "None")
* HD texture -> +10 credits (texture_quality = "detailed")
* detailed geometry -> +20 credits (geometry_quality = "detailed")
*
* 1 credit = $0.01
*/
const calculateTripo3DGenerationPrice = (
node: LGraphNode,
task: 'text' | 'image' | 'multiview'
): string => {
const getWidget = (name: string): IComboWidget | undefined =>
node.widgets?.find((w) => w.name === name) as IComboWidget | undefined
const getString = (name: string, defaultValue: string): string => {
const widget = getWidget(name)
if (!widget || widget.value === undefined || widget.value === null) {
return defaultValue
}
return String(widget.value)
}
const getBool = (name: string, defaultValue: boolean): boolean => {
const widget = getWidget(name)
if (!widget || widget.value === undefined || widget.value === null) {
return defaultValue
}
const v = widget.value
if (typeof v === 'number') return v !== 0
const lower = String(v).toLowerCase()
if (lower === 'true') return true
if (lower === 'false') return false
return defaultValue
}
// ---- read widget values with sensible defaults (mirroring backend) ----
const modelVersionRaw = getString('model_version', '').toLowerCase()
if (modelVersionRaw === '')
return '$0.1-0.65/Run (varies with quad, style, texture & quality)'
const styleRaw = getString('style', 'None')
const hasStyle = styleRaw.toLowerCase() !== 'none'
// Backend defaults: texture=true, pbr=true, quad=false, qualities="standard"
const hasTexture = getBool('texture', false)
const hasPbr = getBool('pbr', false)
const quad = getBool('quad', false)
const textureQualityRaw = getString(
'texture_quality',
'standard'
).toLowerCase()
const geometryQualityRaw = getString(
'geometry_quality',
'standard'
).toLowerCase()
const isHdTexture = textureQualityRaw === 'detailed'
const isDetailedGeometry = geometryQualityRaw === 'detailed'
const withTexture = hasTexture || hasPbr
let baseCredits: number
if (modelVersionRaw.includes('v1.4')) {
// V1.4 model: Text=20, Image=30, Refine=30
if (task === 'text') {
baseCredits = 20
} else {
// treat Multiview same as Image if V1.4 is ever used there
baseCredits = 30
}
} else {
// V3.0, V2.5, V2.0 models
if (!withTexture) {
if (task === 'text') {
baseCredits = 10 // Text to 3D without texture
} else {
baseCredits = 20 // Image/Multiview to 3D without texture
}
} else {
if (task === 'text') {
baseCredits = 20 // Text to 3D with standard texture
} else {
baseCredits = 30 // Image/Multiview to 3D with standard texture
}
}
}
// ---- advanced extras on top of base generation ----
let credits = baseCredits
if (hasStyle) credits += 5 // Style
if (quad) credits += 5 // Quad Topology
if (isHdTexture) credits += 10 // HD Texture
if (isDetailedGeometry) credits += 20 // Detailed Geometry Quality
const dollars = credits * 0.01
return `$${dollars.toFixed(2)}/Run`
}
/**
* Static pricing data for API nodes, now supporting both strings and functions
*/
@@ -1482,119 +1599,16 @@ const apiNodeCosts: Record<string, { displayPrice: string | PricingFunction }> =
},
// Tripo nodes - using actual node names from ComfyUI
TripoTextToModelNode: {
displayPrice: (node: LGraphNode): string => {
const quadWidget = node.widgets?.find(
(w) => w.name === 'quad'
) as IComboWidget
const styleWidget = node.widgets?.find(
(w) => w.name === 'style'
) as IComboWidget
const textureWidget = node.widgets?.find(
(w) => w.name === 'texture'
) as IComboWidget
const textureQualityWidget = node.widgets?.find(
(w) => w.name === 'texture_quality'
) as IComboWidget
if (!quadWidget || !styleWidget || !textureWidget)
return '$0.1-0.4/Run (varies with quad, style, texture & quality)'
const quad = String(quadWidget.value).toLowerCase() === 'true'
const style = String(styleWidget.value).toLowerCase()
const texture = String(textureWidget.value).toLowerCase() === 'true'
const textureQuality = String(
textureQualityWidget?.value || 'standard'
).toLowerCase()
// Pricing logic based on CSV data
if (style.includes('none')) {
if (!quad) {
if (!texture) return '$0.10/Run'
else return '$0.15/Run'
} else {
if (textureQuality.includes('detailed')) {
if (!texture) return '$0.30/Run'
else return '$0.35/Run'
} else {
if (!texture) return '$0.20/Run'
else return '$0.25/Run'
}
}
} else {
// any style
if (!quad) {
if (!texture) return '$0.15/Run'
else return '$0.20/Run'
} else {
if (textureQuality.includes('detailed')) {
if (!texture) return '$0.35/Run'
else return '$0.40/Run'
} else {
if (!texture) return '$0.25/Run'
else return '$0.30/Run'
}
}
}
}
displayPrice: (node: LGraphNode): string =>
calculateTripo3DGenerationPrice(node, 'text')
},
TripoImageToModelNode: {
displayPrice: (node: LGraphNode): string => {
const quadWidget = node.widgets?.find(
(w) => w.name === 'quad'
) as IComboWidget
const styleWidget = node.widgets?.find(
(w) => w.name === 'style'
) as IComboWidget
const textureWidget = node.widgets?.find(
(w) => w.name === 'texture'
) as IComboWidget
const textureQualityWidget = node.widgets?.find(
(w) => w.name === 'texture_quality'
) as IComboWidget
if (!quadWidget || !styleWidget || !textureWidget)
return '$0.2-0.5/Run (varies with quad, style, texture & quality)'
const quad = String(quadWidget.value).toLowerCase() === 'true'
const style = String(styleWidget.value).toLowerCase()
const texture = String(textureWidget.value).toLowerCase() === 'true'
const textureQuality = String(
textureQualityWidget?.value || 'standard'
).toLowerCase()
// 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'
} else {
if (textureQuality.includes('detailed')) {
if (!texture) return '$0.40/Run'
else return '$0.45/Run'
} else {
if (!texture) return '$0.30/Run'
else return '$0.35/Run'
}
}
} else {
// any style
if (!quad) {
if (!texture) return '$0.25/Run'
else return '$0.30/Run'
} else {
if (textureQuality.includes('detailed')) {
if (!texture) return '$0.45/Run'
else return '$0.50/Run'
} else {
if (!texture) return '$0.35/Run'
else return '$0.40/Run'
}
}
}
}
displayPrice: (node: LGraphNode): string =>
calculateTripo3DGenerationPrice(node, 'image')
},
TripoRefineNode: {
displayPrice: '$0.3/Run'
TripoMultiviewToModelNode: {
displayPrice: (node: LGraphNode): string =>
calculateTripo3DGenerationPrice(node, 'multiview')
},
TripoTextureNode: {
displayPrice: (node: LGraphNode): string => {
@@ -1608,68 +1622,94 @@ const apiNodeCosts: Record<string, { displayPrice: string | PricingFunction }> =
return textureQuality.includes('detailed') ? '$0.2/Run' : '$0.1/Run'
}
},
TripoConvertModelNode: {
displayPrice: '$0.10/Run'
TripoRigNode: {
displayPrice: '$0.25/Run'
},
TripoRetargetRiggedModelNode: {
displayPrice: '$0.10/Run'
},
TripoMultiviewToModelNode: {
TripoConversionNode: {
displayPrice: (node: LGraphNode): string => {
const quadWidget = node.widgets?.find(
(w) => w.name === 'quad'
) as IComboWidget
const styleWidget = node.widgets?.find(
(w) => w.name === 'style'
) as IComboWidget
const textureWidget = node.widgets?.find(
(w) => w.name === 'texture'
) as IComboWidget
const textureQualityWidget = node.widgets?.find(
(w) => w.name === 'texture_quality'
) as IComboWidget
const getWidgetValue = (name: string) =>
node.widgets?.find((w) => w.name === name)?.value
if (!quadWidget || !styleWidget || !textureWidget)
return '$0.2-0.5/Run (varies with quad, style, texture & quality)'
const quad = String(quadWidget.value).toLowerCase() === 'true'
const style = String(styleWidget.value).toLowerCase()
const texture = String(textureWidget.value).toLowerCase() === 'true'
const textureQuality = String(
textureQualityWidget?.value || 'standard'
).toLowerCase()
// 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'
} else {
if (textureQuality.includes('detailed')) {
if (!texture) return '$0.40/Run'
else return '$0.45/Run'
} else {
if (!texture) return '$0.30/Run'
else return '$0.35/Run'
}
}
} else {
// any style
if (!quad) {
if (!texture) return '$0.25/Run'
else return '$0.30/Run'
} else {
if (textureQuality.includes('detailed')) {
if (!texture) return '$0.45/Run'
else return '$0.50/Run'
} else {
if (!texture) return '$0.35/Run'
else return '$0.40/Run'
}
}
const getNumber = (name: string, defaultValue: number): number => {
const raw = getWidgetValue(name)
if (raw === undefined || raw === null || raw === '')
return defaultValue
if (typeof raw === 'number')
return Number.isFinite(raw) ? raw : defaultValue
const n = Number(raw)
return Number.isFinite(n) ? n : defaultValue
}
const getBool = (name: string, defaultValue: boolean): boolean => {
const v = getWidgetValue(name)
if (v === undefined || v === null) return defaultValue
if (typeof v === 'number') return v !== 0
const lower = String(v).toLowerCase()
if (lower === 'true') return true
if (lower === 'false') return false
return defaultValue
}
let hasAdvancedParam = false
// ---- booleans that trigger advanced when true ----
if (getBool('quad', false)) hasAdvancedParam = true
if (getBool('force_symmetry', false)) hasAdvancedParam = true
if (getBool('flatten_bottom', false)) hasAdvancedParam = true
if (getBool('pivot_to_center_bottom', false)) hasAdvancedParam = true
if (getBool('with_animation', false)) hasAdvancedParam = true
if (getBool('pack_uv', false)) hasAdvancedParam = true
if (getBool('bake', false)) hasAdvancedParam = true
if (getBool('export_vertex_colors', false)) hasAdvancedParam = true
if (getBool('animate_in_place', false)) hasAdvancedParam = true
// ---- numeric params with special default sentinels ----
const faceLimit = getNumber('face_limit', -1)
if (faceLimit !== -1) hasAdvancedParam = true
const textureSize = getNumber('texture_size', 4096)
if (textureSize !== 4096) hasAdvancedParam = true
const flattenBottomThreshold = getNumber(
'flatten_bottom_threshold',
0.0
)
if (flattenBottomThreshold !== 0.0) hasAdvancedParam = true
const scaleFactor = getNumber('scale_factor', 1.0)
if (scaleFactor !== 1.0) hasAdvancedParam = true
// ---- string / combo params with non-default values ----
const textureFormatRaw = String(
getWidgetValue('texture_format') ?? 'JPEG'
).toUpperCase()
if (textureFormatRaw !== 'JPEG') hasAdvancedParam = true
const partNamesRaw = String(getWidgetValue('part_names') ?? '')
if (partNamesRaw.trim().length > 0) hasAdvancedParam = true
const fbxPresetRaw = String(
getWidgetValue('fbx_preset') ?? 'blender'
).toLowerCase()
if (fbxPresetRaw !== 'blender') hasAdvancedParam = true
const exportOrientationRaw = String(
getWidgetValue('export_orientation') ?? 'default'
).toLowerCase()
if (exportOrientationRaw !== 'default') hasAdvancedParam = true
const credits = hasAdvancedParam ? 10 : 5
const dollars = credits * 0.01
return `$${dollars.toFixed(2)}/Run`
}
},
TripoRetargetNode: {
displayPrice: '$0.10/Run'
},
TripoRefineNode: {
displayPrice: '$0.30/Run'
},
// Google/Gemini nodes
GeminiNode: {
displayPrice: (node: LGraphNode): string => {
@@ -2019,8 +2059,51 @@ export const useNodePricing = () => {
RunwayImageToVideoNodeGen4: ['duration'],
RunwayFirstLastFrameNode: ['duration'],
// Tripo nodes
TripoTextToModelNode: ['quad', 'style', 'texture', 'texture_quality'],
TripoImageToModelNode: ['quad', 'style', 'texture', 'texture_quality'],
TripoTextToModelNode: [
'model_version',
'quad',
'style',
'texture',
'pbr',
'texture_quality',
'geometry_quality'
],
TripoImageToModelNode: [
'model_version',
'quad',
'style',
'texture',
'pbr',
'texture_quality',
'geometry_quality'
],
TripoMultiviewToModelNode: [
'model_version',
'quad',
'texture',
'pbr',
'texture_quality',
'geometry_quality'
],
TripoConversionNode: [
'quad',
'face_limit',
'texture_size',
'texture_format',
'force_symmetry',
'flatten_bottom',
'flatten_bottom_threshold',
'pivot_to_center_bottom',
'scale_factor',
'with_animation',
'pack_uv',
'bake',
'part_names',
'fbx_preset',
'export_vertex_colors',
'export_orientation',
'animate_in_place'
],
TripoTextureNode: ['texture_quality'],
// Google/Gemini nodes
GeminiNode: ['model'],

View File

@@ -1414,16 +1414,22 @@ describe('useNodePricing', () => {
'duration'
])
expect(getRelevantWidgetNames('TripoTextToModelNode')).toEqual([
'model_version',
'quad',
'style',
'texture',
'texture_quality'
'pbr',
'texture_quality',
'geometry_quality'
])
expect(getRelevantWidgetNames('TripoImageToModelNode')).toEqual([
'model_version',
'quad',
'style',
'texture',
'texture_quality'
'pbr',
'texture_quality',
'geometry_quality'
])
})
})
@@ -1507,6 +1513,7 @@ describe('useNodePricing', () => {
it('should return v2.5 standard pricing for TripoTextToModelNode', () => {
const { getNodeDisplayPrice } = useNodePricing()
const node = createMockNode('TripoTextToModelNode', [
{ name: 'model_version', value: 'v2.5' },
{ name: 'quad', value: false },
{ name: 'style', value: 'any style' },
{ name: 'texture', value: false },
@@ -1520,6 +1527,7 @@ describe('useNodePricing', () => {
it('should return v2.5 detailed pricing for TripoTextToModelNode', () => {
const { getNodeDisplayPrice } = useNodePricing()
const node = createMockNode('TripoTextToModelNode', [
{ name: 'model_version', value: 'v2.5' },
{ name: 'quad', value: true },
{ name: 'style', value: 'any style' },
{ name: 'texture', value: false },
@@ -1527,12 +1535,13 @@ describe('useNodePricing', () => {
])
const price = getNodeDisplayPrice(node)
expect(price).toBe('$0.35/Run') // any style, quad, no texture, detailed
expect(price).toBe('$0.30/Run') // any style, quad, no texture, detailed
})
it('should return v2.0 detailed pricing for TripoImageToModelNode', () => {
const { getNodeDisplayPrice } = useNodePricing()
const node = createMockNode('TripoImageToModelNode', [
{ name: 'model_version', value: 'v2.0' },
{ name: 'quad', value: true },
{ name: 'style', value: 'any style' },
{ name: 'texture', value: false },
@@ -1540,12 +1549,13 @@ describe('useNodePricing', () => {
])
const price = getNodeDisplayPrice(node)
expect(price).toBe('$0.45/Run') // any style, quad, no texture, detailed
expect(price).toBe('$0.40/Run') // any style, quad, no texture, detailed
})
it('should return legacy pricing for TripoTextToModelNode', () => {
const { getNodeDisplayPrice } = useNodePricing()
const node = createMockNode('TripoTextToModelNode', [
{ name: 'model_version', value: 'v2.0' },
{ name: 'quad', value: false },
{ name: 'style', value: 'none' },
{ name: 'texture', value: false },
@@ -1561,7 +1571,7 @@ describe('useNodePricing', () => {
const node = createMockNode('TripoRefineNode')
const price = getNodeDisplayPrice(node)
expect(price).toBe('$0.3/Run')
expect(price).toBe('$0.30/Run')
})
it('should return fallback for TripoTextToModelNode without model', () => {
@@ -1570,7 +1580,7 @@ describe('useNodePricing', () => {
const price = getNodeDisplayPrice(node)
expect(price).toBe(
'$0.1-0.4/Run (varies with quad, style, texture & quality)'
'$0.1-0.65/Run (varies with quad, style, texture & quality)'
)
})
@@ -1592,24 +1602,39 @@ describe('useNodePricing', () => {
// Test different parameter combinations
const testCases = [
{ quad: false, style: 'none', texture: false, expected: '$0.10/Run' },
{
model_version: 'v3.0',
quad: false,
style: 'none',
texture: false,
expected: '$0.10/Run'
},
{
model_version: 'v3.0',
quad: false,
style: 'any style',
texture: false,
expected: '$0.15/Run'
},
{ quad: true, style: 'none', texture: false, expected: '$0.20/Run' },
{
model_version: 'v3.0',
quad: true,
style: 'any style',
texture: false,
expected: '$0.25/Run'
expected: '$0.20/Run'
},
{
model_version: 'v3.0',
quad: true,
style: 'any style',
texture: true,
expected: '$0.30/Run'
}
]
testCases.forEach(({ quad, style, texture, expected }) => {
const node = createMockNode('TripoTextToModelNode', [
{ name: 'model_version', value: 'v2.0' },
{ name: 'quad', value: quad },
{ name: 'style', value: style },
{ name: 'texture', value: texture },
@@ -1619,17 +1644,9 @@ describe('useNodePricing', () => {
})
})
it('should return static price for TripoConvertModelNode', () => {
it('should return static price for TripoRetargetNode', () => {
const { getNodeDisplayPrice } = useNodePricing()
const node = createMockNode('TripoConvertModelNode')
const price = getNodeDisplayPrice(node)
expect(price).toBe('$0.10/Run')
})
it('should return static price for TripoRetargetRiggedModelNode', () => {
const { getNodeDisplayPrice } = useNodePricing()
const node = createMockNode('TripoRetargetRiggedModelNode')
const node = createMockNode('TripoRetargetNode')
const price = getNodeDisplayPrice(node)
expect(price).toBe('$0.10/Run')
@@ -1640,6 +1657,7 @@ describe('useNodePricing', () => {
// Test basic case - no style, no quad, no texture
const basicNode = createMockNode('TripoMultiviewToModelNode', [
{ name: 'model_version', value: 'v3.0' },
{ name: 'quad', value: false },
{ name: 'style', value: 'none' },
{ name: 'texture', value: false },
@@ -1649,6 +1667,7 @@ describe('useNodePricing', () => {
// Test high-end case - any style, quad, texture, detailed
const highEndNode = createMockNode('TripoMultiviewToModelNode', [
{ name: 'model_version', value: 'v3.0' },
{ name: 'quad', value: true },
{ name: 'style', value: 'stylized' },
{ name: 'texture', value: true },
@@ -1663,7 +1682,7 @@ describe('useNodePricing', () => {
const price = getNodeDisplayPrice(node)
expect(price).toBe(
'$0.2-0.5/Run (varies with quad, style, texture & quality)'
'$0.1-0.65/Run (varies with quad, style, texture & quality)'
)
})
})
@@ -1870,7 +1889,7 @@ describe('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: true, expected: '$0.30/Run' },
{
quad: true,
style: 'any style',
@@ -1879,9 +1898,9 @@ describe('useNodePricing', () => {
expected: '$0.50/Run'
},
{
quad: true,
quad: false,
style: 'any style',
texture: false,
texture: true,
textureQuality: 'standard',
expected: '$0.35/Run'
}
@@ -1890,6 +1909,7 @@ describe('useNodePricing', () => {
testCases.forEach(
({ quad, style, texture, textureQuality, expected }) => {
const widgets = [
{ name: 'model_version', value: 'v3.0' },
{ name: 'quad', value: quad },
{ name: 'style', value: style },
{ name: 'texture', value: texture }
@@ -1909,7 +1929,7 @@ describe('useNodePricing', () => {
const price = getNodeDisplayPrice(node)
expect(price).toBe(
'$0.2-0.5/Run (varies with quad, style, texture & quality)'
'$0.1-0.65/Run (varies with quad, style, texture & quality)'
)
})
@@ -1919,7 +1939,7 @@ describe('useNodePricing', () => {
const price = getNodeDisplayPrice(node)
expect(price).toBe(
'$0.1-0.4/Run (varies with quad, style, texture & quality)'
'$0.1-0.65/Run (varies with quad, style, texture & quality)'
)
})
@@ -1931,7 +1951,7 @@ describe('useNodePricing', () => {
const price = getNodeDisplayPrice(node)
expect(price).toBe(
'$0.1-0.4/Run (varies with quad, style, texture & quality)'
'$0.1-0.65/Run (varies with quad, style, texture & quality)'
)
})