Skip to content
Merged
Show file tree
Hide file tree
Changes from 11 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/i18n-custom-nodes.yaml
Comment thread
DrJKL marked this conversation as resolved.
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ jobs:
uses: actions/checkout@v4
with:
repository: Comfy-Org/ComfyUI_devtools
path: ComfyUI/custom_nodes/ComfyUI_devtools
path: ComfyUI/custom_nodes/ComfyUI_devtools
- name: Checkout custom node repository
uses: actions/checkout@v4
with:
Expand Down
73 changes: 73 additions & 0 deletions src/composables/node/useNodePricing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,75 @@ const byteDanceVideoPricingCalculator = (node: LGraphNode): string => {
: `$${minCost.toFixed(2)}-$${maxCost.toFixed(2)}/Run`
}

// ---- constants (file-local) ----
const SORA720 = new Set<string>(['720x1280', '1280x720'])
const SORA_PRO_HIRES = new Set<string>(['1024x1792', '1792x1024'])
const ALL_SIZES = new Set<string>([...SORA720, ...SORA_PRO_HIRES])

function validateSora2Selection(
modelRaw: string,
duration: number,
sizeRaw: string
): string | undefined {
const model = String(modelRaw ?? '').toLowerCase()
const size = String(sizeRaw ?? '').toLowerCase()

if (!duration || Number.isNaN(duration)) return 'Set duration (4s /8s /12s)'
if (!size) return 'Set size (720x1280, 1280x720, 1024x1792, 1792x1024)'
if (!ALL_SIZES.has(size))
return 'Size must be 720x1280, 1280x720, 1024x1792, or 1792x1024'
if (model.includes('sora-2-pro')) return undefined

if (model.includes('sora-2')) {
if (!SORA720.has(size)) return 'sora-2 supports only 720x1280 or 1280x720'
return undefined
}

return 'Unsupported model'
}
function perSecForSora2(modelRaw: string, sizeRaw: string): number {
const model = String(modelRaw ?? '').toLowerCase()
const size = String(sizeRaw ?? '').toLowerCase()

if (model.includes('sora-2-pro')) {
if (SORA_PRO_HIRES.has(size)) return 0.5
if (SORA720.has(size)) return 0.3
}
// plain sora-2 (validated to 720p already)
if (model.includes('sora-2')) {
return 0.1
}
return SORA_PRO_HIRES.has(size) ? 0.5 : 0.1
}

function formatRunPrice(perSec: number, duration: number) {
return `$${(perSec * duration).toFixed(2)}/Run`
}

