Compare commits

...

12 Commits

Author SHA1 Message Date
MaanilVerma
ff893d2408 [chore] Update Ingest API types from cloud@fbcf68e 2026-06-27 05:17:50 +00:00
Christian Byrne
4a2393be48 chore: drop unnecessary exports on file-local types to satisfy knip (#13204)
Current `main` **fails a fresh `knip` run** with 13 unused exported
types (exit 1). They're invisible on main because lint/knip only runs on
`pull_request`/`merge_group`, never on push to main — so merge skew (one
PR adds an export used by file X; a later PR removes X's usage)
accumulates latent failures that ambush backport branches (e.g. #13163,
#13162).

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

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

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

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

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

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

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

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

Discussed with @alexisrolland and @PabloWiedemann 

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

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

**Base branch:** `main`

---------

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

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

## Changes

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

## Review Focus

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


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

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

## Changes

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

## Review Focus

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

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

## Validation

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

## Screenshots (if applicable)

N/A, CI workflow-only.

<!-- CURSOR_SUMMARY -->
---

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

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

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

## Changes

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

## Review Focus

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

## Caveat

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

## Screenshots (if applicable)

N/A

---------

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

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

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

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

Closes #11870

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

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

---------

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

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

## Changes

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

## Review Focus

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

## Screenshots

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

---------

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

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

## Changes

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

## Review Focus

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

---------

Co-authored-by: nav-tej <36310614+nav-tej@users.noreply.github.com>
Co-authored-by: Christian Byrne <cbyrne@comfy.org>
2026-06-26 15:11:12 -07:00
Christian Byrne
dfcb336499 fix: give backport push a token that can update workflow files (#13038)
## Summary

The `PR Backport` workflow silently fails for any PR that also modifies
a file under `.github/workflows/**`.

## Root cause

The `backport` job checks out with the default `GITHUB_TOKEN` and reuses
those persisted credentials for `git push`. GitHub refuses to let that
token create or update workflow files:

```
! [remote rejected]  backport-12804-to-core-1.45 -> backport-12804-to-core-1.45
  (refusing to allow a GitHub App to create or update workflow
   `.github/workflows/ci-tests-e2e.yaml` without `workflows` permission)
error: failed to push some refs
```

The cherry-pick itself succeeds — only the push is rejected. And because
the `run:` step inherits `set -e`, the loop aborts before writing the
`failed=` output, so the "Comment on failures" step (`if: failure() &&
steps.backport.outputs.failed`) posts nothing. The result is a red job
with no explanation on the PR.

## History

PR #12804 touched `.github/workflows/ci-tests-e2e.yaml` and
`.github/actions/setup-frontend/action.yaml`. Its backport run
([27788259837](https://github.com/Comfy-Org/ComfyUI_frontend/actions/runs/27788259837/job/82230406910))
failed exactly this way: cherry-pick clean on every target, push
rejected on the workflow file. All four backports (#12966, #12967,
#12968, #12969) had to be created manually.

## Changes

Check out with `PR_GH_TOKEN` (already used by the Create-PR step) so the
push carries `workflow` scope.

> [!IMPORTANT]
> `PR_GH_TOKEN` must have **workflow** write permission for this to take
effect. If it does not, the secret needs that scope added.

## Follow-up (not in this PR)

The push failure aborts the whole job under `set -e` with no PR comment.
Even with the token fixed, a push rejected for another reason (branch
protection, etc.) would still fail silently. Wrapping the push so a
single-target failure is recorded as a `push-failed` reason and reported
via the existing failure-comment step would make the workflow degrade
gracefully.

---------

Co-authored-by: GitHub Action <action@github.com>
Co-authored-by: Alexander Brown <drjkl@comfy.org>
2026-06-26 20:37:02 +00:00
328 changed files with 44273 additions and 13895 deletions

View File

@@ -88,9 +88,9 @@ jobs:
- name: Strip non-source entries from coverage
if: steps.coverage-shards.outputs.has-coverage == 'true'
run: |
# Drop served bundle scripts (localhost-8188/assets/*.js) that V8 records but have no source file on disk, which would abort genhtml.
lcov --remove coverage/playwright/coverage.lcov \
'*localhost-8188*' \
'assets/images/*' \
-o coverage/playwright/coverage.lcov \
--ignore-errors unused
wc -l coverage/playwright/coverage.lcov
@@ -121,7 +121,8 @@ jobs:
--title "ComfyUI E2E Coverage" \
--no-function-coverage \
--precision 1 \
--ignore-errors source,unmapped
--ignore-errors source,unmapped,range \
--synthesize-missing
- name: Upload HTML report artifact
if: steps.coverage-shards.outputs.has-coverage == 'true'

View File

@@ -67,6 +67,11 @@ jobs:
uses: actions/checkout@v6
with:
fetch-depth: 0
# Persist a token with `workflow` scope so the backport push can
# include changes to .github/workflows/**. The default GITHUB_TOKEN
# is refused by GitHub when a push creates/updates workflow files,
# which silently aborted the whole job (see PR #12804 backport).
token: ${{ secrets.PR_GH_TOKEN }}
- name: Configure git
run: |

View File

@@ -21,7 +21,8 @@ module.exports = defineConfig({
'ar',
'tr',
'pt-BR',
'fa'
'fa',
'he'
],
reference: `Special names to keep untranslated: flux, photomaker, clip, vae, cfg, stable audio, stable cascade, stable zero, controlnet, lora, HiDream, Civitai, Hugging Face.
'latent' is the short form of 'latent space'.
@@ -37,5 +38,11 @@ module.exports = defineConfig({
- Keep commonly used technical terms in English when they are standard in Persian software (e.g., node, workflow).
- Use Arabic-Indic numerals (۰-۹) for numbers where appropriate.
- Maintain consistency with terminology used in Persian software and design applications.
IMPORTANT Hebrew Translation Guidelines:
- For 'he' locale: Use modern, formal Hebrew (עברית תקנית) for a professional tone throughout the UI.
- Hebrew is a right-to-left (RTL) language. Keep all interpolation placeholders ({name}, {count}), pipe-separated plural forms, and English technical terms intact and in their original positions.
- Preferred glossary: node = צומת (plural צמתים), workflow = תהליך עבודה, queue = תור, canvas = קנבס, widget = פקד, subgraph = תת-גרף, prompt = פרומפט/הנחיה (per context), bypass = עקיפה, mute = השתקה.
- Keep widely-recognized technical terms in English (Latin script): API, GPU, CUDA, VAE, CLIP, LoRA, ControlNet, Civitai, Hugging Face, Nodes 2.0, etc.
`
})

View File

@@ -179,6 +179,9 @@ This project uses **pnpm**. Always prefer scripts defined in `package.json` (e.g
23. Favor pure functions (especially testable ones)
24. Do not use function expressions if it's possible to use function declarations instead
25. Watch out for [Code Smells](https://wiki.c2.com/?CodeSmell) and refactor to avoid them
26. Do not add alias helpers whose implementation is just a single-line call to another function
- Bad: `function id(value) { return nodeId(value) }`
- Use the real function directly, or introduce a named helper only when it adds validation, branching, domain meaning, or shared behavior beyond renaming
## Design Standards

View File

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

View File

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

View File

@@ -14,6 +14,24 @@
{ "type": "host", "value": "website-frontend-comfyui.vercel.app" }
],
"headers": [{ "key": "X-Robots-Tag", "value": "index, follow" }]
},
{
"source": "/payment/(.*)",
"headers": [
{
"key": "X-Robots-Tag",
"value": "noindex"
}
]
},
{
"source": "/:locale/payment/(.*)",
"headers": [
{
"key": "X-Robots-Tag",
"value": "noindex"
}
]
}
],
"redirects": [

View File

@@ -4,8 +4,9 @@
import type { Locator, Page } from '@playwright/test'
import { TestIds } from '@e2e/fixtures/selectors'
import { VueNodeFixture } from '@e2e/fixtures/utils/vueNodeFixtures'
import { getSlotKey } from '@/renderer/core/layout/slots/slotIdentifier'
import { toNodeId } from '@/types/nodeId'
import { VueNodeFixture } from '@e2e/fixtures/utils/vueNodeFixtures'
export class VueNodeHelpers {
/**
@@ -43,7 +44,7 @@ export class VueNodeHelpers {
.locator('.lg-slot--input')
.filter({
has: this.page.locator(
`[data-slot-key="${getSlotKey(nodeId, slotIndex, true)}"]`
`[data-slot-key="${getSlotKey(toNodeId(nodeId), slotIndex, true)}"]`
)
})
}
@@ -251,14 +252,18 @@ export class VueNodeHelpers {
const key = await slot.getByTestId('slot-dot').getAttribute('data-slot-key')
if (!key) return false
return await this.page.evaluate((key) => {
const [nodeId, type, slotId] = key.split('-')
const node = app?.canvas?.graph?.getNodeById(nodeId)
if (!node) return false
const [rawNodeId, type, slotId] = key.split('-')
const nodeId = toNodeId(rawNodeId)
return await this.page.evaluate(
([nodeId, type, slotId]) => {
const node = app?.canvas?.graph?.getNodeById(nodeId)
if (!node) return false
return type === 'in'
? node.inputs[Number(slotId)]?.link !== null
: !!node.outputs[Number(slotId)].links?.length
}, key)
return type === 'in'
? node.inputs[Number(slotId)]?.link !== null
: !!node.outputs[Number(slotId)]?.links?.length
},
[nodeId, type, slotId] as const
)
}
}

View File

@@ -5,10 +5,9 @@ import type {
LGraph,
LGraphNode
} from '@/lib/litegraph/src/litegraph'
import type {
ComfyWorkflowJSON,
NodeId
} from '@/platform/workflow/validation/schemas/workflowSchema'
import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema'
import { toNodeId } from '@/types/nodeId'
import type { NodeId, SerializedNodeId } from '@/types/nodeId'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import { DefaultGraphPositions } from '@e2e/fixtures/constants/defaultGraphPositions'
import type { Position, Size } from '@e2e/fixtures/types'
@@ -42,11 +41,12 @@ export class NodeOperationsHelper {
}
async getSelectedNodeIds(): Promise<NodeId[]> {
return await this.page.evaluate(() => {
const selectedNodeIds = await this.page.evaluate(() => {
const selected = window.app?.canvas?.selected_nodes
if (!selected) return []
return Object.keys(selected).map(Number)
return Object.keys(selected)
})
return selectedNodeIds.map(toNodeId)
}
/**
@@ -114,8 +114,8 @@ export class NodeOperationsHelper {
return this.getNodeRefById(id)
}
async getNodeRefById(id: NodeId): Promise<NodeReference> {
return new NodeReference(id, this.comfyPage)
async getNodeRefById(id: SerializedNodeId): Promise<NodeReference> {
return new NodeReference(toNodeId(id), this.comfyPage)
}
async getNodeRefsByType(
@@ -136,7 +136,7 @@ export class NodeOperationsHelper {
},
{ type, includeSubgraph }
)
).map((id: NodeId) => this.getNodeRefById(id))
).map((id: SerializedNodeId) => this.getNodeRefById(id))
)
}
@@ -148,7 +148,7 @@ export class NodeOperationsHelper {
.app!.graph.nodes.filter((n: LGraphNode) => n.title === title)
.map((n: LGraphNode) => n.id)
}, title)
).map((id: NodeId) => this.getNodeRefById(id))
).map((id: SerializedNodeId) => this.getNodeRefById(id))
)
}

View File

@@ -1,6 +1,9 @@
import { expect } from '@playwright/test'
import type { Locator, Page } from '@playwright/test'
import { toNodeId } from '@/types/nodeId'
import type { NodeId } from '@/types/nodeId'
import { comfyPageFixture } from '@e2e/fixtures/ComfyPage'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import { assetPath } from '@e2e/fixtures/utils/paths'
@@ -45,9 +48,9 @@ async function orbitDragFromCanvasCenter(
export class Preview3DPipelineContext {
/** Matches node ids in `browser_tests/assets/3d/preview3d_pipeline.json`. */
static readonly loadNodeId = '1'
static readonly loadNodeId = toNodeId(1)
/** Matches node ids in `browser_tests/assets/3d/preview3d_pipeline.json`. */
static readonly previewNodeId = '2'
static readonly previewNodeId = toNodeId(2)
readonly load3d: Load3DHelper
readonly preview3d: Load3DHelper
@@ -61,9 +64,9 @@ export class Preview3DPipelineContext {
)
}
async getModelFileWidgetValue(nodeId: string): Promise<string> {
async getModelFileWidgetValue(nodeId: NodeId): Promise<string> {
return this.comfyPage.page.evaluate((id) => {
const node = window.app!.graph.getNodeById(Number(id))
const node = window.app!.graph.getNodeById(id)
if (!node?.widgets) return ''
const w = node.widgets.find((x) => x.name === 'model_file')
const v = w?.value
@@ -71,9 +74,9 @@ export class Preview3DPipelineContext {
}, nodeId)
}
async getLastTimeModelFile(nodeId: string): Promise<string> {
async getLastTimeModelFile(nodeId: NodeId): Promise<string> {
return this.comfyPage.page.evaluate((id) => {
const node = window.app!.graph.getNodeById(Number(id))
const node = window.app!.graph.getNodeById(id)
if (!node?.properties) return ''
const v = (node.properties as Record<string, unknown>)[
'Last Time Model File'
@@ -82,9 +85,9 @@ export class Preview3DPipelineContext {
}, nodeId)
}
async getCameraStateFromProperties(nodeId: string): Promise<unknown> {
async getCameraStateFromProperties(nodeId: NodeId): Promise<unknown> {
return this.comfyPage.page.evaluate((id) => {
const node = window.app!.graph.getNodeById(Number(id))
const node = window.app!.graph.getNodeById(id)
if (!node?.properties) return null
const cfg = (node.properties as Record<string, unknown>)['Camera Config']
if (cfg === null || typeof cfg !== 'object') return null

View File

@@ -7,6 +7,7 @@ import type {
} from '@/lib/litegraph/src/litegraph'
import type { ComfyWorkflow } from '@/platform/workflow/management/stores/comfyWorkflow'
import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema'
import { toNodeId } from '@/types/nodeId'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import { SubgraphEditor } from '@e2e/fixtures/components/SubgraphEditor'
@@ -549,6 +550,7 @@ export class SubgraphHelper {
}
static getTextSlotPosition(page: Page, nodeId: string) {
const localNodeId = toNodeId(nodeId)
return page.evaluate((id) => {
const node = window.app!.canvas.graph!.getNodeById(id)
if (!node) return null
@@ -565,7 +567,7 @@ export class SubgraphHelper {
}
}
return null
}, nodeId)
}, localNodeId)
}
static async expectWidgetBelowHeader(

View File

@@ -1,7 +1,7 @@
import { expect } from '@playwright/test'
import type { SerialisableLLink } from '@/lib/litegraph/src/types/serialisation'
import type { NodeId } from '@/platform/workflow/validation/schemas/workflowSchema'
import type { NodeId } from '@/types/nodeId'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import type { Position, Size } from '@e2e/fixtures/types'
import { VueNodeFixture } from '@e2e/fixtures/utils/vueNodeFixtures'

View File

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

View File

@@ -10,10 +10,11 @@ import { assetPath } from '@e2e/fixtures/utils/paths'
import { PropertiesPanelHelper } from '@e2e/tests/propertiesPanel/PropertiesPanelHelper'
import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema'
import { toNodeId } from '@/types/nodeId'
const PROMOTED_MODEL_WIDGET_NAME = 'ckpt_name'
export interface PromotedMissingModelWorkflow {
interface PromotedMissingModelWorkflow {
workflowName: string
hostNodeId: number
hostNodeTitle: string
@@ -418,7 +419,7 @@ async function enterSubgraphForStaleInteriorCheck(
throw new Error(`Expected visible subgraph node ${targetNodeId}`)
}
window.app!.canvas.setGraph(node.subgraph)
}, numericNodeId)
}, toNodeId(normalizedNodeId))
await comfyPage.nextFrame()
await comfyPage.vueNodes.waitForNodes()
}

View File

@@ -1,4 +1,5 @@
import type { NodeProperty } from '@/lib/litegraph/src/LGraphNode'
import { toNodeId } from '@/types/nodeId'
import { parsePreviewExposures } from '@/core/schemas/previewExposureSchema'
import type { PreviewExposure } from '@/core/schemas/previewExposureSchema'
@@ -44,6 +45,7 @@ export async function getPromotedWidgets(
comfyPage: ComfyPage,
nodeId: string
): Promise<PromotedWidgetEntry[]> {
const localNodeId = toNodeId(nodeId)
const { widgetSources, previewExposures } = await comfyPage.page.evaluate(
(id) => {
const node = window.app!.canvas.graph!.getNodeById(id)
@@ -91,7 +93,7 @@ export async function getPromotedWidgets(
})
return { widgetSources, previewExposures }
},
nodeId
localNodeId
)
const exposures = isNodeProperty(previewExposures)

View File

@@ -10,9 +10,10 @@ import {
STABLE_CHECKPOINT_2
} from '@e2e/fixtures/data/assetFixtures'
import { TestIds } from '@e2e/fixtures/selectors'
import { toNodeId } from '@/types/nodeId'
const WORKFLOW = 'missing/missing_model_promoted_widget'
const HOST_NODE_ID = 2
const HOST_NODE_ID = toNodeId(2)
const WIDGET_NAME = 'ckpt_name'
const SELECTED_MODEL = STABLE_CHECKPOINT_2.name

View File

@@ -1,5 +1,7 @@
import { expect } from '@playwright/test'
import { toNodeId } from '@/types/nodeId'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
test.beforeEach(async ({ comfyPage }) => {
@@ -39,16 +41,22 @@ test.describe('Graph', { tag: ['@smoke', '@canvas'] }, () => {
await comfyPage.workflow.loadWorkflow('links/duplicate_links_slot_drift')
function evaluateGraph() {
return comfyPage.page.evaluate(() => {
const nodeIds = {
switchCfg: toNodeId(120),
ksampler85: toNodeId(85),
ksampler86: toNodeId(86)
}
return comfyPage.page.evaluate((nodeIds) => {
const graph = window.app!.graph!
const subgraph = graph.subgraphs.values().next().value
if (!subgraph) return { error: 'No subgraph found' }
// Node 120 = Switch (CFG), connects to both KSamplerAdvanced 85 and 86
const switchCfg = subgraph.getNodeById(120)
const ksampler85 = subgraph.getNodeById(85)
const ksampler86 = subgraph.getNodeById(86)
const switchCfg = subgraph.getNodeById(nodeIds.switchCfg)
const ksampler85 = subgraph.getNodeById(nodeIds.ksampler85)
const ksampler86 = subgraph.getNodeById(nodeIds.ksampler86)
if (!switchCfg || !ksampler85 || !ksampler86)
return { error: 'Required nodes not found' }
@@ -74,7 +82,10 @@ test.describe('Graph', { tag: ['@smoke', '@canvas'] }, () => {
// Count links from Switch(CFG) to node 85 cfg (should be 1, not 2)
let cfgLinkToNode85Count = 0
for (const link of subgraph.links.values()) {
if (link.origin_id === 120 && link.target_id === 85)
if (
String(link.origin_id) === '120' &&
String(link.target_id) === '85'
)
cfgLinkToNode85Count++
}
@@ -89,7 +100,7 @@ test.describe('Graph', { tag: ['@smoke', '@canvas'] }, () => {
switchOutputLinkCount,
cfgLinkToNode85Count
}
})
}, nodeIds)
}
// Poll graph state once, then assert all properties

View File

@@ -4,6 +4,9 @@ import { expect } from '@playwright/test'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
import { TestIds } from '@e2e/fixtures/selectors'
import { toNodeId } from '@/types/nodeId'
const IMAGE_COMPARE_NODE_ID = toNodeId(1)
test.describe('Image Compare', { tag: ['@widget', '@vue-nodes'] }, () => {
test.beforeEach(async ({ comfyPage }) => {
@@ -29,15 +32,15 @@ test.describe('Image Compare', { tag: ['@widget', '@vue-nodes'] }, () => {
}
) {
await comfyPage.page.evaluate(
({ value }) => {
const node = window.app!.graph.getNodeById(1)
({ nodeId, value }) => {
const node = window.app!.graph.getNodeById(nodeId)
const widget = node?.widgets?.find((w) => w.type === 'imagecompare')
if (widget) {
widget.value = value
widget.callback?.(value)
}
},
{ value }
{ nodeId: IMAGE_COMPARE_NODE_ID, value }
)
await comfyPage.nextFrame()
}
@@ -450,11 +453,11 @@ test.describe('Image Compare', { tag: ['@widget', '@vue-nodes'] }, () => {
test('ImageCompare node enforces minimum size', async ({ comfyPage }) => {
const minWidth = 400
const minHeight = 350
const size = await comfyPage.page.evaluate(() => {
const graphNode = window.app!.graph.getNodeById(1)
const size = await comfyPage.page.evaluate((nodeId) => {
const graphNode = window.app!.graph.getNodeById(nodeId)
if (!graphNode?.size) return null
return { width: graphNode.size[0], height: graphNode.size[1] }
})
}, IMAGE_COMPARE_NODE_ID)
expect(
size,
'ImageCompare node id 1 must exist in loaded workflow graph'
@@ -600,15 +603,15 @@ test.describe('Image Compare', { tag: ['@widget', '@vue-nodes'] }, () => {
}) => {
const url = createTestImageDataUrl('Legacy', '#c00')
await comfyPage.page.evaluate(
({ url }) => {
const node = window.app!.graph.getNodeById(1)
({ nodeId, url }) => {
const node = window.app!.graph.getNodeById(nodeId)
const widget = node?.widgets?.find((w) => w.type === 'imagecompare')
if (widget) {
widget.value = url
widget.callback?.(url)
}
},
{ url }
{ nodeId: IMAGE_COMPARE_NODE_ID, url }
)
await comfyPage.nextFrame()

View File

@@ -1,6 +1,7 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
import { toNodeId } from '@/types/nodeId'
test.describe('Image Crop', () => {
test.beforeEach(async ({ comfyPage }) => {
@@ -95,15 +96,15 @@ test.describe('Image Crop', () => {
const newBounds = { x: 50, y: 100, width: 200, height: 300 }
await comfyPage.page.evaluate(
({ bounds }) => {
const node = window.app!.graph.getNodeById(1)
({ nodeId, bounds }) => {
const node = window.app!.graph.getNodeById(nodeId)
const widget = node?.widgets?.find((w) => w.type === 'imagecrop')
if (widget) {
widget.value = bounds
widget.callback?.(bounds)
}
},
{ bounds: newBounds }
{ nodeId: toNodeId(1), bounds: newBounds }
)
await comfyPage.nextFrame()

View File

@@ -2,15 +2,16 @@ import { expect } from '@playwright/test'
import type { Page } from '@playwright/test'
import { load3dTest as test } from '@e2e/fixtures/helpers/Load3DFixtures'
import { toNodeId } from '@/types/nodeId'
const getGizmoConfig = (page: Page) =>
page.evaluate(() => {
const n = window.app!.graph.getNodeById(1)
page.evaluate((nodeId) => {
const n = window.app!.graph.getNodeById(nodeId)
const modelConfig = n?.properties?.['Model Config'] as
| { gizmo?: { enabled: boolean; mode: string } }
| undefined
return modelConfig?.gizmo
})
}, toNodeId(1))
test.describe('Load3D Gizmo Controls', () => {
test(

View File

@@ -2,6 +2,7 @@ import { expect } from '@playwright/test'
import { assetPath } from '@e2e/fixtures/utils/paths'
import { load3dTest as test } from '@e2e/fixtures/helpers/Load3DFixtures'
import { toNodeId } from '@/types/nodeId'
test.describe('Load3D', () => {
test(
@@ -67,13 +68,13 @@ test.describe('Load3D', () => {
await expect
.poll(() =>
comfyPage.page.evaluate(() => {
const n = window.app!.graph.getNodeById(1)
comfyPage.page.evaluate((nodeId) => {
const n = window.app!.graph.getNodeById(nodeId)
const config = n?.properties?.['Scene Config'] as
| Record<string, string>
| undefined
return config?.backgroundColor
})
}, toNodeId(1))
)
.toBe('#cc3333')

View File

@@ -3,6 +3,7 @@ import { expect, mergeTests } from '@playwright/test'
import { ExecutionHelper } from '@e2e/fixtures/helpers/ExecutionHelper'
import { maskEditorTest as test } from '@e2e/fixtures/helpers/MaskEditorHelper'
import { webSocketFixture } from '@e2e/fixtures/ws'
import { toNodeId } from '@/types/nodeId'
const wstest = mergeTests(test, webSocketFixture)
@@ -331,7 +332,8 @@ wstest(
async function getNodeOutput() {
return await comfyPage.page.evaluate(
() => graph!.getNodeById('1')!.images?.[0]?.filename
(nodeId) => graph!.getNodeById(nodeId)!.images?.[0]?.filename,
toNodeId(1)
)
}

View File

@@ -2,6 +2,8 @@ import {
comfyExpect as expect,
comfyPageFixture as test
} from '@e2e/fixtures/ComfyPage'
import { toNodeId } from '@/types/nodeId'
import type { SerializedNodeId } from '@/types/nodeId'
type ComfyPage = Parameters<Parameters<typeof test>[2]>[0]['comfyPage']
@@ -32,12 +34,13 @@ async function addGhostAtCenter(comfyPage: ComfyPage) {
return { nodeId: nodeRef.id, centerX, centerY }
}
function getNodeById(comfyPage: ComfyPage, nodeId: number | string) {
function getNodeById(comfyPage: ComfyPage, nodeId: SerializedNodeId) {
const localNodeId = toNodeId(nodeId)
return comfyPage.page.evaluate((id) => {
const node = window.app!.graph.getNodeById(id)
if (!node) return null
return { ghost: !!node.flags.ghost }
}, nodeId)
}, localNodeId)
}
for (const mode of ['litegraph', 'vue'] as const) {

View File

@@ -12,6 +12,7 @@ import {
setupNodeReplacement
} from '@e2e/fixtures/helpers/NodeReplacementHelper'
import { TestIds } from '@e2e/fixtures/selectors'
import { toNodeId } from '@/types/nodeId'
const renderModes = [
{ name: 'vue nodes', vueNodesEnabled: true },
@@ -245,8 +246,10 @@ test.describe('Node replacement', { tag: ['@node', '@ui'] }, () => {
.click()
const replacedNodeOutputLinkCount = await comfyPage.page.evaluate(
() =>
window.app!.graph!.getNodeById(2)?.outputs[0]?.links?.length ?? 0
(nodeId) =>
window.app!.graph!.getNodeById(nodeId)?.outputs[0]?.links
?.length ?? 0,
toNodeId(2)
)
expect(
replacedNodeOutputLinkCount,

View File

@@ -23,6 +23,7 @@ import {
selectVueAssetPromotedModel
} from '@e2e/fixtures/utils/promotedMissingModel'
import { TestIds } from '@e2e/fixtures/selectors'
import { toNodeId } from '@/types/nodeId'
import { PropertiesPanelHelper } from '@e2e/tests/propertiesPanel/PropertiesPanelHelper'
import type { AssetMetadata } from '@/platform/assets/schemas/assetSchema'
@@ -384,11 +385,11 @@ test.describe(
await expect
.poll(() =>
comfyPage.page.evaluate(() => {
const node = window.app!.graph.getNodeById(1)
comfyPage.page.evaluate((nodeId) => {
const node = window.app!.graph.getNodeById(nodeId)
return node?.widgets?.find((widget) => widget.name === 'ckpt_name')
?.value
})
}, toNodeId(1))
)
.toBe(CLOUD_IMPORTED_CANONICAL_MODEL_NAME)
})

View File

@@ -4,15 +4,16 @@ import {
} from '@e2e/fixtures/ComfyPage'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import type { Position } from '@e2e/fixtures/types'
import type { NodeId } from '@/types/nodeId'
type NodeSnapshot = { id: number } & Position
type NodeSnapshot = { id: NodeId } & Position
async function getAllNodePositions(
comfyPage: ComfyPage
): Promise<NodeSnapshot[]> {
return comfyPage.page.evaluate(() =>
window.app!.graph.nodes.map((n) => ({
id: n.id as number,
id: n.id,
x: n.pos[0],
y: n.pos[1]
}))
@@ -21,7 +22,7 @@ async function getAllNodePositions(
async function getNodePosition(
comfyPage: ComfyPage,
nodeId: number
nodeId: NodeId
): Promise<Position | undefined> {
return comfyPage.page.evaluate((targetNodeId) => {
const node = window.app!.graph.nodes.find((n) => n.id === targetNodeId)

View File

@@ -1095,6 +1095,33 @@ test.describe('Assets sidebar - drag and drop', () => {
const fileComboWidget = await nodes[0].getWidget(0)
await expect.poll(() => fileComboWidget.getValue()).toBe('test.png [temp]')
})
test('Loading as workflow reuses asset name', async ({ comfyPage }) => {
await comfyPage.assets.mockOutputHistory([
createMockJob({
id: 'job',
preview_output: {
filename: `testimage.png`,
type: 'temp',
nodeId: '1',
mediaType: 'images'
}
})
])
const path = comfyPage.assetPath('workflowInMedia/workflow.webp')
await comfyPage.page.route('**/view?**', (route) => route.fulfill({ path }))
const { assetsTab } = comfyPage.menu
await assetsTab.open()
await assetsTab.waitForAssets()
await expect(assetsTab.assetCards).toHaveCount(1)
const targetPosition = { x: 400, y: 100 }
await assetsTab.assetCards.dragTo(comfyPage.canvas, { targetPosition })
const getTabName = () => comfyPage.menu.topbar.getActiveTabName()
await expect.poll(getTabName).toContain('testimage')
})
})
test('Insert as node', { tag: '@vue-nodes' }, async ({ comfyPage }) => {

View File

@@ -3,6 +3,7 @@ import { expect, mergeTests } from '@playwright/test'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import { comfyPageFixture } from '@e2e/fixtures/ComfyPage'
import { subgraphBreadcrumbFixture } from '@e2e/fixtures/helpers/SubgraphBreadcrumbHelper'
import { toNodeId } from '@/types/nodeId'
const test = mergeTests(comfyPageFixture, subgraphBreadcrumbFixture)
@@ -198,7 +199,7 @@ test.describe('Subgraph Breadcrumb', { tag: ['@subgraph'] }, () => {
const rootNodeTitle = await comfyPage.page.evaluate(
(nodeId) => window.app!.graph!.getNodeById(nodeId)?.title ?? null,
OUTER_SUBGRAPH_NODE_ID_IN_NESTED
toNodeId(OUTER_SUBGRAPH_NODE_ID_IN_NESTED)
)
expect(rootNodeTitle).toBe(newName)
})

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -2,6 +2,7 @@ import type { Meta, StoryObj } from '@storybook/vue3-vite'
import ErrorNodeCard from './ErrorNodeCard.vue'
import type { ErrorCardData } from './types'
import { createNodeExecutionId } from '@/types/nodeIdentification'
import { toNodeId } from '@/types/nodeId'
const meta: Meta<typeof ErrorNodeCard> = {
title: 'RightSidePanel/Errors/ErrorNodeCard',
@@ -24,7 +25,7 @@ type Story = StoryObj<typeof meta>
const singleErrorCard: ErrorCardData = {
id: 'node-10',
title: 'CLIPTextEncode',
nodeId: createNodeExecutionId([10]),
nodeId: createNodeExecutionId([toNodeId(10)]),
nodeTitle: 'CLIP Text Encode (Prompt)',
errors: [
{
@@ -37,7 +38,7 @@ const singleErrorCard: ErrorCardData = {
const multipleErrorsCard: ErrorCardData = {
id: 'node-24',
title: 'VAEDecode',
nodeId: createNodeExecutionId([24]),
nodeId: createNodeExecutionId([toNodeId(24)]),
nodeTitle: 'VAE Decode',
errors: [
{
@@ -54,7 +55,7 @@ const multipleErrorsCard: ErrorCardData = {
const runtimeErrorCard: ErrorCardData = {
id: 'exec-45',
title: 'KSampler',
nodeId: createNodeExecutionId([45]),
nodeId: createNodeExecutionId([toNodeId(45)]),
nodeTitle: 'KSampler',
errors: [
{
@@ -70,6 +71,19 @@ const runtimeErrorCard: ErrorCardData = {
]
}
const subgraphErrorCard: ErrorCardData = {
id: 'node-3:15',
title: 'KSampler',
nodeId: createNodeExecutionId([toNodeId(3), toNodeId(15)]),
nodeTitle: 'Nested KSampler',
errors: [
{
message: 'Latent input is required.',
details: ''
}
]
}
const promptOnlyCard: ErrorCardData = {
id: '__prompt__',
title: 'Prompt has no outputs.',
@@ -87,6 +101,12 @@ export const SingleValidationError: Story = {
}
}
export const NestedNodeError: Story = {
args: {
card: subgraphErrorCard
}
}
/** Multiple validation errors on one node */
export const MultipleErrors: Story = {
args: {

View File

@@ -7,6 +7,7 @@ import { createI18n } from 'vue-i18n'
import ErrorNodeCard from './ErrorNodeCard.vue'
import type { ErrorCardData } from './types'
import { createNodeExecutionId } from '@/types/nodeIdentification'
import { toNodeId } from '@/types/nodeId'
const mockGetLogs = vi.fn(() => Promise.resolve('mock server logs'))
const mockSerialize = vi.fn(() => ({ nodes: [] }))
@@ -156,7 +157,7 @@ describe('ErrorNodeCard.vue', () => {
return {
id: `exec-${++cardIdCounter}`,
title: 'KSampler',
nodeId: createNodeExecutionId([10]),
nodeId: createNodeExecutionId([toNodeId(10)]),
nodeTitle: 'KSampler',
errors: [
{
@@ -249,7 +250,7 @@ describe('ErrorNodeCard.vue', () => {
renderCard({
id: `node-${++cardIdCounter}`,
title: 'KSampler',
nodeId: createNodeExecutionId([10]),
nodeId: createNodeExecutionId([toNodeId(10)]),
nodeTitle: 'KSampler',
errors: [
{
@@ -387,7 +388,7 @@ describe('ErrorNodeCard.vue', () => {
const card: ErrorCardData = {
id: `exec-${++cardIdCounter}`,
title: 'KSampler',
nodeId: createNodeExecutionId([10]),
nodeId: createNodeExecutionId([toNodeId(10)]),
nodeTitle: 'KSampler',
errors: [
{

View File

@@ -6,6 +6,7 @@ import type { useSystemStatsStore } from '@/stores/systemStatsStore'
import type { ErrorCardData } from './types'
import { createNodeExecutionId } from '@/types/nodeIdentification'
import { useErrorReport } from './useErrorReport'
import { toNodeId } from '@/types/nodeId'
async function flushPromises() {
await new Promise((resolve) => setTimeout(resolve, 0))
@@ -104,7 +105,7 @@ function makeCard(overrides: Partial<ErrorCardData> = {}): ErrorCardData {
return {
id: 'card-1',
title: 'KSampler',
nodeId: createNodeExecutionId([42]),
nodeId: createNodeExecutionId([toNodeId(42)]),
errors: [],
...overrides
}
@@ -182,7 +183,7 @@ describe('useErrorReport', () => {
exceptionType: 'RuntimeError',
exceptionMessage: 'CUDA oom',
traceback: 'trace-0',
nodeId: createNodeExecutionId([42]),
nodeId: createNodeExecutionId([toNodeId(42)]),
nodeType: 'KSampler',
systemStats: sampleSystemStats,
serverLogs: 'server logs',

View File

@@ -17,6 +17,7 @@ import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import type { NodeExecutionId } from '@/types/nodeIdentification'
import { toNodeId } from '@/types/nodeId'
import { getExecutionIdByNode } from '@/utils/graphTraversalUtil'
import SectionWidgets from './SectionWidgets.vue'
@@ -81,7 +82,7 @@ function createHostWithPromotedModel(): {
graph.add(host)
const sourceNode = new LGraphNode('CheckpointLoaderSimple')
sourceNode.id = 42
sourceNode.id = toNodeId(42)
const sourceInput = sourceNode.addInput('ckpt_name', 'COMBO')
const sourceWidget = sourceNode.addWidget(
'combo',

View File

@@ -151,10 +151,11 @@ function isWidgetShownOnParents(
const source = widgetPromotedSource(widgetNode, widget)
return parents.some((parent) => {
if (source) {
const widgetNodeId = widgetNode.id
const interiorNodeId =
String(widgetNode.id) === String(parent.id)
? source.nodeId
: String(widgetNode.id)
: widgetNodeId
return isWidgetPromotedOnSubgraphNode(parent, {
sourceNodeId: interiorNodeId,
@@ -162,7 +163,7 @@ function isWidgetShownOnParents(
})
}
return isWidgetPromotedOnSubgraphNode(parent, {
sourceNodeId: String(widgetNode.id),
sourceNodeId: widgetNode.id,
sourceWidgetName: widget.name
})
})

View File

@@ -4,11 +4,12 @@ import { computed, reactive, ref, shallowRef, watch } from 'vue'
import CollapseToggleButton from '@/components/rightSidePanel/layout/CollapseToggleButton.vue'
import type { LGraphNode, NodeId } from '@/lib/litegraph/src/litegraph'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import AsyncSearchInput from '@/components/ui/search-input/AsyncSearchInput.vue'
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
import type { NodeId } from '@/types/nodeId'
import { computedSectionDataList, searchWidgetsAndNodes } from '../shared'
import type { NodeWidgetsListList } from '../shared'

View File

@@ -3,11 +3,12 @@ import { storeToRefs } from 'pinia'
import { computed, reactive, ref, shallowRef, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import type { LGraphNode, NodeId } from '@/lib/litegraph/src/litegraph'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import CollapseToggleButton from '@/components/rightSidePanel/layout/CollapseToggleButton.vue'
import AsyncSearchInput from '@/components/ui/search-input/AsyncSearchInput.vue'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
import type { NodeId } from '@/types/nodeId'
import { computedSectionDataList, searchWidgetsAndNodes } from '../shared'
import type { NodeWidgetsListList } from '../shared'
@@ -38,7 +39,11 @@ const advancedWidgetsSectionDataList = computed((): NodeWidgetsListList => {
const advancedWidgets = widgets
.filter(
(w) =>
!(w.options?.canvasOnly || w.options?.hidden) && w.options?.advanced
!(
w.options?.canvasOnly ||
w.options?.hidden ||
w.options?.hideInPanel
) && w.options?.advanced
)
.map((widget) => ({ node, widget }))
return { widgets: advancedWidgets, node }

View File

@@ -82,7 +82,7 @@ const advancedInputsWidgets = computed((): NodeWidgetsList => {
return allInteriorWidgets.filter(
({ node: interiorNode, widget }) =>
!isWidgetPromotedOnSubgraphNode(node, {
sourceNodeId: String(interiorNode.id),
sourceNodeId: interiorNode.id,
sourceWidgetName: getWidgetName(widget)
})
)

View File

@@ -90,9 +90,10 @@ function handleHideInput() {
const source = widgetPromotedSource(node, widget)
if (source) {
const currentNodeId = node.id
for (const parent of parents) {
const sourceNodeId =
String(node.id) === String(parent.id) ? source.nodeId : String(node.id)
String(node.id) === String(parent.id) ? source.nodeId : currentNodeId
demotePromotedInput(parent, {
sourceNodeId,
sourceWidgetName: source.widgetName

View File

@@ -10,6 +10,7 @@ import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
import { widgetId } from '@/types/widgetId'
import WidgetItem from './WidgetItem.vue'
import { toNodeId } from '@/types/nodeId'
const { mockGetInputSpecForWidget, StubWidgetComponent } = vi.hoisted(() => ({
mockGetInputSpecForWidget: vi.fn(),
@@ -145,7 +146,7 @@ describe('WidgetItem', () => {
const expectedOptions = {
values: ['model_a.safetensors', 'model_b.safetensors']
}
const id = widgetId('test-graph-id', 1, 'ckpt_name')
const id = widgetId('test-graph-id', toNodeId(1), 'ckpt_name')
const widget = createMockWidget({ widgetId: id, name: 'ckpt_name' })
useWidgetValueStore().registerWidget(id, {
type: 'combo',
@@ -160,7 +161,7 @@ describe('WidgetItem', () => {
})
it('passes type from widget state to the widget component', () => {
const id = widgetId('test-graph-id', 1, 'ckpt_name')
const id = widgetId('test-graph-id', toNodeId(1), 'ckpt_name')
const widget = createMockWidget({ widgetId: id, type: 'string' })
useWidgetValueStore().registerWidget(id, {
type: 'combo',
@@ -175,7 +176,7 @@ describe('WidgetItem', () => {
})
it('passes name from widget state to the widget component', () => {
const id = widgetId('test-graph-id', 1, 'ckpt_name')
const id = widgetId('test-graph-id', toNodeId(1), 'ckpt_name')
const widget = createMockWidget({ widgetId: id, name: 'source_name' })
useWidgetValueStore().registerWidget(id, {
type: 'combo',
@@ -190,7 +191,7 @@ describe('WidgetItem', () => {
})
it('passes value from widget state to the widget component', () => {
const id = widgetId('test-graph-id', 1, 'ckpt_name')
const id = widgetId('test-graph-id', toNodeId(1), 'ckpt_name')
const widget = createMockWidget({ widgetId: id, value: 'source value' })
useWidgetValueStore().registerWidget(id, {
type: 'combo',

View File

@@ -70,7 +70,7 @@ const widgetComponent = computed(() => {
const isLinked = computed(() => {
const safeWidget = useVueNodeLifecycle()
.nodeManager.value?.vueNodeData.get(String(node.id))
.nodeManager.value?.vueNodeData.get(node.id)
?.widgets?.find((w) => w.name === widget.name)
return safeWidget?.slotMetadata
? !!safeWidget.slotMetadata.linked
@@ -79,10 +79,10 @@ const isLinked = computed(() => {
const simplifiedWidget = computed((): SimplifiedWidget => {
const graphId = node.graph?.rootGraph?.id
const bareNodeId = stripGraphPrefix(String(node.id))
const bareNodeId = stripGraphPrefix(node.id)
const widgetState = widget.widgetId
? useWidgetValueStore().getWidget(widget.widgetId)
: graphId
: graphId && bareNodeId
? widgetValueStore.getWidget(widgetId(graphId, bareNodeId, widget.name))
: undefined
const widgetName = widgetState?.name ?? widget.name
@@ -212,7 +212,7 @@ const displayLabel = customRef((track, trigger) => {
:is="widgetComponent"
v-model="widgetValue"
:widget="simplifiedWidget"
:node-id="String(node.id)"
:node-id="node.id"
:node-type="node.type"
:class="cn('col-span-1', shouldExpand(widget.type) && 'min-h-36')"
/>

View File

@@ -1,24 +1,40 @@
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { LGraphGroup } from '@/lib/litegraph/src/LGraphGroup'
import { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import type { Positionable } from '@/lib/litegraph/src/interfaces'
import type {
IBaseWidget,
IWidgetOptions
} from '@/lib/litegraph/src/types/widgets'
import { toNodeId } from '@/types/nodeId'
import { describe, expect, it, beforeEach } from 'vitest'
import { flatAndCategorizeSelectedItems, searchWidgets } from './shared'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import {
computedSectionDataList,
flatAndCategorizeSelectedItems,
searchWidgets,
searchWidgetsAndNodes
} from './shared'
import type { NodeWidgetsListList } from './shared'
describe('searchWidgets', () => {
const createWidget = (
function createWidget(
name: string,
type: string,
value?: string,
label?: string
): { widget: IBaseWidget } => ({
widget: {
name,
type,
value,
label
} as IBaseWidget
})
): { widget: IBaseWidget } {
return {
widget: {
name,
options: {},
type,
value,
label,
y: 0
}
}
}
it('should return all widgets when query is empty', () => {
const widgets = [
@@ -71,6 +87,99 @@ describe('searchWidgets', () => {
})
})
describe('searchWidgetsAndNodes', () => {
function createWidget(name: string): IBaseWidget {
return {
name,
options: {},
type: 'number',
y: 0
}
}
function createNodeSection(
id: number,
title: string,
widgetNames: string[]
): NodeWidgetsListList[number] {
const node = new LGraphNode(title)
node.id = toNodeId(id)
const widgets = widgetNames.map((name) => ({
node,
widget: createWidget(name)
}))
return { node, widgets }
}
it('keeps all widgets for matching nodes and filters widgets for other nodes', () => {
const matchingNode = createNodeSection(1, 'Image Size', ['width', 'height'])
const matchingWidget = createNodeSection(2, 'Sampler', [
'seed',
'imageQuality'
])
const hiddenNode = createNodeSection(3, 'Preview', ['scale'])
const result = searchWidgetsAndNodes(
[matchingNode, matchingWidget, hiddenNode],
'image'
)
expect(result).toEqual([
matchingNode,
{
...matchingWidget,
widgets: [matchingWidget.widgets[1]]
}
])
})
})
describe('computedSectionDataList', () => {
beforeEach(() => {
setActivePinia(createTestingPinia())
})
function createWidget(
name: string,
options: IWidgetOptions = {}
): IBaseWidget {
return { name, type: 'number', options, y: 0 } as IBaseWidget
}
it('omits hideInPanel widgets while keeping the rest on the node', () => {
const node = new LGraphNode('Load3D')
node.widgets = [
createWidget('seed'),
createWidget('viewport', { hideInPanel: true })
]
const { widgetsSectionDataList } = computedSectionDataList([node])
const shownNames = widgetsSectionDataList.value[0].widgets.map(
({ widget }) => widget.name
)
expect(shownNames).toEqual(['seed'])
})
it('hides canvasOnly, hidden, and hideInPanel widgets from the panel', () => {
const node = new LGraphNode('Load3D')
node.widgets = [
createWidget('seed'),
createWidget('preview', { canvasOnly: true }),
createWidget('internal', { hidden: true }),
createWidget('viewport', { hideInPanel: true })
]
const { widgetsSectionDataList } = computedSectionDataList([node])
const shownNames = widgetsSectionDataList.value[0].widgets.map(
({ widget }) => widget.name
)
expect(shownNames).toEqual(['seed'])
})
})
describe('flatAndCategorizeSelectedItems', () => {
let testGroup1: LGraphGroup
let testGroup2: LGraphGroup

View File

@@ -5,8 +5,9 @@ import type { IFuseOptions } from 'fuse.js'
import type { Positionable } from '@/lib/litegraph/src/interfaces'
import type { LGraphGroup } from '@/lib/litegraph/src/LGraphGroup'
import type { LGraphNode, NodeId } from '@/lib/litegraph/src/LGraphNode'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import type { NodeId } from '@/types/nodeId'
import { isLGraphGroup, isLGraphNode } from '@/utils/litegraphUtil'
import { useSettingStore } from '@/platform/settings/settingStore'
@@ -107,18 +108,14 @@ export function searchWidgetsAndNodes(
nodeMatches.map((result) => result.item.nodeId)
)
return list
.map((item) => {
if (matchedNodeIds.has(item.node.id)) {
return { ...item, keep: true }
}
return {
...item,
keep: false,
widgets: searchWidgets(item.widgets, query)
}
})
.filter((item) => item.keep || item.widgets.length > 0)
return list.flatMap((item) => {
if (matchedNodeIds.has(item.node.id)) {
return [item]
}
const widgets = searchWidgets(item.widgets, query)
return widgets.length > 0 ? [{ ...item, widgets }] : []
})
}
type MixedSelectionItem = LGraphGroup | LGraphNode
@@ -266,6 +263,7 @@ export function computedSectionDataList(nodes: MaybeRefOrGetter<LGraphNode[]>) {
!(
w.options?.canvasOnly ||
w.options?.hidden ||
w.options?.hideInPanel ||
(w.options?.advanced && !includesAdvanced.value)
)
)

View File

@@ -32,6 +32,7 @@ import AsyncSearchInput from '@/components/ui/search-input/AsyncSearchInput.vue'
import { useLitegraphService } from '@/services/litegraphService'
import { usePreviewExposureStore } from '@/stores/previewExposureStore'
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
import { UNASSIGNED_NODE_ID } from '@/types/nodeId'
import { cn } from '@comfyorg/tailwind-utils'
import SubgraphNodeWidget from './SubgraphNodeWidget.vue'
@@ -116,7 +117,7 @@ function getActivePreviewRows(node: SubgraphNode): PreviewRow[] {
const rootGraphId = node.rootGraph.id
const exposures = previewExposureStore.getExposures(rootGraphId, hostLocator)
return exposures.flatMap((exposure): PreviewRow[] => {
const sourceNode = node.subgraph._nodes_by_id[exposure.sourceNodeId]
const sourceNode = node.subgraph.getNodeById(exposure.sourceNodeId)
if (!sourceNode) return []
const realWidget = getPromotableWidgets(sourceNode).find(
(candidate) => candidate.name === exposure.sourcePreviewName
@@ -248,7 +249,7 @@ function rowDisplayName(row: ActiveRow): string {
function isRowLinked(row: ActiveRow): boolean {
if (row.kind !== 'promoted') return false
if (row.node.id === -1) return true
if (row.node.id === UNASSIGNED_NODE_ID) return true
const source = promotedRowSource(row)
return (
!!activeNode.value &&

View File

@@ -4,8 +4,8 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick } from 'vue'
import { createI18n } from 'vue-i18n'
import type { NodeId } from '@/platform/workflow/validation/schemas/workflowSchema'
import type { ResultItemImpl } from '@/stores/queueStore'
import type { SerializedNodeId } from '@/types/nodeId'
import MediaLightbox from './MediaLightbox.vue'
@@ -28,7 +28,7 @@ type MockResultItem = Partial<ResultItemImpl> & {
filename: string
subfolder: string
type: string
nodeId: NodeId
nodeId: SerializedNodeId
mediaType: string
id?: string
url?: string
@@ -63,7 +63,7 @@ describe('MediaLightbox', () => {
filename: 'image1.jpg',
subfolder: 'outputs',
type: 'output',
nodeId: '123' as NodeId,
nodeId: '123',
mediaType: 'images',
isImage: true,
isVideo: false,
@@ -75,7 +75,7 @@ describe('MediaLightbox', () => {
filename: 'image2.jpg',
subfolder: 'outputs',
type: 'output',
nodeId: '456' as NodeId,
nodeId: '456',
mediaType: 'images',
isImage: true,
isVideo: false,
@@ -87,7 +87,7 @@ describe('MediaLightbox', () => {
filename: 'image3.jpg',
subfolder: 'outputs',
type: 'output',
nodeId: '789' as NodeId,
nodeId: '789',
mediaType: 'images',
isImage: true,
isVideo: false,

View File

@@ -6,6 +6,7 @@ import { defineComponent, h, nextTick, ref, shallowRef } from 'vue'
import { useBoundingBoxes } from './useBoundingBoxes'
import type { BoundingBox } from '@/types/boundingBoxes'
import { toNodeId } from '@/types/nodeId'
const { appState } = vi.hoisted(() => ({
appState: { node: null as unknown }
@@ -103,7 +104,7 @@ function setup(initial: BoundingBox[] = []) {
const canvasContainer = shallowRef<HTMLDivElement | null>(null)
const inlineEditorEl = shallowRef<HTMLTextAreaElement | null>(null)
const modelValue = ref(initial)
const api = useBoundingBoxes('1', {
const api = useBoundingBoxes(toNodeId('1'), {
canvasEl,
canvasContainer,
inlineEditorEl,

View File

@@ -18,6 +18,7 @@ import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { app } from '@/scripts/app'
import { useNodeOutputStore } from '@/stores/nodeOutputStore'
import type { BoundingBox } from '@/types/boundingBoxes'
import type { NodeId } from '@/types/nodeId'
import { readableTextColor, textOnColor } from '@/utils/colorUtil'
const HANDLE_PX = 8
@@ -39,7 +40,7 @@ interface UseBoundingBoxesOptions {
}
export function useBoundingBoxes(
nodeId: string,
nodeId: NodeId,
{
canvasEl,
canvasContainer,
@@ -63,9 +64,7 @@ export function useBoundingBoxes(
nodeId && app.canvas?.graph ? app.canvas.graph.getNodeById(nodeId) : null
)
const { selectedNodeIds } = storeToRefs(useCanvasStore())
const isNodeSelected = computed(() =>
selectedNodeIds.value.has(String(nodeId))
)
const isNodeSelected = computed(() => selectedNodeIds.value.has(nodeId))
function dimWidget(name: 'width' | 'height'): number | undefined {
const v = litegraphNode.value?.widgets?.find((w) => w.name === name)?.value

View File

@@ -11,6 +11,7 @@ import {
} from '@/lib/litegraph/src/litegraph'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import type { NodeId } from '@/renderer/core/layout/types'
import { toNodeId } from '@/types/nodeId'
import type { ReadOnlyRect } from '@/lib/litegraph/src/interfaces'
const mockApp = vi.hoisted(() => ({
@@ -38,7 +39,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 = id
node.id = toNodeId(id)
node.mode = mode
return node
}
@@ -69,7 +70,7 @@ class MockNode implements Positionable {
) {
this.pos = pos
this.size = size
this.id = 'mock-node'
this.id = toNodeId('mock-node')
this.boundingRect = [0, 0, 0, 0]
}

View File

@@ -0,0 +1,91 @@
import { render } from '@testing-library/vue'
import { createPinia, setActivePinia } from 'pinia'
import { defineComponent, h, markRaw, ref } from 'vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useSelectionToolboxPosition } from '@/composables/canvas/useSelectionToolboxPosition'
import type { Positionable } from '@/lib/litegraph/src/interfaces'
import type { LGraphCanvas } from '@/lib/litegraph/src/litegraph'
import {
LGraphGroup,
LGraphNode,
LiteGraph
} from '@/lib/litegraph/src/litegraph'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { toNodeId } from '@/types/nodeId'
const mockApp = vi.hoisted(() => ({
canvas: null
}))
vi.mock('@/scripts/app', () => ({ app: mockApp }))
vi.mock('@/composables/useVueFeatureFlags', () => ({
useVueFeatureFlags: () => ({
shouldRenderVueNodes: { value: false }
})
}))
describe('useSelectionToolboxPosition', () => {
let canvasStore: ReturnType<typeof useCanvasStore>
beforeEach(() => {
setActivePinia(createPinia())
canvasStore = useCanvasStore()
})
function renderToolboxForSelection(item: Positionable) {
canvasStore.canvas = markRaw({
canvas: document.createElement('canvas'),
ds: {
offset: [0, 0],
scale: 1
},
selectedItems: new Set([item]),
state: {
draggingItems: false,
selectionChanged: true
}
} as Partial<LGraphCanvas> as LGraphCanvas)
let toolbox: HTMLElement | undefined
const TestHarness = defineComponent({
setup() {
const toolboxRef = ref<HTMLElement>(document.createElement('div'))
toolbox = toolboxRef.value
useSelectionToolboxPosition(toolboxRef)
return () => h('div')
}
})
const wrapper = render(TestHarness)
if (!toolbox) throw new Error('Toolbox element was not initialized')
return { toolbox, unmount: wrapper.unmount }
}
it('positions groups from their unchanged bounds', () => {
const group = new LGraphGroup('Group', 1)
group.pos = [100, 200]
group.size = [160, 80]
const { toolbox, unmount } = renderToolboxForSelection(group)
expect(toolbox.style.getPropertyValue('--tb-y')).toBe('190px')
unmount()
})
it('positions nodes from bounds that include the title bar', () => {
const node = new LGraphNode('Node')
node.id = toNodeId(1)
node.pos = [100, 200]
node.size = [160, 80]
const { toolbox, unmount } = renderToolboxForSelection(node)
expect(toolbox.style.getPropertyValue('--tb-y')).toBe(
`${190 - LiteGraph.NODE_TITLE_HEIGHT}px`
)
unmount()
})
})

View File

@@ -48,6 +48,42 @@ function currentSelectionMatchesSignature(
return buildSelectionSignature(store) === moreOptionsSelectionSignature
}
function getFullNodeBounds(item: LGraphNode | LGraphGroup): ReadOnlyRect {
if (item instanceof LGraphGroup) {
return [item.pos[0], item.pos[1], item.size[0], item.size[1]]
}
return [
item.pos[0],
item.pos[1] - LiteGraph.NODE_TITLE_HEIGHT,
item.size[0],
item.size[1] + LiteGraph.NODE_TITLE_HEIGHT
]
}
function getVueNodeBounds(item: LGraphNode): ReadOnlyRect | null {
const layout = layoutStore.getNodeLayoutRef(item.id).value
if (!layout) return null
return [
layout.bounds.x,
layout.bounds.y - LiteGraph.NODE_TITLE_HEIGHT,
layout.bounds.width,
layout.bounds.height + LiteGraph.NODE_TITLE_HEIGHT
]
}
function getSelectionBounds(
item: LGraphNode | LGraphGroup,
shouldUseVueLayout: boolean
): ReadOnlyRect {
if (shouldUseVueLayout && item instanceof LGraphNode) {
return getVueNodeBounds(item) ?? getFullNodeBounds(item)
}
return getFullNodeBounds(item)
}
export function useSelectionToolboxPosition(
toolboxRef: Ref<HTMLElement | undefined>
) {
@@ -99,27 +135,8 @@ export function useSelectionToolboxPosition(
// Skip items without valid IDs
if (item.id == null) continue
if (shouldRenderVueNodes.value && typeof item.id === 'string') {
// Use layout store for Vue nodes (only works with string IDs)
const layout = layoutStore.getNodeLayoutRef(item.id).value
if (layout) {
allBounds.push([
layout.bounds.x,
layout.bounds.y,
layout.bounds.width,
layout.bounds.height
])
}
} else {
// Fallback to LiteGraph bounds for regular nodes or non-string IDs
if (item instanceof LGraphNode || item instanceof LGraphGroup) {
allBounds.push([
item.pos[0],
item.pos[1] - LiteGraph.NODE_TITLE_HEIGHT,
item.size[0],
item.size[1] + LiteGraph.NODE_TITLE_HEIGHT
])
}
if (item instanceof LGraphNode || item instanceof LGraphGroup) {
allBounds.push(getSelectionBounds(item, shouldRenderVueNodes.value))
}
}

View File

@@ -1,23 +1,8 @@
import { describe, expect, it } from 'vitest'
import { computeArrangement } from '@/composables/graph/useArrangeNodes'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { TitleMode } from '@/lib/litegraph/src/types/globalEnums'
interface MockNodeSpec {
id: number | string
pos: [number, number]
size: [number, number]
title_mode?: TitleMode
}
const makeNode = (spec: MockNodeSpec): LGraphNode =>
({
id: spec.id,
pos: spec.pos,
size: spec.size,
title_mode: spec.title_mode
}) as unknown as LGraphNode
import { toNodeId } from '@/types/nodeId'
import { createMockLGraphNode } from '@/utils/__tests__/litegraphTestUtils'
const GAP = 12
const TITLE = 30 // LiteGraph.NODE_TITLE_HEIGHT default
@@ -27,7 +12,13 @@ describe('computeArrangement', () => {
expect(computeArrangement([], 'vertical')).toEqual([])
expect(
computeArrangement(
[makeNode({ id: 1, pos: [0, 0], size: [100, 50] })],
[
createMockLGraphNode({
id: toNodeId(1),
pos: [0, 0],
size: [100, 50]
})
],
'grid'
)
).toEqual([])
@@ -36,9 +27,21 @@ describe('computeArrangement', () => {
describe('vertical', () => {
it('left-aligns to anchor x and stacks downward sorted by current y', () => {
const nodes = [
makeNode({ id: 'a', pos: [10, 100], size: [100, 50] }),
makeNode({ id: 'b', pos: [200, 0], size: [80, 30] }),
makeNode({ id: 'c', pos: [50, 200], size: [120, 40] })
createMockLGraphNode({
id: toNodeId('a'),
pos: [10, 100],
size: [100, 50]
}),
createMockLGraphNode({
id: toNodeId('b'),
pos: [200, 0],
size: [80, 30]
}),
createMockLGraphNode({
id: toNodeId('c'),
pos: [50, 200],
size: [120, 40]
})
]
// Anchor: 'a' has smallest x+y (110). Sort by Y: b(0), a(100), c(200).
// Visual top of layout = anchor.posY - TITLE = 100 - 30 = 70.
@@ -56,14 +59,14 @@ describe('computeArrangement', () => {
it('omits the title-height contribution for NO_TITLE nodes', () => {
const nodes = [
makeNode({
id: 1,
createMockLGraphNode({
id: toNodeId(1),
pos: [0, 0],
size: [100, 100],
title_mode: TitleMode.NO_TITLE
}),
makeNode({
id: 2,
createMockLGraphNode({
id: toNodeId(2),
pos: [0, 200],
size: [100, 100],
title_mode: TitleMode.NO_TITLE
@@ -74,22 +77,26 @@ describe('computeArrangement', () => {
// 2: pos.y = 112.
const result = computeArrangement(nodes, 'vertical')
expect(result).toEqual([
{ nodeId: 1, position: { x: 0, y: 0 } },
{ nodeId: 2, position: { x: 0, y: 100 + GAP } }
{ nodeId: '1', position: { x: 0, y: 0 } },
{ nodeId: '2', position: { x: 0, y: 100 + GAP } }
])
})
it('preserves heterogeneous heights when computing gaps', () => {
const nodes = [
makeNode({ id: 1, pos: [0, 0], size: [100, 200] }),
makeNode({ id: 2, pos: [0, 50], size: [100, 50] })
createMockLGraphNode({
id: toNodeId(1),
pos: [0, 0],
size: [100, 200]
}),
createMockLGraphNode({ id: toNodeId(2), pos: [0, 50], size: [100, 50] })
]
// visualTop=-30. 1: pos.y=0; visualTop += (200+30)+12 = 212.
// 2: pos.y = 212+30 = 242.
const result = computeArrangement(nodes, 'vertical')
expect(result).toEqual([
{ nodeId: 1, position: { x: 0, y: 0 } },
{ nodeId: 2, position: { x: 0, y: 200 + TITLE + GAP } }
{ nodeId: '1', position: { x: 0, y: 0 } },
{ nodeId: '2', position: { x: 0, y: 200 + TITLE + GAP } }
])
})
})
@@ -97,9 +104,21 @@ describe('computeArrangement', () => {
describe('horizontal', () => {
it('top-aligns to anchor y and lays out rightward sorted by current x', () => {
const nodes = [
makeNode({ id: 'a', pos: [100, 50], size: [80, 40] }),
makeNode({ id: 'b', pos: [0, 200], size: [60, 30] }),
makeNode({ id: 'c', pos: [300, 80], size: [50, 50] })
createMockLGraphNode({
id: toNodeId('a'),
pos: [100, 50],
size: [80, 40]
}),
createMockLGraphNode({
id: toNodeId('b'),
pos: [0, 200],
size: [60, 30]
}),
createMockLGraphNode({
id: toNodeId('c'),
pos: [300, 80],
size: [50, 50]
})
]
// Anchor: smallest x+y → a(150), b(200), c(380) → anchor 'a' at (100, 50).
// Sort by X: b(0), a(100), c(300)
@@ -119,10 +138,22 @@ describe('computeArrangement', () => {
describe('grid', () => {
it('lays out 4 nodes as 2x2 with column/row sizes from max width/height', () => {
const nodes = [
makeNode({ id: 1, pos: [0, 0], size: [100, 50] }),
makeNode({ id: 2, pos: [200, 0], size: [80, 60] }),
makeNode({ id: 3, pos: [0, 100], size: [120, 40] }),
makeNode({ id: 4, pos: [200, 100], size: [90, 30] })
createMockLGraphNode({ id: toNodeId(1), pos: [0, 0], size: [100, 50] }),
createMockLGraphNode({
id: toNodeId(2),
pos: [200, 0],
size: [80, 60]
}),
createMockLGraphNode({
id: toNodeId(3),
pos: [0, 100],
size: [120, 40]
}),
createMockLGraphNode({
id: toNodeId(4),
pos: [200, 100],
size: [90, 30]
})
]
// Anchor: 1 at (0,0). Sort by Y then X: 1, 2, 3, 4. cols=2, rows=2.
// Col widths: col0=max(100,120)=120; col1=max(80,90)=90.
@@ -131,18 +162,18 @@ describe('computeArrangement', () => {
// pos.y = rowVisualTop + 30 (titleHeight).
const result = computeArrangement(nodes, 'grid')
expect(result).toEqual([
{ nodeId: 1, position: { x: 0, y: 0 } },
{ nodeId: 2, position: { x: 132, y: 0 } },
{ nodeId: 3, position: { x: 0, y: 102 } },
{ nodeId: 4, position: { x: 132, y: 102 } }
{ nodeId: '1', position: { x: 0, y: 0 } },
{ nodeId: '2', position: { x: 132, y: 0 } },
{ nodeId: '3', position: { x: 0, y: 102 } },
{ nodeId: '4', position: { x: 132, y: 102 } }
])
})
it('uses ceil(sqrt(n)) columns for non-square counts', () => {
// 5 nodes → ceil(sqrt(5))=3 cols, 2 rows. Last cell empty.
const nodes = Array.from({ length: 5 }, (_, i) =>
makeNode({
id: i + 1,
createMockLGraphNode({
id: toNodeId(i + 1),
pos: [i * 50, i * 50],
size: [40, 40]
})
@@ -164,11 +195,23 @@ describe('computeArrangement', () => {
it('picks the node with smallest x+y, not min-x or min-y alone', () => {
const nodes = [
// min y but large x: x+y = 1000
makeNode({ id: 'minY', pos: [1000, 0], size: [50, 50] }),
createMockLGraphNode({
id: toNodeId('minY'),
pos: [1000, 0],
size: [50, 50]
}),
// min x but large y: x+y = 1000
makeNode({ id: 'minX', pos: [0, 1000], size: [50, 50] }),
createMockLGraphNode({
id: toNodeId('minX'),
pos: [0, 1000],
size: [50, 50]
}),
// smallest x+y: 600
makeNode({ id: 'anchor', pos: [300, 300], size: [50, 50] })
createMockLGraphNode({
id: toNodeId('anchor'),
pos: [300, 300],
size: [50, 50]
})
]
const result = computeArrangement(nodes, 'vertical')
// All updates left-align to anchor.x = 300. First in sort = minY (y=0).

View File

@@ -1,12 +1,13 @@
import { useSelectionState } from '@/composables/graph/useSelectionState'
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
import type { LGraphNode, NodeId } from '@/lib/litegraph/src/litegraph'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { TitleMode } from '@/lib/litegraph/src/types/globalEnums'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMutations'
import { LayoutSource } from '@/renderer/core/layout/types'
import type { Point } from '@/renderer/core/layout/types'
import { app } from '@/scripts/app'
import type { NodeId } from '@/types/nodeId'
export type ArrangeLayout = 'vertical' | 'horizontal' | 'grid'

View File

@@ -22,6 +22,7 @@ import { useMissingNodesErrorStore } from '@/platform/nodeReplacement/missingNod
import { app } from '@/scripts/app'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { createNodeExecutionId } from '@/types/nodeIdentification'
import { toNodeId } from '@/types/nodeId'
import { seedRequiredInputMissingNodeError } from '@/utils/__tests__/executionErrorTestUtils'
import type { MissingMediaCandidate } from '@/platform/missingMedia/types'
import type { MissingModelCandidate } from '@/platform/missingModel/types'
@@ -1108,7 +1109,7 @@ describe('clearWidgetRelatedErrors parameter routing', () => {
graph.add(host)
const interiorNode = new LGraphNode('CheckpointLoaderSimple')
interiorNode.id = 1
interiorNode.id = toNodeId(1)
subgraph.add(interiorNode)
const input = interiorNode.addInput('ckpt_name', 'COMBO')
const widget = interiorNode.addWidget(

View File

@@ -34,6 +34,8 @@ import { useNodeReplacementStore } from '@/platform/nodeReplacement/nodeReplacem
import { getCnrIdFromNode } from '@/platform/nodeReplacement/cnrIdUtil'
import { app } from '@/scripts/app'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { toNodeId } from '@/types/nodeId'
import type { NodeId } from '@/types/nodeId'
import { useModelToNodeStore } from '@/stores/modelToNodeStore'
import {
collectAllNodes,
@@ -54,6 +56,14 @@ type OriginalCallbacks = {
const originalCallbacks = new WeakMap<LGraphNode, OriginalCallbacks>()
function getRemovedNodeExecutionId(graph: LGraph, nodeId: NodeId): string {
if (!app.rootGraph) return String(nodeId)
return (
getExecutionIdForNodeInGraph(app.rootGraph, graph, nodeId) ?? String(nodeId)
)
}
function installNodeHooks(node: LGraphNode): void {
if (hookedNodes.has(node)) return
hookedNodes.add(node)
@@ -319,7 +329,7 @@ function scheduleAddedNodeScan(node: LGraphNode): void {
function handleNodeModeChange(
localGraph: LGraph,
nodeId: number,
nodeId: NodeId,
oldMode: number,
newMode: number
): void {
@@ -416,9 +426,7 @@ export function installErrorClearingHooks(graph: LGraph): () => void {
// "parentId:...:nodeId" path that matches how missing asset errors
// are keyed; without this, removal falls back to the local ID and
// misses subgraph entries.
const execId = app.rootGraph
? getExecutionIdForNodeInGraph(app.rootGraph, graph, node.id)
: String(node.id)
const execId = getRemovedNodeExecutionId(graph, node.id)
removeNodeErrors(node, execId)
restoreNodeHooksRecursive(node)
originalOnNodeRemoved?.call(this, node)
@@ -429,7 +437,7 @@ export function installErrorClearingHooks(graph: LGraph): () => void {
if (event.type === 'node:property:changed' && event.property === 'mode') {
handleNodeModeChange(
graph,
event.nodeId as number,
toNodeId(event.nodeId),
event.oldValue as number,
event.newValue as number
)

View File

@@ -68,7 +68,7 @@ describe('Node Reactivity', () => {
const onValueChange = vi.fn()
graph.trigger('node:slot-links:changed', {
nodeId: String(node.id),
nodeId: node.id,
slotType: NodeSlotType.INPUT
})
await nextTick()
@@ -116,7 +116,7 @@ describe('Widget slotMetadata reactivity on link disconnect', () => {
const { graph, node } = createWidgetInputGraph()
const { vueNodeData } = useGraphNodeManager(graph)
const nodeData = vueNodeData.get(String(node.id))
const nodeData = vueNodeData.get(node.id)
const widgetData = nodeData?.widgets?.find((w) => w.name === 'prompt')
expect(widgetData?.slotMetadata).toBeDefined()
@@ -127,7 +127,7 @@ describe('Widget slotMetadata reactivity on link disconnect', () => {
const { graph, node } = createWidgetInputGraph()
const { vueNodeData } = useGraphNodeManager(graph)
const nodeData = vueNodeData.get(String(node.id))
const nodeData = vueNodeData.get(node.id)
const widgetData = nodeData?.widgets?.find((w) => w.name === 'prompt')
// Verify initially linked
@@ -155,7 +155,7 @@ describe('Widget slotMetadata reactivity on link disconnect', () => {
const { graph, node } = createWidgetInputGraph()
const { vueNodeData } = useGraphNodeManager(graph)
const nodeData = vueNodeData.get(String(node.id))!
const nodeData = vueNodeData.get(node.id)!
// Mimic what processedWidgets does in NodeWidgets.vue:
// derive disabled from slotMetadata.linked
@@ -204,7 +204,7 @@ describe('Widget slotMetadata reactivity on link disconnect', () => {
throw new Error('Expected SubgraphInput.connect to produce a link')
const { vueNodeData } = useGraphNodeManager(subgraph)
const nodeData = vueNodeData.get(String(node.id))
const nodeData = vueNodeData.get(node.id)
const widgetData = nodeData?.widgets?.find((w) => w.name === 'prompt')
expect(widgetData?.slotMetadata?.linked).toBe(true)
@@ -230,7 +230,7 @@ describe('Widget slotMetadata reactivity on link disconnect', () => {
graph.add(subgraphNode)
const { vueNodeData } = useGraphNodeManager(graph)
const nodeData = vueNodeData.get(String(subgraphNode.id))
const nodeData = vueNodeData.get(subgraphNode.id)
const widgetData = nodeData?.widgets?.find((w) => w.name === 'value')
expect(widgetData).toBeDefined()
@@ -242,7 +242,7 @@ describe('Widget slotMetadata reactivity on link disconnect', () => {
const { graph, node } = createWidgetInputGraph()
const { vueNodeData } = useGraphNodeManager(graph)
const nodeData = vueNodeData.get(String(node.id))!
const nodeData = vueNodeData.get(node.id)!
const widgetData = nodeData.widgets!.find((w) => w.name === 'prompt')!
expect(widgetData.slotMetadata?.linked).toBe(true)
@@ -278,7 +278,7 @@ describe('Subgraph output slot label reactivity', () => {
graph.add(node)
const { vueNodeData } = useGraphNodeManager(graph)
const nodeId = String(node.id)
const nodeId = node.id
const nodeData = vueNodeData.get(nodeId)
if (!nodeData?.outputs) throw new Error('Expected output data to exist')
@@ -306,7 +306,7 @@ describe('Subgraph output slot label reactivity', () => {
graph.add(node)
const { vueNodeData } = useGraphNodeManager(graph)
const nodeId = String(node.id)
const nodeId = node.id
const nodeData = vueNodeData.get(nodeId)
if (!nodeData?.inputs) throw new Error('Expected input data to exist')
@@ -369,7 +369,7 @@ describe('Nested promoted widget mapping', () => {
graph.add(subgraphNodeB)
const { vueNodeData } = useGraphNodeManager(graph)
const nodeData = vueNodeData.get(String(subgraphNodeB.id))
const nodeData = vueNodeData.get(subgraphNodeB.id)
const mappedWidget = nodeData?.widgets?.[0]
expect(mappedWidget).toBeDefined()
@@ -406,7 +406,7 @@ describe('Nested promoted widget mapping', () => {
graph.add(subgraphNode)
const { vueNodeData } = useGraphNodeManager(graph)
const nodeData = vueNodeData.get(String(subgraphNode.id))
const nodeData = vueNodeData.get(subgraphNode.id)
const widgets = nodeData?.widgets
expect(widgets).toHaveLength(2)
@@ -452,7 +452,7 @@ describe('Promoted widget sourceExecutionId', () => {
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graph)
const { vueNodeData } = useGraphNodeManager(graph)
const nodeData = vueNodeData.get(String(subgraphNode.id))
const nodeData = vueNodeData.get(subgraphNode.id)
const promotedWidget = nodeData?.widgets?.find(
(w) => w.name === 'ckpt_input'
)
@@ -475,7 +475,7 @@ describe('Promoted widget sourceExecutionId', () => {
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graph)
const { vueNodeData } = useGraphNodeManager(graph)
const nodeData = vueNodeData.get(String(node.id))
const nodeData = vueNodeData.get(node.id)
const widget = nodeData?.widgets?.find((w) => w.name === 'steps')
expect(widget).toBeDefined()
@@ -714,12 +714,13 @@ describe('Pre-remove vueNodeData drain', () => {
const node = new LGraphNode('test')
graph.add(node)
const { vueNodeData } = useGraphNodeManager(graph)
const id = node.id
expect(vueNodeData.has(String(node.id))).toBe(true)
expect(vueNodeData.has(id)).toBe(true)
let dataPresentInOnRemoved: boolean | undefined
node.onRemoved = () => {
dataPresentInOnRemoved = vueNodeData.has(String(node.id))
dataPresentInOnRemoved = vueNodeData.has(id)
}
graph.remove(node)

View File

@@ -17,7 +17,8 @@ import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMutations'
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
import { LayoutSource } from '@/renderer/core/layout/types'
import type { NodeId } from '@/renderer/core/layout/types'
import { toNodeId } from '@/types/nodeId'
import type { NodeId } from '@/types/nodeId'
import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
import { isDOMWidget } from '@/scripts/domWidget'
import { IS_CONTROL_WIDGET } from '@/scripts/widgets'
@@ -26,7 +27,6 @@ import { useWidgetValueStore } from '@/stores/widgetValueStore'
import type { WidgetValue, SafeControlWidget } from '@/types/simplifiedWidget'
import { normalizeControlOption } from '@/types/simplifiedWidget'
import { getWidgetIdForNode } from '@/utils/litegraphUtil'
import type { NodeId as WorkflowNodeId } from '@/platform/workflow/validation/schemas/workflowSchema'
import type { NodeExecutionId } from '@/types/nodeIdentification'
import type { WidgetId } from '@/types/widgetId'
@@ -46,7 +46,7 @@ import { app } from '@/scripts/app'
export interface WidgetSlotMetadata {
index: number
linked: boolean
originNodeId?: string
originNodeId?: NodeId
originOutputName?: string
type: string
}
@@ -129,10 +129,10 @@ export interface VueNodeData {
export interface GraphNodeManager {
// Reactive state - safe data extracted from LiteGraph nodes
vueNodeData: ReadonlyMap<string, VueNodeData>
vueNodeData: ReadonlyMap<NodeId, VueNodeData>
// Access to original LiteGraph nodes (non-reactive)
getNode(id: WorkflowNodeId): LGraphNode | undefined
getNode(id: NodeId): LGraphNode | undefined
// Lifecycle methods
cleanup(): void
@@ -335,14 +335,14 @@ function buildSlotMetadata(
): Map<string, WidgetSlotMetadata> {
const metadata = new Map<string, WidgetSlotMetadata>()
inputs?.forEach((input, index) => {
let originNodeId: string | undefined
let originNodeId: NodeId | undefined
let originOutputName: string | undefined
if (input.link != null && graphRef) {
const link = graphRef.getLink(input.link)
const originNode = link ? graphRef.getNodeById(link.origin_id) : null
if (link && originNode) {
originNodeId = String(link.origin_id)
originNodeId = link.origin_id
originOutputName = originNode.outputs?.[link.origin_slot]?.name
}
}
@@ -453,7 +453,7 @@ export function extractVueNodeData(node: LGraphNode): VueNodeData {
const badges = node.badges
return {
id: String(node.id),
id: node.id,
title: typeof node.title === 'string' ? node.title : '',
type: nodeType,
mode: node.mode || 0,
@@ -480,12 +480,12 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
// Get layout mutations composable
const { createNode, deleteNode, setSource } = useLayoutMutations()
// Safe reactive data extracted from LiteGraph nodes
const vueNodeData = reactive(new Map<string, VueNodeData>())
const vueNodeData = reactive(new Map<NodeId, VueNodeData>())
// Non-reactive storage for original LiteGraph nodes
const nodeRefs = new Map<string, LGraphNode>()
const nodeRefs = new Map<NodeId, LGraphNode>()
const refreshNodeSlots = (nodeId: string) => {
const refreshNodeSlots = (nodeId: NodeId) => {
const nodeRef = nodeRefs.get(nodeId)
const currentData = vueNodeData.get(nodeId)
@@ -500,14 +500,14 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
}
// Get access to original LiteGraph node (non-reactive)
const getNode = (id: WorkflowNodeId): LGraphNode | undefined => {
return nodeRefs.get(String(id))
const getNode = (id: NodeId): LGraphNode | undefined => {
return nodeRefs.get(id)
}
const syncWithGraph = () => {
if (!graph?._nodes) return
const currentNodes = new Set(graph._nodes.map((n) => String(n.id)))
const currentNodes = new Set(graph._nodes.map((n) => n.id))
// Remove deleted nodes
for (const id of Array.from(vueNodeData.keys())) {
@@ -519,7 +519,7 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
// Add/update existing nodes
graph._nodes.forEach((node) => {
const id = String(node.id)
const id = node.id
// Store non-reactive reference
nodeRefs.set(id, node)
@@ -537,7 +537,7 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
node: LGraphNode,
originalCallback?: (node: LGraphNode) => void
) => {
const id = String(node.id)
const id = node.id
// Store non-reactive reference to original node
nodeRefs.set(id, node)
@@ -592,8 +592,7 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
}
}
const dropNodeReferences = (node: LGraphNode) => {
const id = String(node.id)
const dropNodeReferences = (id: NodeId) => {
nodeRefs.delete(id)
vueNodeData.delete(id)
}
@@ -602,9 +601,12 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
node: LGraphNode,
originalCallback?: (node: LGraphNode) => void
) => {
const id = String(node.id)
const id = node.id
// Remove node from layout store
setSource(LayoutSource.Canvas)
void deleteNode(id)
dropNodeReferences(id)
originalCallback?.(node)
}
@@ -652,7 +654,7 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
const beforeNodeRemovedListener = (
e: CustomEvent<{ node: LGraphNode }>
) => {
dropNodeReferences(e.detail.node)
dropNodeReferences(e.detail.node.id)
}
graph.events.addEventListener(
'node:before-removed',
@@ -663,7 +665,7 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
[K in LGraphTriggerAction]: (event: LGraphTriggerParam<K>) => void
} = {
'node:property:changed': (propertyEvent) => {
const nodeId = String(propertyEvent.nodeId)
const nodeId = toNodeId(propertyEvent.nodeId)
const currentData = vueNodeData.get(nodeId)
if (currentData) {
@@ -759,15 +761,15 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
}
},
'node:slot-errors:changed': (slotErrorsEvent) => {
refreshNodeSlots(String(slotErrorsEvent.nodeId))
refreshNodeSlots(toNodeId(slotErrorsEvent.nodeId))
},
'node:slot-links:changed': (slotLinksEvent) => {
if (slotLinksEvent.slotType === NodeSlotType.INPUT) {
refreshNodeSlots(String(slotLinksEvent.nodeId))
refreshNodeSlots(toNodeId(slotLinksEvent.nodeId))
}
},
'node:slot-label:changed': (slotLabelEvent) => {
const nodeId = String(slotLabelEvent.nodeId)
const nodeId = toNodeId(slotLabelEvent.nodeId)
const nodeRef = nodeRefs.get(nodeId)
if (!nodeRef) return

View File

@@ -1,13 +1,10 @@
import { computed, ref } from 'vue'
import type { Ref } from 'vue'
import type {
LGraphGroup,
LGraphNode,
NodeId
} from '@/lib/litegraph/src/litegraph'
import type { LGraphGroup, LGraphNode } from '@/lib/litegraph/src/litegraph'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { getExtraOptionsForWidget } from '@/services/litegraphService'
import type { SerializedNodeId } from '@/types/nodeId'
import { isLGraphGroup } from '@/utils/litegraphUtil'
import {
@@ -50,7 +47,7 @@ export enum BadgeVariant {
// Global singleton for NodeOptions component reference
let nodeOptionsInstance: null | NodeOptionsInstance = null
const hoveredWidget = ref<[string, NodeId | undefined]>()
const hoveredWidget = ref<[string, SerializedNodeId | undefined]>()
/**
* Toggle the node options popover
@@ -70,7 +67,7 @@ export function toggleNodeOptions(event: Event) {
export function showNodeOptions(
event: MouseEvent,
widgetName?: string,
nodeId?: NodeId
nodeId?: SerializedNodeId
) {
hoveredWidget.value = widgetName ? [widgetName, nodeId] : undefined
if (nodeOptionsInstance?.show) {

View File

@@ -8,6 +8,7 @@ import { useNodeMenuOptions } from '@/composables/graph/useNodeMenuOptions'
import type { Positionable } from '@/lib/litegraph/src/litegraph'
import { LGraphEventMode, LGraphNode } from '@/lib/litegraph/src/litegraph'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { toNodeId } from '@/types/nodeId'
// canvasStore transitively imports the app singleton; stub it so the real
// ComfyApp module never loads during these unit tests.
@@ -45,7 +46,7 @@ const i18n = createI18n({
const nodeWithMode = (mode: LGraphEventMode, id = 1): LGraphNode => {
const node = new LGraphNode('Test')
node.id = id
node.id = toNodeId(id)
node.mode = mode
return node
}

View File

@@ -10,6 +10,7 @@ import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMuta
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
import { useLayoutSync } from '@/renderer/core/layout/sync/useLayoutSync'
import { app as comfyApp } from '@/scripts/app'
import { UNASSIGNED_NODE_ID } from '@/types/nodeId'
function useVueNodeLifecycleIndividual() {
const canvasStore = useCanvasStore()
@@ -29,7 +30,7 @@ function useVueNodeLifecycleIndividual() {
// Initialize layout system with existing nodes from active graph
const nodes = activeGraph._nodes.map((node: LGraphNode) => ({
id: node.id.toString(),
id: node.id,
pos: [node.pos[0], node.pos[1]] as [number, number],
size: [node.size[0], node.size[1]] as [number, number]
}))
@@ -45,6 +46,11 @@ function useVueNodeLifecycleIndividual() {
// Seed existing links into the Layout Store (topology only)
for (const link of activeGraph._links.values()) {
if (
link.origin_id === UNASSIGNED_NODE_ID ||
link.target_id === UNASSIGNED_NODE_ID
)
continue
layoutMutations.createLink(
link.id,
link.origin_id,

View File

@@ -1,7 +1,6 @@
import { useChainCallback } from '@/composables/functional/useChainCallback'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { MIME_ASSET_INFO } from '@/platform/assets/schemas/mediaAssetSchema'
import { zResultItem } from '@/schemas/apiSchema'
import { parseAssetInfo } from '@/platform/assets/schemas/mediaAssetSchema'
import type { ResultItem } from '@/schemas/apiSchema'
type DragHandler = (e: DragEvent) => boolean
@@ -14,14 +13,6 @@ interface DragAndDropOptions<T> {
fileFilter?: (file: File) => boolean
}
function parseAssetInfo(assetString?: string) {
try {
return zResultItem.safeParse(JSON.parse(assetString ?? '')).data
} catch {
// output was not parsable, allow fallthrough and return undefined
}
}
/**
* Adds drag and drop file handling to a node
* Will also resolve 'text/uri-list' to a file before passing
@@ -67,7 +58,7 @@ export const useNodeDragAndDrop = <T>(
await onDrop(files)
return true
}
const asset = parseAssetInfo(e?.dataTransfer?.getData(MIME_ASSET_INFO))
const asset = parseAssetInfo(e.dataTransfer!)
if (asset?.filename && options.onResultItemDrop) {
await options.onResultItemDrop(asset)
return true

View File

@@ -12,6 +12,7 @@ import {
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
import type { ComfyNodeDef, PriceBadge } from '@/schemas/nodeDefSchema'
import { toNodeId } from '@/types/nodeId'
import { createMockLGraphNode } from '@/utils/__tests__/litegraphTestUtils'
// -----------------------------------------------------------------------------
@@ -619,14 +620,15 @@ describe('useNodePricing', () => {
LiteGraph.vueNodesMode = true
try {
const revBefore = getNodeRevisionRef(node.id).value
const nodeId = node.id
const revBefore = getNodeRevisionRef(nodeId).value
const tickBefore = pricingRevision.value
getNodeDisplayPrice(node)
await new Promise((r) => setTimeout(r, 50))
// VueNodes path bumps per-node ref and the global tick.
expect(getNodeRevisionRef(node.id).value).toBeGreaterThan(revBefore)
expect(getNodeRevisionRef(nodeId).value).toBeGreaterThan(revBefore)
expect(pricingRevision.value).toBeGreaterThan(tickBefore)
} finally {
LiteGraph.vueNodesMode = false
@@ -658,7 +660,7 @@ describe('useNodePricing', () => {
describe('getNodeRevisionRef', () => {
it('should return a ref for a node ID', () => {
const { getNodeRevisionRef } = useNodePricing()
const ref = getNodeRevisionRef('node-1')
const ref = getNodeRevisionRef(toNodeId('node-1'))
expect(ref).toBeDefined()
expect(ref.value).toBe(0)
@@ -666,25 +668,24 @@ describe('useNodePricing', () => {
it('should return the same ref for the same node ID', () => {
const { getNodeRevisionRef } = useNodePricing()
const ref1 = getNodeRevisionRef('node-same')
const ref2 = getNodeRevisionRef('node-same')
const ref1 = getNodeRevisionRef(toNodeId('node-same'))
const ref2 = getNodeRevisionRef(toNodeId('node-same'))
expect(ref1).toBe(ref2)
})
it('should return different refs for different node IDs', () => {
const { getNodeRevisionRef } = useNodePricing()
const ref1 = getNodeRevisionRef('node-a')
const ref2 = getNodeRevisionRef('node-b')
const ref1 = getNodeRevisionRef(toNodeId('node-a'))
const ref2 = getNodeRevisionRef(toNodeId('node-b'))
expect(ref1).not.toBe(ref2)
})
it('should handle both string and number node IDs', () => {
const { getNodeRevisionRef } = useNodePricing()
// Number ID gets stringified, so '123' and 123 should return the same ref
const refFromNumber = getNodeRevisionRef(123)
const refFromString = getNodeRevisionRef('123')
const refFromNumber = getNodeRevisionRef(toNodeId(123))
const refFromString = getNodeRevisionRef(toNodeId('123'))
expect(refFromNumber).toBe(refFromString)
})

View File

@@ -24,6 +24,7 @@ import type {
WidgetDependency
} from '@/schemas/nodeDefSchema'
import { useNodeDefStore } from '@/stores/nodeDefStore'
import type { NodeId } from '@/types/nodeId'
import type { Expression } from 'jsonata'
import jsonata from 'jsonata'
@@ -452,18 +453,17 @@ const pricingTick = ref(0)
// Per-node revision tracking for VueNodes mode (more efficient than global tick)
// Uses plain Map with individual refs per node for fine-grained reactivity
// Keys are stringified node IDs to handle both string and number ID types
const nodeRevisions = new Map<string, Ref<number>>()
const nodeRevisions = new Map<NodeId, Ref<number>>()
/**
* Get or create a revision ref for a specific node.
* Each node has its own independent ref, so updates to one won't trigger others.
*/
const getNodeRevisionRef = (nodeId: string | number): Ref<number> => {
const key = String(nodeId)
let rev = nodeRevisions.get(key)
const getNodeRevisionRef = (nodeId: NodeId): Ref<number> => {
let rev = nodeRevisions.get(nodeId)
if (!rev) {
rev = ref(0)
nodeRevisions.set(key, rev)
nodeRevisions.set(nodeId, rev)
}
return rev
}

View File

@@ -11,6 +11,7 @@ import {
import { useNodeOutputStore } from '@/stores/nodeOutputStore'
import { usePreviewExposureStore } from '@/stores/previewExposureStore'
import { createNodeLocatorId } from '@/types/nodeIdentification'
import { toNodeId } from '@/types/nodeId'
import { CANVAS_IMAGE_PREVIEW_WIDGET } from './canvasImagePreviewTypes'
import { usePromotedPreviews } from './usePromotedPreviews'
@@ -58,7 +59,7 @@ function addInteriorNode(
} = { id: 10 }
): LGraphNode {
const node = new LGraphNode('test')
node.id = options.id
node.id = toNodeId(options.id)
if (options.previewMediaType) {
node.previewMediaType = options.previewMediaType
}
@@ -69,7 +70,7 @@ function addInteriorNode(
function seedOutputs(subgraphId: string, nodeIds: Array<number | string>) {
const store = useNodeOutputStore()
for (const nodeId of nodeIds) {
const locatorId = createNodeLocatorId(subgraphId, nodeId)
const locatorId = createNodeLocatorId(subgraphId, toNodeId(nodeId))
store.nodeOutputs[locatorId] = {
images: [{ filename: 'output.png' }]
}
@@ -82,7 +83,7 @@ function seedPreviewImages(
) {
const store = useNodeOutputStore()
for (const { nodeId, urls } of entries) {
const locatorId = createNodeLocatorId(subgraphId, nodeId)
const locatorId = createNodeLocatorId(subgraphId, toNodeId(nodeId))
store.nodePreviewImages[locatorId] = urls
}
}
@@ -232,7 +233,9 @@ describe(usePromotedPreviews, () => {
exposePreview(setup, '10')
const blobUrl = 'blob:http://localhost/glsl-preview'
seedPreviewImages(setup.subgraph.id, [{ nodeId: 10, urls: [blobUrl] }])
seedPreviewImages(setup.subgraph.id, [
{ nodeId: toNodeId(10), urls: [blobUrl] }
])
vi.mocked(useNodeOutputStore().getNodeImageUrls).mockReturnValue([blobUrl])
const { promotedPreviews } = usePromotedPreviews(() => setup.subgraphNode)
@@ -255,7 +258,9 @@ describe(usePromotedPreviews, () => {
expect(promotedPreviews.value).toEqual([])
const blobUrl = 'blob:http://localhost/glsl-preview'
seedPreviewImages(setup.subgraph.id, [{ nodeId: 10, urls: [blobUrl] }])
seedPreviewImages(setup.subgraph.id, [
{ nodeId: toNodeId(10), urls: [blobUrl] }
])
vi.mocked(useNodeOutputStore().getNodeImageUrls).mockReturnValue([blobUrl])
expect(promotedPreviews.value).toEqual([

View File

@@ -6,6 +6,7 @@ import { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
import type { UUID } from '@/utils/uuid'
import { useNodeOutputStore } from '@/stores/nodeOutputStore'
import { usePreviewExposureStore } from '@/stores/previewExposureStore'
import type { NodeId } from '@/types/nodeId'
import {
appendNodeExecutionId,
createNodeLocatorId
@@ -13,7 +14,7 @@ import {
import type { NodeExecutionId } from '@/types/nodeIdentification'
interface PromotedPreview {
sourceNodeId: string
sourceNodeId: NodeId
sourceWidgetName: string
type: 'image' | 'video' | 'audio'
urls: string[]
@@ -41,7 +42,7 @@ export function usePromotedPreviews(
/** Touches reactive sources for Vue tracking; `getNodeImageUrls` reads non-reactive app state. */
function readReactivePreviewUrls(
leafHost: SubgraphNode,
leafSourceNodeId: string,
leafSourceNodeId: NodeId,
leafExecutionId: NodeExecutionId,
interiorNode: LGraphNode
): string[] | undefined {
@@ -49,6 +50,8 @@ export function usePromotedPreviews(
leafHost.subgraph.id,
leafSourceNodeId
)
if (!locatorId) return undefined
const reactiveOutputs = nodeOutputStore.nodeOutputs[locatorId]
const reactivePreviews = nodeOutputStore.nodePreviewImages[locatorId]
const reactiveExecutionOutputs =
@@ -89,7 +92,7 @@ export function usePromotedPreviews(
function resolveNestedHost(
rootGraphId: UUID,
currentHostLocator: string,
sourceNodeId: string
sourceNodeId: NodeId
) {
const currentHost = hostNodesByLocator.get(currentHostLocator)
const sourceNode = currentHost?.subgraph.getNodeById(sourceNodeId)
@@ -123,10 +126,16 @@ export function usePromotedPreviews(
const interiorNode = leafHost.subgraph.getNodeById(leaf.sourceNodeId)
if (!interiorNode) return []
const leafExecutionId = appendNodeExecutionId(
leafHostLocator,
leaf.sourceNodeId
)
if (!leafExecutionId) return []
const urls = readReactivePreviewUrls(
leafHost,
leaf.sourceNodeId,
appendNodeExecutionId(leafHostLocator, leaf.sourceNodeId),
leafExecutionId,
interiorNode
)
if (!urls?.length) return []

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