Compare commits

..

2 Commits

Author SHA1 Message Date
jaeone94
cea30e2b69 fix: harden asset hash verification abort handling 2026-05-07 21:01:45 +09:00
jaeone94
dfcca34880 refactor: extract asset hash verification 2026-05-07 21:01:45 +09:00
175 changed files with 1347 additions and 12050 deletions

View File

@@ -19,26 +19,15 @@ reviews:
- name: End-to-end regression coverage for fixes
mode: error
instructions: |
Use only PR metadata already available in the review context:
- the PR title
- commit subjects in this PR
- The files changed in this PR relative to the PR base (equivalent to `base...head`)
- the PR description.
Do not rely on shell commands.
Do not inspect reverse diffs, files changed only on the base branch, or files outside this PR.
If the changed-file list or commit subjects are unavailable, mark the check inconclusive instead of guessing.
Use only PR metadata already available in the review context: the PR title, commit subjects in this PR, the files changed in this PR relative to the PR base (equivalent to `base...head`), and the PR description.
Do not rely on shell commands. Do not inspect reverse diffs, files changed only on the base branch, or files outside this PR. If the changed-file list or commit subjects are unavailable, mark the check inconclusive instead of guessing.
Fail if all of the following are true:
1. The PR title and/or any commit subject in the PR uses bug-fix language such as `fix`, `fixed`, `fixes`, `fixing`, `bugfix`, or `hotfix`.
2. The PR changes files under `src/` or `packages/` related to the main frontend application but the PR does not change at least one file under `browser_tests/`.
3. The PR description lacks a concrete explanation of why an end-to-end regression test was not added.
Do not fail if the changes are exclusively in `apps/website`, just documentation changes, or changes related to CI processes.
The goal is to make sure that fixes include End-to-End regression tests. Do not insist on tests when the PR is not fixing a bug.
Pass otherwise.
When failing, mention which bug-fix signal you found and ask the author to either add or update a Playwright regression test under `browser_tests/` or add a concrete explanation in the PR description of why an end-to-end regression test is not practical.
Pass if at least one of the following is true:
1. Neither the PR title nor any commit subject in the PR uses bug-fix language such as `fix`, `fixed`, `fixes`, `fixing`, `bugfix`, or `hotfix`.
2. The PR changes at least one file under `browser_tests/`.
3. The PR description includes a concrete, non-placeholder explanation of why an end-to-end regression test was not added.
Fail otherwise. When failing, mention which bug-fix signal you found and ask the author to either add or update a Playwright regression test under `browser_tests/` or add a concrete explanation in the PR description of why an end-to-end regression test is not practical.
- name: ADR compliance for entity/litegraph changes
mode: warning
instructions: |

View File

@@ -32,34 +32,16 @@ test.describe('Careers page @smoke', () => {
}
})
test('clicking a department button scrolls to and activates that section', async ({
test('ENGINEERING category filter narrows the role list', async ({
page
}) => {
const rolesSection = page.getByTestId('careers-roles')
await rolesSection.scrollIntoViewIfNeeded()
await expect(rolesSection).toBeVisible()
const allCount = await page.getByTestId('careers-role-link').count()
const engineeringButton = page.getByRole('button', {
name: 'ENGINEERING',
exact: true
})
// RolesSection is hydrated via `client:visible`. Once the button responds
// to a click by flipping aria-pressed, Vue is hydrated and the rest of
// the locator logic is in effect.
await expect(async () => {
await engineeringButton.click()
await expect(engineeringButton).toHaveAttribute('aria-pressed', 'true', {
timeout: 1_000
})
}).toPass({ timeout: 10_000 })
const engineeringSection = page.locator('#careers-dept-engineering')
await expect(engineeringSection).toBeInViewport()
expect(await page.getByTestId('careers-role-link').count()).toBe(allCount)
await page.getByRole('button', { name: 'ENGINEERING', exact: true }).click()
const engineeringLocator = page.getByTestId('careers-role-link')
await expect(engineeringLocator.first()).toBeVisible()
const engineeringCount = await engineeringLocator.count()
expect(engineeringCount).toBeLessThanOrEqual(allCount)
expect(engineeringCount).toBeGreaterThan(0)
})
})

View File

@@ -1,61 +0,0 @@
import { expect } from '@playwright/test'
import { test } from './fixtures/blockExternalMedia'
const M4_PRO_14_INCH_VIEWPORT = { width: 2016, height: 1310 }
const LAST_SECTION_HASH = '#contact'
test.describe(
'ContentSection scroll-spy @smoke',
{
annotation: [
{
type: 'issue',
description:
'https://linear.app/comfyorg/issue/FE-604/bug-bottom-badge-not-activating-on-scroll-at-high-resolution-3024x1964'
},
{
type: 'environment',
description:
'14" MacBook M4 Pro logical viewport reported in FE-604; /privacy-policy reproduces because of its short trailing sections'
}
]
},
() => {
test.use({ viewport: M4_PRO_14_INCH_VIEWPORT })
test('activates the last badge when user scrolls to the bottom', async ({
page
}) => {
await page.goto('/privacy-policy')
const sidebarNav = page.getByRole('navigation', {
name: 'Category filter'
})
const badges = sidebarNav.getByRole('button')
const lastBadge = badges.last()
await expect(badges.first()).toHaveAttribute('aria-pressed', 'true')
await expect(lastBadge).toHaveAttribute('aria-pressed', 'false')
await page.evaluate(() =>
window.scrollTo(0, document.documentElement.scrollHeight)
)
await expect(lastBadge).toHaveAttribute('aria-pressed', 'true')
})
test('activates the last badge when page mounts already at the bottom via trailing hash', async ({
page
}) => {
await page.goto(`/privacy-policy${LAST_SECTION_HASH}`)
const sidebarNav = page.getByRole('navigation', {
name: 'Category filter'
})
const lastBadge = sidebarNav.getByRole('button').last()
await expect(lastBadge).toHaveAttribute('aria-pressed', 'true')
})
}
)

View File

@@ -1,33 +0,0 @@
import { expect } from '@playwright/test'
import { test } from './fixtures/blockExternalMedia'
test.describe('Customers @smoke', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/customers')
})
test('hero image declares intrinsic dimensions so layout reserves space before load', async ({
page
}) => {
const heroImage = page.locator('img[alt="Comfy 3D logo"]')
await expect(heroImage).toBeVisible()
await expect(heroImage).toHaveAttribute('width', /^\d+$/)
await expect(heroImage).toHaveAttribute('height', /^\d+$/)
// Regression guard: an unloaded <img> without intrinsic dimensions
// collapses to ~0px, then jumps to its natural size on load and pushes
// the video below it. Reserved space must persist before bytes arrive.
const heightWhileUnloaded = await page.evaluate(() => {
const img = document.querySelector<HTMLImageElement>(
'img[alt="Comfy 3D logo"]'
)
if (!img) return null
img.removeAttribute('src')
return img.getBoundingClientRect().height
})
expect(heightWhileUnloaded).not.toBeNull()
expect(heightWhileUnloaded!).toBeGreaterThan(100)
})
})

View File

