Compare commits

..

17 Commits

Author SHA1 Message Date
coderabbitai[bot]
ef4c751aa1 📝 CodeRabbit Chat: Implement requested code changes 2026-03-29 22:48:11 +00:00
bymyself
9391663346 fix: use QueuePanel page object for moreOptionsButton
Replaces inline getByLabel with queuePanel.moreOptionsButton
and openClearHistoryDialog method on the page object.

Addresses review feedback:
https://github.com/Comfy-Org/ComfyUI_frontend/pull/10586#discussion_r3005779512
https://github.com/Comfy-Org/ComfyUI_frontend/pull/10586#discussion_r3005779205
2026-03-28 23:35:50 -07:00
bymyself
f84edf4651 fix: extract QueuePanel page object with overlayToggle
Addresses review feedback:
https://github.com/Comfy-Org/ComfyUI_frontend/pull/10586#discussion_r3005778699
2026-03-28 23:35:41 -07:00
bymyself
b7d009bce0 fix: use centralized TestIds for queue selectors
Address CodeRabbit review — replace inline test-id strings with
TestIds.queue.overlayToggle and TestIds.queue.clearHistoryAction
from fixtures/selectors.ts.
2026-03-26 22:11:07 -07:00
bymyself
475e6927dd fix: use Unicode right single quotation mark to match i18n locale text 2026-03-26 18:29:03 -07:00
GitHub Action
dcb2a2dbaf [automated] Apply ESLint and Oxfmt fixes 2026-03-27 00:46:18 +00:00
bymyself
8cc9e54b9a test: add QueueClearHistoryDialog E2E tests (DLG-02) 2026-03-26 17:43:01 -07:00
Alexander Brown
897cf9cb8f Mark failing test as in need of fixing (#10572)
CC @christian-byrne

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10572-Mark-failing-test-as-in-need-of-fixing-32f6d73d3650815cba72d19ebc54b0b3)
by [Unito](https://www.unito.io)
2026-03-26 12:23:44 -07:00
jaeone94
e9b01cf479 fix: create initial workflow tab when persistence is disabled (#10565)
## Summary

- When `Comfy.Workflow.Persist` is OFF and storage is empty,
`initializeWorkflow()` returned without creating any workflow tab —
leaving users with no tab and no way to save
- Now falls through to `loadDefaultWorkflow()` so a default temporary
workflow is always created

## Root Cause

In `useWorkflowPersistenceV2.ts`, `initializeWorkflow()` had an early
return when persistence was disabled:

```ts
if (!workflowPersistenceEnabled.value) return
```

This skipped `loadDefaultWorkflow()`, which is responsible for creating
the initial temporary workflow tab via `comfyApp.loadGraphData()` →
`afterLoadNewGraph()` → `workflowStore.createNewTemporary()`.

## Fix

One-line change: `return` → `return loadDefaultWorkflow()`.

## Test plan

- [x] E2E test: verifies `openWorkflows.length >= 1` after reload with
persistence OFF

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10565-fix-create-initial-workflow-tab-when-persistence-is-disabled-32f6d73d365081d5a681c3e019d373c3)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Alexander Brown <drjkl@comfy.org>
2026-03-26 11:16:50 -07:00
Terry Jia
bcb39b1bf6 feat: support histogram display in curve widget (#10365)
## Summary
- WidgetCurve reads histogram data from nodeOutputStore (sent by backend
CurveEditor node via ui output) and passes it to CurveEditor
- histogramToPath now supports arbitrary-length bin arrays instead
ofhardcoded 256

need BE changes

## Screenshots (if applicable)
<img width="2431" height="1022" alt="image"
src="https://github.com/user-attachments/assets/8421d4a7-1bff-4269-8b55-649838f9d619"
/>

<img width="2462" height="979" alt="image"
src="https://github.com/user-attachments/assets/191c9163-82ab-4eb2-bb74-0037b3ccd383"
/>

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10365-feat-support-histogram-display-in-curve-widget-32a6d73d3650816b9852d73309a0b35f)
by [Unito](https://www.unito.io)
2026-03-26 05:29:08 -04:00
Alexander Brown
d940ea76ee fix: repoint ancestor promoted widget bindings when packing nested subgraphs (#10532)
## Summary

Packing nodes inside a subgraph into a nested subgraph no longer blanks
the parent subgraph node's promoted widget values.

## Changes

- **What**: After `convertToSubgraph` moves interior nodes into a nested
subgraph, `_repointAncestorPromotions` rewrites the promotion store
entries on all host SubgraphNodes so they chain through the new nested
node. `rebuildInputWidgetBindings()` then clears the stale
`input._widget` PromotedWidgetView cache and re-resolves bindings from
current connections.
- The root cause was two separate sets of PromotedWidgetView references:
`node.widgets` (rebuilt from the store — correct) vs `input._widget`
(cached at promotion time — stale). `SubgraphNode.serialize()` reads
`input._widget.value`, which resolved against removed node IDs →
`missing-node` → blank values on the next `checkState` cycle.

## Review Focus

- `_repointAncestorPromotions` iterates all graphs to find host nodes of
the current subgraph type — verify this covers all cases (multiple
instances of the same subgraph type).
- `rebuildInputWidgetBindings()` clears `_promotedViewManager` and
re-resolves — confirm no side effects on event listeners or pending
promotions.
- The nested node gets duplicate promotion entries (from both
`_repointAncestorPromotions` and `promoteRecommendedWidgets` via the
`subgraph-converted` event). `store.promote()` deduplicates via
`isPromoted`, but worth verifying.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10532-fix-repoint-ancestor-promoted-widget-bindings-when-packing-nested-subgraphs-32e6d73d365081109d5aea0660434082)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Amp <amp@ampcode.com>
Co-authored-by: Benjamin Lu <benjaminlu1107@gmail.com>
Co-authored-by: Christian Byrne <cbyrne@comfy.org>
Co-authored-by: GitHub Action <action@github.com>
Co-authored-by: Comfy Org PR Bot <snomiao+comfy-pr@gmail.com>
Co-authored-by: christian-byrne <72887196+christian-byrne@users.noreply.github.com>
Co-authored-by: github-actions <github-actions@github.com>
Co-authored-by: pythongosssss <125205205+pythongosssss@users.noreply.github.com>
Co-authored-by: Yourz <crazilou@vip.qq.com>
2026-03-25 22:43:52 -07:00
Alexander Brown
d397318ad8 Update README for browser testing commands (#10541)
## Summary

Update recommended commands in browser_tests README

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10541-Update-README-for-browser-testing-commands-32f6d73d36508175a675e865a990caed)
by [Unito](https://www.unito.io)

---------

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2026-03-25 21:50:02 -07:00
Christian Byrne
d860d54366 test: add large graph zoom perf test for ResizeObserver baseline (#10478)
## Summary

Adds a `@perf` test that establishes a baseline for ResizeObserver
layout cost during zoom on a large graph (245 nodes).

## Changes

- **What**: New `large graph zoom interaction` perf test that zooms
in/out 30 steps on `large-graph-workflow`, measuring `layouts`,
`layoutDurationMs`, `frameDurationMs`, and `TBT`. Each zoom step
triggers ResizeObserver for all node elements due to CSS scale changes.

## Review Focus

This is **PR 1 of 2** for throttling the ResizeObserver during zoom/pan.
Once this merges and establishes a baseline on main, the fix PR (#10473)
will show a CI-proven delta demonstrating the improvement.

The test follows the same patterns as `large graph pan interaction` and
`canvas zoom sweep`.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10478-test-add-large-graph-zoom-perf-test-for-ResizeObserver-baseline-32d6d73d365081169537e557c14d8c51)
by [Unito](https://www.unito.io)
2026-03-25 21:05:36 -07:00
Comfy Org PR Bot
86d202bcc1 1.43.6 (#10540)
Patch version increment to 1.43.6

**Base branch:** `main`

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10540-1-43-6-32f6d73d3650810fb3f5c6d4c59130f2)
by [Unito](https://www.unito.io)

---------

Co-authored-by: christian-byrne <72887196+christian-byrne@users.noreply.github.com>
Co-authored-by: Christian Byrne <cbyrne@comfy.org>
Co-authored-by: github-actions <github-actions@github.com>
2026-03-25 20:31:24 -07:00
Johnpaul Chiwetelu
60b6f78397 chore: bump CI container to 0.0.16 (#10527) 2026-03-26 02:34:27 +01:00
Yourz
6c7c3ea006 fix: tree explorer row height and width overflow (#10501)
## Summary

Fix tree explorer row sizing: consistent row height and prevent
horizontal overflow.

## Changes

- **What**:
1. Reduce node bookmark button from `size-6` (24px) to `size-5` (20px)
so node and folder rows both have 36px height, matching
`TreeVirtualizer` estimate-size and fixing tree list overlap.
2. Change row width from `w-full` to `w-[calc(100%-var(--spacing)*4)]`
to prevent horizontal overflow while keeping `mx-2` margin.

## Review Focus

Pure UI change — no test coverage needed. Verify tree rows render at
consistent height and no horizontal overflow occurs.

## Screenshots (if applicable)

| Before    | After |
| -------- | ------- |
|<img width="1218" height="1662" alt="image"
src="https://github.com/user-attachments/assets/89c799ab-cef3-40ee-88ca-900f5d3c7890"
/>|<img width="407" height="758" alt="image"
src="https://github.com/user-attachments/assets/f9aa4569-aaf8-467f-9dde-a187151af9aa"
/>|

N/A

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10501-fix-tree-explorer-row-height-and-width-overflow-32e6d73d3650819aa645c2262693ec62)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Amp <amp@ampcode.com>
2026-03-26 09:20:42 +09:00
pythongosssss
771f68f92a fix: App mode - workaround for alt+m producing alt+μ on mac (#10528)
## Summary

Adds a second keybinding for app mode as on Mac Alt+M produces Alt+μ

## Changes

- **What**: add extra binding entry

## Screenshots (if applicable)

Still shows alt + m in the keybinding panel
<img width="840" height="206" alt="image"
src="https://github.com/user-attachments/assets/f1906bc2-e7c2-4eac-b3ca-5a8a207cc93c"
/>

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10528-fix-App-mode-workaround-for-alt-m-producing-alt-on-mac-32e6d73d36508176b7d6d918c5bb88f3)
by [Unito](https://www.unito.io)
2026-03-25 16:15:41 -07:00
41 changed files with 1989 additions and 106 deletions

View File

@@ -39,7 +39,7 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 60
container:
image: ghcr.io/comfy-org/comfyui-ci-container:0.0.13
image: ghcr.io/comfy-org/comfyui-ci-container:0.0.16
credentials:
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
@@ -87,7 +87,7 @@ jobs:
needs: setup
runs-on: ubuntu-latest
container:
image: ghcr.io/comfy-org/comfyui-ci-container:0.0.13
image: ghcr.io/comfy-org/comfyui-ci-container:0.0.16
credentials:
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}

View File

@@ -77,7 +77,7 @@ jobs:
needs: setup
runs-on: ubuntu-latest
container:
image: ghcr.io/comfy-org/comfyui-ci-container:0.0.13
image: ghcr.io/comfy-org/comfyui-ci-container:0.0.16
credentials:
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}

View File

@@ -75,7 +75,7 @@ For tests that specifically need to test release functionality, see the example
**Always use UI mode for development:**
```bash
pnpm exec playwright test --ui
pnpm test:browser:local --ui
```
UI mode features:
@@ -91,29 +91,8 @@ UI mode features:
For CI or headless testing:
```bash
pnpm exec playwright test # Run all tests
pnpm exec playwright test widget.spec.ts # Run specific test file
```
### Local Development Config
For debugging, you can try adjusting these settings in `playwright.config.ts`:
```typescript
export default defineConfig({
// VERY HELPFUL: Skip screenshot tests locally
grep: process.env.CI ? undefined : /^(?!.*screenshot).*$/
retries: 0, // No retries while debugging. Increase if writing new tests. that may be flaky.
workers: 1, // Single worker for easier debugging. Increase to match CPU cores if you want to run a lot of tests in parallel.
timeout: 30000, // Longer timeout for breakpoints
use: {
trace: 'on', // Always capture traces (CI uses 'on-first-retry')
video: 'on' // Always record video (CI uses 'retain-on-failure')
},
})
pnpm test:browser:local # Run all tests
pnpm test:browser:local widget.spec.ts # Run specific test file
```
## Test Structure
@@ -385,7 +364,7 @@ export default defineConfig({
Option 2 - Generate local baselines for comparison:
```bash
pnpm exec playwright test --update-snapshots
pnpm test:browser:local --update-snapshots
```
### Creating New Screenshot Baselines

View File

@@ -0,0 +1,817 @@
{
"id": "9ae6082b-c7f4-433c-9971-7a8f65a3ea65",
"revision": 0,
"last_node_id": 61,
"last_link_id": 70,
"nodes": [
{
"id": 35,
"type": "MarkdownNote",
"pos": [-424.0076397768001, 199.99406275798367],
"size": [510, 774],
"flags": {
"collapsed": false
},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [],
"title": "Model link",
"properties": {},
"widgets_values": [
"## Report workflow issue\n\nIf you found any issues when running this workflow, [report template issue here](https://github.com/Comfy-Org/workflow_templates/issues)\n\n\n## Model links\n\n**text_encoders**\n\n- [qwen_3_4b.safetensors](https://huggingface.co/Comfy-Org/z_image_turbo/resolve/main/split_files/text_encoders/qwen_3_4b.safetensors)\n\n**loras**\n\n- [pixel_art_style_z_image_turbo.safetensors](https://huggingface.co/tarn59/pixel_art_style_lora_z_image_turbo/resolve/main/pixel_art_style_z_image_turbo.safetensors)\n\n**diffusion_models**\n\n- [z_image_turbo_bf16.safetensors](https://huggingface.co/Comfy-Org/z_image_turbo/resolve/main/split_files/diffusion_models/z_image_turbo_bf16.safetensors)\n\n**vae**\n\n- [ae.safetensors](https://huggingface.co/Comfy-Org/z_image_turbo/resolve/main/split_files/vae/ae.safetensors)\n\n\nModel Storage Location\n\n```\n📂 ComfyUI/\n├── 📂 models/\n│ ├── 📂 text_encoders/\n│ │ └── qwen_3_4b.safetensors\n│ ├── 📂 loras/\n│ │ └── pixel_art_style_z_image_turbo.safetensors\n│ ├── 📂 diffusion_models/\n│ │ └── z_image_turbo_bf16.safetensors\n│ └── 📂 vae/\n│ └── ae.safetensors\n```\n"
],
"color": "#432",
"bgcolor": "#000"
},
{
"id": 9,
"type": "SaveImage",
"pos": [569.9875743118757, 199.99406275798367],
"size": [780, 660],
"flags": {},
"order": 2,
"mode": 0,
"inputs": [
{
"name": "images",
"type": "IMAGE",
"link": 62
}
],
"outputs": [],
"properties": {
"Node name for S&R": "SaveImage",
"cnr_id": "comfy-core",
"ver": "0.3.64",
"enableTabs": false,
"tabWidth": 65,
"tabXOffset": 10,
"hasSecondTab": false,
"secondTabText": "Send Back",
"secondTabOffset": 80,
"secondTabWidth": 65
},
"widgets_values": ["z-image-turbo"]
},
{
"id": 57,
"type": "f2fdebf6-dfaf-43b6-9eb2-7f70613cfdc1",
"pos": [128.01215102992103, 199.99406275798367],
"size": [400, 470],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [
{
"label": "prompt",
"name": "text",
"type": "STRING",
"widget": {
"name": "text"
},
"link": null
}
],
"outputs": [
{
"name": "IMAGE",
"type": "IMAGE",
"links": [62]
}
],
"properties": {
"proxyWidgets": [
["27", "text"],
["13", "width"],
["13", "height"],
["28", "unet_name"],
["30", "clip_name"],
["29", "vae_name"],
["3", "steps"],
["3", "control_after_generate"]
],
"cnr_id": "comfy-core",
"ver": "0.3.73",
"enableTabs": false,
"tabWidth": 65,
"tabXOffset": 10,
"hasSecondTab": false,
"secondTabText": "Send Back",
"secondTabOffset": 80,
"secondTabWidth": 65
},
"widgets_values": []
}
],
"links": [[62, 57, 0, 9, 0, "IMAGE"]],
"groups": [],
"definitions": {
"subgraphs": [
{
"id": "f2fdebf6-dfaf-43b6-9eb2-7f70613cfdc1",
"version": 1,
"state": {
"lastGroupId": 4,
"lastNodeId": 61,
"lastLinkId": 70,
"lastRerouteId": 0
},
"revision": 0,
"config": {},
"name": "Text to Image (Z-Image-Turbo)",
"inputNode": {
"id": -10,
"bounding": [-80, 425, 120, 180]
},
"outputNode": {
"id": -20,
"bounding": [1490, 415, 120, 60]
},
"inputs": [
{
"id": "fb178669-e742-4a53-8a69-7df59834dfd8",
"name": "text",
"type": "STRING",
"linkIds": [34],
"label": "prompt",
"pos": [20, 445]
},
{
"id": "dd780b3c-23e9-46ff-8469-156008f42e5a",
"name": "width",
"type": "INT",
"linkIds": [35],
"pos": [20, 465]
},
{
"id": "7b08d546-6bb0-4ef9-82e9-ffae5e1ee6bc",
"name": "height",
"type": "INT",
"linkIds": [36],
"pos": [20, 485]
},
{
"id": "8ed4eb73-a2bf-4766-8bf4-c5890b560596",
"name": "unet_name",
"type": "COMBO",
"linkIds": [38],
"pos": [20, 505]
},
{
"id": "f362d639-d412-4b5d-8490-1e9995dc5f82",
"name": "clip_name",
"type": "COMBO",
"linkIds": [39],
"pos": [20, 525]
},
{
"id": "ee25ac16-de63-4b74-bbbb-5b29fdc1efcf",
"name": "vae_name",
"type": "COMBO",
"linkIds": [40],
"pos": [20, 545]
},
{
"id": "51cbcd61-9218-4bcb-89ac-ecdfb1ef8892",
"name": "steps",
"type": "INT",
"linkIds": [70],
"pos": [20, 565]
}
],
"outputs": [
{
"id": "1fa72a21-ce00-4952-814e-1f2ffbe87d1d",
"name": "IMAGE",
"type": "IMAGE",
"linkIds": [16],
"localized_name": "IMAGE",
"pos": [1510, 435]
}
],
"widgets": [],
"nodes": [
{
"id": 30,
"type": "CLIPLoader",
"pos": [110, 330],
"size": [270, 106],
"flags": {},
"order": 7,
"mode": 0,
"inputs": [
{
"localized_name": "clip_name",
"name": "clip_name",
"type": "COMBO",
"widget": {
"name": "clip_name"
},
"link": 39
}
],
"outputs": [
{
"localized_name": "CLIP",
"name": "CLIP",
"type": "CLIP",
"links": [28]
}
],
"properties": {
"Node name for S&R": "CLIPLoader",
"cnr_id": "comfy-core",
"ver": "0.3.73",
"models": [
{
"name": "qwen_3_4b.safetensors",
"url": "https://huggingface.co/Comfy-Org/z_image_turbo/resolve/main/split_files/text_encoders/qwen_3_4b.safetensors",
"directory": "text_encoders"
}
],
"enableTabs": false,
"tabWidth": 65,
"tabXOffset": 10,
"hasSecondTab": false,
"secondTabText": "Send Back",
"secondTabOffset": 80,
"secondTabWidth": 65
},
"widgets_values": ["qwen_3_4b.safetensors", "lumina2", "default"]
},
{
"id": 29,
"type": "VAELoader",
"pos": [110, 480],
"size": [270, 58],
"flags": {},
"order": 6,
"mode": 0,
"inputs": [
{
"localized_name": "vae_name",
"name": "vae_name",
"type": "COMBO",
"widget": {
"name": "vae_name"
},
"link": 40
}
],
"outputs": [
{
"localized_name": "VAE",
"name": "VAE",
"type": "VAE",
"links": [27]
}
],
"properties": {
"Node name for S&R": "VAELoader",
"cnr_id": "comfy-core",
"ver": "0.3.73",
"models": [
{
"name": "ae.safetensors",
"url": "https://huggingface.co/Comfy-Org/z_image_turbo/resolve/main/split_files/vae/ae.safetensors",
"directory": "vae"
}
],
"enableTabs": false,
"tabWidth": 65,
"tabXOffset": 10,
"hasSecondTab": false,
"secondTabText": "Send Back",
"secondTabOffset": 80,
"secondTabWidth": 65
},
"widgets_values": ["ae.safetensors"]
},
{
"id": 33,
"type": "ConditioningZeroOut",
"pos": [640, 620],
"size": [204.134765625, 26],
"flags": {},
"order": 8,
"mode": 0,
"inputs": [
{
"localized_name": "conditioning",
"name": "conditioning",
"type": "CONDITIONING",
"link": 32
}
],
"outputs": [
{
"localized_name": "CONDITIONING",
"name": "CONDITIONING",
"type": "CONDITIONING",
"links": [33]
}
],
"properties": {
"Node name for S&R": "ConditioningZeroOut",
"cnr_id": "comfy-core",
"ver": "0.3.73",
"enableTabs": false,
"tabWidth": 65,
"tabXOffset": 10,
"hasSecondTab": false,
"secondTabText": "Send Back",
"secondTabOffset": 80,
"secondTabWidth": 65
},
"widgets_values": []
},
{
"id": 8,
"type": "VAEDecode",
"pos": [1220, 160],
"size": [210, 46],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [
{
"localized_name": "samples",
"name": "samples",
"type": "LATENT",
"link": 14
},
{
"localized_name": "vae",
"name": "vae",
"type": "VAE",
"link": 27
}
],
"outputs": [
{
"localized_name": "IMAGE",
"name": "IMAGE",
"type": "IMAGE",
"slot_index": 0,
"links": [16]
}
],
"properties": {
"Node name for S&R": "VAEDecode",
"cnr_id": "comfy-core",
"ver": "0.3.64",
"enableTabs": false,
"tabWidth": 65,
"tabXOffset": 10,
"hasSecondTab": false,
"secondTabText": "Send Back",
"secondTabOffset": 80,
"secondTabWidth": 65
},
"widgets_values": []
},
{
"id": 28,
"type": "UNETLoader",
"pos": [110, 200],
"size": [270, 82],
"flags": {},
"order": 5,
"mode": 0,
"inputs": [
{
"localized_name": "unet_name",
"name": "unet_name",
"type": "COMBO",
"widget": {
"name": "unet_name"
},
"link": 38
}
],
"outputs": [
{
"localized_name": "MODEL",
"name": "MODEL",
"type": "MODEL",
"links": [26]
}
],
"properties": {
"Node name for S&R": "UNETLoader",
"cnr_id": "comfy-core",
"ver": "0.3.73",
"models": [
{
"name": "z_image_turbo_bf16.safetensors",
"url": "https://huggingface.co/Comfy-Org/z_image_turbo/resolve/main/split_files/diffusion_models/z_image_turbo_bf16.safetensors",
"directory": "diffusion_models"
}
],
"enableTabs": false,
"tabWidth": 65,
"tabXOffset": 10,
"hasSecondTab": false,
"secondTabText": "Send Back",
"secondTabOffset": 80,
"secondTabWidth": 65
},
"widgets_values": ["z_image_turbo_bf16.safetensors", "default"]
},
{
"id": 27,
"type": "CLIPTextEncode",
"pos": [430, 200],
"size": [410, 370],
"flags": {},
"order": 4,
"mode": 0,
"inputs": [
{
"localized_name": "clip",
"name": "clip",
"type": "CLIP",
"link": 28
},
{
"localized_name": "text",
"name": "text",
"type": "STRING",
"widget": {
"name": "text"
},
"link": 34
}
],
"outputs": [
{
"localized_name": "CONDITIONING",
"name": "CONDITIONING",
"type": "CONDITIONING",
"links": [30, 32]
}
],
"properties": {
"Node name for S&R": "CLIPTextEncode",
"cnr_id": "comfy-core",
"ver": "0.3.73",
"enableTabs": false,
"tabWidth": 65,
"tabXOffset": 10,
"hasSecondTab": false,
"secondTabText": "Send Back",
"secondTabOffset": 80,
"secondTabWidth": 65
},
"widgets_values": [
"Latina female with thick wavy hair, harbor boats and pastel houses behind. Breezy seaside light, warm tones, cinematic close-up. "
]
},
{
"id": 13,
"type": "EmptySD3LatentImage",
"pos": [110, 630],
"size": [260, 110],
"flags": {},
"order": 3,
"mode": 0,
"inputs": [
{
"localized_name": "width",
"name": "width",
"type": "INT",
"widget": {
"name": "width"
},
"link": 35
},
{
"localized_name": "height",
"name": "height",
"type": "INT",
"widget": {
"name": "height"
},
"link": 36
}
],
"outputs": [
{
"localized_name": "LATENT",
"name": "LATENT",
"type": "LATENT",
"slot_index": 0,
"links": [17]
}
],
"properties": {
"Node name for S&R": "EmptySD3LatentImage",
"cnr_id": "comfy-core",
"ver": "0.3.64",
"enableTabs": false,
"tabWidth": 65,
"tabXOffset": 10,
"hasSecondTab": false,
"secondTabText": "Send Back",
"secondTabOffset": 80,
"secondTabWidth": 65
},
"widgets_values": [1024, 1024, 1]
},
{
"id": 11,
"type": "ModelSamplingAuraFlow",
"pos": [880, 160],
"size": [310, 60],
"flags": {},
"order": 2,
"mode": 0,
"inputs": [
{
"localized_name": "model",
"name": "model",
"type": "MODEL",
"link": 26
}
],
"outputs": [
{
"localized_name": "MODEL",
"name": "MODEL",
"type": "MODEL",
"slot_index": 0,
"links": [13]
}
],
"properties": {
"Node name for S&R": "ModelSamplingAuraFlow",
"cnr_id": "comfy-core",
"ver": "0.3.64",
"enableTabs": false,
"tabWidth": 65,
"tabXOffset": 10,
"hasSecondTab": false,
"secondTabText": "Send Back",
"secondTabOffset": 80,
"secondTabWidth": 65
},
"widgets_values": [3]
},
{
"id": 3,
"type": "KSampler",
"pos": [880, 270],
"size": [315, 262],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [
{
"localized_name": "model",
"name": "model",
"type": "MODEL",
"link": 13
},
{
"localized_name": "positive",
"name": "positive",
"type": "CONDITIONING",
"link": 30
},
{
"localized_name": "negative",
"name": "negative",
"type": "CONDITIONING",
"link": 33
},
{
"localized_name": "latent_image",
"name": "latent_image",
"type": "LATENT",
"link": 17
},
{
"localized_name": "steps",
"name": "steps",
"type": "INT",
"widget": {
"name": "steps"
},
"link": 70
}
],
"outputs": [
{
"localized_name": "LATENT",
"name": "LATENT",
"type": "LATENT",
"slot_index": 0,
"links": [14]
}
],
"properties": {
"Node name for S&R": "KSampler",
"cnr_id": "comfy-core",
"ver": "0.3.64",
"enableTabs": false,
"tabWidth": 65,
"tabXOffset": 10,
"hasSecondTab": false,
"secondTabText": "Send Back",
"secondTabOffset": 80,
"secondTabWidth": 65
},
"widgets_values": [
0,
"randomize",
8,
1,
"res_multistep",
"simple",
1
]
}
],
"groups": [
{
"id": 2,
"title": "Step2 - Image size",
"bounding": [100, 560, 290, 200],
"color": "#3f789e",
"flags": {}
},
{
"id": 3,
"title": "Step3 - Prompt",
"bounding": [410, 130, 450, 540],
"color": "#3f789e",
"flags": {}
},
{
"id": 4,
"title": "Step1 - Load models",
"bounding": [100, 130, 290, 413.6],
"color": "#3f789e",
"flags": {}
}
],
"links": [
{
"id": 32,
"origin_id": 27,
"origin_slot": 0,
"target_id": 33,
"target_slot": 0,
"type": "CONDITIONING"
},
{
"id": 26,
"origin_id": 28,
"origin_slot": 0,
"target_id": 11,
"target_slot": 0,
"type": "MODEL"
},
{
"id": 14,
"origin_id": 3,
"origin_slot": 0,
"target_id": 8,
"target_slot": 0,
"type": "LATENT"
},
{
"id": 27,
"origin_id": 29,
"origin_slot": 0,
"target_id": 8,
"target_slot": 1,
"type": "VAE"
},
{
"id": 13,
"origin_id": 11,
"origin_slot": 0,
"target_id": 3,
"target_slot": 0,
"type": "MODEL"
},
{
"id": 30,
"origin_id": 27,
"origin_slot": 0,
"target_id": 3,
"target_slot": 1,
"type": "CONDITIONING"
},
{
"id": 33,
"origin_id": 33,
"origin_slot": 0,
"target_id": 3,
"target_slot": 2,
"type": "CONDITIONING"
},
{
"id": 17,
"origin_id": 13,
"origin_slot": 0,
"target_id": 3,
"target_slot": 3,
"type": "LATENT"
},
{
"id": 28,
"origin_id": 30,
"origin_slot": 0,
"target_id": 27,
"target_slot": 0,
"type": "CLIP"
},
{
"id": 16,
"origin_id": 8,
"origin_slot": 0,
"target_id": -20,
"target_slot": 0,
"type": "IMAGE"
},
{
"id": 34,
"origin_id": -10,
"origin_slot": 0,
"target_id": 27,
"target_slot": 1,
"type": "STRING"
},
{
"id": 35,
"origin_id": -10,
"origin_slot": 1,
"target_id": 13,
"target_slot": 0,
"type": "INT"
},
{
"id": 36,
"origin_id": -10,
"origin_slot": 2,
"target_id": 13,
"target_slot": 1,
"type": "INT"
},
{
"id": 38,
"origin_id": -10,
"origin_slot": 3,
"target_id": 28,
"target_slot": 0,
"type": "COMBO"
},
{
"id": 39,
"origin_id": -10,
"origin_slot": 4,
"target_id": 30,
"target_slot": 0,
"type": "COMBO"
},
{
"id": 40,
"origin_id": -10,
"origin_slot": 5,
"target_id": 29,
"target_slot": 0,
"type": "COMBO"
},
{
"id": 70,
"origin_id": -10,
"origin_slot": 6,
"target_id": 3,
"target_slot": 4,
"type": "INT"
}
],
"extra": {
"workflowRendererVersion": "LG"
}
}
]
},
"config": {},
"extra": {
"ds": {
"scale": 0.6488294314381271,
"offset": [733, 392.7886597938144]
},
"frontendVersion": "1.43.4",
"workflowRendererVersion": "LG",
"VHS_latentpreview": false,
"VHS_latentpreviewrate": 0,
"VHS_MetadataImage": true,
"VHS_KeepIntermediate": true
},
"version": 0.4
}

View File

@@ -18,6 +18,8 @@ import { ComfyNodeSearchBoxV2 } from './components/ComfyNodeSearchBoxV2'
import { ContextMenu } from './components/ContextMenu'
import { SettingDialog } from './components/SettingDialog'
import { BottomPanel } from './components/BottomPanel'
import { ConfirmDialog } from './components/ConfirmDialog'
import { QueuePanel } from './components/QueuePanel'
import {
NodeLibrarySidebarTab,
WorkflowsSidebarTab
@@ -38,7 +40,6 @@ import { SubgraphHelper } from './helpers/SubgraphHelper'
import { ToastHelper } from './helpers/ToastHelper'
import { WorkflowHelper } from './helpers/WorkflowHelper'
import type { NodeReference } from './utils/litegraphUtils'
import type { WorkspaceStore } from '../types/globals'
dotenvConfig()
@@ -111,48 +112,6 @@ class ComfyMenu {
}
}
type KeysOfType<T, Match> = {
[K in keyof T]: T[K] extends Match ? K : never
}[keyof T]
class ConfirmDialog {
private readonly root: Locator
public readonly delete: Locator
public readonly overwrite: Locator
public readonly reject: Locator
public readonly confirm: Locator
constructor(public readonly page: Page) {
this.root = page.getByRole('dialog')
this.delete = this.root.getByRole('button', { name: 'Delete' })
this.overwrite = this.root.getByRole('button', { name: 'Overwrite' })
this.reject = this.root.getByRole('button', { name: 'Cancel' })
this.confirm = this.root.getByRole('button', { name: 'Confirm' })
}
async click(locator: KeysOfType<ConfirmDialog, Locator>) {
const loc = this[locator]
await loc.waitFor({ state: 'visible' })
await loc.click()
// Wait for the dialog mask to disappear after confirming
const mask = this.page.locator('.p-dialog-mask')
const count = await mask.count()
if (count > 0) {
await mask.first().waitFor({ state: 'hidden', timeout: 3000 })
}
// Wait for workflow service to finish if it's busy
await this.page.waitForFunction(
() =>
(window.app?.extensionManager as WorkspaceStore | undefined)?.workflow
?.isBusy === false,
undefined,
{ timeout: 3000 }
)
}
}
export class ComfyPage {
public readonly url: string
// All canvas position operations are based on default view of canvas.
@@ -191,6 +150,7 @@ export class ComfyPage {
public readonly featureFlags: FeatureFlagHelper
public readonly command: CommandHelper
public readonly bottomPanel: BottomPanel
public readonly queuePanel: QueuePanel
public readonly perf: PerformanceHelper
public readonly queue: QueueHelper
@@ -237,6 +197,7 @@ export class ComfyPage {
this.featureFlags = new FeatureFlagHelper(page)
this.command = new CommandHelper(page)
this.bottomPanel = new BottomPanel(page)
this.queuePanel = new QueuePanel(page)
this.perf = new PerformanceHelper(page)
this.queue = new QueueHelper(page)
}
@@ -510,4 +471,4 @@ export const comfyExpect = expect.extend({
message: () => `Expected element to ${isFocused ? 'not ' : ''}be focused.`
}
}
})
})

View File

@@ -0,0 +1,64 @@
import type { Locator, Page } from '@playwright/test'
import type { WorkspaceStore } from '../../types/globals'
type KeysOfType<T, Match> = {
[K in keyof T]: T[K] extends Match ? K : never
}[keyof T]
/**
* Page object for the generic confirm dialog shown via `dialogService.confirm()`.
*
* Accessible on `comfyPage.confirmDialog`.
*/
export class ConfirmDialog {
readonly root: Locator
readonly delete: Locator
readonly overwrite: Locator
/** Cancel / reject button */
readonly reject: Locator
/** Primary confirm button */
readonly confirm: Locator
constructor(public readonly page: Page) {
this.root = page.getByRole('dialog')
this.delete = this.root.getByRole('button', { name: 'Delete' })
this.overwrite = this.root.getByRole('button', { name: 'Overwrite' })
this.reject = this.root.getByRole('button', { name: 'Cancel' })
this.confirm = this.root.getByRole('button', { name: 'Confirm' })
}
async isVisible(): Promise<boolean> {
return this.root.isVisible()
}
async waitForVisible(): Promise<void> {
await this.root.waitFor({ state: 'visible' })
}
async waitForHidden(): Promise<void> {
await this.root.waitFor({ state: 'hidden' })
}
async click(locator: KeysOfType<ConfirmDialog, Locator>) {
const loc = this[locator]
await loc.waitFor({ state: 'visible' })
await loc.click()
// Wait for the dialog mask to disappear after confirming
const mask = this.page.locator('.p-dialog-mask')
const count = await mask.count()
if (count > 0) {
await mask.first().waitFor({ state: 'hidden', timeout: 3000 })
}
// Wait for workflow service to finish if it's busy
await this.page.waitForFunction(
() =>
(window.app?.extensionManager as WorkspaceStore | undefined)?.workflow
?.isBusy === false,
undefined,
{ timeout: 3000 }
)
}
}

View File

@@ -0,0 +1,56 @@
import type { Locator, Page } from '@playwright/test'
import { comfyExpect as expect } from '../ComfyPage'
import { TestIds } from '../selectors'
/**
* Page object for the "Clear queue history?" confirmation dialog that opens
* from the queue panel's history actions menu.
*/
export class QueueClearHistoryDialog {
readonly root: Locator
readonly cancelButton: Locator
readonly clearButton: Locator
readonly closeButton: Locator
constructor(public readonly page: Page) {
this.root = page.getByRole('dialog')
this.cancelButton = this.root.getByRole('button', { name: 'Cancel' })
this.clearButton = this.root.getByRole('button', { name: 'Clear' })
this.closeButton = this.root.getByLabel('Close')
}
async isVisible(): Promise<boolean> {
return this.root.isVisible()
}
async waitForVisible(): Promise<void> {
await this.root.waitFor({ state: 'visible' })
}
async waitForHidden(): Promise<void> {
await this.root.waitFor({ state: 'hidden' })
}
}
export class QueuePanel {
readonly overlayToggle: Locator
readonly moreOptionsButton: Locator
readonly clearHistoryDialog: QueueClearHistoryDialog
constructor(readonly page: Page) {
this.overlayToggle = page.getByTestId(TestIds.queue.overlayToggle)
this.moreOptionsButton = page.getByLabel(/More options/i).first()
this.clearHistoryDialog = new QueueClearHistoryDialog(page)
}
async openClearHistoryDialog() {
await this.moreOptionsButton.click()
const clearHistoryAction = this.page.getByTestId(
TestIds.queue.clearHistoryAction
)
await expect(clearHistoryAction).toBeVisible()
await clearHistoryAction.click()
}
}

View File

@@ -84,6 +84,10 @@ export const TestIds = {
user: {
currentUserIndicator: 'current-user-indicator'
},
queue: {
overlayToggle: 'queue-overlay-toggle',
clearHistoryAction: 'clear-history-action'
},
errors: {
imageLoadError: 'error-loading-image',
videoLoadError: 'error-loading-video'
@@ -112,4 +116,5 @@ export type TestIdValue =
(id: string) => string
>
| (typeof TestIds.user)[keyof typeof TestIds.user]
| (typeof TestIds.queue)[keyof typeof TestIds.queue]
| (typeof TestIds.errors)[keyof typeof TestIds.errors]

View File

@@ -18,15 +18,13 @@ test.describe('Confirm dialog text wrapping', { tag: ['@mobile'] }, () => {
.catch(() => {})
}, longFilename)
const dialog = comfyPage.page.getByRole('dialog')
await expect(dialog).toBeVisible()
const dialog = comfyPage.confirmDialog
await dialog.waitForVisible()
const confirmButton = dialog.getByRole('button', { name: 'Confirm' })
await expect(confirmButton).toBeVisible()
await expect(confirmButton).toBeInViewport()
await expect(dialog.confirm).toBeVisible()
await expect(dialog.confirm).toBeInViewport()
const cancelButton = dialog.getByRole('button', { name: 'Cancel' })
await expect(cancelButton).toBeVisible()
await expect(cancelButton).toBeInViewport()
await expect(dialog.reject).toBeVisible()
await expect(dialog.reject).toBeInViewport()
})
})
})

View File

@@ -0,0 +1,137 @@
import {
comfyPageFixture as test,
comfyExpect as expect
} from '../../fixtures/ComfyPage'
test.describe('QueueClearHistoryDialog', { tag: '@ui' }, () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setup()
// Expand the queue overlay so the JobHistoryActionsMenu is visible
await comfyPage.queuePanel.overlayToggle.click()
})
test('Dialog opens from queue panel history actions menu', async ({
comfyPage
}) => {
await comfyPage.queuePanel.openClearHistoryDialog()
await expect(comfyPage.queuePanel.clearHistoryDialog.root).toBeVisible()
})
test('Dialog shows confirmation message with title, description, and assets note', async ({
comfyPage
}) => {
await comfyPage.queuePanel.openClearHistoryDialog()
const dialog = comfyPage.queuePanel.clearHistoryDialog
await expect(dialog.root).toBeVisible()
// Verify title
await expect(
dialog.root.getByText('Clear your job queue history?')
).toBeVisible()
// Verify description
await expect(
dialog.root.getByText(
'All the finished or failed jobs below will be removed from this Job queue panel.'
)
).toBeVisible()
// Verify assets note (locale uses Unicode RIGHT SINGLE QUOTATION MARK \u2019)
await expect(
dialog.root.getByText(
'Assets generated by these jobs won\u2019t be deleted and can always be viewed from the assets panel.'
)
).toBeVisible()
})
test('Cancel button closes dialog without clearing history', async ({
comfyPage
}) => {
await comfyPage.queuePanel.openClearHistoryDialog()
const dialog = comfyPage.queuePanel.clearHistoryDialog
await expect(dialog.root).toBeVisible()
// Intercept the clear API call — it should NOT be called
let clearCalled = false
await comfyPage.page.route('**/api/history', (route) => {
if (route.request().method() === 'POST') {
clearCalled = true
}
return route.continue()
})
await dialog.cancelButton.click()
await expect(dialog.root).not.toBeVisible()
expect(clearCalled).toBe(false)
await comfyPage.page.unroute('**/api/history')
})
test('Close (X) button closes dialog without clearing history', async ({
comfyPage
}) => {
await comfyPage.queuePanel.openClearHistoryDialog()
const dialog = comfyPage.queuePanel.clearHistoryDialog
await expect(dialog.root).toBeVisible()
// Intercept the clear API call — it should NOT be called
let clearCalled = false
await comfyPage.page.route('**/api/history', (route) => {
if (route.request().method() === 'POST') {
clearCalled = true
}
return route.continue()
})
await dialog.closeButton.click()
await expect(dialog.root).not.toBeVisible()
expect(clearCalled).toBe(false)
await comfyPage.page.unroute('**/api/history')
})
test('Confirm clears queue history and closes dialog', async ({
comfyPage
}) => {
await comfyPage.queuePanel.openClearHistoryDialog()
const dialog = comfyPage.queuePanel.clearHistoryDialog
await expect(dialog.root).toBeVisible()
// Intercept the clear API call to verify it is made
const clearPromise = comfyPage.page.waitForRequest(
(req) => req.url().includes('/api/history') && req.method() === 'POST'
)
await dialog.clearButton.click()
// Verify the API call was made
const request = await clearPromise
expect(request.postDataJSON()).toEqual({ clear: true })
await expect(dialog.root).not.toBeVisible()
})
test('Dialog state resets after close and reopen', async ({ comfyPage }) => {
// Open and cancel
await comfyPage.queuePanel.openClearHistoryDialog()
const dialog = comfyPage.queuePanel.clearHistoryDialog
await expect(dialog.root).toBeVisible()
await dialog.cancelButton.click()
await expect(dialog.root).not.toBeVisible()
// Reopen — dialog should be fresh (Clear button enabled, not stuck)
await comfyPage.queuePanel.openClearHistoryDialog()
await expect(dialog.root).toBeVisible()
await expect(dialog.clearButton).toBeVisible()
await expect(dialog.clearButton).toBeEnabled()
})
})

