Compare commits
67 Commits
feat/nativ
...
drjkl/syst
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
579f2eab04 | ||
|
|
daf2a583bf | ||
|
|
a9bee7639b | ||
|
|
fefbe7843c | ||
|
|
6445690ed3 | ||
|
|
e20ca37606 | ||
|
|
9e84a3f357 | ||
|
|
603914e78f | ||
|
|
c7797b201e | ||
|
|
612b8a4e92 | ||
|
|
aa68573a6e | ||
|
|
79acf7be5e | ||
|
|
465e4fc9ec | ||
|
|
1aa271538d | ||
|
|
e56b770d77 | ||
|
|
73ec166f36 | ||
|
|
c13b497f8d | ||
|
|
f020b4651f | ||
|
|
7196d92049 | ||
|
|
a1ff977051 | ||
|
|
a896db128f | ||
|
|
e012aee4e2 | ||
|
|
931a102cab | ||
|
|
de96911a09 | ||
|
|
fc7f88fd9e | ||
|
|
2e36f8edf1 | ||
|
|
d7f0e74e6a | ||
|
|
bea38f4ad9 | ||
|
|
d9c5bd3300 | ||
|
|
0af90e77e5 | ||
|
|
2c73a29e47 | ||
|
|
4fac859988 | ||
|
|
4fb0eba5f0 | ||
|
|
166f24d462 | ||
|
|
817cee1806 | ||
|
|
fbc5d96a5c | ||
|
|
56225c8593 | ||
|
|
e471178861 | ||
|
|
4d020233bf | ||
|
|
6cbef8c22d | ||
|
|
5e0f0cfdf9 | ||
|
|
eac6587bc4 | ||
|
|
aa4b6c70c5 | ||
|
|
93fdbab098 | ||
|
|
3471de82fb | ||
|
|
e797fc6c3a | ||
|
|
8623384c79 | ||
|
|
d01424f99e | ||
|
|
0b99394a34 | ||
|
|
2a4102690d | ||
|
|
574c8d37d9 | ||
|
|
7809c2faf8 | ||
|
|
3e462b663a | ||
|
|
54a3b035b8 | ||
|
|
2082f5f43b | ||
|
|
c69f009775 | ||
|
|
749425d1ea | ||
|
|
2157901a74 | ||
|
|
b78de7c41f | ||
|
|
3e8b0f56e9 | ||
|
|
386e2d2796 | ||
|
|
914ceae014 | ||
|
|
c87de750ca | ||
|
|
c0fc78554a | ||
|
|
1f50a78530 | ||
|
|
5098fb8fa7 | ||
|
|
c0b1ed9770 |
|
Before Width: | Height: | Size: 31 KiB After Width: | Height: | Size: 31 KiB |
|
Before Width: | Height: | Size: 43 KiB After Width: | Height: | Size: 44 KiB |
|
Before Width: | Height: | Size: 85 KiB After Width: | Height: | Size: 87 KiB |
|
Before Width: | Height: | Size: 86 KiB After Width: | Height: | Size: 88 KiB |
|
Before Width: | Height: | Size: 3.2 KiB After Width: | Height: | Size: 6.5 KiB |
|
Before Width: | Height: | Size: 6.6 KiB After Width: | Height: | Size: 3.2 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 56 KiB |
@@ -1,23 +1,23 @@
|
||||
{
|
||||
"name": "Comfy",
|
||||
"short_name": "Comfy",
|
||||
"id": "/",
|
||||
"start_url": "/",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/web-app-manifest-192x192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "any"
|
||||
"purpose": "any maskable"
|
||||
},
|
||||
{
|
||||
"src": "/web-app-manifest-512x512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "any"
|
||||
"purpose": "any maskable"
|
||||
}
|
||||
],
|
||||
"theme_color": "#211927",
|
||||
"background_color": "#211927",
|
||||
"display": "standalone"
|
||||
"display": "standalone",
|
||||
"id": "/",
|
||||
"start_url": "/"
|
||||
}
|
||||
|
||||
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 3.9 KiB |
|
Before Width: | Height: | Size: 38 KiB After Width: | Height: | Size: 8.8 KiB |
@@ -21,7 +21,7 @@ export const Default: Story = {
|
||||
args: {
|
||||
title: 'Product',
|
||||
links: [
|
||||
{ label: 'Local', href: '/local' },
|
||||
{ label: 'Desktop', href: '/download' },
|
||||
{ label: 'Cloud', href: '/cloud' },
|
||||
{ label: 'API', href: '/api' },
|
||||
{ label: 'Enterprise', href: '/enterprise' }
|
||||
|
||||
@@ -12,9 +12,9 @@ const meta: Meta<typeof ProductCard> = {
|
||||
})
|
||||
],
|
||||
args: {
|
||||
title: 'Comfy\nLocal',
|
||||
title: 'Comfy\nDesktop',
|
||||
description: 'Run ComfyUI on your own hardware.',
|
||||
cta: 'SEE LOCAL FEATURES',
|
||||
cta: 'SEE DESKTOP FEATURES',
|
||||
href: '#',
|
||||
bg: 'bg-primary-warm-gray'
|
||||
}
|
||||
@@ -31,9 +31,9 @@ export const AllCards: Story = {
|
||||
template: `
|
||||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<ProductCard
|
||||
title="Comfy\nLocal"
|
||||
title="Comfy\nDesktop"
|
||||
description="Run ComfyUI on your own hardware."
|
||||
cta="SEE LOCAL FEATURES"
|
||||
cta="SEE DESKTOP FEATURES"
|
||||
href="#"
|
||||
bg="bg-primary-warm-gray"
|
||||
/>
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { Locale } from '../../../i18n/translations'
|
||||
import { computed } from 'vue'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
|
||||
import type { Platform } from '../../../composables/useDownloadUrl'
|
||||
import {
|
||||
downloadUrls,
|
||||
useDownloadUrl
|
||||
@@ -18,13 +19,15 @@ const { locale = 'en', class: customClass = '' } = defineProps<{
|
||||
|
||||
const { downloadUrl, platform, showFallback } = useDownloadUrl()
|
||||
|
||||
const ICONS = {
|
||||
const label = computed(() => t('download.hero.downloadLocal', locale))
|
||||
|
||||
const ICONS: Record<Platform, string> = {
|
||||
windows: '/icons/os/windows.svg',
|
||||
mac: '/icons/os/apple.svg'
|
||||
} as const
|
||||
}
|
||||
|
||||
interface ButtonSpec {
|
||||
key: string
|
||||
key: Platform
|
||||
href: string
|
||||
icon: string
|
||||
ariaLabel?: string
|
||||
@@ -41,19 +44,18 @@ const buttons = computed<ButtonSpec[]>(() => {
|
||||
]
|
||||
}
|
||||
if (showFallback.value) {
|
||||
const label = t('download.hero.downloadLocal', locale)
|
||||
return [
|
||||
{
|
||||
key: 'windows',
|
||||
href: downloadUrls.windows,
|
||||
icon: ICONS.windows,
|
||||
ariaLabel: `${label} — Windows`
|
||||
ariaLabel: `${label.value} — Windows`
|
||||
},
|
||||
{
|
||||
key: 'mac',
|
||||
href: downloadUrls.macArm,
|
||||
icon: ICONS.mac,
|
||||
ariaLabel: `${label} — macOS`
|
||||
ariaLabel: `${label.value} — macOS`
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -77,11 +79,8 @@ const buttons = computed<ButtonSpec[]>(() => {
|
||||
:src="btn.icon"
|
||||
alt=""
|
||||
class="ppformula-text-center size-5 -translate-y-0.75"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span class="ppformula-text-center">{{
|
||||
t('download.hero.downloadLocal', locale)
|
||||
}}</span>
|
||||
<span class="ppformula-text-center">{{ label }}</span>
|
||||
</span>
|
||||
</BrandButton>
|
||||
</template>
|
||||
|
||||
@@ -7,13 +7,13 @@ export const downloadUrls = {
|
||||
macArm: 'https://download.comfy.org/mac/dmg/arm64'
|
||||
} as const
|
||||
|
||||
type DetectedPlatform = 'windows' | 'mac' | null
|
||||
export type Platform = 'windows' | 'mac'
|
||||
|
||||
function isMobile(ua: string): boolean {
|
||||
return /iphone|ipad|ipod|android/.test(ua)
|
||||
}
|
||||
|
||||
function detectPlatform(ua: string): DetectedPlatform {
|
||||
function detectPlatform(ua: string): Platform | null {
|
||||
if (isMobile(ua)) return null
|
||||
if (ua.includes('win')) return 'windows'
|
||||
if (ua.includes('macintosh') || ua.includes('mac os x')) return 'mac'
|
||||
@@ -23,7 +23,7 @@ function detectPlatform(ua: string): DetectedPlatform {
|
||||
// TODO: Only Windows x64 and macOS arm64 are available today.
|
||||
// When Linux and/or macIntel builds are added, extend detection and URLs here.
|
||||
export function useDownloadUrl() {
|
||||
const platform = ref<DetectedPlatform>(null)
|
||||
const platform = ref<Platform | null>(null)
|
||||
const detected = ref(false)
|
||||
const isMobileUa = ref(false)
|
||||
|
||||
|
||||
@@ -174,16 +174,16 @@ const translations = {
|
||||
'zh-CN': '掌控每个模型、每个节点、每个步骤、每个输出。'
|
||||
},
|
||||
'products.local.title': {
|
||||
en: 'Comfy\nLocal',
|
||||
'zh-CN': 'Comfy\n本地版'
|
||||
en: 'Comfy\nDesktop',
|
||||
'zh-CN': 'Comfy\n桌面版'
|
||||
},
|
||||
'products.local.description': {
|
||||
en: 'Run ComfyUI on your own hardware.',
|
||||
'zh-CN': '在您自己的硬件上运行 ComfyUI。'
|
||||
},
|
||||
'products.local.cta': {
|
||||
en: 'SEE LOCAL FEATURES',
|
||||
'zh-CN': '查看本地版属性'
|
||||
en: 'SEE DESKTOP FEATURES',
|
||||
'zh-CN': '查看桌面版属性'
|
||||
},
|
||||
'products.cloud.title': {
|
||||
en: 'Comfy\nCloud',
|
||||
@@ -1057,18 +1057,18 @@ const translations = {
|
||||
'zh-CN': 'Cloud 与本地运行 ComfyUI 有什么区别?'
|
||||
},
|
||||
'cloud.faq.2.a': {
|
||||
en: 'Cloud runs on powerful remote GPUs and is accessible from any device. Local runs entirely on your computer, giving you full control and offline use.',
|
||||
en: 'Cloud runs on powerful remote GPUs and is accessible from any device. Comfy Desktop runs entirely on your computer, giving you full control and offline use.',
|
||||
'zh-CN':
|
||||
'Cloud 在强大的远程 GPU 上运行,可从任何设备访问。本地版完全在您的电脑上运行,提供完全控制和离线使用。'
|
||||
'Cloud 在强大的远程 GPU 上运行,可从任何设备访问。Comfy 桌面版完全在您的电脑上运行,提供完全控制和离线使用。'
|
||||
},
|
||||
'cloud.faq.3.q': {
|
||||
en: 'Which version should I choose, Comfy Cloud or local ComfyUI (self-hosted)?',
|
||||
'zh-CN': '我应该选择 Comfy Cloud 还是本地 ComfyUI(自托管)?'
|
||||
en: 'Which version should I choose, Comfy Cloud or Comfy Desktop?',
|
||||
'zh-CN': '我应该选择 Comfy Cloud 还是 Comfy 桌面版?'
|
||||
},
|
||||
'cloud.faq.3.a': {
|
||||
en: "Comfy Cloud has zero setup, is easy to share with your team, and is faster than most GPUs you can run on a desktop workstation. You can immediately run the best models and workflows from the community on Comfy Cloud.\nLocal ComfyUI is infinitely customizable, works offline, and you don't need to worry about queue times. However, depending on what you want to create, you might need to have a good GPU and some amount of technical knowledge to install community-created custom nodes.",
|
||||
en: "Comfy Cloud has zero setup, is easy to share with your team, and is faster than most GPUs you can run on a desktop workstation. You can immediately run the best models and workflows from the community on Comfy Cloud.\nComfy Desktop is infinitely customizable, works offline, and you don't need to worry about queue times. However, depending on what you want to create, you might need to have a good GPU and some amount of technical knowledge to install community-created custom nodes.",
|
||||
'zh-CN':
|
||||
'Comfy Cloud 无需任何设置,方便与团队共享,比大多数桌面工作站 GPU 更快。您可以立即在 Comfy Cloud 上运行社区中最好的模型和工作流。\n本地 ComfyUI 可以无限定制,支持离线工作,无需担心排队时间。但根据您的创作需求,可能需要一块好的 GPU 以及一定的技术知识来安装社区创建的自定义节点。'
|
||||
'Comfy Cloud 无需任何设置,方便与团队共享,比大多数桌面工作站 GPU 更快。您可以立即在 Comfy Cloud 上运行社区中最好的模型和工作流。\nComfy 桌面版可以无限定制,支持离线工作,无需担心排队时间。但根据您的创作需求,可能需要一块好的 GPU 以及一定的技术知识来安装社区创建的自定义节点。'
|
||||
},
|
||||
'cloud.faq.4.q': {
|
||||
en: 'Do I need a GPU or a strong computer to use Comfy Cloud?',
|
||||
@@ -1091,9 +1091,9 @@ const translations = {
|
||||
'zh-CN': '我可以在 Comfy Cloud 上使用现有的工作流吗?'
|
||||
},
|
||||
'cloud.faq.6.a': {
|
||||
en: 'Yes, your workflows work across Local and Cloud. Just note that only the most popular custom nodes are supported for now, but more will be added soon.',
|
||||
en: 'Yes, your workflows work across Desktop and Cloud. Just note that only the most popular custom nodes are supported for now, but more will be added soon.',
|
||||
'zh-CN':
|
||||
'可以,您的工作流在本地和云端都能使用。请注意,目前仅支持最热门的自定义节点,但很快会添加更多。'
|
||||
'可以,您的工作流在桌面版和云端都能使用。请注意,目前仅支持最热门的自定义节点,但很快会添加更多。'
|
||||
},
|
||||
'cloud.faq.7.q': {
|
||||
en: 'Are all ComfyUI extensions and custom nodes supported?',
|
||||
@@ -1145,9 +1145,9 @@ const translations = {
|
||||
'zh-CN': '合作伙伴节点积分和我的 Cloud 订阅有什么区别?'
|
||||
},
|
||||
'cloud.faq.12.a': {
|
||||
en: 'Comfy Cloud has a credit system that is used for both Partner nodes (formerly API nodes) and running workflows on cloud.\n1. Partner Nodes (Pay-as-you-go): These nodes (formerly called API nodes) run third-party models via API calls and can be used on both Comfy Cloud and Local/Self-Hosted ComfyUI. Each node has its own usage cost, determined by the API provider, and we directly match their pricing.\n2. Running workflows on cloud: Exclusive to Comfy Cloud, you get a set amount of credits per month, with the amount differing based on your plan. More credits can be topped up anytime. Credits are only used up for GPU time while workflows are running — not while editing or building them. No idle costs, no setup, and no infrastructure to manage.',
|
||||
en: 'Comfy Cloud has a credit system that is used for both Partner nodes (formerly API nodes) and running workflows on cloud.\n1. Partner Nodes (Pay-as-you-go): These nodes (formerly called API nodes) run third-party models via API calls and can be used on both Comfy Cloud and Comfy Desktop. Each node has its own usage cost, determined by the API provider, and we directly match their pricing.\n2. Running workflows on cloud: Exclusive to Comfy Cloud, you get a set amount of credits per month, with the amount differing based on your plan. More credits can be topped up anytime. Credits are only used up for GPU time while workflows are running — not while editing or building them. No idle costs, no setup, and no infrastructure to manage.',
|
||||
'zh-CN':
|
||||
'Comfy Cloud 有一个积分系统,用于合作伙伴节点(原 API 节点)和在云端运行工作流。\n1. 合作伙伴节点(按需付费):这些节点(原称 API 节点)通过 API 调用运行第三方模型,可在 Comfy Cloud 和本地/自托管 ComfyUI 上使用。每个节点有其自身的使用成本,由 API 提供商决定,我们直接匹配他们的定价。\n2. 在云端运行工作流:Comfy Cloud 专属,您每月获得一定数量的积分,数量根据您的计划而不同。积分可随时充值。积分仅在工作流运行时用于 GPU 时间——编辑或构建时不消耗。无闲置成本,无需设置,无需管理基础设施。'
|
||||
'Comfy Cloud 有一个积分系统,用于合作伙伴节点(原 API 节点)和在云端运行工作流。\n1. 合作伙伴节点(按需付费):这些节点(原称 API 节点)通过 API 调用运行第三方模型,可在 Comfy Cloud 和 Comfy 桌面版上使用。每个节点有其自身的使用成本,由 API 提供商决定,我们直接匹配他们的定价。\n2. 在云端运行工作流:Comfy Cloud 专属,您每月获得一定数量的积分,数量根据您的计划而不同。积分可随时充值。积分仅在工作流运行时用于 GPU 时间——编辑或构建时不消耗。无闲置成本,无需设置,无需管理基础设施。'
|
||||
},
|
||||
'cloud.faq.13.q': {
|
||||
en: 'Can I cancel my subscription?',
|
||||
@@ -1411,9 +1411,9 @@ const translations = {
|
||||
'zh-CN': '合作伙伴节点'
|
||||
},
|
||||
'pricing.included.feature8.description': {
|
||||
en: 'Run <strong>proprietary models</strong> through Comfy\'s <a href="https://docs.comfy.org/tutorials/partner-nodes/overview" class="text-primary-comfy-yellow underline">Partner Nodes</a>, such as Nano Banana. The amount of credits each node uses depends on the model and parameters you set in the node, but these credits are the same ones that your monthly subscription comes with. These credits can also be used across Comfy Cloud and local ComfyUI. Read more about Partner nodes <a href="https://docs.comfy.org/tutorials/partner-nodes/overview" class="text-primary-comfy-yellow underline">here</a>.',
|
||||
en: 'Run <strong>proprietary models</strong> through Comfy\'s <a href="https://docs.comfy.org/tutorials/partner-nodes/overview" class="text-primary-comfy-yellow underline">Partner Nodes</a>, such as Nano Banana. The amount of credits each node uses depends on the model and parameters you set in the node, but these credits are the same ones that your monthly subscription comes with. These credits can also be used across Comfy Cloud and Comfy Desktop. Read more about Partner nodes <a href="https://docs.comfy.org/tutorials/partner-nodes/overview" class="text-primary-comfy-yellow underline">here</a>.',
|
||||
'zh-CN':
|
||||
'通过 Comfy 的<a href="https://docs.comfy.org/tutorials/partner-nodes/overview" class="text-primary-comfy-yellow underline">合作伙伴节点</a>运行<strong>专有模型</strong>,如 Nano Banana。每个节点消耗的积分取决于所用模型和参数设置,且与月度订阅积分通用。积分可在 Comfy Cloud 和本地 ComfyUI 间通用。了解更多关于合作伙伴节点的信息请点击<a href="https://docs.comfy.org/tutorials/partner-nodes/overview" class="text-primary-comfy-yellow underline">此处</a>。'
|
||||
'通过 Comfy 的<a href="https://docs.comfy.org/tutorials/partner-nodes/overview" class="text-primary-comfy-yellow underline">合作伙伴节点</a>运行<strong>专有模型</strong>,如 Nano Banana。每个节点消耗的积分取决于所用模型和参数设置,且与月度订阅积分通用。积分可在 Comfy Cloud 和 Comfy 桌面版间通用。了解更多关于合作伙伴节点的信息请点击<a href="https://docs.comfy.org/tutorials/partner-nodes/overview" class="text-primary-comfy-yellow underline">此处</a>。'
|
||||
},
|
||||
'pricing.included.feature9.title': {
|
||||
en: 'Job queue',
|
||||
|
||||
@@ -73,7 +73,7 @@ const websiteJsonLd = {
|
||||
|
||||
<link rel="icon" type="image/png" href="/favicon-96x96.png" sizes="96x96" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<link rel="shortcut icon" href="/favicon.ico" />
|
||||
<link rel="shortcut icon" href="/favicon.ico" sizes="48x48" />
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
|
||||
<link rel="manifest" href="/site.webmanifest" />
|
||||
<meta name="theme-color" content="#211927" />
|
||||
|
||||
@@ -11,9 +11,9 @@ import { t } from '../i18n/translations'
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
title="Download Comfy — Run AI Locally"
|
||||
title="Download Comfy Desktop — Run AI on Your Hardware"
|
||||
description={t('download.hero.subtitle', 'en')}
|
||||
keywords={['comfyui app', 'comfyui desktop app', 'comfy ui application', 'comfyui download', 'download comfyui', 'comfyui windows', 'comfyui mac', 'comfyui linux', 'comfyui local']}
|
||||
keywords={['comfyui app', 'comfyui desktop app', 'comfyui desktop', 'comfy ui application', 'comfyui download', 'download comfyui', 'comfyui windows', 'comfyui mac', 'comfyui linux']}
|
||||
>
|
||||
<CloudBannerSection />
|
||||
<HeroSection client:load />
|
||||
|
||||
@@ -11,7 +11,7 @@ import { t } from '../../i18n/translations'
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
title="下载 — Comfy"
|
||||
title="下载 Comfy 桌面版 — 在您的硬件上运行 AI"
|
||||
description={t('download.hero.subtitle', 'zh-CN')}
|
||||
keywords={['comfyui app', 'comfyui desktop app', 'comfyui download', 'ComfyUI 下载', 'ComfyUI 桌面应用', 'ComfyUI 应用', 'ComfyUI Windows', 'ComfyUI macOS', 'ComfyUI Linux']}
|
||||
>
|
||||
|
||||
@@ -2,6 +2,8 @@ import posthog from 'posthog-js'
|
||||
|
||||
import { createPostHogBeforeSend } from '@comfyorg/shared-frontend-utils/piiUtil'
|
||||
|
||||
import type { Platform } from '@/composables/useDownloadUrl'
|
||||
|
||||
const POSTHOG_KEY =
|
||||
import.meta.env.PUBLIC_POSTHOG_KEY ??
|
||||
'phc_iKfK86id4xVYws9LybMje0h44eGtfwFgRPIBehmy8rO'
|
||||
@@ -39,7 +41,7 @@ export function capturePageview() {
|
||||
}
|
||||
}
|
||||
|
||||
export function captureDownloadClick(platform: string) {
|
||||
export function captureDownloadClick(platform: Platform) {
|
||||
if (!initialized) return
|
||||
try {
|
||||
posthog.capture('website:download_button_clicked', { platform })
|
||||
|
||||
61
browser_tests/assets/missing/node_replacement_same_type.json
Normal file
@@ -0,0 +1,61 @@
|
||||
{
|
||||
"last_node_id": 2,
|
||||
"last_link_id": 0,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 1,
|
||||
"type": "E2E_OldSampler",
|
||||
"pos": [100, 100],
|
||||
"size": [400, 262],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{ "name": "model", "type": "MODEL", "link": null },
|
||||
{ "name": "positive", "type": "CONDITIONING", "link": null },
|
||||
{ "name": "negative", "type": "CONDITIONING", "link": null },
|
||||
{ "name": "latent_image", "type": "LATENT", "link": null }
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "LATENT",
|
||||
"type": "LATENT",
|
||||
"links": [],
|
||||
"slot_index": 0
|
||||
}
|
||||
],
|
||||
"properties": { "Node name for S&R": "E2E_OldSampler" },
|
||||
"widgets_values": [42, 20, 7, "euler", "normal"]
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"type": "E2E_OldSampler",
|
||||
"pos": [520, 100],
|
||||
"size": [400, 262],
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{ "name": "model", "type": "MODEL", "link": null },
|
||||
{ "name": "positive", "type": "CONDITIONING", "link": null },
|
||||
{ "name": "negative", "type": "CONDITIONING", "link": null },
|
||||
{ "name": "latent_image", "type": "LATENT", "link": null }
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "LATENT",
|
||||
"type": "LATENT",
|
||||
"links": [],
|
||||
"slot_index": 0
|
||||
}
|
||||
],
|
||||
"properties": { "Node name for S&R": "E2E_OldSampler" },
|
||||
"widgets_values": [43, 20, 7, "euler", "normal"]
|
||||
}
|
||||
],
|
||||
"links": [],
|
||||
"groups": [],
|
||||
"config": {},
|
||||
"extra": { "ds": { "scale": 1, "offset": [0, 0] } },
|
||||
"version": 0.4
|
||||
}
|
||||
436
browser_tests/assets/subgraphs/link-seed.json
Normal file
@@ -0,0 +1,436 @@
|
||||
{
|
||||
"id": "ca685f6a-7402-42cc-84ae-b659b06cc8b1",
|
||||
"revision": 0,
|
||||
"last_node_id": 10,
|
||||
"last_link_id": 15,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 7,
|
||||
"type": "CLIPTextEncode",
|
||||
"pos": [497.59999999999985, 468.79999999999995],
|
||||
"size": [510.328125, 216.71875],
|
||||
"flags": {},
|
||||
"order": 3,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "clip",
|
||||
"type": "CLIP",
|
||||
"link": 5
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "CONDITIONING",
|
||||
"type": "CONDITIONING",
|
||||
"slot_index": 0,
|
||||
"links": [12]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "CLIPTextEncode"
|
||||
},
|
||||
"widgets_values": ["text, watermark"]
|
||||
},
|
||||
{
|
||||
"id": 6,
|
||||
"type": "CLIPTextEncode",
|
||||
"pos": [499.9999999999999, 225.1999633789062],
|
||||
"size": [507.40625, 197.171875],
|
||||
"flags": {},
|
||||
"order": 2,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "clip",
|
||||
"type": "CLIP",
|
||||
"link": 3
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "CONDITIONING",
|
||||
"type": "CONDITIONING",
|
||||
"slot_index": 0,
|
||||
"links": [11]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "CLIPTextEncode"
|
||||
},
|
||||
"widgets_values": [
|
||||
"beautiful scenery nature glass bottle landscape, , purple galaxy bottle,"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 5,
|
||||
"type": "EmptyLatentImage",
|
||||
"pos": [569.5999633789061, 732.7998535156249],
|
||||
"size": [378, 144],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "LATENT",
|
||||
"type": "LATENT",
|
||||
"slot_index": 0,
|
||||
"links": [13]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "EmptyLatentImage"
|
||||
},
|
||||
"widgets_values": [512, 512, 1]
|
||||
},
|
||||
{
|
||||
"id": 8,
|
||||
"type": "VAEDecode",
|
||||
"pos": [1452.7999999999997, 227.59999999999997],
|
||||
"size": [252, 72],
|
||||
"flags": {},
|
||||
"order": 5,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "samples",
|
||||
"type": "LATENT",
|
||||
"link": 14
|
||||
},
|
||||
{
|
||||
"name": "vae",
|
||||
"type": "VAE",
|
||||
"link": 8
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "IMAGE",
|
||||
"type": "IMAGE",
|
||||
"slot_index": 0,
|
||||
"links": [9]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "VAEDecode"
|
||||
},
|
||||
"widgets_values": []
|
||||
},
|
||||
{
|
||||
"id": 9,
|
||||
"type": "SaveImage",
|
||||
"pos": [1743.1999999999998, 228.79999999999995],
|
||||
"size": [252, 84],
|
||||
"flags": {},
|
||||
"order": 6,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "images",
|
||||
"type": "IMAGE",
|
||||
"link": 9
|
||||
}
|
||||
],
|
||||
"outputs": [],
|
||||
"properties": {},
|
||||
"widgets_values": ["ComfyUI"]
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"type": "CheckpointLoaderSimple",
|
||||
"pos": [33.20003662109363, 570.8],
|
||||
"size": [378, 130.65625],
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "MODEL",
|
||||
"type": "MODEL",
|
||||
"slot_index": 0,
|
||||
"links": [10]
|
||||
},
|
||||
{
|
||||
"name": "CLIP",
|
||||
"type": "CLIP",
|
||||
"slot_index": 1,
|
||||
"links": [3, 5]
|
||||
},
|
||||
{
|
||||
"name": "VAE",
|
||||
"type": "VAE",
|
||||
"slot_index": 2,
|
||||
"links": [8]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "CheckpointLoaderSimple"
|
||||
},
|
||||
"widgets_values": ["v1-5-pruned-emaonly-fp16.safetensors"]
|
||||
},
|
||||
{
|
||||
"id": 10,
|
||||
"type": "5526b801-03ef-4797-9052-cbc171512972",
|
||||
"pos": [1145.277734375, 340.85618896484374],
|
||||
"size": [225, 184],
|
||||
"flags": {},
|
||||
"order": 4,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "model",
|
||||
"type": "MODEL",
|
||||
"link": 10
|
||||
},
|
||||
{
|
||||
"name": "positive",
|
||||
"type": "CONDITIONING",
|
||||
"link": 11
|
||||
},
|
||||
{
|
||||
"name": "negative",
|
||||
"type": "CONDITIONING",
|
||||
"link": 12
|
||||
},
|
||||
{
|
||||
"name": "latent_image",
|
||||
"type": "LATENT",
|
||||
"link": 13
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "LATENT",
|
||||
"type": "LATENT",
|
||||
"links": [14]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"proxyWidgets": [["-1", "seed"]]
|
||||
},
|
||||
"widgets_values": [1]
|
||||
}
|
||||
],
|
||||
"links": [
|
||||
[3, 4, 1, 6, 0, "CLIP"],
|
||||
[5, 4, 1, 7, 0, "CLIP"],
|
||||
[8, 4, 2, 8, 1, "VAE"],
|
||||
[9, 8, 0, 9, 0, "IMAGE"],
|
||||
[10, 4, 0, 10, 0, "MODEL"],
|
||||
[11, 6, 0, 10, 1, "CONDITIONING"],
|
||||
[12, 7, 0, 10, 2, "CONDITIONING"],
|
||||
[13, 5, 0, 10, 3, "LATENT"],
|
||||
[14, 10, 0, 8, 0, "LATENT"]
|
||||
],
|
||||
"groups": [],
|
||||
"definitions": {
|
||||
"subgraphs": [
|
||||
{
|
||||
"id": "5526b801-03ef-4797-9052-cbc171512972",
|
||||
"version": 1,
|
||||
"state": {
|
||||
"lastGroupId": 0,
|
||||
"lastNodeId": 10,
|
||||
"lastLinkId": 15,
|
||||
"lastRerouteId": 0
|
||||
},
|
||||
"revision": 0,
|
||||
"config": {},
|
||||
"name": "New Subgraph",
|
||||
"inputNode": {
|
||||
"id": -10,
|
||||
"bounding": [1031.12, 372.62745605468746, 120, 140]
|
||||
},
|
||||
"outputNode": {
|
||||
"id": -20,
|
||||
"bounding": [1772.7199999999998, 408.62745605468746, 120, 60]
|
||||
},
|
||||
"inputs": [
|
||||
{
|
||||
"id": "2a9d656b-f723-4cdd-897f-894835ce9c50",
|
||||
"name": "model",
|
||||
"type": "MODEL",
|
||||
"linkIds": [1],
|
||||
"localized_name": "model",
|
||||
"pos": [1131.12, 392.62745605468746]
|
||||
},
|
||||
{
|
||||
"id": "ea2d1491-db84-40fa-81e0-48008843ef25",
|
||||
"name": "positive",
|
||||
"type": "CONDITIONING",
|
||||
"linkIds": [4],
|
||||
"localized_name": "positive",
|
||||
"pos": [1131.12, 412.62745605468746]
|
||||
},
|
||||
{
|
||||
"id": "8e021d1a-2032-4dfc-84a3-b649831bd474",
|
||||
"name": "negative",
|
||||
"type": "CONDITIONING",
|
||||
"linkIds": [6],
|
||||
"localized_name": "negative",
|
||||
"pos": [1131.12, 432.62745605468746]
|
||||
},
|
||||
{
|
||||
"id": "977613a0-f164-4004-87a4-1f70ecca7c73",
|
||||
"name": "latent_image",
|
||||
"type": "LATENT",
|
||||
"linkIds": [2],
|
||||
"localized_name": "latent_image",
|
||||
"pos": [1131.12, 452.62745605468746]
|
||||
},
|
||||
{
|
||||
"id": "42ba848c-ab1d-4eab-9f86-3693f407e253",
|
||||
"name": "seed",
|
||||
"type": "INT",
|
||||
"linkIds": [15],
|
||||
"pos": [1131.12, 472.62745605468746]
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"id": "73e0e3cd-90f1-4934-8278-2c5c64fd40f6",
|
||||
"name": "LATENT",
|
||||
"type": "LATENT",
|
||||
"linkIds": [7],
|
||||
"localized_name": "LATENT",
|
||||
"pos": [1792.7199999999998, 428.62745605468746]
|
||||
}
|
||||
],
|
||||
"widgets": [],
|
||||
"nodes": [
|
||||
{
|
||||
"id": 3,
|
||||
"type": "KSampler",
|
||||
"pos": [1247.1199707031249, 272.23994140624995],
|
||||
"size": [453.59375, 380.765625],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"localized_name": "model",
|
||||
"name": "model",
|
||||
"type": "MODEL",
|
||||
"link": 1
|
||||
},
|
||||
{
|
||||
"localized_name": "positive",
|
||||
"name": "positive",
|
||||
"type": "CONDITIONING",
|
||||
"link": 4
|
||||
},
|
||||
{
|
||||
"localized_name": "negative",
|
||||
"name": "negative",
|
||||
"type": "CONDITIONING",
|
||||
"link": 6
|
||||
},
|
||||
{
|
||||
"localized_name": "latent_image",
|
||||
"name": "latent_image",
|
||||
"type": "LATENT",
|
||||
"link": 2
|
||||
},
|
||||
{
|
||||
"localized_name": "seed",
|
||||
"name": "seed",
|
||||
"type": "INT",
|
||||
"widget": {
|
||||
"name": "seed"
|
||||
},
|
||||
"link": 15
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "LATENT",
|
||||
"name": "LATENT",
|
||||
"type": "LATENT",
|
||||
"slot_index": 0,
|
||||
"links": [7]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "KSampler"
|
||||
},
|
||||
"widgets_values": [
|
||||
156680208700286,
|
||||
"increment",
|
||||
20,
|
||||
8,
|
||||
"euler",
|
||||
"normal",
|
||||
1
|
||||
]
|
||||
}
|
||||
],
|
||||
"groups": [],
|
||||
"links": [
|
||||
{
|
||||
"id": 1,
|
||||
"origin_id": -10,
|
||||
"origin_slot": 0,
|
||||
"target_id": 3,
|
||||
"target_slot": 0,
|
||||
"type": "MODEL"
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"origin_id": -10,
|
||||
"origin_slot": 1,
|
||||
"target_id": 3,
|
||||
"target_slot": 1,
|
||||
"type": "CONDITIONING"
|
||||
},
|
||||
{
|
||||
"id": 6,
|
||||
"origin_id": -10,
|
||||
"origin_slot": 2,
|
||||
"target_id": 3,
|
||||
"target_slot": 2,
|
||||
"type": "CONDITIONING"
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"origin_id": -10,
|
||||
"origin_slot": 3,
|
||||
"target_id": 3,
|
||||
"target_slot": 3,
|
||||
"type": "LATENT"
|
||||
},
|
||||
{
|
||||
"id": 7,
|
||||
"origin_id": 3,
|
||||
"origin_slot": 0,
|
||||
"target_id": -20,
|
||||
"target_slot": 0,
|
||||
"type": "LATENT"
|
||||
},
|
||||
{
|
||||
"id": 15,
|
||||
"origin_id": -10,
|
||||
"origin_slot": 4,
|
||||
"target_id": 3,
|
||||
"target_slot": 4,
|
||||
"type": "INT"
|
||||
}
|
||||
],
|
||||
"extra": {
|
||||
"workflowRendererVersion": "Vue"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"config": {},
|
||||
"extra": {
|
||||
"ds": {
|
||||
"offset": [0, 0],
|
||||
"scale": 1
|
||||
},
|
||||
"workflowRendererVersion": "Vue",
|
||||
"frontendVersion": "1.40.0"
|
||||
},
|
||||
"version": 0.4
|
||||
}
|
||||
404
browser_tests/assets/subgraphs/proxy-seed.json
Normal file
@@ -0,0 +1,404 @@
|
||||
{
|
||||
"id": "ca685f6a-7402-42cc-84ae-b659b06cc8b1",
|
||||
"revision": 0,
|
||||
"last_node_id": 10,
|
||||
"last_link_id": 14,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 7,
|
||||
"type": "CLIPTextEncode",
|
||||
"pos": [497.59999999999985, 468.79999999999995],
|
||||
"size": [510.328125, 216.71875],
|
||||
"flags": {},
|
||||
"order": 3,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "clip",
|
||||
"type": "CLIP",
|
||||
"link": 5
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "CONDITIONING",
|
||||
"type": "CONDITIONING",
|
||||
"slot_index": 0,
|
||||
"links": [12]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "CLIPTextEncode"
|
||||
},
|
||||
"widgets_values": ["text, watermark"]
|
||||
},
|
||||
{
|
||||
"id": 6,
|
||||
"type": "CLIPTextEncode",
|
||||
"pos": [499.9999999999999, 225.1999633789062],
|
||||
"size": [507.40625, 197.171875],
|
||||
"flags": {},
|
||||
"order": 2,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "clip",
|
||||
"type": "CLIP",
|
||||
"link": 3
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "CONDITIONING",
|
||||
"type": "CONDITIONING",
|
||||
"slot_index": 0,
|
||||
"links": [11]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "CLIPTextEncode"
|
||||
},
|
||||
"widgets_values": [
|
||||
"beautiful scenery nature glass bottle landscape, , purple galaxy bottle,"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 5,
|
||||
"type": "EmptyLatentImage",
|
||||
"pos": [569.5999633789061, 732.7998535156249],
|
||||
"size": [378, 144],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "LATENT",
|
||||
"type": "LATENT",
|
||||
"slot_index": 0,
|
||||
"links": [13]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "EmptyLatentImage"
|
||||
},
|
||||
"widgets_values": [512, 512, 1]
|
||||
},
|
||||
{
|
||||
"id": 8,
|
||||
"type": "VAEDecode",
|
||||
"pos": [1452.7999999999997, 227.59999999999997],
|
||||
"size": [252, 72],
|
||||
"flags": {},
|
||||
"order": 5,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "samples",
|
||||
"type": "LATENT",
|
||||
"link": 14
|
||||
},
|
||||
{
|
||||
"name": "vae",
|
||||
"type": "VAE",
|
||||
"link": 8
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "IMAGE",
|
||||
"type": "IMAGE",
|
||||
"slot_index": 0,
|
||||
"links": [9]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "VAEDecode"
|
||||
},
|
||||
"widgets_values": []
|
||||
},
|
||||
{
|
||||
"id": 9,
|
||||
"type": "SaveImage",
|
||||
"pos": [1743.1999999999998, 228.79999999999995],
|
||||
"size": [252, 84],
|
||||
"flags": {},
|
||||
"order": 6,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "images",
|
||||
"type": "IMAGE",
|
||||
"link": 9
|
||||
}
|
||||
],
|
||||
"outputs": [],
|
||||
"properties": {},
|
||||
"widgets_values": ["ComfyUI"]
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"type": "CheckpointLoaderSimple",
|
||||
"pos": [33.20003662109363, 570.8],
|
||||
"size": [378, 130.65625],
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "MODEL",
|
||||
"type": "MODEL",
|
||||
"slot_index": 0,
|
||||
"links": [10]
|
||||
},
|
||||
{
|
||||
"name": "CLIP",
|
||||
"type": "CLIP",
|
||||
"slot_index": 1,
|
||||
"links": [3, 5]
|
||||
},
|
||||
{
|
||||
"name": "VAE",
|
||||
"type": "VAE",
|
||||
"slot_index": 2,
|
||||
"links": [8]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "CheckpointLoaderSimple"
|
||||
},
|
||||
"widgets_values": ["v1-5-pruned-emaonly-fp16.safetensors"]
|
||||
},
|
||||
{
|
||||
"id": 10,
|
||||
"type": "5526b801-03ef-4797-9052-cbc171512972",
|
||||
"pos": [1145.277734375, 340.85618896484374],
|
||||
"size": [225, 184],
|
||||
"flags": {},
|
||||
"order": 4,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "model",
|
||||
"type": "MODEL",
|
||||
"link": 10
|
||||
},
|
||||
{
|
||||
"name": "positive",
|
||||
"type": "CONDITIONING",
|
||||
"link": 11
|
||||
},
|
||||
{
|
||||
"name": "negative",
|
||||
"type": "CONDITIONING",
|
||||
"link": 12
|
||||
},
|
||||
{
|
||||
"name": "latent_image",
|
||||
"type": "LATENT",
|
||||
"link": 13
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "LATENT",
|
||||
"type": "LATENT",
|
||||
"links": [14]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"proxyWidgets": [["3", "seed"]]
|
||||
},
|
||||
"widgets_values": []
|
||||
}
|
||||
],
|
||||
"links": [
|
||||
[3, 4, 1, 6, 0, "CLIP"],
|
||||
[5, 4, 1, 7, 0, "CLIP"],
|
||||
[8, 4, 2, 8, 1, "VAE"],
|
||||
[9, 8, 0, 9, 0, "IMAGE"],
|
||||
[10, 4, 0, 10, 0, "MODEL"],
|
||||
[11, 6, 0, 10, 1, "CONDITIONING"],
|
||||
[12, 7, 0, 10, 2, "CONDITIONING"],
|
||||
[13, 5, 0, 10, 3, "LATENT"],
|
||||
[14, 10, 0, 8, 0, "LATENT"]
|
||||
],
|
||||
"groups": [],
|
||||
"definitions": {
|
||||
"subgraphs": [
|
||||
{
|
||||
"id": "5526b801-03ef-4797-9052-cbc171512972",
|
||||
"version": 1,
|
||||
"state": {
|
||||
"lastGroupId": 0,
|
||||
"lastNodeId": 10,
|
||||
"lastLinkId": 14,
|
||||
"lastRerouteId": 0
|
||||
},
|
||||
"revision": 0,
|
||||
"config": {},
|
||||
"name": "New Subgraph",
|
||||
"inputNode": {
|
||||
"id": -10,
|
||||
"bounding": [1031.12, 372.62745605468746, 120, 120]
|
||||
},
|
||||
"outputNode": {
|
||||
"id": -20,
|
||||
"bounding": [1772.7199999999998, 408.62745605468746, 120, 60]
|
||||
},
|
||||
"inputs": [
|
||||
{
|
||||
"id": "2a9d656b-f723-4cdd-897f-894835ce9c50",
|
||||
"name": "model",
|
||||
"type": "MODEL",
|
||||
"linkIds": [1],
|
||||
"localized_name": "model",
|
||||
"pos": [1131.12, 392.62745605468746]
|
||||
},
|
||||
{
|
||||
"id": "ea2d1491-db84-40fa-81e0-48008843ef25",
|
||||
"name": "positive",
|
||||
"type": "CONDITIONING",
|
||||
"linkIds": [4],
|
||||
"localized_name": "positive",
|
||||
"pos": [1131.12, 412.62745605468746]
|
||||
},
|
||||
{
|
||||
"id": "8e021d1a-2032-4dfc-84a3-b649831bd474",
|
||||
"name": "negative",
|
||||
"type": "CONDITIONING",
|
||||
"linkIds": [6],
|
||||
"localized_name": "negative",
|
||||
"pos": [1131.12, 432.62745605468746]
|
||||
},
|
||||
{
|
||||
"id": "977613a0-f164-4004-87a4-1f70ecca7c73",
|
||||
"name": "latent_image",
|
||||
"type": "LATENT",
|
||||
"linkIds": [2],
|
||||
"localized_name": "latent_image",
|
||||
"pos": [1131.12, 452.62745605468746]
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"id": "73e0e3cd-90f1-4934-8278-2c5c64fd40f6",
|
||||
"name": "LATENT",
|
||||
"type": "LATENT",
|
||||
"linkIds": [7],
|
||||
"localized_name": "LATENT",
|
||||
"pos": [1792.7199999999998, 428.62745605468746]
|
||||
}
|
||||
],
|
||||
"widgets": [],
|
||||
"nodes": [
|
||||
{
|
||||
"id": 3,
|
||||
"type": "KSampler",
|
||||
"pos": [1247.1199707031249, 272.23994140624995],
|
||||
"size": [453.59375, 380.765625],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"localized_name": "model",
|
||||
"name": "model",
|
||||
"type": "MODEL",
|
||||
"link": 1
|
||||
},
|
||||
{
|
||||
"localized_name": "positive",
|
||||
"name": "positive",
|
||||
"type": "CONDITIONING",
|
||||
"link": 4
|
||||
},
|
||||
{
|
||||
"localized_name": "negative",
|
||||
"name": "negative",
|
||||
"type": "CONDITIONING",
|
||||
"link": 6
|
||||
},
|
||||
{
|
||||
"localized_name": "latent_image",
|
||||
"name": "latent_image",
|
||||
"type": "LATENT",
|
||||
"link": 2
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "LATENT",
|
||||
"name": "LATENT",
|
||||
"type": "LATENT",
|
||||
"slot_index": 0,
|
||||
"links": [7]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "KSampler"
|
||||
},
|
||||
"widgets_values": [1, "increment", 20, 8, "euler", "normal", 1]
|
||||
}
|
||||
],
|
||||
"groups": [],
|
||||
"links": [
|
||||
{
|
||||
"id": 1,
|
||||
"origin_id": -10,
|
||||
"origin_slot": 0,
|
||||
"target_id": 3,
|
||||
"target_slot": 0,
|
||||
"type": "MODEL"
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"origin_id": -10,
|
||||
"origin_slot": 1,
|
||||
"target_id": 3,
|
||||
"target_slot": 1,
|
||||
"type": "CONDITIONING"
|
||||
},
|
||||
{
|
||||
"id": 6,
|
||||
"origin_id": -10,
|
||||
"origin_slot": 2,
|
||||
"target_id": 3,
|
||||
"target_slot": 2,
|
||||
"type": "CONDITIONING"
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"origin_id": -10,
|
||||
"origin_slot": 3,
|
||||
"target_id": 3,
|
||||
"target_slot": 3,
|
||||
"type": "LATENT"
|
||||
},
|
||||
{
|
||||
"id": 7,
|
||||
"origin_id": 3,
|
||||
"origin_slot": 0,
|
||||
"target_id": -20,
|
||||
"target_slot": 0,
|
||||
"type": "LATENT"
|
||||
}
|
||||
],
|
||||
"extra": {
|
||||
"workflowRendererVersion": "Vue"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"config": {},
|
||||
"extra": {
|
||||
"ds": {
|
||||
"offset": [0, 0],
|
||||
"scale": 1
|
||||
},
|
||||
"workflowRendererVersion": "Vue",
|
||||
"frontendVersion": "1.40.0"
|
||||
},
|
||||
"version": 0.4
|
||||
}
|
||||
439
browser_tests/assets/subgraphs/zit-seed.json
Normal file
@@ -0,0 +1,439 @@
|
||||
{
|
||||
"id": "ca685f6a-7402-42cc-84ae-b659b06cc8b1",
|
||||
"revision": 0,
|
||||
"last_node_id": 10,
|
||||
"last_link_id": 15,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 7,
|
||||
"type": "CLIPTextEncode",
|
||||
"pos": [498.26665242513025, 471.46666463216144],
|
||||
"size": [510.328125, 252.71875],
|
||||
"flags": {},
|
||||
"order": 3,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "clip",
|
||||
"type": "CLIP",
|
||||
"link": 5
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "CONDITIONING",
|
||||
"type": "CONDITIONING",
|
||||
"slot_index": 0,
|
||||
"links": [12]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "CLIPTextEncode"
|
||||
},
|
||||
"widgets_values": ["text, watermark"]
|
||||
},
|
||||
{
|
||||
"id": 6,
|
||||
"type": "CLIPTextEncode",
|
||||
"pos": [500.66667683919275, 227.8666280110677],
|
||||
"size": [507.40625, 233.171875],
|
||||
"flags": {},
|
||||
"order": 2,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "clip",
|
||||
"type": "CLIP",
|
||||
"link": 3
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "CONDITIONING",
|
||||
"type": "CONDITIONING",
|
||||
"slot_index": 0,
|
||||
"links": [11]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "CLIPTextEncode"
|
||||
},
|
||||
"widgets_values": [
|
||||
"beautiful scenery nature glass bottle landscape, , purple galaxy bottle,"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 5,
|
||||
"type": "EmptyLatentImage",
|
||||
"pos": [570.266591389974, 735.4665120442708],
|
||||
"size": [378, 216],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "LATENT",
|
||||
"type": "LATENT",
|
||||
"slot_index": 0,
|
||||
"links": [13]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "EmptyLatentImage"
|
||||
},
|
||||
"widgets_values": [512, 512, 1]
|
||||
},
|
||||
{
|
||||
"id": 8,
|
||||
"type": "VAEDecode",
|
||||
"pos": [1453.466512044271, 230.26666768391925],
|
||||
"size": [252, 138],
|
||||
"flags": {},
|
||||
"order": 5,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "samples",
|
||||
"type": "LATENT",
|
||||
"link": 14
|
||||
},
|
||||
{
|
||||
"name": "vae",
|
||||
"type": "VAE",
|
||||
"link": 8
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "IMAGE",
|
||||
"type": "IMAGE",
|
||||
"slot_index": 0,
|
||||
"links": [9]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "VAEDecode"
|
||||
},
|
||||
"widgets_values": []
|
||||
},
|
||||
{
|
||||
"id": 9,
|
||||
"type": "SaveImage",
|
||||
"pos": [1743.866658528646, 231.46666463216144],
|
||||
"size": [252, 148],
|
||||
"flags": {},
|
||||
"order": 6,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "images",
|
||||
"type": "IMAGE",
|
||||
"link": 9
|
||||
}
|
||||
],
|
||||
"outputs": [],
|
||||
"properties": {},
|
||||
"widgets_values": ["ComfyUI"]
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"type": "CheckpointLoaderSimple",
|
||||
"pos": [33.866689046223996, 573.4666951497395],
|
||||
"size": [378, 196],
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "MODEL",
|
||||
"type": "MODEL",
|
||||
"slot_index": 0,
|
||||
"links": [10]
|
||||
},
|
||||
{
|
||||
"name": "CLIP",
|
||||
"type": "CLIP",
|
||||
"slot_index": 1,
|
||||
"links": [3, 5]
|
||||
},
|
||||
{
|
||||
"name": "VAE",
|
||||
"type": "VAE",
|
||||
"slot_index": 2,
|
||||
"links": [8]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "CheckpointLoaderSimple"
|
||||
},
|
||||
"widgets_values": ["v1-5-pruned-emaonly-fp16.safetensors"]
|
||||
},
|
||||
{
|
||||
"id": 10,
|
||||
"type": "5526b801-03ef-4797-9052-cbc171512972",
|
||||
"pos": [1145.9444173177085, 343.52284749348956],
|
||||
"size": [225, 220],
|
||||
"flags": {},
|
||||
"order": 4,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "model",
|
||||
"type": "MODEL",
|
||||
"link": 10
|
||||
},
|
||||
{
|
||||
"name": "positive",
|
||||
"type": "CONDITIONING",
|
||||
"link": 11
|
||||
},
|
||||
{
|
||||
"name": "negative",
|
||||
"type": "CONDITIONING",
|
||||
"link": 12
|
||||
},
|
||||
{
|
||||
"name": "latent_image",
|
||||
"type": "LATENT",
|
||||
"link": 13
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "LATENT",
|
||||
"type": "LATENT",
|
||||
"links": [14]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"proxyWidgets": [
|
||||
["-1", "seed"],
|
||||
["3", "control_after_generate"]
|
||||
]
|
||||
},
|
||||
"widgets_values": [1]
|
||||
}
|
||||
],
|
||||
"links": [
|
||||
[3, 4, 1, 6, 0, "CLIP"],
|
||||
[5, 4, 1, 7, 0, "CLIP"],
|
||||
[8, 4, 2, 8, 1, "VAE"],
|
||||
[9, 8, 0, 9, 0, "IMAGE"],
|
||||
[10, 4, 0, 10, 0, "MODEL"],
|
||||
[11, 6, 0, 10, 1, "CONDITIONING"],
|
||||
[12, 7, 0, 10, 2, "CONDITIONING"],
|
||||
[13, 5, 0, 10, 3, "LATENT"],
|
||||
[14, 10, 0, 8, 0, "LATENT"]
|
||||
],
|
||||
"groups": [],
|
||||
"definitions": {
|
||||
"subgraphs": [
|
||||
{
|
||||
"id": "5526b801-03ef-4797-9052-cbc171512972",
|
||||
"version": 1,
|
||||
"state": {
|
||||
"lastGroupId": 0,
|
||||
"lastNodeId": 10,
|
||||
"lastLinkId": 15,
|
||||
"lastRerouteId": 0
|
||||
},
|
||||
"revision": 0,
|
||||
"config": {},
|
||||
"name": "New Subgraph",
|
||||
"inputNode": {
|
||||
"id": -10,
|
||||
"bounding": [1031.12, 372.62745605468746, 120, 140]
|
||||
},
|
||||
"outputNode": {
|
||||
"id": -20,
|
||||
"bounding": [1772.7199999999998, 408.62745605468746, 120, 60]
|
||||
},
|
||||
"inputs": [
|
||||
{
|
||||
"id": "2a9d656b-f723-4cdd-897f-894835ce9c50",
|
||||
"name": "model",
|
||||
"type": "MODEL",
|
||||
"linkIds": [1],
|
||||
"localized_name": "model",
|
||||
"pos": [1131.12, 392.62745605468746]
|
||||
},
|
||||
{
|
||||
"id": "ea2d1491-db84-40fa-81e0-48008843ef25",
|
||||
"name": "positive",
|
||||
"type": "CONDITIONING",
|
||||
"linkIds": [4],
|
||||
"localized_name": "positive",
|
||||
"pos": [1131.12, 412.62745605468746]
|
||||
},
|
||||
{
|
||||
"id": "8e021d1a-2032-4dfc-84a3-b649831bd474",
|
||||
"name": "negative",
|
||||
"type": "CONDITIONING",
|
||||
"linkIds": [6],
|
||||
"localized_name": "negative",
|
||||
"pos": [1131.12, 432.62745605468746]
|
||||
},
|
||||
{
|
||||
"id": "977613a0-f164-4004-87a4-1f70ecca7c73",
|
||||
"name": "latent_image",
|
||||
"type": "LATENT",
|
||||
"linkIds": [2],
|
||||
"localized_name": "latent_image",
|
||||
"pos": [1131.12, 452.62745605468746]
|
||||
},
|
||||
{
|
||||
"id": "42ba848c-ab1d-4eab-9f86-3693f407e253",
|
||||
"name": "seed",
|
||||
"type": "INT",
|
||||
"linkIds": [15],
|
||||
"pos": [1131.12, 472.62745605468746]
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"id": "73e0e3cd-90f1-4934-8278-2c5c64fd40f6",
|
||||
"name": "LATENT",
|
||||
"type": "LATENT",
|
||||
"linkIds": [7],
|
||||
"localized_name": "LATENT",
|
||||
"pos": [1792.7199999999998, 428.62745605468746]
|
||||
}
|
||||
],
|
||||
"widgets": [],
|
||||
"nodes": [
|
||||
{
|
||||
"id": 3,
|
||||
"type": "KSampler",
|
||||
"pos": [1247.1199707031249, 272.23994140624995],
|
||||
"size": [453.59375, 380.765625],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"localized_name": "model",
|
||||
"name": "model",
|
||||
"type": "MODEL",
|
||||
"link": 1
|
||||
},
|
||||
{
|
||||
"localized_name": "positive",
|
||||
"name": "positive",
|
||||
"type": "CONDITIONING",
|
||||
"link": 4
|
||||
},
|
||||
{
|
||||
"localized_name": "negative",
|
||||
"name": "negative",
|
||||
"type": "CONDITIONING",
|
||||
"link": 6
|
||||
},
|
||||
{
|
||||
"localized_name": "latent_image",
|
||||
"name": "latent_image",
|
||||
"type": "LATENT",
|
||||
"link": 2
|
||||
},
|
||||
{
|
||||
"localized_name": "seed",
|
||||
"name": "seed",
|
||||
"type": "INT",
|
||||
"widget": {
|
||||
"name": "seed"
|
||||
},
|
||||
"link": 15
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "LATENT",
|
||||
"name": "LATENT",
|
||||
"type": "LATENT",
|
||||
"slot_index": 0,
|
||||
"links": [7]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "KSampler"
|
||||
},
|
||||
"widgets_values": [
|
||||
156680208700286,
|
||||
"increment",
|
||||
20,
|
||||
8,
|
||||
"euler",
|
||||
"normal",
|
||||
1
|
||||
]
|
||||
}
|
||||
],
|
||||
"groups": [],
|
||||
"links": [
|
||||
{
|
||||
"id": 1,
|
||||
"origin_id": -10,
|
||||
"origin_slot": 0,
|
||||
"target_id": 3,
|
||||
"target_slot": 0,
|
||||
"type": "MODEL"
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"origin_id": -10,
|
||||
"origin_slot": 1,
|
||||
"target_id": 3,
|
||||
"target_slot": 1,
|
||||
"type": "CONDITIONING"
|
||||
},
|
||||
{
|
||||
"id": 6,
|
||||
"origin_id": -10,
|
||||
"origin_slot": 2,
|
||||
"target_id": 3,
|
||||
"target_slot": 2,
|
||||
"type": "CONDITIONING"
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"origin_id": -10,
|
||||
"origin_slot": 3,
|
||||
"target_id": 3,
|
||||
"target_slot": 3,
|
||||
"type": "LATENT"
|
||||
},
|
||||
{
|
||||
"id": 7,
|
||||
"origin_id": 3,
|
||||
"origin_slot": 0,
|
||||
"target_id": -20,
|
||||
"target_slot": 0,
|
||||
"type": "LATENT"
|
||||
},
|
||||
{
|
||||
"id": 15,
|
||||
"origin_id": -10,
|
||||
"origin_slot": 4,
|
||||
"target_id": 3,
|
||||
"target_slot": 4,
|
||||
"type": "INT"
|
||||
}
|
||||
],
|
||||
"extra": {
|
||||
"workflowRendererVersion": "Vue"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"config": {},
|
||||
"extra": {
|
||||
"ds": {
|
||||
"offset": [0, 0],
|
||||
"scale": 1
|
||||
},
|
||||
"workflowRendererVersion": "Vue",
|
||||
"frontendVersion": "1.35.0"
|
||||
},
|
||||
"version": 0.4
|
||||
}
|
||||
@@ -70,6 +70,7 @@ export const TestIds = {
|
||||
missingModelImportUnsupported: 'missing-model-import-unsupported',
|
||||
missingMediaGroup: 'error-group-missing-media',
|
||||
swapNodesGroup: 'error-group-swap-nodes',
|
||||
swapNodeGroupCount: 'swap-node-group-count',
|
||||
missingMediaRow: 'missing-media-row',
|
||||
missingMediaLocateButton: 'missing-media-locate-button',
|
||||
publishTabPanel: 'publish-tab-panel',
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import type { NodeProperty } from '@/lib/litegraph/src/LGraphNode'
|
||||
|
||||
import type { PromotedWidgetSource } from '@/core/graph/subgraph/promotedWidgetTypes'
|
||||
import { parsePreviewExposures } from '@/core/schemas/previewExposureSchema'
|
||||
import type { PreviewExposure } from '@/core/schemas/previewExposureSchema'
|
||||
|
||||
@@ -8,8 +7,13 @@ import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
|
||||
export type PromotedWidgetEntry = [string, string]
|
||||
|
||||
interface ResolvedWidgetSource {
|
||||
sourceNodeId: string
|
||||
sourceWidgetName: string
|
||||
}
|
||||
|
||||
function widgetSourceToEntry(
|
||||
source: PromotedWidgetSource
|
||||
source: ResolvedWidgetSource
|
||||
): PromotedWidgetEntry {
|
||||
return [source.sourceNodeId, source.sourceWidgetName]
|
||||
}
|
||||
@@ -20,23 +24,22 @@ function previewExposureToEntry(
|
||||
return [exposure.sourceNodeId, exposure.sourcePreviewName]
|
||||
}
|
||||
|
||||
function isPromotedWidgetSource(value: unknown): value is PromotedWidgetSource {
|
||||
return (
|
||||
!!value &&
|
||||
typeof value === 'object' &&
|
||||
'sourceNodeId' in value &&
|
||||
'sourceWidgetName' in value &&
|
||||
typeof value.sourceNodeId === 'string' &&
|
||||
typeof value.sourceWidgetName === 'string'
|
||||
)
|
||||
}
|
||||
|
||||
function isNodeProperty(value: unknown): value is NodeProperty {
|
||||
if (value === null || value === undefined) return false
|
||||
const t = typeof value
|
||||
return t === 'string' || t === 'number' || t === 'boolean' || t === 'object'
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the promoted widgets of a subgraph host node from the live graph.
|
||||
*
|
||||
* Promoted widgets are now store-backed: a host input is promoted iff it
|
||||
* carries a `widgetId`, and its interior source identity is resolved on demand
|
||||
* by walking the subgraph input link (mirroring `resolveSubgraphInputTarget`).
|
||||
* This intentionally avoids the removed `widget.sourceNodeId`/`sourceWidgetName`
|
||||
* denormalization, so the helper reflects the real projection rather than a
|
||||
* deleted widget-object contract.
|
||||
*/
|
||||
export async function getPromotedWidgets(
|
||||
comfyPage: ComfyPage,
|
||||
nodeId: string
|
||||
@@ -44,21 +47,49 @@ export async function getPromotedWidgets(
|
||||
const { widgetSources, previewExposures } = await comfyPage.page.evaluate(
|
||||
(id) => {
|
||||
const node = window.app!.canvas.graph!.getNodeById(id)
|
||||
const widgetSources = (node?.widgets ?? []).flatMap((widget) => {
|
||||
if (!('sourceNodeId' in widget) || !('sourceWidgetName' in widget))
|
||||
return []
|
||||
return [
|
||||
{
|
||||
sourceNodeId: widget.sourceNodeId,
|
||||
sourceWidgetName: widget.sourceWidgetName
|
||||
const previewExposures = node?.serialize()?.properties?.previewExposures
|
||||
if (!node?.isSubgraphNode?.())
|
||||
return { widgetSources: [], previewExposures }
|
||||
|
||||
const { subgraph } = node
|
||||
const resolveSource = (
|
||||
inputName: string
|
||||
): ResolvedWidgetSource | undefined => {
|
||||
const inputSlot = subgraph.inputNode.slots.find(
|
||||
(slot) => slot.name === inputName
|
||||
)
|
||||
if (!inputSlot) return undefined
|
||||
for (const linkId of inputSlot.linkIds) {
|
||||
const link = subgraph.getLink(linkId)
|
||||
if (!link) continue
|
||||
const { inputNode } = link.resolve(subgraph)
|
||||
if (!inputNode || !Array.isArray(inputNode.inputs)) continue
|
||||
const targetInput = inputNode.inputs.find(
|
||||
(entry) => entry.link === linkId
|
||||
)
|
||||
if (!targetInput) continue
|
||||
if (inputNode.isSubgraphNode?.()) {
|
||||
return {
|
||||
sourceNodeId: String(inputNode.id),
|
||||
sourceWidgetName: targetInput.name
|
||||
}
|
||||
}
|
||||
]
|
||||
})
|
||||
const serializedNode = node?.serialize()
|
||||
return {
|
||||
widgetSources,
|
||||
previewExposures: serializedNode?.properties?.previewExposures
|
||||
const widget = inputNode.getWidgetFromSlot(targetInput)
|
||||
if (!widget) continue
|
||||
return {
|
||||
sourceNodeId: String(inputNode.id),
|
||||
sourceWidgetName: widget.name
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
const widgetSources = (node.inputs ?? []).flatMap((input) => {
|
||||
if (!input.widgetId) return []
|
||||
const source = resolveSource(input.name)
|
||||
return source ? [source] : []
|
||||
})
|
||||
return { widgetSources, previewExposures }
|
||||
},
|
||||
nodeId
|
||||
)
|
||||
@@ -67,7 +98,7 @@ export async function getPromotedWidgets(
|
||||
? parsePreviewExposures(previewExposures)
|
||||
: []
|
||||
return [
|
||||
...widgetSources.filter(isPromotedWidgetSource).map(widgetSourceToEntry),
|
||||
...widgetSources.map(widgetSourceToEntry),
|
||||
...exposures.map(previewExposureToEntry)
|
||||
]
|
||||
}
|
||||
|
||||
@@ -48,6 +48,36 @@ test.describe('Node replacement', { tag: ['@node', '@ui'] }, () => {
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test('Shows direct row label and locate action for a single replacement group', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const swapGroup = getSwapNodesGroup(comfyPage.page)
|
||||
const rowLabel = swapGroup.getByRole('button', {
|
||||
name: 'E2E_OldSampler',
|
||||
exact: true
|
||||
})
|
||||
|
||||
await expect(rowLabel).toBeVisible()
|
||||
await expect(
|
||||
swapGroup.getByRole('button', {
|
||||
name: 'Locate node on canvas',
|
||||
exact: true
|
||||
})
|
||||
).toBeVisible()
|
||||
await expect(
|
||||
swapGroup.getByTestId(TestIds.dialogs.swapNodeGroupCount)
|
||||
).toHaveCount(0)
|
||||
|
||||
await comfyPage.canvasOps.pan({ x: -800, y: -800 })
|
||||
const offsetBeforeLocate = await comfyPage.canvasOps.getOffset()
|
||||
|
||||
await rowLabel.click()
|
||||
|
||||
await expect
|
||||
.poll(() => comfyPage.canvasOps.getOffset())
|
||||
.not.toEqual(offsetBeforeLocate)
|
||||
})
|
||||
|
||||
test('Replace Node replaces a single group in-place', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
@@ -116,6 +146,55 @@ test.describe('Node replacement', { tag: ['@node', '@ui'] }, () => {
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Same-type replacement group', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.VueNodes.Enabled',
|
||||
mode.vueNodesEnabled
|
||||
)
|
||||
await setupNodeReplacement(comfyPage, mockNodeReplacementsSingle)
|
||||
await loadWorkflowAndOpenErrorsTab(
|
||||
comfyPage,
|
||||
'missing/node_replacement_same_type'
|
||||
)
|
||||
})
|
||||
|
||||
test('Groups same-type replacement rows behind the title disclosure', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const swapGroup = getSwapNodesGroup(comfyPage.page)
|
||||
const countBadge = swapGroup.getByTestId(
|
||||
TestIds.dialogs.swapNodeGroupCount
|
||||
)
|
||||
const childRows = swapGroup.getByRole('listitem')
|
||||
const expandButton = swapGroup.getByRole('button', {
|
||||
name: 'Expand E2E_OldSampler',
|
||||
exact: true
|
||||
})
|
||||
|
||||
await expect(expandButton).toBeVisible()
|
||||
await expect(countBadge).toHaveText('2')
|
||||
await expect(childRows).toHaveCount(0)
|
||||
|
||||
await expandButton.click()
|
||||
await expect(childRows).toHaveCount(2)
|
||||
await expect(
|
||||
swapGroup.getByRole('button', {
|
||||
name: 'E2E_OldSampler',
|
||||
exact: true
|
||||
})
|
||||
).toHaveCount(2)
|
||||
|
||||
await swapGroup
|
||||
.getByRole('button', {
|
||||
name: 'Collapse E2E_OldSampler',
|
||||
exact: true
|
||||
})
|
||||
.click()
|
||||
await expect(childRows).toHaveCount(0)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Multi-type replacement', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting(
|
||||
|
||||
@@ -217,6 +217,14 @@ test.describe('Nested Subgraphs', { tag: ['@subgraph'] }, () => {
|
||||
}
|
||||
})
|
||||
|
||||
// Each promoted input must surface its own source value, so assert the
|
||||
// name->value mapping rather than the first textbox in DOM order.
|
||||
const EXPECTED_VALUE_BY_INPUT: Record<string, RegExp> = {
|
||||
value: /Inner 1/,
|
||||
value_1: /Inner 2/,
|
||||
value_1_1: /Inner 3/
|
||||
}
|
||||
|
||||
test('Promoted widgets from inner SubgraphNode are visible with correct values', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
@@ -228,11 +236,16 @@ test.describe('Nested Subgraphs', { tag: ['@subgraph'] }, () => {
|
||||
const widgets = outerNode.getByTestId(TestIds.widgets.widget)
|
||||
await comfyExpect(widgets).toHaveCount(4)
|
||||
|
||||
const valueWidget = outerNode
|
||||
.getByRole('textbox', { name: 'value' })
|
||||
.first()
|
||||
await comfyExpect(valueWidget).toBeVisible()
|
||||
await comfyExpect(valueWidget).toHaveValue(/Inner 1/)
|
||||
for (const [inputName, expectedValue] of Object.entries(
|
||||
EXPECTED_VALUE_BY_INPUT
|
||||
)) {
|
||||
const valueWidget = outerNode.getByRole('textbox', {
|
||||
name: inputName,
|
||||
exact: true
|
||||
})
|
||||
await comfyExpect(valueWidget).toBeVisible()
|
||||
await comfyExpect(valueWidget).toHaveValue(expectedValue)
|
||||
}
|
||||
})
|
||||
|
||||
test('Promoted widgets from inner SubgraphNode carry correct source identity', async ({
|
||||
@@ -271,11 +284,16 @@ test.describe('Nested Subgraphs', { tag: ['@subgraph'] }, () => {
|
||||
const widgetsAfter = outerNodeAfter.getByTestId(TestIds.widgets.widget)
|
||||
await comfyExpect(widgetsAfter).toHaveCount(initialCount)
|
||||
|
||||
const valueWidget = outerNodeAfter
|
||||
.getByRole('textbox', { name: 'value' })
|
||||
.first()
|
||||
await comfyExpect(valueWidget).toBeVisible()
|
||||
await comfyExpect(valueWidget).toHaveValue(/Inner 1/)
|
||||
for (const [inputName, expectedValue] of Object.entries(
|
||||
EXPECTED_VALUE_BY_INPUT
|
||||
)) {
|
||||
const valueWidget = outerNodeAfter.getByRole('textbox', {
|
||||
name: inputName,
|
||||
exact: true
|
||||
})
|
||||
await comfyExpect(valueWidget).toBeVisible()
|
||||
await comfyExpect(valueWidget).toHaveValue(expectedValue)
|
||||
}
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
@@ -53,6 +53,22 @@ test.describe(
|
||||
await SubgraphHelper.expectWidgetBelowHeader(nodeLocator, seedWidget)
|
||||
})
|
||||
|
||||
test('Promoted textarea materializes once when a node is converted to a subgraph', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow('default')
|
||||
|
||||
const clipNode = await comfyPage.nodeOps.getNodeRefById('6')
|
||||
await clipNode.click('title')
|
||||
const subgraphNode = await clipNode.convertToSubgraph()
|
||||
|
||||
const promotedTextarea = comfyPage.vueNodes
|
||||
.getNodeLocator(String(subgraphNode.id))
|
||||
.getByRole('textbox', { name: 'text', exact: true })
|
||||
await expect(promotedTextarea).toHaveCount(1)
|
||||
await expect(promotedTextarea).toBeVisible()
|
||||
})
|
||||
|
||||
test.describe(
|
||||
'Promoted Text Widget Lifecycle',
|
||||
{ tag: ['@vue-nodes'] },
|
||||
|
||||
50
browser_tests/tests/subgraph/subgraphSeed.spec.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { mergeTests } from '@playwright/test'
|
||||
|
||||
import {
|
||||
comfyExpect as expect,
|
||||
comfyPageFixture as test
|
||||
} from '@e2e/fixtures/ComfyPage'
|
||||
import { ExecutionHelper } from '@e2e/fixtures/helpers/ExecutionHelper'
|
||||
import { webSocketFixture } from '@e2e/fixtures/ws'
|
||||
|
||||
const wstest = mergeTests(test, webSocketFixture)
|
||||
|
||||
wstest(
|
||||
'Seed handling',
|
||||
{ tag: '@vue-nodes' },
|
||||
async ({ comfyPage, getWebSocket }) => {
|
||||
const execution = new ExecutionHelper(comfyPage, await getWebSocket())
|
||||
|
||||
async function verifySeedControl(initializeState = true) {
|
||||
const seedWidget = comfyPage.vueNodes.getWidgetByName('', 'seed')
|
||||
const { input, valueControl } =
|
||||
comfyPage.vueNodes.getInputNumberControls(seedWidget)
|
||||
|
||||
if (initializeState) {
|
||||
await input.fill('1')
|
||||
await valueControl.click()
|
||||
await comfyPage.page.getByRole('radio', { name: 'increment' }).click()
|
||||
await comfyPage.keyboard.press('Escape')
|
||||
}
|
||||
|
||||
await execution.run()
|
||||
await expect.soft(input).toHaveValue('2')
|
||||
}
|
||||
|
||||
await test.step('seed updates on generation', async () => {
|
||||
await verifySeedControl()
|
||||
})
|
||||
|
||||
await test.step('subgraph seed updates on generation', async () => {
|
||||
await comfyPage.subgraph.convertDefaultKSamplerToSubgraph()
|
||||
await verifySeedControl()
|
||||
})
|
||||
|
||||
for (const w of ['link-seed', 'proxy-seed', 'zit-seed']) {
|
||||
await test.step(`seed updates for old workflow: ${w}`, async () => {
|
||||
await comfyPage.workflow.loadWorkflow('subgraphs/' + w)
|
||||
await verifySeedControl(false)
|
||||
})
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -484,6 +484,14 @@ test.describe('Subgraph Serialization', { tag: ['@subgraph'] }, () => {
|
||||
'subgraphs/subgraph-with-promoted-text-widget'
|
||||
)
|
||||
|
||||
// Assert against the visible textbox the user sees, not the internal
|
||||
// graph/widget projection.
|
||||
const promotedTextWidgets = comfyPage.page.getByRole('textbox', {
|
||||
name: 'text',
|
||||
exact: true
|
||||
})
|
||||
await comfyExpect(promotedTextWidgets).toHaveCount(1)
|
||||
|
||||
const originalNode = await comfyPage.nodeOps.getNodeRefById('11')
|
||||
const originalPos = await originalNode.getPosition()
|
||||
|
||||
@@ -497,31 +505,58 @@ test.describe('Subgraph Serialization', { tag: ['@subgraph'] }, () => {
|
||||
await comfyPage.page.keyboard.up('Alt')
|
||||
}
|
||||
|
||||
async function collectSubgraphNodeIds() {
|
||||
return comfyPage.page.evaluate(() => {
|
||||
const graph = window.app!.canvas.graph!
|
||||
return graph.nodes
|
||||
.filter(
|
||||
(n) =>
|
||||
typeof n.isSubgraphNode === 'function' && n.isSubgraphNode()
|
||||
)
|
||||
.map((n) => String(n.id))
|
||||
})
|
||||
}
|
||||
|
||||
await expect
|
||||
.poll(async () => (await collectSubgraphNodeIds()).length)
|
||||
.toBeGreaterThan(1)
|
||||
|
||||
const subgraphNodeIds = await collectSubgraphNodeIds()
|
||||
for (const nodeId of subgraphNodeIds) {
|
||||
const promotedWidgets = await getPromotedWidgets(comfyPage, nodeId)
|
||||
expect(promotedWidgets.length).toBeGreaterThan(0)
|
||||
expect(
|
||||
promotedWidgets.some(([, widgetName]) => widgetName === 'text')
|
||||
).toBe(true)
|
||||
}
|
||||
await comfyExpect(promotedTextWidgets).toHaveCount(2)
|
||||
})
|
||||
|
||||
test(
|
||||
'Cloning a subgraph node preserves edited promoted widget values on original and clone',
|
||||
{ tag: ['@vue-nodes'] },
|
||||
async ({ comfyPage }) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-with-promoted-text-widget'
|
||||
)
|
||||
|
||||
const editedValue = 'Edited prompt that must survive cloning'
|
||||
const originalTextbox = comfyPage.vueNodes
|
||||
.getNodeLocator('11')
|
||||
.getByRole('textbox', { name: 'text' })
|
||||
await expect(originalTextbox).toBeVisible()
|
||||
await expect(originalTextbox).toHaveValue('')
|
||||
await originalTextbox.fill(editedValue)
|
||||
|
||||
const originalNode = await comfyPage.nodeOps.getNodeRefById('11')
|
||||
await originalNode.click('title')
|
||||
await comfyPage.clipboard.copy()
|
||||
await comfyPage.clipboard.paste()
|
||||
|
||||
async function collectSubgraphNodeIds() {
|
||||
return comfyPage.page.evaluate(() => {
|
||||
const graph = window.app!.canvas.graph!
|
||||
return graph.nodes
|
||||
.filter(
|
||||
(n) =>
|
||||
typeof n.isSubgraphNode === 'function' && n.isSubgraphNode()
|
||||
)
|
||||
.map((n) => String(n.id))
|
||||
})
|
||||
}
|
||||
|
||||
await expect
|
||||
.poll(async () => (await collectSubgraphNodeIds()).length)
|
||||
.toBeGreaterThan(1)
|
||||
|
||||
const subgraphNodeIds = await collectSubgraphNodeIds()
|
||||
for (const nodeId of subgraphNodeIds) {
|
||||
const textbox = comfyPage.vueNodes
|
||||
.getNodeLocator(nodeId)
|
||||
.getByRole('textbox', { name: 'text' })
|
||||
await expect(
|
||||
textbox,
|
||||
`node ${nodeId} promoted text widget reset to default after clone`
|
||||
).toHaveValue(editedValue)
|
||||
}
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
test.describe('Duplicate ID Remapping', () => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@comfyorg/comfyui-frontend",
|
||||
"version": "1.46.12",
|
||||
"version": "1.46.13",
|
||||
"private": true,
|
||||
"description": "Official front-end implementation of ComfyUI",
|
||||
"homepage": "https://comfy.org",
|
||||
|
||||
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 15 KiB |
@@ -10,7 +10,7 @@ import IoItem from '@/components/builder/IoItem.vue'
|
||||
import PropertiesAccordionItem from '@/components/rightSidePanel/layout/PropertiesAccordionItem.vue'
|
||||
import { useResolvedSelectedInputs } from '@/components/builder/useResolvedSelectedInputs'
|
||||
import type { ResolvedSelection } from '@/components/builder/useResolvedSelectedInputs'
|
||||
import type { WidgetEntityId } from '@/world/entityIds'
|
||||
import type { WidgetId } from '@/types/widgetId'
|
||||
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import type { LGraphNode, NodeId } from '@/lib/litegraph/src/LGraphNode'
|
||||
import type { LGraphCanvas } from '@/lib/litegraph/src/LGraphCanvas'
|
||||
@@ -110,8 +110,8 @@ function getWidgetBounding(entry: ResolvedSelection): BoundStyle | undefined {
|
||||
}
|
||||
}
|
||||
|
||||
function removeSelectedEntityId(entityId: WidgetEntityId): void {
|
||||
const index = appModeStore.selectedInputs.findIndex(([id]) => id === entityId)
|
||||
function removeSelectedWidgetId(widgetId: WidgetId): void {
|
||||
const index = appModeStore.selectedInputs.findIndex(([id]) => id === widgetId)
|
||||
if (index !== -1) appModeStore.selectedInputs.splice(index, 1)
|
||||
}
|
||||
|
||||
@@ -139,11 +139,11 @@ function handleClick(e: MouseEvent) {
|
||||
}
|
||||
if (!isSelectInputsMode.value || widget.options.canvasOnly) return
|
||||
|
||||
const entityId = widget.entityId
|
||||
if (!entityId) return
|
||||
const index = appModeStore.selectedInputs.findIndex(([id]) => id === entityId)
|
||||
const widgetId = widget.widgetId
|
||||
if (!widgetId) return
|
||||
const index = appModeStore.selectedInputs.findIndex(([id]) => id === widgetId)
|
||||
if (index === -1)
|
||||
appModeStore.selectedInputs.push([entityId, widget.name, undefined])
|
||||
appModeStore.selectedInputs.push([widgetId, widget.name, undefined])
|
||||
else appModeStore.selectedInputs.splice(index, 1)
|
||||
}
|
||||
|
||||
@@ -172,7 +172,7 @@ const renderedInputs = computed<[string, MaybeRef<BoundStyle> | undefined][]>(
|
||||
() =>
|
||||
resolvedInputs.value.map(
|
||||
(entry) =>
|
||||
[entry.entityId, getWidgetBounding(entry)] as [
|
||||
[entry.widgetId, getWidgetBounding(entry)] as [
|
||||
string,
|
||||
MaybeRef<BoundStyle> | undefined
|
||||
]
|
||||
@@ -220,7 +220,7 @@ const renderedInputs = computed<[string, MaybeRef<BoundStyle> | undefined][]>(
|
||||
v-slot="{ dragClass }"
|
||||
v-model="appModeStore.selectedInputs"
|
||||
>
|
||||
<template v-for="entry in resolvedInputs" :key="entry.entityId">
|
||||
<template v-for="entry in resolvedInputs" :key="entry.widgetId">
|
||||
<IoItem
|
||||
v-if="entry.status === 'resolved'"
|
||||
:class="
|
||||
@@ -239,7 +239,7 @@ const renderedInputs = computed<[string, MaybeRef<BoundStyle> | undefined][]>(
|
||||
"
|
||||
:title="entry.displayName"
|
||||
:sub-title="t('linearMode.builder.unknownWidget')"
|
||||
:remove="() => removeSelectedEntityId(entry.entityId)"
|
||||
:remove="() => removeSelectedWidgetId(entry.widgetId)"
|
||||
/>
|
||||
</template>
|
||||
</DraggableList>
|
||||
|
||||
@@ -60,7 +60,7 @@ const mappedSelections = computed((): WidgetEntry[] => {
|
||||
|
||||
return resolvedInputs.value.flatMap((entry) => {
|
||||
if (entry.status !== 'resolved') return []
|
||||
const { entityId, node, widget, config } = entry
|
||||
const { widgetId, node, widget, config } = entry
|
||||
if (node.mode !== LGraphEventMode.ALWAYS) return []
|
||||
|
||||
if (!nodeDataByNode.has(node)) {
|
||||
@@ -70,7 +70,7 @@ const mappedSelections = computed((): WidgetEntry[] => {
|
||||
|
||||
const matchingWidget = fullNodeData.widgets?.find((vueWidget) => {
|
||||
if (vueWidget.slotMetadata?.linked) return false
|
||||
return vueWidget.entityId === entityId
|
||||
return vueWidget.widgetId === widgetId
|
||||
})
|
||||
if (!matchingWidget) return []
|
||||
|
||||
@@ -79,7 +79,7 @@ const mappedSelections = computed((): WidgetEntry[] => {
|
||||
|
||||
return [
|
||||
{
|
||||
key: entityId,
|
||||
key: widgetId,
|
||||
persistedHeight: config?.height,
|
||||
nodeData: {
|
||||
...fullNodeData,
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { fromAny } from '@total-typescript/shoehorn'
|
||||
import { fromAny, fromPartial } from '@total-typescript/shoehorn'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
import type { INodeInputSlot } from '@/lib/litegraph/src/interfaces'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useAppModeStore } from '@/stores/appModeStore'
|
||||
import type { WidgetEntityId } from '@/world/entityIds'
|
||||
import type { WidgetId } from '@/types/widgetId'
|
||||
|
||||
import { useResolvedSelectedInputs } from './useResolvedSelectedInputs'
|
||||
|
||||
@@ -22,18 +23,29 @@ vi.mock('@/scripts/app', () => ({
|
||||
}))
|
||||
|
||||
const rootGraphId = '11111111-1111-4111-8111-111111111111'
|
||||
const entitySeed = `${rootGraphId}:1:seed` as WidgetEntityId
|
||||
const entitySeed = `${rootGraphId}:1:seed` as WidgetId
|
||||
|
||||
function makeNode(id: number, widgetNames: string[]): LGraphNode {
|
||||
return fromAny<LGraphNode, unknown>({
|
||||
id,
|
||||
inputs: [],
|
||||
isSubgraphNode: () => false,
|
||||
widgets: widgetNames.map((name) => ({
|
||||
name,
|
||||
entityId: `${rootGraphId}:${id}:${name}` as WidgetEntityId
|
||||
widgetId: `${rootGraphId}:${id}:${name}` as WidgetId
|
||||
}))
|
||||
})
|
||||
}
|
||||
|
||||
function makeSubgraphNode(id: number, inputs: INodeInputSlot[]): LGraphNode {
|
||||
return fromAny<LGraphNode, unknown>({
|
||||
id,
|
||||
inputs,
|
||||
isSubgraphNode: () => true,
|
||||
widgets: []
|
||||
})
|
||||
}
|
||||
|
||||
function setRootGraphNodes(nodes: LGraphNode[]) {
|
||||
vi.mocked(app.rootGraph).nodes = nodes
|
||||
vi.mocked(app.rootGraph).getNodeById = vi.fn(
|
||||
@@ -88,4 +100,27 @@ describe('useResolvedSelectedInputs', () => {
|
||||
|
||||
expect(resolved.value[0]?.status).toBe('unknown')
|
||||
})
|
||||
|
||||
it('resolves promoted subgraph inputs from their host input widgetId', () => {
|
||||
const node = makeSubgraphNode(1, [
|
||||
fromPartial<INodeInputSlot>({
|
||||
name: 'seed',
|
||||
label: 'renamed_seed',
|
||||
widgetId: entitySeed
|
||||
})
|
||||
])
|
||||
setRootGraphNodes([node])
|
||||
|
||||
const appModeStore = useAppModeStore()
|
||||
appModeStore.selectedInputs = [[entitySeed, 'seed']]
|
||||
|
||||
const resolved = useResolvedSelectedInputs()
|
||||
|
||||
expect(resolved.value[0]).toMatchObject({
|
||||
status: 'resolved',
|
||||
node,
|
||||
displayName: 'seed',
|
||||
widget: { name: 'seed', label: 'renamed_seed', widgetId: entitySeed }
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,18 +1,19 @@
|
||||
import { useEventListener } from '@vueuse/core'
|
||||
import { computed, shallowRef, triggerRef } from 'vue'
|
||||
|
||||
import { promotedInputWidgets } from '@/core/graph/subgraph/promotedInputWidget'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
import type { InputWidgetConfig } from '@/platform/workflow/management/stores/comfyWorkflow'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useAppModeStore } from '@/stores/appModeStore'
|
||||
import type { WidgetEntityId } from '@/world/entityIds'
|
||||
import { isWidgetEntityId, parseWidgetEntityId } from '@/world/entityIds'
|
||||
import type { WidgetId } from '@/types/widgetId'
|
||||
import { isWidgetId, parseWidgetId } from '@/types/widgetId'
|
||||
|
||||
export type ResolvedSelection =
|
||||
| {
|
||||
status: 'resolved'
|
||||
entityId: WidgetEntityId
|
||||
widgetId: WidgetId
|
||||
node: LGraphNode
|
||||
widget: IBaseWidget
|
||||
displayName: string
|
||||
@@ -20,7 +21,7 @@ export type ResolvedSelection =
|
||||
}
|
||||
| {
|
||||
status: 'unknown'
|
||||
entityId: WidgetEntityId
|
||||
widgetId: WidgetId
|
||||
displayName: string
|
||||
config?: InputWidgetConfig
|
||||
}
|
||||
@@ -54,16 +55,19 @@ export function useResolvedSelectedInputs() {
|
||||
if (!rootGraph) return []
|
||||
|
||||
return appModeStore.selectedInputs.flatMap(
|
||||
([entityId, displayName, config]): ResolvedSelection[] => {
|
||||
if (!isWidgetEntityId(entityId)) return []
|
||||
const { nodeId, name } = parseWidgetEntityId(entityId)
|
||||
([widgetId, displayName, config]): ResolvedSelection[] => {
|
||||
if (!isWidgetId(widgetId)) return []
|
||||
const { nodeId, name } = parseWidgetId(widgetId)
|
||||
const node = rootGraph.getNodeById(nodeId)
|
||||
const widget = node?.widgets?.find((w) => w.name === name)
|
||||
const widgets = node?.isSubgraphNode()
|
||||
? promotedInputWidgets(node)
|
||||
: node?.widgets
|
||||
const widget = widgets?.find((w) => w.name === name)
|
||||
if (!node || !widget) {
|
||||
return [{ status: 'unknown', entityId, displayName, config }]
|
||||
return [{ status: 'unknown', widgetId, displayName, config }]
|
||||
}
|
||||
return [
|
||||
{ status: 'resolved', entityId, node, widget, displayName, config }
|
||||
{ status: 'resolved', widgetId, node, widget, displayName, config }
|
||||
]
|
||||
}
|
||||
)
|
||||
|
||||
@@ -33,7 +33,6 @@ const {
|
||||
items,
|
||||
gridStyle,
|
||||
bufferRows = 1,
|
||||
scrollThrottle = 64,
|
||||
resizeDebounce = 64,
|
||||
defaultItemHeight = 200,
|
||||
defaultItemWidth = 200,
|
||||
@@ -42,7 +41,6 @@ const {
|
||||
items: (T & { key: string })[]
|
||||
gridStyle: CSSProperties
|
||||
bufferRows?: number
|
||||
scrollThrottle?: number
|
||||
resizeDebounce?: number
|
||||
defaultItemHeight?: number
|
||||
defaultItemWidth?: number
|
||||
@@ -61,7 +59,6 @@ const itemWidth = ref(defaultItemWidth)
|
||||
const container = ref<HTMLElement | null>(null)
|
||||
const { width, height } = useElementSize(container)
|
||||
const { y: scrollY } = useScroll(container, {
|
||||
throttle: scrollThrottle,
|
||||
eventListenerOptions: { passive: true }
|
||||
})
|
||||
|
||||
|
||||
@@ -57,154 +57,85 @@ function drawFrame(canvas: LGraphCanvas) {
|
||||
canvas.onDrawForeground?.({} as CanvasRenderingContext2D, new Rectangle())
|
||||
}
|
||||
|
||||
describe('DomWidgets transition grace characterization', () => {
|
||||
describe('DomWidgets positioning', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
})
|
||||
|
||||
it('applies transition grace for exactly one frame when override exists but is not active', () => {
|
||||
it('positions an active visible widget relative to its owning node', () => {
|
||||
const canvasStore = useCanvasStore()
|
||||
const domWidgetStore = useDomWidgetStore()
|
||||
|
||||
const graphA = new LGraph()
|
||||
const graphB = new LGraph()
|
||||
const interiorNode = createNode(graphA, 1, 'interior', [100, 200])
|
||||
const overrideNode = createNode(graphB, 2, 'override', [600, 700])
|
||||
|
||||
const widget = createWidget('widget-transition', interiorNode, 14)
|
||||
const overrideWidget = createWidget('override-widget', overrideNode, 22)
|
||||
const graph = new LGraph()
|
||||
const node = createNode(graph, 1, 'host', [100, 200])
|
||||
const widget = createWidget('widget-pos', node, 14)
|
||||
|
||||
domWidgetStore.registerWidget(widget)
|
||||
domWidgetStore.setPositionOverride(widget.id, {
|
||||
node: overrideNode,
|
||||
widget: overrideWidget
|
||||
|
||||
const canvas = createCanvas(graph)
|
||||
canvasStore.canvas = canvas
|
||||
|
||||
render(DomWidgets, {
|
||||
global: { stubs: { DomWidget: true } }
|
||||
})
|
||||
|
||||
drawFrame(canvas)
|
||||
|
||||
const widgetState = domWidgetStore.widgetStates.get(widget.id)
|
||||
if (!widgetState) throw new Error('Widget state not registered')
|
||||
expect(widgetState.visible).toBe(true)
|
||||
expect(widgetState.pos).toEqual([110, 224])
|
||||
})
|
||||
|
||||
it('hides a widget whose owning node is in a different graph', () => {
|
||||
const canvasStore = useCanvasStore()
|
||||
const domWidgetStore = useDomWidgetStore()
|
||||
|
||||
const currentGraph = new LGraph()
|
||||
const otherGraph = new LGraph()
|
||||
const node = createNode(otherGraph, 1, 'host', [100, 200])
|
||||
const widget = createWidget('widget-other-graph', node, 14)
|
||||
|
||||
domWidgetStore.registerWidget(widget)
|
||||
|
||||
const canvas = createCanvas(currentGraph)
|
||||
canvasStore.canvas = canvas
|
||||
|
||||
render(DomWidgets, {
|
||||
global: { stubs: { DomWidget: true } }
|
||||
})
|
||||
|
||||
drawFrame(canvas)
|
||||
|
||||
const widgetState = domWidgetStore.widgetStates.get(widget.id)
|
||||
if (!widgetState) throw new Error('Widget state not registered')
|
||||
expect(widgetState.visible).toBe(false)
|
||||
})
|
||||
|
||||
it('hides an inactive widget', () => {
|
||||
const canvasStore = useCanvasStore()
|
||||
const domWidgetStore = useDomWidgetStore()
|
||||
|
||||
const graph = new LGraph()
|
||||
const node = createNode(graph, 1, 'host', [0, 0])
|
||||
const widget = createWidget('widget-inactive', node, 10)
|
||||
|
||||
domWidgetStore.registerWidget(widget)
|
||||
domWidgetStore.deactivateWidget(widget.id)
|
||||
|
||||
const widgetState = domWidgetStore.widgetStates.get(widget.id)
|
||||
if (!widgetState) throw new Error('Widget state not registered')
|
||||
widgetState.visible = true
|
||||
widgetState.pos = [321, 654]
|
||||
|
||||
const canvas = createCanvas(graphA)
|
||||
const canvas = createCanvas(graph)
|
||||
canvasStore.canvas = canvas
|
||||
|
||||
render(DomWidgets, {
|
||||
global: {
|
||||
stubs: {
|
||||
DomWidget: true
|
||||
}
|
||||
}
|
||||
global: { stubs: { DomWidget: true } }
|
||||
})
|
||||
|
||||
drawFrame(canvas)
|
||||
expect(widgetState.visible).toBe(true)
|
||||
expect(widgetState.pos).toEqual([321, 654])
|
||||
|
||||
drawFrame(canvas)
|
||||
expect(widgetState.visible).toBe(false)
|
||||
})
|
||||
|
||||
it('uses override positioning while override node is in current graph even when widget is inactive', () => {
|
||||
const canvasStore = useCanvasStore()
|
||||
const domWidgetStore = useDomWidgetStore()
|
||||
|
||||
const graphA = new LGraph()
|
||||
const graphB = new LGraph()
|
||||
const interiorNode = createNode(graphA, 1, 'interior', [10, 20])
|
||||
const overrideNode = createNode(graphB, 2, 'override', [300, 400])
|
||||
|
||||
const widget = createWidget('widget-override-active', interiorNode, 8)
|
||||
const overrideWidget = createWidget(
|
||||
'override-position-source',
|
||||
overrideNode,
|
||||
18
|
||||
)
|
||||
|
||||
domWidgetStore.registerWidget(widget)
|
||||
domWidgetStore.setPositionOverride(widget.id, {
|
||||
node: overrideNode,
|
||||
widget: overrideWidget
|
||||
})
|
||||
domWidgetStore.deactivateWidget(widget.id)
|
||||
|
||||
const widgetState = domWidgetStore.widgetStates.get(widget.id)
|
||||
if (!widgetState) throw new Error('Widget state not registered')
|
||||
|
||||
const canvas = createCanvas(graphB)
|
||||
canvasStore.canvas = canvas
|
||||
|
||||
render(DomWidgets, {
|
||||
global: {
|
||||
stubs: {
|
||||
DomWidget: true
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
drawFrame(canvas)
|
||||
|
||||
expect(widgetState.visible).toBe(true)
|
||||
expect(widgetState.pos).toEqual([310, 428])
|
||||
})
|
||||
|
||||
it('cleans orphaned transition-grace ids after widget removal', () => {
|
||||
const canvasStore = useCanvasStore()
|
||||
const domWidgetStore = useDomWidgetStore()
|
||||
|
||||
const graphA = new LGraph()
|
||||
const graphB = new LGraph()
|
||||
const interiorNode = createNode(graphA, 1, 'interior', [0, 0])
|
||||
const overrideNode = createNode(graphB, 2, 'override', [200, 200])
|
||||
|
||||
const canvas = createCanvas(graphA)
|
||||
canvasStore.canvas = canvas
|
||||
|
||||
render(DomWidgets, {
|
||||
global: {
|
||||
stubs: {
|
||||
DomWidget: true
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const oldWidget = createWidget('shared-widget-id', interiorNode, 10)
|
||||
const overrideWidget = createWidget(
|
||||
'shared-override-widget',
|
||||
overrideNode,
|
||||
14
|
||||
)
|
||||
|
||||
domWidgetStore.registerWidget(oldWidget)
|
||||
domWidgetStore.setPositionOverride(oldWidget.id, {
|
||||
node: overrideNode,
|
||||
widget: overrideWidget
|
||||
})
|
||||
domWidgetStore.deactivateWidget(oldWidget.id)
|
||||
|
||||
drawFrame(canvas)
|
||||
domWidgetStore.unregisterWidget(oldWidget.id)
|
||||
|
||||
drawFrame(canvas)
|
||||
|
||||
const replacementWidget = createWidget('shared-widget-id', interiorNode, 10)
|
||||
domWidgetStore.registerWidget(replacementWidget)
|
||||
domWidgetStore.setPositionOverride(replacementWidget.id, {
|
||||
node: overrideNode,
|
||||
widget: overrideWidget
|
||||
})
|
||||
domWidgetStore.deactivateWidget(replacementWidget.id)
|
||||
|
||||
const replacementState = domWidgetStore.widgetStates.get(
|
||||
replacementWidget.id
|
||||
)
|
||||
if (!replacementState) throw new Error('Replacement widget missing state')
|
||||
replacementState.visible = true
|
||||
replacementState.pos = [999, 999]
|
||||
|
||||
drawFrame(canvas)
|
||||
|
||||
expect(replacementState.visible).toBe(true)
|
||||
expect(replacementState.pos).toEqual([999, 999])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -21,7 +21,6 @@ import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { useDomWidgetStore } from '@/stores/domWidgetStore'
|
||||
|
||||
const domWidgetStore = useDomWidgetStore()
|
||||
const overrideTransitionGrace = new Set<string>()
|
||||
|
||||
const widgetStates = computed(() => [...domWidgetStore.widgetStates.values()])
|
||||
|
||||
@@ -31,47 +30,16 @@ const updateWidgets = () => {
|
||||
|
||||
const lowQuality = lgCanvas.low_quality
|
||||
const currentGraph = lgCanvas.graph
|
||||
const seenWidgetIds = new Set<string>()
|
||||
|
||||
for (const widgetState of widgetStates.value) {
|
||||
const widget = widgetState.widget
|
||||
seenWidgetIds.add(widget.id)
|
||||
|
||||
// Use position override only when the override node (SubgraphNode) is
|
||||
// in the current graph. When the user enters the subgraph, the override
|
||||
// node is no longer visible — fall back to the widget's own node.
|
||||
// Use graph reference equality (IDs are not unique across graphs).
|
||||
const override = widgetState.positionOverride
|
||||
const useOverride = !!override && currentGraph === override.node.graph
|
||||
const inOverrideTransitionGap =
|
||||
!!override && !useOverride && !widgetState.active
|
||||
const useTransitionGrace =
|
||||
inOverrideTransitionGap && !overrideTransitionGrace.has(widget.id)
|
||||
|
||||
if (useTransitionGrace) {
|
||||
overrideTransitionGrace.add(widget.id)
|
||||
} else if (!inOverrideTransitionGap) {
|
||||
overrideTransitionGrace.delete(widget.id)
|
||||
}
|
||||
|
||||
// Early exit for non-visible widgets.
|
||||
// When a position override is active (widget promoted to SubgraphNode),
|
||||
// the interior widget's `active` flag is false (its node is in the
|
||||
// subgraph, not the current graph) — bypass that check.
|
||||
if (
|
||||
!widget.isVisible() ||
|
||||
(!widgetState.active && !useOverride && !useTransitionGrace)
|
||||
) {
|
||||
if (!widget.isVisible() || !widgetState.active) {
|
||||
widgetState.visible = false
|
||||
continue
|
||||
}
|
||||
|
||||
// During graph transitions, hold the previous position for one frame
|
||||
// so promoted widgets don't briefly disappear before activation flips.
|
||||
if (useTransitionGrace) continue
|
||||
|
||||
const posNode = useOverride ? override.node : widget.node
|
||||
const posWidget = useOverride ? override.widget : widget
|
||||
const posNode = widget.node
|
||||
|
||||
const isInCorrectGraph = posNode.graph === currentGraph
|
||||
const nodeVisible = lgCanvas.isNodeVisible(posNode)
|
||||
@@ -85,22 +53,16 @@ const updateWidgets = () => {
|
||||
const margin = widget.margin
|
||||
widgetState.pos = [
|
||||
posNode.pos[0] + margin,
|
||||
posNode.pos[1] + margin + posWidget.y
|
||||
posNode.pos[1] + margin + widget.y
|
||||
]
|
||||
widgetState.size = [
|
||||
(posWidget.width ?? posNode.width) - margin * 2,
|
||||
(posWidget.computedHeight ?? 50) - margin * 2
|
||||
(widget.width ?? posNode.width) - margin * 2,
|
||||
(widget.computedHeight ?? 50) - margin * 2
|
||||
]
|
||||
widgetState.zIndex = getDomWidgetZIndex(posNode, currentGraph)
|
||||
widgetState.readonly = lgCanvas.read_only
|
||||
}
|
||||
}
|
||||
|
||||
for (const widgetId of overrideTransitionGrace) {
|
||||
if (!seenWidgetIds.has(widgetId)) {
|
||||
overrideTransitionGrace.delete(widgetId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const canvasStore = useCanvasStore()
|
||||
|
||||
@@ -54,7 +54,7 @@ vi.mock('@/platform/settings/settingStore', () => ({
|
||||
})
|
||||
}))
|
||||
|
||||
function createWidgetState(overrideDisabled: boolean): DomWidgetState {
|
||||
function createWidgetState(disabled: boolean): DomWidgetState {
|
||||
const domWidgetStore = useDomWidgetStore()
|
||||
const node = createMockLGraphNode({
|
||||
id: 1,
|
||||
@@ -70,14 +70,10 @@ function createWidgetState(overrideDisabled: boolean): DomWidgetState {
|
||||
value: '',
|
||||
options: {},
|
||||
node,
|
||||
computedDisabled: false
|
||||
computedDisabled: disabled
|
||||
})
|
||||
|
||||
domWidgetStore.registerWidget(widget)
|
||||
domWidgetStore.setPositionOverride(widget.id, {
|
||||
node: createMockLGraphNode({ id: 2 }),
|
||||
widget: { computedDisabled: overrideDisabled } as DomWidgetState['widget']
|
||||
})
|
||||
|
||||
const state = domWidgetStore.widgetStates.get(widget.id)
|
||||
if (!state) throw new Error('Expected registered DomWidgetState')
|
||||
@@ -98,7 +94,7 @@ describe('DomWidget disabled style', () => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('uses disabled style when promoted override widget is computedDisabled', async () => {
|
||||
it('uses disabled style when widget is computedDisabled', async () => {
|
||||
const widgetState = createWidgetState(true)
|
||||
const { container } = render(DomWidget, {
|
||||
props: {
|
||||
|
||||
@@ -69,11 +69,7 @@ const updateDomClipping = () => {
|
||||
return
|
||||
}
|
||||
|
||||
const override = widgetState.positionOverride
|
||||
const overrideInGraph =
|
||||
override && lgCanvas.graph?.getNodeById(override.node.id)
|
||||
const ownerNode = overrideInGraph ? override.node : widgetState.widget.node
|
||||
const isSelected = selectedNode === ownerNode
|
||||
const isSelected = selectedNode === widgetState.widget.node
|
||||
const renderArea = selectedNode?.renderArea
|
||||
const offset = lgCanvas.ds.offset
|
||||
const scale = lgCanvas.ds.scale
|
||||
@@ -104,10 +100,7 @@ const updateDomClipping = () => {
|
||||
const { left, top } = useElementBounding(canvasStore.getCanvas().canvas)
|
||||
|
||||
function composeStyle() {
|
||||
const override = widgetState.positionOverride
|
||||
const isDisabled = override
|
||||
? (override.widget.computedDisabled ?? widget.computedDisabled)
|
||||
: widget.computedDisabled
|
||||
const isDisabled = widget.computedDisabled
|
||||
|
||||
style.value = {
|
||||
...positionStyle.value,
|
||||
@@ -167,13 +160,7 @@ onMounted(() => {
|
||||
const lgCanvas = canvasStore.canvas
|
||||
if (!lgCanvas) return
|
||||
|
||||
const override = widgetState.positionOverride
|
||||
const overrideInGraph =
|
||||
override && lgCanvas.graph?.getNodeById(override.node.id)
|
||||
const ownerNode = overrideInGraph
|
||||
? override.node
|
||||
: widgetState.widget.node
|
||||
|
||||
const ownerNode = widgetState.widget.node
|
||||
lgCanvas.selectNode(ownerNode)
|
||||
lgCanvas.bringToFront(ownerNode)
|
||||
}
|
||||
|
||||
@@ -530,7 +530,9 @@ describe('TabErrors.vue', () => {
|
||||
expect(
|
||||
screen.getByText('Some nodes can be replaced with alternatives')
|
||||
).toBeInTheDocument()
|
||||
expect(screen.getByText('OldSampler (1)')).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'OldSampler' })
|
||||
).toBeInTheDocument()
|
||||
expect(screen.getByText('KSampler')).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByRole('button', { name: /Replace Node/ })
|
||||
|
||||
@@ -157,7 +157,6 @@
|
||||
<SwapNodesCard
|
||||
v-if="group.type === 'swap_nodes'"
|
||||
:swap-node-groups="swapNodeGroups"
|
||||
:show-node-id-badge="showNodeIdBadge"
|
||||
@locate-node="handleLocateMissingNode"
|
||||
@replace="handleReplaceGroup"
|
||||
/>
|
||||
|
||||
@@ -12,14 +12,15 @@ import {
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { widgetPromotedSource } from '@/core/graph/subgraph/promotedInputWidget'
|
||||
import { isWidgetPromotedOnSubgraphNode } from '@/core/graph/subgraph/promotionUtils'
|
||||
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
|
||||
import type { LGraphGroup, LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { SubgraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { DraggableList } from '@/scripts/ui/draggableList'
|
||||
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
|
||||
import { useWidgetValueStore } from '@/stores/widgetValueStore'
|
||||
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
@@ -146,16 +147,17 @@ function isWidgetShownOnParents(
|
||||
widgetNode: LGraphNode,
|
||||
widget: IBaseWidget
|
||||
): boolean {
|
||||
const source = widgetPromotedSource(widgetNode, widget)
|
||||
return parents.some((parent) => {
|
||||
if (isPromotedWidgetView(widget)) {
|
||||
if (source) {
|
||||
const interiorNodeId =
|
||||
String(widgetNode.id) === String(parent.id)
|
||||
? widget.sourceNodeId
|
||||
? source.nodeId
|
||||
: String(widgetNode.id)
|
||||
|
||||
return isWidgetPromotedOnSubgraphNode(parent, {
|
||||
sourceNodeId: interiorNodeId,
|
||||
sourceWidgetName: widget.sourceWidgetName
|
||||
sourceWidgetName: source.widgetName
|
||||
})
|
||||
}
|
||||
return isWidgetPromotedOnSubgraphNode(parent, {
|
||||
@@ -234,7 +236,10 @@ function navigateToErrorTab() {
|
||||
rightSidePanelStore.openPanel('errors')
|
||||
}
|
||||
|
||||
function writeWidgetValue(widget: IBaseWidget, value: WidgetValue) {
|
||||
function setWidgetValue(widget: IBaseWidget, value: WidgetValue) {
|
||||
// Store-backed widgets (interior node widgets and promoted subgraph inputs)
|
||||
// are addressed by widgetId; writing there keeps the displayed value in sync.
|
||||
if (widget.widgetId) useWidgetValueStore().setValue(widget.widgetId, value)
|
||||
widget.value = value
|
||||
widget.callback?.(value)
|
||||
canvasStore.canvas?.setDirty(true, true)
|
||||
@@ -245,18 +250,18 @@ function handleResetAllWidgets() {
|
||||
const spec = nodeDefStore.getInputSpecForWidget(widgetNode, widget.name)
|
||||
const defaultValue = getWidgetDefaultValue(spec)
|
||||
if (defaultValue !== undefined) {
|
||||
writeWidgetValue(widget, defaultValue)
|
||||
setWidgetValue(widget, defaultValue)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handleWidgetValueUpdate(widget: IBaseWidget, newValue: WidgetValue) {
|
||||
if (newValue === undefined) return
|
||||
writeWidgetValue(widget, newValue)
|
||||
setWidgetValue(widget, newValue)
|
||||
}
|
||||
|
||||
function handleWidgetReset(widget: IBaseWidget, newValue: WidgetValue) {
|
||||
writeWidgetValue(widget, newValue)
|
||||
setWidgetValue(widget, newValue)
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
import { render } from '@testing-library/vue'
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import { promoteValueWidgetViaSubgraphInput } from '@/core/graph/subgraph/promotionUtils'
|
||||
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type { LGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
|
||||
import {
|
||||
createTestSubgraph,
|
||||
createTestSubgraphNode
|
||||
} from '@/lib/litegraph/src/subgraph/__fixtures__/subgraphHelpers'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
import { useWidgetValueStore } from '@/stores/widgetValueStore'
|
||||
import { widgetId } from '@/types/widgetId'
|
||||
|
||||
import TabSubgraphInputs from './TabSubgraphInputs.vue'
|
||||
|
||||
vi.mock('@/services/litegraphService', () => ({
|
||||
useLitegraphService: () => ({ updatePreviews: vi.fn() })
|
||||
}))
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: { en: { rightSidePanel: { inputs: 'Inputs', inputsNone: 'None' } } }
|
||||
})
|
||||
|
||||
const captured: { rows: { node: LGraphNode; widget: IBaseWidget }[] } = {
|
||||
rows: []
|
||||
}
|
||||
|
||||
const SectionWidgetsStub = {
|
||||
props: ['widgets', 'node', 'parents'],
|
||||
setup(props: Record<string, unknown>) {
|
||||
captured.rows = props.widgets as {
|
||||
node: LGraphNode
|
||||
widget: IBaseWidget
|
||||
}[]
|
||||
return () => null
|
||||
}
|
||||
}
|
||||
|
||||
function buildHostWithPromotedSeed(): {
|
||||
host: SubgraphNode
|
||||
sourceNode: LGraphNode
|
||||
} {
|
||||
const subgraph = createTestSubgraph()
|
||||
const host = createTestSubgraphNode(subgraph)
|
||||
const graph = host.graph as LGraph
|
||||
graph.add(host)
|
||||
|
||||
const sourceNode = new LGraphNode('Sampler')
|
||||
const input = sourceNode.addInput('seed', 'INT')
|
||||
const seedWidget = sourceNode.addWidget('number', 'seed', 42, () => {})
|
||||
input.widget = { name: seedWidget.name }
|
||||
subgraph.add(sourceNode)
|
||||
|
||||
promoteValueWidgetViaSubgraphInput(host, sourceNode, seedWidget)
|
||||
return { host, sourceNode }
|
||||
}
|
||||
|
||||
function renderPanel(node: SubgraphNode) {
|
||||
return render(TabSubgraphInputs, {
|
||||
props: { node },
|
||||
global: {
|
||||
plugins: [i18n],
|
||||
stubs: {
|
||||
SectionWidgets: SectionWidgetsStub,
|
||||
AsyncSearchInput: true,
|
||||
CollapseToggleButton: true
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
describe('TabSubgraphInputs', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
captured.rows = []
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('lists a subgraph node promoted widget as a store-backed parameter row', () => {
|
||||
const { host } = buildHostWithPromotedSeed()
|
||||
|
||||
renderPanel(host)
|
||||
|
||||
const seedRow = captured.rows.find((row) => row.widget.name === 'seed')
|
||||
expect(seedRow).toBeDefined()
|
||||
expect(seedRow?.node.id).toBe(host.id)
|
||||
expect(seedRow?.widget.type).toBe('number')
|
||||
expect(seedRow?.widget.widgetId).toBe(
|
||||
widgetId(host.rootGraph.id, host.id, 'seed')
|
||||
)
|
||||
expect(seedRow?.widget.value).toBe(42)
|
||||
})
|
||||
|
||||
it('reflects the current host widget value from the store', () => {
|
||||
const { host } = buildHostWithPromotedSeed()
|
||||
const id = widgetId(host.rootGraph.id, host.id, 'seed')
|
||||
useWidgetValueStore().setValue(id, 7)
|
||||
|
||||
renderPanel(host)
|
||||
|
||||
const seedRow = captured.rows.find((row) => row.widget.name === 'seed')
|
||||
expect(seedRow?.widget.value).toBe(7)
|
||||
})
|
||||
|
||||
it('reflects value changes through the same descriptor without rebuilding it', () => {
|
||||
const { host } = buildHostWithPromotedSeed()
|
||||
renderPanel(host)
|
||||
|
||||
const seedRow = captured.rows.find((row) => row.widget.name === 'seed')!
|
||||
expect(seedRow.widget.value).toBe(42)
|
||||
|
||||
// A value edit must not require a new descriptor object: the same row
|
||||
// reflects the store change via its live getter, keeping render keys stable.
|
||||
useWidgetValueStore().setValue(
|
||||
widgetId(host.rootGraph.id, host.id, 'seed'),
|
||||
100
|
||||
)
|
||||
expect(seedRow.widget.value).toBe(100)
|
||||
})
|
||||
})
|
||||
@@ -3,14 +3,13 @@ import { storeToRefs } from 'pinia'
|
||||
import { computed, nextTick, ref, shallowRef, useTemplateRef, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
|
||||
import { promotedInputWidgets } from '@/core/graph/subgraph/promotedInputWidget'
|
||||
import {
|
||||
getWidgetName,
|
||||
isWidgetPromotedOnSubgraphNode,
|
||||
reorderSubgraphInputsByWidgetOrder
|
||||
} from '@/core/graph/subgraph/promotionUtils'
|
||||
import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import AsyncSearchInput from '@/components/ui/search-input/AsyncSearchInput.vue'
|
||||
import CollapseToggleButton from '@/components/rightSidePanel/layout/CollapseToggleButton.vue'
|
||||
@@ -45,32 +44,6 @@ const isAllCollapsed = computed({
|
||||
})
|
||||
const advancedInputsSectionRef = useTemplateRef('advancedInputsSectionRef')
|
||||
|
||||
function isSamePromotedWidget(a: IBaseWidget, b: IBaseWidget): boolean {
|
||||
return (
|
||||
isPromotedWidgetView(a) &&
|
||||
isPromotedWidgetView(b) &&
|
||||
a.sourceNodeId === b.sourceNodeId &&
|
||||
a.sourceWidgetName === b.sourceWidgetName
|
||||
)
|
||||
}
|
||||
|
||||
function getPromotedWidgets(): IBaseWidget[] {
|
||||
const inputWidgets = node.inputs
|
||||
.map((input) => input._widget)
|
||||
.filter((widget): widget is IBaseWidget =>
|
||||
Boolean(widget && isPromotedWidgetView(widget))
|
||||
)
|
||||
const extraWidgets = (node.widgets ?? []).filter(
|
||||
(widget) =>
|
||||
isPromotedWidgetView(widget) &&
|
||||
!inputWidgets.some((inputWidget) =>
|
||||
isSamePromotedWidget(inputWidget, widget)
|
||||
)
|
||||
)
|
||||
|
||||
return [...inputWidgets, ...extraWidgets]
|
||||
}
|
||||
|
||||
watch(
|
||||
focusedSection,
|
||||
async (section) => {
|
||||
@@ -93,7 +66,7 @@ watch(
|
||||
)
|
||||
|
||||
const widgetsList = computed((): NodeWidgetsList => {
|
||||
return getPromotedWidgets().map((widget) => ({ node, widget }))
|
||||
return promotedInputWidgets(node).map((widget) => ({ node, widget }))
|
||||
})
|
||||
|
||||
const advancedInputsWidgets = computed((): NodeWidgetsList => {
|
||||
|
||||
@@ -5,8 +5,9 @@ import { useI18n } from 'vue-i18n'
|
||||
|
||||
import MoreButton from '@/components/button/MoreButton.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
|
||||
import { widgetPromotedSource } from '@/core/graph/subgraph/promotedInputWidget'
|
||||
import {
|
||||
demotePromotedInput,
|
||||
demoteWidget,
|
||||
isLinkedPromotion,
|
||||
promoteWidget
|
||||
@@ -16,6 +17,7 @@ import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { useNodeDefStore } from '@/stores/nodeDefStore'
|
||||
import { useWidgetValueStore } from '@/stores/widgetValueStore'
|
||||
import { useFavoritedWidgetsStore } from '@/stores/workspace/favoritedWidgetsStore'
|
||||
import { getWidgetDefaultValue, promptWidgetLabel } from '@/utils/widgetUtil'
|
||||
import type { WidgetValue } from '@/utils/widgetUtil'
|
||||
@@ -45,8 +47,10 @@ const { t } = useI18n()
|
||||
|
||||
const hasParents = computed(() => parents?.length > 0)
|
||||
const isLinked = computed(() => {
|
||||
if (!node.isSubgraphNode() || !isPromotedWidgetView(widget)) return false
|
||||
return isLinkedPromotion(node, widget.sourceNodeId, widget.sourceWidgetName)
|
||||
if (!node.isSubgraphNode()) return false
|
||||
const source = widgetPromotedSource(node, widget)
|
||||
if (!source) return false
|
||||
return isLinkedPromotion(node, source.nodeId, source.widgetName)
|
||||
})
|
||||
const canToggleVisibility = computed(() => hasParents.value && !isLinked.value)
|
||||
const favoriteNode = computed(() =>
|
||||
@@ -64,9 +68,16 @@ const defaultValue = computed(() => getWidgetDefaultValue(inputSpec.value))
|
||||
|
||||
const hasDefault = computed(() => defaultValue.value !== undefined)
|
||||
|
||||
const currentValue = computed(
|
||||
() =>
|
||||
(widget.widgetId &&
|
||||
useWidgetValueStore().getWidget(widget.widgetId)?.value) ??
|
||||
widget.value
|
||||
)
|
||||
|
||||
const isCurrentValueDefault = computed(() => {
|
||||
if (!hasDefault.value) return true
|
||||
return isEqual(widget.value, defaultValue.value)
|
||||
return isEqual(currentValue.value, defaultValue.value)
|
||||
})
|
||||
|
||||
async function handleRename() {
|
||||
@@ -77,21 +88,15 @@ async function handleRename() {
|
||||
function handleHideInput() {
|
||||
if (!parents?.length) return
|
||||
|
||||
if (isPromotedWidgetView(widget)) {
|
||||
const source = widgetPromotedSource(node, widget)
|
||||
if (source) {
|
||||
for (const parent of parents) {
|
||||
const sourceNodeId =
|
||||
String(node.id) === String(parent.id)
|
||||
? widget.sourceNodeId
|
||||
: String(node.id)
|
||||
demoteWidget(
|
||||
{
|
||||
id: sourceNodeId,
|
||||
title: node.title,
|
||||
type: node.type
|
||||
},
|
||||
widget,
|
||||
[parent]
|
||||
)
|
||||
String(node.id) === String(parent.id) ? source.nodeId : String(node.id)
|
||||
demotePromotedInput(parent, {
|
||||
sourceNodeId,
|
||||
sourceWidgetName: source.widgetName
|
||||
})
|
||||
}
|
||||
canvasStore.canvas?.setDirty(true, true)
|
||||
} else {
|
||||
|
||||
@@ -7,6 +7,8 @@ import { createI18n } from 'vue-i18n'
|
||||
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
import { useWidgetValueStore } from '@/stores/widgetValueStore'
|
||||
import { widgetId } from '@/types/widgetId'
|
||||
import WidgetItem from './WidgetItem.vue'
|
||||
|
||||
const { mockGetInputSpecForWidget, StubWidgetComponent } = vi.hoisted(() => ({
|
||||
@@ -42,10 +44,6 @@ vi.mock('@/composables/graph/useGraphNodeManager', () => ({
|
||||
getControlWidget: vi.fn(() => undefined)
|
||||
}))
|
||||
|
||||
vi.mock('@/core/graph/subgraph/resolveConcretePromotedWidget', () => ({
|
||||
resolvePromotedWidgetSource: vi.fn(() => undefined)
|
||||
}))
|
||||
|
||||
vi.mock(
|
||||
'@/renderer/extensions/vueNodes/widgets/registry/widgetRegistry',
|
||||
() => ({
|
||||
@@ -96,43 +94,6 @@ function createMockWidget(overrides: Partial<IBaseWidget> = {}): IBaseWidget {
|
||||
} as IBaseWidget
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a mock PromotedWidgetView that mirrors the real class:
|
||||
* properties like name, type, value, options are prototype getters,
|
||||
* NOT own properties — so object spread loses them.
|
||||
*/
|
||||
function createMockPromotedWidgetView(
|
||||
sourceOptions: IBaseWidget['options'] = {
|
||||
values: ['model_a.safetensors', 'model_b.safetensors']
|
||||
}
|
||||
): IBaseWidget {
|
||||
class MockPromotedWidgetView {
|
||||
readonly sourceNodeId = '42'
|
||||
readonly sourceWidgetName = 'ckpt_name'
|
||||
readonly serialize = false
|
||||
|
||||
get name(): string {
|
||||
return 'ckpt_name'
|
||||
}
|
||||
get type(): string {
|
||||
return 'combo'
|
||||
}
|
||||
get value(): unknown {
|
||||
return 'model_a.safetensors'
|
||||
}
|
||||
get options(): IBaseWidget['options'] {
|
||||
return sourceOptions
|
||||
}
|
||||
get label(): string | undefined {
|
||||
return undefined
|
||||
}
|
||||
get y(): number {
|
||||
return 0
|
||||
}
|
||||
}
|
||||
return fromAny<IBaseWidget, unknown>(new MockPromotedWidgetView())
|
||||
}
|
||||
|
||||
function renderWidgetItem(
|
||||
widget: IBaseWidget,
|
||||
node: LGraphNode = createMockNode()
|
||||
@@ -167,7 +128,7 @@ describe('WidgetItem', () => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('promoted widget options', () => {
|
||||
describe('widget state rendering', () => {
|
||||
it('passes options from a regular widget to the widget component', () => {
|
||||
const widget = createMockWidget({
|
||||
options: { values: ['a', 'b', 'c'] }
|
||||
@@ -180,35 +141,63 @@ describe('WidgetItem', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('passes options from a PromotedWidgetView to the widget component', () => {
|
||||
it('passes options from widget state to the widget component', () => {
|
||||
const expectedOptions = {
|
||||
values: ['model_a.safetensors', 'model_b.safetensors']
|
||||
}
|
||||
const widget = createMockPromotedWidgetView(expectedOptions)
|
||||
const id = widgetId('test-graph-id', 1, 'ckpt_name')
|
||||
const widget = createMockWidget({ widgetId: id, name: 'ckpt_name' })
|
||||
useWidgetValueStore().registerWidget(id, {
|
||||
type: 'combo',
|
||||
value: 'model_a.safetensors',
|
||||
options: expectedOptions
|
||||
})
|
||||
|
||||
const { container } = renderWidgetItem(widget)
|
||||
const stub = getStubWidget(container)
|
||||
|
||||
expect(stub.options).toEqual(expectedOptions)
|
||||
})
|
||||
|
||||
it('passes type from a PromotedWidgetView to the widget component', () => {
|
||||
const widget = createMockPromotedWidgetView()
|
||||
it('passes type from widget state to the widget component', () => {
|
||||
const id = widgetId('test-graph-id', 1, 'ckpt_name')
|
||||
const widget = createMockWidget({ widgetId: id, type: 'string' })
|
||||
useWidgetValueStore().registerWidget(id, {
|
||||
type: 'combo',
|
||||
value: 'model_a.safetensors',
|
||||
options: { values: ['model_a.safetensors'] }
|
||||
})
|
||||
|
||||
const { container } = renderWidgetItem(widget)
|
||||
const stub = getStubWidget(container)
|
||||
|
||||
expect(stub.type).toBe('combo')
|
||||
})
|
||||
|
||||
it('passes name from a PromotedWidgetView to the widget component', () => {
|
||||
const widget = createMockPromotedWidgetView()
|
||||
it('passes name from widget state to the widget component', () => {
|
||||
const id = widgetId('test-graph-id', 1, 'ckpt_name')
|
||||
const widget = createMockWidget({ widgetId: id, name: 'source_name' })
|
||||
useWidgetValueStore().registerWidget(id, {
|
||||
type: 'combo',
|
||||
value: 'model_a.safetensors',
|
||||
options: { values: ['model_a.safetensors'] }
|
||||
})
|
||||
|
||||
const { container } = renderWidgetItem(widget)
|
||||
const stub = getStubWidget(container)
|
||||
|
||||
expect(stub.name).toBe('ckpt_name')
|
||||
})
|
||||
|
||||
it('passes value from a PromotedWidgetView to the widget component', () => {
|
||||
const widget = createMockPromotedWidgetView()
|
||||
it('passes value from widget state to the widget component', () => {
|
||||
const id = widgetId('test-graph-id', 1, 'ckpt_name')
|
||||
const widget = createMockWidget({ widgetId: id, value: 'source value' })
|
||||
useWidgetValueStore().registerWidget(id, {
|
||||
type: 'combo',
|
||||
value: 'model_a.safetensors',
|
||||
options: { values: ['model_a.safetensors'] }
|
||||
})
|
||||
|
||||
const { container } = renderWidgetItem(widget)
|
||||
const stub = getStubWidget(container)
|
||||
|
||||
|
||||
@@ -4,7 +4,6 @@ import { useI18n } from 'vue-i18n'
|
||||
|
||||
import EditableText from '@/components/common/EditableText.vue'
|
||||
import { getControlWidget } from '@/composables/graph/useGraphNodeManager'
|
||||
import { resolvePromotedWidgetSource } from '@/core/graph/subgraph/resolveConcretePromotedWidget'
|
||||
import { st } from '@/i18n'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
|
||||
@@ -17,11 +16,12 @@ import {
|
||||
} from '@/renderer/extensions/vueNodes/widgets/registry/widgetRegistry'
|
||||
import { useNodeDefStore } from '@/stores/nodeDefStore'
|
||||
import {
|
||||
useWidgetValueStore,
|
||||
stripGraphPrefix
|
||||
stripGraphPrefix,
|
||||
useWidgetValueStore
|
||||
} from '@/stores/widgetValueStore'
|
||||
import { useFavoritedWidgetsStore } from '@/stores/workspace/favoritedWidgetsStore'
|
||||
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
|
||||
import { widgetId } from '@/types/widgetId'
|
||||
import { resolveNodeDisplayName } from '@/utils/nodeTitleUtil'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
import { renameWidget } from '@/utils/widgetUtil'
|
||||
@@ -67,35 +67,32 @@ const widgetComponent = computed(() => {
|
||||
return component || WidgetLegacy
|
||||
})
|
||||
|
||||
function resolveSourceWidget(): { node: LGraphNode; widget: IBaseWidget } {
|
||||
const source = resolvePromotedWidgetSource(node, widget)
|
||||
return source ?? { node, widget }
|
||||
}
|
||||
|
||||
const simplifiedWidget = computed((): SimplifiedWidget => {
|
||||
const { node: sourceNode, widget: sourceWidget } = resolveSourceWidget()
|
||||
const graphId = node.graph?.rootGraph?.id
|
||||
const bareNodeId = stripGraphPrefix(String(sourceNode.id))
|
||||
const widgetState = graphId
|
||||
? widgetValueStore.getWidget(graphId, bareNodeId, sourceWidget.name)
|
||||
: undefined
|
||||
const bareNodeId = stripGraphPrefix(String(node.id))
|
||||
const widgetState = widget.widgetId
|
||||
? useWidgetValueStore().getWidget(widget.widgetId)
|
||||
: graphId
|
||||
? widgetValueStore.getWidget(widgetId(graphId, bareNodeId, widget.name))
|
||||
: undefined
|
||||
const widgetName = widgetState?.name ?? widget.name
|
||||
const widgetType = widgetState?.type ?? widget.type
|
||||
|
||||
return {
|
||||
name: widget.name,
|
||||
type: widget.type,
|
||||
name: widgetName,
|
||||
type: widgetType,
|
||||
value: widgetState?.value ?? widget.value,
|
||||
label: widgetState?.label ?? widget.label,
|
||||
options: widgetState?.options ?? widget.options,
|
||||
spec: nodeDefStore.getInputSpecForWidget(sourceNode, sourceWidget.name),
|
||||
controlWidget: getControlWidget(sourceWidget)
|
||||
spec: nodeDefStore.getInputSpecForWidget(node, widgetName),
|
||||
controlWidget: getControlWidget(widget)
|
||||
}
|
||||
})
|
||||
|
||||
const sourceNodeName = computed((): string | null => {
|
||||
const sourceNode = resolvePromotedWidgetSource(node, widget)?.node ?? node
|
||||
if (!sourceNode) return null
|
||||
const displayNodeName = computed((): string | null => {
|
||||
if (!node) return null
|
||||
const fallbackNodeTitle = t('rightSidePanel.fallbackNodeTitle')
|
||||
return resolveNodeDisplayName(sourceNode, {
|
||||
return resolveNodeDisplayName(node, {
|
||||
emptyLabel: fallbackNodeTitle,
|
||||
untitledLabel: fallbackNodeTitle,
|
||||
st
|
||||
@@ -167,10 +164,10 @@ const displayLabel = customRef((track, trigger) => {
|
||||
/>
|
||||
|
||||
<span
|
||||
v-if="(showNodeName || hasParents) && sourceNodeName"
|
||||
v-if="(showNodeName || hasParents) && displayNodeName"
|
||||
class="mx-1 my-0 min-w-10 flex-1 truncate p-0 text-right text-xs text-muted-foreground"
|
||||
>
|
||||
{{ sourceNodeName }}
|
||||
{{ displayNodeName }}
|
||||
</span>
|
||||
<div
|
||||
v-if="!hiddenWidgetActions"
|
||||
|
||||
@@ -14,8 +14,9 @@ import { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { usePreviewExposureStore } from '@/stores/previewExposureStore'
|
||||
|
||||
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
|
||||
import { promotedInputWidget } from '@/core/graph/subgraph/promotedInputWidget'
|
||||
import { promoteValueWidgetViaSubgraphInput } from '@/core/graph/subgraph/promotionUtils'
|
||||
import { resolveSubgraphInputTarget } from '@/core/graph/subgraph/resolveSubgraphInputTarget'
|
||||
import SubgraphEditor from './SubgraphEditor.vue'
|
||||
import type { ComponentProps } from 'vue-component-type-helpers'
|
||||
import type DraggableList from '@/components/common/DraggableList.vue'
|
||||
@@ -167,11 +168,20 @@ describe('SubgraphEditor', () => {
|
||||
.map((el) => el.textContent?.trim())
|
||||
).toEqual(['first', 'second'])
|
||||
|
||||
const promotedWidgets = host.widgets.filter(isPromotedWidgetView)
|
||||
const reversed = [
|
||||
{ kind: 'promoted', node: secondNode, widget: promotedWidgets[1] },
|
||||
{ kind: 'promoted', node: firstNode, widget: promotedWidgets[0] }
|
||||
] as PromotedRow[]
|
||||
const rowFor = (sourceNode: LGraphNode) => {
|
||||
const input = host.inputs.find((input) => {
|
||||
if (!input.widgetId) return false
|
||||
const target = resolveSubgraphInputTarget(host, input.name)
|
||||
return target?.nodeId === String(sourceNode.id)
|
||||
})!
|
||||
return {
|
||||
kind: 'promoted',
|
||||
node: sourceNode,
|
||||
input,
|
||||
widget: promotedInputWidget(input)!
|
||||
}
|
||||
}
|
||||
const reversed = [rowFor(secondNode), rowFor(firstNode)] as PromotedRow[]
|
||||
listSetter?.(reversed)
|
||||
await nextTick()
|
||||
|
||||
@@ -182,6 +192,42 @@ describe('SubgraphEditor', () => {
|
||||
).toEqual(['second', 'first'])
|
||||
})
|
||||
|
||||
it('moves a widget to shown when promoted from the hidden section', async () => {
|
||||
const subgraph = createTestSubgraph()
|
||||
const host = createTestSubgraphNode(subgraph)
|
||||
const sourceNode = new LGraphNode('SourceNode')
|
||||
subgraph.add(sourceNode)
|
||||
|
||||
const sourceInput = sourceNode.addInput('first', 'STRING')
|
||||
const sourceWidget = sourceNode.addWidget('text', 'first', '', () => {})
|
||||
sourceInput.widget = { name: sourceWidget.name }
|
||||
useCanvasStore().selectedItems = [host]
|
||||
|
||||
render(SubgraphEditor, {
|
||||
container: document.body.appendChild(document.createElement('div')),
|
||||
global: {
|
||||
plugins: [i18n],
|
||||
stubs: {
|
||||
DraggableList: {
|
||||
template:
|
||||
'<div data-testid="draggable-list"><slot drag-class="draggable-item" /></div>'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const hidden = screen.getByTestId('subgraph-editor-hidden-section')
|
||||
await userEvent.click(within(hidden).getByTestId('subgraph-widget-toggle'))
|
||||
await nextTick()
|
||||
|
||||
const shown = screen.getByTestId('subgraph-editor-shown-section')
|
||||
expect(
|
||||
within(shown)
|
||||
.getAllByTestId('subgraph-widget-label')
|
||||
.map((el) => el.textContent?.trim())
|
||||
).toEqual(['first'])
|
||||
})
|
||||
|
||||
it('demotes linked promoted widgets when "Hide all" is clicked', async () => {
|
||||
const subgraph = createTestSubgraph()
|
||||
const host = createTestSubgraphNode(subgraph)
|
||||
@@ -213,13 +259,13 @@ describe('SubgraphEditor', () => {
|
||||
}
|
||||
})
|
||||
|
||||
expect(host.widgets.filter(isPromotedWidgetView)).toHaveLength(2)
|
||||
expect(host.inputs.filter((input) => input.widgetId)).toHaveLength(2)
|
||||
|
||||
const shown = screen.getByTestId('subgraph-editor-shown-section')
|
||||
const hideAllLink = within(shown).getByText('Hide all')
|
||||
await userEvent.click(hideAllLink)
|
||||
|
||||
expect(host.widgets.filter(isPromotedWidgetView)).toHaveLength(0)
|
||||
expect(host.inputs.filter((input) => input.widgetId)).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('removes the exposure when a preview row without a real source widget is demoted', async () => {
|
||||
|
||||
@@ -5,9 +5,8 @@ import { computed, onMounted, shallowRef, watch } from 'vue'
|
||||
|
||||
import DraggableList from '@/components/common/DraggableList.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
|
||||
import type { PromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
|
||||
import {
|
||||
demotePromotedInput,
|
||||
demoteWidget,
|
||||
getPromotableWidgets,
|
||||
isLinkedPromotion,
|
||||
@@ -16,8 +15,14 @@ import {
|
||||
pruneDisconnected,
|
||||
reorderSubgraphInputsByWidgetOrder
|
||||
} from '@/core/graph/subgraph/promotionUtils'
|
||||
import {
|
||||
promotedInputSource,
|
||||
promotedInputWidget
|
||||
} from '@/core/graph/subgraph/promotedInputWidget'
|
||||
import type { PromotedSource } from '@/core/graph/subgraph/promotedInputWidget'
|
||||
import type { WidgetItem } from '@/core/graph/subgraph/promotionUtils'
|
||||
import type { PreviewExposure } from '@/core/schemas/previewExposureSchema'
|
||||
import type { INodeInputSlot } from '@/lib/litegraph/src/interfaces'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
@@ -33,7 +38,8 @@ import SubgraphNodeWidget from './SubgraphNodeWidget.vue'
|
||||
type PromotedRow = {
|
||||
kind: 'promoted'
|
||||
node: LGraphNode
|
||||
widget: PromotedWidgetView
|
||||
input: INodeInputSlot
|
||||
widget: IBaseWidget
|
||||
}
|
||||
type PreviewRow = {
|
||||
kind: 'preview'
|
||||
@@ -54,11 +60,23 @@ const activeNode = computed(() => {
|
||||
return undefined
|
||||
})
|
||||
|
||||
const promotedWidgets = shallowRef<readonly IBaseWidget[]>([])
|
||||
function refreshPromotedWidgets() {
|
||||
promotedWidgets.value = activeNode.value?.widgets ?? []
|
||||
const promotedRows = shallowRef<readonly PromotedRow[]>([])
|
||||
function buildPromotedRows(node: SubgraphNode): PromotedRow[] {
|
||||
return node.inputs.flatMap((input): PromotedRow[] => {
|
||||
const widget = promotedInputWidget(input)
|
||||
if (!widget) return []
|
||||
const source = promotedInputSource(node, input)
|
||||
if (!source) return []
|
||||
const sourceNode = node.subgraph._nodes_by_id[source.nodeId]
|
||||
if (!sourceNode) return []
|
||||
return [{ kind: 'promoted', node: sourceNode, input, widget }]
|
||||
})
|
||||
}
|
||||
watch(activeNode, refreshPromotedWidgets, { immediate: true })
|
||||
function refreshPromotedRows() {
|
||||
const node = activeNode.value
|
||||
promotedRows.value = node ? buildPromotedRows(node) : []
|
||||
}
|
||||
watch(activeNode, refreshPromotedRows, { immediate: true })
|
||||
useEventListener(
|
||||
() => activeNode.value?.subgraph.events,
|
||||
[
|
||||
@@ -68,34 +86,29 @@ useEventListener(
|
||||
'removing-input',
|
||||
'inputs-reordered'
|
||||
],
|
||||
refreshPromotedWidgets
|
||||
refreshPromotedRows
|
||||
)
|
||||
|
||||
function promotedRowSource(row: PromotedRow): PromotedSource | undefined {
|
||||
const node = activeNode.value
|
||||
return node ? promotedInputSource(node, row.input) : undefined
|
||||
}
|
||||
|
||||
const activeRows = computed<ActiveRow[]>(() => {
|
||||
const node = activeNode.value
|
||||
if (!node) return []
|
||||
return [...getActivePromotedRows(node), ...getActivePreviewRows(node)]
|
||||
return [...promotedRows.value, ...getActivePreviewRows(node)]
|
||||
})
|
||||
|
||||
const activePromotedRows = computed<PromotedRow[]>({
|
||||
get() {
|
||||
const node = activeNode.value
|
||||
return node ? getActivePromotedRows(node) : []
|
||||
return [...promotedRows.value]
|
||||
},
|
||||
set(value: PromotedRow[]) {
|
||||
updateActivePromotedRows(value, activePromotedRows.value)
|
||||
}
|
||||
})
|
||||
|
||||
function getActivePromotedRows(node: SubgraphNode): PromotedRow[] {
|
||||
return promotedWidgets.value.flatMap((widget): PromotedRow[] => {
|
||||
if (!isPromotedWidgetView(widget)) return []
|
||||
const sourceNode = node.subgraph._nodes_by_id[widget.sourceNodeId]
|
||||
if (!sourceNode) return []
|
||||
return [{ kind: 'promoted', node: sourceNode, widget }]
|
||||
})
|
||||
}
|
||||
|
||||
function getActivePreviewRows(node: SubgraphNode): PreviewRow[] {
|
||||
const hostLocator = String(node.id)
|
||||
const rootGraphId = node.rootGraph.id
|
||||
@@ -130,7 +143,7 @@ function updateActivePromotedRows(
|
||||
if (currentKeys.size === nextKeys.size) {
|
||||
reorderSubgraphInputsByWidgetOrder(
|
||||
node,
|
||||
value.map((row) => row.widget)
|
||||
value.map((row) => ({ widgetId: row.widget.widgetId }))
|
||||
)
|
||||
}
|
||||
refreshPromotedWidgetRendering()
|
||||
@@ -151,9 +164,11 @@ const interiorWidgets = computed<WidgetItem[]>(() => {
|
||||
})
|
||||
|
||||
function activeRowSourceKey(row: ActiveRow): string {
|
||||
return row.kind === 'promoted'
|
||||
? `${row.widget.sourceNodeId}:${row.widget.sourceWidgetName}`
|
||||
: `${row.exposure.sourceNodeId}:${row.exposure.sourcePreviewName}`
|
||||
if (row.kind !== 'promoted')
|
||||
return `${row.exposure.sourceNodeId}:${row.exposure.sourcePreviewName}`
|
||||
|
||||
const source = promotedRowSource(row)
|
||||
return `${source?.nodeId ?? row.node.id}:${source?.widgetName ?? ''}`
|
||||
}
|
||||
|
||||
const candidateWidgets = computed<WidgetItem[]>(() => {
|
||||
@@ -228,18 +243,16 @@ function rowDisplayName(row: ActiveRow): string {
|
||||
function isRowLinked(row: ActiveRow): boolean {
|
||||
if (row.kind !== 'promoted') return false
|
||||
if (row.node.id === -1) return true
|
||||
const source = promotedRowSource(row)
|
||||
return (
|
||||
!!activeNode.value &&
|
||||
isLinkedPromotion(
|
||||
activeNode.value,
|
||||
String(row.node.id),
|
||||
row.widget.sourceWidgetName
|
||||
)
|
||||
!!source &&
|
||||
isLinkedPromotion(activeNode.value, String(row.node.id), source.widgetName)
|
||||
)
|
||||
}
|
||||
|
||||
function promotedRowKey(row: PromotedRow): string {
|
||||
return `${row.node.id}: ${row.widget.name}:${row.widget.sourceNodeId}`
|
||||
return `${row.node.id}: ${row.widget.name}`
|
||||
}
|
||||
|
||||
function rowKey(row: ActiveRow): string {
|
||||
@@ -256,7 +269,14 @@ function demoteRow(row: ActiveRow) {
|
||||
const subgraphNode = activeNode.value
|
||||
if (!subgraphNode) return
|
||||
if (row.kind === 'promoted') {
|
||||
demoteWidget(row.node, row.widget, [subgraphNode])
|
||||
const source = promotedRowSource(row)
|
||||
if (source) {
|
||||
demotePromotedInput(subgraphNode, {
|
||||
sourceNodeId: source.nodeId,
|
||||
sourceWidgetName: source.widgetName
|
||||
})
|
||||
}
|
||||
refreshPromotedWidgetRendering()
|
||||
return
|
||||
}
|
||||
if (row.realWidget) {
|
||||
@@ -274,13 +294,18 @@ function demoteRow(row: ActiveRow) {
|
||||
function promotePromotedRow(row: PromotedRow) {
|
||||
const subgraphNode = activeNode.value
|
||||
if (!subgraphNode) return
|
||||
promoteWidget(row.node, row.widget, [subgraphNode])
|
||||
const source = promotedRowSource(row)
|
||||
const sourceWidget = source
|
||||
? row.node.widgets?.find((widget) => widget.name === source.widgetName)
|
||||
: undefined
|
||||
if (sourceWidget) promoteWidget(row.node, sourceWidget, [subgraphNode])
|
||||
}
|
||||
|
||||
function promoteCandidate([node, widget]: WidgetItem) {
|
||||
const subgraphNode = activeNode.value
|
||||
if (!subgraphNode) return
|
||||
promoteWidget(node, widget, [subgraphNode])
|
||||
refreshPromotedRows()
|
||||
}
|
||||
|
||||
function showAll() {
|
||||
|
||||
@@ -4,12 +4,8 @@ import { setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { installErrorClearingHooks } from '@/composables/graph/useErrorClearingHooks'
|
||||
import { promoteValueWidgetViaSubgraphInput } from '@/core/graph/subgraph/promotionUtils'
|
||||
import { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type {
|
||||
CanvasPointer,
|
||||
CanvasPointerEvent,
|
||||
LGraphCanvas
|
||||
} from '@/lib/litegraph/src/litegraph'
|
||||
import {
|
||||
createTestSubgraph,
|
||||
createTestSubgraphNode
|
||||
@@ -256,273 +252,6 @@ describe('Widget change error clearing via onWidgetChanged', () => {
|
||||
expect(store.lastNodeErrors).toBeNull()
|
||||
expect(mediaStore.missingMediaCandidates).toBeNull()
|
||||
})
|
||||
|
||||
it('uses interior node execution ID for promoted widget error clearing', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'ckpt_input', type: '*' }]
|
||||
})
|
||||
const interiorNode = new LGraphNode('CheckpointLoaderSimple')
|
||||
const interiorInput = interiorNode.addInput('ckpt_input', '*')
|
||||
interiorNode.addWidget(
|
||||
'combo',
|
||||
'ckpt_name',
|
||||
'model.safetensors',
|
||||
() => undefined,
|
||||
{ values: ['model.safetensors'] }
|
||||
)
|
||||
interiorInput.widget = { name: 'ckpt_name' }
|
||||
subgraph.add(interiorNode)
|
||||
subgraph.inputNode.slots[0].connect(interiorInput, interiorNode)
|
||||
|
||||
const subgraphNode = createTestSubgraphNode(subgraph, { id: 65 })
|
||||
subgraphNode._internalConfigureAfterSlots()
|
||||
const graph = subgraphNode.graph as LGraph
|
||||
graph.add(subgraphNode)
|
||||
|
||||
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graph)
|
||||
installErrorClearingHooks(graph)
|
||||
|
||||
const store = useExecutionErrorStore()
|
||||
const interiorExecId = `${subgraphNode.id}:${interiorNode.id}`
|
||||
|
||||
const promotedWidget = subgraphNode.widgets?.find(
|
||||
(w) => 'sourceWidgetName' in w && w.sourceWidgetName === 'ckpt_name'
|
||||
)
|
||||
expect(promotedWidget).toBeDefined()
|
||||
|
||||
seedRequiredInputMissingNodeError(store, interiorExecId, 'ckpt_name')
|
||||
|
||||
subgraphNode.onWidgetChanged!.call(
|
||||
subgraphNode,
|
||||
'ckpt_name',
|
||||
'other_model.safetensors',
|
||||
'model.safetensors',
|
||||
promotedWidget!
|
||||
)
|
||||
|
||||
expect(store.lastNodeErrors).toBeNull()
|
||||
})
|
||||
|
||||
it('clears range errors for promoted widgets by interior widget name', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'steps_input', type: 'INT' }]
|
||||
})
|
||||
const interiorNode = new LGraphNode('KSampler')
|
||||
const interiorInput = interiorNode.addInput('steps_input', 'INT')
|
||||
interiorNode.addWidget('number', 'steps', 150, () => undefined, {
|
||||
min: 1,
|
||||
max: 100
|
||||
})
|
||||
interiorInput.widget = { name: 'steps' }
|
||||
subgraph.add(interiorNode)
|
||||
subgraph.inputNode.slots[0].connect(interiorInput, interiorNode)
|
||||
|
||||
const subgraphNode = createTestSubgraphNode(subgraph, { id: 65 })
|
||||
subgraphNode._internalConfigureAfterSlots()
|
||||
const graph = subgraphNode.graph as LGraph
|
||||
graph.add(subgraphNode)
|
||||
|
||||
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graph)
|
||||
installErrorClearingHooks(graph)
|
||||
|
||||
const store = useExecutionErrorStore()
|
||||
const interiorExecId = `${subgraphNode.id}:${interiorNode.id}`
|
||||
store.lastNodeErrors = {
|
||||
[interiorExecId]: {
|
||||
errors: [
|
||||
{
|
||||
type: 'value_bigger_than_max',
|
||||
message: 'Too big',
|
||||
details: '',
|
||||
extra_info: { input_name: 'steps' }
|
||||
}
|
||||
],
|
||||
dependent_outputs: [],
|
||||
class_type: 'KSampler'
|
||||
}
|
||||
}
|
||||
|
||||
const promotedWidget = subgraphNode.widgets?.find(
|
||||
(w) => 'sourceWidgetName' in w && w.sourceWidgetName === 'steps'
|
||||
)
|
||||
expect(promotedWidget).toBeDefined()
|
||||
|
||||
subgraphNode.onWidgetChanged!.call(
|
||||
subgraphNode,
|
||||
'steps',
|
||||
50,
|
||||
150,
|
||||
promotedWidget!
|
||||
)
|
||||
|
||||
expect(store.lastNodeErrors).toBeNull()
|
||||
})
|
||||
|
||||
it('clears missing model state when a promoted widget changes through the legacy canvas path', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'ckpt_input', type: '*' }]
|
||||
})
|
||||
const interiorNode = new LGraphNode('CheckpointLoaderSimple')
|
||||
interiorNode.type = 'CheckpointLoaderSimple'
|
||||
const interiorInput = interiorNode.addInput('ckpt_input', '*')
|
||||
interiorNode.addWidget(
|
||||
'combo',
|
||||
'ckpt_name',
|
||||
'missing.safetensors',
|
||||
() => undefined,
|
||||
{ values: ['missing.safetensors', 'present.safetensors'] }
|
||||
)
|
||||
interiorInput.widget = { name: 'ckpt_name' }
|
||||
subgraph.add(interiorNode)
|
||||
subgraph.inputNode.slots[0].connect(interiorInput, interiorNode)
|
||||
|
||||
const subgraphNode = createTestSubgraphNode(subgraph, {
|
||||
id: 65,
|
||||
pos: [0, 0],
|
||||
size: [200, 100]
|
||||
})
|
||||
subgraphNode._internalConfigureAfterSlots()
|
||||
const graph = subgraphNode.graph as LGraph
|
||||
graph.add(subgraphNode)
|
||||
|
||||
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graph)
|
||||
installErrorClearingHooks(graph)
|
||||
|
||||
const missingModelStore = useMissingModelStore()
|
||||
const interiorExecId = `${subgraphNode.id}:${interiorNode.id}`
|
||||
missingModelStore.setMissingModels([
|
||||
{
|
||||
nodeId: interiorExecId,
|
||||
nodeType: 'CheckpointLoaderSimple',
|
||||
widgetName: 'ckpt_name',
|
||||
isAssetSupported: false,
|
||||
name: 'missing.safetensors',
|
||||
isMissing: true
|
||||
} satisfies MissingModelCandidate
|
||||
])
|
||||
|
||||
const promotedWidget = subgraphNode.widgets?.find(
|
||||
(widget) =>
|
||||
'sourceWidgetName' in widget && widget.sourceWidgetName === 'ckpt_name'
|
||||
)
|
||||
expect(promotedWidget).toBeDefined()
|
||||
|
||||
const clickEvent = fromAny<CanvasPointerEvent, unknown>({
|
||||
canvasX: 190,
|
||||
canvasY: 20,
|
||||
deltaX: 0
|
||||
})
|
||||
const pointer = fromAny<CanvasPointer, unknown>({
|
||||
eDown: clickEvent
|
||||
})
|
||||
const canvas = fromAny<LGraphCanvas, unknown>({
|
||||
graph_mouse: [190, 20],
|
||||
last_mouseclick: 0
|
||||
})
|
||||
|
||||
const handled = promotedWidget!.onPointerDown?.(
|
||||
pointer,
|
||||
subgraphNode,
|
||||
canvas
|
||||
)
|
||||
expect(handled).toBe(true)
|
||||
expect(pointer.onClick).toBeDefined()
|
||||
|
||||
pointer.onClick?.(clickEvent)
|
||||
|
||||
expect(missingModelStore.missingModelCandidates).toBeNull()
|
||||
})
|
||||
|
||||
it('keeps unchanged same-named promoted model targets on the canvas path', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [
|
||||
{ name: 'first_ckpt', type: '*' },
|
||||
{ name: 'second_ckpt', type: '*' }
|
||||
]
|
||||
})
|
||||
const firstNode = new LGraphNode('CheckpointLoaderSimple')
|
||||
firstNode.type = 'CheckpointLoaderSimple'
|
||||
const firstInput = firstNode.addInput('first_ckpt', '*')
|
||||
const firstWidget = firstNode.addWidget(
|
||||
'combo',
|
||||
'ckpt_name',
|
||||
'missing.safetensors',
|
||||
() => undefined,
|
||||
{ values: ['missing.safetensors', 'present.safetensors'] }
|
||||
)
|
||||
firstInput.widget = { name: 'ckpt_name' }
|
||||
subgraph.add(firstNode)
|
||||
subgraph.inputNode.slots[0].connect(firstInput, firstNode)
|
||||
|
||||
const secondNode = new LGraphNode('CheckpointLoaderSimple')
|
||||
secondNode.type = 'CheckpointLoaderSimple'
|
||||
const secondInput = secondNode.addInput('second_ckpt', '*')
|
||||
secondNode.addWidget(
|
||||
'combo',
|
||||
'ckpt_name',
|
||||
'missing.safetensors',
|
||||
() => undefined,
|
||||
{ values: ['missing.safetensors', 'present.safetensors'] }
|
||||
)
|
||||
secondInput.widget = { name: 'ckpt_name' }
|
||||
subgraph.add(secondNode)
|
||||
subgraph.inputNode.slots[1].connect(secondInput, secondNode)
|
||||
|
||||
const subgraphNode = createTestSubgraphNode(subgraph, { id: 65 })
|
||||
subgraphNode._internalConfigureAfterSlots()
|
||||
const graph = subgraphNode.graph as LGraph
|
||||
graph.add(subgraphNode)
|
||||
|
||||
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graph)
|
||||
installErrorClearingHooks(graph)
|
||||
|
||||
const promotedWidgets =
|
||||
subgraphNode.widgets?.filter(
|
||||
(widget) =>
|
||||
'sourceWidgetName' in widget &&
|
||||
widget.sourceWidgetName === 'ckpt_name'
|
||||
) ?? []
|
||||
expect(promotedWidgets).toHaveLength(2)
|
||||
|
||||
const missingModelStore = useMissingModelStore()
|
||||
const firstExecId = `${subgraphNode.id}:${firstNode.id}`
|
||||
const secondExecId = `${subgraphNode.id}:${secondNode.id}`
|
||||
missingModelStore.setMissingModels([
|
||||
{
|
||||
nodeId: firstExecId,
|
||||
nodeType: 'CheckpointLoaderSimple',
|
||||
widgetName: 'ckpt_name',
|
||||
isAssetSupported: false,
|
||||
name: 'missing.safetensors',
|
||||
isMissing: true
|
||||
} satisfies MissingModelCandidate,
|
||||
{
|
||||
nodeId: secondExecId,
|
||||
nodeType: 'CheckpointLoaderSimple',
|
||||
widgetName: 'ckpt_name',
|
||||
isAssetSupported: false,
|
||||
name: 'missing.safetensors',
|
||||
isMissing: true
|
||||
} satisfies MissingModelCandidate
|
||||
])
|
||||
|
||||
firstWidget.value = 'present.safetensors'
|
||||
subgraphNode.onWidgetChanged!.call(
|
||||
subgraphNode,
|
||||
'ckpt_name',
|
||||
'present.safetensors',
|
||||
'missing.safetensors',
|
||||
firstWidget
|
||||
)
|
||||
|
||||
expect(missingModelStore.missingModelCandidates).toEqual([
|
||||
expect.objectContaining({
|
||||
nodeId: secondExecId,
|
||||
widgetName: 'ckpt_name',
|
||||
name: 'missing.safetensors'
|
||||
})
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('installErrorClearingHooks lifecycle', () => {
|
||||
@@ -1249,4 +978,54 @@ describe('clearWidgetRelatedErrors parameter routing', () => {
|
||||
|
||||
clearSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('clears promoted widget errors by interior execution id', () => {
|
||||
const subgraph = createTestSubgraph()
|
||||
const graph = subgraph.rootGraph
|
||||
const host = createTestSubgraphNode(subgraph, { id: 2 })
|
||||
graph.add(host)
|
||||
|
||||
const interiorNode = new LGraphNode('CheckpointLoaderSimple')
|
||||
interiorNode.id = 1
|
||||
subgraph.add(interiorNode)
|
||||
const input = interiorNode.addInput('ckpt_name', 'COMBO')
|
||||
const widget = interiorNode.addWidget(
|
||||
'combo',
|
||||
'ckpt_name',
|
||||
'fake_model.safetensors',
|
||||
() => undefined,
|
||||
{ values: ['fake_model.safetensors', 'real_model.safetensors'] }
|
||||
)
|
||||
input.widget = { name: widget.name }
|
||||
|
||||
expect(
|
||||
promoteValueWidgetViaSubgraphInput(host, interiorNode, widget).ok
|
||||
).toBe(true)
|
||||
installErrorClearingHooks(graph)
|
||||
|
||||
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graph)
|
||||
const missingModelStore = useMissingModelStore()
|
||||
missingModelStore.setMissingModels([
|
||||
{
|
||||
nodeId: '2:1',
|
||||
nodeType: 'CheckpointLoaderSimple',
|
||||
widgetName: 'ckpt_name',
|
||||
isAssetSupported: false,
|
||||
name: 'fake_model.safetensors',
|
||||
directory: 'checkpoints',
|
||||
isMissing: true
|
||||
}
|
||||
])
|
||||
|
||||
const promotedWidget = host.widgets[0]
|
||||
host.onWidgetChanged!.call(
|
||||
host,
|
||||
promotedWidget.name,
|
||||
'real_model.safetensors',
|
||||
'fake_model.safetensors',
|
||||
promotedWidget
|
||||
)
|
||||
|
||||
expect(missingModelStore.hasMissingModels).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -6,12 +6,9 @@
|
||||
* works in legacy canvas mode as well.
|
||||
*/
|
||||
import { useChainCallback } from '@/composables/functional/useChainCallback'
|
||||
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
|
||||
import type { PromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
|
||||
import { resolveConcretePromotedWidget } from '@/core/graph/subgraph/resolveConcretePromotedWidget'
|
||||
import { widgetPromotedSource } from '@/core/graph/subgraph/promotedInputWidget'
|
||||
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import type { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
import {
|
||||
LGraphEventMode,
|
||||
NodeSlotType
|
||||
@@ -46,130 +43,6 @@ import {
|
||||
isAncestorPathActive
|
||||
} from '@/utils/graphTraversalUtil'
|
||||
|
||||
interface WidgetErrorClearingTarget {
|
||||
executionId: string
|
||||
validationInputName: string
|
||||
assetWidgetName: string
|
||||
currentValue: unknown
|
||||
options?: { min?: number; max?: number }
|
||||
}
|
||||
|
||||
function getWidgetRangeOptions(widget: IBaseWidget): {
|
||||
min?: number
|
||||
max?: number
|
||||
} {
|
||||
return {
|
||||
min: widget.options?.min,
|
||||
max: widget.options?.max
|
||||
}
|
||||
}
|
||||
|
||||
function plainWidgetToErrorTarget(
|
||||
widget: IBaseWidget,
|
||||
hostExecId: string
|
||||
): WidgetErrorClearingTarget {
|
||||
return {
|
||||
executionId: hostExecId,
|
||||
validationInputName: widget.name,
|
||||
assetWidgetName: widget.name,
|
||||
currentValue: widget.value,
|
||||
options: getWidgetRangeOptions(widget)
|
||||
}
|
||||
}
|
||||
|
||||
function promotedWidgetToErrorTarget(
|
||||
rootGraph: LGraph,
|
||||
hostNode: LGraphNode,
|
||||
widget: PromotedWidgetView,
|
||||
hostExecId: string
|
||||
): WidgetErrorClearingTarget {
|
||||
const result = resolveConcretePromotedWidget(
|
||||
hostNode,
|
||||
widget.sourceNodeId,
|
||||
widget.sourceWidgetName
|
||||
)
|
||||
const execId =
|
||||
result.status === 'resolved' && result.resolved.node
|
||||
? (getExecutionIdByNode(rootGraph, result.resolved.node) ?? hostExecId)
|
||||
: hostExecId
|
||||
const resolvedWidget =
|
||||
result.status === 'resolved' ? result.resolved.widget : widget
|
||||
|
||||
return {
|
||||
executionId: execId,
|
||||
validationInputName: resolvedWidget.name,
|
||||
assetWidgetName: widget.sourceWidgetName,
|
||||
currentValue: resolvedWidget.value,
|
||||
options: getWidgetRangeOptions(resolvedWidget)
|
||||
}
|
||||
}
|
||||
|
||||
function resolveCanvasPathPromotedWidgetTargets(
|
||||
rootGraph: LGraph,
|
||||
hostNode: LGraphNode,
|
||||
widget: IBaseWidget,
|
||||
hostExecId: string,
|
||||
newValue: unknown
|
||||
): WidgetErrorClearingTarget[] {
|
||||
if (!hostNode.isSubgraphNode?.() || isPromotedWidgetView(widget)) return []
|
||||
|
||||
// Canvas-path events lose promoted identity, so the post-write value
|
||||
// disambiguates same-named promoted widgets.
|
||||
return (hostNode.widgets ?? [])
|
||||
.filter(isPromotedWidgetView)
|
||||
.filter((promotedWidget) => promotedWidget.sourceWidgetName === widget.name)
|
||||
.map((promotedWidget) =>
|
||||
promotedWidgetToErrorTarget(
|
||||
rootGraph,
|
||||
hostNode,
|
||||
promotedWidget,
|
||||
hostExecId
|
||||
)
|
||||
)
|
||||
.filter((target) => Object.is(target.currentValue, newValue))
|
||||
}
|
||||
|
||||
function resolveWidgetErrorTargets(
|
||||
rootGraph: LGraph,
|
||||
hostNode: LGraphNode,
|
||||
widget: IBaseWidget,
|
||||
hostExecId: string,
|
||||
newValue: unknown
|
||||
): WidgetErrorClearingTarget[] {
|
||||
if (isPromotedWidgetView(widget)) {
|
||||
return [
|
||||
promotedWidgetToErrorTarget(rootGraph, hostNode, widget, hostExecId)
|
||||
]
|
||||
}
|
||||
|
||||
const canvasPathTargets = resolveCanvasPathPromotedWidgetTargets(
|
||||
rootGraph,
|
||||
hostNode,
|
||||
widget,
|
||||
hostExecId,
|
||||
newValue
|
||||
)
|
||||
return canvasPathTargets.length
|
||||
? canvasPathTargets
|
||||
: [plainWidgetToErrorTarget(widget, hostExecId)]
|
||||
}
|
||||
|
||||
function clearWidgetErrorTargets(
|
||||
targets: WidgetErrorClearingTarget[],
|
||||
newValue: unknown
|
||||
): void {
|
||||
const store = useExecutionErrorStore()
|
||||
for (const target of targets) {
|
||||
store.clearWidgetRelatedErrors(
|
||||
target.executionId,
|
||||
target.validationInputName,
|
||||
target.assetWidgetName,
|
||||
newValue,
|
||||
target.options
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const hookedNodes = new WeakSet<LGraphNode>()
|
||||
|
||||
type OriginalCallbacks = {
|
||||
@@ -203,21 +76,24 @@ function installNodeHooks(node: LGraphNode): void {
|
||||
|
||||
node.onWidgetChanged = useChainCallback(
|
||||
node.onWidgetChanged,
|
||||
// _name is the LiteGraph callback arg; re-derive from the widget
|
||||
// object to handle promoted widgets where sourceWidgetName differs.
|
||||
function (_name, newValue, _oldValue, widget) {
|
||||
if (!app.rootGraph) return
|
||||
const hostExecId = getExecutionIdByNode(app.rootGraph, node)
|
||||
if (!hostExecId) return
|
||||
|
||||
const targets = resolveWidgetErrorTargets(
|
||||
app.rootGraph,
|
||||
node,
|
||||
widget,
|
||||
hostExecId,
|
||||
newValue
|
||||
const promotedSource = widgetPromotedSource(node, widget)
|
||||
const executionId = promotedSource
|
||||
? `${hostExecId}:${promotedSource.nodeId}`
|
||||
: hostExecId
|
||||
const widgetName = promotedSource?.widgetName ?? widget.name
|
||||
|
||||
useExecutionErrorStore().clearWidgetRelatedErrors(
|
||||
executionId,
|
||||
widgetName,
|
||||
widgetName,
|
||||
newValue,
|
||||
{ min: widget.options?.min, max: widget.options?.max }
|
||||
)
|
||||
clearWidgetErrorTargets(targets, newValue)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { fromAny } from '@total-typescript/shoehorn'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { computed, nextTick, watch } from 'vue'
|
||||
|
||||
import { useGraphNodeManager } from '@/composables/graph/useGraphNodeManager'
|
||||
import { createPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetView'
|
||||
import { BaseWidget, LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { widgetEntityId } from '@/world/entityIds'
|
||||
import { widgetId } from '@/types/widgetId'
|
||||
import {
|
||||
createTestSubgraph,
|
||||
createTestSubgraphNode
|
||||
@@ -47,9 +45,10 @@ describe('Node Reactivity', () => {
|
||||
expect((widget as BaseWidget).node.id).toBe(node.id)
|
||||
|
||||
// Initial value should be in store after setNodeId was called
|
||||
expect(store.getWidget(graph.id, node.id, 'testnum')?.value).toBe(2)
|
||||
const id = widgetId(graph.id, node.id, 'testnum')
|
||||
expect(store.getWidget(id)?.value).toBe(2)
|
||||
|
||||
const state = store.getWidget(graph.id, node.id, 'testnum')
|
||||
const state = store.getWidget(id)
|
||||
if (!state) throw new Error('Expected widget state to exist')
|
||||
|
||||
const onValueChange = vi.fn()
|
||||
@@ -74,7 +73,7 @@ describe('Node Reactivity', () => {
|
||||
})
|
||||
await nextTick()
|
||||
|
||||
const state = store.getWidget(graph.id, node.id, 'testnum')
|
||||
const state = store.getWidget(widgetId(graph.id, node.id, 'testnum'))
|
||||
if (!state) throw new Error('Expected widget state to exist')
|
||||
|
||||
const widgetValue = computed(() => state.value)
|
||||
@@ -211,105 +210,32 @@ describe('Widget slotMetadata reactivity on link disconnect', () => {
|
||||
expect(widgetData?.slotMetadata?.linked).toBe(true)
|
||||
})
|
||||
|
||||
it('resolves slotMetadata for promoted widgets where SafeWidgetData.name differs from input.widget.name', () => {
|
||||
// Set up a subgraph with an interior node that has a "prompt" widget.
|
||||
// createPromotedWidgetView resolves against this interior node.
|
||||
const subgraph = createTestSubgraph()
|
||||
it('names promoted widgets after the subgraph input slot and exposes the interior source name', () => {
|
||||
// Subgraph input named "value" promotes an interior "prompt" widget. The
|
||||
// projected widget's name is the input slot name "value"; the interior
|
||||
// source widget name "prompt" is carried separately for backend lookups.
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'value', type: 'STRING' }]
|
||||
})
|
||||
const interiorNode = new LGraphNode('interior')
|
||||
interiorNode.id = 10
|
||||
const interiorInput = interiorNode.addInput('value', 'STRING')
|
||||
interiorNode.addWidget('string', 'prompt', 'hello', () => undefined, {})
|
||||
interiorInput.widget = { name: 'prompt' }
|
||||
subgraph.add(interiorNode)
|
||||
subgraph.inputNode.slots[0].connect(interiorInput, interiorNode)
|
||||
|
||||
const subgraphNode = createTestSubgraphNode(subgraph, { id: 123 })
|
||||
|
||||
// Create a PromotedWidgetView with identityName="value" (subgraph input
|
||||
// slot name) and sourceWidgetName="prompt" (interior widget name).
|
||||
// PromotedWidgetView.name returns "value" (identity), safeWidgetMapper
|
||||
// sets SafeWidgetData.name to sourceWidgetName ("prompt").
|
||||
const promotedView = createPromotedWidgetView(
|
||||
subgraphNode,
|
||||
'10',
|
||||
'prompt',
|
||||
'value',
|
||||
'value'
|
||||
)
|
||||
|
||||
// Host the promoted view on a regular node so we can control widgets
|
||||
// directly (SubgraphNode.widgets is a synthetic getter).
|
||||
const graph = new LGraph()
|
||||
const hostNode = new LGraphNode('host')
|
||||
hostNode.widgets = [promotedView]
|
||||
const input = hostNode.addInput('value', 'STRING')
|
||||
input.widget = { name: 'value' }
|
||||
graph.add(hostNode)
|
||||
|
||||
const { vueNodeData } = useGraphNodeManager(graph)
|
||||
const nodeData = vueNodeData.get(String(hostNode.id))
|
||||
|
||||
// SafeWidgetData.name is "prompt" (sourceWidgetName), but the
|
||||
// input slot widget name is "value" — slotName bridges this gap.
|
||||
const widgetData = nodeData?.widgets?.find((w) => w.name === 'prompt')
|
||||
expect(widgetData).toBeDefined()
|
||||
expect(widgetData?.slotName).toBe('value')
|
||||
expect(widgetData?.slotMetadata).toBeDefined()
|
||||
})
|
||||
|
||||
it('prefers exact _widget input matches before same-name fallbacks for promoted widgets', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [
|
||||
{ name: 'seed', type: '*' },
|
||||
{ name: 'seed', type: '*' }
|
||||
]
|
||||
})
|
||||
|
||||
const firstNode = new LGraphNode('FirstNode')
|
||||
const firstInput = firstNode.addInput('seed', '*')
|
||||
firstNode.addWidget('number', 'seed', 1, () => undefined, {})
|
||||
firstInput.widget = { name: 'seed' }
|
||||
subgraph.add(firstNode)
|
||||
|
||||
const secondNode = new LGraphNode('SecondNode')
|
||||
const secondInput = secondNode.addInput('seed', '*')
|
||||
secondNode.addWidget('number', 'seed', 2, () => undefined, {})
|
||||
secondInput.widget = { name: 'seed' }
|
||||
subgraph.add(secondNode)
|
||||
|
||||
subgraph.inputNode.slots[0].connect(firstInput, firstNode)
|
||||
subgraph.inputNode.slots[1].connect(secondInput, secondNode)
|
||||
|
||||
const subgraphNode = createTestSubgraphNode(subgraph, { id: 124 })
|
||||
const graph = subgraphNode.graph
|
||||
if (!graph) throw new Error('Expected subgraph node graph')
|
||||
subgraphNode._internalConfigureAfterSlots()
|
||||
const graph = subgraphNode.graph as LGraph
|
||||
graph.add(subgraphNode)
|
||||
|
||||
const promotedViews = subgraphNode.widgets
|
||||
const secondPromotedView = promotedViews[1]
|
||||
if (!secondPromotedView) throw new Error('Expected second promoted view')
|
||||
|
||||
fromAny<
|
||||
{
|
||||
sourceNodeId: string
|
||||
sourceWidgetName: string
|
||||
},
|
||||
unknown
|
||||
>(secondPromotedView).sourceNodeId = '9999'
|
||||
fromAny<
|
||||
{
|
||||
sourceNodeId: string
|
||||
sourceWidgetName: string
|
||||
},
|
||||
unknown
|
||||
>(secondPromotedView).sourceWidgetName = 'stale_widget'
|
||||
|
||||
const { vueNodeData } = useGraphNodeManager(graph)
|
||||
const nodeData = vueNodeData.get(String(subgraphNode.id))
|
||||
const secondMappedWidget = nodeData?.widgets?.find(
|
||||
(widget) => widget.slotMetadata?.index === 1
|
||||
)
|
||||
if (!secondMappedWidget)
|
||||
throw new Error('Expected mapped widget for slot 1')
|
||||
|
||||
expect(secondMappedWidget.name).not.toBe('stale_widget')
|
||||
const widgetData = nodeData?.widgets?.find((w) => w.name === 'value')
|
||||
expect(widgetData).toBeDefined()
|
||||
expect(widgetData?.sourceWidgetName).toBe('prompt')
|
||||
expect(widgetData?.slotMetadata).toBeDefined()
|
||||
})
|
||||
|
||||
it('clears stale slotMetadata when input no longer matches widget', async () => {
|
||||
@@ -448,8 +374,8 @@ describe('Nested promoted widget mapping', () => {
|
||||
|
||||
expect(mappedWidget).toBeDefined()
|
||||
expect(mappedWidget?.type).toBe('combo')
|
||||
expect(mappedWidget?.entityId).toBe(
|
||||
widgetEntityId(graph.id, subgraphNodeB.id, 'b_input')
|
||||
expect(mappedWidget?.widgetId).toBe(
|
||||
widgetId(graph.id, subgraphNodeB.id, 'b_input')
|
||||
)
|
||||
})
|
||||
|
||||
@@ -484,13 +410,13 @@ describe('Nested promoted widget mapping', () => {
|
||||
const widgets = nodeData?.widgets
|
||||
|
||||
expect(widgets).toHaveLength(2)
|
||||
expect(widgets?.[0]?.entityId).toBe(
|
||||
widgetEntityId(graph.id, subgraphNode.id, 'first_seed')
|
||||
expect(widgets?.[0]?.widgetId).toBe(
|
||||
widgetId(graph.id, subgraphNode.id, 'first_seed')
|
||||
)
|
||||
expect(widgets?.[1]?.entityId).toBe(
|
||||
widgetEntityId(graph.id, subgraphNode.id, 'second_seed')
|
||||
expect(widgets?.[1]?.widgetId).toBe(
|
||||
widgetId(graph.id, subgraphNode.id, 'second_seed')
|
||||
)
|
||||
expect(widgets?.[0]?.entityId).not.toBe(widgets?.[1]?.entityId)
|
||||
expect(widgets?.[0]?.widgetId).not.toBe(widgets?.[1]?.widgetId)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -528,10 +454,11 @@ describe('Promoted widget sourceExecutionId', () => {
|
||||
const { vueNodeData } = useGraphNodeManager(graph)
|
||||
const nodeData = vueNodeData.get(String(subgraphNode.id))
|
||||
const promotedWidget = nodeData?.widgets?.find(
|
||||
(w) => w.name === 'ckpt_name'
|
||||
(w) => w.name === 'ckpt_input'
|
||||
)
|
||||
|
||||
expect(promotedWidget).toBeDefined()
|
||||
expect(promotedWidget?.sourceWidgetName).toBe('ckpt_name')
|
||||
// The interior node is inside subgraphNode (id=65),
|
||||
// so its execution ID should be "65:<interiorNodeId>"
|
||||
expect(promotedWidget?.sourceExecutionId).toBe(
|
||||
|
||||
@@ -3,17 +3,16 @@
|
||||
* Provides event-driven reactivity with performance optimizations
|
||||
*/
|
||||
import { reactiveComputed } from '@vueuse/core'
|
||||
import cloneDeep from 'es-toolkit/compat/cloneDeep'
|
||||
import { reactive, shallowReactive } from 'vue'
|
||||
|
||||
import { useChainCallback } from '@/composables/functional/useChainCallback'
|
||||
import type { PromotedWidgetSource } from '@/core/graph/subgraph/promotedWidgetTypes'
|
||||
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
|
||||
import { matchPromotedInput } from '@/core/graph/subgraph/matchPromotedInput'
|
||||
import {
|
||||
resolveConcretePromotedWidget,
|
||||
resolvePromotedWidgetSource
|
||||
} from '@/core/graph/subgraph/resolveConcretePromotedWidget'
|
||||
import { resolveSubgraphInputTarget } from '@/core/graph/subgraph/resolveSubgraphInputTarget'
|
||||
inputForWidget,
|
||||
promotedInputSource,
|
||||
promotedInputWidgets
|
||||
} from '@/core/graph/subgraph/promotedInputWidget'
|
||||
import { resolveConcretePromotedWidget } from '@/core/graph/subgraph/resolveConcretePromotedWidget'
|
||||
import type {
|
||||
INodeInputSlot,
|
||||
INodeOutputSlot
|
||||
@@ -27,10 +26,11 @@ import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
|
||||
import { isDOMWidget } from '@/scripts/domWidget'
|
||||
import { IS_CONTROL_WIDGET } from '@/scripts/widgets'
|
||||
import { useNodeDefStore } from '@/stores/nodeDefStore'
|
||||
import { useWidgetValueStore } from '@/stores/widgetValueStore'
|
||||
import type { WidgetValue, SafeControlWidget } from '@/types/simplifiedWidget'
|
||||
import { normalizeControlOption } from '@/types/simplifiedWidget'
|
||||
import { getWidgetEntityIdForNode } from '@/utils/litegraphUtil'
|
||||
import type { WidgetEntityId } from '@/world/entityIds'
|
||||
import { getWidgetIdForNode } from '@/utils/litegraphUtil'
|
||||
import type { WidgetId } from '@/types/widgetId'
|
||||
|
||||
import type {
|
||||
LGraph,
|
||||
@@ -38,7 +38,8 @@ import type {
|
||||
LGraphNode,
|
||||
LGraphTriggerAction,
|
||||
LGraphTriggerEvent,
|
||||
LGraphTriggerParam
|
||||
LGraphTriggerParam,
|
||||
SubgraphNode
|
||||
} from '@/lib/litegraph/src/litegraph'
|
||||
import type { TitleMode } from '@/lib/litegraph/src/types/globalEnums'
|
||||
import { NodeSlotType } from '@/lib/litegraph/src/types/globalEnums'
|
||||
@@ -60,7 +61,7 @@ type Badges = (LGraphBadge | (() => LGraphBadge))[]
|
||||
* Value and metadata (label, hidden, disabled, etc.) are accessed via widgetValueStore.
|
||||
*/
|
||||
export interface SafeWidgetData {
|
||||
entityId?: WidgetEntityId
|
||||
widgetId?: WidgetId
|
||||
nodeId?: NodeId
|
||||
name: string
|
||||
type: string
|
||||
@@ -81,17 +82,12 @@ export interface SafeWidgetData {
|
||||
advanced?: boolean
|
||||
hidden?: boolean
|
||||
read_only?: boolean
|
||||
values?: unknown
|
||||
}
|
||||
/** Input specification from node definition */
|
||||
spec?: InputSpec
|
||||
/** Input slot metadata (index and link status) */
|
||||
slotMetadata?: WidgetSlotMetadata
|
||||
/**
|
||||
* Original LiteGraph widget name used for slot metadata matching.
|
||||
* For promoted widgets, `name` is `sourceWidgetName` (interior widget name)
|
||||
* which differs from the subgraph node's input slot widget name.
|
||||
*/
|
||||
slotName?: string
|
||||
/**
|
||||
* Execution ID of the interior node that owns the source widget.
|
||||
* Only set for promoted widgets where the source node differs from the
|
||||
@@ -99,10 +95,14 @@ export interface SafeWidgetData {
|
||||
* execution ID (e.g. `"65:42"` vs the host node's `"65"`).
|
||||
*/
|
||||
sourceExecutionId?: string
|
||||
/**
|
||||
* Interior source widget name. Only set for promoted widgets, where `name`
|
||||
* is the host input slot name; missing-model lookups key by the interior
|
||||
* widget name, which can differ from the slot name (e.g. after a rename).
|
||||
*/
|
||||
sourceWidgetName?: string
|
||||
/** Tooltip text from the resolved widget. */
|
||||
tooltip?: string
|
||||
/** For promoted widgets, the display label from the subgraph input slot. */
|
||||
promotedLabel?: string
|
||||
}
|
||||
|
||||
export interface VueNodeData {
|
||||
@@ -143,18 +143,6 @@ export interface GraphNodeManager {
|
||||
cleanup(): void
|
||||
}
|
||||
|
||||
function isPromotedDOMWidget(widget: IBaseWidget): boolean {
|
||||
if (!isPromotedWidgetView(widget)) return false
|
||||
const sourceWidget = resolvePromotedWidgetSource(widget.node, widget)
|
||||
if (!sourceWidget) return false
|
||||
|
||||
const innerWidget = sourceWidget.widget
|
||||
return (
|
||||
('element' in innerWidget && !!innerWidget.element) ||
|
||||
('component' in innerWidget && !!innerWidget.component)
|
||||
)
|
||||
}
|
||||
|
||||
export function getControlWidget(
|
||||
widget: IBaseWidget
|
||||
): SafeControlWidget | undefined {
|
||||
@@ -214,73 +202,83 @@ function normalizeWidgetValue(value: unknown): WidgetValue {
|
||||
return undefined
|
||||
}
|
||||
|
||||
function extractWidgetDisplayOptions(
|
||||
widget: IBaseWidget
|
||||
): SafeWidgetData['options'] {
|
||||
if (!widget.options) return undefined
|
||||
|
||||
return {
|
||||
canvasOnly: widget.options.canvasOnly,
|
||||
advanced: widget.options?.advanced ?? widget.advanced,
|
||||
hidden: widget.options.hidden,
|
||||
read_only: widget.options.read_only
|
||||
}
|
||||
}
|
||||
|
||||
function isDOMBackedWidget(widget: IBaseWidget): boolean {
|
||||
return (
|
||||
('element' in widget && !!widget.element) ||
|
||||
('component' in widget && !!widget.component)
|
||||
)
|
||||
}
|
||||
|
||||
interface PromotedWidgetMetadata {
|
||||
controlWidget?: SafeControlWidget
|
||||
isDOMWidget: boolean
|
||||
sourceExecutionId?: string
|
||||
sourceWidgetName?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the interior source of a promoted subgraph input to derive the
|
||||
* metadata that backend lookups key by (execution ID, interior widget name)
|
||||
* plus the source widget's control + DOM nature. Also seeds host widget state
|
||||
* if it is somehow missing. Returns undefined when the widget is not promoted.
|
||||
*/
|
||||
function resolvePromotedMetadata(
|
||||
node: SubgraphNode,
|
||||
widget: IBaseWidget
|
||||
): PromotedWidgetMetadata | undefined {
|
||||
const input = inputForWidget(node, widget)
|
||||
if (!input?.widgetId) return undefined
|
||||
const source = promotedInputSource(node, input)
|
||||
if (!source) return undefined
|
||||
|
||||
const resolution = resolveConcretePromotedWidget(
|
||||
node,
|
||||
source.nodeId,
|
||||
source.widgetName
|
||||
)
|
||||
const resolved =
|
||||
resolution.status === 'resolved' ? resolution.resolved : undefined
|
||||
const sourceWidget = resolved?.widget
|
||||
const sourceNode = resolved?.node
|
||||
|
||||
ensurePromotedHostWidgetState(input.widgetId, input, sourceWidget)
|
||||
|
||||
return {
|
||||
controlWidget: sourceWidget ? getControlWidget(sourceWidget) : undefined,
|
||||
isDOMWidget: sourceWidget ? isDOMBackedWidget(sourceWidget) : false,
|
||||
sourceExecutionId:
|
||||
sourceNode && app.rootGraph
|
||||
? (getExecutionIdByNode(app.rootGraph, sourceNode) ?? undefined)
|
||||
: undefined,
|
||||
sourceWidgetName: sourceWidget?.name
|
||||
}
|
||||
}
|
||||
|
||||
function safeWidgetMapper(
|
||||
node: LGraphNode,
|
||||
slotMetadata: Map<string, WidgetSlotMetadata>
|
||||
): (widget: IBaseWidget) => SafeWidgetData {
|
||||
function extractWidgetDisplayOptions(
|
||||
widget: IBaseWidget
|
||||
): SafeWidgetData['options'] {
|
||||
if (!widget.options) return undefined
|
||||
|
||||
return {
|
||||
canvasOnly: widget.options.canvasOnly,
|
||||
advanced: widget.options?.advanced ?? widget.advanced,
|
||||
hidden: widget.options.hidden,
|
||||
read_only: widget.options.read_only
|
||||
}
|
||||
}
|
||||
|
||||
function resolvePromotedSourceByInputName(
|
||||
inputName: string
|
||||
): PromotedWidgetSource | null {
|
||||
const resolvedTarget = resolveSubgraphInputTarget(node, inputName)
|
||||
if (!resolvedTarget) return null
|
||||
|
||||
return {
|
||||
sourceNodeId: resolvedTarget.nodeId,
|
||||
sourceWidgetName: resolvedTarget.widgetName
|
||||
}
|
||||
}
|
||||
|
||||
function resolvePromotedWidgetIdentity(widget: IBaseWidget): {
|
||||
displayName: string
|
||||
promotedSource: PromotedWidgetSource | null
|
||||
} {
|
||||
if (!isPromotedWidgetView(widget)) {
|
||||
return {
|
||||
displayName: widget.name,
|
||||
promotedSource: null
|
||||
}
|
||||
}
|
||||
|
||||
const matchedInput = matchPromotedInput(node.inputs, widget)
|
||||
const promotedInputName = matchedInput?.name
|
||||
const displayName = promotedInputName ?? widget.name
|
||||
const directSource: PromotedWidgetSource = {
|
||||
sourceNodeId: widget.sourceNodeId,
|
||||
sourceWidgetName: widget.sourceWidgetName
|
||||
}
|
||||
const promotedSource =
|
||||
matchedInput?._widget === widget
|
||||
? (resolvePromotedSourceByInputName(displayName) ?? directSource)
|
||||
: directSource
|
||||
|
||||
return {
|
||||
displayName,
|
||||
promotedSource
|
||||
}
|
||||
}
|
||||
const duplicateIndexByKey = new Map<string, number>()
|
||||
|
||||
return function (widget) {
|
||||
try {
|
||||
const { displayName, promotedSource } =
|
||||
resolvePromotedWidgetIdentity(widget)
|
||||
|
||||
// Get shared enhancements (controlWidget, spec, nodeType)
|
||||
const sharedEnhancements = getSharedWidgetEnhancements(node, widget)
|
||||
const slotInfo =
|
||||
slotMetadata.get(displayName) ?? slotMetadata.get(widget.name)
|
||||
const duplicateKey = `${widget.name}:${widget.type}`
|
||||
const duplicateIndex = duplicateIndexByKey.get(duplicateKey) ?? 0
|
||||
duplicateIndexByKey.set(duplicateKey, duplicateIndex + 1)
|
||||
const slotInfo = slotMetadata.get(widget.name)
|
||||
|
||||
// Wrapper callback specific to Nodes 2.0 rendering
|
||||
const callback = (v: unknown) => {
|
||||
@@ -294,67 +292,26 @@ function safeWidgetMapper(
|
||||
node.widgets?.forEach((w) => w.triggerDraw?.())
|
||||
}
|
||||
|
||||
const isPromotedPseudoWidget =
|
||||
isPromotedWidgetView(widget) && widget.sourceWidgetName.startsWith('$$')
|
||||
|
||||
// Extract only render-critical options (canvasOnly, advanced, read_only)
|
||||
const options = extractWidgetDisplayOptions(widget)
|
||||
const subgraphId = node.isSubgraphNode() && node.subgraph.id
|
||||
|
||||
const resolvedSourceResult =
|
||||
isPromotedWidgetView(widget) && promotedSource
|
||||
? resolveConcretePromotedWidget(
|
||||
node,
|
||||
promotedSource.sourceNodeId,
|
||||
promotedSource.sourceWidgetName
|
||||
)
|
||||
: null
|
||||
const resolvedSource =
|
||||
resolvedSourceResult?.status === 'resolved'
|
||||
? resolvedSourceResult.resolved
|
||||
: undefined
|
||||
const sourceWidget = resolvedSource?.widget
|
||||
const sourceNode = resolvedSource?.node
|
||||
|
||||
const effectiveWidget = sourceWidget ?? widget
|
||||
|
||||
const localId = isPromotedWidgetView(widget)
|
||||
? String(sourceNode?.id ?? promotedSource?.sourceNodeId)
|
||||
const promoted = node.isSubgraphNode()
|
||||
? resolvePromotedMetadata(node, widget)
|
||||
: undefined
|
||||
const nodeId =
|
||||
subgraphId && localId ? `${subgraphId}:${localId}` : undefined
|
||||
const sourceWidgetName = isPromotedWidgetView(widget)
|
||||
? (sourceWidget?.name ?? promotedSource?.sourceWidgetName)
|
||||
: undefined
|
||||
const name = sourceWidgetName ?? displayName
|
||||
|
||||
if (isPromotedWidgetView(widget)) widget.ensureHostWidgetState()
|
||||
|
||||
return {
|
||||
entityId: getWidgetEntityIdForNode(node, widget),
|
||||
nodeId,
|
||||
name,
|
||||
type: effectiveWidget.type,
|
||||
...sharedEnhancements,
|
||||
widgetId: getWidgetIdForNode(node, widget, duplicateIndex),
|
||||
name: widget.name,
|
||||
type: widget.type,
|
||||
...getSharedWidgetEnhancements(node, widget),
|
||||
...(promoted?.controlWidget && {
|
||||
controlWidget: promoted.controlWidget
|
||||
}),
|
||||
callback,
|
||||
hasLayoutSize: typeof effectiveWidget.computeLayoutSize === 'function',
|
||||
isDOMWidget: isDOMWidget(widget) || isPromotedDOMWidget(widget),
|
||||
options: isPromotedPseudoWidget
|
||||
? {
|
||||
...(extractWidgetDisplayOptions(effectiveWidget) ?? options),
|
||||
canvasOnly: true
|
||||
}
|
||||
: (extractWidgetDisplayOptions(effectiveWidget) ?? options),
|
||||
hasLayoutSize: typeof widget.computeLayoutSize === 'function',
|
||||
isDOMWidget: promoted?.isDOMWidget ?? isDOMWidget(widget),
|
||||
options: extractWidgetDisplayOptions(widget),
|
||||
slotMetadata: slotInfo,
|
||||
// For promoted widgets, name is sourceWidgetName while widget.name
|
||||
// is the subgraph input slot name — store the slot name for lookups.
|
||||
slotName: name !== widget.name ? widget.name : undefined,
|
||||
sourceExecutionId:
|
||||
sourceNode && app.rootGraph
|
||||
? (getExecutionIdByNode(app.rootGraph, sourceNode) ?? undefined)
|
||||
: undefined,
|
||||
tooltip: widget.tooltip,
|
||||
promotedLabel: isPromotedWidgetView(widget) ? widget.label : undefined
|
||||
sourceExecutionId: promoted?.sourceExecutionId,
|
||||
sourceWidgetName: promoted?.sourceWidgetName,
|
||||
tooltip: widget.tooltip
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
@@ -370,6 +327,24 @@ function safeWidgetMapper(
|
||||
}
|
||||
}
|
||||
|
||||
function ensurePromotedHostWidgetState(
|
||||
id: WidgetId,
|
||||
input: INodeInputSlot,
|
||||
sourceWidget: IBaseWidget | undefined
|
||||
): void {
|
||||
if (!sourceWidget) return
|
||||
const store = useWidgetValueStore()
|
||||
if (store.getWidget(id)) return
|
||||
store.registerWidget(id, {
|
||||
type: sourceWidget.type,
|
||||
value: sourceWidget.value,
|
||||
options: cloneDeep(sourceWidget.options ?? {}),
|
||||
label: input.label ?? input.name,
|
||||
serialize: sourceWidget.serialize,
|
||||
disabled: sourceWidget.disabled
|
||||
})
|
||||
}
|
||||
|
||||
function buildSlotMetadata(
|
||||
inputs: INodeInputSlot[] | undefined,
|
||||
graphRef: LGraph | null | undefined
|
||||
@@ -471,14 +446,16 @@ export function extractVueNodeData(node: LGraphNode): VueNodeData {
|
||||
})
|
||||
|
||||
const safeWidgets = reactiveComputed<SafeWidgetData[]>(() => {
|
||||
const widgetsSnapshot = node.widgets ?? []
|
||||
|
||||
const freshMetadata = buildSlotMetadata(node.inputs, node.graph)
|
||||
slotMetadata.clear()
|
||||
for (const [key, value] of freshMetadata) {
|
||||
slotMetadata.set(key, value)
|
||||
}
|
||||
return widgetsSnapshot.map(safeWidgetMapper(node, slotMetadata))
|
||||
|
||||
const widgets = node.isSubgraphNode()
|
||||
? promotedInputWidgets(node)
|
||||
: (node.widgets ?? [])
|
||||
return widgets.map(safeWidgetMapper(node, slotMetadata))
|
||||
})
|
||||
|
||||
const nodeType =
|
||||
@@ -534,7 +511,7 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
|
||||
|
||||
// Update only widgets with new slot metadata, keeping other widget data intact
|
||||
for (const widget of currentData.widgets ?? []) {
|
||||
widget.slotMetadata = slotMetadata.get(widget.slotName ?? widget.name)
|
||||
widget.slotMetadata = slotMetadata.get(widget.name)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -812,7 +789,7 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
|
||||
if (slotLabelEvent.slotType !== NodeSlotType.INPUT && nodeRef.outputs) {
|
||||
nodeRef.outputs = [...nodeRef.outputs]
|
||||
}
|
||||
// Re-extract widget data so promotedLabel reflects the rename
|
||||
// Re-extract widget data so the label reflects the rename
|
||||
vueNodeData.set(nodeId, extractVueNodeData(nodeRef))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { computed, ref } from 'vue'
|
||||
import type { Ref } from 'vue'
|
||||
|
||||
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
|
||||
import type {
|
||||
LGraphGroup,
|
||||
LGraphNode,
|
||||
@@ -265,16 +264,8 @@ export function useMoreOptionsMenu() {
|
||||
options.push(...getImageMenuOptions(selectedNodes.value[0]))
|
||||
options.push({ type: 'divider' })
|
||||
}
|
||||
const [widgetName, nodeId] = hoveredWidget.value ?? []
|
||||
const widget =
|
||||
nodeId !== undefined
|
||||
? node?.widgets?.find(
|
||||
(w) =>
|
||||
isPromotedWidgetView(w) &&
|
||||
w.sourceWidgetName === widgetName &&
|
||||
w.sourceNodeId === nodeId
|
||||
)
|
||||
: node?.widgets?.find((w) => w.name === widgetName)
|
||||
const [widgetName] = hoveredWidget.value ?? []
|
||||
const widget = node?.widgets?.find((w) => w.name === widgetName)
|
||||
if (widget) {
|
||||
const widgetOptions = convertContextMenuToOptions(
|
||||
getExtraOptionsForWidget(node, widget)
|
||||
|
||||
@@ -1,11 +1,21 @@
|
||||
import { describe, expect, vi } from 'vitest'
|
||||
|
||||
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { useWidgetValueStore } from '@/stores/widgetValueStore'
|
||||
|
||||
import { subgraphTest } from '@/lib/litegraph/src/subgraph/__fixtures__/subgraphFixtures'
|
||||
|
||||
import { usePriceBadge } from '@/composables/node/usePriceBadge'
|
||||
|
||||
const getNodeDisplayPrice = vi.fn(
|
||||
(_node: LGraphNode, overrides?: ReadonlyMap<string, unknown>) =>
|
||||
String(overrides?.get('prompt') ?? 'missing override')
|
||||
)
|
||||
|
||||
vi.mock('@/composables/node/useNodePricing', () => ({
|
||||
useNodePricing: () => ({ getNodeDisplayPrice })
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/workspace/colorPaletteStore', () => ({
|
||||
useColorPaletteStore: () => ({
|
||||
completedActivePalette: {
|
||||
@@ -54,4 +64,43 @@ describe('subgraph pricing', () => {
|
||||
expect(getBadgeText(subgraphNode)).toBe('Partner Nodes x 5')
|
||||
}
|
||||
)
|
||||
|
||||
subgraphTest(
|
||||
'uses promoted widget override from any matching internal link',
|
||||
({ subgraphWithNode }) => {
|
||||
const { subgraphNode, subgraph } = subgraphWithNode
|
||||
class ApiNode extends LGraphNode {
|
||||
static override nodeData = { name: 'ApiNode', api_node: true }
|
||||
}
|
||||
const apiNode = new ApiNode('api node')
|
||||
apiNode.badges = [getCreditsBadge('$0.05/Run')]
|
||||
const apiInput = apiNode.addInput('prompt', 'STRING')
|
||||
apiInput.widget = { name: 'prompt' }
|
||||
apiNode.addWidget('string', 'prompt', 'inner value', () => undefined, {})
|
||||
|
||||
const decoyNode = new LGraphNode('decoy node')
|
||||
const decoyInput = decoyNode.addInput('prompt', 'STRING')
|
||||
decoyInput.widget = { name: 'prompt' }
|
||||
decoyNode.addWidget(
|
||||
'string',
|
||||
'prompt',
|
||||
'decoy value',
|
||||
() => undefined,
|
||||
{}
|
||||
)
|
||||
|
||||
subgraph.add(decoyNode)
|
||||
subgraph.add(apiNode)
|
||||
subgraph.inputNode.slots[0].connect(decoyInput, decoyNode)
|
||||
subgraph.inputNode.slots[0].connect(apiInput, apiNode)
|
||||
subgraphNode._internalConfigureAfterSlots()
|
||||
const inputWidgetId = subgraphNode.inputs[0].widgetId
|
||||
if (!inputWidgetId) throw new Error('Missing promoted input widgetId')
|
||||
useWidgetValueStore().setValue(inputWidgetId, 'outer value')
|
||||
|
||||
updateSubgraphCredits(subgraphNode)
|
||||
|
||||
expect(getBadgeText(subgraphNode)).toBe('outer value')
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
@@ -2,9 +2,14 @@ import type { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { LGraphBadge } from '@/lib/litegraph/src/litegraph'
|
||||
|
||||
import { useNodePricing } from '@/composables/node/useNodePricing'
|
||||
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
|
||||
import type { INodeInputSlot } from '@/lib/litegraph/src/interfaces'
|
||||
import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
|
||||
import { adjustColor } from '@/utils/colorUtil'
|
||||
import { useWidgetValueStore } from '@/stores/widgetValueStore'
|
||||
|
||||
type LinkedWidgetInput = INodeInputSlot & {
|
||||
_subgraphSlot?: { linkIds?: number[] }
|
||||
}
|
||||
|
||||
const componentIconSvg = new Image()
|
||||
componentIconSvg.src =
|
||||
@@ -95,11 +100,20 @@ export const usePriceBadge = () => {
|
||||
): ReadonlyMap<string, unknown> {
|
||||
const overrides = new Map<string, unknown>()
|
||||
if (!wrapper.isSubgraphNode()) return overrides
|
||||
const innerId = String(innerNode.id)
|
||||
for (const w of wrapper.widgets ?? []) {
|
||||
if (!isPromotedWidgetView(w)) continue
|
||||
if (w.sourceNodeId !== innerId) continue
|
||||
overrides.set(w.sourceWidgetName, w.value)
|
||||
|
||||
for (const input of wrapper.inputs as LinkedWidgetInput[]) {
|
||||
if (!input.widgetId) continue
|
||||
for (const linkId of input._subgraphSlot?.linkIds ?? []) {
|
||||
const link = wrapper.subgraph.getLink(linkId)
|
||||
if (link?.target_id !== innerNode.id) continue
|
||||
const targetInput = innerNode.inputs[link.target_slot]
|
||||
const widgetName = targetInput?.widget?.name
|
||||
if (!widgetName) continue
|
||||
overrides.set(
|
||||
widgetName,
|
||||
useWidgetValueStore().getWidget(input.widgetId)?.value
|
||||
)
|
||||
}
|
||||
}
|
||||
return overrides
|
||||
}
|
||||
|
||||
@@ -1,12 +1,19 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import type { WidgetState } from '@/stores/widgetValueStore'
|
||||
import type { NodeId } from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||
import type { WidgetState } from '@/types/widgetState'
|
||||
|
||||
import { boundsExtractor, singleValueExtractor } from './useUpstreamValue'
|
||||
|
||||
function widget(name: string, value: unknown): WidgetState {
|
||||
return { name, type: 'INPUT', value, nodeId: '1' as NodeId, options: {} }
|
||||
return {
|
||||
name,
|
||||
type: 'INPUT',
|
||||
value,
|
||||
nodeId: '1' as NodeId,
|
||||
options: {},
|
||||
y: 0
|
||||
}
|
||||
}
|
||||
|
||||
const isNumber = (v: unknown): v is number => typeof v === 'number'
|
||||
|
||||
@@ -2,9 +2,9 @@ import { computed } from 'vue'
|
||||
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { useWidgetValueStore } from '@/stores/widgetValueStore'
|
||||
import type { WidgetState } from '@/stores/widgetValueStore'
|
||||
import type { Bounds } from '@/renderer/core/layout/types'
|
||||
import type { LinkedUpstreamInfo } from '@/types/simplifiedWidget'
|
||||
import type { WidgetState } from '@/types/widgetState'
|
||||
|
||||
type ValueExtractor<T = unknown> = (
|
||||
widgets: WidgetState[],
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { fromPartial } from '@total-typescript/shoehorn'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
@@ -21,8 +20,8 @@ import {
|
||||
normalizeLegacyProxyWidgetEntry,
|
||||
readHostQuarantine
|
||||
} from '@/core/graph/subgraph/migration/proxyWidgetMigration'
|
||||
import type { PromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
|
||||
import { usePreviewExposureStore } from '@/stores/previewExposureStore'
|
||||
import { useWidgetValueStore } from '@/stores/widgetValueStore'
|
||||
|
||||
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
|
||||
useCanvasStore: () => ({})
|
||||
@@ -55,39 +54,15 @@ function addInnerNode(
|
||||
return node
|
||||
}
|
||||
|
||||
function addPromotedHostInput(
|
||||
function getPromotedInputValue(
|
||||
host: SubgraphNode,
|
||||
args: {
|
||||
inputName: string
|
||||
promotedName: string
|
||||
sourceNodeId: string
|
||||
sourceWidgetName: string
|
||||
initialValue?: TWidgetValue
|
||||
}
|
||||
): { setValue: (v: TWidgetValue) => void; getValue: () => TWidgetValue } {
|
||||
let widgetValue: TWidgetValue = args.initialValue ?? 0
|
||||
const slot = host.addInput(args.inputName, '*')
|
||||
slot._widget = fromPartial<PromotedWidgetView>({
|
||||
node: host,
|
||||
name: args.promotedName,
|
||||
sourceNodeId: args.sourceNodeId,
|
||||
sourceWidgetName: args.sourceWidgetName,
|
||||
get value() {
|
||||
return widgetValue
|
||||
},
|
||||
set value(v: TWidgetValue) {
|
||||
widgetValue = v
|
||||
},
|
||||
hydrateHostValue(v: TWidgetValue) {
|
||||
widgetValue = v
|
||||
}
|
||||
})
|
||||
return {
|
||||
setValue: (v) => {
|
||||
widgetValue = v
|
||||
},
|
||||
getValue: () => widgetValue
|
||||
}
|
||||
name: string
|
||||
): TWidgetValue | undefined {
|
||||
const input = host.inputs.find((input) => input.name === name)
|
||||
if (!input?.widgetId) return undefined
|
||||
return useWidgetValueStore().getWidget(input.widgetId)?.value as
|
||||
| TWidgetValue
|
||||
| undefined
|
||||
}
|
||||
|
||||
function addPrimitiveWithTargets(
|
||||
@@ -141,29 +116,6 @@ describe('flushProxyWidgetMigration', () => {
|
||||
})
|
||||
|
||||
describe('value-widget repair', () => {
|
||||
it('alreadyLinked: applies host value to the matching promoted widget', () => {
|
||||
const host = buildHost()
|
||||
const inner = addInnerNode(host, 'Inner', (n) => {
|
||||
n.addWidget('number', 'seed', 0, () => {})
|
||||
})
|
||||
const handle = addPromotedHostInput(host, {
|
||||
inputName: 'seed_link',
|
||||
promotedName: 'seed',
|
||||
sourceNodeId: String(inner.id),
|
||||
sourceWidgetName: 'seed',
|
||||
initialValue: 0
|
||||
})
|
||||
|
||||
host.properties.proxyWidgets = [[String(inner.id), 'seed']]
|
||||
flushProxyWidgetMigration({
|
||||
hostNode: host,
|
||||
hostWidgetValues: [99]
|
||||
})
|
||||
|
||||
expect(handle.getValue()).toBe(99)
|
||||
expect(host.properties.proxyWidgets).toBeUndefined()
|
||||
})
|
||||
|
||||
it('alreadyLinked: hydrates real promoted widget without mutating the interior widget', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'seed', type: 'INT' }]
|
||||
@@ -183,23 +135,61 @@ describe('flushProxyWidgetMigration', () => {
|
||||
hostWidgetValues: [99]
|
||||
})
|
||||
|
||||
expect(host.widgets[0].value).toBe(99)
|
||||
expect(getPromotedInputValue(host, 'seed')).toBe(99)
|
||||
const innerWidget = inner.widgets!.find((w) => w.name === 'seed')!
|
||||
expect(innerWidget.value).toBe(0)
|
||||
})
|
||||
|
||||
it('createSubgraphInput: uses disambiguator for duplicate nested widget names', () => {
|
||||
const rootGraph = new LGraph()
|
||||
const innerSubgraph = createTestSubgraph({ rootGraph })
|
||||
const firstText = new LGraphNode('CLIPTextEncode')
|
||||
const firstSlot = firstText.addInput('text', 'STRING')
|
||||
firstSlot.widget = { name: 'text' }
|
||||
firstText.addWidget('text', 'text', '11111111111', () => {})
|
||||
innerSubgraph.add(firstText)
|
||||
|
||||
const secondText = new LGraphNode('CLIPTextEncode')
|
||||
const secondSlot = secondText.addInput('text', 'STRING')
|
||||
secondSlot.widget = { name: 'text' }
|
||||
secondText.addWidget('text', 'text', '22222222222', () => {})
|
||||
innerSubgraph.add(secondText)
|
||||
|
||||
const nestedHost = createTestSubgraphNode(innerSubgraph, {
|
||||
parentGraph: rootGraph
|
||||
})
|
||||
nestedHost.properties.proxyWidgets = [
|
||||
[String(firstText.id), 'text'],
|
||||
[String(secondText.id), 'text']
|
||||
]
|
||||
flushProxyWidgetMigration({ hostNode: nestedHost })
|
||||
|
||||
const outerSubgraph = createTestSubgraph({ rootGraph })
|
||||
outerSubgraph.add(nestedHost)
|
||||
const outerHost = createTestSubgraphNode(outerSubgraph, {
|
||||
parentGraph: rootGraph
|
||||
})
|
||||
outerHost.properties.proxyWidgets = [
|
||||
[String(nestedHost.id), 'text', String(secondText.id)]
|
||||
]
|
||||
|
||||
flushProxyWidgetMigration({ hostNode: outerHost })
|
||||
|
||||
expect(getPromotedInputValue(outerHost, 'text')).toBe('22222222222')
|
||||
})
|
||||
|
||||
it('alreadyLinked: leaves widget value unchanged when host value is a sparse hole', () => {
|
||||
const host = buildHost()
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'seed', type: 'INT' }]
|
||||
})
|
||||
const host = createTestSubgraphNode(subgraph)
|
||||
host.graph!.add(host)
|
||||
const inner = addInnerNode(host, 'Inner', (n) => {
|
||||
n.addWidget('number', 'seed', 0, () => {})
|
||||
})
|
||||
const handle = addPromotedHostInput(host, {
|
||||
inputName: 'seed_link',
|
||||
promotedName: 'seed',
|
||||
sourceNodeId: String(inner.id),
|
||||
sourceWidgetName: 'seed',
|
||||
initialValue: 7
|
||||
const slot = n.addInput('seed', 'INT')
|
||||
const innerWidget = n.addWidget('number', 'seed', 7, () => {})
|
||||
slot.widget = { name: innerWidget.name }
|
||||
})
|
||||
subgraph.inputNode.slots[0].connect(inner.inputs[0], inner)
|
||||
|
||||
host.properties.proxyWidgets = [[String(inner.id), 'seed']]
|
||||
const sparse: unknown[] = []
|
||||
@@ -208,43 +198,7 @@ describe('flushProxyWidgetMigration', () => {
|
||||
hostWidgetValues: sparse
|
||||
})
|
||||
|
||||
expect(handle.getValue()).toBe(7)
|
||||
})
|
||||
|
||||
it('alreadyLinked: ambiguous matching inputs quarantine without applying host value', () => {
|
||||
const host = buildHost()
|
||||
const inner = addInnerNode(host, 'Inner', (n) => {
|
||||
n.addWidget('number', 'seed', 0, () => {})
|
||||
})
|
||||
const a = addPromotedHostInput(host, {
|
||||
inputName: 'first_seed',
|
||||
promotedName: 'seed',
|
||||
sourceNodeId: String(inner.id),
|
||||
sourceWidgetName: 'seed',
|
||||
initialValue: 1
|
||||
})
|
||||
const b = addPromotedHostInput(host, {
|
||||
inputName: 'second_seed',
|
||||
promotedName: 'seed',
|
||||
sourceNodeId: String(inner.id),
|
||||
sourceWidgetName: 'seed',
|
||||
initialValue: 2
|
||||
})
|
||||
|
||||
host.properties.proxyWidgets = [[String(inner.id), 'seed']]
|
||||
flushProxyWidgetMigration({
|
||||
hostNode: host,
|
||||
hostWidgetValues: [99]
|
||||
})
|
||||
|
||||
expect(a.getValue()).toBe(1)
|
||||
expect(b.getValue()).toBe(2)
|
||||
expect(readHostQuarantine(host)).toEqual([
|
||||
expect.objectContaining({
|
||||
originalEntry: [String(inner.id), 'seed'],
|
||||
reason: 'ambiguousSubgraphInput'
|
||||
})
|
||||
])
|
||||
expect(getPromotedInputValue(host, 'seed')).toBe(7)
|
||||
})
|
||||
|
||||
it('createSubgraphInput: creates exactly one new SubgraphInput linked to the source widget', () => {
|
||||
@@ -264,29 +218,25 @@ describe('flushProxyWidgetMigration', () => {
|
||||
expect(created?._widget).toBeDefined()
|
||||
})
|
||||
|
||||
it('createSubgraphInput: honors disambiguatingSourceNodeId when source widget name has been deduplicated', () => {
|
||||
it('createSubgraphInput: preserves the source slot label', () => {
|
||||
const host = buildHost()
|
||||
const inner = addInnerNode(host, 'InnerWithDedupedPromotion', (n) => {
|
||||
const slot1 = n.addInput('text', 'STRING')
|
||||
slot1.widget = { name: 'text' }
|
||||
const w1 = n.addWidget('text', 'text', '11111111111', () => {})
|
||||
Object.assign(w1, { sourceNodeId: '1', sourceWidgetName: 'text' })
|
||||
|
||||
const slot2 = n.addInput('text_1', 'STRING')
|
||||
slot2.widget = { name: 'text_1' }
|
||||
const w2 = n.addWidget('text', 'text_1', '22222222222', () => {})
|
||||
Object.assign(w2, { sourceNodeId: '2', sourceWidgetName: 'text' })
|
||||
const inner = addInnerNode(host, 'Inner', (n) => {
|
||||
const slot = n.addInput('text', 'STRING')
|
||||
slot.label = 'renamed_from_sidepanel'
|
||||
slot.widget = { name: 'text' }
|
||||
n.addWidget('text', 'text', '', () => {})
|
||||
})
|
||||
|
||||
host.properties.proxyWidgets = [[String(inner.id), 'text', '2']]
|
||||
host.properties.proxyWidgets = [[String(inner.id), 'text']]
|
||||
flushProxyWidgetMigration({ hostNode: host })
|
||||
|
||||
const created = host.subgraph.inputs.at(-1)
|
||||
expect(created?._widget).toBeDefined()
|
||||
const linkedSlot = inner.inputs.find(
|
||||
(slot) => slot.link === created?.linkIds[0]
|
||||
)
|
||||
expect(linkedSlot?.name).toBe('text_1')
|
||||
const promotedInput = host.inputs.find((input) => input.name === 'text')
|
||||
expect(promotedInput?.label).toBe('renamed_from_sidepanel')
|
||||
expect(
|
||||
promotedInput?.widgetId
|
||||
? useWidgetValueStore().getWidget(promotedInput.widgetId)?.label
|
||||
: undefined
|
||||
).toBe('renamed_from_sidepanel')
|
||||
})
|
||||
|
||||
it('createSubgraphInput: quarantines missingSubgraphInput when source widget has no backing input slot', () => {
|
||||
@@ -361,8 +311,7 @@ describe('flushProxyWidgetMigration', () => {
|
||||
hostWidgetValues: [123]
|
||||
})
|
||||
|
||||
const hostInput = host.inputs.at(-1)
|
||||
expect(hostInput?._widget?.value).toBe(123)
|
||||
expect(getPromotedInputValue(host, 'value')).toBe(123)
|
||||
})
|
||||
|
||||
it('seeds value from the primitive widget when no host value is supplied', () => {
|
||||
@@ -375,8 +324,7 @@ describe('flushProxyWidgetMigration', () => {
|
||||
host.properties.proxyWidgets = [[String(primitive.id), 'value']]
|
||||
flushProxyWidgetMigration({ hostNode: host })
|
||||
|
||||
const hostInput = host.inputs.at(-1)
|
||||
expect(hostInput?._widget?.value).toBe(11)
|
||||
expect(getPromotedInputValue(host, 'value')).toBe(11)
|
||||
})
|
||||
|
||||
it('quarantines an unlinked primitive node with no fan-out', () => {
|
||||
@@ -474,10 +422,8 @@ describe('flushProxyWidgetMigration', () => {
|
||||
expect(hostA.properties.proxyWidgetErrorQuarantine).toBeUndefined()
|
||||
expect(hostB.properties.proxyWidgetErrorQuarantine).toBeUndefined()
|
||||
|
||||
const widgetA = hostA.inputs.at(-1)?._widget
|
||||
const widgetB = hostB.inputs.at(-1)?._widget
|
||||
expect(widgetA?.value).toBe(11)
|
||||
expect(widgetB?.value).toBe(22)
|
||||
expect(getPromotedInputValue(hostA, 'value')).toBe(11)
|
||||
expect(getPromotedInputValue(hostB, 'value')).toBe(22)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { isEqual } from 'es-toolkit/compat'
|
||||
|
||||
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
|
||||
import { promotedInputWidget } from '@/core/graph/subgraph/promotedInputWidget'
|
||||
import type { PromotedWidgetSource } from '@/core/graph/subgraph/promotedWidgetTypes'
|
||||
import {
|
||||
findHostInputForPromotion,
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
isPreviewPseudoWidget
|
||||
} from '@/core/graph/subgraph/promotionUtils'
|
||||
import { resolveConcretePromotedWidget } from '@/core/graph/subgraph/resolveConcretePromotedWidget'
|
||||
import { resolveSubgraphInputTarget } from '@/core/graph/subgraph/resolveSubgraphInputTarget'
|
||||
import type { SerializedProxyWidgetTuple } from '@/core/schemas/promotionSchema'
|
||||
import { parseProxyWidgets } from '@/core/schemas/promotionSchema'
|
||||
import type {
|
||||
@@ -27,6 +28,7 @@ import type {
|
||||
} from '@/lib/litegraph/src/types/widgets'
|
||||
import { isWidgetValue } from '@/lib/litegraph/src/types/widgets'
|
||||
import { usePreviewExposureStore } from '@/stores/previewExposureStore'
|
||||
import { useWidgetValueStore } from '@/stores/widgetValueStore'
|
||||
|
||||
interface LegacyProxyEntrySource extends PromotedWidgetSource {
|
||||
disambiguatingSourceNodeId?: string
|
||||
@@ -93,23 +95,24 @@ function resolveSourceWidget(
|
||||
sourceWidgetName: string,
|
||||
disambiguatingSourceNodeId?: string
|
||||
): IBaseWidget | undefined {
|
||||
const widgets = sourceNode.widgets
|
||||
if (widgets && disambiguatingSourceNodeId !== undefined) {
|
||||
const byDisambiguator = widgets.find(
|
||||
(w) =>
|
||||
isPromotedWidgetView(w) &&
|
||||
w.sourceNodeId === disambiguatingSourceNodeId &&
|
||||
w.sourceWidgetName === sourceWidgetName
|
||||
)
|
||||
if (byDisambiguator) return byDisambiguator
|
||||
// Disambiguator missed: fall back only to non-promoted same-name widgets.
|
||||
// A sibling PromotedWidgetView would re-introduce the cross-binding bug.
|
||||
const byName = widgets.find(
|
||||
(w) => !isPromotedWidgetView(w) && w.name === sourceWidgetName
|
||||
)
|
||||
if (byName) return byName
|
||||
if (sourceNode.isSubgraphNode()) {
|
||||
const input = sourceNode.inputs.find((input) => {
|
||||
const target = resolveSubgraphInputTarget(sourceNode, input.name)
|
||||
if (disambiguatingSourceNodeId) {
|
||||
return (
|
||||
target?.widgetName === sourceWidgetName &&
|
||||
target.nodeId === disambiguatingSourceNodeId
|
||||
)
|
||||
}
|
||||
if (input.name === sourceWidgetName) return true
|
||||
return target?.widgetName === sourceWidgetName
|
||||
})
|
||||
// Store-backed projection for a promoted input on a nested subgraph node:
|
||||
// getSlotFromWidget locates the backing slot by widgetId.
|
||||
if (input?.widgetId) return promotedInputWidget(input) ?? undefined
|
||||
}
|
||||
|
||||
const widgets = sourceNode.widgets
|
||||
return (
|
||||
widgets?.find((w) => w.name === sourceWidgetName) ??
|
||||
getPromotableWidgets(sourceNode).find((w) => w.name === sourceWidgetName)
|
||||
@@ -300,19 +303,6 @@ function classify(
|
||||
normalized.sourceWidgetName
|
||||
)
|
||||
if (linkedInput) {
|
||||
const ambiguous =
|
||||
hostNode.inputs.filter((input) => {
|
||||
const w = input._widget
|
||||
return (
|
||||
!!w &&
|
||||
isPromotedWidgetView(w) &&
|
||||
w.sourceNodeId === normalized.sourceNodeId &&
|
||||
w.sourceWidgetName === normalized.sourceWidgetName
|
||||
)
|
||||
}).length > 1
|
||||
if (ambiguous) {
|
||||
return { kind: 'quarantine', reason: 'ambiguousSubgraphInput' }
|
||||
}
|
||||
return { kind: 'alreadyLinked', subgraphInputName: linkedInput.name }
|
||||
}
|
||||
|
||||
@@ -373,19 +363,23 @@ function classify(
|
||||
}
|
||||
}
|
||||
|
||||
function applyHostValue(widget: IBaseWidget, entry: PendingEntry): void {
|
||||
if (entry.isHole) return
|
||||
if (
|
||||
isPromotedWidgetView(widget) &&
|
||||
typeof widget.hydrateHostValue === 'function'
|
||||
) {
|
||||
widget.hydrateHostValue(entry.hostValue)
|
||||
return
|
||||
}
|
||||
console.error(
|
||||
'[proxyWidgetMigration] applyHostValue called with non-promoted widget; refusing to write to shared interior',
|
||||
{ widgetName: widget.name, type: widget.type }
|
||||
)
|
||||
function applyHostValueToInput(
|
||||
input: INodeInputSlot,
|
||||
entry: PendingEntry
|
||||
): boolean {
|
||||
if (!input.widgetId || entry.isHole) return Boolean(input.widgetId)
|
||||
return useWidgetValueStore().setValue(input.widgetId, entry.hostValue)
|
||||
}
|
||||
|
||||
function applyHostLabelToInput(
|
||||
input: INodeInputSlot,
|
||||
label: string | undefined
|
||||
): void {
|
||||
if (label === undefined) return
|
||||
input.label = label
|
||||
if (!input.widgetId) return
|
||||
const state = useWidgetValueStore().getWidget(input.widgetId)
|
||||
if (state) state.label = label
|
||||
}
|
||||
|
||||
function addUniqueSubgraphInput(
|
||||
@@ -422,10 +416,9 @@ function repairAlreadyLinked(
|
||||
return { ok: false, reason: 'ambiguousSubgraphInput' }
|
||||
}
|
||||
const hostInput = matches[0]
|
||||
if (!hostInput._widget) {
|
||||
if (!applyHostValueToInput(hostInput, entry)) {
|
||||
return { ok: false, reason: 'missingSubgraphInput' }
|
||||
}
|
||||
applyHostValue(hostInput._widget, entry)
|
||||
return { ok: true, subgraphInputName: hostInput.name }
|
||||
}
|
||||
|
||||
@@ -480,11 +473,10 @@ function repairCreateSubgraphInput(
|
||||
const hostInput = hostNode.inputs.find(
|
||||
(input) => input.name === newSubgraphInput.name
|
||||
)
|
||||
if (!hostInput?._widget) {
|
||||
return { ok: true, subgraphInputName: newSubgraphInput.name }
|
||||
if (hostInput) {
|
||||
applyHostLabelToInput(hostInput, slot.label)
|
||||
applyHostValueToInput(hostInput, entry)
|
||||
}
|
||||
|
||||
applyHostValue(hostInput._widget, entry)
|
||||
return { ok: true, subgraphInputName: newSubgraphInput.name }
|
||||
}
|
||||
|
||||
@@ -649,22 +641,19 @@ function repairPrimitive(
|
||||
return failPrimitive('mutation failed; rolled back', { error: e })
|
||||
}
|
||||
|
||||
// Apply through the host's input mirror (PromotedWidgetView), NOT
|
||||
// `newSubgraphInput._widget`: the interior is shared across hosts.
|
||||
const hostInput = hostNode.inputs.find(
|
||||
(input) => input.name === newSubgraphInput.name
|
||||
)
|
||||
const hostInputWidget = hostInput?._widget
|
||||
if (hostInputWidget) {
|
||||
if (hostInput) {
|
||||
const valueEntry = validated.uniqueEntries.find((e) => !e.isHole)
|
||||
if (valueEntry) {
|
||||
applyHostValue(hostInputWidget, valueEntry)
|
||||
applyHostValueToInput(hostInput, valueEntry)
|
||||
} else {
|
||||
const primitiveValue = primitiveNode.widgets?.find(
|
||||
(w) => w.name === validated.sourceWidgetName
|
||||
)?.value as TWidgetValue | undefined
|
||||
if (primitiveValue !== undefined) {
|
||||
applyHostValue(hostInputWidget, {
|
||||
applyHostValueToInput(hostInput, {
|
||||
...validated.uniqueEntries[0],
|
||||
hostValue: primitiveValue,
|
||||
isHole: false
|
||||
|
||||
118
src/core/graph/subgraph/promotedInputWidget.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
import type { INodeInputSlot } from '@/lib/litegraph/src/interfaces'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
import { isWidgetValue } from '@/lib/litegraph/src/types/widgets'
|
||||
import { useWidgetValueStore } from '@/stores/widgetValueStore'
|
||||
|
||||
import { resolveSubgraphInputTarget } from './resolveSubgraphInputTarget'
|
||||
|
||||
/**
|
||||
* Where a promoted subgraph input is sourced from inside the subgraph. The
|
||||
* interior node id + widget name that the host input slot forwards to. Resolved
|
||||
* by walking the live link, so it is authoritative derived data — never stored
|
||||
* on the projected widget.
|
||||
*/
|
||||
export interface PromotedSource {
|
||||
nodeId: string
|
||||
widgetName: string
|
||||
}
|
||||
|
||||
/**
|
||||
* The interior source of a host input slot, or undefined when the slot is not a
|
||||
* promoted widget input.
|
||||
*/
|
||||
export function promotedInputSource(
|
||||
node: LGraphNode,
|
||||
input: INodeInputSlot
|
||||
): PromotedSource | undefined {
|
||||
if (!input.widgetId) return undefined
|
||||
return resolveSubgraphInputTarget(node, input.name)
|
||||
}
|
||||
|
||||
/** The host input slot backing a projected widget, matched by widgetId. */
|
||||
export function inputForWidget(
|
||||
node: LGraphNode,
|
||||
widget: IBaseWidget
|
||||
): INodeInputSlot | undefined {
|
||||
return node.getSlotFromWidget(widget)
|
||||
}
|
||||
|
||||
/**
|
||||
* The interior source of a widget when it is a promoted subgraph input.
|
||||
* Replaces ad-hoc "is this promoted?" duck-typing: a widget is promoted iff its
|
||||
* host node is a subgraph node and its backing input slot has an interior
|
||||
* source.
|
||||
*/
|
||||
export function widgetPromotedSource(
|
||||
node: LGraphNode,
|
||||
widget: IBaseWidget
|
||||
): PromotedSource | undefined {
|
||||
if (!node.isSubgraphNode()) return undefined
|
||||
const input = inputForWidget(node, widget)
|
||||
if (!input) return undefined
|
||||
return promotedInputSource(node, input)
|
||||
}
|
||||
|
||||
/**
|
||||
* Projects a promoted subgraph input into an ordinary widget descriptor. The
|
||||
* descriptor is store-backed: type/value/options read live from
|
||||
* {@link useWidgetValueStore} by widgetId (mirroring BaseWidget), so the row
|
||||
* list does not reactively rebuild — and re-key — on every value edit.
|
||||
*
|
||||
* `name` is the input slot name (unique + fixed; widgetId derives from it), and
|
||||
* `label` is the mutable display label. Returns null when the input is not a
|
||||
* promoted widget input.
|
||||
*/
|
||||
export function promotedInputWidget(input: INodeInputSlot): IBaseWidget | null {
|
||||
const id = input.widgetId
|
||||
if (!id) return null
|
||||
const store = useWidgetValueStore()
|
||||
return {
|
||||
get name() {
|
||||
return store.getWidget(id)?.name ?? input.name
|
||||
},
|
||||
get label() {
|
||||
return store.getWidget(id)?.label ?? input.label ?? input.name
|
||||
},
|
||||
set label(next) {
|
||||
const state = store.getWidget(id)
|
||||
if (state) state.label = next
|
||||
},
|
||||
get y() {
|
||||
return store.getWidget(id)?.y ?? 0
|
||||
},
|
||||
set y(next) {
|
||||
const state = store.getWidget(id)
|
||||
if (state) state.y = next
|
||||
},
|
||||
widgetId: id,
|
||||
get type() {
|
||||
return store.getWidget(id)?.type ?? 'text'
|
||||
},
|
||||
get options() {
|
||||
return store.getWidget(id)?.options ?? {}
|
||||
},
|
||||
get value() {
|
||||
const value = store.getWidget(id)?.value
|
||||
return isWidgetValue(value) ? value : undefined
|
||||
},
|
||||
set value(next) {
|
||||
store.setValue(id, next)
|
||||
},
|
||||
// Canvas edits operate on a transient concrete widget (toConcreteWidget),
|
||||
// so the value setter above is never invoked; BaseWidget.setValue writes its
|
||||
// own local state and then calls this callback, which is the only bridge
|
||||
// back to the store.
|
||||
callback(next) {
|
||||
store.setValue(id, next)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Every promoted subgraph input on a node, projected to ordinary widgets. */
|
||||
export function promotedInputWidgets(node: LGraphNode): IBaseWidget[] {
|
||||
return node.inputs.flatMap((input) => {
|
||||
const widget = promotedInputWidget(input)
|
||||
return widget ? [widget] : []
|
||||
})
|
||||
}
|
||||
@@ -1,31 +1,17 @@
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
import type { WidgetEntityId } from '@/world/entityIds'
|
||||
|
||||
export interface ResolvedPromotedWidget {
|
||||
node: LGraphNode
|
||||
widget: IBaseWidget
|
||||
}
|
||||
|
||||
/**
|
||||
* A persisted promotion's source identity: the interior node + widget a host
|
||||
* subgraph input was promoted from. Used by the migration/schema layer, where
|
||||
* the source is a stored tuple rather than something link-derivable.
|
||||
*/
|
||||
export interface PromotedWidgetSource {
|
||||
sourceNodeId: string
|
||||
sourceWidgetName: string
|
||||
}
|
||||
|
||||
export interface PromotedWidgetView extends IBaseWidget {
|
||||
readonly node: SubgraphNode
|
||||
readonly entityId: WidgetEntityId
|
||||
readonly sourceNodeId: string
|
||||
readonly sourceWidgetName: string
|
||||
|
||||
hydrateHostValue(value: IBaseWidget['value']): void
|
||||
|
||||
ensureHostWidgetState(): void
|
||||
}
|
||||
|
||||
export function isPromotedWidgetView(
|
||||
widget: IBaseWidget
|
||||
): widget is PromotedWidgetView {
|
||||
return 'sourceNodeId' in widget && 'sourceWidgetName' in widget
|
||||
}
|
||||
|
||||
@@ -1,100 +0,0 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import {
|
||||
createTestSubgraph,
|
||||
createTestSubgraphNode,
|
||||
resetSubgraphFixtureState
|
||||
} from '@/lib/litegraph/src/subgraph/__fixtures__/subgraphHelpers'
|
||||
import { useWidgetValueStore } from '@/stores/widgetValueStore'
|
||||
|
||||
import { isPromotedWidgetView } from './promotedWidgetTypes'
|
||||
|
||||
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
|
||||
useCanvasStore: () => ({})
|
||||
}))
|
||||
vi.mock('@/services/litegraphService', () => ({
|
||||
useLitegraphService: () => ({ updatePreviews: () => ({}) })
|
||||
}))
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
resetSubgraphFixtureState()
|
||||
})
|
||||
|
||||
function createNumericInteriorNode(initialValue: number) {
|
||||
const node = new LGraphNode('Interior')
|
||||
const input = node.addInput('value', 'number')
|
||||
node.addOutput('out', 'number')
|
||||
|
||||
const widget = node.addWidget('number', 'widget', initialValue, () => {}, {
|
||||
min: 0,
|
||||
max: 100,
|
||||
step: 1
|
||||
})
|
||||
input.widget = { name: widget.name }
|
||||
|
||||
return { node, widget }
|
||||
}
|
||||
|
||||
describe('PromotedWidgetView — host-wins semantics', () => {
|
||||
it('does not leak host-side writes into the interior widget or into a sibling host', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'value', type: 'number' }]
|
||||
})
|
||||
const { node: interior, widget: interiorWidget } =
|
||||
createNumericInteriorNode(42)
|
||||
subgraph.add(interior)
|
||||
subgraph.inputNode.slots[0].connect(interior.inputs[0], interior)
|
||||
|
||||
const hostA = createTestSubgraphNode(subgraph, { id: 100 })
|
||||
const hostB = createTestSubgraphNode(subgraph, { id: 101 })
|
||||
|
||||
const viewA = hostA.widgets.find(isPromotedWidgetView)
|
||||
const viewB = hostB.widgets.find(isPromotedWidgetView)
|
||||
if (!viewA || !viewB)
|
||||
throw new Error('Expected promoted views on both hosts')
|
||||
|
||||
viewA.value = 7
|
||||
|
||||
expect(viewA.value).toBe(7)
|
||||
expect(interiorWidget.value).toBe(42)
|
||||
expect(viewB.value).toBe(42)
|
||||
})
|
||||
|
||||
it('keeps the interior widgetValueStore row untouched when a host writes', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'value', type: 'number' }]
|
||||
})
|
||||
const { node: interior } = createNumericInteriorNode(42)
|
||||
subgraph.add(interior)
|
||||
subgraph.inputNode.slots[0].connect(interior.inputs[0], interior)
|
||||
|
||||
const widgetStore = useWidgetValueStore()
|
||||
widgetStore.registerWidget(subgraph.rootGraph.id, {
|
||||
nodeId: String(interior.id),
|
||||
name: 'widget',
|
||||
type: 'number',
|
||||
value: 42,
|
||||
options: {},
|
||||
label: undefined,
|
||||
serialize: true,
|
||||
disabled: false
|
||||
})
|
||||
|
||||
const host = createTestSubgraphNode(subgraph, { id: 200 })
|
||||
const view = host.widgets.find(isPromotedWidgetView)
|
||||
if (!view) throw new Error('Expected promoted view on host')
|
||||
|
||||
view.value = 99
|
||||
|
||||
const interiorState = widgetStore.getWidget(
|
||||
subgraph.rootGraph.id,
|
||||
String(interior.id),
|
||||
'widget'
|
||||
)
|
||||
expect(interiorState?.value).toBe(42)
|
||||
})
|
||||
})
|
||||
@@ -1,614 +0,0 @@
|
||||
import type { LGraphNode, NodeId } from '@/lib/litegraph/src/LGraphNode'
|
||||
import type { LGraphCanvas } from '@/lib/litegraph/src/LGraphCanvas'
|
||||
import type { CanvasPointer } from '@/lib/litegraph/src/CanvasPointer'
|
||||
import type { Point } from '@/lib/litegraph/src/interfaces'
|
||||
import type { CanvasPointerEvent } from '@/lib/litegraph/src/types/events'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
import { isWidgetValue } from '@/lib/litegraph/src/types/widgets'
|
||||
import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
|
||||
import type { BaseWidget } from '@/lib/litegraph/src/widgets/BaseWidget'
|
||||
import { toConcreteWidget } from '@/lib/litegraph/src/widgets/widgetMap'
|
||||
import { t } from '@/i18n'
|
||||
import { nextValueForLinkedTarget } from '@/scripts/valueControl'
|
||||
import { useDomWidgetStore } from '@/stores/domWidgetStore'
|
||||
import {
|
||||
stripGraphPrefix,
|
||||
useWidgetValueStore
|
||||
} from '@/stores/widgetValueStore'
|
||||
import type { WidgetState } from '@/stores/widgetValueStore'
|
||||
import {
|
||||
resolveConcretePromotedWidget,
|
||||
resolvePromotedWidgetAtHost
|
||||
} from '@/core/graph/subgraph/resolveConcretePromotedWidget'
|
||||
import { matchPromotedInput } from '@/core/graph/subgraph/matchPromotedInput'
|
||||
import { hasWidgetNode } from '@/core/graph/subgraph/widgetNodeTypeGuard'
|
||||
import type { WidgetEntityId } from '@/world/entityIds'
|
||||
import { widgetEntityId } from '@/world/entityIds'
|
||||
import { ensureWidgetState, getWidgetState } from '@/world/widgetValueIO'
|
||||
|
||||
import { isPromotedWidgetView } from './promotedWidgetTypes'
|
||||
import type { PromotedWidgetView as IPromotedWidgetView } from './promotedWidgetTypes'
|
||||
|
||||
export type { PromotedWidgetView } from './promotedWidgetTypes'
|
||||
export { isPromotedWidgetView } from './promotedWidgetTypes'
|
||||
|
||||
interface SubgraphSlotRef {
|
||||
name: string
|
||||
label?: string
|
||||
displayName?: string
|
||||
}
|
||||
|
||||
type LegacyMouseWidget = IBaseWidget & {
|
||||
mouse: (e: CanvasPointerEvent, pos: Point, node: LGraphNode) => unknown
|
||||
}
|
||||
|
||||
function hasLegacyMouse(widget: IBaseWidget): widget is LegacyMouseWidget {
|
||||
return 'mouse' in widget && typeof widget.mouse === 'function'
|
||||
}
|
||||
|
||||
const designTokenCache = new Map<string, string>()
|
||||
|
||||
export function createPromotedWidgetView(
|
||||
subgraphNode: SubgraphNode,
|
||||
nodeId: string,
|
||||
widgetName: string,
|
||||
displayName?: string,
|
||||
identityName?: string
|
||||
): IPromotedWidgetView {
|
||||
return new PromotedWidgetView(
|
||||
subgraphNode,
|
||||
nodeId,
|
||||
widgetName,
|
||||
displayName,
|
||||
identityName
|
||||
)
|
||||
}
|
||||
|
||||
class PromotedWidgetView implements IPromotedWidgetView {
|
||||
[symbol: symbol]: boolean
|
||||
|
||||
readonly sourceNodeId: string
|
||||
readonly sourceWidgetName: string
|
||||
|
||||
readonly serialize = false
|
||||
|
||||
last_y?: number
|
||||
computedHeight?: number
|
||||
|
||||
private readonly graphId: string
|
||||
private yValue = 0
|
||||
private _computedDisabled = false
|
||||
|
||||
private projectedSourceNode?: LGraphNode
|
||||
private projectedSourceWidget?: IBaseWidget
|
||||
private projectedSourceWidgetType?: IBaseWidget['type']
|
||||
private projectedWidget?: BaseWidget
|
||||
private cachedDeepestByFrame?: { node: LGraphNode; widget: IBaseWidget }
|
||||
private cachedDeepestFrame = -1
|
||||
|
||||
private _boundSlot?: SubgraphSlotRef
|
||||
private _boundSlotVersion = -1
|
||||
|
||||
private _lastAutoSeededValue?: IBaseWidget['value']
|
||||
|
||||
constructor(
|
||||
private readonly subgraphNode: SubgraphNode,
|
||||
nodeId: string,
|
||||
widgetName: string,
|
||||
private readonly displayName?: string,
|
||||
private readonly identityName?: string
|
||||
) {
|
||||
this.sourceNodeId = nodeId
|
||||
this.sourceWidgetName = widgetName
|
||||
this.graphId = subgraphNode.rootGraph.id
|
||||
}
|
||||
|
||||
get node(): SubgraphNode {
|
||||
return this.subgraphNode
|
||||
}
|
||||
|
||||
get name(): string {
|
||||
return this.identityName ?? this.sourceWidgetName
|
||||
}
|
||||
|
||||
get entityId(): WidgetEntityId {
|
||||
return widgetEntityId(this.graphId, this.subgraphNode.id, this.name)
|
||||
}
|
||||
|
||||
get y(): number {
|
||||
return this.yValue
|
||||
}
|
||||
|
||||
set y(value: number) {
|
||||
this.yValue = value
|
||||
this.syncDomOverride()
|
||||
}
|
||||
|
||||
get computedDisabled(): boolean {
|
||||
return this._computedDisabled
|
||||
}
|
||||
|
||||
set computedDisabled(value: boolean | undefined) {
|
||||
this._computedDisabled = value ?? false
|
||||
}
|
||||
|
||||
get type(): IBaseWidget['type'] {
|
||||
return this.resolveDeepest()?.widget.type ?? 'button'
|
||||
}
|
||||
|
||||
get options(): IBaseWidget['options'] {
|
||||
return this.resolveDeepest()?.widget.options ?? {}
|
||||
}
|
||||
|
||||
get tooltip(): string | undefined {
|
||||
return this.resolveDeepest()?.widget.tooltip
|
||||
}
|
||||
|
||||
get linkedWidgets(): IBaseWidget[] | undefined {
|
||||
return this.resolveDeepest()?.widget.linkedWidgets
|
||||
}
|
||||
|
||||
get value(): IBaseWidget['value'] {
|
||||
const hostState = this.getHostWidgetState()
|
||||
if (hostState && isWidgetValue(hostState.value)) return hostState.value
|
||||
|
||||
const state = this.getWidgetState()
|
||||
if (state && isWidgetValue(state.value)) return state.value
|
||||
return this.resolveAtHost()?.widget.value
|
||||
}
|
||||
|
||||
set value(value: IBaseWidget['value']) {
|
||||
this.setHostWidgetState(value)
|
||||
}
|
||||
|
||||
private getHostWidgetState(): WidgetState | undefined {
|
||||
return getWidgetState(this.entityId)
|
||||
}
|
||||
|
||||
private setHostWidgetState(value: IBaseWidget['value']): void {
|
||||
if (!isWidgetValue(value)) return
|
||||
|
||||
const state = this.getHostWidgetState()
|
||||
if (state) {
|
||||
state.value = value
|
||||
this._lastAutoSeededValue = undefined
|
||||
return
|
||||
}
|
||||
|
||||
this.registerHostWidgetState(value)
|
||||
this._lastAutoSeededValue = undefined
|
||||
}
|
||||
|
||||
ensureHostWidgetState(): void {
|
||||
const fallback = this.fallbackEffectiveValue()
|
||||
const existing = this.getHostWidgetState()
|
||||
|
||||
if (existing) {
|
||||
if (
|
||||
this._lastAutoSeededValue !== undefined &&
|
||||
existing.value === this._lastAutoSeededValue &&
|
||||
isWidgetValue(fallback) &&
|
||||
fallback !== existing.value
|
||||
) {
|
||||
existing.value = fallback
|
||||
this._lastAutoSeededValue = fallback
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
this.registerHostWidgetState(fallback)
|
||||
this._lastAutoSeededValue = fallback
|
||||
}
|
||||
|
||||
private fallbackEffectiveValue(): IBaseWidget['value'] {
|
||||
const state = this.getWidgetState()
|
||||
if (state && isWidgetValue(state.value)) return state.value
|
||||
return this.resolveAtHost()?.widget.value
|
||||
}
|
||||
|
||||
private registerHostWidgetState(value: IBaseWidget['value']): void {
|
||||
const resolved = this.resolveDeepest()
|
||||
ensureWidgetState(this.entityId, {
|
||||
type: resolved?.widget.type ?? 'button',
|
||||
value,
|
||||
options: { ...(resolved?.widget.options ?? {}) },
|
||||
label: this.displayName,
|
||||
serialize: this.serialize,
|
||||
disabled: this.computedDisabled
|
||||
})
|
||||
}
|
||||
|
||||
get label(): string | undefined {
|
||||
const slot = this.getBoundSubgraphSlot()
|
||||
if (slot) return slot.label ?? slot.displayName ?? slot.name
|
||||
const state = this.getWidgetState()
|
||||
return state?.label ?? this.displayName
|
||||
}
|
||||
|
||||
set label(value: string | undefined) {
|
||||
const slot = this.getBoundSubgraphSlot()
|
||||
if (slot) slot.label = value || undefined
|
||||
const state = this.getWidgetState()
|
||||
if (state) state.label = value
|
||||
}
|
||||
|
||||
hydrateHostValue(value: IBaseWidget['value']): void {
|
||||
this.setHostWidgetState(value)
|
||||
}
|
||||
|
||||
private getBoundSubgraphSlot(): SubgraphSlotRef | undefined {
|
||||
const version = this.subgraphNode.inputs?.length ?? 0
|
||||
if (this._boundSlotVersion === version) return this._boundSlot
|
||||
|
||||
this._boundSlot = this.findBoundSubgraphSlot()
|
||||
this._boundSlotVersion = version
|
||||
return this._boundSlot
|
||||
}
|
||||
|
||||
private findBoundSubgraphSlot(): SubgraphSlotRef | undefined {
|
||||
for (const input of this.subgraphNode.inputs ?? []) {
|
||||
const slot = input._subgraphSlot as SubgraphSlotRef | undefined
|
||||
if (!slot) continue
|
||||
|
||||
const w = input._widget
|
||||
if (
|
||||
w &&
|
||||
isPromotedWidgetView(w) &&
|
||||
w.sourceNodeId === this.sourceNodeId &&
|
||||
w.sourceWidgetName === this.sourceWidgetName
|
||||
) {
|
||||
return slot
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
get hidden(): boolean {
|
||||
return this.resolveDeepest()?.widget.hidden ?? false
|
||||
}
|
||||
|
||||
get computeLayoutSize(): IBaseWidget['computeLayoutSize'] {
|
||||
const resolved = this.resolveDeepest()
|
||||
const computeLayoutSize = resolved?.widget.computeLayoutSize
|
||||
if (!computeLayoutSize) return undefined
|
||||
return (node: LGraphNode) => computeLayoutSize.call(resolved.widget, node)
|
||||
}
|
||||
|
||||
get computeSize(): IBaseWidget['computeSize'] {
|
||||
const resolved = this.resolveDeepest()
|
||||
const computeSize = resolved?.widget.computeSize
|
||||
if (!computeSize) return undefined
|
||||
return (width?: number) => computeSize.call(resolved.widget, width)
|
||||
}
|
||||
|
||||
draw(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
_node: LGraphNode,
|
||||
widgetWidth: number,
|
||||
y: number,
|
||||
H: number,
|
||||
lowQuality?: boolean
|
||||
): void {
|
||||
const resolved = this.resolveDeepest()
|
||||
if (!resolved) {
|
||||
drawDisconnectedPlaceholder(ctx, widgetWidth, y, H)
|
||||
return
|
||||
}
|
||||
|
||||
if (isBaseDOMWidget(resolved.widget)) return this.syncDomOverride(resolved)
|
||||
|
||||
const projected = this.getProjectedWidget(resolved)
|
||||
if (!projected || typeof projected.drawWidget !== 'function') return
|
||||
|
||||
const originalY = projected.y
|
||||
const originalComputedHeight = projected.computedHeight
|
||||
const originalComputedDisabled = projected.computedDisabled
|
||||
|
||||
const originalLabel = projected.label
|
||||
|
||||
projected.y = this.y
|
||||
projected.computedHeight = this.computedHeight
|
||||
projected.computedDisabled = this.computedDisabled
|
||||
projected.value = this.value
|
||||
projected.label = this.label
|
||||
|
||||
try {
|
||||
projected.drawWidget(ctx, {
|
||||
width: widgetWidth,
|
||||
showText: !lowQuality,
|
||||
previewImages: resolved.node.imgs
|
||||
})
|
||||
} finally {
|
||||
projected.y = originalY
|
||||
projected.computedHeight = originalComputedHeight
|
||||
projected.computedDisabled = originalComputedDisabled
|
||||
projected.label = originalLabel
|
||||
}
|
||||
}
|
||||
|
||||
onPointerDown(
|
||||
pointer: CanvasPointer,
|
||||
_node: LGraphNode,
|
||||
canvas: LGraphCanvas
|
||||
): boolean {
|
||||
const resolved = this.resolveAtHost()
|
||||
if (!resolved) return false
|
||||
|
||||
const interior = resolved.widget
|
||||
if (typeof interior.onPointerDown === 'function') {
|
||||
const handled = interior.onPointerDown(pointer, this.subgraphNode, canvas)
|
||||
if (handled) return true
|
||||
}
|
||||
|
||||
const concrete = toConcreteWidget(interior, this.subgraphNode, false)
|
||||
if (concrete)
|
||||
return this.bindConcretePointerHandlers(pointer, canvas, concrete)
|
||||
|
||||
if (hasLegacyMouse(interior))
|
||||
return this.handleLegacyMouse(pointer, interior)
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
callback(
|
||||
value: unknown,
|
||||
canvas?: LGraphCanvas,
|
||||
node?: LGraphNode,
|
||||
pos?: Point,
|
||||
e?: CanvasPointerEvent
|
||||
) {
|
||||
this.resolveAtHost()?.widget.callback?.(value, canvas, node, pos, e)
|
||||
}
|
||||
|
||||
afterQueued({
|
||||
isPartialExecution
|
||||
}: { isPartialExecution?: boolean } = {}): void {
|
||||
this.applyValueControlToHost(isPartialExecution)
|
||||
}
|
||||
|
||||
private applyValueControlToHost(isPartialExecution?: boolean): void {
|
||||
if (this.subgraphNode.getSlotFromWidget(this)?.link != null) return
|
||||
|
||||
const resolved = this.resolveAtHost()
|
||||
const next = nextValueForLinkedTarget({
|
||||
target: this,
|
||||
linkedWidgets: resolved?.widget.linkedWidgets,
|
||||
nodeId: this.subgraphNode.id,
|
||||
isPartialExecution
|
||||
})
|
||||
if (next === undefined) return
|
||||
|
||||
this.hydrateHostValue(next)
|
||||
}
|
||||
|
||||
private resolveAtHost():
|
||||
| { node: LGraphNode; widget: IBaseWidget }
|
||||
| undefined {
|
||||
return resolvePromotedWidgetAtHost(
|
||||
this.subgraphNode,
|
||||
this.sourceNodeId,
|
||||
this.sourceWidgetName
|
||||
)
|
||||
}
|
||||
|
||||
private resolveDeepest():
|
||||
| { node: LGraphNode; widget: IBaseWidget }
|
||||
| undefined {
|
||||
const frame = this.subgraphNode.rootGraph.primaryCanvas?.frame
|
||||
if (frame !== undefined && this.cachedDeepestFrame === frame)
|
||||
return this.cachedDeepestByFrame
|
||||
|
||||
const result = resolveConcretePromotedWidget(
|
||||
this.subgraphNode,
|
||||
this.sourceNodeId,
|
||||
this.sourceWidgetName
|
||||
)
|
||||
const resolved = result.status === 'resolved' ? result.resolved : undefined
|
||||
|
||||
if (frame !== undefined) {
|
||||
this.cachedDeepestFrame = frame
|
||||
this.cachedDeepestByFrame = resolved
|
||||
}
|
||||
|
||||
return resolved
|
||||
}
|
||||
|
||||
private getWidgetState() {
|
||||
const linkedState = this.getLinkedInputWidgetStates()[0]
|
||||
if (linkedState) return linkedState
|
||||
|
||||
const resolved = this.resolveDeepest()
|
||||
if (!resolved) return undefined
|
||||
return useWidgetValueStore().getWidget(
|
||||
this.graphId,
|
||||
stripGraphPrefix(String(resolved.node.id)),
|
||||
resolved.widget.name
|
||||
)
|
||||
}
|
||||
|
||||
private getLinkedInputWidgets(): Array<{
|
||||
nodeId: NodeId
|
||||
widgetName: string
|
||||
widget: IBaseWidget
|
||||
}> {
|
||||
const linkedInputSlot = this.subgraphNode.inputs.find((input) => {
|
||||
if (!input._subgraphSlot) return false
|
||||
if (matchPromotedInput([input], this) !== input) return false
|
||||
|
||||
const boundWidget = input._widget
|
||||
if (boundWidget === this) return true
|
||||
|
||||
if (boundWidget && isPromotedWidgetView(boundWidget)) {
|
||||
return (
|
||||
boundWidget.sourceNodeId === this.sourceNodeId &&
|
||||
boundWidget.sourceWidgetName === this.sourceWidgetName
|
||||
)
|
||||
}
|
||||
|
||||
return input._subgraphSlot
|
||||
.getConnectedWidgets()
|
||||
.filter(hasWidgetNode)
|
||||
.some(
|
||||
(widget) =>
|
||||
String(widget.node.id) === this.sourceNodeId &&
|
||||
widget.name === this.sourceWidgetName
|
||||
)
|
||||
})
|
||||
const linkedInput = linkedInputSlot?._subgraphSlot
|
||||
if (!linkedInput) return []
|
||||
|
||||
return linkedInput
|
||||
.getConnectedWidgets()
|
||||
.filter(hasWidgetNode)
|
||||
.map((widget) => ({
|
||||
nodeId: stripGraphPrefix(String(widget.node.id)),
|
||||
widgetName: widget.name,
|
||||
widget
|
||||
}))
|
||||
}
|
||||
|
||||
private getLinkedInputWidgetStates(): WidgetState[] {
|
||||
const widgetStore = useWidgetValueStore()
|
||||
|
||||
return this.getLinkedInputWidgets()
|
||||
.map(({ nodeId, widgetName }) =>
|
||||
widgetStore.getWidget(this.graphId, nodeId, widgetName)
|
||||
)
|
||||
.filter((state): state is WidgetState => state !== undefined)
|
||||
}
|
||||
|
||||
private getProjectedWidget(resolved: {
|
||||
node: LGraphNode
|
||||
widget: IBaseWidget
|
||||
}): BaseWidget | undefined {
|
||||
const shouldRebuild =
|
||||
!this.projectedWidget ||
|
||||
this.projectedSourceNode !== resolved.node ||
|
||||
this.projectedSourceWidget !== resolved.widget ||
|
||||
this.projectedSourceWidgetType !== resolved.widget.type
|
||||
|
||||
if (!shouldRebuild) return this.projectedWidget
|
||||
|
||||
const concrete = toConcreteWidget(resolved.widget, resolved.node, false)
|
||||
if (!concrete) {
|
||||
this.projectedWidget = undefined
|
||||
this.projectedSourceNode = undefined
|
||||
this.projectedSourceWidget = undefined
|
||||
this.projectedSourceWidgetType = undefined
|
||||
return undefined
|
||||
}
|
||||
|
||||
this.projectedWidget = concrete.createCopyForNode(this.subgraphNode)
|
||||
this.projectedSourceNode = resolved.node
|
||||
this.projectedSourceWidget = resolved.widget
|
||||
this.projectedSourceWidgetType = resolved.widget.type
|
||||
return this.projectedWidget
|
||||
}
|
||||
|
||||
private bindConcretePointerHandlers(
|
||||
pointer: CanvasPointer,
|
||||
canvas: LGraphCanvas,
|
||||
concrete: BaseWidget
|
||||
): boolean {
|
||||
const downEvent = pointer.eDown
|
||||
if (!downEvent) return false
|
||||
|
||||
pointer.onClick = () =>
|
||||
concrete.onClick({
|
||||
e: downEvent,
|
||||
node: this.subgraphNode,
|
||||
canvas
|
||||
})
|
||||
pointer.onDrag = (eMove) =>
|
||||
concrete.onDrag?.({
|
||||
e: eMove,
|
||||
node: this.subgraphNode,
|
||||
canvas
|
||||
})
|
||||
return true
|
||||
}
|
||||
|
||||
private handleLegacyMouse(
|
||||
pointer: CanvasPointer,
|
||||
interior: LegacyMouseWidget
|
||||
): boolean {
|
||||
const downEvent = pointer.eDown
|
||||
if (!downEvent) return false
|
||||
|
||||
const downPosition: Point = [
|
||||
downEvent.canvasX - this.subgraphNode.pos[0],
|
||||
downEvent.canvasY - this.subgraphNode.pos[1]
|
||||
]
|
||||
interior.mouse(downEvent, downPosition, this.subgraphNode)
|
||||
|
||||
pointer.finally = () => {
|
||||
const upEvent = pointer.eUp
|
||||
if (!upEvent) return
|
||||
|
||||
const upPosition: Point = [
|
||||
upEvent.canvasX - this.subgraphNode.pos[0],
|
||||
upEvent.canvasY - this.subgraphNode.pos[1]
|
||||
]
|
||||
interior.mouse(upEvent, upPosition, this.subgraphNode)
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
private syncDomOverride(
|
||||
resolved:
|
||||
| { node: LGraphNode; widget: IBaseWidget }
|
||||
| undefined = this.resolveAtHost()
|
||||
) {
|
||||
if (!resolved || !isBaseDOMWidget(resolved.widget)) return
|
||||
useDomWidgetStore().setPositionOverride(resolved.widget.id, {
|
||||
node: this.subgraphNode,
|
||||
widget: this
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function isBaseDOMWidget(
|
||||
widget: IBaseWidget
|
||||
): widget is IBaseWidget & { id: string } {
|
||||
return 'id' in widget && ('element' in widget || 'component' in widget)
|
||||
}
|
||||
|
||||
function drawDisconnectedPlaceholder(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
width: number,
|
||||
y: number,
|
||||
H: number
|
||||
) {
|
||||
const backgroundColor = readDesignToken(
|
||||
'--color-secondary-background',
|
||||
'#333'
|
||||
)
|
||||
const textColor = readDesignToken('--color-text-secondary', '#999')
|
||||
const fontSize = readDesignToken('--text-2xs', '11px')
|
||||
const fontFamily = readDesignToken('--font-inter', 'sans-serif')
|
||||
|
||||
ctx.save()
|
||||
ctx.fillStyle = backgroundColor
|
||||
ctx.fillRect(15, y, width - 30, H)
|
||||
ctx.fillStyle = textColor
|
||||
ctx.font = `${fontSize} ${fontFamily}`
|
||||
ctx.textAlign = 'center'
|
||||
ctx.textBaseline = 'middle'
|
||||
ctx.fillText(t('subgraphStore.disconnected'), width / 2, y + H / 2)
|
||||
ctx.restore()
|
||||
}
|
||||
|
||||
function readDesignToken(token: string, fallback: string): string {
|
||||
if (typeof document === 'undefined') return fallback
|
||||
|
||||
const cachedValue = designTokenCache.get(token)
|
||||
if (cachedValue) return cachedValue
|
||||
|
||||
const value = getComputedStyle(document.documentElement)
|
||||
.getPropertyValue(token)
|
||||
.trim()
|
||||
const resolvedValue = value || fallback
|
||||
designTokenCache.set(token, resolvedValue)
|
||||
return resolvedValue
|
||||
}
|
||||
@@ -3,22 +3,46 @@ import { fromPartial } from '@total-typescript/shoehorn'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { promotedInputWidget } from '@/core/graph/subgraph/promotedInputWidget'
|
||||
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
|
||||
import {
|
||||
createTestSubgraph,
|
||||
createTestSubgraphNode
|
||||
} from '@/lib/litegraph/src/subgraph/__fixtures__/subgraphHelpers'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
|
||||
import { usePreviewExposureStore } from '@/stores/previewExposureStore'
|
||||
import { useWidgetValueStore } from '@/stores/widgetValueStore'
|
||||
import type { WidgetId } from '@/types/widgetId'
|
||||
|
||||
function widgetSourceNodeId(w: IBaseWidget): string | undefined {
|
||||
return isPromotedWidgetView(w) ? w.sourceNodeId : undefined
|
||||
function promotedInputNames(host: {
|
||||
inputs: Array<{ widgetId?: unknown; name: string }>
|
||||
}) {
|
||||
return host.inputs
|
||||
.filter((input) => input.widgetId)
|
||||
.map((input) => input.name)
|
||||
}
|
||||
|
||||
type TestPromotedWidget = IBaseWidget & {
|
||||
sourceNodeId: string
|
||||
sourceWidgetName: string
|
||||
function promotedHostWidgetNames(host: { widgets?: IBaseWidget[] }) {
|
||||
return host.widgets?.map((widget) => widget.name) ?? []
|
||||
}
|
||||
|
||||
function writePromotedInputValue(
|
||||
host: { inputs: Array<{ widgetId?: WidgetId; name: string }> },
|
||||
name: string,
|
||||
value: IBaseWidget['value']
|
||||
) {
|
||||
const input = host.inputs.find((input) => input.name === name)
|
||||
if (!input?.widgetId) throw new Error(`Missing promoted input ${name}`)
|
||||
useWidgetValueStore().setValue(input.widgetId, value)
|
||||
}
|
||||
|
||||
function promotedWidgetRef(host: SubgraphNode, name: string): IBaseWidget {
|
||||
const input = host.inputs.find((input) => input.name === name)
|
||||
if (!input?.widgetId) throw new Error(`Missing promoted input ${name}`)
|
||||
const widget = promotedInputWidget(input)
|
||||
if (!widget) throw new Error(`Missing promoted input ${name}`)
|
||||
return widget
|
||||
}
|
||||
|
||||
const updatePreviewsMock = vi.hoisted(() => vi.fn())
|
||||
@@ -31,11 +55,9 @@ import {
|
||||
autoExposeKnownPreviewNodes,
|
||||
demoteWidget,
|
||||
getPromotableWidgets,
|
||||
getWidgetName,
|
||||
hasUnpromotedWidgets,
|
||||
isLinkedPromotion,
|
||||
isPreviewPseudoWidget,
|
||||
isWidgetPromotedOnSubgraphNode,
|
||||
promoteValueWidgetViaSubgraphInput,
|
||||
promoteRecommendedWidgets,
|
||||
pruneDisconnected,
|
||||
@@ -168,15 +190,18 @@ describe('pruneDisconnected', () => {
|
||||
promoteValueWidgetViaSubgraphInput(subgraphNode, interiorNode, keptWidget)
|
||||
|
||||
const missingWidgetInput = subgraph.addInput('missing-widget', 'STRING')
|
||||
missingWidgetInput._widget = fromPartial<TestPromotedWidget>({
|
||||
sourceNodeId: String(interiorNode.id),
|
||||
sourceWidgetName: 'missing-widget'
|
||||
})
|
||||
const missingNodeInput = subgraph.addInput('missing-node', 'STRING')
|
||||
missingNodeInput._widget = fromPartial<TestPromotedWidget>({
|
||||
sourceNodeId: '9999',
|
||||
sourceWidgetName: 'missing-node'
|
||||
})
|
||||
const keptWidgetId = subgraphNode.inputs.find(
|
||||
(input) => input.name === 'kept'
|
||||
)?.widgetId
|
||||
if (!keptWidgetId) throw new Error('Missing kept widgetId')
|
||||
for (const input of [missingWidgetInput, missingNodeInput]) {
|
||||
const hostInput = subgraphNode.inputs.find(
|
||||
(entry) => entry._subgraphSlot === input
|
||||
)
|
||||
if (!hostInput) throw new Error(`Missing host input ${input.name}`)
|
||||
hostInput.widgetId = keptWidgetId
|
||||
}
|
||||
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
|
||||
@@ -301,6 +326,25 @@ describe('promoteRecommendedWidgets', () => {
|
||||
expect(subgraphNode.serialize().properties?.proxyWidgets).toBeUndefined()
|
||||
})
|
||||
|
||||
it('preserves the source slot label when promoting a value widget', () => {
|
||||
const subgraph = createTestSubgraph()
|
||||
const subgraphNode = createTestSubgraphNode(subgraph)
|
||||
const interiorNode = new LGraphNode('Prompt')
|
||||
const input = interiorNode.addInput('text', 'STRING')
|
||||
input.label = 'renamed_from_sidepanel'
|
||||
const textWidget = interiorNode.addWidget('text', 'text', '', () => {})
|
||||
input.widget = { name: textWidget.name }
|
||||
subgraph.add(interiorNode)
|
||||
|
||||
promoteValueWidgetViaSubgraphInput(subgraphNode, interiorNode, textWidget)
|
||||
|
||||
const hostInput = subgraphNode.inputs.find((input) => input.name === 'text')
|
||||
expect(hostInput?.label).toBe('renamed_from_sidepanel')
|
||||
expect(promotedWidgetRef(subgraphNode, 'text').label).toBe(
|
||||
'renamed_from_sidepanel'
|
||||
)
|
||||
})
|
||||
|
||||
it('promotes virtual previews through preview exposures', () => {
|
||||
const subgraph = createTestSubgraph()
|
||||
const subgraphNode = createTestSubgraphNode(subgraph)
|
||||
@@ -485,79 +529,45 @@ describe('isLinkedPromotion', () => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
})
|
||||
|
||||
function linkedWidget(
|
||||
sourceNodeId: string,
|
||||
sourceWidgetName: string,
|
||||
extra: Record<string, unknown> = {}
|
||||
): IBaseWidget {
|
||||
return {
|
||||
sourceNodeId,
|
||||
sourceWidgetName,
|
||||
name: 'value',
|
||||
type: 'text',
|
||||
value: '',
|
||||
options: {},
|
||||
y: 0,
|
||||
...extra
|
||||
} as unknown as IBaseWidget
|
||||
function promoteSource(host: SubgraphNode, widgetName: string): LGraphNode {
|
||||
const node = new LGraphNode('Source')
|
||||
const input = node.addInput(widgetName, 'STRING')
|
||||
const widget = node.addWidget('text', widgetName, '', () => {})
|
||||
input.widget = { name: widget.name }
|
||||
host.subgraph.add(node)
|
||||
promoteValueWidgetViaSubgraphInput(host, node, widget)
|
||||
return node
|
||||
}
|
||||
|
||||
function createSubgraphWithInputs(count = 1) {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: Array.from({ length: count }, (_, i) => ({
|
||||
name: `input_${i}`,
|
||||
type: 'STRING' as const
|
||||
}))
|
||||
})
|
||||
return createTestSubgraphNode(subgraph)
|
||||
}
|
||||
it('returns true for a linked promotion', () => {
|
||||
const host = createTestSubgraphNode(createTestSubgraph())
|
||||
const node = promoteSource(host, 'text')
|
||||
|
||||
it('returns true when an input has a matching _widget', () => {
|
||||
const subgraphNode = createSubgraphWithInputs()
|
||||
subgraphNode.inputs[0]._widget = linkedWidget('3', 'text')
|
||||
|
||||
expect(isLinkedPromotion(subgraphNode, '3', 'text')).toBe(true)
|
||||
expect(isLinkedPromotion(host, String(node.id), 'text')).toBe(true)
|
||||
})
|
||||
|
||||
it('returns false when no inputs exist or none match', () => {
|
||||
const subgraph = createTestSubgraph()
|
||||
const subgraphNode = createTestSubgraphNode(subgraph)
|
||||
it('returns false when no promotion exists', () => {
|
||||
const host = createTestSubgraphNode(createTestSubgraph())
|
||||
|
||||
expect(isLinkedPromotion(subgraphNode, '999', 'nonexistent')).toBe(false)
|
||||
expect(isLinkedPromotion(host, '999', 'nonexistent')).toBe(false)
|
||||
})
|
||||
|
||||
it('returns false when sourceNodeId matches but sourceWidgetName does not', () => {
|
||||
const subgraphNode = createSubgraphWithInputs()
|
||||
subgraphNode.inputs[0]._widget = linkedWidget('3', 'text')
|
||||
it('returns false when sourceWidgetName does not match', () => {
|
||||
const host = createTestSubgraphNode(createTestSubgraph())
|
||||
const node = promoteSource(host, 'text')
|
||||
|
||||
expect(isLinkedPromotion(subgraphNode, '3', 'wrong_name')).toBe(false)
|
||||
expect(isLinkedPromotion(host, String(node.id), 'wrong_name')).toBe(false)
|
||||
})
|
||||
|
||||
it('returns false when _widget is undefined on input', () => {
|
||||
const subgraphNode = createSubgraphWithInputs()
|
||||
it('identifies linked widgets across different inputs', () => {
|
||||
const host = createTestSubgraphNode(createTestSubgraph())
|
||||
const nodeA = promoteSource(host, 'string_a')
|
||||
const nodeB = promoteSource(host, 'value')
|
||||
|
||||
expect(isLinkedPromotion(subgraphNode, '3', 'text')).toBe(false)
|
||||
})
|
||||
|
||||
it('matches by sourceNodeId even when disambiguatingSourceNodeId is present', () => {
|
||||
const subgraphNode = createSubgraphWithInputs()
|
||||
subgraphNode.inputs[0]._widget = linkedWidget('6', 'text', {
|
||||
disambiguatingSourceNodeId: '1'
|
||||
})
|
||||
|
||||
expect(isLinkedPromotion(subgraphNode, '6', 'text')).toBe(true)
|
||||
expect(isLinkedPromotion(subgraphNode, '1', 'text')).toBe(false)
|
||||
})
|
||||
|
||||
it('identifies multiple linked widgets across different inputs', () => {
|
||||
const subgraphNode = createSubgraphWithInputs(2)
|
||||
subgraphNode.inputs[0]._widget = linkedWidget('3', 'string_a')
|
||||
subgraphNode.inputs[1]._widget = linkedWidget('4', 'value')
|
||||
|
||||
expect(isLinkedPromotion(subgraphNode, '3', 'string_a')).toBe(true)
|
||||
expect(isLinkedPromotion(subgraphNode, '4', 'value')).toBe(true)
|
||||
expect(isLinkedPromotion(subgraphNode, '3', 'value')).toBe(false)
|
||||
expect(isLinkedPromotion(subgraphNode, '5', 'string_a')).toBe(false)
|
||||
expect(isLinkedPromotion(host, String(nodeA.id), 'string_a')).toBe(true)
|
||||
expect(isLinkedPromotion(host, String(nodeB.id), 'value')).toBe(true)
|
||||
expect(isLinkedPromotion(host, String(nodeA.id), 'value')).toBe(false)
|
||||
expect(isLinkedPromotion(host, '5', 'string_a')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -607,17 +617,13 @@ describe('reorderSubgraphInputsByName', () => {
|
||||
promoteValueWidgetViaSubgraphInput(host, firstNode, firstWidget)
|
||||
promoteValueWidgetViaSubgraphInput(host, secondNode, secondWidget)
|
||||
|
||||
expect(host.widgets.map((widget) => widget.name)).toEqual([
|
||||
'first',
|
||||
'second'
|
||||
])
|
||||
expect(promotedInputNames(host)).toEqual(['first', 'second'])
|
||||
expect(promotedHostWidgetNames(host)).toEqual(['first', 'second'])
|
||||
|
||||
reorderSubgraphInputsByName(host, ['second', 'first'])
|
||||
|
||||
expect(host.widgets.map((widget) => widget.name)).toEqual([
|
||||
'second',
|
||||
'first'
|
||||
])
|
||||
expect(promotedInputNames(host)).toEqual(['second', 'first'])
|
||||
expect(promotedHostWidgetNames(host)).toEqual(['second', 'first'])
|
||||
})
|
||||
|
||||
it('keeps promoted widget values aligned when a plain input is reordered before them', () => {
|
||||
@@ -637,15 +643,13 @@ describe('reorderSubgraphInputsByName', () => {
|
||||
promoteValueWidgetViaSubgraphInput(host, firstNode, firstWidget)
|
||||
subgraph.addInput('plain', 'STRING')
|
||||
promoteValueWidgetViaSubgraphInput(host, secondNode, secondWidget)
|
||||
host.widgets[0].value = 'first value'
|
||||
host.widgets[1].value = 'second value'
|
||||
writePromotedInputValue(host, 'first', 'first value')
|
||||
writePromotedInputValue(host, 'second', 'second value')
|
||||
|
||||
reorderSubgraphInputsByName(host, ['plain', 'second', 'first'])
|
||||
|
||||
expect(host.widgets.map((widget) => widget.name)).toEqual([
|
||||
'second',
|
||||
'first'
|
||||
])
|
||||
expect(promotedInputNames(host)).toEqual(['second', 'first'])
|
||||
expect(promotedHostWidgetNames(host)).toEqual(['second', 'first'])
|
||||
expect(host.serialize().widgets_values).toEqual([
|
||||
'second value',
|
||||
'first value'
|
||||
@@ -727,15 +731,21 @@ describe('reorderSubgraphInputsByWidgetOrder', () => {
|
||||
secondInput.widget = { name: secondWidget.name }
|
||||
promoteValueWidgetViaSubgraphInput(host, firstNode, firstWidget)
|
||||
promoteValueWidgetViaSubgraphInput(host, secondNode, secondWidget)
|
||||
host.widgets[0].value = 'first value'
|
||||
host.widgets[1].value = 'second value'
|
||||
writePromotedInputValue(host, 'text', 'first value')
|
||||
writePromotedInputValue(host, 'text_1', 'second value')
|
||||
|
||||
reorderSubgraphInputsByWidgetOrder(host, [host.widgets[1], host.widgets[0]])
|
||||
|
||||
expect(host.widgets.map((widget) => widgetSourceNodeId(widget))).toEqual([
|
||||
String(secondNode.id),
|
||||
String(firstNode.id)
|
||||
const firstPromotedWidget = promotedWidgetRef(host, 'text')
|
||||
const secondPromotedWidget = promotedWidgetRef(host, 'text_1')
|
||||
reorderSubgraphInputsByWidgetOrder(host, [
|
||||
secondPromotedWidget,
|
||||
firstPromotedWidget
|
||||
])
|
||||
|
||||
expect(host.subgraph.inputs.map((input) => input.name)).toEqual([
|
||||
'text_1',
|
||||
'text'
|
||||
])
|
||||
expect(promotedHostWidgetNames(host)).toEqual(['text_1', 'text'])
|
||||
expect(host.serialize().widgets_values).toEqual([
|
||||
'second value',
|
||||
'first value'
|
||||
@@ -775,10 +785,10 @@ describe('demoteWidget — axiomatic projection retraction', () => {
|
||||
const { host, interiorNode, interiorWidget } = setupPromotedWidget()
|
||||
const hostInput = host.inputs[0]
|
||||
hostInput.link = 9999
|
||||
const promotedViewsBefore = host.widgets.length
|
||||
const promotedInputId = hostInput.widgetId
|
||||
|
||||
expect(host.subgraph.inputs).toHaveLength(1)
|
||||
expect(promotedViewsBefore).toBeGreaterThan(0)
|
||||
expect(promotedInputId).toBeDefined()
|
||||
|
||||
demoteWidget(interiorNode, interiorWidget, [host])
|
||||
|
||||
@@ -788,13 +798,9 @@ describe('demoteWidget — axiomatic projection retraction', () => {
|
||||
expect(
|
||||
isLinkedPromotion(host, String(interiorNode.id), interiorWidget.name)
|
||||
).toBe(false)
|
||||
expect(
|
||||
host.widgets.some(
|
||||
(widget) =>
|
||||
widgetSourceNodeId(widget) === String(interiorNode.id) &&
|
||||
widget.name === interiorWidget.name
|
||||
)
|
||||
).toBe(false)
|
||||
expect(host.widgets).toHaveLength(0)
|
||||
if (!promotedInputId) throw new Error('Missing promoted input widgetId')
|
||||
expect(useWidgetValueStore().getWidget(promotedInputId)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('removes the slot entirely when host slot has no external link', () => {
|
||||
@@ -812,12 +818,7 @@ describe('demoteWidget — axiomatic projection retraction', () => {
|
||||
const { host, nodeA, widgetA, nodeB, widgetB } =
|
||||
buildDuplicateNamePromotion()
|
||||
|
||||
const promotedViewForB = host.widgets.find(
|
||||
(w) => isPromotedWidgetView(w) && w.sourceNodeId === String(nodeB.id)
|
||||
)
|
||||
expect(promotedViewForB!.name).toBe('text_1')
|
||||
|
||||
demoteWidget(nodeB, promotedViewForB!, [host])
|
||||
demoteWidget(nodeB, widgetB, [host])
|
||||
|
||||
expect(host.subgraph.inputs.map((i) => i.name)).toEqual(['text'])
|
||||
expect(isLinkedPromotion(host, String(nodeB.id), widgetB.name)).toBe(false)
|
||||
@@ -825,15 +826,19 @@ describe('demoteWidget — axiomatic projection retraction', () => {
|
||||
})
|
||||
|
||||
it('demotes the correct slot when widget lives on a nested SubgraphNode with same-named deep sources', () => {
|
||||
const { host: innerHost, nodeB } = buildDuplicateNamePromotion()
|
||||
const { host: innerHost } = buildDuplicateNamePromotion()
|
||||
|
||||
const outerSubgraph = createTestSubgraph()
|
||||
const outerHost = createTestSubgraphNode(outerSubgraph)
|
||||
outerSubgraph.add(innerHost)
|
||||
|
||||
for (const w of [...innerHost.widgets]) {
|
||||
for (const input of innerHost.inputs) {
|
||||
expect(
|
||||
promoteValueWidgetViaSubgraphInput(outerHost, innerHost, w).ok
|
||||
promoteValueWidgetViaSubgraphInput(
|
||||
outerHost,
|
||||
innerHost,
|
||||
promotedWidgetRef(innerHost, input.name)
|
||||
).ok
|
||||
).toBe(true)
|
||||
}
|
||||
expect(outerHost.subgraph.inputs.map((i) => i.name)).toEqual([
|
||||
@@ -841,12 +846,7 @@ describe('demoteWidget — axiomatic projection retraction', () => {
|
||||
'text_1'
|
||||
])
|
||||
|
||||
const innerViewForB = innerHost.widgets.find(
|
||||
(w) => isPromotedWidgetView(w) && w.sourceNodeId === String(nodeB.id)
|
||||
)
|
||||
expect(innerViewForB!.name).toBe('text_1')
|
||||
|
||||
demoteWidget(innerHost, innerViewForB!, [outerHost])
|
||||
demoteWidget(innerHost, promotedWidgetRef(innerHost, 'text_1'), [outerHost])
|
||||
|
||||
expect(outerHost.subgraph.inputs.map((i) => i.name)).toEqual(['text'])
|
||||
expect(isLinkedPromotion(outerHost, String(innerHost.id), 'text_1')).toBe(
|
||||
@@ -863,66 +863,19 @@ describe('disambiguated nested promotion identity', () => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
})
|
||||
|
||||
function linkedView(
|
||||
sourceNodeId: string,
|
||||
sourceWidgetName: string,
|
||||
overrides: Record<string, unknown> = {}
|
||||
): IBaseWidget {
|
||||
return {
|
||||
sourceNodeId,
|
||||
sourceWidgetName,
|
||||
name: sourceWidgetName,
|
||||
type: 'text',
|
||||
value: '',
|
||||
options: {},
|
||||
y: 0,
|
||||
...overrides
|
||||
} as unknown as IBaseWidget
|
||||
}
|
||||
|
||||
function createSubgraphHost() {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'text_1', type: 'STRING' }]
|
||||
})
|
||||
return createTestSubgraphNode(subgraph)
|
||||
}
|
||||
|
||||
it('identifies a promoted nested view by its immediate slot name, not its deep source widget name', () => {
|
||||
const host = createSubgraphHost()
|
||||
host.inputs[0]._widget = linkedView('inner', 'text_1')
|
||||
|
||||
const interiorWidget = linkedView('inner', 'text', { name: 'text_1' })
|
||||
const interiorNode = {
|
||||
id: 'inner',
|
||||
title: 'inner',
|
||||
type: 'inner'
|
||||
} as unknown as LGraphNode
|
||||
|
||||
const source = {
|
||||
sourceNodeId: String(interiorNode.id),
|
||||
sourceWidgetName: getWidgetName(interiorWidget)
|
||||
}
|
||||
|
||||
expect(isWidgetPromotedOnSubgraphNode(host, source, interiorWidget)).toBe(
|
||||
true
|
||||
)
|
||||
})
|
||||
|
||||
it('does not prune a promotion whose source is a nested SubgraphNode exposing a disambiguated widget', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'text_1', type: 'STRING' }]
|
||||
})
|
||||
const { host: innerHost } = buildDuplicateNamePromotion()
|
||||
const subgraph = createTestSubgraph()
|
||||
const host = createTestSubgraphNode(subgraph)
|
||||
subgraph.add(innerHost)
|
||||
|
||||
const nestedSubgraphNode = {
|
||||
id: 'inner',
|
||||
title: 'inner',
|
||||
type: 'inner',
|
||||
widgets: [linkedView('deep', 'text', { name: 'text_1' })]
|
||||
} as unknown as LGraphNode
|
||||
subgraph.add(nestedSubgraphNode)
|
||||
|
||||
host.inputs[0]._widget = linkedView('inner', 'text_1')
|
||||
expect(
|
||||
promoteValueWidgetViaSubgraphInput(
|
||||
host,
|
||||
innerHost,
|
||||
promotedWidgetRef(innerHost, 'text_1')
|
||||
).ok
|
||||
).toBe(true)
|
||||
|
||||
pruneDisconnected(host)
|
||||
|
||||
@@ -956,9 +909,13 @@ describe('disambiguated nested promotion identity', () => {
|
||||
const outerHost = createTestSubgraphNode(outerSubgraph)
|
||||
outerSubgraph.add(innerHost)
|
||||
|
||||
for (const w of [...innerHost.widgets]) {
|
||||
for (const input of innerHost.inputs) {
|
||||
expect(
|
||||
promoteValueWidgetViaSubgraphInput(outerHost, innerHost, w).ok
|
||||
promoteValueWidgetViaSubgraphInput(
|
||||
outerHost,
|
||||
innerHost,
|
||||
promotedWidgetRef(innerHost, input.name)
|
||||
).ok
|
||||
).toBe(true)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import cloneDeep from 'es-toolkit/compat/cloneDeep'
|
||||
import * as Sentry from '@sentry/vue'
|
||||
import type { PromotedWidgetSource } from '@/core/graph/subgraph/promotedWidgetTypes'
|
||||
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
|
||||
import { t } from '@/i18n'
|
||||
import type { IContextMenuValue } from '@/lib/litegraph/src/litegraph'
|
||||
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
@@ -18,7 +18,9 @@ import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { useLitegraphService } from '@/services/litegraphService'
|
||||
import { usePreviewExposureStore } from '@/stores/previewExposureStore'
|
||||
import { useSubgraphNavigationStore } from '@/stores/subgraphNavigationStore'
|
||||
import { readWidgetValue } from '@/world/widgetValueIO'
|
||||
import type { WidgetId } from '@/types/widgetId'
|
||||
import { widgetId } from '@/types/widgetId'
|
||||
import { useWidgetValueStore } from '@/stores/widgetValueStore'
|
||||
|
||||
type PartialNode = Pick<LGraphNode, 'title' | 'id' | 'type'>
|
||||
|
||||
@@ -46,16 +48,47 @@ export function findHostInputForPromotion(
|
||||
sourceWidgetName: string
|
||||
) {
|
||||
return subgraphNode.inputs.find((input) => {
|
||||
const w = input._widget
|
||||
const source = input._subgraphSlot
|
||||
? resolvePromotionSource(subgraphNode, input._subgraphSlot)
|
||||
: undefined
|
||||
return (
|
||||
w &&
|
||||
isPromotedWidgetView(w) &&
|
||||
w.sourceNodeId === sourceNodeId &&
|
||||
w.sourceWidgetName === sourceWidgetName
|
||||
source?.sourceNodeId === sourceNodeId &&
|
||||
source.sourceWidgetName === sourceWidgetName
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
function resolvePromotionSource(
|
||||
subgraphNode: SubgraphNode,
|
||||
subgraphInput: { linkIds: readonly number[] }
|
||||
): PromotedWidgetSource | undefined {
|
||||
for (const linkId of subgraphInput.linkIds) {
|
||||
const link = subgraphNode.subgraph.getLink(linkId)
|
||||
if (!link) continue
|
||||
|
||||
const { inputNode } = link.resolve(subgraphNode.subgraph)
|
||||
if (!inputNode || !Array.isArray(inputNode.inputs)) continue
|
||||
|
||||
const targetInput = inputNode.inputs.find((entry) => entry.link === linkId)
|
||||
if (!targetInput) continue
|
||||
|
||||
if (inputNode.isSubgraphNode()) {
|
||||
return {
|
||||
sourceNodeId: String(inputNode.id),
|
||||
sourceWidgetName: targetInput.name
|
||||
}
|
||||
}
|
||||
|
||||
const targetWidget = inputNode.getWidgetFromSlot(targetInput)
|
||||
if (!targetWidget) continue
|
||||
|
||||
return {
|
||||
sourceNodeId: String(inputNode.id),
|
||||
sourceWidgetName: targetWidget.name
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function reorderSubgraphInputsByName(
|
||||
subgraphNode: SubgraphNode,
|
||||
orderedInputNames: readonly string[]
|
||||
@@ -78,13 +111,12 @@ export function reorderSubgraphInputsByName(
|
||||
|
||||
export function reorderSubgraphInputsByWidgetOrder(
|
||||
subgraphNode: SubgraphNode,
|
||||
orderedWidgets: readonly IBaseWidget[]
|
||||
orderedWidgets: readonly Pick<IBaseWidget, 'widgetId'>[]
|
||||
): void {
|
||||
const remainingIndices = new Set(subgraphNode.inputs.keys())
|
||||
const orderedIndices = orderedWidgets.flatMap((orderedWidget) => {
|
||||
for (const index of remainingIndices) {
|
||||
const widget = subgraphNode.inputs[index]?._widget
|
||||
if (widget && isSamePromotedWidget(widget, orderedWidget)) {
|
||||
if (isSamePromotedInput(subgraphNode, index, orderedWidget)) {
|
||||
remainingIndices.delete(index)
|
||||
return [index]
|
||||
}
|
||||
@@ -101,37 +133,48 @@ function applySubgraphInputOrder(
|
||||
subgraphNode: SubgraphNode,
|
||||
orderedIndices: readonly number[]
|
||||
): void {
|
||||
const widgetValues = subgraphNode.inputs.map((input) =>
|
||||
getExplicitHostWidgetValue(input?._widget)
|
||||
)
|
||||
const widgetValues = subgraphNode.inputs.map((input) => {
|
||||
const id = input?.widgetId
|
||||
if (!id) return undefined
|
||||
const value = useWidgetValueStore().getWidget(id)?.value
|
||||
return isWidgetValue(value) ? value : undefined
|
||||
})
|
||||
|
||||
reorderSubgraphInputs(subgraphNode, orderedIndices)
|
||||
|
||||
for (const [newIndex, oldIndex] of orderedIndices.entries()) {
|
||||
const value = widgetValues[oldIndex]
|
||||
if (value === undefined) continue
|
||||
const widget = subgraphNode.inputs[newIndex]?._widget
|
||||
if (widget) widget.value = value
|
||||
const id = subgraphNode.inputs[newIndex]?.widgetId
|
||||
if (value === undefined || !id) continue
|
||||
useWidgetValueStore().setValue(id, value)
|
||||
}
|
||||
}
|
||||
|
||||
function getExplicitHostWidgetValue(
|
||||
widget: IBaseWidget | undefined
|
||||
): IBaseWidget['value'] | undefined {
|
||||
if (!widget) return undefined
|
||||
if (!isPromotedWidgetView(widget)) return widget.value
|
||||
function isSamePromotedInput(
|
||||
subgraphNode: SubgraphNode,
|
||||
inputIndex: number,
|
||||
orderedWidget: Pick<IBaseWidget, 'widgetId'>
|
||||
): boolean {
|
||||
const input = subgraphNode.inputs[inputIndex]
|
||||
const linkedInput = input?._subgraphSlot
|
||||
if (!input || !linkedInput) return false
|
||||
|
||||
const value = readWidgetValue(widget.entityId)
|
||||
return isWidgetValue(value) ? value : undefined
|
||||
}
|
||||
for (const linkId of linkedInput.linkIds) {
|
||||
const link = subgraphNode.subgraph.getLink(linkId)
|
||||
if (!link) continue
|
||||
|
||||
function isSamePromotedWidget(left: IBaseWidget, right: IBaseWidget): boolean {
|
||||
return (
|
||||
isPromotedWidgetView(left) &&
|
||||
isPromotedWidgetView(right) &&
|
||||
left.sourceNodeId === right.sourceNodeId &&
|
||||
left.sourceWidgetName === right.sourceWidgetName
|
||||
)
|
||||
const { inputNode, input: targetInput } = link.resolve(
|
||||
subgraphNode.subgraph
|
||||
)
|
||||
if (!inputNode || !targetInput) continue
|
||||
|
||||
const targetWidget = inputNode.getWidgetFromSlot(targetInput)
|
||||
if (targetWidget === orderedWidget) return true
|
||||
|
||||
if (input.widgetId && input.widgetId === orderedWidget.widgetId) return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
function isPreviewExposed(
|
||||
@@ -168,13 +211,9 @@ function toPromotionSource(
|
||||
node: PartialNode,
|
||||
widget: IBaseWidget
|
||||
): PromotedWidgetSource {
|
||||
const widgetIsParentLevelView =
|
||||
isPromotedWidgetView(widget) && widget.sourceNodeId === String(node.id)
|
||||
return {
|
||||
sourceNodeId: String(node.id),
|
||||
sourceWidgetName: widgetIsParentLevelView
|
||||
? widget.sourceWidgetName
|
||||
: getWidgetName(widget)
|
||||
sourceWidgetName: getWidgetName(widget)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -211,15 +250,53 @@ export function promoteValueWidgetViaSubgraphInput(
|
||||
inputName,
|
||||
String(sourceSlot.type ?? sourceWidget.type ?? '*')
|
||||
)
|
||||
subgraphInput.label = sourceSlot.label
|
||||
const link = subgraphInput.connect(sourceSlot, sourceNode)
|
||||
if (!link) {
|
||||
subgraphNode.subgraph.removeInput(subgraphInput)
|
||||
return { ok: false, reason: 'connectFailed' }
|
||||
}
|
||||
|
||||
const hostInput = subgraphNode.inputs.find(
|
||||
(input) => input._subgraphSlot === subgraphInput
|
||||
)
|
||||
if (hostInput) hostInput.label = sourceSlot.label
|
||||
|
||||
seedNestedPromotedInputState(subgraphNode, subgraphInput.name, sourceSlot)
|
||||
|
||||
return { ok: true }
|
||||
}
|
||||
|
||||
function seedNestedPromotedInputState(
|
||||
subgraphNode: SubgraphNode,
|
||||
inputName: string,
|
||||
sourceSlot: { widgetId?: WidgetId; label?: string }
|
||||
): void {
|
||||
if (!sourceSlot.widgetId) return
|
||||
|
||||
const hostInput = subgraphNode.inputs.find(
|
||||
(input) => input._subgraphSlot?.name === inputName
|
||||
)
|
||||
if (!hostInput || hostInput.widgetId) return
|
||||
|
||||
const sourceState = useWidgetValueStore().getWidget(sourceSlot.widgetId)
|
||||
if (!sourceState) return
|
||||
|
||||
const id = widgetId(subgraphNode.rootGraph.id, subgraphNode.id, inputName)
|
||||
hostInput.widget ??= { name: inputName }
|
||||
hostInput.widget.name = inputName
|
||||
hostInput.widgetId = id
|
||||
useWidgetValueStore().registerWidget(id, {
|
||||
type: sourceState.type,
|
||||
value: sourceState.value,
|
||||
options: cloneDeep(sourceState.options ?? {}),
|
||||
label: hostInput.label ?? sourceSlot.label ?? inputName,
|
||||
serialize: sourceState.serialize,
|
||||
disabled: sourceState.disabled,
|
||||
isDOMWidget: sourceState.isDOMWidget
|
||||
})
|
||||
}
|
||||
|
||||
function promotePreviewViaExposure(
|
||||
subgraphNode: SubgraphNode,
|
||||
sourceNode: LGraphNode,
|
||||
@@ -283,6 +360,32 @@ export function promoteWidget(
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes the host input projecting a linked promotion identified by source.
|
||||
* Returns true when an input was found and demoted.
|
||||
*/
|
||||
export function demotePromotedInput(
|
||||
subgraphNode: SubgraphNode,
|
||||
source: PromotedWidgetSource
|
||||
): boolean {
|
||||
if (!subgraphNode.subgraph) return false
|
||||
|
||||
const hostInput = findHostInputForPromotion(
|
||||
subgraphNode,
|
||||
source.sourceNodeId,
|
||||
source.sourceWidgetName
|
||||
)
|
||||
const linkedInput = hostInput?._subgraphSlot
|
||||
if (!linkedInput) return false
|
||||
|
||||
if (hostInput.link != null) {
|
||||
linkedInput.disconnect()
|
||||
} else {
|
||||
subgraphNode.subgraph.removeInput(linkedInput)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
export function demoteWidget(
|
||||
node: PartialNode,
|
||||
widget: IBaseWidget,
|
||||
@@ -292,21 +395,7 @@ export function demoteWidget(
|
||||
for (const parent of parents) {
|
||||
if (!parent.subgraph) continue
|
||||
|
||||
const hostInput = findHostInputForPromotion(
|
||||
parent,
|
||||
source.sourceNodeId,
|
||||
source.sourceWidgetName
|
||||
)
|
||||
const linkedInput = hostInput?._subgraphSlot
|
||||
if (linkedInput) {
|
||||
const hasExternalLink = hostInput.link != null
|
||||
if (hasExternalLink) {
|
||||
linkedInput.disconnect()
|
||||
} else {
|
||||
parent.subgraph.removeInput(linkedInput)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if (demotePromotedInput(parent, source)) continue
|
||||
|
||||
if (isPreviewPseudoWidget(widget)) {
|
||||
const previewStore = usePreviewExposureStore()
|
||||
@@ -505,37 +594,19 @@ export function pruneDisconnected(subgraphNode: SubgraphNode) {
|
||||
const removedEntries: PromotedWidgetSource[] = []
|
||||
|
||||
const staleInputs = subgraph.inputs.filter((input) => {
|
||||
const widget = input._widget
|
||||
if (!widget || !isPromotedWidgetView(widget)) return false
|
||||
const source = resolvePromotionSource(subgraphNode, input)
|
||||
if (source) return false
|
||||
|
||||
// If the SubgraphInput has any live link to an interior target slot that
|
||||
// still has a widget, the promotion is alive — even when the widget's
|
||||
// sourceNodeId points at a deeply-nested interior node that does not exist
|
||||
// directly in `subgraph` (nested SubgraphNode promotions).
|
||||
for (const linkId of input.linkIds) {
|
||||
const link = subgraph.getLink(linkId)
|
||||
if (!link) continue
|
||||
const { inputNode } = link.resolve(subgraph)
|
||||
if (!inputNode) continue
|
||||
const targetInputSlot = inputNode.inputs?.find(
|
||||
(slot) => slot.link === linkId
|
||||
)
|
||||
if (!targetInputSlot) continue
|
||||
if (inputNode.getWidgetFromSlot(targetInputSlot)) return false
|
||||
}
|
||||
|
||||
const node = subgraph.getNodeById(widget.sourceNodeId)
|
||||
if (!node) {
|
||||
removedEntries.push(widget)
|
||||
return true
|
||||
}
|
||||
const hasWidget = getPromotableWidgets(node).some(
|
||||
(iw) => iw.name === widget.sourceWidgetName
|
||||
const hostInput = subgraphNode.inputs.find(
|
||||
(entry) => entry._subgraphSlot === input
|
||||
)
|
||||
if (!hasWidget) {
|
||||
removedEntries.push(widget)
|
||||
}
|
||||
return !hasWidget
|
||||
if (!hostInput?.widgetId && !hostInput?._widget) return false
|
||||
|
||||
removedEntries.push({
|
||||
sourceNodeId: String(subgraphNode.id),
|
||||
sourceWidgetName: input.name
|
||||
})
|
||||
return true
|
||||
})
|
||||
|
||||
for (const input of staleInputs) {
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { fromAny } from '@total-typescript/shoehorn'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, test, vi } from 'vitest'
|
||||
|
||||
import {
|
||||
resolveConcretePromotedWidget,
|
||||
resolvePromotedWidgetAtHost
|
||||
} from '@/core/graph/subgraph/resolveConcretePromotedWidget'
|
||||
import { resolveConcretePromotedWidget } from '@/core/graph/subgraph/resolveConcretePromotedWidget'
|
||||
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
|
||||
import {
|
||||
@@ -24,15 +22,6 @@ vi.mock('@/services/litegraphService', () => ({
|
||||
useLitegraphService: () => ({ updatePreviews: () => ({}) })
|
||||
}))
|
||||
|
||||
type PromotedWidgetStub = Pick<
|
||||
IBaseWidget,
|
||||
'name' | 'type' | 'options' | 'value' | 'y'
|
||||
> & {
|
||||
sourceNodeId: string
|
||||
sourceWidgetName: string
|
||||
node?: SubgraphNode
|
||||
}
|
||||
|
||||
function createHostNode(id: number): SubgraphNode {
|
||||
return createTestSubgraphNode(createTestSubgraph(), { id })
|
||||
}
|
||||
@@ -47,55 +36,10 @@ function addConcreteWidget(node: LGraphNode, name: string): IBaseWidget {
|
||||
return node.addWidget('text', name, `${name}-value`, () => undefined)
|
||||
}
|
||||
|
||||
function createPromotedWidget(
|
||||
name: string,
|
||||
sourceNodeId: string,
|
||||
sourceWidgetName: string,
|
||||
node?: SubgraphNode
|
||||
): IBaseWidget {
|
||||
const promotedWidget: PromotedWidgetStub = {
|
||||
name,
|
||||
type: 'button',
|
||||
options: {},
|
||||
y: 0,
|
||||
value: undefined,
|
||||
sourceNodeId,
|
||||
sourceWidgetName,
|
||||
node
|
||||
}
|
||||
return promotedWidget as IBaseWidget
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
})
|
||||
|
||||
describe('resolvePromotedWidgetAtHost', () => {
|
||||
test('resolves a direct concrete widget on the host subgraph node', () => {
|
||||
const host = createHostNode(100)
|
||||
const concreteNode = addNodeToHost(host, 'leaf')
|
||||
addConcreteWidget(concreteNode, 'seed')
|
||||
|
||||
const resolved = resolvePromotedWidgetAtHost(
|
||||
host,
|
||||
String(concreteNode.id),
|
||||
'seed'
|
||||
)
|
||||
|
||||
expect(resolved).toBeDefined()
|
||||
expect(resolved?.node.id).toBe(concreteNode.id)
|
||||
expect(resolved?.widget.name).toBe('seed')
|
||||
})
|
||||
|
||||
test('returns undefined when host does not contain the target node', () => {
|
||||
const host = createHostNode(100)
|
||||
|
||||
const resolved = resolvePromotedWidgetAtHost(host, 'missing', 'seed')
|
||||
|
||||
expect(resolved).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolveConcretePromotedWidget', () => {
|
||||
test('resolves a direct concrete source widget', () => {
|
||||
const host = createHostNode(100)
|
||||
@@ -114,102 +58,86 @@ describe('resolveConcretePromotedWidget', () => {
|
||||
expect(result.resolved.widget.name).toBe('seed')
|
||||
})
|
||||
|
||||
test('descends through nested promoted widgets to resolve concrete source', () => {
|
||||
const rootHost = createHostNode(100)
|
||||
const nestedHost = createHostNode(101)
|
||||
const leafNode = addNodeToHost(nestedHost, 'leaf')
|
||||
addConcreteWidget(leafNode, 'seed')
|
||||
const sourceNode = addNodeToHost(rootHost, 'source')
|
||||
sourceNode.widgets = [
|
||||
createPromotedWidget('outer', String(leafNode.id), 'seed', nestedHost)
|
||||
]
|
||||
|
||||
const result = resolveConcretePromotedWidget(
|
||||
rootHost,
|
||||
String(sourceNode.id),
|
||||
'outer'
|
||||
)
|
||||
|
||||
expect(result.status).toBe('resolved')
|
||||
if (result.status !== 'resolved') return
|
||||
expect(result.resolved.node.id).toBe(leafNode.id)
|
||||
expect(result.resolved.widget.name).toBe('seed')
|
||||
})
|
||||
|
||||
test('returns cycle failure when promoted widgets form a loop', () => {
|
||||
const hostA = createHostNode(200)
|
||||
const hostB = createHostNode(201)
|
||||
const relayA = addNodeToHost(hostA, 'relayA')
|
||||
const relayB = addNodeToHost(hostB, 'relayB')
|
||||
|
||||
relayA.widgets = [
|
||||
createPromotedWidget('wA', String(relayB.id), 'wB', hostB)
|
||||
]
|
||||
relayB.widgets = [
|
||||
createPromotedWidget('wB', String(relayA.id), 'wA', hostA)
|
||||
]
|
||||
|
||||
const result = resolveConcretePromotedWidget(hostA, String(relayA.id), 'wA')
|
||||
|
||||
expect(result).toEqual({
|
||||
status: 'failure',
|
||||
failure: 'cycle'
|
||||
test('descends through nested subgraph inputs to the deepest concrete widget', () => {
|
||||
const innerSubgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'x', type: '*' }]
|
||||
})
|
||||
})
|
||||
const leaf = new LGraphNode('Leaf')
|
||||
const leafInput = leaf.addInput('x', '*')
|
||||
leaf.addWidget('combo', 'seed', 'a', () => undefined, {
|
||||
values: ['a', 'b']
|
||||
})
|
||||
leafInput.widget = { name: 'seed' }
|
||||
innerSubgraph.add(leaf)
|
||||
innerSubgraph.inputNode.slots[0].connect(leafInput, leaf)
|
||||
|
||||
test('does not report a cycle when different host objects share an id', () => {
|
||||
const rootHost = createHostNode(41)
|
||||
const nestedHost = createHostNode(41)
|
||||
const leafNode = addNodeToHost(nestedHost, 'leaf')
|
||||
addConcreteWidget(leafNode, 'w')
|
||||
const sourceNode = addNodeToHost(rootHost, 'source')
|
||||
sourceNode.widgets = [
|
||||
createPromotedWidget('w', String(leafNode.id), 'w', nestedHost)
|
||||
]
|
||||
const innerNode = createTestSubgraphNode(innerSubgraph, { id: 11 })
|
||||
|
||||
const outerSubgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'y', type: '*' }]
|
||||
})
|
||||
outerSubgraph.add(innerNode)
|
||||
innerNode._internalConfigureAfterSlots()
|
||||
outerSubgraph.inputNode.slots[0].connect(innerNode.inputs[0], innerNode)
|
||||
|
||||
const outerNode = createTestSubgraphNode(outerSubgraph, { id: 22 })
|
||||
|
||||
const result = resolveConcretePromotedWidget(
|
||||
rootHost,
|
||||
String(sourceNode.id),
|
||||
'w'
|
||||
outerNode,
|
||||
String(innerNode.id),
|
||||
'x'
|
||||
)
|
||||
|
||||
expect(result.status).toBe('resolved')
|
||||
if (result.status !== 'resolved') return
|
||||
|
||||
expect(result.resolved.node.id).toBe(leafNode.id)
|
||||
expect(result.resolved.widget.name).toBe('w')
|
||||
expect(result.resolved.node.id).toBe(leaf.id)
|
||||
expect(result.resolved.widget.name).toBe('seed')
|
||||
expect(result.resolved.widget.type).toBe('combo')
|
||||
})
|
||||
|
||||
test('returns max-depth-exceeded for very deep non-cyclic promoted chains', () => {
|
||||
const hosts = Array.from({ length: 102 }, (_, index) =>
|
||||
createHostNode(index + 1)
|
||||
)
|
||||
const relayNodes = hosts.map((host, index) =>
|
||||
addNodeToHost(host, `relay-${index}`)
|
||||
test('returns cycle when nested promoted widget traversal revisits the same input', () => {
|
||||
const recursiveInput = { name: 'x', link: 1 }
|
||||
const recursiveNode = fromAny<LGraphNode, unknown>({
|
||||
id: 11,
|
||||
inputs: [recursiveInput],
|
||||
isSubgraphNode: () => true,
|
||||
subgraph: {
|
||||
inputNode: { slots: [{ name: 'x', linkIds: [1] }] },
|
||||
getLink: () => ({
|
||||
resolve: () => ({ inputNode: recursiveNode })
|
||||
}),
|
||||
getNodeById: () => recursiveNode
|
||||
}
|
||||
})
|
||||
const host = fromAny<SubgraphNode, unknown>({
|
||||
isSubgraphNode: () => true,
|
||||
subgraph: {
|
||||
getNodeById: () => recursiveNode
|
||||
}
|
||||
})
|
||||
|
||||
const result = resolveConcretePromotedWidget(host, '11', 'x')
|
||||
|
||||
expect(result).toEqual({ status: 'failure', failure: 'cycle' })
|
||||
})
|
||||
|
||||
test('returns max-depth-exceeded for a chain over the traversal limit', () => {
|
||||
const subgraphs = Array.from({ length: 102 }, () =>
|
||||
createTestSubgraph({ inputs: [{ name: 'x', type: '*' }] })
|
||||
)
|
||||
|
||||
for (let index = 0; index < relayNodes.length - 1; index += 1) {
|
||||
relayNodes[index].widgets = [
|
||||
createPromotedWidget(
|
||||
`w-${index}`,
|
||||
String(relayNodes[index + 1].id),
|
||||
`w-${index + 1}`,
|
||||
hosts[index + 1]
|
||||
)
|
||||
]
|
||||
for (let index = 0; index < subgraphs.length - 1; index++) {
|
||||
const current = subgraphs[index]
|
||||
const next = subgraphs[index + 1]
|
||||
const nextNode = createTestSubgraphNode(next, { id: index + 1 })
|
||||
current.add(nextNode)
|
||||
nextNode._internalConfigureAfterSlots()
|
||||
current.inputNode.slots[0].connect(nextNode.inputs[0], nextNode)
|
||||
}
|
||||
|
||||
addConcreteWidget(
|
||||
relayNodes[relayNodes.length - 1],
|
||||
`w-${relayNodes.length - 1}`
|
||||
)
|
||||
|
||||
const result = resolveConcretePromotedWidget(
|
||||
hosts[0],
|
||||
String(relayNodes[0].id),
|
||||
'w-0'
|
||||
)
|
||||
const host = createTestSubgraphNode(subgraphs[0], { id: 200 })
|
||||
|
||||
const result = resolveConcretePromotedWidget(host, '1', 'x')
|
||||
expect(result).toEqual({
|
||||
status: 'failure',
|
||||
failure: 'max-depth-exceeded'
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import type { ResolvedPromotedWidget } from '@/core/graph/subgraph/promotedWidgetTypes'
|
||||
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
|
||||
import { resolveSubgraphInputTarget } from '@/core/graph/subgraph/resolveSubgraphInputTarget'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
|
||||
type PromotedWidgetResolutionFailure =
|
||||
| 'invalid-host'
|
||||
@@ -41,6 +40,17 @@ function traversePromotedWidgetChain(
|
||||
return { status: 'failure', failure: 'missing-node' }
|
||||
}
|
||||
|
||||
if (sourceNode.isSubgraphNode()) {
|
||||
const target = resolveSubgraphInputTarget(sourceNode, currentWidgetName)
|
||||
if (!target) {
|
||||
return { status: 'failure', failure: 'missing-widget' }
|
||||
}
|
||||
currentHost = sourceNode
|
||||
currentNodeId = target.nodeId
|
||||
currentWidgetName = target.widgetName
|
||||
continue
|
||||
}
|
||||
|
||||
const sourceWidget = sourceNode.widgets?.find(
|
||||
(entry) => entry.name === currentWidgetName
|
||||
)
|
||||
@@ -48,39 +58,15 @@ function traversePromotedWidgetChain(
|
||||
return { status: 'failure', failure: 'missing-widget' }
|
||||
}
|
||||
|
||||
if (!isPromotedWidgetView(sourceWidget)) {
|
||||
return {
|
||||
status: 'resolved',
|
||||
resolved: { node: sourceNode, widget: sourceWidget }
|
||||
}
|
||||
return {
|
||||
status: 'resolved',
|
||||
resolved: { node: sourceNode, widget: sourceWidget }
|
||||
}
|
||||
|
||||
if (!sourceWidget.node?.isSubgraphNode()) {
|
||||
return { status: 'failure', failure: 'missing-node' }
|
||||
}
|
||||
|
||||
currentHost = sourceWidget.node
|
||||
currentNodeId = sourceWidget.sourceNodeId
|
||||
currentWidgetName = sourceWidget.sourceWidgetName
|
||||
}
|
||||
|
||||
return { status: 'failure', failure: 'max-depth-exceeded' }
|
||||
}
|
||||
|
||||
export function resolvePromotedWidgetAtHost(
|
||||
hostNode: SubgraphNode,
|
||||
nodeId: string,
|
||||
widgetName: string
|
||||
): ResolvedPromotedWidget | undefined {
|
||||
const node = hostNode.subgraph.getNodeById(nodeId)
|
||||
if (!node) return undefined
|
||||
|
||||
const widget = node.widgets?.find((entry) => entry.name === widgetName)
|
||||
if (!widget) return undefined
|
||||
|
||||
return { node, widget }
|
||||
}
|
||||
|
||||
export function resolveConcretePromotedWidget(
|
||||
hostNode: LGraphNode,
|
||||
nodeId: string,
|
||||
@@ -91,20 +77,3 @@ export function resolveConcretePromotedWidget(
|
||||
}
|
||||
return traversePromotedWidgetChain(hostNode, nodeId, widgetName)
|
||||
}
|
||||
|
||||
export function resolvePromotedWidgetSource(
|
||||
hostNode: LGraphNode,
|
||||
widget: IBaseWidget
|
||||
): ResolvedPromotedWidget | undefined {
|
||||
if (!isPromotedWidgetView(widget)) return undefined
|
||||
if (!hostNode.isSubgraphNode()) return undefined
|
||||
|
||||
const result = resolveConcretePromotedWidget(
|
||||
hostNode,
|
||||
widget.sourceNodeId,
|
||||
widget.sourceWidgetName
|
||||
)
|
||||
if (result.status === 'resolved') return result.resolved
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
|
||||
export function hasWidgetNode(
|
||||
widget: IBaseWidget
|
||||
): widget is IBaseWidget & { node: LGraphNode } {
|
||||
return 'node' in widget && !!widget.node
|
||||
}
|
||||
@@ -1,7 +1,5 @@
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
|
||||
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
|
||||
|
||||
const widgetRenderKeys = new WeakMap<IBaseWidget, string>()
|
||||
let nextWidgetRenderKeyId = 0
|
||||
|
||||
@@ -9,9 +7,7 @@ export function getStableWidgetRenderKey(widget: IBaseWidget): string {
|
||||
const cachedKey = widgetRenderKeys.get(widget)
|
||||
if (cachedKey) return cachedKey
|
||||
|
||||
const prefix = isPromotedWidgetView(widget) ? 'promoted' : 'widget'
|
||||
const key = `${prefix}:${nextWidgetRenderKeyId++}`
|
||||
|
||||
const key = `widget:${nextWidgetRenderKeyId++}`
|
||||
widgetRenderKeys.set(widget, key)
|
||||
return key
|
||||
}
|
||||
|
||||
7
src/core/graph/widgets/control/controlWidgetMarker.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
|
||||
export const IS_CONTROL_WIDGET = Symbol()
|
||||
|
||||
export function isValueControlWidget(widget: IBaseWidget): boolean {
|
||||
return (widget as Record<symbol, unknown>)[IS_CONTROL_WIDGET] === true
|
||||
}
|
||||
35
src/core/graph/widgets/control/syncWidgetControl.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import type { NodeId } from '@/lib/litegraph/src/LGraphNode'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
import { useWidgetValueStore } from '@/stores/widgetValueStore'
|
||||
import type { WidgetId } from '@/types/widgetId'
|
||||
import { widgetId } from '@/types/widgetId'
|
||||
import type { UUID } from '@/utils/uuid'
|
||||
|
||||
import { isValueControlWidget } from './controlWidgetMarker'
|
||||
|
||||
/**
|
||||
* Registers (or clears) the control component for a target widget, derived from
|
||||
* its linked widgets. Called wherever the target's value is registered, so
|
||||
* control state follows the same deferral as value state.
|
||||
*/
|
||||
export function syncWidgetControl(
|
||||
targetId: WidgetId,
|
||||
graphId: UUID,
|
||||
nodeId: NodeId,
|
||||
linkedWidgets: readonly IBaseWidget[] | undefined
|
||||
): void {
|
||||
const store = useWidgetValueStore()
|
||||
const control = linkedWidgets?.find(isValueControlWidget)
|
||||
if (!control) {
|
||||
store.deleteWidgetControl(targetId)
|
||||
return
|
||||
}
|
||||
|
||||
const filter = linkedWidgets?.find(
|
||||
(widget) => widget !== control && widget.type === 'string'
|
||||
)
|
||||
store.registerWidgetControl(targetId, {
|
||||
controlWidgetId: widgetId(graphId, nodeId, control.name),
|
||||
filterWidgetId: filter ? widgetId(graphId, nodeId, filter.name) : undefined
|
||||
})
|
||||
}
|
||||
@@ -2,11 +2,12 @@ import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
|
||||
import { IS_CONTROL_WIDGET } from './controlWidgetMarker'
|
||||
import {
|
||||
computeNextControlledValue,
|
||||
IS_CONTROL_WIDGET,
|
||||
isValueControlWidget
|
||||
} from './valueControl'
|
||||
} from '@/core/graph/widgets/control/controlWidgetMarker'
|
||||
|
||||
import { computeNextControlledValue } from './valueControl'
|
||||
|
||||
const makeNumberWidget = (
|
||||
value: number,
|
||||
@@ -28,27 +29,15 @@ const makeComboWidget = (value: string, values: string[]): IBaseWidget =>
|
||||
}) as unknown as IBaseWidget
|
||||
|
||||
describe('isValueControlWidget', () => {
|
||||
it('returns true for a marked widget with both lifecycle hooks', () => {
|
||||
it('returns true for a marked widget', () => {
|
||||
const widget = {
|
||||
[IS_CONTROL_WIDGET]: true,
|
||||
beforeQueued: () => {},
|
||||
afterQueued: () => {}
|
||||
[IS_CONTROL_WIDGET]: true
|
||||
} as unknown as IBaseWidget
|
||||
expect(isValueControlWidget(widget)).toBe(true)
|
||||
})
|
||||
|
||||
it('returns false when the marker symbol is missing', () => {
|
||||
const widget = {
|
||||
beforeQueued: () => {},
|
||||
afterQueued: () => {}
|
||||
} as unknown as IBaseWidget
|
||||
expect(isValueControlWidget(widget)).toBe(false)
|
||||
})
|
||||
|
||||
it('returns false when lifecycle hooks are missing', () => {
|
||||
const widget = {
|
||||
[IS_CONTROL_WIDGET]: true
|
||||
} as unknown as IBaseWidget
|
||||
const widget = {} as unknown as IBaseWidget
|
||||
expect(isValueControlWidget(widget)).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -1,52 +1,37 @@
|
||||
import { isComboWidget } from '@/lib/litegraph/src/litegraph'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
|
||||
import { IS_CONTROL_WIDGET } from './controlWidgetMarker'
|
||||
|
||||
type ValueControlMode =
|
||||
export type ValueControlMode =
|
||||
| 'fixed'
|
||||
| 'increment'
|
||||
| 'increment-wrap'
|
||||
| 'decrement'
|
||||
| 'randomize'
|
||||
|
||||
export function nextValueForLinkedTarget(params: {
|
||||
target: IBaseWidget
|
||||
linkedWidgets: IBaseWidget[] | undefined
|
||||
nodeId: unknown
|
||||
isPartialExecution: boolean | undefined
|
||||
}): IBaseWidget['value'] | undefined {
|
||||
if (params.isPartialExecution) return undefined
|
||||
const linked = params.linkedWidgets
|
||||
if (!linked) return undefined
|
||||
const VALUE_CONTROL_MODES: ReadonlySet<string> = new Set([
|
||||
'fixed',
|
||||
'increment',
|
||||
'increment-wrap',
|
||||
'decrement',
|
||||
'randomize'
|
||||
])
|
||||
|
||||
const controlWidget = linked.find(isValueControlWidget)
|
||||
if (!controlWidget) return undefined
|
||||
export function isValueControlMode(value: unknown): value is ValueControlMode {
|
||||
return typeof value === 'string' && VALUE_CONTROL_MODES.has(value)
|
||||
}
|
||||
|
||||
const comboFilter = linked.find(
|
||||
(w) => w !== controlWidget && w.type === 'string'
|
||||
)
|
||||
const filterValue =
|
||||
typeof comboFilter?.value === 'string' ? comboFilter.value : undefined
|
||||
|
||||
const mode = controlWidget.value as ValueControlMode
|
||||
return computeNextControlledValue(params.target, mode, {
|
||||
comboFilter: filterValue,
|
||||
nodeId: params.nodeId
|
||||
})
|
||||
/**
|
||||
* The minimal widget shape needed to advance a controlled value. Matches both a
|
||||
* litegraph widget and a `WidgetState` row from the widget value store.
|
||||
*/
|
||||
export interface ValueControlTarget {
|
||||
type: IBaseWidget['type']
|
||||
value?: unknown
|
||||
options: IBaseWidget['options']
|
||||
}
|
||||
|
||||
const SAFE_INTEGER_MAX = 1125899906842624
|
||||
const SAFE_INTEGER_MIN = -1125899906842624
|
||||
|
||||
export function isValueControlWidget(widget: IBaseWidget): boolean {
|
||||
return (
|
||||
(widget as Record<symbol, unknown>)[IS_CONTROL_WIDGET] === true &&
|
||||
typeof widget.beforeQueued === 'function' &&
|
||||
typeof widget.afterQueued === 'function'
|
||||
)
|
||||
}
|
||||
|
||||
function buildComboFilter(
|
||||
filter: string | undefined,
|
||||
nodeId?: unknown
|
||||
@@ -71,13 +56,13 @@ function buildComboFilter(
|
||||
}
|
||||
|
||||
export function computeNextControlledValue(
|
||||
target: IBaseWidget,
|
||||
target: ValueControlTarget,
|
||||
mode: ValueControlMode,
|
||||
options: { comboFilter?: string; nodeId?: unknown } = {}
|
||||
): IBaseWidget['value'] | undefined {
|
||||
if (mode === 'fixed') return undefined
|
||||
|
||||
if (isComboWidget(target)) {
|
||||
if (target.type === 'combo') {
|
||||
return computeNextComboValue(target, mode, options)
|
||||
}
|
||||
|
||||
@@ -85,7 +70,7 @@ export function computeNextControlledValue(
|
||||
}
|
||||
|
||||
function computeNextComboValue(
|
||||
target: IBaseWidget,
|
||||
target: ValueControlTarget,
|
||||
mode: ValueControlMode,
|
||||
{ comboFilter, nodeId }: { comboFilter?: string; nodeId?: unknown }
|
||||
): IBaseWidget['value'] | undefined {
|
||||
@@ -132,7 +117,7 @@ function computeNextComboValue(
|
||||
}
|
||||
|
||||
function computeNextNumberValue(
|
||||
target: IBaseWidget,
|
||||
target: ValueControlTarget,
|
||||
mode: ValueControlMode
|
||||
): number | undefined {
|
||||
if (typeof target.value !== 'number') return undefined
|
||||
@@ -24,6 +24,7 @@ import { useLitegraphService } from '@/services/litegraphService'
|
||||
import { app } from '@/scripts/app'
|
||||
import type { ComfyApp } from '@/scripts/app'
|
||||
import { useWidgetValueStore } from '@/stores/widgetValueStore'
|
||||
import { widgetId } from '@/types/widgetId'
|
||||
|
||||
const INLINE_INPUTS = false
|
||||
|
||||
@@ -190,7 +191,9 @@ function dynamicComboWidget(
|
||||
const getState = () => {
|
||||
const graphId = resolveNodeRootGraphId(node)
|
||||
if (!graphId) return undefined
|
||||
return useWidgetValueStore().getWidget(graphId, node.id, widget.name)
|
||||
return useWidgetValueStore().getWidget(
|
||||
widgetId(graphId, node.id, widget.name)
|
||||
)
|
||||
}
|
||||
Object.defineProperty(widget, 'value', {
|
||||
get() {
|
||||
|
||||
172
src/core/graph/widgets/widgetControlSystem.test.ts
Normal file
@@ -0,0 +1,172 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
import { isNodeBindable } from '@/lib/litegraph/src/utils/type'
|
||||
import { IS_CONTROL_WIDGET } from '@/core/graph/widgets/control/controlWidgetMarker'
|
||||
import { useWidgetValueStore } from '@/stores/widgetValueStore'
|
||||
|
||||
import { runWidgetControl } from './widgetControlSystem'
|
||||
|
||||
const controlMode = vi.hoisted(() => ({ value: 'after' as 'before' | 'after' }))
|
||||
|
||||
vi.mock('@/platform/settings/settingStore', () => ({
|
||||
useSettingStore: () => ({
|
||||
get: (key: string) =>
|
||||
key === 'Comfy.WidgetControlMode' ? controlMode.value : undefined
|
||||
})
|
||||
}))
|
||||
|
||||
function markControl(widget: IBaseWidget): IBaseWidget {
|
||||
;(widget as IBaseWidget & Record<symbol, unknown>)[IS_CONTROL_WIDGET] = true
|
||||
return widget
|
||||
}
|
||||
|
||||
function addSeedNode(
|
||||
graph: LGraph,
|
||||
{ mode = 'increment', value = 1 }: { mode?: string; value?: number } = {}
|
||||
): LGraphNode {
|
||||
const node = new LGraphNode('SeedNode')
|
||||
node.id = 1
|
||||
const seed = node.addWidget('number', 'seed', value, () => {}, {
|
||||
min: 0,
|
||||
max: 1_000_000,
|
||||
step2: 1
|
||||
})
|
||||
const control = node.addWidget(
|
||||
'combo',
|
||||
'control_after_generate',
|
||||
mode,
|
||||
() => {},
|
||||
{ values: ['fixed', 'increment', 'decrement', 'randomize'] }
|
||||
)
|
||||
markControl(control)
|
||||
seed.linkedWidgets = [control]
|
||||
graph.add(node)
|
||||
return node
|
||||
}
|
||||
|
||||
function seedValue(node: LGraphNode): unknown {
|
||||
const store = useWidgetValueStore()
|
||||
return store.getWidget(node.widgets![0].widgetId!)?.value
|
||||
}
|
||||
|
||||
describe('runWidgetControl', () => {
|
||||
let graph: LGraph
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
controlMode.value = 'after'
|
||||
graph = new LGraph()
|
||||
graph.id = 'graph-a'
|
||||
})
|
||||
|
||||
it('increments a controlled value after queueing', () => {
|
||||
const node = addSeedNode(graph, { mode: 'increment' })
|
||||
|
||||
runWidgetControl(graph, 'after')
|
||||
|
||||
expect(seedValue(node)).toBe(2)
|
||||
})
|
||||
|
||||
it('leaves the value unchanged when the mode is fixed', () => {
|
||||
const node = addSeedNode(graph, { mode: 'fixed' })
|
||||
|
||||
runWidgetControl(graph, 'after')
|
||||
|
||||
expect(seedValue(node)).toBe(1)
|
||||
})
|
||||
|
||||
it('does not run on a target whose input is link-fed', () => {
|
||||
const node = addSeedNode(graph, { mode: 'increment' })
|
||||
node.addInput('seed', 'number', { link: 1, widget: { name: 'seed' } })
|
||||
|
||||
runWidgetControl(graph, 'after')
|
||||
|
||||
expect(seedValue(node)).toBe(1)
|
||||
})
|
||||
|
||||
it('does not run during partial execution', () => {
|
||||
const node = addSeedNode(graph, { mode: 'increment' })
|
||||
|
||||
runWidgetControl(graph, 'after', { isPartialExecution: true })
|
||||
|
||||
expect(seedValue(node)).toBe(1)
|
||||
})
|
||||
|
||||
it('skips the first queue in before mode, then advances', () => {
|
||||
controlMode.value = 'before'
|
||||
const node = addSeedNode(graph, { mode: 'increment' })
|
||||
|
||||
runWidgetControl(graph, 'before')
|
||||
expect(seedValue(node)).toBe(1)
|
||||
|
||||
runWidgetControl(graph, 'before')
|
||||
expect(seedValue(node)).toBe(2)
|
||||
})
|
||||
|
||||
it('ignores after-phase work when in before mode', () => {
|
||||
controlMode.value = 'before'
|
||||
const node = addSeedNode(graph, { mode: 'increment' })
|
||||
|
||||
runWidgetControl(graph, 'after')
|
||||
|
||||
expect(seedValue(node)).toBe(1)
|
||||
})
|
||||
|
||||
it('applies a combo filter when advancing a combo value', () => {
|
||||
const store = useWidgetValueStore()
|
||||
const node = new LGraphNode('CkptNode')
|
||||
node.id = 1
|
||||
const ckpt = node.addWidget('combo', 'ckpt', 'a.safetensors', () => {}, {
|
||||
values: ['a.safetensors', 'b.ckpt', 'c.safetensors']
|
||||
})
|
||||
const control = markControl(
|
||||
node.addWidget('combo', 'control_after_generate', 'increment', () => {}, {
|
||||
values: ['fixed', 'increment', 'decrement', 'randomize']
|
||||
})
|
||||
)
|
||||
const filter = node.addWidget(
|
||||
'string',
|
||||
'control_filter_list',
|
||||
'safetensors',
|
||||
() => {},
|
||||
{}
|
||||
)
|
||||
ckpt.linkedWidgets = [control, filter]
|
||||
graph.add(node)
|
||||
|
||||
runWidgetControl(graph, 'after')
|
||||
|
||||
expect(store.getWidget(ckpt.widgetId!)?.value).toBe('c.safetensors')
|
||||
})
|
||||
|
||||
it('only advances controls belonging to the queued graph', () => {
|
||||
const node = addSeedNode(graph, { mode: 'increment' })
|
||||
|
||||
const otherGraph = new LGraph()
|
||||
otherGraph.id = 'graph-b'
|
||||
const otherNode = addSeedNode(otherGraph, { mode: 'increment' })
|
||||
|
||||
runWidgetControl(graph, 'after')
|
||||
|
||||
expect(seedValue(node)).toBe(2)
|
||||
expect(seedValue(otherNode)).toBe(1)
|
||||
})
|
||||
|
||||
it('preserves the before-mode skip when the widget re-registers', () => {
|
||||
controlMode.value = 'before'
|
||||
const node = addSeedNode(graph, { mode: 'increment' })
|
||||
|
||||
runWidgetControl(graph, 'before')
|
||||
expect(seedValue(node)).toBe(1)
|
||||
|
||||
const seed = node.widgets![0]
|
||||
if (isNodeBindable(seed)) seed.setNodeId(node.id)
|
||||
|
||||
runWidgetControl(graph, 'before')
|
||||
expect(seedValue(node)).toBe(2)
|
||||
})
|
||||
})
|
||||
77
src/core/graph/widgets/widgetControlSystem.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import type { LGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useWidgetValueStore } from '@/stores/widgetValueStore'
|
||||
import type { WidgetId } from '@/types/widgetId'
|
||||
import { parseWidgetId, widgetId } from '@/types/widgetId'
|
||||
import { forEachNode } from '@/utils/graphTraversalUtil'
|
||||
|
||||
import {
|
||||
computeNextControlledValue,
|
||||
isValueControlMode
|
||||
} from './control/valueControl'
|
||||
|
||||
export type WidgetControlPhase = 'before' | 'after'
|
||||
|
||||
/**
|
||||
* Widget ids whose input slot is currently link-fed, so their value comes from
|
||||
* upstream and control must not advance it. Derived live from the graph.
|
||||
*/
|
||||
function collectLinkFedTargets(graph: LGraph): Set<WidgetId> {
|
||||
const graphId = graph.rootGraph.id
|
||||
const linkFed = new Set<WidgetId>()
|
||||
forEachNode(graph, (node) => {
|
||||
for (const input of node.inputs ?? []) {
|
||||
if (input.link == null) continue
|
||||
if (input.widgetId) {
|
||||
linkFed.add(input.widgetId)
|
||||
} else if (input.widget?.name) {
|
||||
linkFed.add(widgetId(graphId, node.id, input.widget.name))
|
||||
}
|
||||
}
|
||||
})
|
||||
return linkFed
|
||||
}
|
||||
|
||||
/** Advances the graph's controlled widget store values at the given queue phase. */
|
||||
export function runWidgetControl(
|
||||
graph: LGraph,
|
||||
phase: WidgetControlPhase,
|
||||
{ isPartialExecution }: { isPartialExecution?: boolean } = {}
|
||||
): void {
|
||||
if (isPartialExecution) return
|
||||
|
||||
const runBefore =
|
||||
useSettingStore().get('Comfy.WidgetControlMode') === 'before'
|
||||
if (phase === 'before' && !runBefore) return
|
||||
if (phase === 'after' && runBefore) return
|
||||
|
||||
const store = useWidgetValueStore()
|
||||
const linkFed = collectLinkFedTargets(graph)
|
||||
for (const [targetId, control] of store.getWidgetControls(
|
||||
graph.rootGraph.id
|
||||
)) {
|
||||
const target = store.getWidget(targetId)
|
||||
if (!target || linkFed.has(targetId)) continue
|
||||
|
||||
if (phase === 'before' && !control.hasExecuted) {
|
||||
control.hasExecuted = true
|
||||
continue
|
||||
}
|
||||
control.hasExecuted = true
|
||||
|
||||
const mode = store.getWidget(control.controlWidgetId)?.value
|
||||
if (!isValueControlMode(mode)) continue
|
||||
|
||||
const filter = control.filterWidgetId
|
||||
? store.getWidget(control.filterWidgetId)?.value
|
||||
: undefined
|
||||
|
||||
const next = computeNextControlledValue(target, mode, {
|
||||
comboFilter: typeof filter === 'string' ? filter : undefined,
|
||||
nodeId: parseWidgetId(targetId).nodeId
|
||||
})
|
||||
if (next === undefined) continue
|
||||
|
||||
store.setValue(targetId, next)
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import { app } from '@/scripts/app'
|
||||
import { useWidgetValueStore } from '@/stores/widgetValueStore'
|
||||
|
||||
import { applyFirstWidgetValueToGraph } from './widgetValuePropagation'
|
||||
import { widgetId } from '@/types/widgetId'
|
||||
|
||||
function applyToGraph(this: LGraphNode, extraLinks: LLink[] = []) {
|
||||
applyFirstWidgetValueToGraph(this, extraLinks)
|
||||
@@ -51,16 +52,15 @@ function onCustomComboCreated(this: LGraphNode) {
|
||||
Object.defineProperty(widget, 'value', {
|
||||
get() {
|
||||
return (
|
||||
useWidgetValueStore().getWidget(app.rootGraph.id, node.id, widgetName)
|
||||
?.value ?? localValue
|
||||
useWidgetValueStore().getWidget(
|
||||
widgetId(app.rootGraph.id, node.id, widgetName)
|
||||
)?.value ?? localValue
|
||||
)
|
||||
},
|
||||
set(v: string) {
|
||||
localValue = v
|
||||
const state = useWidgetValueStore().getWidget(
|
||||
app.rootGraph.id,
|
||||
node.id,
|
||||
widgetName
|
||||
widgetId(app.rootGraph.id, node.id, widgetName)
|
||||
)
|
||||
if (state) state.value = v
|
||||
updateCombo()
|
||||
|
||||
@@ -20,6 +20,7 @@ import type { ComfyNodeDef } from '@/schemas/nodeDefSchema'
|
||||
import type { DOMWidget } from '@/scripts/domWidget'
|
||||
import { useAudioService } from '@/services/audioService'
|
||||
import { type NodeLocatorId } from '@/types'
|
||||
import { widgetId } from '@/types/widgetId'
|
||||
import { getNodeByLocatorId } from '@/utils/graphTraversalUtil'
|
||||
|
||||
import { api } from '../../scripts/api'
|
||||
@@ -152,16 +153,16 @@ app.registerExtension({
|
||||
|
||||
audioUIWidget.options.getValue = () =>
|
||||
(useWidgetValueStore().getWidget(
|
||||
resolveNodeRootGraphId(node, app.rootGraph.id),
|
||||
node.id,
|
||||
inputName
|
||||
widgetId(
|
||||
resolveNodeRootGraphId(node, app.rootGraph.id),
|
||||
node.id,
|
||||
inputName
|
||||
)
|
||||
)?.value as string) ?? ''
|
||||
audioUIWidget.options.setValue = (v) => {
|
||||
const graphId = resolveNodeRootGraphId(node, app.rootGraph.id)
|
||||
const widgetState = useWidgetValueStore().getWidget(
|
||||
graphId,
|
||||
node.id,
|
||||
inputName
|
||||
widgetId(graphId, node.id, inputName)
|
||||
)
|
||||
if (widgetState) widgetState.value = v
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ import type { UUID } from '@/utils/uuid'
|
||||
import { zeroUuid } from '@/utils/uuid'
|
||||
import { usePreviewExposureStore } from '@/stores/previewExposureStore'
|
||||
import { useWidgetValueStore } from '@/stores/widgetValueStore'
|
||||
import { widgetId } from '@/types/widgetId'
|
||||
import {
|
||||
createTestSubgraph,
|
||||
createTestSubgraphData,
|
||||
@@ -296,9 +297,8 @@ describe('Graph Clearing and Callbacks', () => {
|
||||
})
|
||||
|
||||
const widgetValueStore = useWidgetValueStore()
|
||||
widgetValueStore.registerWidget(graphId, {
|
||||
nodeId: '10' as NodeId,
|
||||
name: 'seed',
|
||||
const seedWidgetId = widgetId(graphId, '10' as NodeId, 'seed')
|
||||
widgetValueStore.registerWidget(seedWidgetId, {
|
||||
type: 'number',
|
||||
value: 1,
|
||||
options: {},
|
||||
@@ -307,7 +307,7 @@ describe('Graph Clearing and Callbacks', () => {
|
||||
disabled: undefined
|
||||
})
|
||||
|
||||
expect(widgetValueStore.getWidget(graphId, '10' as NodeId, 'seed')).toEqual(
|
||||
expect(widgetValueStore.getWidget(seedWidgetId)).toEqual(
|
||||
expect.objectContaining({ value: 1 })
|
||||
)
|
||||
expect(
|
||||
@@ -316,9 +316,7 @@ describe('Graph Clearing and Callbacks', () => {
|
||||
|
||||
graph.clear()
|
||||
|
||||
expect(
|
||||
widgetValueStore.getWidget(graphId, '10' as NodeId, 'seed')
|
||||
).toBeUndefined()
|
||||
expect(widgetValueStore.getWidget(seedWidgetId)).toBeUndefined()
|
||||
expect(previewExposureStore.getExposures(graphId, `${graphId}:1`)).toEqual(
|
||||
[]
|
||||
)
|
||||
|
||||
@@ -96,11 +96,9 @@ import { BaseWidget } from './widgets/BaseWidget'
|
||||
import { toConcreteWidget } from './widgets/widgetMap'
|
||||
import type { WidgetTypeMap } from './widgets/widgetMap'
|
||||
|
||||
import type { NodeId } from '@/world/entityIds'
|
||||
|
||||
// #region Types
|
||||
|
||||
export type { NodeId }
|
||||
export type NodeId = number | string
|
||||
|
||||
export type NodeProperty = string | number | boolean | object
|
||||
|
||||
@@ -4241,7 +4239,9 @@ export class LGraphNode
|
||||
if (!widget) continue
|
||||
|
||||
const offset = LiteGraph.NODE_SLOT_HEIGHT * 0.5
|
||||
slot.pos = [offset, widget.y + offset]
|
||||
const pos: [number, number] = [offset, widget.y + offset]
|
||||
slot.pos = pos
|
||||
this.inputs[i].pos = pos
|
||||
this._measureSlot(slot, i, true)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { Rectangle } from './infrastructure/Rectangle'
|
||||
import type { CanvasColour } from './interfaces'
|
||||
import { LiteGraph } from './litegraph'
|
||||
import { litegraph } from './litegraphInstance'
|
||||
import { RenderShape, TitleMode } from './types/globalEnums'
|
||||
import { cachedMeasureText } from './utils/textMeasureCache'
|
||||
|
||||
@@ -81,12 +81,12 @@ export function strokeShape(
|
||||
}: IDrawBoundingOptions = {}
|
||||
): void {
|
||||
// These param defaults are not compile-time static, and must be re-evaluated at runtime
|
||||
round_radius ??= LiteGraph.ROUND_RADIUS
|
||||
color ??= LiteGraph.NODE_BOX_OUTLINE_COLOR
|
||||
round_radius ??= litegraph().ROUND_RADIUS
|
||||
color ??= litegraph().NODE_BOX_OUTLINE_COLOR
|
||||
|
||||
// Adjust area if title is transparent
|
||||
if (title_mode === TitleMode.TRANSPARENT_TITLE) {
|
||||
const height = title_height ?? LiteGraph.NODE_TITLE_HEIGHT
|
||||
const height = title_height ?? litegraph().NODE_TITLE_HEIGHT
|
||||
area[1] -= height
|
||||
area[3] += height
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { Rectangle } from '@/lib/litegraph/src/infrastructure/Rectangle'
|
||||
import type { CanvasPointerEvent } from '@/lib/litegraph/src/types/events'
|
||||
import type { WidgetId } from '@/types/widgetId'
|
||||
import type { TWidgetValue } from '@/lib/litegraph/src/types/widgets'
|
||||
|
||||
import type { ContextMenu } from './ContextMenu'
|
||||
@@ -362,6 +363,7 @@ export interface IWidgetLocator {
|
||||
export interface INodeInputSlot extends INodeSlot {
|
||||
link: LinkId | null
|
||||
widget?: IWidgetLocator
|
||||
widgetId?: WidgetId
|
||||
alwaysVisible?: boolean
|
||||
|
||||
/**
|
||||
|
||||
@@ -7,6 +7,7 @@ import type {
|
||||
Point,
|
||||
Size
|
||||
} from './interfaces'
|
||||
import { registerLiteGraphInstance } from './litegraphInstance'
|
||||
import { loadPolyfills } from './polyfills'
|
||||
import type { CanvasEventDetail } from './types/events'
|
||||
import type { RenderShape, TitleMode } from './types/globalEnums'
|
||||
@@ -15,6 +16,7 @@ import type { RenderShape, TitleMode } from './types/globalEnums'
|
||||
export { Subgraph } from './subgraph/Subgraph'
|
||||
|
||||
export const LiteGraph = new LiteGraphGlobal()
|
||||
registerLiteGraphInstance(LiteGraph)
|
||||
|
||||
// Load legacy polyfills
|
||||
loadPolyfills()
|
||||
|
||||
26
src/lib/litegraph/src/litegraphInstance.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import type { LiteGraphGlobal } from './LiteGraphGlobal'
|
||||
|
||||
/**
|
||||
* Late-bound holder for the {@link LiteGraphGlobal} singleton.
|
||||
*
|
||||
* This module imports `LiteGraphGlobal` as a type only, so it has no runtime
|
||||
* dependencies. Modules in the widget initialisation chain (e.g. `draw.ts`,
|
||||
* imported transitively by `BaseWidget`) can read singleton constants through
|
||||
* {@link litegraph} without importing the `litegraph` barrel — which would
|
||||
* re-enter the barrel mid-initialisation and evaluate
|
||||
* `LegacyWidget extends BaseWidget` before `BaseWidget` is defined.
|
||||
*
|
||||
* The barrel constructs the singleton and calls {@link registerLiteGraphInstance}.
|
||||
*/
|
||||
let instance: LiteGraphGlobal | null = null
|
||||
|
||||
export function registerLiteGraphInstance(value: LiteGraphGlobal): void {
|
||||
instance = value
|
||||
}
|
||||
|
||||
export function litegraph(): LiteGraphGlobal {
|
||||
if (!instance) {
|
||||
throw new Error('LiteGraph singleton accessed before initialisation')
|
||||
}
|
||||
return instance
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import type { LGraph } from '@/lib/litegraph/src/LGraph'
|
||||
import type { LGraphNode, NodeId } from '@/lib/litegraph/src/LGraphNode'
|
||||
import type { LinkId } from '@/lib/litegraph/src/LLink'
|
||||
import { InvalidLinkError } from '@/lib/litegraph/src/infrastructure/InvalidLinkError'
|
||||
import { useWidgetValueStore } from '@/stores/widgetValueStore'
|
||||
import { NullGraphError } from '@/lib/litegraph/src/infrastructure/NullGraphError'
|
||||
import { RecursionError } from '@/lib/litegraph/src/infrastructure/RecursionError'
|
||||
import { SlotIndexError } from '@/lib/litegraph/src/infrastructure/SlotIndexError'
|
||||
@@ -183,15 +184,14 @@ export class ExecutableNodeDTO implements ExecutableLGraphNode {
|
||||
// Nothing connected
|
||||
const linkId = subgraphNodeInput.link
|
||||
if (linkId == null) {
|
||||
const widget = subgraphNode.getWidgetFromSlot(subgraphNodeInput)
|
||||
if (!widget) return
|
||||
const id = subgraphNodeInput.widgetId
|
||||
if (!id) return
|
||||
|
||||
// Special case: SubgraphNode widget.
|
||||
return {
|
||||
node: this,
|
||||
origin_id: this.id,
|
||||
origin_slot: -1,
|
||||
widgetInfo: { value: widget.value }
|
||||
widgetInfo: { value: useWidgetValueStore().getWidget(id)?.value }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,128 +0,0 @@
|
||||
import { describe, expect, test } from 'vitest'
|
||||
|
||||
import { PromotedWidgetViewManager } from '@/lib/litegraph/src/subgraph/PromotedWidgetViewManager'
|
||||
|
||||
type TestPromotionEntry = {
|
||||
sourceNodeId: string
|
||||
sourceWidgetName: string
|
||||
viewKey?: string
|
||||
}
|
||||
|
||||
function makeView(entry: TestPromotionEntry) {
|
||||
const baseKey = `${entry.sourceNodeId}:${entry.sourceWidgetName}`
|
||||
|
||||
return {
|
||||
key: entry.viewKey ? `${baseKey}:${entry.viewKey}` : baseKey
|
||||
}
|
||||
}
|
||||
|
||||
describe('PromotedWidgetViewManager', () => {
|
||||
test('returns memoized array when entries reference is unchanged', () => {
|
||||
const manager = new PromotedWidgetViewManager<{ key: string }>()
|
||||
const entries = [{ sourceNodeId: '1', sourceWidgetName: 'widgetA' }]
|
||||
|
||||
const first = manager.reconcile(entries, makeView)
|
||||
const second = manager.reconcile(entries, makeView)
|
||||
|
||||
expect(second).toBe(first)
|
||||
expect(second[0]).toBe(first[0])
|
||||
})
|
||||
|
||||
test('preserves view identity while reflecting order changes', () => {
|
||||
const manager = new PromotedWidgetViewManager<{ key: string }>()
|
||||
|
||||
const firstPass = manager.reconcile(
|
||||
[
|
||||
{ sourceNodeId: '1', sourceWidgetName: 'widgetA' },
|
||||
{ sourceNodeId: '1', sourceWidgetName: 'widgetB' }
|
||||
],
|
||||
makeView
|
||||
)
|
||||
|
||||
const reordered = manager.reconcile(
|
||||
[
|
||||
{ sourceNodeId: '1', sourceWidgetName: 'widgetB' },
|
||||
{ sourceNodeId: '1', sourceWidgetName: 'widgetA' }
|
||||
],
|
||||
makeView
|
||||
)
|
||||
|
||||
expect(reordered[0]).toBe(firstPass[1])
|
||||
expect(reordered[1]).toBe(firstPass[0])
|
||||
})
|
||||
|
||||
test('deduplicates by first occurrence and clears stale cache entries', () => {
|
||||
const manager = new PromotedWidgetViewManager<{ key: string }>()
|
||||
|
||||
const first = manager.reconcile(
|
||||
[
|
||||
{ sourceNodeId: '1', sourceWidgetName: 'widgetA' },
|
||||
{ sourceNodeId: '1', sourceWidgetName: 'widgetB' },
|
||||
{ sourceNodeId: '1', sourceWidgetName: 'widgetA' }
|
||||
],
|
||||
makeView
|
||||
)
|
||||
expect(first.map((view) => view.key)).toStrictEqual([
|
||||
'1:widgetA',
|
||||
'1:widgetB'
|
||||
])
|
||||
|
||||
manager.reconcile(
|
||||
[{ sourceNodeId: '1', sourceWidgetName: 'widgetB' }],
|
||||
makeView
|
||||
)
|
||||
|
||||
const restored = manager.reconcile(
|
||||
[
|
||||
{ sourceNodeId: '1', sourceWidgetName: 'widgetB' },
|
||||
{ sourceNodeId: '1', sourceWidgetName: 'widgetA' }
|
||||
],
|
||||
makeView
|
||||
)
|
||||
|
||||
expect(restored[0]).toBe(first[1])
|
||||
expect(restored[1]).not.toBe(first[0])
|
||||
})
|
||||
|
||||
test('keeps distinct views for same source widget when viewKeys differ', () => {
|
||||
const manager = new PromotedWidgetViewManager<{ key: string }>()
|
||||
|
||||
const views = manager.reconcile(
|
||||
[
|
||||
{ sourceNodeId: '1', sourceWidgetName: 'widgetA', viewKey: 'slotA' },
|
||||
{ sourceNodeId: '1', sourceWidgetName: 'widgetA', viewKey: 'slotB' }
|
||||
],
|
||||
makeView
|
||||
)
|
||||
|
||||
expect(views).toHaveLength(2)
|
||||
expect(views[0]).not.toBe(views[1])
|
||||
expect(views[0].key).toBe('1:widgetA:slotA')
|
||||
expect(views[1].key).toBe('1:widgetA:slotB')
|
||||
})
|
||||
|
||||
test('removeByViewKey removes only the targeted keyed view', () => {
|
||||
const manager = new PromotedWidgetViewManager<{ key: string }>()
|
||||
|
||||
const firstPass = manager.reconcile(
|
||||
[
|
||||
{ sourceNodeId: '1', sourceWidgetName: 'widgetA', viewKey: 'slotA' },
|
||||
{ sourceNodeId: '1', sourceWidgetName: 'widgetA', viewKey: 'slotB' }
|
||||
],
|
||||
makeView
|
||||
)
|
||||
|
||||
manager.removeByViewKey('1', 'widgetA', 'slotA')
|
||||
|
||||
const secondPass = manager.reconcile(
|
||||
[
|
||||
{ sourceNodeId: '1', sourceWidgetName: 'widgetA', viewKey: 'slotA' },
|
||||
{ sourceNodeId: '1', sourceWidgetName: 'widgetA', viewKey: 'slotB' }
|
||||
],
|
||||
makeView
|
||||
)
|
||||
|
||||
expect(secondPass[0]).not.toBe(firstPass[0])
|
||||
expect(secondPass[1]).toBe(firstPass[1])
|
||||
})
|
||||
})
|
||||
@@ -1,118 +0,0 @@
|
||||
import type { PromotedWidgetSource } from '@/core/graph/subgraph/promotedWidgetTypes'
|
||||
|
||||
type ViewManagerEntry = PromotedWidgetSource & {
|
||||
viewKey?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Reconciles promoted widget entries to stable view instances.
|
||||
*
|
||||
* Keeps object identity stable by key while preserving the current
|
||||
* promotion order and deduplicating duplicate entries by first occurrence.
|
||||
*/
|
||||
export class PromotedWidgetViewManager<TView> {
|
||||
private viewCache = new Map<string, TView>()
|
||||
private cachedViews: TView[] | null = null
|
||||
private cachedEntryKeys: string[] | null = null
|
||||
|
||||
reconcile<TEntry extends ViewManagerEntry>(
|
||||
entries: readonly TEntry[],
|
||||
createView: (entry: TEntry) => TView
|
||||
): TView[] {
|
||||
const entryKeys = entries.map((entry) =>
|
||||
this.makeKey(entry.sourceNodeId, entry.sourceWidgetName, entry.viewKey)
|
||||
)
|
||||
|
||||
if (this.cachedViews && this.areEntryKeysEqual(entryKeys))
|
||||
return this.cachedViews
|
||||
|
||||
const views: TView[] = []
|
||||
const seenKeys = new Set<string>()
|
||||
|
||||
for (const entry of entries) {
|
||||
const key = this.makeKey(
|
||||
entry.sourceNodeId,
|
||||
entry.sourceWidgetName,
|
||||
entry.viewKey
|
||||
)
|
||||
if (seenKeys.has(key)) continue
|
||||
seenKeys.add(key)
|
||||
|
||||
const existing = this.viewCache.get(key)
|
||||
if (existing) {
|
||||
views.push(existing)
|
||||
continue
|
||||
}
|
||||
|
||||
const nextView = createView(entry)
|
||||
this.viewCache.set(key, nextView)
|
||||
views.push(nextView)
|
||||
}
|
||||
|
||||
for (const key of this.viewCache.keys()) {
|
||||
if (!seenKeys.has(key)) this.viewCache.delete(key)
|
||||
}
|
||||
|
||||
this.cachedViews = views
|
||||
this.cachedEntryKeys = entryKeys
|
||||
return views
|
||||
}
|
||||
|
||||
getOrCreate(
|
||||
sourceNodeId: string,
|
||||
sourceWidgetName: string,
|
||||
createView: () => TView,
|
||||
viewKey?: string
|
||||
): TView {
|
||||
const key = this.makeKey(sourceNodeId, sourceWidgetName, viewKey)
|
||||
const cached = this.viewCache.get(key)
|
||||
if (cached) return cached
|
||||
|
||||
const view = createView()
|
||||
this.viewCache.set(key, view)
|
||||
return view
|
||||
}
|
||||
|
||||
remove(sourceNodeId: string, sourceWidgetName: string): void {
|
||||
this.viewCache.delete(this.makeKey(sourceNodeId, sourceWidgetName))
|
||||
this.invalidateMemoizedList()
|
||||
}
|
||||
|
||||
removeByViewKey(
|
||||
sourceNodeId: string,
|
||||
sourceWidgetName: string,
|
||||
viewKey: string
|
||||
): void {
|
||||
this.viewCache.delete(this.makeKey(sourceNodeId, sourceWidgetName, viewKey))
|
||||
this.invalidateMemoizedList()
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.viewCache.clear()
|
||||
this.invalidateMemoizedList()
|
||||
}
|
||||
|
||||
invalidateMemoizedList(): void {
|
||||
this.cachedViews = null
|
||||
this.cachedEntryKeys = null
|
||||
}
|
||||
|
||||
private areEntryKeysEqual(entryKeys: string[]): boolean {
|
||||
if (!this.cachedEntryKeys) return false
|
||||
if (this.cachedEntryKeys.length !== entryKeys.length) return false
|
||||
|
||||
for (let index = 0; index < entryKeys.length; index += 1) {
|
||||
if (this.cachedEntryKeys[index] !== entryKeys[index]) return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
private makeKey(
|
||||
sourceNodeId: string,
|
||||
sourceWidgetName: string,
|
||||
viewKey?: string
|
||||
): string {
|
||||
const baseKey = `${sourceNodeId}:${sourceWidgetName}`
|
||||
return viewKey ? `${baseKey}:${viewKey}` : baseKey
|
||||
}
|
||||
}
|
||||
@@ -486,10 +486,6 @@ describe('SubgraphIO - Empty Slot Connection', () => {
|
||||
'seed',
|
||||
'seed_1'
|
||||
])
|
||||
expect(subgraphNode.widgets.map((widget) => widget.name)).toStrictEqual([
|
||||
'seed',
|
||||
'seed_1'
|
||||
])
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
@@ -101,43 +101,6 @@ describe('SubgraphNode Memory Management', () => {
|
||||
})
|
||||
|
||||
describe('Widget Promotion Memory Management', () => {
|
||||
it('should not mutate manually injected widget references', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'testInput', type: 'number' }]
|
||||
})
|
||||
const subgraphNode = createTestSubgraphNode(subgraph)
|
||||
|
||||
const input = subgraphNode.inputs[0]
|
||||
const mockWidget = {
|
||||
type: 'number',
|
||||
name: 'promoted_widget',
|
||||
value: 123,
|
||||
options: {},
|
||||
y: 0,
|
||||
draw: vi.fn(),
|
||||
mouse: vi.fn(),
|
||||
computeSize: vi.fn(),
|
||||
createCopyForNode: vi.fn().mockReturnValue({
|
||||
type: 'number',
|
||||
name: 'promoted_widget',
|
||||
value: 123
|
||||
})
|
||||
} as Partial<IWidget> as IWidget
|
||||
|
||||
input._widget = mockWidget
|
||||
input.widget = { name: 'promoted_widget' }
|
||||
subgraphNode.widgets.push(mockWidget)
|
||||
|
||||
expect(input._widget).toBe(mockWidget)
|
||||
expect(input.widget).toBeDefined()
|
||||
expect(subgraphNode.widgets).toContain(mockWidget)
|
||||
|
||||
subgraphNode.removeWidget(mockWidget)
|
||||
|
||||
// removeWidget only affects managed promoted widgets, not manually injected entries.
|
||||
expect(subgraphNode.widgets).toContain(mockWidget)
|
||||
})
|
||||
|
||||
it('should not leak widgets during reconfiguration', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'input1', type: 'number' }]
|
||||
|
||||
@@ -5,13 +5,19 @@
|
||||
* IO synchronization, and edge cases.
|
||||
*/
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { fromAny } from '@total-typescript/shoehorn'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { LGraph, LGraphNode, SubgraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import {
|
||||
BaseWidget,
|
||||
LGraph,
|
||||
LGraphNode,
|
||||
LiteGraph,
|
||||
SubgraphNode
|
||||
} from '@/lib/litegraph/src/litegraph'
|
||||
import type { ExportedSubgraphInstance } from '@/lib/litegraph/src/types/serialisation'
|
||||
import { NodeSlotType } from '@/lib/litegraph/src/types/globalEnums'
|
||||
import { useWidgetValueStore } from '@/stores/widgetValueStore'
|
||||
|
||||
import { subgraphTest } from './__fixtures__/subgraphFixtures'
|
||||
import {
|
||||
@@ -199,6 +205,148 @@ describe('SubgraphNode Synchronization', () => {
|
||||
expect(subgraphNode.outputs[0].label).toBe('newOutput')
|
||||
})
|
||||
|
||||
it('represents promoted host widgets by input widgetId and WidgetState', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'text', type: 'STRING' }]
|
||||
})
|
||||
|
||||
const interiorNode = new LGraphNode('Interior')
|
||||
const input = interiorNode.addInput('value', 'STRING')
|
||||
input.widget = { name: 'value' }
|
||||
interiorNode.addOutput('out', 'STRING')
|
||||
interiorNode.addWidget('text', 'value', 'initial', () => {})
|
||||
subgraph.add(interiorNode)
|
||||
subgraph.inputNode.slots[0].connect(interiorNode.inputs[0], interiorNode)
|
||||
|
||||
const subgraphNode = createTestSubgraphNode(subgraph)
|
||||
const promotedInput = subgraphNode.inputs[0]
|
||||
const inputWidgetId = promotedInput.widgetId
|
||||
|
||||
expect(subgraphNode.widgets).toMatchObject([
|
||||
{ name: 'text', widgetId: inputWidgetId }
|
||||
])
|
||||
expect(promotedInput._widget).toBe(subgraphNode.widgets[0])
|
||||
expect(inputWidgetId).toBeDefined()
|
||||
expect('sourceNodeId' in promotedInput).toBe(false)
|
||||
expect('sourceWidgetName' in promotedInput).toBe(false)
|
||||
if (!inputWidgetId) throw new Error('Missing widgetId')
|
||||
|
||||
expect(useWidgetValueStore().getWidget(inputWidgetId)?.value).toBe(
|
||||
'initial'
|
||||
)
|
||||
})
|
||||
|
||||
it('binds promoted host widgets as stable LiteGraph widgets', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'text', type: 'STRING' }]
|
||||
})
|
||||
|
||||
const interiorNode = new LGraphNode('Interior')
|
||||
const input = interiorNode.addInput('value', 'STRING')
|
||||
input.widget = { name: 'value' }
|
||||
interiorNode.addOutput('out', 'STRING')
|
||||
interiorNode.addWidget('text', 'value', 'initial', () => {})
|
||||
subgraph.add(interiorNode)
|
||||
subgraph.inputNode.slots[0].connect(interiorNode.inputs[0], interiorNode)
|
||||
|
||||
const subgraphNode = createTestSubgraphNode(subgraph)
|
||||
const promotedInput = subgraphNode.inputs[0]
|
||||
const widget = subgraphNode.widgets[0]
|
||||
|
||||
expect(widget).toBeDefined()
|
||||
expect(subgraphNode.widgets[0]).toBe(widget)
|
||||
expect(promotedInput._widget).toBe(widget)
|
||||
expect(subgraphNode.getWidgetFromSlot(promotedInput)).toBe(widget)
|
||||
|
||||
subgraphNode.arrange()
|
||||
|
||||
expect(promotedInput.pos?.[1]).toBeGreaterThan(
|
||||
LiteGraph.NODE_SLOT_HEIGHT * 0.5
|
||||
)
|
||||
})
|
||||
|
||||
it('does not expose promoted widgetId to BaseWidget assignment', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'text', type: 'STRING' }]
|
||||
})
|
||||
|
||||
const interiorNode = new LGraphNode('Interior')
|
||||
const input = interiorNode.addInput('value', 'STRING')
|
||||
input.widget = { name: 'value' }
|
||||
interiorNode.addOutput('out', 'STRING')
|
||||
interiorNode.addWidget('text', 'value', 'initial', () => {})
|
||||
subgraph.add(interiorNode)
|
||||
subgraph.inputNode.slots[0].connect(interiorNode.inputs[0], interiorNode)
|
||||
|
||||
const subgraphNode = createTestSubgraphNode(subgraph)
|
||||
const widget = subgraphNode.widgets[0]
|
||||
expect(widget?.widgetId).toBeDefined()
|
||||
|
||||
expect(() => {
|
||||
// @ts-expect-error Abstract class instantiation
|
||||
new BaseWidget({ ...widget, node: subgraphNode })
|
||||
}).not.toThrow()
|
||||
})
|
||||
|
||||
it('reads promoted widget label and y from WidgetState', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'text', type: 'STRING' }]
|
||||
})
|
||||
|
||||
const interiorNode = new LGraphNode('Interior')
|
||||
const input = interiorNode.addInput('value', 'STRING')
|
||||
input.widget = { name: 'value' }
|
||||
interiorNode.addOutput('out', 'STRING')
|
||||
interiorNode.addWidget('text', 'value', 'initial', () => {})
|
||||
subgraph.add(interiorNode)
|
||||
subgraph.inputNode.slots[0].connect(interiorNode.inputs[0], interiorNode)
|
||||
|
||||
const subgraphNode = createTestSubgraphNode(subgraph)
|
||||
const promotedInput = subgraphNode.inputs[0]
|
||||
const widget = subgraphNode.widgets[0]
|
||||
const id = promotedInput.widgetId
|
||||
if (!id) throw new Error('Missing widgetId')
|
||||
const state = useWidgetValueStore().getWidget(id)
|
||||
if (!state) throw new Error('Missing widget state')
|
||||
|
||||
state.label = 'Stored Label'
|
||||
state.y = 27
|
||||
|
||||
expect(widget?.name).toBe('text')
|
||||
expect(widget?.label).toBe('Stored Label')
|
||||
expect(widget?.y).toBe(27)
|
||||
})
|
||||
|
||||
it('writes promoted widget label and y to WidgetState', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'text', type: 'STRING' }]
|
||||
})
|
||||
|
||||
const interiorNode = new LGraphNode('Interior')
|
||||
const input = interiorNode.addInput('value', 'STRING')
|
||||
input.widget = { name: 'value' }
|
||||
interiorNode.addOutput('out', 'STRING')
|
||||
interiorNode.addWidget('text', 'value', 'initial', () => {})
|
||||
subgraph.add(interiorNode)
|
||||
subgraph.inputNode.slots[0].connect(interiorNode.inputs[0], interiorNode)
|
||||
|
||||
const subgraphNode = createTestSubgraphNode(subgraph)
|
||||
const promotedInput = subgraphNode.inputs[0]
|
||||
const widget = subgraphNode.widgets[0]
|
||||
const id = promotedInput.widgetId
|
||||
if (!id) throw new Error('Missing widgetId')
|
||||
|
||||
if (!widget) throw new Error('Missing projected widget')
|
||||
widget.label = 'Projected Label'
|
||||
widget.y = 31
|
||||
|
||||
expect(useWidgetValueStore().getWidget(id)).toMatchObject({
|
||||
name: 'text',
|
||||
label: 'Projected Label',
|
||||
y: 31
|
||||
})
|
||||
})
|
||||
|
||||
it('should keep input.widget.name stable after rename (onGraphConfigured safety)', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'text', type: 'STRING' }]
|
||||
@@ -218,7 +366,6 @@ describe('SubgraphNode Synchronization', () => {
|
||||
|
||||
const originalWidgetName = promotedInput.widget!.name
|
||||
|
||||
// Rename the subgraph input label
|
||||
subgraph.inputs[0].label = 'my_custom_prompt'
|
||||
subgraph.events.dispatch('renaming-input', {
|
||||
input: subgraph.inputs[0],
|
||||
@@ -227,17 +374,16 @@ describe('SubgraphNode Synchronization', () => {
|
||||
newName: 'my_custom_prompt'
|
||||
})
|
||||
|
||||
// widget.name stays as the internal name — NOT the display label
|
||||
expect(promotedInput.widget!.name).toBe(originalWidgetName)
|
||||
|
||||
// The display label is on input.label (live-read via PromotedWidgetView.label)
|
||||
expect(promotedInput.label).toBe('my_custom_prompt')
|
||||
|
||||
// input.widget.name should still match a widget in node.widgets
|
||||
const matchingWidget = subgraphNode.widgets?.find(
|
||||
(w) => w.name === promotedInput.widget!.name
|
||||
expect(subgraphNode.widgets).toMatchObject([
|
||||
{ name: 'text', label: 'my_custom_prompt' }
|
||||
])
|
||||
expect(promotedInput.widgetId).toBeDefined()
|
||||
if (!promotedInput.widgetId) throw new Error('Missing widgetId')
|
||||
expect(useWidgetValueStore().getWidget(promotedInput.widgetId)?.label).toBe(
|
||||
'my_custom_prompt'
|
||||
)
|
||||
expect(matchingWidget).toBeDefined()
|
||||
})
|
||||
|
||||
it('should preserve renamed label through serialize/configure round-trip', () => {
|
||||
@@ -254,10 +400,10 @@ describe('SubgraphNode Synchronization', () => {
|
||||
subgraph.inputNode.slots[0].connect(interiorNode.inputs[0], interiorNode)
|
||||
|
||||
const subgraphNode = createTestSubgraphNode(subgraph)
|
||||
const promotedWidget = subgraphNode.widgets?.[0]
|
||||
expect(promotedWidget).toBeDefined()
|
||||
const inputSlot = subgraphNode.inputs[0]
|
||||
expect(inputSlot.widgetId).toBeDefined()
|
||||
if (!inputSlot.widgetId) throw new Error('Missing widgetId')
|
||||
|
||||
// Rename via the subgraph slot (simulates right-click rename)
|
||||
subgraph.inputs[0].label = 'My Seed'
|
||||
subgraphNode.inputs[0].label = 'My Seed'
|
||||
subgraph.events.dispatch('renaming-input', {
|
||||
@@ -267,20 +413,18 @@ describe('SubgraphNode Synchronization', () => {
|
||||
newName: 'My Seed'
|
||||
})
|
||||
|
||||
// Label should be visible before round-trip
|
||||
const widgetBeforeRoundTrip = subgraphNode.widgets?.[0]
|
||||
expect(widgetBeforeRoundTrip!.label || widgetBeforeRoundTrip!.name).toBe(
|
||||
expect(useWidgetValueStore().getWidget(inputSlot.widgetId)?.label).toBe(
|
||||
'My Seed'
|
||||
)
|
||||
|
||||
// Serialize and reconfigure (simulates save/reload)
|
||||
const serialized = subgraphNode.serialize()
|
||||
subgraphNode.configure(serialized)
|
||||
|
||||
// Label should survive the round-trip
|
||||
const widgetAfterRoundTrip = subgraphNode.widgets?.[0]
|
||||
expect(widgetAfterRoundTrip).toBeDefined()
|
||||
expect(widgetAfterRoundTrip!.label || widgetAfterRoundTrip!.name).toBe(
|
||||
expect(subgraphNode.widgets).toMatchObject([
|
||||
{ name: 'seed', label: 'My Seed' }
|
||||
])
|
||||
expect(inputSlot.label).toBe('My Seed')
|
||||
expect(useWidgetValueStore().getWidget(inputSlot.widgetId)?.label).toBe(
|
||||
'My Seed'
|
||||
)
|
||||
})
|
||||
@@ -339,11 +483,10 @@ describe('SubgraphNode widget name collision on rename', () => {
|
||||
// Display labels: input[1] was renamed
|
||||
expect(subgraphNode.inputs[1].label).toBe('prompt_a')
|
||||
|
||||
// Distinct _widget bindings
|
||||
expect(subgraphNode.inputs[0]._widget).toBeDefined()
|
||||
expect(subgraphNode.inputs[1]._widget).toBeDefined()
|
||||
expect(subgraphNode.inputs[0]._widget).not.toBe(
|
||||
subgraphNode.inputs[1]._widget
|
||||
expect(subgraphNode.inputs[0].widgetId).toBeDefined()
|
||||
expect(subgraphNode.inputs[1].widgetId).toBeDefined()
|
||||
expect(subgraphNode.inputs[0].widgetId).not.toBe(
|
||||
subgraphNode.inputs[1].widgetId
|
||||
)
|
||||
})
|
||||
|
||||
@@ -394,11 +537,10 @@ describe('SubgraphNode widget name collision on rename', () => {
|
||||
expect(subgraphNode.inputs[0].widget?.name).toBe(key0)
|
||||
expect(subgraphNode.inputs[1].widget?.name).toBe(key1)
|
||||
|
||||
// Distinct _widget bindings survive the rename
|
||||
expect(subgraphNode.inputs[0]._widget).toBeDefined()
|
||||
expect(subgraphNode.inputs[1]._widget).toBeDefined()
|
||||
expect(subgraphNode.inputs[0]._widget).not.toBe(
|
||||
subgraphNode.inputs[1]._widget
|
||||
expect(subgraphNode.inputs[0].widgetId).toBeDefined()
|
||||
expect(subgraphNode.inputs[1].widgetId).toBeDefined()
|
||||
expect(subgraphNode.inputs[0].widgetId).not.toBe(
|
||||
subgraphNode.inputs[1].widgetId
|
||||
)
|
||||
})
|
||||
|
||||
@@ -437,17 +579,13 @@ describe('SubgraphNode widget name collision on rename', () => {
|
||||
newName: 'alpha'
|
||||
})
|
||||
|
||||
// Simulate onGraphConfigured check: for each input with widget,
|
||||
// find a matching widget by name. If not found, the input gets removed.
|
||||
for (const input of subgraphNode.inputs) {
|
||||
if (!input.widget) continue
|
||||
const name = input.widget.name
|
||||
const w = subgraphNode.widgets?.find((w) => w.name === name)
|
||||
// Every input should find at least one matching widget
|
||||
expect(w).toBeDefined()
|
||||
expect(input.widgetId).toBeDefined()
|
||||
if (!input.widgetId) throw new Error('Missing widgetId')
|
||||
expect(useWidgetValueStore().getWidget(input.widgetId)).toBeDefined()
|
||||
}
|
||||
|
||||
// Both inputs should survive
|
||||
expect(subgraphNode.widgets).toHaveLength(2)
|
||||
expect(subgraphNode.inputs).toHaveLength(2)
|
||||
})
|
||||
})
|
||||
@@ -929,41 +1067,6 @@ describe('Nested SubgraphNode duplicate input prevention', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('SubgraphNode promotion view keys', () => {
|
||||
it('distinguishes tuples that differ only by colon placement', () => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
|
||||
const subgraph = createTestSubgraph()
|
||||
const subgraphNode = createTestSubgraphNode(subgraph)
|
||||
const nodeWithKeyBuilder = fromAny<
|
||||
{
|
||||
_makePromotionViewKey: (
|
||||
inputKey: string,
|
||||
interiorNodeId: string,
|
||||
widgetName: string,
|
||||
inputName?: string
|
||||
) => string
|
||||
},
|
||||
unknown
|
||||
>(subgraphNode)
|
||||
|
||||
const firstKey = nodeWithKeyBuilder._makePromotionViewKey(
|
||||
'65',
|
||||
'18',
|
||||
'a:b',
|
||||
'c'
|
||||
)
|
||||
const secondKey = nodeWithKeyBuilder._makePromotionViewKey(
|
||||
'65',
|
||||
'18',
|
||||
'a',
|
||||
'b:c'
|
||||
)
|
||||
|
||||
expect(firstKey).not.toBe(secondKey)
|
||||
})
|
||||
})
|
||||
|
||||
describe('SubgraphNode label propagation', () => {
|
||||
it('should preserve input labels from configure path', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
@@ -1008,15 +1111,21 @@ describe('SubgraphNode label propagation', () => {
|
||||
const labelChangedSpy = vi.spyOn(subgraphNode.graph!, 'trigger')
|
||||
|
||||
expect(promotedInput.label).toBeUndefined()
|
||||
expect(promotedInput._widget).toBeDefined()
|
||||
expect(promotedInput._widget).toBe(subgraphNode.widgets[0])
|
||||
expect(promotedInput.widgetId).toBeDefined()
|
||||
if (!promotedInput.widgetId) throw new Error('Missing widgetId')
|
||||
|
||||
subgraph.renameInput(subgraph.inputs[0], 'Steps Count')
|
||||
|
||||
expect(promotedInput.label).toBe('Steps Count')
|
||||
expect(promotedInput.name).toBe('steps')
|
||||
expect(promotedInput.widget?.name).toBe(originalWidgetName)
|
||||
expect(promotedInput._widget?.label).toBe('Steps Count')
|
||||
expect(subgraphNode.widgets?.[0].label).toBe('Steps Count')
|
||||
expect(useWidgetValueStore().getWidget(promotedInput.widgetId)?.label).toBe(
|
||||
'Steps Count'
|
||||
)
|
||||
expect(subgraphNode.widgets).toMatchObject([
|
||||
{ name: 'steps', label: 'Steps Count', widgetId: promotedInput.widgetId }
|
||||
])
|
||||
expect(labelChangedSpy).toHaveBeenCalledWith('node:slot-label:changed', {
|
||||
nodeId: subgraphNode.id,
|
||||
slotType: NodeSlotType.INPUT
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import cloneDeep from 'es-toolkit/compat/cloneDeep'
|
||||
import type { BaseLGraph, LGraph, SubgraphId } from '@/lib/litegraph/src/LGraph'
|
||||
import type { LGraphButton } from '@/lib/litegraph/src/LGraphButton'
|
||||
import type { LGraphCanvas } from '@/lib/litegraph/src/LGraphCanvas'
|
||||
@@ -33,23 +34,19 @@ import type {
|
||||
TWidgetValue
|
||||
} from '@/lib/litegraph/src/types/widgets'
|
||||
import { isWidgetValue } from '@/lib/litegraph/src/types/widgets'
|
||||
import {
|
||||
createPromotedWidgetView,
|
||||
isPromotedWidgetView
|
||||
} from '@/core/graph/subgraph/promotedWidgetView'
|
||||
import type { PromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetView'
|
||||
import type { PromotedWidgetSource } from '@/core/graph/subgraph/promotedWidgetTypes'
|
||||
import { resolveConcretePromotedWidget } from '@/core/graph/subgraph/resolveConcretePromotedWidget'
|
||||
import { resolveSubgraphInputTarget } from '@/core/graph/subgraph/resolveSubgraphInputTarget'
|
||||
import { syncWidgetControl } from '@/core/graph/widgets/control/syncWidgetControl'
|
||||
import { parsePreviewExposures } from '@/core/schemas/previewExposureSchema'
|
||||
import { parseProxyWidgetErrorQuarantine } from '@/core/schemas/proxyWidgetQuarantineSchema'
|
||||
import { useDomWidgetStore } from '@/stores/domWidgetStore'
|
||||
import { usePreviewExposureStore } from '@/stores/previewExposureStore'
|
||||
import { useWidgetValueStore } from '@/stores/widgetValueStore'
|
||||
import { createNodeLocatorId } from '@/types/nodeIdentification'
|
||||
import { readWidgetValue } from '@/world/widgetValueIO'
|
||||
import type { WidgetId } from '@/types/widgetId'
|
||||
import { widgetId } from '@/types/widgetId'
|
||||
|
||||
import { ExecutableNodeDTO } from './ExecutableNodeDTO'
|
||||
import type { ExecutableLGraphNode, ExecutionId } from './ExecutableNodeDTO'
|
||||
import { PromotedWidgetViewManager } from './PromotedWidgetViewManager'
|
||||
import type { SubgraphInput } from './SubgraphInput'
|
||||
import { createBitmapCache } from './svgBitmapCache'
|
||||
|
||||
@@ -57,11 +54,6 @@ const workflowSvg = new Image()
|
||||
workflowSvg.src =
|
||||
"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' width='16' height='16'%3E%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 16 16'%3E%3Cpath stroke='white' stroke-linecap='round' stroke-width='1.3' d='M9.18613 3.09999H6.81377M9.18613 12.9H7.55288c-3.08678 0-5.35171-2.99581-4.60305-6.08843l.3054-1.26158M14.7486 2.1721l-.5931 2.45c-.132.54533-.6065.92789-1.1508.92789h-2.2993c-.77173 0-1.33797-.74895-1.1508-1.5221l.5931-2.45c.132-.54533.6065-.9279 1.1508-.9279h2.2993c.7717 0 1.3379.74896 1.1508 1.52211Zm-8.3033 0-.59309 2.45c-.13201.54533-.60646.92789-1.15076.92789H2.4021c-.7717 0-1.33793-.74895-1.15077-1.5221l.59309-2.45c.13201-.54533.60647-.9279 1.15077-.9279h2.29935c.77169 0 1.33792.74896 1.15076 1.52211Zm8.3033 9.8-.5931 2.45c-.132.5453-.6065.9279-1.1508.9279h-2.2993c-.77173 0-1.33797-.749-1.1508-1.5221l.5931-2.45c.132-.5453.6065-.9279 1.1508-.9279h2.2993c.7717 0 1.3379.7489 1.1508 1.5221Z'/%3E%3C/svg%3E %3C/svg%3E"
|
||||
|
||||
type LinkedPromotionEntry = PromotedWidgetSource & {
|
||||
inputName: string
|
||||
inputKey: string
|
||||
slotName: string
|
||||
}
|
||||
const workflowBitmapCache = createBitmapCache(workflowSvg, 32)
|
||||
|
||||
export class SubgraphNode extends LGraphNode implements BaseLGraph {
|
||||
@@ -85,179 +77,14 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
|
||||
return true
|
||||
}
|
||||
|
||||
private _promotedViewManager =
|
||||
new PromotedWidgetViewManager<PromotedWidgetView>()
|
||||
private _cacheVersion = 0
|
||||
private _linkedEntriesCache?: {
|
||||
version: number
|
||||
entries: LinkedPromotionEntry[]
|
||||
}
|
||||
private _promotedViewsCache?: {
|
||||
version: number
|
||||
views: PromotedWidgetView[]
|
||||
}
|
||||
|
||||
declare widgets: IBaseWidget[]
|
||||
|
||||
private _resolveLinkedPromotionBySubgraphInput(
|
||||
subgraphInput: SubgraphInput
|
||||
): PromotedWidgetSource | undefined {
|
||||
for (const linkId of subgraphInput.linkIds) {
|
||||
const link = this.subgraph.getLink(linkId)
|
||||
if (!link) continue
|
||||
|
||||
const { inputNode } = link.resolve(this.subgraph)
|
||||
if (!inputNode || !Array.isArray(inputNode.inputs)) continue
|
||||
|
||||
const targetInput = inputNode.inputs.find(
|
||||
(entry) => entry.link === linkId
|
||||
)
|
||||
if (!targetInput) continue
|
||||
|
||||
const targetWidget = inputNode.getWidgetFromSlot(targetInput)
|
||||
if (!targetWidget) continue
|
||||
|
||||
if (inputNode.isSubgraphNode()) {
|
||||
return {
|
||||
sourceNodeId: String(inputNode.id),
|
||||
sourceWidgetName: targetInput.name
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
sourceNodeId: String(inputNode.id),
|
||||
sourceWidgetName: targetWidget.name
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private _getLinkedPromotionEntries(cache = true): LinkedPromotionEntry[] {
|
||||
const cached = this._linkedEntriesCache
|
||||
if (cache && cached?.version === this._cacheVersion) return cached.entries
|
||||
|
||||
const linkedEntries: LinkedPromotionEntry[] = []
|
||||
|
||||
for (const input of this.inputs) {
|
||||
const subgraphInput = input._subgraphSlot
|
||||
if (!subgraphInput) continue
|
||||
|
||||
const boundWidget =
|
||||
input._widget && isPromotedWidgetView(input._widget)
|
||||
? input._widget
|
||||
: undefined
|
||||
if (boundWidget) {
|
||||
const boundNode = this.subgraph.getNodeById(boundWidget.sourceNodeId)
|
||||
const hasBoundSourceWidget =
|
||||
boundNode?.widgets?.some(
|
||||
(widget) => widget.name === boundWidget.sourceWidgetName
|
||||
) === true
|
||||
if (hasBoundSourceWidget) {
|
||||
linkedEntries.push({
|
||||
inputName: input.label ?? input.name,
|
||||
inputKey: String(subgraphInput.id),
|
||||
slotName: subgraphInput.name,
|
||||
sourceNodeId: boundWidget.sourceNodeId,
|
||||
sourceWidgetName: boundWidget.sourceWidgetName
|
||||
})
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
const resolved =
|
||||
this._resolveLinkedPromotionBySubgraphInput(subgraphInput)
|
||||
if (!resolved) continue
|
||||
|
||||
linkedEntries.push({
|
||||
inputName: input.label ?? input.name,
|
||||
inputKey: String(subgraphInput.id),
|
||||
slotName: subgraphInput.name,
|
||||
...resolved
|
||||
})
|
||||
}
|
||||
|
||||
const seenEntryKeys = new Set<string>()
|
||||
const deduplicatedEntries = linkedEntries.filter((entry) => {
|
||||
const entryKey = this._makePromotionViewKey(
|
||||
entry.inputKey,
|
||||
entry.sourceNodeId,
|
||||
entry.sourceWidgetName,
|
||||
entry.inputName
|
||||
)
|
||||
if (seenEntryKeys.has(entryKey)) return false
|
||||
|
||||
seenEntryKeys.add(entryKey)
|
||||
return true
|
||||
})
|
||||
|
||||
if (cache)
|
||||
this._linkedEntriesCache = {
|
||||
version: this._cacheVersion,
|
||||
entries: deduplicatedEntries
|
||||
}
|
||||
|
||||
return deduplicatedEntries
|
||||
}
|
||||
|
||||
private _getPromotedViews(): PromotedWidgetView[] {
|
||||
const cachedViews = this._promotedViewsCache
|
||||
if (cachedViews?.version === this._cacheVersion) return cachedViews.views
|
||||
|
||||
const linkedEntries = this._getLinkedPromotionEntries()
|
||||
const reconcileEntries: Array<{
|
||||
sourceNodeId: string
|
||||
sourceWidgetName: string
|
||||
viewKey: string
|
||||
slotName: string
|
||||
}> = []
|
||||
const displayNameByViewKey = new Map<string, string>()
|
||||
for (const entry of linkedEntries) {
|
||||
const viewKey = this._makePromotionViewKey(
|
||||
entry.inputKey,
|
||||
entry.sourceNodeId,
|
||||
entry.sourceWidgetName,
|
||||
entry.inputName
|
||||
)
|
||||
reconcileEntries.push({
|
||||
sourceNodeId: entry.sourceNodeId,
|
||||
sourceWidgetName: entry.sourceWidgetName,
|
||||
slotName: entry.slotName,
|
||||
viewKey
|
||||
})
|
||||
displayNameByViewKey.set(viewKey, entry.inputName)
|
||||
}
|
||||
|
||||
const views = this._promotedViewManager.reconcile(
|
||||
reconcileEntries,
|
||||
(entry) =>
|
||||
createPromotedWidgetView(
|
||||
this,
|
||||
entry.sourceNodeId,
|
||||
entry.sourceWidgetName,
|
||||
entry.viewKey ? displayNameByViewKey.get(entry.viewKey) : undefined,
|
||||
entry.slotName
|
||||
)
|
||||
)
|
||||
|
||||
this._promotedViewsCache = {
|
||||
version: this._cacheVersion,
|
||||
views
|
||||
}
|
||||
|
||||
return views
|
||||
}
|
||||
|
||||
invalidatePromotedViews(): void {
|
||||
this._cacheVersion++
|
||||
}
|
||||
|
||||
private _makePromotionViewKey(
|
||||
inputKey: string,
|
||||
sourceNodeId: string,
|
||||
sourceWidgetName: string,
|
||||
inputName = ''
|
||||
): string {
|
||||
return JSON.stringify([inputKey, sourceNodeId, sourceWidgetName, inputName])
|
||||
}
|
||||
/**
|
||||
* Retained as a no-op for extension compatibility: promoted host widgets are
|
||||
* now store-backed and addressed by widgetId, so there is no view cache to
|
||||
* invalidate.
|
||||
*/
|
||||
invalidatePromotedViews(): void {}
|
||||
|
||||
private _eventAbortController = new AbortController()
|
||||
|
||||
@@ -270,7 +97,11 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
|
||||
this.graph = graph
|
||||
|
||||
Object.defineProperty(this, 'widgets', {
|
||||
get: () => this._getPromotedViews(),
|
||||
get: () =>
|
||||
this.inputs.flatMap((input) => {
|
||||
const widget = this._projectPromotedWidget(input)
|
||||
return widget ? [widget] : []
|
||||
}),
|
||||
set: () => {
|
||||
if (import.meta.env.DEV)
|
||||
console.warn(
|
||||
@@ -310,9 +141,9 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
|
||||
this._setWidget(
|
||||
subgraphInput,
|
||||
existingInput,
|
||||
inputNode,
|
||||
widget,
|
||||
input.widget,
|
||||
inputNode
|
||||
input.widget
|
||||
)
|
||||
return
|
||||
}
|
||||
@@ -369,8 +200,11 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
|
||||
// identifier used by onGraphConfigured (widgetInputs.ts) to match
|
||||
// inputs to widgets. Changing it to the display label would cause
|
||||
// collisions when two promoted inputs share the same label.
|
||||
// Display is handled via input.label and _widget.label.
|
||||
if (input._widget) input._widget.label = newName
|
||||
if (input.widgetId) {
|
||||
const state = useWidgetValueStore().getWidget(input.widgetId)
|
||||
if (state) state.label = newName
|
||||
}
|
||||
this.invalidatePromotedViews()
|
||||
this.graph?.trigger('node:slot-label:changed', {
|
||||
nodeId: this.id,
|
||||
@@ -419,6 +253,63 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
|
||||
}
|
||||
}
|
||||
|
||||
private _projectPromotedWidget(
|
||||
input: INodeInputSlot
|
||||
): IBaseWidget | undefined {
|
||||
if (input._widget) return input._widget
|
||||
|
||||
const id = input.widgetId
|
||||
if (!id) return
|
||||
|
||||
const store = useWidgetValueStore()
|
||||
const widget: IBaseWidget = {
|
||||
get name() {
|
||||
return store.getWidget(id)?.name ?? input.name
|
||||
},
|
||||
get label() {
|
||||
return store.getWidget(id)?.label ?? input.label ?? input.name
|
||||
},
|
||||
set label(next) {
|
||||
const state = store.getWidget(id)
|
||||
if (state) state.label = next
|
||||
},
|
||||
get y() {
|
||||
return store.getWidget(id)?.y ?? 0
|
||||
},
|
||||
set y(next) {
|
||||
const state = store.getWidget(id)
|
||||
if (state) state.y = next
|
||||
},
|
||||
get type() {
|
||||
return store.getWidget(id)?.type ?? 'text'
|
||||
},
|
||||
get options() {
|
||||
return store.getWidget(id)?.options ?? {}
|
||||
},
|
||||
get value() {
|
||||
const value = store.getWidget(id)?.value
|
||||
return isWidgetValue(value) ? value : undefined
|
||||
},
|
||||
set value(next) {
|
||||
store.setValue(id, next)
|
||||
},
|
||||
// Canvas edits operate on a transient concrete widget (toConcreteWidget),
|
||||
// so the value setter above is never invoked; BaseWidget.setValue writes
|
||||
// its own local state and then calls this callback, which is the only
|
||||
// bridge back to the store.
|
||||
callback(next) {
|
||||
store.setValue(id, next)
|
||||
}
|
||||
}
|
||||
Object.defineProperty(widget, 'widgetId', {
|
||||
value: id,
|
||||
enumerable: false,
|
||||
configurable: true
|
||||
})
|
||||
input._widget = widget
|
||||
return input._widget
|
||||
}
|
||||
|
||||
private _addSubgraphInputListeners(
|
||||
subgraphInput: SubgraphInput,
|
||||
input: INodeInputSlot & Partial<ISubgraphInput>
|
||||
@@ -437,33 +328,16 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
|
||||
subgraphInput.events.addEventListener(
|
||||
'input-connected',
|
||||
(e) => {
|
||||
this.invalidatePromotedViews()
|
||||
input.shape = this.getSlotShape(subgraphInput, e.detail.input)
|
||||
if (!e.detail.widget || !e.detail.node) return
|
||||
|
||||
const boundWidget =
|
||||
input._widget && isPromotedWidgetView(input._widget)
|
||||
? input._widget
|
||||
: undefined
|
||||
const hasStaleBoundWidget =
|
||||
boundWidget &&
|
||||
this.subgraph
|
||||
.getNodeById(boundWidget.sourceNodeId)
|
||||
?.widgets?.some(
|
||||
(widget) => widget.name === boundWidget.sourceWidgetName
|
||||
) !== true
|
||||
|
||||
const shouldSetWidgetFromEvent = !input._widget || hasStaleBoundWidget
|
||||
if (shouldSetWidgetFromEvent)
|
||||
this._setWidget(
|
||||
subgraphInput,
|
||||
input,
|
||||
e.detail.widget,
|
||||
e.detail.input.widget,
|
||||
e.detail.node
|
||||
)
|
||||
|
||||
this.invalidatePromotedViews()
|
||||
this._setWidget(
|
||||
subgraphInput,
|
||||
input,
|
||||
e.detail.node,
|
||||
e.detail.widget,
|
||||
e.detail.input.widget
|
||||
)
|
||||
},
|
||||
{ signal }
|
||||
)
|
||||
@@ -482,9 +356,11 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
|
||||
}
|
||||
|
||||
if (input._widget) this.ensureWidgetRemoved(input._widget)
|
||||
if (input.widgetId) useWidgetValueStore().deleteWidget(input.widgetId)
|
||||
|
||||
delete input.pos
|
||||
delete input.widget
|
||||
delete input.widgetId
|
||||
input._widget = undefined
|
||||
this.invalidatePromotedViews()
|
||||
},
|
||||
@@ -609,12 +485,13 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
|
||||
|
||||
let valueIndex = 0
|
||||
for (const input of this.inputs) {
|
||||
const view = input._widget
|
||||
if (!view || !isPromotedWidgetView(view)) continue
|
||||
if (!input.widgetId) continue
|
||||
const value =
|
||||
quarantineValuesByInputName.get(input.name) ??
|
||||
widgetValues?.[valueIndex]
|
||||
if (value !== undefined) view.hydrateHostValue(value)
|
||||
if (value !== undefined) {
|
||||
useWidgetValueStore().setValue(input.widgetId, value)
|
||||
}
|
||||
valueIndex += 1
|
||||
}
|
||||
}
|
||||
@@ -639,7 +516,6 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
|
||||
this._rebindInputSubgraphSlots()
|
||||
|
||||
this.inputs = this.inputs.filter((input) => input._subgraphSlot)
|
||||
this._promotedViewManager.clear()
|
||||
this.invalidatePromotedViews()
|
||||
|
||||
this._hydratePreviewExposures()
|
||||
@@ -683,13 +559,13 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
|
||||
}
|
||||
|
||||
rebuildInputWidgetBindings(): void {
|
||||
this._promotedViewManager.clear()
|
||||
this.invalidatePromotedViews()
|
||||
|
||||
for (const input of this.inputs) {
|
||||
delete input.widget
|
||||
delete input.pos
|
||||
input._widget = undefined
|
||||
delete input.widgetId
|
||||
this._clearPromotedWidget(input)
|
||||
const subgraphInput = input._subgraphSlot
|
||||
if (!subgraphInput) continue
|
||||
this._resolveInputWidget(subgraphInput, input)
|
||||
@@ -725,76 +601,120 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
|
||||
}
|
||||
|
||||
const widget = inputNode.getWidgetFromSlot(targetInput)
|
||||
if (!widget) continue
|
||||
if (widget) {
|
||||
this._setWidget(
|
||||
subgraphInput,
|
||||
input,
|
||||
inputNode,
|
||||
widget,
|
||||
targetInput.widget
|
||||
)
|
||||
break
|
||||
}
|
||||
|
||||
this._setWidget(
|
||||
subgraphInput,
|
||||
input,
|
||||
widget,
|
||||
targetInput.widget,
|
||||
inputNode
|
||||
)
|
||||
break
|
||||
// Nested promotion: the source is itself a promoted subgraph input with
|
||||
// no concrete widget. Resolve the deepest interior widget so this input
|
||||
// still receives widget state and a widgetId.
|
||||
const nested = this._resolveNestedPromotedSource(inputNode, targetInput)
|
||||
if (nested) {
|
||||
this._setWidget(
|
||||
subgraphInput,
|
||||
input,
|
||||
nested.node,
|
||||
nested.widget,
|
||||
targetInput.widget
|
||||
)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private _resolveNestedPromotedSource(
|
||||
inputNode: LGraphNode,
|
||||
targetInput: INodeInputSlot
|
||||
): { node: LGraphNode; widget: IBaseWidget } | undefined {
|
||||
if (!inputNode.isSubgraphNode()) return undefined
|
||||
|
||||
const target = resolveSubgraphInputTarget(inputNode, targetInput.name)
|
||||
if (!target) return undefined
|
||||
|
||||
const resolved = resolveConcretePromotedWidget(
|
||||
inputNode,
|
||||
target.nodeId,
|
||||
target.widgetName
|
||||
)
|
||||
return resolved.status === 'resolved' ? resolved.resolved : undefined
|
||||
}
|
||||
|
||||
private _setWidget(
|
||||
subgraphInput: Readonly<SubgraphInput>,
|
||||
input: INodeInputSlot,
|
||||
interiorNode: Readonly<LGraphNode>,
|
||||
interiorWidget: Readonly<IBaseWidget>,
|
||||
inputWidget: IWidgetLocator | undefined,
|
||||
interiorNode: LGraphNode
|
||||
inputWidget: IWidgetLocator | undefined
|
||||
) {
|
||||
this.invalidatePromotedViews()
|
||||
|
||||
const nodeId = String(interiorNode.id)
|
||||
const widgetName = interiorWidget.name
|
||||
this._clearPromotedWidget(input)
|
||||
|
||||
const previousView = input._widget
|
||||
|
||||
if (
|
||||
previousView &&
|
||||
isPromotedWidgetView(previousView) &&
|
||||
(previousView.sourceNodeId !== nodeId ||
|
||||
previousView.sourceWidgetName !== widgetName)
|
||||
) {
|
||||
this._removePromotedView(previousView)
|
||||
}
|
||||
|
||||
const view = this._promotedViewManager.getOrCreate(
|
||||
nodeId,
|
||||
widgetName,
|
||||
() =>
|
||||
createPromotedWidgetView(
|
||||
this,
|
||||
nodeId,
|
||||
widgetName,
|
||||
input.label ?? subgraphInput.name,
|
||||
subgraphInput.name
|
||||
),
|
||||
this._makePromotionViewKey(
|
||||
String(subgraphInput.id),
|
||||
nodeId,
|
||||
widgetName,
|
||||
input.label ?? input.name
|
||||
)
|
||||
)
|
||||
|
||||
// NOTE: This code creates linked chains of prototypes for passing across
|
||||
// multiple levels of subgraphs. As part of this, it intentionally avoids
|
||||
// creating new objects. Have care when making changes.
|
||||
input.widget ??= { name: subgraphInput.name }
|
||||
input.widget.name = subgraphInput.name
|
||||
if (inputWidget) Object.setPrototypeOf(input.widget, inputWidget)
|
||||
|
||||
input._widget = view
|
||||
const id = widgetId(this.rootGraph.id, this.id, subgraphInput.name)
|
||||
input.widgetId = id
|
||||
useWidgetValueStore().registerWidget(id, {
|
||||
type: interiorWidget.type,
|
||||
value: interiorWidget.value,
|
||||
options: cloneDeep(interiorWidget.options ?? {}),
|
||||
label: input.label ?? subgraphInput.name,
|
||||
serialize: interiorWidget.serialize,
|
||||
disabled: interiorWidget.disabled,
|
||||
isDOMWidget:
|
||||
'isDOMWidget' in interiorWidget &&
|
||||
typeof interiorWidget.isDOMWidget === 'boolean'
|
||||
? interiorWidget.isDOMWidget
|
||||
: undefined
|
||||
})
|
||||
syncWidgetControl(
|
||||
id,
|
||||
this.rootGraph.id,
|
||||
interiorNode.id,
|
||||
interiorWidget.linkedWidgets
|
||||
)
|
||||
input._widget =
|
||||
this.createPromotedHostWidget(input, id, interiorWidget) ??
|
||||
this._projectPromotedWidget(input)
|
||||
this._setConcreteSlots()
|
||||
|
||||
this.subgraph.events.dispatch('widget-promoted', {
|
||||
widget: view,
|
||||
widget: interiorWidget,
|
||||
subgraphNode: this
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* App-layer hook to build a promoted DOM widget; the default falls back to the
|
||||
* store-backed projection. Litegraph core stays free of Vue/Pinia/DOM.
|
||||
*/
|
||||
protected createPromotedHostWidget(
|
||||
_input: INodeInputSlot,
|
||||
_id: WidgetId,
|
||||
_sourceWidget: Readonly<IBaseWidget>
|
||||
): IBaseWidget | undefined {
|
||||
return undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs the host widget's `onRemove` (unregistering DOM widgets) and clears it.
|
||||
* Unlike {@link ensureWidgetRemoved}, dispatches no demotion event, so it is
|
||||
* safe on re-resolution.
|
||||
*/
|
||||
private _clearPromotedWidget(input: INodeInputSlot): void {
|
||||
input._widget?.onRemove?.()
|
||||
input._widget = undefined
|
||||
}
|
||||
|
||||
override onAdded(_graph: LGraph): void {
|
||||
this.invalidatePromotedViews()
|
||||
}
|
||||
@@ -816,10 +736,13 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
|
||||
override getSlotFromWidget(
|
||||
widget: IBaseWidget | undefined
|
||||
): INodeInputSlot | undefined {
|
||||
if (!widget || !isPromotedWidgetView(widget))
|
||||
return super.getSlotFromWidget(widget)
|
||||
|
||||
return this.inputs.find((input) => input._widget === widget)
|
||||
if (widget?.widgetId) {
|
||||
const promotedInput = this.inputs.find(
|
||||
(input) => input.widgetId === widget.widgetId
|
||||
)
|
||||
if (promotedInput) return promotedInput
|
||||
}
|
||||
return super.getSlotFromWidget(widget)
|
||||
}
|
||||
|
||||
override getWidgetFromSlot(slot: INodeInputSlot): IBaseWidget | undefined {
|
||||
@@ -928,83 +851,20 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
|
||||
return nodes
|
||||
}
|
||||
|
||||
private _clearDomOverrideForView(view: PromotedWidgetView): void {
|
||||
const resolved = resolveConcretePromotedWidget(
|
||||
this,
|
||||
view.sourceNodeId,
|
||||
view.sourceWidgetName
|
||||
)
|
||||
if (resolved.status !== 'resolved') return
|
||||
|
||||
const interiorWidget = resolved.resolved.widget
|
||||
if (
|
||||
interiorWidget &&
|
||||
'id' in interiorWidget &&
|
||||
('element' in interiorWidget || 'component' in interiorWidget)
|
||||
) {
|
||||
useDomWidgetStore().clearPositionOverride(String(interiorWidget.id))
|
||||
}
|
||||
}
|
||||
|
||||
private _removePromotedView(view: PromotedWidgetView): void {
|
||||
this.invalidatePromotedViews()
|
||||
|
||||
this._promotedViewManager.remove(view.sourceNodeId, view.sourceWidgetName)
|
||||
for (const input of this.inputs) {
|
||||
if (input._widget !== view || !input._subgraphSlot) continue
|
||||
const inputName = input.label ?? input.name
|
||||
|
||||
this._promotedViewManager.removeByViewKey(
|
||||
view.sourceNodeId,
|
||||
view.sourceWidgetName,
|
||||
this._makePromotionViewKey(
|
||||
String(input._subgraphSlot.id),
|
||||
view.sourceNodeId,
|
||||
view.sourceWidgetName,
|
||||
inputName
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override removeWidget(widget: IBaseWidget): void {
|
||||
this.ensureWidgetRemoved(widget)
|
||||
}
|
||||
|
||||
override ensureWidgetRemoved(widget: IBaseWidget): void {
|
||||
if (isPromotedWidgetView(widget)) {
|
||||
this._clearDomOverrideForView(widget)
|
||||
this._removePromotedView(widget)
|
||||
}
|
||||
for (const input of this.inputs) {
|
||||
if (input._widget === widget) {
|
||||
input._widget = undefined
|
||||
input.widget = undefined
|
||||
}
|
||||
}
|
||||
widget.onRemove?.()
|
||||
this.subgraph.events.dispatch('widget-demoted', {
|
||||
widget,
|
||||
subgraphNode: this
|
||||
})
|
||||
|
||||
this.invalidatePromotedViews()
|
||||
}
|
||||
|
||||
override onRemoved(): void {
|
||||
this._eventAbortController.abort()
|
||||
this.invalidatePromotedViews()
|
||||
|
||||
for (const widget of this.widgets) {
|
||||
if (isPromotedWidgetView(widget)) {
|
||||
this._clearDomOverrideForView(widget)
|
||||
}
|
||||
this.subgraph.events.dispatch('widget-demoted', {
|
||||
widget,
|
||||
subgraphNode: this
|
||||
})
|
||||
}
|
||||
|
||||
this._promotedViewManager.clear()
|
||||
|
||||
for (const input of this.inputs) {
|
||||
if (
|
||||
@@ -1013,6 +873,7 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
|
||||
) {
|
||||
input._listenerController.abort()
|
||||
}
|
||||
this._clearPromotedWidget(input)
|
||||
}
|
||||
}
|
||||
override drawTitleBox(
|
||||
@@ -1074,24 +935,9 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
|
||||
|
||||
serialized.properties = serializedProperties
|
||||
|
||||
if (
|
||||
import.meta.env?.DEV &&
|
||||
this.widgets.some((w) => !isPromotedWidgetView(w))
|
||||
) {
|
||||
console.warn(
|
||||
`SubgraphNode ${this.id}: serialize() drops non-promoted host widgets ` +
|
||||
`(${this.widgets
|
||||
.filter((w) => !isPromotedWidgetView(w))
|
||||
.map((w) => w.name)
|
||||
.join(', ')}); ` +
|
||||
'expected only PromotedWidgetView instances.'
|
||||
)
|
||||
}
|
||||
|
||||
const widgetValues = this.inputs.flatMap((input) => {
|
||||
const widget = input._widget
|
||||
if (!widget || !isPromotedWidgetView(widget)) return []
|
||||
const value = readWidgetValue(widget.entityId)
|
||||
if (!input.widgetId) return []
|
||||
const value = useWidgetValueStore().getWidget(input.widgetId)?.value
|
||||
return [isWidgetValue(value) ? value : undefined]
|
||||
})
|
||||
|
||||
|
||||
@@ -1,27 +1,28 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { fromAny } from '@total-typescript/shoehorn'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type {
|
||||
ISlotType,
|
||||
LGraphCanvas,
|
||||
Subgraph,
|
||||
TWidgetType
|
||||
} from '@/lib/litegraph/src/litegraph'
|
||||
import { BaseWidget, LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { extractVueNodeData } from '@/composables/graph/useGraphNodeManager'
|
||||
import { NumberWidget } from '@/lib/litegraph/src/widgets/NumberWidget'
|
||||
import {
|
||||
appendQuarantine,
|
||||
flushProxyWidgetMigration,
|
||||
makeQuarantineEntry
|
||||
} from '@/core/graph/subgraph/migration/proxyWidgetMigration'
|
||||
import type { PromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
|
||||
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
|
||||
import { reorderSubgraphInputsByName } from '@/core/graph/subgraph/promotionUtils'
|
||||
import type { SerializedProxyWidgetTuple } from '@/core/schemas/promotionSchema'
|
||||
import { computeProcessedWidgets } from '@/renderer/extensions/vueNodes/composables/useProcessedWidgets'
|
||||
import { IS_CONTROL_WIDGET } from '@/scripts/controlWidgetMarker'
|
||||
import { IS_CONTROL_WIDGET } from '@/core/graph/widgets/control/controlWidgetMarker'
|
||||
import { usePreviewExposureStore } from '@/stores/previewExposureStore'
|
||||
import { useWidgetValueStore } from '@/stores/widgetValueStore'
|
||||
import type { WidgetId } from '@/types/widgetId'
|
||||
import { widgetId } from '@/types/widgetId'
|
||||
import { createNodeLocatorId } from '@/types/nodeIdentification'
|
||||
import { graphToPrompt } from '@/utils/executionUtil'
|
||||
|
||||
@@ -77,15 +78,46 @@ function setupPromotedWidget(
|
||||
return createTestSubgraphNode(subgraph)
|
||||
}
|
||||
|
||||
function expectPromotedWidgetView(
|
||||
widget: unknown
|
||||
): asserts widget is PromotedWidgetView {
|
||||
expect(widget).toMatchObject({
|
||||
sourceNodeId: expect.any(String),
|
||||
sourceWidgetName: expect.any(String)
|
||||
function promotedInputs(node: {
|
||||
inputs: Array<{ widgetId?: WidgetId; name: string }>
|
||||
}) {
|
||||
return node.inputs.filter(
|
||||
(input): input is { widgetId: WidgetId; name: string } =>
|
||||
Boolean(input.widgetId)
|
||||
)
|
||||
}
|
||||
|
||||
function promotedWidgetStates(node: {
|
||||
inputs: Array<{ widgetId?: WidgetId; name: string }>
|
||||
}) {
|
||||
return promotedInputs(node).map((input) => {
|
||||
const state = useWidgetValueStore().getWidget(input.widgetId)
|
||||
if (!state) throw new Error(`Missing widget state ${input.widgetId}`)
|
||||
return state
|
||||
})
|
||||
}
|
||||
|
||||
function promotedWidgetStateByName(
|
||||
node: { inputs: Array<{ widgetId?: WidgetId; name: string }> },
|
||||
name: string
|
||||
) {
|
||||
const input = node.inputs.find((input) => input.name === name)
|
||||
if (!input?.widgetId) throw new Error(`Missing promoted input ${name}`)
|
||||
const state = useWidgetValueStore().getWidget(input.widgetId)
|
||||
if (!state) throw new Error(`Missing widget state ${input.widgetId}`)
|
||||
return state
|
||||
}
|
||||
|
||||
function writePromotedWidgetValue(
|
||||
node: { inputs: Array<{ widgetId?: WidgetId; name: string }> },
|
||||
index: number,
|
||||
value: unknown
|
||||
) {
|
||||
const input = promotedInputs(node)[index]
|
||||
if (!input) throw new Error(`Missing promoted input ${index}`)
|
||||
useWidgetValueStore().setValue(input.widgetId, value)
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
resetSubgraphFixtureState()
|
||||
@@ -101,11 +133,41 @@ describe('SubgraphWidgetPromotion', () => {
|
||||
const { node } = createNodeWithWidget('Test Node')
|
||||
const subgraphNode = setupPromotedWidget(subgraph, node)
|
||||
|
||||
// The widget should be promoted to the subgraph node
|
||||
expect(subgraphNode.widgets).toHaveLength(1)
|
||||
expect(subgraphNode.widgets[0].name).toBe('value') // Uses subgraph input name
|
||||
expect(subgraphNode.widgets[0].type).toBe('number')
|
||||
expect(subgraphNode.widgets[0].value).toBe(42)
|
||||
expect(subgraphNode.widgets).toHaveLength(
|
||||
promotedInputs(subgraphNode).length
|
||||
)
|
||||
expect(promotedWidgetStateByName(subgraphNode, 'value')).toMatchObject({
|
||||
type: 'number',
|
||||
value: 42
|
||||
})
|
||||
})
|
||||
|
||||
it('resolves nested promoted widgets before the inner host input is hydrated', () => {
|
||||
const innerSubgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'value', type: 'number' }]
|
||||
})
|
||||
const { node: leaf } = createNodeWithWidget('Leaf')
|
||||
innerSubgraph.add(leaf)
|
||||
innerSubgraph.inputNode.slots[0].connect(leaf.inputs[0], leaf)
|
||||
const innerNode = createTestSubgraphNode(innerSubgraph)
|
||||
innerNode._internalConfigureAfterSlots()
|
||||
innerNode.inputs[0].widgetId = undefined
|
||||
|
||||
const outerSubgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'outer_value', type: 'number' }]
|
||||
})
|
||||
outerSubgraph.add(innerNode)
|
||||
outerSubgraph.inputNode.slots[0].connect(innerNode.inputs[0], innerNode)
|
||||
|
||||
const outerNode = createTestSubgraphNode(outerSubgraph)
|
||||
outerNode._internalConfigureAfterSlots()
|
||||
|
||||
expect(promotedWidgetStateByName(outerNode, 'outer_value')).toMatchObject(
|
||||
{
|
||||
type: 'number',
|
||||
value: 42
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
it('should promote all widget types', () => {
|
||||
@@ -147,13 +209,12 @@ describe('SubgraphWidgetPromotion', () => {
|
||||
|
||||
const subgraphNode = createTestSubgraphNode(subgraph)
|
||||
|
||||
// All widgets should be promoted
|
||||
expect(subgraphNode.widgets).toHaveLength(3)
|
||||
|
||||
// Check specific widget values
|
||||
expect(subgraphNode.widgets[0].value).toBe(100)
|
||||
expect(subgraphNode.widgets[1].value).toBe('test')
|
||||
expect(subgraphNode.widgets[2].value).toBe(true)
|
||||
expect(subgraphNode.widgets).toHaveLength(
|
||||
promotedInputs(subgraphNode).length
|
||||
)
|
||||
expect(
|
||||
promotedWidgetStates(subgraphNode).map((state) => state.value)
|
||||
).toEqual([100, 'test', true])
|
||||
})
|
||||
|
||||
it('should fire widget-promoted event when widget is promoted', () => {
|
||||
@@ -229,13 +290,13 @@ describe('SubgraphWidgetPromotion', () => {
|
||||
// Create SubgraphNode
|
||||
const subgraphNode = createTestSubgraphNode(subgraph)
|
||||
|
||||
// Both widgets should be promoted
|
||||
expect(subgraphNode.widgets).toHaveLength(2)
|
||||
expect(subgraphNode.widgets[0].name).toBe('input1')
|
||||
expect(subgraphNode.widgets[0].value).toBe(10)
|
||||
|
||||
expect(subgraphNode.widgets[1].name).toBe('input2')
|
||||
expect(subgraphNode.widgets[1].value).toBe('hello')
|
||||
expect(subgraphNode.widgets).toHaveLength(
|
||||
promotedInputs(subgraphNode).length
|
||||
)
|
||||
expect(promotedWidgetStateByName(subgraphNode, 'input1').value).toBe(10)
|
||||
expect(promotedWidgetStateByName(subgraphNode, 'input2').value).toBe(
|
||||
'hello'
|
||||
)
|
||||
})
|
||||
|
||||
it('should fire widget-demoted events when node is removed', () => {
|
||||
@@ -246,7 +307,10 @@ describe('SubgraphWidgetPromotion', () => {
|
||||
const { node } = createNodeWithWidget('Test Node')
|
||||
const subgraphNode = setupPromotedWidget(subgraph, node)
|
||||
|
||||
expect(subgraphNode.widgets).toHaveLength(1)
|
||||
expect(subgraphNode.widgets).toHaveLength(
|
||||
promotedInputs(subgraphNode).length
|
||||
)
|
||||
expect(promotedInputs(subgraphNode)).toHaveLength(1)
|
||||
|
||||
const eventCapture = createEventCapture(subgraph.events, [
|
||||
'widget-demoted'
|
||||
@@ -255,9 +319,9 @@ describe('SubgraphWidgetPromotion', () => {
|
||||
// Remove the subgraph node
|
||||
subgraphNode.onRemoved()
|
||||
|
||||
// Should fire demoted events for all widgets
|
||||
const demotedEvents = eventCapture.getEventsByType('widget-demoted')
|
||||
expect(demotedEvents).toHaveLength(1)
|
||||
expect(demotedEvents).toHaveLength(0)
|
||||
expect(promotedInputs(subgraphNode)).toHaveLength(1)
|
||||
|
||||
eventCapture.cleanup()
|
||||
})
|
||||
@@ -274,7 +338,9 @@ describe('SubgraphWidgetPromotion', () => {
|
||||
const subgraphNode = createTestSubgraphNode(subgraph)
|
||||
|
||||
// No widgets should be promoted
|
||||
expect(subgraphNode.widgets).toHaveLength(0)
|
||||
expect(subgraphNode.widgets).toHaveLength(
|
||||
promotedInputs(subgraphNode).length
|
||||
)
|
||||
})
|
||||
|
||||
it('should handle disconnection of promoted widget', () => {
|
||||
@@ -284,13 +350,36 @@ describe('SubgraphWidgetPromotion', () => {
|
||||
|
||||
const { node } = createNodeWithWidget('Test Node')
|
||||
const subgraphNode = setupPromotedWidget(subgraph, node)
|
||||
expect(subgraphNode.widgets).toHaveLength(1)
|
||||
expect(subgraphNode.widgets).toHaveLength(
|
||||
promotedInputs(subgraphNode).length
|
||||
)
|
||||
expect(promotedInputs(subgraphNode)).toHaveLength(1)
|
||||
|
||||
// Disconnect the link
|
||||
subgraph.inputNode.slots[0].disconnect()
|
||||
|
||||
// Widget should be removed (through event listeners)
|
||||
expect(subgraphNode.widgets).toHaveLength(0)
|
||||
expect(subgraphNode.widgets).toHaveLength(
|
||||
promotedInputs(subgraphNode).length
|
||||
)
|
||||
expect(promotedInputs(subgraphNode)).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('writes canvas edits back to the host widget store', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'value', type: 'number' }]
|
||||
})
|
||||
|
||||
const { node } = createNodeWithWidget('Test Node')
|
||||
const subgraphNode = setupPromotedWidget(subgraph, node)
|
||||
|
||||
// Canvas interaction wraps the projected widget in a transient concrete
|
||||
// widget (toConcreteWidget), so the edit only reaches the store through
|
||||
// the widget callback, not the projected widget's value setter.
|
||||
const hostWidget = subgraphNode.widgets[0]
|
||||
const concrete = new NumberWidget(fromAny(hostWidget), subgraphNode)
|
||||
const canvas = fromAny<LGraphCanvas, unknown>({ graph_mouse: [0, 0] })
|
||||
concrete.setValue(99, { e: fromAny({}), node: subgraphNode, canvas })
|
||||
|
||||
expect(promotedWidgetStateByName(subgraphNode, 'value').value).toBe(99)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -320,10 +409,12 @@ describe('SubgraphWidgetPromotion', () => {
|
||||
|
||||
hostNode.configure(serializedHostNode)
|
||||
|
||||
expect(hostNode.widgets).toHaveLength(1)
|
||||
expect(hostNode.widgets[0].name).toBe('batch_size')
|
||||
expect(hostNode.widgets[0].value).toBe(1)
|
||||
expect(hostNode.widgets[0].options.step).toBe(10)
|
||||
expect(hostNode.widgets).toHaveLength(promotedInputs(hostNode).length)
|
||||
expect(promotedWidgetStateByName(hostNode, 'batch_size')).toMatchObject({
|
||||
name: 'batch_size',
|
||||
value: 1,
|
||||
options: expect.objectContaining({ step: 10 })
|
||||
})
|
||||
})
|
||||
|
||||
it('should prune proxyWidgets referencing nodes not in subgraph on configure', () => {
|
||||
@@ -364,18 +455,17 @@ describe('SubgraphWidgetPromotion', () => {
|
||||
|
||||
outerNode.configure(outerNode.serialize())
|
||||
|
||||
// Check widgets getter — stale entries should not produce views
|
||||
const widgetSourceIds = outerNode.widgets
|
||||
.filter(isPromotedWidgetView)
|
||||
.filter((w) => !w.name.startsWith('$$'))
|
||||
.map((w) => w.sourceNodeId)
|
||||
|
||||
expect(widgetSourceIds).not.toContain('999')
|
||||
expect(widgetSourceIds).not.toContain('998')
|
||||
expect(widgetSourceIds).toContain(keptSamplerNodeId)
|
||||
expect(outerNode.widgets).toHaveLength(promotedInputs(outerNode).length)
|
||||
expect(promotedWidgetStateByName(outerNode, 'model').value).toBe(42)
|
||||
expect(outerNode.properties.proxyWidgets).toEqual([
|
||||
['999', 'text'],
|
||||
['998', 'text'],
|
||||
[keptSamplerNodeId, 'widget']
|
||||
])
|
||||
expect(keptSamplerNodeId).toBe(String(samplerNode.id))
|
||||
})
|
||||
|
||||
it('resolves legacy prefixed proxyWidgets via the immediate child PromotedWidgetView identity', () => {
|
||||
it('resolves legacy prefixed proxyWidgets via the immediate child promoted-widget identity', () => {
|
||||
const rootGraph = createTestRootGraph()
|
||||
|
||||
const innerSubgraph = createTestSubgraph({
|
||||
@@ -417,12 +507,7 @@ describe('SubgraphWidgetPromotion', () => {
|
||||
hostNode.configure(serializedHostNode)
|
||||
flushProxyWidgetMigration({ hostNode })
|
||||
|
||||
const promotedWidgets = hostNode.widgets
|
||||
.filter(isPromotedWidgetView)
|
||||
.filter((widget) => !widget.name.startsWith('$$'))
|
||||
|
||||
expect(promotedWidgets).toHaveLength(1)
|
||||
expect(promotedWidgets[0]?.sourceNodeId).toBe(String(nestedNode.id))
|
||||
expect(hostNode.widgets).toHaveLength(promotedInputs(hostNode).length)
|
||||
expect(hostNode.properties.proxyWidgets).toBeUndefined()
|
||||
expect(hostNode.properties.proxyWidgetErrorQuarantine).toBeUndefined()
|
||||
})
|
||||
@@ -446,12 +531,8 @@ describe('SubgraphWidgetPromotion', () => {
|
||||
const cloneNode = createTestSubgraphNode(subgraph)
|
||||
cloneNode.configure(serialized)
|
||||
|
||||
const promotedNames = cloneNode.widgets
|
||||
.filter(isPromotedWidgetView)
|
||||
.filter((widget) => !widget.name.startsWith('$$'))
|
||||
.map((widget) => widget.sourceWidgetName)
|
||||
|
||||
expect(promotedNames).toContain('text')
|
||||
expect(cloneNode.widgets).toHaveLength(promotedInputs(cloneNode).length)
|
||||
expect(promotedWidgetStateByName(cloneNode, 'text').value).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -462,6 +543,9 @@ describe('SubgraphWidgetPromotion', () => {
|
||||
})
|
||||
|
||||
const originalTooltip = 'This is a test tooltip'
|
||||
const eventCapture = createEventCapture(subgraph.events, [
|
||||
'widget-promoted'
|
||||
])
|
||||
const { node } = createNodeWithWidget(
|
||||
'Test Node',
|
||||
'number',
|
||||
@@ -471,9 +555,13 @@ describe('SubgraphWidgetPromotion', () => {
|
||||
)
|
||||
const subgraphNode = setupPromotedWidget(subgraph, node)
|
||||
|
||||
// The promoted widget should preserve the original tooltip
|
||||
expect(subgraphNode.widgets).toHaveLength(1)
|
||||
expect(subgraphNode.widgets[0].tooltip).toBe(originalTooltip)
|
||||
expect(subgraphNode.widgets).toHaveLength(
|
||||
promotedInputs(subgraphNode).length
|
||||
)
|
||||
expect(
|
||||
eventCapture.getEventsByType('widget-promoted')[0].detail.widget.tooltip
|
||||
).toBe(originalTooltip)
|
||||
eventCapture.cleanup()
|
||||
})
|
||||
|
||||
it('should handle widgets with no tooltip', () => {
|
||||
@@ -481,12 +569,19 @@ describe('SubgraphWidgetPromotion', () => {
|
||||
inputs: [{ name: 'value', type: 'number' }]
|
||||
})
|
||||
|
||||
const eventCapture = createEventCapture(subgraph.events, [
|
||||
'widget-promoted'
|
||||
])
|
||||
const { node } = createNodeWithWidget('Test Node', 'number', 42, 'number')
|
||||
const subgraphNode = setupPromotedWidget(subgraph, node)
|
||||
|
||||
// The promoted widget should have undefined tooltip
|
||||
expect(subgraphNode.widgets).toHaveLength(1)
|
||||
expect(subgraphNode.widgets[0].tooltip).toBeUndefined()
|
||||
expect(subgraphNode.widgets).toHaveLength(
|
||||
promotedInputs(subgraphNode).length
|
||||
)
|
||||
expect(
|
||||
eventCapture.getEventsByType('widget-promoted')[0].detail.widget.tooltip
|
||||
).toBeUndefined()
|
||||
eventCapture.cleanup()
|
||||
})
|
||||
|
||||
it('should preserve tooltips for multiple promoted widgets', () => {
|
||||
@@ -539,13 +634,20 @@ describe('SubgraphWidgetPromotion', () => {
|
||||
multiWidgetNode
|
||||
)
|
||||
|
||||
// Create SubgraphNode
|
||||
const eventCapture = createEventCapture(subgraph.events, [
|
||||
'widget-promoted'
|
||||
])
|
||||
const subgraphNode = createTestSubgraphNode(subgraph)
|
||||
|
||||
// Both widgets should preserve their tooltips
|
||||
expect(subgraphNode.widgets).toHaveLength(2)
|
||||
expect(subgraphNode.widgets[0].tooltip).toBe('Number widget tooltip')
|
||||
expect(subgraphNode.widgets[1].tooltip).toBe('String widget tooltip')
|
||||
expect(subgraphNode.widgets).toHaveLength(
|
||||
promotedInputs(subgraphNode).length
|
||||
)
|
||||
expect(
|
||||
eventCapture
|
||||
.getEventsByType('widget-promoted')
|
||||
.map((event) => event.detail.widget.tooltip)
|
||||
).toEqual(['Number widget tooltip', 'String widget tooltip'])
|
||||
eventCapture.cleanup()
|
||||
})
|
||||
|
||||
it('should preserve original tooltip after promotion', () => {
|
||||
@@ -554,6 +656,9 @@ describe('SubgraphWidgetPromotion', () => {
|
||||
})
|
||||
|
||||
const originalTooltip = 'Original tooltip'
|
||||
const eventCapture = createEventCapture(subgraph.events, [
|
||||
'widget-promoted'
|
||||
])
|
||||
const { node } = createNodeWithWidget(
|
||||
'Test Node',
|
||||
'number',
|
||||
@@ -562,16 +667,16 @@ describe('SubgraphWidgetPromotion', () => {
|
||||
originalTooltip
|
||||
)
|
||||
const subgraphNode = setupPromotedWidget(subgraph, node)
|
||||
const state = promotedWidgetStateByName(subgraphNode, 'value')
|
||||
|
||||
const promotedWidget = subgraphNode.widgets[0]
|
||||
|
||||
// The promoted widget should preserve the original tooltip
|
||||
expect(promotedWidget.tooltip).toBe(originalTooltip)
|
||||
|
||||
// The promoted widget should still function normally
|
||||
expect(promotedWidget.name).toBe('value') // Uses subgraph input name
|
||||
expect(promotedWidget.type).toBe('number')
|
||||
expect(promotedWidget.value).toBe(42)
|
||||
expect(subgraphNode.widgets).toHaveLength(
|
||||
promotedInputs(subgraphNode).length
|
||||
)
|
||||
expect(
|
||||
eventCapture.getEventsByType('widget-promoted')[0].detail.widget.tooltip
|
||||
).toBe(originalTooltip)
|
||||
expect(state).toMatchObject({ name: 'value', type: 'number', value: 42 })
|
||||
eventCapture.cleanup()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -586,15 +691,7 @@ describe('SubgraphWidgetPromotion', () => {
|
||||
subgraph.inputNode.slots[0].connect(interiorNode.inputs[0], interiorNode)
|
||||
|
||||
const hostNode = createTestSubgraphNode(subgraph)
|
||||
const hostWidget = hostNode.widgets[0]
|
||||
expectPromotedWidgetView(hostWidget)
|
||||
useWidgetValueStore().registerWidget(hostNode.rootGraph.id, {
|
||||
nodeId: hostNode.id,
|
||||
name: hostWidget.name,
|
||||
type: hostWidget.type,
|
||||
value: 99,
|
||||
options: {}
|
||||
})
|
||||
writePromotedWidgetValue(hostNode, 0, 99)
|
||||
hostNode.serialize()
|
||||
|
||||
expect(interiorWidget.value).toBe(42)
|
||||
@@ -684,28 +781,11 @@ describe('SubgraphWidgetPromotion', () => {
|
||||
return built
|
||||
}
|
||||
|
||||
function vueEdit(
|
||||
host: ReturnType<typeof createTestSubgraphNode>,
|
||||
index: number,
|
||||
value: EditValue
|
||||
) {
|
||||
const widgets = computeProcessedWidgets({
|
||||
nodeData: extractVueNodeData(host),
|
||||
graphId: host.rootGraph.id,
|
||||
showAdvanced: false,
|
||||
isGraphReady: false,
|
||||
rootGraph: null,
|
||||
ui: { getTooltipConfig: () => ({}), handleNodeRightClick: () => {} }
|
||||
})
|
||||
widgets[index].updateHandler(value)
|
||||
}
|
||||
|
||||
function applyEdit(
|
||||
host: ReturnType<typeof createTestSubgraphNode>,
|
||||
edit: EditSpec
|
||||
) {
|
||||
if (edit.via === 'viewKey') host.widgets[edit.index].value = edit.value
|
||||
else vueEdit(host, edit.index, edit.value)
|
||||
writePromotedWidgetValue(host, edit.index, edit.value)
|
||||
}
|
||||
|
||||
function applyReorder(
|
||||
@@ -808,7 +888,9 @@ describe('SubgraphWidgetPromotion', () => {
|
||||
applyReorder(host, c.reorder)
|
||||
|
||||
if (c.expectedNames) {
|
||||
expect(host.widgets.map((w) => w.name)).toEqual(c.expectedNames)
|
||||
expect(promotedInputs(host).map((input) => input.name)).toEqual(
|
||||
c.expectedNames
|
||||
)
|
||||
}
|
||||
if (c.expectedWidgetsValues !== undefined) {
|
||||
expect(host.serialize().widgets_values).toEqual(
|
||||
@@ -833,13 +915,9 @@ describe('SubgraphWidgetPromotion', () => {
|
||||
controlMarker: boolean
|
||||
seedHostValue: number
|
||||
mutateSourceSeedAfterReorder?: number
|
||||
callAfterQueued?: boolean
|
||||
expect: {
|
||||
promptSeed?: number
|
||||
sourceSeed?: number
|
||||
processedSeedValue?: number
|
||||
hostSeedValue?: number
|
||||
storeSeedValue?: number
|
||||
}
|
||||
}
|
||||
|
||||
@@ -860,25 +938,6 @@ describe('SubgraphWidgetPromotion', () => {
|
||||
controlMarker: false,
|
||||
seedHostValue: 123456,
|
||||
expect: { sourceSeed: 0 }
|
||||
},
|
||||
{
|
||||
name: 'Vue + increment + afterQueued: processed widgets reflect increment',
|
||||
editVia: 'vue',
|
||||
controlMode: 'increment',
|
||||
controlMarker: true,
|
||||
seedHostValue: 123456,
|
||||
callAfterQueued: true,
|
||||
expect: { processedSeedValue: 123457 }
|
||||
},
|
||||
{
|
||||
name: 'ViewKey + increment + afterQueued: host seed increments without source value',
|
||||
editVia: 'viewKey',
|
||||
controlMode: 'increment',
|
||||
controlMarker: true,
|
||||
seedHostValue: 2,
|
||||
mutateSourceSeedAfterReorder: 8,
|
||||
callAfterQueued: true,
|
||||
expect: { hostSeedValue: 3, storeSeedValue: 3 }
|
||||
}
|
||||
]
|
||||
|
||||
@@ -897,26 +956,18 @@ describe('SubgraphWidgetPromotion', () => {
|
||||
host.graph?.add(host)
|
||||
}
|
||||
|
||||
if (c.editVia === 'viewKey') {
|
||||
host.widgets[0].value = 'positive prompt'
|
||||
host.widgets[1].value = 'negative prompt'
|
||||
host.widgets[2].value = c.seedHostValue
|
||||
seed.widget.linkedWidgets = [
|
||||
makeControlWidget(c.controlMode, c.controlMarker) as never
|
||||
]
|
||||
} else {
|
||||
seed.widget.linkedWidgets = [
|
||||
makeControlWidget(c.controlMode, c.controlMarker) as never
|
||||
]
|
||||
vueEdit(host, 2, c.seedHostValue)
|
||||
}
|
||||
writePromotedWidgetValue(host, 0, 'positive prompt')
|
||||
writePromotedWidgetValue(host, 1, 'negative prompt')
|
||||
writePromotedWidgetValue(host, 2, c.seedHostValue)
|
||||
seed.widget.linkedWidgets = [
|
||||
makeControlWidget(c.controlMode, c.controlMarker) as never
|
||||
]
|
||||
|
||||
reorderSubgraphInputsByName(host, ['text_1', 'seed', 'text'])
|
||||
|
||||
if (c.mutateSourceSeedAfterReorder !== undefined) {
|
||||
seed.widget.value = c.mutateSourceSeedAfterReorder
|
||||
}
|
||||
if (c.callAfterQueued) host.widgets[1].afterQueued?.()
|
||||
|
||||
if (c.expect.promptSeed !== undefined) {
|
||||
const { output } = await graphToPrompt(host.rootGraph)
|
||||
@@ -927,53 +978,6 @@ describe('SubgraphWidgetPromotion', () => {
|
||||
if (c.expect.sourceSeed !== undefined) {
|
||||
expect(seed.widget.value).toBe(c.expect.sourceSeed)
|
||||
}
|
||||
if (c.expect.processedSeedValue !== undefined) {
|
||||
const updated = computeProcessedWidgets({
|
||||
nodeData: extractVueNodeData(host),
|
||||
graphId: host.rootGraph.id,
|
||||
showAdvanced: false,
|
||||
isGraphReady: false,
|
||||
rootGraph: null,
|
||||
ui: { getTooltipConfig: () => ({}), handleNodeRightClick: () => {} }
|
||||
})
|
||||
expect(updated[1].value).toBe(c.expect.processedSeedValue)
|
||||
}
|
||||
if (c.expect.hostSeedValue !== undefined) {
|
||||
expect(host.widgets[1].value).toBe(c.expect.hostSeedValue)
|
||||
}
|
||||
if (c.expect.storeSeedValue !== undefined) {
|
||||
expect(
|
||||
useWidgetValueStore()
|
||||
.getNodeWidgets(host.rootGraph.id, host.id)
|
||||
.find((entry) => entry.name === 'seed')?.value
|
||||
).toBe(c.expect.storeSeedValue)
|
||||
}
|
||||
})
|
||||
|
||||
it('afterQueued does not run value-control when the host input is externally linked', () => {
|
||||
const subgraph = createTestSubgraph()
|
||||
const sources = buildSources(
|
||||
subgraph,
|
||||
TEXT_TEXT_SEED.map((s) =>
|
||||
s.title === 'Sampler' ? { ...s, hugeMaxSeed: true } : s
|
||||
)
|
||||
)
|
||||
const seed = sources[2]
|
||||
const host = createTestSubgraphNode(subgraph)
|
||||
|
||||
seed.widget.linkedWidgets = [
|
||||
makeControlWidget('increment', true) as never
|
||||
]
|
||||
host.widgets[2].value = 2
|
||||
reorderSubgraphInputsByName(host, ['text_1', 'seed', 'text'])
|
||||
|
||||
const seedSlot = host.getSlotFromWidget(host.widgets[1])
|
||||
expect(seedSlot).toBeDefined()
|
||||
seedSlot!.link = -1
|
||||
|
||||
host.widgets[1].afterQueued?.()
|
||||
|
||||
expect(host.widgets[1].value).toBe(2)
|
||||
})
|
||||
|
||||
it('serializes promoted values from each host independently', () => {
|
||||
@@ -993,8 +997,8 @@ describe('SubgraphWidgetPromotion', () => {
|
||||
subgraph.rootGraph.add(firstHost)
|
||||
subgraph.rootGraph.add(secondHost)
|
||||
|
||||
firstHost.widgets[0].value = 111
|
||||
secondHost.widgets[0].value = 222
|
||||
writePromotedWidgetValue(firstHost, 0, 111)
|
||||
writePromotedWidgetValue(secondHost, 0, 222)
|
||||
|
||||
expect(firstHost.serialize().widgets_values).toEqual([111])
|
||||
expect(secondHost.serialize().widgets_values).toEqual([222])
|
||||
@@ -1006,16 +1010,17 @@ describe('SubgraphWidgetPromotion', () => {
|
||||
const host = createTestSubgraphNode(subgraph)
|
||||
const widgetStore = useWidgetValueStore()
|
||||
for (const { node, widget } of sources) {
|
||||
widgetStore.registerWidget(host.rootGraph.id, {
|
||||
nodeId: node.id,
|
||||
name: widget.name,
|
||||
type: widget.type,
|
||||
value: `${node.title} value`,
|
||||
options: {}
|
||||
})
|
||||
widgetStore.registerWidget(
|
||||
widgetId(host.rootGraph.id, node.id, widget.name),
|
||||
{
|
||||
type: widget.type,
|
||||
value: `${node.title} value`,
|
||||
options: {}
|
||||
}
|
||||
)
|
||||
}
|
||||
reorderSubgraphInputsByName(host, ['second', 'first'])
|
||||
expect(host.serialize().widgets_values).toBeUndefined()
|
||||
expect(host.serialize().widgets_values).toEqual(['', ''])
|
||||
})
|
||||
|
||||
it('does not acquire a host overlay when a source fallback is saved and reloaded', () => {
|
||||
@@ -1032,15 +1037,16 @@ describe('SubgraphWidgetPromotion', () => {
|
||||
|
||||
const host = createTestSubgraphNode(subgraph, { id: 101 })
|
||||
const widgetStore = useWidgetValueStore()
|
||||
widgetStore.registerWidget(host.rootGraph.id, {
|
||||
nodeId: interiorNode.id,
|
||||
name: interiorWidget.name,
|
||||
type: interiorWidget.type,
|
||||
value: 'source fallback',
|
||||
options: {}
|
||||
})
|
||||
widgetStore.registerWidget(
|
||||
widgetId(host.rootGraph.id, interiorNode.id, interiorWidget.name),
|
||||
{
|
||||
type: interiorWidget.type,
|
||||
value: 'source fallback',
|
||||
options: {}
|
||||
}
|
||||
)
|
||||
const serialized = host.serialize()
|
||||
expect(serialized.widgets_values).toBeUndefined()
|
||||
expect(serialized.widgets_values).toEqual([''])
|
||||
|
||||
widgetStore.clearGraph(host.rootGraph.id)
|
||||
const reloaded = createTestSubgraphNode(subgraph, { id: 101 })
|
||||
@@ -1048,8 +1054,8 @@ describe('SubgraphWidgetPromotion', () => {
|
||||
|
||||
expect(
|
||||
widgetStore.getNodeWidgets(reloaded.rootGraph.id, reloaded.id)
|
||||
).toEqual([])
|
||||
expect(reloaded.serialize().widgets_values).toBeUndefined()
|
||||
).toHaveLength(1)
|
||||
expect(reloaded.serialize().widgets_values).toEqual([''])
|
||||
})
|
||||
|
||||
it('does not hydrate missing widgets_values entries as explicit host overlays', () => {
|
||||
@@ -1057,33 +1063,31 @@ describe('SubgraphWidgetPromotion', () => {
|
||||
buildSources(subgraph, TEXT_PAIR)
|
||||
|
||||
const host = createTestSubgraphNode(subgraph, { id: 101 })
|
||||
host.widgets[1].value = 'second host value'
|
||||
writePromotedWidgetValue(host, 1, 'second host value')
|
||||
const serialized = host.serialize()
|
||||
expect(serialized.widgets_values).toEqual([
|
||||
undefined,
|
||||
'second host value'
|
||||
])
|
||||
expect(serialized.widgets_values).toEqual(['', 'second host value'])
|
||||
|
||||
const widgetStore = useWidgetValueStore()
|
||||
widgetStore.clearGraph(host.rootGraph.id)
|
||||
const reloaded = createTestSubgraphNode(subgraph, { id: 101 })
|
||||
reloaded.configure(serialized)
|
||||
|
||||
const [first, second] = reloaded.widgets
|
||||
expectPromotedWidgetView(first)
|
||||
expectPromotedWidgetView(second)
|
||||
expect(reloaded.widgets).toHaveLength(promotedInputs(reloaded).length)
|
||||
expect(
|
||||
widgetStore.getWidget(reloaded.rootGraph.id, reloaded.id, first.name)
|
||||
).toBeUndefined()
|
||||
widgetStore.getWidget(
|
||||
widgetId(reloaded.rootGraph.id, reloaded.id, 'first')
|
||||
)?.value
|
||||
).toBe('')
|
||||
expect(
|
||||
widgetStore.getWidget(reloaded.rootGraph.id, reloaded.id, second.name)
|
||||
?.value
|
||||
widgetStore.getWidget(
|
||||
widgetId(reloaded.rootGraph.id, reloaded.id, 'second')
|
||||
)?.value
|
||||
).toBe('second host value')
|
||||
expect(
|
||||
widgetStore.getNodeWidgets(reloaded.rootGraph.id, reloaded.id)
|
||||
).toHaveLength(1)
|
||||
).toHaveLength(2)
|
||||
expect(reloaded.serialize().widgets_values).toEqual([
|
||||
undefined,
|
||||
'',
|
||||
'second host value'
|
||||
])
|
||||
})
|
||||
@@ -1369,7 +1373,12 @@ describe('SubgraphWidgetPromotion', () => {
|
||||
reloaded.configure(serialized)
|
||||
|
||||
const byName = new Map(
|
||||
reloaded.inputs.map((input) => [input.name, input._widget?.value])
|
||||
reloaded.inputs.map((input) => [
|
||||
input.name,
|
||||
input.widgetId
|
||||
? useWidgetValueStore().getWidget(input.widgetId)?.value
|
||||
: undefined
|
||||
])
|
||||
)
|
||||
expect(byName.get('unet_name')).toBe('z_image_turbo_bf16.safetensors')
|
||||
expect(byName.get('clip_name')).toBe('qwen_3_4b.safetensors')
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { Bounds } from '@/renderer/core/layout/types'
|
||||
import type { CurveData } from '@/components/curve/types'
|
||||
import type { WidgetEntityId } from '@/world/entityIds'
|
||||
import type { WidgetId } from '@/types/widgetId'
|
||||
|
||||
import type {
|
||||
CanvasColour,
|
||||
@@ -399,7 +399,7 @@ export interface IBaseWidget<
|
||||
|
||||
linkedWidgets?: IBaseWidget[]
|
||||
|
||||
readonly entityId?: WidgetEntityId
|
||||
readonly widgetId?: WidgetId
|
||||
|
||||
name: string
|
||||
options: TOptions
|
||||
|
||||
@@ -7,6 +7,7 @@ import { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type { INumericWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
import { NumberWidget } from '@/lib/litegraph/src/widgets/NumberWidget'
|
||||
import { useWidgetValueStore } from '@/stores/widgetValueStore'
|
||||
import { widgetId } from '@/types/widgetId'
|
||||
|
||||
function createTestWidget(
|
||||
node: LGraphNode,
|
||||
@@ -95,7 +96,7 @@ describe('BaseWidget store integration', () => {
|
||||
widget.disabled = true
|
||||
widget.advanced = true
|
||||
|
||||
const state = store.getWidget(graph.id, 1, 'writeWidget')
|
||||
const state = store.getWidget(widgetId(graph.id, 1, 'writeWidget'))
|
||||
expect(state?.label).toBe('Updated Label')
|
||||
expect(state?.disabled).toBe(true)
|
||||
|
||||
@@ -108,9 +109,11 @@ describe('BaseWidget store integration', () => {
|
||||
widget.setNodeId(1)
|
||||
|
||||
widget.value = 99
|
||||
expect(store.getWidget(graph.id, 1, 'valueWidget')?.value).toBe(99)
|
||||
expect(store.getWidget(widgetId(graph.id, 1, 'valueWidget'))?.value).toBe(
|
||||
99
|
||||
)
|
||||
|
||||
const state = store.getWidget(graph.id, 1, 'valueWidget')!
|
||||
const state = store.getWidget(widgetId(graph.id, 1, 'valueWidget'))!
|
||||
state.value = 55
|
||||
expect(widget.value).toBe(55)
|
||||
})
|
||||
@@ -128,9 +131,9 @@ describe('BaseWidget store integration', () => {
|
||||
})
|
||||
widget.setNodeId(1)
|
||||
|
||||
const state = store.getWidget(graph.id, 1, 'autoRegWidget')
|
||||
const state = store.getWidget(widgetId(graph.id, 1, 'autoRegWidget'))
|
||||
expect(state).toBeDefined()
|
||||
expect(state?.nodeId).toBe(1)
|
||||
expect(state?.nodeId).toBe('1')
|
||||
expect(state?.name).toBe('autoRegWidget')
|
||||
expect(state?.type).toBe('number')
|
||||
expect(state?.value).toBe(100)
|
||||
@@ -146,7 +149,7 @@ describe('BaseWidget store integration', () => {
|
||||
const widget = createTestWidget(node, { name: 'defaultsWidget' })
|
||||
widget.setNodeId(1)
|
||||
|
||||
const state = store.getWidget(graph.id, 1, 'defaultsWidget')
|
||||
const state = store.getWidget(widgetId(graph.id, 1, 'defaultsWidget'))
|
||||
expect(state).toBeDefined()
|
||||
expect(state?.disabled).toBe(false)
|
||||
expect(state?.label).toBeUndefined()
|
||||
@@ -159,7 +162,9 @@ describe('BaseWidget store integration', () => {
|
||||
const widget = createTestWidget(node, { name: 'valuesWidget', value: 77 })
|
||||
widget.setNodeId(1)
|
||||
|
||||
expect(store.getWidget(graph.id, 1, 'valuesWidget')?.value).toBe(77)
|
||||
expect(
|
||||
store.getWidget(widgetId(graph.id, 1, 'valuesWidget'))?.value
|
||||
).toBe(77)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -177,20 +182,26 @@ describe('BaseWidget store integration', () => {
|
||||
get() {
|
||||
const graphId = widget.node.graph?.rootGraph.id
|
||||
if (!graphId) return defaultValue
|
||||
const state = store.getWidget(graphId, node.id, 'system_prompt')
|
||||
const state = store.getWidget(
|
||||
widgetId(graphId, node.id, 'system_prompt')
|
||||
)
|
||||
return (state?.value as string) ?? defaultValue
|
||||
},
|
||||
set(v: string) {
|
||||
const graphId = widget.node.graph?.rootGraph.id
|
||||
if (!graphId) return
|
||||
const state = store.getWidget(graphId, node.id, 'system_prompt')
|
||||
const state = store.getWidget(
|
||||
widgetId(graphId, node.id, 'system_prompt')
|
||||
)
|
||||
if (state) state.value = v
|
||||
}
|
||||
})
|
||||
|
||||
widget.setNodeId(node.id)
|
||||
|
||||
const state = store.getWidget(graph.id, node.id, 'system_prompt')
|
||||
const state = store.getWidget(
|
||||
widgetId(graph.id, node.id, 'system_prompt')
|
||||
)
|
||||
expect(state?.value).toBe(defaultValue)
|
||||
})
|
||||
})
|
||||
@@ -211,7 +222,7 @@ describe('BaseWidget store integration', () => {
|
||||
|
||||
widget.disabled = undefined
|
||||
|
||||
const state = store.getWidget(graph.id, 1, 'testWidget')
|
||||
const state = store.getWidget(widgetId(graph.id, 1, 'testWidget'))
|
||||
expect(state?.disabled).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -10,17 +10,18 @@ import type {
|
||||
LGraphNode,
|
||||
Size
|
||||
} from '@/lib/litegraph/src/litegraph'
|
||||
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import { litegraph } from '@/lib/litegraph/src/litegraphInstance'
|
||||
import type { CanvasPointerEvent } from '@/lib/litegraph/src/types/events'
|
||||
import type {
|
||||
IBaseWidget,
|
||||
NodeBindable,
|
||||
TWidgetType
|
||||
} from '@/lib/litegraph/src/types/widgets'
|
||||
import type { WidgetState } from '@/stores/widgetValueStore'
|
||||
import { syncWidgetControl } from '@/core/graph/widgets/control/syncWidgetControl'
|
||||
import { useWidgetValueStore } from '@/stores/widgetValueStore'
|
||||
import type { WidgetEntityId } from '@/world/entityIds'
|
||||
import { widgetEntityId } from '@/world/entityIds'
|
||||
import type { WidgetId } from '@/types/widgetId'
|
||||
import { widgetId } from '@/types/widgetId'
|
||||
import type { WidgetState } from '@/types/widgetState'
|
||||
|
||||
export interface DrawWidgetOptions {
|
||||
/** The width of the node where this widget will be displayed. */
|
||||
@@ -132,11 +133,11 @@ export abstract class BaseWidget<TWidget extends IBaseWidget = IBaseWidget>
|
||||
this._state.value = value
|
||||
}
|
||||
|
||||
get entityId(): WidgetEntityId | undefined {
|
||||
get widgetId(): WidgetId | undefined {
|
||||
const graphId = this.node.graph?.rootGraph.id
|
||||
const nodeId = this._state.nodeId
|
||||
if (!graphId || nodeId === undefined) return undefined
|
||||
return widgetEntityId(graphId, nodeId, this.name)
|
||||
return widgetId(graphId, nodeId, this.name)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -147,14 +148,12 @@ export abstract class BaseWidget<TWidget extends IBaseWidget = IBaseWidget>
|
||||
const graphId = this.node.graph?.rootGraph.id
|
||||
if (!graphId) return
|
||||
|
||||
this._state = useWidgetValueStore().registerWidget(graphId, {
|
||||
const id = widgetId(graphId, nodeId, this.name)
|
||||
this._state = useWidgetValueStore().registerWidget(id, {
|
||||
...this._state,
|
||||
// BaseWidget: this.value getter returns this._state.value. So value: this.value === value: this._state.value.
|
||||
// BaseDOMWidgetImpl: this.value getter returns options.getValue?.() ?? ''. Resolves the correct initial value instead of undefined.
|
||||
// I.e., calls overriden getter -> options.getValue() -> correct value (https://github.com/Comfy-Org/ComfyUI_frontend/issues/9194).
|
||||
value: this.value,
|
||||
nodeId
|
||||
value: this.value
|
||||
})
|
||||
syncWidgetControl(id, graphId, nodeId, this.linkedWidgets)
|
||||
}
|
||||
|
||||
constructor(widget: TWidget & { node: LGraphNode })
|
||||
@@ -208,14 +207,15 @@ export abstract class BaseWidget<TWidget extends IBaseWidget = IBaseWidget>
|
||||
label,
|
||||
disabled: disabled ?? false,
|
||||
serialize: this.serialize,
|
||||
options: this.options
|
||||
options: this.options,
|
||||
y: this.y
|
||||
}
|
||||
}
|
||||
|
||||
getOutlineColor() {
|
||||
return this.advanced
|
||||
? LiteGraph.WIDGET_ADVANCED_OUTLINE_COLOR
|
||||
: LiteGraph.WIDGET_OUTLINE_COLOR
|
||||
? litegraph().WIDGET_ADVANCED_OUTLINE_COLOR
|
||||
: litegraph().WIDGET_OUTLINE_COLOR
|
||||
}
|
||||
|
||||
get outline_color() {
|
||||
@@ -223,23 +223,23 @@ export abstract class BaseWidget<TWidget extends IBaseWidget = IBaseWidget>
|
||||
}
|
||||
|
||||
get background_color() {
|
||||
return LiteGraph.WIDGET_BGCOLOR
|
||||
return litegraph().WIDGET_BGCOLOR
|
||||
}
|
||||
|
||||
get height() {
|
||||
return LiteGraph.NODE_WIDGET_HEIGHT
|
||||
return litegraph().NODE_WIDGET_HEIGHT
|
||||
}
|
||||
|
||||
get text_color() {
|
||||
return LiteGraph.WIDGET_TEXT_COLOR
|
||||
return litegraph().WIDGET_TEXT_COLOR
|
||||
}
|
||||
|
||||
get secondary_text_color() {
|
||||
return LiteGraph.WIDGET_SECONDARY_TEXT_COLOR
|
||||
return litegraph().WIDGET_SECONDARY_TEXT_COLOR
|
||||
}
|
||||
|
||||
get disabledTextColor() {
|
||||
return LiteGraph.WIDGET_DISABLED_TEXT_COLOR
|
||||
return litegraph().WIDGET_DISABLED_TEXT_COLOR
|
||||
}
|
||||
|
||||
get displayName() {
|
||||
@@ -361,7 +361,7 @@ export abstract class BaseWidget<TWidget extends IBaseWidget = IBaseWidget>
|
||||
if (requiredWidth <= totalWidth) {
|
||||
// Draw label & value normally
|
||||
drawTextInArea({ ctx, text: displayName, area, align: 'left' })
|
||||
} else if (LiteGraph.truncateWidgetTextEvenly) {
|
||||
} else if (litegraph().truncateWidgetTextEvenly) {
|
||||
// Label + value will not fit - scale evenly to fit
|
||||
const scale = (totalWidth - gap) / (requiredWidth - gap)
|
||||
area.width = labelWidth * scale
|
||||
@@ -371,7 +371,7 @@ export abstract class BaseWidget<TWidget extends IBaseWidget = IBaseWidget>
|
||||
// Move the area to the right to render the value
|
||||
area.right = x + totalWidth
|
||||
area.setWidthRightAnchored(valueWidth * scale)
|
||||
} else if (LiteGraph.truncateWidgetValuesFirst) {
|
||||
} else if (litegraph().truncateWidgetValuesFirst) {
|
||||
// Label + value will not fit - use legacy scaling of value first
|
||||
const cappedLabelWidth = Math.min(labelWidth, totalWidth)
|
||||
|
||||
|
||||
@@ -822,6 +822,8 @@
|
||||
"CONDITIONING": "تكييف",
|
||||
"CONTROL_NET": "ControlNet",
|
||||
"CURVE": "منحنى",
|
||||
"DA3_GEOMETRY": "DA3_GEOMETRY",
|
||||
"DA3_MODEL": "DA3_MODEL",
|
||||
"ELEVENLABS_VOICE": "ELEVENLABS_VOICE",
|
||||
"FACE_DETECTION_MODEL": "نموذج كشف الوجه",
|
||||
"FACE_LANDMARKS": "معالم الوجه",
|
||||
@@ -3091,11 +3093,9 @@
|
||||
"collapse": "طي",
|
||||
"expand": "توسيع",
|
||||
"installAll": "تثبيت الكل",
|
||||
"installNodePack": "تثبيت حزمة العقد",
|
||||
"installed": "تم التثبيت",
|
||||
"installing": "جارٍ التثبيت...",
|
||||
"ossManagerDisabledHint": "لتثبيت العقد المفقودة، قم أولاً بتشغيل {pipCmd} في بيئة بايثون الخاصة بك لتثبيت مدير العقد، ثم أعد تشغيل ComfyUI مع العلم {flag}.",
|
||||
"searchInManager": "البحث في مدير العقد",
|
||||
"title": "حزم العقد المفقودة",
|
||||
"unknownPack": "حزمة غير معروفة",
|
||||
"unsupportedTitle": "حزم العقد غير المدعومة",
|
||||
@@ -3673,6 +3673,7 @@
|
||||
"comfyCloudLogo": "شعار Comfy Cloud",
|
||||
"contactOwnerToSubscribe": "يرجى التواصل مع مالك مساحة العمل للاشتراك",
|
||||
"contactUs": "تواصل معنا",
|
||||
"creditSliderSave": "وفر {percent}% ({amount})",
|
||||
"creditsRemainingThisMonth": "الرصيد المتبقي لهذا الشهر",
|
||||
"creditsRemainingThisYear": "الرصيد المتبقي لهذا العام",
|
||||
"creditsYouveAdded": "الرصيد الذي أضفته",
|
||||
|
||||