mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-07-17 17:28:58 +00:00
Compare commits
23 Commits
codex/veri
...
v1.47.9
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3a7ea05851 | ||
|
|
6c5f9a17c4 | ||
|
|
2912e167d3 | ||
|
|
d677cc6404 | ||
|
|
b0f2595efe | ||
|
|
6ec75d6985 | ||
|
|
7cc27b07fa | ||
|
|
51ea158b15 | ||
|
|
0cb2b61ece | ||
|
|
8529adfeb1 | ||
|
|
5374aa7ee1 | ||
|
|
c7fe6a23ec | ||
|
|
df9b5bfa0a | ||
|
|
a6b7ce11aa | ||
|
|
d3b100be8d | ||
|
|
54b0c10148 | ||
|
|
2b540a5281 | ||
|
|
51156c5503 | ||
|
|
e11f98b91f | ||
|
|
bab2a22428 | ||
|
|
1efe8d9da5 | ||
|
|
7b1cc3498d | ||
|
|
b30cedffec |
@@ -63,3 +63,14 @@ reviews:
|
||||
Pass if none of these patterns are found in the diff.
|
||||
|
||||
When warning, reference the specific ADR by number and link to `docs/adr/` for context. Frame findings as directional guidance since ADR 0003 and 0008 are in Proposed status.
|
||||
|
||||
path_instructions:
|
||||
- path: '**/*.test.ts'
|
||||
instructions: |
|
||||
Treat `.agents/checks/test-quality.md`, `docs/testing/README.md`, and `docs/guidance/vitest.md` as required review context for every changed Vitest test file.
|
||||
- path: 'src/lib/litegraph/**/*.test.ts'
|
||||
instructions: |
|
||||
Treat `.agents/checks/test-quality.md`, `docs/testing/README.md`, `docs/guidance/vitest.md`, and `docs/testing/litegraph-testing.md` as required review context for every changed litegraph Vitest test file.
|
||||
- path: '{browser_tests,apps/website/e2e}/**/*.spec.ts'
|
||||
instructions: |
|
||||
Treat `.agents/checks/test-quality.md`, `docs/testing/README.md`, and `docs/guidance/playwright.md` as required review context for every changed Playwright test file.
|
||||
|
||||
@@ -23,6 +23,10 @@ on:
|
||||
required: false
|
||||
default: 'Comfy-Org/ComfyUI'
|
||||
type: string
|
||||
target_branch:
|
||||
description: 'Optional: force a specific release branch, e.g. core/1.47 or core/2.0. Overrides the pin-derived target — use to skip a dead minor or do an out-of-cadence / major release.'
|
||||
required: false
|
||||
type: string
|
||||
|
||||
jobs:
|
||||
check-release-week:
|
||||
@@ -49,12 +53,15 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: Summary
|
||||
env:
|
||||
TARGET_BRANCH_OVERRIDE: ${{ inputs.target_branch }}
|
||||
run: |
|
||||
echo "## Release Check" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- Is release week: ${{ steps.check.outputs.is_release_week }}" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- Manual trigger: ${{ github.event_name == 'workflow_dispatch' }}" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- Release type: ${{ inputs.release_type || 'minor (scheduled)' }}" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- Target branch override: ${TARGET_BRANCH_OVERRIDE:-(none — pin-derived)}" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
resolve-version:
|
||||
needs: check-release-week
|
||||
@@ -103,6 +110,7 @@ jobs:
|
||||
working-directory: frontend
|
||||
env:
|
||||
RELEASE_TYPE: ${{ inputs.release_type || 'minor' }}
|
||||
TARGET_BRANCH: ${{ inputs.target_branch }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
|
||||
@@ -86,6 +86,7 @@ const companyColumn: { title: string; links: FooterLink[] } = {
|
||||
{ label: t('footer.about', locale), href: routes.about },
|
||||
{ label: t('nav.careers', locale), href: routes.careers },
|
||||
{ label: t('footer.termsOfService', locale), href: routes.termsOfService },
|
||||
{ label: t('footer.enterpriseMsa', locale), href: routes.enterpriseMsa },
|
||||
{ label: t('footer.privacyPolicy', locale), href: routes.privacyPolicy }
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import type { Locale, TranslationKey } from '../../i18n/translations'
|
||||
|
||||
import { localizeHref } from '../../config/routes'
|
||||
import { t } from '../../i18n/translations'
|
||||
|
||||
const {
|
||||
@@ -15,8 +16,7 @@ const {
|
||||
locale?: Locale
|
||||
}>()
|
||||
|
||||
const localePrefix = locale === 'en' ? '' : `/${locale}`
|
||||
const nextHref = `${localePrefix}/demos/${nextSlug}`
|
||||
const nextHref = localizeHref(`/demos/${nextSlug}`, locale)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { getRoutes } from '../../config/routes'
|
||||
import { hasKey, translationKeys } from '../../i18n/translations'
|
||||
|
||||
const PREFIX = 'enterprise-msa'
|
||||
|
||||
function deriveMsaSectionIds(): string[] {
|
||||
const labelRegex = new RegExp(`^${PREFIX}\\.([0-9]+-[a-z-]+)\\.label$`)
|
||||
const ids: string[] = []
|
||||
for (const key of translationKeys) {
|
||||
const match = key.match(labelRegex)
|
||||
if (match && !ids.includes(match[1])) ids.push(match[1])
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
describe('enterprise MSA i18n', () => {
|
||||
it('every derived section has a title and at least one block', () => {
|
||||
const sectionIds = deriveMsaSectionIds()
|
||||
expect(sectionIds.length).toBeGreaterThan(0)
|
||||
for (const id of sectionIds) {
|
||||
expect(hasKey(`${PREFIX}.${id}.title`)).toBe(true)
|
||||
expect(hasKey(`${PREFIX}.${id}.block.0`)).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
it('exposes the page-chrome keys the .astro file references', () => {
|
||||
for (const suffix of [
|
||||
'effective-date',
|
||||
'page.title',
|
||||
'page.description',
|
||||
'page.heading',
|
||||
'page.tocLabel',
|
||||
'page.effectiveDateLabel',
|
||||
'page.parties'
|
||||
]) {
|
||||
expect(hasKey(`${PREFIX}.${suffix}`)).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
it('serves the enterprise MSA at the canonical /enterprise-msa path regardless of locale', () => {
|
||||
expect(getRoutes('en').enterpriseMsa).toBe('/enterprise-msa')
|
||||
expect(getRoutes('zh-CN').enterpriseMsa).toBe('/enterprise-msa')
|
||||
})
|
||||
})
|
||||
@@ -1,7 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
import { Check, Copy } from '@lucide/vue'
|
||||
import { useClipboard } from '@vueuse/core'
|
||||
|
||||
import { computed } from 'vue'
|
||||
|
||||
// Interactive: the copy button is inert until its host island is hydrated.
|
||||
// Render under a `client:*` directive (e.g. `client:visible`) when the page
|
||||
// needs it to work.
|
||||
@@ -11,6 +14,8 @@ const {
|
||||
copiedLabel = 'Copied'
|
||||
} = defineProps<{ value: string; copyLabel?: string; copiedLabel?: string }>()
|
||||
|
||||
const multiline = computed(() => value.includes('\n'))
|
||||
|
||||
const { copy, copied } = useClipboard({ copiedDuring: 2000 })
|
||||
|
||||
function handleCopy() {
|
||||
@@ -20,15 +25,32 @@ function handleCopy() {
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="bg-transparency-white-t4 border-primary-warm-gray flex items-center gap-2 rounded-xl border px-4 py-3"
|
||||
:class="
|
||||
cn(
|
||||
'bg-transparency-white-t4 border-primary-warm-gray flex gap-2 rounded-xl border px-4 py-3',
|
||||
multiline ? 'items-start' : 'items-center'
|
||||
)
|
||||
"
|
||||
>
|
||||
<span class="flex-1 truncate font-mono text-xs text-primary-comfy-canvas">
|
||||
<span
|
||||
:class="
|
||||
cn(
|
||||
'flex-1 font-mono text-xs text-primary-comfy-canvas',
|
||||
multiline ? 'wrap-break-word whitespace-pre-line' : 'truncate'
|
||||
)
|
||||
"
|
||||
>
|
||||
{{ value }}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
:aria-label="copied ? copiedLabel : copyLabel"
|
||||
class="text-primary-warm-gray shrink-0 cursor-pointer transition-colors hover:text-primary-comfy-canvas"
|
||||
:class="
|
||||
cn(
|
||||
'text-primary-warm-gray shrink-0 cursor-pointer transition-colors hover:text-primary-comfy-canvas',
|
||||
multiline && 'mt-0.5'
|
||||
)
|
||||
"
|
||||
@click="handleCopy"
|
||||
>
|
||||
<component :is="copied ? Check : Copy" class="size-4" />
|
||||
|
||||
31
apps/website/src/composables/useCurrentPath.test.ts
Normal file
31
apps/website/src/composables/useCurrentPath.test.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { isHrefActive } from './useCurrentPath'
|
||||
|
||||
describe('isHrefActive', () => {
|
||||
it('matches the current page', () => {
|
||||
expect(isHrefActive('/mcp', '/mcp')).toBe(true)
|
||||
})
|
||||
|
||||
it('does not match other pages', () => {
|
||||
expect(isHrefActive('/mcp', '/pricing')).toBe(false)
|
||||
})
|
||||
|
||||
it('matches regardless of a trailing slash', () => {
|
||||
expect(isHrefActive('/mcp', '/mcp/')).toBe(true)
|
||||
})
|
||||
|
||||
it('ignores query and hash on the href', () => {
|
||||
expect(isHrefActive('/mcp?ref=banner#setup', '/mcp')).toBe(true)
|
||||
})
|
||||
|
||||
it('never matches an external href', () => {
|
||||
expect(
|
||||
isHrefActive('https://docs.comfy.org/agent-tools/cloud', '/mcp')
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('never matches an empty href', () => {
|
||||
expect(isHrefActive('', '/mcp')).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -3,6 +3,7 @@ import type { Locale, TranslationKey } from '../i18n/translations'
|
||||
|
||||
import { t } from '../i18n/translations'
|
||||
import { resolveRel } from '../utils/cta'
|
||||
import { localizeHref } from './routes'
|
||||
|
||||
// The banner "CMS": a single typed config resolved through i18n at build time.
|
||||
// `isActive` is the master on/off switch (supersedes the old SHOW_ANNOUNCEMENT_BANNER).
|
||||
@@ -73,7 +74,7 @@ export function getBannerData(
|
||||
: undefined,
|
||||
link: link
|
||||
? {
|
||||
href: link.href,
|
||||
href: localizeHref(link.href, locale),
|
||||
title: t(link.titleKey, locale),
|
||||
target,
|
||||
rel: resolveRel({ target: target ?? '_self' }),
|
||||
|
||||
23
apps/website/src/config/routes.test.ts
Normal file
23
apps/website/src/config/routes.test.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { localizeHref } from './routes'
|
||||
|
||||
describe('localizeHref', () => {
|
||||
it('prefixes an internal path for a non-default locale', () => {
|
||||
expect(localizeHref('/mcp', 'zh-CN')).toBe('/zh-CN/mcp')
|
||||
})
|
||||
|
||||
it('leaves the default locale unprefixed', () => {
|
||||
expect(localizeHref('/mcp', 'en')).toBe('/mcp')
|
||||
})
|
||||
|
||||
it('passes external URLs through unchanged', () => {
|
||||
expect(
|
||||
localizeHref('https://docs.comfy.org/agent-tools/cloud', 'zh-CN')
|
||||
).toBe('https://docs.comfy.org/agent-tools/cloud')
|
||||
})
|
||||
|
||||
it('never prefixes locale-invariant routes', () => {
|
||||
expect(localizeHref('/terms-of-service', 'zh-CN')).toBe('/terms-of-service')
|
||||
})
|
||||
})
|
||||
@@ -15,6 +15,7 @@ const baseRoutes = {
|
||||
demos: '/demos',
|
||||
learning: '/learning',
|
||||
termsOfService: '/terms-of-service',
|
||||
enterpriseMsa: '/enterprise-msa',
|
||||
privacyPolicy: '/privacy-policy',
|
||||
affiliates: '/affiliates',
|
||||
affiliateTerms: '/affiliates/terms',
|
||||
@@ -35,19 +36,37 @@ type Routes = typeof baseRoutes
|
||||
// block in src/i18n/translations.ts for the reasoning.
|
||||
//
|
||||
// termsOfService: legal-reviewed English-only document, same reasoning.
|
||||
//
|
||||
// enterpriseMsa: legal-reviewed English-only document (Comfy Enterprise
|
||||
// Customer Agreement template), same reasoning. See the comment header
|
||||
// in src/pages/enterprise-msa.astro.
|
||||
const LOCALE_INVARIANT_ROUTE_KEYS = new Set<keyof Routes>([
|
||||
'affiliates',
|
||||
'affiliateTerms',
|
||||
'termsOfService'
|
||||
'termsOfService',
|
||||
'enterpriseMsa'
|
||||
])
|
||||
|
||||
const LOCALE_INVARIANT_PATHS = new Set<string>(
|
||||
[...LOCALE_INVARIANT_ROUTE_KEYS].map((key) => baseRoutes[key])
|
||||
)
|
||||
|
||||
/**
|
||||
* Prefix an internal path with the locale (`/mcp` → `/zh-CN/mcp`). External
|
||||
* URLs and locale-invariant routes pass through unchanged.
|
||||
*/
|
||||
export function localizeHref(href: string, locale: Locale = 'en'): string {
|
||||
if (locale === 'en' || !href.startsWith('/')) return href
|
||||
if (LOCALE_INVARIANT_PATHS.has(href)) return href
|
||||
return `/${locale}${href}`
|
||||
}
|
||||
|
||||
export function getRoutes(locale: Locale = 'en'): Routes {
|
||||
if (locale === 'en') return baseRoutes
|
||||
const prefix = `/${locale}`
|
||||
return Object.fromEntries(
|
||||
Object.entries(baseRoutes).map(([k, v]) => [
|
||||
k,
|
||||
LOCALE_INVARIANT_ROUTE_KEYS.has(k as keyof Routes) ? v : `${prefix}${v}`
|
||||
Object.entries(baseRoutes).map(([key, path]) => [
|
||||
key,
|
||||
localizeHref(path, locale)
|
||||
])
|
||||
) as unknown as Routes
|
||||
}
|
||||
@@ -60,13 +79,12 @@ export const externalLinks = {
|
||||
cloudStatus: 'https://status.comfy.org',
|
||||
discord: 'https://discord.com/invite/comfyorg',
|
||||
docs: 'https://docs.comfy.org/',
|
||||
docsApi: 'https://docs.comfy.org/api-reference/cloud',
|
||||
docsApi: 'https://docs.comfy.org/development/cloud/overview#quick-start',
|
||||
docsMcp: 'https://docs.comfy.org/agent-tools/cloud',
|
||||
docsSubscription: 'https://docs.comfy.org/support/subscription/subscribing',
|
||||
github: 'https://github.com/Comfy-Org/ComfyUI',
|
||||
githubInstall: 'https://github.com/Comfy-Org/ComfyUI#installing',
|
||||
instagram: 'https://www.instagram.com/comfyui/',
|
||||
mcpServer: 'https://cloud.comfy.org/mcp',
|
||||
mcpSkills: 'https://github.com/Comfy-Org/comfy-skills',
|
||||
platform: 'https://platform.comfy.org',
|
||||
platformUsage: 'https://platform.comfy.org/profile/usage',
|
||||
|
||||
@@ -72,6 +72,24 @@ export const drops: readonly Drop[] = [
|
||||
href: { en: '/download', 'zh-CN': '/zh-CN/download' }
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'comfy-mcp',
|
||||
badge: NEW_BADGE,
|
||||
category: CLOUD,
|
||||
media: imageFor('Drops_2x2card_MCP.jpg', {
|
||||
en: 'Comfy MCP',
|
||||
'zh-CN': 'Comfy MCP'
|
||||
}),
|
||||
title: { en: 'Comfy MCP', 'zh-CN': 'Comfy MCP' },
|
||||
description: {
|
||||
en: 'The full power of ComfyUI from anywhere — no setup, no GPU required.',
|
||||
'zh-CN': '随时随地体验 ComfyUI 的全部能力 — 无需配置,无需 GPU。'
|
||||
},
|
||||
cta: {
|
||||
label: EXPLORE,
|
||||
href: { en: '/mcp', 'zh-CN': '/zh-CN/mcp' }
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'app-mode',
|
||||
badge: NEW_BADGE,
|
||||
@@ -112,24 +130,6 @@ export const drops: readonly Drop[] = [
|
||||
href: { en: '/api', 'zh-CN': '/zh-CN/api' }
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'comfy-mcp',
|
||||
badge: NEW_BADGE,
|
||||
category: CLOUD,
|
||||
media: imageFor('Drops_2x2card_MCP.jpg', {
|
||||
en: 'Comfy MCP',
|
||||
'zh-CN': 'Comfy MCP'
|
||||
}),
|
||||
title: { en: 'Comfy MCP', 'zh-CN': 'Comfy MCP' },
|
||||
description: {
|
||||
en: 'The full power of ComfyUI from anywhere — no setup, no GPU required.',
|
||||
'zh-CN': '随时随地体验 ComfyUI 的全部能力 — 无需配置,无需 GPU。'
|
||||
},
|
||||
cta: {
|
||||
label: EXPLORE,
|
||||
href: { en: '/mcp', 'zh-CN': '/zh-CN/mcp' }
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'community-workflows',
|
||||
category: COMMUNITY,
|
||||
|
||||
@@ -1872,6 +1872,10 @@ const translations = {
|
||||
en: 'VIEW DOCS',
|
||||
'zh-CN': '查看文档'
|
||||
},
|
||||
'mcp.hero.installMcp': {
|
||||
en: 'INSTALL MCP',
|
||||
'zh-CN': '安装 MCP'
|
||||
},
|
||||
'mcp.hero.runWorkflow': {
|
||||
en: 'RUN A WORKFLOW',
|
||||
'zh-CN': '运行工作流'
|
||||
@@ -1909,21 +1913,27 @@ const translations = {
|
||||
},
|
||||
'mcp.setup.step1.label': { en: 'STEP 1', 'zh-CN': '第 1 步' },
|
||||
'mcp.setup.step1.title': {
|
||||
en: 'Copy the MCP URL',
|
||||
'zh-CN': '复制 MCP URL'
|
||||
en: 'Ask your agent to install Comfy MCP',
|
||||
'zh-CN': '让你的智能体安装 Comfy MCP'
|
||||
},
|
||||
'mcp.setup.step1.command': {
|
||||
en: 'Help me install Comfy MCP.\nFollow the setup guide at {url}',
|
||||
'zh-CN': '帮我安装 Comfy MCP。\n请按照 {url} 上的设置指南操作。'
|
||||
},
|
||||
'mcp.setup.step1.description': {
|
||||
en: "Click the copy button below. You'll paste it into your client in the next step.",
|
||||
'zh-CN': '点击下方的复制按钮,下一步将其粘贴到你的客户端中。'
|
||||
en: 'Paste this into Claude, Cursor, Codex, or any MCP-compatible agent. It reads the docs and adds the connector for you.',
|
||||
'zh-CN':
|
||||
'将它粘贴到 Claude、Cursor、Codex 或任意兼容 MCP 的智能体中。它会读取文档并为你添加连接器。'
|
||||
},
|
||||
'mcp.setup.step2.label': { en: 'STEP 2', 'zh-CN': '第 2 步' },
|
||||
'mcp.setup.step2.title': {
|
||||
en: 'Add the connector',
|
||||
'zh-CN': '添加连接器'
|
||||
en: 'Or add it by hand',
|
||||
'zh-CN': '或手动添加'
|
||||
},
|
||||
'mcp.setup.step2.description': {
|
||||
en: 'Name it Comfy Cloud and paste the URL. The docs below cover every client.',
|
||||
'zh-CN': '将其命名为 Comfy Cloud 并粘贴 URL。下方文档涵盖各类客户端。'
|
||||
en: 'Prefer manual setup? Add Comfy Cloud as a custom connector with the MCP URL. The docs cover every client.',
|
||||
'zh-CN':
|
||||
'想手动配置?用 MCP URL 将 Comfy Cloud 添加为自定义连接器。文档涵盖各类客户端。'
|
||||
},
|
||||
'mcp.setup.step2.cta': {
|
||||
en: 'COMFY CLOUD MCP DOCS',
|
||||
@@ -3486,6 +3496,429 @@ const translations = {
|
||||
'zh-CN': '生效日期'
|
||||
},
|
||||
|
||||
// ── Enterprise MSA ─────────────────────────────────────────────────
|
||||
// English-only, by design. This is a legal-reviewed customer-facing
|
||||
// template. Serving a translated variant would expose Comfy to
|
||||
// liability from the translation diverging from the approved English
|
||||
// source. See the matching header comment in
|
||||
// src/pages/enterprise-msa.astro and the LOCALE_INVARIANT_ROUTE_KEYS
|
||||
// entry in src/config/routes.ts.
|
||||
'enterprise-msa.effective-date': {
|
||||
en: 'May 22, 2026',
|
||||
'zh-CN': 'May 22, 2026'
|
||||
},
|
||||
'enterprise-msa.1-definitions.label': {
|
||||
en: 'DEFINITIONS',
|
||||
'zh-CN': 'DEFINITIONS'
|
||||
},
|
||||
'enterprise-msa.1-definitions.title': {
|
||||
en: '1. Definitions',
|
||||
'zh-CN': '1. Definitions'
|
||||
},
|
||||
'enterprise-msa.1-definitions.block.0': {
|
||||
en: '<strong>“Affiliates”</strong> means any entity that directly or indirectly controls, is controlled by, or is under common control with a party, where “control” means the ownership of more than fifty percent (50%) of the voting securities or other voting interests of such entity.',
|
||||
'zh-CN':
|
||||
'<strong>“Affiliates”</strong> means any entity that directly or indirectly controls, is controlled by, or is under common control with a party, where “control” means the ownership of more than fifty percent (50%) of the voting securities or other voting interests of such entity.'
|
||||
},
|
||||
'enterprise-msa.1-definitions.block.1': {
|
||||
en: '<strong>“Applicable Laws”</strong> means all federal and state laws, treaties, rules, regulations, regulatory and supervisory guidance, directives, policies, orders or determinations of a regulatory authority applicable to the activities and obligations contemplated under this Agreement.',
|
||||
'zh-CN':
|
||||
'<strong>“Applicable Laws”</strong> means all federal and state laws, treaties, rules, regulations, regulatory and supervisory guidance, directives, policies, orders or determinations of a regulatory authority applicable to the activities and obligations contemplated under this Agreement.'
|
||||
},
|
||||
'enterprise-msa.1-definitions.block.2': {
|
||||
en: '<strong>“Comfy API”</strong> means the application programming interface and related developer tools made available by Comfy that allows Customer to access and execute visual AI workflows programmatically as production endpoints from within Customer’s own applications or systems.',
|
||||
'zh-CN':
|
||||
'<strong>“Comfy API”</strong> means the application programming interface and related developer tools made available by Comfy that allows Customer to access and execute visual AI workflows programmatically as production endpoints from within Customer’s own applications or systems.'
|
||||
},
|
||||
'enterprise-msa.1-definitions.block.3': {
|
||||
en: '<strong>“Comfy Branding”</strong> means the names, logos, and associated trademarks owned or in progress of being owned by Comfy.',
|
||||
'zh-CN':
|
||||
'<strong>“Comfy Branding”</strong> means the names, logos, and associated trademarks owned or in progress of being owned by Comfy.'
|
||||
},
|
||||
'enterprise-msa.1-definitions.block.4': {
|
||||
en: '<strong>“Comfy Cloud”</strong> means the cloud-based hosting environment made available by Comfy that allows Customer to access and run visual AI workflows remotely through Comfy’s infrastructure, without requiring local installation or hardware.',
|
||||
'zh-CN':
|
||||
'<strong>“Comfy Cloud”</strong> means the cloud-based hosting environment made available by Comfy that allows Customer to access and run visual AI workflows remotely through Comfy’s infrastructure, without requiring local installation or hardware.'
|
||||
},
|
||||
'enterprise-msa.1-definitions.block.5': {
|
||||
en: '<strong>“Comfy Enterprise”</strong> means the enterprise-grade product tier made available by Comfy that provides organizations with dedicated infrastructure, enhanced security, administrative controls, and related support services for deploying and managing visual AI workflows at scale.',
|
||||
'zh-CN':
|
||||
'<strong>“Comfy Enterprise”</strong> means the enterprise-grade product tier made available by Comfy that provides organizations with dedicated infrastructure, enhanced security, administrative controls, and related support services for deploying and managing visual AI workflows at scale.'
|
||||
},
|
||||
'enterprise-msa.1-definitions.block.6': {
|
||||
en: '<strong>“Comfy OSS”</strong> means the open-source software, source code, libraries, tools, and related components made available by Comfy under one or more open source licenses, including the software repositories published by Comfy at <a href="https://github.com/Comfy-Org" class="text-white underline">https://github.com/Comfy-Org</a>, as updated, modified, or supplemented from time to time. For the avoidance of doubt, Comfy OSS does not include any proprietary software, infrastructure, or functionality made available by Comfy under this Agreement or in connection with any commercial product or offering.',
|
||||
'zh-CN':
|
||||
'<strong>“Comfy OSS”</strong> means the open-source software, source code, libraries, tools, and related components made available by Comfy under one or more open source licenses, including the software repositories published by Comfy at <a href="https://github.com/Comfy-Org" class="text-white underline">https://github.com/Comfy-Org</a>, as updated, modified, or supplemented from time to time. For the avoidance of doubt, Comfy OSS does not include any proprietary software, infrastructure, or functionality made available by Comfy under this Agreement or in connection with any commercial product or offering.'
|
||||
},
|
||||
'enterprise-msa.1-definitions.block.7': {
|
||||
en: '<strong>“Comfy Products”</strong> means Comfy Cloud, Comfy API, Comfy Enterprise and other products, software, features, tools, and functionality made available by Comfy to Customer under this Agreement, excluding any Comfy OSS.',
|
||||
'zh-CN':
|
||||
'<strong>“Comfy Products”</strong> means Comfy Cloud, Comfy API, Comfy Enterprise and other products, software, features, tools, and functionality made available by Comfy to Customer under this Agreement, excluding any Comfy OSS.'
|
||||
},
|
||||
'enterprise-msa.1-definitions.block.8': {
|
||||
en: '<strong>“Customer Data”</strong> means electronic data and information submitted or generated by Customer in connection with its use of the Comfy Products, including all Inputs and Outputs.',
|
||||
'zh-CN':
|
||||
'<strong>“Customer Data”</strong> means electronic data and information submitted or generated by Customer in connection with its use of the Comfy Products, including all Inputs and Outputs.'
|
||||
},
|
||||
'enterprise-msa.1-definitions.block.9': {
|
||||
en: '<strong>“Open Source License”</strong> means the open source license(s) under which Comfy makes Comfy OSS available, as identified in the applicable source code repository.',
|
||||
'zh-CN':
|
||||
'<strong>“Open Source License”</strong> means the open source license(s) under which Comfy makes Comfy OSS available, as identified in the applicable source code repository.'
|
||||
},
|
||||
'enterprise-msa.1-definitions.block.10': {
|
||||
en: '<strong>“Operational Metadata”</strong> means usage and diagnostic information generated by the Comfy Products and collected by Comfy to support, maintain, and optimize the performance and security of the Comfy Products, including information regarding software versions, system configuration, uptime, error logs, health metrics, and feature usage. Operational Metadata does not include Customer Data or Confidential Information.',
|
||||
'zh-CN':
|
||||
'<strong>“Operational Metadata”</strong> means usage and diagnostic information generated by the Comfy Products and collected by Comfy to support, maintain, and optimize the performance and security of the Comfy Products, including information regarding software versions, system configuration, uptime, error logs, health metrics, and feature usage. Operational Metadata does not include Customer Data or Confidential Information.'
|
||||
},
|
||||
'enterprise-msa.1-definitions.block.11': {
|
||||
en: '<strong>“Order Form”</strong> means the online sign-up flow, order form or other ordering document entered into or otherwise agreed by Customer that references this Agreement. The initial Order Form is attached as Exhibit A.',
|
||||
'zh-CN':
|
||||
'<strong>“Order Form”</strong> means the online sign-up flow, order form or other ordering document entered into or otherwise agreed by Customer that references this Agreement. The initial Order Form is attached as Exhibit A.'
|
||||
},
|
||||
'enterprise-msa.1-definitions.block.12': {
|
||||
en: '<strong>“User”</strong> means Customer’s or Customer’s Affiliates’ employees and contractors who are authorized by Customer to access and use the Comfy Products on Customer’s or Customer’s Affiliates’ behalf according to the terms of this Agreement.',
|
||||
'zh-CN':
|
||||
'<strong>“User”</strong> means Customer’s or Customer’s Affiliates’ employees and contractors who are authorized by Customer to access and use the Comfy Products on Customer’s or Customer’s Affiliates’ behalf according to the terms of this Agreement.'
|
||||
},
|
||||
'enterprise-msa.2-comfy-products.label': {
|
||||
en: 'PRODUCTS',
|
||||
'zh-CN': 'PRODUCTS'
|
||||
},
|
||||
'enterprise-msa.2-comfy-products.title': {
|
||||
en: '2. Comfy Products',
|
||||
'zh-CN': '2. Comfy Products'
|
||||
},
|
||||
'enterprise-msa.2-comfy-products.block.0': {
|
||||
en: '<strong>Right to Access and Use Comfy Products.</strong> Subject to Customer’s compliance with all of the terms and conditions of this Agreement, Comfy grants Customer and Customer’s Users a non-exclusive, non-sublicensable, non-transferable right during the term of this Agreement to access and use the Comfy Products as set forth in the applicable Order Form for Customer’s internal business purposes.',
|
||||
'zh-CN':
|
||||
'<strong>Right to Access and Use Comfy Products.</strong> Subject to Customer’s compliance with all of the terms and conditions of this Agreement, Comfy grants Customer and Customer’s Users a non-exclusive, non-sublicensable, non-transferable right during the term of this Agreement to access and use the Comfy Products as set forth in the applicable Order Form for Customer’s internal business purposes.'
|
||||
},
|
||||
'enterprise-msa.2-comfy-products.block.1': {
|
||||
en: '<strong>Customer Data.</strong> As between Comfy and Customer, Customer retains all right, title, and interest in and to any data, images, videos, prompts, models, workflows, nodes, parameters, or other materials submitted or uploaded by Customer to the Comfy Products (“Input”), as well as any images, videos, designs, or other visual content generated through Customer’s use of the Comfy Products as a result of processing Customer’s Input (“Output”). Customer acknowledges that due to the nature of artificial intelligence, Comfy may generate the same or similar Output for other customers, and Customer shall have no right, title, or interest in or to Output generated for any other customer.',
|
||||
'zh-CN':
|
||||
'<strong>Customer Data.</strong> As between Comfy and Customer, Customer retains all right, title, and interest in and to any data, images, videos, prompts, models, workflows, nodes, parameters, or other materials submitted or uploaded by Customer to the Comfy Products (“Input”), as well as any images, videos, designs, or other visual content generated through Customer’s use of the Comfy Products as a result of processing Customer’s Input (“Output”). Customer acknowledges that due to the nature of artificial intelligence, Comfy may generate the same or similar Output for other customers, and Customer shall have no right, title, or interest in or to Output generated for any other customer.'
|
||||
},
|
||||
'enterprise-msa.2-comfy-products.block.2': {
|
||||
en: '<strong>No AI Training.</strong> Comfy will not use Input or Output to train generative AI or diffusion models. Comfy may, however, collect and use limited metadata derived from Customer’s use of the Comfy Products, such as prompt classifications, workflow structures, and node configurations, to improve the performance, functionality, and user experience of the Comfy Products.',
|
||||
'zh-CN':
|
||||
'<strong>No AI Training.</strong> Comfy will not use Input or Output to train generative AI or diffusion models. Comfy may, however, collect and use limited metadata derived from Customer’s use of the Comfy Products, such as prompt classifications, workflow structures, and node configurations, to improve the performance, functionality, and user experience of the Comfy Products.'
|
||||
},
|
||||
'enterprise-msa.2-comfy-products.block.3': {
|
||||
en: '<strong>Comfy OSS.</strong> Customer may use Comfy OSS under the terms of the applicable Open Source License(s) governing each respective component, as identified in the corresponding source code repository, rather than under this Agreement. Nothing in this Agreement shall be construed to limit, supersede, or modify any rights or obligations arising under an applicable Open Source License. If Customer chooses to use the Comfy Products in conjunction with Comfy OSS, this Agreement applies solely to Customer’s use of the Comfy Products and not to the Comfy OSS itself.',
|
||||
'zh-CN':
|
||||
'<strong>Comfy OSS.</strong> Customer may use Comfy OSS under the terms of the applicable Open Source License(s) governing each respective component, as identified in the corresponding source code repository, rather than under this Agreement. Nothing in this Agreement shall be construed to limit, supersede, or modify any rights or obligations arising under an applicable Open Source License. If Customer chooses to use the Comfy Products in conjunction with Comfy OSS, this Agreement applies solely to Customer’s use of the Comfy Products and not to the Comfy OSS itself.'
|
||||
},
|
||||
'enterprise-msa.2-comfy-products.block.4': {
|
||||
en: '<strong>Partner Nodes.</strong> Certain features of the Comfy Products allow Customer to access third-party AI model providers (“Partner Nodes”) through Comfy. When Customer uses a Partner Node, Comfy proxies Customer’s request to the applicable third-party provider, transmitting the information necessary to fulfill Customer’s request, including prompts, images, models, and parameters. Comfy does not transmit Customer’s identity or account information to third-party providers in connection with Partner Node requests. Customer’s use of Partner Nodes is subject to the terms and policies of the applicable third-party provider, and Comfy is not responsible for the data practices of such providers. Usage of Partner Nodes is metered and billed through Comfy.',
|
||||
'zh-CN':
|
||||
'<strong>Partner Nodes.</strong> Certain features of the Comfy Products allow Customer to access third-party AI model providers (“Partner Nodes”) through Comfy. When Customer uses a Partner Node, Comfy proxies Customer’s request to the applicable third-party provider, transmitting the information necessary to fulfill Customer’s request, including prompts, images, models, and parameters. Comfy does not transmit Customer’s identity or account information to third-party providers in connection with Partner Node requests. Customer’s use of Partner Nodes is subject to the terms and policies of the applicable third-party provider, and Comfy is not responsible for the data practices of such providers. Usage of Partner Nodes is metered and billed through Comfy.'
|
||||
},
|
||||
'enterprise-msa.2-comfy-products.block.5': {
|
||||
en: '<strong>Modification of Comfy Products.</strong> Comfy may, at any time and in its sole discretion, modify, update, enhance, restrict, suspend, or discontinue the Comfy Products, in whole or in part, including by changing or removing features, functionality, endpoints, specifications, documentation, access methods, usage limits, or availability. Comfy has no obligation to maintain or support any particular version of the Comfy Products or to ensure backward compatibility. Any such modifications may be made with or without notice and may result in interruptions to or degradation of the Comfy Products. Comfy shall have no liability arising out of or related to any modification, suspension, or discontinuation of the Comfy Products, and Customer acknowledges that its use of the Comfy Products is at its own risk and that it should not rely on the continued availability of any aspect of the Comfy Products.',
|
||||
'zh-CN':
|
||||
'<strong>Modification of Comfy Products.</strong> Comfy may, at any time and in its sole discretion, modify, update, enhance, restrict, suspend, or discontinue the Comfy Products, in whole or in part, including by changing or removing features, functionality, endpoints, specifications, documentation, access methods, usage limits, or availability. Comfy has no obligation to maintain or support any particular version of the Comfy Products or to ensure backward compatibility. Any such modifications may be made with or without notice and may result in interruptions to or degradation of the Comfy Products. Comfy shall have no liability arising out of or related to any modification, suspension, or discontinuation of the Comfy Products, and Customer acknowledges that its use of the Comfy Products is at its own risk and that it should not rely on the continued availability of any aspect of the Comfy Products.'
|
||||
},
|
||||
'enterprise-msa.2-comfy-products.block.6': {
|
||||
en: '<strong>Data Retention and Deletion.</strong> Comfy retains Customer Data for as long as Customer’s account remains active or as otherwise necessary to provide the Comfy Products, comply with applicable legal obligations, resolve disputes, and enforce this Agreement. Specific retention periods for different categories of Customer Data are set forth in Comfy’s retention documentation, available at <a href="https://docs.comfy.org/support/data-retention" class="text-white underline">docs.comfy.org/support/data-retention</a>, as updated from time to time. Customer may request deletion of Customer’s account and associated Customer Data by contacting Comfy at <a href="mailto:legal@comfy.org" class="text-white underline">legal@comfy.org</a>. Upon receipt of a verified deletion request, Comfy will use commercially reasonable efforts to delete or de-identify Customer’s personal information from its primary systems within a reasonable time. Customer acknowledges that: (i) deletion may not propagate immediately to all backup systems, third-party analytics providers, or observability systems, which retain data subject to their own retention policies; (ii) certain Customer Data may be retained as required by applicable law or for legitimate business purposes such as billing records; and (iii) aggregated or de-identified data derived from Customer’s use of the Comfy Products may be retained indefinitely.',
|
||||
'zh-CN':
|
||||
'<strong>Data Retention and Deletion.</strong> Comfy retains Customer Data for as long as Customer’s account remains active or as otherwise necessary to provide the Comfy Products, comply with applicable legal obligations, resolve disputes, and enforce this Agreement. Specific retention periods for different categories of Customer Data are set forth in Comfy’s retention documentation, available at <a href="https://docs.comfy.org/support/data-retention" class="text-white underline">docs.comfy.org/support/data-retention</a>, as updated from time to time. Customer may request deletion of Customer’s account and associated Customer Data by contacting Comfy at <a href="mailto:legal@comfy.org" class="text-white underline">legal@comfy.org</a>. Upon receipt of a verified deletion request, Comfy will use commercially reasonable efforts to delete or de-identify Customer’s personal information from its primary systems within a reasonable time. Customer acknowledges that: (i) deletion may not propagate immediately to all backup systems, third-party analytics providers, or observability systems, which retain data subject to their own retention policies; (ii) certain Customer Data may be retained as required by applicable law or for legitimate business purposes such as billing records; and (iii) aggregated or de-identified data derived from Customer’s use of the Comfy Products may be retained indefinitely.'
|
||||
},
|
||||
'enterprise-msa.3-customer-responsibilities.label': {
|
||||
en: 'CUSTOMER',
|
||||
'zh-CN': 'CUSTOMER'
|
||||
},
|
||||
'enterprise-msa.3-customer-responsibilities.title': {
|
||||
en: '3. Customer Responsibilities',
|
||||
'zh-CN': '3. Customer Responsibilities'
|
||||
},
|
||||
'enterprise-msa.3-customer-responsibilities.block.0': {
|
||||
en: '<strong>Registration.</strong> To access and use the Comfy Products, Customer may be required to register one or more accounts by providing Comfy with the information specified in the applicable registration form, including Customer’s email address. Customer shall ensure that all registration information provided to Comfy is complete and accurate, and shall promptly update such information as necessary to keep it current. Customer shall be liable for all activities conducted through its account, including any unauthorized access or use resulting from Customer’s failure to implement reasonable access controls or to limit access to its systems and devices.',
|
||||
'zh-CN':
|
||||
'<strong>Registration.</strong> To access and use the Comfy Products, Customer may be required to register one or more accounts by providing Comfy with the information specified in the applicable registration form, including Customer’s email address. Customer shall ensure that all registration information provided to Comfy is complete and accurate, and shall promptly update such information as necessary to keep it current. Customer shall be liable for all activities conducted through its account, including any unauthorized access or use resulting from Customer’s failure to implement reasonable access controls or to limit access to its systems and devices.'
|
||||
},
|
||||
'enterprise-msa.3-customer-responsibilities.block.1': {
|
||||
en: '<strong>General Technology Restrictions.</strong> Customer agrees that it will not, directly or indirectly: (i) sublicense the Comfy Products for use by a third party; (ii) reverse engineer or attempt to extract the source code or underlying methodology from the Comfy Products or any related software, except to the extent that this restriction is expressly prohibited by Applicable Laws; (iii) use or facilitate the use of the Comfy Products for any activities that are prohibited by Applicable Laws or otherwise; (iv) bypass or circumvent measures employed to prevent or limit access to the Comfy Products; (v) use the Comfy Products to create a product or service competitive with Comfy’s products or services; (vi) create derivative works of or otherwise create, attempt to create or derive, or knowingly assist any third party to create or derive, the source code underlying the Comfy Products; or (vii) otherwise use or interact with the Comfy Products for any purpose not expressly permitted under this Agreement.',
|
||||
'zh-CN':
|
||||
'<strong>General Technology Restrictions.</strong> Customer agrees that it will not, directly or indirectly: (i) sublicense the Comfy Products for use by a third party; (ii) reverse engineer or attempt to extract the source code or underlying methodology from the Comfy Products or any related software, except to the extent that this restriction is expressly prohibited by Applicable Laws; (iii) use or facilitate the use of the Comfy Products for any activities that are prohibited by Applicable Laws or otherwise; (iv) bypass or circumvent measures employed to prevent or limit access to the Comfy Products; (v) use the Comfy Products to create a product or service competitive with Comfy’s products or services; (vi) create derivative works of or otherwise create, attempt to create or derive, or knowingly assist any third party to create or derive, the source code underlying the Comfy Products; or (vii) otherwise use or interact with the Comfy Products for any purpose not expressly permitted under this Agreement.'
|
||||
},
|
||||
'enterprise-msa.3-customer-responsibilities.block.2': {
|
||||
en: '<strong>Acceptable Use; Prohibited Customer Data.</strong> Customer is solely responsible for ensuring that all Input submitted to the Comfy Products complies with all Applicable Laws, and Customer agrees that it will not, and will not permit any third party to submit to Comfy or the Comfy Products or otherwise use the Comfy Products to create: (i) any data, designs, or other materials subject to U.S. export control laws and regulations; (ii) any viruses, malware, ransomware, Trojan horses, worms, spyware, or other malicious or harmful code or content that could damage, disrupt, interfere with, or compromise the Comfy Products, Comfy’s systems or infrastructure, or the data or systems of any other user or third party; (iii) any Customer Data that depicts, promotes, or facilitates illegal activity, including without limitation child sexual abuse material, non-consensual intimate imagery, or content that incites violence or hatred against any individual or group; (iv) any Customer Data that infringes or misappropriates the intellectual property rights, privacy rights, or publicity rights of any third party, including without limitation by submitting models, images, or other materials without the right to do so; (v) any content or information that is intentionally deceptive or misleading, including without limitation synthetic media designed to impersonate a real individual without their consent; or (vi) any Customer Data that could reasonably be expected to cause harm to any individual or group.',
|
||||
'zh-CN':
|
||||
'<strong>Acceptable Use; Prohibited Customer Data.</strong> Customer is solely responsible for ensuring that all Input submitted to the Comfy Products complies with all Applicable Laws, and Customer agrees that it will not, and will not permit any third party to submit to Comfy or the Comfy Products or otherwise use the Comfy Products to create: (i) any data, designs, or other materials subject to U.S. export control laws and regulations; (ii) any viruses, malware, ransomware, Trojan horses, worms, spyware, or other malicious or harmful code or content that could damage, disrupt, interfere with, or compromise the Comfy Products, Comfy’s systems or infrastructure, or the data or systems of any other user or third party; (iii) any Customer Data that depicts, promotes, or facilitates illegal activity, including without limitation child sexual abuse material, non-consensual intimate imagery, or content that incites violence or hatred against any individual or group; (iv) any Customer Data that infringes or misappropriates the intellectual property rights, privacy rights, or publicity rights of any third party, including without limitation by submitting models, images, or other materials without the right to do so; (v) any content or information that is intentionally deceptive or misleading, including without limitation synthetic media designed to impersonate a real individual without their consent; or (vi) any Customer Data that could reasonably be expected to cause harm to any individual or group.'
|
||||
},
|
||||
'enterprise-msa.4-payment.label': {
|
||||
en: 'PAYMENT',
|
||||
'zh-CN': 'PAYMENT'
|
||||
},
|
||||
'enterprise-msa.4-payment.title': {
|
||||
en: '4. Payment',
|
||||
'zh-CN': '4. Payment'
|
||||
},
|
||||
'enterprise-msa.4-payment.block.0': {
|
||||
en: '<strong>Fees.</strong> Customer will pay Comfy the fees set forth in the applicable Order Form. Customer shall pay those amounts due and not disputed in good faith within seven (7) days of the date of receipt of the applicable invoice, unless a specific date for payment is set forth in such Order Form, in which case payment will be due on the date specified. Except as otherwise specified herein or in any applicable Order Form, (a) fees are quoted and payable in United States dollars and (b) payment obligations are non-cancelable and non-pro-ratable for partial months, and fees paid are non-refundable. Comfy reserves the right to change its fees upon each renewal term. Customer is responsible for all usage under Customer’s account, including usage by Customer’s Users and under Customer’s credentials and API keys.',
|
||||
'zh-CN':
|
||||
'<strong>Fees.</strong> Customer will pay Comfy the fees set forth in the applicable Order Form. Customer shall pay those amounts due and not disputed in good faith within seven (7) days of the date of receipt of the applicable invoice, unless a specific date for payment is set forth in such Order Form, in which case payment will be due on the date specified. Except as otherwise specified herein or in any applicable Order Form, (a) fees are quoted and payable in United States dollars and (b) payment obligations are non-cancelable and non-pro-ratable for partial months, and fees paid are non-refundable. Comfy reserves the right to change its fees upon each renewal term. Customer is responsible for all usage under Customer’s account, including usage by Customer’s Users and under Customer’s credentials and API keys.'
|
||||
},
|
||||
'enterprise-msa.4-payment.block.1': {
|
||||
en: '<strong>Prepaid Credits.</strong> Customer may prepay for usage credits (“Credits”) which may be applied toward usage of the Comfy Products at the rates set forth on Comfy’s pricing page. Except for documented billing errors or similar service issues attributed to Comfy, all purchases of Credits are final and non-refundable, and Comfy will not issue refunds or credits for any unused, partially used, or remaining Credits under any circumstances, including upon termination or expiration of Customer’s account. Comfy reserves the right to modify the pricing or Credit redemption rates applicable to future Credit purchases upon reasonable notice, but any Credits purchased prior to such modification will be honored at the rates in effect at the time of purchase.',
|
||||
'zh-CN':
|
||||
'<strong>Prepaid Credits.</strong> Customer may prepay for usage credits (“Credits”) which may be applied toward usage of the Comfy Products at the rates set forth on Comfy’s pricing page. Except for documented billing errors or similar service issues attributed to Comfy, all purchases of Credits are final and non-refundable, and Comfy will not issue refunds or credits for any unused, partially used, or remaining Credits under any circumstances, including upon termination or expiration of Customer’s account. Comfy reserves the right to modify the pricing or Credit redemption rates applicable to future Credit purchases upon reasonable notice, but any Credits purchased prior to such modification will be honored at the rates in effect at the time of purchase.'
|
||||
},
|
||||
'enterprise-msa.4-payment.block.2': {
|
||||
en: '<strong>Taxes.</strong> Fees are exclusive of all taxes, duties, levies, and similar governmental assessments (including sales, use, VAT/GST, and withholding taxes), and Customer is responsible for all such amounts other than taxes based on Comfy’s net income; if withholding is required by law, Customer will gross up payments so Comfy receives the invoiced amount, unless prohibited by law.',
|
||||
'zh-CN':
|
||||
'<strong>Taxes.</strong> Fees are exclusive of all taxes, duties, levies, and similar governmental assessments (including sales, use, VAT/GST, and withholding taxes), and Customer is responsible for all such amounts other than taxes based on Comfy’s net income; if withholding is required by law, Customer will gross up payments so Comfy receives the invoiced amount, unless prohibited by law.'
|
||||
},
|
||||
'enterprise-msa.4-payment.block.3': {
|
||||
en: '<strong>Late Payments; Suspension.</strong> Overdue undisputed amounts may accrue interest at the lesser of 1.5% per month or the maximum rate permitted by law, plus reasonable collection costs. Comfy may suspend or limit access to the Comfy Products (including throttling, disabling API keys, or downgrading to the Free Tier) for non-payment of undisputed amounts after providing commercially reasonable notice and an opportunity to cure, unless Comfy reasonably determines immediate suspension is necessary to protect the Comfy Products or comply with Applicable Laws.',
|
||||
'zh-CN':
|
||||
'<strong>Late Payments; Suspension.</strong> Overdue undisputed amounts may accrue interest at the lesser of 1.5% per month or the maximum rate permitted by law, plus reasonable collection costs. Comfy may suspend or limit access to the Comfy Products (including throttling, disabling API keys, or downgrading to the Free Tier) for non-payment of undisputed amounts after providing commercially reasonable notice and an opportunity to cure, unless Comfy reasonably determines immediate suspension is necessary to protect the Comfy Products or comply with Applicable Laws.'
|
||||
},
|
||||
'enterprise-msa.5-term-termination.label': {
|
||||
en: 'TERM',
|
||||
'zh-CN': 'TERM'
|
||||
},
|
||||
'enterprise-msa.5-term-termination.title': {
|
||||
en: '5. Term; Termination',
|
||||
'zh-CN': '5. Term; Termination'
|
||||
},
|
||||
'enterprise-msa.5-term-termination.block.0': {
|
||||
en: '<strong>Term.</strong> The term of this Agreement will commence on the Effective Date and continue until terminated as set forth below (“Term”). The initial term of each Order Form will begin on the Subscription Start Date of such Order Form and will continue for the subscription term set forth therein. Except as set forth in such Order Form, the Order Form will renew for successive renewal terms equal to the length of the Initial Subscription Term.',
|
||||
'zh-CN':
|
||||
'<strong>Term.</strong> The term of this Agreement will commence on the Effective Date and continue until terminated as set forth below (“Term”). The initial term of each Order Form will begin on the Subscription Start Date of such Order Form and will continue for the subscription term set forth therein. Except as set forth in such Order Form, the Order Form will renew for successive renewal terms equal to the length of the Initial Subscription Term.'
|
||||
},
|
||||
'enterprise-msa.5-term-termination.block.1': {
|
||||
en: '<strong>Termination of Agreement.</strong> Each party may terminate this Agreement upon written notice to the other party if there are no Order Forms then in effect. Each party may also terminate this Agreement or the applicable Order Form upon written notice in the event (a) the other party commits any material breach of this Agreement or the applicable Order Form and fails to remedy such breach within thirty (30) days after written notice of such breach or (b) subject to applicable law, upon the other party’s liquidation, commencement of dissolution proceedings or assignment of substantially all its assets for the benefit of creditors, or if the other party becomes the subject of bankruptcy or similar proceeding that is not dismissed within sixty (60) days.',
|
||||
'zh-CN':
|
||||
'<strong>Termination of Agreement.</strong> Each party may terminate this Agreement upon written notice to the other party if there are no Order Forms then in effect. Each party may also terminate this Agreement or the applicable Order Form upon written notice in the event (a) the other party commits any material breach of this Agreement or the applicable Order Form and fails to remedy such breach within thirty (30) days after written notice of such breach or (b) subject to applicable law, upon the other party’s liquidation, commencement of dissolution proceedings or assignment of substantially all its assets for the benefit of creditors, or if the other party becomes the subject of bankruptcy or similar proceeding that is not dismissed within sixty (60) days.'
|
||||
},
|
||||
'enterprise-msa.5-term-termination.block.2': {
|
||||
en: '<strong>Deletion of Customer Data Upon Termination.</strong> Upon expiration or termination of this Agreement, Comfy will delete Customer Data from its primary production systems within sixty (60) days. Notwithstanding the foregoing, Customer Data may persist in routine backup systems beyond such period solely to the extent necessary under Comfy’s standard backup retention schedule, provided that such data is not actively accessed or used by Comfy and remains subject to the confidentiality obligations of this Agreement.',
|
||||
'zh-CN':
|
||||
'<strong>Deletion of Customer Data Upon Termination.</strong> Upon expiration or termination of this Agreement, Comfy will delete Customer Data from its primary production systems within sixty (60) days. Notwithstanding the foregoing, Customer Data may persist in routine backup systems beyond such period solely to the extent necessary under Comfy’s standard backup retention schedule, provided that such data is not actively accessed or used by Comfy and remains subject to the confidentiality obligations of this Agreement.'
|
||||
},
|
||||
'enterprise-msa.5-term-termination.block.3': {
|
||||
en: '<strong>Survival.</strong> Termination or expiration will not affect any rights or obligations, including the payment of amounts due, which have accrued under this Agreement up to the date of termination or expiration. Upon termination or expiration of this Agreement, the provisions that are intended by their nature to survive termination will survive and continue in full force and effect in accordance with their terms, including confidentiality obligations, proprietary rights, indemnification, limitations of liability, and disclaimers.',
|
||||
'zh-CN':
|
||||
'<strong>Survival.</strong> Termination or expiration will not affect any rights or obligations, including the payment of amounts due, which have accrued under this Agreement up to the date of termination or expiration. Upon termination or expiration of this Agreement, the provisions that are intended by their nature to survive termination will survive and continue in full force and effect in accordance with their terms, including confidentiality obligations, proprietary rights, indemnification, limitations of liability, and disclaimers.'
|
||||
},
|
||||
'enterprise-msa.6-confidentiality.label': {
|
||||
en: 'CONFIDENTIALITY',
|
||||
'zh-CN': 'CONFIDENTIALITY'
|
||||
},
|
||||
'enterprise-msa.6-confidentiality.title': {
|
||||
en: '6. Confidentiality',
|
||||
'zh-CN': '6. Confidentiality'
|
||||
},
|
||||
'enterprise-msa.6-confidentiality.block.0': {
|
||||
en: '<strong>Definition of Confidential Information.</strong> “Confidential Information” means all non-public information disclosed by a party (“Disclosing Party”) to the other party (“Receiving Party”), whether oral or written, that is designated as confidential or that reasonably should be understood to be confidential given the nature of the information and circumstances of disclosure. Confidential Information of Customer includes Customer Data; Confidential Information of Comfy includes the Comfy Products; and each party’s Confidential Information includes the terms of this Agreement and any Order Forms (including pricing), as well as business, financial, marketing, technical, and product information. Confidential Information excludes information that the Receiving Party can demonstrate: (i) is or becomes publicly available without breach; (ii) was known prior to disclosure without breach; (iii) is received from a third party without breach; or (iv) was independently developed without use of or reference to the Disclosing Party’s Confidential Information.',
|
||||
'zh-CN':
|
||||
'<strong>Definition of Confidential Information.</strong> “Confidential Information” means all non-public information disclosed by a party (“Disclosing Party”) to the other party (“Receiving Party”), whether oral or written, that is designated as confidential or that reasonably should be understood to be confidential given the nature of the information and circumstances of disclosure. Confidential Information of Customer includes Customer Data; Confidential Information of Comfy includes the Comfy Products; and each party’s Confidential Information includes the terms of this Agreement and any Order Forms (including pricing), as well as business, financial, marketing, technical, and product information. Confidential Information excludes information that the Receiving Party can demonstrate: (i) is or becomes publicly available without breach; (ii) was known prior to disclosure without breach; (iii) is received from a third party without breach; or (iv) was independently developed without use of or reference to the Disclosing Party’s Confidential Information.'
|
||||
},
|
||||
'enterprise-msa.6-confidentiality.block.1': {
|
||||
en: '<strong>Protection of Confidential Information.</strong> The Receiving Party will: (a) protect Confidential Information using at least reasonable care; (b) use it solely to perform under this Agreement; and (c) limit access to its and its Affiliates’ employees and contractors with a need to know and confidentiality obligations at least as protective as those herein. Neither party may disclose the terms of this Agreement or any Order Form except to its Affiliates, legal counsel, or accountants, and remains responsible for their compliance. Upon written request, the Receiving Party will promptly return or destroy Confidential Information, except for information retained in routine backups or as required by law or internal retention policies.',
|
||||
'zh-CN':
|
||||
'<strong>Protection of Confidential Information.</strong> The Receiving Party will: (a) protect Confidential Information using at least reasonable care; (b) use it solely to perform under this Agreement; and (c) limit access to its and its Affiliates’ employees and contractors with a need to know and confidentiality obligations at least as protective as those herein. Neither party may disclose the terms of this Agreement or any Order Form except to its Affiliates, legal counsel, or accountants, and remains responsible for their compliance. Upon written request, the Receiving Party will promptly return or destroy Confidential Information, except for information retained in routine backups or as required by law or internal retention policies.'
|
||||
},
|
||||
'enterprise-msa.6-confidentiality.block.2': {
|
||||
en: '<strong>Compelled Disclosure.</strong> The Receiving Party may disclose Confidential Information if legally required, provided it gives prior notice (where permitted) and reasonable assistance, at the Disclosing Party’s expense, to seek protective treatment. Any disclosure will be limited to what is legally required, and the Receiving Party will request confidential treatment. These obligations survive while Confidential Information remains in the Receiving Party’s possession.',
|
||||
'zh-CN':
|
||||
'<strong>Compelled Disclosure.</strong> The Receiving Party may disclose Confidential Information if legally required, provided it gives prior notice (where permitted) and reasonable assistance, at the Disclosing Party’s expense, to seek protective treatment. Any disclosure will be limited to what is legally required, and the Receiving Party will request confidential treatment. These obligations survive while Confidential Information remains in the Receiving Party’s possession.'
|
||||
},
|
||||
'enterprise-msa.6-confidentiality.block.3': {
|
||||
en: '<strong>Data Security.</strong> Comfy will implement and maintain commercially reasonable administrative, technical, and physical safeguards designed to protect Customer Data against unauthorized access, disclosure, alteration, or destruction. These measures will be no less protective than those Comfy uses to protect its own confidential information of a similar nature. In the event Comfy becomes aware of a confirmed security breach that results in unauthorized access to or disclosure of Customer Data, Comfy will notify Customer without undue delay and will provide reasonable cooperation to assist Customer in investigating and mitigating the effects of such breach. Customer acknowledges that no security measures are perfect or impenetrable, and Comfy does not guarantee that Customer Data will be free from unauthorized access or disclosure.',
|
||||
'zh-CN':
|
||||
'<strong>Data Security.</strong> Comfy will implement and maintain commercially reasonable administrative, technical, and physical safeguards designed to protect Customer Data against unauthorized access, disclosure, alteration, or destruction. These measures will be no less protective than those Comfy uses to protect its own confidential information of a similar nature. In the event Comfy becomes aware of a confirmed security breach that results in unauthorized access to or disclosure of Customer Data, Comfy will notify Customer without undue delay and will provide reasonable cooperation to assist Customer in investigating and mitigating the effects of such breach. Customer acknowledges that no security measures are perfect or impenetrable, and Comfy does not guarantee that Customer Data will be free from unauthorized access or disclosure.'
|
||||
},
|
||||
'enterprise-msa.7-proprietary-rights.label': {
|
||||
en: 'IP',
|
||||
'zh-CN': 'IP'
|
||||
},
|
||||
'enterprise-msa.7-proprietary-rights.title': {
|
||||
en: '7. Proprietary Rights',
|
||||
'zh-CN': '7. Proprietary Rights'
|
||||
},
|
||||
'enterprise-msa.7-proprietary-rights.block.0': {
|
||||
en: '<strong>Reservation of Rights.</strong> Comfy and its licensors retain all right, title, and interest, including all intellectual property and proprietary rights, in and to the Comfy Products, Comfy Branding, and all software, code, algorithms, protocols, interfaces, tools, documentation, data structures, and other technology underlying or embodied in, or used to provide, the Comfy Products (collectively, “Comfy Materials”). Except for the limited rights expressly granted to Customer under this Agreement, no rights or licenses are granted, whether by implication, estoppel, or otherwise. Comfy expressly reserves all rights in and to the Comfy Materials not expressly granted hereunder.',
|
||||
'zh-CN':
|
||||
'<strong>Reservation of Rights.</strong> Comfy and its licensors retain all right, title, and interest, including all intellectual property and proprietary rights, in and to the Comfy Products, Comfy Branding, and all software, code, algorithms, protocols, interfaces, tools, documentation, data structures, and other technology underlying or embodied in, or used to provide, the Comfy Products (collectively, “Comfy Materials”). Except for the limited rights expressly granted to Customer under this Agreement, no rights or licenses are granted, whether by implication, estoppel, or otherwise. Comfy expressly reserves all rights in and to the Comfy Materials not expressly granted hereunder.'
|
||||
},
|
||||
'enterprise-msa.7-proprietary-rights.block.1': {
|
||||
en: '<strong>Feedback.</strong> Customer may from time to time provide feedback (including suggestions, comments for enhancements, functionality or usability, etc.) (“Feedback”) to Comfy regarding Customer’s experience using, and needs and integration requirements for, the Comfy Products. Comfy shall have full discretion to determine whether or not to proceed with the development of any requested enhancements, new features or functionality, and Customer hereby grants Comfy the full, unencumbered, royalty-free right to incorporate and otherwise fully exploit Feedback in connection with Comfy’s products and services.',
|
||||
'zh-CN':
|
||||
'<strong>Feedback.</strong> Customer may from time to time provide feedback (including suggestions, comments for enhancements, functionality or usability, etc.) (“Feedback”) to Comfy regarding Customer’s experience using, and needs and integration requirements for, the Comfy Products. Comfy shall have full discretion to determine whether or not to proceed with the development of any requested enhancements, new features or functionality, and Customer hereby grants Comfy the full, unencumbered, royalty-free right to incorporate and otherwise fully exploit Feedback in connection with Comfy’s products and services.'
|
||||
},
|
||||
'enterprise-msa.7-proprietary-rights.block.2': {
|
||||
en: '<strong>Operational Metadata.</strong> Customer agrees that Comfy may collect and use Operational Metadata to operate, maintain, improve, and support the Comfy Products, including for diagnostics, analytics, system performance, and reporting purposes. Comfy will only disclose Operational Metadata externally if such data is (a) aggregated or anonymized with data across other customers, and (b) does not disclose the identity of Customer or any Customer Confidential Information.',
|
||||
'zh-CN':
|
||||
'<strong>Operational Metadata.</strong> Customer agrees that Comfy may collect and use Operational Metadata to operate, maintain, improve, and support the Comfy Products, including for diagnostics, analytics, system performance, and reporting purposes. Comfy will only disclose Operational Metadata externally if such data is (a) aggregated or anonymized with data across other customers, and (b) does not disclose the identity of Customer or any Customer Confidential Information.'
|
||||
},
|
||||
'enterprise-msa.8-warranties-disclaimer.label': {
|
||||
en: 'WARRANTIES',
|
||||
'zh-CN': 'WARRANTIES'
|
||||
},
|
||||
'enterprise-msa.8-warranties-disclaimer.title': {
|
||||
en: '8. Warranties; Disclaimer',
|
||||
'zh-CN': '8. Warranties; Disclaimer'
|
||||
},
|
||||
'enterprise-msa.8-warranties-disclaimer.block.0': {
|
||||
en: '<strong>Comfy.</strong> Comfy warrants that it will, consistent with prevailing industry standards, provide the Comfy Products in a professional and workmanlike manner and the Comfy Products will conform in all material respects with the Documentation. For material breach of the foregoing express warranty, Customer’s exclusive remedy shall be the re-performance of the deficient Comfy Products or, if Comfy cannot re-perform such deficient Comfy Products as warranted within thirty (30) days after receipt of written notice of the warranty breach, Customer shall be entitled to terminate the applicable Order Form and recover a pro-rata portion of the prepaid subscription fees corresponding to the terminated portion of the applicable subscription term.',
|
||||
'zh-CN':
|
||||
'<strong>Comfy.</strong> Comfy warrants that it will, consistent with prevailing industry standards, provide the Comfy Products in a professional and workmanlike manner and the Comfy Products will conform in all material respects with the Documentation. For material breach of the foregoing express warranty, Customer’s exclusive remedy shall be the re-performance of the deficient Comfy Products or, if Comfy cannot re-perform such deficient Comfy Products as warranted within thirty (30) days after receipt of written notice of the warranty breach, Customer shall be entitled to terminate the applicable Order Form and recover a pro-rata portion of the prepaid subscription fees corresponding to the terminated portion of the applicable subscription term.'
|
||||
},
|
||||
'enterprise-msa.8-warranties-disclaimer.block.1': {
|
||||
en: '<strong>Customer.</strong> Customer represents and warrants that it owns or has obtained all necessary rights, licenses, and permissions to submit Customer Data to the Comfy Products, and that Customer Data does not include any content that Customer is legally prohibited from sharing or processing through the Comfy Products.',
|
||||
'zh-CN':
|
||||
'<strong>Customer.</strong> Customer represents and warrants that it owns or has obtained all necessary rights, licenses, and permissions to submit Customer Data to the Comfy Products, and that Customer Data does not include any content that Customer is legally prohibited from sharing or processing through the Comfy Products.'
|
||||
},
|
||||
'enterprise-msa.8-warranties-disclaimer.block.2': {
|
||||
en: '<strong>Disclaimer.</strong> EXCEPT AS SET FORTH HEREIN, THE COMFY PRODUCTS AND OUTPUT ARE PROVIDED “AS IS” WITHOUT ANY WARRANTY OF ANY KIND. COMFY DISCLAIMS ANY AND ALL WARRANTIES, REPRESENTATIONS, AND CONDITIONS RELATING TO THE COMFY PRODUCTS (INCLUDING ANY OUTPUT), WHETHER EXPRESS, IMPLIED, INCLUDING, BUT NOT LIMITED TO, ANY REPRESENTATION, WARRANTY, OR CONDITION OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, TITLE OR NON-INFRINGEMENT. CUSTOMER AGREES AND ACKNOWLEDGES THAT CUSTOMER’S USE OF ANY OUTPUT PROVIDED BY THE COMFY PRODUCTS IS AT CUSTOMER’S OWN RISK. Customer is solely responsible for (a) verifying the Output is appropriate for Customer’s use case, and (b) any decisions, actions, or omissions taken in reliance on the OUTPUT. IN NO EVENT WILL COMFY BE LIABLE FOR ANY DAMAGES OR LOSSES ARISING FROM OR RELATED TO CUSTOMER’S USE OF OR RELIANCE ON THE OUTPUT, INCLUDING ANY DECISIONS MADE OR ACTIONS TAKEN BASED ON THE OUTPUT.',
|
||||
'zh-CN':
|
||||
'<strong>Disclaimer.</strong> EXCEPT AS SET FORTH HEREIN, THE COMFY PRODUCTS AND OUTPUT ARE PROVIDED “AS IS” WITHOUT ANY WARRANTY OF ANY KIND. COMFY DISCLAIMS ANY AND ALL WARRANTIES, REPRESENTATIONS, AND CONDITIONS RELATING TO THE COMFY PRODUCTS (INCLUDING ANY OUTPUT), WHETHER EXPRESS, IMPLIED, INCLUDING, BUT NOT LIMITED TO, ANY REPRESENTATION, WARRANTY, OR CONDITION OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, TITLE OR NON-INFRINGEMENT. CUSTOMER AGREES AND ACKNOWLEDGES THAT CUSTOMER’S USE OF ANY OUTPUT PROVIDED BY THE COMFY PRODUCTS IS AT CUSTOMER’S OWN RISK. Customer is solely responsible for (a) verifying the Output is appropriate for Customer’s use case, and (b) any decisions, actions, or omissions taken in reliance on the OUTPUT. IN NO EVENT WILL COMFY BE LIABLE FOR ANY DAMAGES OR LOSSES ARISING FROM OR RELATED TO CUSTOMER’S USE OF OR RELIANCE ON THE OUTPUT, INCLUDING ANY DECISIONS MADE OR ACTIONS TAKEN BASED ON THE OUTPUT.'
|
||||
},
|
||||
'enterprise-msa.9-limitation-of-liability.label': {
|
||||
en: 'LIABILITY',
|
||||
'zh-CN': 'LIABILITY'
|
||||
},
|
||||
'enterprise-msa.9-limitation-of-liability.title': {
|
||||
en: '9. Limitation of Liability',
|
||||
'zh-CN': '9. Limitation of Liability'
|
||||
},
|
||||
'enterprise-msa.9-limitation-of-liability.block.0': {
|
||||
en: 'UNDER NO LEGAL THEORY, WHETHER IN TORT, CONTRACT, OR OTHERWISE, WILL EITHER PARTY BE LIABLE TO THE OTHER UNDER THIS AGREEMENT FOR (A) ANY INDIRECT, SPECIAL, INCIDENTAL, CONSEQUENTIAL OR PUNITIVE DAMAGES OF ANY CHARACTER, INCLUDING DAMAGES FOR LOSS OF GOODWILL, LOST PROFITS, LOST SALES OR BUSINESS, WORK STOPPAGE, COMPUTER FAILURE OR MALFUNCTION, LOST CONTENT OR DATA, EVEN IF A REPRESENTATIVE OF SUCH PARTY HAS BEEN ADVISED, KNEW OR SHOULD HAVE KNOWN OF THE POSSIBILITY OF SUCH DAMAGES, OR (B) EXCLUDING CUSTOMER’S PAYMENT OBLIGATIONS, ANY AGGREGATE DAMAGES, COSTS, OR LIABILITIES IN EXCESS OF THE AMOUNTS PAID BY CUSTOMER UNDER THE APPLICABLE ORDER FORM DURING THE TWELVE (12) MONTHS PRECEDING THE CLAIM.',
|
||||
'zh-CN':
|
||||
'UNDER NO LEGAL THEORY, WHETHER IN TORT, CONTRACT, OR OTHERWISE, WILL EITHER PARTY BE LIABLE TO THE OTHER UNDER THIS AGREEMENT FOR (A) ANY INDIRECT, SPECIAL, INCIDENTAL, CONSEQUENTIAL OR PUNITIVE DAMAGES OF ANY CHARACTER, INCLUDING DAMAGES FOR LOSS OF GOODWILL, LOST PROFITS, LOST SALES OR BUSINESS, WORK STOPPAGE, COMPUTER FAILURE OR MALFUNCTION, LOST CONTENT OR DATA, EVEN IF A REPRESENTATIVE OF SUCH PARTY HAS BEEN ADVISED, KNEW OR SHOULD HAVE KNOWN OF THE POSSIBILITY OF SUCH DAMAGES, OR (B) EXCLUDING CUSTOMER’S PAYMENT OBLIGATIONS, ANY AGGREGATE DAMAGES, COSTS, OR LIABILITIES IN EXCESS OF THE AMOUNTS PAID BY CUSTOMER UNDER THE APPLICABLE ORDER FORM DURING THE TWELVE (12) MONTHS PRECEDING THE CLAIM.'
|
||||
},
|
||||
'enterprise-msa.10-indemnification.label': {
|
||||
en: 'INDEMNITY',
|
||||
'zh-CN': 'INDEMNITY'
|
||||
},
|
||||
'enterprise-msa.10-indemnification.title': {
|
||||
en: '10. Indemnification',
|
||||
'zh-CN': '10. Indemnification'
|
||||
},
|
||||
'enterprise-msa.10-indemnification.block.0': {
|
||||
en: '<strong>Indemnity by Comfy.</strong> Comfy will defend Customer against any claim, demand, suit, or proceeding (“Claim”) made or brought against Customer by a third party alleging that the Comfy Products as provided by Comfy infringes or misappropriates a U.S. patent, copyright or trade secret and will indemnify Customer for any damages finally awarded against Customer (or any settlement approved by Comfy) in connection with any such Claim; provided that (a) Customer will promptly notify Comfy of such Claim, (b) Comfy will have the sole and exclusive authority to defend and/or settle any such Claim (provided that Comfy may not settle any Claim without Customer’s prior written consent, which will not be unreasonably withheld, unless it unconditionally releases Customer of all related liability) and (c) Customer reasonably cooperates with Comfy in connection therewith. If the use of the Comfy Products by Customer has become, or in Comfy’s opinion is likely to become, the subject of any claim of infringement, Comfy may at its option and expense (i) procure for Customer the right to continue using and receiving the Comfy Products as set forth hereunder; (ii) replace or modify the Comfy Products to make it non-infringing (with comparable functionality); or (iii) if the options in clauses (i) or (ii) are not reasonably practicable, terminate the applicable Order Form and provide a pro rata refund of any prepaid subscription fees corresponding to the terminated portion of the applicable subscription term. Comfy will have no liability or obligation with respect to any Claim to the extent such Claim is caused by (A) prompts, inputs, or other instructions or materials submitted by Customer or its Users; (B) Customer’s use of any outputs, generated content, or models in a manner not authorized under this Agreement; (C) modification of any generated outputs by or on behalf of Customer; (D) Customer Data, including any third-party intellectual property, likenesses, or other proprietary material incorporated therein; or (E) Customer’s failure to obtain rights, consents, or clearances required for the submission or use of any content through the Comfy Products (clauses (A) through (E), “Excluded Claims”). This Section states Comfy’s sole and exclusive liability and obligation, and Customer’s exclusive remedy, for any claim of any nature related to infringement or misappropriation of intellectual property.',
|
||||
'zh-CN':
|
||||
'<strong>Indemnity by Comfy.</strong> Comfy will defend Customer against any claim, demand, suit, or proceeding (“Claim”) made or brought against Customer by a third party alleging that the Comfy Products as provided by Comfy infringes or misappropriates a U.S. patent, copyright or trade secret and will indemnify Customer for any damages finally awarded against Customer (or any settlement approved by Comfy) in connection with any such Claim; provided that (a) Customer will promptly notify Comfy of such Claim, (b) Comfy will have the sole and exclusive authority to defend and/or settle any such Claim (provided that Comfy may not settle any Claim without Customer’s prior written consent, which will not be unreasonably withheld, unless it unconditionally releases Customer of all related liability) and (c) Customer reasonably cooperates with Comfy in connection therewith. If the use of the Comfy Products by Customer has become, or in Comfy’s opinion is likely to become, the subject of any claim of infringement, Comfy may at its option and expense (i) procure for Customer the right to continue using and receiving the Comfy Products as set forth hereunder; (ii) replace or modify the Comfy Products to make it non-infringing (with comparable functionality); or (iii) if the options in clauses (i) or (ii) are not reasonably practicable, terminate the applicable Order Form and provide a pro rata refund of any prepaid subscription fees corresponding to the terminated portion of the applicable subscription term. Comfy will have no liability or obligation with respect to any Claim to the extent such Claim is caused by (A) prompts, inputs, or other instructions or materials submitted by Customer or its Users; (B) Customer’s use of any outputs, generated content, or models in a manner not authorized under this Agreement; (C) modification of any generated outputs by or on behalf of Customer; (D) Customer Data, including any third-party intellectual property, likenesses, or other proprietary material incorporated therein; or (E) Customer’s failure to obtain rights, consents, or clearances required for the submission or use of any content through the Comfy Products (clauses (A) through (E), “Excluded Claims”). This Section states Comfy’s sole and exclusive liability and obligation, and Customer’s exclusive remedy, for any claim of any nature related to infringement or misappropriation of intellectual property.'
|
||||
},
|
||||
'enterprise-msa.10-indemnification.block.1': {
|
||||
en: '<strong>Indemnification by Customer.</strong> Customer will defend Comfy against any Claim made or brought against Comfy by a third party to the extent arising out of Customer’s breach of Section 3 or the Excluded Claims, and Customer will indemnify Comfy for any damages finally awarded against Comfy (or any settlement approved by Customer) in connection with any such Claim; provided that (a) Comfy will promptly notify Customer of such Claim, (b) Customer will have the sole and exclusive authority to defend and/or settle any such Claim (provided that Customer may not settle any Claim without Comfy’s prior written consent, which will not be unreasonably withheld, unless it unconditionally releases Comfy of all liability) and (c) Comfy reasonably cooperates with Customer in connection therewith.',
|
||||
'zh-CN':
|
||||
'<strong>Indemnification by Customer.</strong> Customer will defend Comfy against any Claim made or brought against Comfy by a third party to the extent arising out of Customer’s breach of Section 3 or the Excluded Claims, and Customer will indemnify Comfy for any damages finally awarded against Comfy (or any settlement approved by Customer) in connection with any such Claim; provided that (a) Comfy will promptly notify Customer of such Claim, (b) Customer will have the sole and exclusive authority to defend and/or settle any such Claim (provided that Customer may not settle any Claim without Comfy’s prior written consent, which will not be unreasonably withheld, unless it unconditionally releases Comfy of all liability) and (c) Comfy reasonably cooperates with Customer in connection therewith.'
|
||||
},
|
||||
'enterprise-msa.11-miscellaneous.label': {
|
||||
en: 'MISCELLANEOUS',
|
||||
'zh-CN': 'MISCELLANEOUS'
|
||||
},
|
||||
'enterprise-msa.11-miscellaneous.title': {
|
||||
en: '11. Miscellaneous',
|
||||
'zh-CN': '11. Miscellaneous'
|
||||
},
|
||||
'enterprise-msa.11-miscellaneous.block.0': {
|
||||
en: '<strong>Governing Law.</strong> This Agreement will be governed by the laws of the State of California, exclusive of its rules governing choice of law and conflict of laws. The parties agree to the exclusive jurisdiction and venue of the state and federal courts located in San Francisco, CA and each party irrevocably submits to such jurisdiction and venue and waives any objection based on inconvenient forum. This Agreement will not be governed by the United Nations Convention on Contracts for the International Sale of Goods.',
|
||||
'zh-CN':
|
||||
'<strong>Governing Law.</strong> This Agreement will be governed by the laws of the State of California, exclusive of its rules governing choice of law and conflict of laws. The parties agree to the exclusive jurisdiction and venue of the state and federal courts located in San Francisco, CA and each party irrevocably submits to such jurisdiction and venue and waives any objection based on inconvenient forum. This Agreement will not be governed by the United Nations Convention on Contracts for the International Sale of Goods.'
|
||||
},
|
||||
'enterprise-msa.11-miscellaneous.block.1': {
|
||||
en: '<strong>Export Compliance.</strong> Customer will comply with the export laws and regulations of the United States, the European Union and other applicable jurisdictions in using the Comfy Products.',
|
||||
'zh-CN':
|
||||
'<strong>Export Compliance.</strong> Customer will comply with the export laws and regulations of the United States, the European Union and other applicable jurisdictions in using the Comfy Products.'
|
||||
},
|
||||
'enterprise-msa.11-miscellaneous.block.2': {
|
||||
en: '<strong>Publicity.</strong> Customer agrees that Comfy may refer to Customer’s name, logo, and trademarks in Comfy’s marketing materials and website; however, Comfy will not use Customer’s name or trademarks in any other publicity (e.g., press releases, customer references and case studies) without Customer’s prior written consent (which may be by email) not to be unreasonably withheld, conditioned, or delayed.',
|
||||
'zh-CN':
|
||||
'<strong>Publicity.</strong> Customer agrees that Comfy may refer to Customer’s name, logo, and trademarks in Comfy’s marketing materials and website; however, Comfy will not use Customer’s name or trademarks in any other publicity (e.g., press releases, customer references and case studies) without Customer’s prior written consent (which may be by email) not to be unreasonably withheld, conditioned, or delayed.'
|
||||
},
|
||||
'enterprise-msa.11-miscellaneous.block.3': {
|
||||
en: '<strong>Third-Party Infrastructure.</strong> Customer acknowledges that the Comfy Products relies on third-party infrastructure, hardware, and services, including cloud computing providers and GPU infrastructure providers (collectively, “Third-Party Infrastructure”), and that the availability, performance, and security of the Comfy Products may be affected by the operation, maintenance, or failure of such Third-Party Infrastructure. Comfy will use commercially reasonable efforts to maintain Comfy Products availability but makes no representation or warranty regarding the performance or availability of any Third-Party Infrastructure, and Comfy shall have no liability to Customer for any interruption, degradation, loss of data, or other harm arising out of or related to any failure, outage, or limitation of Third-Party Infrastructure, whether or not within Comfy’s control.',
|
||||
'zh-CN':
|
||||
'<strong>Third-Party Infrastructure.</strong> Customer acknowledges that the Comfy Products relies on third-party infrastructure, hardware, and services, including cloud computing providers and GPU infrastructure providers (collectively, “Third-Party Infrastructure”), and that the availability, performance, and security of the Comfy Products may be affected by the operation, maintenance, or failure of such Third-Party Infrastructure. Comfy will use commercially reasonable efforts to maintain Comfy Products availability but makes no representation or warranty regarding the performance or availability of any Third-Party Infrastructure, and Comfy shall have no liability to Customer for any interruption, degradation, loss of data, or other harm arising out of or related to any failure, outage, or limitation of Third-Party Infrastructure, whether or not within Comfy’s control.'
|
||||
},
|
||||
'enterprise-msa.11-miscellaneous.block.4': {
|
||||
en: '<strong>Assignment; Delegation.</strong> Neither party hereto may assign or otherwise transfer this Agreement, in whole or in part, without the other party’s prior written consent, except that Comfy may assign this Agreement without consent to a successor to all or substantially all of its assets or business related to this Agreement. Any attempted assignment, delegation, or transfer by either party in violation hereof will be null and void. Subject to the foregoing, this Agreement will be binding on the parties and their successors and assigns.',
|
||||
'zh-CN':
|
||||
'<strong>Assignment; Delegation.</strong> Neither party hereto may assign or otherwise transfer this Agreement, in whole or in part, without the other party’s prior written consent, except that Comfy may assign this Agreement without consent to a successor to all or substantially all of its assets or business related to this Agreement. Any attempted assignment, delegation, or transfer by either party in violation hereof will be null and void. Subject to the foregoing, this Agreement will be binding on the parties and their successors and assigns.'
|
||||
},
|
||||
'enterprise-msa.11-miscellaneous.block.5': {
|
||||
en: '<strong>Amendment; Waiver.</strong> No amendment or modification to this Agreement, nor any waiver of any rights hereunder, will be effective unless assented to in writing by both parties. Any such waiver will be only to the specific provision and under the specific circumstances for which it was given and will not apply with respect to any repeated or continued violation of the same provision or any other provision. Failure or delay by either party to enforce any provision of this Agreement will not be deemed a waiver of future enforcement of that or any other provision.',
|
||||
'zh-CN':
|
||||
'<strong>Amendment; Waiver.</strong> No amendment or modification to this Agreement, nor any waiver of any rights hereunder, will be effective unless assented to in writing by both parties. Any such waiver will be only to the specific provision and under the specific circumstances for which it was given and will not apply with respect to any repeated or continued violation of the same provision or any other provision. Failure or delay by either party to enforce any provision of this Agreement will not be deemed a waiver of future enforcement of that or any other provision.'
|
||||
},
|
||||
'enterprise-msa.11-miscellaneous.block.6': {
|
||||
en: '<strong>Relationship.</strong> Nothing contained herein will in any way constitute any association, partnership, agency, employment or joint venture between the parties hereto, or be construed to evidence the intention of the parties to establish any such relationship. Neither party will have the authority to obligate or bind the other in any manner, and nothing herein contained will give rise to, or is intended to give rise to any rights of any kind in favor of any third parties.',
|
||||
'zh-CN':
|
||||
'<strong>Relationship.</strong> Nothing contained herein will in any way constitute any association, partnership, agency, employment or joint venture between the parties hereto, or be construed to evidence the intention of the parties to establish any such relationship. Neither party will have the authority to obligate or bind the other in any manner, and nothing herein contained will give rise to, or is intended to give rise to any rights of any kind in favor of any third parties.'
|
||||
},
|
||||
'enterprise-msa.11-miscellaneous.block.7': {
|
||||
en: '<strong>Unenforceability.</strong> If a court of competent jurisdiction determines that any provision of this Agreement is invalid, illegal, or otherwise unenforceable, such provision will be enforced as nearly as possible in accordance with the stated intention of the parties, while the remainder of this Agreement will remain in full force and effect and bind the parties according to its terms.',
|
||||
'zh-CN':
|
||||
'<strong>Unenforceability.</strong> If a court of competent jurisdiction determines that any provision of this Agreement is invalid, illegal, or otherwise unenforceable, such provision will be enforced as nearly as possible in accordance with the stated intention of the parties, while the remainder of this Agreement will remain in full force and effect and bind the parties according to its terms.'
|
||||
},
|
||||
'enterprise-msa.11-miscellaneous.block.8': {
|
||||
en: '<strong>Notices.</strong> Any notice required or permitted to be given hereunder will be given in writing by personal delivery, certified mail, return receipt requested, or by overnight delivery. Notices to the parties must be sent to the respective address set forth in the signature blocks below, or such other address designated pursuant to this Section.',
|
||||
'zh-CN':
|
||||
'<strong>Notices.</strong> Any notice required or permitted to be given hereunder will be given in writing by personal delivery, certified mail, return receipt requested, or by overnight delivery. Notices to the parties must be sent to the respective address set forth in the signature blocks below, or such other address designated pursuant to this Section.'
|
||||
},
|
||||
'enterprise-msa.11-miscellaneous.block.9': {
|
||||
en: '<strong>Force Majeure.</strong> Neither party will be deemed in breach hereunder for any cessation, interruption or delay in the performance of its obligations due to causes beyond its reasonable control, including earthquake, flood, or other natural disaster, act of God, labor controversy, civil disturbance, terrorism, war (whether or not officially declared), cyber attacks (e.g., denial of service attacks), or the inability to obtain sufficient supplies, transportation, or other essential commodity or service required in the conduct of its business, or any change in or the adoption of any law, regulation, judgment or decree for which the party could not reasonably prepare mitigation in advance.',
|
||||
'zh-CN':
|
||||
'<strong>Force Majeure.</strong> Neither party will be deemed in breach hereunder for any cessation, interruption or delay in the performance of its obligations due to causes beyond its reasonable control, including earthquake, flood, or other natural disaster, act of God, labor controversy, civil disturbance, terrorism, war (whether or not officially declared), cyber attacks (e.g., denial of service attacks), or the inability to obtain sufficient supplies, transportation, or other essential commodity or service required in the conduct of its business, or any change in or the adoption of any law, regulation, judgment or decree for which the party could not reasonably prepare mitigation in advance.'
|
||||
},
|
||||
'enterprise-msa.11-miscellaneous.block.10': {
|
||||
en: '<strong>Entire Agreement.</strong> This Agreement comprises the entire agreement between Customer and Comfy with respect to its subject matter, and supersedes all prior and contemporaneous proposals, statements, sales materials or presentations and agreements (oral and written). No oral or written information or advice given by Comfy, its agents or employees will create a warranty or in any way increase the scope of the warranties in this Agreement.',
|
||||
'zh-CN':
|
||||
'<strong>Entire Agreement.</strong> This Agreement comprises the entire agreement between Customer and Comfy with respect to its subject matter, and supersedes all prior and contemporaneous proposals, statements, sales materials or presentations and agreements (oral and written). No oral or written information or advice given by Comfy, its agents or employees will create a warranty or in any way increase the scope of the warranties in this Agreement.'
|
||||
},
|
||||
'enterprise-msa.12-exhibit-a.label': {
|
||||
en: 'EXHIBIT A',
|
||||
'zh-CN': 'EXHIBIT A'
|
||||
},
|
||||
'enterprise-msa.12-exhibit-a.title': {
|
||||
en: 'Exhibit A. Order Form',
|
||||
'zh-CN': 'Exhibit A. Order Form'
|
||||
},
|
||||
'enterprise-msa.12-exhibit-a.block.0': {
|
||||
en: 'The initial Order Form is attached as <strong>Exhibit A</strong> to the executed copy of this Agreement. Each Order Form is subject to the terms and conditions of this Agreement, and by executing an Order Form, Customer agrees to be bound by the terms and conditions of this Agreement.',
|
||||
'zh-CN':
|
||||
'The initial Order Form is attached as <strong>Exhibit A</strong> to the executed copy of this Agreement. Each Order Form is subject to the terms and conditions of this Agreement, and by executing an Order Form, Customer agrees to be bound by the terms and conditions of this Agreement.'
|
||||
},
|
||||
'enterprise-msa.12-exhibit-a.block.1': {
|
||||
en: 'This document reproduces the current template of the Enterprise Customer Agreement for reference only. The executed Agreement between Comfy and Customer, together with any signed Order Forms, governs the relationship between the parties. To request an executable copy, please contact <a href="mailto:sales@comfy.org" class="text-white underline">sales@comfy.org</a>.',
|
||||
'zh-CN':
|
||||
'This document reproduces the current template of the Enterprise Customer Agreement for reference only. The executed Agreement between Comfy and Customer, together with any signed Order Forms, governs the relationship between the parties. To request an executable copy, please contact <a href="mailto:sales@comfy.org" class="text-white underline">sales@comfy.org</a>.'
|
||||
},
|
||||
'enterprise-msa.page.title': {
|
||||
en: 'Enterprise MSA — Comfy',
|
||||
'zh-CN': 'Enterprise MSA — Comfy'
|
||||
},
|
||||
'enterprise-msa.page.description': {
|
||||
en: 'Comfy Enterprise Customer Agreement — the master services agreement that governs Comfy Enterprise deployments of Comfy Cloud, Comfy API, and related products.',
|
||||
'zh-CN':
|
||||
'Comfy Enterprise Customer Agreement — the master services agreement that governs Comfy Enterprise deployments of Comfy Cloud, Comfy API, and related products.'
|
||||
},
|
||||
'enterprise-msa.page.heading': {
|
||||
en: 'Enterprise Customer Agreement',
|
||||
'zh-CN': 'Enterprise Customer Agreement'
|
||||
},
|
||||
'enterprise-msa.page.tocLabel': {
|
||||
en: 'On this page',
|
||||
'zh-CN': 'On this page'
|
||||
},
|
||||
'enterprise-msa.page.effectiveDateLabel': {
|
||||
en: 'Effective Date',
|
||||
'zh-CN': 'Effective Date'
|
||||
},
|
||||
'enterprise-msa.page.parties': {
|
||||
en: 'This Enterprise Customer Agreement (the “Agreement”) is entered into by and between Comfy Organization, Inc., a Delaware corporation (“Comfy”), and the entity identified on the applicable Order Form (“Customer”), and is effective as of the date set forth on the applicable Order Form (the “Effective Date”).',
|
||||
'zh-CN':
|
||||
'This Enterprise Customer Agreement (the “Agreement”) is entered into by and between Comfy Organization, Inc., a Delaware corporation (“Comfy”), and the entity identified on the applicable Order Form (“Customer”), and is effective as of the date set forth on the applicable Order Form (the “Effective Date”).'
|
||||
},
|
||||
'footer.enterpriseMsa': {
|
||||
en: 'Enterprise MSA',
|
||||
'zh-CN': 'Enterprise MSA'
|
||||
},
|
||||
|
||||
// Customers page
|
||||
'customers.hero.label': {
|
||||
en: 'CUSTOMER STORIES',
|
||||
|
||||
@@ -7,6 +7,7 @@ import SiteFooter from '../components/common/SiteFooter.vue'
|
||||
import HeaderMain from '../components/common/HeaderMain/HeaderMain.vue'
|
||||
import AnnouncementBanner from '../templates/drops/AnnouncementBanner.vue'
|
||||
import { bannerConfig, getBannerData } from '../config/banner'
|
||||
import { isHrefActive } from '../composables/useCurrentPath'
|
||||
import {
|
||||
BANNER_DISMISS_ATTR,
|
||||
BANNER_STORAGE_KEY,
|
||||
@@ -43,12 +44,15 @@ const rawStars = await fetchGitHubStars('Comfy-Org', 'ComfyUI')
|
||||
const githubStars = rawStars ? formatStarCount(rawStars) : ''
|
||||
|
||||
// Announcement banner — build-time visibility gate + content-hash version key.
|
||||
// A promo never advertises the page you are already on, so the banner is
|
||||
// suppressed when its CTA points at the current path.
|
||||
const bannerData = getBannerData(bannerConfig, locale)
|
||||
const bannerVisible = evaluateBannerVisibility(bannerConfig, {
|
||||
currentLocale: locale,
|
||||
currentSection: 'sitewide',
|
||||
now: new Date(),
|
||||
})
|
||||
const bannerVisible =
|
||||
evaluateBannerVisibility(bannerConfig, {
|
||||
currentLocale: locale,
|
||||
currentSection: 'sitewide',
|
||||
now: new Date(),
|
||||
}) && !isHrefActive(bannerData.link?.href ?? '', Astro.url.pathname)
|
||||
const bannerVersion = createBannerVersion(bannerData, locale)
|
||||
|
||||
const gtmId = 'GTM-NP9JM6K7'
|
||||
|
||||
36
apps/website/src/pages/enterprise-msa.astro
Normal file
36
apps/website/src/pages/enterprise-msa.astro
Normal file
@@ -0,0 +1,36 @@
|
||||
---
|
||||
// Enterprise Customer Agreement (Enterprise MSA) — English only, by design.
|
||||
// Legal-reviewed copy must not be served under a localized route until legal
|
||||
// explicitly approves a translation; rendering an unreviewed translation as
|
||||
// the active MSA exposes us to liability from the translation diverging from
|
||||
// the approved English source. See the matching comment in
|
||||
// src/i18n/translations.ts for the i18n block, and the entry in
|
||||
// LOCALE_INVARIANT_ROUTE_KEYS in src/config/routes.ts.
|
||||
import BaseLayout from '../layouts/BaseLayout.astro'
|
||||
import HeroSection from '../components/legal/HeroSection.vue'
|
||||
import LegalContentSection from '../components/legal/LegalContentSection.vue'
|
||||
import { t } from '../i18n/translations'
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
title={t('enterprise-msa.page.title')}
|
||||
description={t('enterprise-msa.page.description')}
|
||||
>
|
||||
<HeroSection title={t('enterprise-msa.page.heading')} />
|
||||
<p class="text-primary-warm-gray mt-2 text-center text-sm">
|
||||
{t('enterprise-msa.page.effectiveDateLabel')}: {
|
||||
t('enterprise-msa.effective-date')
|
||||
}
|
||||
</p>
|
||||
<p
|
||||
class="text-primary-comfy-canvas mx-auto mt-8 max-w-3xl px-4 text-center text-sm/relaxed lg:px-0"
|
||||
>
|
||||
{t('enterprise-msa.page.parties')}
|
||||
</p>
|
||||
<LegalContentSection
|
||||
prefix="enterprise-msa"
|
||||
locale="en"
|
||||
tocLabelKey="enterprise-msa.page.tocLabel"
|
||||
client:load
|
||||
/>
|
||||
</BaseLayout>
|
||||
@@ -17,7 +17,7 @@ const ctas = mcpCtas(locale)
|
||||
badge-text="MCP"
|
||||
:title="t('mcp.hero.heading', locale)"
|
||||
:subtitle="t('mcp.hero.subtitle', locale)"
|
||||
:primary-cta="ctas.runWorkflow"
|
||||
:primary-cta="ctas.installMcp"
|
||||
:secondary-cta="ctas.docs"
|
||||
>
|
||||
<template #media>
|
||||
|
||||
@@ -17,7 +17,10 @@ const cards: FeatureCard[] = [
|
||||
description: t('mcp.setup.step1.description', locale),
|
||||
action: {
|
||||
type: 'code',
|
||||
value: externalLinks.mcpServer
|
||||
value: t('mcp.setup.step1.command', locale).replace(
|
||||
'{url}',
|
||||
externalLinks.docsMcp
|
||||
)
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -53,6 +56,8 @@ const cards: FeatureCard[] = [
|
||||
|
||||
<template>
|
||||
<FeatureGrid01
|
||||
id="setup"
|
||||
class="scroll-mt-24 lg:scroll-mt-36"
|
||||
:eyebrow="t('mcp.setup.label', locale)"
|
||||
:heading="t('mcp.setup.heading', locale)"
|
||||
:subtitle="t('mcp.setup.subtitle', locale)"
|
||||
|
||||
@@ -9,16 +9,25 @@ export interface McpCta {
|
||||
}
|
||||
|
||||
/**
|
||||
* The two calls-to-action shared by the MCP hero and "how it works" sections:
|
||||
* view the docs, or run a workflow in the cloud.
|
||||
* Calls-to-action for the MCP page: view the docs, jump to the on-page setup
|
||||
* steps, or run a workflow in the cloud. The hero leads with install + docs;
|
||||
* the "how it works" section pairs run-a-workflow with docs.
|
||||
*/
|
||||
export function mcpCtas(locale: Locale): { docs: McpCta; runWorkflow: McpCta } {
|
||||
export function mcpCtas(locale: Locale): {
|
||||
docs: McpCta
|
||||
installMcp: McpCta
|
||||
runWorkflow: McpCta
|
||||
} {
|
||||
return {
|
||||
docs: {
|
||||
label: t('mcp.hero.viewDocs', locale),
|
||||
href: externalLinks.docsMcp,
|
||||
target: '_blank'
|
||||
},
|
||||
installMcp: {
|
||||
label: t('mcp.hero.installMcp', locale),
|
||||
href: '#setup'
|
||||
},
|
||||
runWorkflow: {
|
||||
label: t('mcp.hero.runWorkflow', locale),
|
||||
href: getRoutes(locale).cloud
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"last_node_id": 1,
|
||||
"last_link_id": 0,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 1,
|
||||
"type": "LoadVideo",
|
||||
"pos": [50, 120],
|
||||
"size": [400, 200],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "VIDEO",
|
||||
"type": "VIDEO",
|
||||
"links": null
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "LoadVideo"
|
||||
},
|
||||
"widgets_values": ["video/cloud-video-hash.mp4 [output]", "image"]
|
||||
}
|
||||
],
|
||||
"links": [],
|
||||
"groups": [],
|
||||
"config": {},
|
||||
"extra": {
|
||||
"ds": {
|
||||
"offset": [0, 0],
|
||||
"scale": 1
|
||||
}
|
||||
},
|
||||
"version": 0.4
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
WORKSPACE_FEATURE_FLAG
|
||||
} from '@e2e/fixtures/data/cloudWorkspace'
|
||||
import { CloudAuthHelper } from '@e2e/fixtures/helpers/CloudAuthHelper'
|
||||
import { mockWorkspaceTokenMint } from '@e2e/fixtures/utils/workspaceMocks'
|
||||
|
||||
interface RoleChangeRequest {
|
||||
url: string
|
||||
@@ -92,9 +93,7 @@ export class CloudWorkspaceMockHelper {
|
||||
await page.route('**/api/auth/session', (r) =>
|
||||
r.fulfill(jsonRoute({ token: 'mock-workspace-token' }))
|
||||
)
|
||||
await page.route('**/api/auth/token', (r) =>
|
||||
r.fulfill(jsonRoute({ token: 'mock-workspace-token' }))
|
||||
)
|
||||
await mockWorkspaceTokenMint(page, TEAM_WORKSPACE)
|
||||
await page.route('**/releases**', (r) => r.fulfill(jsonRoute([])))
|
||||
|
||||
await page.route('**/api/workspaces', (r) =>
|
||||
|
||||
@@ -110,7 +110,8 @@ export const TestIds = {
|
||||
},
|
||||
propertiesPanel: {
|
||||
root: 'properties-panel',
|
||||
errorsTab: 'panel-tab-errors'
|
||||
errorsTab: 'panel-tab-errors',
|
||||
selectionContextStrip: 'selection-context-strip'
|
||||
},
|
||||
assets: {
|
||||
browserModal: 'asset-browser-modal',
|
||||
|
||||
@@ -33,6 +33,27 @@ export function member(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stub `POST /api/auth/token` with a valid workspace token for `ws`. Without
|
||||
* this the mint fails and auth cannot resolve the active workspace.
|
||||
*/
|
||||
export async function mockWorkspaceTokenMint(
|
||||
page: Page,
|
||||
ws: Pick<WorkspaceWithRole, 'id' | 'name' | 'type' | 'role'>
|
||||
) {
|
||||
await page.route('**/api/auth/token', (r) =>
|
||||
r.fulfill(
|
||||
jsonRoute({
|
||||
token: 'mock-workspace-token',
|
||||
expires_at: new Date(Date.now() + 60 * 60 * 1000).toISOString(),
|
||||
workspace: { id: ws.id, name: ws.name, type: ws.type },
|
||||
role: ws.role,
|
||||
permissions: []
|
||||
})
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Stub the workspace resolution + members list so the cloud app boots into the
|
||||
* given workspace with the given roster (drives the original-owner gate).
|
||||
@@ -46,17 +67,7 @@ export async function mockWorkspace(
|
||||
if (route.request().method() !== 'GET') return route.fallback()
|
||||
await route.fulfill(jsonRoute({ workspaces: [ws] }))
|
||||
})
|
||||
await page.route('**/api/auth/token', (r) =>
|
||||
r.fulfill(
|
||||
jsonRoute({
|
||||
token: 'mock-workspace-token',
|
||||
expires_at: new Date(Date.now() + 60 * 60 * 1000).toISOString(),
|
||||
workspace: { id: ws.id, name: ws.name, type: ws.type },
|
||||
role: ws.role,
|
||||
permissions: []
|
||||
})
|
||||
)
|
||||
)
|
||||
await mockWorkspaceTokenMint(page, ws)
|
||||
await page.route('**/api/workspace/members**', (r) =>
|
||||
r.fulfill(
|
||||
jsonRoute({
|
||||
|
||||
@@ -11,6 +11,10 @@ import type {
|
||||
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
import { mockSystemStats } from '@e2e/fixtures/data/systemStats'
|
||||
import { CloudAuthHelper } from '@e2e/fixtures/helpers/CloudAuthHelper'
|
||||
import {
|
||||
mockWorkspaceTokenMint,
|
||||
workspace
|
||||
} from '@e2e/fixtures/utils/workspaceMocks'
|
||||
|
||||
/**
|
||||
* Billing facade consumers — FE-933 (B3) regression.
|
||||
@@ -81,6 +85,7 @@ async function mockCloudBoot(
|
||||
await page.route('**/api/auth/session', (r) =>
|
||||
r.fulfill(jsonRoute({ token: 'mock-workspace-token' }))
|
||||
)
|
||||
await mockWorkspaceTokenMint(page, workspace('personal', 'owner'))
|
||||
await page.route('**/releases**', (r) => r.fulfill(jsonRoute([])))
|
||||
|
||||
// Single personal workspace.
|
||||
|
||||
279
browser_tests/tests/cloudSecrets.spec.ts
Normal file
279
browser_tests/tests/cloudSecrets.spec.ts
Normal file
@@ -0,0 +1,279 @@
|
||||
import { expect } from '@playwright/test'
|
||||
import type { Page, Route } from '@playwright/test'
|
||||
|
||||
import type { RemoteConfig } from '@/platform/remoteConfig/types'
|
||||
|
||||
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
import { bootCloud, mockCloudBoot } from '@e2e/fixtures/utils/cloudBootMocks'
|
||||
import { jsonRoute } from '@e2e/fixtures/utils/jsonRoute'
|
||||
|
||||
/**
|
||||
* End-to-end coverage for the user-secrets (API keys) surface in the cloud app:
|
||||
* add a provider key, see it listed, delete it — the full CRUD round-trip —
|
||||
* plus the entitlement contract that a non-entitled account never sees the
|
||||
* gated providers.
|
||||
*
|
||||
* Drives a raw `page` against fully-mocked endpoints (the `comfyPage` fixture
|
||||
* would reach the OSS devtools backend during setup); `mockCloudBoot` +
|
||||
* `bootCloud` boot the app signed-in, and this spec layers a stateful in-memory
|
||||
* `/secrets` backend on top so the flow is deterministic and never touches a
|
||||
* real server.
|
||||
*/
|
||||
const APP_URL = process.env.PLAYWRIGHT_TEST_URL || 'http://localhost:8188'
|
||||
|
||||
// `/api/features` is the remote-config source. Enabling user secrets is what
|
||||
// surfaces the Secrets settings panel for a signed-in user.
|
||||
const BOOT_FEATURES = {
|
||||
user_secrets_enabled: true
|
||||
} satisfies RemoteConfig
|
||||
|
||||
// TutorialCompleted suppresses the new-user template browser, whose modal
|
||||
// overlay (z-1700) would otherwise intercept clicks on the settings dialog.
|
||||
const BOOT_SETTINGS = { 'Comfy.TutorialCompleted': true }
|
||||
|
||||
// The plaintext key a user types in. It must be sent on create but NEVER echoed
|
||||
// back by the API or rendered anywhere in the UI.
|
||||
const RUNWAY_KEY_VALUE = 'sk-runway-do-not-echo-0xDEADBEEF'
|
||||
|
||||
interface SecretRecord {
|
||||
id: string
|
||||
name: string
|
||||
provider?: string
|
||||
created_at: string
|
||||
updated_at: string
|
||||
last_used_at?: string
|
||||
}
|
||||
|
||||
interface CreateCapture {
|
||||
name?: string
|
||||
provider?: string
|
||||
secret_value?: string
|
||||
}
|
||||
|
||||
interface SecretsBackend {
|
||||
/** Bodies received by POST /secrets, in order — for asserting what was sent. */
|
||||
createRequests: CreateCapture[]
|
||||
/** Current server-side store — for asserting delete actually removed a row. */
|
||||
store: SecretRecord[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Stateful mock of the ingest `/secrets` surface. A single route handler
|
||||
* branches on path + method so registration order can never make a specific
|
||||
* path (`/secrets/providers`, `/secrets/:id`) lose to the collection glob.
|
||||
*
|
||||
* `providerIds` models entitlement: an entitled account sees runway/gemini,
|
||||
* a non-entitled account gets an empty list (the server omits them).
|
||||
*/
|
||||
async function mockSecretsBackend(
|
||||
page: Page,
|
||||
providerIds: string[]
|
||||
): Promise<SecretsBackend> {
|
||||
const backend: SecretsBackend = { createRequests: [], store: [] }
|
||||
let idSeq = 0
|
||||
|
||||
const respondList = (route: Route) =>
|
||||
route.fulfill(jsonRoute({ data: backend.store }))
|
||||
|
||||
await page.route('**/api/secrets**', async (route) => {
|
||||
const request = route.request()
|
||||
const { pathname } = new URL(request.url())
|
||||
const method = request.method()
|
||||
|
||||
// The glob `**/api/secrets**` also matches the panel's own lazy-loaded
|
||||
// source module (`/src/platform/secrets/api/secretsApi.ts`), whose path
|
||||
// contains the `/api/secrets` substring. Fulfilling that dev-server module
|
||||
// request with JSON breaks the dynamic import and the panel never mounts.
|
||||
// Anchor to the start of the pathname so only genuine `/api/secrets…` API
|
||||
// routes are handled; everything else falls through to the real Vite server.
|
||||
if (!/^\/api\/secrets(\/|$)/.test(pathname)) {
|
||||
return route.continue()
|
||||
}
|
||||
|
||||
// GET /secrets/providers — the entitlement-gated provider allowlist.
|
||||
if (pathname.endsWith('/secrets/providers')) {
|
||||
return route.fulfill(
|
||||
jsonRoute({ data: providerIds.map((id) => ({ id })) })
|
||||
)
|
||||
}
|
||||
|
||||
// /secrets/:id — item routes (only DELETE is exercised by this flow).
|
||||
const itemMatch = pathname.match(/\/secrets\/([^/]+)$/)
|
||||
if (itemMatch) {
|
||||
const id = itemMatch[1]
|
||||
if (method === 'DELETE') {
|
||||
backend.store = backend.store.filter((s) => s.id !== id)
|
||||
return route.fulfill({ status: 204, body: '' })
|
||||
}
|
||||
return respondList(route)
|
||||
}
|
||||
|
||||
// /secrets — collection routes.
|
||||
if (method === 'POST') {
|
||||
const body = (request.postDataJSON() ?? {}) as CreateCapture
|
||||
backend.createRequests.push(body)
|
||||
idSeq += 1
|
||||
const created: SecretRecord = {
|
||||
id: `00000000-0000-4000-8000-${String(idSeq).padStart(12, '0')}`,
|
||||
name: body.name ?? '',
|
||||
provider: body.provider,
|
||||
created_at: '2026-07-08T00:00:00Z',
|
||||
updated_at: '2026-07-08T00:00:00Z'
|
||||
}
|
||||
backend.store.push(created)
|
||||
// Response echoes metadata ONLY — the schema has no secret_value field.
|
||||
return route.fulfill(jsonRoute(created))
|
||||
}
|
||||
|
||||
// GET /secrets (list).
|
||||
return respondList(route)
|
||||
})
|
||||
|
||||
return backend
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the settings dialog and land on the Secrets panel, waiting for both the
|
||||
* provider allowlist and the secret list to resolve so subsequent assertions
|
||||
* are not racing the panel's on-mount fetches.
|
||||
*/
|
||||
async function openSecretsPanel(page: Page) {
|
||||
const settingsDialog = page.getByTestId('settings-dialog')
|
||||
|
||||
await page.evaluate(() => {
|
||||
const app = window.app
|
||||
if (!app) throw new Error('window.app is not available')
|
||||
return app.extensionManager.command.execute('Comfy.ShowSettingsDialog')
|
||||
})
|
||||
await settingsDialog.waitFor({ state: 'visible' })
|
||||
|
||||
const providersResolved = page.waitForResponse((r) =>
|
||||
r.url().includes('/api/secrets/providers')
|
||||
)
|
||||
const listResolved = page.waitForResponse(
|
||||
(r) =>
|
||||
/\/api\/secrets(\?|$)/.test(r.url()) && r.request().method() === 'GET'
|
||||
)
|
||||
|
||||
await settingsDialog
|
||||
.locator('nav')
|
||||
.getByRole('button', { name: 'Secrets' })
|
||||
.click()
|
||||
|
||||
await Promise.all([providersResolved, listResolved])
|
||||
return settingsDialog
|
||||
}
|
||||
|
||||
test.describe('Cloud user secrets (API keys)', { tag: '@cloud' }, () => {
|
||||
test('an entitled account can add, list, and delete a provider key', async ({
|
||||
page
|
||||
}) => {
|
||||
test.slow()
|
||||
|
||||
await mockCloudBoot(page, {
|
||||
features: BOOT_FEATURES,
|
||||
settings: BOOT_SETTINGS
|
||||
})
|
||||
await bootCloud(page)
|
||||
const backend = await mockSecretsBackend(page, ['runway', 'gemini'])
|
||||
|
||||
await page.goto(APP_URL)
|
||||
await page.waitForFunction(() => !!window.app?.extensionManager, null, {
|
||||
timeout: 45_000
|
||||
})
|
||||
|
||||
const settingsDialog = await openSecretsPanel(page)
|
||||
|
||||
// Empty state before anything is added.
|
||||
await expect(settingsDialog.getByText(/No secrets stored/)).toBeVisible()
|
||||
|
||||
// --- ADD -------------------------------------------------------------
|
||||
await settingsDialog.getByRole('button', { name: 'Add Secret' }).click()
|
||||
|
||||
const formDialog = page
|
||||
.getByRole('dialog')
|
||||
.filter({ hasText: 'Secret Value' })
|
||||
await expect(formDialog).toBeVisible()
|
||||
|
||||
// Pick the entitled Runway provider from the server-driven dropdown.
|
||||
await formDialog.locator('#secret-provider').click()
|
||||
await page.getByRole('option', { name: 'Runway' }).click()
|
||||
|
||||
await formDialog.locator('#secret-name').fill('My Runway Key')
|
||||
await formDialog.locator('input[type="password"]').fill(RUNWAY_KEY_VALUE)
|
||||
|
||||
await formDialog.getByRole('button', { name: 'Save', exact: true }).click()
|
||||
await expect(formDialog).toBeHidden()
|
||||
|
||||
// --- LIST ------------------------------------------------------------
|
||||
await expect(settingsDialog.getByText('My Runway Key')).toBeVisible()
|
||||
await expect(settingsDialog.getByText(/No secrets stored/)).toBeHidden()
|
||||
|
||||
// The create request carried the plaintext value + provider...
|
||||
expect(backend.createRequests).toHaveLength(1)
|
||||
expect(backend.createRequests[0]).toMatchObject({
|
||||
name: 'My Runway Key',
|
||||
provider: 'runway',
|
||||
secret_value: RUNWAY_KEY_VALUE
|
||||
})
|
||||
// ...but the value must never be echoed back into the list — the API
|
||||
// response carries metadata only, so nothing should render it as text.
|
||||
await expect(page.getByText(RUNWAY_KEY_VALUE)).toHaveCount(0)
|
||||
|
||||
// --- DELETE ----------------------------------------------------------
|
||||
await settingsDialog
|
||||
.getByRole('button', { name: 'Delete', exact: true })
|
||||
.click()
|
||||
|
||||
const confirmDialog = page
|
||||
.getByRole('dialog')
|
||||
.filter({ hasText: 'Delete Secret' })
|
||||
await confirmDialog
|
||||
.getByRole('button', { name: 'Delete', exact: true })
|
||||
.click()
|
||||
|
||||
await expect(settingsDialog.getByText('My Runway Key')).toBeHidden()
|
||||
await expect(settingsDialog.getByText(/No secrets stored/)).toBeVisible()
|
||||
expect(backend.store).toHaveLength(0)
|
||||
})
|
||||
|
||||
test('a non-entitled account never sees the gated providers', async ({
|
||||
page
|
||||
}) => {
|
||||
test.slow()
|
||||
|
||||
await mockCloudBoot(page, {
|
||||
features: BOOT_FEATURES,
|
||||
settings: BOOT_SETTINGS
|
||||
})
|
||||
await bootCloud(page)
|
||||
// Non-entitled: the server omits runway/gemini from the allowlist.
|
||||
await mockSecretsBackend(page, [])
|
||||
|
||||
await page.goto(APP_URL)
|
||||
await page.waitForFunction(() => !!window.app?.extensionManager, null, {
|
||||
timeout: 45_000
|
||||
})
|
||||
|
||||
const settingsDialog = await openSecretsPanel(page)
|
||||
await expect(settingsDialog.getByText(/No secrets stored/)).toBeVisible()
|
||||
|
||||
// The add form opens, but its provider dropdown is empty — the gated
|
||||
// providers must not appear anywhere.
|
||||
await settingsDialog.getByRole('button', { name: 'Add Secret' }).click()
|
||||
const formDialog = page
|
||||
.getByRole('dialog')
|
||||
.filter({ hasText: 'Secret Value' })
|
||||
await expect(formDialog).toBeVisible()
|
||||
|
||||
await formDialog.locator('#secret-provider').click()
|
||||
// Anchor on the opened listbox so the absence assertions below can't pass
|
||||
// vacuously against a dropdown that never opened.
|
||||
const providerListbox = page.getByRole('listbox')
|
||||
await expect(providerListbox).toBeVisible()
|
||||
// An empty allowlist must yield an empty dropdown. Asserting zero options
|
||||
// (not just runway/gemini absent) also rejects the fetch-failure fallback,
|
||||
// where `availableProviders` is null and the default providers would show.
|
||||
await expect(providerListbox.getByRole('option')).toHaveCount(0)
|
||||
})
|
||||
})
|
||||
@@ -7,6 +7,10 @@ import type { BillingStatusResponse } from '@/platform/workspace/api/workspaceAp
|
||||
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
import { mockSystemStats } from '@e2e/fixtures/data/systemStats'
|
||||
import { CloudAuthHelper } from '@e2e/fixtures/helpers/CloudAuthHelper'
|
||||
import {
|
||||
mockWorkspaceTokenMint,
|
||||
workspace
|
||||
} from '@e2e/fixtures/utils/workspaceMocks'
|
||||
|
||||
// Drives a raw `page` (not the `comfyPage` fixture) so the cloud app boots
|
||||
// against fully mocked endpoints; `comfyPage` would try to reach the OSS
|
||||
@@ -97,6 +101,7 @@ async function mockCloudBoot(page: Page) {
|
||||
await page.route('**/api/auth/session', (r) =>
|
||||
r.fulfill(jsonRoute({ token: 'mock-workspace-token' }))
|
||||
)
|
||||
await mockWorkspaceTokenMint(page, workspace('personal', 'owner'))
|
||||
await page.route('**/releases**', (r) => r.fulfill(jsonRoute([])))
|
||||
|
||||
// Single personal workspace.
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 21 KiB After Width: | Height: | Size: 21 KiB |
@@ -1,7 +1,13 @@
|
||||
import { mergeTests } from '@playwright/test'
|
||||
|
||||
import {
|
||||
comfyPageFixture as test,
|
||||
comfyExpect as expect
|
||||
} from '@e2e/fixtures/ComfyPage'
|
||||
import { ExecutionHelper } from '@e2e/fixtures/helpers/ExecutionHelper'
|
||||
import { webSocketFixture } from '@e2e/fixtures/ws'
|
||||
|
||||
const wstest = mergeTests(test, webSocketFixture)
|
||||
|
||||
test.describe('Preview as Text node', () => {
|
||||
test('does not include preview widget values in the API prompt', async ({
|
||||
@@ -39,4 +45,34 @@ test.describe('Preview as Text node', () => {
|
||||
expect(previewEntry!.inputs).not.toHaveProperty('preview_text')
|
||||
expect(previewEntry!.inputs).not.toHaveProperty('previewMode')
|
||||
})
|
||||
|
||||
wstest(
|
||||
'restoring workflow restores state',
|
||||
{ tag: '@vue-nodes' },
|
||||
async ({ comfyPage, getWebSocket }) => {
|
||||
const execution = new ExecutionHelper(comfyPage, await getWebSocket())
|
||||
|
||||
await comfyPage.menu.topbar.newWorkflowButton.click()
|
||||
await comfyPage.searchBoxV2.addNode('Preview as Text')
|
||||
const node = await comfyPage.vueNodes.getFixtureByTitle('Preview as Text')
|
||||
const preview = node.root.locator('textarea')
|
||||
|
||||
await test.step('node previews execution result', async () => {
|
||||
const id = await comfyPage.vueNodes.getNodeIdByTitle('Preview as Text')
|
||||
execution.executed('', id, { text: 'massive fennec ears' })
|
||||
await expect(preview).toHaveValue('massive fennec ears')
|
||||
})
|
||||
|
||||
await test.step('swap to a different workflow and back', async () => {
|
||||
await comfyPage.menu.topbar.getTab(0).click()
|
||||
await expect(node.root).toBeHidden()
|
||||
await comfyPage.menu.topbar.getTab(1).click()
|
||||
await expect(node.root).toBeVisible()
|
||||
})
|
||||
|
||||
await expect(preview, 'previous output is restored').toHaveValue(
|
||||
'massive fennec ears'
|
||||
)
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { expect, mergeTests } from '@playwright/test'
|
||||
import type { Page, Route } from '@playwright/test'
|
||||
import type { Asset, ListAssetsResponse } from '@comfyorg/ingest-types'
|
||||
import type {
|
||||
Asset,
|
||||
GetAllSettingsResponse,
|
||||
GetSettingByIdResponse,
|
||||
ListAssetsResponse
|
||||
} from '@comfyorg/ingest-types'
|
||||
|
||||
import {
|
||||
assetRequestIncludesTag,
|
||||
@@ -8,6 +13,7 @@ import {
|
||||
} from '@e2e/fixtures/assetApiFixture'
|
||||
import { comfyPageFixture } from '@e2e/fixtures/ComfyPage'
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
import type { WorkspaceStore } from '@e2e/types/globals'
|
||||
import {
|
||||
routeObjectInfoFromSetupApi,
|
||||
setComboInputOptions
|
||||
@@ -23,10 +29,11 @@ import type { RawJobListItem } from '@/platform/remote/comfyui/jobs/jobTypes'
|
||||
const ossTest = mergeTests(comfyPageFixture, jobsRouteFixture)
|
||||
const outputHash =
|
||||
'147257c95a3e957e0deee73a077cfec89da2d906dd086ca70a2b0c897a9591d6e.png'
|
||||
const outputVideoHash = 'cloud-video-hash.mp4'
|
||||
const plainVideoFileName = 'plain_video.mp4'
|
||||
const graphDropPosition = { x: 500, y: 300 }
|
||||
const missingMediaUploadObservationMs = 1_000
|
||||
const missingMediaUploadPollMs = 100
|
||||
const missingMediaObservationMs = 1_000
|
||||
const missingMediaPollMs = 100
|
||||
const emptyMediaLoaderNodes = [
|
||||
{
|
||||
nodeType: 'LoadImage',
|
||||
@@ -60,6 +67,18 @@ const cloudOutputAsset: Asset & { hash?: string } = {
|
||||
last_access_time: '2026-05-01T00:00:00Z'
|
||||
}
|
||||
|
||||
const cloudOutputVideoAsset: Asset & { hash?: string } = {
|
||||
id: 'test-output-video-hash-001',
|
||||
name: 'ComfyUI_00001_.mp4',
|
||||
hash: outputVideoHash,
|
||||
size: 4_194_304,
|
||||
mime_type: 'video/mp4',
|
||||
tags: ['output'],
|
||||
created_at: '2026-05-01T00:00:00Z',
|
||||
updated_at: '2026-05-01T00:00:00Z',
|
||||
last_access_time: '2026-05-01T00:00:00Z'
|
||||
}
|
||||
|
||||
const cloudUploadedVideoAsset: Asset & { hash?: string } = {
|
||||
id: 'test-uploaded-video-001',
|
||||
name: plainVideoFileName,
|
||||
@@ -92,10 +111,21 @@ interface CloudUploadAssetState {
|
||||
|
||||
async function routeCloudBootstrapApis(page: Page) {
|
||||
await page.route('**/api/settings**', async (route) => {
|
||||
const completedSurveySetting: GetSettingByIdResponse = {
|
||||
value: { usage: 'personal' }
|
||||
}
|
||||
const allSettings: GetAllSettingsResponse = {}
|
||||
const body = route
|
||||
.request()
|
||||
.url()
|
||||
.includes('/api/settings/onboarding_survey')
|
||||
? completedSurveySetting
|
||||
: allSettings
|
||||
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({})
|
||||
body: JSON.stringify(body)
|
||||
})
|
||||
})
|
||||
await page.route('**/api/userdata**', async (route) => {
|
||||
@@ -121,7 +151,10 @@ async function routeCloudBootstrapApis(page: Page) {
|
||||
})
|
||||
}
|
||||
|
||||
const cloudOutputTest = createCloudAssetsFixture([cloudOutputAsset]).extend({
|
||||
const cloudOutputTest = createCloudAssetsFixture([
|
||||
cloudOutputAsset,
|
||||
cloudOutputVideoAsset
|
||||
]).extend({
|
||||
page: async ({ page }, use) => {
|
||||
await routeCloudBootstrapApis(page)
|
||||
const unrouteObjectInfo = await routeObjectInfoFromSetupApi(page)
|
||||
@@ -225,6 +258,33 @@ function getErrorOverlay(comfyPage: ComfyPage) {
|
||||
return comfyPage.page.getByTestId(TestIds.dialogs.errorOverlay)
|
||||
}
|
||||
|
||||
function isOutputAssetsRequest(url: string) {
|
||||
return url.includes('/api/assets') && assetRequestIncludesTag(url, 'output')
|
||||
}
|
||||
|
||||
async function waitForOutputAssetsResponse(comfyPage: ComfyPage) {
|
||||
await comfyPage.page.waitForResponse(
|
||||
(response) =>
|
||||
response.status() === 200 && isOutputAssetsRequest(response.url())
|
||||
)
|
||||
}
|
||||
|
||||
async function getCachedMissingMediaWarningNames(
|
||||
comfyPage: ComfyPage
|
||||
): Promise<string[] | null> {
|
||||
return await comfyPage.page.evaluate(() => {
|
||||
const workflow = (window.app!.extensionManager as WorkspaceStore).workflow
|
||||
.activeWorkflow
|
||||
if (!workflow) return null
|
||||
|
||||
return (
|
||||
workflow.pendingWarnings?.missingMediaCandidates?.map(
|
||||
(candidate) => candidate.name
|
||||
) ?? []
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
async function expectNoErrorsTab(comfyPage: ComfyPage) {
|
||||
await expect(getErrorOverlay(comfyPage)).toBeHidden()
|
||||
|
||||
@@ -327,25 +387,31 @@ async function expectLoadVideoUploading(comfyPage: ComfyPage) {
|
||||
.toBe(true)
|
||||
}
|
||||
|
||||
async function expectNoMissingMediaDuringUpload(comfyPage: ComfyPage) {
|
||||
async function expectNoMissingMediaForObservationWindow(comfyPage: ComfyPage) {
|
||||
await comfyPage.nextFrame()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
let sawErrorOverlay = false
|
||||
let sawCachedMissingMedia = false
|
||||
const startedAt = Date.now()
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const cachedMissingMedia =
|
||||
await getCachedMissingMediaWarningNames(comfyPage)
|
||||
sawCachedMissingMedia =
|
||||
sawCachedMissingMedia || !!cachedMissingMedia?.length
|
||||
sawErrorOverlay =
|
||||
sawErrorOverlay || (await getErrorOverlay(comfyPage).isVisible())
|
||||
return (
|
||||
!sawErrorOverlay &&
|
||||
Date.now() - startedAt >= missingMediaUploadObservationMs
|
||||
!sawCachedMissingMedia &&
|
||||
Date.now() - startedAt >= missingMediaObservationMs
|
||||
)
|
||||
},
|
||||
{
|
||||
timeout: missingMediaUploadObservationMs + missingMediaUploadPollMs * 5,
|
||||
intervals: [missingMediaUploadPollMs]
|
||||
timeout: missingMediaObservationMs + missingMediaPollMs * 5,
|
||||
intervals: [missingMediaPollMs]
|
||||
}
|
||||
)
|
||||
.toBe(true)
|
||||
@@ -424,7 +490,7 @@ ossTest.describe(
|
||||
})
|
||||
|
||||
await expectLoadVideoUploading(comfyPage)
|
||||
await expectNoMissingMediaDuringUpload(comfyPage)
|
||||
await expectNoMissingMediaForObservationWindow(comfyPage)
|
||||
|
||||
await delayedUpload.finishUpload()
|
||||
await expect(getErrorOverlay(comfyPage)).toBeHidden()
|
||||
@@ -482,18 +548,30 @@ cloudOutputTest.describe(
|
||||
|
||||
cloudOutputTest(
|
||||
'resolves compact annotated output media from output assets',
|
||||
async ({ cloudAssetRequests, comfyPage }) => {
|
||||
async ({ comfyPage }) => {
|
||||
const outputAssetsResponse = waitForOutputAssetsResponse(comfyPage)
|
||||
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'missing/missing_media_cloud_output_annotation'
|
||||
)
|
||||
|
||||
await expect
|
||||
.poll(() =>
|
||||
cloudAssetRequests.some((url) =>
|
||||
assetRequestIncludesTag(url, 'output')
|
||||
)
|
||||
)
|
||||
.toBe(true)
|
||||
await outputAssetsResponse
|
||||
await expectNoMissingMediaForObservationWindow(comfyPage)
|
||||
await expectNoErrorsTab(comfyPage)
|
||||
}
|
||||
)
|
||||
|
||||
cloudOutputTest(
|
||||
'resolves subfoldered output video media from flat output asset hashes',
|
||||
async ({ comfyPage }) => {
|
||||
const outputAssetsResponse = waitForOutputAssetsResponse(comfyPage)
|
||||
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'missing/missing_media_cloud_output_video_subfolder'
|
||||
)
|
||||
|
||||
await outputAssetsResponse
|
||||
await expectNoMissingMediaForObservationWindow(comfyPage)
|
||||
await expectNoErrorsTab(comfyPage)
|
||||
}
|
||||
)
|
||||
@@ -529,7 +607,7 @@ cloudUploadRaceTest.describe(
|
||||
})
|
||||
|
||||
await expectLoadVideoUploading(comfyPage)
|
||||
await expectNoMissingMediaDuringUpload(comfyPage)
|
||||
await expectNoMissingMediaForObservationWindow(comfyPage)
|
||||
|
||||
markUploadedCloudAssetAvailable()
|
||||
await delayedUpload.finishUpload()
|
||||
|
||||
@@ -286,7 +286,7 @@ test.describe('Errors tab - Mode-aware errors', { tag: '@ui' }, () => {
|
||||
await expect(missingModelGroup).toBeHidden()
|
||||
})
|
||||
|
||||
test('Selecting a node filters errors tab to only that node', async ({
|
||||
test('Selecting a node keeps all errors visible and shows selection context', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await loadWorkflowAndOpenErrorsTab(
|
||||
@@ -301,14 +301,25 @@ test.describe('Errors tab - Mode-aware errors', { tag: '@ui' }, () => {
|
||||
|
||||
const node1 = await comfyPage.nodeOps.getNodeRefById('1')
|
||||
await node1.click('title')
|
||||
|
||||
await expect(
|
||||
getMissingModelLabel(missingModelGroup, FAKE_MODEL_NAME)
|
||||
).toBeVisible()
|
||||
await expectReferenceBadge(missingModelGroup, 2)
|
||||
const strip = comfyPage.page.getByTestId(
|
||||
TestIds.propertiesPanel.selectionContextStrip
|
||||
)
|
||||
await expect(strip).toBeVisible()
|
||||
await expect(
|
||||
missingModelGroup.getByTestId(TestIds.dialogs.missingModelLocate)
|
||||
).toHaveCount(1)
|
||||
strip,
|
||||
'The strip count is scoped to the selection, diverging from the global reference badge'
|
||||
).toContainText('1 error')
|
||||
|
||||
await comfyPage.canvas.click()
|
||||
await expect(
|
||||
strip,
|
||||
'Deselecting swaps the always-visible strip back to the summary'
|
||||
).toContainText('2 nodes — 1 error')
|
||||
await expectReferenceBadge(missingModelGroup, 2)
|
||||
})
|
||||
})
|
||||
@@ -381,7 +392,7 @@ test.describe('Errors tab - Mode-aware errors', { tag: '@ui' }, () => {
|
||||
await expect(missingMediaGroup).toBeHidden()
|
||||
})
|
||||
|
||||
test('Selecting a node filters errors tab to only that node', async ({
|
||||
test('Selecting a node keeps all media rows visible and shows selection context', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow('missing/missing_media_multiple')
|
||||
@@ -403,13 +414,66 @@ test.describe('Errors tab - Mode-aware errors', { tag: '@ui' }, () => {
|
||||
|
||||
const node = await comfyPage.nodeOps.getNodeRefById('10')
|
||||
await node.click('title')
|
||||
await expect(mediaRows).toHaveCount(1)
|
||||
|
||||
// Selection no longer filters the list — rows stay global and the
|
||||
// selection is surfaced via the context strip instead.
|
||||
const strip = comfyPage.page.getByTestId(
|
||||
TestIds.propertiesPanel.selectionContextStrip
|
||||
)
|
||||
await expect(strip).toBeVisible()
|
||||
await expect(strip).toContainText('1 error')
|
||||
await expect(mediaRows).toHaveCount(2)
|
||||
|
||||
await comfyPage.canvas.click({ position: { x: 400, y: 600 } })
|
||||
// Deselecting swaps the always-visible strip back to the summary
|
||||
await expect(strip).toContainText('2 nodes — 2 errors')
|
||||
await expect(mediaRows).toHaveCount(2)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Selection emphasis', () => {
|
||||
test('Selecting a node collapses unrelated groups and highlights its rows', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await loadWorkflowAndOpenErrorsTab(
|
||||
comfyPage,
|
||||
'missing/missing_nodes_and_media'
|
||||
)
|
||||
|
||||
const missingNodeCard = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.missingNodeCard
|
||||
)
|
||||
const mediaRow = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.missingMediaRow
|
||||
)
|
||||
const strip = comfyPage.page.getByTestId(
|
||||
TestIds.propertiesPanel.selectionContextStrip
|
||||
)
|
||||
await expect(missingNodeCard).toBeVisible()
|
||||
await expect(mediaRow).toBeVisible()
|
||||
await expect(strip).toContainText('2 nodes — 2 errors')
|
||||
|
||||
const mediaNode = await comfyPage.nodeOps.getNodeRefById('10')
|
||||
// The node sits near the canvas top where overlays intercept clicks
|
||||
await mediaNode.centerOnNode()
|
||||
await mediaNode.click('title')
|
||||
|
||||
// The unrelated missing-node group auto-collapses while the matched
|
||||
// media row stays visible and is marked as part of the selection
|
||||
await expect(missingNodeCard).toBeHidden()
|
||||
await expect(mediaRow).toBeVisible()
|
||||
await expect(mediaRow).toHaveAttribute('aria-current', 'true')
|
||||
await expect(strip).toContainText('1 error')
|
||||
|
||||
await comfyPage.canvas.click({ position: { x: 400, y: 600 } })
|
||||
// Emphasis ends: the collapsed group re-expands and the strip
|
||||
// returns to the workflow summary
|
||||
await expect(missingNodeCard).toBeVisible()
|
||||
await expect(mediaRow).not.toHaveAttribute('aria-current', 'true')
|
||||
await expect(strip).toContainText('2 nodes — 2 errors')
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Subgraph', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await cleanupFakeModel(comfyPage)
|
||||
|
||||
@@ -23,6 +23,7 @@ import { webSocketFixture } from '@e2e/fixtures/ws'
|
||||
const test = mergeTests(comfyPageFixture, webSocketFixture)
|
||||
|
||||
const ERROR_CLASS = /ring-destructive-background/
|
||||
const SLOT_ERROR_CLASS = /before:ring-error/
|
||||
const UNKNOWN_NODE_ID = '1'
|
||||
const INNER_EXECUTION_ID = '2:1'
|
||||
const KSAMPLER_MODEL_INPUT_NAME = 'model'
|
||||
@@ -69,6 +70,25 @@ async function selectLoadImageNodeForPaste(
|
||||
}, localLoadImageId)
|
||||
}
|
||||
|
||||
async function getInputSlotIndexByName(
|
||||
comfyPage: ComfyPage,
|
||||
nodeId: string,
|
||||
inputName: string
|
||||
): Promise<number> {
|
||||
return comfyPage.page.evaluate(
|
||||
({ inputName, nodeId }) => {
|
||||
const graph = window.app!.canvas.graph ?? window.app!.graph
|
||||
const node = graph.getNodeById(nodeId)
|
||||
const index = node?.findInputSlot(inputName) ?? -1
|
||||
if (index < 0) {
|
||||
throw new Error(`Input slot "${inputName}" not found`)
|
||||
}
|
||||
return index
|
||||
},
|
||||
{ inputName, nodeId: toNodeId(nodeId) }
|
||||
)
|
||||
}
|
||||
|
||||
async function setupLoadImageErrorScenario(comfyPage: ComfyPage) {
|
||||
await comfyPage.workflow.loadWorkflow('widgets/load_image_widget')
|
||||
const loadImageNode = (
|
||||
@@ -139,17 +159,10 @@ test.describe('Vue Node Error', { tag: '@vue-nodes' }, () => {
|
||||
async ({ comfyPage }) => {
|
||||
const ksamplerId = await comfyPage.vueNodes.getNodeIdByTitle('KSampler')
|
||||
const ksamplerNode = comfyPage.vueNodes.getNodeLocator(ksamplerId)
|
||||
const modelInputIndex = await comfyPage.page.evaluate(
|
||||
({ nodeId, inputName }) => {
|
||||
const node = window.app!.graph.getNodeById(nodeId)
|
||||
const index =
|
||||
node?.inputs?.findIndex((input) => input.name === inputName) ?? -1
|
||||
if (index < 0) {
|
||||
throw new Error(`Input slot "${inputName}" not found`)
|
||||
}
|
||||
return index
|
||||
},
|
||||
{ nodeId: toNodeId(ksamplerId), inputName: KSAMPLER_MODEL_INPUT_NAME }
|
||||
const modelInputIndex = await getInputSlotIndexByName(
|
||||
comfyPage,
|
||||
ksamplerId,
|
||||
KSAMPLER_MODEL_INPUT_NAME
|
||||
)
|
||||
const modelInputSlotRow = comfyPage.vueNodes.getInputSlotRow(
|
||||
ksamplerId,
|
||||
@@ -175,7 +188,7 @@ test.describe('Vue Node Error', { tag: '@vue-nodes' }, () => {
|
||||
|
||||
await expect(modelInputSlotRow).toBeVisible()
|
||||
await expect(modelInputSlotRow).toBeInViewport()
|
||||
await expect(modelInputSlotHighlight).toHaveClass(/before:ring-error/)
|
||||
await expect(modelInputSlotHighlight).toHaveClass(SLOT_ERROR_CLASS)
|
||||
await expect(
|
||||
comfyPage.vueNodes.getNodeInnerWrapper(ksamplerId)
|
||||
).toHaveClass(ERROR_CLASS)
|
||||
@@ -407,5 +420,76 @@ test.describe('Vue Node Error', { tag: '@vue-nodes' }, () => {
|
||||
|
||||
await expect(innerWrapper).toHaveClass(ERROR_CLASS)
|
||||
})
|
||||
|
||||
test('boundary-linked validation error surfaces on the subgraph host', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
|
||||
const subgraphParentId =
|
||||
await comfyPage.vueNodes.getNodeIdByTitle('New Subgraph')
|
||||
const innerWrapper =
|
||||
comfyPage.vueNodes.getNodeInnerWrapper(subgraphParentId)
|
||||
const hostInputIndex = await getInputSlotIndexByName(
|
||||
comfyPage,
|
||||
subgraphParentId,
|
||||
'positive'
|
||||
)
|
||||
const hostInputSlotHighlight =
|
||||
comfyPage.vueNodes.getInputSlotConnectionDot(
|
||||
subgraphParentId,
|
||||
hostInputIndex
|
||||
)
|
||||
await expect(
|
||||
innerWrapper,
|
||||
'subgraph host must mount before injecting validation errors'
|
||||
).toBeVisible()
|
||||
await expect(
|
||||
innerWrapper,
|
||||
'subgraph host should start without an error ring'
|
||||
).not.toHaveClass(ERROR_CLASS)
|
||||
|
||||
await test.step('surface the boundary-linked error on the host', async () => {
|
||||
const exec = new ExecutionHelper(comfyPage)
|
||||
await exec.mockValidationFailure({
|
||||
[INNER_EXECUTION_ID]: buildKSamplerError(
|
||||
'required_input_missing',
|
||||
'positive',
|
||||
'Required input is missing: positive'
|
||||
)
|
||||
})
|
||||
await comfyPage.runButton.click()
|
||||
await dismissErrorOverlay(comfyPage)
|
||||
|
||||
await expect(innerWrapper).toHaveClass(ERROR_CLASS)
|
||||
await expect(hostInputSlotHighlight).toHaveClass(SLOT_ERROR_CLASS)
|
||||
})
|
||||
|
||||
await test.step('confirm the interior node does not show the surfaced ring', async () => {
|
||||
await comfyPage.vueNodes.enterSubgraph(subgraphParentId)
|
||||
await comfyPage.nextFrame()
|
||||
await expect.poll(() => comfyPage.subgraph.isInSubgraph()).toBe(true)
|
||||
const interiorKSamplerId =
|
||||
await comfyPage.vueNodes.getNodeIdByTitle('KSampler')
|
||||
const interiorPositiveInputIndex = await getInputSlotIndexByName(
|
||||
comfyPage,
|
||||
interiorKSamplerId,
|
||||
'positive'
|
||||
)
|
||||
const interiorPositiveSlotHighlight =
|
||||
comfyPage.vueNodes.getInputSlotConnectionDot(
|
||||
interiorKSamplerId,
|
||||
interiorPositiveInputIndex
|
||||
)
|
||||
const interiorInnerWrapper =
|
||||
comfyPage.vueNodes.getNodeInnerWrapper(interiorKSamplerId)
|
||||
|
||||
await expect(interiorInnerWrapper).toBeVisible()
|
||||
await expect(interiorInnerWrapper).not.toHaveClass(ERROR_CLASS)
|
||||
await expect(interiorPositiveSlotHighlight).toBeVisible()
|
||||
await expect(interiorPositiveSlotHighlight).not.toHaveClass(
|
||||
SLOT_ERROR_CLASS
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
120
docs/adr/0011-derived-credential-lifecycle.md
Normal file
120
docs/adr/0011-derived-credential-lifecycle.md
Normal file
@@ -0,0 +1,120 @@
|
||||
# 11. Derived Credential Lifecycle for Cloud Auth
|
||||
|
||||
Date: 2026-07-09
|
||||
|
||||
## Status
|
||||
|
||||
Proposed
|
||||
|
||||
<!-- [Proposed | Accepted | Rejected | Deprecated | Superseded by [ADR-NNNN](NNNN-title.md)] -->
|
||||
|
||||
## Context
|
||||
|
||||
Cloud authentication derives several short-lived credentials from a single
|
||||
source of truth — the Firebase identity (ID token):
|
||||
|
||||
- the **workspace JWT** minted by exchanging the Firebase token (`workspaceAuthStore`),
|
||||
- the **session cookie** created by POSTing the Firebase token to `/auth/session`
|
||||
(`useSessionCookie`),
|
||||
- and consumer state gated on those credentials, such as **subscription status**
|
||||
(`useSubscription`).
|
||||
|
||||
A recurring class of production bugs traces back to how these derived credentials
|
||||
are kept fresh rather than to any single code path:
|
||||
|
||||
- **FE-613** — workspace token exchange is not reactive to Firebase auth state.
|
||||
Its refresh relies on a `setTimeout` timer that browsers throttle in background
|
||||
tabs, so a backgrounded session serves an expired workspace JWT and every cloud
|
||||
call 401s until reload.
|
||||
- **Workspace/personal oscillation** (PR #13511) — when a valid workspace token is
|
||||
momentarily absent, `getAuthHeader`/`getAuthToken` silently downgraded to the
|
||||
personal Firebase token, so requests authenticated as the wrong identity.
|
||||
- **Run-button toggle loop** (Slack, related to FE-1072) — a Firebase token-refresh
|
||||
burst on wake/network-swap fans out into concurrent, undeduped subscription
|
||||
fetches racing an in-flight session-cookie rotation; some land pre-rotation and
|
||||
return 401/empty, flapping `subscriptionStatus` and the run button.
|
||||
|
||||
These are not independent defects. They are symptoms of one design shape: **each
|
||||
derived credential has its own ad-hoc refresh lifecycle, driven by timers or
|
||||
one-shot events rather than the source identity, with no coalescing of concurrent
|
||||
refreshes and with silent fallback to a different identity or a stale value on
|
||||
failure.** Any credential built this way can go stale, stampede, or downgrade.
|
||||
|
||||
## Decision
|
||||
|
||||
Treat every derived credential as a pure function of the Firebase identity, and
|
||||
require all of them to obey the same lifecycle invariants. New auth code must
|
||||
satisfy these; existing code migrates toward them incrementally.
|
||||
|
||||
1. **Single source of truth.** The Firebase identity is authoritative. Workspace
|
||||
JWT and session cookie are derivations of it, never independent state that can
|
||||
drift from it.
|
||||
|
||||
2. **Valid-on-read.** A caller asking for a credential gets a currently-valid one
|
||||
or a definitive failure — never a known-expired one. Validity is checked at the
|
||||
point of use (expiry-aware), not assumed because a background timer _should_
|
||||
have refreshed. Timers may be an optimization, never the guarantee.
|
||||
|
||||
3. **Single-flight.** Concurrent requests for the same credential share one
|
||||
in-flight mint/refresh. A refresh burst collapses to a single network call.
|
||||
|
||||
4. **Fail-closed, never downgrade.** If the correct-scope credential cannot be
|
||||
obtained, fail the request. Never silently substitute a different identity or
|
||||
scope (e.g. personal token for a workspace request).
|
||||
|
||||
5. **Bounded reactive retry.** Invalidation is driven by the source identity
|
||||
(`onIdTokenChanged`), not by polling or wall-clock timers alone. A `401` on a
|
||||
derived credential triggers at most one re-mint and one retry, then surfaces
|
||||
the error.
|
||||
|
||||
6. **Explicit scope.** A credential names the identity/workspace it is for.
|
||||
Coalesced results are verified against the requested scope before use.
|
||||
|
||||
PR #13511 is the first increment: workspace-token recovery is now valid-on-read,
|
||||
single-flight, fail-closed, and reconciles a revoked workspace instead of
|
||||
downgrading; subscription-status and session-cookie creation are now
|
||||
single-flight so a refresh burst can no longer flap them. It intentionally does
|
||||
**not** yet add the `onIdTokenChanged` subscription FE-613 proposes — recovery is
|
||||
lazy (on read) rather than reactive (on refresh). Invariant 5 is the remaining
|
||||
gap and is tracked by FE-950 (Unified Cloud Auth) and FE-963 (reactive 401
|
||||
re-mint + single retry).
|
||||
|
||||
Alternatives considered:
|
||||
|
||||
- **Layer more defensive checks per call site.** Rejected: this is what produced
|
||||
the current state — correctness that depends on every caller remembering to
|
||||
guard is the defect, not the fix.
|
||||
- **A single reactive credential store subscribing to Firebase, replacing all
|
||||
three ad-hoc lifecycles at once.** Deferred, not rejected: it is the target
|
||||
end-state, but a big-bang rewrite of live auth is too risky. We migrate under
|
||||
these invariants incrementally instead.
|
||||
|
||||
## Consequences
|
||||
|
||||
### Positive
|
||||
|
||||
- Whole categories of failure become structurally hard rather than individually
|
||||
patched: stale-on-wake (invariant 2), refresh stampede (3), wrong-identity
|
||||
requests (4).
|
||||
- New auth code has a single checklist to satisfy, and reviewers a single rubric
|
||||
to apply.
|
||||
- Establishes a shared vocabulary (valid-on-read, single-flight, fail-closed) for
|
||||
reasoning about auth changes.
|
||||
|
||||
### Negative
|
||||
|
||||
- Fail-closed surfaces auth failures that silent downgrade previously masked; some
|
||||
transient conditions now show errors instead of degrading quietly, so
|
||||
transient-vs-permanent classification must be correct.
|
||||
- The invariants are not yet fully realized. Until invariant 5 lands, recovery is
|
||||
lazy and a backgrounded tab still relies on the next read to heal, leaving a
|
||||
visible gap against FE-613's reactive ideal.
|
||||
- Existing lifecycles remain non-uniform during migration, so the mental model is
|
||||
"target vs. current" until the reactive credential store exists.
|
||||
|
||||
## Notes
|
||||
|
||||
- Related: [ADR-0003](0003-crdt-based-layout-system.md) is unrelated in domain but
|
||||
shares the philosophy of designing invariants that make illegal states
|
||||
unrepresentable rather than guarding against them per call site.
|
||||
- Tickets: FE-613, FE-950, FE-963, FE-1072. PR: #13511.
|
||||
@@ -20,6 +20,7 @@ An Architecture Decision Record captures an important architectural decision mad
|
||||
| [0008](0008-entity-component-system.md) | Entity Component System | Proposed | 2026-03-23 |
|
||||
| [0009](0009-subgraph-promoted-widgets-use-linked-inputs.md) | Subgraph Promoted Widgets Use Linked Inputs | Proposed | 2026-05-05 |
|
||||
| [0010](0010-remove-nx-orchestration.md) | Remove Nx Orchestration | Accepted | 2026-05-19 |
|
||||
| [0011](0011-derived-credential-lifecycle.md) | Derived Credential Lifecycle for Cloud Auth | Proposed | 2026-07-09 |
|
||||
|
||||
## Creating a New ADR
|
||||
|
||||
|
||||
@@ -4,11 +4,12 @@ This guide provides an overview of testing approaches used in the ComfyUI Fronte
|
||||
|
||||
## Testing Documentation
|
||||
|
||||
Documentation for unit tests is organized into three guides:
|
||||
Documentation for unit tests is organized into four guides:
|
||||
|
||||
- [Component Testing](./component-testing.md) - How to test Vue components
|
||||
- [Unit Testing](./unit-testing.md) - How to test utility functions, composables, and other non-component code
|
||||
- [Store Testing](./store-testing.md) - How to test Pinia stores specifically
|
||||
- [LiteGraph Testing](./litegraph-testing.md) - How to test LiteGraph graph, node, link, and workflow behavior
|
||||
|
||||
## Testing Structure
|
||||
|
||||
|
||||
9
docs/testing/litegraph-testing.md
Normal file
9
docs/testing/litegraph-testing.md
Normal file
@@ -0,0 +1,9 @@
|
||||
# LiteGraph Testing Guide
|
||||
|
||||
This guide covers test patterns for LiteGraph graph, node, link, subgraph, and workflow behavior in ComfyUI Frontend.
|
||||
|
||||
## Shared Factories
|
||||
|
||||
Reuse shared factories in `src/utils/__tests__/litegraphTestUtils.ts` instead of hand-rolling LiteGraph node, canvas, graph, subgraph, or workflow builders.
|
||||
|
||||
Use real LiteGraph instances or shared factories when they exercise behavior directly. Avoid mocking LiteGraph classes unless the test is intentionally checking a seam outside LiteGraph itself.
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@comfyorg/comfyui-frontend",
|
||||
"version": "1.47.6",
|
||||
"version": "1.47.9",
|
||||
"private": true,
|
||||
"description": "Official front-end implementation of ComfyUI",
|
||||
"homepage": "https://comfy.org",
|
||||
|
||||
@@ -3,6 +3,7 @@ import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
appendWorkflowJsonExt,
|
||||
ensureWorkflowSuffix,
|
||||
escapeVueI18nMessageSyntax,
|
||||
formatLocalizedMediumDate,
|
||||
formatLocalizedNumber,
|
||||
getFilePathSeparatorVariants,
|
||||
@@ -414,15 +415,15 @@ describe('formatUtil', () => {
|
||||
})
|
||||
|
||||
describe('isPreviewableMediaType', () => {
|
||||
it('returns true for image/video/audio/3D', () => {
|
||||
it('returns true for image/video/audio/3D/text', () => {
|
||||
expect(isPreviewableMediaType('image')).toBe(true)
|
||||
expect(isPreviewableMediaType('video')).toBe(true)
|
||||
expect(isPreviewableMediaType('audio')).toBe(true)
|
||||
expect(isPreviewableMediaType('3D')).toBe(true)
|
||||
expect(isPreviewableMediaType('text')).toBe(true)
|
||||
})
|
||||
|
||||
it('returns false for text/other', () => {
|
||||
expect(isPreviewableMediaType('text')).toBe(false)
|
||||
it('returns false for other', () => {
|
||||
expect(isPreviewableMediaType('other')).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -477,4 +478,49 @@ describe('formatUtil', () => {
|
||||
expect(formatLocalizedMediumDate('not a date', 'en')).toBe('—')
|
||||
})
|
||||
})
|
||||
|
||||
describe('escapeVueI18nMessageSyntax', () => {
|
||||
it('escapes a literal @ that would break linked-message compilation', () => {
|
||||
expect(
|
||||
escapeVueI18nMessageSyntax('clips (tagged @Audio1-3 in the prompt)')
|
||||
).toBe("clips (tagged {'@'}Audio1-3 in the prompt)")
|
||||
})
|
||||
|
||||
it('escapes @ in an email address', () => {
|
||||
expect(escapeVueI18nMessageSyntax('support@comfy.org')).toBe(
|
||||
"support{'@'}comfy.org"
|
||||
)
|
||||
})
|
||||
|
||||
it('escapes interpolation braces', () => {
|
||||
expect(escapeVueI18nMessageSyntax('size {w}x{h}')).toBe(
|
||||
"size {'{'}w{'}'}x{'{'}h{'}'}"
|
||||
)
|
||||
})
|
||||
|
||||
it('escapes the plural pipe separator', () => {
|
||||
expect(escapeVueI18nMessageSyntax('foreground | background')).toBe(
|
||||
"foreground {'|'} background"
|
||||
)
|
||||
})
|
||||
|
||||
it('escapes the modulo percent so it cannot re-form %{', () => {
|
||||
expect(escapeVueI18nMessageSyntax('50%{done}')).toBe(
|
||||
"50{'%'}{'{'}done{'}'}"
|
||||
)
|
||||
})
|
||||
|
||||
it('escapes every occurrence in a single pass', () => {
|
||||
expect(escapeVueI18nMessageSyntax('@a @b @c')).toBe(
|
||||
"{'@'}a {'@'}b {'@'}c"
|
||||
)
|
||||
})
|
||||
|
||||
it('leaves strings without syntax characters unchanged', () => {
|
||||
expect(escapeVueI18nMessageSyntax('no special chars here')).toBe(
|
||||
'no special chars here'
|
||||
)
|
||||
expect(escapeVueI18nMessageSyntax('')).toBe('')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -178,6 +178,40 @@ export function normalizeI18nKey(key: string) {
|
||||
return typeof key === 'string' ? key.replace(/\./g, '_') : ''
|
||||
}
|
||||
|
||||
/**
|
||||
* Characters that vue-i18n's message compiler treats as syntax in message text,
|
||||
* so plain text has to escape them to render verbatim through `t()`/`st()`:
|
||||
*
|
||||
* - `@` starts a linked-message reference (`@:key`); malformed usage throws
|
||||
* `Invalid linked format`.
|
||||
* - `{` / `}` delimit interpolation (`{name}`, `{'literal'}`); an unbalanced
|
||||
* brace throws `Unterminated/Unbalanced closing brace`.
|
||||
* - `|` separates plural branches, so `a | b` silently renders as one branch.
|
||||
* - `%` forms modulo interpolation when immediately followed by `{` (`%{name}`);
|
||||
* it must be escaped too, otherwise escaping a following `{` re-forms `%{`.
|
||||
*
|
||||
* The set is a build-inlined `const enum` (`TokenChars`) in
|
||||
* `@intlify/message-compiler` and is not exported, so it is hardcoded here.
|
||||
*
|
||||
* @see https://vue-i18n.intlify.dev/guide/essentials/syntax (Special Characters, Literal interpolation)
|
||||
* @see https://vue-i18n.intlify.dev/guide/essentials/pluralization
|
||||
*/
|
||||
const VUE_I18N_SYNTAX_CHARS = /[@{}|%]/g
|
||||
|
||||
/**
|
||||
* Escapes vue-i18n message-syntax characters as literal interpolations (`{'x'}`)
|
||||
* so arbitrary text renders verbatim instead of being parsed as syntax. This is
|
||||
* the only escape vue-i18n supports; see {@link VUE_I18N_SYNTAX_CHARS}.
|
||||
*
|
||||
* Only apply to values read through the compiler (`t()`/`st()`). Values read raw
|
||||
* via `tm()`/`stRaw()` (e.g. node tooltips) must be left untouched, or the
|
||||
* literal `{'x'}` would surface to users. Apply exactly once to raw text: the
|
||||
* escape output itself contains `{`/`}`, so it is not idempotent.
|
||||
*/
|
||||
export function escapeVueI18nMessageSyntax(text: string): string {
|
||||
return text.replace(VUE_I18N_SYNTAX_CHARS, (char) => `{'${char}'}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Takes a dynamic prompt in the format {opt1|opt2|{optA|optB}|} and randomly replaces groups. Supports C style comments.
|
||||
* @param input The dynamic prompt to process
|
||||
@@ -677,12 +711,7 @@ export function getMediaTypeFromFilename(
|
||||
}
|
||||
|
||||
export function isPreviewableMediaType(mediaType: MediaType): boolean {
|
||||
return (
|
||||
mediaType === 'image' ||
|
||||
mediaType === 'video' ||
|
||||
mediaType === 'audio' ||
|
||||
mediaType === '3D'
|
||||
)
|
||||
return mediaType !== 'other'
|
||||
}
|
||||
|
||||
export function formatTime(seconds: number): string {
|
||||
|
||||
3
public/assets/images/gemini.svg
Normal file
3
public/assets/images/gemini.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" role="img" aria-label="Google Gemini">
|
||||
<path d="M12 1c.6 5.4 4.6 9.4 10 10-5.4.6-9.4 4.6-10 10-.6-5.4-4.6-9.4-10-10 5.4-.6 9.4-4.6 10-10z" fill="#4285F4"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 248 B |
4
public/assets/images/runway.svg
Normal file
4
public/assets/images/runway.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" role="img" aria-label="Runway">
|
||||
<rect width="24" height="24" rx="5" fill="#6E56CF"/>
|
||||
<path d="M9.5 8.2v7.6l6.3-3.8z" fill="#ffffff"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 228 B |
120
scripts/cicd/resolve-comfyui-release.test.ts
Normal file
120
scripts/cicd/resolve-comfyui-release.test.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
import fs from 'fs'
|
||||
import os from 'os'
|
||||
import path from 'path'
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
computeTargetVersion,
|
||||
isValidSemver,
|
||||
parseRequirementsVersion,
|
||||
parseTargetBranchOverride
|
||||
} from './resolve-comfyui-release'
|
||||
|
||||
describe('parseRequirementsVersion', () => {
|
||||
let dir: string
|
||||
|
||||
beforeEach(() => {
|
||||
dir = fs.mkdtempSync(path.join(os.tmpdir(), 'resolve-release-'))
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(dir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
function writeRequirements(content: string): string {
|
||||
const filePath = path.join(dir, 'requirements.txt')
|
||||
fs.writeFileSync(filePath, content)
|
||||
return filePath
|
||||
}
|
||||
|
||||
it('parses a pinned == version', () => {
|
||||
const file = writeRequirements(
|
||||
'torch\ncomfyui-frontend-package==1.45.20\nnumpy'
|
||||
)
|
||||
expect(parseRequirementsVersion(file)).toBe('1.45.20')
|
||||
})
|
||||
|
||||
it('parses a >= constraint', () => {
|
||||
const file = writeRequirements('comfyui-frontend-package>=2.0.3')
|
||||
expect(parseRequirementsVersion(file)).toBe('2.0.3')
|
||||
})
|
||||
|
||||
it('returns null when the package is absent', () => {
|
||||
const file = writeRequirements('torch\nnumpy')
|
||||
expect(parseRequirementsVersion(file)).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null when the file is missing', () => {
|
||||
expect(parseRequirementsVersion(path.join(dir, 'nope.txt'))).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('isValidSemver', () => {
|
||||
it('accepts a valid X.Y.Z', () => {
|
||||
expect(isValidSemver('1.45.20')).toBe(true)
|
||||
expect(isValidSemver('2.0.0')).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects non-three-part versions', () => {
|
||||
expect(isValidSemver('1.45')).toBe(false)
|
||||
expect(isValidSemver('1.45.20.1')).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects non-numeric or leading-zero-padded parts', () => {
|
||||
expect(isValidSemver('1.x.0')).toBe(false)
|
||||
expect(isValidSemver('v1.45.20')).toBe(false)
|
||||
expect(isValidSemver('1.045.20')).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects empty / non-string input', () => {
|
||||
expect(isValidSemver('')).toBe(false)
|
||||
// @ts-expect-error exercising runtime guard
|
||||
expect(isValidSemver(undefined)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('parseTargetBranchOverride', () => {
|
||||
it('parses core/1.47', () => {
|
||||
expect(parseTargetBranchOverride('core/1.47')).toEqual({
|
||||
major: 1,
|
||||
minor: 47,
|
||||
branch: 'core/1.47'
|
||||
})
|
||||
})
|
||||
|
||||
it('parses a major bump core/2.0', () => {
|
||||
expect(parseTargetBranchOverride('core/2.0')).toEqual({
|
||||
major: 2,
|
||||
minor: 0,
|
||||
branch: 'core/2.0'
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects malformed overrides', () => {
|
||||
expect(parseTargetBranchOverride('core/1')).toBeNull()
|
||||
expect(parseTargetBranchOverride('1.47')).toBeNull()
|
||||
expect(parseTargetBranchOverride('core/1.47.0')).toBeNull()
|
||||
expect(parseTargetBranchOverride('release/1.47')).toBeNull()
|
||||
expect(parseTargetBranchOverride('core/v1.47')).toBeNull()
|
||||
expect(parseTargetBranchOverride('')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('computeTargetVersion', () => {
|
||||
it('bumps patch on a 2.x line when commits exist', () => {
|
||||
expect(computeTargetVersion(2, 0, 'v2.0.3', true)).toBe('2.0.4')
|
||||
})
|
||||
|
||||
it('keeps the tag version when no new commits exist', () => {
|
||||
expect(computeTargetVersion(2, 0, 'v2.0.3', false)).toBe('2.0.3')
|
||||
})
|
||||
|
||||
it('starts a fresh major.minor line at .0 when no tag exists', () => {
|
||||
expect(computeTargetVersion(2, 0, null, true)).toBe('2.0.0')
|
||||
expect(computeTargetVersion(1, 47, null, true)).toBe('1.47.0')
|
||||
})
|
||||
|
||||
it('returns null for a malformed tag', () => {
|
||||
expect(computeTargetVersion(1, 47, 'v1.47', true)).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -81,15 +81,72 @@ function isValidSemver(version: string): boolean {
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the latest patch tag for a given minor version
|
||||
* Parse a target branch override of the form `core/<major>.<minor>`.
|
||||
* Returns the parsed major/minor and normalized branch, or null if malformed.
|
||||
*/
|
||||
function getLatestPatchTag(repoPath: string, minor: number): string | null {
|
||||
function parseTargetBranchOverride(
|
||||
branch: string
|
||||
): { major: number; minor: number; branch: string } | null {
|
||||
const match = branch.match(/^core\/(\d+)\.(\d+)$/)
|
||||
if (!match) {
|
||||
return null
|
||||
}
|
||||
return {
|
||||
major: Number(match[1]),
|
||||
minor: Number(match[2]),
|
||||
branch
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the next release version for a target major.minor line.
|
||||
*
|
||||
* With no prior tag, the line starts at `.0`. With a prior tag, the patch is
|
||||
* bumped when there are pending commits, otherwise the tagged version stands.
|
||||
* Returns null if the tag is not valid semver.
|
||||
*/
|
||||
function computeTargetVersion(
|
||||
targetMajor: number,
|
||||
targetMinor: number,
|
||||
latestPatchTag: string | null,
|
||||
hasPendingCommits: boolean
|
||||
): string | null {
|
||||
if (!latestPatchTag) {
|
||||
return `${targetMajor}.${targetMinor}.0`
|
||||
}
|
||||
|
||||
const tagVersion = latestPatchTag.replace('v', '')
|
||||
if (!isValidSemver(tagVersion)) {
|
||||
return null
|
||||
}
|
||||
|
||||
const existingPatch = Number(tagVersion.split('.')[2])
|
||||
return hasPendingCommits
|
||||
? `${targetMajor}.${targetMinor}.${existingPatch + 1}`
|
||||
: tagVersion
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether a branch exists on origin in the given repo.
|
||||
*/
|
||||
function branchExists(branch: string, repoPath: string): boolean {
|
||||
return Boolean(exec(`git rev-parse --verify origin/${branch}`, repoPath))
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the latest patch tag for a given major.minor version
|
||||
*/
|
||||
function getLatestPatchTag(
|
||||
repoPath: string,
|
||||
major: number,
|
||||
minor: number
|
||||
): string | null {
|
||||
// Fetch all tags
|
||||
exec('git fetch --tags', repoPath)
|
||||
|
||||
// Use git's native version sorting to get the latest tag
|
||||
const latestTag = exec(
|
||||
`git tag -l 'v1.${minor}.*' --sort=-version:refname | head -n 1`,
|
||||
`git tag -l 'v${major}.${minor}.*' --sort=-version:refname | head -n 1`,
|
||||
repoPath
|
||||
)
|
||||
|
||||
@@ -101,7 +158,7 @@ function getLatestPatchTag(repoPath: string, minor: number): string | null {
|
||||
const validTagRegex = /^v\d+\.\d+\.\d+$/
|
||||
if (!validTagRegex.test(latestTag)) {
|
||||
console.error(
|
||||
`Latest tag for minor version ${minor} is not valid semver: ${latestTag}`
|
||||
`Latest tag for version ${major}.${minor} is not valid semver: ${latestTag}`
|
||||
)
|
||||
return null
|
||||
}
|
||||
@@ -132,85 +189,106 @@ function resolveRelease(
|
||||
return null
|
||||
}
|
||||
|
||||
const [major, currentMinor, patch] = currentVersion.split('.').map(Number)
|
||||
const [currentMajor, currentMinor] = currentVersion.split('.').map(Number)
|
||||
|
||||
// Fetch all branches
|
||||
exec('git fetch origin', frontendRepoPath)
|
||||
|
||||
// Determine target branch based on release type:
|
||||
// 'patch' → target current minor (hotfix for production version)
|
||||
// 'minor' → try next minor, fall back to current minor (bi-weekly cadence)
|
||||
const releaseTypeInput =
|
||||
process.env.RELEASE_TYPE?.trim().toLowerCase() || 'minor'
|
||||
if (releaseTypeInput !== 'minor' && releaseTypeInput !== 'patch') {
|
||||
console.error(
|
||||
`Invalid RELEASE_TYPE: "${releaseTypeInput}". Expected "minor" or "patch"`
|
||||
)
|
||||
return null
|
||||
}
|
||||
const releaseType: 'minor' | 'patch' = releaseTypeInput
|
||||
// Target major defaults to the current pin's major, but a TARGET_BRANCH
|
||||
// override (below) can retarget both major and minor.
|
||||
let targetMajor = currentMajor
|
||||
let targetMinor: number
|
||||
let targetBranch: string
|
||||
|
||||
if (releaseType === 'patch') {
|
||||
targetMinor = currentMinor
|
||||
targetBranch = `core/1.${targetMinor}`
|
||||
const targetBranchOverride = process.env.TARGET_BRANCH?.trim()
|
||||
|
||||
const branchExists = exec(
|
||||
`git rev-parse --verify origin/${targetBranch}`,
|
||||
frontendRepoPath
|
||||
)
|
||||
|
||||
if (!branchExists) {
|
||||
if (targetBranchOverride) {
|
||||
// Manual override takes precedence over RELEASE_TYPE / pin-derived selection.
|
||||
const parsed = parseTargetBranchOverride(targetBranchOverride)
|
||||
if (!parsed) {
|
||||
console.error(
|
||||
`Patch release requested but branch ${targetBranch} does not exist`
|
||||
`Invalid TARGET_BRANCH: "${targetBranchOverride}". Expected format: core/<major>.<minor> (e.g. core/1.47 or core/2.0)`
|
||||
)
|
||||
return null
|
||||
}
|
||||
|
||||
targetMajor = parsed.major
|
||||
targetMinor = parsed.minor
|
||||
targetBranch = parsed.branch
|
||||
|
||||
if (!branchExists(targetBranch, frontendRepoPath)) {
|
||||
console.error(
|
||||
`Manual override branch ${targetBranch} does not exist in frontend repo`
|
||||
)
|
||||
return null
|
||||
}
|
||||
|
||||
console.error(
|
||||
`Patch release: targeting current production branch ${targetBranch}`
|
||||
`Manual override: targeting ${targetBranch} (ignoring release_type)`
|
||||
)
|
||||
} else {
|
||||
// Try next minor first, fall back to current minor if not available
|
||||
targetMinor = currentMinor + 1
|
||||
targetBranch = `core/1.${targetMinor}`
|
||||
|
||||
const nextMinorExists = exec(
|
||||
`git rev-parse --verify origin/${targetBranch}`,
|
||||
frontendRepoPath
|
||||
)
|
||||
|
||||
if (!nextMinorExists) {
|
||||
// Fall back to current minor for minor release
|
||||
targetMinor = currentMinor
|
||||
targetBranch = `core/1.${targetMinor}`
|
||||
|
||||
const currentMinorExists = exec(
|
||||
`git rev-parse --verify origin/${targetBranch}`,
|
||||
frontendRepoPath
|
||||
// Determine target branch based on release type:
|
||||
// 'patch' → target current minor (hotfix for production version)
|
||||
// 'minor' → try next minor, fall back to current minor (bi-weekly cadence)
|
||||
const releaseTypeInput =
|
||||
process.env.RELEASE_TYPE?.trim().toLowerCase() || 'minor'
|
||||
if (releaseTypeInput !== 'minor' && releaseTypeInput !== 'patch') {
|
||||
console.error(
|
||||
`Invalid RELEASE_TYPE: "${releaseTypeInput}". Expected "minor" or "patch"`
|
||||
)
|
||||
return null
|
||||
}
|
||||
const releaseType: 'minor' | 'patch' = releaseTypeInput
|
||||
|
||||
if (!currentMinorExists) {
|
||||
if (releaseType === 'patch') {
|
||||
targetMinor = currentMinor
|
||||
targetBranch = `core/${targetMajor}.${targetMinor}`
|
||||
|
||||
if (!branchExists(targetBranch, frontendRepoPath)) {
|
||||
console.error(
|
||||
`Neither core/1.${currentMinor + 1} nor core/1.${currentMinor} branches exist in frontend repo`
|
||||
`Patch release requested but branch ${targetBranch} does not exist`
|
||||
)
|
||||
return null
|
||||
}
|
||||
|
||||
console.error(
|
||||
`Next minor branch core/1.${currentMinor + 1} not found, falling back to core/1.${currentMinor} for minor release`
|
||||
`Patch release: targeting current production branch ${targetBranch}`
|
||||
)
|
||||
} else {
|
||||
// Try next minor first, fall back to current minor if not available
|
||||
targetMinor = currentMinor + 1
|
||||
targetBranch = `core/${targetMajor}.${targetMinor}`
|
||||
|
||||
if (!branchExists(targetBranch, frontendRepoPath)) {
|
||||
// Fall back to current minor for minor release
|
||||
targetMinor = currentMinor
|
||||
targetBranch = `core/${targetMajor}.${targetMinor}`
|
||||
|
||||
if (!branchExists(targetBranch, frontendRepoPath)) {
|
||||
console.error(
|
||||
`Neither core/${targetMajor}.${currentMinor + 1} nor core/${targetMajor}.${currentMinor} branches exist in frontend repo`
|
||||
)
|
||||
return null
|
||||
}
|
||||
|
||||
console.error(
|
||||
`Next minor branch core/${targetMajor}.${currentMinor + 1} not found, falling back to core/${targetMajor}.${currentMinor} for minor release`
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Get latest patch tag for target minor
|
||||
const latestPatchTag = getLatestPatchTag(frontendRepoPath, targetMinor)
|
||||
// Get latest patch tag for target major.minor
|
||||
const latestPatchTag = getLatestPatchTag(
|
||||
frontendRepoPath,
|
||||
targetMajor,
|
||||
targetMinor
|
||||
)
|
||||
|
||||
let needsRelease = false
|
||||
let branchHeadSha: string | null = null
|
||||
let needsRelease: boolean
|
||||
let branchHeadSha: string | null
|
||||
let tagCommitSha: string | null = null
|
||||
let targetVersion = currentVersion
|
||||
let targetVersion: string
|
||||
|
||||
if (latestPatchTag) {
|
||||
// Get commit SHA for the tag
|
||||
@@ -231,34 +309,23 @@ function resolveRelease(
|
||||
const commitCount = parseInt(commitsBetween, 10)
|
||||
needsRelease = !isNaN(commitCount) && commitCount > 0
|
||||
|
||||
// Parse existing patch number and increment if needed
|
||||
const tagVersion = latestPatchTag.replace('v', '')
|
||||
|
||||
// Validate tag version format
|
||||
if (!isValidSemver(tagVersion)) {
|
||||
const nextVersion = computeTargetVersion(
|
||||
targetMajor,
|
||||
targetMinor,
|
||||
latestPatchTag,
|
||||
needsRelease
|
||||
)
|
||||
if (!nextVersion) {
|
||||
console.error(
|
||||
`Invalid tag version format: ${tagVersion}. Expected format: X.Y.Z`
|
||||
`Invalid tag version format: ${latestPatchTag}. Expected format: vX.Y.Z`
|
||||
)
|
||||
return null
|
||||
}
|
||||
|
||||
const [, , existingPatch] = tagVersion.split('.').map(Number)
|
||||
|
||||
// Validate existingPatch is a valid number
|
||||
if (!Number.isFinite(existingPatch) || existingPatch < 0) {
|
||||
console.error(`Invalid patch number in tag: ${existingPatch}`)
|
||||
return null
|
||||
}
|
||||
|
||||
if (needsRelease) {
|
||||
targetVersion = `1.${targetMinor}.${existingPatch + 1}`
|
||||
} else {
|
||||
targetVersion = tagVersion
|
||||
}
|
||||
targetVersion = nextVersion
|
||||
} else {
|
||||
// No tags exist for this minor version, need to create v1.{targetMinor}.0
|
||||
// No tags exist for this major.minor version, need to create the .0 patch
|
||||
needsRelease = true
|
||||
targetVersion = `1.${targetMinor}.0`
|
||||
targetVersion = `${targetMajor}.${targetMinor}.0`
|
||||
branchHeadSha = exec(
|
||||
`git rev-parse origin/${targetBranch}`,
|
||||
frontendRepoPath
|
||||
@@ -281,26 +348,41 @@ function resolveRelease(
|
||||
}
|
||||
}
|
||||
|
||||
// Main execution
|
||||
const comfyuiRepoPath = process.argv[2]
|
||||
const frontendRepoPath = process.argv[3] || process.cwd()
|
||||
/**
|
||||
* Main execution: parse args, resolve, and print the JSON result.
|
||||
*/
|
||||
function main(): void {
|
||||
const comfyuiRepoPath = process.argv[2]
|
||||
const frontendRepoPath = process.argv[3] || process.cwd()
|
||||
|
||||
if (!comfyuiRepoPath) {
|
||||
console.error(
|
||||
'Usage: resolve-comfyui-release.ts <comfyui-repo-path> [frontend-repo-path]'
|
||||
)
|
||||
process.exit(1)
|
||||
if (!comfyuiRepoPath) {
|
||||
console.error(
|
||||
'Usage: resolve-comfyui-release.ts <comfyui-repo-path> [frontend-repo-path]'
|
||||
)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const releaseInfo = resolveRelease(comfyuiRepoPath, frontendRepoPath)
|
||||
|
||||
if (!releaseInfo) {
|
||||
console.error('Failed to resolve release information')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
// Output as JSON for GitHub Actions
|
||||
// oxlint-disable-next-line no-console -- stdout is captured by the workflow
|
||||
console.log(JSON.stringify(releaseInfo, null, 2))
|
||||
}
|
||||
|
||||
const releaseInfo = resolveRelease(comfyuiRepoPath, frontendRepoPath)
|
||||
|
||||
if (!releaseInfo) {
|
||||
console.error('Failed to resolve release information')
|
||||
process.exit(1)
|
||||
// Only run when invoked directly, not when imported by tests.
|
||||
if (process.argv[1] && import.meta.url === `file://${process.argv[1]}`) {
|
||||
main()
|
||||
}
|
||||
|
||||
// Output as JSON for GitHub Actions
|
||||
// oxlint-disable-next-line no-console -- stdout is captured by the workflow
|
||||
console.log(JSON.stringify(releaseInfo, null, 2))
|
||||
|
||||
export { resolveRelease }
|
||||
export {
|
||||
computeTargetVersion,
|
||||
isValidSemver,
|
||||
parseRequirementsVersion,
|
||||
parseTargetBranchOverride,
|
||||
resolveRelease
|
||||
}
|
||||
|
||||
@@ -3,7 +3,10 @@ import * as fs from 'fs'
|
||||
import type { ComfyNodeDef } from '@/schemas/nodeDefSchema'
|
||||
|
||||
import { comfyPageFixture as test } from '../browser_tests/fixtures/ComfyPage'
|
||||
import { normalizeI18nKey } from '../packages/shared-frontend-utils/src/formatUtil'
|
||||
import {
|
||||
escapeVueI18nMessageSyntax,
|
||||
normalizeI18nKey
|
||||
} from '@/utils/formatUtil'
|
||||
import type { ComfyNodeDefImpl } from '../src/stores/nodeDefStore'
|
||||
|
||||
const localePath = './src/locales/en/main.json'
|
||||
@@ -44,8 +47,6 @@ test('collect-i18n-node-defs', async ({ comfyPage }) => {
|
||||
}
|
||||
)
|
||||
|
||||
console.log(`Collected ${nodeDefs.length} node definitions`)
|
||||
|
||||
const allDataTypesLocale = Object.fromEntries(
|
||||
nodeDefs
|
||||
.flatMap((nodeDef) => {
|
||||
@@ -60,7 +61,7 @@ test('collect-i18n-node-defs', async ({ comfyPage }) => {
|
||||
)
|
||||
return allDataTypes.map((dataType) => [
|
||||
normalizeI18nKey(dataType),
|
||||
dataType
|
||||
escapeVueI18nMessageSyntax(dataType)
|
||||
])
|
||||
})
|
||||
.sort((a, b) => a[0].localeCompare(b[0]))
|
||||
@@ -98,7 +99,10 @@ test('collect-i18n-node-defs', async ({ comfyPage }) => {
|
||||
const runtimeWidgets = Object.fromEntries(
|
||||
Object.entries(widgetsMappings)
|
||||
.sort((a, b) => a[0].localeCompare(b[0]))
|
||||
.map(([key, value]) => [normalizeI18nKey(key), { name: value }])
|
||||
.map(([key, value]) => [
|
||||
normalizeI18nKey(key),
|
||||
{ name: value ? escapeVueI18nMessageSyntax(value) : value }
|
||||
])
|
||||
)
|
||||
|
||||
if (Object.keys(runtimeWidgets).length > 0) {
|
||||
@@ -121,7 +125,10 @@ test('collect-i18n-node-defs', async ({ comfyPage }) => {
|
||||
function extractInputs(nodeDef: ComfyNodeDefImpl) {
|
||||
const inputs = Object.fromEntries(
|
||||
Object.values(nodeDef.inputs).flatMap((input) => {
|
||||
const name = input.name
|
||||
const name =
|
||||
input.name === undefined
|
||||
? undefined
|
||||
: escapeVueI18nMessageSyntax(input.name)
|
||||
const tooltip = input.tooltip
|
||||
|
||||
if (name === undefined && tooltip === undefined) {
|
||||
@@ -146,7 +153,10 @@ test('collect-i18n-node-defs', async ({ comfyPage }) => {
|
||||
const outputs = Object.fromEntries(
|
||||
nodeDef.outputs.flatMap((output, i) => {
|
||||
// Ignore data types if they are already translated in allDataTypesLocale.
|
||||
const name = output.name in allDataTypesLocale ? undefined : output.name
|
||||
const name =
|
||||
output.name === undefined || output.name in allDataTypesLocale
|
||||
? undefined
|
||||
: escapeVueI18nMessageSyntax(output.name)
|
||||
const tooltip = output.tooltip
|
||||
|
||||
if (name === undefined && tooltip === undefined) {
|
||||
@@ -179,8 +189,12 @@ test('collect-i18n-node-defs', async ({ comfyPage }) => {
|
||||
return [
|
||||
normalizeI18nKey(nodeDef.name),
|
||||
{
|
||||
display_name: nodeDef.display_name ?? nodeDef.name,
|
||||
description: nodeDef.description || undefined,
|
||||
display_name: escapeVueI18nMessageSyntax(
|
||||
nodeDef.display_name ?? nodeDef.name
|
||||
),
|
||||
description: nodeDef.description
|
||||
? escapeVueI18nMessageSyntax(nodeDef.description)
|
||||
: undefined,
|
||||
inputs: Object.keys(inputs).length > 0 ? inputs : undefined,
|
||||
outputs: extractOutputs(nodeDef)
|
||||
}
|
||||
@@ -192,7 +206,10 @@ test('collect-i18n-node-defs', async ({ comfyPage }) => {
|
||||
nodeDefs.flatMap((nodeDef) =>
|
||||
nodeDef.category
|
||||
.split('/')
|
||||
.map((category) => [normalizeI18nKey(category), category])
|
||||
.map((category) => [
|
||||
normalizeI18nKey(category),
|
||||
escapeVueI18nMessageSyntax(category)
|
||||
])
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@@ -4,37 +4,67 @@
|
||||
data-testid="bounding-boxes"
|
||||
@pointerdown.stop
|
||||
>
|
||||
<div
|
||||
ref="canvasContainer"
|
||||
class="relative w-full shrink-0 overflow-hidden rounded-sm border border-component-node-border bg-node-component-surface"
|
||||
:style="canvasStyle"
|
||||
>
|
||||
<canvas
|
||||
ref="canvasEl"
|
||||
tabindex="0"
|
||||
class="absolute inset-0 size-full rounded-sm outline-none"
|
||||
:style="{ cursor: canvasCursor }"
|
||||
@pointerdown="onPointerDown"
|
||||
@pointermove="onCanvasPointerMove"
|
||||
@pointerup="onDocPointerUp"
|
||||
@pointercancel="onDocPointerUp"
|
||||
@pointerleave="onPointerLeave"
|
||||
@lostpointercapture="onDocPointerUp"
|
||||
@dblclick="onDoubleClick"
|
||||
@keydown="onCanvasKeyDown"
|
||||
@focus="focused = true"
|
||||
@blur="focused = false"
|
||||
/>
|
||||
<textarea
|
||||
v-if="inlineEditor"
|
||||
ref="inlineEditorEl"
|
||||
v-model="inlineEditor.value"
|
||||
class="absolute box-border resize-none rounded-sm border-2 bg-black/90 p-1 font-mono text-xs text-white outline-none"
|
||||
:style="inlineEditor.style"
|
||||
data-capture-wheel="true"
|
||||
@keydown.stop="onInlineKeyDown"
|
||||
@blur="commitInlineEditor"
|
||||
/>
|
||||
<div class="flex flex-col">
|
||||
<div
|
||||
class="flex h-9 items-center gap-1 rounded-t-sm border border-b-0 border-component-node-border bg-component-node-widget-background px-2"
|
||||
>
|
||||
<Button
|
||||
variant="textonly"
|
||||
size="unset"
|
||||
:aria-pressed="grid"
|
||||
:class="
|
||||
cn(
|
||||
actionBtnClass,
|
||||
grid && 'bg-component-node-widget-background-selected'
|
||||
)
|
||||
"
|
||||
@click="grid = !grid"
|
||||
>
|
||||
<i class="icon-[lucide--grid-3x3] size-4" />
|
||||
<span>{{ $t('boundingBoxes.grid') }}</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant="textonly"
|
||||
size="unset"
|
||||
:class="cn(actionBtnClass, 'ml-auto')"
|
||||
@click="clearAll"
|
||||
>
|
||||
<i class="icon-[lucide--undo-2] size-4" />
|
||||
<span>{{ $t('boundingBoxes.clearAll') }}</span>
|
||||
</Button>
|
||||
</div>
|
||||
<div
|
||||
ref="canvasContainer"
|
||||
class="relative w-full shrink-0 overflow-hidden rounded-b-sm border border-t-0 border-component-node-border bg-base-background"
|
||||
:style="canvasStyle"
|
||||
>
|
||||
<canvas
|
||||
ref="canvasEl"
|
||||
tabindex="0"
|
||||
class="absolute inset-0 size-full rounded-sm text-node-component-slot-text outline-none"
|
||||
:style="{ cursor: canvasCursor }"
|
||||
@pointerdown="onPointerDown"
|
||||
@pointermove="onCanvasPointerMove"
|
||||
@pointerup="onDocPointerUp"
|
||||
@pointercancel="onDocPointerUp"
|
||||
@pointerleave="onPointerLeave"
|
||||
@lostpointercapture="onDocPointerUp"
|
||||
@dblclick="onDoubleClick"
|
||||
@keydown="onCanvasKeyDown"
|
||||
@focus="focused = true"
|
||||
@blur="focused = false"
|
||||
/>
|
||||
<textarea
|
||||
v-if="inlineEditor"
|
||||
ref="inlineEditorEl"
|
||||
v-model="inlineEditor.value"
|
||||
class="absolute box-border resize-none rounded-sm border-2 bg-black/90 p-1 font-mono text-xs text-white outline-none"
|
||||
:style="inlineEditor.style"
|
||||
data-capture-wheel="true"
|
||||
@keydown.stop="onInlineKeyDown"
|
||||
@blur="commitInlineEditor"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
@@ -122,16 +152,6 @@
|
||||
<div v-else-if="hasRegions" class="text-node-text-muted px-1 text-xs">
|
||||
{{ $t('boundingBoxes.clickRegionToEdit') }}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="md"
|
||||
class="gap-2 rounded-lg border border-component-node-border bg-component-node-background text-xs text-muted-foreground hover:text-base-foreground"
|
||||
@click="clearAll"
|
||||
>
|
||||
<i class="icon-[lucide--undo-2]" />
|
||||
{{ $t('boundingBoxes.clearAll') }}
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -147,6 +167,9 @@ import { useBoundingBoxes } from '@/composables/boundingBoxes/useBoundingBoxes'
|
||||
import type { BoundingBox } from '@/types/boundingBoxes'
|
||||
import type { NodeId } from '@/types/nodeId'
|
||||
|
||||
const actionBtnClass =
|
||||
'flex shrink-0 items-center gap-1.5 rounded-md border-0 bg-transparent px-2 py-1 text-sm text-base-foreground outline-none transition-colors hover:bg-component-node-widget-background-hovered'
|
||||
|
||||
const { nodeId } = defineProps<{ nodeId: NodeId }>()
|
||||
const modelValue = defineModel<BoundingBox[]>({ default: () => [] })
|
||||
|
||||
@@ -172,7 +195,8 @@ const {
|
||||
commitInlineEditor,
|
||||
setActiveType,
|
||||
clearAll,
|
||||
syncState
|
||||
syncState,
|
||||
grid
|
||||
} = useBoundingBoxes(nodeId, {
|
||||
canvasEl,
|
||||
canvasContainer,
|
||||
|
||||
@@ -126,7 +126,7 @@ function nodeToNodeData(node: LGraphNode) {
|
||||
|
||||
return {
|
||||
...nodeData,
|
||||
hasErrors: !!executionErrorStore.lastNodeErrors?.[node.id],
|
||||
hasErrors: !!executionErrorStore.surfacedNodeErrors?.[node.id],
|
||||
dropIndicator,
|
||||
onDragDrop: node.onDragDrop,
|
||||
onDragOver: node.onDragOver
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
<script setup lang="ts">
|
||||
import { ZIndex } from '@primeuix/utils/zindex'
|
||||
import type { MenuItem } from 'primevue/menuitem'
|
||||
import {
|
||||
DropdownMenuArrow,
|
||||
@@ -12,12 +11,10 @@ import { computed, ref, toValue } from 'vue'
|
||||
|
||||
import DropdownItem from '@/components/common/DropdownItem.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useModalLiftedZIndex } from '@/composables/useModalLiftedZIndex'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
import type { ButtonVariants } from '../ui/button/button.variants'
|
||||
|
||||
// Shared base for @primeuix's auto-incrementing 'modal' z-index counter.
|
||||
const MODAL_BASE_Z_INDEX = 1700
|
||||
|
||||
defineOptions({
|
||||
inheritAttrs: false
|
||||
})
|
||||
@@ -46,15 +43,8 @@ const contentClass = computed(() =>
|
||||
)
|
||||
)
|
||||
|
||||
// Body-portaled content keeps its static z-1700 unless a dialog that joined
|
||||
// @primeuix's auto-incrementing 'modal' counter is open above it; then lift
|
||||
// past that dialog so the menu isn't hidden behind it.
|
||||
const open = ref(false)
|
||||
const contentStyle = computed(() => {
|
||||
if (!open.value) return undefined
|
||||
const topZIndex = ZIndex.getCurrent('modal')
|
||||
return topZIndex >= MODAL_BASE_Z_INDEX ? { zIndex: topZIndex + 1 } : undefined
|
||||
})
|
||||
const contentStyle = useModalLiftedZIndex(open)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { cleanup, render, screen } from '@testing-library/vue'
|
||||
import { cleanup, render, screen, waitFor } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import PrimeVue from 'primevue/config'
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { defineComponent, h } from 'vue'
|
||||
import { defineComponent, h, nextTick } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import GlobalDialog from '@/components/dialog/GlobalDialog.vue'
|
||||
@@ -12,6 +12,9 @@ import {
|
||||
onRekaFocusOutside,
|
||||
onRekaPointerDownOutside
|
||||
} from '@/components/dialog/rekaPrimeVueBridge'
|
||||
import UiDialog from '@/components/ui/dialog/Dialog.vue'
|
||||
import UiDialogOverlay from '@/components/ui/dialog/DialogOverlay.vue'
|
||||
import UiDialogPortal from '@/components/ui/dialog/DialogPortal.vue'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
|
||||
const i18n = createI18n({
|
||||
@@ -29,6 +32,14 @@ const Body = defineComponent({
|
||||
setup: () => () => h('p', { 'data-testid': 'body' }, 'body content')
|
||||
})
|
||||
|
||||
const ClosedNonModalDialog = defineComponent({
|
||||
name: 'ClosedNonModalDialog',
|
||||
setup: () => () =>
|
||||
h(UiDialog, { open: false, modal: false }, () =>
|
||||
h(UiDialogPortal, null, () => h(UiDialogOverlay))
|
||||
)
|
||||
})
|
||||
|
||||
function mountDialog() {
|
||||
return render(GlobalDialog, {
|
||||
global: { plugins: [PrimeVue, i18n] }
|
||||
@@ -275,6 +286,81 @@ describe('GlobalDialog Reka parity with PrimeVue', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('GlobalDialog Reka overlay scrim', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
})
|
||||
|
||||
it('renders a backdrop scrim for modal Reka dialogs', async () => {
|
||||
mountDialog()
|
||||
const store = useDialogStore()
|
||||
|
||||
store.showDialog({
|
||||
key: 'reka-modal-scrim',
|
||||
title: 'Modal',
|
||||
component: Body,
|
||||
dialogComponentProps: { renderer: 'reka' }
|
||||
})
|
||||
|
||||
await screen.findByRole('dialog')
|
||||
expect(screen.queryAllByTestId('dialog-overlay')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('shows a backdrop scrim while a non-modal Reka dialog is open', async () => {
|
||||
// Reka's own DialogOverlay renders nothing when the root is non-modal,
|
||||
// which silently dropped the scrim behind Settings/Manager (modal: false).
|
||||
mountDialog()
|
||||
const store = useDialogStore()
|
||||
|
||||
store.showDialog({
|
||||
key: 'reka-non-modal-scrim',
|
||||
title: 'Non-modal',
|
||||
component: Body,
|
||||
dialogComponentProps: { renderer: 'reka', modal: false }
|
||||
})
|
||||
|
||||
await screen.findByRole('dialog')
|
||||
expect(screen.queryAllByTestId('dialog-overlay')).toHaveLength(1)
|
||||
|
||||
store.closeDialog({ key: 'reka-non-modal-scrim' })
|
||||
await waitFor(() =>
|
||||
expect(screen.queryAllByTestId('dialog-overlay')).toHaveLength(0)
|
||||
)
|
||||
})
|
||||
|
||||
it('renders no scrim for a mounted but closed non-modal dialog', async () => {
|
||||
// CustomizationDialog mounts its non-modal Dialog root with open=false;
|
||||
// the scrim must stay gated on open, not just on mount.
|
||||
render(ClosedNonModalDialog)
|
||||
await nextTick()
|
||||
expect(screen.queryAllByTestId('dialog-overlay')).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('dismisses the dialog on a scrim pointerdown', async () => {
|
||||
mountDialog()
|
||||
const store = useDialogStore()
|
||||
const user = userEvent.setup()
|
||||
|
||||
store.showDialog({
|
||||
key: 'reka-scrim-dismiss',
|
||||
title: 'Non-modal',
|
||||
component: Body,
|
||||
dialogComponentProps: { renderer: 'reka', modal: false }
|
||||
})
|
||||
|
||||
await screen.findByRole('dialog')
|
||||
await user.click(screen.getByTestId('dialog-overlay'))
|
||||
|
||||
await waitFor(() =>
|
||||
expect(store.isDialogOpen('reka-scrim-dismiss')).toBe(false)
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('shouldPreventRekaDismiss', () => {
|
||||
function makeEvent(target: Element | null) {
|
||||
let prevented = false
|
||||
@@ -363,6 +449,12 @@ describe('shouldPreventRekaDismiss', () => {
|
||||
expect(event.defaultPrevented).toBe(false)
|
||||
})
|
||||
|
||||
it('focus-outside never dismisses when dismissOnFocusOutside is false', () => {
|
||||
const event = makeEvent(document.body)
|
||||
onRekaFocusOutside(event, { dismissOnFocusOutside: false })
|
||||
expect(event.defaultPrevented).toBe(true)
|
||||
})
|
||||
|
||||
it('focus-outside on a sibling Reka portal does not dismiss the parent', () => {
|
||||
const portal = document.createElement('div')
|
||||
portal.setAttribute('role', 'dialog')
|
||||
|
||||
@@ -32,7 +32,9 @@
|
||||
dialogStore.activeKey === item.key
|
||||
)
|
||||
"
|
||||
@focus-outside="onRekaFocusOutside"
|
||||
@focus-outside="
|
||||
(e) => onRekaFocusOutside(e, item.dialogComponentProps)
|
||||
"
|
||||
@mousedown="() => dialogStore.riseDialog({ key: item.key })"
|
||||
>
|
||||
<template v-if="item.dialogComponentProps.headless">
|
||||
|
||||
@@ -53,7 +53,22 @@ export function onRekaPointerDownOutside(
|
||||
// nested Reka or PrimeVue dialog teleported to body). Without this guard a
|
||||
// non-modal Reka dialog would dismiss itself the moment a nested dialog
|
||||
// receives focus.
|
||||
export function onRekaFocusOutside(event: OutsideEvent) {
|
||||
//
|
||||
// A container dialog (e.g. Settings) that hosts nested confirm/edit dialogs can
|
||||
// also lose focus to an ordinary app element — not just a portal — when a
|
||||
// nested dialog closes and the element it focused was removed (deleting the
|
||||
// selected row). That programmatic focus shift is not a dismiss intent, so such
|
||||
// a dialog opts out of focus-outside dismissal entirely via
|
||||
// `dismissOnFocusOutside: false`; it still dismisses on escape or an outside
|
||||
// pointer.
|
||||
export function onRekaFocusOutside(
|
||||
event: OutsideEvent,
|
||||
options: { dismissOnFocusOutside?: boolean } = {}
|
||||
) {
|
||||
if (options.dismissOnFocusOutside === false) {
|
||||
event.preventDefault()
|
||||
return
|
||||
}
|
||||
if (isInsideOverlay(event.detail.originalEvent.target)) {
|
||||
event.preventDefault()
|
||||
}
|
||||
|
||||
@@ -32,7 +32,7 @@ describe('PaletteSwatchRow', () => {
|
||||
|
||||
it('appends a color when the add button is clicked', async () => {
|
||||
const { emitted } = renderRow(['#ff0000'])
|
||||
await userEvent.click(screen.getByRole('button'))
|
||||
await userEvent.click(screen.getByRole('button', { name: '+' }))
|
||||
expect(lastEmit(emitted)).toEqual(['#ff0000', '#ffffff'])
|
||||
})
|
||||
|
||||
@@ -44,18 +44,14 @@ describe('PaletteSwatchRow', () => {
|
||||
|
||||
it('hides the add button once the max is reached', () => {
|
||||
renderRow(['#a', '#b'], 2)
|
||||
expect(screen.queryByRole('button')).toBeNull()
|
||||
expect(screen.queryByRole('button', { name: '+' })).toBeNull()
|
||||
})
|
||||
|
||||
it('writes a picked color back through the hidden color input', async () => {
|
||||
const { container, emitted } = renderRow(['#ff0000', '#00ff00'])
|
||||
await fireEvent.click(container.querySelector('[data-index="1"]')!)
|
||||
const input = container.querySelector(
|
||||
'input[type="color"]'
|
||||
) as HTMLInputElement
|
||||
input.value = '#0000ff'
|
||||
await fireEvent.input(input)
|
||||
expect(lastEmit(emitted)).toEqual(['#ff0000', '#0000ff'])
|
||||
it('opens the color picker when a swatch is clicked', async () => {
|
||||
const { container } = renderRow(['#ff0000'])
|
||||
const swatch = container.querySelector('[data-index="0"]')!
|
||||
await userEvent.click(swatch)
|
||||
expect(swatch.getAttribute('data-state')).toBe('open')
|
||||
})
|
||||
|
||||
it('starts a drag on pointer down without emitting', async () => {
|
||||
|
||||
@@ -1,17 +1,25 @@
|
||||
<template>
|
||||
<div ref="container" class="flex flex-wrap items-center gap-1">
|
||||
<div
|
||||
<ColorPicker
|
||||
v-for="(hex, i) in modelValue"
|
||||
:key="`${i}-${hex}`"
|
||||
:data-index="i"
|
||||
:data-hex="hex"
|
||||
class="relative size-5 cursor-pointer rounded-sm border border-component-node-border"
|
||||
:style="{ background: hex }"
|
||||
:title="t('palette.swatchTitle')"
|
||||
@click="openPicker(i, $event)"
|
||||
@contextmenu.prevent.stop="remove(i)"
|
||||
@pointerdown="onPointerDown(i, $event)"
|
||||
/>
|
||||
:key="i"
|
||||
:model-value="hex"
|
||||
:alpha="false"
|
||||
@update:model-value="(value) => updateAt(i, value)"
|
||||
>
|
||||
<template #trigger>
|
||||
<button
|
||||
type="button"
|
||||
:data-index="i"
|
||||
:data-hex="hex"
|
||||
class="relative size-5 cursor-pointer rounded-sm border border-component-node-border p-0"
|
||||
:style="{ background: hex }"
|
||||
:title="t('palette.swatchTitle')"
|
||||
@contextmenu.prevent.stop="remove(i)"
|
||||
@pointerdown="onPointerDown(i, $event)"
|
||||
/>
|
||||
</template>
|
||||
</ColorPicker>
|
||||
<button
|
||||
v-if="modelValue.length < max"
|
||||
type="button"
|
||||
@@ -21,12 +29,6 @@
|
||||
>
|
||||
+
|
||||
</button>
|
||||
<input
|
||||
ref="picker"
|
||||
type="color"
|
||||
class="pointer-events-none absolute size-0 opacity-0"
|
||||
@input="onPickerInput"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -34,6 +36,7 @@
|
||||
import { useTemplateRef } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import ColorPicker from '@/components/ui/color-picker/ColorPicker.vue'
|
||||
import { usePaletteSwatchRow } from '@/composables/palette/usePaletteSwatchRow'
|
||||
|
||||
const { max = 5 } = defineProps<{ max?: number }>()
|
||||
@@ -41,8 +44,9 @@ const modelValue = defineModel<string[]>({ required: true })
|
||||
const { t } = useI18n()
|
||||
|
||||
const container = useTemplateRef<HTMLDivElement>('container')
|
||||
const picker = useTemplateRef<HTMLInputElement>('picker')
|
||||
|
||||
const { openPicker, onPickerInput, remove, addColor, onPointerDown } =
|
||||
usePaletteSwatchRow({ modelValue, container, picker })
|
||||
const { updateAt, remove, addColor, onPointerDown } = usePaletteSwatchRow({
|
||||
modelValue,
|
||||
container
|
||||
})
|
||||
</script>
|
||||
|
||||
239
src/components/rightSidePanel/errors/ErrorGroupList.test.ts
Normal file
239
src/components/rightSidePanel/errors/ErrorGroupList.test.ts
Normal file
@@ -0,0 +1,239 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import type { TestingPinia } from '@pinia/testing'
|
||||
import { render, screen, waitFor, within } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import PrimeVue from 'primevue/config'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { testI18n } from '@/components/searchbox/v2/__test__/testUtils'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
|
||||
import { isLGraphNode } from '@/utils/litegraphUtil'
|
||||
import { getNodeByExecutionId } from '@/utils/graphTraversalUtil'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { fromAny } from '@total-typescript/shoehorn'
|
||||
|
||||
import ErrorGroupList from './ErrorGroupList.vue'
|
||||
|
||||
vi.mock('@/scripts/app', () => ({
|
||||
app: {
|
||||
rootGraph: {
|
||||
serialize: vi.fn(() => ({})),
|
||||
getNodeById: vi.fn()
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/graphTraversalUtil', () => ({
|
||||
getNodeByExecutionId: vi.fn(),
|
||||
getExecutionIdByNode: vi.fn(),
|
||||
getRootParentNode: vi.fn(() => null),
|
||||
forEachNode: vi.fn(),
|
||||
mapAllNodes: vi.fn(() => [])
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/litegraphUtil', () => ({
|
||||
isLGraphNode: vi.fn(() => false)
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useCopyToClipboard', () => ({
|
||||
useCopyToClipboard: vi.fn(() => ({
|
||||
copyToClipboard: vi.fn()
|
||||
}))
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/canvas/useFocusNode', () => ({
|
||||
useFocusNode: vi.fn(() => ({
|
||||
focusNode: vi.fn()
|
||||
}))
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/missingModel/missingModelDownload', () => ({
|
||||
downloadModel: vi.fn(),
|
||||
fetchModelMetadata: vi.fn().mockResolvedValue({
|
||||
fileSize: null,
|
||||
gatedRepoUrl: null
|
||||
}),
|
||||
isModelDownloadable: vi.fn(() => true),
|
||||
toBrowsableUrl: vi.fn((url: string) => url)
|
||||
}))
|
||||
|
||||
const SAMPLER_NODE = { id: '1', title: 'SamplerNode' }
|
||||
const LOADER_NODE = { id: '2', title: 'LoaderNode' }
|
||||
|
||||
function seedTwoErrorGroups(pinia: TestingPinia) {
|
||||
const executionErrorStore = useExecutionErrorStore(pinia)
|
||||
executionErrorStore.lastNodeErrors = fromAny<
|
||||
typeof executionErrorStore.lastNodeErrors,
|
||||
unknown
|
||||
>({
|
||||
'1': {
|
||||
class_type: 'KSampler',
|
||||
dependent_outputs: [],
|
||||
errors: [
|
||||
{
|
||||
type: 'required_input_missing',
|
||||
message: 'Required input is missing',
|
||||
details: '',
|
||||
extra_info: { input_name: 'clip' }
|
||||
}
|
||||
]
|
||||
},
|
||||
'2': {
|
||||
class_type: 'CLIPLoader',
|
||||
dependent_outputs: [],
|
||||
errors: [
|
||||
{ type: 'weird_error', message: 'Something odd happened', details: '' }
|
||||
]
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function renderList(pinia: TestingPinia) {
|
||||
const user = userEvent.setup()
|
||||
render(ErrorGroupList, {
|
||||
global: {
|
||||
plugins: [PrimeVue, testI18n, pinia],
|
||||
stubs: {
|
||||
AsyncSearchInput: {
|
||||
template: '<input />'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
return { user }
|
||||
}
|
||||
|
||||
function createPinia() {
|
||||
return createTestingPinia({ createSpy: vi.fn, stubActions: false })
|
||||
}
|
||||
|
||||
function getSectionByTitle(title: string) {
|
||||
const sections = screen.getAllByTestId('error-group-execution')
|
||||
const section = sections.find((s) => within(s).queryByText(title))
|
||||
expect(section).toBeDefined()
|
||||
return section!
|
||||
}
|
||||
|
||||
function isSectionExpanded(section: HTMLElement) {
|
||||
const [header] = within(section).getAllByRole('button', { hidden: true })
|
||||
return header.getAttribute('aria-expanded') === 'true'
|
||||
}
|
||||
|
||||
describe('ErrorGroupList selection emphasis', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.mocked(isLGraphNode).mockReturnValue(true)
|
||||
vi.mocked(getNodeByExecutionId).mockImplementation((_, nodeId) =>
|
||||
fromAny<LGraphNode, unknown>(
|
||||
String(nodeId) === '1' ? SAMPLER_NODE : LOADER_NODE
|
||||
)
|
||||
)
|
||||
})
|
||||
|
||||
it('expands matched groups, collapses others, and restores on deselect', async () => {
|
||||
const pinia = createPinia()
|
||||
seedTwoErrorGroups(pinia)
|
||||
renderList(pinia)
|
||||
const canvasStore = useCanvasStore(pinia)
|
||||
|
||||
const samplerSection = getSectionByTitle('Missing connection')
|
||||
const loaderSection = getSectionByTitle('Validation failed')
|
||||
expect(isSectionExpanded(samplerSection)).toBe(true)
|
||||
expect(isSectionExpanded(loaderSection)).toBe(true)
|
||||
|
||||
canvasStore.selectedItems = fromAny<
|
||||
typeof canvasStore.selectedItems,
|
||||
unknown
|
||||
>([SAMPLER_NODE])
|
||||
await waitFor(() => {
|
||||
expect(isSectionExpanded(loaderSection)).toBe(false)
|
||||
})
|
||||
expect(isSectionExpanded(samplerSection)).toBe(true)
|
||||
|
||||
canvasStore.selectedItems = []
|
||||
await waitFor(() => {
|
||||
expect(isSectionExpanded(loaderSection)).toBe(true)
|
||||
})
|
||||
expect(isSectionExpanded(samplerSection)).toBe(true)
|
||||
})
|
||||
|
||||
it('expands only matched groups for a selection that predates mount', async () => {
|
||||
const pinia = createPinia()
|
||||
seedTwoErrorGroups(pinia)
|
||||
const canvasStore = useCanvasStore(pinia)
|
||||
canvasStore.selectedItems = fromAny<
|
||||
typeof canvasStore.selectedItems,
|
||||
unknown
|
||||
>([SAMPLER_NODE])
|
||||
|
||||
renderList(pinia)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(isSectionExpanded(getSectionByTitle('Validation failed'))).toBe(
|
||||
false
|
||||
)
|
||||
})
|
||||
expect(isSectionExpanded(getSectionByTitle('Missing connection'))).toBe(
|
||||
true
|
||||
)
|
||||
})
|
||||
|
||||
it('leaves manual collapse state alone for selections without errors', async () => {
|
||||
const pinia = createPinia()
|
||||
seedTwoErrorGroups(pinia)
|
||||
const { user } = renderList(pinia)
|
||||
const canvasStore = useCanvasStore(pinia)
|
||||
|
||||
const loaderSection = getSectionByTitle('Validation failed')
|
||||
const [loaderHeader] = within(loaderSection).getAllByRole('button')
|
||||
await user.click(loaderHeader)
|
||||
expect(isSectionExpanded(loaderSection)).toBe(false)
|
||||
|
||||
canvasStore.selectedItems = fromAny<
|
||||
typeof canvasStore.selectedItems,
|
||||
unknown
|
||||
>([{ id: '99', title: 'Unrelated' }])
|
||||
await waitFor(() => {
|
||||
// No emphasis: the strip falls back to the workflow summary
|
||||
expect(screen.getByTestId('selection-context-strip')).toHaveTextContent(
|
||||
'2 nodes — 2 errors'
|
||||
)
|
||||
})
|
||||
expect(isSectionExpanded(loaderSection)).toBe(false)
|
||||
expect(isSectionExpanded(getSectionByTitle('Missing connection'))).toBe(
|
||||
true
|
||||
)
|
||||
})
|
||||
|
||||
it('always shows the strip: workflow summary by default, selection while emphasized', async () => {
|
||||
const pinia = createPinia()
|
||||
seedTwoErrorGroups(pinia)
|
||||
renderList(pinia)
|
||||
const canvasStore = useCanvasStore(pinia)
|
||||
|
||||
const strip = screen.getByTestId('selection-context-strip')
|
||||
expect(strip).toHaveTextContent('2 nodes — 2 errors')
|
||||
|
||||
canvasStore.selectedItems = fromAny<
|
||||
typeof canvasStore.selectedItems,
|
||||
unknown
|
||||
>([SAMPLER_NODE])
|
||||
await waitFor(() => {
|
||||
expect(strip).toHaveTextContent('SamplerNode — 1 error')
|
||||
})
|
||||
|
||||
canvasStore.selectedItems = fromAny<
|
||||
typeof canvasStore.selectedItems,
|
||||
unknown
|
||||
>([SAMPLER_NODE, LOADER_NODE])
|
||||
await waitFor(() => {
|
||||
expect(strip).toHaveTextContent('2 nodes selected — 2 errors')
|
||||
})
|
||||
|
||||
canvasStore.selectedItems = []
|
||||
await waitFor(() => {
|
||||
expect(strip).toHaveTextContent('2 nodes — 2 errors')
|
||||
})
|
||||
})
|
||||
})
|
||||
609
src/components/rightSidePanel/errors/ErrorGroupList.vue
Normal file
609
src/components/rightSidePanel/errors/ErrorGroupList.vue
Normal file
@@ -0,0 +1,609 @@
|
||||
<template>
|
||||
<div class="flex min-w-0 flex-col">
|
||||
<!-- Search bar + collapse toggle -->
|
||||
<div
|
||||
class="flex min-w-0 shrink-0 items-center border-b border-interface-stroke px-4 pt-1 pb-4"
|
||||
>
|
||||
<AsyncSearchInput v-model="searchQuery" class="flex-1" />
|
||||
<CollapseToggleButton
|
||||
v-model="isAllCollapsed"
|
||||
:show="!isSearching && allErrorGroups.length > 1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="min-w-0 flex-1 overflow-y-auto bg-interface-panel-surface p-3">
|
||||
<div
|
||||
v-if="filteredGroups.length === 0"
|
||||
role="status"
|
||||
class="px-1 pt-5 pb-15 text-center text-sm text-muted-foreground"
|
||||
>
|
||||
{{
|
||||
searchQuery.trim()
|
||||
? t('rightSidePanel.noneSearchDesc')
|
||||
: t('rightSidePanel.noErrors')
|
||||
}}
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else
|
||||
class="overflow-hidden rounded-lg border border-secondary-background"
|
||||
>
|
||||
<!-- Errors summary hero -->
|
||||
<div
|
||||
data-testid="errors-summary-hero"
|
||||
class="flex items-center gap-2 bg-base-foreground/5 p-2"
|
||||
>
|
||||
<span
|
||||
class="flex h-12 min-w-9 shrink-0 items-center justify-center px-1 text-[2rem]/none font-extrabold text-destructive-background-hover tabular-nums"
|
||||
>
|
||||
{{ totalErrorCount }}
|
||||
</span>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
class="h-9 w-px shrink-0 bg-interface-stroke"
|
||||
/>
|
||||
<div class="flex min-w-0 flex-1 flex-col gap-1 px-2">
|
||||
<span class="text-xs/tight font-semibold text-base-foreground">
|
||||
{{ t('rightSidePanel.errorsDetected', totalErrorCount) }}
|
||||
</span>
|
||||
<span class="text-xs/tight text-muted-foreground">
|
||||
{{ t('rightSidePanel.resolveBeforeRun') }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Context strip: workflow summary, or the selection's errors -->
|
||||
<div
|
||||
data-testid="selection-context-strip"
|
||||
role="status"
|
||||
class="flex items-center border-t border-secondary-background px-3 pt-3.5 pb-1.5"
|
||||
>
|
||||
<i18n-t
|
||||
:keypath="strip.keypath"
|
||||
:plural="strip.count"
|
||||
tag="span"
|
||||
:class="
|
||||
cn(
|
||||
'min-w-0 flex-1 truncate text-xs font-semibold transition-colors duration-200',
|
||||
hasSelectionEmphasis
|
||||
? 'text-primary-background-hover'
|
||||
: 'text-muted-foreground'
|
||||
)
|
||||
"
|
||||
>
|
||||
<template #node>{{ selectionStripNodeLabel }}</template>
|
||||
<template #nodes>{{ strip.nodes }}</template>
|
||||
<template #count>{{ strip.count }}</template>
|
||||
</i18n-t>
|
||||
</div>
|
||||
|
||||
<!-- Group by Class Type -->
|
||||
<TransitionGroup tag="div" name="list-scale" class="relative">
|
||||
<ErrorCardSection
|
||||
v-for="group in filteredGroups"
|
||||
:key="group.groupKey"
|
||||
:data-testid="'error-group-' + group.type.replaceAll('_', '-')"
|
||||
:title="group.displayTitle"
|
||||
:count="group.count"
|
||||
:collapse="isSectionCollapsed(group.groupKey) && !isSearching"
|
||||
class="border-t border-secondary-background first:border-t-0"
|
||||
@update:collapse="setSectionCollapsed(group.groupKey, $event)"
|
||||
>
|
||||
<template #actions>
|
||||
<Button
|
||||
v-if="
|
||||
group.type === 'missing_node' &&
|
||||
missingNodePacks.length > 0 &&
|
||||
shouldShowInstallButton
|
||||
"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
class="shrink-0"
|
||||
:disabled="isInstallingAll"
|
||||
@click.stop="installAll"
|
||||
>
|
||||
<DotSpinner v-if="isInstallingAll" duration="1s" :size="12" />
|
||||
{{
|
||||
isInstallingAll
|
||||
? t('rightSidePanel.missingNodePacks.installing')
|
||||
: t('rightSidePanel.missingNodePacks.installAll')
|
||||
}}
|
||||
</Button>
|
||||
<Button
|
||||
v-else-if="group.type === 'swap_nodes'"
|
||||
v-tooltip.top="
|
||||
t(
|
||||
'nodeReplacement.replaceAllWarning',
|
||||
'Replaces all available nodes in this group.'
|
||||
)
|
||||
"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
class="shrink-0"
|
||||
@click.stop="handleReplaceAll()"
|
||||
>
|
||||
{{ t('nodeReplacement.replaceAll', 'Replace All') }}
|
||||
</Button>
|
||||
<Button
|
||||
v-else-if="
|
||||
group.type === 'missing_model' &&
|
||||
showMissingModelHeaderRefresh
|
||||
"
|
||||
data-testid="missing-model-header-refresh"
|
||||
variant="muted-textonly"
|
||||
size="icon"
|
||||
class="shrink-0 rounded-lg hover:bg-transparent hover:text-base-foreground"
|
||||
:aria-label="t('rightSidePanel.missingModels.refresh')"
|
||||
:aria-busy="missingModelStore.isRefreshingMissingModels"
|
||||
:aria-disabled="missingModelStore.isRefreshingMissingModels"
|
||||
@click.stop="handleMissingModelRefresh"
|
||||
>
|
||||
<DotSpinner
|
||||
v-if="missingModelStore.isRefreshingMissingModels"
|
||||
aria-hidden="true"
|
||||
duration="1s"
|
||||
:size="12"
|
||||
/>
|
||||
<i
|
||||
v-else
|
||||
aria-hidden="true"
|
||||
class="icon-[lucide--refresh-cw] size-4 shrink-0"
|
||||
/>
|
||||
</Button>
|
||||
<span
|
||||
v-if="
|
||||
group.type === 'missing_model' &&
|
||||
showMissingModelHeaderRefresh
|
||||
"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
class="sr-only"
|
||||
>
|
||||
{{
|
||||
missingModelStore.isRefreshingMissingModels
|
||||
? t('rightSidePanel.missingModels.refreshing')
|
||||
: ''
|
||||
}}
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<div
|
||||
v-if="group.displayMessage"
|
||||
data-testid="error-group-display-message"
|
||||
class="px-3 py-1"
|
||||
>
|
||||
<p
|
||||
class="m-0 text-xs/normal wrap-break-word whitespace-pre-wrap text-base-foreground/50"
|
||||
>
|
||||
{{ group.displayMessage }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Missing Node Packs -->
|
||||
<MissingNodeCard
|
||||
v-if="group.type === 'missing_node'"
|
||||
:show-info-button="shouldShowManagerButtons"
|
||||
:missing-pack-groups="missingPackGroups"
|
||||
:highlighted-node-ids="selectionMatchedAssetNodeIds"
|
||||
@locate-node="handleLocateMissingNode"
|
||||
@open-manager-info="handleOpenManagerInfo"
|
||||
/>
|
||||
|
||||
<!-- Swap Nodes -->
|
||||
<SwapNodesCard
|
||||
v-if="group.type === 'swap_nodes'"
|
||||
:swap-node-groups="swapNodeGroups"
|
||||
:highlighted-node-ids="selectionMatchedAssetNodeIds"
|
||||
@locate-node="handleLocateMissingNode"
|
||||
@replace="handleReplaceGroup"
|
||||
/>
|
||||
|
||||
<!-- Execution Errors -->
|
||||
<div v-if="isExecutionItemListGroup(group)" class="px-3">
|
||||
<ul class="m-0 list-none space-y-1 p-0">
|
||||
<li
|
||||
v-for="item in getExecutionItemList(group)"
|
||||
:key="item.key"
|
||||
:aria-current="
|
||||
isCardInSelection(item.cardId) ? 'true' : undefined
|
||||
"
|
||||
:class="
|
||||
cn(
|
||||
'min-w-0',
|
||||
selectionEmphasisClass(isCardInSelection(item.cardId))
|
||||
)
|
||||
"
|
||||
>
|
||||
<div class="flex min-w-0 items-center gap-2">
|
||||
<span class="flex min-w-0 flex-1 items-center gap-1">
|
||||
<button
|
||||
v-tooltip.top="{
|
||||
value: item.displayDetails || undefined,
|
||||
showDelay: 300
|
||||
}"
|
||||
type="button"
|
||||
class="focus-visible:ring-ring m-0 inline max-w-full cursor-pointer appearance-none rounded-sm border-0 bg-transparent p-0 text-left text-xs/relaxed font-normal wrap-break-word text-muted-foreground outline-none hover:text-base-foreground focus:outline-none focus-visible:ring-1 focus-visible:outline-none focus-visible:ring-inset"
|
||||
@click="handleLocateNode(item.nodeId)"
|
||||
>
|
||||
{{ item.label }}
|
||||
</button>
|
||||
<Button
|
||||
v-if="item.displayDetails"
|
||||
variant="textonly"
|
||||
size="icon-sm"
|
||||
:class="
|
||||
cn(
|
||||
'size-6 shrink-0 text-muted-foreground hover:text-base-foreground focus-visible:ring-inset',
|
||||
isExecutionItemDetailExpanded(item.key) &&
|
||||
'bg-secondary-background-selected text-base-foreground hover:bg-secondary-background-selected'
|
||||
)
|
||||
"
|
||||
:aria-label="
|
||||
t('rightSidePanel.infoFor', { item: item.label })
|
||||
"
|
||||
:aria-controls="getExecutionItemDetailId(item.key)"
|
||||
:aria-expanded="isExecutionItemDetailExpanded(item.key)"
|
||||
@click.stop="toggleExecutionItemDetail(item.key)"
|
||||
>
|
||||
<i class="icon-[lucide--info] size-3.5" />
|
||||
</Button>
|
||||
</span>
|
||||
<Button
|
||||
variant="textonly"
|
||||
size="icon-sm"
|
||||
class="size-8 shrink-0 text-muted-foreground hover:text-base-foreground focus-visible:ring-inset"
|
||||
:aria-label="
|
||||
t('rightSidePanel.locateNodeFor', {
|
||||
item: item.label
|
||||
})
|
||||
"
|
||||
@click.stop="handleLocateNode(item.nodeId)"
|
||||
>
|
||||
<i class="icon-[lucide--locate] size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<TransitionCollapse>
|
||||
<p
|
||||
v-if="
|
||||
item.displayDetails &&
|
||||
isExecutionItemDetailExpanded(item.key)
|
||||
"
|
||||
:id="getExecutionItemDetailId(item.key)"
|
||||
class="m-0 mt-0.5 pr-10 text-2xs/relaxed wrap-break-word whitespace-pre-wrap text-muted-foreground"
|
||||
>
|
||||
{{ item.displayDetails }}
|
||||
</p>
|
||||
</TransitionCollapse>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div v-else-if="group.type === 'execution'" class="space-y-3 px-3">
|
||||
<ErrorNodeCard
|
||||
v-for="card in group.cards"
|
||||
:key="card.id"
|
||||
:card="card"
|
||||
:aria-current="isCardInSelection(card.id) ? 'true' : undefined"
|
||||
:class="
|
||||
cn(
|
||||
selectionEmphasisClass(isCardInSelection(card.id)),
|
||||
isCardInSelection(card.id) && '-my-1 py-1'
|
||||
)
|
||||
"
|
||||
@locate-node="handleLocateNode"
|
||||
@copy-to-clipboard="copyToClipboard"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Missing Models -->
|
||||
<MissingModelCard
|
||||
v-if="group.type === 'missing_model'"
|
||||
:missing-model-groups="missingModelGroups"
|
||||
:highlighted-node-ids="selectionMatchedAssetNodeIds"
|
||||
@locate-model="handleLocateAssetNode"
|
||||
/>
|
||||
|
||||
<!-- Missing Media -->
|
||||
<MissingMediaCard
|
||||
v-if="group.type === 'missing_media'"
|
||||
:missing-media-groups="missingMediaGroups"
|
||||
:highlighted-node-ids="selectionMatchedAssetNodeIds"
|
||||
@locate-node="handleLocateAssetNode"
|
||||
/>
|
||||
</ErrorCardSection>
|
||||
</TransitionGroup>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
import { useCopyToClipboard } from '@/composables/useCopyToClipboard'
|
||||
import { useFocusNode } from '@/composables/canvas/useFocusNode'
|
||||
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
|
||||
import { useManagerState } from '@/workbench/extensions/manager/composables/useManagerState'
|
||||
import { ManagerTab } from '@/workbench/extensions/manager/types/comfyManagerTypes'
|
||||
|
||||
import CollapseToggleButton from '../layout/CollapseToggleButton.vue'
|
||||
import TransitionCollapse from '../layout/TransitionCollapse.vue'
|
||||
import AsyncSearchInput from '@/components/ui/search-input/AsyncSearchInput.vue'
|
||||
import ErrorCardSection from './ErrorCardSection.vue'
|
||||
import ErrorNodeCard from './ErrorNodeCard.vue'
|
||||
import MissingNodeCard from './MissingNodeCard.vue'
|
||||
import SwapNodesCard from '@/platform/nodeReplacement/components/SwapNodesCard.vue'
|
||||
import MissingModelCard from '@/platform/missingModel/components/MissingModelCard.vue'
|
||||
import MissingMediaCard from '@/platform/missingMedia/components/MissingMediaCard.vue'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import DotSpinner from '@/components/common/DotSpinner.vue'
|
||||
import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
|
||||
import { usePackInstall } from '@/workbench/extensions/manager/composables/nodePack/usePackInstall'
|
||||
import { useMissingNodes } from '@/workbench/extensions/manager/composables/nodePack/useMissingNodes'
|
||||
import { useErrorGroups } from './useErrorGroups'
|
||||
import type { SwapNodeGroup } from './useErrorGroups'
|
||||
import type { ErrorGroup } from './types'
|
||||
import { isExecutionItemListGroup } from './executionItemList'
|
||||
import { selectionEmphasisClass } from './selectionEmphasis'
|
||||
import { useNodeReplacement } from '@/platform/nodeReplacement/useNodeReplacement'
|
||||
|
||||
interface ExecutionItemListEntry {
|
||||
key: string
|
||||
cardId: string
|
||||
nodeId: string
|
||||
label: string
|
||||
displayDetails?: string
|
||||
}
|
||||
|
||||
const { t } = useI18n()
|
||||
const { copyToClipboard } = useCopyToClipboard()
|
||||
const { focusNode } = useFocusNode()
|
||||
const rightSidePanelStore = useRightSidePanelStore()
|
||||
const missingModelStore = useMissingModelStore()
|
||||
const { shouldShowManagerButtons, shouldShowInstallButton, openManager } =
|
||||
useManagerState()
|
||||
const { missingNodePacks } = useMissingNodes()
|
||||
const { isInstalling: isInstallingAll, installAllPacks: installAll } =
|
||||
usePackInstall(() => missingNodePacks.value)
|
||||
const { replaceGroup, replaceAllGroups } = useNodeReplacement()
|
||||
|
||||
const searchQuery = ref('')
|
||||
const expandedExecutionItemDetailKeys = ref(new Set<string>())
|
||||
const isSearching = computed(() => searchQuery.value.trim() !== '')
|
||||
|
||||
function getExecutionItemList(group: ErrorGroup): ExecutionItemListEntry[] {
|
||||
if (group.type !== 'execution') return []
|
||||
|
||||
const items: ExecutionItemListEntry[] = []
|
||||
for (const card of group.cards) {
|
||||
if (!card.nodeId) continue
|
||||
for (let idx = 0; idx < card.errors.length; idx++) {
|
||||
const error = card.errors[idx]
|
||||
const label = error.displayItemLabel
|
||||
if (!label) continue
|
||||
items.push({
|
||||
key: `${card.id}:${idx}`,
|
||||
cardId: card.id,
|
||||
nodeId: card.nodeId,
|
||||
label,
|
||||
displayDetails: error.displayDetails
|
||||
})
|
||||
}
|
||||
}
|
||||
return items.sort(compareExecutionItemListEntry)
|
||||
}
|
||||
|
||||
function compareExecutionItemListEntry(
|
||||
a: ExecutionItemListEntry,
|
||||
b: ExecutionItemListEntry
|
||||
) {
|
||||
return (
|
||||
a.nodeId.localeCompare(b.nodeId, undefined, { numeric: true }) ||
|
||||
a.label.localeCompare(b.label)
|
||||
)
|
||||
}
|
||||
|
||||
function isExecutionItemDetailExpanded(key: string) {
|
||||
return expandedExecutionItemDetailKeys.value.has(key)
|
||||
}
|
||||
|
||||
function toggleExecutionItemDetail(key: string) {
|
||||
const nextKeys = new Set(expandedExecutionItemDetailKeys.value)
|
||||
if (nextKeys.has(key)) {
|
||||
nextKeys.delete(key)
|
||||
} else {
|
||||
nextKeys.add(key)
|
||||
}
|
||||
expandedExecutionItemDetailKeys.value = nextKeys
|
||||
}
|
||||
|
||||
function getExecutionItemDetailId(key: string) {
|
||||
return `execution-item-detail-${key}`
|
||||
}
|
||||
|
||||
const {
|
||||
allErrorGroups,
|
||||
filteredGroups,
|
||||
collapseState,
|
||||
errorNodeCache,
|
||||
missingNodeCache,
|
||||
missingPackGroups,
|
||||
missingModelGroups,
|
||||
missingMediaGroups,
|
||||
swapNodeGroups,
|
||||
hasSelection,
|
||||
selectedNodeCount,
|
||||
selectedNodeTitle,
|
||||
selectionMatchedGroupKeys,
|
||||
selectionMatchedCardIds,
|
||||
selectionMatchedAssetNodeIds,
|
||||
selectionErrorCount,
|
||||
errorNodeCount
|
||||
} = useErrorGroups(searchQuery)
|
||||
|
||||
const totalErrorCount = computed(() =>
|
||||
filteredGroups.value.reduce((sum, group) => sum + group.count, 0)
|
||||
)
|
||||
|
||||
const hasSelectionEmphasis = computed(
|
||||
() => hasSelection.value && selectionErrorCount.value > 0
|
||||
)
|
||||
const selectionStripNodeLabel = computed(
|
||||
() => selectedNodeTitle.value ?? t('g.untitled')
|
||||
)
|
||||
|
||||
// The strip is a status line, not a view of the current filter — summary
|
||||
// numbers are workflow-wide, never search-filtered.
|
||||
const workflowErrorCount = computed(() =>
|
||||
allErrorGroups.value.reduce((sum, group) => sum + group.count, 0)
|
||||
)
|
||||
|
||||
const strip = computed(() => {
|
||||
if (hasSelectionEmphasis.value) {
|
||||
return {
|
||||
keypath:
|
||||
selectedNodeCount.value === 1
|
||||
? 'rightSidePanel.selectedNodeErrors'
|
||||
: 'rightSidePanel.selectedNodesErrors',
|
||||
nodes: selectedNodeCount.value,
|
||||
count: selectionErrorCount.value
|
||||
}
|
||||
}
|
||||
return {
|
||||
keypath:
|
||||
errorNodeCount.value === 0
|
||||
? // Node-less errors (e.g. prompt-level) would read as "0 nodes"
|
||||
'rightSidePanel.errorsSummary'
|
||||
: errorNodeCount.value === 1
|
||||
? 'rightSidePanel.errorNodeSummary'
|
||||
: 'rightSidePanel.errorNodesSummary',
|
||||
nodes: errorNodeCount.value,
|
||||
count: workflowErrorCount.value
|
||||
}
|
||||
})
|
||||
|
||||
function isCardInSelection(cardId: string): boolean {
|
||||
return selectionMatchedCardIds.value.has(cardId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Dedupes the Set-valued computed (fresh reference per recompute) so the
|
||||
* emphasis watcher below only fires when the matched membership changes.
|
||||
*/
|
||||
const selectionEmphasisSignature = computed(() =>
|
||||
hasSelection.value
|
||||
? Array.from(selectionMatchedGroupKeys.value).sort().join('\n')
|
||||
: ''
|
||||
)
|
||||
|
||||
/**
|
||||
* Selection acts as emphasis, not a filter: expand the groups containing
|
||||
* the selected nodes' errors and collapse the rest. When the emphasis ends
|
||||
* (selection cleared or moved to a node without errors), re-expand all
|
||||
* groups so the tab reads as the workflow overview again.
|
||||
*/
|
||||
watch(
|
||||
selectionEmphasisSignature,
|
||||
(signature, previousSignature) => {
|
||||
if (!signature) {
|
||||
if (!previousSignature) return
|
||||
for (const groupKey of Object.keys(collapseState)) {
|
||||
setSectionCollapsed(groupKey, false)
|
||||
}
|
||||
return
|
||||
}
|
||||
const matchedKeys = selectionMatchedGroupKeys.value
|
||||
for (const group of allErrorGroups.value) {
|
||||
setSectionCollapsed(group.groupKey, !matchedKeys.has(group.groupKey))
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
const showMissingModelHeaderRefresh = computed(
|
||||
() => !isCloud && missingModelGroups.value.length > 0
|
||||
)
|
||||
|
||||
function handleMissingModelRefresh() {
|
||||
if (missingModelStore.isRefreshingMissingModels) return
|
||||
|
||||
void missingModelStore.refreshMissingModels()
|
||||
}
|
||||
|
||||
const isAllCollapsed = computed({
|
||||
get() {
|
||||
return filteredGroups.value.every((g) => isSectionCollapsed(g.groupKey))
|
||||
},
|
||||
set(collapse: boolean) {
|
||||
for (const group of allErrorGroups.value) {
|
||||
setSectionCollapsed(group.groupKey, collapse)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
function isSectionCollapsed(groupKey: string): boolean {
|
||||
// Defaults to expanded when not explicitly set by the user
|
||||
return collapseState[groupKey] ?? false
|
||||
}
|
||||
|
||||
function setSectionCollapsed(groupKey: string, collapsed: boolean) {
|
||||
collapseState[groupKey] = collapsed
|
||||
}
|
||||
|
||||
/**
|
||||
* When an external trigger (e.g. "See Error" button in SectionWidgets)
|
||||
* sets focusedErrorNodeId, expand only the group containing the target
|
||||
* node and collapse all others so the user sees the relevant errors
|
||||
* immediately.
|
||||
*/
|
||||
watch(
|
||||
() => rightSidePanelStore.focusedErrorNodeId,
|
||||
(graphNodeId) => {
|
||||
if (!graphNodeId) return
|
||||
const prefix = `${graphNodeId}:`
|
||||
for (const group of allErrorGroups.value) {
|
||||
if (group.type !== 'execution') continue
|
||||
|
||||
const hasMatch = group.cards.some(
|
||||
(card) =>
|
||||
card.graphNodeId === graphNodeId ||
|
||||
(card.nodeId?.startsWith(prefix) ?? false)
|
||||
)
|
||||
setSectionCollapsed(group.groupKey, !hasMatch)
|
||||
}
|
||||
rightSidePanelStore.focusedErrorNodeId = null
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
function handleLocateNode(nodeId: string) {
|
||||
focusNode(nodeId, errorNodeCache.value)
|
||||
}
|
||||
|
||||
function handleLocateMissingNode(nodeId: string) {
|
||||
focusNode(nodeId, missingNodeCache.value)
|
||||
}
|
||||
|
||||
function handleLocateAssetNode(nodeId: string) {
|
||||
focusNode(nodeId)
|
||||
}
|
||||
|
||||
function handleOpenManagerInfo(packId: string) {
|
||||
const isKnownToRegistry = missingNodePacks.value.some((p) => p.id === packId)
|
||||
if (isKnownToRegistry) {
|
||||
openManager({ initialTab: ManagerTab.Missing, initialPackId: packId })
|
||||
} else {
|
||||
openManager({ initialTab: ManagerTab.All, initialPackId: packId })
|
||||
}
|
||||
}
|
||||
|
||||
function handleReplaceGroup(group: SwapNodeGroup) {
|
||||
replaceGroup(group)
|
||||
}
|
||||
|
||||
function handleReplaceAll() {
|
||||
replaceAllGroups(swapNodeGroups.value)
|
||||
}
|
||||
</script>
|
||||
@@ -1,9 +1,6 @@
|
||||
<template>
|
||||
<div class="flex min-h-0 flex-1 flex-col gap-2 overflow-hidden">
|
||||
<div
|
||||
v-if="card.nodeId && !compact"
|
||||
class="flex min-h-8 flex-wrap items-center gap-2"
|
||||
>
|
||||
<div v-if="card.nodeId" class="flex min-h-8 flex-wrap items-center gap-2">
|
||||
<span class="flex min-w-0 flex-1">
|
||||
<button
|
||||
v-if="hasRuntimeError && (card.nodeTitle || card.title)"
|
||||
@@ -103,7 +100,7 @@
|
||||
|
||||
<TransitionCollapse>
|
||||
<div
|
||||
v-if="error.isRuntimeError && isRuntimeDisclosureExpanded"
|
||||
v-if="error.isRuntimeError && runtimeDetailsExpanded"
|
||||
:id="getRuntimeDetailsId(idx)"
|
||||
role="region"
|
||||
data-testid="runtime-error-panel"
|
||||
@@ -186,9 +183,8 @@ import type { ErrorCardData, ErrorItem } from './types'
|
||||
import { useErrorActions } from './useErrorActions'
|
||||
import { useErrorReport } from './useErrorReport'
|
||||
|
||||
const { card, compact = false } = defineProps<{
|
||||
const { card } = defineProps<{
|
||||
card: ErrorCardData
|
||||
compact?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -203,9 +199,6 @@ const runtimeDetailsExpanded = ref(true)
|
||||
const hasRuntimeError = computed(() =>
|
||||
card.errors.some((error) => error.isRuntimeError)
|
||||
)
|
||||
const isRuntimeDisclosureExpanded = computed(
|
||||
() => compact || runtimeDetailsExpanded.value
|
||||
)
|
||||
const runtimeDetailsControlIds = computed(() =>
|
||||
card.errors
|
||||
.map((error, idx) => (error.isRuntimeError ? getRuntimeDetailsId(idx) : ''))
|
||||
|
||||
@@ -56,12 +56,15 @@
|
||||
>
|
||||
</template>
|
||||
</i18n-t>
|
||||
<div class="flex flex-col gap-1 overflow-hidden">
|
||||
<div class="-mx-1.5 flex flex-col gap-1 overflow-hidden px-1.5">
|
||||
<MissingPackGroupRow
|
||||
v-for="group in missingPackGroups"
|
||||
:key="group.packId ?? '__unknown__'"
|
||||
:group="group"
|
||||
:show-info-button="showInfoButton"
|
||||
:highlighted="
|
||||
someNodeTypeInSelection(group.nodeTypes, highlightedNodeIds)
|
||||
"
|
||||
@locate-node="emit('locateNode', $event)"
|
||||
@open-manager-info="emit('openManagerInfo', $event)"
|
||||
/>
|
||||
@@ -106,10 +109,13 @@ import { useSystemStatsStore } from '@/stores/systemStatsStore'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type { MissingPackGroup } from '@/components/rightSidePanel/errors/useErrorGroups'
|
||||
import MissingPackGroupRow from '@/components/rightSidePanel/errors/MissingPackGroupRow.vue'
|
||||
import { someNodeTypeInSelection } from '@/components/rightSidePanel/errors/selectionEmphasis'
|
||||
|
||||
const { showInfoButton, missingPackGroups } = defineProps<{
|
||||
showInfoButton: boolean
|
||||
missingPackGroups: MissingPackGroup[]
|
||||
/** Execution node ids to emphasize (current canvas selection). */
|
||||
highlightedNodeIds?: Set<string>
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
<template>
|
||||
<div class="mb-1 flex w-full flex-col gap-0.5 last:mb-0">
|
||||
<div class="flex min-h-8 w-full items-center gap-1">
|
||||
<div
|
||||
:aria-current="highlighted ? 'true' : undefined"
|
||||
:class="
|
||||
cn(
|
||||
'flex min-h-8 items-center gap-1',
|
||||
selectionEmphasisClass(highlighted)
|
||||
)
|
||||
"
|
||||
>
|
||||
<Button
|
||||
v-if="hasMultipleNodeTypes"
|
||||
data-testid="missing-node-pack-expand"
|
||||
@@ -216,6 +224,8 @@
|
||||
import { computed, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
import { selectionEmphasisClass } from './selectionEmphasis'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import DotSpinner from '@/components/common/DotSpinner.vue'
|
||||
import TransitionCollapse from '@/components/rightSidePanel/layout/TransitionCollapse.vue'
|
||||
@@ -227,9 +237,11 @@ import { ManagerTab } from '@/workbench/extensions/manager/types/comfyManagerTyp
|
||||
import type { MissingNodeType } from '@/types/comfy'
|
||||
import type { MissingPackGroup } from '@/components/rightSidePanel/errors/useErrorGroups'
|
||||
|
||||
const { group, showInfoButton } = defineProps<{
|
||||
const { group, showInfoButton, highlighted } = defineProps<{
|
||||
group: MissingPackGroup
|
||||
showInfoButton: boolean
|
||||
/** Emphasize the header row (pack containing the canvas selection). */
|
||||
highlighted?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
|
||||
@@ -1,275 +1,6 @@
|
||||
<template>
|
||||
<div class="flex h-full min-w-0 flex-col">
|
||||
<!-- Search bar + collapse toggle -->
|
||||
<div
|
||||
class="flex min-w-0 shrink-0 items-center border-b border-interface-stroke px-4 pt-1 pb-4"
|
||||
>
|
||||
<AsyncSearchInput v-model="searchQuery" class="flex-1" />
|
||||
<CollapseToggleButton
|
||||
v-model="isAllCollapsed"
|
||||
:show="!isSearching && tabErrorGroups.length > 1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="min-w-0 flex-1 overflow-y-auto bg-interface-panel-surface p-3"
|
||||
aria-live="polite"
|
||||
>
|
||||
<div
|
||||
v-if="filteredGroups.length === 0"
|
||||
class="px-1 pt-5 pb-15 text-center text-sm text-muted-foreground"
|
||||
>
|
||||
{{
|
||||
searchQuery.trim()
|
||||
? t('rightSidePanel.noneSearchDesc')
|
||||
: t('rightSidePanel.noErrors')
|
||||
}}
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else
|
||||
class="overflow-hidden rounded-lg border border-secondary-background"
|
||||
>
|
||||
<!-- Errors summary hero -->
|
||||
<div
|
||||
data-testid="errors-summary-hero"
|
||||
class="flex items-center gap-2 bg-base-foreground/5 p-2"
|
||||
>
|
||||
<span
|
||||
class="flex h-12 min-w-9 shrink-0 items-center justify-center px-1 text-[2rem]/none font-extrabold text-destructive-background-hover tabular-nums"
|
||||
>
|
||||
{{ totalErrorCount }}
|
||||
</span>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
class="h-9 w-px shrink-0 bg-interface-stroke"
|
||||
/>
|
||||
<div class="flex min-w-0 flex-1 flex-col gap-1 px-2">
|
||||
<span class="text-xs/tight font-semibold text-base-foreground">
|
||||
{{ t('rightSidePanel.errorsDetected', totalErrorCount) }}
|
||||
</span>
|
||||
<span class="text-xs/tight text-muted-foreground">
|
||||
{{ t('rightSidePanel.resolveBeforeRun') }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Group by Class Type -->
|
||||
<TransitionGroup tag="div" name="list-scale" class="relative">
|
||||
<ErrorCardSection
|
||||
v-for="group in filteredGroups"
|
||||
:key="group.groupKey"
|
||||
:data-testid="'error-group-' + group.type.replaceAll('_', '-')"
|
||||
:title="group.displayTitle"
|
||||
:count="group.count"
|
||||
:collapse="isSectionCollapsed(group.groupKey) && !isSearching"
|
||||
class="border-t border-secondary-background first:border-t-0"
|
||||
@update:collapse="setSectionCollapsed(group.groupKey, $event)"
|
||||
>
|
||||
<template #actions>
|
||||
<Button
|
||||
v-if="
|
||||
group.type === 'missing_node' &&
|
||||
missingNodePacks.length > 0 &&
|
||||
shouldShowInstallButton
|
||||
"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
class="shrink-0"
|
||||
:disabled="isInstallingAll"
|
||||
@click.stop="installAll"
|
||||
>
|
||||
<DotSpinner v-if="isInstallingAll" duration="1s" :size="12" />
|
||||
{{
|
||||
isInstallingAll
|
||||
? t('rightSidePanel.missingNodePacks.installing')
|
||||
: t('rightSidePanel.missingNodePacks.installAll')
|
||||
}}
|
||||
</Button>
|
||||
<Button
|
||||
v-else-if="group.type === 'swap_nodes'"
|
||||
v-tooltip.top="
|
||||
t(
|
||||
'nodeReplacement.replaceAllWarning',
|
||||
'Replaces all available nodes in this group.'
|
||||
)
|
||||
"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
class="shrink-0"
|
||||
@click.stop="handleReplaceAll()"
|
||||
>
|
||||
{{ t('nodeReplacement.replaceAll', 'Replace All') }}
|
||||
</Button>
|
||||
<Button
|
||||
v-else-if="
|
||||
group.type === 'missing_model' &&
|
||||
showMissingModelHeaderRefresh
|
||||
"
|
||||
data-testid="missing-model-header-refresh"
|
||||
variant="muted-textonly"
|
||||
size="icon"
|
||||
class="shrink-0 rounded-lg hover:bg-transparent hover:text-base-foreground"
|
||||
:aria-label="t('rightSidePanel.missingModels.refresh')"
|
||||
:aria-busy="missingModelStore.isRefreshingMissingModels"
|
||||
:aria-disabled="missingModelStore.isRefreshingMissingModels"
|
||||
@click.stop="handleMissingModelRefresh"
|
||||
>
|
||||
<DotSpinner
|
||||
v-if="missingModelStore.isRefreshingMissingModels"
|
||||
aria-hidden="true"
|
||||
duration="1s"
|
||||
:size="12"
|
||||
/>
|
||||
<i
|
||||
v-else
|
||||
aria-hidden="true"
|
||||
class="icon-[lucide--refresh-cw] size-4 shrink-0"
|
||||
/>
|
||||
</Button>
|
||||
<span
|
||||
v-if="
|
||||
group.type === 'missing_model' &&
|
||||
showMissingModelHeaderRefresh
|
||||
"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
class="sr-only"
|
||||
>
|
||||
{{
|
||||
missingModelStore.isRefreshingMissingModels
|
||||
? t('rightSidePanel.missingModels.refreshing')
|
||||
: ''
|
||||
}}
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<div
|
||||
v-if="group.displayMessage"
|
||||
data-testid="error-group-display-message"
|
||||
class="px-3 py-1"
|
||||
>
|
||||
<p
|
||||
class="m-0 text-xs/normal wrap-break-word whitespace-pre-wrap text-base-foreground/50"
|
||||
>
|
||||
{{ group.displayMessage }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Missing Node Packs -->
|
||||
<MissingNodeCard
|
||||
v-if="group.type === 'missing_node'"
|
||||
:show-info-button="shouldShowManagerButtons"
|
||||
:missing-pack-groups="missingPackGroups"
|
||||
@locate-node="handleLocateMissingNode"
|
||||
@open-manager-info="handleOpenManagerInfo"
|
||||
/>
|
||||
|
||||
<!-- Swap Nodes -->
|
||||
<SwapNodesCard
|
||||
v-if="group.type === 'swap_nodes'"
|
||||
:swap-node-groups="swapNodeGroups"
|
||||
@locate-node="handleLocateMissingNode"
|
||||
@replace="handleReplaceGroup"
|
||||
/>
|
||||
|
||||
<!-- Execution Errors -->
|
||||
<div v-if="isExecutionItemListGroup(group)" class="px-3">
|
||||
<ul class="m-0 list-none space-y-1 p-0">
|
||||
<li
|
||||
v-for="item in getExecutionItemList(group)"
|
||||
:key="item.key"
|
||||
class="min-w-0"
|
||||
>
|
||||
<div class="flex min-w-0 items-center gap-2">
|
||||
<span class="flex min-w-0 flex-1 items-center gap-1">
|
||||
<button
|
||||
v-tooltip.top="{
|
||||
value: item.displayDetails || undefined,
|
||||
showDelay: 300
|
||||
}"
|
||||
type="button"
|
||||
class="focus-visible:ring-ring m-0 inline max-w-full cursor-pointer appearance-none rounded-sm border-0 bg-transparent p-0 text-left text-xs/relaxed font-normal wrap-break-word text-muted-foreground outline-none hover:text-base-foreground focus:outline-none focus-visible:ring-1 focus-visible:outline-none focus-visible:ring-inset"
|
||||
@click="handleLocateNode(item.nodeId)"
|
||||
>
|
||||
{{ item.label }}
|
||||
</button>
|
||||
<Button
|
||||
v-if="item.displayDetails"
|
||||
variant="textonly"
|
||||
size="icon-sm"
|
||||
:class="
|
||||
cn(
|
||||
'size-6 shrink-0 text-muted-foreground hover:text-base-foreground focus-visible:ring-inset',
|
||||
isExecutionItemDetailExpanded(item.key) &&
|
||||
'bg-secondary-background-selected text-base-foreground hover:bg-secondary-background-selected'
|
||||
)
|
||||
"
|
||||
:aria-label="
|
||||
t('rightSidePanel.infoFor', { item: item.label })
|
||||
"
|
||||
:aria-controls="getExecutionItemDetailId(item.key)"
|
||||
:aria-expanded="isExecutionItemDetailExpanded(item.key)"
|
||||
@click.stop="toggleExecutionItemDetail(item.key)"
|
||||
>
|
||||
<i class="icon-[lucide--info] size-3.5" />
|
||||
</Button>
|
||||
</span>
|
||||
<Button
|
||||
variant="textonly"
|
||||
size="icon-sm"
|
||||
class="size-8 shrink-0 text-muted-foreground hover:text-base-foreground focus-visible:ring-inset"
|
||||
:aria-label="
|
||||
t('rightSidePanel.locateNodeFor', { item: item.label })
|
||||
"
|
||||
@click.stop="handleLocateNode(item.nodeId)"
|
||||
>
|
||||
<i class="icon-[lucide--locate] size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<TransitionCollapse>
|
||||
<p
|
||||
v-if="
|
||||
item.displayDetails &&
|
||||
isExecutionItemDetailExpanded(item.key)
|
||||
"
|
||||
:id="getExecutionItemDetailId(item.key)"
|
||||
class="m-0 mt-0.5 pr-10 text-2xs/relaxed wrap-break-word whitespace-pre-wrap text-muted-foreground"
|
||||
>
|
||||
{{ item.displayDetails }}
|
||||
</p>
|
||||
</TransitionCollapse>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div v-else-if="group.type === 'execution'" class="space-y-3 px-3">
|
||||
<ErrorNodeCard
|
||||
v-for="card in group.cards"
|
||||
:key="card.id"
|
||||
:card="card"
|
||||
:compact="isSingleNodeSelected"
|
||||
@locate-node="handleLocateNode"
|
||||
@copy-to-clipboard="copyToClipboard"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Missing Models -->
|
||||
<MissingModelCard
|
||||
v-if="group.type === 'missing_model'"
|
||||
:missing-model-groups="missingModelGroups"
|
||||
@locate-model="handleLocateAssetNode"
|
||||
/>
|
||||
|
||||
<!-- Missing Media -->
|
||||
<MissingMediaCard
|
||||
v-if="group.type === 'missing_media'"
|
||||
:missing-media-groups="missingMediaGroups"
|
||||
@locate-node="handleLocateAssetNode"
|
||||
/>
|
||||
</ErrorCardSection>
|
||||
</TransitionGroup>
|
||||
</div>
|
||||
</div>
|
||||
<ErrorGroupList class="min-h-0 flex-1" />
|
||||
|
||||
<ErrorPanelSurveyCta v-if="ErrorPanelSurveyCta" />
|
||||
|
||||
@@ -308,44 +39,14 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, defineAsyncComponent, ref, watch } from 'vue'
|
||||
import { defineAsyncComponent } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
import { useCopyToClipboard } from '@/composables/useCopyToClipboard'
|
||||
import { useFocusNode } from '@/composables/canvas/useFocusNode'
|
||||
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
|
||||
import { useManagerState } from '@/workbench/extensions/manager/composables/useManagerState'
|
||||
import { ManagerTab } from '@/workbench/extensions/manager/types/comfyManagerTypes'
|
||||
|
||||
import CollapseToggleButton from '../layout/CollapseToggleButton.vue'
|
||||
import TransitionCollapse from '../layout/TransitionCollapse.vue'
|
||||
import AsyncSearchInput from '@/components/ui/search-input/AsyncSearchInput.vue'
|
||||
import ErrorCardSection from './ErrorCardSection.vue'
|
||||
import ErrorNodeCard from './ErrorNodeCard.vue'
|
||||
import MissingNodeCard from './MissingNodeCard.vue'
|
||||
import SwapNodesCard from '@/platform/nodeReplacement/components/SwapNodesCard.vue'
|
||||
import MissingModelCard from '@/platform/missingModel/components/MissingModelCard.vue'
|
||||
import MissingMediaCard from '@/platform/missingMedia/components/MissingMediaCard.vue'
|
||||
import { isCloud, isDesktop, isNightly } from '@/platform/distribution/types'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import DotSpinner from '@/components/common/DotSpinner.vue'
|
||||
import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
|
||||
import { usePackInstall } from '@/workbench/extensions/manager/composables/nodePack/usePackInstall'
|
||||
import { useMissingNodes } from '@/workbench/extensions/manager/composables/nodePack/useMissingNodes'
|
||||
import { useErrorActions } from './useErrorActions'
|
||||
import { useErrorGroups } from './useErrorGroups'
|
||||
import type { SwapNodeGroup } from './useErrorGroups'
|
||||
import type { ErrorGroup } from './types'
|
||||
import { isExecutionItemListGroup } from './executionItemList'
|
||||
import { useNodeReplacement } from '@/platform/nodeReplacement/useNodeReplacement'
|
||||
import { isCloud, isDesktop, isNightly } from '@/platform/distribution/types'
|
||||
|
||||
interface ExecutionItemListEntry {
|
||||
key: string
|
||||
nodeId: string
|
||||
label: string
|
||||
displayDetails?: string
|
||||
}
|
||||
import ErrorGroupList from './ErrorGroupList.vue'
|
||||
import { useErrorActions } from './useErrorActions'
|
||||
|
||||
const ErrorPanelSurveyCta =
|
||||
isNightly && !isCloud && !isDesktop
|
||||
@@ -355,171 +56,5 @@ const ErrorPanelSurveyCta =
|
||||
: undefined
|
||||
|
||||
const { t } = useI18n()
|
||||
const { copyToClipboard } = useCopyToClipboard()
|
||||
const { focusNode } = useFocusNode()
|
||||
const { openGitHubIssues, contactSupport } = useErrorActions()
|
||||
const rightSidePanelStore = useRightSidePanelStore()
|
||||
const missingModelStore = useMissingModelStore()
|
||||
const { shouldShowManagerButtons, shouldShowInstallButton, openManager } =
|
||||
useManagerState()
|
||||
const { missingNodePacks } = useMissingNodes()
|
||||
const { isInstalling: isInstallingAll, installAllPacks: installAll } =
|
||||
usePackInstall(() => missingNodePacks.value)
|
||||
const { replaceGroup, replaceAllGroups } = useNodeReplacement()
|
||||
|
||||
const searchQuery = ref('')
|
||||
const expandedExecutionItemDetailKeys = ref(new Set<string>())
|
||||
const isSearching = computed(() => searchQuery.value.trim() !== '')
|
||||
|
||||
function getExecutionItemList(group: ErrorGroup): ExecutionItemListEntry[] {
|
||||
if (group.type !== 'execution') return []
|
||||
|
||||
const items: ExecutionItemListEntry[] = []
|
||||
for (const card of group.cards) {
|
||||
if (!card.nodeId) continue
|
||||
for (let idx = 0; idx < card.errors.length; idx++) {
|
||||
const error = card.errors[idx]
|
||||
const label = error.displayItemLabel
|
||||
if (!label) continue
|
||||
items.push({
|
||||
key: `${card.id}:${idx}`,
|
||||
nodeId: card.nodeId,
|
||||
label,
|
||||
displayDetails: error.displayDetails
|
||||
})
|
||||
}
|
||||
}
|
||||
return items.sort(compareExecutionItemListEntry)
|
||||
}
|
||||
|
||||
function compareExecutionItemListEntry(
|
||||
a: ExecutionItemListEntry,
|
||||
b: ExecutionItemListEntry
|
||||
) {
|
||||
return (
|
||||
a.nodeId.localeCompare(b.nodeId, undefined, { numeric: true }) ||
|
||||
a.label.localeCompare(b.label)
|
||||
)
|
||||
}
|
||||
|
||||
function isExecutionItemDetailExpanded(key: string) {
|
||||
return expandedExecutionItemDetailKeys.value.has(key)
|
||||
}
|
||||
|
||||
function toggleExecutionItemDetail(key: string) {
|
||||
const nextKeys = new Set(expandedExecutionItemDetailKeys.value)
|
||||
if (nextKeys.has(key)) {
|
||||
nextKeys.delete(key)
|
||||
} else {
|
||||
nextKeys.add(key)
|
||||
}
|
||||
expandedExecutionItemDetailKeys.value = nextKeys
|
||||
}
|
||||
|
||||
function getExecutionItemDetailId(key: string) {
|
||||
return `execution-item-detail-${key}`
|
||||
}
|
||||
|
||||
const {
|
||||
allErrorGroups,
|
||||
tabErrorGroups,
|
||||
filteredGroups,
|
||||
collapseState,
|
||||
isSingleNodeSelected,
|
||||
errorNodeCache,
|
||||
missingNodeCache,
|
||||
missingPackGroups,
|
||||
filteredMissingModelGroups: missingModelGroups,
|
||||
filteredMissingMediaGroups: missingMediaGroups,
|
||||
swapNodeGroups
|
||||
} = useErrorGroups(searchQuery)
|
||||
|
||||
const totalErrorCount = computed(() =>
|
||||
filteredGroups.value.reduce((sum, group) => sum + group.count, 0)
|
||||
)
|
||||
|
||||
const showMissingModelHeaderRefresh = computed(
|
||||
() => !isCloud && missingModelGroups.value.length > 0
|
||||
)
|
||||
|
||||
function handleMissingModelRefresh() {
|
||||
if (missingModelStore.isRefreshingMissingModels) return
|
||||
|
||||
void missingModelStore.refreshMissingModels()
|
||||
}
|
||||
|
||||
const isAllCollapsed = computed({
|
||||
get() {
|
||||
return filteredGroups.value.every((g) => isSectionCollapsed(g.groupKey))
|
||||
},
|
||||
set(collapse: boolean) {
|
||||
for (const group of tabErrorGroups.value) {
|
||||
setSectionCollapsed(group.groupKey, collapse)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
function isSectionCollapsed(groupKey: string): boolean {
|
||||
// Defaults to expanded when not explicitly set by the user
|
||||
return collapseState[groupKey] ?? false
|
||||
}
|
||||
|
||||
function setSectionCollapsed(groupKey: string, collapsed: boolean) {
|
||||
collapseState[groupKey] = collapsed
|
||||
}
|
||||
|
||||
/**
|
||||
* When an external trigger (e.g. "See Error" button in SectionWidgets)
|
||||
* sets focusedErrorNodeId, expand only the group containing the target
|
||||
* node and collapse all others so the user sees the relevant errors
|
||||
* immediately.
|
||||
*/
|
||||
watch(
|
||||
() => rightSidePanelStore.focusedErrorNodeId,
|
||||
(graphNodeId) => {
|
||||
if (!graphNodeId) return
|
||||
const prefix = `${graphNodeId}:`
|
||||
for (const group of allErrorGroups.value) {
|
||||
if (group.type !== 'execution') continue
|
||||
|
||||
const hasMatch = group.cards.some(
|
||||
(card) =>
|
||||
card.graphNodeId === graphNodeId ||
|
||||
(card.nodeId?.startsWith(prefix) ?? false)
|
||||
)
|
||||
setSectionCollapsed(group.groupKey, !hasMatch)
|
||||
}
|
||||
rightSidePanelStore.focusedErrorNodeId = null
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
function handleLocateNode(nodeId: string) {
|
||||
focusNode(nodeId, errorNodeCache.value)
|
||||
}
|
||||
|
||||
function handleLocateMissingNode(nodeId: string) {
|
||||
focusNode(nodeId, missingNodeCache.value)
|
||||
}
|
||||
|
||||
function handleLocateAssetNode(nodeId: string) {
|
||||
focusNode(nodeId)
|
||||
}
|
||||
|
||||
function handleOpenManagerInfo(packId: string) {
|
||||
const isKnownToRegistry = missingNodePacks.value.some((p) => p.id === packId)
|
||||
if (isKnownToRegistry) {
|
||||
openManager({ initialTab: ManagerTab.Missing, initialPackId: packId })
|
||||
} else {
|
||||
openManager({ initialTab: ManagerTab.All, initialPackId: packId })
|
||||
}
|
||||
}
|
||||
|
||||
function handleReplaceGroup(group: SwapNodeGroup) {
|
||||
replaceGroup(group)
|
||||
}
|
||||
|
||||
function handleReplaceAll() {
|
||||
replaceAllGroups(swapNodeGroups.value)
|
||||
}
|
||||
</script>
|
||||
|
||||
30
src/components/rightSidePanel/errors/selectionEmphasis.ts
Normal file
30
src/components/rightSidePanel/errors/selectionEmphasis.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
import type { MissingNodeType } from '@/types/comfy'
|
||||
|
||||
// The negative margin and matching padding cancel out, so the background
|
||||
// bleeds 6px past the content without shifting the text.
|
||||
const EMPHASIS_CLASS = 'rounded-sm bg-blue-selection -mx-1.5 px-1.5'
|
||||
|
||||
// Present even when unhighlighted so the emphasis animates both ways.
|
||||
const TRANSITION_CLASS =
|
||||
'transition-[background-color,margin,padding,border-radius] duration-200'
|
||||
|
||||
/** Classes emphasizing rows/cards that belong to the canvas selection. */
|
||||
export function selectionEmphasisClass(highlighted: boolean | undefined) {
|
||||
return cn(TRANSITION_CLASS, highlighted && EMPHASIS_CLASS)
|
||||
}
|
||||
|
||||
/** True when any node type resolves to a node in the given id set. */
|
||||
export function someNodeTypeInSelection(
|
||||
nodeTypes: MissingNodeType[],
|
||||
nodeIds: Set<string> | undefined
|
||||
): boolean {
|
||||
if (!nodeIds?.size) return false
|
||||
return nodeTypes.some(
|
||||
(nodeType) =>
|
||||
typeof nodeType !== 'string' &&
|
||||
nodeType.nodeId != null &&
|
||||
nodeIds.has(String(nodeType.nodeId))
|
||||
)
|
||||
}
|
||||
@@ -4,9 +4,12 @@ import { nextTick, ref } from 'vue'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { MissingNodeType } from '@/types/comfy'
|
||||
import type { NodeExecutionId } from '@/types/nodeIdentification'
|
||||
import type * as GraphTraversalUtil from '@/utils/graphTraversalUtil'
|
||||
|
||||
vi.mock('@/scripts/app', () => ({
|
||||
app: {
|
||||
isGraphReady: true,
|
||||
rootGraph: {
|
||||
serialize: vi.fn(() => ({})),
|
||||
getNodeById: vi.fn()
|
||||
@@ -126,6 +129,14 @@ import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
|
||||
import { useMissingNodesErrorStore } from '@/platform/nodeReplacement/missingNodesErrorStore'
|
||||
import { isLGraphNode } from '@/utils/litegraphUtil'
|
||||
import { nodeError, validationError } from '@/utils/__tests__/nodeErrorHelpers'
|
||||
import { createBoundaryLinkedSubgraph } from '@/lib/litegraph/src/subgraph/__fixtures__/subgraphHelpers'
|
||||
import {
|
||||
getExecutionIdByNode,
|
||||
getNodeByExecutionId
|
||||
} from '@/utils/graphTraversalUtil'
|
||||
import { SubgraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { useErrorGroups } from './useErrorGroups'
|
||||
import type { MissingMediaCandidate } from '@/platform/missingMedia/types'
|
||||
|
||||
@@ -205,6 +216,7 @@ describe('useErrorGroups', () => {
|
||||
setActivePinia(createPinia())
|
||||
mockIsCloud.value = false
|
||||
vi.mocked(isLGraphNode).mockReturnValue(false)
|
||||
vi.mocked(getNodeByExecutionId).mockReset()
|
||||
})
|
||||
|
||||
describe('missingPackGroups', () => {
|
||||
@@ -485,6 +497,47 @@ describe('useErrorGroups', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('groups lifted boundary errors under the host node card', async () => {
|
||||
const { store, groups } = createErrorGroups()
|
||||
const { rootGraph, host } = createBoundaryLinkedSubgraph({
|
||||
interiorType: 'InteriorClass'
|
||||
})
|
||||
const { getNodeByExecutionId: actualGetNodeByExecutionId } =
|
||||
await vi.importActual<typeof GraphTraversalUtil>(
|
||||
'@/utils/graphTraversalUtil'
|
||||
)
|
||||
vi.mocked(getNodeByExecutionId).mockImplementation((_, nodeId) => {
|
||||
return actualGetNodeByExecutionId(rootGraph, String(nodeId))
|
||||
})
|
||||
store.lastNodeErrors = {
|
||||
'12:5': nodeError(
|
||||
[
|
||||
validationError(
|
||||
'required_input_missing',
|
||||
'seed_input',
|
||||
{},
|
||||
'Required input is missing'
|
||||
)
|
||||
],
|
||||
'InteriorClass'
|
||||
)
|
||||
}
|
||||
await nextTick()
|
||||
|
||||
const execGroup = groups.allErrorGroups.value.find(
|
||||
(g) => g.type === 'execution'
|
||||
)
|
||||
expect(execGroup?.type).toBe('execution')
|
||||
if (execGroup?.type !== 'execution') return
|
||||
|
||||
const card = execGroup.cards[0]
|
||||
expect(card.nodeId).toBe('12')
|
||||
expect(card.title).toBe(host.title)
|
||||
expect(card.errors[0].displayDetails).toBe(
|
||||
`${host.title} is missing a required input: seed`
|
||||
)
|
||||
})
|
||||
|
||||
it('groups node validation errors by catalog id across node types', async () => {
|
||||
const { store, groups } = createErrorGroups()
|
||||
store.lastNodeErrors = {
|
||||
@@ -986,24 +1039,13 @@ describe('useErrorGroups', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('unfiltered vs selection-filtered model/media groups', () => {
|
||||
it('exposes both unfiltered (missingModelGroups) and filtered (filteredMissingModelGroups)', () => {
|
||||
const { groups } = createErrorGroups()
|
||||
expect(groups.missingModelGroups).toBeDefined()
|
||||
expect(groups.filteredMissingModelGroups).toBeDefined()
|
||||
expect(groups.missingMediaGroups).toBeDefined()
|
||||
expect(groups.filteredMissingMediaGroups).toBeDefined()
|
||||
})
|
||||
|
||||
it('missingModelGroups returns total candidates regardless of selection (ErrorOverlay contract)', async () => {
|
||||
describe('selection does not shrink displayed groups', () => {
|
||||
it('missingModelGroups returns total candidates regardless of selection', async () => {
|
||||
const { store, groups } = createErrorGroups()
|
||||
store.surfaceMissingModels([
|
||||
makeModel('a.safetensors', { nodeId: '1', directory: 'checkpoints' }),
|
||||
makeModel('b.safetensors', { nodeId: '2', directory: 'checkpoints' })
|
||||
])
|
||||
// Simulate canvas selection of a single node so the filtered
|
||||
// variant actually narrows. Without this, both sides return the
|
||||
// same value trivially and the test can't prove the contract.
|
||||
vi.mocked(isLGraphNode).mockReturnValue(true)
|
||||
const canvasStore = useCanvasStore()
|
||||
canvasStore.selectedItems = fromAny<
|
||||
@@ -1012,23 +1054,18 @@ describe('useErrorGroups', () => {
|
||||
>([{ id: '1' }])
|
||||
await nextTick()
|
||||
|
||||
// Unfiltered total stays at one group of two models regardless of
|
||||
// the selection — ErrorOverlay reads this for the overlay label
|
||||
// and must not shrink with canvas selection.
|
||||
// Displayed groups never shrink with canvas selection — the count
|
||||
// and list always describe the whole workflow.
|
||||
expect(groups.missingModelGroups.value).toHaveLength(1)
|
||||
expect(groups.missingModelGroups.value[0].models).toHaveLength(2)
|
||||
|
||||
// Filtered variant does narrow under the same selection state —
|
||||
// this is how the errors tab scopes cards to the selected node.
|
||||
// Exact filtered output depends on the app.rootGraph lookup
|
||||
// (mocked to return undefined here); what matters is that the
|
||||
// filtered shape is a different reference and does not blindly
|
||||
// mirror the unfiltered one.
|
||||
expect(groups.filteredMissingModelGroups.value).not.toBe(
|
||||
groups.missingModelGroups.value
|
||||
)
|
||||
expect(
|
||||
groups.filteredGroups.value.find((g) => g.type === 'missing_model')
|
||||
?.count
|
||||
).toBe(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('missing media counting', () => {
|
||||
it('counts missing media by affected node rows, not grouped filenames', async () => {
|
||||
const { store, groups } = createErrorGroups()
|
||||
store.surfaceMissingMedia([
|
||||
@@ -1051,8 +1088,8 @@ describe('useErrorGroups', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('tabErrorGroups', () => {
|
||||
it('filters prompt error when a node is selected', async () => {
|
||||
describe('selection emphasis', () => {
|
||||
it('never marks workflow-level prompt errors as matched by a selection', async () => {
|
||||
const { store, groups } = createErrorGroups()
|
||||
const canvasStore = useCanvasStore()
|
||||
vi.mocked(isLGraphNode).mockReturnValue(true)
|
||||
@@ -1067,11 +1104,205 @@ describe('useErrorGroups', () => {
|
||||
}
|
||||
await nextTick()
|
||||
|
||||
const promptGroup = groups.tabErrorGroups.value.find(
|
||||
const promptGroup = groups.allErrorGroups.value.find(
|
||||
(g) =>
|
||||
g.type === 'execution' && g.displayTitle === 'Prompt has no outputs'
|
||||
)
|
||||
expect(promptGroup).toBeUndefined()
|
||||
expect(promptGroup).toBeDefined()
|
||||
expect(
|
||||
groups.selectionMatchedGroupKeys.value.has(promptGroup!.groupKey)
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('reports no selection state when nothing is selected', async () => {
|
||||
const { store, groups } = createErrorGroups()
|
||||
store.lastNodeErrors = {
|
||||
'1': {
|
||||
class_type: 'KSampler',
|
||||
dependent_outputs: [],
|
||||
errors: [{ type: 'value_error', message: 'Bad value', details: '' }]
|
||||
}
|
||||
}
|
||||
await nextTick()
|
||||
|
||||
expect(groups.hasSelection.value).toBe(false)
|
||||
expect(groups.selectionMatchedGroupKeys.value.size).toBe(0)
|
||||
expect(groups.selectionMatchedCardIds.value.size).toBe(0)
|
||||
expect(groups.selectionErrorCount.value).toBe(0)
|
||||
})
|
||||
|
||||
it('matches groups and cards of the selected error node', async () => {
|
||||
const { store, groups } = createErrorGroups()
|
||||
const canvasStore = useCanvasStore()
|
||||
vi.mocked(isLGraphNode).mockReturnValue(true)
|
||||
const selectedNode = { id: '1' }
|
||||
vi.mocked(getNodeByExecutionId).mockImplementation((_, nodeId) =>
|
||||
fromAny<LGraphNode, unknown>(
|
||||
String(nodeId) === '1' ? selectedNode : { id: String(nodeId) }
|
||||
)
|
||||
)
|
||||
canvasStore.selectedItems = fromAny<
|
||||
typeof canvasStore.selectedItems,
|
||||
unknown
|
||||
>([selectedNode])
|
||||
store.lastNodeErrors = {
|
||||
'1': {
|
||||
class_type: 'KSampler',
|
||||
dependent_outputs: [],
|
||||
errors: [{ type: 'value_error', message: 'Bad value', details: '' }]
|
||||
},
|
||||
'2': {
|
||||
class_type: 'CLIPLoader',
|
||||
dependent_outputs: [],
|
||||
errors: [
|
||||
{ type: 'file_not_found', message: 'File not found', details: '' }
|
||||
]
|
||||
}
|
||||
}
|
||||
await nextTick()
|
||||
|
||||
expect(groups.hasSelection.value).toBe(true)
|
||||
expect(groups.selectionErrorCount.value).toBe(1)
|
||||
expect(groups.selectionMatchedCardIds.value.has('node-1')).toBe(true)
|
||||
expect(groups.selectionMatchedCardIds.value.has('node-2')).toBe(false)
|
||||
expect(groups.selectionMatchedAssetNodeIds.value.size).toBe(0)
|
||||
// Both error groups remain displayed regardless of the selection
|
||||
const executionGroups = groups.filteredGroups.value.filter(
|
||||
(g) => g.type === 'execution'
|
||||
)
|
||||
const displayedCardIds = executionGroups.flatMap((g) =>
|
||||
g.type === 'execution' ? g.cards.map((c) => c.id) : []
|
||||
)
|
||||
expect(displayedCardIds).toContain('node-1')
|
||||
expect(displayedCardIds).toContain('node-2')
|
||||
})
|
||||
|
||||
it('narrows missing-node emphasis to packs containing the selected node', async () => {
|
||||
const { groups } = createErrorGroups()
|
||||
const missingNodesStore = useMissingNodesErrorStore()
|
||||
const canvasStore = useCanvasStore()
|
||||
vi.mocked(isLGraphNode).mockReturnValue(true)
|
||||
vi.mocked(getNodeByExecutionId).mockImplementation((_, nodeId) =>
|
||||
fromAny<LGraphNode, unknown>({ id: String(nodeId) })
|
||||
)
|
||||
canvasStore.selectedItems = fromAny<
|
||||
typeof canvasStore.selectedItems,
|
||||
unknown
|
||||
>([{ id: '2' }])
|
||||
missingNodesStore.setMissingNodeTypes([
|
||||
makeMissingNodeType('NodeB', { cnrId: 'pack-1', nodeId: '2' }),
|
||||
makeMissingNodeType('NodeC', { cnrId: 'pack-2', nodeId: '3' })
|
||||
])
|
||||
await nextTick()
|
||||
|
||||
// Emphasis counts only the packs containing the selected node…
|
||||
expect(groups.selectionMatchedGroupKeys.value.has('missing_node')).toBe(
|
||||
true
|
||||
)
|
||||
expect(groups.selectionErrorCount.value).toBe(1)
|
||||
// …and marks only the selected node for row highlighting.
|
||||
expect(groups.selectionMatchedAssetNodeIds.value.has('2')).toBe(true)
|
||||
expect(groups.selectionMatchedAssetNodeIds.value.has('3')).toBe(false)
|
||||
// Display still shows every pack.
|
||||
const missingNodeGroup = groups.filteredGroups.value.find(
|
||||
(g) => g.type === 'missing_node'
|
||||
)
|
||||
expect(missingNodeGroup?.count).toBe(2)
|
||||
})
|
||||
|
||||
it('does not emphasize missing-node groups for unrelated selections', async () => {
|
||||
const { groups } = createErrorGroups()
|
||||
const missingNodesStore = useMissingNodesErrorStore()
|
||||
const canvasStore = useCanvasStore()
|
||||
vi.mocked(isLGraphNode).mockReturnValue(true)
|
||||
vi.mocked(getNodeByExecutionId).mockImplementation((_, nodeId) =>
|
||||
fromAny<LGraphNode, unknown>({ id: String(nodeId) })
|
||||
)
|
||||
canvasStore.selectedItems = fromAny<
|
||||
typeof canvasStore.selectedItems,
|
||||
unknown
|
||||
>([{ id: '99' }])
|
||||
missingNodesStore.setMissingNodeTypes([
|
||||
makeMissingNodeType('NodeB', { cnrId: 'pack-1', nodeId: '2' })
|
||||
])
|
||||
await nextTick()
|
||||
|
||||
expect(groups.selectionMatchedGroupKeys.value.has('missing_node')).toBe(
|
||||
false
|
||||
)
|
||||
expect(groups.selectionErrorCount.value).toBe(0)
|
||||
// Display is unaffected by the unrelated selection.
|
||||
expect(
|
||||
groups.filteredGroups.value.find((g) => g.type === 'missing_node')
|
||||
?.count
|
||||
).toBe(1)
|
||||
})
|
||||
|
||||
it('matches errors through graph resolution, not raw execution ids', async () => {
|
||||
const { store, groups } = createErrorGroups()
|
||||
const canvasStore = useCanvasStore()
|
||||
vi.mocked(isLGraphNode).mockReturnValue(true)
|
||||
// The error is keyed by a subgraph execution id ('2:5') that resolves
|
||||
// to a different graph node id ('7') at the current graph level.
|
||||
const selectedNode = { id: '7' }
|
||||
vi.mocked(getNodeByExecutionId).mockImplementation((_, nodeId) =>
|
||||
fromAny<LGraphNode, unknown>(
|
||||
String(nodeId) === '2:5' ? selectedNode : undefined
|
||||
)
|
||||
)
|
||||
canvasStore.selectedItems = fromAny<
|
||||
typeof canvasStore.selectedItems,
|
||||
unknown
|
||||
>([selectedNode])
|
||||
store.lastNodeErrors = {
|
||||
'2:5': {
|
||||
class_type: 'KSampler',
|
||||
dependent_outputs: [],
|
||||
errors: [{ type: 'value_error', message: 'Bad value', details: '' }]
|
||||
}
|
||||
}
|
||||
await nextTick()
|
||||
|
||||
expect(groups.selectionErrorCount.value).toBe(1)
|
||||
expect(groups.selectionMatchedCardIds.value.has('node-2:5')).toBe(true)
|
||||
})
|
||||
|
||||
it('matches interior errors when a subgraph container is selected', async () => {
|
||||
const { store, groups } = createErrorGroups()
|
||||
const canvasStore = useCanvasStore()
|
||||
vi.mocked(isLGraphNode).mockReturnValue(true)
|
||||
// A container selection matches interior errors by execution-id prefix,
|
||||
// even when the interior node does not resolve at the current level.
|
||||
const containerNode = fromAny<SubgraphNode, unknown>(
|
||||
Object.assign(Object.create(SubgraphNode.prototype), { id: '2' })
|
||||
)
|
||||
vi.mocked(getNodeByExecutionId).mockReturnValue(null)
|
||||
vi.mocked(getExecutionIdByNode).mockReturnValue(
|
||||
fromAny<NodeExecutionId, unknown>('2')
|
||||
)
|
||||
canvasStore.selectedItems = fromAny<
|
||||
typeof canvasStore.selectedItems,
|
||||
unknown
|
||||
>([containerNode])
|
||||
store.lastNodeErrors = {
|
||||
'2:5': {
|
||||
class_type: 'KSampler',
|
||||
dependent_outputs: [],
|
||||
errors: [{ type: 'value_error', message: 'Bad value', details: '' }]
|
||||
},
|
||||
'9': {
|
||||
class_type: 'CLIPLoader',
|
||||
dependent_outputs: [],
|
||||
errors: [
|
||||
{ type: 'file_not_found', message: 'File not found', details: '' }
|
||||
]
|
||||
}
|
||||
}
|
||||
await nextTick()
|
||||
|
||||
expect(groups.selectionErrorCount.value).toBe(1)
|
||||
expect(groups.selectionMatchedCardIds.value.has('node-2:5')).toBe(true)
|
||||
expect(groups.selectionMatchedCardIds.value.has('node-9')).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -24,6 +24,7 @@ import { st } from '@/i18n'
|
||||
import type { MissingNodeType } from '@/types/comfy'
|
||||
import type { ErrorCardData, ErrorGroup, ErrorItem } from './types'
|
||||
import { shouldRenderExecutionItemList } from './executionItemList'
|
||||
import { someNodeTypeInSelection } from './selectionEmphasis'
|
||||
import type { NodeExecutionId } from '@/types/nodeIdentification'
|
||||
import type { MissingModelGroup } from '@/platform/missingModel/types'
|
||||
import type { ResolvedCatalogErrorMessage } from '@/platform/errorCatalog/types'
|
||||
@@ -259,12 +260,25 @@ export function useErrorGroups(searchQuery: MaybeRefOrGetter<string>) {
|
||||
}
|
||||
})
|
||||
|
||||
const isSingleNodeSelected = computed(
|
||||
() =>
|
||||
selectedNodeInfo.value.nodeIds?.size === 1 &&
|
||||
selectedNodeInfo.value.containerExecutionIds.size === 0
|
||||
const hasSelection = computed(() => selectedNodeInfo.value.nodeIds !== null)
|
||||
|
||||
const selectedNodeCount = computed(
|
||||
() => selectedNodeInfo.value.nodeIds?.size ?? 0
|
||||
)
|
||||
|
||||
const selectedNodeTitle = computed(() => {
|
||||
if (selectedNodeCount.value !== 1) return null
|
||||
const node = canvasStore.selectedItems.find(isLGraphNode)
|
||||
if (!node) return null
|
||||
return (
|
||||
resolveNodeDisplayName(node, {
|
||||
emptyLabel: '',
|
||||
untitledLabel: '',
|
||||
st
|
||||
}) || null
|
||||
)
|
||||
})
|
||||
|
||||
const errorNodeCache = computed(() => {
|
||||
const map = new Map<string, LGraphNode>()
|
||||
for (const execId of executionErrorStore.allErrorExecutionIds) {
|
||||
@@ -368,10 +382,10 @@ export function useErrorGroups(searchQuery: MaybeRefOrGetter<string>) {
|
||||
groupsMap: Map<string, GroupEntry>,
|
||||
filterBySelection = false
|
||||
) {
|
||||
if (!executionErrorStore.lastNodeErrors) return
|
||||
if (!executionErrorStore.surfacedNodeErrors) return
|
||||
|
||||
for (const [rawNodeId, nodeError] of Object.entries(
|
||||
executionErrorStore.lastNodeErrors
|
||||
executionErrorStore.surfacedNodeErrors
|
||||
)) {
|
||||
const nodeId = tryNormalizeNodeExecutionId(rawNodeId)
|
||||
if (!nodeId) continue
|
||||
@@ -581,38 +595,50 @@ export function useErrorGroups(searchQuery: MaybeRefOrGetter<string>) {
|
||||
return Array.from(map.values()).sort((a, b) => a.type.localeCompare(b.type))
|
||||
})
|
||||
|
||||
/** Builds an ErrorGroup from missingNodesError. Returns [] when none present. */
|
||||
function buildMissingNodeGroups(): ErrorGroup[] {
|
||||
/**
|
||||
* Builds ErrorGroups from missingNodesError. Returns [] when none present.
|
||||
* `includeGroup` narrows which swap/pack groups are counted (used to scope
|
||||
* emphasis to the canvas selection); groups reduced to zero are omitted.
|
||||
*/
|
||||
function buildMissingNodeGroups(
|
||||
includeGroup: (nodeTypes: MissingNodeType[]) => boolean = () => true
|
||||
): ErrorGroup[] {
|
||||
const error = missingNodesStore.missingNodesError
|
||||
if (!error) return []
|
||||
|
||||
const groups: ErrorGroup[] = []
|
||||
const swapCount = swapNodeGroups.value.filter((group) =>
|
||||
includeGroup(group.nodeTypes)
|
||||
).length
|
||||
const packCount = missingPackGroups.value.filter((group) =>
|
||||
includeGroup(group.nodeTypes)
|
||||
).length
|
||||
|
||||
if (swapNodeGroups.value.length > 0) {
|
||||
if (swapCount > 0) {
|
||||
groups.push({
|
||||
type: 'swap_nodes' as const,
|
||||
groupKey: 'swap_nodes',
|
||||
count: swapNodeGroups.value.length,
|
||||
count: swapCount,
|
||||
priority: 0,
|
||||
...resolveMissingErrorMessage({
|
||||
kind: 'swap_nodes',
|
||||
nodeTypes: missingNodesStore.missingNodesError?.nodeTypes ?? [],
|
||||
count: swapNodeGroups.value.length,
|
||||
nodeTypes: error.nodeTypes,
|
||||
count: swapCount,
|
||||
isCloud
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
if (missingPackGroups.value.length > 0) {
|
||||
if (packCount > 0) {
|
||||
groups.push({
|
||||
type: 'missing_node' as const,
|
||||
groupKey: 'missing_node',
|
||||
count: missingPackGroups.value.length,
|
||||
count: packCount,
|
||||
priority: 1,
|
||||
...resolveMissingErrorMessage({
|
||||
kind: 'missing_node',
|
||||
nodeTypes: error.nodeTypes,
|
||||
count: missingPackGroups.value.length,
|
||||
count: packCount,
|
||||
isCloud
|
||||
})
|
||||
})
|
||||
@@ -699,31 +725,33 @@ export function useErrorGroups(searchQuery: MaybeRefOrGetter<string>) {
|
||||
return executionNodeId ? isAssetErrorInSelection(executionNodeId) : false
|
||||
}
|
||||
|
||||
const filteredMissingModelGroups = computed(() => {
|
||||
if (!selectedNodeInfo.value.nodeIds) return missingModelGroups.value
|
||||
/** Model groups narrowed to the selection, for emphasis derivation only. */
|
||||
const missingModelGroupsForSelection = computed(() => {
|
||||
if (!hasSelection.value) return []
|
||||
const candidates = missingModelStore.missingModelCandidates
|
||||
if (!candidates?.length) return []
|
||||
const filtered = candidates.filter(
|
||||
const matched = candidates.filter(
|
||||
(c) => c.nodeId != null && isAssetCandidateInSelection(c.nodeId)
|
||||
)
|
||||
if (!filtered.length) return []
|
||||
return groupMissingModelCandidates(filtered, isCloud)
|
||||
if (!matched.length) return []
|
||||
return groupMissingModelCandidates(matched, isCloud)
|
||||
})
|
||||
|
||||
const filteredMissingMediaGroups = computed(() => {
|
||||
if (!selectedNodeInfo.value.nodeIds) return missingMediaGroups.value
|
||||
/** Media groups narrowed to the selection, for emphasis derivation only. */
|
||||
const missingMediaGroupsForSelection = computed(() => {
|
||||
if (!hasSelection.value) return []
|
||||
const candidates = missingMediaStore.missingMediaCandidates
|
||||
if (!candidates?.length) return []
|
||||
const filtered = candidates.filter(
|
||||
const matched = candidates.filter(
|
||||
(c) => c.nodeId != null && isAssetCandidateInSelection(c.nodeId)
|
||||
)
|
||||
if (!filtered.length) return []
|
||||
return groupCandidatesByMediaType(filtered)
|
||||
if (!matched.length) return []
|
||||
return groupCandidatesByMediaType(matched)
|
||||
})
|
||||
|
||||
function buildMissingModelGroupsFiltered(): ErrorGroup[] {
|
||||
if (!filteredMissingModelGroups.value.length) return []
|
||||
const count = countMissingModels(filteredMissingModelGroups.value)
|
||||
function buildMissingModelGroupsForSelection(): ErrorGroup[] {
|
||||
if (!missingModelGroupsForSelection.value.length) return []
|
||||
const count = countMissingModels(missingModelGroupsForSelection.value)
|
||||
return [
|
||||
{
|
||||
type: 'missing_model' as const,
|
||||
@@ -732,7 +760,7 @@ export function useErrorGroups(searchQuery: MaybeRefOrGetter<string>) {
|
||||
priority: 2,
|
||||
...resolveMissingErrorMessage({
|
||||
kind: 'missing_model',
|
||||
groups: filteredMissingModelGroups.value,
|
||||
groups: missingModelGroupsForSelection.value,
|
||||
count,
|
||||
isCloud
|
||||
})
|
||||
@@ -740,10 +768,10 @@ export function useErrorGroups(searchQuery: MaybeRefOrGetter<string>) {
|
||||
]
|
||||
}
|
||||
|
||||
function buildMissingMediaGroupsFiltered(): ErrorGroup[] {
|
||||
if (!filteredMissingMediaGroups.value.length) return []
|
||||
function buildMissingMediaGroupsForSelection(): ErrorGroup[] {
|
||||
if (!missingMediaGroupsForSelection.value.length) return []
|
||||
const totalRows = countMissingMediaReferences(
|
||||
filteredMissingMediaGroups.value
|
||||
missingMediaGroupsForSelection.value
|
||||
)
|
||||
return [
|
||||
{
|
||||
@@ -753,7 +781,7 @@ export function useErrorGroups(searchQuery: MaybeRefOrGetter<string>) {
|
||||
priority: 3,
|
||||
...resolveMissingErrorMessage({
|
||||
kind: 'missing_media',
|
||||
groups: filteredMissingMediaGroups.value,
|
||||
groups: missingMediaGroupsForSelection.value,
|
||||
count: totalRows,
|
||||
isCloud
|
||||
})
|
||||
@@ -776,47 +804,113 @@ export function useErrorGroups(searchQuery: MaybeRefOrGetter<string>) {
|
||||
]
|
||||
})
|
||||
|
||||
const tabErrorGroups = computed<ErrorGroup[]>(() => {
|
||||
const groupsMap = new Map<string, GroupEntry>()
|
||||
/**
|
||||
* The subset of error groups whose errors belong to the current canvas
|
||||
* selection. Empty when nothing is selected. Display always shows all
|
||||
* groups; this subset only drives selection emphasis (auto-expand, card
|
||||
* highlight, context strip).
|
||||
*/
|
||||
const selectionScopedGroups = computed<ErrorGroup[]>(() => {
|
||||
if (!hasSelection.value) return []
|
||||
|
||||
const groupsMap = new Map<string, GroupEntry>()
|
||||
processPromptError(groupsMap, true)
|
||||
processNodeErrors(groupsMap, true)
|
||||
processExecutionError(groupsMap, true)
|
||||
|
||||
const filterByNode = selectedNodeInfo.value.nodeIds !== null
|
||||
|
||||
// Missing nodes are intentionally unfiltered — they represent
|
||||
// pack-level problems relevant regardless of which node is selected.
|
||||
return [
|
||||
...buildMissingNodeGroups(),
|
||||
...(filterByNode
|
||||
? buildMissingModelGroupsFiltered()
|
||||
: buildMissingModelGroups()),
|
||||
...(filterByNode
|
||||
? buildMissingMediaGroupsFiltered()
|
||||
: buildMissingMediaGroups()),
|
||||
...buildMissingNodeGroups((nodeTypes) =>
|
||||
someNodeTypeInSelection(nodeTypes, selectionMatchedAssetNodeIds.value)
|
||||
),
|
||||
...buildMissingModelGroupsForSelection(),
|
||||
...buildMissingMediaGroupsForSelection(),
|
||||
...toSortedGroups(groupsMap)
|
||||
]
|
||||
})
|
||||
|
||||
/**
|
||||
* Execution node ids referenced by any missing-asset candidate (models,
|
||||
* media, missing node types).
|
||||
*/
|
||||
const assetNodeIdsWithError = computed<string[]>(() => {
|
||||
const candidateIds = [
|
||||
...(missingModelStore.missingModelCandidates ?? []),
|
||||
...(missingMediaStore.missingMediaCandidates ?? [])
|
||||
].map((candidate) => candidate.nodeId)
|
||||
const missingNodeTypeIds = (
|
||||
missingNodesStore.missingNodesError?.nodeTypes ?? []
|
||||
).map((nodeType) =>
|
||||
typeof nodeType === 'string' ? undefined : nodeType.nodeId
|
||||
)
|
||||
return [...candidateIds, ...missingNodeTypeIds]
|
||||
.filter((nodeId) => nodeId != null)
|
||||
.map(String)
|
||||
})
|
||||
|
||||
/**
|
||||
* Asset node ids that belong to the current selection. Drives row-level
|
||||
* highlighting inside the missing-* cards.
|
||||
*/
|
||||
const selectionMatchedAssetNodeIds = computed<Set<string>>(() => {
|
||||
if (!hasSelection.value) return new Set()
|
||||
return new Set(
|
||||
assetNodeIdsWithError.value.filter(isAssetCandidateInSelection)
|
||||
)
|
||||
})
|
||||
|
||||
const selectionMatchedGroupKeys = computed<Set<string>>(() => {
|
||||
if (!hasSelection.value) return new Set()
|
||||
return new Set(selectionScopedGroups.value.map((group) => group.groupKey))
|
||||
})
|
||||
|
||||
const selectionMatchedCardIds = computed<Set<string>>(() => {
|
||||
if (!hasSelection.value) return new Set()
|
||||
return new Set(
|
||||
selectionScopedGroups.value
|
||||
.flatMap((group) => (group.type === 'execution' ? group.cards : []))
|
||||
.map((card) => card.id)
|
||||
)
|
||||
})
|
||||
|
||||
const selectionErrorCount = computed(() => {
|
||||
if (!hasSelection.value) return 0
|
||||
return selectionScopedGroups.value.reduce(
|
||||
(sum, group) => sum + group.count,
|
||||
0
|
||||
)
|
||||
})
|
||||
|
||||
/** Distinct nodes affected by any error (workflow-level summary). */
|
||||
const errorNodeCount = computed(() => {
|
||||
const executionNodeIds = allErrorGroups.value
|
||||
.flatMap((group) => (group.type === 'execution' ? group.cards : []))
|
||||
.map((card) => card.nodeId)
|
||||
.filter((nodeId) => nodeId != null)
|
||||
return new Set([...executionNodeIds, ...assetNodeIdsWithError.value]).size
|
||||
})
|
||||
|
||||
const filteredGroups = computed<ErrorGroup[]>(() => {
|
||||
const query = toValue(searchQuery).trim()
|
||||
return searchErrorGroups(tabErrorGroups.value, query)
|
||||
return searchErrorGroups(allErrorGroups.value, query)
|
||||
})
|
||||
|
||||
return {
|
||||
allErrorGroups,
|
||||
tabErrorGroups,
|
||||
filteredGroups,
|
||||
collapseState,
|
||||
isSingleNodeSelected,
|
||||
errorNodeCache,
|
||||
missingNodeCache,
|
||||
missingPackGroups,
|
||||
missingModelGroups,
|
||||
missingMediaGroups,
|
||||
filteredMissingModelGroups,
|
||||
filteredMissingMediaGroups,
|
||||
swapNodeGroups
|
||||
swapNodeGroups,
|
||||
hasSelection,
|
||||
selectedNodeCount,
|
||||
selectedNodeTitle,
|
||||
selectionMatchedGroupKeys,
|
||||
selectionMatchedCardIds,
|
||||
selectionMatchedAssetNodeIds,
|
||||
selectionErrorCount,
|
||||
errorNodeCount
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,6 +49,7 @@
|
||||
/>
|
||||
<ResultVideo v-else-if="activeItem.isVideo" :result="activeItem" />
|
||||
<ResultAudio v-else-if="activeItem.isAudio" :result="activeItem" />
|
||||
<ResultText v-else-if="activeItem.isText" :result="activeItem" />
|
||||
</template>
|
||||
</div>
|
||||
|
||||
@@ -75,6 +76,7 @@ import Button from '@/components/ui/button/Button.vue'
|
||||
import type { ResultItemImpl } from '@/stores/queueStore'
|
||||
|
||||
import ResultAudio from './ResultAudio.vue'
|
||||
import ResultText from './ResultText.vue'
|
||||
import ResultVideo from './ResultVideo.vue'
|
||||
|
||||
const emit = defineEmits<{
|
||||
|
||||
21
src/components/sidebar/tabs/queue/ResultText.vue
Normal file
21
src/components/sidebar/tabs/queue/ResultText.vue
Normal file
@@ -0,0 +1,21 @@
|
||||
<template>
|
||||
<article
|
||||
class="m-auto max-h-[80vh] w-[min(90vw,42rem)] scroll-shadows-secondary-background overflow-y-auto rounded-lg bg-secondary-background p-4 whitespace-pre-wrap"
|
||||
>
|
||||
<span v-if="hasError" class="text-muted-foreground">
|
||||
{{ $t('g.textFailedToLoad') }}
|
||||
</span>
|
||||
<template v-else>{{ textContent }}</template>
|
||||
</article>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useTextFileContent } from '@/composables/useTextFileContent'
|
||||
import type { ResultItemImpl } from '@/stores/queueStore'
|
||||
|
||||
const { result } = defineProps<{
|
||||
result: ResultItemImpl
|
||||
}>()
|
||||
|
||||
const { textContent, hasError } = useTextFileContent(() => result)
|
||||
</script>
|
||||
@@ -8,7 +8,10 @@ import {
|
||||
PopoverTrigger
|
||||
} from 'reka-ui'
|
||||
|
||||
import { ref } from 'vue'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useModalLiftedZIndex } from '@/composables/useModalLiftedZIndex'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
defineOptions({
|
||||
@@ -26,10 +29,13 @@ const {
|
||||
to?: string | HTMLElement
|
||||
showArrow?: boolean
|
||||
}>()
|
||||
|
||||
const open = ref(false)
|
||||
const contentStyle = useModalLiftedZIndex(open)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<PopoverRoot v-slot="{ close }">
|
||||
<PopoverRoot v-slot="{ close }" v-model:open="open">
|
||||
<PopoverTrigger as-child>
|
||||
<slot name="button">
|
||||
<Button size="icon">
|
||||
@@ -43,6 +49,7 @@ const {
|
||||
:side-offset="5"
|
||||
:collision-padding="10"
|
||||
v-bind="$attrs"
|
||||
:style="contentStyle"
|
||||
class="data-[state=open]:data-[side=top]:animate-slideDownAndFade data-[state=open]:data-[side=right]:animate-slideLeftAndFade data-[state=open]:data-[side=bottom]:animate-slideUpAndFade data-[state=open]:data-[side=left]:animate-slideRightAndFade z-1700 rounded-lg border border-border-subtle bg-base-background p-2 shadow-sm will-change-[transform,opacity]"
|
||||
>
|
||||
<slot :close>
|
||||
|
||||
@@ -7,26 +7,34 @@ import {
|
||||
} from 'reka-ui'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
|
||||
import { useModalLiftedZIndex } from '@/composables/useModalLiftedZIndex'
|
||||
import type { HSVA } from '@/utils/colorUtil'
|
||||
import { hexToHsva, hsbToRgb, hsvaToHex, rgbToHex } from '@/utils/colorUtil'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
import ColorPickerPanel from './ColorPickerPanel.vue'
|
||||
|
||||
defineProps<{
|
||||
const { alpha = true } = defineProps<{
|
||||
class?: string
|
||||
disabled?: boolean
|
||||
alpha?: boolean
|
||||
}>()
|
||||
|
||||
const modelValue = defineModel<string>({ default: '#000000' })
|
||||
|
||||
const hsva = ref<HSVA>(hexToHsva(modelValue.value || '#000000'))
|
||||
function readHsva(hex: string): HSVA {
|
||||
const next = hexToHsva(hex || '#000000')
|
||||
if (!alpha) next.a = 100
|
||||
return next
|
||||
}
|
||||
|
||||
const hsva = ref<HSVA>(readHsva(modelValue.value))
|
||||
const displayMode = ref<'hex' | 'rgba'>('hex')
|
||||
|
||||
watch(modelValue, (newVal) => {
|
||||
const current = hsvaToHex(hsva.value)
|
||||
if (newVal !== current) {
|
||||
hsva.value = hexToHsva(newVal || '#000000')
|
||||
hsva.value = readHsva(newVal)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -60,54 +68,57 @@ const previewColor = computed(() => {
|
||||
const displayHex = computed(() => rgbToHex(baseRgb.value).toLowerCase())
|
||||
|
||||
const isOpen = ref(false)
|
||||
const contentStyle = useModalLiftedZIndex(isOpen)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<PopoverRoot v-model:open="isOpen">
|
||||
<PopoverTrigger as-child>
|
||||
<button
|
||||
type="button"
|
||||
:disabled="$props.disabled"
|
||||
:class="
|
||||
cn(
|
||||
'flex h-8 w-full items-center overflow-clip rounded-lg border border-transparent bg-component-node-widget-background pr-2 outline-none hover:bg-component-node-widget-background-hovered disabled:cursor-not-allowed disabled:opacity-50',
|
||||
isOpen && 'border-node-stroke',
|
||||
$props.class
|
||||
)
|
||||
"
|
||||
>
|
||||
<div class="flex size-8 shrink-0 items-center justify-center">
|
||||
<div class="relative size-4 overflow-hidden rounded-sm">
|
||||
<div
|
||||
class="absolute inset-0"
|
||||
:style="{
|
||||
backgroundImage:
|
||||
'repeating-conic-gradient(#808080 0% 25%, transparent 0% 50%)',
|
||||
backgroundSize: '4px 4px'
|
||||
}"
|
||||
/>
|
||||
<div
|
||||
class="absolute inset-0"
|
||||
:style="{ backgroundColor: previewColor }"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="flex flex-1 items-center justify-between pl-1 text-xs text-component-node-foreground"
|
||||
<slot name="trigger">
|
||||
<button
|
||||
type="button"
|
||||
:disabled="$props.disabled"
|
||||
:class="
|
||||
cn(
|
||||
'flex h-8 w-full items-center overflow-clip rounded-lg border border-transparent bg-component-node-widget-background pr-2 outline-none hover:bg-component-node-widget-background-hovered disabled:cursor-not-allowed disabled:opacity-50',
|
||||
isOpen && 'border-node-stroke',
|
||||
$props.class
|
||||
)
|
||||
"
|
||||
>
|
||||
<template v-if="displayMode === 'hex'">
|
||||
<span>{{ displayHex }}</span>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="flex gap-2">
|
||||
<span>{{ baseRgb.r }}</span>
|
||||
<span>{{ baseRgb.g }}</span>
|
||||
<span>{{ baseRgb.b }}</span>
|
||||
<div class="flex size-8 shrink-0 items-center justify-center">
|
||||
<div class="relative size-4 overflow-hidden rounded-sm">
|
||||
<div
|
||||
class="absolute inset-0"
|
||||
:style="{
|
||||
backgroundImage:
|
||||
'repeating-conic-gradient(#808080 0% 25%, transparent 0% 50%)',
|
||||
backgroundSize: '4px 4px'
|
||||
}"
|
||||
/>
|
||||
<div
|
||||
class="absolute inset-0"
|
||||
:style="{ backgroundColor: previewColor }"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<span>{{ hsva.a }}%</span>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
class="flex flex-1 items-center justify-between pl-1 text-xs text-component-node-foreground"
|
||||
>
|
||||
<template v-if="displayMode === 'hex'">
|
||||
<span>{{ displayHex }}</span>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="flex gap-2">
|
||||
<span>{{ baseRgb.r }}</span>
|
||||
<span>{{ baseRgb.g }}</span>
|
||||
<span>{{ baseRgb.b }}</span>
|
||||
</div>
|
||||
</template>
|
||||
<span>{{ hsva.a }}%</span>
|
||||
</div>
|
||||
</button>
|
||||
</slot>
|
||||
</PopoverTrigger>
|
||||
<PopoverPortal>
|
||||
<PopoverContent
|
||||
@@ -116,10 +127,12 @@ const isOpen = ref(false)
|
||||
:side-offset="7"
|
||||
:collision-padding="10"
|
||||
class="z-1700"
|
||||
:style="contentStyle"
|
||||
>
|
||||
<ColorPickerPanel
|
||||
v-model:hsva="hsva"
|
||||
v-model:display-mode="displayMode"
|
||||
:alpha
|
||||
/>
|
||||
</PopoverContent>
|
||||
</PopoverPortal>
|
||||
|
||||
@@ -13,6 +13,8 @@ import { hsbToRgb, rgbToHex } from '@/utils/colorUtil'
|
||||
import ColorPickerSaturationValue from './ColorPickerSaturationValue.vue'
|
||||
import ColorPickerSlider from './ColorPickerSlider.vue'
|
||||
|
||||
const { alpha = true } = defineProps<{ alpha?: boolean }>()
|
||||
|
||||
const hsva = defineModel<HSVA>('hsva', { required: true })
|
||||
const displayMode = defineModel<'hex' | 'rgba'>('displayMode', {
|
||||
required: true
|
||||
@@ -37,6 +39,7 @@ const { t } = useI18n()
|
||||
/>
|
||||
<ColorPickerSlider v-model="hsva.h" type="hue" />
|
||||
<ColorPickerSlider
|
||||
v-if="alpha"
|
||||
v-model="hsva.a"
|
||||
type="alpha"
|
||||
:hue="hsva.h"
|
||||
@@ -72,7 +75,7 @@ const { t } = useI18n()
|
||||
<span class="w-6 shrink-0 text-center">{{ rgb.g }}</span>
|
||||
<span class="w-6 shrink-0 text-center">{{ rgb.b }}</span>
|
||||
</template>
|
||||
<span class="shrink-0 border-l border-border-subtle pl-1"
|
||||
<span v-if="alpha" class="shrink-0 border-l border-border-subtle pl-1"
|
||||
>{{ hsva.a }}%</span
|
||||
>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import type { DialogOverlayProps } from 'reka-ui'
|
||||
import { DialogOverlay } from 'reka-ui'
|
||||
import { DialogOverlay, Presence, injectDialogRootContext } from 'reka-ui'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
@@ -8,16 +8,27 @@ import { cn } from '@comfyorg/tailwind-utils'
|
||||
const { class: customClass = '', ...delegated } = defineProps<
|
||||
DialogOverlayProps & { class?: HTMLAttributes['class'] }
|
||||
>()
|
||||
|
||||
// Reka renders DialogOverlay only for modal dialogs; non-modal dialogs still
|
||||
// need the scrim, so render a plain backdrop for them.
|
||||
const rootContext = injectDialogRootContext()
|
||||
|
||||
const overlayClass =
|
||||
'fixed inset-0 z-1700 bg-black/70 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DialogOverlay
|
||||
v-if="rootContext.modal.value"
|
||||
v-bind="delegated"
|
||||
:class="
|
||||
cn(
|
||||
'fixed inset-0 z-1700 bg-black/70 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0',
|
||||
customClass
|
||||
)
|
||||
"
|
||||
data-testid="dialog-overlay"
|
||||
:class="cn(overlayClass, customClass)"
|
||||
/>
|
||||
<Presence v-else :present="delegated.forceMount || rootContext.open.value">
|
||||
<div
|
||||
:data-state="rootContext.open.value ? 'open' : 'closed'"
|
||||
data-testid="dialog-overlay"
|
||||
:class="cn(overlayClass, customClass)"
|
||||
/>
|
||||
</Presence>
|
||||
</template>
|
||||
|
||||
@@ -156,7 +156,7 @@ describe('fromBoundingBoxes', () => {
|
||||
y: 200,
|
||||
width: 300,
|
||||
height: 400,
|
||||
metadata: { type: 'text', text: 'hi', desc: 'd', palette: ['#fff'] }
|
||||
metadata: { type: 'text', text: 'hi', desc: 'd', palette: ['#ffffff'] }
|
||||
}
|
||||
]
|
||||
expect(fromBoundingBoxes(boxes, 1000, 1000)[0]).toEqual({
|
||||
@@ -167,10 +167,31 @@ describe('fromBoundingBoxes', () => {
|
||||
type: 'text',
|
||||
text: 'hi',
|
||||
desc: 'd',
|
||||
palette: ['#fff']
|
||||
palette: ['#ffffff']
|
||||
})
|
||||
})
|
||||
|
||||
it('normalizes palette entries and drops invalid colors', () => {
|
||||
const boxes: BoundingBox[] = [
|
||||
{
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 10,
|
||||
height: 10,
|
||||
metadata: {
|
||||
type: 'obj',
|
||||
text: '',
|
||||
desc: '',
|
||||
palette: ['#FF0000', '#abc', 'red', '', 123] as unknown as string[]
|
||||
}
|
||||
}
|
||||
]
|
||||
expect(fromBoundingBoxes(boxes, 100, 100)[0].palette).toEqual([
|
||||
'#ff0000',
|
||||
'#aabbcc'
|
||||
])
|
||||
})
|
||||
|
||||
it('fills defaults when metadata is missing or partial', () => {
|
||||
const boxes = [{ x: 0, y: 0, width: 10, height: 10 }] as BoundingBox[]
|
||||
expect(fromBoundingBoxes(boxes, 100, 100)[0]).toMatchObject({
|
||||
|
||||
@@ -202,6 +202,22 @@ function isBoundingBox(b: unknown): b is BoundingBox {
|
||||
)
|
||||
}
|
||||
|
||||
function normalizeHexColor(color: unknown): string | null {
|
||||
if (typeof color !== 'string') return null
|
||||
const hex = color.trim().toLowerCase()
|
||||
const short = /^#([0-9a-f])([0-9a-f])([0-9a-f])$/.exec(hex)
|
||||
if (short) {
|
||||
return `#${short[1]}${short[1]}${short[2]}${short[2]}${short[3]}${short[3]}`
|
||||
}
|
||||
return /^#([0-9a-f]{6}|[0-9a-f]{8})$/.test(hex) ? hex : null
|
||||
}
|
||||
|
||||
function normalizePalette(palette: unknown): string[] {
|
||||
return Array.isArray(palette)
|
||||
? palette.map(normalizeHexColor).filter((c): c is string => c !== null)
|
||||
: []
|
||||
}
|
||||
|
||||
export function fromBoundingBoxes(
|
||||
boxes: readonly BoundingBox[],
|
||||
width: number,
|
||||
@@ -219,9 +235,7 @@ export function fromBoundingBoxes(
|
||||
type: meta.type === 'text' ? 'text' : 'obj',
|
||||
text: typeof meta.text === 'string' ? meta.text : '',
|
||||
desc: typeof meta.desc === 'string' ? meta.desc : '',
|
||||
palette: Array.isArray(meta.palette)
|
||||
? meta.palette.filter((c): c is string => typeof c === 'string')
|
||||
: []
|
||||
palette: normalizePalette(meta.palette)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -8,14 +8,32 @@ import { useBoundingBoxes } from './useBoundingBoxes'
|
||||
import type { BoundingBox } from '@/types/boundingBoxes'
|
||||
import { toNodeId } from '@/types/nodeId'
|
||||
|
||||
const { appState } = vi.hoisted(() => ({
|
||||
appState: { node: null as unknown }
|
||||
const { appState, outputState } = vi.hoisted(() => ({
|
||||
appState: { node: null as unknown },
|
||||
outputState: {
|
||||
outputs: undefined as unknown,
|
||||
nodeOutputs: null as { value: Record<string, unknown> } | null
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/scripts/app', () => ({
|
||||
app: { canvas: { graph: { getNodeById: () => appState.node } } }
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/nodeOutputStore', async () => {
|
||||
const { ref } = await import('vue')
|
||||
const nodeOutputs = ref<Record<string, unknown>>({})
|
||||
outputState.nodeOutputs = nodeOutputs
|
||||
return {
|
||||
useNodeOutputStore: () => ({
|
||||
nodeOutputs,
|
||||
nodePreviewImages: ref({}),
|
||||
getNodeImageUrls: () => undefined,
|
||||
getNodeOutputs: () => outputState.outputs
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
const ctx = {
|
||||
measureText: (s: string) => ({ width: s.length * 7 }),
|
||||
setTransform: () => {},
|
||||
@@ -27,6 +45,9 @@ const ctx = {
|
||||
save: () => {},
|
||||
restore: () => {},
|
||||
beginPath: () => {},
|
||||
moveTo: () => {},
|
||||
arc: () => {},
|
||||
fill: () => {},
|
||||
rect: () => {},
|
||||
clip: () => {},
|
||||
font: '',
|
||||
@@ -58,17 +79,32 @@ function makeCanvas(): HTMLCanvasElement {
|
||||
return el
|
||||
}
|
||||
|
||||
function makeNode() {
|
||||
interface MockNode {
|
||||
widgets: { name: string; value: unknown }[]
|
||||
findInputSlot: (name: string) => number
|
||||
getInputNode: () => null
|
||||
isInputConnected?: () => boolean
|
||||
}
|
||||
|
||||
function makeNode(): MockNode {
|
||||
return {
|
||||
widgets: [
|
||||
{ name: 'width', value: 512 },
|
||||
{ name: 'height', value: 512 }
|
||||
{ name: 'height', value: 512 },
|
||||
{ name: 'last_incoming', value: [] }
|
||||
],
|
||||
findInputSlot: () => -1,
|
||||
getInputNode: () => null
|
||||
}
|
||||
}
|
||||
|
||||
const lastIncomingOf = (node: MockNode) =>
|
||||
node.widgets.find((w) => w.name === 'last_incoming')!.value
|
||||
|
||||
const setLastIncomingOf = (node: MockNode, value: BoundingBox[]) => {
|
||||
node.widgets.find((w) => w.name === 'last_incoming')!.value = value
|
||||
}
|
||||
|
||||
const pe = (
|
||||
clientX: number,
|
||||
clientY: number,
|
||||
@@ -96,6 +132,8 @@ interface Captured extends Api {
|
||||
modelValue: Ref<BoundingBox[]>
|
||||
}
|
||||
|
||||
const modelBoxes = (c: Captured) => c.modelValue.value
|
||||
|
||||
function setup(initial: BoundingBox[] = []) {
|
||||
let captured: Captured | undefined
|
||||
const Harness = defineComponent({
|
||||
@@ -128,9 +166,19 @@ const box = (over: Partial<BoundingBox> = {}): BoundingBox => ({
|
||||
...over
|
||||
})
|
||||
|
||||
function makeConnectedNode(): MockNode {
|
||||
return {
|
||||
...makeNode(),
|
||||
findInputSlot: (name: string) => (name === 'bboxes' ? 1 : -1),
|
||||
isInputConnected: () => true
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
appState.node = makeNode()
|
||||
outputState.outputs = undefined
|
||||
if (outputState.nodeOutputs) outputState.nodeOutputs.value = {}
|
||||
vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) => {
|
||||
void Promise.resolve().then(() => cb(0))
|
||||
return 1
|
||||
@@ -168,8 +216,8 @@ describe('useBoundingBoxes drawing', () => {
|
||||
c.onCanvasPointerMove(pe(60, 60))
|
||||
c.onDocPointerUp(pe(60, 60))
|
||||
await flush()
|
||||
expect(c.modelValue.value).toHaveLength(1)
|
||||
expect(c.modelValue.value[0].width).toBeGreaterThan(0)
|
||||
expect(modelBoxes(c)).toHaveLength(1)
|
||||
expect(modelBoxes(c)[0].width).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('discards a zero-size draw', async () => {
|
||||
@@ -177,7 +225,7 @@ describe('useBoundingBoxes drawing', () => {
|
||||
c.onPointerDown(pe(10, 10))
|
||||
c.onDocPointerUp(pe(10, 10))
|
||||
await flush()
|
||||
expect(c.modelValue.value).toHaveLength(0)
|
||||
expect(modelBoxes(c)).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('selects an existing region instead of drawing when clicking inside it', async () => {
|
||||
@@ -185,7 +233,7 @@ describe('useBoundingBoxes drawing', () => {
|
||||
c.onPointerDown(pe(30, 30))
|
||||
c.onDocPointerUp(pe(30, 30))
|
||||
await flush()
|
||||
expect(c.modelValue.value).toHaveLength(1)
|
||||
expect(modelBoxes(c)).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -194,7 +242,7 @@ describe('useBoundingBoxes region editing', () => {
|
||||
const c = setup([box()])
|
||||
c.setActiveType('text')
|
||||
await flush()
|
||||
expect(c.modelValue.value[0].metadata.type).toBe('text')
|
||||
expect(modelBoxes(c)[0].metadata.type).toBe('text')
|
||||
})
|
||||
|
||||
it('deletes the active region on Delete', async () => {
|
||||
@@ -205,14 +253,18 @@ describe('useBoundingBoxes region editing', () => {
|
||||
stopPropagation: () => {}
|
||||
} as unknown as KeyboardEvent)
|
||||
await flush()
|
||||
expect(c.modelValue.value).toHaveLength(0)
|
||||
expect(modelBoxes(c)).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('clears all regions', async () => {
|
||||
it('clears all regions and invalidates the applied upstream input', async () => {
|
||||
const node = makeNode()
|
||||
setLastIncomingOf(node, [box()])
|
||||
appState.node = node
|
||||
const c = setup([box(), box({ x: 0 })])
|
||||
c.clearAll()
|
||||
await flush()
|
||||
expect(c.modelValue.value).toHaveLength(0)
|
||||
expect(modelBoxes(c)).toHaveLength(0)
|
||||
expect(lastIncomingOf(node)).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -226,7 +278,7 @@ describe('useBoundingBoxes inline editor', () => {
|
||||
c.inlineEditor.value!.value = 'a label'
|
||||
c.commitInlineEditor()
|
||||
await flush()
|
||||
expect(c.modelValue.value[0].metadata.desc).toBe('a label')
|
||||
expect(modelBoxes(c)[0].metadata.desc).toBe('a label')
|
||||
expect(c.inlineEditor.value).toBeNull()
|
||||
})
|
||||
|
||||
@@ -239,6 +291,168 @@ describe('useBoundingBoxes inline editor', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('useBoundingBoxes incoming bboxes input', () => {
|
||||
it('adopts cached outputs on mount without overwriting existing edits', () => {
|
||||
const node = makeConnectedNode()
|
||||
appState.node = node
|
||||
const incoming = [box({ x: 0, width: 100 })]
|
||||
outputState.outputs = { input_bboxes: incoming }
|
||||
const c = setup([box({ x: 200, width: 300 })])
|
||||
expect(modelBoxes(c)).toHaveLength(1)
|
||||
expect(modelBoxes(c)[0].width).toBe(300)
|
||||
expect(lastIncomingOf(node)).toEqual(incoming)
|
||||
})
|
||||
|
||||
it('does not re-apply an already applied output after a remount', async () => {
|
||||
const node = makeConnectedNode()
|
||||
const incoming = [box({ x: 0, width: 100 })]
|
||||
setLastIncomingOf(node, incoming)
|
||||
appState.node = node
|
||||
outputState.outputs = { input_bboxes: incoming }
|
||||
const c = setup([box({ x: 200, width: 300 })])
|
||||
outputState.nodeOutputs!.value = { updated: true }
|
||||
await flush()
|
||||
expect(modelBoxes(c)[0].width).toBe(300)
|
||||
})
|
||||
|
||||
it('ignores incoming output when the input is not connected', () => {
|
||||
outputState.outputs = { input_bboxes: [box({ x: 0, width: 100 })] }
|
||||
const c = setup([])
|
||||
expect(modelBoxes(c)).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('repopulates from the next run after clearing the canvas', async () => {
|
||||
appState.node = makeConnectedNode()
|
||||
const c = setup([])
|
||||
|
||||
outputState.outputs = { input_bboxes: [box({ x: 0, width: 100 })] }
|
||||
outputState.nodeOutputs!.value = { n: 1 }
|
||||
await flush()
|
||||
expect(modelBoxes(c)).toHaveLength(1)
|
||||
|
||||
c.clearAll()
|
||||
await flush()
|
||||
expect(modelBoxes(c)).toHaveLength(0)
|
||||
|
||||
outputState.nodeOutputs!.value = { n: 2 }
|
||||
await flush()
|
||||
expect(modelBoxes(c)).toHaveLength(1)
|
||||
expect(modelBoxes(c)[0].width).toBe(100)
|
||||
})
|
||||
|
||||
it('does not apply output updates while the input is disconnected', async () => {
|
||||
let connected = true
|
||||
appState.node = {
|
||||
...makeConnectedNode(),
|
||||
isInputConnected: () => connected
|
||||
}
|
||||
const c = setup([])
|
||||
|
||||
outputState.outputs = { input_bboxes: [box({ x: 0, width: 100 })] }
|
||||
outputState.nodeOutputs!.value = { n: 1 }
|
||||
await flush()
|
||||
expect(modelBoxes(c)).toHaveLength(1)
|
||||
|
||||
c.clearAll()
|
||||
await flush()
|
||||
connected = false
|
||||
outputState.nodeOutputs!.value = { n: 2 }
|
||||
await flush()
|
||||
expect(modelBoxes(c)).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('does not apply incoming boxes while the user is drawing', async () => {
|
||||
appState.node = makeConnectedNode()
|
||||
const c = setup([])
|
||||
c.grid.value = false
|
||||
c.onPointerDown(pe(10, 10))
|
||||
c.onCanvasPointerMove(pe(50, 50))
|
||||
|
||||
outputState.outputs = {
|
||||
input_bboxes: [box({ x: 0, width: 100, height: 100 })]
|
||||
}
|
||||
outputState.nodeOutputs!.value = { n: 1 }
|
||||
await flush()
|
||||
|
||||
c.onDocPointerUp(pe(50, 50))
|
||||
await flush()
|
||||
expect(modelBoxes(c)).toHaveLength(1)
|
||||
expect(modelBoxes(c)[0].width).toBe(205)
|
||||
})
|
||||
|
||||
it('applies incoming boxes when outputs stream in after mount', async () => {
|
||||
const node = makeConnectedNode()
|
||||
appState.node = node
|
||||
const c = setup([])
|
||||
expect(modelBoxes(c)).toHaveLength(0)
|
||||
|
||||
const incoming = [box({ x: 0, width: 100 })]
|
||||
outputState.outputs = { input_bboxes: incoming }
|
||||
outputState.nodeOutputs!.value = { updated: true }
|
||||
await flush()
|
||||
|
||||
expect(modelBoxes(c)).toHaveLength(1)
|
||||
expect(modelBoxes(c)[0].width).toBe(100)
|
||||
expect(lastIncomingOf(node)).toEqual(incoming)
|
||||
})
|
||||
|
||||
it('re-seeds the canvas over user edits when the upstream value changes', async () => {
|
||||
const node = makeConnectedNode()
|
||||
setLastIncomingOf(node, [box({ x: 0, width: 100 })])
|
||||
appState.node = node
|
||||
const c = setup([box({ x: 200, width: 300 })])
|
||||
|
||||
const changed = [box({ x: 64, width: 128 })]
|
||||
outputState.outputs = { input_bboxes: changed }
|
||||
outputState.nodeOutputs!.value = { n: 1 }
|
||||
await flush()
|
||||
|
||||
expect(modelBoxes(c)[0].width).toBe(128)
|
||||
expect(lastIncomingOf(node)).toEqual(changed)
|
||||
})
|
||||
})
|
||||
|
||||
describe('useBoundingBoxes grid snapping', () => {
|
||||
it('snaps a drawn box to the grid when grid is enabled (default)', async () => {
|
||||
const c = setup()
|
||||
c.onPointerDown(pe(10, 10))
|
||||
c.onCanvasPointerMove(pe(60, 60))
|
||||
c.onDocPointerUp(pe(60, 60))
|
||||
await flush()
|
||||
expect(modelBoxes(c)).toHaveLength(1)
|
||||
expect(modelBoxes(c)[0].x).toBe(64)
|
||||
expect(modelBoxes(c)[0].width).toBe(256)
|
||||
})
|
||||
|
||||
it('does not snap when grid is disabled', async () => {
|
||||
const c = setup()
|
||||
c.grid.value = false
|
||||
c.onPointerDown(pe(10, 10))
|
||||
c.onCanvasPointerMove(pe(55, 55))
|
||||
c.onDocPointerUp(pe(55, 55))
|
||||
await flush()
|
||||
expect(modelBoxes(c)[0].width).toBe(230)
|
||||
})
|
||||
|
||||
it('keeps the anchored edge fixed when resizing a single edge', async () => {
|
||||
const c = setup([box({ x: 51, y: 51, width: 256, height: 256 })])
|
||||
c.onPointerDown(pe(60, 30))
|
||||
c.onCanvasPointerMove(pe(80, 30))
|
||||
c.onDocPointerUp(pe(80, 30))
|
||||
await flush()
|
||||
expect(modelBoxes(c)[0].x).toBe(51)
|
||||
})
|
||||
|
||||
it('removes a box that a resize collapses to zero size', async () => {
|
||||
const c = setup([box({ x: 64, y: 64, width: 128, height: 128 })])
|
||||
c.onPointerDown(pe(37, 25))
|
||||
c.onCanvasPointerMove(pe(14, 25))
|
||||
c.onDocPointerUp(pe(14, 25))
|
||||
await flush()
|
||||
expect(modelBoxes(c)).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('useBoundingBoxes hover cursor', () => {
|
||||
it('switches to a pointer cursor over a tag', async () => {
|
||||
const c = setup([box({ x: 10, y: 10, width: 256, height: 256 })])
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useElementSize } from '@vueuse/core'
|
||||
import { cloneDeep, isEqual } from 'es-toolkit'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import type { Ref, ShallowRef } from 'vue'
|
||||
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue'
|
||||
@@ -15,6 +16,7 @@ import type {
|
||||
Region
|
||||
} from '@/composables/boundingBoxes/boundingBoxesUtil'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import type { NodeOutputWith } from '@/schemas/apiSchema'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useNodeOutputStore } from '@/stores/nodeOutputStore'
|
||||
import type { BoundingBox } from '@/types/boundingBoxes'
|
||||
@@ -25,6 +27,10 @@ const HANDLE_PX = 8
|
||||
const DIMENSION_STEP = 16
|
||||
const BG_DIM = 0.75
|
||||
const MAX_ELEMENT_COLORS = 5
|
||||
const GRID_PX = 32
|
||||
const MAX_GRID_CELLS = 64
|
||||
const DOT_ALPHA = 0.18
|
||||
const DOT_RADIUS = 1
|
||||
|
||||
interface InlineEditorState {
|
||||
value: string
|
||||
@@ -57,6 +63,7 @@ export function useBoundingBoxes(
|
||||
const hoverTagIndex = ref<number | null>(null)
|
||||
const bgImage = ref<HTMLImageElement | null>(null)
|
||||
const inlineEditor = ref<InlineEditorState | null>(null)
|
||||
const grid = ref(true)
|
||||
|
||||
const { width: containerWidth } = useElementSize(canvasContainer)
|
||||
|
||||
@@ -96,6 +103,89 @@ export function useBoundingBoxes(
|
||||
return Math.max(0, Math.min(1, n))
|
||||
}
|
||||
|
||||
function gridSpec() {
|
||||
const axisFraction = (size: number) =>
|
||||
Math.max(GRID_PX, Math.ceil(size / MAX_GRID_CELLS)) / size
|
||||
return {
|
||||
fx: axisFraction(widthValue.value),
|
||||
fy: axisFraction(heightValue.value)
|
||||
}
|
||||
}
|
||||
|
||||
function snapFraction(value: number, step: number) {
|
||||
return step > 0 ? clampToCanvas(Math.round(value / step) * step) : value
|
||||
}
|
||||
|
||||
function snapRegion(region: Region, mode: HitMode): Region {
|
||||
if (!grid.value) return region
|
||||
const { fx, fy } = gridSpec()
|
||||
if (mode === 'move') {
|
||||
return {
|
||||
...region,
|
||||
x: Math.min(snapFraction(region.x, fx), 1 - region.w),
|
||||
y: Math.min(snapFraction(region.y, fy), 1 - region.h)
|
||||
}
|
||||
}
|
||||
const snapLeft =
|
||||
mode === 'draw' ||
|
||||
mode === 'resize-l' ||
|
||||
mode === 'resize-tl' ||
|
||||
mode === 'resize-bl'
|
||||
const snapRight =
|
||||
mode === 'draw' ||
|
||||
mode === 'resize-r' ||
|
||||
mode === 'resize-tr' ||
|
||||
mode === 'resize-br'
|
||||
const snapTop =
|
||||
mode === 'draw' ||
|
||||
mode === 'resize-t' ||
|
||||
mode === 'resize-tl' ||
|
||||
mode === 'resize-tr'
|
||||
const snapBottom =
|
||||
mode === 'draw' ||
|
||||
mode === 'resize-b' ||
|
||||
mode === 'resize-bl' ||
|
||||
mode === 'resize-br'
|
||||
const x1 = snapLeft ? snapFraction(region.x, fx) : region.x
|
||||
const y1 = snapTop ? snapFraction(region.y, fy) : region.y
|
||||
const x2 = snapRight
|
||||
? snapFraction(region.x + region.w, fx)
|
||||
: region.x + region.w
|
||||
const y2 = snapBottom
|
||||
? snapFraction(region.y + region.h, fy)
|
||||
: region.y + region.h
|
||||
return {
|
||||
...region,
|
||||
x: x1,
|
||||
y: y1,
|
||||
w: Math.max(0, x2 - x1),
|
||||
h: Math.max(0, y2 - y1)
|
||||
}
|
||||
}
|
||||
|
||||
function drawDots(ctx: CanvasRenderingContext2D, W: number, H: number) {
|
||||
const el = canvasEl.value
|
||||
if (!el) return
|
||||
const { fx, fy } = gridSpec()
|
||||
if (fx <= 0 || fy <= 0) return
|
||||
const cols = Math.round(1 / fx)
|
||||
const rows = Math.round(1 / fy)
|
||||
ctx.save()
|
||||
ctx.globalAlpha = DOT_ALPHA
|
||||
ctx.fillStyle = getComputedStyle(el).color
|
||||
ctx.beginPath()
|
||||
for (let i = 0; i <= cols; i++) {
|
||||
const cx = Math.min(1, i * fx) * W
|
||||
for (let j = 0; j <= rows; j++) {
|
||||
const cy = Math.min(1, j * fy) * H
|
||||
ctx.moveTo(cx + DOT_RADIUS, cy)
|
||||
ctx.arc(cx, cy, DOT_RADIUS, 0, Math.PI * 2)
|
||||
}
|
||||
}
|
||||
ctx.fill()
|
||||
ctx.restore()
|
||||
}
|
||||
|
||||
function logicalSize() {
|
||||
const el = canvasEl.value
|
||||
return { w: el?.clientWidth || 1, h: el?.clientHeight || 1 }
|
||||
@@ -146,6 +236,8 @@ export function useBoundingBoxes(
|
||||
ctx.fillRect(0, 0, W, H)
|
||||
}
|
||||
|
||||
if (grid.value) drawDots(ctx, W, H)
|
||||
|
||||
const showActive = focused.value || isNodeSelected.value
|
||||
const aIdx = showActive ? activeIndex.value : -1
|
||||
const order = state.value.regions
|
||||
@@ -366,7 +458,7 @@ export function useBoundingBoxes(
|
||||
const dx = mN.x - dragStartNorm.value.x
|
||||
const dy = mN.y - dragStartNorm.value.y
|
||||
const nb = applyDrag(dragMode.value, boxAtStart.value, dx, dy)
|
||||
state.value.regions[activeIndex.value] = nb
|
||||
state.value.regions[activeIndex.value] = snapRegion(nb, dragMode.value)
|
||||
requestDraw()
|
||||
}
|
||||
|
||||
@@ -375,7 +467,7 @@ export function useBoundingBoxes(
|
||||
drawing.value = false
|
||||
canvasEl.value?.releasePointerCapture?.(e.pointerId)
|
||||
const b = state.value.regions[activeIndex.value]
|
||||
if (b && (b.w < 0.005 || b.h < 0.005) && dragMode.value === 'draw') {
|
||||
if (b && (b.w < 0.005 || b.h < 0.005)) {
|
||||
removeRegion(activeIndex.value)
|
||||
}
|
||||
syncState()
|
||||
@@ -510,6 +602,7 @@ export function useBoundingBoxes(
|
||||
function clearAll() {
|
||||
state.value.regions = []
|
||||
activeIndex.value = -1
|
||||
setLastIncoming([])
|
||||
syncState()
|
||||
}
|
||||
|
||||
@@ -530,6 +623,23 @@ export function useBoundingBoxes(
|
||||
watch(isNodeSelected, () => requestDraw())
|
||||
watch([widthValue, heightValue], () => syncState())
|
||||
|
||||
watch(
|
||||
litegraphNode,
|
||||
(node) => {
|
||||
const props = node?.properties as { bboxGrid?: unknown } | undefined
|
||||
if (props && typeof props.bboxGrid === 'boolean')
|
||||
grid.value = props.bboxGrid
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
watch(grid, (enabled) => {
|
||||
const props = litegraphNode.value?.properties as
|
||||
| Record<string, unknown>
|
||||
| undefined
|
||||
if (props) props.bboxGrid = enabled
|
||||
requestDraw()
|
||||
})
|
||||
|
||||
const nodeOutputStore = useNodeOutputStore()
|
||||
function applyImageDimensions(naturalWidth: number, naturalHeight: number) {
|
||||
const node = litegraphNode.value
|
||||
@@ -580,10 +690,63 @@ export function useBoundingBoxes(
|
||||
}
|
||||
img.src = url
|
||||
}
|
||||
watch(() => nodeOutputStore.nodeOutputs, updateBgImage, { deep: true })
|
||||
function lastIncomingWidget() {
|
||||
return litegraphNode.value?.widgets?.find((w) => w.name === 'last_incoming')
|
||||
}
|
||||
|
||||
function lastIncomingValue(): BoundingBox[] {
|
||||
const value = lastIncomingWidget()?.value
|
||||
return Array.isArray(value) ? (value as BoundingBox[]) : []
|
||||
}
|
||||
|
||||
function setLastIncoming(boxes: BoundingBox[]) {
|
||||
const widget = lastIncomingWidget()
|
||||
if (!widget) return
|
||||
const next = cloneDeep(boxes)
|
||||
widget.value = next
|
||||
widget.callback?.(next)
|
||||
}
|
||||
|
||||
function applyIncomingBoxes(apply = true) {
|
||||
if (drawing.value) return
|
||||
const node = litegraphNode.value
|
||||
if (!node) return
|
||||
const slot = node.findInputSlot('bboxes')
|
||||
if (slot < 0 || !node.isInputConnected(slot)) return
|
||||
const outputs = nodeOutputStore.getNodeOutputs(node) as
|
||||
| NodeOutputWith<{ input_bboxes?: BoundingBox[] }>
|
||||
| undefined
|
||||
const incoming = outputs?.input_bboxes
|
||||
if (!incoming?.length) return
|
||||
const applied = lastIncomingValue()
|
||||
if (isEqual(incoming, applied)) return
|
||||
if (!apply) {
|
||||
if (!applied.length && state.value.regions.length)
|
||||
setLastIncoming(incoming)
|
||||
return
|
||||
}
|
||||
state.value.regions = fromBoundingBoxes(
|
||||
incoming,
|
||||
widthValue.value,
|
||||
heightValue.value
|
||||
)
|
||||
activeIndex.value = state.value.regions.length ? 0 : -1
|
||||
setLastIncoming(incoming)
|
||||
syncState()
|
||||
}
|
||||
|
||||
watch(
|
||||
() => nodeOutputStore.nodeOutputs,
|
||||
() => {
|
||||
updateBgImage()
|
||||
applyIncomingBoxes()
|
||||
},
|
||||
{ deep: true }
|
||||
)
|
||||
watch(() => nodeOutputStore.nodePreviewImages, updateBgImage, { deep: true })
|
||||
|
||||
updateBgImage()
|
||||
applyIncomingBoxes(false)
|
||||
void nextTick(() => requestDraw())
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
@@ -608,6 +771,7 @@ export function useBoundingBoxes(
|
||||
commitInlineEditor,
|
||||
setActiveType,
|
||||
clearAll,
|
||||
syncState
|
||||
syncState,
|
||||
grid
|
||||
}
|
||||
}
|
||||
|
||||
@@ -84,7 +84,7 @@ function reconcileNodeErrorFlags(
|
||||
}
|
||||
|
||||
export function useNodeErrorFlagSync(
|
||||
lastNodeErrors: Ref<Record<string, NodeError> | null>,
|
||||
nodeErrors: Ref<Record<string, NodeError> | null>,
|
||||
missingModelStore: ReturnType<typeof useMissingModelStore>,
|
||||
missingMediaStore: ReturnType<typeof useMissingMediaStore>
|
||||
): () => void {
|
||||
@@ -95,7 +95,7 @@ export function useNodeErrorFlagSync(
|
||||
|
||||
const stop = watch(
|
||||
[
|
||||
lastNodeErrors,
|
||||
nodeErrors,
|
||||
() => missingModelStore.missingModelNodeIds,
|
||||
() => missingMediaStore.missingMediaNodeIds,
|
||||
showErrorsTab
|
||||
@@ -108,7 +108,7 @@ export function useNodeErrorFlagSync(
|
||||
// Vue nodes compute hasAnyError independently and are unaffected.
|
||||
reconcileNodeErrorFlags(
|
||||
app.rootGraph,
|
||||
lastNodeErrors.value,
|
||||
nodeErrors.value,
|
||||
showErrorsTab.value
|
||||
? missingModelStore.missingModelAncestorExecutionIds
|
||||
: new Set(),
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import type { EffectScope } from 'vue'
|
||||
import { effectScope, ref, shallowRef } from 'vue'
|
||||
|
||||
@@ -13,17 +13,12 @@ afterEach(() => {
|
||||
function setup(initial: string[]) {
|
||||
const modelValue = ref(initial)
|
||||
const container = shallowRef(document.createElement('div'))
|
||||
const picker = shallowRef(document.createElement('input'))
|
||||
const scope = effectScope()
|
||||
scopes.push(scope)
|
||||
const api = scope.run(() =>
|
||||
usePaletteSwatchRow({ modelValue, container, picker })
|
||||
)!
|
||||
return { modelValue, container, picker, ...api }
|
||||
const api = scope.run(() => usePaletteSwatchRow({ modelValue, container }))!
|
||||
return { modelValue, container, ...api }
|
||||
}
|
||||
|
||||
const mouseEvent = () => ({ stopPropagation: vi.fn() }) as unknown as MouseEvent
|
||||
|
||||
describe('usePaletteSwatchRow', () => {
|
||||
it('appends a default color', () => {
|
||||
const { modelValue, addColor } = setup(['#000000'])
|
||||
@@ -37,31 +32,17 @@ describe('usePaletteSwatchRow', () => {
|
||||
expect(modelValue.value).toEqual(['#a', '#c'])
|
||||
})
|
||||
|
||||
it('seeds the picker input with the clicked color before opening it', () => {
|
||||
const { picker, openPicker } = setup(['#112233'])
|
||||
const click = vi.spyOn(picker.value!, 'click')
|
||||
openPicker(0, mouseEvent())
|
||||
expect(picker.value!.value).toBe('#112233')
|
||||
expect(click).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('falls back to white when the slot is empty', () => {
|
||||
const { picker, openPicker } = setup([''])
|
||||
openPicker(0, mouseEvent())
|
||||
expect(picker.value!.value).toBe('#ffffff')
|
||||
})
|
||||
|
||||
it('writes the picked color back to the open slot', () => {
|
||||
const { modelValue, openPicker, onPickerInput } = setup(['#a', '#b'])
|
||||
openPicker(1, mouseEvent())
|
||||
onPickerInput({ target: { value: '#123456' } } as unknown as Event)
|
||||
it('updates the color at an index', () => {
|
||||
const { modelValue, updateAt } = setup(['#a', '#b'])
|
||||
updateAt(1, '#123456')
|
||||
expect(modelValue.value).toEqual(['#a', '#123456'])
|
||||
})
|
||||
|
||||
it('ignores picker input when no slot is open', () => {
|
||||
const { modelValue, onPickerInput } = setup(['#a'])
|
||||
onPickerInput({ target: { value: '#123456' } } as unknown as Event)
|
||||
expect(modelValue.value).toEqual(['#a'])
|
||||
it('ignores an update that does not change the color', () => {
|
||||
const { modelValue, updateAt } = setup(['#a'])
|
||||
const before = modelValue.value
|
||||
updateAt(0, '#a')
|
||||
expect(modelValue.value).toBe(before)
|
||||
})
|
||||
|
||||
it('reorders via drag when the pointer crosses another swatch', () => {
|
||||
|
||||
@@ -5,30 +5,16 @@ import { ref } from 'vue'
|
||||
interface UsePaletteSwatchRowOptions {
|
||||
modelValue: Ref<string[]>
|
||||
container: Readonly<ShallowRef<HTMLDivElement | null>>
|
||||
picker: Readonly<ShallowRef<HTMLInputElement | null>>
|
||||
}
|
||||
|
||||
export function usePaletteSwatchRow({
|
||||
modelValue,
|
||||
container,
|
||||
picker
|
||||
container
|
||||
}: UsePaletteSwatchRowOptions) {
|
||||
const pickerIndex = ref<number | null>(null)
|
||||
|
||||
function openPicker(i: number, e: MouseEvent) {
|
||||
e.stopPropagation()
|
||||
pickerIndex.value = i
|
||||
const el = picker.value
|
||||
if (!el) return
|
||||
el.value = modelValue.value[i] || '#ffffff'
|
||||
el.click()
|
||||
}
|
||||
|
||||
function onPickerInput(e: Event) {
|
||||
const v = (e.target as HTMLInputElement).value
|
||||
if (pickerIndex.value === null) return
|
||||
function updateAt(i: number, value: string) {
|
||||
if (modelValue.value[i] === value) return
|
||||
const next = modelValue.value.slice()
|
||||
next[pickerIndex.value] = v
|
||||
next[i] = value
|
||||
modelValue.value = next
|
||||
}
|
||||
|
||||
@@ -105,8 +91,7 @@ export function usePaletteSwatchRow({
|
||||
})
|
||||
|
||||
return {
|
||||
openPicker,
|
||||
onPickerInput,
|
||||
updateAt,
|
||||
remove,
|
||||
addColor,
|
||||
onPointerDown
|
||||
|
||||
@@ -77,6 +77,14 @@ vi.mock('pinia', async (importOriginal) => {
|
||||
}
|
||||
})
|
||||
|
||||
const { settingGetMock } = vi.hoisted(() => ({
|
||||
settingGetMock: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/settings/settingStore', () => ({
|
||||
useSettingStore: () => ({ get: settingGetMock })
|
||||
}))
|
||||
|
||||
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
|
||||
useCanvasStore: vi.fn()
|
||||
}))
|
||||
@@ -95,6 +103,9 @@ describe('useLoad3d', () => {
|
||||
vi.clearAllMocks()
|
||||
nodeToLoad3dMap.clear()
|
||||
vi.mocked(getActivePinia).mockReturnValue(null as unknown as Pinia)
|
||||
settingGetMock.mockImplementation((key: string) =>
|
||||
key === 'Comfy.Load3D.BackgroundColor' ? '282828' : undefined
|
||||
)
|
||||
|
||||
mockNode = createMockLGraphNode({
|
||||
properties: {
|
||||
@@ -356,6 +367,20 @@ describe('useLoad3d', () => {
|
||||
expect(composable.isPreview.value).toBe(true)
|
||||
})
|
||||
|
||||
it('should set preview mode for save-viewer nodes despite width/height widgets', async () => {
|
||||
Object.defineProperty(mockNode, 'constructor', {
|
||||
value: { comfyClass: 'Save3DAdvanced' },
|
||||
configurable: true
|
||||
})
|
||||
|
||||
const composable = useLoad3d(mockNode)
|
||||
const containerRef = document.createElement('div')
|
||||
|
||||
await composable.initializeLoad3d(containerRef)
|
||||
|
||||
expect(composable.isPreview.value).toBe(true)
|
||||
})
|
||||
|
||||
it('should handle initialization errors', async () => {
|
||||
vi.mocked(createLoad3d).mockImplementationOnce(() => {
|
||||
throw new Error('Load3d creation failed')
|
||||
@@ -383,7 +408,37 @@ describe('useLoad3d', () => {
|
||||
const nodeRef = shallowRef<LGraphNode | null>(mockNode)
|
||||
const composable = useLoad3d(nodeRef)
|
||||
|
||||
expect(composable.sceneConfig.value.backgroundColor).toBe('#000000')
|
||||
expect(composable.sceneConfig.value.backgroundColor).toBe('#282828')
|
||||
})
|
||||
|
||||
it('defaults background color from the Comfy.Load3D.BackgroundColor setting', () => {
|
||||
vi.mocked(getActivePinia).mockReturnValue({} as unknown as Pinia)
|
||||
vi.mocked(useCanvasStore).mockReturnValue(
|
||||
reactive({ appScalePercentage: 100 }) as unknown as ReturnType<
|
||||
typeof useCanvasStore
|
||||
>
|
||||
)
|
||||
settingGetMock.mockImplementation((key: string) =>
|
||||
key === 'Comfy.Load3D.BackgroundColor' ? '123456' : undefined
|
||||
)
|
||||
|
||||
const composable = useLoad3d(mockNode)
|
||||
|
||||
expect(composable.sceneConfig.value.backgroundColor).toBe('#123456')
|
||||
})
|
||||
|
||||
it('attaches event listeners before running queued ready callbacks', async () => {
|
||||
const composable = useLoad3d(mockNode)
|
||||
let listenersAttachedWhenCallbackRan = false
|
||||
|
||||
composable.waitForLoad3d(() => {
|
||||
listenersAttachedWhenCallbackRan =
|
||||
vi.mocked(mockLoad3d.addEventListener!).mock.calls.length > 0
|
||||
})
|
||||
|
||||
await composable.initializeLoad3d(document.createElement('div'))
|
||||
|
||||
expect(listenersAttachedWhenCallbackRan).toBe(true)
|
||||
})
|
||||
|
||||
it('passes getZoomScale callback to createLoad3d', async () => {
|
||||
|
||||
@@ -8,6 +8,7 @@ import { useChainCallback } from '@/composables/functional/useChainCallback'
|
||||
import type Load3d from '@/extensions/core/load3d/Load3d'
|
||||
import Load3dUtils from '@/extensions/core/load3d/Load3dUtils'
|
||||
import { createLoad3d } from '@/extensions/core/load3d/createLoad3d'
|
||||
import { isLoad3dResultViewerNode } from '@/extensions/core/load3d/nodeTypes'
|
||||
import {
|
||||
isAssetPreviewSupported,
|
||||
persistThumbnail
|
||||
@@ -118,7 +119,9 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
|
||||
|
||||
const sceneConfig = ref<SceneConfig>({
|
||||
showGrid: true,
|
||||
backgroundColor: '#000000',
|
||||
backgroundColor: getActivePinia()
|
||||
? '#' + useSettingStore().get('Comfy.Load3D.BackgroundColor')
|
||||
: '#282828',
|
||||
backgroundImage: '',
|
||||
backgroundRenderMode: 'tiled'
|
||||
})
|
||||
@@ -192,6 +195,7 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
|
||||
const heightWidget = node.widgets?.find((w) => w.name === 'height')
|
||||
|
||||
if (
|
||||
isLoad3dResultViewerNode(node.constructor.comfyClass ?? '') ||
|
||||
node.constructor.comfyClass?.startsWith('Preview') ||
|
||||
!(widthWidget && heightWidget)
|
||||
) {
|
||||
@@ -248,6 +252,8 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
|
||||
|
||||
nodeToLoad3dMap.set(node, load3d)
|
||||
|
||||
handleEvents('add')
|
||||
|
||||
const callbacks = pendingCallbacks.get(node)
|
||||
|
||||
if (callbacks && load3d) {
|
||||
@@ -263,8 +269,6 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
|
||||
if (load3d) invokeReadyCallback(callback, load3d)
|
||||
})
|
||||
}
|
||||
|
||||
handleEvents('add')
|
||||
} catch (error) {
|
||||
console.error('Error initializing Load3d:', error)
|
||||
useToastStore().addAlert(
|
||||
|
||||
@@ -4,7 +4,7 @@ import QuickLRU from '@alloc/quick-lru'
|
||||
import type Load3d from '@/extensions/core/load3d/Load3d'
|
||||
import Load3dUtils from '@/extensions/core/load3d/Load3dUtils'
|
||||
import { createLoad3d } from '@/extensions/core/load3d/createLoad3d'
|
||||
import { isLoad3dPreviewNode } from '@/extensions/core/load3d/nodeTypes'
|
||||
import { isLoad3dResultViewerNode } from '@/extensions/core/load3d/nodeTypes'
|
||||
import type {
|
||||
AnimationItem,
|
||||
BackgroundRenderModeType,
|
||||
@@ -371,7 +371,7 @@ export const useLoad3dViewer = (node?: LGraphNode) => {
|
||||
| LightConfig
|
||||
| undefined
|
||||
|
||||
isPreview.value = isLoad3dPreviewNode(node.type ?? '')
|
||||
isPreview.value = isLoad3dResultViewerNode(node.type ?? '')
|
||||
|
||||
if (sceneConfig) {
|
||||
backgroundColor.value =
|
||||
|
||||
23
src/composables/useModalLiftedZIndex.ts
Normal file
23
src/composables/useModalLiftedZIndex.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { ZIndex } from '@primeuix/utils/zindex'
|
||||
import type { Ref } from 'vue'
|
||||
import { computed } from 'vue'
|
||||
|
||||
// Shared base for @primeuix's auto-incrementing 'modal' z-index counter.
|
||||
const MODAL_BASE_Z_INDEX = 1700
|
||||
|
||||
/**
|
||||
* Inline z-index style for body-portaled popover/menu content. Such content
|
||||
* keeps its static `z-1700` class unless a dialog that joined @primeuix's
|
||||
* auto-incrementing 'modal' counter (Reka and PrimeVue dialogs both do, via
|
||||
* `v-reka-z-index` or PrimeVue's mask) is open above it; then lift past that
|
||||
* dialog so the content isn't hidden behind the dialog or its scrim.
|
||||
*/
|
||||
export function useModalLiftedZIndex(open: Ref<boolean>) {
|
||||
return computed(() => {
|
||||
if (!open.value) return undefined
|
||||
const topZIndex = ZIndex.getCurrent('modal')
|
||||
return topZIndex >= MODAL_BASE_Z_INDEX
|
||||
? { zIndex: topZIndex + 1 }
|
||||
: undefined
|
||||
})
|
||||
}
|
||||
71
src/composables/useTextFileContent.test.ts
Normal file
71
src/composables/useTextFileContent.test.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { useTextFileContent } from '@/composables/useTextFileContent'
|
||||
|
||||
function stubFetch(response: Partial<Response> | Error) {
|
||||
const mock =
|
||||
response instanceof Error
|
||||
? vi.fn().mockRejectedValue(response)
|
||||
: vi.fn().mockResolvedValue(response)
|
||||
vi.stubGlobal('fetch', mock)
|
||||
return mock
|
||||
}
|
||||
|
||||
describe(useTextFileContent, () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it('returns inline content without fetching', async () => {
|
||||
const fetchMock = stubFetch(new Error('should not be called'))
|
||||
const { textContent } = useTextFileContent(() => ({
|
||||
content: 'inline text',
|
||||
url: 'http://example.com/file.txt'
|
||||
}))
|
||||
|
||||
await vi.waitFor(() => expect(textContent.value).toBe('inline text'))
|
||||
expect(fetchMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('fetches text from the url when no inline content is present', async () => {
|
||||
const fetchMock = stubFetch({
|
||||
ok: true,
|
||||
text: () => Promise.resolve('fetched text')
|
||||
})
|
||||
const { textContent, hasError } = useTextFileContent(() => ({
|
||||
url: 'http://example.com/file.txt'
|
||||
}))
|
||||
|
||||
await vi.waitFor(() => expect(textContent.value).toBe('fetched text'))
|
||||
expect(fetchMock).toHaveBeenCalledWith('http://example.com/file.txt')
|
||||
expect(hasError.value).toBe(false)
|
||||
})
|
||||
|
||||
it('flags an error for a non-ok response', async () => {
|
||||
stubFetch({ ok: false })
|
||||
const { textContent, hasError } = useTextFileContent(() => ({
|
||||
url: 'http://example.com/missing.txt'
|
||||
}))
|
||||
|
||||
await vi.waitFor(() => expect(hasError.value).toBe(true))
|
||||
expect(textContent.value).toBe('')
|
||||
})
|
||||
|
||||
it('flags an error when the fetch rejects', async () => {
|
||||
stubFetch(new Error('network down'))
|
||||
const { hasError } = useTextFileContent(() => ({
|
||||
url: 'http://example.com/file.txt'
|
||||
}))
|
||||
|
||||
await vi.waitFor(() => expect(hasError.value).toBe(true))
|
||||
})
|
||||
|
||||
it('resolves empty content when there is no source', async () => {
|
||||
const fetchMock = stubFetch(new Error('should not be called'))
|
||||
const { textContent, isLoading } = useTextFileContent(() => undefined)
|
||||
|
||||
await vi.waitFor(() => expect(isLoading.value).toBe(false))
|
||||
expect(textContent.value).toBe('')
|
||||
expect(fetchMock).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
40
src/composables/useTextFileContent.ts
Normal file
40
src/composables/useTextFileContent.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { computedAsync } from '@vueuse/core'
|
||||
import { ref, toValue } from 'vue'
|
||||
import type { MaybeRefOrGetter } from 'vue'
|
||||
|
||||
interface TextSource {
|
||||
content?: string
|
||||
url?: string
|
||||
}
|
||||
|
||||
export function useTextFileContent(
|
||||
source: MaybeRefOrGetter<TextSource | undefined>
|
||||
) {
|
||||
const isLoading = ref(false)
|
||||
const hasError = ref(false)
|
||||
|
||||
const textContent = computedAsync(
|
||||
async () => {
|
||||
hasError.value = false
|
||||
const { content, url } = toValue(source) ?? {}
|
||||
if (content !== undefined) return content
|
||||
if (!url) return ''
|
||||
|
||||
const response = await fetch(url)
|
||||
if (!response.ok) {
|
||||
hasError.value = true
|
||||
return ''
|
||||
}
|
||||
return await response.text()
|
||||
},
|
||||
'',
|
||||
{
|
||||
evaluating: isLoading,
|
||||
onError: () => {
|
||||
hasError.value = true
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
return { textContent, isLoading, hasError }
|
||||
}
|
||||
299
src/core/graph/subgraph/liftNodeErrorsToBoundary.test.ts
Normal file
299
src/core/graph/subgraph/liftNodeErrorsToBoundary.test.ts
Normal file
@@ -0,0 +1,299 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
|
||||
import { promoteValueWidgetViaSubgraphInput } from '@/core/graph/subgraph/promotionUtils'
|
||||
import { nodeError, validationError } from '@/utils/__tests__/nodeErrorHelpers'
|
||||
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import {
|
||||
createBoundaryLinkedSubgraph,
|
||||
createTestRootGraph,
|
||||
createTestSubgraph,
|
||||
createTestSubgraphNode
|
||||
} from '@/lib/litegraph/src/subgraph/__fixtures__/subgraphHelpers'
|
||||
import { toNodeId } from '@/types/nodeId'
|
||||
|
||||
import { liftNodeErrorsToBoundary } from './liftNodeErrorsToBoundary'
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
})
|
||||
|
||||
describe('liftNodeErrorsToBoundary', () => {
|
||||
it('lifts a boundary-linked slot error to the host', () => {
|
||||
const { host, rootGraph } = createBoundaryLinkedSubgraph()
|
||||
const errors = {
|
||||
'12:5': nodeError([
|
||||
validationError('required_input_missing', 'seed_input')
|
||||
])
|
||||
}
|
||||
|
||||
const result = liftNodeErrorsToBoundary(rootGraph, errors)
|
||||
|
||||
expect(result).toEqual({
|
||||
'12': {
|
||||
class_type: host.title,
|
||||
dependent_outputs: [],
|
||||
errors: [
|
||||
expect.objectContaining({
|
||||
type: 'required_input_missing',
|
||||
extra_info: expect.objectContaining({
|
||||
input_name: 'seed',
|
||||
source_execution_id: '12:5',
|
||||
source_input_name: 'seed_input'
|
||||
})
|
||||
})
|
||||
]
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('lifts a promoted-widget value error to the host input', () => {
|
||||
const rootGraph = createTestRootGraph()
|
||||
const subgraph = createTestSubgraph({ rootGraph })
|
||||
const host = createTestSubgraphNode(subgraph, { id: 12 })
|
||||
rootGraph.add(host)
|
||||
|
||||
const interior = new LGraphNode('CheckpointLoaderSimple')
|
||||
interior.id = toNodeId(5)
|
||||
const input = interior.addInput('ckpt_name', 'COMBO')
|
||||
const widget = interior.addWidget('combo', 'ckpt_name', '', () => {}, {
|
||||
values: ['present.safetensors']
|
||||
})
|
||||
input.widget = { name: widget.name }
|
||||
subgraph.add(interior)
|
||||
|
||||
expect(promoteValueWidgetViaSubgraphInput(host, interior, widget).ok).toBe(
|
||||
true
|
||||
)
|
||||
|
||||
const result = liftNodeErrorsToBoundary(rootGraph, {
|
||||
'12:5': nodeError([
|
||||
validationError('value_not_in_list', 'ckpt_name', {
|
||||
received_value: 'missing.safetensors',
|
||||
input_config: ['COMBO', { values: ['present.safetensors'] }]
|
||||
})
|
||||
])
|
||||
})
|
||||
|
||||
expect(result['12'].errors[0].extra_info).toMatchObject({
|
||||
input_name: 'ckpt_name',
|
||||
source_execution_id: '12:5',
|
||||
source_input_name: 'ckpt_name',
|
||||
received_value: 'missing.safetensors',
|
||||
input_config: ['COMBO', { values: ['present.safetensors'] }]
|
||||
})
|
||||
})
|
||||
|
||||
it('recurses through nested boundary-linked hosts', () => {
|
||||
const rootGraph = createTestRootGraph()
|
||||
const outerSubgraph = createTestSubgraph({
|
||||
rootGraph,
|
||||
inputs: [{ name: 'seed', type: '*' }]
|
||||
})
|
||||
const outerHost = createTestSubgraphNode(outerSubgraph, { id: 1 })
|
||||
outerHost.title = 'Outer Host'
|
||||
rootGraph.add(outerHost)
|
||||
|
||||
const middleSubgraph = createTestSubgraph({
|
||||
rootGraph,
|
||||
inputs: [{ name: 'seed', type: '*' }]
|
||||
})
|
||||
const middleHost = createTestSubgraphNode(middleSubgraph, {
|
||||
id: 2,
|
||||
parentGraph: outerSubgraph
|
||||
})
|
||||
outerSubgraph.add(middleHost)
|
||||
outerSubgraph.inputNode.slots[0].connect(middleHost.inputs[0], middleHost)
|
||||
|
||||
const leaf = new LGraphNode('LeafNode')
|
||||
leaf.id = toNodeId(3)
|
||||
const leafInput = leaf.addInput('seed_input', '*')
|
||||
middleSubgraph.add(leaf)
|
||||
middleSubgraph.inputNode.slots[0].connect(leafInput, leaf)
|
||||
|
||||
const result = liftNodeErrorsToBoundary(rootGraph, {
|
||||
'1:2:3': nodeError([
|
||||
validationError('required_input_missing', 'seed_input')
|
||||
])
|
||||
})
|
||||
|
||||
expect(Object.keys(result)).toEqual(['1'])
|
||||
expect(result['1'].class_type).toBe(outerHost.title)
|
||||
expect(result['1'].errors[0].extra_info).toMatchObject({
|
||||
input_name: 'seed',
|
||||
source_execution_id: '1:2:3',
|
||||
source_input_name: 'seed_input'
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps errors on ordinary interior data-flow links', () => {
|
||||
const rootGraph = createTestRootGraph()
|
||||
const subgraph = createTestSubgraph({
|
||||
rootGraph,
|
||||
inputs: [{ name: 'seed', type: '*' }]
|
||||
})
|
||||
const host = createTestSubgraphNode(subgraph, { id: 12 })
|
||||
rootGraph.add(host)
|
||||
|
||||
const source = new LGraphNode('SourceNode')
|
||||
source.id = toNodeId(4)
|
||||
source.addOutput('seed', '*')
|
||||
subgraph.add(source)
|
||||
|
||||
const target = new LGraphNode('TargetNode')
|
||||
target.id = toNodeId(5)
|
||||
target.addInput('seed_input', '*')
|
||||
subgraph.add(target)
|
||||
source.connect(0, target, 0)
|
||||
|
||||
const errors = {
|
||||
'12:5': nodeError([
|
||||
validationError('required_input_missing', 'seed_input')
|
||||
])
|
||||
}
|
||||
|
||||
expect(liftNodeErrorsToBoundary(rootGraph, errors)).toEqual(errors)
|
||||
})
|
||||
|
||||
it('keeps errors without a liftable subject on the interior node', () => {
|
||||
const { rootGraph } = createBoundaryLinkedSubgraph()
|
||||
const errors = {
|
||||
'12:5': nodeError([
|
||||
validationError('required_input_missing'),
|
||||
validationError('exception_during_validation', 'seed_input'),
|
||||
validationError('dependency_cycle', 'seed_input'),
|
||||
validationError(
|
||||
'custom_validation_failed',
|
||||
'seed_input',
|
||||
{ received_value: 'image.png' },
|
||||
'Invalid image file'
|
||||
)
|
||||
])
|
||||
}
|
||||
|
||||
expect(liftNodeErrorsToBoundary(rootGraph, errors)).toEqual(errors)
|
||||
})
|
||||
|
||||
it('keeps unknown typed validation errors on the interior node', () => {
|
||||
const { rootGraph } = createBoundaryLinkedSubgraph()
|
||||
const errors = {
|
||||
'12:5': nodeError([
|
||||
validationError('future_backend_validation_type', 'seed_input')
|
||||
])
|
||||
}
|
||||
|
||||
expect(liftNodeErrorsToBoundary(rootGraph, errors)).toEqual(errors)
|
||||
})
|
||||
|
||||
it('splits liftable and non-liftable errors from the same node entry', () => {
|
||||
const { rootGraph } = createBoundaryLinkedSubgraph()
|
||||
|
||||
const result = liftNodeErrorsToBoundary(rootGraph, {
|
||||
'12:5': nodeError([
|
||||
validationError('required_input_missing', 'seed_input'),
|
||||
validationError('exception_during_validation', 'seed_input')
|
||||
])
|
||||
})
|
||||
|
||||
expect(result['12'].errors).toHaveLength(1)
|
||||
expect(result['12'].errors[0].type).toBe('required_input_missing')
|
||||
expect(result['12:5'].errors).toHaveLength(1)
|
||||
expect(result['12:5'].errors[0].type).toBe('exception_during_validation')
|
||||
})
|
||||
|
||||
it('merges a lifted error into an existing host entry', () => {
|
||||
const { rootGraph } = createBoundaryLinkedSubgraph()
|
||||
const errors = {
|
||||
'12': {
|
||||
class_type: 'ExistingHostClass',
|
||||
dependent_outputs: ['existing-output'],
|
||||
errors: [validationError('value_smaller_than_min', 'other')]
|
||||
},
|
||||
'12:5': nodeError([
|
||||
validationError('required_input_missing', 'seed_input')
|
||||
])
|
||||
}
|
||||
|
||||
const result = liftNodeErrorsToBoundary(rootGraph, errors)
|
||||
|
||||
expect(result['12']).toMatchObject({
|
||||
class_type: 'ExistingHostClass',
|
||||
dependent_outputs: ['existing-output']
|
||||
})
|
||||
expect(result['12'].errors.map((error) => error.type)).toEqual([
|
||||
'value_smaller_than_min',
|
||||
'required_input_missing'
|
||||
])
|
||||
})
|
||||
|
||||
it('keeps own errors before lifted errors for nested host keys', () => {
|
||||
const rootGraph = createTestRootGraph()
|
||||
const outerSubgraph = createTestSubgraph({ rootGraph })
|
||||
const outerHost = createTestSubgraphNode(outerSubgraph, { id: 1 })
|
||||
rootGraph.add(outerHost)
|
||||
|
||||
const middleSubgraph = createTestSubgraph({
|
||||
rootGraph,
|
||||
inputs: [{ name: 'seed', type: '*' }]
|
||||
})
|
||||
const middleHost = createTestSubgraphNode(middleSubgraph, {
|
||||
id: 2,
|
||||
parentGraph: outerSubgraph
|
||||
})
|
||||
outerSubgraph.add(middleHost)
|
||||
|
||||
const leaf = new LGraphNode('LeafNode')
|
||||
leaf.id = toNodeId(3)
|
||||
const leafInput = leaf.addInput('seed_input', '*')
|
||||
middleSubgraph.add(leaf)
|
||||
middleSubgraph.inputNode.slots[0].connect(leafInput, leaf)
|
||||
|
||||
const result = liftNodeErrorsToBoundary(rootGraph, {
|
||||
'1:2:3': nodeError([
|
||||
validationError('required_input_missing', 'seed_input')
|
||||
]),
|
||||
'1:2': nodeError([validationError('value_smaller_than_min', 'seed')])
|
||||
})
|
||||
|
||||
expect(result['1:2'].errors.map((error) => error.type)).toEqual([
|
||||
'value_smaller_than_min',
|
||||
'required_input_missing'
|
||||
])
|
||||
})
|
||||
|
||||
it('preserves empty error entries unchanged', () => {
|
||||
const rootGraph = createTestRootGraph()
|
||||
const errors = {
|
||||
'12': nodeError([], 'ExtraRootNode')
|
||||
}
|
||||
|
||||
expect(liftNodeErrorsToBoundary(rootGraph, errors)).toEqual(errors)
|
||||
})
|
||||
|
||||
it('fails open without mutating the input record', () => {
|
||||
const rootGraph = createTestRootGraph()
|
||||
const subgraph = createTestSubgraph({ rootGraph })
|
||||
const host = createTestSubgraphNode(subgraph, { id: 12 })
|
||||
rootGraph.add(host)
|
||||
const interior = new LGraphNode('InteriorNode')
|
||||
interior.id = toNodeId(5)
|
||||
interior.addInput('unlinked', '*')
|
||||
subgraph.add(interior)
|
||||
|
||||
const errors = {
|
||||
'99:5': nodeError([validationError('required_input_missing', 'x')]),
|
||||
'12:5': nodeError([
|
||||
validationError('required_input_missing', 'missing'),
|
||||
validationError('value_not_in_list', 'unlinked')
|
||||
])
|
||||
}
|
||||
const original = structuredClone(errors)
|
||||
|
||||
const result = liftNodeErrorsToBoundary(rootGraph, errors)
|
||||
|
||||
expect(result).toEqual(original)
|
||||
expect(errors).toEqual(original)
|
||||
expect(result).not.toBe(errors)
|
||||
})
|
||||
})
|
||||
193
src/core/graph/subgraph/liftNodeErrorsToBoundary.ts
Normal file
193
src/core/graph/subgraph/liftNodeErrorsToBoundary.ts
Normal file
@@ -0,0 +1,193 @@
|
||||
import { groupBy, partition } from 'es-toolkit'
|
||||
|
||||
import type { LGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import type { NodeError } from '@/schemas/apiSchema'
|
||||
import { tryNormalizeNodeExecutionId } from '@/types/nodeIdentification'
|
||||
import type { NodeExecutionId } from '@/types/nodeIdentification'
|
||||
import { isNodeLevelValidationError } from '@/utils/executionErrorUtil'
|
||||
import type { NodeValidationError } from '@/utils/executionErrorUtil'
|
||||
import { getNodeByExecutionId } from '@/utils/graphTraversalUtil'
|
||||
import { isSubgraph } from '@/utils/typeGuardUtil'
|
||||
|
||||
export interface LiftedErrorExtraInfo {
|
||||
input_name: string
|
||||
source_execution_id: string
|
||||
source_input_name: string
|
||||
}
|
||||
|
||||
export interface LiftedSurface {
|
||||
hostExecId: NodeExecutionId
|
||||
hostInputName: string
|
||||
}
|
||||
|
||||
interface ErrorPlacement {
|
||||
kind: 'own' | 'lifted'
|
||||
targetExecId: string
|
||||
error: NodeValidationError
|
||||
}
|
||||
|
||||
export function getLiftedErrorSource(
|
||||
error: NodeValidationError
|
||||
): LiftedErrorExtraInfo | null {
|
||||
const extraInfo = error.extra_info
|
||||
if (!extraInfo) return null
|
||||
|
||||
const { input_name, source_execution_id, source_input_name } = extraInfo
|
||||
if (
|
||||
typeof input_name !== 'string' ||
|
||||
typeof source_execution_id !== 'string' ||
|
||||
typeof source_input_name !== 'string'
|
||||
) {
|
||||
return null
|
||||
}
|
||||
|
||||
return { input_name, source_execution_id, source_input_name }
|
||||
}
|
||||
|
||||
function getHostExecutionId(executionId: string): NodeExecutionId | null {
|
||||
const separatorIndex = executionId.lastIndexOf(':')
|
||||
if (separatorIndex <= 0) return null
|
||||
return tryNormalizeNodeExecutionId(executionId.slice(0, separatorIndex))
|
||||
}
|
||||
|
||||
/**
|
||||
* Boundary surfaces that expose `(executionId, inputName)`, innermost first.
|
||||
* Walks one host per level and stops at the last resolvable surface, so an
|
||||
* unresolvable deeper host falls back to the shallower one (fail-open).
|
||||
*/
|
||||
export function resolveLiftChain(
|
||||
rootGraph: LGraph,
|
||||
executionId: string,
|
||||
inputName: string
|
||||
): LiftedSurface[] {
|
||||
const chain: LiftedSurface[] = []
|
||||
let currentExecId = executionId
|
||||
let currentInputName = inputName
|
||||
|
||||
for (;;) {
|
||||
const node = getNodeByExecutionId(rootGraph, currentExecId)
|
||||
const graph = node?.graph
|
||||
if (!node || !graph || !isSubgraph(graph)) break
|
||||
|
||||
const slot = node.inputs?.find((input) => input.name === currentInputName)
|
||||
if (slot?.link == null) break
|
||||
|
||||
const subgraphInput = graph
|
||||
.getLink(slot.link)
|
||||
?.resolve(graph)?.subgraphInput
|
||||
if (!subgraphInput) break
|
||||
|
||||
const hostExecId = getHostExecutionId(currentExecId)
|
||||
if (!hostExecId || !getNodeByExecutionId(rootGraph, hostExecId)) break
|
||||
|
||||
chain.push({ hostExecId, hostInputName: subgraphInput.name })
|
||||
currentExecId = hostExecId
|
||||
currentInputName = subgraphInput.name
|
||||
}
|
||||
|
||||
return chain
|
||||
}
|
||||
|
||||
function createEmptyNodeError(nodeError: NodeError): NodeError {
|
||||
return {
|
||||
...nodeError,
|
||||
errors: []
|
||||
}
|
||||
}
|
||||
|
||||
// Lifted host entries use the host title for display; SubgraphNode.type is a UUID.
|
||||
function createLiftedHostEntry(
|
||||
rootGraph: LGraph,
|
||||
hostExecId: string
|
||||
): NodeError {
|
||||
return {
|
||||
class_type:
|
||||
getNodeByExecutionId(rootGraph, hostExecId)?.title ?? hostExecId,
|
||||
dependent_outputs: [],
|
||||
errors: []
|
||||
}
|
||||
}
|
||||
|
||||
function toErrorPlacement(
|
||||
rootGraph: LGraph,
|
||||
executionId: string,
|
||||
error: NodeValidationError
|
||||
): ErrorPlacement {
|
||||
const inputName = error.extra_info?.input_name
|
||||
const surface =
|
||||
inputName && !isNodeLevelValidationError(error)
|
||||
? resolveLiftChain(rootGraph, executionId, inputName).at(-1)
|
||||
: undefined
|
||||
|
||||
if (!inputName || !surface) {
|
||||
return {
|
||||
kind: 'own',
|
||||
targetExecId: executionId,
|
||||
error
|
||||
}
|
||||
}
|
||||
|
||||
const liftedExtraInfo: LiftedErrorExtraInfo = {
|
||||
input_name: surface.hostInputName,
|
||||
source_execution_id: executionId,
|
||||
source_input_name: inputName
|
||||
}
|
||||
|
||||
return {
|
||||
kind: 'lifted',
|
||||
targetExecId: surface.hostExecId,
|
||||
error: {
|
||||
...error,
|
||||
extra_info: {
|
||||
...error.extra_info,
|
||||
...liftedExtraInfo
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function liftNodeErrorsToBoundary(
|
||||
rootGraph: LGraph,
|
||||
nodeErrors: Record<string, NodeError>
|
||||
): Record<string, NodeError> {
|
||||
const output: Record<string, NodeError> = {}
|
||||
const placements = Object.entries(nodeErrors).flatMap(
|
||||
([executionId, nodeError]) =>
|
||||
nodeError.errors.map((error) =>
|
||||
toErrorPlacement(rootGraph, executionId, error)
|
||||
)
|
||||
)
|
||||
|
||||
for (const [executionId, nodeError] of Object.entries(nodeErrors)) {
|
||||
if (nodeError.errors.length === 0) {
|
||||
output[executionId] = createEmptyNodeError(nodeError)
|
||||
}
|
||||
}
|
||||
|
||||
const placementsByTarget = groupBy(
|
||||
placements,
|
||||
(placement) => placement.targetExecId
|
||||
)
|
||||
|
||||
for (const [targetExecId, targetPlacements] of Object.entries(
|
||||
placementsByTarget
|
||||
)) {
|
||||
const baseEntry = nodeErrors[targetExecId]
|
||||
? createEmptyNodeError(nodeErrors[targetExecId])
|
||||
: createLiftedHostEntry(rootGraph, targetExecId)
|
||||
|
||||
const [ownErrors, liftedErrors] = partition(
|
||||
targetPlacements,
|
||||
(placement) => placement.kind === 'own'
|
||||
)
|
||||
|
||||
output[targetExecId] = {
|
||||
...baseEntry,
|
||||
errors: [...ownErrors, ...liftedErrors].map(
|
||||
(placement) => placement.error
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return output
|
||||
}
|
||||
@@ -32,7 +32,8 @@ function makeNode(connected: boolean, comfyClass = 'CreateBoundingBoxes') {
|
||||
const widgets: MockWidget[] = [
|
||||
{ name: 'width', hidden: false, options: {} },
|
||||
{ name: 'height', hidden: false, options: {} },
|
||||
{ name: 'other', hidden: false, options: {} }
|
||||
{ name: 'other', hidden: false, options: {} },
|
||||
{ name: 'last_incoming', hidden: false, options: {} }
|
||||
]
|
||||
return {
|
||||
constructor: { comfyClass },
|
||||
@@ -73,6 +74,15 @@ describe('Comfy.CreateBoundingBoxes extension', () => {
|
||||
expect(node.widgets[0].options.hidden).toBe(false)
|
||||
})
|
||||
|
||||
it('always hides the internal last_incoming widget', () => {
|
||||
for (const connected of [true, false]) {
|
||||
const node = makeNode(connected)
|
||||
state.extension!.nodeCreated(node)
|
||||
expect(node.widgets[3].hidden).toBe(true)
|
||||
expect(node.widgets[3].options.hidden).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
it('writes visibility through the widget value store when present', () => {
|
||||
state.widgetState = { options: {} }
|
||||
const node = makeNode(true)
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useExtensionService } from '@/services/extensionService'
|
||||
import { useWidgetValueStore } from '@/stores/widgetValueStore'
|
||||
|
||||
const DIMENSION_WIDGETS = new Set(['width', 'height'])
|
||||
const INTERNAL_WIDGETS = new Set(['last_incoming'])
|
||||
|
||||
useExtensionService().registerExtension({
|
||||
name: 'Comfy.CreateBoundingBoxes',
|
||||
@@ -15,20 +16,30 @@ useExtensionService().registerExtension({
|
||||
|
||||
const widgetValueStore = useWidgetValueStore()
|
||||
|
||||
const setWidgetHidden = (
|
||||
widget: NonNullable<typeof node.widgets>[number],
|
||||
hidden: boolean
|
||||
) => {
|
||||
widget.hidden = hidden
|
||||
const state = widget.widgetId
|
||||
? widgetValueStore.getWidget(widget.widgetId)
|
||||
: undefined
|
||||
if (state?.options) state.options.hidden = hidden
|
||||
else widget.options.hidden = hidden
|
||||
}
|
||||
|
||||
const syncDimensionVisibility = () => {
|
||||
const slot = node.findInputSlot('background')
|
||||
const hidden = slot >= 0 && node.isInputConnected(slot)
|
||||
for (const widget of node.widgets ?? []) {
|
||||
if (!DIMENSION_WIDGETS.has(widget.name)) continue
|
||||
widget.hidden = hidden
|
||||
const state = widget.widgetId
|
||||
? widgetValueStore.getWidget(widget.widgetId)
|
||||
: undefined
|
||||
if (state?.options) state.options.hidden = hidden
|
||||
else widget.options.hidden = hidden
|
||||
if (DIMENSION_WIDGETS.has(widget.name)) setWidgetHidden(widget, hidden)
|
||||
}
|
||||
}
|
||||
|
||||
for (const widget of node.widgets ?? []) {
|
||||
if (INTERNAL_WIDGETS.has(widget.name)) setWidgetHidden(widget, true)
|
||||
}
|
||||
|
||||
syncDimensionVisibility()
|
||||
node.onConnectionsChange = useChainCallback(
|
||||
node.onConnectionsChange,
|
||||
|
||||
@@ -21,6 +21,7 @@ if (!isCloud) {
|
||||
import './noteNode'
|
||||
import './painter'
|
||||
import './previewAny'
|
||||
import './saveText'
|
||||
import './rerouteNode'
|
||||
import './saveImageExtraOutput'
|
||||
// saveMesh is loaded on-demand with load3d (see load3dLazy.ts)
|
||||
|
||||
@@ -143,14 +143,23 @@ async function loadExtensionsFresh(): Promise<{
|
||||
load3DExt: ExtCreated
|
||||
preview3DExt: ExtCreated
|
||||
preview3DAdvancedExt: ExtCreated
|
||||
save3DAdvancedExt: ExtCreated
|
||||
}> {
|
||||
vi.resetModules()
|
||||
registerExtensionMock.mockClear()
|
||||
await import('@/extensions/core/load3d')
|
||||
const extByName = (name: string): ExtCreated => {
|
||||
const call = registerExtensionMock.mock.calls.find(
|
||||
(c) => (c[0] as ExtCreated).name === name
|
||||
)
|
||||
if (!call) throw new Error(`Extension ${name} was not registered`)
|
||||
return call[0] as ExtCreated
|
||||
}
|
||||
return {
|
||||
load3DExt: registerExtensionMock.mock.calls[0][0] as ExtCreated,
|
||||
preview3DExt: registerExtensionMock.mock.calls[1][0] as ExtCreated,
|
||||
preview3DAdvancedExt: registerExtensionMock.mock.calls[2][0] as ExtCreated
|
||||
load3DExt: extByName('Comfy.Load3D'),
|
||||
preview3DExt: extByName('Comfy.Preview3D'),
|
||||
preview3DAdvancedExt: extByName('Comfy.Preview3DAdvanced'),
|
||||
save3DAdvancedExt: extByName('Comfy.Save3DAdvanced')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -264,14 +273,15 @@ function setupBaseMocks() {
|
||||
describe('load3d module registration', () => {
|
||||
beforeEach(setupBaseMocks)
|
||||
|
||||
it('registers Comfy.Load3D, Comfy.Preview3D, and Comfy.Preview3DAdvanced extensions on import', async () => {
|
||||
const { load3DExt, preview3DExt, preview3DAdvancedExt } =
|
||||
it('registers Comfy.Load3D, Comfy.Preview3D, Comfy.Preview3DAdvanced, and Comfy.Save3DAdvanced extensions on import', async () => {
|
||||
const { load3DExt, preview3DExt, preview3DAdvancedExt, save3DAdvancedExt } =
|
||||
await loadExtensionsFresh()
|
||||
|
||||
expect(registerExtensionMock).toHaveBeenCalledTimes(3)
|
||||
expect(registerExtensionMock).toHaveBeenCalledTimes(4)
|
||||
expect(load3DExt.name).toBe('Comfy.Load3D')
|
||||
expect(preview3DExt.name).toBe('Comfy.Preview3D')
|
||||
expect(preview3DAdvancedExt.name).toBe('Comfy.Preview3DAdvanced')
|
||||
expect(save3DAdvancedExt.name).toBe('Comfy.Save3DAdvanced')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -711,6 +721,39 @@ describe('Comfy.Preview3D.onNodeOutputsUpdated', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('Comfy.Save3DAdvanced.onNodeOutputsUpdated', () => {
|
||||
beforeEach(setupBaseMocks)
|
||||
|
||||
it('restores the saved model from the output folder when opened from history', async () => {
|
||||
const { save3DAdvancedExt } = await loadExtensionsFresh()
|
||||
const node = makePreview3DAdvancedNode({ comfyClass: 'Save3DAdvanced' })
|
||||
getNodeByLocatorIdMock.mockReturnValue(node)
|
||||
|
||||
save3DAdvancedExt.onNodeOutputsUpdated!({
|
||||
'7': { result: ['3d\\ComfyUI_00001.glb'] }
|
||||
} as never)
|
||||
|
||||
expect(node.properties['Last Time Model File']).toBe('3d/ComfyUI_00001.glb')
|
||||
expect(configureForSaveMeshMock).toHaveBeenCalledWith(
|
||||
'output',
|
||||
'3d/ComfyUI_00001.glb',
|
||||
expect.objectContaining({ silentOnNotFound: true })
|
||||
)
|
||||
})
|
||||
|
||||
it('skips nodes whose comfyClass is not Save3DAdvanced', async () => {
|
||||
const { save3DAdvancedExt } = await loadExtensionsFresh()
|
||||
const node = makePreview3DAdvancedNode({ comfyClass: 'Preview3DAdvanced' })
|
||||
getNodeByLocatorIdMock.mockReturnValue(node)
|
||||
|
||||
save3DAdvancedExt.onNodeOutputsUpdated!({
|
||||
'7': { result: ['mesh.glb'] }
|
||||
} as never)
|
||||
|
||||
expect(configureForSaveMeshMock).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Comfy.Preview3DAdvanced.nodeCreated', () => {
|
||||
beforeEach(setupBaseMocks)
|
||||
|
||||
@@ -1032,6 +1075,50 @@ describe('Comfy.Preview3DAdvanced.getNodeMenuItems', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('Comfy.Save3DAdvanced.nodeCreated', () => {
|
||||
beforeEach(setupBaseMocks)
|
||||
|
||||
it('skips nodes whose comfyClass is not Save3DAdvanced', async () => {
|
||||
const { save3DAdvancedExt } = await loadExtensionsFresh()
|
||||
const node = makePreview3DAdvancedNode({ comfyClass: 'Preview3DAdvanced' })
|
||||
|
||||
await save3DAdvancedExt.nodeCreated(node)
|
||||
|
||||
expect(waitForLoad3dMock).not.toHaveBeenCalled()
|
||||
expect(configureForSaveMeshMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('restores persisted models from the output folder, not temp', async () => {
|
||||
const { save3DAdvancedExt } = await loadExtensionsFresh()
|
||||
const node = makePreview3DAdvancedNode({
|
||||
comfyClass: 'Save3DAdvanced',
|
||||
properties: { 'Last Time Model File': '3d/ComfyUI_00001_.glb' }
|
||||
})
|
||||
|
||||
await save3DAdvancedExt.nodeCreated(node)
|
||||
|
||||
expect(configureForSaveMeshMock).toHaveBeenCalledWith(
|
||||
'output',
|
||||
'3d/ComfyUI_00001_.glb',
|
||||
{ silentOnNotFound: true }
|
||||
)
|
||||
})
|
||||
|
||||
it('onExecuted loads the saved file from the output folder', async () => {
|
||||
const { save3DAdvancedExt } = await loadExtensionsFresh()
|
||||
const node = makePreview3DAdvancedNode({ comfyClass: 'Save3DAdvanced' })
|
||||
|
||||
await save3DAdvancedExt.nodeCreated(node)
|
||||
node.onExecuted!({ result: ['3d/ComfyUI_00002_.glb'] })
|
||||
|
||||
expect(configureForSaveMeshMock).toHaveBeenCalledWith(
|
||||
'output',
|
||||
'3d/ComfyUI_00002_.glb',
|
||||
{ silentOnNotFound: true }
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Comfy.Load3D scene widget serializeValue caching', () => {
|
||||
beforeEach(setupBaseMocks)
|
||||
|
||||
|
||||
@@ -15,8 +15,10 @@ import { createExportMenuItems } from '@/extensions/core/load3d/exportMenuHelper
|
||||
import type {
|
||||
CameraConfig,
|
||||
CameraState,
|
||||
LoadFolder,
|
||||
Model3DInfo
|
||||
} from '@/extensions/core/load3d/interfaces'
|
||||
import type Load3d from '@/extensions/core/load3d/Load3d'
|
||||
import Load3DConfiguration from '@/extensions/core/load3d/Load3DConfiguration'
|
||||
import {
|
||||
LOAD3D_NONE_MODEL,
|
||||
@@ -48,6 +50,7 @@ import { ComponentWidgetImpl, addWidget } from '@/scripts/domWidget'
|
||||
import { useExtensionService } from '@/services/extensionService'
|
||||
import { useLoad3dService } from '@/services/load3dService'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
import type { ComfyExtension } from '@/types/comfy'
|
||||
import { isLoad3dNode } from '@/utils/litegraphUtil'
|
||||
|
||||
const inputSpecLoad3D: CustomInputSpec = {
|
||||
@@ -287,8 +290,11 @@ useExtensionService().registerExtension({
|
||||
getCustomWidgets() {
|
||||
const VIEWPORT_STATE_NODES = new Set([
|
||||
'Preview3DAdvanced',
|
||||
'Save3DAdvanced',
|
||||
'PreviewGaussianSplat',
|
||||
'PreviewPointCloud'
|
||||
'PreviewPointCloud',
|
||||
'SaveGaussianSplat',
|
||||
'SavePointCloud'
|
||||
])
|
||||
return {
|
||||
LOAD_3D(node) {
|
||||
@@ -679,155 +685,215 @@ useExtensionService().registerExtension({
|
||||
}
|
||||
})
|
||||
|
||||
useExtensionService().registerExtension({
|
||||
name: 'Comfy.Preview3DAdvanced',
|
||||
function applyPreview3DAdvancedResult(
|
||||
node: LGraphNode,
|
||||
load3d: Load3d,
|
||||
result: NonNullable<Preview3DAdvancedOutput['result']>,
|
||||
loadFolder: LoadFolder,
|
||||
comfyClass: string
|
||||
): void {
|
||||
const filePath = result[0]
|
||||
if (!filePath) return
|
||||
|
||||
getNodeMenuItems(node: LGraphNode): (IContextMenuValue | null)[] {
|
||||
if (node.constructor.comfyClass !== 'Preview3DAdvanced') return []
|
||||
const normalizedPath = filePath.replaceAll('\\', '/')
|
||||
node.properties['Last Time Model File'] = normalizedPath
|
||||
|
||||
const load3d = useLoad3dService().getLoad3d(node)
|
||||
if (!load3d) return []
|
||||
const config = new Load3DConfiguration(load3d, node.properties)
|
||||
config.configureForSaveMesh(loadFolder, normalizedPath, {
|
||||
silentOnNotFound: true
|
||||
})
|
||||
|
||||
if (load3d.isSplatModel()) return []
|
||||
const cameraState = result[1]
|
||||
const modelTransform = result[2]?.[0]
|
||||
if (!cameraState && !modelTransform) return
|
||||
|
||||
return createExportMenuItems(load3d)
|
||||
},
|
||||
const targetGeneration = load3d.currentLoadGeneration
|
||||
void load3d
|
||||
.whenLoadIdle()
|
||||
.then(() => {
|
||||
if (load3d.currentLoadGeneration !== targetGeneration) return
|
||||
if (cameraState) load3d.setCameraState(cameraState)
|
||||
if (modelTransform) load3d.applyModelTransform(modelTransform)
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(
|
||||
`Failed to apply input camera_info / model_3d_info from ${comfyClass}:`,
|
||||
error
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
async nodeCreated(node: LGraphNode) {
|
||||
if (node.constructor.comfyClass !== 'Preview3DAdvanced') return
|
||||
function createPreview3DAdvancedExtension(
|
||||
comfyClass: string,
|
||||
extensionName: string,
|
||||
loadFolder: LoadFolder
|
||||
): ComfyExtension {
|
||||
return {
|
||||
name: extensionName,
|
||||
|
||||
const [oldWidth, oldHeight] = node.size
|
||||
onNodeOutputsUpdated(
|
||||
nodeOutputs: Record<NodeLocatorId, NodeExecutionOutput>
|
||||
) {
|
||||
for (const [locatorId, output] of Object.entries(nodeOutputs)) {
|
||||
const result = (output as Preview3DAdvancedOutput).result
|
||||
if (!result?.[0]) continue
|
||||
|
||||
node.setSize([Math.max(oldWidth, 400), Math.max(oldHeight, 550)])
|
||||
const node = getNodeByLocatorId(app.rootGraph, locatorId)
|
||||
if (!node || node.constructor.comfyClass !== comfyClass) continue
|
||||
|
||||
await nextTick()
|
||||
|
||||
const onExecuted = node.onExecuted
|
||||
|
||||
useLoad3d(node).onLoad3dReady((load3d) => {
|
||||
const lastTimeModelFile = node.properties['Last Time Model File']
|
||||
if (!lastTimeModelFile) return
|
||||
|
||||
const config = new Load3DConfiguration(load3d, node.properties)
|
||||
config.configureForSaveMesh('temp', lastTimeModelFile as string, {
|
||||
silentOnNotFound: true
|
||||
})
|
||||
|
||||
const cameraConfig = node.properties['Camera Config'] as
|
||||
| CameraConfig
|
||||
| undefined
|
||||
const cameraState = cameraConfig?.state
|
||||
if (!cameraState) return
|
||||
|
||||
const targetGeneration = load3d.currentLoadGeneration
|
||||
void load3d
|
||||
.whenLoadIdle()
|
||||
.then(() => {
|
||||
if (load3d.currentLoadGeneration !== targetGeneration) return
|
||||
load3d.setCameraState(cameraState)
|
||||
load3d.forceRender()
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(
|
||||
'Failed to restore camera state for Preview3DAdvanced:',
|
||||
error
|
||||
useLoad3d(node).waitForLoad3d((load3d) => {
|
||||
applyPreview3DAdvancedResult(
|
||||
node,
|
||||
load3d,
|
||||
result,
|
||||
loadFolder,
|
||||
comfyClass
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
useLoad3d(node).waitForLoad3d((load3d) => {
|
||||
const sceneWidget = node.widgets?.find((w) => w.name === 'viewport_state')
|
||||
if (!sceneWidget) return
|
||||
|
||||
const resolveLoad3d = () => nodeToLoad3dMap.get(node) ?? load3d
|
||||
|
||||
const widthWidget = node.widgets?.find((w) => w.name === 'width')
|
||||
const heightWidget = node.widgets?.find((w) => w.name === 'height')
|
||||
if (widthWidget && heightWidget) {
|
||||
load3d.setTargetSize(
|
||||
widthWidget.value as number,
|
||||
heightWidget.value as number
|
||||
)
|
||||
widthWidget.callback = (value: number) => {
|
||||
resolveLoad3d().setTargetSize(value, heightWidget.value as number)
|
||||
}
|
||||
heightWidget.callback = (value: number) => {
|
||||
resolveLoad3d().setTargetSize(widthWidget.value as number, value)
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
sceneWidget.serializeValue = async () => {
|
||||
const currentLoad3d = nodeToLoad3dMap.get(node)
|
||||
if (!currentLoad3d) {
|
||||
console.error('No load3d instance found for node')
|
||||
return null
|
||||
}
|
||||
getNodeMenuItems(node: LGraphNode): (IContextMenuValue | null)[] {
|
||||
if (node.constructor.comfyClass !== comfyClass) return []
|
||||
|
||||
const cameraConfig: CameraConfig = (node.properties['Camera Config'] as
|
||||
| CameraConfig
|
||||
| undefined) || {
|
||||
cameraType: currentLoad3d.getCurrentCameraType(),
|
||||
fov: currentLoad3d.cameraManager.perspectiveCamera.fov
|
||||
}
|
||||
cameraConfig.state = currentLoad3d.getCameraState()
|
||||
node.properties['Camera Config'] = cameraConfig
|
||||
const load3d = useLoad3dService().getLoad3d(node)
|
||||
if (!load3d) return []
|
||||
|
||||
const modelInfo = currentLoad3d.getModelInfo()
|
||||
const model_3d_info: Model3DInfo = modelInfo ? [modelInfo] : []
|
||||
if (load3d.isSplatModel()) return []
|
||||
|
||||
return {
|
||||
image: '',
|
||||
mask: '',
|
||||
normal: '',
|
||||
camera_info: cameraConfig.state || null,
|
||||
recording: '',
|
||||
model_3d_info
|
||||
}
|
||||
}
|
||||
return createExportMenuItems(load3d)
|
||||
},
|
||||
|
||||
node.onExecuted = function (output: Preview3DAdvancedOutput) {
|
||||
onExecuted?.call(this, output)
|
||||
async nodeCreated(node: LGraphNode) {
|
||||
if (node.constructor.comfyClass !== comfyClass) return
|
||||
|
||||
const result = output.result
|
||||
const filePath = result?.[0]
|
||||
const [oldWidth, oldHeight] = node.size
|
||||
|
||||
if (!filePath) {
|
||||
const msg = t('toastMessages.unableToGetModelFilePath')
|
||||
console.error(msg)
|
||||
useToastStore().addAlert(msg)
|
||||
return
|
||||
}
|
||||
node.setSize([Math.max(oldWidth, 400), Math.max(oldHeight, 550)])
|
||||
|
||||
const normalizedPath = filePath.replaceAll('\\', '/')
|
||||
node.properties['Last Time Model File'] = normalizedPath
|
||||
await nextTick()
|
||||
|
||||
const currentLoad3d = resolveLoad3d()
|
||||
const config = new Load3DConfiguration(currentLoad3d, node.properties)
|
||||
config.configureForSaveMesh('temp', normalizedPath, {
|
||||
const onExecuted = node.onExecuted
|
||||
const { onLoad3dReady, waitForLoad3d } = useLoad3d(node)
|
||||
|
||||
onLoad3dReady((load3d) => {
|
||||
const lastTimeModelFile = node.properties['Last Time Model File']
|
||||
if (!lastTimeModelFile) return
|
||||
|
||||
const config = new Load3DConfiguration(load3d, node.properties)
|
||||
config.configureForSaveMesh(loadFolder, lastTimeModelFile as string, {
|
||||
silentOnNotFound: true
|
||||
})
|
||||
|
||||
const cameraState = result?.[1]
|
||||
const modelTransform = result?.[2]?.[0]
|
||||
if (cameraState || modelTransform) {
|
||||
const targetGeneration = currentLoad3d.currentLoadGeneration
|
||||
void currentLoad3d
|
||||
.whenLoadIdle()
|
||||
.then(() => {
|
||||
if (currentLoad3d.currentLoadGeneration !== targetGeneration)
|
||||
return
|
||||
if (cameraState) currentLoad3d.setCameraState(cameraState)
|
||||
if (modelTransform)
|
||||
currentLoad3d.applyModelTransform(modelTransform)
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(
|
||||
'Failed to apply input camera_info / model_3d_info from Preview3DAdvanced:',
|
||||
error
|
||||
)
|
||||
})
|
||||
const cameraConfig = node.properties['Camera Config'] as
|
||||
| CameraConfig
|
||||
| undefined
|
||||
const cameraState = cameraConfig?.state
|
||||
if (!cameraState) return
|
||||
|
||||
const targetGeneration = load3d.currentLoadGeneration
|
||||
void load3d
|
||||
.whenLoadIdle()
|
||||
.then(() => {
|
||||
if (load3d.currentLoadGeneration !== targetGeneration) return
|
||||
load3d.setCameraState(cameraState)
|
||||
load3d.forceRender()
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(
|
||||
`Failed to restore camera state for ${comfyClass}:`,
|
||||
error
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
waitForLoad3d((load3d) => {
|
||||
const sceneWidget = node.widgets?.find(
|
||||
(w) => w.name === 'viewport_state'
|
||||
)
|
||||
if (!sceneWidget) return
|
||||
|
||||
const resolveLoad3d = () => nodeToLoad3dMap.get(node) ?? load3d
|
||||
|
||||
const widthWidget = node.widgets?.find((w) => w.name === 'width')
|
||||
const heightWidget = node.widgets?.find((w) => w.name === 'height')
|
||||
if (widthWidget && heightWidget) {
|
||||
load3d.setTargetSize(
|
||||
widthWidget.value as number,
|
||||
heightWidget.value as number
|
||||
)
|
||||
widthWidget.callback = (value: number) => {
|
||||
resolveLoad3d().setTargetSize(value, heightWidget.value as number)
|
||||
}
|
||||
heightWidget.callback = (value: number) => {
|
||||
resolveLoad3d().setTargetSize(widthWidget.value as number, value)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
sceneWidget.serializeValue = async () => {
|
||||
const currentLoad3d = nodeToLoad3dMap.get(node)
|
||||
if (!currentLoad3d) {
|
||||
console.error('No load3d instance found for node')
|
||||
return null
|
||||
}
|
||||
|
||||
const cameraConfig: CameraConfig = (node.properties[
|
||||
'Camera Config'
|
||||
] as CameraConfig | undefined) || {
|
||||
cameraType: currentLoad3d.getCurrentCameraType(),
|
||||
fov: currentLoad3d.cameraManager.perspectiveCamera.fov
|
||||
}
|
||||
cameraConfig.state = currentLoad3d.getCameraState()
|
||||
node.properties['Camera Config'] = cameraConfig
|
||||
|
||||
const modelInfo = currentLoad3d.getModelInfo()
|
||||
const model_3d_info: Model3DInfo = modelInfo ? [modelInfo] : []
|
||||
|
||||
return {
|
||||
image: '',
|
||||
mask: '',
|
||||
normal: '',
|
||||
camera_info: cameraConfig.state || null,
|
||||
recording: '',
|
||||
model_3d_info
|
||||
}
|
||||
}
|
||||
|
||||
node.onExecuted = function (output: Preview3DAdvancedOutput) {
|
||||
onExecuted?.call(this, output)
|
||||
|
||||
const result = output.result
|
||||
if (!result?.[0]) {
|
||||
const msg = t('toastMessages.unableToGetModelFilePath')
|
||||
console.error(msg)
|
||||
useToastStore().addAlert(msg)
|
||||
return
|
||||
}
|
||||
|
||||
applyPreview3DAdvancedResult(
|
||||
node,
|
||||
resolveLoad3d(),
|
||||
result,
|
||||
loadFolder,
|
||||
comfyClass
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
useExtensionService().registerExtension(
|
||||
createPreview3DAdvancedExtension(
|
||||
'Preview3DAdvanced',
|
||||
'Comfy.Preview3DAdvanced',
|
||||
'temp'
|
||||
)
|
||||
)
|
||||
useExtensionService().registerExtension(
|
||||
createPreview3DAdvancedExtension(
|
||||
'Save3DAdvanced',
|
||||
'Comfy.Save3DAdvanced',
|
||||
'output'
|
||||
)
|
||||
)
|
||||
|
||||
@@ -14,6 +14,7 @@ export type MaterialMode =
|
||||
export type UpDirection = 'original' | '-x' | '+x' | '-y' | '+y' | '-z' | '+z'
|
||||
export type CameraType = 'perspective' | 'orthographic'
|
||||
export type BackgroundRenderModeType = 'tiled' | 'panorama'
|
||||
export type LoadFolder = 'temp' | 'output'
|
||||
|
||||
interface CameraQuaternion {
|
||||
x: number
|
||||
|
||||
@@ -3,21 +3,24 @@
|
||||
* Adding a new node type that uses the viewer = one line change here.
|
||||
*/
|
||||
|
||||
const LOAD3D_PREVIEW_NODES = new Set([
|
||||
const LOAD3D_RESULT_VIEWER_NODES = new Set([
|
||||
'Preview3D',
|
||||
'PreviewGaussianSplat',
|
||||
'PreviewPointCloud'
|
||||
'PreviewPointCloud',
|
||||
'Save3DAdvanced',
|
||||
'SaveGaussianSplat',
|
||||
'SavePointCloud'
|
||||
])
|
||||
|
||||
const LOAD3D_ALL_NODES = new Set([
|
||||
...LOAD3D_PREVIEW_NODES,
|
||||
...LOAD3D_RESULT_VIEWER_NODES,
|
||||
'Load3D',
|
||||
'Load3DAdvanced',
|
||||
'SaveGLB'
|
||||
])
|
||||
|
||||
export const isLoad3dPreviewNode = (nodeType: string): boolean =>
|
||||
LOAD3D_PREVIEW_NODES.has(nodeType)
|
||||
export const isLoad3dResultViewerNode = (nodeType: string): boolean =>
|
||||
LOAD3D_RESULT_VIEWER_NODES.has(nodeType)
|
||||
|
||||
export const isLoad3dNode = (nodeType: string): boolean =>
|
||||
LOAD3D_ALL_NODES.has(nodeType)
|
||||
|
||||
@@ -90,7 +90,10 @@ describe('load3dLazy', () => {
|
||||
'Preview3D',
|
||||
'PreviewGaussianSplat',
|
||||
'PreviewPointCloud',
|
||||
'SaveGLB'
|
||||
'SaveGLB',
|
||||
'Save3DAdvanced',
|
||||
'SaveGaussianSplat',
|
||||
'SavePointCloud'
|
||||
])(
|
||||
'recognizes %s as a 3D node type and triggers the lazy-load path',
|
||||
async (nodeType) => {
|
||||
|
||||
@@ -76,14 +76,24 @@ type ExtCreated = ComfyExtension & {
|
||||
async function loadExtensionsFresh(): Promise<{
|
||||
splatExt: ExtCreated
|
||||
pointCloudExt: ExtCreated
|
||||
saveSplatExt: ExtCreated
|
||||
savePointCloudExt: ExtCreated
|
||||
}> {
|
||||
vi.resetModules()
|
||||
registerExtensionMock.mockClear()
|
||||
await import('@/extensions/core/load3dPreviewExtensions')
|
||||
const [splatCall, pointCloudCall] = registerExtensionMock.mock.calls
|
||||
const extByName = (name: string): ExtCreated => {
|
||||
const call = registerExtensionMock.mock.calls.find(
|
||||
(c) => (c[0] as ExtCreated).name === name
|
||||
)
|
||||
if (!call) throw new Error(`Extension ${name} was not registered`)
|
||||
return call[0] as ExtCreated
|
||||
}
|
||||
return {
|
||||
splatExt: splatCall[0] as ExtCreated,
|
||||
pointCloudExt: pointCloudCall[0] as ExtCreated
|
||||
splatExt: extByName('Comfy.PreviewGaussianSplat'),
|
||||
pointCloudExt: extByName('Comfy.PreviewPointCloud'),
|
||||
saveSplatExt: extByName('Comfy.SaveGaussianSplat'),
|
||||
savePointCloudExt: extByName('Comfy.SavePointCloud')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -92,6 +102,7 @@ interface FakeLoad3d {
|
||||
isSplatModel: ReturnType<typeof vi.fn>
|
||||
forceRender: ReturnType<typeof vi.fn>
|
||||
setCameraState: ReturnType<typeof vi.fn>
|
||||
applyModelTransform: ReturnType<typeof vi.fn>
|
||||
setTargetSize: ReturnType<typeof vi.fn>
|
||||
getCurrentCameraType: ReturnType<typeof vi.fn>
|
||||
getCameraState: ReturnType<typeof vi.fn>
|
||||
@@ -106,6 +117,7 @@ function makeLoad3dMock(): FakeLoad3d {
|
||||
isSplatModel: vi.fn(() => false),
|
||||
forceRender: vi.fn(),
|
||||
setCameraState: vi.fn(),
|
||||
applyModelTransform: vi.fn(),
|
||||
setTargetSize: vi.fn(),
|
||||
getCurrentCameraType: vi.fn(() => 'perspective'),
|
||||
getCameraState: vi.fn(() => ({ position: { x: 0, y: 0, z: 0 } })),
|
||||
@@ -151,12 +163,59 @@ function setupBaseMocks() {
|
||||
describe('load3dPreviewExtensions module registration', () => {
|
||||
beforeEach(setupBaseMocks)
|
||||
|
||||
it('registers both preview extensions on import', async () => {
|
||||
const { splatExt, pointCloudExt } = await loadExtensionsFresh()
|
||||
it('registers preview and save extensions on import', async () => {
|
||||
const { splatExt, pointCloudExt, saveSplatExt, savePointCloudExt } =
|
||||
await loadExtensionsFresh()
|
||||
|
||||
expect(registerExtensionMock).toHaveBeenCalledTimes(2)
|
||||
expect(registerExtensionMock).toHaveBeenCalledTimes(4)
|
||||
expect(splatExt.name).toBe('Comfy.PreviewGaussianSplat')
|
||||
expect(pointCloudExt.name).toBe('Comfy.PreviewPointCloud')
|
||||
expect(saveSplatExt.name).toBe('Comfy.SaveGaussianSplat')
|
||||
expect(savePointCloudExt.name).toBe('Comfy.SavePointCloud')
|
||||
})
|
||||
|
||||
it('save extensions load the saved file from the output folder, not temp', async () => {
|
||||
const { saveSplatExt, savePointCloudExt } = await loadExtensionsFresh()
|
||||
const load3d = makeLoad3dMock()
|
||||
waitForLoad3dMock.mockImplementation((cb: (l: FakeLoad3d) => void) =>
|
||||
cb(load3d)
|
||||
)
|
||||
|
||||
const splatNode = makePreviewNode({ comfyClass: 'SaveGaussianSplat' })
|
||||
await saveSplatExt.nodeCreated(splatNode)
|
||||
splatNode.onExecuted!({ result: ['3d/ComfyUI_00001_.ply'] })
|
||||
|
||||
expect(configureForSaveMeshMock).toHaveBeenLastCalledWith(
|
||||
'output',
|
||||
'3d/ComfyUI_00001_.ply',
|
||||
expect.objectContaining({ silentOnNotFound: true })
|
||||
)
|
||||
|
||||
const pcNode = makePreviewNode({ comfyClass: 'SavePointCloud' })
|
||||
await savePointCloudExt.nodeCreated(pcNode)
|
||||
pcNode.onExecuted!({ result: ['3d/ComfyUI_00002_.ply'] })
|
||||
|
||||
expect(configureForSaveMeshMock).toHaveBeenLastCalledWith(
|
||||
'output',
|
||||
'3d/ComfyUI_00002_.ply',
|
||||
expect.objectContaining({ silentOnNotFound: true })
|
||||
)
|
||||
})
|
||||
|
||||
it('restores persisted models from the output folder on nodeCreated, not temp', async () => {
|
||||
const { saveSplatExt } = await loadExtensionsFresh()
|
||||
const node = makePreviewNode({
|
||||
comfyClass: 'SaveGaussianSplat',
|
||||
properties: { 'Last Time Model File': '3d/ComfyUI_00001_.ply' }
|
||||
})
|
||||
|
||||
await saveSplatExt.nodeCreated(node)
|
||||
|
||||
expect(configureForSaveMeshMock).toHaveBeenCalledWith(
|
||||
'output',
|
||||
'3d/ComfyUI_00001_.ply',
|
||||
expect.objectContaining({ silentOnNotFound: true })
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -214,6 +273,44 @@ describe('Comfy.PreviewGaussianSplat.nodeCreated', () => {
|
||||
expect(cameraConfig?.state).toEqual(cameraState)
|
||||
})
|
||||
|
||||
it('applies onExecuted results to the remounted instance, not the disposed closure', async () => {
|
||||
const { splatExt } = await loadExtensionsFresh()
|
||||
const original = makeLoad3dMock()
|
||||
waitForLoad3dMock.mockImplementation((cb: (l: FakeLoad3d) => void) =>
|
||||
cb(original)
|
||||
)
|
||||
const node = makePreviewNode()
|
||||
|
||||
await splatExt.nodeCreated(node)
|
||||
|
||||
const remounted = makeLoad3dMock()
|
||||
nodeToLoad3dMapMock.set(node, remounted)
|
||||
|
||||
node.onExecuted!({
|
||||
result: ['scene.ply', { position: { x: 1, y: 2, z: 3 } }]
|
||||
})
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
|
||||
expect(remounted.forceRender).toHaveBeenCalled()
|
||||
expect(original.forceRender).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('re-applies the model transform from result[2] on execute', async () => {
|
||||
const { saveSplatExt } = await loadExtensionsFresh()
|
||||
const load3d = makeLoad3dMock()
|
||||
waitForLoad3dMock.mockImplementation((cb: (l: FakeLoad3d) => void) =>
|
||||
cb(load3d)
|
||||
)
|
||||
const node = makePreviewNode({ comfyClass: 'SaveGaussianSplat' })
|
||||
const transform = { position: { x: 1, y: 2, z: 3 } }
|
||||
|
||||
await saveSplatExt.nodeCreated(node)
|
||||
node.onExecuted!({ result: ['scene.ply', undefined, [transform]] })
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
|
||||
expect(load3d.applyModelTransform).toHaveBeenCalledWith(transform)
|
||||
})
|
||||
|
||||
it('syncs width/height widgets to load3d.setTargetSize and registers callbacks', async () => {
|
||||
const { splatExt } = await loadExtensionsFresh()
|
||||
const load3d = makeLoad3dMock()
|
||||
|
||||
@@ -5,6 +5,7 @@ import { createExportMenuItems } from '@/extensions/core/load3d/exportMenuHelper
|
||||
import type {
|
||||
CameraConfig,
|
||||
CameraState,
|
||||
LoadFolder,
|
||||
Model3DInfo
|
||||
} from '@/extensions/core/load3d/interfaces'
|
||||
import type Load3d from '@/extensions/core/load3d/Load3d'
|
||||
@@ -29,7 +30,9 @@ function applyResultToLoad3d(
|
||||
node: LGraphNode,
|
||||
load3d: Load3d,
|
||||
filePath: string,
|
||||
cameraState: CameraState | undefined
|
||||
cameraState: CameraState | undefined,
|
||||
modelTransform: Model3DInfo[number] | undefined,
|
||||
loadFolder: LoadFolder
|
||||
): void {
|
||||
const normalizedPath = filePath.replaceAll('\\', '/')
|
||||
node.properties['Last Time Model File'] = normalizedPath
|
||||
@@ -46,7 +49,7 @@ function applyResultToLoad3d(
|
||||
}
|
||||
|
||||
const config = new Load3DConfiguration(load3d, node.properties)
|
||||
config.configureForSaveMesh('temp', normalizedPath, {
|
||||
config.configureForSaveMesh(loadFolder, normalizedPath, {
|
||||
silentOnNotFound: true
|
||||
})
|
||||
|
||||
@@ -54,13 +57,15 @@ function applyResultToLoad3d(
|
||||
void load3d.whenLoadIdle().then(() => {
|
||||
if (load3d.currentLoadGeneration !== targetGeneration) return
|
||||
if (cameraState) load3d.setCameraState(cameraState)
|
||||
if (modelTransform) load3d.applyModelTransform(modelTransform)
|
||||
load3d.forceRender()
|
||||
})
|
||||
}
|
||||
|
||||
function createPreview3DExtension(
|
||||
comfyClass: string,
|
||||
extensionName: string
|
||||
extensionName: string,
|
||||
loadFolder: LoadFolder
|
||||
): ComfyExtension {
|
||||
const applyPreviewOutput = (
|
||||
node: LGraphNode,
|
||||
@@ -68,10 +73,18 @@ function createPreview3DExtension(
|
||||
): void => {
|
||||
const filePath = result[0]
|
||||
const cameraState = result[1]
|
||||
const modelTransform = result[2]?.[0]
|
||||
if (!filePath) return
|
||||
|
||||
useLoad3d(node).waitForLoad3d((load3d) => {
|
||||
applyResultToLoad3d(node, load3d, filePath, cameraState)
|
||||
applyResultToLoad3d(
|
||||
node,
|
||||
load3d,
|
||||
filePath,
|
||||
cameraState,
|
||||
modelTransform,
|
||||
loadFolder
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -119,7 +132,7 @@ function createPreview3DExtension(
|
||||
if (!lastTimeModelFile) return
|
||||
|
||||
const config = new Load3DConfiguration(load3d, node.properties)
|
||||
config.configureForSaveMesh('temp', lastTimeModelFile as string, {
|
||||
config.configureForSaveMesh(loadFolder, lastTimeModelFile as string, {
|
||||
silentOnNotFound: true
|
||||
})
|
||||
|
||||
@@ -136,6 +149,8 @@ function createPreview3DExtension(
|
||||
})
|
||||
|
||||
waitForLoad3d((load3d) => {
|
||||
const resolveLoad3d = () => nodeToLoad3dMap.get(node) ?? load3d
|
||||
|
||||
const sceneWidget = node.widgets?.find(
|
||||
(w) => w.name === 'viewport_state'
|
||||
)
|
||||
@@ -148,10 +163,10 @@ function createPreview3DExtension(
|
||||
heightWidget.value as number
|
||||
)
|
||||
widthWidget.callback = (value: number) => {
|
||||
load3d.setTargetSize(value, heightWidget.value as number)
|
||||
resolveLoad3d().setTargetSize(value, heightWidget.value as number)
|
||||
}
|
||||
heightWidget.callback = (value: number) => {
|
||||
load3d.setTargetSize(widthWidget.value as number, value)
|
||||
resolveLoad3d().setTargetSize(widthWidget.value as number, value)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -199,7 +214,14 @@ function createPreview3DExtension(
|
||||
return
|
||||
}
|
||||
|
||||
applyResultToLoad3d(node, load3d, filePath, result?.[1])
|
||||
applyResultToLoad3d(
|
||||
node,
|
||||
resolveLoad3d(),
|
||||
filePath,
|
||||
result?.[1],
|
||||
result?.[2]?.[0],
|
||||
loadFolder
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -207,8 +229,26 @@ function createPreview3DExtension(
|
||||
}
|
||||
|
||||
useExtensionService().registerExtension(
|
||||
createPreview3DExtension('PreviewGaussianSplat', 'Comfy.PreviewGaussianSplat')
|
||||
createPreview3DExtension(
|
||||
'PreviewGaussianSplat',
|
||||
'Comfy.PreviewGaussianSplat',
|
||||
'temp'
|
||||
)
|
||||
)
|
||||
useExtensionService().registerExtension(
|
||||
createPreview3DExtension('PreviewPointCloud', 'Comfy.PreviewPointCloud')
|
||||
createPreview3DExtension(
|
||||
'PreviewPointCloud',
|
||||
'Comfy.PreviewPointCloud',
|
||||
'temp'
|
||||
)
|
||||
)
|
||||
useExtensionService().registerExtension(
|
||||
createPreview3DExtension(
|
||||
'SaveGaussianSplat',
|
||||
'Comfy.SaveGaussianSplat',
|
||||
'output'
|
||||
)
|
||||
)
|
||||
useExtensionService().registerExtension(
|
||||
createPreview3DExtension('SavePointCloud', 'Comfy.SavePointCloud', 'output')
|
||||
)
|
||||
|
||||
@@ -1,6 +1,17 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { ComfyExtension } from '@/types/comfy'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
|
||||
const { addTextPreviewWidgets, updateTextPreviewWidgets } = vi.hoisted(() => ({
|
||||
addTextPreviewWidgets: vi.fn(),
|
||||
updateTextPreviewWidgets: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/extensions/core/textPreviewWidgets', () => ({
|
||||
addTextPreviewWidgets,
|
||||
updateTextPreviewWidgets
|
||||
}))
|
||||
|
||||
const capturedExtensions: ComfyExtension[] = []
|
||||
|
||||
@@ -12,103 +23,51 @@ vi.mock('@/services/extensionService', () => ({
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/scripts/app', () => ({ app: {} }))
|
||||
type BeforeRegister = NonNullable<ComfyExtension['beforeRegisterNodeDef']>
|
||||
|
||||
interface MockWidget {
|
||||
name: string
|
||||
options: Record<string, unknown>
|
||||
element: { readOnly: boolean }
|
||||
callback?: (value: unknown) => void
|
||||
value: unknown
|
||||
hidden: boolean
|
||||
label: string
|
||||
serialize?: boolean
|
||||
}
|
||||
async function setupNode() {
|
||||
const ext = capturedExtensions.find((e) => e.name === 'Comfy.PreviewAny')
|
||||
expect(ext).toBeDefined()
|
||||
|
||||
const createdWidgets: MockWidget[] = []
|
||||
const nodeType = { prototype: {} } as unknown as Parameters<BeforeRegister>[0]
|
||||
const nodeData = { name: 'PreviewAny' } as Parameters<BeforeRegister>[1]
|
||||
await ext!.beforeRegisterNodeDef!(
|
||||
nodeType,
|
||||
nodeData,
|
||||
{} as Parameters<BeforeRegister>[2]
|
||||
)
|
||||
|
||||
vi.mock('@/scripts/widgets', () => {
|
||||
const create =
|
||||
(kind: string) =>
|
||||
(
|
||||
node: { widgets?: MockWidget[] },
|
||||
name: string,
|
||||
_info: unknown,
|
||||
_app: unknown
|
||||
) => {
|
||||
const widget: MockWidget = {
|
||||
name,
|
||||
options: {},
|
||||
element: { readOnly: false },
|
||||
value: kind === 'BOOLEAN' ? false : '',
|
||||
hidden: false,
|
||||
label: ''
|
||||
}
|
||||
node.widgets = node.widgets ?? []
|
||||
node.widgets.push(widget)
|
||||
createdWidgets.push(widget)
|
||||
return { widget }
|
||||
}
|
||||
return {
|
||||
ComfyWidgets: {
|
||||
MARKDOWN: create('MARKDOWN'),
|
||||
STRING: create('STRING'),
|
||||
BOOLEAN: create('BOOLEAN')
|
||||
}
|
||||
const node = {} as LGraphNode
|
||||
const proto = nodeType.prototype as {
|
||||
onNodeCreated?: () => void
|
||||
onExecuted?: (message: { text?: string }) => void
|
||||
}
|
||||
})
|
||||
return { node, proto }
|
||||
}
|
||||
|
||||
describe('PreviewAny extension', () => {
|
||||
beforeEach(async () => {
|
||||
capturedExtensions.length = 0
|
||||
createdWidgets.length = 0
|
||||
addTextPreviewWidgets.mockClear()
|
||||
updateTextPreviewWidgets.mockClear()
|
||||
vi.resetModules()
|
||||
await import('./previewAny')
|
||||
})
|
||||
|
||||
async function setupNode() {
|
||||
const ext = capturedExtensions.find((e) => e.name === 'Comfy.PreviewAny')
|
||||
expect(ext).toBeDefined()
|
||||
it('adds the shared text preview widgets on node creation', async () => {
|
||||
const { node, proto } = await setupNode()
|
||||
|
||||
const nodeType = { prototype: {} } as unknown as Parameters<
|
||||
NonNullable<ComfyExtension['beforeRegisterNodeDef']>
|
||||
>[0]
|
||||
const nodeData = { name: 'PreviewAny' } as Parameters<
|
||||
NonNullable<ComfyExtension['beforeRegisterNodeDef']>
|
||||
>[1]
|
||||
|
||||
await ext!.beforeRegisterNodeDef!(
|
||||
nodeType,
|
||||
nodeData,
|
||||
{} as Parameters<NonNullable<ComfyExtension['beforeRegisterNodeDef']>>[2]
|
||||
)
|
||||
|
||||
const node: { widgets?: MockWidget[] } = {}
|
||||
const proto = nodeType.prototype as { onNodeCreated?: () => void }
|
||||
proto.onNodeCreated!.call(node)
|
||||
return node
|
||||
}
|
||||
|
||||
it('excludes preview widgets from the API prompt to prevent re-execution', async () => {
|
||||
await setupNode()
|
||||
expect(addTextPreviewWidgets).toHaveBeenCalledWith(node)
|
||||
})
|
||||
|
||||
const previewMarkdown = createdWidgets.find(
|
||||
(w) => w.name === 'preview_markdown'
|
||||
)
|
||||
const previewText = createdWidgets.find((w) => w.name === 'preview_text')
|
||||
const previewMode = createdWidgets.find((w) => w.name === 'previewMode')
|
||||
it('updates the preview with executed text', async () => {
|
||||
const { node, proto } = await setupNode()
|
||||
const message = { text: 'hello' }
|
||||
|
||||
expect(previewMarkdown).toBeDefined()
|
||||
expect(previewText).toBeDefined()
|
||||
expect(previewMode).toBeDefined()
|
||||
proto.onExecuted!.call(node, message)
|
||||
|
||||
// widget.options.serialize === false is what executionUtil.graphToPrompt
|
||||
// checks to exclude a widget from the API prompt sent to the backend.
|
||||
// Without this, post-execution widget value updates (the rendered preview
|
||||
// text) get serialized as inputs, change the cache signature, and cause
|
||||
// the node to re-execute on the next prompt.
|
||||
expect(previewMarkdown!.options.serialize).toBe(false)
|
||||
expect(previewText!.options.serialize).toBe(false)
|
||||
expect(previewMode!.options.serialize).toBe(false)
|
||||
expect(updateTextPreviewWidgets).toHaveBeenCalledWith(node, message)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -4,11 +4,14 @@ https://github.com/rgthree/rgthree-comfy/blob/main/py/display_any.py
|
||||
upstream requested in https://github.com/Kosinkadink/rfcs/blob/main/rfcs/0000-corenodes.md#preview-nodes
|
||||
*/
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
import {
|
||||
addTextPreviewWidgets,
|
||||
updateTextPreviewWidgets
|
||||
} from '@/extensions/core/textPreviewWidgets'
|
||||
import type { ComfyNodeDef } from '@/schemas/nodeDefSchema'
|
||||
import { app } from '@/scripts/app'
|
||||
import { type DOMWidget } from '@/scripts/domWidget'
|
||||
import { ComfyWidgets } from '@/scripts/widgets'
|
||||
import { useExtensionService } from '@/services/extensionService'
|
||||
import { getNodeByLocatorId } from '@/utils/graphTraversalUtil'
|
||||
|
||||
useExtensionService().registerExtension({
|
||||
name: 'Comfy.PreviewAny',
|
||||
@@ -16,82 +19,24 @@ useExtensionService().registerExtension({
|
||||
nodeType: typeof LGraphNode,
|
||||
nodeData: ComfyNodeDef
|
||||
) {
|
||||
if (nodeData.name === 'PreviewAny') {
|
||||
const onNodeCreated = nodeType.prototype.onNodeCreated
|
||||
if (nodeData.name !== 'PreviewAny') return
|
||||
|
||||
nodeType.prototype.onNodeCreated = function () {
|
||||
onNodeCreated ? onNodeCreated.apply(this, []) : undefined
|
||||
const onNodeCreated = nodeType.prototype.onNodeCreated
|
||||
nodeType.prototype.onNodeCreated = function () {
|
||||
onNodeCreated?.apply(this, [])
|
||||
addTextPreviewWidgets(this)
|
||||
}
|
||||
|
||||
const showValueWidget = ComfyWidgets['MARKDOWN'](
|
||||
this,
|
||||
'preview_markdown',
|
||||
['MARKDOWN', {}],
|
||||
app
|
||||
).widget as DOMWidget<HTMLTextAreaElement, string>
|
||||
|
||||
const showValueWidgetPlain = ComfyWidgets['STRING'](
|
||||
this,
|
||||
'preview_text',
|
||||
['STRING', { multiline: true }],
|
||||
app
|
||||
).widget as DOMWidget<HTMLTextAreaElement, string>
|
||||
|
||||
const showAsPlaintextWidget = ComfyWidgets['BOOLEAN'](
|
||||
this,
|
||||
'previewMode',
|
||||
[
|
||||
'BOOLEAN',
|
||||
{ label_on: 'Markdown', label_off: 'Plaintext', default: false }
|
||||
],
|
||||
app
|
||||
)
|
||||
|
||||
showAsPlaintextWidget.widget.callback = (value: boolean) => {
|
||||
showValueWidget.hidden = !value
|
||||
showValueWidget.options.hidden = !value
|
||||
showValueWidgetPlain.hidden = value
|
||||
showValueWidgetPlain.options.hidden = value
|
||||
}
|
||||
|
||||
showValueWidget.label = 'Preview'
|
||||
showValueWidget.hidden = true
|
||||
showValueWidget.options.hidden = true
|
||||
showValueWidget.options.read_only = true
|
||||
showValueWidget.options.serialize = false
|
||||
showValueWidget.element.readOnly = true
|
||||
showValueWidget.serialize = false
|
||||
|
||||
showValueWidgetPlain.label = 'Preview'
|
||||
showValueWidgetPlain.hidden = false
|
||||
showValueWidgetPlain.options.hidden = false
|
||||
showValueWidgetPlain.options.read_only = true
|
||||
showValueWidgetPlain.options.serialize = false
|
||||
showValueWidgetPlain.element.readOnly = true
|
||||
showValueWidgetPlain.serialize = false
|
||||
|
||||
// The previewMode toggle is a frontend-only display preference and
|
||||
// is not declared in the backend INPUT_TYPES, so it must not be
|
||||
// serialized into the API prompt (would alter the cache signature).
|
||||
showAsPlaintextWidget.widget.options.serialize = false
|
||||
}
|
||||
|
||||
const onExecuted = nodeType.prototype.onExecuted
|
||||
|
||||
nodeType.prototype.onExecuted = function (message) {
|
||||
onExecuted === null || onExecuted === void 0
|
||||
? void 0
|
||||
: onExecuted.apply(this, [message])
|
||||
|
||||
const previewWidgets =
|
||||
this.widgets?.filter((w) => w.name.startsWith('preview_')) ?? []
|
||||
|
||||
for (const previewWidget of previewWidgets) {
|
||||
const text = message.text ?? ''
|
||||
previewWidget.value = Array.isArray(text)
|
||||
? (text?.join('\n\n') ?? '')
|
||||
: text
|
||||
}
|
||||
}
|
||||
const onExecuted = nodeType.prototype.onExecuted
|
||||
nodeType.prototype.onExecuted = function (message) {
|
||||
onExecuted?.apply(this, [message])
|
||||
updateTextPreviewWidgets(this, message)
|
||||
}
|
||||
},
|
||||
onNodeOutputsUpdated(nodeOutputs) {
|
||||
for (const [nodeLocatorId, output] of Object.entries(nodeOutputs)) {
|
||||
const node = getNodeByLocatorId(app.rootGraph, nodeLocatorId)
|
||||
if (node?.type === 'PreviewAny') updateTextPreviewWidgets(node, output)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -81,6 +81,9 @@ describe('Comfy.SaveImageExtraOutput', () => {
|
||||
'SaveAudioOpus',
|
||||
'SaveAudioAdvanced',
|
||||
'SaveGLB',
|
||||
'Save3DAdvanced',
|
||||
'SaveGaussianSplat',
|
||||
'SavePointCloud',
|
||||
'SaveAnimatedPNG',
|
||||
'CLIPSave',
|
||||
'VAESave',
|
||||
|
||||
@@ -16,6 +16,9 @@ const saveNodeTypes = new Set([
|
||||
'SaveAudioOpus',
|
||||
'SaveAudioAdvanced',
|
||||
'SaveGLB',
|
||||
'Save3DAdvanced',
|
||||
'SaveGaussianSplat',
|
||||
'SavePointCloud',
|
||||
'SaveAnimatedPNG',
|
||||
'CLIPSave',
|
||||
'VAESave',
|
||||
|
||||
24
src/extensions/core/saveText.ts
Normal file
24
src/extensions/core/saveText.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import {
|
||||
addTextPreviewWidgets,
|
||||
updateTextPreviewWidgets
|
||||
} from '@/extensions/core/textPreviewWidgets'
|
||||
import { useExtensionService } from '@/services/extensionService'
|
||||
|
||||
useExtensionService().registerExtension({
|
||||
name: 'Comfy.saveText',
|
||||
async beforeRegisterNodeDef(nodeType, nodeData) {
|
||||
if (nodeData.name !== 'SaveText') return
|
||||
|
||||
const onNodeCreated = nodeType.prototype.onNodeCreated
|
||||
nodeType.prototype.onNodeCreated = function () {
|
||||
onNodeCreated?.apply(this, [])
|
||||
addTextPreviewWidgets(this)
|
||||
}
|
||||
|
||||
const onExecuted = nodeType.prototype.onExecuted
|
||||
nodeType.prototype.onExecuted = function (message) {
|
||||
onExecuted?.apply(this, [message])
|
||||
updateTextPreviewWidgets(this, message)
|
||||
}
|
||||
}
|
||||
})
|
||||
103
src/extensions/core/textPreviewWidgets.test.ts
Normal file
103
src/extensions/core/textPreviewWidgets.test.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
|
||||
interface MockWidget {
|
||||
name: string
|
||||
options: Record<string, unknown>
|
||||
value?: unknown
|
||||
serialize?: boolean
|
||||
}
|
||||
|
||||
vi.mock('@/scripts/app', () => ({ app: { rootGraph: { id: 'graph-1' } } }))
|
||||
|
||||
vi.mock('@/lib/litegraph/src/litegraph', () => ({
|
||||
resolveNodeRootGraphId: () => 'graph-1'
|
||||
}))
|
||||
|
||||
vi.mock(
|
||||
'@/renderer/extensions/vueNodes/widgets/components/WidgetTextPreview.vue',
|
||||
() => ({
|
||||
default: {}
|
||||
})
|
||||
)
|
||||
|
||||
vi.mock('@/stores/widgetValueStore', () => ({
|
||||
useWidgetValueStore: () => ({ getWidget: () => undefined })
|
||||
}))
|
||||
|
||||
vi.mock('@/scripts/domWidget', () => ({
|
||||
ComponentWidgetImpl: class {
|
||||
name: string
|
||||
options: Record<string, unknown>
|
||||
type: string
|
||||
serialize?: boolean
|
||||
constructor(obj: {
|
||||
name: string
|
||||
options: Record<string, unknown>
|
||||
type: string
|
||||
}) {
|
||||
this.name = obj.name
|
||||
this.options = obj.options
|
||||
this.type = obj.type
|
||||
}
|
||||
},
|
||||
addWidget: (node: { widgets?: MockWidget[] }, widget: MockWidget) => {
|
||||
node.widgets = node.widgets ?? []
|
||||
node.widgets.push(widget)
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/scripts/widgets', () => ({
|
||||
ComfyWidgets: {
|
||||
BOOLEAN: (node: { widgets?: MockWidget[] }, name: string) => {
|
||||
const widget: MockWidget = { name, options: {}, value: false }
|
||||
node.widgets = node.widgets ?? []
|
||||
node.widgets.push(widget)
|
||||
return { widget }
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
const { addTextPreviewWidgets, updateTextPreviewWidgets } =
|
||||
await import('./textPreviewWidgets')
|
||||
|
||||
function makeNode(): LGraphNode & { widgets: MockWidget[] } {
|
||||
return { id: '1', widgets: [] } as unknown as LGraphNode & {
|
||||
widgets: MockWidget[]
|
||||
}
|
||||
}
|
||||
|
||||
describe('addTextPreviewWidgets', () => {
|
||||
it('adds a non-serialized preview widget and a non-serialized mode toggle', () => {
|
||||
const node = makeNode()
|
||||
addTextPreviewWidgets(node)
|
||||
|
||||
const preview = node.widgets.find((w) => w.name === 'preview_text')
|
||||
const mode = node.widgets.find((w) => w.name === 'preview_mode')
|
||||
|
||||
expect(preview?.type).toBe('textPreview')
|
||||
expect(preview?.serialize).toBe(false)
|
||||
expect(preview?.options.serialize).toBe(false)
|
||||
expect(mode?.options.serialize).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('updateTextPreviewWidgets', () => {
|
||||
let node: LGraphNode & { widgets: MockWidget[] }
|
||||
|
||||
beforeEach(() => {
|
||||
node = makeNode()
|
||||
node.widgets.push({ name: 'preview_text', options: {}, value: '' })
|
||||
})
|
||||
|
||||
it('joins array text into the preview widget value', () => {
|
||||
updateTextPreviewWidgets(node, { text: ['a', 'b'] })
|
||||
expect(node.widgets[0].value).toBe('a\n\nb')
|
||||
})
|
||||
|
||||
it('writes a plain string message as-is', () => {
|
||||
updateTextPreviewWidgets(node, { text: 'hello' })
|
||||
expect(node.widgets[0].value).toBe('hello')
|
||||
})
|
||||
})
|
||||
78
src/extensions/core/textPreviewWidgets.ts
Normal file
78
src/extensions/core/textPreviewWidgets.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
import { resolveNodeRootGraphId } from '@/lib/litegraph/src/litegraph'
|
||||
import WidgetTextPreview from '@/renderer/extensions/vueNodes/widgets/components/WidgetTextPreview.vue'
|
||||
import type { CustomInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
|
||||
import { app } from '@/scripts/app'
|
||||
import { ComponentWidgetImpl, addWidget } from '@/scripts/domWidget'
|
||||
import { ComfyWidgets } from '@/scripts/widgets'
|
||||
import { useWidgetValueStore } from '@/stores/widgetValueStore'
|
||||
import { widgetId } from '@/types/widgetId'
|
||||
|
||||
const PREVIEW_WIDGET_NAME = 'preview_text'
|
||||
const MODE_WIDGET_NAME = 'preview_mode'
|
||||
|
||||
const inputSpecTextPreview: CustomInputSpec = {
|
||||
name: PREVIEW_WIDGET_NAME,
|
||||
type: 'TEXT_PREVIEW',
|
||||
isPreview: true
|
||||
}
|
||||
|
||||
export function addTextPreviewWidgets(node: LGraphNode) {
|
||||
const widgetStore = useWidgetValueStore()
|
||||
let fallbackValue = ''
|
||||
|
||||
const previewWidgetId = () =>
|
||||
widgetId(
|
||||
resolveNodeRootGraphId(node, app.rootGraph.id),
|
||||
node.id,
|
||||
PREVIEW_WIDGET_NAME
|
||||
)
|
||||
|
||||
const preview = new ComponentWidgetImpl<string | object>({
|
||||
node,
|
||||
name: PREVIEW_WIDGET_NAME,
|
||||
component: WidgetTextPreview,
|
||||
inputSpec: inputSpecTextPreview,
|
||||
type: 'textPreview',
|
||||
options: {
|
||||
serialize: false,
|
||||
hideInPanel: true,
|
||||
getMinHeight: () => 60,
|
||||
getValue: () => {
|
||||
const stored = widgetStore.getWidget(previewWidgetId())?.value
|
||||
return typeof stored === 'string' ? stored : fallbackValue
|
||||
},
|
||||
setValue: (value: string | object) => {
|
||||
fallbackValue = typeof value === 'string' ? value : ''
|
||||
const state = widgetStore.getWidget(previewWidgetId())
|
||||
if (state) state.value = fallbackValue
|
||||
}
|
||||
}
|
||||
})
|
||||
preview.serialize = false
|
||||
addWidget(node, preview)
|
||||
|
||||
const modeWidget = ComfyWidgets['BOOLEAN'](
|
||||
node,
|
||||
MODE_WIDGET_NAME,
|
||||
[
|
||||
'BOOLEAN',
|
||||
{ label_on: 'Markdown', label_off: 'Plain text', default: false }
|
||||
],
|
||||
app
|
||||
).widget
|
||||
|
||||
modeWidget.options.serialize = false
|
||||
modeWidget.serialize = false
|
||||
}
|
||||
|
||||
export function updateTextPreviewWidgets(
|
||||
node: LGraphNode,
|
||||
message: { text?: string | string[] }
|
||||
) {
|
||||
const preview = node.widgets?.find((w) => w.name === PREVIEW_WIDGET_NAME)
|
||||
if (!preview) return
|
||||
|
||||
const text = message.text ?? ''
|
||||
preview.value = Array.isArray(text) ? text.join('\n\n') : text
|
||||
}
|
||||
80
src/i18n.safeTranslation.test.ts
Normal file
80
src/i18n.safeTranslation.test.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
|
||||
import { i18n, st, stRaw } from './i18n'
|
||||
|
||||
const TEST_NAMESPACE = 'safeTranslationTest'
|
||||
|
||||
beforeEach(() => {
|
||||
i18n.global.locale.value = 'en'
|
||||
const messages = i18n.global.getLocaleMessage('en')
|
||||
delete (messages as Record<string, unknown>)[TEST_NAMESPACE]
|
||||
i18n.global.setLocaleMessage('en', messages)
|
||||
})
|
||||
|
||||
describe('st', () => {
|
||||
it('returns the fallback when the key is not found', () => {
|
||||
expect(st('safeTranslationTest.missing', 'Fallback value')).toBe(
|
||||
'Fallback value'
|
||||
)
|
||||
})
|
||||
|
||||
it('uses compiled translations for valid locale messages', () => {
|
||||
i18n.global.mergeLocaleMessage('en', {
|
||||
safeTranslationTest: {
|
||||
valid: 'Translated value'
|
||||
}
|
||||
})
|
||||
|
||||
expect(st('safeTranslationTest.valid', 'Fallback value')).toBe(
|
||||
'Translated value'
|
||||
)
|
||||
})
|
||||
|
||||
it('returns raw locale messages when vue-i18n compilation fails', () => {
|
||||
const message = 'Provided by @acme/model with JSON such as {"mode":"fast"}'
|
||||
|
||||
i18n.global.mergeLocaleMessage('en', {
|
||||
safeTranslationTest: {
|
||||
invalidLinkedFormat: message
|
||||
}
|
||||
})
|
||||
|
||||
expect(
|
||||
st('safeTranslationTest.invalidLinkedFormat', 'Fallback value')
|
||||
).toBe(message)
|
||||
})
|
||||
})
|
||||
|
||||
describe('stRaw', () => {
|
||||
it('returns raw locale messages for valid keys', () => {
|
||||
i18n.global.mergeLocaleMessage('en', {
|
||||
safeTranslationTest: {
|
||||
rawValue: 'Raw value'
|
||||
}
|
||||
})
|
||||
|
||||
expect(stRaw('safeTranslationTest.rawValue', 'Fallback value')).toBe(
|
||||
'Raw value'
|
||||
)
|
||||
})
|
||||
|
||||
it('returns raw messages containing vue-i18n syntax', () => {
|
||||
const message = 'Provided by @acme/model with JSON such as {"mode":"fast"}'
|
||||
|
||||
i18n.global.mergeLocaleMessage('en', {
|
||||
safeTranslationTest: {
|
||||
rawSyntax: message
|
||||
}
|
||||
})
|
||||
|
||||
expect(stRaw('safeTranslationTest.rawSyntax', 'Fallback value')).toBe(
|
||||
message
|
||||
)
|
||||
})
|
||||
|
||||
it('returns the fallback when the key is not found', () => {
|
||||
expect(stRaw('safeTranslationTest.rawMissing', 'Fallback value')).toBe(
|
||||
'Fallback value'
|
||||
)
|
||||
})
|
||||
})
|
||||
20
src/i18n.ts
20
src/i18n.ts
@@ -159,15 +159,28 @@ export const te: (typeof i18n.global)['te'] = i18n.global.te
|
||||
export const d: (typeof i18n.global)['d'] = i18n.global.d
|
||||
const tm = i18n.global.tm
|
||||
|
||||
function rawTranslationOrFallback(key: string, fallbackMessage: string) {
|
||||
const message = tm(key)
|
||||
return typeof message === 'string' ? message : fallbackMessage
|
||||
}
|
||||
|
||||
/**
|
||||
* Safe translation function that returns the fallback message if the key is not found.
|
||||
* Invalid message syntax falls back to the raw locale message instead of crashing.
|
||||
*
|
||||
* @param key - The key to translate.
|
||||
* @param fallbackMessage - The fallback message to use if the key is not found.
|
||||
*/
|
||||
export function st(key: string, fallbackMessage: string) {
|
||||
// The normal defaultMsg overload fails in some cases for custom nodes
|
||||
return te(key) ? t(key) : fallbackMessage
|
||||
if (!te(key)) return fallbackMessage
|
||||
|
||||
try {
|
||||
// The normal defaultMsg overload fails in some cases for custom nodes
|
||||
return t(key)
|
||||
} catch (error) {
|
||||
if (!(error instanceof SyntaxError)) throw error
|
||||
return rawTranslationOrFallback(key, fallbackMessage)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -180,6 +193,5 @@ export function st(key: string, fallbackMessage: string) {
|
||||
export function stRaw(key: string, fallbackMessage: string) {
|
||||
if (!te(key)) return fallbackMessage
|
||||
|
||||
const message = tm(key)
|
||||
return typeof message === 'string' ? message : fallbackMessage
|
||||
return rawTranslationOrFallback(key, fallbackMessage)
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
SUBGRAPH_OUTPUT_ID
|
||||
} from '@/lib/litegraph/src/constants'
|
||||
import type { SerializedNodeId } from '@/types/nodeId'
|
||||
import { toNodeId } from '@/types/nodeId'
|
||||
import {
|
||||
LGraph,
|
||||
LGraphNode,
|
||||
@@ -86,6 +87,23 @@ interface TestSubgraphNodeOptions {
|
||||
size?: [number, number]
|
||||
}
|
||||
|
||||
interface BoundaryLinkedSubgraphOptions {
|
||||
rootGraph?: LGraph
|
||||
hostId?: SerializedNodeId
|
||||
interiorId?: SerializedNodeId
|
||||
boundaryName?: string
|
||||
inputName?: string
|
||||
hostTitle?: string
|
||||
interiorType?: string
|
||||
}
|
||||
|
||||
export interface BoundaryLinkedSubgraphFixture {
|
||||
rootGraph: LGraph
|
||||
subgraph: Subgraph
|
||||
host: SubgraphNode
|
||||
interior: LGraphNode
|
||||
}
|
||||
|
||||
interface NestedSubgraphOptions {
|
||||
depth?: number
|
||||
nodesPerLevel?: number
|
||||
@@ -269,6 +287,32 @@ export function createTestSubgraphNode(
|
||||
return new SubgraphNode(parentGraph, subgraph, instanceData)
|
||||
}
|
||||
|
||||
export function createBoundaryLinkedSubgraph({
|
||||
rootGraph = createTestRootGraph(),
|
||||
hostId = 12,
|
||||
interiorId = 5,
|
||||
boundaryName = 'seed',
|
||||
inputName = 'seed_input',
|
||||
hostTitle = 'Host Subgraph',
|
||||
interiorType = 'InteriorNode'
|
||||
}: BoundaryLinkedSubgraphOptions = {}): BoundaryLinkedSubgraphFixture {
|
||||
const subgraph = createTestSubgraph({
|
||||
rootGraph,
|
||||
inputs: [{ name: boundaryName, type: '*' }]
|
||||
})
|
||||
const host = createTestSubgraphNode(subgraph, { id: hostId })
|
||||
host.title = hostTitle
|
||||
rootGraph.add(host)
|
||||
|
||||
const interior = new LGraphNode(interiorType)
|
||||
interior.id = toNodeId(interiorId)
|
||||
const input = interior.addInput(inputName, '*')
|
||||
subgraph.add(interior)
|
||||
subgraph.inputNode.slots[0].connect(input, interior)
|
||||
|
||||
return { rootGraph, subgraph, host, interior }
|
||||
}
|
||||
|
||||
export function setupComplexPromotionFixture(): {
|
||||
graph: LGraph
|
||||
subgraph: Subgraph
|
||||
|
||||
@@ -621,10 +621,13 @@
|
||||
"namePlaceholder": "أدخل اسمك هنا",
|
||||
"profileCreationNav": "إنشاء الملف الشخصي",
|
||||
"startPublishingButton": "ابدأ النشر",
|
||||
"startUpdatingButton": "تحديث سير العمل",
|
||||
"successDescription": "يمكنك الآن رفع سير عملك على صفحة المبدع الخاصة بك",
|
||||
"successProfileLink": "comfy.com/p/{username}",
|
||||
"successProfileUrl": "صفحتك الشخصية متاحة الآن على",
|
||||
"successTitle": "يبدو رائعًا، {'@'}{username}!",
|
||||
"updateIntroDescription": "ادفع أحدث التغييرات إلى ComfyHub. سيبقى رابط المشاركة والإحصائيات كما هي.",
|
||||
"updateIntroTitle": "تحديث سير العمل الخاص بك على ComfyHub",
|
||||
"uploadCover": "+ رفع صورة الغلاف",
|
||||
"uploadProfilePicture": "+ رفع صورة الملف الشخصي",
|
||||
"uploadWorkflowButton": "رفع سير عملي",
|
||||
@@ -648,6 +651,8 @@
|
||||
"publishSuccessDescription": "تم نشر سير العمل الخاص بك على ComfyHub.",
|
||||
"publishSuccessTitle": "تم النشر بنجاح",
|
||||
"removeExampleImage": "إزالة الصورة النموذجية",
|
||||
"renameFailedDescription": "تم نشر سير العمل بنجاح، لكن فشلت إعادة تسمية الملف المحلي. يرجى إعادة تسميته مرة أخرى ليتطابق.",
|
||||
"renameFailedTitle": "فشل إعادة التسمية",
|
||||
"selectAThumbnail": "اختر صورة مصغرة",
|
||||
"shareAs": "مشاركة كـ",
|
||||
"showLessTags": "عرض أقل...",
|
||||
@@ -665,6 +670,7 @@
|
||||
"thumbnailVideo": "فيديو",
|
||||
"title": "النشر على ComfyHub",
|
||||
"unsavedDescription": "يجب حفظ سير العمل الخاص بك قبل النشر على ComfyHub. احفظه الآن للمتابعة.",
|
||||
"updateButton": "تحديث سير العمل",
|
||||
"uploadAnImage": "انقر للاستعراض أو اسحب صورة",
|
||||
"uploadComparison": "رفع صورة قبل وبعد",
|
||||
"uploadComparisonAfterPrompt": "بعد",
|
||||
@@ -783,6 +789,8 @@
|
||||
"faqs": "الأسئلة المتكررة",
|
||||
"invoiceHistory": "تاريخ الفواتير",
|
||||
"lastUpdated": "آخر تحديث",
|
||||
"loadEventsError": "فشل في تحميل النشاط. يرجى المحاولة مرة أخرى.",
|
||||
"loadEventsUnknownError": "حدث خطأ أثناء تحميل النشاط. يرجى إعادة التحميل والمحاولة مرة أخرى.",
|
||||
"messageSupport": "مراسلة الدعم",
|
||||
"model": "النموذج",
|
||||
"purchaseCredits": "شراء رصيد",
|
||||
@@ -1564,6 +1572,7 @@
|
||||
"extensions": "الملحقات",
|
||||
"failed": "فشل",
|
||||
"failedToCopyJobId": "فشل نسخ معرف المهمة",
|
||||
"failedToDownloadFile": "فشل في تنزيل الملف",
|
||||
"failedToDownloadImage": "فشل في تنزيل الصورة",
|
||||
"failedToDownloadVideo": "فشل في تنزيل الفيديو",
|
||||
"favorites": "المفضلة",
|
||||
@@ -1808,6 +1817,7 @@
|
||||
"systemStatsRAMTotal": "إجمالي الذاكرة (RAM)",
|
||||
"systemStatsTemplatesVersion": "إصدار القوالب",
|
||||
"terminal": "الطرفية",
|
||||
"textFailedToLoad": "فشل تحميل النص",
|
||||
"title": "العنوان",
|
||||
"triggerPhrase": "عبارة التشغيل",
|
||||
"unknownError": "خطأ غير معروف",
|
||||
@@ -2401,6 +2411,11 @@
|
||||
"pending": "قيد الانتظار",
|
||||
"unknown": "غير معروف"
|
||||
},
|
||||
"survey": {
|
||||
"error": "تعذر تحميل هذا الاستبيان. يرجى المحاولة مرة أخرى لاحقًا.",
|
||||
"intro": "تثبيت العقد المخصصة قادم قريبًا إلى Comfy Cloud. أجب عن بعض الأسئلة السريعة للانضمام إلى قائمة الانتظار والمساهمة في تطوير هذه الميزة.",
|
||||
"title": "انضم إلى قائمة الانتظار"
|
||||
},
|
||||
"title": "مدير العقد المخصصة",
|
||||
"toFinishSetup": "لإكمال الإعداد",
|
||||
"totalNodes": "إجمالي العقد",
|
||||
@@ -2769,7 +2784,6 @@
|
||||
"Runway": "رن واي",
|
||||
"Sonilo": "Sonilo",
|
||||
"Sora": "سورا",
|
||||
"Stability AI": "Stability AI",
|
||||
"Tencent": "Tencent",
|
||||
"Topaz": "Topaz",
|
||||
"Tripo": "تريبو",
|
||||
@@ -2801,7 +2815,6 @@
|
||||
"cosmos": "cosmos",
|
||||
"create": "إنشاء",
|
||||
"custom": "مخصص",
|
||||
"custom_sampling": "تجميع مخصص",
|
||||
"dancer": "راقص",
|
||||
"debug": "تصحيح",
|
||||
"detection": "الكشف",
|
||||
@@ -2865,7 +2878,6 @@
|
||||
"stable video": "stable video",
|
||||
"stable video 3d": "stable video 3d",
|
||||
"stable zero123": "stable zero123",
|
||||
"stable_cascade": "سلسلة ثابتة",
|
||||
"supir": "supir",
|
||||
"text": "نص",
|
||||
"training": "تدريب",
|
||||
@@ -3102,8 +3114,11 @@
|
||||
"errorHelpGithub": "إرسال مشكلة على GitHub",
|
||||
"errorHelpSupport": "تواصل مع الدعم الفني",
|
||||
"errorLog": "سجل الأخطاء",
|
||||
"errorNodeSummary": "{nodes} عقدة — {count} خطأ | {nodes} عقدة — {count} أخطاء",
|
||||
"errorNodesSummary": "{nodes} عقد — {count} خطأ | {nodes} عقد — {count} أخطاء",
|
||||
"errors": "الأخطاء",
|
||||
"errorsDetected": "تم اكتشاف خطأ | تم اكتشاف أخطاء",
|
||||
"errorsSummary": "{count} خطأ | {count} أخطاء",
|
||||
"executionErrorOccurred": "حدث خطأ أثناء التنفيذ. تحقق من علامة تبويب الأخطاء لمزيد من التفاصيل.",
|
||||
"expand": "توسيع",
|
||||
"fallbackGroupTitle": "مجموعة",
|
||||
@@ -3193,6 +3208,8 @@
|
||||
"resetToDefault": "إعادة التعيين إلى الافتراضي",
|
||||
"resolveBeforeRun": "يرجى الحل قبل تشغيل سير العمل",
|
||||
"seeError": "عرض الخطأ",
|
||||
"selectedNodeErrors": "{node} — {count} خطأ | {node} — {count} أخطاء",
|
||||
"selectedNodesErrors": "{nodes} عقد محددة — {count} خطأ | {nodes} عقد محددة — {count} أخطاء",
|
||||
"settings": "الإعدادات",
|
||||
"showAdvancedInputsButton": "إظهار المدخلات المتقدمة",
|
||||
"showAdvancedShort": "إظهار الخيارات المتقدمة",
|
||||
@@ -3223,6 +3240,10 @@
|
||||
"namePlaceholder": "مثال: مفتاح API الخاص بي",
|
||||
"noSecrets": "لا توجد أسرار مخزنة. أضف أول مفتاح API للبدء.",
|
||||
"provider": "المزود",
|
||||
"providerHelp": {
|
||||
"gemini": "أدخل مفتاح Google Gemini API الخاص بك. يمكنك إنشاء واحد في Google AI Studio.",
|
||||
"runway": "أدخل مفتاح Runway API الخاص بك. يمكنك العثور عليه في إعدادات حسابك على Runway ضمن مفاتيح API."
|
||||
},
|
||||
"providerHint": "اختياري. اختيار مزود يتيح استخدام الرمز تلقائيًا.",
|
||||
"secretValue": "قيمة السر",
|
||||
"secretValueHint": "سيتم تشفير هذه القيمة ولا يمكن عرضها مرة أخرى.",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user