View File

@@ -10,6 +10,7 @@ import type { ComfyPage } from '../fixtures/ComfyPage'
import { DefaultGraphPositions } from '../fixtures/constants/defaultGraphPositions'
import { TestIds } from '../fixtures/selectors'
import type { NodeReference } from '../fixtures/utils/litegraphUtils'
import type { WorkspaceStore } from '../types/globals'
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled')
@@ -720,6 +721,19 @@ test.describe('Load workflow', { tag: '@screenshot' }, () => {
await expect(comfyPage.canvas).toHaveScreenshot('string_input.png')
})
test('Creates initial workflow tab when persistence is disabled', async ({
comfyPage
}) => {
await comfyPage.settings.setSetting('Comfy.Workflow.Persist', false)
await comfyPage.setup()
const openCount = await comfyPage.page.evaluate(() => {
return (window.app!.extensionManager as WorkspaceStore).workflow
.openWorkflows.length
})
expect(openCount).toBeGreaterThanOrEqual(1)
})
test('Restore workflow on reload (switch workflow)', async ({
comfyPage
}) => {

View File

@@ -68,7 +68,7 @@ test.describe(
})
})
test('Load workflow from URL dropped onto Vue node', async ({
test.fixme('Load workflow from URL dropped onto Vue node', async ({
comfyPage
}) => {
const fakeUrl = 'https://example.com/workflow.png'

View File

@@ -154,6 +154,38 @@ test.describe('Performance', { tag: ['@perf'] }, () => {
)
})
test('large graph zoom interaction', async ({ comfyPage }) => {
await comfyPage.workflow.loadWorkflow('large-graph-workflow')
const canvas = comfyPage.canvas
const box = await canvas.boundingBox()
if (!box) throw new Error('Canvas bounding box not available')
// Position mouse at center so wheel events hit the canvas
const centerX = box.x + box.width / 2
const centerY = box.y + box.height / 2
await comfyPage.page.mouse.move(centerX, centerY)
await comfyPage.perf.startMeasuring()
// Zoom in 30 steps then out 30 steps — each step triggers
// ResizeObserver for all ~245 node elements due to CSS scale change.
for (let i = 0; i < 30; i++) {
await comfyPage.page.mouse.wheel(0, -100)
await comfyPage.nextFrame()
}
for (let i = 0; i < 30; i++) {
await comfyPage.page.mouse.wheel(0, 100)
await comfyPage.nextFrame()
}
const m = await comfyPage.perf.stopMeasuring('large-graph-zoom')
recordMeasurement(m)
console.log(
`Large graph zoom: ${m.layouts} layouts, ${m.layoutDurationMs.toFixed(1)}ms layout, ${m.frameDurationMs.toFixed(1)}ms/frame, TBT=${m.totalBlockingTimeMs.toFixed(0)}ms`
)
})
test('subgraph DOM widget clipping during node selection', async ({
comfyPage
}) => {

View File

@@ -142,12 +142,12 @@ test.describe(
})
})
test.describe('Placeholder Behavior After Promoted Source Removal', () => {
test.describe('Cleanup Behavior After Promoted Source Removal', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
})
test('Removing promoted source node inside subgraph falls back to disconnected placeholder on exterior', async ({
test('Removing promoted source node inside subgraph cleans up exterior proxyWidgets', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
@@ -182,8 +182,8 @@ test.describe(
})
})
.toEqual({
proxyWidgetCount: initialWidgets.length,
firstWidgetType: 'button'
proxyWidgetCount: 0,
firstWidgetType: undefined
})
})

View File

@@ -0,0 +1,195 @@
import {
comfyPageFixture as test,
comfyExpect as expect
} from '../fixtures/ComfyPage'
/**
* Regression test for PR #10532:
* Packing all nodes inside a subgraph into a nested subgraph was causing
* the parent subgraph node's promoted widget values to go blank.
*
* Root cause: SubgraphNode had two sets of PromotedWidgetView references —
* node.widgets (rebuilt from the promotion store) vs input._widget (cached
* at promotion time). After repointing, input._widget still pointed to
* removed node IDs, causing missing-node failures and blank values on the
* next checkState cycle.
*/
test.describe(
'Nested subgraph pack preserves promoted widget values',
{ tag: ['@subgraph', '@widget'] },
() => {
const WORKFLOW = 'subgraphs/nested-pack-promoted-values'
const HOST_NODE_ID = '57'
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
})
test('Promoted widget values persist after packing interior nodes into nested subgraph', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(WORKFLOW)
await comfyPage.vueNodes.waitForNodes()
const nodeLocator = comfyPage.vueNodes.getNodeLocator(HOST_NODE_ID)
await expect(nodeLocator).toBeVisible()
// 1. Verify initial promoted widget values via Vue node DOM
const widthWidget = nodeLocator
.getByLabel('width', { exact: true })
.first()
const heightWidget = nodeLocator
.getByLabel('height', { exact: true })
.first()
const stepsWidget = nodeLocator
.getByLabel('steps', { exact: true })
.first()
const textWidget = nodeLocator.getByRole('textbox', { name: 'prompt' })
const widthControls =
comfyPage.vueNodes.getInputNumberControls(widthWidget)
const heightControls =
comfyPage.vueNodes.getInputNumberControls(heightWidget)
const stepsControls =
comfyPage.vueNodes.getInputNumberControls(stepsWidget)
await expect(async () => {
await expect(widthControls.input).toHaveValue('1024')
await expect(heightControls.input).toHaveValue('1024')
await expect(stepsControls.input).toHaveValue('8')
await expect(textWidget).toHaveValue(/Latina female/)
}).toPass({ timeout: 5000 })
// 2. Enter the subgraph via Vue node button
await comfyPage.vueNodes.enterSubgraph(HOST_NODE_ID)
expect(await comfyPage.subgraph.isInSubgraph()).toBe(true)
// 3. Disable Vue nodes for canvas operations (select all + convert)
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', false)
await comfyPage.nextFrame()
// 4. Select all interior nodes and convert to nested subgraph
await comfyPage.canvas.click()
await comfyPage.canvas.press('Control+a')
await comfyPage.nextFrame()
await comfyPage.page.evaluate(() => {
const canvas = window.app!.canvas
canvas.graph!.convertToSubgraph(canvas.selectedItems)
})
await comfyPage.nextFrame()
// 5. Navigate back to root graph and trigger a checkState cycle
await comfyPage.subgraph.exitViaBreadcrumb()
await comfyPage.canvas.click()
await comfyPage.nextFrame()
// 6. Re-enable Vue nodes and verify values are preserved
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
await comfyPage.vueNodes.waitForNodes()
const nodeAfter = comfyPage.vueNodes.getNodeLocator(HOST_NODE_ID)
await expect(nodeAfter).toBeVisible()
const widthAfter = nodeAfter.getByLabel('width', { exact: true }).first()
const heightAfter = nodeAfter
.getByLabel('height', { exact: true })
.first()
const stepsAfter = nodeAfter.getByLabel('steps', { exact: true }).first()
const textAfter = nodeAfter.getByRole('textbox', { name: 'prompt' })
const widthControlsAfter =
comfyPage.vueNodes.getInputNumberControls(widthAfter)
const heightControlsAfter =
comfyPage.vueNodes.getInputNumberControls(heightAfter)
const stepsControlsAfter =
comfyPage.vueNodes.getInputNumberControls(stepsAfter)
await expect(async () => {
await expect(widthControlsAfter.input).toHaveValue('1024')
await expect(heightControlsAfter.input).toHaveValue('1024')
await expect(stepsControlsAfter.input).toHaveValue('8')
await expect(textAfter).toHaveValue(/Latina female/)
}).toPass({ timeout: 5000 })
})
test('proxyWidgets entries resolve to valid interior nodes after packing', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(WORKFLOW)
await comfyPage.vueNodes.waitForNodes()
// Verify the host node is visible
const nodeLocator = comfyPage.vueNodes.getNodeLocator(HOST_NODE_ID)
await expect(nodeLocator).toBeVisible()
// Enter the subgraph via Vue node button, then disable for canvas ops
await comfyPage.vueNodes.enterSubgraph(HOST_NODE_ID)
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', false)
await comfyPage.nextFrame()
await comfyPage.canvas.click()
await comfyPage.canvas.press('Control+a')
await comfyPage.nextFrame()
await comfyPage.page.evaluate(() => {
const canvas = window.app!.canvas
canvas.graph!.convertToSubgraph(canvas.selectedItems)
})
await comfyPage.nextFrame()
await comfyPage.subgraph.exitViaBreadcrumb()
await comfyPage.canvas.click()
await comfyPage.nextFrame()
// Verify all proxyWidgets entries resolve
await expect(async () => {
const result = await comfyPage.page.evaluate((hostId) => {
const graph = window.app!.graph!
const hostNode = graph.getNodeById(hostId)
if (
!hostNode ||
typeof hostNode.isSubgraphNode !== 'function' ||
!hostNode.isSubgraphNode()
) {
return { error: 'Host node not found or not a subgraph node' }
}
const proxyWidgets = hostNode.properties?.proxyWidgets ?? []
const entries = (proxyWidgets as unknown[])
.filter(
(e): e is [string, string] =>
Array.isArray(e) &&
e.length >= 2 &&
typeof e[0] === 'string' &&
typeof e[1] === 'string' &&
!e[1].startsWith('$$')
)
.map(([nodeId, widgetName]) => {
const interiorNode = hostNode.subgraph.getNodeById(Number(nodeId))
return {
nodeId,
widgetName,
resolved: interiorNode !== null && interiorNode !== undefined
}
})
return { entries, count: entries.length }
}, HOST_NODE_ID)
expect(result).not.toHaveProperty('error')
const { entries, count } = result as {
entries: { nodeId: string; widgetName: string; resolved: boolean }[]
count: number
}
expect(count).toBeGreaterThan(0)
for (const entry of entries) {
expect(
entry.resolved,
`Widget "${entry.widgetName}" (node ${entry.nodeId}) should resolve`
).toBe(true)
}
}).toPass({ timeout: 5000 })
})
}
)

