Compare commits

...

24 Commits

Author SHA1 Message Date
GitHub Action
3d4a70d62f [automated] Apply ESLint and Oxfmt fixes 2026-05-22 21:33:16 +00:00
mattmillerai
fef19b1c52 [chore] Update Ingest API types from cloud@7271394 2026-05-22 21:29:52 +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
Comfy Org PR Bot
9a2bea7283 chore(website): refresh Ashby and cloud nodes snapshots (#12410)
Automated refresh of remote-data snapshots used by the website
build:

- `apps/website/src/data/ashby-roles.snapshot.json` — Ashby job
  board API
- `apps/website/src/data/cloud-nodes.snapshot.json` — Comfy Cloud
  `/api/object_info`

**Flow:**
1. `Release: Website` workflow ran (manual trigger).
2. This PR opens with the regenerated snapshots.
3. `CI: Vercel Website Preview` deploys a preview for review.
4. Merging to `main` triggers the production Vercel deploy.

The snapshot fallback in `apps/website/src/utils/ashby.ts` and
`apps/website/src/utils/cloudNodes.ts` remains intact: builds
without the respective API keys continue to use the committed
snapshot (with a warning annotation in CI).

Triggered by workflow run `26260485885`.

Co-authored-by: christian-byrne <72887196+christian-byrne@users.noreply.github.com>
2026-05-22 00:21:09 +00:00
Christian Byrne
0a07781a76 fix(website): fetch cloud nodes from registry API instead of object_info (#12408)
## Summary

- Fixes cloud-nodes search not finding nodes like FaceDetailer
- The `/api/object_info` endpoint only returns a subset of nodes per
pack (~39 for Impact Pack), but the registry API has the full list (~197
nodes)
- Now fetches complete node list from registry API while still using
object_info to determine which packs are cloud-supported

## Changes

- Add `fetchRegistryPacksWithNodes()` to fetch full node list from
registry (`/nodes/{packId}/versions/{version}/comfy-nodes`)
- Keep using object_info to determine which packs are cloud-supported
- Prefer registry nodes when available, fall back to object_info nodes
- Add retry logic for comfy-nodes fetching
- Add comprehensive tests (13 new tests, 36 total)

## Test plan

- [x] All existing cloudNodes tests pass (36 tests)
- [x] New tests cover registry node fetching, pagination, retry logic
- [x] Type check passes
- [x] Lint passes
- [ ] Verify search for "FaceDetailer" returns Impact Pack on deployed
preview

## Related

- Fixes failing test in #12388 (the data refresh PR)

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-05-21 23:46:33 +00:00
pythongosssss
b3ba6c9344 fix: select node after adding from library (#12404)
## Summary

When adding a node from the library sidebar, the node was not correctly
selected upon placing it. This was due to the canvas capturing the node
under the cursor on mouse down, however the node had not yet been
comitted to the graph at that point, and so selection was then cleared
on mouse up.

## Changes

- **What**: 
- add `blockCommitPointerDown` so if the cursor is over the canvas stop
propagation to prevent LiteGraph adding the mouse handler to clear the
selection

## Review Focus

Alternative approaches considered were blocking the event in endDrag
however this then required manual cleanup of LiteGraph handlers or
overriding the `pointer.onClick` function to force selection of our
node, both felt worse than this approach.

## Screenshots (if applicable)


https://github.com/user-attachments/assets/a2eb154e-5178-4a1e-b5c7-884efd7a10c6
2026-05-21 19:52:56 +00:00
AustinMroz
a50b3d16da Persist splash until graph load completes (#12387)
When an app mode workflow is opened on fresh page load, either from a
template url, or a persisted in browser cache, the UI would briefly
display the graph view prior to swapping to app mode. This is fixed by
continuing to display the splash screen until workflow state has loaded.

Share by url brings unique difficulties. The function call does not
return until a user has responded to a dialogue. If the splash screen
were blocked by this, the user would never be able to see the dialogue.
Consequentially, this change is not applied to shared workflow urls and
the (very unlikely) url including both a template url and a share url
will now prioritize the template url.

A best effort e2e test is included, but is a little clunky.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12387-Persist-splash-until-graph-load-completes-3666d73d3650813495e4ccad6052c1e4)
by [Unito](https://www.unito.io)
2026-05-21 19:44:12 +00:00
AustinMroz
3ce0c07af2 Use utility function to add node with V2 search (#12382)
Default search box settings are a little inconvenient to work with. This
PR introduces a new `addNode` utility function to the V2 search fixture
that handles all the steps of opening search and adding a node that a
user would perform.

It then migrates several PRs I have recently written to use this new
fixture.

See also #12029

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12382-Use-utility-function-to-add-node-with-V2-search-3666d73d3650817c8c73c9104b1113bf)
by [Unito](https://www.unito.io)
2026-05-21 19:25:26 +00:00
Terry Jia
52d77e6ee0 chore: upgrade sparkjs to 2.x and three to 0.184 (#12396)
## Summary
- Spark 2.x requires SparkRenderer in scene tree; add it in SceneManager
and protect it in clearModel so model reloads don't dispose the splat
renderer.
- three 0.184 OrbitControls listens on ownerDocument; drop redundant
pointermove/up .stop in Load3D containers so the document listener can
receive events.
- Narrow Texture.image type for 0.184 strict typing.
2026-05-21 10:06:20 -04:00
jaeone94
f1f65cff61 feat: select top asset widget FormDropdown result on Enter (#12209)
## Summary

Allow asset/media FormDropdown searches to select the top filtered
result when the user presses Enter. This covers image, video, audio,
mesh, model-like asset selects, and other `WidgetSelectDropdown`-backed
media widgets.

## Implementation Scope

This PR implements a **top-result Enter shortcut** for the custom
asset/media dropdown path only:

- In scope: `WidgetSelectDropdown` -> `FormDropdown` asset/media
widgets.
- In scope: while the dropdown is open, single-select, and the search
text is non-empty, the first current search result becomes the Enter
candidate.
- In scope: pressing Enter in the search input selects that
candidate/top result through the existing selection path.
- In scope: candidate feedback for this shortcut, including visual
candidate styling and a polite screen-reader announcement for the
current top result.
- In scope: stale async search protection, empty-query/no-result no-op
behavior, multi-select guard behavior, and focus return to the trigger
after Enter selection closes the menu.
- Out of scope: plain combo widgets (`WidgetSelectDefault` /
`SelectPlus`). That path is PrimeVue-based and should be handled
separately from this focused asset-widget PR.
- Out of scope: full combobox/listbox keyboard navigation, including
Tab-to-list focus, ArrowUp/ArrowDown candidate movement, Home/End
behavior, scroll-to-active-item behavior, and a full ARIA
combobox/listbox refactor.

Follow-up arrow-key navigation should validate the interaction model
separately. This PR keeps the candidate state narrow and localized so
that future work can either extend it into movable active-item state or
replace it as part of a fuller combobox/listbox implementation.

## Changes

- **What**: Added an explicit Enter event from `FormSearchInput`, routed
it through the FormDropdown menu actions, and selected the current top
search result in `FormDropdown`.
- **What**: Kept the existing `computedAsync` + debounced filtering path
for normal typing, while Enter performs a one-off search against the
latest input before selecting. Stale async Enter results are ignored if
the query or item source changes before resolution.
- **What**: Prevented closed FormDropdown state from treating the full
unfiltered list as current search results, limited Enter-to-select to
single-select dropdowns, and made empty search Enter a no-op.
- **What**: Returned focus to the dropdown trigger after single-select
selection closes the menu.
- **What**: Added candidate styling for the first current FormDropdown
result while a search query is active so the Enter target is visible to
users.
- **What**: Added a polite screen-reader announcement for the current
top result candidate.
- **What**: Fixed the FormDropdownMenuActions `baseModelSelected` model
default to use a `Set` factory instead of a shared instance.
- **What**: Added unit coverage for the search Enter event, FormDropdown
selection behavior, focus return, debounce/Enter behavior, stale async
Enter protection, empty-query no-op behavior, closed-state stale result
protection, multi-select guard behavior, and candidate announcement
behavior. Added App Mode E2E coverage for asset FormDropdown Enter
selection.
- **What**: Extracted reusable app-mode dropdown fixture helpers and
updated the existing FormDropdown clipping test to use the shared
helper.

## Review Focus

Please focus review on the asset/media FormDropdown path, especially
`getTopSearchResult()`, the single-select/empty-query guards, stale
async search protection, trigger focus return after selection, and
candidate feedback in grid/list layouts.

The plain combo path and full arrow-key navigation are intentionally
left for separate follow-up work.

## Screenshots (if applicable)



https://github.com/user-attachments/assets/3eb3456d-93a3-4959-91a3-188f8116ccc9



Validation performed:

- Latest final-commit validation:
- `pnpm test:unit
src/renderer/extensions/vueNodes/widgets/components/form/FormSearchInput.test.ts
src/renderer/extensions/vueNodes/widgets/components/form/dropdown/FormDropdown.test.ts
src/renderer/extensions/vueNodes/widgets/components/form/dropdown/FormDropdownMenuActions.test.ts
src/renderer/extensions/vueNodes/widgets/components/form/dropdown/FormDropdownMenu.test.ts`
- Commit hook: `pnpm exec stylelint ...`, `pnpm exec oxfmt --write ...`,
`pnpm exec oxlint --type-aware --fix ...`, `pnpm exec eslint --cache
--fix ...`, `pnpm typecheck`
  - Push hook: `pnpm knip --cache`
  - `git diff --check`
- Earlier branch validation for this flow:
  - `pnpm install`
  - `pnpm typecheck:browser`
- `PLAYWRIGHT_LOCAL=1 PLAYWRIGHT_TEST_URL=http://localhost:5173
PLAYWRIGHT_SETUP_API_URL=http://localhost:8188 pnpm test:browser --
--project=chromium browser_tests/tests/appMode.spec.ts -g "Drag and
Drop|FormDropdown search Enter selects the top filtered item"
--reporter=list`
- `PLAYWRIGHT_LOCAL=1 PLAYWRIGHT_TEST_URL=http://localhost:5173
PLAYWRIGHT_SETUP_API_URL=http://localhost:8188 pnpm test:browser --
--project=chromium browser_tests/tests/appMode.spec.ts -g "FormDropdown
search Enter selects the top filtered item" --reporter=list`
- `PLAYWRIGHT_LOCAL=1 PLAYWRIGHT_TEST_URL=http://localhost:5173
PLAYWRIGHT_SETUP_API_URL=http://localhost:8188 pnpm test:browser --
--project=chromium browser_tests/tests/appModeDropdownClipping.spec.ts
-g "FormDropdown popup is not clipped" --reporter=list`
2026-05-21 02:26:26 +00:00
Alexander Brown
b0144db644 build: migrate pnpm config to v11 (#12195)
## Summary

Migrate pnpm configuration to the v11 layout and clean up stale v10-era
references.

## Changes

- **What**: Moves pnpm settings into `pnpm-workspace.yaml`, converts
build dependency policy to `allowBuilds`, removes stale workspace
`packageManager` pins, and updates global install commands in CI.
- **Dependencies**: No new dependencies.

## Review Focus

- Confirm pnpm v11 workspace settings match the former `.npmrc`
behavior.
- Confirm CI global install syntax is compatible with pnpm v11.

## Test Plan

- `pnpm install --frozen-lockfile`
- `pnpm exec oxfmt --check pnpm-workspace.yaml
packages/shared-frontend-utils/package.json
packages/registry-types/package.json packages/ingest-types/package.json
packages/design-system/package.json
.github/workflows/weekly-docs-check.yaml
.github/workflows/pr-claude-review.yaml`
- commit hook: `pnpm typecheck`

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12195-build-migrate-pnpm-config-to-v11-35e6d73d36508116a821dbc71db94cd1)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Amp <amp@ampcode.com>
2026-05-21 01:53:09 +00:00
Comfy Org PR Bot
8ee8dd03c4 1.45.12 (#12393)
Patch version increment to 1.45.12

**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-21 00:47:07 +00:00
Benjamin Lu
d472ca783b test: cover FE-130 assets sidebar route mocks (#12332)
## Summary

Adds a focused FE-130 assets sidebar browser-test slice without
extending the stateful asset helper path.

## Changes

- **What**: Extends `jobsRouteFixture` with job-detail and
history-delete helpers for generated asset flows.
- **What**: Adds an assets sidebar tab spec covering generated/imported
rendering, preview opening, generated selection footer actions, and
explicit delete refresh behavior.

## Review Focus

The delete test keeps backend state explicit: it captures the
`/api/history` request, then replaces the `/api/jobs` history mock with
the post-delete response. The imported-file and `/api/view` mocks stay
local to this focused spec instead of growing `AssetHelper` or adding a
sidebar-specific fixture.
2026-05-20 17:01:51 -07:00
Deep Mehta
e80ec6e3d4 feat: add model-to-node mappings for geometry_estimation and optical_flow (#12389)
## Summary

Add entries to `MODEL_NODE_MAPPINGS` so the model browser's "Use" button
creates the correct loader node for two model directories introduced in
recent node-pack updates.

## Changes

- **What**: 2 new entries in
`src/platform/assets/mappings/modelNodeMappings.ts`:
  - `geometry_estimation` → `LoadMoGeModel` / `model_name`
  - `optical_flow` → `OpticalFlowLoader` / `model_name`
- **Breaking**: none

## Review Focus

- Node class names and input keys cross-checked against the published
node definitions:
- `LoadMoGeModel` is the MoGe geometry-estimation loader (companion
nodes: `MoGeInference`, `MoGeRender`, `MoGeContextStrandModel`)
  - `OpticalFlowLoader` is the RAFT optical-flow loader
- Both directories accept a single model file via a COMBO widget on the
loader node

## Test plan

- [ ] Verify "Use" button works for each new model directory in the
model browser:
- One `geometry_estimation` model (e.g.
`moge_2_vitl_normal_fp16.safetensors`) → creates a `LoadMoGeModel` node
with the file preselected
- One `optical_flow` model → creates an `OpticalFlowLoader` node with
the file preselected

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12389-feat-add-model-to-node-mappings-for-geometry_estimation-and-optical_flow-3666d73d36508190981fcaf77f9d2ee4)
by [Unito](https://www.unito.io)
2026-05-20 23:26:40 +00:00
159 changed files with 18541 additions and 4808 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

@@ -39,7 +39,7 @@ jobs:
- name: Install dependencies for analysis tools
run: |
pnpm install -g typescript @vue/compiler-sfc
pnpm add -g typescript @vue/compiler-sfc
- name: Run Claude PR Review
uses: anthropics/claude-code-action@ff34ce0ff04a470bd3fa56c1ef391c8f1c19f8e9 # v1.0.38

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

View File

@@ -40,11 +40,11 @@ jobs:
- name: Install dependencies for analysis tools
run: |
# Check if packages are already available locally
if ! pnpm list typescript @vue/compiler-sfc >/dev/null 2>&1; then
if ! pnpm list -g typescript @vue/compiler-sfc >/dev/null 2>&1; then
echo "Installing TypeScript and Vue compiler globally..."
pnpm install -g typescript @vue/compiler-sfc
pnpm add -g typescript @vue/compiler-sfc
else
echo "TypeScript and Vue compiler already available locally"
echo "TypeScript and Vue compiler already available globally"
fi
- name: Run Claude Documentation Review

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*

3
.npmrc
View File

@@ -1,3 +0,0 @@
ignore-workspace-root-check=true
catalog-mode=prefer
public-hoist-pattern[]=@parcel/watcher

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

@@ -1,5 +1,5 @@
{
"fetchedAt": "2026-05-12T16:10:34.114Z",
"fetchedAt": "2026-05-22T00:07:48.353Z",
"departments": [
{
"name": "DESIGN",
@@ -36,14 +36,14 @@
"id": "6a6d865eeb3c10a8",
"title": "Senior Software Engineer, Frontend",
"department": "Engineering",
"location": "San Francisco",
"location": "Remote",
"jobUrl": "https://jobs.ashbyhq.com/comfy-org/c3e0584d-5490-491f-aae4-b5922ef63fd2"
},
{
"id": "1b4f7f1da9616e14",
"title": "Senior Software Engineer, Backend Generalist",
"department": "Engineering",
"location": "San Francisco",
"location": "Remote",
"jobUrl": "https://jobs.ashbyhq.com/comfy-org/732f8b39-076d-4847-afe3-f54d4451607e"
},
{
@@ -71,14 +71,14 @@
"id": "91604c4182a1bc3c",
"title": "Software Engineer, Core ComfyUI Contributor",
"department": "Engineering",
"location": "San Francisco",
"location": "Remote",
"jobUrl": "https://jobs.ashbyhq.com/comfy-org/7d4062d6-d500-445a-9a5f-014971af259f"
},
{
"id": "a1dbc0576ab14034",
"title": "Software Engineer, ComfyUI Desktop",
"department": "Engineering",
"location": "San Francisco",
"location": "Remote",
"jobUrl": "https://jobs.ashbyhq.com/comfy-org/ad2f76cb-a787-47d8-81c5-7e7f917747c0"
},
{
@@ -105,21 +105,21 @@
"id": "23dd98cab77ff459",
"title": "Freelance Motion Designer",
"department": "Marketing",
"location": "San Francisco",
"location": "Remote",
"jobUrl": "https://jobs.ashbyhq.com/comfy-org/a7ccc2b4-4d9d-4e04-b39c-28a711995b5b"
},
{
"id": "a998b9fc973ff3c0",
"title": "Creative Artist",
"department": "Marketing",
"location": "San Francisco",
"location": "Remote",
"jobUrl": "https://jobs.ashbyhq.com/comfy-org/19ba10aa-4961-45e8-8473-66a8a7a8079d"
},
{
"id": "3e730938026d6e70",
"title": "Graphic Designer",
"department": "Marketing",
"location": "San Francisco",
"location": "Remote",
"jobUrl": "https://jobs.ashbyhq.com/comfy-org/49fa0b07-3fa1-4a3a-b2c6-d2cc684ad63f"
},
{
@@ -135,6 +135,20 @@
"department": "Marketing",
"location": "San Francisco",
"jobUrl": "https://jobs.ashbyhq.com/comfy-org/89d3ff75-2055-4e92-9c69-81feff55627c"
},
{
"id": "e11f8b9e58dbea81",
"title": "Creative Producer",
"department": "Marketing",
"location": "Remote",
"jobUrl": "https://jobs.ashbyhq.com/comfy-org/7be2d690-7a2b-4ebf-b1c4-6907b273d3d9"
},
{
"id": "6eac654593208ec3",
"title": "Forward Deployed Creative Technologist",
"department": "Marketing",
"location": "San Francisco",
"jobUrl": "https://jobs.ashbyhq.com/comfy-org/af49c05f-dcd8-4c3d-a464-43eb3b1c6efc"
}
]
},

File diff suppressed because one or more lines are too long

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

@@ -2,7 +2,8 @@ import { describe, expect, it, vi } from 'vitest'
import {
DEFAULT_REGISTRY_BASE_URL,
fetchRegistryPacks
fetchRegistryPacks,
fetchRegistryPacksWithNodes
} from './cloudNodes.registry'
function jsonResponse(
@@ -142,3 +143,315 @@ describe('fetchRegistryPacks', () => {
expect(result.size).toBe(0)
})
})
describe('fetchRegistryPacksWithNodes', () => {
it('fetches pack metadata and comfy nodes for each pack', async () => {
const fetchImpl = vi.fn(async (input: RequestInfo | URL) => {
const url = new URL(String(input))
// Pack metadata request
if (url.pathname === '/nodes') {
return jsonResponse({
nodes: [
{
id: 'comfyui-impact-pack',
name: 'ComfyUI Impact Pack',
repository: 'https://github.com/ltdrdata/ComfyUI-Impact-Pack',
latest_version: { version: '8.0.0', createdAt: '2026-01-01' }
}
]
})
}
// Comfy nodes request
if (url.pathname.includes('/comfy-nodes')) {
return jsonResponse({
comfy_nodes: [
{ comfy_node_name: 'FaceDetailer', category: 'detailer' },
{ comfy_node_name: 'DetailerForEach', category: 'detailer' }
],
totalNumberOfPages: 1
})
}
return new Response('Not found', { status: 404 })
})
const result = await fetchRegistryPacksWithNodes(['comfyui-impact-pack'], {
fetchImpl: fetchImpl as typeof fetch
})
expect(result.size).toBe(1)
const packData = result.get('comfyui-impact-pack')
expect(packData).not.toBeNull()
expect(packData?.pack.name).toBe('ComfyUI Impact Pack')
expect(packData?.nodes).toHaveLength(2)
expect(packData?.nodes[0]?.comfy_node_name).toBe('FaceDetailer')
})
it('handles pagination for comfy nodes', async () => {
let comfyNodesCallCount = 0
const fetchImpl = vi.fn(async (input: RequestInfo | URL) => {
const url = new URL(String(input))
if (url.pathname === '/nodes') {
return jsonResponse({
nodes: [
{
id: 'big-pack',
name: 'Big Pack',
latest_version: { version: '1.0.0' }
}
]
})
}
if (url.pathname.includes('/comfy-nodes')) {
comfyNodesCallCount++
const page = Number(url.searchParams.get('page') ?? '1')
if (page === 1) {
return jsonResponse({
comfy_nodes: [
{ comfy_node_name: 'Node1', category: 'cat1' },
{ comfy_node_name: 'Node2', category: 'cat1' }
],
totalNumberOfPages: 2
})
} else {
return jsonResponse({
comfy_nodes: [{ comfy_node_name: 'Node3', category: 'cat2' }],
totalNumberOfPages: 2
})
}
}
return new Response('Not found', { status: 404 })
})
const result = await fetchRegistryPacksWithNodes(['big-pack'], {
fetchImpl: fetchImpl as typeof fetch
})
expect(comfyNodesCallCount).toBe(2)
const packData = result.get('big-pack')
expect(packData?.nodes).toHaveLength(3)
})
it('returns null for packs without latest_version', async () => {
const fetchImpl = vi.fn(async (input: RequestInfo | URL) => {
const url = new URL(String(input))
if (url.pathname === '/nodes') {
return jsonResponse({
nodes: [
{
id: 'no-version-pack',
name: 'No Version Pack',
latest_version: null
}
]
})
}
return new Response('Not found', { status: 404 })
})
const result = await fetchRegistryPacksWithNodes(['no-version-pack'], {
fetchImpl: fetchImpl as typeof fetch
})
expect(result.get('no-version-pack')).toBeNull()
})
it('returns empty nodes array when comfy-nodes request fails', async () => {
const fetchImpl = vi.fn(async (input: RequestInfo | URL) => {
const url = new URL(String(input))
if (url.pathname === '/nodes') {
return jsonResponse({
nodes: [
{
id: 'failing-pack',
name: 'Failing Pack',
latest_version: { version: '1.0.0' }
}
]
})
}
if (url.pathname.includes('/comfy-nodes')) {
return new Response('Server error', { status: 500 })
}
return new Response('Not found', { status: 404 })
})
const result = await fetchRegistryPacksWithNodes(['failing-pack'], {
fetchImpl: fetchImpl as typeof fetch
})
const packData = result.get('failing-pack')
expect(packData).not.toBeNull()
expect(packData?.pack.name).toBe('Failing Pack')
expect(packData?.nodes).toHaveLength(0)
})
it('handles null comfy_nodes in response', async () => {
const fetchImpl = vi.fn(async (input: RequestInfo | URL) => {
const url = new URL(String(input))
if (url.pathname === '/nodes') {
return jsonResponse({
nodes: [
{
id: 'null-nodes-pack',
name: 'Null Nodes Pack',
latest_version: { version: '1.0.0' }
}
]
})
}
if (url.pathname.includes('/comfy-nodes')) {
return jsonResponse({
comfy_nodes: null,
totalNumberOfPages: 1
})
}
return new Response('Not found', { status: 404 })
})
const result = await fetchRegistryPacksWithNodes(['null-nodes-pack'], {
fetchImpl: fetchImpl as typeof fetch
})
const packData = result.get('null-nodes-pack')
expect(packData?.nodes).toHaveLength(0)
})
it('fetches nodes for multiple packs in parallel', async () => {
const packIds = ['pack-a', 'pack-b', 'pack-c']
const fetchImpl = vi.fn(async (input: RequestInfo | URL) => {
const url = new URL(String(input))
if (url.pathname === '/nodes') {
const requestedIds = url.searchParams.getAll('node_id')
return jsonResponse({
nodes: requestedIds.map((id) => ({
id,
name: id.toUpperCase(),
latest_version: { version: '1.0.0' }
}))
})
}
if (url.pathname.includes('/comfy-nodes')) {
const packId = url.pathname.split('/nodes/')[1]?.split('/')[0]
return jsonResponse({
comfy_nodes: [
{ comfy_node_name: `${packId}-node`, category: 'test' }
],
totalNumberOfPages: 1
})
}
return new Response('Not found', { status: 404 })
})
const result = await fetchRegistryPacksWithNodes(packIds, {
fetchImpl: fetchImpl as typeof fetch
})
expect(result.size).toBe(3)
for (const packId of packIds) {
const packData = result.get(packId)
expect(packData).not.toBeNull()
expect(packData?.nodes[0]?.comfy_node_name).toBe(`${packId}-node`)
}
})
it('retries comfy-nodes fetch once on failure', async () => {
let comfyNodesAttempts = 0
const fetchImpl = vi.fn(async (input: RequestInfo | URL) => {
const url = new URL(String(input))
if (url.pathname === '/nodes') {
return jsonResponse({
nodes: [
{
id: 'retry-pack',
name: 'Retry Pack',
latest_version: { version: '1.0.0' }
}
]
})
}
if (url.pathname.includes('/comfy-nodes')) {
comfyNodesAttempts++
if (comfyNodesAttempts === 1) {
return new Response('Server error', { status: 500 })
}
return jsonResponse({
comfy_nodes: [{ comfy_node_name: 'RetryNode', category: 'test' }],
totalNumberOfPages: 1
})
}
return new Response('Not found', { status: 404 })
})
const result = await fetchRegistryPacksWithNodes(['retry-pack'], {
fetchImpl: fetchImpl as typeof fetch
})
expect(comfyNodesAttempts).toBe(2)
const packData = result.get('retry-pack')
expect(packData?.nodes).toHaveLength(1)
expect(packData?.nodes[0]?.comfy_node_name).toBe('RetryNode')
})
it('normalizes null boolean fields in comfy nodes', async () => {
const fetchImpl = vi.fn(async (input: RequestInfo | URL) => {
const url = new URL(String(input))
if (url.pathname === '/nodes') {
return jsonResponse({
nodes: [
{
id: 'bool-pack',
name: 'Bool Pack',
latest_version: { version: '1.0.0' }
}
]
})
}
if (url.pathname.includes('/comfy-nodes')) {
return jsonResponse({
comfy_nodes: [
{
comfy_node_name: 'TestNode',
category: 'test',
deprecated: null,
experimental: null
}
],
totalNumberOfPages: 1
})
}
return new Response('Not found', { status: 404 })
})
const result = await fetchRegistryPacksWithNodes(['bool-pack'], {
fetchImpl: fetchImpl as typeof fetch
})
const packData = result.get('bool-pack')
expect(packData?.nodes[0]?.deprecated).toBeUndefined()
expect(packData?.nodes[0]?.experimental).toBeUndefined()
})
})

View File

@@ -5,8 +5,10 @@ import type { components } from '@comfyorg/registry-types'
export const DEFAULT_REGISTRY_BASE_URL = 'https://api.comfy.org'
const DEFAULT_TIMEOUT_MS = 5_000
const BATCH_SIZE = 50
const COMFY_NODES_PAGE_SIZE = 500
export type RegistryPack = components['schemas']['Node']
export type RegistryComfyNode = components['schemas']['ComfyNode']
function nullToUndefined<T>(value: T | null | undefined): T | undefined {
return value ?? undefined
@@ -58,6 +60,29 @@ const RegistryListResponseSchema = z
})
.passthrough()
const RegistryComfyNodeSchema = z
.object({
comfy_node_name: optionalString,
category: optionalString,
description: optionalString,
deprecated: z
.boolean()
.nullish()
.transform((v) => v ?? undefined),
experimental: z
.boolean()
.nullish()
.transform((v) => v ?? undefined)
})
.passthrough()
const RegistryComfyNodesResponseSchema = z
.object({
comfy_nodes: z.array(RegistryComfyNodeSchema).nullish(),
totalNumberOfPages: z.number().nullish()
})
.passthrough()
interface FetchRegistryOptions {
baseUrl?: string
timeoutMs?: number
@@ -122,6 +147,142 @@ export async function fetchRegistryPacks(
return resolved
}
export interface RegistryPackWithNodes {
pack: RegistryPack
nodes: RegistryComfyNode[]
}
export async function fetchRegistryPacksWithNodes(
packIds: readonly string[],
options: FetchRegistryOptions = {}
): Promise<Map<string, RegistryPackWithNodes | null>> {
const packs = await fetchRegistryPacks(packIds, options)
const baseUrl = options.baseUrl ?? DEFAULT_REGISTRY_BASE_URL
const timeoutMs = clampTimeoutMs(options.timeoutMs)
const fetchImpl = options.fetchImpl ?? fetch
const entries = await Promise.all(
[...packs.entries()].map(
async ([packId, pack]): Promise<
[string, RegistryPackWithNodes | null]
> => {
if (!pack?.latest_version?.version) {
return [packId, null]
}
const nodes = await fetchComfyNodesForPack(
fetchImpl,
baseUrl,
packId,
pack.latest_version.version,
timeoutMs
)
return [packId, { pack, nodes }]
}
)
)
return new Map(entries)
}
async function fetchComfyNodesForPack(
fetchImpl: typeof fetch,
baseUrl: string,
packId: string,
version: string,
timeoutMs: number
): Promise<RegistryComfyNode[]> {
const allNodes: RegistryComfyNode[] = []
let page = 1
let totalPages = 1
while (page <= totalPages) {
const result = await fetchComfyNodesPageWithRetry(
fetchImpl,
baseUrl,
packId,
version,
page,
timeoutMs
)
if (!result) break
allNodes.push(...result.nodes)
totalPages = result.totalPages
page++
}
return allNodes
}
async function fetchComfyNodesPageWithRetry(
fetchImpl: typeof fetch,
baseUrl: string,
packId: string,
version: string,
page: number,
timeoutMs: number
): Promise<{ nodes: RegistryComfyNode[]; totalPages: number } | null> {
const firstAttempt = await fetchComfyNodesPage(
fetchImpl,
baseUrl,
packId,
version,
page,
timeoutMs
)
if (firstAttempt) return firstAttempt
// Retry once on failure
return fetchComfyNodesPage(
fetchImpl,
baseUrl,
packId,
version,
page,
timeoutMs
)
}
async function fetchComfyNodesPage(
fetchImpl: typeof fetch,
baseUrl: string,
packId: string,
version: string,
page: number,
timeoutMs: number
): Promise<{ nodes: RegistryComfyNode[]; totalPages: number } | null> {
const controller = new AbortController()
const timer = setTimeout(() => controller.abort(), timeoutMs)
try {
const url = `${baseUrl}/nodes/${encodeURIComponent(packId)}/versions/${encodeURIComponent(version)}/comfy-nodes?limit=${COMFY_NODES_PAGE_SIZE}&page=${page}`
const res = await fetchImpl(url, {
method: 'GET',
headers: { Accept: 'application/json' },
signal: controller.signal
})
if (!res.ok) return null
const rawBody: unknown = await res.json()
const parsed = RegistryComfyNodesResponseSchema.safeParse(rawBody)
if (!parsed.success) return null
return {
nodes: (parsed.data.comfy_nodes ?? []) as RegistryComfyNode[],
totalPages: parsed.data.totalNumberOfPages ?? 1
}
} catch {
return null
} finally {
clearTimeout(timer)
}
}
async function fetchBatchWithRetry(
fetchImpl: typeof fetch,
baseUrl: string,

View File

@@ -8,12 +8,16 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { NodesSnapshot } from '../data/cloudNodes'
import type * as ObjectInfoParser from '@comfyorg/object-info-parser'
const fetchRegistryPacksMock = vi.hoisted(() => vi.fn(async () => new Map()))
import type { RegistryPackWithNodes } from './cloudNodes.registry'
const fetchRegistryPacksWithNodesMock = vi.hoisted(() =>
vi.fn(async () => new Map<string, RegistryPackWithNodes | null>())
)
const sanitizeCallSpy = vi.hoisted(() => vi.fn())
vi.mock('./cloudNodes.registry', () => ({
DEFAULT_REGISTRY_BASE_URL: 'https://api.comfy.org',
fetchRegistryPacks: fetchRegistryPacksMock
fetchRegistryPacksWithNodes: fetchRegistryPacksWithNodesMock
}))
vi.mock('@comfyorg/object-info-parser', async (importOriginal) => {
@@ -90,8 +94,8 @@ describe('fetchCloudNodesForBuild', () => {
beforeEach(() => {
resetCloudNodesFetcherForTests()
fetchRegistryPacksMock.mockReset()
fetchRegistryPacksMock.mockResolvedValue(new Map())
fetchRegistryPacksWithNodesMock.mockReset()
fetchRegistryPacksWithNodesMock.mockResolvedValue(new Map())
sanitizeCallSpy.mockReset()
delete process.env.WEBSITE_CLOUD_API_KEY
})
@@ -102,14 +106,21 @@ describe('fetchCloudNodesForBuild', () => {
})
it('returns fresh when API succeeds', async () => {
fetchRegistryPacksMock.mockResolvedValue(
new Map([
fetchRegistryPacksWithNodesMock.mockResolvedValue(
new Map<string, RegistryPackWithNodes | null>([
[
'comfyui-impact-pack',
{
id: 'comfyui-impact-pack',
name: 'ComfyUI Impact Pack',
repository: 'https://github.com/ltdrdata/ComfyUI-Impact-Pack'
pack: {
id: 'comfyui-impact-pack',
name: 'ComfyUI Impact Pack',
repository: 'https://github.com/ltdrdata/ComfyUI-Impact-Pack',
latest_version: { version: '1.0.0' }
},
nodes: [
{ comfy_node_name: 'FaceDetailer', category: 'detailer' },
{ comfy_node_name: 'DetailerForEach', category: 'detailer' }
]
}
]
])
@@ -129,6 +140,10 @@ describe('fetchCloudNodesForBuild', () => {
expect(outcome.snapshot.packs[0]?.repoUrl).toBe(
'https://github.com/ltdrdata/ComfyUI-Impact-Pack'
)
// Nodes should come from registry, not object_info
expect(outcome.snapshot.packs[0]?.nodes).toHaveLength(2)
expect(outcome.snapshot.packs[0]?.nodes[0]?.name).toBe('DetailerForEach')
expect(outcome.snapshot.packs[0]?.nodes[1]?.name).toBe('FaceDetailer')
})
it('drops invalid nodes individually and keeps valid nodes', async () => {
@@ -297,7 +312,7 @@ describe('fetchCloudNodesForBuild', () => {
})
it('returns fresh even when registry enrichment fails', async () => {
fetchRegistryPacksMock.mockResolvedValue(new Map())
fetchRegistryPacksWithNodesMock.mockResolvedValue(new Map())
const fetchImpl = vi.fn(async () => response({ ImpactNode: validNode() }))
const outcome = await fetchCloudNodesForBuild({
apiKey: KEY,
@@ -305,5 +320,8 @@ describe('fetchCloudNodesForBuild', () => {
fetchImpl: fetchImpl as typeof fetch
})
expect(outcome.status).toBe('fresh')
// Falls back to object_info nodes when registry fails
if (outcome.status !== 'fresh') return
expect(outcome.snapshot.packs[0]?.nodes[0]?.name).toBe('ImpactNode')
})
})

View File

@@ -6,12 +6,15 @@ import {
validateComfyNodeDef
} from '@comfyorg/object-info-parser'
import type { RegistryPack } from './cloudNodes.registry'
import type {
RegistryComfyNode,
RegistryPackWithNodes
} from './cloudNodes.registry'
import type { NodesSnapshot, Pack, PackNode } from '../data/cloudNodes'
import bundledSnapshot from '../data/cloud-nodes.snapshot.json' with { type: 'json' }
import { isNodesSnapshot } from '../data/cloudNodes'
import { fetchRegistryPacks } from './cloudNodes.registry'
import { fetchRegistryPacksWithNodes } from './cloudNodes.registry'
import { CloudNodesEnvelopeSchema } from './cloudNodes.schema'
const DEFAULT_BASE_URL = 'https://cloud.comfy.org'
@@ -235,26 +238,28 @@ async function parseCloudNodes(
const sanitizedDefs = sanitizeUserContent(
validDefs as Record<string, NonNullable<(typeof validDefs)[string]>>
)
const grouped = groupNodesByPack(sanitizedDefs)
let registryMap = new Map<string, RegistryPack | null>()
// Use object_info to determine which packs are cloud-supported
const grouped = groupNodesByPack(sanitizedDefs)
const packIds = grouped.map((pack) => pack.id)
// Fetch full pack metadata and node list from registry
let registryMap = new Map<string, RegistryPackWithNodes | null>()
try {
registryMap = await fetchRegistryPacks(
grouped.map((pack) => pack.id),
{ fetchImpl: options.fetchImpl }
)
registryMap = await fetchRegistryPacksWithNodes(packIds, {
fetchImpl: options.fetchImpl
})
} catch {
registryMap = new Map()
}
const packs = grouped.map((pack) =>
toDomainPack(
pack.id,
pack.displayName,
pack.nodes,
registryMap.get(pack.id)
)
)
const packs = grouped
.map((pack) => {
const registryData = registryMap.get(pack.id)
// Use registry nodes if available, otherwise fall back to object_info nodes
return toDomainPack(pack.id, pack.displayName, pack.nodes, registryData)
})
.filter((pack) => pack.nodes.length > 0)
return { kind: 'ok', packs, droppedNodes }
}
@@ -274,7 +279,7 @@ function safeExternalUrl(value: string | undefined): string | undefined {
function toDomainPack(
packId: string,
fallbackDisplayName: string,
nodes: Array<{
objectInfoNodes: Array<{
className: string
def: {
display_name: string
@@ -284,8 +289,18 @@ function toDomainPack(
experimental?: boolean
}
}>,
registryPack: RegistryPack | null | undefined
registryData: RegistryPackWithNodes | null | undefined
): Pack {
const registryPack = registryData?.pack
// Prefer registry nodes if available, fall back to object_info nodes
const nodes =
registryData?.nodes && registryData.nodes.length > 0
? registryData.nodes
.map((node) => toDomainNodeFromRegistry(node))
.filter((n): n is PackNode => n !== null)
: objectInfoNodes.map((node) => toDomainNode(node.className, node.def))
return {
id: packId,
registryId: registryPack?.id,
@@ -308,9 +323,20 @@ function toDomainPack(
registryPack?.latest_version?.createdAt ?? registryPack?.created_at,
supportedOs: registryPack?.supported_os,
supportedAccelerators: registryPack?.supported_accelerators,
nodes: nodes
.map((node) => toDomainNode(node.className, node.def))
.sort((a, b) => a.displayName.localeCompare(b.displayName))
nodes: nodes.sort((a, b) => a.displayName.localeCompare(b.displayName))
}
}
function toDomainNodeFromRegistry(node: RegistryComfyNode): PackNode | null {
if (!node.comfy_node_name) return null
return {
name: node.comfy_node_name,
displayName: node.comfy_node_name,
category: node.category || '',
description: node.description || undefined,
deprecated: node.deprecated,
experimental: node.experimental
}
}

View File

@@ -1,8 +1,10 @@
import { expect } from '@playwright/test'
import type { Locator } from '@playwright/test'
import type { RootCategoryId } from '@/components/searchbox/v2/rootCategories'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import { TestIds } from '@e2e/fixtures/selectors'
import type { Position } from '@e2e/fixtures/types'
const { searchBoxV2 } = TestIds
@@ -84,11 +86,12 @@ export class ComfyNodeSearchBoxV2 {
await this.input.waitFor({ state: 'visible' })
}
async openByDoubleClickCanvas(): Promise<void> {
async openByDoubleClickCanvas(position?: Position) {
const { x, y } = position ?? { x: 200, y: 200 }
// Use page.mouse.dblclick (not canvas.dblclick) so the z-999 Vue overlay
// does not intercept; coords target a viewport spot that is on the canvas
// and clear of both the side toolbar and any default-graph nodes.
await this.comfyPage.page.mouse.dblclick(200, 200, { delay: 5 })
await this.comfyPage.page.mouse.dblclick(x, y, { delay: 5 })
}
async ensureV2Search(): Promise<void> {
@@ -109,4 +112,14 @@ export class ComfyNodeSearchBoxV2 {
'search box'
)
}
async addNode(query: string, options: { position?: Position } = {}) {
const position = options.position ?? { x: 200, y: 200 }
await this.openByDoubleClickCanvas(position)
await this.input.fill(query)
await expect(this.results.first()).toContainText(query)
await this.comfyPage.page.keyboard.press('Enter')
await expect(this.dialog).toBeHidden()
await this.comfyPage.page.mouse.click(position.x, position.y)
}
}

View File

@@ -2,10 +2,24 @@ import type { Locator } from '@playwright/test'
export class WidgetSelectDropdownFixture {
public readonly selection: Locator
public readonly trigger: Locator
constructor(public readonly root: Locator) {
this.trigger = root.locator('button:has(> span)').first()
this.selection = root.locator('button span span')
}
async open(): Promise<void> {
await this.trigger.click()
}
async searchAndSelectTop(popover: Locator, query: string): Promise<void> {
await this.open()
const searchInput = popover.getByRole('textbox')
await searchInput.fill(query)
await searchInput.press('Enter')
}
async selectedItem(): Promise<string> {
return await this.selection.innerText()
}

View File

@@ -1,6 +1,7 @@
import type { Locator, Page } from '@playwright/test'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import { WidgetSelectDropdownFixture } from '@e2e/fixtures/components/WidgetSelectDropdown'
/**
* Helper for interacting with widgets rendered in app mode (linear view).
@@ -24,6 +25,11 @@ export class AppModeWidgetHelper {
return this.container.locator(`[data-widget-key="${key}"]`)
}
/** Get a FormDropdown widget by its key (e.g. "10:image"). */
getSelectDropdown(key: string): WidgetSelectDropdownFixture {
return new WidgetSelectDropdownFixture(this.getWidgetItem(key))
}
/** Fill a textarea widget (e.g. CLIP Text Encode prompt). */
async fillTextarea(key: string, value: string) {
const widget = this.getWidgetItem(key)

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

@@ -8,6 +8,7 @@ import {
} from '@comfyorg/ingest-types/zod'
import type {
JobDetail,
JobStatus,
RawJobListItem,
zJobsListResponse
@@ -182,6 +183,24 @@ export class JobsRouteMocker {
return await this.mockPostManageRoute('history', zHistoryManageRequest, {})
}
async mockDeleteHistory(): Promise<HistoryManageRequest[]> {
return await this.mockPostManageRoute('history', zHistoryManageRequest, {})
}
async mockJobDetail(jobId: string, detail: JobDetail): Promise<void> {
await this.page.route(
(url) => url.pathname.endsWith(`/api/jobs/${encodeURIComponent(jobId)}`),
async (requestRoute) => {
if (requestRoute.request().method().toUpperCase() !== 'GET') {
await requestRoute.fallback()
return
}
await requestRoute.fulfill({ json: detail })
}
)
}
private async mockPostManageRoute<TRequest>(
type: 'queue' | 'history',
requestSchema: z.ZodType<TRequest>,

View File

@@ -2,16 +2,10 @@ import {
comfyPageFixture as test,
comfyExpect as expect
} from '@e2e/fixtures/ComfyPage'
import { WidgetSelectDropdownFixture } from '@e2e/fixtures/components/WidgetSelectDropdown'
import { TestIds } from '@e2e/fixtures/selectors'
test.describe('App mode usage', () => {
test('Drag and Drop', async ({ comfyPage, comfyFiles }) => {
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
await comfyPage.settings.setSetting(
'Comfy.NodeSearchBoxImpl',
'v1 (legacy)'
)
test('Drag and Drop @vue-nodes', async ({ comfyPage, comfyFiles }) => {
const { centerPanel } = comfyPage.appMode
await comfyPage.appMode.enterAppModeWithInputs([['3', 'seed']])
await expect(centerPanel, 'Enter app mode').toBeVisible()
@@ -25,15 +19,12 @@ test.describe('App mode usage', () => {
//prep a load image
await test.step('Add a load image node', async () => {
await comfyPage.workflow.loadWorkflow('default')
await comfyPage.page.mouse.dblclick(200, 200, { delay: 5 })
await comfyPage.searchBox.fillAndSelectFirstNode('Load Image')
await comfyPage.searchBoxV2.addNode('Load Image')
const loadImage = await comfyPage.vueNodes.getNodeLocator('10')
await expect(loadImage).toBeVisible()
})
const imageInput = new WidgetSelectDropdownFixture(
comfyPage.appMode.linearWidgets.locator('.lg-node-widget')
)
const imageInput = comfyPage.appMode.widgets.getSelectDropdown('10:image')
await test.step('Enter app mode with image input', async () => {
await comfyPage.appMode.enterAppModeWithInputs([['10', 'image']])
@@ -107,6 +98,45 @@ test.describe('App mode usage', () => {
//verify values are consistent with litegraph
})
test('FormDropdown search Enter selects the top filtered item', async ({
comfyPage
}) => {
await comfyPage.appMode.enableLinearMode()
const loadImageNode = await comfyPage.nodeOps.addNode('LoadImage')
await comfyPage.nextFrame()
const fileComboWidget = await loadImageNode.getWidget(0)
const targetImage = String(await fileComboWidget.getValue())
const initialImage = 'not-selected.png'
await comfyPage.page.evaluate(
([nodeId, value]) => {
const node = window.app!.graph!.getNodeById(nodeId)
const widget = node?.widgets?.[0]
if (!widget) throw new Error(`Image widget not found: ${nodeId}`)
widget.value = value
},
[loadImageNode.id, initialImage] as const
)
await expect.poll(() => fileComboWidget.getValue()).toBe(initialImage)
await comfyPage.appMode.enterAppModeWithInputs([
[String(loadImageNode.id), 'image']
])
await expect(comfyPage.appMode.linearWidgets).toBeVisible()
const imageInput = comfyPage.appMode.widgets.getSelectDropdown(
`${loadImageNode.id}:image`
)
const popover = comfyPage.appMode.imagePickerPopover
await expect(imageInput.root).toBeVisible()
await imageInput.searchAndSelectTop(popover, targetImage)
await expect(popover).toBeHidden()
await expect(imageInput.selection).toHaveText(targetImage)
await expect.poll(() => fileComboWidget.getValue()).toBe(targetImage)
})
test.describe('Mobile', { tag: ['@mobile'] }, () => {
test('panel navigation', async ({ comfyPage }) => {
const { mobile } = comfyPage.appMode

View File

@@ -75,33 +75,28 @@ test.describe('App mode builder selection', () => {
})
test('Marks canvas readOnly', async ({ comfyPage }) => {
await comfyPage.settings.setSetting(
'Comfy.NodeSearchBoxImpl',
'v1 (legacy)'
)
await comfyPage.page.mouse.dblclick(100, 100, { delay: 5 })
await comfyPage.searchBoxV2.openByDoubleClickCanvas()
await expect(
comfyPage.searchBox.input,
comfyPage.searchBoxV2.input,
'Canvas is initially editable'
).toHaveCount(1)
).toBeVisible()
await comfyPage.page.keyboard.press('Escape')
await comfyPage.appMode.enterBuilder()
await comfyPage.appMode.steps.goToInputs()
await comfyPage.page.mouse.dblclick(100, 100, { delay: 5 })
await comfyPage.searchBoxV2.openByDoubleClickCanvas()
await expect(
comfyPage.searchBox.input,
comfyPage.searchBoxV2.input,
'Entering builder makes the canvas readonly'
).toHaveCount(0)
).toBeHidden()
await comfyPage.page.keyboard.press('Space')
await comfyPage.page.mouse.dblclick(100, 100, { delay: 5 })
await comfyPage.searchBoxV2.openByDoubleClickCanvas()
await expect(
comfyPage.searchBox.input,
comfyPage.searchBoxV2.input,
'Canvas remains readonly after pressing space'
).toHaveCount(0)
).toBeHidden()
const ksampler = await comfyPage.vueNodes.getFixtureByTitle('KSampler')
// oxlint-disable-next-line playwright/no-force-option -- Node container has conditional pointer-events:none that blocks actionability
@@ -112,10 +107,10 @@ test.describe('App mode builder selection', () => {
).toBeHidden()
await comfyPage.page.keyboard.press('Escape')
await comfyPage.page.mouse.dblclick(100, 100, { delay: 5 })
await comfyPage.searchBoxV2.openByDoubleClickCanvas()
await expect(
comfyPage.searchBox.input,
comfyPage.searchBoxV2.input,
'Canvas is no longer readonly after exiting'
).toHaveCount(1)
).toBeVisible()
})
})

View File

@@ -131,13 +131,10 @@ test.describe('App mode dropdown clipping', { tag: '@ui' }, () => {
el.scrollTo({ top: el.scrollHeight, behavior: 'instant' })
)
// Click the FormDropdown trigger button for the image widget.
// The button emits 'select-click' which toggles the Popover.
const imageRow = widgetList.locator(
'div:has(> div > span:text-is("image"))'
const imageInput = comfyPage.appMode.widgets.getSelectDropdown(
`${loadImageId}:image`
)
const dropdownButton = imageRow.locator('button:has(> span)').first()
await dropdownButton.click()
await imageInput.open()
// The unstyled PrimeVue Popover renders with role="dialog".
// Locate the one containing the image grid (filter buttons like "All", "Inputs").

View File

@@ -1,4 +1,5 @@
import {
ComfyPage,
comfyPageFixture as test,
comfyExpect as expect
} from '@e2e/fixtures/ComfyPage'
@@ -43,4 +44,45 @@ test.describe('Linear Mode', { tag: '@ui' }, () => {
await expect(comfyPage.page.getByTestId('linear-widgets')).toBeVisible()
await expect(comfyPage.canvas).toBeHidden()
})
test('Spinner persists until workflow loaded', async ({
page,
request
}, testInfo) => {
const comfyPage = new ComfyPage(page, request)
const { parallelIndex } = testInfo
const username = `playwright-test-${parallelIndex}`
const userId = await comfyPage.setupUser(username)
comfyPage.userIds[parallelIndex] = userId
await page.goto(`${comfyPage.url}/api/users`)
await page.evaluate((id) => {
localStorage.clear()
sessionStorage.clear()
localStorage.setItem('Comfy.userId', id)
}, comfyPage.id)
const splash = page.locator('#splash-loader')
let notifyWorkflowRequested!: () => void
const workflowRequested = new Promise<void>(
(r) => (notifyWorkflowRequested = r)
)
let unblockRequest!: () => void
const requestUnblocked = new Promise<void>((r) => (unblockRequest = r))
await page.route('**/templates/default.json', async (route) => {
notifyWorkflowRequested()
await requestUnblocked
return route.continue()
})
await comfyPage.goto({ url: `${comfyPage.url}/?template=default` })
await workflowRequested
await comfyPage.nextFrame()
await expect(splash).toBeVisible()
unblockRequest()
await expect(splash).toBeHidden()
})
})

View File

@@ -5,7 +5,6 @@ import {
test('Price badge displays on subgraphs @vue-nodes', async ({ comfyPage }) => {
const apiNodeName = 'Node With Price Badge'
await comfyPage.settings.setSetting('Comfy.NodeSearchBoxImpl', 'v1 (legacy)')
const priceBadge = comfyPage.page.locator('.lg-node-header i + span')
const apiNode = comfyPage.vueNodes.getNodeByTitle(apiNodeName)
@@ -13,9 +12,7 @@ test('Price badge displays on subgraphs @vue-nodes', async ({ comfyPage }) => {
await comfyPage.menu.topbar.newWorkflowButton.click()
await comfyPage.nextFrame()
await comfyPage.page.mouse.dblclick(500, 500, { delay: 5 })
await comfyPage.searchBox.fillAndSelectFirstNode(apiNodeName)
await expect(comfyPage.searchBox.input).toBeHidden()
await comfyPage.searchBoxV2.addNode(apiNodeName)
await expect(apiNode, 'Add partner node').toBeVisible()
await expect(apiNode.locator(priceBadge), 'Has price badge').toBeVisible()

View File

@@ -10,6 +10,9 @@ import type {
RawJobListItem
} from '@/platform/remote/comfyui/jobs/jobTypes'
// Legacy coverage backed by AssetsHelper's shadow backend. New assets-sidebar
// browser coverage should use typed route mocks in assetsSidebarTab.spec.ts.
// ---------------------------------------------------------------------------
// Shared fixtures
// ---------------------------------------------------------------------------

View File

@@ -0,0 +1,278 @@
import { expect, mergeTests } from '@playwright/test'
import type { Page } from '@playwright/test'
import { comfyPageFixture } from '@e2e/fixtures/ComfyPage'
import {
createRouteMockJob,
jobsRouteFixture,
routeMockJobTimestamp
} from '@e2e/fixtures/jobsRouteFixture'
import type {
JobDetail,
RawJobListItem
} from '@/platform/remote/comfyui/jobs/jobTypes'
const test = mergeTests(comfyPageFixture, jobsRouteFixture)
interface ViewFile {
body?: Buffer | string
contentType?: string
}
type ViewFilesByName = Readonly<Record<string, ViewFile>>
const transparentPng = Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAFgwJ/lwPIRwAAAABJRU5ErkJggg==',
'base64'
)
const alphaJob = createRouteMockJob({
id: 'alpha',
create_time: routeMockJobTimestamp - 1_000,
execution_start_time: routeMockJobTimestamp - 1_000,
execution_end_time: routeMockJobTimestamp,
preview_output: {
filename: 'alpha.png',
subfolder: '',
type: 'output',
nodeId: '1',
mediaType: 'images'
}
})
const betaJob = createRouteMockJob({
id: 'beta',
create_time: routeMockJobTimestamp - 2_000,
execution_start_time: routeMockJobTimestamp - 2_000,
execution_end_time: routeMockJobTimestamp,
preview_output: {
filename: 'beta.png',
subfolder: '',
type: 'output',
nodeId: '2',
mediaType: 'images'
}
})
const multiOutputJob = createRouteMockJob({
id: 'multi-output',
create_time: routeMockJobTimestamp - 3_000,
execution_start_time: routeMockJobTimestamp - 3_000,
execution_end_time: routeMockJobTimestamp,
preview_output: {
filename: 'multi-output-a.png',
subfolder: '',
type: 'output',
nodeId: '3',
mediaType: 'images'
},
outputs_count: 2
})
const multiOutputJobDetail: JobDetail = {
...multiOutputJob,
outputs: {
'3': {
images: [
{
filename: 'multi-output-a.png',
subfolder: '',
type: 'output'
},
{
filename: 'multi-output-b.png',
subfolder: '',
type: 'output'
}
]
}
}
}
const generatedJobs: RawJobListItem[] = [alphaJob, betaJob]
const viewFiles = {
'alpha.png': {},
'beta.png': {},
'imported.png': {},
'multi-output-a.png': {},
'multi-output-b.png': {}
}
async function mockInputFiles(page: Page, files: readonly string[]) {
await page.route('**/internal/files/input**', async (route) => {
if (route.request().method().toUpperCase() !== 'GET') {
await route.fallback()
return
}
await route.fulfill({ json: [...files] })
})
}
async function mockViewFiles(page: Page, filesByName: ViewFilesByName) {
await page.route('**/api/view**', async (route) => {
if (route.request().method().toUpperCase() !== 'GET') {
await route.fallback()
return
}
const url = new URL(route.request().url())
const filename = url.searchParams.get('filename')
if (!filename) {
await route.fulfill({
status: 400,
json: { error: 'Missing filename' } satisfies { error: string }
})
return
}
const file = filesByName[filename]
if (!file) {
await route.fulfill({
status: 404,
json: {
error: `Unknown filename: ${filename}`
} satisfies { error: string }
})
return
}
await route.fulfill({
body: file.body ?? transparentPng,
contentType: file.contentType ?? 'image/png'
})
})
}
test.describe('FE-130 assets sidebar route mocks', () => {
test.beforeEach(async ({ jobsRoutes, page }) => {
await jobsRoutes.mockJobsQueue([])
await jobsRoutes.mockJobsHistory(generatedJobs)
await mockInputFiles(page, ['imported.png'])
await mockViewFiles(page, viewFiles)
})
test('renders generated and imported assets with image previews', async ({
comfyPage
}) => {
const tab = comfyPage.menu.assetsTab
await comfyPage.setup()
await tab.open()
await expect(tab.getAssetCardByName('alpha')).toBeVisible()
await expect(tab.getAssetCardByName('beta')).toBeVisible()
await expect(
comfyPage.page.getByRole('img', { name: 'alpha.png' })
).toHaveJSProperty('naturalWidth', 1)
await tab.switchToImported()
await expect(tab.getAssetCardByName('imported')).toBeVisible()
await expect(
comfyPage.page.getByRole('img', { name: 'imported.png' })
).toHaveJSProperty('naturalWidth', 1)
})
test('opens previews for generated and imported images', async ({
comfyPage
}) => {
const tab = comfyPage.menu.assetsTab
await comfyPage.setup()
await tab.open()
await comfyPage.page.getByRole('img', { name: 'alpha.png' }).dblclick()
await expect(comfyPage.mediaLightbox.root).toBeVisible()
await expect(
comfyPage.mediaLightbox.root.getByRole('img', {
name: 'alpha.png'
})
).toBeVisible()
await comfyPage.mediaLightbox.closeButton.click()
await expect(comfyPage.mediaLightbox.root).toBeHidden()
await tab.switchToImported()
await comfyPage.page.getByRole('img', { name: 'imported.png' }).dblclick()
await expect(comfyPage.mediaLightbox.root).toBeVisible()
await expect(
comfyPage.mediaLightbox.root.getByRole('img', {
name: 'imported.png'
})
).toBeVisible()
})
test('shows footer actions for single and multiple generated selections', async ({
comfyPage
}) => {
const tab = comfyPage.menu.assetsTab
await comfyPage.setup()
await tab.open()
await tab.getAssetCardByName('alpha').click()
await expect(tab.selectionCountButton).toHaveText(/Assets Selected:\s*1\b/)
await expect(tab.deleteSelectedButton).toBeVisible()
await expect(tab.downloadSelectedButton).toBeVisible()
await comfyPage.page.keyboard.down('Control')
await tab.getAssetCardByName('beta').click()
await comfyPage.page.keyboard.up('Control')
await expect(tab.selectionCountButton).toHaveText(/Assets Selected:\s*2\b/)
await expect(tab.deleteSelectedButton).toBeVisible()
await expect(tab.downloadSelectedButton).toBeVisible()
})
test('loads full generated job outputs from job detail', async ({
comfyPage,
jobsRoutes
}) => {
const tab = comfyPage.menu.assetsTab
await jobsRoutes.mockJobsHistory([multiOutputJob])
await jobsRoutes.mockJobDetail('multi-output', multiOutputJobDetail)
await comfyPage.setup()
await tab.open()
await tab
.getAssetCardByName('multi-output-a')
.getByRole('button', { name: 'See more outputs' })
.click()
await expect(tab.backToAssetsButton).toBeVisible()
await expect(tab.getAssetCardByName('multi-output-b')).toBeVisible()
await expect(
comfyPage.page.getByRole('img', { name: 'multi-output-b.png' })
).toHaveJSProperty('naturalWidth', 1)
})
test('deletes a generated output asset through explicit history refresh', async ({
comfyPage,
jobsRoutes
}) => {
const tab = comfyPage.menu.assetsTab
await comfyPage.setup()
await tab.open()
await expect(tab.getAssetCardByName('alpha')).toBeVisible()
const deleteRequests = await jobsRoutes.mockDeleteHistory()
await jobsRoutes.mockJobsHistory([betaJob])
await tab.getAssetCardByName('alpha').click({ button: 'right' })
await tab.contextMenuItem('Delete').click()
await comfyPage.confirmDialog.delete.click()
await expect.poll(() => deleteRequests).toHaveLength(1)
expect(deleteRequests[0]).toEqual({ delete: ['alpha'] })
await expect(tab.getAssetCardByName('alpha')).toHaveCount(0)
await expect(comfyPage.toast.toastSuccesses).toContainText(
'Asset deleted successfully'
)
})
})

View File

@@ -129,4 +129,26 @@ test.describe('Node library sidebar V2', () => {
await expect(tab.nodePreview, 'Preview displays on hover').toBeVisible()
await expect(tab.nodePreview).toContainText('Inverts the image')
})
test('Click-to-place from sidebar selects the newly added node', async ({
comfyPage
}) => {
const tab = comfyPage.menu.nodeLibraryTabV2
await comfyPage.nodeOps.clearGraph()
await tab.expandFolder('sampling')
const canvasBox = (await comfyPage.canvas.boundingBox())!
const target = {
x: canvasBox.width / 2,
y: canvasBox.height / 2
}
await tab.getNode('KSampler (Advanced)').click()
await comfyPage.canvas.click({ position: target })
await expect.poll(() => comfyPage.nodeOps.getGraphNodesCount()).toBe(1)
await expect
.poll(() => comfyPage.nodeOps.getSelectedGraphNodesCount())
.toBe(1)
})
})

View File

@@ -5,10 +5,6 @@ import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
test.describe('Subgraph Operations', { tag: ['@slow', '@subgraph'] }, () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled')
await comfyPage.settings.setSetting(
'Comfy.NodeSearchBoxImpl',
'v1 (legacy)'
)
})
test.describe('Subgraph Clipboard Operations', () => {
@@ -58,8 +54,7 @@ test.describe('Subgraph Operations', { tag: ['@slow', '@subgraph'] }, () => {
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('2')
await subgraphNode.navigateIntoSubgraph()
await comfyPage.canvasOps.doubleClick()
await comfyPage.searchBox.fillAndSelectFirstNode('Note')
await comfyPage.searchBoxV2.addNode('Note')
await comfyPage.nextFrame()
const initialCount = await comfyPage.subgraph.getNodeCount()

View File

@@ -745,20 +745,19 @@ test('Link already promoted widget @vue-nodes', async ({ comfyPage }) => {
})
test('Can promote multiple previews @vue-nodes', async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.NodeSearchBoxImpl', 'v1 (legacy)')
await comfyPage.menu.topbar.newWorkflowButton.click()
await comfyPage.nextFrame()
await test.step('Add and rename a Load Image node', async () => {
await comfyPage.page.mouse.dblclick(300, 300, { delay: 5 })
await comfyPage.searchBox.fillAndSelectFirstNode('Load Image')
const position = { x: 300, y: 300 }
await comfyPage.searchBoxV2.addNode('Load Image', { position })
const loadImage = await comfyPage.vueNodes.getFixtureByTitle('Load Image')
await loadImage.setTitle('Character Reference')
})
await test.step('Add a second Load Image node', async () => {
await comfyPage.page.mouse.dblclick(600, 300, { delay: 5 })
await comfyPage.searchBox.fillAndSelectFirstNode('Load Image')
const position = { x: 600, y: 300 }
await comfyPage.searchBoxV2.addNode('Load Image', { position })
})
await test.step('Convert both nodes to subgraph', async () => {

View File

@@ -1082,17 +1082,10 @@ test.describe(
comfyPage,
comfyMouse
}) => {
await comfyPage.settings.setSetting(
'Comfy.NodeSearchBoxImpl',
'v1 (legacy)'
)
// Setup workflow with a KSampler node
await comfyPage.command.executeCommand('Comfy.NewBlankWorkflow')
await comfyPage.nodeOps.waitForGraphNodes(0)
await comfyPage.command.executeCommand('Workspace.SearchBox.Toggle')
await comfyPage.nextFrame()
await comfyPage.searchBox.fillAndSelectFirstNode('KSampler')
await comfyPage.searchBoxV2.addNode('KSampler')
await comfyPage.nodeOps.waitForGraphNodes(1)
// Convert the KSampler node to a subgraph

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

@@ -9,8 +9,6 @@ const file1 = 'workflow.mp4' as const
const file2 = 'workflow.webm' as const
test('@vue-nodes Load Video', async ({ comfyPage, comfyFiles }) => {
await comfyPage.settings.setSetting('Comfy.NodeSearchBoxImpl', 'v1 (legacy)')
const loadVideoNode = comfyPage.vueNodes.getNodeByTitle('Load Video')
const loadVideo = new VideoPreview(loadVideoNode)
@@ -18,9 +16,7 @@ test('@vue-nodes Load Video', async ({ comfyPage, comfyFiles }) => {
await comfyPage.menu.topbar.newWorkflowButton.click()
await comfyPage.nextFrame()
await comfyPage.page.mouse.dblclick(500, 300, { delay: 5 })
await comfyPage.searchBox.fillAndSelectFirstNode('Load Video')
await comfyPage.searchBoxV2.addNode('Load Video')
await expect(loadVideoNode).toHaveCount(1)
await expect(loadVideoNode).toBeVisible()
})

View File

@@ -5,8 +5,6 @@ import {
} from '@e2e/fixtures/ComfyPage'
test('@vue-nodes Audio Widget', async ({ comfyPage, comfyFiles }) => {
await comfyPage.settings.setSetting('Comfy.NodeSearchBoxImpl', 'v1 (legacy)')
const loadAudioNode = comfyPage.vueNodes.getNodeByTitle('Load Audio')
const audioPreview = new AudioPreview(loadAudioNode)
@@ -14,9 +12,7 @@ test('@vue-nodes Audio Widget', async ({ comfyPage, comfyFiles }) => {
await comfyPage.menu.topbar.newWorkflowButton.click()
await comfyPage.nextFrame()
//await comfyPage.canvasOps.doubleClick()
await comfyPage.page.mouse.dblclick(500, 500, { delay: 5 })
await comfyPage.searchBox.fillAndSelectFirstNode('Load Audio')
await comfyPage.searchBoxV2.addNode('Load Audio')
await expect(loadAudioNode).toBeVisible()
})

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.11",
"version": "1.45.13",
"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": "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",
"prepare": "pnpm exec husky || true && git config blame.ignoreRevsFile .git-blame-ignore-revs || true",
"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:",
@@ -113,7 +114,7 @@
"primevue": "catalog:",
"reka-ui": "catalog:",
"semver": "^7.7.2",
"three": "^0.170.0",
"three": "catalog:",
"tiptap-markdown": "^0.8.10",
"typegpu": "catalog:",
"vee-validate": "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:",
@@ -211,20 +207,7 @@
},
"engines": {
"node": "24.x",
"pnpm": ">=10"
"pnpm": ">=11"
},
"packageManager": "pnpm@10.33.0",
"pnpm": {
"overrides": {
"vite": "catalog:"
},
"ignoredBuiltDependencies": [
"@firebase/util",
"core-js",
"protobufjs",
"sharp",
"unrs-resolver",
"vue-demi"
]
}
"packageManager": "pnpm@11.1.1"
}

View File

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

View File

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

View File

@@ -356,10 +356,7 @@ export type {
GetModelFoldersResponse,
GetModelFoldersResponses,
GetModelPreviewData,
GetModelPreviewError,
GetModelPreviewErrors,
GetModelPreviewResponse,
GetModelPreviewResponses,
GetModelsInFolderData,
GetModelsInFolderError,
GetModelsInFolderErrors,
@@ -389,8 +386,21 @@ export type {
GetNodeReplacementsErrors,
GetNodeReplacementsResponse,
GetNodeReplacementsResponses,
GetOpenapiSpecData,
GetOpenapiSpecResponses,
GetOAuthAuthorizationServerData,
GetOAuthAuthorizationServerError,
GetOAuthAuthorizationServerErrors,
GetOAuthAuthorizationServerResponse,
GetOAuthAuthorizationServerResponses,
GetOAuthAuthorizeData,
GetOAuthAuthorizeError,
GetOAuthAuthorizeErrors,
GetOAuthAuthorizeResponse,
GetOAuthAuthorizeResponses,
GetOAuthProtectedResourceData,
GetOAuthProtectedResourceError,
GetOAuthProtectedResourceErrors,
GetOAuthProtectedResourceResponse,
GetOAuthProtectedResourceResponses,
GetPaymentPortalData,
GetPaymentPortalError,
GetPaymentPortalErrors,
@@ -427,11 +437,11 @@ export type {
GetSecretErrors,
GetSecretResponse,
GetSecretResponses,
GetSettingByKeyData,
GetSettingByKeyError,
GetSettingByKeyErrors,
GetSettingByKeyResponse,
GetSettingByKeyResponses,
GetSettingByIdData,
GetSettingByIdError,
GetSettingByIdErrors,
GetSettingByIdResponse,
GetSettingByIdResponses,
GetStaticExtensionsData,
GetStaticExtensionsErrors,
GetStaticExtensionsResponses,
@@ -447,6 +457,7 @@ export type {
GetTaskResponses,
GetTemplateProxyData,
GetTemplateProxyErrors,
GetTemplateProxyResponses,
GetUserData,
GetUserdataData,
GetUserdataError,
@@ -642,6 +653,17 @@ export type {
MoveUserdataFileResponse,
MoveUserdataFileResponses,
NodeInfo,
OAuthAuthorizationServerMetadata,
OAuthAuthorizeRedirectResponse,
OAuthConsentChallenge,
OAuthConsentChallengeWorkspace,
OAuthProtectedResourceMetadata,
OAuthRegisterBadRequestResponse,
OAuthRegisterError,
OAuthRegisterRequest,
OAuthRegisterResponse,
OAuthTokenError,
OAuthTokenResponse,
PaginationInfo,
PartnerUsageRequest,
PartnerUsageResponse,
@@ -663,6 +685,21 @@ export type {
PostMonitoringTasksSubpathData,
PostMonitoringTasksSubpathErrors,
PostMonitoringTasksSubpathResponses,
PostOAuthAuthorizeData,
PostOAuthAuthorizeError,
PostOAuthAuthorizeErrors,
PostOAuthAuthorizeResponse,
PostOAuthAuthorizeResponses,
PostOAuthRegisterData,
PostOAuthRegisterError,
PostOAuthRegisterErrors,
PostOAuthRegisterResponse,
PostOAuthRegisterResponses,
PostOAuthTokenData,
PostOAuthTokenError,
PostOAuthTokenErrors,
PostOAuthTokenResponse,
PostOAuthTokenResponses,
PostPprofSymbolData,
PostPprofSymbolResponses,
PostUserdataFileData,
@@ -799,11 +836,11 @@ export type {
UpdateSecretRequest,
UpdateSecretResponse,
UpdateSecretResponses,
UpdateSettingByKeyData,
UpdateSettingByKeyError,
UpdateSettingByKeyErrors,
UpdateSettingByKeyResponse,
UpdateSettingByKeyResponses,
UpdateSettingByIdData,
UpdateSettingByIdError,
UpdateSettingByIdErrors,
UpdateSettingByIdResponse,
UpdateSettingByIdResponses,
UpdateSubscriptionCacheData,
UpdateSubscriptionCacheError,
UpdateSubscriptionCacheErrors,

View File

@@ -1382,6 +1382,250 @@ export type JwkKey = {
y: string
}
/**
* RFC 6749 §5.2 error response.
*/
export type OAuthTokenError = {
/**
* RFC 6749 §5.2 error code: invalid_request, invalid_client, invalid_grant, unauthorized_client, unsupported_grant_type, invalid_scope.
*/
error: string
/**
* Human-readable, no leak of internal storage state.
*/
error_description?: string
}
/**
* RFC 6749 §5.1 successful token response.
*/
export type OAuthTokenResponse = {
/**
* Resource-bound Cloud JWT (audience matches the protected resource).
*/
access_token: string
token_type: 'Bearer'
/**
* Access token lifetime in seconds.
*/
expires_in: number
/**
* Opaque refresh token. Rotates on every successful refresh; presenting an already-rotated token revokes the entire family.
*/
refresh_token: string
/**
* Space-delimited scopes granted with this token.
*/
scope: string
}
/**
* One workspace option presented in the OAuth consent challenge. Promoted to a named schema so the generated Go type is referenceable in handlers and tests rather than re-declared as an anonymous struct at every callsite.
*
*/
export type OAuthConsentChallengeWorkspace = {
id: string
name: string
type: 'personal' | 'team'
role: 'owner' | 'member'
}
/**
* Redirect target produced after a JSON consent submission. The frontend must navigate the browser to this URL so custom-scheme client callbacks work without relying on fetch-visible 302 headers.
*/
export type OAuthAuthorizeRedirectResponse = {
/**
* OAuth client redirect URI with either code+state for allow, or error+state for deny.
*/
redirect_url: string
}
/**
* Server-side state describing the OAuth consent decision the user is being asked to make. Returned by GET /oauth/authorize when a valid Cloud session exists; the frontend renders the consent UI from this payload and POSTs the decision back. Browser never sees the original OAuth params on resume.
*
*/
export type OAuthConsentChallenge = {
/**
* Opaque server-side identifier for the authorization-request row. Carried back unchanged in the consent submission.
*/
oauth_request_id: string
/**
* Per-row CSRF token bound to this authorization request (not to the session). Must be echoed back on POST.
*/
csrf_token: string
/**
* Human-readable name of the OAuth client requesting authorization, from oauth_clients.display_name.
*/
client_display_name: string
/**
* Human-readable name of the protected resource, from oauth_resources.display_name.
*/
resource_display_name: string
/**
* Scopes the client is requesting for this resource. The frontend should present these for the user to approve.
*/
scopes: Array<string>
/**
* Workspaces the user can select from. Membership is re-checked on POST.
*/
workspaces: Array<OAuthConsentChallengeWorkspace>
}
/**
* OAuth 2.1 protected-resource metadata (RFC 9728).
*/
export type OAuthProtectedResourceMetadata = {
resource: string
authorization_servers: Array<string>
scopes_supported: Array<string>
bearer_methods_supported?: Array<string>
}
/**
* RFC 7591 §3.2.2 error response.
*/
export type OAuthRegisterError = {
error: 'invalid_redirect_uri' | 'invalid_client_metadata'
error_description?: string | null
}
/**
* Union of the two 400 shapes /oauth/register can emit. `OAuthRegisterError` is the handler-shaped RFC 7591 §3.2.2 error; `BindingErrorResponse` is the strict-server binding-layer error fired when the request body fails OpenAPI-schema validation before the handler runs.
*
*/
export type OAuthRegisterBadRequestResponse =
| OAuthRegisterError
| BindingErrorResponse
/**
* Error shape returned when request binding or validation fails before the handler runs.
*/
export type BindingErrorResponse = {
message: string
}
/**
* RFC 7591 §3.2.1 successful registration response.
*/
export type OAuthRegisterResponse = {
/**
* Server-generated client_id. Always carries the `mcp-dyn-` prefix.
*/
client_id: string
/**
* Unix timestamp (seconds) when the client was registered.
*/
client_id_issued_at: number
client_name?: string
redirect_uris: Array<string>
grant_types: Array<string>
response_types: Array<string>
token_endpoint_auth_method: 'none'
application_type: 'native' | 'web'
}
/**
* RFC 7591 §2 client metadata document. Only the fields the server honors are listed; presence of `scope` or `resource_grants` in the request is rejected (`invalid_client_metadata`) because those are server-owned for dynamic clients. `additionalProperties: false` mirrors the runtime middleware that rejects any unknown metadata key.
*
*/
export type OAuthRegisterRequest = {
/**
* 15 redirect URIs. Validated against `application_type` policy.
*/
redirect_uris: Array<string>
/**
* Human-readable name shown in the consent UI. Reserved-name list rejects impersonation of major MCP clients.
*/
client_name?: string
/**
* RFC 7591 §2 application_type. **REQUIRED** — clients MUST declare intent; the server does not default this field. `native` for desktop / CLI / MCP-spec-strict clients (loopback redirects); `web` for hosted clients (HTTPS only, host must be allowlisted). A missing or explicitly empty `application_type` rejects with `invalid_client_metadata`. The realistic MCP-client population is overwhelmingly native/loopback — requiring explicit declaration avoids silently bouncing those clients off the web HTTPS policy.
*
*/
application_type: 'native' | 'web'
/**
* Public clients only this phase — must be `none` if present. The server forces `none` regardless.
*/
token_endpoint_auth_method?: 'none'
/**
* Optional. Defaults to `["authorization_code","refresh_token"]`.
*/
grant_types?: Array<'authorization_code' | 'refresh_token'>
/**
* Optional. Defaults to `["code"]`.
*/
response_types?: Array<'code'>
/**
* **REJECTED IF PRESENT.** Dynamic clients do not pick scopes — the server assigns scopes from the active MCP resource's published list. Sending `scope` in the registration body is treated as a privilege-escalation attempt and returns `invalid_client_metadata`. The field is documented here so clients see a well-defined error rather than silent drop.
*
*/
scope?: string | null
/**
* **REJECTED IF PRESENT.** Same reason as `scope`. The set of resources and scopes a dynamic client may request is server-policy, not request-driven.
*
*/
resource_grants?: {
[key: string]: Array<string>
} | null
/**
* **REJECTED IF PRESENT.** Unsupported RFC 7591 metadata for this public MCP-client phase.
*/
client_uri?: string | null
/**
* **REJECTED IF PRESENT.** Unsupported RFC 7591 metadata for this public MCP-client phase.
*/
logo_uri?: string | null
/**
* **REJECTED IF PRESENT.** Unsupported RFC 7591 metadata for this public MCP-client phase.
*/
tos_uri?: string | null
/**
* **REJECTED IF PRESENT.** Unsupported RFC 7591 metadata for this public MCP-client phase.
*/
policy_uri?: string | null
/**
* **REJECTED IF PRESENT.** Unsupported RFC 7591 metadata for this public MCP-client phase.
*/
software_id?: string | null
/**
* **REJECTED IF PRESENT.** Unsupported RFC 7591 metadata for this public MCP-client phase.
*/
software_version?: string | null
/**
* **REJECTED IF PRESENT.** Unsupported RFC 7591 metadata for this public MCP-client phase.
*/
contacts?: Array<string> | null
/**
* **REJECTED IF PRESENT.** Unsupported RFC 7591 metadata for this public MCP-client phase.
*/
jwks?: {
[key: string]: unknown
} | null
/**
* **REJECTED IF PRESENT.** Unsupported RFC 7591 metadata for this public MCP-client phase.
*/
jwks_uri?: string | null
}
/**
* OAuth 2.1 authorization-server metadata (RFC 8414).
*/
export type OAuthAuthorizationServerMetadata = {
issuer: string
authorization_endpoint: string
token_endpoint: string
jwks_uri: string
/**
* RFC 7591 §3.1 Dynamic Client Registration endpoint. Advertised so MCP-spec-compliant clients can auto-discover and self-register without operator involvement. Present only when DCR is enabled.
*
*/
registration_endpoint?: string
response_types_supported: Array<string>
grant_types_supported: Array<string>
code_challenge_methods_supported: Array<string>
token_endpoint_auth_methods_supported: Array<string>
scopes_supported?: Array<string>
}
/**
* JSON Web Key Set containing the public keys used to verify Cloud JWTs.
*/
@@ -1531,6 +1775,10 @@ export type WorkspaceApiKeyInfo = {
* User-provided label
*/
name: string
/**
* User-provided description of the key's purpose. Limit is byte-based (UTF-8 encoding); 5000 bytes equals 5000 ASCII characters or fewer multi-byte characters.
*/
description: string
/**
* First 8 chars after prefix for display
*/
@@ -1565,6 +1813,10 @@ export type CreateWorkspaceApiKeyResponse = {
* User-provided label
*/
name: string
/**
* User-provided description of the key's purpose. Limit is byte-based (UTF-8 encoding); 5000 bytes equals 5000 ASCII characters or fewer multi-byte characters.
*/
description: string
/**
* The full plaintext API key (only shown once)
*/
@@ -1591,6 +1843,10 @@ export type CreateWorkspaceApiKeyRequest = {
* User-provided label for the key
*/
name: string
/**
* User-provided description of the key's purpose. Limit is byte-based (UTF-8 encoding); 5000 bytes equals 5000 ASCII characters or fewer multi-byte characters.
*/
description?: string
/**
* Optional expiration timestamp
*/
@@ -2270,6 +2526,12 @@ export type ListAssetsResponse = {
* Whether more assets are available beyond this page
*/
has_more: boolean
/**
* Opaque cursor to pass as the `after` query parameter to fetch the
* next page. Omitted from the response when there are no more results.
*
*/
next_cursor?: string
}
/**
@@ -2284,6 +2546,10 @@ export type Asset = {
* Name of the asset file
*/
name: string
/**
* Display name of the asset. Mirrors name for backwards compatibility.
*/
display_name?: string | null
/**
* Blake3 hash of the asset content
*/
@@ -2360,6 +2626,10 @@ export type AssetUpdated = {
* Updated name of the asset
*/
name?: string
/**
* Display name of the asset. Mirrors name for backwards compatibility.
*/
display_name?: string | null
/**
* Blake3 hash of the asset content
*/
@@ -3035,18 +3305,11 @@ export type ExportDownloadUrlResponse = {
expires_at?: string
}
/**
* Error shape returned when request binding or validation fails before the handler runs.
*/
export type BindingErrorResponse = {
message: string
}
/**
* Standard error response with a machine-readable code and human-readable message.
*/
export type ErrorResponse = {
code: string
code?: string
message: string
}
@@ -3124,6 +3387,12 @@ export type ListAssetsResponseWritable = {
* Whether more assets are available beyond this page
*/
has_more: boolean
/**
* Opaque cursor to pass as the `after` query parameter to fetch the
* next page. Omitted from the response when there are no more results.
*
*/
next_cursor?: string
}
/**
@@ -3138,6 +3407,10 @@ export type AssetWritable = {
* Name of the asset file
*/
name: string
/**
* Display name of the asset. Mirrors name for backwards compatibility.
*/
display_name?: string | null
/**
* Blake3 hash of the asset content
*/
@@ -3507,50 +3780,6 @@ export type GetModelsInFolderResponses = {
export type GetModelsInFolderResponse =
GetModelsInFolderResponses[keyof GetModelsInFolderResponses]
export type GetModelPreviewData = {
body?: never
path: {
/**
* The folder name containing the model
*/
folder: string
/**
* The path index (usually 0 for cloud service)
*/
path_index: number
/**
* The model filename (with or without .webp extension)
*/
filename: string
}
query?: never
url: '/api/experiment/models/preview/{folder}/{path_index}/{filename}'
}
export type GetModelPreviewErrors = {
/**
* Model not found or preview not available
*/
404: ErrorResponse
/**
* Internal server error
*/
500: ErrorResponse
}
export type GetModelPreviewError =
GetModelPreviewErrors[keyof GetModelPreviewErrors]
export type GetModelPreviewResponses = {
/**
* Success - Model preview image
*/
200: Blob | File
}
export type GetModelPreviewResponse =
GetModelPreviewResponses[keyof GetModelPreviewResponses]
export type GetLegacyHistoryData = {
body?: never
path?: never
@@ -4012,10 +4241,6 @@ export type ListAssetsData = {
* Sort order
*/
order?: 'asc' | 'desc'
/**
* Filter assets by job IDs (prompt IDs)
*/
job_ids?: Array<string>
/**
* Whether to include public/shared assets in results
*/
@@ -4024,6 +4249,17 @@ export type ListAssetsData = {
* Filter assets by exact content hash
*/
asset_hash?: string
/**
* Opaque cursor for keyset pagination. Pass the `next_cursor` value
* from the previous response to fetch the next page. When provided,
* `offset` is ignored. Cursor pagination is only supported with
* `sort` values `created_at`, `updated_at`, `name`, or `size`;
* requests combining `after` with other sort fields return 400.
* The cursor must have been minted under the same `sort` value used
* in the follow-up request.
*
*/
after?: string
}
url: '/api/assets'
}
@@ -4122,10 +4358,6 @@ export type UploadAssetErrors = {
export type UploadAssetError = UploadAssetErrors[keyof UploadAssetErrors]
export type UploadAssetResponses = {
/**
* Asset already exists (returned existing asset)
*/
200: AssetCreated
/**
* Asset created successfully
*/
@@ -4188,10 +4420,6 @@ export type CreateAssetFromHashError =
CreateAssetFromHashErrors[keyof CreateAssetFromHashErrors]
export type CreateAssetFromHashResponses = {
/**
* Asset reference already exists (returned existing)
*/
200: AssetCreated
/**
* Asset reference created successfully
*/
@@ -5345,19 +5573,19 @@ export type UpdateMultipleSettingsResponses = {
export type UpdateMultipleSettingsResponse =
UpdateMultipleSettingsResponses[keyof UpdateMultipleSettingsResponses]
export type GetSettingByKeyData = {
export type GetSettingByIdData = {
body?: never
path: {
/**
* Setting key to retrieve
* Setting id to retrieve
*/
key: string
id: string
}
query?: never
url: '/api/settings/{key}'
url: '/api/settings/{id}'
}
export type GetSettingByKeyErrors = {
export type GetSettingByIdErrors = {
/**
* Unauthorized
*/
@@ -5368,10 +5596,10 @@ export type GetSettingByKeyErrors = {
404: ErrorResponse
}
export type GetSettingByKeyError =
GetSettingByKeyErrors[keyof GetSettingByKeyErrors]
export type GetSettingByIdError =
GetSettingByIdErrors[keyof GetSettingByIdErrors]
export type GetSettingByKeyResponses = {
export type GetSettingByIdResponses = {
/**
* Setting value response
*/
@@ -5383,25 +5611,25 @@ export type GetSettingByKeyResponses = {
}
}
export type GetSettingByKeyResponse =
GetSettingByKeyResponses[keyof GetSettingByKeyResponses]
export type GetSettingByIdResponse =
GetSettingByIdResponses[keyof GetSettingByIdResponses]
export type UpdateSettingByKeyData = {
export type UpdateSettingByIdData = {
/**
* New value for the setting
*/
body: unknown
path: {
/**
* Setting key to update
* Setting id to update
*/
key: string
id: string
}
query?: never
url: '/api/settings/{key}'
url: '/api/settings/{id}'
}
export type UpdateSettingByKeyErrors = {
export type UpdateSettingByIdErrors = {
/**
* Invalid request
*/
@@ -5412,10 +5640,10 @@ export type UpdateSettingByKeyErrors = {
401: ErrorResponse
}
export type UpdateSettingByKeyError =
UpdateSettingByKeyErrors[keyof UpdateSettingByKeyErrors]
export type UpdateSettingByIdError =
UpdateSettingByIdErrors[keyof UpdateSettingByIdErrors]
export type UpdateSettingByKeyResponses = {
export type UpdateSettingByIdResponses = {
/**
* Updated setting value response
*/
@@ -5427,8 +5655,8 @@ export type UpdateSettingByKeyResponses = {
}
}
export type UpdateSettingByKeyResponse =
UpdateSettingByKeyResponses[keyof UpdateSettingByKeyResponses]
export type UpdateSettingByIdResponse =
UpdateSettingByIdResponses[keyof UpdateSettingByIdResponses]
export type SubmitFeedbackData = {
body: FeedbackRequest
@@ -5916,40 +6144,6 @@ export type UploadMaskResponses = {
* Type of upload (e.g., "output")
*/
type?: string
/**
* Additional metadata for mask detection and re-editing
*/
metadata?: {
/**
* Whether this file is a mask
*/
is_mask?: boolean
/**
* Hash of the original unmasked image
*/
original_hash?: string
/**
* Type of mask (e.g., "painted_masked")
*/
mask_type?: string
/**
* Related mask layer files (if available)
*/
related_files?: {
/**
* Hash of the mask layer
*/
mask?: string
/**
* Hash of the paint layer
*/
paint?: string
/**
* Hash of the painted image
*/
painted?: string
}
}
}
}
@@ -6117,6 +6311,229 @@ export type GetJwksResponses = {
export type GetJwksResponse = GetJwksResponses[keyof GetJwksResponses]
export type GetOAuthAuthorizationServerData = {
body?: never
path?: never
query?: never
url: '/.well-known/oauth-authorization-server'
}
export type GetOAuthAuthorizationServerErrors = {
/**
* OAuth disabled
*/
404: ErrorResponse
}
export type GetOAuthAuthorizationServerError =
GetOAuthAuthorizationServerErrors[keyof GetOAuthAuthorizationServerErrors]
export type GetOAuthAuthorizationServerResponses = {
/**
* Authorization-server metadata
*/
200: OAuthAuthorizationServerMetadata
}
export type GetOAuthAuthorizationServerResponse =
GetOAuthAuthorizationServerResponses[keyof GetOAuthAuthorizationServerResponses]
export type GetOAuthProtectedResourceData = {
body?: never
path?: never
query?: never
url: '/.well-known/oauth-protected-resource'
}
export type GetOAuthProtectedResourceErrors = {
/**
* OAuth disabled or no active resource configured
*/
404: ErrorResponse
}
export type GetOAuthProtectedResourceError =
GetOAuthProtectedResourceErrors[keyof GetOAuthProtectedResourceErrors]
export type GetOAuthProtectedResourceResponses = {
/**
* Protected-resource metadata
*/
200: OAuthProtectedResourceMetadata
}
export type GetOAuthProtectedResourceResponse =
GetOAuthProtectedResourceResponses[keyof GetOAuthProtectedResourceResponses]
export type GetOAuthAuthorizeData = {
body?: never
path?: never
query?: {
response_type?: string
client_id?: string
redirect_uri?: string
scope?: string
/**
* RFC 6749 §10.12 marks `state` as RECOMMENDED. Our hardening makes
* it REQUIRED on the initial-entry path (omitted only on the resume
* path where `oauth_request_id` is supplied instead). This parameter
* is `required: false` at the spec level only because the operation
* is dual-mode (initial entry vs. resume); the runtime parser
* (services/ingest/server/implementation/oauth/protocol/request.go)
* rejects empty `state` on the initial-entry path with a stable
* `invalid_request` 400.
*
*/
state?: string
code_challenge?: string
code_challenge_method?: string
resource?: string
oauth_request_id?: string
}
url: '/oauth/authorize'
}
export type GetOAuthAuthorizeErrors = {
/**
* Invalid authorize request (pre-redirect failure — unknown client, redirect mismatch, malformed params)
*/
400: ErrorResponse
/**
* OAuth disabled
*/
404: ErrorResponse
}
export type GetOAuthAuthorizeError =
GetOAuthAuthorizeErrors[keyof GetOAuthAuthorizeErrors]
export type GetOAuthAuthorizeResponses = {
/**
* Consent challenge payload (cookie present, email verified). Frontend renders the consent UI from this payload and POSTs back to /oauth/authorize.
*
*/
200: OAuthConsentChallenge
}
export type GetOAuthAuthorizeResponse =
GetOAuthAuthorizeResponses[keyof GetOAuthAuthorizeResponses]
export type PostOAuthAuthorizeData = {
body: {
oauth_request_id: string
csrf_token: string
decision: 'allow' | 'deny'
workspace_id: string
}
path?: never
query?: never
url: '/oauth/authorize'
}
export type PostOAuthAuthorizeErrors = {
/**
* Bad request (CSRF mismatch, expired/consumed request, inaccessible workspace)
*/
400: ErrorResponse
/**
* Scope broadening on consent re-grant — fresh consent flow required
*/
403: ErrorResponse
/**
* OAuth disabled
*/
404: ErrorResponse
}
export type PostOAuthAuthorizeError =
PostOAuthAuthorizeErrors[keyof PostOAuthAuthorizeErrors]
export type PostOAuthAuthorizeResponses = {
/**
* Redirect URL for the frontend to navigate to (allow → with code+state; deny → with error+state)
*/
200: OAuthAuthorizeRedirectResponse
}
export type PostOAuthAuthorizeResponse =
PostOAuthAuthorizeResponses[keyof PostOAuthAuthorizeResponses]
export type PostOAuthTokenData = {
body: {
grant_type: 'authorization_code' | 'refresh_token'
client_id: string
code?: string
redirect_uri?: string
code_verifier?: string
refresh_token?: string
scope?: string
client_secret?: string
}
path?: never
query?: never
url: '/oauth/token'
}
export type PostOAuthTokenErrors = {
/**
* RFC 6749 §5.2 error
*/
400: OAuthTokenError
/**
* OAuth disabled
*/
404: ErrorResponse
}
export type PostOAuthTokenError =
PostOAuthTokenErrors[keyof PostOAuthTokenErrors]
export type PostOAuthTokenResponses = {
/**
* New token pair
*/
200: OAuthTokenResponse
}
export type PostOAuthTokenResponse =
PostOAuthTokenResponses[keyof PostOAuthTokenResponses]
export type PostOAuthRegisterData = {
body: OAuthRegisterRequest
path?: never
query?: never
url: '/oauth/register'
}
export type PostOAuthRegisterErrors = {
/**
* Bad request. Two shapes possible: `OAuthRegisterError` (RFC 7591 §3.2.2, emitted by the handler for invalid client metadata, missing application_type, reserved client_name, etc.) OR `BindingErrorResponse` (emitted by the strict-server binding layer when the request body fails OpenAPI-schema validation — malformed JSON, missing required fields, `additionalProperties: false` violations).
*
*/
400: OAuthRegisterBadRequestResponse
/**
* OAuth disabled
*/
404: ErrorResponse
/**
* No active MCP resource is configured — DCR cannot mint a usable client until ops seeds an active oauth_resources row.
*/
503: ErrorResponse
}
export type PostOAuthRegisterError =
PostOAuthRegisterErrors[keyof PostOAuthRegisterErrors]
export type PostOAuthRegisterResponses = {
/**
* Registered. Body echoes the metadata RFC 7591 §3.2.1 requires.
*/
201: OAuthRegisterResponse
}
export type PostOAuthRegisterResponse =
PostOAuthRegisterResponses[keyof PostOAuthRegisterResponses]
export type ListWorkspacesData = {
body?: never
path?: never
@@ -6679,7 +7096,7 @@ export type CreateWorkspaceApiKeyErrors = {
*/
401: ErrorResponse
/**
* Not a workspace member or personal workspace
* Not a workspace member
*/
403: ErrorResponse
/**
@@ -8888,9 +9305,20 @@ export type GetTemplateProxyData = {
export type GetTemplateProxyErrors = {
/**
* Template not found
* Template not found.
*/
404: unknown
/**
* Workflow templates version not available.
*/
503: unknown
}
export type GetTemplateProxyResponses = {
/**
* Template file content streamed from GCS.
*/
200: unknown
}
export type GetHealthData = {
@@ -8918,20 +9346,6 @@ export type GetHealthResponses = {
export type GetHealthResponse = GetHealthResponses[keyof GetHealthResponses]
export type GetOpenapiSpecData = {
body?: never
path?: never
query?: never
url: '/openapi'
}
export type GetOpenapiSpecResponses = {
/**
* OpenAPI specification document
*/
200: unknown
}
export type GetMonitoringTasksData = {
body?: never
path?: never
@@ -9194,6 +9608,33 @@ export type PostCustomNodeProxyResponses = {
200: unknown
}
export type GetModelPreviewData = {
body?: never
path: {
/**
* The folder name containing the model.
*/
folder: string
/**
* The path index (usually 0 for cloud service).
*/
path_index: number
/**
* The model filename (with or without .webp extension).
*/
filename: string
}
query?: never
url: '/api/experiment/models/preview/{folder}/{path_index}/{filename}'
}
export type GetModelPreviewErrors = {
/**
* Preview not available on Cloud
*/
404: unknown
}
export type GetLegacyPromptByIdData = {
body?: never
path: {

View File

@@ -879,6 +879,153 @@ export const zJwkKey = z.object({
y: z.string()
})
/**
* RFC 6749 §5.2 error response.
*/
export const zOAuthTokenError = z.object({
error: z.string(),
error_description: z.string().optional()
})
/**
* RFC 6749 §5.1 successful token response.
*/
export const zOAuthTokenResponse = z.object({
access_token: z.string(),
token_type: z.enum(['Bearer']),
expires_in: z.number().int(),
refresh_token: z.string(),
scope: z.string()
})
/**
* One workspace option presented in the OAuth consent challenge. Promoted to a named schema so the generated Go type is referenceable in handlers and tests rather than re-declared as an anonymous struct at every callsite.
*
*/
export const zOAuthConsentChallengeWorkspace = z.object({
id: z.string(),
name: z.string(),
type: z.enum(['personal', 'team']),
role: z.enum(['owner', 'member'])
})
/**
* Redirect target produced after a JSON consent submission. The frontend must navigate the browser to this URL so custom-scheme client callbacks work without relying on fetch-visible 302 headers.
*/
export const zOAuthAuthorizeRedirectResponse = z.object({
redirect_url: z.string().url()
})
/**
* Server-side state describing the OAuth consent decision the user is being asked to make. Returned by GET /oauth/authorize when a valid Cloud session exists; the frontend renders the consent UI from this payload and POSTs the decision back. Browser never sees the original OAuth params on resume.
*
*/
export const zOAuthConsentChallenge = z.object({
oauth_request_id: z.string().uuid(),
csrf_token: z.string(),
client_display_name: z.string(),
resource_display_name: z.string(),
scopes: z.array(z.string()),
workspaces: z.array(zOAuthConsentChallengeWorkspace)
})
/**
* OAuth 2.1 protected-resource metadata (RFC 9728).
*/
export const zOAuthProtectedResourceMetadata = z.object({
resource: z.string().url(),
authorization_servers: z.array(z.string().url()),
scopes_supported: z.array(z.string()),
bearer_methods_supported: z.array(z.string()).optional()
})
/**
* RFC 7591 §3.2.2 error response.
*/
export const zOAuthRegisterError = z.object({
error: z.enum(['invalid_redirect_uri', 'invalid_client_metadata']),
error_description: z.string().nullish()
})
/**
* Error shape returned when request binding or validation fails before the handler runs.
*/
export const zBindingErrorResponse = z.object({
message: z.string()
})
/**
* Union of the two 400 shapes /oauth/register can emit. `OAuthRegisterError` is the handler-shaped RFC 7591 §3.2.2 error; `BindingErrorResponse` is the strict-server binding-layer error fired when the request body fails OpenAPI-schema validation before the handler runs.
*
*/
export const zOAuthRegisterBadRequestResponse = z.union([
zOAuthRegisterError,
zBindingErrorResponse
])
/**
* RFC 7591 §3.2.1 successful registration response.
*/
export const zOAuthRegisterResponse = z.object({
client_id: z.string(),
client_id_issued_at: z.coerce
.bigint()
.min(BigInt('-9223372036854775808'), {
message: 'Invalid value: Expected int64 to be >= -9223372036854775808'
})
.max(BigInt('9223372036854775807'), {
message: 'Invalid value: Expected int64 to be <= 9223372036854775807'
}),
client_name: z.string().optional(),
redirect_uris: z.array(z.string()),
grant_types: z.array(z.string()),
response_types: z.array(z.string()),
token_endpoint_auth_method: z.enum(['none']),
application_type: z.enum(['native', 'web'])
})
/**
* RFC 7591 §2 client metadata document. Only the fields the server honors are listed; presence of `scope` or `resource_grants` in the request is rejected (`invalid_client_metadata`) because those are server-owned for dynamic clients. `additionalProperties: false` mirrors the runtime middleware that rejects any unknown metadata key.
*
*/
export const zOAuthRegisterRequest = z.object({
redirect_uris: z.array(z.string()).min(1).max(5),
client_name: z.string().max(100).optional(),
application_type: z.enum(['native', 'web']),
token_endpoint_auth_method: z.enum(['none']).optional(),
grant_types: z
.array(z.enum(['authorization_code', 'refresh_token']))
.optional(),
response_types: z.array(z.enum(['code'])).optional(),
scope: z.string().nullish(),
resource_grants: z.record(z.array(z.string())).nullish(),
client_uri: z.string().nullish(),
logo_uri: z.string().nullish(),
tos_uri: z.string().nullish(),
policy_uri: z.string().nullish(),
software_id: z.string().nullish(),
software_version: z.string().nullish(),
contacts: z.array(z.string()).nullish(),
jwks: z.record(z.unknown()).nullish(),
jwks_uri: z.string().nullish()
})
/**
* OAuth 2.1 authorization-server metadata (RFC 8414).
*/
export const zOAuthAuthorizationServerMetadata = z.object({
issuer: z.string().url(),
authorization_endpoint: z.string().url(),
token_endpoint: z.string().url(),
jwks_uri: z.string().url(),
registration_endpoint: z.string().url().optional(),
response_types_supported: z.array(z.string()),
grant_types_supported: z.array(z.string()),
code_challenge_methods_supported: z.array(z.string()),
token_endpoint_auth_methods_supported: z.array(z.string()),
scopes_supported: z.array(z.string()).optional()
})
/**
* JSON Web Key Set containing the public keys used to verify Cloud JWTs.
*/
@@ -940,6 +1087,7 @@ export const zWorkspaceApiKeyInfo = z.object({
workspace_id: z.string(),
user_id: z.string(),
name: z.string(),
description: z.string().max(5000),
key_prefix: z.string(),
expires_at: z.string().datetime().optional(),
last_used_at: z.string().datetime().optional(),
@@ -960,6 +1108,7 @@ export const zListWorkspaceApiKeysResponse = z.object({
export const zCreateWorkspaceApiKeyResponse = z.object({
id: z.string().uuid(),
name: z.string(),
description: z.string().max(5000),
key: z.string(),
key_prefix: z.string(),
expires_at: z.string().datetime().optional(),
@@ -971,6 +1120,7 @@ export const zCreateWorkspaceApiKeyResponse = z.object({
*/
export const zCreateWorkspaceApiKeyRequest = z.object({
name: z.string(),
description: z.string().max(5000).optional(),
expires_at: z.string().datetime().optional()
})
@@ -1353,6 +1503,7 @@ export const zListTagsResponse = z.object({
export const zAsset = z.object({
id: z.string().uuid(),
name: z.string(),
display_name: z.string().nullish(),
asset_hash: z
.string()
.regex(/^blake3:[a-f0-9]{64}$/)
@@ -1385,7 +1536,8 @@ export const zAsset = z.object({
export const zListAssetsResponse = z.object({
assets: z.array(zAsset),
total: z.number().int(),
has_more: z.boolean()
has_more: z.boolean(),
next_cursor: z.string().optional()
})
/**
@@ -1394,6 +1546,7 @@ export const zListAssetsResponse = z.object({
export const zAssetUpdated = z.object({
id: z.string().uuid(),
name: z.string().optional(),
display_name: z.string().nullish(),
asset_hash: z
.string()
.regex(/^blake3:[a-f0-9]{64}$/)
@@ -1753,18 +1906,11 @@ export const zExportDownloadUrlResponse = z.object({
expires_at: z.string().datetime().optional()
})
/**
* Error shape returned when request binding or validation fails before the handler runs.
*/
export const zBindingErrorResponse = z.object({
message: z.string()
})
/**
* Standard error response with a machine-readable code and human-readable message.
*/
export const zErrorResponse = z.object({
code: z.string(),
code: z.string().optional(),
message: z.string()
})
@@ -1796,6 +1942,7 @@ export const zPromptRequest = z.object({
export const zAssetWritable = z.object({
id: z.string().uuid(),
name: z.string(),
display_name: z.string().nullish(),
asset_hash: z
.string()
.regex(/^blake3:[a-f0-9]{64}$/)
@@ -1827,7 +1974,8 @@ export const zAssetWritable = z.object({
export const zListAssetsResponseWritable = z.object({
assets: z.array(zAssetWritable),
total: z.number().int(),
has_more: z.boolean()
has_more: z.boolean(),
next_cursor: z.string().optional()
})
/**
@@ -1961,21 +2109,6 @@ export const zGetModelsInFolderData = z.object({
*/
export const zGetModelsInFolderResponse = z.array(zModelFile)
export const zGetModelPreviewData = z.object({
body: z.never().optional(),
path: z.object({
folder: z.string(),
path_index: z.number().int(),
filename: z.string()
}),
query: z.never().optional()
})
/**
* Success - Model preview image
*/
export const zGetModelPreviewResponse = z.string()
export const zGetLegacyHistoryData = z.object({
body: z.never().optional(),
path: z.never().optional(),
@@ -2132,9 +2265,9 @@ export const zListAssetsData = z.object({
.enum(['name', 'created_at', 'updated_at', 'size', 'last_access_time'])
.optional(),
order: z.enum(['asc', 'desc']).optional(),
job_ids: z.array(z.string().uuid()).optional(),
include_public: z.boolean().optional().default(true),
asset_hash: z.string().optional()
asset_hash: z.string().optional(),
after: z.string().optional()
})
.optional()
})
@@ -2157,7 +2290,7 @@ export const zUploadAssetData = z.object({
})
/**
* Asset already exists (returned existing asset)
* Asset created successfully
*/
export const zUploadAssetResponse = zAssetCreated
@@ -2174,7 +2307,7 @@ export const zCreateAssetFromHashData = z.object({
})
/**
* Asset reference already exists (returned existing)
* Asset reference created successfully
*/
export const zCreateAssetFromHashResponse = zAssetCreated
@@ -2509,10 +2642,10 @@ export const zUpdateMultipleSettingsData = z.object({
*/
export const zUpdateMultipleSettingsResponse = z.record(z.unknown())
export const zGetSettingByKeyData = z.object({
export const zGetSettingByIdData = z.object({
body: z.never().optional(),
path: z.object({
key: z.string()
id: z.string()
}),
query: z.never().optional()
})
@@ -2520,14 +2653,14 @@ export const zGetSettingByKeyData = z.object({
/**
* Setting value response
*/
export const zGetSettingByKeyResponse = z.object({
export const zGetSettingByIdResponse = z.object({
value: z.unknown().optional()
})
export const zUpdateSettingByKeyData = z.object({
export const zUpdateSettingByIdData = z.object({
body: z.unknown(),
path: z.object({
key: z.string()
id: z.string()
}),
query: z.never().optional()
})
@@ -2535,7 +2668,7 @@ export const zUpdateSettingByKeyData = z.object({
/**
* Updated setting value response
*/
export const zUpdateSettingByKeyResponse = z.object({
export const zUpdateSettingByIdResponse = z.object({
value: z.unknown().optional()
})
@@ -2691,21 +2824,7 @@ export const zUploadMaskData = z.object({
export const zUploadMaskResponse = z.object({
name: z.string().optional(),
subfolder: z.string().optional(),
type: z.string().optional(),
metadata: z
.object({
is_mask: z.boolean().optional(),
original_hash: z.string().optional(),
mask_type: z.string().optional(),
related_files: z
.object({
mask: z.string().optional(),
paint: z.string().optional(),
painted: z.string().optional()
})
.optional()
})
.optional()
type: z.string().optional()
})
export const zGetLogsData = z.object({
@@ -2774,6 +2893,101 @@ export const zGetJwksData = z.object({
*/
export const zGetJwksResponse = zJwksResponse
export const zGetOAuthAuthorizationServerData = z.object({
body: z.never().optional(),
path: z.never().optional(),
query: z.never().optional()
})
/**
* Authorization-server metadata
*/
export const zGetOAuthAuthorizationServerResponse =
zOAuthAuthorizationServerMetadata
export const zGetOAuthProtectedResourceData = z.object({
body: z.never().optional(),
path: z.never().optional(),
query: z.never().optional()
})
/**
* Protected-resource metadata
*/
export const zGetOAuthProtectedResourceResponse =
zOAuthProtectedResourceMetadata
export const zGetOAuthAuthorizeData = z.object({
body: z.never().optional(),
path: z.never().optional(),
query: z
.object({
response_type: z.string().optional(),
client_id: z.string().optional(),
redirect_uri: z.string().optional(),
scope: z.string().optional(),
state: z.string().optional(),
code_challenge: z.string().optional(),
code_challenge_method: z.string().optional(),
resource: z.string().optional(),
oauth_request_id: z.string().optional()
})
.optional()
})
/**
* Consent challenge payload (cookie present, email verified). Frontend renders the consent UI from this payload and POSTs back to /oauth/authorize.
*
*/
export const zGetOAuthAuthorizeResponse = zOAuthConsentChallenge
export const zPostOAuthAuthorizeData = z.object({
body: z.object({
oauth_request_id: z.string().uuid(),
csrf_token: z.string(),
decision: z.enum(['allow', 'deny']),
workspace_id: z.string()
}),
path: z.never().optional(),
query: z.never().optional()
})
/**
* Redirect URL for the frontend to navigate to (allow → with code+state; deny → with error+state)
*/
export const zPostOAuthAuthorizeResponse = zOAuthAuthorizeRedirectResponse
export const zPostOAuthTokenData = z.object({
body: z.object({
grant_type: z.enum(['authorization_code', 'refresh_token']),
client_id: z.string(),
code: z.string().optional(),
redirect_uri: z.string().optional(),
code_verifier: z.string().optional(),
refresh_token: z.string().optional(),
scope: z.string().optional(),
client_secret: z.string().optional()
}),
path: z.never().optional(),
query: z.never().optional()
})
/**
* New token pair
*/
export const zPostOAuthTokenResponse = zOAuthTokenResponse
export const zPostOAuthRegisterData = z.object({
body: zOAuthRegisterRequest,
path: z.never().optional(),
query: z.never().optional()
})
/**
* Registered. Body echoes the metadata RFC 7591 §3.2.1 requires.
*/
export const zPostOAuthRegisterResponse = zOAuthRegisterResponse
export const zListWorkspacesData = z.object({
body: z.never().optional(),
path: z.never().optional(),
@@ -3671,12 +3885,6 @@ export const zGetHealthData = z.object({
*/
export const zGetHealthResponse = z.string()
export const zGetOpenapiSpecData = z.object({
body: z.never().optional(),
path: z.never().optional(),
query: z.never().optional()
})
export const zGetMonitoringTasksData = z.object({
body: z.never().optional(),
path: z.never().optional(),
@@ -3757,6 +3965,16 @@ export const zPostCustomNodeProxyData = z.object({
query: z.never().optional()
})
export const zGetModelPreviewData = z.object({
body: z.never().optional(),
path: z.object({
folder: z.string(),
path_index: z.number().int(),
filename: z.string()
}),
query: z.never().optional()
})
export const zGetLegacyPromptByIdData = z.object({
body: z.never().optional(),
path: z.object({

View File

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

View File

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

View File

@@ -18,6 +18,5 @@
},
"devDependencies": {
"typescript": "catalog:"
},
"packageManager": "pnpm@10.17.1"
}
}

View File

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

3320
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -2,6 +2,11 @@ packages:
- apps/**
- packages/**
ignoreWorkspaceRootCheck: true
catalogMode: prefer
publicHoistPattern:
- '@parcel/watcher'
catalog:
'@alloc/quick-lru': ^5.2.0
'@astrojs/check': ^0.9.8
@@ -16,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
@@ -31,7 +32,7 @@ catalog:
'@primevue/themes': ^4.2.5
'@sentry/vite-plugin': ^4.6.0
'@sentry/vue': ^10.32.1
'@sparkjsdev/spark': ^0.1.10
'@sparkjsdev/spark': ^2.1.0
'@storybook/addon-docs': ^10.2.10
'@storybook/addon-mcp': 0.1.6
'@storybook/vue3': ^10.2.10
@@ -54,7 +55,7 @@ catalog:
'@types/jsdom': ^21.1.7
'@types/node': ^24.1.0
'@types/semver': ^7.7.0
'@types/three': ^0.170.0
'@types/three': ^0.184.1
'@vee-validate/zod': ^4.15.1
'@vercel/analytics': ^2.0.1
'@vitejs/plugin-vue': ^6.0.0
@@ -97,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
@@ -113,7 +113,7 @@ catalog:
storybook: ^10.2.10
stylelint: ^16.26.1
tailwindcss: ^4.3.0
three: ^0.170.0
three: ^0.184.0
tailwindcss-primeui: ^0.6.1
tsx: ^4.15.6
tw-animate-css: ^1.3.8
@@ -144,22 +144,19 @@ catalog:
cleanupUnusedCatalogs: true
ignoredBuiltDependencies:
- '@firebase/util'
- protobufjs
- unrs-resolver
- vue-demi
onlyBuiltDependencies:
- '@playwright/browser-chromium'
- '@playwright/browser-firefox'
- '@playwright/browser-webkit'
- '@sentry/cli'
- '@tailwindcss/oxide'
- esbuild
- nx
- oxc-resolver
allowBuilds:
'@firebase/util': false
'@sentry/cli': true
'@tailwindcss/oxide': true
core-js: false
esbuild: true
oxc-resolver: true
protobufjs: false
sharp: false
unrs-resolver: false
vue-demi: false
overrides:
vite: 'catalog:'
'@tiptap/pm': 2.27.2
'@types/eslint': '-'

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

@@ -541,32 +541,26 @@ onMounted(async () => {
}
vueNodeLifecycle.setupEmptyGraphListener()
// Load color palette
colorPaletteStore.customPalettes = settingStore.get(
'Comfy.CustomColorPalettes'
)
// Restore saved workflow and workflow tabs state
await workflowPersistence.initializeWorkflow()
await workflowPersistence.restoreWorkflowTabsState()
await workflowPersistence.loadTemplateFromUrlIfPresent()
} finally {
workspaceStore.spinner = false
}
await workflowPersistence.loadSharedWorkflowFromUrlIfPresent()
comfyApp.canvas.onSelectionChange = useChainCallback(
comfyApp.canvas.onSelectionChange,
() => canvasStore.updateSelectedItems()
)
// Load color palette
colorPaletteStore.customPalettes = settingStore.get(
'Comfy.CustomColorPalettes'
)
// Restore saved workflow and workflow tabs state
await workflowPersistence.initializeWorkflow()
await workflowPersistence.restoreWorkflowTabsState()
const sharedWorkflowLoadStatus =
await workflowPersistence.loadSharedWorkflowFromUrlIfPresent()
// Load template from URL if present
if (sharedWorkflowLoadStatus === 'not-present') {
await workflowPersistence.loadTemplateFromUrlIfPresent()
}
// Accept workspace invite from URL if present (e.g., ?invite=TOKEN)
// WorkspaceAuthGate ensures flag state is resolved before GraphCanvas mounts
if (inviteUrlLoader && flags.teamWorkspacesEnabled) {

View File

@@ -4,8 +4,6 @@
@mouseenter="handleMouseEnter"
@mouseleave="handleMouseLeave"
@pointerdown.stop
@pointermove.stop
@pointerup.stop
>
<Load3DScene
v-if="node"

View File

@@ -5,11 +5,7 @@
data-capture-wheel="true"
tabindex="-1"
@pointerdown.stop="focusContainer"
@pointermove.stop
@pointerup.stop
@mousedown.stop
@mousemove.stop
@mouseup.stop
@contextmenu.stop.prevent
@dragover.prevent.stop="handleDragOver"
@dragleave.stop="handleDragLeave"

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

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

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

@@ -25,17 +25,19 @@ type Searcher = NonNullable<ComponentProps<typeof AsyncSearchInput>['searcher']>
function renderSearch(
initialQuery: string = '',
searcher?: Searcher,
updateKey?: { value: unknown }
updateKey?: { value: unknown },
onEnter?: (event: KeyboardEvent) => void
) {
const query = ref(initialQuery)
const key = updateKey
const Harness = defineComponent({
components: { AsyncSearchInput },
setup: () => ({ query, searcher, key }),
setup: () => ({ query, searcher, key, onEnter }),
template: `<AsyncSearchInput
v-model="query"
:searcher="searcher"
:update-key="key"
@enter="onEnter"
/>`
})
const utils = render(Harness, { global: { plugins: [i18n] } })
@@ -63,6 +65,14 @@ describe('AsyncSearchInput', () => {
await user.type(screen.getByRole('textbox'), 'abc')
expect(query.value).toBe('abc')
})
it('emits enter when the user presses Enter in the textbox', async () => {
const onEnter = vi.fn()
renderSearch('', undefined, undefined, onEnter)
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime })
await user.type(screen.getByRole('textbox'), '{Enter}')
expect(onEnter).toHaveBeenCalledTimes(1)
})
})
describe('Clear button', () => {

View File

@@ -23,6 +23,9 @@ const {
debounceMaxWaitMs?: number
class?: HTMLAttributes['class']
}>()
const emit = defineEmits<{
enter: [event: KeyboardEvent]
}>()
const searchQuery = defineModel<string>({ default: '' })
@@ -62,6 +65,11 @@ function handleFocus(event: FocusEvent) {
target.select()
}
}
function handleKeydownEnter(event: KeyboardEvent) {
if (event.isComposing) return
emit('enter', event)
}
</script>
<template>
@@ -97,6 +105,7 @@ function handleFocus(event: FocusEvent) {
:placeholder="$t('g.searchPlaceholder', { subject: '' })"
:autofocus
@focus="handleFocus"
@keydown.enter="handleKeydownEnter"
/>
<button
v-if="searchQuery.trim().length > 0"

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

@@ -3,20 +3,27 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
import type { useNodeDragToCanvas as UseNodeDragToCanvasType } from './useNodeDragToCanvas'
const { mockAddNodeOnGraph, mockConvertEventToCanvasOffset, mockCanvas } =
vi.hoisted(() => {
const mockConvertEventToCanvasOffset = vi.fn()
return {
mockAddNodeOnGraph: vi.fn(),
mockConvertEventToCanvasOffset,
mockCanvas: {
canvas: {
getBoundingClientRect: vi.fn()
},
convertEventToCanvasOffset: mockConvertEventToCanvasOffset
}
const {
mockAddNodeOnGraph,
mockConvertEventToCanvasOffset,
mockSelectItems,
mockCanvas
} = vi.hoisted(() => {
const mockConvertEventToCanvasOffset = vi.fn()
const mockSelectItems = vi.fn()
return {
mockAddNodeOnGraph: vi.fn(),
mockConvertEventToCanvasOffset,
mockSelectItems,
mockCanvas: {
canvas: {
getBoundingClientRect: vi.fn()
},
convertEventToCanvasOffset: mockConvertEventToCanvasOffset,
selectItems: mockSelectItems
}
})
}
})
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
useCanvasStore: vi.fn(() => ({
@@ -119,6 +126,11 @@ describe('useNodeDragToCanvas', () => {
'pointermove',
expect.any(Function)
)
expect(addEventListenerSpy).toHaveBeenCalledWith(
'pointerdown',
expect.any(Function),
true
)
expect(addEventListenerSpy).toHaveBeenCalledWith(
'pointerup',
expect.any(Function),
@@ -239,6 +251,57 @@ describe('useNodeDragToCanvas', () => {
expect(isDragging.value).toBe(true)
})
it('should select the placed node when one is returned from the graph', () => {
mockCanvas.canvas.getBoundingClientRect.mockReturnValue({
left: 0,
right: 500,
top: 0,
bottom: 500
})
mockConvertEventToCanvasOffset.mockReturnValue([150, 150])
const placedNode = { id: 1 }
mockAddNodeOnGraph.mockReturnValue(placedNode)
const { startDrag, setupGlobalListeners } = useNodeDragToCanvas()
setupGlobalListeners()
startDrag(mockNodeDef)
document.dispatchEvent(
new PointerEvent('pointerup', {
clientX: 250,
clientY: 250,
bubbles: true
})
)
expect(mockSelectItems).toHaveBeenCalledWith([placedNode])
})
it('should not call selectItems when graph returns no node', () => {
mockCanvas.canvas.getBoundingClientRect.mockReturnValue({
left: 0,
right: 500,
top: 0,
bottom: 500
})
mockConvertEventToCanvasOffset.mockReturnValue([150, 150])
mockAddNodeOnGraph.mockReturnValue(null)
const { startDrag, setupGlobalListeners } = useNodeDragToCanvas()
setupGlobalListeners()
startDrag(mockNodeDef)
document.dispatchEvent(
new PointerEvent('pointerup', {
clientX: 250,
clientY: 250,
bubbles: true
})
)
expect(mockSelectItems).not.toHaveBeenCalled()
})
it('should not add node on pointerup when in native drag mode', () => {
mockCanvas.canvas.getBoundingClientRect.mockReturnValue({
left: 0,
@@ -339,4 +402,58 @@ describe('useNodeDragToCanvas', () => {
expect(dragMode.value).toBe('click')
})
})
describe('blockCommitPointerDown', () => {
function dispatchPointerDown(x: number, y: number) {
const event = new PointerEvent('pointerdown', {
clientX: x,
clientY: y,
bubbles: true,
cancelable: true
})
const stopSpy = vi.spyOn(event, 'stopImmediatePropagation')
document.dispatchEvent(event)
return stopSpy
}
beforeEach(() => {
mockCanvas.canvas.getBoundingClientRect.mockReturnValue({
left: 0,
right: 500,
top: 0,
bottom: 500
})
})
it('should stop propagation when in click-drag mode over canvas', () => {
const { startDrag, setupGlobalListeners } = useNodeDragToCanvas()
setupGlobalListeners()
startDrag(mockNodeDef)
expect(dispatchPointerDown(250, 250)).toHaveBeenCalled()
})
it('should not stop propagation when not dragging', () => {
const { setupGlobalListeners } = useNodeDragToCanvas()
setupGlobalListeners()
expect(dispatchPointerDown(250, 250)).not.toHaveBeenCalled()
})
it('should not stop propagation in native drag mode', () => {
const { startDrag, setupGlobalListeners } = useNodeDragToCanvas()
setupGlobalListeners()
startDrag(mockNodeDef, 'native')
expect(dispatchPointerDown(250, 250)).not.toHaveBeenCalled()
})
it('should not stop propagation when pointer is outside canvas', () => {
const { startDrag, setupGlobalListeners } = useNodeDragToCanvas()
setupGlobalListeners()
startDrag(mockNodeDef)
expect(dispatchPointerDown(600, 250)).not.toHaveBeenCalled()
})
})
})

View File

@@ -22,31 +22,33 @@ function cancelDrag() {
dragMode.value = 'click'
}
function addNodeAtPosition(clientX: number, clientY: number): boolean {
if (!draggedNode.value) return false
const canvasStore = useCanvasStore()
const canvas = canvasStore.canvas
if (!canvas) return false
const canvasElement = canvas.canvas as HTMLCanvasElement
function isOverCanvas(clientX: number, clientY: number): boolean {
const canvasElement = useCanvasStore().canvas?.canvas as
| HTMLCanvasElement
| undefined
if (!canvasElement) return false
const rect = canvasElement.getBoundingClientRect()
const isOverCanvas =
return (
clientX >= rect.left &&
clientX <= rect.right &&
clientY >= rect.top &&
clientY <= rect.bottom
)
}
if (isOverCanvas) {
const pos = canvas.convertEventToCanvasOffset({
clientX,
clientY
} as PointerEvent)
const litegraphService = useLitegraphService()
litegraphService.addNodeOnGraph(draggedNode.value, { pos })
return true
}
return false
function addNodeAtPosition(clientX: number, clientY: number): boolean {
if (!draggedNode.value) return false
const canvas = useCanvasStore().canvas
if (!canvas) return false
if (!isOverCanvas(clientX, clientY)) return false
const pos = canvas.convertEventToCanvasOffset({
clientX,
clientY
} as PointerEvent)
const node = useLitegraphService().addNodeOnGraph(draggedNode.value, { pos })
if (node) canvas.selectItems([node])
return true
}
function endDrag(e: PointerEvent) {
@@ -64,11 +66,19 @@ function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') cancelDrag()
}
// Prevent LiteGraph's empty-canvas hit-test from deselecting the placed node on pointerup.
function blockCommitPointerDown(e: PointerEvent) {
if (!isDragging.value || dragMode.value !== 'click') return
if (!isOverCanvas(e.clientX, e.clientY)) return
e.stopImmediatePropagation()
}
function setupGlobalListeners() {
if (listenersSetup) return
listenersSetup = true
document.addEventListener('pointermove', updatePosition)
document.addEventListener('pointerdown', blockCommitPointerDown, true)
document.addEventListener('pointerup', endDrag, true)
document.addEventListener('keydown', handleKeydown)
}
@@ -78,6 +88,7 @@ function cleanupGlobalListeners() {
listenersSetup = false
document.removeEventListener('pointermove', updatePosition)
document.removeEventListener('pointerdown', blockCommitPointerDown, true)
document.removeEventListener('pointerup', endDrag, true)
document.removeEventListener('keydown', handleKeydown)

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

@@ -1,3 +1,4 @@
import { SparkRenderer } from '@sparkjsdev/spark'
import * as THREE from 'three'
import type { OrbitControls } from 'three/examples/jsm/controls/OrbitControls'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
@@ -137,6 +138,13 @@ describe('SceneManager', () => {
expect(manager.scene.children).toContain(manager.gridHelper)
})
it('adds a SparkRenderer to the scene so SplatMesh instances render', () => {
const sparkRenderers = manager.scene.children.filter(
(child) => child instanceof SparkRenderer
)
expect(sparkRenderers).toHaveLength(1)
})
it('builds a separate background scene with a tiled mesh', () => {
expect(manager.backgroundScene).toBeInstanceOf(THREE.Scene)
expect(manager.backgroundMesh).toBeInstanceOf(THREE.Mesh)

View File

@@ -1,3 +1,4 @@
import { SparkRenderer } from '@sparkjsdev/spark'
import * as THREE from 'three'
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls'
@@ -11,6 +12,7 @@ import {
export class SceneManager implements SceneManagerInterface {
scene!: THREE.Scene
gridHelper: THREE.GridHelper
private sparkRenderer: SparkRenderer
backgroundScene!: THREE.Scene
backgroundCamera: THREE.OrthographicCamera
@@ -42,6 +44,12 @@ export class SceneManager implements SceneManagerInterface {
this.getActiveCamera = getActiveCamera
// Spark 2.x requires a SparkRenderer in the scene tree to render SplatMesh
// (Gaussian splat) instances; without it splats are silent no-ops. Kept
// alive across model reloads by SceneModelManager.clearModel.
this.sparkRenderer = new SparkRenderer({ renderer })
this.scene.add(this.sparkRenderer)
this.gridHelper = new THREE.GridHelper(20, 20)
this.gridHelper.position.set(0, 0, 0)
this.scene.add(this.gridHelper)
@@ -277,8 +285,8 @@ export class SceneManager implements SceneManagerInterface {
if (!material.map) return
const imageAspect =
backgroundTexture.image.width / backgroundTexture.image.height
const image = backgroundTexture.image as { width: number; height: number }
const imageAspect = image.width / image.height
const targetAspect = targetWidth / targetHeight
if (imageAspect > targetAspect) {

View File

@@ -1,3 +1,4 @@
import { SparkRenderer } from '@sparkjsdev/spark'
import * as THREE from 'three'
import { describe, expect, it, vi } from 'vitest'
@@ -355,6 +356,20 @@ describe('SceneModelManager', () => {
expect(geoDispose).toHaveBeenCalled()
expect(matDispose).toHaveBeenCalled()
})
it('preserves SparkRenderer across model reloads', async () => {
const { manager, scene } = createManager()
const sparkRenderer = new SparkRenderer({
renderer: {} as THREE.WebGLRenderer
})
scene.add(sparkRenderer)
const model = createMeshModel()
await manager.setupModel(model)
manager.clearModel()
expect(scene.children).toContain(sparkRenderer)
})
})
describe('reset', () => {

View File

@@ -1,3 +1,4 @@
import { SparkRenderer } from '@sparkjsdev/spark'
import * as THREE from 'three'
import type { GLTF } from 'three/examples/jsm/loaders/GLTFLoader'
@@ -317,6 +318,7 @@ export class SceneModelManager implements ModelManagerInterface {
object instanceof THREE.GridHelper ||
object instanceof THREE.Light ||
object instanceof THREE.Camera ||
object instanceof SparkRenderer ||
object.name === 'GizmoTransformControls'
if (!isEnvironmentObject) {

View File

@@ -800,6 +800,8 @@
"CONTROL_NET": "ControlNet",
"CURVE": "منحنى",
"ELEVENLABS_VOICE": "ELEVENLABS_VOICE",
"FACE_DETECTION_MODEL": "نموذج كشف الوجه",
"FACE_LANDMARKS": "معالم الوجه",
"FILE_3D": "ملف ثلاثي الأبعاد",
"FILE_3D_FBX": "ملف FBX ثلاثي الأبعاد",
"FILE_3D_GLB": "ملف GLB ثلاثي الأبعاد",
@@ -2278,6 +2280,7 @@
"Meshy": "Meshy",
"MiniMax": "MiniMax",
"OpenAI": "OpenAI",
"OpenRouter": "OpenRouter",
"PixVerse": "PixVerse",
"Quiver": "Quiver",
"Recraft": "Recraft",
@@ -2294,6 +2297,7 @@
"Vidu": "فيدو",
"Wan": "وان",
"WaveSpeed": "WaveSpeed",
"adjustments": "تعديلات",
"advanced": "متقدم",
"api node": "عقدة API",
"attention_experiments": "تجارب الانتباه",
@@ -2443,7 +2447,8 @@
"nonPublicAssetsWarningLine1": "يأتي هذا سير العمل مع أصول غير عامة.",
"nonPublicAssetsWarningLine2": "سيتم استيراد هذه الأصول إلى مكتبتك عند فتح سير العمل",
"openWithoutImporting": "فتح بدون استيراد",
"openWorkflow": "فتح سير العمل"
"openWorkflow": "فتح سير العمل",
"opening": "جارٍ فتح سير العمل المشترك..."
},
"painter": {
"background": "الخلفية",
@@ -3620,7 +3625,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"
},
@@ -5699,6 +5709,7 @@
}
},
"ImageCropV2": {
"description": "قص الصورة إلى الأبعاد المحددة.",
"display_name": "قص الصورة",
"inputs": {
"crop_region": {
@@ -5719,6 +5730,7 @@
}
},
"ImageDeduplication": {
"description": "إزالة الصور المكررة أو المتشابهة جداً من القائمة.",
"display_name": "إزالة تكرار الصور",
"inputs": {
"images": {
@@ -5773,6 +5785,7 @@
}
},
"ImageGrid": {
"description": "ترتيب عدة صور في شبكة.",
"display_name": "شبكة الصور",
"inputs": {
"cell_height": {
@@ -8366,6 +8379,7 @@
}
},
"LoadImageDataSetFromFolder": {
"description": "تحميل مجموعة بيانات من الصور من مجلد محدد وإرجاع قائمة بالصور. الصيغ المدعومة: PNG، JPG، JPEG، WEBP.",
"display_name": "تحميل مجموعة بيانات الصور من مجلد",
"inputs": {
"folder": {
@@ -8409,6 +8423,7 @@
}
},
"LoadImageTextDataSetFromFolder": {
"description": "تحميل مجموعة بيانات من أزواج الصور والتعليقات النصية من مجلد محدد وإرجاعها كقائمة. الصيغ المدعومة: PNG، JPG، JPEG، WEBP.",
"display_name": "تحميل مجموعة بيانات الصور والنصوص من مجلد",
"inputs": {
"folder": {
@@ -8435,6 +8450,20 @@
}
}
},
"LoadMediaPipeFaceLandmarker": {
"display_name": "تحميل MediaPipe Face Landmarker",
"inputs": {
"model_name": {
"name": "model_name",
"tooltip": "ملف safetensors الخاص بـ Face Landmarker من models/mediapipe/."
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"LoadMoGeModel": {
"display_name": "تحميل نموذج MoGe",
"inputs": {
@@ -8449,6 +8478,7 @@
}
},
"LoadTrainingDataset": {
"description": "تحميل مجموعة بيانات التدريب المشفرة (الفضاء الكامن + الشروط) من القرص لاستخدامها في التدريب.",
"display_name": "تحميل مجموعة بيانات التدريب",
"inputs": {
"folder_name": {
@@ -9232,6 +9262,7 @@
}
},
"MakeTrainingDataset": {
"description": "ترميز الصور باستخدام VAE والنصوص باستخدام CLIP لإنشاء مجموعة بيانات تدريبية من الفضاء الكامن والشروط.",
"display_name": "إنشاء مجموعة بيانات تدريبية",
"inputs": {
"clip": {
@@ -9322,7 +9353,97 @@
}
}
},
"MediaPipeFaceLandmarker": {
"description": "اكتشاف معالم الوجه باستخدام نموذج MediaPipe.",
"display_name": "MediaPipe Face Landmarker",
"inputs": {
"detector_variant": {
"name": "detector_variant",
"tooltip": "نطاق كاشف الوجه. 'short' مخصص للوجوه القريبة (ضمن ~٢ متر من الكاميرا)؛ 'full' يغطي الوجوه البعيدة/الأصغر (حتى ~٥ متر) لكنه أبطأ. 'both' يشغل كلا الكاشفين ويحتفظ بالكاشف الذي وجد وجوهًا أكثر في كل إطار (تكلفة كشف مضاعفة تقريبًا)."
},
"face_detection_model": {
"name": "face_detection_model"
},
"image": {
"name": "الصورة"
},
"min_confidence": {
"name": "min_confidence",
"tooltip": "عتبة درجة BlazeFace. خفضها لالتقاط الوجوه الصغيرة/المحجوبة."
},
"missing_frame_fallback": {
"name": "missing_frame_fallback",
"tooltip": "سلوك كل إطار عند فشل الكشف في دفعة. 'empty' يترك الإطار بدون وجه. 'previous' ينسخ آخر كشف ناجح. 'interpolate' يقوم بتقريب المعالم/الصندوق/أشكال المزج بين الإطارات الناجحة المحيطة. في حالة تعدد الوجوه: يتم إقران الوجوه عبر الإطارات بأقرب مركز صندوق."
},
"num_faces": {
"name": "num_faces",
"tooltip": "أقصى عدد للوجوه التي يتم إرجاعها في كل إطار. ٠ = بدون حد (إرجاع جميع الوجوه المكتشفة)."
}
},
"outputs": {
"0": {
"name": "face_landmarks",
"tooltip": null
},
"1": {
"name": "bboxes",
"tooltip": null
}
}
},
"MediaPipeFaceMask": {
"description": "رسم قناع باستخدام معالم الوجه.",
"display_name": "MediaPipe Face Mask",
"inputs": {
"face_landmarks": {
"name": "face_landmarks"
},
"regions": {
"name": "regions",
"tooltip": "'all' = اتحاد face_oval+lips+eyes+irises (والذي يختصر إلى face_oval لأنه يحيط بالباقي). 'custom' = تفعيل كل منطقة بشكل فردي لتكوينات مثل lips+eyes."
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"MediaPipeFaceMeshVisualize": {
"description": "رسم شبكة معالم الوجه على الصورة المدخلة.",
"display_name": "تصوير شبكة وجه MediaPipe",
"inputs": {
"color": {
"name": "color"
},
"connections": {
"name": "connections",
"tooltip": "'all' = oval+eyes+brows+lips+irises+nose. 'fill' = مضلع face_oval صلب (قناع صورة ظلية). 'custom' = تفعيل كل ميزة بشكل فردي (بما في ذلك 'tesselation'، شبكة الأسلاك الكاملة ذات ٢٥٤٧ حافة)."
},
"face_landmarks": {
"name": "face_landmarks"
},
"image": {
"name": "الصورة",
"tooltip": "إذا لم يتم توصيلها، سيتم استخدام لوحة سوداء."
},
"point_size": {
"name": "point_size",
"tooltip": "نصف قطر نقطة المعلم بالبكسل. ٠ يعطل رسم النقاط."
},
"thickness": {
"name": "thickness",
"tooltip": "سُمك خط الحافة بالبكسل. ٠ يعطل رسم الحواف."
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"MergeImageLists": {
"description": "دمج عدة قوائم صور في قائمة واحدة.",
"display_name": "دمج قوائم الصور",
"inputs": {
"images": {
@@ -9777,6 +9898,7 @@
}
},
"MoGeInference": {
"description": "تشغيل MoGe على صورة واحدة لتقدير العمق والهندسة.",
"display_name": "استدلال MoGe",
"inputs": {
"apply_mask": {
@@ -9813,6 +9935,7 @@
}
},
"MoGePanoramaInference": {
"description": "تشغيل MoGe على صورة بانورامية إكويركتانجولار عن طريق تقسيمها إلى ١٢ منظوراً، إجراء الاستدلال على كل منها، ودمج النتائج في خريطة عمق واحدة.",
"display_name": "استدلال MoGe بانوراما",
"inputs": {
"batch_size": {
@@ -9847,6 +9970,7 @@
}
},
"MoGePointMapToMesh": {
"description": "تحويل خريطة نقاط MoGe إلى شبكة ثلاثية الأبعاد.",
"display_name": "MoGe تحويل خريطة النقاط إلى شبكة",
"inputs": {
"batch_index": {
@@ -9876,6 +10000,7 @@
}
},
"MoGeRender": {
"description": "عرض خريطة عمق أو خريطة عادية من بيانات الهندسة.",
"display_name": "MoGe عرض",
"inputs": {
"moge_geometry": {
@@ -12164,6 +12289,7 @@
}
},
"NormalizeImages": {
"description": "تطبيع الصور باستخدام المتوسط والانحراف المعياري.",
"display_name": "تطبيع الصور",
"inputs": {
"images": {
@@ -12493,6 +12619,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": {
@@ -13306,6 +13465,7 @@
}
},
"RandomCropImages": {
"description": "قص الصورة بشكل عشوائي إلى الأبعاد المحددة.",
"display_name": "قص عشوائي للصور",
"inputs": {
"control_after_generate": {
@@ -14168,6 +14328,7 @@
}
},
"ResizeImagesByLongerEdge": {
"description": "تغيير حجم الصور بحيث يتطابق الحافة الأطول مع البعد المحدد مع الحفاظ على نسبة العرض إلى الارتفاع.",
"display_name": "تغيير حجم الصور حسب الحافة الأطول",
"inputs": {
"images": {
@@ -14187,6 +14348,7 @@
}
},
"ResizeImagesByShorterEdge": {
"description": "تغيير حجم الصور بحيث يتطابق الحافة الأقصر مع البُعد المحدد مع الحفاظ على نسبة العرض إلى الارتفاع.",
"display_name": "تغيير حجم الصور حسب الحافة الأقصر",
"inputs": {
"images": {
@@ -14206,6 +14368,7 @@
}
},
"ResolutionBucket": {
"description": "تجميع الـ latent والتهيئات في مجموعات (buckets)",
"display_name": "تجميع الدقة",
"inputs": {
"conditioning": {
@@ -15550,6 +15713,7 @@
}
},
"SaveImageDataSetToFolder": {
"description": "حفظ مجموعة بيانات من الصور في مجلد محدد. الصيغ المدعومة: PNG.",
"display_name": "حفظ مجموعة بيانات الصور في مجلد",
"inputs": {
"filename_prefix": {
@@ -15567,6 +15731,7 @@
}
},
"SaveImageTextDataSetToFolder": {
"description": "حفظ مجموعة بيانات من أزواج الصور والتعليقات النصية في مجلد محدد. تُحفظ الصور كملفات PNG والتعليقات كملفات TXT بنفس بادئة اسم الملف.",
"display_name": "حفظ مجموعة بيانات الصور والنصوص في مجلد",
"inputs": {
"filename_prefix": {
@@ -15637,6 +15802,7 @@
}
},
"SaveTrainingDataset": {
"description": "حفظ مجموعة بيانات التدريب المشفرة (latents + التهيئة) على القرص لتحميلها بكفاءة أثناء التدريب.",
"display_name": "حفظ مجموعة بيانات التدريب",
"inputs": {
"conditioning": {
@@ -15823,6 +15989,7 @@
}
},
"ShuffleDataset": {
"description": "تبديل ترتيب الصور في القائمة بشكل عشوائي.",
"display_name": "تبديل ترتيب مجموعة بيانات الصور",
"inputs": {
"control_after_generate": {
@@ -15845,6 +16012,7 @@
}
},
"ShuffleImageTextDataset": {
"description": "تبديل ترتيب أزواج الصورة والنص في القائمة بشكل عشوائي.",
"display_name": "تبديل ترتيب مجموعة بيانات الصور والنصوص",
"inputs": {
"control_after_generate": {
@@ -16661,6 +16829,23 @@
}
}
},
"StringFormat": {
"description": "مماثل لطريقة تنسيق السلاسل النصية في بايثون. يدعم جميع خيارات وميزات التنسيق في بايثون.",
"display_name": "تنسيق النص",
"inputs": {
"f_string": {
"name": "f_string"
},
"values": {
"name": "values"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"StringLength": {
"display_name": "الطول",
"inputs": {
@@ -19328,6 +19513,7 @@
}
},
"VoxelToMesh": {
"description": "تحويل شبكة فوكسل إلى شبكة مضلعة (mesh).",
"display_name": "تحويل الفوكسل إلى شبكة",
"inputs": {
"algorithm": {
@@ -19347,6 +19533,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,6 +1785,8 @@
"CONTROL_NET": "CONTROL_NET",
"CURVE": "CURVE",
"ELEVENLABS_VOICE": "ELEVENLABS_VOICE",
"FACE_DETECTION_MODEL": "FACE_DETECTION_MODEL",
"FACE_LANDMARKS": "FACE_LANDMARKS",
"FILE_3D": "FILE_3D",
"FILE_3D_FBX": "FILE_3D_FBX",
"FILE_3D_GLB": "FILE_3D_GLB",
@@ -2135,6 +2139,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",
@@ -2707,7 +2748,8 @@
"placeholderMesh": "Select mesh...",
"placeholderModel": "Select model...",
"placeholderUnknown": "Select media...",
"maxSelectionReached": "Maximum selection limit reached"
"maxSelectionReached": "Maximum selection limit reached",
"topResult": "Top result: {result}"
},
"valueControl": {
"header": {

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"
}
@@ -5700,6 +5710,7 @@
},
"ImageCropV2": {
"display_name": "Crop Image",
"description": "Crop an image to the specified dimensions.",
"inputs": {
"image": {
"name": "image"
@@ -5719,7 +5730,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 +5785,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 +7958,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 +8002,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": {
@@ -8014,6 +8029,20 @@
}
}
},
"LoadMediaPipeFaceLandmarker": {
"display_name": "Load Face Detection Model (MediaPipe)",
"inputs": {
"model_name": {
"name": "model_name",
"tooltip": "Face detection model from models/detection/."
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"LoadMoGeModel": {
"display_name": "Load MoGe Model",
"inputs": {
@@ -8029,6 +8058,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",
@@ -8417,7 +8447,7 @@
}
},
"LTXVAudioVAELoader": {
"display_name": "LTXV Audio VAE Loader",
"display_name": "Load LTXV Audio VAE",
"inputs": {
"ckpt_name": {
"name": "ckpt_name",
@@ -9233,6 +9263,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",
@@ -9322,8 +9353,98 @@
}
}
},
"MediaPipeFaceLandmarker": {
"display_name": "Detect Face Landmarks (MediaPipe)",
"description": "Detects facial landmarks using MediaPipe model.",
"inputs": {
"face_detection_model": {
"name": "face_detection_model"
},
"image": {
"name": "image"
},
"detector_variant": {
"name": "detector_variant",
"tooltip": "Face detector range. 'short' is tuned for close-up faces (within ~2 m of the camera); 'full' covers farther / smaller faces (up to ~5 m) but is slower. 'both' runs both detectors and keeps whichever found more faces per frame (~2× detection cost)."
},
"num_faces": {
"name": "num_faces",
"tooltip": "Maximum faces to return per frame. 0 = no cap (return all detected)."
},
"min_confidence": {
"name": "min_confidence",
"tooltip": "BlazeFace score threshold. Lower to catch small/occluded faces."
},
"missing_frame_fallback": {
"name": "missing_frame_fallback",
"tooltip": "Per-frame behaviour when detection fails in a batch. 'empty' leaves the frame faceless. 'previous' copies the most recent successful detection. 'interpolate' lerps landmarks/bbox/blendshapes between bracketing successful frames. Multi-face: pairs faces across frames by greedy bbox-centre NN."
}
},
"outputs": {
"0": {
"name": "face_landmarks",
"tooltip": null
},
"1": {
"name": "bboxes",
"tooltip": null
}
}
},
"MediaPipeFaceMask": {
"display_name": "Draw Face Mask (MediaPipe)",
"description": "Draws a mask from face landmarks.",
"inputs": {
"face_landmarks": {
"name": "face_landmarks"
},
"regions": {
"name": "regions",
"tooltip": "'all' = union of face_oval+lips+eyes+irises (which collapses to face_oval since it encloses the rest). 'custom' = toggle each region individually for combos like lips+eyes."
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"MediaPipeFaceMeshVisualize": {
"display_name": "Visualize Face Landmarks (MediaPipe)",
"description": "Draws face landmarks mesh on the input image.",
"inputs": {
"face_landmarks": {
"name": "face_landmarks"
},
"connections": {
"name": "connections",
"tooltip": "'all' = oval+eyes+brows+lips+irises+nose. 'fill' = solid face_oval polygon (silhouette mask). 'custom' = toggle each feature individually (including 'tesselation', the full 2547-edge wireframe)."
},
"color": {
"name": "color"
},
"thickness": {
"name": "thickness",
"tooltip": "Edge line thickness in pixels. 0 disables edge drawing."
},
"point_size": {
"name": "point_size",
"tooltip": "Landmark dot radius in pixels. 0 disables point drawing."
},
"image": {
"name": "image",
"tooltip": "If not connected, a black canvas will be used."
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"MergeImageLists": {
"display_name": "Merge Image Lists",
"display_name": "Merge Image Lists (DEPRECATED)",
"description": "Concatenate multiple image lists into one.",
"inputs": {
"images": {
"name": "images",
@@ -9338,7 +9459,7 @@
}
},
"MergeTextLists": {
"display_name": "Merge Text Lists",
"display_name": "Merge Text Lists (DEPRECATED)",
"inputs": {
"texts": {
"name": "texts",
@@ -12003,7 +12124,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"
@@ -12039,7 +12161,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"
@@ -12073,7 +12196,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"
@@ -12102,7 +12226,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"
@@ -12164,7 +12289,8 @@
}
},
"NormalizeImages": {
"display_name": "Normalize Images",
"display_name": "Normalize Image Colors",
"description": "Normalize images using mean and standard deviation.",
"inputs": {
"images": {
"name": "images",
@@ -12493,6 +12619,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": {
@@ -13278,7 +13437,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",
@@ -14027,7 +14187,7 @@
}
},
"ReplaceText": {
"display_name": "Replace Text",
"display_name": "Replace Text (DEPRECATED)",
"inputs": {
"texts": {
"name": "texts",
@@ -14140,7 +14300,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",
@@ -14148,7 +14309,7 @@
},
"longer_edge": {
"name": "longer_edge",
"tooltip": "Target length for the longer edge."
"tooltip": "Target dimension for the longer edge."
}
},
"outputs": {
@@ -14159,7 +14320,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",
@@ -14167,7 +14329,7 @@
},
"shorter_edge": {
"name": "shorter_edge",
"tooltip": "Target length for the shorter edge."
"tooltip": "Target dimension for the shorter edge."
}
},
"outputs": {
@@ -14179,6 +14341,7 @@
},
"ResolutionBucket": {
"display_name": "Resolution Bucket",
"description": "Group latents and conditionings into buckets",
"inputs": {
"latents": {
"name": "latents",
@@ -15292,7 +15455,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",
@@ -15309,16 +15473,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)."
@@ -15326,6 +15487,10 @@
"filename_prefix": {
"name": "filename_prefix",
"tooltip": "Prefix for saved image filenames."
},
"texts": {
"name": "texts",
"tooltip": "List of text captions to save."
}
}
},
@@ -15380,6 +15545,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",
@@ -15702,7 +15868,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",
@@ -15724,7 +15891,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",
@@ -16540,6 +16708,23 @@
}
}
},
"StringFormat": {
"display_name": "Format Text",
"description": "Same as Python's string format method. Supports all of Python's format options and features.",
"inputs": {
"values": {
"name": "values"
},
"f_string": {
"name": "f_string"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"StringLength": {
"display_name": "Text Length",
"inputs": {
@@ -16609,7 +16794,7 @@
}
},
"StripWhitespace": {
"display_name": "Strip Whitespace",
"display_name": "Strip Whitespace (DEPRECATED)",
"inputs": {
"texts": {
"name": "texts",
@@ -17376,7 +17561,7 @@
}
},
"TextToLowercase": {
"display_name": "Text to Lowercase",
"display_name": "Convert Text to Lowercase (DEPRECATED)",
"inputs": {
"texts": {
"name": "texts",
@@ -17391,7 +17576,7 @@
}
},
"TextToUppercase": {
"display_name": "Text to Uppercase",
"display_name": "Convert Text to Uppercase (DEPRECATED)",
"inputs": {
"texts": {
"name": "texts",
@@ -19332,6 +19517,7 @@
},
"VoxelToMesh": {
"display_name": "Voxel to Mesh",
"description": "Converts a voxel grid to a mesh.",
"inputs": {
"voxel": {
"name": "voxel"
@@ -19350,7 +19536,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"

View File

@@ -800,6 +800,8 @@
"CONTROL_NET": "RED_DE_CONTROL",
"CURVE": "CURVA",
"ELEVENLABS_VOICE": "ELEVENLABS_VOICE",
"FACE_DETECTION_MODEL": "MODELO_DE_DETECCIÓN_DE_CARAS",
"FACE_LANDMARKS": "FACE_LANDMARKS",
"FILE_3D": "ARCHIVO_3D",
"FILE_3D_FBX": "ARCHIVO_3D_FBX",
"FILE_3D_GLB": "ARCHIVO_3D_GLB",
@@ -2278,6 +2280,7 @@
"Meshy": "Meshy",
"MiniMax": "MiniMax",
"OpenAI": "OpenAI",
"OpenRouter": "OpenRouter",
"PixVerse": "PixVerse",
"Quiver": "Quiver",
"Recraft": "Recraft",
@@ -2294,6 +2297,7 @@
"Vidu": "Vidu",
"Wan": "Wan",
"WaveSpeed": "WaveSpeed",
"adjustments": "ajustes",
"advanced": "avanzado",
"api node": "nodo api",
"attention_experiments": "experimentos_de_atención",
@@ -2443,7 +2447,8 @@
"nonPublicAssetsWarningLine1": "Este flujo de trabajo incluye recursos no públicos.",
"nonPublicAssetsWarningLine2": "Estos se importarán a tu biblioteca al abrir el flujo de trabajo",
"openWithoutImporting": "Abrir sin importar",
"openWorkflow": "Abrir flujo de trabajo"
"openWorkflow": "Abrir flujo de trabajo",
"opening": "Abriendo flujo de trabajo compartido..."
},
"painter": {
"background": "Fondo",
@@ -3620,7 +3625,8 @@
"placeholderMesh": "Seleccionar malla...",
"placeholderModel": "Seleccionar modelo...",
"placeholderUnknown": "Seleccionar medio...",
"placeholderVideo": "Seleccionar video..."
"placeholderVideo": "Seleccionar video...",
"topResult": "Mejor resultado: {result}"
},
"valueControl": {
"decrement": "Disminuir valor",

View File

@@ -119,6 +119,7 @@
}
},
"AdjustBrightness": {
"description": "Ajusta el brillo de una imagen.",
"display_name": "Ajustar brillo",
"inputs": {
"factor": {
@@ -138,6 +139,7 @@
}
},
"AdjustContrast": {
"description": "Ajusta el contraste de una imagen.",
"display_name": "Ajustar contraste",
"inputs": {
"factor": {
@@ -176,6 +178,7 @@
}
},
"AudioAdjustVolume": {
"description": "Ajusta el volumen del audio en una cantidad especificada en decibelios (dB).",
"display_name": "Ajustar Volumen de Audio",
"inputs": {
"audio": {
@@ -577,6 +580,9 @@
"model_auto_downscale": {
"name": "auto_downscale"
},
"model_auto_upscale": {
"name": "auto_upscale"
},
"model_duration": {
"name": "duración"
},
@@ -1547,6 +1553,7 @@
}
},
"CenterCropImages": {
"description": "Recorta una imagen al centro con las dimensiones especificadas.",
"display_name": "Recorte central de imágenes",
"inputs": {
"height": {
@@ -1666,6 +1673,9 @@
"model_max_tokens": {
"name": "max_tokens"
},
"model_reasoning_effort": {
"name": "reasoning_effort"
},
"model_temperature": {
"name": "temperature"
},
@@ -5699,6 +5709,7 @@
}
},
"ImageCropV2": {
"description": "Recorta una imagen a las dimensiones especificadas.",
"display_name": "Recorte de Imagen",
"inputs": {
"crop_region": {
@@ -5719,6 +5730,7 @@
}
},
"ImageDeduplication": {
"description": "Elimina imágenes duplicadas o muy similares de una lista.",
"display_name": "Eliminación de Imágenes Duplicadas",
"inputs": {
"images": {
@@ -5773,6 +5785,7 @@
}
},
"ImageGrid": {
"description": "Organiza varias imágenes en una cuadrícula.",
"display_name": "Cuadrícula de Imágenes",
"inputs": {
"cell_height": {
@@ -8366,6 +8379,7 @@
}
},
"LoadImageDataSetFromFolder": {
"description": "Carga un conjunto de datos de imágenes desde una carpeta especificada y devuelve una lista de imágenes. Formatos soportados: PNG, JPG, JPEG, WEBP.",
"display_name": "Cargar conjunto de imágenes desde carpeta",
"inputs": {
"folder": {
@@ -8409,6 +8423,7 @@
}
},
"LoadImageTextDataSetFromFolder": {
"description": "Carga un conjunto de datos de pares de imágenes y textos desde una carpeta especificada y los devuelve como una lista. Formatos soportados: PNG, JPG, JPEG, WEBP.",
"display_name": "Cargar conjunto de imágenes y texto desde carpeta",
"inputs": {
"folder": {
@@ -8435,6 +8450,20 @@
}
}
},
"LoadMediaPipeFaceLandmarker": {
"display_name": "Cargar MediaPipe Face Landmarker",
"inputs": {
"model_name": {
"name": "model_name",
"tooltip": "Face Landmarker safetensors de models/mediapipe/."
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"LoadMoGeModel": {
"display_name": "Cargar modelo MoGe",
"inputs": {
@@ -8449,6 +8478,7 @@
}
},
"LoadTrainingDataset": {
"description": "Carga un conjunto de datos de entrenamiento codificado (latentes + condicionamiento) desde el disco para su uso en entrenamiento.",
"display_name": "Cargar conjunto de datos de entrenamiento",
"inputs": {
"folder_name": {
@@ -9232,6 +9262,7 @@
}
},
"MakeTrainingDataset": {
"description": "Codifica imágenes con VAE y textos con CLIP para crear un conjunto de datos de latentes y condicionamientos para entrenamiento.",
"display_name": "Crear Conjunto de Datos de Entrenamiento",
"inputs": {
"clip": {
@@ -9322,7 +9353,97 @@
}
}
},
"MediaPipeFaceLandmarker": {
"description": "Detecta puntos de referencia faciales usando el modelo MediaPipe.",
"display_name": "MediaPipe Face Landmarker",
"inputs": {
"detector_variant": {
"name": "detector_variant",
"tooltip": "Rango del detector facial. 'short' está ajustado para rostros cercanos (a menos de ~2 m de la cámara); 'full' cubre rostros más lejanos/pequeños (hasta ~5 m) pero es más lento. 'both' ejecuta ambos detectores y mantiene el que encontró más rostros por fotograma (costo de detección ~2×)."
},
"face_detection_model": {
"name": "face_detection_model"
},
"image": {
"name": "image"
},
"min_confidence": {
"name": "min_confidence",
"tooltip": "Umbral de puntuación BlazeFace. Disminuye para captar rostros pequeños/ocultos."
},
"missing_frame_fallback": {
"name": "missing_frame_fallback",
"tooltip": "Comportamiento por fotograma cuando falla la detección en un lote. 'empty' deja el fotograma sin rostro. 'previous' copia la detección exitosa más reciente. 'interpolate' interpola puntos clave/bbox/blendshapes entre los fotogramas exitosos adyacentes. Multi-rostro: empareja rostros entre fotogramas por centro de bbox más cercano."
},
"num_faces": {
"name": "num_faces",
"tooltip": "Máximo de rostros a devolver por fotograma. 0 = sin límite (devuelve todos los detectados)."
}
},
"outputs": {
"0": {
"name": "face_landmarks",
"tooltip": null
},
"1": {
"name": "bboxes",
"tooltip": null
}
}
},
"MediaPipeFaceMask": {
"description": "Dibuja una máscara a partir de los puntos de referencia faciales.",
"display_name": "MediaPipe Face Mask",
"inputs": {
"face_landmarks": {
"name": "face_landmarks"
},
"regions": {
"name": "regions",
"tooltip": "'all' = unión de face_oval+lips+eyes+irises (que se reduce a face_oval ya que lo contiene todo). 'custom' = activa cada región individualmente para combinaciones como lips+eyes."
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"MediaPipeFaceMeshVisualize": {
"description": "Dibuja la malla de puntos de referencia faciales sobre la imagen de entrada.",
"display_name": "Visualizar Malla Facial de MediaPipe",
"inputs": {
"color": {
"name": "color"
},
"connections": {
"name": "connections",
"tooltip": "'all' = oval+eyes+brows+lips+irises+nose. 'fill' = polígono sólido face_oval (máscara de silueta). 'custom' = activa cada característica individualmente (incluyendo 'tesselation', el entramado completo de 2547 aristas)."
},
"face_landmarks": {
"name": "face_landmarks"
},
"image": {
"name": "image",
"tooltip": "Si no se conecta, se usará un lienzo negro."
},
"point_size": {
"name": "point_size",
"tooltip": "Radio de los puntos de referencia en píxeles. 0 desactiva el dibujo de puntos."
},
"thickness": {
"name": "thickness",
"tooltip": "Grosor de línea de los bordes en píxeles. 0 desactiva el dibujo de bordes."
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"MergeImageLists": {
"description": "Concatena varias listas de imágenes en una sola.",
"display_name": "Unir Listas de Imágenes",
"inputs": {
"images": {
@@ -9777,6 +9898,7 @@
}
},
"MoGeInference": {
"description": "Ejecuta MoGe en una sola imagen para estimar profundidad y geometría.",
"display_name": "Inferencia MoGe",
"inputs": {
"apply_mask": {
@@ -9813,6 +9935,7 @@
}
},
"MoGePanoramaInference": {
"description": "Ejecuta MoGe en una panorámica equirectangular dividiéndola en 12 vistas en perspectiva, ejecutando inferencia en cada una y fusionando los resultados en un solo mapa de profundidad.",
"display_name": "Inferencia panorámica MoGe",
"inputs": {
"batch_size": {
@@ -9847,6 +9970,7 @@
}
},
"MoGePointMapToMesh": {
"description": "Convierte un mapa de puntos MoGe en una malla 3D.",
"display_name": "MoGe Point Map a Malla",
"inputs": {
"batch_index": {
@@ -9876,6 +10000,7 @@
}
},
"MoGeRender": {
"description": "Renderiza un mapa de profundidad o un mapa normal a partir de datos de geometría",
"display_name": "MoGe Renderizado",
"inputs": {
"moge_geometry": {
@@ -12164,6 +12289,7 @@
}
},
"NormalizeImages": {
"description": "Normaliza imágenes usando la media y la desviación estándar.",
"display_name": "Normalizar Imágenes",
"inputs": {
"images": {
@@ -12493,6 +12619,39 @@
}
}
},
"OpenRouterLLMNode": {
"description": "Genera respuestas de texto a través de OpenRouter. Redirige a un conjunto seleccionado de modelos populares de xAI, DeepSeek, Qwen, Mistral, Z.AI (GLM), Moonshot (Kimi) y Perplexity Sonar.",
"display_name": "OpenRouter LLM",
"inputs": {
"control_after_generate": {
"name": "control after generate"
},
"model": {
"name": "model",
"tooltip": "El modelo de OpenRouter utilizado para generar la respuesta."
},
"model_reasoning_effort": {
"name": "reasoning_effort"
},
"prompt": {
"name": "prompt",
"tooltip": "Entrada de texto para el modelo."
},
"seed": {
"name": "seed",
"tooltip": "Semilla para muestreo. Establecer en 0 para omitir. La mayoría de los modelos lo toman solo como sugerencia."
},
"system_prompt": {
"name": "system_prompt",
"tooltip": "Instrucciones fundamentales que dictan el comportamiento del modelo."
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"OpticalFlowLoader": {
"display_name": "Cargar modelo de flujo óptico",
"inputs": {
@@ -13306,6 +13465,7 @@
}
},
"RandomCropImages": {
"description": "Recorta aleatoriamente una imagen a las dimensiones especificadas.",
"display_name": "Recorte Aleatorio de Imágenes",
"inputs": {
"control_after_generate": {
@@ -14168,6 +14328,7 @@
}
},
"ResizeImagesByLongerEdge": {
"description": "Redimensiona imágenes para que el borde más largo coincida con la dimensión especificada, manteniendo la relación de aspecto.",
"display_name": "Redimensionar imágenes por el borde más largo",
"inputs": {
"images": {
@@ -14187,6 +14348,7 @@
}
},
"ResizeImagesByShorterEdge": {
"description": "Redimensiona las imágenes para que el borde más corto coincida con la dimensión especificada, manteniendo la proporción de aspecto.",
"display_name": "Redimensionar imágenes por el borde más corto",
"inputs": {
"images": {
@@ -14206,6 +14368,7 @@
}
},
"ResolutionBucket": {
"description": "Agrupa latents y condicionamientos en buckets",
"display_name": "Agrupación por resolución",
"inputs": {
"conditioning": {
@@ -15550,6 +15713,7 @@
}
},
"SaveImageDataSetToFolder": {
"description": "Guarda un conjunto de imágenes en una carpeta especificada. Formatos soportados: PNG.",
"display_name": "Guardar conjunto de imágenes en carpeta",
"inputs": {
"filename_prefix": {
@@ -15567,6 +15731,7 @@
}
},
"SaveImageTextDataSetToFolder": {
"description": "Guarda un conjunto de pares de imágenes y subtítulos de texto en una carpeta especificada. Las imágenes se guardan como archivos PNG y los subtítulos como archivos TXT con el mismo prefijo de nombre de archivo.",
"display_name": "Guardar conjunto de imágenes y textos en carpeta",
"inputs": {
"filename_prefix": {
@@ -15637,6 +15802,7 @@
}
},
"SaveTrainingDataset": {
"description": "Guarda el conjunto de datos de entrenamiento codificado (latents + conditioning) en disco para una carga eficiente durante el entrenamiento.",
"display_name": "Guardar conjunto de datos de entrenamiento",
"inputs": {
"conditioning": {
@@ -15823,6 +15989,7 @@
}
},
"ShuffleDataset": {
"description": "Mezcla aleatoriamente el orden de las imágenes en una lista.",
"display_name": "Barajar conjunto de imágenes",
"inputs": {
"control_after_generate": {
@@ -15845,6 +16012,7 @@
}
},
"ShuffleImageTextDataset": {
"description": "Mezcla aleatoriamente el orden de los pares imagen-texto en una lista.",
"display_name": "Barajar conjunto de imágenes y textos",
"inputs": {
"control_after_generate": {
@@ -16661,6 +16829,23 @@
}
}
},
"StringFormat": {
"description": "Igual que el método de formato de cadenas de Python. Soporta todas las opciones y características de formato de Python.",
"display_name": "Formatear Texto",
"inputs": {
"f_string": {
"name": "f_string"
},
"values": {
"name": "values"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"StringLength": {
"display_name": "Longitud",
"inputs": {
@@ -19328,6 +19513,7 @@
}
},
"VoxelToMesh": {
"description": "Convierte una cuadrícula de vóxeles en una malla.",
"display_name": "VoxelToMesh",
"inputs": {
"algorithm": {
@@ -19347,6 +19533,7 @@
}
},
"VoxelToMeshBasic": {
"description": "Convierte una cuadrícula de vóxeles en una malla.",
"display_name": "VoxelAMallaBásico",
"inputs": {
"threshold": {

View File

@@ -800,6 +800,8 @@
"CONTROL_NET": "controlnet",
"CURVE": "CURVE",
"ELEVENLABS_VOICE": "ELEVENLABS_VOICE",
"FACE_DETECTION_MODEL": "مدل تشخیص چهره",
"FACE_LANDMARKS": "FACE_LANDMARKS",
"FILE_3D": "FILE_3D",
"FILE_3D_FBX": "FILE_3D_FBX",
"FILE_3D_GLB": "FILE_3D_GLB",
@@ -2278,6 +2280,7 @@
"Meshy": "Meshy",
"MiniMax": "MiniMax",
"OpenAI": "OpenAI",
"OpenRouter": "OpenRouter",
"PixVerse": "PixVerse",
"Quiver": "Quiver",
"Recraft": "Recraft",
@@ -2294,6 +2297,7 @@
"Vidu": "Vidu",
"Wan": "Wan",
"WaveSpeed": "WaveSpeed",
"adjustments": "تنظیمات",
"advanced": "پیشرفته",
"api node": "گره API",
"attention_experiments": "آزمایش‌های توجه",
@@ -2443,7 +2447,8 @@
"nonPublicAssetsWarningLine1": "این گردش‌کار دارای دارایی‌های غیرعمومی است.",
"nonPublicAssetsWarningLine2": "این موارد هنگام باز کردن گردش‌کار به کتابخانه شما وارد می‌شوند",
"openWithoutImporting": "باز کردن بدون وارد کردن",
"openWorkflow": "باز کردن گردش‌کار"
"openWorkflow": "باز کردن گردش‌کار",
"opening": "در حال باز کردن گردش‌کار مشترک..."
},
"painter": {
"background": "پس‌زمینه",
@@ -3632,7 +3637,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": "auto_downscale"
},
"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": "حداکثر توکن‌ها"
},
"model_reasoning_effort": {
"name": "reasoning_effort"
},
"model_temperature": {
"name": "دمای مدل"
},
@@ -5699,6 +5709,7 @@
}
},
"ImageCropV2": {
"description": "برش تصویر به ابعاد مشخص شده.",
"display_name": "برش تصویر",
"inputs": {
"crop_region": {
@@ -5719,6 +5730,7 @@
}
},
"ImageDeduplication": {
"description": "حذف تصاویر تکراری یا بسیار مشابه از یک لیست.",
"display_name": "حذف تصاویر تکراری",
"inputs": {
"images": {
@@ -5773,6 +5785,7 @@
}
},
"ImageGrid": {
"description": "چیدمان چندین تصویر در یک ساختار شبکه‌ای.",
"display_name": "شبکه تصاویر",
"inputs": {
"cell_height": {
@@ -8366,6 +8379,7 @@
}
},
"LoadImageDataSetFromFolder": {
"description": "بارگذاری مجموعه‌ای از تصاویر از یک پوشه مشخص و بازگرداندن لیستی از تصاویر. فرمت‌های پشتیبانی‌شده: PNG، JPG، JPEG، WEBP.",
"display_name": "بارگذاری مجموعه تصاویر از پوشه",
"inputs": {
"folder": {
@@ -8409,6 +8423,7 @@
}
},
"LoadImageTextDataSetFromFolder": {
"description": "بارگذاری مجموعه‌ای از جفت‌های تصویر و کپشن متنی از یک پوشه مشخص و بازگرداندن آن‌ها به صورت لیست. فرمت‌های پشتیبانی‌شده: PNG، JPG، JPEG، WEBP.",
"display_name": "بارگذاری مجموعه داده تصویر و متن از پوشه",
"inputs": {
"folder": {
@@ -8435,6 +8450,20 @@
}
}
},
"LoadMediaPipeFaceLandmarker": {
"display_name": "بارگذاری MediaPipe Face Landmarker",
"inputs": {
"model_name": {
"name": "model_name",
"tooltip": "safetensors مربوط به Face Landmarker از مسیر models/mediapipe/."
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"LoadMoGeModel": {
"display_name": "بارگذاری مدل MoGe",
"inputs": {
@@ -8449,6 +8478,7 @@
}
},
"LoadTrainingDataset": {
"description": "بارگذاری دیتاست آموزش رمزگذاری‌شده (latents + conditioning) از دیسک برای استفاده در آموزش.",
"display_name": "بارگذاری مجموعه داده آموزشی",
"inputs": {
"folder_name": {
@@ -9232,6 +9262,7 @@
}
},
"MakeTrainingDataset": {
"description": "رمزگذاری تصاویر با VAE و متون با CLIP برای ساخت دیتاست آموزشی از latents و conditioning.",
"display_name": "ایجاد دیتاست آموزش",
"inputs": {
"clip": {
@@ -9322,7 +9353,97 @@
}
}
},
"MediaPipeFaceLandmarker": {
"description": "شناسایی نقاط کلیدی صورت با استفاده از مدل MediaPipe.",
"display_name": "MediaPipe Face Landmarker",
"inputs": {
"detector_variant": {
"name": "detector_variant",
"tooltip": "محدوده تشخیص چهره. 'short' برای چهره‌های نزدیک (تا حدود ۲ متر از دوربین) تنظیم شده است؛ 'full' چهره‌های دورتر/کوچکتر (تا حدود ۵ متر) را پوشش می‌دهد اما کندتر است. 'both' هر دو تشخیص‌دهنده را اجرا می‌کند و هر کدام که چهره‌های بیشتری در هر فریم پیدا کند را نگه می‌دارد (هزینه تشخیص تقریباً ۲ برابر)."
},
"face_detection_model": {
"name": "face_detection_model"
},
"image": {
"name": "image"
},
"min_confidence": {
"name": "min_confidence",
"tooltip": "آستانه امتیاز BlazeFace. برای شناسایی چهره‌های کوچک یا پوشیده مقدار را کاهش دهید."
},
"missing_frame_fallback": {
"name": "missing_frame_fallback",
"tooltip": "رفتار هر فریم زمانی که تشخیص در یک دسته ناموفق باشد. 'empty' فریم را بدون چهره باقی می‌گذارد. 'previous' آخرین تشخیص موفق را کپی می‌کند. 'interpolate' نقاط/باکس/شکل‌های ترکیبی را بین فریم‌های موفق مجاور میان‌یابی می‌کند. چند چهره: جفت‌سازی چهره‌ها بین فریم‌ها با نزدیک‌ترین مرکز باکس به صورت حریصانه."
},
"num_faces": {
"name": "num_faces",
"tooltip": "حداکثر تعداد چهره برای بازگشت در هر فریم. ۰ = بدون محدودیت (همه چهره‌های شناسایی‌شده بازگردانده می‌شوند)."
}
},
"outputs": {
"0": {
"name": "face_landmarks",
"tooltip": null
},
"1": {
"name": "bboxes",
"tooltip": null
}
}
},
"MediaPipeFaceMask": {
"description": "ترسیم ماسک از نقاط کلیدی صورت.",
"display_name": "ماسک چهره MediaPipe",
"inputs": {
"face_landmarks": {
"name": "face_landmarks"
},
"regions": {
"name": "regions",
"tooltip": "'all' = اجتماع face_oval+lips+eyes+irises (که به دلیل احاطه شدن توسط face_oval به همان face_oval تبدیل می‌شود). 'custom' = فعال/غیرفعال کردن هر ناحیه به صورت جداگانه برای ترکیب‌هایی مانند lips+eyes."
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"MediaPipeFaceMeshVisualize": {
"description": "ترسیم مش نقاط کلیدی صورت روی تصویر ورودی.",
"display_name": "بصری‌سازی Face Mesh MediaPipe",
"inputs": {
"color": {
"name": "color"
},
"connections": {
"name": "connections",
"tooltip": "'all' = بیضی+چشم‌ها+ابروها+لب‌ها+عنبیه‌ها+بینی. 'fill' = چندضلعی face_oval پر (ماسک سیلوئت). 'custom' = فعال/غیرفعال کردن هر ویژگی به صورت جداگانه (شامل 'tesselation'، شبکه سیمی کامل با ۲۵۴۷ لبه)."
},
"face_landmarks": {
"name": "face_landmarks"
},
"image": {
"name": "image",
"tooltip": "در صورت عدم اتصال، یک بوم سیاه استفاده خواهد شد."
},
"point_size": {
"name": "point_size",
"tooltip": "شعاع نقطه‌های نشانه‌گذاری بر حسب پیکسل. ۰ یعنی رسم نقطه غیرفعال است."
},
"thickness": {
"name": "thickness",
"tooltip": "ضخامت خطوط لبه بر حسب پیکسل. ۰ یعنی رسم لبه غیرفعال است."
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"MergeImageLists": {
"description": "ادغام چندین لیست تصویر در یک لیست.",
"display_name": "ادغام فهرست تصاویر",
"inputs": {
"images": {
@@ -9777,6 +9898,7 @@
}
},
"MoGeInference": {
"description": "اجرای MoGe روی یک تصویر برای تخمین عمق و هندسه.",
"display_name": "استنتاج MoGe",
"inputs": {
"apply_mask": {
@@ -9813,6 +9935,7 @@
}
},
"MoGePanoramaInference": {
"description": "اجرای MoGe روی یک پانورامای equirectangular با تقسیم آن به ۱۲ نمای پرسپکتیو، اجرای inference روی هر کدام و ادغام نتایج در یک نقشه عمق واحد.",
"display_name": "استنتاج پانورامای MoGe",
"inputs": {
"batch_size": {
@@ -9847,6 +9970,7 @@
}
},
"MoGePointMapToMesh": {
"description": "تبدیل نقشه نقاط MoGe به یک مش سه‌بعدی.",
"display_name": "MoGe Point Map to Mesh",
"inputs": {
"batch_index": {
@@ -9876,6 +10000,7 @@
}
},
"MoGeRender": {
"description": "رندر نقشه عمق یا نقشه نرمال از داده‌های هندسی.",
"display_name": "MoGe Render",
"inputs": {
"moge_geometry": {
@@ -12164,6 +12289,7 @@
}
},
"NormalizeImages": {
"description": "نرمال‌سازی تصاویر با استفاده از میانگین و انحراف معیار.",
"display_name": "نرمال‌سازی تصاویر",
"inputs": {
"images": {
@@ -12493,6 +12619,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": "بذر نمونه‌گیری. برای حذف، مقدار ۰ را قرار دهید. اکثر مدل‌ها این مقدار را فقط به عنوان راهنما در نظر می‌گیرند."
},
"system_prompt": {
"name": "system_prompt",
"tooltip": "دستورالعمل‌های پایه که رفتار مدل را تعیین می‌کند."
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"OpticalFlowLoader": {
"display_name": "بارگذاری مدل Optical Flow",
"inputs": {
@@ -13306,6 +13465,7 @@
}
},
"RandomCropImages": {
"description": "برش تصادفی تصویر به ابعاد مشخص شده.",
"display_name": "برش تصادفی تصاویر",
"inputs": {
"control_after_generate": {
@@ -14168,6 +14328,7 @@
}
},
"ResizeImagesByLongerEdge": {
"description": "تغییر اندازه تصاویر به گونه‌ای که لبه بلندتر با بعد مشخص شده مطابقت داشته باشد و نسبت ابعاد حفظ شود.",
"display_name": "تغییر اندازه تصاویر بر اساس ضلع بلندتر",
"inputs": {
"images": {
@@ -14187,6 +14348,7 @@
}
},
"ResizeImagesByShorterEdge": {
"description": "تغییر اندازه تصاویر به گونه‌ای که لبه کوتاه‌تر با ابعاد مشخص شده مطابقت داشته باشد و نسبت ابعاد حفظ شود.",
"display_name": "تغییر اندازه تصاویر بر اساس ضلع کوتاه‌تر",
"inputs": {
"images": {
@@ -14206,6 +14368,7 @@
}
},
"ResolutionBucket": {
"description": "گروه‌بندی latentها و conditioningها در سطل‌ها",
"display_name": "دسته‌بندی بر اساس وضوح",
"inputs": {
"conditioning": {
@@ -15550,6 +15713,7 @@
}
},
"SaveImageDataSetToFolder": {
"description": "ذخیره یک مجموعه داده تصویر در پوشه‌ای مشخص. فرمت‌های پشتیبانی‌شده: PNG.",
"display_name": "ذخیره مجموعه تصاویر در پوشه",
"inputs": {
"filename_prefix": {
@@ -15567,6 +15731,7 @@
}
},
"SaveImageTextDataSetToFolder": {
"description": "ذخیره یک مجموعه داده شامل جفت‌های تصویر و کپشن متنی در پوشه‌ای مشخص. تصاویر به صورت فایل PNG و کپشن‌ها به صورت فایل TXT با همان پیشوند نام فایل ذخیره می‌شوند.",
"display_name": "ذخیره مجموعه تصویر و متن در پوشه",
"inputs": {
"filename_prefix": {
@@ -15637,6 +15802,7 @@
}
},
"SaveTrainingDataset": {
"description": "ذخیره مجموعه داده آموزش کدگذاری‌شده (latentها + conditioning) روی دیسک برای بارگذاری سریع‌تر در زمان آموزش.",
"display_name": "ذخیره مجموعه داده آموزشی",
"inputs": {
"conditioning": {
@@ -15823,6 +15989,7 @@
}
},
"ShuffleDataset": {
"description": "ترتیب تصاویر را در یک لیست به صورت تصادفی جابجا می‌کند.",
"display_name": "درهم‌ریختن دیتاست تصویر",
"inputs": {
"control_after_generate": {
@@ -15845,6 +16012,7 @@
}
},
"ShuffleImageTextDataset": {
"description": "ترتیب جفت‌های تصویر-متن را در یک لیست به صورت تصادفی جابجا می‌کند.",
"display_name": "درهم‌ریختن دیتاست تصویر-متن",
"inputs": {
"control_after_generate": {
@@ -16661,6 +16829,23 @@
}
}
},
"StringFormat": {
"description": "همانند متد فرمت رشته در پایتون. از تمام گزینه‌ها و ویژگی‌های فرمت پایتون پشتیبانی می‌کند.",
"display_name": "فرمت متن",
"inputs": {
"f_string": {
"name": "f_string"
},
"values": {
"name": "values"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"StringLength": {
"display_name": "طول",
"inputs": {
@@ -19328,6 +19513,7 @@
}
},
"VoxelToMesh": {
"description": "تبدیل شبکه voxel به mesh.",
"display_name": "VoxelToMesh",
"inputs": {
"algorithm": {
@@ -19347,6 +19533,7 @@
}
},
"VoxelToMeshBasic": {
"description": "تبدیل شبکه voxel به mesh.",
"display_name": "VoxelToMeshBasic",
"inputs": {
"threshold": {

View File

@@ -800,6 +800,8 @@
"CONTROL_NET": "RESEAU_DE_CONTROLE",
"CURVE": "COURBE",
"ELEVENLABS_VOICE": "ELEVENLABS_VOICE",
"FACE_DETECTION_MODEL": "MODÈLE_DE_DÉTECTION_DE_VISAGE",
"FACE_LANDMARKS": "FACE_LANDMARKS",
"FILE_3D": "FICHIER_3D",
"FILE_3D_FBX": "FICHIER_3D_FBX",
"FILE_3D_GLB": "FICHIER_3D_GLB",
@@ -2278,6 +2280,7 @@
"Meshy": "Meshy",
"MiniMax": "MiniMax",
"OpenAI": "OpenAI",
"OpenRouter": "OpenRouter",
"PixVerse": "PixVerse",
"Quiver": "Quiver",
"Recraft": "Recraft",
@@ -2294,6 +2297,7 @@
"Vidu": "Vidu",
"Wan": "Wan",
"WaveSpeed": "WaveSpeed",
"adjustments": "ajustements",
"advanced": "avancé",
"api node": "nœud api",
"attention_experiments": "expériences_d'attention",
@@ -2443,7 +2447,8 @@
"nonPublicAssetsWarningLine1": "Ce workflow contient des ressources non publiques.",
"nonPublicAssetsWarningLine2": "Celles-ci seront importées dans votre bibliothèque lors de l'ouverture du workflow",
"openWithoutImporting": "Ouvrir sans importer",
"openWorkflow": "Ouvrir le workflow"
"openWorkflow": "Ouvrir le workflow",
"opening": "Ouverture du workflow partagé..."
},
"painter": {
"background": "Arrière-plan",
@@ -3620,7 +3625,8 @@
"placeholderMesh": "Sélectionner un mesh...",
"placeholderModel": "Sélectionner un modèle...",
"placeholderUnknown": "Sélectionner un média...",
"placeholderVideo": "Sélectionner une vidéo..."
"placeholderVideo": "Sélectionner une vidéo...",
"topResult": "Meilleur résultat : {result}"
},
"valueControl": {
"decrement": "Décrémenter la valeur",

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