Compare commits

...

11 Commits

Author SHA1 Message Date
Comfy Org PR Bot
360fd31821 1.28.9 (#6786)
Patch version increment to 1.28.9

**Base branch:** `core/1.28`

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6786-1-28-9-2b16d73d365081f9bacdc5ab856566fd)
by [Unito](https://www.unito.io)

Co-authored-by: christian-byrne <72887196+christian-byrne@users.noreply.github.com>
2025-11-20 10:49:33 -07:00
Comfy Org PR Bot
7f9c025dc8 [backport core/1.28] feat(api-nodes-pricing): add Nano-Banana-2 prices (#6782)
Backport of #6781 to `core/1.28`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6782-backport-core-1-28-feat-api-nodes-pricing-add-Nano-Banana-2-prices-2b16d73d365081189741f82af2904a61)
by [Unito](https://www.unito.io)

Co-authored-by: Alexander Piskun <13381981+bigcat88@users.noreply.github.com>
2025-11-20 10:34:22 -07:00
Comfy Org PR Bot
b503d9a44f 1.28.8 (#6344)
Patch version increment to 1.28.8

**Base branch:** `core/1.28`

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6344-1-28-8-29a6d73d3650813ab2a6e688d1aa646b)
by [Unito](https://www.unito.io)

Co-authored-by: Kosinkadink <7365912+Kosinkadink@users.noreply.github.com>
2025-10-27 23:27:12 -07:00
Comfy Org PR Bot
a040916b5f [backport core/1.28] feat(api-nodes): add pricing for new LTXV-2 models (#6343)
Backport of #6307 to `core/1.28`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6343-backport-core-1-28-feat-api-nodes-add-pricing-for-new-LTXV-2-models-29a6d73d3650811da262c952ae44806c)
by [Unito](https://www.unito.io)

Co-authored-by: Alexander Piskun <13381981+bigcat88@users.noreply.github.com>
2025-10-27 23:03:37 -07:00
Arjan Singh
eec2b573a9 Backports for 1.28 (#6084)
## Summary

Create version `1.28.7`

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6084-Backports-for-1-28-28e6d73d3650819fa902d38a22a04736)
by [Unito](https://www.unito.io)
2025-10-15 20:04:22 -07:00
Comfy Org PR Bot
891ed737b8 [backport 1.28] Add additional check when restoring widgets_values (#6083)
Backport of #6054 to `core/1.28`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6083-backport-1-28-Add-additional-check-when-restoring-widgets_values-28e6d73d3650811f9730da120b3ec172)
by [Unito](https://www.unito.io)

Co-authored-by: AustinMroz <austin@comfy.org>
2025-10-15 19:39:41 -07:00
Comfy Org PR Bot
f36ad8544c [backport 1.28] add pricing for new Veo3.1 model (#6075)
Backport of #6074 to `core/1.28`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6075-backport-1-28-add-pricing-for-new-Veo3-1-model-28d6d73d365081c0b647f8a472d4fca9)
by [Unito](https://www.unito.io)

Co-authored-by: Alexander Piskun <13381981+bigcat88@users.noreply.github.com>
2025-10-15 21:52:45 +03:00
Comfy Org PR Bot
947ea62b00 [backport 1.28] Use type check instead of cast (#6042)
Backport of #6041 to `core/1.28`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6042-backport-1-28-Use-type-check-instead-of-cast-28b6d73d365081c890abe16efbff7c2b)
by [Unito](https://www.unito.io)

Co-authored-by: AustinMroz <austin@comfy.org>
2025-10-14 10:48:07 -07:00
Comfy Org PR Bot
644ef01d22 [backport 1.28] fix(execution): reset progress state after runs to unfreeze tab title/favicon (main) (#6027)
Backport of #6026 to `core/1.28`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6027-backport-1-28-fix-execution-reset-progress-state-after-runs-to-unfreeze-tab-title-fav-28a6d73d3650815d866efa9f3190f88c)
by [Unito](https://www.unito.io)

Co-authored-by: Benjamin Lu <benceruleanlu@proton.me>
2025-10-11 19:25:12 -07:00
AustinMroz
b00aca1af0 [Backport 1.28] Allow reordering of linked subgraph widgets (#6009)
Manual backport of #5981 to `core/1.28`

Requires minor styling change since theme code will not be backported.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6009-Backport-1-28-Allow-reordering-of-linked-subgraph-widgets-2886d73d36508125a125e8acc8ae08a7)
by [Unito](https://www.unito.io)
2025-10-10 14:07:39 -07:00
AustinMroz
565305175c 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>
2025-10-08 15:03:16 -07:00
16 changed files with 469 additions and 122 deletions

View File

@@ -1,7 +1,7 @@
{
"name": "@comfyorg/comfyui-frontend",
"private": true,
"version": "1.28.6",
"version": "1.28.9",
"type": "module",
"repository": "https://github.com/Comfy-Org/ComfyUI_frontend",
"homepage": "https://comfy.org",

View File

@@ -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

View File

@@ -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, {

View File

@@ -169,6 +169,114 @@ const byteDanceVideoPricingCalculator = (node: LGraphNode): string => {
: `$${minCost.toFixed(2)}-$${maxCost.toFixed(2)}/Run`
}
const ltxvPricingCalculator = (node: LGraphNode): string => {
const modelWidget = node.widgets?.find(
(w) => w.name === 'model'
) as IComboWidget
const durationWidget = node.widgets?.find(
(w) => w.name === 'duration'
) as IComboWidget
const resolutionWidget = node.widgets?.find(
(w) => w.name === 'resolution'
) as IComboWidget
const fallback = '$0.04-0.24/second'
if (!modelWidget || !durationWidget || !resolutionWidget) return fallback
const model = String(modelWidget.value).toLowerCase()
const resolution = String(resolutionWidget.value).toLowerCase()
const seconds = parseFloat(String(durationWidget.value))
const priceByModel: Record<string, Record<string, number>> = {
'ltx-2 (pro)': {
'1920x1080': 0.06,
'2560x1440': 0.12,
'3840x2160': 0.24
},
'ltx-2 (fast)': {
'1920x1080': 0.04,
'2560x1440': 0.08,
'3840x2160': 0.16
}
}
const modelTable = priceByModel[model]
if (!modelTable) return fallback
const pps = modelTable[resolution]
if (!pps) return fallback
const cost = (pps * seconds).toFixed(2)
return `$${cost}/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 +303,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(
@@ -1070,9 +1181,15 @@ const apiNodeCosts: Record<string, { displayPrice: string | PricingFunction }> =
const generateAudio =
String(generateAudioWidget.value).toLowerCase() === 'true'
if (model.includes('veo-3.0-fast-generate-001')) {
if (
model.includes('veo-3.0-fast-generate-001') ||
model.includes('veo-3.1-fast-generate')
) {
return generateAudio ? '$1.20/Run' : '$0.80/Run'
} else if (model.includes('veo-3.0-generate-001')) {
} else if (
model.includes('veo-3.0-generate-001') ||
model.includes('veo-3.1-generate')
) {
return generateAudio ? '$3.20/Run' : '$1.60/Run'
}
@@ -1426,7 +1543,26 @@ const apiNodeCosts: Record<string, { displayPrice: string | PricingFunction }> =
}
},
GeminiImageNode: {
displayPrice: '$0.03 per 1K tokens'
displayPrice: '~$0.039/Image (1K)'
},
GeminiImage2Node: {
displayPrice: (node: LGraphNode): string => {
const resolutionWidget = node.widgets?.find(
(w) => w.name === 'resolution'
) as IComboWidget
if (!resolutionWidget) return 'Token-based'
const resolution = String(resolutionWidget.value)
if (resolution.includes('1K')) {
return '~$0.134/Image'
} else if (resolution.includes('2K')) {
return '~$0.134/Image'
} else if (resolution.includes('4K')) {
return '~$0.24/Image'
}
return 'Token-based'
}
},
// OpenAI nodes
OpenAIChatNode: {
@@ -1617,6 +1753,12 @@ const apiNodeCosts: Record<string, { displayPrice: string | PricingFunction }> =
},
WanImageToImageApi: {
displayPrice: '$0.03/Run'
},
LtxvApiTextToVideo: {
displayPrice: ltxvPricingCalculator
},
LtxvApiImageToVideo: {
displayPrice: ltxvPricingCalculator
}
}
@@ -1658,6 +1800,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'],
@@ -1703,6 +1846,7 @@ export const useNodePricing = () => {
TripoTextureNode: ['texture_quality'],
// Google/Gemini nodes
GeminiNode: ['model'],
GeminiImage2Node: ['resolution'],
// OpenAI nodes
OpenAIChatNode: ['model'],
// ByteDance
@@ -1718,7 +1862,9 @@ export const useNodePricing = () => {
ByteDanceFirstLastFrameNode: ['model', 'duration', 'resolution'],
ByteDanceImageReferenceNode: ['model', 'duration', 'resolution'],
WanTextToVideoApi: ['duration', 'size'],
WanImageToVideoApi: ['duration', 'resolution']
WanImageToVideoApi: ['duration', 'resolution'],
LtxvApiTextToVideo: ['model', 'duration', 'resolution'],
LtxvApiImageToVideo: ['model', 'duration', 'resolution']
}
return widgetMap[nodeType] || []
}

View File

@@ -66,15 +66,21 @@ const activeNode = computed(() => {
const activeWidgets = computed<WidgetItem[]>({
get() {
if (!activeNode.value) return []
const node = activeNode.value
if (!node) return []
return proxyWidgets.value.flatMap(([id, name]: [string, string]) => {
function mapWidgets([id, name]: [string, string]): WidgetItem[] {
if (id === '-1') {
const widget = node.widgets.find((w) => w.name === name)
if (!widget) return []
return [[{ id: -1, title: '(Linked)', type: '' }, widget]]
}
const wNode = node.subgraph._nodes_by_id[id]
if (!wNode?.widgets) return []
const w = wNode.widgets.find((w) => w.name === name)
if (!w) return []
return [[wNode, w]]
})
const widget = wNode.widgets.find((w) => w.name === name)
if (!widget) return []
return [[wNode, widget]]
}
return proxyWidgets.value.flatMap(mapWidgets)
},
set(value: WidgetItem[]) {
const node = activeNode.value
@@ -82,9 +88,7 @@ const activeWidgets = computed<WidgetItem[]>({
console.error('Attempted to toggle widgets with no node selected')
return
}
//map back to id/name
const widgets: ProxyWidgetsProperty = value.map(widgetItemToProperty)
proxyWidgets.value = widgets
proxyWidgets.value = value.map(widgetItemToProperty)
}
})
@@ -167,10 +171,10 @@ function showAll() {
function hideAll() {
const node = activeNode.value
if (!node) return //Not reachable
//Not great from a nesting perspective, but path is cold
//and it cleans up potential error states
proxyWidgets.value = proxyWidgets.value.filter(
(widgetItem) => !filteredActive.value.some(matchesWidgetItem(widgetItem))
(propertyItem) =>
!filteredActive.value.some(matchesWidgetItem(propertyItem)) ||
propertyItem[0] === '-1'
)
}
function showRecommended() {
@@ -260,20 +264,16 @@ onBeforeUnmount(() => {
>
</div>
<div ref="draggableItems">
<div
<SubgraphNodeWidget
v-for="[node, widget] in filteredActive"
:key="toKey([node, widget])"
class="w-full draggable-item"
style=""
>
<SubgraphNodeWidget
:node-title="node.title"
:widget-name="widget.name"
:is-shown="true"
:is-draggable="!debouncedQuery"
@toggle-visibility="demote([node, widget])"
/>
</div>
:node-title="node.title"
:widget-name="widget.name"
:is-shown="true"
:is-draggable="!debouncedQuery"
:is-physical="node.id === -1"
@toggle-visibility="demote([node, widget])"
/>
</div>
</div>
<div v-if="filteredCandidates.length" class="pt-1 pb-4">
@@ -288,17 +288,13 @@ onBeforeUnmount(() => {
{{ $t('subgraphStore.showAll') }}</a
>
</div>
<div
<SubgraphNodeWidget
v-for="[node, widget] in filteredCandidates"
:key="toKey([node, widget])"
class="w-full"
>
<SubgraphNodeWidget
:node-title="node.title"
:widget-name="widget.name"
@toggle-visibility="promote([node, widget])"
/>
</div>
:node-title="node.title"
:widget-name="widget.name"
@toggle-visibility="promote([node, widget])"
/>
</div>
<div
v-if="recommendedWidgets.length"

View File

@@ -8,6 +8,7 @@ const props = defineProps<{
widgetName: string
isShown?: boolean
isDraggable?: boolean
isPhysical?: boolean
}>()
defineEmits<{
(e: 'toggleVisibility'): void
@@ -17,11 +18,17 @@ function classes() {
return cn(
'flex py-1 pr-4 pl-0 break-all rounded items-center gap-1',
'bg-pure-white dark-theme:bg-charcoal-800',
props.isDraggable
? 'drag-handle cursor-grab [.is-draggable]:cursor-grabbing'
: ''
props.isDraggable &&
'draggable-item drag-handle cursor-grab [.is-draggable]:cursor-grabbing'
)
}
function getIcon() {
return props.isPhysical
? 'icon-[lucide--link]'
: props.isDraggable
? 'icon-[lucide--eye]'
: 'icon-[lucide--eye-off]'
}
</script>
<template>
<div :class="classes()">
@@ -40,7 +47,8 @@ function classes() {
<Button
size="small"
text
:icon="isDraggable ? 'icon-[lucide--eye]' : 'icon-[lucide--eye-off]'"
:icon="getIcon()"
:disabled="isPhysical"
severity="secondary"
@click.stop="$emit('toggleVisibility')"
/>

View File

@@ -1,5 +1,6 @@
import { demoteWidget } from '@/core/graph/subgraph/proxyWidgetUtils'
import { parseProxyWidgets } from '@/core/schemas/proxyWidget'
import type { NodeProperty } from '@/lib/litegraph/src/LGraphNode'
import type {
LGraph,
LGraphCanvas,
@@ -75,15 +76,17 @@ const onConfigure = function (
const canvasStore = useCanvasStore()
//Must give value to proxyWidgets prior to defining or it won't serialize
this.properties.proxyWidgets ??= []
let proxyWidgets = this.properties.proxyWidgets
originalOnConfigure?.call(this, serialisedNode)
Object.defineProperty(this.properties, 'proxyWidgets', {
get: () => {
return proxyWidgets
},
set: (property: string) => {
get: () =>
this.widgets.map((w) =>
isProxyWidget(w)
? [w._overlay.nodeId, w._overlay.widgetName]
: ['-1', w.name]
),
set: (property: NodeProperty) => {
const parsed = parseProxyWidgets(property)
const { deactivateWidget, setWidget } = useDomWidgetStore()
const isActiveGraph = useCanvasStore().canvas?.graph === this.graph
@@ -92,21 +95,38 @@ const onConfigure = function (
if (w instanceof DOMWidgetImpl) deactivateWidget(w.id)
}
}
this.widgets = this.widgets.filter((w) => !isProxyWidget(w))
for (const [nodeId, widgetName] of parsed) {
const w = addProxyWidget(this, `${nodeId}`, widgetName)
const newWidgets = parsed.flatMap(([nodeId, widgetName]) => {
if (nodeId === '-1') {
const widget = this.widgets.find((w) => w.name === widgetName)
return widget ? [widget] : []
}
const w = newProxyWidget(this, nodeId, widgetName)
if (isActiveGraph && w instanceof DOMWidgetImpl) setWidget(w)
}
proxyWidgets = property
return [w]
})
this.widgets = this.widgets.filter(
(w) => !isProxyWidget(w) && !parsed.some(([, name]) => w.name === name)
)
this.widgets.push(...newWidgets)
canvasStore.canvas?.setDirty(true, true)
this._setConcreteSlots()
this.arrange()
}
})
this.properties.proxyWidgets = proxyWidgets
if (serialisedNode.properties?.proxyWidgets) {
this.properties.proxyWidgets = serialisedNode.properties.proxyWidgets
const parsed = parseProxyWidgets(serialisedNode.properties.proxyWidgets)
serialisedNode.widgets_values?.forEach((v, index) => {
if (parsed[index]?.[0] !== '-1') return
const widget = this.widgets.find((w) => w.name == parsed[index][1])
if (v !== null && widget) widget.value = v
})
}
}
function addProxyWidget(
function newProxyWidget(
subgraphNode: SubgraphNode,
nodeId: string,
widgetName: string
@@ -121,7 +141,6 @@ function addProxyWidget(
afterQueued: undefined,
computedHeight: undefined,
isProxyWidget: true,
label: name,
last_y: undefined,
name,
node: subgraphNode,
@@ -131,7 +150,7 @@ function addProxyWidget(
width: undefined,
y: 0
}
return addProxyFromOverlay(subgraphNode, overlay)
return newProxyFromOverlay(subgraphNode, overlay)
}
function resolveLinkedWidget(
overlay: Overlay
@@ -142,7 +161,7 @@ function resolveLinkedWidget(
return [n, n.widgets?.find((w: IBaseWidget) => w.name === widgetName)]
}
function addProxyFromOverlay(subgraphNode: SubgraphNode, overlay: Overlay) {
function newProxyFromOverlay(subgraphNode: SubgraphNode, overlay: Overlay) {
const { updatePreviews } = useLitegraphService()
let [linkedNode, linkedWidget] = resolveLinkedWidget(overlay)
let backingWidget = linkedWidget ?? disconnectedWidget
@@ -214,6 +233,5 @@ function addProxyFromOverlay(subgraphNode: SubgraphNode, overlay: Overlay) {
}
}
const w = new Proxy(disconnectedWidget, handler)
subgraphNode.widgets.push(w)
return w
}

View File

@@ -11,13 +11,15 @@ import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets.ts'
import { useLitegraphService } from '@/services/litegraphService'
import { useSubgraphNavigationStore } from '@/stores/subgraphNavigationStore'
export type WidgetItem = [LGraphNode, IBaseWidget]
type PartialNode = Pick<LGraphNode, 'title' | 'id' | 'type'>
export type WidgetItem = [PartialNode, IBaseWidget]
function getProxyWidgets(node: SubgraphNode) {
return parseProxyWidgets(node.properties.proxyWidgets)
}
export function promoteWidget(
node: LGraphNode,
node: PartialNode,
widget: IBaseWidget,
parents: SubgraphNode[]
) {
@@ -32,7 +34,7 @@ export function promoteWidget(
}
export function demoteWidget(
node: LGraphNode,
node: PartialNode,
widget: IBaseWidget,
parents: SubgraphNode[]
) {
@@ -119,9 +121,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)

View File

@@ -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)

View File

@@ -201,8 +201,9 @@ app.registerExtension({
) as unknown as DOMWidget<HTMLAudioElement, string>
const onAudioWidgetUpdate = () => {
if (typeof audioWidget.value !== 'string') return
audioUIWidget.element.src = api.apiURL(
getResourceURL(...splitFilePath(audioWidget.value as string))
getResourceURL(...splitFilePath(audioWidget.value))
)
}
// Initially load default audio file to audioUIWidget.

View File

@@ -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(() => {

View File

@@ -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()
}
}

View File

@@ -239,6 +239,7 @@ export const useExecutionStore = defineStore('execution', () => {
api.addEventListener('execution_start', handleExecutionStart)
api.addEventListener('execution_cached', handleExecutionCached)
api.addEventListener('execution_interrupted', handleExecutionInterrupted)
api.addEventListener('execution_success', handleExecutionSuccess)
api.addEventListener('executed', handleExecuted)
api.addEventListener('executing', handleExecuting)
api.addEventListener('progress', handleProgress)
@@ -253,6 +254,7 @@ export const useExecutionStore = defineStore('execution', () => {
api.removeEventListener('execution_start', handleExecutionStart)
api.removeEventListener('execution_cached', handleExecutionCached)
api.removeEventListener('execution_interrupted', handleExecutionInterrupted)
api.removeEventListener('execution_success', handleExecutionSuccess)
api.removeEventListener('executed', handleExecuted)
api.removeEventListener('executing', handleExecuting)
api.removeEventListener('progress', handleProgress)
@@ -277,7 +279,7 @@ export const useExecutionStore = defineStore('execution', () => {
}
function handleExecutionInterrupted() {
nodeProgressStates.value = {}
resetExecutionState()
}
function handleExecuted(e: CustomEvent<ExecutedWsMessage>) {
@@ -285,6 +287,10 @@ export const useExecutionStore = defineStore('execution', () => {
activePrompt.value.nodes[e.detail.node] = true
}
function handleExecutionSuccess() {
resetExecutionState()
}
function handleExecuting(e: CustomEvent<NodeId | null>): void {
// Clear the current node progress when a new node starts executing
_executingNodeProgress.value = null
@@ -346,6 +352,19 @@ export const useExecutionStore = defineStore('execution', () => {
function handleExecutionError(e: CustomEvent<ExecutionErrorWsMessage>) {
lastExecutionError.value = e.detail
resetExecutionState()
}
/**
* Reset execution-related state after a run completes or is stopped.
*/
function resetExecutionState() {
nodeProgressStates.value = {}
if (activePromptId.value) {
delete queuedPrompts.value[activePromptId.value]
}
activePromptId.value = null
_executingNodeProgress.value = null
}
function getNodeIdIfExecuting(nodeId: string | number) {

View File

@@ -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()
@@ -1651,7 +1759,7 @@ describe('useNodePricing', () => {
const node = createMockNode('GeminiImageNode')
const price = getNodeDisplayPrice(node)
expect(price).toBe('$0.03 per 1K tokens')
expect(price).toBe('~$0.039/Image (1K)')
})
})
@@ -2081,4 +2189,54 @@ describe('useNodePricing', () => {
expect(price).toBe('$0.05-0.15/second')
})
})
describe('dynamic pricing - LtxvApiTextToVideo', () => {
it('should return $0.30 for Pro 1080p 5s', () => {
const { getNodeDisplayPrice } = useNodePricing()
const node = createMockNode('LtxvApiTextToVideo', [
{ name: 'model', value: 'LTX-2 (Pro)' },
{ name: 'duration', value: '5' },
{ name: 'resolution', value: '1920x1080' }
])
const price = getNodeDisplayPrice(node)
expect(price).toBe('$0.30/Run') // 0.06 * 5
})
it('should parse "10s" duration strings', () => {
const { getNodeDisplayPrice } = useNodePricing()
const node = createMockNode('LtxvApiTextToVideo', [
{ name: 'model', value: 'LTX-2 (Fast)' },
{ name: 'duration', value: '10' },
{ name: 'resolution', value: '3840x2160' }
])
const price = getNodeDisplayPrice(node)
expect(price).toBe('$1.60/Run') // 0.16 * 10
})
it('should fall back when a required widget is missing (no resolution)', () => {
const { getNodeDisplayPrice } = useNodePricing()
const node = createMockNode('LtxvApiTextToVideo', [
{ name: 'model', value: 'LTX-2 (Pro)' },
{ name: 'duration', value: '5' }
// missing resolution
])
const price = getNodeDisplayPrice(node)
expect(price).toBe('$0.04-0.24/second')
})
it('should fall back for unknown model', () => {
const { getNodeDisplayPrice } = useNodePricing()
const node = createMockNode('LtxvApiTextToVideo', [
{ name: 'model', value: 'LTX-3 (Pro)' },
{ name: 'duration', value: 5 },
{ name: 'resolution', value: '1920x1080' }
])
const price = getNodeDisplayPrice(node)
expect(price).toBe('$0.04-0.24/second')
})
})
})

View File

@@ -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)
})

View File

@@ -1,11 +1,9 @@
import { describe, expect, test, vi } from 'vitest'
import { registerProxyWidgets } from '@/core/graph/subgraph/proxyWidget'
import {
type LGraphCanvas,
LGraphNode,
type SubgraphNode
} from '@/lib/litegraph/src/litegraph'
import { parseProxyWidgets } from '@/core/schemas/proxyWidget'
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { LGraphCanvas, SubgraphNode } from '@/lib/litegraph/src/litegraph'
import {
createTestSubgraph,
@@ -66,14 +64,19 @@ describe('Subgraph proxyWidgets', () => {
subgraphNode.widgets[1].name
)
})
test('Will not modify existing widgets', () => {
test('Will serialize existing widgets', () => {
const [subgraphNode, innerNodes] = setupSubgraph(1)
innerNodes[0].addWidget('text', 'stringWidget', 'value', () => {})
innerNodes[0].addWidget('text', 'istringWidget', 'value', () => {})
subgraphNode.addWidget('text', 'stringWidget', 'value', () => {})
subgraphNode.properties.proxyWidgets = [['1', 'stringWidget']]
const proxyWidgets = parseProxyWidgets(subgraphNode.properties.proxyWidgets)
proxyWidgets.push(['1', 'istringWidget'])
subgraphNode.properties.proxyWidgets = proxyWidgets
expect(subgraphNode.widgets.length).toBe(2)
subgraphNode.properties.proxyWidgets = []
expect(subgraphNode.widgets.length).toBe(1)
expect(subgraphNode.widgets[0].name).toBe('stringWidget')
subgraphNode.properties.proxyWidgets = [proxyWidgets[1], proxyWidgets[0]]
expect(subgraphNode.widgets[0].name).toBe('1: istringWidget')
})
test('Will mirror changes to value', () => {
const [subgraphNode, innerNodes] = setupSubgraph(1)