Compare commits

..

10 Commits

Author SHA1 Message Date
Christian Byrne
5b35ee6eed Merge branch 'main' into test/cov-app 2026-06-25 18:57:12 -07:00
Connor Byrne
f91b2ba771 docs: drop @deprecated tag from canonical isApiJson
This is now the canonical location; the wrapper in app.ts points here as
the replacement, so the tag has no valid target.

Addresses review feedback:
https://github.com/Comfy-Org/ComfyUI_frontend/pull/11400#discussion_r3263065147
2026-06-25 18:55:38 -07:00
Connor Byrne
39bfb900f6 refactor: use const for entityMap in sanitizeNodeName
Addresses review feedback:
https://github.com/Comfy-Org/ComfyUI_frontend/pull/11400#discussion_r3263065145
2026-06-25 18:55:19 -07:00
Connor Byrne
5efbc325f7 refactor: use named isObject import from es-toolkit/compat
Addresses review feedback:
https://github.com/Comfy-Org/ComfyUI_frontend/pull/11400#discussion_r3263065149
2026-06-25 18:54:31 -07:00
Alexander Brown
7e4f58ca26 Merge branch 'main' into test/cov-app 2026-05-18 17:33:08 -07:00
Christian Byrne
027ddeb427 Merge branch 'main' into test/cov-app 2026-05-04 13:26:57 -07:00
bymyself
eb4c397808 fix: guard positionBatchLayout against empty input
Addresses review feedback:
https://github.com/Comfy-Org/ComfyUI_frontend/pull/11400#discussion_r3114705436
2026-04-21 18:45:53 -07:00
Christian Byrne
484f6dc341 Merge branch 'main' into test/cov-app 2026-04-20 19:46:44 -07:00
bymyself
c2e06edf87 refactor: extract pure functions from ComfyApp into appUtil
Addresses review feedback:
https://github.com/Comfy-Org/ComfyUI_frontend/pull/11400#pullrequestreview-2907693605

- Extract sanitizeNodeName, isApiJson, stackNodesVertically,
  positionBatchLayout into src/scripts/appUtil.ts as pure functions
- ComfyApp methods delegate to the extracted functions
- Replace mock-heavy tests (deprecated getter assertions, private method
  bracket-notation access, store delegation checks) with zero-mock pure
  function tests in appUtil.test.ts
- Restore app.test.ts to its existing integration tests that use minimal
  mocking of owned modules (usePaste, litegraphUtil)
2026-04-20 19:26:46 -07:00
bymyself
90799092d5 test: extend unit tests for ComfyApp
Cover sanitizeNodeName, getPreviewFormatParam, getRandParam,
onClipspaceEditorClosed, nodeOutputs setter, deprecated getters
(lastNodeErrors, lastExecutionError, runningNodeId, storageLocation,
isNewUserSession, shiftDown, widgets, extensions, progress),
showMissingNodesError, isApiJson, isGraphReady, configuringGraph,
positionNodes, and clientPosToCanvasPos/canvasPosToClientPos.
2026-04-18 21:56:53 -07:00
557 changed files with 7089 additions and 42777 deletions

View File

@@ -33,15 +33,15 @@ Flag:
- **New circular entity dependencies** — New circular imports between `LGraph``Subgraph`, `LGraphNode``LGraphCanvas`, or similar entity classes.
- **Direct `graph._version++`** — Mutating the private version counter directly instead of through a public API. Extensions already depend on this side-channel; it must become a proper API.
### Dedicated Stores and Data/Behavior Separation
### Centralized Registries and ECS-Style Access
Entity data lives in dedicated Pinia stores keyed by string IDs (`widgetValueStore`, `domWidgetStore`, `layoutStore`, `nodeOutputStore`, `subgraphNavigationStore`, `previewExposureStore`), not on entity instances.
All entity data access should move toward centralized query patterns, not instance property access.
Flag:
- **New instance method/property patterns** — Adding `node.someProperty` or `node.someMethod()` for data that belongs in a dedicated store (e.g. widget values → `widgetValueStore` keyed by `WidgetId`).
- **New instance method/property patterns** — Adding `node.someProperty` or `node.someMethod()` for data that should be a component in the World, queried via `world.getComponent(entityId, ComponentType)`.
- **OOP inheritance for entity modeling** — Extending entity classes with new subclasses instead of composing behavior through components and systems.
- **Duplicated authority** — Storing the same entity state in both a class property and a store, or across two stores, so ownership becomes ambiguous. Each piece of state should have one owning store.
- **Scattered state** — New entity state stored in multiple locations (class properties, stores, local variables) instead of being consolidated in the World or in a single store.
### Extension Ecosystem Impact

View File

@@ -88,9 +88,9 @@ jobs:
- name: Strip non-source entries from coverage
if: steps.coverage-shards.outputs.has-coverage == 'true'
run: |
# Drop served bundle scripts (localhost-8188/assets/*.js) that V8 records but have no source file on disk, which would abort genhtml.
lcov --remove coverage/playwright/coverage.lcov \
'*localhost-8188*' \
'assets/images/*' \
-o coverage/playwright/coverage.lcov \
--ignore-errors unused
wc -l coverage/playwright/coverage.lcov
@@ -121,8 +121,7 @@ jobs:
--title "ComfyUI E2E Coverage" \
--no-function-coverage \
--precision 1 \
--ignore-errors source,unmapped,range \
--synthesize-missing
--ignore-errors source,unmapped
- name: Upload HTML report artifact
if: steps.coverage-shards.outputs.has-coverage == 'true'

View File

@@ -41,7 +41,7 @@ jobs:
# Allowlist bots so they don't need to sign (optional, comma-separated).
# *[bot] is a catch-all for any GitHub App bot account.
allowlist: action@github.com,actions-user,ampagent,claude,comfy-pr-bot,GitHub Action,github-actions,Glary Bot,Glary-Bot,*[bot]
allowlist: actions-user,ampagent,claude,comfy-pr-bot,github-actions,*[bot],Glary Bot
# Custom PR comment messages
custom-notsigned-prcomment: |

View File

@@ -67,11 +67,6 @@ jobs:
uses: actions/checkout@v6
with:
fetch-depth: 0
# Persist a token with `workflow` scope so the backport push can
# include changes to .github/workflows/**. The default GITHUB_TOKEN
# is refused by GitHub when a push creates/updates workflow files,
# which silently aborted the whole job (see PR #12804 backport).
token: ${{ secrets.PR_GH_TOKEN }}
- name: Configure git
run: |

View File

@@ -21,8 +21,7 @@ module.exports = defineConfig({
'ar',
'tr',
'pt-BR',
'fa',
'he'
'fa'
],
reference: `Special names to keep untranslated: flux, photomaker, clip, vae, cfg, stable audio, stable cascade, stable zero, controlnet, lora, HiDream, Civitai, Hugging Face.
'latent' is the short form of 'latent space'.
@@ -38,11 +37,5 @@ module.exports = defineConfig({
- Keep commonly used technical terms in English when they are standard in Persian software (e.g., node, workflow).
- Use Arabic-Indic numerals (۰-۹) for numbers where appropriate.
- Maintain consistency with terminology used in Persian software and design applications.
IMPORTANT Hebrew Translation Guidelines:
- For 'he' locale: Use modern, formal Hebrew (עברית תקנית) for a professional tone throughout the UI.
- Hebrew is a right-to-left (RTL) language. Keep all interpolation placeholders ({name}, {count}), pipe-separated plural forms, and English technical terms intact and in their original positions.
- Preferred glossary: node = צומת (plural צמתים), workflow = תהליך עבודה, queue = תור, canvas = קנבס, widget = פקד, subgraph = תת-גרף, prompt = פרומפט/הנחיה (per context), bypass = עקיפה, mute = השתקה.
- Keep widely-recognized technical terms in English (Latin script): API, GPU, CUDA, VAE, CLIP, LoRA, ControlNet, Civitai, Hugging Face, Nodes 2.0, etc.
`
})

View File