const sora2PricingCalculator: PricingFunction = (node: LGraphNode): string => {
const modelW = node.widgets?.find((w) => w.name === 'model') as
| IComboWidget
| undefined
const durationW = node.widgets?.find(
(w) => w.name === 'duration' || w.name === 'duration_s'
) as IComboWidget | undefined
const sizeW = node.widgets?.find((w) => w.name === 'size') as
| IComboWidget
| undefined

if (!modelW || !durationW) return 'Set model, duration & size'
if (!sizeW) return 'Set size (720x1280, 1280x720, 1024x1792, 1792x1024)'

const model = String(modelW.value ?? '')
const duration = Number(durationW.value)
const size = String(sizeW.value ?? '')

const validationError = validateSora2Selection(model, duration, size)
if (validationError) return validationError

const perSec = perSecForSora2(model, size)
return formatRunPrice(perSec, duration)
}
/**
* Static pricing data for API nodes, now supporting both strings and functions
*/
Expand All @@ -195,6 +264,9 @@ const apiNodeCosts: Record<string, { displayPrice: string | PricingFunction }> =
FluxProKontextMaxNode: {
displayPrice: '$0.08/Run'
},
OpenAIVideoSora2: {
displayPrice: sora2PricingCalculator
},
IdeogramV1: {
displayPrice: (node: LGraphNode): string => {
const numImagesWidget = node.widgets?.find(
Expand Down Expand Up @@ -1589,6 +1661,7 @@ export const useNodePricing = () => {
MinimaxHailuoVideoNode: ['resolution', 'duration'],
OpenAIDalle3: ['size', 'quality'],
OpenAIDalle2: ['size', 'n'],
OpenAIVideoSora2: ['model', 'size', 'duration'],
OpenAIGPTImage1: ['quality', 'n'],
IdeogramV1: ['num_images', 'turbo'],
IdeogramV2: ['num_images', 'turbo'],
Expand Down
110 changes: 110 additions & 0 deletions tests-ui/tests/composables/node/useNodePricing.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -269,7 +269,117 @@ describe('useNodePricing', () => {
expect(price).toBe('$0.04-0.12/Run (varies with size & quality)')
})
})
// ============================== OpenAIVideoSora2 ==============================
describe('dynamic pricing - OpenAIVideoSora2', () => {
it('should require model, duration & size when widgets are missing', () => {
const { getNodeDisplayPrice } = useNodePricing()
const node = createMockNode('OpenAIVideoSora2', [])
expect(getNodeDisplayPrice(node)).toBe('Set model, duration & size')
})

it('should require duration when duration is invalid or zero', () => {
const { getNodeDisplayPrice } = useNodePricing()
const nodeNaN = createMockNode('OpenAIVideoSora2', [
{ name: 'model', value: 'sora-2-pro' },
{ name: 'duration', value: 'oops' },
{ name: 'size', value: '720x1280' }
])
expect(getNodeDisplayPrice(nodeNaN)).toBe('Set duration (4/8/12)')

const nodeZero = createMockNode('OpenAIVideoSora2', [
{ name: 'model', value: 'sora-2-pro' },
{ name: 'duration', value: 0 },
{ name: 'size', value: '720x1280' }
])
expect(getNodeDisplayPrice(nodeZero)).toBe('Set duration (4/8/12)')
})

it('should require size when size is missing', () => {
const { getNodeDisplayPrice } = useNodePricing()
const node = createMockNode('OpenAIVideoSora2', [
{ name: 'model', value: 'sora-2-pro' },
{ name: 'duration', value: 8 }
])
expect(getNodeDisplayPrice(node)).toBe(
'Set size (720x1280, 1280x720, 1024x1792, 1792x1024)'
)
})

it('should compute pricing for sora-2-pro with 1024x1792', () => {
const { getNodeDisplayPrice } = useNodePricing()
const node = createMockNode('OpenAIVideoSora2', [
{ name: 'model', value: 'sora-2-pro' },
{ name: 'duration', value: 8 },
{ name: 'size', value: '1024x1792' }
])
expect(getNodeDisplayPrice(node)).toBe('$4.00/Run') // 0.5 * 8
})

it('should compute pricing for sora-2-pro with 720x1280', () => {
const { getNodeDisplayPrice } = useNodePricing()
const node = createMockNode('OpenAIVideoSora2', [
{ name: 'model', value: 'sora-2-pro' },
{ name: 'duration', value: 12 },
{ name: 'size', value: '720x1280' }
])
expect(getNodeDisplayPrice(node)).toBe('$3.60/Run') // 0.3 * 12
})

it('should reject unsupported size for sora-2-pro', () => {
const { getNodeDisplayPrice } = useNodePricing()
const node = createMockNode('OpenAIVideoSora2', [
{ name: 'model', value: 'sora-2-pro' },
{ name: 'duration', value: 8 },
{ name: 'size', value: '640x640' }
])
expect(getNodeDisplayPrice(node)).toBe(
'Size must be 720x1280, 1280x720, 1024x1792, or 1792x1024'
)
})

it('should compute pricing for sora-2 (720x1280 only)', () => {
const { getNodeDisplayPrice } = useNodePricing()
const node = createMockNode('OpenAIVideoSora2', [
{ name: 'model', value: 'sora-2' },
{ name: 'duration', value: 10 },
{ name: 'size', value: '720x1280' }
])
expect(getNodeDisplayPrice(node)).toBe('$1.00/Run') // 0.1 * 10
})

it('should reject non-720 sizes for sora-2', () => {
const { getNodeDisplayPrice } = useNodePricing()
const node = createMockNode('OpenAIVideoSora2', [
{ name: 'model', value: 'sora-2' },
{ name: 'duration', value: 8 },
{ name: 'size', value: '1024x1792' }
])
expect(getNodeDisplayPrice(node)).toBe(
'sora-2 supports only 720x1280 or 1280x720'
)
})
it('should accept duration_s alias for duration', () => {
const { getNodeDisplayPrice } = useNodePricing()
const node = createMockNode('OpenAIVideoSora2', [
{ name: 'model', value: 'sora-2-pro' },
{ name: 'duration_s', value: 4 },
{ name: 'size', value: '1792x1024' }
])
expect(getNodeDisplayPrice(node)).toBe('$2.00/Run') // 0.5 * 4
})

it('should be case-insensitive for model and size', () => {
const { getNodeDisplayPrice } = useNodePricing()
const node = createMockNode('OpenAIVideoSora2', [
{ name: 'model', value: 'SoRa-2-PrO' },
{ name: 'duration', value: 12 },
{ name: 'size', value: '1280x720' }
])
expect(getNodeDisplayPrice(node)).toBe('$3.60/Run') // 0.3 * 12
})
})

// ============================== MinimaxHailuoVideoNode ==============================
describe('dynamic pricing - MinimaxHailuoVideoNode', () => {
it('should return $0.28 for 6s duration and 768P resolution', () => {
const { getNodeDisplayPrice } = useNodePricing()
Expand Down