Compare commits

...

17 Commits

Author SHA1 Message Date
Comfy Org PR Bot
e1049a99a3 1.46.1 (#12445)
Patch version increment to 1.46.1

**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-05-25 01:33:11 +00:00
Jukka Seppänen
3da6e1766e feat: optional retain camera view on Load3D model reload (#12440)
When comparing outputs from 3D generations, it's very hard to see small
differences since the camera always resets. This adds an option to lock
the camera, so only the model refreshes.

## Summary

Adds an opt-in per-node toggle that preserves the current camera view
(position, target, zoom, camera type) across model loads in Load3D /
Load3DAnimation nodes, instead of resetting to default framing.

## Changes

- **What**: New `retainViewOnReload?: boolean` field on `CameraConfig`,
a `Load3d.setRetainViewOnReload()` setter wired through the existing
`useLoad3d` camera-config watcher, capture/restore logic in
`Load3d._loadModelInternal`, and a lock-icon toggle button in
`CameraControls.vue` below the FOV slider. Preference persists via the
existing `node.properties['Camera Config']` mechanism.

## Review Focus

- **First-load semantics**: retain only kicks in once a model has
successfully loaded at least once (`hasLoadedModel` flag), so the
default `setupForModel` framing wins on a fresh node. `clearModel()`
resets the flag so the next load also reframes.
- **Restore order vs. `SceneModelManager.setupModel`**: the scene model
manager unconditionally calls `setupForModel` during a load, which
clobbers the camera. The restore in `_loadModelInternal` runs *after*
the load completes, on top of that framing.
- **Camera-type mismatch**: if the saved state's `cameraType` differs
from the currently active camera, `toggleCamera()` runs before
`setCameraState()` so the perspective/orthographic camera being restored
is actually the active one. Covered by a dedicated test.
- **Scope**: only wired through `useLoad3d` (LiteGraph node controls).
The full-page viewer (`useLoad3dViewer` / `ViewerCameraControls`) is
deliberately not extended — the modal is mostly a one-shot
view-and-close flow, so retain there would add surface area for an
uncommon use case.
- **Failed loads**: `hasLoadedModel` only flips inside `if
(modelManager.currentModel)`, so a load that produces no model leaves
the flag where it was. Captured camera state is still applied on top,
which effectively no-ops since nothing reset it.


## Video


https://github.com/user-attachments/assets/880d6ad1-28a9-4413-83a3-8323d05d904a
2026-05-23 08:47:30 -04:00
Comfy Org PR Bot
52830a9e73 1.46.0 (#12439)
Minor version increment to 1.46.0

**Base branch:** `main`

---------

Co-authored-by: dante01yoon <6510430+dante01yoon@users.noreply.github.com>
Co-authored-by: github-actions <github-actions@github.com>
2026-05-23 18:16:38 +09:00
Comfy Org PR Bot
1cd98e8ab2 1.45.14 (#12432)
Patch version increment to 1.45.14

**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-05-23 02:36:28 +00:00
jaeone94
654a7d6904 Add error catalog display resolver (#12402)
## Summary

This PR introduces the frontend error catalog display resolver as the
foundation for the DES-220 / FE-816 error messaging work.

The main goal is to create a single FE boundary where raw Core/Cloud
error payloads can be converted into human-friendly display fields,
while preserving the original API contract fields (`message` and
`details`) unchanged. UI components can now prefer resolved display copy
when it exists and fall back to the raw API copy otherwise.

As a small concrete sample, this PR implements the first cataloged
validation error:

- `required_input_missing` is resolved as the `missing_connection`
catalog item.
- Panel title: `Missing connection`
- Panel message: `Required input slots have no connection feeding them.`
- Detail/item copy can include the node and input name, e.g. `KSampler
is missing a required input: model` and `KSampler - model`.
- Single-error toast/overlay-oriented fields are added to the data model
for follow-up UI work, but this PR does not redesign the overlay.

## What This PR Targets

This PR is intentionally scoped as the skeleton PR for the error catalog
UX system.

It adds:

- A new resolver module under `src/platform/errorCatalog`.
- Shared resolved display fields:
  - `catalogId`
  - `displayTitle`
  - `displayMessage`
  - `displayDetails`
  - `displayItemLabel`
  - `toastTitle`
  - `toastMessage`
- A resolver entry point for run-time workflow errors:
  - node validation errors
  - execution/runtime errors
  - prompt errors
- A resolver entry point for pre-run missing-resource groups:
  - missing node packs
  - swap nodes
  - missing models
  - missing media
- Error group wiring so `useErrorGroups` resolves display copy in one
place instead of making UI components own message decisions.
- The first real validation rule for `required_input_missing` / Missing
connection.
- The existing prompt error copy moved into the
`errorCatalog.promptErrors` namespace in `src/locales/en/main.json`.
- Tests covering the resolver, grouping behavior, panel rendering,
prompt error copy, missing group copy, and fallback behavior.

## What This PR Deliberately Does Not Target

This PR avoids the larger UX and product behavior changes so the
foundation can land separately.

It does not:

- Redesign the error overlay.
- Redesign the right-side error panel.
- Change the shape of Core/Cloud API error payloads.
- Replace raw `message` / `details`; those remain intact for
API-contract alignment and technical debugging.
- Re-group execution errors by final message type yet.
- Add special runtime error messaging for credits, timeouts, content
policy, OOM, or rate limits.
- Render the new `displayItemLabel` everywhere it will eventually be
useful.

## User-Facing Behavior

Most behavior is preserved.

The main visible change is for missing required input validation errors.
Those now display as Missing connection copy instead of exposing the raw
validation message directly.

Prompt errors should keep the same user-facing wording as before, but
the source of that wording now lives under the error catalog namespace.

Missing node/model/media/swap-node groups still preserve the existing
titles, counts, and friendly messages, but their display copy now flows
through the same resolver boundary.

Execution/runtime errors receive catalog fields for future toast/overlay
usage, but the current runtime overlay path intentionally keeps the raw
technical error copy until the overlay redesign PR decides how to
consume the new fields.

## Screenshots 
Before
<img width="505" height="266" alt="스크린샷 2026-05-22 오후 2 15 27"
src="https://github.com/user-attachments/assets/09e8eb31-dca4-42d8-8237-9474cb71a14c"
/>
<img width="463" height="317" alt="스크린샷 2026-05-22 오후 2 16 09"
src="https://github.com/user-attachments/assets/c0a0159e-5bd9-4b3f-9c21-c0040373fbca"
/>

After
<img width="482" height="297" alt="스크린샷 2026-05-22 오후 2 14 25"
src="https://github.com/user-attachments/assets/4ca10bf0-29d2-4b65-940e-0d78db3fd278"
/>
<img width="460" height="194" alt="스크린샷 2026-05-22 오후 2 16 55"
src="https://github.com/user-attachments/assets/20848054-5012-4dd3-b903-ef8c920f70c8"
/>


## Follow-Up PR Plan

This PR is the first stacked PR in the error catalog work. Follow-up PRs
are expected to build on this foundation in roughly this order:

1. Expand general execution error messaging.
- Add broader validation error handling beyond `required_input_missing`,
including list/range/value validation cases.
   - Add general runtime execution messaging.
- Continue migrating prompt error display decisions into the catalog
resolver.

2. Add special runtime error messaging.
   - Credits / insufficient credits.
   - Timeout.
   - Content not allowed / blocked content.
   - Server crash.
   - Out of memory.
   - Rate limiting.
   - Other high-volume Cloud-only runtime failures from DES-220.

3. Re-group execution errors by message/catalog type.
- Move away from grouping primarily by node class when the cataloged
error type is the more useful user-facing grouping key.
   - Keep raw technical details available inside cards/logs.

4. Update the error overlay behavior.
   - Use `toastTitle` and `toastMessage` for single-error cases.
   - Use aggregate copy such as "N errors found" for multi-error cases.
   - Add node navigation affordances where appropriate.

5. Update the right-side error panel design.
   - Render resolved item labels such as `Node name - Input name`.
   - Align expanded card details and logs with the new design.
   - Preserve copy/debug affordances for technical details.

6. Fold in related missing media/model/node messaging improvements.
- FE-583 should become a child/follow-up issue in this stack for
improving missing image/media messaging.

## Validation

- `pnpm format`
- `pnpm lint`
- `pnpm typecheck`
- `pnpm test:unit`
- Targeted resolver/grouping tests during review iterations
- `pnpm knip`

`pnpm knip` passes with only the pre-existing tag hint:

`Unused tag in src/scripts/metadata/flac.ts: getFromFlacBuffer →
@knipIgnoreUnusedButUsedByCustomNodes`
2026-05-23 01:31:29 +00:00
imick-io
eeeacc9b03 feat(website): constrain sections to max-w-9xl on wide screens (#12428)
Add max-w-9xl mx-auto to section/container wrappers across the website
so layout stays centered and capped at 96rem on screens wider than
1536px.

## Summary

<!-- One sentence describing what changed and why. -->

## Changes

- **What**: <!-- Core functionality added/modified -->
- **Breaking**: <!-- Any breaking changes (if none, remove this line)
-->
- **Dependencies**: <!-- New dependencies (if none, remove this line)
-->

## Review Focus

<!-- Critical design decisions or edge cases that need attention -->

<!-- If this PR fixes an issue, uncomment and update the line below -->
<!-- Fixes #ISSUE_NUMBER -->

## Screenshots (if applicable)

<!-- Add screenshots or video recording to help explain your changes -->

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 21:36:29 +00:00
Terry Jia
f744a4f1f8 chore: gitignore .nx daemon workspace data (#12427)
## Summary
add .nx to gitignore as it causes some workspace-cache now
2026-05-22 19:45:44 +00:00
Terry Jia
0588ca45b3 test: remove stale primevue mocks from load3d tests (#12350)
## Summary
The load3d components no longer import primevue/select or
primevue/checkbox, so the vi.mock blocks targeting them had no effect.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12350-test-remove-stale-primevue-mocks-from-load3d-tests-3666d73d365081f692dbe91e16d594c3)
by [Unito](https://www.unito.io)
2026-05-22 19:32:18 +00:00
Daxiong (Lin)
60ce0ee0c3 Add png and ico favicon for comfy.org (#12426) 2026-05-22 11:55:41 -07:00
jaeone94
91d2df45a1 Fix V2 draft lifecycle persistence (#12269)
## Summary

This PR fixes the remaining FE-367 workflow persistence gap by moving
the workflow draft lifecycle callers from the legacy V1 draft store to
`workflowDraftStoreV2`, following the core design from #10367 while
omitting unrelated changes.

It keeps the change focused on saved workflow tab restore and V2 draft
lifecycle behavior:

- save active workflow drafts through V2 before loading a new graph
- load, save, save-as, close, rename, and delete workflows against V2
draft storage
- prefer a fresh V2 draft when loading a saved workflow, and discard
stale drafts when the remote workflow is newer
- restore saved open tabs from persisted tab state instead of letting
stale active-path state win
- preserve V2 draft payload timestamps when moving or refreshing draft
recency
- remove the now-unused V1 draft store/cache implementation instead of
suppressing knip; the raw V1 on-disk migration path remains for existing
users

Co-authored-by: xmarre <xmarre@users.noreply.github.com>

## Test coverage

Added unit coverage for V2 draft load, stale draft discard, rename/close
lifecycle cleanup, tab restore ordering, metadata-load waiting/fallback,
draft recency updates, quota eviction retry, and persistence-disabled
reset behavior.

Updated the workflow persistence composable tests to use a real
`vue-i18n` plugin host instead of mocking `vue-i18n`.

Added an E2E regression test that saves two workflows, edits an inactive
saved tab draft, makes the active-path pointer stale, reloads, and
verifies the saved tab order, active tab, and inactive draft
restoration.

## Validation

- `pnpm format`
- `pnpm lint`
- `pnpm typecheck`
- `pnpm test:unit`
- pre-push `pnpm knip` (passes with the existing flac tag hint)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12269-Fix-V2-draft-lifecycle-persistence-3606d73d365081b4a84feb1696ed88bb)
by [Unito](https://www.unito.io)

---------

Co-authored-by: xmarre <xmarre@users.noreply.github.com>
2026-05-22 15:24:31 +00:00
Robin Huang
7b4fef5eca fix: show empty state when node library search has no matches (#12254)
The left-sidebar node library search fell back to rendering all visible
node defs whenever the filter returned zero hits, so gibberish queries
looked like the filter wasn't applied. Gates the fallback on the query
string and renders a "No nodes match" empty state across all tabs
(Essentials/All/Blueprints) when the active query has no results.

Before:


https://github.com/user-attachments/assets/ab11ef5e-c757-41f1-9e07-3427942b9929

After: 



https://github.com/user-attachments/assets/a724aaab-95a2-4832-a694-3d8e543fdabf


┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12254-fix-show-empty-state-when-node-library-search-has-no-matches-3606d73d365081d19eaaff1095355072)
by [Unito](https://www.unito.io)
2026-05-22 10:15:26 +00:00
Alexander Brown
c703db5f6c chore: remove Nx and migrate monorepo workflows to pnpm (#12355)
## Summary
- remove Nx root/config artifacts and workspace 
x metadata
- replace Nx-based root scripts with direct pnpm workspace + native
Vite/Vitest/Playwright commands
- remove Nx dependencies/catalog entries and regenerate lockfile
- clean residual Nx references from CI workflows, tooling config, and
docs

## Validation
- pnpm install --frozen-lockfile
- pnpm lint
- pnpm typecheck
- pnpm build
- pnpm build:cloud
- pnpm build:desktop
- pnpm build:types
- pnpm test:browser -- --list
- pnpm test:unit *(4 pre-existing failing tests)*

ΓöåIssue is synchronized with this [Notion
page](https://www.notion.so/PR-12355-chore-remove-Nx-and-migrate-monorepo-workflows-to-pnpm-3666d73d3650817888b9e85e24a10b22)
by [Unito](https://www.unito.io)

---

**For keeping Nx**
- Better monorepo orchestration (task graph, affected runs, caching).
- Can reduce CI/runtime cost at larger scale.
- Unified interface across tools and packages.

**Against keeping Nx**
- Extra abstraction layer and config complexity.
- Higher maintenance/debugging cost (plugins, cache behavior, drift).
- Native `pnpm` + tool CLIs are simpler and clearer today.

**Bottom line**
- If current pain is low: prefer simplicity and remove Nx.
- If measured scale pain is high: orchestration may be worth it (Nx or
another tool later).

---------

Co-authored-by: Amp <amp@ampcode.com>
2026-05-22 05:37:10 +00:00
skishore23
3011d3a60c feat: OAuth consent UI for authorization (BE-638) (#12159)
## Summary

Frontend half of MCP OAuth (BE-638) — `oauth_request_id` plumbing, Cloud
session cookie integration, and the consent screen that cloud's
`/oauth/authorize` hands off to the browser. Scoped strictly to
OAuth/consent code; local cloud-dev support is held back as local-only
changes.

## Changes

- **What**:
- `oauth_request_id` capture + `sessionStorage` preservation across
login / signup / SSO (`onboardingCloudRoutes.ts`,
`preservedQueryNamespaces.ts`, router guards). Cleared on success or
explicit logout. Never forwarded to a third-party redirect.
- `POST /api/auth/session` integration so the Cloud session cookie is
set before the consent fetch (`useSessionCookie.ts`).
- Consent route `/cloud/oauth/consent` — fetches the JSON challenge,
renders client display name + scopes + redirect URI + Native/Web
app-type badge, submits the user's decision.
- Workspace picker: inline radio list (mirrors the cloud workspace
switcher) using `WorkspaceProfilePic` avatars. Capped at `max-h-72` with
`scrollbar-custom` so 10+ workspaces stay discoverable.
- Cloud calls go via relative URLs (same-origin through Vite proxy /
prod reverse proxy) — fixes the cross-origin cookie loss that would
bounce the consent challenge back to login.
- 400 / 403 / 404 cloud errors map to user-facing copy (expired /
scope_broadening / feature_unavailable).
- `vite.config.mts` only adds the `/oauth` proxy (5 lines) — required
for same-origin OAuth calls in dev.
- **Breaking**: None.
- **Dependencies**: None added.

## Review Focus

- **Cross-origin cookie footgun** (`oauthApi.ts:54-67`): chose
same-origin relative URLs deliberately, comment captures why.
- **Deny + workspace_id fallback** (`OAuthConsentView.vue:312-313`): if
user denies without picking a workspace, we send `workspaces[0].id`.
Cloud team should confirm deny is workspace-independent.
- **Login-flow preservation**: confirm no third-party redirect ever sees
`oauth_request_id`.
- **`useSessionCookie` ordering**: session cookie must be set before any
OAuth resume navigation fires.
- **`labelFor()` uses computed i18n keys** (`oauth.workspace.${value}`):
static key analyzer flags `personal`/`team`/`owner`/`member` as unused,
but they're read at runtime.

## Commits

| | Commit | Scope |
|---|---|---|
| 1 | `<foundation>` | OAuth plumbing — `oauth_request_id`, session
cookie, consent route, API client (10 files) |
| 2 | `a973abec0` | UI polish — inline workspace picker, error mapping,
app-type badge, redirect URI (5 files) |

## Test plan

- [x] `pnpm test:unit src/platform/cloud/oauth/
src/platform/auth/session/` — 17/17 pass
- [x] `pnpm typecheck` — clean
- [x] `pnpm lint` — clean
- [ ] Manual staging E2E — blocked on cloud-side BE-633–BE-637

## Out of scope (kept as local-only changes)

Local cloud-dev fixes (Firebase auth emulator wiring, local API base,
Mixpanel disable, registry proxy, `__DEV_SERVER_COMFYUI_URL__` build
define) are useful for running the OAuth flow against a local cloud
backend, but aren't required for staging/prod. They're held back from
this PR and can ship separately if needed.

## Supersedes

Closes #12158.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12159-feat-OAuth-consent-UI-for-MCP-authorization-BE-638-35e6d73d3650811e956ff550995f40e6)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: Alexander Brown <drjkl@comfy.org>
Co-authored-by: Amp <amp@ampcode.com>
2026-05-22 04:11:55 +00:00
Comfy Org PR Bot
6e31ce77c6 1.45.13 (#12412)
Patch version increment to 1.45.13

**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-05-22 03:57:19 +00:00
AustinMroz
551c595bbb Remove template vram sorting (#12414)
With the dynamic vram changes, vram is both much more difficult to
measure, and much less useful of a metric. To prevent confusion, it has
been removed as a metric.

See also: #9074
2026-05-22 02:56:17 +00:00
AustinMroz
ee286291d4 Fix reactivity on matchType output slots (#12397)
Relevant: #9935. A PR claimed to solve the same issue (and was approved
by me), but the issue persists. Even when checking out that exact
commit, the issue does not appear affected.

This PR is somewhat heavier. It converts the outputs into
shallowReactive. Since there is no individual moment of registration for
outputs, this conversion happens on type change and leverages that
calling `shallowReactive` on a shallow reactive is low cost and
reflexive. It also adds a test to ensure that regression can not happen
in the future.

| Before | After |
| ------ | ----- |
| <img width="360" alt="before"
src="https://github.com/user-attachments/assets/3e4f4a0a-906f-4539-95b6-b2e80de7ceff"
/> | <img width="360" alt="after"
src="https://github.com/user-attachments/assets/1a29ac66-ed5e-4874-82dc-ce9f6135dea5"
/>|
2026-05-22 02:56:02 +00:00
Daxiong (Lin)
efb214efe7 Update favicon and favicon progress with new logo (#12407)
Replace the favicon and favicon progress images with the new logo
2026-05-22 02:51:21 +00:00
158 changed files with 8245 additions and 3466 deletions

View File

@@ -45,12 +45,8 @@ jobs:
path: dist/
retention-days: 1
# Build cloud distribution for @cloud tagged tests
# NX_SKIP_NX_CACHE=true is required because `nx build` was already run
# for the OSS distribution above. Without skipping cache, Nx returns
# the cached OSS build since env vars aren't part of the cache key.
- name: Build cloud frontend
run: NX_SKIP_NX_CACHE=true pnpm build:cloud
run: pnpm build:cloud
- name: Upload cloud frontend
uses: actions/upload-artifact@v6

View File

@@ -59,7 +59,7 @@ jobs:
pnpm zipdist ./dist ./dist-desktop.zip
# Default release artifact for core/PyPI.
NX_SKIP_NX_CACHE=true pnpm build
pnpm build
pnpm zipdist
- name: Upload dist artifact
uses: actions/upload-artifact@v6

5
.gitignore vendored
View File

@@ -19,6 +19,7 @@ yarn.lock
node_modules
.pnpm-store
.nx
dist
dist-ssr
*.local
@@ -89,10 +90,6 @@ storybook-static
# MCP Servers
.playwright-mcp/*
.nx/cache
.nx/workspace-data
.cursor/rules/nx-rules.mdc
.github/instructions/nx.instructions.md
vite.config.*.timestamp*
vitest.config.*.timestamp*

View File

@@ -1 +0,0 @@
.claude/worktrees

View File

@@ -2,7 +2,6 @@
"$schema": "./node_modules/oxlint/configuration_schema.json",
"ignorePatterns": [
".i18nrc.cjs",
".nx/*",
"**/vite.config.*.timestamp*",
"**/vitest.config.*.timestamp*",
"components.d.ts",

View File

@@ -35,7 +35,7 @@ See @docs/guidance/\*.md for file-type-specific conventions (auto-loaded by glob
## Monorepo Architecture
The project uses **Nx** for build orchestration and task management
The project uses **pnpm workspaces** for monorepo organization and native tool CLIs for task execution
## Package Manager
@@ -237,7 +237,6 @@ See @docs/testing/\*.md for detailed patterns.
- ComfyUI: <https://docs.comfy.org>
- Electron: <https://www.electronjs.org/docs/latest/>
- Wiki: <https://deepwiki.com/Comfy-Org/ComfyUI_frontend/1-overview>
- Nx: <https://nx.dev/docs/reference/nx-commands>
- [Practical Test Pyramid](https://martinfowler.com/articles/practical-test-pyramid.html)
## Architecture Decision Records

View File

@@ -7,7 +7,7 @@ This guide helps you resolve common issues when developing ComfyUI Frontend.
```mermaid
flowchart TD
A[Having Issues?] --> B{What's the problem?}
B -->|Dev server stuck| C[nx serve hangs]
B -->|Dev server stuck| C[pnpm dev hangs]
B -->|Build errors| D[Check build issues]
B -->|Lint errors| Q[Check linting issues]
B -->|Dependency issues| E[Package problems]
@@ -23,7 +23,7 @@ flowchart TD
G -->|No| H[Run: pnpm i]
G -->|Still stuck| I[Run: pnpm clean]
I --> J{Still stuck?}
J -->|Yes| K[Nuclear option:<br/>pnpm dlx rimraf node_modules<br/>&& pnpm i]
J -->|Yes| K[Nuclear option:<br/>pnpm clean:all<br/>&& pnpm i]
J -->|No| L[Fixed!]
H --> L
@@ -41,11 +41,11 @@ flowchart TD
### Development Server Issues
#### Q: `pnpm dev` or `nx serve` gets stuck and won't start
#### Q: `pnpm dev` gets stuck and won't start
**Symptoms:**
- Command hangs on "nx serve"
- Command hangs during Vite startup
- Dev server doesn't respond
- Terminal appears frozen
@@ -65,7 +65,7 @@ flowchart TD
3. **Last resort - Full node_modules reset:**
```bash
pnpm dlx rimraf node_modules && pnpm i
pnpm clean:all && pnpm i
```
**Why this happens:**
@@ -73,7 +73,7 @@ flowchart TD
- Corrupted dependency cache
- Outdated lock files after branch switching
- Incomplete previous installations
- NX cache corruption
- stale local build cache
---

View File

@@ -3,8 +3,11 @@
"version": "0.0.6",
"type": "module",
"scripts": {
"lint": "nx run @comfyorg/desktop-ui:lint",
"typecheck": "nx run @comfyorg/desktop-ui:typecheck",
"dev": "pnpm -w exec vite --config apps/desktop-ui/vite.config.mts",
"build": "pnpm -w exec vite build --config apps/desktop-ui/vite.config.mts",
"preview": "pnpm -w exec vite preview --config apps/desktop-ui/vite.config.mts",
"lint": "eslint src --cache",
"typecheck": "vue-tsc --noEmit -p tsconfig.json",
"test:unit": "vitest run --config vitest.config.mts",
"storybook": "storybook dev -p 6007",
"build-storybook": "storybook build -o dist/storybook"
@@ -33,88 +36,5 @@
"vite-plugin-html": "catalog:",
"vite-plugin-vue-devtools": "catalog:",
"vue-tsc": "catalog:"
},
"nx": {
"tags": [
"scope:desktop",
"type:app"
],
"targets": {
"dev": {
"executor": "nx:run-commands",
"continuous": true,
"options": {
"cwd": "apps/desktop-ui",
"command": "vite --config vite.config.mts"
}
},
"serve": {
"executor": "nx:run-commands",
"continuous": true,
"options": {
"cwd": "apps/desktop-ui",
"command": "vite --config vite.config.mts"
}
},
"build": {
"executor": "nx:run-commands",
"cache": true,
"dependsOn": [
"^build"
],
"options": {
"command": "vite build --config apps/desktop-ui/vite.config.mts"
},
"outputs": [
"{projectRoot}/dist"
]
},
"preview": {
"executor": "nx:run-commands",
"continuous": true,
"dependsOn": [
"build"
],
"options": {
"cwd": "apps/desktop-ui",
"command": "vite preview --config vite.config.mts"
}
},
"storybook": {
"executor": "nx:run-commands",
"continuous": true,
"options": {
"cwd": "apps/desktop-ui",
"command": "storybook dev -p 6007"
}
},
"build-storybook": {
"executor": "nx:run-commands",
"cache": true,
"options": {
"cwd": "apps/desktop-ui",
"command": "storybook build -o dist/storybook"
},
"outputs": [
"{projectRoot}/dist/storybook"
]
},
"lint": {
"executor": "nx:run-commands",
"cache": true,
"options": {
"cwd": "apps/desktop-ui",
"command": "eslint src --cache"
}
},
"typecheck": {
"executor": "nx:run-commands",
"cache": true,
"options": {
"cwd": "apps/desktop-ui",
"command": "vue-tsc --noEmit -p tsconfig.json"
}
}
}
}
}

View File

@@ -45,88 +45,5 @@
"tsx": "catalog:",
"typescript": "catalog:",
"vitest": "catalog:"
},
"nx": {
"tags": [
"scope:website",
"type:app"
],
"targets": {
"dev": {
"executor": "nx:run-commands",
"continuous": true,
"options": {
"cwd": "apps/website",
"command": "astro dev"
}
},
"serve": {
"executor": "nx:run-commands",
"continuous": true,
"options": {
"cwd": "apps/website",
"command": "astro dev"
}
},
"build": {
"executor": "nx:run-commands",
"cache": true,
"dependsOn": [
"^build"
],
"options": {
"cwd": "apps/website",
"command": "astro build"
},
"outputs": [
"{projectRoot}/dist"
]
},
"preview": {
"executor": "nx:run-commands",
"continuous": true,
"dependsOn": [
"build"
],
"options": {
"cwd": "apps/website",
"command": "astro preview"
}
},
"typecheck": {
"executor": "nx:run-commands",
"cache": true,
"options": {
"cwd": "apps/website",
"command": "astro check"
}
},
"test:unit": {
"executor": "nx:run-commands",
"cache": true,
"options": {
"cwd": "apps/website",
"command": "vitest run"
}
},
"test:coverage": {
"executor": "nx:run-commands",
"cache": true,
"options": {
"cwd": "apps/website",
"command": "vitest run --coverage"
}
},
"test:e2e": {
"executor": "nx:run-commands",
"dependsOn": [
"build"
],
"options": {
"cwd": "apps/website",
"command": "playwright test"
}
}
}
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

View File

@@ -10,7 +10,7 @@ const { locale = 'en' } = defineProps<{ locale?: Locale }>()
</script>
<template>
<section class="px-6 py-24 lg:px-20 lg:py-32">
<section class="max-w-9xl mx-auto px-6 py-24 lg:px-20 lg:py-32">
<GlassCard
class="mx-auto mt-20 flex flex-col gap-12 lg:flex-row lg:items-stretch lg:gap-8"
>

View File

@@ -74,7 +74,7 @@ useHeroAnimation({
</div>
<!-- Video -->
<div ref="videoRef" class="px-4 pb-20 lg:px-20 lg:pb-40">
<div ref="videoRef" class="max-w-9xl mx-auto px-4 pb-20 lg:px-20 lg:pb-40">
<VideoPlayer
src="https://media.comfy.org/website/about/co-founders.webm"
poster="https://media.comfy.org/website/about/co-founders-poster.webp"

View File

@@ -33,7 +33,7 @@ const values: {
</script>
<template>
<section class="px-6 py-24 lg:px-20 lg:py-32">
<section class="max-w-9xl mx-auto px-6 py-24 lg:px-20 lg:py-32">
<div class="mx-auto max-w-5xl text-center">
<SectionLabel>
{{ t('about.values.label', locale) }}

View File

@@ -16,7 +16,7 @@ const investors = [
</script>
<template>
<section class="px-6 py-24 lg:px-20 lg:py-32">
<section class="max-w-9xl mx-auto px-6 py-24 lg:px-20 lg:py-32">
<div class="mx-auto text-center">
<span
class="text-primary-comfy-yellow text-xs font-semibold tracking-widest uppercase"

View File

@@ -14,7 +14,7 @@ const reasons: TranslationKey[] = [
</script>
<template>
<section class="px-6 py-24 lg:px-20 lg:py-32">
<section class="max-w-9xl mx-auto px-6 py-24 lg:px-20 lg:py-32">
<WireNodeLayout :reasons right-card-padding="p-6" :locale="locale">
<template #right-card>
<img

View File

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

View File

@@ -46,7 +46,9 @@ const cards = excludeProduct
</script>
<template>
<section class="bg-primary-comfy-ink px-0 py-20 lg:px-20 lg:py-24">
<section
class="bg-primary-comfy-ink max-w-9xl mx-auto px-0 py-20 lg:px-20 lg:py-24"
>
<!-- Header -->
<div class="flex flex-col items-center px-4 text-center">
<SectionLabel v-if="labelKey">

View File

@@ -45,11 +45,11 @@ const progressPercent = computed(() => `${progress.value * 100}%`)
</script>
<template>
<section class="px-6 py-16 lg:px-16 lg:py-24">
<section class="max-w-9xl mx-auto px-6 py-16 lg:px-16 lg:py-24">
<!-- Scrollable track -->
<div
ref="trackRef"
class="scrollbar-none flex snap-x snap-mandatory gap-12 overflow-x-auto lg:gap-20"
class="flex snap-x snap-mandatory scrollbar-none gap-12 overflow-x-auto lg:gap-20"
>
<div
v-for="(fb, i) in feedbacks"

View File

@@ -72,7 +72,7 @@ function handleLogoLoad() {
</div>
<!-- Video -->
<div ref="videoRef" class="px-4 pb-20 lg:px-20 lg:pb-40">
<div ref="videoRef" class="max-w-9xl mx-auto px-4 pb-20 lg:px-20 lg:pb-40">
<VideoPlayer
src="https://media.comfy.org/website/customers/blackmath/video.webm"
poster="https://media.comfy.org/website/customers/blackmath/poster.webp"

View File

@@ -10,7 +10,7 @@ const prefix = locale === 'zh-CN' ? '/zh-CN' : ''
<template>
<section
class="grid grid-cols-1 gap-6 px-6 py-16 lg:grid-cols-2 lg:px-16 lg:py-24"
class="max-w-9xl mx-auto grid grid-cols-1 gap-6 px-6 py-16 lg:grid-cols-2 lg:px-16 lg:py-24"
>
<a
v-for="story in customerStories"

View File

@@ -7,7 +7,7 @@ const { locale = 'en' } = defineProps<{ locale?: Locale }>()
</script>
<template>
<section class="px-6 py-16 lg:px-20 lg:py-40">
<section class="max-w-9xl mx-auto px-6 py-16 lg:px-20 lg:py-40">
<VideoPlayer
src="https://media.comfy.org/website/customers/silverside/video.webm"
poster="https://media.comfy.org/website/customers/silverside/poster.webp"

View File

@@ -7,7 +7,7 @@ const { locale = 'en' } = defineProps<{ locale?: Locale }>()
<template>
<section
class="flex flex-col items-center px-4 pt-16 pb-24 text-center lg:px-20 lg:pt-20 lg:pb-40"
class="max-w-9xl mx-auto flex flex-col items-center px-4 pt-16 pb-24 text-center lg:px-20 lg:pt-20 lg:pb-40"
>
<span
class="text-primary-comfy-yellow text-sm font-bold tracking-widest uppercase"

View File

@@ -223,7 +223,10 @@ while (idx < items.length) {
</script>
<template>
<section data-testid="gallery-grid" class="px-4 pb-20 lg:px-20">
<section
data-testid="gallery-grid"
class="max-w-9xl mx-auto px-4 pb-20 lg:px-20"
>
<!-- Desktop grid -->
<div
class="rounded-5xl bg-transparency-white-t4 hidden flex-col gap-2 p-2 lg:flex"

View File

@@ -8,7 +8,9 @@ const { locale = 'en' } = defineProps<{ locale?: Locale }>()
</script>
<template>
<section class="flex flex-col items-center px-6 pt-36 pb-16 text-center">
<section
class="max-w-9xl mx-auto flex flex-col items-center px-6 pt-36 pb-16 text-center"
>
<SectionLabel>
{{ t('gallery.label', locale) }}
</SectionLabel>

View File

@@ -15,7 +15,7 @@ const row2 = [
<template>
<section
class="bg-primary-comfy-ink flex flex-col items-center px-4 py-24 lg:px-6 lg:py-32"
class="bg-primary-comfy-ink max-w-9xl mx-auto flex flex-col items-center px-4 py-24 lg:px-6 lg:py-32"
>
<!-- Node rows -->
<div

View File

@@ -12,7 +12,9 @@ const routes = getRoutes(locale)
</script>
<template>
<section class="bg-primary-comfy-ink px-4 py-20 lg:px-20 lg:py-24">
<section
class="bg-primary-comfy-ink max-w-9xl mx-auto px-4 py-20 lg:px-20 lg:py-24"
>
<GlassCard
class="flex flex-col gap-12 lg:flex-row lg:items-stretch lg:gap-8"
>

View File

@@ -36,7 +36,9 @@ const steps = [
</script>
<template>
<section class="bg-primary-comfy-ink px-4 py-20 lg:px-20 lg:py-24">
<section
class="bg-primary-comfy-ink max-w-9xl mx-auto px-4 py-20 lg:px-20 lg:py-24"
>
<div class="flex flex-col gap-12 lg:flex-row lg:gap-8">
<!-- Left heading -->
<div

View File

@@ -15,7 +15,7 @@ const { loaded: logoLoaded } = useHeroLogo(logoContainer)
<template>
<section
class="relative flex min-h-auto flex-col lg:flex-row lg:items-center"
class="max-w-9xl relative mx-auto flex min-h-auto flex-col lg:flex-row lg:items-center"
>
<div
ref="logoContainer"

View File

@@ -55,7 +55,10 @@ watch(activeIndex, (current, previous) => {
</script>
<template>
<section ref="sectionRef" class="px-4 py-20 lg:px-20 lg:py-24">
<section
ref="sectionRef"
class="max-w-9xl mx-auto px-4 py-20 lg:px-20 lg:py-24"
>
<!-- Section header -->
<div class="flex flex-col items-center text-center">
<NodeBadge :segments="badgeSegments" segment-class="" />

View File

@@ -121,7 +121,7 @@ const activePlanIndex = ref(0)
</script>
<template>
<section class="px-4 py-16 lg:px-20 lg:py-14">
<section class="max-w-9xl mx-auto px-4 py-16 lg:px-20 lg:py-14">
<!-- Header -->
<div class="mx-auto mb-8 max-w-3xl text-center lg:mb-10">
<h1
@@ -135,7 +135,7 @@ const activePlanIndex = ref(0)
</div>
<!-- Mobile plan tabs -->
<div class="scrollbar-none mb-6 flex gap-2 overflow-x-auto lg:hidden">
<div class="mb-6 flex scrollbar-none gap-2 overflow-x-auto lg:hidden">
<button
v-for="(plan, index) in plans"
:key="plan.id"

View File

@@ -60,7 +60,7 @@ const features: IncludedFeature[] = [
</script>
<template>
<section class="px-4 py-16 lg:px-20 lg:py-24">
<section class="max-w-9xl mx-auto px-4 py-16 lg:px-20 lg:py-24">
<div class="mx-auto w-full lg:grid lg:grid-cols-[280px_1fr] lg:gap-x-16">
<!-- Heading -->
<div

View File

@@ -25,7 +25,7 @@ const cards = [
</script>
<template>
<section class="px-4 pt-24 lg:px-20 lg:pt-40">
<section class="max-w-9xl mx-auto px-4 pt-24 lg:px-20 lg:pt-40">
<h2
class="text-primary-comfy-canvas text-3.5xl/tight mx-auto max-w-3xl text-center font-light lg:text-5xl/tight"
>

View File

@@ -10,7 +10,7 @@ const { locale = 'en' } = defineProps<{ locale?: Locale }>()
<template>
<section
class="bg-transparency-white-t4 rounded-5xl mx-4 mt-4 mb-24 p-2 lg:mx-20 lg:mt-8 lg:mb-40"
class="bg-transparency-white-t4 rounded-5xl max-w-9xl mx-auto mt-4 mb-24 p-2 px-4 lg:mt-8 lg:mb-40 lg:px-20"
>
<div
class="bg-primary-comfy-yellow flex flex-col gap-24 rounded-4xl p-8 lg:flex-row lg:items-end lg:justify-between"

View File

@@ -442,7 +442,7 @@ onBeforeUnmount(() => {
</script>
<template>
<section class="px-4 py-24 lg:px-20">
<section class="max-w-9xl mx-auto px-4 py-24 lg:px-20">
<GlassCard
class="flex flex-col gap-8 lg:flex-row lg:items-stretch lg:gap-16"
>

View File

@@ -10,7 +10,7 @@ const { locale = 'en' } = defineProps<{ locale?: Locale }>()
</script>
<template>
<section class="px-4 py-24 lg:px-20 lg:py-40">
<section class="max-w-9xl mx-auto px-4 py-24 lg:px-20 lg:py-40">
<div
class="bg-transparency-white-t4 rounded-5xl flex flex-col-reverse items-stretch gap-10 p-2 lg:flex-row lg:gap-8"
>

View File

@@ -77,7 +77,9 @@ function getCardClass(layoutClass: string): string {
</script>
<template>
<section class="bg-primary-comfy-ink px-4 py-24 lg:px-20 lg:py-40">
<section
class="bg-primary-comfy-ink max-w-9xl mx-auto px-4 py-24 lg:px-20 lg:py-40"
>
<div class="mx-auto flex w-full max-w-7xl flex-col items-center">
<p
class="text-primary-comfy-yellow text-center text-sm font-bold tracking-widest uppercase"

View File

@@ -11,7 +11,7 @@ defineProps<{
</script>
<template>
<section class="px-4 py-24 lg:px-20">
<section class="max-w-9xl mx-auto px-4 py-24 lg:px-20">
<SectionHeader>
{{ heading }}
<template v-if="subtitle" #subtitle>

View File

@@ -22,7 +22,7 @@ defineProps<{
</script>
<template>
<section class="px-4 py-24 lg:px-20">
<section class="max-w-9xl mx-auto px-4 py-24 lg:px-20">
<SectionHeader>
{{ heading }}
<template #subtitle>

View File

@@ -29,7 +29,7 @@ const {
<template>
<section
class="flex flex-col gap-4 px-4 py-24 lg:flex-row lg:gap-16 lg:px-20 lg:py-40"
class="max-w-9xl mx-auto flex flex-col gap-4 px-4 py-24 lg:flex-row lg:gap-16 lg:px-20 lg:py-40"
>
<!-- Left heading -->
<div

View File

@@ -72,6 +72,9 @@ const websiteJsonLd = {
<title>{title}</title>
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
<link rel="icon" href="/favicon.png" type="image/png" />
<link rel="icon" href="/favicon.ico" sizes="any" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
<link rel="canonical" href={canonicalURL.href} />
<link rel="preconnect" href="https://www.googletagmanager.com" />
<link rel="dns-prefetch" href="https://www.googletagmanager.com" />

View File

@@ -62,12 +62,39 @@ export class WorkflowHelper {
async waitForDraftPersisted() {
await this.comfyPage.page.waitForFunction(() =>
Object.keys(localStorage).some((k) =>
k.startsWith('Comfy.Workflow.Draft.v2:')
Object.keys(localStorage).some((key) =>
key.startsWith('Comfy.Workflow.Draft.v2:')
)
)
}
/** Waits for V2 draft index recency, not payload content freshness. */
async waitForDraftIndexUpdatedSince(updatedSince: number) {
await this.comfyPage.page.waitForFunction((indexUpdatedSince) => {
for (let i = 0; i < window.localStorage.length; i++) {
const key = window.localStorage.key(i)
if (!key?.startsWith('Comfy.Workflow.DraftIndex.v2:')) continue
const json = window.localStorage.getItem(key)
if (!json) continue
try {
const index = JSON.parse(json)
if (
typeof index.updatedAt === 'number' &&
index.updatedAt >= indexUpdatedSince
) {
return true
}
} catch {
// Ignore malformed storage while waiting for persistence.
}
}
return false
}, updatedSince)
}
/**
* Reloads the current page and waits for the app to initialize.
* Unlike ComfyPage.setup(), this preserves localStorage (drafts) and

View File

@@ -19,3 +19,19 @@ test('Can display a slot mismatched from widget type', async ({
await expect(width.locator('path[fill*="INT"]')).toBeVisible()
await expect(width.locator('path[fill*="FLOAT"]')).toBeVisible()
})
test('MatchType updates output color @vue-nodes', async ({ comfyPage }) => {
await comfyPage.menu.topbar.newWorkflowButton.click()
await comfyPage.nextFrame()
await comfyPage.searchBoxV2.addNode('Load Image')
const loadImage = await comfyPage.vueNodes.getFixtureByTitle('Load Image')
await comfyPage.searchBoxV2.addNode('Switch', {
position: { x: 600, y: 200 }
})
const switchNode = await comfyPage.vueNodes.getFixtureByTitle('switch')
await loadImage.getSlot('MASK').dragTo(switchNode.getSlot('on_false'))
const slotEl = switchNode.getSlot('output').locator('.slot-dot')
await expect.poll(() => slotEl.getAttribute('style')).toContain('MASK')
})

View File

@@ -4,6 +4,103 @@ import { expect } from '@playwright/test'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
import { fitToViewInstant } from '@e2e/fixtures/utils/fitToView'
const generateUniqueFilename = () =>
`${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`
const waitForWorkflowTabState = async (comfyPage: ComfyPage, minPaths = 2) => {
await comfyPage.page.waitForFunction((expectedMinPaths) => {
let hasActivePath = false
let hasOpenPaths = false
for (let i = 0; i < window.sessionStorage.length; i++) {
const key = window.sessionStorage.key(i)
if (key?.startsWith('Comfy.Workflow.ActivePath:')) {
hasActivePath = true
}
if (!key?.startsWith('Comfy.Workflow.OpenPaths:')) {
continue
}
const raw = window.sessionStorage.getItem(key)
if (!raw) continue
try {
const state = JSON.parse(raw) as { paths?: unknown[] }
hasOpenPaths =
Array.isArray(state.paths) && state.paths.length >= expectedMinPaths
if (hasActivePath && hasOpenPaths) return true
} catch {
return false
}
}
return hasActivePath && hasOpenPaths
}, minPaths)
}
type NodeRef = NonNullable<
Awaited<ReturnType<ComfyPage['nodeOps']['getFirstNodeRef']>>
>
const getRequiredFirstNodeRef = async (
comfyPage: ComfyPage,
message: string
): Promise<NodeRef> => {
const node = await comfyPage.nodeOps.getFirstNodeRef()
expect(node, message).toBeDefined()
if (!node) throw new Error(message)
return node
}
const makeActivePathStale = async (
comfyPage: ComfyPage,
staleWorkflowName: string,
activeWorkflowName: string
) => {
// Intentionally desync ActivePath from OpenPaths to exercise stale pointer recovery.
await comfyPage.page.evaluate(
([staleName, activeName]) => {
const findStorageKey = (prefix: string) => {
for (let i = 0; i < window.sessionStorage.length; i++) {
const key = window.sessionStorage.key(i)
if (key?.startsWith(prefix)) return key
}
throw new Error(`Missing ${prefix} persistence key`)
}
const activePathKey = findStorageKey('Comfy.Workflow.ActivePath:')
const openPathsKey = findStorageKey('Comfy.Workflow.OpenPaths:')
const activePointer = JSON.parse(
window.sessionStorage.getItem(activePathKey)!
) as { path: string }
const openPointer = JSON.parse(
window.sessionStorage.getItem(openPathsKey)!
) as { paths: string[]; activeIndex: number }
const pathForName = (name: string) => {
const path = openPointer.paths.find((candidate) =>
candidate.endsWith(`${name}.json`)
)
if (!path) throw new Error(`Missing stored path for ${name}`)
return path
}
const stalePath = pathForName(staleName)
const activePath = pathForName(activeName)
activePointer.path = stalePath
openPointer.paths = [stalePath, activePath]
openPointer.activeIndex = 1
window.sessionStorage.setItem(
activePathKey,
JSON.stringify(activePointer)
)
window.sessionStorage.setItem(openPathsKey, JSON.stringify(openPointer))
},
[staleWorkflowName, activeWorkflowName]
)
}
async function getNodeOutputImageCount(
comfyPage: ComfyPage,
@@ -103,9 +200,11 @@ test.describe('Workflow Persistence', () => {
await comfyPage.menu.topbar.saveWorkflow('outputs-test')
const firstNode = await comfyPage.nodeOps.getFirstNodeRef()
expect(firstNode).toBeTruthy()
const nodeId = String(firstNode!.id)
const firstNode = await getRequiredFirstNodeRef(
comfyPage,
'First node should be available after loading the default workflow'
)
const nodeId = String(firstNode.id)
// Simulate node outputs as if execution completed
await comfyPage.page.evaluate((id) => {
@@ -382,6 +481,59 @@ test.describe('Workflow Persistence', () => {
await expect.poll(() => comfyPage.nodeOps.getNodeCount()).toBe(nodeCountB)
})
test('Restores saved workflow drafts from inactive restored tabs', async ({
comfyPage
}) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
await comfyPage.settings.setSetting('Comfy.Workflow.Persist', true)
await comfyPage.settings.setSetting(
'Comfy.Workflow.WorkflowTabsPosition',
'Topbar'
)
const workflowA = generateUniqueFilename()
const workflowB = generateUniqueFilename()
await comfyPage.workflow.loadWorkflow('nodes/single_ksampler')
await fitToViewInstant(comfyPage)
await comfyPage.menu.topbar.saveWorkflow(workflowA)
const firstNode = await getRequiredFirstNodeRef(
comfyPage,
'First node should be available after loading single_ksampler'
)
await firstNode.centerOnNode()
const draftSaveStartedAt = Date.now()
await firstNode.toggleCollapse()
expect(await firstNode.isCollapsed()).toBe(true)
await comfyPage.workflow.waitForDraftIndexUpdatedSince(draftSaveStartedAt)
await comfyPage.menu.topbar.triggerTopbarCommand(['New'])
await comfyPage.menu.topbar.saveWorkflow(workflowB)
await waitForWorkflowTabState(comfyPage)
await makeActivePathStale(comfyPage, workflowA, workflowB)
await comfyPage.workflow.reloadAndWaitForApp()
await expect
.poll(() => comfyPage.menu.topbar.getActiveTabName())
.toBe(workflowB)
const tabs = await comfyPage.menu.topbar.getTabNames()
expect(tabs).toEqual(expect.arrayContaining([workflowA, workflowB]))
expect(tabs.indexOf(workflowA)).toBeLessThan(tabs.indexOf(workflowB))
await comfyPage.menu.topbar.getWorkflowTab(workflowA).click()
await comfyPage.workflow.waitForWorkflowIdle()
await expect.poll(() => comfyPage.nodeOps.getGraphNodesCount()).toBe(1)
const restoredNode = await getRequiredFirstNodeRef(
comfyPage,
'Restored node should be available after switching back to workflow A'
)
expect(await restoredNode.isCollapsed()).toBe(true)
await expect(comfyPage.toast.toastErrors).toHaveCount(0)
})
test('Closing an inactive tab with save preserves its own content', async ({
comfyPage
}) => {

View File

@@ -4,7 +4,7 @@ Date: 2025-08-25
## Status
Proposed
Accepted (Nx tooling choice superseded by [ADR-0010](0010-remove-nx-orchestration.md))
<!-- [Proposed | Accepted | Rejected | Deprecated | Superseded by [ADR-NNNN](NNNN-title.md)] -->
@@ -31,6 +31,8 @@ For more information on Monorepos, check out [monorepo.tools](https://monorepo.t
For monorepo management, I'd probably go with [Nx](https://nx.dev/), but I could be conviced otherwise.
There's a [whole list here](https://monorepo.tools/#tools-review) if you're interested.
> **Update:** The Nx tooling choice has since been reversed. See [ADR-0010: Remove Nx Orchestration](0010-remove-nx-orchestration.md) for the migration to direct pnpm workspace scripts and native tool CLIs.
## Consequences
### Positive

View File

@@ -0,0 +1,92 @@
# 10. Remove Nx Orchestration
Date: 2026-05-19
## Status
Accepted
<!-- [Proposed | Accepted | Rejected | Deprecated | Superseded by [ADR-NNNN](NNNN-title.md)] -->
## Context
[ADR-0002](0002-monorepo-conversion.md) adopted [Nx](https://nx.dev/) as a tooling option for managing the
ComfyUI Frontend monorepo on top of pnpm workspaces. Nx was introduced as task
orchestration to coordinate builds, tests, lints, and types across the apps and
packages workspaces.
In practice, Nx provided little value beyond what pnpm workspaces and the
underlying native tool CLIs (Vite, Vitest, Playwright, ESLint, oxlint, oxfmt,
TypeScript) already offer:
- pnpm's `--filter` and `--recursive` flags already provide topological,
parallel, and selective execution across workspaces.
- Each underlying tool already has fast, well-supported caching (Vite, Vitest,
ESLint, oxlint, TS incremental builds, etc.).
- Nx added an extra configuration surface (`nx.json`, `.nxignore`, per-package
`nx` blocks), an extra cache layer, an extra `node_modules/.cache/nx`
artifact, and an extra CI dimension to debug.
- Contributors and AI agents had to learn the Nx mental model in addition to
pnpm and the individual tool CLIs.
- The Nx daemon and remote-cache features were not in use, so the runtime
benefit was limited to local task graph caching, which is largely redundant
with the per-tool caches.
The cost (configuration, mental overhead, surprise behavior, occasional
cache-related failures) exceeded the benefit.
## Decision
Remove Nx from the repository and run monorepo tasks using:
- pnpm workspace scripts (`pnpm -r run <script>`,
`pnpm --filter <pkg> run <script>`).
- Each tool's native CLI (Vite, Vitest, Playwright, ESLint, oxlint, oxfmt,
`vue-tsc`, etc.) invoked directly from the relevant workspace.
Concretely, this change:
- Deletes `nx.json` and `.nxignore`.
- Removes `nx` entries from root and per-package `package.json` files (the
`nx` block on each `package.json`, the dev dependency, and Nx-specific
scripts).
- Removes `nx`-related entries from `pnpm-workspace.yaml`'s `allowBuilds`.
- Rewrites the affected CI workflows (`.github/workflows/ci-tests-e2e.yaml`,
`.github/workflows/release-draft-create.yaml`) to call pnpm/native CLIs
directly.
- Updates `AGENTS.md`, `TROUBLESHOOTING.md`, and
[ADR-0002](0002-monorepo-conversion.md) to reflect the new tooling story.
- Cleans up Nx-specific lint/format/ignore rules in `.oxlintrc.json`,
`eslint.config.ts`, `vite.config.mts`, and `.gitignore`.
## Consequences
### Positive
- Fewer moving parts: no `nx.json`, no `.nx/` cache, no Nx daemon, no
Nx-specific scripts to maintain.
- Easier onboarding for contributors and AI agents: pnpm + each tool's CLI is
the only required knowledge.
- CI logs and failures are easier to read because tasks run directly under the
tool that owns them, instead of being wrapped by Nx.
- Faster, more predictable cache invalidation behavior — each tool owns its
own cache and we no longer hit Nx-cache edge cases.
- Smaller dependency tree (~2k fewer lines in `pnpm-lock.yaml`).
### Negative
- We lose Nx's unified task graph and project graph commands; coordination
across workspaces now relies on pnpm filters and explicit script wiring.
- We lose Nx's remote/distributed caching as a future option without
re-adopting Nx (or a comparable tool like Turborepo).
- Contributors who already knew Nx workflows need to relearn the equivalent
pnpm invocations.
## Notes
- The migration is purely a tooling change; no application behavior, public
API, or build output changes.
- If we later need more sophisticated task orchestration (e.g. distributed
remote cache, fine-grained affected-graph queries), revisit this decision and
evaluate Nx, Turborepo, or Moon at that time, with concrete CI/perf data to
justify the additional complexity.

View File

@@ -8,16 +8,18 @@ An Architecture Decision Record captures an important architectural decision mad
## ADR Index
| ADR | Title | Status | Date |
| -------------------------------------------------------- | ---------------------------------------- | -------- | ---------- |
| [0001](0001-merge-litegraph-into-frontend.md) | Merge LiteGraph.js into ComfyUI Frontend | Accepted | 2025-08-05 |
| [0002](0002-monorepo-conversion.md) | Restructure as a Monorepo | Accepted | 2025-08-25 |
| [0003](0003-crdt-based-layout-system.md) | Centralized Layout Management with CRDT | Proposed | 2025-08-27 |
| [0004](0004-fork-primevue-ui-library.md) | Fork PrimeVue UI Library | Rejected | 2025-08-27 |
| [0005](0005-remove-importmap-for-vue-extensions.md) | Remove Import Map for Vue Extensions | Accepted | 2025-12-13 |
| [0006](0006-primitive-node-copy-paste-lifecycle.md) | PrimitiveNode Copy/Paste Lifecycle | Proposed | 2026-02-22 |
| [0007](0007-node-execution-output-passthrough-schema.md) | NodeExecutionOutput Passthrough Schema | Accepted | 2026-03-11 |
| [0008](0008-entity-component-system.md) | Entity Component System | Proposed | 2026-03-23 |
| ADR | Title | Status | Date |
| ----------------------------------------------------------- | ------------------------------------------- | -------- | ---------- |
| [0001](0001-merge-litegraph-into-frontend.md) | Merge LiteGraph.js into ComfyUI Frontend | Accepted | 2025-08-05 |
| [0002](0002-monorepo-conversion.md) | Restructure as a Monorepo | Accepted | 2025-08-25 |
| [0003](0003-crdt-based-layout-system.md) | Centralized Layout Management with CRDT | Proposed | 2025-08-27 |
| [0004](0004-fork-primevue-ui-library.md) | Fork PrimeVue UI Library | Rejected | 2025-08-27 |
| [0005](0005-remove-importmap-for-vue-extensions.md) | Remove Import Map for Vue Extensions | Accepted | 2025-12-13 |
| [0006](0006-primitive-node-copy-paste-lifecycle.md) | PrimitiveNode Copy/Paste Lifecycle | Proposed | 2026-02-22 |
| [0007](0007-node-execution-output-passthrough-schema.md) | NodeExecutionOutput Passthrough Schema | Accepted | 2026-03-11 |
| [0008](0008-entity-component-system.md) | Entity Component System | Proposed | 2026-03-23 |
| [0009](0009-subgraph-promoted-widgets-use-linked-inputs.md) | Subgraph Promoted Widgets Use Linked Inputs | Proposed | 2026-05-05 |
| [0010](0010-remove-nx-orchestration.md) | Remove Nx Orchestration | Accepted | 2026-05-19 |
## Creating a New ADR

View File

@@ -76,7 +76,6 @@ export default defineConfig([
{
ignores: [
'.i18nrc.cjs',
'.nx/*',
'**/vite.config.*.timestamp*',
'**/vitest.config.*.timestamp*',
'components.d.ts',

41
nx.json
View File

@@ -1,41 +0,0 @@
{
"$schema": "./node_modules/nx/schemas/nx-schema.json",
"plugins": [
{
"plugin": "@nx/eslint/plugin",
"options": {
"targetName": "lint"
}
},
{
"plugin": "@nx/storybook/plugin",
"options": {
"serveStorybookTargetName": "storybook",
"buildStorybookTargetName": "build-storybook",
"testStorybookTargetName": "test-storybook",
"staticStorybookTargetName": "static-storybook"
}
},
{
"plugin": "@nx/vite/plugin",
"options": {
"buildTargetName": "build",
"testTargetName": "test",
"serveTargetName": "serve",
"devTargetName": "dev",
"previewTargetName": "preview",
"serveStaticTargetName": "serve-static",
"typecheckTargetName": "typecheck",
"buildDepsTargetName": "build-deps",
"watchDepsTargetName": "watch-deps"
}
},
{
"plugin": "@nx/playwright/plugin",
"options": {
"targetName": "e2e"
}
}
],
"analytics": false
}

View File

@@ -1,6 +1,6 @@
{
"name": "@comfyorg/comfyui-frontend",
"version": "1.45.12",
"version": "1.46.1",
"private": true,
"description": "Official front-end implementation of ComfyUI",
"homepage": "https://comfy.org",
@@ -8,20 +8,22 @@
"repository": "https://github.com/Comfy-Org/ComfyUI_frontend",
"type": "module",
"scripts": {
"build:cloud": "cross-env DISTRIBUTION=cloud NODE_OPTIONS='--max-old-space-size=8192' nx build",
"build:desktop": "nx build @comfyorg/desktop-ui",
"build:cloud": "cross-env DISTRIBUTION=cloud NODE_OPTIONS='--max-old-space-size=8192' vite build --config vite.config.mts",
"build:desktop": "pnpm --filter @comfyorg/desktop-ui run build",
"build-storybook": "storybook build",
"build:types": "cross-env NODE_OPTIONS='--max-old-space-size=8192' nx build --config vite.types.config.mts && node scripts/prepare-types.js",
"build:types": "cross-env NODE_OPTIONS='--max-old-space-size=8192' vite build --config vite.types.config.mts && node scripts/prepare-types.js",
"build:analyze": "cross-env ANALYZE_BUNDLE=true pnpm build",
"build": "cross-env NODE_OPTIONS='--max-old-space-size=8192' pnpm typecheck && nx build",
"build": "cross-env NODE_OPTIONS='--max-old-space-size=8192' pnpm typecheck && vite build --config vite.config.mts",
"clean": "pnpm dlx rimraf dist dist-ssr coverage playwright-report blob-report test-results node_modules/.vite apps/desktop-ui/dist apps/website/dist",
"clean:all": "pnpm clean && pnpm dlx rimraf node_modules",
"size:collect": "node scripts/size-collect.js",
"size:report": "node scripts/size-report.js",
"collect-i18n": "pnpm exec playwright test --config=playwright.i18n.config.ts",
"dev:cloud": "cross-env DEV_SERVER_COMFYUI_URL='https://testcloud.comfy.org/' nx serve",
"dev:desktop": "nx dev @comfyorg/desktop-ui",
"dev:electron": "cross-env DISTRIBUTION=desktop nx serve --config vite.electron.config.mts",
"dev:no-vue": "cross-env DISABLE_VUE_PLUGINS=true nx serve",
"dev": "nx serve",
"dev:cloud": "cross-env DEV_SERVER_COMFYUI_URL='https://testcloud.comfy.org/' vite --config vite.config.mts",
"dev:desktop": "pnpm --filter @comfyorg/desktop-ui run dev",
"dev:electron": "cross-env DISTRIBUTION=desktop vite --config vite.electron.config.mts",
"dev:no-vue": "cross-env DISABLE_VUE_PLUGINS=true vite --config vite.config.mts",
"dev": "vite --config vite.config.mts",
"devtools:pycheck": "python3 -m compileall -q tools/devtools",
"format:check": "oxfmt --check",
"format": "oxfmt --write",
@@ -34,26 +36,25 @@
"lint:unstaged:fix": "git diff --name-only HEAD | grep -E '\\.(js|ts|vue|mts)$' | xargs -r eslint --cache --fix",
"lint:unstaged": "git diff --name-only HEAD | grep -E '\\.(js|ts|vue|mts)$' | xargs -r eslint --cache",
"lint": "pnpm stylelint && oxlint src browser_tests --type-aware && eslint src --cache",
"lint:desktop": "nx run @comfyorg/desktop-ui:lint",
"lint:desktop": "pnpm --filter @comfyorg/desktop-ui run lint",
"locale": "lobe-i18n locale",
"oxlint": "oxlint src browser_tests --type-aware",
"prepare": "pnpm exec husky || true && git config blame.ignoreRevsFile .git-blame-ignore-revs || true",
"preview": "nx preview",
"storybook": "nx storybook",
"storybook:desktop": "nx run @comfyorg/desktop-ui:storybook",
"preview": "vite preview --config vite.config.mts",
"storybook": "storybook dev -p 6006",
"storybook:desktop": "pnpm --filter @comfyorg/desktop-ui run storybook",
"stylelint:fix": "stylelint --cache --fix '{apps,packages,src}/**/*.{css,vue}'",
"stylelint": "stylelint --cache '{apps,packages,src}/**/*.{css,vue}'",
"test:browser": "pnpm exec nx e2e",
"test:browser": "pnpm exec playwright test",
"test:browser:coverage": "cross-env COLLECT_COVERAGE=true pnpm test:browser",
"test:browser:local": "cross-env PLAYWRIGHT_LOCAL=1 PLAYWRIGHT_TEST_URL=http://localhost:5173 pnpm test:browser",
"test:coverage": "vitest run --coverage",
"test:unit": "nx run test",
"test:unit": "vitest run",
"typecheck": "vue-tsc --noEmit",
"typecheck:browser": "vue-tsc --project browser_tests/tsconfig.json",
"typecheck:desktop": "nx run @comfyorg/desktop-ui:typecheck",
"typecheck:website": "nx run @comfyorg/website:typecheck",
"zipdist": "node scripts/zipdist.js",
"clean": "nx reset"
"typecheck:desktop": "pnpm --filter @comfyorg/desktop-ui run typecheck",
"typecheck:website": "pnpm --filter @comfyorg/website run typecheck",
"zipdist": "node scripts/zipdist.js"
},
"dependencies": {
"@alloc/quick-lru": "catalog:",
@@ -131,10 +132,6 @@
"@eslint/js": "catalog:",
"@intlify/eslint-plugin-vue-i18n": "catalog:",
"@lobehub/i18n-cli": "catalog:",
"@nx/eslint": "catalog:",
"@nx/playwright": "catalog:",
"@nx/storybook": "catalog:",
"@nx/vite": "catalog:",
"@pinia/testing": "catalog:",
"@playwright/test": "catalog:",
"@sentry/vite-plugin": "catalog:",
@@ -180,7 +177,6 @@
"markdown-table": "catalog:",
"mixpanel-browser": "catalog:",
"monocart-coverage-reports": "catalog:",
"nx": "catalog:",
"oxfmt": "catalog:",
"oxlint": "catalog:",
"oxlint-tsgolint": "catalog:",

View File

@@ -19,11 +19,5 @@
"devDependencies": {
"tailwindcss": "catalog:",
"typescript": "catalog:"
},
"nx": {
"tags": [
"scope:shared",
"type:design"
]
}
}

View File

@@ -15,11 +15,5 @@
},
"devDependencies": {
"@hey-api/openapi-ts": "0.93.0"
},
"nx": {
"tags": [
"scope:shared",
"type:types"
]
}
}

View File

@@ -20,11 +20,5 @@
"devDependencies": {
"typescript": "catalog:",
"vitest": "catalog:"
},
"nx": {
"tags": [
"scope:shared",
"type:util"
]
}
}

View File

@@ -5,11 +5,5 @@
"type": "module",
"exports": {
".": "./src/comfyRegistryTypes.ts"
},
"nx": {
"tags": [
"scope:shared",
"type:types"
]
}
}

View File

@@ -17,11 +17,5 @@
},
"devDependencies": {
"typescript": "catalog:"
},
"nx": {
"tags": [
"scope:shared",
"type:util"
]
}
}

2246
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -21,10 +21,6 @@ catalog:
'@iconify/utils': ^3.1.0
'@intlify/eslint-plugin-vue-i18n': ^4.1.1
'@lobehub/i18n-cli': ^1.26.1
'@nx/eslint': 22.6.1
'@nx/playwright': 22.6.1
'@nx/storybook': 22.6.1
'@nx/vite': 22.6.1
'@pinia/testing': ^1.0.3
'@playwright/test': ^1.58.1
'@primeuix/forms': 0.0.2
@@ -102,7 +98,6 @@ catalog:
markdown-table: ^3.0.4
mixpanel-browser: ^2.71.0
monocart-coverage-reports: ^2.12.9
nx: 22.6.1
oxfmt: ^0.44.0
oxlint: ^1.59.0
oxlint-tsgolint: ^0.20.0
@@ -155,7 +150,6 @@ allowBuilds:
'@tailwindcss/oxide': true
core-js: false
esbuild: true
nx: true
oxc-resolver: true
protobufjs: false
sharp: false

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 9.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 647 B

After

Width:  |  Height:  |  Size: 274 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 674 B

After

Width:  |  Height:  |  Size: 277 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 674 B

After

Width:  |  Height:  |  Size: 269 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 674 B

After

Width:  |  Height:  |  Size: 284 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 698 B

After

Width:  |  Height:  |  Size: 281 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 700 B

After

Width:  |  Height:  |  Size: 277 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 702 B

After

Width:  |  Height:  |  Size: 280 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 705 B

After

Width:  |  Height:  |  Size: 302 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 708 B

After

Width:  |  Height:  |  Size: 285 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 705 B

After

Width:  |  Height:  |  Size: 296 B

View File

@@ -744,10 +744,6 @@ const sortOptions = computed(() => [
value: 'popular'
},
{ name: t('templateWorkflows.sort.newest', 'Newest'), value: 'newest' },
{
name: t('templateWorkflows.sort.vramLowToHigh', 'VRAM Usage (Low to High)'),
value: 'vram-low-to-high'
},
{
name: t(
'templateWorkflows.sort.modelSizeLowToHigh',

View File

@@ -87,42 +87,26 @@ const rightSidePanelStore = useRightSidePanelStore()
const canvasStore = useCanvasStore()
const { totalErrorCount, isErrorOverlayOpen } = storeToRefs(executionErrorStore)
const { allErrorGroups, missingModelGroups } = useErrorGroups(ref(''), t)
const { allErrorGroups } = useErrorGroups(ref(''))
const singleErrorType = computed(() => {
const types = new Set(allErrorGroups.value.map((g) => g.type))
return types.size === 1 ? [...types][0] : null
})
const friendlyMessageMap: Record<string, () => string> = {
missing_node: () => t('errorOverlay.missingNodes'),
swap_nodes: () => t('errorOverlay.swapNodes'),
missing_media: () => t('errorOverlay.missingMedia'),
missing_model: () => {
const modelCount = missingModelGroups.value.reduce(
(count, g) => count + g.models.length,
0
)
return t('errorOverlay.missingModels', { count: modelCount }, modelCount)
}
}
function toFriendlyMessage(group: (typeof allErrorGroups.value)[number]) {
return friendlyMessageMap[group.type]?.() ?? null
}
const overlayMessages = computed<string[]>(() => {
const messages = new Set<string>()
for (const group of allErrorGroups.value) {
const friendly = toFriendlyMessage(group)
if (friendly) {
messages.add(friendly)
} else if (group.type === 'execution') {
if (group.type === 'execution') {
// TODO(FE-816 overlay-redesign): Keep runtime overlay copy raw until the
// overlay redesign decides how to use catalog toast fields.
for (const card of group.cards) {
for (const err of card.errors) {
messages.add(err.message)
}
}
} else {
messages.add(group.displayMessage ?? group.displayTitle)
}
}
return Array.from(messages)

View File

@@ -71,6 +71,7 @@
v-if="showCameraControls"
v-model:camera-type="cameraConfig!.cameraType"
v-model:fov="cameraConfig!.fov"
v-model:retain-view-on-reload="cameraConfig!.retainViewOnReload"
/>
<div v-if="showLightControls" class="flex flex-col">

View File

@@ -6,24 +6,6 @@ import { createI18n } from 'vue-i18n'
import AnimationControls from '@/components/load3d/controls/AnimationControls.vue'
vi.mock('primevue/select', () => ({
default: {
name: 'Select',
props: ['modelValue', 'options', 'optionLabel', 'optionValue'],
emits: ['update:modelValue'],
template: `
<select
:value="modelValue"
@change="$emit('update:modelValue', isNaN(Number($event.target.value)) ? $event.target.value : Number($event.target.value))"
>
<option v-for="opt in options" :key="opt[optionValue]" :value="opt[optionValue]">
{{ opt[optionLabel] }}
</option>
</select>
`
}
}))
vi.mock('@/components/ui/slider/Slider.vue', () => ({
default: {
name: 'UiSlider',

View File

@@ -11,17 +11,39 @@
:aria-label="$t('load3d.switchCamera')"
@click="switchCamera"
>
<i :class="['pi', 'pi-camera', 'text-lg text-base-foreground']" />
<i :class="cn('pi pi-camera text-lg text-base-foreground')" />
</Button>
<PopupSlider
v-if="showFOVButton"
v-model="fov"
:tooltip-text="$t('load3d.fov')"
/>
<Button
v-tooltip.right="{
value: $t('load3d.retainViewOnReload'),
showDelay: 300
}"
size="icon"
variant="textonly"
class="rounded-full"
:aria-label="$t('load3d.retainViewOnReload')"
:aria-pressed="retainViewOnReload"
@click="retainViewOnReload = !retainViewOnReload"
>
<i
:class="
cn(
'pi text-lg text-base-foreground',
retainViewOnReload ? 'pi-lock' : 'pi-lock-open'
)
"
/>
</Button>
</div>
</template>
<script setup lang="ts">
import { cn } from '@comfyorg/tailwind-utils'
import { computed } from 'vue'
import PopupSlider from '@/components/load3d/controls/PopupSlider.vue'
@@ -30,6 +52,9 @@ import type { CameraType } from '@/extensions/core/load3d/interfaces'
const cameraType = defineModel<CameraType>('cameraType')
const fov = defineModel<number>('fov')
const retainViewOnReload = defineModel<boolean>('retainViewOnReload', {
default: false
})
const showFOVButton = computed(() => cameraType.value === 'perspective')
const switchCamera = () => {

View File

@@ -6,23 +6,6 @@ import { createI18n } from 'vue-i18n'
import ViewerSceneControls from '@/components/load3d/controls/viewer/ViewerSceneControls.vue'
vi.mock('primevue/checkbox', () => ({
default: {
name: 'Checkbox',
props: ['modelValue', 'inputId', 'binary', 'name'],
emits: ['update:modelValue'],
template: `
<input
type="checkbox"
:id="inputId"
:name="name"
:checked="modelValue"
@change="$emit('update:modelValue', $event.target.checked)"
/>
`
}
}))
const i18n = createI18n({
legacy: false,
locale: 'en',

View File

@@ -209,6 +209,40 @@ describe('ErrorNodeCard.vue', () => {
expect(screen.queryByText(/ComfyUI Error Report/)).not.toBeInTheDocument()
})
it('displays catalog-resolved copy when available', async () => {
renderCard({
id: `node-${++cardIdCounter}`,
title: 'KSampler',
nodeId: '10',
nodeTitle: 'KSampler',
errors: [
{
message: 'Required input is missing',
details: 'model',
displayTitle: 'Missing connection',
displayMessage:
'Required input slots have no connection feeding them.',
displayDetails: 'KSampler is missing a required input: model',
displayItemLabel: 'KSampler - model'
}
]
})
await waitFor(() => {
expect(screen.getByText('Missing connection')).toBeInTheDocument()
})
expect(
screen.getByText('Required input slots have no connection feeding them.')
).toBeInTheDocument()
expect(
screen.getByText('KSampler is missing a required input: model')
).toBeInTheDocument()
expect(screen.queryByText('KSampler - model')).not.toBeInTheDocument()
expect(
screen.queryByText('Required input is missing')
).not.toBeInTheDocument()
})
it('copies enriched report when copy button is clicked for runtime error', async () => {
const reportText = '# Full Report Content'
mockGenerateErrorReport.mockReturnValue(reportText)

View File

@@ -54,12 +54,20 @@
)
"
>
<!-- Human-friendly category/title when resolved by the error catalog. -->
<p
v-if="error.displayTitle"
class="m-0 px-0.5 text-sm font-semibold text-destructive-background-hover"
>
{{ error.displayTitle }}
</p>
<!-- Error Message -->
<p
v-if="error.message"
v-if="getDisplayMessage(error)"
class="m-0 max-h-[4lh] overflow-y-auto px-0.5 text-sm/relaxed wrap-break-word whitespace-pre-wrap"
>
{{ error.message }}
{{ getDisplayMessage(error) }}
</p>
<!-- Traceback / Details (enriched with full report for runtime errors) -->
@@ -171,11 +179,15 @@ function handleEnterSubgraph() {
function handleCopyError(idx: number) {
const details = displayedDetailsMap.value[idx]
const message = card.errors[idx]?.message
const message = getDisplayMessage(card.errors[idx])
emit('copyToClipboard', [message, details].filter(Boolean).join('\n\n'))
}
function handleCheckGithub(error: ErrorItem) {
findOnGitHub(error.message)
}
function getDisplayMessage(error: ErrorItem | undefined) {
return error?.displayMessage ?? error?.message
}
</script>

View File

@@ -57,11 +57,6 @@ describe('TabErrors.vue', () => {
downloadAll: 'Download all',
refresh: 'Refresh',
refreshing: 'Refreshing missing models.'
},
promptErrors: {
prompt_no_outputs: {
desc: 'Prompt has no outputs'
}
}
}
}
@@ -103,7 +98,7 @@ describe('TabErrors.vue', () => {
expect(screen.getByText('No errors')).toBeInTheDocument()
})
it('renders prompt-level errors (Group title = error message)', async () => {
it('renders prompt-level errors with resolved display message', async () => {
renderComponent({
executionError: {
lastPromptError: {
@@ -115,7 +110,11 @@ describe('TabErrors.vue', () => {
})
expect(screen.getByText('Server Error: No outputs')).toBeInTheDocument()
expect(screen.getByText('Prompt has no outputs')).toBeInTheDocument()
expect(
screen.getByText(
'The workflow does not contain any output nodes (e.g. Save Image, Preview Image) to produce a result.'
)
).toBeInTheDocument()
expect(screen.queryByText('Error details')).not.toBeInTheDocument()
})

View File

@@ -21,7 +21,7 @@
<div
class="shrink-0 pb-2 text-sm font-semibold text-destructive-background-hover"
>
{{ singleRuntimeErrorGroup?.title }}
{{ singleRuntimeErrorGroup?.displayTitle }}
</div>
<ErrorNodeCard
:key="singleRuntimeErrorCard.id"
@@ -53,12 +53,12 @@
<!-- Group by Class Type -->
<PropertiesAccordionItem
v-for="group in filteredGroups"
:key="group.title"
:key="group.groupKey"
:data-testid="'error-group-' + group.type.replaceAll('_', '-')"
:collapse="isSectionCollapsed(group.title) && !isSearching"
:collapse="isSectionCollapsed(group.groupKey) && !isSearching"
class="border-b border-interface-stroke"
:size="getGroupSize(group)"
@update:collapse="setSectionCollapsed(group.title, $event)"
@update:collapse="setSectionCollapsed(group.groupKey, $event)"
>
<template #label>
<div class="flex min-w-0 flex-1 items-center gap-2">
@@ -67,13 +67,7 @@
class="icon-[lucide--octagon-alert] size-4 shrink-0 text-destructive-background-hover"
/>
<span class="truncate text-destructive-background-hover">
{{
group.type === 'missing_node'
? `${group.title} (${missingPackGroups.length})`
: group.type === 'swap_nodes'
? `${group.title} (${swapNodeGroups.length})`
: group.title
}}
{{ group.displayTitle }}
</span>
<span
v-if="group.type === 'execution' && group.cards.length > 1"
@@ -331,7 +325,7 @@ const {
filteredMissingModelGroups: missingModelGroups,
filteredMissingMediaGroups: missingMediaGroups,
swapNodeGroups
} = useErrorGroups(searchQuery, t)
} = useErrorGroups(searchQuery)
const missingModelDownloadableModels = computed(() => {
if (isCloud) return []
@@ -366,22 +360,22 @@ const singleRuntimeErrorCard = computed(
const isAllCollapsed = computed({
get() {
return filteredGroups.value.every((g) => isSectionCollapsed(g.title))
return filteredGroups.value.every((g) => isSectionCollapsed(g.groupKey))
},
set(collapse: boolean) {
for (const group of tabErrorGroups.value) {
setSectionCollapsed(group.title, collapse)
setSectionCollapsed(group.groupKey, collapse)
}
}
})
function isSectionCollapsed(title: string): boolean {
function isSectionCollapsed(groupKey: string): boolean {
// Defaults to expanded when not explicitly set by the user
return collapseState[title] ?? false
return collapseState[groupKey] ?? false
}
function setSectionCollapsed(title: string, collapsed: boolean) {
collapseState[title] = collapsed
function setSectionCollapsed(groupKey: string, collapsed: boolean) {
collapseState[groupKey] = collapsed
}
/**
@@ -403,7 +397,7 @@ watch(
card.graphNodeId === graphNodeId ||
(card.nodeId?.startsWith(prefix) ?? false)
)
setSectionCollapsed(group.title, !hasMatch)
setSectionCollapsed(group.groupKey, !hasMatch)
}
rightSidePanelStore.focusedErrorNodeId = null
},

View File

@@ -27,6 +27,8 @@ vi.mock('@/platform/distribution/types', () => ({
}))
vi.mock('@/i18n', () => ({
te: vi.fn(() => false),
t: vi.fn((key: string) => key),
st: vi.fn((_key: string, fallback: string) => fallback)
}))
@@ -84,8 +86,7 @@ describe('swapNodeGroups computed', () => {
useMissingNodesErrorStore().surfaceMissingNodes(nodeTypes)
const searchQuery = ref('')
const t = (key: string) => key
const { swapNodeGroups } = useErrorGroups(searchQuery, t)
const { swapNodeGroups } = useErrorGroups(searchQuery)
return swapNodeGroups
}

View File

@@ -1,5 +1,9 @@
export interface ErrorItem {
import type { ResolvedErrorMessage } from '@/platform/errorCatalog/types'
export interface ErrorItem extends ResolvedErrorMessage {
/** Raw source/API-compatible message. */
message: string
/** Raw source/API-compatible details. */
details?: string
isRuntimeError?: boolean
exceptionType?: string
@@ -15,14 +19,28 @@ export interface ErrorCardData {
errors: ErrorItem[]
}
interface ErrorGroupBase extends Omit<ResolvedErrorMessage, 'displayTitle'> {
/** Stable structural key used for rendering, collapse state, and cache identity. */
groupKey: string
/** Human-friendly title resolved for UI display. */
displayTitle: string
priority: number
}
export type ErrorGroup =
| {
| (ErrorGroupBase & {
type: 'execution'
title: string
cards: ErrorCardData[]
priority: number
}
| { type: 'missing_node'; title: string; priority: number }
| { type: 'swap_nodes'; title: string; priority: number }
| { type: 'missing_model'; title: string; priority: number }
| { type: 'missing_media'; title: string; priority: number }
})
| (ErrorGroupBase & {
type: 'missing_node'
})
| (ErrorGroupBase & {
type: 'swap_nodes'
})
| (ErrorGroupBase & {
type: 'missing_model'
})
| (ErrorGroupBase & {
type: 'missing_media'
})

View File

@@ -30,7 +30,15 @@ vi.mock('@/platform/distribution/types', () => ({
}))
vi.mock('@/i18n', () => ({
st: vi.fn((_key: string, fallback: string) => fallback)
te: vi.fn(() => false),
st: vi.fn((_key: string, fallback: string) => fallback),
t: vi.fn((key: string, params?: { count?: number }) => {
if (key === 'errorOverlay.missingModels') {
const count = params?.count ?? 0
return `${count} required ${count === 1 ? 'model is' : 'models are'} missing`
}
return key
})
}))
vi.mock('@/stores/comfyRegistryStore', () => ({
@@ -113,8 +121,7 @@ function makeModel(
function createErrorGroups() {
const store = useExecutionErrorStore()
const searchQuery = ref('')
const t = (key: string) => key
const groups = useErrorGroups(searchQuery, t)
const groups = useErrorGroups(searchQuery)
return { store, searchQuery, groups }
}
@@ -245,6 +252,11 @@ describe('useErrorGroups', () => {
(g) => g.type === 'missing_node'
)
expect(missingGroup).toBeDefined()
expect(missingGroup?.groupKey).toBe('missing_node')
expect(missingGroup?.displayTitle).toBe('Missing Node Packs (1)')
expect(missingGroup?.displayMessage).toBe(
'Some nodes are missing and need to be installed'
)
})
it('includes swap_nodes group when replaceable nodes exist', async () => {
@@ -329,6 +341,54 @@ describe('useErrorGroups', () => {
(g) => g.type === 'execution'
)
expect(execGroups.length).toBeGreaterThan(0)
expect(execGroups[0].groupKey).toBe('execution:KSampler')
expect(execGroups[0].displayTitle).toBe('KSampler')
})
it('resolves required_input_missing item display copy', async () => {
const { store, groups } = createErrorGroups()
store.lastNodeErrors = {
'1': {
class_type: 'KSampler',
dependent_outputs: [],
errors: [
{
type: 'required_input_missing',
message: 'Required input is missing',
details: 'model',
extra_info: {
input_name: 'model'
}
}
]
}
}
await nextTick()
const execGroup = groups.allErrorGroups.value.find(
(g) => g.type === 'execution'
)
expect(execGroup?.type).toBe('execution')
if (execGroup?.type !== 'execution') return
const card = execGroup.cards[0]
const error = card.errors[0]
expect(error.message).toBe('Required input is missing')
expect(error.details).toBe('model')
expect(error.catalogId).toBe('missing_connection')
expect(error.displayTitle).toBe('Missing connection')
expect(error.displayMessage).toBe(
'Required input slots have no connection feeding them.'
)
expect(error.displayDetails).toBe(
'KSampler is missing a required input: model'
)
expect(error.displayItemLabel).toBe('KSampler - model')
expect(error.toastTitle).toBe('Required input missing')
expect(error.toastMessage).toBe(
'KSampler is missing a required input: model'
)
})
it('includes execution error from runtime errors', async () => {
@@ -351,6 +411,11 @@ describe('useErrorGroups', () => {
(g) => g.type === 'execution'
)
expect(execGroups.length).toBeGreaterThan(0)
if (execGroups[0].type !== 'execution') return
expect(execGroups[0].cards[0].errors[0].displayItemLabel).toBe('KSampler')
expect(execGroups[0].cards[0].errors[0].toastTitle).toBe(
'KSampler failed'
)
})
it('includes prompt error when present', async () => {
@@ -363,7 +428,7 @@ describe('useErrorGroups', () => {
await nextTick()
const promptGroup = groups.allErrorGroups.value.find(
(g) => g.type === 'execution' && g.title === 'No outputs'
(g) => g.type === 'execution' && g.displayTitle === 'No outputs'
)
expect(promptGroup).toBeDefined()
})
@@ -546,7 +611,7 @@ describe('useErrorGroups', () => {
expect(messages.filter((m) => m === 'Error A')).toHaveLength(1)
})
it('includes missing node group title as message', async () => {
it('includes missing node group display message', async () => {
const { groups } = createErrorGroups()
const missingNodesStore = useMissingNodesErrorStore()
missingNodesStore.setMissingNodeTypes([
@@ -558,7 +623,9 @@ describe('useErrorGroups', () => {
(g) => g.type === 'missing_node'
)
expect(missingGroup).toBeDefined()
expect(groups.groupedErrorMessages.value).toContain(missingGroup!.title)
expect(groups.groupedErrorMessages.value).toContain(
missingGroup!.displayMessage
)
})
})
@@ -703,6 +770,8 @@ describe('useErrorGroups', () => {
(g) => g.type === 'missing_model'
)
expect(modelGroup).toBeDefined()
expect(modelGroup?.groupKey).toBe('missing_model')
expect(modelGroup?.displayTitle).toBe('Missing Models (1)')
})
})

View File

@@ -33,6 +33,10 @@ import type {
import type { MissingMediaGroup } from '@/platform/missingMedia/types'
import { groupCandidatesByName } from '@/platform/missingModel/missingModelScan'
import { groupCandidatesByMediaType } from '@/platform/missingMedia/missingMediaScan'
import {
resolveMissingErrorMessage,
resolveRunErrorMessage
} from '@/platform/errorCatalog/errorMessageResolver'
import {
isNodeExecutionId,
compareExecutionId
@@ -40,11 +44,6 @@ import {
const PROMPT_CARD_ID = '__prompt__'
const SINGLE_GROUP_KEY = '__single__'
const KNOWN_PROMPT_ERROR_TYPES = new Set([
'prompt_no_outputs',
'no_prompt',
'server_error'
])
/** Sentinel: distinguishes "fetch in-flight" from "fetch done, pack not found (null)". */
const RESOLVING = '__RESOLVING__'
@@ -66,6 +65,7 @@ export interface SwapNodeGroup {
interface GroupEntry {
type: 'execution'
displayTitle: string
priority: number
cards: Map<string, ErrorCardData>
}
@@ -104,13 +104,19 @@ function resolveNodeInfo(nodeId: string) {
function getOrCreateGroup(
groupsMap: Map<string, GroupEntry>,
title: string,
groupKey: string,
displayTitle = groupKey,
priority = 1
): Map<string, ErrorCardData> {
let entry = groupsMap.get(title)
let entry = groupsMap.get(groupKey)
if (!entry) {
entry = { type: 'execution', priority, cards: new Map() }
groupsMap.set(title, entry)
entry = {
type: 'execution',
displayTitle,
priority,
cards: new Map()
}
groupsMap.set(groupKey, entry)
}
return entry.cards
}
@@ -160,7 +166,10 @@ function addCardErrorToGroup(
card: ErrorCardData,
error: ErrorItem
) {
const group = getOrCreateGroup(messageMap, error.message, 1)
const displayTitle =
error.displayTitle ?? error.displayMessage ?? error.message
const groupKey = error.catalogId ?? displayTitle
const group = getOrCreateGroup(messageMap, groupKey, displayTitle, 1)
if (!group.has(card.id)) {
group.set(card.id, { ...card, errors: [] })
}
@@ -173,15 +182,16 @@ function compareNodeId(a: ErrorCardData, b: ErrorCardData): number {
function toSortedGroups(groupsMap: Map<string, GroupEntry>): ErrorGroup[] {
return Array.from(groupsMap.entries())
.map(([title, groupData]) => ({
.map(([rawGroupKey, groupData]) => ({
type: 'execution' as const,
title,
groupKey: `execution:${rawGroupKey}`,
displayTitle: groupData.displayTitle,
cards: Array.from(groupData.cards.values()).sort(compareNodeId),
priority: groupData.priority
}))
.sort((a, b) => {
if (a.priority !== b.priority) return a.priority - b.priority
return a.title.localeCompare(b.title)
return a.displayTitle.localeCompare(b.displayTitle)
})
}
@@ -199,8 +209,16 @@ function searchErrorGroups(groups: ErrorGroup[], query: string) {
cardIndex: ci,
searchableNodeId: card.nodeId ?? '',
searchableNodeTitle: card.nodeTitle ?? '',
searchableMessage: card.errors.map((e) => e.message).join(' '),
searchableDetails: card.errors.map((e) => e.details ?? '').join(' ')
searchableMessage: card.errors
.map((e) =>
[e.displayTitle, e.displayMessage, e.message]
.filter(Boolean)
.join(' ')
)
.join(' '),
searchableDetails: card.errors
.map((e) => [e.displayDetails, e.details].filter(Boolean).join(' '))
.join(' ')
})
}
}
@@ -235,10 +253,7 @@ function searchErrorGroups(groups: ErrorGroup[], query: string) {
.filter((group) => group.type !== 'execution' || group.cards.length > 0)
}
export function useErrorGroups(
searchQuery: MaybeRefOrGetter<string>,
t: (key: string) => string
) {
export function useErrorGroups(searchQuery: MaybeRefOrGetter<string>) {
const executionErrorStore = useExecutionErrorStore()
const missingNodesStore = useMissingNodesErrorStore()
const missingModelStore = useMissingModelStore()
@@ -323,11 +338,13 @@ export function useErrorGroups(
) {
if (filterBySelection && !isErrorInSelection(nodeId)) return
const groupKey = isSingleNodeSelected.value ? SINGLE_GROUP_KEY : classType
const cards = getOrCreateGroup(groupsMap, groupKey, 1)
const cards = getOrCreateGroup(groupsMap, groupKey, classType, 1)
if (!cards.has(nodeId)) {
cards.set(nodeId, createErrorCard(nodeId, classType, idPrefix))
}
cards.get(nodeId)?.errors.push(...errors)
const card = cards.get(nodeId)
if (!card) return
card.errors.push(...errors)
}
function processPromptError(groupsMap: Map<string, GroupEntry>) {
@@ -335,24 +352,27 @@ export function useErrorGroups(
return
const error = executionErrorStore.lastPromptError
const groupTitle = error.message
const cards = getOrCreateGroup(groupsMap, groupTitle, 0)
const isKnown = KNOWN_PROMPT_ERROR_TYPES.has(error.type)
// For server_error, resolve the i18n key based on the environment
let errorTypeKey = error.type
if (error.type === 'server_error') {
errorTypeKey = isCloud ? 'server_error_cloud' : 'server_error_local'
}
const i18nKey = `rightSidePanel.promptErrors.${errorTypeKey}.desc`
const resolvedDisplay = resolveRunErrorMessage({
kind: 'prompt',
error,
isCloud
})
const groupDisplayTitle = resolvedDisplay.displayTitle ?? error.message
const cards = getOrCreateGroup(
groupsMap,
`prompt:${error.type}`,
groupDisplayTitle,
0
)
// Prompt errors are not tied to a node, so they bypass addNodeErrorToGroup.
cards.set(PROMPT_CARD_ID, {
id: PROMPT_CARD_ID,
title: groupTitle,
title: groupDisplayTitle,
errors: [
{
message: isKnown ? t(i18nKey) : error.message
message: error.message,
...resolvedDisplay
}
]
})
@@ -367,15 +387,24 @@ export function useErrorGroups(
for (const [nodeId, nodeError] of Object.entries(
executionErrorStore.lastNodeErrors
)) {
const nodeDisplayName =
resolveNodeInfo(nodeId).title || nodeError.class_type
addNodeErrorToGroup(
groupsMap,
nodeId,
nodeError.class_type,
'node',
nodeError.errors.map((e) => ({
message: e.message,
details: e.details ?? undefined
})),
nodeError.errors.map((e) => {
return {
message: e.message,
details: e.details ?? undefined,
...resolveRunErrorMessage({
kind: 'node_validation',
error: e,
nodeDisplayName
})
}
}),
filterBySelection
)
}
@@ -388,6 +417,12 @@ export function useErrorGroups(
if (!executionErrorStore.lastExecutionError) return
const e = executionErrorStore.lastExecutionError
const resolvedDisplay = resolveRunErrorMessage({
kind: 'execution',
error: e,
nodeDisplayName: e.node_type,
isCloud
})
addNodeErrorToGroup(
groupsMap,
String(e.node_id),
@@ -398,7 +433,8 @@ export function useErrorGroups(
message: `${e.exception_type}: ${e.exception_message}`,
details: e.traceback.join('\n'),
isRuntimeError: true,
exceptionType: e.exception_type
exceptionType: e.exception_type,
...resolvedDisplay
}
],
filterBySelection
@@ -568,16 +604,28 @@ export function useErrorGroups(
if (swapNodeGroups.value.length > 0) {
groups.push({
type: 'swap_nodes' as const,
title: st('nodeReplacement.swapNodesTitle', 'Swap Nodes'),
priority: 0
groupKey: 'swap_nodes',
priority: 0,
...resolveMissingErrorMessage({
kind: 'swap_nodes',
nodeTypes: missingNodesStore.missingNodesError?.nodeTypes ?? [],
count: swapNodeGroups.value.length,
isCloud
})
})
}
if (missingPackGroups.value.length > 0) {
groups.push({
type: 'missing_node' as const,
title: error.message,
priority: 1
groupKey: 'missing_node',
priority: 1,
...resolveMissingErrorMessage({
kind: 'missing_node',
nodeTypes: error.nodeTypes,
count: missingPackGroups.value.length,
isCloud
})
})
}
@@ -630,11 +678,21 @@ export function useErrorGroups(
function buildMissingModelGroups(): ErrorGroup[] {
if (!missingModelGroups.value.length) return []
const count = missingModelGroups.value.reduce(
(total, group) => total + group.models.length,
0
)
return [
{
type: 'missing_model' as const,
title: `${t('rightSidePanel.missingModels.missingModelsTitle')} (${missingModelGroups.value.reduce((count, group) => count + group.models.length, 0)})`,
priority: 2
groupKey: 'missing_model',
priority: 2,
...resolveMissingErrorMessage({
kind: 'missing_model',
groups: missingModelGroups.value,
count,
isCloud
})
}
]
}
@@ -654,8 +712,15 @@ export function useErrorGroups(
return [
{
type: 'missing_media' as const,
title: `${t('rightSidePanel.missingMedia.missingMediaTitle')} (${totalItems})`,
priority: 3
groupKey: 'missing_media',
priority: 3,
...resolveMissingErrorMessage({
kind: 'missing_media',
groups: missingMediaGroups.value,
count: totalItems,
mediaTypes: missingMediaGroups.value.map((group) => group.mediaType),
isCloud
})
}
]
}
@@ -736,11 +801,21 @@ export function useErrorGroups(
function buildMissingModelGroupsFiltered(): ErrorGroup[] {
if (!filteredMissingModelGroups.value.length) return []
const count = filteredMissingModelGroups.value.reduce(
(total, group) => total + group.models.length,
0
)
return [
{
type: 'missing_model' as const,
title: `${t('rightSidePanel.missingModels.missingModelsTitle')} (${filteredMissingModelGroups.value.reduce((count, group) => count + group.models.length, 0)})`,
priority: 2
groupKey: 'missing_model',
priority: 2,
...resolveMissingErrorMessage({
kind: 'missing_model',
groups: filteredMissingModelGroups.value,
count,
isCloud
})
}
]
}
@@ -754,8 +829,17 @@ export function useErrorGroups(
return [
{
type: 'missing_media' as const,
title: `${t('rightSidePanel.missingMedia.missingMediaTitle')} (${totalItems})`,
priority: 3
groupKey: 'missing_media',
priority: 3,
...resolveMissingErrorMessage({
kind: 'missing_media',
groups: filteredMissingMediaGroups.value,
count: totalItems,
mediaTypes: filteredMissingMediaGroups.value.map(
(group) => group.mediaType
),
isCloud
})
}
]
}
@@ -813,11 +897,11 @@ export function useErrorGroups(
if (group.type === 'execution') {
for (const card of group.cards) {
for (const err of card.errors) {
messages.add(err.message)
messages.add(err.displayMessage ?? err.message)
}
}
} else {
messages.add(group.title)
messages.add(group.displayMessage ?? group.displayTitle)
}
}
return Array.from(messages)

View File

@@ -23,7 +23,7 @@ export function useErrorReport(cardSource: MaybeRefOrGetter<ErrorCardData>) {
return Object.fromEntries(
card.errors.map((error, idx) => [
idx,
enrichedDetails[idx] ?? error.details
enrichedDetails[idx] ?? error.displayDetails ?? error.details
])
)
})

View File

@@ -3,10 +3,20 @@ import { ref } from 'vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
import { render, screen } from '@testing-library/vue'
import { fireEvent, render, screen } from '@testing-library/vue'
import NodeLibrarySidebarTabV2 from './NodeLibrarySidebarTabV2.vue'
const hoisted = vi.hoisted(() => ({
mockSearchNode: vi.fn<(query: string) => unknown[]>(() => [])
}))
vi.mock('@/services/nodeSearchService', () => ({
NodeSearchService: class {
searchNode = hoisted.mockSearchNode
}
}))
vi.mock('@vueuse/core', async () => {
const actual = await vi.importActual('@vueuse/core')
return {
@@ -72,8 +82,10 @@ vi.mock('./nodeLibrary/NodeDragPreview.vue', () => ({
vi.mock('@/components/ui/search-input/SearchInput.vue', () => ({
default: {
name: 'SearchBox',
template: '<input data-testid="search-box" />',
template:
'<input data-testid="search-box" :value="modelValue" @input="$emit(\'update:modelValue\', $event.target.value)" />',
props: ['modelValue', 'placeholder'],
emits: ['update:modelValue', 'search'],
setup() {
return { focus: vi.fn() }
},
@@ -84,12 +96,22 @@ vi.mock('@/components/ui/search-input/SearchInput.vue', () => ({
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: { en: {} }
messages: {
en: {
sideToolbar: {
nodeLibraryTab: {
noMatchingNodes: 'No nodes match "{query}"'
}
}
}
}
})
describe('NodeLibrarySidebarTabV2', () => {
beforeEach(() => {
vi.clearAllMocks()
hoisted.mockSearchNode.mockReset()
hoisted.mockSearchNode.mockReturnValue([])
})
function renderComponent() {
@@ -123,4 +145,49 @@ describe('NodeLibrarySidebarTabV2', () => {
expect(screen.queryByTestId('all-panel')).not.toBeInTheDocument()
expect(screen.queryByTestId('blueprints-panel')).not.toBeInTheDocument()
})
describe('search empty state', () => {
it('does not render the empty state when search query is empty', () => {
renderComponent()
expect(screen.queryByText(/No nodes match/)).not.toBeInTheDocument()
expect(screen.getByTestId('essential-panel')).toBeInTheDocument()
})
it('renders the empty state with the query when search has no matches', async () => {
hoisted.mockSearchNode.mockReturnValue([])
renderComponent()
await fireEvent.update(screen.getByTestId('search-box'), 'gibberish')
expect(screen.getByText('No nodes match "gibberish"')).toBeInTheDocument()
expect(screen.queryByTestId('essential-panel')).not.toBeInTheDocument()
expect(screen.queryByTestId('all-panel')).not.toBeInTheDocument()
expect(screen.queryByTestId('blueprints-panel')).not.toBeInTheDocument()
})
it('hides the empty state when the search has matches', async () => {
hoisted.mockSearchNode.mockReturnValue([{ name: 'KSampler' }])
renderComponent()
await fireEvent.update(screen.getByTestId('search-box'), 'ksampler')
expect(screen.queryByText(/No nodes match/)).not.toBeInTheDocument()
expect(screen.getByTestId('essential-panel')).toBeInTheDocument()
})
it('hides the empty state once the query is cleared', async () => {
hoisted.mockSearchNode.mockReturnValue([])
renderComponent()
const input = screen.getByTestId('search-box')
await fireEvent.update(input, 'gibberish')
expect(screen.getByText('No nodes match "gibberish"')).toBeInTheDocument()
await fireEvent.update(input, '')
expect(screen.queryByText(/No nodes match/)).not.toBeInTheDocument()
expect(screen.getByTestId('essential-panel')).toBeInTheDocument()
})
})
})

View File

@@ -117,7 +117,17 @@
<template #body>
<NodeDragPreview />
<div class="flex h-full flex-col">
<div class="min-h-0 flex-1 overflow-y-auto py-2">
<div
v-if="hasNoMatches"
class="flex min-h-0 flex-1 items-center justify-center px-6 py-8 text-center text-sm text-muted-foreground"
>
{{
$t('sideToolbar.nodeLibraryTab.noMatchingNodes', {
query: searchQuery
})
}}
</div>
<div v-else class="min-h-0 flex-1 overflow-y-auto py-2">
<TabPanel
v-if="flags.nodeLibraryEssentialsEnabled"
:model-value="selectedTab"
@@ -274,9 +284,13 @@ const filteredNodeDefs = computed(() => {
})
const activeNodes = computed(() =>
filteredNodeDefs.value.length > 0
? filteredNodeDefs.value
: nodeDefStore.visibleNodeDefs
searchQuery.value.length === 0
? nodeDefStore.visibleNodeDefs
: filteredNodeDefs.value
)
const hasNoMatches = computed(
() => searchQuery.value.length > 0 && filteredNodeDefs.value.length === 0
)
const sections = computed(() => {

View File

@@ -53,7 +53,6 @@ const sortOptions: SelectOption[] = [
{ name: 'Recommended', value: 'recommended' },
{ name: 'Popular', value: 'popular' },
{ name: 'Newest', value: 'newest' },
{ name: 'VRAM Usage (Low to High)', value: 'vram-low-to-high' },
{ name: 'Model Size (Low to High)', value: 'model-size-low-to-high' },
{ name: 'Alphabetical (A-Z)', value: 'alphabetical' }
]

View File

@@ -144,6 +144,7 @@ describe('useLoad3d', () => {
setMaterialMode: vi.fn(),
toggleCamera: vi.fn(),
setFOV: vi.fn(),
setRetainViewOnReload: vi.fn(),
setLightIntensity: vi.fn(),
setCameraState: vi.fn(),
loadModel: vi.fn().mockResolvedValue(undefined),
@@ -568,17 +569,21 @@ describe('useLoad3d', () => {
vi.mocked(mockLoad3d.toggleCamera!).mockClear()
vi.mocked(mockLoad3d.setFOV!).mockClear()
vi.mocked(mockLoad3d.setRetainViewOnReload!).mockClear()
composable.cameraConfig.value.cameraType = 'orthographic'
composable.cameraConfig.value.fov = 90
composable.cameraConfig.value.retainViewOnReload = true
await nextTick()
expect(mockLoad3d.toggleCamera).toHaveBeenCalledWith('orthographic')
expect(mockLoad3d.setFOV).toHaveBeenCalledWith(90)
expect(mockLoad3d.setRetainViewOnReload).toHaveBeenCalledWith(true)
expect(mockNode.properties['Camera Config']).toEqual({
cameraType: 'orthographic',
fov: 90,
state: null
state: null,
retainViewOnReload: true
})
})

View File

@@ -483,6 +483,7 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
nodeRef.value.properties['Camera Config'] = newValue
load3d.toggleCamera(newValue.cameraType)
load3d.setFOV(newValue.fov)
load3d.setRetainViewOnReload(newValue.retainViewOnReload ?? false)
}
},
{ deep: true }

View File

@@ -78,59 +78,6 @@ describe('useTemplateFiltering', () => {
vi.unstubAllGlobals()
})
it('sorts templates by VRAM from low to high and pushes missing values last', () => {
const gb = (value: number) => value * 1024 ** 3
const templates = ref<TemplateInfo[]>([
{
name: 'missing-vram',
description: 'no vram value',
mediaType: 'image',
mediaSubtype: 'png'
},
{
name: 'highest-vram',
description: 'high usage',
mediaType: 'image',
mediaSubtype: 'png',
vram: gb(12)
},
{
name: 'mid-vram',
description: 'medium usage',
mediaType: 'image',
mediaSubtype: 'png',
vram: gb(7.5)
},
{
name: 'low-vram',
description: 'low usage',
mediaType: 'image',
mediaSubtype: 'png',
vram: gb(5)
},
{
name: 'zero-vram',
description: 'unknown usage',
mediaType: 'image',
mediaSubtype: 'png',
vram: 0
}
])
const { sortBy, filteredTemplates } = useTemplateFiltering(templates)
sortBy.value = 'vram-low-to-high'
expect(filteredTemplates.value.map((template) => template.name)).toEqual([
'low-vram',
'mid-vram',
'highest-vram',
'missing-vram',
'zero-vram'
])
})
it('filters by search text, models, tags, and license with debounce handling', async () => {
vi.useFakeTimers()

View File

@@ -220,17 +220,6 @@ export function useTemplateFiltering(
})
})
const getVramMetric = (template: TemplateInfo) => {
if (
typeof template.vram === 'number' &&
Number.isFinite(template.vram) &&
template.vram > 0
) {
return template.vram
}
return Number.POSITIVE_INFINITY
}
watch(
filteredByRunsOn,
(templates) => {
@@ -279,22 +268,6 @@ export function useTemplateFiltering(
const dateB = new Date(b.date || '1970-01-01')
return dateB.getTime() - dateA.getTime()
})
case 'vram-low-to-high':
return templates.sort((a, b) => {
const vramA = getVramMetric(a)
const vramB = getVramMetric(b)
if (vramA === vramB) {
const nameA = a.title || a.name || ''
const nameB = b.title || b.name || ''
return nameA.localeCompare(nameB)
}
if (vramA === Number.POSITIVE_INFINITY) return 1
if (vramB === Number.POSITIVE_INFINITY) return -1
return vramA - vramB
})
case 'model-size-low-to-high':
return templates.sort((a, b) => {
const sizeA =

View File

@@ -321,12 +321,9 @@ function withComfyMatchType(node: LGraphNode): asserts node is MatchTypeNode {
if (!outputType) throw new Error('invalid connection')
this.outputs.forEach((output, idx) => {
if (!(outputGroups?.[idx] == matchKey)) return
this.outputs[idx] = shallowReactive(this.outputs[idx])
changeOutputType(this, output, outputType)
})
// Force Vue reactivity update for output slot types.
// Outputs are wrapped in shallowReactive by useGraphNodeManager,
// so mutating output.type alone doesn't trigger re-render.
this.outputs = [...this.outputs]
app.canvas?.setDirty(true, true)
}
)

View File

@@ -1,3 +1,4 @@
import { clearOAuthRequestId } from '@/platform/cloud/oauth/oauthState'
import { useSessionCookie } from '@/platform/auth/session/useSessionCookie'
import { useExtensionService } from '@/services/extensionService'
@@ -19,6 +20,7 @@ useExtensionService().registerExtension({
},
onAuthUserLogout: async () => {
clearOAuthRequestId()
const { deleteSession } = useSessionCookie()
await deleteSession()
}

View File

@@ -2,7 +2,10 @@ import * as THREE from 'three'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import Load3d from '@/extensions/core/load3d/Load3d'
import type { GizmoMode } from '@/extensions/core/load3d/interfaces'
import type {
CameraState,
GizmoMode
} from '@/extensions/core/load3d/interfaces'
const {
cloneSkinnedMock,
@@ -769,6 +772,133 @@ describe('Load3d', () => {
})
})
describe('retainViewOnReload', () => {
function setupLoadInternal(initialFlag: boolean) {
const getCameraState = vi.fn<() => CameraState>(() => ({
position: new THREE.Vector3(1, 2, 3),
target: new THREE.Vector3(),
zoom: 1,
cameraType: 'perspective'
}))
const setCameraState = vi.fn()
const getCurrentCameraType = vi.fn(() => 'perspective' as const)
const loaderLoadModel = vi.fn().mockResolvedValue(undefined)
Object.assign(ctx.load3d, {
cameraManager: {
...ctx.cameraManager,
getCameraState,
setCameraState,
getCurrentCameraType
},
controlsManager: { ...ctx.controlsManager, reset: vi.fn() },
loaderManager: { loadModel: loaderLoadModel },
modelManager: {
...ctx.modelManager,
currentModel: new THREE.Group(),
originalModel: null
},
animationManager: {
...ctx.animationManager,
setupModelAnimations: vi.fn()
},
handleResize: vi.fn(),
retainViewOnReload: initialFlag,
hasLoadedModel: false
})
return { getCameraState, setCameraState, getCurrentCameraType }
}
it('first load uses default framing even with retain enabled', async () => {
const mocks = setupLoadInternal(true)
await ctx.load3d.loadModel('a.glb')
// hasLoadedModel started false, so retain shouldn't kick in yet.
expect(ctx.cameraManager.reset).toHaveBeenCalledOnce()
expect(mocks.getCameraState).not.toHaveBeenCalled()
expect(mocks.setCameraState).not.toHaveBeenCalled()
})
it('subsequent load captures camera state, skips reset, and restores it', async () => {
const mocks = setupLoadInternal(true)
await ctx.load3d.loadModel('a.glb')
;(ctx.cameraManager.reset as ReturnType<typeof vi.fn>).mockClear()
mocks.getCameraState.mockClear()
mocks.setCameraState.mockClear()
await ctx.load3d.loadModel('b.glb')
expect(ctx.cameraManager.reset).not.toHaveBeenCalled()
expect(mocks.getCameraState).toHaveBeenCalledOnce()
expect(mocks.setCameraState).toHaveBeenCalledOnce()
})
it('does not retain when the flag is off, even after a prior load', async () => {
const mocks = setupLoadInternal(false)
await ctx.load3d.loadModel('a.glb')
;(ctx.cameraManager.reset as ReturnType<typeof vi.fn>).mockClear()
mocks.getCameraState.mockClear()
mocks.setCameraState.mockClear()
await ctx.load3d.loadModel('b.glb')
expect(ctx.cameraManager.reset).toHaveBeenCalledOnce()
expect(mocks.getCameraState).not.toHaveBeenCalled()
expect(mocks.setCameraState).not.toHaveBeenCalled()
})
it('toggles to the saved camera type before restoring state when types differ', async () => {
const mocks = setupLoadInternal(true)
mocks.getCameraState.mockImplementation(() => ({
position: new THREE.Vector3(0, 0, 5),
target: new THREE.Vector3(),
zoom: 1,
cameraType: 'orthographic'
}))
// First load (active type stays perspective per the default mock).
await ctx.load3d.loadModel('a.glb')
;(ctx.cameraManager.toggleCamera as ReturnType<typeof vi.fn>).mockClear()
await ctx.load3d.loadModel('b.glb')
expect(ctx.cameraManager.toggleCamera).toHaveBeenCalledWith(
'orthographic'
)
expect(mocks.setCameraState).toHaveBeenCalledOnce()
})
it('resets hasLoadedModel on clearModel so the next load uses default framing', async () => {
const mocks = setupLoadInternal(true)
await ctx.load3d.loadModel('a.glb')
ctx.load3d.clearModel()
;(ctx.cameraManager.reset as ReturnType<typeof vi.fn>).mockClear()
mocks.getCameraState.mockClear()
await ctx.load3d.loadModel('b.glb')
expect(ctx.cameraManager.reset).toHaveBeenCalledOnce()
expect(mocks.getCameraState).not.toHaveBeenCalled()
})
it('setRetainViewOnReload flips the runtime behavior between loads', async () => {
const mocks = setupLoadInternal(false)
await ctx.load3d.loadModel('a.glb')
ctx.load3d.setRetainViewOnReload(true)
;(ctx.cameraManager.reset as ReturnType<typeof vi.fn>).mockClear()
mocks.getCameraState.mockClear()
mocks.setCameraState.mockClear()
await ctx.load3d.loadModel('b.glb')
expect(ctx.cameraManager.reset).not.toHaveBeenCalled()
expect(mocks.getCameraState).toHaveBeenCalledOnce()
expect(mocks.setCameraState).toHaveBeenCalledOnce()
})
})
describe('captureScene', () => {
it('hides the gizmo helper during capture and restores it after success', async () => {
const captureResult = { scene: 'a', mask: 'b', normal: 'c' }

View File

@@ -104,6 +104,8 @@ class Load3d {
private disposeContextMenuGuard: (() => void) | null = null
private resizeObserver: ResizeObserver | null = null
private getZoomScaleCallback: (() => number) | undefined
private retainViewOnReload: boolean = false
private hasLoadedModel: boolean = false
constructor(
container: Element | HTMLElement,
@@ -564,13 +566,33 @@ class Load3d {
}
}
/**
* Toggles whether `_loadModelInternal` preserves the current camera state
* across model loads. When enabled and a model has previously loaded, the
* camera position/target/zoom (and camera type) are captured before the
* scene clears and restored after the new model is in place.
*/
public setRetainViewOnReload(value: boolean): void {
this.retainViewOnReload = value
}
private async _loadModelInternal(
url: string,
originalFileName?: string,
options?: LoadModelOptions
): Promise<void> {
this.cameraManager.reset()
this.controlsManager.reset()
// Retain view only kicks in after a successful first load — on the very
// first load there's no meaningful "current" framing to preserve, so the
// default `setupForModel` framing wins.
const shouldRetainView = this.retainViewOnReload && this.hasLoadedModel
const savedCameraState = shouldRetainView
? this.cameraManager.getCameraState()
: null
if (!shouldRetainView) {
this.cameraManager.reset()
this.controlsManager.reset()
}
this.gizmoManager.detach()
this.modelManager.clearModel()
this.animationManager.dispose()
@@ -583,6 +605,19 @@ class Load3d {
this.modelManager.currentModel,
this.modelManager.originalModel
)
this.hasLoadedModel = true
}
if (savedCameraState) {
// SceneModelManager.setupModel called setupForModel which clobbered the
// camera. Restore the captured state on top of that.
if (
savedCameraState.cameraType !==
this.cameraManager.getCurrentCameraType()
) {
this.toggleCamera(savedCameraState.cameraType)
}
this.cameraManager.setCameraState(savedCameraState)
}
this.handleResize()
@@ -607,6 +642,7 @@ class Load3d {
this.gizmoManager.detach()
this.modelManager.clearModel()
this.adapterRef.current = null
this.hasLoadedModel = false
this.forceRender()
}

View File

@@ -50,6 +50,7 @@ export interface CameraConfig {
cameraType: CameraType
fov: number
state?: CameraState
retainViewOnReload?: boolean
}
export interface LightConfig {

View File

@@ -800,7 +800,7 @@
"CONTROL_NET": "ControlNet",
"CURVE": "منحنى",
"ELEVENLABS_VOICE": "ELEVENLABS_VOICE",
"FACE_LANDMARKER": "FACE_LANDMARKER",
"FACE_DETECTION_MODEL": "نموذج كشف الوجه",
"FACE_LANDMARKS": "معالم الوجه",
"FILE_3D": "ملف ثلاثي الأبعاد",
"FILE_3D_FBX": "ملف FBX ثلاثي الأبعاد",
@@ -911,6 +911,44 @@
"paused": "تم الإيقاف مؤقتًا",
"resume": "استئناف التنزيل"
},
"errorCatalog": {
"fallbacks": {
"inputName": "مدخل غير معروف",
"nodeName": "هذه العقدة"
},
"promptErrors": {
"no_prompt": {
"desc": "بيانات سير العمل المرسلة إلى الخادم فارغة. قد يكون هذا خطأ غير متوقع في النظام."
},
"prompt_no_outputs": {
"desc": "سير العمل لا يحتوي على أي عقدة إخراج (مثل حفظ الصورة أو معاينة الصورة) لإنتاج نتيجة."
},
"server_error_cloud": {
"desc": "واجه الخادم خطأ غير متوقع. يرجى المحاولة لاحقاً."
},
"server_error_local": {
"desc": "واجه الخادم خطأ غير متوقع. يرجى مراجعة سجلات الخادم."
}
},
"runtimeErrors": {
"execution_failed": {
"itemLabel": "{nodeName}",
"toastMessageCloud": "حدث خطأ أثناء تنفيذ هذه العقدة. تحقق من المدخلات أو جرّب إعداداً مختلفاً. لم يتم خصم أي رصيد.",
"toastMessageLocal": "حدث خطأ أثناء تنفيذ هذه العقدة. تحقق من المدخلات أو جرّب إعداداً مختلفاً.",
"toastTitle": "فشل {nodeName}"
}
},
"validationErrors": {
"required_input_missing": {
"details": "{nodeName} تفتقد إلى مدخل مطلوب: {inputName}",
"itemLabel": "{nodeName} - {inputName}",
"message": "بعض منافذ الإدخال المطلوبة غير متصلة.",
"title": "الاتصال مفقود",
"toastMessage": "{nodeName} تفتقد إلى مدخل مطلوب: {inputName}",
"toastTitle": "مدخل مطلوب مفقود"
}
}
},
"errorDialog": {
"accessRestrictedMessage": "Your account is not authorized for this feature.",
"accessRestrictedTitle": "Access Restricted",
@@ -1713,6 +1751,7 @@
"reloadingModel": "جاري إعادة تحميل النموذج...",
"removeBackgroundImage": "إزالة صورة الخلفية",
"resizeNodeMatchOutput": "تغيير حجم العقدة لتتناسب مع المخرج",
"retainViewOnReload": "تثبيت عرض الكاميرا عند إعادة تحميل النموذج",
"scene": "المشهد",
"showGrid": "عرض الشبكة",
"showSkeleton": "إظهار الهيكل العظمي",
@@ -2280,6 +2319,7 @@
"Meshy": "Meshy",
"MiniMax": "MiniMax",
"OpenAI": "OpenAI",
"OpenRouter": "OpenRouter",
"PixVerse": "PixVerse",
"Quiver": "Quiver",
"Recraft": "Recraft",
@@ -2296,6 +2336,7 @@
"Vidu": "فيدو",
"Wan": "وان",
"WaveSpeed": "WaveSpeed",
"adjustments": "تعديلات",
"advanced": "متقدم",
"api node": "عقدة API",
"attention_experiments": "تجارب الانتباه",
@@ -2435,6 +2476,43 @@
},
"title": "جهازك غير مدعوم"
},
"oauth": {
"consent": {
"allow": "متابعة",
"appTypeNative": "تطبيق أصلي",
"appTypeWeb": "تطبيق ويب",
"deny": "إلغاء",
"errorExpired": "انتهت صلاحية طلب الموافقة هذا أو تم استخدامه بالفعل. يرجى إعادة البدء من تطبيق العميل.",
"errorScopeBroadening": "الأذونات التي تمت الموافقة عليها سابقًا لا تغطي هذا الطلب. ستحتاج إلى إعادة التفويض بالأذونات الجديدة.",
"errorUnavailable": "هذه الميزة غير متوفرة حاليًا. يرجى التواصل مع الدعم إذا استمرت المشكلة.",
"genericError": "فشل طلب OAuth. يرجى إعادة البدء من تطبيق العميل.",
"loading": "جارٍ تحميل طلب التفويض…",
"missingRequest": "هذا الطلب التفويضي غير موجود. يرجى إعادة البدء من تطبيق العميل.",
"noWorkspaces": "لا توجد مساحات عمل مؤهلة لهذا الطلب.",
"permissionsHeader": "الأذونات",
"redirectNotice": "سيتم إعادة توجيهك إلى",
"resourceFallback": "هذا التطبيق",
"sessionError": "فشل في إنشاء الجلسة. يرجى المحاولة مرة أخرى.",
"sessionErrorToastSummary": "تعذر متابعة تسجيل الدخول عبر OAuth",
"subtitle": "سجّل الدخول إلى {resource} للمتابعة",
"title": "{client} يطلب الوصول",
"workspaceHelp": "تنطبق الأذونات على مساحة العمل هذه فقط.",
"workspaceLabel": "مساحة العمل"
},
"scopes": {
"mcp:tools:call": {
"label": "تشغيل سير العمل نيابةً عنك"
},
"mcp:tools:read": {
"label": "عرض أدوات سير العمل المتاحة"
}
},
"workspace": {
"member": "عضو",
"owner": "المالك",
"personal": "شخصي"
}
},
"openSharedWorkflow": {
"author": "المؤلف:",
"copyAssetsAndOpen": "استيراد الأصول وفتح سير العمل",
@@ -2662,20 +2740,6 @@
"normal": "عادي",
"parameters": "المعلمات",
"pinned": "مثبت",
"promptErrors": {
"no_prompt": {
"desc": "بيانات سير العمل المرسلة إلى الخادم فارغة. قد يكون هذا خطأ غير متوقع في النظام."
},
"prompt_no_outputs": {
"desc": "سير العمل لا يحتوي على أي عقدة إخراج (مثل حفظ الصورة، معاينة الصورة) لإنتاج نتيجة."
},
"server_error_cloud": {
"desc": "واجه الخادم خطأ غير متوقع. يرجى المحاولة لاحقاً."
},
"server_error_local": {
"desc": "واجه الخادم خطأ غير متوقع. يرجى مراجعة سجلات الخادم."
}
},
"properties": "الخصائص",
"removeFavorite": "إزالة من المفضلة",
"resetAllParameters": "إعادة تعيين جميع المعلمات",
@@ -3073,6 +3137,7 @@
"sourceDesc": "التجميع حسب نوع المصدر (أساسي، مخصص، API)"
},
"noBookmarkedNodes": "لا توجد مفضلات بعد",
"noMatchingNodes": "لا توجد عقد تطابق \"{query}\"",
"resetView": "إعادة تعيين العرض إلى الافتراضي",
"sections": {
"bookmarked": "المفضلة",
@@ -3458,8 +3523,7 @@
"newest": "الأحدث",
"popular": "الأكثر شيوعًا",
"recommended": "موصى به",
"searchPlaceholder": "بحث...",
"vramLowToHigh": "استخدام VRAM (من الأقل إلى الأعلى)"
"searchPlaceholder": "بحث..."
},
"sorting": "ترتيب حسب",
"title": "ابدأ باستخدام قالب",
@@ -3623,7 +3687,8 @@
"placeholderMesh": "اختر شبكة...",
"placeholderModel": "اختر نموذج...",
"placeholderUnknown": "اختر وسائط...",
"placeholderVideo": "اختر فيديو..."
"placeholderVideo": "اختر فيديو...",
"topResult": "أفضل نتيجة: {result}"
},
"valueControl": {
"decrement": "إنقاص القيمة",

View File

@@ -119,6 +119,7 @@
}
},
"AdjustBrightness": {
"description": "ضبط سطوع الصورة.",
"display_name": "ضبط السطوع",
"inputs": {
"factor": {
@@ -138,6 +139,7 @@
}
},
"AdjustContrast": {
"description": "ضبط تباين الصورة.",
"display_name": "ضبط التباين",
"inputs": {
"factor": {
@@ -176,6 +178,7 @@
}
},
"AudioAdjustVolume": {
"description": "ضبط مستوى صوت الملف الصوتي بمقدار محدد بوحدة ديسيبل (dB).",
"display_name": "ضبط مستوى الصوت",
"inputs": {
"audio": {
@@ -577,6 +580,9 @@
"model_auto_downscale": {
"name": "التقليل التلقائي للحجم"
},
"model_auto_upscale": {
"name": "auto_upscale"
},
"model_duration": {
"name": "المدة"
},
@@ -1547,6 +1553,7 @@
}
},
"CenterCropImages": {
"description": "قص الصورة من المنتصف إلى الأبعاد المحددة.",
"display_name": "قص الصور من المنتصف",
"inputs": {
"height": {
@@ -1666,6 +1673,9 @@
"model_max_tokens": {
"name": "max_tokens"
},
"model_reasoning_effort": {
"name": "reasoning_effort"
},
"model_temperature": {
"name": "temperature"
},
@@ -1790,6 +1800,20 @@
}
}
},
"ComfyAndNode": {
"description": "عملية AND المنطقية. تُرجع صحيح إذا كانت جميع القيم صحيحة حسب قواعد الحقيقة في بايثون.",
"display_name": "و",
"inputs": {
"values": {
"name": "القيم"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"ComfyMathExpression": {
"display_name": "تعبير رياضي",
"inputs": {
@@ -1813,6 +1837,20 @@
}
}
},
"ComfyNotNode": {
"description": "عملية NOT المنطقية. تُرجع صحيح إذا كانت القيمة غير صحيحة حسب قواعد الحقيقة في بايثون.",
"display_name": "ليس",
"inputs": {
"value": {
"name": "القيمة"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"ComfyNumberConvert": {
"display_name": "تحويل الرقم",
"inputs": {
@@ -1829,6 +1867,20 @@
}
}
},
"ComfyOrNode": {
"description": "عملية OR المنطقية. تُرجع صحيح إذا كانت أي من القيم صحيحة حسب قواعد الحقيقة في بايثون.",
"display_name": "أو",
"inputs": {
"values": {
"name": "القيم"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"ComfySwitchNode": {
"display_name": "مفتاح التحويل",
"inputs": {
@@ -5699,6 +5751,7 @@
}
},
"ImageCropV2": {
"description": "قص الصورة إلى الأبعاد المحددة.",
"display_name": "قص الصورة",
"inputs": {
"crop_region": {
@@ -5719,6 +5772,7 @@
}
},
"ImageDeduplication": {
"description": "إزالة الصور المكررة أو المتشابهة جداً من القائمة.",
"display_name": "إزالة تكرار الصور",
"inputs": {
"images": {
@@ -5773,6 +5827,7 @@
}
},
"ImageGrid": {
"description": "ترتيب عدة صور في شبكة.",
"display_name": "شبكة الصور",
"inputs": {
"cell_height": {
@@ -8366,6 +8421,7 @@
}
},
"LoadImageDataSetFromFolder": {
"description": "تحميل مجموعة بيانات من الصور من مجلد محدد وإرجاع قائمة بالصور. الصيغ المدعومة: PNG، JPG، JPEG، WEBP.",
"display_name": "تحميل مجموعة بيانات الصور من مجلد",
"inputs": {
"folder": {
@@ -8409,6 +8465,7 @@
}
},
"LoadImageTextDataSetFromFolder": {
"description": "تحميل مجموعة بيانات من أزواج الصور والتعليقات النصية من مجلد محدد وإرجاعها كقائمة. الصيغ المدعومة: PNG، JPG، JPEG، WEBP.",
"display_name": "تحميل مجموعة بيانات الصور والنصوص من مجلد",
"inputs": {
"folder": {
@@ -8463,6 +8520,7 @@
}
},
"LoadTrainingDataset": {
"description": "تحميل مجموعة بيانات التدريب المشفرة (الفضاء الكامن + الشروط) من القرص لاستخدامها في التدريب.",
"display_name": "تحميل مجموعة بيانات التدريب",
"inputs": {
"folder_name": {
@@ -9246,6 +9304,7 @@
}
},
"MakeTrainingDataset": {
"description": "ترميز الصور باستخدام VAE والنصوص باستخدام CLIP لإنشاء مجموعة بيانات تدريبية من الفضاء الكامن والشروط.",
"display_name": "إنشاء مجموعة بيانات تدريبية",
"inputs": {
"clip": {
@@ -9337,14 +9396,15 @@
}
},
"MediaPipeFaceLandmarker": {
"description": "اكتشاف معالم الوجه باستخدام نموذج MediaPipe.",
"display_name": "MediaPipe Face Landmarker",
"inputs": {
"detector_variant": {
"name": "detector_variant",
"tooltip": "نطاق كاشف الوجه. 'short' مخصص للوجوه القريبة (ضمن ~٢ متر من الكاميرا)؛ 'full' يغطي الوجوه البعيدة/الأصغر (حتى ~٥ متر) لكنه أبطأ. 'both' يشغل كلا الكاشفين ويحتفظ بالكاشف الذي وجد وجوهًا أكثر في كل إطار (تكلفة كشف مضاعفة تقريبًا)."
},
"face_landmarker": {
"name": "face_landmarker"
"face_detection_model": {
"name": "face_detection_model"
},
"image": {
"name": "الصورة"
@@ -9374,6 +9434,7 @@
}
},
"MediaPipeFaceMask": {
"description": "رسم قناع باستخدام معالم الوجه.",
"display_name": "MediaPipe Face Mask",
"inputs": {
"face_landmarks": {
@@ -9391,6 +9452,7 @@
}
},
"MediaPipeFaceMeshVisualize": {
"description": "رسم شبكة معالم الوجه على الصورة المدخلة.",
"display_name": "تصوير شبكة وجه MediaPipe",
"inputs": {
"color": {
@@ -9423,6 +9485,7 @@
}
},
"MergeImageLists": {
"description": "دمج عدة قوائم صور في قائمة واحدة.",
"display_name": "دمج قوائم الصور",
"inputs": {
"images": {
@@ -9877,6 +9940,7 @@
}
},
"MoGeInference": {
"description": "تشغيل MoGe على صورة واحدة لتقدير العمق والهندسة.",
"display_name": "استدلال MoGe",
"inputs": {
"apply_mask": {
@@ -9913,6 +9977,7 @@
}
},
"MoGePanoramaInference": {
"description": "تشغيل MoGe على صورة بانورامية إكويركتانجولار عن طريق تقسيمها إلى ١٢ منظوراً، إجراء الاستدلال على كل منها، ودمج النتائج في خريطة عمق واحدة.",
"display_name": "استدلال MoGe بانوراما",
"inputs": {
"batch_size": {
@@ -9947,6 +10012,7 @@
}
},
"MoGePointMapToMesh": {
"description": "تحويل خريطة نقاط MoGe إلى شبكة ثلاثية الأبعاد.",
"display_name": "MoGe تحويل خريطة النقاط إلى شبكة",
"inputs": {
"batch_index": {
@@ -9976,6 +10042,7 @@
}
},
"MoGeRender": {
"description": "عرض خريطة عمق أو خريطة عادية من بيانات الهندسة.",
"display_name": "MoGe عرض",
"inputs": {
"moge_geometry": {
@@ -12264,6 +12331,7 @@
}
},
"NormalizeImages": {
"description": "تطبيع الصور باستخدام المتوسط والانحراف المعياري.",
"display_name": "تطبيع الصور",
"inputs": {
"images": {
@@ -12593,6 +12661,39 @@
}
}
},
"OpenRouterLLMNode": {
"description": "توليد ردود نصية عبر OpenRouter. يوجه إلى مجموعة مختارة من النماذج الشهيرة من xAI، DeepSeek، Qwen، Mistral، Z.AI (GLM)، Moonshot (Kimi)، وPerplexity Sonar.",
"display_name": "OpenRouter LLM",
"inputs": {
"control_after_generate": {
"name": "control after generate"
},
"model": {
"name": "model",
"tooltip": "نموذج OpenRouter المستخدم لتوليد الرد."
},
"model_reasoning_effort": {
"name": "reasoning_effort"
},
"prompt": {
"name": "prompt",
"tooltip": "إدخال نصي للنموذج."
},
"seed": {
"name": "seed",
"tooltip": "بذرة العينة. اضبطها على 0 للتجاهل. معظم النماذج تعتبرها مجرد إشارة."
},
"system_prompt": {
"name": "system_prompt",
"tooltip": "تعليمات أساسية تحدد سلوك النموذج."
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"OpticalFlowLoader": {
"display_name": "تحميل نموذج التدفق البصري",
"inputs": {
@@ -13406,6 +13507,7 @@
}
},
"RandomCropImages": {
"description": "قص الصورة بشكل عشوائي إلى الأبعاد المحددة.",
"display_name": "قص عشوائي للصور",
"inputs": {
"control_after_generate": {
@@ -14268,6 +14370,7 @@
}
},
"ResizeImagesByLongerEdge": {
"description": "تغيير حجم الصور بحيث يتطابق الحافة الأطول مع البعد المحدد مع الحفاظ على نسبة العرض إلى الارتفاع.",
"display_name": "تغيير حجم الصور حسب الحافة الأطول",
"inputs": {
"images": {
@@ -14287,6 +14390,7 @@
}
},
"ResizeImagesByShorterEdge": {
"description": "تغيير حجم الصور بحيث يتطابق الحافة الأقصر مع البُعد المحدد مع الحفاظ على نسبة العرض إلى الارتفاع.",
"display_name": "تغيير حجم الصور حسب الحافة الأقصر",
"inputs": {
"images": {
@@ -14306,6 +14410,7 @@
}
},
"ResolutionBucket": {
"description": "تجميع الـ latent والتهيئات في مجموعات (buckets)",
"display_name": "تجميع الدقة",
"inputs": {
"conditioning": {
@@ -14538,6 +14643,164 @@
}
}
},
"Rodin3D_Gen25_Image": {
"description": "إنشاء نموذج ثلاثي الأبعاد من ١ إلى ٥ صور مرجعية عبر Rodin Gen-2.5. اختر وضع (سريع / عادي / عالي جدًا) لضبط الجودة مقابل التكلفة.",
"display_name": "Rodin 3D Gen-2.5 - من صورة إلى ثلاثي الأبعاد",
"inputs": {
"TAPose": {
"name": "T/A Pose",
"tooltip": "وضعية T/A للنماذج البشرية."
},
"addon_highpack": {
"name": "إضافة HighPack",
"tooltip": "إضافة HighPack: خامات ٤K وزيادة عدد الأوجه ~١٦ مرة في وضع Quad."
},
"bbox_height": {
"name": "ارتفاع الصندوق المحيط",
"tooltip": "ارتفاع الصندوق المحيط (محور Z)."
},
"bbox_length": {
"name": "طول الصندوق المحيط",
"tooltip": "طول الصندوق المحيط (محور X)."
},
"bbox_width": {
"name": "عرض الصندوق المحيط",
"tooltip": "عرض الصندوق المحيط (محور Y). ضع القيمة ٠ مع البقية لتخطي الصندوق."
},
"control_after_generate": {
"name": "التحكم بعد التوليد"
},
"geometry_file_format": {
"name": "تنسيق ملف الهندسة"
},
"hd_texture": {
"name": "خامة عالية الجودة",
"tooltip": "تعزيز جودة الخامة."
},
"height_cm": {
"name": "الارتفاع (سم)",
"tooltip": "ارتفاع النموذج التقريبي بالسنتيمتر (٠ للتخطي)."
},
"images": {
"name": "الصور",
"tooltip": "١-٥ صور. تُستخدم الصورة الأولى للمواد عند العرض من عدة زوايا."
},
"material": {
"name": "المادة"
},
"mode": {
"name": "الوضع",
"tooltip": "وضع التوليد. عادي = متوازن. سريع = ١٬٠٠٠-٢٠٬٠٠٠ وجه للنماذج السريعة. عالي جدًا = ٢٠٬٠٠٠-٢٬٠٠٠٬٠٠٠ وجه مع تفاصيل دقيقة اختيارية."
},
"mode_creative": {
"name": "إبداعي"
},
"mode_polygon_count": {
"name": "عدد الأوجه"
},
"mode_tier": {
"name": "الطبقة"
},
"seed": {
"name": "البذرة"
},
"texture_delight": {
"name": "إزالة الإضاءة من الخامة",
"tooltip": "إزالة الإضاءة المخبوزة من الخامات."
},
"texture_mode": {
"name": "وضع الخامة",
"tooltip": "إعداد جودة الخامة. 'افتراضي' يستخدم إعداد الخادم الافتراضي للطبقة المختارة."
},
"use_original_alpha": {
"name": "الحفاظ على الشفافية الأصلية",
"tooltip": "الحفاظ على شفافية الصورة."
}
},
"outputs": {
"0": {
"name": "ملف النموذج",
"tooltip": null
}
}
},
"Rodin3D_Gen25_Text": {
"description": "إنشاء نموذج ثلاثي الأبعاد من وصف نصي باستخدام Rodin Gen-2.5. اختر وضع التشغيل (سريع / عادي / عالي للغاية) لضبط الجودة مقابل التكلفة.",
"display_name": "Rodin 3D Gen-2.5 - تحويل النص إلى نموذج ثلاثي الأبعاد",
"inputs": {
"TAPose": {
"name": "وضعية T/A",
"tooltip": "وضعية T/A للنماذج البشرية."
},
"addon_highpack": {
"name": "إضافة HighPack",
"tooltip": "إضافة HighPack: خامات 4K وزيادة عدد الأوجه ~١٦ مرة في وضع Quad."
},
"bbox_height": {
"name": "ارتفاع الصندوق المحيط",
"tooltip": "ارتفاع الصندوق المحيط (محور Z)."
},
"bbox_length": {
"name": "طول الصندوق المحيط",
"tooltip": "طول الصندوق المحيط (محور X)."
},
"bbox_width": {
"name": "عرض الصندوق المحيط",
"tooltip": "عرض الصندوق المحيط (محور Y). ضع القيمة ٠ مع القيم الأخرى لتخطي الصندوق."
},
"control_after_generate": {
"name": "التحكم بعد التوليد"
},
"geometry_file_format": {
"name": "صيغة ملف الهندسة"
},
"hd_texture": {
"name": "خامة عالية الجودة",
"tooltip": "تعزيز جودة الخامة."
},
"height_cm": {
"name": "الارتفاع (سم)",
"tooltip": "الارتفاع التقريبي للنموذج بالسنتيمتر (٠ للتخطي)."
},
"material": {
"name": "الخامة"
},
"mode": {
"name": "الوضع",
"tooltip": "وضع التوليد. عادي = توازن بين الجودة والسرعة. سريع = ١٬٠٠٠-٢٠٬٠٠٠ وجه للنماذج الأولية السريعة. عالي للغاية = ٢٠٬٠٠٠-٢٬٠٠٠٬٠٠٠ وجه مع تفاصيل دقيقة اختيارية."
},
"mode_creative": {
"name": "إبداعي"
},
"mode_polygon_count": {
"name": "عدد الأوجه"
},
"mode_tier": {
"name": "الفئة"
},
"prompt": {
"name": "الوصف النصي",
"tooltip": "الوصف النصي للنموذج ثلاثي الأبعاد."
},
"seed": {
"name": "البذرة"
},
"texture_delight": {
"name": "إزالة الإضاءة من الخامة",
"tooltip": "إزالة الإضاءة المخبوزة من الخامات."
},
"texture_mode": {
"name": "وضع الخامة",
"tooltip": "إعداد جودة الخامة. 'افتراضي' يستخدم الإعداد الافتراضي للخادم حسب الفئة المختارة."
}
},
"outputs": {
"0": {
"name": "ملف النموذج",
"tooltip": null
}
}
},
"Rodin3D_Regular": {
"description": "توليد أصول ثلاثية الأبعاد باستخدام واجهة برمجة تطبيقات رودين",
"display_name": "رودين 3D توليد - توليد عادي",
@@ -15650,6 +15913,7 @@
}
},
"SaveImageDataSetToFolder": {
"description": "حفظ مجموعة بيانات من الصور في مجلد محدد. الصيغ المدعومة: PNG.",
"display_name": "حفظ مجموعة بيانات الصور في مجلد",
"inputs": {
"filename_prefix": {
@@ -15667,6 +15931,7 @@
}
},
"SaveImageTextDataSetToFolder": {
"description": "حفظ مجموعة بيانات من أزواج الصور والتعليقات النصية في مجلد محدد. تُحفظ الصور كملفات PNG والتعليقات كملفات TXT بنفس بادئة اسم الملف.",
"display_name": "حفظ مجموعة بيانات الصور والنصوص في مجلد",
"inputs": {
"filename_prefix": {
@@ -15737,6 +16002,7 @@
}
},
"SaveTrainingDataset": {
"description": "حفظ مجموعة بيانات التدريب المشفرة (latents + التهيئة) على القرص لتحميلها بكفاءة أثناء التدريب.",
"display_name": "حفظ مجموعة بيانات التدريب",
"inputs": {
"conditioning": {
@@ -15923,6 +16189,7 @@
}
},
"ShuffleDataset": {
"description": "تبديل ترتيب الصور في القائمة بشكل عشوائي.",
"display_name": "تبديل ترتيب مجموعة بيانات الصور",
"inputs": {
"control_after_generate": {
@@ -15945,6 +16212,7 @@
}
},
"ShuffleImageTextDataset": {
"description": "تبديل ترتيب أزواج الصورة والنص في القائمة بشكل عشوائي.",
"display_name": "تبديل ترتيب مجموعة بيانات الصور والنصوص",
"inputs": {
"control_after_generate": {
@@ -19445,6 +19713,7 @@
}
},
"VoxelToMesh": {
"description": "تحويل شبكة فوكسل إلى شبكة مضلعة (mesh).",
"display_name": "تحويل الفوكسل إلى شبكة",
"inputs": {
"algorithm": {
@@ -19464,6 +19733,7 @@
}
},
"VoxelToMeshBasic": {
"description": "تحويل شبكة فوكسل إلى شبكة مضلعة (mesh).",
"display_name": "تحويل الفوكسل إلى شبكة أساسية",
"inputs": {
"threshold": {

View File

@@ -904,6 +904,7 @@
"alphabetical": "A-Z",
"alphabeticalDesc": "Sort alphabetically within groups"
},
"noMatchingNodes": "No nodes match \"{query}\"",
"sections": {
"favorites": "Bookmarks",
"favoriteNode": "Bookmark Node",
@@ -1112,7 +1113,6 @@
"alphabetical": "A → Z",
"newest": "Newest",
"searchPlaceholder": "Search...",
"vramLowToHigh": "VRAM Usage (Low to High)",
"modelSizeLowToHigh": "Model Size (Low to High)",
"default": "Default"
},
@@ -1661,6 +1661,7 @@
"dataset": "dataset",
"text": "text",
"image": "image",
"adjustments": "adjustments",
"sampling": "sampling",
"schedulers": "schedulers",
"conditioning": "conditioning",
@@ -1676,6 +1677,7 @@
"video": "video",
"ByteDance": "ByteDance",
"filters": "filters",
"transform": "transform",
"advanced": "advanced",
"guidance": "guidance",
"model_merging": "model_merging",
@@ -1694,7 +1696,6 @@
"inpaint": "inpaint",
"scheduling": "scheduling",
"create": "create",
"transform": "transform",
"deprecated": "deprecated",
"detection": "detection",
"debug": "debug",
@@ -1734,6 +1735,7 @@
"geometry_estimation": "geometry_estimation",
"OpenAI": "OpenAI",
"Sora": "Sora",
"OpenRouter": "OpenRouter",
"cond pair": "cond pair",
"photomaker": "photomaker",
"PixVerse": "PixVerse",
@@ -1783,7 +1785,7 @@
"CONTROL_NET": "CONTROL_NET",
"CURVE": "CURVE",
"ELEVENLABS_VOICE": "ELEVENLABS_VOICE",
"FACE_LANDMARKER": "FACE_LANDMARKER",
"FACE_DETECTION_MODEL": "FACE_DETECTION_MODEL",
"FACE_LANDMARKS": "FACE_LANDMARKS",
"FILE_3D": "FILE_3D",
"FILE_3D_FBX": "FILE_3D_FBX",
@@ -1950,6 +1952,7 @@
},
"load3d": {
"switchCamera": "Switch Camera",
"retainViewOnReload": "Lock camera view across model reloads",
"showGrid": "Show Grid",
"backgroundColor": "Background Color",
"lightIntensity": "Light Intensity",
@@ -2137,6 +2140,43 @@
"slots": "Node Slots Error",
"widgets": "Node Widgets Error"
},
"oauth": {
"consent": {
"allow": "Continue",
"deny": "Cancel",
"genericError": "OAuth request failed. Please restart from the client app.",
"loading": "Loading authorization request…",
"missingRequest": "This authorization request is missing. Please restart from the client app.",
"noWorkspaces": "No eligible workspaces are available for this request.",
"title": "{client} wants access",
"subtitle": "Sign in to {resource} to continue",
"resourceFallback": "this app",
"workspaceLabel": "Workspace",
"permissionsHeader": "Permissions",
"workspaceHelp": "Permissions apply to this workspace only.",
"redirectNotice": "You'll be redirected to",
"appTypeNative": "Native app",
"appTypeWeb": "Web app",
"errorExpired": "This consent request has expired or has already been used. Please restart from the client app.",
"errorScopeBroadening": "The previously approved permissions don't cover this request. You'll need to re-authorize with the new permissions.",
"errorUnavailable": "This feature isn't available right now. Please contact support if the problem persists.",
"sessionError": "Failed to establish session. Please try again.",
"sessionErrorToastSummary": "Couldn't continue OAuth sign-in"
},
"scopes": {
"mcp:tools:read": {
"label": "View available workflow tools"
},
"mcp:tools:call": {
"label": "Run workflows on your behalf"
}
},
"workspace": {
"personal": "Personal",
"owner": "Owner",
"member": "Member"
}
},
"auth": {
"apiKey": {
"title": "API Key",
@@ -3531,20 +3571,6 @@
"getHelpTooltip": "Report this error and we'll help you resolve it",
"enterSubgraph": "Enter subgraph",
"seeError": "See Error",
"promptErrors": {
"prompt_no_outputs": {
"desc": "The workflow does not contain any output nodes (e.g. Save Image, Preview Image) to produce a result."
},
"no_prompt": {
"desc": "The workflow data sent to the server is empty. This may be an unexpected system error."
},
"server_error_local": {
"desc": "The server encountered an unexpected error. Please check the server logs."
},
"server_error_cloud": {
"desc": "The server encountered an unexpected error. Please try again later."
}
},
"errorHelp": "For more help, {github} or {support}",
"errorHelpGithub": "submit a GitHub issue",
"errorHelpSupport": "contact our support",
@@ -3629,6 +3655,44 @@
"swapNodes": "Some nodes can be replaced with alternatives",
"missingMedia": "Some nodes are missing required inputs"
},
"errorCatalog": {
"fallbacks": {
"nodeName": "This node",
"inputName": "unknown input"
},
"validationErrors": {
"required_input_missing": {
"title": "Missing connection",
"message": "Required input slots have no connection feeding them.",
"details": "{nodeName} is missing a required input: {inputName}",
"itemLabel": "{nodeName} - {inputName}",
"toastTitle": "Required input missing",
"toastMessage": "{nodeName} is missing a required input: {inputName}"
}
},
"runtimeErrors": {
"execution_failed": {
"itemLabel": "{nodeName}",
"toastTitle": "{nodeName} failed",
"toastMessageLocal": "This node threw an error during execution. Check its inputs or try a different configuration.",
"toastMessageCloud": "This node threw an error during execution. Check its inputs or try a different configuration. No credits charged."
}
},
"promptErrors": {
"prompt_no_outputs": {
"desc": "The workflow does not contain any output nodes (e.g. Save Image, Preview Image) to produce a result."
},
"no_prompt": {
"desc": "The workflow data sent to the server is empty. This may be an unexpected system error."
},
"server_error_local": {
"desc": "The server encountered an unexpected error. Please check the server logs."
},
"server_error_cloud": {
"desc": "The server encountered an unexpected error. Please try again later."
}
}
},
"help": {
"recentReleases": "Recent releases",
"helpCenterMenu": "Help Center Menu"

View File

@@ -22,7 +22,7 @@
}
},
"AddTextPrefix": {
"display_name": "Add Text Prefix",
"display_name": "Add Text Prefix (DEPRECATED)",
"inputs": {
"texts": {
"name": "texts",
@@ -41,7 +41,7 @@
}
},
"AddTextSuffix": {
"display_name": "Add Text Suffix",
"display_name": "Add Text Suffix (DEPRECATED)",
"inputs": {
"texts": {
"name": "texts",
@@ -61,6 +61,7 @@
},
"AdjustBrightness": {
"display_name": "Adjust Brightness",
"description": "Adjust the brightness of an image.",
"inputs": {
"images": {
"name": "images",
@@ -80,6 +81,7 @@
},
"AdjustContrast": {
"display_name": "Adjust Contrast",
"description": "Adjust the contrast of an image.",
"inputs": {
"images": {
"name": "images",
@@ -176,7 +178,8 @@
}
},
"AudioAdjustVolume": {
"display_name": "Audio Adjust Volume",
"display_name": "Adjust Audio Volume",
"description": "Adjust the volume of the audio by a specified amount in decibels (dB).",
"inputs": {
"audio": {
"name": "audio"
@@ -193,7 +196,7 @@
}
},
"AudioConcat": {
"display_name": "Audio Concat",
"display_name": "Concatenate Audio",
"description": "Concatenates the audio1 to audio2 in the specified direction.",
"inputs": {
"audio1": {
@@ -284,7 +287,7 @@
}
},
"AudioMerge": {
"display_name": "Audio Merge",
"display_name": "Merge Audio",
"description": "Combine two audio tracks by overlaying their waveforms.",
"inputs": {
"audio1": {
@@ -585,6 +588,9 @@
"model_auto_downscale": {
"name": "auto_downscale"
},
"model_auto_upscale": {
"name": "auto_upscale"
},
"model_duration": {
"name": "duration"
},
@@ -1115,7 +1121,8 @@
}
},
"CenterCropImages": {
"display_name": "Center Crop Images",
"display_name": "Crop Image (Center)",
"description": "Center crop an image to the specified dimensions.",
"inputs": {
"images": {
"name": "images",
@@ -1299,6 +1306,9 @@
"model_max_tokens": {
"name": "max_tokens"
},
"model_reasoning_effort": {
"name": "reasoning_effort"
},
"model_temperature": {
"name": "temperature"
}
@@ -1790,6 +1800,20 @@
}
}
},
"ComfyAndNode": {
"display_name": "And",
"description": "Logical AND operation. Returns true if all of the values are truthy. Uses Python's rules for truthiness.",
"inputs": {
"values": {
"name": "values"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"ComfyMathExpression": {
"display_name": "Math Expression",
"inputs": {
@@ -1813,6 +1837,20 @@
}
}
},
"ComfyNotNode": {
"display_name": "Not",
"description": "Logical NOT operation. Returns true if the value is falsy. Uses Python's rules for truthiness.",
"inputs": {
"value": {
"name": "value"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"ComfyNumberConvert": {
"display_name": "Convert Number",
"inputs": {
@@ -1829,6 +1867,20 @@
}
}
},
"ComfyOrNode": {
"display_name": "Or",
"description": "Logical OR operation. Returns true if any of the values are truthy. Uses Python's rules for truthiness.",
"inputs": {
"values": {
"name": "values"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"ComfySwitchNode": {
"display_name": "Switch",
"inputs": {
@@ -5700,6 +5752,7 @@
},
"ImageCropV2": {
"display_name": "Crop Image",
"description": "Crop an image to the specified dimensions.",
"inputs": {
"image": {
"name": "image"
@@ -5719,7 +5772,8 @@
}
},
"ImageDeduplication": {
"display_name": "Image Deduplication",
"display_name": "Deduplicate Images",
"description": "Remove duplicate or very similar images from a list.",
"inputs": {
"images": {
"name": "images",
@@ -5773,7 +5827,8 @@
}
},
"ImageGrid": {
"display_name": "Image Grid",
"display_name": "Make Image Grid",
"description": "Arrange multiple images into a grid layout.",
"inputs": {
"images": {
"name": "images",
@@ -7945,7 +8000,8 @@
}
},
"LoadImageDataSetFromFolder": {
"display_name": "Load Image Dataset from Folder",
"display_name": "Load Image (from Folder)",
"description": "Load a dataset of images from a specified folder and return a list of images. Supported formats: PNG, JPG, JPEG, WEBP.",
"inputs": {
"folder": {
"name": "folder",
@@ -7988,11 +8044,12 @@
}
},
"LoadImageTextDataSetFromFolder": {
"display_name": "Load Image and Text Dataset from Folder",
"display_name": "Load Image-Text (from Folder)",
"description": "Load a dataset of pairs of images and text captions from a specified folder and return them as a list. Supported formats: PNG, JPG, JPEG, WEBP.",
"inputs": {
"folder": {
"name": "folder",
"tooltip": "The folder to load images from."
"tooltip": "The folder to load images and text captions from."
}
},
"outputs": {
@@ -8015,11 +8072,11 @@
}
},
"LoadMediaPipeFaceLandmarker": {
"display_name": "Load MediaPipe Face Landmarker",
"display_name": "Load Face Detection Model (MediaPipe)",
"inputs": {
"model_name": {
"name": "model_name",
"tooltip": "Face Landmarker safetensors from models/mediapipe/."
"tooltip": "Face detection model from models/detection/."
}
},
"outputs": {
@@ -8043,6 +8100,7 @@
},
"LoadTrainingDataset": {
"display_name": "Load Training Dataset",
"description": "Load encoded training dataset (latents + conditioning) from disk for use in training.",
"inputs": {
"folder_name": {
"name": "folder_name",
@@ -8431,7 +8489,7 @@
}
},
"LTXVAudioVAELoader": {
"display_name": "LTXV Audio VAE Loader",
"display_name": "Load LTXV Audio VAE",
"inputs": {
"ckpt_name": {
"name": "ckpt_name",
@@ -9247,6 +9305,7 @@
},
"MakeTrainingDataset": {
"display_name": "Make Training Dataset",
"description": "Encode images with VAE and texts with CLIP to create a training dataset of latents and conditionings.",
"inputs": {
"images": {
"name": "images",
@@ -9337,10 +9396,11 @@
}
},
"MediaPipeFaceLandmarker": {
"display_name": "MediaPipe Face Landmarker",
"display_name": "Detect Face Landmarks (MediaPipe)",
"description": "Detects facial landmarks using MediaPipe model.",
"inputs": {
"face_landmarker": {
"name": "face_landmarker"
"face_detection_model": {
"name": "face_detection_model"
},
"image": {
"name": "image"
@@ -9374,7 +9434,8 @@
}
},
"MediaPipeFaceMask": {
"display_name": "MediaPipe Face Mask",
"display_name": "Draw Face Mask (MediaPipe)",
"description": "Draws a mask from face landmarks.",
"inputs": {
"face_landmarks": {
"name": "face_landmarks"
@@ -9391,7 +9452,8 @@
}
},
"MediaPipeFaceMeshVisualize": {
"display_name": "MediaPipe Face Mesh Visualize",
"display_name": "Visualize Face Landmarks (MediaPipe)",
"description": "Draws face landmarks mesh on the input image.",
"inputs": {
"face_landmarks": {
"name": "face_landmarks"
@@ -9423,7 +9485,8 @@
}
},
"MergeImageLists": {
"display_name": "Merge Image Lists",
"display_name": "Merge Image Lists (DEPRECATED)",
"description": "Concatenate multiple image lists into one.",
"inputs": {
"images": {
"name": "images",
@@ -9438,7 +9501,7 @@
}
},
"MergeTextLists": {
"display_name": "Merge Text Lists",
"display_name": "Merge Text Lists (DEPRECATED)",
"inputs": {
"texts": {
"name": "texts",
@@ -12103,7 +12166,8 @@
}
},
"MoGeInference": {
"display_name": "MoGe Inference",
"display_name": "Run MoGe Inference",
"description": "Run MoGe on a single image to estimate depth and geometry.",
"inputs": {
"moge_model": {
"name": "moge_model"
@@ -12139,7 +12203,8 @@
}
},
"MoGePanoramaInference": {
"display_name": "MoGe Panorama Inference",
"display_name": "Run MoGe Panorama Inference",
"description": "Run MoGe on an equirectangular panorama by splitting it into 12 perspective views, running inference on each, and merging the results into a single depth map.",
"inputs": {
"moge_model": {
"name": "moge_model"
@@ -12173,7 +12238,8 @@
}
},
"MoGePointMapToMesh": {
"display_name": "MoGe Point Map to Mesh",
"display_name": "Convert MoGe Point Map to Mesh",
"description": "Convert a MoGe point map into a 3D mesh.",
"inputs": {
"moge_geometry": {
"name": "moge_geometry"
@@ -12202,7 +12268,8 @@
}
},
"MoGeRender": {
"display_name": "MoGe Render",
"display_name": "Render MoGe Geometry",
"description": "Render a depth map or normal map from geometry data",
"inputs": {
"moge_geometry": {
"name": "moge_geometry"
@@ -12264,7 +12331,8 @@
}
},
"NormalizeImages": {
"display_name": "Normalize Images",
"display_name": "Normalize Image Colors",
"description": "Normalize images using mean and standard deviation.",
"inputs": {
"images": {
"name": "images",
@@ -12593,6 +12661,39 @@
}
}
},
"OpenRouterLLMNode": {
"display_name": "OpenRouter LLM",
"description": "Generate text responses through OpenRouter. Routes to a curated set of popular models from xAI, DeepSeek, Qwen, Mistral, Z.AI (GLM), Moonshot (Kimi), and Perplexity Sonar.",
"inputs": {
"prompt": {
"name": "prompt",
"tooltip": "Text input to the model."
},
"model": {
"name": "model",
"tooltip": "The OpenRouter model used to generate the response."
},
"seed": {
"name": "seed",
"tooltip": "Seed for sampling. Set to 0 to omit. Most models treat this as a hint only."
},
"system_prompt": {
"name": "system_prompt",
"tooltip": "Foundational instructions that dictate the model's behavior."
},
"control_after_generate": {
"name": "control after generate"
},
"model_reasoning_effort": {
"name": "reasoning_effort"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"OpticalFlowLoader": {
"display_name": "Load Optical Flow Model",
"inputs": {
@@ -13378,7 +13479,8 @@
}
},
"RandomCropImages": {
"display_name": "Random Crop Images",
"display_name": "Crop Image (Random)",
"description": "Randomly crop an image to the specified dimensions.",
"inputs": {
"images": {
"name": "images",
@@ -14127,7 +14229,7 @@
}
},
"ReplaceText": {
"display_name": "Replace Text",
"display_name": "Replace Text (DEPRECATED)",
"inputs": {
"texts": {
"name": "texts",
@@ -14240,7 +14342,8 @@
}
},
"ResizeImagesByLongerEdge": {
"display_name": "Resize Images by Longer Edge",
"display_name": "Resize Images by Longer Edge (DEPRECATED)",
"description": "Resize images so that the longer edge matches the specified dimension while preserving aspect ratio.",
"inputs": {
"images": {
"name": "images",
@@ -14248,7 +14351,7 @@
},
"longer_edge": {
"name": "longer_edge",
"tooltip": "Target length for the longer edge."
"tooltip": "Target dimension for the longer edge."
}
},
"outputs": {
@@ -14259,7 +14362,8 @@
}
},
"ResizeImagesByShorterEdge": {
"display_name": "Resize Images by Shorter Edge",
"display_name": "Resize Images by Shorter Edge (DEPRECATED)",
"description": "Resize images so that the shorter edge matches the specified dimension while preserving aspect ratio.",
"inputs": {
"images": {
"name": "images",
@@ -14267,7 +14371,7 @@
},
"shorter_edge": {
"name": "shorter_edge",
"tooltip": "Target length for the shorter edge."
"tooltip": "Target dimension for the shorter edge."
}
},
"outputs": {
@@ -14279,6 +14383,7 @@
},
"ResolutionBucket": {
"display_name": "Resolution Bucket",
"description": "Group latents and conditionings into buckets",
"inputs": {
"latents": {
"name": "latents",
@@ -14510,6 +14615,164 @@
}
}
},
"Rodin3D_Gen25_Image": {
"display_name": "Rodin 3D Gen-2.5 - Image to 3D",
"description": "Generate a 3D model from 1-5 reference images via Rodin Gen-2.5. Pick a mode (Fast / Regular / Extreme-High) to tune quality vs. cost.",
"inputs": {
"images": {
"name": "images",
"tooltip": "1-5 images. The first image is used for materials when multi-view."
},
"mode": {
"name": "mode",
"tooltip": "Generation mode. Regular = balanced. Fast = 1K-20K faces for rapid prototyping. Extreme-High = 20K-2M faces with optional micro details."
},
"material": {
"name": "material"
},
"geometry_file_format": {
"name": "geometry_file_format"
},
"texture_mode": {
"name": "texture_mode",
"tooltip": "Texture quality preset. 'Default' uses the server's default for the selected tier."
},
"seed": {
"name": "seed"
},
"TAPose": {
"name": "TAPose",
"tooltip": "T/A pose for human-like models."
},
"hd_texture": {
"name": "hd_texture",
"tooltip": "High-quality texture enhancement."
},
"texture_delight": {
"name": "texture_delight",
"tooltip": "Remove baked lighting from textures."
},
"use_original_alpha": {
"name": "use_original_alpha",
"tooltip": "Preserve image transparency."
},
"addon_highpack": {
"name": "addon_highpack",
"tooltip": "HighPack addon: 4K textures and ~16x faces in Quad mode."
},
"bbox_width": {
"name": "bbox_width",
"tooltip": "Bounding-box width (Y axis). Set to 0 with the others to skip bbox."
},
"bbox_height": {
"name": "bbox_height",
"tooltip": "Bounding-box height (Z axis)."
},
"bbox_length": {
"name": "bbox_length",
"tooltip": "Bounding-box length (X axis)."
},
"height_cm": {
"name": "height_cm",
"tooltip": "Approximate model height in centimeters (0 to skip)."
},
"control_after_generate": {
"name": "control after generate"
},
"mode_creative": {
"name": "creative"
},
"mode_polygon_count": {
"name": "polygon_count"
},
"mode_tier": {
"name": "tier"
}
},
"outputs": {
"0": {
"name": "model_file",
"tooltip": null
}
}
},
"Rodin3D_Gen25_Text": {
"display_name": "Rodin 3D Gen-2.5 - Text to 3D",
"description": "Generate a 3D model from a text prompt via Rodin Gen-2.5. Pick a mode (Fast / Regular / Extreme-High) to tune quality vs. cost.",
"inputs": {
"prompt": {
"name": "prompt",
"tooltip": "Text prompt for the 3D model."
},
"mode": {
"name": "mode",
"tooltip": "Generation mode. Regular = balanced. Fast = 1K-20K faces for rapid prototyping. Extreme-High = 20K-2M faces with optional micro details."
},
"material": {
"name": "material"
},
"geometry_file_format": {
"name": "geometry_file_format"
},
"texture_mode": {
"name": "texture_mode",
"tooltip": "Texture quality preset. 'Default' uses the server's default for the selected tier."
},
"seed": {
"name": "seed"
},
"TAPose": {
"name": "TAPose",
"tooltip": "T/A pose for human-like models."
},
"hd_texture": {
"name": "hd_texture",
"tooltip": "High-quality texture enhancement."
},
"texture_delight": {
"name": "texture_delight",
"tooltip": "Remove baked lighting from textures."
},
"addon_highpack": {
"name": "addon_highpack",
"tooltip": "HighPack addon: 4K textures and ~16x faces in Quad mode."
},
"bbox_width": {
"name": "bbox_width",
"tooltip": "Bounding-box width (Y axis). Set to 0 with the others to skip bbox."
},
"bbox_height": {
"name": "bbox_height",
"tooltip": "Bounding-box height (Z axis)."
},
"bbox_length": {
"name": "bbox_length",
"tooltip": "Bounding-box length (X axis)."
},
"height_cm": {
"name": "height_cm",
"tooltip": "Approximate model height in centimeters (0 to skip)."
},
"control_after_generate": {
"name": "control after generate"
},
"mode_creative": {
"name": "creative"
},
"mode_polygon_count": {
"name": "polygon_count"
},
"mode_tier": {
"name": "tier"
}
},
"outputs": {
"0": {
"name": "model_file",
"tooltip": null
}
}
},
"Rodin3D_Regular": {
"display_name": "Rodin 3D Generate - Regular Generate",
"description": "Generate 3D Assets using Rodin API",
@@ -15392,7 +15655,8 @@
}
},
"SaveImageDataSetToFolder": {
"display_name": "Save Image Dataset to Folder",
"display_name": "Save Image (to Folder) (DEPRECATED)",
"description": "Save a dataset of images to a specified folder. Supported formats: PNG.",
"inputs": {
"images": {
"name": "images",
@@ -15409,16 +15673,13 @@
}
},
"SaveImageTextDataSetToFolder": {
"display_name": "Save Image and Text Dataset to Folder",
"display_name": "Save Image-Text (to Folder)",
"description": "Save a dataset of pairs of images and text captions to a specified folder. Images are saved as PNG files and captions are saved as TXT files with the same filename_prefix.",
"inputs": {
"images": {
"name": "images",
"tooltip": "List of images to save."
},
"texts": {
"name": "texts",
"tooltip": "List of text captions to save."
},
"folder_name": {
"name": "folder_name",
"tooltip": "Name of the folder to save images to (inside output directory)."
@@ -15426,6 +15687,10 @@
"filename_prefix": {
"name": "filename_prefix",
"tooltip": "Prefix for saved image filenames."
},
"texts": {
"name": "texts",
"tooltip": "List of text captions to save."
}
}
},
@@ -15480,6 +15745,7 @@
},
"SaveTrainingDataset": {
"display_name": "Save Training Dataset",
"description": "Save encoded training dataset (latents + conditioning) to disk for efficient loading during training.",
"inputs": {
"latents": {
"name": "latents",
@@ -15802,7 +16068,8 @@
}
},
"ShuffleDataset": {
"display_name": "Shuffle Image Dataset",
"display_name": "Shuffle Images List",
"description": "Randomly shuffle the order of images in a list.",
"inputs": {
"images": {
"name": "images",
@@ -15824,7 +16091,8 @@
}
},
"ShuffleImageTextDataset": {
"display_name": "Shuffle Image-Text Dataset",
"display_name": "Shuffle Pairs of Image-Text",
"description": "Randomly shuffle the order of pairs of image-text in a list.",
"inputs": {
"images": {
"name": "images",
@@ -16726,7 +16994,7 @@
}
},
"StripWhitespace": {
"display_name": "Strip Whitespace",
"display_name": "Strip Whitespace (DEPRECATED)",
"inputs": {
"texts": {
"name": "texts",
@@ -17493,7 +17761,7 @@
}
},
"TextToLowercase": {
"display_name": "Text to Lowercase",
"display_name": "Convert Text to Lowercase (DEPRECATED)",
"inputs": {
"texts": {
"name": "texts",
@@ -17508,7 +17776,7 @@
}
},
"TextToUppercase": {
"display_name": "Text to Uppercase",
"display_name": "Convert Text to Uppercase (DEPRECATED)",
"inputs": {
"texts": {
"name": "texts",
@@ -19449,6 +19717,7 @@
},
"VoxelToMesh": {
"display_name": "Voxel to Mesh",
"description": "Converts a voxel grid to a mesh.",
"inputs": {
"voxel": {
"name": "voxel"
@@ -19467,7 +19736,8 @@
}
},
"VoxelToMeshBasic": {
"display_name": "Voxel to Mesh (Basic)",
"display_name": "Voxel to Mesh (Basic) (DEPRECATED)",
"description": "Converts a voxel grid to a mesh.",
"inputs": {
"voxel": {
"name": "voxel"

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