@@ -179,9 +179,6 @@ This project uses **pnpm**. Always prefer scripts defined in `package.json` (e.g
23. Favor pure functions (especially testable ones)
24. Do not use function expressions if it's possible to use function declarations instead
25. Watch out for [Code Smells](https://wiki.c2.com/?CodeSmell) and refactor to avoid them
26. Do not add alias helpers whose implementation is just a single-line call to another function
- Bad: `function id(value) { return nodeId(value) }`
- Use the real function directly, or introduce a named helper only when it adds validation, branching, domain meaning, or shared behavior beyond renaming
## Design Standards
@@ -249,7 +246,7 @@ All architectural decisions are documented in `docs/adr/`. Code changes must be
### Entity Architecture Constraints (ADR 0003 + ADR 0008)
1. **Command pattern for all mutations**: Every entity state change must be a serializable, idempotent, deterministic command — replayable, undoable, and transmittable over CRDT. No imperative fire-and-forget mutation APIs. Systems produce command batches, not direct side effects.
2. **Dedicated stores over instance state**: Entity data lives in dedicated Pinia stores keyed by string IDs — widget values in `widgetValueStore` keyed by `WidgetId` (`graphId:nodeId:name`, see `src/types/widgetId.ts`), plus `domWidgetStore`, `layoutStore`, `nodeOutputStore`, `subgraphNavigationStore`, and `previewExposureStore`. Prefer a focused store to a single unified registry. Do not add new instance properties/methods to entity classes for data that belongs in a store. Do not use OOP inheritance for entity modeling.
2. **Centralized registries and ECS-style access**: Entity data lives in the World (centralized registry), queried via `world.getComponent(entityId, ComponentType)`. Do not add new instance properties/methods to entity classes. Do not use OOP inheritance for entity modeling.
3. **No god-object growth**: Do not add methods to `LGraphNode`, `LGraphCanvas`, `LGraph`, or `Subgraph`. Extract to systems, stores, or composables.
4. **Plain data components**: ECS components are plain data objects — no methods, no back-references to parent entities. Behavior belongs in systems (pure functions).
5. **Extension ecosystem impact**: Changes to entity callbacks (`onConnectionsChange`, `onRemoved`, `onAdded`, `onConnectInput/Output`, `onConfigure`, `onWidgetChanged`), `node.widgets` access, `node.serialize`, or `graph._version++` affect 40+ custom node repos and require migration guidance.

View File

@@ -1,231 +0,0 @@
import type { Page } from '@playwright/test'
import { expect } from '@playwright/test'
import { externalLinks } from '../src/config/routes'
import { drops } from '../src/data/drops'
import type { Locale } from '../src/i18n/translations'
import { t } from '../src/i18n/translations'
import { test } from './fixtures/blockExternalMedia'
const PATH_EN = '/launches'
const PATH_ZH = '/zh-CN/launches'
const CLOUD_URL = 'https://cloud.comfy.org'
const LOCALES: ReadonlyArray<readonly [string, Locale]> = [
[PATH_EN, 'en'],
[PATH_ZH, 'zh-CN']
]
function heroSection(page: Page, locale: Locale) {
return page.locator('section').filter({
has: page.getByRole('heading', {
level: 1,
name: t('launches.hero.title', locale)
})
})
}
function ctaSection(page: Page, locale: Locale) {
return page.locator('section').filter({
has: page.getByRole('heading', {
level: 2,
name: t('launches.cta.heading', locale)
})
})
}
function dropsSection(page: Page, locale: Locale) {
return page.locator('section').filter({
has: page.getByRole('heading', {
level: 2,
name: t('launches.section.title', locale)
})
})
}
test.describe('Launches landing — desktop @smoke', () => {
test('renders the configured title at /launches', async ({ page }) => {
await page.goto(PATH_EN)
await expect(page).toHaveTitle(t('launches.page.title', 'en'))
})
test('renders the localized title at /zh-CN/launches', async ({ page }) => {
await page.goto(PATH_ZH)
await expect(page).toHaveTitle(t('launches.page.title', 'zh-CN'))
})
test('is indexable at both locales', async ({ page }) => {
await page.goto(PATH_EN)
await expect(page.locator('meta[name="robots"]')).toHaveCount(0)
await page.goto(PATH_ZH)
await expect(page.locator('meta[name="robots"]')).toHaveCount(0)
})
test('hero h1 renders the localized title in both locales', async ({
page
}) => {
await page.goto(PATH_EN)
await expect(
page.getByRole('heading', {
level: 1,
name: t('launches.hero.title', 'en')
})
).toBeVisible()
await page.goto(PATH_ZH)
await expect(
page.getByRole('heading', {
level: 1,
name: t('launches.hero.title', 'zh-CN')
})
).toBeVisible()
})
test('hero primary CTA links to /download per locale', async ({ page }) => {
for (const [path, locale, expectedHref] of [
[PATH_EN, 'en', '/download'],
[PATH_ZH, 'zh-CN', '/zh-CN/download']
] as const) {
await page.goto(path)
const primary = heroSection(page, locale).getByRole('link', {
name: t('launches.hero.primary', locale)
})
await expect(primary).toBeVisible()
await expect(primary).toHaveAttribute('href', expectedHref)
}
})
test('hero secondary CTA opens external Cloud in a new tab on both locales', async ({
page
}) => {
for (const [path, locale] of LOCALES) {
await page.goto(path)
const secondary = heroSection(page, locale).getByRole('link', {
name: t('launches.hero.secondary', locale)
})
await expect(secondary).toBeVisible()
await expect(secondary).toHaveAttribute('href', CLOUD_URL)
await expect(secondary).toHaveAttribute('target', '_blank')
await expect(secondary).toHaveAttribute('rel', 'noopener noreferrer')
}
})
test('closing CTA shows heading and both action buttons in both locales', async ({
page
}) => {
for (const [path, locale] of LOCALES) {
await page.goto(path)
const section = ctaSection(page, locale)
await expect(
section.getByRole('heading', {
level: 2,
name: t('launches.cta.heading', locale)
})
).toBeVisible()
const primary = section.getByRole('link', {
name: t('launches.cta.primary', locale)
})
await expect(primary).toBeVisible()
await expect(primary).toHaveAttribute('href', externalLinks.cloud)
await expect(primary).toHaveAttribute('target', '_blank')
await expect(primary).toHaveAttribute('rel', 'noopener noreferrer')
const secondary = section.getByRole('link', {
name: t('launches.cta.secondary', locale)
})
await expect(secondary).toBeVisible()
await expect(secondary).toHaveAttribute('href', externalLinks.workflows)
await expect(secondary).toHaveAttribute('target', '_blank')
await expect(secondary).toHaveAttribute('rel', 'noopener noreferrer')
}
})
test('drops section renders one card per data entry with the correct localized href in both locales', async ({
page
}) => {
for (const [path, locale] of LOCALES) {
await page.goto(path)
const section = dropsSection(page, locale)
await expect(
section.getByRole('heading', {
level: 2,
name: t('launches.section.title', locale)
})
).toBeVisible()
const cards = section.locator('[data-slot="card"]')
await expect(cards).toHaveCount(drops.length)
for (const [i, drop] of drops.entries()) {
const card = cards.nth(i)
await expect(card).toContainText(drop.title[locale])
const explore = card.getByRole('link', {
name: drop.cta.label[locale]
})
await expect(explore).toBeVisible()
await expect(explore).toHaveAttribute('href', drop.cta.href[locale])
}
}
})
test('desktop: first 4 drop cards are wider than cards 5+', async ({
page
}) => {
await page.goto(PATH_EN)
const cards = dropsSection(page, 'en').locator('[data-slot="card"]')
await expect(cards).toHaveCount(drops.length)
await expect
.poll(async () => {
const firstWidth = (await cards.nth(0).boundingBox())?.width ?? 0
const fifthWidth = (await cards.nth(4).boundingBox())?.width ?? 0
return firstWidth - fifthWidth
})
.toBeGreaterThan(0)
})
})
test.describe('Launches landing — mobile @mobile', () => {
test('drops grid stacks in a single column at mobile width', async ({
page
}) => {
await page.goto(PATH_EN)
const cards = dropsSection(page, 'en').locator('[data-slot="card"]')
await expect(cards).toHaveCount(drops.length)
const viewport = page.viewportSize()
expect(viewport, 'viewport size').not.toBeNull()
await expect
.poll(async () => (await cards.nth(0).boundingBox())?.width ?? 0)
.toBeGreaterThanOrEqual(viewport!.width * 0.7)
await expect
.poll(async () => {
const firstBox = await cards.nth(0).boundingBox()
const secondBox = await cards.nth(1).boundingBox()
if (!firstBox || !secondBox) return false
return secondBox.y >= firstBox.y + firstBox.height
})
.toBe(true)
})
test('closing CTA heading stays within viewport width', async ({ page }) => {
await page.goto(PATH_EN)
const heading = page.getByRole('heading', {
level: 2,
name: t('launches.cta.heading', 'en')
})
await heading.scrollIntoViewIfNeeded()
await expect(heading).toBeVisible()
const box = await heading.boundingBox()
expect(box, 'CTA heading bounding box').not.toBeNull()
const viewport = page.viewportSize()
expect(viewport, 'viewport size').not.toBeNull()
expect(box!.x + box!.width).toBeLessThanOrEqual(viewport!.width + 1)
})
})

Binary file not shown.

Before

Width:  |  Height:  |  Size: 59 KiB

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

After

Width:  |  Height:  |  Size: 58 KiB

View File

@@ -1,3 +1,3 @@
<svg preserveAspectRatio="none" width="100%" height="100%" overflow="visible" style="display: block;" viewBox="0 0 20 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<path id="Vector" d="M20 32V0C20 5.39616 15.5172 9.78053 10 9.78053C4.48276 9.78053 0 5.416 0 0V32C0 26.6038 4.48276 22.2195 10 22.2195C15.5172 22.2195 20 26.6038 20 32Z" fill="var(--fill-0, #F2FF59)"/>
<svg width="20" height="32" viewBox="0 0 20 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M20 32V0C20 5.39616 15.5172 9.78053 10 9.78053C4.48276 9.78053 0 5.416 0 0V32C0 26.6038 4.48276 22.2195 10 22.2195C15.5172 22.2195 20 26.6038 20 32Z" fill="#F2FF59"/>
</svg>

Before

Width:  |  Height:  |  Size: 380 B

After

Width:  |  Height:  |  Size: 279 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 MiB

View File

@@ -29,5 +29,6 @@ Allow: /
Disallow: /_astro/
Disallow: /_website/
Disallow: /_vercel/
Disallow: /payment/
Sitemap: https://comfy.org/sitemap-index.xml

View File

@@ -1,14 +1,10 @@
<script setup lang="ts">
import type { AnchorHTMLAttributes } from 'vue'
import Button from '../ui/button/Button.vue'
import { resolveRel } from '../../utils/cta'
import BrandButton from '../common/BrandButton.vue'
type Cta = {
label: string
href: string
target?: AnchorHTMLAttributes['target']
rel?: AnchorHTMLAttributes['rel']
target?: '_blank' | '_self' | '_parent' | '_top'
}
type TermsLink = {
@@ -16,11 +12,10 @@ type TermsLink = {
href: string
}
const { heading, primaryCta, secondaryCta, termsLink } = defineProps<{
defineProps<{
heading: string
primaryCta: Cta
secondaryCta?: Cta
termsLink?: TermsLink
termsLink: TermsLink
}>()
</script>
@@ -29,37 +24,23 @@ const { heading, primaryCta, secondaryCta, termsLink } = defineProps<{
class="max-w-9xl mx-auto flex flex-col items-center px-6 py-16 text-center lg:py-24"
>
<h2
class="max-w-3xl text-4xl/snug font-light tracking-tight text-pretty text-primary-comfy-canvas lg:text-6xl/snug"
class="text-4xl font-light tracking-tight text-primary-comfy-canvas lg:text-6xl"
>
{{ heading }}
</h2>
<div class="mt-10 flex flex-col gap-4 sm:flex-row lg:mt-12">
<Button
as="a"
:href="primaryCta.href"
:target="primaryCta.target"
:rel="resolveRel(primaryCta)"
variant="default"
size="lg"
>
{{ primaryCta.label }}
</Button>
<Button
v-if="secondaryCta"
as="a"
:href="secondaryCta.href"
:target="secondaryCta.target"
:rel="resolveRel(secondaryCta)"
variant="outline"
size="lg"
>
{{ secondaryCta.label }}
</Button>
</div>
<BrandButton
:href="primaryCta.href"
:target="primaryCta.target"
:rel="primaryCta.target === '_blank' ? 'noopener noreferrer' : undefined"
variant="outline"
size="lg"
class="mt-10 px-20 py-4 text-base uppercase lg:mt-12"
>
{{ primaryCta.label }}
</BrandButton>
<a
v-if="termsLink"
:href="termsLink.href"
class="mt-8 text-sm text-primary-comfy-canvas/70 underline underline-offset-4 transition-colors hover:text-primary-comfy-canvas"
>

View File

@@ -26,7 +26,7 @@ function toggle(index: number) {
</script>
<template>
<section class="max-w-9xl mx-auto px-6 py-16 lg:py-24">
<section class="max-w-9xl mx-auto px-4 py-24 md:px-20 md:py-40">
<div class="flex flex-col gap-6 md:flex-row md:gap-16">
<!-- Left heading -->
<div

View File

@@ -1,117 +0,0 @@
<script setup lang="ts">
import { cn } from '@comfyorg/tailwind-utils'
import type { Component } from 'vue'
import Button from '@/components/ui/button/Button.vue'
import CopyableField from '@/components/ui/copyable-field/CopyableField.vue'
import SectionHeader from '../common/SectionHeader.vue'
type CardAction =
| {
type: 'link'
label: string
href: string
target?: '_blank'
icon?: Component
}
| { type: 'code'; value: string }
export interface FeatureCard {
id: string
label?: string
title: string
description: string
action?: CardAction
}
const {
eyebrow,
heading,
subtitle,
columns = 3,
cards,
copyLabel,
copiedLabel
} = defineProps<{
eyebrow?: string
heading: string
subtitle?: string
columns?: 2 | 3 | 4
cards: readonly FeatureCard[]
copyLabel?: string
copiedLabel?: string
}>()
const columnClass: Record<2 | 3 | 4, string> = {
2: 'lg:grid-cols-2',
3: 'lg:grid-cols-3',
4: 'lg:grid-cols-4'
}
</script>
<template>
<section class="max-w-9xl mx-auto px-6 py-16 lg:py-24">
<SectionHeader :label="eyebrow" align="start">
{{ heading }}
<template v-if="subtitle" #subtitle>
<p class="mt-4 max-w-xl text-sm text-smoke-700 lg:text-base">
{{ subtitle }}
</p>
</template>
</SectionHeader>
<div :class="cn('mt-16 grid grid-cols-1 gap-6', columnClass[columns])">
<div
v-for="card in cards"
:key="card.id"
class="bg-transparency-white-t4 flex flex-col rounded-3xl p-6 lg:p-8"
>
<p
v-if="card.label"
class="text-primary-comfy-yellow text-xs font-bold tracking-widest uppercase"
>
{{ card.label }}
</p>
<h3
:class="
cn(
'text-xl font-light text-primary-comfy-canvas lg:text-2xl',
card.label && 'mt-3'
)
"
>
{{ card.title }}
</h3>
<p class="mt-3 text-sm text-smoke-700">
{{ card.description }}
</p>
<div v-if="card.action" class="mt-6">
<Button
v-if="card.action.type === 'link'"
as="a"
:href="card.action.href"
:target="card.action.target"
:rel="
card.action.target === '_blank'
? 'noopener noreferrer'
: undefined
"
variant="outline"
:append-icon="card.action.icon"
>
{{ card.action.label }}
</Button>
<CopyableField
v-else
:value="card.action.value"
:copy-label="copyLabel"
:copied-label="copiedLabel"
/>
</div>
</div>
</div>
</section>
</template>

View File

@@ -1,100 +0,0 @@
<script setup lang="ts">
import Button from '@/components/ui/button/Button.vue'
import SectionHeader from '../common/SectionHeader.vue'
import NodeUnionIcon from '../icons/NodeUnionIcon.vue'
type Cta = { label: string; href: string; target?: '_blank' }
export interface FeatureStep {
id: string
number: string
title: string
description: string
}
defineProps<{
heading: string
steps: readonly FeatureStep[]
primaryCta?: Cta
secondaryCta?: Cta
}>()
</script>
<template>
<section class="max-w-9xl mx-auto px-6 py-16 lg:py-24">
<SectionHeader>{{ heading }}</SectionHeader>
<!-- Step cards in a row, joined by node-union connectors on desktop -->
<div
class="mt-12 flex flex-col gap-4 lg:flex-row lg:items-stretch lg:gap-0"
>
<template v-for="(step, i) in steps" :key="step.id">
<div
v-if="i > 0"
class="relative z-10 -mx-px hidden shrink-0 items-center justify-center self-stretch lg:flex"
aria-hidden="true"
>
<NodeUnionIcon
class="text-primary-comfy-yellow size-4 scale-x-150 rotate-90"
/>
</div>
<div
class="border-primary-comfy-yellow flex flex-1 flex-col rounded-[40px] border-2 bg-primary-comfy-ink p-2"
>
<div class="flex flex-1 flex-col gap-4 p-8">
<div>
<p
class="text-primary-comfy-yellow text-xs font-bold tracking-widest uppercase"
>
{{ step.number }}
</p>
<h3
class="mt-1 text-2xl font-medium tracking-widest text-primary-comfy-canvas uppercase"
>
{{ step.title }}
</h3>
</div>
<p class="text-primary-comfy-canvas">
{{ step.description }}
</p>
</div>
</div>
</template>
</div>
<div
v-if="primaryCta || secondaryCta"
class="mt-12 flex flex-col items-center gap-4 lg:flex-row lg:justify-center"
>
<Button
v-if="primaryCta"
as="a"
:href="primaryCta.href"
:target="primaryCta.target"
:rel="
primaryCta.target === '_blank' ? 'noopener noreferrer' : undefined
"
size="lg"
class="w-full lg:w-auto lg:min-w-48"
>
{{ primaryCta.label }}
</Button>
<Button
v-if="secondaryCta"
as="a"
:href="secondaryCta.href"
:target="secondaryCta.target"
:rel="
secondaryCta.target === '_blank' ? 'noopener noreferrer' : undefined
"
variant="outline"
size="lg"
class="w-full lg:w-auto lg:min-w-48"
>
{{ secondaryCta.label }}
</Button>
</div>
</section>
</template>

View File

@@ -1,108 +0,0 @@
<script setup lang="ts">
import { cn } from '@comfyorg/tailwind-utils'
import type { Locale } from '../../i18n/translations'
import GlassCard from '../common/GlassCard.vue'
import SectionHeader from '../common/SectionHeader.vue'
import VideoPlayer from '../common/VideoPlayer.vue'
import type { VideoTrack } from '../common/VideoPlayer.vue'
type RowMedia =
| { type: 'image'; src: string; alt?: string }
| {
type: 'video'
src: string
// <video> has no native alt; used as the player's accessible label.
alt?: string
poster?: string
tracks?: readonly VideoTrack[]
autoplay?: boolean
loop?: boolean
minimal?: boolean
hideControls?: boolean
}
export interface FeatureRow {
id: string
title: string
description: string
media: RowMedia
}
const {
heading,
eyebrow,
locale = 'en',
rows
} = defineProps<{
heading: string
eyebrow?: string
locale?: Locale
rows: readonly FeatureRow[]
}>()
</script>
<template>
<section class="max-w-9xl mx-auto px-6 py-16 lg:py-24">
<SectionHeader :label="eyebrow" max-width="xl">
{{ heading }}
</SectionHeader>
<div class="mt-16 flex flex-col gap-4 lg:gap-6">
<GlassCard
v-for="(row, i) in rows"
:key="row.id"
class="flex flex-col gap-8 lg:flex-row lg:items-stretch lg:gap-0"
>
<!-- Text -->
<div
:class="
cn(
'order-2 flex flex-col justify-center gap-4 p-6 lg:w-1/2 lg:p-12',
i % 2 === 0 ? 'lg:order-1' : 'lg:order-2'
)
"
>
<h3 class="text-2xl font-light text-primary-comfy-canvas lg:text-3xl">
{{ row.title }}
</h3>
<p class="text-sm text-smoke-700 lg:text-base">
{{ row.description }}
</p>
</div>
<!-- Media: image or video -->
<div
:class="
cn(
'order-1 flex lg:w-1/2',
i % 2 === 0 ? 'lg:order-2' : 'lg:order-1'
)
"
>
<img
v-if="row.media.type === 'image'"
:src="row.media.src"
:alt="row.media.alt ?? row.title"
loading="lazy"
decoding="async"
class="aspect-4/3 w-full rounded-4xl object-cover"
/>
<VideoPlayer
v-else
:locale="locale"
:aria-label="row.media.alt ?? row.title"
:src="row.media.src"
:poster="row.media.poster"
:tracks="row.media.tracks"
:autoplay="row.media.autoplay"
:loop="row.media.loop"
:minimal="row.media.minimal"
:hide-controls="row.media.hideControls"
class="w-full"
/>
</div>
</GlassCard>
</div>
</section>
</template>

View File

@@ -1,166 +0,0 @@
<script setup lang="ts">
import type { AnchorHTMLAttributes } from 'vue'
import { computed, onMounted, ref } from 'vue'
import { useNow } from '@vueuse/core'
import Button from '../ui/button/Button.vue'
import { resolveRel } from '../../utils/cta'
type Cta = {
label: string
href: string
target?: AnchorHTMLAttributes['target']
rel?: AnchorHTMLAttributes['rel']
}
type Visual =
| {
type: 'image'
src: string
alt: string
width?: number
height?: number
}
| {
type: 'video'
src: string
alt: string
poster?: string
width?: number
height?: number
}
const {
visual,
eyebrow,
title,
subtitle,
primaryCta,
secondaryCta,
youtubeVideoId,
startDateTime,
endDateTime
} = defineProps<{
visual?: Visual
eyebrow?: string
title: string
subtitle?: string
primaryCta: Cta
secondaryCta?: Cta
youtubeVideoId: string
startDateTime: string
endDateTime: string
}>()
const embedUrl = computed(
() =>
`https://www.youtube-nocookie.com/embed/${youtubeVideoId}?autoplay=1&mute=1&rel=0`
)
// Keep SSR/initial paint deterministic on the logo and only flip to the embed
// after client hydration — avoids a build-time `now` leaking into the markup.
const mounted = ref(false)
onMounted(() => {
mounted.value = true
})
const now = useNow({ interval: 30_000 })
const startMs = computed(() => new Date(startDateTime).getTime())
const endMs = computed(() => new Date(endDateTime).getTime())
const isLive = computed(
() =>
mounted.value &&
now.value.getTime() >= startMs.value &&
now.value.getTime() < endMs.value
)
</script>
<template>
<section
class="max-w-9xl mx-auto flex flex-col items-center px-6 py-16 text-center lg:py-24"
>
<div
v-if="isLive"
class="mb-10 aspect-video w-full overflow-hidden rounded-2xl lg:mb-12"
>
<iframe
:src="embedUrl"
:title="title"
class="size-full"
loading="lazy"
allow="autoplay; encrypted-media; picture-in-picture"
allowfullscreen
/>
</div>
<slot v-else name="visual">
<img
v-if="visual?.type === 'image'"
:src="visual.src"
:alt="visual.alt"
:width="visual.width"
:height="visual.height"
fetchpriority="high"
decoding="async"
class="mb-10 h-auto w-full max-w-md lg:mb-12 lg:max-w-lg"
/>
<video
v-else-if="visual?.type === 'video'"
:src="visual.src"
:poster="visual.poster"
:aria-label="visual.alt"
:width="visual.width"
:height="visual.height"
autoplay
loop
muted
playsinline
preload="metadata"
class="mb-10 h-auto w-full max-w-md lg:mb-12 lg:max-w-2xl"
/>
</slot>
<p
v-if="eyebrow"
class="mb-4 text-sm font-medium tracking-wide text-primary-comfy-canvas/70 uppercase"
>
{{ eyebrow }}
</p>
<h1
class="max-w-3xl text-4xl/snug font-light tracking-tight text-pretty text-primary-comfy-canvas lg:text-6xl/snug"
>
{{ title }}
</h1>
<p
v-if="subtitle"
class="mt-6 max-w-2xl text-base text-primary-comfy-canvas/70 lg:text-lg"
>
{{ subtitle }}
</p>
<div class="mt-10 flex flex-col gap-4 sm:flex-row lg:mt-12">
<Button
as="a"
:href="primaryCta.href"
:target="primaryCta.target"
:rel="resolveRel(primaryCta)"
size="lg"
>
{{ primaryCta.label }}
</Button>
<Button
v-if="secondaryCta"
as="a"
:href="secondaryCta.href"
:target="secondaryCta.target"
:rel="resolveRel(secondaryCta)"
variant="outline"
size="lg"
>
{{ secondaryCta.label }}
</Button>
</div>
</section>
</template>

View File

@@ -1,8 +1,6 @@
<script setup lang="ts">
import { cn } from '@comfyorg/tailwind-utils'
import type { HTMLAttributes } from 'vue'
import type { Locale } from '../../i18n/translations'
import BrandButton from '../common/BrandButton.vue'
import ProductHeroBadge from '../common/ProductHeroBadge.vue'
@@ -29,7 +27,6 @@ const {
badgeLogoAlt,
title,
titleHighlight,
subtitle,
features = [],
primaryCta,
secondaryCta,
@@ -44,17 +41,14 @@ const {
videoAutoplay = false,
videoLoop = false,
videoMinimal = false,
videoHideControls = false,
class: className
videoHideControls = false
} = defineProps<{
locale?: Locale
class?: HTMLAttributes['class']
badgeText: string
badgeLogoSrc?: string
badgeLogoAlt?: string
title: string
titleHighlight?: string
subtitle?: string
features?: string[]
primaryCta: Cta
secondaryCta?: Cta
@@ -78,8 +72,7 @@ const {
:class="
cn(
'max-w-9xl relative mx-auto flex flex-col items-center gap-12 px-6 pt-20 pb-16 md:pt-28 md:pb-24 lg:items-center lg:gap-16 lg:px-16',
imagePosition === 'right' ? 'lg:flex-row' : 'lg:flex-row-reverse',
className
imagePosition === 'right' ? 'lg:flex-row' : 'lg:flex-row-reverse'
)
"
>
@@ -91,7 +84,7 @@ const {
/>
<h1
class="mt-8 text-2xl leading-[125%] font-light tracking-[-1.44px] whitespace-pre-line text-primary-comfy-canvas md:text-4xl lg:text-5xl"
class="mt-8 text-2xl leading-[125%] font-light tracking-[-1.44px] text-primary-comfy-canvas md:text-4xl lg:text-5xl"
>
<template v-if="titleHighlight">
<span class="text-primary-warm-white">{{ titleHighlight }}</span>
@@ -100,13 +93,6 @@ const {
<template v-else>{{ title }}</template>
</h1>
<p
v-if="subtitle"
class="mt-6 max-w-xl text-base text-primary-comfy-canvas/80"
>
{{ subtitle }}
</p>
<ul v-if="features.length" class="mt-8 space-y-3">
<li
v-for="feature in features"
@@ -141,29 +127,27 @@ const {
</div>
<div class="order-first w-full lg:order-last lg:flex-1">
<slot name="media">
<VideoPlayer
v-if="videoSrc"
:locale
:src="videoSrc"
:poster="videoPoster"
:tracks="videoTracks"
:autoplay="videoAutoplay"
:loop="videoLoop"
:minimal="videoMinimal"
:hide-controls="videoHideControls"
/>
<img
v-else-if="imageSrc"
:src="imageSrc"
:alt="imageAlt"
:width="imageWidth"
:height="imageHeight"
fetchpriority="high"
decoding="async"
class="aspect-4/3 w-full rounded-3xl object-cover"
/>
</slot>
<VideoPlayer
v-if="videoSrc"
:locale
:src="videoSrc"
:poster="videoPoster"
:tracks="videoTracks"
:autoplay="videoAutoplay"
:loop="videoLoop"
:minimal="videoMinimal"
:hide-controls="videoHideControls"
/>
<img
v-else-if="imageSrc"
:src="imageSrc"
:alt="imageAlt"
:width="imageWidth"
:height="imageHeight"
fetchpriority="high"
decoding="async"
class="aspect-4/3 w-full rounded-3xl object-cover"
/>
</div>
</section>
</template>

View File

@@ -1,59 +0,0 @@
<script setup lang="ts">
export interface Reason {
id: string
title: string
description: string
}
const { highlightClass = 'text-white' } = defineProps<{
heading: string
headingHighlight?: string
highlightClass?: string
subtitle?: string
reasons: readonly Reason[]
}>()
</script>
<template>
<section
class="max-w-9xl mx-auto flex flex-col gap-4 px-6 py-16 lg:flex-row lg:gap-16 lg:py-24"
>
<!-- Left heading -->
<div
class="sticky top-20 z-10 w-full shrink-0 self-start bg-primary-comfy-ink py-4 lg:top-28 lg:w-115 lg:py-0"
>
<h2
class="text-4xl/16 font-light whitespace-pre-line text-primary-comfy-canvas lg:text-5xl/16"
>
{{ heading
}}<span v-if="headingHighlight" :class="highlightClass">{{
headingHighlight
}}</span>
</h2>
<p v-if="subtitle" class="mt-6 text-sm text-primary-comfy-canvas/70">
{{ subtitle }}
</p>
</div>
<!-- Right reasons list -->
<div class="flex-1">
<div
v-for="reason in reasons"
:key="reason.id"
class="flex flex-col gap-4 border-b border-primary-comfy-canvas/20 py-10 first:pt-0 lg:gap-12 xl:flex-row"
>
<div class="shrink-0 xl:w-84">
<h3
class="text-2xl font-light whitespace-pre-line text-primary-comfy-canvas"
>
{{ reason.title }}
</h3>
<slot name="reason-extra" :reason="reason" />
</div>
<p class="flex-1 text-sm text-primary-comfy-canvas/70">
{{ reason.description }}
</p>
</div>
</div>
</section>
</template>

View File

@@ -6,7 +6,6 @@ import type { HTMLAttributes } from 'vue'
import type { BrandButtonVariants } from './brandButton.variants'
import { brandButtonVariants } from './brandButton.variants'
import { resolveRel } from '../../utils/cta'
const props = defineProps<{
href?: string
@@ -17,8 +16,9 @@ const props = defineProps<{
class?: HTMLAttributes['class']
}>()
const resolvedRel = computed(() =>
resolveRel({ rel: props.rel, target: props.target })
const resolvedRel = computed(
() =>
props.rel ?? (props.target === '_blank' ? 'noopener noreferrer' : undefined)
)
</script>

View File

@@ -7,14 +7,12 @@ const {
label,
headingTag = 'h2',
maxWidth = 'lg',
headingSize = 'section',
align = 'center'
headingSize = 'section'
} = defineProps<{
label?: string
headingTag?: 'h1' | 'h2' | 'h3'
maxWidth?: 'md' | 'lg' | 'xl'
headingSize?: 'section' | 'hero'
align?: 'center' | 'start'
}>()
const maxWidthClass = {
@@ -30,14 +28,7 @@ const headingSizeClass = {
</script>
<template>
<div
:class="
cn(
maxWidthClass[maxWidth],
align === 'center' ? 'mx-auto text-center' : 'text-left'
)
"
>
<div :class="cn('mx-auto text-center', maxWidthClass[maxWidth])">
<SectionLabel v-if="label">{{ label }}</SectionLabel>
<component
:is="headingTag"

View File

@@ -37,15 +37,13 @@ const topColumns: { title: string; links: FooterLink[] }[] = [
{ label: t('nav.comfyLocal', locale), href: routes.download },
{ label: t('nav.comfyCloud', locale), href: routes.cloud },
{ label: t('nav.comfyApi', locale), href: routes.api },
{ label: t('nav.comfyEnterprise', locale), href: routes.cloudEnterprise },
{ label: t('nav.mcpServer', locale), href: routes.mcp }
{ label: t('nav.comfyEnterprise', locale), href: routes.cloudEnterprise }
]
},
{
title: t('footer.resources', locale),
links: [
{ label: t('nav.learning', locale), href: routes.learning },
{ label: t('nav.launches', locale), href: routes.launches },
{
label: t('footer.blog', locale),
href: externalLinks.blog,

View File

@@ -25,7 +25,7 @@ const {
data-slot="badge"
:data-variant="variant"
:data-size="size"
:class="cn(badgeVariants({ size, variant }), className)"
:class="cn(badgeVariants({ variant, size }), className)"
>
<slot name="prepend">
<component :is="prependIcon" v-if="prependIcon" />

View File

@@ -4,16 +4,15 @@ import { cva } from 'cva'
export const badgeVariants = cva({
base: 'text-primary-warm-gray font-formula leading-none focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 inline-flex w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-full border border-transparent font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:ring-[3px] [&>svg]:pointer-events-none [&>svg]:size-3',
variants: {
size: {
md: 'px-4 py-1 text-xs',
xs: 'px-2 py-0.5 text-[9px]'
},
variant: {
default: 'bg-transparency-ink-t80',
subtle: 'bg-transparency-white-t4 text-primary-comfy-canvas',
category: 'text-primary-comfy-yellow px-0 font-semibold uppercase',
accent:
'before:bg-primary-comfy-yellow relative isolate overflow-visible rounded-none bg-transparent px-2 py-0.5 text-[9px] font-bold tracking-wide text-primary-comfy-ink uppercase before:absolute before:inset-0 before:-z-10 before:-skew-x-12 before:rounded-sm'
},
size: {
md: 'px-4 py-1 text-xs',
xs: 'px-2 py-0.5 text-[9px]'
}
},
defaultVariants: {

View File

@@ -8,8 +8,7 @@ export const buttonVariants = cva(
{
variants: {
size: {
sm: 'h-8 px-4 py-2 text-xs md:text-sm',
default: 'h-10 px-6 py-2.5 text-xs md:text-sm',
default: 'h-10 px-6 py-2.5',
lg: 'h-14 px-8 py-4 text-base'
},
variant: {
@@ -18,8 +17,6 @@ export const buttonVariants = cva(
outline:
'text-primary-comfy-yellow hover:bg-primary-comfy-yellow border uppercase hover:text-primary-comfy-ink',
link: "text-primary-comfy-yellow h-auto justify-start px-0 py-1 text-base uppercase hover:opacity-90 [&_svg:not([class*='size-'])]:size-6",
underlineLink:
"text-primary-comfy-yellow relative h-auto justify-start px-0 py-1 uppercase after:absolute after:bottom-0 after:left-0 after:h-0.5 after:w-full after:origin-left after:scale-x-0 after:bg-current after:transition-transform after:duration-200 hover:opacity-90 hover:after:scale-x-100 [&_svg:not([class*='size-'])]:size-6",
nav: 'text-primary-warm-white hover:text-primary-comfy-yellow h-auto justify-between px-0 py-1 text-start text-2xl font-medium',
navMuted:
'hover:text-primary-comfy-yellow h-auto w-full justify-between px-0 py-1 text-start text-2xl font-medium text-primary-comfy-canvas uppercase'

View File

@@ -1,22 +0,0 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@comfyorg/tailwind-utils'
const { class: className } = defineProps<{
class?: HTMLAttributes['class']
}>()
</script>
<template>
<div
data-slot="card"
:class="
cn(
'bg-transparency-white-t4 text-primary-warm-white rounded-4.5xl flex flex-col gap-6 shadow-sm',
className
)
"
>
<slot />
</div>
</template>

View File

@@ -1,14 +0,0 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@comfyorg/tailwind-utils'
const { class: className } = defineProps<{
class?: HTMLAttributes['class']
}>()
</script>
<template>
<div data-slot="card-content" :class="cn('px-6', className)">
<slot />
</div>
</template>

View File

@@ -1,19 +0,0 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@comfyorg/tailwind-utils'
const { class: className } = defineProps<{
class?: HTMLAttributes['class']
}>()
</script>
<template>
<div
data-slot="card-description"
:class="
cn('line-clamp-3 text-base text-primary-comfy-canvas/70', className)
"
>
<slot />
</div>
</template>

View File

@@ -1,14 +0,0 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@comfyorg/tailwind-utils'
const { class: className } = defineProps<{
class?: HTMLAttributes['class']
}>()
</script>
<template>
<div data-slot="card-footer" :class="cn('flex items-center', className)">
<slot />
</div>
</template>

View File

@@ -1,14 +0,0 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@comfyorg/tailwind-utils'
const { class: className } = defineProps<{
class?: HTMLAttributes['class']
}>()
</script>
<template>
<div data-slot="card-header" :class="cn('flex flex-col gap-1.5', className)">
<slot />
</div>
</template>

View File

@@ -1,22 +0,0 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@comfyorg/tailwind-utils'
const { class: className } = defineProps<{
class?: HTMLAttributes['class']
}>()
</script>
<template>
<div
data-slot="card-title"
:class="
cn(
'text-xl leading-none font-medium text-primary-comfy-canvas md:text-2xl',
className
)
"
>
<slot />
</div>
</template>

View File

@@ -1,37 +0,0 @@
<script setup lang="ts">
import { Check, Copy } from '@lucide/vue'
import { useClipboard } from '@vueuse/core'
// 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.
const {
value,
copyLabel = 'Copy',
copiedLabel = 'Copied'
} = defineProps<{ value: string; copyLabel?: string; copiedLabel?: string }>()
const { copy, copied } = useClipboard({ copiedDuring: 2000 })
function handleCopy() {
void copy(value)
}
</script>
<template>
<div
class="bg-transparency-white-t4 border-primary-warm-gray flex items-center gap-2 rounded-xl border px-4 py-3"
>
<span class="flex-1 truncate font-mono text-xs text-primary-comfy-canvas">
{{ 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"
@click="handleCopy"
>
<component :is="copied ? Check : Copy" class="size-4" />
</button>
</div>
</template>

View File

@@ -22,12 +22,6 @@ interface HeroLogoConfig {
cursorTiltStrength: number
bgScale: number
slideDuration: number
svgMarkup: string
fitAxis: 'width' | 'height'
targetSize: number
respectReducedMotion: boolean
baseUrl: string
fadeInDurationMs: number
}
const DEFAULTS: HeroLogoConfig = {
@@ -40,25 +34,19 @@ const DEFAULTS: HeroLogoConfig = {
extrudeDepth: 200,
cursorTiltStrength: 0.5,
bgScale: 0.8,
slideDuration: 0.4,
svgMarkup: SVG_MARKUP,
fitAxis: 'height',
targetSize: 3,
respectReducedMotion: true,
baseUrl: BASE_URL,
fadeInDurationMs: 0
slideDuration: 0.4
}
function buildImageUrls(baseUrl: string): string[] {
function buildImageUrls(): string[] {
return Array.from({ length: IMAGE_COUNT }, (_, i) => {
const index = String(i).padStart(5, '0')
return `${baseUrl}/image_sequence_${index}.webp`
return `${BASE_URL}/image_sequence_${index}.webp`
})
}
function parseShapes(markup: string): THREE.Shape[] {
function parseShapes(): THREE.Shape[] {
const loader = new SVGLoader()
const svgData = loader.parse(markup)
const svgData = loader.parse(SVG_MARKUP)
const shapes: THREE.Shape[] = []
svgData.paths.forEach((path) => {
shapes.push(...SVGLoader.createShapes(path))
@@ -97,8 +85,7 @@ export function useHeroLogo(
onMounted(async () => {
try {
const container = containerRef.value
if (!container || (cfg.respectReducedMotion && prefersReducedMotion()))
return
if (!container || prefersReducedMotion()) return
const { width, height } = container.getBoundingClientRect()
@@ -115,9 +102,6 @@ export function useHeroLogo(
renderer.domElement.style.width = '100%'
renderer.domElement.style.height = '100%'
renderer.domElement.style.opacity = '0'
if (cfg.fadeInDurationMs > 0) {
renderer.domElement.style.transition = `opacity ${cfg.fadeInDurationMs}ms ease`
}
renderer.domElement.setAttribute('aria-hidden', 'true')
container.appendChild(renderer.domElement)
@@ -142,36 +126,24 @@ export function useHeroLogo(
camera.position.z = cfg.zoom
// SVG shape
const shapes = parseShapes(cfg.svgMarkup)
const shapes = parseShapes()
const tempGeo = new THREE.ShapeGeometry(shapes)
tempGeo.computeBoundingBox()
const bb = tempGeo.boundingBox
if (!bb) {
tempGeo.dispose()
cleanup?.()
return
}
const bb = tempGeo.boundingBox!
const cx = (bb.max.x + bb.min.x) / 2
const cy = (bb.max.y + bb.min.y) / 2
const fitExtent =
cfg.fitAxis === 'width' ? bb.max.x - bb.min.x : bb.max.y - bb.min.y
if (fitExtent <= 0) {
tempGeo.dispose()
cleanup?.()
return
}
const scaleFactor = cfg.targetSize / fitExtent
const scaleFactor = 3 / (bb.max.y - bb.min.y)
tempGeo.dispose()
// Image sequence textures — load first frame eagerly, rest lazily
const urls = buildImageUrls(cfg.baseUrl)
const urls = buildImageUrls()
const textures = await loadTextures(urls.slice(0, 1))
if (disposed) return
renderer.domElement.style.opacity = '1'
loaded.value = true
void loadTextures(urls.slice(1)).then((rest) => {
loadTextures(urls.slice(1)).then((rest) => {
if (!disposed) textures.push(...rest)
})

View File

@@ -8,7 +8,6 @@ const baseRoutes = {
cloudEnterprise: '/cloud/enterprise',
api: '/api',
gallery: '/gallery',
launches: '/launches',
about: '/about',
careers: '/careers',
customers: '/customers',
@@ -19,8 +18,7 @@ const baseRoutes = {
affiliates: '/affiliates',
affiliateTerms: '/affiliates/terms',
contact: '/contact',
models: '/p/supported-models',
mcp: '/mcp'
models: '/p/supported-models'
} as const
type Routes = typeof baseRoutes
@@ -61,12 +59,10 @@ export const externalLinks = {
discord: 'https://discord.com/invite/comfyorg',
docs: 'https://docs.comfy.org/',
docsApi: 'https://docs.comfy.org/api-reference/cloud',
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',
platform: 'https://platform.comfy.org',
platformUsage: 'https://platform.comfy.org/profile/usage',
reddit: 'https://www.reddit.com/r/comfyui/',

View File

@@ -1,248 +0,0 @@
// Image URLs are placeholders at media.comfy.org/website/drops/<id>.png —
// asset uploads and native zh-CN review are pending follow-ups (see
// apps/website/.scratch/drops-page/PRD.md).
import { externalLinks } from '../config/routes'
import type { LocalizedText } from '../i18n/translations'
type DropMedia =
| { type: 'image'; src: string; alt: LocalizedText }
| { type: 'video'; src: string; alt: LocalizedText; poster?: string }
export type Drop = {
id: string
badge?: LocalizedText
category: LocalizedText
media: DropMedia
title: LocalizedText
description: LocalizedText
cta: { label: LocalizedText; href: LocalizedText }
}
const EXPLORE: LocalizedText = { en: 'EXPLORE', 'zh-CN': '探索' }
const PLATFORM: LocalizedText = { en: 'Platform', 'zh-CN': '平台' }
const CLOUD: LocalizedText = { en: 'Cloud', 'zh-CN': '云端' }
const COMMUNITY: LocalizedText = { en: 'Community', 'zh-CN': '社区' }
const DEVELOPER: LocalizedText = { en: 'Developer', 'zh-CN': '开发者' }
const MODELS_AND_NODES: LocalizedText = {
en: 'Models & Nodes',
'zh-CN': '模型与节点'
}
const NEW_BADGE: LocalizedText = { en: 'NEW', 'zh-CN': '新' }
const FEATURED_BADGE: LocalizedText = { en: 'FEATURED', 'zh-CN': '精选' }
function imageFor(fileName: string, alt: LocalizedText): DropMedia {
return {
type: 'image',
src: `https://media.comfy.org/website/drops/${fileName}`,
alt
}
}
function videoFor(
fileName: string,
alt: LocalizedText,
poster?: string
): DropMedia {
return {
type: 'video',
src: `https://media.comfy.org/website/drops/${fileName}`,
alt,
...(poster && {
poster: `https://media.comfy.org/website/drops/${poster}`
})
}
}
export const drops: readonly Drop[] = [
{
id: 'desktop-client',
badge: NEW_BADGE,
category: PLATFORM,
media: imageFor('Drops_2x2card_Desktop.jpg', {
en: 'New Desktop Client',
'zh-CN': '新桌面客户端'
}),
title: { en: 'New Desktop Client', 'zh-CN': '新桌面客户端' },
description: {
en: 'A faster, redesigned desktop app for ComfyUI — one-click install and managed updates.',
'zh-CN': '更快、重新设计的 ComfyUI 桌面应用程序 — 一键安装与受管更新。'
},
cta: {
label: EXPLORE,
href: { en: '/download', 'zh-CN': '/zh-CN/download' }
}
},
{
id: 'app-mode',
badge: NEW_BADGE,
category: PLATFORM,
media: videoFor('Drops_2x2card_APP.mp4', {
en: 'App Mode',
'zh-CN': 'App 模式'
}),
title: { en: 'App Mode', 'zh-CN': 'App 模式' },
description: {
en: 'A simplified view of your workflows. Flip back to the node graph anytime to go deeper.',
'zh-CN': '工作流的简化视图。随时切换回节点图视图以深入了解。'
},
// TODO: no destination page yet — link out when App Mode lands.
cta: {
label: EXPLORE,
href: {
en: 'https://docs.comfy.org/interface/app-mode',
'zh-CN': 'https://docs.comfy.org/zh/interface/app-mode'
}
}
},
{
id: 'comfy-api',
badge: NEW_BADGE,
category: DEVELOPER,
media: imageFor('Drops_2x2card_API.jpg', {
en: 'Comfy API',
'zh-CN': 'Comfy API'
}),
title: { en: 'Comfy API', 'zh-CN': 'Comfy API' },
description: {
en: 'Turn any workflow into a production endpoint. Automate generation and scale to thousands of outputs.',
'zh-CN': '将任意工作流变成生产端点。自动化生成并扩展到数千个输出。'
},
cta: {
label: EXPLORE,
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: externalLinks.docsMcp, 'zh-CN': externalLinks.docsMcp }
}
},
{
id: 'community-workflows',
category: COMMUNITY,
media: imageFor('Drops_3x3card_Comm Workflows.jpg', {
en: 'Community Workflows',
'zh-CN': '社区工作流'
}),
title: {
en: 'Community Workflows',
'zh-CN': '社区工作流'
},
description: {
en: 'Browse and remix thousands of community-shared workflows. Start from a proven template.',
'zh-CN': '浏览和混搭数千个社区共享的工作流。从经过验证的模板开始。'
},
cta: {
label: EXPLORE,
href: { en: externalLinks.workflows, 'zh-CN': externalLinks.workflows }
}
},
{
id: 'supported-models',
category: MODELS_AND_NODES,
media: imageFor('Drops_Supported models.jpg', {
en: 'Supported Models',
'zh-CN': '支持的模型'
}),
title: { en: 'Supported Models', 'zh-CN': '支持的模型' },
description: {
en: 'Run the latest open and partner models — every checkpoint, LoRA, and ControlNet, ready to use in your graph.',
'zh-CN':
'运行最新的开源和合作伙伴模型 — 每个 checkpoint、LoRA 和 ControlNet 都可直接在工作流中使用。'
},
cta: {
label: EXPLORE,
href: { en: '/p/supported-models', 'zh-CN': '/zh-CN/p/supported-models' }
}
},
{
id: 'supported-nodes',
category: MODELS_AND_NODES,
media: videoFor('Drops_3x3card_supported nodes.mp4', {
en: 'Supported Nodes',
'zh-CN': '支持的节点'
}),
title: { en: 'Supported Nodes', 'zh-CN': '支持的节点' },
description: {
en: 'Thousands of community and partner nodes, curated and verified to run on Comfy Cloud.',
'zh-CN':
'数千个社区与合作伙伴节点,经过精选与验证,可在 Comfy Cloud 上运行。'
},
cta: {
label: EXPLORE,
href: {
en: '/cloud/supported-nodes',
'zh-CN': '/zh-CN/cloud/supported-nodes'
}
}
},
{
id: 'comfy-enterprise',
category: CLOUD,
media: imageFor('Drops_3x3card_enterprise.png', {
en: 'Comfy Enterprise',
'zh-CN': 'Comfy 企业版'
}),
title: { en: 'Comfy Enterprise', 'zh-CN': 'Comfy 企业版' },
description: {
en: 'Enterprise-grade infrastructure for the creative engine inside your organization.',
'zh-CN': '为您组织内创意引擎提供的企业级基础设施。'
},
cta: {
label: EXPLORE,
href: { en: '/cloud/enterprise', 'zh-CN': '/zh-CN/cloud/enterprise' }
}
},
{
id: 'learning-hub',
category: COMMUNITY,
media: imageFor('Drops_3x3_Learninghub.jpg', {
en: 'Learning Hub',
'zh-CN': '学习中心'
}),
title: { en: 'Learning Hub', 'zh-CN': '学习中心' },
description: {
en: 'Walkthroughs and ready-to-run workflows to take you from first render to production pipeline.',
'zh-CN': '配套教程与开箱即用的工作流,带您从第一次渲染走向生产管线。'
},
cta: {
label: { en: 'START LEARNING', 'zh-CN': '开始学习' },
href: { en: '/learning', 'zh-CN': '/zh-CN/learning' }
}
},
{
id: 'share-comfy',
badge: NEW_BADGE,
category: COMMUNITY,
media: videoFor('Drops_3x3card_Affilliate.mp4', {
en: 'Comfy Affiliate',
'zh-CN': 'Comfy Affiliate'
}),
title: {
en: 'Comfy Affiliate',
'zh-CN': 'Comfy Affiliate'
},
description: {
en: 'Share Comfy with your audience and earn for every creator you bring on board.',
'zh-CN': '与您的受众分享 Comfy为您带来的每一位创作者获得回报。'
},
// /affiliates is locale-invariant: same URL in both locales.
cta: {
label: { en: 'LEARN MORE', 'zh-CN': '了解更多' },
href: { en: '/affiliates', 'zh-CN': '/affiliates' }
}
}
]

View File

@@ -38,7 +38,7 @@ export const learningTutorials: readonly LearningTutorial[] = [
label: 'English'
}
],
href: 'https://comfy.org/workflows/8f2cf0df5da6-8f2cf0df5da6/',
// href: '#',
tags: [partnerNodesTag, imageToVideoTag]
},
{

View File

@@ -69,19 +69,10 @@ export function getMainNavigation(locale: Locale): NavItem[] {
{
header: t('nav.colFeatures', locale),
items: [
{
label: t('nav.mcpServer', locale),
href: routes.mcp,
badge: 'new'
},
// TODO: no page yet — re-enable when landing pages ship
// { label: t('nav.mcpServer', locale), href: '#', badge: 'new' },
// { label: t('nav.appMode', locale), href: '#' },
// { label: t('nav.agentSkills', locale), href: '#' },
{
label: t('nav.launches', locale),
href: routes.launches,
badge: 'new'
},
{
label: t('nav.docs', locale),
href: externalLinks.docs,

View File

@@ -11,16 +11,6 @@ const translations = {
'zh-CN': '图像生成视频'
},
// UI (global, reusable across sections)
'ui.copy': {
en: 'Copy',
'zh-CN': '复制'
},
'ui.copied': {
en: 'Copied',
'zh-CN': '已复制'
},
// CTAs (global, reusable across sections)
'cta.tryWorkflow': {
en: 'Try Workflow',
@@ -1835,308 +1825,6 @@ const translations = {
'我们尽力为经历面试流程的候选人提供有意义的反馈。由于申请量较大,在简历筛选阶段可能无法提供详细反馈。'
},
// MCP Meta
'mcp.meta.title': {
en: 'Comfy MCP — Drive ComfyUI from any AI agent',
'zh-CN': 'Comfy MCP — 让任何 AI 智能体驱动 ComfyUI'
},
'mcp.meta.description': {
en: 'Comfy MCP exposes the full ComfyUI engine over the Model Context Protocol. Generate images, video, audio, and 3D from Claude Code, Claude Desktop, and any MCP-compatible client.',
'zh-CN':
'Comfy MCP 通过模型上下文协议暴露完整的 ComfyUI 引擎,可在 Claude Code、Claude Desktop 及任何兼容 MCP 的客户端中生成图像、视频、音频和 3D 内容。'
},
// MCP HeroSection
'mcp.hero.heading': {
en: 'Drive ComfyUI from\nany AI agent.',
'zh-CN': '让任何 AI 智能体\n驱动 ComfyUI。'
},
'mcp.hero.subtitle': {
en: 'Comfy MCP exposes the full ComfyUI engine over the Model Context Protocol — so your assistant can access the ecosystem, build workflows, and generate images, video, audio, or 3D.',
'zh-CN':
'Comfy MCP 通过模型上下文协议暴露完整的 ComfyUI 引擎——让你的助手能够接入生态系统、构建工作流,并生成图像、视频、音频或 3D 内容。'
},
'mcp.hero.demoPrompt': {
en: "match this frame's palette, make the hero key art",
'zh-CN': '匹配这一帧的配色,生成主视觉关键画面'
},
'mcp.hero.viewDocs': {
en: 'VIEW DOCS',
'zh-CN': '查看文档'
},
'mcp.hero.runWorkflow': {
en: 'RUN A WORKFLOW',
'zh-CN': '运行工作流'
},
'mcp.hero.demoGenerate': {
en: 'GENERATE',
'zh-CN': '生成'
},
'mcp.hero.demoActionGenerateImage': {
en: 'GENERATE-IMAGE',
'zh-CN': '生成图像'
},
'mcp.hero.demoActionGenerate3d': {
en: 'GENERATE-3D ASSET',
'zh-CN': '生成 3D 资产'
},
'mcp.hero.demoActionUpscale': {
en: 'UPSCALE-IMAGE',
'zh-CN': '放大图像'
},
// MCP SetupStepsSection
'mcp.setup.label': {
en: 'GET STARTED',
'zh-CN': '快速开始'
},
'mcp.setup.heading': {
en: 'Set up Comfy MCP in three steps',
'zh-CN': '三步完成 Comfy MCP 配置'
},
'mcp.setup.subtitle': {
en: 'Add Comfy Cloud as a built-in connector in Claude, and the full ComfyUI toolset is available right in your chat.',
'zh-CN':
'将 Comfy Cloud 添加为 Claude 的内置连接器ComfyUI 全套工具即可直接在对话中使用。'
},
'mcp.setup.step1.label': { en: 'STEP 1', 'zh-CN': '第 1 步' },
'mcp.setup.step1.title': {
en: 'Open Claude settings',
'zh-CN': '打开 Claude 设置'
},
'mcp.setup.step1.description': {
en: 'Launch the app or open claude.ai and go to Settings > Connections',
'zh-CN': '启动应用或打开 claude.ai前往"设置 > 连接"'
},
'mcp.setup.step1.cta': {
en: 'SETTINGS → CONNECTIONS',
'zh-CN': '设置 > 连接'
},
'mcp.setup.step2.label': { en: 'STEP 2', 'zh-CN': '第 2 步' },
'mcp.setup.step2.title': {
en: 'Add the Comfy Cloud custom connector',
'zh-CN': '添加 Comfy Cloud 自定义连接器'
},
'mcp.setup.step2.description': {
en: 'Name it Comfy Cloud and paste the URL',
'zh-CN': '将其命名为 Comfy Cloud 并粘贴 URL'
},
'mcp.setup.step3.label': { en: 'STEP 3', 'zh-CN': '第 3 步' },
'mcp.setup.step3.title': {
en: 'Connect and sign in',
'zh-CN': '连接并登录'
},
'mcp.setup.step3.description': {
en: "Click Add > Connect, sign in with your Comfy account. You're all set. Now just ask Claude to generate an image.",
'zh-CN':
'点击"添加 > 连接",使用 Comfy 账户登录。配置完成。现在直接让 Claude 生成图像即可。'
},
// MCP WhyBuildSection
'mcp.why.heading': {
en: 'Why build on\n',
'zh-CN': '为什么选择\n'
},
'mcp.why.headingHighlight': {
en: 'Comfy MCP?',
'zh-CN': 'Comfy MCP'
},
'mcp.why.subtitle': {
en: 'A trusted infrastructure that lets engineers and professionals ship faster.',
'zh-CN': '一套值得信赖的基础设施,让工程师和专业人士交付更快。'
},
'mcp.why.1.title': {
en: 'Open protocol,\nany client.',
'zh-CN': '开放协议,\n任意客户端。'
},
'mcp.why.1.description': {
en: 'MCP is an open standard, so any MCP-compatible client can connect. Today Comfy supports Claude Code and Claude Desktop, with more clients coming.',
'zh-CN':
'MCP 是开放标准,因此任何兼容 MCP 的客户端都能接入。目前 Comfy 支持 Claude Code 和 Claude Desktop更多客户端即将推出。'
},
'mcp.why.2.title': {
en: 'The full engine,\nnot a sandbox.',
'zh-CN': '完整引擎,\n非沙箱环境。'
},
'mcp.why.2.description': {
en: 'Same tool your team uses. Fully connected multi-step, multi-GPU workflows. Everything available now and in the future.',
'zh-CN':
'与你团队使用的相同工具。完整连接的多步骤、多 GPU 工作流。当前及未来的所有功能均可使用。'
},
'mcp.why.3.title': {
en: 'Outputs you keep.',
'zh-CN': '输出归你所有。'
},
'mcp.why.3.description': {
en: 'Downloads go to your Comfy library — store, reuse, remix, and share without leaving the ecosystem.',
'zh-CN':
'下载内容保存到你的 Comfy 库——在生态系统内存储、复用、二次创作和分享。'
},
'mcp.why.4.title': {
en: 'Powered by\nComfy Cloud.',
'zh-CN': '由 Comfy Cloud\n提供支持。'
},
'mcp.why.4.description': {
en: 'Run without a local GPU through the same infrastructure your team already trusts.',
'zh-CN': '无需本地 GPU通过你团队信赖的相同基础设施运行。'
},
// MCP ToolsSection
'mcp.tools.heading': {
en: 'Everything ComfyUI can do,\nnow available as tools.',
'zh-CN': 'ComfyUI 能做的一切,\n现在都可作为工具调用。'
},
'mcp.tools.1.title': {
en: 'Generate anything',
'zh-CN': '生成任意内容'
},
'mcp.tools.1.description': {
en: 'Generate images, video, audio, 3D, upscale, or remove backgrounds. Add or remove elements in images, create or modify any visual, audio, or 3D asset at any scale.',
'zh-CN':
'生成图像、视频、音频、3D 内容,放大分辨率或移除背景。添加或删除图像元素,以任意规模创建或修改任何视觉、音频或 3D 资产。'
},
'mcp.tools.1.alt': {
en: 'Comfy MCP generating images, video, audio, and 3D assets from a single prompt',
'zh-CN': 'Comfy MCP 通过单个提示生成图像、视频、音频和 3D 资产'
},
'mcp.tools.2.title': {
en: 'Search the ecosystem',
'zh-CN': '搜索生态系统'
},
'mcp.tools.2.description': {
en: 'Query thousands of models, browse rankings, and choose workflow templates straight from your response.',
'zh-CN': '查询数千个模型,浏览排名,直接在对话中选择工作流模板。'
},
'mcp.tools.2.alt': {
en: 'Comfy MCP searching the ecosystem of models, rankings, and workflow templates',
'zh-CN': 'Comfy MCP 搜索模型、排名和工作流模板的生态系统'
},
'mcp.tools.3.title': {
en: 'Run real workflows',
'zh-CN': '运行真实工作流'
},
'mcp.tools.3.description': {
en: 'Turn any ComfyUI workflow into a callable tool. The full power of the engine, driven by your agent.',
'zh-CN':
'将任何 ComfyUI 工作流转换为可调用的工具。由你的智能体驱动完整的引擎能力。'
},
'mcp.tools.3.alt': {
en: 'Comfy MCP running a ComfyUI workflow as a callable tool from a chat',
'zh-CN': 'Comfy MCP 在对话中将 ComfyUI 工作流作为可调用工具运行'
},
// MCP HowItWorksSection
'mcp.howItWorks.heading': {
en: 'How it works',
'zh-CN': '工作原理'
},
'mcp.howItWorks.step1.number': { en: '01', 'zh-CN': '01' },
'mcp.howItWorks.step1.title': {
en: 'CONNECT',
'zh-CN': '连接'
},
'mcp.howItWorks.step1.description': {
en: 'Add the Comfy Cloud MCP server to Claude Code or Claude Desktop and sign in once with OAuth. No API keys to manage.',
'zh-CN':
'将 Comfy Cloud MCP 服务器添加到 Claude Code 或 Claude Desktop通过 OAuth 一次性登录。无需管理 API 密钥。'
},
'mcp.howItWorks.step2.number': { en: '02', 'zh-CN': '02' },
'mcp.howItWorks.step2.title': {
en: 'DISCOVER',
'zh-CN': '发现'
},
'mcp.howItWorks.step2.description': {
en: "Your agent gets Comfy's tools: search, generate, submit, and retrieve — everything it needs to create.",
'zh-CN':
'你的智能体获得 Comfy 的工具:搜索、生成、提交和获取——一切所需,应有尽有。'
},
'mcp.howItWorks.step3.number': { en: '03', 'zh-CN': '03' },
'mcp.howItWorks.step3.title': {
en: 'CREATE',
'zh-CN': '创作'
},
'mcp.howItWorks.step3.description': {
en: 'Request what you want, the agent queues and runs the workflow, and returns the finished result.',
'zh-CN': '描述你的需求,智能体排队执行工作流,并返回最终结果。'
},
// MCP FAQSection
'mcp.faq.heading': {
en: 'Q&As',
'zh-CN': '常见问答'
},
'mcp.faq.1.q': {
en: 'Which clients are supported?',
'zh-CN': '支持哪些客户端?'
},
'mcp.faq.1.a': {
en: 'Claude Code and Claude Desktop today, both signing in with OAuth. Support for more clients is coming.',
'zh-CN':
'目前支持 Claude Code 和 Claude Desktop均通过 OAuth 登录。更多客户端的支持即将推出。'
},
'mcp.faq.2.q': {
en: 'Do I need an API key?',
'zh-CN': '我需要 API 密钥吗?'
},
'mcp.faq.2.a': {
en: 'Not for Claude Code or Claude Desktop. They use OAuth. An API key is only needed for headless or CI setups with no browser.',
'zh-CN':
'Claude Code 和 Claude Desktop 不需要,它们使用 OAuth。仅在没有浏览器的无头或 CI 环境中才需要 API 密钥。'
},
'mcp.faq.3.q': {
en: 'Do the slash commands work in Claude Desktop?',
'zh-CN': '斜杠命令在 Claude Desktop 中可以使用吗?'
},
'mcp.faq.3.a': {
en: 'No. They ship in the Claude Code plugin. Desktop connects to the same MCP server, so the tools work; just ask in plain language.',
'zh-CN':
'不可以。斜杠命令包含在 Claude Code 插件中。Claude Desktop 连接的是同一个 MCP 服务器,因此工具可以正常使用;直接用自然语言提问即可。'
},
'mcp.faq.4.q': {
en: "The sign-in didn't open a browser.",
'zh-CN': '登录时没有打开浏览器。'
},
'mcp.faq.4.a': {
en: 'In Claude Code, run /mcp, select comfy-cloud, and choose Authenticate. In Claude Desktop, reopen the connector from Customize → Connectors.',
'zh-CN':
'在 Claude Code 中,运行 /mcp选择 comfy-cloud然后选择 Authenticate授权。在 Claude Desktop 中,从“自定义 → 连接器”重新打开该连接器。'
},
'mcp.faq.5.q': {
en: 'How do I connect in Claude Code?',
'zh-CN': '如何在 Claude Code 中连接?'
},
'mcp.faq.5.a': {
en: 'Add the marketplace and install the comfy-cloud plugin, then run /mcp → comfy-cloud → Authenticate. It adds the connection and slash commands in one step.',
'zh-CN':
'添加插件市场并安装 comfy-cloud 插件,然后运行 /mcp → comfy-cloud → Authenticate授权。一步即可添加连接和斜杠命令。'
},
'mcp.faq.6.q': {
en: "What's the server URL for Claude Desktop?",
'zh-CN': 'Claude Desktop 的服务器 URL 是什么?'
},
'mcp.faq.6.a': {
en: 'Add a custom connector in Customize → Connectors pointing to https://cloud.comfy.org/mcp, then sign in when prompted.',
'zh-CN':
'在“自定义 → 连接器”中添加一个指向 https://cloud.comfy.org/mcp 的自定义连接器,然后在提示时登录。'
},
'mcp.faq.7.q': {
en: 'What can my agent do once connected?',
'zh-CN': '连接后我的智能体能做什么?'
},
'mcp.faq.7.a': {
en: 'Generate images, video, audio, and 3D; search models, nodes, and templates; and run ComfyUI workflows, all from a chat.',
'zh-CN':
'生成图像、视频、音频和 3D搜索模型、节点和模板并运行 ComfyUI 工作流——全部在对话中完成。'
},
'mcp.faq.8.q': {
en: 'Is it generally available?',
'zh-CN': '现已正式发布了吗?'
},
'mcp.faq.8.a': {
en: 'Comfy Cloud MCP is in open beta and available to everyone.',
'zh-CN': 'Comfy Cloud MCP 目前处于公开测试阶段,所有人均可使用。'
},
// SiteNav
'nav.products': { en: 'Products', 'zh-CN': '产品' },
'nav.pricing': { en: 'Pricing', 'zh-CN': '价格' },
@@ -2161,7 +1849,6 @@ const translations = {
'nav.aboutUs': { en: 'About Us', 'zh-CN': '关于我们' },
'nav.careers': { en: 'Careers', 'zh-CN': '招聘' },
'nav.customerStories': { en: 'Customer Stories', 'zh-CN': '客户故事' },
'nav.launches': { en: 'Launches', 'zh-CN': '发布' },
'nav.downloadLocal': { en: 'DOWNLOAD DESKTOP', 'zh-CN': '下载桌面版' },
'nav.launchCloud': { en: 'LAUNCH CLOUD', 'zh-CN': '启动云端' },
'nav.ctaDesktopPrefix': { en: 'DOWNLOAD', 'zh-CN': '下载' },
@@ -2179,7 +1866,6 @@ const translations = {
'nav.back': { en: 'BACK', 'zh-CN': '返回' },
'nav.badgeNew': { en: 'NEW', 'zh-CN': '新' },
// Column headers used in HeaderMainDesktop dropdowns
'nav.mcpServer': { en: 'Comfy MCP', 'zh-CN': 'Comfy MCP' },
'nav.colFeatures': { en: 'Features', 'zh-CN': '功能' },
'nav.colPrograms': { en: 'Programs', 'zh-CN': '项目' },
'nav.colConnect': { en: 'Connect', 'zh-CN': '联系' },
@@ -5242,70 +4928,6 @@ const translations = {
'affiliate.cta.termsLabel': {
en: 'Read the affiliate program terms',
'zh-CN': '阅读联盟计划条款'
},
// Launches page (/launches) — head metadata
// zh-CN strings pending native review (see apps/website/.scratch/drops-page/PRD.md)
'launches.page.title': {
en: 'ComfyUI Live Demo & Q&A — June 29 Launch Livestream',
'zh-CN': 'ComfyUI 直播演示与问答 — 6 月 29 日发布直播'
},
'launches.page.description': {
en: 'Join the ComfyUI livestream on June 29 for a hands-on product demo and live Q&A. See whats new across desktop, cloud, and community, and get your questions answered.',
'zh-CN':
'6 月 29 日加入 ComfyUI 直播,观看实操产品演示并参与实时问答。了解桌面、云端和社区的最新内容,并获得解答。'
},
// Launches page (/launches) — hero section
// zh-CN strings pending native review (see apps/website/.scratch/drops-page/PRD.md)
'launches.hero.title': {
en: 'Everything new in ComfyUI',
'zh-CN': 'ComfyUI 全新内容'
},
'launches.hero.primary': {
en: 'Download Desktop',
'zh-CN': '下载桌面版'
},
'launches.hero.secondary': {
en: 'Launch Cloud',
'zh-CN': '启动云端'
},
'launches.hero.visualAlt': {
en: 'Comfy',
'zh-CN': 'Comfy'
},
// Launches page (/launches) — subscribe banner
// zh-CN strings pending native review (see apps/website/.scratch/drops-page/PRD.md)
'launches.banner.text': {
en: 'Join the live stream. Get answers in real time.',
'zh-CN': '加入直播,实时获得解答。'
},
'launches.banner.cta': {
en: 'Join livestream',
'zh-CN': '加入直播'
},
// Launches page (/launches) — closing CTA
// zh-CN strings pending native review (see apps/website/.scratch/drops-page/PRD.md)
'launches.cta.heading': {
en: 'Everything Comfy ships. All in one place.',
'zh-CN': 'Comfy 的全部内容,一处尽享。'
},
'launches.cta.primary': {
en: 'Open Comfy Cloud',
'zh-CN': '打开 Comfy Cloud'
},
'launches.cta.secondary': {
en: 'Try Workflow',
'zh-CN': '试用工作流'
},
// Launches page (/launches) — launches grid
// zh-CN strings pending native review (see apps/website/.scratch/drops-page/PRD.md)
'launches.section.title': {
en: 'Latest Launches',
'zh-CN': '最新发布'
}
} as const satisfies Record<string, Record<Locale, string>>

View File

@@ -1,20 +0,0 @@
---
import BaseLayout from '../layouts/BaseLayout.astro'
import CtaSection from '../templates/drops/CtaSection.vue'
import DropsSection from '../templates/drops/DropsSection.vue'
import HeroSection from '../templates/drops/HeroSection.vue'
import SubscribeBanner from '../templates/drops/SubscribeBanner.vue'
import { t } from '../i18n/translations'
const locale = 'en' as const
---
<BaseLayout
title={t('launches.page.title', locale)}
description={t('launches.page.description', locale)}
>
<SubscribeBanner locale={locale} client:load />
<HeroSection locale={locale} client:load />
<DropsSection locale={locale} />
<CtaSection locale={locale} />
</BaseLayout>

View File

@@ -1,24 +0,0 @@
---
import BaseLayout from '../layouts/BaseLayout.astro'
import ProductCardsSection from '../components/common/ProductCardsSection.vue'
import HeroSection from '../templates/mcp/HeroSection.vue'
import SetupSection from '../templates/mcp/SetupSection.vue'
import WhySection from '../templates/mcp/WhySection.vue'
import ToolsSection from '../templates/mcp/ToolsSection.vue'
import HowItWorksSection from '../templates/mcp/HowItWorksSection.vue'
import FAQSection from '../templates/mcp/FAQSection.vue'
import { t } from '../i18n/translations'
---
<BaseLayout
title={t('mcp.meta.title', 'en')}
description={t('mcp.meta.description', 'en')}
>
<HeroSection locale="en" client:load />
<SetupSection locale="en" client:visible />
<WhySection locale="en" />
<ToolsSection locale="en" />
<HowItWorksSection locale="en" />
<ProductCardsSection locale="en" label-key="products.labelProducts" />
<FAQSection client:visible locale="en" />
</BaseLayout>

View File

@@ -1,20 +0,0 @@
---
import BaseLayout from '../../layouts/BaseLayout.astro'
import CtaSection from '../../templates/drops/CtaSection.vue'
import DropsSection from '../../templates/drops/DropsSection.vue'
import HeroSection from '../../templates/drops/HeroSection.vue'
import SubscribeBanner from '../../templates/drops/SubscribeBanner.vue'
import { t } from '../../i18n/translations'
const locale = 'zh-CN' as const
---
<BaseLayout
title={t('launches.page.title', locale)}
description={t('launches.page.description', locale)}
>
<SubscribeBanner locale={locale} client:load />
<HeroSection locale={locale} client:load />
<DropsSection locale={locale} />
<CtaSection locale={locale} />
</BaseLayout>

View File

@@ -1,24 +0,0 @@
---
import BaseLayout from '../../layouts/BaseLayout.astro'
import ProductCardsSection from '../../components/common/ProductCardsSection.vue'
import HeroSection from '../../templates/mcp/HeroSection.vue'
import SetupSection from '../../templates/mcp/SetupSection.vue'
import WhySection from '../../templates/mcp/WhySection.vue'
import ToolsSection from '../../templates/mcp/ToolsSection.vue'
import HowItWorksSection from '../../templates/mcp/HowItWorksSection.vue'
import FAQSection from '../../templates/mcp/FAQSection.vue'
import { t } from '../../i18n/translations'
---
<BaseLayout
title={t('mcp.meta.title', 'zh-CN')}
description={t('mcp.meta.description', 'zh-CN')}
>
<HeroSection locale="zh-CN" client:load />
<SetupSection locale="zh-CN" client:visible />
<WhySection locale="zh-CN" />
<ToolsSection locale="zh-CN" />
<HowItWorksSection locale="zh-CN" />
<ProductCardsSection locale="zh-CN" label-key="products.labelProducts" />
<FAQSection client:visible locale="zh-CN" />
</BaseLayout>

View File

@@ -162,45 +162,6 @@
animation: ripple-effect 4s linear infinite;
}
@keyframes cursor-blink {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0;
}
}
@utility animate-cursor-blink {
animation: cursor-blink 1s step-end infinite;
}
.card-slide-enter-active {
transition:
transform 0.4s cubic-bezier(0.25, 0.46, 0.45, 0.94),
opacity 0.4s ease;
}
.card-slide-enter-from {
transform: translateX(56px);
opacity: 0;
}
/* Existing cards slide down smoothly when a new card is prepended. */
.card-slide-move {
transition: transform 0.4s cubic-bezier(0.25, 0.46, 0.45, 0.94);
}
.card-slide-leave-active {
transition: opacity 0.2s ease;
}
.card-slide-leave-to {
opacity: 0;
}
@utility animate-delay-* {
animation-delay: --value([*]);
}

View File

@@ -1,25 +0,0 @@
<script setup lang="ts">
import type { Locale } from '../../i18n/translations'
import CtaCenter01 from '../../components/blocks/CtaCenter01.vue'
import { externalLinks } from '../../config/routes'
import { t } from '../../i18n/translations'
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
</script>
<template>
<CtaCenter01
:heading="t('launches.cta.heading', locale)"
:primary-cta="{
label: t('launches.cta.primary', locale),
href: externalLinks.cloud,
target: '_blank'
}"
:secondary-cta="{
label: t('launches.cta.secondary', locale),
href: externalLinks.workflows,
target: '_blank'
}"
/>
</template>

View File

@@ -1,76 +0,0 @@
<script setup lang="ts">
import type { Drop } from '../../data/drops'
import type { Locale } from '../../i18n/translations'
import Badge from '../../components/ui/badge/Badge.vue'
import ButtonPill from '../../components/ui/button-pill/ButtonPill.vue'
import Card from '../../components/ui/card/Card.vue'
import CardContent from '../../components/ui/card/CardContent.vue'
import CardDescription from '../../components/ui/card/CardDescription.vue'
import CardFooter from '../../components/ui/card/CardFooter.vue'
import CardHeader from '../../components/ui/card/CardHeader.vue'
import CardTitle from '../../components/ui/card/CardTitle.vue'
const { drop, locale } = defineProps<{
drop: Drop
locale: Locale
}>()
</script>
<template>
<Card class="group/pill-trigger relative h-full overflow-hidden">
<a
:href="drop.cta.href[locale]"
:aria-label="`${drop.title[locale]} ${drop.cta.label[locale]}`"
class="rounded-4.5xl focus-visible:ring-primary-comfy-yellow absolute inset-0 z-10 focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none"
/>
<div class="flex flex-col-reverse">
<CardHeader class="gap-2 px-6">
<Badge variant="category">
{{ drop.category[locale] }}
</Badge>
<CardTitle class="pt-4">
{{ drop.title[locale] }}
</CardTitle>
<CardDescription>
{{ drop.description[locale] }}
</CardDescription>
</CardHeader>
<CardContent class="relative p-2">
<div class="aspect-video w-full overflow-hidden rounded-4xl">
<img
v-if="drop.media.type === 'image'"
:src="drop.media.src"
:alt="drop.media.alt[locale]"
loading="lazy"
decoding="async"
class="size-full object-cover object-center transition-transform duration-500 ease-out group-hover/pill-trigger:scale-105"
/>
<video
v-else
:src="drop.media.src"
:poster="drop.media.poster"
:aria-label="drop.media.alt[locale]"
autoplay
loop
muted
playsinline
preload="metadata"
class="size-full object-cover object-center transition-transform duration-500 ease-out group-hover/pill-trigger:scale-105"
/>
</div>
<Badge v-if="drop.badge" variant="accent" class="absolute top-6 left-8">
{{ drop.badge[locale] }}
</Badge>
</CardContent>
</div>
<CardFooter class="mt-auto px-6 pb-6">
<ButtonPill as="span" variant="ghost" icon-position="left">
{{ drop.cta.label[locale] }}
</ButtonPill>
</CardFooter>
</Card>
</template>

View File

@@ -1,29 +0,0 @@
<script setup lang="ts">
import type { Locale } from '../../i18n/translations'
import DropCard from './DropCard.vue'
import { drops } from '../../data/drops'
import { t } from '../../i18n/translations'
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
</script>
<template>
<section class="max-w-9xl mx-auto px-6 py-16 lg:py-24">
<h2
class="text-primary-warm-white text-3xl font-light tracking-tight lg:text-5xl"
>
{{ t('launches.section.title', locale) }}
</h2>
<div class="mt-10 grid grid-cols-1 gap-6 md:grid-cols-6 lg:mt-12">
<div
v-for="(drop, index) in drops"
:key="drop.id"
:class="index < 4 ? 'md:col-span-3' : 'md:col-span-2'"
>
<DropCard :drop :locale />
</div>
</div>
</section>
</template>

View File

@@ -1,35 +0,0 @@
<script setup lang="ts">
import type { Locale } from '../../i18n/translations'
import HeroLivestream01 from '../../components/blocks/HeroLivestream01.vue'
import LaunchesHeroLogo from './LaunchesHeroLogo.vue'
import { externalLinks, getRoutes } from '../../config/routes'
import { t } from '../../i18n/translations'
import { livestream } from './livestream'
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
const routes = getRoutes(locale)
</script>
<template>
<HeroLivestream01
:title="t('launches.hero.title', locale)"
:primary-cta="{
label: t('launches.hero.primary', locale),
href: routes.download
}"
:secondary-cta="{
label: t('launches.hero.secondary', locale),
href: externalLinks.cloud,
target: '_blank'
}"
:youtube-video-id="livestream.youtubeVideoId"
:start-date-time="livestream.startDateTime"
:end-date-time="livestream.endDateTime"
>
<template #visual>
<LaunchesHeroLogo :label="t('launches.hero.visualAlt', locale)" />
</template>
</HeroLivestream01>
</template>

File diff suppressed because one or more lines are too long

View File

@@ -1,61 +0,0 @@
<script setup lang="ts">
import { useTimeoutFn } from '@vueuse/core'
import { onMounted, ref } from 'vue'
import type { Locale } from '../../i18n/translations'
import { t } from '../../i18n/translations'
import Button from '@/components/ui/button/Button.vue'
import { resolveRel } from '../../utils/cta'
import { livestream } from './livestream'
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
const signUpHref = `https://www.youtube.com/watch?v=${livestream.youtubeVideoId}`
const signUpRel = resolveRel({ target: '_blank' })
// Hide once the livestream window closes — both for visitors arriving after
// the event and for visitors whose tab is open when it ends.
const endMs = new Date(livestream.endDateTime).getTime()
const visible = ref(true)
// useTimeoutFn auto-clears on unmount. Arm it client-side only so SSR never
// schedules a long-lived server timer.
const { start } = useTimeoutFn(
() => {
visible.value = false
},
() => Math.max(0, endMs - Date.now()),
{ immediate: false }
)
onMounted(() => {
if (endMs - Date.now() <= 0) {
visible.value = false
} else {
start()
}
})
</script>
<template>
<div v-if="visible" class="px-4">
<div
class="bg-primary-comfy-plum max-w-8xl rounded-5xl text-primary-warm-white mx-auto flex w-full flex-col items-center justify-center gap-2 px-6 py-5 text-center text-sm sm:flex-row sm:gap-4"
>
<p class="ppformula-text-center">
{{ t('launches.banner.text', locale) }}
</p>
<Button
:href="signUpHref"
as="a"
variant="underlineLink"
size="sm"
target="_blank"
:rel="signUpRel"
>
{{ t('launches.banner.cta', locale) }}
</Button>
</div>
</div>
</template>

View File

@@ -1,5 +0,0 @@
export const livestream = {
youtubeVideoId: 'yo7b_zHd20g',
startDateTime: '2026-06-29T15:00:00Z',
endDateTime: '2026-07-02T17:15:00Z'
} as const

View File

@@ -1,195 +0,0 @@
<script setup lang="ts">
import { Check } from '@lucide/vue'
import { computed, onMounted, onUnmounted, ref } from 'vue'
import type { Locale } from '../../i18n/translations'
import { t } from '../../i18n/translations'
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
const PROMPT = t('mcp.hero.demoPrompt', locale)
const generateLabel = t('mcp.hero.demoGenerate', locale)
const cards = [
{
actionKey: 'mcp.hero.demoActionGenerateImage',
file: 'moodboard_v1.png · 6-up',
tag: 'Gmail',
thumb: '/images/mcp/mcp-thumb-moodboard.webp'
},
{
actionKey: 'mcp.hero.demoActionGenerateImage',
file: 'concepts_0103.png',
tag: 'Notion',
thumb: '/images/mcp/mcp-thumb-concepts.webp'
},
{
actionKey: 'mcp.hero.demoActionGenerateImage',
file: 'hero_keyart.png',
tag: 'Figma',
thumb: '/images/mcp/mcp-thumb-keyart.webp'
},
{
actionKey: 'mcp.hero.demoActionGenerate3d',
file: 'asphalt_pbr/ · 5 maps',
tag: 'Blender',
thumb: '/images/mcp/mcp-thumb-asphalt.webp'
},
{
actionKey: 'mcp.hero.demoActionUpscale',
file: 'kaiju_neon_4k.png · 4096',
tag: null,
thumb: '/images/mcp/mcp-thumb-kaiju.webp'
}
] as const
const visibleCount = ref(0)
const displayedPrompt = ref('')
const promptDone = ref(false)
const displayedCards = computed(() =>
cards
.slice(0, visibleCount.value)
.map((card) => ({ ...card, action: t(card.actionKey, locale) }))
// Newest card first — it slides in right below the prompt box and pushes
// the rest down.
.reverse()
)
let timer: ReturnType<typeof setTimeout> | null = null
let active = false
function schedule(fn: () => void, ms: number) {
timer = setTimeout(() => {
if (active) fn()
}, ms)
}
function typePrompt(onDone: () => void) {
displayedPrompt.value = ''
promptDone.value = false
let i = 0
function step() {
i++
displayedPrompt.value = PROMPT.slice(0, i)
if (i < PROMPT.length) {
schedule(step, 35)
} else {
promptDone.value = true
schedule(onDone, 350)
}
}
schedule(step, 50)
}
function revealNextCard() {
if (visibleCount.value >= cards.length) {
// All done — pause then reset
schedule(() => {
visibleCount.value = 0
schedule(revealNextCard, 500)
}, 2500)
return
}
// Type the prompt, then slide in the next card
typePrompt(() => {
visibleCount.value++
schedule(revealNextCard, 400)
})
}
onMounted(() => {
active = true
schedule(revealNextCard, 600)
})
onUnmounted(() => {
active = false
if (timer) clearTimeout(timer)
})
</script>
<template>
<div class="flex flex-col gap-6 max-lg:h-[50vh]">
<!-- Prompt panel -->
<div
class="rounded-5xl flex flex-col justify-between gap-8 overflow-hidden bg-white/4 p-8"
>
<p
class="font-formula text-[17px] leading-relaxed font-light text-primary-comfy-canvas"
>
{{ displayedPrompt
}}<span
class="bg-primary-comfy-yellow ml-0.5 inline-block h-5.5 w-2 translate-y-0.5"
:class="promptDone ? 'animate-cursor-blink' : ''"
/>
</p>
<div class="flex items-center gap-3">
<div class="h-px flex-1 bg-white/10" />
<div
class="bg-primary-comfy-yellow font-formula rounded-2xl px-4 py-3 text-sm font-extrabold tracking-[0.7px] text-primary-comfy-ink uppercase"
>
{{ generateLabel }}
</div>
</div>
</div>
<!-- Cards accumulate each slides in from the right after its prompt cycle -->
<div class="relative overflow-hidden max-lg:min-h-0 max-lg:flex-1">
<TransitionGroup
name="card-slide"
tag="div"
class="flex flex-col gap-2.5 max-lg:absolute max-lg:inset-x-0 max-lg:top-0"
>
<div
v-for="(card, i) in displayedCards"
:key="card.file"
class="flex items-center gap-3.5 overflow-hidden rounded-3xl px-4 py-3.5"
:class="i === 0 ? 'bg-white/8' : 'bg-white/4'"
>
<img
:src="card.thumb"
:alt="card.action"
class="size-13.5 shrink-0 rounded-[14px] object-cover"
/>
<div class="flex min-w-0 flex-1 flex-col gap-1">
<p
class="font-formula text-primary-comfy-yellow text-xs font-extrabold tracking-[0.7px] uppercase"
>
{{ card.action }}
</p>
<p
class="font-formula truncate text-sm font-light text-primary-comfy-canvas"
>
{{ card.file }}
</p>
</div>
<span
v-if="card.tag"
class="font-formula relative isolate inline-flex h-8 shrink-0 items-center justify-center overflow-visible bg-transparent px-5 text-sm font-extrabold tracking-[0.7px] text-white/60 uppercase before:absolute before:inset-0 before:-z-10 before:-skew-x-12 before:rounded-sm before:bg-white/20"
>
<span class="ppformula-text-center">
{{ card.tag }}
</span>
</span>
<Check
class="size-4 shrink-0 text-primary-comfy-canvas/60"
:stroke-width="1.5"
/>
</div>
</TransitionGroup>
<!-- Bottom fade so accumulating cards dissolve into the page background -->
<div
class="pointer-events-none absolute inset-x-0 bottom-0 z-10 h-32 bg-linear-to-t from-primary-comfy-ink to-transparent lg:hidden"
/>
</div>
</div>
</template>

View File

@@ -1,19 +0,0 @@
<script setup lang="ts">
import FAQSplit01 from '../../components/blocks/FAQSplit01.vue'
import type { Locale } from '../../i18n/translations'
import { t } from '../../i18n/translations'
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
const faqNumbers = [1, 2, 3, 4, 5, 6, 7, 8] as const
const faqs = faqNumbers.map((n) => ({
id: String(n),
question: t(`mcp.faq.${n}.q`, locale),
answer: t(`mcp.faq.${n}.a`, locale)
}))
</script>
<template>
<FAQSplit01 :heading="t('mcp.faq.heading', locale)" :faqs="faqs" />
</template>

View File

@@ -1,27 +0,0 @@
<script setup lang="ts">
import HeroSplit01 from '../../components/blocks/HeroSplit01.vue'
import type { Locale } from '../../i18n/translations'
import { t } from '../../i18n/translations'
import ComfyMcpDemo from './ComfyMcpDemo.vue'
import { mcpCtas } from './ctas'
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
const ctas = mcpCtas(locale)
</script>
<template>
<HeroSplit01
:locale="locale"
class="min-h-screen"
badge-text="MCP"
:title="t('mcp.hero.heading', locale)"
:subtitle="t('mcp.hero.subtitle', locale)"
:primary-cta="ctas.runWorkflow"
:secondary-cta="ctas.docs"
>
<template #media>
<ComfyMcpDemo :locale="locale" />
</template>
</HeroSplit01>
</template>

View File

@@ -1,29 +0,0 @@
<script setup lang="ts">
import FeatureGrid02 from '../../components/blocks/FeatureGrid02.vue'
import type { FeatureStep } from '../../components/blocks/FeatureGrid02.vue'
import type { Locale } from '../../i18n/translations'
import { t } from '../../i18n/translations'
import { mcpCtas } from './ctas'
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
const ctas = mcpCtas(locale)
const stepNumbers = [1, 2, 3] as const
const steps: FeatureStep[] = stepNumbers.map((n) => ({
id: String(n),
number: t(`mcp.howItWorks.step${n}.number`, locale),
title: t(`mcp.howItWorks.step${n}.title`, locale),
description: t(`mcp.howItWorks.step${n}.description`, locale)
}))
</script>
<template>
<FeatureGrid02
:heading="t('mcp.howItWorks.heading', locale)"
:steps="steps"
:primary-cta="ctas.runWorkflow"
:secondary-cta="ctas.docs"
/>
</template>

View File

@@ -1,55 +0,0 @@
<script setup lang="ts">
import { ArrowUpRight } from '@lucide/vue'
import FeatureGrid01 from '../../components/blocks/FeatureGrid01.vue'
import type { FeatureCard } from '../../components/blocks/FeatureGrid01.vue'
import { externalLinks } from '../../config/routes'
import type { Locale } from '../../i18n/translations'
import { t } from '../../i18n/translations'
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
const cards: FeatureCard[] = [
{
id: 'step1',
label: t('mcp.setup.step1.label', locale),
title: t('mcp.setup.step1.title', locale),
description: t('mcp.setup.step1.description', locale),
action: {
type: 'link',
label: t('mcp.setup.step1.cta', locale),
href: `${externalLinks.cloud}/settings/connections`,
target: '_blank',
icon: ArrowUpRight
}
},
{
id: 'step2',
label: t('mcp.setup.step2.label', locale),
title: t('mcp.setup.step2.title', locale),
description: t('mcp.setup.step2.description', locale),
action: {
type: 'code',
value: externalLinks.mcpServer
}
},
{
id: 'step3',
label: t('mcp.setup.step3.label', locale),
title: t('mcp.setup.step3.title', locale),
description: t('mcp.setup.step3.description', locale)
}
]
</script>
<template>
<FeatureGrid01
:eyebrow="t('mcp.setup.label', locale)"
:heading="t('mcp.setup.heading', locale)"
:subtitle="t('mcp.setup.subtitle', locale)"
:columns="3"
:cards="cards"
:copy-label="t('ui.copy', locale)"
:copied-label="t('ui.copied', locale)"
/>
</template>

View File

@@ -1,66 +0,0 @@
<script setup lang="ts">
import FeatureRows01 from '../../components/blocks/FeatureRows01.vue'
import type { FeatureRow } from '../../components/blocks/FeatureRows01.vue'
import type { Locale, TranslationKey } from '../../i18n/translations'
import { t } from '../../i18n/translations'
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
type ToolMedia =
| { type: 'image'; src: string }
| {
type: 'video'
src: string
autoplay?: boolean
loop?: boolean
hideControls?: boolean
}
const tools: { n: 1 | 2 | 3; media: ToolMedia; altKey?: TranslationKey }[] = [
{
n: 1,
media: {
type: 'image',
src: 'https://media.comfy.org/website/mcp/generate-everything.gif'
},
altKey: 'mcp.tools.1.alt'
},
{
n: 2,
media: {
type: 'image',
src: 'https://media.comfy.org/website/mcp/search-ecosystem.png'
},
altKey: 'mcp.tools.2.alt'
},
{
n: 3,
media: {
type: 'video',
src: 'https://media.comfy.org/website/mcp/run-real-workflows.mp4',
autoplay: true,
loop: true,
hideControls: true
},
altKey: 'mcp.tools.3.alt'
}
]
const rows: FeatureRow[] = tools.map(({ n, media, altKey }) => {
const alt = altKey ? t(altKey, locale) : undefined
return {
id: String(n),
title: t(`mcp.tools.${n}.title`, locale),
description: t(`mcp.tools.${n}.description`, locale),
media: { ...media, alt }
}
})
</script>
<template>
<FeatureRows01
:locale="locale"
:heading="t('mcp.tools.heading', locale)"
:rows="rows"
/>
</template>

View File

@@ -1,26 +0,0 @@
<script setup lang="ts">
import ReasonsSplit01 from '../../components/blocks/ReasonsSplit01.vue'
import type { Reason } from '../../components/blocks/ReasonsSplit01.vue'
import type { Locale } from '../../i18n/translations'
import { t } from '../../i18n/translations'
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
const reasonNumbers = [1, 2, 3, 4] as const
const reasons: Reason[] = reasonNumbers.map((n) => ({
id: String(n),
title: t(`mcp.why.${n}.title`, locale),
description: t(`mcp.why.${n}.description`, locale)
}))
</script>
<template>
<ReasonsSplit01
:heading="t('mcp.why.heading', locale)"
:heading-highlight="t('mcp.why.headingHighlight', locale)"
highlight-class="text-primary-comfy-yellow"
:subtitle="t('mcp.why.subtitle', locale)"
:reasons="reasons"
/>
</template>

View File

@@ -1,27 +0,0 @@
import { externalLinks, getRoutes } from '../../config/routes'
import type { Locale } from '../../i18n/translations'
import { t } from '../../i18n/translations'
export interface McpCta {
label: string
href: string
target?: '_blank'
}
/**
* 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.
*/
export function mcpCtas(locale: Locale): { docs: McpCta; runWorkflow: McpCta } {
return {
docs: {
label: t('mcp.hero.viewDocs', locale),
href: externalLinks.docsMcp,
target: '_blank'
},
runWorkflow: {
label: t('mcp.hero.runWorkflow', locale),
href: getRoutes(locale).cloud
}
}
}

View File

@@ -1,18 +0,0 @@
import { describe, expect, it } from 'vitest'
import { resolveRel } from './cta'
describe('resolveRel', () => {
it('prefers an explicit rel over the target-derived default', () => {
expect(resolveRel({ rel: 'nofollow', target: '_blank' })).toBe('nofollow')
})
it('adds noopener noreferrer for _blank targets', () => {
expect(resolveRel({ target: '_blank' })).toBe('noopener noreferrer')
})
it('returns undefined for non-blank targets', () => {
expect(resolveRel({ target: '_self' })).toBeUndefined()
expect(resolveRel({})).toBeUndefined()
})
})

View File

@@ -1,10 +0,0 @@
import type { AnchorHTMLAttributes } from 'vue'
export function resolveRel(cta: {
rel?: AnchorHTMLAttributes['rel']
target?: AnchorHTMLAttributes['target']
}): AnchorHTMLAttributes['rel'] {
return (
cta.rel ?? (cta.target === '_blank' ? 'noopener noreferrer' : undefined)
)
}

View File

@@ -1,7 +1,7 @@
/** @knipIgnoreUsedByStackedPR */
export type VideoFormat = 'webm' | 'mp4'
type VideoSource = {
export type VideoSource = {
src: string
type: `video/${VideoFormat}`
format: VideoFormat

View File

@@ -14,24 +14,6 @@
{ "type": "host", "value": "website-frontend-comfyui.vercel.app" }
],
"headers": [{ "key": "X-Robots-Tag", "value": "index, follow" }]
},
{
"source": "/payment/(.*)",
"headers": [
{
"key": "X-Robots-Tag",
"value": "noindex"
}
]
},
{
"source": "/:locale/payment/(.*)",
"headers": [
{
"key": "X-Robots-Tag",
"value": "noindex"
}
]
}
],
"redirects": [

View File

@@ -1,197 +0,0 @@
{
"id": "test-missing-model-nested-promoted-widget",
"revision": 0,
"last_node_id": 4,
"last_link_id": 0,
"nodes": [
{
"id": 3,
"type": "outer-subgraph-with-promoted-missing-model",
"pos": [10, 250],
"size": [400, 200],
"flags": {},
"order": 0,
"mode": 0,
"title": "Resolved Shared Outer Subgraph",
"inputs": [],
"outputs": [],
"properties": {},
"widgets_values": ["resolved_model.safetensors"]
},
{
"id": 4,
"type": "outer-subgraph-with-promoted-missing-model",
"pos": [450, 250],
"size": [400, 200],
"flags": {},
"order": 1,
"mode": 0,
"title": "Outer Subgraph with Promoted Missing Model",
"inputs": [],
"outputs": [],
"properties": {},
"widgets_values": ["fake_model.safetensors"]
}
],
"links": [],
"groups": [],
"definitions": {
"subgraphs": [
{
"id": "outer-subgraph-with-promoted-missing-model",
"version": 1,
"state": {
"lastGroupId": 0,
"lastNodeId": 2,
"lastLinkId": 2,
"lastRerouteId": 0
},
"revision": 0,
"config": {},
"name": "Outer Subgraph with Promoted Missing Model",
"inputNode": {
"id": -10,
"bounding": [100, 200, 120, 60]
},
"outputNode": {
"id": -20,
"bounding": [600, 200, 120, 60]
},
"inputs": [
{
"id": "outer-ckpt-name-input-id",
"name": "ckpt_name",
"type": "COMBO",
"linkIds": [2],
"pos": { "0": 150, "1": 220 }
}
],
"outputs": [],
"widgets": [],
"nodes": [
{
"id": 2,
"type": "inner-subgraph-with-promoted-missing-model",
"pos": [250, 180],
"size": [400, 200],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [
{
"localized_name": "ckpt_name",
"name": "ckpt_name",
"type": "COMBO",
"widget": {
"name": "ckpt_name"
},
"link": 2
}
],
"outputs": [],
"properties": {},
"widgets_values": ["fake_model.safetensors"]
}
],
"links": [
{
"id": 2,
"origin_id": -10,
"origin_slot": 0,
"target_id": 2,
"target_slot": 0,
"type": "COMBO"
}
]
},
{
"id": "inner-subgraph-with-promoted-missing-model",
"version": 1,
"state": {
"lastGroupId": 0,
"lastNodeId": 1,
"lastLinkId": 1,
"lastRerouteId": 0
},
"revision": 0,
"config": {},
"name": "Inner Subgraph with Promoted Missing Model",
"inputNode": {
"id": -10,
"bounding": [100, 200, 120, 60]
},
"outputNode": {
"id": -20,
"bounding": [500, 200, 120, 60]
},
"inputs": [
{
"id": "inner-ckpt-name-input-id",
"name": "ckpt_name",
"type": "COMBO",
"linkIds": [1],
"pos": { "0": 150, "1": 220 }
}
],
"outputs": [],
"widgets": [],
"nodes": [
{
"id": 1,
"type": "CheckpointLoaderSimple",
"pos": [250, 180],
"size": [315, 98],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [
{
"localized_name": "ckpt_name",
"name": "ckpt_name",
"type": "COMBO",
"widget": {
"name": "ckpt_name"
},
"link": 1
}
],
"outputs": [
{ "name": "MODEL", "type": "MODEL", "links": null },
{ "name": "CLIP", "type": "CLIP", "links": null },
{ "name": "VAE", "type": "VAE", "links": null }
],
"properties": {
"Node name for S&R": "CheckpointLoaderSimple"
},
"widgets_values": ["fake_model.safetensors"]
}
],
"links": [
{
"id": 1,
"origin_id": -10,
"origin_slot": 0,
"target_id": 1,
"target_slot": 0,
"type": "COMBO"
}
]
}
]
},
"config": {},
"extra": {
"ds": {
"scale": 1,
"offset": [0, 0]
}
},
"models": [
{
"name": "fake_model.safetensors",
"url": "http://localhost:8188/api/devtools/fake_model.safetensors",
"directory": "checkpoints"
}
],
"version": 0.4
}

View File

@@ -4,9 +4,8 @@
import type { Locator, Page } from '@playwright/test'
import { TestIds } from '@e2e/fixtures/selectors'
import { getSlotKey } from '@/renderer/core/layout/slots/slotIdentifier'
import { toNodeId } from '@/types/nodeId'
import { VueNodeFixture } from '@e2e/fixtures/utils/vueNodeFixtures'
import { getSlotKey } from '@/renderer/core/layout/slots/slotIdentifier'
export class VueNodeHelpers {
/**
@@ -44,7 +43,7 @@ export class VueNodeHelpers {
.locator('.lg-slot--input')
.filter({
has: this.page.locator(
`[data-slot-key="${getSlotKey(toNodeId(nodeId), slotIndex, true)}"]`
`[data-slot-key="${getSlotKey(nodeId, slotIndex, true)}"]`
)
})
}
@@ -252,18 +251,14 @@ export class VueNodeHelpers {
const key = await slot.getByTestId('slot-dot').getAttribute('data-slot-key')
if (!key) return false
const [rawNodeId, type, slotId] = key.split('-')
const nodeId = toNodeId(rawNodeId)
return await this.page.evaluate(
([nodeId, type, slotId]) => {
const node = app?.canvas?.graph?.getNodeById(nodeId)
if (!node) return false
return await this.page.evaluate((key) => {
const [nodeId, type, slotId] = key.split('-')
const node = app?.canvas?.graph?.getNodeById(nodeId)
if (!node) return false
return type === 'in'
? node.inputs[Number(slotId)]?.link !== null
: !!node.outputs[Number(slotId)]?.links?.length
},
[nodeId, type, slotId] as const
)
return type === 'in'
? node.inputs[Number(slotId)]?.link !== null
: !!node.outputs[Number(slotId)].links?.length
}, key)
}
}

View File

@@ -93,7 +93,7 @@ export class NodeLibrarySidebarTabV2 extends SidebarTab {
public readonly searchInput: Locator
public readonly sidebarContent: Locator
public readonly allTab: Locator
public readonly essentialsTab: Locator
public readonly blueprintsTab: Locator
public readonly sortButton: Locator
public readonly nodePreview: Locator
@@ -101,8 +101,8 @@ export class NodeLibrarySidebarTabV2 extends SidebarTab {
super(page, 'node-library')
this.searchInput = page.getByPlaceholder('Search...')
this.sidebarContent = page.locator('.sidebar-content-container')
this.allTab = this.getTab('All nodes')
this.essentialsTab = this.getTab('Essentials')
this.allTab = this.getTab('All')
this.blueprintsTab = this.getTab('Blueprints')
this.sortButton = this.sidebarContent.getByRole('button', { name: 'Sort' })
this.nodePreview = page.getByTestId(TestIds.sidebar.nodePreviewCard)
}

View File

@@ -5,9 +5,10 @@ import type {
LGraph,
LGraphNode
} from '@/lib/litegraph/src/litegraph'
import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema'
import { toNodeId } from '@/types/nodeId'
import type { NodeId, SerializedNodeId } from '@/types/nodeId'
import type {
ComfyWorkflowJSON,
NodeId
} from '@/platform/workflow/validation/schemas/workflowSchema'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import { DefaultGraphPositions } from '@e2e/fixtures/constants/defaultGraphPositions'
import type { Position, Size } from '@e2e/fixtures/types'
@@ -41,12 +42,11 @@ export class NodeOperationsHelper {
}
async getSelectedNodeIds(): Promise<NodeId[]> {
const selectedNodeIds = await this.page.evaluate(() => {
return await this.page.evaluate(() => {
const selected = window.app?.canvas?.selected_nodes
if (!selected) return []
return Object.keys(selected)
return Object.keys(selected).map(Number)
})
return selectedNodeIds.map(toNodeId)
}
/**
@@ -114,8 +114,8 @@ export class NodeOperationsHelper {
return this.getNodeRefById(id)
}
async getNodeRefById(id: SerializedNodeId): Promise<NodeReference> {
return new NodeReference(toNodeId(id), this.comfyPage)
async getNodeRefById(id: NodeId): Promise<NodeReference> {
return new NodeReference(id, this.comfyPage)
}
async getNodeRefsByType(
@@ -136,7 +136,7 @@ export class NodeOperationsHelper {
},
{ type, includeSubgraph }
)
).map((id: SerializedNodeId) => this.getNodeRefById(id))
).map((id: NodeId) => this.getNodeRefById(id))
)
}
@@ -148,7 +148,7 @@ export class NodeOperationsHelper {
.app!.graph.nodes.filter((n: LGraphNode) => n.title === title)
.map((n: LGraphNode) => n.id)
}, title)
).map((id: SerializedNodeId) => this.getNodeRefById(id))
).map((id: NodeId) => this.getNodeRefById(id))
)
}

View File

@@ -1,9 +1,6 @@
import { expect } from '@playwright/test'
import type { Locator, Page } from '@playwright/test'
import { toNodeId } from '@/types/nodeId'
import type { NodeId } from '@/types/nodeId'
import { comfyPageFixture } from '@e2e/fixtures/ComfyPage'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import { assetPath } from '@e2e/fixtures/utils/paths'
@@ -48,9 +45,9 @@ async function orbitDragFromCanvasCenter(
export class Preview3DPipelineContext {
/** Matches node ids in `browser_tests/assets/3d/preview3d_pipeline.json`. */
static readonly loadNodeId = toNodeId(1)
static readonly loadNodeId = '1'
/** Matches node ids in `browser_tests/assets/3d/preview3d_pipeline.json`. */
static readonly previewNodeId = toNodeId(2)
static readonly previewNodeId = '2'
readonly load3d: Load3DHelper
readonly preview3d: Load3DHelper
@@ -64,9 +61,9 @@ export class Preview3DPipelineContext {
)
}
async getModelFileWidgetValue(nodeId: NodeId): Promise<string> {
async getModelFileWidgetValue(nodeId: string): Promise<string> {
return this.comfyPage.page.evaluate((id) => {
const node = window.app!.graph.getNodeById(id)
const node = window.app!.graph.getNodeById(Number(id))
if (!node?.widgets) return ''
const w = node.widgets.find((x) => x.name === 'model_file')
const v = w?.value
@@ -74,9 +71,9 @@ export class Preview3DPipelineContext {
}, nodeId)
}
async getLastTimeModelFile(nodeId: NodeId): Promise<string> {
async getLastTimeModelFile(nodeId: string): Promise<string> {
return this.comfyPage.page.evaluate((id) => {
const node = window.app!.graph.getNodeById(id)
const node = window.app!.graph.getNodeById(Number(id))
if (!node?.properties) return ''
const v = (node.properties as Record<string, unknown>)[
'Last Time Model File'
@@ -85,9 +82,9 @@ export class Preview3DPipelineContext {
}, nodeId)
}
async getCameraStateFromProperties(nodeId: NodeId): Promise<unknown> {
async getCameraStateFromProperties(nodeId: string): Promise<unknown> {
return this.comfyPage.page.evaluate((id) => {
const node = window.app!.graph.getNodeById(id)
const node = window.app!.graph.getNodeById(Number(id))
if (!node?.properties) return null
const cfg = (node.properties as Record<string, unknown>)['Camera Config']
if (cfg === null || typeof cfg !== 'object') return null

View File

@@ -7,7 +7,6 @@ import type {
} from '@/lib/litegraph/src/litegraph'
import type { ComfyWorkflow } from '@/platform/workflow/management/stores/comfyWorkflow'
import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema'
import { toNodeId } from '@/types/nodeId'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import { SubgraphEditor } from '@e2e/fixtures/components/SubgraphEditor'
@@ -550,7 +549,6 @@ export class SubgraphHelper {
}
static getTextSlotPosition(page: Page, nodeId: string) {
const localNodeId = toNodeId(nodeId)
return page.evaluate((id) => {
const node = window.app!.canvas.graph!.getNodeById(id)
if (!node) return null
@@ -567,7 +565,7 @@ export class SubgraphHelper {
}
}
return null
}, localNodeId)
}, nodeId)
}
static async expectWidgetBelowHeader(

View File

@@ -1,7 +1,7 @@
import { expect } from '@playwright/test'
import type { SerialisableLLink } from '@/lib/litegraph/src/types/serialisation'
import type { NodeId } from '@/types/nodeId'
import type { NodeId } from '@/platform/workflow/validation/schemas/workflowSchema'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import type { Position, Size } from '@e2e/fixtures/types'
import { VueNodeFixture } from '@e2e/fixtures/utils/vueNodeFixtures'

View File

@@ -1,136 +0,0 @@
import type { Page, Route } from '@playwright/test'
import {
getComboSpecComboOptions,
isComboInputSpec,
isComboInputSpecV1
} from '@/schemas/nodeDefSchema'
import type {
ComboInputSpec,
ComboInputSpecV2,
ComfyNodeDef,
InputSpec
} from '@/schemas/nodeDefSchema'
type ObjectInfoResponse = Record<string, ComfyNodeDef>
type ComboInput = ComboInputSpec | ComboInputSpecV2
const OBJECT_INFO_ROUTE = '**/object_info'
function getRequiredInput(
objectInfo: ObjectInfoResponse,
nodeType: string,
inputName: string
): InputSpec {
const nodeInfo = objectInfo[nodeType]
if (!nodeInfo) {
throw new Error(`Missing object_info entry for ${nodeType}`)
}
const requiredInputs = nodeInfo.input?.required
if (!requiredInputs) {
throw new Error(`Missing required inputs for ${nodeType}`)
}
const input = requiredInputs[inputName]
if (!input) {
throw new Error(`Missing input ${nodeType}.${inputName}`)
}
return input
}
function getComboInput(
objectInfo: ObjectInfoResponse,
nodeType: string,
inputName: string
): ComboInput {
const input = getRequiredInput(objectInfo, nodeType, inputName)
if (isComboInputSpec(input)) {
return input
}
throw new Error(`Expected ${nodeType}.${inputName} to be a combo input`)
}
export function setComboInputOptions(
objectInfo: ObjectInfoResponse,
nodeType: string,
inputName: string,
values: ReadonlyArray<string | number>
): void {
const input = getComboInput(objectInfo, nodeType, inputName)
const nextValues = [...values]
if (isComboInputSpecV1(input)) {
input[0] = nextValues
return
}
input[1] = { ...input[1], options: nextValues }
}
export function appendComboInputOptions(
objectInfo: ObjectInfoResponse,
nodeType: string,
inputName: string,
values: ReadonlyArray<string | number>
): void {
const input = getComboInput(objectInfo, nodeType, inputName)
setComboInputOptions(objectInfo, nodeType, inputName, [
...getComboSpecComboOptions(input),
...values
])
}
export async function routeObjectInfoFromSetupApi(
page: Page,
customize?: (objectInfo: ObjectInfoResponse) => void | Promise<void>
): Promise<() => Promise<void>> {
const setupApiUrl =
process.env.PLAYWRIGHT_SETUP_API_URL ?? 'http://127.0.0.1:8188'
const objectInfoUrl = new URL('/object_info', setupApiUrl).toString()
const objectInfoRouteHandler = async (route: Route) => {
let objectInfo: ObjectInfoResponse
try {
const response = await fetch(objectInfoUrl, {
signal: AbortSignal.timeout(5_000)
})
if (!response.ok) {
await route.fulfill({
status: response.status,
contentType: response.headers.get('content-type') ?? 'text/plain',
body: await response.text()
})
return
}
objectInfo = (await response.json()) as ObjectInfoResponse
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
await route.fulfill({
status: 502,
contentType: 'application/json',
body: JSON.stringify({
error: `Failed to fetch setup object_info from ${objectInfoUrl}: ${message}`
})
})
return
}
await customize?.(objectInfo)
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(objectInfo)
})
}
await page.route(OBJECT_INFO_ROUTE, objectInfoRouteHandler)
return async () => {
if (page.isClosed()) return
await page.unroute(OBJECT_INFO_ROUTE, objectInfoRouteHandler)
}
}

View File

@@ -1,425 +0,0 @@
import { readFileSync } from 'fs'
import { expect } from '@playwright/test'
import type { Locator } from '@playwright/test'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import { TestIds } from '@e2e/fixtures/selectors'
import { loadWorkflowAndOpenErrorsTab } from '@e2e/fixtures/helpers/ErrorsTabHelper'
import { assetPath } from '@e2e/fixtures/utils/paths'
import { PropertiesPanelHelper } from '@e2e/tests/propertiesPanel/PropertiesPanelHelper'
import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema'
import { toNodeId } from '@/types/nodeId'
const PROMOTED_MODEL_WIDGET_NAME = 'ckpt_name'
interface PromotedMissingModelWorkflow {
workflowName: string
hostNodeId: number
hostNodeTitle: string
sharedDefinitionSiblingHostNodeId?: number
sharedDefinitionSiblingHostNodeTitle?: string
}
type RootWorkflowNode = {
id: number | string
widgets_values?: unknown[] | Record<string, unknown>
}
type RootWorkflowData = ComfyWorkflowJSON & {
nodes?: RootWorkflowNode[]
}
export const NESTED_PROMOTED_MISSING_MODEL_WORKFLOW: PromotedMissingModelWorkflow =
{
workflowName: 'missing/missing_model_nested_promoted_widget',
hostNodeId: 4,
hostNodeTitle: 'Outer Subgraph with Promoted Missing Model',
sharedDefinitionSiblingHostNodeId: 3,
sharedDefinitionSiblingHostNodeTitle: 'Resolved Shared Outer Subgraph'
}
export function getMissingModelLabel(group: Locator, modelName: string) {
return group.getByRole('button', { name: modelName, exact: true })
}
export async function expectSingleMissingModelReference(
group: Locator,
modelName: string
) {
await expectMissingModelReferenceCount(group, modelName, 1)
}
export async function expectMissingModelReferenceCount(
group: Locator,
modelName: string,
count: number
) {
await expect(getMissingModelLabel(group, modelName)).toHaveCount(1)
const badge = group.getByTestId(TestIds.dialogs.missingModelReferenceCount)
if (count === 1) {
await expect(badge).toBeHidden()
return
}
await expect(badge).toBeVisible()
await expect(badge).toHaveText(String(count))
}
export async function loadPromotedMissingModelAndOpenErrorsTab(
comfyPage: ComfyPage,
workflow: PromotedMissingModelWorkflow,
modelName: string
): Promise<Locator> {
await loadWorkflowAndOpenErrorsTab(comfyPage, workflow.workflowName)
const missingModelGroup = comfyPage.page.getByTestId(
TestIds.dialogs.missingModelsGroup
)
await expectSingleMissingModelReference(missingModelGroup, modelName)
return missingModelGroup
}
export async function loadPromotedMissingModelWithHostValuesAndOpenErrorsTab(
comfyPage: ComfyPage,
workflow: PromotedMissingModelWorkflow,
hostValues: Record<number, string>,
modelName: string,
expectedReferenceCount: number
): Promise<Locator> {
await loadPromotedMissingModelWithHostValues(comfyPage, workflow, hostValues)
const errorOverlay = comfyPage.page.getByTestId(TestIds.dialogs.errorOverlay)
await expect(errorOverlay).toBeVisible()
await errorOverlay.getByTestId(TestIds.dialogs.errorOverlaySeeErrors).click()
await expect(errorOverlay).toBeHidden()
const missingModelGroup = comfyPage.page.getByTestId(
TestIds.dialogs.missingModelsGroup
)
await expectMissingModelReferenceCount(
missingModelGroup,
modelName,
expectedReferenceCount
)
return missingModelGroup
}
export async function expectNoMissingModelUi(comfyPage: ComfyPage) {
const panel = new PropertiesPanelHelper(comfyPage.page)
await expect(
comfyPage.page.getByTestId(TestIds.dialogs.errorOverlay)
).toBeHidden()
await panel.open(comfyPage.actionbar.propertiesButton)
await expect(
panel.root.getByTestId(TestIds.propertiesPanel.errorsTab)
).toBeHidden()
await expect(
comfyPage.page.getByTestId(TestIds.dialogs.missingModelsGroup)
).toBeHidden()
}
export async function selectVueComboPromotedModelByTitle(
comfyPage: ComfyPage,
nodeTitle: string,
modelName: string
) {
await comfyPage.vueNodes.selectComboOption(
nodeTitle,
PROMOTED_MODEL_WIDGET_NAME,
modelName
)
}
export async function selectVueAssetPromotedModel(
comfyPage: ComfyPage,
workflow: PromotedMissingModelWorkflow,
currentModelName: string,
modelName: string
) {
await selectModelFromFormDropdown(
comfyPage,
comfyPage.vueNodes.getNodeByTitle(workflow.hostNodeTitle),
currentModelName,
modelName
)
}
export async function selectSectionComboPromotedModel(
comfyPage: ComfyPage,
workflow: PromotedMissingModelWorkflow,
modelName: string
) {
const panel = await openHostNodeParametersPanel(comfyPage, workflow)
const combo = panel.contentArea.getByRole('combobox', {
name: PROMOTED_MODEL_WIDGET_NAME,
exact: true
})
await combo.click()
await comfyPage.page
.getByRole('option', { name: modelName, exact: true })
.click()
}
export async function selectSectionAssetPromotedModel(
comfyPage: ComfyPage,
workflow: PromotedMissingModelWorkflow,
currentModelName: string,
modelName: string
) {
const panel = await openHostNodeParametersPanel(comfyPage, workflow)
await selectModelFromFormDropdown(
comfyPage,
panel.contentArea,
currentModelName,
modelName
)
}
export async function setLegacyPromotedComboModel(
comfyPage: ComfyPage,
workflow: PromotedMissingModelWorkflow,
modelName: string
) {
await comfyPage.page.evaluate(
({ hostNodeId, widgetName, value }) => {
type LegacyPromotedWidget = {
name?: string
value?: unknown
callback?: (value: string) => void
setValue?: (
value: string,
options: {
e: PointerEvent
node: unknown
canvas: unknown
}
) => void
}
type LegacyPromotedNode = {
onWidgetChanged?: (
name: string,
newValue: string,
oldValue: unknown,
widget: LegacyPromotedWidget
) => void
widgets?: LegacyPromotedWidget[]
}
type LegacyPromotedGraph = {
getNodeById: (nodeId: number) => LegacyPromotedNode | undefined
}
const currentGraph = window.app?.graph as LegacyPromotedGraph | undefined
const hostNode: LegacyPromotedNode | undefined =
currentGraph?.getNodeById(hostNodeId)
if (!hostNode) {
throw new Error(`Expected subgraph host node ${hostNodeId}`)
}
const widget = hostNode.widgets?.find(
(entry) => entry.name === widgetName
) as LegacyPromotedWidget | undefined
if (!widget) {
throw new Error(`Expected host ${widgetName} widget`)
}
const oldValue = widget.value
if (widget.setValue) {
widget.setValue(value, {
e: new PointerEvent('pointerup'),
node: hostNode,
canvas: window.app!.canvas
})
return
}
widget.value = value
widget.callback?.(value)
hostNode.onWidgetChanged?.(
widget.name ?? widgetName,
value,
oldValue,
widget
)
},
{
hostNodeId: workflow.hostNodeId,
widgetName: PROMOTED_MODEL_WIDGET_NAME,
value: modelName
}
)
}
export async function selectLegacyPromotedAssetModel(
comfyPage: ComfyPage,
workflow: PromotedMissingModelWorkflow,
assetId: string
) {
await clickLegacyHostPromotedWidget(comfyPage, workflow)
const modal = comfyPage.page.locator(
'[data-component-id="AssetBrowserModal"]'
)
await expect(modal).toBeVisible()
const assetCard = modal.locator(`[data-asset-id="${assetId}"]`)
await expect(assetCard).toBeVisible()
await assetCard.getByRole('button', { name: 'Use', exact: true }).click()
await expect(modal).toBeHidden()
}
export async function expectResolvedPromotedModelSuppressesStaleInteriorErrors(
comfyPage: ComfyPage,
workflow: PromotedMissingModelWorkflow,
expectedStaleInteriorWidgets: Array<{
subgraphNodeIdToEnter: string
nodeTitle: string
}>,
resolvedModelName: string,
staleModelName: string
) {
await loadPromotedMissingModelWithHostValues(comfyPage, workflow, {
[workflow.hostNodeId]: resolvedModelName
})
const promotedModelCombo = comfyPage.vueNodes
.getNodeByTitle(workflow.hostNodeTitle)
.getByRole('combobox', { name: PROMOTED_MODEL_WIDGET_NAME, exact: true })
await expect(promotedModelCombo).toContainText(resolvedModelName)
await expectNoMissingModelUi(comfyPage)
for (const step of expectedStaleInteriorWidgets) {
await enterSubgraphForStaleInteriorCheck(
comfyPage,
step.subgraphNodeIdToEnter
)
await expect.poll(() => comfyPage.subgraph.isInSubgraph()).toBe(true)
await comfyPage.nextFrame()
const node = comfyPage.vueNodes.getNodeByTitle(step.nodeTitle)
await expect(node).toBeVisible()
const staleCombo = node.getByRole('combobox', {
name: PROMOTED_MODEL_WIDGET_NAME,
exact: true
})
await expect(
staleCombo,
`${step.nodeTitle} should expose the stale linked interior widget`
).toBeDisabled()
await expect(
staleCombo,
`${step.nodeTitle} should keep the stale interior value`
).toContainText(staleModelName)
await expectNoMissingModelUi(comfyPage)
}
}
async function openHostNodeParametersPanel(
comfyPage: ComfyPage,
workflow: PromotedMissingModelWorkflow
): Promise<PropertiesPanelHelper> {
await comfyPage.vueNodes.selectNode(String(workflow.hostNodeId))
const panel = new PropertiesPanelHelper(comfyPage.page)
await panel.open(comfyPage.actionbar.propertiesButton)
await expect(panel.getTab('Parameters')).toBeVisible()
await panel.switchToTab('Parameters')
return panel
}
async function loadPromotedMissingModelWithHostValues(
comfyPage: ComfyPage,
workflow: PromotedMissingModelWorkflow,
hostValues: Record<number, string>
) {
const graphData = readPromotedMissingModelWorkflow(workflow.workflowName)
for (const [hostNodeId, value] of Object.entries(hostValues)) {
setRootHostWidgetValue(graphData, Number(hostNodeId), value)
}
await comfyPage.workflow.loadGraphData(graphData)
await comfyPage.vueNodes.waitForNodes()
}
function readPromotedMissingModelWorkflow(workflowName: string) {
return JSON.parse(
readFileSync(assetPath(`${workflowName}.json`), 'utf-8')
) as RootWorkflowData
}
function setRootHostWidgetValue(
graphData: RootWorkflowData,
hostNodeId: number,
value: string
) {
const hostNode = graphData.nodes?.find(
(node) => Number(node.id) === hostNodeId
)
if (!hostNode) throw new Error(`Expected host node ${hostNodeId}`)
if (Array.isArray(hostNode.widgets_values)) {
hostNode.widgets_values[0] = value
return
}
hostNode.widgets_values = {
...(hostNode.widgets_values ?? {}),
[PROMOTED_MODEL_WIDGET_NAME]: value
}
}
async function selectModelFromFormDropdown(
comfyPage: ComfyPage,
root: Locator,
currentModelName: string,
nextModelName: string
) {
const trigger = root
.getByRole('button', { name: currentModelName, exact: true })
.first()
await expect(trigger).toBeVisible()
await trigger.click()
const menu = comfyPage.page.getByTestId('form-dropdown-menu')
await expect(menu).toBeVisible()
await menu.getByText(nextModelName, { exact: true }).click()
await expect(menu).toBeHidden()
}
async function clickLegacyHostPromotedWidget(
comfyPage: ComfyPage,
workflow: PromotedMissingModelWorkflow
) {
const hostNode = await comfyPage.nodeOps.getNodeRefById(workflow.hostNodeId)
await hostNode.centerOnNode()
const widget = await hostNode.getWidgetByName(PROMOTED_MODEL_WIDGET_NAME)
await widget.click()
}
async function enterSubgraphForStaleInteriorCheck(
comfyPage: ComfyPage,
nodeId: string
) {
const numericNodeId = Number(nodeId)
if (Number.isNaN(numericNodeId)) {
throw new Error(`Expected numeric subgraph node id, got ${nodeId}`)
}
const normalizedNodeId = String(numericNodeId)
const enterButton =
comfyPage.vueNodes.getSubgraphEnterButton(normalizedNodeId)
if ((await enterButton.count()) > 0) {
await comfyPage.vueNodes.enterSubgraph(normalizedNodeId)
return
}
await comfyPage.page.evaluate((targetNodeId) => {
const graph = window.app?.canvas.graph
const node = graph?.getNodeById(targetNodeId)
if (!node?.isSubgraphNode()) {
throw new Error(`Expected visible subgraph node ${targetNodeId}`)
}
window.app!.canvas.setGraph(node.subgraph)
}, toNodeId(normalizedNodeId))
await comfyPage.nextFrame()
await comfyPage.vueNodes.waitForNodes()
}

View File

@@ -1,5 +1,4 @@
import type { NodeProperty } from '@/lib/litegraph/src/LGraphNode'
import { toNodeId } from '@/types/nodeId'
import { parsePreviewExposures } from '@/core/schemas/previewExposureSchema'
import type { PreviewExposure } from '@/core/schemas/previewExposureSchema'
@@ -45,7 +44,6 @@ export async function getPromotedWidgets(
comfyPage: ComfyPage,
nodeId: string
): Promise<PromotedWidgetEntry[]> {
const localNodeId = toNodeId(nodeId)
const { widgetSources, previewExposures } = await comfyPage.page.evaluate(
(id) => {
const node = window.app!.canvas.graph!.getNodeById(id)
@@ -93,7 +91,7 @@ export async function getPromotedWidgets(
})
return { widgetSources, previewExposures }
},
localNodeId
nodeId
)
const exposures = isNodeProperty(previewExposures)

View File

@@ -10,10 +10,9 @@ import {
STABLE_CHECKPOINT_2
} from '@e2e/fixtures/data/assetFixtures'
import { TestIds } from '@e2e/fixtures/selectors'
import { toNodeId } from '@/types/nodeId'
const WORKFLOW = 'missing/missing_model_promoted_widget'
const HOST_NODE_ID = toNodeId(2)
const HOST_NODE_ID = 2
const WIDGET_NAME = 'ckpt_name'
const SELECTED_MODEL = STABLE_CHECKPOINT_2.name

View File

@@ -1,7 +1,5 @@
import { expect } from '@playwright/test'
import { toNodeId } from '@/types/nodeId'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
test.beforeEach(async ({ comfyPage }) => {
@@ -41,22 +39,16 @@ test.describe('Graph', { tag: ['@smoke', '@canvas'] }, () => {
await comfyPage.workflow.loadWorkflow('links/duplicate_links_slot_drift')
function evaluateGraph() {
const nodeIds = {
switchCfg: toNodeId(120),
ksampler85: toNodeId(85),
ksampler86: toNodeId(86)
}
return comfyPage.page.evaluate((nodeIds) => {
return comfyPage.page.evaluate(() => {
const graph = window.app!.graph!
const subgraph = graph.subgraphs.values().next().value
if (!subgraph) return { error: 'No subgraph found' }
// Node 120 = Switch (CFG), connects to both KSamplerAdvanced 85 and 86
const switchCfg = subgraph.getNodeById(nodeIds.switchCfg)
const ksampler85 = subgraph.getNodeById(nodeIds.ksampler85)
const ksampler86 = subgraph.getNodeById(nodeIds.ksampler86)
const switchCfg = subgraph.getNodeById(120)
const ksampler85 = subgraph.getNodeById(85)
const ksampler86 = subgraph.getNodeById(86)
if (!switchCfg || !ksampler85 || !ksampler86)
return { error: 'Required nodes not found' }
@@ -82,10 +74,7 @@ test.describe('Graph', { tag: ['@smoke', '@canvas'] }, () => {
// Count links from Switch(CFG) to node 85 cfg (should be 1, not 2)
let cfgLinkToNode85Count = 0
for (const link of subgraph.links.values()) {
if (
String(link.origin_id) === '120' &&
String(link.target_id) === '85'
)
if (link.origin_id === 120 && link.target_id === 85)
cfgLinkToNode85Count++
}
@@ -100,7 +89,7 @@ test.describe('Graph', { tag: ['@smoke', '@canvas'] }, () => {
switchOutputLinkCount,
cfgLinkToNode85Count
}
}, nodeIds)
})
}
// Poll graph state once, then assert all properties

View File

@@ -4,9 +4,6 @@ import { expect } from '@playwright/test'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
import { TestIds } from '@e2e/fixtures/selectors'
import { toNodeId } from '@/types/nodeId'
const IMAGE_COMPARE_NODE_ID = toNodeId(1)
test.describe('Image Compare', { tag: ['@widget', '@vue-nodes'] }, () => {
test.beforeEach(async ({ comfyPage }) => {
@@ -32,15 +29,15 @@ test.describe('Image Compare', { tag: ['@widget', '@vue-nodes'] }, () => {
}
) {
await comfyPage.page.evaluate(
({ nodeId, value }) => {
const node = window.app!.graph.getNodeById(nodeId)
({ value }) => {
const node = window.app!.graph.getNodeById(1)
const widget = node?.widgets?.find((w) => w.type === 'imagecompare')
if (widget) {
widget.value = value
widget.callback?.(value)
}
},
{ nodeId: IMAGE_COMPARE_NODE_ID, value }
{ value }
)
await comfyPage.nextFrame()
}
@@ -453,11 +450,11 @@ test.describe('Image Compare', { tag: ['@widget', '@vue-nodes'] }, () => {
test('ImageCompare node enforces minimum size', async ({ comfyPage }) => {
const minWidth = 400
const minHeight = 350
const size = await comfyPage.page.evaluate((nodeId) => {
const graphNode = window.app!.graph.getNodeById(nodeId)
const size = await comfyPage.page.evaluate(() => {
const graphNode = window.app!.graph.getNodeById(1)
if (!graphNode?.size) return null
return { width: graphNode.size[0], height: graphNode.size[1] }
}, IMAGE_COMPARE_NODE_ID)
})
expect(
size,
'ImageCompare node id 1 must exist in loaded workflow graph'
@@ -603,15 +600,15 @@ test.describe('Image Compare', { tag: ['@widget', '@vue-nodes'] }, () => {
}) => {
const url = createTestImageDataUrl('Legacy', '#c00')
await comfyPage.page.evaluate(
({ nodeId, url }) => {
const node = window.app!.graph.getNodeById(nodeId)
({ url }) => {
const node = window.app!.graph.getNodeById(1)
const widget = node?.widgets?.find((w) => w.type === 'imagecompare')
if (widget) {
widget.value = url
widget.callback?.(url)
}
},
{ nodeId: IMAGE_COMPARE_NODE_ID, url }
{ url }
)
await comfyPage.nextFrame()

View File

@@ -1,7 +1,6 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
import { toNodeId } from '@/types/nodeId'
test.describe('Image Crop', () => {
test.beforeEach(async ({ comfyPage }) => {
@@ -96,15 +95,15 @@ test.describe('Image Crop', () => {
const newBounds = { x: 50, y: 100, width: 200, height: 300 }
await comfyPage.page.evaluate(
({ nodeId, bounds }) => {
const node = window.app!.graph.getNodeById(nodeId)
({ bounds }) => {
const node = window.app!.graph.getNodeById(1)
const widget = node?.widgets?.find((w) => w.type === 'imagecrop')
if (widget) {
widget.value = bounds
widget.callback?.(bounds)
}
},
{ nodeId: toNodeId(1), bounds: newBounds }
{ bounds: newBounds }
)
await comfyPage.nextFrame()

View File

@@ -2,16 +2,15 @@ import { expect } from '@playwright/test'
import type { Page } from '@playwright/test'
import { load3dTest as test } from '@e2e/fixtures/helpers/Load3DFixtures'
import { toNodeId } from '@/types/nodeId'
const getGizmoConfig = (page: Page) =>
page.evaluate((nodeId) => {
const n = window.app!.graph.getNodeById(nodeId)
page.evaluate(() => {
const n = window.app!.graph.getNodeById(1)
const modelConfig = n?.properties?.['Model Config'] as
| { gizmo?: { enabled: boolean; mode: string } }
| undefined
return modelConfig?.gizmo
}, toNodeId(1))
})
test.describe('Load3D Gizmo Controls', () => {
test(

View File

@@ -2,7 +2,6 @@ import { expect } from '@playwright/test'
import { assetPath } from '@e2e/fixtures/utils/paths'
import { load3dTest as test } from '@e2e/fixtures/helpers/Load3DFixtures'
import { toNodeId } from '@/types/nodeId'
test.describe('Load3D', () => {
test(
@@ -68,13 +67,13 @@ test.describe('Load3D', () => {
await expect
.poll(() =>
comfyPage.page.evaluate((nodeId) => {
const n = window.app!.graph.getNodeById(nodeId)
comfyPage.page.evaluate(() => {
const n = window.app!.graph.getNodeById(1)
const config = n?.properties?.['Scene Config'] as
| Record<string, string>
| undefined
return config?.backgroundColor
}, toNodeId(1))
})
)
.toBe('#cc3333')

View File

@@ -3,7 +3,6 @@ import { expect, mergeTests } from '@playwright/test'
import { ExecutionHelper } from '@e2e/fixtures/helpers/ExecutionHelper'
import { maskEditorTest as test } from '@e2e/fixtures/helpers/MaskEditorHelper'
import { webSocketFixture } from '@e2e/fixtures/ws'
import { toNodeId } from '@/types/nodeId'
const wstest = mergeTests(test, webSocketFixture)
@@ -332,8 +331,7 @@ wstest(
async function getNodeOutput() {
return await comfyPage.page.evaluate(
(nodeId) => graph!.getNodeById(nodeId)!.images?.[0]?.filename,
toNodeId(1)
() => graph!.getNodeById('1')!.images?.[0]?.filename
)
}

View File

@@ -2,8 +2,6 @@ import {
comfyExpect as expect,
comfyPageFixture as test
} from '@e2e/fixtures/ComfyPage'
import { toNodeId } from '@/types/nodeId'
import type { SerializedNodeId } from '@/types/nodeId'
type ComfyPage = Parameters<Parameters<typeof test>[2]>[0]['comfyPage']
@@ -34,13 +32,12 @@ async function addGhostAtCenter(comfyPage: ComfyPage) {
return { nodeId: nodeRef.id, centerX, centerY }
}
function getNodeById(comfyPage: ComfyPage, nodeId: SerializedNodeId) {
const localNodeId = toNodeId(nodeId)
function getNodeById(comfyPage: ComfyPage, nodeId: number | string) {
return comfyPage.page.evaluate((id) => {
const node = window.app!.graph.getNodeById(id)
if (!node) return null
return { ghost: !!node.flags.ghost }
}, localNodeId)
}, nodeId)
}
for (const mode of ['litegraph', 'vue'] as const) {

View File

@@ -33,11 +33,49 @@ test.describe('Node Library Essentials Tab', { tag: '@ui' }, () => {
})
})
test('Node library opens via sidebar', async ({ comfyPage }) => {
const tabButton = comfyPage.page.locator('.node-library-tab-button')
await tabButton.click()
const sidebarContent = comfyPage.page.locator(
'.comfy-vue-side-bar-container'
)
await expect(sidebarContent).toBeVisible()
})
test('Essentials tab is visible in node library', async ({ comfyPage }) => {
const tabButton = comfyPage.page.locator('.node-library-tab-button')
await tabButton.click()
const essentialsTab = comfyPage.page.getByRole('tab', {
name: /essentials/i
})
await expect(essentialsTab).toBeVisible()
})
test('Clicking essentials tab shows essential node cards', async ({
comfyPage
}) => {
const tabButton = comfyPage.page.locator('.node-library-tab-button')
await tabButton.click()
const essentialsTab = comfyPage.page.getByRole('tab', {
name: /essentials/i
})
await essentialsTab.click()
const essentialCards = comfyPage.page.locator('[data-node-name]')
await expect(essentialCards.first()).toBeVisible()
})
test('Essential node cards have node names', async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.NodeLibrary.NewDesign', true)
const tab = comfyPage.menu.nodeLibraryTabV2
await tab.open()
await tab.essentialsTab.click()
const tabButton = comfyPage.page.locator('.node-library-tab-button')
await tabButton.click()
const essentialsTab = comfyPage.page.getByRole('tab', {
name: /essentials/i
})
await essentialsTab.click()
const firstCard = comfyPage.page.locator('[data-node-name]').first()
await expect(firstCard).toBeVisible()
@@ -48,18 +86,21 @@ test.describe('Node Library Essentials Tab', { tag: '@ui' }, () => {
test('Node library can switch between all and essentials tabs', async ({
comfyPage
}) => {
await comfyPage.settings.setSetting('Comfy.NodeLibrary.NewDesign', true)
const tab = comfyPage.menu.nodeLibraryTabV2
await tab.open()
await tab.allTab.click()
const tabButton = comfyPage.page.locator('.node-library-tab-button')
await tabButton.click()
await tab.essentialsTab.click()
await expect(tab.essentialsTab).toHaveAttribute('aria-selected', 'true')
const essentialsTab = comfyPage.page.getByRole('tab', {
name: /essentials/i
})
const allNodesTab = comfyPage.page.getByRole('tab', { name: /^all$/i })
await essentialsTab.click()
await expect(essentialsTab).toHaveAttribute('aria-selected', 'true')
const essentialCards = comfyPage.page.locator('[data-node-name]')
await expect(essentialCards.first()).toBeVisible()
await tab.allTab.click()
await expect(tab.allTab).toHaveAttribute('aria-selected', 'true')
await expect(tab.essentialsTab).toHaveAttribute('aria-selected', 'false')
await allNodesTab.click()
await expect(allNodesTab).toHaveAttribute('aria-selected', 'true')
await expect(essentialsTab).toHaveAttribute('aria-selected', 'false')
})
})

View File

@@ -12,7 +12,6 @@ import {
setupNodeReplacement
} from '@e2e/fixtures/helpers/NodeReplacementHelper'
import { TestIds } from '@e2e/fixtures/selectors'
import { toNodeId } from '@/types/nodeId'
const renderModes = [
{ name: 'vue nodes', vueNodesEnabled: true },
@@ -246,10 +245,8 @@ test.describe('Node replacement', { tag: ['@node', '@ui'] }, () => {
.click()
const replacedNodeOutputLinkCount = await comfyPage.page.evaluate(
(nodeId) =>
window.app!.graph!.getNodeById(nodeId)?.outputs[0]?.links
?.length ?? 0,
toNodeId(2)
() =>
window.app!.graph!.getNodeById(2)?.outputs[0]?.links?.length ?? 0
)
expect(
replacedNodeOutputLinkCount,

View File

@@ -10,20 +10,8 @@ import {
countAssetRequestsByTag,
createCloudAssetsFixture
} from '@e2e/fixtures/assetApiFixture'
import {
cleanupFakeModel,
loadWorkflowAndOpenErrorsTab
} from '@e2e/fixtures/helpers/ErrorsTabHelper'
import {
NESTED_PROMOTED_MISSING_MODEL_WORKFLOW,
expectNoMissingModelUi,
loadPromotedMissingModelAndOpenErrorsTab,
selectLegacyPromotedAssetModel,
selectSectionAssetPromotedModel,
selectVueAssetPromotedModel
} from '@e2e/fixtures/utils/promotedMissingModel'
import { loadWorkflowAndOpenErrorsTab } from '@e2e/fixtures/helpers/ErrorsTabHelper'
import { TestIds } from '@e2e/fixtures/selectors'
import { toNodeId } from '@/types/nodeId'
import { PropertiesPanelHelper } from '@e2e/tests/propertiesPanel/PropertiesPanelHelper'
import type { AssetMetadata } from '@/platform/assets/schemas/assetSchema'
@@ -32,8 +20,6 @@ const WORKFLOW = 'missing/nested_subgraph_installed_model'
const IMPORT_SECTIONS_WORKFLOW = 'missing/cloud_missing_model_import_sections'
const OUTER_SUBGRAPH_NODE_ID = '205'
const LOTUS_MODEL_NAME = 'lotus-depth-d-v1-1.safetensors'
const FAKE_MODEL_NAME = 'fake_model.safetensors'
const RESOLVED_PROMOTED_MODEL_NAME = 'resolved_model.safetensors'
const CLOUD_IMPORTABLE_MODEL_NAME = 'cloud_importable_model.safetensors'
const CLOUD_UNKNOWN_MODEL_NAME = 'cloud_unknown_model.safetensors'
const CLOUD_IMPORTED_CANONICAL_MODEL_NAME =
@@ -69,25 +55,7 @@ const EXISTING_CLOUD_IMPORTABLE_MODEL: Asset & { hash?: string } = {
}
}
const RESOLVED_PROMOTED_MODEL_ASSET: Asset & { hash?: string } = {
id: 'test-resolved-promoted-model',
name: RESOLVED_PROMOTED_MODEL_NAME,
hash: 'blake3:0000000000000000000000000000000000000000000000000000000000000205',
size: 1_024,
mime_type: 'application/octet-stream',
tags: ['models', 'checkpoints'],
created_at: '2026-05-05T00:00:00Z',
updated_at: '2026-05-05T00:00:00Z',
last_access_time: '2026-05-05T00:00:00Z',
user_metadata: {
filename: RESOLVED_PROMOTED_MODEL_NAME
}
}
const test = createCloudAssetsFixture([LOTUS_DIFFUSION_MODEL])
const promotedModelTest = createCloudAssetsFixture([
RESOLVED_PROMOTED_MODEL_ASSET
])
function getRequestedIncludeTags(requestUrl: string): string[] {
return (
@@ -385,94 +353,13 @@ test.describe(
await expect
.poll(() =>
comfyPage.page.evaluate((nodeId) => {
const node = window.app!.graph.getNodeById(nodeId)
comfyPage.page.evaluate(() => {
const node = window.app!.graph.getNodeById(1)
return node?.widgets?.find((widget) => widget.name === 'ckpt_name')
?.value
}, toNodeId(1))
})
)
.toBe(CLOUD_IMPORTED_CANONICAL_MODEL_NAME)
})
}
)
promotedModelTest.describe(
'Errors tab - Cloud promoted subgraph missing models',
{ tag: '@cloud' },
() => {
promotedModelTest.beforeEach(async ({ comfyPage }) => {
await cleanupFakeModel(comfyPage)
await comfyPage.settings.setSetting('Comfy.Assets.UseAssetAPI', true)
await comfyPage.settings.setSetting(
'Comfy.RightSidePanel.ShowErrorsTab',
true
)
})
promotedModelTest.afterEach(async ({ comfyPage }) => {
await cleanupFakeModel(comfyPage)
})
promotedModelTest(
'Changing a Cloud Vue promoted asset widget clears a nested subgraph error',
{ tag: ['@vue-nodes', '@widget', '@subgraph'] },
async ({ comfyPage }) => {
await loadPromotedMissingModelAndOpenErrorsTab(
comfyPage,
NESTED_PROMOTED_MISSING_MODEL_WORKFLOW,
FAKE_MODEL_NAME
)
await selectVueAssetPromotedModel(
comfyPage,
NESTED_PROMOTED_MISSING_MODEL_WORKFLOW,
FAKE_MODEL_NAME,
RESOLVED_PROMOTED_MODEL_NAME
)
await expectNoMissingModelUi(comfyPage)
}
)
promotedModelTest(
'Changing a Cloud Vue promoted asset from the Parameters tab clears a nested subgraph error',
{ tag: ['@vue-nodes', '@widget', '@subgraph'] },
async ({ comfyPage }) => {
await loadPromotedMissingModelAndOpenErrorsTab(
comfyPage,
NESTED_PROMOTED_MISSING_MODEL_WORKFLOW,
FAKE_MODEL_NAME
)
await selectSectionAssetPromotedModel(
comfyPage,
NESTED_PROMOTED_MISSING_MODEL_WORKFLOW,
FAKE_MODEL_NAME,
RESOLVED_PROMOTED_MODEL_NAME
)
await expectNoMissingModelUi(comfyPage)
}
)
promotedModelTest(
'Changing a Cloud legacy promoted asset clears a nested subgraph error',
{ tag: ['@canvas', '@widget', '@subgraph'] },
async ({ comfyPage }) => {
await loadPromotedMissingModelAndOpenErrorsTab(
comfyPage,
NESTED_PROMOTED_MISSING_MODEL_WORKFLOW,
FAKE_MODEL_NAME
)
await selectLegacyPromotedAssetModel(
comfyPage,
NESTED_PROMOTED_MISSING_MODEL_WORKFLOW,
RESOLVED_PROMOTED_MODEL_ASSET.id
)
await expectNoMissingModelUi(comfyPage)
}
)
}
)

View File

@@ -8,10 +8,6 @@ import {
} from '@e2e/fixtures/assetApiFixture'
import { comfyPageFixture } from '@e2e/fixtures/ComfyPage'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import {
routeObjectInfoFromSetupApi,
setComboInputOptions
} from '@e2e/fixtures/utils/objectInfo'
import {
createRouteMockJob,
jobsRouteFixture
@@ -90,6 +86,50 @@ interface CloudUploadAssetState {
isUploadedAssetAvailable: boolean
}
type ObjectInfoResponse = Record<
string,
{ input?: { required?: Record<string, unknown> } }
>
function setComboInputOptions(
objectInfo: ObjectInfoResponse,
nodeType: string,
inputName: string,
values: string[]
) {
const nodeInfo = objectInfo[nodeType]
if (!nodeInfo) {
throw new Error(`Missing object_info entry for ${nodeType}`)
}
const requiredInputs = nodeInfo.input?.required
if (!requiredInputs) {
throw new Error(`Missing required inputs for ${nodeType}`)
}
const input = requiredInputs[inputName]
if (!Array.isArray(input)) {
throw new Error(`Expected ${nodeType}.${inputName} to be a combo input`)
}
const [valuesOrType, options] = input
const optionsObject =
options && typeof options === 'object' && !Array.isArray(options)
if (Array.isArray(valuesOrType)) {
input[0] = values
} else if (valuesOrType !== 'COMBO') {
throw new Error(`Expected ${nodeType}.${inputName} to have combo options`)
}
if (optionsObject) {
Object.assign(options, { options: values })
} else if (!Array.isArray(valuesOrType)) {
throw new Error(
`Expected ${nodeType}.${inputName} to have options metadata`
)
}
}
async function routeCloudBootstrapApis(page: Page) {
await page.route('**/api/settings**', async (route) => {
await route.fulfill({
@@ -121,10 +161,57 @@ async function routeCloudBootstrapApis(page: Page) {
})
}
async function routeSetupObjectInfo(
page: Page,
customize?: (objectInfo: ObjectInfoResponse) => void
) {
const setupApiUrl =
process.env.PLAYWRIGHT_SETUP_API_URL ?? 'http://127.0.0.1:8188'
const objectInfoUrl = new URL('/object_info', setupApiUrl).toString()
const objectInfoRouteHandler = async (route: Route) => {
try {
const response = await fetch(objectInfoUrl, {
signal: AbortSignal.timeout(5_000)
})
if (!response.ok) {
await route.fulfill({
status: response.status,
contentType: response.headers.get('content-type') ?? 'text/plain',
body: await response.text()
})
return
}
const objectInfo = (await response.json()) as ObjectInfoResponse
customize?.(objectInfo)
await route.fulfill({
status: response.status,
contentType: 'application/json',
body: JSON.stringify(objectInfo)
})
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
await route.fulfill({
status: 502,
contentType: 'application/json',
body: JSON.stringify({
error: `Failed to fetch setup object_info from ${objectInfoUrl}: ${message}`
})
})
}
}
await page.route('**/object_info', objectInfoRouteHandler)
return async () =>
await page.unroute('**/object_info', objectInfoRouteHandler)
}
const cloudOutputTest = createCloudAssetsFixture([cloudOutputAsset]).extend({
page: async ({ page }, use) => {
await routeCloudBootstrapApis(page)
const unrouteObjectInfo = await routeObjectInfoFromSetupApi(page)
const unrouteObjectInfo = await routeSetupObjectInfo(page)
try {
await use(page)
@@ -138,16 +225,13 @@ const cloudEmptyMediaInputsTest = createCloudAssetsFixture([]).extend({
page: async ({ page }, use) => {
await routeCloudBootstrapApis(page)
const unrouteObjectInfo = await routeObjectInfoFromSetupApi(
page,
(objectInfo) => {
for (const node of emptyMediaLoaderNodes) {
setComboInputOptions(objectInfo, node.nodeType, node.widgetName, [
node.serverOnlyOption
])
}
const unrouteObjectInfo = await routeSetupObjectInfo(page, (objectInfo) => {
for (const node of emptyMediaLoaderNodes) {
setComboInputOptions(objectInfo, node.nodeType, node.widgetName, [
node.serverOnlyOption
])
}
)
})
try {
await use(page)
@@ -162,7 +246,7 @@ const cloudUploadRaceTest = comfyPageFixture.extend<{
}>({
page: async ({ page }, use) => {
await routeCloudBootstrapApis(page)
const unrouteObjectInfo = await routeObjectInfoFromSetupApi(page)
const unrouteObjectInfo = await routeSetupObjectInfo(page)
const state: CloudUploadAssetState = {
isUploadedAssetAvailable: false

View File

@@ -8,46 +8,12 @@ import {
openErrorsTab,
loadWorkflowAndOpenErrorsTab
} from '@e2e/fixtures/helpers/ErrorsTabHelper'
import {
appendComboInputOptions,
routeObjectInfoFromSetupApi
} from '@e2e/fixtures/utils/objectInfo'
import {
NESTED_PROMOTED_MISSING_MODEL_WORKFLOW,
expectMissingModelReferenceCount,
expectNoMissingModelUi,
expectResolvedPromotedModelSuppressesStaleInteriorErrors,
expectSingleMissingModelReference,
getMissingModelLabel,
loadPromotedMissingModelAndOpenErrorsTab,
loadPromotedMissingModelWithHostValuesAndOpenErrorsTab,
selectSectionComboPromotedModel,
selectVueComboPromotedModelByTitle,
setLegacyPromotedComboModel
} from '@e2e/fixtures/utils/promotedMissingModel'
const FAKE_MODEL_NAME = 'fake_model.safetensors'
const RESOLVED_PROMOTED_MODEL_NAME = 'resolved_model.safetensors'
const promotedModelTest = test.extend({
page: async ({ page }, use) => {
const unrouteObjectInfo = await routeObjectInfoFromSetupApi(
page,
(objectInfo) =>
appendComboInputOptions(
objectInfo,
'CheckpointLoaderSimple',
'ckpt_name',
[RESOLVED_PROMOTED_MODEL_NAME]
)
)
try {
await use(page)
} finally {
await unrouteObjectInfo()
}
}
})
function getModelLabel(group: Locator, modelName: string = FAKE_MODEL_NAME) {
return group.getByRole('button', { name: modelName, exact: true })
}
async function expectReferenceBadge(group: Locator, count: number) {
await expect(
@@ -203,9 +169,7 @@ test.describe('Errors tab - Mode-aware errors', { tag: '@ui' }, () => {
TestIds.dialogs.missingModelsGroup
)
await expect(missingModelGroup).toBeVisible()
await expect(
getMissingModelLabel(missingModelGroup, FAKE_MODEL_NAME)
).toBeVisible()
await expect(getModelLabel(missingModelGroup)).toBeVisible()
const node = await comfyPage.nodeOps.getNodeRefById('1')
await node.click('title')
@@ -301,9 +265,7 @@ 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 expect(getModelLabel(missingModelGroup)).toBeVisible()
await expect(
missingModelGroup.getByTestId(TestIds.dialogs.missingModelLocate)
).toHaveCount(1)
@@ -419,184 +381,92 @@ test.describe('Errors tab - Mode-aware errors', { tag: '@ui' }, () => {
await cleanupFakeModel(comfyPage)
})
promotedModelTest(
'Changing an OSS Vue promoted model clears a nested subgraph error',
{ tag: ['@vue-nodes', '@widget', '@subgraph'] },
async ({ comfyPage }) => {
let missingModelGroup: Locator
await test.step('A: shared-definition active host reports the missing model', async () => {
missingModelGroup = await loadPromotedMissingModelAndOpenErrorsTab(
comfyPage,
NESTED_PROMOTED_MISSING_MODEL_WORKFLOW,
FAKE_MODEL_NAME
)
})
await test.step('B: bypassing the resolved sibling host keeps the active host error visible', async () => {
const siblingHostNodeId =
NESTED_PROMOTED_MISSING_MODEL_WORKFLOW.sharedDefinitionSiblingHostNodeId
if (siblingHostNodeId === undefined) {
throw new Error('Expected a shared-definition sibling host')
}
const siblingHost =
await comfyPage.nodeOps.getNodeRefById(siblingHostNodeId)
await siblingHost.centerOnNode()
await siblingHost.click('title')
await comfyPage.keyboard.bypass()
await expect.poll(() => siblingHost.isBypassed()).toBeTruthy()
await comfyPage.canvas.click({ position: { x: 700, y: 650 } })
await openErrorsTab(comfyPage)
await expectSingleMissingModelReference(
missingModelGroup,
FAKE_MODEL_NAME
)
})
await test.step('C: changing the active host promoted widget resolves the model', async () => {
const activeHost = await comfyPage.nodeOps.getNodeRefById(
NESTED_PROMOTED_MISSING_MODEL_WORKFLOW.hostNodeId
)
await activeHost.centerOnNode()
await selectVueComboPromotedModelByTitle(
comfyPage,
NESTED_PROMOTED_MISSING_MODEL_WORKFLOW.hostNodeTitle,
RESOLVED_PROMOTED_MODEL_NAME
)
})
await test.step('D: the missing model UI clears', async () => {
await expectNoMissingModelUi(comfyPage)
})
await test.step('E: two missing shared-definition hosts report two references', async () => {
const siblingHostNodeId =
NESTED_PROMOTED_MISSING_MODEL_WORKFLOW.sharedDefinitionSiblingHostNodeId
if (siblingHostNodeId === undefined) {
throw new Error('Expected a shared-definition sibling host')
}
missingModelGroup =
await loadPromotedMissingModelWithHostValuesAndOpenErrorsTab(
comfyPage,
NESTED_PROMOTED_MISSING_MODEL_WORKFLOW,
{
[siblingHostNodeId]: FAKE_MODEL_NAME,
[NESTED_PROMOTED_MISSING_MODEL_WORKFLOW.hostNodeId]:
FAKE_MODEL_NAME
},
FAKE_MODEL_NAME,
2
)
})
await test.step('F: changing one missing host leaves the other missing reference', async () => {
await selectVueComboPromotedModelByTitle(
comfyPage,
NESTED_PROMOTED_MISSING_MODEL_WORKFLOW.hostNodeTitle,
RESOLVED_PROMOTED_MODEL_NAME
)
await expectMissingModelReferenceCount(
missingModelGroup,
FAKE_MODEL_NAME,
1
)
})
await test.step('G: changing the remaining missing host clears the model error', async () => {
const siblingHostTitle =
NESTED_PROMOTED_MISSING_MODEL_WORKFLOW.sharedDefinitionSiblingHostNodeTitle
if (siblingHostTitle === undefined) {
throw new Error('Expected a shared-definition sibling host title')
}
await selectVueComboPromotedModelByTitle(
comfyPage,
siblingHostTitle,
RESOLVED_PROMOTED_MODEL_NAME
)
await expectNoMissingModelUi(comfyPage)
})
}
)
promotedModelTest(
'Changing an OSS Vue promoted model from the Parameters tab clears a nested subgraph error',
{ tag: ['@vue-nodes', '@widget', '@subgraph'] },
async ({ comfyPage }) => {
await loadPromotedMissingModelAndOpenErrorsTab(
comfyPage,
NESTED_PROMOTED_MISSING_MODEL_WORKFLOW,
FAKE_MODEL_NAME
)
await selectSectionComboPromotedModel(
comfyPage,
NESTED_PROMOTED_MISSING_MODEL_WORKFLOW,
RESOLVED_PROMOTED_MODEL_NAME
)
await expectNoMissingModelUi(comfyPage)
}
)
promotedModelTest(
'Changing an OSS legacy promoted model clears a nested subgraph error',
test(
'Resolving a promoted missing model widget through the legacy canvas path clears its error',
{ tag: ['@canvas', '@widget', '@subgraph'] },
async ({ comfyPage }) => {
const resolvedModelName = 'v1-5-pruned-emaonly-fp16.safetensors'
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', false)
await loadPromotedMissingModelAndOpenErrorsTab(
await loadWorkflowAndOpenErrorsTab(
comfyPage,
NESTED_PROMOTED_MISSING_MODEL_WORKFLOW,
FAKE_MODEL_NAME
'missing/missing_model_promoted_widget'
)
await setLegacyPromotedComboModel(
comfyPage,
NESTED_PROMOTED_MISSING_MODEL_WORKFLOW,
RESOLVED_PROMOTED_MODEL_NAME
const missingModelGroup = comfyPage.page.getByTestId(
TestIds.dialogs.missingModelsGroup
)
await expect(getModelLabel(missingModelGroup)).toBeVisible()
await expectNoMissingModelUi(comfyPage)
await comfyPage.page.evaluate((value) => {
const hostNode = window.app!.graph!.getNodeById(2)
if (!hostNode?.isSubgraphNode()) {
throw new Error('Expected subgraph host node')
}
const interiorNode = hostNode.subgraph.getNodeById(1)
const widget = interiorNode?.widgets?.find(
(entry) => entry.name === 'ckpt_name'
)
type SettableWidget = typeof widget & {
setValue?: (
value: string,
options: {
e: PointerEvent
node: unknown
canvas: unknown
}
) => void
}
const settableWidget = widget as SettableWidget | undefined
if (!settableWidget?.setValue) {
throw new Error('Expected concrete ckpt_name widget')
}
settableWidget.setValue(value, {
e: new PointerEvent('pointerup'),
node: hostNode,
canvas: window.app!.canvas
})
}, resolvedModelName)
await expect(missingModelGroup).toBeHidden()
}
)
promotedModelTest(
test(
'Refreshing a resolved promoted missing model clears the combo invalid state',
{ tag: ['@widget', '@subgraph'] },
async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
await loadWorkflowAndOpenErrorsTab(
comfyPage,
NESTED_PROMOTED_MISSING_MODEL_WORKFLOW.workflowName
'missing/missing_model_promoted_widget'
)
await comfyPage.vueNodes.waitForNodes()
const missingModelGroup = comfyPage.page.getByTestId(
TestIds.dialogs.missingModelsGroup
)
await expect(
getMissingModelLabel(missingModelGroup, FAKE_MODEL_NAME)
).toBeVisible()
await expect(getModelLabel(missingModelGroup)).toBeVisible()
const promotedModelCombo = comfyPage.vueNodes
.getNodeByTitle(NESTED_PROMOTED_MISSING_MODEL_WORKFLOW.hostNodeTitle)
.getNodeByTitle('Subgraph with Promoted Missing Model')
.getByRole('combobox', { name: 'ckpt_name', exact: true })
await expect(promotedModelCombo).toHaveAttribute('aria-invalid', 'true')
const unrouteObjectInfo = await routeObjectInfoFromSetupApi(
comfyPage.page,
(objectInfo) =>
appendComboInputOptions(
objectInfo,
'CheckpointLoaderSimple',
'ckpt_name',
[FAKE_MODEL_NAME, RESOLVED_PROMOTED_MODEL_NAME]
)
)
const objectInfoRoute = /\/object_info$/
try {
await comfyPage.page.route(objectInfoRoute, async (route) => {
const response = await route.fetch()
const objectInfo = await response.json()
const ckptName =
objectInfo.CheckpointLoaderSimple.input.required.ckpt_name
ckptName[0] = [...ckptName[0], 'fake_model.safetensors']
await route.fulfill({ response, json: objectInfo })
})
await comfyPage.page
.getByTestId(TestIds.dialogs.missingModelRefresh)
.click()
@@ -608,31 +478,11 @@ test.describe('Errors tab - Mode-aware errors', { tag: '@ui' }, () => {
'true'
)
} finally {
await unrouteObjectInfo()
await comfyPage.page.unroute(objectInfoRoute)
}
}
)
promotedModelTest(
'Reloading a resolved nested promoted model ignores stale interior values',
{ tag: ['@vue-nodes', '@widget', '@subgraph'] },
async ({ comfyPage }) => {
await expectResolvedPromotedModelSuppressesStaleInteriorErrors(
comfyPage,
NESTED_PROMOTED_MISSING_MODEL_WORKFLOW,
[
{
subgraphNodeIdToEnter: '4',
nodeTitle: 'Inner Subgraph with Promoted Missing Model'
},
{ subgraphNodeIdToEnter: '2', nodeTitle: 'Load Checkpoint' }
],
RESOLVED_PROMOTED_MODEL_NAME,
FAKE_MODEL_NAME
)
}
)
test('Bypassing a subgraph hides interior errors, un-bypassing restores them', async ({
comfyPage
}) => {

View File

@@ -4,16 +4,15 @@ import {
} from '@e2e/fixtures/ComfyPage'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import type { Position } from '@e2e/fixtures/types'
import type { NodeId } from '@/types/nodeId'
type NodeSnapshot = { id: NodeId } & Position
type NodeSnapshot = { id: number } & Position
async function getAllNodePositions(
comfyPage: ComfyPage
): Promise<NodeSnapshot[]> {
return comfyPage.page.evaluate(() =>
window.app!.graph.nodes.map((n) => ({
id: n.id,
id: n.id as number,
x: n.pos[0],
y: n.pos[1]
}))
@@ -22,7 +21,7 @@ async function getAllNodePositions(
async function getNodePosition(
comfyPage: ComfyPage,
nodeId: NodeId
nodeId: number
): Promise<Position | undefined> {
return comfyPage.page.evaluate((targetNodeId) => {
const node = window.app!.graph.nodes.find((n) => n.id === targetNodeId)

View File

@@ -1095,33 +1095,6 @@ test.describe('Assets sidebar - drag and drop', () => {
const fileComboWidget = await nodes[0].getWidget(0)
await expect.poll(() => fileComboWidget.getValue()).toBe('test.png [temp]')
})
test('Loading as workflow reuses asset name', async ({ comfyPage }) => {
await comfyPage.assets.mockOutputHistory([
createMockJob({
id: 'job',
preview_output: {
filename: `testimage.png`,
type: 'temp',
nodeId: '1',
mediaType: 'images'
}
})
])
const path = comfyPage.assetPath('workflowInMedia/workflow.webp')
await comfyPage.page.route('**/view?**', (route) => route.fulfill({ path }))
const { assetsTab } = comfyPage.menu
await assetsTab.open()
await assetsTab.waitForAssets()
await expect(assetsTab.assetCards).toHaveCount(1)
const targetPosition = { x: 400, y: 100 }
await assetsTab.assetCards.dragTo(comfyPage.canvas, { targetPosition })
const getTabName = () => comfyPage.menu.topbar.getActiveTabName()
await expect.poll(getTabName).toContain('testimage')
})
})
test('Insert as node', { tag: '@vue-nodes' }, async ({ comfyPage }) => {

View File

@@ -10,6 +10,20 @@ test.describe('Node library sidebar V2', () => {
await tab.open()
})
test('Can switch between tabs', async ({ comfyPage }) => {
const tab = comfyPage.menu.nodeLibraryTabV2
await expect(tab.allTab).toHaveAttribute('aria-selected', 'true')
await tab.blueprintsTab.click()
await expect(tab.blueprintsTab).toHaveAttribute('aria-selected', 'true')
await expect(tab.allTab).toHaveAttribute('aria-selected', 'false')
await tab.allTab.click()
await expect(tab.allTab).toHaveAttribute('aria-selected', 'true')
await expect(tab.blueprintsTab).toHaveAttribute('aria-selected', 'false')
})
test('All tab displays node tree with folders', async ({ comfyPage }) => {
const tab = comfyPage.menu.nodeLibraryTabV2
@@ -109,9 +123,8 @@ test.describe('Node library sidebar V2', () => {
test('Blueprint previews include description', async ({ comfyPage }) => {
const tab = comfyPage.menu.nodeLibraryTabV2
await tab.allTab.click()
await tab.blueprintsTab.click()
await tab.expandFolder('Comfy Blueprints')
await tab.getNode('test blueprint').hover()
await expect(tab.nodePreview, 'Preview displays on hover').toBeVisible()
await expect(tab.nodePreview).toContainText('Inverts the image')

View File

@@ -3,7 +3,6 @@ import { expect, mergeTests } from '@playwright/test'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import { comfyPageFixture } from '@e2e/fixtures/ComfyPage'
import { subgraphBreadcrumbFixture } from '@e2e/fixtures/helpers/SubgraphBreadcrumbHelper'
import { toNodeId } from '@/types/nodeId'
const test = mergeTests(comfyPageFixture, subgraphBreadcrumbFixture)
@@ -199,7 +198,7 @@ test.describe('Subgraph Breadcrumb', { tag: ['@subgraph'] }, () => {
const rootNodeTitle = await comfyPage.page.evaluate(
(nodeId) => window.app!.graph!.getNodeById(nodeId)?.title ?? null,
toNodeId(OUTER_SUBGRAPH_NODE_ID_IN_NESTED)
OUTER_SUBGRAPH_NODE_ID_IN_NESTED
)
expect(rootNodeTitle).toBe(newName)
})

View File

@@ -1,6 +1,6 @@
import { expect } from '@playwright/test'
import type { NodeId } from '@/types/nodeId'
import type { NodeId } from '@/platform/workflow/validation/schemas/workflowSchema'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
interface SubgraphNodePosition {

View File

@@ -5,7 +5,6 @@ import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
import { TestIds } from '@e2e/fixtures/selectors'
import { getPseudoPreviewWidgets } from '@e2e/fixtures/utils/promotedWidgets'
import { toNodeId } from '@/types/nodeId'
const domPreviewSelector = '.image-preview'
@@ -58,12 +57,12 @@ test.describe('Subgraph Lifecycle', { tag: ['@subgraph'] }, () => {
})
.toBeGreaterThan(0)
await comfyPage.page.evaluate((nodeId) => {
await comfyPage.page.evaluate(() => {
const graph = window.app!.graph!
const subgraphNode = graph.getNodeById(nodeId)
const subgraphNode = graph.getNodeById('5')
if (!subgraphNode || !subgraphNode.isSubgraphNode()) return
graph.unpackSubgraph(subgraphNode)
}, toNodeId(5))
})
await comfyPage.nextFrame()
await expect

View File

@@ -2,7 +2,6 @@ import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
import { TestIds } from '@e2e/fixtures/selectors'
import { toNodeId } from '@/types/nodeId'
const UPDATED_SUBGRAPH_TITLE = 'Updated Subgraph Title'
@@ -261,18 +260,17 @@ test.describe('Subgraph Navigation', { tag: ['@slow', '@subgraph'] }, () => {
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
const subgraphNodeId = await comfyPage.subgraph.findSubgraphNodeId()
const localSubgraphNodeId = toNodeId(subgraphNodeId)
await comfyPage.page.evaluate((nodeId) => {
const node = window.app!.canvas.graph!.getNodeById(nodeId)!
node.progress = 0.5
}, localSubgraphNodeId)
}, subgraphNodeId)
await expect
.poll(() =>
comfyPage.page.evaluate(
(nodeId) => window.app!.canvas.graph!.getNodeById(nodeId)!.progress,
localSubgraphNodeId
subgraphNodeId
)
)
.toBe(0.5)
@@ -289,7 +287,7 @@ test.describe('Subgraph Navigation', { tag: ['@slow', '@subgraph'] }, () => {
.poll(() =>
comfyPage.page.evaluate((nodeId) => {
return window.app!.canvas.graph!.getNodeById(nodeId)!.progress
}, localSubgraphNodeId)
}, subgraphNodeId)
)
.toBeUndefined()
})
@@ -300,12 +298,11 @@ test.describe('Subgraph Navigation', { tag: ['@slow', '@subgraph'] }, () => {
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
const subgraphNodeId = await comfyPage.subgraph.findSubgraphNodeId()
const localSubgraphNodeId = toNodeId(subgraphNodeId)
await comfyPage.page.evaluate((nodeId) => {
const node = window.app!.canvas.graph!.getNodeById(nodeId)!
node.progress = 0.7
}, localSubgraphNodeId)
}, subgraphNodeId)
const subgraphNode =
await comfyPage.nodeOps.getNodeRefById(subgraphNodeId)

View File

@@ -471,10 +471,11 @@ test.describe(
'subgraphs/subgraph-with-promoted-text-widget'
)
let initialWidgetCount = 0
await expect
.poll(() => getPromotedWidgetCount(comfyPage, '11'))
.toBeGreaterThan(0)
const initialWidgetCount = await getPromotedWidgetCount(comfyPage, '11')
initialWidgetCount = await getPromotedWidgetCount(comfyPage, '11')
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('11')
await subgraphNode.navigateIntoSubgraph()

View File

@@ -1,8 +1,5 @@
import { expect } from '@playwright/test'
import { toNodeId } from '@/types/nodeId'
import type { NodeId } from '@/types/nodeId'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import { comfyExpect, comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
import { SubgraphHelper } from '@e2e/fixtures/helpers/SubgraphHelper'
@@ -42,10 +39,9 @@ async function getPrimitiveFanoutSnapshot(
comfyPage: ComfyPage,
hostNodeId: string
): Promise<PrimitiveFanoutSnapshot> {
const localHostNodeId = toNodeId(hostNodeId)
return comfyPage.page.evaluate((id) => {
const graph = window.app!.canvas.graph!
const hostNode = graph.getNodeById(id)
const hostNode = graph.getNodeById(Number(id))
if (!hostNode?.isSubgraphNode?.()) {
throw new Error(`Host node ${id} is not a SubgraphNode`)
}
@@ -84,7 +80,7 @@ async function getPrimitiveFanoutSnapshot(
primitiveOriginLinkCount,
serializedProperties: serializedNode?.properties ?? {}
}
}, localHostNodeId)
}, hostNodeId)
}
async function getSerializedSubgraphNodeProperties(
@@ -107,20 +103,19 @@ async function expectPromotedWidgetsToResolveToInteriorNodes(
) {
expect(widgets.length).toBeGreaterThan(0)
const hostNodeId = toNodeId(hostSubgraphNodeId)
const interiorNodeIds = widgets.map(([id]) => toNodeId(id))
const interiorNodeIds = widgets.map(([id]) => id)
const results = await comfyPage.page.evaluate(
([hostId, ids]) => {
const graph = window.app!.graph!
const hostNode = graph.getNodeById(hostId)
const hostNode = graph.getNodeById(Number(hostId))
if (!hostNode?.isSubgraphNode()) return ids.map(() => false)
return ids.map((id) => {
const interiorNode = hostNode.subgraph.getNodeById(id)
const interiorNode = hostNode.subgraph.getNodeById(Number(id))
return interiorNode !== null && interiorNode !== undefined
})
},
[hostNodeId, interiorNodeIds] as const
[hostSubgraphNodeId, interiorNodeIds] as const
)
expect(results).toEqual(widgets.map(() => true))
@@ -575,7 +570,8 @@ test.describe('Subgraph Serialization', { tag: ['@subgraph'] }, () => {
const allGraphs = [graph, ...graph.subgraphs.values()]
const allIds = allGraphs
.flatMap((g) => g._nodes)
.map((n) => String(n.id))
.map((n) => n.id)
.filter((id): id is number => typeof id === 'number')
return { allIds, uniqueCount: new Set(allIds).size }
})
@@ -591,7 +587,10 @@ test.describe('Subgraph Serialization', { tag: ['@subgraph'] }, () => {
const rootIds = await comfyPage.page.evaluate(() => {
const graph = window.app!.canvas.graph!
return graph._nodes.map((n) => Number(n.id)).sort((a, b) => a - b)
return graph._nodes
.map((n) => n.id)
.filter((id): id is number => typeof id === 'number')
.sort((a, b) => a - b)
})
expect(rootIds).toEqual([1, 2, 5])
@@ -634,18 +633,18 @@ test.describe('Subgraph Serialization', { tag: ['@subgraph'] }, () => {
)
]
const SENTINEL_IDS = new Set(['-1', '-10', '-20'])
const isSentinelNodeId = (id: number | string) =>
SENTINEL_IDS.has(String(id))
const SENTINEL_IDS = new Set([-1, -10, -20])
const isSentinelNodeId = (id: number | string): id is number =>
typeof id === 'number' && SENTINEL_IDS.has(id)
const checkEndpoint = (
label: string,
kind: 'origin_id' | 'target_id',
id: NodeId,
id: number | string,
g: typeof graph
): string | null => {
if (isSentinelNodeId(id)) return null
if (!g.getNodeById(id)) {
if (typeof id !== 'number' || !g._nodes_by_id[id]) {
return `${label}: ${kind} ${id} invalid or not found`
}
return null

View File

@@ -6,7 +6,6 @@ import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/w
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
import { SubgraphHelper } from '@e2e/fixtures/helpers/SubgraphHelper'
import { toNodeId } from '@/types/nodeId'
import {
expectSlotsWithinBounds,
measureNodeSlotOffsets
@@ -461,17 +460,16 @@ test.describe('Subgraph Slots', { tag: ['@slow', '@subgraph'] }, () => {
const subgraphNodeAfter = comfyPage.vueNodes.getNodeLocator('19')
await expect(subgraphNodeAfter).toBeVisible()
const subgraphNodeId = toNodeId(19)
await expect
.poll(() =>
comfyPage.page.evaluate((nodeId) => {
const node = window.app!.canvas.graph!.getNodeById(nodeId)
comfyPage.page.evaluate(() => {
const node = window.app!.canvas.graph!.getNodeById('19')
if (!node) return null
const widget = node.widgets?.find((entry: { name: string }) =>
entry.name.includes('seed')
)
return widget?.label || widget?.name || null
}, subgraphNodeId)
})
)
.toBe(RENAMED_LABEL)

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