diff --git a/apps/website/src/composables/useHeroLogo.ts b/apps/website/src/composables/useHeroLogo.ts
new file mode 100644
index 0000000000..3a1e0b847a
--- /dev/null
+++ b/apps/website/src/composables/useHeroLogo.ts
@@ -0,0 +1,328 @@
+import type { Ref } from 'vue'
+import { onMounted, onUnmounted, ref } from 'vue'
+
+import * as THREE from 'three'
+import { SVGLoader } from 'three/addons/loaders/SVGLoader.js'
+
+import { prefersReducedMotion } from './useReducedMotion'
+
+const IMAGE_COUNT = 16
+const BASE_URL = 'https://media.comfy.org/website/homepage/hero-logo-seq'
+
+const SVG_MARKUP = ` `
+
+interface HeroLogoConfig {
+ speed: number
+ tiltX: number
+ tiltZ: number
+ zoom: number
+ fov: number
+ logoColor: string
+ extrudeDepth: number
+ cursorTiltStrength: number
+ bgScale: number
+ slideDuration: number
+}
+
+const DEFAULTS: HeroLogoConfig = {
+ speed: 1,
+ tiltX: -0.1,
+ tiltZ: -0.1,
+ zoom: 7,
+ fov: 50,
+ logoColor: '#F2FF59',
+ extrudeDepth: 200,
+ cursorTiltStrength: 0.5,
+ bgScale: 0.8,
+ slideDuration: 0.4
+}
+
+function buildImageUrls(): string[] {
+ return Array.from({ length: IMAGE_COUNT }, (_, i) => {
+ const index = String(i).padStart(5, '0')
+ return `${BASE_URL}/image_sequence_${index}.webp`
+ })
+}
+
+function parseShapes(): THREE.Shape[] {
+ const loader = new SVGLoader()
+ const svgData = loader.parse(SVG_MARKUP)
+ const shapes: THREE.Shape[] = []
+ svgData.paths.forEach((path) => {
+ shapes.push(...SVGLoader.createShapes(path))
+ })
+ return shapes
+}
+
+function loadTextures(urls: string[]): Promise {
+ return Promise.all(
+ urls.map(
+ (url) =>
+ new Promise((resolve) => {
+ const img = new Image()
+ img.crossOrigin = 'anonymous'
+ img.onload = () => {
+ const tex = new THREE.Texture(img)
+ tex.needsUpdate = true
+ tex.colorSpace = THREE.SRGBColorSpace
+ resolve(tex)
+ }
+ img.onerror = () => resolve(null)
+ img.src = url
+ })
+ )
+ ).then((results) => results.filter((t): t is THREE.Texture => t !== null))
+}
+
+export function useHeroLogo(
+ containerRef: Ref,
+ config: Partial = {}
+) {
+ const cfg = { ...DEFAULTS, ...config }
+ const loaded = ref(false)
+ let cleanup: (() => void) | undefined
+
+ onMounted(async () => {
+ try {
+ const container = containerRef.value
+ if (!container || prefersReducedMotion()) return
+
+ const { width, height } = container.getBoundingClientRect()
+
+ const renderer = new THREE.WebGLRenderer({
+ antialias: true,
+ stencil: true,
+ alpha: true
+ })
+ renderer.setSize(width, height)
+ renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2))
+ renderer.outputColorSpace = THREE.SRGBColorSpace
+ renderer.domElement.style.position = 'absolute'
+ renderer.domElement.style.inset = '0'
+ renderer.domElement.style.width = '100%'
+ renderer.domElement.style.height = '100%'
+ renderer.domElement.style.opacity = '0'
+ renderer.domElement.setAttribute('aria-hidden', 'true')
+ container.appendChild(renderer.domElement)
+
+ let disposed = false
+ const teardowns: Array<() => void> = []
+ cleanup = () => {
+ disposed = true
+ teardowns.forEach((fn) => fn())
+ }
+ teardowns.push(() => {
+ renderer.dispose()
+ renderer.domElement.remove()
+ })
+
+ const scene = new THREE.Scene()
+ const camera = new THREE.PerspectiveCamera(
+ cfg.fov,
+ width / height,
+ 0.1,
+ 1000
+ )
+ camera.position.z = cfg.zoom
+
+ // SVG shape
+ const shapes = parseShapes()
+ const tempGeo = new THREE.ShapeGeometry(shapes)
+ tempGeo.computeBoundingBox()
+ const bb = tempGeo.boundingBox!
+ const cx = (bb.max.x + bb.min.x) / 2
+ const cy = (bb.max.y + bb.min.y) / 2
+ const scaleFactor = 3 / (bb.max.y - bb.min.y)
+ tempGeo.dispose()
+
+ // Image sequence textures — load first frame eagerly, rest lazily
+ const urls = buildImageUrls()
+ const textures = await loadTextures(urls.slice(0, 1))
+ if (disposed) return
+
+ renderer.domElement.style.opacity = '1'
+ loaded.value = true
+
+ loadTextures(urls.slice(1)).then((rest) => {
+ if (!disposed) textures.push(...rest)
+ })
+
+ // Background plane (stencil read)
+ const bgPlaneGeo = new THREE.PlaneGeometry(14, 14)
+ const bgPlaneMat = new THREE.MeshBasicMaterial({
+ transparent: true,
+ opacity: 1,
+ map: textures[0] ?? null,
+ depthTest: false,
+ depthWrite: false,
+ stencilWrite: true,
+ stencilFunc: THREE.EqualStencilFunc,
+ stencilRef: 1,
+ stencilFail: THREE.KeepStencilOp,
+ stencilZFail: THREE.KeepStencilOp,
+ stencilZPass: THREE.KeepStencilOp
+ })
+ const bgPlane = new THREE.Mesh(bgPlaneGeo, bgPlaneMat)
+ bgPlane.renderOrder = 1
+ bgPlane.scale.set(cfg.bgScale, cfg.bgScale, 1)
+ scene.add(bgPlane)
+
+ // Logo group
+ const group = new THREE.Group()
+ scene.add(group)
+
+ const s = scaleFactor
+ const depth = cfg.extrudeDepth
+
+ // Front face
+ const shapeGeo = new THREE.ShapeGeometry(shapes)
+ shapeGeo.translate(-cx, -cy, 0)
+ shapeGeo.scale(s, -s, s)
+ const shapeMat = new THREE.MeshBasicMaterial({
+ color: cfg.logoColor,
+ side: THREE.DoubleSide,
+ depthTest: false,
+ depthWrite: false,
+ transparent: true
+ })
+ const logoMesh = new THREE.Mesh(shapeGeo, shapeMat)
+ logoMesh.renderOrder = 2
+ group.add(logoMesh)
+
+ // Extrusion stencil mask
+ const extrudeGeo = new THREE.ExtrudeGeometry(shapes, {
+ depth,
+ bevelEnabled: false
+ })
+ extrudeGeo.translate(-cx, -cy, -depth)
+ extrudeGeo.scale(s, -s, s)
+ const extrudeMat = new THREE.MeshBasicMaterial({
+ colorWrite: false,
+ depthWrite: true,
+ depthTest: true,
+ stencilWrite: true,
+ stencilRef: 1,
+ stencilFunc: THREE.AlwaysStencilFunc,
+ stencilZPass: THREE.ReplaceStencilOp,
+ stencilFail: THREE.KeepStencilOp,
+ stencilZFail: THREE.KeepStencilOp,
+ side: THREE.DoubleSide
+ })
+ const extrudeMesh = new THREE.Mesh(extrudeGeo, extrudeMat)
+ extrudeMesh.renderOrder = 0
+ group.add(extrudeMesh)
+
+ // Interaction
+ let isDragging = false
+ let previousX = 0
+ let dragVelocity = 0
+ let currentTiltX = 0
+ let currentTiltY = 0
+ let pointerX = 0
+ let pointerY = 0
+ let rotationT = 0
+ let currentSlide = 0
+ let slideTimer = 0
+ let animationId = 0
+
+ function onMouseMove(e: MouseEvent) {
+ pointerX = (e.clientX / window.innerWidth) * 2 - 1
+ pointerY = (e.clientY / window.innerHeight) * 2 - 1
+ }
+
+ function onPointerDown(e: PointerEvent) {
+ isDragging = true
+ dragVelocity = 0
+ previousX = e.clientX
+ }
+
+ function onPointerMove(e: PointerEvent) {
+ if (!isDragging) return
+ dragVelocity = (e.clientX - previousX) * 0.005
+ rotationT += dragVelocity
+ previousX = e.clientX
+ }
+
+ function onPointerUp() {
+ isDragging = false
+ }
+
+ function onResize() {
+ const rect = container!.getBoundingClientRect()
+ camera.aspect = rect.width / rect.height
+ camera.updateProjectionMatrix()
+ renderer.setSize(rect.width, rect.height)
+ }
+
+ window.addEventListener('mousemove', onMouseMove)
+ renderer.domElement.addEventListener('pointerdown', onPointerDown)
+ window.addEventListener('pointermove', onPointerMove)
+ window.addEventListener('pointerup', onPointerUp)
+ window.addEventListener('resize', onResize)
+
+ const clock = new THREE.Clock()
+
+ function animate() {
+ if (disposed) return
+ animationId = requestAnimationFrame(animate)
+ const dt = clock.getDelta()
+
+ if (!isDragging && Math.abs(dragVelocity) > 0.0001) {
+ dragVelocity *= 0.95
+ rotationT += dragVelocity
+ } else if (!isDragging) {
+ dragVelocity = 0
+ }
+
+ rotationT += cfg.speed * dt
+
+ currentTiltX += (pointerY - currentTiltX) * 0.08
+ currentTiltY += (pointerX - currentTiltY) * 0.08
+
+ group.rotation.y = rotationT % (Math.PI * 2)
+ group.rotation.x = cfg.tiltX - currentTiltX * cfg.cursorTiltStrength
+ group.rotation.z = cfg.tiltZ
+
+ if (textures.length > 1) {
+ slideTimer += dt
+ if (slideTimer >= cfg.slideDuration) {
+ slideTimer = 0
+ currentSlide = (currentSlide + 1) % textures.length
+ bgPlaneMat.map = textures[currentSlide]
+ bgPlaneMat.needsUpdate = true
+ }
+ }
+
+ renderer.render(scene, camera)
+ }
+
+ animate()
+
+ teardowns.push(
+ () => cancelAnimationFrame(animationId),
+ () => window.removeEventListener('mousemove', onMouseMove),
+ () =>
+ renderer.domElement.removeEventListener('pointerdown', onPointerDown),
+ () => window.removeEventListener('pointermove', onPointerMove),
+ () => window.removeEventListener('pointerup', onPointerUp),
+ () => window.removeEventListener('resize', onResize),
+ () => bgPlaneGeo.dispose(),
+ () => bgPlaneMat.dispose(),
+ () => shapeGeo.dispose(),
+ () => shapeMat.dispose(),
+ () => extrudeGeo.dispose(),
+ () => extrudeMat.dispose(),
+ () => textures.forEach((tex) => tex.dispose())
+ )
+ } catch (err) {
+ console.error('[useHeroLogo] initialization failed:', err)
+ cleanup?.()
+ }
+ })
+
+ onUnmounted(() => {
+ cleanup?.()
+ })
+
+ return { loaded }
+}
diff --git a/apps/website/src/config/demos.ts b/apps/website/src/config/demos.ts
index 4225e765c2..980a7f1dee 100644
--- a/apps/website/src/config/demos.ts
+++ b/apps/website/src/config/demos.ts
@@ -15,6 +15,14 @@ interface Demo {
readonly transcript?: TranslationKey
readonly publishedDate: string
readonly modifiedDate: string
+ /**
+ * Width / height of the Arcade demo's source recording (e.g. 1.93 for a
+ * landscape screencast). Sizes the embed container to match so rounded
+ * corners hug the content instead of empty letterbox space. Source from
+ * Arcade's `_serializablePublicFlow.aspectRatio` (which is height/width —
+ * invert it). Defaults to 16/9 if omitted.
+ */
+ readonly aspectRatio?: number
}
export const demos: readonly Demo[] = [
@@ -32,7 +40,8 @@ export const demos: readonly Demo[] = [
difficulty: 'beginner',
tags: ['templates', 'image', 'video'],
publishedDate: '2026-04-19',
- modifiedDate: '2026-04-19'
+ modifiedDate: '2026-04-19',
+ aspectRatio: 1.931
},
{
slug: 'workflow-templates',
@@ -48,7 +57,25 @@ export const demos: readonly Demo[] = [
difficulty: 'beginner',
tags: ['getting-started', 'templates', 'workflow'],
publishedDate: '2026-04-19',
- modifiedDate: '2026-04-19'
+ modifiedDate: '2026-04-19',
+ aspectRatio: 1.931
+ },
+ {
+ slug: 'community-workflows',
+ arcadeId: 'mqZh17oWDuWIyhK0xwEV',
+ category: 'demos.category.gettingStarted',
+ title: 'demos.community-workflows.title',
+ description: 'demos.community-workflows.description',
+ transcript: 'demos.community-workflows.transcript',
+ ogImage: '/images/demos/community-workflows-og.png',
+ thumbnail: '/images/demos/community-workflows-thumb.webp',
+ estimatedTime: 'demos.duration.2min',
+ durationIso: 'PT2M',
+ difficulty: 'beginner',
+ tags: ['getting-started', 'community', 'workflow', 'hub'],
+ publishedDate: '2026-05-04',
+ modifiedDate: '2026-05-04',
+ aspectRatio: 1.931
}
]
diff --git a/apps/website/src/i18n/translations.ts b/apps/website/src/i18n/translations.ts
index 23a96e1626..863be9fa60 100644
--- a/apps/website/src/i18n/translations.ts
+++ b/apps/website/src/i18n/translations.ts
@@ -1119,6 +1119,10 @@ const translations = {
en: 'Import your own LoRAs',
'zh-CN': '导入你自己的 LoRA'
},
+ 'pricing.plan.creator.feature2': {
+ en: '3 concurrent API jobs',
+ 'zh-CN': '3 个并发 API 任务'
+ },
'pricing.plan.pro.label': { en: 'PRO', 'zh-CN': '专业版' },
'pricing.plan.pro.summary': {
@@ -1143,6 +1147,10 @@ const translations = {
en: 'Longer workflow runtime (up to 1 hour)',
'zh-CN': '更长工作流运行时长(最长 1 小时)'
},
+ 'pricing.plan.pro.feature2': {
+ en: '5 concurrent API jobs',
+ 'zh-CN': '5 个并发 API 任务'
+ },
'pricing.enterprise.label': { en: 'ENTERPRISE', 'zh-CN': '企业版' },
'pricing.enterprise.heading': {
@@ -1599,7 +1607,7 @@ const translations = {
},
'nav.comfyHub': { en: 'Comfy Hub', 'zh-CN': 'Comfy Hub' },
'nav.gallery': { en: 'Gallery', 'zh-CN': '画廊' },
- 'nav.blogs': { en: 'Blogs', 'zh-CN': '博客' },
+ 'nav.blogs': { en: 'Blog', 'zh-CN': '博客' },
'nav.github': { en: 'GitHub', 'zh-CN': 'GitHub' },
'nav.discord': { en: 'Discord', 'zh-CN': 'Discord' },
'nav.docs': { en: 'Docs', 'zh-CN': '文档' },
@@ -3562,6 +3570,20 @@ const translations = {
'打开模板浏览器 — 点击 ComfyUI 侧栏中的模板图标。浏览分类 — 模板按任务分类:图像生成、视频、放大等。预览模板 — 将鼠标悬停在模板上查看预览。加载并自定义 — 点击加载模板,然后修改参数。 '
},
+ 'demos.community-workflows.title': {
+ en: 'Explore and Use a Community Workflow from the Hub',
+ 'zh-CN': '探索并使用社区工作流'
+ },
+ 'demos.community-workflows.description': {
+ en: 'Discover how to find and get started with popular community workflows for generative AI projects.',
+ 'zh-CN': '了解如何查找并使用流行的社区工作流来构建生成式 AI 项目。'
+ },
+ 'demos.community-workflows.transcript': {
+ en: 'Open the Workflow Hub — From the ComfyUI sidebar, navigate to the community Workflow Hub to browse curated and trending workflows shared by the community.Browse popular workflows — Explore featured projects sorted by popularity, recency, and category to find one that matches your goal.Preview a workflow — Click a workflow card to see example outputs, required models, and a description of what it produces.Open in ComfyUI — Use the "Get Started" action to load the selected community workflow directly onto your canvas.Run and customize — Queue the workflow to generate your first result, then tweak prompts, models, and parameters to make it your own. ',
+ 'zh-CN':
+ '打开工作流中心 — 在 ComfyUI 侧栏中,进入社区工作流中心,浏览社区分享的精选和热门工作流。浏览热门工作流 — 按热度、时间和分类浏览精选项目,找到符合需求的工作流。预览工作流 — 点击工作流卡片,查看示例输出、所需模型和功能描述。在 ComfyUI 中打开 — 使用"开始使用"按钮,将选中的社区工作流直接加载到画布。运行并自定义 — 排队执行工作流以生成首个结果,然后调整提示词、模型和参数。 '
+ },
+
'demos.nav.nextDemo': { en: "What's Next", 'zh-CN': '下一个演示' },
'demos.nav.viewDemo': { en: 'View Demo', 'zh-CN': '查看演示' },
'demos.nav.allDemos': { en: 'All Demos', 'zh-CN': '所有演示' },
diff --git a/apps/website/src/layouts/BaseLayout.astro b/apps/website/src/layouts/BaseLayout.astro
index a996e50f4a..e18cbffe16 100644
--- a/apps/website/src/layouts/BaseLayout.astro
+++ b/apps/website/src/layouts/BaseLayout.astro
@@ -10,6 +10,7 @@ import { fetchGitHubStars, formatStarCount } from '../utils/github'
interface Props {
title: string
description?: string
+ keywords?: string[]
ogImage?: string
noindex?: boolean
}
@@ -17,10 +18,13 @@ interface Props {
const {
title,
description = 'Comfy is the AI creation engine for visual professionals who demand control.',
+ keywords,
ogImage = 'https://media.comfy.org/website/comfy.webp',
noindex = false,
} = Astro.props
+const keywordsContent = keywords && keywords.length > 0 ? keywords.join(', ') : undefined
+
const siteBase = Astro.site ?? 'https://comfy.org'
const canonicalURL = new URL(Astro.url.pathname, siteBase)
const ogImageURL = new URL(ogImage, siteBase)
@@ -62,6 +66,7 @@ const websiteJsonLd = {
+ {keywordsContent && }
{noindex && }
{title}
diff --git a/apps/website/src/pages/cloud/index.astro b/apps/website/src/pages/cloud/index.astro
index bf2268943b..889a98adb8 100644
--- a/apps/website/src/pages/cloud/index.astro
+++ b/apps/website/src/pages/cloud/index.astro
@@ -7,9 +7,14 @@ import AudienceSection from '../../components/product/cloud/AudienceSection.vue'
import PricingSection from '../../components/product/cloud/PricingSection.vue'
import ProductCardsSection from '../../components/product/cloud/ProductCardsSection.vue'
import FAQSection from '../../components/product/cloud/FAQSection.vue'
+import { t } from '../../i18n/translations'
---
-
+
diff --git a/apps/website/src/pages/demos/[slug].astro b/apps/website/src/pages/demos/[slug].astro
index babd1116e9..79f9d7c963 100644
--- a/apps/website/src/pages/demos/[slug].astro
+++ b/apps/website/src/pages/demos/[slug].astro
@@ -121,6 +121,7 @@ const breadcrumbJsonLd = {
diff --git a/apps/website/src/pages/download.astro b/apps/website/src/pages/download.astro
index fba35c5e57..3ff4bb0d99 100644
--- a/apps/website/src/pages/download.astro
+++ b/apps/website/src/pages/download.astro
@@ -7,9 +7,14 @@ import ReasonSection from '../components/product/local/ReasonSection.vue'
import EcoSystemSection from '../components/product/local/EcoSystemSection.vue'
import ProductCardsSection from '../components/product/local/ProductCardsSection.vue'
import FAQSection from '../components/product/local/FAQSection.vue'
+import { t } from '../i18n/translations'
---
-
+
diff --git a/apps/website/src/pages/index.astro b/apps/website/src/pages/index.astro
index 42472b493b..9b1a8906c8 100644
--- a/apps/website/src/pages/index.astro
+++ b/apps/website/src/pages/index.astro
@@ -8,9 +8,14 @@ import UseCaseSection from '../components/home/UseCaseSection.vue'
import CaseStudySpotlightSection from '../components/home/CaseStudySpotlightSection.vue'
import GetStartedSection from '../components/home/GetStartedSection.vue'
import BuildWhatSection from '../components/home/BuildWhatSection.vue'
+import { t } from '../i18n/translations'
---
-
+
diff --git a/apps/website/src/pages/zh-CN/cloud/index.astro b/apps/website/src/pages/zh-CN/cloud/index.astro
index 705babf616..0f3ee8b065 100644
--- a/apps/website/src/pages/zh-CN/cloud/index.astro
+++ b/apps/website/src/pages/zh-CN/cloud/index.astro
@@ -7,9 +7,14 @@ import AudienceSection from '../../../components/product/cloud/AudienceSection.v
import PricingSection from '../../../components/product/cloud/PricingSection.vue'
import ProductCardsSection from '../../../components/product/cloud/ProductCardsSection.vue'
import FAQSection from '../../../components/product/cloud/FAQSection.vue'
+import { t } from '../../../i18n/translations'
---
-
+
diff --git a/apps/website/src/pages/zh-CN/demos/[slug].astro b/apps/website/src/pages/zh-CN/demos/[slug].astro
index a443895333..fb31d3a40a 100644
--- a/apps/website/src/pages/zh-CN/demos/[slug].astro
+++ b/apps/website/src/pages/zh-CN/demos/[slug].astro
@@ -122,6 +122,7 @@ const breadcrumbJsonLd = {
diff --git a/apps/website/src/pages/zh-CN/download.astro b/apps/website/src/pages/zh-CN/download.astro
index 0899ad3e4c..108bf80b5d 100644
--- a/apps/website/src/pages/zh-CN/download.astro
+++ b/apps/website/src/pages/zh-CN/download.astro
@@ -7,9 +7,14 @@ import ReasonSection from '../../components/product/local/ReasonSection.vue'
import EcoSystemSection from '../../components/product/local/EcoSystemSection.vue'
import ProductCardsSection from '../../components/product/local/ProductCardsSection.vue'
import FAQSection from '../../components/product/local/FAQSection.vue'
+import { t } from '../../i18n/translations'
---
-
+
diff --git a/apps/website/src/pages/zh-CN/index.astro b/apps/website/src/pages/zh-CN/index.astro
index 35ba15273d..df9e74f70a 100644
--- a/apps/website/src/pages/zh-CN/index.astro
+++ b/apps/website/src/pages/zh-CN/index.astro
@@ -8,9 +8,14 @@ import UseCaseSection from '../../components/home/UseCaseSection.vue'
import CaseStudySpotlightSection from '../../components/home/CaseStudySpotlightSection.vue'
import GetStartedSection from '../../components/home/GetStartedSection.vue'
import BuildWhatSection from '../../components/home/BuildWhatSection.vue'
+import { t } from '../../i18n/translations'
---
-
+
diff --git a/apps/website/src/styles/global.css b/apps/website/src/styles/global.css
index a6051ff497..c3d934adc5 100644
--- a/apps/website/src/styles/global.css
+++ b/apps/website/src/styles/global.css
@@ -101,13 +101,13 @@
transform: translateX(0);
}
100% {
- transform: translateX(-50%);
+ transform: translateX(calc(-100% - var(--marquee-gap, 0px)));
}
}
@keyframes marquee-reverse {
0% {
- transform: translateX(-50%);
+ transform: translateX(calc(-100% - var(--marquee-gap, 0px)));
}
100% {
transform: translateX(0);
@@ -115,11 +115,15 @@
}
@utility animate-marquee {
- animation: marquee 30s linear infinite;
+ @media (prefers-reduced-motion: no-preference) {
+ animation: marquee 30s linear infinite;
+ }
}
@utility animate-marquee-reverse {
- animation: marquee-reverse 30s linear infinite;
+ @media (prefers-reduced-motion: no-preference) {
+ animation: marquee-reverse 30s linear infinite;
+ }
}
@keyframes ripple-effect {
diff --git a/browser_tests/assets/missing/nested_subgraph_installed_model.json b/browser_tests/assets/missing/nested_subgraph_installed_model.json
new file mode 100644
index 0000000000..e5a4ca39d3
--- /dev/null
+++ b/browser_tests/assets/missing/nested_subgraph_installed_model.json
@@ -0,0 +1,232 @@
+{
+ "id": "14af6003-d4ee-4dee-8e3d-cbff2e5519b3",
+ "revision": 0,
+ "last_node_id": 205,
+ "last_link_id": 383,
+ "nodes": [
+ {
+ "id": 205,
+ "type": "821645cc-a5d2-468f-990c-17d9de2e0d1b",
+ "pos": [4720, 5820],
+ "size": [400, 470],
+ "flags": {},
+ "order": 0,
+ "mode": 0,
+ "inputs": [
+ {
+ "label": "lotus_model",
+ "name": "unet_name_1",
+ "type": "COMBO",
+ "widget": {
+ "name": "unet_name_1"
+ },
+ "link": null
+ }
+ ],
+ "outputs": [
+ {
+ "name": "IMAGE",
+ "type": "IMAGE",
+ "links": null
+ }
+ ],
+ "properties": {
+ "proxyWidgets": [["76", "unet_name"]]
+ },
+ "widgets_values": []
+ }
+ ],
+ "links": [],
+ "groups": [],
+ "definitions": {
+ "subgraphs": [
+ {
+ "id": "821645cc-a5d2-468f-990c-17d9de2e0d1b",
+ "version": 1,
+ "state": {
+ "lastGroupId": 8,
+ "lastNodeId": 205,
+ "lastLinkId": 383,
+ "lastRerouteId": 0
+ },
+ "revision": 0,
+ "config": {},
+ "name": "Depth to Image (Z-Image-Turbo)",
+ "inputNode": {
+ "id": -10,
+ "bounding": [28, 4936, 128, 68]
+ },
+ "outputNode": {
+ "id": -20,
+ "bounding": [1599, 4936, 128, 68]
+ },
+ "inputs": [
+ {
+ "id": "80e6915f-5d59-4d6b-a197-d8c565ad2922",
+ "name": "unet_name_1",
+ "type": "COMBO",
+ "linkIds": [258],
+ "pos": [132, 4960]
+ }
+ ],
+ "outputs": [
+ {
+ "id": "47f9a22d-6619-4917-9447-a7d5d08dceb5",
+ "name": "IMAGE",
+ "type": "IMAGE",
+ "linkIds": [],
+ "pos": [1623, 4960]
+ }
+ ],
+ "widgets": [],
+ "nodes": [
+ {
+ "id": 76,
+ "type": "a1134394-29e4-48dc-9b1e-e601a14d6fb8",
+ "pos": [250, 4910],
+ "size": [400, 210],
+ "flags": {},
+ "order": 0,
+ "mode": 0,
+ "inputs": [
+ {
+ "name": "unet_name",
+ "type": "COMBO",
+ "widget": {
+ "name": "unet_name"
+ },
+ "link": 258
+ }
+ ],
+ "outputs": [
+ {
+ "name": "IMAGE",
+ "type": "IMAGE",
+ "links": []
+ }
+ ],
+ "properties": {
+ "proxyWidgets": [["203", "unet_name"]]
+ },
+ "widgets_values": []
+ }
+ ],
+ "groups": [],
+ "links": [
+ {
+ "id": 258,
+ "origin_id": -10,
+ "origin_slot": 0,
+ "target_id": 76,
+ "target_slot": 0,
+ "type": "COMBO"
+ }
+ ],
+ "extra": {
+ "workflowRendererVersion": "LG",
+ "ds": {
+ "scale": 1,
+ "offset": [-30, -4760]
+ }
+ }
+ },
+ {
+ "id": "a1134394-29e4-48dc-9b1e-e601a14d6fb8",
+ "version": 1,
+ "state": {
+ "lastGroupId": 8,
+ "lastNodeId": 205,
+ "lastLinkId": 383,
+ "lastRerouteId": 0
+ },
+ "revision": 0,
+ "config": {},
+ "name": "Image to Depth Map (Lotus)",
+ "inputNode": {
+ "id": -10,
+ "bounding": [-60, -173, 128, 68]
+ },
+ "outputNode": {
+ "id": -20,
+ "bounding": [1650, -173, 128, 68]
+ },
+ "inputs": [
+ {
+ "id": "d721b249-fd2a-441b-9a78-2805f04e2644",
+ "name": "unet_name",
+ "type": "COMBO",
+ "linkIds": [256],
+ "pos": [44, -149]
+ }
+ ],
+ "outputs": [
+ {
+ "id": "2ec278bd-0b66-4b30-9c5b-994d5f638214",
+ "name": "IMAGE",
+ "type": "IMAGE",
+ "linkIds": [],
+ "pos": [1674, -149]
+ }
+ ],
+ "widgets": [],
+ "nodes": [
+ {
+ "id": 203,
+ "type": "UNETLoader",
+ "pos": [180, -200],
+ "size": [400, 200],
+ "flags": {},
+ "order": 0,
+ "mode": 0,
+ "inputs": [
+ {
+ "name": "unet_name",
+ "type": "COMBO",
+ "widget": {
+ "name": "unet_name"
+ },
+ "link": 256
+ }
+ ],
+ "outputs": [
+ {
+ "name": "MODEL",
+ "type": "MODEL",
+ "links": []
+ }
+ ],
+ "properties": {},
+ "widgets_values": ["lotus-depth-d-v1-1.safetensors", "default"]
+ }
+ ],
+ "groups": [],
+ "links": [
+ {
+ "id": 256,
+ "origin_id": -10,
+ "origin_slot": 0,
+ "target_id": 203,
+ "target_slot": 0,
+ "type": "COMBO"
+ }
+ ],
+ "extra": {
+ "workflowRendererVersion": "LG",
+ "ds": {
+ "scale": 1,
+ "offset": [40, 350]
+ }
+ }
+ }
+ ]
+ },
+ "config": {},
+ "extra": {
+ "workflowRendererVersion": "LG",
+ "ds": {
+ "scale": 1,
+ "offset": [-4500, -5670]
+ }
+ },
+ "version": 0.4
+}
diff --git a/browser_tests/assets/subgraphs/large-subgraph-80-nodes.json b/browser_tests/assets/subgraphs/large-subgraph-80-nodes.json
new file mode 100644
index 0000000000..f0d5d69aff
--- /dev/null
+++ b/browser_tests/assets/subgraphs/large-subgraph-80-nodes.json
@@ -0,0 +1,1449 @@
+{
+ "id": "0d334859-c564-4e64-9f6c-59f454fb80ee",
+ "revision": 0,
+ "last_node_id": 3,
+ "last_link_id": 0,
+ "nodes": [
+ {
+ "id": 1,
+ "type": "8d95b22c-eac2-415c-839a-93cf7cad91fd",
+ "pos": [400, 300],
+ "size": [400, 200],
+ "flags": {},
+ "order": 0,
+ "mode": 0,
+ "inputs": [],
+ "outputs": [],
+ "properties": {},
+ "widgets_values": []
+ },
+ {
+ "id": 2,
+ "type": "Note",
+ "pos": [100, 300],
+ "size": [210, 130],
+ "flags": {},
+ "order": 1,
+ "mode": 0,
+ "inputs": [],
+ "outputs": [],
+ "properties": {
+ "text": ""
+ },
+ "widgets_values": ["Root graph context node 1"]
+ },
+ {
+ "id": 3,
+ "type": "Note",
+ "pos": [100, 500],
+ "size": [210, 130],
+ "flags": {},
+ "order": 2,
+ "mode": 0,
+ "inputs": [],
+ "outputs": [],
+ "properties": {
+ "text": ""
+ },
+ "widgets_values": ["Root graph context node 2"]
+ }
+ ],
+ "links": [],
+ "groups": [],
+ "definitions": {
+ "subgraphs": [
+ {
+ "id": "8d95b22c-eac2-415c-839a-93cf7cad91fd",
+ "version": 1,
+ "state": {
+ "lastGroupId": 0,
+ "lastNodeId": 80,
+ "lastLinkId": 0,
+ "lastRerouteId": 0
+ },
+ "revision": 0,
+ "config": {},
+ "name": "Large Subgraph",
+ "inputNode": {
+ "id": -10,
+ "bounding": [-100, 150, 120, 60]
+ },
+ "outputNode": {
+ "id": -20,
+ "bounding": [2650, 150, 120, 60]
+ },
+ "inputs": [],
+ "outputs": [],
+ "widgets": [],
+ "nodes": [
+ {
+ "id": 1,
+ "type": "Note",
+ "pos": [100, 100],
+ "size": [210, 130],
+ "flags": {},
+ "order": 0,
+ "mode": 0,
+ "inputs": [],
+ "outputs": [],
+ "properties": {
+ "text": ""
+ },
+ "widgets_values": ["Node 1"],
+ "color": "#432",
+ "bgcolor": "#653"
+ },
+ {
+ "id": 2,
+ "type": "Note",
+ "pos": [350, 100],
+ "size": [210, 130],
+ "flags": {},
+ "order": 1,
+ "mode": 0,
+ "inputs": [],
+ "outputs": [],
+ "properties": {
+ "text": ""
+ },
+ "widgets_values": ["Node 2"],
+ "color": "#432",
+ "bgcolor": "#653"
+ },
+ {
+ "id": 3,
+ "type": "Note",
+ "pos": [600, 100],
+ "size": [210, 130],
+ "flags": {},
+ "order": 2,
+ "mode": 0,
+ "inputs": [],
+ "outputs": [],
+ "properties": {
+ "text": ""
+ },
+ "widgets_values": ["Node 3"],
+ "color": "#432",
+ "bgcolor": "#653"
+ },
+ {
+ "id": 4,
+ "type": "Note",
+ "pos": [850, 100],
+ "size": [210, 130],
+ "flags": {},
+ "order": 3,
+ "mode": 0,
+ "inputs": [],
+ "outputs": [],
+ "properties": {
+ "text": ""
+ },
+ "widgets_values": ["Node 4"],
+ "color": "#432",
+ "bgcolor": "#653"
+ },
+ {
+ "id": 5,
+ "type": "Note",
+ "pos": [1100, 100],
+ "size": [210, 130],
+ "flags": {},
+ "order": 4,
+ "mode": 0,
+ "inputs": [],
+ "outputs": [],
+ "properties": {
+ "text": ""
+ },
+ "widgets_values": ["Node 5"],
+ "color": "#432",
+ "bgcolor": "#653"
+ },
+ {
+ "id": 6,
+ "type": "Note",
+ "pos": [1350, 100],
+ "size": [210, 130],
+ "flags": {},
+ "order": 5,
+ "mode": 0,
+ "inputs": [],
+ "outputs": [],
+ "properties": {
+ "text": ""
+ },
+ "widgets_values": ["Node 6"],
+ "color": "#432",
+ "bgcolor": "#653"
+ },
+ {
+ "id": 7,
+ "type": "Note",
+ "pos": [1600, 100],
+ "size": [210, 130],
+ "flags": {},
+ "order": 6,
+ "mode": 0,
+ "inputs": [],
+ "outputs": [],
+ "properties": {
+ "text": ""
+ },
+ "widgets_values": ["Node 7"],
+ "color": "#432",
+ "bgcolor": "#653"
+ },
+ {
+ "id": 8,
+ "type": "Note",
+ "pos": [1850, 100],
+ "size": [210, 130],
+ "flags": {},
+ "order": 7,
+ "mode": 0,
+ "inputs": [],
+ "outputs": [],
+ "properties": {
+ "text": ""
+ },
+ "widgets_values": ["Node 8"],
+ "color": "#432",
+ "bgcolor": "#653"
+ },
+ {
+ "id": 9,
+ "type": "Note",
+ "pos": [2100, 100],
+ "size": [210, 130],
+ "flags": {},
+ "order": 8,
+ "mode": 0,
+ "inputs": [],
+ "outputs": [],
+ "properties": {
+ "text": ""
+ },
+ "widgets_values": ["Node 9"],
+ "color": "#432",
+ "bgcolor": "#653"
+ },
+ {
+ "id": 10,
+ "type": "Note",
+ "pos": [2350, 100],
+ "size": [210, 130],
+ "flags": {},
+ "order": 9,
+ "mode": 0,
+ "inputs": [],
+ "outputs": [],
+ "properties": {
+ "text": ""
+ },
+ "widgets_values": ["Node 10"],
+ "color": "#432",
+ "bgcolor": "#653"
+ },
+ {
+ "id": 11,
+ "type": "Note",
+ "pos": [100, 280],
+ "size": [210, 130],
+ "flags": {},
+ "order": 10,
+ "mode": 0,
+ "inputs": [],
+ "outputs": [],
+ "properties": {
+ "text": ""
+ },
+ "widgets_values": ["Node 11"],
+ "color": "#432",
+ "bgcolor": "#653"
+ },
+ {
+ "id": 12,
+ "type": "Note",
+ "pos": [350, 280],
+ "size": [210, 130],
+ "flags": {},
+ "order": 11,
+ "mode": 0,
+ "inputs": [],
+ "outputs": [],
+ "properties": {
+ "text": ""
+ },
+ "widgets_values": ["Node 12"],
+ "color": "#432",
+ "bgcolor": "#653"
+ },
+ {
+ "id": 13,
+ "type": "Note",
+ "pos": [600, 280],
+ "size": [210, 130],
+ "flags": {},
+ "order": 12,
+ "mode": 0,
+ "inputs": [],
+ "outputs": [],
+ "properties": {
+ "text": ""
+ },
+ "widgets_values": ["Node 13"],
+ "color": "#432",
+ "bgcolor": "#653"
+ },
+ {
+ "id": 14,
+ "type": "Note",
+ "pos": [850, 280],
+ "size": [210, 130],
+ "flags": {},
+ "order": 13,
+ "mode": 0,
+ "inputs": [],
+ "outputs": [],
+ "properties": {
+ "text": ""
+ },
+ "widgets_values": ["Node 14"],
+ "color": "#432",
+ "bgcolor": "#653"
+ },
+ {
+ "id": 15,
+ "type": "Note",
+ "pos": [1100, 280],
+ "size": [210, 130],
+ "flags": {},
+ "order": 14,
+ "mode": 0,
+ "inputs": [],
+ "outputs": [],
+ "properties": {
+ "text": ""
+ },
+ "widgets_values": ["Node 15"],
+ "color": "#432",
+ "bgcolor": "#653"
+ },
+ {
+ "id": 16,
+ "type": "Note",
+ "pos": [1350, 280],
+ "size": [210, 130],
+ "flags": {},
+ "order": 15,
+ "mode": 0,
+ "inputs": [],
+ "outputs": [],
+ "properties": {
+ "text": ""
+ },
+ "widgets_values": ["Node 16"],
+ "color": "#432",
+ "bgcolor": "#653"
+ },
+ {
+ "id": 17,
+ "type": "Note",
+ "pos": [1600, 280],
+ "size": [210, 130],
+ "flags": {},
+ "order": 16,
+ "mode": 0,
+ "inputs": [],
+ "outputs": [],
+ "properties": {
+ "text": ""
+ },
+ "widgets_values": ["Node 17"],
+ "color": "#432",
+ "bgcolor": "#653"
+ },
+ {
+ "id": 18,
+ "type": "Note",
+ "pos": [1850, 280],
+ "size": [210, 130],
+ "flags": {},
+ "order": 17,
+ "mode": 0,
+ "inputs": [],
+ "outputs": [],
+ "properties": {
+ "text": ""
+ },
+ "widgets_values": ["Node 18"],
+ "color": "#432",
+ "bgcolor": "#653"
+ },
+ {
+ "id": 19,
+ "type": "Note",
+ "pos": [2100, 280],
+ "size": [210, 130],
+ "flags": {},
+ "order": 18,
+ "mode": 0,
+ "inputs": [],
+ "outputs": [],
+ "properties": {
+ "text": ""
+ },
+ "widgets_values": ["Node 19"],
+ "color": "#432",
+ "bgcolor": "#653"
+ },
+ {
+ "id": 20,
+ "type": "Note",
+ "pos": [2350, 280],
+ "size": [210, 130],
+ "flags": {},
+ "order": 19,
+ "mode": 0,
+ "inputs": [],
+ "outputs": [],
+ "properties": {
+ "text": ""
+ },
+ "widgets_values": ["Node 20"],
+ "color": "#432",
+ "bgcolor": "#653"
+ },
+ {
+ "id": 21,
+ "type": "Note",
+ "pos": [100, 460],
+ "size": [210, 130],
+ "flags": {},
+ "order": 20,
+ "mode": 0,
+ "inputs": [],
+ "outputs": [],
+ "properties": {
+ "text": ""
+ },
+ "widgets_values": ["Node 21"],
+ "color": "#432",
+ "bgcolor": "#653"
+ },
+ {
+ "id": 22,
+ "type": "Note",
+ "pos": [350, 460],
+ "size": [210, 130],
+ "flags": {},
+ "order": 21,
+ "mode": 0,
+ "inputs": [],
+ "outputs": [],
+ "properties": {
+ "text": ""
+ },
+ "widgets_values": ["Node 22"],
+ "color": "#432",
+ "bgcolor": "#653"
+ },
+ {
+ "id": 23,
+ "type": "Note",
+ "pos": [600, 460],
+ "size": [210, 130],
+ "flags": {},
+ "order": 22,
+ "mode": 0,
+ "inputs": [],
+ "outputs": [],
+ "properties": {
+ "text": ""
+ },
+ "widgets_values": ["Node 23"],
+ "color": "#432",
+ "bgcolor": "#653"
+ },
+ {
+ "id": 24,
+ "type": "Note",
+ "pos": [850, 460],
+ "size": [210, 130],
+ "flags": {},
+ "order": 23,
+ "mode": 0,
+ "inputs": [],
+ "outputs": [],
+ "properties": {
+ "text": ""
+ },
+ "widgets_values": ["Node 24"],
+ "color": "#432",
+ "bgcolor": "#653"
+ },
+ {
+ "id": 25,
+ "type": "Note",
+ "pos": [1100, 460],
+ "size": [210, 130],
+ "flags": {},
+ "order": 24,
+ "mode": 0,
+ "inputs": [],
+ "outputs": [],
+ "properties": {
+ "text": ""
+ },
+ "widgets_values": ["Node 25"],
+ "color": "#432",
+ "bgcolor": "#653"
+ },
+ {
+ "id": 26,
+ "type": "Note",
+ "pos": [1350, 460],
+ "size": [210, 130],
+ "flags": {},
+ "order": 25,
+ "mode": 0,
+ "inputs": [],
+ "outputs": [],
+ "properties": {
+ "text": ""
+ },
+ "widgets_values": ["Node 26"],
+ "color": "#432",
+ "bgcolor": "#653"
+ },
+ {
+ "id": 27,
+ "type": "Note",
+ "pos": [1600, 460],
+ "size": [210, 130],
+ "flags": {},
+ "order": 26,
+ "mode": 0,
+ "inputs": [],
+ "outputs": [],
+ "properties": {
+ "text": ""
+ },
+ "widgets_values": ["Node 27"],
+ "color": "#432",
+ "bgcolor": "#653"
+ },
+ {
+ "id": 28,
+ "type": "Note",
+ "pos": [1850, 460],
+ "size": [210, 130],
+ "flags": {},
+ "order": 27,
+ "mode": 0,
+ "inputs": [],
+ "outputs": [],
+ "properties": {
+ "text": ""
+ },
+ "widgets_values": ["Node 28"],
+ "color": "#432",
+ "bgcolor": "#653"
+ },
+ {
+ "id": 29,
+ "type": "Note",
+ "pos": [2100, 460],
+ "size": [210, 130],
+ "flags": {},
+ "order": 28,
+ "mode": 0,
+ "inputs": [],
+ "outputs": [],
+ "properties": {
+ "text": ""
+ },
+ "widgets_values": ["Node 29"],
+ "color": "#432",
+ "bgcolor": "#653"
+ },
+ {
+ "id": 30,
+ "type": "Note",
+ "pos": [2350, 460],
+ "size": [210, 130],
+ "flags": {},
+ "order": 29,
+ "mode": 0,
+ "inputs": [],
+ "outputs": [],
+ "properties": {
+ "text": ""
+ },
+ "widgets_values": ["Node 30"],
+ "color": "#432",
+ "bgcolor": "#653"
+ },
+ {
+ "id": 31,
+ "type": "Note",
+ "pos": [100, 640],
+ "size": [210, 130],
+ "flags": {},
+ "order": 30,
+ "mode": 0,
+ "inputs": [],
+ "outputs": [],
+ "properties": {
+ "text": ""
+ },
+ "widgets_values": ["Node 31"],
+ "color": "#432",
+ "bgcolor": "#653"
+ },
+ {
+ "id": 32,
+ "type": "Note",
+ "pos": [350, 640],
+ "size": [210, 130],
+ "flags": {},
+ "order": 31,
+ "mode": 0,
+ "inputs": [],
+ "outputs": [],
+ "properties": {
+ "text": ""
+ },
+ "widgets_values": ["Node 32"],
+ "color": "#432",
+ "bgcolor": "#653"
+ },
+ {
+ "id": 33,
+ "type": "Note",
+ "pos": [600, 640],
+ "size": [210, 130],
+ "flags": {},
+ "order": 32,
+ "mode": 0,
+ "inputs": [],
+ "outputs": [],
+ "properties": {
+ "text": ""
+ },
+ "widgets_values": ["Node 33"],
+ "color": "#432",
+ "bgcolor": "#653"
+ },
+ {
+ "id": 34,
+ "type": "Note",
+ "pos": [850, 640],
+ "size": [210, 130],
+ "flags": {},
+ "order": 33,
+ "mode": 0,
+ "inputs": [],
+ "outputs": [],
+ "properties": {
+ "text": ""
+ },
+ "widgets_values": ["Node 34"],
+ "color": "#432",
+ "bgcolor": "#653"
+ },
+ {
+ "id": 35,
+ "type": "Note",
+ "pos": [1100, 640],
+ "size": [210, 130],
+ "flags": {},
+ "order": 34,
+ "mode": 0,
+ "inputs": [],
+ "outputs": [],
+ "properties": {
+ "text": ""
+ },
+ "widgets_values": ["Node 35"],
+ "color": "#432",
+ "bgcolor": "#653"
+ },
+ {
+ "id": 36,
+ "type": "Note",
+ "pos": [1350, 640],
+ "size": [210, 130],
+ "flags": {},
+ "order": 35,
+ "mode": 0,
+ "inputs": [],
+ "outputs": [],
+ "properties": {
+ "text": ""
+ },
+ "widgets_values": ["Node 36"],
+ "color": "#432",
+ "bgcolor": "#653"
+ },
+ {
+ "id": 37,
+ "type": "Note",
+ "pos": [1600, 640],
+ "size": [210, 130],
+ "flags": {},
+ "order": 36,
+ "mode": 0,
+ "inputs": [],
+ "outputs": [],
+ "properties": {
+ "text": ""
+ },
+ "widgets_values": ["Node 37"],
+ "color": "#432",
+ "bgcolor": "#653"
+ },
+ {
+ "id": 38,
+ "type": "Note",
+ "pos": [1850, 640],
+ "size": [210, 130],
+ "flags": {},
+ "order": 37,
+ "mode": 0,
+ "inputs": [],
+ "outputs": [],
+ "properties": {
+ "text": ""
+ },
+ "widgets_values": ["Node 38"],
+ "color": "#432",
+ "bgcolor": "#653"
+ },
+ {
+ "id": 39,
+ "type": "Note",
+ "pos": [2100, 640],
+ "size": [210, 130],
+ "flags": {},
+ "order": 38,
+ "mode": 0,
+ "inputs": [],
+ "outputs": [],
+ "properties": {
+ "text": ""
+ },
+ "widgets_values": ["Node 39"],
+ "color": "#432",
+ "bgcolor": "#653"
+ },
+ {
+ "id": 40,
+ "type": "Note",
+ "pos": [2350, 640],
+ "size": [210, 130],
+ "flags": {},
+ "order": 39,
+ "mode": 0,
+ "inputs": [],
+ "outputs": [],
+ "properties": {
+ "text": ""
+ },
+ "widgets_values": ["Node 40"],
+ "color": "#432",
+ "bgcolor": "#653"
+ },
+ {
+ "id": 41,
+ "type": "Note",
+ "pos": [100, 820],
+ "size": [210, 130],
+ "flags": {},
+ "order": 40,
+ "mode": 0,
+ "inputs": [],
+ "outputs": [],
+ "properties": {
+ "text": ""
+ },
+ "widgets_values": ["Node 41"],
+ "color": "#432",
+ "bgcolor": "#653"
+ },
+ {
+ "id": 42,
+ "type": "Note",
+ "pos": [350, 820],
+ "size": [210, 130],
+ "flags": {},
+ "order": 41,
+ "mode": 0,
+ "inputs": [],
+ "outputs": [],
+ "properties": {
+ "text": ""
+ },
+ "widgets_values": ["Node 42"],
+ "color": "#432",
+ "bgcolor": "#653"
+ },
+ {
+ "id": 43,
+ "type": "Note",
+ "pos": [600, 820],
+ "size": [210, 130],
+ "flags": {},
+ "order": 42,
+ "mode": 0,
+ "inputs": [],
+ "outputs": [],
+ "properties": {
+ "text": ""
+ },
+ "widgets_values": ["Node 43"],
+ "color": "#432",
+ "bgcolor": "#653"
+ },
+ {
+ "id": 44,
+ "type": "Note",
+ "pos": [850, 820],
+ "size": [210, 130],
+ "flags": {},
+ "order": 43,
+ "mode": 0,
+ "inputs": [],
+ "outputs": [],
+ "properties": {
+ "text": ""
+ },
+ "widgets_values": ["Node 44"],
+ "color": "#432",
+ "bgcolor": "#653"
+ },
+ {
+ "id": 45,
+ "type": "Note",
+ "pos": [1100, 820],
+ "size": [210, 130],
+ "flags": {},
+ "order": 44,
+ "mode": 0,
+ "inputs": [],
+ "outputs": [],
+ "properties": {
+ "text": ""
+ },
+ "widgets_values": ["Node 45"],
+ "color": "#432",
+ "bgcolor": "#653"
+ },
+ {
+ "id": 46,
+ "type": "Note",
+ "pos": [1350, 820],
+ "size": [210, 130],
+ "flags": {},
+ "order": 45,
+ "mode": 0,
+ "inputs": [],
+ "outputs": [],
+ "properties": {
+ "text": ""
+ },
+ "widgets_values": ["Node 46"],
+ "color": "#432",
+ "bgcolor": "#653"
+ },
+ {
+ "id": 47,
+ "type": "Note",
+ "pos": [1600, 820],
+ "size": [210, 130],
+ "flags": {},
+ "order": 46,
+ "mode": 0,
+ "inputs": [],
+ "outputs": [],
+ "properties": {
+ "text": ""
+ },
+ "widgets_values": ["Node 47"],
+ "color": "#432",
+ "bgcolor": "#653"
+ },
+ {
+ "id": 48,
+ "type": "Note",
+ "pos": [1850, 820],
+ "size": [210, 130],
+ "flags": {},
+ "order": 47,
+ "mode": 0,
+ "inputs": [],
+ "outputs": [],
+ "properties": {
+ "text": ""
+ },
+ "widgets_values": ["Node 48"],
+ "color": "#432",
+ "bgcolor": "#653"
+ },
+ {
+ "id": 49,
+ "type": "Note",
+ "pos": [2100, 820],
+ "size": [210, 130],
+ "flags": {},
+ "order": 48,
+ "mode": 0,
+ "inputs": [],
+ "outputs": [],
+ "properties": {
+ "text": ""
+ },
+ "widgets_values": ["Node 49"],
+ "color": "#432",
+ "bgcolor": "#653"
+ },
+ {
+ "id": 50,
+ "type": "Note",
+ "pos": [2350, 820],
+ "size": [210, 130],
+ "flags": {},
+ "order": 49,
+ "mode": 0,
+ "inputs": [],
+ "outputs": [],
+ "properties": {
+ "text": ""
+ },
+ "widgets_values": ["Node 50"],
+ "color": "#432",
+ "bgcolor": "#653"
+ },
+ {
+ "id": 51,
+ "type": "Note",
+ "pos": [100, 1000],
+ "size": [210, 130],
+ "flags": {},
+ "order": 50,
+ "mode": 0,
+ "inputs": [],
+ "outputs": [],
+ "properties": {
+ "text": ""
+ },
+ "widgets_values": ["Node 51"],
+ "color": "#432",
+ "bgcolor": "#653"
+ },
+ {
+ "id": 52,
+ "type": "Note",
+ "pos": [350, 1000],
+ "size": [210, 130],
+ "flags": {},
+ "order": 51,
+ "mode": 0,
+ "inputs": [],
+ "outputs": [],
+ "properties": {
+ "text": ""
+ },
+ "widgets_values": ["Node 52"],
+ "color": "#432",
+ "bgcolor": "#653"
+ },
+ {
+ "id": 53,
+ "type": "Note",
+ "pos": [600, 1000],
+ "size": [210, 130],
+ "flags": {},
+ "order": 52,
+ "mode": 0,
+ "inputs": [],
+ "outputs": [],
+ "properties": {
+ "text": ""
+ },
+ "widgets_values": ["Node 53"],
+ "color": "#432",
+ "bgcolor": "#653"
+ },
+ {
+ "id": 54,
+ "type": "Note",
+ "pos": [850, 1000],
+ "size": [210, 130],
+ "flags": {},
+ "order": 53,
+ "mode": 0,
+ "inputs": [],
+ "outputs": [],
+ "properties": {
+ "text": ""
+ },
+ "widgets_values": ["Node 54"],
+ "color": "#432",
+ "bgcolor": "#653"
+ },
+ {
+ "id": 55,
+ "type": "Note",
+ "pos": [1100, 1000],
+ "size": [210, 130],
+ "flags": {},
+ "order": 54,
+ "mode": 0,
+ "inputs": [],
+ "outputs": [],
+ "properties": {
+ "text": ""
+ },
+ "widgets_values": ["Node 55"],
+ "color": "#432",
+ "bgcolor": "#653"
+ },
+ {
+ "id": 56,
+ "type": "Note",
+ "pos": [1350, 1000],
+ "size": [210, 130],
+ "flags": {},
+ "order": 55,
+ "mode": 0,
+ "inputs": [],
+ "outputs": [],
+ "properties": {
+ "text": ""
+ },
+ "widgets_values": ["Node 56"],
+ "color": "#432",
+ "bgcolor": "#653"
+ },
+ {
+ "id": 57,
+ "type": "Note",
+ "pos": [1600, 1000],
+ "size": [210, 130],
+ "flags": {},
+ "order": 56,
+ "mode": 0,
+ "inputs": [],
+ "outputs": [],
+ "properties": {
+ "text": ""
+ },
+ "widgets_values": ["Node 57"],
+ "color": "#432",
+ "bgcolor": "#653"
+ },
+ {
+ "id": 58,
+ "type": "Note",
+ "pos": [1850, 1000],
+ "size": [210, 130],
+ "flags": {},
+ "order": 57,
+ "mode": 0,
+ "inputs": [],
+ "outputs": [],
+ "properties": {
+ "text": ""
+ },
+ "widgets_values": ["Node 58"],
+ "color": "#432",
+ "bgcolor": "#653"
+ },
+ {
+ "id": 59,
+ "type": "Note",
+ "pos": [2100, 1000],
+ "size": [210, 130],
+ "flags": {},
+ "order": 58,
+ "mode": 0,
+ "inputs": [],
+ "outputs": [],
+ "properties": {
+ "text": ""
+ },
+ "widgets_values": ["Node 59"],
+ "color": "#432",
+ "bgcolor": "#653"
+ },
+ {
+ "id": 60,
+ "type": "Note",
+ "pos": [2350, 1000],
+ "size": [210, 130],
+ "flags": {},
+ "order": 59,
+ "mode": 0,
+ "inputs": [],
+ "outputs": [],
+ "properties": {
+ "text": ""
+ },
+ "widgets_values": ["Node 60"],
+ "color": "#432",
+ "bgcolor": "#653"
+ },
+ {
+ "id": 61,
+ "type": "Note",
+ "pos": [100, 1180],
+ "size": [210, 130],
+ "flags": {},
+ "order": 60,
+ "mode": 0,
+ "inputs": [],
+ "outputs": [],
+ "properties": {
+ "text": ""
+ },
+ "widgets_values": ["Node 61"],
+ "color": "#432",
+ "bgcolor": "#653"
+ },
+ {
+ "id": 62,
+ "type": "Note",
+ "pos": [350, 1180],
+ "size": [210, 130],
+ "flags": {},
+ "order": 61,
+ "mode": 0,
+ "inputs": [],
+ "outputs": [],
+ "properties": {
+ "text": ""
+ },
+ "widgets_values": ["Node 62"],
+ "color": "#432",
+ "bgcolor": "#653"
+ },
+ {
+ "id": 63,
+ "type": "Note",
+ "pos": [600, 1180],
+ "size": [210, 130],
+ "flags": {},
+ "order": 62,
+ "mode": 0,
+ "inputs": [],
+ "outputs": [],
+ "properties": {
+ "text": ""
+ },
+ "widgets_values": ["Node 63"],
+ "color": "#432",
+ "bgcolor": "#653"
+ },
+ {
+ "id": 64,
+ "type": "Note",
+ "pos": [850, 1180],
+ "size": [210, 130],
+ "flags": {},
+ "order": 63,
+ "mode": 0,
+ "inputs": [],
+ "outputs": [],
+ "properties": {
+ "text": ""
+ },
+ "widgets_values": ["Node 64"],
+ "color": "#432",
+ "bgcolor": "#653"
+ },
+ {
+ "id": 65,
+ "type": "Note",
+ "pos": [1100, 1180],
+ "size": [210, 130],
+ "flags": {},
+ "order": 64,
+ "mode": 0,
+ "inputs": [],
+ "outputs": [],
+ "properties": {
+ "text": ""
+ },
+ "widgets_values": ["Node 65"],
+ "color": "#432",
+ "bgcolor": "#653"
+ },
+ {
+ "id": 66,
+ "type": "Note",
+ "pos": [1350, 1180],
+ "size": [210, 130],
+ "flags": {},
+ "order": 65,
+ "mode": 0,
+ "inputs": [],
+ "outputs": [],
+ "properties": {
+ "text": ""
+ },
+ "widgets_values": ["Node 66"],
+ "color": "#432",
+ "bgcolor": "#653"
+ },
+ {
+ "id": 67,
+ "type": "Note",
+ "pos": [1600, 1180],
+ "size": [210, 130],
+ "flags": {},
+ "order": 66,
+ "mode": 0,
+ "inputs": [],
+ "outputs": [],
+ "properties": {
+ "text": ""
+ },
+ "widgets_values": ["Node 67"],
+ "color": "#432",
+ "bgcolor": "#653"
+ },
+ {
+ "id": 68,
+ "type": "Note",
+ "pos": [1850, 1180],
+ "size": [210, 130],
+ "flags": {},
+ "order": 67,
+ "mode": 0,
+ "inputs": [],
+ "outputs": [],
+ "properties": {
+ "text": ""
+ },
+ "widgets_values": ["Node 68"],
+ "color": "#432",
+ "bgcolor": "#653"
+ },
+ {
+ "id": 69,
+ "type": "Note",
+ "pos": [2100, 1180],
+ "size": [210, 130],
+ "flags": {},
+ "order": 68,
+ "mode": 0,
+ "inputs": [],
+ "outputs": [],
+ "properties": {
+ "text": ""
+ },
+ "widgets_values": ["Node 69"],
+ "color": "#432",
+ "bgcolor": "#653"
+ },
+ {
+ "id": 70,
+ "type": "Note",
+ "pos": [2350, 1180],
+ "size": [210, 130],
+ "flags": {},
+ "order": 69,
+ "mode": 0,
+ "inputs": [],
+ "outputs": [],
+ "properties": {
+ "text": ""
+ },
+ "widgets_values": ["Node 70"],
+ "color": "#432",
+ "bgcolor": "#653"
+ },
+ {
+ "id": 71,
+ "type": "Note",
+ "pos": [100, 1360],
+ "size": [210, 130],
+ "flags": {},
+ "order": 70,
+ "mode": 0,
+ "inputs": [],
+ "outputs": [],
+ "properties": {
+ "text": ""
+ },
+ "widgets_values": ["Node 71"],
+ "color": "#432",
+ "bgcolor": "#653"
+ },
+ {
+ "id": 72,
+ "type": "Note",
+ "pos": [350, 1360],
+ "size": [210, 130],
+ "flags": {},
+ "order": 71,
+ "mode": 0,
+ "inputs": [],
+ "outputs": [],
+ "properties": {
+ "text": ""
+ },
+ "widgets_values": ["Node 72"],
+ "color": "#432",
+ "bgcolor": "#653"
+ },
+ {
+ "id": 73,
+ "type": "Note",
+ "pos": [600, 1360],
+ "size": [210, 130],
+ "flags": {},
+ "order": 72,
+ "mode": 0,
+ "inputs": [],
+ "outputs": [],
+ "properties": {
+ "text": ""
+ },
+ "widgets_values": ["Node 73"],
+ "color": "#432",
+ "bgcolor": "#653"
+ },
+ {
+ "id": 74,
+ "type": "Note",
+ "pos": [850, 1360],
+ "size": [210, 130],
+ "flags": {},
+ "order": 73,
+ "mode": 0,
+ "inputs": [],
+ "outputs": [],
+ "properties": {
+ "text": ""
+ },
+ "widgets_values": ["Node 74"],
+ "color": "#432",
+ "bgcolor": "#653"
+ },
+ {
+ "id": 75,
+ "type": "Note",
+ "pos": [1100, 1360],
+ "size": [210, 130],
+ "flags": {},
+ "order": 74,
+ "mode": 0,
+ "inputs": [],
+ "outputs": [],
+ "properties": {
+ "text": ""
+ },
+ "widgets_values": ["Node 75"],
+ "color": "#432",
+ "bgcolor": "#653"
+ },
+ {
+ "id": 76,
+ "type": "Note",
+ "pos": [1350, 1360],
+ "size": [210, 130],
+ "flags": {},
+ "order": 75,
+ "mode": 0,
+ "inputs": [],
+ "outputs": [],
+ "properties": {
+ "text": ""
+ },
+ "widgets_values": ["Node 76"],
+ "color": "#432",
+ "bgcolor": "#653"
+ },
+ {
+ "id": 77,
+ "type": "Note",
+ "pos": [1600, 1360],
+ "size": [210, 130],
+ "flags": {},
+ "order": 76,
+ "mode": 0,
+ "inputs": [],
+ "outputs": [],
+ "properties": {
+ "text": ""
+ },
+ "widgets_values": ["Node 77"],
+ "color": "#432",
+ "bgcolor": "#653"
+ },
+ {
+ "id": 78,
+ "type": "Note",
+ "pos": [1850, 1360],
+ "size": [210, 130],
+ "flags": {},
+ "order": 77,
+ "mode": 0,
+ "inputs": [],
+ "outputs": [],
+ "properties": {
+ "text": ""
+ },
+ "widgets_values": ["Node 78"],
+ "color": "#432",
+ "bgcolor": "#653"
+ },
+ {
+ "id": 79,
+ "type": "Note",
+ "pos": [2100, 1360],
+ "size": [210, 130],
+ "flags": {},
+ "order": 78,
+ "mode": 0,
+ "inputs": [],
+ "outputs": [],
+ "properties": {
+ "text": ""
+ },
+ "widgets_values": ["Node 79"],
+ "color": "#432",
+ "bgcolor": "#653"
+ },
+ {
+ "id": 80,
+ "type": "Note",
+ "pos": [2350, 1360],
+ "size": [210, 130],
+ "flags": {},
+ "order": 79,
+ "mode": 0,
+ "inputs": [],
+ "outputs": [],
+ "properties": {
+ "text": ""
+ },
+ "widgets_values": ["Node 80"],
+ "color": "#432",
+ "bgcolor": "#653"
+ }
+ ],
+ "links": [],
+ "groups": [],
+ "reroutes": []
+ }
+ ]
+ },
+ "config": {},
+ "extra": {},
+ "version": 0.4
+}
diff --git a/browser_tests/assets/subgraphs/subgraph-with-link-and-proxied-primitive.json b/browser_tests/assets/subgraphs/subgraph-with-link-and-proxied-primitive.json
new file mode 100644
index 0000000000..433d1b7458
--- /dev/null
+++ b/browser_tests/assets/subgraphs/subgraph-with-link-and-proxied-primitive.json
@@ -0,0 +1,179 @@
+{
+ "id": "5c4a1450-26b8-4b34-b5ea-e3465273441e",
+ "revision": 0,
+ "last_node_id": 4,
+ "last_link_id": 2,
+ "nodes": [
+ {
+ "id": 2,
+ "type": "16aadaf6-aa66-4041-843e-589a6572a3ac",
+ "pos": [602, 409],
+ "size": [225, 144],
+ "flags": {},
+ "order": 0,
+ "mode": 0,
+ "inputs": [],
+ "outputs": [],
+ "properties": {
+ "proxyWidgets": [
+ ["1", "value"],
+ ["4", "value"]
+ ]
+ },
+ "widgets_values": []
+ }
+ ],
+ "links": [],
+ "groups": [],
+ "definitions": {
+ "subgraphs": [
+ {
+ "id": "16aadaf6-aa66-4041-843e-589a6572a3ac",
+ "version": 1,
+ "state": {
+ "lastGroupId": 0,
+ "lastNodeId": 4,
+ "lastLinkId": 2,
+ "lastRerouteId": 0
+ },
+ "revision": 0,
+ "config": {},
+ "name": "New Subgraph",
+ "inputNode": {
+ "id": -10,
+ "bounding": [349, 383, 128, 68]
+ },
+ "outputNode": {
+ "id": -20,
+ "bounding": [867, 383, 128, 48]
+ },
+ "inputs": [
+ {
+ "id": "50fd1af4-4f20-434f-9828-6971210be4e9",
+ "name": "value",
+ "type": "STRING",
+ "linkIds": [1],
+ "pos": [453, 407]
+ }
+ ],
+ "outputs": [],
+ "widgets": [],
+ "nodes": [
+ {
+ "id": 1,
+ "type": "PrimitiveString",
+ "pos": [537, 368],
+ "size": [270, 108],
+ "flags": {},
+ "order": 2,
+ "mode": 0,
+ "inputs": [
+ {
+ "localized_name": "value",
+ "name": "value",
+ "type": "STRING",
+ "widget": {
+ "name": "value"
+ },
+ "link": 1
+ }
+ ],
+ "outputs": [
+ {
+ "localized_name": "STRING",
+ "name": "STRING",
+ "type": "STRING",
+ "links": null
+ }
+ ],
+ "properties": {
+ "Node name for S&R": "PrimitiveString"
+ },
+ "widgets_values": [""]
+ },
+ {
+ "id": 3,
+ "type": "PrimitiveInt",
+ "pos": [534.9899497487436, 515.4924623115581],
+ "size": [270, 104],
+ "flags": {},
+ "order": 0,
+ "mode": 0,
+ "inputs": [
+ {
+ "localized_name": "value",
+ "name": "value",
+ "type": "INT",
+ "widget": {
+ "name": "value"
+ },
+ "link": 2
+ }
+ ],
+ "outputs": [
+ {
+ "localized_name": "INT",
+ "name": "INT",
+ "type": "INT",
+ "links": null
+ }
+ ],
+ "properties": {
+ "Node name for S&R": "PrimitiveInt"
+ },
+ "widgets_values": [0, "randomize"]
+ },
+ {
+ "id": 4,
+ "type": "PrimitiveNode",
+ "pos": [258.4381232333541, 549.1608040200999],
+ "size": [225, 104],
+ "flags": {},
+ "order": 1,
+ "mode": 0,
+ "inputs": [],
+ "outputs": [
+ {
+ "name": "INT",
+ "type": "INT",
+ "widget": {
+ "name": "value"
+ },
+ "links": [2]
+ }
+ ],
+ "properties": {
+ "Run widget replace on values": false
+ },
+ "widgets_values": [0, "randomize"]
+ }
+ ],
+ "groups": [],
+ "links": [
+ {
+ "id": 1,
+ "origin_id": -10,
+ "origin_slot": 0,
+ "target_id": 1,
+ "target_slot": 0,
+ "type": "STRING"
+ },
+ {
+ "id": 2,
+ "origin_id": 4,
+ "origin_slot": 0,
+ "target_id": 3,
+ "target_slot": 0,
+ "type": "INT"
+ }
+ ],
+ "extra": {}
+ }
+ ]
+ },
+ "config": {},
+ "extra": {
+ "frontendVersion": "1.44.17"
+ },
+ "version": 0.4
+}
diff --git a/browser_tests/assets/widgets/load_image_widget_missing_file.json b/browser_tests/assets/widgets/load_image_widget_missing_file.json
new file mode 100644
index 0000000000..4e101b8d44
--- /dev/null
+++ b/browser_tests/assets/widgets/load_image_widget_missing_file.json
@@ -0,0 +1,42 @@
+{
+ "last_node_id": 10,
+ "last_link_id": 10,
+ "nodes": [
+ {
+ "id": 10,
+ "type": "LoadImage",
+ "pos": [50, 50],
+ "size": [315, 314],
+ "flags": {},
+ "order": 0,
+ "mode": 0,
+ "inputs": [],
+ "outputs": [
+ {
+ "name": "IMAGE",
+ "type": "IMAGE",
+ "links": null
+ },
+ {
+ "name": "MASK",
+ "type": "MASK",
+ "links": null
+ }
+ ],
+ "properties": {
+ "Node name for S&R": "LoadImage"
+ },
+ "widgets_values": ["this-image-does-not-exist-deadbeef.png", "image"]
+ }
+ ],
+ "links": [],
+ "groups": [],
+ "config": {},
+ "extra": {
+ "ds": {
+ "offset": [0, 0],
+ "scale": 1
+ }
+ },
+ "version": 0.4
+}
diff --git a/browser_tests/fixtures/ComfyPage.ts b/browser_tests/fixtures/ComfyPage.ts
index 4d06834f06..f73f3212a0 100644
--- a/browser_tests/fixtures/ComfyPage.ts
+++ b/browser_tests/fixtures/ComfyPage.ts
@@ -190,6 +190,9 @@ export class ComfyPage {
/** Worker index to test user ID */
public readonly userIds: string[] = []
+ /** Whether the current test runs in Vue Nodes mode (initialized from `@vue-nodes` tag). */
+ public isVueNodes = false
+
/** Test user ID for the current context */
get id() {
return this.userIds[comfyPageFixture.info().parallelIndex]
@@ -352,6 +355,12 @@ export class ComfyPage {
await nextFrame(this.page)
}
+ async idleFrames(count: number) {
+ for (let i = 0; i < count; i++) {
+ await this.nextFrame()
+ }
+ }
+
async delay(ms: number) {
return sleep(ms)
}
@@ -494,6 +503,7 @@ export const comfyPageFixture = base.extend<{
comfyPage.userIds[parallelIndex] = userId
const isVueNodes = testInfo.tags.includes('@vue-nodes')
+ comfyPage.isVueNodes = isVueNodes
try {
await comfyPage.setupSettings({
diff --git a/browser_tests/fixtures/VueNodeHelpers.ts b/browser_tests/fixtures/VueNodeHelpers.ts
index e7bdab272f..679dd52a5e 100644
--- a/browser_tests/fixtures/VueNodeHelpers.ts
+++ b/browser_tests/fixtures/VueNodeHelpers.ts
@@ -5,6 +5,7 @@ import type { Locator, Page } from '@playwright/test'
import { TestIds } from '@e2e/fixtures/selectors'
import { VueNodeFixture } from '@e2e/fixtures/utils/vueNodeFixtures'
+import { getSlotKey } from '@/renderer/core/layout/slots/slotIdentifier'
export class VueNodeHelpers {
/**
@@ -37,6 +38,22 @@ export class VueNodeHelpers {
return this.getNodeLocator(nodeId).getByTestId(TestIds.node.innerWrapper)
}
+ getInputSlotRow(nodeId: string, slotIndex: number): Locator {
+ return this.getNodeLocator(nodeId)
+ .locator('.lg-slot--input')
+ .filter({
+ has: this.page.locator(
+ `[data-slot-key="${getSlotKey(nodeId, slotIndex, true)}"]`
+ )
+ })
+ }
+
+ getInputSlotConnectionDot(nodeId: string, slotIndex: number): Locator {
+ return this.getInputSlotRow(nodeId, slotIndex).getByTestId(
+ TestIds.node.slotConnectionDot
+ )
+ }
+
/**
* Get locator for Vue nodes by the node's title (displayed name in the header).
* Matches against the actual title element, not the full node body.
@@ -200,13 +217,20 @@ export class VueNodeHelpers {
}
}
+ /**
+ * Locator for the Enter Subgraph footer button.
+ */
+ getSubgraphEnterButton(nodeId?: string): Locator {
+ const root = nodeId ? this.getNodeLocator(nodeId) : this.page
+ return root.getByTestId(TestIds.widgets.subgraphEnterButton).first()
+ }
+
/**
* Enter the subgraph of a node.
* @param nodeId - The ID of the node to enter the subgraph of. If not provided, the first matched subgraph will be entered.
*/
async enterSubgraph(nodeId?: string): Promise {
- const locator = nodeId ? this.getNodeLocator(nodeId) : this.page
- const editButton = locator.getByTestId(TestIds.widgets.subgraphEnterButton)
+ const editButton = this.getSubgraphEnterButton(nodeId)
// The footer tab button extends below the node body (visible area),
// but its bounding box center overlaps the node body div.
diff --git a/browser_tests/fixtures/assetApiFixture.ts b/browser_tests/fixtures/assetApiFixture.ts
index 8cc6c06536..2c157b1fc2 100644
--- a/browser_tests/fixtures/assetApiFixture.ts
+++ b/browser_tests/fixtures/assetApiFixture.ts
@@ -1,8 +1,34 @@
import { test as base } from '@playwright/test'
+import type { Page, Route } from '@playwright/test'
+import type { Asset, ListAssetsResponse } from '@comfyorg/ingest-types'
+import { comfyPageFixture } from '@e2e/fixtures/ComfyPage'
import type { AssetHelper } from '@e2e/fixtures/helpers/AssetHelper'
import { createAssetHelper } from '@e2e/fixtures/helpers/AssetHelper'
+const ASSETS_ROUTE_PATTERN = /\/api\/assets(?:\?.*)?$/
+const cloudAssetRequestsByPage = new WeakMap()
+
+function makeAssetsResponse(assets: ReadonlyArray): ListAssetsResponse {
+ return { assets: [...assets], total: assets.length, has_more: false }
+}
+
+export function assetRequestIncludesTag(url: string, tag: string): boolean {
+ const includeTags = new URL(url).searchParams.get('include_tags') ?? ''
+ return includeTags
+ .split(',')
+ .map((value) => value.trim())
+ .filter(Boolean)
+ .includes(tag)
+}
+
+export function countAssetRequestsByTag(
+ requests: string[],
+ tag: string
+): number {
+ return requests.filter((url) => assetRequestIncludesTag(url, tag)).length
+}
+
export const assetApiFixture = base.extend<{
assetApi: AssetHelper
}>({
@@ -14,3 +40,31 @@ export const assetApiFixture = base.extend<{
await assetApi.clearMocks()
}
})
+
+export function createCloudAssetsFixture(assets: ReadonlyArray) {
+ return comfyPageFixture.extend<{
+ cloudAssetRequests: string[]
+ }>({
+ page: async ({ page }, use) => {
+ const cloudAssetRequests: string[] = []
+ cloudAssetRequestsByPage.set(page, cloudAssetRequests)
+
+ async function assetsRouteHandler(route: Route) {
+ cloudAssetRequests.push(route.request().url())
+ await route.fulfill({
+ status: 200,
+ contentType: 'application/json',
+ body: JSON.stringify(makeAssetsResponse(assets))
+ })
+ }
+
+ await page.route(ASSETS_ROUTE_PATTERN, assetsRouteHandler)
+ await use(page)
+ await page.unroute(ASSETS_ROUTE_PATTERN, assetsRouteHandler)
+ cloudAssetRequestsByPage.delete(page)
+ },
+ cloudAssetRequests: async ({ page }, use) => {
+ await use(cloudAssetRequestsByPage.get(page) ?? [])
+ }
+ })
+}
diff --git a/browser_tests/fixtures/components/Actionbar.ts b/browser_tests/fixtures/components/Actionbar.ts
index a559a013cb..0fad74979a 100644
--- a/browser_tests/fixtures/components/Actionbar.ts
+++ b/browser_tests/fixtures/components/Actionbar.ts
@@ -39,10 +39,32 @@ class ComfyQueueButton {
await this.dropdownButton.click()
return new ComfyQueueButtonOptions(this.actionbar.page)
}
+
+ public async openOptions() {
+ const options = new ComfyQueueButtonOptions(this.actionbar.page)
+ if (!(await options.menu.isVisible())) {
+ await this.dropdownButton.click()
+ }
+ return options
+ }
}
class ComfyQueueButtonOptions {
- constructor(public readonly page: Page) {}
+ public readonly menu: Locator
+ public readonly modeItems: Locator
+
+ constructor(public readonly page: Page) {
+ this.menu = page.getByRole('menu')
+ this.modeItems = this.menu.getByRole('menuitem')
+ }
+
+ public modeItem(name: string) {
+ return this.menu.getByRole('menuitem', { name, exact: true })
+ }
+
+ public async selectMode(name: string) {
+ await this.modeItem(name).click()
+ }
public async setMode(mode: AutoQueueMode) {
await this.page.evaluate((mode) => {
diff --git a/browser_tests/fixtures/components/ContextMenu.ts b/browser_tests/fixtures/components/ContextMenu.ts
index fab6059baa..df22d95dad 100644
--- a/browser_tests/fixtures/components/ContextMenu.ts
+++ b/browser_tests/fixtures/components/ContextMenu.ts
@@ -20,6 +20,7 @@ export class ContextMenu {
async clickMenuItemExact(name: string): Promise {
await this.page.getByRole('menuitem', { name, exact: true }).click()
+ await this.waitForHidden()
}
/**
diff --git a/browser_tests/fixtures/components/Topbar.ts b/browser_tests/fixtures/components/Topbar.ts
index c91ccf11e8..608a262f07 100644
--- a/browser_tests/fixtures/components/Topbar.ts
+++ b/browser_tests/fixtures/components/Topbar.ts
@@ -82,7 +82,7 @@ export class Topbar {
}
getSaveDialog(): Locator {
- return this.page.locator('.p-dialog-content input')
+ return this.page.getByRole('dialog').getByRole('textbox')
}
saveWorkflow(workflowName: string): Promise {
@@ -116,9 +116,9 @@ export class Topbar {
// Check if a confirmation dialog appeared (e.g., "Overwrite existing file?")
// If so, return early to let the test handle the confirmation
- const confirmationDialog = this.page.locator(
- '.p-dialog:has-text("Overwrite")'
- )
+ const confirmationDialog = this.page
+ .getByRole('dialog')
+ .filter({ hasText: 'Overwrite' })
if (await confirmationDialog.isVisible()) {
return
}
diff --git a/browser_tests/fixtures/components/WidgetSelectDropdown.ts b/browser_tests/fixtures/components/WidgetSelectDropdown.ts
new file mode 100644
index 0000000000..15221961ff
--- /dev/null
+++ b/browser_tests/fixtures/components/WidgetSelectDropdown.ts
@@ -0,0 +1,12 @@
+import type { Locator } from '@playwright/test'
+
+export class WidgetSelectDropdownFixture {
+ public readonly selection: Locator
+
+ constructor(public readonly root: Locator) {
+ this.selection = root.locator('button span span')
+ }
+ async selectedItem(): Promise {
+ return await this.selection.innerText()
+ }
+}
diff --git a/browser_tests/fixtures/helpers/AppModeHelper.ts b/browser_tests/fixtures/helpers/AppModeHelper.ts
index f8337f1444..3951eda5d8 100644
--- a/browser_tests/fixtures/helpers/AppModeHelper.ts
+++ b/browser_tests/fixtures/helpers/AppModeHelper.ts
@@ -9,13 +9,15 @@ import { BuilderFooterHelper } from '@e2e/fixtures/helpers/BuilderFooterHelper'
import { BuilderSaveAsHelper } from '@e2e/fixtures/helpers/BuilderSaveAsHelper'
import { BuilderSelectHelper } from '@e2e/fixtures/helpers/BuilderSelectHelper'
import { BuilderStepsHelper } from '@e2e/fixtures/helpers/BuilderStepsHelper'
+import { MobileAppHelper } from '@e2e/fixtures/helpers/MobileAppHelper'
export class AppModeHelper {
- readonly steps: BuilderStepsHelper
readonly footer: BuilderFooterHelper
+ readonly mobile: MobileAppHelper
readonly saveAs: BuilderSaveAsHelper
readonly select: BuilderSelectHelper
readonly outputHistory: OutputHistoryComponent
+ readonly steps: BuilderStepsHelper
readonly widgets: AppModeWidgetHelper
/** The "Connect an output" popover shown when saving without outputs. */
@@ -60,13 +62,16 @@ export class AppModeHelper {
public readonly vueNodeSwitchDismissButton: Locator
/** The "Don't show again" checkbox inside the Vue Node switch popup. */
public readonly vueNodeSwitchDontShowAgainCheckbox: Locator
+ /** The main content area where outputs are displayed*/
+ public readonly centerPanel: Locator
constructor(private readonly comfyPage: ComfyPage) {
- this.steps = new BuilderStepsHelper(comfyPage)
+ this.mobile = new MobileAppHelper(comfyPage)
this.footer = new BuilderFooterHelper(comfyPage)
this.saveAs = new BuilderSaveAsHelper(comfyPage)
this.select = new BuilderSelectHelper(comfyPage)
this.outputHistory = new OutputHistoryComponent(comfyPage.page)
+ this.steps = new BuilderStepsHelper(comfyPage)
this.widgets = new AppModeWidgetHelper(comfyPage)
this.connectOutputPopover = this.page.getByTestId(
@@ -125,6 +130,7 @@ export class AppModeHelper {
this.vueNodeSwitchDontShowAgainCheckbox = this.page.getByTestId(
TestIds.appMode.vueNodeSwitchDontShowAgain
)
+ this.centerPanel = this.page.getByTestId(TestIds.linear.centerPanel)
}
private get page(): Page {
diff --git a/browser_tests/fixtures/helpers/AssetHelper.ts b/browser_tests/fixtures/helpers/AssetHelper.ts
index e5d7171998..6bcfaa14cf 100644
--- a/browser_tests/fixtures/helpers/AssetHelper.ts
+++ b/browser_tests/fixtures/helpers/AssetHelper.ts
@@ -215,11 +215,12 @@ export class AssetHelper {
return this.store.size
}
private handleListAssets(route: Route, url: URL) {
- const includeTags = url.searchParams.get('include_tags')?.split(',') ?? []
+ const includeTags = parseAssetTagParam(url.searchParams.get('include_tags'))
+ const excludeTags = parseAssetTagParam(url.searchParams.get('exclude_tags'))
const limit = parseInt(url.searchParams.get('limit') ?? '0', 10)
const offset = parseInt(url.searchParams.get('offset') ?? '0', 10)
- let filtered = this.getFilteredAssets(includeTags)
+ let filtered = this.getFilteredAssets(includeTags, excludeTags)
if (limit > 0) {
filtered = filtered.slice(offset, offset + limit)
}
@@ -296,15 +297,29 @@ export class AssetHelper {
this.paginationOptions = null
this.uploadResponse = null
}
- private getFilteredAssets(tags: string[]): Asset[] {
+ private getFilteredAssets(
+ includeTags: string[],
+ excludeTags: string[]
+ ): Asset[] {
const assets = [...this.store.values()]
- if (tags.length === 0) return assets
- return assets.filter((asset) =>
- tags.every((tag) => (asset.tags ?? []).includes(tag))
+ return assets.filter(
+ (asset) =>
+ includeTags.every((tag) => (asset.tags ?? []).includes(tag)) &&
+ excludeTags.every((tag) => !(asset.tags ?? []).includes(tag))
)
}
}
+
+function parseAssetTagParam(value: string | null): string[] {
+ return (
+ value
+ ?.split(',')
+ .map((tag) => tag.trim())
+ .filter(Boolean) ?? []
+ )
+}
+
export function createAssetHelper(
page: Page,
...operators: AssetOperator[]
diff --git a/browser_tests/fixtures/helpers/BuilderSelectHelper.ts b/browser_tests/fixtures/helpers/BuilderSelectHelper.ts
index 6b56182e6b..5a878556c4 100644
--- a/browser_tests/fixtures/helpers/BuilderSelectHelper.ts
+++ b/browser_tests/fixtures/helpers/BuilderSelectHelper.ts
@@ -127,9 +127,7 @@ export class BuilderSelectHelper {
await popoverTrigger.click()
await this.page.getByText('Rename', { exact: true }).click()
- const dialogInput = this.page.locator(
- '.p-dialog-content input[type="text"]'
- )
+ const dialogInput = this.page.getByRole('dialog').getByRole('textbox')
await dialogInput.fill(newName)
await this.page.keyboard.press('Enter')
await dialogInput.waitFor({ state: 'hidden' })
diff --git a/browser_tests/fixtures/helpers/DragDropHelper.ts b/browser_tests/fixtures/helpers/DragDropHelper.ts
index ee89551e45..6f02b776d6 100644
--- a/browser_tests/fixtures/helpers/DragDropHelper.ts
+++ b/browser_tests/fixtures/helpers/DragDropHelper.ts
@@ -1,4 +1,5 @@
import { readFileSync } from 'fs'
+import { basename } from 'path'
import type { Page } from '@playwright/test'
@@ -13,6 +14,7 @@ export class DragDropHelper {
async dragAndDropExternalResource(
options: {
fileName?: string
+ filePath?: string
url?: string
dropPosition?: Position
waitForUpload?: boolean
@@ -22,13 +24,14 @@ export class DragDropHelper {
const {
dropPosition = { x: 100, y: 100 },
fileName,
+ filePath,
url,
waitForUpload = false,
preserveNativePropagation = false
} = options
- if (!fileName && !url)
- throw new Error('Must provide either fileName or url')
+ if (!fileName && !filePath && !url)
+ throw new Error('Must provide fileName, filePath, or url')
const evaluateParams: {
dropPosition: Position
@@ -39,12 +42,22 @@ export class DragDropHelper {
preserveNativePropagation: boolean
} = { dropPosition, preserveNativePropagation }
- if (fileName) {
- const filePath = assetPath(fileName)
- const buffer = readFileSync(filePath)
+ if (fileName || filePath) {
+ const resolvedPath = filePath ?? assetPath(fileName!)
+ const displayName = fileName ?? basename(resolvedPath)
+ let buffer: Buffer
+ try {
+ buffer = readFileSync(resolvedPath)
+ } catch (error) {
+ const reason = error instanceof Error ? error.message : String(error)
+ throw new Error(
+ `Failed to read drag-and-drop fixture at "${resolvedPath}": ${reason}`,
+ { cause: error }
+ )
+ }
- evaluateParams.fileName = fileName
- evaluateParams.fileType = getMimeType(fileName)
+ evaluateParams.fileName = displayName
+ evaluateParams.fileType = getMimeType(displayName)
evaluateParams.buffer = [...new Uint8Array(buffer)]
}
@@ -148,6 +161,13 @@ export class DragDropHelper {
return this.dragAndDropExternalResource({ fileName, ...options })
}
+ async dragAndDropFilePath(
+ filePath: string,
+ options: { dropPosition?: Position; waitForUpload?: boolean } = {}
+ ): Promise {
+ return this.dragAndDropExternalResource({ filePath, ...options })
+ }
+
async dragAndDropURL(
url: string,
options: {
diff --git a/browser_tests/fixtures/helpers/ExecutionHelper.ts b/browser_tests/fixtures/helpers/ExecutionHelper.ts
index 3483a13667..e13f8c0db0 100644
--- a/browser_tests/fixtures/helpers/ExecutionHelper.ts
+++ b/browser_tests/fixtures/helpers/ExecutionHelper.ts
@@ -1,6 +1,10 @@
import type { WebSocketRoute } from '@playwright/test'
-import type { NodeError, PromptResponse } from '@/schemas/apiSchema'
+import type {
+ NodeError,
+ NodeProgressState,
+ PromptResponse
+} from '@/schemas/apiSchema'
import type { RawJobListItem } from '@/platform/remote/comfyui/jobs/jobTypes'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import { createMockJob } from '@e2e/fixtures/helpers/AssetsHelper'
@@ -230,6 +234,16 @@ export class ExecutionHelper {
)
}
+ /** Send `progress_state` WS event with per-node execution state. */
+ progressState(jobId: string, nodes: Record): void {
+ this.requireWs().send(
+ JSON.stringify({
+ type: 'progress_state',
+ data: { prompt_id: jobId, nodes }
+ })
+ )
+ }
+
/**
* Complete a job by adding it to mock history, sending execution_success,
* and triggering a history refresh via a status event.
diff --git a/browser_tests/fixtures/helpers/MobileAppHelper.ts b/browser_tests/fixtures/helpers/MobileAppHelper.ts
new file mode 100644
index 0000000000..54dc77b83d
--- /dev/null
+++ b/browser_tests/fixtures/helpers/MobileAppHelper.ts
@@ -0,0 +1,33 @@
+import type { Locator, Page } from '@playwright/test'
+
+import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
+import { TestIds } from '@e2e/fixtures/selectors'
+
+export class MobileAppHelper {
+ private readonly page: Page
+ readonly contentPanel: Locator
+ readonly navigation: Locator
+ readonly navigationTabs: Locator
+ readonly view: Locator
+ readonly workflows: Locator
+
+ constructor(comfyPage: ComfyPage) {
+ this.page = comfyPage.page
+ this.view = this.page.getByTestId(TestIds.linear.mobile)
+ this.contentPanel = this.page.getByRole('tabpanel')
+ this.navigation = this.page.getByRole('tablist').filter({ hasText: 'Run' })
+ this.navigationTabs = this.navigation.getByRole('tab')
+ this.workflows = this.view.getByTestId(TestIds.linear.mobileWorkflows)
+ }
+
+ async switchWorkflow(workflowName: string) {
+ await this.workflows.click()
+ await this.page.getByRole('menu').getByText(workflowName).click()
+ }
+ async navigateTab(name: 'run' | 'outputs' | 'assets') {
+ await this.navigation.getByRole('tab', { name }).click()
+ }
+ async tap(locator: Locator, { count = 1 }: { count?: number } = {}) {
+ for (let i = 0; i < count; i++) await locator.tap()
+ }
+}
diff --git a/browser_tests/fixtures/helpers/NodeOperationsHelper.ts b/browser_tests/fixtures/helpers/NodeOperationsHelper.ts
index 9ad8cc8009..a0fadd2f74 100644
--- a/browser_tests/fixtures/helpers/NodeOperationsHelper.ts
+++ b/browser_tests/fixtures/helpers/NodeOperationsHelper.ts
@@ -18,9 +18,7 @@ export class NodeOperationsHelper {
public readonly promptDialogInput: Locator
constructor(private comfyPage: ComfyPage) {
- this.promptDialogInput = this.page.locator(
- '.p-dialog-content input[type="text"]'
- )
+ this.promptDialogInput = this.page.getByRole('dialog').getByRole('textbox')
}
private get page() {
diff --git a/browser_tests/fixtures/helpers/SubgraphHelper.ts b/browser_tests/fixtures/helpers/SubgraphHelper.ts
index 9660667165..1c9f059991 100644
--- a/browser_tests/fixtures/helpers/SubgraphHelper.ts
+++ b/browser_tests/fixtures/helpers/SubgraphHelper.ts
@@ -362,6 +362,9 @@ export class SubgraphHelper {
await this.comfyPage.nextFrame()
await expect.poll(async () => this.isInSubgraph()).toBe(false)
+ if (this.comfyPage.isVueNodes) {
+ await this.comfyPage.vueNodes.waitForNodes()
+ }
}
async countGraphPseudoPreviewEntries(): Promise {
diff --git a/browser_tests/fixtures/selectors.ts b/browser_tests/fixtures/selectors.ts
index 3f7a9f9c09..6f401f5056 100644
--- a/browser_tests/fixtures/selectors.ts
+++ b/browser_tests/fixtures/selectors.ts
@@ -116,7 +116,8 @@ export const TestIds = {
titleInput: 'node-title-input',
pinIndicator: 'node-pin-indicator',
innerWrapper: 'node-inner-wrapper',
- mainImage: 'main-image'
+ mainImage: 'main-image',
+ slotConnectionDot: 'slot-connection-dot'
},
selectionToolbox: {
root: 'selection-toolbox',
@@ -143,6 +144,14 @@ export const TestIds = {
domWidgetTextarea: 'dom-widget-textarea',
subgraphEnterButton: 'subgraph-enter-button'
},
+ linear: {
+ centerPanel: 'linear-center-panel',
+ mobile: 'linear-mobile',
+ mobileNavigation: 'linear-mobile-navigation',
+ mobileWorkflows: 'linear-mobile-workflows',
+ outputInfo: 'linear-output-info',
+ widgetContainer: 'linear-widgets'
+ },
builder: {
footerNav: 'builder-footer-nav',
saveButton: 'builder-save-button',
diff --git a/browser_tests/fixtures/utils/mimeTypeUtil.ts b/browser_tests/fixtures/utils/mimeTypeUtil.ts
index 88f5b20a60..8ec0b1a8e1 100644
--- a/browser_tests/fixtures/utils/mimeTypeUtil.ts
+++ b/browser_tests/fixtures/utils/mimeTypeUtil.ts
@@ -7,6 +7,9 @@ export function getMimeType(fileName: string): string {
if (name.endsWith('.avif')) return 'image/avif'
if (name.endsWith('.webm')) return 'video/webm'
if (name.endsWith('.mp4')) return 'video/mp4'
+ if (name.endsWith('.mp3')) return 'audio/mpeg'
+ if (name.endsWith('.flac')) return 'audio/flac'
+ if (name.endsWith('.ogg') || name.endsWith('.opus')) return 'audio/ogg'
if (name.endsWith('.json')) return 'application/json'
if (name.endsWith('.glb')) return 'model/gltf-binary'
return 'application/octet-stream'
diff --git a/browser_tests/fixtures/utils/paths.ts b/browser_tests/fixtures/utils/paths.ts
index c8bc5ee3cb..682336d4b2 100644
--- a/browser_tests/fixtures/utils/paths.ts
+++ b/browser_tests/fixtures/utils/paths.ts
@@ -1,3 +1,7 @@
export function assetPath(fileName: string): string {
return `./browser_tests/assets/${fileName}`
}
+
+export function metadataFixturePath(fileName: string): string {
+ return `./src/scripts/metadata/__fixtures__/${fileName}`
+}
diff --git a/browser_tests/fixtures/utils/vueNodeFixtures.ts b/browser_tests/fixtures/utils/vueNodeFixtures.ts
index 5616fcdf0e..b2ce49d994 100644
--- a/browser_tests/fixtures/utils/vueNodeFixtures.ts
+++ b/browser_tests/fixtures/utils/vueNodeFixtures.ts
@@ -13,6 +13,8 @@ export class VueNodeFixture {
public readonly collapseButton: Locator
public readonly collapseIcon: Locator
public readonly root: Locator
+ public readonly widgets: Locator
+ public readonly imagePreview: Locator
constructor(private readonly locator: Locator) {
this.header = locator.locator('[data-testid^="node-header-"]')
@@ -23,6 +25,8 @@ export class VueNodeFixture {
this.collapseButton = locator.getByTestId('node-collapse-button')
this.collapseIcon = this.collapseButton.locator('i')
this.root = locator
+ this.widgets = this.locator.locator('.lg-node-widget')
+ this.imagePreview = locator.locator('.image-preview')
}
async getTitle(): Promise {
@@ -39,6 +43,16 @@ export class VueNodeFixture {
await this.collapseButton.click()
}
+ /**
+ * Select this node and delete it via the Delete key, waiting for the node
+ * element to leave the DOM before resolving.
+ */
+ async delete(): Promise {
+ await this.header.click()
+ await this.header.press('Delete')
+ await this.locator.waitFor({ state: 'hidden' })
+ }
+
async getCollapseIconClass(): Promise {
return (await this.collapseIcon.getAttribute('class')) ?? ''
}
diff --git a/browser_tests/tests/appMode.spec.ts b/browser_tests/tests/appMode.spec.ts
new file mode 100644
index 0000000000..2a84b9f418
--- /dev/null
+++ b/browser_tests/tests/appMode.spec.ts
@@ -0,0 +1,154 @@
+import {
+ comfyPageFixture as test,
+ comfyExpect as expect
+} from '@e2e/fixtures/ComfyPage'
+import { WidgetSelectDropdownFixture } from '@e2e/fixtures/components/WidgetSelectDropdown'
+
+test.describe('App mode usage', () => {
+ test('Drag and Drop', async ({ comfyPage, comfyFiles }) => {
+ await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
+ await comfyPage.settings.setSetting(
+ 'Comfy.NodeSearchBoxImpl',
+ 'v1 (legacy)'
+ )
+ const { centerPanel } = comfyPage.appMode
+ await comfyPage.appMode.enterAppModeWithInputs([['3', 'seed']])
+ await expect(centerPanel, 'Enter app mode').toBeVisible()
+
+ //an app without an image input will load the workflow
+ await test.step('App without an image input loads workflow', async () => {
+ await comfyPage.dragDrop.dragAndDropFile('workflowInMedia/workflow.webp')
+ await expect(centerPanel).toBeHidden()
+ })
+
+ //prep a load image
+ await test.step('Add a load image node', async () => {
+ await comfyPage.workflow.loadWorkflow('default')
+ await comfyPage.page.mouse.dblclick(200, 200, { delay: 5 })
+ await comfyPage.searchBox.fillAndSelectFirstNode('Load Image')
+ const loadImage = await comfyPage.vueNodes.getNodeLocator('10')
+ await expect(loadImage).toBeVisible()
+ })
+
+ const imageInput = new WidgetSelectDropdownFixture(
+ comfyPage.appMode.linearWidgets.locator('.lg-node-widget')
+ )
+
+ await test.step('Enter app mode with image input', async () => {
+ await comfyPage.appMode.enterAppModeWithInputs([['10', 'image']])
+ await expect(centerPanel).toBeVisible()
+
+ await expect(imageInput.root).toBeVisible()
+ })
+
+ await test.step('Dragging an image redirects to image input', async () => {
+ const initialImage = await imageInput.selectedItem()
+
+ await comfyPage.dragDrop.dragAndDropExternalResource({
+ fileName: 'workflow.webp',
+ filePath: './browser_tests/assets/workflowInMedia/workflow.webp',
+ preserveNativePropagation: true
+ })
+ comfyFiles.deleteAfterTest({ filename: 'workflow.webp', type: 'input' })
+
+ await expect(imageInput.selection).not.toHaveText(initialImage)
+ await expect(
+ centerPanel,
+ 'A file with workflow should not open a new workflow'
+ ).toBeVisible()
+ })
+
+ await test.step('Dragging a url redirects to image input', async () => {
+ const secondImage = await imageInput.selectedItem()
+ await comfyPage.dragDrop.dragAndDropURL('/assets/images/og-image.png', {
+ preserveNativePropagation: true
+ })
+ comfyFiles.deleteAfterTest({
+ filename: 'og-image.png',
+ type: 'input'
+ })
+ await expect(imageInput.selection).not.toHaveText(secondImage)
+ })
+ })
+
+ test('Widget Interaction', async ({ comfyPage }) => {
+ await comfyPage.appMode.enterAppModeWithInputs([
+ ['3', 'seed'],
+ ['3', 'sampler_name'],
+ ['6', 'text']
+ ])
+ const seed = comfyPage.appMode.linearWidgets.getByLabel('seed', {
+ exact: true
+ })
+ const { input, incrementButton, decrementButton } =
+ comfyPage.vueNodes.getInputNumberControls(seed)
+ const initialValue = Number(await input.inputValue())
+
+ await seed.dragTo(incrementButton, { steps: 5 })
+ const intermediateValue = Number(await input.inputValue())
+ expect(intermediateValue).toBeGreaterThan(initialValue)
+
+ await seed.dragTo(decrementButton, { steps: 5 })
+ const endValue = Number(await input.inputValue())
+ expect(endValue).toBeLessThan(intermediateValue)
+
+ const sampler = comfyPage.appMode.linearWidgets.getByLabel('sampler_name', {
+ exact: true
+ })
+ await sampler.click()
+
+ await comfyPage.page.getByRole('searchbox').fill('uni')
+ await comfyPage.page.keyboard.press('ArrowDown')
+ await comfyPage.page.keyboard.press('Enter')
+ await expect(sampler).toHaveText('uni_pc')
+
+ //verify values are consistent with litegraph
+ })
+
+ test.describe('Mobile', { tag: ['@mobile'] }, () => {
+ test('panel navigation', async ({ comfyPage }) => {
+ const { mobile } = comfyPage.appMode
+ await comfyPage.appMode.enterAppModeWithInputs([['3', 'steps']])
+ await expect(mobile.view).toBeVisible()
+ await expect(mobile.navigation).toBeVisible()
+
+ await mobile.navigateTab('assets')
+ await expect(mobile.contentPanel).toHaveAccessibleName('Assets')
+
+ const buttons = await mobile.navigationTabs.all()
+ await buttons[0].dragTo(buttons[2], { steps: 5 })
+ await expect(mobile.contentPanel).toHaveAccessibleName('Outputs')
+
+ await mobile.navigateTab('run')
+ await expect(comfyPage.appMode.linearWidgets).toBeInViewport({ ratio: 1 })
+
+ const steps = comfyPage.page.getByRole('spinbutton')
+ const initialValue = Number(await steps.inputValue())
+ await mobile.tap(
+ comfyPage.page.getByRole('button', { name: 'increment' }),
+ { count: 5 }
+ )
+ await expect(steps).toHaveValue(String(initialValue + 5))
+ await mobile.tap(
+ comfyPage.page.getByRole('button', { name: 'decrement' }),
+ { count: 3 }
+ )
+
+ await expect(steps).toHaveValue(String(initialValue + 2))
+ })
+
+ test('workflow selection', async ({ comfyPage }) => {
+ const widgetNames = ['seed', 'steps', 'denoise', 'cfg']
+ for (const name of widgetNames)
+ await comfyPage.appMode.enterAppModeWithInputs([['3', name]])
+ await expect(comfyPage.appMode.mobile.workflows).toBeVisible()
+
+ const widgets = comfyPage.appMode.linearWidgets
+ await comfyPage.appMode.mobile.navigateTab('run')
+ for (let i = 0; i < widgetNames.length; i++) {
+ await comfyPage.appMode.mobile.switchWorkflow(`(${i + 2})`)
+ await expect(widgets.getByText(widgetNames[i])).toBeVisible()
+ }
+ })
+ })
+})
diff --git a/browser_tests/tests/appModeBuilder.spec.ts b/browser_tests/tests/appModeBuilder.spec.ts
new file mode 100644
index 0000000000..99392b0445
--- /dev/null
+++ b/browser_tests/tests/appModeBuilder.spec.ts
@@ -0,0 +1,121 @@
+import {
+ comfyPageFixture as test,
+ comfyExpect as expect
+} from '@e2e/fixtures/ComfyPage'
+
+test.describe('App mode builder selection', () => {
+ test.beforeEach(async ({ comfyPage }) => {
+ await comfyPage.appMode.enableLinearMode()
+ })
+
+ test('Can independently select inputs of same name', async ({
+ comfyPage
+ }) => {
+ await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
+ const items = comfyPage.appMode.select.inputItems
+
+ await comfyPage.vueNodes.selectNodes(['6', '7'])
+ await comfyPage.command.executeCommand('Comfy.Graph.ConvertToSubgraph')
+
+ await comfyPage.appMode.enterBuilder()
+ await comfyPage.appMode.steps.goToInputs()
+ await expect(items).toHaveCount(0)
+
+ const prompts = comfyPage.vueNodes
+ .getNodeByTitle('New Subgraph')
+ .locator('.lg-node-widget')
+ const count = await prompts.count()
+ for (let i = 0; i < count; i++) {
+ await expect(prompts.nth(i)).toBeVisible()
+ await prompts.nth(i).click()
+ await expect(items).toHaveCount(i + 1)
+ }
+ })
+
+ test('Can select outputs', async ({ comfyPage }) => {
+ await comfyPage.appMode.enterBuilder()
+ await comfyPage.appMode.steps.goToOutputs()
+
+ await comfyPage.nodeOps
+ .getNodeRefById('9')
+ .then((ref) => ref.centerOnNode())
+ const saveImage = await comfyPage.vueNodes.getNodeLocator('9')
+ await saveImage.click()
+
+ const items = comfyPage.appMode.select.inputItems
+ await expect(items).toHaveCount(1)
+ })
+
+ test('Can not select nodes with errors or notes', async ({ comfyPage }) => {
+ //Manually set error state on checkpoint loader
+ //Shouldn't be needed on ci, but has spotty reliability
+ await comfyPage.page.evaluate(() => (graph!.nodes[6].has_errors = true))
+ await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
+
+ const items = comfyPage.appMode.select.inputItems
+ await comfyPage.appMode.enterBuilder()
+ await comfyPage.appMode.steps.goToInputs()
+ await expect(items).toHaveCount(0)
+
+ await comfyPage.appMode.select.selectInputWidget(
+ 'Load Checkpoint',
+ 'ckpt_name'
+ )
+ await expect(items).toHaveCount(0)
+
+ await comfyPage.workflow.loadWorkflow('nodes/note_nodes')
+ await comfyPage.appMode.enterBuilder()
+ await comfyPage.appMode.steps.goToInputs()
+ await expect(items).toHaveCount(0)
+
+ await comfyPage.appMode.select.selectInputWidget('Note', 'text')
+ await comfyPage.appMode.select.selectInputWidget('Markdown Note', 'text')
+
+ await expect(items).toHaveCount(0)
+ })
+
+ test('Marks canvas readOnly', async ({ comfyPage }) => {
+ await comfyPage.settings.setSetting(
+ 'Comfy.NodeSearchBoxImpl',
+ 'v1 (legacy)'
+ )
+
+ await comfyPage.page.mouse.dblclick(100, 100, { delay: 5 })
+ await expect(
+ comfyPage.searchBox.input,
+ 'Canvas is initially editable'
+ ).toHaveCount(1)
+ await comfyPage.page.keyboard.press('Escape')
+
+ await comfyPage.appMode.enterBuilder()
+ await comfyPage.appMode.steps.goToInputs()
+
+ await comfyPage.page.mouse.dblclick(100, 100, { delay: 5 })
+ await expect(
+ comfyPage.searchBox.input,
+ 'Entering builder makes the canvas readonly'
+ ).toHaveCount(0)
+
+ await comfyPage.page.keyboard.press('Space')
+ await comfyPage.page.mouse.dblclick(100, 100, { delay: 5 })
+ await expect(
+ comfyPage.searchBox.input,
+ 'Canvas remains readonly after pressing space'
+ ).toHaveCount(0)
+
+ const ksampler = await comfyPage.vueNodes.getFixtureByTitle('KSampler')
+ // oxlint-disable-next-line playwright/no-force-option -- Node container has conditional pointer-events:none that blocks actionability
+ await ksampler.header.dblclick({ force: true })
+ await expect(
+ ksampler.titleEditor.input,
+ 'Double clicking node titles will not initiate a rename'
+ ).toBeHidden()
+
+ await comfyPage.page.keyboard.press('Escape')
+ await comfyPage.page.mouse.dblclick(100, 100, { delay: 5 })
+ await expect(
+ comfyPage.searchBox.input,
+ 'Canvas is no longer readonly after exiting'
+ ).toHaveCount(1)
+ })
+})
diff --git a/browser_tests/tests/assetHelper.spec.ts b/browser_tests/tests/assetHelper.spec.ts
index 8e4721d8d0..508fc50c68 100644
--- a/browser_tests/tests/assetHelper.spec.ts
+++ b/browser_tests/tests/assetHelper.spec.ts
@@ -133,6 +133,29 @@ test.describe('AssetHelper', () => {
expect(data.assets[0].id).toBe(STABLE_CHECKPOINT.id)
})
+ test('GET /assets filters by exclude_tags', async ({
+ comfyPage,
+ assetApi
+ }) => {
+ assetApi.configure(
+ withAsset(STABLE_INPUT_IMAGE),
+ withAsset({
+ ...STABLE_INPUT_IMAGE,
+ id: 'missing-input',
+ tags: ['input', 'missing']
+ })
+ )
+ await assetApi.mock()
+
+ const { body } = await assetApi.fetch(
+ `${comfyPage.url}/api/assets?include_tags=input,&exclude_tags= missing,`
+ )
+ const data = body as { assets: Array<{ id: string }> }
+ expect(data.assets.map((asset) => asset.id)).toEqual([
+ STABLE_INPUT_IMAGE.id
+ ])
+ })
+
test('GET /assets/:id returns single asset or 404', async ({
comfyPage,
assetApi
diff --git a/browser_tests/tests/cloud-asset-default.spec.ts b/browser_tests/tests/cloud-asset-default.spec.ts
index b345bf80e1..ef5b0df3ff 100644
--- a/browser_tests/tests/cloud-asset-default.spec.ts
+++ b/browser_tests/tests/cloud-asset-default.spec.ts
@@ -1,51 +1,20 @@
import { expect } from '@playwright/test'
-import type { Route } from '@playwright/test'
-import type { Asset, ListAssetsResponse } from '@comfyorg/ingest-types'
-import { comfyPageFixture } from '@e2e/fixtures/ComfyPage'
+import type { Asset } from '@comfyorg/ingest-types'
+import {
+ assetRequestIncludesTag,
+ createCloudAssetsFixture
+} from '@e2e/fixtures/assetApiFixture'
import {
STABLE_CHECKPOINT,
STABLE_CHECKPOINT_2
} from '@e2e/fixtures/data/assetFixtures'
-function makeAssetsResponse(assets: Asset[]): ListAssetsResponse {
- return { assets, total: assets.length, has_more: false }
-}
-
const CLOUD_ASSETS: Asset[] = [STABLE_CHECKPOINT, STABLE_CHECKPOINT_2]
const WAITING_FOR_WIDGET_TYPE = 'waiting:type'
const WAITING_FOR_WIDGET_VALUE = 'waiting:value'
-// Stub /api/assets before the app loads. The local ComfyUI backend has no
-// /api/assets endpoint (returns 503), which poisons the assets store on
-// first load. Narrow pattern avoids intercepting static /assets/*.js bundles.
-//
-// TODO: Consider moving this stub into ComfyPage fixture for all @cloud tests.
-const test = comfyPageFixture.extend<{
- cloudAssetRequests: string[]
- stubCloudAssets: void
-}>({
- cloudAssetRequests: async ({ page: _page }, use) => {
- await use([])
- },
- stubCloudAssets: [
- async ({ cloudAssetRequests, page }, use) => {
- const pattern = /\/api\/assets(?:\?.*)?$/
- const assetsRouteHandler = (route: Route) => {
- cloudAssetRequests.push(route.request().url())
- return route.fulfill({
- status: 200,
- contentType: 'application/json',
- body: JSON.stringify(makeAssetsResponse(CLOUD_ASSETS))
- })
- }
- await page.route(pattern, assetsRouteHandler)
- await use()
- await page.unroute(pattern, assetsRouteHandler)
- },
- { auto: true }
- ]
-})
+const test = createCloudAssetsFixture(CLOUD_ASSETS)
test.describe('Asset-supported node default value', { tag: '@cloud' }, () => {
test.afterEach(async ({ comfyPage }) => {
@@ -62,11 +31,9 @@ test.describe('Asset-supported node default value', { tag: '@cloud' }, () => {
// new nodes resolve against the cloud asset list after the fetch.
await expect
.poll(() =>
- cloudAssetRequests.some((url) => {
- const includeTags =
- new URL(url).searchParams.get('include_tags') ?? ''
- return includeTags.split(',').includes('checkpoints')
- })
+ cloudAssetRequests.some((url) =>
+ assetRequestIncludesTag(url, 'checkpoints')
+ )
)
.toBe(true)
diff --git a/browser_tests/tests/defaultKeybindings.spec.ts b/browser_tests/tests/defaultKeybindings.spec.ts
index 3ecdf0e89a..54ea64f90d 100644
--- a/browser_tests/tests/defaultKeybindings.spec.ts
+++ b/browser_tests/tests/defaultKeybindings.spec.ts
@@ -229,9 +229,9 @@ test.describe('Default Keybindings', { tag: '@keyboard' }, () => {
// The dialog appearing proves the keybinding was intercepted by the app.
await comfyPage.keyboard.press('Control+s')
- // The Save As dialog should appear (p-dialog overlay)
- const dialogOverlay = comfyPage.page.locator('.p-dialog-mask')
- await expect(dialogOverlay).toBeVisible()
+ // The Save As dialog should appear
+ const saveDialog = comfyPage.page.getByRole('dialog')
+ await expect(saveDialog).toBeVisible()
// Dismiss the dialog
await comfyPage.keyboard.press('Escape')
diff --git a/browser_tests/tests/dialogs/publishDialog.spec.ts b/browser_tests/tests/dialogs/publishDialog.spec.ts
index 00d7db0bbe..4820d35851 100644
--- a/browser_tests/tests/dialogs/publishDialog.spec.ts
+++ b/browser_tests/tests/dialogs/publishDialog.spec.ts
@@ -16,9 +16,9 @@ async function saveAndOpenPublishDialog(
workflowName: string
): Promise {
await comfyPage.menu.topbar.saveWorkflow(workflowName)
- const overwriteDialog = comfyPage.page.locator(
- '.p-dialog:has-text("Overwrite")'
- )
+ const overwriteDialog = comfyPage.page
+ .getByRole('dialog')
+ .filter({ hasText: 'Overwrite' })
// Bounded wait: point-in-time isVisible() can miss dialogs that open
// slightly after saveWorkflow() resolves.
try {
diff --git a/browser_tests/tests/imageCompare.spec.ts-snapshots/image-compare-default-50-chromium-linux.png b/browser_tests/tests/imageCompare.spec.ts-snapshots/image-compare-default-50-chromium-linux.png
index 194fd5756d..7b9b6d67a6 100644
Binary files a/browser_tests/tests/imageCompare.spec.ts-snapshots/image-compare-default-50-chromium-linux.png and b/browser_tests/tests/imageCompare.spec.ts-snapshots/image-compare-default-50-chromium-linux.png differ
diff --git a/browser_tests/tests/imageCompare.spec.ts-snapshots/image-compare-slider-25-chromium-linux.png b/browser_tests/tests/imageCompare.spec.ts-snapshots/image-compare-slider-25-chromium-linux.png
index 7886baf27b..433a0b263a 100644
Binary files a/browser_tests/tests/imageCompare.spec.ts-snapshots/image-compare-slider-25-chromium-linux.png and b/browser_tests/tests/imageCompare.spec.ts-snapshots/image-compare-slider-25-chromium-linux.png differ
diff --git a/browser_tests/tests/imageCompare.spec.ts-snapshots/image-compare-slider-75-chromium-linux.png b/browser_tests/tests/imageCompare.spec.ts-snapshots/image-compare-slider-75-chromium-linux.png
index c26fb12929..db4143f56f 100644
Binary files a/browser_tests/tests/imageCompare.spec.ts-snapshots/image-compare-slider-75-chromium-linux.png and b/browser_tests/tests/imageCompare.spec.ts-snapshots/image-compare-slider-75-chromium-linux.png differ
diff --git a/browser_tests/tests/keybindingPanel.spec.ts b/browser_tests/tests/keybindingPanel.spec.ts
new file mode 100644
index 0000000000..934e2c31d4
--- /dev/null
+++ b/browser_tests/tests/keybindingPanel.spec.ts
@@ -0,0 +1,548 @@
+import type { Locator, Page } from '@playwright/test'
+import { expect } from '@playwright/test'
+
+import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
+import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
+
+const MULTI_BINDING_COMMAND = 'Comfy.Canvas.DeleteSelectedItems'
+const SINGLE_BINDING_COMMAND = 'Comfy.SaveWorkflow'
+const NO_BINDING_COMMAND = 'TestCommand.KeybindingPanelE2E.NoBinding'
+
+async function searchKeybindings(page: Page, query: string) {
+ await getKeybindingSearchInput(page).fill(query)
+}
+
+async function clearSearch(page: Page) {
+ await getKeybindingSearchInput(page).clear()
+}
+
+function getKeybindingSearchInput(page: Page): Locator {
+ return page.getByPlaceholder('Search Keybindings...')
+}
+
+function getCommandRow(page: Page, commandId: string): Locator {
+ return page
+ .locator('.keybinding-panel tr')
+ .filter({ has: page.locator(`[title="${commandId}"]`) })
+}
+
+function getExpansionContent(page: Page, commandId: string): Locator {
+ // PrimeVue renders the expansion row as the next sibling of the
+ // expanded row. Scoping by sibling avoids matching unrelated expanded rows.
+ return getCommandRow(page, commandId)
+ .locator('xpath=following-sibling::tr[1]')
+ .getByTestId('keybinding-expansion-content')
+}
+
+async function openContextMenu(page: Page, commandId: string) {
+ const row = getCommandRow(page, commandId)
+ await row.locator(`[title="${commandId}"]`).click({ button: 'right' })
+ await expect(
+ page.getByRole('menuitem', { name: /Change keybinding/i })
+ ).toBeVisible()
+}
+
+function getKeybindingInput(page: Page): Locator {
+ return getEditKeybindingDialog(page).locator('input[autofocus]')
+}
+
+function getEditKeybindingDialog(page: Page): Locator {
+ return page.getByRole('dialog', { name: /Modify keybinding/i })
+}
+
+function getRemoveAllKeybindingsDialog(page: Page): Locator {
+ return page.getByRole('dialog', { name: /Remove all keybindings/i })
+}
+
+function getResetAllKeybindingsDialog(page: Page): Locator {
+ return page.getByRole('dialog', { name: /Reset all keybindings/i })
+}
+
+async function pressComboOnInput(page: Page, combo: string) {
+ const input = getKeybindingInput(page)
+ await expect(input).toBeFocused()
+ await input.press(combo)
+}
+
+async function saveAndCloseKeybindingDialog(page: Page) {
+ const dialog = getEditKeybindingDialog(page)
+ await dialog.getByRole('button', { name: /Save/i }).click()
+ await expect(dialog).toBeHidden()
+}
+
+async function cancelAndCloseDialog(page: Page) {
+ const dialog = getEditKeybindingDialog(page)
+ await dialog.getByRole('button', { name: /Cancel/i }).click()
+ await expect(dialog).toBeHidden()
+}
+
+async function addKeybindingToRow(page: Page, row: Locator, combo: string) {
+ await row.getByRole('button', { name: /Add new keybinding/i }).click()
+ await pressComboOnInput(page, combo)
+ await saveAndCloseKeybindingDialog(page)
+}
+
+test.beforeEach(async ({ comfyPage }) => {
+ await registerNoBindingCommand(comfyPage)
+ await comfyPage.settingDialog.open()
+ await comfyPage.settingDialog.category('Keybinding').click()
+})
+
+test.afterEach(async ({ comfyPage }) => {
+ await comfyPage.settings.setSetting('Comfy.Keybinding.NewBindings', [])
+ await comfyPage.settings.setSetting('Comfy.Keybinding.UnsetBindings', [])
+})
+
+async function registerNoBindingCommand(comfyPage: ComfyPage) {
+ await comfyPage.page.evaluate((commandId) => {
+ const app = window.app!
+ app.registerExtension({
+ name: 'TestExtension.KeybindingPanelE2E',
+ commands: [{ id: commandId, function: () => {} }]
+ })
+ }, NO_BINDING_COMMAND)
+}
+
+test.describe('Keybinding Panel', { tag: '@keyboard' }, () => {
+ test.describe('Row Expansion', () => {
+ test('Click on row with 2+ keybindings toggles expansion', async ({
+ comfyPage
+ }) => {
+ const { page } = comfyPage
+
+ await searchKeybindings(page, MULTI_BINDING_COMMAND)
+ const row = getCommandRow(page, MULTI_BINDING_COMMAND)
+ await expect(row).toBeVisible()
+
+ await row.locator(`[title="${MULTI_BINDING_COMMAND}"]`).click()
+
+ const expansionContent = getExpansionContent(page, MULTI_BINDING_COMMAND)
+ await expect(expansionContent).toBeVisible()
+
+ await row.locator(`[title="${MULTI_BINDING_COMMAND}"]`).click()
+ await expect(expansionContent).toBeHidden()
+ })
+
+ test('Click on row with 1 keybinding does not expand', async ({
+ comfyPage
+ }) => {
+ const { page } = comfyPage
+
+ await searchKeybindings(page, SINGLE_BINDING_COMMAND)
+ const row = getCommandRow(page, SINGLE_BINDING_COMMAND)
+ await expect(row).toBeVisible()
+
+ await row.locator(`[title="${SINGLE_BINDING_COMMAND}"]`).click()
+
+ const expansionContent = getExpansionContent(page, SINGLE_BINDING_COMMAND)
+ await expect(expansionContent).toBeHidden()
+ })
+ })
+
+ test.describe('Double-Click', () => {
+ test('Double-click row with 0 keybindings opens Add dialog', async ({
+ comfyPage
+ }) => {
+ const { page } = comfyPage
+
+ await searchKeybindings(page, NO_BINDING_COMMAND)
+ const row = getCommandRow(page, NO_BINDING_COMMAND)
+ await expect(row).toBeVisible()
+
+ await row.locator(`[title="${NO_BINDING_COMMAND}"]`).dblclick()
+
+ const input = getKeybindingInput(page)
+ await expect(input).toBeVisible()
+
+ await cancelAndCloseDialog(page)
+ })
+
+ test('Double-click row with 1 keybinding opens Edit dialog', async ({
+ comfyPage
+ }) => {
+ const { page } = comfyPage
+
+ await searchKeybindings(page, SINGLE_BINDING_COMMAND)
+ const row = getCommandRow(page, SINGLE_BINDING_COMMAND)
+ await expect(row).toBeVisible()
+
+ await row.locator(`[title="${SINGLE_BINDING_COMMAND}"]`).dblclick()
+
+ const input = getKeybindingInput(page)
+ await expect(input).toBeVisible()
+
+ await cancelAndCloseDialog(page)
+ })
+ })
+
+ test.describe('Context Menu', () => {
+ test('Right-click row shows context menu with correct items', async ({
+ comfyPage
+ }) => {
+ const { page } = comfyPage
+
+ await searchKeybindings(page, SINGLE_BINDING_COMMAND)
+ await openContextMenu(page, SINGLE_BINDING_COMMAND)
+
+ const changeItem = page.getByRole('menuitem', {
+ name: /Change keybinding/i
+ })
+ const addItem = page.getByRole('menuitem', {
+ name: /Add new keybinding/i
+ })
+ const resetItem = page.getByRole('menuitem', {
+ name: /Reset to default/i
+ })
+ const removeItem = page.getByRole('menuitem', {
+ name: /Remove keybinding/i
+ })
+
+ await expect(changeItem).toBeVisible()
+ await expect(addItem).toBeVisible()
+ await expect(resetItem).toBeVisible()
+ await expect(removeItem).toBeVisible()
+
+ await page.keyboard.press('Escape')
+ })
+
+ test("Context menu 'Add new keybinding' opens add dialog", async ({
+ comfyPage
+ }) => {
+ const { page } = comfyPage
+
+ await searchKeybindings(page, SINGLE_BINDING_COMMAND)
+ await openContextMenu(page, SINGLE_BINDING_COMMAND)
+
+ await page.getByRole('menuitem', { name: /Add new keybinding/i }).click()
+
+ const input = getKeybindingInput(page)
+ await expect(input).toBeVisible()
+
+ await cancelAndCloseDialog(page)
+ })
+
+ test("Context menu 'Change keybinding' on single-binding command opens edit dialog", async ({
+ comfyPage
+ }) => {
+ const { page } = comfyPage
+
+ await searchKeybindings(page, SINGLE_BINDING_COMMAND)
+ await openContextMenu(page, SINGLE_BINDING_COMMAND)
+
+ await page.getByRole('menuitem', { name: /Change keybinding/i }).click()
+
+ const input = getKeybindingInput(page)
+ await expect(input).toBeVisible()
+
+ await cancelAndCloseDialog(page)
+ })
+
+ test("Context menu 'Change keybinding' on multi-binding command expands row", async ({
+ comfyPage
+ }) => {
+ const { page } = comfyPage
+
+ await searchKeybindings(page, MULTI_BINDING_COMMAND)
+
+ const expansionContent = getExpansionContent(page, MULTI_BINDING_COMMAND)
+ await expect(expansionContent).toBeHidden()
+
+ await openContextMenu(page, MULTI_BINDING_COMMAND)
+
+ await page.getByRole('menuitem', { name: /Change keybinding/i }).click()
+
+ await expect(expansionContent).toBeVisible()
+ })
+
+ test("Context menu 'Remove keybinding' after adding second binding shows confirm dialog", async ({
+ comfyPage
+ }) => {
+ const { page } = comfyPage
+
+ await searchKeybindings(page, SINGLE_BINDING_COMMAND)
+ const row = getCommandRow(page, SINGLE_BINDING_COMMAND)
+
+ await addKeybindingToRow(page, row, 'Control+Shift+F9')
+
+ await openContextMenu(page, SINGLE_BINDING_COMMAND)
+ await page.getByRole('menuitem', { name: /Remove keybinding/i }).click()
+
+ const confirmDialog = getRemoveAllKeybindingsDialog(page)
+ await expect(confirmDialog).toBeVisible()
+ await confirmDialog.getByRole('button', { name: /Remove all/i }).click()
+
+ await expect(row.locator('td').nth(1)).toContainText('-')
+ })
+
+ test("Context menu 'Reset to default' resets modified command", async ({
+ comfyPage
+ }) => {
+ const { page } = comfyPage
+
+ await searchKeybindings(page, SINGLE_BINDING_COMMAND)
+ const row = getCommandRow(page, SINGLE_BINDING_COMMAND)
+
+ await addKeybindingToRow(page, row, 'Control+Shift+F10')
+
+ await openContextMenu(page, SINGLE_BINDING_COMMAND)
+ await page.getByRole('menuitem', { name: /Reset to default/i }).click()
+
+ await expect(row.getByRole('button', { name: /Reset/i })).toBeDisabled()
+ })
+
+ test('Context menu items disabled when no keybindings', async ({
+ comfyPage
+ }) => {
+ const { page } = comfyPage
+
+ await searchKeybindings(page, NO_BINDING_COMMAND)
+ await openContextMenu(page, NO_BINDING_COMMAND)
+
+ const changeItem = page.getByRole('menuitem', {
+ name: /Change keybinding/i
+ })
+ const removeItem = page.getByRole('menuitem', {
+ name: /Remove keybinding/i
+ })
+
+ await expect(changeItem).toHaveAttribute('data-disabled', '')
+ await expect(removeItem).toHaveAttribute('data-disabled', '')
+
+ await page.keyboard.press('Escape')
+ })
+ })
+
+ test.describe('Action Buttons', () => {
+ test('Edit button opens edit dialog for single-binding command', async ({
+ comfyPage
+ }) => {
+ const { page } = comfyPage
+
+ await searchKeybindings(page, SINGLE_BINDING_COMMAND)
+ const row = getCommandRow(page, SINGLE_BINDING_COMMAND)
+
+ const editButton = row.getByRole('button', { name: /^Edit$/i })
+ await expect(editButton).toBeVisible()
+ await editButton.click()
+
+ const input = getKeybindingInput(page)
+ await expect(input).toBeVisible()
+
+ await cancelAndCloseDialog(page)
+ })
+
+ test('Add button opens add dialog', async ({ comfyPage }) => {
+ const { page } = comfyPage
+
+ await searchKeybindings(page, SINGLE_BINDING_COMMAND)
+ const row = getCommandRow(page, SINGLE_BINDING_COMMAND)
+
+ await row.getByRole('button', { name: /Add new keybinding/i }).click()
+
+ const input = getKeybindingInput(page)
+ await expect(input).toBeVisible()
+
+ await cancelAndCloseDialog(page)
+ })
+
+ test('Reset button is disabled for unmodified commands', async ({
+ comfyPage
+ }) => {
+ const { page } = comfyPage
+
+ await searchKeybindings(page, SINGLE_BINDING_COMMAND)
+ const row = getCommandRow(page, SINGLE_BINDING_COMMAND)
+
+ const resetButton = row.getByRole('button', { name: /Reset/i })
+ await expect(resetButton).toBeDisabled()
+ })
+
+ test('Reset button resets modified keybinding', async ({ comfyPage }) => {
+ const { page } = comfyPage
+
+ await searchKeybindings(page, SINGLE_BINDING_COMMAND)
+ const row = getCommandRow(page, SINGLE_BINDING_COMMAND)
+
+ await addKeybindingToRow(page, row, 'Control+Shift+F11')
+
+ const resetButton = row.getByRole('button', { name: /Reset/i })
+ await expect(resetButton).toBeEnabled()
+
+ await resetButton.click()
+
+ await expect(resetButton).toBeDisabled()
+ })
+
+ test('Delete button is disabled for commands with 0 keybindings', async ({
+ comfyPage
+ }) => {
+ const { page } = comfyPage
+
+ await searchKeybindings(page, NO_BINDING_COMMAND)
+ const row = getCommandRow(page, NO_BINDING_COMMAND)
+
+ const deleteButton = row.getByRole('button', { name: /Delete/i })
+ await expect(deleteButton).toBeDisabled()
+ })
+
+ test('Delete button removes single keybinding directly', async ({
+ comfyPage
+ }) => {
+ const { page } = comfyPage
+
+ await searchKeybindings(page, NO_BINDING_COMMAND)
+ const row = getCommandRow(page, NO_BINDING_COMMAND)
+
+ await addKeybindingToRow(page, row, 'Control+Shift+F12')
+
+ const deleteButton = row.getByRole('button', { name: /Delete/i })
+ await expect(deleteButton).toBeEnabled()
+ await deleteButton.click()
+
+ await expect(row.locator('td').nth(1)).toContainText('-')
+ })
+
+ test('Delete button on command with 2+ keybindings shows confirm dialog', async ({
+ comfyPage
+ }) => {
+ const { page } = comfyPage
+
+ await searchKeybindings(page, MULTI_BINDING_COMMAND)
+ const row = getCommandRow(page, MULTI_BINDING_COMMAND)
+
+ const deleteButton = row.getByRole('button', { name: /Delete/i })
+ await deleteButton.click()
+
+ const confirmDialog = getRemoveAllKeybindingsDialog(page)
+ await expect(confirmDialog).toBeVisible()
+
+ await confirmDialog.getByRole('button', { name: /Cancel/i }).click()
+ await expect(confirmDialog).toBeHidden()
+ await expect(row.locator('td').nth(1)).not.toContainText('-')
+ })
+ })
+
+ test.describe('Expanded Row Actions', () => {
+ test('Edit button in expanded row opens edit dialog for that binding', async ({
+ comfyPage
+ }) => {
+ const { page } = comfyPage
+
+ await searchKeybindings(page, MULTI_BINDING_COMMAND)
+
+ await page.locator(`[title="${MULTI_BINDING_COMMAND}"]`).click()
+ const expansionContent = getExpansionContent(page, MULTI_BINDING_COMMAND)
+ await expect(expansionContent).toBeVisible()
+
+ const firstBindingRow = expansionContent
+ .getByTestId('keybinding-expansion-binding')
+ .first()
+ await firstBindingRow.getByRole('button', { name: /^Edit$/i }).click()
+
+ const input = getKeybindingInput(page)
+ await expect(input).toBeVisible()
+
+ await cancelAndCloseDialog(page)
+ })
+
+ test('Delete button in expanded row removes that binding and collapses', async ({
+ comfyPage
+ }) => {
+ const { page } = comfyPage
+
+ await searchKeybindings(page, MULTI_BINDING_COMMAND)
+
+ await page.locator(`[title="${MULTI_BINDING_COMMAND}"]`).click()
+ const expansionContent = getExpansionContent(page, MULTI_BINDING_COMMAND)
+ await expect(expansionContent).toBeVisible()
+
+ const bindingRows = expansionContent.getByTestId(
+ 'keybinding-expansion-binding'
+ )
+ await expect
+ .poll(() => bindingRows.count(), {
+ message: 'Expected at least 2 bindings'
+ })
+ .toBeGreaterThanOrEqual(2)
+ const initialBindingCount = await bindingRows.count()
+
+ await bindingRows
+ .first()
+ .getByRole('button', { name: /Remove keybinding/i })
+ .click()
+
+ if (initialBindingCount === 2) {
+ // Expansion auto-collapses when bindings drop below 2
+ await expect(expansionContent).toBeHidden()
+ } else {
+ await expect(bindingRows).toHaveCount(initialBindingCount - 1)
+ }
+ })
+ })
+
+ test.describe('Reset All', () => {
+ test('Reset All button shows confirmation and resets on confirm', async ({
+ comfyPage
+ }) => {
+ const { page } = comfyPage
+
+ await searchKeybindings(page, SINGLE_BINDING_COMMAND)
+ const row = getCommandRow(page, SINGLE_BINDING_COMMAND)
+ await addKeybindingToRow(page, row, 'Control+Shift+F8')
+
+ await expect(row.getByRole('button', { name: /Reset/i })).toBeEnabled()
+
+ await clearSearch(page)
+
+ const resetAllButton = page
+ .locator('.keybinding-panel')
+ .getByRole('button', { name: /Reset All/i })
+ await resetAllButton.click()
+
+ const confirmDialog = getResetAllKeybindingsDialog(page)
+ await expect(confirmDialog).toBeVisible()
+ await expect(confirmDialog).toContainText(/Reset all keybindings/i)
+
+ await confirmDialog.getByRole('button', { name: /Reset All/i }).click()
+
+ await expect(comfyPage.toast.visibleToasts).toHaveCount(1)
+
+ await searchKeybindings(page, SINGLE_BINDING_COMMAND)
+ const rowAfterReset = getCommandRow(page, SINGLE_BINDING_COMMAND)
+ await expect(
+ rowAfterReset.getByRole('button', { name: /Reset/i })
+ ).toBeDisabled()
+ })
+
+ test('Reset All confirmation can be cancelled', async ({ comfyPage }) => {
+ const { page } = comfyPage
+
+ const resetAllButton = page
+ .locator('.keybinding-panel')
+ .getByRole('button', { name: /Reset All/i })
+ await resetAllButton.click()
+
+ const confirmDialog = getResetAllKeybindingsDialog(page)
+ await expect(confirmDialog).toBeVisible()
+ await confirmDialog.getByRole('button', { name: /Cancel/i }).click()
+
+ await expect(confirmDialog).toBeHidden()
+ })
+ })
+
+ test.describe('Search Filter', () => {
+ test('Typing in search clears expanded rows', async ({ comfyPage }) => {
+ const { page } = comfyPage
+
+ await searchKeybindings(page, MULTI_BINDING_COMMAND)
+
+ await page.locator(`[title="${MULTI_BINDING_COMMAND}"]`).click()
+ const expansionContent = getExpansionContent(page, MULTI_BINDING_COMMAND)
+ await expect(expansionContent).toBeVisible()
+
+ // Changing the filter triggers watch(filters, ...) which clears expansion
+ await searchKeybindings(page, MULTI_BINDING_COMMAND + ' ')
+ await expect(expansionContent).toBeHidden()
+ })
+ })
+})
diff --git a/browser_tests/tests/loadWorkflowInMedia.spec.ts-snapshots/workflow-webm-chromium-linux.png b/browser_tests/tests/loadWorkflowInMedia.spec.ts-snapshots/workflow-webm-chromium-linux.png
index c7be8c99e4..f617ff8b15 100644
Binary files a/browser_tests/tests/loadWorkflowInMedia.spec.ts-snapshots/workflow-webm-chromium-linux.png and b/browser_tests/tests/loadWorkflowInMedia.spec.ts-snapshots/workflow-webm-chromium-linux.png differ
diff --git a/browser_tests/tests/metadataWorkflowImport.spec.ts b/browser_tests/tests/metadataWorkflowImport.spec.ts
new file mode 100644
index 0000000000..ef2efb91e0
--- /dev/null
+++ b/browser_tests/tests/metadataWorkflowImport.spec.ts
@@ -0,0 +1,62 @@
+import {
+ comfyPageFixture as test,
+ comfyExpect as expect
+} from '@e2e/fixtures/ComfyPage'
+import { metadataFixturePath } from '@e2e/fixtures/utils/paths'
+
+type MetadataFixture = {
+ fileName: string
+ parser: string
+}
+
+// Each fixture embeds the same single-KSampler workflow (see
+// scripts/generate-embedded-metadata-test-files.py), exercising a different
+// parser in src/scripts/metadata/. Dropping the file should import that
+// workflow.
+const FIXTURES: readonly MetadataFixture[] = [
+ { fileName: 'with_metadata.png', parser: 'png' },
+ { fileName: 'with_metadata.avif', parser: 'avif' },
+ { fileName: 'with_metadata.webp', parser: 'webp' },
+ { fileName: 'with_metadata_exif_prefix.webp', parser: 'webp (exif prefix)' },
+ { fileName: 'with_metadata.flac', parser: 'flac' },
+ { fileName: 'with_metadata.mp3', parser: 'mp3' },
+ { fileName: 'with_metadata.opus', parser: 'ogg' },
+ { fileName: 'with_metadata.mp4', parser: 'isobmff' },
+ { fileName: 'with_metadata.webm', parser: 'ebml (webm)' }
+] as const
+
+test.describe(
+ 'Metadata drop-to-load workflow import',
+ { tag: ['@workflow'] },
+ () => {
+ test.beforeEach(async ({ comfyPage }) => {
+ await comfyPage.nodeOps.clearGraph()
+ await expect.poll(() => comfyPage.nodeOps.getGraphNodesCount()).toBe(0)
+ })
+
+ for (const { fileName, parser } of FIXTURES) {
+ test(`loads embedded workflow from ${fileName} (${parser})`, async ({
+ comfyPage
+ }) => {
+ await test.step(`drop ${fileName} on canvas`, async () => {
+ await comfyPage.dragDrop.dragAndDropFilePath(
+ metadataFixturePath(fileName)
+ )
+ })
+
+ await test.step('graph contains only the embedded KSampler', async () => {
+ await expect
+ .poll(() => comfyPage.nodeOps.getGraphNodesCount())
+ .toBe(1)
+
+ const ksamplers =
+ await comfyPage.nodeOps.getNodeRefsByType('KSampler')
+ expect(
+ ksamplers,
+ 'exactly one KSampler should have been loaded from the fixture'
+ ).toHaveLength(1)
+ })
+ })
+ }
+ }
+)
diff --git a/browser_tests/tests/nodeBadge.spec.ts b/browser_tests/tests/nodeBadge.spec.ts
index e33237be11..975225b389 100644
--- a/browser_tests/tests/nodeBadge.spec.ts
+++ b/browser_tests/tests/nodeBadge.spec.ts
@@ -8,6 +8,9 @@ test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled')
})
+const DEPRECATED_NODE_TYPE = 'ImageBatch'
+const API_NODE_TYPE = 'FluxProUltraImageNode'
+
test.describe('Node Badge', { tag: ['@screenshot', '@smoke', '@node'] }, () => {
test('Can add badge', async ({ comfyPage }) => {
await comfyPage.page.evaluate(() => {
@@ -141,3 +144,73 @@ test.describe(
})
}
)
+
+for (const vueEnabled of [false, true] as const) {
+ const renderer = vueEnabled ? 'vue' : 'classic'
+ const tag = vueEnabled
+ ? ['@vue-nodes', '@screenshot', '@node']
+ : ['@screenshot', '@node']
+
+ test.describe(`Node lifecycle badge (${renderer})`, { tag }, () => {
+ test.beforeEach(async ({ comfyPage }) => {
+ await comfyPage.settings.setSetting('Comfy.Graph.CanvasInfo', false)
+ })
+
+ for (const mode of [NodeBadgeMode.ShowAll, NodeBadgeMode.None] as const) {
+ test(`renders deprecated node with mode=${mode}`, async ({
+ comfyPage
+ }) => {
+ await comfyPage.settings.setSetting(
+ 'Comfy.NodeBadge.NodeLifeCycleBadgeMode',
+ mode
+ )
+ await comfyPage.nodeOps.clearGraph()
+ await comfyPage.nodeOps.addNode(DEPRECATED_NODE_TYPE, undefined, {
+ x: 100,
+ y: 100
+ })
+ await comfyPage.canvasOps.resetView()
+ await expect(comfyPage.canvas).toHaveScreenshot(
+ `node-lifecycle-${mode}-${renderer}.png`
+ )
+ })
+ }
+ })
+
+ test.describe(`API pricing badge (${renderer})`, { tag }, () => {
+ test.beforeEach(async ({ comfyPage }) => {
+ await comfyPage.settings.setSetting('Comfy.Graph.CanvasInfo', false)
+ await comfyPage.page.evaluate((type) => {
+ const registered = window.LiteGraph!.registered_node_types[type] as {
+ nodeData?: { price_badge?: unknown }
+ }
+ if (!registered?.nodeData) throw new Error(`No nodeData for ${type}`)
+ registered.nodeData.price_badge = {
+ engine: 'jsonata',
+ expr: "{'type': 'text', 'text': '99.9 credits/Run'}",
+ depends_on: { widgets: [], inputs: [], input_groups: [] }
+ }
+ }, API_NODE_TYPE)
+ })
+
+ for (const enabled of [true, false] as const) {
+ test(`renders api node with showApiPricing=${enabled}`, async ({
+ comfyPage
+ }) => {
+ await comfyPage.settings.setSetting(
+ 'Comfy.NodeBadge.ShowApiPricing',
+ enabled
+ )
+ await comfyPage.nodeOps.clearGraph()
+ await comfyPage.nodeOps.addNode(API_NODE_TYPE, undefined, {
+ x: 100,
+ y: 100
+ })
+ await comfyPage.canvasOps.resetView()
+ await expect(comfyPage.canvas).toHaveScreenshot(
+ `api-pricing-${enabled ? 'on' : 'off'}-${renderer}.png`
+ )
+ })
+ }
+ })
+}
diff --git a/browser_tests/tests/nodeBadge.spec.ts-snapshots/api-pricing-off-classic-chromium-linux.png b/browser_tests/tests/nodeBadge.spec.ts-snapshots/api-pricing-off-classic-chromium-linux.png
new file mode 100644
index 0000000000..e60b605005
Binary files /dev/null and b/browser_tests/tests/nodeBadge.spec.ts-snapshots/api-pricing-off-classic-chromium-linux.png differ
diff --git a/browser_tests/tests/nodeBadge.spec.ts-snapshots/api-pricing-off-vue-chromium-linux.png b/browser_tests/tests/nodeBadge.spec.ts-snapshots/api-pricing-off-vue-chromium-linux.png
new file mode 100644
index 0000000000..cbc87ffc7c
Binary files /dev/null and b/browser_tests/tests/nodeBadge.spec.ts-snapshots/api-pricing-off-vue-chromium-linux.png differ
diff --git a/browser_tests/tests/nodeBadge.spec.ts-snapshots/api-pricing-on-classic-chromium-linux.png b/browser_tests/tests/nodeBadge.spec.ts-snapshots/api-pricing-on-classic-chromium-linux.png
new file mode 100644
index 0000000000..e151d56bc0
Binary files /dev/null and b/browser_tests/tests/nodeBadge.spec.ts-snapshots/api-pricing-on-classic-chromium-linux.png differ
diff --git a/browser_tests/tests/nodeBadge.spec.ts-snapshots/api-pricing-on-vue-chromium-linux.png b/browser_tests/tests/nodeBadge.spec.ts-snapshots/api-pricing-on-vue-chromium-linux.png
new file mode 100644
index 0000000000..e0f46d3791
Binary files /dev/null and b/browser_tests/tests/nodeBadge.spec.ts-snapshots/api-pricing-on-vue-chromium-linux.png differ
diff --git a/browser_tests/tests/nodeBadge.spec.ts-snapshots/node-lifecycle-None-classic-chromium-linux.png b/browser_tests/tests/nodeBadge.spec.ts-snapshots/node-lifecycle-None-classic-chromium-linux.png
new file mode 100644
index 0000000000..7820eff13a
Binary files /dev/null and b/browser_tests/tests/nodeBadge.spec.ts-snapshots/node-lifecycle-None-classic-chromium-linux.png differ
diff --git a/browser_tests/tests/nodeBadge.spec.ts-snapshots/node-lifecycle-None-vue-chromium-linux.png b/browser_tests/tests/nodeBadge.spec.ts-snapshots/node-lifecycle-None-vue-chromium-linux.png
new file mode 100644
index 0000000000..e0edd2135a
Binary files /dev/null and b/browser_tests/tests/nodeBadge.spec.ts-snapshots/node-lifecycle-None-vue-chromium-linux.png differ
diff --git a/browser_tests/tests/nodeBadge.spec.ts-snapshots/node-lifecycle-Show-all-classic-chromium-linux.png b/browser_tests/tests/nodeBadge.spec.ts-snapshots/node-lifecycle-Show-all-classic-chromium-linux.png
new file mode 100644
index 0000000000..6ee2971450
Binary files /dev/null and b/browser_tests/tests/nodeBadge.spec.ts-snapshots/node-lifecycle-Show-all-classic-chromium-linux.png differ
diff --git a/browser_tests/tests/nodeBadge.spec.ts-snapshots/node-lifecycle-Show-all-vue-chromium-linux.png b/browser_tests/tests/nodeBadge.spec.ts-snapshots/node-lifecycle-Show-all-vue-chromium-linux.png
new file mode 100644
index 0000000000..7f679259f8
Binary files /dev/null and b/browser_tests/tests/nodeBadge.spec.ts-snapshots/node-lifecycle-Show-all-vue-chromium-linux.png differ
diff --git a/browser_tests/tests/painter.spec.ts b/browser_tests/tests/painter.spec.ts
index e8aa977055..aa0fd38444 100644
--- a/browser_tests/tests/painter.spec.ts
+++ b/browser_tests/tests/painter.spec.ts
@@ -692,19 +692,27 @@ test.describe('Painter', { tag: ['@widget', '@vue-nodes'] }, () => {
})
})
- test('Controls collapse to single column in compact mode', async ({
+ test('Controls stack label above widget in compact mode', async ({
comfyPage
}) => {
const painterWidget = comfyPage.vueNodes
.getNodeLocator('1')
.locator('.widget-expands')
const toolLabel = painterWidget.getByText('Tool', { exact: true })
+ const brushButton = painterWidget.getByText('Brush', { exact: true })
await expect(
toolLabel,
- 'tool label should be visible in two-column layout'
+ 'tool label should be visible in wide layout'
).toBeVisible()
+ const wideLabelBox = await toolLabel.boundingBox()
+ const wideBrushBox = await brushButton.boundingBox()
+ expect(
+ wideLabelBox && wideBrushBox && wideLabelBox.x < wideBrushBox.x,
+ 'label should sit to the left of the brush button in wide layout'
+ ).toBe(true)
+
await comfyPage.page.evaluate(() => {
const graph = window.graph as TestGraphAccess | undefined
const node = graph?._nodes_by_id?.['1']
@@ -716,8 +724,22 @@ test.describe('Painter', { tag: ['@widget', '@vue-nodes'] }, () => {
await expect(
toolLabel,
- 'tool label should hide in compact single-column layout'
- ).toBeHidden()
+ 'tool label should remain visible in compact layout'
+ ).toBeVisible()
+
+ await expect
+ .poll(
+ async () => {
+ const labelBox = await toolLabel.boundingBox()
+ const brushBox = await brushButton.boundingBox()
+ if (!labelBox || !brushBox) return false
+ return labelBox.y + labelBox.height <= brushBox.y
+ },
+ {
+ message: 'label should stack above the brush button in compact layout'
+ }
+ )
+ .toBe(true)
})
test('Multiple sequential strokes at different positions all accumulate', async ({
diff --git a/browser_tests/tests/performance.spec.ts b/browser_tests/tests/performance.spec.ts
index 2988653fe3..3882fa61cd 100644
--- a/browser_tests/tests/performance.spec.ts
+++ b/browser_tests/tests/performance.spec.ts
@@ -351,6 +351,45 @@ test.describe('Performance', { tag: ['@perf'] }, () => {
})
})
+ test(
+ 'subgraph transition (enter and exit)',
+ { tag: ['@vue-nodes'] },
+ async ({ comfyPage }, testInfo) => {
+ // Heaviest perf test: loads an 80-node subgraph and pays ~30s/repeat.
+ // The signal is dominated by N=80 mount cost, so a single sample per
+ // CI invocation is sufficient — early-return on subsequent repeats.
+ if (testInfo.repeatEachIndex > 0) return
+
+ // Load workflow with a subgraph containing 80 interior nodes.
+ // Entering the subgraph unmounts root nodes and mounts all 80 interior
+ // nodes synchronously — this is the bottleneck we're measuring.
+ await comfyPage.workflow.loadWorkflow('subgraphs/large-subgraph-80-nodes')
+
+ await comfyPage.idleFrames(30)
+
+ await comfyPage.vueNodes.enterSubgraph()
+ await comfyPage.vueNodes.waitForNodes(80)
+ await comfyPage.idleFrames(30)
+
+ // Exit back to root graph before measuring a fresh enter/exit cycle
+ await comfyPage.subgraph.exitViaBreadcrumb()
+ await comfyPage.idleFrames(10)
+
+ // Start measuring the enter transition
+ await comfyPage.perf.startMeasuring()
+
+ await comfyPage.vueNodes.enterSubgraph()
+ await comfyPage.vueNodes.waitForNodes(80)
+ await comfyPage.idleFrames(30)
+
+ const m = await comfyPage.perf.stopMeasuring('subgraph-transition-enter')
+ recordMeasurement(m)
+ console.log(
+ `Subgraph enter (80 nodes): ${m.taskDurationMs.toFixed(0)}ms task, ${m.layouts} layouts, TBT=${m.totalBlockingTimeMs.toFixed(0)}ms`
+ )
+ }
+ )
+
test('workflow execution', async ({ comfyPage }) => {
// Uses lightweight PrimitiveString → PreviewAny workflow (no GPU needed)
await comfyPage.workflow.loadWorkflow('execution/partial_execution')
diff --git a/browser_tests/tests/previewAsText.spec.ts b/browser_tests/tests/previewAsText.spec.ts
new file mode 100644
index 0000000000..8d8338119e
--- /dev/null
+++ b/browser_tests/tests/previewAsText.spec.ts
@@ -0,0 +1,42 @@
+import {
+ comfyPageFixture as test,
+ comfyExpect as expect
+} from '../fixtures/ComfyPage'
+
+test.describe('Preview as Text node', () => {
+ test('does not include preview widget values in the API prompt', async ({
+ comfyPage
+ }) => {
+ await comfyPage.page.evaluate(() => {
+ const node = window.LiteGraph!.createNode('PreviewAny')!
+ node.pos = [500, 200]
+ window.app!.graph.add(node)
+ })
+
+ // Simulate a previous execution: backend returned text and the frontend
+ // populated the preview widget values. The next prompt submission must
+ // NOT echo those values back as inputs (which would change the cache
+ // signature and trigger a redundant re-execution).
+ await comfyPage.page.evaluate(() => {
+ const node = window.app!.graph.nodes.find((n) => n.type === 'PreviewAny')!
+ for (const widget of node.widgets ?? []) {
+ if (widget.name?.startsWith('preview_')) {
+ widget.value = 'rendered preview content from previous execution'
+ }
+ }
+ })
+
+ const apiWorkflow = await comfyPage.workflow.getExportedWorkflow({
+ api: true
+ })
+
+ const previewEntry = Object.values(apiWorkflow).find(
+ (n) => n.class_type === 'PreviewAny'
+ )
+ expect(previewEntry).toBeDefined()
+
+ expect(previewEntry!.inputs).not.toHaveProperty('preview_markdown')
+ expect(previewEntry!.inputs).not.toHaveProperty('preview_text')
+ expect(previewEntry!.inputs).not.toHaveProperty('previewMode')
+ })
+})
diff --git a/browser_tests/tests/propertiesPanel/errorsTabCloudMissingModels.spec.ts b/browser_tests/tests/propertiesPanel/errorsTabCloudMissingModels.spec.ts
new file mode 100644
index 0000000000..a2bdc77bf6
--- /dev/null
+++ b/browser_tests/tests/propertiesPanel/errorsTabCloudMissingModels.spec.ts
@@ -0,0 +1,93 @@
+import { expect } from '@playwright/test'
+
+import type { Asset } from '@comfyorg/ingest-types'
+import {
+ countAssetRequestsByTag,
+ createCloudAssetsFixture
+} from '@e2e/fixtures/assetApiFixture'
+import { TestIds } from '@e2e/fixtures/selectors'
+import { PropertiesPanelHelper } from '@e2e/tests/propertiesPanel/PropertiesPanelHelper'
+
+const WORKFLOW = 'missing/nested_subgraph_installed_model'
+const OUTER_SUBGRAPH_NODE_ID = '205'
+const LOTUS_MODEL_NAME = 'lotus-depth-d-v1-1.safetensors'
+
+const LOTUS_DIFFUSION_MODEL: Asset = {
+ id: 'test-lotus-depth-d-v1-1',
+ name: LOTUS_MODEL_NAME,
+ asset_hash:
+ 'blake3:0000000000000000000000000000000000000000000000000000000000000203',
+ size: 1_024,
+ mime_type: 'application/octet-stream',
+ tags: ['models', 'diffusion_models'],
+ created_at: '2026-05-05T00:00:00Z',
+ updated_at: '2026-05-05T00:00:00Z',
+ last_access_time: '2026-05-05T00:00:00Z',
+ user_metadata: {
+ filename: LOTUS_MODEL_NAME
+ }
+}
+
+const test = createCloudAssetsFixture([LOTUS_DIFFUSION_MODEL])
+
+test.describe(
+ 'Errors tab - Cloud missing models',
+ { tag: ['@cloud', '@vue-nodes'] },
+ () => {
+ test.beforeEach(async ({ comfyPage }) => {
+ await comfyPage.settings.setSetting(
+ 'Comfy.RightSidePanel.ShowErrorsTab',
+ true
+ )
+ })
+
+ test('keeps installed models resolved after returning from a nested subgraph', async ({
+ cloudAssetRequests,
+ comfyPage
+ }) => {
+ await comfyPage.workflow.loadWorkflow(WORKFLOW)
+
+ const panel = new PropertiesPanelHelper(comfyPage.page)
+ const errorOverlay = comfyPage.page.getByTestId(
+ TestIds.dialogs.errorOverlay
+ )
+ const errorsTab = panel.root.getByTestId(
+ TestIds.propertiesPanel.errorsTab
+ )
+
+ await expect
+ .poll(
+ () => countAssetRequestsByTag(cloudAssetRequests, 'diffusion_models'),
+ { timeout: 10_000 }
+ )
+ .toBeGreaterThan(0)
+ await expect(errorOverlay).toBeHidden()
+ await panel.open(comfyPage.actionbar.propertiesButton)
+ await expect(errorsTab).toBeHidden()
+ await panel.close()
+
+ await comfyPage.vueNodes.enterSubgraph(OUTER_SUBGRAPH_NODE_ID)
+ await expect.poll(() => comfyPage.subgraph.isInSubgraph()).toBe(true)
+ await expect(errorOverlay).toBeHidden()
+
+ const requestCountBeforeRootReturn = countAssetRequestsByTag(
+ cloudAssetRequests,
+ 'diffusion_models'
+ )
+
+ await comfyPage.subgraph.exitViaBreadcrumb()
+ await panel.open(comfyPage.actionbar.propertiesButton)
+
+ await expect
+ .poll(
+ () =>
+ countAssetRequestsByTag(cloudAssetRequests, 'diffusion_models') >
+ requestCountBeforeRootReturn,
+ { timeout: 10_000 }
+ )
+ .toBe(true)
+
+ await expect(errorsTab).toBeHidden()
+ })
+ }
+)
diff --git a/browser_tests/tests/queueButtonModes.spec.ts b/browser_tests/tests/queueButtonModes.spec.ts
new file mode 100644
index 0000000000..03fa580651
--- /dev/null
+++ b/browser_tests/tests/queueButtonModes.spec.ts
@@ -0,0 +1,63 @@
+import { expect } from '@playwright/test'
+
+import type { PromptResponse } from '@/schemas/apiSchema'
+
+import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
+
+const queueModeLabels = ['Run', 'Run (On Change)', 'Run (Instant)']
+const runOnChangeLabel = queueModeLabels[1]
+
+test.describe('Queue button modes', { tag: '@ui' }, () => {
+ test('Run button is visible in topbar', async ({ comfyPage }) => {
+ await expect(comfyPage.actionbar.queueButton.primaryButton).toBeVisible()
+ })
+
+ test('Queue mode trigger menu is visible', async ({ comfyPage }) => {
+ await expect(comfyPage.actionbar.queueButton.dropdownButton).toBeVisible()
+ })
+
+ test('Clicking queue mode trigger opens mode menu', async ({ comfyPage }) => {
+ const options = await comfyPage.actionbar.queueButton.openOptions()
+
+ await expect(options.menu).toBeVisible()
+ })
+
+ test('Queue mode menu shows available modes', async ({ comfyPage }) => {
+ const options = await comfyPage.actionbar.queueButton.openOptions()
+
+ await expect(options.menu).toBeVisible()
+ await expect(options.modeItems).toHaveText(queueModeLabels)
+ })
+
+ test('Selecting a non-default mode updates the Run button label', async ({
+ comfyPage
+ }) => {
+ const queueButton = comfyPage.actionbar.queueButton
+ const options = await queueButton.openOptions()
+
+ await expect(options.menu).toBeVisible()
+ await options.selectMode(runOnChangeLabel)
+
+ await expect(queueButton.primaryButton).toContainText(runOnChangeLabel)
+ })
+
+ test('Run button sends prompt when clicked', async ({ comfyPage }) => {
+ let promptQueued = false
+ const mockResponse: PromptResponse = {
+ prompt_id: 'test-id',
+ node_errors: {},
+ error: ''
+ }
+ await comfyPage.page.route('**/api/prompt', async (route) => {
+ promptQueued = true
+ await route.fulfill({
+ status: 200,
+ body: JSON.stringify(mockResponse)
+ })
+ })
+
+ await comfyPage.actionbar.queueButton.primaryButton.click()
+
+ await expect.poll(() => promptQueued).toBe(true)
+ })
+})
diff --git a/browser_tests/tests/saveImageAndWebp.spec.ts b/browser_tests/tests/saveImageAndWebp.spec.ts
index 36357fa042..ac4507c215 100644
--- a/browser_tests/tests/saveImageAndWebp.spec.ts
+++ b/browser_tests/tests/saveImageAndWebp.spec.ts
@@ -16,7 +16,7 @@ test.describe(
await comfyPage.runButton.click()
const saveImageNode = comfyPage.vueNodes.getNodeByTitle('Save Image')
- const saveWebmNode = comfyPage.vueNodes.getNodeByTitle('SaveWEBM')
+ const saveWebmNode = comfyPage.vueNodes.getNodeByTitle('Save WEBM')
// Wait for SaveImage to render an img inside .image-preview
await expect(saveImageNode.locator('.image-preview img')).toBeVisible({
diff --git a/browser_tests/tests/saveImageAndWebp.spec.ts-snapshots/save-image-and-webm-preview-chromium-linux.png b/browser_tests/tests/saveImageAndWebp.spec.ts-snapshots/save-image-and-webm-preview-chromium-linux.png
index 16ec205bea..20cfac2ef1 100644
Binary files a/browser_tests/tests/saveImageAndWebp.spec.ts-snapshots/save-image-and-webm-preview-chromium-linux.png and b/browser_tests/tests/saveImageAndWebp.spec.ts-snapshots/save-image-and-webm-preview-chromium-linux.png differ
diff --git a/browser_tests/tests/subgraph/subgraphPromotion.spec.ts b/browser_tests/tests/subgraph/subgraphPromotion.spec.ts
index 91657e4d50..9eb349a0dc 100644
--- a/browser_tests/tests/subgraph/subgraphPromotion.spec.ts
+++ b/browser_tests/tests/subgraph/subgraphPromotion.spec.ts
@@ -535,6 +535,75 @@ test.describe(
.poll(() => getPromotedWidgetCount(comfyPage, '11'))
.toBeLessThan(initialWidgetCount)
})
+
+ test('Does not cleanup unconfigured Primitive', async ({ comfyPage }) => {
+ await comfyPage.workflow.loadWorkflow(
+ 'subgraphs/subgraph-with-link-and-proxied-primitive'
+ )
+ await expect
+ .poll(
+ () => getPromotedWidgetCount(comfyPage, '2'),
+ 'Primitive widget is restored on load'
+ )
+ .toBe(2)
+
+ await comfyPage.page.evaluate(() => app!.canvas.setDirty(true))
+ const subgraphNode = await comfyPage.nodeOps.getFirstNodeRef()
+ const promotedPrimitive = await subgraphNode!.getWidget(1)
+ await expect
+ .poll(
+ () => promotedPrimitive.getValue(),
+ 'Primitive widget is not in a disconnected state'
+ )
+ .toBe(0)
+ })
})
+
+ test.fail(
+ 'Promoted text widget is removed when source node is deleted inside the subgraph',
+ { tag: '@vue-nodes' },
+ async ({ comfyPage }) => {
+ await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
+
+ const clipFixture = await comfyPage.vueNodes.getFixtureByTitle(
+ 'CLIP Text Encode (Prompt)'
+ )
+ await comfyPage.contextMenu.openForVueNode(clipFixture.header)
+ await comfyPage.contextMenu.clickMenuItemExact('Convert to Subgraph')
+
+ const subgraphNode = comfyPage.vueNodes
+ .getNodeByTitle('New Subgraph')
+ .first()
+ await expect(subgraphNode).toBeVisible()
+
+ const subgraphNodeId =
+ await comfyPage.vueNodes.getNodeIdByTitle('New Subgraph')
+
+ await expect
+ .poll(() => getPromotedWidgetNames(comfyPage, subgraphNodeId))
+ .toContain('text')
+ await expect(
+ subgraphNode.getByTestId(TestIds.widgets.domWidgetTextarea)
+ ).toBeVisible()
+
+ await comfyPage.vueNodes.enterSubgraph(subgraphNodeId)
+ await expect.poll(() => comfyPage.subgraph.isInSubgraph()).toBe(true)
+ await comfyPage.vueNodes.waitForNodes()
+
+ const interiorClip = await comfyPage.vueNodes.getFixtureByTitle(
+ 'CLIP Text Encode (Prompt)'
+ )
+ await interiorClip.delete()
+
+ await comfyPage.subgraph.exitViaBreadcrumb()
+
+ const subgraphNodeAfter =
+ comfyPage.vueNodes.getNodeLocator(subgraphNodeId)
+ await expect(subgraphNodeAfter).toBeVisible()
+ await expect(
+ subgraphNodeAfter.getByTestId(TestIds.widgets.domWidgetTextarea)
+ ).toBeHidden()
+ }
+ )
}
)
diff --git a/browser_tests/tests/topbar/workflowTabs.spec.ts b/browser_tests/tests/topbar/workflowTabs.spec.ts
index 264a94dad3..34954d3b3f 100644
--- a/browser_tests/tests/topbar/workflowTabs.spec.ts
+++ b/browser_tests/tests/topbar/workflowTabs.spec.ts
@@ -1,4 +1,5 @@
import { expect } from '@playwright/test'
+import type { Locator, Page } from '@playwright/test'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
@@ -125,6 +126,48 @@ test.describe('Workflow tabs', () => {
await expect(activeTab.locator('text=•')).toBeVisible()
})
+ test('Can drag tab to end', async ({ comfyPage }) => {
+ const topbar = comfyPage.menu.topbar
+
+ await topbar.newWorkflowButton.click()
+ await topbar.newWorkflowButton.click()
+ await expect.poll(() => topbar.getTabNames()).toHaveLength(3)
+ const [a, b, c] = await topbar.getTabNames()
+
+ await topbar.getTab(0).dragTo(topbar.getTab(2))
+
+ await expect.poll(() => topbar.getTabNames()).toEqual([b, c, a])
+ })
+
+ test('Can drag tab to start', async ({ comfyPage }) => {
+ const topbar = comfyPage.menu.topbar
+
+ await topbar.newWorkflowButton.click()
+ await topbar.newWorkflowButton.click()
+ await expect.poll(() => topbar.getTabNames()).toHaveLength(3)
+ const [a, b, c] = await topbar.getTabNames()
+
+ await topbar.getTab(2).dragTo(topbar.getTab(0))
+
+ await expect.poll(() => topbar.getTabNames()).toEqual([c, a, b])
+ })
+
+ test('Drag preserves active tab', async ({ comfyPage }) => {
+ const topbar = comfyPage.menu.topbar
+
+ await topbar.newWorkflowButton.click()
+ await topbar.newWorkflowButton.click()
+ await expect.poll(() => topbar.getTabNames()).toHaveLength(3)
+
+ const [, b] = await topbar.getTabNames()
+ await topbar.getTab(1).click()
+ await expect.poll(() => topbar.getActiveTabName()).toContain(b)
+
+ await topbar.getTab(0).dragTo(topbar.getTab(2))
+
+ await expect.poll(() => topbar.getActiveTabName()).toContain(b)
+ })
+
test('Multiple tabs can be created, switched, and closed', async ({
comfyPage
}) => {
@@ -146,4 +189,79 @@ test.describe('Workflow tabs', () => {
await topbar.closeWorkflowTab('Unsaved Workflow (2)')
await expect.poll(() => topbar.getTabNames()).toHaveLength(2)
})
+
+ test.describe('Closing a modified workflow tab (FE-419)', () => {
+ async function modifyActiveWorkflow(page: Page, activeTab: Locator) {
+ await page.evaluate(() => {
+ const graph = window.app?.graph
+ const node = window.LiteGraph?.createNode('Note')
+ if (graph && node) graph.add(node)
+ })
+ await expect(
+ activeTab.getByTestId('workflow-dirty-indicator')
+ ).toHaveCount(1)
+ }
+
+ test('shows "Close anyway" label and no Cancel button on dirtyClose dialog', async ({
+ comfyPage
+ }) => {
+ const topbar = comfyPage.menu.topbar
+
+ await topbar.newWorkflowButton.click()
+ await expect.poll(() => topbar.getTabNames()).toHaveLength(2)
+
+ await modifyActiveWorkflow(comfyPage.page, topbar.getActiveTab())
+ await topbar.closeWorkflowTab('Unsaved Workflow (2)')
+
+ const dialog = comfyPage.page.getByRole('dialog')
+ await expect(dialog).toBeVisible()
+ await expect(
+ dialog.getByRole('button', { name: 'Close anyway' })
+ ).toBeVisible()
+ await expect(dialog.getByRole('button', { name: 'Save' })).toBeVisible()
+ await expect(dialog.getByRole('button', { name: 'Cancel' })).toHaveCount(
+ 0
+ )
+ })
+
+ test('clicking "Close anyway" closes the tab without saving', async ({
+ comfyPage
+ }) => {
+ const topbar = comfyPage.menu.topbar
+
+ await topbar.newWorkflowButton.click()
+ await expect.poll(() => topbar.getTabNames()).toHaveLength(2)
+
+ await modifyActiveWorkflow(comfyPage.page, topbar.getActiveTab())
+ await topbar.closeWorkflowTab('Unsaved Workflow (2)')
+
+ await comfyPage.page
+ .getByRole('dialog')
+ .getByRole('button', { name: 'Close anyway' })
+ .click()
+
+ await expect.poll(() => topbar.getTabNames()).toHaveLength(1)
+ await expect
+ .poll(() => topbar.getActiveTabName())
+ .toContain('Unsaved Workflow')
+ })
+
+ test('dismissing the dialog keeps the modified tab open', async ({
+ comfyPage
+ }) => {
+ const topbar = comfyPage.menu.topbar
+
+ await topbar.newWorkflowButton.click()
+ await expect.poll(() => topbar.getTabNames()).toHaveLength(2)
+
+ await modifyActiveWorkflow(comfyPage.page, topbar.getActiveTab())
+ await topbar.closeWorkflowTab('Unsaved Workflow (2)')
+
+ await expect(comfyPage.page.getByRole('dialog')).toBeVisible()
+ await comfyPage.page.keyboard.press('Escape')
+ await expect(comfyPage.page.getByRole('dialog')).toBeHidden()
+
+ await expect.poll(() => topbar.getTabNames()).toHaveLength(2)
+ })
+ })
})
diff --git a/browser_tests/tests/vueNodes/interactions/node/imagePreview.spec.ts b/browser_tests/tests/vueNodes/interactions/node/imagePreview.spec.ts
index 0f22d70569..4050cafaff 100644
--- a/browser_tests/tests/vueNodes/interactions/node/imagePreview.spec.ts
+++ b/browser_tests/tests/vueNodes/interactions/node/imagePreview.spec.ts
@@ -21,9 +21,8 @@ test.describe('Vue Nodes Image Preview', { tag: '@vue-nodes' }, () => {
})
const nodeId = String(loadImageNode.id)
- const imagePreview = comfyPage.vueNodes
- .getNodeLocator(nodeId)
- .locator('.image-preview')
+ const { imagePreview } =
+ await comfyPage.vueNodes.getFixtureByTitle('Load Image')
await expect(imagePreview).toBeVisible()
await expect(imagePreview.locator('img')).toBeVisible({ timeout: 30_000 })
@@ -44,6 +43,25 @@ test.describe('Vue Nodes Image Preview', { tag: '@vue-nodes' }, () => {
await expect(comfyPage.page.locator('.mask-editor-dialog')).toBeVisible()
})
+ test('hides mask and download buttons when image is missing', async ({
+ comfyPage
+ }) => {
+ await comfyPage.workflow.loadWorkflow(
+ 'widgets/load_image_widget_missing_file'
+ )
+
+ const { imagePreview } =
+ await comfyPage.vueNodes.getFixtureByTitle('Load Image')
+
+ await expect(imagePreview).toBeVisible()
+ await expect(imagePreview.getByTestId('error-loading-image')).toBeVisible()
+
+ await imagePreview.getByRole('region').hover()
+
+ await expect(imagePreview.getByLabel('Edit or mask image')).toHaveCount(0)
+ await expect(imagePreview.getByLabel('Download image')).toHaveCount(0)
+ })
+
test('shows image context menu options', async ({ comfyPage }) => {
const { nodeId } = await loadImageOnNode(comfyPage)
diff --git a/browser_tests/tests/vueNodes/interactions/node/move.spec.ts b/browser_tests/tests/vueNodes/interactions/node/move.spec.ts
index 53642662f5..af0ce5761c 100644
--- a/browser_tests/tests/vueNodes/interactions/node/move.spec.ts
+++ b/browser_tests/tests/vueNodes/interactions/node/move.spec.ts
@@ -1,3 +1,5 @@
+import type { Locator } from '@playwright/test'
+
import {
comfyExpect as expect,
comfyPageFixture as test
@@ -39,6 +41,19 @@ test.describe('Vue Node Moving', { tag: '@vue-nodes' }, () => {
expect(Math.abs(a.y - b.y)).toBeLessThanOrEqual(tol)
}
+ const dragFromTabButton = async (comfyPage: ComfyPage, button: Locator) => {
+ const box = await button.boundingBox()
+ if (!box) throw new Error('Tab button has no bounding box')
+ const start = {
+ x: box.x + box.width / 2,
+ y: box.y + box.height * 0.75
+ }
+ await comfyPage.canvasOps.dragAndDrop(start, {
+ x: start.x + 120,
+ y: start.y + 80
+ })
+ }
+
test('should allow moving nodes by dragging', async ({ comfyPage }) => {
const loadCheckpointHeaderPos = await getLoadCheckpointHeaderPos(comfyPage)
await comfyPage.canvasOps.dragAndDrop(loadCheckpointHeaderPos, {
@@ -90,6 +105,63 @@ test.describe('Vue Node Moving', { tag: '@vue-nodes' }, () => {
await expectPosChanged(headerPos, afterPos)
})
+ test('should not toggle advanced inputs when dragging by the Advanced button', async ({
+ comfyPage
+ }) => {
+ await comfyPage.settings.setSetting(
+ 'Comfy.Node.AlwaysShowAdvancedWidgets',
+ false
+ )
+ await comfyPage.nodeOps.addNode(
+ 'ModelSamplingFlux',
+ {},
+ {
+ x: 500,
+ y: 200
+ }
+ )
+ await comfyPage.vueNodes.waitForNodes()
+
+ const node = comfyPage.vueNodes.getNodeByTitle('ModelSamplingFlux')
+ const showButton = node.getByText('Show advanced inputs')
+ const widgets = node.locator('.lg-node-widget')
+
+ await expect(showButton).toBeVisible()
+ await expect(widgets).toHaveCount(2)
+
+ const beforePos = await node.boundingBox()
+ if (!beforePos) throw new Error('Node has no bounding box')
+
+ await dragFromTabButton(comfyPage, showButton)
+
+ await expect(showButton).toBeVisible()
+ await expect(node.getByText('Hide advanced inputs')).toBeHidden()
+ await expect(widgets).toHaveCount(2)
+
+ const afterPos = await node.boundingBox()
+ if (!afterPos) throw new Error('Node missing after drag')
+ await expectPosChanged(beforePos, afterPos)
+ })
+
+ test('should not enter subgraph when dragging by the Enter Subgraph button', async ({
+ comfyPage
+ }) => {
+ await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
+
+ const subgraphNode = await comfyPage.nodeOps.getNodeRefById('2')
+ const beforePos = await subgraphNode.getPosition()
+
+ await dragFromTabButton(
+ comfyPage,
+ comfyPage.vueNodes.getSubgraphEnterButton('2')
+ )
+
+ expect(await comfyPage.subgraph.isInSubgraph()).toBe(false)
+
+ const afterPos = await subgraphNode.getPosition()
+ await expectPosChanged(beforePos, afterPos)
+ })
+
test('should move all selected nodes together when dragging one with Meta held', async ({
comfyPage
}) => {
diff --git a/browser_tests/tests/vueNodes/nodeStates/error.spec.ts b/browser_tests/tests/vueNodes/nodeStates/error.spec.ts
index 926d5e2594..f64d1e1376 100644
--- a/browser_tests/tests/vueNodes/nodeStates/error.spec.ts
+++ b/browser_tests/tests/vueNodes/nodeStates/error.spec.ts
@@ -13,6 +13,7 @@ import {
ExecutionHelper,
buildKSamplerError
} from '@e2e/fixtures/helpers/ExecutionHelper'
+import { fitToViewInstant } from '@e2e/fixtures/utils/fitToView'
import { webSocketFixture } from '@e2e/fixtures/ws'
const test = mergeTests(comfyPageFixture, webSocketFixture)
@@ -20,6 +21,7 @@ const test = mergeTests(comfyPageFixture, webSocketFixture)
const ERROR_CLASS = /ring-destructive-background/
const UNKNOWN_NODE_ID = '1'
const INNER_EXECUTION_ID = '2:1'
+const KSAMPLER_MODEL_INPUT_NAME = 'model'
test.describe('Vue Node Error', { tag: '@vue-nodes' }, () => {
test('should display error state when node is missing (node from workflow is not installed)', async ({
@@ -71,6 +73,59 @@ test.describe('Vue Node Error', { tag: '@vue-nodes' }, () => {
).toHaveClass(ERROR_CLASS)
})
+ test(
+ 'highlights the missing required input slot',
+ { tag: ['@screenshot', '@node'] },
+ async ({ comfyPage }) => {
+ const ksamplerId = await comfyPage.vueNodes.getNodeIdByTitle('KSampler')
+ const ksamplerNode = comfyPage.vueNodes.getNodeLocator(ksamplerId)
+ const modelInputIndex = await comfyPage.page.evaluate(
+ ({ nodeId, inputName }) => {
+ const node = window.app!.graph.getNodeById(nodeId)
+ const index =
+ node?.inputs?.findIndex((input) => input.name === inputName) ?? -1
+ if (index < 0) {
+ throw new Error(`Input slot "${inputName}" not found`)
+ }
+ return index
+ },
+ { nodeId: ksamplerId, inputName: KSAMPLER_MODEL_INPUT_NAME }
+ )
+ const modelInputSlotRow = comfyPage.vueNodes.getInputSlotRow(
+ ksamplerId,
+ modelInputIndex
+ )
+ const modelInputSlotHighlight =
+ comfyPage.vueNodes.getInputSlotConnectionDot(
+ ksamplerId,
+ modelInputIndex
+ )
+ const exec = new ExecutionHelper(comfyPage)
+ await exec.mockValidationFailure({
+ [ksamplerId]: buildKSamplerError(
+ 'required_input_missing',
+ KSAMPLER_MODEL_INPUT_NAME,
+ `Required input is missing: ${KSAMPLER_MODEL_INPUT_NAME}`
+ )
+ })
+
+ await comfyPage.runButton.click()
+ await dismissErrorOverlay(comfyPage)
+ await fitToViewInstant(comfyPage)
+
+ await expect(modelInputSlotRow).toBeVisible()
+ await expect(modelInputSlotRow).toBeInViewport()
+ await expect(modelInputSlotHighlight).toHaveClass(/before:ring-error/)
+ await expect(
+ comfyPage.vueNodes.getNodeInnerWrapper(ksamplerId)
+ ).toHaveClass(ERROR_CLASS)
+ await comfyPage.expectScreenshot(
+ ksamplerNode,
+ 'vue-node-required-input-missing-slot-error.png'
+ )
+ }
+ )
+
test('clears error ring when user edits an out-of-range number widget back into range', async ({
comfyPage
}) => {
diff --git a/browser_tests/tests/vueNodes/nodeStates/error.spec.ts-snapshots/vue-node-required-input-missing-slot-error-chromium-linux.png b/browser_tests/tests/vueNodes/nodeStates/error.spec.ts-snapshots/vue-node-required-input-missing-slot-error-chromium-linux.png
new file mode 100644
index 0000000000..3222d1a6c2
Binary files /dev/null and b/browser_tests/tests/vueNodes/nodeStates/error.spec.ts-snapshots/vue-node-required-input-missing-slot-error-chromium-linux.png differ
diff --git a/browser_tests/tests/wsReconnectStaleJob.spec.ts b/browser_tests/tests/wsReconnectStaleJob.spec.ts
new file mode 100644
index 0000000000..d67d404c7d
--- /dev/null
+++ b/browser_tests/tests/wsReconnectStaleJob.spec.ts
@@ -0,0 +1,211 @@
+import type { WebSocketRoute } from '@playwright/test'
+import { mergeTests } from '@playwright/test'
+import type { z } from 'zod'
+
+import {
+ comfyExpect as expect,
+ comfyPageFixture
+} from '@e2e/fixtures/ComfyPage'
+import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
+import { ExecutionHelper } from '@e2e/fixtures/helpers/ExecutionHelper'
+import { webSocketFixture } from '@e2e/fixtures/ws'
+import type {
+ RawJobListItem,
+ zJobsListResponse
+} from '@/platform/remote/comfyui/jobs/jobTypes'
+
+type JobsListResponse = z.infer
+
+const test = mergeTests(comfyPageFixture, webSocketFixture)
+
+const KSAMPLER_NODE = '3'
+const EXECUTING_CLASS = /outline-node-stroke-executing/
+
+const QUEUE_ROUTE = /\/api\/jobs\?[^/]*status=in_progress,pending/
+const HISTORY_ROUTE = /\/api\/jobs\?[^/]*status=completed/
+
+function jobsResponse(jobs: RawJobListItem[]): JobsListResponse {
+ return {
+ jobs,
+ pagination: { offset: 0, limit: 200, total: jobs.length, has_more: false }
+ }
+}
+
+async function mockJobsRoute(
+ comfyPage: ComfyPage,
+ pattern: RegExp,
+ body: string,
+ status: number = 200
+): Promise<() => number> {
+ let count = 0
+ await comfyPage.page.route(pattern, async (route) => {
+ count += 1
+ await route.fulfill({
+ status,
+ contentType: 'application/json',
+ body
+ })
+ })
+ return () => count
+}
+
+const emptyJobsBody = JSON.stringify(jobsResponse([]))
+
+type Scenario = {
+ name: string
+ /** Built per-test so it can incorporate the runtime-assigned jobId. */
+ queueBody: (jobId: string) => string
+ /** Whether the active job state should still be reflected after reconnect. */
+ expectsActiveAfter: boolean
+}
+
+const scenarios: Scenario[] = [
+ {
+ name: 'clears stale active job when queue is empty after reconnect',
+ queueBody: () => emptyJobsBody,
+ expectsActiveAfter: false
+ },
+ {
+ name: 'preserves active job when the job is still in the queue',
+ queueBody: (jobId) =>
+ JSON.stringify(
+ jobsResponse([
+ { id: jobId, status: 'in_progress', create_time: Date.now() }
+ ])
+ ),
+ expectsActiveAfter: true
+ }
+]
+
+/**
+ * Stub the queue/history endpoints per `scenario`, close the WS, and wait
+ * for the auto-reconnect to issue a fresh queue fetch.
+ */
+async function triggerReconnect(
+ comfyPage: ComfyPage,
+ ws: WebSocketRoute,
+ scenario: Scenario,
+ jobId: string
+): Promise {
+ await mockJobsRoute(comfyPage, HISTORY_ROUTE, emptyJobsBody)
+ const queueFetches = await mockJobsRoute(
+ comfyPage,
+ QUEUE_ROUTE,
+ scenario.queueBody(jobId)
+ )
+ const fetchesBeforeClose = queueFetches()
+ await ws.close()
+ await expect.poll(queueFetches).toBeGreaterThan(fetchesBeforeClose)
+}
+
+test.describe('WebSocket reconnect with stale job', { tag: '@ui' }, () => {
+ test.describe('app mode skeleton', () => {
+ test.beforeEach(async ({ comfyPage }) => {
+ await comfyPage.appMode.enterAppModeWithInputs([[KSAMPLER_NODE, 'seed']])
+ await expect(comfyPage.appMode.linearWidgets).toBeVisible()
+ })
+
+ for (const scenario of scenarios) {
+ test(scenario.name, async ({ comfyPage, getWebSocket }) => {
+ const ws = await getWebSocket()
+ const exec = new ExecutionHelper(comfyPage, ws)
+
+ const jobId = await exec.run()
+ exec.executionStart(jobId)
+
+ // Skeleton visibility is the deterministic sync point: it appears
+ // once both `storeJob` (HTTP) and `executionStart` (WS) have been
+ // processed, regardless of arrival order.
+ const firstSkeleton = comfyPage.appMode.outputHistory.skeletons.first()
+ await expect(firstSkeleton).toBeVisible()
+
+ await triggerReconnect(comfyPage, ws, scenario, jobId)
+
+ if (scenario.expectsActiveAfter) {
+ await expect(firstSkeleton).toBeVisible()
+ } else {
+ await expect(comfyPage.appMode.outputHistory.skeletons).toHaveCount(0)
+ }
+ })
+ }
+
+ test('preserves active job when the queue endpoint fails on reconnect', async ({
+ comfyPage,
+ getWebSocket
+ }) => {
+ const ws = await getWebSocket()
+ const exec = new ExecutionHelper(comfyPage, ws)
+
+ const jobId = await exec.run()
+ exec.executionStart(jobId)
+
+ const firstSkeleton = comfyPage.appMode.outputHistory.skeletons.first()
+ await expect(firstSkeleton).toBeVisible()
+
+ await mockJobsRoute(comfyPage, HISTORY_ROUTE, emptyJobsBody)
+
+ // Prime queueStore.runningTasks with the active job — a WS status
+ // event drives GraphView.onStatus -> queueStore.update().
+ const primer = await mockJobsRoute(
+ comfyPage,
+ QUEUE_ROUTE,
+ JSON.stringify(
+ jobsResponse([
+ { id: jobId, status: 'in_progress', create_time: Date.now() }
+ ])
+ )
+ )
+ exec.status(1)
+ await expect.poll(primer).toBeGreaterThanOrEqual(1)
+
+ // Swap to a failing handler so the reconnect-driven fetch 500s.
+ // The fix should preserve runningTasks from the priming call rather
+ // than overwriting it with empty/error state.
+ await comfyPage.page.unroute(QUEUE_ROUTE)
+ const failed = await mockJobsRoute(comfyPage, QUEUE_ROUTE, '{}', 500)
+
+ const before = failed()
+ await ws.close()
+ await expect.poll(failed).toBeGreaterThan(before)
+
+ await expect(firstSkeleton).toBeVisible()
+ })
+ })
+
+ test.describe('vue node executing class', { tag: '@vue-nodes' }, () => {
+ for (const scenario of scenarios) {
+ test(scenario.name, async ({ comfyPage, getWebSocket }) => {
+ const ws = await getWebSocket()
+ const exec = new ExecutionHelper(comfyPage, ws)
+
+ // The executing outline lives on the outer `[data-node-id]`
+ // container, not the inner wrapper.
+ const ksamplerNode = comfyPage.vueNodes.getNodeLocator(KSAMPLER_NODE)
+ await expect(ksamplerNode).toBeVisible()
+
+ const jobId = await exec.run()
+ exec.executionStart(jobId)
+ exec.progressState(jobId, {
+ [KSAMPLER_NODE]: {
+ value: 0,
+ max: 1,
+ state: 'running',
+ node_id: KSAMPLER_NODE,
+ display_node_id: KSAMPLER_NODE,
+ prompt_id: jobId
+ }
+ })
+
+ await expect(ksamplerNode).toHaveClass(EXECUTING_CLASS)
+
+ await triggerReconnect(comfyPage, ws, scenario, jobId)
+
+ if (scenario.expectsActiveAfter) {
+ await expect(ksamplerNode).toHaveClass(EXECUTING_CLASS)
+ } else {
+ await expect(ksamplerNode).not.toHaveClass(EXECUTING_CLASS)
+ }
+ })
+ }
+ })
+})
diff --git a/docs/adr/0008-entity-component-system.md b/docs/adr/0008-entity-component-system.md
index 42b427f0f6..36233903a0 100644
--- a/docs/adr/0008-entity-component-system.md
+++ b/docs/adr/0008-entity-component-system.md
@@ -249,6 +249,7 @@ Companion architecture documents that expand on the design in this ADR:
| [ECS Lifecycle Scenarios](../architecture/ecs-lifecycle-scenarios.md) | Before/after walkthroughs of lifecycle operations (node removal, link creation, etc.) |
| [World API and Command Layer](../architecture/ecs-world-command-api.md) | How each lifecycle scenario maps to a command in the World API |
| [Subgraph Boundaries and Widget Promotion](../architecture/subgraph-boundaries-and-promotion.md) | Design rationale for modeling subgraphs as node components, not separate entities |
+| [ADR 0009: Subgraph promoted widgets](0009-subgraph-promoted-widgets-use-linked-inputs.md) | Follow-up decision for promoted widget identity and value ownership at subgraph boundaries |
| [Appendix: Critical Analysis](../architecture/appendix-critical-analysis.md) | Independent verification of the accuracy of the architecture documents |
| [Change Tracker](../architecture/change-tracker.md) | Documents the current undo/redo system that ECS cross-cutting concerns will replace |
diff --git a/docs/adr/0009-subgraph-promoted-widgets-use-linked-inputs.md b/docs/adr/0009-subgraph-promoted-widgets-use-linked-inputs.md
new file mode 100644
index 0000000000..daec5aacfb
--- /dev/null
+++ b/docs/adr/0009-subgraph-promoted-widgets-use-linked-inputs.md
@@ -0,0 +1,328 @@
+# 9. Subgraph promoted widgets use linked inputs
+
+Date: 2026-05-05
+
+Appendices:
+
+- [Before/after flow diagrams](./0009-subgraph-promoted-widgets-use-linked-inputs/before-after-flows.md)
+- [System comparison](./0009-subgraph-promoted-widgets-use-linked-inputs/system-comparison.md)
+- [Removing `disambiguatingSourceNodeId`](./0009-subgraph-promoted-widgets-use-linked-inputs/disambiguating-source-node-id.md)
+
+## Status
+
+Proposed
+
+## Context
+
+Subgraph widget promotion historically had two overlapping representations:
+
+1. `properties.proxyWidgets`, a serialized list of source node/widget tuples;
+2. linked subgraph inputs, where an interior widget-bearing input is exposed
+ through the subgraph boundary.
+
+This created ambiguous ownership. Runtime value reads could collapse to an
+interior source widget, while host `widgets_values` could also carry an
+exterior value. Multiple host instances of the same subgraph could therefore
+stomp one another, and serialization could mutate interior widgets as a
+persistence carrier for exterior values.
+
+The ECS widget migration makes that ambiguity more expensive: widgets are
+becoming entities with component state keyed by stable entity identity, and
+subgraphs are modeled as graph boundary structure rather than a separate
+promotion-specific entity kind.
+
+## Decision
+
+Promoted widgets are represented only as standard linked `SubgraphInput`
+widgets. A promoted widget is a host-scoped widget entity owned by a subgraph
+input on a host `SubgraphNode`. The interior source widget supplies schema,
+type, options, tooltip, and default metadata, but it is not the owner of the
+host value.
+
+Display-only preview surfacing, such as `$$canvas-image-preview`, is not a
+promoted widget. It is a separate preview-exposure system because it has no
+host-owned widget value, does not feed prompt serialization, and often points at
+virtual `serialize: false` pseudo-widgets that may not exist on the source node.
+
+`properties.proxyWidgets` becomes a legacy load-time input only. Successful
+repair consumes entries from `proxyWidgets`; canonical saves do not re-emit
+those entries. The standard serialized representation is the existing subgraph
+interface/input form plus host-node `widgets_values`.
+
+Display-only preview exposures use their own host-node-scoped serialized entry,
+`properties.previewExposures`, instead of `properties.proxyWidgets` and instead
+of linked `SubgraphInput` widgets. Canonical preview-exposure JSON uses preview
+language, not widget language:
+
+```ts
+type PreviewExposure = {
+ name: string
+ sourceNodeId: string
+ sourcePreviewName: string
+}
+```
+
+Host-node scope preserves current behavior where different instances of the
+same subgraph can choose different exposed previews.
+
+The entry intentionally stores only host preview identity and source locator
+identity. `name` is the host-scoped stable identity for this preview exposure,
+analogous to `SubgraphInput.name`; it is not a display label. It is generated
+with existing collision behavior, such as `nextUniqueName(...)`, when an
+exposure is created. Media type, display labels, titles, image/video/audio URLs,
+and other runtime preview details are derived from the current graph and output
+state. Array order is the canonical display order. Preview exposures do not get
+a separate persisted `label` in this slice; if a future rename UX needs one, it
+should follow the same rule as subgraph inputs: `name` is identity and `label`
+is display-only.
+
+Preview exposures are persisted user choices after creation. Packing nodes into
+a subgraph may auto-add recommended preview exposures for supported output
+nodes, and users may explicitly add or remove additional preview exposures
+afterward. Normal load/save does not re-derive previews from node type alone,
+because that would make old workflows change when support for new preview node
+types is added. Unresolved preview exposures remain persisted and inert;
+automatic cleanup does not prune them. They are removed only by explicit user
+action or by destruction/unpacking of the owning host.
+
+Preview exposures compose through nested subgraph hosts by chaining immediate
+boundaries. If an outer subgraph wants to show a preview exposed by an inner
+subgraph host, the outer `previewExposures` entry points at the immediate inner
+`SubgraphNode`, and `sourcePreviewName` names the inner host's preview-exposure
+identity, not the deepest interior preview name. Runtime preview resolution may
+then follow the inner host's own preview exposures to find media. Canonical JSON
+does not persist flattened deep paths, because deep paths would couple host UI
+state to private nested graph internals.
+
+## Identity and value ownership
+
+- UI/value identity is host-scoped: host node locator plus
+ `SubgraphInput.name`.
+- Host-scoped identity means the host `SubgraphNode` instance within its
+ containing `graphScope`; the interior source node is not the state or
+ persistence owner.
+- `SubgraphInput.name` is the stable internal identity.
+- `SubgraphInput.label` / `localized_name` are display-only.
+- `SubgraphInput.id` may be used for slot-instance reconciliation, not as the
+ persisted widget value key.
+- Source node/widget identity remains metadata for diagnostics, missing-model
+ lookup, schema projection, and migration only.
+- The host/exterior value wins over the interior/source value during repair,
+ persistence, and prompt serialization.
+
+This follows the existing widget/slot convention: `name` is identity, `label`
+is display.
+
+Promoted-widget value state is a host-scoped sparse overlay over source-widget
+metadata and defaults. The source widget remains the schema/default provider;
+host value state is materialized only when the exterior value differs from the
+effective source default or when restored from persisted host state. Canonical
+save/load must not eagerly mirror source defaults or use interior widgets as
+persistence carriers.
+
+## Forward migration
+
+Loading a workflow with legacy `proxyWidgets` runs a one-way repair:
+
+1. Parse `properties.proxyWidgets` with the existing Zod-inferred tuple type.
+2. Invalid raw `proxyWidgets` data logs `console.error`, does not throw, and is
+ not quarantined.
+3. Build a multi-pass association map before mutation:
+ - normalized legacy proxy entry;
+ - projected legacy promoted-widget order;
+ - host `widgets_values` value, preserving sparse holes;
+ - repair strategy or failure reason;
+ - whether the entry is a value widget or display-only preview exposure.
+4. Defer mutations until node IDs/entity IDs are stable and the subgraph graph
+ is configured.
+5. On flush, re-resolve against current graph state, because clone/paste/load
+ flows may have remapped or created nodes and links.
+6. If already represented by a linked `SubgraphInput`, consider the legacy
+ entry resolved and consume it.
+7. Otherwise repair through existing subgraph input/link systems.
+8. If the entry is display-only preview surfacing, migrate it into the separate
+ preview-exposure representation instead of creating a linked `SubgraphInput`.
+9. If value-widget repair fails, write inert quarantine metadata and warn.
+
+The repair is idempotent. Pending plans store tuple/value data and re-check the
+current graph before applying mutations.
+
+Legacy entries are classified as preview exposures when either:
+
+- the legacy source name starts with `$$`; or
+- the source node resolves to a matching pseudo-preview widget, such as a
+ `serialize: false` preview/video/audio UI widget.
+
+Everything else is treated as a value-widget promotion candidate. An unresolved
+preview-shaped entry remains inert at runtime and is still persisted, because
+preview-capable pseudo-widgets and output media can be removed and re-added
+dynamically. It is not quarantined because it has no user value to preserve. A
+non-`$$` entry that cannot resolve to a source widget is a value-widget repair
+failure and follows the quarantine path unless it can resolve to a
+pseudo-preview widget.
+
+## Proxy widget error quarantine
+
+Valid legacy entries that cannot be repaired are persisted in
+`properties.proxyWidgetErrorQuarantine`. Quarantined entries are inert: they do
+not hydrate runtime promoted widgets, do not participate in execution, and are
+not used for app-mode/favorites identity.
+
+Quarantine entries preserve enough information to avoid data loss and support
+future tooling:
+
+```ts
+type ProxyWidgetErrorQuarantineEntry = {
+ originalEntry: ProxyWidgetTuple
+ reason:
+ | 'missingSourceNode'
+ | 'missingSourceWidget'
+ | 'missingSubgraphInput'
+ | 'ambiguousSubgraphInput'
+ | 'unlinkedSourceWidget'
+ | 'primitiveBypassFailed'
+ hostValue?: TWidgetValue
+ attemptedAtVersion: 1
+}
+```
+
+Unresolved legacy UI selections/favorites are dropped with `console.warn`.
+Workflow-level promotion/value intent is preserved by
+`proxyWidgetErrorQuarantine`, not by a second UI quarantine format.
+
+## Primitive-node repair
+
+Legacy `proxyWidgets` may point at `PrimitiveNode` outputs. Primitive nodes
+serve nearly the same purpose as subgraph inputs: they provide a widget value to
+one or more target widget inputs. The migration repairs this expected legacy
+shape in the first migration rather than quarantining it by default.
+
+Primitive repair:
+
+- coalesces exact duplicate legacy entries during planning;
+- uses the primitive node's user title as the base input name when the node was
+ renamed, otherwise the primitive output widget name;
+- applies existing naming behavior and `nextUniqueName(...)` for collisions;
+- uses the existing primitive merge/config compatibility logic;
+- creates one `SubgraphInput` for the primitive fanout;
+- reconnects every former primitive output target to that input in target
+ order, using standard connect/disconnect APIs;
+- applies the host value when one exists, otherwise seeds from the source
+ primitive value;
+- leaves the primitive node and its widget value in place, but disconnected and
+ inert.
+
+Primitive repair is all-or-quarantine. If any target cannot be validated or
+reconnected, the migration does not leave a partial rewrite; it quarantines the
+entry with `hostValue` and logs the reason.
+
+## Serialization
+
+After repair/quarantine:
+
+- `properties.proxyWidgets` is omitted for repaired entries;
+- display-only preview entries are omitted from `properties.proxyWidgets` and
+ emitted through `properties.previewExposures`;
+- `properties.proxyWidgetErrorQuarantine` carries unrepaired valid entries;
+- preview exposures do not carry quarantine values because they do not own user
+ values; unresolved preview exposures remain inert in `previewExposures`;
+- host `widgets_values` contains host-owned values only for canonical host
+ widgets, not source-owned defaults or interior persistence copies;
+- quarantined legacy values live in `proxyWidgetErrorQuarantine.hostValue`;
+- array-form `widgets_values` remains for now.
+
+Preview exposures are display-only UI metadata. They drive host canvas/app-mode
+preview rendering, but they do not create prompt inputs, do not create
+`widgets_values`, do not alter node execution order, do not become executable
+graph edges, and do not participate in prompt serialization. Runtime mapping
+from backend `display_node`/output messages to a host preview exposure is a UI
+projection only.
+
+The old `SubgraphNode.serialize()` behavior that copied exterior promoted
+values into connected interior widgets is removed. A temporary TODO should mark
+that removal point until the migration is proven stable. Host values are
+serialized through standard subgraph-input widgets instead.
+
+Longer term, `widgets_values` should move from array order to an object/map
+keyed by stable widget name, but that migration is out of scope for this
+decision.
+
+## App mode, builder, and favorites
+
+The runtime migration and UI identity migration ship in the same slice. The UI
+must not persist promoted selections by source node/widget identity after this
+change.
+
+Canonical UI identity is:
+
+```ts
+type PromotedWidgetUiIdentity = {
+ hostNodeLocator: string
+ subgraphInputName: string
+}
+```
+
+Legacy source-identity selections are migrated when they resolve through the
+standard input created or confirmed by the migration. Unresolved selections are
+dropped with a warning.
+
+Preview exposure output selections are also host-scoped and must not persist
+interior source node identity. Canonical preview/output identity is:
+
+```ts
+type PreviewExposureUiIdentity = {
+ hostNodeLocator: string
+ previewName: string
+}
+```
+
+The UI references the explicit preview exposure itself. This keeps subgraphs
+opaque: consumers select the host boundary contract, not the interior node that
+currently supplies media. Legacy output selections that refer to interior
+preview source nodes may migrate if they resolve to a preview-exposure chain;
+otherwise they are dropped with `console.warn`. There is no separate preview UI
+quarantine.
+
+## PromotionStore
+
+`PromotionStore` becomes vestigial. It may remain temporarily as a derived
+runtime compatibility/index layer for existing consumers, but it is not
+serialized authority, must not create promotions without linked
+`SubgraphInput`s, and should be removed once consumers query the standard graph
+interface directly.
+
+## Considered options
+
+### Keep `proxyWidgets` as canonical serialized topology
+
+Rejected. This preserves two representations for the same concept and keeps
+source-widget identity in the value-ownership path.
+
+### Preserve bare promoted widgets as degraded runtime state
+
+Rejected. This would avoid some migration complexity, but it perpetuates the
+ambiguity that caused host/source value bugs and makes ECS identity less clear.
+
+### Quarantine primitive-node promotions by default
+
+Rejected. Primitive-node proxy promotions are expected legacy workflows, and
+quarantining them would break users unnecessarily. They are repaired by bypassing
+the primitive node when the repair can be validated all-or-nothing.
+
+### Migrate `widgets_values` to object/map form now
+
+Rejected for this slice. Name-keyed object form is the desired long-term
+direction, but combining it with the promotion migration increases blast radius
+for existing workflow consumers that still assume array order.
+
+## Consequences
+
+- Promoted widget values become host-instance-owned and ECS-compatible.
+- Source widgets remain metadata/default providers, not persistence carriers.
+- Legacy workflows are repaired toward one standard representation.
+- Quarantine preserves unrepaired valid legacy data without reintroducing bare
+ runtime promotion.
+- Primitive fanout repair is more complex, but avoids breaking common existing
+ workflows.
+- UI code must migrate with the runtime migration to avoid mixed identity states.
+- `PromotionStore` has a clear removal path.
diff --git a/docs/adr/0009-subgraph-promoted-widgets-use-linked-inputs/before-after-flows.md b/docs/adr/0009-subgraph-promoted-widgets-use-linked-inputs/before-after-flows.md
new file mode 100644
index 0000000000..2f8dcd7d08
--- /dev/null
+++ b/docs/adr/0009-subgraph-promoted-widgets-use-linked-inputs/before-after-flows.md
@@ -0,0 +1,210 @@
+# Appendix: Before and after flows
+
+This appendix visualizes the ownership and migration flows described in
+[ADR 0009](../0009-subgraph-promoted-widgets-use-linked-inputs.md).
+
+## Before: proxy widgets and linked inputs overlap
+
+Historically, promoted widgets could be represented both as serialized
+`properties.proxyWidgets` entries and as linked subgraph inputs. Runtime value
+reads could collapse back to the interior source widget, while host
+`widgets_values` could also carry an exterior value for the same promoted UI.
+
+```mermaid
+flowchart TD
+ workflow[Workflow JSON] --> proxyWidgets[properties.proxyWidgets]
+ workflow --> hostValues[host widgets_values]
+ proxyWidgets --> promotionStore[PromotionStore / promotion runtime]
+ promotionStore --> sourceWidget[Interior source widget]
+ linkedInput[Linked SubgraphInput] --> hostWidget[Host promoted widget]
+ sourceWidget --> hostWidget
+ hostValues --> hostWidget
+ hostWidget --> prompt[Prompt serialization]
+ hostWidget -. may copy value back .-> sourceWidget
+ sourceWidget -. shared by host instances .-> otherHost[Another host instance]
+
+ classDef legacy fill:#fff3cd,stroke:#a66f00,color:#332200
+ classDef ambiguous fill:#f8d7da,stroke:#842029,color:#330000
+ classDef canonical fill:#d1e7dd,stroke:#0f5132,color:#052e16
+
+ class proxyWidgets,promotionStore legacy
+ class sourceWidget,hostValues ambiguous
+ class linkedInput,hostWidget canonical
+```
+
+Key problems in the old flow:
+
+- `properties.proxyWidgets` and linked `SubgraphInput` widgets could describe
+ the same promotion.
+- Interior source widgets supplied both schema metadata and, in some flows,
+ persisted host values.
+- Multiple host instances of the same subgraph could stomp one another through
+ the shared interior widget value.
+- Display-only previews were mixed into widget-promotion language even though
+ they do not own values or feed prompt serialization.
+
+## After: linked inputs are the promoted-widget boundary
+
+Promoted value widgets are now represented only as standard linked
+`SubgraphInput` widgets. The source widget remains the schema/default provider,
+but the host `SubgraphNode` owns the promoted value.
+
+```mermaid
+flowchart TD
+ workflow[Workflow JSON] --> subgraphInterface[Subgraph interface / inputs]
+ workflow --> hostValues[host widgets_values]
+ subgraphInterface --> subgraphInput[SubgraphInput.name]
+ subgraphInput --> hostWidget[Host-scoped widget entity]
+ hostValues --> hostWidget
+ sourceWidget[Interior source widget] --> schema[Schema, type, options, tooltip, default]
+ schema --> hostWidget
+ hostWidget --> prompt[Prompt serialization]
+
+ hostIdentity[Host node locator + SubgraphInput.name] --> hostWidget
+ sourceWidget -. metadata only .-> diagnostics[Diagnostics / lookup / migration]
+ sourceWidget -. no host value ownership .-> schema
+
+ classDef owner fill:#d1e7dd,stroke:#0f5132,color:#052e16
+ classDef metadata fill:#cff4fc,stroke:#055160,color:#032830
+ classDef persisted fill:#e2e3e5,stroke:#41464b,color:#212529
+
+ class subgraphInterface,subgraphInput,hostWidget,hostIdentity owner
+ class sourceWidget,schema,diagnostics metadata
+ class workflow,hostValues persisted
+```
+
+Canonical ownership after the migration:
+
+- UI/value identity is host-scoped: host node locator plus
+ `SubgraphInput.name`.
+- `SubgraphInput.name` is stable identity; labels and localized names are
+ display-only.
+- Host values win during repair, persistence, and prompt serialization.
+- Source widgets provide metadata and defaults only.
+- Canonical saves omit repaired `properties.proxyWidgets` entries.
+
+## Legacy load migration
+
+Loading a workflow with legacy `proxyWidgets` performs an idempotent repair. The
+repair builds a plan before mutating graph state, then re-resolves against the
+current graph when node IDs and links are stable.
+
+```mermaid
+flowchart TD
+ start[Load workflow] --> parse{Parse properties.proxyWidgets}
+ parse -->|invalid raw data| invalid[console.error and ignore]
+ parse -->|valid tuples| plan[Build repair plan]
+ plan --> classify{Classify entry}
+
+ classify -->|value widget| valueRepair{Already linked SubgraphInput?}
+ valueRepair -->|yes| consume[Consume legacy proxy entry]
+ valueRepair -->|no| repair[Repair through subgraph input/link systems]
+ repair --> repairResult{Repair succeeded?}
+ repairResult -->|yes| consume
+ repairResult -->|no| quarantine[Persist proxyWidgetErrorQuarantine]
+
+ classify -->|primitive fanout| primitive[Validate all primitive targets]
+ primitive --> primitiveResult{All targets reconnectable?}
+ primitiveResult -->|yes| primitiveRepair[Create one SubgraphInput and reconnect fanout]
+ primitiveRepair --> consume
+ primitiveResult -->|no| quarantine
+
+ classify -->|display-only preview| preview[Create / keep previewExposures entry]
+ preview --> consume
+
+ consume --> save[Canonical save]
+ quarantine --> save
+ save --> omit[Omit repaired entries from proxyWidgets]
+ save --> keepQuarantine[Persist unrepaired value intent in quarantine]
+ save --> keepPreview[Persist previews in previewExposures]
+
+ classDef ok fill:#d1e7dd,stroke:#0f5132,color:#052e16
+ classDef warn fill:#fff3cd,stroke:#a66f00,color:#332200
+ classDef error fill:#f8d7da,stroke:#842029,color:#330000
+ classDef neutral fill:#e2e3e5,stroke:#41464b,color:#212529
+
+ class consume,repair,primitiveRepair,preview,save,omit,keepPreview ok
+ class plan,classify,valueRepair,primitive,primitiveResult,repairResult neutral
+ class quarantine,keepQuarantine warn
+ class invalid error
+```
+
+## Preview exposures are separate from value widgets
+
+Display-only previews, such as `$$canvas-image-preview`, are not promoted
+widgets. They have host-scoped serialized identity, but they do not create
+prompt inputs, do not create `widgets_values`, and do not own user values.
+
+```mermaid
+flowchart TD
+ hostNode[Host SubgraphNode] --> previewExposures[properties.previewExposures]
+ previewExposures --> exposure[PreviewExposure.name]
+ exposure --> sourceLocator[sourceNodeId + sourcePreviewName]
+ sourceLocator --> runtimePreview[Runtime preview/output state]
+ runtimePreview --> hostCanvas[Host canvas / app-mode preview]
+
+ exposure --> uiIdentity[hostNodeLocator + previewName]
+ runtimePreview -. UI projection only .-> hostCanvas
+ previewExposures -. no prompt input .-> noPrompt[No prompt serialization]
+ previewExposures -. no value widget .-> noValue[No widgets_values entry]
+ previewExposures -. no graph edge .-> noEdge[No executable graph edge]
+
+ classDef preview fill:#cff4fc,stroke:#055160,color:#032830
+ classDef noValue fill:#f8d7da,stroke:#842029,color:#330000
+ classDef persisted fill:#e2e3e5,stroke:#41464b,color:#212529
+
+ class previewExposures,exposure,sourceLocator,runtimePreview,hostCanvas,uiIdentity preview
+ class noPrompt,noValue,noEdge noValue
+ class hostNode persisted
+```
+
+For nested subgraphs, preview exposures chain across immediate host boundaries
+instead of persisting flattened deep paths.
+
+```mermaid
+flowchart LR
+ outerHost[Outer SubgraphNode] --> outerExposure[Outer previewExposures entry]
+ outerExposure --> innerHost[Immediate inner SubgraphNode]
+ innerHost --> innerExposure[Inner previewExposures entry]
+ innerExposure --> deepestPreview[Interior preview source]
+ deepestPreview --> media[Resolved media]
+
+ outerExposure -. sourcePreviewName names inner preview identity .-> innerExposure
+ outerExposure -. does not persist deep private path .-> opaque[Subgraph internals remain opaque]
+
+ classDef boundary fill:#d1e7dd,stroke:#0f5132,color:#052e16
+ classDef preview fill:#cff4fc,stroke:#055160,color:#032830
+ classDef note fill:#fff3cd,stroke:#a66f00,color:#332200
+
+ class outerHost,innerHost boundary
+ class outerExposure,innerExposure,deepestPreview,media preview
+ class opaque note
+```
+
+## Serialization summary
+
+```mermaid
+flowchart TD
+ canonical[Canonical serialized SubgraphNode] --> inputs[Subgraph interface / inputs]
+ canonical --> values[widgets_values for host-owned values]
+ canonical --> previews[properties.previewExposures]
+ canonical --> quarantine[properties.proxyWidgetErrorQuarantine]
+ canonical -. omits repaired entries .-> noProxy[No canonical proxyWidgets]
+
+ inputs --> valueWidgets[Promoted value widgets]
+ values --> valueWidgets
+ previews --> previewUi[Display-only preview UI]
+ quarantine --> futureTooling[Future recovery tooling]
+
+ valueWidgets --> prompt[Prompt serialization]
+ previewUi -. not serialized into prompt .-> prompt
+ quarantine -. inert .-> prompt
+
+ classDef canonical fill:#d1e7dd,stroke:#0f5132,color:#052e16
+ classDef inert fill:#fff3cd,stroke:#a66f00,color:#332200
+ classDef removed fill:#f8d7da,stroke:#842029,color:#330000
+
+ class inputs,values,valueWidgets,prompt,canonical canonical
+ class previews,previewUi,quarantine,futureTooling inert
+ class noProxy removed
+```
diff --git a/docs/adr/0009-subgraph-promoted-widgets-use-linked-inputs/disambiguating-source-node-id.md b/docs/adr/0009-subgraph-promoted-widgets-use-linked-inputs/disambiguating-source-node-id.md
new file mode 100644
index 0000000000..f9cb51b1f4
--- /dev/null
+++ b/docs/adr/0009-subgraph-promoted-widgets-use-linked-inputs/disambiguating-source-node-id.md
@@ -0,0 +1,147 @@
+# Appendix: Removing `disambiguatingSourceNodeId`
+
+This appendix explains where the existing promotion system needs
+`disambiguatingSourceNodeId`, why that need appears, and how the canonical form
+chosen by [ADR 0009](../0009-subgraph-promoted-widgets-use-linked-inputs.md)
+removes the pattern from promoted-widget identity.
+
+## Why the disambiguator exists
+
+The legacy promotion model identifies a promoted widget by source location:
+
+```ts
+type PromotedWidgetSource = {
+ sourceNodeId: string
+ sourceWidgetName: string
+ disambiguatingSourceNodeId?: string
+}
+```
+
+`sourceNodeId` is the immediate interior node visible from the host subgraph.
+That is not always the original widget owner. When promotions pass through
+nested subgraphs, two promoted widgets can have the same immediate
+`sourceNodeId` and `sourceWidgetName` while pointing at different leaf widgets.
+`disambiguatingSourceNodeId` carries the deepest source node ID so the runtime
+can choose the right promoted view.
+
+```mermaid
+flowchart TD
+ outerHost[Outer host SubgraphNode] --> middleNode[Interior middle SubgraphNode]
+ middleNode --> middleWidgetA[Promoted widget view: text]
+ middleNode --> middleWidgetB[Promoted widget view: text]
+ middleWidgetA --> leafA[Leaf source node 17 / widget text]
+ middleWidgetB --> leafB[Leaf source node 42 / widget text]
+
+ oldKeyA[Old key: middleNodeId + text + disambiguatingSourceNodeId 17]
+ oldKeyB[Old key: middleNodeId + text + disambiguatingSourceNodeId 42]
+ middleWidgetA -. requires .-> oldKeyA
+ middleWidgetB -. requires .-> oldKeyB
+
+ classDef host fill:#d1e7dd,stroke:#0f5132,color:#052e16
+ classDef ambiguous fill:#fff3cd,stroke:#a66f00,color:#332200
+ classDef leaf fill:#cff4fc,stroke:#055160,color:#032830
+
+ class outerHost host
+ class middleNode,middleWidgetA,middleWidgetB,oldKeyA,oldKeyB ambiguous
+ class leafA,leafB leaf
+```
+
+The disambiguator is therefore not a domain concept. It is compensating for an
+identity model that asks host UI state to identify private nested internals.
+
+## Existing places that need it
+
+| Area | Current use of `disambiguatingSourceNodeId` | Ambiguity being patched |
+| ---------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------- |
+| Promotion source types | `PromotedWidgetSource` and `PromotedWidgetView` carry the optional field. | Source identity needs more than immediate node ID plus widget name for nested promoted views. |
+| Concrete widget resolution | `findWidgetByIdentity(...)` matches promoted views by `(disambiguatingSourceNodeId ?? sourceNodeId)` when a source node ID is supplied. | Multiple promoted views under the same intermediate node can share a widget name. |
+| Legacy proxy normalization | Prefixed legacy names such as `123:widget_name` are converted into structured source identity and tested with candidate disambiguators. | Old serialized names encode leaf identity inside the widget name string. |
+| Promotion store keys | `makePromotionEntryKey(...)`, `isPromoted(...)`, and `demote(...)` include the field in equality. | Store-level uniqueness would collapse distinct nested promotions without the leaf ID. |
+| Linked promotion propagation | `SubgraphNode._resolveLinkedPromotionBySubgraphInput(...)` preserves the leaf ID when a linked input targets an inner subgraph promoted view. | The outer host otherwise sees only the immediate inner `SubgraphNode` and the promoted widget name. |
+| Subgraph editor UI | The editor uses the field when resolving active widgets and when writing reordered/toggled promotions back to the store. | UI list operations must not merge same-name promoted views from different leaves. |
+
+## New promoted-widget identity
+
+ADR 0009 moves promoted value identity to the host boundary:
+
+```ts
+type PromotedWidgetUiIdentity = {
+ hostNodeLocator: string
+ subgraphInputName: string
+}
+```
+
+The canonical widget is owned by a `SubgraphInput` on the host
+`SubgraphNode`. The host widget no longer needs to identify the deepest source
+node to preserve value identity. The source widget is consulted for schema,
+defaults, diagnostics, and migration, but it is not the value owner.
+
+```mermaid
+flowchart TD
+ host[Host SubgraphNode] --> inputA[SubgraphInput.name: prompt]
+ host --> inputB[SubgraphInput.name: negative_prompt]
+ inputA --> hostWidgetA[Host-owned widget entity]
+ inputB --> hostWidgetB[Host-owned widget entity]
+
+ hostWidgetA -. schema/default metadata .-> sourceA[Interior source widget text]
+ hostWidgetB -. schema/default metadata .-> sourceB[Interior source widget text]
+
+ identityA[Identity: hostNodeLocator + prompt] --> hostWidgetA
+ identityB[Identity: hostNodeLocator + negative_prompt] --> hostWidgetB
+ sourceA -. not part of host value key .-> identityA
+ sourceB -. not part of host value key .-> identityB
+
+ classDef owner fill:#d1e7dd,stroke:#0f5132,color:#052e16
+ classDef metadata fill:#cff4fc,stroke:#055160,color:#032830
+ classDef removed fill:#f8d7da,stroke:#842029,color:#330000
+
+ class host,inputA,inputB,hostWidgetA,hostWidgetB,identityA,identityB owner
+ class sourceA,sourceB metadata
+```
+
+This is the same rule the subgraph interface already uses: `name` is stable
+identity, and `label` / `localized_name` are display-only.
+
+## How the new form removes each need
+
+| Previous disambiguation site | New canonical replacement |
+| ------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------- |
+| `PromotedWidgetSource.disambiguatingSourceNodeId` | Host value identity is `hostNodeLocator + SubgraphInput.name`; source locator fields become migration/diagnostic metadata only. |
+| `PromotedWidgetView.disambiguatingSourceNodeId` | Host-scoped widget entities are derived from subgraph inputs, not from promoted views chained through nested source widgets. |
+| `findWidgetByIdentity(...)` leaf matching | Runtime value lookup starts from the host input identity; source traversal is metadata resolution, not value identity resolution. |
+| Legacy prefixed widget-name normalization | Load migration consumes legacy source-shaped entries and writes standard subgraph input state or quarantine metadata. |
+| PromotionStore source-key equality | `PromotionStore` becomes a temporary derived index; canonical consumers query subgraph inputs directly. |
+| Linked promotion propagation across nested hosts | Nested value composition is represented boundary-by-boundary by linked subgraph inputs with stable names. |
+| Subgraph editor active widget matching | Editor state can operate on host boundary entries instead of matching leaf source widgets through same-name promoted views. |
+
+## Boundary-by-boundary nested flow
+
+The new form avoids flattened deep source paths. Each host boundary exposes its
+own named input, and the next outer host links to that immediate boundary
+contract.
+
+```mermaid
+flowchart LR
+ leaf[Leaf node widget] --> innerInput[Inner SubgraphInput.name: text]
+ innerInput --> innerHostWidget[Inner host-owned widget]
+ innerHostWidget --> outerInput[Outer SubgraphInput.name: prompt]
+ outerInput --> outerHostWidget[Outer host-owned widget]
+
+ innerIdentity[Inner value key: innerHost + text] --> innerHostWidget
+ outerIdentity[Outer value key: outerHost + prompt] --> outerHostWidget
+ leaf -. schema/default source .-> innerHostWidget
+ leaf -. not persisted as outer value key .-> outerIdentity
+
+ classDef boundary fill:#d1e7dd,stroke:#0f5132,color:#052e16
+ classDef source fill:#cff4fc,stroke:#055160,color:#032830
+ classDef note fill:#fff3cd,stroke:#a66f00,color:#332200
+
+ class innerInput,innerHostWidget,outerInput,outerHostWidget,innerIdentity,outerIdentity boundary
+ class leaf source
+```
+
+Because each layer has its own stable `SubgraphInput.name`, two same-name leaf
+widgets no longer require a persisted leaf-node disambiguator at the outer host.
+If the user exposes both, the collision is resolved when the host inputs are
+created by assigning distinct input names with the existing unique-name
+behavior.
diff --git a/docs/adr/0009-subgraph-promoted-widgets-use-linked-inputs/system-comparison.md b/docs/adr/0009-subgraph-promoted-widgets-use-linked-inputs/system-comparison.md
new file mode 100644
index 0000000000..82265820e5
--- /dev/null
+++ b/docs/adr/0009-subgraph-promoted-widgets-use-linked-inputs/system-comparison.md
@@ -0,0 +1,37 @@
+# Appendix: System comparison
+
+This appendix compares the legacy promoted-widget systems with the canonical
+linked-input model chosen by
+[ADR 0009](../0009-subgraph-promoted-widgets-use-linked-inputs.md).
+
+| Concern | Legacy `properties.proxyWidgets` promotions | Linked `SubgraphInput` promotions before migration | New canonical linked-input system |
+| -------------------------- | -------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------- |
+| Serialized authority | `properties.proxyWidgets` stores source node/widget tuples as promotion topology. | Subgraph interface/input links can also represent the same exposed widget. | Subgraph interface/input links are the only canonical topology for promoted value widgets. |
+| Load-time role | Hydrates promoted widgets directly from legacy tuples. | May already describe the promoted widget, creating overlap with `proxyWidgets`. | Existing linked inputs are accepted as resolved; legacy tuples are consumed by repair or quarantined. |
+| Save-time role | Could be re-emitted as promotion state. | Serialized as normal subgraph interface data. | Repaired `proxyWidgets` entries are omitted; standard subgraph inputs plus host `widgets_values` are saved. |
+| Value owner | Ambiguous: host `widgets_values` and the interior source widget could both carry the value. | Closer to the desired boundary model, but still coexisted with source/proxy ownership paths. | Host `SubgraphNode` owns value state through host-scoped widget identity. |
+| Schema/default provider | Interior source widget provides schema and may also become persistence carrier. | Interior source widget provides source metadata through the link. | Interior source widget provides schema, type, options, tooltip, and defaults only. |
+| UI identity | Often persisted by source node/widget identity. | Can use subgraph input identity, but mixed states still exist while proxy identity remains. | Host node locator plus `SubgraphInput.name`. |
+| Display label handling | Source widget identity and display concerns can blur. | Uses existing subgraph input naming conventions. | `SubgraphInput.name` is stable identity; `label` / `localized_name` are display-only. |
+| Multiple host instances | Risk of host instances stomping one another through shared interior values. | Better host boundary shape, but overlap with proxy/source value paths can reintroduce ambiguity. | Host-instance-owned sparse overlay prevents shared interior widget value stomping. |
+| Prompt serialization | May read values through promoted runtime state that can collapse to source widgets. | Can serialize through standard subgraph input widgets when used consistently. | Promoted values serialize only through standard host-owned subgraph-input widgets. |
+| Interior mutation on save | Existing `SubgraphNode.serialize()` behavior could copy exterior values into connected interior widgets. | Could still be affected by legacy copy-back behavior. | Copy-back is removed; source widgets are not persistence carriers. |
+| Primitive-node promotions | Legacy tuples may point at `PrimitiveNode` outputs. | Not the canonical primitive fanout representation by itself. | Repaired all-or-nothing into one `SubgraphInput` that reconnects validated fanout targets. |
+| Invalid or unresolved data | Invalid data could sit in legacy promotion state or fail repair paths. | Missing linked inputs can be ambiguous when proxy data exists. | Invalid raw data logs and is ignored; unrepaired valid value entries go to `proxyWidgetErrorQuarantine`. |
+| Display-only previews | Often mixed into `proxyWidgets` despite not being value widgets. | Linked inputs are inappropriate because previews do not own values or prompt inputs. | Separate host-scoped `properties.previewExposures` entries model preview UI only. |
+| Preview persistence | Preview selections can depend on source preview/widget-like identity. | No clean distinction from promoted widget inputs. | Preview identity is host node locator plus `previewName`; unresolved previews stay inert and persisted. |
+| Nested preview behavior | Deep source identity can leak through host UI state. | Linked value inputs do not model display-only preview composition. | Preview exposures chain across immediate subgraph host boundaries; deep private paths are not persisted. |
+| ECS compatibility | Weak: value identity can depend on source widget tuples and mutable interior widgets. | Partial: linked inputs fit boundary modeling, but duplicate authority remains. | Strong: host-scoped widget entity identity maps cleanly to ECS component state. |
+| Long-term status | Legacy load-time input only. | Becomes the standard representation once overlap is removed. | Canonical system; `PromotionStore` becomes a temporary derived compatibility/index layer. |
+
+## Practical migration summary
+
+| Legacy shape | New result |
+| -------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------- |
+| Valid `proxyWidgets` entry already represented by a linked `SubgraphInput` | Entry is consumed; the existing linked input remains canonical. |
+| Valid value-widget `proxyWidgets` entry without a linked input | Repair creates or reconnects standard subgraph input/link state. |
+| Valid primitive fanout entry | Repair creates one `SubgraphInput`, reconnects all validated targets, and leaves the primitive node inert. |
+| Valid value-widget entry that cannot be repaired | Entry is persisted in `properties.proxyWidgetErrorQuarantine` with the host value when available. |
+| Preview-shaped legacy entry | Entry is migrated into `properties.previewExposures`, not a linked input. |
+| Unresolved preview exposure | Entry remains inert in `previewExposures`; it is not quarantined because it owns no user value. |
+| Invalid raw `proxyWidgets` data | Logs `console.error`, does not throw, and is not quarantined. |
diff --git a/docs/architecture/ecs-migration-plan.md b/docs/architecture/ecs-migration-plan.md
index a3ba8fbe53..e6a1d1d61a 100644
--- a/docs/architecture/ecs-migration-plan.md
+++ b/docs/architecture/ecs-migration-plan.md
@@ -231,6 +231,11 @@ assigning synthetic widget IDs (via `lastWidgetId` counter on LGraphState).
the ID mapping — widgets currently lack independent IDs, so the bridge must
maintain a `(nodeId, widgetName) -> WidgetEntityId` lookup.
+**Promoted-widget caveat:** ADR 0009 assigns promoted value widgets a
+host-boundary identity (`host node locator + SubgraphInput.name`). Interior
+source node/widget identity is preserved only as migration and diagnostic
+metadata.
+
### 2c. Read-only bridge for Node metadata
Populate `NodeType`, `NodeVisual`, `Properties`, `Execution` components by
@@ -663,6 +668,10 @@ The 6 proto-ECS stores use 6 different keying strategies:
| NodeOutputStore | `"${subgraphId}:${nodeId}"` |
| SubgraphNavigationStore | subgraphId or `'root'` |
+ADR 0009 refines the promoted-widget target: promoted value widgets should use
+host boundary identity (`host node locator + SubgraphInput.name`), not interior
+source node/widget identity.
+
The World unifies these under branded entity IDs. But stores that use
composite keys (e.g., `nodeId:widgetName`) reflect a genuine structural
reality — a widget is identified by its relationship to a node. Synthetic
diff --git a/docs/architecture/proto-ecs-stores.md b/docs/architecture/proto-ecs-stores.md
index 0831f0535d..f5f09ccb50 100644
--- a/docs/architecture/proto-ecs-stores.md
+++ b/docs/architecture/proto-ecs-stores.md
@@ -17,6 +17,10 @@ Six stores extract entity state out of class instances into centralized, queryab
| NodeOutputStore | Execution results | `nodeLocatorId` | `"${subgraphId}:${nodeId}"` | Output data, preview URLs |
| SubgraphNavigationStore | Canvas viewport | `subgraphId` | `subgraphId` or `'root'` | LRU viewport cache |
+ADR 0009 refines promoted-widget identity: promoted value widgets are keyed by
+the host boundary (`host node locator + SubgraphInput.name`), while interior
+source node/widget identity is migration and diagnostic metadata only.
+
## 2. WidgetValueStore
**File:** `src/stores/widgetValueStore.ts`
@@ -254,6 +258,9 @@ Each store invents its own identity scheme:
| NodeOutputStore | `"${subgraphId}:${nodeId}"` | Composite string | No |
In the ECS target, all of these would use branded entity IDs (`WidgetEntityId`, `NodeEntityId`, etc.) with compile-time cross-kind protection.
+For promoted value widgets, ADR 0009 narrows the target key to host boundary
+identity (`host node locator + SubgraphInput.name`) instead of interior source
+identity.
## 6. Extraction Map
diff --git a/docs/architecture/subgraph-boundaries-and-promotion.md b/docs/architecture/subgraph-boundaries-and-promotion.md
index 51d7b8b941..0426a8afe1 100644
--- a/docs/architecture/subgraph-boundaries-and-promotion.md
+++ b/docs/architecture/subgraph-boundaries-and-promotion.md
@@ -404,26 +404,21 @@ Whichever candidate is chosen:
instance-specific state beyond inputs — must remain reachable. This is a
constraint, not a current requirement.
-### Recommendation and decision criteria
+### Decision
-**Lean toward A.** It eliminates an entire subsystem by recognizing a structural
-truth: promotion is adding a typed input to a function signature. The type
-system already handles widget creation for typed inputs. Building a parallel
-mechanism for "promoted widgets" is building a second, narrower version of
-something the system already does.
+[ADR 0009](../adr/0009-subgraph-promoted-widgets-use-linked-inputs.md)
+chooses Candidate A for promoted value widgets. It eliminates an entire
+subsystem by recognizing a structural truth: promotion is adding a typed input
+to a function signature. The type system already handles widget creation for
+typed inputs. Building a parallel mechanism for "promoted widgets" is building
+a second, narrower version of something the system already does.
The cost of A is a migration path for existing `proxyWidgets` serialization. On
-load, the `SerializationSystem` converts `proxyWidgets` entries into interface
-inputs and boundary links. This is a one-time ratchet conversion — once
-loaded and re-saved, the workflow uses the new format.
-
-**Choose B if** the team determines that promoted widgets must remain
-visually or behaviorally distinct from normal input widgets in ways the type →
-widget mapping cannot express, or if the `proxyWidgets` migration burden exceeds
-the current release cycle's capacity.
-
-**Decision needed before** Phase 3 of the ECS migration, when systems are
-introduced and the widget/connectivity architecture solidifies.
+load, the `SerializationSystem` converts value-widget `proxyWidgets` entries
+into interface inputs and boundary links. Once loaded and re-saved, the workflow
+uses the new format. ADR 0009 separates display-only preview exposures from
+promoted value widgets; those previews use their own host-scoped serialized
+representation instead of linked `SubgraphInput` widgets.
---
@@ -471,14 +466,14 @@ and produces the recursive `ExportedSubgraph` structure, matching the current
format exactly. Existing workflows, the ComfyUI backend, and third-party tools
see no change.
-| Direction | Format | Notes |
-| --------------- | ------------------------------- | ---------------------------------------- |
-| **Save/export** | Nested (current shape) | SerializationSystem walks scope tree |
-| **Load/import** | Nested (current) or future flat | Ratchet: normalize to flat World on load |
+| Direction | Format | Notes |
+| --------------- | ------------------------------- | ------------------------------------------ |
+| **Save/export** | Nested (current shape) | SerializationSystem walks scope tree |
+| **Load/import** | Nested (current) or future flat | Migration: normalize to flat World on load |
-The "ratchet conversion" pattern: load any supported format, normalize to the
-internal model. The system accepts old formats indefinitely but produces the
-current format on save.
+The migration pattern: load any supported format and normalize to the internal
+model. The system accepts old formats indefinitely but produces the current
+format on save.
### Widget identity at the boundary
@@ -511,13 +506,12 @@ SubgraphIO {
}
```
-If Candidate A (connections-only promotion) is chosen: promoted widgets become
-interface inputs, serialized as additional `SubgraphIO` entries. On load, legacy
-`proxyWidgets` data is converted to interface inputs and boundary links (ratchet
-migration). On save, `proxyWidgets` is no longer written.
-
-If Candidate B (simplified promotion) is chosen: `proxyWidgets` continues to be
-serialized in its current format.
+ADR 0009 chooses Candidate A (connections-only promotion) for promoted value
+widgets: they become interface inputs, serialized as additional `SubgraphIO`
+entries. On load, legacy value-widget `proxyWidgets` data is converted to
+interface inputs and boundary links. On save, repaired `proxyWidgets` entries
+are no longer written. Display-only preview exposures use separate
+host-scoped `previewExposures` serialization.
### Backward-compatible loading contract
@@ -555,7 +549,7 @@ This document proposes or surfaces the following changes to
| World structure | Implied per-graph containment | Flat World with `graphScope` tags; one World per workflow |
| Acyclicity | Not addressed | DAG invariant on `SubgraphStructure.graphId` references, enforced on mutation |
| Boundary model | Deferred | Typed interface contracts on `SubgraphStructure`; no virtual nodes or magic IDs |
-| Widget promotion | Treated as a given feature to migrate | Open decision: Candidate A (connections-only) vs B (simplified component) |
+| Widget promotion | Treated as a given feature to migrate | ADR 0009 chooses Candidate A: promoted value widgets are linked inputs |
| Serialization | Not explicitly separated from internal model | Internal model ≠ wire format; `SerializationSystem` is the membrane |
| Backward compat | Implicit | Explicit contract: load any prior format, indefinitely |
diff --git a/package.json b/package.json
index 41448afc03..b02c50813f 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "@comfyorg/comfyui-frontend",
- "version": "1.44.17",
+ "version": "1.45.4",
"private": true,
"description": "Official front-end implementation of ComfyUI",
"homepage": "https://comfy.org",
@@ -11,7 +11,7 @@
"build:cloud": "cross-env DISTRIBUTION=cloud NODE_OPTIONS='--max-old-space-size=8192' nx build",
"build:desktop": "nx build @comfyorg/desktop-ui",
"build-storybook": "storybook build",
- "build:types": "nx build --config vite.types.config.mts && node scripts/prepare-types.js",
+ "build:types": "cross-env NODE_OPTIONS='--max-old-space-size=8192' nx build --config vite.types.config.mts && node scripts/prepare-types.js",
"build:analyze": "cross-env ANALYZE_BUNDLE=true pnpm build",
"build": "cross-env NODE_OPTIONS='--max-old-space-size=8192' pnpm typecheck && nx build",
"size:collect": "node scripts/size-collect.js",
diff --git a/packages/ingest-types/src/types.gen.ts b/packages/ingest-types/src/types.gen.ts
index 3ce132cc2a..8340251939 100644
--- a/packages/ingest-types/src/types.gen.ts
+++ b/packages/ingest-types/src/types.gen.ts
@@ -523,10 +523,6 @@ export type ImportPublishedAssetsRequest = {
* IDs of published assets (inputs and models) to import.
*/
published_asset_ids: Array
- /**
- * The share ID of the published workflow these assets belong to. Required for authorization.
- */
- share_id: string
}
/**
diff --git a/packages/ingest-types/src/zod.gen.ts b/packages/ingest-types/src/zod.gen.ts
index 807cb9d7e4..42a6029bfd 100644
--- a/packages/ingest-types/src/zod.gen.ts
+++ b/packages/ingest-types/src/zod.gen.ts
@@ -310,8 +310,7 @@ export const zImportPublishedAssetsResponse = z.object({
* Request body for importing assets from a published workflow.
*/
export const zImportPublishedAssetsRequest = z.object({
- published_asset_ids: z.array(z.string().min(1).max(64)).max(1000),
- share_id: z.string().min(1).max(64)
+ published_asset_ids: z.array(z.string())
})
/**
diff --git a/packages/registry-types/src/comfyRegistryTypes.ts b/packages/registry-types/src/comfyRegistryTypes.ts
index fd3825f53e..182adf6eea 100644
--- a/packages/registry-types/src/comfyRegistryTypes.ts
+++ b/packages/registry-types/src/comfyRegistryTypes.ts
@@ -10057,6 +10057,8 @@ export interface components {
};
progress: number;
create_time: number;
+ /** @description Actual credits consumed by the task. Present once status is finalized; 0 for failed tasks. */
+ consumed_credit?: number;
};
TripoSuccessTask: {
/** @enum {integer} */
@@ -12575,12 +12577,16 @@ export interface components {
Rodin3DGenerateRequest: {
/** @description The reference images to generate 3D Assets. */
images: string;
+ /** @description Text prompt used by the upstream Rodin API. Required by upstream for text-to-3D requests (no images uploaded); optional for image-to-3D requests where it acts as additional guidance. */
+ prompt?: string;
/** @description Seed. */
seed?: number;
tier?: components["schemas"]["RodinTierType"];
material?: components["schemas"]["RodinMaterialType"];
quality?: components["schemas"]["RodinQualityType"];
mesh_mode?: components["schemas"]["RodinMeshModeType"];
+ /** @description Optional list of upstream addon flags (e.g. "HighPack"). */
+ addons?: string[];
};
/**
* @description Rodin Tier para options
diff --git a/packages/shared-frontend-utils/src/formatUtil.test.ts b/packages/shared-frontend-utils/src/formatUtil.test.ts
index 3a1e6c877d..b858db80c3 100644
--- a/packages/shared-frontend-utils/src/formatUtil.test.ts
+++ b/packages/shared-frontend-utils/src/formatUtil.test.ts
@@ -3,12 +3,14 @@ import { describe, expect, it } from 'vitest'
import {
appendWorkflowJsonExt,
ensureWorkflowSuffix,
+ getFilePathSeparatorVariants,
getFilenameDetails,
getMediaTypeFromFilename,
getPathDetails,
highlightQuery,
isCivitaiModelUrl,
isPreviewableMediaType,
+ joinFilePath,
truncateFilename
} from './formatUtil'
@@ -83,9 +85,11 @@ describe('formatUtil', () => {
describe('video files', () => {
it('should identify video extensions correctly', () => {
expect(getMediaTypeFromFilename('video.mp4')).toBe('video')
+ expect(getMediaTypeFromFilename('apple.m4v')).toBe('video')
expect(getMediaTypeFromFilename('clip.webm')).toBe('video')
expect(getMediaTypeFromFilename('movie.mov')).toBe('video')
expect(getMediaTypeFromFilename('film.avi')).toBe('video')
+ expect(getMediaTypeFromFilename('episode.mkv')).toBe('video')
})
})
@@ -299,6 +303,42 @@ describe('formatUtil', () => {
})
})
+ describe('joinFilePath', () => {
+ it('joins subfolder and filename with normalized slash separators', () => {
+ expect(joinFilePath('nested\\folder', 'child\\file.png')).toBe(
+ 'nested/folder/child/file.png'
+ )
+ })
+
+ it('trims boundary separators without changing the filename body', () => {
+ expect(joinFilePath('/nested/folder/', '/file.png')).toBe(
+ 'nested/folder/file.png'
+ )
+ })
+
+ it('returns the normalized filename when no subfolder is provided', () => {
+ expect(joinFilePath('', 'nested\\file.png')).toBe('nested/file.png')
+ })
+
+ it('returns the normalized subfolder without a trailing slash when no filename is provided', () => {
+ expect(joinFilePath('nested\\folder', '')).toBe('nested/folder')
+ expect(joinFilePath('nested\\folder', null)).toBe('nested/folder')
+ })
+ })
+
+ describe('getFilePathSeparatorVariants', () => {
+ it('returns slash and backslash variants for nested paths', () => {
+ expect(getFilePathSeparatorVariants('nested\\folder/file.png')).toEqual([
+ 'nested/folder/file.png',
+ 'nested\\folder\\file.png'
+ ])
+ })
+
+ it('returns a single value when no separator is present', () => {
+ expect(getFilePathSeparatorVariants('file.png')).toEqual(['file.png'])
+ })
+ })
+
describe('appendWorkflowJsonExt', () => {
it('appends .app.json when isApp is true', () => {
expect(appendWorkflowJsonExt('test', true)).toBe('test.app.json')
diff --git a/packages/shared-frontend-utils/src/formatUtil.ts b/packages/shared-frontend-utils/src/formatUtil.ts
index 3e52190092..40e10921de 100644
--- a/packages/shared-frontend-utils/src/formatUtil.ts
+++ b/packages/shared-frontend-utils/src/formatUtil.ts
@@ -256,6 +256,31 @@ export function isValidUrl(url: string): boolean {
}
}
+export function joinFilePath(
+ subfolder: string | null | undefined,
+ filename: string | null | undefined
+): string {
+ const normalizedSubfolder = normalizeFilePathSeparators(
+ subfolder ?? ''
+ ).replace(/^\/+|\/+$/g, '')
+ const normalizedFilename = normalizeFilePathSeparators(
+ filename ?? ''
+ ).replace(/^\/+/g, '')
+ if (!normalizedSubfolder) return normalizedFilename
+ if (!normalizedFilename) return normalizedSubfolder
+ return `${normalizedSubfolder}/${normalizedFilename}`
+}
+
+export function getFilePathSeparatorVariants(filepath: string): string[] {
+ const slashPath = normalizeFilePathSeparators(filepath)
+ const backslashPath = slashPath.replace(/\//g, '\\')
+ return slashPath === backslashPath ? [slashPath] : [slashPath, backslashPath]
+}
+
+function normalizeFilePathSeparators(filepath: string): string {
+ return filepath.replace(/[\\/]+/g, '/')
+}
+
/**
* Parses a filepath into its filename and subfolder components.
*
@@ -274,8 +299,7 @@ export function parseFilePath(filepath: string): {
} {
if (!filepath?.trim()) return { filename: '', subfolder: '' }
- const normalizedPath = filepath
- .replace(/[\\/]+/g, '/') // Normalize path separators
+ const normalizedPath = normalizeFilePathSeparators(filepath)
.replace(/^\//, '') // Remove leading slash
.replace(/\/$/, '') // Remove trailing slash
@@ -557,7 +581,7 @@ const IMAGE_EXTENSIONS = [
'tiff',
'svg'
] as const
-const VIDEO_EXTENSIONS = ['mp4', 'webm', 'mov', 'avi'] as const
+const VIDEO_EXTENSIONS = ['mp4', 'm4v', 'webm', 'mov', 'avi', 'mkv'] as const
const AUDIO_EXTENSIONS = ['mp3', 'wav', 'ogg', 'flac'] as const
const THREE_D_EXTENSIONS = ['obj', 'fbx', 'gltf', 'glb', 'usdz'] as const
const TEXT_EXTENSIONS = [
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 3bf369cc14..9732a8c73d 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -160,8 +160,8 @@ catalogs:
specifier: ^7.7.0
version: 7.7.0
'@types/three':
- specifier: ^0.169.0
- version: 0.169.0
+ specifier: ^0.170.0
+ version: 0.170.0
'@vee-validate/zod':
specifier: ^4.15.1
version: 4.15.1
@@ -339,6 +339,9 @@ catalogs:
tailwindcss-primeui:
specifier: ^0.6.1
version: 0.6.1
+ three:
+ specifier: ^0.170.0
+ version: 0.170.0
tsx:
specifier: ^4.15.6
version: 4.19.4
@@ -698,7 +701,7 @@ importers:
version: 7.7.0
'@types/three':
specifier: 'catalog:'
- version: 0.169.0
+ version: 0.170.0
'@vitejs/plugin-vue':
specifier: 'catalog:'
version: 6.0.3(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))(vue@3.5.13(typescript@5.9.3))
@@ -964,6 +967,9 @@ importers:
posthog-js:
specifier: 'catalog:'
version: 1.358.1
+ three:
+ specifier: 'catalog:'
+ version: 0.170.0
vue:
specifier: 'catalog:'
version: 3.5.13(typescript@5.9.3)
@@ -4508,8 +4514,8 @@ packages:
'@types/stats.js@0.17.3':
resolution: {integrity: sha512-pXNfAD3KHOdif9EQXZ9deK82HVNaXP5ZIF5RP2QG6OQFNTaY2YIetfrE9t528vEreGQvEPRDDc8muaoYeK0SxQ==}
- '@types/three@0.169.0':
- resolution: {integrity: sha512-oan7qCgJBt03wIaK+4xPWclYRPG9wzcg7Z2f5T8xYTNEF95kh0t0lklxLLYBDo7gQiGLYzE6iF4ta7nXF2bcsw==}
+ '@types/three@0.170.0':
+ resolution: {integrity: sha512-CUm2uckq+zkCY7ZbFpviRttY+6f9fvwm6YqSqPfA5K22s9w7R4VnA3rzJse8kHVvuzLcTx+CjNCs2NYe0QFAyg==}
'@types/tough-cookie@4.0.5':
resolution: {integrity: sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==}
@@ -9883,8 +9889,8 @@ packages:
vue-component-type-helpers@3.2.6:
resolution: {integrity: sha512-O02tnvIfOQVmnvoWwuSydwRoHjZVt8UEBR+2p4rT35p8GAy5VTlWP8o5qXfJR/GWCN0nVZoYWsVUvx2jwgdBmQ==}
- vue-component-type-helpers@3.2.7:
- resolution: {integrity: sha512-+gPp5YGmhfsj1IN+xUo7y0fb4clfnOiiUA39y07yW1VzCRjzVgwLbtmdWlghh7mXrPsEaYc7rrIir/HT6C8vYQ==}
+ vue-component-type-helpers@3.2.8:
+ resolution: {integrity: sha512-9689efAXhN/EV86plgkL/XFiJSXhGtWPG6JDboZ+QnjlUWUUQrQ0ILKQtw4iQsuwIwu5k6Aw+JnehDe7161e7A==}
vue-demi@0.14.10:
resolution: {integrity: sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==}
@@ -13405,7 +13411,7 @@ snapshots:
storybook: 10.2.10(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
type-fest: 2.19.0
vue: 3.5.13(typescript@5.9.3)
- vue-component-type-helpers: 3.2.7
+ vue-component-type-helpers: 3.2.8
'@swc/helpers@0.5.17':
dependencies:
@@ -13834,7 +13840,7 @@ snapshots:
'@types/stats.js@0.17.3': {}
- '@types/three@0.169.0':
+ '@types/three@0.170.0':
dependencies:
'@tweenjs/tween.js': 23.1.3
'@types/stats.js': 0.17.3
@@ -14189,7 +14195,7 @@ snapshots:
sirv: 3.0.2
tinyglobby: 0.2.15
tinyrainbow: 3.0.3
- vitest: 4.0.16(@opentelemetry/api@1.9.0)(@types/node@24.10.4)(@vitest/ui@4.0.16)(esbuild@0.27.3)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@27.4.0)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)
+ vitest: 4.0.16(@opentelemetry/api@1.9.0)(@types/node@25.0.3)(@vitest/ui@4.0.16)(esbuild@0.27.3)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@27.4.0)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)
'@vitest/utils@3.2.4':
dependencies:
@@ -20530,7 +20536,7 @@ snapshots:
vue-component-type-helpers@3.2.6: {}
- vue-component-type-helpers@3.2.7: {}
+ vue-component-type-helpers@3.2.8: {}
vue-demi@0.14.10(vue@3.5.13(typescript@5.9.3)):
dependencies:
diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml
index 2059d90ab8..7eb19d6d70 100644
--- a/pnpm-workspace.yaml
+++ b/pnpm-workspace.yaml
@@ -54,7 +54,7 @@ catalog:
'@types/jsdom': ^21.1.7
'@types/node': ^24.1.0
'@types/semver': ^7.7.0
- '@types/three': ^0.169.0
+ '@types/three': ^0.170.0
'@vee-validate/zod': ^4.15.1
'@vercel/analytics': ^2.0.1
'@vitejs/plugin-vue': ^6.0.0
@@ -113,6 +113,7 @@ catalog:
storybook: ^10.2.10
stylelint: ^16.26.1
tailwindcss: ^4.2.0
+ three: ^0.170.0
tailwindcss-primeui: ^0.6.1
tsx: ^4.15.6
tw-animate-css: ^1.3.8
diff --git a/public/assets/sorted-custom-node-map.json b/public/assets/sorted-custom-node-map.json
index 3bd0889fe1..87cea332c8 100644
--- a/public/assets/sorted-custom-node-map.json
+++ b/public/assets/sorted-custom-node-map.json
@@ -3,6 +3,7 @@
"LoadImage": 3474,
"CLIPTextEncode": 2435,
"SaveImage": 1762,
+ "SaveImageAdvanced": 1762,
"VAEDecode": 1754,
"KSampler": 1511,
"CheckpointLoaderSimple": 1293,
diff --git a/scripts/generate-embedded-metadata-test-files.py b/scripts/generate-embedded-metadata-test-files.py
index 2b57e296d3..b66546ca28 100644
--- a/scripts/generate-embedded-metadata-test-files.py
+++ b/scripts/generate-embedded-metadata-test-files.py
@@ -19,6 +19,7 @@ import subprocess
import av
from PIL import Image
+from PIL.PngImagePlugin import PngInfo
REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
FIXTURES_DIR = os.path.join(REPO_ROOT, 'src', 'scripts', 'metadata', '__fixtures__')
@@ -115,6 +116,15 @@ def generate_av_fixture(
report(name)
+def generate_png():
+ img = make_1x1_image()
+ info = PngInfo()
+ info.add_text('workflow', WORKFLOW_JSON)
+ info.add_text('prompt', PROMPT_JSON)
+ img.save(out('with_metadata.png'), 'PNG', pnginfo=info)
+ report('with_metadata.png')
+
+
def generate_webp():
img = make_1x1_image()
exif = build_exif_bytes()
@@ -167,6 +177,7 @@ def generate_webm():
if __name__ == '__main__':
print('Generating fixtures...')
+ generate_png()
generate_webp()
generate_avif()
generate_flac()
diff --git a/src/components/custom/widget/WorkflowTemplateSelectorDialog.vue b/src/components/custom/widget/WorkflowTemplateSelectorDialog.vue
index 718505fba1..7d22b88818 100644
--- a/src/components/custom/widget/WorkflowTemplateSelectorDialog.vue
+++ b/src/components/custom/widget/WorkflowTemplateSelectorDialog.vue
@@ -40,7 +40,10 @@
-
+
@@ -416,6 +423,7 @@ import BaseModalLayout from '@/components/widget/layout/BaseModalLayout.vue'
import LeftSidePanel from '@/components/widget/panel/LeftSidePanel.vue'
import { useIntersectionObserver } from '@/composables/useIntersectionObserver'
import { useLazyPagination } from '@/composables/useLazyPagination'
+import { usePrimeVueOverlayChildStyle } from '@/composables/usePopoverSizing'
import { useTemplateFiltering } from '@/composables/useTemplateFiltering'
import { isCloud } from '@/platform/distribution/types'
import { useTelemetry } from '@/platform/telemetry'
@@ -632,6 +640,8 @@ const selectedRunsOnObjects = computed({
const loadingTemplate = ref(null)
const hoveredTemplate = ref(null)
const cardRefs = ref([])
+const primeVueOverlay = usePrimeVueOverlayChildStyle()
+const selectContentStyle = primeVueOverlay.contentStyle
// Force re-render key for templates when sorting changes
const templateListKey = ref(0)
diff --git a/src/components/dialog/GlobalDialog.test.ts b/src/components/dialog/GlobalDialog.test.ts
new file mode 100644
index 0000000000..1fa839772d
--- /dev/null
+++ b/src/components/dialog/GlobalDialog.test.ts
@@ -0,0 +1,192 @@
+import { createTestingPinia } from '@pinia/testing'
+import { cleanup, render, screen } from '@testing-library/vue'
+import userEvent from '@testing-library/user-event'
+import { setActivePinia } from 'pinia'
+import PrimeVue from 'primevue/config'
+import { afterEach, beforeEach, describe, expect, it } from 'vitest'
+import { defineComponent, h } from 'vue'
+import { createI18n } from 'vue-i18n'
+
+import GlobalDialog from '@/components/dialog/GlobalDialog.vue'
+import { useDialogStore } from '@/stores/dialogStore'
+
+const i18n = createI18n({
+ legacy: false,
+ locale: 'en',
+ messages: { en: { g: { close: 'Close' } } },
+ missingWarn: false,
+ fallbackWarn: false
+})
+
+const Body = defineComponent({
+ name: 'Body',
+ setup: () => () => h('p', { 'data-testid': 'body' }, 'body content')
+})
+
+function mountDialog() {
+ return render(GlobalDialog, {
+ global: { plugins: [PrimeVue, i18n] }
+ })
+}
+
+describe('GlobalDialog renderer branching', () => {
+ beforeEach(() => {
+ setActivePinia(createTestingPinia({ stubActions: false }))
+ })
+
+ afterEach(() => {
+ cleanup()
+ })
+
+ it('renders the PrimeVue branch when renderer is omitted', async () => {
+ mountDialog()
+ const store = useDialogStore()
+
+ store.showDialog({
+ key: 'primevue-default',
+ title: 'PrimeVue dialog',
+ component: Body
+ })
+
+ const dialogs = await screen.findAllByRole('dialog')
+ expect(dialogs.some((el) => el.classList.contains('p-dialog'))).toBe(true)
+ })
+
+ it('renders the Reka branch when renderer is reka', async () => {
+ mountDialog()
+ const store = useDialogStore()
+
+ store.showDialog({
+ key: 'reka-opt-in',
+ title: 'Reka dialog',
+ component: Body,
+ dialogComponentProps: { renderer: 'reka' }
+ })
+
+ const dialogs = await screen.findAllByRole('dialog')
+ expect(dialogs.length).toBeGreaterThan(0)
+ expect(dialogs.some((el) => el.classList.contains('p-dialog'))).toBe(false)
+ })
+
+ it('preserves the renderer flag on the dialog stack item', async () => {
+ mountDialog()
+ const store = useDialogStore()
+
+ store.showDialog({
+ key: 'reka-flag-check',
+ title: 'Reka',
+ component: Body,
+ dialogComponentProps: { renderer: 'reka' }
+ })
+
+ await screen.findByRole('dialog')
+ const item = store.dialogStack.find((d) => d.key === 'reka-flag-check')
+ expect(item?.dialogComponentProps.renderer).toBe('reka')
+ })
+})
+
+describe('GlobalDialog Reka parity with PrimeVue', () => {
+ beforeEach(() => {
+ setActivePinia(createTestingPinia({ stubActions: false }))
+ })
+
+ afterEach(() => {
+ cleanup()
+ })
+
+ it('omits the close button when closable is false', async () => {
+ mountDialog()
+ const store = useDialogStore()
+
+ store.showDialog({
+ key: 'reka-not-closable',
+ title: 'No close',
+ component: Body,
+ dialogComponentProps: { renderer: 'reka', closable: false }
+ })
+
+ await screen.findByRole('dialog')
+ expect(screen.queryByRole('button', { name: 'Close' })).toBeNull()
+ })
+
+ it('renders the close button by default', async () => {
+ mountDialog()
+ const store = useDialogStore()
+
+ store.showDialog({
+ key: 'reka-closable',
+ title: 'Closable',
+ component: Body,
+ dialogComponentProps: { renderer: 'reka' }
+ })
+
+ await screen.findByRole('dialog')
+ expect(screen.getByRole('button', { name: 'Close' })).toBeInTheDocument()
+ })
+
+ it('omits the title when headless is true', async () => {
+ mountDialog()
+ const store = useDialogStore()
+
+ store.showDialog({
+ key: 'reka-headless',
+ title: 'Hidden title',
+ component: Body,
+ dialogComponentProps: { renderer: 'reka', headless: true }
+ })
+
+ await screen.findByRole('dialog')
+ expect(screen.queryByText('Hidden title')).toBeNull()
+ })
+
+ it('renders the title when headless is omitted', async () => {
+ mountDialog()
+ const store = useDialogStore()
+
+ store.showDialog({
+ key: 'reka-titled',
+ title: 'Visible title',
+ component: Body,
+ dialogComponentProps: { renderer: 'reka' }
+ })
+
+ await screen.findByRole('dialog')
+ expect(screen.getByText('Visible title')).toBeInTheDocument()
+ })
+
+ it('closes the dialog on Escape by default', async () => {
+ mountDialog()
+ const store = useDialogStore()
+ const user = userEvent.setup()
+
+ store.showDialog({
+ key: 'reka-esc-default',
+ title: 'Esc closes',
+ component: Body,
+ dialogComponentProps: { renderer: 'reka' }
+ })
+
+ await screen.findByRole('dialog')
+ await user.keyboard('{Escape}')
+
+ expect(store.isDialogOpen('reka-esc-default')).toBe(false)
+ })
+
+ it('does not close on Escape when closable is false', async () => {
+ mountDialog()
+ const store = useDialogStore()
+ const user = userEvent.setup()
+
+ store.showDialog({
+ key: 'reka-esc-blocked',
+ title: 'Esc blocked',
+ component: Body,
+ dialogComponentProps: { renderer: 'reka', closable: false }
+ })
+
+ await screen.findByRole('dialog')
+ await user.keyboard('{Escape}')
+
+ expect(store.isDialogOpen('reka-esc-blocked')).toBe(true)
+ })
+})
diff --git a/src/components/dialog/GlobalDialog.vue b/src/components/dialog/GlobalDialog.vue
index 0e8c9a016d..67f21481d7 100644
--- a/src/components/dialog/GlobalDialog.vue
+++ b/src/components/dialog/GlobalDialog.vue
@@ -1,49 +1,107 @@
-
-
-
-
-
- {{ item.title || ' ' }}
-
-
-
+
+ onRekaOpenChange(item.key, open)"
+ >
+
+
+
+ item.dialogComponentProps.closeOnEscape === false &&
+ e.preventDefault()
+ "
+ @pointer-down-outside="
+ (e) =>
+ item.dialogComponentProps.dismissableMask === false &&
+ e.preventDefault()
+ "
+ @mousedown="() => dialogStore.riseDialog({ key: item.key })"
+ >
+
+
+
+ {{ item.title || ' ' }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ item.title || ' ' }}
+
+
+
-
+
-
-
-
-
+
+
+
+
+
diff --git a/src/components/load3d/controls/viewer/ViewerExportControls.test.ts b/src/components/load3d/controls/viewer/ViewerExportControls.test.ts
index 800fd3d70b..131358860d 100644
--- a/src/components/load3d/controls/viewer/ViewerExportControls.test.ts
+++ b/src/components/load3d/controls/viewer/ViewerExportControls.test.ts
@@ -5,22 +5,65 @@ import { createI18n } from 'vue-i18n'
import ViewerExportControls from '@/components/load3d/controls/viewer/ViewerExportControls.vue'
-vi.mock('primevue/select', () => ({
- default: {
- name: 'Select',
- props: ['modelValue', 'options', 'optionLabel', 'optionValue'],
- emits: ['update:modelValue'],
- template: `
-
-
- {{ opt[optionLabel] }}
-
-
- `
+vi.mock('@/components/ui/select/Select.vue', async () => {
+ const { provide } = await import('vue')
+ return {
+ default: {
+ name: 'Select',
+ props: ['modelValue'],
+ emits: ['update:modelValue'],
+ setup(
+ props: { modelValue: string },
+ { emit }: { emit: (event: string, value: string) => void }
+ ) {
+ provide('selectModelValue', (): string => props.modelValue)
+ provide('selectUpdate', (v: string): void =>
+ emit('update:modelValue', v)
+ )
+ },
+ template: '
'
+ }
}
+})
+
+vi.mock('@/components/ui/select/SelectContent.vue', async () => {
+ const { inject, ref, onMounted } = await import('vue')
+ return {
+ default: {
+ name: 'SelectContent',
+ setup() {
+ const selectModelValue = inject<() => string>('selectModelValue')
+ const selectUpdate = inject<(v: string) => void>('selectUpdate')
+ const el = ref(null)
+ onMounted(() => {
+ if (el.value) el.value.value = selectModelValue?.() ?? ''
+ })
+ return {
+ el,
+ onChange: (e: Event) => {
+ selectUpdate?.((e.target as HTMLSelectElement).value)
+ }
+ }
+ },
+ template: ' '
+ }
+ }
+})
+
+vi.mock('@/components/ui/select/SelectItem.vue', () => ({
+ default: {
+ name: 'SelectItem',
+ props: ['value'],
+ template: ' '
+ }
+}))
+
+vi.mock('@/components/ui/select/SelectTrigger.vue', () => ({
+ default: { name: 'SelectTrigger', template: ' ' }
+}))
+
+vi.mock('@/components/ui/select/SelectValue.vue', () => ({
+ default: { name: 'SelectValue', template: ' ' }
}))
const i18n = createI18n({
diff --git a/src/components/load3d/controls/viewer/ViewerExportControls.vue b/src/components/load3d/controls/viewer/ViewerExportControls.vue
index 7c19710374..eb719ec962 100644
--- a/src/components/load3d/controls/viewer/ViewerExportControls.vue
+++ b/src/components/load3d/controls/viewer/ViewerExportControls.vue
@@ -1,11 +1,18 @@
-
+
+
+
+
+
+
+ {{ fmt.label }}
+
+
diff --git a/src/components/load3d/controls/viewer/ViewerModelControls.test.ts b/src/components/load3d/controls/viewer/ViewerModelControls.test.ts
index 787255638d..53453c844f 100644
--- a/src/components/load3d/controls/viewer/ViewerModelControls.test.ts
+++ b/src/components/load3d/controls/viewer/ViewerModelControls.test.ts
@@ -9,20 +9,65 @@ import type {
UpDirection
} from '@/extensions/core/load3d/interfaces'
-vi.mock('primevue/select', () => ({
- default: {
- name: 'Select',
- props: ['modelValue', 'options', 'optionLabel', 'optionValue'],
- emits: ['update:modelValue'],
- template: `
-
- {{ opt.label }}
-
- `
+vi.mock('@/components/ui/select/Select.vue', async () => {
+ const { provide } = await import('vue')
+ return {
+ default: {
+ name: 'Select',
+ props: ['modelValue'],
+ emits: ['update:modelValue'],
+ setup(
+ props: { modelValue: string },
+ { emit }: { emit: (event: string, value: string) => void }
+ ) {
+ provide('selectModelValue', (): string => props.modelValue)
+ provide('selectUpdate', (v: string): void =>
+ emit('update:modelValue', v)
+ )
+ },
+ template: '
'
+ }
}
+})
+
+vi.mock('@/components/ui/select/SelectContent.vue', async () => {
+ const { inject, ref, onMounted } = await import('vue')
+ return {
+ default: {
+ name: 'SelectContent',
+ setup() {
+ const selectModelValue = inject<() => string>('selectModelValue')
+ const selectUpdate = inject<(v: string) => void>('selectUpdate')
+ const el = ref(null)
+ onMounted(() => {
+ if (el.value) el.value.value = selectModelValue?.() ?? ''
+ })
+ return {
+ el,
+ onChange: (e: Event) => {
+ selectUpdate?.((e.target as HTMLSelectElement).value)
+ }
+ }
+ },
+ template: ' '
+ }
+ }
+})
+
+vi.mock('@/components/ui/select/SelectItem.vue', () => ({
+ default: {
+ name: 'SelectItem',
+ props: ['value'],
+ template: ' '
+ }
+}))
+
+vi.mock('@/components/ui/select/SelectTrigger.vue', () => ({
+ default: { name: 'SelectTrigger', template: ' ' }
+}))
+
+vi.mock('@/components/ui/select/SelectValue.vue', () => ({
+ default: { name: 'SelectValue', template: ' ' }
}))
const i18n = createI18n({
diff --git a/src/components/load3d/controls/viewer/ViewerModelControls.vue b/src/components/load3d/controls/viewer/ViewerModelControls.vue
index e56310e8c8..8e4288f4da 100644
--- a/src/components/load3d/controls/viewer/ViewerModelControls.vue
+++ b/src/components/load3d/controls/viewer/ViewerModelControls.vue
@@ -2,31 +2,51 @@
{{ $t('load3d.upDirection') }}
-
+
+
+
+
+
+
+ {{ opt.label }}
+
+
+
{{ $t('load3d.materialMode') }}
-
+
+
+
+
+
+
+ {{ opt.label }}
+
+
+
diff --git a/src/components/topbar/CurrentUserPopoverLegacy.test.ts b/src/components/topbar/CurrentUserPopoverLegacy.test.ts
index b07d637788..0a4958b4b3 100644
--- a/src/components/topbar/CurrentUserPopoverLegacy.test.ts
+++ b/src/components/topbar/CurrentUserPopoverLegacy.test.ts
@@ -252,6 +252,20 @@ describe('CurrentUserPopoverLegacy', () => {
expect(screen.getByText('Log Out')).toBeInTheDocument()
})
+ describe('credits help icon (FE-617)', () => {
+ it('renders the credits help icon as an interactive button with the unified-credits tooltip as its accessible name', () => {
+ renderComponent()
+
+ const helpButton = screen.getByTestId('credits-info-button')
+ expect(helpButton).toBeInTheDocument()
+ expect(helpButton.tagName).toBe('BUTTON')
+ expect(helpButton).toHaveAttribute(
+ 'aria-label',
+ enMessages.credits.unified.tooltip
+ )
+ })
+ })
+
it('opens user settings and emits close event when settings item is clicked', async () => {
const { user, onClose } = renderComponent()
diff --git a/src/components/topbar/CurrentUserPopoverLegacy.vue b/src/components/topbar/CurrentUserPopoverLegacy.vue
index 083ba1948c..90f431ae86 100644
--- a/src/components/topbar/CurrentUserPopoverLegacy.vue
+++ b/src/components/topbar/CurrentUserPopoverLegacy.vue
@@ -41,10 +41,16 @@
{{
formattedBalance
}}
-
+ variant="muted-textonly"
+ size="icon-sm"
+ class="mr-auto"
+ :aria-label="$t('credits.unified.tooltip')"
+ data-testid="credits-info-button"
+ >
+
+
diff --git a/src/components/topbar/WorkflowTabs.test.ts b/src/components/topbar/WorkflowTabs.test.ts
new file mode 100644
index 0000000000..5914bd9b41
--- /dev/null
+++ b/src/components/topbar/WorkflowTabs.test.ts
@@ -0,0 +1,186 @@
+import { render, screen } from '@testing-library/vue'
+import userEvent from '@testing-library/user-event'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { defineComponent, h, reactive } from 'vue'
+import { createI18n } from 'vue-i18n'
+
+import enMessages from '@/locales/en/main.json' with { type: 'json' }
+
+import WorkflowTabs from './WorkflowTabs.vue'
+
+const distribution = vi.hoisted(() => ({
+ isCloud: false,
+ isDesktop: false,
+ isNightly: false
+}))
+
+const tabBarLayout = vi.hoisted(() => ({ value: 'Default' }))
+
+vi.mock('@/platform/distribution/types', () => ({
+ get isCloud() {
+ return distribution.isCloud
+ },
+ get isDesktop() {
+ return distribution.isDesktop
+ },
+ get isNightly() {
+ return distribution.isNightly
+ }
+}))
+
+vi.mock('@/platform/settings/settingStore', () => ({
+ useSettingStore: () => ({
+ get: (key: string) =>
+ key === 'Comfy.UI.TabBarLayout' ? tabBarLayout.value : undefined
+ })
+}))
+
+vi.mock('@/composables/auth/useCurrentUser', () => ({
+ useCurrentUser: () => ({ isLoggedIn: { value: false } })
+}))
+
+vi.mock('@/composables/useFeatureFlags', () => ({
+ useFeatureFlags: () => ({ flags: { showSignInButton: false } })
+}))
+
+vi.mock('@/composables/element/useOverflowObserver', () => ({
+ useOverflowObserver: () => ({
+ isOverflowing: { value: false },
+ disposed: { value: false },
+ checkOverflow: vi.fn(),
+ dispose: vi.fn()
+ })
+}))
+
+vi.mock('@/platform/workflow/core/services/workflowService', () => ({
+ useWorkflowService: () => ({
+ openWorkflow: vi.fn(),
+ closeWorkflow: vi.fn()
+ })
+}))
+
+vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({
+ useWorkflowStore: () =>
+ reactive({
+ openWorkflows: [],
+ activeWorkflow: null
+ })
+}))
+
+vi.mock('@/stores/commandStore', () => ({
+ useCommandStore: () => ({ execute: vi.fn() })
+}))
+
+vi.mock('@/stores/workspaceStore', () => ({
+ useWorkspaceStore: () => ({ shiftDown: false })
+}))
+
+vi.mock('@/utils/mouseDownUtil', () => ({
+ whileMouseDown: vi.fn()
+}))
+
+vi.mock('./WorkflowOverflowMenu.vue', () => ({
+ default: defineComponent({
+ name: 'WorkflowOverflowMenuStub',
+ render: () => h('div')
+ })
+}))
+
+vi.mock('./WorkflowTab.vue', () => ({
+ default: defineComponent({
+ name: 'WorkflowTabStub',
+ render: () => h('div')
+ })
+}))
+
+vi.mock('./CurrentUserButton.vue', () => ({
+ default: defineComponent({
+ name: 'CurrentUserButtonStub',
+ render: () => h('div')
+ })
+}))
+
+vi.mock('./LoginButton.vue', () => ({
+ default: defineComponent({
+ name: 'LoginButtonStub',
+ render: () => h('div')
+ })
+}))
+
+function renderComponent() {
+ const user = userEvent.setup()
+ const i18n = createI18n({
+ legacy: false,
+ locale: 'en',
+ messages: { en: enMessages }
+ })
+
+ const result = render(WorkflowTabs, {
+ global: {
+ plugins: [i18n],
+ directives: {
+ tooltip: {}
+ }
+ }
+ })
+
+ return { user, ...result }
+}
+
+describe('WorkflowTabs feedback button', () => {
+ let openSpy: ReturnType
+
+ beforeEach(() => {
+ distribution.isCloud = false
+ distribution.isDesktop = false
+ distribution.isNightly = false
+ tabBarLayout.value = 'Default'
+ openSpy = vi.spyOn(window, 'open').mockReturnValue(null)
+ })
+
+ afterEach(() => {
+ openSpy.mockRestore()
+ })
+
+ it('opens the Typeform survey tagged with topbar source on Cloud', async () => {
+ distribution.isCloud = true
+ const { user } = renderComponent()
+
+ await user.click(screen.getByRole('button', { name: 'Feedback' }))
+
+ expect(openSpy).toHaveBeenCalledWith(
+ 'https://form.typeform.com/to/q7azbWPi#distribution=ccloud&source=topbar',
+ '_blank',
+ 'noopener,noreferrer'
+ )
+ })
+
+ it('opens the Typeform survey tagged with topbar source on Nightly', async () => {
+ distribution.isNightly = true
+ const { user } = renderComponent()
+
+ await user.click(screen.getByRole('button', { name: 'Feedback' }))
+
+ expect(openSpy).toHaveBeenCalledWith(
+ 'https://form.typeform.com/to/q7azbWPi#distribution=oss-nightly&source=topbar',
+ '_blank',
+ 'noopener,noreferrer'
+ )
+ })
+
+ it('does not render the feedback button on non-Cloud/non-Nightly builds', () => {
+ renderComponent()
+ expect(
+ screen.queryByRole('button', { name: 'Feedback' })
+ ).not.toBeInTheDocument()
+ })
+
+ it('does not render the feedback button when the legacy tab bar is active', () => {
+ distribution.isCloud = true
+ tabBarLayout.value = 'Legacy'
+ renderComponent()
+ expect(
+ screen.queryByRole('button', { name: 'Feedback' })
+ ).not.toBeInTheDocument()
+ })
+})
diff --git a/src/components/topbar/WorkflowTabs.vue b/src/components/topbar/WorkflowTabs.vue
index a773cefb0b..0bb86ce571 100644
--- a/src/components/topbar/WorkflowTabs.vue
+++ b/src/components/topbar/WorkflowTabs.vue
@@ -119,7 +119,7 @@ import { useCurrentUser } from '@/composables/auth/useCurrentUser'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import { useOverflowObserver } from '@/composables/element/useOverflowObserver'
import { useSettingStore } from '@/platform/settings/settingStore'
-import { buildFeedbackUrl } from '@/platform/support/config'
+import { buildFeedbackTypeformUrl } from '@/platform/support/config'
import { useWorkflowService } from '@/platform/workflow/core/services/workflowService'
import type { ComfyWorkflow } from '@/platform/workflow/management/stores/workflowStore'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
@@ -152,9 +152,12 @@ const isIntegratedTabBar = computed(
)
const showCurrentUser = computed(() => isCloud || isLoggedIn.value)
-const feedbackUrl = buildFeedbackUrl()
function openFeedback() {
- window.open(feedbackUrl, '_blank', 'noopener,noreferrer')
+ window.open(
+ buildFeedbackTypeformUrl('topbar'),
+ '_blank',
+ 'noopener,noreferrer'
+ )
}
const containerRef = ref(null)
diff --git a/src/components/ui/dialog/Dialog.stories.ts b/src/components/ui/dialog/Dialog.stories.ts
new file mode 100644
index 0000000000..5d4c5e1048
--- /dev/null
+++ b/src/components/ui/dialog/Dialog.stories.ts
@@ -0,0 +1,188 @@
+import type { Meta, StoryObj } from '@storybook/vue3-vite'
+import { ref } from 'vue'
+
+import Button from '@/components/ui/button/Button.vue'
+import Dialog from '@/components/ui/dialog/Dialog.vue'
+import DialogClose from '@/components/ui/dialog/DialogClose.vue'
+import DialogContent from '@/components/ui/dialog/DialogContent.vue'
+import DialogDescription from '@/components/ui/dialog/DialogDescription.vue'
+import DialogFooter from '@/components/ui/dialog/DialogFooter.vue'
+import DialogHeader from '@/components/ui/dialog/DialogHeader.vue'
+import DialogOverlay from '@/components/ui/dialog/DialogOverlay.vue'
+import DialogPortal from '@/components/ui/dialog/DialogPortal.vue'
+import DialogTitle from '@/components/ui/dialog/DialogTitle.vue'
+import { FOR_STORIES } from '@/components/ui/dialog/dialog.variants'
+
+const { sizes } = FOR_STORIES
+
+const meta: Meta = {
+ title: 'Components/Dialog/Dialog',
+ tags: ['autodocs'],
+ argTypes: {
+ size: {
+ control: { type: 'select' },
+ options: sizes,
+ defaultValue: 'md'
+ }
+ },
+ args: {
+ size: 'md'
+ }
+}
+
+export default meta
+type Story = StoryObj
+
+export const Default: Story = {
+ render: (args) => ({
+ components: {
+ Button,
+ Dialog,
+ DialogPortal,
+ DialogOverlay,
+ DialogContent,
+ DialogHeader,
+ DialogFooter,
+ DialogTitle,
+ DialogDescription,
+ DialogClose
+ },
+ setup() {
+ const open = ref(false)
+ return { args, open }
+ },
+ template: `
+ Open dialog
+
+
+
+
+
+ Are you sure?
+
+
+
+
+ This action cannot be undone. The selected items will be permanently removed.
+
+
+
+ Cancel
+ Delete
+
+
+
+
+ `
+ })
+}
+
+export const LongContent: Story = {
+ render: (args) => ({
+ components: {
+ Button,
+ Dialog,
+ DialogPortal,
+ DialogOverlay,
+ DialogContent,
+ DialogHeader,
+ DialogTitle,
+ DialogClose
+ },
+ setup() {
+ const open = ref(false)
+ return { args, open }
+ },
+ template: `
+ Open long content
+
+
+
+
+
+ Long content scrolls
+
+
+
+
+ Paragraph {{ n }} — the dialog body should scroll independently
+ while the header and footer stay pinned.
+
+
+
+
+
+ `
+ })
+}
+
+export const Headless: Story = {
+ render: () => ({
+ components: {
+ Button,
+ Dialog,
+ DialogPortal,
+ DialogOverlay,
+ DialogContent
+ },
+ setup() {
+ const open = ref(false)
+ return { open }
+ },
+ template: `
+ Open headless
+
+
+
+
+ No header, no footer — fully custom content.
+ Close
+
+
+
+ `
+ })
+}
+
+export const AllSizes: Story = {
+ render: () => ({
+ components: {
+ Button,
+ Dialog,
+ DialogPortal,
+ DialogOverlay,
+ DialogContent,
+ DialogHeader,
+ DialogTitle,
+ DialogClose
+ },
+ setup() {
+ const openSize = ref(null)
+ return { openSize, sizes }
+ },
+ template: `
+
+ {{ s }}
+
+ { if (!o) openSize = null }"
+ >
+
+
+
+
+ Size: {{ s }}
+
+
+
+ The {{ s }} size variant.
+
+
+
+
+ `
+ })
+}
diff --git a/src/components/ui/dialog/Dialog.vue b/src/components/ui/dialog/Dialog.vue
new file mode 100644
index 0000000000..56e2b7076c
--- /dev/null
+++ b/src/components/ui/dialog/Dialog.vue
@@ -0,0 +1,13 @@
+
+
+
+ emit('update:open', open)">
+
+
+
diff --git a/src/components/ui/dialog/DialogClose.vue b/src/components/ui/dialog/DialogClose.vue
new file mode 100644
index 0000000000..c11b5f7e65
--- /dev/null
+++ b/src/components/ui/dialog/DialogClose.vue
@@ -0,0 +1,18 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/components/ui/dialog/DialogContent.vue b/src/components/ui/dialog/DialogContent.vue
new file mode 100644
index 0000000000..4d89e5c0a2
--- /dev/null
+++ b/src/components/ui/dialog/DialogContent.vue
@@ -0,0 +1,33 @@
+
+
+
+
+
+
+
diff --git a/src/components/ui/dialog/DialogDescription.vue b/src/components/ui/dialog/DialogDescription.vue
new file mode 100644
index 0000000000..a213658286
--- /dev/null
+++ b/src/components/ui/dialog/DialogDescription.vue
@@ -0,0 +1,20 @@
+
+
+
+
+
+
+
diff --git a/src/components/ui/dialog/DialogFooter.vue b/src/components/ui/dialog/DialogFooter.vue
new file mode 100644
index 0000000000..5c653a3a6f
--- /dev/null
+++ b/src/components/ui/dialog/DialogFooter.vue
@@ -0,0 +1,22 @@
+
+
+
+
+
+
+
diff --git a/src/components/ui/dialog/DialogHeader.vue b/src/components/ui/dialog/DialogHeader.vue
new file mode 100644
index 0000000000..6441d7adf7
--- /dev/null
+++ b/src/components/ui/dialog/DialogHeader.vue
@@ -0,0 +1,22 @@
+
+
+
+
+
+
+
diff --git a/src/components/ui/dialog/DialogOverlay.vue b/src/components/ui/dialog/DialogOverlay.vue
new file mode 100644
index 0000000000..7ddf333a98
--- /dev/null
+++ b/src/components/ui/dialog/DialogOverlay.vue
@@ -0,0 +1,23 @@
+
+
+
+
+
diff --git a/src/components/ui/dialog/DialogPortal.vue b/src/components/ui/dialog/DialogPortal.vue
new file mode 100644
index 0000000000..b629e0ad44
--- /dev/null
+++ b/src/components/ui/dialog/DialogPortal.vue
@@ -0,0 +1,12 @@
+
+
+
+
+
+
+
diff --git a/src/components/ui/dialog/DialogTitle.vue b/src/components/ui/dialog/DialogTitle.vue
new file mode 100644
index 0000000000..9b4ae6bd03
--- /dev/null
+++ b/src/components/ui/dialog/DialogTitle.vue
@@ -0,0 +1,20 @@
+
+
+
+
+
+
+
diff --git a/src/components/ui/dialog/dialog.variants.ts b/src/components/ui/dialog/dialog.variants.ts
new file mode 100644
index 0000000000..f3b7d1b61e
--- /dev/null
+++ b/src/components/ui/dialog/dialog.variants.ts
@@ -0,0 +1,32 @@
+import type { VariantProps } from 'cva'
+import { cva } from 'cva'
+
+export const dialogContentVariants = cva({
+ base: 'fixed top-1/2 left-1/2 z-1700 flex max-h-[85vh] w-[calc(100vw-1rem)] -translate-x-1/2 -translate-y-1/2 flex-col rounded-lg border border-border-subtle bg-base-background shadow-lg outline-none data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95',
+ variants: {
+ size: {
+ sm: 'sm:max-w-sm',
+ md: 'sm:max-w-xl',
+ lg: 'sm:max-w-3xl',
+ xl: 'sm:max-w-5xl',
+ full: 'sm:max-w-[calc(100vw-1rem)]'
+ }
+ },
+ defaultVariants: {
+ size: 'md'
+ }
+})
+
+export type DialogContentVariants = VariantProps
+
+export type DialogContentSize = NonNullable
+
+const sizes = [
+ 'sm',
+ 'md',
+ 'lg',
+ 'xl',
+ 'full'
+] as const satisfies Array
+
+export const FOR_STORIES = { sizes } as const
diff --git a/src/components/ui/multi-select/MultiSelect.vue b/src/components/ui/multi-select/MultiSelect.vue
index ad2a19f601..e6d562e76a 100644
--- a/src/components/ui/multi-select/MultiSelect.vue
+++ b/src/components/ui/multi-select/MultiSelect.vue
@@ -50,7 +50,7 @@
position="popper"
:side-offset="8"
align="start"
- :style="popoverStyle"
+ :style="[popoverStyle, contentStyle]"
:class="selectContentClass"
@keydown="onContentKeydown"
@focus-outside="preventFocusDismiss"
@@ -152,6 +152,7 @@ import {
ComboboxViewport
} from 'reka-ui'
import { computed, ref } from 'vue'
+import type { StyleValue } from 'vue'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
@@ -183,7 +184,8 @@ const {
searchPlaceholder,
listMaxHeight = '28rem',
popoverMinWidth,
- popoverMaxWidth
+ popoverMaxWidth,
+ contentStyle
} = defineProps<{
/** Input label shown on the trigger button */
label?: string
@@ -207,6 +209,7 @@ const {
popoverMinWidth?: string
/** Maximum width of the popover (default: auto) */
popoverMaxWidth?: string
+ contentStyle?: StyleValue
}>()
const selectedItems = defineModel({
diff --git a/src/components/ui/search-input/SearchAutocomplete.vue b/src/components/ui/search-input/SearchAutocomplete.vue
index 56832e3305..629369cf60 100644
--- a/src/components/ui/search-input/SearchAutocomplete.vue
+++ b/src/components/ui/search-input/SearchAutocomplete.vue
@@ -70,6 +70,7 @@
v-if="suggestions.length > 0"
position="popper"
:side-offset="4"
+ :style="contentStyle"
:class="
cn(
'z-3000 max-h-60 w-(--reka-combobox-trigger-width) overflow-y-auto',
@@ -99,7 +100,7 @@
diff --git a/src/platform/assets/components/UploadModelConfirmation.vue b/src/platform/assets/components/UploadModelConfirmation.vue
index 106d1190fb..ec5056a6df 100644
--- a/src/platform/assets/components/UploadModelConfirmation.vue
+++ b/src/platform/assets/components/UploadModelConfirmation.vue
@@ -1,5 +1,8 @@
-
+
{{ $t('assetBrowser.modelAssociatedWithLink') }}
@@ -39,6 +42,7 @@
"
:options="modelTypes"
:disabled="isLoading"
+ :content-style="selectContentStyle"
data-attr="upload-model-step2-type-selector"
/>
@@ -47,6 +51,7 @@
diff --git a/src/platform/assets/components/modelInfo/ModelInfoPanel.vue b/src/platform/assets/components/modelInfo/ModelInfoPanel.vue
index d4b1deaff1..b9936caa2f 100644
--- a/src/platform/assets/components/modelInfo/ModelInfoPanel.vue
+++ b/src/platform/assets/components/modelInfo/ModelInfoPanel.vue
@@ -77,7 +77,7 @@
:placeholder="t('assetBrowser.modelInfo.selectModelType')"
/>
-
+
import { useDebounceFn } from '@vueuse/core'
import { computed, ref, useTemplateRef, watch } from 'vue'
+import type { StyleValue } from 'vue'
import { useI18n } from 'vue-i18n'
import EditableText from '@/components/common/EditableText.vue'
@@ -257,9 +258,10 @@ const accordionClass = cn(
'border-t border-border-default bg-modal-panel-background'
)
-const { asset, cacheKey } = defineProps<{
+const { asset, cacheKey, selectContentStyle } = defineProps<{
asset: AssetDisplayItem
cacheKey?: string
+ selectContentStyle?: StyleValue
}>()
const assetsStore = useAssetsStore()
diff --git a/src/platform/assets/composables/media/assetMappers.test.ts b/src/platform/assets/composables/media/assetMappers.test.ts
new file mode 100644
index 0000000000..a4667a234e
--- /dev/null
+++ b/src/platform/assets/composables/media/assetMappers.test.ts
@@ -0,0 +1,52 @@
+import { describe, expect, it, vi } from 'vitest'
+
+import { mapInputFileToAssetItem } from './assetMappers'
+
+vi.mock('@/scripts/api', () => ({
+ api: {
+ apiURL: (path: string) => `/api${path}`
+ }
+}))
+
+vi.mock('@/platform/distribution/cloudPreviewUtil', () => ({
+ appendCloudResParam: vi.fn()
+}))
+
+describe('mapInputFileToAssetItem', () => {
+ it('preserves a clean filename', () => {
+ const asset = mapInputFileToAssetItem('photo.png', 0, 'input')
+
+ expect(asset.name).toBe('photo.png')
+ expect(asset.id).toBe('input-0-photo.png')
+ expect(asset.preview_url).toBe('/api/view?filename=photo.png&type=input')
+ })
+
+ it.each([
+ ['photo.png [input]', 'photo.png'],
+ ['photo.png [output]', 'photo.png'],
+ ['photo.png [temp]', 'photo.png'],
+ ['clip.mp4[input]', 'clip.mp4'],
+ ['MyFile.WEBP [Input]', 'MyFile.WEBP']
+ ])('strips ComfyUI directory annotation: %s -> %s', (input, expectedName) => {
+ const asset = mapInputFileToAssetItem(input, 1, 'input')
+
+ expect(asset.name).toBe(expectedName)
+ expect(asset.id).toBe(`input-1-${expectedName}`)
+ expect(asset.preview_url).toBe(
+ `/api/view?filename=${encodeURIComponent(expectedName)}&type=input`
+ )
+ })
+
+ it('leaves non-annotation brackets in the filename intact', () => {
+ const asset = mapInputFileToAssetItem('my [draft] image.png', 0, 'input')
+
+ expect(asset.name).toBe('my [draft] image.png')
+ })
+
+ it('uses the directory passed in for the type query param', () => {
+ const asset = mapInputFileToAssetItem('clip.mp4 [output]', 0, 'output')
+
+ expect(asset.preview_url).toBe('/api/view?filename=clip.mp4&type=output')
+ expect(asset.tags).toEqual(['output'])
+ })
+})
diff --git a/src/platform/assets/composables/media/assetMappers.ts b/src/platform/assets/composables/media/assetMappers.ts
index da468ca9b3..67544a0e87 100644
--- a/src/platform/assets/composables/media/assetMappers.ts
+++ b/src/platform/assets/composables/media/assetMappers.ts
@@ -51,6 +51,17 @@ export function mapTaskOutputToAssetItem(
}
}
+/**
+ * Strips ComfyUI's trailing directory-type annotation (e.g. ` [input]`,
+ * ` [output]`, `[temp]`) from a filename returned by the OSS internal
+ * `/internal/files/{type}` endpoint. The annotation is part of the wire
+ * format LoadImage-style widgets expect, but for the assets sidebar we
+ * want the canonical on-disk filename so type detection / titles work.
+ */
+function stripDirectoryAnnotation(filename: string): string {
+ return filename.replace(/\s*\[(?:input|output|temp)\]\s*$/i, '')
+}
+
/**
* Maps input directory file to AssetItem format
* @param filename The filename
@@ -63,13 +74,14 @@ export function mapInputFileToAssetItem(
index: number,
directory: 'input' | 'output' = 'input'
): AssetItem {
- const params = new URLSearchParams({ filename, type: directory })
+ const cleanName = stripDirectoryAnnotation(filename)
+ const params = new URLSearchParams({ filename: cleanName, type: directory })
const preview_url = api.apiURL(`/view?${params}`)
- appendCloudResParam(params, filename)
+ appendCloudResParam(params, cleanName)
return {
- id: `${directory}-${index}-${filename}`,
- name: filename,
+ id: `${directory}-${index}-${cleanName}`,
+ name: cleanName,
size: 0,
created_at: new Date().toISOString(),
tags: [directory],
diff --git a/src/platform/assets/composables/useAssetBrowser.perf.test.ts b/src/platform/assets/composables/useAssetBrowser.perf.test.ts
new file mode 100644
index 0000000000..b15b44a492
--- /dev/null
+++ b/src/platform/assets/composables/useAssetBrowser.perf.test.ts
@@ -0,0 +1,113 @@
+import { createPinia, setActivePinia } from 'pinia'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { nextTick, ref } from 'vue'
+
+import { useAssetBrowser } from '@/platform/assets/composables/useAssetBrowser'
+import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
+import * as assetMetadataUtils from '@/platform/assets/utils/assetMetadataUtils'
+
+vi.mock('vue-i18n', () => ({
+ useI18n: () => ({
+ t: (key: string) => key
+ })
+}))
+
+vi.mock('@/i18n', () => ({
+ t: (key: string) => key,
+ d: (date: Date) => date.toLocaleDateString()
+}))
+
+const ASSET_COUNT = 200
+const CATEGORIES = ['inputs', 'outputs'] as const
+const TAB_SWITCHES = 6
+
+function makeAsset(index: number): AssetItem {
+ const category = CATEGORIES[index % CATEGORIES.length]
+ return {
+ id: `asset-${index}`,
+ name: `asset-${index}.safetensors`,
+ asset_hash: `blake3:${index}`,
+ size: 1024,
+ mime_type: 'application/octet-stream',
+ tags: ['models', category],
+ created_at: '2024-01-01T00:00:00Z',
+ updated_at: '2024-01-01T00:00:00Z',
+ last_access_time: '2024-01-01T00:00:00Z',
+ is_immutable: false
+ }
+}
+
+describe('useAssetBrowser - filter tab switching perf (FE-229)', () => {
+ beforeEach(() => {
+ setActivePinia(createPinia())
+ vi.restoreAllMocks()
+ })
+
+ it('does not re-transform every asset on each filter tab switch', async () => {
+ const assets = Array.from({ length: ASSET_COUNT }, (_, i) => makeAsset(i))
+ const filenameSpy = vi.spyOn(assetMetadataUtils, 'getAssetFilename')
+
+ const { selectedNavItem, filteredAssets } = useAssetBrowser(ref(assets))
+
+ // Initial materialization of the 'all' tab.
+ void filteredAssets.value
+ await nextTick()
+ const baselineCalls = filenameSpy.mock.calls.length
+
+ // Simulate the user clicking back and forth between All / Inputs / Outputs.
+ const tabs: ('all' | 'inputs' | 'outputs')[] = [
+ 'inputs',
+ 'outputs',
+ 'all',
+ 'inputs',
+ 'outputs',
+ 'all'
+ ]
+ expect(tabs).toHaveLength(TAB_SWITCHES)
+
+ for (const tab of tabs) {
+ selectedNavItem.value = tab
+ void filteredAssets.value
+ await nextTick()
+ }
+
+ const switchCalls = filenameSpy.mock.calls.length - baselineCalls
+
+ // Naive (no memoization) cost is approximately:
+ // inputs (100) + outputs (100) + all (200) + inputs (100) + outputs (100) + all (200) = 800.
+ // With per-asset memoization the same asset object should never be transformed twice,
+ // so total work across all tab switches must stay within a small multiple of ASSET_COUNT.
+ const budget = ASSET_COUNT * 2
+ expect(switchCalls).toBeLessThanOrEqual(budget)
+ })
+
+ it('returns identical display item references for unchanged assets across tab switches', async () => {
+ const assets = Array.from({ length: ASSET_COUNT }, (_, i) => makeAsset(i))
+
+ const { selectedNavItem, filteredAssets } = useAssetBrowser(ref(assets))
+
+ const firstAllSnapshot = new Map(
+ filteredAssets.value.map((item) => [item.id, item])
+ )
+ await nextTick()
+
+ selectedNavItem.value = 'inputs'
+ void filteredAssets.value
+ await nextTick()
+
+ selectedNavItem.value = 'all'
+ const secondAll = filteredAssets.value
+ await nextTick()
+
+ // If transformAssetForDisplay is memoized per asset, the display items for
+ // the unchanged underlying assets should be the very same object identity
+ // when we navigate back to 'all'. Without memoization every re-render
+ // produces brand-new objects, which forces downstream components
+ // (AssetGrid / AssetCard) to re-render every card.
+ const reusedReferences = secondAll.filter(
+ (item) => firstAllSnapshot.get(item.id) === item
+ ).length
+
+ expect(reusedReferences).toBe(ASSET_COUNT)
+ })
+})
diff --git a/src/platform/assets/composables/useAssetBrowser.ts b/src/platform/assets/composables/useAssetBrowser.ts
index 6879ae8125..03f042a4de 100644
--- a/src/platform/assets/composables/useAssetBrowser.ts
+++ b/src/platform/assets/composables/useAssetBrowser.ts
@@ -4,7 +4,7 @@ import { useFuse } from '@vueuse/integrations/useFuse'
import type { UseFuseOptions } from '@vueuse/integrations/useFuse'
import { storeToRefs } from 'pinia'
-import { d, t } from '@/i18n'
+import { t } from '@/i18n'
import type {
AssetFilterState,
OwnershipOption
@@ -38,12 +38,51 @@ export interface AssetDisplayItem extends AssetItem {
secondaryText: string
badges: AssetBadge[]
stats: {
- formattedDate?: string
downloadCount?: string
stars?: string
}
}
+const displayItemCache = new WeakMap()
+
+function buildDisplayItem(asset: AssetItem): AssetDisplayItem {
+ const badges: AssetBadge[] = []
+
+ const typeTag = asset.tags.find((tag) => tag !== 'models')
+ if (typeTag) {
+ const badgeLabel = typeTag.includes('/')
+ ? typeTag.substring(typeTag.indexOf('/') + 1)
+ : typeTag
+
+ badges.push({ label: badgeLabel, type: 'type' })
+ }
+
+ for (const model of getAssetBaseModels(asset)) {
+ badges.push({ label: model, type: 'base' })
+ }
+
+ // Intentionally no formatted date here — the WeakMap caches by AssetItem
+ // reference, so a pre-formatted string would pin the locale active at first
+ // transform. AssetCard formats `created_at` at render via `d()` instead.
+ return {
+ ...asset,
+ secondaryText: getAssetFilename(asset),
+ badges,
+ stats: {
+ downloadCount: undefined,
+ stars: undefined
+ }
+ }
+}
+
+function transformAssetForDisplay(asset: AssetItem): AssetDisplayItem {
+ const cached = displayItemCache.get(asset)
+ if (cached) return cached
+ const built = buildDisplayItem(asset)
+ displayItemCache.set(asset, built)
+ return built
+}
+
/**
* Asset Browser composable
* Manages search, filtering, asset transformation and selection logic
@@ -82,46 +121,6 @@ export function useAssetBrowser(
return selectedNavItem.value
})
- // Transform API asset to display asset
- function transformAssetForDisplay(asset: AssetItem): AssetDisplayItem {
- const secondaryText = getAssetFilename(asset)
-
- const badges: AssetBadge[] = []
-
- const typeTag = asset.tags.find((tag) => tag !== 'models')
- // Type badge from non-root tag
- if (typeTag) {
- // Remove category prefix from badge label (e.g. "checkpoint/model" → "model")
- const badgeLabel = typeTag.includes('/')
- ? typeTag.substring(typeTag.indexOf('/') + 1)
- : typeTag
-
- badges.push({ label: badgeLabel, type: 'type' })
- }
-
- // Base model badges from metadata
- const baseModels = getAssetBaseModels(asset)
- for (const model of baseModels) {
- badges.push({ label: model, type: 'base' })
- }
-
- // Create display stats from API data
- const stats = {
- formattedDate: asset.created_at
- ? d(new Date(asset.created_at), { dateStyle: 'short' })
- : undefined,
- downloadCount: undefined, // Not available in API
- stars: undefined // Not available in API
- }
-
- return {
- ...asset,
- secondaryText,
- badges,
- stats
- }
- }
-
const typeCategories = computed(() => {
const categories = assets.value
.filter((asset) => asset.tags.includes(MODELS_TAG))
diff --git a/src/platform/assets/composables/useMediaAssetActions.test.ts b/src/platform/assets/composables/useMediaAssetActions.test.ts
index 375a90abec..c5f558e0b6 100644
--- a/src/platform/assets/composables/useMediaAssetActions.test.ts
+++ b/src/platform/assets/composables/useMediaAssetActions.test.ts
@@ -167,6 +167,52 @@ vi.mock('@/scripts/api', () => ({
}
}))
+const mockAppGraph = vi.hoisted(() => ({ value: { _nodes: [] as unknown[] } }))
+vi.mock('@/scripts/app', () => ({
+ app: {
+ get graph() {
+ return mockAppGraph.value
+ },
+ get rootGraph() {
+ return mockAppGraph.value
+ }
+ }
+}))
+
+const mockRemoveNodeOutputs = vi.hoisted(() => vi.fn())
+const mockRemoveNodeOutputsForNode = vi.hoisted(() => vi.fn())
+vi.mock('@/stores/nodeOutputStore', () => ({
+ useNodeOutputStore: () => ({
+ removeNodeOutputs: mockRemoveNodeOutputs,
+ removeNodeOutputsForNode: mockRemoveNodeOutputsForNode
+ })
+}))
+
+const mockCaptureCanvasState = vi.hoisted(() => vi.fn())
+vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({
+ useWorkflowStore: () => ({
+ activeWorkflow: {
+ changeTracker: { captureCanvasState: mockCaptureCanvasState }
+ }
+ })
+}))
+
+const mockClearNodePreviewCache = vi.hoisted(() => vi.fn())
+vi.mock('../utils/clearNodePreviewCacheForValues', () => ({
+ clearNodePreviewCacheForValues: mockClearNodePreviewCache,
+ findNodesReferencingValues: vi.fn(() => [])
+}))
+
+const mockClearWidgetValues = vi.hoisted(() => vi.fn())
+vi.mock('../utils/clearDeletedAssetWidgetValues', () => ({
+ clearDeletedAssetWidgetValues: mockClearWidgetValues
+}))
+
+const mockMarkMissingMedia = vi.hoisted(() => vi.fn())
+vi.mock('../utils/markDeletedAssetsAsMissingMedia', () => ({
+ markDeletedAssetsAsMissingMedia: mockMarkMissingMedia
+}))
+
function createMockAsset(overrides: Partial = {}): AssetItem {
return {
id: 'test-asset-id',
@@ -793,4 +839,120 @@ describe('useMediaAssetActions', () => {
expect(dialogProps.itemList).toEqual(['fallback-image.png'])
})
})
+
+ describe('deleteAssets — FE-230 preview cache clearing', () => {
+ beforeEach(() => {
+ mockIsCloud.value = true
+ mockGetAssetType.mockReturnValue('input')
+ mockDeleteAsset.mockReset()
+ mockShowDialog.mockImplementation(
+ (opts: {
+ props: {
+ onConfirm: () => Promise | void
+ }
+ }) => {
+ void opts.props.onConfirm()
+ }
+ )
+ mockAppGraph.value = { _nodes: [] }
+ })
+
+ it('invokes clearNodePreviewCacheForValues with canonical widget-value variants', async () => {
+ mockDeleteAsset.mockResolvedValue(undefined)
+ const actions = useMediaAssetActions()
+ const asset = createMockAsset({
+ id: 'asset-match',
+ name: 'foo.png',
+ asset_hash: 'abc123.png',
+ tags: ['input']
+ })
+
+ await actions.deleteAssets(asset)
+
+ await vi.waitFor(() => {
+ expect(mockClearNodePreviewCache).toHaveBeenCalledTimes(1)
+ })
+ const [graphArg, valuesArg, removeArg] =
+ mockClearNodePreviewCache.mock.calls[0]
+ expect(graphArg).toBe(mockAppGraph.value)
+ expect(valuesArg).toEqual(
+ new Set(['foo.png', 'foo.png [input]', 'abc123.png'])
+ )
+ expect(typeof removeArg).toBe('function')
+
+ const sampleNode = { id: 42 }
+ removeArg(sampleNode)
+ expect(mockRemoveNodeOutputsForNode).toHaveBeenCalledWith(sampleNode)
+ // Locator is resolved from the node's own graph, not from the raw id —
+ // covers Load Image / Load Video nodes nested inside subgraphs.
+ expect(mockRemoveNodeOutputs).not.toHaveBeenCalled()
+
+ expect(mockClearWidgetValues).toHaveBeenCalledWith(
+ mockAppGraph.value,
+ new Set(['foo.png', 'foo.png [input]', 'abc123.png'])
+ )
+
+ expect(mockMarkMissingMedia).toHaveBeenCalledWith(
+ mockAppGraph.value,
+ new Set(['foo.png', 'foo.png [input]', 'abc123.png'])
+ )
+
+ // markMissing + previewCache must run before widget-value clearing,
+ // otherwise findNodesReferencingValues sees blanked widgets and matches
+ // nothing.
+ const markOrder = mockMarkMissingMedia.mock.invocationCallOrder[0]
+ const cacheOrder = mockClearNodePreviewCache.mock.invocationCallOrder[0]
+ const clearOrder = mockClearWidgetValues.mock.invocationCallOrder[0]
+ expect(markOrder).toBeLessThan(clearOrder)
+ expect(cacheOrder).toBeLessThan(clearOrder)
+
+ // Programmatic widget mutation doesn't go through DOM events, so the
+ // workflow won't be flagged as modified unless we capture explicitly.
+ expect(mockCaptureCanvasState).toHaveBeenCalled()
+ })
+
+ it('emits the [output]-annotated variant for output assets, including subfolder', async () => {
+ mockDeleteAsset.mockResolvedValue(undefined)
+ mockGetAssetType.mockReturnValue('output')
+ mockGetOutputAssetMetadata.mockReturnValue({
+ subfolder: 'outputs/2025'
+ })
+ const actions = useMediaAssetActions()
+ const asset = createMockAsset({
+ id: 'asset-output',
+ name: 'gen.png',
+ tags: ['output']
+ })
+
+ await actions.deleteAssets(asset)
+
+ await vi.waitFor(() => {
+ expect(mockClearNodePreviewCache).toHaveBeenCalledTimes(1)
+ })
+ const [, valuesArg] = mockClearNodePreviewCache.mock.calls[0]
+ expect(valuesArg).toEqual(new Set(['outputs/2025/gen.png [output]']))
+ expect(valuesArg.has('gen.png')).toBe(false)
+ expect(valuesArg.has('gen.png [input]')).toBe(false)
+ })
+
+ it('omits filenames of failed deletions and skips the helper when nothing was deleted', async () => {
+ mockDeleteAsset.mockRejectedValue(new Error('boom'))
+ const actions = useMediaAssetActions()
+ const asset = createMockAsset({
+ id: 'asset-failed',
+ name: 'failed.png',
+ asset_hash: 'failhash.png'
+ })
+
+ await actions.deleteAssets(asset)
+
+ await vi.waitFor(() => {
+ expect(mockDeleteAsset).toHaveBeenCalled()
+ })
+ expect(mockClearNodePreviewCache).not.toHaveBeenCalled()
+ expect(mockClearWidgetValues).not.toHaveBeenCalled()
+ expect(mockMarkMissingMedia).not.toHaveBeenCalled()
+ expect(mockCaptureCanvasState).not.toHaveBeenCalled()
+ })
+ })
})
diff --git a/src/platform/assets/composables/useMediaAssetActions.ts b/src/platform/assets/composables/useMediaAssetActions.ts
index 3f9b8e6ab7..6b0a9a631c 100644
--- a/src/platform/assets/composables/useMediaAssetActions.ts
+++ b/src/platform/assets/composables/useMediaAssetActions.ts
@@ -7,16 +7,22 @@ import { downloadFile } from '@/base/common/downloadUtil'
import { useCopyToClipboard } from '@/composables/useCopyToClipboard'
import { isCloud } from '@/platform/distribution/types'
import { useWorkflowActionsService } from '@/platform/workflow/core/services/workflowActionsService'
+import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import { extractWorkflowFromAsset } from '@/platform/workflow/utils/workflowExtractionUtil'
import { api } from '@/scripts/api'
+import { app } from '@/scripts/app'
import { useLitegraphService } from '@/services/litegraphService'
import { useNodeDefStore } from '@/stores/nodeDefStore'
import { getOutputAssetMetadata } from '../schemas/assetMetadataSchema'
import { useAssetsStore } from '@/stores/assetsStore'
import { useDialogStore } from '@/stores/dialogStore'
+import { useNodeOutputStore } from '@/stores/nodeOutputStore'
import { getAssetDisplayName } from '../utils/assetMetadataUtils'
import { getAssetType } from '../utils/assetTypeUtil'
import { getAssetUrl } from '../utils/assetUrlUtil'
+import { clearDeletedAssetWidgetValues } from '../utils/clearDeletedAssetWidgetValues'
+import { clearNodePreviewCacheForValues } from '../utils/clearNodePreviewCacheForValues'
+import { markDeletedAssetsAsMissingMedia } from '../utils/markDeletedAssetsAsMissingMedia'
import { getAssetOutputCount } from '../utils/outputAssetUtil'
import { createAnnotatedPath } from '@/utils/createAnnotatedPath'
import { detectNodeTypeFromFilename } from '@/utils/loaderNodeUtil'
@@ -30,6 +36,35 @@ import { assetService } from '../services/assetService'
const EXCLUDED_TAGS = new Set(['models', 'input', 'output'])
+/**
+ * Canonical widget-value strings that may reference this asset, scoped by the
+ * asset's source type so basenames cannot cross-match across input/output.
+ *
+ * Output assets emit ` [output]` (and the subfolder-prefixed form when
+ * present in metadata). Input/temp assets emit the bare name plus the explicit
+ * annotation. `asset_hash` is included whenever present, since cloud-stored
+ * assets can be referenced by hash.
+ */
+function widgetValueVariantsForAsset(asset: AssetItem): string[] {
+ const variants: string[] = []
+ const type = getAssetType(asset, 'input')
+ const name = asset.name
+ if (name) {
+ if (type === 'output') {
+ const subfolder = getOutputAssetMetadata(asset.user_metadata)?.subfolder
+ const path = subfolder ? `${subfolder}/${name}` : name
+ variants.push(`${path} [output]`)
+ } else if (type === 'temp') {
+ variants.push(`${name} [temp]`)
+ } else {
+ variants.push(name)
+ variants.push(`${name} [input]`)
+ }
+ }
+ if (asset.asset_hash) variants.push(asset.asset_hash)
+ return variants
+}
+
export function useMediaAssetActions() {
const { t } = useI18n()
const toast = useToast()
@@ -639,6 +674,31 @@ export function useMediaAssetActions() {
await assetsStore.updateInputs()
}
+ const rootGraph = app.rootGraph
+ if (rootGraph) {
+ const deletedValues = new Set()
+ assetArray.forEach((asset, index) => {
+ if (results[index].status !== 'fulfilled') return
+ for (const value of widgetValueVariantsForAsset(asset)) {
+ deletedValues.add(value)
+ }
+ })
+ if (deletedValues.size > 0) {
+ const nodeOutputStore = useNodeOutputStore()
+ // Order matters: mark + cache-clear both look up nodes by
+ // current widget.value, so they must run before
+ // clearDeletedAssetWidgetValues blanks those values.
+ markDeletedAssetsAsMissingMedia(rootGraph, deletedValues)
+ clearNodePreviewCacheForValues(
+ rootGraph,
+ deletedValues,
+ (node) => nodeOutputStore.removeNodeOutputsForNode(node)
+ )
+ clearDeletedAssetWidgetValues(rootGraph, deletedValues)
+ useWorkflowStore().activeWorkflow?.changeTracker?.captureCanvasState()
+ }
+ }
+
// Invalidate model caches for affected categories
const modelCategories = new Set()
diff --git a/src/platform/assets/mappings/modelNodeMappings.ts b/src/platform/assets/mappings/modelNodeMappings.ts
index fd2d60f590..0368c7ca52 100644
--- a/src/platform/assets/mappings/modelNodeMappings.ts
+++ b/src/platform/assets/mappings/modelNodeMappings.ts
@@ -52,9 +52,6 @@ export const MODEL_NODE_MAPPINGS: ReadonlyArray<
// ---- SAM3 3D segmentation (comfyui-sam3) ----
['sam3', 'LoadSAM3Model', 'model_path'],
- // ---- Ultralytics detection (comfyui-impact-subpack) ----
- ['ultralytics', 'UltralyticsDetectorProvider', 'model_name'],
-
// ---- DepthAnything (comfyui-depthanythingv2, comfyui-depthanythingv3) ----
['depthanything', 'DownloadAndLoadDepthAnythingV2Model', 'model'],
['depthanything3', 'DownloadAndLoadDepthAnythingV3Model', 'model'],
diff --git a/src/platform/assets/schemas/assetSchema.ts b/src/platform/assets/schemas/assetSchema.ts
index bd31113009..0f7017fea7 100644
--- a/src/platform/assets/schemas/assetSchema.ts
+++ b/src/platform/assets/schemas/assetSchema.ts
@@ -1,3 +1,4 @@
+import { zListAssetsResponse } from '@comfyorg/ingest-types/zod'
import { z } from 'zod'
// Zod schemas for asset API validation matching ComfyUI Assets REST API spec
@@ -20,11 +21,11 @@ const zAsset = z.object({
user_metadata: z.record(z.unknown()).optional() // API allows arbitrary key-value pairs
})
-const zAssetResponse = z.object({
- assets: z.array(zAsset).optional(),
- total: z.number().optional(),
- has_more: z.boolean().optional()
-})
+const zAssetResponse = zListAssetsResponse
+ .pick({ total: true, has_more: true })
+ .extend({
+ assets: z.array(zAsset)
+ })
const zModelFolder = z.object({
name: z.string(),
diff --git a/src/platform/assets/services/assetService.test.ts b/src/platform/assets/services/assetService.test.ts
index e718cb3d72..b073bcce71 100644
--- a/src/platform/assets/services/assetService.test.ts
+++ b/src/platform/assets/services/assetService.test.ts
@@ -1,11 +1,12 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
-import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
+import type {
+ AssetItem,
+ AssetResponse
+} from '@/platform/assets/schemas/assetSchema'
import {
MISSING_TAG,
- assetService,
- isBlake3AssetHash,
- toBlake3AssetHash
+ assetService
} from '@/platform/assets/services/assetService'
import { api } from '@/scripts/api'
@@ -49,9 +50,10 @@ vi.mock('@/i18n', () => ({
const fetchApiMock = vi.mocked(api.fetchApi)
-const validBlake3Hash =
- '1111111111111111111111111111111111111111111111111111111111111111'
-const validBlake3AssetHash = `blake3:${validBlake3Hash}`
+type AssetListResponseOptions = {
+ hasMore?: AssetResponse['has_more']
+ total?: AssetResponse['total']
+}
function buildResponse(
body: unknown,
@@ -64,6 +66,13 @@ function buildResponse(
} as unknown as Response
}
+function buildAssetListResponse(
+ assets: AssetItem[],
+ { hasMore = false, total = assets.length }: AssetListResponseOptions = {}
+): Response {
+ return buildResponse({ assets, total, has_more: hasMore })
+}
+
function validAsset(overrides: Partial = {}): AssetItem {
return {
id: 'asset-1',
@@ -189,25 +198,6 @@ describe(assetService.getAssetMetadata, () => {
})
})
-describe(isBlake3AssetHash, () => {
- it('accepts only prefixed 64-character blake3 hashes', () => {
- expect(isBlake3AssetHash(validBlake3AssetHash)).toBe(true)
- expect(isBlake3AssetHash('BLAKE3:' + validBlake3Hash.toUpperCase())).toBe(
- true
- )
- expect(isBlake3AssetHash('blake3:abc')).toBe(false)
- expect(isBlake3AssetHash(validBlake3Hash)).toBe(false)
- })
-})
-
-describe(toBlake3AssetHash, () => {
- it('normalizes 64-character blake3 hex values to asset hashes', () => {
- expect(toBlake3AssetHash(validBlake3Hash)).toBe(validBlake3AssetHash)
- expect(toBlake3AssetHash('abc')).toBeNull()
- expect(toBlake3AssetHash(undefined)).toBeNull()
- })
-})
-
describe(assetService.uploadAssetFromUrl, () => {
beforeEach(() => {
vi.clearAllMocks()
@@ -218,7 +208,7 @@ describe(assetService.uploadAssetFromUrl, () => {
const staleAssets = [validAsset({ id: 'stale-input', tags: ['input'] })]
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
fetchApiMock
- .mockResolvedValueOnce(buildResponse({ assets: staleAssets }))
+ .mockResolvedValueOnce(buildAssetListResponse(staleAssets))
.mockResolvedValueOnce(buildResponse({ id: 'missing-name' }))
await assetService.getInputAssetsIncludingPublic()
@@ -240,7 +230,7 @@ describe(assetService.uploadAssetFromUrl, () => {
const staleAssets = [validAsset({ id: 'stale-input', tags: ['input'] })]
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
fetchApiMock
- .mockResolvedValueOnce(buildResponse({ assets: staleAssets }))
+ .mockResolvedValueOnce(buildAssetListResponse(staleAssets))
.mockResolvedValueOnce(
buildResponse(validAsset({ id: 'uploaded-input', tags: ['input'] }))
)
@@ -301,7 +291,7 @@ describe(assetService.uploadAssetFromBase64, () => {
.spyOn(globalThis, 'fetch')
.mockResolvedValueOnce(new Response('hello'))
fetchApiMock
- .mockResolvedValueOnce(buildResponse({ assets: staleAssets }))
+ .mockResolvedValueOnce(buildAssetListResponse(staleAssets))
.mockResolvedValueOnce(buildResponse({ id: 'missing-name' }))
await assetService.getInputAssetsIncludingPublic()
@@ -327,7 +317,7 @@ describe(assetService.uploadAssetFromBase64, () => {
.spyOn(globalThis, 'fetch')
.mockResolvedValueOnce(new Response('hello'))
fetchApiMock
- .mockResolvedValueOnce(buildResponse({ assets: staleAssets }))
+ .mockResolvedValueOnce(buildAssetListResponse(staleAssets))
.mockResolvedValueOnce(
buildResponse({
...validAsset({ id: 'uploaded-input', tags: ['input'] }),
@@ -421,17 +411,14 @@ describe(assetService.getAssetModelFolders, () => {
vi.clearAllMocks()
})
- it('filters out missing-tagged assets and blacklisted directories, returning alphabetical unique folders without include_public', async () => {
+ it('requests missing-tag exclusion and returns alphabetical unique folders without include_public', async () => {
fetchApiMock.mockResolvedValueOnce(
- buildResponse({
- assets: [
- validAsset({ id: 'a', tags: ['models', 'loras'] }),
- validAsset({ id: 'b', tags: ['models', 'checkpoints'] }),
- validAsset({ id: 'c', tags: ['models', 'configs'] }),
- validAsset({ id: 'd', tags: ['models', 'missing', 'controlnet'] }),
- validAsset({ id: 'e', tags: ['models', 'loras'] })
- ]
- })
+ buildAssetListResponse([
+ validAsset({ id: 'a', tags: ['models', 'loras'] }),
+ validAsset({ id: 'b', tags: ['models', 'checkpoints'] }),
+ validAsset({ id: 'c', tags: ['models', 'configs'] }),
+ validAsset({ id: 'e', tags: ['models', 'loras'] })
+ ])
)
const folders = await assetService.getAssetModelFolders()
@@ -444,6 +431,7 @@ describe(assetService.getAssetModelFolders, () => {
const requestedUrl = fetchApiMock.mock.calls[0]?.[0] as string
const params = new URL(requestedUrl, 'http://localhost').searchParams
expect(params.has('include_public')).toBe(false)
+ expect(params.get('exclude_tags')).toBe(MISSING_TAG)
})
})
@@ -490,14 +478,9 @@ describe(assetService.getAssetsByTag, () => {
vi.clearAllMocks()
})
- it('forwards include_public=true by default and excludes missing-tagged assets', async () => {
+ it('forwards include_public=true by default and requests missing-tag exclusion', async () => {
fetchApiMock.mockResolvedValueOnce(
- buildResponse({
- assets: [
- validAsset({ id: 'visible', tags: ['input'] }),
- validAsset({ id: 'hidden', tags: ['input', 'missing'] })
- ]
- })
+ buildAssetListResponse([validAsset({ id: 'visible', tags: ['input'] })])
)
const assets = await assetService.getAssetsByTag('input')
@@ -507,6 +490,20 @@ describe(assetService.getAssetsByTag, () => {
const requestedUrl = fetchApiMock.mock.calls[0]?.[0] as string
const params = new URL(requestedUrl, 'http://localhost').searchParams
expect(params.get('include_public')).toBe('true')
+ expect(params.get('exclude_tags')).toBe(MISSING_TAG)
+ })
+
+ it('normalizes tag query parameters', async () => {
+ fetchApiMock.mockResolvedValueOnce(
+ buildAssetListResponse([validAsset({ id: 'visible', tags: ['input'] })])
+ )
+
+ await assetService.getAssetsByTag(' input ')
+
+ const requestedUrl = fetchApiMock.mock.calls[0]?.[0] as string
+ const params = new URL(requestedUrl, 'http://localhost').searchParams
+ expect(params.get('include_tags')).toBe('input')
+ expect(params.get('exclude_tags')).toBe(MISSING_TAG)
})
})
@@ -518,17 +515,16 @@ describe(assetService.getAllAssetsByTag, () => {
it('paginates tagged asset requests with include_public=true', async () => {
fetchApiMock
.mockResolvedValueOnce(
- buildResponse({
- assets: [
+ buildAssetListResponse(
+ [
validAsset({ id: 'a', tags: ['input'] }),
validAsset({ id: 'b', tags: ['input'] })
- ]
- })
+ ],
+ { hasMore: true }
+ )
)
.mockResolvedValueOnce(
- buildResponse({
- assets: [validAsset({ id: 'c', tags: ['input'] })]
- })
+ buildAssetListResponse([validAsset({ id: 'c', tags: ['input'] })])
)
const assets = await assetService.getAllAssetsByTag('input', true, {
@@ -540,63 +536,33 @@ describe(assetService.getAllAssetsByTag, () => {
const firstUrl = fetchApiMock.mock.calls[0]?.[0] as string
const firstParams = new URL(firstUrl, 'http://localhost').searchParams
expect(firstParams.get('include_public')).toBe('true')
+ expect(firstParams.get('exclude_tags')).toBe(MISSING_TAG)
expect(firstParams.get('limit')).toBe('2')
expect(firstParams.has('offset')).toBe(false)
const secondUrl = fetchApiMock.mock.calls[1]?.[0] as string
const secondParams = new URL(secondUrl, 'http://localhost').searchParams
expect(secondParams.get('include_public')).toBe('true')
+ expect(secondParams.get('exclude_tags')).toBe(MISSING_TAG)
expect(secondParams.get('limit')).toBe('2')
expect(secondParams.get('offset')).toBe('2')
})
- it('paginates from raw response size before filtering missing-tagged assets', async () => {
- fetchApiMock
- .mockResolvedValueOnce(
- buildResponse({
- assets: [
- validAsset({ id: 'visible', tags: ['input'] }),
- validAsset({ id: 'hidden', tags: ['input', MISSING_TAG] })
- ]
- })
- )
- .mockResolvedValueOnce(
- buildResponse({
- assets: [validAsset({ id: 'later-public', tags: ['input'] })]
- })
- )
-
- const assets = await assetService.getAllAssetsByTag('input', true, {
- limit: 2
- })
-
- expect(assets.map((a) => a.id)).toEqual(['visible', 'later-public'])
- expect(fetchApiMock).toHaveBeenCalledTimes(2)
-
- const secondUrl = fetchApiMock.mock.calls[1]?.[0]
- if (typeof secondUrl !== 'string') {
- throw new Error('Expected a second asset request URL')
- }
- const secondParams = new URL(secondUrl, 'http://localhost').searchParams
- expect(secondParams.get('offset')).toBe('2')
- })
-
it('honors has_more when walking tagged asset pages', async () => {
fetchApiMock
.mockResolvedValueOnce(
- buildResponse({
- assets: [
+ buildAssetListResponse(
+ [
validAsset({ id: 'first', tags: ['input'] }),
validAsset({ id: 'second', tags: ['input'] })
],
- has_more: true
- })
+ { hasMore: true }
+ )
)
.mockResolvedValueOnce(
- buildResponse({
- assets: [validAsset({ id: 'later-public', tags: ['input'] })],
- has_more: false
- })
+ buildAssetListResponse([
+ validAsset({ id: 'later-public', tags: ['input'] })
+ ])
)
const assets = await assetService.getAllAssetsByTag('input', true, {
@@ -614,12 +580,41 @@ describe(assetService.getAllAssetsByTag, () => {
expect(secondParams.get('offset')).toBe('2')
})
+ it.each([
+ {
+ name: 'missing has_more',
+ body: {
+ assets: [validAsset({ id: 'a', tags: ['input'] })],
+ total: 1
+ }
+ },
+ {
+ name: 'missing total',
+ body: {
+ assets: [validAsset({ id: 'a', tags: ['input'] })],
+ has_more: false
+ }
+ },
+ {
+ name: 'non-boolean has_more',
+ body: {
+ assets: [validAsset({ id: 'a', tags: ['input'] })],
+ total: 1,
+ has_more: 'false'
+ }
+ }
+ ])('rejects asset responses with $name', async ({ body }) => {
+ fetchApiMock.mockResolvedValueOnce(buildResponse(body))
+
+ await expect(
+ assetService.getAllAssetsByTag('input', true, { limit: 2 })
+ ).rejects.toThrow(/Invalid asset response/)
+ })
+
it('passes abort signals through paginated requests', async () => {
const controller = new AbortController()
fetchApiMock.mockResolvedValueOnce(
- buildResponse({
- assets: [validAsset({ id: 'a', tags: ['input'] })]
- })
+ buildAssetListResponse([validAsset({ id: 'a', tags: ['input'] })])
)
await assetService.getAllAssetsByTag('input', true, {
@@ -636,12 +631,13 @@ describe(assetService.getAllAssetsByTag, () => {
const controller = new AbortController()
fetchApiMock.mockImplementationOnce(async () => {
controller.abort()
- return buildResponse({
- assets: [
+ return buildAssetListResponse(
+ [
validAsset({ id: 'a', tags: ['input'] }),
validAsset({ id: 'b', tags: ['input'] })
- ]
- })
+ ],
+ { hasMore: true }
+ )
})
await expect(
@@ -666,7 +662,7 @@ describe(assetService.getInputAssetsIncludingPublic, () => {
validAsset({ id: 'user-input', tags: ['input'] }),
validAsset({ id: 'public-input', tags: ['input'], is_immutable: true })
]
- fetchApiMock.mockResolvedValueOnce(buildResponse({ assets }))
+ fetchApiMock.mockResolvedValueOnce(buildAssetListResponse(assets))
const first = await assetService.getInputAssetsIncludingPublic()
const second = await assetService.getInputAssetsIncludingPublic()
@@ -685,8 +681,8 @@ describe(assetService.getInputAssetsIncludingPublic, () => {
const staleAssets = [validAsset({ id: 'stale-input', tags: ['input'] })]
const freshAssets = [validAsset({ id: 'fresh-input', tags: ['input'] })]
fetchApiMock
- .mockResolvedValueOnce(buildResponse({ assets: staleAssets }))
- .mockResolvedValueOnce(buildResponse({ assets: freshAssets }))
+ .mockResolvedValueOnce(buildAssetListResponse(staleAssets))
+ .mockResolvedValueOnce(buildAssetListResponse(freshAssets))
await assetService.getInputAssetsIncludingPublic()
assetService.invalidateInputAssetsIncludingPublic()
@@ -720,7 +716,7 @@ describe(assetService.getInputAssetsIncludingPublic, () => {
await expect(first).rejects.toMatchObject({ name: 'AbortError' })
expect(serviceSignal).toBeUndefined()
- resolveResponse(buildResponse({ assets }))
+ resolveResponse(buildAssetListResponse(assets))
await expect(second).resolves.toEqual(assets)
expect(fetchApiMock).toHaveBeenCalledOnce()
@@ -750,7 +746,7 @@ describe(assetService.getInputAssetsIncludingPublic, () => {
await expect(first).rejects.toMatchObject({ name: 'AbortError' })
await expect(second).rejects.toMatchObject({ name: 'AbortError' })
- resolveResponse(buildResponse({ assets }))
+ resolveResponse(buildAssetListResponse(assets))
await Promise.resolve()
await expect(assetService.getInputAssetsIncludingPublic()).resolves.toEqual(
@@ -770,12 +766,12 @@ describe(assetService.getInputAssetsIncludingPublic, () => {
resolveResponse = resolve
})
)
- .mockResolvedValueOnce(buildResponse({ assets: freshAssets }))
+ .mockResolvedValueOnce(buildAssetListResponse(freshAssets))
const inFlight = assetService.getInputAssetsIncludingPublic()
assetService.invalidateInputAssetsIncludingPublic()
- resolveResponse(buildResponse({ assets }))
+ resolveResponse(buildAssetListResponse(assets))
await expect(inFlight).resolves.toEqual(assets)
await expect(assetService.getInputAssetsIncludingPublic()).resolves.toEqual(
@@ -788,9 +784,9 @@ describe(assetService.getInputAssetsIncludingPublic, () => {
const staleAssets = [validAsset({ id: 'stale-input', tags: ['input'] })]
const freshAssets = [validAsset({ id: 'fresh-input', tags: ['input'] })]
fetchApiMock
- .mockResolvedValueOnce(buildResponse({ assets: staleAssets }))
+ .mockResolvedValueOnce(buildAssetListResponse(staleAssets))
.mockResolvedValueOnce(buildResponse(null))
- .mockResolvedValueOnce(buildResponse({ assets: freshAssets }))
+ .mockResolvedValueOnce(buildAssetListResponse(freshAssets))
await assetService.getInputAssetsIncludingPublic()
await assetService.deleteAsset('stale-input')
@@ -809,9 +805,9 @@ describe(assetService.getInputAssetsIncludingPublic, () => {
const uploadedAsset = validAsset({ id: 'uploaded-input', tags: ['input'] })
const freshAssets = [uploadedAsset]
fetchApiMock
- .mockResolvedValueOnce(buildResponse({ assets: staleAssets }))
+ .mockResolvedValueOnce(buildAssetListResponse(staleAssets))
.mockResolvedValueOnce(buildResponse(uploadedAsset))
- .mockResolvedValueOnce(buildResponse({ assets: freshAssets }))
+ .mockResolvedValueOnce(buildAssetListResponse(freshAssets))
await assetService.getInputAssetsIncludingPublic()
await assetService.uploadAssetAsync({
@@ -827,7 +823,7 @@ describe(assetService.getInputAssetsIncludingPublic, () => {
it('does not invalidate cached input assets for pending async input uploads', async () => {
const staleAssets = [validAsset({ id: 'stale-input', tags: ['input'] })]
fetchApiMock
- .mockResolvedValueOnce(buildResponse({ assets: staleAssets }))
+ .mockResolvedValueOnce(buildAssetListResponse(staleAssets))
.mockResolvedValueOnce(
buildResponse(
{ task_id: 'task-1', status: 'running' },
@@ -849,7 +845,7 @@ describe(assetService.getInputAssetsIncludingPublic, () => {
it('does not invalidate cached input assets for non-input uploads', async () => {
const staleAssets = [validAsset({ id: 'stale-input', tags: ['input'] })]
fetchApiMock
- .mockResolvedValueOnce(buildResponse({ assets: staleAssets }))
+ .mockResolvedValueOnce(buildAssetListResponse(staleAssets))
.mockResolvedValueOnce(buildResponse(validAsset({ tags: ['models'] })))
await assetService.getInputAssetsIncludingPublic()
@@ -863,37 +859,3 @@ describe(assetService.getInputAssetsIncludingPublic, () => {
expect(fetchApiMock).toHaveBeenCalledTimes(2)
})
})
-
-describe(assetService.checkAssetHash, () => {
- beforeEach(() => {
- vi.clearAllMocks()
- })
-
- it.each([
- [200, 'exists'],
- [404, 'missing'],
- [400, 'invalid']
- ] as const)('maps %s responses to %s', async (status, expected) => {
- const hash =
- 'blake3:1111111111111111111111111111111111111111111111111111111111111111'
- fetchApiMock.mockResolvedValueOnce(buildResponse(null, { status }))
-
- await expect(assetService.checkAssetHash(hash)).resolves.toBe(expected)
-
- expect(fetchApiMock).toHaveBeenCalledWith(
- `/assets/hash/${encodeURIComponent(hash)}`,
- {
- method: 'HEAD',
- signal: undefined
- }
- )
- })
-
- it('throws for unexpected responses', async () => {
- fetchApiMock.mockResolvedValueOnce(buildResponse(null, { status: 500 }))
-
- await expect(assetService.checkAssetHash('blake3:abc')).rejects.toThrow(
- 'Unexpected asset hash check status: 500'
- )
- })
-})
diff --git a/src/platform/assets/services/assetService.ts b/src/platform/assets/services/assetService.ts
index 65132bace7..3ac5dc2c71 100644
--- a/src/platform/assets/services/assetService.ts
+++ b/src/platform/assets/services/assetService.ts
@@ -36,6 +36,7 @@ interface AssetPaginationOptions extends PaginationOptions {
interface AssetRequestOptions extends PaginationOptions {
includeTags: string[]
+ excludeTags?: string[]
includePublic?: boolean
signal?: AbortSignal
}
@@ -181,27 +182,12 @@ const INPUT_ASSETS_WITH_PUBLIC_LIMIT = 500
export const MODELS_TAG = 'models'
/** Asset tag used by the backend for placeholder records that are not installed. */
export const MISSING_TAG = 'missing'
+const DEFAULT_EXCLUDED_ASSET_TAGS = [MISSING_TAG]
-/** Result of a HEAD lookup against an exact asset hash. */
-export type AssetHashStatus = 'exists' | 'missing' | 'invalid'
-
-const BLAKE3_ASSET_HASH_PATTERN = /^blake3:[0-9a-f]{64}$/i
-const BLAKE3_HEX_PATTERN = /^[0-9a-f]{64}$/i
const uploadedAssetResponseSchema = assetItemSchema.extend({
created_new: z.boolean()
})
-/** Returns true for a prefixed BLAKE3 asset hash: `blake3:<64 hex>`. */
-export function isBlake3AssetHash(value: string): boolean {
- return BLAKE3_ASSET_HASH_PATTERN.test(value)
-}
-
-/** Converts a raw 64-character BLAKE3 hex digest into an asset hash. */
-export function toBlake3AssetHash(hash: string | undefined): string | null {
- if (!hash || !BLAKE3_HEX_PATTERN.test(hash)) return null
- return `blake3:${hash}`
-}
-
function createAbortError(): DOMException {
return new DOMException('Aborted', 'AbortError')
}
@@ -210,6 +196,10 @@ function throwIfAborted(signal?: AbortSignal): void {
if (signal?.aborted) throw createAbortError()
}
+function normalizeAssetTags(tags: string[]): string[] {
+ return tags.map((tag) => tag.trim()).filter(Boolean)
+}
+
async function withCallerAbort(
promise: Promise,
signal?: AbortSignal
@@ -290,15 +280,22 @@ function createAssetService() {
): Promise {
const {
includeTags,
+ excludeTags = DEFAULT_EXCLUDED_ASSET_TAGS,
limit = DEFAULT_LIMIT,
offset,
includePublic,
signal
} = options
+ const normalizedIncludeTags = normalizeAssetTags(includeTags)
+ const normalizedExcludeTags = normalizeAssetTags(excludeTags)
+
const queryParams = new URLSearchParams({
- include_tags: includeTags.join(','),
+ include_tags: normalizedIncludeTags.join(','),
limit: limit.toString()
})
+ if (normalizedExcludeTags.length > 0) {
+ queryParams.set('exclude_tags', normalizedExcludeTags.join(','))
+ }
if (offset !== undefined && offset > 0) {
queryParams.set('offset', offset.toString())
}
@@ -337,15 +334,10 @@ function createAssetService() {
// Blacklist directories we don't want to show
const blacklistedDirectories = new Set(['configs'])
- // Extract directory names from assets that actually exist, exclude missing assets
- const discoveredFolders = new Set(
- data?.assets
- ?.filter((asset) => !asset.tags.includes(MISSING_TAG))
- ?.flatMap((asset) => asset.tags)
- ?.filter(
- (tag) => tag !== MODELS_TAG && !blacklistedDirectories.has(tag)
- ) ?? []
- )
+ const folderTags = data.assets
+ .flatMap((asset) => asset.tags)
+ .filter((tag) => tag !== MODELS_TAG && !blacklistedDirectories.has(tag))
+ const discoveredFolders = new Set(folderTags)
// Return only discovered folders in alphabetical order
const sortedFolders = Array.from(discoveredFolders).toSorted()
@@ -363,17 +355,10 @@ function createAssetService() {
`models for ${folder}`
)
- return (
- data?.assets
- ?.filter(
- (asset) =>
- !asset.tags.includes(MISSING_TAG) && asset.tags.includes(folder)
- )
- ?.map((asset) => ({
- name: asset.name,
- pathIndex: 0
- })) ?? []
- )
+ return data.assets.map((asset) => ({
+ name: asset.name,
+ pathIndex: 0
+ }))
}
/**
@@ -449,12 +434,7 @@ function createAssetService() {
)
// Return full AssetItem[] objects (don't strip like getAssetModels does)
- return (
- data?.assets?.filter(
- (asset) =>
- !asset.tags.includes(MISSING_TAG) && asset.tags.includes(category)
- ) ?? []
- )
+ return data.assets
}
/**
@@ -473,11 +453,8 @@ function createAssetService() {
}
const data = await res.json()
- // Validate the single asset response against our schema
- const result = assetResponseSchema.safeParse({ assets: [data] })
- if (result.success && result.data.assets?.[0]) {
- return result.data.assets[0]
- }
+ const result = assetItemSchema.safeParse(data)
+ if (result.success) return result.data
const error = result.error
? fromZodError(result.error)
@@ -503,18 +480,32 @@ function createAssetService() {
includePublic: boolean = true,
{ limit = DEFAULT_LIMIT, offset = 0, signal }: AssetPaginationOptions = {}
): Promise {
- const data = await handleAssetRequest(
+ const data = await getAssetsPageByTag(tag, includePublic, {
+ limit,
+ offset,
+ signal
+ })
+
+ return data.assets
+ }
+
+ /**
+ * Gets one paginated asset response filtered by a specific tag.
+ */
+ async function getAssetsPageByTag(
+ tag: string,
+ includePublic: boolean = true,
+ { limit = DEFAULT_LIMIT, offset = 0, signal }: AssetPaginationOptions = {}
+ ): Promise {
+ return await handleAssetRequest(
{ includeTags: [tag], limit, offset, includePublic, signal },
`assets for tag ${tag}`
)
-
- return (
- data?.assets?.filter((asset) => !asset.tags.includes(MISSING_TAG)) ?? []
- )
}
/**
* Gets every asset for a tag by walking paginated asset API responses.
+ * Pagination follows the required server-provided `has_more` flag.
*
* @param tag - The tag to filter by (e.g., 'models', 'input')
* @param includePublic - Whether to include public assets (default: true)
@@ -535,23 +526,19 @@ function createAssetService() {
while (true) {
if (signal?.aborted) throw createAbortError()
- const data = await handleAssetRequest(
- {
- includeTags: [tag],
- limit: pageSize,
- offset,
- includePublic,
- signal
- },
- `assets for tag ${tag}`
- )
- const batch = data.assets ?? []
- assets.push(...batch.filter((asset) => !asset.tags.includes(MISSING_TAG)))
+ const data = await getAssetsPageByTag(tag, includePublic, {
+ limit: pageSize,
+ offset,
+ signal
+ })
+ const batch = data.assets
+ if (batch.length === 0) {
+ return assets
+ }
- const noMoreFromServer = data.has_more === false
- const inferredLastPage =
- data.has_more === undefined && batch.length < pageSize
- if (batch.length === 0 || noMoreFromServer || inferredLastPage) {
+ assets.push(...batch)
+
+ if (!data.has_more) {
return assets
}
@@ -598,31 +585,6 @@ function createAssetService() {
return await withCallerAbort(request, signal)
}
- /**
- * Checks whether an asset exists for an exact asset hash.
- *
- * Uses the HEAD /assets/hash/{hash} endpoint and maps status-only responses:
- * 200 -> exists, 404 -> missing, and 400 -> invalid hash format.
- */
- async function checkAssetHash(
- assetHash: string,
- signal?: AbortSignal
- ): Promise {
- const response = await api.fetchApi(
- `${ASSETS_ENDPOINT}/hash/${encodeURIComponent(assetHash)}`,
- {
- method: 'HEAD',
- signal
- }
- )
-
- if (response.status === 200) return 'exists'
- if (response.status === 404) return 'missing'
- if (response.status === 400) return 'invalid'
-
- throw new Error(`Unexpected asset hash check status: ${response.status}`)
- }
-
/**
* Deletes an asset by ID
* Only available in cloud environment
@@ -983,10 +945,10 @@ function createAssetService() {
getAssetsForNodeType,
getAssetDetails,
getAssetsByTag,
+ getAssetsPageByTag,
getAllAssetsByTag,
getInputAssetsIncludingPublic,
invalidateInputAssetsIncludingPublic,
- checkAssetHash,
deleteAsset,
updateAsset,
addAssetTags,
diff --git a/src/platform/assets/utils/assetPreviewUtil.test.ts b/src/platform/assets/utils/assetPreviewUtil.test.ts
index 738ef256a8..07c71a9b99 100644
--- a/src/platform/assets/utils/assetPreviewUtil.test.ts
+++ b/src/platform/assets/utils/assetPreviewUtil.test.ts
@@ -15,6 +15,7 @@ const mockGetServerFeature = vi.hoisted(() => vi.fn(() => false))
const mockIsAssetAPIEnabled = vi.hoisted(() => vi.fn(() => false))
const mockUploadAssetFromBase64 = vi.hoisted(() => vi.fn())
const mockUpdateAsset = vi.hoisted(() => vi.fn())
+const mockSetAssetPreview = vi.hoisted(() => vi.fn())
vi.mock('@/scripts/api', () => ({
api: {
@@ -33,6 +34,10 @@ vi.mock('@/platform/assets/services/assetService', () => ({
}
}))
+vi.mock('@/stores/assetsStore', () => ({
+ useAssetsStore: () => ({ setAssetPreview: mockSetAssetPreview })
+}))
+
function mockFetchResponse(assets: Record[]) {
mockFetchApi.mockResolvedValueOnce({
ok: true,
@@ -264,4 +269,66 @@ describe('persistThumbnail', () => {
preview_id: 'new-preview-id'
})
})
+
+ it('patches the assets store by name with the new preview after upload', async () => {
+ mockFetchEmpty()
+ mockFetchResponse([localAsset])
+ mockUploadAssetFromBase64.mockResolvedValue({ id: 'new-preview-id' })
+ mockUpdateAsset.mockResolvedValue({})
+
+ const blob = new Blob(['fake-png'], { type: 'image/png' })
+ await persistThumbnail('ComfyUI_00081_.glb', blob)
+
+ expect(mockSetAssetPreview).toHaveBeenCalledWith(
+ localAsset.name,
+ 'new-preview-id',
+ 'http://localhost:8188/assets/new-preview-id/content'
+ )
+ })
+
+ it('uses the cloud asset name (not the hash) when patching the store', async () => {
+ mockFetchResponse([cloudAsset])
+ mockUploadAssetFromBase64.mockResolvedValue({ id: 'new-preview-id' })
+ mockUpdateAsset.mockResolvedValue({})
+
+ const blob = new Blob(['fake-png'], { type: 'image/png' })
+ await persistThumbnail('c6cadcee57dd.glb', blob)
+
+ expect(mockSetAssetPreview).toHaveBeenCalledWith(
+ cloudAsset.name,
+ 'new-preview-id',
+ 'http://localhost:8188/assets/new-preview-id/content'
+ )
+ })
+
+ it('does not patch the store when the asset already has a preview', async () => {
+ mockFetchEmpty()
+ mockFetchResponse([localAssetWithPreview])
+
+ const blob = new Blob(['fake-png'], { type: 'image/png' })
+ await persistThumbnail('ComfyUI_00081_.glb', blob)
+
+ expect(mockSetAssetPreview).not.toHaveBeenCalled()
+ })
+
+ it('does not patch the store when no asset is found', async () => {
+ mockFetchEmpty()
+ mockFetchEmpty()
+
+ const blob = new Blob(['fake-png'], { type: 'image/png' })
+ await persistThumbnail('nonexistent.glb', blob)
+
+ expect(mockSetAssetPreview).not.toHaveBeenCalled()
+ })
+
+ it('does not patch the store when upload fails', async () => {
+ mockFetchEmpty()
+ mockFetchResponse([localAsset])
+ mockUploadAssetFromBase64.mockRejectedValue(new Error('upload failed'))
+
+ const blob = new Blob(['fake-png'], { type: 'image/png' })
+ await persistThumbnail('ComfyUI_00081_.glb', blob)
+
+ expect(mockSetAssetPreview).not.toHaveBeenCalled()
+ })
})
diff --git a/src/platform/assets/utils/assetPreviewUtil.ts b/src/platform/assets/utils/assetPreviewUtil.ts
index c9fbcce9ec..bdb604abac 100644
--- a/src/platform/assets/utils/assetPreviewUtil.ts
+++ b/src/platform/assets/utils/assetPreviewUtil.ts
@@ -1,5 +1,6 @@
import { assetService } from '@/platform/assets/services/assetService'
import { api } from '@/scripts/api'
+import { useAssetsStore } from '@/stores/assetsStore'
interface AssetRecord {
id: string
@@ -80,6 +81,9 @@ export async function persistThumbnail(
await assetService.updateAsset(asset.id, {
preview_id: uploaded.id
})
+
+ const previewUrl = api.apiURL(`/assets/${uploaded.id}/content`)
+ useAssetsStore().setAssetPreview(asset.name, uploaded.id, previewUrl)
} catch {
// Non-critical — client still shows the rendered thumbnail
}
diff --git a/src/platform/assets/utils/clearDeletedAssetWidgetValues.test.ts b/src/platform/assets/utils/clearDeletedAssetWidgetValues.test.ts
new file mode 100644
index 0000000000..239654eaa9
--- /dev/null
+++ b/src/platform/assets/utils/clearDeletedAssetWidgetValues.test.ts
@@ -0,0 +1,173 @@
+import { describe, expect, it, vi } from 'vitest'
+
+import type { LGraph } from '@/lib/litegraph/src/litegraph'
+
+import { clearDeletedAssetWidgetValues } from './clearDeletedAssetWidgetValues'
+
+type MockWidget = {
+ name: string
+ value: unknown
+ callback?: (value: unknown) => void
+}
+type MockNode = {
+ id: number
+ widgets?: MockWidget[]
+ graph?: { setDirtyCanvas: (v: boolean) => void }
+ isSubgraphNode?: () => boolean
+ subgraph?: { nodes: MockNode[] }
+}
+
+function makeGraph(nodes: MockNode[]): LGraph {
+ return { nodes } as unknown as LGraph
+}
+
+describe('FE-230 clearDeletedAssetWidgetValues', () => {
+ it('clears widget.value and invokes widget.callback so consumers run their own change-handling', () => {
+ const setDirty = vi.fn()
+ const callback = vi.fn()
+ const node: MockNode = {
+ id: 1,
+ widgets: [{ name: 'image', value: 'outputs/foo.png [output]', callback }],
+ graph: { setDirtyCanvas: setDirty }
+ }
+
+ clearDeletedAssetWidgetValues(
+ makeGraph([node]),
+ new Set(['outputs/foo.png [output]'])
+ )
+
+ expect(node.widgets![0].value).toBe('')
+ expect(callback).toHaveBeenCalledWith('')
+ expect(setDirty).toHaveBeenCalledWith(true)
+ })
+
+ it('leaves untouched widgets that do not match deleted values', () => {
+ const matchedCallback = vi.fn()
+ const keptCallback = vi.fn()
+ const node: MockNode = {
+ id: 2,
+ widgets: [
+ {
+ name: 'image',
+ value: 'outputs/foo.png [output]',
+ callback: matchedCallback
+ },
+ { name: 'mask', value: 'inputs/keep.png', callback: keptCallback }
+ ],
+ graph: { setDirtyCanvas: vi.fn() }
+ }
+
+ clearDeletedAssetWidgetValues(
+ makeGraph([node]),
+ new Set(['outputs/foo.png [output]'])
+ )
+
+ expect(node.widgets![0].value).toBe('')
+ expect(node.widgets![1].value).toBe('inputs/keep.png')
+ expect(matchedCallback).toHaveBeenCalledWith('')
+ expect(keptCallback).not.toHaveBeenCalled()
+ })
+
+ it('leaves nodes alone when none of their widgets reference a deleted value (mask-editor case)', () => {
+ const setDirty = vi.fn()
+ const callback = vi.fn()
+ const node: MockNode = {
+ id: 3,
+ widgets: [
+ {
+ name: 'image',
+ value: 'clipspace/clipspace-painted-masked-1.png [input]',
+ callback
+ }
+ ],
+ graph: { setDirtyCanvas: setDirty }
+ }
+
+ clearDeletedAssetWidgetValues(
+ makeGraph([node]),
+ new Set(['outputs/some-other-asset.png [output]'])
+ )
+
+ expect(node.widgets![0].value).toBe(
+ 'clipspace/clipspace-painted-masked-1.png [input]'
+ )
+ expect(callback).not.toHaveBeenCalled()
+ expect(setDirty).not.toHaveBeenCalled()
+ })
+
+ it('no-ops when the deleted-values set is empty', () => {
+ const setDirty = vi.fn()
+ const callback = vi.fn()
+ const node: MockNode = {
+ id: 4,
+ widgets: [{ name: 'image', value: 'outputs/foo.png [output]', callback }],
+ graph: { setDirtyCanvas: setDirty }
+ }
+
+ clearDeletedAssetWidgetValues(makeGraph([node]), new Set())
+
+ expect(node.widgets![0].value).toBe('outputs/foo.png [output]')
+ expect(callback).not.toHaveBeenCalled()
+ expect(setDirty).not.toHaveBeenCalled()
+ })
+
+ it('handles widgets without a callback (legacy nodes) without throwing', () => {
+ const node: MockNode = {
+ id: 5,
+ widgets: [{ name: 'image', value: 'outputs/foo.png [output]' }],
+ graph: { setDirtyCanvas: vi.fn() }
+ }
+
+ expect(() =>
+ clearDeletedAssetWidgetValues(
+ makeGraph([node]),
+ new Set(['outputs/foo.png [output]'])
+ )
+ ).not.toThrow()
+
+ expect(node.widgets![0].value).toBe('')
+ })
+
+ it('clears all matching widgets across multiple nodes', () => {
+ const cbA = vi.fn()
+ const cbB = vi.fn()
+ const nodeA: MockNode = {
+ id: 6,
+ widgets: [
+ { name: 'image', value: 'outputs/a.png [output]', callback: cbA }
+ ],
+ graph: { setDirtyCanvas: vi.fn() }
+ }
+ const nodeB: MockNode = {
+ id: 7,
+ widgets: [
+ { name: 'image', value: 'outputs/a.png [output]', callback: cbB }
+ ],
+ graph: { setDirtyCanvas: vi.fn() }
+ }
+
+ clearDeletedAssetWidgetValues(
+ makeGraph([nodeA, nodeB]),
+ new Set(['outputs/a.png [output]'])
+ )
+
+ expect(nodeA.widgets![0].value).toBe('')
+ expect(nodeB.widgets![0].value).toBe('')
+ expect(cbA).toHaveBeenCalledWith('')
+ expect(cbB).toHaveBeenCalledWith('')
+ })
+
+ it('does not affect nodes without widgets', () => {
+ const node: MockNode = {
+ id: 8,
+ graph: { setDirtyCanvas: vi.fn() }
+ }
+
+ expect(() =>
+ clearDeletedAssetWidgetValues(
+ makeGraph([node]),
+ new Set(['outputs/foo.png [output]'])
+ )
+ ).not.toThrow()
+ })
+})
diff --git a/src/platform/assets/utils/clearDeletedAssetWidgetValues.ts b/src/platform/assets/utils/clearDeletedAssetWidgetValues.ts
new file mode 100644
index 0000000000..daf3ba6299
--- /dev/null
+++ b/src/platform/assets/utils/clearDeletedAssetWidgetValues.ts
@@ -0,0 +1,40 @@
+import type { LGraph, Subgraph } from '@/lib/litegraph/src/litegraph'
+
+import { findNodesReferencingValues } from './clearNodePreviewCacheForValues'
+
+/**
+ * Clear widget values that reference deleted assets so the persisted workflow
+ * JSON stops claiming the deleted asset is in use.
+ *
+ * Without this, after `useMediaAssetActions.deleteAssets` succeeds the
+ * in-memory preview is cleared (`clearNodePreviewCacheForValues`) but the
+ * widget value still points at the deleted asset. On reload the workflow JSON
+ * is restored verbatim and `useImageUploadWidget` re-fetches the URL — for
+ * output assets the file is still served (history-soft-delete), so the
+ * preview re-renders despite the asset being "deleted" everywhere else.
+ *
+ * Mutates `widget.value` (which `LGraphNode.serialize` re-reads to rebuild
+ * `widgets_values`) and invokes `widget.callback` so widgets like Load Image
+ * run their own change-handling (clearing `node.imgs`, calling
+ * `setNodeOutputs`, etc.).
+ *
+ * FE-230 — covers the post-reload case without re-introducing
+ * useMissingMediaPreviewSync, which couldn't distinguish deletion from
+ * verification false-positives (e.g. mask-editor saved values).
+ */
+export function clearDeletedAssetWidgetValues(
+ rootGraph: LGraph | Subgraph,
+ deletedValues: ReadonlySet
+): void {
+ if (deletedValues.size === 0) return
+ for (const node of findNodesReferencingValues(rootGraph, deletedValues)) {
+ if (!node.widgets) continue
+ for (const widget of node.widgets) {
+ if (typeof widget.value !== 'string') continue
+ if (!deletedValues.has(widget.value)) continue
+ widget.value = ''
+ widget.callback?.('')
+ }
+ node.graph?.setDirtyCanvas(true)
+ }
+}
diff --git a/src/platform/assets/utils/clearNodePreviewCacheForValues.test.ts b/src/platform/assets/utils/clearNodePreviewCacheForValues.test.ts
new file mode 100644
index 0000000000..4d772db7f2
--- /dev/null
+++ b/src/platform/assets/utils/clearNodePreviewCacheForValues.test.ts
@@ -0,0 +1,241 @@
+import { describe, expect, it, vi } from 'vitest'
+
+import type { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
+
+import {
+ clearNodePreviewCacheForValues,
+ findNodesReferencingValues
+} from './clearNodePreviewCacheForValues'
+
+type MockWidget = { name: string; value: unknown }
+type MockNode = {
+ id: number
+ widgets?: MockWidget[]
+ imgs?: unknown
+ videoContainer?: unknown
+ graph?: { setDirtyCanvas: (v: boolean) => void }
+ isSubgraphNode?: () => boolean
+ subgraph?: { nodes: MockNode[] }
+}
+
+function makeGraph(nodes: MockNode[]): LGraph {
+ return { nodes } as unknown as LGraph
+}
+
+describe('FE-230 clearNodePreviewCacheForValues', () => {
+ it('clears node.imgs and removes outputs when a widget value matches a deleted value', () => {
+ const setDirty = vi.fn()
+ const remove = vi.fn()
+ const node: MockNode = {
+ id: 7,
+ widgets: [{ name: 'image', value: 'foo.png' }],
+ imgs: [{ src: 'blob:stale' }],
+ graph: { setDirtyCanvas: setDirty }
+ }
+
+ clearNodePreviewCacheForValues(
+ makeGraph([node]),
+ new Set(['foo.png']),
+ remove as unknown as (node: LGraphNode) => void
+ )
+
+ expect(node.imgs).toBeUndefined()
+ expect(remove).toHaveBeenCalledWith(node)
+ expect(setDirty).toHaveBeenCalledWith(true)
+ })
+
+ it('leaves unrelated nodes untouched', () => {
+ const setDirty = vi.fn()
+ const remove = vi.fn()
+ const node: MockNode = {
+ id: 8,
+ widgets: [{ name: 'image', value: 'unrelated.png' }],
+ imgs: [{ src: 'blob:keep' }],
+ graph: { setDirtyCanvas: setDirty }
+ }
+
+ clearNodePreviewCacheForValues(
+ makeGraph([node]),
+ new Set(['foo.png']),
+ remove as unknown as (node: LGraphNode) => void
+ )
+
+ expect(node.imgs).toEqual([{ src: 'blob:keep' }])
+ expect(remove).not.toHaveBeenCalled()
+ expect(setDirty).not.toHaveBeenCalled()
+ })
+
+ it('no-ops when the deleted value set is empty', () => {
+ const setDirty = vi.fn()
+ const remove = vi.fn()
+ const node: MockNode = {
+ id: 9,
+ widgets: [{ name: 'image', value: 'foo.png' }],
+ imgs: [{ src: 'blob:keep' }],
+ graph: { setDirtyCanvas: setDirty }
+ }
+
+ clearNodePreviewCacheForValues(
+ makeGraph([node]),
+ new Set(),
+ remove as unknown as (node: LGraphNode) => void
+ )
+
+ expect(node.imgs).toEqual([{ src: 'blob:keep' }])
+ expect(remove).not.toHaveBeenCalled()
+ expect(setDirty).not.toHaveBeenCalled()
+ })
+
+ it('matches the [output]-annotated form for output assets', () => {
+ const remove = vi.fn()
+ const node: MockNode = {
+ id: 12,
+ widgets: [{ name: 'image', value: 'foo.png [output]' }],
+ imgs: [{ src: 'blob:stale' }],
+ graph: { setDirtyCanvas: vi.fn() }
+ }
+
+ clearNodePreviewCacheForValues(
+ makeGraph([node]),
+ new Set(['foo.png [output]']),
+ remove as unknown as (node: LGraphNode) => void
+ )
+
+ expect(node.imgs).toBeUndefined()
+ expect(remove).toHaveBeenCalledWith(node)
+ })
+
+ it('matches the subfolder-prefixed annotated form when provided', () => {
+ const remove = vi.fn()
+ const node: MockNode = {
+ id: 13,
+ widgets: [{ name: 'image', value: 'sub/foo.png [output]' }],
+ imgs: [{ src: 'blob:stale' }],
+ graph: { setDirtyCanvas: vi.fn() }
+ }
+
+ clearNodePreviewCacheForValues(
+ makeGraph([node]),
+ new Set(['sub/foo.png [output]']),
+ remove as unknown as (node: LGraphNode) => void
+ )
+
+ expect(node.imgs).toBeUndefined()
+ expect(remove).toHaveBeenCalledWith(node)
+ })
+
+ it('does not cross-match basenames across input/output sources', () => {
+ const remove = vi.fn()
+ const inputNode: MockNode = {
+ id: 1,
+ widgets: [{ name: 'image', value: 'foo.png' }],
+ imgs: [{ src: 'blob:input' }],
+ graph: { setDirtyCanvas: vi.fn() }
+ }
+ const outputNode: MockNode = {
+ id: 2,
+ widgets: [{ name: 'image', value: 'foo.png [output]' }],
+ imgs: [{ src: 'blob:output' }],
+ graph: { setDirtyCanvas: vi.fn() }
+ }
+
+ clearNodePreviewCacheForValues(
+ makeGraph([inputNode, outputNode]),
+ new Set(['foo.png']),
+ remove as unknown as (node: LGraphNode) => void
+ )
+
+ expect(inputNode.imgs).toBeUndefined()
+ expect(outputNode.imgs).toEqual([{ src: 'blob:output' }])
+ expect(remove).toHaveBeenCalledWith(inputNode)
+ expect(remove).not.toHaveBeenCalledWith(outputNode)
+ })
+
+ it('also clears videoContainer for video previews', () => {
+ const remove = vi.fn()
+ const node: MockNode = {
+ id: 15,
+ widgets: [{ name: 'video', value: 'clip.mp4' }],
+ videoContainer: { foo: 'bar' },
+ graph: { setDirtyCanvas: vi.fn() }
+ }
+
+ clearNodePreviewCacheForValues(
+ makeGraph([node]),
+ new Set(['clip.mp4']),
+ remove as unknown as (node: LGraphNode) => void
+ )
+
+ expect(node.videoContainer).toBeUndefined()
+ expect(remove).toHaveBeenCalledWith(node)
+ })
+
+ it('matches any widget on the node, not just "image"', () => {
+ const remove = vi.fn()
+ const node: MockNode = {
+ id: 10,
+ widgets: [
+ { name: 'seed', value: 42 },
+ { name: 'video', value: 'clip.mp4' }
+ ],
+ imgs: [{ src: 'blob:videostale' }],
+ graph: { setDirtyCanvas: vi.fn() }
+ }
+
+ clearNodePreviewCacheForValues(
+ makeGraph([node]),
+ new Set(['clip.mp4']),
+ remove as unknown as (node: LGraphNode) => void
+ )
+
+ expect(node.imgs).toBeUndefined()
+ expect(remove).toHaveBeenCalledWith(node)
+ })
+
+ it('walks subgraph interiors and matches nested nodes', () => {
+ const inner: MockNode = {
+ id: 100,
+ widgets: [{ name: 'image', value: 'nested.png [output]' }],
+ imgs: [{ src: 'blob:nested' }],
+ graph: { setDirtyCanvas: vi.fn() }
+ }
+ const wrapper: MockNode = {
+ id: 50,
+ widgets: [],
+ isSubgraphNode: () => true,
+ subgraph: { nodes: [inner] }
+ }
+ const remove = vi.fn()
+
+ clearNodePreviewCacheForValues(
+ makeGraph([wrapper]),
+ new Set(['nested.png [output]']),
+ remove as unknown as (node: LGraphNode) => void
+ )
+
+ expect(inner.imgs).toBeUndefined()
+ expect(remove).toHaveBeenCalledWith(inner)
+ })
+})
+
+describe('FE-230 findNodesReferencingValues', () => {
+ it('skips subgraph wrapper nodes (only their interior nodes match)', () => {
+ const inner: MockNode = {
+ id: 100,
+ widgets: [{ name: 'image', value: 'foo.png' }]
+ }
+ const wrapper: MockNode = {
+ id: 50,
+ widgets: [{ name: 'image', value: 'foo.png' }],
+ isSubgraphNode: () => true,
+ subgraph: { nodes: [inner] }
+ }
+
+ const matches = findNodesReferencingValues(
+ makeGraph([wrapper]),
+ new Set(['foo.png'])
+ )
+
+ expect(matches).toEqual([inner])
+ })
+})
diff --git a/src/platform/assets/utils/clearNodePreviewCacheForValues.ts b/src/platform/assets/utils/clearNodePreviewCacheForValues.ts
new file mode 100644
index 0000000000..bbd6d9c5f2
--- /dev/null
+++ b/src/platform/assets/utils/clearNodePreviewCacheForValues.ts
@@ -0,0 +1,65 @@
+import type {
+ LGraph,
+ LGraphNode,
+ Subgraph
+} from '@/lib/litegraph/src/litegraph'
+import { collectAllNodes } from '@/utils/graphTraversalUtil'
+
+/**
+ * Clear cached Load Image / Load Video preview state on any node whose widget
+ * value matches one of the given values. Covers:
+ * - the canvas renderer cache (`node.imgs`, `node.videoContainer`)
+ * - the Vue preview source — must be cleared via `removeOutputsForNode`
+ * so the Pinia reactive ref (`nodeOutputStore.nodeOutputs.value`) updates,
+ * not just the legacy `app.nodeOutputs` mirror
+ *
+ * Comparison is full-string against the widget value as stored — callers must
+ * provide the canonical widget-value variants for each deleted asset (e.g.
+ * `foo.png`, `foo.png [output]`, `sub/foo.png [output]`, ``). This
+ * avoids false matches when two distinct assets share a basename across
+ * input/output sources.
+ *
+ * Walks the full graph hierarchy via `collectAllNodes`, so Load Image / Load
+ * Video nodes inside subgraphs are also matched.
+ *
+ * FE-230 — invoked after successful asset deletion so the Load Image / Load
+ * Video node preview does not keep displaying a thumbnail for an asset that
+ * no longer exists.
+ */
+export function clearNodePreviewCacheForValues(
+ rootGraph: LGraph | Subgraph,
+ deletedValues: ReadonlySet,
+ removeOutputsForNode: (node: LGraphNode) => void
+): void {
+ if (deletedValues.size === 0) return
+ for (const node of findNodesReferencingValues(rootGraph, deletedValues)) {
+ removeOutputsForNode(node)
+ node.imgs = undefined
+ node.videoContainer = undefined
+ node.graph?.setDirtyCanvas(true)
+ }
+}
+
+/**
+ * Walk the graph hierarchy and yield each leaf node whose widget value matches
+ * one of `deletedValues`. Used by both the preview-clearing path and the
+ * missing-media-marking path so the two stay in lockstep.
+ *
+ * Skips subgraph wrapper nodes — only their interior nodes are inspected.
+ */
+export function findNodesReferencingValues(
+ rootGraph: LGraph | Subgraph,
+ deletedValues: ReadonlySet
+): LGraphNode[] {
+ if (deletedValues.size === 0) return []
+ const matches: LGraphNode[] = []
+ for (const node of collectAllNodes(rootGraph)) {
+ if (!node.widgets?.length) continue
+ if (node.isSubgraphNode?.()) continue
+ const referencesDeleted = node.widgets.some(
+ (w) => typeof w.value === 'string' && deletedValues.has(w.value)
+ )
+ if (referencesDeleted) matches.push(node)
+ }
+ return matches
+}
diff --git a/src/platform/assets/utils/markDeletedAssetsAsMissingMedia.test.ts b/src/platform/assets/utils/markDeletedAssetsAsMissingMedia.test.ts
new file mode 100644
index 0000000000..705d65499e
--- /dev/null
+++ b/src/platform/assets/utils/markDeletedAssetsAsMissingMedia.test.ts
@@ -0,0 +1,185 @@
+import { createPinia, setActivePinia } from 'pinia'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+import type { LGraph } from '@/lib/litegraph/src/litegraph'
+import { useMissingMediaStore } from '@/platform/missingMedia/missingMediaStore'
+
+import { markDeletedAssetsAsMissingMedia } from './markDeletedAssetsAsMissingMedia'
+
+vi.mock('@/platform/distribution/types', () => ({
+ isCloud: true
+}))
+
+const mockScanNodeMediaCandidates = vi.hoisted(() => vi.fn())
+vi.mock('@/platform/missingMedia/missingMediaScan', () => ({
+ scanNodeMediaCandidates: mockScanNodeMediaCandidates
+}))
+
+vi.mock('@/renderer/core/canvas/canvasStore', () => ({
+ useCanvasStore: () => ({ currentGraph: null })
+}))
+
+function makeGraph(nodes: unknown[]): LGraph {
+ return { nodes } as unknown as LGraph
+}
+
+describe('FE-230 markDeletedAssetsAsMissingMedia', () => {
+ beforeEach(() => {
+ setActivePinia(createPinia())
+ mockScanNodeMediaCandidates.mockReset()
+ mockScanNodeMediaCandidates.mockReturnValue([])
+ })
+
+ it('adds missing-media candidates only for widgets whose value is in the deleted set', () => {
+ const node = {
+ id: 1,
+ type: 'LoadImage',
+ widgets: [
+ { name: 'image', value: 'sub/foo.png [output]' },
+ { name: 'mask', value: 'unrelated.png' }
+ ]
+ }
+ mockScanNodeMediaCandidates.mockReturnValue([
+ {
+ nodeId: '1',
+ nodeType: 'LoadImage',
+ widgetName: 'image',
+ mediaType: 'image',
+ name: 'sub/foo.png [output]'
+ },
+ {
+ nodeId: '1',
+ nodeType: 'LoadImage',
+ widgetName: 'mask',
+ mediaType: 'image',
+ name: 'unrelated.png'
+ }
+ ])
+
+ markDeletedAssetsAsMissingMedia(
+ makeGraph([node]),
+ new Set(['sub/foo.png [output]'])
+ )
+
+ const store = useMissingMediaStore()
+ expect(store.missingMediaCandidates).toEqual([
+ {
+ nodeId: '1',
+ nodeType: 'LoadImage',
+ widgetName: 'image',
+ mediaType: 'image',
+ name: 'sub/foo.png [output]',
+ isMissing: true
+ }
+ ])
+ })
+
+ it('does not cross-match basenames across input/output sources', () => {
+ const inputNode = {
+ id: 2,
+ type: 'LoadImage',
+ widgets: [{ name: 'image', value: 'foo.png' }]
+ }
+ const outputNode = {
+ id: 3,
+ type: 'LoadImage',
+ widgets: [{ name: 'image', value: 'foo.png [output]' }]
+ }
+
+ markDeletedAssetsAsMissingMedia(
+ makeGraph([inputNode, outputNode]),
+ new Set(['foo.png'])
+ )
+
+ expect(mockScanNodeMediaCandidates).toHaveBeenCalledTimes(1)
+ expect(mockScanNodeMediaCandidates).toHaveBeenCalledWith(
+ expect.anything(),
+ inputNode,
+ true
+ )
+ })
+
+ it('skips nodes with NEVER or BYPASS mode', () => {
+ const bypassed = {
+ id: 4,
+ type: 'LoadImage',
+ mode: 4,
+ widgets: [{ name: 'image', value: 'foo.png [output]' }]
+ }
+ const never = {
+ id: 5,
+ type: 'LoadImage',
+ mode: 2,
+ widgets: [{ name: 'image', value: 'foo.png [output]' }]
+ }
+
+ markDeletedAssetsAsMissingMedia(
+ makeGraph([bypassed, never]),
+ new Set(['foo.png [output]'])
+ )
+
+ expect(mockScanNodeMediaCandidates).not.toHaveBeenCalled()
+ const store = useMissingMediaStore()
+ expect(store.missingMediaCandidates).toBeNull()
+ })
+
+ it('walks subgraph interiors and marks nested nodes', () => {
+ const inner = {
+ id: 100,
+ type: 'LoadImage',
+ widgets: [{ name: 'image', value: 'nested.png [output]' }]
+ }
+ const wrapper = {
+ id: 50,
+ widgets: [],
+ isSubgraphNode: () => true,
+ subgraph: { nodes: [inner] }
+ }
+ mockScanNodeMediaCandidates.mockReturnValue([
+ {
+ nodeId: '50:100',
+ nodeType: 'LoadImage',
+ widgetName: 'image',
+ mediaType: 'image',
+ name: 'nested.png [output]'
+ }
+ ])
+
+ markDeletedAssetsAsMissingMedia(
+ makeGraph([wrapper]),
+ new Set(['nested.png [output]'])
+ )
+
+ const store = useMissingMediaStore()
+ expect(store.missingMediaCandidates).toEqual([
+ {
+ nodeId: '50:100',
+ nodeType: 'LoadImage',
+ widgetName: 'image',
+ mediaType: 'image',
+ name: 'nested.png [output]',
+ isMissing: true
+ }
+ ])
+ })
+
+ it('is a no-op when no nodes reference any deleted value', () => {
+ const node = {
+ id: 2,
+ type: 'LoadImage',
+ widgets: [{ name: 'image', value: 'kept.png' }]
+ }
+
+ markDeletedAssetsAsMissingMedia(makeGraph([node]), new Set(['gone.png']))
+
+ expect(mockScanNodeMediaCandidates).not.toHaveBeenCalled()
+ const store = useMissingMediaStore()
+ expect(store.missingMediaCandidates).toBeNull()
+ })
+
+ it('does nothing when the deleted value set is empty', () => {
+ markDeletedAssetsAsMissingMedia(makeGraph([]), new Set())
+ const store = useMissingMediaStore()
+ expect(store.missingMediaCandidates).toBeNull()
+ })
+})
diff --git a/src/platform/assets/utils/markDeletedAssetsAsMissingMedia.ts b/src/platform/assets/utils/markDeletedAssetsAsMissingMedia.ts
new file mode 100644
index 0000000000..800e685147
--- /dev/null
+++ b/src/platform/assets/utils/markDeletedAssetsAsMissingMedia.ts
@@ -0,0 +1,50 @@
+import type { LGraph } from '@/lib/litegraph/src/litegraph'
+import { LGraphEventMode } from '@/lib/litegraph/src/types/globalEnums'
+import { isCloud } from '@/platform/distribution/types'
+import { scanNodeMediaCandidates } from '@/platform/missingMedia/missingMediaScan'
+import { useMissingMediaStore } from '@/platform/missingMedia/missingMediaStore'
+import type { MissingMediaCandidate } from '@/platform/missingMedia/types'
+
+import { findNodesReferencingValues } from './clearNodePreviewCacheForValues'
+
+/**
+ * After a successful asset deletion, surface the affected Load Image / Load
+ * Video / Load Audio nodes through the missing-media store. Without this, UI
+ * surfaces that filter against `missingMediaCandidates` (e.g. the Vue node
+ * widget dropdown) keep listing the deleted asset because the verification
+ * pipeline only runs on workflow load — there is no signal that the live
+ * deletion just invalidated some references.
+ *
+ * Walks the full graph hierarchy (including subgraphs) and skips bypassed /
+ * never-execute nodes, mirroring `scanAllMediaCandidates` so the live-delete
+ * path stays in lockstep with the workflow-load verification.
+ *
+ * Comparison is full-string against the widget value, so two distinct assets
+ * that share a basename across input/output sources do not cross-match.
+ */
+export function markDeletedAssetsAsMissingMedia(
+ rootGraph: LGraph,
+ deletedValues: ReadonlySet
+): void {
+ if (deletedValues.size === 0) return
+
+ const matchedNodes = findNodesReferencingValues(rootGraph, deletedValues)
+ if (!matchedNodes.length) return
+
+ const candidates: MissingMediaCandidate[] = []
+ for (const node of matchedNodes) {
+ if (
+ node.mode === LGraphEventMode.NEVER ||
+ node.mode === LGraphEventMode.BYPASS
+ )
+ continue
+ for (const candidate of scanNodeMediaCandidates(rootGraph, node, isCloud)) {
+ if (!deletedValues.has(candidate.name)) continue
+ candidates.push({ ...candidate, isMissing: true })
+ }
+ }
+
+ if (candidates.length) {
+ useMissingMediaStore().addMissingMedia(candidates)
+ }
+}
diff --git a/src/platform/cloud/onboarding/CloudLoginView.vue b/src/platform/cloud/onboarding/CloudLoginView.vue
index 969227bca1..ce0ec8f790 100644
--- a/src/platform/cloud/onboarding/CloudLoginView.vue
+++ b/src/platform/cloud/onboarding/CloudLoginView.vue
@@ -7,7 +7,7 @@
{{ t('auth.login.title') }}
-
+
{{
freeTierCredits
? t('auth.login.freeTierDescription', {
@@ -86,7 +89,11 @@
class="text-sm underline"
@click="switchToSocialLogin"
>
- {{ t('auth.login.backToSocialLogin') }}
+ {{
+ googleSsoBlockedReason
+ ? t('auth.login.backToGithubLogin')
+ : t('auth.login.backToSocialLogin')
+ }}
diff --git a/src/platform/missingMedia/mediaPathDetectionUtil.test.ts b/src/platform/missingMedia/mediaPathDetectionUtil.test.ts
new file mode 100644
index 0000000000..d9a1dba670
--- /dev/null
+++ b/src/platform/missingMedia/mediaPathDetectionUtil.test.ts
@@ -0,0 +1,80 @@
+import { describe, expect, it } from 'vitest'
+
+import {
+ getAnnotatedMediaPathTypeForDetection,
+ getMediaPathDetectionNames,
+ normalizeAnnotatedMediaPathForDetection
+} from './mediaPathDetectionUtil'
+
+describe('normalizeAnnotatedMediaPathForDetection', () => {
+ it.each([
+ ['photo.png [input]', 'photo.png'],
+ ['result.png [output]', 'result.png'],
+ ['photo.png [input]', 'photo.png'],
+ ['with spaces.png [output]', 'with spaces.png'],
+ ['nested/folder/video.mp4 [output]', 'nested/folder/video.mp4']
+ ])('strips Core-style annotation from %s', (value, expected) => {
+ expect(normalizeAnnotatedMediaPathForDetection(value)).toBe(expected)
+ })
+
+ it.each([
+ ['photo.png[input]', 'photo.png'],
+ ['result.png[output]', 'result.png'],
+ ['with spaces.png [output]', 'with spaces.png']
+ ])('strips Cloud compact annotation from %s', (value, expected) => {
+ expect(
+ normalizeAnnotatedMediaPathForDetection(value, {
+ allowCompactSuffix: true
+ })
+ ).toBe(expected)
+ })
+
+ it('does not strip compact annotations in Core mode', () => {
+ expect(normalizeAnnotatedMediaPathForDetection('photo.png[input]')).toBe(
+ 'photo.png[input]'
+ )
+ })
+
+ it.each(['photo.png [draft]', 'photo [output] copy.png', 'photo.png', ''])(
+ 'leaves non-matching values unchanged: %s',
+ (value) => {
+ expect(normalizeAnnotatedMediaPathForDetection(value)).toBe(value)
+ }
+ )
+})
+
+describe('getMediaPathDetectionNames', () => {
+ it('returns raw and normalized names when an annotation is stripped', () => {
+ expect(getMediaPathDetectionNames('photo.png [input]')).toEqual([
+ 'photo.png [input]',
+ 'photo.png'
+ ])
+ })
+
+ it('returns only the raw name when no annotation is stripped', () => {
+ expect(getMediaPathDetectionNames('photo.png')).toEqual(['photo.png'])
+ })
+})
+
+describe('getAnnotatedMediaPathTypeForDetection', () => {
+ it.each([
+ ['photo.png [input]', 'input'],
+ ['photo.png [output]', 'output']
+ ])('returns the Core-style annotation type from %s', (value, expected) => {
+ expect(getAnnotatedMediaPathTypeForDetection(value)).toBe(expected)
+ })
+
+ it('returns the compact annotation type in Cloud mode', () => {
+ expect(
+ getAnnotatedMediaPathTypeForDetection('photo.png[output]', {
+ allowCompactSuffix: true
+ })
+ ).toBe('output')
+ })
+
+ it('returns undefined when no supported annotation is present', () => {
+ expect(getAnnotatedMediaPathTypeForDetection('photo.png [draft]')).toBe(
+ undefined
+ )
+ })
+})
diff --git a/src/platform/missingMedia/mediaPathDetectionUtil.ts b/src/platform/missingMedia/mediaPathDetectionUtil.ts
new file mode 100644
index 0000000000..2e27311f08
--- /dev/null
+++ b/src/platform/missingMedia/mediaPathDetectionUtil.ts
@@ -0,0 +1,44 @@
+// Missing-media-scoped helpers for deriving comparison keys from media widget paths.
+const CORE_ANNOTATED_MEDIA_PATTERN = /\s+\[(input|output)\]$/
+const CLOUD_ANNOTATED_MEDIA_PATTERN = /\s*\[(input|output)\]$/
+
+type AnnotatedMediaPathType = 'input' | 'output'
+
+interface AnnotatedMediaPathOptions {
+ allowCompactSuffix?: boolean
+}
+
+function getAnnotatedMediaPathMatch(
+ value: string,
+ options: AnnotatedMediaPathOptions = {}
+): RegExpMatchArray | null {
+ const pattern = options.allowCompactSuffix
+ ? CLOUD_ANNOTATED_MEDIA_PATTERN
+ : CORE_ANNOTATED_MEDIA_PATTERN
+ return value.match(pattern)
+}
+
+export function getAnnotatedMediaPathTypeForDetection(
+ value: string,
+ options: AnnotatedMediaPathOptions = {}
+): AnnotatedMediaPathType | undefined {
+ return getAnnotatedMediaPathMatch(value, options)?.[1] as
+ | AnnotatedMediaPathType
+ | undefined
+}
+
+export function normalizeAnnotatedMediaPathForDetection(
+ value: string,
+ options: AnnotatedMediaPathOptions = {}
+): string {
+ const match = getAnnotatedMediaPathMatch(value, options)
+ return match ? value.slice(0, match.index) : value
+}
+
+export function getMediaPathDetectionNames(
+ value: string,
+ options: AnnotatedMediaPathOptions = {}
+): string[] {
+ const normalized = normalizeAnnotatedMediaPathForDetection(value, options)
+ return normalized === value ? [value] : [value, normalized]
+}
diff --git a/src/platform/missingMedia/missingMediaAssetResolver.test.ts b/src/platform/missingMedia/missingMediaAssetResolver.test.ts
new file mode 100644
index 0000000000..c6eee64c47
--- /dev/null
+++ b/src/platform/missingMedia/missingMediaAssetResolver.test.ts
@@ -0,0 +1,325 @@
+import { fromAny } from '@total-typescript/shoehorn'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
+import type * as AssetServiceModule from '@/platform/assets/services/assetService'
+import type * as FetchJobsModule from '@/platform/remote/comfyui/jobs/fetchJobs'
+import type { JobListItem } from '@/platform/remote/comfyui/jobs/jobTypes'
+import {
+ getAssetDetectionNames,
+ resolveMissingMediaAssetSources
+} from './missingMediaAssetResolver'
+
+const { mockGetInputAssetsIncludingPublic, mockGetAssetsPageByTag } =
+ vi.hoisted(() => ({
+ mockGetInputAssetsIncludingPublic: vi.fn(),
+ mockGetAssetsPageByTag: vi.fn()
+ }))
+
+const { mockFetchHistoryPage } = vi.hoisted(() => ({
+ mockFetchHistoryPage: vi.fn()
+}))
+
+vi.mock('@/platform/assets/services/assetService', async () => {
+ const actual = await vi.importActual
(
+ '@/platform/assets/services/assetService'
+ )
+
+ return {
+ ...actual,
+ assetService: {
+ ...actual.assetService,
+ getInputAssetsIncludingPublic: mockGetInputAssetsIncludingPublic,
+ getAssetsPageByTag: mockGetAssetsPageByTag
+ }
+ }
+})
+
+vi.mock('@/platform/remote/comfyui/jobs/fetchJobs', async () => {
+ const actual = await vi.importActual(
+ '@/platform/remote/comfyui/jobs/fetchJobs'
+ )
+
+ return {
+ ...actual,
+ fetchHistoryPage: mockFetchHistoryPage
+ }
+})
+
+function makeAsset(name: string, assetHash: string | null = null): AssetItem {
+ return {
+ id: name,
+ name,
+ asset_hash: assetHash,
+ mime_type: null,
+ tags: ['input']
+ }
+}
+
+function makeHistoryJob(
+ filename: string,
+ options: { id?: string; subfolder?: string } = {}
+): JobListItem {
+ return fromAny({
+ id: options.id ?? filename,
+ status: 'completed',
+ create_time: 0,
+ priority: 0,
+ preview_output: {
+ filename,
+ subfolder: options.subfolder ?? '',
+ type: 'output',
+ nodeId: '1',
+ mediaType: 'images'
+ }
+ })
+}
+
+function makeHistoryPage(
+ jobs: JobListItem[],
+ options: { offset?: number; hasMore?: boolean; total?: number } = {}
+) {
+ return {
+ jobs,
+ total: options.total ?? jobs.length,
+ offset: options.offset ?? 0,
+ limit: 200,
+ hasMore: options.hasMore ?? false
+ }
+}
+
+function makeAssetPage(
+ assets: AssetItem[],
+ options: { hasMore?: boolean; total?: number } = {}
+) {
+ return {
+ assets,
+ total: options.total ?? assets.length,
+ has_more: options.hasMore ?? false
+ }
+}
+
+describe('resolveMissingMediaAssetSources', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockGetInputAssetsIncludingPublic.mockResolvedValue([])
+ mockGetAssetsPageByTag.mockResolvedValue(makeAssetPage([]))
+ mockFetchHistoryPage.mockResolvedValue(makeHistoryPage([]))
+ })
+
+ it('loads cloud input assets when requested', async () => {
+ const inputAsset = makeAsset('photo.png')
+ mockGetInputAssetsIncludingPublic.mockResolvedValue([inputAsset])
+
+ const result = await resolveMissingMediaAssetSources({
+ isCloud: true,
+ includeGeneratedAssets: false,
+ generatedMatchNames: new Set(),
+ allowCompactSuffix: true
+ })
+
+ expect(result.inputAssets).toEqual([inputAsset])
+ expect(result.generatedAssets).toEqual([])
+ expect(mockGetInputAssetsIncludingPublic).toHaveBeenCalledWith(
+ expect.any(AbortSignal)
+ )
+ expect(mockFetchHistoryPage).not.toHaveBeenCalled()
+ })
+
+ it('loads cloud output assets by tag when generated candidates need verification', async () => {
+ const outputAsset = makeAsset('output.png')
+ mockGetAssetsPageByTag.mockResolvedValue(makeAssetPage([outputAsset]))
+
+ const result = await resolveMissingMediaAssetSources({
+ isCloud: true,
+ includeGeneratedAssets: true,
+ generatedMatchNames: new Set(['output.png']),
+ allowCompactSuffix: true
+ })
+
+ expect(result.generatedAssets).toEqual([outputAsset])
+ expect(mockGetAssetsPageByTag).toHaveBeenCalledWith(
+ 'output',
+ true,
+ expect.objectContaining({
+ limit: 500,
+ offset: 0,
+ signal: expect.any(AbortSignal)
+ })
+ )
+ expect(mockFetchHistoryPage).not.toHaveBeenCalled()
+ })
+
+ it('stops reading cloud output asset pages once all requested names are found', async () => {
+ const target = 'target-output.png'
+ mockGetAssetsPageByTag.mockResolvedValueOnce(
+ makeAssetPage([makeAsset(target)], { hasMore: true, total: 501 })
+ )
+
+ const result = await resolveMissingMediaAssetSources({
+ isCloud: true,
+ includeGeneratedAssets: true,
+ generatedMatchNames: new Set([target]),
+ allowCompactSuffix: true
+ })
+
+ expect(result.generatedAssets).toEqual([makeAsset(target)])
+ expect(mockGetAssetsPageByTag).toHaveBeenCalledOnce()
+ })
+
+ it('aborts cloud output asset loading when input asset loading fails', async () => {
+ const inputError = new Error('input failed')
+ let rejectInputAssets!: (err: Error) => void
+ let resolveOutputAssets!: (page: ReturnType) => void
+ mockGetInputAssetsIncludingPublic.mockReturnValueOnce(
+ new Promise((_, reject) => {
+ rejectInputAssets = reject
+ })
+ )
+ mockGetAssetsPageByTag.mockReturnValueOnce(
+ new Promise((resolve) => {
+ resolveOutputAssets = resolve
+ })
+ )
+
+ const promise = resolveMissingMediaAssetSources({
+ isCloud: true,
+ includeGeneratedAssets: true,
+ generatedMatchNames: new Set(['target.png']),
+ allowCompactSuffix: true
+ })
+
+ await Promise.resolve()
+ expect(mockGetAssetsPageByTag).toHaveBeenCalledOnce()
+
+ rejectInputAssets(inputError)
+ await expect(promise).rejects.toBe(inputError)
+
+ resolveOutputAssets(makeAssetPage([makeAsset('other.png')]))
+ await Promise.resolve()
+
+ const outputSignal = mockGetAssetsPageByTag.mock.calls[0]?.[2]?.signal
+ expect(outputSignal).toBeInstanceOf(AbortSignal)
+ expect(outputSignal.aborted).toBe(true)
+ expect(mockFetchHistoryPage).not.toHaveBeenCalled()
+ })
+
+ it('stops reading generated history once all requested names are found', async () => {
+ const target = 'target.png'
+ mockFetchHistoryPage.mockResolvedValueOnce(
+ makeHistoryPage([makeHistoryJob(target)], {
+ hasMore: true,
+ total: 400
+ })
+ )
+
+ const result = await resolveMissingMediaAssetSources({
+ isCloud: false,
+ includeGeneratedAssets: true,
+ generatedMatchNames: new Set([target]),
+ allowCompactSuffix: true
+ })
+
+ expect(result.generatedAssets).toHaveLength(1)
+ expect(result.generatedAssets[0].name).toBe(target)
+ expect(mockFetchHistoryPage).toHaveBeenCalledOnce()
+ })
+
+ it('advances pagination from the requested offset, not the echoed offset', async () => {
+ const target = 'target.png'
+ mockFetchHistoryPage
+ .mockResolvedValueOnce(
+ makeHistoryPage(
+ Array.from({ length: 200 }, (_, index) =>
+ makeHistoryJob(`other-${index}.png`)
+ ),
+ { offset: 0, hasMore: true, total: 201 }
+ )
+ )
+ .mockResolvedValueOnce(
+ makeHistoryPage([makeHistoryJob(target)], {
+ offset: 0,
+ hasMore: true,
+ total: 201
+ })
+ )
+
+ await resolveMissingMediaAssetSources({
+ isCloud: false,
+ includeGeneratedAssets: true,
+ generatedMatchNames: new Set([target]),
+ allowCompactSuffix: true
+ })
+
+ expect(mockFetchHistoryPage).toHaveBeenNthCalledWith(
+ 1,
+ expect.any(Function),
+ 200,
+ 0
+ )
+ expect(mockFetchHistoryPage).toHaveBeenNthCalledWith(
+ 2,
+ expect.any(Function),
+ 200,
+ 200
+ )
+ })
+
+ it('stops if history reports hasMore but returns an empty page', async () => {
+ mockFetchHistoryPage.mockResolvedValueOnce(
+ makeHistoryPage([], { hasMore: true, total: 1 })
+ )
+
+ const result = await resolveMissingMediaAssetSources({
+ isCloud: false,
+ includeGeneratedAssets: true,
+ generatedMatchNames: new Set(['missing.png']),
+ allowCompactSuffix: true
+ })
+
+ expect(result.generatedAssets).toEqual([])
+ expect(mockFetchHistoryPage).toHaveBeenCalledOnce()
+ })
+
+ it('stops if history repeats the same job page', async () => {
+ const repeatedJob = makeHistoryJob('other.png', { id: 'same-job' })
+ mockFetchHistoryPage
+ .mockResolvedValueOnce(
+ makeHistoryPage([repeatedJob], { hasMore: true, total: 2 })
+ )
+ .mockResolvedValueOnce(
+ makeHistoryPage([repeatedJob], { offset: 1, hasMore: true, total: 2 })
+ )
+
+ const result = await resolveMissingMediaAssetSources({
+ isCloud: false,
+ includeGeneratedAssets: true,
+ generatedMatchNames: new Set(['missing.png']),
+ allowCompactSuffix: true
+ })
+
+ expect(result.generatedAssets).toHaveLength(1)
+ expect(mockFetchHistoryPage).toHaveBeenCalledTimes(2)
+ })
+
+ it('includes slash and backslash subfolder identifiers for detection', () => {
+ const names = getAssetDetectionNames(
+ {
+ ...makeAsset('child\\photo.png', 'hash.png'),
+ user_metadata: { subfolder: 'nested\\folder' }
+ },
+ { allowCompactSuffix: true }
+ )
+
+ expect(names).toEqual(
+ expect.arrayContaining([
+ 'child\\photo.png',
+ 'hash.png',
+ 'nested/folder/child/photo.png',
+ 'nested\\folder\\child\\photo.png'
+ ])
+ )
+ expect(names).not.toContain('nested/folder/hash.png')
+ expect(names).not.toContain('nested\\folder\\hash.png')
+ })
+})
diff --git a/src/platform/missingMedia/missingMediaAssetResolver.ts b/src/platform/missingMedia/missingMediaAssetResolver.ts
new file mode 100644
index 0000000000..00732f8dc5
--- /dev/null
+++ b/src/platform/missingMedia/missingMediaAssetResolver.ts
@@ -0,0 +1,286 @@
+import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
+import { assetService } from '@/platform/assets/services/assetService'
+import { fetchHistoryPage } from '@/platform/remote/comfyui/jobs/fetchJobs'
+import type { JobListItem } from '@/platform/remote/comfyui/jobs/jobTypes'
+import { api } from '@/scripts/api'
+import { getFilePathSeparatorVariants, joinFilePath } from '@/utils/formatUtil'
+import { getMediaPathDetectionNames } from './mediaPathDetectionUtil'
+
+const HISTORY_MEDIA_ASSETS_PAGE_SIZE = 200
+const CLOUD_OUTPUT_ASSETS_PAGE_SIZE = 500
+
+interface MediaPathDetectionOptions {
+ allowCompactSuffix: boolean
+}
+
+export interface MissingMediaAssetSources {
+ inputAssets: AssetItem[]
+ generatedAssets: AssetItem[]
+}
+
+export interface ResolveMissingMediaAssetSourcesOptions {
+ signal?: AbortSignal
+ isCloud: boolean
+ includeGeneratedAssets: boolean
+ generatedMatchNames: ReadonlySet
+ allowCompactSuffix: boolean
+}
+
+export type MissingMediaAssetResolver = (
+ options: ResolveMissingMediaAssetSourcesOptions
+) => Promise
+
+export async function resolveMissingMediaAssetSources({
+ signal,
+ isCloud,
+ includeGeneratedAssets,
+ generatedMatchNames,
+ allowCompactSuffix
+}: ResolveMissingMediaAssetSourcesOptions): Promise {
+ const pathOptions = { allowCompactSuffix }
+
+ const controller = new AbortController()
+ const abortFromCaller = () => controller.abort(signal?.reason)
+ if (signal?.aborted) {
+ abortFromCaller()
+ } else {
+ signal?.addEventListener('abort', abortFromCaller, { once: true })
+ }
+
+ try {
+ const [inputAssets, generatedAssets] = await Promise.all([
+ abortSiblingsOnFailure(
+ isCloud
+ ? assetService.getInputAssetsIncludingPublic(controller.signal)
+ : Promise.resolve([]),
+ controller
+ ),
+ abortSiblingsOnFailure(
+ includeGeneratedAssets
+ ? fetchGeneratedAssets(controller.signal, {
+ isCloud,
+ generatedMatchNames,
+ pathOptions
+ })
+ : Promise.resolve([]),
+ controller
+ )
+ ])
+
+ return { inputAssets, generatedAssets }
+ } finally {
+ signal?.removeEventListener('abort', abortFromCaller)
+ }
+}
+
+interface FetchGeneratedAssetsOptions {
+ isCloud: boolean
+ generatedMatchNames: ReadonlySet
+ pathOptions: MediaPathDetectionOptions
+}
+
+export function getAssetDetectionNames(
+ asset: AssetItem,
+ options: MediaPathDetectionOptions
+): string[] {
+ const names = new Set()
+ // Treat names and hashes as opaque match keys because Cloud may use either in widget values.
+ addPathDetectionNames(names, asset.asset_hash, options)
+ addPathDetectionNames(names, asset.name, options)
+
+ const subfolder = asset.user_metadata?.subfolder
+ if (typeof subfolder === 'string' && subfolder) {
+ addSubfolderPathDetectionNames(names, subfolder, asset.name, options)
+ }
+
+ return Array.from(names)
+}
+
+async function fetchGeneratedAssets(
+ signal: AbortSignal | undefined,
+ { isCloud, generatedMatchNames, pathOptions }: FetchGeneratedAssetsOptions
+): Promise {
+ if (isCloud) {
+ return await fetchCloudGeneratedAssets(
+ signal,
+ generatedMatchNames,
+ pathOptions
+ )
+ }
+
+ return await fetchGeneratedHistoryAssets(
+ signal,
+ generatedMatchNames,
+ pathOptions
+ )
+}
+
+async function fetchCloudGeneratedAssets(
+ signal: AbortSignal | undefined,
+ targetNames: ReadonlySet,
+ pathOptions: MediaPathDetectionOptions
+): Promise {
+ const assets: AssetItem[] = []
+ const foundTargetNames = new Set()
+ let offset = 0
+
+ while (true) {
+ signal?.throwIfAborted()
+
+ const assetPage = await assetService.getAssetsPageByTag('output', true, {
+ limit: CLOUD_OUTPUT_ASSETS_PAGE_SIZE,
+ offset,
+ signal
+ })
+
+ signal?.throwIfAborted()
+
+ const batch = assetPage.assets
+ if (batch.length === 0) return assets
+
+ for (const asset of batch) {
+ assets.push(asset)
+ rememberResolvedTargetNames(
+ asset,
+ targetNames,
+ foundTargetNames,
+ pathOptions
+ )
+ }
+
+ if (
+ !assetPage.has_more ||
+ hasResolvedAllTargetNames(targetNames, foundTargetNames)
+ ) {
+ return assets
+ }
+
+ offset += batch.length
+ }
+}
+
+async function fetchGeneratedHistoryAssets(
+ signal: AbortSignal | undefined,
+ targetNames: ReadonlySet,
+ pathOptions: MediaPathDetectionOptions
+): Promise {
+ const assets: AssetItem[] = []
+ const foundTargetNames = new Set()
+ const seenJobIds = new Set()
+ let offset = 0
+
+ while (true) {
+ signal?.throwIfAborted()
+
+ const requestedOffset = offset
+ const historyPage = await fetchHistoryPage(
+ api.fetchApi.bind(api),
+ HISTORY_MEDIA_ASSETS_PAGE_SIZE,
+ requestedOffset
+ )
+
+ signal?.throwIfAborted()
+
+ let newJobCount = 0
+ for (const job of historyPage.jobs) {
+ if (seenJobIds.has(job.id)) continue
+ seenJobIds.add(job.id)
+ newJobCount += 1
+
+ const asset = mapHistoryJobToAsset(job)
+ if (!asset) continue
+
+ assets.push(asset)
+ rememberResolvedTargetNames(
+ asset,
+ targetNames,
+ foundTargetNames,
+ pathOptions
+ )
+ }
+
+ if (
+ !historyPage.hasMore ||
+ historyPage.jobs.length === 0 ||
+ newJobCount === 0 ||
+ hasResolvedAllTargetNames(targetNames, foundTargetNames)
+ ) {
+ return assets
+ }
+
+ offset = requestedOffset + historyPage.jobs.length
+ }
+}
+
+async function abortSiblingsOnFailure(
+ promise: Promise,
+ controller: AbortController
+): Promise {
+ try {
+ return await promise
+ } catch (err) {
+ if (!controller.signal.aborted) controller.abort(err)
+ throw err
+ }
+}
+
+function addPathDetectionNames(
+ names: Set,
+ value: string | null | undefined,
+ options: MediaPathDetectionOptions
+) {
+ if (!value) return
+ for (const name of getMediaPathDetectionNames(value, options)) {
+ names.add(name)
+ }
+}
+
+function addSubfolderPathDetectionNames(
+ names: Set,
+ subfolder: string,
+ value: string | null | undefined,
+ options: MediaPathDetectionOptions
+) {
+ if (!value) return
+
+ const filePath = joinFilePath(subfolder, value)
+ for (const path of getFilePathSeparatorVariants(filePath)) {
+ addPathDetectionNames(names, path, options)
+ }
+}
+
+function rememberResolvedTargetNames(
+ asset: AssetItem,
+ targetNames: ReadonlySet,
+ foundTargetNames: Set,
+ options: MediaPathDetectionOptions
+) {
+ if (targetNames.size === 0) return
+
+ for (const name of getAssetDetectionNames(asset, options)) {
+ if (targetNames.has(name)) foundTargetNames.add(name)
+ }
+}
+
+function hasResolvedAllTargetNames(
+ targetNames: ReadonlySet,
+ foundTargetNames: ReadonlySet
+): boolean {
+ return targetNames.size > 0 && foundTargetNames.size === targetNames.size
+}
+
+function mapHistoryJobToAsset(job: JobListItem): AssetItem | null {
+ const output = job.preview_output
+ if (job.status !== 'completed' || !output?.filename) return null
+
+ return {
+ id: `${job.id}-${output.filename}`,
+ name: output.filename,
+ display_name: output.display_name,
+ mime_type: null,
+ tags: ['output'],
+ user_metadata: {
+ subfolder: output.subfolder
+ }
+ }
+}
diff --git a/src/platform/missingMedia/missingMediaScan.test.ts b/src/platform/missingMedia/missingMediaScan.test.ts
index 8e77aae88c..78073743bc 100644
--- a/src/platform/missingMedia/missingMediaScan.test.ts
+++ b/src/platform/missingMedia/missingMediaScan.test.ts
@@ -6,21 +6,27 @@ import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import type { IComboWidget } from '@/lib/litegraph/src/types/widgets'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import type * as AssetServiceModule from '@/platform/assets/services/assetService'
+import type * as FetchJobsModule from '@/platform/remote/comfyui/jobs/fetchJobs'
+import type { JobListItem } from '@/platform/remote/comfyui/jobs/jobTypes'
+import type { MissingMediaAssetResolver } from './missingMediaAssetResolver'
import {
scanAllMediaCandidates,
scanNodeMediaCandidates,
- verifyCloudMediaCandidates,
+ verifyMediaCandidates,
groupCandidatesByName,
groupCandidatesByMediaType
} from './missingMediaScan'
import type { MissingMediaCandidate } from './types'
-const { mockCheckAssetHash, mockGetInputAssetsIncludingPublic } = vi.hoisted(
- () => ({
- mockCheckAssetHash: vi.fn(),
- mockGetInputAssetsIncludingPublic: vi.fn()
- })
-)
+const { mockGetInputAssetsIncludingPublic, mockGetAssetsPageByTag } =
+ vi.hoisted(() => ({
+ mockGetInputAssetsIncludingPublic: vi.fn(),
+ mockGetAssetsPageByTag: vi.fn()
+ }))
+
+const { mockFetchHistoryPage } = vi.hoisted(() => ({
+ mockFetchHistoryPage: vi.fn()
+}))
vi.mock('@/utils/graphTraversalUtil', () => ({
collectAllNodes: (graph: { _testNodes: LGraphNode[] }) => graph._testNodes,
@@ -39,12 +45,23 @@ vi.mock('@/platform/assets/services/assetService', async () => {
...actual,
assetService: {
...actual.assetService,
- checkAssetHash: mockCheckAssetHash,
- getInputAssetsIncludingPublic: mockGetInputAssetsIncludingPublic
+ getInputAssetsIncludingPublic: mockGetInputAssetsIncludingPublic,
+ getAssetsPageByTag: mockGetAssetsPageByTag
}
}
})
+vi.mock('@/platform/remote/comfyui/jobs/fetchJobs', async () => {
+ const actual = await vi.importActual(
+ '@/platform/remote/comfyui/jobs/fetchJobs'
+ )
+
+ return {
+ ...actual,
+ fetchHistoryPage: mockFetchHistoryPage
+ }
+})
+
function makeCandidate(
nodeId: string,
name: string,
@@ -104,6 +121,43 @@ function makeAsset(name: string, assetHash: string | null = null): AssetItem {
}
}
+function makeAssetResolver(
+ inputAssets: AssetItem[],
+ generatedAssets: AssetItem[] = []
+): MissingMediaAssetResolver {
+ return vi.fn(async () => ({ inputAssets, generatedAssets }))
+}
+
+function makeAssetPage(
+ assets: AssetItem[],
+ options: { hasMore?: boolean; total?: number } = {}
+) {
+ return {
+ assets,
+ total: options.total ?? assets.length,
+ has_more: options.hasMore ?? false
+ }
+}
+
+function makeHistoryJob(
+ filename: string,
+ options: { id?: string; subfolder?: string } = {}
+): JobListItem {
+ return fromAny({
+ id: options.id ?? filename,
+ status: 'completed',
+ create_time: 0,
+ priority: 0,
+ preview_output: {
+ filename,
+ subfolder: options.subfolder ?? '',
+ type: 'output',
+ nodeId: '1',
+ mediaType: 'images'
+ }
+ })
+}
+
describe('scanNodeMediaCandidates', () => {
it('returns candidate for a LoadImage node with missing image', () => {
const graph = makeGraph([])
@@ -149,6 +203,131 @@ describe('scanNodeMediaCandidates', () => {
expect(result).toEqual([])
})
+
+ it.each([
+ {
+ nodeType: 'LoadImage',
+ widgetName: 'image',
+ mediaType: 'image',
+ value: 'photo.png [input]',
+ option: 'photo.png'
+ },
+ {
+ nodeType: 'LoadImageMask',
+ widgetName: 'image',
+ mediaType: 'image',
+ value: 'mask.png [input]',
+ option: 'mask.png'
+ },
+ {
+ nodeType: 'LoadVideo',
+ widgetName: 'file',
+ mediaType: 'video',
+ value: 'clip.mp4 [input]',
+ option: 'clip.mp4'
+ },
+ {
+ nodeType: 'LoadAudio',
+ widgetName: 'audio',
+ mediaType: 'audio',
+ value: 'sound.wav [input]',
+ option: 'sound.wav'
+ }
+ ])(
+ 'matches annotated $nodeType values against clean OSS options',
+ ({ nodeType, widgetName, mediaType, value, option }) => {
+ const graph = makeGraph([])
+ const node = makeMediaNode(
+ 1,
+ nodeType,
+ [makeMediaCombo(widgetName, value, [option])],
+ 0
+ )
+
+ const result = scanNodeMediaCandidates(graph, node, false)
+
+ expect(result).toHaveLength(1)
+ expect(result[0]).toMatchObject({
+ nodeType,
+ widgetName,
+ mediaType,
+ name: value,
+ isMissing: false
+ })
+ }
+ )
+
+ it.each([
+ {
+ nodeType: 'LoadImage',
+ widgetName: 'image',
+ value: 'photo.png [output]'
+ },
+ {
+ nodeType: 'LoadVideo',
+ widgetName: 'file',
+ value: 'clip.mp4 [output]'
+ },
+ {
+ nodeType: 'LoadAudio',
+ widgetName: 'audio',
+ value: 'sound.wav [output]'
+ }
+ ])(
+ 'leaves OSS $nodeType output annotations pending when not in options',
+ ({ nodeType, widgetName, value }) => {
+ const graph = makeGraph([])
+ const node = makeMediaNode(
+ 1,
+ nodeType,
+ [makeMediaCombo(widgetName, value, ['other-file.png', value])],
+ 0
+ )
+
+ const result = scanNodeMediaCandidates(graph, node, false)
+
+ expect(result[0]).toMatchObject({
+ nodeType,
+ widgetName,
+ name: value,
+ isMissing: undefined
+ })
+ }
+ )
+
+ it('marks OSS input annotations missing when the clean option is absent', () => {
+ const graph = makeGraph([])
+ const node = makeMediaNode(
+ 1,
+ 'LoadImage',
+ [makeMediaCombo('image', 'photo.png [input]', ['other.png'])],
+ 0
+ )
+
+ const result = scanNodeMediaCandidates(graph, node, false)
+
+ expect(result[0]).toMatchObject({
+ name: 'photo.png [input]',
+ isMissing: true
+ })
+ })
+
+ it('does not treat compact Cloud annotations as valid OSS options', () => {
+ const graph = makeGraph([])
+ const node = makeMediaNode(
+ 1,
+ 'LoadImage',
+ [makeMediaCombo('image', 'photo.png[input]', ['photo.png'])],
+ 0
+ )
+
+ const result = scanNodeMediaCandidates(graph, node, false)
+
+ expect(result[0]).toMatchObject({
+ name: 'photo.png[input]',
+ isMissing: true
+ })
+ })
})
describe('scanAllMediaCandidates', () => {
@@ -265,7 +444,7 @@ describe('groupCandidatesByMediaType', () => {
})
})
-describe('verifyCloudMediaCandidates', () => {
+describe('verifyMediaCandidates', () => {
const existingHash =
'blake3:1111111111111111111111111111111111111111111111111111111111111111'
const missingHash =
@@ -273,36 +452,355 @@ describe('verifyCloudMediaCandidates', () => {
beforeEach(() => {
vi.clearAllMocks()
- mockCheckAssetHash.mockResolvedValue('missing')
mockGetInputAssetsIncludingPublic.mockResolvedValue([])
+ mockGetAssetsPageByTag.mockResolvedValue(makeAssetPage([]))
+ mockFetchHistoryPage.mockResolvedValue({
+ jobs: [],
+ total: 0,
+ offset: 0,
+ limit: 200,
+ hasMore: false
+ })
})
- it('marks candidates missing when the asset hash is not found', async () => {
+ it('matches candidates by available input asset name or hash', async () => {
const candidates = [
- makeCandidate('1', missingHash, { isMissing: undefined }),
- makeCandidate('2', existingHash, { isMissing: undefined })
+ makeCandidate('1', 'photo.png', { isMissing: undefined }),
+ makeCandidate('2', existingHash, { isMissing: undefined }),
+ makeCandidate('3', missingHash, { isMissing: undefined })
]
+ const resolveAssetSources = makeAssetResolver([
+ makeAsset('photo.png', existingHash)
+ ])
- const checkAssetHash = vi.fn(async (assetHash: string) =>
- assetHash === existingHash ? ('exists' as const) : ('missing' as const)
+ await verifyMediaCandidates(candidates, {
+ isCloud: true,
+ resolveAssetSources
+ })
+
+ expect(candidates[0].isMissing).toBe(false)
+ expect(candidates[1].isMissing).toBe(false)
+ expect(candidates[2].isMissing).toBe(true)
+ expect(resolveAssetSources).toHaveBeenCalledWith({
+ signal: undefined,
+ isCloud: true,
+ includeGeneratedAssets: false,
+ generatedMatchNames: new Set(),
+ allowCompactSuffix: true
+ })
+ })
+
+ it('matches asset names when asset_hash is null', async () => {
+ const candidates = [
+ makeCandidate('1', 'legacy-photo.png', { isMissing: undefined }),
+ makeCandidate('2', 'missing-photo.png', { isMissing: undefined })
+ ]
+ const resolveAssetSources = makeAssetResolver([
+ makeAsset('legacy-photo.png', null)
+ ])
+
+ await verifyMediaCandidates(candidates, {
+ isCloud: true,
+ resolveAssetSources
+ })
+
+ expect(candidates[0].isMissing).toBe(false)
+ expect(candidates[1].isMissing).toBe(true)
+ })
+
+ it('matches annotated candidate names against clean asset names', async () => {
+ const candidates = [
+ makeCandidate('1', 'photo.png [input]', { isMissing: undefined }),
+ makeCandidate('2', 'clip.mp4[input]', {
+ nodeType: 'LoadVideo',
+ widgetName: 'file',
+ mediaType: 'video',
+ isMissing: undefined
+ }),
+ makeCandidate('3', 'missing.wav [output]', {
+ nodeType: 'LoadAudio',
+ widgetName: 'audio',
+ mediaType: 'audio',
+ isMissing: undefined
+ })
+ ]
+ const resolveAssetSources = makeAssetResolver(
+ [makeAsset('photo.png'), makeAsset('clip.mp4')],
+ []
)
- await verifyCloudMediaCandidates(candidates, undefined, checkAssetHash)
+ await verifyMediaCandidates(candidates, {
+ isCloud: true,
+ resolveAssetSources
+ })
- expect(candidates[0].isMissing).toBe(true)
- expect(candidates[1].isMissing).toBe(false)
+ expect(candidates[0]).toMatchObject({
+ name: 'photo.png [input]',
+ isMissing: false
+ })
+ expect(candidates[1]).toMatchObject({
+ name: 'clip.mp4[input]',
+ isMissing: false
+ })
+ expect(candidates[2]).toMatchObject({
+ name: 'missing.wav [output]',
+ isMissing: true
+ })
})
- it('uses assetService.checkAssetHash by default', async () => {
+ it('matches output hash filenames against generated media assets', async () => {
+ const candidates = [
+ makeCandidate(
+ '1',
+ '147257c95a3e957e0deee73a077cfec89da2d906dd086ca70a2b0c897a9591d6e.png [output]',
+ {
+ isMissing: undefined
+ }
+ )
+ ]
+ const resolveAssetSources = makeAssetResolver(
+ [],
+ [
+ makeAsset(
+ '147257c95a3e957e0deee73a077cfec89da2d906dd086ca70a2b0c897a9591d6e.png'
+ )
+ ]
+ )
+
+ await verifyMediaCandidates(candidates, {
+ isCloud: true,
+ resolveAssetSources
+ })
+
+ expect(resolveAssetSources).toHaveBeenCalledWith({
+ signal: undefined,
+ isCloud: true,
+ includeGeneratedAssets: true,
+ generatedMatchNames: new Set([
+ '147257c95a3e957e0deee73a077cfec89da2d906dd086ca70a2b0c897a9591d6e.png'
+ ]),
+ allowCompactSuffix: true
+ })
+ expect(candidates[0]).toMatchObject({
+ name: '147257c95a3e957e0deee73a077cfec89da2d906dd086ca70a2b0c897a9591d6e.png [output]',
+ isMissing: false
+ })
+ })
+
+ it('does not satisfy output annotations with input assets of the same name', async () => {
+ const candidates = [
+ makeCandidate('1', 'photo.png [output]', { isMissing: undefined })
+ ]
+ const resolveAssetSources = makeAssetResolver([makeAsset('photo.png')])
+
+ await verifyMediaCandidates(candidates, {
+ isCloud: true,
+ resolveAssetSources
+ })
+
+ expect(candidates[0].isMissing).toBe(true)
+ })
+
+ it('does not satisfy input candidates with output assets of the same name', async () => {
+ const candidates = [
+ makeCandidate('1', 'photo.png', { isMissing: undefined })
+ ]
+ const resolveAssetSources = makeAssetResolver([], [makeAsset('photo.png')])
+
+ await verifyMediaCandidates(candidates, {
+ isCloud: true,
+ resolveAssetSources
+ })
+
+ expect(candidates[0].isMissing).toBe(true)
+ })
+
+ it('verifies OSS output candidates against generated history without cloud assets', async () => {
+ const candidates = [
+ makeCandidate('1', 'subfolder/photo.png [output]', {
+ isMissing: undefined
+ })
+ ]
+
+ mockFetchHistoryPage.mockResolvedValueOnce({
+ jobs: [makeHistoryJob('photo.png', { subfolder: 'subfolder' })],
+ total: 1,
+ offset: 0,
+ limit: 200,
+ hasMore: false
+ })
+
+ await verifyMediaCandidates(candidates, { isCloud: false })
+
+ expect(mockGetInputAssetsIncludingPublic).not.toHaveBeenCalled()
+ expect(mockFetchHistoryPage).toHaveBeenCalledWith(
+ expect.any(Function),
+ 200,
+ 0
+ )
+ expect(candidates[0]).toMatchObject({
+ name: 'subfolder/photo.png [output]',
+ isMissing: false
+ })
+ })
+
+ it('does not normalize compact annotations when verifying OSS candidates', async () => {
+ const candidates = [
+ makeCandidate('1', 'photo.png[output]', { isMissing: undefined })
+ ]
+ const resolveAssetSources = makeAssetResolver([makeAsset('photo.png')])
+
+ await verifyMediaCandidates(candidates, {
+ isCloud: false,
+ resolveAssetSources
+ })
+
+ expect(resolveAssetSources).toHaveBeenCalledWith({
+ signal: undefined,
+ isCloud: false,
+ includeGeneratedAssets: false,
+ generatedMatchNames: new Set(),
+ allowCompactSuffix: false
+ })
+ expect(candidates[0].isMissing).toBe(true)
+ })
+
+ it('matches when the asset identifier itself is annotated', async () => {
+ const candidates = [
+ makeCandidate('1', 'clip.mp4[output]', { isMissing: undefined })
+ ]
+ const resolveAssetSources = makeAssetResolver(
+ [],
+ [makeAsset('clip.mp4 [output]')]
+ )
+
+ await verifyMediaCandidates(candidates, {
+ isCloud: true,
+ resolveAssetSources
+ })
+
+ expect(candidates[0].isMissing).toBe(false)
+ })
+
+ it('marks pending candidates missing when no input assets are available', async () => {
+ const candidates = [
+ makeCandidate('1', 'photo.png', { isMissing: undefined })
+ ]
+
+ await verifyMediaCandidates(candidates, {
+ isCloud: true,
+ resolveAssetSources: makeAssetResolver([])
+ })
+
+ expect(candidates[0].isMissing).toBe(true)
+ })
+
+ it('uses public input assets by default', async () => {
const candidates = [
makeCandidate('1', existingHash, { isMissing: undefined })
]
- mockCheckAssetHash.mockResolvedValue('exists')
+ mockGetInputAssetsIncludingPublic.mockResolvedValue([
+ makeAsset('stored-photo.png', existingHash)
+ ])
- await verifyCloudMediaCandidates(candidates)
+ await verifyMediaCandidates(candidates, { isCloud: true })
expect(candidates[0].isMissing).toBe(false)
- expect(mockCheckAssetHash).toHaveBeenCalledWith(existingHash, undefined)
+ expect(mockGetInputAssetsIncludingPublic).toHaveBeenCalledWith(
+ expect.any(AbortSignal)
+ )
+ expect(mockFetchHistoryPage).not.toHaveBeenCalled()
+ })
+
+ it('reads cloud output assets by tag for output candidates', async () => {
+ const outputHash =
+ '147257c95a3e957e0deee73a077cfec89da2d906dd086ca70a2b0c897a9591d6e.png'
+ const candidates = [
+ makeCandidate('1', `${outputHash} [output]`, { isMissing: undefined })
+ ]
+ mockGetAssetsPageByTag.mockResolvedValue(
+ makeAssetPage([makeAsset(outputHash)])
+ )
+
+ await verifyMediaCandidates(candidates, { isCloud: true })
+
+ expect(mockGetInputAssetsIncludingPublic).toHaveBeenCalledWith(
+ expect.any(AbortSignal)
+ )
+ expect(mockGetAssetsPageByTag).toHaveBeenCalledWith(
+ 'output',
+ true,
+ expect.objectContaining({
+ limit: 500,
+ offset: 0,
+ signal: expect.any(AbortSignal)
+ })
+ )
+ expect(mockFetchHistoryPage).not.toHaveBeenCalled()
+ expect(candidates[0].isMissing).toBe(false)
+ })
+
+ it('walks OSS generated history pages until hasMore is false', async () => {
+ const outputHash =
+ '147257c95a3e957e0deee73a077cfec89da2d906dd086ca70a2b0c897a9591d6e.png'
+ const candidates = [
+ makeCandidate('1', `${outputHash} [output]`, { isMissing: undefined })
+ ]
+ mockFetchHistoryPage
+ .mockResolvedValueOnce({
+ jobs: Array.from({ length: 200 }, (_, index) =>
+ makeHistoryJob(`other-${index}.png`)
+ ),
+ total: 201,
+ offset: 0,
+ limit: 200,
+ hasMore: true
+ })
+ .mockResolvedValueOnce({
+ jobs: [makeHistoryJob(outputHash)],
+ total: 201,
+ offset: 200,
+ limit: 200,
+ hasMore: false
+ })
+
+ await verifyMediaCandidates(candidates, { isCloud: false })
+
+ expect(mockFetchHistoryPage).toHaveBeenNthCalledWith(
+ 1,
+ expect.any(Function),
+ 200,
+ 0
+ )
+ expect(mockFetchHistoryPage).toHaveBeenNthCalledWith(
+ 2,
+ expect.any(Function),
+ 200,
+ 200
+ )
+ expect(candidates[0].isMissing).toBe(false)
+ })
+
+ it('trusts OSS history hasMore instead of page length', async () => {
+ const candidates = [
+ makeCandidate('1', 'missing-output.png [output]', {
+ isMissing: undefined
+ })
+ ]
+ mockFetchHistoryPage.mockResolvedValueOnce({
+ jobs: Array.from({ length: 200 }, (_, index) =>
+ makeHistoryJob(`other-${index}.png`)
+ ),
+ total: 200,
+ offset: 0,
+ limit: 200,
+ hasMore: false
+ })
+
+ await verifyMediaCandidates(candidates, { isCloud: false })
+
+ expect(mockFetchHistoryPage).toHaveBeenCalledOnce()
+ expect(candidates[0].isMissing).toBe(true)
})
it('respects abort signal before execution', async () => {
@@ -313,27 +811,33 @@ describe('verifyCloudMediaCandidates', () => {
makeCandidate('1', missingHash, { isMissing: undefined })
]
- await verifyCloudMediaCandidates(candidates, controller.signal)
+ await verifyMediaCandidates(candidates, {
+ isCloud: true,
+ signal: controller.signal
+ })
expect(candidates[0].isMissing).toBeUndefined()
- expect(mockCheckAssetHash).not.toHaveBeenCalled()
+ expect(mockGetInputAssetsIncludingPublic).not.toHaveBeenCalled()
})
- it('respects abort signal after hash verification', async () => {
+ it('respects abort signal after loading input assets', async () => {
const controller = new AbortController()
const candidates = [
makeCandidate('1', existingHash, { isMissing: undefined })
]
- const checkAssetHash = vi.fn(async () => {
+ const resolveAssetSources: MissingMediaAssetResolver = vi.fn(async () => {
controller.abort()
- return 'exists' as const
+ return {
+ inputAssets: [makeAsset('stored-photo.png', existingHash)],
+ generatedAssets: []
+ }
})
- await verifyCloudMediaCandidates(
- candidates,
- controller.signal,
- checkAssetHash
- )
+ await verifyMediaCandidates(candidates, {
+ isCloud: true,
+ signal: controller.signal,
+ resolveAssetSources
+ })
expect(candidates[0].isMissing).toBeUndefined()
})
@@ -341,52 +845,30 @@ describe('verifyCloudMediaCandidates', () => {
it('skips candidates already resolved as true', async () => {
const candidates = [makeCandidate('1', missingHash, { isMissing: true })]
- await verifyCloudMediaCandidates(candidates)
+ await verifyMediaCandidates(candidates, { isCloud: true })
expect(candidates[0].isMissing).toBe(true)
- expect(mockCheckAssetHash).not.toHaveBeenCalled()
+ expect(mockGetInputAssetsIncludingPublic).not.toHaveBeenCalled()
})
it('skips candidates already resolved as false', async () => {
const candidates = [makeCandidate('1', existingHash, { isMissing: false })]
- await verifyCloudMediaCandidates(candidates)
+ await verifyMediaCandidates(candidates, { isCloud: true })
expect(candidates[0].isMissing).toBe(false)
- expect(mockCheckAssetHash).not.toHaveBeenCalled()
+ expect(mockGetInputAssetsIncludingPublic).not.toHaveBeenCalled()
})
it('skips entirely when no pending candidates', async () => {
const candidates = [makeCandidate('1', missingHash, { isMissing: true })]
- await verifyCloudMediaCandidates(candidates)
+ await verifyMediaCandidates(candidates, { isCloud: true })
- expect(mockCheckAssetHash).not.toHaveBeenCalled()
+ expect(mockGetInputAssetsIncludingPublic).not.toHaveBeenCalled()
})
- it('falls back to input assets for non-blake3 candidate names', async () => {
- const candidates = [
- makeCandidate('1', 'photo.png', { isMissing: undefined }),
- makeCandidate('2', 'missing.png', { isMissing: undefined })
- ]
- const fetchInputAssets = vi.fn(async () => [
- makeAsset('stored-photo.png', 'photo.png')
- ])
-
- await verifyCloudMediaCandidates(
- candidates,
- undefined,
- undefined,
- fetchInputAssets
- )
-
- expect(mockCheckAssetHash).not.toHaveBeenCalled()
- expect(fetchInputAssets).toHaveBeenCalledOnce()
- expect(candidates[0].isMissing).toBe(false)
- expect(candidates[1].isMissing).toBe(true)
- })
-
- it('uses public input assets for default legacy fallback', async () => {
+ it('loads public input assets for default verification', async () => {
const candidates = [
makeCandidate('1', 'public-photo.png', { isMissing: undefined })
]
@@ -396,135 +878,62 @@ describe('verifyCloudMediaCandidates', () => {
inputAssets[42] = makeAsset('public-asset-record', 'public-photo.png')
mockGetInputAssetsIncludingPublic.mockResolvedValue(inputAssets)
- await verifyCloudMediaCandidates(candidates)
-
- expect(mockGetInputAssetsIncludingPublic).toHaveBeenCalledWith(undefined)
- expect(candidates[0].isMissing).toBe(false)
- })
-
- it('silences aborts while loading legacy fallback input assets', async () => {
- const abortError = new Error('aborted')
- abortError.name = 'AbortError'
- const controller = new AbortController()
- const candidates = [
- makeCandidate('1', 'photo.png', { isMissing: undefined })
- ]
- const fetchInputAssets = vi.fn(async () => {
- controller.abort()
- throw abortError
- })
-
- await expect(
- verifyCloudMediaCandidates(
- candidates,
- controller.signal,
- undefined,
- fetchInputAssets
- )
- ).resolves.toBeUndefined()
-
- expect(candidates[0].isMissing).toBeUndefined()
- })
-
- it('silences aborts from the default legacy fallback input asset store path', async () => {
- const abortError = new Error('aborted')
- abortError.name = 'AbortError'
- const controller = new AbortController()
- const candidates = [
- makeCandidate('1', 'photo.png', { isMissing: undefined })
- ]
- mockGetInputAssetsIncludingPublic.mockImplementationOnce(async () => {
- controller.abort()
- throw abortError
- })
-
- await expect(
- verifyCloudMediaCandidates(candidates, controller.signal)
- ).resolves.toBeUndefined()
+ await verifyMediaCandidates(candidates, { isCloud: true })
expect(mockGetInputAssetsIncludingPublic).toHaveBeenCalledWith(
- controller.signal
+ expect.any(AbortSignal)
)
+ expect(candidates[0].isMissing).toBe(false)
+ })
+
+ it('silences aborts while loading input assets', async () => {
+ const abortError = new Error('aborted')
+ abortError.name = 'AbortError'
+ const controller = new AbortController()
+ const candidates = [
+ makeCandidate('1', 'photo.png', { isMissing: undefined })
+ ]
+ const resolveAssetSources: MissingMediaAssetResolver = vi.fn(async () => {
+ controller.abort()
+ throw abortError
+ })
+
+ await expect(
+ verifyMediaCandidates(candidates, {
+ isCloud: true,
+ signal: controller.signal,
+ resolveAssetSources
+ })
+ ).resolves.toBeUndefined()
+
expect(candidates[0].isMissing).toBeUndefined()
})
- it('falls back to input assets when the hash endpoint returns 400', async () => {
+ it('forwards the signal to the default input asset fetcher and silences aborts', async () => {
+ const abortError = new Error('aborted')
+ abortError.name = 'AbortError'
+ const controller = new AbortController()
const candidates = [
- makeCandidate('1', existingHash, { isMissing: undefined })
+ makeCandidate('1', 'photo.png', { isMissing: undefined })
]
- mockCheckAssetHash.mockResolvedValue('invalid')
- const fetchInputAssets = vi.fn(async () => [
- makeAsset('photo.png', existingHash)
- ])
-
- await verifyCloudMediaCandidates(
- candidates,
- undefined,
- undefined,
- fetchInputAssets
+ let serviceSignal: AbortSignal | undefined
+ mockGetInputAssetsIncludingPublic.mockImplementationOnce(
+ async (signal?: AbortSignal) => {
+ serviceSignal = signal
+ controller.abort()
+ throw abortError
+ }
)
- expect(mockCheckAssetHash).toHaveBeenCalledWith(existingHash, undefined)
- expect(fetchInputAssets).toHaveBeenCalledOnce()
- expect(candidates[0].isMissing).toBe(false)
- })
+ await expect(
+ verifyMediaCandidates(candidates, {
+ isCloud: true,
+ signal: controller.signal
+ })
+ ).resolves.toBeUndefined()
- it('falls back to input assets when hash verification fails', async () => {
- const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
- const candidates = [
- makeCandidate('1', existingHash, { isMissing: undefined })
- ]
- const checkAssetHash = vi.fn(async () => {
- throw new Error('network failed')
- })
- const fetchInputAssets = vi.fn(async () => [
- makeAsset('photo.png', existingHash)
- ])
-
- await verifyCloudMediaCandidates(
- candidates,
- undefined,
- checkAssetHash,
- fetchInputAssets
- )
-
- expect(fetchInputAssets).toHaveBeenCalledOnce()
- expect(candidates[0].isMissing).toBe(false)
- expect(warn).toHaveBeenCalledOnce()
- warn.mockRestore()
- })
-
- it('does not call the hash endpoint for malformed blake3-looking values', async () => {
- const malformedHash = 'blake3:abc'
- const candidates = [
- makeCandidate('1', malformedHash, { isMissing: undefined })
- ]
- const fetchInputAssets = vi.fn(async () => [
- makeAsset('legacy.png', malformedHash)
- ])
-
- await verifyCloudMediaCandidates(
- candidates,
- undefined,
- undefined,
- fetchInputAssets
- )
-
- expect(mockCheckAssetHash).not.toHaveBeenCalled()
- expect(fetchInputAssets).toHaveBeenCalledOnce()
- expect(candidates[0].isMissing).toBe(false)
- })
-
- it('deduplicates checks for repeated candidate names', async () => {
- const candidates = [
- makeCandidate('1', missingHash, { isMissing: undefined }),
- makeCandidate('2', missingHash, { isMissing: undefined })
- ]
-
- await verifyCloudMediaCandidates(candidates)
-
- expect(mockCheckAssetHash).toHaveBeenCalledOnce()
- expect(candidates[0].isMissing).toBe(true)
- expect(candidates[1].isMissing).toBe(true)
+ expect(serviceSignal).toBeInstanceOf(AbortSignal)
+ expect(serviceSignal?.aborted).toBe(true)
+ expect(candidates[0].isMissing).toBeUndefined()
})
})
diff --git a/src/platform/missingMedia/missingMediaScan.ts b/src/platform/missingMedia/missingMediaScan.ts
index 5050996e06..afbd3bcf27 100644
--- a/src/platform/missingMedia/missingMediaScan.ts
+++ b/src/platform/missingMedia/missingMediaScan.ts
@@ -19,11 +19,17 @@ import {
import { LGraphEventMode } from '@/lib/litegraph/src/types/globalEnums'
import { resolveComboValues } from '@/utils/litegraphUtil'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
-import type { AssetHashStatus } from '@/platform/assets/services/assetService'
+import { isAbortError } from '@/utils/typeGuardUtil'
import {
- assetService,
- isBlake3AssetHash
-} from '@/platform/assets/services/assetService'
+ getAnnotatedMediaPathTypeForDetection,
+ getMediaPathDetectionNames,
+ normalizeAnnotatedMediaPathForDetection
+} from './mediaPathDetectionUtil'
+import {
+ getAssetDetectionNames,
+ resolveMissingMediaAssetSources
+} from './missingMediaAssetResolver'
+import type { MissingMediaAssetResolver } from './missingMediaAssetResolver'
/** Map of node types to their media widget name and media type. */
const MEDIA_NODE_WIDGETS: Record<
@@ -31,6 +37,7 @@ const MEDIA_NODE_WIDGETS: Record<
{ widgetName: string; mediaType: MediaType }
> = {
LoadImage: { widgetName: 'image', mediaType: 'image' },
+ LoadImageMask: { widgetName: 'image', mediaType: 'image' },
LoadVideo: { widgetName: 'file', mediaType: 'video' },
LoadAudio: { widgetName: 'audio', mediaType: 'audio' }
}
@@ -42,7 +49,8 @@ function isComboWidget(widget: IBaseWidget): widget is IComboWidget {
/**
* Scan combo widgets on media nodes for file values that may be missing.
*
- * OSS: `isMissing` resolved immediately via widget options.
+ * OSS: `isMissing` is resolved immediately via widget options unless an
+ * output annotation needs generated-history verification.
* Cloud: `isMissing` left `undefined` for async verification.
*/
export function scanAllMediaCandidates(
@@ -95,8 +103,17 @@ export function scanNodeMediaCandidates(
if (isCloud) {
isMissing = undefined
} else {
- const options = resolveComboValues(widget)
- isMissing = !options.includes(value)
+ const type = getAnnotatedMediaPathTypeForDetection(value)
+ if (type === 'output') {
+ isMissing = undefined
+ } else {
+ const options = resolveComboValues(widget)
+ const detectionNames = getMediaPathDetectionNames(value)
+ const existsInOptions = detectionNames.some((name) =>
+ options.includes(name)
+ )
+ isMissing = !existsInOptions
+ }
}
candidates.push({
@@ -112,99 +129,57 @@ export function scanNodeMediaCandidates(
return candidates
}
-type AssetHashVerifier = (
- assetHash: string,
+interface MediaVerificationOptions {
+ isCloud: boolean
signal?: AbortSignal
-) => Promise
-
-type InputAssetFetcher = (signal?: AbortSignal) => Promise
-
-function groupCandidatesForHashLookup(candidates: MissingMediaCandidate[]): {
- candidatesByHash: Map
- legacyCandidates: MissingMediaCandidate[]
-} {
- const candidatesByHash = new Map()
- const legacyCandidates: MissingMediaCandidate[] = []
-
- for (const candidate of candidates) {
- if (!isBlake3AssetHash(candidate.name)) {
- legacyCandidates.push(candidate)
- continue
- }
-
- const hashCandidates = candidatesByHash.get(candidate.name)
- if (hashCandidates) hashCandidates.push(candidate)
- else candidatesByHash.set(candidate.name, [candidate])
- }
-
- return { candidatesByHash, legacyCandidates }
-}
-
-async function verifyCandidatesByHash(
- candidatesByHash: Map,
- legacyCandidates: MissingMediaCandidate[],
- signal: AbortSignal | undefined,
- checkAssetHash: AssetHashVerifier
-): Promise {
- await Promise.all(
- Array.from(candidatesByHash, async ([assetHash, hashCandidates]) => {
- if (signal?.aborted) return
-
- let status: AssetHashStatus
- try {
- status = await checkAssetHash(assetHash, signal)
- if (signal?.aborted) return
- } catch (err) {
- if (signal?.aborted || isAbortError(err)) return
- console.warn(
- '[Missing Media Pipeline] Failed to verify asset hash:',
- err
- )
- legacyCandidates.push(...hashCandidates)
- return
- }
-
- if (status === 'invalid') {
- legacyCandidates.push(...hashCandidates)
- return
- }
-
- for (const candidate of hashCandidates) {
- candidate.isMissing = status === 'missing'
- }
- })
- )
+ resolveAssetSources?: MissingMediaAssetResolver
}
/**
- * Verify cloud media candidates by probing the asset hash endpoint first.
- * Invalid hash values fall back to the legacy input asset list check.
+ * Verify media candidates against assets available to the current runtime.
+ *
+ * A candidate's `name` may be either a filename or an opaque asset hash.
+ * Cloud-side `asset_hash` is not guaranteed to follow a single shape, so we
+ * match against the union of `asset.name` and `asset.asset_hash`. Output
+ * candidates are matched against Cloud output assets or Core generated-history
+ * assets because Core resolves those annotations against output folders, not
+ * input files.
+ * Cloud accepts compact annotated media paths, so only Cloud verification
+ * normalizes compact suffixes.
*/
-export async function verifyCloudMediaCandidates(
+export async function verifyMediaCandidates(
candidates: MissingMediaCandidate[],
- signal?: AbortSignal,
- checkAssetHash: AssetHashVerifier = assetService.checkAssetHash,
- fetchInputAssets: InputAssetFetcher = fetchMissingInputAssets
+ {
+ isCloud,
+ signal,
+ resolveAssetSources = resolveMissingMediaAssetSources
+ }: MediaVerificationOptions
): Promise {
if (signal?.aborted) return
const pending = candidates.filter((c) => c.isMissing === undefined)
if (pending.length === 0) return
- const { candidatesByHash, legacyCandidates } =
- groupCandidatesForHashLookup(pending)
- await verifyCandidatesByHash(
- candidatesByHash,
- legacyCandidates,
- signal,
- checkAssetHash
+ // Core stores spaced annotations such as `file.png [output]`; Cloud also
+ // accepts compact forms such as `file.png[output]`.
+ const pathOptions = { allowCompactSuffix: isCloud }
+ const generatedMatchNames = getGeneratedCandidateMatchNames(
+ pending,
+ pathOptions
)
- if (signal?.aborted || legacyCandidates.length === 0) return
-
let inputAssets: AssetItem[]
+ let generatedAssets: AssetItem[]
try {
- inputAssets = await fetchInputAssets(signal)
+ const assetSources = await resolveAssetSources({
+ signal,
+ isCloud,
+ includeGeneratedAssets: generatedMatchNames.size > 0,
+ generatedMatchNames,
+ allowCompactSuffix: isCloud
+ })
+ inputAssets = assetSources.inputAssets
+ generatedAssets = assetSources.generatedAssets
} catch (err) {
if (signal?.aborted || isAbortError(err)) return
throw err
@@ -212,28 +187,62 @@ export async function verifyCloudMediaCandidates(
if (signal?.aborted) return
- const assetHashes = new Set(
- inputAssets.map((a) => a.asset_hash).filter((h): h is string => !!h)
- )
+ const inputAssetIdentifiers = new Set()
+ const outputAssetIdentifiers = new Set()
+ addAssetIdentifiers(inputAssetIdentifiers, inputAssets, pathOptions)
+ addAssetIdentifiers(outputAssetIdentifiers, generatedAssets, pathOptions)
- for (const candidate of legacyCandidates) {
- candidate.isMissing = !assetHashes.has(candidate.name)
+ for (const candidate of pending) {
+ const detectionNames = getMediaPathDetectionNames(
+ candidate.name,
+ pathOptions
+ )
+ const type = getAnnotatedMediaPathTypeForDetection(
+ candidate.name,
+ pathOptions
+ )
+ const identifiers =
+ type === 'output' ? outputAssetIdentifiers : inputAssetIdentifiers
+ candidate.isMissing = !detectionNames.some((name) => identifiers.has(name))
}
}
-async function fetchMissingInputAssets(
- signal?: AbortSignal
-): Promise {
- return await assetService.getInputAssetsIncludingPublic(signal)
+function getGeneratedCandidateMatchNames(
+ candidates: MissingMediaCandidate[],
+ pathOptions: { allowCompactSuffix: boolean }
+): Set {
+ const names = new Set()
+ for (const candidate of candidates) {
+ if (!isGeneratedCandidate(candidate, pathOptions)) continue
+
+ names.add(
+ normalizeAnnotatedMediaPathForDetection(candidate.name, pathOptions)
+ )
+ }
+ return names
}
-function isAbortError(err: unknown): boolean {
- return (
- typeof err === 'object' &&
- err !== null &&
- 'name' in err &&
- err.name === 'AbortError'
+function isGeneratedCandidate(
+ candidate: MissingMediaCandidate,
+ pathOptions: { allowCompactSuffix: boolean }
+): boolean {
+ const type = getAnnotatedMediaPathTypeForDetection(
+ candidate.name,
+ pathOptions
)
+ return type === 'output'
+}
+
+function addAssetIdentifiers(
+ identifiers: Set,
+ assets: AssetItem[],
+ pathOptions: { allowCompactSuffix: boolean }
+) {
+ for (const asset of assets) {
+ for (const name of getAssetDetectionNames(asset, pathOptions)) {
+ identifiers.add(name)
+ }
+ }
}
/** Group confirmed-missing candidates by file name into view models. */
diff --git a/src/platform/missingMedia/types.ts b/src/platform/missingMedia/types.ts
index a07433dc34..8f1f08a69b 100644
--- a/src/platform/missingMedia/types.ts
+++ b/src/platform/missingMedia/types.ts
@@ -16,7 +16,9 @@ export interface MissingMediaCandidate {
/**
* - `true` — confirmed missing
* - `false` — confirmed present
- * - `undefined` — pending async verification (cloud only)
+ * - `undefined` — pending async verification. Cloud candidates start pending;
+ * OSS output annotated paths may also be deferred to generated-history
+ * verification.
*/
isMissing: boolean | undefined
}
diff --git a/src/platform/missingModel/missingModelScan.test.ts b/src/platform/missingModel/missingModelScan.test.ts
index 05326f8bb0..e365718d28 100644
--- a/src/platform/missingModel/missingModelScan.test.ts
+++ b/src/platform/missingModel/missingModelScan.test.ts
@@ -19,11 +19,6 @@ import activeSubgraphUnmatchedModel from '@/platform/missingModel/__fixtures__/a
import bypassedSubgraphUnmatchedModel from '@/platform/missingModel/__fixtures__/bypassedSubgraphUnmatchedModel.json' with { type: 'json' }
import type { MissingModelCandidate } from '@/platform/missingModel/types'
import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema'
-import type * as AssetServiceModule from '@/platform/assets/services/assetService'
-
-const { mockCheckAssetHash } = vi.hoisted(() => ({
- mockCheckAssetHash: vi.fn()
-}))
vi.mock('@/utils/graphTraversalUtil', () => ({
collectAllNodes: (graph: { _testNodes: LGraphNode[] }) => graph._testNodes,
@@ -33,20 +28,6 @@ vi.mock('@/utils/graphTraversalUtil', () => ({
) => node._testExecutionId ?? String(node.id)
}))
-vi.mock('@/platform/assets/services/assetService', async () => {
- const actual = await vi.importActual(
- '@/platform/assets/services/assetService'
- )
-
- return {
- ...actual,
- assetService: {
- ...actual.assetService,
- checkAssetHash: mockCheckAssetHash
- }
- }
-})
-
/** Helper: create a combo widget mock */
function makeComboWidget(
name: string,
@@ -1391,23 +1372,14 @@ describe('OSS missing model detection (non-Cloud path)', () => {
})
})
-const {
- mockUpdateModelsForNodeType,
- mockIsModelLoading,
- mockHasMore,
- mockGetAssets
-} = vi.hoisted(() => ({
+const { mockUpdateModelsForNodeType, mockGetAssets } = vi.hoisted(() => ({
mockUpdateModelsForNodeType: vi.fn().mockResolvedValue(undefined),
- mockIsModelLoading: vi.fn().mockReturnValue(false),
- mockHasMore: vi.fn().mockReturnValue(false),
mockGetAssets: vi.fn().mockReturnValue([])
}))
vi.mock('@/stores/assetsStore', () => ({
useAssetsStore: () => ({
updateModelsForNodeType: mockUpdateModelsForNodeType,
- isModelLoading: mockIsModelLoading,
- hasMore: mockHasMore,
getAssets: mockGetAssets
})
}))
@@ -1440,9 +1412,7 @@ function makeAssetCandidate(
describe('verifyAssetSupportedCandidates', () => {
beforeEach(() => {
vi.clearAllMocks()
- mockCheckAssetHash.mockResolvedValue('missing')
- mockIsModelLoading.mockReturnValue(false)
- mockHasMore.mockReturnValue(false)
+ mockUpdateModelsForNodeType.mockResolvedValue(undefined)
mockGetAssets.mockReturnValue([])
})
@@ -1458,84 +1428,15 @@ describe('verifyAssetSupportedCandidates', () => {
)
})
- it('should resolve isMissing=false when the blake3 hash endpoint finds the asset', async () => {
- const hash =
- '1111111111111111111111111111111111111111111111111111111111111111'
- const candidates = [
- makeAssetCandidate('model.safetensors', {
- hash,
- hashType: 'blake3'
- })
- ]
- mockCheckAssetHash.mockResolvedValue('exists')
-
- await verifyAssetSupportedCandidates(candidates)
-
- expect(candidates[0].isMissing).toBe(false)
- expect(mockCheckAssetHash).toHaveBeenCalledWith(`blake3:${hash}`, undefined)
- expect(mockUpdateModelsForNodeType).not.toHaveBeenCalled()
- })
-
- it('should fall back to asset store matching when the blake3 hash is not found', async () => {
+ it('should match filenames regardless of hash metadata shape', async () => {
const hash =
'2222222222222222222222222222222222222222222222222222222222222222'
const candidates = [
makeAssetCandidate('my_model.safetensors', {
hash,
hashType: 'blake3'
- })
- ]
- mockCheckAssetHash.mockResolvedValue('missing')
- mockGetAssets.mockReturnValue([
- {
- id: '1',
- name: 'my_model.safetensors',
- asset_hash: null,
- metadata: { filename: 'my_model.safetensors' }
- }
- ])
-
- await verifyAssetSupportedCandidates(candidates)
-
- expect(candidates[0].isMissing).toBe(false)
- expect(mockUpdateModelsForNodeType).toHaveBeenCalledWith(
- 'CheckpointLoaderSimple'
- )
- })
-
- it('should fall back to asset store matching when hash verification fails', async () => {
- const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
- const hash =
- '3333333333333333333333333333333333333333333333333333333333333333'
- const candidates = [
- makeAssetCandidate('my_model.safetensors', {
- hash,
- hashType: 'blake3'
- })
- ]
- mockCheckAssetHash.mockRejectedValue(new Error('network failed'))
- mockGetAssets.mockReturnValue([
- {
- id: '1',
- name: 'my_model.safetensors',
- asset_hash: null,
- metadata: { filename: 'my_model.safetensors' }
- }
- ])
-
- await verifyAssetSupportedCandidates(candidates)
-
- expect(candidates[0].isMissing).toBe(false)
- expect(mockUpdateModelsForNodeType).toHaveBeenCalledWith(
- 'CheckpointLoaderSimple'
- )
- expect(warn).toHaveBeenCalledOnce()
- warn.mockRestore()
- })
-
- it('should skip malformed blake3 hashes and use asset store matching', async () => {
- const candidates = [
- makeAssetCandidate('my_model.safetensors', {
+ }),
+ makeAssetCandidate('other_model.safetensors', {
hash: 'abc123',
hashType: 'blake3'
})
@@ -1546,38 +1447,25 @@ describe('verifyAssetSupportedCandidates', () => {
name: 'my_model.safetensors',
asset_hash: null,
metadata: { filename: 'my_model.safetensors' }
+ },
+ {
+ id: '2',
+ name: 'other_model.safetensors',
+ asset_hash: null,
+ metadata: { filename: 'other_model.safetensors' }
}
])
await verifyAssetSupportedCandidates(candidates)
- expect(mockCheckAssetHash).not.toHaveBeenCalled()
expect(candidates[0].isMissing).toBe(false)
+ expect(candidates[1].isMissing).toBe(false)
+ expect(mockUpdateModelsForNodeType).toHaveBeenCalledWith(
+ 'CheckpointLoaderSimple'
+ )
})
- it('should not warn or fall back when hash verification is aborted', async () => {
- const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
- const abortError = new Error('aborted')
- abortError.name = 'AbortError'
- const hash =
- '4444444444444444444444444444444444444444444444444444444444444444'
- const candidates = [
- makeAssetCandidate('my_model.safetensors', {
- hash,
- hashType: 'blake3'
- })
- ]
- mockCheckAssetHash.mockRejectedValue(abortError)
-
- await verifyAssetSupportedCandidates(candidates)
-
- expect(candidates[0].isMissing).toBeUndefined()
- expect(mockUpdateModelsForNodeType).not.toHaveBeenCalled()
- expect(warn).not.toHaveBeenCalled()
- warn.mockRestore()
- })
-
- it('should resolve isMissing=false when asset with matching hash exists', async () => {
+ it('should resolve isMissing=false when asset with matching asset_hash exists', async () => {
const candidates = [
makeAssetCandidate('model.safetensors', {
hash: 'abc123',
@@ -1591,7 +1479,6 @@ describe('verifyAssetSupportedCandidates', () => {
await verifyAssetSupportedCandidates(candidates)
expect(candidates[0].isMissing).toBe(false)
- expect(mockCheckAssetHash).not.toHaveBeenCalled()
})
it('should resolve isMissing=false when asset with matching filename exists', async () => {
@@ -1675,6 +1562,55 @@ describe('verifyAssetSupportedCandidates', () => {
expect(mockUpdateModelsForNodeType).toHaveBeenCalledWith('LoraLoader')
})
+ it('should leave candidates unresolved when their node type fails to load', async () => {
+ const candidates = [
+ makeAssetCandidate('checkpoint.safetensors', {
+ nodeType: 'CheckpointLoaderSimple'
+ }),
+ makeAssetCandidate('lora.safetensors', { nodeType: 'LoraLoader' })
+ ]
+ mockUpdateModelsForNodeType.mockImplementation(async (nodeType: string) => {
+ if (nodeType === 'LoraLoader') throw new Error('load failed')
+ })
+ mockGetAssets.mockImplementation((nodeType: string) =>
+ nodeType === 'CheckpointLoaderSimple'
+ ? [
+ {
+ id: '1',
+ name: 'checkpoint.safetensors',
+ asset_hash: null,
+ metadata: { filename: 'checkpoint.safetensors' }
+ }
+ ]
+ : []
+ )
+
+ await verifyAssetSupportedCandidates(candidates)
+
+ expect(candidates[0].isMissing).toBe(false)
+ expect(candidates[1].isMissing).toBeUndefined()
+ })
+
+ it('should leave candidates unresolved when aborted after asset loads settle', async () => {
+ const controller = new AbortController()
+ const candidates = [makeAssetCandidate('model.safetensors')]
+ mockUpdateModelsForNodeType.mockImplementation(async () => {
+ controller.abort()
+ })
+ mockGetAssets.mockReturnValue([
+ {
+ id: '1',
+ name: 'model.safetensors',
+ asset_hash: null,
+ metadata: { filename: 'model.safetensors' }
+ }
+ ])
+
+ await verifyAssetSupportedCandidates(candidates, controller.signal)
+
+ expect(candidates[0].isMissing).toBeUndefined()
+ })
+
it('should match filename with path prefix normalization', async () => {
const candidates = [makeAssetCandidate('subfolder/my_model.safetensors')]
mockGetAssets.mockReturnValue([
diff --git a/src/platform/missingModel/missingModelScan.ts b/src/platform/missingModel/missingModelScan.ts
index bef803112a..85f2b6b56c 100644
--- a/src/platform/missingModel/missingModelScan.ts
+++ b/src/platform/missingModel/missingModelScan.ts
@@ -24,11 +24,6 @@ import {
} from '@/utils/graphTraversalUtil'
import { LGraphEventMode } from '@/lib/litegraph/src/types/globalEnums'
import { resolveComboValues } from '@/utils/litegraphUtil'
-import type { AssetHashStatus } from '@/platform/assets/services/assetService'
-import {
- assetService,
- toBlake3AssetHash
-} from '@/platform/assets/services/assetService'
export type MissingModelWorkflowData = FlattenableWorkflowGraph & {
models?: ModelFile[]
@@ -450,16 +445,10 @@ interface AssetVerifier {
getAssets: (nodeType: string) => AssetItem[] | undefined
}
-type AssetHashVerifier = (
- assetHash: string,
- signal?: AbortSignal
-) => Promise
-
export async function verifyAssetSupportedCandidates(
candidates: MissingModelCandidate[],
signal?: AbortSignal,
- assetsStore?: AssetVerifier,
- checkAssetHash: AssetHashVerifier = assetService.checkAssetHash
+ assetsStore?: AssetVerifier
): Promise {
if (signal?.aborted) return
@@ -468,52 +457,10 @@ export async function verifyAssetSupportedCandidates(
)
if (pendingCandidates.length === 0) return
- const pendingNodeTypes = new Set()
- const candidatesByHash = new Map()
-
- for (const candidate of pendingCandidates) {
- const assetHash = getBlake3AssetHash(candidate)
- if (!assetHash) {
- pendingNodeTypes.add(candidate.nodeType)
- continue
- }
-
- const hashCandidates = candidatesByHash.get(assetHash)
- if (hashCandidates) hashCandidates.push(candidate)
- else candidatesByHash.set(assetHash, [candidate])
- }
-
- await Promise.all(
- Array.from(candidatesByHash, async ([assetHash, hashCandidates]) => {
- if (signal?.aborted) return
-
- try {
- const status = await checkAssetHash(assetHash, signal)
- if (signal?.aborted) return
-
- if (status === 'exists') {
- for (const candidate of hashCandidates) {
- candidate.isMissing = false
- }
- return
- }
- } catch (err) {
- if (signal?.aborted || isAbortError(err)) return
- console.warn(
- '[Missing Model Pipeline] Failed to verify asset hash:',
- err
- )
- }
-
- for (const candidate of hashCandidates) {
- pendingNodeTypes.add(candidate.nodeType)
- }
- })
+ const pendingNodeTypes = new Set(
+ pendingCandidates.map((candidate) => candidate.nodeType)
)
- if (signal?.aborted) return
- if (pendingNodeTypes.size === 0) return
-
const store =
assetsStore ?? (await import('@/stores/assetsStore')).useAssetsStore()
@@ -544,20 +491,6 @@ export async function verifyAssetSupportedCandidates(
}
}
-function getBlake3AssetHash(candidate: MissingModelCandidate): string | null {
- if (candidate.hashType?.toLowerCase() !== 'blake3') return null
- return toBlake3AssetHash(candidate.hash)
-}
-
-function isAbortError(err: unknown): boolean {
- return (
- typeof err === 'object' &&
- err !== null &&
- 'name' in err &&
- err.name === 'AbortError'
- )
-}
-
function normalizePath(path: string): string {
return path.replace(/\\/g, '/')
}
diff --git a/src/platform/remote/comfyui/jobs/fetchJobs.test.ts b/src/platform/remote/comfyui/jobs/fetchJobs.test.ts
index 41b01606e2..53ad431f84 100644
--- a/src/platform/remote/comfyui/jobs/fetchJobs.test.ts
+++ b/src/platform/remote/comfyui/jobs/fetchJobs.test.ts
@@ -3,6 +3,7 @@ import { describe, expect, it, vi } from 'vitest'
import {
extractWorkflow,
fetchHistory,
+ fetchHistoryPage,
fetchJobDetail,
fetchQueue
} from '@/platform/remote/comfyui/jobs/fetchJobs'
@@ -29,15 +30,16 @@ function createMockJob(
function createMockResponse(
jobs: RawJobListItem[],
- total: number = jobs.length
+ total: number = jobs.length,
+ pagination: Partial = {}
): JobsListResponse {
return {
jobs,
pagination: {
- offset: 0,
- limit: 200,
+ offset: pagination.offset ?? 0,
+ limit: pagination.limit ?? 200,
total,
- has_more: false
+ has_more: pagination.has_more ?? false
}
}
}
@@ -100,7 +102,8 @@ describe('fetchJobs', () => {
createMockJob('job4', 'completed'),
createMockJob('job5', 'completed')
],
- 10 // total of 10 jobs
+ 10, // total of 10 jobs
+ { offset: 5 }
)
)
})
@@ -185,6 +188,36 @@ describe('fetchJobs', () => {
expect(result[1].id).toBe('text-job')
expect(result[2].id).toBe('no-preview-job')
})
+
+ it('returns server pagination metadata for history pages', async () => {
+ const mockFetch = vi.fn().mockResolvedValue({
+ ok: true,
+ json: () =>
+ Promise.resolve(
+ createMockResponse(
+ [
+ createMockJob('job4', 'completed'),
+ createMockJob('job5', 'completed')
+ ],
+ 10,
+ { offset: 5, limit: 2, has_more: true }
+ )
+ )
+ })
+
+ const result = await fetchHistoryPage(mockFetch, 2, 5)
+
+ expect(mockFetch).toHaveBeenCalledWith(
+ '/jobs?status=completed,failed,cancelled&limit=2&offset=5'
+ )
+ expect(result.jobs).toHaveLength(2)
+ expect(result.offset).toBe(5)
+ expect(result.limit).toBe(2)
+ expect(result.total).toBe(10)
+ expect(result.hasMore).toBe(true)
+ expect(result.jobs[0].priority).toBe(5)
+ expect(result.jobs[1].priority).toBe(4)
+ })
})
describe('fetchQueue', () => {
diff --git a/src/platform/remote/comfyui/jobs/fetchJobs.ts b/src/platform/remote/comfyui/jobs/fetchJobs.ts
index 6eee0e959c..25790a5ecd 100644
--- a/src/platform/remote/comfyui/jobs/fetchJobs.ts
+++ b/src/platform/remote/comfyui/jobs/fetchJobs.ts
@@ -22,6 +22,16 @@ interface FetchJobsRawResult {
jobs: RawJobListItem[]
total: number
offset: number
+ limit: number
+ hasMore: boolean
+}
+
+export interface FetchHistoryPageResult {
+ jobs: JobListItem[]
+ total: number
+ offset: number
+ limit: number
+ hasMore: boolean
}
/**
@@ -40,13 +50,25 @@ async function fetchJobsRaw(
const res = await fetchApi(url)
if (!res.ok) {
console.error(`[Jobs API] Failed to fetch jobs: ${res.status}`)
- return { jobs: [], total: 0, offset: 0 }
+ return {
+ jobs: [],
+ total: 0,
+ offset,
+ limit: maxItems,
+ hasMore: false
+ }
}
const data = zJobsListResponse.parse(await res.json())
- return { jobs: data.jobs, total: data.pagination.total, offset }
+ return {
+ jobs: data.jobs,
+ total: data.pagination.total,
+ offset: data.pagination.offset,
+ limit: data.pagination.limit,
+ hasMore: data.pagination.has_more
+ }
} catch (error) {
console.error('[Jobs API] Error fetching jobs:', error)
- return { jobs: [], total: 0, offset: 0 }
+ return { jobs: [], total: 0, offset, limit: maxItems, hasMore: false }
}
}
@@ -76,14 +98,33 @@ export async function fetchHistory(
maxItems: number = 200,
offset: number = 0
): Promise {
- const { jobs, total } = await fetchJobsRaw(
+ const { jobs } = await fetchHistoryPage(fetchApi, maxItems, offset)
+ return jobs
+}
+
+/**
+ * Fetches one page of history with server-provided pagination metadata.
+ */
+export async function fetchHistoryPage(
+ fetchApi: (url: string) => Promise,
+ maxItems: number = 200,
+ offset: number = 0
+): Promise {
+ const result = await fetchJobsRaw(
fetchApi,
['completed', 'failed', 'cancelled'],
maxItems,
offset
)
+
// History gets priority based on total count (lower than queue)
- return assignPriority(jobs, total - offset)
+ return {
+ jobs: assignPriority(result.jobs, result.total - result.offset),
+ total: result.total,
+ offset: result.offset,
+ limit: result.limit,
+ hasMore: result.hasMore
+ }
}
/**
diff --git a/src/platform/settings/constants/coreSettings.ts b/src/platform/settings/constants/coreSettings.ts
index f805a4e067..4a6a6f79fa 100644
--- a/src/platform/settings/constants/coreSettings.ts
+++ b/src/platform/settings/constants/coreSettings.ts
@@ -659,7 +659,7 @@ export const CORE_SETTINGS: SettingParams[] = [
tooltip:
'The maximum number of tasks added to the queue at one button click',
type: 'number',
- defaultValue: isCloud ? 32 : 100,
+ defaultValue: 100,
versionAdded: '1.3.5'
},
{
diff --git a/src/platform/support/config.test.ts b/src/platform/support/config.test.ts
new file mode 100644
index 0000000000..7720b79e77
--- /dev/null
+++ b/src/platform/support/config.test.ts
@@ -0,0 +1,52 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+const distribution = vi.hoisted(() => ({ isCloud: false, isNightly: false }))
+
+vi.mock('@/platform/distribution/types', () => ({
+ get isCloud() {
+ return distribution.isCloud
+ },
+ get isNightly() {
+ return distribution.isNightly
+ }
+}))
+
+describe('buildFeedbackTypeformUrl', () => {
+ beforeEach(() => {
+ distribution.isCloud = false
+ distribution.isNightly = false
+ })
+
+ async function build(source: 'topbar' | 'action-bar' | 'help-center') {
+ vi.resetModules()
+ const { buildFeedbackTypeformUrl } = await import('./config')
+ return buildFeedbackTypeformUrl(source)
+ }
+
+ it('tags Cloud builds with distribution=ccloud', async () => {
+ distribution.isCloud = true
+ expect(await build('topbar')).toBe(
+ 'https://form.typeform.com/to/q7azbWPi#distribution=ccloud&source=topbar'
+ )
+ })
+
+ it('tags Nightly builds with distribution=oss-nightly', async () => {
+ distribution.isNightly = true
+ expect(await build('action-bar')).toBe(
+ 'https://form.typeform.com/to/q7azbWPi#distribution=oss-nightly&source=action-bar'
+ )
+ })
+
+ it('tags OSS builds with distribution=oss', async () => {
+ expect(await build('help-center')).toBe(
+ 'https://form.typeform.com/to/q7azbWPi#distribution=oss&source=help-center'
+ )
+ })
+
+ it('uses a URL fragment so distribution and source are not sent to the server', async () => {
+ distribution.isCloud = true
+ const url = new URL(await build('topbar'))
+ expect(url.search).toBe('')
+ expect(url.hash).toBe('#distribution=ccloud&source=topbar')
+ })
+})
diff --git a/src/platform/support/config.ts b/src/platform/support/config.ts
index 349bfb1ec9..38747ab4d8 100644
--- a/src/platform/support/config.ts
+++ b/src/platform/support/config.ts
@@ -15,7 +15,7 @@ const ZENDESK_FIELDS = {
} as const
/**
- * Gets the distribution identifier for Zendesk tracking.
+ * Gets the distribution identifier for tracking.
* Helps distinguish feedback from different build types.
*/
function getDistribution(): 'ccloud' | 'oss-nightly' | 'oss' {
@@ -25,17 +25,22 @@ function getDistribution(): 'ccloud' | 'oss-nightly' | 'oss' {
}
const SUPPORT_BASE_URL = 'https://support.comfy.org/hc/en-us/requests/new'
-const ZENDESK_FEEDBACK_FORM_ID = '43066738713236'
+const FEEDBACK_TYPEFORM_BASE_URL = 'https://form.typeform.com/to/q7azbWPi'
/**
- * Builds the feedback form URL with the appropriate distribution tag.
+ * Builds the feedback Typeform URL tagged with the current build distribution
+ * and the UI source that opened it. Tags are passed via the URL fragment
+ * (Typeform's hidden-field convention) so survey responses can be segmented
+ * by distribution (cloud / oss-nightly / oss) and entry point.
*/
-export function buildFeedbackUrl(): string {
+export function buildFeedbackTypeformUrl(
+ source: 'topbar' | 'action-bar' | 'help-center'
+): string {
const params = new URLSearchParams({
- ticket_form_id: ZENDESK_FEEDBACK_FORM_ID,
- [ZENDESK_FIELDS.DISTRIBUTION]: getDistribution()
+ distribution: getDistribution(),
+ source
})
- return `${SUPPORT_BASE_URL}?${params.toString()}`
+ return `${FEEDBACK_TYPEFORM_BASE_URL}#${params.toString()}`
}
/**
diff --git a/src/platform/workflow/core/services/workflowService.test.ts b/src/platform/workflow/core/services/workflowService.test.ts
index 73d4a151b2..d5d897b354 100644
--- a/src/platform/workflow/core/services/workflowService.test.ts
+++ b/src/platform/workflow/core/services/workflowService.test.ts
@@ -418,24 +418,51 @@ describe('useWorkflowService', () => {
})
vi.mocked(workflowStore.saveWorkflow).mockResolvedValue()
- await useWorkflowService().saveWorkflow(workflow)
+ const result = await useWorkflowService().saveWorkflow(workflow)
+ expect(result).toBe(true)
expect(workflowStore.saveWorkflow).toHaveBeenCalledWith(workflow)
})
- it('should call saveWorkflowAs for temporary workflows', async () => {
+ it('should return false when temporary workflow save is cancelled', async () => {
const workflow = createModeTestWorkflow({
path: 'workflows/Unsaved Workflow.json'
})
Object.defineProperty(workflow, 'isTemporary', { get: () => true })
vi.spyOn(workflow, 'promptSave').mockResolvedValue(null)
- await useWorkflowService().saveWorkflow(workflow)
+ const result = await useWorkflowService().saveWorkflow(workflow)
+ expect(result).toBe(false)
expect(workflowStore.saveWorkflow).not.toHaveBeenCalled()
})
})
+ describe('closeWorkflow', () => {
+ let workflowStore: ReturnType
+ let service: ReturnType
+
+ beforeEach(() => {
+ workflowStore = useWorkflowStore()
+ service = useWorkflowService()
+ })
+
+ it('keeps a temporary workflow open when Save As is cancelled', async () => {
+ const workflow = createModeTestWorkflow({
+ path: 'workflows/Unsaved Workflow.json'
+ })
+ workflow.isModified = true
+ Object.defineProperty(workflow, 'isTemporary', { get: () => true })
+ vi.spyOn(workflow, 'promptSave').mockResolvedValue(null)
+ mockConfirm.mockResolvedValue(true)
+
+ const closed = await service.closeWorkflow(workflow)
+
+ expect(closed).toBe(false)
+ expect(workflowStore.closeWorkflow).not.toHaveBeenCalled()
+ })
+ })
+
describe('afterLoadNewGraph', () => {
let workflowStore: ReturnType
let existingWorkflow: LoadedComfyWorkflow
diff --git a/src/platform/workflow/core/services/workflowService.ts b/src/platform/workflow/core/services/workflowService.ts
index 15dc01879b..c7bc669d1d 100644
--- a/src/platform/workflow/core/services/workflowService.ts
+++ b/src/platform/workflow/core/services/workflowService.ts
@@ -174,40 +174,39 @@ export const useWorkflowService = () => {
* Save a workflow
* @param workflow The workflow to save
*/
- const saveWorkflow = async (workflow: ComfyWorkflow) => {
+ const saveWorkflow = async (workflow: ComfyWorkflow): Promise => {
if (workflow.isTemporary) {
- await saveWorkflowAs(workflow)
- } else {
- workflow.changeTracker?.prepareForSave()
- const isApp = workflow.initialMode === 'app'
- const expectedPath =
- workflow.directory +
- '/' +
- appendWorkflowJsonExt(workflow.filename, isApp)
- if (workflow.path !== expectedPath) {
- const existing = workflowStore.getWorkflowByPath(expectedPath)
- if (existing && !existing.isTemporary) {
- if ((await confirmOverwrite(expectedPath)) !== true) {
- await workflowStore.saveWorkflow(workflow)
- return
- }
- await deleteWorkflow(existing, true)
- }
- await renameWorkflow(workflow, expectedPath)
- toastStore.add({
- severity: 'info',
- summary: t(
- isApp
- ? 'workflowService.savedAsApp'
- : 'workflowService.savedAsWorkflow'
- ),
- life: 3000
- })
- }
-
- await workflowStore.saveWorkflow(workflow)
- useTelemetry()?.trackWorkflowSaved({ is_app: isApp, is_new: false })
+ return await saveWorkflowAs(workflow)
}
+
+ workflow.changeTracker?.prepareForSave()
+ const isApp = workflow.initialMode === 'app'
+ const expectedPath =
+ workflow.directory + '/' + appendWorkflowJsonExt(workflow.filename, isApp)
+ if (workflow.path !== expectedPath) {
+ const existing = workflowStore.getWorkflowByPath(expectedPath)
+ if (existing && !existing.isTemporary) {
+ if ((await confirmOverwrite(expectedPath)) !== true) {
+ await workflowStore.saveWorkflow(workflow)
+ return true
+ }
+ await deleteWorkflow(existing, true)
+ }
+ await renameWorkflow(workflow, expectedPath)
+ toastStore.add({
+ severity: 'info',
+ summary: t(
+ isApp
+ ? 'workflowService.savedAsApp'
+ : 'workflowService.savedAsWorkflow'
+ ),
+ life: 3000
+ })
+ }
+
+ await workflowStore.saveWorkflow(workflow)
+ useTelemetry()?.trackWorkflowSaved({ is_app: isApp, is_new: false })
+ return true
}
/**
@@ -284,13 +283,15 @@ export const useWorkflowService = () => {
type: 'dirtyClose',
message: t('sideToolbar.workflowTab.dirtyClose'),
itemList: [workflow.path],
- hint: options.hint
+ hint: options.hint,
+ denyLabel: t('sideToolbar.workflowTab.dirtyCloseAnyway')
})
// Cancel
if (confirmed === null) return false
if (confirmed === true) {
- await saveWorkflow(workflow)
+ const saved = await saveWorkflow(workflow)
+ if (!saved) return false
}
}
diff --git a/src/platform/workspace/components/CurrentUserPopoverWorkspace.vue b/src/platform/workspace/components/CurrentUserPopoverWorkspace.vue
index 89195d9cb6..27425ba4ac 100644
--- a/src/platform/workspace/components/CurrentUserPopoverWorkspace.vue
+++ b/src/platform/workspace/components/CurrentUserPopoverWorkspace.vue
@@ -68,10 +68,16 @@
{{
displayedCredits
}}
-
+ variant="muted-textonly"
+ size="icon-sm"
+ class="mr-auto"
+ :aria-label="$t('credits.unified.tooltip')"
+ data-testid="credits-info-button"
+ >
+
+
(() => [
])
-