@@ -1,71 +1,27 @@
import { expect, test } from '@playwright/test'
import { demos, getNextDemo } from '../src/config/demos'
import { t } from '../src/i18n/translations'
const escapeRegExp = (value: string): string =>
value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
test.describe('Demo pages @smoke', () => {
for (const demo of demos) {
const nextDemo = getNextDemo(demo.slug)
test('demo detail page renders hero and embed', async ({ page }) => {
await page.goto('/demos/image-to-video')
await expect(page.getByRole('heading', { level: 1 })).toBeVisible()
await expect(page.getByRole('heading', { level: 1 })).toContainText(
'Create a Video from an Image'
)
const iframe = page.locator('iframe[title*="Interactive demo"]')
await expect(iframe).toBeAttached()
})
test(`/demos/${demo.slug} renders hero, embed, transcript, and next-demo nav`, async ({
page
}) => {
await page.goto(`/demos/${demo.slug}`)
test('demo detail page has transcript section', async ({ page }) => {
await page.goto('/demos/image-to-video')
await expect(
page.getByRole('button', { name: /demo transcript/i })
).toBeVisible()
})
const heading = page.getByRole('heading', { level: 1 })
await expect(heading).toBeVisible()
await expect(heading).toContainText(t(demo.title, 'en'))
const ogImage = page.locator('head meta[property="og:image"]')
await expect(ogImage).toHaveAttribute(
'content',
new RegExp(`${escapeRegExp(demo.slug)}-og\\.png`)
)
const iframe = page.locator(
`iframe[title*="${t('demos.embed.label', 'en')}"]`
)
await expect(iframe).toBeAttached()
await expect(iframe).toHaveAttribute(
'src',
new RegExp(escapeRegExp(demo.arcadeId))
)
await expect(
page.getByRole('button', { name: /demo transcript/i })
).toBeVisible()
await expect(
page.getByText(t(nextDemo.title, 'en')).first()
).toBeVisible()
const nextThumb = page.locator(`img[src="${nextDemo.thumbnail}"]`).first()
await expect(nextThumb).toBeAttached()
await expect(nextThumb).toBeVisible()
const naturalWidth = await nextThumb.evaluate(
(img) => (img as HTMLImageElement).naturalWidth
)
expect(naturalWidth).toBeGreaterThan(1)
})
test(`/zh-CN/demos/${demo.slug} renders localized content`, async ({
page
}) => {
await page.goto(`/zh-CN/demos/${demo.slug}`)
await expect(page).toHaveURL(/\/zh-CN\/demos\//)
const heading = page.getByRole('heading', { level: 1 })
await expect(heading).toContainText(t(demo.title, 'zh-CN'))
await expect(heading).toContainText(/[\u4E00-\u9FFF]/)
await expect(
page.getByText(t(nextDemo.title, 'zh-CN')).first()
).toBeVisible()
})
}
test('demo detail page has next demo navigation', async ({ page }) => {
await page.goto('/demos/image-to-video')
await expect(page.getByText(/what's next/i)).toBeVisible()
})
test('demo library page renders', async ({ page }) => {
await page.goto('/demos')
@@ -76,4 +32,13 @@ test.describe('Demo pages @smoke', () => {
const response = await page.goto('/demos/nonexistent')
expect(response?.status()).toBe(404)
})
test('zh-CN demo page renders localized content', async ({ page }) => {
await page.goto('/zh-CN/demos/image-to-video')
await expect(page.getByRole('heading', { level: 1 })).toContainText(
'从图片创建视频'
)
const nextDemoLink = page.locator('a[href*="/zh-CN/demos/"]').first()
await expect(nextDemoLink).toBeAttached()
})
})

View File

@@ -1,4 +1,3 @@
import type { Page } from '@playwright/test'
import { expect } from '@playwright/test'
import { test } from './fixtures/blockExternalMedia'
@@ -48,105 +47,4 @@ test.describe('Mobile layout @mobile', () => {
const mobileContainer = page.getByTestId('social-proof-mobile')
await expect(mobileContainer).toBeVisible()
})
test.describe('SocialProofBar seamless marquee', () => {
test.use({ contextOptions: { reducedMotion: 'no-preference' } })
test('mobile forward marquee loops seamlessly', async ({ page }) => {
const geometry = await measureMarqueeLoopGeometry(
page,
'[data-testid="social-proof-mobile"] .animate-marquee'
)
expectSeamlessForwardLoop(geometry)
})
test('mobile reverse marquee loops seamlessly', async ({ page }) => {
const geometry = await measureMarqueeLoopGeometry(
page,
'[data-testid="social-proof-mobile"] .animate-marquee-reverse'
)
expectSeamlessReverseLoop(geometry)
})
})
})
test.describe('Desktop SocialProofBar @smoke', () => {
test.use({ contextOptions: { reducedMotion: 'no-preference' } })
test.beforeEach(async ({ page }) => {
await page.goto('/')
})
test('desktop marquee loops seamlessly', async ({ page }) => {
const geometry = await measureMarqueeLoopGeometry(
page,
'[data-testid="social-proof-desktop"] .animate-marquee'
)
expectSeamlessForwardLoop(geometry)
})
})
type MarqueeGeometry = {
copyWidths: number[]
startPositions: number[]
endPositions: number[]
}
async function measureMarqueeLoopGeometry(
page: Page,
selector: string
): Promise<MarqueeGeometry> {
await page.locator(selector).first().waitFor()
return page.evaluate((sel) => {
const tracks = Array.from(
document.querySelectorAll<HTMLElement>(sel)
).slice(0, 2)
const firstAnimation = tracks[0]?.getAnimations()[0]
if (!firstAnimation) {
throw new Error(`No CSS animation found on ${sel}`)
}
const duration = firstAnimation.effect?.getTiming().duration
if (typeof duration !== 'number' || duration <= 1) {
throw new Error(
`Animation on ${sel} has unusable duration: ${String(duration)}`
)
}
const setAllTimes = (time: number) => {
for (const track of tracks) {
for (const anim of track.getAnimations()) {
anim.currentTime = time
}
}
void document.body.offsetWidth
}
const readX = () => tracks.map((track) => track.getBoundingClientRect().x)
setAllTimes(0)
const startPositions = readX()
const copyWidths = tracks.map(
(track) => track.getBoundingClientRect().width
)
setAllTimes(duration - 0.1)
const endPositions = readX()
return { copyWidths, startPositions, endPositions }
}, selector)
}
function expectTwoMatchingCopies(geometry: MarqueeGeometry) {
const { copyWidths } = geometry
expect(copyWidths.length, 'expected two duplicate marquee tracks').toBe(2)
expect(copyWidths[0]).toBeGreaterThan(0)
expect(copyWidths[1]).toBeCloseTo(copyWidths[0], 0)
}
function expectSeamlessForwardLoop(geometry: MarqueeGeometry) {
expectTwoMatchingCopies(geometry)
// Copy 2 ends the cycle exactly where copy 1 started, so the restart
// (when copy 1 jumps back to its start position) is visually indistinguishable.
expect(geometry.endPositions[1]).toBeCloseTo(geometry.startPositions[0], 0)
}
function expectSeamlessReverseLoop(geometry: MarqueeGeometry) {
expectTwoMatchingCopies(geometry)
// Reverse marquee: copy 1 ends the cycle where copy 2 started.
expect(geometry.endPositions[0]).toBeCloseTo(geometry.startPositions[1], 0)
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 270 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 119 KiB

After

Width:  |  Height:  |  Size: 69 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 69 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 119 KiB

After

Width:  |  Height:  |  Size: 69 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 69 B

View File

@@ -1,13 +1,10 @@
<script setup lang="ts">
import { useEventListener, useTemplateRefsList } from '@vueuse/core'
import { computed, onMounted, ref } from 'vue'
import { computed, ref } from 'vue'
import type { Department } from '../../data/roles'
import type { Locale } from '../../i18n/translations'
import { prefersReducedMotion } from '../../composables/useReducedMotion'
import { t } from '../../i18n/translations'
import { scrollTo } from '../../scripts/smoothScroll'
import CategoryNav from '../common/CategoryNav.vue'
import SectionLabel from '../common/SectionLabel.vue'
@@ -16,72 +13,24 @@ const { locale = 'en', departments = [] } = defineProps<{
departments?: readonly Department[]
}>()
const activeCategory = ref('all')
const visibleDepartments = computed(() =>
departments.filter((d) => d.roles.length > 0)
)
const categories = computed(() =>
visibleDepartments.value.map((d) => ({ label: d.name, value: d.key }))
const categories = computed(() => [
{ label: 'ALL', value: 'all' },
...visibleDepartments.value.map((d) => ({ label: d.name, value: d.key }))
])
const filteredDepartments = computed(() =>
activeCategory.value === 'all'
? visibleDepartments.value
: visibleDepartments.value.filter((d) => d.key === activeCategory.value)
)
const hasRoles = computed(() => visibleDepartments.value.length > 0)
const activeCategory = ref('')
const sectionRefs = useTemplateRefsList<HTMLElement>()
let isScrolling = false
let pendingFrame = 0
const HEADER_OFFSET = -144
const ACTIVATION_OFFSET = 300
const deptElementId = (key: string) => `careers-dept-${key}`
function pickActiveSection() {
pendingFrame = 0
if (isScrolling) return
const sections = sectionRefs.value as HTMLElement[]
if (sections.length === 0) return
let active = sections[0]
for (const el of sections) {
if (el.getBoundingClientRect().top - ACTIVATION_OFFSET <= 0) {
active = el
} else {
break
}
}
activeCategory.value = active.id.replace(/^careers-dept-/, '')
}
function scheduleUpdate() {
if (pendingFrame !== 0) return
pendingFrame = requestAnimationFrame(pickActiveSection)
}
onMounted(pickActiveSection)
useEventListener('scroll', scheduleUpdate, { passive: true })
useEventListener('resize', scheduleUpdate, { passive: true })
function scrollToDepartment(deptKey: string) {
activeCategory.value = deptKey
isScrolling = true
const el = document.getElementById(deptElementId(deptKey))
if (!el) {
isScrolling = false
return
}
scrollTo(el, {
offset: HEADER_OFFSET,
duration: 0.8,
immediate: prefersReducedMotion(),
onComplete: () => {
isScrolling = false
pickActiveSection()
}
})
}
</script>
<template>
@@ -99,10 +48,9 @@ function scrollToDepartment(deptKey: string) {
</h2>
<CategoryNav
v-if="hasRoles"
v-model="activeCategory"
:categories="categories"
:model-value="activeCategory"
class="mt-4"
@update:model-value="scrollToDepartment"
/>
</div>
</div>
@@ -117,11 +65,9 @@ function scrollToDepartment(deptKey: string) {
</p>
<div
v-for="dept in visibleDepartments"
:id="deptElementId(dept.key)"
:ref="sectionRefs.set"
v-for="dept in filteredDepartments"
:key="dept.key"
class="mb-12 scroll-mt-24 last:mb-0 md:scroll-mt-36"
class="mb-12 last:mb-0"
>
<SectionLabel>
{{ dept.name }}

View File

@@ -1,11 +1,7 @@
<script setup lang="ts">
import { cn } from '@comfyorg/tailwind-utils'
import {
useEventListener,
useIntersectionObserver,
useTemplateRefsList
} from '@vueuse/core'
import { computed, onMounted, ref } from 'vue'
import { useIntersectionObserver, useTemplateRefsList } from '@vueuse/core'
import { computed, ref } from 'vue'
import type { Locale, TranslationKey } from '../../i18n/translations'
@@ -44,25 +40,13 @@ const activeSection = ref(sections[0]?.id ?? '')
const sectionRefs = useTemplateRefsList<HTMLElement>()
let isScrolling = false
let scrollSafetyTimer: ReturnType<typeof setTimeout> | undefined
const HEADER_OFFSET = -144
const BOTTOM_THRESHOLD_PX = 4
const SCROLL_SAFETY_MS = 1500
function clearScrollLock() {
isScrolling = false
if (scrollSafetyTimer !== undefined) {
clearTimeout(scrollSafetyTimer)
scrollSafetyTimer = undefined
}
}
useIntersectionObserver(
sectionRefs,
(entries) => {
if (isScrolling) return
if (isAtBottom()) return
let best: IntersectionObserverEntry | null = null
for (const entry of entries) {
if (!entry.isIntersecting) continue
@@ -74,39 +58,22 @@ useIntersectionObserver(
{ rootMargin: '-20% 0px -60% 0px' }
)
function isAtBottom(): boolean {
const scrollBottom = window.scrollY + window.innerHeight
return (
scrollBottom >= document.documentElement.scrollHeight - BOTTOM_THRESHOLD_PX
)
}
function activateLastIfAtBottom() {
if (isScrolling) return
if (!isAtBottom()) return
const lastId = sections[sections.length - 1]?.id
if (lastId) activeSection.value = lastId
}
onMounted(activateLastIfAtBottom)
useEventListener('scroll', activateLastIfAtBottom, { passive: true })
function scrollToSection(id: string) {
activeSection.value = id
clearScrollLock()
isScrolling = true
scrollSafetyTimer = setTimeout(clearScrollLock, SCROLL_SAFETY_MS)
const el = document.getElementById(id)
if (el) {
scrollTo(el, {
offset: HEADER_OFFSET,
duration: 0.8,
immediate: prefersReducedMotion(),
onComplete: clearScrollLock
onComplete: () => {
isScrolling = false
}
})
return
}
clearScrollLock()
isScrolling = false
}
</script>

View File

@@ -26,7 +26,7 @@ const {
<img
src="/icons/node-left.svg"
alt=""
class="-mx-px h-full w-auto self-stretch"
class="-mx-px self-stretch"
aria-hidden="true"
/>
@@ -38,7 +38,7 @@ const {
v-if="i > 0"
src="/icons/node-union.svg"
alt=""
class="-mx-px h-full w-auto self-stretch"
class="-mx-px self-stretch"
aria-hidden="true"
/>
<span
@@ -72,7 +72,7 @@ const {
<img
src="/icons/node-right.svg"
alt=""
class="-mx-px h-full w-auto self-stretch"
class="-mx-px self-stretch"
aria-hidden="true"
/>
</div>

View File

@@ -14,28 +14,23 @@ const logos = [
'Ubisoft'
]
const mobileRow1Logos = logos.slice(0, 6)
const mobileRow2Logos = logos.slice(6)
const desktopLogos = Array.from({ length: 4 }, () => logos).flat()
const row1 = logos.slice(0, 6)
const mobileRow1 = [...row1, ...row1]
const row2 = logos.slice(6)
const mobileRow2 = [...row2, ...row2]
</script>
<template>
<section class="overflow-hidden py-12">
<!-- Single row on desktop -->
<div data-testid="social-proof-desktop" class="hidden w-max gap-2 md:flex">
<div class="animate-marquee hidden items-center gap-2 md:flex">
<div
v-for="copy in 2"
:key="copy"
class="animate-marquee flex shrink-0 items-center gap-2"
style="--marquee-gap: 0.5rem"
:aria-hidden="copy === 2 ? 'true' : undefined"
v-for="(logo, i) in desktopLogos"
:key="`${logo}-${i}`"
class="flex h-20 w-50 shrink-0 items-center justify-center"
>
<div
v-for="logo in logos"
:key="logo"
class="flex h-20 w-50 shrink-0 items-center justify-center"
>
<img :src="`/icons/clients/${logo}.svg`" :alt="logo" />
</div>
<img :src="`/icons/clients/${logo}.svg`" :alt="logo" />
</div>
</div>
@@ -44,38 +39,22 @@ const mobileRow2Logos = logos.slice(6)
data-testid="social-proof-mobile"
class="flex flex-col gap-8 md:hidden"
>
<div class="flex w-max gap-8">
<div class="animate-marquee flex items-center gap-8">
<div
v-for="copy in 2"
:key="copy"
class="animate-marquee flex shrink-0 items-center gap-8"
style="--marquee-gap: 2rem"
:aria-hidden="copy === 2 ? 'true' : undefined"
v-for="(logo, i) in mobileRow1"
:key="`${logo}-${i}`"
class="flex h-14 w-40 shrink-0 items-center justify-center"
>
<div
v-for="logo in mobileRow1Logos"
:key="logo"
class="flex h-14 w-40 shrink-0 items-center justify-center"
>
<img :src="`/icons/clients/${logo}.svg`" :alt="logo" />
</div>
<img :src="`/icons/clients/${logo}.svg`" :alt="logo" />
</div>
</div>
<div class="flex w-max gap-8">
<div class="animate-marquee-reverse flex items-center gap-8">
<div
v-for="copy in 2"
:key="copy"
class="animate-marquee-reverse flex shrink-0 items-center gap-8"
style="--marquee-gap: 2rem"
:aria-hidden="copy === 2 ? 'true' : undefined"
v-for="(logo, i) in mobileRow2"
:key="`${logo}-${i}`"
class="flex h-14 w-40 shrink-0 items-center justify-center"
>
<div
v-for="logo in mobileRow2Logos"
:key="logo"
class="flex h-14 w-40 shrink-0 items-center justify-center"
>
<img :src="`/icons/clients/${logo}.svg`" :alt="logo" />
</div>
<img :src="`/icons/clients/${logo}.svg`" :alt="logo" />
</div>
</div>
</div>

View File

@@ -75,7 +75,7 @@ const progressPercent = computed(() => `${progress.value * 100}%`)
<!-- Progress bar -->
<div class="h-1 flex-1 rounded-full bg-white/20">
<div
class="bg-primary-comfy-yellow h-full rounded-full"
class="bg-primary-comfy-yellow h-full rounded-full transition-all duration-200"
:style="{ width: progressPercent }"
/>
</div>

View File

@@ -5,7 +5,6 @@ import { useHeroAnimation } from '../../composables/useHeroAnimation'
import SectionLabel from '../common/SectionLabel.vue'
import type { Locale } from '../../i18n/translations'
import { t } from '../../i18n/translations'
import { ScrollTrigger } from '../../scripts/gsapSetup'
import VideoPlayer from '../common/VideoPlayer.vue'
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
@@ -23,10 +22,6 @@ useHeroAnimation({
logo: logoRef,
video: videoRef
})
function handleLogoLoad() {
ScrollTrigger.refresh(true)
}
</script>
<template>
@@ -42,10 +37,7 @@ function handleLogoLoad() {
<img
src="https://media.comfy.org/website/customers/c-projection.webp"
alt="Comfy 3D logo"
width="1568"
height="1763"
class="mx-auto h-auto w-full max-w-md lg:max-w-none"
@load="handleLogoLoad"
class="mx-auto w-full max-w-md lg:max-w-none"
/>
</div>

View File

@@ -8,12 +8,10 @@ import { t } from '../../i18n/translations'
const {
arcadeId,
title,
aspectRatio = 16 / 9,
locale = 'en'
} = defineProps<{
arcadeId: string
title: string
aspectRatio?: number
locale?: Locale
}>()
@@ -26,8 +24,7 @@ const loaded = ref(false)
:aria-label="t('demos.embed.label', locale)"
>
<div
class="relative mx-auto max-w-6xl overflow-hidden rounded-4xl border border-white/10"
:style="{ aspectRatio }"
class="relative mx-auto aspect-video max-w-6xl overflow-hidden rounded-4xl border border-white/10"
>
<div
v-if="!loaded"

View File

@@ -276,6 +276,29 @@ onUnmounted(() => {
fill="#211927"
/>
</g>
<!-- Left-edge fade -->
<rect
x="300"
y="150"
width="250"
height="900"
fill="url(#localHeroFadeLeft)"
/>
<defs>
<linearGradient
id="localHeroFadeLeft"
x1="550"
y1="600"
x2="300"
y2="600"
gradientUnits="userSpaceOnUse"
>
<stop stop-color="#211927" stop-opacity="0" />
<stop offset="1" stop-color="#211927" />
</linearGradient>
</defs>
</svg>
</div>

View File

@@ -15,14 +15,6 @@ 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[] = [
@@ -40,8 +32,7 @@ export const demos: readonly Demo[] = [
difficulty: 'beginner',
tags: ['templates', 'image', 'video'],
publishedDate: '2026-04-19',
modifiedDate: '2026-04-19',
aspectRatio: 1.931
modifiedDate: '2026-04-19'
},
{
slug: 'workflow-templates',
@@ -57,25 +48,7 @@ export const demos: readonly Demo[] = [
difficulty: 'beginner',
tags: ['getting-started', 'templates', 'workflow'],
publishedDate: '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
modifiedDate: '2026-04-19'
}
]

View File

@@ -3570,20 +3570,6 @@ const translations = {
'<ol><li><strong>打开模板浏览器</strong> — 点击 ComfyUI 侧栏中的模板图标。</li><li><strong>浏览分类</strong> — 模板按任务分类:图像生成、视频、放大等。</li><li><strong>预览模板</strong> — 将鼠标悬停在模板上查看预览。</li><li><strong>加载并自定义</strong> — 点击加载模板,然后修改参数。</li></ol>'
},
'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: '<ol><li><strong>Open the Workflow Hub</strong> — From the ComfyUI sidebar, navigate to the community Workflow Hub to browse curated and trending workflows shared by the community.</li><li><strong>Browse popular workflows</strong> — Explore featured projects sorted by popularity, recency, and category to find one that matches your goal.</li><li><strong>Preview a workflow</strong> — Click a workflow card to see example outputs, required models, and a description of what it produces.</li><li><strong>Open in ComfyUI</strong> — Use the "Get Started" action to load the selected community workflow directly onto your canvas.</li><li><strong>Run and customize</strong> — Queue the workflow to generate your first result, then tweak prompts, models, and parameters to make it your own.</li></ol>',
'zh-CN':
'<ol><li><strong>打开工作流中心</strong> — 在 ComfyUI 侧栏中,进入社区工作流中心,浏览社区分享的精选和热门工作流。</li><li><strong>浏览热门工作流</strong> — 按热度、时间和分类浏览精选项目,找到符合需求的工作流。</li><li><strong>预览工作流</strong> — 点击工作流卡片,查看示例输出、所需模型和功能描述。</li><li><strong>在 ComfyUI 中打开</strong> — 使用"开始使用"按钮,将选中的社区工作流直接加载到画布。</li><li><strong>运行并自定义</strong> — 排队执行工作流以生成首个结果,然后调整提示词、模型和参数。</li></ol>'
},
'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': '所有演示' },

View File

@@ -121,7 +121,6 @@ const breadcrumbJsonLd = {
<ArcadeEmbed
arcadeId={demo.arcadeId}
title={title}
aspectRatio={demo.aspectRatio}
client:load
/>

View File

@@ -122,7 +122,6 @@ const breadcrumbJsonLd = {
<ArcadeEmbed
arcadeId={demo.arcadeId}
title={title}
aspectRatio={demo.aspectRatio}
locale="zh-CN"
client:load
/>

View File

@@ -101,13 +101,13 @@
transform: translateX(0);
}
100% {
transform: translateX(calc(-100% - var(--marquee-gap, 0px)));
transform: translateX(-50%);
}
}
@keyframes marquee-reverse {
0% {
transform: translateX(calc(-100% - var(--marquee-gap, 0px)));
transform: translateX(-50%);
}
100% {
transform: translateX(0);
@@ -115,15 +115,11 @@
}
@utility animate-marquee {
@media (prefers-reduced-motion: no-preference) {
animation: marquee 30s linear infinite;
}
animation: marquee 30s linear infinite;
}
@utility animate-marquee-reverse {
@media (prefers-reduced-motion: no-preference) {
animation: marquee-reverse 30s linear infinite;
}
animation: marquee-reverse 30s linear infinite;
}
@keyframes ripple-effect {

View File

@@ -1,42 +0,0 @@
{
"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
}

View File

@@ -190,9 +190,6 @@ 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]
@@ -470,7 +467,6 @@ const COLLECT_COVERAGE = process.env.COLLECT_COVERAGE === 'true'
export const comfyPageFixture = base.extend<{
initialFeatureFlags: Record<string, unknown>
initialSettings: Record<string, unknown>
comfyPage: ComfyPage
comfyMouse: ComfyMouse
comfyFiles: ComfyFiles
@@ -478,10 +474,6 @@ export const comfyPageFixture = base.extend<{
// Allows configuring feature flags for tests with before initial setup:
// `test.use({ initialFeatureFlags: { my_flag: true } })`.
initialFeatureFlags: [{}, { option: true }],
// Allows seeding user settings before initial page load:
// `test.use({ initialSettings: { 'Comfy.Locale': 'zh' } })`. Merged on top of
// the fixture's defaults so per-test values win.
initialSettings: [{}, { option: true }],
page: async ({ page, browserName }, use) => {
if (browserName !== 'chromium' || !COLLECT_COVERAGE) {
@@ -499,11 +491,7 @@ export const comfyPageFixture = base.extend<{
await mcr.add(coverage)
},
comfyPage: async (
{ page, request, initialFeatureFlags, initialSettings },
use,
testInfo
) => {
comfyPage: async ({ page, request, initialFeatureFlags }, use, testInfo) => {
const comfyPage = new ComfyPage(page, request)
const { parallelIndex } = testInfo
@@ -512,7 +500,6 @@ export const comfyPageFixture = base.extend<{
comfyPage.userIds[parallelIndex] = userId
const isVueNodes = testInfo.tags.includes('@vue-nodes')
comfyPage.isVueNodes = isVueNodes
try {
await comfyPage.setupSettings({
@@ -538,8 +525,7 @@ export const comfyPageFixture = base.extend<{
// Disable errors tab to prevent missing model detection from
// rendering error indicators on nodes during unrelated tests.
'Comfy.RightSidePanel.ShowErrorsTab': false,
...(isVueNodes && { 'Comfy.VueNodes.Enabled': true }),
...initialSettings
...(isVueNodes && { 'Comfy.VueNodes.Enabled': true })
})
} catch (e) {
console.error(e)

View File

@@ -217,20 +217,13 @@ 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<void> {
const editButton = this.getSubgraphEnterButton(nodeId)
const locator = nodeId ? this.getNodeLocator(nodeId) : this.page
const editButton = locator.getByTestId(TestIds.widgets.subgraphEnterButton)
// The footer tab button extends below the node body (visible area),
// but its bounding box center overlaps the node body div.

View File

@@ -20,7 +20,6 @@ export class ContextMenu {
async clickMenuItemExact(name: string): Promise<void> {
await this.page.getByRole('menuitem', { name, exact: true }).click()
await this.waitForHidden()
}
/**

View File

@@ -82,7 +82,7 @@ export class Topbar {
}
getSaveDialog(): Locator {
return this.page.getByRole('dialog').getByRole('textbox')
return this.page.locator('.p-dialog-content input')
}
saveWorkflow(workflowName: string): Promise<void> {
@@ -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
.getByRole('dialog')
.filter({ hasText: 'Overwrite' })
const confirmationDialog = this.page.locator(
'.p-dialog:has-text("Overwrite")'
)
if (await confirmationDialog.isVisible()) {
return
}

View File

@@ -1,12 +0,0 @@
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<string> {
return await this.selection.innerText()
}
}

View File

@@ -9,15 +9,13 @@ 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. */
@@ -62,16 +60,13 @@ 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.mobile = new MobileAppHelper(comfyPage)
this.steps = new BuilderStepsHelper(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(
@@ -130,7 +125,6 @@ export class AppModeHelper {
this.vueNodeSwitchDontShowAgainCheckbox = this.page.getByTestId(
TestIds.appMode.vueNodeSwitchDontShowAgain
)
this.centerPanel = this.page.getByTestId(TestIds.linear.centerPanel)
}
private get page(): Page {

View File

@@ -127,7 +127,9 @@ export class BuilderSelectHelper {
await popoverTrigger.click()
await this.page.getByText('Rename', { exact: true }).click()
const dialogInput = this.page.getByRole('dialog').getByRole('textbox')
const dialogInput = this.page.locator(
'.p-dialog-content input[type="text"]'
)
await dialogInput.fill(newName)
await this.page.keyboard.press('Enter')
await dialogInput.waitFor({ state: 'hidden' })

View File

@@ -1,10 +1,6 @@
import type { WebSocketRoute } from '@playwright/test'
import type {
NodeError,
NodeProgressState,
PromptResponse
} from '@/schemas/apiSchema'
import type { NodeError, 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'
@@ -234,16 +230,6 @@ export class ExecutionHelper {
)
}
/** Send `progress_state` WS event with per-node execution state. */
progressState(jobId: string, nodes: Record<string, NodeProgressState>): 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.

View File

@@ -1,33 +0,0 @@
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()
}
}

View File

@@ -18,7 +18,9 @@ export class NodeOperationsHelper {
public readonly promptDialogInput: Locator
constructor(private comfyPage: ComfyPage) {
this.promptDialogInput = this.page.getByRole('dialog').getByRole('textbox')
this.promptDialogInput = this.page.locator(
'.p-dialog-content input[type="text"]'
)
}
private get page() {

View File

@@ -362,9 +362,6 @@ 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<number> {

View File

@@ -144,14 +144,6 @@ 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',

View File

@@ -13,8 +13,6 @@ 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-"]')
@@ -25,8 +23,6 @@ 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<string> {
@@ -43,16 +39,6 @@ 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<void> {
await this.header.click()
await this.header.press('Delete')
await this.locator.waitFor({ state: 'hidden' })
}
async getCollapseIconClass(): Promise<string> {
return (await this.collapseIcon.getAttribute('class')) ?? ''
}

View File

@@ -1,154 +0,0 @@
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()
}
})
})
})

View File

@@ -1,121 +0,0 @@
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)
})
})

View File

@@ -1,47 +0,0 @@
import type { CustomNodesI18n } from '@/schemas/apiSchema'
import {
comfyExpect as expect,
comfyPageFixture as test
} from '@e2e/fixtures/ComfyPage'
const NODE_TYPE = 'DevToolsNodeWithStringInput'
const LOCALIZED_ZH = '本地化字符串输入 (ZH)'
const i18nResponse: CustomNodesI18n = {
zh: {
nodeDefs: {
[NODE_TYPE]: { display_name: LOCALIZED_ZH }
}
}
}
test.describe(
'Custom node locales loading',
{ tag: ['@ui', '@vue-nodes'] },
() => {
test.use({ initialSettings: { 'Comfy.Locale': 'zh' } })
test.beforeEach(async ({ page }) => {
await page.route('**/api/i18n', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(i18nResponse)
})
})
})
// Regression test for PR #7214 (issue #7025): custom-node i18n data was
// clobbered when a non-English locale was lazily loaded, so nodes from
// custom packs lost their translated display_name on locale switch.
test('preserves custom-node /api/i18n translation through lazy locale load', async ({
comfyPage
}) => {
await comfyPage.nodeOps.addNode(NODE_TYPE)
await expect(comfyPage.vueNodes.getNodeByTitle(LOCALIZED_ZH)).toHaveCount(
1
)
})
}
)

View File

@@ -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
const saveDialog = comfyPage.page.getByRole('dialog')
await expect(saveDialog).toBeVisible()
// The Save As dialog should appear (p-dialog overlay)
const dialogOverlay = comfyPage.page.locator('.p-dialog-mask')
await expect(dialogOverlay).toBeVisible()
// Dismiss the dialog
await comfyPage.keyboard.press('Escape')

View File

@@ -16,9 +16,9 @@ async function saveAndOpenPublishDialog(
workflowName: string
): Promise<void> {
await comfyPage.menu.topbar.saveWorkflow(workflowName)
const overwriteDialog = comfyPage.page
.getByRole('dialog')
.filter({ hasText: 'Overwrite' })
const overwriteDialog = comfyPage.page.locator(
'.p-dialog:has-text("Overwrite")'
)
// Bounded wait: point-in-time isVisible() can miss dialogs that open
// slightly after saveWorkflow() resolves.
try {

View File

@@ -8,9 +8,6 @@ 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(() => {
@@ -144,73 +141,3 @@ 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`
)
})
}
})
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 43 KiB

View File

@@ -558,52 +558,5 @@ test.describe(
.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()
}
)
}
)

View File

@@ -21,8 +21,9 @@ test.describe('Vue Nodes Image Preview', { tag: '@vue-nodes' }, () => {
})
const nodeId = String(loadImageNode.id)
const { imagePreview } =
await comfyPage.vueNodes.getFixtureByTitle('Load Image')
const imagePreview = comfyPage.vueNodes
.getNodeLocator(nodeId)
.locator('.image-preview')
await expect(imagePreview).toBeVisible()
await expect(imagePreview.locator('img')).toBeVisible({ timeout: 30_000 })
@@ -43,25 +44,6 @@ 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)

View File

@@ -1,5 +1,3 @@
import type { Locator } from '@playwright/test'
import {
comfyExpect as expect,
comfyPageFixture as test
@@ -41,19 +39,6 @@ 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, {
@@ -105,63 +90,6 @@ 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
}) => {

View File

@@ -1,211 +0,0 @@
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<typeof zJobsListResponse>
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<void> {
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)
}
})
}
})
})

View File

@@ -249,7 +249,6 @@ 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 |

View File

@@ -1,328 +0,0 @@
# 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.

View File

@@ -1,210 +0,0 @@
# 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
```

View File

@@ -1,147 +0,0 @@
# 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.

View File

@@ -1,37 +0,0 @@
# 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. |

View File

@@ -231,11 +231,6 @@ 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
@@ -668,10 +663,6 @@ 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

View File

@@ -17,10 +17,6 @@ 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`
@@ -258,9 +254,6 @@ 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

View File

@@ -404,21 +404,26 @@ Whichever candidate is chosen:
instance-specific state beyond inputs — must remain reachable. This is a
constraint, not a current requirement.
### Decision
### Recommendation and decision criteria
[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.
**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.
The cost of A is a migration path for existing `proxyWidgets` serialization. On
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.
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.
---
@@ -466,14 +471,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 | Migration: 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 | Ratchet: normalize to flat World on load |
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.
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.
### Widget identity at the boundary
@@ -506,12 +511,13 @@ SubgraphIO {
}
```
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.
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.
### Backward-compatible loading contract
@@ -549,7 +555,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 | ADR 0009 chooses Candidate A: promoted value widgets are linked inputs |
| Widget promotion | Treated as a given feature to migrate | Open decision: Candidate A (connections-only) vs B (simplified component) |
| 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 |

View File

@@ -1,6 +1,6 @@
{
"name": "@comfyorg/comfyui-frontend",
"version": "1.45.5",
"version": "1.45.0",
"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": "cross-env NODE_OPTIONS='--max-old-space-size=8192' nx build --config vite.types.config.mts && node scripts/prepare-types.js",
"build:types": "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",

View File

@@ -523,6 +523,10 @@ export type ImportPublishedAssetsRequest = {
* IDs of published assets (inputs and models) to import.
*/
published_asset_ids: Array<string>
/**
* The share ID of the published workflow these assets belong to. Required for authorization.
*/
share_id: string
}
/**

View File

@@ -310,7 +310,8 @@ 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())
published_asset_ids: z.array(z.string().min(1).max(64)).max(1000),
share_id: z.string().min(1).max(64)
})
/**

View File

@@ -10057,8 +10057,6 @@ 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} */

View File

@@ -3,14 +3,12 @@ import { describe, expect, it } from 'vitest'
import {
appendWorkflowJsonExt,
ensureWorkflowSuffix,
getFilePathSeparatorVariants,
getFilenameDetails,
getMediaTypeFromFilename,
getPathDetails,
highlightQuery,
isCivitaiModelUrl,
isPreviewableMediaType,
joinFilePath,
truncateFilename
} from './formatUtil'
@@ -85,11 +83,9 @@ 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')
})
})
@@ -303,42 +299,6 @@ 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')

View File

@@ -256,31 +256,6 @@ 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.
*
@@ -299,7 +274,8 @@ export function parseFilePath(filepath: string): {
} {
if (!filepath?.trim()) return { filename: '', subfolder: '' }
const normalizedPath = normalizeFilePathSeparators(filepath)
const normalizedPath = filepath
.replace(/[\\/]+/g, '/') // Normalize path separators
.replace(/^\//, '') // Remove leading slash
.replace(/\/$/, '') // Remove trailing slash
@@ -581,7 +557,7 @@ const IMAGE_EXTENSIONS = [
'tiff',
'svg'
] as const
const VIDEO_EXTENSIONS = ['mp4', 'm4v', 'webm', 'mov', 'avi', 'mkv'] as const
const VIDEO_EXTENSIONS = ['mp4', 'webm', 'mov', 'avi'] 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 = [

View File

@@ -11,7 +11,6 @@
<DialogOverlay />
<DialogContent
:size="item.dialogComponentProps.size ?? 'md'"
:class="item.dialogComponentProps.contentClass"
:aria-labelledby="item.key"
@escape-key-down="
(e) =>

View File

@@ -1,11 +1,11 @@
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { createPinia, setActivePinia } from 'pinia'
import PrimeVue from 'primevue/config'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { ComponentProps } from 'vue-component-type-helpers'
import { createI18n } from 'vue-i18n'
import { useDialogStore } from '@/stores/dialogStore'
import ConfirmationDialogContent from './ConfirmationDialogContent.vue'
type Props = ComponentProps<typeof ConfirmationDialogContent>
@@ -13,23 +13,7 @@ type Props = ComponentProps<typeof ConfirmationDialogContent>
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
g: {
cancel: 'Cancel',
confirm: 'Confirm',
delete: 'Delete',
overwrite: 'Overwrite',
save: 'Save',
no: 'No',
ok: 'OK',
close: 'Close'
},
desktopMenu: {
reinstall: 'Reinstall'
}
}
},
messages: { en: {} },
missingWarn: false,
fallbackWarn: false
})
@@ -40,9 +24,10 @@ describe('ConfirmationDialogContent', () => {
})
function renderComponent(props: Partial<Props> = {}) {
const user = userEvent.setup()
render(ConfirmationDialogContent, {
global: { plugins: [i18n] },
return render(ConfirmationDialogContent, {
global: {
plugins: [PrimeVue, i18n]
},
props: {
message: 'Test message',
type: 'default',
@@ -50,7 +35,6 @@ describe('ConfirmationDialogContent', () => {
...props
} as Props
})
return { user }
}
it('renders long messages without breaking layout', () => {
@@ -60,103 +44,42 @@ describe('ConfirmationDialogContent', () => {
expect(screen.getByText(longFilename)).toBeInTheDocument()
})
it('renders the hint as a status alert when provided', () => {
renderComponent({ hint: 'This action cannot be undone.' })
const status = screen.getByRole('status')
expect(status).toHaveTextContent('This action cannot be undone.')
it('omits the Cancel button when type is dirtyClose', () => {
renderComponent({ type: 'dirtyClose' })
expect(screen.queryByText('g.cancel')).not.toBeInTheDocument()
expect(screen.getByText('g.save')).toBeInTheDocument()
})
it('does not render a status alert when hint is omitted', () => {
renderComponent()
expect(screen.queryByRole('status')).not.toBeInTheDocument()
it('uses the provided denyLabel for the deny button on dirtyClose', () => {
renderComponent({ type: 'dirtyClose', denyLabel: 'Sign out anyway' })
expect(screen.getByText('Sign out anyway')).toBeInTheDocument()
expect(screen.queryByText('g.no')).not.toBeInTheDocument()
})
describe('button surface per type', () => {
it("type='default' renders Cancel and Confirm", () => {
renderComponent({ type: 'default' })
expect(screen.getByRole('button', { name: 'Cancel' })).toBeInTheDocument()
expect(
screen.getByRole('button', { name: 'Confirm' })
).toBeInTheDocument()
})
it("type='delete' renders Cancel and Delete", () => {
renderComponent({ type: 'delete' })
expect(screen.getByRole('button', { name: 'Cancel' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Delete' })).toBeInTheDocument()
})
it("type='overwrite' renders Cancel and Overwrite", () => {
renderComponent({ type: 'overwrite' })
expect(screen.getByRole('button', { name: 'Cancel' })).toBeInTheDocument()
expect(
screen.getByRole('button', { name: 'Overwrite' })
).toBeInTheDocument()
})
it("type='dirtyClose' renders No and Save (no Cancel)", () => {
renderComponent({ type: 'dirtyClose' })
expect(
screen.queryByRole('button', { name: 'Cancel' })
).not.toBeInTheDocument()
expect(screen.getByRole('button', { name: 'No' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Save' })).toBeInTheDocument()
})
it("type='info' renders only OK (no Cancel)", () => {
renderComponent({ type: 'info' })
expect(screen.getByRole('button', { name: 'OK' })).toBeInTheDocument()
expect(
screen.queryByRole('button', { name: 'Cancel' })
).not.toBeInTheDocument()
})
})
it('confirm callback receives true and closes the dialog', async () => {
it('calls onConfirm(false) when deny is clicked on dirtyClose', async () => {
const onConfirm = vi.fn()
const { user } = renderComponent({ type: 'default', onConfirm })
const closeSpy = vi.spyOn(useDialogStore(), 'closeDialog')
renderComponent({
type: 'dirtyClose',
denyLabel: 'Close anyway',
onConfirm
})
await user.click(screen.getByRole('button', { name: 'Confirm' }))
await userEvent.click(screen.getByRole('button', { name: 'Close anyway' }))
expect(onConfirm).toHaveBeenCalledWith(false)
})
it('calls onConfirm(true) when save is clicked on dirtyClose', async () => {
const onConfirm = vi.fn()
renderComponent({ type: 'dirtyClose', onConfirm })
await userEvent.click(screen.getByRole('button', { name: 'g.save' }))
expect(onConfirm).toHaveBeenCalledWith(true)
expect(closeSpy).toHaveBeenCalledOnce()
})
describe('dirtyClose deny label', () => {
it('uses the provided denyLabel for the deny button', () => {
renderComponent({ type: 'dirtyClose', denyLabel: 'Sign out anyway' })
expect(screen.getByText('Sign out anyway')).toBeInTheDocument()
expect(
screen.queryByRole('button', { name: 'No' })
).not.toBeInTheDocument()
})
it('falls back to "No" when denyLabel is not provided', () => {
renderComponent({ type: 'dirtyClose' })
expect(screen.getByRole('button', { name: 'No' })).toBeInTheDocument()
})
it('calls onConfirm(false) when deny is clicked', async () => {
const onConfirm = vi.fn()
const { user } = renderComponent({
type: 'dirtyClose',
denyLabel: 'Close anyway',
onConfirm
})
await user.click(screen.getByRole('button', { name: 'Close anyway' }))
expect(onConfirm).toHaveBeenCalledWith(false)
})
it('calls onConfirm(true) when save is clicked', async () => {
const onConfirm = vi.fn()
const { user } = renderComponent({ type: 'dirtyClose', onConfirm })
await user.click(screen.getByRole('button', { name: 'Save' }))
expect(onConfirm).toHaveBeenCalledWith(true)
})
it('falls back to "no" label when denyLabel is not provided', () => {
renderComponent({ type: 'dirtyClose' })
expect(screen.getByText('g.no')).toBeInTheDocument()
})
})

View File

@@ -9,14 +9,16 @@
{{ item }}
</li>
</ul>
<div
<Message
v-if="hint"
role="status"
class="mt-2 flex items-start gap-2 text-sm text-muted-foreground"
class="mt-2"
icon="pi pi-info-circle"
severity="secondary"
size="small"
variant="simple"
>
<i class="pi pi-info-circle mt-0.5" aria-hidden="true" />
<span>{{ hint }}</span>
</div>
{{ hint }}
</Message>
</div>
<div class="flex shrink-0 flex-wrap justify-end gap-4">
<div
@@ -113,6 +115,7 @@
</template>
<script setup lang="ts">
import Message from 'primevue/message'
import { ref } from 'vue'
import { useI18n } from 'vue-i18n'

View File

@@ -146,7 +146,6 @@ import type { VueNodeData } from '@/composables/graph/useGraphNodeManager'
import { useVueNodeLifecycle } from '@/composables/graph/useVueNodeLifecycle'
import { useNodeBadge } from '@/composables/node/useNodeBadge'
import { useCanvasDrop } from '@/composables/useCanvasDrop'
import { useCanvasSearchBoxMenu } from '@/composables/useCanvasSearchBoxMenu'
import { useContextMenuTranslation } from '@/composables/useContextMenuTranslation'
import { useCopy } from '@/composables/useCopy'
import { useGlobalLitegraph } from '@/composables/useGlobalLitegraph'
@@ -460,7 +459,6 @@ useLitegraphSettings()
useNodeBadge()
useGlobalLitegraph()
useCanvasSearchBoxMenu()
useContextMenuTranslation()
useCopy()
usePaste()

View File

@@ -4,7 +4,7 @@ import { computed, reactive, ref, shallowRef, watch } from 'vue'
import CollapseToggleButton from '@/components/rightSidePanel/layout/CollapseToggleButton.vue'
import type { LGraphNode, NodeId } from '@/lib/litegraph/src/litegraph'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import FormSearchInput from '@/renderer/extensions/vueNodes/widgets/components/form/FormSearchInput.vue'
@@ -44,24 +44,24 @@ watch(
}
)
function isSectionCollapsed(nodeId: NodeId): boolean {
function isSectionCollapsed(nodeId: string): boolean {
// Defaults to collapsed when not explicitly set by the user
return collapseMap[nodeId] ?? true
}
function setSectionCollapsed(nodeId: NodeId, collapsed: boolean) {
function setSectionCollapsed(nodeId: string, collapsed: boolean) {
collapseMap[nodeId] = collapsed
}
const isAllCollapsed = computed({
get() {
return searchedWidgetsSectionDataList.value.every(({ node }) =>
isSectionCollapsed(node.id)
isSectionCollapsed(String(node.id))
)
},
set(collapse: boolean) {
for (const { node } of widgetsSectionDataList.value) {
setSectionCollapsed(node.id, collapse)
setSectionCollapsed(String(node.id), collapse)
}
}
})
@@ -101,7 +101,7 @@ async function searcher(query: string) {
:key="node.id"
:node
:widgets
:collapse="isSectionCollapsed(node.id) && !isSearching"
:collapse="isSectionCollapsed(String(node.id)) && !isSearching"
:tooltip="
isSearching || widgets.length
? ''
@@ -109,7 +109,7 @@ async function searcher(query: string) {
"
show-locate-button
class="border-b border-interface-stroke"
@update:collapse="setSectionCollapsed(node.id, $event)"
@update:collapse="setSectionCollapsed(String(node.id), $event)"
/>
</TransitionGroup>
</template>

View File

@@ -3,7 +3,7 @@ import { storeToRefs } from 'pinia'
import { computed, reactive, ref, shallowRef, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import type { LGraphNode, NodeId } from '@/lib/litegraph/src/litegraph'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import CollapseToggleButton from '@/components/rightSidePanel/layout/CollapseToggleButton.vue'
import FormSearchInput from '@/renderer/extensions/vueNodes/widgets/components/form/FormSearchInput.vue'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
@@ -68,19 +68,19 @@ watch(
}
)
function isSectionCollapsed(nodeId: NodeId): boolean {
function isSectionCollapsed(nodeId: string): boolean {
// When not explicitly set, sections are collapsed if multiple nodes are selected
return collapseMap[nodeId] ?? isMultipleNodesSelected.value
}
function setSectionCollapsed(nodeId: NodeId, collapsed: boolean) {
function setSectionCollapsed(nodeId: string, collapsed: boolean) {
collapseMap[nodeId] = collapsed
}
const isAllCollapsed = computed({
get() {
const normalAllCollapsed = searchedWidgetsSectionDataList.value.every(
({ node }) => isSectionCollapsed(node.id)
({ node }) => isSectionCollapsed(String(node.id))
)
const hasAdvanced = advancedWidgetsSectionDataList.value.length > 0
return hasAdvanced
@@ -89,7 +89,7 @@ const isAllCollapsed = computed({
},
set(collapse: boolean) {
for (const { node } of widgetsSectionDataList.value) {
setSectionCollapsed(node.id, collapse)
setSectionCollapsed(String(node.id), collapse)
}
advancedCollapsed.value = collapse
}
@@ -154,7 +154,7 @@ const advancedLabel = computed(() => {
:node
:label
:widgets
:collapse="isSectionCollapsed(node.id) && !isSearching"
:collapse="isSectionCollapsed(String(node.id)) && !isSearching"
:show-locate-button="isMultipleNodesSelected"
:tooltip="
isSearching || widgets.length
@@ -162,7 +162,7 @@ const advancedLabel = computed(() => {
: t('rightSidePanel.inputsNoneTooltip')
"
class="border-b border-interface-stroke"
@update:collapse="setSectionCollapsed(node.id, $event)"
@update:collapse="setSectionCollapsed(String(node.id), $event)"
/>
</TransitionGroup>
<template v-if="advancedWidgetsSectionDataList.length > 0 && !isSearching">

View File

@@ -252,20 +252,6 @@ 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()

View File

@@ -41,16 +41,10 @@
<span v-else class="text-base font-semibold text-base-foreground">{{
formattedBalance
}}</span>
<Button
<i
v-tooltip="{ value: $t('credits.unified.tooltip'), showDelay: 300 }"
variant="muted-textonly"
size="icon-sm"
class="mr-auto"
:aria-label="$t('credits.unified.tooltip')"
data-testid="credits-info-button"
>
<i class="icon-[lucide--circle-help]" />
</Button>
class="mr-auto icon-[lucide--circle-help] cursor-help text-base text-muted-foreground"
/>
<Button
v-if="isCloud && isFreeTier"
variant="gradient"

View File

@@ -543,7 +543,7 @@ describe('realtime scan verifies pending cloud candidates', () => {
}
])
const verifySpy = vi
.spyOn(missingMediaScan, 'verifyMediaCandidates')
.spyOn(missingMediaScan, 'verifyCloudMediaCandidates')
.mockImplementation(async (candidates) => {
for (const c of candidates) c.isMissing = true
})
@@ -686,7 +686,7 @@ describe('realtime verification staleness guards', () => {
let resolveVerify: (() => void) | undefined
const verifyPromise = new Promise<void>((r) => (resolveVerify = r))
const verifySpy = vi
.spyOn(missingMediaScan, 'verifyMediaCandidates')
.spyOn(missingMediaScan, 'verifyCloudMediaCandidates')
.mockImplementation(async (candidates) => {
await verifyPromise
for (const c of candidates) c.isMissing = true

View File

@@ -28,7 +28,7 @@ import {
import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
import {
scanNodeMediaCandidates,
verifyMediaCandidates
verifyCloudMediaCandidates
} from '@/platform/missingMedia/missingMediaScan'
import { useMissingMediaStore } from '@/platform/missingMedia/missingMediaStore'
import { useMissingNodesErrorStore } from '@/platform/nodeReplacement/missingNodesErrorStore'
@@ -209,8 +209,8 @@ function scanSingleNodeErrors(node: LGraphNode): void {
if (confirmedMedia.length) {
useMissingMediaStore().addMissingMedia(confirmedMedia)
}
// Cloud media scans return pending for asset verification. OSS scans only
// return pending for generated output/temp media.
// Cloud media scans always return isMissing: undefined pending
// verification against the input-assets list.
const pendingMedia = mediaCandidates.filter((c) => c.isMissing === undefined)
if (pendingMedia.length) {
void verifyAndAddPendingMedia(pendingMedia)
@@ -282,7 +282,7 @@ async function verifyAndAddPendingMedia(
): Promise<void> {
const rootGraphAtScan = app.rootGraph
try {
await verifyMediaCandidates(pending, { isCloud })
await verifyCloudMediaCandidates(pending)
if (app.rootGraph !== rootGraphAtScan) return
const verified = pending.filter(
(c) => c.isMissing === true && isCandidateStillActive(c.nodeId)

View File

@@ -73,14 +73,12 @@ export const useNodeDragAndDrop = <T>(
return true
}
const baseUri = e?.dataTransfer?.getData('text/uri-list') ?? ''
const uri = URL.parse(baseUri, location.href)
const uri = URL.parse(e?.dataTransfer?.getData('text/uri-list') ?? '')
if (!uri || uri.origin !== location.origin) return false
try {
const resp = await fetch(uri)
const fileName =
uri?.searchParams?.get('filename') ?? baseUri.split('/').at(-1)
const fileName = uri?.searchParams?.get('filename')
if (!fileName || !resp.ok) return false
const blob = await resp.blob()

View File

@@ -1,134 +0,0 @@
import { createPinia, setActivePinia } from 'pinia'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { useCanvasSearchBoxMenu } from '@/composables/useCanvasSearchBoxMenu'
import type {
ContextMenu,
IContextMenuValue
} from '@/lib/litegraph/src/litegraph'
import { LGraphCanvas } from '@/lib/litegraph/src/litegraph'
import type { CanvasPointerEvent } from '@/lib/litegraph/src/types/events'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useSearchBoxStore } from '@/stores/workspace/searchBoxStore'
import { createMockCanvas } from '@/utils/__tests__/litegraphTestUtils'
describe('useCanvasSearchBoxMenu', () => {
let originalGetCanvasMenuOptions: typeof LGraphCanvas.prototype.getCanvasMenuOptions
let mockCanvas: LGraphCanvas
beforeEach(() => {
setActivePinia(createPinia())
originalGetCanvasMenuOptions = LGraphCanvas.prototype.getCanvasMenuOptions
LGraphCanvas.prototype.getCanvasMenuOptions =
function (): (IContextMenuValue | null)[] {
const items: (IContextMenuValue<string> | null)[] = [
{
content: 'Add Node',
has_submenu: true,
callback: LGraphCanvas.onMenuAdd
},
{ content: 'Add Group', callback: vi.fn() }
]
return items as (IContextMenuValue | null)[]
}
mockCanvas = createMockCanvas({
constructor: { prototype: LGraphCanvas.prototype } as typeof LGraphCanvas
} as Partial<LGraphCanvas>)
})
afterEach(() => {
LGraphCanvas.prototype.getCanvasMenuOptions = originalGetCanvasMenuOptions
vi.restoreAllMocks()
})
function invokeAddNodeCallback(
addNode: IContextMenuValue,
previousMenu?: Partial<ContextMenu<unknown>>
) {
void addNode.callback?.call(
addNode as never,
undefined,
undefined,
undefined as never,
previousMenu as ContextMenu<unknown> | undefined
)
}
it('leaves the default Add Node entry untouched when the setting is off', () => {
vi.spyOn(useSettingStore(), 'get').mockImplementation((id) =>
id === 'Comfy.NodeSearchBox.ReplaceCanvasMenu' ? false : undefined
)
useCanvasSearchBoxMenu()
const items = LGraphCanvas.prototype.getCanvasMenuOptions.call(mockCanvas)
const addNode = items.find((i) => i?.content === 'Add Node')
expect(addNode?.callback).toBe(LGraphCanvas.onMenuAdd)
expect(addNode?.has_submenu).toBe(true)
})
it('forwards the original right-click event to the search box so the node lands at the click position', () => {
vi.spyOn(useSettingStore(), 'get').mockImplementation((id) =>
id === 'Comfy.NodeSearchBox.ReplaceCanvasMenu' ? true : undefined
)
const openAtEvent = vi.spyOn(useSearchBoxStore(), 'openAtEvent')
const toggleVisible = vi.spyOn(useSearchBoxStore(), 'toggleVisible')
const triggerEvent = {
canvasX: 123,
canvasY: 456
} as unknown as CanvasPointerEvent
const previousMenu = {
getFirstEvent: () => triggerEvent
} as unknown as Partial<ContextMenu<unknown>>
useCanvasSearchBoxMenu()
const items = LGraphCanvas.prototype.getCanvasMenuOptions.call(mockCanvas)
const addNode = items.find((i) => i?.content === 'Add Node')
expect(addNode).toBeTruthy()
expect(addNode?.has_submenu).toBe(false)
invokeAddNodeCallback(addNode!, previousMenu)
expect(openAtEvent).toHaveBeenCalledTimes(1)
expect(openAtEvent).toHaveBeenCalledWith(triggerEvent)
expect(toggleVisible).not.toHaveBeenCalled()
})
it('falls back to toggleVisible when no originating event is available', () => {
vi.spyOn(useSettingStore(), 'get').mockReturnValue(true)
const openAtEvent = vi.spyOn(useSearchBoxStore(), 'openAtEvent')
const toggleVisible = vi.spyOn(useSearchBoxStore(), 'toggleVisible')
useCanvasSearchBoxMenu()
const items = LGraphCanvas.prototype.getCanvasMenuOptions.call(mockCanvas)
const addNode = items.find((i) => i?.content === 'Add Node')
invokeAddNodeCallback(addNode!, undefined)
expect(openAtEvent).not.toHaveBeenCalled()
expect(toggleVisible).toHaveBeenCalledTimes(1)
})
it('preserves other canvas menu entries', () => {
vi.spyOn(useSettingStore(), 'get').mockReturnValue(true)
useCanvasSearchBoxMenu()
const items = LGraphCanvas.prototype.getCanvasMenuOptions.call(mockCanvas)
const contents = items.map((i) => i?.content)
expect(contents).toEqual(['Add Node', 'Add Group'])
})
it('is idempotent across repeated invocations (HMR, remount)', () => {
useCanvasSearchBoxMenu()
const firstPatch = LGraphCanvas.prototype.getCanvasMenuOptions
useCanvasSearchBoxMenu()
useCanvasSearchBoxMenu()
expect(LGraphCanvas.prototype.getCanvasMenuOptions).toBe(firstPatch)
})
})

View File

@@ -1,105 +0,0 @@
import { legacyMenuCompat } from '@/lib/litegraph/src/contextMenuCompat'
import type {
ContextMenu,
IContextMenuValue
} from '@/lib/litegraph/src/litegraph'
import { LGraphCanvas } from '@/lib/litegraph/src/litegraph'
import type { CanvasPointerEvent } from '@/lib/litegraph/src/types/events'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useSearchBoxStore } from '@/stores/workspace/searchBoxStore'
const REPLACE_SETTING_ID = 'Comfy.NodeSearchBox.ReplaceCanvasMenu'
const WRAPPER_MARK = Symbol('useCanvasSearchBoxMenu.wrapper')
/**
* When the experimental "replace canvas menu" setting is enabled, the
* right-click canvas menu's "Add Node" entry opens the Vue node search box
* (which already includes blueprints, partner nodes, core nodes, and
* extensions) instead of the legacy LiteGraph category submenu.
*
* The replacement is identified by callback identity against
* {@link LGraphCanvas.onMenuAdd} so it remains stable across the translation
* wrapper installed by {@link useContextMenuTranslation}. The original
* right-click event is forwarded via {@link ContextMenu.getFirstEvent} so the
* resulting node lands at the click position instead of canvas center.
*
* Installation is idempotent: repeated calls (e.g. HMR remounts) do not stack
* wrappers because the wrapper is tagged and detected on re-entry.
*/
export const useCanvasSearchBoxMenu = () => {
legacyMenuCompat.install(LGraphCanvas.prototype, 'getCanvasMenuOptions')
const previousGetCanvasMenuOptions =
LGraphCanvas.prototype.getCanvasMenuOptions
if (isOurWrapper(previousGetCanvasMenuOptions)) return
const wrapped: typeof previousGetCanvasMenuOptions = function (
this: LGraphCanvas,
...args
) {
const items = previousGetCanvasMenuOptions.apply(this, args)
const settingStore = useSettingStore()
if (!settingStore.get(REPLACE_SETTING_ID)) return items
return items.map((item) =>
isLegacyAddNode(item) ? buildSearchBoxAddNode(item) : item
)
}
markAsOurWrapper(wrapped)
LGraphCanvas.prototype.getCanvasMenuOptions = wrapped
legacyMenuCompat.registerWrapper(
'getCanvasMenuOptions',
wrapped,
previousGetCanvasMenuOptions,
LGraphCanvas.prototype
)
}
function isOurWrapper(fn: unknown): boolean {
return !!fn && (fn as { [WRAPPER_MARK]?: true })[WRAPPER_MARK] === true
}
function markAsOurWrapper(fn: object) {
Object.defineProperty(fn, WRAPPER_MARK, {
value: true,
enumerable: false,
configurable: false,
writable: false
})
}
function isLegacyAddNode(
item: IContextMenuValue | null
): item is IContextMenuValue {
return (
!!item &&
typeof item === 'object' &&
item.callback === LGraphCanvas.onMenuAdd
)
}
function buildSearchBoxAddNode(original: IContextMenuValue): IContextMenuValue {
return {
...original,
has_submenu: false,
submenu: undefined,
callback: (
_value?: unknown,
_options?: unknown,
_event?: MouseEvent,
previousMenu?: ContextMenu<unknown>
) => {
const triggerEvent = previousMenu?.getFirstEvent() as
| CanvasPointerEvent
| undefined
const store = useSearchBoxStore()
if (triggerEvent) {
store.openAtEvent(triggerEvent)
} else {
store.toggleVisible()
}
}
}
}

View File

@@ -1,88 +0,0 @@
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useReconnectQueueRefresh } from '@/composables/useReconnectQueueRefresh'
import type { JobListItem } from '@/platform/remote/comfyui/jobs/jobTypes'
import { api } from '@/scripts/api'
import { useExecutionStore } from '@/stores/executionStore'
function makeJob(id: string, status: JobListItem['status']): JobListItem {
return {
id,
status,
create_time: 0,
update_time: 0,
last_state_update: 0,
priority: 0
}
}
vi.mock('@/scripts/api', () => ({
api: {
getQueue: vi.fn(),
getHistory: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
apiURL: vi.fn((p: string) => `/api${p}`)
}
}))
describe('useReconnectQueueRefresh', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
vi.restoreAllMocks()
vi.mocked(api.getQueue).mockResolvedValue({ Running: [], Pending: [] })
vi.mocked(api.getHistory).mockResolvedValue([])
})
it('forwards running+pending job ids to clearActiveJobIfStale', async () => {
vi.mocked(api.getQueue).mockResolvedValue({
Running: [makeJob('run-1', 'in_progress')],
Pending: [makeJob('pend-1', 'pending'), makeJob('pend-2', 'pending')]
})
const executionStore = useExecutionStore()
const clearSpy = vi
.spyOn(executionStore, 'clearActiveJobIfStale')
.mockImplementation(() => {})
const refresh = useReconnectQueueRefresh()
await refresh()
expect(clearSpy).toHaveBeenCalledTimes(1)
expect(clearSpy).toHaveBeenCalledWith(
new Set(['run-1', 'pend-1', 'pend-2'])
)
})
it('passes an empty set when the queue is genuinely empty', async () => {
const executionStore = useExecutionStore()
const clearSpy = vi
.spyOn(executionStore, 'clearActiveJobIfStale')
.mockImplementation(() => {})
const refresh = useReconnectQueueRefresh()
await refresh()
expect(clearSpy).toHaveBeenCalledWith(new Set())
})
it('reuses the prior queue snapshot when the fetch fails, so a still-running job is not falsely cleared', async () => {
vi.mocked(api.getQueue)
.mockResolvedValueOnce({
Running: [makeJob('run-1', 'in_progress')],
Pending: []
})
.mockRejectedValueOnce(new Error('network down'))
const executionStore = useExecutionStore()
const clearSpy = vi
.spyOn(executionStore, 'clearActiveJobIfStale')
.mockImplementation(() => {})
const refresh = useReconnectQueueRefresh()
await refresh() // primes the store with run-1
await refresh() // network failure here — store must not go empty
expect(clearSpy).toHaveBeenLastCalledWith(new Set(['run-1']))
})
})

View File

@@ -1,25 +0,0 @@
import { useExecutionStore } from '@/stores/executionStore'
import { useQueueStore } from '@/stores/queueStore'
/**
* After a WebSocket reconnect, refresh the queue from the server and clear
* any active job that finished during the disconnect window. Returns the
* handler so the caller can wire it to the `reconnected` api event.
*
* `update()` preserves the previous queue snapshot when the fetch fails, so
* if the network is still flaky we reconcile against the last known good
* state rather than an empty (and falsely "stale") set.
*/
export function useReconnectQueueRefresh() {
const queueStore = useQueueStore()
const executionStore = useExecutionStore()
return async function refreshOnReconnect() {
await queueStore.update()
const activeJobIds = new Set([
...queueStore.runningTasks.map((t) => t.jobId),
...queueStore.pendingTasks.map((t) => t.jobId)
])
executionStore.clearActiveJobIfStale(activeJobIds)
}
}

View File

@@ -9,10 +9,7 @@ import type {
CameraState
} from '@/extensions/core/load3d/interfaces'
import Load3DConfiguration from '@/extensions/core/load3d/Load3DConfiguration'
import {
LOAD3D_NONE_MODEL,
SUPPORTED_EXTENSIONS_ACCEPT
} from '@/extensions/core/load3d/constants'
import { SUPPORTED_EXTENSIONS_ACCEPT } from '@/extensions/core/load3d/constants'
import Load3dUtils from '@/extensions/core/load3d/Load3dUtils'
import { t } from '@/i18n'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
@@ -293,9 +290,13 @@ useExtensionService().registerExtension({
)
node.addWidget('button', 'clear', 'clear', () => {
useLoad3d(node).waitForLoad3d((load3d) => {
load3d.clearModel()
})
const modelWidget = node.widgets?.find((w) => w.name === 'model_file')
if (modelWidget) {
modelWidget.value = LOAD3D_NONE_MODEL
modelWidget.value = ''
}
})

View File

@@ -521,8 +521,7 @@ describe('Load3DConfiguration.configure forwards persisted + settings to load3d'
setLightIntensity: vi.fn(),
setHDRIIntensity: vi.fn(),
setHDRIAsBackground: vi.fn(),
setHDRIEnabled: vi.fn(),
emitModelReady: vi.fn()
setHDRIEnabled: vi.fn()
} as unknown as Load3d
}
@@ -594,91 +593,3 @@ describe('Load3DConfiguration.configure forwards persisted + settings to load3d'
expect(load3d.setLightIntensity).toHaveBeenCalledWith(9)
})
})
describe('Load3DConfiguration "none" model handling', () => {
let load3d: Load3d
let loadModelSpy: ReturnType<typeof vi.fn>
let clearModelSpy: ReturnType<typeof vi.fn>
function makeLoad3dMock(): Load3d {
loadModelSpy = vi.fn().mockResolvedValue(undefined)
clearModelSpy = vi.fn()
return {
loadModel: loadModelSpy,
clearModel: clearModelSpy,
setUpDirection: vi.fn(),
setMaterialMode: vi.fn(),
setTargetSize: vi.fn(),
setCameraState: vi.fn(),
toggleGrid: vi.fn(),
setBackgroundColor: vi.fn(),
setBackgroundImage: vi.fn().mockResolvedValue(undefined),
setBackgroundRenderMode: vi.fn(),
toggleCamera: vi.fn(),
setFOV: vi.fn(),
setLightIntensity: vi.fn(),
setHDRIIntensity: vi.fn(),
setHDRIAsBackground: vi.fn(),
setHDRIEnabled: vi.fn(),
emitModelReady: vi.fn()
} as unknown as Load3d
}
async function flush() {
await new Promise<void>((resolve) => setTimeout(resolve, 0))
}
beforeEach(() => {
load3d = makeLoad3dMock()
vi.mocked(Load3dUtils.splitFilePath).mockReturnValue(['', 'model.glb'])
vi.mocked(Load3dUtils.getResourceURL).mockReturnValue('/view')
})
afterEach(() => {
vi.restoreAllMocks()
})
it('does not load or clear a model when the initial widget value is "none"', async () => {
const config = new Load3DConfiguration(load3d)
config.configure({
modelWidget: { value: 'none' } as unknown as IBaseWidget,
loadFolder: 'input'
})
await flush()
expect(loadModelSpy).not.toHaveBeenCalled()
expect(clearModelSpy).not.toHaveBeenCalled()
})
it('clears the model (and skips loadModel) when the widget value changes to "none"', async () => {
const config = new Load3DConfiguration(load3d)
const widget = { value: 'model.glb' } as unknown as IBaseWidget
config.configure({ modelWidget: widget, loadFolder: 'input' })
await flush()
loadModelSpy.mockClear()
clearModelSpy.mockClear()
widget.value = 'none'
await flush()
expect(clearModelSpy).toHaveBeenCalledTimes(1)
expect(loadModelSpy).not.toHaveBeenCalled()
})
it('loads a model when the widget value transitions from "none" to a real path', async () => {
const config = new Load3DConfiguration(load3d)
const widget = { value: 'none' } as unknown as IBaseWidget
config.configure({ modelWidget: widget, loadFolder: 'input' })
await flush()
expect(loadModelSpy).not.toHaveBeenCalled()
widget.value = 'model.glb'
await flush()
expect(loadModelSpy).toHaveBeenCalledWith(expect.any(String), 'model.glb', {
silentOnNotFound: false
})
})
})

View File

@@ -1,4 +1,3 @@
import { LOAD3D_NONE_MODEL } from '@/extensions/core/load3d/constants'
import Load3d from '@/extensions/core/load3d/Load3d'
import Load3dUtils from '@/extensions/core/load3d/Load3dUtils'
import type {
@@ -110,7 +109,7 @@ class Load3DConfiguration {
cameraState,
silentOnNotFound
)
if (modelWidget.value && modelWidget.value !== LOAD3D_NONE_MODEL) {
if (modelWidget.value) {
void onModelWidgetUpdate(modelWidget.value)
}
@@ -281,10 +280,7 @@ class Load3DConfiguration {
) {
let isFirstLoad = true
return async (value: string | number | boolean | object) => {
if (!value || value === LOAD3D_NONE_MODEL) {
this.load3d.clearModel()
return
}
if (!value) return
const { filename, folder } = parseAnnotatedFilename(
value as string,

View File

@@ -22,5 +22,3 @@ export const SUPPORTED_HDRI_EXTENSIONS = new Set(['.hdr', '.exr'])
export const SUPPORTED_HDRI_EXTENSIONS_ACCEPT = [
...SUPPORTED_HDRI_EXTENSIONS
].join(',')
export const LOAD3D_NONE_MODEL = 'none'

View File

@@ -1,173 +0,0 @@
import { fromPartial } from '@total-typescript/shoehorn'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { IContextMenuValue } from '@/lib/litegraph/src/litegraph'
import {
LGraph,
LGraphCanvas,
LGraphNode,
LiteGraph
} from '@/lib/litegraph/src/litegraph'
class TestNode extends LGraphNode {
static override type = 'TestNode'
constructor(title?: string) {
super(title ?? 'TestNode')
}
}
function makeNodeClass(title: string) {
class N extends TestNode {
static override title = title
constructor() {
super(title)
}
}
return N
}
function createCanvas(graph: LGraph): LGraphCanvas {
const el = document.createElement('canvas')
el.width = 800
el.height = 600
const ctx = fromPartial<CanvasRenderingContext2D>({
measureText: vi.fn().mockReturnValue({ width: 50 }),
getTransform: vi
.fn()
.mockReturnValue({ a: 1, b: 0, c: 0, d: 1, e: 0, f: 0 })
})
el.getContext = vi.fn().mockReturnValue(ctx)
el.getBoundingClientRect = vi.fn().mockReturnValue({
left: 0,
top: 0,
width: 800,
height: 600
})
return new LGraphCanvas(el, graph, { skip_render: true })
}
type MenuEntry = IContextMenuValue<string>
describe('LGraphCanvas.onMenuAdd category sorting', () => {
let graph: LGraph
let canvas: LGraphCanvas
const registeredTypes: string[] = []
let originalContextMenu: typeof LiteGraph.ContextMenu
const capturedEntries: MenuEntry[][] = []
beforeEach(() => {
graph = new LGraph()
canvas = createCanvas(graph)
LGraphCanvas.active_canvas = canvas
capturedEntries.length = 0
originalContextMenu = LiteGraph.ContextMenu
const MockContextMenu = vi.fn(function (
this: unknown,
values: MenuEntry[]
) {
capturedEntries.push(values)
}) as unknown as typeof LiteGraph.ContextMenu
LiteGraph.ContextMenu = MockContextMenu
})
afterEach(() => {
LiteGraph.ContextMenu = originalContextMenu
for (const type of registeredTypes) {
delete LiteGraph.registered_node_types[type]
}
registeredTypes.length = 0
})
function register(type: string, title: string) {
LiteGraph.registerNodeType(type, makeNodeClass(title))
registeredTypes.push(type)
}
function openTopLevelMenu() {
const event = new MouseEvent('contextmenu', { clientX: 10, clientY: 10 })
LGraphCanvas.onMenuAdd(undefined, undefined, event)
return event
}
function drillInto(label: string, sourceEvent: MouseEvent) {
const top = capturedEntries[capturedEntries.length - 1]
const entry = top.find((e) => e.content === label)
expect(entry, `submenu entry "${label}" should exist`).toBeDefined()
expect(entry!.callback).toBeDefined()
expect(typeof entry!.value).toBe('string')
const callback = entry!.callback!
const menuThis = document.createElement('div') as ThisParameterType<
typeof callback
>
void callback.call(menuThis, entry, undefined, sourceEvent, undefined)
}
it('sorts top-level category submenus alphabetically (case-insensitive)', () => {
register('zebra/zNode', 'Zebra Node')
register('Apple/aNode', 'Apple Node')
register('middle/mNode', 'Middle Node')
openTopLevelMenu()
const submenuLabels = capturedEntries[0]
.filter((e) => e.has_submenu)
.map((e) => e.content)
const ours = submenuLabels.filter((label) =>
['Apple', 'middle', 'zebra'].includes(label ?? '')
)
expect(ours).toEqual(['Apple', 'middle', 'zebra'])
})
it('uses natural numeric ordering for numbered category names', () => {
register('Cat10/n10', 'Item10')
register('Cat2/n2', 'Item2')
register('Cat1/n1', 'Item1')
openTopLevelMenu()
const ours = capturedEntries[0]
.filter(
(e) =>
e.has_submenu && ['Cat1', 'Cat2', 'Cat10'].includes(e.content ?? '')
)
.map((e) => e.content)
expect(ours).toEqual(['Cat1', 'Cat2', 'Cat10'])
})
it('sorts leaf nodes inside a category alphabetically', () => {
register('leafsort/Zeta', 'Zeta')
register('leafsort/Alpha', 'Alpha')
register('leafsort/Mike', 'Mike')
const event = openTopLevelMenu()
drillInto('leafsort', event)
const leafLabels = capturedEntries[1]
.filter((e) => !e.has_submenu)
.map((e) => e.content)
expect(leafLabels).toEqual(['Alpha', 'Mike', 'Zeta'])
})
it('places category submenus before leaf entries within a category level', () => {
register('mixed/leafA', 'A Leaf')
register('mixed/leafZ', 'Z Leaf')
register('mixed/inner/deep', 'Deep')
const event = openTopLevelMenu()
drillInto('mixed', event)
const inside = capturedEntries[1]
const ours = inside.filter((e) =>
['inner', 'A Leaf', 'Z Leaf'].includes(e.content ?? '')
)
expect(ours[0].content).toBe('inner')
expect(ours[0].has_submenu).toBe(true)
expect(ours[1].content).toBe('A Leaf')
expect(ours[2].content).toBe('Z Leaf')
})
})

View File

@@ -1179,7 +1179,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
const categories = LiteGraph.getNodeTypesCategories(
canvas.filter || graph.filter
).filter((category) => category.startsWith(base_category))
const categoryEntries: AddNodeMenu[] = []
const entries: AddNodeMenu[] = []
for (const category of categories) {
if (!category) continue
@@ -1197,11 +1197,11 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
// in case it has a namespace like "shader::math/rand" it hides the namespace
if (name.includes('::')) name = name.split('::', 2)[1]
const index = categoryEntries.findIndex(
const index = entries.findIndex(
(entry) => entry.value === category_path
)
if (index === -1) {
categoryEntries.push({
entries.push({
value: category_path,
content: name,
has_submenu: true,
@@ -1212,19 +1212,11 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
}
}
const compareByContent = (a: AddNodeMenu, b: AddNodeMenu) =>
(a.content ?? '').localeCompare(b.content ?? '', undefined, {
numeric: true,
sensitivity: 'base'
})
categoryEntries.sort(compareByContent)
const nodes = LiteGraph.getNodeTypesInCategory(
base_category.slice(0, -1),
canvas.filter || graph.filter
)
const nodeEntries: AddNodeMenu[] = []
for (const node of nodes) {
if (node.skip_list) continue
@@ -1254,13 +1246,9 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
}
}
nodeEntries.push(entry)
entries.push(entry)
}
nodeEntries.sort(compareByContent)
const entries: AddNodeMenu[] = [...categoryEntries, ...nodeEntries]
new LiteGraph.ContextMenu(entries, { event: e, parentMenu: prev_menu })
}
}

View File

@@ -293,9 +293,7 @@
"title": "إعادة المصادقة مطلوبة"
},
"signOut": {
"saveFailed": "تم إلغاء تسجيل الخروج بسبب فشل حفظ \"{workflow}\".",
"signOut": "تسجيل الخروج",
"signOutAnyway": "تسجيل الخروج على أي حال",
"success": "تم تسجيل الخروج بنجاح",
"successDetail": "لقد تم تسجيل خروجك من حسابك.",
"unsavedChangesMessage": "لديك تغييرات غير محفوظة ستفقد عند تسجيل الخروج. هل ترغب في المتابعة؟",
@@ -785,7 +783,6 @@
"AUDIO_ENCODER": "مُشَفِّر الصوت",
"AUDIO_ENCODER_OUTPUT": "مخرجات مُشَفِّر الصوت",
"AUDIO_RECORD": "تسجيل صوتي",
"BACKGROUND_REMOVAL": "إزالة الخلفية",
"BOOLEAN": "منطقي",
"BOUNDING_BOX": "مربع التحديد",
"CAMERA_CONTROL": "تحكم الكاميرا",
@@ -2285,13 +2282,15 @@
"Vidu": "فيدو",
"Wan": "وان",
"WaveSpeed": "WaveSpeed",
"_for_testing": "_للاختبار",
"advanced": "متقدم",
"animation": "الرسوم المتحركة",
"api": "API",
"api node": "عقدة API",
"attention_experiments": "تجارب الانتباه",
"audio": "صوت",
"background removal": "إزالة الخلفية",
"batch": "دفعة",
"camera": "كاميرا",
"chroma_radiance": "تألق اللون",
"clip": "clip",
"color": "لون",
@@ -2300,6 +2299,7 @@
"cond pair": "زوج شرطي",
"cond single": "شرط فردي",
"conditioning": "التكييف",
"context": "سياق",
"controlnet": "كونترول نت",
"create": "إنشاء",
"custom_sampling": "تجميع مخصص",
@@ -2308,7 +2308,6 @@
"deprecated": "مهمل",
"detection": "الكشف",
"edit_models": "تحرير النماذج",
"experimental": "تجريبي",
"flux": "تدفق",
"gligen": "gligen",
"guidance": "التوجيه",
@@ -2324,6 +2323,7 @@
"lotus": "lotus",
"ltxv": "ltxv",
"mask": "قناع",
"math": "رياضيات",
"model": "نموذج",
"model_merging": "دمج النماذج",
"model_patches": "تصحيحات النموذج",
@@ -2340,6 +2340,7 @@
"save": "حفظ",
"schedulers": "الجدولة",
"scheduling": "الجدولة",
"sd": "sd",
"sd3": "sd3",
"shader": "shader",
"sigmas": "سيجمات",
@@ -2347,6 +2348,7 @@
"style_model": "نموذج النمط",
"supir": "supir",
"text": "نص",
"textgen": "textgen",
"training": "تدريب",
"transform": "تحويل",
"unet": "unet",
@@ -3150,7 +3152,6 @@
"deleteFailedTitle": "فشل الحذف",
"deleted": "تم حذف سير العمل",
"dirtyClose": "تم تعديل الملفات أدناه. هل تريد حفظها قبل الإغلاق؟",
"dirtyCloseAnyway": "إغلاق على أي حال",
"dirtyCloseHint": "اضغط Shift للإغلاق بدون تنبيه",
"dirtyCloseTitle": "حفظ التغييرات؟",
"workflowTreeType": {

View File

@@ -24,40 +24,6 @@
}
}
},
"ARVideoI2V": {
"display_name": "ARVideoI2V",
"inputs": {
"batch_size": {
"name": "حجم الدفعة"
},
"height": {
"name": "الارتفاع"
},
"length": {
"name": "الطول"
},
"model": {
"name": "model"
},
"start_image": {
"name": "الصورة_البدء"
},
"vae": {
"name": "vae"
},
"width": {
"name": "العرض"
}
},
"outputs": {
"0": {
"tooltip": null
},
"1": {
"tooltip": null
}
}
},
"AddNoise": {
"display_name": "إضافة ضجيج",
"inputs": {
@@ -953,50 +919,6 @@
}
}
},
"ByteDanceSeedreamNodeV2": {
"description": "توليد موحد من النص إلى الصورة وتحرير دقيق لجملة واحدة بدقة تصل إلى 4K.",
"display_name": "ByteDance Seedream 4.5 & 5.0",
"inputs": {
"control_after_generate": {
"name": "التحكم بعد التوليد"
},
"model": {
"name": "النموذج"
},
"model_fail_on_partial": {
"name": "فشل عند التوليد الجزئي"
},
"model_height": {
"name": "الارتفاع"
},
"model_max_images": {
"name": "أقصى عدد للصور"
},
"model_size_preset": {
"name": "إعداد الحجم"
},
"model_width": {
"name": "العرض"
},
"prompt": {
"name": "الموجه",
"tooltip": "موجه نصي لإنشاء أو تعديل صورة."
},
"seed": {
"name": "البذرة",
"tooltip": "البذرة المستخدمة للتوليد."
},
"watermark": {
"name": "علامة مائية",
"tooltip": "هل تريد إضافة علامة \"تم الإنشاء بواسطة الذكاء الاصطناعي\" على الصورة."
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"ByteDanceTextToVideoNode": {
"description": "توليد فيديو باستخدام نماذج ByteDance عبر API استنادًا إلى النص الموجه",
"display_name": "ByteDance نص إلى فيديو",
@@ -1733,10 +1655,6 @@
},
"1": {
"tooltip": null
},
"2": {
"name": "قيمة منطقية",
"tooltip": null
}
}
},
@@ -3476,37 +3394,6 @@
}
}
},
"Flux2ImageNode": {
"description": "توليد الصور عبر Flux.2 [pro] أو Flux.2 [max] من موجه وصور مرجعية اختيارية.",
"display_name": "Flux.2 Image",
"inputs": {
"control_after_generate": {
"name": "التحكم بعد التوليد"
},
"model": {
"name": "النموذج"
},
"model_height": {
"name": "الارتفاع"
},
"model_width": {
"name": "العرض"
},
"prompt": {
"name": "الموجه",
"tooltip": "موجه لتوليد أو تعديل الصورة"
},
"seed": {
"name": "البذرة",
"tooltip": "البذرة العشوائية المستخدمة لإنشاء الضوضاء."
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"Flux2MaxImageNode": {
"description": "ينشئ الصور بشكل متزامن بناءً على النص والوَضوح.",
"display_name": "Flux.2 [max] صورة",
@@ -4284,54 +4171,6 @@
}
}
},
"GeminiNanoBanana2V2": {
"description": "توليد أو تعديل الصور بشكل متزامن عبر Google Vertex API.",
"display_name": "نانا موز 2",
"inputs": {
"control_after_generate": {
"name": "التحكم بعد التوليد"
},
"model": {
"name": "النموذج"
},
"model_aspect_ratio": {
"name": "نسبة العرض إلى الارتفاع"
},
"model_resolution": {
"name": "الدقة"
},
"model_thinking_level": {
"name": "مستوى التفكير"
},
"prompt": {
"name": "الموجه",
"tooltip": "وصف نصي للصورة المراد توليدها أو التعديلات التي يجب تطبيقها. أدرج أي قيود أو أنماط أو تفاصيل يجب على النموذج اتباعها."
},
"response_modalities": {
"name": "أنماط الاستجابة"
},
"seed": {
"name": "البذرة",
"tooltip": "عند تثبيت البذرة على قيمة محددة، يبذل النموذج قصارى جهده لتقديم نفس الاستجابة للطلبات المتكررة. لا يتم ضمان إخراج حتمي. أيضًا، تغيير النموذج أو إعدادات المعلمات مثل درجة الحرارة قد يؤدي إلى اختلافات في الاستجابة حتى عند استخدام نفس قيمة البذرة. بشكل افتراضي، يتم استخدام قيمة بذرة عشوائية."
},
"system_prompt": {
"name": "موجه النظام",
"tooltip": "تعليمات أساسية تحدد سلوك الذكاء الاصطناعي."
}
},
"outputs": {
"0": {
"tooltip": null
},
"1": {
"tooltip": null
},
"2": {
"name": "صورة التفكير",
"tooltip": "أول صورة من عملية تفكير النموذج. متوفرة فقط عند مستوى التفكير العالي ونمط الاستجابة صورة+نص."
}
}
},
"GeminiNode": {
"description": "إنشاء استجابات نصية باستخدام نموذج الذكاء الاصطناعي Gemini من Google. يمكنك تقديم أنواع متعددة من المدخلات (نص، صور، صوت، فيديو) كسياق لإنشاء استجابات أكثر صلة ومعنى.",
"display_name": "Google Gemini",
@@ -4530,40 +4369,6 @@
}
}
},
"GrokImageEditNodeV2": {
"description": "تعديل صورة موجودة بناءً على موجه نصي",
"display_name": "تعديل صورة Grok",
"inputs": {
"control_after_generate": {
"name": "التحكم بعد التوليد"
},
"model": {
"name": "النموذج"
},
"model_aspect_ratio": {
"name": "نسبة العرض إلى الارتفاع"
},
"model_number_of_images": {
"name": "عدد الصور"
},
"model_resolution": {
"name": "الدقة"
},
"prompt": {
"name": "الموجه",
"tooltip": "الموجه النصي المستخدم لتوليد الصورة"
},
"seed": {
"name": "البذرة",
"tooltip": "البذرة لتحديد ما إذا كان يجب إعادة تشغيل العقدة؛ النتائج الفعلية غير حتمية بغض النظر عن البذرة."
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"GrokImageNode": {
"description": "توليد صور باستخدام Grok بناءً على مطالبة نصية",
"display_name": "صورة Grok",
@@ -8158,21 +7963,6 @@
}
}
},
"LoadBackgroundRemovalModel": {
"display_name": "تحميل نموذج إزالة الخلفية",
"inputs": {
"bg_removal_name": {
"name": "اسم_إزالة_الخلفية",
"tooltip": "النموذج المستخدم لإزالة الخلفيات من الصور"
}
},
"outputs": {
"0": {
"name": "نموذج_الخلفية",
"tooltip": null
}
}
},
"LoadImage": {
"display_name": "تحميل صورة",
"inputs": {
@@ -12074,50 +11864,6 @@
}
}
},
"OpenAIGPTImageNodeV2": {
"description": "توليد الصور عبر نقطة نهاية GPT Image من OpenAI.",
"display_name": "OpenAI GPT Image 2",
"inputs": {
"control_after_generate": {
"name": "التحكم بعد التوليد"
},
"model": {
"name": "النموذج"
},
"model_background": {
"name": "الخلفية"
},
"model_custom_height": {
"name": "ارتفاع مخصص"
},
"model_custom_width": {
"name": "عرض مخصص"
},
"model_quality": {
"name": "الجودة"
},
"model_size": {
"name": "الحجم"
},
"n": {
"name": "عدد الصور",
"tooltip": "كم عدد الصور التي سيتم توليدها"
},
"prompt": {
"name": "الموجه",
"tooltip": "موجه نصي لـ GPT Image"
},
"seed": {
"name": "البذرة",
"tooltip": "لم يتم تطبيقها بعد في الخلفية"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"OpenAIInputFiles": {
"description": "يقوم بتحميل وإعداد ملفات الإدخال (نص، pdf، إلخ) لتضمينها كمدخلات لعقدة OpenAI Chat. سيتم قراءة الملفات بواسطة نموذج OpenAI عند إنشاء الرد. 🛈 تلميح: يمكن ربطها مع عقد OpenAI Input File الأخرى.",
"display_name": "OpenAI ChatGPT Input Files",
@@ -13666,25 +13412,6 @@
}
}
},
"RemoveBackground": {
"display_name": "إزالة الخلفية",
"inputs": {
"bg_removal_model": {
"name": "نموذج_إزالة_الخلفية",
"tooltip": "نموذج إزالة الخلفية المستخدم لتوليد القناع"
},
"image": {
"name": "الصورة",
"tooltip": "صورة الإدخال لإزالة الخلفية منها"
}
},
"outputs": {
"0": {
"name": "القناع",
"tooltip": "قناع المقدمة المُنتج"
}
}
},
"RenormCFG": {
"display_name": "إعادة تهيئة CFG",
"inputs": {
@@ -17578,8 +17305,7 @@
"name": "PBR"
},
"quad": {
"name": "رباعي",
"tooltip": "هذا المعامل قديم ولم يعد له أي تأثير."
"name": "رباعي"
},
"texture": {
"name": "الملمس"
@@ -19547,156 +19273,6 @@
}
}
},
"WanDancerEncodeAudio": {
"display_name": "WanDancerEncodeAudio",
"inputs": {
"audio": {
"name": "الصوت"
},
"audio_inject_scale": {
"name": "مقياس حقن الصوت",
"tooltip": "المقياس لميزات الصوت عند حقنها في نموذج الفيديو."
},
"video_frames": {
"name": "إطارات الفيديو"
}
},
"outputs": {
"0": {
"name": "مخرجات مشفر الصوت",
"tooltip": null
},
"1": {
"name": "سلسلة معدل الإطارات (fps)",
"tooltip": "معدل الإطارات المحسوب بناءً على طول الصوت وعدد إطارات الفيديو. يُستخدم في الموجه."
}
}
},
"WanDancerPadKeyframes": {
"display_name": "WanDancerPadKeyframes",
"inputs": {
"audio": {
"name": "الصوت",
"tooltip": "الصوت المستخدم لحساب إجمالي إطارات الإخراج واستخراج صوت المقطع."
},
"images": {
"name": "الصور"
},
"segment_index": {
"name": "فهرس المقطع",
"tooltip": "أي مقطع هذا (٠ للأول، ١ للثاني، إلخ.)"
},
"segment_length": {
"name": "طول المقطع",
"tooltip": "طول هذا المقطع (عادةً ١٤٩ إطاراً)"
}
},
"outputs": {
"0": {
"name": "تسلسل الإطارات الرئيسية المبطنة",
"tooltip": "تسلسل الإطارات الرئيسية بعد التبطين"
},
"1": {
"name": "قناع الإطارات الرئيسية",
"tooltip": "قناع يحدد الإطارات الصالحة"
},
"2": {
"name": "مقطع الصوت",
"tooltip": "مقطع الصوت لهذا الجزء من الفيديو"
}
}
},
"WanDancerPadKeyframesList": {
"display_name": "WanDancerPadKeyframesList",
"inputs": {
"audio": {
"name": "الصوت",
"tooltip": "الصوت الذي سيتم تقطيعه لكل مقطع صادر."
},
"images": {
"name": "الصور"
},
"num_segments": {
"name": "عدد المقاطع",
"tooltip": "عدد المقاطع المبطنة التي سيتم إصدارها كقوائم."
},
"segment_length": {
"name": "طول المقطع",
"tooltip": "طول كل مقطع (عادةً ١٤٩ إطاراً)"
}
},
"outputs": {
"0": {
"name": "تسلسلات الإطارات الرئيسية المبطنة",
"tooltip": "تسلسلات الإطارات الرئيسية بعد التبطين"
},
"1": {
"name": "أقنعة الإطارات الرئيسية",
"tooltip": "أقنعة تحدد الإطارات الصالحة"
},
"2": {
"name": "مقطع الصوت",
"tooltip": "مقطع الصوت لكل جزء من الفيديو"
}
}
},
"WanDancerVideo": {
"display_name": "WanDancerVideo",
"inputs": {
"audio_encoder_output": {
"name": "مخرجات ترميز الصوت"
},
"clip_vision_output": {
"name": "clip_vision_output",
"tooltip": "تضمينات CLIP للرؤية للإطار الأول."
},
"clip_vision_output_ref": {
"name": "clip_vision_output_ref",
"tooltip": "تضمينات CLIP للرؤية لصورة المرجع."
},
"height": {
"name": "الارتفاع"
},
"length": {
"name": "الطول",
"tooltip": "عدد الإطارات في الفيديو المُنتج. يجب أن يبقى ١٤٩ لـ WanDancer."
},
"mask": {
"name": "قناع",
"tooltip": "قناع معالجة الصورة للصورة/الصور الابتدائية. الأبيض يبقى، الأسود يُولّد. يُستخدم للتوليد المحلي."
},
"negative": {
"name": "سلبي"
},
"positive": {
"name": "إيجابي"
},
"start_image": {
"name": "الصورة الابتدائية",
"tooltip": "الصورة أو الصور الأولية التي سيتم ترميزها، يمكن أن تكون أي عدد من الإطارات."
},
"vae": {
"name": "vae"
},
"width": {
"name": "العرض"
}
},
"outputs": {
"0": {
"name": "إيجابي",
"tooltip": null
},
"1": {
"name": "سلبي",
"tooltip": null
},
"2": {
"name": "كامِن",
"tooltip": "كامِن فارغ."
}
}
},
"WanFirstLastFrameToVideo": {
"display_name": "وان إطار أول وآخر إلى فيديو",
"inputs": {

View File

@@ -1650,7 +1650,7 @@
"Directories": "Directories"
},
"nodeCategories": {
"experimental": "experimental",
"_for_testing": "_for_testing",
"custom_sampling": "custom_sampling",
"noise": "noise",
"dataset": "dataset",
@@ -1658,9 +1658,8 @@
"image": "image",
"sampling": "sampling",
"schedulers": "schedulers",
"conditioning": "conditioning",
"video_models": "video_models",
"audio": "audio",
"conditioning": "conditioning",
"loaders": "loaders",
"guiders": "guiders",
"batch": "batch",
@@ -1683,14 +1682,17 @@
"postprocessing": "postprocessing",
"hooks": "hooks",
"combine": "combine",
"math": "math",
"logic": "logic",
"cond single": "cond single",
"context": "context",
"controlnet": "controlnet",
"inpaint": "inpaint",
"scheduling": "scheduling",
"create": "create",
"deprecated": "deprecated",
"detection": "detection",
"": "",
"debug": "debug",
"model": "model",
"ElevenLabs": "ElevenLabs",
@@ -1701,14 +1703,14 @@
"unet": "unet",
"sigmas": "sigmas",
"BFL": "BFL",
"": "",
"Gemini": "Gemini",
"video_models": "video_models",
"gligen": "gligen",
"shader": "shader",
"Grok": "Grok",
"Wan": "Wan",
"HitPaw": "HitPaw",
"3d_models": "3d_models",
"sd": "sd",
"Ideogram": "Ideogram",
"transform": "transform",
"color": "color",
@@ -1735,24 +1737,27 @@
"Quiver": "Quiver",
"Recraft": "Recraft",
"edit_models": "edit_models",
"background removal": "background removal",
"Reve": "Reve",
"Rodin": "Rodin",
"Runway": "Runway",
"animation": "animation",
"api": "api",
"save": "save",
"upscale_diffusion": "upscale_diffusion",
"clip": "clip",
"Sonilo": "Sonilo",
"Stability AI": "Stability AI",
"stable_cascade": "stable_cascade",
"3d_models": "3d_models",
"style_model": "style_model",
"supir": "supir",
"Tencent": "Tencent",
"textgen": "textgen",
"Topaz": "Topaz",
"Tripo": "Tripo",
"Veo": "Veo",
"Vidu": "Vidu",
"camera": "camera",
"WaveSpeed": "WaveSpeed",
"zimage": "zimage"
},
@@ -1762,7 +1767,6 @@
"AUDIO_ENCODER": "AUDIO_ENCODER",
"AUDIO_ENCODER_OUTPUT": "AUDIO_ENCODER_OUTPUT",
"AUDIO_RECORD": "AUDIO_RECORD",
"BACKGROUND_REMOVAL": "BACKGROUND_REMOVAL",
"BOOLEAN": "BOOLEAN",
"BOUNDING_BOX": "BOUNDING_BOX",
"CAMERA_CONTROL": "CAMERA_CONTROL",

View File

@@ -141,40 +141,6 @@
}
}
},
"ARVideoI2V": {
"display_name": "ARVideoI2V",
"inputs": {
"model": {
"name": "model"
},
"vae": {
"name": "vae"
},
"start_image": {
"name": "start_image"
},
"width": {
"name": "width"
},
"height": {
"name": "height"
},
"length": {
"name": "length"
},
"batch_size": {
"name": "batch_size"
}
},
"outputs": {
"0": {
"tooltip": null
},
"1": {
"tooltip": null
}
}
},
"AudioAdjustVolume": {
"display_name": "Audio Adjust Volume",
"inputs": {
@@ -230,7 +196,7 @@
}
},
"AudioEncoderLoader": {
"display_name": "Load Audio Encoder",
"display_name": "AudioEncoderLoader",
"inputs": {
"audio_encoder_name": {
"name": "audio_encoder_name"
@@ -953,50 +919,6 @@
}
}
},
"ByteDanceSeedreamNodeV2": {
"display_name": "ByteDance Seedream 4.5 & 5.0",
"description": "Unified text-to-image generation and precise single-sentence editing at up to 4K resolution.",
"inputs": {
"prompt": {
"name": "prompt",
"tooltip": "Text prompt for creating or editing an image."
},
"model": {
"name": "model"
},
"seed": {
"name": "seed",
"tooltip": "Seed to use for generation."
},
"watermark": {
"name": "watermark",
"tooltip": "Whether to add an \"AI generated\" watermark to the image."
},
"control_after_generate": {
"name": "control after generate"
},
"model_fail_on_partial": {
"name": "fail_on_partial"
},
"model_height": {
"name": "height"
},
"model_max_images": {
"name": "max_images"
},
"model_size_preset": {
"name": "size_preset"
},
"model_width": {
"name": "width"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"ByteDanceTextToVideoNode": {
"display_name": "ByteDance Text to Video",
"description": "Generate video using ByteDance models via api based on prompt",
@@ -1733,10 +1655,6 @@
},
"1": {
"tooltip": null
},
"2": {
"name": "BOOL",
"tooltip": null
}
}
},
@@ -3476,37 +3394,6 @@
}
}
},
"Flux2ImageNode": {
"display_name": "Flux.2 Image",
"description": "Generate images via Flux.2 [pro] or Flux.2 [max] from a prompt and optional reference images.",
"inputs": {
"prompt": {
"name": "prompt",
"tooltip": "Prompt for the image generation or edit"
},
"model": {
"name": "model"
},
"seed": {
"name": "seed",
"tooltip": "The random seed used for creating the noise."
},
"control_after_generate": {
"name": "control after generate"
},
"model_height": {
"name": "height"
},
"model_width": {
"name": "width"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"Flux2MaxImageNode": {
"display_name": "Flux.2 [max] Image",
"description": "Generates images synchronously based on prompt and resolution.",
@@ -4176,54 +4063,6 @@
}
}
},
"GeminiNanoBanana2V2": {
"display_name": "Nano Banana 2",
"description": "Generate or edit images synchronously via Google Vertex API.",
"inputs": {
"prompt": {
"name": "prompt",
"tooltip": "Text prompt describing the image to generate or the edits to apply. Include any constraints, styles, or details the model should follow."
},
"model": {
"name": "model"
},
"seed": {
"name": "seed",
"tooltip": "When the seed is fixed to a specific value, the model makes a best effort to provide the same response for repeated requests. Deterministic output isn't guaranteed. Also, changing the model or parameter settings, such as the temperature, can cause variations in the response even when you use the same seed value. By default, a random seed value is used."
},
"response_modalities": {
"name": "response_modalities"
},
"system_prompt": {
"name": "system_prompt",
"tooltip": "Foundational instructions that dictate an AI's behavior."
},
"control_after_generate": {
"name": "control after generate"
},
"model_aspect_ratio": {
"name": "aspect_ratio"
},
"model_resolution": {
"name": "resolution"
},
"model_thinking_level": {
"name": "thinking_level"
}
},
"outputs": {
"0": {
"tooltip": null
},
"1": {
"tooltip": null
},
"2": {
"name": "thought_image",
"tooltip": "First image from the model's thinking process. Only available with thinking_level HIGH and IMAGE+TEXT modality."
}
}
},
"GeminiNode": {
"display_name": "Google Gemini",
"description": "Generate text responses with Google's Gemini AI model. You can provide multiple types of inputs (text, images, audio, video) as context for generating more relevant and meaningful responses.",
@@ -4403,7 +4242,7 @@
}
},
"GLIGENLoader": {
"display_name": "Load GLIGEN Model",
"display_name": "GLIGENLoader",
"inputs": {
"gligen_name": {
"name": "gligen_name"
@@ -4530,40 +4369,6 @@
}
}
},
"GrokImageEditNodeV2": {
"display_name": "Grok Image Edit",
"description": "Modify an existing image based on a text prompt",
"inputs": {
"prompt": {
"name": "prompt",
"tooltip": "The text prompt used to generate the image"
},
"model": {
"name": "model"
},
"seed": {
"name": "seed",
"tooltip": "Seed to determine if node should re-run; actual results are nondeterministic regardless of seed."
},
"control_after_generate": {
"name": "control after generate"
},
"model_aspect_ratio": {
"name": "aspect_ratio"
},
"model_number_of_images": {
"name": "number_of_images"
},
"model_resolution": {
"name": "resolution"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"GrokImageNode": {
"display_name": "Grok Image",
"description": "Generate images using Grok based on a text prompt",
@@ -5034,7 +4839,7 @@
}
},
"HunyuanRefinerLatent": {
"display_name": "Hunyuan Latent Refiner",
"display_name": "HunyuanRefinerLatent",
"inputs": {
"positive": {
"name": "positive"
@@ -5139,7 +4944,7 @@
}
},
"HunyuanVideo15SuperResolution": {
"display_name": "Hunyuan Video 1.5 Super Resolution",
"display_name": "HunyuanVideo15SuperResolution",
"inputs": {
"positive": {
"name": "positive"
@@ -5179,7 +4984,7 @@
}
},
"HypernetworkLoader": {
"display_name": "Load Hypernetwork",
"display_name": "HypernetworkLoader",
"inputs": {
"model": {
"name": "model"
@@ -5475,6 +5280,9 @@
"ImageCompositeMasked": {
"display_name": "Image Composite Masked",
"inputs": {
"destination": {
"name": "destination"
},
"source": {
"name": "source"
},
@@ -5487,9 +5295,6 @@
"resize_source": {
"name": "resize_source"
},
"destination": {
"name": "destination"
},
"mask": {
"name": "mask"
}
@@ -5739,7 +5544,7 @@
}
},
"ImageQuantize": {
"display_name": "Quantize Image",
"display_name": "ImageQuantize",
"inputs": {
"image": {
"name": "image"
@@ -5871,7 +5676,7 @@
}
},
"ImageSharpen": {
"display_name": "Sharpen Image",
"display_name": "ImageSharpen",
"inputs": {
"image": {
"name": "image"
@@ -7745,21 +7550,6 @@
}
}
},
"LoadBackgroundRemovalModel": {
"display_name": "Load Background Removal Model",
"inputs": {
"bg_removal_name": {
"name": "bg_removal_name",
"tooltip": "The model used to remove backgrounds from images"
}
},
"outputs": {
"0": {
"name": "bg_model",
"tooltip": null
}
}
},
"LoadImage": {
"display_name": "Load Image",
"inputs": {
@@ -8421,7 +8211,7 @@
}
},
"LTXVPreprocess": {
"display_name": "LTXV Preprocess",
"display_name": "LTXVPreprocess",
"inputs": {
"image": {
"name": "image"
@@ -12074,50 +11864,6 @@
}
}
},
"OpenAIGPTImageNodeV2": {
"display_name": "OpenAI GPT Image 2",
"description": "Generates images via OpenAI's GPT Image endpoint.",
"inputs": {
"prompt": {
"name": "prompt",
"tooltip": "Text prompt for GPT Image"
},
"model": {
"name": "model"
},
"n": {
"name": "n",
"tooltip": "How many images to generate"
},
"seed": {
"name": "seed",
"tooltip": "not implemented yet in backend"
},
"control_after_generate": {
"name": "control after generate"
},
"model_background": {
"name": "background"
},
"model_custom_height": {
"name": "custom_height"
},
"model_custom_width": {
"name": "custom_width"
},
"model_quality": {
"name": "quality"
},
"model_size": {
"name": "size"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"OpenAIInputFiles": {
"display_name": "OpenAI ChatGPT Input Files",
"description": "Loads and prepares input files (text, pdf, etc.) to include as inputs for the OpenAI Chat Node. The files will be read by the OpenAI model when generating a response. 🛈 TIP: Can be chained together with other OpenAI Input File nodes.",
@@ -12395,7 +12141,7 @@
}
},
"PerpNeg": {
"display_name": "Perp-Neg (DEPRECATED by Perp-Neg Guider)",
"display_name": "Perp-Neg (DEPRECATED by PerpNegGuider)",
"inputs": {
"model": {
"name": "model"
@@ -12414,7 +12160,7 @@
}
},
"PerpNegGuider": {
"display_name": "Perp-Neg Guider",
"display_name": "PerpNegGuider",
"inputs": {
"model": {
"name": "model"
@@ -13638,25 +13384,6 @@
}
}
},
"RemoveBackground": {
"display_name": "Remove Background",
"inputs": {
"image": {
"name": "image",
"tooltip": "Input image to remove the background from"
},
"bg_removal_model": {
"name": "bg_removal_model",
"tooltip": "Background removal model used to generate the mask"
}
},
"outputs": {
"0": {
"name": "mask",
"tooltip": "Generated foreground mask"
}
}
},
"RenormCFG": {
"display_name": "RenormCFG",
"inputs": {
@@ -14410,11 +14137,11 @@
},
"detection_threshold": {
"name": "detection_threshold",
"tooltip": "Score threshold for text-prompted detection."
"tooltip": "Score threshold for text-prompted detection"
},
"max_objects": {
"name": "max_objects",
"tooltip": "Max tracked objects. Initial masks count toward this limit. 0 uses the internal cap of 64."
"tooltip": "Max tracked objects (0=unlimited). Initial masks count toward this limit."
},
"detect_interval": {
"name": "detect_interval",
@@ -14984,7 +14711,7 @@
}
},
"SaveImageWebsocket": {
"display_name": "Save Image (Websocket)",
"display_name": "SaveImageWebsocket",
"inputs": {
"images": {
"name": "images"
@@ -16906,7 +16633,7 @@
}
},
"TextGenerate": {
"display_name": "Generate Text",
"display_name": "TextGenerate",
"inputs": {
"clip": {
"name": "clip"
@@ -16968,7 +16695,7 @@
}
},
"TextGenerateLTX2Prompt": {
"display_name": "Generate LTX2 Prompt",
"display_name": "TextGenerateLTX2Prompt",
"inputs": {
"clip": {
"name": "clip"
@@ -17587,8 +17314,7 @@
"name": "face_limit"
},
"quad": {
"name": "quad",
"tooltip": "This parameter is deprecated and does nothing."
"name": "quad"
},
"geometry_quality": {
"name": "geometry_quality"
@@ -17795,7 +17521,7 @@
}
},
"unCLIPCheckpointLoader": {
"display_name": "Load unCLIP Checkpoint",
"display_name": "unCLIPCheckpointLoader",
"inputs": {
"ckpt_name": {
"name": "ckpt_name"
@@ -18985,7 +18711,7 @@
}
},
"VoxelToMesh": {
"display_name": "Voxel to Mesh",
"display_name": "VoxelToMesh",
"inputs": {
"voxel": {
"name": "voxel"
@@ -19004,7 +18730,7 @@
}
},
"VoxelToMeshBasic": {
"display_name": "Voxel to Mesh (Basic)",
"display_name": "VoxelToMeshBasic",
"inputs": {
"voxel": {
"name": "voxel"
@@ -19586,156 +19312,6 @@
}
}
},
"WanDancerEncodeAudio": {
"display_name": "WanDancerEncodeAudio",
"inputs": {
"audio": {
"name": "audio"
},
"video_frames": {
"name": "video_frames"
},
"audio_inject_scale": {
"name": "audio_inject_scale",
"tooltip": "The scale for the audio features when injected into the video model."
}
},
"outputs": {
"0": {
"name": "audio_encoder_output",
"tooltip": null
},
"1": {
"name": "fps_string",
"tooltip": "The calculated fps based on the audio length and the number of video frames. Used in the prompt."
}
}
},
"WanDancerPadKeyframes": {
"display_name": "WanDancerPadKeyframes",
"inputs": {
"images": {
"name": "images"
},
"segment_length": {
"name": "segment_length",
"tooltip": "Length of this segment (usually 149 frames)"
},
"segment_index": {
"name": "segment_index",
"tooltip": "Which segment this is (0 for first, 1 for second, etc.)"
},
"audio": {
"name": "audio",
"tooltip": "Audio to calculate total output frames from and extract segment audio."
}
},
"outputs": {
"0": {
"name": "keyframes_sequence",
"tooltip": "Padded keyframe sequence"
},
"1": {
"name": "keyframes_mask",
"tooltip": "Mask indicating valid frames"
},
"2": {
"name": "audio_segment",
"tooltip": "Audio segment for this video segment"
}
}
},
"WanDancerPadKeyframesList": {
"display_name": "WanDancerPadKeyframesList",
"inputs": {
"images": {
"name": "images"
},
"segment_length": {
"name": "segment_length",
"tooltip": "Length of each segment (usually 149 frames)"
},
"num_segments": {
"name": "num_segments",
"tooltip": "How many padded segments to emit as lists."
},
"audio": {
"name": "audio",
"tooltip": "Audio to slice for each emitted segment."
}
},
"outputs": {
"0": {
"name": "keyframes_sequence",
"tooltip": "Padded keyframe sequences"
},
"1": {
"name": "keyframes_mask",
"tooltip": "Masks indicating valid frames"
},
"2": {
"name": "audio_segment",
"tooltip": "Audio segment for each video segment"
}
}
},
"WanDancerVideo": {
"display_name": "WanDancerVideo",
"inputs": {
"positive": {
"name": "positive"
},
"negative": {
"name": "negative"
},
"vae": {
"name": "vae"
},
"width": {
"name": "width"
},
"height": {
"name": "height"
},
"length": {
"name": "length",
"tooltip": "The number of frames in the generated video. Should stay 149 for WanDancer."
},
"clip_vision_output": {
"name": "clip_vision_output",
"tooltip": "The CLIP vision embeds for the first frame."
},
"clip_vision_output_ref": {
"name": "clip_vision_output_ref",
"tooltip": "The CLIP vision embeds for the reference image."
},
"start_image": {
"name": "start_image",
"tooltip": "The initial image(s) to be encoded, can be any number of frames."
},
"mask": {
"name": "mask",
"tooltip": "Image conditioning mask for the start image(s). White is kept, black is generated. Used for the local generations."
},
"audio_encoder_output": {
"name": "audio_encoder_output"
}
},
"outputs": {
"0": {
"name": "positive",
"tooltip": null
},
"1": {
"name": "negative",
"tooltip": null
},
"2": {
"name": "latent",
"tooltip": "Empty latent."
}
}
},
"WanFirstLastFrameToVideo": {
"display_name": "WanFirstLastFrameToVideo",
"inputs": {

View File

@@ -328,10 +328,6 @@
"name": "Show node frequency in search results",
"tooltip": "Only applies to v1 (legacy)"
},
"Comfy_NodeSearchBox_ReplaceCanvasMenu": {
"name": "Replace canvas right-click \"Add Node\" with search box",
"tooltip": "When enabled, the right-click canvas menu opens the node search box instead of the LiteGraph category submenu. The search box includes blueprints, partner nodes, core nodes, and extensions."
},
"Comfy_NodeSuggestions_number": {
"name": "Number of nodes suggestions",
"tooltip": "Only for litegraph searchbox/context menu"

View File

@@ -293,9 +293,7 @@
"title": "Reautenticación requerida"
},
"signOut": {
"saveFailed": "Cierre de sesión cancelado porque falló el guardado de \"{workflow}\".",
"signOut": "Cerrar sesión",
"signOutAnyway": "Cerrar sesión de todos modos",
"success": "Sesión cerrada correctamente",
"successDetail": "Has cerrado sesión en tu cuenta.",
"unsavedChangesMessage": "Tienes cambios no guardados que se perderán al cerrar sesión. ¿Quieres continuar?",
@@ -785,7 +783,6 @@
"AUDIO_ENCODER": "CODIFICADOR_AUDIO",
"AUDIO_ENCODER_OUTPUT": "SALIDA_CODIFICADOR_AUDIO",
"AUDIO_RECORD": "GRABACIÓN_AUDIO",
"BACKGROUND_REMOVAL": "ELIMINACIÓN_DE_FONDO",
"BOOLEAN": "BOOLEANO",
"BOUNDING_BOX": "CUADRO DELIMITADOR",
"CAMERA_CONTROL": "CONTROL DE CÁMARA",
@@ -2285,13 +2282,15 @@
"Vidu": "Vidu",
"Wan": "Wan",
"WaveSpeed": "WaveSpeed",
"_for_testing": "_para_pruebas",
"advanced": "avanzado",
"animation": "animación",
"api": "api",
"api node": "nodo api",
"attention_experiments": "experimentos_de_atención",
"audio": "audio",
"background removal": "eliminación de fondo",
"batch": "lote",
"camera": "cámara",
"chroma_radiance": "chroma_radiance",
"clip": "clip",
"color": "color",
@@ -2300,6 +2299,7 @@
"cond pair": "par_cond",
"cond single": "cond único",
"conditioning": "acondicionamiento",
"context": "contexto",
"controlnet": "controlnet",
"create": "crear",
"custom_sampling": "muestreo_personalizado",
@@ -2308,7 +2308,6 @@
"deprecated": "obsoleto",
"detection": "detección",
"edit_models": "editar_modelos",
"experimental": "experimental",
"flux": "flux",
"gligen": "gligen",
"guidance": "orientación",
@@ -2324,6 +2323,7 @@
"lotus": "lotus",
"ltxv": "ltxv",
"mask": "mask",
"math": "matemáticas",
"model": "modelo",
"model_merging": "fusión_de_modelos",
"model_patches": "parches_de_modelo",
@@ -2340,6 +2340,7 @@
"save": "guardar",
"schedulers": "programadores",
"scheduling": "programación",
"sd": "sd",
"sd3": "sd3",
"shader": "shader",
"sigmas": "sigmas",
@@ -2347,6 +2348,7 @@
"style_model": "modelo_de_estilo",
"supir": "supir",
"text": "texto",
"textgen": "textgen",
"training": "entrenamiento",
"transform": "transformar",
"unet": "unet",
@@ -3150,7 +3152,6 @@
"deleteFailedTitle": "Eliminación fallida",
"deleted": "Flujo de trabajo eliminado",
"dirtyClose": "Los archivos a continuación han sido modificados. ¿Te gustaría guardarlos antes de cerrar?",
"dirtyCloseAnyway": "Cerrar de todos modos",
"dirtyCloseHint": "Mantén presionada la tecla Shift para cerrar sin preguntar",
"dirtyCloseTitle": "¿Guardar cambios?",
"workflowTreeType": {

View File

@@ -24,40 +24,6 @@
}
}
},
"ARVideoI2V": {
"display_name": "ARVideoI2V",
"inputs": {
"batch_size": {
"name": "tamaño_de_lote"
},
"height": {
"name": "alto"
},
"length": {
"name": "longitud"
},
"model": {
"name": "modelo"
},
"start_image": {
"name": "imagen_inicial"
},
"vae": {
"name": "vae"
},
"width": {
"name": "ancho"
}
},
"outputs": {
"0": {
"tooltip": null
},
"1": {
"tooltip": null
}
}
},
"AddNoise": {
"display_name": "AñadirRuido",
"inputs": {
@@ -953,50 +919,6 @@
}
}
},
"ByteDanceSeedreamNodeV2": {
"description": "Generación unificada de texto a imagen y edición precisa de una sola frase hasta una resolución de 4K.",
"display_name": "ByteDance Seedream 4.5 & 5.0",
"inputs": {
"control_after_generate": {
"name": "control después de generar"
},
"model": {
"name": "modelo"
},
"model_fail_on_partial": {
"name": "fallar_en_parcial"
},
"model_height": {
"name": "altura"
},
"model_max_images": {
"name": "imágenes_máximas"
},
"model_size_preset": {
"name": "preajuste_de_tamaño"
},
"model_width": {
"name": "ancho"
},
"prompt": {
"name": "prompt",
"tooltip": "Indicador de texto para crear o editar una imagen."
},
"seed": {
"name": "semilla",
"tooltip": "Semilla a utilizar para la generación."
},
"watermark": {
"name": "marca de agua",
"tooltip": "Indica si se añade una marca de agua de \"Generado por IA\" a la imagen."
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"ByteDanceTextToVideoNode": {
"description": "Generar video usando modelos de ByteDance mediante API basado en prompt",
"display_name": "ByteDance Texto a Video",
@@ -1733,10 +1655,6 @@
},
"1": {
"tooltip": null
},
"2": {
"name": "BOOL",
"tooltip": null
}
}
},
@@ -3476,37 +3394,6 @@
}
}
},
"Flux2ImageNode": {
"description": "Genera imágenes mediante Flux.2 [pro] o Flux.2 [max] a partir de un prompt y, opcionalmente, imágenes de referencia.",
"display_name": "Flux.2 Image",
"inputs": {
"control_after_generate": {
"name": "control después de generar"
},
"model": {
"name": "modelo"
},
"model_height": {
"name": "altura"
},
"model_width": {
"name": "ancho"
},
"prompt": {
"name": "prompt",
"tooltip": "Prompt para la generación o edición de la imagen"
},
"seed": {
"name": "semilla",
"tooltip": "La semilla aleatoria utilizada para crear el ruido."
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"Flux2MaxImageNode": {
"description": "Genera imágenes de forma sincrónica según el prompt y la resolución.",
"display_name": "Flux.2 [max] Imagen",
@@ -4284,54 +4171,6 @@
}
}
},
"GeminiNanoBanana2V2": {
"description": "Genera o edita imágenes de forma síncrona a través de la API de Google Vertex.",
"display_name": "Nano Banana 2",
"inputs": {
"control_after_generate": {
"name": "control after generate"
},
"model": {
"name": "model"
},
"model_aspect_ratio": {
"name": "aspect_ratio"
},
"model_resolution": {
"name": "resolution"
},
"model_thinking_level": {
"name": "thinking_level"
},
"prompt": {
"name": "prompt",
"tooltip": "Texto descriptivo de la imagen a generar o de las ediciones a aplicar. Incluye cualquier restricción, estilo o detalle que el modelo deba seguir."
},
"response_modalities": {
"name": "response_modalities"
},
"seed": {
"name": "seed",
"tooltip": "Cuando la semilla se fija a un valor específico, el modelo intenta proporcionar la misma respuesta en solicitudes repetidas. No se garantiza una salida determinista. Además, cambiar el modelo o los parámetros, como la temperatura, puede causar variaciones en la respuesta incluso usando la misma semilla. Por defecto, se utiliza una semilla aleatoria."
},
"system_prompt": {
"name": "system_prompt",
"tooltip": "Instrucciones fundamentales que dictan el comportamiento de la IA."
}
},
"outputs": {
"0": {
"tooltip": null
},
"1": {
"tooltip": null
},
"2": {
"name": "thought_image",
"tooltip": "Primera imagen del proceso de pensamiento del modelo. Solo disponible con thinking_level ALTO y modalidad IMAGEN+TEXTO."
}
}
},
"GeminiNode": {
"description": "Genera respuestas de texto con el modelo de IA Gemini de Google. Puede proporcionar múltiples tipos de entradas (texto, imágenes, audio, video) como contexto para generar respuestas más relevantes y significativas.",
"display_name": "Google Gemini",
@@ -4530,40 +4369,6 @@
}
}
},
"GrokImageEditNodeV2": {
"description": "Modifica una imagen existente en base a un prompt de texto",
"display_name": "Grok Image Edit",
"inputs": {
"control_after_generate": {
"name": "control después de generar"
},
"model": {
"name": "modelo"
},
"model_aspect_ratio": {
"name": "relación_de_aspecto"
},
"model_number_of_images": {
"name": "número_de_imágenes"
},
"model_resolution": {
"name": "resolución"
},
"prompt": {
"name": "prompt",
"tooltip": "El prompt de texto utilizado para generar la imagen"
},
"seed": {
"name": "semilla",
"tooltip": "Semilla para determinar si el nodo debe volver a ejecutarse; los resultados reales son no deterministas independientemente de la semilla."
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"GrokImageNode": {
"description": "Genera imágenes usando Grok a partir de una indicación de texto",
"display_name": "Imagen Grok",
@@ -8158,21 +7963,6 @@
}
}
},
"LoadBackgroundRemovalModel": {
"display_name": "Cargar modelo de eliminación de fondo",
"inputs": {
"bg_removal_name": {
"name": "nombre_del_modelo_de_eliminación_de_fondo",
"tooltip": "El modelo utilizado para eliminar fondos de imágenes"
}
},
"outputs": {
"0": {
"name": "modelo_de_fondo",
"tooltip": null
}
}
},
"LoadImage": {
"display_name": "Cargar Imagen",
"inputs": {
@@ -12074,50 +11864,6 @@
}
}
},
"OpenAIGPTImageNodeV2": {
"description": "Genera imágenes a través del endpoint GPT Image de OpenAI.",
"display_name": "OpenAI GPT Image 2",
"inputs": {
"control_after_generate": {
"name": "control después de generar"
},
"model": {
"name": "modelo"
},
"model_background": {
"name": "fondo"
},
"model_custom_height": {
"name": "altura_personalizada"
},
"model_custom_width": {
"name": "ancho_personalizado"
},
"model_quality": {
"name": "calidad"
},
"model_size": {
"name": "tamaño"
},
"n": {
"name": "n",
"tooltip": "Cuántas imágenes generar"
},
"prompt": {
"name": "prompt",
"tooltip": "Prompt de texto para GPT Image"
},
"seed": {
"name": "semilla",
"tooltip": "aún no implementado en el backend"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"OpenAIInputFiles": {
"description": "Carga y prepara archivos de entrada (texto, pdf, etc.) para incluirlos como entradas para el Nodo de Chat de OpenAI. Los archivos serán leídos por el modelo de OpenAI al generar una respuesta. 🛈 CONSEJO: Se puede encadenar con otros nodos de Archivos de Entrada de OpenAI.",
"display_name": "Archivos de Entrada de OpenAI ChatGPT",
@@ -13666,25 +13412,6 @@
}
}
},
"RemoveBackground": {
"display_name": "Eliminar fondo",
"inputs": {
"bg_removal_model": {
"name": "modelo_de_eliminación_de_fondo",
"tooltip": "Modelo de eliminación de fondo utilizado para generar la máscara"
},
"image": {
"name": "imagen",
"tooltip": "Imagen de entrada para eliminar el fondo"
}
},
"outputs": {
"0": {
"name": "máscara",
"tooltip": "Máscara de primer plano generada"
}
}
},
"RenormCFG": {
"display_name": "RenormCFG",
"inputs": {
@@ -17578,8 +17305,7 @@
"name": "pbr"
},
"quad": {
"name": "cuadrilátero",
"tooltip": "Este parámetro está obsoleto y no hace nada."
"name": "cuadrilátero"
},
"texture": {
"name": "textura"
@@ -19547,156 +19273,6 @@
}
}
},
"WanDancerEncodeAudio": {
"display_name": "WanDancerEncodeAudio",
"inputs": {
"audio": {
"name": "audio"
},
"audio_inject_scale": {
"name": "audio_inject_scale",
"tooltip": "La escala para las características de audio cuando se inyectan en el modelo de video."
},
"video_frames": {
"name": "video_frames"
}
},
"outputs": {
"0": {
"name": "audio_encoder_output",
"tooltip": null
},
"1": {
"name": "fps_string",
"tooltip": "Los fps calculados en base a la duración del audio y el número de fotogramas de video. Se usa en el prompt."
}
}
},
"WanDancerPadKeyframes": {
"display_name": "WanDancerPadKeyframes",
"inputs": {
"audio": {
"name": "audio",
"tooltip": "Audio para calcular el total de fotogramas de salida y extraer el audio del segmento."
},
"images": {
"name": "images"
},
"segment_index": {
"name": "segment_index",
"tooltip": "Qué segmento es este (0 para el primero, 1 para el segundo, etc.)"
},
"segment_length": {
"name": "segment_length",
"tooltip": "Longitud de este segmento (usualmente 149 fotogramas)"
}
},
"outputs": {
"0": {
"name": "keyframes_sequence",
"tooltip": "Secuencia de keyframes rellenada"
},
"1": {
"name": "keyframes_mask",
"tooltip": "Máscara que indica los fotogramas válidos"
},
"2": {
"name": "audio_segment",
"tooltip": "Segmento de audio para este segmento de video"
}
}
},
"WanDancerPadKeyframesList": {
"display_name": "WanDancerPadKeyframesList",
"inputs": {
"audio": {
"name": "audio",
"tooltip": "Audio para dividir para cada segmento emitido."
},
"images": {
"name": "images"
},
"num_segments": {
"name": "num_segments",
"tooltip": "Cuántos segmentos rellenados emitir como listas."
},
"segment_length": {
"name": "segment_length",
"tooltip": "Longitud de cada segmento (usualmente 149 fotogramas)"
}
},
"outputs": {
"0": {
"name": "keyframes_sequence",
"tooltip": "Secuencias de keyframes rellenadas"
},
"1": {
"name": "keyframes_mask",
"tooltip": "Máscaras que indican los fotogramas válidos"
},
"2": {
"name": "audio_segment",
"tooltip": "Segmento de audio para cada segmento de video"
}
}
},
"WanDancerVideo": {
"display_name": "WanDancerVideo",
"inputs": {
"audio_encoder_output": {
"name": "audio_encoder_output"
},
"clip_vision_output": {
"name": "clip_vision_output",
"tooltip": "Las incrustaciones de visión de CLIP para el primer fotograma."
},
"clip_vision_output_ref": {
"name": "clip_vision_output_ref",
"tooltip": "Las incrustaciones de visión de CLIP para la imagen de referencia."
},
"height": {
"name": "alto"
},
"length": {
"name": "longitud",
"tooltip": "El número de fotogramas en el video generado. Debe mantenerse en 149 para WanDancer."
},
"mask": {
"name": "máscara",
"tooltip": "Máscara de acondicionamiento de imagen para la(s) imagen(es) inicial(es). El blanco se mantiene, el negro se genera. Se utiliza para las generaciones locales."
},
"negative": {
"name": "negativo"
},
"positive": {
"name": "positivo"
},
"start_image": {
"name": "imagen_inicial",
"tooltip": "La(s) imagen(es) inicial(es) a codificar, puede ser cualquier cantidad de fotogramas."
},
"vae": {
"name": "vae"
},
"width": {
"name": "ancho"
}
},
"outputs": {
"0": {
"name": "positivo",
"tooltip": null
},
"1": {
"name": "negativo",
"tooltip": null
},
"2": {
"name": "latente",
"tooltip": "Latente vacío."
}
}
},
"WanFirstLastFrameToVideo": {
"display_name": "WanFirstLastFrameToVideo",
"inputs": {

View File

@@ -293,9 +293,7 @@
"title": "احراز هویت مجدد لازم است"
},
"signOut": {
"saveFailed": "خروج لغو شد زیرا ذخیره «{workflow}» با شکست مواجه شد.",
"signOut": "خروج",
"signOutAnyway": "خروج به هر حال",
"success": "خروج با موفقیت انجام شد",
"successDetail": "شما با موفقیت از حساب کاربری خود خارج شدید.",
"unsavedChangesMessage": "شما تغییرات ذخیره‌نشده‌ای دارید که با خروج از حساب از بین خواهند رفت. آیا مایل به ادامه هستید؟",
@@ -785,7 +783,6 @@
"AUDIO_ENCODER": "رمزگذار صوت",
"AUDIO_ENCODER_OUTPUT": "خروجی رمزگذار صوت",
"AUDIO_RECORD": "ضبط صوت",
"BACKGROUND_REMOVAL": "حذف پس‌زمینه",
"BOOLEAN": "بولی",
"BOUNDING_BOX": "BOUNDING_BOX",
"CAMERA_CONTROL": "کنترل دوربین",
@@ -2285,13 +2282,15 @@
"Vidu": "Vidu",
"Wan": "Wan",
"WaveSpeed": "WaveSpeed",
"_for_testing": "_for_testing",
"advanced": "پیشرفته",
"animation": "انیمیشن",
"api": "API",
"api node": "گره API",
"attention_experiments": "آزمایش‌های توجه",
"audio": "صدا",
"background removal": "حذف پس‌زمینه",
"batch": "دسته‌ای",
"camera": "دوربین",
"chroma_radiance": "درخشندگی رنگی",
"clip": "clip",
"color": "رنگ",
@@ -2300,6 +2299,7 @@
"cond pair": "جفت شرط",
"cond single": "شرط تکی",
"conditioning": "شرط‌گذاری",
"context": "زمینه",
"controlnet": "controlnet",
"create": "ایجاد",
"custom_sampling": "نمونه‌گیری سفارشی",
@@ -2308,7 +2308,6 @@
"deprecated": "منسوخ",
"detection": "شناسایی",
"edit_models": "ویرایش مدل‌ها",
"experimental": "آزمایشی",
"flux": "flux",
"gligen": "gligen",
"guidance": "راهنمایی",
@@ -2324,6 +2323,7 @@
"lotus": "lotus",
"ltxv": "ltxv",
"mask": "ماسک",
"math": "ریاضی",
"model": "مدل",
"model_merging": "ادغام مدل",
"model_patches": "وصله‌های مدل",
@@ -2340,6 +2340,7 @@
"save": "ذخیره",
"schedulers": "زمان‌بندی‌ها",
"scheduling": "زمان‌بندی",
"sd": "sd",
"sd3": "sd3",
"shader": "shader",
"sigmas": "سیگماها",
@@ -2347,6 +2348,7 @@
"style_model": "مدل سبک",
"supir": "supir",
"text": "متن",
"textgen": "textgen",
"training": "آموزش",
"transform": "تبدیل",
"unet": "unet",
@@ -3162,7 +3164,6 @@
"deleteFailedTitle": "حذف ناموفق بود",
"deleted": "Workflow حذف شد",
"dirtyClose": "فایل‌های زیر تغییر کرده‌اند. آیا مایل به ذخیره آن‌ها قبل از بستن هستید؟",
"dirtyCloseAnyway": "بستن به هر حال",
"dirtyCloseHint": "برای بستن بدون پیام، Shift را نگه دارید",
"dirtyCloseTitle": "ذخیره تغییرات؟",
"workflowTreeType": {

Some files were not shown because too many files have changed in this diff Show More