mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-03-15 01:48:06 +00:00
Compare commits
11 Commits
devtools/r
...
core/1.28
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
360fd31821 | ||
|
|
7f9c025dc8 | ||
|
|
b503d9a44f | ||
|
|
a040916b5f | ||
|
|
eec2b573a9 | ||
|
|
891ed737b8 | ||
|
|
f36ad8544c | ||
|
|
947ea62b00 | ||
|
|
644ef01d22 | ||
|
|
b00aca1af0 | ||
|
|
565305175c |
@@ -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",
|
||||
|
||||
@@ -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,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] || []
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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')"
|
||||
/>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user