Compare commits

...

20 Commits

Author SHA1 Message Date
huang47
29c876ec33 test: add origin CI guard smoke test 2026-06-29 20:30:44 -07:00
huang47
027aabc9e3 ci: skip chromatic deploy for fork PRs 2026-06-29 19:16:46 -07:00
huang47
9bb13dca52 ci: skip cloud cleanup dispatch for fork PRs 2026-06-29 19:16:38 -07:00
huang47
28a4881fdf ci: skip cloud build dispatch for fork PRs 2026-06-29 19:15:37 -07:00
huang47
1e655f44c2 ci: skip vercel preview deploy for fork PRs 2026-06-29 19:15:15 -07:00
huang47
8f216f15e3 ci: skip website e2e report deploy for fork PRs 2026-06-29 15:20:51 -07:00
imick-io
fb3350ee0e feat(website): redesign Comfy MCP setup steps and add button variant (#13285)
## Summary

Reworks the Comfy MCP page's **"Set up Comfy MCP in three steps"**
section to match the new design, and adds a per-action button `variant`
option to `FeatureGrid01`.

The three steps are now:

| Step | Title | Action |
| --- | --- | --- |
| 1 | Copy the MCP URL | Copy field showing
`https://cloud.comfy.org/mcp` |
| 2 | Add the connector | Filled button **"COMFY CLOUD MCP DOCS" ↗** →
MCP docs |
| 3 | Connect and sign in | Filled button **"COMFY CLOUD SKILLS" ↗** →
comfy-skills repo |

## Changes

- **`FeatureGrid01.vue`** — add `variant?: 'default' | 'outline'` to the
link card action; button now uses `card.action.variant ?? 'outline'`
instead of a hardcoded outline, so callers can opt into the filled
style.
- **`config/routes.ts`** — add `mcpSkills` external link
(`https://github.com/Comfy-Org/comfy-skills`).
- **`i18n/translations.ts`** — refresh the `mcp.setup.*` copy (en +
zh-CN): new subtitle, reworded steps, new `step2.cta` / `step3.cta`,
drop the now-unused `step1.cta`.
- **`SetupSection.vue`** — re-map cards: step 1 → copy field, steps 2 &
3 → filled link buttons.

## Test plan

- [x] `pnpm typecheck` — 0 errors
- [x] Pre-commit hooks (stylelint, oxfmt, oxlint, eslint, typecheck)
pass
- [ ] Visual check on `/mcp` and `/zh-CN/mcp` (copy field on step 1; two
filled yellow CTAs with up-right arrows on steps 2 & 3)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 19:08:03 +00:00
imick-io
be8e0010ee feat(website): rebuild Comfy MCP page on the design system (+ zh-CN) (#13283)
Rebuilds the **Comfy MCP** marketing page on the website design-system
stack and adds the missing zh-CN page.

## What's here
- Replaces the bespoke `components/product/mcp/` section silo with thin
`templates/mcp/*` wrappers over reusable `blocks/` + `common/`
components.
- Adds `src/pages/zh-CN/mcp.astro` and threads `locale` through every
section (was English-only).
- New/extended design-system blocks:
- `FeatureGrid01` — setup steps, with a reusable `ui/CopyableField`
(uses `@vueuse/core` `useClipboard`).
- `FeatureGrid02` — how-it-works steps with `NodeUnionIcon` connectors +
a CTA pair via `ui/button`.
- `FeatureRows01` — alternating media rows; `ReasonsSplit01` — "why"
list.
- `HeroSplit01` gained `subtitle`, a `media` slot, and a `class`
passthrough; `SectionHeader` gained `align`.
- Standardized block section spacing on `px-6 py-16 lg:py-24`.
- Refreshed all 8 MCP FAQ answers (en + zh-CN) and hydrated the FAQ
section so the accordion is interactive.

## Notes
- Stacked on the original MCP landing-page commits (previously PR
#13095); those ride along here.
- `typecheck` and `build` are green; `/mcp` and `/zh-CN/mcp` both render
in both locales.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Balpreet Brar <balpreet.brar@growthnatives.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: github-actions <github-actions@github.com>
Co-authored-by: Alexander Brown <drjkl@comfy.org>
2026-06-29 10:04:24 -07:00
imick-io
d0e97d6933 fix(website): move launches nav item and add cleanplate workflow link (#13282)
## Summary
- Move the `/launches` nav item from **Company → More** to **Products →
Features** in the main navbar
- Add the workflow link to the **Cleanplate Walkthrough** learning
tutorial (`https://comfy.org/workflows/8f2cf0df5da6-8f2cf0df5da6/`)

## Changes
- `apps/website/src/data/mainNavigation.ts`
- `apps/website/src/data/learningTutorials.ts`

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 16:24:10 +00:00
Maanil Verma
3377b8e07e feat(publish): prefill prior metadata and update published workflows in place (#13139)
> **Goal: make updating a published Hub workflow painless in admin
panel.** Today a admin who needs to change a
> published workflow has to reject + reshare + republish, which breaks
the share link, resets stats,
> and blanks the thumbnail — and the rejected backlog can't be cleared.
This is one of 3 PRs that fix it:
>
> | PR | Repo | Fixes |
> |----|------|-------|
> | [#4505](https://github.com/Comfy-Org/cloud/pull/4505) | `cloud` |
Re-publish stops losing data; the editor can read its own published
metadata; reviewer search + admin delete |
> | #13139 | `ComfyUI_frontend` | The publish dialog prefills prior
metadata + thumbnail and updates in place |
> | [#10 ](https://github.com/Comfy-Org/comfy-admin-panels/pull/10)|
`comfy-admin-panels` | Admins can delete rejected/stale workflows |
>
> **Merge order:** `cloud` first (the others depend on its endpoints),
then `ComfyUI_frontend` and
> `comfy-admin-panels` in either order.

## Summary

Editor side of the same effort. Re-opening the publish dialog for an
already-published workflow now
prefills its description, tags, and thumbnail, restores the thumbnail
across step/type changes, keeps
the local workflow name in sync, and labels the action "Update" instead
of "Publish". Depends on the
`cloud` PR — that's what finally returns the `share_id` the dialog reads
from.

## Changes

- **What**:
- **Prefill the thumbnail** — `extractPrefill` dropped `thumbnail_url` /
`thumbnail_comparison_url`,
so only the thumbnail *type* was remembered, never the image. Thread the
URLs into `PublishPrefill`
and restore them; on submit, send the existing URL when no new file is
attached (reuses the
`sampleImageUrls` precedent — an existing URL, not a `File`), so
re-publishing doesn't blank it.
- **Uploads survive navigation** — the thumbnail step kept its `File` in
local component state, so
leaving the step and coming back blanked a fresh upload. The step is now
controlled — files live in
    the form data, the single source of truth that survives the remount.
- **Type-gate the prefilled image** — a restored image must only show on
the tab it belongs to;
`existingThumbnailType` keeps an image off the video tab (which was
hiding the upload prompt) while
    still restoring it when you toggle back.
- **Refetch on rename** — the dialog is a reused singleton, so
`onMounted` fires once; a watch on the
active workflow path refetches prefill when a rename changes it (the
description was going stale).
- **Name sync** — editing the name field published a new Hub display
name but never renamed the local
workflow, so the editor tab (and a reload) kept the old name. Publish
now renames the local file
    when the chosen name differs.
- **"Update" CTA** — the intro panel and footer read "Update" (not
"Publish") when the workflow is
already published, and note that the share link + stats are preserved.

## Review Focus

- `existingThumbnailType` is the load-bearing bit for both the preview
and submit gating — confirm an
image prefill never submits as a video after a type toggle, and that
toggling back restores it.
- Name sync renames *after* a successful publish and is non-fatal on
failure (toast + keep the publish).
The Hub record is keyed by workflow ID, so the rename doesn't orphan it
— worth a sanity check.

## Screenshots



https://github.com/user-attachments/assets/99dd9eff-987f-4ddb-9cf1-e9b40f61e7dc
2026-06-27 05:14:17 +00:00
Christian Byrne
4a2393be48 chore: drop unnecessary exports on file-local types to satisfy knip (#13204)
Current `main` **fails a fresh `knip` run** with 13 unused exported
types (exit 1). They're invisible on main because lint/knip only runs on
`pull_request`/`merge_group`, never on push to main — so merge skew (one
PR adds an export used by file X; a later PR removes X's usage)
accumulates latent failures that ambush backport branches (e.g. #13163,
#13162).

Each of the 13 is `export`ed but referenced only within its own file
(verified 0 importers; ≥2 in-file uses, so not dead code). Fix: drop the
redundant `export`.

Types cleaned: `VideoSource`, `ObjectInfoResponse`,
`PromotedMissingModelWorkflow`, `PixelReadout`, `ResizeDirection`,
`ResizeHandle`, `RunButtonTelemetryOptions`, `ResolvedModelNode`,
`AccountPreconditionContext`, `SubscriptionDialogOptions`,
`MonthlyCreditsUsage`, `MissingMediaReference`, `ResolvedHostWidget`.

Reviewer note: `ResolvedHostWidget` and `ResolvedModelNode` sit under
`renderer/extensions`/`platform/assets`; no in-repo importers, but if
either is intended as published/extension-facing API, prefer a knip
`entry`/`ignore` over un-exporting — flag in review and I'll adjust.

After fresh `knip`: **0 unused exported types**.

Supersedes #13179 (fixed only `AccountPreconditionContext`). Pairs with
the push-gate workflow #13203 — merge this first so that gate is green
on main.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 03:01:59 +00:00
Terry Jia
a451a90868 FE-1150 feat(rightSidePanel): hide 3D viewport widgets from panel (#13206)
## Summary
Add a hideInPanel widget option so a widget still renders on the node
body but is omitted from the right side panel. Apply it to the Three.js
viewport widgets (Load3D, Preview3D, Load3DAdvanced, SaveGLB), whose
non-syncable scene state would diverge if a second instance rendered in
the panel.

App mode and the subgraph editor are unaffected (they filter on
canvasOnly independently).

Discussed with @alexisrolland and @PabloWiedemann 

## Screenshots
before
<img width="2206" height="1181" alt="image"
src="https://github.com/user-attachments/assets/e536871f-65e6-4d6e-aa61-dc981362214f"
/>

after
<img width="2743" height="1295" alt="image"
src="https://github.com/user-attachments/assets/6cc6d252-57ac-464a-a2b7-1ada5ab9e705"
/>
2026-06-26 22:48:47 -04:00
Comfy Org PR Bot
be102899d7 1.47.6 (#13194)
Patch version increment to 1.47.6

**Base branch:** `main`

---------

Co-authored-by: christian-byrne <72887196+christian-byrne@users.noreply.github.com>
Co-authored-by: github-actions <github-actions@github.com>
2026-06-27 01:06:26 +00:00
Wei Hai
abd1a6f10a fix: use error colour for runtime execution error node outline (#13184)
## Summary

A node that fails at runtime is now outlined in the same red as a node
that fails validation, instead of magenta.

## Changes

- **What**: The `executionError` stroke in `litegraphService.ts` was
hardcoded to magenta (`#f0f`); validation errors use
`LiteGraph.NODE_ERROR_COLOUR` (red). Reuse that constant so both error
states render consistently. One-line change; `LiteGraph` is already
imported.

## Review Focus

No test added: asserting a hardcoded stroke colour would be a
change-detector test. The two error paths (validation via `has_errors` /
`NODE_ERROR_COLOUR`, runtime via `lastExecutionError`) now share the
same colour source.
2026-06-27 00:00:08 +00:00
AustinMroz
c16f10b49e Long workflow name cleanup (#13180)
When loading a workflow by dragging and dropping an output from the
assets sidebar, the very long and unhelpful url would be used as the
workflow name. This is fixed by instead using the asset display name
| Before | After |
| ------ | ----- |
| <img width="360" alt="before"
src="https://github.com/user-attachments/assets/5c68ae48-1fa6-40e1-b2fb-6188ccd60391"/>
| <img width="360" alt="after"
src="https://github.com/user-attachments/assets/29770c35-da48-4be9-943e-8ee69eb25e6a"
/>|


Additionally, a max width is added to the breadcrumb items to avoid
extremely long names.
| Before | After |
| ------ | ----- |
| <img width="360" alt="before"
src="https://github.com/user-attachments/assets/508155ec-81d7-4ca5-8910-f42a70c9cb4b"/>
| <img width="360" alt="after"
src="https://github.com/user-attachments/assets/d335ceb7-bfeb-481f-a132-c700e017ee0c"
/>|
2026-06-26 23:11:40 +00:00
ShihChi Huang
64253de713 fix: e2e coverage html report asset failures (#13127)
## Summary

Harden E2E coverage HTML generation against non-renderable LCOV source
entries so public assets and stale sourcemap paths no longer abort the
report.

## Changes

- **What**: Removes `assets/images/*` entries from merged E2E LCOV
before upload/report generation.
- **What**: Lets `genhtml` ignore range warnings and synthesize missing
source files when LCOV references stale paths.
- **Dependencies**: None.

## Review Focus

Root cause: Playwright/Monocart can emit LCOV `SF:` records that
`genhtml` cannot read from the checkout. The failed run stopped first on
public assets like `assets/images/hf-logo.svg`; replaying the same
artifact also exposed stale source paths after those assets were
removed.

The filter is intentionally `assets/images/*`, not `assets/*`, because
real `lcov` matching would also remove legitimate source coverage under
`src/platform/assets/...`.

## Validation

- `yamllint --config-file .yamllint
.github/workflows/ci-tests-e2e-coverage.yaml`
- Replayed failed run `28138018468` merged LCOV:
  - `assets/images/*` strip leaves `0` `SF:assets/...` entries
  - preserves `68` `SF:src/platform/assets/...` entries
- `genhtml` exits `0` with `--ignore-errors source,unmapped,range
--synthesize-missing`
- Commit hook: `oxfmt`, `oxlint`, `eslint`, `pnpm typecheck`
- Push hook: `knip --cache`

## Screenshots (if applicable)

N/A, CI workflow-only.

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> **Low Risk**
> Changes are limited to the E2E coverage GitHub Actions workflow; no
application runtime or security paths are touched.
> 
> **Overview**
> Fixes E2E coverage HTML generation failing when merged LCOV references
paths **genhtml** cannot read (public static assets and stale sourcemap
paths from Playwright/Monocart).
> 
> The **Strip non-source entries** step now also drops `assets/images/*`
via `lcov --remove`, scoped narrowly so real source under
`src/platform/assets/...` stays in the report. **Generate HTML coverage
report** passes `--ignore-errors source,unmapped,range` and
`--synthesize-missing` so remaining unmapped or missing sources do not
abort the job.
> 
> <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
ede5556644. Bugbot is set up for automated
code reviews on this repo. Configure
[here](https://www.cursor.com/dashboard/bugbot).</sup>
<!-- /CURSOR_SUMMARY -->

Co-authored-by: huang47 <157390+huang47@users.noreply.github.com>
2026-06-26 23:05:13 +00:00
Alexander Brown
b4ae6344d7 Brand local node IDs (#13085)
## Summary

Adds a branded local `NodeId` helper and starts separating local node
identity from serialized workflow IDs.

## Changes

- **What**: Adds central `NodeId` parsing/branding helpers, migrates
nearby widget identity types, keeps queue results at the serialized
boundary, and removes misleading workflow `NodeId` usage from execution
error maps.

## Review Focus

Check that the first migration slice keeps serialized/API IDs as raw
`number | string` while local UI/store IDs use the branded string type.

## Caveat

`SUBGRAPH_INPUT_ID` and `SUBGRAPH_OUTPUT_ID` are now branded local
`NodeId` string values internally instead of numeric sentinels.
Reviewers should double-check extension compatibility for callers that
import `Constants` and compare those values numerically.

## Screenshots (if applicable)

N/A

---------

Co-authored-by: GitHub Action <action@github.com>
Co-authored-by: AustinMroz <austin@comfy.org>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 22:54:04 +00:00
Christian Byrne
feaa4ce82f refactor: localize system stats headers and fix PyTorch casing (#12253)
## Summary
Localizes the 12 system stats column headers via vue-i18n and fixes the
`Pytorch` → `PyTorch` casing typo.

## Changes
- **src/locales/en/main.json**: Add 11 i18n keys under `g.systemStats*`
namespace
- **src/components/common/SystemStatsPanel.vue**: 
  - Import `useI18n` and use `t()` for column headers
  - Change `header` field to `headerKey` in ColumnDef type
  - Fix PyTorch casing (was `Pytorch Version`)

## Context
Follow-up to PR #11816 per review comment.

## Test plan
- [x] Column headers render correctly
- [x] Copy System Info includes localized headers
- [ ] Verify other locales can override (out of scope - EN only for now)

Closes #11870

🤖 Generated with [Claude Code](https://claude.com/claude-code)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12253-refactor-localize-system-stats-headers-and-fix-PyTorch-casing-3606d73d36508134af99f7ca4f9c6593)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
Co-authored-by: GitHub Action <action@github.com>
2026-06-26 15:30:28 -07:00
Yosef Chai
f5d059b720 feat(i18n): add Hebrew (he) language support (#12721)
## Summary

Adds full Hebrew (`he` / עברית) localization for the ComfyUI frontend
UI, registered the same additive way as the existing RTL locales (`ar`,
`fa`).

## Changes

- **What**:
- New `src/locales/he/main.json`, `commands.json`, and `settings.json` —
complete, human-reviewed Hebrew translations.
- Exact key parity with `en` (3524 / 125 / 235 keys), verified
programmatically.
- All `{interpolation}` placeholders and `|`-separated plural forms are
preserved (same segment counts as `en`).
- Established technical terms are kept in English (API, GPU, VAE, CLIP,
LoRA, ControlNet, Civitai, Hugging Face, flux, Nodes 2.0, …).
- `dataTypes` and `nodeCategories` are kept as verbatim English
identifiers; option **keys** in
`settings.json`/`menuLabels`/`contextMenu` are left untouched (only
their display values are translated).
- `src/locales/he/nodeDefs.json` is an empty object on purpose, so node
definitions fall back to English and get auto-populated by the release
i18n workflow (per the locale CONTRIBUTING guide).
- Registered `he: { text: 'עברית', loaders: loadersFor('he') }` in
`src/locales/localeConfig.ts` (which automatically adds it to the
Settings → Language dropdown and `SUPPORTED_LOCALE_OPTIONS`).
- Added `he` to `outputLocales` in `.i18nrc.cjs`, plus a Hebrew glossary
block for the CI translator.

## Review Focus

- This follows the approach of #7876 (Persian/Farsi). Like the existing
`ar`/`fa` locales, it translates UI text only and does **not** introduce
RTL layout (`dir="rtl"`) — the app does not currently apply RTL layout
for any locale. I'm happy to follow up with proper RTL layout support in
a separate PR if that's wanted.
- Recurring-term glossary used for consistency: node = צומת, workflow =
תהליך עבודה, queue = תור, widget = פקד, subgraph = תת-גרף, canvas =
קנבס, bypass = עקיפה, prompt = פרומפט.
- Native-speaker review is very welcome. cc translation maintainers
@Yorha4D @KarryCharon @DorotaLuna @shinshin86

## Screenshots

Text-only locale addition — no UI/layout changes. After this change,
**Settings → Language** lists **"עברית"**, and selecting it renders the
UI in Hebrew (untranslated node definitions fall back to English).

---------

Co-authored-by: Yosef Chai <192742853+yosef-chai@users.noreply.github.com>
Co-authored-by: christian-byrne <abolkonsky.rem@gmail.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 21:58:18 +00:00
balpreetgrowthnatives
e2c670bbc3 fix: prevent payment routes from being indexed (#12706)
## Summary

Allow crawler access to `/payment/` routes in robots.txt so search
engines can read the `noindex` tag, and forcefully inject the
`x-robots-tag: noindex` header via `vercel.json`.

## Changes

- **What**: Removed `Disallow: /payment/` from `robots.txt` and added
rules to `vercel.json` applying `x-robots-tag: noindex` to
`/payment/(.*)` and `/zh-CN/payment/(.*)` routes.

## Review Focus

- The configurations in `vercel.json` apply to both English and
localized payment routes (`/zh-CN/payment/(.*)`).

---------

Co-authored-by: nav-tej <36310614+nav-tej@users.noreply.github.com>
Co-authored-by: Christian Byrne <cbyrne@comfy.org>
2026-06-26 15:11:12 -07:00
382 changed files with 35692 additions and 2643 deletions

View File

@@ -88,9 +88,9 @@ jobs:
- name: Strip non-source entries from coverage
if: steps.coverage-shards.outputs.has-coverage == 'true'
run: |
# Drop served bundle scripts (localhost-8188/assets/*.js) that V8 records but have no source file on disk, which would abort genhtml.
lcov --remove coverage/playwright/coverage.lcov \
'*localhost-8188*' \
'assets/images/*' \
-o coverage/playwright/coverage.lcov \
--ignore-errors unused
wc -l coverage/playwright/coverage.lcov
@@ -121,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'

View File

@@ -95,6 +95,7 @@ jobs:
if: |
github.event_name == 'workflow_dispatch'
|| (github.event_name == 'pull_request'
&& github.event.pull_request.head.repo.fork == false
&& startsWith(github.head_ref, 'version-bump-')
&& (needs.changes.outputs.storybook-changes == 'true'
|| needs.changes.outputs.app-frontend-changes == 'true'

View File

@@ -30,7 +30,7 @@ concurrency:
jobs:
deploy-preview:
if: github.event_name == 'pull_request'
if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == false
runs-on: ubuntu-latest
permissions:
contents: read

View File

@@ -67,7 +67,15 @@ jobs:
- name: Deploy report to Cloudflare
id: deploy
if: always() && !cancelled()
if: >-
${{
always() &&
!cancelled() &&
(
github.event_name != 'pull_request' ||
github.event.pull_request.head.repo.fork == false
)
}}
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}

View File

@@ -32,12 +32,13 @@ jobs:
if: >
github.repository == 'Comfy-Org/ComfyUI_frontend' &&
(github.event_name != 'pull_request' ||
(github.event.action == 'labeled' &&
contains(fromJSON('["preview","preview-cpu","preview-gpu"]'), github.event.label.name)) ||
(github.event.action == 'synchronize' &&
(contains(github.event.pull_request.labels.*.name, 'preview') ||
contains(github.event.pull_request.labels.*.name, 'preview-cpu') ||
contains(github.event.pull_request.labels.*.name, 'preview-gpu'))))
(github.event.pull_request.head.repo.fork == false &&
((github.event.action == 'labeled' &&
contains(fromJSON('["preview","preview-cpu","preview-gpu"]'), github.event.label.name)) ||
(github.event.action == 'synchronize' &&
(contains(github.event.pull_request.labels.*.name, 'preview') ||
contains(github.event.pull_request.labels.*.name, 'preview-cpu') ||
contains(github.event.pull_request.labels.*.name, 'preview-gpu'))))))
runs-on: ubuntu-latest
steps:
- name: Build client payload

View File

@@ -21,6 +21,7 @@ jobs:
# - Preview label specifically removed
if: >
github.repository == 'Comfy-Org/ComfyUI_frontend' &&
github.event.pull_request.head.repo.fork == false &&
((github.event.action == 'closed' &&
(contains(github.event.pull_request.labels.*.name, 'preview') ||
contains(github.event.pull_request.labels.*.name, 'preview-cpu') ||

View File

@@ -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.
`
})

View File

@@ -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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 59 KiB

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

After

Width:  |  Height:  |  Size: 58 KiB

View File

@@ -1,3 +1,3 @@
<svg 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

View File

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

View File

@@ -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

View 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>

View 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>

View 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>

View File

@@ -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>

View 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>

View File

@@ -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"

View File

@@ -37,7 +37,8 @@ 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 }
]
},
{

View File

@@ -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>

View File

@@ -19,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
@@ -65,6 +66,8 @@ export const externalLinks = {
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/',

View File

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

View File

@@ -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,
@@ -180,11 +189,6 @@ export function getMainNavigation(locale: Locale): NavItem[] {
},
// TODO: no /brand page yet
// { label: t('nav.brand', locale), href: '#' },
{
label: t('nav.launches', locale),
href: routes.launches,
badge: 'new'
},
{
label: t('nav.blogs', locale),
href: externalLinks.blog,

View File

@@ -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': '价格' },
@@ -1867,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': '联系' },

View 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>

View 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>

View File

@@ -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([*]);
}

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

View File

@@ -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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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
}
}
}

View File

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

View File

@@ -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": [

View 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
)
}
}

View File

@@ -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))
)
}

View File

@@ -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

View File

@@ -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(

View File

@@ -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'

View File

@@ -12,7 +12,7 @@ import type {
InputSpec
} from '@/schemas/nodeDefSchema'
export type ObjectInfoResponse = Record<string, ComfyNodeDef>
type ObjectInfoResponse = Record<string, ComfyNodeDef>
type ComboInput = ComboInputSpec | ComboInputSpecV2

View File

@@ -10,10 +10,11 @@ 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'
export interface PromotedMissingModelWorkflow {
interface PromotedMissingModelWorkflow {
workflowName: string
hostNodeId: number
hostNodeTitle: string
@@ -418,7 +419,7 @@ async function enterSubgraphForStaleInteriorCheck(
throw new Error(`Expected visible subgraph node ${targetNodeId}`)
}
window.app!.canvas.setGraph(node.subgraph)
}, numericNodeId)
}, toNodeId(normalizedNodeId))
await comfyPage.nextFrame()
await comfyPage.vueNodes.waitForNodes()
}

View File

@@ -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)

View File

@@ -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

View File

@@ -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

View File

@@ -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()

View File

@@ -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()

View File

@@ -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(

View File

@@ -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')

View File

@@ -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)
)
}

View File

@@ -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) {

View File

@@ -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,

View File

@@ -23,6 +23,7 @@ import {
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'
@@ -384,11 +385,11 @@ 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)
})

View File

@@ -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)

View File

@@ -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 }) => {

View File

@@ -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)
})

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,6 @@
import type { Locator, Page } from '@playwright/test'
import type { NodeId } from '@/platform/workflow/validation/schemas/workflowSchema'
import type { NodeId } from '@/types/nodeId'
import { getSlotKey } from '@/renderer/core/layout/slots/slotIdentifier'
import {
comfyExpect as expect,
@@ -67,7 +67,7 @@ function slotLocator(
slotIndex: number,
isInput: boolean
) {
const key = getSlotKey(String(nodeId), slotIndex, isInput)
const key = getSlotKey(nodeId, slotIndex, isInput)
return page.locator(`[data-slot-key="${key}"]`)
}

View File

@@ -5,6 +5,7 @@ import {
comfyPageFixture
} from '@e2e/fixtures/ComfyPage'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import { toNodeId } from '@/types/nodeId'
import {
cleanupFakeModel,
dismissErrorOverlay,
@@ -59,12 +60,13 @@ async function selectLoadImageNodeForPaste(
comfyPage: ComfyPage,
loadImageId: string
): Promise<void> {
const localLoadImageId = toNodeId(loadImageId)
await comfyPage.page.evaluate((nodeId) => {
const node = window.app!.graph.getNodeById(Number(nodeId))
const node = window.app!.graph.getNodeById(nodeId)
if (!node) throw new Error(`Load Image node ${nodeId} not found`)
window.app!.canvas.selectNode(node)
window.app!.canvas.current_node = node
}, loadImageId)
}, localLoadImageId)
}
async function setupLoadImageErrorScenario(comfyPage: ComfyPage) {
@@ -147,7 +149,7 @@ test.describe('Vue Node Error', { tag: '@vue-nodes' }, () => {
}
return index
},
{ nodeId: ksamplerId, inputName: KSAMPLER_MODEL_INPUT_NAME }
{ nodeId: toNodeId(ksamplerId), inputName: KSAMPLER_MODEL_INPUT_NAME }
)
const modelInputSlotRow = comfyPage.vueNodes.getInputSlotRow(
ksamplerId,

View File

@@ -2,14 +2,15 @@ import {
comfyPageFixture as test,
comfyExpect as expect
} from '@e2e/fixtures/ComfyPage'
import { toNodeId } from '@/types/nodeId'
test('Can display a slot mismatched from widget type', async ({
comfyPage
}) => {
await comfyPage.page.evaluate(() => {
const emptyLatent = window.app!.graph.getNodeById(5)!
await comfyPage.page.evaluate((nodeId) => {
const emptyLatent = window.app!.graph.getNodeById(nodeId)!
emptyLatent.inputs[0].type = 'INT,FLOAT'
})
}, toNodeId(5))
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
const width = comfyPage.vueNodes

View File

@@ -5,6 +5,7 @@ import {
comfyPageFixture as test
} from '@e2e/fixtures/ComfyPage'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import { toNodeId } from '@/types/nodeId'
type CropValue = { x: number; y: number; width: number; height: number } | null
@@ -15,6 +16,7 @@ async function getCropValue(
comfyPage: ComfyPage,
nodeId: number
): Promise<CropValue> {
const localNodeId = toNodeId(nodeId)
return comfyPage.page.evaluate((id) => {
const n = window.app!.graph.getNodeById(id)
const w = n?.widgets?.find((x) => x.type === 'imagecrop')
@@ -34,7 +36,7 @@ async function getCropValue(
}
}
return null
}, nodeId)
}, localNodeId)
}
async function setCropBounds(
@@ -42,6 +44,7 @@ async function setCropBounds(
nodeId: number,
bounds: { x: number; y: number; width: number; height: number }
) {
const localNodeId = toNodeId(nodeId)
await comfyPage.page.evaluate(
({ id, b }) => {
const n = window.app!.graph.getNodeById(id)
@@ -51,7 +54,7 @@ async function setCropBounds(
w.callback?.(b)
}
},
{ id: nodeId, b: bounds }
{ id: localNodeId, b: bounds }
)
await comfyPage.nextFrame()
await comfyPage.nextFrame()

View File

@@ -2,6 +2,7 @@ import {
comfyPageFixture as test,
comfyExpect as expect
} from '@e2e/fixtures/ComfyPage'
import { toNodeId } from '@/types/nodeId'
test('@vue-nodes In App Mode, widget width updates with panel size', async ({
comfyPage,
@@ -17,7 +18,8 @@ test('@vue-nodes In App Mode, widget width updates with panel size', async ({
const getWidth = () =>
comfyPage.page.evaluate(
() => graph!.getNodeById(10)!.widgets![0].width ?? 0
(nodeId) => graph!.getNodeById(nodeId)!.widgets![0].width ?? 0,
toNodeId(10)
)
await test.step('Mouse clicks resolve to button regions', async () => {

View File

@@ -1,9 +1,7 @@
import type { Page, Request } from '@playwright/test'
import type {
ComfyApiWorkflow,
NodeId
} from '@/platform/workflow/validation/schemas/workflowSchema'
import type { ComfyApiWorkflow } from '@/platform/workflow/validation/schemas/workflowSchema'
import type { SerializedNodeId } from '@/types/nodeId'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import {
comfyExpect as expect,
@@ -140,13 +138,13 @@ test.describe('Workflow settings', { tag: '@canvas' }, () => {
test.describe('Comfy.Workflow.SortNodeIdOnSave', () => {
async function getSerializedNodeIds(
comfyPage: ComfyPage
): Promise<NodeId[]> {
): Promise<SerializedNodeId[]> {
return (await comfyPage.workflow.getExportedWorkflow()).nodes.map(
(n) => n.id
)
}
function ascendingById(ids: NodeId[]): NodeId[] {
function ascendingById(ids: SerializedNodeId[]): SerializedNodeId[] {
return [...ids].sort((a, b) => Number(a) - Number(b))
}

View File

@@ -16,6 +16,7 @@ Extensions are the primary way to add functionality to ComfyUI. They can be cust
- Extension architecture principles
- Hook execution sequence
- Best practices for extension development
- **[Node ID Migration Notes](./node-id-migration.md)** - Compatibility guidance for branded node IDs and subgraph boundary sentinel values
## Quick Links

View File

@@ -0,0 +1,15 @@
# Node ID Migration Notes
ComfyUI frontend now normalizes local node IDs to the branded `NodeId` string
type at internal boundaries. Serialized workflows and API payloads may still
contain numeric IDs, but litegraph node and link fields should be treated as
strings after they enter the frontend.
Extension authors should avoid numeric comparisons against node IDs. In
particular, subgraph boundary sentinels are exposed as branded string IDs:
- `SUBGRAPH_INPUT_ID` serializes from `-10`
- `SUBGRAPH_OUTPUT_ID` serializes from `-20`
Use the exported constants where available, or normalize both sides to strings
before comparing legacy values.

View File

@@ -1,6 +1,6 @@
{
"name": "@comfyorg/comfyui-frontend",
"version": "1.47.5",
"version": "1.47.6",
"private": true,
"description": "Official front-end implementation of ComfyUI",
"homepage": "https://comfy.org",

View File

@@ -0,0 +1 @@
PR 13291 origin CI guard smoke test.

View File

@@ -8,6 +8,7 @@ import { createI18n } from 'vue-i18n'
import WidgetBoundingBoxes from './WidgetBoundingBoxes.vue'
import boundingBoxes from '@/locales/en/main.json'
import type { BoundingBox } from '@/types/boundingBoxes'
import { toNodeId } from '@/types/nodeId'
const { appState } = vi.hoisted(() => ({ appState: { node: null as unknown } }))
@@ -83,7 +84,7 @@ function prepCanvas(canvas: HTMLCanvasElement) {
function renderWidget(modelValue: BoundingBox[]) {
const result = render(WidgetBoundingBoxes, {
props: { nodeId: '1', modelValue },
props: { nodeId: toNodeId('1'), modelValue },
global: { plugins: [i18n] }
})
const canvas = screen.getByTestId('bounding-boxes').querySelector('canvas')!

View File

@@ -145,8 +145,9 @@ import Button from '@/components/ui/button/Button.vue'
import Textarea from '@/components/ui/textarea/Textarea.vue'
import { useBoundingBoxes } from '@/composables/boundingBoxes/useBoundingBoxes'
import type { BoundingBox } from '@/types/boundingBoxes'
import type { NodeId } from '@/types/nodeId'
const { nodeId } = defineProps<{ nodeId: string }>()
const { nodeId } = defineProps<{ nodeId: NodeId }>()
const modelValue = defineModel<BoundingBox[]>({ default: () => [] })
const canvasEl = useTemplateRef<HTMLCanvasElement>('canvasEl')

View File

@@ -22,7 +22,7 @@
data-testid="subgraph-breadcrumb-missing-nodes-icon"
class="icon-[lucide--triangle-alert] text-warning-background"
/>
<span class="p-breadcrumb-item-label px-2">{{ item.label }}</span>
<span class="p-breadcrumb-item-label max-w-72 px-2">{{ item.label }}</span>
<Tag
v-if="item.isBlueprint"
data-testid="subgraph-breadcrumb-blueprint-tag"

View File

@@ -12,8 +12,9 @@ import { useResolvedSelectedInputs } from '@/components/builder/useResolvedSelec
import type { ResolvedSelection } from '@/components/builder/useResolvedSelectedInputs'
import type { WidgetId } from '@/types/widgetId'
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
import type { LGraphNode, NodeId } from '@/lib/litegraph/src/LGraphNode'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import type { LGraphCanvas } from '@/lib/litegraph/src/LGraphCanvas'
import type { NodeId } from '@/types/nodeId'
import {
LGraphEventMode,
TitleMode
@@ -132,7 +133,7 @@ function handleClick(e: MouseEvent) {
if (!isSelectOutputsMode.value) return
if (!node.constructor.nodeData?.output_node)
return canvasInteractions.forwardEventToCanvas(e)
const index = appModeStore.selectedOutputs.findIndex((id) => id == node.id)
const index = appModeStore.selectedOutputs.findIndex((id) => id === node.id)
if (index === -1) appModeStore.selectedOutputs.push(node.id)
else appModeStore.selectedOutputs.splice(index, 1)
return
@@ -287,7 +288,7 @@ const renderedInputs = computed<[string, MaybeRef<BoundStyle> | undefined][]>(
:title
:sub-title="String(key)"
:remove="
() => remove(appModeStore.selectedOutputs, (k) => k == key)
() => remove(appModeStore.selectedOutputs, (k) => k === key)
"
/>
</DraggableList>
@@ -347,7 +348,7 @@ const renderedInputs = computed<[string, MaybeRef<BoundStyle> | undefined][]>(
v-if="isSelected"
class="pointer-events-auto absolute -top-1/2 -right-1/2 size-full cursor-pointer rounded-lg bg-warning-background p-2"
@click.stop="
remove(appModeStore.selectedOutputs, (k) => k == key)
remove(appModeStore.selectedOutputs, (k) => k === key)
"
@pointerdown.stop
>

View File

@@ -23,6 +23,7 @@ import { useAppModeStore } from '@/stores/appModeStore'
import { parseImageWidgetValue } from '@/utils/imageUtil'
import { cn } from '@comfyorg/tailwind-utils'
import { HideLayoutFieldKey } from '@/types/widgetTypes'
import { UNASSIGNED_NODE_ID } from '@/types/nodeId'
import { promptRenameWidget } from '@/utils/widgetUtil'
interface WidgetEntry {
@@ -75,7 +76,7 @@ const mappedSelections = computed((): WidgetEntry[] => {
if (!matchingWidget) return []
matchingWidget.slotMetadata = undefined
matchingWidget.nodeId = String(node.id)
matchingWidget.nodeId = node.id
return [
{
@@ -139,7 +140,7 @@ async function handleDragDrop() {
return false
}
app.dragOverNode = { id: -1, onDragDrop }
app.dragOverNode = { id: UNASSIGNED_NODE_ID, onDragDrop }
}
defineExpose({ handleDragDrop })

View File

@@ -0,0 +1,48 @@
import { render, screen } from '@testing-library/vue'
import { describe, expect, it } from 'vitest'
import type { DeviceStats } from '@/schemas/apiSchema'
import DeviceInfo from './DeviceInfo.vue'
function createDevice(overrides: Partial<DeviceStats> = {}): DeviceStats {
return {
name: 'cuda:0 NVIDIA RTX',
type: 'cuda',
index: 0,
vram_total: 1024,
vram_free: 512,
torch_vram_total: 2048,
torch_vram_free: 256,
...overrides
}
}
function renderDeviceInfo(device: DeviceStats) {
return render(DeviceInfo, { props: { device } })
}
describe('DeviceInfo', () => {
it('renders device name and type as-is', () => {
renderDeviceInfo(createDevice())
expect(screen.getByText('cuda:0 NVIDIA RTX')).toBeTruthy()
expect(screen.getByText('cuda')).toBeTruthy()
})
it('formats vram fields as human-readable sizes', () => {
renderDeviceInfo(
createDevice({
vram_total: 1024,
vram_free: 0,
torch_vram_total: 1048576,
torch_vram_free: 1073741824
})
)
expect(screen.getByText('1 KB')).toBeTruthy()
expect(screen.getByText('0 B')).toBeTruthy()
expect(screen.getByText('1 MB')).toBeTruthy()
expect(screen.getByText('1 GB')).toBeTruthy()
})
})

View File

@@ -0,0 +1,86 @@
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
import enMessages from '@/locales/en/main.json'
import type { SystemStats } from '@/schemas/apiSchema'
import SystemStatsPanel from './SystemStatsPanel.vue'
const copyToClipboard = vi.fn()
vi.mock('@/composables/useCopyToClipboard', () => ({
useCopyToClipboard: () => ({ copyToClipboard })
}))
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: { en: enMessages }
})
function createStats(
overrides: Partial<SystemStats['system']> = {}
): SystemStats {
return {
system: {
os: 'posix',
python_version: '3.12.4',
embedded_python: false,
comfyui_version: 'v1.2.3',
pytorch_version: '2.4.0',
argv: ['main.py', '--listen'],
ram_total: 1024,
ram_free: 512,
installed_templates_version: '1.0.0',
required_templates_version: '1.0.0',
...overrides
},
devices: []
}
}
function renderPanel(stats: SystemStats) {
return render(SystemStatsPanel, {
props: { stats },
global: {
plugins: [i18n],
stubs: { Divider: true, TabView: true, TabPanel: true, DeviceInfo: true }
}
})
}
describe('SystemStatsPanel', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('renders localized headers with corrected PyTorch casing', () => {
renderPanel(createStats())
expect(screen.getByText('PyTorch Version')).toBeTruthy()
expect(screen.queryByText('Pytorch Version')).toBeNull()
expect(screen.getByText('OS')).toBeTruthy()
expect(screen.getByText('Python Version')).toBeTruthy()
expect(screen.getByText('Arguments')).toBeTruthy()
})
it('formats values for display', () => {
renderPanel(createStats({ ram_total: 1024, argv: ['main.py', '--cpu'] }))
expect(screen.getByText('1 KB')).toBeTruthy()
expect(screen.getByText('main.py --cpu')).toBeTruthy()
})
it('copies localized, formatted system info to the clipboard', async () => {
renderPanel(createStats())
await userEvent.click(screen.getByText(enMessages.g.copySystemInfo))
expect(copyToClipboard).toHaveBeenCalledTimes(1)
const copied = copyToClipboard.mock.calls[0][0] as string
expect(copied).toContain('## System Info')
expect(copied).toContain('PyTorch Version: 2.4.0')
expect(copied).toContain('RAM Total: 1 KB')
})
})

View File

@@ -13,7 +13,7 @@
<div class="grid grid-cols-2 gap-2">
<template v-for="col in systemColumns" :key="col.field">
<div :class="cn('font-medium', isOutdated(col) && 'text-danger-100')">
{{ col.header }}
{{ $t(col.headerKey) }}
</div>
<div :class="cn(isOutdated(col) && 'text-danger-100')">
{{ getDisplayValue(col) }}
@@ -58,8 +58,10 @@ import { isCloud } from '@/platform/distribution/types'
import type { SystemStats } from '@/schemas/apiSchema'
import { formatCommitHash, formatSize } from '@/utils/formatUtil'
import { cn } from '@comfyorg/tailwind-utils'
import { useI18n } from 'vue-i18n'
const frontendCommit = __COMFYUI_FRONTEND_COMMIT__
const { t } = useI18n()
const props = defineProps<{
stats: SystemStats
@@ -78,7 +80,7 @@ type SystemInfoKey = keyof SystemStats['system']
type ColumnDef = {
field: SystemInfoKey
header: string
headerKey: string
getValue?: () => string
format?: (value: string) => string
formatNumber?: (value: number) => string
@@ -86,31 +88,45 @@ type ColumnDef = {
/** Columns for local distribution */
const localColumns: ColumnDef[] = [
{ field: 'os', header: 'OS' },
{ field: 'python_version', header: 'Python Version' },
{ field: 'embedded_python', header: 'Embedded Python' },
{ field: 'pytorch_version', header: 'Pytorch Version' },
{ field: 'argv', header: 'Arguments' },
{ field: 'ram_total', header: 'RAM Total', formatNumber: formatSize },
{ field: 'ram_free', header: 'RAM Free', formatNumber: formatSize },
{ field: 'installed_templates_version', header: 'Templates Version' }
{ field: 'os', headerKey: 'g.systemStatsOS' },
{ field: 'python_version', headerKey: 'g.systemStatsPythonVersion' },
{ field: 'embedded_python', headerKey: 'g.systemStatsEmbeddedPython' },
{ field: 'pytorch_version', headerKey: 'g.systemStatsPyTorchVersion' },
{ field: 'argv', headerKey: 'g.systemStatsArguments' },
{
field: 'ram_total',
headerKey: 'g.systemStatsRAMTotal',
formatNumber: formatSize
},
{
field: 'ram_free',
headerKey: 'g.systemStatsRAMFree',
formatNumber: formatSize
},
{
field: 'installed_templates_version',
headerKey: 'g.systemStatsTemplatesVersion'
}
]
/** Columns for cloud distribution */
const cloudColumns: ColumnDef[] = [
{ field: 'cloud_version', header: 'Cloud Version' },
{ field: 'cloud_version', headerKey: 'g.systemStatsCloudVersion' },
{
field: 'comfyui_version',
header: 'ComfyUI Version',
headerKey: 'g.systemStatsComfyUIVersion',
format: formatCommitHash
},
{
field: 'comfyui_frontend_version',
header: 'Frontend Version',
headerKey: 'g.systemStatsFrontendVersion',
getValue: () => frontendCommit,
format: formatCommitHash
},
{ field: 'workflow_templates_version', header: 'Templates Version' }
{
field: 'workflow_templates_version',
headerKey: 'g.systemStatsTemplatesVersion'
}
]
const systemColumns = computed(() => (isCloud ? cloudColumns : localColumns))
@@ -141,7 +157,7 @@ function formatSystemInfoText(): string {
for (const col of systemColumns.value) {
const display = getDisplayValue(col)
if (display !== undefined && display !== '') {
lines.push(`${col.header}: ${display}`)
lines.push(`${t(col.headerKey)}: ${display}`)
}
}

View File

@@ -4,6 +4,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import { defineComponent, ref } from 'vue'
import { createI18n } from 'vue-i18n'
import { toNodeId } from '@/types/nodeId'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
const i18n = createI18n({
@@ -239,7 +240,7 @@ describe('WidgetCurve', () => {
renderWidget(
makeWidget({
options: { disabled: true },
linkedUpstream: { nodeId: 'n1' }
linkedUpstream: { nodeId: toNodeId('n1') }
})
)
const parsed = JSON.parse(

View File

@@ -11,6 +11,7 @@ import type { LGraphCanvas } from '@/lib/litegraph/src/LGraphCanvas'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import type { BaseDOMWidget } from '@/scripts/domWidget'
import { useDomWidgetStore } from '@/stores/domWidgetStore'
import { toNodeId } from '@/types/nodeId'
type TestWidget = BaseDOMWidget<object | string>
@@ -21,7 +22,7 @@ function createNode(
pos: [number, number]
) {
const node = new LGraphNode(title)
node.id = id
node.id = toNodeId(id)
node.pos = [...pos]
node.size = [240, 120]
graph.add(node)

View File

@@ -73,7 +73,7 @@
:key="nodeData.id"
:node-data="nodeData"
:error="
executionErrorStore.lastExecutionError?.node_id === nodeData.id
executionErrorStore.lastExecutionErrorNodeId === nodeData.id
? 'Execution error'
: null
"

View File

@@ -3,6 +3,9 @@ import PrimeVue from 'primevue/config'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { defineComponent, nextTick, ref } from 'vue'
import { toNodeId } from '@/types/nodeId'
import type { NodeId } from '@/types/nodeId'
const execHolder = vi.hoisted(() => ({
state: null as {
executingNodeIds: Array<string | number>
@@ -35,7 +38,7 @@ const SkeletonStub = defineComponent({
function renderPreview(
text: string,
{ nodeId = 'node-1' }: { nodeId?: string | number } = {}
{ nodeId = toNodeId('node-1') }: { nodeId?: NodeId } = {}
) {
const value = ref(text)
const Harness = defineComponent({
@@ -167,21 +170,21 @@ describe('TextPreviewWidget', () => {
it('hides the Skeleton on mount when execution is already idle', () => {
execState().executingNodeIds = []
execState().isIdle = true
renderPreview('text', { nodeId: 'n1' })
renderPreview('text', { nodeId: toNodeId('n1') })
expect(screen.queryByTestId('skeleton')).toBeNull()
})
it('shows a Skeleton on mount when the parent node is executing', () => {
execState().executingNodeIds = ['n1']
execState().isIdle = false
renderPreview('text', { nodeId: 'n1' })
renderPreview('text', { nodeId: toNodeId('n1') })
expect(screen.getByTestId('skeleton')).toBeInTheDocument()
})
it('hides the Skeleton when execution transitions to idle', async () => {
execState().executingNodeIds = ['n1']
execState().isIdle = false
renderPreview('text', { nodeId: 'n1' })
renderPreview('text', { nodeId: toNodeId('n1') })
expect(screen.getByTestId('skeleton')).toBeInTheDocument()
execState().executingNodeIds = []
@@ -194,7 +197,7 @@ describe('TextPreviewWidget', () => {
it('hides the Skeleton when the parent node leaves executingNodeIds', async () => {
execState().executingNodeIds = ['n1']
execState().isIdle = false
renderPreview('text', { nodeId: 'n1' })
renderPreview('text', { nodeId: toNodeId('n1') })
execState().executingNodeIds = ['other']
await nextTick()

View File

@@ -14,10 +14,10 @@
<script setup lang="ts">
import { default as DOMPurify } from 'dompurify'
import Skeleton from 'primevue/skeleton'
import { computed, onMounted, watch } from 'vue'
import { computed } from 'vue'
import type { NodeId } from '@/lib/litegraph/src/litegraph'
import { useExecutionStore } from '@/stores/executionStore'
import type { NodeId } from '@/types/nodeId'
import { linkifyHtml, nl2br } from '@/utils/formatUtil'
const modelValue = defineModel<string>({ required: true })
@@ -28,8 +28,7 @@ const props = defineProps<{
const executionStore = useExecutionStore()
const isParentNodeExecuting = computed(() => {
if (executionStore.isIdle) return false
if (!parentNodeId) return executionStore.executingNodeIds.length > 0
return executionStore.executingNodeIds.includes(parentNodeId)
return executionStore.executingNodeIds.includes(props.nodeId)
})
const formattedText = computed(() => {
const src = modelValue.value
@@ -64,19 +63,4 @@ const formattedText = computed(() => {
ALLOWED_ATTR: ['href', 'target', 'rel']
})
})
let parentNodeId: NodeId | null = null
onMounted(() => {
// Get the parent node ID from props if provided
// For backward compatibility, fall back to the first executing node
parentNodeId = props.nodeId ?? parentNodeId
})
// Lazily adopt the first executing node as the parent when no nodeId is known.
watch(
() => executionStore.executingNodeIds,
(ids) => {
if (!parentNodeId && ids.length > 0) parentNodeId = ids[0]
}
)
</script>

View File

@@ -5,6 +5,7 @@ import { defineComponent, ref } from 'vue'
import { createI18n } from 'vue-i18n'
import type { Bounds } from '@/renderer/core/layout/types'
import { toNodeId } from '@/types/nodeId'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
import type { Ref } from 'vue'
@@ -132,11 +133,12 @@ function renderWidget(
initialModel: Bounds = { x: 0, y: 0, width: 512, height: 512 }
) {
const value = ref<Bounds>(initialModel)
const nodeId = toNodeId(1)
const Harness = defineComponent({
components: { WidgetImageCrop },
setup: () => ({ value, widget }),
setup: () => ({ value, widget, nodeId }),
template:
'<WidgetImageCrop v-model="value" :widget="widget" :node-id="1" />'
'<WidgetImageCrop v-model="value" :widget="widget" :node-id="nodeId" />'
})
const utils = render(Harness, {
global: {
@@ -233,7 +235,7 @@ describe('WidgetImageCrop', () => {
renderWidget(
makeWidget({
options: { disabled: true },
linkedUpstream: { nodeId: 'n1' }
linkedUpstream: { nodeId: toNodeId('n1') }
}),
{ x: 0, y: 0, width: 512, height: 512 }
)

View File

@@ -135,8 +135,8 @@ import {
boundsExtractor,
useUpstreamValue
} from '@/composables/useUpstreamValue'
import type { NodeId } from '@/platform/workflow/validation/schemas/workflowSchema'
import type { Bounds } from '@/renderer/core/layout/types'
import type { NodeId } from '@/types/nodeId'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
import { cn } from '@comfyorg/tailwind-utils'

View File

@@ -6,6 +6,8 @@ import { createI18n } from 'vue-i18n'
import Load3D from '@/components/load3d/Load3D.vue'
import type { ComponentWidget } from '@/scripts/domWidget'
import { toNodeId } from '@/types/nodeId'
import type { NodeId } from '@/types/nodeId'
const { load3dState, resolveNodeMock, settingGetMock } = vi.hoisted(() => ({
load3dState: {
@@ -83,7 +85,7 @@ const i18n = createI18n({
type RenderOptions = {
widget?: unknown
nodeId?: number | string
nodeId?: NodeId
stateOverrides?: Partial<ReturnType<typeof buildLoad3dStub>>
enable3DViewer?: boolean
}
@@ -165,16 +167,17 @@ describe('Load3D', () => {
})
it('falls back to resolveNode(nodeId) when the widget lacks a node', async () => {
const nodeId = toNodeId(42)
resolveNodeMock.mockReturnValue(MOCK_NODE)
renderLoad3D({ widget: {}, nodeId: 42 })
renderLoad3D({ widget: {}, nodeId })
expect(resolveNodeMock).toHaveBeenCalledWith(42)
expect(resolveNodeMock).toHaveBeenCalledWith(nodeId)
expect(await screen.findByTestId('load3d-scene')).toBeInTheDocument()
})
it('does not render Load3DScene when no node can be resolved', async () => {
resolveNodeMock.mockReturnValue(null)
renderLoad3D({ widget: {}, nodeId: 99 })
renderLoad3D({ widget: {}, nodeId: toNodeId(99) })
await Promise.resolve()
expect(screen.queryByTestId('load3d-scene')).not.toBeInTheDocument()
@@ -219,7 +222,11 @@ describe('Load3D', () => {
it('hides ViewerControls when there is no node even if the setting is on', () => {
resolveNodeMock.mockReturnValue(null)
renderLoad3D({ widget: {}, nodeId: 1, enable3DViewer: true })
renderLoad3D({
widget: {},
nodeId: toNodeId(1),
enable3DViewer: true
})
expect(screen.queryByTestId('viewer-controls')).not.toBeInTheDocument()
})
})

View File

@@ -115,10 +115,10 @@ import Button from '@/components/ui/button/Button.vue'
import { useLoad3d } from '@/composables/useLoad3d'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import { useSettingStore } from '@/platform/settings/settingStore'
import type { NodeId } from '@/platform/workflow/validation/schemas/workflowSchema'
import { resolveNode } from '@/utils/litegraphUtil'
import type { ComponentWidget } from '@/scripts/domWidget'
import type { NodeId } from '@/types/nodeId'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
import { resolveNode } from '@/utils/litegraphUtil'
const {
widget,

View File

@@ -2,6 +2,8 @@ import { render } from '@testing-library/vue'
import { describe, expect, it, vi } from 'vitest'
import { defineComponent, h, ref } from 'vue'
import { toNodeId } from '@/types/nodeId'
const lastProps = ref<Record<string, unknown> | null>(null)
vi.mock('@/components/load3d/Load3D.vue', () => ({
@@ -39,9 +41,10 @@ describe('Load3DAdvanced', () => {
})
it('forwards widget and nodeId to the inner Load3D', () => {
const nodeId = toNodeId('a')
const widget = { node: { id: 'a', type: 'Load3DAdvanced' } }
render(Load3DAdvanced, { props: { widget: widget as never, nodeId: 'a' } })
render(Load3DAdvanced, { props: { widget: widget as never, nodeId } })
expect(lastProps.value?.widget).toEqual(widget)
expect(lastProps.value?.nodeId).toBe('a')
expect(lastProps.value?.nodeId).toBe(nodeId)
})
})

View File

@@ -10,8 +10,8 @@
<script setup lang="ts">
import Load3D from '@/components/load3d/Load3D.vue'
import type { NodeId } from '@/platform/workflow/validation/schemas/workflowSchema'
import type { ComponentWidget } from '@/scripts/domWidget'
import type { NodeId } from '@/types/nodeId'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
defineProps<{

View File

@@ -289,11 +289,12 @@ import { computed, useTemplateRef } from 'vue'
import Button from '@/components/ui/button/Button.vue'
import Slider from '@/components/ui/slider/Slider.vue'
import { PAINTER_TOOLS, usePainter } from '@/composables/painter/usePainter'
import type { NodeId } from '@/types/nodeId'
import { toHexFromFormat } from '@/utils/colorUtil'
import { cn } from '@comfyorg/tailwind-utils'
const { nodeId } = defineProps<{
nodeId: string
nodeId: NodeId
}>()
const modelValue = defineModel<string>({ default: '' })

View File

@@ -1,5 +1,6 @@
import { render, screen } from '@testing-library/vue'
import { createNodeLocatorId } from '@/types/nodeIdentification'
import { toNodeId } from '@/types/nodeId'
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { defineComponent, ref } from 'vue'
@@ -135,7 +136,7 @@ describe('WidgetRange', () => {
setUpstream({ min: 0.3, max: 0.7 })
renderWidget(
makeWidget({ disabled: true } as IWidgetRangeOptions, {
linkedUpstream: { nodeId: 'n1' }
linkedUpstream: { nodeId: toNodeId('n1') }
}),
{ min: 0, max: 1 }
)
@@ -145,10 +146,13 @@ describe('WidgetRange', () => {
it('ignores upstream value when not disabled', () => {
setUpstream({ min: 0.3, max: 0.7 })
renderWidget(makeWidget({}, { linkedUpstream: { nodeId: 'n1' } }), {
min: 0,
max: 1
})
renderWidget(
makeWidget({}, { linkedUpstream: { nodeId: toNodeId('n1') } }),
{
min: 0,
max: 1
}
)
const el = screen.getByTestId('range-editor')
expect(JSON.parse(el.dataset.model!)).toEqual({ min: 0, max: 1 })
})
@@ -167,7 +171,10 @@ describe('WidgetRange', () => {
loc1: { histogram_range_w: [1, 2, 3, 4] }
}
renderWidget(
makeWidget({}, { nodeLocatorId: createNodeLocatorId(null, 'loc1') })
makeWidget(
{},
{ nodeLocatorId: createNodeLocatorId(null, toNodeId('loc1')) }
)
)
expect(screen.getByTestId('range-editor').dataset.hasHistogram).toBe(
'true'
@@ -179,7 +186,10 @@ describe('WidgetRange', () => {
loc1: { histogram_range_w: [] }
}
renderWidget(
makeWidget({}, { nodeLocatorId: createNodeLocatorId(null, 'loc1') })
makeWidget(
{},
{ nodeLocatorId: createNodeLocatorId(null, toNodeId('loc1')) }
)
)
expect(screen.getByTestId('range-editor').dataset.hasHistogram).toBe(
'false'

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