Compare commits
53 Commits
alexis/api
...
uy/in-app-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5654c4edab | ||
|
|
ef57ee29ea | ||
|
|
b751e717b3 | ||
|
|
80b1c3cd71 | ||
|
|
462029b004 | ||
|
|
65021e2b8a | ||
|
|
e8d8ab412c | ||
|
|
7f8e7f7fb2 | ||
|
|
9182ef4948 | ||
|
|
a94b3d541b | ||
|
|
8ce6f6e234 | ||
|
|
b571db1897 | ||
|
|
c1b5a5166c | ||
|
|
11e0446bb8 | ||
|
|
e45a1bed17 | ||
|
|
ddb0a181ea | ||
|
|
927ba00e91 | ||
|
|
8a61e9aa72 | ||
|
|
636608664d | ||
|
|
499a706081 | ||
|
|
fb40f2fdb9 | ||
|
|
2c9cce86d7 | ||
|
|
f4e0430072 | ||
|
|
c78592c1ec | ||
|
|
00b0c6b434 | ||
|
|
da34fa3944 | ||
|
|
c8ed15da31 | ||
|
|
b132abc64a | ||
|
|
55c52a730a | ||
|
|
fbe462143a | ||
|
|
61cb1bcde0 | ||
|
|
9dcab4ee96 | ||
|
|
dc29f30b02 | ||
|
|
fb3350ee0e | ||
|
|
be8e0010ee | ||
|
|
d0e97d6933 | ||
|
|
3377b8e07e | ||
|
|
4a2393be48 | ||
|
|
a451a90868 | ||
|
|
be102899d7 | ||
|
|
abd1a6f10a | ||
|
|
c16f10b49e | ||
|
|
64253de713 | ||
|
|
b4ae6344d7 | ||
|
|
feaa4ce82f | ||
|
|
f5d059b720 | ||
|
|
e2c670bbc3 | ||
|
|
dfcb336499 | ||
|
|
caabebe145 | ||
|
|
7d3d8ce63f | ||
|
|
506e33e7dd | ||
|
|
7376402fc6 | ||
|
|
be38f14619 |
@@ -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.
|
||||
|
||||
### Centralized Registries and ECS-Style Access
|
||||
### Dedicated Stores and Data/Behavior Separation
|
||||
|
||||
All entity data access should move toward centralized query patterns, not instance property access.
|
||||
Entity data lives in dedicated Pinia stores keyed by string IDs (`widgetValueStore`, `domWidgetStore`, `layoutStore`, `nodeOutputStore`, `subgraphNavigationStore`, `previewExposureStore`), not on entity instances.
|
||||
|
||||
Flag:
|
||||
|
||||
- **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)`.
|
||||
- **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`).
|
||||
- **OOP inheritance for entity modeling** — Extending entity classes with new subclasses instead of composing behavior through components and systems.
|
||||
- **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.
|
||||
- **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.
|
||||
|
||||
### Extension Ecosystem Impact
|
||||
|
||||
|
||||
30
.cursor/rules/agent-panel-layout.md
Normal file
@@ -0,0 +1,30 @@
|
||||
---
|
||||
description: Agent chat panel layout rule — always full viewport height, never nested under the header bar
|
||||
globs:
|
||||
- src/components/LiteGraphCanvasSplitterOverlay.vue
|
||||
- src/platform/agent/**
|
||||
alwaysApply: true
|
||||
---
|
||||
|
||||
# Agent Panel Layout
|
||||
|
||||
The Comfy Agent chat panel must always span the **full viewport height** — from the very top of the screen to the bottom, alongside the header bar and canvas, not below them.
|
||||
|
||||
## Correct structure
|
||||
|
||||
`LiteGraphCanvasSplitterOverlay` uses a top-level **`flex-row`** so the agent panel is a sibling of the entire left column (tabs + canvas), not a child inside it:
|
||||
|
||||
```
|
||||
div.flex-row (viewport)
|
||||
├── div.flex-col.flex-1 ← left side: everything else
|
||||
│ ├── slot#workflow-tabs ← header bar
|
||||
│ └── div.flex-1 ← canvas + sidebar panels
|
||||
└── div.shrink-0 (agent panel) ← RIGHT: full viewport height
|
||||
```
|
||||
|
||||
## Rules
|
||||
|
||||
- **Never** place the agent panel inside the `div` that sits below `slot#workflow-tabs`. That causes the panel to start below the header bar.
|
||||
- The agent panel div must be a **direct child** of the outermost `div.flex-row` container in `LiteGraphCanvasSplitterOverlay.vue`.
|
||||
- The left side (`flex-1 flex-col`) wraps both `slot#workflow-tabs` AND the canvas/splitter row.
|
||||
- The agent panel has `h-full` and `shrink-0` so it fills the full height and does not flex-shrink.
|
||||
34
.cursor/rules/icon-button-tooltip.mdc
Normal file
@@ -0,0 +1,34 @@
|
||||
---
|
||||
description: Icon buttons must always have a tooltip
|
||||
globs: src/**/*.vue
|
||||
alwaysApply: false
|
||||
---
|
||||
|
||||
# Icon Button Tooltip Requirement
|
||||
|
||||
Every icon-only button (`size="icon"` or any button containing only an icon with no visible label) **must** be wrapped in a `Tooltip` so users can discover what it does.
|
||||
|
||||
## Required Pattern
|
||||
|
||||
```vue
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<Button size="icon" border-interface-stroke" :aria-label="$t('...')">
|
||||
<i class="icon-[lucide--some-icon] size-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">{{ $t('...') }}</TooltipContent>
|
||||
</Tooltip>
|
||||
```
|
||||
|
||||
## Imports
|
||||
|
||||
```ts
|
||||
import Tooltip from '@/components/ui/tooltip/Tooltip.vue'
|
||||
import TooltipContent from '@/components/ui/tooltip/TooltipContent.vue'
|
||||
import TooltipTrigger from '@/components/ui/tooltip/TooltipTrigger.vue'
|
||||
```
|
||||
|
||||
- Always use `side="top"` unless a different direction is needed for layout reasons.
|
||||
- The `aria-label` on the button and the tooltip text should be the same translated string.
|
||||
- Use `vue-i18n` (`$t(...)`) for the label — never hardcode strings.
|
||||
5
.github/workflows/ci-tests-e2e-coverage.yaml
vendored
@@ -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,7 +121,8 @@ jobs:
|
||||
--title "ComfyUI E2E Coverage" \
|
||||
--no-function-coverage \
|
||||
--precision 1 \
|
||||
--ignore-errors source,unmapped
|
||||
--ignore-errors source,unmapped,range \
|
||||
--synthesize-missing
|
||||
|
||||
- name: Upload HTML report artifact
|
||||
if: steps.coverage-shards.outputs.has-coverage == 'true'
|
||||
|
||||
2
.github/workflows/cla.yml
vendored
@@ -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: actions-user,ampagent,claude,comfy-pr-bot,github-actions,*[bot],Glary Bot
|
||||
allowlist: action@github.com,actions-user,ampagent,claude,comfy-pr-bot,GitHub Action,github-actions,Glary Bot,Glary-Bot,*[bot]
|
||||
|
||||
# Custom PR comment messages
|
||||
custom-notsigned-prcomment: |
|
||||
|
||||
5
.github/workflows/pr-backport.yaml
vendored
@@ -67,6 +67,11 @@ 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: |
|
||||
|
||||
@@ -21,7 +21,8 @@ module.exports = defineConfig({
|
||||
'ar',
|
||||
'tr',
|
||||
'pt-BR',
|
||||
'fa'
|
||||
'fa',
|
||||
'he'
|
||||
],
|
||||
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'.
|
||||
@@ -37,5 +38,11 @@ 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.
|
||||
`
|
||||
})
|
||||
|
||||
@@ -179,6 +179,9 @@ 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
|
||||
|
||||
@@ -246,7 +249,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. **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.
|
||||
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.
|
||||
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.
|
||||
|
||||
231
apps/website/e2e/launches.spec.ts
Normal file
@@ -0,0 +1,231 @@
|
||||
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)
|
||||
})
|
||||
})
|
||||
|
Before Width: | Height: | Size: 59 KiB After Width: | Height: | Size: 59 KiB |
|
Before Width: | Height: | Size: 58 KiB After Width: | Height: | Size: 58 KiB |
@@ -1,3 +1,3 @@
|
||||
<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 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>
|
||||
|
||||
|
Before Width: | Height: | Size: 279 B After Width: | Height: | Size: 380 B |
BIN
apps/website/public/images/mcp/mcp-thumb-asphalt.webp
Normal file
|
After Width: | Height: | Size: 2.4 MiB |
BIN
apps/website/public/images/mcp/mcp-thumb-concepts.webp
Normal file
|
After Width: | Height: | Size: 1.6 MiB |
BIN
apps/website/public/images/mcp/mcp-thumb-kaiju.webp
Normal file
|
After Width: | Height: | Size: 1.2 MiB |
BIN
apps/website/public/images/mcp/mcp-thumb-keyart.webp
Normal file
|
After Width: | Height: | Size: 1.6 MiB |
BIN
apps/website/public/images/mcp/mcp-thumb-moodboard.webp
Normal file
|
After Width: | Height: | Size: 1.8 MiB |
@@ -29,6 +29,5 @@ Allow: /
|
||||
Disallow: /_astro/
|
||||
Disallow: /_website/
|
||||
Disallow: /_vercel/
|
||||
Disallow: /payment/
|
||||
|
||||
Sitemap: https://comfy.org/sitemap-index.xml
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
<script setup lang="ts">
|
||||
import BrandButton from '../common/BrandButton.vue'
|
||||
import type { AnchorHTMLAttributes } from 'vue'
|
||||
|
||||
import Button from '../ui/button/Button.vue'
|
||||
import { resolveRel } from '../../utils/cta'
|
||||
|
||||
type Cta = {
|
||||
label: string
|
||||
href: string
|
||||
target?: '_blank' | '_self' | '_parent' | '_top'
|
||||
target?: AnchorHTMLAttributes['target']
|
||||
rel?: AnchorHTMLAttributes['rel']
|
||||
}
|
||||
|
||||
type TermsLink = {
|
||||
@@ -12,10 +16,11 @@ type TermsLink = {
|
||||
href: string
|
||||
}
|
||||
|
||||
defineProps<{
|
||||
const { heading, primaryCta, secondaryCta, termsLink } = defineProps<{
|
||||
heading: string
|
||||
primaryCta: Cta
|
||||
termsLink: TermsLink
|
||||
secondaryCta?: Cta
|
||||
termsLink?: TermsLink
|
||||
}>()
|
||||
</script>
|
||||
|
||||
@@ -24,23 +29,37 @@ defineProps<{
|
||||
class="max-w-9xl mx-auto flex flex-col items-center px-6 py-16 text-center lg:py-24"
|
||||
>
|
||||
<h2
|
||||
class="text-4xl font-light tracking-tight text-primary-comfy-canvas lg:text-6xl"
|
||||
class="max-w-3xl text-4xl/snug font-light tracking-tight text-pretty text-primary-comfy-canvas lg:text-6xl/snug"
|
||||
>
|
||||
{{ heading }}
|
||||
</h2>
|
||||
|
||||
<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>
|
||||
<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>
|
||||
|
||||
<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"
|
||||
>
|
||||
|
||||
@@ -26,7 +26,7 @@ function toggle(index: number) {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="max-w-9xl mx-auto px-4 py-24 md:px-20 md:py-40">
|
||||
<section class="max-w-9xl mx-auto px-6 py-16 lg:py-24">
|
||||
<div class="flex flex-col gap-6 md:flex-row md:gap-16">
|
||||
<!-- Left heading -->
|
||||
<div
|
||||
|
||||
120
apps/website/src/components/blocks/FeatureGrid01.vue
Normal file
@@ -0,0 +1,120 @@
|
||||
<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
|
||||
variant?: 'default' | 'outline'
|
||||
}
|
||||
| { type: 'code'; value: string }
|
||||
|
||||
export interface FeatureCard {
|
||||
id: string
|
||||
label?: string
|
||||
title: string
|
||||
description: string
|
||||
action?: CardAction
|
||||
}
|
||||
|
||||
type ColumnCount = 2 | 3 | 4
|
||||
|
||||
const {
|
||||
cards,
|
||||
columns = 3,
|
||||
copiedLabel,
|
||||
copyLabel,
|
||||
eyebrow,
|
||||
heading,
|
||||
subtitle
|
||||
} = defineProps<{
|
||||
cards: readonly FeatureCard[]
|
||||
columns?: ColumnCount
|
||||
copiedLabel?: string
|
||||
copyLabel?: string
|
||||
eyebrow?: string
|
||||
heading: string
|
||||
subtitle?: string
|
||||
}>()
|
||||
|
||||
const columnClass: Record<ColumnCount, 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="card.action.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>
|
||||
100
apps/website/src/components/blocks/FeatureGrid02.vue
Normal file
@@ -0,0 +1,100 @@
|
||||
<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>
|
||||
108
apps/website/src/components/blocks/FeatureRows01.vue
Normal file
@@ -0,0 +1,108 @@
|
||||
<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>
|
||||
166
apps/website/src/components/blocks/HeroLivestream01.vue
Normal file
@@ -0,0 +1,166 @@
|
||||
<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>
|
||||
@@ -1,6 +1,8 @@
|
||||
<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'
|
||||
@@ -27,6 +29,7 @@ const {
|
||||
badgeLogoAlt,
|
||||
title,
|
||||
titleHighlight,
|
||||
subtitle,
|
||||
features = [],
|
||||
primaryCta,
|
||||
secondaryCta,
|
||||
@@ -41,14 +44,17 @@ const {
|
||||
videoAutoplay = false,
|
||||
videoLoop = false,
|
||||
videoMinimal = false,
|
||||
videoHideControls = false
|
||||
videoHideControls = false,
|
||||
class: className
|
||||
} = defineProps<{
|
||||
locale?: Locale
|
||||
class?: HTMLAttributes['class']
|
||||
badgeText: string
|
||||
badgeLogoSrc?: string
|
||||
badgeLogoAlt?: string
|
||||
title: string
|
||||
titleHighlight?: string
|
||||
subtitle?: string
|
||||
features?: string[]
|
||||
primaryCta: Cta
|
||||
secondaryCta?: Cta
|
||||
@@ -72,7 +78,8 @@ 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'
|
||||
imagePosition === 'right' ? 'lg:flex-row' : 'lg:flex-row-reverse',
|
||||
className
|
||||
)
|
||||
"
|
||||
>
|
||||
@@ -84,7 +91,7 @@ const {
|
||||
/>
|
||||
|
||||
<h1
|
||||
class="mt-8 text-2xl leading-[125%] font-light tracking-[-1.44px] text-primary-comfy-canvas md:text-4xl lg:text-5xl"
|
||||
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"
|
||||
>
|
||||
<template v-if="titleHighlight">
|
||||
<span class="text-primary-warm-white">{{ titleHighlight }}</span>
|
||||
@@ -93,6 +100,13 @@ 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"
|
||||
@@ -127,27 +141,29 @@ const {
|
||||
</div>
|
||||
|
||||
<div class="order-first w-full lg:order-last lg:flex-1">
|
||||
<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 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>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
59
apps/website/src/components/blocks/ReasonsSplit01.vue
Normal file
@@ -0,0 +1,59 @@
|
||||
<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>
|
||||
@@ -6,6 +6,7 @@ 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
|
||||
@@ -16,9 +17,8 @@ const props = defineProps<{
|
||||
class?: HTMLAttributes['class']
|
||||
}>()
|
||||
|
||||
const resolvedRel = computed(
|
||||
() =>
|
||||
props.rel ?? (props.target === '_blank' ? 'noopener noreferrer' : undefined)
|
||||
const resolvedRel = computed(() =>
|
||||
resolveRel({ rel: props.rel, target: props.target })
|
||||
)
|
||||
</script>
|
||||
|
||||
|
||||
@@ -7,12 +7,14 @@ const {
|
||||
label,
|
||||
headingTag = 'h2',
|
||||
maxWidth = 'lg',
|
||||
headingSize = 'section'
|
||||
headingSize = 'section',
|
||||
align = 'center'
|
||||
} = defineProps<{
|
||||
label?: string
|
||||
headingTag?: 'h1' | 'h2' | 'h3'
|
||||
maxWidth?: 'md' | 'lg' | 'xl'
|
||||
headingSize?: 'section' | 'hero'
|
||||
align?: 'center' | 'start'
|
||||
}>()
|
||||
|
||||
const maxWidthClass = {
|
||||
@@ -28,7 +30,14 @@ const headingSizeClass = {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="cn('mx-auto text-center', maxWidthClass[maxWidth])">
|
||||
<div
|
||||
:class="
|
||||
cn(
|
||||
maxWidthClass[maxWidth],
|
||||
align === 'center' ? 'mx-auto text-center' : 'text-left'
|
||||
)
|
||||
"
|
||||
>
|
||||
<SectionLabel v-if="label">{{ label }}</SectionLabel>
|
||||
<component
|
||||
:is="headingTag"
|
||||
|
||||
@@ -37,13 +37,15 @@ 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.comfyEnterprise', locale), href: routes.cloudEnterprise },
|
||||
{ label: t('nav.mcpServer', locale), href: routes.mcp }
|
||||
]
|
||||
},
|
||||
{
|
||||
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,
|
||||
|
||||
@@ -25,7 +25,7 @@ const {
|
||||
data-slot="badge"
|
||||
:data-variant="variant"
|
||||
:data-size="size"
|
||||
:class="cn(badgeVariants({ variant, size }), className)"
|
||||
:class="cn(badgeVariants({ size, variant }), className)"
|
||||
>
|
||||
<slot name="prepend">
|
||||
<component :is="prependIcon" v-if="prependIcon" />
|
||||
|
||||
@@ -4,15 +4,16 @@ 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: {
|
||||
variant: {
|
||||
default: 'bg-transparency-ink-t80',
|
||||
subtle: 'bg-transparency-white-t4 text-primary-comfy-canvas',
|
||||
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]'
|
||||
},
|
||||
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'
|
||||
}
|
||||
},
|
||||
defaultVariants: {
|
||||
|
||||
@@ -8,7 +8,8 @@ export const buttonVariants = cva(
|
||||
{
|
||||
variants: {
|
||||
size: {
|
||||
default: 'h-10 px-6 py-2.5',
|
||||
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',
|
||||
lg: 'h-14 px-8 py-4 text-base'
|
||||
},
|
||||
variant: {
|
||||
@@ -17,6 +18,8 @@ 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'
|
||||
|
||||
22
apps/website/src/components/ui/card/Card.vue
Normal file
@@ -0,0 +1,22 @@
|
||||
<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>
|
||||
14
apps/website/src/components/ui/card/CardContent.vue
Normal file
@@ -0,0 +1,14 @@
|
||||
<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>
|
||||
19
apps/website/src/components/ui/card/CardDescription.vue
Normal file
@@ -0,0 +1,19 @@
|
||||
<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>
|
||||
14
apps/website/src/components/ui/card/CardFooter.vue
Normal file
@@ -0,0 +1,14 @@
|
||||
<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>
|
||||
14
apps/website/src/components/ui/card/CardHeader.vue
Normal file
@@ -0,0 +1,14 @@
|
||||
<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>
|
||||
22
apps/website/src/components/ui/card/CardTitle.vue
Normal file
@@ -0,0 +1,22 @@
|
||||
<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>
|
||||
@@ -0,0 +1,37 @@
|
||||
<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>
|
||||
@@ -22,6 +22,12 @@ interface HeroLogoConfig {
|
||||
cursorTiltStrength: number
|
||||
bgScale: number
|
||||
slideDuration: number
|
||||
svgMarkup: string
|
||||
fitAxis: 'width' | 'height'
|
||||
targetSize: number
|
||||
respectReducedMotion: boolean
|
||||
baseUrl: string
|
||||
fadeInDurationMs: number
|
||||
}
|
||||
|
||||
const DEFAULTS: HeroLogoConfig = {
|
||||
@@ -34,19 +40,25 @@ const DEFAULTS: HeroLogoConfig = {
|
||||
extrudeDepth: 200,
|
||||
cursorTiltStrength: 0.5,
|
||||
bgScale: 0.8,
|
||||
slideDuration: 0.4
|
||||
slideDuration: 0.4,
|
||||
svgMarkup: SVG_MARKUP,
|
||||
fitAxis: 'height',
|
||||
targetSize: 3,
|
||||
respectReducedMotion: true,
|
||||
baseUrl: BASE_URL,
|
||||
fadeInDurationMs: 0
|
||||
}
|
||||
|
||||
function buildImageUrls(): string[] {
|
||||
function buildImageUrls(baseUrl: string): string[] {
|
||||
return Array.from({ length: IMAGE_COUNT }, (_, i) => {
|
||||
const index = String(i).padStart(5, '0')
|
||||
return `${BASE_URL}/image_sequence_${index}.webp`
|
||||
return `${baseUrl}/image_sequence_${index}.webp`
|
||||
})
|
||||
}
|
||||
|
||||
function parseShapes(): THREE.Shape[] {
|
||||
function parseShapes(markup: string): THREE.Shape[] {
|
||||
const loader = new SVGLoader()
|
||||
const svgData = loader.parse(SVG_MARKUP)
|
||||
const svgData = loader.parse(markup)
|
||||
const shapes: THREE.Shape[] = []
|
||||
svgData.paths.forEach((path) => {
|
||||
shapes.push(...SVGLoader.createShapes(path))
|
||||
@@ -85,7 +97,8 @@ export function useHeroLogo(
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const container = containerRef.value
|
||||
if (!container || prefersReducedMotion()) return
|
||||
if (!container || (cfg.respectReducedMotion && prefersReducedMotion()))
|
||||
return
|
||||
|
||||
const { width, height } = container.getBoundingClientRect()
|
||||
|
||||
@@ -102,6 +115,9 @@ 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)
|
||||
|
||||
@@ -126,24 +142,36 @@ export function useHeroLogo(
|
||||
camera.position.z = cfg.zoom
|
||||
|
||||
// SVG shape
|
||||
const shapes = parseShapes()
|
||||
const shapes = parseShapes(cfg.svgMarkup)
|
||||
const tempGeo = new THREE.ShapeGeometry(shapes)
|
||||
tempGeo.computeBoundingBox()
|
||||
const bb = tempGeo.boundingBox!
|
||||
const bb = tempGeo.boundingBox
|
||||
if (!bb) {
|
||||
tempGeo.dispose()
|
||||
cleanup?.()
|
||||
return
|
||||
}
|
||||
const cx = (bb.max.x + bb.min.x) / 2
|
||||
const cy = (bb.max.y + bb.min.y) / 2
|
||||
const scaleFactor = 3 / (bb.max.y - bb.min.y)
|
||||
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
|
||||
tempGeo.dispose()
|
||||
|
||||
// Image sequence textures — load first frame eagerly, rest lazily
|
||||
const urls = buildImageUrls()
|
||||
const urls = buildImageUrls(cfg.baseUrl)
|
||||
const textures = await loadTextures(urls.slice(0, 1))
|
||||
if (disposed) return
|
||||
|
||||
renderer.domElement.style.opacity = '1'
|
||||
loaded.value = true
|
||||
|
||||
loadTextures(urls.slice(1)).then((rest) => {
|
||||
void loadTextures(urls.slice(1)).then((rest) => {
|
||||
if (!disposed) textures.push(...rest)
|
||||
})
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ const baseRoutes = {
|
||||
cloudEnterprise: '/cloud/enterprise',
|
||||
api: '/api',
|
||||
gallery: '/gallery',
|
||||
launches: '/launches',
|
||||
about: '/about',
|
||||
careers: '/careers',
|
||||
customers: '/customers',
|
||||
@@ -18,7 +19,8 @@ const baseRoutes = {
|
||||
affiliates: '/affiliates',
|
||||
affiliateTerms: '/affiliates/terms',
|
||||
contact: '/contact',
|
||||
models: '/p/supported-models'
|
||||
models: '/p/supported-models',
|
||||
mcp: '/mcp'
|
||||
} as const
|
||||
|
||||
type Routes = typeof baseRoutes
|
||||
@@ -59,10 +61,13 @@ 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',
|
||||
mcpSkills: 'https://github.com/Comfy-Org/comfy-skills',
|
||||
platform: 'https://platform.comfy.org',
|
||||
platformUsage: 'https://platform.comfy.org/profile/usage',
|
||||
reddit: 'https://www.reddit.com/r/comfyui/',
|
||||
|
||||
248
apps/website/src/data/drops.ts
Normal file
@@ -0,0 +1,248 @@
|
||||
// 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: '/mcp', 'zh-CN': '/zh-CN/mcp' }
|
||||
}
|
||||
},
|
||||
{
|
||||
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' }
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -38,7 +38,7 @@ export const learningTutorials: readonly LearningTutorial[] = [
|
||||
label: 'English'
|
||||
}
|
||||
],
|
||||
// href: '#',
|
||||
href: 'https://comfy.org/workflows/8f2cf0df5da6-8f2cf0df5da6/',
|
||||
tags: [partnerNodesTag, imageToVideoTag]
|
||||
},
|
||||
{
|
||||
|
||||
@@ -69,10 +69,19 @@ 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,
|
||||
|
||||
@@ -11,6 +11,16 @@ 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',
|
||||
@@ -1825,6 +1835,311 @@ 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 custom connector in Claude, Cursor, Codex, or any MCP-compatible client. Sign in once, and the full ComfyUI toolset is available right in your chat.',
|
||||
'zh-CN':
|
||||
'将 Comfy Cloud 添加为 Claude、Cursor、Codex 或任意兼容 MCP 客户端的自定义连接器。登录一次,ComfyUI 全套工具即可直接在对话中使用。'
|
||||
},
|
||||
'mcp.setup.step1.label': { en: 'STEP 1', 'zh-CN': '第 1 步' },
|
||||
'mcp.setup.step1.title': {
|
||||
en: 'Copy the MCP URL',
|
||||
'zh-CN': '复制 MCP URL'
|
||||
},
|
||||
'mcp.setup.step1.description': {
|
||||
en: "Click the copy button below. You'll paste it into your client in the next step.",
|
||||
'zh-CN': '点击下方的复制按钮,下一步将其粘贴到你的客户端中。'
|
||||
},
|
||||
'mcp.setup.step2.label': { en: 'STEP 2', 'zh-CN': '第 2 步' },
|
||||
'mcp.setup.step2.title': {
|
||||
en: 'Add the connector',
|
||||
'zh-CN': '添加连接器'
|
||||
},
|
||||
'mcp.setup.step2.description': {
|
||||
en: 'Name it Comfy Cloud and paste the URL. The docs below cover every client.',
|
||||
'zh-CN': '将其命名为 Comfy Cloud 并粘贴 URL。下方文档涵盖各类客户端。'
|
||||
},
|
||||
'mcp.setup.step2.cta': {
|
||||
en: 'COMFY CLOUD MCP DOCS',
|
||||
'zh-CN': 'COMFY CLOUD MCP 文档'
|
||||
},
|
||||
'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 Connect, sign in, and every Comfy Cloud skill is ready in your client.',
|
||||
'zh-CN': '点击"连接"并登录,所有 Comfy Cloud 技能即可在你的客户端中使用。'
|
||||
},
|
||||
'mcp.setup.step3.cta': {
|
||||
en: 'COMFY CLOUD SKILLS',
|
||||
'zh-CN': 'COMFY CLOUD 技能'
|
||||
},
|
||||
|
||||
// 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': '价格' },
|
||||
@@ -1849,6 +2164,7 @@ 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': '下载' },
|
||||
@@ -1866,6 +2182,7 @@ 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': '联系' },
|
||||
@@ -2596,18 +2913,18 @@ const translations = {
|
||||
'zh-CN': 'Plans; Fees; Free Tier.'
|
||||
},
|
||||
'tos.payment.block.1': {
|
||||
en: 'Your use of the Comfy Products is subject to the plan selected via the applicable ordering page, online sign-up flow, or order form (“Plan”). Comfy may offer a free or freemium tier (“Free Tier”) and one or more paid tiers; the applicable Plan may include usage caps, feature restrictions, throttling, overage charges, or upgrade requirements, each as described in the pricing page or applicable Order Form. You are responsible for all usage under your account, including usage by your Users and under your credentials and API keys. Comfy may modify, suspend, or discontinue any Plan (including the Free Tier) consistent with this Agreement and the Order Forms.',
|
||||
en: 'Your use of the Comfy Products is subject to the plan selected via the applicable ordering page, online sign-up flow, or order form (“Plan”). Comfy may offer a free or freemium tier (“Free Tier”) and one or more paid tiers; the applicable Plan may include usage caps, feature restrictions, throttling, overage charges, or upgrade requirements, each as described in the pricing page or applicable Order Form. If a Free Tier user provides a valid payment method in connection with their account (including for identity verification, future upgrade purposes, or any other reason), such user expressly authorizes Comfy to charge that payment method for any usage that exceeds the applicable Free Tier limits, including overages resulting from intentional use, usage by authorized users or third parties under the account, or technical factors. Comfy will use reasonable efforts to notify users when they approach or exceed Free Tier limits, but such notice is not a condition of Comfy’s right to charge for overages. You are responsible for all usage under your account, including usage by your Users and under your credentials and API keys. Comfy may modify, suspend, or discontinue any Plan (including the Free Tier) consistent with this Agreement and the Order Forms.',
|
||||
'zh-CN':
|
||||
'Your use of the Comfy Products is subject to the plan selected via the applicable ordering page, online sign-up flow, or order form (“Plan”). Comfy may offer a free or freemium tier (“Free Tier”) and one or more paid tiers; the applicable Plan may include usage caps, feature restrictions, throttling, overage charges, or upgrade requirements, each as described in the pricing page or applicable Order Form. You are responsible for all usage under your account, including usage by your Users and under your credentials and API keys. Comfy may modify, suspend, or discontinue any Plan (including the Free Tier) consistent with this Agreement and the Order Forms.'
|
||||
'Your use of the Comfy Products is subject to the plan selected via the applicable ordering page, online sign-up flow, or order form (“Plan”). Comfy may offer a free or freemium tier (“Free Tier”) and one or more paid tiers; the applicable Plan may include usage caps, feature restrictions, throttling, overage charges, or upgrade requirements, each as described in the pricing page or applicable Order Form. If a Free Tier user provides a valid payment method in connection with their account (including for identity verification, future upgrade purposes, or any other reason), such user expressly authorizes Comfy to charge that payment method for any usage that exceeds the applicable Free Tier limits, including overages resulting from intentional use, usage by authorized users or third parties under the account, or technical factors. Comfy will use reasonable efforts to notify users when they approach or exceed Free Tier limits, but such notice is not a condition of Comfy’s right to charge for overages. You are responsible for all usage under your account, including usage by your Users and under your credentials and API keys. Comfy may modify, suspend, or discontinue any Plan (including the Free Tier) consistent with this Agreement and the Order Forms.'
|
||||
},
|
||||
'tos.payment.block.2.heading': {
|
||||
en: 'Self-Serve Credit Card Billing.',
|
||||
'zh-CN': 'Self-Serve Credit Card Billing.'
|
||||
},
|
||||
'tos.payment.block.3': {
|
||||
en: 'For self-serve Plans, Customer will provide a valid payment method (e.g., credit card) and authorizes Comfy (and its payment processor) to charge all fees and taxes when due. Unless the Order Forms state otherwise, subscription components (if any) will be billed in advance on a recurring basis and usage-based components (including any overages) will be billed in arrears for the applicable billing period (and may be charged as usage accrues). Paid self-serve Plans automatically renew for successive billing periods until cancelled through the console or as otherwise described in the Order Forms; if a charge fails, Comfy may retry the charge and Customer must promptly update its payment method.',
|
||||
en: 'For self-serve Plans, Customer will provide a valid payment method (e.g., credit card) and authorizes Comfy (and its payment processor) to charge all fees and taxes when due. Unless the Order Forms state otherwise, subscription components (if any) will be billed in advance on a recurring basis and usage-based components (including any overages) will be billed in arrears for the applicable billing period (and may be charged as usage accrues). This billing authorization applies regardless of whether the Customer is on a paid Plan or a Free Tier at the time the overage is incurred. Paid self-serve Plans automatically renew for successive billing periods until cancelled through the console or as otherwise described in the Order Forms; if a charge fails, Comfy may retry the charge and Customer must promptly update its payment method. The same retry rights apply to any failed overage charges incurred by Free Tier users.',
|
||||
'zh-CN':
|
||||
'For self-serve Plans, Customer will provide a valid payment method (e.g., credit card) and authorizes Comfy (and its payment processor) to charge all fees and taxes when due. Unless the Order Forms state otherwise, subscription components (if any) will be billed in advance on a recurring basis and usage-based components (including any overages) will be billed in arrears for the applicable billing period (and may be charged as usage accrues). Paid self-serve Plans automatically renew for successive billing periods until cancelled through the console or as otherwise described in the Order Forms; if a charge fails, Comfy may retry the charge and Customer must promptly update its payment method.'
|
||||
'For self-serve Plans, Customer will provide a valid payment method (e.g., credit card) and authorizes Comfy (and its payment processor) to charge all fees and taxes when due. Unless the Order Forms state otherwise, subscription components (if any) will be billed in advance on a recurring basis and usage-based components (including any overages) will be billed in arrears for the applicable billing period (and may be charged as usage accrues). This billing authorization applies regardless of whether the Customer is on a paid Plan or a Free Tier at the time the overage is incurred. Paid self-serve Plans automatically renew for successive billing periods until cancelled through the console or as otherwise described in the Order Forms; if a charge fails, Comfy may retry the charge and Customer must promptly update its payment method. The same retry rights apply to any failed overage charges incurred by Free Tier users.'
|
||||
},
|
||||
'tos.payment.block.4.heading': {
|
||||
en: 'Invoiced Billing.',
|
||||
@@ -4928,6 +5245,70 @@ 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 what’s 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>>
|
||||
|
||||
|
||||
20
apps/website/src/pages/launches.astro
Normal file
@@ -0,0 +1,20 @@
|
||||
---
|
||||
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>
|
||||
24
apps/website/src/pages/mcp.astro
Normal file
@@ -0,0 +1,24 @@
|
||||
---
|
||||
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>
|
||||
20
apps/website/src/pages/zh-CN/launches.astro
Normal file
@@ -0,0 +1,20 @@
|
||||
---
|
||||
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>
|
||||
24
apps/website/src/pages/zh-CN/mcp.astro
Normal file
@@ -0,0 +1,24 @@
|
||||
---
|
||||
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>
|
||||
@@ -162,6 +162,45 @@
|
||||
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([*]);
|
||||
}
|
||||
|
||||
25
apps/website/src/templates/drops/CtaSection.vue
Normal file
@@ -0,0 +1,25 @@
|
||||
<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>
|
||||
76
apps/website/src/templates/drops/DropCard.vue
Normal file
@@ -0,0 +1,76 @@
|
||||
<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>
|
||||
29
apps/website/src/templates/drops/DropsSection.vue
Normal file
@@ -0,0 +1,29 @@
|
||||
<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>
|
||||
35
apps/website/src/templates/drops/HeroSection.vue
Normal file
@@ -0,0 +1,35 @@
|
||||
<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>
|
||||
55
apps/website/src/templates/drops/LaunchesHeroLogo.vue
Normal file
61
apps/website/src/templates/drops/SubscribeBanner.vue
Normal file
@@ -0,0 +1,61 @@
|
||||
<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>
|
||||
5
apps/website/src/templates/drops/livestream.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export const livestream = {
|
||||
youtubeVideoId: 'yo7b_zHd20g',
|
||||
startDateTime: '2026-06-29T15:00:00Z',
|
||||
endDateTime: '2026-07-02T17:15:00Z'
|
||||
} as const
|
||||
195
apps/website/src/templates/mcp/ComfyMcpDemo.vue
Normal file
@@ -0,0 +1,195 @@
|
||||
<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_01–03.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>
|
||||
19
apps/website/src/templates/mcp/FAQSection.vue
Normal file
@@ -0,0 +1,19 @@
|
||||
<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>
|
||||
27
apps/website/src/templates/mcp/HeroSection.vue
Normal file
@@ -0,0 +1,27 @@
|
||||
<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>
|
||||
29
apps/website/src/templates/mcp/HowItWorksSection.vue
Normal file
@@ -0,0 +1,29 @@
|
||||
<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>
|
||||
64
apps/website/src/templates/mcp/SetupSection.vue
Normal file
@@ -0,0 +1,64 @@
|
||||
<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: 'code',
|
||||
value: externalLinks.mcpServer
|
||||
}
|
||||
},
|
||||
{
|
||||
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: 'link',
|
||||
label: t('mcp.setup.step2.cta', locale),
|
||||
href: externalLinks.docsMcp,
|
||||
target: '_blank',
|
||||
icon: ArrowUpRight,
|
||||
variant: 'default'
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'step3',
|
||||
label: t('mcp.setup.step3.label', locale),
|
||||
title: t('mcp.setup.step3.title', locale),
|
||||
description: t('mcp.setup.step3.description', locale),
|
||||
action: {
|
||||
type: 'link',
|
||||
label: t('mcp.setup.step3.cta', locale),
|
||||
href: externalLinks.mcpSkills,
|
||||
target: '_blank',
|
||||
icon: ArrowUpRight,
|
||||
variant: 'default'
|
||||
}
|
||||
}
|
||||
]
|
||||
</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>
|
||||
66
apps/website/src/templates/mcp/ToolsSection.vue
Normal file
@@ -0,0 +1,66 @@
|
||||
<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>
|
||||
26
apps/website/src/templates/mcp/WhySection.vue
Normal file
@@ -0,0 +1,26 @@
|
||||
<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>
|
||||
27
apps/website/src/templates/mcp/ctas.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
18
apps/website/src/utils/cta.test.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
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()
|
||||
})
|
||||
})
|
||||
10
apps/website/src/utils/cta.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
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)
|
||||
)
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
/** @knipIgnoreUsedByStackedPR */
|
||||
export type VideoFormat = 'webm' | 'mp4'
|
||||
|
||||
export type VideoSource = {
|
||||
type VideoSource = {
|
||||
src: string
|
||||
type: `video/${VideoFormat}`
|
||||
format: VideoFormat
|
||||
|
||||
@@ -14,6 +14,24 @@
|
||||
{ "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": [
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
{
|
||||
"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
|
||||
}
|
||||
BIN
browser_tests/assets/video/video-preview-portrait.webm
Normal file
BIN
browser_tests/assets/video/video-preview-square.webm
Normal file
BIN
browser_tests/assets/video/video-preview-wide.webm
Normal file
@@ -4,8 +4,9 @@
|
||||
import type { Locator, Page } from '@playwright/test'
|
||||
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
import { VueNodeFixture } from '@e2e/fixtures/utils/vueNodeFixtures'
|
||||
import { getSlotKey } from '@/renderer/core/layout/slots/slotIdentifier'
|
||||
import { toNodeId } from '@/types/nodeId'
|
||||
import { VueNodeFixture } from '@e2e/fixtures/utils/vueNodeFixtures'
|
||||
|
||||
export class VueNodeHelpers {
|
||||
/**
|
||||
@@ -43,7 +44,7 @@ export class VueNodeHelpers {
|
||||
.locator('.lg-slot--input')
|
||||
.filter({
|
||||
has: this.page.locator(
|
||||
`[data-slot-key="${getSlotKey(nodeId, slotIndex, true)}"]`
|
||||
`[data-slot-key="${getSlotKey(toNodeId(nodeId), slotIndex, true)}"]`
|
||||
)
|
||||
})
|
||||
}
|
||||
@@ -251,14 +252,18 @@ export class VueNodeHelpers {
|
||||
const key = await slot.getByTestId('slot-dot').getAttribute('data-slot-key')
|
||||
if (!key) 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
|
||||
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 type === 'in'
|
||||
? node.inputs[Number(slotId)]?.link !== null
|
||||
: !!node.outputs[Number(slotId)].links?.length
|
||||
}, key)
|
||||
return type === 'in'
|
||||
? node.inputs[Number(slotId)]?.link !== null
|
||||
: !!node.outputs[Number(slotId)]?.links?.length
|
||||
},
|
||||
[nodeId, type, slotId] as const
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -93,7 +93,7 @@ export class NodeLibrarySidebarTabV2 extends SidebarTab {
|
||||
public readonly searchInput: Locator
|
||||
public readonly sidebarContent: Locator
|
||||
public readonly allTab: Locator
|
||||
public readonly blueprintsTab: Locator
|
||||
public readonly essentialsTab: 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')
|
||||
this.blueprintsTab = this.getTab('Blueprints')
|
||||
this.allTab = this.getTab('All nodes')
|
||||
this.essentialsTab = this.getTab('Essentials')
|
||||
this.sortButton = this.sidebarContent.getByRole('button', { name: 'Sort' })
|
||||
this.nodePreview = page.getByTestId(TestIds.sidebar.nodePreviewCard)
|
||||
}
|
||||
|
||||
@@ -5,10 +5,9 @@ import type {
|
||||
LGraph,
|
||||
LGraphNode
|
||||
} from '@/lib/litegraph/src/litegraph'
|
||||
import type {
|
||||
ComfyWorkflowJSON,
|
||||
NodeId
|
||||
} from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||
import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||
import { toNodeId } from '@/types/nodeId'
|
||||
import type { NodeId, SerializedNodeId } from '@/types/nodeId'
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
import { DefaultGraphPositions } from '@e2e/fixtures/constants/defaultGraphPositions'
|
||||
import type { Position, Size } from '@e2e/fixtures/types'
|
||||
@@ -42,11 +41,12 @@ export class NodeOperationsHelper {
|
||||
}
|
||||
|
||||
async getSelectedNodeIds(): Promise<NodeId[]> {
|
||||
return await this.page.evaluate(() => {
|
||||
const selectedNodeIds = await this.page.evaluate(() => {
|
||||
const selected = window.app?.canvas?.selected_nodes
|
||||
if (!selected) return []
|
||||
return Object.keys(selected).map(Number)
|
||||
return Object.keys(selected)
|
||||
})
|
||||
return selectedNodeIds.map(toNodeId)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -114,8 +114,8 @@ export class NodeOperationsHelper {
|
||||
return this.getNodeRefById(id)
|
||||
}
|
||||
|
||||
async getNodeRefById(id: NodeId): Promise<NodeReference> {
|
||||
return new NodeReference(id, this.comfyPage)
|
||||
async getNodeRefById(id: SerializedNodeId): Promise<NodeReference> {
|
||||
return new NodeReference(toNodeId(id), this.comfyPage)
|
||||
}
|
||||
|
||||
async getNodeRefsByType(
|
||||
@@ -136,7 +136,7 @@ export class NodeOperationsHelper {
|
||||
},
|
||||
{ type, includeSubgraph }
|
||||
)
|
||||
).map((id: NodeId) => this.getNodeRefById(id))
|
||||
).map((id: SerializedNodeId) => 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: NodeId) => this.getNodeRefById(id))
|
||||
).map((id: SerializedNodeId) => this.getNodeRefById(id))
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
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'
|
||||
@@ -45,9 +48,9 @@ async function orbitDragFromCanvasCenter(
|
||||
|
||||
export class Preview3DPipelineContext {
|
||||
/** Matches node ids in `browser_tests/assets/3d/preview3d_pipeline.json`. */
|
||||
static readonly loadNodeId = '1'
|
||||
static readonly loadNodeId = toNodeId(1)
|
||||
/** Matches node ids in `browser_tests/assets/3d/preview3d_pipeline.json`. */
|
||||
static readonly previewNodeId = '2'
|
||||
static readonly previewNodeId = toNodeId(2)
|
||||
|
||||
readonly load3d: Load3DHelper
|
||||
readonly preview3d: Load3DHelper
|
||||
@@ -61,9 +64,9 @@ export class Preview3DPipelineContext {
|
||||
)
|
||||
}
|
||||
|
||||
async getModelFileWidgetValue(nodeId: string): Promise<string> {
|
||||
async getModelFileWidgetValue(nodeId: NodeId): Promise<string> {
|
||||
return this.comfyPage.page.evaluate((id) => {
|
||||
const node = window.app!.graph.getNodeById(Number(id))
|
||||
const node = window.app!.graph.getNodeById(id)
|
||||
if (!node?.widgets) return ''
|
||||
const w = node.widgets.find((x) => x.name === 'model_file')
|
||||
const v = w?.value
|
||||
@@ -71,9 +74,9 @@ export class Preview3DPipelineContext {
|
||||
}, nodeId)
|
||||
}
|
||||
|
||||
async getLastTimeModelFile(nodeId: string): Promise<string> {
|
||||
async getLastTimeModelFile(nodeId: NodeId): Promise<string> {
|
||||
return this.comfyPage.page.evaluate((id) => {
|
||||
const node = window.app!.graph.getNodeById(Number(id))
|
||||
const node = window.app!.graph.getNodeById(id)
|
||||
if (!node?.properties) return ''
|
||||
const v = (node.properties as Record<string, unknown>)[
|
||||
'Last Time Model File'
|
||||
@@ -82,9 +85,9 @@ export class Preview3DPipelineContext {
|
||||
}, nodeId)
|
||||
}
|
||||
|
||||
async getCameraStateFromProperties(nodeId: string): Promise<unknown> {
|
||||
async getCameraStateFromProperties(nodeId: NodeId): Promise<unknown> {
|
||||
return this.comfyPage.page.evaluate((id) => {
|
||||
const node = window.app!.graph.getNodeById(Number(id))
|
||||
const node = window.app!.graph.getNodeById(id)
|
||||
if (!node?.properties) return null
|
||||
const cfg = (node.properties as Record<string, unknown>)['Camera Config']
|
||||
if (cfg === null || typeof cfg !== 'object') return null
|
||||
|
||||
@@ -7,6 +7,7 @@ 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'
|
||||
@@ -549,6 +550,7 @@ 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
|
||||
@@ -565,7 +567,7 @@ export class SubgraphHelper {
|
||||
}
|
||||
}
|
||||
return null
|
||||
}, nodeId)
|
||||
}, localNodeId)
|
||||
}
|
||||
|
||||
static async expectWidgetBelowHeader(
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import type { SerialisableLLink } from '@/lib/litegraph/src/types/serialisation'
|
||||
import type { NodeId } from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||
import type { NodeId } from '@/types/nodeId'
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
import type { Position, Size } from '@e2e/fixtures/types'
|
||||
import { VueNodeFixture } from '@e2e/fixtures/utils/vueNodeFixtures'
|
||||
|
||||
136
browser_tests/fixtures/utils/objectInfo.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
425
browser_tests/fixtures/utils/promotedMissingModel.ts
Normal file
@@ -0,0 +1,425 @@
|
||||
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()
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
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'
|
||||
@@ -44,6 +45,7 @@ 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)
|
||||
@@ -91,7 +93,7 @@ export async function getPromotedWidgets(
|
||||
})
|
||||
return { widgetSources, previewExposures }
|
||||
},
|
||||
nodeId
|
||||
localNodeId
|
||||
)
|
||||
|
||||
const exposures = isNodeProperty(previewExposures)
|
||||
|
||||
@@ -10,9 +10,10 @@ 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 = 2
|
||||
const HOST_NODE_ID = toNodeId(2)
|
||||
const WIDGET_NAME = 'ckpt_name'
|
||||
const SELECTED_MODEL = STABLE_CHECKPOINT_2.name
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { toNodeId } from '@/types/nodeId'
|
||||
|
||||
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
@@ -39,16 +41,22 @@ test.describe('Graph', { tag: ['@smoke', '@canvas'] }, () => {
|
||||
await comfyPage.workflow.loadWorkflow('links/duplicate_links_slot_drift')
|
||||
|
||||
function evaluateGraph() {
|
||||
return comfyPage.page.evaluate(() => {
|
||||
const nodeIds = {
|
||||
switchCfg: toNodeId(120),
|
||||
ksampler85: toNodeId(85),
|
||||
ksampler86: toNodeId(86)
|
||||
}
|
||||
|
||||
return comfyPage.page.evaluate((nodeIds) => {
|
||||
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(120)
|
||||
const ksampler85 = subgraph.getNodeById(85)
|
||||
const ksampler86 = subgraph.getNodeById(86)
|
||||
const switchCfg = subgraph.getNodeById(nodeIds.switchCfg)
|
||||
const ksampler85 = subgraph.getNodeById(nodeIds.ksampler85)
|
||||
const ksampler86 = subgraph.getNodeById(nodeIds.ksampler86)
|
||||
if (!switchCfg || !ksampler85 || !ksampler86)
|
||||
return { error: 'Required nodes not found' }
|
||||
|
||||
@@ -74,7 +82,10 @@ 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 (link.origin_id === 120 && link.target_id === 85)
|
||||
if (
|
||||
String(link.origin_id) === '120' &&
|
||||
String(link.target_id) === '85'
|
||||
)
|
||||
cfgLinkToNode85Count++
|
||||
}
|
||||
|
||||
@@ -89,7 +100,7 @@ test.describe('Graph', { tag: ['@smoke', '@canvas'] }, () => {
|
||||
switchOutputLinkCount,
|
||||
cfgLinkToNode85Count
|
||||
}
|
||||
})
|
||||
}, nodeIds)
|
||||
}
|
||||
|
||||
// Poll graph state once, then assert all properties
|
||||
|
||||
@@ -4,6 +4,9 @@ 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 }) => {
|
||||
@@ -29,15 +32,15 @@ test.describe('Image Compare', { tag: ['@widget', '@vue-nodes'] }, () => {
|
||||
}
|
||||
) {
|
||||
await comfyPage.page.evaluate(
|
||||
({ value }) => {
|
||||
const node = window.app!.graph.getNodeById(1)
|
||||
({ nodeId, value }) => {
|
||||
const node = window.app!.graph.getNodeById(nodeId)
|
||||
const widget = node?.widgets?.find((w) => w.type === 'imagecompare')
|
||||
if (widget) {
|
||||
widget.value = value
|
||||
widget.callback?.(value)
|
||||
}
|
||||
},
|
||||
{ value }
|
||||
{ nodeId: IMAGE_COMPARE_NODE_ID, value }
|
||||
)
|
||||
await comfyPage.nextFrame()
|
||||
}
|
||||
@@ -450,11 +453,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(() => {
|
||||
const graphNode = window.app!.graph.getNodeById(1)
|
||||
const size = await comfyPage.page.evaluate((nodeId) => {
|
||||
const graphNode = window.app!.graph.getNodeById(nodeId)
|
||||
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'
|
||||
@@ -600,15 +603,15 @@ test.describe('Image Compare', { tag: ['@widget', '@vue-nodes'] }, () => {
|
||||
}) => {
|
||||
const url = createTestImageDataUrl('Legacy', '#c00')
|
||||
await comfyPage.page.evaluate(
|
||||
({ url }) => {
|
||||
const node = window.app!.graph.getNodeById(1)
|
||||
({ nodeId, url }) => {
|
||||
const node = window.app!.graph.getNodeById(nodeId)
|
||||
const widget = node?.widgets?.find((w) => w.type === 'imagecompare')
|
||||
if (widget) {
|
||||
widget.value = url
|
||||
widget.callback?.(url)
|
||||
}
|
||||
},
|
||||
{ url }
|
||||
{ nodeId: IMAGE_COMPARE_NODE_ID, url }
|
||||
)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
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 }) => {
|
||||
@@ -95,15 +96,15 @@ test.describe('Image Crop', () => {
|
||||
const newBounds = { x: 50, y: 100, width: 200, height: 300 }
|
||||
|
||||
await comfyPage.page.evaluate(
|
||||
({ bounds }) => {
|
||||
const node = window.app!.graph.getNodeById(1)
|
||||
({ nodeId, bounds }) => {
|
||||
const node = window.app!.graph.getNodeById(nodeId)
|
||||
const widget = node?.widgets?.find((w) => w.type === 'imagecrop')
|
||||
if (widget) {
|
||||
widget.value = bounds
|
||||
widget.callback?.(bounds)
|
||||
}
|
||||
},
|
||||
{ bounds: newBounds }
|
||||
{ nodeId: toNodeId(1), bounds: newBounds }
|
||||
)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
|
||||
@@ -2,15 +2,16 @@ 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(() => {
|
||||
const n = window.app!.graph.getNodeById(1)
|
||||
page.evaluate((nodeId) => {
|
||||
const n = window.app!.graph.getNodeById(nodeId)
|
||||
const modelConfig = n?.properties?.['Model Config'] as
|
||||
| { gizmo?: { enabled: boolean; mode: string } }
|
||||
| undefined
|
||||
return modelConfig?.gizmo
|
||||
})
|
||||
}, toNodeId(1))
|
||||
|
||||
test.describe('Load3D Gizmo Controls', () => {
|
||||
test(
|
||||
|
||||
@@ -2,6 +2,7 @@ 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(
|
||||
@@ -67,13 +68,13 @@ test.describe('Load3D', () => {
|
||||
|
||||
await expect
|
||||
.poll(() =>
|
||||
comfyPage.page.evaluate(() => {
|
||||
const n = window.app!.graph.getNodeById(1)
|
||||
comfyPage.page.evaluate((nodeId) => {
|
||||
const n = window.app!.graph.getNodeById(nodeId)
|
||||
const config = n?.properties?.['Scene Config'] as
|
||||
| Record<string, string>
|
||||
| undefined
|
||||
return config?.backgroundColor
|
||||
})
|
||||
}, toNodeId(1))
|
||||
)
|
||||
.toBe('#cc3333')
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ 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)
|
||||
|
||||
@@ -331,7 +332,8 @@ wstest(
|
||||
|
||||
async function getNodeOutput() {
|
||||
return await comfyPage.page.evaluate(
|
||||
() => graph!.getNodeById('1')!.images?.[0]?.filename
|
||||
(nodeId) => graph!.getNodeById(nodeId)!.images?.[0]?.filename,
|
||||
toNodeId(1)
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,8 @@ 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']
|
||||
|
||||
@@ -32,12 +34,13 @@ async function addGhostAtCenter(comfyPage: ComfyPage) {
|
||||
return { nodeId: nodeRef.id, centerX, centerY }
|
||||
}
|
||||
|
||||
function getNodeById(comfyPage: ComfyPage, nodeId: number | string) {
|
||||
function getNodeById(comfyPage: ComfyPage, nodeId: SerializedNodeId) {
|
||||
const localNodeId = toNodeId(nodeId)
|
||||
return comfyPage.page.evaluate((id) => {
|
||||
const node = window.app!.graph.getNodeById(id)
|
||||
if (!node) return null
|
||||
return { ghost: !!node.flags.ghost }
|
||||
}, nodeId)
|
||||
}, localNodeId)
|
||||
}
|
||||
|
||||
for (const mode of ['litegraph', 'vue'] as const) {
|
||||
|
||||
@@ -33,49 +33,11 @@ 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 }) => {
|
||||
const tabButton = comfyPage.page.locator('.node-library-tab-button')
|
||||
await tabButton.click()
|
||||
|
||||
const essentialsTab = comfyPage.page.getByRole('tab', {
|
||||
name: /essentials/i
|
||||
})
|
||||
await essentialsTab.click()
|
||||
await comfyPage.settings.setSetting('Comfy.NodeLibrary.NewDesign', true)
|
||||
const tab = comfyPage.menu.nodeLibraryTabV2
|
||||
await tab.open()
|
||||
await tab.essentialsTab.click()
|
||||
|
||||
const firstCard = comfyPage.page.locator('[data-node-name]').first()
|
||||
await expect(firstCard).toBeVisible()
|
||||
@@ -86,21 +48,18 @@ test.describe('Node Library Essentials Tab', { tag: '@ui' }, () => {
|
||||
test('Node library can switch between all and essentials tabs', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const tabButton = comfyPage.page.locator('.node-library-tab-button')
|
||||
await tabButton.click()
|
||||
await comfyPage.settings.setSetting('Comfy.NodeLibrary.NewDesign', true)
|
||||
const tab = comfyPage.menu.nodeLibraryTabV2
|
||||
await tab.open()
|
||||
await tab.allTab.click()
|
||||
|
||||
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')
|
||||
await tab.essentialsTab.click()
|
||||
await expect(tab.essentialsTab).toHaveAttribute('aria-selected', 'true')
|
||||
const essentialCards = comfyPage.page.locator('[data-node-name]')
|
||||
await expect(essentialCards.first()).toBeVisible()
|
||||
|
||||
await allNodesTab.click()
|
||||
await expect(allNodesTab).toHaveAttribute('aria-selected', 'true')
|
||||
await expect(essentialsTab).toHaveAttribute('aria-selected', 'false')
|
||||
await tab.allTab.click()
|
||||
await expect(tab.allTab).toHaveAttribute('aria-selected', 'true')
|
||||
await expect(tab.essentialsTab).toHaveAttribute('aria-selected', 'false')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -12,6 +12,7 @@ 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 },
|
||||
@@ -245,8 +246,10 @@ test.describe('Node replacement', { tag: ['@node', '@ui'] }, () => {
|
||||
.click()
|
||||
|
||||
const replacedNodeOutputLinkCount = await comfyPage.page.evaluate(
|
||||
() =>
|
||||
window.app!.graph!.getNodeById(2)?.outputs[0]?.links?.length ?? 0
|
||||
(nodeId) =>
|
||||
window.app!.graph!.getNodeById(nodeId)?.outputs[0]?.links
|
||||
?.length ?? 0,
|
||||
toNodeId(2)
|
||||
)
|
||||
expect(
|
||||
replacedNodeOutputLinkCount,
|
||||
|
||||
@@ -10,8 +10,20 @@ import {
|
||||
countAssetRequestsByTag,
|
||||
createCloudAssetsFixture
|
||||
} from '@e2e/fixtures/assetApiFixture'
|
||||
import { loadWorkflowAndOpenErrorsTab } from '@e2e/fixtures/helpers/ErrorsTabHelper'
|
||||
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 { 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'
|
||||
@@ -20,6 +32,8 @@ 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 =
|
||||
@@ -55,7 +69,25 @@ 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 (
|
||||
@@ -353,13 +385,94 @@ test.describe(
|
||||
|
||||
await expect
|
||||
.poll(() =>
|
||||
comfyPage.page.evaluate(() => {
|
||||
const node = window.app!.graph.getNodeById(1)
|
||||
comfyPage.page.evaluate((nodeId) => {
|
||||
const node = window.app!.graph.getNodeById(nodeId)
|
||||
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)
|
||||
}
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
@@ -8,6 +8,10 @@ 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
|
||||
@@ -86,50 +90,6 @@ 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({
|
||||
@@ -161,57 +121,10 @@ 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 routeSetupObjectInfo(page)
|
||||
const unrouteObjectInfo = await routeObjectInfoFromSetupApi(page)
|
||||
|
||||
try {
|
||||
await use(page)
|
||||
@@ -225,13 +138,16 @@ const cloudEmptyMediaInputsTest = createCloudAssetsFixture([]).extend({
|
||||
page: async ({ page }, use) => {
|
||||
await routeCloudBootstrapApis(page)
|
||||
|
||||
const unrouteObjectInfo = await routeSetupObjectInfo(page, (objectInfo) => {
|
||||
for (const node of emptyMediaLoaderNodes) {
|
||||
setComboInputOptions(objectInfo, node.nodeType, node.widgetName, [
|
||||
node.serverOnlyOption
|
||||
])
|
||||
const unrouteObjectInfo = await routeObjectInfoFromSetupApi(
|
||||
page,
|
||||
(objectInfo) => {
|
||||
for (const node of emptyMediaLoaderNodes) {
|
||||
setComboInputOptions(objectInfo, node.nodeType, node.widgetName, [
|
||||
node.serverOnlyOption
|
||||
])
|
||||
}
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
try {
|
||||
await use(page)
|
||||
@@ -246,7 +162,7 @@ const cloudUploadRaceTest = comfyPageFixture.extend<{
|
||||
}>({
|
||||
page: async ({ page }, use) => {
|
||||
await routeCloudBootstrapApis(page)
|
||||
const unrouteObjectInfo = await routeSetupObjectInfo(page)
|
||||
const unrouteObjectInfo = await routeObjectInfoFromSetupApi(page)
|
||||
|
||||
const state: CloudUploadAssetState = {
|
||||
isUploadedAssetAvailable: false
|
||||
|
||||
@@ -8,12 +8,46 @@ 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'
|
||||
|
||||
function getModelLabel(group: Locator, modelName: string = FAKE_MODEL_NAME) {
|
||||
return group.getByRole('button', { name: modelName, exact: true })
|
||||
}
|
||||
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()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
async function expectReferenceBadge(group: Locator, count: number) {
|
||||
await expect(
|
||||
@@ -169,7 +203,9 @@ test.describe('Errors tab - Mode-aware errors', { tag: '@ui' }, () => {
|
||||
TestIds.dialogs.missingModelsGroup
|
||||
)
|
||||
await expect(missingModelGroup).toBeVisible()
|
||||
await expect(getModelLabel(missingModelGroup)).toBeVisible()
|
||||
await expect(
|
||||
getMissingModelLabel(missingModelGroup, FAKE_MODEL_NAME)
|
||||
).toBeVisible()
|
||||
|
||||
const node = await comfyPage.nodeOps.getNodeRefById('1')
|
||||
await node.click('title')
|
||||
@@ -265,7 +301,9 @@ test.describe('Errors tab - Mode-aware errors', { tag: '@ui' }, () => {
|
||||
|
||||
const node1 = await comfyPage.nodeOps.getNodeRefById('1')
|
||||
await node1.click('title')
|
||||
await expect(getModelLabel(missingModelGroup)).toBeVisible()
|
||||
await expect(
|
||||
getMissingModelLabel(missingModelGroup, FAKE_MODEL_NAME)
|
||||
).toBeVisible()
|
||||
await expect(
|
||||
missingModelGroup.getByTestId(TestIds.dialogs.missingModelLocate)
|
||||
).toHaveCount(1)
|
||||
@@ -381,92 +419,184 @@ test.describe('Errors tab - Mode-aware errors', { tag: '@ui' }, () => {
|
||||
await cleanupFakeModel(comfyPage)
|
||||
})
|
||||
|
||||
test(
|
||||
'Resolving a promoted missing model widget through the legacy canvas path clears its error',
|
||||
{ tag: ['@canvas', '@widget', '@subgraph'] },
|
||||
promotedModelTest(
|
||||
'Changing an OSS Vue promoted model clears a nested subgraph error',
|
||||
{ tag: ['@vue-nodes', '@widget', '@subgraph'] },
|
||||
async ({ comfyPage }) => {
|
||||
const resolvedModelName = 'v1-5-pruned-emaonly-fp16.safetensors'
|
||||
let missingModelGroup: Locator
|
||||
|
||||
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', false)
|
||||
await loadWorkflowAndOpenErrorsTab(
|
||||
comfyPage,
|
||||
'missing/missing_model_promoted_widget'
|
||||
)
|
||||
|
||||
const missingModelGroup = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.missingModelsGroup
|
||||
)
|
||||
await expect(getModelLabel(missingModelGroup)).toBeVisible()
|
||||
|
||||
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'
|
||||
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
|
||||
)
|
||||
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')
|
||||
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')
|
||||
}
|
||||
|
||||
settableWidget.setValue(value, {
|
||||
e: new PointerEvent('pointerup'),
|
||||
node: hostNode,
|
||||
canvas: window.app!.canvas
|
||||
})
|
||||
}, resolvedModelName)
|
||||
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 expect(missingModelGroup).toBeHidden()
|
||||
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)
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
test(
|
||||
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',
|
||||
{ tag: ['@canvas', '@widget', '@subgraph'] },
|
||||
async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', false)
|
||||
await loadPromotedMissingModelAndOpenErrorsTab(
|
||||
comfyPage,
|
||||
NESTED_PROMOTED_MISSING_MODEL_WORKFLOW,
|
||||
FAKE_MODEL_NAME
|
||||
)
|
||||
|
||||
await setLegacyPromotedComboModel(
|
||||
comfyPage,
|
||||
NESTED_PROMOTED_MISSING_MODEL_WORKFLOW,
|
||||
RESOLVED_PROMOTED_MODEL_NAME
|
||||
)
|
||||
|
||||
await expectNoMissingModelUi(comfyPage)
|
||||
}
|
||||
)
|
||||
|
||||
promotedModelTest(
|
||||
'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,
|
||||
'missing/missing_model_promoted_widget'
|
||||
NESTED_PROMOTED_MISSING_MODEL_WORKFLOW.workflowName
|
||||
)
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
|
||||
const missingModelGroup = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.missingModelsGroup
|
||||
)
|
||||
await expect(getModelLabel(missingModelGroup)).toBeVisible()
|
||||
await expect(
|
||||
getMissingModelLabel(missingModelGroup, FAKE_MODEL_NAME)
|
||||
).toBeVisible()
|
||||
|
||||
const promotedModelCombo = comfyPage.vueNodes
|
||||
.getNodeByTitle('Subgraph with Promoted Missing Model')
|
||||
.getNodeByTitle(NESTED_PROMOTED_MISSING_MODEL_WORKFLOW.hostNodeTitle)
|
||||
.getByRole('combobox', { name: 'ckpt_name', exact: true })
|
||||
await expect(promotedModelCombo).toHaveAttribute('aria-invalid', 'true')
|
||||
|
||||
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 })
|
||||
})
|
||||
const unrouteObjectInfo = await routeObjectInfoFromSetupApi(
|
||||
comfyPage.page,
|
||||
(objectInfo) =>
|
||||
appendComboInputOptions(
|
||||
objectInfo,
|
||||
'CheckpointLoaderSimple',
|
||||
'ckpt_name',
|
||||
[FAKE_MODEL_NAME, RESOLVED_PROMOTED_MODEL_NAME]
|
||||
)
|
||||
)
|
||||
|
||||
try {
|
||||
await comfyPage.page
|
||||
.getByTestId(TestIds.dialogs.missingModelRefresh)
|
||||
.click()
|
||||
@@ -478,11 +608,31 @@ test.describe('Errors tab - Mode-aware errors', { tag: '@ui' }, () => {
|
||||
'true'
|
||||
)
|
||||
} finally {
|
||||
await comfyPage.page.unroute(objectInfoRoute)
|
||||
await unrouteObjectInfo()
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
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
|
||||
}) => {
|
||||
|
||||
@@ -4,15 +4,16 @@ 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: number } & Position
|
||||
type NodeSnapshot = { id: NodeId } & Position
|
||||
|
||||
async function getAllNodePositions(
|
||||
comfyPage: ComfyPage
|
||||
): Promise<NodeSnapshot[]> {
|
||||
return comfyPage.page.evaluate(() =>
|
||||
window.app!.graph.nodes.map((n) => ({
|
||||
id: n.id as number,
|
||||
id: n.id,
|
||||
x: n.pos[0],
|
||||
y: n.pos[1]
|
||||
}))
|
||||
@@ -21,7 +22,7 @@ async function getAllNodePositions(
|
||||
|
||||
async function getNodePosition(
|
||||
comfyPage: ComfyPage,
|
||||
nodeId: number
|
||||
nodeId: NodeId
|
||||
): Promise<Position | undefined> {
|
||||
return comfyPage.page.evaluate((targetNodeId) => {
|
||||
const node = window.app!.graph.nodes.find((n) => n.id === targetNodeId)
|
||||
|
||||
|
Before Width: | Height: | Size: 90 KiB After Width: | Height: | Size: 90 KiB |
@@ -1095,6 +1095,33 @@ 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 }) => {
|
||||
|
||||
@@ -10,20 +10,6 @@ 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
|
||||
|
||||
@@ -123,8 +109,9 @@ test.describe('Node library sidebar V2', () => {
|
||||
|
||||
test('Blueprint previews include description', async ({ comfyPage }) => {
|
||||
const tab = comfyPage.menu.nodeLibraryTabV2
|
||||
await tab.blueprintsTab.click()
|
||||
await tab.allTab.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')
|
||||
|
||||
@@ -3,6 +3,7 @@ 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)
|
||||
|
||||
@@ -198,7 +199,7 @@ test.describe('Subgraph Breadcrumb', { tag: ['@subgraph'] }, () => {
|
||||
|
||||
const rootNodeTitle = await comfyPage.page.evaluate(
|
||||
(nodeId) => window.app!.graph!.getNodeById(nodeId)?.title ?? null,
|
||||
OUTER_SUBGRAPH_NODE_ID_IN_NESTED
|
||||
toNodeId(OUTER_SUBGRAPH_NODE_ID_IN_NESTED)
|
||||
)
|
||||
expect(rootNodeTitle).toBe(newName)
|
||||
})
|
||||
|
||||