View File

@@ -1,6 +1,6 @@
{
"name": "@comfyorg/comfyui-frontend",
"version": "1.43.5",
"version": "1.43.6",
"private": true,
"description": "Official front-end implementation of ComfyUI",
"homepage": "https://comfy.org",
@@ -44,7 +44,7 @@
"stylelint:fix": "stylelint --cache --fix '{apps,packages,src}/**/*.{css,vue}'",
"stylelint": "stylelint --cache '{apps,packages,src}/**/*.{css,vue}'",
"test:browser": "pnpm exec nx e2e",
"test:browser:local": "cross-env PLAYWRIGHT_LOCAL=1 pnpm test:browser",
"test:browser:local": "cross-env PLAYWRIGHT_LOCAL=1 PLAYWRIGHT_TEST_URL=http://localhost:5173 pnpm test:browser",
"test:unit": "nx run test",
"typecheck": "vue-tsc --noEmit",
"typecheck:browser": "vue-tsc --project browser_tests/tsconfig.json",

View File

@@ -8,7 +8,7 @@
:get-children="
(item) => (item.children?.length ? item.children : undefined)
"
class="m-0 min-w-0 p-0 pb-2"
class="m-0 min-w-0 p-0 px-2 pb-2"
>
<TreeVirtualizer
v-slot="{ item }"

View File

@@ -28,7 +28,7 @@
<button
:class="
cn(
'hover:text-foreground flex size-6 shrink-0 cursor-pointer items-center justify-center rounded-sm border-none bg-transparent text-muted-foreground',
'hover:text-foreground flex size-4 shrink-0 cursor-pointer items-center justify-center rounded-sm border-none bg-transparent text-muted-foreground',
'opacity-0 group-hover/tree-node:opacity-100'
)
"
@@ -105,7 +105,7 @@ defineOptions({
})
const ROW_CLASS =
'group/tree-node flex w-full min-w-0 cursor-pointer select-none items-center gap-3 overflow-hidden py-2 outline-none hover:bg-comfy-input mx-2 rounded'
'group/tree-node flex w-full min-w-0 cursor-pointer select-none items-center gap-3 overflow-hidden py-2 outline-none hover:bg-comfy-input rounded'
const { item } = defineProps<{
item: FlattenedItem<RenderedTreeExplorerNode<ComfyNodeDefImpl>>

View File

@@ -22,19 +22,21 @@
:model-value="effectiveCurve.points"
:disabled="isDisabled"
:interpolation="effectiveCurve.interpolation"
:histogram="histogram"
@update:model-value="onPointsChange"
/>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { computed, watch } from 'vue'
import {
singleValueExtractor,
useUpstreamValue
} from '@/composables/useUpstreamValue'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
import { useNodeOutputStore } from '@/stores/nodeOutputStore'
import Select from '@/components/ui/select/Select.vue'
import SelectContent from '@/components/ui/select/SelectContent.vue'
@@ -63,11 +65,27 @@ const modelValue = defineModel<CurveData>({
const isDisabled = computed(() => !!widget.options?.disabled)
const nodeOutputStore = useNodeOutputStore()
const histogram = computed(() => {
const locatorId = widget.nodeLocatorId
if (!locatorId) return null
const output = nodeOutputStore.nodeOutputs[locatorId]
const data = output?.histogram
if (!Array.isArray(data) || data.length === 0) return null
return new Uint32Array(data)
})
const upstreamValue = useUpstreamValue(
() => widget.linkedUpstream,
singleValueExtractor(isCurveData)
)
watch(upstreamValue, (upstream) => {
if (isDisabled.value && upstream) {
modelValue.value = upstream
}
})
const effectiveCurve = computed(() =>
isDisabled.value && upstreamValue.value
? upstreamValue.value

View File

@@ -150,21 +150,27 @@ export function createMonotoneInterpolator(
}
/**
* Convert a 256-bin histogram into an SVG path string.
* Normalizes using the 99.5th percentile to avoid outlier spikes.
* Convert a histogram (arbitrary number of bins) into an SVG path string.
* Applies square-root scaling and normalizes using the 99.5th percentile
* to avoid outlier spikes.
*/
export function histogramToPath(histogram: Uint32Array): string {
if (!histogram.length) return ''
const len = histogram.length
if (len === 0) return ''
const sorted = Array.from(histogram).sort((a, b) => a - b)
const max = sorted[Math.floor(255 * 0.995)]
const sqrtValues = new Float32Array(len)
for (let i = 0; i < len; i++) sqrtValues[i] = Math.sqrt(histogram[i])
const sorted = Array.from(sqrtValues).sort((a, b) => a - b)
const max = sorted[Math.floor((len - 1) * 0.995)]
if (max === 0) return ''
const invMax = 1 / max
const lastIdx = len - 1
const parts: string[] = ['M0,1']
for (let i = 0; i < 256; i++) {
const x = i / 255
const y = 1 - Math.min(1, histogram[i] * invMax)
for (let i = 0; i < len; i++) {
const x = lastIdx === 0 ? 0.5 : i / lastIdx
const y = 1 - Math.min(1, sqrtValues[i] * invMax)
parts.push(`L${x},${y}`)
}
parts.push('L1,1 Z')

View File

@@ -0,0 +1,287 @@
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import {
LGraphNode,
LiteGraph,
SubgraphNode
} from '@/lib/litegraph/src/litegraph'
import type {
ExportedSubgraphInstance,
Positionable,
Subgraph
} from '@/lib/litegraph/src/litegraph'
import {
createTestSubgraph,
createTestSubgraphNode,
resetSubgraphFixtureState
} from '@/lib/litegraph/src/subgraph/__fixtures__/subgraphHelpers'
import { usePromotionStore } from '@/stores/promotionStore'
/**
* Registers a minimal SubgraphNode class for a subgraph definition
* so that `LiteGraph.createNode(subgraphId)` works in tests.
*/
function registerSubgraphNodeType(subgraph: Subgraph): void {
const instanceData: ExportedSubgraphInstance = {
id: -1,
type: subgraph.id,
pos: [0, 0],
size: [100, 100],
inputs: [],
outputs: [],
flags: {},
order: 0,
mode: 0
}
const node = class extends SubgraphNode {
constructor() {
super(subgraph.rootGraph, subgraph, instanceData)
}
}
Object.defineProperty(node, 'title', { value: subgraph.name })
LiteGraph.registerNodeType(subgraph.id, node)
}
const registeredTypes: string[] = []
afterEach(() => {
for (const type of registeredTypes) {
LiteGraph.unregisterNodeType(type)
}
registeredTypes.length = 0
})
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
resetSubgraphFixtureState()
})
describe('_repointAncestorPromotions', () => {
function setupParentSubgraphWithWidgets() {
const parentSubgraph = createTestSubgraph({
name: 'Parent Subgraph',
inputs: [{ name: 'input', type: '*' }],
outputs: [{ name: 'output', type: '*' }]
})
const rootGraph = parentSubgraph.rootGraph
// We need to listen for new subgraph registrations so
// LiteGraph.createNode works during convertToSubgraph
rootGraph.events.addEventListener('subgraph-created', (e) => {
const { subgraph } = e.detail
registerSubgraphNodeType(subgraph)
registeredTypes.push(subgraph.id)
})
const interiorNode = new LGraphNode('Interior Node')
interiorNode.addInput('in', '*')
interiorNode.addOutput('out', '*')
interiorNode.addWidget('text', 'prompt', 'hello world', () => {})
parentSubgraph.add(interiorNode)
// Create host SubgraphNode in root graph
registerSubgraphNodeType(parentSubgraph)
registeredTypes.push(parentSubgraph.id)
const hostNode = createTestSubgraphNode(parentSubgraph)
rootGraph.add(hostNode)
return { rootGraph, parentSubgraph, interiorNode, hostNode }
}
it('repoints parent promotions when interior nodes are packed into a nested subgraph', () => {
const { rootGraph, parentSubgraph, interiorNode, hostNode } =
setupParentSubgraphWithWidgets()
// Promote the interior node's widget on the host
const store = usePromotionStore()
store.promote(rootGraph.id, hostNode.id, {
sourceNodeId: String(interiorNode.id),
sourceWidgetName: 'prompt'
})
const beforeEntries = store.getPromotions(rootGraph.id, hostNode.id)
expect(beforeEntries).toHaveLength(1)
expect(beforeEntries[0].sourceNodeId).toBe(String(interiorNode.id))
// Pack the interior node into a nested subgraph
const { node: nestedSubgraphNode } = parentSubgraph.convertToSubgraph(
new Set<Positionable>([interiorNode])
)
// After conversion, the host's promotion should be repointed
const afterEntries = store.getPromotions(rootGraph.id, hostNode.id)
expect(afterEntries).toHaveLength(1)
expect(afterEntries[0].sourceNodeId).toBe(String(nestedSubgraphNode.id))
expect(afterEntries[0].sourceWidgetName).toBe('prompt')
expect(afterEntries[0].disambiguatingSourceNodeId).toBe(
String(interiorNode.id)
)
// The nested subgraph node should also have the promotion
const nestedEntries = store.getPromotions(
rootGraph.id,
nestedSubgraphNode.id
)
expect(nestedEntries).toHaveLength(1)
expect(nestedEntries[0].sourceNodeId).toBe(String(interiorNode.id))
expect(nestedEntries[0].sourceWidgetName).toBe('prompt')
})
it('preserves promotions that reference non-moved nodes', () => {
const { rootGraph, parentSubgraph, interiorNode, hostNode } =
setupParentSubgraphWithWidgets()
const remainingNode = new LGraphNode('Remaining Node')
remainingNode.addWidget('text', 'widget_b', 'b', () => {})
parentSubgraph.add(remainingNode)
const store = usePromotionStore()
store.promote(rootGraph.id, hostNode.id, {
sourceNodeId: String(interiorNode.id),
sourceWidgetName: 'prompt'
})
store.promote(rootGraph.id, hostNode.id, {
sourceNodeId: String(remainingNode.id),
sourceWidgetName: 'widget_b'
})
// Pack only the interiorNode
parentSubgraph.convertToSubgraph(new Set<Positionable>([interiorNode]))
const afterEntries = store.getPromotions(rootGraph.id, hostNode.id)
expect(afterEntries).toHaveLength(2)
// The remaining node's promotion should be unchanged
const remainingEntry = afterEntries.find(
(e) => e.sourceWidgetName === 'widget_b'
)
expect(remainingEntry?.sourceNodeId).toBe(String(remainingNode.id))
expect(remainingEntry?.disambiguatingSourceNodeId).toBeUndefined()
// The moved node's promotion should be repointed
const movedEntry = afterEntries.find((e) => e.sourceWidgetName === 'prompt')
expect(movedEntry?.sourceNodeId).not.toBe(String(interiorNode.id))
expect(movedEntry?.disambiguatingSourceNodeId).toBe(String(interiorNode.id))
})
it('does not modify promotions when converting in root graph', () => {
const parentSubgraph = createTestSubgraph({ name: 'Dummy' })
const rootGraph = parentSubgraph.rootGraph
rootGraph.events.addEventListener('subgraph-created', (e) => {
const { subgraph } = e.detail
registerSubgraphNodeType(subgraph)
registeredTypes.push(subgraph.id)
})
const node = new LGraphNode('Root Node')
node.addInput('in', '*')
node.addOutput('out', '*')
node.addWidget('text', 'value', 'test', () => {})
rootGraph.add(node)
// Converting in root graph should not throw
rootGraph.convertToSubgraph(new Set<Positionable>([node]))
})
it('uses existing disambiguatingSourceNodeId as fallback on repeat packing', () => {
const { rootGraph, parentSubgraph, interiorNode, hostNode } =
setupParentSubgraphWithWidgets()
const store = usePromotionStore()
store.promote(rootGraph.id, hostNode.id, {
sourceNodeId: String(interiorNode.id),
sourceWidgetName: 'prompt'
})
// First pack: interior node → nested subgraph
const { node: firstNestedNode } = parentSubgraph.convertToSubgraph(
new Set<Positionable>([interiorNode])
)
const afterFirstPack = store.getPromotions(rootGraph.id, hostNode.id)
expect(afterFirstPack).toHaveLength(1)
expect(afterFirstPack[0].sourceNodeId).toBe(String(firstNestedNode.id))
expect(afterFirstPack[0].disambiguatingSourceNodeId).toBe(
String(interiorNode.id)
)
// Second pack: nested subgraph → another level of nesting
const { node: secondNestedNode } = parentSubgraph.convertToSubgraph(
new Set<Positionable>([firstNestedNode])
)
// After second pack, promotion should use the disambiguatingSourceNodeId
// as fallback and point to the new nested node
const afterSecondPack = store.getPromotions(rootGraph.id, hostNode.id)
expect(afterSecondPack).toHaveLength(1)
expect(afterSecondPack[0].sourceNodeId).toBe(String(secondNestedNode.id))
expect(afterSecondPack[0].disambiguatingSourceNodeId).toBe(
String(interiorNode.id)
)
})
it('repoints promotions for multiple host instances of the same subgraph', () => {
const parentSubgraph = createTestSubgraph({
name: 'Shared Parent Subgraph',
inputs: [{ name: 'input', type: '*' }],
outputs: [{ name: 'output', type: '*' }]
})
const rootGraph = parentSubgraph.rootGraph
rootGraph.events.addEventListener('subgraph-created', (e) => {
const { subgraph } = e.detail
registerSubgraphNodeType(subgraph)
registeredTypes.push(subgraph.id)
})
const interiorNode = new LGraphNode('Interior Node')
interiorNode.addInput('in', '*')
interiorNode.addOutput('out', '*')
interiorNode.addWidget('text', 'prompt', 'shared', () => {})
parentSubgraph.add(interiorNode)
// Create TWO host SubgraphNodes pointing to the same subgraph
registerSubgraphNodeType(parentSubgraph)
registeredTypes.push(parentSubgraph.id)
const hostNode1 = createTestSubgraphNode(parentSubgraph)
const hostNode2 = createTestSubgraphNode(parentSubgraph)
rootGraph.add(hostNode1)
rootGraph.add(hostNode2)
// Promote on both hosts
const store = usePromotionStore()
store.promote(rootGraph.id, hostNode1.id, {
sourceNodeId: String(interiorNode.id),
sourceWidgetName: 'prompt'
})
store.promote(rootGraph.id, hostNode2.id, {
sourceNodeId: String(interiorNode.id),
sourceWidgetName: 'prompt'
})
// Pack the interior node
const { node: nestedNode } = parentSubgraph.convertToSubgraph(
new Set<Positionable>([interiorNode])
)
// Both hosts' promotions should be repointed to the nested node
const host1Promotions = store.getPromotions(rootGraph.id, hostNode1.id)
expect(host1Promotions).toHaveLength(1)
expect(host1Promotions[0].sourceNodeId).toBe(String(nestedNode.id))
expect(host1Promotions[0].disambiguatingSourceNodeId).toBe(
String(interiorNode.id)
)
const host2Promotions = store.getPromotions(rootGraph.id, hostNode2.id)
expect(host2Promotions).toHaveLength(1)
expect(host2Promotions[0].sourceNodeId).toBe(String(nestedNode.id))
expect(host2Promotions[0].disambiguatingSourceNodeId).toBe(
String(interiorNode.id)
)
})
})

View File

@@ -1,5 +1,6 @@
import { toString } from 'es-toolkit/compat'
import type { PromotedWidgetSource } from '@/core/graph/subgraph/promotedWidgetTypes'
import {
SUBGRAPH_INPUT_ID,
SUBGRAPH_OUTPUT_ID
@@ -9,7 +10,10 @@ import type { UUID } from '@/lib/litegraph/src/utils/uuid'
import { createUuidv4, zeroUuid } from '@/lib/litegraph/src/utils/uuid'
import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMutations'
import { LayoutSource } from '@/renderer/core/layout/types'
import { usePromotionStore } from '@/stores/promotionStore'
import {
makePromotionEntryKey,
usePromotionStore
} from '@/stores/promotionStore'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
import { forEachNode } from '@/utils/graphTraversalUtil'
@@ -1907,6 +1911,13 @@ export class LGraph
subgraphNode._setConcreteSlots()
subgraphNode.arrange()
// Repair ancestor promotions: when nodes are packed into a nested
// subgraph, any host SubgraphNode whose proxyWidgets referenced the
// moved nodes must be repointed to chain through the new nested node.
if (!this.isRootGraph) {
this._repointAncestorPromotions(nodes, subgraphNode as SubgraphNode)
}
this.canvasAction((c) =>
c.canvas.dispatchEvent(
new CustomEvent('subgraph-converted', {
@@ -1919,6 +1930,75 @@ export class LGraph
return { subgraph, node: subgraphNode as SubgraphNode }
}
/**
* After packing nodes into a nested subgraph, repoint any ancestor
* SubgraphNode promotions that referenced the moved nodes so they
* chain through the newly created nested SubgraphNode.
*/
private _repointAncestorPromotions(
movedNodes: Set<LGraphNode>,
nestedSubgraphNode: SubgraphNode
): void {
const movedNodeIds = new Set([...movedNodes].map((n) => String(n.id)))
const store = usePromotionStore()
const nestedNodeId = String(nestedSubgraphNode.id)
const graphId = this.rootGraph.id
const nestedEntries = store.getPromotions(graphId, nestedSubgraphNode.id)
const nextNestedEntries = [...nestedEntries]
const nestedEntryKeys = new Set(
nestedEntries.map((entry) => makePromotionEntryKey(entry))
)
const hostUpdates: Array<{
node: SubgraphNode
entries: PromotedWidgetSource[]
}> = []
// Find all SubgraphNode instances that host `this` subgraph.
// They live in any graph and have `type === this.id`.
const allGraphs: LGraph[] = [
this.rootGraph,
...this.rootGraph._subgraphs.values()
]
for (const graph of allGraphs) {
for (const node of graph._nodes) {
if (!node.isSubgraphNode() || node.type !== this.id) continue
const entries = store.getPromotions(graphId, node.id)
const movedEntries = entries.filter((entry) =>
movedNodeIds.has(entry.sourceNodeId)
)
if (movedEntries.length === 0) continue
for (const entry of movedEntries) {
const key = makePromotionEntryKey(entry)
if (nestedEntryKeys.has(key)) continue
nestedEntryKeys.add(key)
nextNestedEntries.push(entry)
}
const nextEntries = entries.map((entry) => {
if (!movedNodeIds.has(entry.sourceNodeId)) return entry
return {
sourceNodeId: nestedNodeId,
sourceWidgetName: entry.sourceWidgetName,
disambiguatingSourceNodeId:
entry.disambiguatingSourceNodeId ?? entry.sourceNodeId
}
})
hostUpdates.push({ node, entries: nextEntries })
}
}
if (nextNestedEntries.length !== nestedEntries.length)
store.setPromotions(graphId, nestedSubgraphNode.id, nextNestedEntries)
for (const { node, entries } of hostUpdates) {
store.setPromotions(graphId, node.id, entries)
node.rebuildInputWidgetBindings()
}
}
unpackSubgraph(
subgraphNode: SubgraphNode,
options?: { skipMissingNodes?: boolean }

View File

@@ -209,6 +209,14 @@ export class SubgraphInputNode
link.id
)
}
if (subgraphInput.linkIds.length === 0) {
subgraphInput._widget = undefined
}
subgraphInput.events.dispatch('input-disconnected', {
input: subgraphInput
})
const slotIndex = node.inputs.findIndex((inp) => inp === input)
if (slotIndex !== -1) {
node.onConnectionsChange?.(

View File

@@ -884,8 +884,20 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
usePromotionStore().demote(this.rootGraph.id, this.id, source)
}
const didSetWidgetFromEvent = !input._widget
if (didSetWidgetFromEvent)
const boundWidget =
input._widget && isPromotedWidgetView(input._widget)
? input._widget
: undefined
const hasStaleBoundWidget =
boundWidget &&
this.subgraph
.getNodeById(boundWidget.sourceNodeId)
?.widgets?.some(
(widget) => widget.name === boundWidget.sourceWidgetName
) !== true
const shouldSetWidgetFromEvent = !input._widget || hasStaleBoundWidget
if (shouldSetWidgetFromEvent)
this._setWidget(
subgraphInput,
input,
@@ -1111,6 +1123,27 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
}
}
/**
* Clears all cached promoted widget views and re-resolves `input._widget`
* bindings from the current subgraph connections. Called after ancestor
* promotions are repointed during nested subgraph packing.
*/
rebuildInputWidgetBindings(): void {
this._promotedViewManager.clear()
this._invalidatePromotedViewsCache()
for (const input of this.inputs) {
delete input.widget
delete input.pos
input._widget = undefined
const subgraphInput = input._subgraphSlot
if (!subgraphInput) continue
this._resolveInputWidget(subgraphInput, input)
}
this._syncPromotions()
}
private _resolveInputWidget(
subgraphInput: SubgraphInput,
input: INodeInputSlot

View File

@@ -298,13 +298,14 @@ describe('SubgraphWidgetPromotion', () => {
subgraph.add(vaeNode)
const outerNode = createTestSubgraphNode(subgraph)
const keptSamplerNodeId = String(samplerNode.id)
// Inject stale proxyWidgets referencing nodes that don't exist in
// this subgraph (they were packed into a nested subgraph)
outerNode.properties.proxyWidgets = [
['999', 'text'],
['998', 'text'],
[String(samplerNode.id), 'widget']
[keptSamplerNodeId, 'widget']
]
outerNode.configure(outerNode.serialize())
@@ -317,6 +318,7 @@ describe('SubgraphWidgetPromotion', () => {
expect(widgetSourceIds).not.toContain('999')
expect(widgetSourceIds).not.toContain('998')
expect(widgetSourceIds).toContain(keptSamplerNodeId)
})
})

View File

@@ -15913,6 +15913,9 @@
"sampling_mode_min_p": {
"name": "أقل قيمة p"
},
"sampling_mode_presence_penalty": {
"name": "presence_penalty"
},
"sampling_mode_repetition_penalty": {
"name": "عقوبة التكرار"
},
@@ -15927,6 +15930,10 @@
},
"sampling_mode_top_p": {
"name": "أعلى p"
},
"thinking": {
"name": "التفكير",
"tooltip": "التشغيل في وضع التفكير إذا كان النموذج يدعم ذلك."
}
},
"outputs": {
@@ -15957,6 +15964,9 @@
"sampling_mode_min_p": {
"name": "أدنى قيمة p"
},
"sampling_mode_presence_penalty": {
"name": "presence_penalty"
},
"sampling_mode_repetition_penalty": {
"name": "عقوبة التكرار"
},
@@ -15971,6 +15981,10 @@
},
"sampling_mode_top_p": {
"name": "أعلى p"
},
"thinking": {
"name": "التفكير",
"tooltip": "التشغيل في وضع التفكير إذا كان النموذج يدعم ذلك."
}
},
"outputs": {

View File

@@ -15910,9 +15910,16 @@
"image": {
"name": "image"
},
"thinking": {
"name": "thinking",
"tooltip": "Operate in thinking mode if the model supports it."
},
"sampling_mode_min_p": {
"name": "min_p"
},
"sampling_mode_presence_penalty": {
"name": "presence_penalty"
},
"sampling_mode_repetition_penalty": {
"name": "repetition_penalty"
},
@@ -15954,9 +15961,16 @@
"image": {
"name": "image"
},
"thinking": {
"name": "thinking",
"tooltip": "Operate in thinking mode if the model supports it."
},
"sampling_mode_min_p": {
"name": "min_p"
},
"sampling_mode_presence_penalty": {
"name": "presence_penalty"
},
"sampling_mode_repetition_penalty": {
"name": "repetition_penalty"
},

View File

@@ -15913,6 +15913,9 @@
"sampling_mode_min_p": {
"name": "min_p"
},
"sampling_mode_presence_penalty": {
"name": "penalización_de_presencia"
},
"sampling_mode_repetition_penalty": {
"name": "penalización_por_repetición"
},
@@ -15927,6 +15930,10 @@
},
"sampling_mode_top_p": {
"name": "top_p"
},
"thinking": {
"name": "pensando",
"tooltip": "Operar en modo de pensamiento si el modelo lo permite."
}
},
"outputs": {
@@ -15957,6 +15964,9 @@
"sampling_mode_min_p": {
"name": "min_p"
},
"sampling_mode_presence_penalty": {
"name": "penalización_de_presencia"
},
"sampling_mode_repetition_penalty": {
"name": "penalización_por_repetición"
},
@@ -15971,6 +15981,10 @@
},
"sampling_mode_top_p": {
"name": "top_p"
},
"thinking": {
"name": "pensando",
"tooltip": "Operar en modo de pensamiento si el modelo lo permite."
}
},
"outputs": {

View File

@@ -15913,6 +15913,9 @@
"sampling_mode_min_p": {
"name": "حداقل p"
},
"sampling_mode_presence_penalty": {
"name": "presence_penalty"
},
"sampling_mode_repetition_penalty": {
"name": "جریمه تکرار"
},
@@ -15927,6 +15930,10 @@
},
"sampling_mode_top_p": {
"name": "top_p"
},
"thinking": {
"name": "تفکر",
"tooltip": "در حالت تفکر عمل کنید اگر مدل از آن پشتیبانی می‌کند."
}
},
"outputs": {
@@ -15957,6 +15964,9 @@
"sampling_mode_min_p": {
"name": "حداقل p"
},
"sampling_mode_presence_penalty": {
"name": "presence_penalty"
},
"sampling_mode_repetition_penalty": {
"name": "جریمه تکرار"
},
@@ -15971,6 +15981,10 @@
},
"sampling_mode_top_p": {
"name": "top_p"
},
"thinking": {
"name": "تفکر",
"tooltip": "در حالت تفکر عمل کنید اگر مدل از آن پشتیبانی می‌کند."
}
},
"outputs": {

View File

@@ -15913,6 +15913,9 @@
"sampling_mode_min_p": {
"name": "min_p"
},
"sampling_mode_presence_penalty": {
"name": "pénalité_de_presence"
},
"sampling_mode_repetition_penalty": {
"name": "pénalité_de_répétition"
},
@@ -15927,6 +15930,10 @@
},
"sampling_mode_top_p": {
"name": "top_p"
},
"thinking": {
"name": "réflexion",
"tooltip": "Fonctionner en mode réflexion si le modèle le permet."
}
},
"outputs": {
@@ -15957,6 +15964,9 @@
"sampling_mode_min_p": {
"name": "min_p"
},
"sampling_mode_presence_penalty": {
"name": "pénalité_de_presence"
},
"sampling_mode_repetition_penalty": {
"name": "pénalité_de_répétition"
},
@@ -15971,6 +15981,10 @@
},
"sampling_mode_top_p": {
"name": "top_p"
},
"thinking": {
"name": "réflexion",
"tooltip": "Fonctionner en mode réflexion si le modèle le permet."
}
},
"outputs": {

View File

@@ -15913,6 +15913,9 @@
"sampling_mode_min_p": {
"name": "最小p"
},
"sampling_mode_presence_penalty": {
"name": "presence_penalty"
},
"sampling_mode_repetition_penalty": {
"name": "繰り返しペナルティ"
},
@@ -15927,6 +15930,10 @@
},
"sampling_mode_top_p": {
"name": "top_p"
},
"thinking": {
"name": "思考モード",
"tooltip": "モデルが対応している場合、思考モードで動作します。"
}
},
"outputs": {
@@ -15957,6 +15964,9 @@
"sampling_mode_min_p": {
"name": "最小p"
},
"sampling_mode_presence_penalty": {
"name": "presence_penalty"
},
"sampling_mode_repetition_penalty": {
"name": "繰り返しペナルティ"
},
@@ -15971,6 +15981,10 @@
},
"sampling_mode_top_p": {
"name": "top_p"
},
"thinking": {
"name": "思考モード",
"tooltip": "モデルが対応している場合、思考モードで動作します。"
}
},
"outputs": {

View File

@@ -15913,6 +15913,9 @@
"sampling_mode_min_p": {
"name": "최소 p"
},
"sampling_mode_presence_penalty": {
"name": "presence_penalty"
},
"sampling_mode_repetition_penalty": {
"name": "반복 패널티"
},
@@ -15927,6 +15930,10 @@
},
"sampling_mode_top_p": {
"name": "top_p"
},
"thinking": {
"name": "생각 중",
"tooltip": "모델이 지원하는 경우 생각 모드로 작동합니다."
}
},
"outputs": {
@@ -15957,6 +15964,9 @@
"sampling_mode_min_p": {
"name": "최소 p"
},
"sampling_mode_presence_penalty": {
"name": "presence_penalty"
},
"sampling_mode_repetition_penalty": {
"name": "반복 패널티"
},
@@ -15971,6 +15981,10 @@
},
"sampling_mode_top_p": {
"name": "top_p"
},
"thinking": {
"name": "생각 중",
"tooltip": "모델이 지원하는 경우 생각 모드로 작동합니다."
}
},
"outputs": {

View File

@@ -15913,6 +15913,9 @@
"sampling_mode_min_p": {
"name": "min_p"
},
"sampling_mode_presence_penalty": {
"name": "penalidade_de_presença"
},
"sampling_mode_repetition_penalty": {
"name": "penalidade_de_repetição"
},
@@ -15927,6 +15930,10 @@
},
"sampling_mode_top_p": {
"name": "top_p"
},
"thinking": {
"name": "pensando",
"tooltip": "Operar no modo de pensamento se o modelo suportar."
}
},
"outputs": {
@@ -15957,6 +15964,9 @@
"sampling_mode_min_p": {
"name": "min_p"
},
"sampling_mode_presence_penalty": {
"name": "penalidade_de_presença"
},
"sampling_mode_repetition_penalty": {
"name": "penalidade_de_repetição"
},
@@ -15971,6 +15981,10 @@
},
"sampling_mode_top_p": {
"name": "top_p"
},
"thinking": {
"name": "pensando",
"tooltip": "Operar no modo de pensamento se o modelo suportar."
}
},
"outputs": {

View File

@@ -15913,6 +15913,9 @@
"sampling_mode_min_p": {
"name": "min_p"
},
"sampling_mode_presence_penalty": {
"name": "штраф за присутствие"
},
"sampling_mode_repetition_penalty": {
"name": "штраф за повторение"
},
@@ -15927,6 +15930,10 @@
},
"sampling_mode_top_p": {
"name": "top_p"
},
"thinking": {
"name": "мышление",
"tooltip": "Работать в режиме мышления, если модель это поддерживает."
}
},
"outputs": {
@@ -15957,6 +15964,9 @@
"sampling_mode_min_p": {
"name": "минимальное p"
},
"sampling_mode_presence_penalty": {
"name": "штраф за присутствие"
},
"sampling_mode_repetition_penalty": {
"name": "штраф за повторение"
},
@@ -15971,6 +15981,10 @@
},
"sampling_mode_top_p": {
"name": "top_p"
},
"thinking": {
"name": "мышление",
"tooltip": "Работать в режиме мышления, если модель это поддерживает."
}
},
"outputs": {

View File

@@ -15913,6 +15913,9 @@
"sampling_mode_min_p": {
"name": "min_p"
},
"sampling_mode_presence_penalty": {
"name": "presence_penalty"
},
"sampling_mode_repetition_penalty": {
"name": "tekrar_cezası"
},
@@ -15927,6 +15930,10 @@
},
"sampling_mode_top_p": {
"name": "top_p"
},
"thinking": {
"name": "düşünme",
"tooltip": "Model destekliyorsa düşünme modunda çalış."
}
},
"outputs": {
@@ -15957,6 +15964,9 @@
"sampling_mode_min_p": {
"name": "min_p"
},
"sampling_mode_presence_penalty": {
"name": "presence_penalty"
},
"sampling_mode_repetition_penalty": {
"name": "tekrar_cezası"
},
@@ -15971,6 +15981,10 @@
},
"sampling_mode_top_p": {
"name": "top_p"
},
"thinking": {
"name": "düşünme",
"tooltip": "Model destekliyorsa düşünme modunda çalış."
}
},
"outputs": {

View File

@@ -15913,6 +15913,9 @@
"sampling_mode_min_p": {
"name": "最小 p"
},
"sampling_mode_presence_penalty": {
"name": "出現懲罰"
},
"sampling_mode_repetition_penalty": {
"name": "重複懲罰"
},
@@ -15927,6 +15930,10 @@
},
"sampling_mode_top_p": {
"name": "top_p"
},
"thinking": {
"name": "思考模式",
"tooltip": "若模型支援,則以思考模式運作。"
}
},
"outputs": {
@@ -15957,6 +15964,9 @@
"sampling_mode_min_p": {
"name": "最小 p 值"
},
"sampling_mode_presence_penalty": {
"name": "出現懲罰"
},
"sampling_mode_repetition_penalty": {
"name": "重複懲罰"
},
@@ -15971,6 +15981,10 @@
},
"sampling_mode_top_p": {
"name": "Top-P"
},
"thinking": {
"name": "思考模式",
"tooltip": "若模型支援,則以思考模式運作。"
}
},
"outputs": {

View File

@@ -15913,6 +15913,9 @@
"sampling_mode_min_p": {
"name": "最小概率"
},
"sampling_mode_presence_penalty": {
"name": "presence_penalty"
},
"sampling_mode_repetition_penalty": {
"name": "重复惩罚"
},
@@ -15927,6 +15930,10 @@
},
"sampling_mode_top_p": {
"name": "Top P"
},
"thinking": {
"name": "思考模式",
"tooltip": "如果模型支持,则以思考模式运行。"
}
},
"outputs": {
@@ -15957,6 +15964,9 @@
"sampling_mode_min_p": {
"name": "最小概率"
},
"sampling_mode_presence_penalty": {
"name": "presence_penalty"
},
"sampling_mode_repetition_penalty": {
"name": "重复惩罚"
},
@@ -15971,6 +15981,10 @@
},
"sampling_mode_top_p": {
"name": "Top P"
},
"thinking": {
"name": "思考模式",
"tooltip": "如果模型支持,则以思考模式运行。"
}
},
"outputs": {

View File

@@ -61,6 +61,13 @@ export const CORE_KEYBINDINGS: Keybinding[] = [
},
commandId: 'Comfy.ToggleLinear'
},
{
combo: {
alt: true,
key: 'µ'
},
commandId: 'Comfy.ToggleLinear'
},
{
combo: {
key: 's',

View File

@@ -171,7 +171,10 @@ export function useWorkflowPersistenceV2() {
}
const initializeWorkflow = async () => {
if (!workflowPersistenceEnabled.value) return
if (!workflowPersistenceEnabled.value) {
await loadDefaultWorkflow()
return
}
try {
const restored = await loadPreviousWorkflowFromStorage()

View File

@@ -125,7 +125,10 @@ import type {
WidgetValue
} from '@/types/simplifiedWidget'
import { cn } from '@/utils/tailwindUtil'
import { getExecutionIdFromNodeData } from '@/utils/graphTraversalUtil'
import {
getExecutionIdFromNodeData,
getLocatorIdFromNodeData
} from '@/utils/graphTraversalUtil'
import { app } from '@/scripts/app'
import InputSlot from './InputSlot.vue'
@@ -407,6 +410,12 @@ const processedWidgets = computed((): ProcessedWidget[] => {
}
: undefined
const nodeLocatorId = widget.nodeId
? widget.nodeId
: nodeData
? getLocatorIdFromNodeData(nodeData)
: undefined
const simplified: SimplifiedWidget = {
name: widget.name,
type: widget.type,
@@ -416,6 +425,7 @@ const processedWidgets = computed((): ProcessedWidget[] => {
controlWidget: widget.controlWidget,
label: widget.promotedLabel ?? widgetState?.label,
linkedUpstream,
nodeLocatorId,
options: widgetOptions,
spec: widget.spec
}

View File

@@ -76,6 +76,9 @@ export interface SimplifiedWidget<
/** Optional serialization method for custom value handling */
serializeValue?: () => unknown
/** NodeLocatorId for the node that owns this widget's execution outputs */
nodeLocatorId?: string
/** Optional input specification backing this widget */
spec?: InputSpecV2