From 565305175c6ceb5631f2c3ac012085b499cc193e Mon Sep 17 00:00:00 2001 From: AustinMroz Date: Wed, 8 Oct 2025 15:03:16 -0700 Subject: [PATCH] Initial 1.28 backports (#5986) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Co-authored-by: Alexander Brown Co-authored-by: Marwan Ahmed <155799754+marawan206@users.noreply.github.com> --- src/components/graph/TitleEditor.vue | 2 +- src/composables/node/useNodeImage.ts | 6 +- src/composables/node/useNodePricing.ts | 72 ++++++++++++ src/core/graph/subgraph/proxyWidget.ts | 1 - src/core/graph/subgraph/proxyWidgetUtils.ts | 12 +- src/core/schemas/proxyWidget.ts | 5 +- src/platform/updates/common/releaseStore.ts | 12 +- src/services/litegraphService.ts | 10 +- .../composables/node/useNodePricing.test.ts | 108 ++++++++++++++++++ tests-ui/tests/store/releaseStore.test.ts | 45 +++----- 10 files changed, 224 insertions(+), 49 deletions(-) diff --git a/src/components/graph/TitleEditor.vue b/src/components/graph/TitleEditor.vue index e5505fac5..738d2c028 100644 --- a/src/components/graph/TitleEditor.vue +++ b/src/components/graph/TitleEditor.vue @@ -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 diff --git a/src/composables/node/useNodeImage.ts b/src/composables/node/useNodeImage.ts index ad0a04178..aad473ae7 100644 --- a/src/composables/node/useNodeImage.ts +++ b/src/composables/node/useNodeImage.ts @@ -98,7 +98,7 @@ const useNodePreview = ( /** * 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 => @@ -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, { diff --git a/src/composables/node/useNodePricing.ts b/src/composables/node/useNodePricing.ts index 194e3c0a0..a2590101c 100644 --- a/src/composables/node/useNodePricing.ts +++ b/src/composables/node/useNodePricing.ts @@ -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 = 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'], diff --git a/src/core/graph/subgraph/proxyWidget.ts b/src/core/graph/subgraph/proxyWidget.ts index d7d76a9d1..3879fed8a 100644 --- a/src/core/graph/subgraph/proxyWidget.ts +++ b/src/core/graph/subgraph/proxyWidget.ts @@ -121,7 +121,6 @@ function addProxyWidget( afterQueued: undefined, computedHeight: undefined, isProxyWidget: true, - label: name, last_y: undefined, name, node: subgraphNode, diff --git a/src/core/graph/subgraph/proxyWidgetUtils.ts b/src/core/graph/subgraph/proxyWidgetUtils.ts index e13cc9942..2d4ed5eb0 100644 --- a/src/core/graph/subgraph/proxyWidgetUtils.ts +++ b/src/core/graph/subgraph/proxyWidgetUtils.ts @@ -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) diff --git a/src/core/schemas/proxyWidget.ts b/src/core/schemas/proxyWidget.ts index 307c4987a..d2df586fc 100644 --- a/src/core/schemas/proxyWidget.ts +++ b/src/core/schemas/proxyWidget.ts @@ -9,7 +9,10 @@ export type ProxyWidgetsProperty = z.infer 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) diff --git a/src/platform/updates/common/releaseStore.ts b/src/platform/updates/common/releaseStore.ts index 470c92272..d51d5d026 100644 --- a/src/platform/updates/common/releaseStore.ts +++ b/src/platform/updates/common/releaseStore.ts @@ -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(() => { diff --git a/src/services/litegraphService.ts b/src/services/litegraphService.ts index d345acb67..bf9d74db0 100644 --- a/src/services/litegraphService.ts +++ b/src/services/litegraphService.ts @@ -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() } } diff --git a/tests-ui/tests/composables/node/useNodePricing.test.ts b/tests-ui/tests/composables/node/useNodePricing.test.ts index 976c5638c..36697cbc8 100644 --- a/tests-ui/tests/composables/node/useNodePricing.test.ts +++ b/tests-ui/tests/composables/node/useNodePricing.test.ts @@ -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() diff --git a/tests-ui/tests/store/releaseStore.test.ts b/tests-ui/tests/store/releaseStore.test.ts index 467ea9f18..4020eac63 100644 --- a/tests-ui/tests/store/releaseStore.test.ts +++ b/tests-ui/tests/store/releaseStore.test.ts @@ -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) })