Compare commits

..

18 Commits

Author SHA1 Message Date
Comfy Org PR Bot
603914e78f 1.46.13 (#12779)
Patch version increment to 1.46.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-06-11 04:04:09 +00:00
jaeone94
c7797b201e Simplify swap node error presentation (#12768)
## Summary

Simplifies the Swap Nodes error card as the fourth slice of the
catalog/error-tab presentation refactor, aligning it with the newer
compact error-row patterns while preserving the existing replace and
locate behavior.

This follows the staged rollout plan from the earlier error-tab PRs:

1. #12683 refined execution-style errors: validation, runtime, and
prompt errors.
2. #12705 simplified missing media errors into flat, locatable rows.
3. #12735 simplified missing node pack errors and aligned grouped-row
behavior.
4. This PR applies the same simplification pass to Swap Nodes errors.
5. A later PR is expected to handle Missing Models, which is larger and
intentionally kept separate.

After the Missing Models slice lands, a follow-up consistency PR will
normalize the shared row/disclosure pattern across Missing Node Packs,
Swap Nodes, and Missing Models together. That follow-up will cover
parameterized i18n labels for disclosure controls, shared text-button
styling, and consistent disclosure semantics/accessibility across those
grouped rows.

## Changes

- **What**: Reworks the Swap Nodes card rows so each replacement group
is presented as a compact row with the source node type, replacement
target, replace action, and locate action.
- **What**: For a single affected node, the visible row label can be
clicked to locate the node, matching the interaction model used by the
newer missing-media and missing-node rows.
- **What**: For multiple affected nodes with the same replacement
target, the group renders a count badge and a disclosure row. Expanding
the group shows the affected node rows, each with its own locate action.
- **What**: Removes the old node-id badge path from Swap Nodes rows.
Node-id badges remain available to the other error cards that still own
that behavior.
- **What**: Keeps replacement behavior unchanged: per-group replacement
and replace-all still call through the existing node replacement store
flow.
- **What**: Adds regression coverage for the new grouped-row UI,
including same-type grouping in both Vue Nodes and LiteGraph render
modes.
- **Breaking**: None.
- **Dependencies**: None.

## Review Focus

Please focus on the Swap Nodes presentation and interaction symmetry
with the previous error-tab PRs:

- Single-node groups should remain directly locatable via the row label
and the locate icon.
- Multi-node groups should expose the count and expand/collapse behavior
without adding duplicate focusable disclosure controls.
- The visible row labels intentionally keep their own accessible names,
while the separate locate icon uses the generic `Locate node on canvas`
accessible name. This mirrors the established pattern from the previous
slices.
- The newly added Playwright fixture covers two same-type replaceable
nodes so duplicate group keys and grouped disclosure behavior are
exercised end-to-end.

## Validation

- `pnpm format`
- `pnpm test:unit
src/platform/nodeReplacement/components/SwapNodeGroupRow.test.ts`
- `pnpm test:browser:local browser_tests/tests/nodeReplacement.spec.ts
--project=chromium`
- Pre-commit hook: lint-staged, stylelint, oxfmt, oxlint, eslint, `pnpm
typecheck`, `pnpm typecheck:browser`
- Pre-push hook: `pnpm knip --cache`
- Additional parallel code review pass completed locally; no blocker or
major issues remained.

## Screenshots (if applicable)

This PR 

<img width="561" height="362" alt="스크린샷 2026-06-11 오전 3 46 06"
src="https://github.com/user-attachments/assets/65395467-6c2f-4aa1-84c5-3d9614c00c80"
/>

old (Main)
<img width="611" height="798" alt="스크린샷 2026-06-11 오전 3 46 32"
src="https://github.com/user-attachments/assets/3862d5df-f839-40c0-9488-ce64b051378e"
/>
2026-06-11 03:50:16 +00:00
Matt Miller
aa68573a6e fix: remove broken throttle from VirtualGrid scroll tracking (#12781)
## Summary

Every `VirtualGrid` consumer (assets sidebar, manager dialog, widget
select dropdown) is blind to discrete mouse-wheel scrolling:
`useScroll`'s `throttle: 64` never reports scroll position, so the
virtualization window stays frozen — users see blank space below the
first viewport of items and `approach-end` (infinite scroll) never
fires. Trackpad scrolling masks the bug by emitting events faster than
the throttle window.

## Changes

- **What**: drop the `throttle` option from `useScroll` in `VirtualGrid`
and remove the `scrollThrottle` prop (no consumer passes it). Scroll
events are frame-aligned and the handler is cheap, so the throttle
bought nothing even when it worked.
- **Root cause**: VueUse ≥14 `throttleFilter` with `leading=false` (what
`useScroll` uses) marks spaced-out events as executed without executing
them — each event re-arms an `isLeading` restore timer that makes the
next event skip its invoke, and the trailing branch is unreachable when
`elapsed > duration`. Regression of vueuse#2390; still present on vueuse
`main`.

## Review Focus

- Verified live against staging: with the throttle, sidebar scrolled to
`scrollTop` 1250 while `scrollY` stayed 0 and the render window stayed
at `[0..3)` of 27 (blank viewport); with this fix, `scrollY` tracks 1:1
and the window advances. Bare-vs-throttled `useScroll` compared
side-by-side on the same element to isolate the cause.
- Unblocks wheel-scroll for #12780's dropdown infinite scroll with no
changes there.

- Fixes FE-990

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-06-11 03:27:54 +00:00
Deep Mehta
79acf7be5e fix: re-encode favicon.ico with PNG frames to fix white corner artifacts (#12753)
## Summary

The rebranded `favicon.ico` renders with **opaque white corners** in
browsers — the rounded mark shows white slivers around its corners on
any background. This is a decode bug, not a design issue: the ICO
contains **BMP-format frames** whose alpha channel Chrome (and other
consumers) mishandle. Verified by loading the raw `.ico` in headless
Chrome on a dark page: corners render white instead of transparent.

Every surface that consumes the `.ico` directly shows the artifact —
Google search results, connector icon scraping, raw image views — while
browser tabs look fine because they prefer the SVG favicon.

## Changes

- **What**: Re-encode `apps/website/public/favicon.ico` and
`public/assets/favicon.ico` with **PNG-format ICO frames** (16/32/48).
PNG frames carry unambiguous alpha and decode correctly in all modern
browsers.
- **No design change**: identical rounded artwork, same transparency,
same sizes and filenames. SVG, PNGs, apple-touch-icon, and manifest
icons are untouched.
- **Breaking**: none.

## Review Focus

- Headless-Chrome verified: the old ico renders white corners on a dark
page; the re-encoded one renders transparent corners. (Comparison in PR
comments.)
- PNG-in-ICO is supported by all modern browsers and Google's favicon
pipeline.
- After merge, please add `needs-backport` + `cloud/1.45` so
cloud.comfy.org's copy gets the same fix.

## Screenshots (if applicable)

---------

Co-authored-by: GitHub Action <action@github.com>
2026-06-11 03:27:53 +00:00
Robin Huang
02adfd4b83 feat: identify prompt source via comfy_usage_source extra_data (#12772)
Adds `comfy_usage_source: 'comfyui-frontend'` to the prompt body's
`extra_data`. The backend forwards this to API nodes' upstream requests
via the `Comfy-Usage-Source` header, so partner node API usage can be
attributed to the frontend.

Used in https://github.com/Comfy-Org/ComfyUI/pull/14404
2026-06-10 22:43:34 +00:00
Robin Huang
7c2c78b537 feat: send deploy_environment as Comfy-Env header on /releases requests (#12771)
Reads `system.deploy_environment` from `/system_stats` (added in
Comfy-Org/ComfyUI#14402) and sends it as the `Comfy-Env` header when
fetching `/releases`, matching the header name the backend already uses
for outbound API node requests. The header is omitted when the backend
doesn't report the field, so older backends are unaffected.

Note: api.comfy.org must allow `Comfy-Env` in
`Access-Control-Allow-Headers` for the CORS preflight to pass.
2026-06-10 21:30:08 +00:00
Matt Miller
bd1fd0680e feat(assets): walk getAllAssetsByTag via keyset cursor (#12720)
## ELI-5

When the app needs *all* the assets for a tag (like every input image),
it asks the server for them one page at a time. Today it says "give me
page starting at item #500" (offset paging). If items get added or
removed while it's flipping through, pages shift and it can show the
same thing twice or skip something.

This switches to "give me the page *after this bookmark*" (cursor
paging). The server now hands back a `next_cursor` bookmark with each
page; we pass it to fetch the next one. Bookmarks don't slip when the
list changes underneath, so the walk is stable and drift-free.

## What

Migrates the full-walk asset pager (`getAllAssetsByTag`) from offset to
keyset (`after` / `next_cursor`) pagination, now that the list-assets
endpoint exposes a cursor contract in the generated types.

- `handleAssetRequest` accepts an `after` cursor and sends it instead of
`offset` when present (the server ignores `offset` alongside a cursor)
- `getAllAssetsByTag` resumes each page from the prior response's
`next_cursor`, and terminates when `has_more` is false or `next_cursor`
is omitted
- `next_cursor` is exposed on the asset response schema; `after` is
threaded through `getAssetsByTag` / `getAssetsPageByTag` for
cursor-aware callers
- offset remains supported for random-access callers; only the full-walk
path changes

## Why

Offset pagination double-counts or skips records when the underlying set
changes mid-walk. Keyset cursors are stable under concurrent
inserts/deletes and scale better than deep offsets.

## Stacking

Based on `update-ingest-types` because the `after`/`next_cursor` types
land there first; this targets that branch and will retarget to the
default branch once it merges. Changes here touch only the asset
service/schema, disjoint from the generated types.

## Follow-ups

The asset store's bespoke offset loops (model loader, flat-output
infinite scroll) and the missing-media resolver still walk by offset;
those migrate in separate PRs.

## Tests

`assetService.test.ts` updated to assert the cursor walk, that the first
page carries neither `after` nor `offset`, that subsequent pages resume
from `next_cursor`, and that the walk halts when `next_cursor` is absent
even if `has_more` is true. Full asset/service + missing-media + store
suites pass locally (193 tests).

---------

Co-authored-by: mattmillerai <7741082+mattmillerai@users.noreply.github.com>
Co-authored-by: GitHub Action <action@github.com>
2026-06-10 21:15:19 +00:00
Robin Huang
9617e498c9 feat: track desktop download button clicks on website (#12770)
Adds a `website:download_button_clicked` PostHog event (with `platform`
property) fired when a user clicks the desktop installer download button
on comfy.org. Previously we only had `/download` pageviews as a proxy —
autocapture is not active on the website project, so these clicks were
untracked. Includes unit tests for the new capture helper.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 20:44:57 +00:00
Terry Jia
25205c0f55 feat: add Load3DAdvanced node (#12723)
## Summary
add Load3DAdvanced node, without FE render, upload BE and HDRI.
BE https://github.com/Comfy-Org/ComfyUI/pull/14316

## Screenshots (if applicable)

https://github.com/user-attachments/assets/e561c919-bb52-4904-97da-fb01885762a7
2026-06-10 13:51:50 -04:00
Dante
598cf33ab7 [bugfix] Truncate long workspace names in workspace switcher (#12762)
## Summary

Long team workspace names wrapped onto multiple lines in the user-menu
workspace switcher, overflowing the fixed 54px rows and breaking the
dropdown layout. Applies the same single-line ellipsis pattern already
used by the current-workspace header
(`CurrentUserPopoverWorkspace.vue`).

## Changes

- **What**: `truncate` on the switcher name span, `max-w-full` on the
name row, `shrink-0` on avatar/tier badge/check icon so only the name
shrinks (`WorkspaceSwitcherPopover.vue`, 5 lines)
- Regression tests: Vitest component test + `@cloud` Playwright e2e
measuring single-line render height

Fixes
[FE-778](https://linear.app/comfyorg/issue/FE-778/bug-team-workspace-names-wrapping-to-multiple-lines-display-poorly-in)

## Red-Green Verification

| Commit | CI | Result |
|---|---|---|
| `30e04e2` test only | [Tests
Unit](https://github.com/Comfy-Org/ComfyUI_frontend/actions/runs/27278378157)
/ [Tests
E2E](https://github.com/Comfy-Org/ComfyUI_frontend/actions/runs/27278378213)
| 🔴 new unit test + cloud e2e fail (proves tests catch the
bug) |
| `d8f9a5c` fix | [Tests
Unit](https://github.com/Comfy-Org/ComfyUI_frontend/actions/runs/27279508881)
/ [Tests
E2E](https://github.com/Comfy-Org/ComfyUI_frontend/actions/runs/27279508715)
| 🟢 same tests pass |

## Screenshots

| Before | After |
|---|---|
| <img width="320" alt="before — name wraps to 4 lines, rows collide"
src="https://github.com/user-attachments/assets/90f3286a-5b50-4477-9b5c-9d32d0b026e4"
/> | <img width="320" alt="after — single line with ellipsis, row height
intact"
src="https://github.com/user-attachments/assets/8e47bbb2-b5b1-4945-a008-68491f39dc46"
/> |

## Review Focus

- Truncation chain: the name span is a flex item, so `truncate`
(overflow-hidden) zeroes its automatic min size; `max-w-full` caps the
`items-start` row at the container width. Mirrors the header pattern —
no new component.
- Figma `Team Plan - Workspaces` (Workspaces Menu component, node
2045-14413) specifies compact single-line rows; long-name overflow was
undesigned, truncation preserves the spec'd layout.
2026-06-10 14:45:36 +00:00
jaeone94
1b14f4df8a Simplify missing node pack error presentation (#12735)
## Summary

Simplify the Missing Node Packs error card so it follows the new
error-tab item-row direction, with clearer pack rows, predictable locate
behavior, and focused E2E coverage.

This is the third PR in the staged error-tab simplification plan:

1. Merged: execution/prompt/validation error presentation and catalog
grouping in #12683.
2. Merged: missing media presentation simplification in #12705.
3. This PR: missing node pack presentation simplification.
4. Planned next: swap-node presentation simplification.
5. Planned later: missing model presentation and action-flow
simplification.

## Changes

- **What**: Refactors Missing Node Packs rows so pack-level and
node-level actions are easier to scan and more consistent with the rest
of the refreshed Errors tab.
- **What**: Removes the node-id badge from missing node pack rows,
matching the simplified item-row direction.
- **What**: Makes a single-node known pack row directly locatable from
the pack label, rather than rendering an extra child row.
- **What**: Keeps multi-node packs collapsed by default, with both the
chevron and pack title toggling the child node list.
- **What**: Keeps unknown packs expanded by default, including the
single-node unknown-pack case, so users can still see the unresolved
node type immediately.
- **What**: Keeps per-node child rows clickable for locate-on-canvas
behavior when a pack contains multiple affected nodes.
- **What**: Replaces missing-node-pack action labels with shared
`g.install` and `g.search` copy and removes now-unused English locale
keys.
- **What**: Adds targeted Playwright coverage for the simplified
missing-node-pack card, including unknown-pack default rows, row-label
locate behavior, and chevron/title expansion behavior.
- **Breaking**: None.
- **Dependencies**: None.

## Review Focus

Please focus on the missing-node-pack row behavior:

- Single known pack with one affected node should stay compact and
locate the node from the pack label or locate icon.
- Known packs with multiple affected nodes should show a count, start
collapsed, and expand/collapse from either the chevron or title.
- Unknown packs should expose the affected node rows immediately,
including when there is only one affected node.
- Locate actions should remain attached to the affected node rows, not
to the parent pack when there are multiple nodes.
- The E2E fixture intentionally uses two missing nodes with the same
`cnr_id` and node sizes of `[400, 200]` to follow browser-test asset
guidance.

## Validation

- `pnpm format:check`
- `pnpm lint`
- `pnpm typecheck`
- `pnpm knip --cache` via pre-push hook
- `pnpm test:unit
src/components/rightSidePanel/errors/MissingPackGroupRow.test.ts
src/components/rightSidePanel/errors/MissingNodeCard.test.ts --run`
- `pnpm test:browser:local
browser_tests/tests/propertiesPanel/errorsTabMissingNodes.spec.ts
--project=chromium`
- Pre-commit hook: staged formatting, linting, `pnpm typecheck`, and
`pnpm typecheck:browser`

## Screenshots

This PR 
<img width="531" height="598" alt="스크린샷 2026-06-10 오전 1 54 31"
src="https://github.com/user-attachments/assets/9c0addeb-92d2-4cef-a4f3-35a87bbad308"
/>

old (Main)
<img width="509" height="807" alt="스크린샷 2026-06-10 오전 1 53 51"
src="https://github.com/user-attachments/assets/b8488f73-d8ed-4356-bd4c-fc678ea205f7"
/>
2026-06-10 09:59:50 +00:00
Dante
ef93b4696c feat(billing): team-plan CreditSlider component — 5 fixed stops (FE-935) (#12644)
## What

https://a46f3266.comfy-storybook.pages.dev/?path=/docs/components-creditslider--docs&globals=theme:dark
<img width="560" height="288" alt="cs12644_dark"
src="https://github.com/user-attachments/assets/c06b5244-d178-4fa5-8bb9-61fd8595fe9b"
/>
<img width="560" height="288" alt="cs12644_light"
src="https://github.com/user-attachments/assets/16626333-43ba-4541-bd11-faaa8513b1e8"
/>


Adds **CreditSlider** — the team-plan credit-subscription slider from
Figma **DES-197** — as a standalone presentational component. **B4
standalone slice (FE-935)**; parent **FE-934**.

## Why it can ship now (no backend dependency)
The slider's 5 stops are **locked in DES-197**, so this component is
built and reviewable independently of the (still-TBD) backend slider
contract. Wiring it into the pricing table is deferred to FE-934.

## How it works
- 5 fixed stops — **200 / 400 / 700 / 1,400 / 2,500 USD** ↔ 42,200 /
84,400 / 147,700 / 295,400 / 527,500 credits; default **$700**.
- Snap-to-stop is guaranteed by driving the shared
`src/components/ui/slider/Slider.vue` (reka-ui) in **index space**
(`:min="0" :max="4" :step="1"`) — the thumb can only land on the 5
stops, with free keyboard-arrow support + ARIA from reka-ui.
- `v-model` carries the selected **USD** value; a `change` event also
emits `{ index, usd, credits }` for the future pricing-table wiring.
- Thresholds live in a typed constant `teamPlanCreditStops.ts` (sibling
to `tierPricing.ts`), **hardcoded per DES-197** with a `TODO(FE-934)` to
source from `GET /api/billing/plans` once the BE contract lands. The
credit figures equal `usdToCredits(usd)` (rate 211); a test guards
against rate drift.

## Files
- `src/platform/cloud/subscription/components/CreditSlider.vue` (+
`CreditSlider.stories.ts`, `CreditSlider.test.ts`)
- `src/platform/cloud/subscription/constants/teamPlanCreditStops.ts`

## Verification
- `vue-tsc --noEmit`: clean.
- `oxlint --type-aware`: 0 errors / 0 warnings.
- `vitest run`: **11/11 pass** — default stop, ArrowRight/ArrowLeft snap
to the adjacent stop (never in between), `change` payload, disabled
state, BE-sourced stops override, empty `stops` renders nothing, all 5
labels render, and the credit-rate-drift guard.

## Not in scope
- Wiring into `PricingTableWorkspace` / the team-plan card (FE-934,
blocked on the BE slider contract).
- The marketing caption and card layout around the slider (parent
component's concern).

Design source: Figma **DES-197** (Team Plan / Workspaces).

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 09:05:34 +00:00
Alexis Rolland
24c512d144 Bumping search ranks (#12750)
## Summary

Bumping the ranking of native nodes to improve their discoverability

## Changes

- **What**: Updated ranking of native nodes in
`public/assets/sorted-custom-node-map.json`
2026-06-10 07:22:10 +00:00
Comfy Org PR Bot
6f6141a8e4 1.46.12 (#12745)
Patch version increment to 1.46.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-06-10 04:48:29 +00:00
jaeone94
14f8fdebdd fix: refresh promoted combo host options after missing model reload (#12692)
## Summary

Fixes a Vue node subgraph case where the missing-model refresh flow
clears the missing-model error, but the promoted combo widget remains in
an invalid visual state because its hosted options snapshot is stale.

## Changes

- **What**: After `reloadNodeDefs()` refreshes combo option lists and
extension `refreshComboInNodes` hooks run, resync hosted options
snapshots for promoted combo widgets so Vue-rendered subgraph nodes see
the newly available model option.
- **What**: Adds a focused E2E regression for the missing-model refresh
path on a subgraph with a promoted `ckpt_name` widget.
- **What**: Hardens the E2E by cleaning up its `/object_info` route
override and asserting the widget's `aria-invalid` state rather than a
Tailwind implementation class.
- **Breaking**: None.
- **Dependencies**: None.

## Review Focus

This PR is intentionally a minimal patch for the missing-model refresh
path, not a broader subgraph architecture change.

Root cause: `reloadNodeDefs()` updates the live LiteGraph combo widget
options, including widgets inside subgraphs. However, Vue nodes render
promoted widgets from a hosted `WidgetState.options` snapshot. When a
missing model is downloaded and the missing-model refresh button reloads
node definitions, the source combo receives the updated model list, but
the promoted host snapshot can still contain the old option list. The
missing-model error and node-level state are cleared, while the Vue
combo still computes itself as invalid from stale options.

The fix keeps the existing host snapshot model intact. It simply resyncs
promoted combo host options after the normal combo refresh pipeline
finishes. This avoids changing promoted-widget ownership,
`useProcessedWidgets` merge precedence, or broader subgraph internals
while addressing the reported stale invalid state.

Why the helper is in `app.ts`: this sync is currently a single-call-site
post-step of `reloadNodeDefs()`, and the ordering is load-bearing. It
must run after the core combo refresh loop and after extension
`refreshComboInNodes` hooks so it captures both built-in and
extension-provided option changes. Keeping the small private helper next
to the refresh orchestration makes that sequence explicit and avoids
adding a new public subgraph helper or introducing a more visible
dependency cycle through `promotionUtils` for a narrow patch.

This is stacked on
`jaeone/fe-942-bug-error-indicators-persist-after-resolving-missing-model`,
so it is opened as a draft until the base PR lands.

## Test Plan

- `pnpm knip`
- `pnpm exec oxfmt --check src/scripts/app.ts
browser_tests/tests/propertiesPanel/errorsTabModeAware.spec.ts`
- `pnpm exec eslint --cache --no-warn-ignored src/scripts/app.ts
browser_tests/tests/propertiesPanel/errorsTabModeAware.spec.ts`
- `pnpm typecheck`
- `pnpm test:browser:local --grep "Refreshing a resolved promoted
missing model clears the combo invalid state"`
- First local run hit a `beforeEach` timeout while the dev server was
still warming custom-node/conflict-detection output.
  - Re-running against the warmed dev server passed.

## Screenshots (if applicable)

N/A. The behavioral E2E covers the visible invalid-state regression.
2026-06-10 04:36:45 +00:00
Deep Mehta
73e3aead16 refactor: recolor comfy-logo-single to new brand (dark, not blue) (#12748)
## Summary

`comfy-logo-single.svg` is still the old **yellow-on-blue** (`#172DD7`)
ComfyUI mark, while the favicon and the rest of the brand have moved to
**dark `#211927` + yellow `#F2FF59`**. This refreshes it to match.

## Changes

- **What**: Pure color swap on
`public/assets/images/comfy-logo-single.svg` (`#172DD7`→`#211927`,
yellows normalized to `#F2FF59`). Geometry/mask unchanged.
- **Breaking**: none.

## Review Focus

- This logo is the **in-app header mark** (`ComfyOrgHeader.vue`) and the
**PWA/cloud manifest icon**, so it's a visible brand change across OSS +
cloud + desktop — flagging for **brand/design sign-off** before merge.
- `comfy-logo-mono.svg` (black/white) is intentionally left as-is.
2026-06-10 04:35:44 +00:00
Benjamin Lu
c190784307 Add share id attribution across share and run telemetry (#12741)
## Summary
- Thread `share_id` through shared workflow open/import, link creation,
auth completion, and run success telemetry
- Persist share attribution on loaded workflows and queued jobs so
shared runs can be joined back to the source link
- Add provider support for `share_link_opened` and `shared_workflow_run`
events across telemetry backends

## Behavior notes
- `execution_success` is now keyed off the success event's own
`prompt_id` (looked up in `queuedJobs`) instead of `activeJobId`. This
fixes successes for non-active jobs being reported with the wrong job
id, but may slightly shift `execution_success` event volume: successes
for jobs this client never queued or saw start are no longer tracked.
- Share auth attribution (`share_auth` preserved query) is cleared if
the user cancels the shared workflow dialog, so only users who proceed
past the dialog have signups attributed to the share link.

## Testing
- Added and updated unit tests for shared workflow loading, link
creation, auth attribution, workflow service loading, and execution
success
- Unit tests, `pnpm test:unit`, and repository checks for formatting,
linting, and type coverage passed
2026-06-09 20:56:31 -07:00
Simon Pinfold
38458c518e feat(assets): include previews in bulk asset export (#12746)
## Summary

Set `include_previews: true` on bulk asset export so exported zips
include preview files, using the new option added to the cloud export
API.

## Changes

- **What**: Added `include_previews` to `AssetExportOptions` and pass
`true` from the bulk export path in `useMediaAssetActions`.

## Review Focus

Field name matches the updated cloud `AssetExportOptions` contract.
2026-06-10 03:03:00 +00:00
361 changed files with 11417 additions and 5424 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 KiB

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.6 KiB

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 56 KiB

View File

@@ -1,23 +1,23 @@
{
"name": "Comfy",
"short_name": "Comfy",
"id": "/",
"start_url": "/",
"icons": [
{
"src": "/web-app-manifest-192x192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any"
"purpose": "any maskable"
},
{
"src": "/web-app-manifest-512x512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any"
"purpose": "any maskable"
}
],
"theme_color": "#211927",
"background_color": "#211927",
"display": "standalone"
"display": "standalone",
"id": "/",
"start_url": "/"
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

After

Width:  |  Height:  |  Size: 8.8 KiB

View File

@@ -8,6 +8,7 @@ import {
useDownloadUrl
} from '../../../composables/useDownloadUrl'
import { t } from '../../../i18n/translations'
import { captureDownloadClick } from '../../../scripts/posthog'
import BrandButton from '../../common/BrandButton.vue'
const { locale = 'en', class: customClass = '' } = defineProps<{
@@ -69,6 +70,7 @@ const buttons = computed<ButtonSpec[]>(() => {
size="lg"
:class="customClass"
:aria-label="btn.ariaLabel"
@click="captureDownloadClick(btn.key)"
>
<span class="inline-flex items-center gap-2">
<img

View File

@@ -73,7 +73,7 @@ const websiteJsonLd = {
<link rel="icon" type="image/png" href="/favicon-96x96.png" sizes="96x96" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="shortcut icon" href="/favicon.ico" />
<link rel="shortcut icon" href="/favicon.ico" sizes="48x48" />
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
<link rel="manifest" href="/site.webmanifest" />
<meta name="theme-color" content="#211927" />

View File

@@ -53,3 +53,28 @@ describe('initPostHog', () => {
expect(result.$set_once).toHaveProperty('plan', 'free')
})
})
describe('captureDownloadClick', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.resetModules()
})
it('captures the download event with the platform', async () => {
const { initPostHog, captureDownloadClick } = await import('./posthog')
initPostHog()
captureDownloadClick('mac')
expect(hoisted.mockCapture).toHaveBeenCalledWith(
'website:download_button_clicked',
{ platform: 'mac' }
)
})
it('does not capture before PostHog is initialized', async () => {
const { captureDownloadClick } = await import('./posthog')
captureDownloadClick('windows')
expect(hoisted.mockCapture).not.toHaveBeenCalled()
})
})

View File

@@ -38,3 +38,12 @@ export function capturePageview() {
console.error('PostHog pageview capture failed', error)
}
}
export function captureDownloadClick(platform: string) {
if (!initialized) return
try {
posthog.capture('website:download_button_clicked', { platform })
} catch (error) {
console.error('PostHog download click capture failed', error)
}
}

View File

@@ -0,0 +1,48 @@
{
"last_node_id": 2,
"last_link_id": 0,
"nodes": [
{
"id": 1,
"type": "TEST_MISSING_PACK_NODE_A",
"pos": [48, 86],
"size": [400, 200],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [],
"properties": {
"Node name for S&R": "TEST_MISSING_PACK_NODE_A",
"cnr_id": "test-missing-node-pack"
},
"widgets_values": []
},
{
"id": 2,
"type": "TEST_MISSING_PACK_NODE_B",
"pos": [520, 86],
"size": [400, 200],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [],
"outputs": [],
"properties": {
"Node name for S&R": "TEST_MISSING_PACK_NODE_B",
"cnr_id": "test-missing-node-pack"
},
"widgets_values": []
}
],
"links": [],
"groups": [],
"config": {},
"extra": {
"ds": {
"scale": 1,
"offset": [0, 0]
}
},
"version": 0.4
}

View File

@@ -0,0 +1,61 @@
{
"last_node_id": 2,
"last_link_id": 0,
"nodes": [
{
"id": 1,
"type": "E2E_OldSampler",
"pos": [100, 100],
"size": [400, 262],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [
{ "name": "model", "type": "MODEL", "link": null },
{ "name": "positive", "type": "CONDITIONING", "link": null },
{ "name": "negative", "type": "CONDITIONING", "link": null },
{ "name": "latent_image", "type": "LATENT", "link": null }
],
"outputs": [
{
"name": "LATENT",
"type": "LATENT",
"links": [],
"slot_index": 0
}
],
"properties": { "Node name for S&R": "E2E_OldSampler" },
"widgets_values": [42, 20, 7, "euler", "normal"]
},
{
"id": 2,
"type": "E2E_OldSampler",
"pos": [520, 100],
"size": [400, 262],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [
{ "name": "model", "type": "MODEL", "link": null },
{ "name": "positive", "type": "CONDITIONING", "link": null },
{ "name": "negative", "type": "CONDITIONING", "link": null },
{ "name": "latent_image", "type": "LATENT", "link": null }
],
"outputs": [
{
"name": "LATENT",
"type": "LATENT",
"links": [],
"slot_index": 0
}
],
"properties": { "Node name for S&R": "E2E_OldSampler" },
"widgets_values": [43, 20, 7, "euler", "normal"]
}
],
"links": [],
"groups": [],
"config": {},
"extra": { "ds": { "scale": 1, "offset": [0, 0] } },
"version": 0.4
}

View File

@@ -45,6 +45,8 @@ export const TestIds = {
errorOverlayMessages: 'error-overlay-messages',
runtimeErrorPanel: 'runtime-error-panel',
missingNodeCard: 'missing-node-card',
missingNodePackExpand: 'missing-node-pack-expand',
missingNodePackCount: 'missing-node-pack-count',
errorCardFindOnGithub: 'error-card-find-on-github',
errorCardCopy: 'error-card-copy',
errorDialog: 'error-dialog',
@@ -68,6 +70,7 @@ export const TestIds = {
missingModelImportUnsupported: 'missing-model-import-unsupported',
missingMediaGroup: 'error-group-missing-media',
swapNodesGroup: 'error-group-swap-nodes',
swapNodeGroupCount: 'swap-node-group-count',
missingMediaRow: 'missing-media-row',
missingMediaLocateButton: 'missing-media-locate-button',
publishTabPanel: 'publish-tab-panel',

View File

@@ -1,5 +1,6 @@
import type { NodeProperty } from '@/lib/litegraph/src/LGraphNode'
import type { PromotedWidgetSource } from '@/core/graph/subgraph/promotedWidgetTypes'
import { parsePreviewExposures } from '@/core/schemas/previewExposureSchema'
import type { PreviewExposure } from '@/core/schemas/previewExposureSchema'
@@ -7,13 +8,8 @@ import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
export type PromotedWidgetEntry = [string, string]
interface ResolvedWidgetSource {
sourceNodeId: string
sourceWidgetName: string
}
function widgetSourceToEntry(
source: ResolvedWidgetSource
source: PromotedWidgetSource
): PromotedWidgetEntry {
return [source.sourceNodeId, source.sourceWidgetName]
}
@@ -24,22 +20,23 @@ function previewExposureToEntry(
return [exposure.sourceNodeId, exposure.sourcePreviewName]
}
function isPromotedWidgetSource(value: unknown): value is PromotedWidgetSource {
return (
!!value &&
typeof value === 'object' &&
'sourceNodeId' in value &&
'sourceWidgetName' in value &&
typeof value.sourceNodeId === 'string' &&
typeof value.sourceWidgetName === 'string'
)
}
function isNodeProperty(value: unknown): value is NodeProperty {
if (value === null || value === undefined) return false
const t = typeof value
return t === 'string' || t === 'number' || t === 'boolean' || t === 'object'
}
/**
* Reads the promoted widgets of a subgraph host node from the live graph.
*
* Promoted widgets are now store-backed: a host input is promoted iff it
* carries a `widgetId`, and its interior source identity is resolved on demand
* by walking the subgraph input link (mirroring `resolveSubgraphInputTarget`).
* This intentionally avoids the removed `widget.sourceNodeId`/`sourceWidgetName`
* denormalization, so the helper reflects the real projection rather than a
* deleted widget-object contract.
*/
export async function getPromotedWidgets(
comfyPage: ComfyPage,
nodeId: string
@@ -47,49 +44,21 @@ export async function getPromotedWidgets(
const { widgetSources, previewExposures } = await comfyPage.page.evaluate(
(id) => {
const node = window.app!.canvas.graph!.getNodeById(id)
const previewExposures = node?.serialize()?.properties?.previewExposures
if (!node?.isSubgraphNode?.())
return { widgetSources: [], previewExposures }
const { subgraph } = node
const resolveSource = (
inputName: string
): ResolvedWidgetSource | undefined => {
const inputSlot = subgraph.inputNode.slots.find(
(slot) => slot.name === inputName
)
if (!inputSlot) return undefined
for (const linkId of inputSlot.linkIds) {
const link = subgraph.getLink(linkId)
if (!link) continue
const { inputNode } = link.resolve(subgraph)
if (!inputNode || !Array.isArray(inputNode.inputs)) continue
const targetInput = inputNode.inputs.find(
(entry) => entry.link === linkId
)
if (!targetInput) continue
if (inputNode.isSubgraphNode?.()) {
return {
sourceNodeId: String(inputNode.id),
sourceWidgetName: targetInput.name
}
const widgetSources = (node?.widgets ?? []).flatMap((widget) => {
if (!('sourceNodeId' in widget) || !('sourceWidgetName' in widget))
return []
return [
{
sourceNodeId: widget.sourceNodeId,
sourceWidgetName: widget.sourceWidgetName
}
const widget = inputNode.getWidgetFromSlot(targetInput)
if (!widget) continue
return {
sourceNodeId: String(inputNode.id),
sourceWidgetName: widget.name
}
}
return undefined
}
const widgetSources = (node.inputs ?? []).flatMap((input) => {
if (!input.widgetId) return []
const source = resolveSource(input.name)
return source ? [source] : []
]
})
return { widgetSources, previewExposures }
const serializedNode = node?.serialize()
return {
widgetSources,
previewExposures: serializedNode?.properties?.previewExposures
}
},
nodeId
)
@@ -98,7 +67,7 @@ export async function getPromotedWidgets(
? parsePreviewExposures(previewExposures)
: []
return [
...widgetSources.map(widgetSourceToEntry),
...widgetSources.filter(isPromotedWidgetSource).map(widgetSourceToEntry),
...exposures.map(previewExposureToEntry)
]
}

View File

@@ -48,6 +48,36 @@ test.describe('Node replacement', { tag: ['@node', '@ui'] }, () => {
).toBeVisible()
})
test('Shows direct row label and locate action for a single replacement group', async ({
comfyPage
}) => {
const swapGroup = getSwapNodesGroup(comfyPage.page)
const rowLabel = swapGroup.getByRole('button', {
name: 'E2E_OldSampler',
exact: true
})
await expect(rowLabel).toBeVisible()
await expect(
swapGroup.getByRole('button', {
name: 'Locate node on canvas',
exact: true
})
).toBeVisible()
await expect(
swapGroup.getByTestId(TestIds.dialogs.swapNodeGroupCount)
).toHaveCount(0)
await comfyPage.canvasOps.pan({ x: -800, y: -800 })
const offsetBeforeLocate = await comfyPage.canvasOps.getOffset()
await rowLabel.click()
await expect
.poll(() => comfyPage.canvasOps.getOffset())
.not.toEqual(offsetBeforeLocate)
})
test('Replace Node replaces a single group in-place', async ({
comfyPage
}) => {
@@ -116,6 +146,55 @@ test.describe('Node replacement', { tag: ['@node', '@ui'] }, () => {
})
})
test.describe('Same-type replacement group', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting(
'Comfy.VueNodes.Enabled',
mode.vueNodesEnabled
)
await setupNodeReplacement(comfyPage, mockNodeReplacementsSingle)
await loadWorkflowAndOpenErrorsTab(
comfyPage,
'missing/node_replacement_same_type'
)
})
test('Groups same-type replacement rows behind the title disclosure', async ({
comfyPage
}) => {
const swapGroup = getSwapNodesGroup(comfyPage.page)
const countBadge = swapGroup.getByTestId(
TestIds.dialogs.swapNodeGroupCount
)
const childRows = swapGroup.getByRole('listitem')
const expandButton = swapGroup.getByRole('button', {
name: 'Expand E2E_OldSampler',
exact: true
})
await expect(expandButton).toBeVisible()
await expect(countBadge).toHaveText('2')
await expect(childRows).toHaveCount(0)
await expandButton.click()
await expect(childRows).toHaveCount(2)
await expect(
swapGroup.getByRole('button', {
name: 'E2E_OldSampler',
exact: true
})
).toHaveCount(2)
await swapGroup
.getByRole('button', {
name: 'Collapse E2E_OldSampler',
exact: true
})
.click()
await expect(childRows).toHaveCount(0)
})
})
test.describe('Multi-type replacement', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting(

View File

@@ -4,7 +4,7 @@ import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
import { TestIds } from '@e2e/fixtures/selectors'
import { loadWorkflowAndOpenErrorsTab } from '@e2e/fixtures/helpers/ErrorsTabHelper'
test.describe('Errors tab - Missing nodes', { tag: '@ui' }, () => {
test.describe('Errors tab - Missing nodes', { tag: ['@ui', '@canvas'] }, () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting(
'Comfy.RightSidePanel.ShowErrorsTab',
@@ -12,27 +12,39 @@ test.describe('Errors tab - Missing nodes', { tag: '@ui' }, () => {
)
})
test('Should show MissingNodeCard in errors tab', async ({ comfyPage }) => {
await loadWorkflowAndOpenErrorsTab(comfyPage, 'missing/missing_nodes')
await expect(
comfyPage.page.getByTestId(TestIds.dialogs.missingNodeCard)
).toBeVisible()
})
test('Should show missing node packs group', async ({ comfyPage }) => {
test('Should show missing node pack card with guidance', async ({
comfyPage
}) => {
await loadWorkflowAndOpenErrorsTab(comfyPage, 'missing/missing_nodes')
const missingNodeGroup = comfyPage.page.getByTestId(
TestIds.dialogs.missingNodePacksGroup
)
await expect(
comfyPage.page.getByTestId(TestIds.dialogs.missingNodeCard)
).toBeVisible()
await expect(missingNodeGroup).toBeVisible()
await expect(
missingNodeGroup.getByTestId(TestIds.dialogs.errorGroupDisplayMessage)
).toHaveText(/\S/)
})
test('Should expand pack group to reveal node type names', async ({
test('Should show unknown pack node rows by default', async ({
comfyPage
}) => {
await loadWorkflowAndOpenErrorsTab(comfyPage, 'missing/missing_nodes')
const missingNodeCard = comfyPage.page.getByTestId(
TestIds.dialogs.missingNodeCard
)
await expect(missingNodeCard.getByText('Unknown pack')).toBeVisible()
await expect(
missingNodeCard.getByRole('button', { name: 'UNKNOWN NODE' })
).toBeVisible()
})
test('Should show subgraph missing node rows by default', async ({
comfyPage
}) => {
await loadWorkflowAndOpenErrorsTab(
@@ -43,66 +55,72 @@ test.describe('Errors tab - Missing nodes', { tag: '@ui' }, () => {
const missingNodeCard = comfyPage.page.getByTestId(
TestIds.dialogs.missingNodeCard
)
await expect(missingNodeCard).toBeVisible()
await missingNodeCard
.getByRole('button', { name: /expand/i })
.first()
.click()
await expect(
missingNodeCard.getByText('MISSING_NODE_TYPE_IN_SUBGRAPH')
missingNodeCard.getByRole('button', {
name: 'MISSING_NODE_TYPE_IN_SUBGRAPH'
})
).toBeVisible()
})
test('Should collapse expanded pack group', async ({ comfyPage }) => {
await loadWorkflowAndOpenErrorsTab(
comfyPage,
'missing/missing_nodes_in_subgraph'
)
test('Should locate missing node from the row label', async ({
comfyPage
}) => {
await loadWorkflowAndOpenErrorsTab(comfyPage, 'missing/missing_nodes')
const missingNodeCard = comfyPage.page.getByTestId(
TestIds.dialogs.missingNodeCard
)
await missingNodeCard
.getByRole('button', { name: /expand/i })
.first()
.click()
await expect(
missingNodeCard.getByText('MISSING_NODE_TYPE_IN_SUBGRAPH')
).toBeVisible()
await comfyPage.canvasOps.pan({ x: -800, y: -800 })
const offsetBeforeLocate = await comfyPage.canvasOps.getOffset()
await missingNodeCard
.getByRole('button', { name: /collapse/i })
.first()
.click()
await expect(
missingNodeCard.getByText('MISSING_NODE_TYPE_IN_SUBGRAPH')
).toBeHidden()
await missingNodeCard.getByRole('button', { name: 'UNKNOWN NODE' }).click()
await expect
.poll(() => comfyPage.canvasOps.getOffset())
.not.toEqual(offsetBeforeLocate)
})
test('Locate node button is visible for expanded pack nodes', async ({
test('Should toggle grouped pack nodes from chevron and title', async ({
comfyPage
}) => {
await loadWorkflowAndOpenErrorsTab(
comfyPage,
'missing/missing_nodes_in_subgraph'
'missing/missing_nodes_same_pack'
)
const missingNodeCard = comfyPage.page.getByTestId(
TestIds.dialogs.missingNodeCard
)
await missingNodeCard
.getByRole('button', { name: /expand/i })
.first()
.click()
const locateButton = missingNodeCard.getByRole('button', {
name: /locate/i
const packTitle = missingNodeCard.getByRole('button', {
name: 'test-missing-node-pack'
})
await expect(locateButton.first()).toBeVisible()
// TODO: Add navigation assertion once subgraph node ID deduplication
// timing is fixed. Currently, collectMissingNodes runs before
// configure(), so execution IDs use pre-remapped node IDs that don't
// match the runtime graph. See PR #9510 / #8762.
const expandButton = missingNodeCard.getByTestId(
TestIds.dialogs.missingNodePackExpand
)
const firstNode = missingNodeCard.getByRole('button', {
name: 'TEST_MISSING_PACK_NODE_A'
})
const secondNode = missingNodeCard.getByRole('button', {
name: 'TEST_MISSING_PACK_NODE_B'
})
await expect(packTitle).toBeVisible()
await expect(
missingNodeCard.getByTestId(TestIds.dialogs.missingNodePackCount)
).toHaveText('2')
await expect(firstNode).toBeHidden()
await expect(secondNode).toBeHidden()
await expandButton.click()
await expect(firstNode).toBeVisible()
await expect(secondNode).toBeVisible()
await packTitle.click()
await expect(firstNode).toBeHidden()
await expect(secondNode).toBeHidden()
await packTitle.click()
await expect(firstNode).toBeVisible()
await expect(secondNode).toBeVisible()
})
})

View File

@@ -425,6 +425,56 @@ test.describe('Errors tab - Mode-aware errors', { tag: '@ui' }, () => {
}
)
test(
'Refreshing a resolved promoted missing model clears the combo invalid state',
{ tag: ['@widget', '@subgraph'] },
async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
await loadWorkflowAndOpenErrorsTab(
comfyPage,
'missing/missing_model_promoted_widget'
)
await comfyPage.vueNodes.waitForNodes()
const missingModelGroup = comfyPage.page.getByTestId(
TestIds.dialogs.missingModelsGroup
)
await expect(missingModelGroup).toContainText(
/fake_model\.safetensors\s*\(1\)/
)
const promotedModelCombo = comfyPage.vueNodes
.getNodeByTitle('Subgraph with Promoted Missing Model')
.getByRole('combobox', { name: 'ckpt_name', exact: true })
await expect(promotedModelCombo).toHaveAttribute('aria-invalid', 'true')
const objectInfoRoute = /\/object_info$/
try {
await comfyPage.page.route(objectInfoRoute, async (route) => {
const response = await route.fetch()
const objectInfo = await response.json()
const ckptName =
objectInfo.CheckpointLoaderSimple.input.required.ckpt_name
ckptName[0] = [...ckptName[0], 'fake_model.safetensors']
await route.fulfill({ response, json: objectInfo })
})
await comfyPage.page
.getByTestId(TestIds.dialogs.missingModelRefresh)
.click()
await expect(missingModelGroup).toBeHidden()
await expect(promotedModelCombo).toBeVisible()
await expect(promotedModelCombo).not.toHaveAttribute(
'aria-invalid',
'true'
)
} finally {
await comfyPage.page.unroute(objectInfoRoute)
}
}
)
test('Bypassing a subgraph hides interior errors, un-bypassing restores them', async ({
comfyPage
}) => {

View File

@@ -0,0 +1,101 @@
import { expect } from '@playwright/test'
import type { RemoteConfig } from '@/platform/remoteConfig/types'
import type { WorkspaceWithRole } from '@/platform/workspace/api/workspaceApi'
import type { WorkspaceTokenResponse } from '@/platform/workspace/stores/workspaceAuthStore'
import { comfyPageFixture } from '@e2e/fixtures/ComfyPage'
const PERSONAL_WORKSPACE_NAME = 'Personal Workspace'
const LONG_WORKSPACE_NAME =
'Quantum Renaissance Collective for Hyperdimensional Latent Diffusion Research and Experimental Workflow Engineering'
// text-sm rows render a single 20px line; a wrapped name is 40px+.
const SINGLE_LINE_MAX_HEIGHT_PX = 28
const mockRemoteConfig: RemoteConfig = { team_workspaces_enabled: true }
const mockListWorkspacesResponse: { workspaces: WorkspaceWithRole[] } = {
workspaces: [
{
id: 'ws-personal',
name: PERSONAL_WORKSPACE_NAME,
type: 'personal',
created_at: '2026-01-01T00:00:00Z',
joined_at: '2026-01-01T00:00:00Z',
role: 'owner'
},
{
id: 'ws-team-long',
name: LONG_WORKSPACE_NAME,
type: 'team',
created_at: '2026-01-02T00:00:00Z',
joined_at: '2026-01-02T00:00:00Z',
role: 'member'
}
]
}
const mockTokenResponse: WorkspaceTokenResponse = {
token: 'mock-workspace-token',
expires_at: new Date(Date.now() + 60 * 60 * 1000).toISOString(),
workspace: {
id: 'ws-personal',
name: PERSONAL_WORKSPACE_NAME,
type: 'personal'
},
role: 'owner',
permissions: []
}
const test = comfyPageFixture.extend({
page: async ({ page }, use) => {
await page.route('**/api/features', (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(mockRemoteConfig)
})
)
await page.route('**/api/workspaces', async (route) => {
if (route.request().method() !== 'GET') {
await route.fallback()
return
}
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(mockListWorkspacesResponse)
})
})
await page.route('**/api/auth/token', (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(mockTokenResponse)
})
)
await use(page)
}
})
test.describe('Workspace switcher', { tag: '@cloud' }, () => {
test('renders a long team workspace name on a single line', async ({
comfyPage
}) => {
const page = comfyPage.page
await comfyPage.toast.closeToasts()
await page.getByRole('button', { name: 'Current user' }).click()
await page.getByText(PERSONAL_WORKSPACE_NAME).click()
const longName = page.getByText(LONG_WORKSPACE_NAME)
await expect(longName).toBeVisible()
const box = await longName.boundingBox()
expect(box).not.toBeNull()
expect(box!.height).toBeLessThan(SINGLE_LINE_MAX_HEIGHT_PX)
})
})

View File

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

View File

@@ -111,6 +111,7 @@ describe('formatUtil', () => {
expect(getMediaTypeFromFilename('scene.fbx')).toBe('3D')
expect(getMediaTypeFromFilename('asset.gltf')).toBe('3D')
expect(getMediaTypeFromFilename('binary.glb')).toBe('3D')
expect(getMediaTypeFromFilename('print.stl')).toBe('3D')
expect(getMediaTypeFromFilename('apple.usdz')).toBe('3D')
expect(getMediaTypeFromFilename('scan.ply')).toBe('3D')
})

View File

@@ -591,7 +591,15 @@ const IMAGE_EXTENSIONS = [
] as const
const VIDEO_EXTENSIONS = ['mp4', 'm4v', 'webm', 'mov', 'avi', 'mkv'] as const
const AUDIO_EXTENSIONS = ['mp3', 'wav', 'ogg', 'flac'] as const
const THREE_D_EXTENSIONS = ['obj', 'fbx', 'gltf', 'glb', 'usdz', 'ply'] as const
const THREE_D_EXTENSIONS = [
'obj',
'fbx',
'gltf',
'glb',
'stl',
'usdz',
'ply'
] as const
const TEXT_EXTENSIONS = [
'txt',
'md',

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

View File

@@ -1,9 +1,9 @@
<svg width="520" height="520" viewBox="0 0 520 520" fill="none" xmlns="http://www.w3.org/2000/svg">
<mask id="mask0_227_285" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="0" y="0" width="520" height="520">
<path d="M0 184.335C0 119.812 0 87.5502 12.5571 62.9055C23.6026 41.2274 41.2274 23.6026 62.9055 12.5571C87.5502 0 119.812 0 184.335 0H335.665C400.188 0 432.45 0 457.094 12.5571C478.773 23.6026 496.397 41.2274 507.443 62.9055C520 87.5502 520 119.812 520 184.335V335.665C520 400.188 520 432.45 507.443 457.094C496.397 478.773 478.773 496.397 457.094 507.443C432.45 520 400.188 520 335.665 520H184.335C119.812 520 87.5502 520 62.9055 507.443C41.2274 496.397 23.6026 478.773 12.5571 457.094C0 432.45 0 400.188 0 335.665V184.335Z" fill="#EEFF30"/>
<path d="M0 184.335C0 119.812 0 87.5502 12.5571 62.9055C23.6026 41.2274 41.2274 23.6026 62.9055 12.5571C87.5502 0 119.812 0 184.335 0H335.665C400.188 0 432.45 0 457.094 12.5571C478.773 23.6026 496.397 41.2274 507.443 62.9055C520 87.5502 520 119.812 520 184.335V335.665C520 400.188 520 432.45 507.443 457.094C496.397 478.773 478.773 496.397 457.094 507.443C432.45 520 400.188 520 335.665 520H184.335C119.812 520 87.5502 520 62.9055 507.443C41.2274 496.397 23.6026 478.773 12.5571 457.094C0 432.45 0 400.188 0 335.665V184.335Z" fill="#F2FF59"/>
</mask>
<g mask="url(#mask0_227_285)">
<rect y="0.751831" width="520" height="520" fill="#172DD7"/>
<path d="M176.484 428.831C168.649 428.831 162.327 425.919 158.204 420.412C153.966 414.755 152.861 406.857 155.171 398.749L164.447 366.178C165.187 363.585 164.672 360.794 163.059 358.636C161.446 356.483 158.921 355.216 156.241 355.216H129.571C121.731 355.216 115.409 352.308 111.289 346.802C107.051 341.14 105.946 333.242 108.258 325.134L140.124 213.748L143.642 201.51C148.371 184.904 165.62 171.407 182.097 171.407H214.009C217.817 171.407 221.167 168.868 222.215 165.183L232.769 128.135C237.494 111.545 254.742 98.048 271.219 98.048L339.468 97.9264L389.431 97.9221C397.268 97.9221 403.59 100.831 407.711 106.337C411.949 111.994 413.054 119.892 410.744 128L396.457 178.164C391.734 194.75 374.485 208.242 358.009 208.242L289.607 208.372H257.706C253.902 208.372 250.557 210.907 249.502 214.588L222.903 307.495C222.159 310.093 222.673 312.892 224.291 315.049C225.904 317.202 228.428 318.469 231.107 318.469C231.113 318.469 276.307 318.381 276.307 318.381H326.122C333.959 318.381 340.281 321.29 344.402 326.796C348.639 332.457 349.744 340.355 347.433 348.463L333.146 398.619C328.423 415.209 311.174 428.701 294.698 428.701L226.299 428.831H176.484Z" fill="#F0FF41"/>
<rect y="0.751831" width="520" height="520" fill="#211927"/>
<path d="M176.484 428.831C168.649 428.831 162.327 425.919 158.204 420.412C153.966 414.755 152.861 406.857 155.171 398.749L164.447 366.178C165.187 363.585 164.672 360.794 163.059 358.636C161.446 356.483 158.921 355.216 156.241 355.216H129.571C121.731 355.216 115.409 352.308 111.289 346.802C107.051 341.14 105.946 333.242 108.258 325.134L140.124 213.748L143.642 201.51C148.371 184.904 165.62 171.407 182.097 171.407H214.009C217.817 171.407 221.167 168.868 222.215 165.183L232.769 128.135C237.494 111.545 254.742 98.048 271.219 98.048L339.468 97.9264L389.431 97.9221C397.268 97.9221 403.59 100.831 407.711 106.337C411.949 111.994 413.054 119.892 410.744 128L396.457 178.164C391.734 194.75 374.485 208.242 358.009 208.242L289.607 208.372H257.706C253.902 208.372 250.557 210.907 249.502 214.588L222.903 307.495C222.159 310.093 222.673 312.892 224.291 315.049C225.904 317.202 228.428 318.469 231.107 318.469C231.113 318.469 276.307 318.381 276.307 318.381H326.122C333.959 318.381 340.281 321.29 344.402 326.796C348.639 332.457 349.744 340.355 347.433 348.463L333.146 398.619C328.423 415.209 311.174 428.701 294.698 428.701L226.299 428.831H176.484Z" fill="#F2FF59"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 2.0 KiB

After

Width:  |  Height:  |  Size: 2.0 KiB

View File

@@ -2,8 +2,8 @@
"PreviewImage": 4314,
"LoadImage": 3474,
"CLIPTextEncode": 2435,
"SaveImageAdvanced": 1763,
"SaveImage": 1762,
"SaveImageAdvanced": 1762,
"VAEDecode": 1754,
"KSampler": 1511,
"CheckpointLoaderSimple": 1293,
@@ -14,6 +14,7 @@
"UpscaleModelLoader": 629,
"UNETLoader": 606,
"VAELoader": 604,
"PreviewAny": 528,
"ShowText|pysssss": 527.5526981023964,
"ImageUpscaleWithModel": 523,
"ControlNetApplyAdvanced": 513,
@@ -24,10 +25,12 @@
"VHS_LoadVideo": 440,
"ImpactSwitch": 349,
"Reroute": 348,
"ResizeImageMaskNode": 337,
"ResizeAndPadImage": 336,
"ImageResizeKJv2": 335,
"StringConcatenate": 326,
"Text Concatenate": 325.7030402103206,
"SaveVideo": 321,
"PreviewAny": 319,
"KSamplerAdvanced": 304,
"SDXLPromptStyler": 297.0913411304729,
"Note": 291,
@@ -52,6 +55,7 @@
"CLIPLoader": 202,
"GeminiNode": 202,
"KSampler (Efficient)": 194.01083622636423,
"RemoveBackground": 187,
"ImageRemoveBackground+": 186,
"IPAdapterModelLoader": 184,
"PrimitiveInt": 183,
@@ -59,7 +63,9 @@
"LoadVideo": 179,
"Text Concatenate (JPS)": 175.98154639522735,
"PrimitiveNode": 175,
"Text Multiline": 163.04749064680308,
"PrimitiveStringMultiline": 166,
"Text Multiline": 165,
"GetImageSize": 164,
"GetImageSize+": 163,
"ImageScaleToTotalPixels": 157,
"String Literal": 150.11343489837878,
@@ -68,15 +74,14 @@
"DownloadAndLoadFlorence2Model": 144,
"LoadImageOutput": 143,
"IPAdapterUnifiedLoader": 141,
"FluxGuidance": 133,
"BatchImagesNode": 134,
"ImageBatchMulti": 133,
"FluxGuidance": 132,
"ByteDanceSeedreamNode": 130,
"CR Text Input Switch": 128.16473423438606,
"IPAdapterAdvanced": 128,
"If ANY execute A else B": 127.77279315110049,
"GeminiImage2Node": 124,
"GetImageSize": 121,
"PrimitiveStringMultiline": 120,
"IPAdapter": 118,
"CreateVideo": 116,
"ConditioningZeroOut": 115,
@@ -102,6 +107,7 @@
"DepthAnythingPreprocessor": 100,
"CR Apply LoRA Stack": 96.02556540496816,
"Image Filter Adjustments": 95.24168323839699,
"ComfyMathExpression": 96,
"SimpleMath+": 95,
"GroundingDinoSAMSegment (segment anything)": 93.28197782196906,
"Image Overlay": 93.28197782196906,
@@ -147,7 +153,6 @@
"Image Resize": 63.494455492264656,
"Automatic CFG": 63.494455492264656,
"Canny": 63,
"StringConcatenate": 63,
"DepthAnything_V2": 61,
"ImageCrop+": 60,
"ModelSamplingSD3": 59,
@@ -199,6 +204,7 @@
"BNK_CLIPTextEncodeAdvanced": 45.857106744413365,
"CR SDXL Aspect Ratio": 45.46516566112778,
"LoadAudio": 45,
"ResolutionSelector": 45,
"smZ CLIPTextEncode": 44.68128349455661,
"Bus Node": 44.68128349455661,
"PreviewTextNode": 44.68128349455661,
@@ -389,7 +395,6 @@
"SD_4XUpscale_Conditioning": 21,
"UltimateSDUpscaleCustomSample": 21,
"StyleModelLoader": 21,
"ResizeAndPadImage": 21,
"Text Random Prompt": 20.77287741413597,
"INPAINT_VAEEncodeInpaintConditioning": 20.77287741413597,
"BrushNet": 20.77287741413597,

View File

@@ -10,7 +10,7 @@ import IoItem from '@/components/builder/IoItem.vue'
import PropertiesAccordionItem from '@/components/rightSidePanel/layout/PropertiesAccordionItem.vue'
import { useResolvedSelectedInputs } from '@/components/builder/useResolvedSelectedInputs'
import type { ResolvedSelection } from '@/components/builder/useResolvedSelectedInputs'
import type { WidgetId } from '@/types/widgetId'
import type { WidgetEntityId } from '@/world/entityIds'
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
import type { LGraphNode, NodeId } from '@/lib/litegraph/src/LGraphNode'
import type { LGraphCanvas } from '@/lib/litegraph/src/LGraphCanvas'
@@ -110,8 +110,8 @@ function getWidgetBounding(entry: ResolvedSelection): BoundStyle | undefined {
}
}
function removeSelectedWidgetId(widgetId: WidgetId): void {
const index = appModeStore.selectedInputs.findIndex(([id]) => id === widgetId)
function removeSelectedEntityId(entityId: WidgetEntityId): void {
const index = appModeStore.selectedInputs.findIndex(([id]) => id === entityId)
if (index !== -1) appModeStore.selectedInputs.splice(index, 1)
}
@@ -139,11 +139,11 @@ function handleClick(e: MouseEvent) {
}
if (!isSelectInputsMode.value || widget.options.canvasOnly) return
const widgetId = widget.widgetId
if (!widgetId) return
const index = appModeStore.selectedInputs.findIndex(([id]) => id === widgetId)
const entityId = widget.entityId
if (!entityId) return
const index = appModeStore.selectedInputs.findIndex(([id]) => id === entityId)
if (index === -1)
appModeStore.selectedInputs.push([widgetId, widget.name, undefined])
appModeStore.selectedInputs.push([entityId, widget.name, undefined])
else appModeStore.selectedInputs.splice(index, 1)
}
@@ -172,7 +172,7 @@ const renderedInputs = computed<[string, MaybeRef<BoundStyle> | undefined][]>(
() =>
resolvedInputs.value.map(
(entry) =>
[entry.widgetId, getWidgetBounding(entry)] as [
[entry.entityId, getWidgetBounding(entry)] as [
string,
MaybeRef<BoundStyle> | undefined
]
@@ -220,7 +220,7 @@ const renderedInputs = computed<[string, MaybeRef<BoundStyle> | undefined][]>(
v-slot="{ dragClass }"
v-model="appModeStore.selectedInputs"
>
<template v-for="entry in resolvedInputs" :key="entry.widgetId">
<template v-for="entry in resolvedInputs" :key="entry.entityId">
<IoItem
v-if="entry.status === 'resolved'"
:class="
@@ -239,7 +239,7 @@ const renderedInputs = computed<[string, MaybeRef<BoundStyle> | undefined][]>(
"
:title="entry.displayName"
:sub-title="t('linearMode.builder.unknownWidget')"
:remove="() => removeSelectedWidgetId(entry.widgetId)"
:remove="() => removeSelectedEntityId(entry.entityId)"
/>
</template>
</DraggableList>

View File

@@ -8,7 +8,6 @@ import { useI18n } from 'vue-i18n'
import Popover from '@/components/ui/Popover.vue'
import Button from '@/components/ui/button/Button.vue'
import { extractVueNodeData } from '@/composables/graph/useGraphNodeManager'
import { UNASSIGNED_NODE_ID } from '@/lib/litegraph/src/utils/nodeId'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import { LGraphEventMode } from '@/lib/litegraph/src/types/globalEnums'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
@@ -61,7 +60,7 @@ const mappedSelections = computed((): WidgetEntry[] => {
return resolvedInputs.value.flatMap((entry) => {
if (entry.status !== 'resolved') return []
const { widgetId, node, widget, config } = entry
const { entityId, node, widget, config } = entry
if (node.mode !== LGraphEventMode.ALWAYS) return []
if (!nodeDataByNode.has(node)) {
@@ -71,16 +70,16 @@ const mappedSelections = computed((): WidgetEntry[] => {
const matchingWidget = fullNodeData.widgets?.find((vueWidget) => {
if (vueWidget.slotMetadata?.linked) return false
return vueWidget.widgetId === widgetId
return vueWidget.entityId === entityId
})
if (!matchingWidget) return []
matchingWidget.slotMetadata = undefined
matchingWidget.nodeId = node.id
matchingWidget.nodeId = String(node.id)
return [
{
key: widgetId,
key: entityId,
persistedHeight: config?.height,
nodeData: {
...fullNodeData,
@@ -140,7 +139,7 @@ async function handleDragDrop() {
return false
}
app.dragOverNode = { id: UNASSIGNED_NODE_ID, onDragDrop }
app.dragOverNode = { id: -1, onDragDrop }
}
defineExpose({ handleDragDrop })

View File

@@ -4,10 +4,9 @@ import { setActivePinia } from 'pinia'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import type { INodeInputSlot } from '@/lib/litegraph/src/interfaces'
import { app } from '@/scripts/app'
import { useAppModeStore } from '@/stores/appModeStore'
import type { WidgetId } from '@/types/widgetId'
import type { WidgetEntityId } from '@/world/entityIds'
import { useResolvedSelectedInputs } from './useResolvedSelectedInputs'
@@ -23,29 +22,18 @@ vi.mock('@/scripts/app', () => ({
}))
const rootGraphId = '11111111-1111-4111-8111-111111111111'
const entitySeed = `${rootGraphId}:1:seed` as WidgetId
const entitySeed = `${rootGraphId}:1:seed` as WidgetEntityId
function makeNode(id: number, widgetNames: string[]): LGraphNode {
return fromAny<LGraphNode, unknown>({
id,
inputs: [],
isSubgraphNode: () => false,
widgets: widgetNames.map((name) => ({
name,
widgetId: `${rootGraphId}:${id}:${name}` as WidgetId
entityId: `${rootGraphId}:${id}:${name}` as WidgetEntityId
}))
})
}
function makeSubgraphNode(id: number, inputs: INodeInputSlot[]): LGraphNode {
return fromAny<LGraphNode, unknown>({
id,
inputs,
isSubgraphNode: () => true,
widgets: []
})
}
function setRootGraphNodes(nodes: LGraphNode[]) {
vi.mocked(app.rootGraph).nodes = nodes
vi.mocked(app.rootGraph).getNodeById = vi.fn(
@@ -100,27 +88,4 @@ describe('useResolvedSelectedInputs', () => {
expect(resolved.value[0]?.status).toBe('unknown')
})
it('resolves promoted subgraph inputs from their host input widgetId', () => {
const node = makeSubgraphNode(1, [
fromAny<INodeInputSlot, unknown>({
name: 'seed',
label: 'renamed_seed',
widgetId: entitySeed
})
])
setRootGraphNodes([node])
const appModeStore = useAppModeStore()
appModeStore.selectedInputs = [[entitySeed, 'seed']]
const resolved = useResolvedSelectedInputs()
expect(resolved.value[0]).toMatchObject({
status: 'resolved',
node,
displayName: 'seed',
widget: { name: 'seed', label: 'renamed_seed', widgetId: entitySeed }
})
})
})

View File

@@ -1,19 +1,18 @@
import { useEventListener } from '@vueuse/core'
import { computed, shallowRef, triggerRef } from 'vue'
import { promotedInputWidgets } from '@/core/graph/subgraph/promotedInputWidget'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import type { InputWidgetConfig } from '@/platform/workflow/management/stores/comfyWorkflow'
import { app } from '@/scripts/app'
import { useAppModeStore } from '@/stores/appModeStore'
import type { WidgetId } from '@/types/widgetId'
import { isWidgetId, parseWidgetId } from '@/types/widgetId'
import type { WidgetEntityId } from '@/world/entityIds'
import { isWidgetEntityId, parseWidgetEntityId } from '@/world/entityIds'
export type ResolvedSelection =
| {
status: 'resolved'
widgetId: WidgetId
entityId: WidgetEntityId
node: LGraphNode
widget: IBaseWidget
displayName: string
@@ -21,7 +20,7 @@ export type ResolvedSelection =
}
| {
status: 'unknown'
widgetId: WidgetId
entityId: WidgetEntityId
displayName: string
config?: InputWidgetConfig
}
@@ -55,19 +54,16 @@ export function useResolvedSelectedInputs() {
if (!rootGraph) return []
return appModeStore.selectedInputs.flatMap(
([widgetId, displayName, config]): ResolvedSelection[] => {
if (!isWidgetId(widgetId)) return []
const { nodeId, name } = parseWidgetId(widgetId)
([entityId, displayName, config]): ResolvedSelection[] => {
if (!isWidgetEntityId(entityId)) return []
const { nodeId, name } = parseWidgetEntityId(entityId)
const node = rootGraph.getNodeById(nodeId)
const widgets = node?.isSubgraphNode()
? promotedInputWidgets(node)
: node?.widgets
const widget = widgets?.find((w) => w.name === name)
const widget = node?.widgets?.find((w) => w.name === name)
if (!node || !widget) {
return [{ status: 'unknown', widgetId, displayName, config }]
return [{ status: 'unknown', entityId, displayName, config }]
}
return [
{ status: 'resolved', widgetId, node, widget, displayName, config }
{ status: 'resolved', entityId, node, widget, displayName, config }
]
}
)

View File

@@ -33,7 +33,6 @@ const {
items,
gridStyle,
bufferRows = 1,
scrollThrottle = 64,
resizeDebounce = 64,
defaultItemHeight = 200,
defaultItemWidth = 200,
@@ -42,7 +41,6 @@ const {
items: (T & { key: string })[]
gridStyle: CSSProperties
bufferRows?: number
scrollThrottle?: number
resizeDebounce?: number
defaultItemHeight?: number
defaultItemWidth?: number
@@ -61,7 +59,6 @@ const itemWidth = ref(defaultItemWidth)
const container = ref<HTMLElement | null>(null)
const { width, height } = useElementSize(container)
const { y: scrollY } = useScroll(container, {
throttle: scrollThrottle,
eventListenerOptions: { passive: true }
})

View File

@@ -5,7 +5,6 @@ import { nextTick } from 'vue'
import { createI18n } from 'vue-i18n'
import ErrorOverlay from './ErrorOverlay.vue'
import { asNodeId } from '@/lib/litegraph/src/litegraph'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import type { NodeError } from '@/schemas/apiSchema'
import type { ErrorGroup } from '@/components/rightSidePanel/errors/types'
@@ -121,7 +120,7 @@ describe('ErrorOverlay', () => {
const executionErrorStore = useExecutionErrorStore()
executionErrorStore.lastNodeErrors = {
[asNodeId('1')]: makeNodeError(['Only error'])
'1': makeNodeError(['Only error'])
}
mockAllErrorGroups.value = [
{
@@ -154,7 +153,7 @@ describe('ErrorOverlay', () => {
const executionErrorStore = useExecutionErrorStore()
executionErrorStore.lastNodeErrors = {
[asNodeId('1')]: makeNodeError(['Only error'])
'1': makeNodeError(['Only error'])
}
mockAllErrorGroups.value = [
{

View File

@@ -5,7 +5,6 @@ import { defineComponent, nextTick } from 'vue'
import { createI18n } from 'vue-i18n'
import { useErrorOverlayState } from './useErrorOverlayState'
import { asNodeId } from '@/lib/litegraph/src/litegraph'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { useMissingMediaStore } from '@/platform/missingMedia/missingMediaStore'
import type { NodeError } from '@/schemas/apiSchema'
@@ -100,7 +99,7 @@ describe('useErrorOverlayState', () => {
const executionErrorStore = useExecutionErrorStore()
executionErrorStore.lastNodeErrors = {
[asNodeId('1')]: makeNodeError(['Only error'])
'1': makeNodeError(['Only error'])
}
mockAllErrorGroups.value = [
{
@@ -130,7 +129,7 @@ describe('useErrorOverlayState', () => {
const executionErrorStore = useExecutionErrorStore()
executionErrorStore.lastNodeErrors = {
[asNodeId('1')]: makeNodeError(['Required input is missing'])
'1': makeNodeError(['Required input is missing'])
}
mockAllErrorGroups.value = [
{
@@ -169,7 +168,7 @@ describe('useErrorOverlayState', () => {
const executionErrorStore = useExecutionErrorStore()
executionErrorStore.lastNodeErrors = {
[asNodeId('1')]: makeNodeError(['Raw validation error'])
'1': makeNodeError(['Raw validation error'])
}
mockAllErrorGroups.value = [
{
@@ -208,7 +207,7 @@ describe('useErrorOverlayState', () => {
const executionErrorStore = useExecutionErrorStore()
executionErrorStore.lastExecutionError = {
prompt_id: 'prompt',
node_id: asNodeId(1),
node_id: 1,
node_type: 'KSampler',
executed: [],
exception_message: 'CUDA out of memory',
@@ -254,7 +253,7 @@ describe('useErrorOverlayState', () => {
const missingMediaStore = useMissingMediaStore()
missingMediaStore.setMissingMedia([
{
nodeId: asNodeId('1'),
nodeId: '1',
nodeType: 'LoadImage',
widgetName: 'image',
mediaType: 'image',
@@ -287,7 +286,7 @@ describe('useErrorOverlayState', () => {
const executionErrorStore = useExecutionErrorStore()
executionErrorStore.lastNodeErrors = {
[asNodeId('1')]: makeNodeError(['Only error'])
'1': makeNodeError(['Only error'])
}
executionErrorStore.showErrorOverlay()
await nextTick()
@@ -301,7 +300,7 @@ describe('useErrorOverlayState', () => {
const executionErrorStore = useExecutionErrorStore()
executionErrorStore.lastNodeErrors = {
[asNodeId('1')]: makeNodeError([
'1': makeNodeError([
'First error',
'Second error',
'Third error',

View File

@@ -6,7 +6,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import DomWidgets from '@/components/graph/DomWidgets.vue'
import { Rectangle } from '@/lib/litegraph/src/infrastructure/Rectangle'
import { LGraph, LGraphNode, asNodeId } from '@/lib/litegraph/src/litegraph'
import { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { LGraphCanvas } from '@/lib/litegraph/src/LGraphCanvas'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import type { BaseDOMWidget } from '@/scripts/domWidget'
@@ -21,7 +21,7 @@ function createNode(
pos: [number, number]
) {
const node = new LGraphNode(title)
node.id = asNodeId(id)
node.id = id
node.pos = [...pos]
node.size = [240, 120]
graph.add(node)

View File

@@ -5,7 +5,6 @@ import { ref } from 'vue'
import { createI18n } from 'vue-i18n'
import Load3D from '@/components/load3d/Load3D.vue'
import { asNodeId } from '@/lib/litegraph/src/litegraph'
import type { ComponentWidget } from '@/scripts/domWidget'
const { load3dState, resolveNodeMock, settingGetMock } = vi.hoisted(() => ({
@@ -110,8 +109,7 @@ function renderLoad3D(options: RenderOptions = {}) {
widget: (options.widget ?? {
node: MOCK_NODE
}) as unknown as ComponentWidget<string[]>,
nodeId:
options.nodeId === undefined ? undefined : asNodeId(options.nodeId)
nodeId: options.nodeId
},
global: {
plugins: [i18n],
@@ -170,7 +168,7 @@ describe('Load3D', () => {
resolveNodeMock.mockReturnValue(MOCK_NODE)
renderLoad3D({ widget: {}, nodeId: 42 })
expect(resolveNodeMock).toHaveBeenCalledWith('42')
expect(resolveNodeMock).toHaveBeenCalledWith(42)
expect(await screen.findByTestId('load3d-scene')).toBeInTheDocument()
})

View File

@@ -23,6 +23,8 @@
:can-use-gizmo="canUseGizmo"
:can-use-lighting="canUseLighting"
:can-export="canExport"
:can-use-hdri="canUseHdri"
:can-use-background-image="canUseBackgroundImage"
:material-modes="materialModes"
:has-skeleton="hasSkeleton"
@update-background-image="handleBackgroundImageUpdate"
@@ -86,7 +88,7 @@
/>
<RecordingControls
v-if="!isPreview"
v-if="canUseRecording && !isPreview"
v-model:is-recording="isRecording"
v-model:has-recording="hasRecording"
v-model:recording-duration="recordingDuration"
@@ -117,9 +119,18 @@ import { resolveNode } from '@/utils/litegraphUtil'
import type { ComponentWidget } from '@/scripts/domWidget'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
const props = defineProps<{
const {
widget,
nodeId,
canUseRecording = true,
canUseHdri = true,
canUseBackgroundImage = true
} = defineProps<{
widget: ComponentWidget<string[]> | SimplifiedWidget
nodeId?: NodeId
canUseRecording?: boolean
canUseHdri?: boolean
canUseBackgroundImage?: boolean
}>()
function isComponentWidget(
@@ -130,11 +141,11 @@ function isComponentWidget(
const node = ref<LGraphNode | null>(null)
if (isComponentWidget(props.widget)) {
node.value = props.widget.node
} else if (props.nodeId) {
if (isComponentWidget(widget)) {
node.value = widget.node
} else if (nodeId) {
onMounted(() => {
node.value = resolveNode(props.nodeId!) ?? null
node.value = resolveNode(nodeId) ?? null
})
}

View File

@@ -0,0 +1,47 @@
import { render } from '@testing-library/vue'
import { describe, expect, it, vi } from 'vitest'
import { defineComponent, h, ref } from 'vue'
const lastProps = ref<Record<string, unknown> | null>(null)
vi.mock('@/components/load3d/Load3D.vue', () => ({
default: defineComponent({
name: 'Load3D',
props: {
widget: { type: null, required: false, default: undefined },
nodeId: { type: null, required: false, default: undefined },
canUseRecording: { type: Boolean, default: true },
canUseHdri: { type: Boolean, default: true },
canUseBackgroundImage: { type: Boolean, default: true }
},
setup(props: Record<string, unknown>) {
lastProps.value = { ...props }
return () => h('div', { 'data-testid': 'load3d-stub' })
}
})
}))
import Load3DAdvanced from '@/components/load3d/Load3DAdvanced.vue'
describe('Load3DAdvanced', () => {
it('renders the inner Load3D with all expressive features disabled', () => {
const MOCK_NODE = { id: 'node', type: 'Load3DAdvanced' }
render(Load3DAdvanced, {
props: {
widget: { node: MOCK_NODE } as never
}
})
expect(lastProps.value).toMatchObject({
canUseRecording: false,
canUseHdri: false,
canUseBackgroundImage: false
})
})
it('forwards widget and nodeId to the inner Load3D', () => {
const widget = { node: { id: 'a', type: 'Load3DAdvanced' } }
render(Load3DAdvanced, { props: { widget: widget as never, nodeId: 'a' } })
expect(lastProps.value?.widget).toEqual(widget)
expect(lastProps.value?.nodeId).toBe('a')
})
})

View File

@@ -0,0 +1,21 @@
<template>
<Load3D
:widget="widget"
:node-id="nodeId"
:can-use-recording="false"
:can-use-hdri="false"
:can-use-background-image="false"
/>
</template>
<script setup lang="ts">
import Load3D from '@/components/load3d/Load3D.vue'
import type { NodeId } from '@/platform/workflow/validation/schemas/workflowSchema'
import type { ComponentWidget } from '@/scripts/domWidget'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
defineProps<{
widget: ComponentWidget<string[]> | SimplifiedWidget
nodeId?: NodeId
}>()
</script>

View File

@@ -52,6 +52,7 @@
v-model:background-image="sceneConfig!.backgroundImage"
v-model:background-render-mode="sceneConfig!.backgroundRenderMode"
v-model:fov="cameraConfig!.fov"
:show-background-image="canUseBackgroundImage"
:hdri-active="
!!lightConfig?.hdri?.hdriPath && !!lightConfig?.hdri?.enabled
"
@@ -81,6 +82,7 @@
/>
<HDRIControls
v-if="canUseHdri"
v-model:hdri-config="lightConfig!.hdri"
:has-background-image="!!sceneConfig?.backgroundImage"
@update-hdri-file="handleHDRIFileUpdate"
@@ -129,12 +131,16 @@ const {
canUseGizmo = true,
canUseLighting = true,
canExport = true,
canUseHdri = true,
canUseBackgroundImage = true,
materialModes = ['original', 'normal', 'wireframe'],
hasSkeleton = false
} = defineProps<{
canUseGizmo?: boolean
canUseLighting?: boolean
canExport?: boolean
canUseHdri?: boolean
canUseBackgroundImage?: boolean
materialModes?: readonly MaterialMode[]
hasSkeleton?: boolean
}>()

View File

@@ -37,7 +37,7 @@
</Button>
</div>
<div v-if="!hasBackgroundImage">
<div v-if="showBackgroundImage && !hasBackgroundImage">
<Button
v-tooltip.right="{
value: $t('load3d.uploadBackgroundImage'),
@@ -61,7 +61,7 @@
</div>
</template>
<div v-if="hasBackgroundImage">
<div v-if="showBackgroundImage && hasBackgroundImage">
<Button
v-tooltip.right="{
value: $t('load3d.panoramaMode'),
@@ -83,12 +83,16 @@
</div>
<PopupSlider
v-if="hasBackgroundImage && backgroundRenderMode === 'panorama'"
v-if="
showBackgroundImage &&
hasBackgroundImage &&
backgroundRenderMode === 'panorama'
"
v-model="fov"
:tooltip-text="$t('load3d.fov')"
/>
<div v-if="hasBackgroundImage">
<div v-if="showBackgroundImage && hasBackgroundImage">
<Button
v-tooltip.right="{
value: $t('load3d.removeBackgroundImage'),
@@ -114,8 +118,9 @@ import Button from '@/components/ui/button/Button.vue'
import type { BackgroundRenderModeType } from '@/extensions/core/load3d/interfaces'
import { cn } from '@comfyorg/tailwind-utils'
const { hdriActive = false } = defineProps<{
const { hdriActive = false, showBackgroundImage = true } = defineProps<{
hdriActive?: boolean
showBackgroundImage?: boolean
}>()
const emit = defineEmits<{

View File

@@ -1,6 +1,5 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import { asNodeId } from '@/lib/litegraph/src/utils/nodeId'
import type {
JobListItem,
JobStatus
@@ -155,7 +154,7 @@ export const Queued: Story = {
value: 1,
max: 1,
state: 'running',
node_id: asNodeId('1'),
node_id: '1',
prompt_id: 'p1'
}
}
@@ -205,7 +204,7 @@ export const QueuedParallel: Story = {
value: 1,
max: 2,
state: 'running',
node_id: asNodeId('1'),
node_id: '1',
prompt_id: 'p1'
}
},
@@ -214,7 +213,7 @@ export const QueuedParallel: Story = {
value: 1,
max: 2,
state: 'running',
node_id: asNodeId('2'),
node_id: '2',
prompt_id: 'p2'
}
}
@@ -255,7 +254,7 @@ export const Running: Story = {
value: 5,
max: 10,
state: 'running',
node_id: asNodeId('1'),
node_id: '1',
prompt_id: 'p1'
}
}
@@ -300,7 +299,7 @@ export const QueuedZeroAheadSingleRunning: Story = {
value: 1,
max: 3,
state: 'running',
node_id: asNodeId('1'),
node_id: '1',
prompt_id: 'p1'
}
}
@@ -348,7 +347,7 @@ export const QueuedZeroAheadMultiRunning: Story = {
value: 2,
max: 5,
state: 'running',
node_id: asNodeId('1'),
node_id: '1',
prompt_id: 'p1'
}
},
@@ -357,7 +356,7 @@ export const QueuedZeroAheadMultiRunning: Story = {
value: 3,
max: 5,
state: 'running',
node_id: asNodeId('2'),
node_id: '2',
prompt_id: 'p2'
}
}

View File

@@ -71,12 +71,11 @@ vi.mock('./MissingPackGroupRow.vue', () => ({
name: 'MissingPackGroupRow',
template: `<div class="pack-row" data-testid="pack-row"
:data-show-info-button="String(showInfoButton)"
:data-show-node-id-badge="String(showNodeIdBadge)"
>
<button data-testid="locate-node" @click="$emit('locate-node', group.nodeTypes[0]?.nodeId)" />
<button data-testid="open-manager-info" @click="$emit('open-manager-info', group.packId)" />
</div>`,
props: ['group', 'showInfoButton', 'showNodeIdBadge'],
props: ['group', 'showInfoButton'],
emits: ['locate-node', 'open-manager-info']
}
}))
@@ -122,7 +121,6 @@ function makePackGroups(count = 2): MissingPackGroup[] {
function renderCard(
props: Partial<{
showInfoButton: boolean
showNodeIdBadge: boolean
missingPackGroups: MissingPackGroup[]
}> = {}
) {
@@ -130,7 +128,6 @@ function renderCard(
const result = render(MissingNodeCard, {
props: {
showInfoButton: false,
showNodeIdBadge: false,
missingPackGroups: makePackGroups(),
...props
},
@@ -169,12 +166,10 @@ describe('MissingNodeCard', () => {
it('passes props correctly to MissingPackGroupRow children', () => {
renderCard({
showInfoButton: true,
showNodeIdBadge: true
showInfoButton: true
})
const row = screen.getAllByTestId('pack-row')[0]
expect(row.getAttribute('data-show-info-button')).toBe('true')
expect(row.getAttribute('data-show-node-id-badge')).toBe('true')
})
})
@@ -256,7 +251,6 @@ describe('MissingNodeCard', () => {
render(MissingNodeCard, {
props: {
showInfoButton: false,
showNodeIdBadge: false,
missingPackGroups: makePackGroups(),
onLocateNode
},
@@ -279,7 +273,6 @@ describe('MissingNodeCard', () => {
render(MissingNodeCard, {
props: {
showInfoButton: false,
showNodeIdBadge: false,
missingPackGroups: makePackGroups(),
onOpenManagerInfo
},

View File

@@ -56,27 +56,29 @@
>
</template>
</i18n-t>
<MissingPackGroupRow
v-for="group in missingPackGroups"
:key="group.packId ?? '__unknown__'"
:group="group"
:show-info-button="showInfoButton"
:show-node-id-badge="showNodeIdBadge"
@locate-node="emit('locateNode', $event)"
@open-manager-info="emit('openManagerInfo', $event)"
/>
<div class="flex flex-col gap-1 overflow-hidden py-2">
<MissingPackGroupRow
v-for="group in missingPackGroups"
:key="group.packId ?? '__unknown__'"
:group="group"
:show-info-button="showInfoButton"
@locate-node="emit('locateNode', $event)"
@open-manager-info="emit('openManagerInfo', $event)"
/>
</div>
</div>
<!-- Apply Changes: shown when manager enabled and at least one pack install succeeded -->
<div v-if="shouldShowManagerButtons" class="px-4">
<Button
v-if="hasInstalledPacksPendingRestart"
variant="primary"
variant="secondary"
size="sm"
:disabled="isRestarting"
class="mt-2 h-9 w-full justify-center gap-2 text-sm font-semibold"
class="mt-2 h-8 w-full min-w-0 rounded-lg text-sm"
@click="applyChanges()"
>
<DotSpinner v-if="isRestarting" duration="1s" :size="14" />
<DotSpinner v-if="isRestarting" duration="1s" :size="12" />
<i
v-else
aria-hidden="true"
@@ -105,9 +107,8 @@ import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { MissingPackGroup } from '@/components/rightSidePanel/errors/useErrorGroups'
import MissingPackGroupRow from '@/components/rightSidePanel/errors/MissingPackGroupRow.vue'
const { showInfoButton, showNodeIdBadge, missingPackGroups } = defineProps<{
const { showInfoButton, missingPackGroups } = defineProps<{
showInfoButton: boolean
showNodeIdBadge: boolean
missingPackGroups: MissingPackGroup[]
}>()

View File

@@ -61,16 +61,16 @@ const i18n = createI18n({
messages: {
en: {
g: {
loading: 'Loading'
install: 'Install',
loading: 'Loading',
search: 'Search'
},
rightSidePanel: {
locateNode: 'Locate node on canvas',
missingNodePacks: {
unknownPack: 'Unknown pack',
installNodePack: 'Install node pack',
installing: 'Installing...',
installed: 'Installed',
searchInManager: 'Search in Node Manager',
viewInManager: 'View in Manager',
collapse: 'Collapse',
expand: 'Expand'
@@ -100,7 +100,6 @@ function renderRow(
props: Partial<{
group: MissingPackGroup
showInfoButton: boolean
showNodeIdBadge: boolean
}> = {}
) {
const user = userEvent.setup()
@@ -110,7 +109,6 @@ function renderRow(
props: {
group: makeGroup(),
showInfoButton: false,
showNodeIdBadge: false,
onLocateNode,
onOpenManagerInfo,
...props
@@ -118,7 +116,6 @@ function renderRow(
global: {
plugins: [createTestingPinia({ createSpy: vi.fn }), PrimeVue, i18n],
stubs: {
TransitionCollapse: { template: '<div><slot /></div>' },
DotSpinner: {
template: '<span role="status" aria-label="loading" />'
}
@@ -156,9 +153,22 @@ describe('MissingPackGroupRow', () => {
expect(screen.getByText(/Loading/)).toBeInTheDocument()
})
it('does not render header locate while pack metadata is resolving', () => {
renderRow({
group: makeGroup({
isResolving: true,
nodeTypes: [{ type: 'OnlyNode', nodeId: '100', isReplaceable: false }]
})
})
expect(
screen.queryByRole('button', { name: 'Locate node on canvas' })
).not.toBeInTheDocument()
})
it('renders node count', () => {
renderRow()
expect(screen.getByText(/\(2\)/)).toBeInTheDocument()
expect(screen.getByText('2')).toBeInTheDocument()
})
it('renders count of 5 for 5 nodeTypes', () => {
@@ -171,38 +181,29 @@ describe('MissingPackGroupRow', () => {
}))
})
})
expect(screen.getByText(/\(5\)/)).toBeInTheDocument()
})
})
describe('Expand / Collapse', () => {
it('starts collapsed', () => {
renderRow()
expect(screen.queryByText('MissingA')).not.toBeInTheDocument()
})
it('expands when chevron is clicked', async () => {
const { user } = renderRow()
await user.click(screen.getByRole('button', { name: 'Expand' }))
expect(screen.getByText('MissingA')).toBeInTheDocument()
expect(screen.getByText('MissingB')).toBeInTheDocument()
})
it('collapses when chevron is clicked again', async () => {
const { user } = renderRow()
await user.click(screen.getByRole('button', { name: 'Expand' }))
expect(screen.getByText('MissingA')).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'Collapse' }))
expect(screen.queryByText('MissingA')).not.toBeInTheDocument()
expect(screen.getByText('5')).toBeInTheDocument()
})
})
describe('Node Type List', () => {
async function expand(user: ReturnType<typeof userEvent.setup>) {
await user.click(screen.getByRole('button', { name: 'Expand' }))
}
it('hides multiple nodeTypes behind the expand control by default', () => {
renderRow()
expect(screen.queryByText('MissingA')).not.toBeInTheDocument()
expect(screen.queryByText('MissingB')).not.toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Expand' })).toBeInTheDocument()
})
it('renders all nodeTypes when expanded', async () => {
it('shows unknown pack nodeTypes by default', () => {
renderRow({ group: makeGroup({ packId: null }) })
expect(
screen.getByRole('button', { name: 'Collapse' })
).toBeInTheDocument()
expect(screen.getByText('MissingA')).toBeInTheDocument()
expect(screen.getByText('MissingB')).toBeInTheDocument()
})
it('renders all nodeTypes after expanding', async () => {
const { user } = renderRow({
group: makeGroup({
nodeTypes: [
@@ -212,40 +213,87 @@ describe('MissingPackGroupRow', () => {
]
})
})
await expand(user)
await user.click(screen.getByRole('button', { name: 'Expand' }))
expect(screen.getByText('NodeA')).toBeInTheDocument()
expect(screen.getByText('NodeB')).toBeInTheDocument()
expect(screen.getByText('NodeC')).toBeInTheDocument()
})
it('shows nodeId badge when showNodeIdBadge is true', async () => {
const { user } = renderRow({ showNodeIdBadge: true })
await expand(user)
expect(screen.getByText('#10')).toBeInTheDocument()
it('hides multiple nodeTypes again after collapsing', async () => {
const { user } = renderRow()
await user.click(screen.getByRole('button', { name: 'Expand' }))
expect(screen.getByText('MissingA')).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'Collapse' }))
expect(screen.queryByText('MissingA')).not.toBeInTheDocument()
})
it('hides nodeId badge when showNodeIdBadge is false', async () => {
const { user } = renderRow({ showNodeIdBadge: false })
await expand(user)
expect(screen.queryByText('#10')).not.toBeInTheDocument()
it('hides a single nodeType without an expand control', () => {
renderRow({
group: makeGroup({
nodeTypes: [{ type: 'OnlyNode', nodeId: '1', isReplaceable: false }]
})
})
expect(screen.queryByText('OnlyNode')).not.toBeInTheDocument()
expect(
screen.queryByRole('button', { name: 'Expand' })
).not.toBeInTheDocument()
})
it('emits locateNode when Locate button is clicked', async () => {
const { user, onLocateNode } = renderRow({ showNodeIdBadge: true })
await expand(user)
it('emits locateNode when the pack label is clicked for one nodeType', async () => {
const { user, onLocateNode } = renderRow({
group: makeGroup({
nodeTypes: [{ type: 'OnlyNode', nodeId: '100', isReplaceable: false }]
})
})
await user.click(screen.getByRole('button', { name: 'my-pack' }))
expect(onLocateNode).toHaveBeenCalledWith('100')
})
it('moves locate to the header when there is one nodeType', async () => {
const { user, onLocateNode } = renderRow({
group: makeGroup({
nodeTypes: [{ type: 'OnlyNode', nodeId: '100', isReplaceable: false }]
})
})
await user.click(
screen.getByRole('button', { name: 'Locate node on canvas' })
)
expect(onLocateNode).toHaveBeenCalledWith('100')
})
it('emits locateNode when expanded child Locate button is clicked', async () => {
const { user, onLocateNode } = renderRow()
await user.click(screen.getByRole('button', { name: 'Expand' }))
await user.click(
screen.getAllByRole('button', { name: 'Locate node on canvas' })[0]
)
expect(onLocateNode).toHaveBeenCalledWith('10')
})
it('does not show Locate for nodeType without nodeId', async () => {
const { user } = renderRow({
it('emits locateNode when node label is clicked', async () => {
const { user, onLocateNode } = renderRow()
await user.click(screen.getByRole('button', { name: 'Expand' }))
await user.click(screen.getByRole('button', { name: 'MissingA' }))
expect(onLocateNode).toHaveBeenCalledWith('10')
})
it('does not show Locate for nodeType without nodeId', () => {
renderRow({
group: makeGroup({
nodeTypes: [{ type: 'NoId', isReplaceable: false } as never]
})
})
await expand(user)
expect(
screen.queryByRole('button', { name: 'Locate node on canvas' })
).not.toBeInTheDocument()
@@ -253,7 +301,6 @@ describe('MissingPackGroupRow', () => {
it('handles mixed nodeTypes with and without nodeId', async () => {
const { user } = renderRow({
showNodeIdBadge: true,
group: makeGroup({
nodeTypes: [
{ type: 'WithId', nodeId: '100', isReplaceable: false },
@@ -261,7 +308,7 @@ describe('MissingPackGroupRow', () => {
]
})
})
await expand(user)
await user.click(screen.getByRole('button', { name: 'Expand' }))
expect(screen.getByText('WithId')).toBeInTheDocument()
expect(screen.getByText('WithoutId')).toBeInTheDocument()
expect(
@@ -274,21 +321,25 @@ describe('MissingPackGroupRow', () => {
it('hides install UI when shouldShowManagerButtons is false', () => {
mockShouldShowManagerButtons.value = false
renderRow()
expect(screen.queryByText('Install node pack')).not.toBeInTheDocument()
expect(
screen.queryByRole('button', { name: 'Install' })
).not.toBeInTheDocument()
})
it('hides install UI when packId is null', () => {
mockShouldShowManagerButtons.value = true
renderRow({ group: makeGroup({ packId: null }) })
expect(screen.queryByText('Install node pack')).not.toBeInTheDocument()
expect(
screen.queryByRole('button', { name: 'Install' })
).not.toBeInTheDocument()
})
it('shows "Search in Node Manager" when packId exists but pack not in registry', () => {
it('shows Search when packId exists but pack not in registry', () => {
mockShouldShowManagerButtons.value = true
mockIsPackInstalled.mockReturnValue(false)
mockMissingNodePacks.value = []
renderRow()
expect(screen.getByText('Search in Node Manager')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Search' })).toBeInTheDocument()
})
it('shows "Installed" state when pack is installed', () => {
@@ -312,7 +363,9 @@ describe('MissingPackGroupRow', () => {
mockIsPackInstalled.mockReturnValue(false)
mockMissingNodePacks.value = [{ id: 'my-pack', name: 'My Pack' }]
renderRow()
expect(screen.getByText('Install node pack')).toBeInTheDocument()
expect(
screen.getByRole('button', { name: 'Install' })
).toBeInTheDocument()
})
it('calls installAllPacks when Install button is clicked', async () => {
@@ -320,9 +373,7 @@ describe('MissingPackGroupRow', () => {
mockIsPackInstalled.mockReturnValue(false)
mockMissingNodePacks.value = [{ id: 'my-pack', name: 'My Pack' }]
const { user } = renderRow()
await user.click(
screen.getByRole('button', { name: /Install node pack/ })
)
await user.click(screen.getByRole('button', { name: 'Install' }))
expect(mockInstallAllPacks).toHaveBeenCalledOnce()
})
@@ -369,7 +420,7 @@ describe('MissingPackGroupRow', () => {
describe('Edge Cases', () => {
it('handles empty nodeTypes array', () => {
renderRow({ group: makeGroup({ nodeTypes: [] }) })
expect(screen.getByText(/\(0\)/)).toBeInTheDocument()
expect(screen.getByText('0')).toBeInTheDocument()
})
})
})

View File

@@ -1,187 +1,221 @@
<template>
<div class="mb-2 flex w-full flex-col">
<!-- Pack header row: pack name + info + chevron -->
<div class="flex h-8 w-full items-center">
<!-- Warning icon for unknown packs -->
<i
v-if="group.packId === null && !group.isResolving"
class="mr-1.5 icon-[lucide--triangle-alert] size-4 shrink-0 text-warning-background"
/>
<p
class="min-w-0 flex-1 truncate text-sm font-medium"
:class="
group.packId === null && !group.isResolving
? 'text-warning-background'
: 'text-foreground'
"
>
<span v-if="group.isResolving" class="text-muted-foreground italic">
{{ t('g.loading') }}...
</span>
<span v-else>
{{
`${group.packId ?? t('rightSidePanel.missingNodePacks.unknownPack')} (${group.nodeTypes.length})`
}}
</span>
</p>
<div class="mb-1 flex w-full flex-col gap-0.5 last:mb-0">
<div class="flex min-h-8 w-full items-center gap-1">
<Button
v-if="showInfoButton && group.packId !== null"
v-if="hasMultipleNodeTypes"
data-testid="missing-node-pack-expand"
variant="textonly"
size="icon-sm"
class="size-8 shrink-0 text-muted-foreground hover:text-base-foreground"
:aria-label="t('rightSidePanel.missingNodePacks.viewInManager')"
@click="emit('openManagerInfo', group.packId ?? '')"
>
<i class="icon-[lucide--info] size-4" />
</Button>
<Button
variant="textonly"
size="icon-sm"
:class="
cn(
'size-8 shrink-0 transition-transform duration-200 hover:bg-transparent',
{ 'rotate-180': expanded }
)
"
size="unset"
:aria-label="
expanded
? t('rightSidePanel.missingNodePacks.collapse')
: t('rightSidePanel.missingNodePacks.expand')
"
:aria-expanded="expanded"
:class="
cn(
'h-8 w-4 shrink-0 p-0 transition-transform duration-200 hover:bg-transparent',
expanded && 'rotate-90'
)
"
@click="toggleExpand"
>
<i
class="icon-[lucide--chevron-down] size-4 text-muted-foreground group-hover:text-base-foreground"
aria-hidden="true"
class="icon-[lucide--chevron-right] size-4 text-muted-foreground"
/>
</Button>
</div>
<!-- Sub-labels: individual node instances, each with their own Locate button -->
<TransitionCollapse>
<div
v-if="expanded"
class="mb-1 flex flex-col gap-0.5 overflow-hidden pl-2"
>
<div
v-for="nodeType in group.nodeTypes"
:key="getKey(nodeType)"
class="flex h-7 items-center"
>
<span
v-if="
showNodeIdBadge &&
typeof nodeType !== 'string' &&
nodeType.nodeId != null
<i
v-if="isUnknownPack"
class="icon-[lucide--triangle-alert] size-4 shrink-0 text-warning-background"
/>
<span class="flex min-w-0 flex-1 items-center gap-2">
<span class="flex min-w-0 items-center gap-2.5">
<button
v-if="hasMultipleNodeTypes && !group.isResolving"
type="button"
:class="
cn(
packTextButtonClass,
isUnknownPack
? 'text-warning-background'
: 'text-base-foreground'
)
"
class="mr-1 shrink-0 rounded-md bg-secondary-background-selected px-2 py-0.5 font-mono text-xs font-bold text-muted-foreground"
:aria-expanded="expanded"
@click="toggleExpand"
>
#{{ nodeType.nodeId }}
{{ packDisplayName }}
</button>
<button
v-else-if="primaryLocatableNodeType"
type="button"
:class="
cn(
packTextButtonClass,
isUnknownPack
? 'text-warning-background'
: 'text-base-foreground'
)
"
@click="handleLocateNode(primaryLocatableNodeType)"
>
{{ packDisplayName }}
</button>
<span
v-else
class="min-w-0 truncate text-sm/relaxed font-normal"
:class="
isUnknownPack ? 'text-warning-background' : 'text-base-foreground'
"
>
<span v-if="group.isResolving" class="text-muted-foreground italic">
{{ t('g.loading') }}...
</span>
<span v-else>
{{ packDisplayName }}
</span>
</span>
<p class="min-w-0 flex-1 truncate text-xs text-muted-foreground">
{{ getLabel(nodeType) }}
</p>
<Button
v-if="typeof nodeType !== 'string' && nodeType.nodeId != null"
v-if="showInfoButton && group.packId !== null"
variant="textonly"
size="icon-sm"
class="mr-1 size-6 shrink-0 text-muted-foreground hover:text-base-foreground"
:aria-label="t('rightSidePanel.locateNode')"
@click="handleLocateNode(nodeType)"
class="size-7 shrink-0 text-muted-foreground hover:bg-transparent hover:text-base-foreground"
:aria-label="t('rightSidePanel.missingNodePacks.viewInManager')"
@click="emit('openManagerInfo', group.packId ?? '')"
>
<i class="icon-[lucide--locate] size-3" />
<i class="icon-[lucide--info] size-4" />
</Button>
</div>
</div>
</TransitionCollapse>
<!-- Install button: shown when manager enabled, registry knows the pack or it's already installed -->
<div
v-if="
shouldShowManagerButtons &&
group.packId !== null &&
(nodePack || comfyManagerStore.isPackInstalled(group.packId))
"
class="flex w-full items-start py-1"
>
<Button
variant="secondary"
size="md"
class="flex w-full flex-1 rounded-lg"
:disabled="
comfyManagerStore.isPackInstalled(group.packId) || isInstalling
"
@click="handlePackInstallClick"
>
<DotSpinner
v-if="isInstalling"
duration="1s"
:size="12"
class="mr-1.5 shrink-0"
/>
<i
v-else-if="comfyManagerStore.isPackInstalled(group.packId)"
class="text-foreground mr-1 icon-[lucide--check] size-4 shrink-0"
/>
<i
v-else
class="text-foreground mr-1 icon-[lucide--download] size-4 shrink-0"
/>
<span class="text-foreground min-w-0 truncate text-sm">
{{
isInstalling
? t('rightSidePanel.missingNodePacks.installing')
: comfyManagerStore.isPackInstalled(group.packId)
? t('rightSidePanel.missingNodePacks.installed')
: t('rightSidePanel.missingNodePacks.installNodePack')
}}
<span
v-if="showNodeCount"
data-testid="missing-node-pack-count"
class="flex size-6 shrink-0 items-center justify-center rounded-md bg-secondary-background-selected text-xs font-bold text-muted-foreground"
>
{{ group.nodeTypes.length }}
</span>
</span>
</Button>
</div>
<!-- Registry still loading: packId known but result not yet available -->
<div
v-else-if="group.packId !== null && shouldShowManagerButtons && isLoading"
class="flex w-full items-start py-1"
>
</span>
<div v-if="showInstallAction" class="ml-auto shrink-0">
<Button
variant="secondary"
size="sm"
class="h-8 shrink-0 rounded-lg text-sm"
:disabled="isPackInstalled || isInstalling"
@click="handlePackInstallClick"
>
<DotSpinner
v-if="isInstalling"
duration="1s"
:size="12"
class="mr-1.5 shrink-0"
/>
<span class="text-foreground min-w-0 truncate">
{{
isInstalling
? t('rightSidePanel.missingNodePacks.installing')
: isPackInstalled
? t('rightSidePanel.missingNodePacks.installed')
: t('g.install')
}}
</span>
</Button>
</div>
<div
class="flex h-8 min-w-0 flex-1 cursor-not-allowed items-center justify-center overflow-hidden rounded-lg bg-secondary-background p-2 opacity-60 select-none"
v-else-if="showLoadingAction"
class="ml-auto flex h-8 shrink-0 cursor-not-allowed items-center justify-center overflow-hidden rounded-lg bg-secondary-background px-2 py-1 text-sm opacity-60 select-none"
>
<DotSpinner duration="1s" :size="12" class="mr-1.5 shrink-0" />
<span class="text-foreground min-w-0 truncate text-sm">
{{ t('g.loading') }}
</span>
</div>
</div>
<!-- Search in Manager: fetch done but pack not found in registry -->
<div
v-else-if="group.packId !== null && shouldShowManagerButtons"
class="flex w-full items-start py-1"
>
<div v-else-if="showSearchAction" class="ml-auto shrink-0">
<Button
variant="secondary"
size="sm"
class="h-8 shrink-0 rounded-lg text-sm"
@click="
openManager({
initialTab: ManagerTab.All,
initialPackId: group.packId!
})
"
>
<span class="text-foreground min-w-0 truncate">
{{ t('g.search') }}
</span>
</Button>
</div>
<Button
variant="secondary"
size="md"
class="flex w-full flex-1 rounded-lg"
@click="
openManager({
initialTab: ManagerTab.All,
initialPackId: group.packId!
})
"
v-if="primaryLocatableNodeType"
variant="textonly"
size="icon-sm"
class="size-8 shrink-0 text-muted-foreground hover:text-base-foreground"
:aria-label="t('rightSidePanel.locateNode')"
@click="handleLocateNode(primaryLocatableNodeType)"
>
<i class="text-foreground mr-1 icon-[lucide--search] size-4 shrink-0" />
<span class="text-foreground min-w-0 truncate text-sm">
{{ t('rightSidePanel.missingNodePacks.searchInManager') }}
</span>
<i aria-hidden="true" class="icon-[lucide--locate] size-4" />
</Button>
</div>
<TransitionCollapse>
<ul
v-if="showNodeTypeList"
:class="
cn(
'm-0 list-none space-y-1 p-0',
(hasMultipleNodeTypes || isUnknownPack) && 'pl-5'
)
"
>
<li
v-for="nodeType in group.nodeTypes"
:key="getKey(nodeType)"
class="min-w-0"
>
<div class="flex min-w-0 items-center gap-2">
<span class="flex min-w-0 flex-1 items-center gap-1">
<button
v-if="isLocatableNodeType(nodeType)"
type="button"
:class="
cn(
packTextButtonClass,
'text-muted-foreground hover:text-base-foreground'
)
"
@click="handleLocateNode(nodeType)"
>
{{ getLabel(nodeType) }}
</button>
<span
v-else
class="text-sm/relaxed wrap-break-word text-muted-foreground"
>
{{ getLabel(nodeType) }}
</span>
</span>
<Button
v-if="isLocatableNodeType(nodeType)"
variant="textonly"
size="icon-sm"
class="size-8 shrink-0 text-muted-foreground hover:text-base-foreground"
:aria-label="t('rightSidePanel.locateNode')"
@click="handleLocateNode(nodeType)"
>
<i aria-hidden="true" class="icon-[lucide--locate] size-4" />
</Button>
</div>
</li>
</ul>
</TransitionCollapse>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { cn } from '@comfyorg/tailwind-utils'
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { cn } from '@comfyorg/tailwind-utils'
import Button from '@/components/ui/button/Button.vue'
import DotSpinner from '@/components/common/DotSpinner.vue'
import TransitionCollapse from '@/components/rightSidePanel/layout/TransitionCollapse.vue'
@@ -193,10 +227,9 @@ import { ManagerTab } from '@/workbench/extensions/manager/types/comfyManagerTyp
import type { MissingNodeType } from '@/types/comfy'
import type { MissingPackGroup } from '@/components/rightSidePanel/errors/useErrorGroups'
const { group, showInfoButton, showNodeIdBadge } = defineProps<{
const { group, showInfoButton } = defineProps<{
group: MissingPackGroup
showInfoButton: boolean
showNodeIdBadge: boolean
}>()
const emit = defineEmits<{
@@ -205,6 +238,10 @@ const emit = defineEmits<{
}>()
const { t } = useI18n()
const expandedOverride = ref<boolean | null>(null)
const packTextButtonClass =
'm-0 inline max-w-full cursor-pointer appearance-none border-0 bg-transparent p-0 text-left text-sm/relaxed font-normal wrap-break-word outline-none focus:outline-none focus-visible:underline focus-visible:ring-0 focus-visible:outline-none'
const { missingNodePacks, isLoading } = useMissingNodes()
const comfyManagerStore = useComfyManagerStore()
@@ -219,17 +256,73 @@ const { isInstalling, installAllPacks } = usePackInstall(() =>
nodePack.value ? [nodePack.value] : []
)
const isUnknownPack = computed(
() => group.packId === null && !group.isResolving
)
const packDisplayName = computed(() => {
if (group.packId === null) {
return t('rightSidePanel.missingNodePacks.unknownPack')
}
return nodePack.value?.name ?? group.packId
})
const isPackInstalled = computed(
() => group.packId !== null && comfyManagerStore.isPackInstalled(group.packId)
)
const showInstallAction = computed(
() =>
shouldShowManagerButtons.value &&
group.packId !== null &&
(nodePack.value !== null || isPackInstalled.value)
)
const showLoadingAction = computed(
() =>
shouldShowManagerButtons.value &&
group.packId !== null &&
!showInstallAction.value &&
isLoading.value
)
const showSearchAction = computed(
() =>
shouldShowManagerButtons.value &&
group.packId !== null &&
!showInstallAction.value &&
!showLoadingAction.value
)
const hasMultipleNodeTypes = computed(() => group.nodeTypes.length > 1)
const showNodeCount = computed(() => group.nodeTypes.length !== 1)
const expanded = computed(
() =>
expandedOverride.value ??
(isUnknownPack.value && hasMultipleNodeTypes.value)
)
const showNodeTypeList = computed(
() =>
(isUnknownPack.value && group.nodeTypes.length === 1) ||
(hasMultipleNodeTypes.value && expanded.value)
)
const primaryLocatableNodeType = computed(() => {
if (group.isResolving) return null
if (isUnknownPack.value) return null
if (group.nodeTypes.length !== 1) return null
const [nodeType] = group.nodeTypes
return isLocatableNodeType(nodeType) ? nodeType : null
})
function handlePackInstallClick() {
if (!group.packId) return
if (!comfyManagerStore.isPackInstalled(group.packId)) {
if (!isPackInstalled.value) {
void installAllPacks()
}
}
const expanded = ref(false)
function toggleExpand() {
expanded.value = !expanded.value
expandedOverride.value = !expanded.value
}
function getKey(nodeType: MissingNodeType): string {
@@ -241,10 +334,14 @@ function getLabel(nodeType: MissingNodeType): string {
return typeof nodeType === 'string' ? nodeType : nodeType.type
}
function isLocatableNodeType(
nodeType: MissingNodeType
): nodeType is Exclude<MissingNodeType, string> & { nodeId: string | number } {
return typeof nodeType !== 'string' && nodeType.nodeId != null
}
function handleLocateNode(nodeType: MissingNodeType) {
if (typeof nodeType === 'string') return
if (nodeType.nodeId != null) {
emit('locateNode', String(nodeType.nodeId))
}
if (!isLocatableNodeType(nodeType)) return
emit('locateNode', String(nodeType.nodeId))
}
</script>

View File

@@ -5,7 +5,6 @@ import PrimeVue from 'primevue/config'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
import TabErrors from './TabErrors.vue'
import { asNodeId } from '@/lib/litegraph/src/litegraph'
import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
import type { MissingMediaCandidate } from '@/platform/missingMedia/types'
import type { MissingModelCandidate } from '@/platform/missingModel/types'
@@ -389,7 +388,7 @@ describe('TabErrors.vue', () => {
it('shows missing model Refresh in the section header when no model is downloadable', async () => {
const missingModel = {
nodeId: asNodeId('1'),
nodeId: '1',
nodeType: 'CheckpointLoaderSimple',
widgetName: 'ckpt_name',
name: 'local-only.safetensors',
@@ -417,7 +416,7 @@ describe('TabErrors.vue', () => {
it('renders missing model display message below the section title', () => {
const missingModel = {
nodeId: asNodeId('1'),
nodeId: '1',
nodeType: 'CheckpointLoaderSimple',
widgetName: 'ckpt_name',
name: 'local-only.safetensors',
@@ -440,7 +439,7 @@ describe('TabErrors.vue', () => {
it('renders missing media display message below the section title', () => {
const missingMedia = {
nodeId: asNodeId('3'),
nodeId: '3',
nodeType: 'LoadImage',
widgetName: 'image',
mediaType: 'image',
@@ -476,7 +475,7 @@ describe('TabErrors.vue', () => {
missingMedia: {
missingMediaCandidates: [
{
nodeId: asNodeId('3'),
nodeId: '3',
nodeType: 'LoadImage',
widgetName: 'image',
mediaType: 'image',
@@ -484,7 +483,7 @@ describe('TabErrors.vue', () => {
isMissing: true
},
{
nodeId: asNodeId('4'),
nodeId: '4',
nodeType: 'PreviewImage',
widgetName: 'image',
mediaType: 'image',
@@ -531,7 +530,9 @@ describe('TabErrors.vue', () => {
expect(
screen.getByText('Some nodes can be replaced with alternatives')
).toBeInTheDocument()
expect(screen.getByText('OldSampler (1)')).toBeInTheDocument()
expect(
screen.getByRole('button', { name: 'OldSampler' })
).toBeInTheDocument()
expect(screen.getByText('KSampler')).toBeInTheDocument()
expect(
screen.getByRole('button', { name: /Replace Node/ })
@@ -540,7 +541,7 @@ describe('TabErrors.vue', () => {
it('keeps missing model Refresh in the card actions when models are downloadable', () => {
const missingModel = {
nodeId: asNodeId('1'),
nodeId: '1',
nodeType: 'CheckpointLoaderSimple',
widgetName: 'ckpt_name',
name: 'downloadable.safetensors',

View File

@@ -148,7 +148,6 @@
<MissingNodeCard
v-if="group.type === 'missing_node'"
:show-info-button="shouldShowManagerButtons"
:show-node-id-badge="showNodeIdBadge"
:missing-pack-groups="missingPackGroups"
@locate-node="handleLocateMissingNode"
@open-manager-info="handleOpenManagerInfo"
@@ -158,7 +157,6 @@
<SwapNodesCard
v-if="group.type === 'swap_nodes'"
:swap-node-groups="swapNodeGroups"
:show-node-id-badge="showNodeIdBadge"
@locate-node="handleLocateMissingNode"
@replace="handleReplaceGroup"
/>

View File

@@ -3,7 +3,6 @@ import { createPinia, setActivePinia } from 'pinia'
import { nextTick, ref } from 'vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { asNodeId } from '@/lib/litegraph/src/litegraph'
import type { MissingNodeType } from '@/types/comfy'
vi.mock('@/scripts/app', () => ({
@@ -171,7 +170,7 @@ function makeModel(
) {
return {
name,
nodeId: asNodeId(opts.nodeId ?? '1'),
nodeId: opts.nodeId ?? '1',
nodeType: 'CheckpointLoaderSimple',
widgetName: opts.widgetName ?? 'ckpt_name',
isAssetSupported: opts.isAssetSupported ?? false,
@@ -190,7 +189,7 @@ function makeMedia(
): MissingMediaCandidate {
return {
name,
nodeId: asNodeId(opts.nodeId),
nodeId: opts.nodeId,
nodeType: opts.nodeType ?? 'LoadImage',
widgetName: opts.widgetName ?? 'image',
mediaType: 'image',
@@ -422,7 +421,7 @@ describe('useErrorGroups', () => {
it('uses fallback catalog grouping for unknown node validation errors', async () => {
const { store, groups } = createErrorGroups()
store.lastNodeErrors = {
[asNodeId('1')]: {
'1': {
class_type: 'KSampler',
dependent_outputs: [],
errors: [
@@ -447,7 +446,7 @@ describe('useErrorGroups', () => {
it('resolves required_input_missing item display copy', async () => {
const { store, groups } = createErrorGroups()
store.lastNodeErrors = {
[asNodeId('1')]: {
'1': {
class_type: 'KSampler',
dependent_outputs: [],
errors: [
@@ -493,7 +492,7 @@ describe('useErrorGroups', () => {
it('groups node validation errors by catalog id across node types', async () => {
const { store, groups } = createErrorGroups()
store.lastNodeErrors = {
[asNodeId('1')]: {
'1': {
class_type: 'KSampler',
dependent_outputs: [],
errors: [
@@ -507,7 +506,7 @@ describe('useErrorGroups', () => {
}
]
},
[asNodeId('2')]: {
'2': {
class_type: 'CLIPLoader',
dependent_outputs: [],
errors: [
@@ -545,7 +544,7 @@ describe('useErrorGroups', () => {
store.lastExecutionError = {
prompt_id: 'test-prompt',
timestamp: Date.now(),
node_id: asNodeId(5),
node_id: 5,
node_type: 'KSampler',
executed: [],
exception_type: 'RuntimeError',
@@ -582,7 +581,7 @@ describe('useErrorGroups', () => {
store.lastExecutionError = {
prompt_id: 'test-prompt',
timestamp: Date.now(),
node_id: asNodeId(5),
node_id: 5,
node_type: 'KSampler',
executed: [],
exception_type: 'torch.OutOfMemoryError',
@@ -651,17 +650,17 @@ describe('useErrorGroups', () => {
it('sorts cards within an execution group by nodeId numerically', async () => {
const { store, groups } = createErrorGroups()
store.lastNodeErrors = {
[asNodeId('10')]: {
'10': {
class_type: 'KSampler',
dependent_outputs: [],
errors: [{ type: 'err', message: 'Error', details: '' }]
},
[asNodeId('2')]: {
'2': {
class_type: 'KSampler',
dependent_outputs: [],
errors: [{ type: 'err', message: 'Error', details: '' }]
},
[asNodeId('1')]: {
'1': {
class_type: 'KSampler',
dependent_outputs: [],
errors: [{ type: 'err', message: 'Error', details: '' }]
@@ -679,17 +678,17 @@ describe('useErrorGroups', () => {
it('sorts cards with subpath nodeIds before higher root IDs', async () => {
const { store, groups } = createErrorGroups()
store.lastNodeErrors = {
[asNodeId('2')]: {
'2': {
class_type: 'KSampler',
dependent_outputs: [],
errors: [{ type: 'err', message: 'Error', details: '' }]
},
[asNodeId('1:20')]: {
'1:20': {
class_type: 'KSampler',
dependent_outputs: [],
errors: [{ type: 'err', message: 'Error', details: '' }]
},
[asNodeId('1')]: {
'1': {
class_type: 'KSampler',
dependent_outputs: [],
errors: [{ type: 'err', message: 'Error', details: '' }]
@@ -707,17 +706,17 @@ describe('useErrorGroups', () => {
it('sorts deeply nested nodeIds by each segment numerically', async () => {
const { store, groups } = createErrorGroups()
store.lastNodeErrors = {
[asNodeId('10:11:99')]: {
'10:11:99': {
class_type: 'KSampler',
dependent_outputs: [],
errors: [{ type: 'err', message: 'Error', details: '' }]
},
[asNodeId('10:11:12')]: {
'10:11:12': {
class_type: 'KSampler',
dependent_outputs: [],
errors: [{ type: 'err', message: 'Error', details: '' }]
},
[asNodeId('10:2')]: {
'10:2': {
class_type: 'KSampler',
dependent_outputs: [],
errors: [{ type: 'err', message: 'Error', details: '' }]
@@ -737,7 +736,7 @@ describe('useErrorGroups', () => {
it('returns all groups when search query is empty', async () => {
const { store, groups } = createErrorGroups()
store.lastNodeErrors = {
[asNodeId('1')]: {
'1': {
class_type: 'KSampler',
dependent_outputs: [],
errors: [{ type: 'value_error', message: 'Bad value', details: '' }]
@@ -751,7 +750,7 @@ describe('useErrorGroups', () => {
it('filters groups based on search query', async () => {
const { store, groups, searchQuery } = createErrorGroups()
store.lastNodeErrors = {
[asNodeId('1')]: {
'1': {
class_type: 'KSampler',
dependent_outputs: [],
errors: [
@@ -762,7 +761,7 @@ describe('useErrorGroups', () => {
}
]
},
[asNodeId('2')]: {
'2': {
class_type: 'CLIPLoader',
dependent_outputs: [],
errors: [
@@ -803,7 +802,7 @@ describe('useErrorGroups', () => {
it('collects unique display messages from node errors', async () => {
const { store, groups } = createErrorGroups()
store.lastNodeErrors = {
[asNodeId('1')]: {
'1': {
class_type: 'KSampler',
dependent_outputs: [],
errors: [
@@ -811,7 +810,7 @@ describe('useErrorGroups', () => {
{ type: 'err_b', message: 'Error B', details: '' }
]
},
[asNodeId('2')]: {
'2': {
class_type: 'CLIPLoader',
dependent_outputs: [],
errors: [{ type: 'err_a', message: 'Error A', details: '' }]

View File

@@ -4,7 +4,6 @@ import type { MaybeRefOrGetter } from 'vue'
import { until } from '@vueuse/core'
import { asNodeId } from '@/lib/litegraph/src/utils/nodeId'
import { api } from '@/scripts/api'
import { app } from '@/scripts/app'
import { useSystemStatsStore } from '@/stores/systemStatsStore'
@@ -82,8 +81,7 @@ export function useErrorReport(cardSource: MaybeRefOrGetter<ErrorCardData>) {
exceptionType: error.exceptionType ?? FALLBACK_EXCEPTION_TYPE,
exceptionMessage: error.message,
traceback: error.details,
nodeId:
card.nodeId === undefined ? undefined : asNodeId(card.nodeId),
nodeId: card.nodeId,
nodeType: card.title,
systemStats: systemStatsStore.systemStats,
serverLogs: logs,

View File

@@ -12,15 +12,14 @@ import {
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import { widgetPromotedSource } from '@/core/graph/subgraph/promotedInputWidget'
import { isWidgetPromotedOnSubgraphNode } from '@/core/graph/subgraph/promotionUtils'
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
import type { LGraphGroup, LGraphNode } from '@/lib/litegraph/src/litegraph'
import { SubgraphNode } from '@/lib/litegraph/src/litegraph'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { DraggableList } from '@/scripts/ui/draggableList'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
import { useSettingStore } from '@/platform/settings/settingStore'
import { cn } from '@comfyorg/tailwind-utils'
@@ -147,17 +146,16 @@ function isWidgetShownOnParents(
widgetNode: LGraphNode,
widget: IBaseWidget
): boolean {
const source = widgetPromotedSource(widgetNode, widget)
return parents.some((parent) => {
if (source) {
if (isPromotedWidgetView(widget)) {
const interiorNodeId =
String(widgetNode.id) === String(parent.id)
? source.nodeId
? widget.sourceNodeId
: String(widgetNode.id)
return isWidgetPromotedOnSubgraphNode(parent, {
sourceNodeId: interiorNodeId,
sourceWidgetName: source.widgetName
sourceWidgetName: widget.sourceWidgetName
})
}
return isWidgetPromotedOnSubgraphNode(parent, {
@@ -236,10 +234,7 @@ function navigateToErrorTab() {
rightSidePanelStore.openPanel('errors')
}
function setWidgetValue(widget: IBaseWidget, value: WidgetValue) {
// Store-backed widgets (interior node widgets and promoted subgraph inputs)
// are addressed by widgetId; writing there keeps the displayed value in sync.
if (widget.widgetId) useWidgetValueStore().setValue(widget.widgetId, value)
function writeWidgetValue(widget: IBaseWidget, value: WidgetValue) {
widget.value = value
widget.callback?.(value)
canvasStore.canvas?.setDirty(true, true)
@@ -250,18 +245,18 @@ function handleResetAllWidgets() {
const spec = nodeDefStore.getInputSpecForWidget(widgetNode, widget.name)
const defaultValue = getWidgetDefaultValue(spec)
if (defaultValue !== undefined) {
setWidgetValue(widget, defaultValue)
writeWidgetValue(widget, defaultValue)
}
}
}
function handleWidgetValueUpdate(widget: IBaseWidget, newValue: WidgetValue) {
if (newValue === undefined) return
setWidgetValue(widget, newValue)
writeWidgetValue(widget, newValue)
}
function handleWidgetReset(widget: IBaseWidget, newValue: WidgetValue) {
setWidgetValue(widget, newValue)
writeWidgetValue(widget, newValue)
}
defineExpose({

View File

@@ -1,127 +0,0 @@
import { render } from '@testing-library/vue'
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
import { promoteValueWidgetViaSubgraphInput } from '@/core/graph/subgraph/promotionUtils'
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { LGraph } from '@/lib/litegraph/src/litegraph'
import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
import {
createTestSubgraph,
createTestSubgraphNode
} from '@/lib/litegraph/src/subgraph/__fixtures__/subgraphHelpers'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
import { widgetId } from '@/types/widgetId'
import TabSubgraphInputs from './TabSubgraphInputs.vue'
vi.mock('@/services/litegraphService', () => ({
useLitegraphService: () => ({ updatePreviews: vi.fn() })
}))
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: { en: { rightSidePanel: { inputs: 'Inputs', inputsNone: 'None' } } }
})
const captured: { rows: { node: LGraphNode; widget: IBaseWidget }[] } = {
rows: []
}
const SectionWidgetsStub = {
props: ['widgets', 'node', 'parents'],
setup(props: Record<string, unknown>) {
captured.rows = props.widgets as {
node: LGraphNode
widget: IBaseWidget
}[]
return () => null
}
}
function buildHostWithPromotedSeed(): {
host: SubgraphNode
sourceNode: LGraphNode
} {
const subgraph = createTestSubgraph()
const host = createTestSubgraphNode(subgraph)
const graph = host.graph as LGraph
graph.add(host)
const sourceNode = new LGraphNode('Sampler')
const input = sourceNode.addInput('seed', 'INT')
const seedWidget = sourceNode.addWidget('number', 'seed', 42, () => {})
input.widget = { name: seedWidget.name }
subgraph.add(sourceNode)
promoteValueWidgetViaSubgraphInput(host, sourceNode, seedWidget)
return { host, sourceNode }
}
function renderPanel(node: SubgraphNode) {
return render(TabSubgraphInputs, {
props: { node },
global: {
plugins: [i18n],
stubs: {
SectionWidgets: SectionWidgetsStub,
AsyncSearchInput: true,
CollapseToggleButton: true
}
}
})
}
describe('TabSubgraphInputs', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
captured.rows = []
vi.clearAllMocks()
})
it('lists a subgraph node promoted widget as a store-backed parameter row', () => {
const { host } = buildHostWithPromotedSeed()
renderPanel(host)
const seedRow = captured.rows.find((row) => row.widget.name === 'seed')
expect(seedRow).toBeDefined()
expect(seedRow?.node.id).toBe(host.id)
expect(seedRow?.widget.type).toBe('number')
expect(seedRow?.widget.widgetId).toBe(
widgetId(host.rootGraph.id, host.id, 'seed')
)
expect(seedRow?.widget.value).toBe(42)
})
it('reflects the current host widget value from the store', () => {
const { host } = buildHostWithPromotedSeed()
const id = widgetId(host.rootGraph.id, host.id, 'seed')
useWidgetValueStore().setValue(id, 7)
renderPanel(host)
const seedRow = captured.rows.find((row) => row.widget.name === 'seed')
expect(seedRow?.widget.value).toBe(7)
})
it('reflects value changes through the same descriptor without rebuilding it', () => {
const { host } = buildHostWithPromotedSeed()
renderPanel(host)
const seedRow = captured.rows.find((row) => row.widget.name === 'seed')!
expect(seedRow.widget.value).toBe(42)
// A value edit must not require a new descriptor object: the same row
// reflects the store change via its live getter, keeping render keys stable.
useWidgetValueStore().setValue(
widgetId(host.rootGraph.id, host.id, 'seed'),
100
)
expect(seedRow.widget.value).toBe(100)
})
})

View File

@@ -3,13 +3,14 @@ import { storeToRefs } from 'pinia'
import { computed, nextTick, ref, shallowRef, useTemplateRef, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { promotedInputWidgets } from '@/core/graph/subgraph/promotedInputWidget'
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
import {
getWidgetName,
isWidgetPromotedOnSubgraphNode,
reorderSubgraphInputsByWidgetOrder
} from '@/core/graph/subgraph/promotionUtils'
import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import AsyncSearchInput from '@/components/ui/search-input/AsyncSearchInput.vue'
import CollapseToggleButton from '@/components/rightSidePanel/layout/CollapseToggleButton.vue'
@@ -44,6 +45,32 @@ const isAllCollapsed = computed({
})
const advancedInputsSectionRef = useTemplateRef('advancedInputsSectionRef')
function isSamePromotedWidget(a: IBaseWidget, b: IBaseWidget): boolean {
return (
isPromotedWidgetView(a) &&
isPromotedWidgetView(b) &&
a.sourceNodeId === b.sourceNodeId &&
a.sourceWidgetName === b.sourceWidgetName
)
}
function getPromotedWidgets(): IBaseWidget[] {
const inputWidgets = node.inputs
.map((input) => input._widget)
.filter((widget): widget is IBaseWidget =>
Boolean(widget && isPromotedWidgetView(widget))
)
const extraWidgets = (node.widgets ?? []).filter(
(widget) =>
isPromotedWidgetView(widget) &&
!inputWidgets.some((inputWidget) =>
isSamePromotedWidget(inputWidget, widget)
)
)
return [...inputWidgets, ...extraWidgets]
}
watch(
focusedSection,
async (section) => {
@@ -66,7 +93,7 @@ watch(
)
const widgetsList = computed((): NodeWidgetsList => {
return promotedInputWidgets(node).map((widget) => ({ node, widget }))
return getPromotedWidgets().map((widget) => ({ node, widget }))
})
const advancedInputsWidgets = computed((): NodeWidgetsList => {

View File

@@ -5,9 +5,8 @@ import { useI18n } from 'vue-i18n'
import MoreButton from '@/components/button/MoreButton.vue'
import Button from '@/components/ui/button/Button.vue'
import { widgetPromotedSource } from '@/core/graph/subgraph/promotedInputWidget'
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
import {
demotePromotedInput,
demoteWidget,
isLinkedPromotion,
promoteWidget
@@ -17,7 +16,6 @@ import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useNodeDefStore } from '@/stores/nodeDefStore'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
import { useFavoritedWidgetsStore } from '@/stores/workspace/favoritedWidgetsStore'
import { getWidgetDefaultValue, promptWidgetLabel } from '@/utils/widgetUtil'
import type { WidgetValue } from '@/utils/widgetUtil'
@@ -47,10 +45,8 @@ const { t } = useI18n()
const hasParents = computed(() => parents?.length > 0)
const isLinked = computed(() => {
if (!node.isSubgraphNode()) return false
const source = widgetPromotedSource(node, widget)
if (!source) return false
return isLinkedPromotion(node, source.nodeId, source.widgetName)
if (!node.isSubgraphNode() || !isPromotedWidgetView(widget)) return false
return isLinkedPromotion(node, widget.sourceNodeId, widget.sourceWidgetName)
})
const canToggleVisibility = computed(() => hasParents.value && !isLinked.value)
const favoriteNode = computed(() =>
@@ -68,16 +64,9 @@ const defaultValue = computed(() => getWidgetDefaultValue(inputSpec.value))
const hasDefault = computed(() => defaultValue.value !== undefined)
const currentValue = computed(
() =>
(widget.widgetId &&
useWidgetValueStore().getWidget(widget.widgetId)?.value) ??
widget.value
)
const isCurrentValueDefault = computed(() => {
if (!hasDefault.value) return true
return isEqual(currentValue.value, defaultValue.value)
return isEqual(widget.value, defaultValue.value)
})
async function handleRename() {
@@ -88,15 +77,21 @@ async function handleRename() {
function handleHideInput() {
if (!parents?.length) return
const source = widgetPromotedSource(node, widget)
if (source) {
if (isPromotedWidgetView(widget)) {
for (const parent of parents) {
const sourceNodeId =
String(node.id) === String(parent.id) ? source.nodeId : String(node.id)
demotePromotedInput(parent, {
sourceNodeId,
sourceWidgetName: source.widgetName
})
String(node.id) === String(parent.id)
? widget.sourceNodeId
: String(node.id)
demoteWidget(
{
id: sourceNodeId,
title: node.title,
type: node.type
},
widget,
[parent]
)
}
canvasStore.canvas?.setDirty(true, true)
} else {

View File

@@ -5,11 +5,8 @@ import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
import { asNodeId } from '@/lib/litegraph/src/litegraph'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
import { widgetId } from '@/types/widgetId'
import WidgetItem from './WidgetItem.vue'
const { mockGetInputSpecForWidget, StubWidgetComponent } = vi.hoisted(() => ({
@@ -45,6 +42,10 @@ vi.mock('@/composables/graph/useGraphNodeManager', () => ({
getControlWidget: vi.fn(() => undefined)
}))
vi.mock('@/core/graph/subgraph/resolveConcretePromotedWidget', () => ({
resolvePromotedWidgetSource: vi.fn(() => undefined)
}))
vi.mock(
'@/renderer/extensions/vueNodes/widgets/registry/widgetRegistry',
() => ({
@@ -95,6 +96,43 @@ function createMockWidget(overrides: Partial<IBaseWidget> = {}): IBaseWidget {
} as IBaseWidget
}
/**
* Creates a mock PromotedWidgetView that mirrors the real class:
* properties like name, type, value, options are prototype getters,
* NOT own properties — so object spread loses them.
*/
function createMockPromotedWidgetView(
sourceOptions: IBaseWidget['options'] = {
values: ['model_a.safetensors', 'model_b.safetensors']
}
): IBaseWidget {
class MockPromotedWidgetView {
readonly sourceNodeId = '42'
readonly sourceWidgetName = 'ckpt_name'
readonly serialize = false
get name(): string {
return 'ckpt_name'
}
get type(): string {
return 'combo'
}
get value(): unknown {
return 'model_a.safetensors'
}
get options(): IBaseWidget['options'] {
return sourceOptions
}
get label(): string | undefined {
return undefined
}
get y(): number {
return 0
}
}
return fromAny<IBaseWidget, unknown>(new MockPromotedWidgetView())
}
function renderWidgetItem(
widget: IBaseWidget,
node: LGraphNode = createMockNode()
@@ -129,7 +167,7 @@ describe('WidgetItem', () => {
vi.clearAllMocks()
})
describe('widget state rendering', () => {
describe('promoted widget options', () => {
it('passes options from a regular widget to the widget component', () => {
const widget = createMockWidget({
options: { values: ['a', 'b', 'c'] }
@@ -142,63 +180,35 @@ describe('WidgetItem', () => {
})
})
it('passes options from widget state to the widget component', () => {
it('passes options from a PromotedWidgetView to the widget component', () => {
const expectedOptions = {
values: ['model_a.safetensors', 'model_b.safetensors']
}
const id = widgetId('test-graph-id', asNodeId(1), 'ckpt_name')
const widget = createMockWidget({ widgetId: id, name: 'ckpt_name' })
useWidgetValueStore().registerWidget(id, {
type: 'combo',
value: 'model_a.safetensors',
options: expectedOptions
})
const widget = createMockPromotedWidgetView(expectedOptions)
const { container } = renderWidgetItem(widget)
const stub = getStubWidget(container)
expect(stub.options).toEqual(expectedOptions)
})
it('passes type from widget state to the widget component', () => {
const id = widgetId('test-graph-id', asNodeId(1), 'ckpt_name')
const widget = createMockWidget({ widgetId: id, type: 'string' })
useWidgetValueStore().registerWidget(id, {
type: 'combo',
value: 'model_a.safetensors',
options: { values: ['model_a.safetensors'] }
})
it('passes type from a PromotedWidgetView to the widget component', () => {
const widget = createMockPromotedWidgetView()
const { container } = renderWidgetItem(widget)
const stub = getStubWidget(container)
expect(stub.type).toBe('combo')
})
it('passes name from widget state to the widget component', () => {
const id = widgetId('test-graph-id', asNodeId(1), 'ckpt_name')
const widget = createMockWidget({ widgetId: id, name: 'source_name' })
useWidgetValueStore().registerWidget(id, {
type: 'combo',
value: 'model_a.safetensors',
options: { values: ['model_a.safetensors'] }
})
it('passes name from a PromotedWidgetView to the widget component', () => {
const widget = createMockPromotedWidgetView()
const { container } = renderWidgetItem(widget)
const stub = getStubWidget(container)
expect(stub.name).toBe('ckpt_name')
})
it('passes value from widget state to the widget component', () => {
const id = widgetId('test-graph-id', asNodeId(1), 'ckpt_name')
const widget = createMockWidget({ widgetId: id, value: 'source value' })
useWidgetValueStore().registerWidget(id, {
type: 'combo',
value: 'model_a.safetensors',
options: { values: ['model_a.safetensors'] }
})
it('passes value from a PromotedWidgetView to the widget component', () => {
const widget = createMockPromotedWidgetView()
const { container } = renderWidgetItem(widget)
const stub = getStubWidget(container)

View File

@@ -4,6 +4,7 @@ import { useI18n } from 'vue-i18n'
import EditableText from '@/components/common/EditableText.vue'
import { getControlWidget } from '@/composables/graph/useGraphNodeManager'
import { resolvePromotedWidgetSource } from '@/core/graph/subgraph/resolveConcretePromotedWidget'
import { st } from '@/i18n'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
@@ -16,12 +17,11 @@ import {
} from '@/renderer/extensions/vueNodes/widgets/registry/widgetRegistry'
import { useNodeDefStore } from '@/stores/nodeDefStore'
import {
stripGraphPrefix,
useWidgetValueStore
useWidgetValueStore,
stripGraphPrefix
} from '@/stores/widgetValueStore'
import { useFavoritedWidgetsStore } from '@/stores/workspace/favoritedWidgetsStore'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
import { widgetId } from '@/types/widgetId'
import { resolveNodeDisplayName } from '@/utils/nodeTitleUtil'
import { cn } from '@comfyorg/tailwind-utils'
import { renameWidget } from '@/utils/widgetUtil'
@@ -67,32 +67,35 @@ const widgetComponent = computed(() => {
return component || WidgetLegacy
})
function resolveSourceWidget(): { node: LGraphNode; widget: IBaseWidget } {
const source = resolvePromotedWidgetSource(node, widget)
return source ?? { node, widget }
}
const simplifiedWidget = computed((): SimplifiedWidget => {
const { node: sourceNode, widget: sourceWidget } = resolveSourceWidget()
const graphId = node.graph?.rootGraph?.id
const bareNodeId = stripGraphPrefix(String(node.id))
const widgetState = widget.widgetId
? useWidgetValueStore().getWidget(widget.widgetId)
: graphId
? widgetValueStore.getWidget(widgetId(graphId, bareNodeId, widget.name))
: undefined
const widgetName = widgetState?.name ?? widget.name
const widgetType = widgetState?.type ?? widget.type
const bareNodeId = stripGraphPrefix(String(sourceNode.id))
const widgetState = graphId
? widgetValueStore.getWidget(graphId, bareNodeId, sourceWidget.name)
: undefined
return {
name: widgetName,
type: widgetType,
name: widget.name,
type: widget.type,
value: widgetState?.value ?? widget.value,
label: widgetState?.label ?? widget.label,
options: widgetState?.options ?? widget.options,
spec: nodeDefStore.getInputSpecForWidget(node, widgetName),
controlWidget: getControlWidget(widget)
spec: nodeDefStore.getInputSpecForWidget(sourceNode, sourceWidget.name),
controlWidget: getControlWidget(sourceWidget)
}
})
const displayNodeName = computed((): string | null => {
if (!node) return null
const sourceNodeName = computed((): string | null => {
const sourceNode = resolvePromotedWidgetSource(node, widget)?.node ?? node
if (!sourceNode) return null
const fallbackNodeTitle = t('rightSidePanel.fallbackNodeTitle')
return resolveNodeDisplayName(node, {
return resolveNodeDisplayName(sourceNode, {
emptyLabel: fallbackNodeTitle,
untitledLabel: fallbackNodeTitle,
st
@@ -164,10 +167,10 @@ const displayLabel = customRef((track, trigger) => {
/>
<span
v-if="(showNodeName || hasParents) && displayNodeName"
v-if="(showNodeName || hasParents) && sourceNodeName"
class="mx-1 my-0 min-w-10 flex-1 truncate p-0 text-right text-xs text-muted-foreground"
>
{{ displayNodeName }}
{{ sourceNodeName }}
</span>
<div
v-if="!hiddenWidgetActions"

View File

@@ -14,9 +14,8 @@ import { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { usePreviewExposureStore } from '@/stores/previewExposureStore'
import { promotedInputWidget } from '@/core/graph/subgraph/promotedInputWidget'
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
import { promoteValueWidgetViaSubgraphInput } from '@/core/graph/subgraph/promotionUtils'
import { resolveSubgraphInputTarget } from '@/core/graph/subgraph/resolveSubgraphInputTarget'
import SubgraphEditor from './SubgraphEditor.vue'
import type { ComponentProps } from 'vue-component-type-helpers'
import type DraggableList from '@/components/common/DraggableList.vue'
@@ -168,20 +167,11 @@ describe('SubgraphEditor', () => {
.map((el) => el.textContent?.trim())
).toEqual(['first', 'second'])
const rowFor = (sourceNode: LGraphNode) => {
const input = host.inputs.find((input) => {
if (!input.widgetId) return false
const target = resolveSubgraphInputTarget(host, input.name)
return target?.nodeId === String(sourceNode.id)
})!
return {
kind: 'promoted',
node: sourceNode,
input,
widget: promotedInputWidget(input)!
}
}
const reversed = [rowFor(secondNode), rowFor(firstNode)] as PromotedRow[]
const promotedWidgets = host.widgets.filter(isPromotedWidgetView)
const reversed = [
{ kind: 'promoted', node: secondNode, widget: promotedWidgets[1] },
{ kind: 'promoted', node: firstNode, widget: promotedWidgets[0] }
] as PromotedRow[]
listSetter?.(reversed)
await nextTick()
@@ -192,42 +182,6 @@ describe('SubgraphEditor', () => {
).toEqual(['second', 'first'])
})
it('moves a widget to shown when promoted from the hidden section', async () => {
const subgraph = createTestSubgraph()
const host = createTestSubgraphNode(subgraph)
const sourceNode = new LGraphNode('SourceNode')
subgraph.add(sourceNode)
const sourceInput = sourceNode.addInput('first', 'STRING')
const sourceWidget = sourceNode.addWidget('text', 'first', '', () => {})
sourceInput.widget = { name: sourceWidget.name }
useCanvasStore().selectedItems = [host]
render(SubgraphEditor, {
container: document.body.appendChild(document.createElement('div')),
global: {
plugins: [i18n],
stubs: {
DraggableList: {
template:
'<div data-testid="draggable-list"><slot drag-class="draggable-item" /></div>'
}
}
}
})
const hidden = screen.getByTestId('subgraph-editor-hidden-section')
await userEvent.click(within(hidden).getByTestId('subgraph-widget-toggle'))
await nextTick()
const shown = screen.getByTestId('subgraph-editor-shown-section')
expect(
within(shown)
.getAllByTestId('subgraph-widget-label')
.map((el) => el.textContent?.trim())
).toEqual(['first'])
})
it('demotes linked promoted widgets when "Hide all" is clicked', async () => {
const subgraph = createTestSubgraph()
const host = createTestSubgraphNode(subgraph)
@@ -259,13 +213,13 @@ describe('SubgraphEditor', () => {
}
})
expect(host.inputs.filter((input) => input.widgetId)).toHaveLength(2)
expect(host.widgets.filter(isPromotedWidgetView)).toHaveLength(2)
const shown = screen.getByTestId('subgraph-editor-shown-section')
const hideAllLink = within(shown).getByText('Hide all')
await userEvent.click(hideAllLink)
expect(host.inputs.filter((input) => input.widgetId)).toHaveLength(0)
expect(host.widgets.filter(isPromotedWidgetView)).toHaveLength(0)
})
it('removes the exposure when a preview row without a real source widget is demoted', async () => {

View File

@@ -5,8 +5,9 @@ import { computed, onMounted, shallowRef, watch } from 'vue'
import DraggableList from '@/components/common/DraggableList.vue'
import Button from '@/components/ui/button/Button.vue'
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
import type { PromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
import {
demotePromotedInput,
demoteWidget,
getPromotableWidgets,
isLinkedPromotion,
@@ -15,15 +16,8 @@ import {
pruneDisconnected,
reorderSubgraphInputsByWidgetOrder
} from '@/core/graph/subgraph/promotionUtils'
import {
promotedInputSource,
promotedInputWidget
} from '@/core/graph/subgraph/promotedInputWidget'
import type { PromotedSource } from '@/core/graph/subgraph/promotedInputWidget'
import type { WidgetItem } from '@/core/graph/subgraph/promotionUtils'
import type { PreviewExposure } from '@/core/schemas/previewExposureSchema'
import type { INodeInputSlot } from '@/lib/litegraph/src/interfaces'
import { asNodeId, UNASSIGNED_NODE_ID } from '@/lib/litegraph/src/utils/nodeId'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
@@ -39,8 +33,7 @@ import SubgraphNodeWidget from './SubgraphNodeWidget.vue'
type PromotedRow = {
kind: 'promoted'
node: LGraphNode
input: INodeInputSlot
widget: IBaseWidget
widget: PromotedWidgetView
}
type PreviewRow = {
kind: 'preview'
@@ -61,23 +54,11 @@ const activeNode = computed(() => {
return undefined
})
const promotedRows = shallowRef<readonly PromotedRow[]>([])
function buildPromotedRows(node: SubgraphNode): PromotedRow[] {
return node.inputs.flatMap((input): PromotedRow[] => {
const widget = promotedInputWidget(input)
if (!widget) return []
const source = promotedInputSource(node, input)
if (!source) return []
const sourceNode = node.subgraph._nodes_by_id[asNodeId(source.nodeId)]
if (!sourceNode) return []
return [{ kind: 'promoted', node: sourceNode, input, widget }]
})
const promotedWidgets = shallowRef<readonly IBaseWidget[]>([])
function refreshPromotedWidgets() {
promotedWidgets.value = activeNode.value?.widgets ?? []
}
function refreshPromotedRows() {
const node = activeNode.value
promotedRows.value = node ? buildPromotedRows(node) : []
}
watch(activeNode, refreshPromotedRows, { immediate: true })
watch(activeNode, refreshPromotedWidgets, { immediate: true })
useEventListener(
() => activeNode.value?.subgraph.events,
[
@@ -87,36 +68,40 @@ useEventListener(
'removing-input',
'inputs-reordered'
],
refreshPromotedRows
refreshPromotedWidgets
)
function promotedRowSource(row: PromotedRow): PromotedSource | undefined {
const node = activeNode.value
return node ? promotedInputSource(node, row.input) : undefined
}
const activeRows = computed<ActiveRow[]>(() => {
const node = activeNode.value
if (!node) return []
return [...promotedRows.value, ...getActivePreviewRows(node)]
return [...getActivePromotedRows(node), ...getActivePreviewRows(node)]
})
const activePromotedRows = computed<PromotedRow[]>({
get() {
return [...promotedRows.value]
const node = activeNode.value
return node ? getActivePromotedRows(node) : []
},
set(value: PromotedRow[]) {
updateActivePromotedRows(value, activePromotedRows.value)
}
})
function getActivePromotedRows(node: SubgraphNode): PromotedRow[] {
return promotedWidgets.value.flatMap((widget): PromotedRow[] => {
if (!isPromotedWidgetView(widget)) return []
const sourceNode = node.subgraph._nodes_by_id[widget.sourceNodeId]
if (!sourceNode) return []
return [{ kind: 'promoted', node: sourceNode, widget }]
})
}
function getActivePreviewRows(node: SubgraphNode): PreviewRow[] {
const hostLocator = String(node.id)
const rootGraphId = node.rootGraph.id
const exposures = previewExposureStore.getExposures(rootGraphId, hostLocator)
return exposures.flatMap((exposure): PreviewRow[] => {
const sourceNode =
node.subgraph._nodes_by_id[asNodeId(exposure.sourceNodeId)]
const sourceNode = node.subgraph._nodes_by_id[exposure.sourceNodeId]
if (!sourceNode) return []
const realWidget = getPromotableWidgets(sourceNode).find(
(candidate) => candidate.name === exposure.sourcePreviewName
@@ -145,7 +130,7 @@ function updateActivePromotedRows(
if (currentKeys.size === nextKeys.size) {
reorderSubgraphInputsByWidgetOrder(
node,
value.map((row) => ({ widgetId: row.widget.widgetId }))
value.map((row) => row.widget)
)
}
refreshPromotedWidgetRendering()
@@ -166,11 +151,9 @@ const interiorWidgets = computed<WidgetItem[]>(() => {
})
function activeRowSourceKey(row: ActiveRow): string {
if (row.kind === 'promoted') {
const source = promotedRowSource(row)
return `${source?.nodeId ?? row.node.id}:${source?.widgetName ?? ''}`
}
return `${row.exposure.sourceNodeId}:${row.exposure.sourcePreviewName}`
return row.kind === 'promoted'
? `${row.widget.sourceNodeId}:${row.widget.sourceWidgetName}`
: `${row.exposure.sourceNodeId}:${row.exposure.sourcePreviewName}`
}
const candidateWidgets = computed<WidgetItem[]>(() => {
@@ -244,17 +227,19 @@ function rowDisplayName(row: ActiveRow): string {
function isRowLinked(row: ActiveRow): boolean {
if (row.kind !== 'promoted') return false
if (row.node.id === UNASSIGNED_NODE_ID) return true
const source = promotedRowSource(row)
if (row.node.id === -1) return true
return (
!!activeNode.value &&
!!source &&
isLinkedPromotion(activeNode.value, String(row.node.id), source.widgetName)
isLinkedPromotion(
activeNode.value,
String(row.node.id),
row.widget.sourceWidgetName
)
)
}
function promotedRowKey(row: PromotedRow): string {
return `${row.node.id}: ${row.widget.name}`
return `${row.node.id}: ${row.widget.name}:${row.widget.sourceNodeId}`
}
function rowKey(row: ActiveRow): string {
@@ -271,14 +256,7 @@ function demoteRow(row: ActiveRow) {
const subgraphNode = activeNode.value
if (!subgraphNode) return
if (row.kind === 'promoted') {
const source = promotedRowSource(row)
if (source) {
demotePromotedInput(subgraphNode, {
sourceNodeId: source.nodeId,
sourceWidgetName: source.widgetName
})
}
refreshPromotedWidgetRendering()
demoteWidget(row.node, row.widget, [subgraphNode])
return
}
if (row.realWidget) {
@@ -296,18 +274,13 @@ function demoteRow(row: ActiveRow) {
function promotePromotedRow(row: PromotedRow) {
const subgraphNode = activeNode.value
if (!subgraphNode) return
const source = promotedRowSource(row)
const sourceWidget = source
? row.node.widgets?.find((widget) => widget.name === source.widgetName)
: undefined
if (sourceWidget) promoteWidget(row.node, sourceWidget, [subgraphNode])
promoteWidget(row.node, row.widget, [subgraphNode])
}
function promoteCandidate([node, widget]: WidgetItem) {
const subgraphNode = activeNode.value
if (!subgraphNode) return
promoteWidget(node, widget, [subgraphNode])
refreshPromotedRows()
}
function showAll() {

View File

@@ -225,7 +225,6 @@ import {
} from 'vue'
import { useI18n } from 'vue-i18n'
import { asNodeId } from '@/lib/litegraph/src/utils/nodeId'
import NoResultsPlaceholder from '@/components/common/NoResultsPlaceholder.vue'
import AssetsSidebarGridView from '@/components/sidebar/tabs/AssetsSidebarGridView.vue'
import AssetsSidebarListView from '@/components/sidebar/tabs/AssetsSidebarListView.vue'
@@ -473,7 +472,7 @@ const galleryItems = computed(() => {
filename: asset.name,
subfolder: '',
type: 'output',
nodeId: asNodeId('0'),
nodeId: '0',
mediaType: mediaType === 'image' ? 'images' : mediaType
})

View File

@@ -7,8 +7,7 @@ import type { Positionable } from '@/lib/litegraph/src/litegraph'
import {
LGraphEventMode,
LGraphNode,
Reroute,
asNodeId
Reroute
} from '@/lib/litegraph/src/litegraph'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import type { NodeId } from '@/renderer/core/layout/types'
@@ -39,7 +38,7 @@ vi.mock('@/lib/litegraph/src/litegraph', async (importOriginal) => {
// unmodified — the node accessors filter selectedItems with the real predicate.
const makeNode = (mode: LGraphEventMode, id = 1): LGraphNode => {
const node = new LGraphNode('Test')
node.id = asNodeId(id)
node.id = id
node.mode = mode
return node
}
@@ -70,7 +69,7 @@ class MockNode implements Positionable {
) {
this.pos = pos
this.size = size
this.id = asNodeId('mock-node')
this.id = 'mock-node'
this.boundingRect = [0, 0, 0, 0]
}

View File

@@ -4,8 +4,12 @@ import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { installErrorClearingHooks } from '@/composables/graph/useErrorClearingHooks'
import { promoteValueWidgetViaSubgraphInput } from '@/core/graph/subgraph/promotionUtils'
import { LGraph, LGraphNode, asNodeId } from '@/lib/litegraph/src/litegraph'
import { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
import type {
CanvasPointer,
CanvasPointerEvent,
LGraphCanvas
} from '@/lib/litegraph/src/litegraph'
import {
createTestSubgraph,
createTestSubgraphNode
@@ -232,7 +236,7 @@ describe('Widget change error clearing via onWidgetChanged', () => {
seedRequiredInputMissingNodeError(store, String(node.id), 'image')
mediaStore.setMissingMedia([
{
nodeId: node.id,
nodeId: String(node.id),
nodeType: 'LoadImage',
widgetName: 'image',
mediaType: 'image',
@@ -252,6 +256,273 @@ describe('Widget change error clearing via onWidgetChanged', () => {
expect(store.lastNodeErrors).toBeNull()
expect(mediaStore.missingMediaCandidates).toBeNull()
})
it('uses interior node execution ID for promoted widget error clearing', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'ckpt_input', type: '*' }]
})
const interiorNode = new LGraphNode('CheckpointLoaderSimple')
const interiorInput = interiorNode.addInput('ckpt_input', '*')
interiorNode.addWidget(
'combo',
'ckpt_name',
'model.safetensors',
() => undefined,
{ values: ['model.safetensors'] }
)
interiorInput.widget = { name: 'ckpt_name' }
subgraph.add(interiorNode)
subgraph.inputNode.slots[0].connect(interiorInput, interiorNode)
const subgraphNode = createTestSubgraphNode(subgraph, { id: 65 })
subgraphNode._internalConfigureAfterSlots()
const graph = subgraphNode.graph as LGraph
graph.add(subgraphNode)
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graph)
installErrorClearingHooks(graph)
const store = useExecutionErrorStore()
const interiorExecId = `${subgraphNode.id}:${interiorNode.id}`
const promotedWidget = subgraphNode.widgets?.find(
(w) => 'sourceWidgetName' in w && w.sourceWidgetName === 'ckpt_name'
)
expect(promotedWidget).toBeDefined()
seedRequiredInputMissingNodeError(store, interiorExecId, 'ckpt_name')
subgraphNode.onWidgetChanged!.call(
subgraphNode,
'ckpt_name',
'other_model.safetensors',
'model.safetensors',
promotedWidget!
)
expect(store.lastNodeErrors).toBeNull()
})
it('clears range errors for promoted widgets by interior widget name', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'steps_input', type: 'INT' }]
})
const interiorNode = new LGraphNode('KSampler')
const interiorInput = interiorNode.addInput('steps_input', 'INT')
interiorNode.addWidget('number', 'steps', 150, () => undefined, {
min: 1,
max: 100
})
interiorInput.widget = { name: 'steps' }
subgraph.add(interiorNode)
subgraph.inputNode.slots[0].connect(interiorInput, interiorNode)
const subgraphNode = createTestSubgraphNode(subgraph, { id: 65 })
subgraphNode._internalConfigureAfterSlots()
const graph = subgraphNode.graph as LGraph
graph.add(subgraphNode)
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graph)
installErrorClearingHooks(graph)
const store = useExecutionErrorStore()
const interiorExecId = `${subgraphNode.id}:${interiorNode.id}`
store.lastNodeErrors = {
[interiorExecId]: {
errors: [
{
type: 'value_bigger_than_max',
message: 'Too big',
details: '',
extra_info: { input_name: 'steps' }
}
],
dependent_outputs: [],
class_type: 'KSampler'
}
}
const promotedWidget = subgraphNode.widgets?.find(
(w) => 'sourceWidgetName' in w && w.sourceWidgetName === 'steps'
)
expect(promotedWidget).toBeDefined()
subgraphNode.onWidgetChanged!.call(
subgraphNode,
'steps',
50,
150,
promotedWidget!
)
expect(store.lastNodeErrors).toBeNull()
})
it('clears missing model state when a promoted widget changes through the legacy canvas path', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'ckpt_input', type: '*' }]
})
const interiorNode = new LGraphNode('CheckpointLoaderSimple')
interiorNode.type = 'CheckpointLoaderSimple'
const interiorInput = interiorNode.addInput('ckpt_input', '*')
interiorNode.addWidget(
'combo',
'ckpt_name',
'missing.safetensors',
() => undefined,
{ values: ['missing.safetensors', 'present.safetensors'] }
)
interiorInput.widget = { name: 'ckpt_name' }
subgraph.add(interiorNode)
subgraph.inputNode.slots[0].connect(interiorInput, interiorNode)
const subgraphNode = createTestSubgraphNode(subgraph, {
id: 65,
pos: [0, 0],
size: [200, 100]
})
subgraphNode._internalConfigureAfterSlots()
const graph = subgraphNode.graph as LGraph
graph.add(subgraphNode)
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graph)
installErrorClearingHooks(graph)
const missingModelStore = useMissingModelStore()
const interiorExecId = `${subgraphNode.id}:${interiorNode.id}`
missingModelStore.setMissingModels([
{
nodeId: interiorExecId,
nodeType: 'CheckpointLoaderSimple',
widgetName: 'ckpt_name',
isAssetSupported: false,
name: 'missing.safetensors',
isMissing: true
} satisfies MissingModelCandidate
])
const promotedWidget = subgraphNode.widgets?.find(
(widget) =>
'sourceWidgetName' in widget && widget.sourceWidgetName === 'ckpt_name'
)
expect(promotedWidget).toBeDefined()
const clickEvent = fromAny<CanvasPointerEvent, unknown>({
canvasX: 190,
canvasY: 20,
deltaX: 0
})
const pointer = fromAny<CanvasPointer, unknown>({
eDown: clickEvent
})
const canvas = fromAny<LGraphCanvas, unknown>({
graph_mouse: [190, 20],
last_mouseclick: 0
})
const handled = promotedWidget!.onPointerDown?.(
pointer,
subgraphNode,
canvas
)
expect(handled).toBe(true)
expect(pointer.onClick).toBeDefined()
pointer.onClick?.(clickEvent)
expect(missingModelStore.missingModelCandidates).toBeNull()
})
it('keeps unchanged same-named promoted model targets on the canvas path', () => {
const subgraph = createTestSubgraph({
inputs: [
{ name: 'first_ckpt', type: '*' },
{ name: 'second_ckpt', type: '*' }
]
})
const firstNode = new LGraphNode('CheckpointLoaderSimple')
firstNode.type = 'CheckpointLoaderSimple'
const firstInput = firstNode.addInput('first_ckpt', '*')
const firstWidget = firstNode.addWidget(
'combo',
'ckpt_name',
'missing.safetensors',
() => undefined,
{ values: ['missing.safetensors', 'present.safetensors'] }
)
firstInput.widget = { name: 'ckpt_name' }
subgraph.add(firstNode)
subgraph.inputNode.slots[0].connect(firstInput, firstNode)
const secondNode = new LGraphNode('CheckpointLoaderSimple')
secondNode.type = 'CheckpointLoaderSimple'
const secondInput = secondNode.addInput('second_ckpt', '*')
secondNode.addWidget(
'combo',
'ckpt_name',
'missing.safetensors',
() => undefined,
{ values: ['missing.safetensors', 'present.safetensors'] }
)
secondInput.widget = { name: 'ckpt_name' }
subgraph.add(secondNode)
subgraph.inputNode.slots[1].connect(secondInput, secondNode)
const subgraphNode = createTestSubgraphNode(subgraph, { id: 65 })
subgraphNode._internalConfigureAfterSlots()
const graph = subgraphNode.graph as LGraph
graph.add(subgraphNode)
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graph)
installErrorClearingHooks(graph)
const promotedWidgets =
subgraphNode.widgets?.filter(
(widget) =>
'sourceWidgetName' in widget &&
widget.sourceWidgetName === 'ckpt_name'
) ?? []
expect(promotedWidgets).toHaveLength(2)
const missingModelStore = useMissingModelStore()
const firstExecId = `${subgraphNode.id}:${firstNode.id}`
const secondExecId = `${subgraphNode.id}:${secondNode.id}`
missingModelStore.setMissingModels([
{
nodeId: firstExecId,
nodeType: 'CheckpointLoaderSimple',
widgetName: 'ckpt_name',
isAssetSupported: false,
name: 'missing.safetensors',
isMissing: true
} satisfies MissingModelCandidate,
{
nodeId: secondExecId,
nodeType: 'CheckpointLoaderSimple',
widgetName: 'ckpt_name',
isAssetSupported: false,
name: 'missing.safetensors',
isMissing: true
} satisfies MissingModelCandidate
])
firstWidget.value = 'present.safetensors'
subgraphNode.onWidgetChanged!.call(
subgraphNode,
'ckpt_name',
'present.safetensors',
'missing.safetensors',
firstWidget
)
expect(missingModelStore.missingModelCandidates).toEqual([
expect.objectContaining({
nodeId: secondExecId,
widgetName: 'ckpt_name',
name: 'missing.safetensors'
})
])
})
})
describe('installErrorClearingHooks lifecycle', () => {
@@ -371,7 +642,7 @@ describe('installErrorClearingHooks lifecycle', () => {
.spyOn(missingModelScan, 'scanNodeModelCandidates')
.mockImplementation((_rootGraph, node) => [
{
nodeId: node.id,
nodeId: String(node.id),
nodeType: node.type,
widgetName: 'ckpt_name',
isAssetSupported: false,
@@ -448,7 +719,7 @@ describe('onNodeRemoved clears missing asset errors by execution ID', () => {
Parameters<typeof modelStore.setMissingModels>[0][number],
unknown
>({
nodeId: node.id,
nodeId: String(node.id),
nodeType: 'CheckpointLoaderSimple',
widgetName: 'ckpt_name',
isAssetSupported: false,
@@ -471,7 +742,7 @@ describe('onNodeRemoved clears missing asset errors by execution ID', () => {
const interiorNode = new LGraphNode('CheckpointLoaderSimple')
subgraph.add(interiorNode)
const subgraphNode = createTestSubgraphNode(subgraph, { id: asNodeId(65) })
const subgraphNode = createTestSubgraphNode(subgraph, { id: 65 })
const rootGraph = subgraphNode.graph as LGraph
rootGraph.add(subgraphNode)
@@ -507,7 +778,7 @@ describe('onNodeRemoved clears missing asset errors by execution ID', () => {
const interiorNode = new LGraphNode('LoadImage')
subgraph.add(interiorNode)
const subgraphNode = createTestSubgraphNode(subgraph, { id: asNodeId(65) })
const subgraphNode = createTestSubgraphNode(subgraph, { id: 65 })
const rootGraph = subgraphNode.graph as LGraph
rootGraph.add(subgraphNode)
@@ -565,7 +836,7 @@ describe('realtime scan verifies pending cloud candidates', () => {
// verifyAssetSupportedCandidates resolves them against the assets store.
vi.spyOn(missingModelScan, 'scanNodeModelCandidates').mockReturnValue([
{
nodeId: node.id,
nodeId: String(node.id),
nodeType: 'CheckpointLoaderSimple',
widgetName: 'ckpt_name',
isAssetSupported: true,
@@ -613,7 +884,7 @@ describe('realtime scan verifies pending cloud candidates', () => {
vi.spyOn(missingModelScan, 'scanNodeModelCandidates').mockReturnValue([])
vi.spyOn(missingMediaScan, 'scanNodeMediaCandidates').mockReturnValue([
{
nodeId: node.id,
nodeId: String(node.id),
nodeType: 'LoadImage',
widgetName: 'image',
mediaType: 'image',
@@ -656,7 +927,7 @@ describe('realtime scan verifies pending cloud candidates', () => {
vi.spyOn(missingModelScan, 'scanNodeModelCandidates').mockReturnValue([
{
nodeId: node.id,
nodeId: String(node.id),
nodeType: 'CheckpointLoaderSimple',
widgetName: 'ckpt_name',
isAssetSupported: true,
@@ -702,7 +973,7 @@ describe('realtime verification staleness guards', () => {
vi.spyOn(missingModelScan, 'scanNodeModelCandidates').mockReturnValue([
{
nodeId: node.id,
nodeId: String(node.id),
nodeType: 'CheckpointLoaderSimple',
widgetName: 'ckpt_name',
isAssetSupported: true,
@@ -753,7 +1024,7 @@ describe('realtime verification staleness guards', () => {
vi.spyOn(missingModelScan, 'scanNodeModelCandidates').mockReturnValue([])
vi.spyOn(missingMediaScan, 'scanNodeMediaCandidates').mockReturnValue([
{
nodeId: node.id,
nodeId: String(node.id),
nodeType: 'LoadImage',
widgetName: 'image',
mediaType: 'image',
@@ -802,7 +1073,7 @@ describe('realtime verification staleness guards', () => {
vi.spyOn(missingModelScan, 'scanNodeModelCandidates').mockReturnValue([
{
nodeId: nodeA.id,
nodeId: String(nodeA.id),
nodeType: 'CheckpointLoaderSimple',
widgetName: 'ckpt_name',
isAssetSupported: true,
@@ -863,7 +1134,7 @@ describe('scan skips interior of bypassed subgraph containers', () => {
const interiorNode = new LGraphNode('CheckpointLoaderSimple')
subgraph.add(interiorNode)
const subgraphNode = createTestSubgraphNode(subgraph, { id: asNodeId(65) })
const subgraphNode = createTestSubgraphNode(subgraph, { id: 65 })
subgraphNode.mode = LGraphEventMode.BYPASS
const rootGraph = subgraphNode.graph as LGraph
rootGraph.add(subgraphNode)
@@ -873,7 +1144,7 @@ describe('scan skips interior of bypassed subgraph containers', () => {
// didn't short-circuit first — return a concrete missing candidate.
vi.spyOn(missingModelScan, 'scanNodeModelCandidates').mockReturnValue([
{
nodeId: asNodeId(`${subgraphNode.id}:${interiorNode.id}`),
nodeId: `${subgraphNode.id}:${interiorNode.id}`,
nodeType: 'CheckpointLoaderSimple',
widgetName: 'ckpt_name',
isAssetSupported: false,
@@ -902,13 +1173,13 @@ describe('scan skips interior of bypassed subgraph containers', () => {
const innerSubgraphNode = createTestSubgraphNode(innerSubgraph, {
parentGraph: outerSubgraph,
id: asNodeId(76)
id: 76
})
outerSubgraph.add(innerSubgraphNode)
const outerSubgraphNode = createTestSubgraphNode(outerSubgraph, {
parentGraph: rootGraph,
id: asNodeId(205)
id: 205
})
rootGraph.add(outerSubgraphNode)
@@ -978,54 +1249,4 @@ describe('clearWidgetRelatedErrors parameter routing', () => {
clearSpy.mockRestore()
})
it('clears promoted widget errors by interior execution id', () => {
const subgraph = createTestSubgraph()
const graph = subgraph.rootGraph
const host = createTestSubgraphNode(subgraph, { id: asNodeId(2) })
graph.add(host)
const interiorNode = new LGraphNode('CheckpointLoaderSimple')
interiorNode.id = asNodeId(1)
subgraph.add(interiorNode)
const input = interiorNode.addInput('ckpt_name', 'COMBO')
const widget = interiorNode.addWidget(
'combo',
'ckpt_name',
'fake_model.safetensors',
() => undefined,
{ values: ['fake_model.safetensors', 'real_model.safetensors'] }
)
input.widget = { name: widget.name }
expect(
promoteValueWidgetViaSubgraphInput(host, interiorNode, widget).ok
).toBe(true)
installErrorClearingHooks(graph)
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graph)
const missingModelStore = useMissingModelStore()
missingModelStore.setMissingModels([
{
nodeId: asNodeId('2:1'),
nodeType: 'CheckpointLoaderSimple',
widgetName: 'ckpt_name',
isAssetSupported: false,
name: 'fake_model.safetensors',
directory: 'checkpoints',
isMissing: true
}
])
const promotedWidget = host.widgets[0]
host.onWidgetChanged!.call(
host,
promotedWidget.name,
'real_model.safetensors',
'fake_model.safetensors',
promotedWidget
)
expect(missingModelStore.hasMissingModels).toBe(false)
})
})

View File

@@ -6,9 +6,12 @@
* works in legacy canvas mode as well.
*/
import { useChainCallback } from '@/composables/functional/useChainCallback'
import { widgetPromotedSource } from '@/core/graph/subgraph/promotedInputWidget'
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
import type { PromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
import { resolveConcretePromotedWidget } from '@/core/graph/subgraph/resolveConcretePromotedWidget'
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
import type { LGraph, LGraphNode, NodeId } from '@/lib/litegraph/src/litegraph'
import type { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import {
LGraphEventMode,
NodeSlotType
@@ -43,6 +46,130 @@ import {
isAncestorPathActive
} from '@/utils/graphTraversalUtil'
interface WidgetErrorClearingTarget {
executionId: string
validationInputName: string
assetWidgetName: string
currentValue: unknown
options?: { min?: number; max?: number }
}
function getWidgetRangeOptions(widget: IBaseWidget): {
min?: number
max?: number
} {
return {
min: widget.options?.min,
max: widget.options?.max
}
}
function plainWidgetToErrorTarget(
widget: IBaseWidget,
hostExecId: string
): WidgetErrorClearingTarget {
return {
executionId: hostExecId,
validationInputName: widget.name,
assetWidgetName: widget.name,
currentValue: widget.value,
options: getWidgetRangeOptions(widget)
}
}
function promotedWidgetToErrorTarget(
rootGraph: LGraph,
hostNode: LGraphNode,
widget: PromotedWidgetView,
hostExecId: string
): WidgetErrorClearingTarget {
const result = resolveConcretePromotedWidget(
hostNode,
widget.sourceNodeId,
widget.sourceWidgetName
)
const execId =
result.status === 'resolved' && result.resolved.node
? (getExecutionIdByNode(rootGraph, result.resolved.node) ?? hostExecId)
: hostExecId
const resolvedWidget =
result.status === 'resolved' ? result.resolved.widget : widget
return {
executionId: execId,
validationInputName: resolvedWidget.name,
assetWidgetName: widget.sourceWidgetName,
currentValue: resolvedWidget.value,
options: getWidgetRangeOptions(resolvedWidget)
}
}
function resolveCanvasPathPromotedWidgetTargets(
rootGraph: LGraph,
hostNode: LGraphNode,
widget: IBaseWidget,
hostExecId: string,
newValue: unknown
): WidgetErrorClearingTarget[] {
if (!hostNode.isSubgraphNode?.() || isPromotedWidgetView(widget)) return []
// Canvas-path events lose promoted identity, so the post-write value
// disambiguates same-named promoted widgets.
return (hostNode.widgets ?? [])
.filter(isPromotedWidgetView)
.filter((promotedWidget) => promotedWidget.sourceWidgetName === widget.name)
.map((promotedWidget) =>
promotedWidgetToErrorTarget(
rootGraph,
hostNode,
promotedWidget,
hostExecId
)
)
.filter((target) => Object.is(target.currentValue, newValue))
}
function resolveWidgetErrorTargets(
rootGraph: LGraph,
hostNode: LGraphNode,
widget: IBaseWidget,
hostExecId: string,
newValue: unknown
): WidgetErrorClearingTarget[] {
if (isPromotedWidgetView(widget)) {
return [
promotedWidgetToErrorTarget(rootGraph, hostNode, widget, hostExecId)
]
}
const canvasPathTargets = resolveCanvasPathPromotedWidgetTargets(
rootGraph,
hostNode,
widget,
hostExecId,
newValue
)
return canvasPathTargets.length
? canvasPathTargets
: [plainWidgetToErrorTarget(widget, hostExecId)]
}
function clearWidgetErrorTargets(
targets: WidgetErrorClearingTarget[],
newValue: unknown
): void {
const store = useExecutionErrorStore()
for (const target of targets) {
store.clearWidgetRelatedErrors(
target.executionId,
target.validationInputName,
target.assetWidgetName,
newValue,
target.options
)
}
}
const hookedNodes = new WeakSet<LGraphNode>()
type OriginalCallbacks = {
@@ -76,24 +203,21 @@ function installNodeHooks(node: LGraphNode): void {
node.onWidgetChanged = useChainCallback(
node.onWidgetChanged,
// _name is the LiteGraph callback arg; re-derive from the widget
// object to handle promoted widgets where sourceWidgetName differs.
function (_name, newValue, _oldValue, widget) {
if (!app.rootGraph) return
const hostExecId = getExecutionIdByNode(app.rootGraph, node)
if (!hostExecId) return
const promotedSource = widgetPromotedSource(node, widget)
const executionId = promotedSource
? `${hostExecId}:${promotedSource.nodeId}`
: hostExecId
const widgetName = promotedSource?.widgetName ?? widget.name
useExecutionErrorStore().clearWidgetRelatedErrors(
executionId,
widgetName,
widgetName,
newValue,
{ min: widget.options?.min, max: widget.options?.max }
const targets = resolveWidgetErrorTargets(
app.rootGraph,
node,
widget,
hostExecId,
newValue
)
clearWidgetErrorTargets(targets, newValue)
}
)
}
@@ -308,7 +432,7 @@ function scheduleAddedNodeScan(node: LGraphNode): void {
function handleNodeModeChange(
localGraph: LGraph,
nodeId: NodeId,
nodeId: number,
oldMode: number,
newMode: number
): void {
@@ -407,7 +531,7 @@ export function installErrorClearingHooks(graph: LGraph): () => void {
if (event.type === 'node:property:changed' && event.property === 'mode') {
handleNodeModeChange(
graph,
event.nodeId,
event.nodeId as number,
event.oldValue as number,
event.newValue as number
)

View File

@@ -1,16 +1,13 @@
import { createTestingPinia } from '@pinia/testing'
import { fromAny } from '@total-typescript/shoehorn'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { computed, nextTick, watch } from 'vue'
import { useGraphNodeManager } from '@/composables/graph/useGraphNodeManager'
import {
BaseWidget,
LGraph,
LGraphNode,
asNodeId
} from '@/lib/litegraph/src/litegraph'
import { widgetId } from '@/types/widgetId'
import { createPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetView'
import { BaseWidget, LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
import { widgetEntityId } from '@/world/entityIds'
import {
createTestSubgraph,
createTestSubgraphNode
@@ -50,10 +47,9 @@ describe('Node Reactivity', () => {
expect((widget as BaseWidget).node.id).toBe(node.id)
// Initial value should be in store after setNodeId was called
const id = widgetId(graph.id, node.id, 'testnum')
expect(store.getWidget(id)?.value).toBe(2)
expect(store.getWidget(graph.id, node.id, 'testnum')?.value).toBe(2)
const state = store.getWidget(id)
const state = store.getWidget(graph.id, node.id, 'testnum')
if (!state) throw new Error('Expected widget state to exist')
const onValueChange = vi.fn()
@@ -78,7 +74,7 @@ describe('Node Reactivity', () => {
})
await nextTick()
const state = store.getWidget(widgetId(graph.id, node.id, 'testnum'))
const state = store.getWidget(graph.id, node.id, 'testnum')
if (!state) throw new Error('Expected widget state to exist')
const widgetValue = computed(() => state.value)
@@ -215,32 +211,105 @@ describe('Widget slotMetadata reactivity on link disconnect', () => {
expect(widgetData?.slotMetadata?.linked).toBe(true)
})
it('names promoted widgets after the subgraph input slot and exposes the interior source name', () => {
// Subgraph input named "value" promotes an interior "prompt" widget. The
// projected widget's name is the input slot name "value"; the interior
// source widget name "prompt" is carried separately for backend lookups.
const subgraph = createTestSubgraph({
inputs: [{ name: 'value', type: 'STRING' }]
})
it('resolves slotMetadata for promoted widgets where SafeWidgetData.name differs from input.widget.name', () => {
// Set up a subgraph with an interior node that has a "prompt" widget.
// createPromotedWidgetView resolves against this interior node.
const subgraph = createTestSubgraph()
const interiorNode = new LGraphNode('interior')
const interiorInput = interiorNode.addInput('value', 'STRING')
interiorNode.id = 10
interiorNode.addWidget('string', 'prompt', 'hello', () => undefined, {})
interiorInput.widget = { name: 'prompt' }
subgraph.add(interiorNode)
subgraph.inputNode.slots[0].connect(interiorInput, interiorNode)
const subgraphNode = createTestSubgraphNode(subgraph, { id: asNodeId(123) })
subgraphNode._internalConfigureAfterSlots()
const graph = subgraphNode.graph as LGraph
const subgraphNode = createTestSubgraphNode(subgraph, { id: 123 })
// Create a PromotedWidgetView with identityName="value" (subgraph input
// slot name) and sourceWidgetName="prompt" (interior widget name).
// PromotedWidgetView.name returns "value" (identity), safeWidgetMapper
// sets SafeWidgetData.name to sourceWidgetName ("prompt").
const promotedView = createPromotedWidgetView(
subgraphNode,
'10',
'prompt',
'value',
'value'
)
// Host the promoted view on a regular node so we can control widgets
// directly (SubgraphNode.widgets is a synthetic getter).
const graph = new LGraph()
const hostNode = new LGraphNode('host')
hostNode.widgets = [promotedView]
const input = hostNode.addInput('value', 'STRING')
input.widget = { name: 'value' }
graph.add(hostNode)
const { vueNodeData } = useGraphNodeManager(graph)
const nodeData = vueNodeData.get(String(hostNode.id))
// SafeWidgetData.name is "prompt" (sourceWidgetName), but the
// input slot widget name is "value" — slotName bridges this gap.
const widgetData = nodeData?.widgets?.find((w) => w.name === 'prompt')
expect(widgetData).toBeDefined()
expect(widgetData?.slotName).toBe('value')
expect(widgetData?.slotMetadata).toBeDefined()
})
it('prefers exact _widget input matches before same-name fallbacks for promoted widgets', () => {
const subgraph = createTestSubgraph({
inputs: [
{ name: 'seed', type: '*' },
{ name: 'seed', type: '*' }
]
})
const firstNode = new LGraphNode('FirstNode')
const firstInput = firstNode.addInput('seed', '*')
firstNode.addWidget('number', 'seed', 1, () => undefined, {})
firstInput.widget = { name: 'seed' }
subgraph.add(firstNode)
const secondNode = new LGraphNode('SecondNode')
const secondInput = secondNode.addInput('seed', '*')
secondNode.addWidget('number', 'seed', 2, () => undefined, {})
secondInput.widget = { name: 'seed' }
subgraph.add(secondNode)
subgraph.inputNode.slots[0].connect(firstInput, firstNode)
subgraph.inputNode.slots[1].connect(secondInput, secondNode)
const subgraphNode = createTestSubgraphNode(subgraph, { id: 124 })
const graph = subgraphNode.graph
if (!graph) throw new Error('Expected subgraph node graph')
graph.add(subgraphNode)
const promotedViews = subgraphNode.widgets
const secondPromotedView = promotedViews[1]
if (!secondPromotedView) throw new Error('Expected second promoted view')
fromAny<
{
sourceNodeId: string
sourceWidgetName: string
},
unknown
>(secondPromotedView).sourceNodeId = '9999'
fromAny<
{
sourceNodeId: string
sourceWidgetName: string
},
unknown
>(secondPromotedView).sourceWidgetName = 'stale_widget'
const { vueNodeData } = useGraphNodeManager(graph)
const nodeData = vueNodeData.get(String(subgraphNode.id))
const secondMappedWidget = nodeData?.widgets?.find(
(widget) => widget.slotMetadata?.index === 1
)
if (!secondMappedWidget)
throw new Error('Expected mapped widget for slot 1')
const widgetData = nodeData?.widgets?.find((w) => w.name === 'value')
expect(widgetData).toBeDefined()
expect(widgetData?.sourceWidgetName).toBe('prompt')
expect(widgetData?.slotMetadata).toBeDefined()
expect(secondMappedWidget.name).not.toBe('stale_widget')
})
it('clears stale slotMetadata when input no longer matches widget', async () => {
@@ -360,9 +429,7 @@ describe('Nested promoted widget mapping', () => {
subgraphA.add(innerNode)
subgraphA.inputNode.slots[0].connect(innerInput, innerNode)
const subgraphNodeA = createTestSubgraphNode(subgraphA, {
id: asNodeId(11)
})
const subgraphNodeA = createTestSubgraphNode(subgraphA, { id: 11 })
const subgraphB = createTestSubgraph({
inputs: [{ name: 'b_input', type: '*' }]
@@ -371,9 +438,7 @@ describe('Nested promoted widget mapping', () => {
subgraphNodeA._internalConfigureAfterSlots()
subgraphB.inputNode.slots[0].connect(subgraphNodeA.inputs[0], subgraphNodeA)
const subgraphNodeB = createTestSubgraphNode(subgraphB, {
id: asNodeId(22)
})
const subgraphNodeB = createTestSubgraphNode(subgraphB, { id: 22 })
const graph = subgraphNodeB.graph as LGraph
graph.add(subgraphNodeB)
@@ -383,8 +448,8 @@ describe('Nested promoted widget mapping', () => {
expect(mappedWidget).toBeDefined()
expect(mappedWidget?.type).toBe('combo')
expect(mappedWidget?.widgetId).toBe(
widgetId(graph.id, subgraphNodeB.id, 'b_input')
expect(mappedWidget?.entityId).toBe(
widgetEntityId(graph.id, subgraphNodeB.id, 'b_input')
)
})
@@ -410,7 +475,7 @@ describe('Nested promoted widget mapping', () => {
subgraph.add(secondNode)
subgraph.inputNode.slots[1].connect(secondInput, secondNode)
const subgraphNode = createTestSubgraphNode(subgraph, { id: asNodeId(100) })
const subgraphNode = createTestSubgraphNode(subgraph, { id: 100 })
const graph = subgraphNode.graph as LGraph
graph.add(subgraphNode)
@@ -419,13 +484,13 @@ describe('Nested promoted widget mapping', () => {
const widgets = nodeData?.widgets
expect(widgets).toHaveLength(2)
expect(widgets?.[0]?.widgetId).toBe(
widgetId(graph.id, subgraphNode.id, 'first_seed')
expect(widgets?.[0]?.entityId).toBe(
widgetEntityId(graph.id, subgraphNode.id, 'first_seed')
)
expect(widgets?.[1]?.widgetId).toBe(
widgetId(graph.id, subgraphNode.id, 'second_seed')
expect(widgets?.[1]?.entityId).toBe(
widgetEntityId(graph.id, subgraphNode.id, 'second_seed')
)
expect(widgets?.[0]?.widgetId).not.toBe(widgets?.[1]?.widgetId)
expect(widgets?.[0]?.entityId).not.toBe(widgets?.[1]?.entityId)
})
})
@@ -453,7 +518,7 @@ describe('Promoted widget sourceExecutionId', () => {
subgraph.add(interiorNode)
subgraph.inputNode.slots[0].connect(interiorInput, interiorNode)
const subgraphNode = createTestSubgraphNode(subgraph, { id: asNodeId(65) })
const subgraphNode = createTestSubgraphNode(subgraph, { id: 65 })
subgraphNode._internalConfigureAfterSlots()
const graph = subgraphNode.graph as LGraph
graph.add(subgraphNode)
@@ -463,11 +528,10 @@ describe('Promoted widget sourceExecutionId', () => {
const { vueNodeData } = useGraphNodeManager(graph)
const nodeData = vueNodeData.get(String(subgraphNode.id))
const promotedWidget = nodeData?.widgets?.find(
(w) => w.name === 'ckpt_input'
(w) => w.name === 'ckpt_name'
)
expect(promotedWidget).toBeDefined()
expect(promotedWidget?.sourceWidgetName).toBe('ckpt_name')
// The interior node is inside subgraphNode (id=65),
// so its execution ID should be "65:<interiorNodeId>"
expect(promotedWidget?.sourceExecutionId).toBe(
@@ -600,7 +664,7 @@ describe('reconcileNodeErrorFlags (via lastNodeErrors watcher)', () => {
interiorNode.addInput('value', 'INT')
subgraph.add(interiorNode)
const subgraphNode = createTestSubgraphNode(subgraph, { id: asNodeId(50) })
const subgraphNode = createTestSubgraphNode(subgraph, { id: 50 })
const graph = subgraphNode.graph as LGraph
graph.add(subgraphNode)
@@ -641,7 +705,7 @@ describe('reconcileNodeErrorFlags (via lastNodeErrors watcher)', () => {
missingModelStore.setMissingModels([
{
nodeId: nodeA.id,
nodeId: String(nodeA.id),
nodeType: 'CheckpointLoader',
widgetName: 'ckpt_name',
isAssetSupported: false,
@@ -661,7 +725,7 @@ describe('reconcileNodeErrorFlags (via lastNodeErrors watcher)', () => {
missingModelStore.setMissingModels([
{
nodeId: nodeA.id,
nodeId: String(nodeA.id),
nodeType: 'CheckpointLoader',
widgetName: 'ckpt_name',
isAssetSupported: false,
@@ -682,7 +746,7 @@ describe('reconcileNodeErrorFlags (via lastNodeErrors watcher)', () => {
const interiorNode = new LGraphNode('CheckpointLoader')
subgraph.add(interiorNode)
const subgraphNode = createTestSubgraphNode(subgraph, { id: asNodeId(50) })
const subgraphNode = createTestSubgraphNode(subgraph, { id: 50 })
const graph = subgraphNode.graph as LGraph
graph.add(subgraphNode)
@@ -698,7 +762,7 @@ describe('reconcileNodeErrorFlags (via lastNodeErrors watcher)', () => {
missingModelStore.setMissingModels([
{
nodeId: asNodeId(`${subgraphNode.id}:${interiorNode.id}`),
nodeId: `${subgraphNode.id}:${interiorNode.id}`,
nodeType: 'CheckpointLoader',
widgetName: 'ckpt_name',
isAssetSupported: false,

View File

@@ -3,16 +3,17 @@
* Provides event-driven reactivity with performance optimizations
*/
import { reactiveComputed } from '@vueuse/core'
import cloneDeep from 'es-toolkit/compat/cloneDeep'
import { reactive, shallowReactive } from 'vue'
import { useChainCallback } from '@/composables/functional/useChainCallback'
import type { PromotedWidgetSource } from '@/core/graph/subgraph/promotedWidgetTypes'
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
import { matchPromotedInput } from '@/core/graph/subgraph/matchPromotedInput'
import {
inputForWidget,
promotedInputSource,
promotedInputWidgets
} from '@/core/graph/subgraph/promotedInputWidget'
import { resolveConcretePromotedWidget } from '@/core/graph/subgraph/resolveConcretePromotedWidget'
resolveConcretePromotedWidget,
resolvePromotedWidgetSource
} from '@/core/graph/subgraph/resolveConcretePromotedWidget'
import { resolveSubgraphInputTarget } from '@/core/graph/subgraph/resolveSubgraphInputTarget'
import type {
INodeInputSlot,
INodeOutputSlot
@@ -26,11 +27,10 @@ import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
import { isDOMWidget } from '@/scripts/domWidget'
import { IS_CONTROL_WIDGET } from '@/scripts/widgets'
import { useNodeDefStore } from '@/stores/nodeDefStore'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
import type { WidgetValue, SafeControlWidget } from '@/types/simplifiedWidget'
import { normalizeControlOption } from '@/types/simplifiedWidget'
import { getWidgetIdForNode } from '@/utils/litegraphUtil'
import type { WidgetId } from '@/types/widgetId'
import { getWidgetEntityIdForNode } from '@/utils/litegraphUtil'
import type { WidgetEntityId } from '@/world/entityIds'
import type {
LGraph,
@@ -38,8 +38,7 @@ import type {
LGraphNode,
LGraphTriggerAction,
LGraphTriggerEvent,
LGraphTriggerParam,
SubgraphNode
LGraphTriggerParam
} from '@/lib/litegraph/src/litegraph'
import type { TitleMode } from '@/lib/litegraph/src/types/globalEnums'
import { NodeSlotType } from '@/lib/litegraph/src/types/globalEnums'
@@ -61,7 +60,7 @@ type Badges = (LGraphBadge | (() => LGraphBadge))[]
* Value and metadata (label, hidden, disabled, etc.) are accessed via widgetValueStore.
*/
export interface SafeWidgetData {
widgetId?: WidgetId
entityId?: WidgetEntityId
nodeId?: NodeId
name: string
type: string
@@ -82,12 +81,17 @@ export interface SafeWidgetData {
advanced?: boolean
hidden?: boolean
read_only?: boolean
values?: unknown
}
/** Input specification from node definition */
spec?: InputSpec
/** Input slot metadata (index and link status) */
slotMetadata?: WidgetSlotMetadata
/**
* Original LiteGraph widget name used for slot metadata matching.
* For promoted widgets, `name` is `sourceWidgetName` (interior widget name)
* which differs from the subgraph node's input slot widget name.
*/
slotName?: string
/**
* Execution ID of the interior node that owns the source widget.
* Only set for promoted widgets where the source node differs from the
@@ -95,14 +99,10 @@ export interface SafeWidgetData {
* execution ID (e.g. `"65:42"` vs the host node's `"65"`).
*/
sourceExecutionId?: string
/**
* Interior source widget name. Only set for promoted widgets, where `name`
* is the host input slot name; missing-model lookups key by the interior
* widget name, which can differ from the slot name (e.g. after a rename).
*/
sourceWidgetName?: string
/** Tooltip text from the resolved widget. */
tooltip?: string
/** For promoted widgets, the display label from the subgraph input slot. */
promotedLabel?: string
}
export interface VueNodeData {
@@ -143,6 +143,18 @@ export interface GraphNodeManager {
cleanup(): void
}
function isPromotedDOMWidget(widget: IBaseWidget): boolean {
if (!isPromotedWidgetView(widget)) return false
const sourceWidget = resolvePromotedWidgetSource(widget.node, widget)
if (!sourceWidget) return false
const innerWidget = sourceWidget.widget
return (
('element' in innerWidget && !!innerWidget.element) ||
('component' in innerWidget && !!innerWidget.component)
)
}
export function getControlWidget(
widget: IBaseWidget
): SafeControlWidget | undefined {
@@ -202,83 +214,73 @@ function normalizeWidgetValue(value: unknown): WidgetValue {
return undefined
}
function extractWidgetDisplayOptions(
widget: IBaseWidget
): SafeWidgetData['options'] {
if (!widget.options) return undefined
return {
canvasOnly: widget.options.canvasOnly,
advanced: widget.options?.advanced ?? widget.advanced,
hidden: widget.options.hidden,
read_only: widget.options.read_only
}
}
function isDOMBackedWidget(widget: IBaseWidget): boolean {
return (
('element' in widget && !!widget.element) ||
('component' in widget && !!widget.component)
)
}
interface PromotedWidgetMetadata {
controlWidget?: SafeControlWidget
isDOMWidget: boolean
sourceExecutionId?: string
sourceWidgetName?: string
}
/**
* Resolves the interior source of a promoted subgraph input to derive the
* metadata that backend lookups key by (execution ID, interior widget name)
* plus the source widget's control + DOM nature. Also seeds host widget state
* if it is somehow missing. Returns undefined when the widget is not promoted.
*/
function resolvePromotedMetadata(
node: SubgraphNode,
widget: IBaseWidget
): PromotedWidgetMetadata | undefined {
const input = inputForWidget(node, widget)
if (!input?.widgetId) return undefined
const source = promotedInputSource(node, input)
if (!source) return undefined
const resolution = resolveConcretePromotedWidget(
node,
source.nodeId,
source.widgetName
)
const resolved =
resolution.status === 'resolved' ? resolution.resolved : undefined
const sourceWidget = resolved?.widget
const sourceNode = resolved?.node
ensurePromotedHostWidgetState(input.widgetId, input, sourceWidget)
return {
controlWidget: sourceWidget ? getControlWidget(sourceWidget) : undefined,
isDOMWidget: sourceWidget ? isDOMBackedWidget(sourceWidget) : false,
sourceExecutionId:
sourceNode && app.rootGraph
? (getExecutionIdByNode(app.rootGraph, sourceNode) ?? undefined)
: undefined,
sourceWidgetName: sourceWidget?.name
}
}
function safeWidgetMapper(
node: LGraphNode,
slotMetadata: Map<string, WidgetSlotMetadata>
): (widget: IBaseWidget) => SafeWidgetData {
const duplicateIndexByKey = new Map<string, number>()
function extractWidgetDisplayOptions(
widget: IBaseWidget
): SafeWidgetData['options'] {
if (!widget.options) return undefined
return {
canvasOnly: widget.options.canvasOnly,
advanced: widget.options?.advanced ?? widget.advanced,
hidden: widget.options.hidden,
read_only: widget.options.read_only
}
}
function resolvePromotedSourceByInputName(
inputName: string
): PromotedWidgetSource | null {
const resolvedTarget = resolveSubgraphInputTarget(node, inputName)
if (!resolvedTarget) return null
return {
sourceNodeId: resolvedTarget.nodeId,
sourceWidgetName: resolvedTarget.widgetName
}
}
function resolvePromotedWidgetIdentity(widget: IBaseWidget): {
displayName: string
promotedSource: PromotedWidgetSource | null
} {
if (!isPromotedWidgetView(widget)) {
return {
displayName: widget.name,
promotedSource: null
}
}
const matchedInput = matchPromotedInput(node.inputs, widget)
const promotedInputName = matchedInput?.name
const displayName = promotedInputName ?? widget.name
const directSource: PromotedWidgetSource = {
sourceNodeId: widget.sourceNodeId,
sourceWidgetName: widget.sourceWidgetName
}
const promotedSource =
matchedInput?._widget === widget
? (resolvePromotedSourceByInputName(displayName) ?? directSource)
: directSource
return {
displayName,
promotedSource
}
}
return function (widget) {
try {
const duplicateKey = `${widget.name}:${widget.type}`
const duplicateIndex = duplicateIndexByKey.get(duplicateKey) ?? 0
duplicateIndexByKey.set(duplicateKey, duplicateIndex + 1)
const slotInfo = slotMetadata.get(widget.name)
const { displayName, promotedSource } =
resolvePromotedWidgetIdentity(widget)
// Get shared enhancements (controlWidget, spec, nodeType)
const sharedEnhancements = getSharedWidgetEnhancements(node, widget)
const slotInfo =
slotMetadata.get(displayName) ?? slotMetadata.get(widget.name)
// Wrapper callback specific to Nodes 2.0 rendering
const callback = (v: unknown) => {
@@ -292,26 +294,67 @@ function safeWidgetMapper(
node.widgets?.forEach((w) => w.triggerDraw?.())
}
const promoted = node.isSubgraphNode()
? resolvePromotedMetadata(node, widget)
const isPromotedPseudoWidget =
isPromotedWidgetView(widget) && widget.sourceWidgetName.startsWith('$$')
// Extract only render-critical options (canvasOnly, advanced, read_only)
const options = extractWidgetDisplayOptions(widget)
const subgraphId = node.isSubgraphNode() && node.subgraph.id
const resolvedSourceResult =
isPromotedWidgetView(widget) && promotedSource
? resolveConcretePromotedWidget(
node,
promotedSource.sourceNodeId,
promotedSource.sourceWidgetName
)
: null
const resolvedSource =
resolvedSourceResult?.status === 'resolved'
? resolvedSourceResult.resolved
: undefined
const sourceWidget = resolvedSource?.widget
const sourceNode = resolvedSource?.node
const effectiveWidget = sourceWidget ?? widget
const localId = isPromotedWidgetView(widget)
? String(sourceNode?.id ?? promotedSource?.sourceNodeId)
: undefined
const nodeId =
subgraphId && localId ? `${subgraphId}:${localId}` : undefined
const sourceWidgetName = isPromotedWidgetView(widget)
? (sourceWidget?.name ?? promotedSource?.sourceWidgetName)
: undefined
const name = sourceWidgetName ?? displayName
if (isPromotedWidgetView(widget)) widget.ensureHostWidgetState()
return {
widgetId: getWidgetIdForNode(node, widget, duplicateIndex),
name: widget.name,
type: widget.type,
...getSharedWidgetEnhancements(node, widget),
...(promoted?.controlWidget && {
controlWidget: promoted.controlWidget
}),
entityId: getWidgetEntityIdForNode(node, widget),
nodeId,
name,
type: effectiveWidget.type,
...sharedEnhancements,
callback,
hasLayoutSize: typeof widget.computeLayoutSize === 'function',
isDOMWidget: promoted?.isDOMWidget ?? isDOMWidget(widget),
options: extractWidgetDisplayOptions(widget),
hasLayoutSize: typeof effectiveWidget.computeLayoutSize === 'function',
isDOMWidget: isDOMWidget(widget) || isPromotedDOMWidget(widget),
options: isPromotedPseudoWidget
? {
...(extractWidgetDisplayOptions(effectiveWidget) ?? options),
canvasOnly: true
}
: (extractWidgetDisplayOptions(effectiveWidget) ?? options),
slotMetadata: slotInfo,
sourceExecutionId: promoted?.sourceExecutionId,
sourceWidgetName: promoted?.sourceWidgetName,
tooltip: widget.tooltip
// For promoted widgets, name is sourceWidgetName while widget.name
// is the subgraph input slot name — store the slot name for lookups.
slotName: name !== widget.name ? widget.name : undefined,
sourceExecutionId:
sourceNode && app.rootGraph
? (getExecutionIdByNode(app.rootGraph, sourceNode) ?? undefined)
: undefined,
tooltip: widget.tooltip,
promotedLabel: isPromotedWidgetView(widget) ? widget.label : undefined
}
} catch (error) {
console.warn(
@@ -327,24 +370,6 @@ function safeWidgetMapper(
}
}
function ensurePromotedHostWidgetState(
id: WidgetId,
input: INodeInputSlot,
sourceWidget: IBaseWidget | undefined
): void {
if (!sourceWidget) return
const store = useWidgetValueStore()
if (store.getWidget(id)) return
store.registerWidget(id, {
type: sourceWidget.type,
value: sourceWidget.value,
options: cloneDeep(sourceWidget.options ?? {}),
label: input.label ?? input.name,
serialize: sourceWidget.serialize,
disabled: sourceWidget.disabled
})
}
function buildSlotMetadata(
inputs: INodeInputSlot[] | undefined,
graphRef: LGraph | null | undefined
@@ -446,16 +471,14 @@ export function extractVueNodeData(node: LGraphNode): VueNodeData {
})
const safeWidgets = reactiveComputed<SafeWidgetData[]>(() => {
const widgetsSnapshot = node.widgets ?? []
const freshMetadata = buildSlotMetadata(node.inputs, node.graph)
slotMetadata.clear()
for (const [key, value] of freshMetadata) {
slotMetadata.set(key, value)
}
const widgets = node.isSubgraphNode()
? promotedInputWidgets(node)
: (node.widgets ?? [])
return widgets.map(safeWidgetMapper(node, slotMetadata))
return widgetsSnapshot.map(safeWidgetMapper(node, slotMetadata))
})
const nodeType =
@@ -469,7 +492,7 @@ export function extractVueNodeData(node: LGraphNode): VueNodeData {
const badges = node.badges
return {
id: node.id,
id: String(node.id),
title: typeof node.title === 'string' ? node.title : '',
type: nodeType,
mode: node.mode || 0,
@@ -511,7 +534,7 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
// Update only widgets with new slot metadata, keeping other widget data intact
for (const widget of currentData.widgets ?? []) {
widget.slotMetadata = slotMetadata.get(widget.name)
widget.slotMetadata = slotMetadata.get(widget.slotName ?? widget.name)
}
}
@@ -553,7 +576,7 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
node: LGraphNode,
originalCallback?: (node: LGraphNode) => void
) => {
const id = node.id
const id = String(node.id)
// Store non-reactive reference to original node
nodeRefs.set(id, node)
@@ -615,7 +638,7 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
node: LGraphNode,
originalCallback?: (node: LGraphNode) => void
) => {
const id = node.id
const id = String(node.id)
// Remove node from layout store
setSource(LayoutSource.Canvas)
@@ -789,7 +812,7 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
if (slotLabelEvent.slotType !== NodeSlotType.INPUT && nodeRef.outputs) {
nodeRef.outputs = [...nodeRef.outputs]
}
// Re-extract widget data so the label reflects the rename
// Re-extract widget data so promotedLabel reflects the rename
vueNodeData.set(nodeId, extractVueNodeData(nodeRef))
}
}

View File

@@ -1,6 +1,7 @@
import { computed, ref } from 'vue'
import type { Ref } from 'vue'
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
import type {
LGraphGroup,
LGraphNode,
@@ -264,8 +265,16 @@ export function useMoreOptionsMenu() {
options.push(...getImageMenuOptions(selectedNodes.value[0]))
options.push({ type: 'divider' })
}
const [widgetName] = hoveredWidget.value ?? []
const widget = node?.widgets?.find((w) => w.name === widgetName)
const [widgetName, nodeId] = hoveredWidget.value ?? []
const widget =
nodeId !== undefined
? node?.widgets?.find(
(w) =>
isPromotedWidgetView(w) &&
w.sourceWidgetName === widgetName &&
w.sourceNodeId === nodeId
)
: node?.widgets?.find((w) => w.name === widgetName)
if (widget) {
const widgetOptions = convertContextMenuToOptions(
getExtraOptionsForWidget(node, widget)

View File

@@ -6,11 +6,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useNodeMenuOptions } from '@/composables/graph/useNodeMenuOptions'
import type { Positionable } from '@/lib/litegraph/src/litegraph'
import {
LGraphEventMode,
LGraphNode,
asNodeId
} from '@/lib/litegraph/src/litegraph'
import { LGraphEventMode, LGraphNode } from '@/lib/litegraph/src/litegraph'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
// canvasStore transitively imports the app singleton; stub it so the real
@@ -49,7 +45,7 @@ const i18n = createI18n({
const nodeWithMode = (mode: LGraphEventMode, id = 1): LGraphNode => {
const node = new LGraphNode('Test')
node.id = asNodeId(id)
node.id = id
node.mode = mode
return node
}

View File

@@ -4,7 +4,6 @@ import { shallowRef, watch } from 'vue'
import { useGraphNodeManager } from '@/composables/graph/useGraphNodeManager'
import type { GraphNodeManager } from '@/composables/graph/useGraphNodeManager'
import { useVueFeatureFlags } from '@/composables/useVueFeatureFlags'
import { asNodeId } from '@/lib/litegraph/src/utils/nodeId'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMutations'
@@ -48,9 +47,9 @@ function useVueNodeLifecycleIndividual() {
for (const link of activeGraph._links.values()) {
layoutMutations.createLink(
link.id,
asNodeId(link.origin_id),
link.origin_id,
link.origin_slot,
asNodeId(link.target_id),
link.target_id,
link.target_slot
)
}

View File

@@ -1,21 +1,11 @@
import { describe, expect, vi } from 'vitest'
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
import { subgraphTest } from '@/lib/litegraph/src/subgraph/__fixtures__/subgraphFixtures'
import { usePriceBadge } from '@/composables/node/usePriceBadge'
const getNodeDisplayPrice = vi.fn(
(_node: LGraphNode, overrides?: ReadonlyMap<string, unknown>) =>
String(overrides?.get('prompt') ?? 'missing override')
)
vi.mock('@/composables/node/useNodePricing', () => ({
useNodePricing: () => ({ getNodeDisplayPrice })
}))
vi.mock('@/stores/workspace/colorPaletteStore', () => ({
useColorPaletteStore: () => ({
completedActivePalette: {
@@ -64,43 +54,4 @@ describe('subgraph pricing', () => {
expect(getBadgeText(subgraphNode)).toBe('Partner Nodes x 5')
}
)
subgraphTest(
'uses promoted widget override from any matching internal link',
({ subgraphWithNode }) => {
const { subgraphNode, subgraph } = subgraphWithNode
class ApiNode extends LGraphNode {
static override nodeData = { name: 'ApiNode', api_node: true }
}
const apiNode = new ApiNode('api node')
apiNode.badges = [getCreditsBadge('$0.05/Run')]
const apiInput = apiNode.addInput('prompt', 'STRING')
apiInput.widget = { name: 'prompt' }
apiNode.addWidget('string', 'prompt', 'inner value', () => undefined, {})
const decoyNode = new LGraphNode('decoy node')
const decoyInput = decoyNode.addInput('prompt', 'STRING')
decoyInput.widget = { name: 'prompt' }
decoyNode.addWidget(
'string',
'prompt',
'decoy value',
() => undefined,
{}
)
subgraph.add(decoyNode)
subgraph.add(apiNode)
subgraph.inputNode.slots[0].connect(decoyInput, decoyNode)
subgraph.inputNode.slots[0].connect(apiInput, apiNode)
subgraphNode._internalConfigureAfterSlots()
const inputWidgetId = subgraphNode.inputs[0].widgetId
if (!inputWidgetId) throw new Error('Missing promoted input widgetId')
useWidgetValueStore().setValue(inputWidgetId, 'outer value')
updateSubgraphCredits(subgraphNode)
expect(getBadgeText(subgraphNode)).toBe('outer value')
}
)
})

View File

@@ -2,14 +2,9 @@ import type { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
import { LGraphBadge } from '@/lib/litegraph/src/litegraph'
import { useNodePricing } from '@/composables/node/useNodePricing'
import type { INodeInputSlot } from '@/lib/litegraph/src/interfaces'
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
import { adjustColor } from '@/utils/colorUtil'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
type LinkedWidgetInput = INodeInputSlot & {
_subgraphSlot?: { linkIds?: number[] }
}
const componentIconSvg = new Image()
componentIconSvg.src =
@@ -100,20 +95,11 @@ export const usePriceBadge = () => {
): ReadonlyMap<string, unknown> {
const overrides = new Map<string, unknown>()
if (!wrapper.isSubgraphNode()) return overrides
for (const input of wrapper.inputs as LinkedWidgetInput[]) {
if (!input.widgetId) continue
for (const linkId of input._subgraphSlot?.linkIds ?? []) {
const link = wrapper.subgraph.getLink(linkId)
if (link?.target_id !== innerNode.id) continue
const targetInput = innerNode.inputs[link.target_slot]
const widgetName = targetInput?.widget?.name
if (!widgetName) continue
overrides.set(
widgetName,
useWidgetValueStore().getWidget(input.widgetId)?.value
)
}
const innerId = String(innerNode.id)
for (const w of wrapper.widgets ?? []) {
if (!isPromotedWidgetView(w)) continue
if (w.sourceNodeId !== innerId) continue
overrides.set(w.sourceWidgetName, w.value)
}
return overrides
}

View File

@@ -3,7 +3,7 @@ import { setActivePinia } from 'pinia'
import { reactive } from 'vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { LGraphNode, asNodeId } from '@/lib/litegraph/src/litegraph'
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
import {
createTestSubgraph,
createTestSubgraphNode
@@ -58,7 +58,7 @@ function addInteriorNode(
} = { id: 10 }
): LGraphNode {
const node = new LGraphNode('test')
node.id = asNodeId(options.id)
node.id = options.id
if (options.previewMediaType) {
node.previewMediaType = options.previewMediaType
}
@@ -69,7 +69,7 @@ function addInteriorNode(
function seedOutputs(subgraphId: string, nodeIds: Array<number | string>) {
const store = useNodeOutputStore()
for (const nodeId of nodeIds) {
const locatorId = createNodeLocatorId(subgraphId, asNodeId(nodeId))
const locatorId = createNodeLocatorId(subgraphId, nodeId)
store.nodeOutputs[locatorId] = {
images: [{ filename: 'output.png' }]
}
@@ -82,7 +82,7 @@ function seedPreviewImages(
) {
const store = useNodeOutputStore()
for (const { nodeId, urls } of entries) {
const locatorId = createNodeLocatorId(subgraphId, asNodeId(nodeId))
const locatorId = createNodeLocatorId(subgraphId, nodeId)
store.nodePreviewImages[locatorId] = urls
}
}
@@ -281,9 +281,7 @@ describe(usePromotedPreviews, () => {
})
const outerSetup = createSetup()
const innerHost = createTestSubgraphNode(innerSetup.subgraph, {
id: asNodeId(20)
})
const innerHost = createTestSubgraphNode(innerSetup.subgraph, { id: 20 })
outerSetup.subgraph.add(innerHost)
const store = usePreviewExposureStore()
@@ -331,16 +329,10 @@ describe(usePromotedPreviews, () => {
})
const outerSetup = createSetup()
const innerHost = createTestSubgraphNode(innerSetup.subgraph, {
id: asNodeId(20)
})
const innerHost = createTestSubgraphNode(innerSetup.subgraph, { id: 20 })
outerSetup.subgraph.add(innerHost)
const firstHost = createTestSubgraphNode(outerSetup.subgraph, {
id: asNodeId(11)
})
const secondHost = createTestSubgraphNode(outerSetup.subgraph, {
id: asNodeId(12)
})
const firstHost = createTestSubgraphNode(outerSetup.subgraph, { id: 11 })
const secondHost = createTestSubgraphNode(outerSetup.subgraph, { id: 12 })
const firstHostLocator = String(firstHost.id)
const secondHostLocator = String(secondHost.id)
const firstNestedLocator = `${firstHostLocator}:${innerHost.id}`

View File

@@ -1,7 +1,6 @@
import type { MaybeRefOrGetter } from 'vue'
import { computed, toValue } from 'vue'
import { asNodeId } from '@/lib/litegraph/src/utils/nodeId'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
import type { UUID } from '@/utils/uuid'
@@ -44,7 +43,7 @@ export function usePromotedPreviews(
): string[] | undefined {
const locatorId = createNodeLocatorId(
leafHost.subgraph.id,
asNodeId(leafSourceNodeId)
leafSourceNodeId
)
const reactiveOutputs = nodeOutputStore.nodeOutputs[locatorId]
const reactivePreviews = nodeOutputStore.nodePreviewImages[locatorId]

View File

@@ -3,7 +3,6 @@ import { beforeEach, describe, expect, it } from 'vitest'
import { useResultGallery } from '@/composables/queue/useResultGallery'
import type { JobListItem as JobListViewItem } from '@/composables/queue/useJobList'
import { asNodeId } from '@/lib/litegraph/src/litegraph'
import type { JobListItem } from '@/platform/remote/comfyui/jobs/jobTypes'
import { ResultItemImpl, TaskItemImpl } from '@/stores/queueStore'
@@ -15,7 +14,7 @@ const createResultItem = (
filename: url,
subfolder: '',
type: 'output',
nodeId: asNodeId('node-1'),
nodeId: 'node-1',
mediaType: supportsPreview ? 'images' : 'unknown'
})
// Override url getter for test matching

View File

@@ -9,7 +9,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import WidgetImageCrop from '@/components/imagecrop/WidgetImageCrop.vue'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { asNodeId } from '@/lib/litegraph/src/litegraph'
import type { NodeId } from '@/platform/workflow/validation/schemas/workflowSchema'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
import {
@@ -84,7 +83,7 @@ const ImageCropHarness = defineComponent({
modelValue,
imageEl,
containerEl,
...useImageCrop(asNodeId(props.nodeId), {
...useImageCrop(props.nodeId as NodeId, {
imageEl,
containerEl,
modelValue
@@ -184,7 +183,7 @@ function setupImageLayout(vm: CropVm, nw: number, nh: number) {
const harnessCleanups: Array<() => void> = []
async function mountHarness(nodeId: NodeId = asNodeId(2)) {
async function mountHarness(nodeId: NodeId = 2 as NodeId) {
const el = document.createElement('div')
document.body.appendChild(el)
const app = createApp(ImageCropHarness, { nodeId: Number(nodeId) })
@@ -658,7 +657,7 @@ describe('WidgetImageCrop', () => {
container: attach,
props: {
widget,
nodeId: asNodeId(2),
nodeId: 2 as NodeId,
modelValue: { x: 0, y: 0, width: 100, height: 100 }
},
global: {
@@ -690,7 +689,7 @@ describe('WidgetImageCrop', () => {
container: attach,
props: {
widget,
nodeId: asNodeId(2),
nodeId: 2 as NodeId,
modelValue: { x: 0, y: 0, width: 200, height: 200 }
},
global: {
@@ -734,7 +733,7 @@ describe('WidgetImageCrop', () => {
container: attach,
props: {
widget,
nodeId: asNodeId(2),
nodeId: 2 as NodeId,
modelValue: { x: 0, y: 0, width: 200, height: 200 }
},
global: {
@@ -780,7 +779,7 @@ describe('WidgetImageCrop', () => {
container: attach,
props: {
widget,
nodeId: asNodeId(2),
nodeId: 2 as NodeId,
modelValue: { x: 0, y: 0, width: 100, height: 100 }
},
global: {

View File

@@ -1,19 +1,12 @@
import { describe, expect, it } from 'vitest'
import type { WidgetState } from '@/stores/widgetValueStore'
import type { NodeId } from '@/platform/workflow/validation/schemas/workflowSchema'
import type { WidgetState } from '@/types/widgetState'
import { boundsExtractor, singleValueExtractor } from './useUpstreamValue'
function widget(name: string, value: unknown): WidgetState {
return {
name,
type: 'INPUT',
value,
nodeId: '1' as NodeId,
options: {},
y: 0
}
return { name, type: 'INPUT', value, nodeId: '1' as NodeId, options: {} }
}
const isNumber = (v: unknown): v is number => typeof v === 'number'

View File

@@ -1,11 +1,10 @@
import { computed } from 'vue'
import { asNodeId } from '@/lib/litegraph/src/utils/nodeId'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
import type { WidgetState } from '@/stores/widgetValueStore'
import type { Bounds } from '@/renderer/core/layout/types'
import type { LinkedUpstreamInfo } from '@/types/simplifiedWidget'
import type { WidgetState } from '@/types/widgetState'
type ValueExtractor<T = unknown> = (
widgets: WidgetState[],
@@ -24,10 +23,7 @@ export function useUpstreamValue<T>(
if (!upstream) return undefined
const graphId = canvasStore.canvas?.graph?.rootGraph.id
if (!graphId) return undefined
const widgets = widgetValueStore.getNodeWidgets(
graphId,
asNodeId(upstream.nodeId)
)
const widgets = widgetValueStore.getNodeWidgets(graphId, upstream.nodeId)
return extractValue(widgets, upstream.outputName)
})
}

View File

@@ -1,4 +1,5 @@
import { createTestingPinia } from '@pinia/testing'
import { fromPartial } from '@total-typescript/shoehorn'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
@@ -20,8 +21,8 @@ import {
normalizeLegacyProxyWidgetEntry,
readHostQuarantine
} from '@/core/graph/subgraph/migration/proxyWidgetMigration'
import type { PromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
import { usePreviewExposureStore } from '@/stores/previewExposureStore'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
useCanvasStore: () => ({})
@@ -54,15 +55,39 @@ function addInnerNode(
return node
}
function getPromotedInputValue(
function addPromotedHostInput(
host: SubgraphNode,
name: string
): TWidgetValue | undefined {
const input = host.inputs.find((input) => input.name === name)
if (!input?.widgetId) return undefined
return useWidgetValueStore().getWidget(input.widgetId)?.value as
| TWidgetValue
| undefined
args: {
inputName: string
promotedName: string
sourceNodeId: string
sourceWidgetName: string
initialValue?: TWidgetValue
}
): { setValue: (v: TWidgetValue) => void; getValue: () => TWidgetValue } {
let widgetValue: TWidgetValue = args.initialValue ?? 0
const slot = host.addInput(args.inputName, '*')
slot._widget = fromPartial<PromotedWidgetView>({
node: host,
name: args.promotedName,
sourceNodeId: args.sourceNodeId,
sourceWidgetName: args.sourceWidgetName,
get value() {
return widgetValue
},
set value(v: TWidgetValue) {
widgetValue = v
},
hydrateHostValue(v: TWidgetValue) {
widgetValue = v
}
})
return {
setValue: (v) => {
widgetValue = v
},
getValue: () => widgetValue
}
}
function addPrimitiveWithTargets(
@@ -116,6 +141,29 @@ describe('flushProxyWidgetMigration', () => {
})
describe('value-widget repair', () => {
it('alreadyLinked: applies host value to the matching promoted widget', () => {
const host = buildHost()
const inner = addInnerNode(host, 'Inner', (n) => {
n.addWidget('number', 'seed', 0, () => {})
})
const handle = addPromotedHostInput(host, {
inputName: 'seed_link',
promotedName: 'seed',
sourceNodeId: String(inner.id),
sourceWidgetName: 'seed',
initialValue: 0
})
host.properties.proxyWidgets = [[String(inner.id), 'seed']]
flushProxyWidgetMigration({
hostNode: host,
hostWidgetValues: [99]
})
expect(handle.getValue()).toBe(99)
expect(host.properties.proxyWidgets).toBeUndefined()
})
it('alreadyLinked: hydrates real promoted widget without mutating the interior widget', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'seed', type: 'INT' }]
@@ -135,61 +183,23 @@ describe('flushProxyWidgetMigration', () => {
hostWidgetValues: [99]
})
expect(getPromotedInputValue(host, 'seed')).toBe(99)
expect(host.widgets[0].value).toBe(99)
const innerWidget = inner.widgets!.find((w) => w.name === 'seed')!
expect(innerWidget.value).toBe(0)
})
it('createSubgraphInput: uses disambiguator for duplicate nested widget names', () => {
const rootGraph = new LGraph()
const innerSubgraph = createTestSubgraph({ rootGraph })
const firstText = new LGraphNode('CLIPTextEncode')
const firstSlot = firstText.addInput('text', 'STRING')
firstSlot.widget = { name: 'text' }
firstText.addWidget('text', 'text', '11111111111', () => {})
innerSubgraph.add(firstText)
const secondText = new LGraphNode('CLIPTextEncode')
const secondSlot = secondText.addInput('text', 'STRING')
secondSlot.widget = { name: 'text' }
secondText.addWidget('text', 'text', '22222222222', () => {})
innerSubgraph.add(secondText)
const nestedHost = createTestSubgraphNode(innerSubgraph, {
parentGraph: rootGraph
})
nestedHost.properties.proxyWidgets = [
[String(firstText.id), 'text'],
[String(secondText.id), 'text']
]
flushProxyWidgetMigration({ hostNode: nestedHost })
const outerSubgraph = createTestSubgraph({ rootGraph })
outerSubgraph.add(nestedHost)
const outerHost = createTestSubgraphNode(outerSubgraph, {
parentGraph: rootGraph
})
outerHost.properties.proxyWidgets = [
[String(nestedHost.id), 'text', String(secondText.id)]
]
flushProxyWidgetMigration({ hostNode: outerHost })
expect(getPromotedInputValue(outerHost, 'text')).toBe('22222222222')
})
it('alreadyLinked: leaves widget value unchanged when host value is a sparse hole', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'seed', type: 'INT' }]
})
const host = createTestSubgraphNode(subgraph)
host.graph!.add(host)
const host = buildHost()
const inner = addInnerNode(host, 'Inner', (n) => {
const slot = n.addInput('seed', 'INT')
const innerWidget = n.addWidget('number', 'seed', 7, () => {})
slot.widget = { name: innerWidget.name }
n.addWidget('number', 'seed', 0, () => {})
})
const handle = addPromotedHostInput(host, {
inputName: 'seed_link',
promotedName: 'seed',
sourceNodeId: String(inner.id),
sourceWidgetName: 'seed',
initialValue: 7
})
subgraph.inputNode.slots[0].connect(inner.inputs[0], inner)
host.properties.proxyWidgets = [[String(inner.id), 'seed']]
const sparse: unknown[] = []
@@ -198,7 +208,43 @@ describe('flushProxyWidgetMigration', () => {
hostWidgetValues: sparse
})
expect(getPromotedInputValue(host, 'seed')).toBe(7)
expect(handle.getValue()).toBe(7)
})
it('alreadyLinked: ambiguous matching inputs quarantine without applying host value', () => {
const host = buildHost()
const inner = addInnerNode(host, 'Inner', (n) => {
n.addWidget('number', 'seed', 0, () => {})
})
const a = addPromotedHostInput(host, {
inputName: 'first_seed',
promotedName: 'seed',
sourceNodeId: String(inner.id),
sourceWidgetName: 'seed',
initialValue: 1
})
const b = addPromotedHostInput(host, {
inputName: 'second_seed',
promotedName: 'seed',
sourceNodeId: String(inner.id),
sourceWidgetName: 'seed',
initialValue: 2
})
host.properties.proxyWidgets = [[String(inner.id), 'seed']]
flushProxyWidgetMigration({
hostNode: host,
hostWidgetValues: [99]
})
expect(a.getValue()).toBe(1)
expect(b.getValue()).toBe(2)
expect(readHostQuarantine(host)).toEqual([
expect.objectContaining({
originalEntry: [String(inner.id), 'seed'],
reason: 'ambiguousSubgraphInput'
})
])
})
it('createSubgraphInput: creates exactly one new SubgraphInput linked to the source widget', () => {
@@ -218,25 +264,29 @@ describe('flushProxyWidgetMigration', () => {
expect(created?._widget).toBeDefined()
})
it('createSubgraphInput: preserves the source slot label', () => {
it('createSubgraphInput: honors disambiguatingSourceNodeId when source widget name has been deduplicated', () => {
const host = buildHost()
const inner = addInnerNode(host, 'Inner', (n) => {
const slot = n.addInput('text', 'STRING')
slot.label = 'renamed_from_sidepanel'
slot.widget = { name: 'text' }
n.addWidget('text', 'text', '', () => {})
const inner = addInnerNode(host, 'InnerWithDedupedPromotion', (n) => {
const slot1 = n.addInput('text', 'STRING')
slot1.widget = { name: 'text' }
const w1 = n.addWidget('text', 'text', '11111111111', () => {})
Object.assign(w1, { sourceNodeId: '1', sourceWidgetName: 'text' })
const slot2 = n.addInput('text_1', 'STRING')
slot2.widget = { name: 'text_1' }
const w2 = n.addWidget('text', 'text_1', '22222222222', () => {})
Object.assign(w2, { sourceNodeId: '2', sourceWidgetName: 'text' })
})
host.properties.proxyWidgets = [[String(inner.id), 'text']]
host.properties.proxyWidgets = [[String(inner.id), 'text', '2']]
flushProxyWidgetMigration({ hostNode: host })
const promotedInput = host.inputs.find((input) => input.name === 'text')
expect(promotedInput?.label).toBe('renamed_from_sidepanel')
expect(
promotedInput?.widgetId
? useWidgetValueStore().getWidget(promotedInput.widgetId)?.label
: undefined
).toBe('renamed_from_sidepanel')
const created = host.subgraph.inputs.at(-1)
expect(created?._widget).toBeDefined()
const linkedSlot = inner.inputs.find(
(slot) => slot.link === created?.linkIds[0]
)
expect(linkedSlot?.name).toBe('text_1')
})
it('createSubgraphInput: quarantines missingSubgraphInput when source widget has no backing input slot', () => {
@@ -311,7 +361,8 @@ describe('flushProxyWidgetMigration', () => {
hostWidgetValues: [123]
})
expect(getPromotedInputValue(host, 'value')).toBe(123)
const hostInput = host.inputs.at(-1)
expect(hostInput?._widget?.value).toBe(123)
})
it('seeds value from the primitive widget when no host value is supplied', () => {
@@ -324,7 +375,8 @@ describe('flushProxyWidgetMigration', () => {
host.properties.proxyWidgets = [[String(primitive.id), 'value']]
flushProxyWidgetMigration({ hostNode: host })
expect(getPromotedInputValue(host, 'value')).toBe(11)
const hostInput = host.inputs.at(-1)
expect(hostInput?._widget?.value).toBe(11)
})
it('quarantines an unlinked primitive node with no fan-out', () => {
@@ -422,8 +474,10 @@ describe('flushProxyWidgetMigration', () => {
expect(hostA.properties.proxyWidgetErrorQuarantine).toBeUndefined()
expect(hostB.properties.proxyWidgetErrorQuarantine).toBeUndefined()
expect(getPromotedInputValue(hostA, 'value')).toBe(11)
expect(getPromotedInputValue(hostB, 'value')).toBe(22)
const widgetA = hostA.inputs.at(-1)?._widget
const widgetB = hostB.inputs.at(-1)?._widget
expect(widgetA?.value).toBe(11)
expect(widgetB?.value).toBe(22)
})
})

View File

@@ -1,6 +1,6 @@
import { isEqual } from 'es-toolkit/compat'
import { promotedInputWidget } from '@/core/graph/subgraph/promotedInputWidget'
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
import type { PromotedWidgetSource } from '@/core/graph/subgraph/promotedWidgetTypes'
import {
findHostInputForPromotion,
@@ -8,7 +8,6 @@ import {
isPreviewPseudoWidget
} from '@/core/graph/subgraph/promotionUtils'
import { resolveConcretePromotedWidget } from '@/core/graph/subgraph/resolveConcretePromotedWidget'
import { resolveSubgraphInputTarget } from '@/core/graph/subgraph/resolveSubgraphInputTarget'
import type { SerializedProxyWidgetTuple } from '@/core/schemas/promotionSchema'
import { parseProxyWidgets } from '@/core/schemas/promotionSchema'
import type {
@@ -17,7 +16,6 @@ import type {
} from '@/core/schemas/proxyWidgetQuarantineSchema'
import { parseProxyWidgetErrorQuarantine } from '@/core/schemas/proxyWidgetQuarantineSchema'
import type { INodeInputSlot } from '@/lib/litegraph/src/interfaces'
import { asNodeId } from '@/lib/litegraph/src/utils/nodeId'
import type { LGraphNode, NodeId } from '@/lib/litegraph/src/litegraph'
import { nextUniqueName } from '@/lib/litegraph/src/strings'
import type { Subgraph } from '@/lib/litegraph/src/subgraph/Subgraph'
@@ -29,7 +27,6 @@ import type {
} from '@/lib/litegraph/src/types/widgets'
import { isWidgetValue } from '@/lib/litegraph/src/types/widgets'
import { usePreviewExposureStore } from '@/stores/previewExposureStore'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
interface LegacyProxyEntrySource extends PromotedWidgetSource {
disambiguatingSourceNodeId?: string
@@ -96,24 +93,23 @@ function resolveSourceWidget(
sourceWidgetName: string,
disambiguatingSourceNodeId?: string
): IBaseWidget | undefined {
if (sourceNode.isSubgraphNode()) {
const input = sourceNode.inputs.find((input) => {
const target = resolveSubgraphInputTarget(sourceNode, input.name)
if (disambiguatingSourceNodeId) {
return (
target?.widgetName === sourceWidgetName &&
target.nodeId === disambiguatingSourceNodeId
)
}
if (input.name === sourceWidgetName) return true
return target?.widgetName === sourceWidgetName
})
// Store-backed projection for a promoted input on a nested subgraph node:
// getSlotFromWidget locates the backing slot by widgetId.
if (input?.widgetId) return promotedInputWidget(input) ?? undefined
const widgets = sourceNode.widgets
if (widgets && disambiguatingSourceNodeId !== undefined) {
const byDisambiguator = widgets.find(
(w) =>
isPromotedWidgetView(w) &&
w.sourceNodeId === disambiguatingSourceNodeId &&
w.sourceWidgetName === sourceWidgetName
)
if (byDisambiguator) return byDisambiguator
// Disambiguator missed: fall back only to non-promoted same-name widgets.
// A sibling PromotedWidgetView would re-introduce the cross-binding bug.
const byName = widgets.find(
(w) => !isPromotedWidgetView(w) && w.name === sourceWidgetName
)
if (byName) return byName
}
const widgets = sourceNode.widgets
return (
widgets?.find((w) => w.name === sourceWidgetName) ??
getPromotableWidgets(sourceNode).find((w) => w.name === sourceWidgetName)
@@ -263,7 +259,7 @@ function collectTargetsStrict(
const link = subgraph.links.get(linkId)
if (!link) return undefined
targets.push({
targetNodeId: asNodeId(link.target_id),
targetNodeId: link.target_id,
targetSlot: link.target_slot
})
}
@@ -279,12 +275,7 @@ function collectTargetsSkippingDangling(
return linkIds.flatMap((linkId) => {
const link = subgraph.links.get(linkId)
return link
? [
{
targetNodeId: asNodeId(link.target_id),
targetSlot: link.target_slot
}
]
? [{ targetNodeId: link.target_id, targetSlot: link.target_slot }]
: []
})
}
@@ -309,6 +300,19 @@ function classify(
normalized.sourceWidgetName
)
if (linkedInput) {
const ambiguous =
hostNode.inputs.filter((input) => {
const w = input._widget
return (
!!w &&
isPromotedWidgetView(w) &&
w.sourceNodeId === normalized.sourceNodeId &&
w.sourceWidgetName === normalized.sourceWidgetName
)
}).length > 1
if (ambiguous) {
return { kind: 'quarantine', reason: 'ambiguousSubgraphInput' }
}
return { kind: 'alreadyLinked', subgraphInputName: linkedInput.name }
}
@@ -369,23 +373,19 @@ function classify(
}
}
function applyHostValueToInput(
input: INodeInputSlot,
entry: PendingEntry
): boolean {
if (!input.widgetId || entry.isHole) return Boolean(input.widgetId)
return useWidgetValueStore().setValue(input.widgetId, entry.hostValue)
}
function applyHostLabelToInput(
input: INodeInputSlot,
label: string | undefined
): void {
if (label === undefined) return
input.label = label
if (!input.widgetId) return
const state = useWidgetValueStore().getWidget(input.widgetId)
if (state) state.label = label
function applyHostValue(widget: IBaseWidget, entry: PendingEntry): void {
if (entry.isHole) return
if (
isPromotedWidgetView(widget) &&
typeof widget.hydrateHostValue === 'function'
) {
widget.hydrateHostValue(entry.hostValue)
return
}
console.error(
'[proxyWidgetMigration] applyHostValue called with non-promoted widget; refusing to write to shared interior',
{ widgetName: widget.name, type: widget.type }
)
}
function addUniqueSubgraphInput(
@@ -422,9 +422,10 @@ function repairAlreadyLinked(
return { ok: false, reason: 'ambiguousSubgraphInput' }
}
const hostInput = matches[0]
if (!applyHostValueToInput(hostInput, entry)) {
if (!hostInput._widget) {
return { ok: false, reason: 'missingSubgraphInput' }
}
applyHostValue(hostInput._widget, entry)
return { ok: true, subgraphInputName: hostInput.name }
}
@@ -479,10 +480,11 @@ function repairCreateSubgraphInput(
const hostInput = hostNode.inputs.find(
(input) => input.name === newSubgraphInput.name
)
if (hostInput) {
applyHostLabelToInput(hostInput, slot.label)
applyHostValueToInput(hostInput, entry)
if (!hostInput?._widget) {
return { ok: true, subgraphInputName: newSubgraphInput.name }
}
applyHostValue(hostInput._widget, entry)
return { ok: true, subgraphInputName: newSubgraphInput.name }
}
@@ -609,7 +611,7 @@ function repairPrimitive(
.filter((l): l is NonNullable<typeof l> => l !== undefined)
.map((l) => ({
primitiveSlot: l.origin_slot,
targetNodeId: asNodeId(l.target_id),
targetNodeId: l.target_id,
targetSlot: l.target_slot
}))
@@ -647,19 +649,22 @@ function repairPrimitive(
return failPrimitive('mutation failed; rolled back', { error: e })
}
// Apply through the host's input mirror (PromotedWidgetView), NOT
// `newSubgraphInput._widget`: the interior is shared across hosts.
const hostInput = hostNode.inputs.find(
(input) => input.name === newSubgraphInput.name
)
if (hostInput) {
const hostInputWidget = hostInput?._widget
if (hostInputWidget) {
const valueEntry = validated.uniqueEntries.find((e) => !e.isHole)
if (valueEntry) {
applyHostValueToInput(hostInput, valueEntry)
applyHostValue(hostInputWidget, valueEntry)
} else {
const primitiveValue = primitiveNode.widgets?.find(
(w) => w.name === validated.sourceWidgetName
)?.value as TWidgetValue | undefined
if (primitiveValue !== undefined) {
applyHostValueToInput(hostInput, {
applyHostValue(hostInputWidget, {
...validated.uniqueEntries[0],
hostValue: primitiveValue,
isHole: false

View File

@@ -1,111 +0,0 @@
import type { INodeInputSlot } from '@/lib/litegraph/src/interfaces'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import { isWidgetValue } from '@/lib/litegraph/src/types/widgets'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
import { resolveSubgraphInputTarget } from './resolveSubgraphInputTarget'
/**
* Where a promoted subgraph input is sourced from inside the subgraph. The
* interior node id + widget name that the host input slot forwards to. Resolved
* by walking the live link, so it is authoritative derived data — never stored
* on the projected widget.
*/
export interface PromotedSource {
nodeId: string
widgetName: string
}
/**
* The interior source of a host input slot, or undefined when the slot is not a
* promoted widget input.
*/
export function promotedInputSource(
node: LGraphNode,
input: INodeInputSlot
): PromotedSource | undefined {
if (!input.widgetId) return undefined
return resolveSubgraphInputTarget(node, input.name)
}
/** The host input slot backing a projected widget, matched by widgetId. */
export function inputForWidget(
node: LGraphNode,
widget: IBaseWidget
): INodeInputSlot | undefined {
return node.getSlotFromWidget(widget)
}
/**
* The interior source of a widget when it is a promoted subgraph input.
* Replaces ad-hoc "is this promoted?" duck-typing: a widget is promoted iff its
* host node is a subgraph node and its backing input slot has an interior
* source.
*/
export function widgetPromotedSource(
node: LGraphNode,
widget: IBaseWidget
): PromotedSource | undefined {
if (!node.isSubgraphNode()) return undefined
const input = inputForWidget(node, widget)
if (!input) return undefined
return promotedInputSource(node, input)
}
/**
* Projects a promoted subgraph input into an ordinary widget descriptor. The
* descriptor is store-backed: type/value/options read live from
* {@link useWidgetValueStore} by widgetId (mirroring BaseWidget), so the row
* list does not reactively rebuild — and re-key — on every value edit.
*
* `name` is the input slot name (unique + fixed; widgetId derives from it), and
* `label` is the mutable display label. Returns null when the input is not a
* promoted widget input.
*/
export function promotedInputWidget(input: INodeInputSlot): IBaseWidget | null {
const id = input.widgetId
if (!id) return null
const store = useWidgetValueStore()
return {
get name() {
return store.getWidget(id)?.name ?? input.name
},
get label() {
return store.getWidget(id)?.label ?? input.label ?? input.name
},
set label(next) {
const state = store.getWidget(id)
if (state) state.label = next
},
get y() {
return store.getWidget(id)?.y ?? 0
},
set y(next) {
const state = store.getWidget(id)
if (state) state.y = next
},
widgetId: id,
get type() {
return store.getWidget(id)?.type ?? 'text'
},
get options() {
return store.getWidget(id)?.options ?? {}
},
get value() {
const value = store.getWidget(id)?.value
return isWidgetValue(value) ? value : undefined
},
set value(next) {
store.setValue(id, next)
}
}
}
/** Every promoted subgraph input on a node, projected to ordinary widgets. */
export function promotedInputWidgets(node: LGraphNode): IBaseWidget[] {
return node.inputs.flatMap((input) => {
const widget = promotedInputWidget(input)
return widget ? [widget] : []
})
}

View File

@@ -1,17 +1,31 @@
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import type { WidgetEntityId } from '@/world/entityIds'
export interface ResolvedPromotedWidget {
node: LGraphNode
widget: IBaseWidget
}
/**
* A persisted promotion's source identity: the interior node + widget a host
* subgraph input was promoted from. Used by the migration/schema layer, where
* the source is a stored tuple rather than something link-derivable.
*/
export interface PromotedWidgetSource {
sourceNodeId: string
sourceWidgetName: string
}
export interface PromotedWidgetView extends IBaseWidget {
readonly node: SubgraphNode
readonly entityId: WidgetEntityId
readonly sourceNodeId: string
readonly sourceWidgetName: string
hydrateHostValue(value: IBaseWidget['value']): void
ensureHostWidgetState(): void
}
export function isPromotedWidgetView(
widget: IBaseWidget
): widget is PromotedWidgetView {
return 'sourceNodeId' in widget && 'sourceWidgetName' in widget
}

View File

@@ -0,0 +1,100 @@
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
import {
createTestSubgraph,
createTestSubgraphNode,
resetSubgraphFixtureState
} from '@/lib/litegraph/src/subgraph/__fixtures__/subgraphHelpers'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
import { isPromotedWidgetView } from './promotedWidgetTypes'
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
useCanvasStore: () => ({})
}))
vi.mock('@/services/litegraphService', () => ({
useLitegraphService: () => ({ updatePreviews: () => ({}) })
}))
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
resetSubgraphFixtureState()
})
function createNumericInteriorNode(initialValue: number) {
const node = new LGraphNode('Interior')
const input = node.addInput('value', 'number')
node.addOutput('out', 'number')
const widget = node.addWidget('number', 'widget', initialValue, () => {}, {
min: 0,
max: 100,
step: 1
})
input.widget = { name: widget.name }
return { node, widget }
}
describe('PromotedWidgetView — host-wins semantics', () => {
it('does not leak host-side writes into the interior widget or into a sibling host', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'value', type: 'number' }]
})
const { node: interior, widget: interiorWidget } =
createNumericInteriorNode(42)
subgraph.add(interior)
subgraph.inputNode.slots[0].connect(interior.inputs[0], interior)
const hostA = createTestSubgraphNode(subgraph, { id: 100 })
const hostB = createTestSubgraphNode(subgraph, { id: 101 })
const viewA = hostA.widgets.find(isPromotedWidgetView)
const viewB = hostB.widgets.find(isPromotedWidgetView)
if (!viewA || !viewB)
throw new Error('Expected promoted views on both hosts')
viewA.value = 7
expect(viewA.value).toBe(7)
expect(interiorWidget.value).toBe(42)
expect(viewB.value).toBe(42)
})
it('keeps the interior widgetValueStore row untouched when a host writes', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'value', type: 'number' }]
})
const { node: interior } = createNumericInteriorNode(42)
subgraph.add(interior)
subgraph.inputNode.slots[0].connect(interior.inputs[0], interior)
const widgetStore = useWidgetValueStore()
widgetStore.registerWidget(subgraph.rootGraph.id, {
nodeId: String(interior.id),
name: 'widget',
type: 'number',
value: 42,
options: {},
label: undefined,
serialize: true,
disabled: false
})
const host = createTestSubgraphNode(subgraph, { id: 200 })
const view = host.widgets.find(isPromotedWidgetView)
if (!view) throw new Error('Expected promoted view on host')
view.value = 99
const interiorState = widgetStore.getWidget(
subgraph.rootGraph.id,
String(interior.id),
'widget'
)
expect(interiorState?.value).toBe(42)
})
})

View File

@@ -0,0 +1,614 @@
import type { LGraphNode, NodeId } from '@/lib/litegraph/src/LGraphNode'
import type { LGraphCanvas } from '@/lib/litegraph/src/LGraphCanvas'
import type { CanvasPointer } from '@/lib/litegraph/src/CanvasPointer'
import type { Point } from '@/lib/litegraph/src/interfaces'
import type { CanvasPointerEvent } from '@/lib/litegraph/src/types/events'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import { isWidgetValue } from '@/lib/litegraph/src/types/widgets'
import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
import type { BaseWidget } from '@/lib/litegraph/src/widgets/BaseWidget'
import { toConcreteWidget } from '@/lib/litegraph/src/widgets/widgetMap'
import { t } from '@/i18n'
import { nextValueForLinkedTarget } from '@/scripts/valueControl'
import { useDomWidgetStore } from '@/stores/domWidgetStore'
import {
stripGraphPrefix,
useWidgetValueStore
} from '@/stores/widgetValueStore'
import type { WidgetState } from '@/stores/widgetValueStore'
import {
resolveConcretePromotedWidget,
resolvePromotedWidgetAtHost
} from '@/core/graph/subgraph/resolveConcretePromotedWidget'
import { matchPromotedInput } from '@/core/graph/subgraph/matchPromotedInput'
import { hasWidgetNode } from '@/core/graph/subgraph/widgetNodeTypeGuard'
import type { WidgetEntityId } from '@/world/entityIds'
import { widgetEntityId } from '@/world/entityIds'
import { ensureWidgetState, getWidgetState } from '@/world/widgetValueIO'
import { isPromotedWidgetView } from './promotedWidgetTypes'
import type { PromotedWidgetView as IPromotedWidgetView } from './promotedWidgetTypes'
export type { PromotedWidgetView } from './promotedWidgetTypes'
export { isPromotedWidgetView } from './promotedWidgetTypes'
interface SubgraphSlotRef {
name: string
label?: string
displayName?: string
}
type LegacyMouseWidget = IBaseWidget & {
mouse: (e: CanvasPointerEvent, pos: Point, node: LGraphNode) => unknown
}
function hasLegacyMouse(widget: IBaseWidget): widget is LegacyMouseWidget {
return 'mouse' in widget && typeof widget.mouse === 'function'
}
const designTokenCache = new Map<string, string>()
export function createPromotedWidgetView(
subgraphNode: SubgraphNode,
nodeId: string,
widgetName: string,
displayName?: string,
identityName?: string
): IPromotedWidgetView {
return new PromotedWidgetView(
subgraphNode,
nodeId,
widgetName,
displayName,
identityName
)
}
class PromotedWidgetView implements IPromotedWidgetView {
[symbol: symbol]: boolean
readonly sourceNodeId: string
readonly sourceWidgetName: string
readonly serialize = false
last_y?: number
computedHeight?: number
private readonly graphId: string
private yValue = 0
private _computedDisabled = false
private projectedSourceNode?: LGraphNode
private projectedSourceWidget?: IBaseWidget
private projectedSourceWidgetType?: IBaseWidget['type']
private projectedWidget?: BaseWidget
private cachedDeepestByFrame?: { node: LGraphNode; widget: IBaseWidget }
private cachedDeepestFrame = -1
private _boundSlot?: SubgraphSlotRef
private _boundSlotVersion = -1
private _lastAutoSeededValue?: IBaseWidget['value']
constructor(
private readonly subgraphNode: SubgraphNode,
nodeId: string,
widgetName: string,
private readonly displayName?: string,
private readonly identityName?: string
) {
this.sourceNodeId = nodeId
this.sourceWidgetName = widgetName
this.graphId = subgraphNode.rootGraph.id
}
get node(): SubgraphNode {
return this.subgraphNode
}
get name(): string {
return this.identityName ?? this.sourceWidgetName
}
get entityId(): WidgetEntityId {
return widgetEntityId(this.graphId, this.subgraphNode.id, this.name)
}
get y(): number {
return this.yValue
}
set y(value: number) {
this.yValue = value
this.syncDomOverride()
}
get computedDisabled(): boolean {
return this._computedDisabled
}
set computedDisabled(value: boolean | undefined) {
this._computedDisabled = value ?? false
}
get type(): IBaseWidget['type'] {
return this.resolveDeepest()?.widget.type ?? 'button'
}
get options(): IBaseWidget['options'] {
return this.resolveDeepest()?.widget.options ?? {}
}
get tooltip(): string | undefined {
return this.resolveDeepest()?.widget.tooltip
}
get linkedWidgets(): IBaseWidget[] | undefined {
return this.resolveDeepest()?.widget.linkedWidgets
}
get value(): IBaseWidget['value'] {
const hostState = this.getHostWidgetState()
if (hostState && isWidgetValue(hostState.value)) return hostState.value
const state = this.getWidgetState()
if (state && isWidgetValue(state.value)) return state.value
return this.resolveAtHost()?.widget.value
}
set value(value: IBaseWidget['value']) {
this.setHostWidgetState(value)
}
private getHostWidgetState(): WidgetState | undefined {
return getWidgetState(this.entityId)
}
private setHostWidgetState(value: IBaseWidget['value']): void {
if (!isWidgetValue(value)) return
const state = this.getHostWidgetState()
if (state) {
state.value = value
this._lastAutoSeededValue = undefined
return
}
this.registerHostWidgetState(value)
this._lastAutoSeededValue = undefined
}
ensureHostWidgetState(): void {
const fallback = this.fallbackEffectiveValue()
const existing = this.getHostWidgetState()
if (existing) {
if (
this._lastAutoSeededValue !== undefined &&
existing.value === this._lastAutoSeededValue &&
isWidgetValue(fallback) &&
fallback !== existing.value
) {
existing.value = fallback
this._lastAutoSeededValue = fallback
}
return
}
this.registerHostWidgetState(fallback)
this._lastAutoSeededValue = fallback
}
private fallbackEffectiveValue(): IBaseWidget['value'] {
const state = this.getWidgetState()
if (state && isWidgetValue(state.value)) return state.value
return this.resolveAtHost()?.widget.value
}
private registerHostWidgetState(value: IBaseWidget['value']): void {
const resolved = this.resolveDeepest()
ensureWidgetState(this.entityId, {
type: resolved?.widget.type ?? 'button',
value,
options: { ...(resolved?.widget.options ?? {}) },
label: this.displayName,
serialize: this.serialize,
disabled: this.computedDisabled
})
}
get label(): string | undefined {
const slot = this.getBoundSubgraphSlot()
if (slot) return slot.label ?? slot.displayName ?? slot.name
const state = this.getWidgetState()
return state?.label ?? this.displayName
}
set label(value: string | undefined) {
const slot = this.getBoundSubgraphSlot()
if (slot) slot.label = value || undefined
const state = this.getWidgetState()
if (state) state.label = value
}
hydrateHostValue(value: IBaseWidget['value']): void {
this.setHostWidgetState(value)
}
private getBoundSubgraphSlot(): SubgraphSlotRef | undefined {
const version = this.subgraphNode.inputs?.length ?? 0
if (this._boundSlotVersion === version) return this._boundSlot
this._boundSlot = this.findBoundSubgraphSlot()
this._boundSlotVersion = version
return this._boundSlot
}
private findBoundSubgraphSlot(): SubgraphSlotRef | undefined {
for (const input of this.subgraphNode.inputs ?? []) {
const slot = input._subgraphSlot as SubgraphSlotRef | undefined
if (!slot) continue
const w = input._widget
if (
w &&
isPromotedWidgetView(w) &&
w.sourceNodeId === this.sourceNodeId &&
w.sourceWidgetName === this.sourceWidgetName
) {
return slot
}
}
return undefined
}
get hidden(): boolean {
return this.resolveDeepest()?.widget.hidden ?? false
}
get computeLayoutSize(): IBaseWidget['computeLayoutSize'] {
const resolved = this.resolveDeepest()
const computeLayoutSize = resolved?.widget.computeLayoutSize
if (!computeLayoutSize) return undefined
return (node: LGraphNode) => computeLayoutSize.call(resolved.widget, node)
}
get computeSize(): IBaseWidget['computeSize'] {
const resolved = this.resolveDeepest()
const computeSize = resolved?.widget.computeSize
if (!computeSize) return undefined
return (width?: number) => computeSize.call(resolved.widget, width)
}
draw(
ctx: CanvasRenderingContext2D,
_node: LGraphNode,
widgetWidth: number,
y: number,
H: number,
lowQuality?: boolean
): void {
const resolved = this.resolveDeepest()
if (!resolved) {
drawDisconnectedPlaceholder(ctx, widgetWidth, y, H)
return
}
if (isBaseDOMWidget(resolved.widget)) return this.syncDomOverride(resolved)
const projected = this.getProjectedWidget(resolved)
if (!projected || typeof projected.drawWidget !== 'function') return
const originalY = projected.y
const originalComputedHeight = projected.computedHeight
const originalComputedDisabled = projected.computedDisabled
const originalLabel = projected.label
projected.y = this.y
projected.computedHeight = this.computedHeight
projected.computedDisabled = this.computedDisabled
projected.value = this.value
projected.label = this.label
try {
projected.drawWidget(ctx, {
width: widgetWidth,
showText: !lowQuality,
previewImages: resolved.node.imgs
})
} finally {
projected.y = originalY
projected.computedHeight = originalComputedHeight
projected.computedDisabled = originalComputedDisabled
projected.label = originalLabel
}
}
onPointerDown(
pointer: CanvasPointer,
_node: LGraphNode,
canvas: LGraphCanvas
): boolean {
const resolved = this.resolveAtHost()
if (!resolved) return false
const interior = resolved.widget
if (typeof interior.onPointerDown === 'function') {
const handled = interior.onPointerDown(pointer, this.subgraphNode, canvas)
if (handled) return true
}
const concrete = toConcreteWidget(interior, this.subgraphNode, false)
if (concrete)
return this.bindConcretePointerHandlers(pointer, canvas, concrete)
if (hasLegacyMouse(interior))
return this.handleLegacyMouse(pointer, interior)
return false
}
callback(
value: unknown,
canvas?: LGraphCanvas,
node?: LGraphNode,
pos?: Point,
e?: CanvasPointerEvent
) {
this.resolveAtHost()?.widget.callback?.(value, canvas, node, pos, e)
}
afterQueued({
isPartialExecution
}: { isPartialExecution?: boolean } = {}): void {
this.applyValueControlToHost(isPartialExecution)
}
private applyValueControlToHost(isPartialExecution?: boolean): void {
if (this.subgraphNode.getSlotFromWidget(this)?.link != null) return
const resolved = this.resolveAtHost()
const next = nextValueForLinkedTarget({
target: this,
linkedWidgets: resolved?.widget.linkedWidgets,
nodeId: this.subgraphNode.id,
isPartialExecution
})
if (next === undefined) return
this.hydrateHostValue(next)
}
private resolveAtHost():
| { node: LGraphNode; widget: IBaseWidget }
| undefined {
return resolvePromotedWidgetAtHost(
this.subgraphNode,
this.sourceNodeId,
this.sourceWidgetName
)
}
private resolveDeepest():
| { node: LGraphNode; widget: IBaseWidget }
| undefined {
const frame = this.subgraphNode.rootGraph.primaryCanvas?.frame
if (frame !== undefined && this.cachedDeepestFrame === frame)
return this.cachedDeepestByFrame
const result = resolveConcretePromotedWidget(
this.subgraphNode,
this.sourceNodeId,
this.sourceWidgetName
)
const resolved = result.status === 'resolved' ? result.resolved : undefined
if (frame !== undefined) {
this.cachedDeepestFrame = frame
this.cachedDeepestByFrame = resolved
}
return resolved
}
private getWidgetState() {
const linkedState = this.getLinkedInputWidgetStates()[0]
if (linkedState) return linkedState
const resolved = this.resolveDeepest()
if (!resolved) return undefined
return useWidgetValueStore().getWidget(
this.graphId,
stripGraphPrefix(String(resolved.node.id)),
resolved.widget.name
)
}
private getLinkedInputWidgets(): Array<{
nodeId: NodeId
widgetName: string
widget: IBaseWidget
}> {
const linkedInputSlot = this.subgraphNode.inputs.find((input) => {
if (!input._subgraphSlot) return false
if (matchPromotedInput([input], this) !== input) return false
const boundWidget = input._widget
if (boundWidget === this) return true
if (boundWidget && isPromotedWidgetView(boundWidget)) {
return (
boundWidget.sourceNodeId === this.sourceNodeId &&
boundWidget.sourceWidgetName === this.sourceWidgetName
)
}
return input._subgraphSlot
.getConnectedWidgets()
.filter(hasWidgetNode)
.some(
(widget) =>
String(widget.node.id) === this.sourceNodeId &&
widget.name === this.sourceWidgetName
)
})
const linkedInput = linkedInputSlot?._subgraphSlot
if (!linkedInput) return []
return linkedInput
.getConnectedWidgets()
.filter(hasWidgetNode)
.map((widget) => ({
nodeId: stripGraphPrefix(String(widget.node.id)),
widgetName: widget.name,
widget
}))
}
private getLinkedInputWidgetStates(): WidgetState[] {
const widgetStore = useWidgetValueStore()
return this.getLinkedInputWidgets()
.map(({ nodeId, widgetName }) =>
widgetStore.getWidget(this.graphId, nodeId, widgetName)
)
.filter((state): state is WidgetState => state !== undefined)
}
private getProjectedWidget(resolved: {
node: LGraphNode
widget: IBaseWidget
}): BaseWidget | undefined {
const shouldRebuild =
!this.projectedWidget ||
this.projectedSourceNode !== resolved.node ||
this.projectedSourceWidget !== resolved.widget ||
this.projectedSourceWidgetType !== resolved.widget.type
if (!shouldRebuild) return this.projectedWidget
const concrete = toConcreteWidget(resolved.widget, resolved.node, false)
if (!concrete) {
this.projectedWidget = undefined
this.projectedSourceNode = undefined
this.projectedSourceWidget = undefined
this.projectedSourceWidgetType = undefined
return undefined
}
this.projectedWidget = concrete.createCopyForNode(this.subgraphNode)
this.projectedSourceNode = resolved.node
this.projectedSourceWidget = resolved.widget
this.projectedSourceWidgetType = resolved.widget.type
return this.projectedWidget
}
private bindConcretePointerHandlers(
pointer: CanvasPointer,
canvas: LGraphCanvas,
concrete: BaseWidget
): boolean {
const downEvent = pointer.eDown
if (!downEvent) return false
pointer.onClick = () =>
concrete.onClick({
e: downEvent,
node: this.subgraphNode,
canvas
})
pointer.onDrag = (eMove) =>
concrete.onDrag?.({
e: eMove,
node: this.subgraphNode,
canvas
})
return true
}
private handleLegacyMouse(
pointer: CanvasPointer,
interior: LegacyMouseWidget
): boolean {
const downEvent = pointer.eDown
if (!downEvent) return false
const downPosition: Point = [
downEvent.canvasX - this.subgraphNode.pos[0],
downEvent.canvasY - this.subgraphNode.pos[1]
]
interior.mouse(downEvent, downPosition, this.subgraphNode)
pointer.finally = () => {
const upEvent = pointer.eUp
if (!upEvent) return
const upPosition: Point = [
upEvent.canvasX - this.subgraphNode.pos[0],
upEvent.canvasY - this.subgraphNode.pos[1]
]
interior.mouse(upEvent, upPosition, this.subgraphNode)
}
return true
}
private syncDomOverride(
resolved:
| { node: LGraphNode; widget: IBaseWidget }
| undefined = this.resolveAtHost()
) {
if (!resolved || !isBaseDOMWidget(resolved.widget)) return
useDomWidgetStore().setPositionOverride(resolved.widget.id, {
node: this.subgraphNode,
widget: this
})
}
}
function isBaseDOMWidget(
widget: IBaseWidget
): widget is IBaseWidget & { id: string } {
return 'id' in widget && ('element' in widget || 'component' in widget)
}
function drawDisconnectedPlaceholder(
ctx: CanvasRenderingContext2D,
width: number,
y: number,
H: number
) {
const backgroundColor = readDesignToken(
'--color-secondary-background',
'#333'
)
const textColor = readDesignToken('--color-text-secondary', '#999')
const fontSize = readDesignToken('--text-2xs', '11px')
const fontFamily = readDesignToken('--font-inter', 'sans-serif')
ctx.save()
ctx.fillStyle = backgroundColor
ctx.fillRect(15, y, width - 30, H)
ctx.fillStyle = textColor
ctx.font = `${fontSize} ${fontFamily}`
ctx.textAlign = 'center'
ctx.textBaseline = 'middle'
ctx.fillText(t('subgraphStore.disconnected'), width / 2, y + H / 2)
ctx.restore()
}
function readDesignToken(token: string, fallback: string): string {
if (typeof document === 'undefined') return fallback
const cachedValue = designTokenCache.get(token)
if (cachedValue) return cachedValue
const value = getComputedStyle(document.documentElement)
.getPropertyValue(token)
.trim()
const resolvedValue = value || fallback
designTokenCache.set(token, resolvedValue)
return resolvedValue
}

View File

@@ -3,46 +3,22 @@ import { fromPartial } from '@total-typescript/shoehorn'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { promotedInputWidget } from '@/core/graph/subgraph/promotedInputWidget'
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
import {
createTestSubgraph,
createTestSubgraphNode
} from '@/lib/litegraph/src/subgraph/__fixtures__/subgraphHelpers'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
import { usePreviewExposureStore } from '@/stores/previewExposureStore'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
import type { WidgetId } from '@/types/widgetId'
function promotedInputNames(host: {
inputs: Array<{ widgetId?: unknown; name: string }>
}) {
return host.inputs
.filter((input) => input.widgetId)
.map((input) => input.name)
function widgetSourceNodeId(w: IBaseWidget): string | undefined {
return isPromotedWidgetView(w) ? w.sourceNodeId : undefined
}
function promotedHostWidgetNames(host: { widgets?: IBaseWidget[] }) {
return host.widgets?.map((widget) => widget.name) ?? []
}
function writePromotedInputValue(
host: { inputs: Array<{ widgetId?: WidgetId; name: string }> },
name: string,
value: IBaseWidget['value']
) {
const input = host.inputs.find((input) => input.name === name)
if (!input?.widgetId) throw new Error(`Missing promoted input ${name}`)
useWidgetValueStore().setValue(input.widgetId, value)
}
function promotedWidgetRef(host: SubgraphNode, name: string): IBaseWidget {
const input = host.inputs.find((input) => input.name === name)
if (!input?.widgetId) throw new Error(`Missing promoted input ${name}`)
const widget = promotedInputWidget(input)
if (!widget) throw new Error(`Missing promoted input ${name}`)
return widget
type TestPromotedWidget = IBaseWidget & {
sourceNodeId: string
sourceWidgetName: string
}
const updatePreviewsMock = vi.hoisted(() => vi.fn())
@@ -55,9 +31,11 @@ import {
autoExposeKnownPreviewNodes,
demoteWidget,
getPromotableWidgets,
getWidgetName,
hasUnpromotedWidgets,
isLinkedPromotion,
isPreviewPseudoWidget,
isWidgetPromotedOnSubgraphNode,
promoteValueWidgetViaSubgraphInput,
promoteRecommendedWidgets,
pruneDisconnected,
@@ -190,18 +168,15 @@ describe('pruneDisconnected', () => {
promoteValueWidgetViaSubgraphInput(subgraphNode, interiorNode, keptWidget)
const missingWidgetInput = subgraph.addInput('missing-widget', 'STRING')
missingWidgetInput._widget = fromPartial<TestPromotedWidget>({
sourceNodeId: String(interiorNode.id),
sourceWidgetName: 'missing-widget'
})
const missingNodeInput = subgraph.addInput('missing-node', 'STRING')
const keptWidgetId = subgraphNode.inputs.find(
(input) => input.name === 'kept'
)?.widgetId
if (!keptWidgetId) throw new Error('Missing kept widgetId')
for (const input of [missingWidgetInput, missingNodeInput]) {
const hostInput = subgraphNode.inputs.find(
(entry) => entry._subgraphSlot === input
)
if (!hostInput) throw new Error(`Missing host input ${input.name}`)
hostInput.widgetId = keptWidgetId
}
missingNodeInput._widget = fromPartial<TestPromotedWidget>({
sourceNodeId: '9999',
sourceWidgetName: 'missing-node'
})
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
@@ -326,25 +301,6 @@ describe('promoteRecommendedWidgets', () => {
expect(subgraphNode.serialize().properties?.proxyWidgets).toBeUndefined()
})
it('preserves the source slot label when promoting a value widget', () => {
const subgraph = createTestSubgraph()
const subgraphNode = createTestSubgraphNode(subgraph)
const interiorNode = new LGraphNode('Prompt')
const input = interiorNode.addInput('text', 'STRING')
input.label = 'renamed_from_sidepanel'
const textWidget = interiorNode.addWidget('text', 'text', '', () => {})
input.widget = { name: textWidget.name }
subgraph.add(interiorNode)
promoteValueWidgetViaSubgraphInput(subgraphNode, interiorNode, textWidget)
const hostInput = subgraphNode.inputs.find((input) => input.name === 'text')
expect(hostInput?.label).toBe('renamed_from_sidepanel')
expect(promotedWidgetRef(subgraphNode, 'text').label).toBe(
'renamed_from_sidepanel'
)
})
it('promotes virtual previews through preview exposures', () => {
const subgraph = createTestSubgraph()
const subgraphNode = createTestSubgraphNode(subgraph)
@@ -529,45 +485,79 @@ describe('isLinkedPromotion', () => {
setActivePinia(createTestingPinia({ stubActions: false }))
})
function promoteSource(host: SubgraphNode, widgetName: string): LGraphNode {
const node = new LGraphNode('Source')
const input = node.addInput(widgetName, 'STRING')
const widget = node.addWidget('text', widgetName, '', () => {})
input.widget = { name: widget.name }
host.subgraph.add(node)
promoteValueWidgetViaSubgraphInput(host, node, widget)
return node
function linkedWidget(
sourceNodeId: string,
sourceWidgetName: string,
extra: Record<string, unknown> = {}
): IBaseWidget {
return {
sourceNodeId,
sourceWidgetName,
name: 'value',
type: 'text',
value: '',
options: {},
y: 0,
...extra
} as unknown as IBaseWidget
}
it('returns true for a linked promotion', () => {
const host = createTestSubgraphNode(createTestSubgraph())
const node = promoteSource(host, 'text')
function createSubgraphWithInputs(count = 1) {
const subgraph = createTestSubgraph({
inputs: Array.from({ length: count }, (_, i) => ({
name: `input_${i}`,
type: 'STRING' as const
}))
})
return createTestSubgraphNode(subgraph)
}
expect(isLinkedPromotion(host, String(node.id), 'text')).toBe(true)
it('returns true when an input has a matching _widget', () => {
const subgraphNode = createSubgraphWithInputs()
subgraphNode.inputs[0]._widget = linkedWidget('3', 'text')
expect(isLinkedPromotion(subgraphNode, '3', 'text')).toBe(true)
})
it('returns false when no promotion exists', () => {
const host = createTestSubgraphNode(createTestSubgraph())
it('returns false when no inputs exist or none match', () => {
const subgraph = createTestSubgraph()
const subgraphNode = createTestSubgraphNode(subgraph)
expect(isLinkedPromotion(host, '999', 'nonexistent')).toBe(false)
expect(isLinkedPromotion(subgraphNode, '999', 'nonexistent')).toBe(false)
})
it('returns false when sourceWidgetName does not match', () => {
const host = createTestSubgraphNode(createTestSubgraph())
const node = promoteSource(host, 'text')
it('returns false when sourceNodeId matches but sourceWidgetName does not', () => {
const subgraphNode = createSubgraphWithInputs()
subgraphNode.inputs[0]._widget = linkedWidget('3', 'text')
expect(isLinkedPromotion(host, String(node.id), 'wrong_name')).toBe(false)
expect(isLinkedPromotion(subgraphNode, '3', 'wrong_name')).toBe(false)
})
it('identifies linked widgets across different inputs', () => {
const host = createTestSubgraphNode(createTestSubgraph())
const nodeA = promoteSource(host, 'string_a')
const nodeB = promoteSource(host, 'value')
it('returns false when _widget is undefined on input', () => {
const subgraphNode = createSubgraphWithInputs()
expect(isLinkedPromotion(host, String(nodeA.id), 'string_a')).toBe(true)
expect(isLinkedPromotion(host, String(nodeB.id), 'value')).toBe(true)
expect(isLinkedPromotion(host, String(nodeA.id), 'value')).toBe(false)
expect(isLinkedPromotion(host, '5', 'string_a')).toBe(false)
expect(isLinkedPromotion(subgraphNode, '3', 'text')).toBe(false)
})
it('matches by sourceNodeId even when disambiguatingSourceNodeId is present', () => {
const subgraphNode = createSubgraphWithInputs()
subgraphNode.inputs[0]._widget = linkedWidget('6', 'text', {
disambiguatingSourceNodeId: '1'
})
expect(isLinkedPromotion(subgraphNode, '6', 'text')).toBe(true)
expect(isLinkedPromotion(subgraphNode, '1', 'text')).toBe(false)
})
it('identifies multiple linked widgets across different inputs', () => {
const subgraphNode = createSubgraphWithInputs(2)
subgraphNode.inputs[0]._widget = linkedWidget('3', 'string_a')
subgraphNode.inputs[1]._widget = linkedWidget('4', 'value')
expect(isLinkedPromotion(subgraphNode, '3', 'string_a')).toBe(true)
expect(isLinkedPromotion(subgraphNode, '4', 'value')).toBe(true)
expect(isLinkedPromotion(subgraphNode, '3', 'value')).toBe(false)
expect(isLinkedPromotion(subgraphNode, '5', 'string_a')).toBe(false)
})
})
@@ -617,13 +607,17 @@ describe('reorderSubgraphInputsByName', () => {
promoteValueWidgetViaSubgraphInput(host, firstNode, firstWidget)
promoteValueWidgetViaSubgraphInput(host, secondNode, secondWidget)
expect(promotedInputNames(host)).toEqual(['first', 'second'])
expect(promotedHostWidgetNames(host)).toEqual(['first', 'second'])
expect(host.widgets.map((widget) => widget.name)).toEqual([
'first',
'second'
])
reorderSubgraphInputsByName(host, ['second', 'first'])
expect(promotedInputNames(host)).toEqual(['second', 'first'])
expect(promotedHostWidgetNames(host)).toEqual(['second', 'first'])
expect(host.widgets.map((widget) => widget.name)).toEqual([
'second',
'first'
])
})
it('keeps promoted widget values aligned when a plain input is reordered before them', () => {
@@ -643,13 +637,15 @@ describe('reorderSubgraphInputsByName', () => {
promoteValueWidgetViaSubgraphInput(host, firstNode, firstWidget)
subgraph.addInput('plain', 'STRING')
promoteValueWidgetViaSubgraphInput(host, secondNode, secondWidget)
writePromotedInputValue(host, 'first', 'first value')
writePromotedInputValue(host, 'second', 'second value')
host.widgets[0].value = 'first value'
host.widgets[1].value = 'second value'
reorderSubgraphInputsByName(host, ['plain', 'second', 'first'])
expect(promotedInputNames(host)).toEqual(['second', 'first'])
expect(promotedHostWidgetNames(host)).toEqual(['second', 'first'])
expect(host.widgets.map((widget) => widget.name)).toEqual([
'second',
'first'
])
expect(host.serialize().widgets_values).toEqual([
'second value',
'first value'
@@ -731,21 +727,15 @@ describe('reorderSubgraphInputsByWidgetOrder', () => {
secondInput.widget = { name: secondWidget.name }
promoteValueWidgetViaSubgraphInput(host, firstNode, firstWidget)
promoteValueWidgetViaSubgraphInput(host, secondNode, secondWidget)
writePromotedInputValue(host, 'text', 'first value')
writePromotedInputValue(host, 'text_1', 'second value')
host.widgets[0].value = 'first value'
host.widgets[1].value = 'second value'
const firstPromotedWidget = promotedWidgetRef(host, 'text')
const secondPromotedWidget = promotedWidgetRef(host, 'text_1')
reorderSubgraphInputsByWidgetOrder(host, [
secondPromotedWidget,
firstPromotedWidget
])
reorderSubgraphInputsByWidgetOrder(host, [host.widgets[1], host.widgets[0]])
expect(host.subgraph.inputs.map((input) => input.name)).toEqual([
'text_1',
'text'
expect(host.widgets.map((widget) => widgetSourceNodeId(widget))).toEqual([
String(secondNode.id),
String(firstNode.id)
])
expect(promotedHostWidgetNames(host)).toEqual(['text_1', 'text'])
expect(host.serialize().widgets_values).toEqual([
'second value',
'first value'
@@ -785,10 +775,10 @@ describe('demoteWidget — axiomatic projection retraction', () => {
const { host, interiorNode, interiorWidget } = setupPromotedWidget()
const hostInput = host.inputs[0]
hostInput.link = 9999
const promotedInputId = hostInput.widgetId
const promotedViewsBefore = host.widgets.length
expect(host.subgraph.inputs).toHaveLength(1)
expect(promotedInputId).toBeDefined()
expect(promotedViewsBefore).toBeGreaterThan(0)
demoteWidget(interiorNode, interiorWidget, [host])
@@ -798,9 +788,13 @@ describe('demoteWidget — axiomatic projection retraction', () => {
expect(
isLinkedPromotion(host, String(interiorNode.id), interiorWidget.name)
).toBe(false)
expect(host.widgets).toHaveLength(0)
if (!promotedInputId) throw new Error('Missing promoted input widgetId')
expect(useWidgetValueStore().getWidget(promotedInputId)).toBeUndefined()
expect(
host.widgets.some(
(widget) =>
widgetSourceNodeId(widget) === String(interiorNode.id) &&
widget.name === interiorWidget.name
)
).toBe(false)
})
it('removes the slot entirely when host slot has no external link', () => {
@@ -818,7 +812,12 @@ describe('demoteWidget — axiomatic projection retraction', () => {
const { host, nodeA, widgetA, nodeB, widgetB } =
buildDuplicateNamePromotion()
demoteWidget(nodeB, widgetB, [host])
const promotedViewForB = host.widgets.find(
(w) => isPromotedWidgetView(w) && w.sourceNodeId === String(nodeB.id)
)
expect(promotedViewForB!.name).toBe('text_1')
demoteWidget(nodeB, promotedViewForB!, [host])
expect(host.subgraph.inputs.map((i) => i.name)).toEqual(['text'])
expect(isLinkedPromotion(host, String(nodeB.id), widgetB.name)).toBe(false)
@@ -826,19 +825,15 @@ describe('demoteWidget — axiomatic projection retraction', () => {
})
it('demotes the correct slot when widget lives on a nested SubgraphNode with same-named deep sources', () => {
const { host: innerHost } = buildDuplicateNamePromotion()
const { host: innerHost, nodeB } = buildDuplicateNamePromotion()
const outerSubgraph = createTestSubgraph()
const outerHost = createTestSubgraphNode(outerSubgraph)
outerSubgraph.add(innerHost)
for (const input of innerHost.inputs) {
for (const w of [...innerHost.widgets]) {
expect(
promoteValueWidgetViaSubgraphInput(
outerHost,
innerHost,
promotedWidgetRef(innerHost, input.name)
).ok
promoteValueWidgetViaSubgraphInput(outerHost, innerHost, w).ok
).toBe(true)
}
expect(outerHost.subgraph.inputs.map((i) => i.name)).toEqual([
@@ -846,7 +841,12 @@ describe('demoteWidget — axiomatic projection retraction', () => {
'text_1'
])
demoteWidget(innerHost, promotedWidgetRef(innerHost, 'text_1'), [outerHost])
const innerViewForB = innerHost.widgets.find(
(w) => isPromotedWidgetView(w) && w.sourceNodeId === String(nodeB.id)
)
expect(innerViewForB!.name).toBe('text_1')
demoteWidget(innerHost, innerViewForB!, [outerHost])
expect(outerHost.subgraph.inputs.map((i) => i.name)).toEqual(['text'])
expect(isLinkedPromotion(outerHost, String(innerHost.id), 'text_1')).toBe(
@@ -863,19 +863,66 @@ describe('disambiguated nested promotion identity', () => {
setActivePinia(createTestingPinia({ stubActions: false }))
})
it('does not prune a promotion whose source is a nested SubgraphNode exposing a disambiguated widget', () => {
const { host: innerHost } = buildDuplicateNamePromotion()
const subgraph = createTestSubgraph()
const host = createTestSubgraphNode(subgraph)
subgraph.add(innerHost)
function linkedView(
sourceNodeId: string,
sourceWidgetName: string,
overrides: Record<string, unknown> = {}
): IBaseWidget {
return {
sourceNodeId,
sourceWidgetName,
name: sourceWidgetName,
type: 'text',
value: '',
options: {},
y: 0,
...overrides
} as unknown as IBaseWidget
}
expect(
promoteValueWidgetViaSubgraphInput(
host,
innerHost,
promotedWidgetRef(innerHost, 'text_1')
).ok
).toBe(true)
function createSubgraphHost() {
const subgraph = createTestSubgraph({
inputs: [{ name: 'text_1', type: 'STRING' }]
})
return createTestSubgraphNode(subgraph)
}
it('identifies a promoted nested view by its immediate slot name, not its deep source widget name', () => {
const host = createSubgraphHost()
host.inputs[0]._widget = linkedView('inner', 'text_1')
const interiorWidget = linkedView('inner', 'text', { name: 'text_1' })
const interiorNode = {
id: 'inner',
title: 'inner',
type: 'inner'
} as unknown as LGraphNode
const source = {
sourceNodeId: String(interiorNode.id),
sourceWidgetName: getWidgetName(interiorWidget)
}
expect(isWidgetPromotedOnSubgraphNode(host, source, interiorWidget)).toBe(
true
)
})
it('does not prune a promotion whose source is a nested SubgraphNode exposing a disambiguated widget', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'text_1', type: 'STRING' }]
})
const host = createTestSubgraphNode(subgraph)
const nestedSubgraphNode = {
id: 'inner',
title: 'inner',
type: 'inner',
widgets: [linkedView('deep', 'text', { name: 'text_1' })]
} as unknown as LGraphNode
subgraph.add(nestedSubgraphNode)
host.inputs[0]._widget = linkedView('inner', 'text_1')
pruneDisconnected(host)
@@ -909,13 +956,9 @@ describe('disambiguated nested promotion identity', () => {
const outerHost = createTestSubgraphNode(outerSubgraph)
outerSubgraph.add(innerHost)
for (const input of innerHost.inputs) {
for (const w of [...innerHost.widgets]) {
expect(
promoteValueWidgetViaSubgraphInput(
outerHost,
innerHost,
promotedWidgetRef(innerHost, input.name)
).ok
promoteValueWidgetViaSubgraphInput(outerHost, innerHost, w).ok
).toBe(true)
}

View File

@@ -1,6 +1,6 @@
import cloneDeep from 'es-toolkit/compat/cloneDeep'
import * as Sentry from '@sentry/vue'
import type { PromotedWidgetSource } from '@/core/graph/subgraph/promotedWidgetTypes'
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
import { t } from '@/i18n'
import type { IContextMenuValue } from '@/lib/litegraph/src/litegraph'
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
@@ -18,9 +18,7 @@ import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useLitegraphService } from '@/services/litegraphService'
import { usePreviewExposureStore } from '@/stores/previewExposureStore'
import { useSubgraphNavigationStore } from '@/stores/subgraphNavigationStore'
import type { WidgetId } from '@/types/widgetId'
import { widgetId } from '@/types/widgetId'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
import { readWidgetValue } from '@/world/widgetValueIO'
type PartialNode = Pick<LGraphNode, 'title' | 'id' | 'type'>
@@ -48,47 +46,16 @@ export function findHostInputForPromotion(
sourceWidgetName: string
) {
return subgraphNode.inputs.find((input) => {
const source = input._subgraphSlot
? resolvePromotionSource(subgraphNode, input._subgraphSlot)
: undefined
const w = input._widget
return (
source?.sourceNodeId === sourceNodeId &&
source.sourceWidgetName === sourceWidgetName
w &&
isPromotedWidgetView(w) &&
w.sourceNodeId === sourceNodeId &&
w.sourceWidgetName === sourceWidgetName
)
})
}
function resolvePromotionSource(
subgraphNode: SubgraphNode,
subgraphInput: { linkIds: readonly number[] }
): PromotedWidgetSource | undefined {
for (const linkId of subgraphInput.linkIds) {
const link = subgraphNode.subgraph.getLink(linkId)
if (!link) continue
const { inputNode } = link.resolve(subgraphNode.subgraph)
if (!inputNode || !Array.isArray(inputNode.inputs)) continue
const targetInput = inputNode.inputs.find((entry) => entry.link === linkId)
if (!targetInput) continue
if (inputNode.isSubgraphNode()) {
return {
sourceNodeId: String(inputNode.id),
sourceWidgetName: targetInput.name
}
}
const targetWidget = inputNode.getWidgetFromSlot(targetInput)
if (!targetWidget) continue
return {
sourceNodeId: String(inputNode.id),
sourceWidgetName: targetWidget.name
}
}
}
export function reorderSubgraphInputsByName(
subgraphNode: SubgraphNode,
orderedInputNames: readonly string[]
@@ -111,12 +78,13 @@ export function reorderSubgraphInputsByName(
export function reorderSubgraphInputsByWidgetOrder(
subgraphNode: SubgraphNode,
orderedWidgets: readonly Pick<IBaseWidget, 'widgetId'>[]
orderedWidgets: readonly IBaseWidget[]
): void {
const remainingIndices = new Set(subgraphNode.inputs.keys())
const orderedIndices = orderedWidgets.flatMap((orderedWidget) => {
for (const index of remainingIndices) {
if (isSamePromotedInput(subgraphNode, index, orderedWidget)) {
const widget = subgraphNode.inputs[index]?._widget
if (widget && isSamePromotedWidget(widget, orderedWidget)) {
remainingIndices.delete(index)
return [index]
}
@@ -133,48 +101,37 @@ function applySubgraphInputOrder(
subgraphNode: SubgraphNode,
orderedIndices: readonly number[]
): void {
const widgetValues = subgraphNode.inputs.map((input) => {
const id = input?.widgetId
if (!id) return undefined
const value = useWidgetValueStore().getWidget(id)?.value
return isWidgetValue(value) ? value : undefined
})
const widgetValues = subgraphNode.inputs.map((input) =>
getExplicitHostWidgetValue(input?._widget)
)
reorderSubgraphInputs(subgraphNode, orderedIndices)
for (const [newIndex, oldIndex] of orderedIndices.entries()) {
const value = widgetValues[oldIndex]
const id = subgraphNode.inputs[newIndex]?.widgetId
if (value === undefined || !id) continue
useWidgetValueStore().setValue(id, value)
if (value === undefined) continue
const widget = subgraphNode.inputs[newIndex]?._widget
if (widget) widget.value = value
}
}
function isSamePromotedInput(
subgraphNode: SubgraphNode,
inputIndex: number,
orderedWidget: Pick<IBaseWidget, 'widgetId'>
): boolean {
const input = subgraphNode.inputs[inputIndex]
const linkedInput = input?._subgraphSlot
if (!input || !linkedInput) return false
function getExplicitHostWidgetValue(
widget: IBaseWidget | undefined
): IBaseWidget['value'] | undefined {
if (!widget) return undefined
if (!isPromotedWidgetView(widget)) return widget.value
for (const linkId of linkedInput.linkIds) {
const link = subgraphNode.subgraph.getLink(linkId)
if (!link) continue
const value = readWidgetValue(widget.entityId)
return isWidgetValue(value) ? value : undefined
}
const { inputNode, input: targetInput } = link.resolve(
subgraphNode.subgraph
)
if (!inputNode || !targetInput) continue
const targetWidget = inputNode.getWidgetFromSlot(targetInput)
if (targetWidget === orderedWidget) return true
if (input.widgetId && input.widgetId === orderedWidget.widgetId) return true
}
return false
function isSamePromotedWidget(left: IBaseWidget, right: IBaseWidget): boolean {
return (
isPromotedWidgetView(left) &&
isPromotedWidgetView(right) &&
left.sourceNodeId === right.sourceNodeId &&
left.sourceWidgetName === right.sourceWidgetName
)
}
function isPreviewExposed(
@@ -211,9 +168,13 @@ function toPromotionSource(
node: PartialNode,
widget: IBaseWidget
): PromotedWidgetSource {
const widgetIsParentLevelView =
isPromotedWidgetView(widget) && widget.sourceNodeId === String(node.id)
return {
sourceNodeId: String(node.id),
sourceWidgetName: getWidgetName(widget)
sourceWidgetName: widgetIsParentLevelView
? widget.sourceWidgetName
: getWidgetName(widget)
}
}
@@ -250,53 +211,15 @@ export function promoteValueWidgetViaSubgraphInput(
inputName,
String(sourceSlot.type ?? sourceWidget.type ?? '*')
)
subgraphInput.label = sourceSlot.label
const link = subgraphInput.connect(sourceSlot, sourceNode)
if (!link) {
subgraphNode.subgraph.removeInput(subgraphInput)
return { ok: false, reason: 'connectFailed' }
}
const hostInput = subgraphNode.inputs.find(
(input) => input._subgraphSlot === subgraphInput
)
if (hostInput) hostInput.label = sourceSlot.label
seedNestedPromotedInputState(subgraphNode, subgraphInput.name, sourceSlot)
return { ok: true }
}
function seedNestedPromotedInputState(
subgraphNode: SubgraphNode,
inputName: string,
sourceSlot: { widgetId?: WidgetId; label?: string }
): void {
if (!sourceSlot.widgetId) return
const hostInput = subgraphNode.inputs.find(
(input) => input._subgraphSlot?.name === inputName
)
if (!hostInput || hostInput.widgetId) return
const sourceState = useWidgetValueStore().getWidget(sourceSlot.widgetId)
if (!sourceState) return
const id = widgetId(subgraphNode.rootGraph.id, subgraphNode.id, inputName)
hostInput.widget ??= { name: inputName }
hostInput.widget.name = inputName
hostInput.widgetId = id
useWidgetValueStore().registerWidget(id, {
type: sourceState.type,
value: sourceState.value,
options: cloneDeep(sourceState.options ?? {}),
label: hostInput.label ?? sourceSlot.label ?? inputName,
serialize: sourceState.serialize,
disabled: sourceState.disabled,
isDOMWidget: sourceState.isDOMWidget
})
}
function promotePreviewViaExposure(
subgraphNode: SubgraphNode,
sourceNode: LGraphNode,
@@ -360,32 +283,6 @@ export function promoteWidget(
})
}
/**
* Removes the host input projecting a linked promotion identified by source.
* Returns true when an input was found and demoted.
*/
export function demotePromotedInput(
subgraphNode: SubgraphNode,
source: PromotedWidgetSource
): boolean {
if (!subgraphNode.subgraph) return false
const hostInput = findHostInputForPromotion(
subgraphNode,
source.sourceNodeId,
source.sourceWidgetName
)
const linkedInput = hostInput?._subgraphSlot
if (!linkedInput) return false
if (hostInput.link != null) {
linkedInput.disconnect()
} else {
subgraphNode.subgraph.removeInput(linkedInput)
}
return true
}
export function demoteWidget(
node: PartialNode,
widget: IBaseWidget,
@@ -395,7 +292,21 @@ export function demoteWidget(
for (const parent of parents) {
if (!parent.subgraph) continue
if (demotePromotedInput(parent, source)) continue
const hostInput = findHostInputForPromotion(
parent,
source.sourceNodeId,
source.sourceWidgetName
)
const linkedInput = hostInput?._subgraphSlot
if (linkedInput) {
const hasExternalLink = hostInput.link != null
if (hasExternalLink) {
linkedInput.disconnect()
} else {
parent.subgraph.removeInput(linkedInput)
}
continue
}
if (isPreviewPseudoWidget(widget)) {
const previewStore = usePreviewExposureStore()
@@ -594,19 +505,37 @@ export function pruneDisconnected(subgraphNode: SubgraphNode) {
const removedEntries: PromotedWidgetSource[] = []
const staleInputs = subgraph.inputs.filter((input) => {
const source = resolvePromotionSource(subgraphNode, input)
if (source) return false
const widget = input._widget
if (!widget || !isPromotedWidgetView(widget)) return false
const hostInput = subgraphNode.inputs.find(
(entry) => entry._subgraphSlot === input
// If the SubgraphInput has any live link to an interior target slot that
// still has a widget, the promotion is alive — even when the widget's
// sourceNodeId points at a deeply-nested interior node that does not exist
// directly in `subgraph` (nested SubgraphNode promotions).
for (const linkId of input.linkIds) {
const link = subgraph.getLink(linkId)
if (!link) continue
const { inputNode } = link.resolve(subgraph)
if (!inputNode) continue
const targetInputSlot = inputNode.inputs?.find(
(slot) => slot.link === linkId
)
if (!targetInputSlot) continue
if (inputNode.getWidgetFromSlot(targetInputSlot)) return false
}
const node = subgraph.getNodeById(widget.sourceNodeId)
if (!node) {
removedEntries.push(widget)
return true
}
const hasWidget = getPromotableWidgets(node).some(
(iw) => iw.name === widget.sourceWidgetName
)
if (!hostInput?.widgetId && !hostInput?._widget) return false
removedEntries.push({
sourceNodeId: String(subgraphNode.id),
sourceWidgetName: input.name
})
return true
if (!hasWidget) {
removedEntries.push(widget)
}
return !hasWidget
})
for (const input of staleInputs) {

View File

@@ -1,10 +1,12 @@
import { createTestingPinia } from '@pinia/testing'
import { fromAny } from '@total-typescript/shoehorn'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, test, vi } from 'vitest'
import { resolveConcretePromotedWidget } from '@/core/graph/subgraph/resolveConcretePromotedWidget'
import { LGraphNode, asNodeId } from '@/lib/litegraph/src/litegraph'
import {
resolveConcretePromotedWidget,
resolvePromotedWidgetAtHost
} from '@/core/graph/subgraph/resolveConcretePromotedWidget'
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
import {
createTestSubgraph,
@@ -22,8 +24,17 @@ vi.mock('@/services/litegraphService', () => ({
useLitegraphService: () => ({ updatePreviews: () => ({}) })
}))
type PromotedWidgetStub = Pick<
IBaseWidget,
'name' | 'type' | 'options' | 'value' | 'y'
> & {
sourceNodeId: string
sourceWidgetName: string
node?: SubgraphNode
}
function createHostNode(id: number): SubgraphNode {
return createTestSubgraphNode(createTestSubgraph(), { id: asNodeId(id) })
return createTestSubgraphNode(createTestSubgraph(), { id })
}
function addNodeToHost(host: SubgraphNode, title: string): LGraphNode {
@@ -36,10 +47,55 @@ function addConcreteWidget(node: LGraphNode, name: string): IBaseWidget {
return node.addWidget('text', name, `${name}-value`, () => undefined)
}
function createPromotedWidget(
name: string,
sourceNodeId: string,
sourceWidgetName: string,
node?: SubgraphNode
): IBaseWidget {
const promotedWidget: PromotedWidgetStub = {
name,
type: 'button',
options: {},
y: 0,
value: undefined,
sourceNodeId,
sourceWidgetName,
node
}
return promotedWidget as IBaseWidget
}
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
})
describe('resolvePromotedWidgetAtHost', () => {
test('resolves a direct concrete widget on the host subgraph node', () => {
const host = createHostNode(100)
const concreteNode = addNodeToHost(host, 'leaf')
addConcreteWidget(concreteNode, 'seed')
const resolved = resolvePromotedWidgetAtHost(
host,
String(concreteNode.id),
'seed'
)
expect(resolved).toBeDefined()
expect(resolved?.node.id).toBe(concreteNode.id)
expect(resolved?.widget.name).toBe('seed')
})
test('returns undefined when host does not contain the target node', () => {
const host = createHostNode(100)
const resolved = resolvePromotedWidgetAtHost(host, 'missing', 'seed')
expect(resolved).toBeUndefined()
})
})
describe('resolveConcretePromotedWidget', () => {
test('resolves a direct concrete source widget', () => {
const host = createHostNode(100)
@@ -58,90 +114,102 @@ describe('resolveConcretePromotedWidget', () => {
expect(result.resolved.widget.name).toBe('seed')
})
test('descends through nested subgraph inputs to the deepest concrete widget', () => {
const innerSubgraph = createTestSubgraph({
inputs: [{ name: 'x', type: '*' }]
})
const leaf = new LGraphNode('Leaf')
const leafInput = leaf.addInput('x', '*')
leaf.addWidget('combo', 'seed', 'a', () => undefined, {
values: ['a', 'b']
})
leafInput.widget = { name: 'seed' }
innerSubgraph.add(leaf)
innerSubgraph.inputNode.slots[0].connect(leafInput, leaf)
const innerNode = createTestSubgraphNode(innerSubgraph, {
id: asNodeId(11)
})
const outerSubgraph = createTestSubgraph({
inputs: [{ name: 'y', type: '*' }]
})
outerSubgraph.add(innerNode)
innerNode._internalConfigureAfterSlots()
outerSubgraph.inputNode.slots[0].connect(innerNode.inputs[0], innerNode)
const outerNode = createTestSubgraphNode(outerSubgraph, {
id: asNodeId(22)
})
test('descends through nested promoted widgets to resolve concrete source', () => {
const rootHost = createHostNode(100)
const nestedHost = createHostNode(101)
const leafNode = addNodeToHost(nestedHost, 'leaf')
addConcreteWidget(leafNode, 'seed')
const sourceNode = addNodeToHost(rootHost, 'source')
sourceNode.widgets = [
createPromotedWidget('outer', String(leafNode.id), 'seed', nestedHost)
]
const result = resolveConcretePromotedWidget(
outerNode,
String(innerNode.id),
'x'
rootHost,
String(sourceNode.id),
'outer'
)
expect(result.status).toBe('resolved')
if (result.status !== 'resolved') return
expect(result.resolved.node.id).toBe(leaf.id)
expect(result.resolved.node.id).toBe(leafNode.id)
expect(result.resolved.widget.name).toBe('seed')
expect(result.resolved.widget.type).toBe('combo')
})
test('returns cycle when nested promoted widget traversal revisits the same input', () => {
const recursiveInput = { name: 'x', link: 1 }
const recursiveNode = fromAny<LGraphNode, unknown>({
id: 11,
inputs: [recursiveInput],
isSubgraphNode: () => true,
subgraph: {
inputNode: { slots: [{ name: 'x', linkIds: [1] }] },
getLink: () => ({
resolve: () => ({ inputNode: recursiveNode })
}),
getNodeById: () => recursiveNode
}
})
const host = fromAny<SubgraphNode, unknown>({
isSubgraphNode: () => true,
subgraph: {
getNodeById: () => recursiveNode
}
})
test('returns cycle failure when promoted widgets form a loop', () => {
const hostA = createHostNode(200)
const hostB = createHostNode(201)
const relayA = addNodeToHost(hostA, 'relayA')
const relayB = addNodeToHost(hostB, 'relayB')
const result = resolveConcretePromotedWidget(host, '11', 'x')
relayA.widgets = [
createPromotedWidget('wA', String(relayB.id), 'wB', hostB)
]
relayB.widgets = [
createPromotedWidget('wB', String(relayA.id), 'wA', hostA)
]
expect(result).toEqual({ status: 'failure', failure: 'cycle' })
const result = resolveConcretePromotedWidget(hostA, String(relayA.id), 'wA')
expect(result).toEqual({
status: 'failure',
failure: 'cycle'
})
})
test('returns max-depth-exceeded for a chain over the traversal limit', () => {
const subgraphs = Array.from({ length: 102 }, () =>
createTestSubgraph({ inputs: [{ name: 'x', type: '*' }] })
test('does not report a cycle when different host objects share an id', () => {
const rootHost = createHostNode(41)
const nestedHost = createHostNode(41)
const leafNode = addNodeToHost(nestedHost, 'leaf')
addConcreteWidget(leafNode, 'w')
const sourceNode = addNodeToHost(rootHost, 'source')
sourceNode.widgets = [
createPromotedWidget('w', String(leafNode.id), 'w', nestedHost)
]
const result = resolveConcretePromotedWidget(
rootHost,
String(sourceNode.id),
'w'
)
for (let index = 0; index < subgraphs.length - 1; index++) {
const current = subgraphs[index]
const next = subgraphs[index + 1]
const nextNode = createTestSubgraphNode(next, { id: asNodeId(index + 1) })
current.add(nextNode)
nextNode._internalConfigureAfterSlots()
current.inputNode.slots[0].connect(nextNode.inputs[0], nextNode)
expect(result.status).toBe('resolved')
if (result.status !== 'resolved') return
expect(result.resolved.node.id).toBe(leafNode.id)
expect(result.resolved.widget.name).toBe('w')
})
test('returns max-depth-exceeded for very deep non-cyclic promoted chains', () => {
const hosts = Array.from({ length: 102 }, (_, index) =>
createHostNode(index + 1)
)
const relayNodes = hosts.map((host, index) =>
addNodeToHost(host, `relay-${index}`)
)
for (let index = 0; index < relayNodes.length - 1; index += 1) {
relayNodes[index].widgets = [
createPromotedWidget(
`w-${index}`,
String(relayNodes[index + 1].id),
`w-${index + 1}`,
hosts[index + 1]
)
]
}
const host = createTestSubgraphNode(subgraphs[0], { id: asNodeId(200) })
addConcreteWidget(
relayNodes[relayNodes.length - 1],
`w-${relayNodes.length - 1}`
)
const result = resolveConcretePromotedWidget(
hosts[0],
String(relayNodes[0].id),
'w-0'
)
const result = resolveConcretePromotedWidget(host, '1', 'x')
expect(result).toEqual({
status: 'failure',
failure: 'max-depth-exceeded'

View File

@@ -1,7 +1,8 @@
import type { ResolvedPromotedWidget } from '@/core/graph/subgraph/promotedWidgetTypes'
import { resolveSubgraphInputTarget } from '@/core/graph/subgraph/resolveSubgraphInputTarget'
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
type PromotedWidgetResolutionFailure =
| 'invalid-host'
@@ -40,17 +41,6 @@ function traversePromotedWidgetChain(
return { status: 'failure', failure: 'missing-node' }
}
if (sourceNode.isSubgraphNode()) {
const target = resolveSubgraphInputTarget(sourceNode, currentWidgetName)
if (!target) {
return { status: 'failure', failure: 'missing-widget' }
}
currentHost = sourceNode
currentNodeId = target.nodeId
currentWidgetName = target.widgetName
continue
}
const sourceWidget = sourceNode.widgets?.find(
(entry) => entry.name === currentWidgetName
)
@@ -58,15 +48,39 @@ function traversePromotedWidgetChain(
return { status: 'failure', failure: 'missing-widget' }
}
return {
status: 'resolved',
resolved: { node: sourceNode, widget: sourceWidget }
if (!isPromotedWidgetView(sourceWidget)) {
return {
status: 'resolved',
resolved: { node: sourceNode, widget: sourceWidget }
}
}
if (!sourceWidget.node?.isSubgraphNode()) {
return { status: 'failure', failure: 'missing-node' }
}
currentHost = sourceWidget.node
currentNodeId = sourceWidget.sourceNodeId
currentWidgetName = sourceWidget.sourceWidgetName
}
return { status: 'failure', failure: 'max-depth-exceeded' }
}
export function resolvePromotedWidgetAtHost(
hostNode: SubgraphNode,
nodeId: string,
widgetName: string
): ResolvedPromotedWidget | undefined {
const node = hostNode.subgraph.getNodeById(nodeId)
if (!node) return undefined
const widget = node.widgets?.find((entry) => entry.name === widgetName)
if (!widget) return undefined
return { node, widget }
}
export function resolveConcretePromotedWidget(
hostNode: LGraphNode,
nodeId: string,
@@ -77,3 +91,20 @@ export function resolveConcretePromotedWidget(
}
return traversePromotedWidgetChain(hostNode, nodeId, widgetName)
}
export function resolvePromotedWidgetSource(
hostNode: LGraphNode,
widget: IBaseWidget
): ResolvedPromotedWidget | undefined {
if (!isPromotedWidgetView(widget)) return undefined
if (!hostNode.isSubgraphNode()) return undefined
const result = resolveConcretePromotedWidget(
hostNode,
widget.sourceNodeId,
widget.sourceWidgetName
)
if (result.status === 'resolved') return result.resolved
return undefined
}

View File

@@ -4,7 +4,7 @@ import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, test, vi } from 'vitest'
import { resolveSubgraphInputLink } from '@/core/graph/subgraph/resolveSubgraphInputLink'
import { LGraphNode, asNodeId } from '@/lib/litegraph/src/litegraph'
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
import {
createTestSubgraph,
createTestSubgraphNode,
@@ -30,7 +30,7 @@ function createSubgraphSetup(inputName: string): {
const subgraph = createTestSubgraph({
inputs: [{ name: inputName, type: '*' }]
})
const subgraphNode = createTestSubgraphNode(subgraph, { id: asNodeId(1) })
const subgraphNode = createTestSubgraphNode(subgraph, { id: 1 })
return { subgraph, subgraphNode }
}

View File

@@ -3,7 +3,7 @@ import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, test, vi } from 'vitest'
import { resolveSubgraphInputTarget } from '@/core/graph/subgraph/resolveSubgraphInputTarget'
import { LGraphNode, asNodeId } from '@/lib/litegraph/src/litegraph'
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
import {
createTestSubgraph,
createTestSubgraphNode
@@ -28,9 +28,7 @@ function createOuterSubgraphSetup(inputNames: string[]): {
const outerSubgraph = createTestSubgraph({
inputs: inputNames.map((name) => ({ name, type: '*' }))
})
const outerSubgraphNode = createTestSubgraphNode(outerSubgraph, {
id: asNodeId(1)
})
const outerSubgraphNode = createTestSubgraphNode(outerSubgraph, { id: 1 })
return { outerSubgraph, outerSubgraphNode }
}
@@ -43,9 +41,7 @@ function addLinkedNestedSubgraphNode(
const innerSubgraph = createTestSubgraph({
inputs: [{ name: linkedInputName, type: '*' }]
})
const innerSubgraphNode = createTestSubgraphNode(innerSubgraph, {
id: asNodeId(819)
})
const innerSubgraphNode = createTestSubgraphNode(innerSubgraph, { id: 819 })
outerSubgraph.add(innerSubgraphNode)
const inputSlot = outerSubgraph.inputNode.slots.find(
@@ -143,7 +139,7 @@ describe('resolveSubgraphInputTarget', () => {
(slot) => slot.name === 'seed'
)!
const node = new LGraphNode('Interior-seed')
node.id = asNodeId(42)
node.id = 42
const input = node.addInput('seed_input', '*')
node.addWidget('number', 'seed', 0, () => undefined)
input.widget = { name: 'seed' }
@@ -189,7 +185,7 @@ describe('resolveSubgraphInputTarget', () => {
]
})
const innerSubgraphNode = createTestSubgraphNode(innerSubgraph, {
id: asNodeId(820)
id: 820
})
outerSubgraph.add(innerSubgraphNode)
@@ -228,7 +224,7 @@ describe('resolveSubgraphInputTarget', () => {
inputs: [{ name: 'seed', type: '*' }]
})
const concreteNode = new LGraphNode('ConcreteNode')
concreteNode.id = asNodeId(900)
concreteNode.id = 900
const concreteInput = concreteNode.addInput('seed_input', '*')
concreteNode.addWidget('number', 'seed', 0, () => undefined)
concreteInput.widget = { name: 'seed' }
@@ -239,7 +235,7 @@ describe('resolveSubgraphInputTarget', () => {
inputs: [{ name: 'seed', type: '*' }]
})
const innerSubgraphNode = createTestSubgraphNode(innerSubgraph, {
id: asNodeId(901)
id: 901
})
middleSubgraph.add(innerSubgraphNode)
const middleInput = innerSubgraphNode.addInput('seed', '*')
@@ -251,7 +247,7 @@ describe('resolveSubgraphInputTarget', () => {
'seed'
])
const middleSubgraphNode = createTestSubgraphNode(middleSubgraph, {
id: asNodeId(902)
id: 902
})
outerSubgraph.add(middleSubgraphNode)
const outerInput = middleSubgraphNode.addInput('seed', '*')

View File

@@ -0,0 +1,8 @@
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
export function hasWidgetNode(
widget: IBaseWidget
): widget is IBaseWidget & { node: LGraphNode } {
return 'node' in widget && !!widget.node
}

View File

@@ -1,5 +1,7 @@
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
const widgetRenderKeys = new WeakMap<IBaseWidget, string>()
let nextWidgetRenderKeyId = 0
@@ -7,7 +9,9 @@ export function getStableWidgetRenderKey(widget: IBaseWidget): string {
const cachedKey = widgetRenderKeys.get(widget)
if (cachedKey) return cachedKey
const key = `widget:${nextWidgetRenderKeyId++}`
const prefix = isPromotedWidgetView(widget) ? 'promoted' : 'widget'
const key = `${prefix}:${nextWidgetRenderKeyId++}`
widgetRenderKeys.set(widget, key)
return key
}

View File

@@ -24,7 +24,6 @@ import { useLitegraphService } from '@/services/litegraphService'
import { app } from '@/scripts/app'
import type { ComfyApp } from '@/scripts/app'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
import { widgetId } from '@/types/widgetId'
const INLINE_INPUTS = false
@@ -191,9 +190,7 @@ function dynamicComboWidget(
const getState = () => {
const graphId = resolveNodeRootGraphId(node)
if (!graphId) return undefined
return useWidgetValueStore().getWidget(
widgetId(graphId, node.id, widget.name)
)
return useWidgetValueStore().getWidget(graphId, node.id, widget.name)
}
Object.defineProperty(widget, 'value', {
get() {

View File

@@ -8,7 +8,6 @@ import { app } from '@/scripts/app'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
import { applyFirstWidgetValueToGraph } from './widgetValuePropagation'
import { widgetId } from '@/types/widgetId'
function applyToGraph(this: LGraphNode, extraLinks: LLink[] = []) {
applyFirstWidgetValueToGraph(this, extraLinks)
@@ -52,15 +51,16 @@ function onCustomComboCreated(this: LGraphNode) {
Object.defineProperty(widget, 'value', {
get() {
return (
useWidgetValueStore().getWidget(
widgetId(app.rootGraph.id, node.id, widgetName)
)?.value ?? localValue
useWidgetValueStore().getWidget(app.rootGraph.id, node.id, widgetName)
?.value ?? localValue
)
},
set(v: string) {
localValue = v
const state = useWidgetValueStore().getWidget(
widgetId(app.rootGraph.id, node.id, widgetName)
app.rootGraph.id,
node.id,
widgetName
)
if (state) state.value = v
updateCombo()

View File

@@ -10,8 +10,7 @@ import {
LGraphNode,
type LGraphNodeConstructor,
LiteGraph,
SubgraphNode,
asNodeId
SubgraphNode
} from '@/lib/litegraph/src/litegraph'
import { useToastStore } from '@/platform/updates/common/toastStore'
import {
@@ -177,7 +176,7 @@ class GroupNodeBuilder {
const storeLinkTypes = (config: SerializedGroupConfig) => {
// Store link types for dynamically typed nodes e.g. reroutes
for (const link of config.links) {
const origin = app.rootGraph.getNodeById(asNodeId(link[4]))
const origin = app.rootGraph.getNodeById(link[4] as NodeId)
const type = origin?.outputs?.[Number(link[1])]?.type
if (type !== undefined) link.push(type)
}
@@ -1067,7 +1066,7 @@ export class GroupNodeHandler {
if (!innerNode) return null
// @ts-expect-error legacy node data format used for configure
innerNode.configure(n)
innerNode.id = asNodeId(`${this.node.id}:${i}`)
innerNode.id = `${this.node.id}:${i}`
innerNode.graph = this.node.graph
return innerNode
})
@@ -1088,7 +1087,7 @@ export class GroupNodeHandler {
node.graph ??= this.node.graph
// Create minimal DTOs rather than cloning the node
const currentId = node.id
const currentId = String(node.id)
// @ts-expect-error temporary id reassignment for DTO creation
node.id = currentId.split(':').at(-1)
const aVeryRealNode = new ExecutableGroupNodeChildDTO(
@@ -1447,7 +1446,7 @@ export class GroupNodeHandler {
).runningInternalNodeId = innerNodeIndex
api.dispatchCustomEvent(
type as 'executing',
getEvent(detail, `${this.node.id}`, this.node) as unknown as NodeId
getEvent(detail, `${this.node.id}`, this.node) as string
)
}
}
@@ -1711,7 +1710,7 @@ export class GroupNodeHandler {
app.rootGraph.remove(node)
// Set internal ID to what is expected after workflow is reloaded
node.id = asNodeId(`${this.node.id}:${i}`)
node.id = `${this.node.id}:${i}`
}
this.linkInputs()

View File

@@ -22,6 +22,7 @@ import {
LOAD3D_NONE_MODEL,
SUPPORTED_EXTENSIONS_ACCEPT
} from '@/extensions/core/load3d/constants'
import { snapshotLoad3dState } from '@/extensions/core/load3d/load3dSerialize'
import Load3dUtils from '@/extensions/core/load3d/Load3dUtils'
import { t } from '@/i18n'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
@@ -413,16 +414,10 @@ useExtensionService().registerExtension({
if (cached) return cached
}
const cameraConfig: CameraConfig = (node.properties[
'Camera Config'
] as CameraConfig | undefined) || {
cameraType: currentLoad3d.getCurrentCameraType(),
fov: currentLoad3d.cameraManager.perspectiveCamera.fov
}
cameraConfig.state = currentLoad3d.getCameraState()
node.properties['Camera Config'] = cameraConfig
currentLoad3d.stopRecording()
const { camera_info, model_3d_info } = snapshotLoad3dState(
node,
currentLoad3d
)
const {
scene: imageData,
@@ -441,16 +436,11 @@ useExtensionService().registerExtension({
currentLoad3d.handleResize()
const modelInfo = currentLoad3d.getModelInfo()
const model_3d_info: Model3DInfo = modelInfo ? [modelInfo] : []
const returnVal: Load3dCachedOutput = {
image: `threed/${data.name} [temp]`,
mask: `threed/${dataMask.name} [temp]`,
normal: `threed/${dataNormal.name} [temp]`,
camera_info:
(node.properties['Camera Config'] as CameraConfig | undefined)
?.state || null,
camera_info,
recording: '',
model_3d_info
}

View File

@@ -23,6 +23,7 @@ const mtlLoaderStub = {
const objLoaderStub = {
setWorkerUrl: vi.fn(),
setMaterials: vi.fn(),
setBaseObject3d: vi.fn(),
loadAsync: vi.fn<(url: string) => Promise<THREE.Object3D>>()
}
@@ -58,6 +59,7 @@ vi.mock('wwobjloader2', () => ({
OBJLoader2Parallel: class {
setWorkerUrl = objLoaderStub.setWorkerUrl
setMaterials = objLoaderStub.setMaterials
setBaseObject3d = objLoaderStub.setBaseObject3d
loadAsync = objLoaderStub.loadAsync
},
MtlObjBridge: {
@@ -247,6 +249,24 @@ describe('MeshModelAdapter', () => {
expect(ctx.registerOriginalMaterial).toHaveBeenCalledTimes(1)
})
it('resets baseObject3d on every load so meshes do not accumulate across calls', async () => {
objLoaderStub.loadAsync.mockResolvedValue(makeFbxLikeGroup())
const adapter = new MeshModelAdapter()
const ctx = makeContext('wireframe')
await adapter.load(ctx, '/api/view/', 'first.obj')
await adapter.load(ctx, '/api/view/', 'second.obj')
expect(objLoaderStub.setBaseObject3d).toHaveBeenCalledTimes(2)
const bases = objLoaderStub.setBaseObject3d.mock.calls.map(
([base]) => base
)
expect(bases[0]).toBeInstanceOf(THREE.Object3D)
expect(bases[1]).toBeInstanceOf(THREE.Object3D)
// Each call should hand the loader a fresh container, not the same one.
expect(bases[0]).not.toBe(bases[1])
})
})
describe('GLTF loader path', () => {

View File

@@ -102,6 +102,8 @@ export class MeshModelAdapter implements ModelAdapter {
path: string,
filename: string
): Promise<THREE.Object3D> {
this.objLoader.setBaseObject3d(new THREE.Object3D())
if (ctx.materialMode === 'original') {
try {
this.mtlLoader.setPath(path)

View File

@@ -0,0 +1,87 @@
import { describe, expect, it, vi } from 'vitest'
import type Load3d from '@/extensions/core/load3d/Load3d'
import { snapshotLoad3dState } from '@/extensions/core/load3d/load3dSerialize'
import type { CameraState } from '@/extensions/core/load3d/interfaces'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
function makeNode(props: Record<string, unknown> = {}): LGraphNode {
return { properties: { ...props } } as unknown as LGraphNode
}
const baseCameraState: CameraState = {
position: { x: 1, y: 2, z: 3 },
target: { x: 0, y: 0, z: 0 },
zoom: 1,
cameraType: 'perspective'
} as unknown as CameraState
function makeLoad3d({
cameraType = 'perspective',
fov = 35,
modelInfo = { transform: { position: [0, 0, 0] } } as unknown
}: {
cameraType?: string
fov?: number
modelInfo?: unknown
} = {}) {
return {
getCurrentCameraType: vi.fn(() => cameraType),
cameraManager: { perspectiveCamera: { fov } },
getCameraState: vi.fn(() => baseCameraState),
stopRecording: vi.fn(),
getModelInfo: vi.fn(() => modelInfo)
} as unknown as Load3d
}
describe('snapshotLoad3dState', () => {
it('returns only camera_info and model_3d_info', () => {
const result = snapshotLoad3dState(makeNode(), makeLoad3d())
expect(Object.keys(result).sort()).toEqual(['camera_info', 'model_3d_info'])
})
it('writes the camera state into properties["Camera Config"]', () => {
const node = makeNode()
snapshotLoad3dState(node, makeLoad3d({ fov: 42 }))
const cfg = node.properties['Camera Config'] as Record<string, unknown>
expect(cfg).toMatchObject({
cameraType: 'perspective',
fov: 42,
state: baseCameraState
})
})
it('preserves an existing Camera Config object instead of replacing it', () => {
const existing = { cameraType: 'orthographic', fov: 99 }
const node = makeNode({ 'Camera Config': existing })
snapshotLoad3dState(node, makeLoad3d())
// Same object reference (mutated in place), with state attached.
expect(node.properties['Camera Config']).toBe(existing)
expect(
(node.properties['Camera Config'] as Record<string, unknown>).state
).toBe(baseCameraState)
})
it('stops in-progress recording as a side effect', () => {
const load3d = makeLoad3d()
snapshotLoad3dState(makeNode(), load3d)
expect(load3d.stopRecording).toHaveBeenCalledOnce()
})
it('returns model_3d_info as a single-element list when a model is loaded', () => {
const info = { transform: { position: [1, 2, 3] } }
const result = snapshotLoad3dState(
makeNode(),
makeLoad3d({ modelInfo: info })
)
expect(result.model_3d_info).toEqual([info])
})
it('returns an empty model_3d_info list when no model is loaded', () => {
const result = snapshotLoad3dState(
makeNode(),
makeLoad3d({ modelInfo: null })
)
expect(result.model_3d_info).toEqual([])
})
})

View File

@@ -0,0 +1,36 @@
import type Load3d from '@/extensions/core/load3d/Load3d'
import type {
CameraConfig,
CameraState,
Model3DInfo
} from '@/extensions/core/load3d/interfaces'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
export type Load3dSerializedBase = {
camera_info: CameraState | null
model_3d_info: Model3DInfo
}
export function snapshotLoad3dState(
node: LGraphNode,
load3d: Load3d
): Load3dSerializedBase {
const cameraConfig: CameraConfig = (node.properties['Camera Config'] as
| CameraConfig
| undefined) || {
cameraType: load3d.getCurrentCameraType(),
fov: load3d.cameraManager.perspectiveCamera.fov
}
cameraConfig.state = load3d.getCameraState()
node.properties['Camera Config'] = cameraConfig
load3d.stopRecording()
const modelInfo = load3d.getModelInfo()
const model_3d_info: Model3DInfo = modelInfo ? [modelInfo] : []
return {
camera_info: cameraConfig.state ?? null,
model_3d_info
}
}

View File

@@ -9,7 +9,12 @@ const LOAD3D_PREVIEW_NODES = new Set([
'PreviewPointCloud'
])
const LOAD3D_ALL_NODES = new Set([...LOAD3D_PREVIEW_NODES, 'Load3D', 'SaveGLB'])
const LOAD3D_ALL_NODES = new Set([
...LOAD3D_PREVIEW_NODES,
'Load3D',
'Load3DAdvanced',
'SaveGLB'
])
export const isLoad3dPreviewNode = (nodeType: string): boolean =>
LOAD3D_PREVIEW_NODES.has(nodeType)

View File

@@ -0,0 +1,103 @@
import { nextTick } from 'vue'
import Load3DAdvanced from '@/components/load3d/Load3DAdvanced.vue'
import { nodeToLoad3dMap, useLoad3d } from '@/composables/useLoad3d'
import { createExportMenuItems } from '@/extensions/core/load3d/exportMenuHelper'
import type { CameraConfig } from '@/extensions/core/load3d/interfaces'
import Load3DConfiguration from '@/extensions/core/load3d/Load3DConfiguration'
import { snapshotLoad3dState } from '@/extensions/core/load3d/load3dSerialize'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import type { IContextMenuValue } from '@/lib/litegraph/src/interfaces'
import type { CustomInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
import { ComponentWidgetImpl, addWidget } from '@/scripts/domWidget'
import { useExtensionService } from '@/services/extensionService'
import { useLoad3dService } from '@/services/load3dService'
const inputSpecLoad3DAdvanced: CustomInputSpec = {
name: 'viewport_state',
type: 'LOAD_3D_ADVANCED',
isPreview: false
}
useExtensionService().registerExtension({
name: 'Comfy.Load3DAdvanced',
beforeRegisterNodeDef(_nodeType, nodeData) {
if (nodeData.name !== 'Load3DAdvanced') return
if (!nodeData.input?.required) return
nodeData.input.required.viewport_state = ['LOAD_3D_ADVANCED', {}]
},
getNodeMenuItems(node: LGraphNode): (IContextMenuValue | null)[] {
if (node.constructor.comfyClass !== 'Load3DAdvanced') return []
const load3d = useLoad3dService().getLoad3d(node)
if (!load3d) return []
return createExportMenuItems(load3d)
},
getCustomWidgets() {
return {
LOAD_3D_ADVANCED(node) {
const widget = new ComponentWidgetImpl({
node,
name: 'viewport_state',
component: Load3DAdvanced,
inputSpec: inputSpecLoad3DAdvanced,
options: {}
})
widget.type = 'load3DAdvanced'
addWidget(node, widget)
return { widget }
}
}
},
async nodeCreated(node: LGraphNode) {
if (node.constructor.comfyClass !== 'Load3DAdvanced') return
const [oldWidth, oldHeight] = node.size
node.setSize([Math.max(oldWidth, 300), Math.max(oldHeight, 600)])
await nextTick()
useLoad3d(node).onLoad3dReady((load3d) => {
const modelWidget = node.widgets?.find((w) => w.name === 'model_file')
const width = node.widgets?.find((w) => w.name === 'width')
const height = node.widgets?.find((w) => w.name === 'height')
if (!modelWidget || !width || !height) return
const cameraConfig = node.properties['Camera Config'] as
| CameraConfig
| undefined
const cameraState = cameraConfig?.state
const config = new Load3DConfiguration(load3d, node.properties)
config.configure({
loadFolder: 'input',
modelWidget,
cameraState,
width,
height
})
})
useLoad3d(node).waitForLoad3d(() => {
const sceneWidget = node.widgets?.find((w) => w.name === 'viewport_state')
if (!sceneWidget) return
sceneWidget.serializeValue = async () => {
const currentLoad3d = nodeToLoad3dMap.get(node)
if (!currentLoad3d) {
console.error('No load3d instance found for node')
return null
}
return snapshotLoad3dState(node, currentLoad3d)
}
})
}
})

View File

@@ -37,6 +37,7 @@ async function loadLoad3dExtensions(): Promise<ComfyExtension[]> {
// Import extensions - they self-register via useExtensionService()
await Promise.all([
import('./load3d'),
import('./load3dAdvanced'),
import('./load3dPreviewExtensions'),
import('./saveMesh')
])
@@ -66,6 +67,12 @@ useExtensionService().registerExtension({
modelFile[1].mesh_upload = true
modelFile[1].upload_subfolder = '3d'
}
} else if (nodeData.name === 'Load3DAdvanced') {
const modelFile = nodeData.input?.required?.model_file
if (modelFile?.[1]) {
modelFile[1].mesh_upload = true
modelFile[1].upload_subfolder = ''
}
}
// Load the 3D extensions and replay their beforeRegisterNodeDef hooks,

View File

@@ -20,7 +20,6 @@ import type { ComfyNodeDef } from '@/schemas/nodeDefSchema'
import type { DOMWidget } from '@/scripts/domWidget'
import { useAudioService } from '@/services/audioService'
import { type NodeLocatorId } from '@/types'
import { widgetId } from '@/types/widgetId'
import { getNodeByLocatorId } from '@/utils/graphTraversalUtil'
import { api } from '../../scripts/api'
@@ -153,16 +152,16 @@ app.registerExtension({
audioUIWidget.options.getValue = () =>
(useWidgetValueStore().getWidget(
widgetId(
resolveNodeRootGraphId(node, app.rootGraph.id),
node.id,
inputName
)
resolveNodeRootGraphId(node, app.rootGraph.id),
node.id,
inputName
)?.value as string) ?? ''
audioUIWidget.options.setValue = (v) => {
const graphId = resolveNodeRootGraphId(node, app.rootGraph.id)
const widgetState = useWidgetValueStore().getWidget(
widgetId(graphId, node.id, inputName)
graphId,
node.id,
inputName
)
if (widgetState) widgetState.value = v
}

View File

@@ -6,7 +6,6 @@ import type {
INodeOutputSlot,
LLink
} from '@/lib/litegraph/src/litegraph'
import { asNodeId } from '@/lib/litegraph/src/litegraph'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import { createMockLLink } from '@/utils/__tests__/litegraphTestUtils'
@@ -40,7 +39,7 @@ function createTargetNode(
id = 7
): Pick<LGraphNode, 'id' | 'inputs' | 'widgets'> {
return fromPartial<Pick<LGraphNode, 'id' | 'inputs' | 'widgets'>>({
id: asNodeId(id),
id,
inputs: [
fromPartial<INodeInputSlot>({
widget: { name: widget.name }

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