mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-02-23 08:14:06 +00:00
Initial 1.28 backports (#5986)
Includes - Subgraph widget promotion fixes (#5911) - fix "what's changed" release toast attention level logic (#5959) - Fix: Missing Node Title Editor bug (#5963) - OpenAIVideoSora2 Frontend pricing (#5958) - hotfix: quick test updates for sora2 pricing badge. (#5966) ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-5986-Initial-1-28-backports-2866d73d365081448ff2c4827bdbb9e5) by [Unito](https://www.unito.io) --------- Co-authored-by: Christian Byrne <cbyrne@comfy.org> Co-authored-by: Alexander Brown <drjkl@comfy.org> Co-authored-by: Marwan Ahmed <155799754+marawan206@users.noreply.github.com>
This commit is contained in:
@@ -47,7 +47,7 @@ const canvasStore = useCanvasStore()
|
||||
const previousCanvasDraggable = ref(true)
|
||||
|
||||
const onEdit = (newValue: string) => {
|
||||
if (titleEditorStore.titleEditorTarget && newValue.trim() !== '') {
|
||||
if (titleEditorStore.titleEditorTarget && newValue?.trim()) {
|
||||
const trimmedTitle = newValue.trim()
|
||||
titleEditorStore.titleEditorTarget.title = trimmedTitle
|
||||
|
||||
|
||||
@@ -98,7 +98,7 @@ const useNodePreview = <T extends MediaElement>(
|
||||
/**
|
||||
* Attaches a preview image to a node.
|
||||
*/
|
||||
export const useNodeImage = (node: LGraphNode) => {
|
||||
export const useNodeImage = (node: LGraphNode, callback?: () => void) => {
|
||||
node.previewMediaType = 'image'
|
||||
|
||||
const loadElement = (url: string): Promise<HTMLImageElement | null> =>
|
||||
@@ -112,6 +112,7 @@ export const useNodeImage = (node: LGraphNode) => {
|
||||
const onLoaded = (elements: HTMLImageElement[]) => {
|
||||
node.imageIndex = null
|
||||
node.imgs = elements
|
||||
callback?.()
|
||||
}
|
||||
|
||||
return useNodePreview(node, {
|
||||
@@ -126,7 +127,7 @@ export const useNodeImage = (node: LGraphNode) => {
|
||||
/**
|
||||
* Attaches a preview video to a node.
|
||||
*/
|
||||
export const useNodeVideo = (node: LGraphNode) => {
|
||||
export const useNodeVideo = (node: LGraphNode, callback?: () => void) => {
|
||||
node.previewMediaType = 'video'
|
||||
let minHeight = DEFAULT_VIDEO_SIZE
|
||||
let minWidth = DEFAULT_VIDEO_SIZE
|
||||
@@ -187,6 +188,7 @@ export const useNodeVideo = (node: LGraphNode) => {
|
||||
}
|
||||
|
||||
node.videoContainer.replaceChildren(videoElement)
|
||||
callback?.()
|
||||
}
|
||||
|
||||
return useNodePreview(node, {
|
||||
|
||||
@@ -169,6 +169,74 @@ const byteDanceVideoPricingCalculator = (node: LGraphNode): string => {
|
||||
: `$${minCost.toFixed(2)}-$${maxCost.toFixed(2)}/Run`
|
||||
}
|
||||
|
||||
// ---- constants ----
|
||||
const SORA_SIZES = {
|
||||
BASIC: new Set(['720x1280', '1280x720']),
|
||||
PRO: new Set(['1024x1792', '1792x1024'])
|
||||
}
|
||||
const ALL_SIZES = new Set([...SORA_SIZES.BASIC, ...SORA_SIZES.PRO])
|
||||
|
||||
// ---- sora-2 pricing helpers ----
|
||||
function validateSora2Selection(
|
||||
modelRaw: string,
|
||||
duration: number,
|
||||
sizeRaw: string
|
||||
): string | undefined {
|
||||
const model = modelRaw?.toLowerCase() ?? ''
|
||||
const size = sizeRaw?.toLowerCase() ?? ''
|
||||
|
||||
if (!duration || Number.isNaN(duration)) return 'Set duration (4s / 8s / 12s)'
|
||||
if (!size) return 'Set size (720x1280, 1280x720, 1024x1792, 1792x1024)'
|
||||
if (!ALL_SIZES.has(size))
|
||||
return 'Invalid size. Must be 720x1280, 1280x720, 1024x1792, or 1792x1024.'
|
||||
|
||||
if (model.includes('sora-2-pro')) return undefined
|
||||
|
||||
if (model.includes('sora-2') && !SORA_SIZES.BASIC.has(size))
|
||||
return 'sora-2 supports only 720x1280 or 1280x720'
|
||||
|
||||
if (!model.includes('sora-2')) return 'Unsupported model'
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
function perSecForSora2(modelRaw: string, sizeRaw: string): number {
|
||||
const model = modelRaw?.toLowerCase() ?? ''
|
||||
const size = sizeRaw?.toLowerCase() ?? ''
|
||||
|
||||
if (model.includes('sora-2-pro')) {
|
||||
return SORA_SIZES.PRO.has(size) ? 0.5 : 0.3
|
||||
}
|
||||
if (model.includes('sora-2')) return 0.1
|
||||
|
||||
return SORA_SIZES.PRO.has(size) ? 0.5 : 0.1
|
||||
}
|
||||
|
||||
function formatRunPrice(perSec: number, duration: number) {
|
||||
return `$${(perSec * duration).toFixed(2)}/Run`
|
||||
}
|
||||
|
||||
// ---- pricing calculator ----
|
||||
const sora2PricingCalculator: PricingFunction = (node: LGraphNode): string => {
|
||||
const getWidgetValue = (name: string) =>
|
||||
String(node.widgets?.find((w) => w.name === name)?.value ?? '')
|
||||
|
||||
const model = getWidgetValue('model')
|
||||
const size = getWidgetValue('size')
|
||||
const duration = Number(
|
||||
node.widgets?.find((w) => ['duration', 'duration_s'].includes(w.name))
|
||||
?.value
|
||||
)
|
||||
|
||||
if (!model || !size || !duration) return 'Set model, duration & size'
|
||||
|
||||
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
|
||||
*/
|
||||
@@ -195,6 +263,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(
|
||||
@@ -1658,6 +1729,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'],
|
||||
|
||||
@@ -121,7 +121,6 @@ function addProxyWidget(
|
||||
afterQueued: undefined,
|
||||
computedHeight: undefined,
|
||||
isProxyWidget: true,
|
||||
label: name,
|
||||
last_y: undefined,
|
||||
name,
|
||||
node: subgraphNode,
|
||||
|
||||
@@ -119,9 +119,15 @@ export function promoteRecommendedWidgets(subgraphNode: SubgraphNode) {
|
||||
const interiorNodes = subgraphNode.subgraph.nodes
|
||||
for (const node of interiorNodes) {
|
||||
node.updateComputedDisabled()
|
||||
//NOTE: Since this operation is async, previews still don't exist after the single frame
|
||||
//Add an onLoad callback to updatePreviews?
|
||||
updatePreviews(node)
|
||||
function checkWidgets() {
|
||||
updatePreviews(node)
|
||||
const widget = node.widgets?.find((w) => w.name.startsWith('$$'))
|
||||
if (!widget) return
|
||||
const pw = getProxyWidgets(subgraphNode)
|
||||
if (pw.some(matchesPropertyItem([node, widget]))) return
|
||||
promoteWidget(node, widget, [subgraphNode])
|
||||
}
|
||||
requestAnimationFrame(() => updatePreviews(node, checkWidgets))
|
||||
}
|
||||
const filteredWidgets: WidgetItem[] = interiorNodes
|
||||
.flatMap(nodeWidgets)
|
||||
|
||||
@@ -9,7 +9,10 @@ export type ProxyWidgetsProperty = z.infer<typeof proxyWidgetsPropertySchema>
|
||||
export function parseProxyWidgets(
|
||||
property: NodeProperty | undefined
|
||||
): ProxyWidgetsProperty {
|
||||
const result = proxyWidgetsPropertySchema.safeParse(property)
|
||||
if (typeof property === 'string') property = JSON.parse(property)
|
||||
const result = proxyWidgetsPropertySchema.safeParse(
|
||||
typeof property === 'string' ? JSON.parse(property) : property
|
||||
)
|
||||
if (result.success) return result.data
|
||||
|
||||
const error = fromZodError(result.error)
|
||||
|
||||
@@ -72,14 +72,10 @@ export const useReleaseStore = defineStore('release', () => {
|
||||
) === 0
|
||||
)
|
||||
|
||||
const hasMediumOrHighAttention = computed(() =>
|
||||
recentReleases.value
|
||||
.slice(0, -1)
|
||||
.some(
|
||||
(release) =>
|
||||
release.attention === 'medium' || release.attention === 'high'
|
||||
)
|
||||
)
|
||||
const hasMediumOrHighAttention = computed(() => {
|
||||
const attention = recentRelease.value?.attention
|
||||
return attention === 'medium' || attention === 'high'
|
||||
})
|
||||
|
||||
// Show toast if needed
|
||||
const shouldShowToast = computed(() => {
|
||||
|
||||
@@ -853,14 +853,14 @@ export const useLitegraphService = () => {
|
||||
return []
|
||||
}
|
||||
}
|
||||
function updatePreviews(node: LGraphNode) {
|
||||
function updatePreviews(node: LGraphNode, callback?: () => void) {
|
||||
try {
|
||||
unsafeUpdatePreviews.call(node)
|
||||
unsafeUpdatePreviews.call(node, callback)
|
||||
} catch (error) {
|
||||
console.error('Error drawing node background', error)
|
||||
}
|
||||
}
|
||||
function unsafeUpdatePreviews(this: LGraphNode) {
|
||||
function unsafeUpdatePreviews(this: LGraphNode, callback?: () => void) {
|
||||
if (this.flags.collapsed) return
|
||||
|
||||
const nodeOutputStore = useNodeOutputStore()
|
||||
@@ -891,9 +891,9 @@ export const useLitegraphService = () => {
|
||||
(this.animatedImages && !isAnimatedWebp && !isAnimatedPng) ||
|
||||
isVideoNode(this)
|
||||
if (isVideo) {
|
||||
useNodeVideo(this).showPreview()
|
||||
useNodeVideo(this, callback).showPreview()
|
||||
} else {
|
||||
useNodeImage(this).showPreview()
|
||||
useNodeImage(this, callback).showPreview()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -301,7 +301,115 @@ 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 model, duration & size')
|
||||
|
||||
const nodeZero = createMockNode('OpenAIVideoSora2', [
|
||||
{ name: 'model', value: 'sora-2-pro' },
|
||||
{ name: 'duration', value: 0 },
|
||||
{ name: 'size', value: '720x1280' }
|
||||
])
|
||||
expect(getNodeDisplayPrice(nodeZero)).toBe('Set model, duration & size')
|
||||
})
|
||||
|
||||
it('should require size when size is missing', () => {
|
||||
const { getNodeDisplayPrice } = useNodePricing()
|
||||
const node = createMockNode('OpenAIVideoSora2', [
|
||||
{ name: 'model', value: 'sora-2-pro' },
|
||||
{ name: 'duration', value: 8 }
|
||||
])
|
||||
expect(getNodeDisplayPrice(node)).toBe('Set model, duration & size')
|
||||
})
|
||||
|
||||
it('should compute pricing for sora-2-pro with 1024x1792', () => {
|
||||
const { getNodeDisplayPrice } = useNodePricing()
|
||||
const node = createMockNode('OpenAIVideoSora2', [
|
||||
{ name: 'model', value: 'sora-2-pro' },
|
||||
{ name: 'duration', value: 8 },
|
||||
{ name: 'size', value: '1024x1792' }
|
||||
])
|
||||
expect(getNodeDisplayPrice(node)).toBe('$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(
|
||||
'Invalid size. Must be 720x1280, 1280x720, 1024x1792, or 1792x1024.'
|
||||
)
|
||||
})
|
||||
|
||||
it('should compute pricing for sora-2 (720x1280 only)', () => {
|
||||
const { getNodeDisplayPrice } = useNodePricing()
|
||||
const node = createMockNode('OpenAIVideoSora2', [
|
||||
{ name: 'model', value: 'sora-2' },
|
||||
{ name: 'duration', value: 10 },
|
||||
{ name: 'size', value: '720x1280' }
|
||||
])
|
||||
expect(getNodeDisplayPrice(node)).toBe('$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()
|
||||
|
||||
@@ -145,18 +145,24 @@ describe('useReleaseStore', () => {
|
||||
|
||||
it('should show toast for medium/high attention releases', () => {
|
||||
vi.mocked(semverCompare).mockReturnValue(1)
|
||||
|
||||
// Need multiple releases for hasMediumOrHighAttention to work
|
||||
const mediumRelease = {
|
||||
...mockRelease,
|
||||
id: 2,
|
||||
attention: 'medium' as const
|
||||
}
|
||||
store.releases = [mockRelease, mediumRelease]
|
||||
store.releases = [mockRelease]
|
||||
|
||||
expect(store.shouldShowToast).toBe(true)
|
||||
})
|
||||
|
||||
it('should not show toast for low attention releases', () => {
|
||||
vi.mocked(semverCompare).mockReturnValue(1)
|
||||
|
||||
const lowAttentionRelease = {
|
||||
...mockRelease,
|
||||
attention: 'low' as const
|
||||
}
|
||||
|
||||
store.releases = [lowAttentionRelease]
|
||||
|
||||
expect(store.shouldShowToast).toBe(false)
|
||||
})
|
||||
|
||||
it('should show red dot for new versions', () => {
|
||||
vi.mocked(semverCompare).mockReturnValue(1)
|
||||
|
||||
@@ -490,12 +496,7 @@ describe('useReleaseStore', () => {
|
||||
|
||||
vi.mocked(semverCompare).mockReturnValue(1)
|
||||
|
||||
const mediumRelease = { ...mockRelease, attention: 'medium' as const }
|
||||
store.releases = [
|
||||
mockRelease,
|
||||
mediumRelease,
|
||||
{ ...mockRelease, attention: 'low' as const }
|
||||
]
|
||||
store.releases = [mockRelease]
|
||||
|
||||
expect(store.shouldShowToast).toBe(true)
|
||||
})
|
||||
@@ -578,14 +579,7 @@ describe('useReleaseStore', () => {
|
||||
|
||||
it('should show toast when conditions are met', () => {
|
||||
vi.mocked(semverCompare).mockReturnValue(1)
|
||||
|
||||
// Need multiple releases for hasMediumOrHighAttention
|
||||
const mediumRelease = {
|
||||
...mockRelease,
|
||||
id: 2,
|
||||
attention: 'medium' as const
|
||||
}
|
||||
store.releases = [mockRelease, mediumRelease]
|
||||
store.releases = [mockRelease]
|
||||
|
||||
expect(store.shouldShowToast).toBe(true)
|
||||
})
|
||||
@@ -615,12 +609,7 @@ describe('useReleaseStore', () => {
|
||||
vi.mocked(semverCompare).mockReturnValue(1)
|
||||
|
||||
// Set up all conditions that would normally show toast
|
||||
const mediumRelease = {
|
||||
...mockRelease,
|
||||
id: 2,
|
||||
attention: 'medium' as const
|
||||
}
|
||||
store.releases = [mockRelease, mediumRelease]
|
||||
store.releases = [mockRelease]
|
||||
|
||||
expect(store.shouldShowToast).toBe(false)
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user