Compare commits

..

45 Commits

Author SHA1 Message Date
Connor Byrne
3a6fe052cd feat(test-framework): extension-API test suite + compat-floor gate (I-TF)
Adds the isolated vitest config, CI workflow, and all 41×3 compat-floor
stub triples so the blast_radius≥2.0 gate passes from day one.

- vitest.extension-api.config.mts — targets src/extension-api-v2/__tests__/
- .github/workflows/ci-tests-extension-api.yaml — two jobs: vitest run +
  python3 scripts/check-compat-floor.py (exits 1 on missing stubs)
- package.json — test:extension-api / :watch / :coverage scripts
- src/extension-api/ — public declaration files (NodeHandle, WidgetHandle,
  defineNodeExtension, typed events, lifecycle hooks)
- src/extension-api-v2/__tests__/v1|v2|migration — 123 stub files covering
  BC.01–BC.41 (all 34 compat-floor categories × 3 stub types)
- packages/extension-api/ — typedoc wrapper package

compat-floor gate: OK — 102 stub files present (34 categories × 3 types)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 18:21:54 -07:00
Christian Byrne
83263fd2ee docs(arch): pass-3 evidence merge (240→634 evidence, 56→59 patterns)
Merges three pass-3 staging sources via scripts/merge-staging-pass3.py:

  R8 (clone-grep top-20 ecosystem packs)  — 887 raw rows, 380 deduped
  R9.security                             — 3 NEW S16.* dynamic-code patterns
  R9.cookiecutter / R9.guides             — sanctioned-public surface evidence

Rollup re-ranked. Notable shifts:

  rank   pattern                                              ★sum  occ
   1     S6.A1  graphToPrompt monkey-patching ⚠️ CRITICAL    23,604  15
   2     S2.N15 prototype.serialize replacement                5,996  12
   3     S11.G2 graph add/remove/findNodesByType              21,086  12
   4     S11.G3 beforeChange/afterChange batching             18,973  13
   5     S7.G1  window.LiteGraph globals as ABI               18,853  14
   6     S6.A4  app.queuePrompt patching                       3,464  11
   7     S10.D1 dynamic addInput/Output                        5,287  11
   8     S10.D2 node.connect/disconnect programmatic          18,643  14
   9     S4.W4  widget.options.values mutation                 7,985  11
  10     S2.N16 node.widgets array access                      7,086  13

S11.G3 (beforeChange batching) and S7.G1 (window globals) entered top-5 —
both drive the v2 'three orthogonal capabilities' thesis: batched mutations,
typed import deprecation path.

3 NEW S16.* patterns from security scan:
  S16.D1 SVG with onload/onerror (XSS-equivalent dynamic JS)
  S16.D3 obfuscated string-replace pipeline
  S16.D7 Python web/ template uses exec/eval to emit JS

star-cache: 105 → 119 starred repos (added Acly, ssitu, cubiq, Fannovel16,
WASasquatch, Crystools, cg-use-everywhere, mikey_nodes, etc. — actual
top-20 ecosystem heavyweights now represented).
2026-05-08 13:39:34 -07:00
Christian Byrne
581c5bb38e feat(ext-api-v2): surface-only API shim — base for Phase A stack
Adds the v2 extension API public surface:

  src/types/extensionV2.ts          (169 lines) — branded entity IDs,
    geometry primitives, slot/widget options, NodeHandle/WidgetHandle
    contracts. Pure types. The stable public surface extensions depend on.

  src/services/extensionV2Service.ts (413 lines) — surface-only impl.
    Thin pass-throughs to existing LGraphNode/widget/canvas. No new
    internal architecture; no patching guards yet (Phase A scope per D9).
    Reactive mounting via Vue EffectScope; scope registry keyed by
    extension:entityId.

  src/extensions/core/{dynamicPrompts,imageCrop,previewAny}.v2.ts
    Three smallest core extensions ported as proof-of-concept. Used
    as the I-COORD.1 reference for Simon + Austin to push back on
    the API shape with concrete code.

Phase A goals (per todo.md "Branch + phasing topology"):
  - Stable v2 surface coworkers can branch off
  - Internal methods are pass-throughs (no behavior change)
  - I-PG.A: no interception/blocking; v1 path coexists unchanged

Stacks under this branch (will be opened as draft PRs):
  - ext-v2/i-tf-test-framework
  - ext-v2/i-sr-scope-registry
  - ext-v2/i-ws-lazy-serialize
2026-05-08 13:25:20 -07:00
Christian Byrne
4d37194be6 docs(arch): pass-2 evidence sweep (52→56 patterns, 174→195 evidence)
Adds 21 evidence rows + 4 new patterns from second MCP code-search burst:

  S11.G3 graph.beforeChange/afterChange — explicit batching seam
  S7.G1  window.LiteGraph / window.comfyAPI globals (CRITICAL)
  S11.G4 graph.setDirtyCanvas — manual redraw flush idiom
  S10.D3 node.setSize(node.computeSize()) — manual relayout idiom

Pattern S11.G4 + S10.D3 together justify v2's reactive-layout value
proposition; S11.G3 motivates a first-class world.batch() API; S7.G1
sets the deprecation sequence for window globals.

star-cache.yaml grew 87 → 105 starred repos; #1 blast-radius is still
S6.A1 graphToPrompt (★17,122) — unchanged.

Reproducible via docs/architecture/extension-api-v2/scripts/add-evidence-pass2.py
2026-05-07 17:45:40 -07:00
Connor Byrne
5bb54de0d7 docs(arch): add v2 extension API touch-point database
Compatibility-surface map for the upcoming v2 extension API redesign.
52 patterns across 15 surface families derived from:
- AGENTS.md §5 (40+ repo callouts)
- ADR 0008 entity taxonomy
- widget-api-thoughts.md output system axis
- Real-world MCP code-search across 87 ecosystem repos

See docs/architecture/extension-api-v2/README.md.
2026-05-07 17:45:40 -07:00
AustinMroz
68843967cf App Mode tests (#10633)
Adds tests for
- Mobile app mode.
- Drag and drop operations in app mode
- Basic widget interaction in app mode.
- The read only state when in builder mode.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10633-App-Mode-tests-3306d73d36508154aa25d8096119a32c)
by [Unito](https://www.unito.io)
2026-05-07 15:36:32 -07:00
Yourz
8c295e7c68 fix: remove transition delay from FeedbackSection progress bar (#12059)
*PR Created by the Glary-Bot Agent*

---

## Summary

The horizontal progress bar in `FeedbackSection.vue` lagged behind the
carousel scroll position because the bar's width was driven reactively
by `useScroll`, but the `transition-all duration-200` utility animated
each width change over 200ms. As scroll continuously emits new target
widths, the bar visibly trailed the scroll position.

Removing the transition makes the bar track scroll synchronously.

## Verification

- Reproduced the lag locally on `/customers`.
- Verified post-fix that `bar.style.width` updates in the same frame as
`scrollLeft` changes (samples at scrollLeft 0 / 944 / 1600 → width 0% /
59% / 100%, with `transitionDuration: 0s`).
- `pnpm exec eslint`, `pnpm exec oxfmt --check`, `pnpm nx typecheck
website`, and `pnpm test:unit` all pass.

## Notes

No regression test added — the customers section components have no
existing unit/E2E coverage in this repo, and standing up a new test
harness for a one-line CSS fix would be disproportionate. Worth
following up on broader carousel coverage as a separate task.

## Screenshot

After fix, scrolled to second slide — progress bar tracks scroll
position synchronously.

## Screenshots

![FeedbackSection progress bar synced with scroll after
fix](https://pub-1fd11710d4c8405b948c9edc4287a3f2.r2.dev/sessions/0b55db78805d95bbfa3c5dc0515c18344f0a9f490ce551057542fb86ec998de6/pr-images/1778167277854-e235c826-8368-4e15-939a-00bad6412625.png)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12059-fix-remove-transition-delay-from-FeedbackSection-progress-bar-3596d73d36508107bc80dc38ea7ab79e)
by [Unito](https://www.unito.io)

Co-authored-by: Glary-Bot <glary-bot@users.noreply.github.com>
2026-05-07 19:31:46 +00:00
pythongosssss
219a574eed test: add failing test for deleting inner nodes with promoted widgets (#12058)
## Summary

Adds a failing test for a current bug with subgraphs where deleting an
inner node where the widget is promoted does not remove the outer widget

## Changes

- **What**: 
- add failing test

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12058-test-add-failing-test-for-deleting-inner-nodes-with-promoted-widgets-3596d73d3650811f9629c0ed3f4bc3c8)
by [Unito](https://www.unito.io)
2026-05-07 18:01:56 +00:00
Yourz
fef2cab31e fix(website): prevent video glitch when c-projection.webp loads on /customers (#12060)
*PR Created by the Glary-Bot Agent*

---

## Summary

Fix the video position glitch on `/customers` caused by
`c-projection.webp` loading.

## Root cause

In `HeroSection.vue` the hero `<img>` for `c-projection.webp` had no
`width`/`height` attributes, so the browser reserved no space for it.
When the image finished loading, the layout shifted ~192px, pushing the
`VideoPlayer` below it. `useHeroAnimation` registers a GSAP
`ScrollTrigger` parallax against the video in `onMounted` (before the
image is loaded), so the cached scroll geometry then went stale and the
video visibly glitched.

## Fix

- Add explicit `width="1568"` / `height="1763"` to the `<img>` (the
image's native size) so the browser reserves the correct aspect-ratio'd
space upfront.
- Add `h-auto` so the height attribute doesn't override the responsive
layout.
- Refresh `ScrollTrigger` on the image's `@load` (with `refresh(true)`
so measurements happen after layout has settled) as a defensive measure
for any sub-pixel adjustments.

## Test coverage

Added `apps/website/e2e/customers.spec.ts` with a regression guard that:
1. Asserts the hero `<img>` declares numeric `width`/`height`
attributes.
2. Asserts the unloaded image still reserves vertical space (>100px),
which is the exact property that prevents the video from jumping when
the image finishes loading.

Verified the test fails when the `width`/`height` attributes are removed
and passes with the fix applied.

## Verification

- `pnpm typecheck` clean.
- `pnpm test:unit` (website app) — 30/30 pass.
- `pnpm test:e2e customers.spec.ts --project=desktop` — passes.
- ESLint + oxfmt clean on changed files.
- Manual Playwright verification confirmed the video bounding rect stays
at `top=785, height=628` through the full image load lifecycle.
Reverting the fix in DevTools and re-loading the image reproduces the
original ~192px shift.

## Out of scope

`apps/website/src/components/contact/FormSection.vue` uses the same
`c-projection.webp` pattern (also without `width`/`height`). It runs
`useHeroAnimation` with `parallax: false`, so the symptom is much
smaller — leaving as a follow-up to keep this PR minimal.

- Fixes
[FE-607](https://linear.app/comfyorg/issue/FE-607/bug-video-on-customers-shifts-position-when-c-projectionwebp-finishes)

## Screenshots

![/customers page rendered with the fix applied — video stays in correct
position below the
hero](https://pub-1fd11710d4c8405b948c9edc4287a3f2.r2.dev/sessions/0ecf73b69b644739e2bdcce0e77bbe7eb44562f7643b82addc8903d68d25fef3/pr-images/1778167649187-841bf1fa-f3a7-4104-9dab-a024e62dcf4d.jpg)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12060-fix-website-prevent-video-glitch-when-c-projection-webp-loads-on-customers-3596d73d365081ebbcb8db25aaa5c451)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Glary-Bot <glary-bot@users.noreply.github.com>
2026-05-07 15:38:39 +00:00
pythongosssss
20ee262f78 fix: prevent enter subgraph/toggle advanced when nodes were dragged (#12051)
## Summary

In Vue nodes mode, if you drag a node by the footer button (e.g. enter
subgraph) after you finish dragging, it then unexpectedly still enters
the subgraph, same applies to advanced widgets.

## Changes

- **What**: 
- store `isDraggingVueNodes` before click event and only emit if false

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12051-fix-prevent-enter-subgraph-toggle-advanced-when-nodes-were-dragged-3596d73d365081929173d8e9371b1332)
by [Unito](https://www.unito.io)
2026-05-07 14:20:09 +00:00
pythongosssss
6a8c453659 test: add missing emitModelReady to mock object (#12056)
## Summary

Unblock CI due to missing function in mock
```
⎯⎯⎯⎯ Unhandled Rejection ⎯⎯⎯⎯⎯
TypeError: this.load3d.emitModelReady is not a function
 ❯ src/extensions/core/load3d/Load3DConfiguration.ts:313:19
    311|       }
    312| 
    313|       this.load3d.emitModelReady()
       |                   ^
    314|     }
    315|   }

This error originated in "src/extensions/core/load3d/Load3DConfiguration.test.ts" test file. It doesn't mean the error was thrown inside the file itself, but while it was running.
The latest test that might've caused the error is "prefers persisted Scene/Camera/Light Config over settings". It might mean one of the following:
- The error was thrown, while Vitest was running this test.
- If the error occurred after the test had been completed, this was the last documented test before it was thrown.
⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯


 Test Files  773 passed (773)
      Tests  10417 passed | 8 skipped (10425)
     Errors  2 errors
   Start at  12:06:06
   Duration  446.79s (transform 32.89s, setup 113.26s, import 589.18s, tests 109.19s, environment 228.45s)
```

## Changes

- **What**: 
- add `emitModelReady` fn to mock

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12056-test-add-missing-emitModelReady-to-mock-object-3596d73d36508130a087f5c7713e932f)
by [Unito](https://www.unito.io)
2026-05-07 14:04:21 +00:00
Terry Jia
ea277dec4d FE-446: test(load3d): cover Load3D/Preview3D extensions and config persistence (#11969)
## Summary
Add unit tests for src/extensions/core/load3d.ts (Load3D and Preview3D
nodeCreated/onExecuted, beforeRegisterNodeDef, getNodeMenuItems, camera
matrix generation guard) and extend Load3DConfiguration tests for the
Scene/Camera/Light config persistence + settingStore fallback paths.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11969-FE-446-test-load3d-cover-Load3D-Preview3D-extensions-and-config-persistence-3576d73d365081c2a847e0dc9621a75d)
by [Unito](https://www.unito.io)
2026-05-07 07:56:07 -04:00
Terry Jia
a7aa124c10 FE-407: fix(assets): show 3D thumbnail without reopening the panel (#11972)
## Summary
After persistThumbnail uploads the preview, patch the in-memory asset by
name (the cross-API stable id) so an open Asset panel reflects the new
preview_url. Media3DTop also picks up the patched preview_url directly,
bypassing the IntersectionObserver gate.

## Screenshots (if applicable)
before


https://github.com/user-attachments/assets/ba0b753f-fede-43c0-b790-5c19a82455f9


after


https://github.com/user-attachments/assets/6273ec0b-0d2e-4355-9889-68c3550f1a72

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11972-FE-407-fix-assets-show-3D-thumbnail-without-reopening-the-panel-3576d73d365081febcbfe6ae84a4a68b)
by [Unito](https://www.unito.io)
2026-05-07 07:55:30 -04:00
Terry Jia
9c62bbc74a FE-412: fix(load3d): persist SaveGLB last model so it survives tab switch (#11965)
## Summary
Mirror the Preview3D pattern: store the executed file path and folder on
node.properties and reload them in nodeCreated so the mesh re-appears
when the node is recreated (e.g. after switching workflow tabs).

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11965-FE-412-fix-load3d-persist-SaveGLB-last-model-so-it-survives-tab-switch-3576d73d365081a98bcadbd9a81539d0)
by [Unito](https://www.unito.io)
2026-05-07 07:54:45 -04:00
Benjamin Lu
f0e16cdf46 ci: handle skipped e2e workflow consumers (#11575)
## Summary

Follow-up to #11568 and #11785. Keeps the E2E coverage workflow clean
when `CI: Tests E2E` is intentionally skipped and no coverage shard
artifacts are produced.

## Changes

- Detect whether downloaded E2E coverage shard artifacts contain any
`coverage.lcov` files.
- Treat missing coverage shards as an intentionally skipped coverage run
instead of running lcov, Codecov, or Pages deployment on missing files.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11575-ci-handle-skipped-e2e-workflow-consumers-34b6d73d3650813f92e1d7d6af0bf958)
by [Unito](https://www.unito.io)
2026-05-07 04:20:36 -07:00
jaeone94
0658c1ac9c refactor: align asset pagination schema (#11899)
## Summary

Align the asset list pagination schema with generated ingest-types
metadata and remove the now-unneeded missing `has_more` fallback branch.

## Changes

- **What**: Reuse `zListAssetsResponse` for `total` and `has_more`, keep
the local loose `AssetItem` shape, and simplify `getAllAssetsByTag()` to
trust the required `has_more` contract.
- **Breaking**: None.
- **Dependencies**: None.

## Review Focus

This is PR 1 of 4 in the missing asset follow-up stack:

1. This PR - Asset schema / pagination cleanup
2. #11900 - Missing asset hash verification utility cleanup
3. #11901 - Browser regression coverage for public input assets
4. #11902 - TanStack Query public-input cache replacement

The key decision is intentionally narrow: pagination metadata now comes
from generated ingest-types, but asset item validation remains locally
loose to avoid changing UI/store synthetic asset shapes in this PR.
`asset_hash` nullability remains unchanged because absent-vs-null hash
semantics are still a backend/API contract follow-up.

Addresses #11894

## Screenshots (if applicable)

N/A
2026-05-07 10:27:15 +00:00
pythongosssss
997501d8fb test: add e2e test for metadata parsing on workflow load (#11522)
## Summary

Adds e2e testing to ensure workflows are correctly loaded from each of
the supported file types

## Changes

- **What**: 
- add png generation
- add mime types for missing files
- add test that loads file and ensures node is present

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11522-test-add-e2e-test-for-metadata-parsing-on-workflow-load-3496d73d36508101ad67d24af1810cec)
by [Unito](https://www.unito.io)
2026-05-07 09:58:52 +00:00
Christian Byrne
ab6e5ba094 feat: boost SaveImageAdvanced node frequency for search ranking (#11853)
*PR Created by the Glary-Bot Agent*

---

Adds an entry for the new `SaveImageAdvanced` node to
`public/assets/sorted-custom-node-map.json` with the same frequency stat
(1762) as the existing `SaveImage` node, so the new Save Image node
ranks at the top of search results when typing "save" — matching the
original node's behavior.

Context: the new Save Image node ([Notion
spec](https://www.notion.so/comfy-org/Save-Image-94a77c506ce145fc9b8c477c52091a04))
replaces/deprecates the original `SaveImage`. Search ranking uses the
static node frequency map; the new node had no entry and was therefore
ranked at frequency 0. Mirroring the original's stat is the manual-boost
approach discussed in the thread.

## Changes
- `public/assets/sorted-custom-node-map.json`: add `"SaveImageAdvanced":
1762` directly after `"SaveImage": 1762` to preserve descending sort
order.

## Verification
- `pnpm typecheck`, `pnpm lint`, and `pnpm format` all pass via
lint-staged on commit.
- JSON validated and entry placement confirmed (position 4, between
`SaveImage` and `VAEDecode`).
- Review (oracle) ran clean: 0 critical / 0 warning / 0 suggestion.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11853-feat-boost-SaveImageAdvanced-node-frequency-for-search-ranking-3546d73d36508168b058d9d750fc3c56)
by [Unito](https://www.unito.io)

Co-authored-by: Glary-Bot <glary-bot@users.noreply.github.com>
2026-05-07 09:45:25 +00:00
Yourz
2322a5a497 fix: use webm video for VFX use case right asset (#12040)
*PR Created by the Glary-Bot Agent*

---

## Summary

Replaces `right1.webp` with `right1.webm` in the VFX panel of
`UseCaseSection`. `BlobMedia` already auto-detects `.webm` URLs and
mounts a `<video>` element (with the `.webp` as poster), so this single
URL swap is the only change required — matching the pattern used by the
other 4 use-case panels.

## Files changed

- `apps/website/src/components/home/UseCaseSection.vue` — swap
`right1.webp` → `right1.webm`.

## Verification

- `pnpm exec nx run website:typecheck` — clean
- `pnpm exec eslint` on changed file — clean
- `pnpm exec oxfmt --check` — clean
- pre-commit lint-staged hooks — passed

## Reviewer note (Oracle finding)

VFX is the default active panel, so the homepage's initial right-rail
asset moves from ~131 KB `.webp` to ~4.1 MB `.webm`. Behaviorally
consistent with the other 4 panels (which already use `.webm`), but
worth confirming whether `right1.webm` should be re-encoded smaller on
the CDN before promoting this PR out of draft.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12040-fix-use-webm-video-for-VFX-use-case-right-asset-3596d73d365081829976f37b733840f1)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Glary-Bot <glary-bot@users.noreply.github.com>
Co-authored-by: Amp <amp@ampcode.com>
2026-05-07 09:32:53 +00:00
Dante
0bc951fd12 fix: clarify unsaved-changes modal buttons and fix sign-out 3-state (#11669)
## Summary

The dirtyClose modal had three buttons (`Cancel | No | Save`) and the
sign-out flow collapsed two distinct outcomes (deny vs. dismiss) into a
single early return — so today clicking "No" *cancels* sign-out instead
of signing out without saving, and clicking "Save" never actually saves
before logging out. This PR drops `Cancel` for `dirtyClose`, gives each
caller a context-specific deny label, and fixes the sign-out 3-state
handling.

- Fixes
[FE-419](https://linear.app/comfyorg/issue/FE-419/unsaved-changes-modal-uses-confusing-button-labels)

## Changes

- **What**:
- `ConfirmationDialogContent.vue`: hide `Cancel` for
`type='dirtyClose'`; add `denyLabel?: string` prop; autofocus `Save`
(preserves work on Enter).
  - `dialogService.confirm()`: accept and forward `denyLabel`.
- `useAuthActions.logout`: handle `null` (cancel) / `false` (sign out
anyway, no save) / `true` (save each modified workflow, then logout)
distinctly. Pass `denyLabel: 'Sign out anyway'`.
  - `workflowService.closeWorkflow`: pass `denyLabel: 'Close anyway'`.
- i18n: add `auth.signOut.signOutAnyway` and
`sideToolbar.workflowTab.closeAnyway`.
- **Breaking**: none. The `denyLabel` prop is optional and falls back to
`g.no`.

## Review Focus

- The "Save" branch in `useAuthActions.logout` now iterates
`workflowStore.modifiedWorkflows` and awaits
`useWorkflowService().saveWorkflow(workflow)` for each before calling
`authStore.logout()`. The close-tab path
(`workflowService.closeWorkflow`) was already correct — only the
sign-out path needed the same shape.
- `ConfirmationDialogContent` autofocus moves from `Cancel` (gone for
`dirtyClose`) to `Save`. The dialog is still dismissable via ESC /
outside-click, which routes through `dialogComponentProps.onClose →
resolve(null)` — sign-out and close-tab both treat `null` as cancel.
- Out of scope: the native browser `beforeunload` warning
(`UnloadWindowConfirmDialog.vue`) is a separate flow and never reaches
the in-app modal.

## Tests

- Unit (`useAuthActions.test.ts`, new): logout handles `null` / `false`
/ `true` / no-modified-workflows; saves *every* modified workflow before
`authStore.logout`; passes `denyLabel='Sign out anyway'`.
- Unit (`ConfirmationDialogContent.test.ts`): Cancel hidden for
`dirtyClose`; custom `denyLabel` rendered; falls back to `g.no` when
omitted.
- E2E (`workflowTabs.spec.ts`): modified-tab close shows `Close anyway`
(not `No`) and no `Cancel`; clicking `Close anyway` removes the tab; ESC
keeps the tab.

## screenshot

### AS IS 

<img width="816" height="379" alt="Screenshot 2026-04-27 at 5 40 19 PM"
src="https://github.com/user-attachments/assets/a8e39403-bf72-455a-8d86-6ceb1f94ac85"
/>

<img width="923" height="396" alt="Screenshot 2026-04-27 at 5 40 38 PM"
src="https://github.com/user-attachments/assets/08031c7c-b3a6-45d7-a4dc-5dcb4e63cfa0"
/>


### TO BE 

<img width="1661" height="872" alt="Screenshot 2026-04-27 at 5 43 40 PM"
src="https://github.com/user-attachments/assets/b89d160b-be66-450e-981e-32b1591f6841"
/>


<img width="1488" height="584" alt="Screenshot 2026-04-27 at 5 44 21 PM"
src="https://github.com/user-attachments/assets/b3a141a7-1f3b-4f25-85a9-49529229c28b"
/>


┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11669-fix-clarify-unsaved-changes-modal-buttons-and-fix-sign-out-3-state-34f6d73d365081bf8afad8e146b3b990)
by [Unito](https://www.unito.io)
2026-05-07 02:49:02 -07:00
Christian Byrne
0446ca7a18 fix: route default topbar feedback button to Typeform (#11863)
*PR Created by the Glary-Bot Agent*

---

## Summary

PR #10890 routed the legacy action bar feedback button and the Help
Center feedback item to the nightly Typeform survey, but the **default
topbar feedback button** in `WorkflowTabs.vue` still called
`buildFeedbackUrl()` and opened Zendesk. Since `Comfy.UI.TabBarLayout`
defaults to `Default` (not `Legacy`), most Cloud/Nightly users were
clicking the WorkflowTabs button and never reaching the Typeform survey
— explaining the lack of survey responses.

## Changes

- Added a shared `buildFeedbackTypeformUrl(source)` helper in
`platform/support/config.ts` that tags the survey URL with:
- `distribution`: `ccloud` / `oss-nightly` / `oss` (preserves the
build-tagging the old `buildFeedbackUrl()` sent to Zendesk so responses
stay segmented)
- `source`: `topbar` / `action-bar` / `help-center` (identifies which UI
entry point launched the survey)

Tags are passed via the URL fragment (Typeform's hidden-field
convention), so they reach the survey but are never sent to the server
in the request line.
- `WorkflowTabs.vue`: replaced `buildFeedbackUrl()` with
`buildFeedbackTypeformUrl('topbar')`.
- `cloudFeedbackTopbarButton.ts` and `HelpCenterMenuContent.vue`: use
the shared builder with their respective source labels instead of inline
URL literals.
- Removed the now-unused `buildFeedbackUrl()` and
`ZENDESK_FEEDBACK_FORM_ID` (knip-clean). `buildSupportUrl()` is
preserved — `Comfy.ContactSupport` (the Help Center "Help" item) still
routes to Zendesk as before.
- Added unit tests for the builder, the WorkflowTabs feedback button,
the legacy action bar button, and the Help Center feedback item
(covering both the Cloud/Nightly Typeform path and the OSS
`Comfy.ContactSupport` fallback).

## Verification

- `pnpm format`, `pnpm lint`, `pnpm typecheck`, `pnpm knip`: clean (one
pre-existing unrelated lint warning in `useWorkspaceBilling.test.ts`)
- `pnpm test:unit` (impacted scope): 506/506 passing, including 13 new
tests

## Review Focus

- Cloud/Nightly gating in `WorkflowTabs.vue` (`v-if="isCloud ||
isNightly"`) is unchanged and matches PR #10890's gating philosophy.
- The Help Center "Help" item and `Comfy.ContactSupport` command
intentionally still route to Zendesk — feedback ≠ support.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11863-fix-route-default-topbar-feedback-button-to-Typeform-3556d73d3650815fb446dac33095d4be)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Glary-Bot <glary-bot@users.noreply.github.com>
2026-05-07 02:45:05 -07:00
Terry Jia
653ee48444 FE-557: fix(painter): responsive label layout + correct resize min-height (#12025)
## Summary

- WidgetPainter: stack label above widget when controls width < 350px,
side-by-side otherwise; labels are always rendered (no longer hidden
when narrow).
- useNodeResize: re-measure the node's intrinsic min content height on
every pointermove instead of capturing it once at drag start, so the
height clamp tracks widgets whose controls reflow taller as width
shrinks (e.g. painter switching to compact layout). Without this, the
node visually sticks at its current height and the user has to release
and grab the corner again to free it.
- Add unit tests for both changes.

## Screenshots (if applicable)
before


https://github.com/user-attachments/assets/74889ad5-63a7-439f-b8e4-0185ed95327f


after


https://github.com/user-attachments/assets/bca77c36-2f90-4685-8603-f8f9c02abe77

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12025-FE-557-fix-painter-responsive-label-layout-correct-resize-min-height-3586d73d365081cf9036f7d52bfabe6c)
by [Unito](https://www.unito.io)
2026-05-07 04:11:51 -04:00
Yourz
81d9df61f2 fix(website): tighten GitHub ticker spacing on wide screens (#12021)
*PR Created by the Glary-Bot Agent*

---

## Summary

The GitHub star ticker reads as detached from the GitHub octocat icon on
wider viewports. The CSS gap is constant (`gap-2` = 8px), but the 28px
icon next to a 20px badge plus the fixed gap make the pair look like two
separate items at `lg+` widths instead of a single coupled unit.

## Change

`apps/website/src/components/common/GitHubStarBadge.vue`:

- Inner gap `gap-2` → `gap-1` (8px → 4px) so the badge and icon sit
closer together.
- Icon `size-7` → `size-6 shrink-0` (28px → 24px) so the icon height is
closer to the badge height (20px) and the pair reads as one unit.

The outer `gap-2` between CTA items in `SiteNav.vue` is intentionally
unchanged — it is the correct spacing between unrelated CTA elements.

## Verification

- `pnpm typecheck` — 0 errors (1 pre-existing hint in unrelated file).
- `pnpm format:check` — clean.
- `pnpm exec eslint
apps/website/src/components/common/GitHubStarBadge.vue` — clean.
- `pnpm test:unit` (apps/website) — 30/30 pass.
- Pre-commit hook (oxfmt + oxlint + eslint + typecheck +
typecheck:website) — passed.
- `/review` (Oracle) — 0 critical, 0 warnings, 0 suggestions.
- Manual visual verification at 1280px, 1920px, and 2560px viewports.
- Tested longer star strings (`999.9K`, `1.2M`) — no wrapping.

## Before / After (2560px viewport)

Before: badge and octocat appear visually detached.
After: badge and octocat read as a single tightly-coupled unit.

## Screenshots

![Before — 2560px nav: 112K badge looks detached from GitHub
icon](https://pub-1fd11710d4c8405b948c9edc4287a3f2.r2.dev/sessions/5ac405b162f5db2c1f077b87e73caf4339911cb07834849a01c4b97409d844d3/pr-images/1778054509381-5426120f-a699-4633-8645-62d5b7e26493.png)

![After — 2560px nav: 112K badge and GitHub icon read as one
unit](https://pub-1fd11710d4c8405b948c9edc4287a3f2.r2.dev/sessions/5ac405b162f5db2c1f077b87e73caf4339911cb07834849a01c4b97409d844d3/pr-images/1778054509735-fc6c7bbe-4cd8-4f40-a8c7-9e5b9d0ad2a2.png)

![Before — 1280px nav
baseline](https://pub-1fd11710d4c8405b948c9edc4287a3f2.r2.dev/sessions/5ac405b162f5db2c1f077b87e73caf4339911cb07834849a01c4b97409d844d3/pr-images/1778054510078-3c5e0b33-b6f4-4b85-b6a6-6ca0c1651e85.png)

![After — 1280px nav: still visually
balanced](https://pub-1fd11710d4c8405b948c9edc4287a3f2.r2.dev/sessions/5ac405b162f5db2c1f077b87e73caf4339911cb07834849a01c4b97409d844d3/pr-images/1778054510429-db5233f4-3f16-402e-b608-6f2230ff8ced.png)

![After — 2560px CTA close-up: badge + icon tightly
coupled](https://pub-1fd11710d4c8405b948c9edc4287a3f2.r2.dev/sessions/5ac405b162f5db2c1f077b87e73caf4339911cb07834849a01c4b97409d844d3/pr-images/1778054510733-6305f2a6-408c-40b8-b3ea-3ae10cb1d171.png)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12021-fix-website-tighten-GitHub-ticker-spacing-on-wide-screens-3586d73d365081be8d66dfbb22b8dc2c)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Glary-Bot <glary-bot@users.noreply.github.com>
Co-authored-by: github-actions <github-actions@github.com>
Co-authored-by: Alexander Brown <drjkl@comfy.org>
2026-05-07 06:03:27 +00:00
Rizumu Ayaka
f4358cb161 perf: drop useMouseInElement to fix templates search lag (#12023)
## Summary

Replace `useMouseInElement` in `CompareSliderThumbnail` with a local
`@mousemove` handler to eliminate ~1s of forced layout per keystroke in
the templates dialog search.

## Changes

- **What**: Profiling the templates dialog search (4× CPU throttle,
cloud distribution) showed a single `getClientRects` block at 977ms
inside Vue's `flushPostFlushCbs → update` path on every keystroke. The
call traces back to VueUse's `useMouseInElement`, which (a) shares a
global `mousemove` listener via `useMouse`, and (b) runs
`el.getBoundingClientRect()` inside a `watch([targetRef, x, y], …, {
immediate: true })`. Every template card with `thumbnailVariant ===
'compareSlider'` mounts one instance — when search filters change and
the page's compareSlider cards re-mount, each instance's `immediate`
watch fires a rect read against freshly-inserted DOM, forcing
synchronous layout of the entire new subtree. With many cards on screen
the costs stack into the ~977ms block. Replaced with a native
`@mousemove` listener bound directly to the slider container; the rect
is read from `event.currentTarget` only when the mouse is actually over
that one card, so the work no longer scales with mounted instance count
and is gated by real pointer activity.
- **Breaking**: None
- **Dependencies**: None

## Review Focus

- UX is unchanged: `sliderPosition` still follows the mouse during hover
and keeps its last value on mouseleave (matches previous behaviour where
`if (!isHovered) return` simply stopped updates without resetting).
- The same `useMouseInElement` pattern still exists in
`src/platform/workflow/sharing/composables/useSliderFromMouse.ts` (used
by `ComfyHubThumbnailStep` in the publish dialog). That path is
single-instance and off the templates hot path, so it's left untouched
to keep this PR scoped — happy to fix it in a follow-up.
- New tests use `userEvent.pointer({ coords })` with a stubbed
`getBoundingClientRect` to lock in the native mousemove path and the
zero-width guard.

## E2E coverage

No `browser_tests/` changes. The `fix:` commit is a defensive clamp
inside `updateSliderPosition`; the overshoot it guards against comes
from subpixel rounding and stale rects observed during hover-in, neither
of which can be deterministically reproduced under Playwright. The perf
change itself is verified via the DevTools profiler (forced-layout block
disappears) — also not assertable as a stable e2e signal across CI
hardware. Both the mousemove-driven slider behavior and the clamp guard
are covered by unit tests in
`src/components/templates/thumbnails/CompareSliderThumbnail.test.ts` (8
cases, including out-of-range pointer coordinates and zero-width
containers).
2026-05-07 05:03:33 +00:00
Comfy Org PR Bot
5948002dee 1.45.0 (#12037)
Minor version increment to 1.45.0

**Base branch:** `main`

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12037-1-45-0-3596d73d365081609323df8b5aac04ce)
by [Unito](https://www.unito.io)

---------

Co-authored-by: DrJKL <448862+DrJKL@users.noreply.github.com>
Co-authored-by: github-actions <github-actions@github.com>
2026-05-07 04:28:55 +00:00
jaeone94
1ab9752af8 fix: keep Reka overlays above PrimeVue dialogs (#12038)
## Summary

Temporarily patch FE-569 by keeping the affected portaled Reka dropdowns
and menus above their containing PrimeVue dialogs when PrimeVue auto
z-index state has been elevated.

## Changes

- **What**: Added a small compatibility helper,
`usePrimeVueOverlayChildStyle`, that returns an anchor ref plus a
computed inline style for child popover content. The helper finds the
nearest PrimeVue dialog mask (`.p-dialog-mask` / `.p-overlay-mask`) from
the parent surface and, only when found, applies `parent z-index + 1` to
the affected Reka overlay content.
- **What**: Applied that helper at the exact PrimeVue parent surfaces
where the issue was found. This PR does not add a global overlay policy
and does not change every Reka select/dropdown in the app.
- **What**: Added optional `contentStyle`/`selectContentStyle` plumbing
only where needed so the style reaches the actual portaled Reka overlay
root.
- **What**: Added focused unit coverage for the helper contract: no
PrimeVue parent preserves existing stacking, PrimeVue dialog/overlay
masks render child content above the parent, low parent z-index values
respect the Reka floor, and invalid z-index values do not inject an
inline override.
- **Approach**: This is intentionally a minimal, parent-scoped band-aid.
It avoids a global PrimeVue overlay scanner because global sampling can
be polluted by unrelated persistent PrimeVue roots such as Toast and
would turn this fix into a broader layering policy.
- **Approach**: The patch targets the confirmed failure mode: a Reka
child overlay rendering below its owning PrimeVue dialog after PrimeVue
autoZIndex has been elevated. It does not attempt to solve PrimeVue
z-index globally.
- **Lifecycle**: This is temporary migration compatibility. PrimeVue
dialogs and controls are being incrementally migrated to Reka UI, so
`usePrimeVueOverlayChildStyle` and the optional style props added for
FE-569 should be removed once the affected parent surfaces move to Reka.
- **Breaking**: None. New props are optional and no public API contract
is changed.
- **Dependencies**: None.

## Patched Entry Points

This PR pinpoints the six affected user-facing surfaces below. Each
patch is applied from the PrimeVue dialog parent and passed only to the
Reka child overlay content that can render underneath that parent.


https://github.com/user-attachments/assets/d0d1522a-ffc7-4934-9e7a-06b83e20f809

1. **Workflow Template Library filters**
- **How to enter**: click the Templates button in the left sidebar, or
open the Comfy menu and choose **Browse Templates**.
- **Affected elements**: the template filter popovers in
`WorkflowTemplateSelectorDialog`: **Model**, **Use case**, **Runs on**,
and **Sort by**.
- **Patch point**: `WorkflowTemplateSelectorDialog.vue` anchors to the
template dialog content filter area and passes `selectContentStyle` to
the affected `MultiSelect` / `SingleSelect` controls.


https://github.com/user-attachments/assets/3641fa24-da51-4392-a904-9085f8a5a2f4

2. **Manager dialog header controls**
- **How to enter**: open Manager from the top/menu Manager entry when
the new Manager UI is available.
- **Affected elements**: the Manager header controls in `ManagerDialog`:
search mode `SingleSelect`, search autocomplete suggestions, and
**Sort** `SingleSelect`.
- **Patch point**: `ManagerDialog.vue` anchors to the dialog header and
passes `selectContentStyle` to those three Reka overlays.


https://github.com/user-attachments/assets/cf25cc06-f851-48ef-9d9c-9ec2da8afc06

3. **Asset Browser filter bar**
- **How to enter**: open the Asset Browser from an eligible model widget
browse action, the Model Library flow, or another
`useAssetBrowserDialog` caller.
- **Affected elements**: `AssetFilterBar` controls: **File formats**,
**Base models**, **Ownership**, and **Sort by**.
- **Patch point**: `AssetBrowserModal.vue` anchors to the PrimeVue
dialog header and passes the style through `AssetFilterBar` to its
`MultiSelect` / `SingleSelect` controls.


https://github.com/user-attachments/assets/e27bd805-10c0-4b3b-97f3-9e11faa47021

4. **Asset Browser model info panel**
- **How to enter**: open Asset Browser, select an asset, then use the
right-side model info panel.
- **Affected element**: the **Model type** select in `ModelInfoPanel`.
- **Patch point**: `AssetBrowserModal.vue` reuses the same parent-scoped
style and passes it to `ModelInfoPanel` as `selectContentStyle`.


https://github.com/user-attachments/assets/5e9f7ef0-ebd7-4987-ba1b-2137c034086f

5. **Upload Model confirmation step**
- **How to enter**: open Asset Browser, click **Upload**, enter/fetch
model metadata, then proceed to the confirmation step.
- **Affected element**: the **Model type** `SingleSelect` in
`UploadModelConfirmation`.
- **Patch point**: `UploadModelConfirmation.vue` anchors within the
upload dialog content and passes `selectContentStyle` to the model type
selector.



https://github.com/user-attachments/assets/ec145f26-8621-455b-915e-bedee47e1cbd

6. **Settings > Keybinding panel controls**
- **How to enter**: open Settings from the sidebar/menu, then select the
**Keybinding** panel.
- **Affected elements**: the keybinding preset select, the preset
overflow dropdown menu, and the row context menu inside
`KeybindingPanel`.
- **Patch point**: `KeybindingPanel.vue` anchors to the settings dialog
panel and passes `keybindingOverlayContentStyle` only to those Reka
overlay roots.

## Review Focus

- Confirm the patch stays narrowly scoped to the six known PrimeVue
parent + Reka child overlay surfaces above.
- Confirm `contentStyle` reaches the actual portaled Reka overlay
content in each patched path.
- Confirm the fallback behavior preserves existing stacking when no
PrimeVue parent overlay is found; in that case the helper returns an
empty style object and leaves existing Tailwind z-index classes alone.
- Please avoid expanding this into a larger overlay refactor. The goal
is a clean, backport-friendly compatibility patch while the Reka
migration continues.

Validation performed:

- `pnpm exec vitest run src/composables/usePopoverSizing.test.ts`
- `pnpm typecheck`
- `pnpm lint` (passes with existing unrelated warnings only)
- `pnpm format:check`
- commit hook lint-staged checks (`oxfmt`, `stylelint`, `oxlint`,
`eslint --fix`, `pnpm typecheck`)
- pre-push `pnpm knip`

Linear: FE-569

## Bug Screenshots 



https://github.com/user-attachments/assets/e73761af-9867-4c50-ab0d-4e32e59011e1



https://github.com/user-attachments/assets/145daf4d-3268-428b-9987-1e1afd0b866f


┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12038-fix-keep-Reka-overlays-above-PrimeVue-dialogs-3596d73d365081e7af49dbc4d3905962)
by [Unito](https://www.unito.io)
2026-05-07 13:37:08 +09:00
Dante
e469611f6d perf: memoize asset display transform across filter tab switches (#11491)
## Root cause

`useAssetBrowser`'s `filteredAssets` computed re-ran
`.map(transformAssetForDisplay)` over the full result set on every tab
switch. `transformAssetForDisplay` allocates fresh `badges`/`stats`
objects, walks `tags`, calls `getAssetBaseModels`, and runs i18n date
formatting per asset — none of which were memoized. Switching All /
Inputs / Outputs forced N transforms per click and produced brand-new
`AssetDisplayItem` references, which also defeated `:key`-based diffing
in `AssetGrid` / `VirtualGrid` and re-rendered every visible card.

## Fix

Memoize `transformAssetForDisplay` at module scope with a
`WeakMap<AssetItem, AssetDisplayItem>`. Unchanged assets reuse the same
display item across tab switches; the GC reclaims entries when assets
are released.

## Before / after (n=200 assets, 6 tab switches: inputs → outputs → all
→ inputs → outputs → all)

| Metric                          | Before | After |
| ------------------------------- | -----: | ----: |
| `transformAssetForDisplay` runs |    800 |     0 |
| Wall time (Vitest harness)      | 13.2 ms | 8.1 ms |
| Reused `AssetDisplayItem` refs  |      0 |   200 |

Measured via
`src/platform/assets/composables/useAssetBrowser.perf.test.ts` plus a
temporary `process.stderr.write` harness.

## Red / green

| Commit    | Purpose |
| --------- | ------- |
| 7367fdd60 | test: failing perf assertions (transform budget + reused
refs) |
| 021b98ac0 | perf: WeakMap memoization of `transformAssetForDisplay` |

## Test plan

- [x] `pnpm exec vitest run
src/platform/assets/composables/useAssetBrowser` — 42/42 pass (including
2 new perf assertions)
- [x] `pnpm typecheck`
- [x] `pnpm exec eslint` on touched files
- [x] `pnpm exec oxfmt --check` on touched files

Fixes [FE-229

](https://linear.app/comfyorg/issue/FE-229/asset-browser-switching-all-inputs-outputs-tabs-is-slow)Source:
https://comfy-organization.slack.com/archives/C0A4XMHANP3/p1776716352588229

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11491-perf-memoize-asset-display-transform-across-filter-tab-switches-3496d73d36508112822dd6e7b58040fe)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Christian Byrne <cbyrne@comfy.org>
2026-05-07 13:03:57 +09:00
Christian Byrne
ad6cbf7cbe feat: align cloud batch count limit with server-side queue cap (#11876)
*PR Created by the Glary-Bot Agent*

---

Raises `Comfy.QueueButton.BatchCountLimit` on cloud from `32` to `100`
to match the server-side `MaxQueuedJobsPerUser` cap
(`cloud/infrastructure/dynamicconfig/prod/config.json:3`). The desktop
default was already `100` and is unchanged — collapsing both branches to
the same constant.

Addresses Discord feature request: [Increase queue batch limit from
200](https://discord.com/channels/1218270712402415686/1243609826299220039/1499104231381012641).

## Change

```diff
-    defaultValue: isCloud ? 32 : 100,
+    defaultValue: 100,
```

The setting is read dynamically by all batch count UIs
(`BatchCountEdit.vue`, `LinearControls.vue`).

## Why 100 (not 512)

Original ask was 200→512. Investigation showed:

- The actual previous default was `100` (desktop) / `32` (cloud), not
200.
- Cloud enforces `MaxQueuedJobsPerUser = 100` per workspace server-side.
A higher frontend cap can't unlock more queued work — extra prompts just
get rejected with `QUEUE_LIMIT`.
- Frontend submits prompts as N sequential `POST /prompt` calls (no
batched-prompt endpoint), so the UI cap is purely about how many clicks
it takes — not throughput.
- Going from 32 → 100 lets cloud users match the server cap in one click
instead of 4. No new behavior is unlocked.

## Known limitation (pre-existing, not introduced here)

The new max equals the absolute server cap, not the user's remaining
capacity. A user with already-queued work can hit `QUEUE_LIMIT`
mid-batch. The pre-existing 32 limit had the same shape (just at a
smaller scale); deriving the UI max from `cap - outstanding` would
require polling and reactive state and is out of scope for a one-line
setting bump.

## Verification

- `pnpm typecheck` — passes
- `pnpm lint` — 0 errors (1 pre-existing warning in unrelated test file)
- `pnpm test:unit` — `BatchCountEdit.test.ts` (3 tests) +
`src/platform/settings/**` (70 tests) all pass
- **Manual (Playwright)**:
- `settingStore.get('Comfy.QueueButton.BatchCountLimit')` returns `100`
at runtime
  - Typing `999` into the batch count widget clamps to `100`
  - Increment button is disabled at `100` (max reached)

## Screenshots

![Queue batch count widget set to 100 (the new max). Increment button is
disabled because the limit was
reached.](https://pub-1fd11710d4c8405b948c9edc4287a3f2.r2.dev/sessions/6a01e1ea573fa88f163fb64768a619d250a0b5da26b04249929b8734e04dac57/pr-images/1777863864881-dcc0e33d-e5e7-4a12-9cf3-89af60fd12f6.png)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11876-feat-align-cloud-batch-count-limit-with-server-side-queue-cap-3566d73d3650819b8d01dbf83d1a8e49)
by [Unito](https://www.unito.io)

Co-authored-by: Glary-Bot <glary-bot@users.noreply.github.com>
2026-05-06 23:19:32 +00:00
Kelly Yang
5ebf5e03ae refactor(load3d): replace PrimeVue Select/Slider/Checkbox with Reka UI (#12020)
Replace PrimeVue components in 3D node viewer controls with the
project's Reka UI equivalents across 7 files.

## Changes

| File | Replaced |
|------|---------|
| `AnimationControls.vue` | `Select` × 2 (speed + animation) |
| `ViewerModelControls.vue` | `Select` × 2 (up direction + material
mode) |
| `ViewerCameraControls.vue` | `Select` + `Slider` (camera type + FOV) |
| `ViewerExportControls.vue` | `Select` (export format) |
| `PopupSlider.vue` | `Slider` |
| `ViewerLightControls.vue` | `Slider` |
| `ViewerSceneControls.vue` | `Checkbox` → native `<input
type="checkbox">` |

## Implementation notes

- `Select` uses `@/components/ui/select/*` compound components. Numeric
model values (animation speed index) are stringified at the binding
boundary and converted back on update, matching Reka `SelectRoot`'s
`string`-only `modelValue` contract.
- `Slider` uses `@/components/ui/slider/Slider.vue`. Single-number
`defineModel` values are wrapped in a `computed` array and unwrapped in
the update handler, following the pattern established in
`LightControls.vue`.
- No new Reka UI wrapper components were created — existing ui/select
and ui/slider primitives were used directly.

## Test 

https://github.com/user-attachments/assets/afca0fc8-a7b6-49ee-b221-ee5725bd127e
1. AnimationControls.vue
- **Add Load3D node** → Upload an animated GLB file (e.g., a character
model).
- **Node preview top bar:** Play/Pause button, speed dropdown, animation
name dropdown, and progress bar.

2. PopupSlider.vue
- **Hover over Load3D preview:** Icon buttons appear in the left
toolbar.
- **"Light Intensity" button (bulb icon)** → Slider pops up on the
right.
- **"FOV" button (view icon)** → Slider pops up on the right.

3. ViewerCameraControls.vue
- **Load3D node** → Settings panel (top-right) → **"Camera"** tab.
- **Features:** Camera type dropdown (Perspective / Orthographic), FOV
slider (visible in Perspective mode).

4. ViewerExportControls.vue
- **Settings panel** → **"Export"** tab.
- **Features:** Format dropdown (GLB / OBJ / STL), Export button.

5. ViewerLightControls.vue
- **Settings panel** → **"Light"** tab.
- **Features:** Light intensity slider.

6. ViewerModelControls.vue
- **Settings panel** → **"Model"** tab.
- **Features:** "Up direction" dropdown, Material mode dropdown
(Wireframe / Normal, etc.).

7. ViewerSceneControls.vue
- **Settings panel** → **"Scene"** tab.
- **Features:** Background color picker, "Show grid" checkbox, upload
background image button.


<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> **Medium Risk**
> UI component swap touches multiple interactive viewer controls
(selects/sliders/checkbox), so small binding/typing differences (string
vs number, array slider values) could cause subtle regressions despite
test updates.
> 
> **Overview**
> Replaces PrimeVue `Select`, `Slider`, and `Checkbox` usages across
Load3D viewer controls with the project’s Reka UI-based primitives
(`@/components/ui/select/*`, `@/components/ui/slider/Slider.vue`) and a
native checkbox.
> 
> Updates v-model wiring to match the new components’ contracts: selects
now bind via string `modelValue` with explicit number casting where
needed, and sliders now wrap single numeric values into `[number]`
arrays with corresponding update handlers. Unit tests are updated to
mock the new UI components and their updated event/value shapes.
> 
> <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
46f99db256. Bugbot is set up for automated
code reviews on this repo. Configure
[here](https://www.cursor.com/dashboard/bugbot).</sup>
<!-- /CURSOR_SUMMARY -->



┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12020-refactor-load3d-replace-PrimeVue-Select-Slider-Checkbox-with-Reka-UI-3586d73d365081f58601d93031016afd)
by [Unito](https://www.unito.io)
2026-05-06 19:30:25 -04:00
Benjamin Lu
d3ab2be695 test: reuse queue button page object in e2e (#11927)
## Summary

Reuse the actionbar queue-button page object in the queue mode E2E tests
so dropdown selectors live in one helper.

## Changes

- **What**: Adds queue mode menu/item helpers to
`ComfyActionbar.queueButton` and updates `queueButtonModes.spec.ts` to
use them.
- **Dependencies**: None.

## Review Focus

This is stacked on #11209 and should be reviewed as a test-infra cleanup
only; the behavior covered by the spec is unchanged.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11927-test-reuse-queue-button-page-object-in-e2e-3566d73d365081918d59c2d587c4c94a)
by [Unito](https://www.unito.io)
2026-05-06 15:07:57 +00:00
pythongosssss
37f0fbcbef fix: add guard to prevent user store re-initialization (#11959)
## Summary

Make `userStore.initialize()` idempotent and concurrency-safe so the
bootstrap, router-guard, and UserSelectView callers share a single
getUserConfig fetch instead of racing/duplicaitng calls.

## Changes

- **What**: 
- cache initialize in a promise so callers all re-use the same result
- remove now redundant is initialized guard
- tests

## Review Focus
- Current user switch/logout uses `window.location.reload()`, no callers
intentionally call initialize to reinit. In future if this changes we
may want to add a parameter to skip the cache or a separate function.
- Failed initializes are not cached to allow callers to retry

- Not practical for e2e tests, the unit tests prove that the requests
are deduped. All a e2e test would do is mock/spy on the network requests
to show multiple requests do not happen - which the unit tests do a
better job of.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11959-fix-add-guard-to-prevent-user-store-re-initialization-3576d73d3650817db7b0e52cc25f9b7b)
by [Unito](https://www.unito.io)
2026-05-06 12:57:36 +00:00
Terry Jia
6ef051f200 FE-537: fix(load3d): preserve camera view, fit transform, and first-frame paint after refresh (#11944)
## Summary

- Defer thumbnail capture until camera state is restored via new
modelReady event so captureThumbnail no longer races with the saved
view, fixing the "snap back to default on hover" regression.
- Repaint the live scene at the end of captureThumbnail so the canvas is
not left with the offscreen mask/normal pass when the render loop is
gated.
- Persist post-fitToViewer model.scale + model.position into the
existing modelConfig.gizmo slot so a refresh reapplies them via the
existing applyGizmoConfigToLoad3d path; rotation stays owned by
upDirection.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11944-FE-537-fix-load3d-preserve-camera-view-fit-transform-and-first-frame-paint-after-re-3576d73d365081429653ea4740612617)
by [Unito](https://www.unito.io)
2026-05-06 08:43:54 -04:00
Dante
0788e71394 feat(dialog): introduce Reka-UI dialog primitives + opt-in renderer branch (Phase 0) (#11719)
## Summary

Lands the renderer infrastructure for migrating ComfyUI Frontend's
central dialog system from PrimeVue to Reka-UI. **Phase 0 of a phased
migration.** No production dialog migrates in this PR — every existing
dialog continues to render through PrimeVue exactly as before.

## Motivation

GitHub issue #11688 surfaced a PrimeVue Dialog `max-width` design
limitation that is awkward to address through PrimeVue's pass-through
styling. ADR 0004 (Rejected, 2025-08-27) explicitly endorses **selective
component replacement with shadcn/Reka-UI** as the path forward for
problematic PrimeVue components, and `AGENTS.md` already directs
contributors to "Avoid new usage of PrimeVue components." The dialog
system is a strong first candidate: clean public API boundary
(`useDialogService` / `dialogStore`), bounded surface (~12 dialogs), and
Reka-UI is already in use elsewhere in the codebase. The #11688 fix
arrives naturally in Phase 1 once `prompt`/`confirm` migrate to the new
primitive's `md` default (`max-width: 36rem`).

## Phased migration plan

This PR is **Phase 0 only**. Each subsequent phase is shipped as its own
PR.

| Phase | Scope | Approx LOC |
| ----- | ----- | ---------- |
| **0 (this PR)** | Reka-UI primitive set under
`src/components/ui/dialog/` + opt-in renderer branch in
`GlobalDialog.vue` + tests + Storybook | ~600 |
| 1 | Migrate `PromptDialogContent` + `ConfirmationDialogContent`;
closes #11688 | ~250 |
| 2 | Migrate `ErrorDialogContent`, `NodeSearchBox(Popover)`,
`SecretFormDialog`, `VideoHelpDialog`, `CustomizationDialog` | ~400 |
| 3 | Migrate Settings dialog (workspace + non-workspace variants) —
designer review | ~300 |
| 4 | Migrate Manager dialog — designer review | ~300 |
| 5 | Migrate `ConfirmDialog` callers (`SecretsPanel`,
`BaseWorkflowsSidebarTab`) | ~150 |
| 6 | Remove PrimeVue Dialog/ConfirmDialog imports + clean up CSS
overrides | ~200 |

Full plan in `temp/plans/dialog-migration-phase-0.md` and ADR draft at
`temp/plans/adr-0009-dialog-reka-migration-DRAFT.md` (will move to
`docs/adr/` after team review).

## Changes

- **What**:
- New shadcn-style primitives at `src/components/ui/dialog/` wrapping
Reka-UI's `Dialog*` components: `Dialog`, `DialogPortal`,
`DialogOverlay`, `DialogContent`, `DialogHeader`, `DialogFooter`,
`DialogTitle`, `DialogDescription`, `DialogClose`. Variants via `cva`
with sizes `sm | md | lg | xl | full`.
- `dialogStore.CustomDialogComponentProps` gains opt-in `renderer?:
'primevue' | 'reka'` (default `'primevue'`) and `size?: 'sm' | 'md' |
'lg' | 'xl' | 'full'`.
- `GlobalDialog.vue` branches the per-stack-item template based on the
`renderer` flag. PrimeVue path is byte-identical to before.
  - Storybook stories: `Default`, `LongContent`, `Headless`, `AllSizes`.
- Unit tests verifying branch selection and that the opt-in flag is
preserved on the dialog stack item.
- **Breaking**: None. Default renderer is `primevue` and no production
dialog opts in.
- **Dependencies**: None. Reka-UI is already a workspace dependency.

## Review Focus

1. **API surface**: `useDialogService` / `dialogStore` public API is
unchanged. Custom-node extensions calling
`app.extensionManager.dialog.*` continue to work.
2. **Renderer branch wiring** in `GlobalDialog.vue` — `escape-key-down`
/ `pointer-down-outside` map to `closeOnEscape` / `dismissableMask`;
`mousedown` calls `dialogStore.riseDialog` to mirror the PrimeVue
PT-based behavior.
3. **Primitive defaults** — `md` size = 36rem max-width (chosen to
resolve #11688 in Phase 1); `full` = `calc(100vw - 1rem)` escape hatch
for Settings/Manager later.
4. **No behavior change**: existing dialogs continue to render unchanged
because nothing opts into `renderer: 'reka'` in this PR.

## Quality gates

- `pnpm typecheck` — clean
- `pnpm lint` — clean (1 pre-existing warning unrelated to this PR)
- `pnpm test:unit` — 48 dialog-adjacent tests pass including 3 new tests
in `GlobalDialog.test.ts`
- `pnpm format` — applied

knip pre-push noise (unused deps in workspace packages, unused
`types.gen.ts`) is pre-existing on `main` and not introduced by this PR.

## Out of scope (deferred)

- Migrating any production dialog — Phase 1+
- Removing PrimeVue dependency — Phase 6
- Touching legacy `ComfyDialog` (`src/scripts/ui/dialog.ts`) — separate
cleanup
- Deduplicating `Dialogue.vue` / `ImageLightbox.vue` against the new
primitives — separate cleanup

Refs #11688

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11719-feat-dialog-introduce-Reka-UI-dialog-primitives-opt-in-renderer-branch-Phase-0-3506d73d365081fc8c83ceadbffd276c)
by [Unito](https://www.unito.io)


# test

## checklist

| Scenario | Cloud prod | This PR | Notes |
  |---|---|---|---|
| Confirm dialog (delete/sign‑out) |  |  | OK/Cancel, ESC, backdrop
click identical |
| Prompt dialog (rename / save as) |  |  | Enter submits, ESC cancels,
focus trap intact |
| Settings dialog open/close |  |  | Tabs, search, ESC, save
persistence unchanged |
| Manager dialog |  |  | Tab switching, sub‑confirm stacking, z‑index
correct |
| Stacked dialog ESC handling |  |  | Only top dialog closes;
mousedown raises bottom |
| Dialog after route change |  |  | No orphaned overlay, no body
scroll lock leak |
| `.p-dialog` DOM attrs | clean | clean | No `renderer=` / `size=`
attribute leak from new optional fields |


## screenshot 
<img width="1616" height="927" alt="Screenshot 2026-05-06 at 8 43 10 PM"
src="https://github.com/user-attachments/assets/c6f668c2-a537-45ae-bf66-8bb0617502de"
/>
<img width="1419" height="951" alt="Screenshot 2026-05-06 at 8 43 41 PM"
src="https://github.com/user-attachments/assets/d82d4b27-cb05-4185-be4a-bd2fb9503130"
/>
<img width="1884" height="1001" alt="Screenshot 2026-05-06 at 8 46
31 PM"
src="https://github.com/user-attachments/assets/dd13f99f-a11e-4b85-9f27-7d30c55cf266"
/>
<img width="1876" height="1009" alt="Screenshot 2026-05-06 at 8 47
29 PM"
src="https://github.com/user-attachments/assets/f9824b57-4a06-44d6-8f18-e1226c764c83"
/>
2026-05-06 12:04:15 +00:00
Yourz
d3f802de10 feat(pricing): add concurrent API jobs feature to Creator and Pro tiers (#12000)
*PR Created by the Glary-Bot Agent*

---

## Summary

Adds a new feature bullet to the Creator and Pro plans on the [cloud
pricing page](https://comfy.org/cloud/pricing) to call out included API
concurrency:

- **Creator**: `3 concurrent API jobs`
- **Pro**: `5 concurrent API jobs`

Free and Standard tiers do not include API access, so they are not
changed.

This matches the language landing in the docs PR:
[Comfy-Org/docs#965](https://github.com/Comfy-Org/docs/pull/965).

## Changes

- `apps/website/src/components/pricing/PriceSection.vue`: added
`feature2` to the Creator and Pro plan feature lists.
- `apps/website/src/i18n/translations.ts`: added
`pricing.plan.creator.feature2` and `pricing.plan.pro.feature2` for `en`
and `zh-CN`.
- Updated `pricing-tiers-{1-sm,2-md,3-lg,4-xl}` visual regression
snapshots in `apps/website/e2e/visual-responsive.spec.ts-snapshots/` to
match the new copy.

## Verification

- `pnpm nx run @comfyorg/website:typecheck` — clean
- ESLint and `oxfmt` clean on changed files (pre-commit lint-staged also
passed)
- `pnpm exec playwright test --project visual -g "pricing-tiers"` — 4/4
passing against the regenerated snapshots
- Manually rendered `localhost:4321/cloud/pricing`; confirmed copy
appears in both desktop and mobile layouts and that Free / Standard are
unchanged. Screenshots below.

## Screenshots

### Desktop
![desktop](pricing-desktop)

### Mobile — Creator
![mobile creator](pricing-mobile-creator)

### Mobile — Pro
![mobile pro](pricing-mobile-pro)


## Screenshots


![pricing-desktop](https://pub-1fd11710d4c8405b948c9edc4287a3f2.r2.dev/sessions/9b9dd9686537a805551dda7b053b16b57fd91a2318efa5c698213337619cb9d2/pr-images/1778029290283-b5f959c8-b0ba-42bf-a822-aa7c04b5a3dc.png)


![pricing-mobile-creator](https://pub-1fd11710d4c8405b948c9edc4287a3f2.r2.dev/sessions/9b9dd9686537a805551dda7b053b16b57fd91a2318efa5c698213337619cb9d2/pr-images/1778029290725-2cf36ff3-bc0d-4746-b37d-e3445f4293ab.png)


![pricing-mobile-pro](https://pub-1fd11710d4c8405b948c9edc4287a3f2.r2.dev/sessions/9b9dd9686537a805551dda7b053b16b57fd91a2318efa5c698213337619cb9d2/pr-images/1778029291135-5fe2f975-a0ac-4aac-91bc-5602670d8bbf.png)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12000-feat-pricing-add-concurrent-API-jobs-feature-to-Creator-and-Pro-tiers-3586d73d365081559acfc44eb5024c52)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Glary-Bot <glary-bot@users.noreply.github.com>
Co-authored-by: github-actions <github-actions@github.com>
2026-05-06 11:46:32 +00:00
Kelly Yang
d78c630d36 test(maskeditor): expand useBrushDrawing behavioral coverage (#12001)
Adds targeted behavioral tests for the slimmed `useBrushDrawing`
orchestration composable (Phase E of the brush-drawing refactor).

## Changes

- 5 new tests covering previously untested branches:
  - `compositeStroke` receives `isRgb=true` when active layer is `rgb`
  - `compositeStroke` receives `isErasing=true` when tool is `eraser`
  - Mask canvas opacity is restored after drawing on the mask layer
- `globalCompositeOperation` is set to `destination-out` during
`handleDrawing` when tool is eraser
- `globalCompositeOperation` is set to `destination-out` during
`handleDrawing` when right mouse button is held

## Coverage (useBrushDrawing.ts)

| Metric | Before | After |
|--------|--------|-------|
| Statements | 86.33% | 87.05% |
| Branches | 68.75% | 70.00% |
| Functions | 90.00% | 90.00% |
| Lines | 89.23% | 90.00% |

All 18 tests pass. GPU paths remain `/* c8 ignore */` excluded
(untestable without WebGL).

- Fixes #0

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> **Low Risk**
> Low risk: changes are limited to unit tests, adding coverage for
eraser/right-click composition and `drawEnd` GPU compositing/opacity
restoration paths without altering production logic.
> 
> **Overview**
> Adds new `useBrushDrawing` test cases to cover previously untested
branches: setting `globalCompositeOperation` to `destination-out` during
`handleDrawing` when erasing (tool or right-click), and verifying
`drawEnd` passes correct `isRgb`/`isErasing` flags to
`gpu.compositeStroke`.
> 
> Also asserts mask-layer opacity is restored after `drawEnd`,
increasing behavioral coverage around stroke completion and canvas
visibility cleanup.
> 
> <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
20c411e6ce. Bugbot is set up for automated
code reviews on this repo. Configure
[here](https://www.cursor.com/dashboard/bugbot).</sup>
<!-- /CURSOR_SUMMARY -->

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12001-test-maskeditor-expand-useBrushDrawing-behavioral-coverage-3586d73d365081388ebcef91c2172c0a)
by [Unito](https://www.unito.io)
2026-05-06 06:15:54 -04:00
Christian Byrne
aa4343a98b test: add perf test for subgraph transition bottleneck (#10480)
## Summary

Add a `@perf` test measuring the cost of entering a subgraph containing
80 interior nodes. Establishes a CI baseline for the synchronous
mount/unmount bottleneck.

## Changes

- **What**: Add `subgraph transition (enter and exit)` perf test to
`performance.spec.ts` and a test workflow asset
(`large-subgraph-80-nodes.json`) with a single subgraph node containing
80 Note nodes.

## Review Focus

This is PR 1 of 2. The test establishes a baseline on main so the
optimization PR (PR 2) can show a CI-proven delta for `taskDurationMs`
and `totalBlockingTimeMs`.

The test:
1. Loads the 80-node subgraph workflow
2. Enters and exits once to warm up
3. Measures a fresh enter transition (start → 80 nodes mounted → layout
settled)
4. Records `taskDurationMs`, `layouts`, and `TBT`

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10480-test-add-perf-test-for-subgraph-transition-bottleneck-32d6d73d3650811b9b6eec03a9591f82)
by [Unito](https://www.unito.io)

---------

Co-authored-by: GitHub Action <action@github.com>
Co-authored-by: Connor Byrne <c.byrne@comfy.org>
2026-05-06 01:54:18 -07:00
Benjamin Lu
270c7e34f4 fix: hide Google free-tier copy in webviews (#11924)
Stacked on #10699.

- Hide Google-specific free-tier promo copy when embedded webviews block
Google SSO.
- Use GitHub-only fallback copy when returning from email signup in that
state.
- Remove the unused export from the Google SSO blocked-reason type so
knip stays clean.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11924-fix-hide-Google-free-tier-copy-in-webviews-3566d73d36508168be7ed28cbe455d9f)
by [Unito](https://www.unito.io)

---------

Co-authored-by: bymyself <cbyrne@comfy.org>
2026-05-06 01:49:40 -07:00
Christian Byrne
666684e6e6 fix: stop PreviewAny widgets from triggering re-execution (#12010)
## Summary

Preview as Text (`PreviewAny`) nodes were re-executing on every prompt
submission because the rendered preview text was being echoed back to
the backend as input values, mutating the cache signature.

## Changes

- **What**: Set `widget.options.serialize = false` on the three widgets
the `Comfy.PreviewAny` extension adds (`preview_markdown`,
`preview_text`, `previewMode`) so they are excluded from the API prompt
sent to the backend.

## Root cause

The extension was setting `widget.serialize = false`, which only
controls **workflow JSON** persistence (checked in
`LGraphNode.serialize`). The **API prompt** serializer in
`executionUtil.graphToPrompt` checks `widget.options.serialize` instead
— a distinct property documented in litegraph's `WIDGET_SERIALIZATION`
convention.

After `onExecuted` writes the rendered text into the widget value, the
next `graphToPrompt` call serialized that text into
`inputs.preview_text` / `inputs.preview_markdown`. The backend cache
signature in `comfy_execution/caching.py` hashes all keys in
`node["inputs"]`, so the changing text invalidated the cache and forced
a redundant execution every time.

## Review Focus

Two commits, red-green TDD:
1. `test:` failing unit + e2e tests asserting the desired behavior.
2. `fix:` adds `options.serialize = false` to make them pass.

Tests verify the widgets are excluded from the API prompt; e2e
additionally simulates a prior execution populating widget values to
mirror the real bug condition.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12010-fix-stop-PreviewAny-widgets-from-triggering-re-execution-3586d73d3650810585cdd077f3ac64f5)
by [Unito](https://www.unito.io)
2026-05-06 05:08:35 +00:00
Christian Byrne
4484b62854 test: add E2E tests for queue button modes (#11209)
## Changes

Add Playwright E2E tests for queue button modes in the topbar (6 tests):

- Run button visibility in topbar
- Queue mode trigger menu visibility
- Opening the mode menu via trigger click
- Verifying available modes are shown as menu item radios
- Menu closes after selecting a mode
- Run button sends prompt when clicked (via intercepted `/api/prompt`
route)

## Test

```bash
pnpm test:browser:local -- browser_tests/tests/queueButtonModes.spec.ts
```

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11209-test-add-E2E-tests-for-queue-button-modes-3416d73d365081a5bf10f8b9c6bdc2a7)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Benjamin Lu <benjaminlu1107@gmail.com>
2026-05-06 03:30:32 +00:00
Christian Byrne
d29169ff4e test: add E2E tests for keybinding settings panel coverage (#11455)
*PR Created by the Glary-Bot Agent*

---

## Summary

Adds 22 Playwright E2E tests for the keybinding settings panel
(`KeybindingPanel.vue`), covering all 15 previously-untested functions
identified via coverage analysis.

## Test Groups

| Group | Tests | Functions Covered |
|---|---|---|
| Row Expansion | 2 | `handleRowClick`, `toggleExpanded`, `expandedRows`
|
| Double-Click | 2 | `handleRowDblClick`, `addKeybinding`,
`editKeybinding` |
| Context Menu | 7 | `handleRowContextMenu`, `clearContextMenuTarget`,
`ctxChangeKeybinding`, `ctxAddKeybinding`, `ctxResetToDefault`,
`ctxRemoveKeybinding` |
| Action Buttons | 7 | `editKeybinding`, `resetKeybinding`,
`removeSingleKeybinding`, `handleRemoveAllKeybindings`,
`handleRemoveKeybindingFromMenu` |
| Expanded Row Actions | 2 | `editKeybinding` (expansion),
`removeSingleKeybinding` (expansion) |
| Reset All | 2 | `resetAllKeybindings` (confirm + cancel) |
| Search Filter | 1 | `watch(filters, ...)` clears expansion |

## Flake Prevention Measures

- Deterministic test command
(`TestCommand.KeybindingPanelE2E.NoBinding`) registered via
`app.registerExtension()` for 0-binding scenarios — avoids flaky
pagination-dependent row scanning
- `pressComboOnInput()` asserts input focus before pressing key combos
in the edit dialog
- `saveAndCloseKeybindingDialog()` waits for dialog teardown to complete
before proceeding
- `openContextMenu()` waits for Reka UI menu items to be visible before
interacting
- Resets `Comfy.Keybinding.NewBindings` and
`Comfy.Keybinding.UnsetBindings` in `afterEach`

## Note on Selectors

Some locators use Tailwind utility classes (`.pl-4`,
`.icon-[lucide--chevron-right]`) because the expansion template and
chevron icon in `KeybindingPanel.vue` lack `data-testid` attributes.
Adding test IDs would be a follow-up to this test-only PR.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11455-test-add-E2E-tests-for-keybinding-settings-panel-coverage-3486d73d365081d7a902fc68091552f2)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Glary-Bot <glary-bot@users.noreply.github.com>
Co-authored-by: Benjamin Lu <benjaminlu1107@gmail.com>
2026-05-05 20:24:13 -07:00
Comfy Org PR Bot
3e6f3444e5 1.44.18 (#11998)
Patch version increment to 1.44.18

**Base branch:** `main`

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11998-1-44-18-3586d73d3650812c990ad3ba0d222d0a)
by [Unito](https://www.unito.io)

---------

Co-authored-by: christian-byrne <72887196+christian-byrne@users.noreply.github.com>
Co-authored-by: github-actions <github-actions@github.com>
Co-authored-by: Alexander Brown <drjkl@comfy.org>
Co-authored-by: DrJKL <DrJKL0424@gmail.com>
2026-05-06 02:40:01 +00:00
Yourz
e46667b33f feat: replace spin logo video with Three.js instance in homepage HeroSection (#11964)
Replace the pre-rendered `.webm` video in the homepage hero section with
an interactive Three.js 3D logo.

## Changes

- Add `three` dependency to the website package and pnpm catalog
- Add `useHeroLogo` composable that sets up the Three.js scene:
  - Extruded Comfy "C" logo with stencil-masked image sequence slideshow
  - Auto-rotation with drag interaction and cursor tilt
  - Graceful degradation if some textures fail to load
- Update `HeroSection.vue` to use the composable instead of a `<video>`
element
- Upload 16 image sequence frames to
`gs://comfy-org-videos/website/homepage/hero-logo-seq/`


<img width="1000" height="648" alt="Kapture 2026-05-05 at 20 54 05"
src="https://github.com/user-attachments/assets/7a7b1634-2da3-4aa2-871a-f64d4d337b39"
/>

@coderabbitai approve


┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11964-feat-replace-spin-logo-video-with-Three-js-instance-in-homepage-HeroSection-3576d73d365081bbab0ed19dd121830c)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Amp <amp@ampcode.com>
Co-authored-by: Alexander Brown <drjkl@comfy.org>
2026-05-06 08:57:35 +08:00
Dante
d5121d3fed fix: converge asset tag cache and server on partial-failure (#11695)
## Summary

`assetsStore.updateAssetTags` issues `removeAssetTags` and
`addAssetTags` as two separate network calls. When the remove succeeds
server-side but the subsequent add rejects, the cache rolls back to the
original tags while the server has already dropped the removed tags —
cache and backend diverge until the next refetch.

This adds a compensating action: if remove succeeded and add then fails,
attempt to re-add the just-removed tags so the server returns to its
prior state. If the compensating add also fails, invalidate the category
cache so the next access refetches fresh state.

- Fixes #11694
- Fixes
[FE-473](https://linear.app/comfyorg/issue/FE-473/fix-converge-asset-tag-cache-and-server-on-partial-failure)
## Changes

- `src/stores/assetsStore.ts`: track which tags were already removed
server-side; on add-failure, re-add them; if compensation fails,
invalidate the resolved category cache via `resolveCategory` +
`invalidateCategory`.
- `src/stores/assetsStore.test.ts`: extend the cloud asset-service mock
with `addAssetTags` / `removeAssetTags` and add a `updateAssetTags
partial-failure compensation` describe block:
  - re-adds removed tags when add fails so cache and server converge
  - invalidates the category cache when compensation also fails
- does not attempt compensation when only the add was attempted (no
remove ran)



## Test plan

- [x] `pnpm exec vitest run src/stores/assetsStore.test.ts` — 42 passed
(39 prior + 3 new)
- [x] `pnpm typecheck`
- [x] `pnpm lint`
- [x] `pnpm knip`

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11695-fix-converge-asset-tag-cache-and-server-on-partial-failure-34f6d73d365081149900f95b6ee4bfa9)
by [Unito](https://www.unito.io)
2026-05-06 00:26:47 +00:00
Christian Byrne
733917d5cf [chore] Update Comfy Registry API types from comfy-api@69cbc3b (#11994)
## Automated API Type Update

This PR updates the Comfy Registry API types from the latest comfy-api
OpenAPI specification.

- API commit: 69cbc3b
- Generated on: 2026-05-05T15:48:16Z

These types are automatically generated using openapi-typescript.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11994-chore-Update-Comfy-Registry-API-types-from-comfy-api-69cbc3b-3576d73d3650818f85e4c98c783a6490)
by [Unito](https://www.unito.io)

Co-authored-by: james00012 <96548424+james00012@users.noreply.github.com>
2026-05-05 23:09:25 +00:00
AustinMroz
08967bc684 Fix pruning of uninitialized promoted primitives (#11987)
Primitive nodes do not create their widgets until their
`onAfterGraphConfigured` method is called. Previously, when a
subgraphNode contains a linked widget, any proxyWidgets that do not
resolve to a real widget are pruned at time of configure. Since this
occurs prior to initialization of the primitive, the primitive value
would be de-promoted before the widget could initialize

This is resolved by the minimally disruptive change of allowing proxied
promotions to primitive nodes to be kept so long as the node itself can
be found.
2026-05-05 21:22:10 +00:00
507 changed files with 40154 additions and 4821 deletions

View File

@@ -54,10 +54,14 @@ jobs:
- name: Start ComfyUI server
uses: ./.github/actions/start-comfyui-server
# PRs run each test once to keep wall time bounded; main runs 3× so the
# baseline saved to perf-data has enough samples to median over noise.
- name: Run performance tests
id: perf
continue-on-error: true
run: pnpm exec playwright test --project=performance --workers=1 --repeat-each=3
env:
PERF_REPEAT: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' && '3' || '2' }}
run: pnpm exec playwright test --project=performance --workers=1 --repeat-each=$PERF_REPEAT
- name: Upload perf metrics
if: always()

View File

@@ -20,6 +20,8 @@ jobs:
github.event.workflow_run.conclusion == 'success'
runs-on: ubuntu-latest
timeout-minutes: 10
outputs:
has-coverage: ${{ steps.coverage-shards.outputs.has-coverage }}
steps:
- name: Checkout repository
@@ -37,31 +39,33 @@ jobs:
path: temp/coverage-shards
if_no_artifact_found: warn
- name: Detect shard coverage data
id: coverage-shards
run: |
if [ -d temp/coverage-shards ] && find temp/coverage-shards -name 'coverage.lcov' -type f | grep -q .; then
echo "has-coverage=true" >> "$GITHUB_OUTPUT"
else
echo "has-coverage=false" >> "$GITHUB_OUTPUT"
echo "No E2E coverage shard artifacts found; treating this run as skipped." >> "$GITHUB_STEP_SUMMARY"
fi
- name: Install lcov
if: steps.coverage-shards.outputs.has-coverage == 'true'
run: sudo apt-get install -y -qq lcov
- name: Merge shard coverage into single LCOV
if: steps.coverage-shards.outputs.has-coverage == 'true'
run: |
mkdir -p coverage/playwright
LCOV_FILES=$(find temp/coverage-shards -name 'coverage.lcov' -type f)
if [ -z "$LCOV_FILES" ]; then
echo "No coverage.lcov files found"
touch coverage/playwright/coverage.lcov
exit 0
fi
ADD_ARGS=""
for f in $LCOV_FILES; do ADD_ARGS="$ADD_ARGS -a $f"; done
lcov $ADD_ARGS -o coverage/playwright/coverage.lcov
wc -l coverage/playwright/coverage.lcov
- name: Validate merged coverage
if: steps.coverage-shards.outputs.has-coverage == 'true'
run: |
SHARD_COUNT=$(find temp/coverage-shards -name 'coverage.lcov' -type f | wc -l | tr -d ' ')
if [ "$SHARD_COUNT" -eq 0 ]; then
echo "::notice::No shard coverage files; upstream E2E was likely skipped."
exit 0
fi
MERGED_SF=$(grep -c '^SF:' coverage/playwright/coverage.lcov || echo 0)
MERGED_LH=$(awk -F: '/^LH:/{s+=$2}END{print s+0}' coverage/playwright/coverage.lcov)
MERGED_LF=$(awk -F: '/^LF:/{s+=$2}END{print s+0}' coverage/playwright/coverage.lcov)
@@ -82,7 +86,7 @@ jobs:
done
- name: Upload merged coverage data
if: always()
if: steps.coverage-shards.outputs.has-coverage == 'true'
uses: actions/upload-artifact@v6
with:
name: e2e-coverage
@@ -91,7 +95,7 @@ jobs:
if-no-files-found: warn
- name: Upload E2E coverage to Codecov
if: always()
if: steps.coverage-shards.outputs.has-coverage == 'true'
uses: codecov/codecov-action@1af58845a975a7985b0beb0cbe6fbbb71a41dbad # v5.5.3
with:
files: coverage/playwright/coverage.lcov
@@ -100,6 +104,7 @@ jobs:
fail_ci_if_error: false
- name: Generate HTML coverage report
if: steps.coverage-shards.outputs.has-coverage == 'true'
run: |
if [ ! -s coverage/playwright/coverage.lcov ]; then
echo "No coverage data; generating placeholder report."
@@ -114,6 +119,7 @@ jobs:
--precision 1
- name: Upload HTML report artifact
if: steps.coverage-shards.outputs.has-coverage == 'true'
uses: actions/upload-artifact@v6
with:
name: e2e-coverage-html
@@ -122,7 +128,9 @@ jobs:
deploy:
needs: merge
if: github.event.workflow_run.head_branch == 'main'
if: >
github.event.workflow_run.head_branch == 'main' &&
needs.merge.outputs.has-coverage == 'true'
runs-on: ubuntu-latest
permissions:
pages: write

View File

@@ -0,0 +1,88 @@
# Description: Extension API test suite (I-TF) + compat-floor gate (I-TF.7)
#
# Runs on any PR touching extension-api declaration files, extension-api-v2
# implementation/tests, or the touch-point DB/rollup (blast-radius changes).
#
# Two jobs:
# test — vitest run against src/extension-api-v2/__tests__/
# compat-floor — python scripts/check-compat-floor.py (exits 1 if any
# blast_radius ≥ 2.0 category is missing a stub triple)
#
# The compat-floor job is the CI enforcement of PLAN.md §Compat-floor:
# "Every blast_radius ≥ 2.0 pattern MUST pass v1 + v2 + migration before v2 ships."
name: 'CI: Tests Extension API'
on:
push:
branches: [main, master, dev*, core/*, extension-v2*]
paths:
- 'src/extension-api/**'
- 'src/extension-api-v2/**'
- 'packages/extension-api/**'
- 'vitest.extension-api.config.mts'
- 'research/touch-points/rollup.yaml'
- 'research/touch-points/behavior-categories.yaml'
- 'scripts/check-compat-floor.py'
- 'pnpm-lock.yaml'
pull_request:
branches-ignore: [wip/*, draft/*, temp/*]
paths:
- 'src/extension-api/**'
- 'src/extension-api-v2/**'
- 'packages/extension-api/**'
- 'vitest.extension-api.config.mts'
- 'research/touch-points/rollup.yaml'
- 'research/touch-points/behavior-categories.yaml'
- 'scripts/check-compat-floor.py'
- 'pnpm-lock.yaml'
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
test:
name: Extension API tests (vitest)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- name: Setup frontend
uses: ./.github/actions/setup-frontend
- name: Run extension-api test suite
run: pnpm test:extension-api
- name: Run with coverage (push only)
if: github.event_name == 'push'
run: pnpm test:extension-api:coverage
- name: Upload coverage to Codecov
if: github.event_name == 'push'
uses: codecov/codecov-action@1af58845a975a7985b0beb0cbe6fbbb71a41dbad # v5.5.3
with:
files: coverage/lcov.info
flags: extension-api
token: ${{ secrets.CODECOV_TOKEN }}
fail_ci_if_error: false
compat-floor:
name: Compat-floor gate (blast_radius ≥ 2.0)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Install PyYAML
run: pip install pyyaml
- name: Check compat floor
run: python3 scripts/check-compat-floor.py
# Exits 1 if any blast_radius ≥ 2.0 behavior category is missing
# any of its three stub files (v1/v2/migration). Enforces PLAN.md §Compat-floor.

View File

@@ -0,0 +1,33 @@
import { expect } from '@playwright/test'
import { test } from './fixtures/blockExternalMedia'
test.describe('Customers @smoke', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/customers')
})
test('hero image declares intrinsic dimensions so layout reserves space before load', async ({
page
}) => {
const heroImage = page.locator('img[alt="Comfy 3D logo"]')
await expect(heroImage).toBeVisible()
await expect(heroImage).toHaveAttribute('width', /^\d+$/)
await expect(heroImage).toHaveAttribute('height', /^\d+$/)
// Regression guard: an unloaded <img> without intrinsic dimensions
// collapses to ~0px, then jumps to its natural size on load and pushes
// the video below it. Reserved space must persist before bytes arrive.
const heightWhileUnloaded = await page.evaluate(() => {
const img = document.querySelector<HTMLImageElement>(
'img[alt="Comfy 3D logo"]'
)
if (!img) return null
img.removeAttribute('src')
return img.getBoundingClientRect().height
})
expect(heightWhileUnloaded).not.toBeNull()
expect(heightWhileUnloaded!).toBeGreaterThan(100)
})
})

View File

@@ -26,8 +26,8 @@ async function assertNoOverflow(page: Page) {
}
async function navigateAndSettle(page: Page, url: string) {
await page.goto(url)
await page.waitForLoadState('networkidle')
await page.goto(url, { waitUntil: 'domcontentloaded' })
await page.waitForLoadState('load')
}
test.describe('Home', { tag: '@visual' }, () => {

Binary file not shown.

Before

Width:  |  Height:  |  Size: 39 KiB

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 85 KiB

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 86 KiB

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 92 KiB

After

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 96 KiB

After

Width:  |  Height:  |  Size: 99 KiB

View File

@@ -27,6 +27,7 @@
"gsap": "catalog:",
"lenis": "catalog:",
"posthog-js": "catalog:",
"three": "catalog:",
"vue": "catalog:",
"zod": "catalog:"
},

View File

@@ -28,7 +28,7 @@ export default defineConfig({
? [['html'], ['json', { outputFile: 'results.json' }]]
: 'html',
expect: {
toHaveScreenshot: { maxDiffPixels: 50 }
toHaveScreenshot: { maxDiffPixels: 100 }
},
...maybeLocalOptions,
webServer: {

View File

@@ -13,7 +13,7 @@ const { stars } = defineProps<{
target="_blank"
rel="noopener noreferrer"
:aria-label="`ComfyUI on GitHub ${stars} stars`"
class="hidden shrink-0 items-center gap-2 lg:flex"
class="hidden shrink-0 items-center gap-1 lg:flex"
>
<NodeBadge
:segments="[{ text: stars }]"
@@ -22,7 +22,7 @@ const { stars } = defineProps<{
size-class="h-5 sm:h-5"
/>
<span
class="bg-primary-comfy-yellow block size-7"
class="bg-primary-comfy-yellow block size-6 shrink-0"
aria-hidden="true"
style="mask: url('/icons/social/github.svg') center / contain no-repeat"
/>

View File

@@ -75,7 +75,7 @@ const progressPercent = computed(() => `${progress.value * 100}%`)
<!-- Progress bar -->
<div class="h-1 flex-1 rounded-full bg-white/20">
<div
class="bg-primary-comfy-yellow h-full rounded-full transition-all duration-200"
class="bg-primary-comfy-yellow h-full rounded-full"
:style="{ width: progressPercent }"
/>
</div>

View File

@@ -5,6 +5,7 @@ import { useHeroAnimation } from '../../composables/useHeroAnimation'
import SectionLabel from '../common/SectionLabel.vue'
import type { Locale } from '../../i18n/translations'
import { t } from '../../i18n/translations'
import { ScrollTrigger } from '../../scripts/gsapSetup'
import VideoPlayer from '../common/VideoPlayer.vue'
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
@@ -22,6 +23,10 @@ useHeroAnimation({
logo: logoRef,
video: videoRef
})
function handleLogoLoad() {
ScrollTrigger.refresh(true)
}
</script>
<template>
@@ -37,7 +42,10 @@ useHeroAnimation({
<img
src="https://media.comfy.org/website/customers/c-projection.webp"
alt="Comfy 3D logo"
class="mx-auto w-full max-w-md lg:max-w-none"
width="1568"
height="1763"
class="mx-auto h-auto w-full max-w-md lg:max-w-none"
@load="handleLogoLoad"
/>
</div>

View File

@@ -1,24 +1,31 @@
<script setup lang="ts">
import { ref } from 'vue'
import type { Locale } from '../../i18n/translations'
import { externalLinks } from '../../config/routes'
import { useHeroLogo } from '../../composables/useHeroLogo'
import { t } from '../../i18n/translations'
import BrandButton from '../common/BrandButton.vue'
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
const logoContainer = ref<HTMLElement>()
const { loaded: logoLoaded } = useHeroLogo(logoContainer)
</script>
<template>
<section
class="relative flex min-h-auto flex-col lg:flex-row lg:items-center"
>
<div class="relative flex-1">
<video
src="https://media.comfy.org/website/homepage/hero-logo-seq.webm"
autoplay
loop
muted
playsinline
class="w-full"
<div
ref="logoContainer"
class="relative flex aspect-square w-full flex-1 items-center justify-center"
>
<img
v-show="!logoLoaded"
src="https://media.comfy.org/website/homepage/hero-logo-seq/Logo00.webp"
alt="Comfy logo"
class="w-3/5"
/>
</div>

View File

@@ -25,7 +25,7 @@ const categories: Category[] = [
{
label: t('useCase.vfx', locale),
leftSrc: 'https://media.comfy.org/website/homepage/use-case/left1.webm',
rightSrc: 'https://media.comfy.org/website/homepage/use-case/right1.webp'
rightSrc: 'https://media.comfy.org/website/homepage/use-case/right1.webm'
},
{
label: t('useCase.advertising', locale),

View File

@@ -77,7 +77,10 @@ const plans: PricingPlan[] = [
ctaKey: 'pricing.plan.creator.cta',
ctaHref: subscribeUrl('creator'),
featureIntroKey: 'pricing.plan.creator.featureIntro',
features: [{ text: 'pricing.plan.creator.feature1' }],
features: [
{ text: 'pricing.plan.creator.feature1' },
{ text: 'pricing.plan.creator.feature2' }
],
isPopular: true
},
{
@@ -90,7 +93,10 @@ const plans: PricingPlan[] = [
ctaKey: 'pricing.plan.pro.cta',
ctaHref: subscribeUrl('pro'),
featureIntroKey: 'pricing.plan.pro.featureIntro',
features: [{ text: 'pricing.plan.pro.feature1' }]
features: [
{ text: 'pricing.plan.pro.feature1' },
{ text: 'pricing.plan.pro.feature2' }
]
},
{
id: 'enterprise',

View File

@@ -0,0 +1,328 @@
import type { Ref } from 'vue'
import { onMounted, onUnmounted, ref } from 'vue'
import * as THREE from 'three'
import { SVGLoader } from 'three/addons/loaders/SVGLoader.js'
import { prefersReducedMotion } from './useReducedMotion'
const IMAGE_COUNT = 16
const BASE_URL = 'https://media.comfy.org/website/homepage/hero-logo-seq'
const SVG_MARKUP = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 375 404"><path fill="#000000" d="M296.597 302.576C297.299 300.205 297.682 297.705 297.682 295.078C297.682 280.529 285.938 268.736 271.45 268.736H153.883C147.564 268.8 142.395 263.673 142.395 257.328C142.395 256.174 142.586 255.084 142.841 254.059L174.499 143.309C175.839 138.438 180.307 134.849 185.541 134.849L303.554 134.72C328.446 134.72 349.444 117.864 355.763 94.8555L373.506 33.1353C374.081 30.9562 374.4 28.5848 374.4 26.2134C374.4 11.7288 362.72 0 348.295 0H205.518C180.754 0 159.819 16.7279 153.373 39.4804L141.373 81.5886C139.969 86.3954 135.565 89.9205 130.332 89.9205H96.0573C71.4845 89.9205 50.7412 106.328 44.1034 128.824L0.957382 280.144C0.319127 282.387 0 284.823 0 287.258C0 301.807 11.7439 313.6 26.2323 313.6H59.9321C66.2508 313.6 71.4207 318.727 71.4207 325.137C71.4207 326.226 71.293 327.316 70.9739 328.341L59.0385 370.065C58.4641 372.308 58.0811 374.615 58.0811 376.987C58.0811 391.471 69.7612 403.2 84.1857 403.2L227.027 403.072C251.855 403.072 272.79 386.28 279.172 363.399L296.533 302.64L296.597 302.576Z"/></svg>`
interface HeroLogoConfig {
speed: number
tiltX: number
tiltZ: number
zoom: number
fov: number
logoColor: string
extrudeDepth: number
cursorTiltStrength: number
bgScale: number
slideDuration: number
}
const DEFAULTS: HeroLogoConfig = {
speed: 1,
tiltX: -0.1,
tiltZ: -0.1,
zoom: 7,
fov: 50,
logoColor: '#F2FF59',
extrudeDepth: 200,
cursorTiltStrength: 0.5,
bgScale: 0.8,
slideDuration: 0.4
}
function buildImageUrls(): string[] {
return Array.from({ length: IMAGE_COUNT }, (_, i) => {
const index = String(i).padStart(5, '0')
return `${BASE_URL}/image_sequence_${index}.webp`
})
}
function parseShapes(): THREE.Shape[] {
const loader = new SVGLoader()
const svgData = loader.parse(SVG_MARKUP)
const shapes: THREE.Shape[] = []
svgData.paths.forEach((path) => {
shapes.push(...SVGLoader.createShapes(path))
})
return shapes
}
function loadTextures(urls: string[]): Promise<THREE.Texture[]> {
return Promise.all(
urls.map(
(url) =>
new Promise<THREE.Texture | null>((resolve) => {
const img = new Image()
img.crossOrigin = 'anonymous'
img.onload = () => {
const tex = new THREE.Texture(img)
tex.needsUpdate = true
tex.colorSpace = THREE.SRGBColorSpace
resolve(tex)
}
img.onerror = () => resolve(null)
img.src = url
})
)
).then((results) => results.filter((t): t is THREE.Texture => t !== null))
}
export function useHeroLogo(
containerRef: Ref<HTMLElement | undefined>,
config: Partial<HeroLogoConfig> = {}
) {
const cfg = { ...DEFAULTS, ...config }
const loaded = ref(false)
let cleanup: (() => void) | undefined
onMounted(async () => {
try {
const container = containerRef.value
if (!container || prefersReducedMotion()) return
const { width, height } = container.getBoundingClientRect()
const renderer = new THREE.WebGLRenderer({
antialias: true,
stencil: true,
alpha: true
})
renderer.setSize(width, height)
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2))
renderer.outputColorSpace = THREE.SRGBColorSpace
renderer.domElement.style.position = 'absolute'
renderer.domElement.style.inset = '0'
renderer.domElement.style.width = '100%'
renderer.domElement.style.height = '100%'
renderer.domElement.style.opacity = '0'
renderer.domElement.setAttribute('aria-hidden', 'true')
container.appendChild(renderer.domElement)
let disposed = false
const teardowns: Array<() => void> = []
cleanup = () => {
disposed = true
teardowns.forEach((fn) => fn())
}
teardowns.push(() => {
renderer.dispose()
renderer.domElement.remove()
})
const scene = new THREE.Scene()
const camera = new THREE.PerspectiveCamera(
cfg.fov,
width / height,
0.1,
1000
)
camera.position.z = cfg.zoom
// SVG shape
const shapes = parseShapes()
const tempGeo = new THREE.ShapeGeometry(shapes)
tempGeo.computeBoundingBox()
const bb = tempGeo.boundingBox!
const cx = (bb.max.x + bb.min.x) / 2
const cy = (bb.max.y + bb.min.y) / 2
const scaleFactor = 3 / (bb.max.y - bb.min.y)
tempGeo.dispose()
// Image sequence textures — load first frame eagerly, rest lazily
const urls = buildImageUrls()
const textures = await loadTextures(urls.slice(0, 1))
if (disposed) return
renderer.domElement.style.opacity = '1'
loaded.value = true
loadTextures(urls.slice(1)).then((rest) => {
if (!disposed) textures.push(...rest)
})
// Background plane (stencil read)
const bgPlaneGeo = new THREE.PlaneGeometry(14, 14)
const bgPlaneMat = new THREE.MeshBasicMaterial({
transparent: true,
opacity: 1,
map: textures[0] ?? null,
depthTest: false,
depthWrite: false,
stencilWrite: true,
stencilFunc: THREE.EqualStencilFunc,
stencilRef: 1,
stencilFail: THREE.KeepStencilOp,
stencilZFail: THREE.KeepStencilOp,
stencilZPass: THREE.KeepStencilOp
})
const bgPlane = new THREE.Mesh(bgPlaneGeo, bgPlaneMat)
bgPlane.renderOrder = 1
bgPlane.scale.set(cfg.bgScale, cfg.bgScale, 1)
scene.add(bgPlane)
// Logo group
const group = new THREE.Group()
scene.add(group)
const s = scaleFactor
const depth = cfg.extrudeDepth
// Front face
const shapeGeo = new THREE.ShapeGeometry(shapes)
shapeGeo.translate(-cx, -cy, 0)
shapeGeo.scale(s, -s, s)
const shapeMat = new THREE.MeshBasicMaterial({
color: cfg.logoColor,
side: THREE.DoubleSide,
depthTest: false,
depthWrite: false,
transparent: true
})
const logoMesh = new THREE.Mesh(shapeGeo, shapeMat)
logoMesh.renderOrder = 2
group.add(logoMesh)
// Extrusion stencil mask
const extrudeGeo = new THREE.ExtrudeGeometry(shapes, {
depth,
bevelEnabled: false
})
extrudeGeo.translate(-cx, -cy, -depth)
extrudeGeo.scale(s, -s, s)
const extrudeMat = new THREE.MeshBasicMaterial({
colorWrite: false,
depthWrite: true,
depthTest: true,
stencilWrite: true,
stencilRef: 1,
stencilFunc: THREE.AlwaysStencilFunc,
stencilZPass: THREE.ReplaceStencilOp,
stencilFail: THREE.KeepStencilOp,
stencilZFail: THREE.KeepStencilOp,
side: THREE.DoubleSide
})
const extrudeMesh = new THREE.Mesh(extrudeGeo, extrudeMat)
extrudeMesh.renderOrder = 0
group.add(extrudeMesh)
// Interaction
let isDragging = false
let previousX = 0
let dragVelocity = 0
let currentTiltX = 0
let currentTiltY = 0
let pointerX = 0
let pointerY = 0
let rotationT = 0
let currentSlide = 0
let slideTimer = 0
let animationId = 0
function onMouseMove(e: MouseEvent) {
pointerX = (e.clientX / window.innerWidth) * 2 - 1
pointerY = (e.clientY / window.innerHeight) * 2 - 1
}
function onPointerDown(e: PointerEvent) {
isDragging = true
dragVelocity = 0
previousX = e.clientX
}
function onPointerMove(e: PointerEvent) {
if (!isDragging) return
dragVelocity = (e.clientX - previousX) * 0.005
rotationT += dragVelocity
previousX = e.clientX
}
function onPointerUp() {
isDragging = false
}
function onResize() {
const rect = container!.getBoundingClientRect()
camera.aspect = rect.width / rect.height
camera.updateProjectionMatrix()
renderer.setSize(rect.width, rect.height)
}
window.addEventListener('mousemove', onMouseMove)
renderer.domElement.addEventListener('pointerdown', onPointerDown)
window.addEventListener('pointermove', onPointerMove)
window.addEventListener('pointerup', onPointerUp)
window.addEventListener('resize', onResize)
const clock = new THREE.Clock()
function animate() {
if (disposed) return
animationId = requestAnimationFrame(animate)
const dt = clock.getDelta()
if (!isDragging && Math.abs(dragVelocity) > 0.0001) {
dragVelocity *= 0.95
rotationT += dragVelocity
} else if (!isDragging) {
dragVelocity = 0
}
rotationT += cfg.speed * dt
currentTiltX += (pointerY - currentTiltX) * 0.08
currentTiltY += (pointerX - currentTiltY) * 0.08
group.rotation.y = rotationT % (Math.PI * 2)
group.rotation.x = cfg.tiltX - currentTiltX * cfg.cursorTiltStrength
group.rotation.z = cfg.tiltZ
if (textures.length > 1) {
slideTimer += dt
if (slideTimer >= cfg.slideDuration) {
slideTimer = 0
currentSlide = (currentSlide + 1) % textures.length
bgPlaneMat.map = textures[currentSlide]
bgPlaneMat.needsUpdate = true
}
}
renderer.render(scene, camera)
}
animate()
teardowns.push(
() => cancelAnimationFrame(animationId),
() => window.removeEventListener('mousemove', onMouseMove),
() =>
renderer.domElement.removeEventListener('pointerdown', onPointerDown),
() => window.removeEventListener('pointermove', onPointerMove),
() => window.removeEventListener('pointerup', onPointerUp),
() => window.removeEventListener('resize', onResize),
() => bgPlaneGeo.dispose(),
() => bgPlaneMat.dispose(),
() => shapeGeo.dispose(),
() => shapeMat.dispose(),
() => extrudeGeo.dispose(),
() => extrudeMat.dispose(),
() => textures.forEach((tex) => tex.dispose())
)
} catch (err) {
console.error('[useHeroLogo] initialization failed:', err)
cleanup?.()
}
})
onUnmounted(() => {
cleanup?.()
})
return { loaded }
}

View File

@@ -1119,6 +1119,10 @@ const translations = {
en: 'Import your own LoRAs',
'zh-CN': '导入你自己的 LoRA'
},
'pricing.plan.creator.feature2': {
en: '3 concurrent API jobs',
'zh-CN': '3 个并发 API 任务'
},
'pricing.plan.pro.label': { en: 'PRO', 'zh-CN': '专业版' },
'pricing.plan.pro.summary': {
@@ -1143,6 +1147,10 @@ const translations = {
en: 'Longer workflow runtime (up to 1 hour)',
'zh-CN': '更长工作流运行时长(最长 1 小时)'
},
'pricing.plan.pro.feature2': {
en: '5 concurrent API jobs',
'zh-CN': '5 个并发 API 任务'
},
'pricing.enterprise.label': { en: 'ENTERPRISE', 'zh-CN': '企业版' },
'pricing.enterprise.heading': {

File diff suppressed because it is too large Load Diff

View File

@@ -1,284 +0,0 @@
{
"id": "2f54e2f0-6db4-4bdf-84a8-9c3ea3ec0123",
"revision": 0,
"last_node_id": 13,
"last_link_id": 9,
"nodes": [
{
"id": 11,
"type": "422723e8-4bf6-438c-823f-881ca81acead",
"pos": [120, 180],
"size": [400, 200],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [
{ "name": "clip", "type": "CLIP", "link": null },
{ "name": "model", "type": "MODEL", "link": null },
{ "name": "positive", "type": "CONDITIONING", "link": null },
{ "name": "negative", "type": "CONDITIONING", "link": null },
{ "name": "latent_image", "type": "LATENT", "link": null }
],
"outputs": [],
"properties": {},
"widgets_values": ["Alpha\n"]
},
{
"id": 12,
"type": "422723e8-4bf6-438c-823f-881ca81acead",
"pos": [420, 180],
"size": [400, 200],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [
{ "name": "clip", "type": "CLIP", "link": null },
{ "name": "model", "type": "MODEL", "link": null },
{ "name": "positive", "type": "CONDITIONING", "link": null },
{ "name": "negative", "type": "CONDITIONING", "link": null },
{ "name": "latent_image", "type": "LATENT", "link": null }
],
"outputs": [],
"properties": {},
"widgets_values": ["Beta\n"]
},
{
"id": 13,
"type": "422723e8-4bf6-438c-823f-881ca81acead",
"pos": [720, 180],
"size": [400, 200],
"flags": {},
"order": 2,
"mode": 0,
"inputs": [
{ "name": "clip", "type": "CLIP", "link": null },
{ "name": "model", "type": "MODEL", "link": null },
{ "name": "positive", "type": "CONDITIONING", "link": null },
{ "name": "negative", "type": "CONDITIONING", "link": null },
{ "name": "latent_image", "type": "LATENT", "link": null }
],
"outputs": [],
"properties": {},
"widgets_values": ["Gamma\n"]
}
],
"links": [],
"groups": [],
"definitions": {
"subgraphs": [
{
"id": "422723e8-4bf6-438c-823f-881ca81acead",
"version": 1,
"state": {
"lastGroupId": 0,
"lastNodeId": 11,
"lastLinkId": 15,
"lastRerouteId": 0
},
"revision": 0,
"config": {},
"name": "New Subgraph",
"inputNode": {
"id": -10,
"bounding": [481.59912109375, 379.13336181640625, 120, 160]
},
"outputNode": {
"id": -20,
"bounding": [1121.59912109375, 379.13336181640625, 120, 40]
},
"inputs": [
{
"id": "0f07c10e-5705-4764-9b24-b69606c6dbcc",
"name": "text",
"type": "STRING",
"linkIds": [10],
"pos": { "0": 581.59912109375, "1": 399.13336181640625 }
},
{
"id": "214a5060-24dd-4299-ab78-8027dc5b9c59",
"name": "clip",
"type": "CLIP",
"linkIds": [11],
"pos": { "0": 581.59912109375, "1": 419.13336181640625 }
},
{
"id": "8ab94c5d-e7df-433c-9177-482a32340552",
"name": "model",
"type": "MODEL",
"linkIds": [12],
"pos": { "0": 581.59912109375, "1": 439.13336181640625 }
},
{
"id": "8a4cd719-8c67-473b-9b44-ac0582d02641",
"name": "positive",
"type": "CONDITIONING",
"linkIds": [13],
"pos": { "0": 581.59912109375, "1": 459.13336181640625 }
},
{
"id": "a78d6b3a-ad40-4300-b0a5-2cdbdb8dc135",
"name": "negative",
"type": "CONDITIONING",
"linkIds": [14],
"pos": { "0": 581.59912109375, "1": 479.13336181640625 }
},
{
"id": "4c7abe0c-902d-49ef-a5b0-cbf02b50b693",
"name": "latent_image",
"type": "LATENT",
"linkIds": [15],
"pos": { "0": 581.59912109375, "1": 499.13336181640625 }
}
],
"outputs": [],
"widgets": [],
"nodes": [
{
"id": 10,
"type": "CLIPTextEncode",
"pos": [661.59912109375, 314.13336181640625],
"size": [400, 200],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [
{
"localized_name": "clip",
"name": "clip",
"type": "CLIP",
"link": 11
},
{
"localized_name": "text",
"name": "text",
"type": "STRING",
"widget": { "name": "text" },
"link": 10
}
],
"outputs": [
{
"localized_name": "CONDITIONING",
"name": "CONDITIONING",
"type": "CONDITIONING",
"links": null
}
],
"properties": {
"Node name for S&R": "CLIPTextEncode"
},
"widgets_values": [""]
},
{
"id": 11,
"type": "KSampler",
"pos": [674.1234741210938, 570.5839233398438],
"size": [270, 262],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [
{
"localized_name": "model",
"name": "model",
"type": "MODEL",
"link": 12
},
{
"localized_name": "positive",
"name": "positive",
"type": "CONDITIONING",
"link": 13
},
{
"localized_name": "negative",
"name": "negative",
"type": "CONDITIONING",
"link": 14
},
{
"localized_name": "latent_image",
"name": "latent_image",
"type": "LATENT",
"link": 15
}
],
"outputs": [
{
"localized_name": "LATENT",
"name": "LATENT",
"type": "LATENT",
"links": null
}
],
"properties": {
"Node name for S&R": "KSampler"
},
"widgets_values": [0, "randomize", 20, 8, "euler", "simple", 1]
}
],
"groups": [],
"links": [
{
"id": 10,
"origin_id": -10,
"origin_slot": 0,
"target_id": 10,
"target_slot": 1,
"type": "STRING"
},
{
"id": 11,
"origin_id": -10,
"origin_slot": 1,
"target_id": 10,
"target_slot": 0,
"type": "CLIP"
},
{
"id": 12,
"origin_id": -10,
"origin_slot": 2,
"target_id": 11,
"target_slot": 0,
"type": "MODEL"
},
{
"id": 13,
"origin_id": -10,
"origin_slot": 3,
"target_id": 11,
"target_slot": 1,
"type": "CONDITIONING"
},
{
"id": 14,
"origin_id": -10,
"origin_slot": 4,
"target_id": 11,
"target_slot": 2,
"type": "CONDITIONING"
},
{
"id": 15,
"origin_id": -10,
"origin_slot": 5,
"target_id": 11,
"target_slot": 3,
"type": "LATENT"
}
],
"extra": {}
}
]
},
"config": {},
"extra": {
"ds": {
"scale": 1,
"offset": [0, 0]
},
"frontendVersion": "1.24.1"
},
"version": 0.4
}

View File

@@ -0,0 +1,179 @@
{
"id": "5c4a1450-26b8-4b34-b5ea-e3465273441e",
"revision": 0,
"last_node_id": 4,
"last_link_id": 2,
"nodes": [
{
"id": 2,
"type": "16aadaf6-aa66-4041-843e-589a6572a3ac",
"pos": [602, 409],
"size": [225, 144],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [],
"properties": {
"proxyWidgets": [
["1", "value"],
["4", "value"]
]
},
"widgets_values": []
}
],
"links": [],
"groups": [],
"definitions": {
"subgraphs": [
{
"id": "16aadaf6-aa66-4041-843e-589a6572a3ac",
"version": 1,
"state": {
"lastGroupId": 0,
"lastNodeId": 4,
"lastLinkId": 2,
"lastRerouteId": 0
},
"revision": 0,
"config": {},
"name": "New Subgraph",
"inputNode": {
"id": -10,
"bounding": [349, 383, 128, 68]
},
"outputNode": {
"id": -20,
"bounding": [867, 383, 128, 48]
},
"inputs": [
{
"id": "50fd1af4-4f20-434f-9828-6971210be4e9",
"name": "value",
"type": "STRING",
"linkIds": [1],
"pos": [453, 407]
}
],
"outputs": [],
"widgets": [],
"nodes": [
{
"id": 1,
"type": "PrimitiveString",
"pos": [537, 368],
"size": [270, 108],
"flags": {},
"order": 2,
"mode": 0,
"inputs": [
{
"localized_name": "value",
"name": "value",
"type": "STRING",
"widget": {
"name": "value"
},
"link": 1
}
],
"outputs": [
{
"localized_name": "STRING",
"name": "STRING",
"type": "STRING",
"links": null
}
],
"properties": {
"Node name for S&R": "PrimitiveString"
},
"widgets_values": [""]
},
{
"id": 3,
"type": "PrimitiveInt",
"pos": [534.9899497487436, 515.4924623115581],
"size": [270, 104],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [
{
"localized_name": "value",
"name": "value",
"type": "INT",
"widget": {
"name": "value"
},
"link": 2
}
],
"outputs": [
{
"localized_name": "INT",
"name": "INT",
"type": "INT",
"links": null
}
],
"properties": {
"Node name for S&R": "PrimitiveInt"
},
"widgets_values": [0, "randomize"]
},
{
"id": 4,
"type": "PrimitiveNode",
"pos": [258.4381232333541, 549.1608040200999],
"size": [225, 104],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [],
"outputs": [
{
"name": "INT",
"type": "INT",
"widget": {
"name": "value"
},
"links": [2]
}
],
"properties": {
"Run widget replace on values": false
},
"widgets_values": [0, "randomize"]
}
],
"groups": [],
"links": [
{
"id": 1,
"origin_id": -10,
"origin_slot": 0,
"target_id": 1,
"target_slot": 0,
"type": "STRING"
},
{
"id": 2,
"origin_id": 4,
"origin_slot": 0,
"target_id": 3,
"target_slot": 0,
"type": "INT"
}
],
"extra": {}
}
]
},
"config": {},
"extra": {
"frontendVersion": "1.44.17"
},
"version": 0.4
}

View File

@@ -190,6 +190,9 @@ export class ComfyPage {
/** Worker index to test user ID */
public readonly userIds: string[] = []
/** Whether the current test runs in Vue Nodes mode (initialized from `@vue-nodes` tag). */
public isVueNodes = false
/** Test user ID for the current context */
get id() {
return this.userIds[comfyPageFixture.info().parallelIndex]
@@ -352,6 +355,12 @@ export class ComfyPage {
await nextFrame(this.page)
}
async idleFrames(count: number) {
for (let i = 0; i < count; i++) {
await this.nextFrame()
}
}
async delay(ms: number) {
return sleep(ms)
}
@@ -494,6 +503,7 @@ export const comfyPageFixture = base.extend<{
comfyPage.userIds[parallelIndex] = userId
const isVueNodes = testInfo.tags.includes('@vue-nodes')
comfyPage.isVueNodes = isVueNodes
try {
await comfyPage.setupSettings({

View File

@@ -217,13 +217,20 @@ export class VueNodeHelpers {
}
}
/**
* Locator for the Enter Subgraph footer button.
*/
getSubgraphEnterButton(nodeId?: string): Locator {
const root = nodeId ? this.getNodeLocator(nodeId) : this.page
return root.getByTestId(TestIds.widgets.subgraphEnterButton).first()
}
/**
* Enter the subgraph of a node.
* @param nodeId - The ID of the node to enter the subgraph of. If not provided, the first matched subgraph will be entered.
*/
async enterSubgraph(nodeId?: string): Promise<void> {
const locator = nodeId ? this.getNodeLocator(nodeId) : this.page
const editButton = locator.getByTestId(TestIds.widgets.subgraphEnterButton)
const editButton = this.getSubgraphEnterButton(nodeId)
// The footer tab button extends below the node body (visible area),
// but its bounding box center overlaps the node body div.

View File

@@ -39,10 +39,32 @@ class ComfyQueueButton {
await this.dropdownButton.click()
return new ComfyQueueButtonOptions(this.actionbar.page)
}
public async openOptions() {
const options = new ComfyQueueButtonOptions(this.actionbar.page)
if (!(await options.menu.isVisible())) {
await this.dropdownButton.click()
}
return options
}
}
class ComfyQueueButtonOptions {
constructor(public readonly page: Page) {}
public readonly menu: Locator
public readonly modeItems: Locator
constructor(public readonly page: Page) {
this.menu = page.getByRole('menu')
this.modeItems = this.menu.getByRole('menuitem')
}
public modeItem(name: string) {
return this.menu.getByRole('menuitem', { name, exact: true })
}
public async selectMode(name: string) {
await this.modeItem(name).click()
}
public async setMode(mode: AutoQueueMode) {
await this.page.evaluate((mode) => {

View File

@@ -20,6 +20,7 @@ export class ContextMenu {
async clickMenuItemExact(name: string): Promise<void> {
await this.page.getByRole('menuitem', { name, exact: true }).click()
await this.waitForHidden()
}
/**

View File

@@ -0,0 +1,12 @@
import type { Locator } from '@playwright/test'
export class WidgetSelectDropdownFixture {
public readonly selection: Locator
constructor(public readonly root: Locator) {
this.selection = root.locator('button span span')
}
async selectedItem(): Promise<string> {
return await this.selection.innerText()
}
}

View File

@@ -9,13 +9,15 @@ import { BuilderFooterHelper } from '@e2e/fixtures/helpers/BuilderFooterHelper'
import { BuilderSaveAsHelper } from '@e2e/fixtures/helpers/BuilderSaveAsHelper'
import { BuilderSelectHelper } from '@e2e/fixtures/helpers/BuilderSelectHelper'
import { BuilderStepsHelper } from '@e2e/fixtures/helpers/BuilderStepsHelper'
import { MobileAppHelper } from '@e2e/fixtures/helpers/MobileAppHelper'
export class AppModeHelper {
readonly steps: BuilderStepsHelper
readonly footer: BuilderFooterHelper
readonly mobile: MobileAppHelper
readonly saveAs: BuilderSaveAsHelper
readonly select: BuilderSelectHelper
readonly outputHistory: OutputHistoryComponent
readonly steps: BuilderStepsHelper
readonly widgets: AppModeWidgetHelper
/** The "Connect an output" popover shown when saving without outputs. */
@@ -60,13 +62,16 @@ export class AppModeHelper {
public readonly vueNodeSwitchDismissButton: Locator
/** The "Don't show again" checkbox inside the Vue Node switch popup. */
public readonly vueNodeSwitchDontShowAgainCheckbox: Locator
/** The main content area where outputs are displayed*/
public readonly centerPanel: Locator
constructor(private readonly comfyPage: ComfyPage) {
this.steps = new BuilderStepsHelper(comfyPage)
this.mobile = new MobileAppHelper(comfyPage)
this.footer = new BuilderFooterHelper(comfyPage)
this.saveAs = new BuilderSaveAsHelper(comfyPage)
this.select = new BuilderSelectHelper(comfyPage)
this.outputHistory = new OutputHistoryComponent(comfyPage.page)
this.steps = new BuilderStepsHelper(comfyPage)
this.widgets = new AppModeWidgetHelper(comfyPage)
this.connectOutputPopover = this.page.getByTestId(
@@ -125,6 +130,7 @@ export class AppModeHelper {
this.vueNodeSwitchDontShowAgainCheckbox = this.page.getByTestId(
TestIds.appMode.vueNodeSwitchDontShowAgain
)
this.centerPanel = this.page.getByTestId(TestIds.linear.centerPanel)
}
private get page(): Page {

View File

@@ -215,11 +215,12 @@ export class AssetHelper {
return this.store.size
}
private handleListAssets(route: Route, url: URL) {
const includeTags = url.searchParams.get('include_tags')?.split(',') ?? []
const includeTags = parseAssetTagParam(url.searchParams.get('include_tags'))
const excludeTags = parseAssetTagParam(url.searchParams.get('exclude_tags'))
const limit = parseInt(url.searchParams.get('limit') ?? '0', 10)
const offset = parseInt(url.searchParams.get('offset') ?? '0', 10)
let filtered = this.getFilteredAssets(includeTags)
let filtered = this.getFilteredAssets(includeTags, excludeTags)
if (limit > 0) {
filtered = filtered.slice(offset, offset + limit)
}
@@ -296,15 +297,29 @@ export class AssetHelper {
this.paginationOptions = null
this.uploadResponse = null
}
private getFilteredAssets(tags: string[]): Asset[] {
private getFilteredAssets(
includeTags: string[],
excludeTags: string[]
): Asset[] {
const assets = [...this.store.values()]
if (tags.length === 0) return assets
return assets.filter((asset) =>
tags.every((tag) => (asset.tags ?? []).includes(tag))
return assets.filter(
(asset) =>
includeTags.every((tag) => (asset.tags ?? []).includes(tag)) &&
excludeTags.every((tag) => !(asset.tags ?? []).includes(tag))
)
}
}
function parseAssetTagParam(value: string | null): string[] {
return (
value
?.split(',')
.map((tag) => tag.trim())
.filter(Boolean) ?? []
)
}
export function createAssetHelper(
page: Page,
...operators: AssetOperator[]

View File

@@ -1,4 +1,5 @@
import { readFileSync } from 'fs'
import { basename } from 'path'
import type { Page } from '@playwright/test'
@@ -13,6 +14,7 @@ export class DragDropHelper {
async dragAndDropExternalResource(
options: {
fileName?: string
filePath?: string
url?: string
dropPosition?: Position
waitForUpload?: boolean
@@ -22,13 +24,14 @@ export class DragDropHelper {
const {
dropPosition = { x: 100, y: 100 },
fileName,
filePath,
url,
waitForUpload = false,
preserveNativePropagation = false
} = options
if (!fileName && !url)
throw new Error('Must provide either fileName or url')
if (!fileName && !filePath && !url)
throw new Error('Must provide fileName, filePath, or url')
const evaluateParams: {
dropPosition: Position
@@ -39,12 +42,22 @@ export class DragDropHelper {
preserveNativePropagation: boolean
} = { dropPosition, preserveNativePropagation }
if (fileName) {
const filePath = assetPath(fileName)
const buffer = readFileSync(filePath)
if (fileName || filePath) {
const resolvedPath = filePath ?? assetPath(fileName!)
const displayName = fileName ?? basename(resolvedPath)
let buffer: Buffer
try {
buffer = readFileSync(resolvedPath)
} catch (error) {
const reason = error instanceof Error ? error.message : String(error)
throw new Error(
`Failed to read drag-and-drop fixture at "${resolvedPath}": ${reason}`,
{ cause: error }
)
}
evaluateParams.fileName = fileName
evaluateParams.fileType = getMimeType(fileName)
evaluateParams.fileName = displayName
evaluateParams.fileType = getMimeType(displayName)
evaluateParams.buffer = [...new Uint8Array(buffer)]
}
@@ -148,6 +161,13 @@ export class DragDropHelper {
return this.dragAndDropExternalResource({ fileName, ...options })
}
async dragAndDropFilePath(
filePath: string,
options: { dropPosition?: Position; waitForUpload?: boolean } = {}
): Promise<void> {
return this.dragAndDropExternalResource({ filePath, ...options })
}
async dragAndDropURL(
url: string,
options: {

View File

@@ -0,0 +1,33 @@
import type { Locator, Page } from '@playwright/test'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import { TestIds } from '@e2e/fixtures/selectors'
export class MobileAppHelper {
private readonly page: Page
readonly contentPanel: Locator
readonly navigation: Locator
readonly navigationTabs: Locator
readonly view: Locator
readonly workflows: Locator
constructor(comfyPage: ComfyPage) {
this.page = comfyPage.page
this.view = this.page.getByTestId(TestIds.linear.mobile)
this.contentPanel = this.page.getByRole('tabpanel')
this.navigation = this.page.getByRole('tablist').filter({ hasText: 'Run' })
this.navigationTabs = this.navigation.getByRole('tab')
this.workflows = this.view.getByTestId(TestIds.linear.mobileWorkflows)
}
async switchWorkflow(workflowName: string) {
await this.workflows.click()
await this.page.getByRole('menu').getByText(workflowName).click()
}
async navigateTab(name: 'run' | 'outputs' | 'assets') {
await this.navigation.getByRole('tab', { name }).click()
}
async tap(locator: Locator, { count = 1 }: { count?: number } = {}) {
for (let i = 0; i < count; i++) await locator.tap()
}
}

View File

@@ -7,10 +7,6 @@ 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 type {
ProxyWidgetTuple,
SerializedProxyWidgetTuple
} from '@/core/schemas/promotionSchema'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import { TestIds } from '@e2e/fixtures/selectors'
@@ -366,6 +362,9 @@ export class SubgraphHelper {
await this.comfyPage.nextFrame()
await expect.poll(async () => this.isInSubgraph()).toBe(false)
if (this.comfyPage.isVueNodes) {
await this.comfyPage.vueNodes.waitForNodes()
}
}
async countGraphPseudoPreviewEntries(): Promise<number> {
@@ -390,7 +389,7 @@ export class SubgraphHelper {
}
async getHostPromotedTupleSnapshot(): Promise<
{ hostNodeId: string; promotedWidgets: SerializedProxyWidgetTuple[] }[]
{ hostNodeId: string; promotedWidgets: [string, string][] }[]
> {
return this.page.evaluate(() => {
const graph = window.app!.canvas.graph!
@@ -405,18 +404,15 @@ export class SubgraphHelper {
: []
const promotedWidgets = proxyWidgets
.filter(
(entry): entry is ProxyWidgetTuple =>
(entry): entry is [string, string] =>
Array.isArray(entry) &&
entry.length >= 2 &&
typeof entry[0] === 'string' &&
typeof entry[1] === 'string'
)
.map(
([sourceNodeId, serializedSourceWidgetName]) =>
[
sourceNodeId,
serializedSourceWidgetName
] satisfies SerializedProxyWidgetTuple
([interiorNodeId, widgetName]) =>
[interiorNodeId, widgetName] as [string, string]
)
return {

View File

@@ -144,6 +144,14 @@ export const TestIds = {
domWidgetTextarea: 'dom-widget-textarea',
subgraphEnterButton: 'subgraph-enter-button'
},
linear: {
centerPanel: 'linear-center-panel',
mobile: 'linear-mobile',
mobileNavigation: 'linear-mobile-navigation',
mobileWorkflows: 'linear-mobile-workflows',
outputInfo: 'linear-output-info',
widgetContainer: 'linear-widgets'
},
builder: {
footerNav: 'builder-footer-nav',
saveButton: 'builder-save-button',

View File

@@ -7,6 +7,9 @@ export function getMimeType(fileName: string): string {
if (name.endsWith('.avif')) return 'image/avif'
if (name.endsWith('.webm')) return 'video/webm'
if (name.endsWith('.mp4')) return 'video/mp4'
if (name.endsWith('.mp3')) return 'audio/mpeg'
if (name.endsWith('.flac')) return 'audio/flac'
if (name.endsWith('.ogg') || name.endsWith('.opus')) return 'audio/ogg'
if (name.endsWith('.json')) return 'application/json'
if (name.endsWith('.glb')) return 'model/gltf-binary'
return 'application/octet-stream'

View File

@@ -1,3 +1,7 @@
export function assetPath(fileName: string): string {
return `./browser_tests/assets/${fileName}`
}
export function metadataFixturePath(fileName: string): string {
return `./src/scripts/metadata/__fixtures__/${fileName}`
}

View File

@@ -13,6 +13,7 @@ export class VueNodeFixture {
public readonly collapseButton: Locator
public readonly collapseIcon: Locator
public readonly root: Locator
public readonly widgets: Locator
constructor(private readonly locator: Locator) {
this.header = locator.locator('[data-testid^="node-header-"]')
@@ -23,6 +24,7 @@ export class VueNodeFixture {
this.collapseButton = locator.getByTestId('node-collapse-button')
this.collapseIcon = this.collapseButton.locator('i')
this.root = locator
this.widgets = this.locator.locator('.lg-node-widget')
}
async getTitle(): Promise<string> {
@@ -39,6 +41,16 @@ export class VueNodeFixture {
await this.collapseButton.click()
}
/**
* Select this node and delete it via the Delete key, waiting for the node
* element to leave the DOM before resolving.
*/
async delete(): Promise<void> {
await this.header.click()
await this.header.press('Delete')
await this.locator.waitFor({ state: 'hidden' })
}
async getCollapseIconClass(): Promise<string> {
return (await this.collapseIcon.getAttribute('class')) ?? ''
}

View File

@@ -0,0 +1,154 @@
import {
comfyPageFixture as test,
comfyExpect as expect
} from '@e2e/fixtures/ComfyPage'
import { WidgetSelectDropdownFixture } from '@e2e/fixtures/components/WidgetSelectDropdown'
test.describe('App mode usage', () => {
test('Drag and Drop', async ({ comfyPage, comfyFiles }) => {
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
await comfyPage.settings.setSetting(
'Comfy.NodeSearchBoxImpl',
'v1 (legacy)'
)
const { centerPanel } = comfyPage.appMode
await comfyPage.appMode.enterAppModeWithInputs([['3', 'seed']])
await expect(centerPanel, 'Enter app mode').toBeVisible()
//an app without an image input will load the workflow
await test.step('App without an image input loads workflow', async () => {
await comfyPage.dragDrop.dragAndDropFile('workflowInMedia/workflow.webp')
await expect(centerPanel).toBeHidden()
})
//prep a load image
await test.step('Add a load image node', async () => {
await comfyPage.workflow.loadWorkflow('default')
await comfyPage.page.mouse.dblclick(200, 200, { delay: 5 })
await comfyPage.searchBox.fillAndSelectFirstNode('Load Image')
const loadImage = await comfyPage.vueNodes.getNodeLocator('10')
await expect(loadImage).toBeVisible()
})
const imageInput = new WidgetSelectDropdownFixture(
comfyPage.appMode.linearWidgets.locator('.lg-node-widget')
)
await test.step('Enter app mode with image input', async () => {
await comfyPage.appMode.enterAppModeWithInputs([['10', 'image']])
await expect(centerPanel).toBeVisible()
await expect(imageInput.root).toBeVisible()
})
await test.step('Dragging an image redirects to image input', async () => {
const initialImage = await imageInput.selectedItem()
await comfyPage.dragDrop.dragAndDropExternalResource({
fileName: 'workflow.webp',
filePath: './browser_tests/assets/workflowInMedia/workflow.webp',
preserveNativePropagation: true
})
comfyFiles.deleteAfterTest({ filename: 'workflow.webp', type: 'input' })
await expect(imageInput.selection).not.toHaveText(initialImage)
await expect(
centerPanel,
'A file with workflow should not open a new workflow'
).toBeVisible()
})
await test.step('Dragging a url redirects to image input', async () => {
const secondImage = await imageInput.selectedItem()
await comfyPage.dragDrop.dragAndDropURL('/assets/images/og-image.png', {
preserveNativePropagation: true
})
comfyFiles.deleteAfterTest({
filename: 'og-image.png',
type: 'input'
})
await expect(imageInput.selection).not.toHaveText(secondImage)
})
})
test('Widget Interaction', async ({ comfyPage }) => {
await comfyPage.appMode.enterAppModeWithInputs([
['3', 'seed'],
['3', 'sampler_name'],
['6', 'text']
])
const seed = comfyPage.appMode.linearWidgets.getByLabel('seed', {
exact: true
})
const { input, incrementButton, decrementButton } =
comfyPage.vueNodes.getInputNumberControls(seed)
const initialValue = Number(await input.inputValue())
await seed.dragTo(incrementButton, { steps: 5 })
const intermediateValue = Number(await input.inputValue())
expect(intermediateValue).toBeGreaterThan(initialValue)
await seed.dragTo(decrementButton, { steps: 5 })
const endValue = Number(await input.inputValue())
expect(endValue).toBeLessThan(intermediateValue)
const sampler = comfyPage.appMode.linearWidgets.getByLabel('sampler_name', {
exact: true
})
await sampler.click()
await comfyPage.page.getByRole('searchbox').fill('uni')
await comfyPage.page.keyboard.press('ArrowDown')
await comfyPage.page.keyboard.press('Enter')
await expect(sampler).toHaveText('uni_pc')
//verify values are consistent with litegraph
})
test.describe('Mobile', { tag: ['@mobile'] }, () => {
test('panel navigation', async ({ comfyPage }) => {
const { mobile } = comfyPage.appMode
await comfyPage.appMode.enterAppModeWithInputs([['3', 'steps']])
await expect(mobile.view).toBeVisible()
await expect(mobile.navigation).toBeVisible()
await mobile.navigateTab('assets')
await expect(mobile.contentPanel).toHaveAccessibleName('Assets')
const buttons = await mobile.navigationTabs.all()
await buttons[0].dragTo(buttons[2], { steps: 5 })
await expect(mobile.contentPanel).toHaveAccessibleName('Outputs')
await mobile.navigateTab('run')
await expect(comfyPage.appMode.linearWidgets).toBeInViewport({ ratio: 1 })
const steps = comfyPage.page.getByRole('spinbutton')
const initialValue = Number(await steps.inputValue())
await mobile.tap(
comfyPage.page.getByRole('button', { name: 'increment' }),
{ count: 5 }
)
await expect(steps).toHaveValue(String(initialValue + 5))
await mobile.tap(
comfyPage.page.getByRole('button', { name: 'decrement' }),
{ count: 3 }
)
await expect(steps).toHaveValue(String(initialValue + 2))
})
test('workflow selection', async ({ comfyPage }) => {
const widgetNames = ['seed', 'steps', 'denoise', 'cfg']
for (const name of widgetNames)
await comfyPage.appMode.enterAppModeWithInputs([['3', name]])
await expect(comfyPage.appMode.mobile.workflows).toBeVisible()
const widgets = comfyPage.appMode.linearWidgets
await comfyPage.appMode.mobile.navigateTab('run')
for (let i = 0; i < widgetNames.length; i++) {
await comfyPage.appMode.mobile.switchWorkflow(`(${i + 2})`)
await expect(widgets.getByText(widgetNames[i])).toBeVisible()
}
})
})
})

View File

@@ -0,0 +1,121 @@
import {
comfyPageFixture as test,
comfyExpect as expect
} from '@e2e/fixtures/ComfyPage'
test.describe('App mode builder selection', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.appMode.enableLinearMode()
})
test('Can independently select inputs of same name', async ({
comfyPage
}) => {
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
const items = comfyPage.appMode.select.inputItems
await comfyPage.vueNodes.selectNodes(['6', '7'])
await comfyPage.command.executeCommand('Comfy.Graph.ConvertToSubgraph')
await comfyPage.appMode.enterBuilder()
await comfyPage.appMode.steps.goToInputs()
await expect(items).toHaveCount(0)
const prompts = comfyPage.vueNodes
.getNodeByTitle('New Subgraph')
.locator('.lg-node-widget')
const count = await prompts.count()
for (let i = 0; i < count; i++) {
await expect(prompts.nth(i)).toBeVisible()
await prompts.nth(i).click()
await expect(items).toHaveCount(i + 1)
}
})
test('Can select outputs', async ({ comfyPage }) => {
await comfyPage.appMode.enterBuilder()
await comfyPage.appMode.steps.goToOutputs()
await comfyPage.nodeOps
.getNodeRefById('9')
.then((ref) => ref.centerOnNode())
const saveImage = await comfyPage.vueNodes.getNodeLocator('9')
await saveImage.click()
const items = comfyPage.appMode.select.inputItems
await expect(items).toHaveCount(1)
})
test('Can not select nodes with errors or notes', async ({ comfyPage }) => {
//Manually set error state on checkpoint loader
//Shouldn't be needed on ci, but has spotty reliability
await comfyPage.page.evaluate(() => (graph!.nodes[6].has_errors = true))
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
const items = comfyPage.appMode.select.inputItems
await comfyPage.appMode.enterBuilder()
await comfyPage.appMode.steps.goToInputs()
await expect(items).toHaveCount(0)
await comfyPage.appMode.select.selectInputWidget(
'Load Checkpoint',
'ckpt_name'
)
await expect(items).toHaveCount(0)
await comfyPage.workflow.loadWorkflow('nodes/note_nodes')
await comfyPage.appMode.enterBuilder()
await comfyPage.appMode.steps.goToInputs()
await expect(items).toHaveCount(0)
await comfyPage.appMode.select.selectInputWidget('Note', 'text')
await comfyPage.appMode.select.selectInputWidget('Markdown Note', 'text')
await expect(items).toHaveCount(0)
})
test('Marks canvas readOnly', async ({ comfyPage }) => {
await comfyPage.settings.setSetting(
'Comfy.NodeSearchBoxImpl',
'v1 (legacy)'
)
await comfyPage.page.mouse.dblclick(100, 100, { delay: 5 })
await expect(
comfyPage.searchBox.input,
'Canvas is initially editable'
).toHaveCount(1)
await comfyPage.page.keyboard.press('Escape')
await comfyPage.appMode.enterBuilder()
await comfyPage.appMode.steps.goToInputs()
await comfyPage.page.mouse.dblclick(100, 100, { delay: 5 })
await expect(
comfyPage.searchBox.input,
'Entering builder makes the canvas readonly'
).toHaveCount(0)
await comfyPage.page.keyboard.press('Space')
await comfyPage.page.mouse.dblclick(100, 100, { delay: 5 })
await expect(
comfyPage.searchBox.input,
'Canvas remains readonly after pressing space'
).toHaveCount(0)
const ksampler = await comfyPage.vueNodes.getFixtureByTitle('KSampler')
// oxlint-disable-next-line playwright/no-force-option -- Node container has conditional pointer-events:none that blocks actionability
await ksampler.header.dblclick({ force: true })
await expect(
ksampler.titleEditor.input,
'Double clicking node titles will not initiate a rename'
).toBeHidden()
await comfyPage.page.keyboard.press('Escape')
await comfyPage.page.mouse.dblclick(100, 100, { delay: 5 })
await expect(
comfyPage.searchBox.input,
'Canvas is no longer readonly after exiting'
).toHaveCount(1)
})
})

View File

@@ -133,6 +133,29 @@ test.describe('AssetHelper', () => {
expect(data.assets[0].id).toBe(STABLE_CHECKPOINT.id)
})
test('GET /assets filters by exclude_tags', async ({
comfyPage,
assetApi
}) => {
assetApi.configure(
withAsset(STABLE_INPUT_IMAGE),
withAsset({
...STABLE_INPUT_IMAGE,
id: 'missing-input',
tags: ['input', 'missing']
})
)
await assetApi.mock()
const { body } = await assetApi.fetch(
`${comfyPage.url}/api/assets?include_tags=input,&exclude_tags= missing,`
)
const data = body as { assets: Array<{ id: string }> }
expect(data.assets.map((asset) => asset.id)).toEqual([
STABLE_INPUT_IMAGE.id
])
})
test('GET /assets/:id returns single asset or 404', async ({
comfyPage,
assetApi

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 14 KiB

View File

@@ -0,0 +1,548 @@
import type { Locator, Page } from '@playwright/test'
import { expect } from '@playwright/test'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
const MULTI_BINDING_COMMAND = 'Comfy.Canvas.DeleteSelectedItems'
const SINGLE_BINDING_COMMAND = 'Comfy.SaveWorkflow'
const NO_BINDING_COMMAND = 'TestCommand.KeybindingPanelE2E.NoBinding'
async function searchKeybindings(page: Page, query: string) {
await getKeybindingSearchInput(page).fill(query)
}
async function clearSearch(page: Page) {
await getKeybindingSearchInput(page).clear()
}
function getKeybindingSearchInput(page: Page): Locator {
return page.getByPlaceholder('Search Keybindings...')
}
function getCommandRow(page: Page, commandId: string): Locator {
return page
.locator('.keybinding-panel tr')
.filter({ has: page.locator(`[title="${commandId}"]`) })
}
function getExpansionContent(page: Page, commandId: string): Locator {
// PrimeVue renders the expansion row as the next sibling <tr> of the
// expanded row. Scoping by sibling avoids matching unrelated expanded rows.
return getCommandRow(page, commandId)
.locator('xpath=following-sibling::tr[1]')
.getByTestId('keybinding-expansion-content')
}
async function openContextMenu(page: Page, commandId: string) {
const row = getCommandRow(page, commandId)
await row.locator(`[title="${commandId}"]`).click({ button: 'right' })
await expect(
page.getByRole('menuitem', { name: /Change keybinding/i })
).toBeVisible()
}
function getKeybindingInput(page: Page): Locator {
return getEditKeybindingDialog(page).locator('input[autofocus]')
}
function getEditKeybindingDialog(page: Page): Locator {
return page.getByRole('dialog', { name: /Modify keybinding/i })
}
function getRemoveAllKeybindingsDialog(page: Page): Locator {
return page.getByRole('dialog', { name: /Remove all keybindings/i })
}
function getResetAllKeybindingsDialog(page: Page): Locator {
return page.getByRole('dialog', { name: /Reset all keybindings/i })
}
async function pressComboOnInput(page: Page, combo: string) {
const input = getKeybindingInput(page)
await expect(input).toBeFocused()
await input.press(combo)
}
async function saveAndCloseKeybindingDialog(page: Page) {
const dialog = getEditKeybindingDialog(page)
await dialog.getByRole('button', { name: /Save/i }).click()
await expect(dialog).toBeHidden()
}
async function cancelAndCloseDialog(page: Page) {
const dialog = getEditKeybindingDialog(page)
await dialog.getByRole('button', { name: /Cancel/i }).click()
await expect(dialog).toBeHidden()
}
async function addKeybindingToRow(page: Page, row: Locator, combo: string) {
await row.getByRole('button', { name: /Add new keybinding/i }).click()
await pressComboOnInput(page, combo)
await saveAndCloseKeybindingDialog(page)
}
test.beforeEach(async ({ comfyPage }) => {
await registerNoBindingCommand(comfyPage)
await comfyPage.settingDialog.open()
await comfyPage.settingDialog.category('Keybinding').click()
})
test.afterEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.Keybinding.NewBindings', [])
await comfyPage.settings.setSetting('Comfy.Keybinding.UnsetBindings', [])
})
async function registerNoBindingCommand(comfyPage: ComfyPage) {
await comfyPage.page.evaluate((commandId) => {
const app = window.app!
app.registerExtension({
name: 'TestExtension.KeybindingPanelE2E',
commands: [{ id: commandId, function: () => {} }]
})
}, NO_BINDING_COMMAND)
}
test.describe('Keybinding Panel', { tag: '@keyboard' }, () => {
test.describe('Row Expansion', () => {
test('Click on row with 2+ keybindings toggles expansion', async ({
comfyPage
}) => {
const { page } = comfyPage
await searchKeybindings(page, MULTI_BINDING_COMMAND)
const row = getCommandRow(page, MULTI_BINDING_COMMAND)
await expect(row).toBeVisible()
await row.locator(`[title="${MULTI_BINDING_COMMAND}"]`).click()
const expansionContent = getExpansionContent(page, MULTI_BINDING_COMMAND)
await expect(expansionContent).toBeVisible()
await row.locator(`[title="${MULTI_BINDING_COMMAND}"]`).click()
await expect(expansionContent).toBeHidden()
})
test('Click on row with 1 keybinding does not expand', async ({
comfyPage
}) => {
const { page } = comfyPage
await searchKeybindings(page, SINGLE_BINDING_COMMAND)
const row = getCommandRow(page, SINGLE_BINDING_COMMAND)
await expect(row).toBeVisible()
await row.locator(`[title="${SINGLE_BINDING_COMMAND}"]`).click()
const expansionContent = getExpansionContent(page, SINGLE_BINDING_COMMAND)
await expect(expansionContent).toBeHidden()
})
})
test.describe('Double-Click', () => {
test('Double-click row with 0 keybindings opens Add dialog', async ({
comfyPage
}) => {
const { page } = comfyPage
await searchKeybindings(page, NO_BINDING_COMMAND)
const row = getCommandRow(page, NO_BINDING_COMMAND)
await expect(row).toBeVisible()
await row.locator(`[title="${NO_BINDING_COMMAND}"]`).dblclick()
const input = getKeybindingInput(page)
await expect(input).toBeVisible()
await cancelAndCloseDialog(page)
})
test('Double-click row with 1 keybinding opens Edit dialog', async ({
comfyPage
}) => {
const { page } = comfyPage
await searchKeybindings(page, SINGLE_BINDING_COMMAND)
const row = getCommandRow(page, SINGLE_BINDING_COMMAND)
await expect(row).toBeVisible()
await row.locator(`[title="${SINGLE_BINDING_COMMAND}"]`).dblclick()
const input = getKeybindingInput(page)
await expect(input).toBeVisible()
await cancelAndCloseDialog(page)
})
})
test.describe('Context Menu', () => {
test('Right-click row shows context menu with correct items', async ({
comfyPage
}) => {
const { page } = comfyPage
await searchKeybindings(page, SINGLE_BINDING_COMMAND)
await openContextMenu(page, SINGLE_BINDING_COMMAND)
const changeItem = page.getByRole('menuitem', {
name: /Change keybinding/i
})
const addItem = page.getByRole('menuitem', {
name: /Add new keybinding/i
})
const resetItem = page.getByRole('menuitem', {
name: /Reset to default/i
})
const removeItem = page.getByRole('menuitem', {
name: /Remove keybinding/i
})
await expect(changeItem).toBeVisible()
await expect(addItem).toBeVisible()
await expect(resetItem).toBeVisible()
await expect(removeItem).toBeVisible()
await page.keyboard.press('Escape')
})
test("Context menu 'Add new keybinding' opens add dialog", async ({
comfyPage
}) => {
const { page } = comfyPage
await searchKeybindings(page, SINGLE_BINDING_COMMAND)
await openContextMenu(page, SINGLE_BINDING_COMMAND)
await page.getByRole('menuitem', { name: /Add new keybinding/i }).click()
const input = getKeybindingInput(page)
await expect(input).toBeVisible()
await cancelAndCloseDialog(page)
})
test("Context menu 'Change keybinding' on single-binding command opens edit dialog", async ({
comfyPage
}) => {
const { page } = comfyPage
await searchKeybindings(page, SINGLE_BINDING_COMMAND)
await openContextMenu(page, SINGLE_BINDING_COMMAND)
await page.getByRole('menuitem', { name: /Change keybinding/i }).click()
const input = getKeybindingInput(page)
await expect(input).toBeVisible()
await cancelAndCloseDialog(page)
})
test("Context menu 'Change keybinding' on multi-binding command expands row", async ({
comfyPage
}) => {
const { page } = comfyPage
await searchKeybindings(page, MULTI_BINDING_COMMAND)
const expansionContent = getExpansionContent(page, MULTI_BINDING_COMMAND)
await expect(expansionContent).toBeHidden()
await openContextMenu(page, MULTI_BINDING_COMMAND)
await page.getByRole('menuitem', { name: /Change keybinding/i }).click()
await expect(expansionContent).toBeVisible()
})
test("Context menu 'Remove keybinding' after adding second binding shows confirm dialog", async ({
comfyPage
}) => {
const { page } = comfyPage
await searchKeybindings(page, SINGLE_BINDING_COMMAND)
const row = getCommandRow(page, SINGLE_BINDING_COMMAND)
await addKeybindingToRow(page, row, 'Control+Shift+F9')
await openContextMenu(page, SINGLE_BINDING_COMMAND)
await page.getByRole('menuitem', { name: /Remove keybinding/i }).click()
const confirmDialog = getRemoveAllKeybindingsDialog(page)
await expect(confirmDialog).toBeVisible()
await confirmDialog.getByRole('button', { name: /Remove all/i }).click()
await expect(row.locator('td').nth(1)).toContainText('-')
})
test("Context menu 'Reset to default' resets modified command", async ({
comfyPage
}) => {
const { page } = comfyPage
await searchKeybindings(page, SINGLE_BINDING_COMMAND)
const row = getCommandRow(page, SINGLE_BINDING_COMMAND)
await addKeybindingToRow(page, row, 'Control+Shift+F10')
await openContextMenu(page, SINGLE_BINDING_COMMAND)
await page.getByRole('menuitem', { name: /Reset to default/i }).click()
await expect(row.getByRole('button', { name: /Reset/i })).toBeDisabled()
})
test('Context menu items disabled when no keybindings', async ({
comfyPage
}) => {
const { page } = comfyPage
await searchKeybindings(page, NO_BINDING_COMMAND)
await openContextMenu(page, NO_BINDING_COMMAND)
const changeItem = page.getByRole('menuitem', {
name: /Change keybinding/i
})
const removeItem = page.getByRole('menuitem', {
name: /Remove keybinding/i
})
await expect(changeItem).toHaveAttribute('data-disabled', '')
await expect(removeItem).toHaveAttribute('data-disabled', '')
await page.keyboard.press('Escape')
})
})
test.describe('Action Buttons', () => {
test('Edit button opens edit dialog for single-binding command', async ({
comfyPage
}) => {
const { page } = comfyPage
await searchKeybindings(page, SINGLE_BINDING_COMMAND)
const row = getCommandRow(page, SINGLE_BINDING_COMMAND)
const editButton = row.getByRole('button', { name: /^Edit$/i })
await expect(editButton).toBeVisible()
await editButton.click()
const input = getKeybindingInput(page)
await expect(input).toBeVisible()
await cancelAndCloseDialog(page)
})
test('Add button opens add dialog', async ({ comfyPage }) => {
const { page } = comfyPage
await searchKeybindings(page, SINGLE_BINDING_COMMAND)
const row = getCommandRow(page, SINGLE_BINDING_COMMAND)
await row.getByRole('button', { name: /Add new keybinding/i }).click()
const input = getKeybindingInput(page)
await expect(input).toBeVisible()
await cancelAndCloseDialog(page)
})
test('Reset button is disabled for unmodified commands', async ({
comfyPage
}) => {
const { page } = comfyPage
await searchKeybindings(page, SINGLE_BINDING_COMMAND)
const row = getCommandRow(page, SINGLE_BINDING_COMMAND)
const resetButton = row.getByRole('button', { name: /Reset/i })
await expect(resetButton).toBeDisabled()
})
test('Reset button resets modified keybinding', async ({ comfyPage }) => {
const { page } = comfyPage
await searchKeybindings(page, SINGLE_BINDING_COMMAND)
const row = getCommandRow(page, SINGLE_BINDING_COMMAND)
await addKeybindingToRow(page, row, 'Control+Shift+F11')
const resetButton = row.getByRole('button', { name: /Reset/i })
await expect(resetButton).toBeEnabled()
await resetButton.click()
await expect(resetButton).toBeDisabled()
})
test('Delete button is disabled for commands with 0 keybindings', async ({
comfyPage
}) => {
const { page } = comfyPage
await searchKeybindings(page, NO_BINDING_COMMAND)
const row = getCommandRow(page, NO_BINDING_COMMAND)
const deleteButton = row.getByRole('button', { name: /Delete/i })
await expect(deleteButton).toBeDisabled()
})
test('Delete button removes single keybinding directly', async ({
comfyPage
}) => {
const { page } = comfyPage
await searchKeybindings(page, NO_BINDING_COMMAND)
const row = getCommandRow(page, NO_BINDING_COMMAND)
await addKeybindingToRow(page, row, 'Control+Shift+F12')
const deleteButton = row.getByRole('button', { name: /Delete/i })
await expect(deleteButton).toBeEnabled()
await deleteButton.click()
await expect(row.locator('td').nth(1)).toContainText('-')
})
test('Delete button on command with 2+ keybindings shows confirm dialog', async ({
comfyPage
}) => {
const { page } = comfyPage
await searchKeybindings(page, MULTI_BINDING_COMMAND)
const row = getCommandRow(page, MULTI_BINDING_COMMAND)
const deleteButton = row.getByRole('button', { name: /Delete/i })
await deleteButton.click()
const confirmDialog = getRemoveAllKeybindingsDialog(page)
await expect(confirmDialog).toBeVisible()
await confirmDialog.getByRole('button', { name: /Cancel/i }).click()
await expect(confirmDialog).toBeHidden()
await expect(row.locator('td').nth(1)).not.toContainText('-')
})
})
test.describe('Expanded Row Actions', () => {
test('Edit button in expanded row opens edit dialog for that binding', async ({
comfyPage
}) => {
const { page } = comfyPage
await searchKeybindings(page, MULTI_BINDING_COMMAND)
await page.locator(`[title="${MULTI_BINDING_COMMAND}"]`).click()
const expansionContent = getExpansionContent(page, MULTI_BINDING_COMMAND)
await expect(expansionContent).toBeVisible()
const firstBindingRow = expansionContent
.getByTestId('keybinding-expansion-binding')
.first()
await firstBindingRow.getByRole('button', { name: /^Edit$/i }).click()
const input = getKeybindingInput(page)
await expect(input).toBeVisible()
await cancelAndCloseDialog(page)
})
test('Delete button in expanded row removes that binding and collapses', async ({
comfyPage
}) => {
const { page } = comfyPage
await searchKeybindings(page, MULTI_BINDING_COMMAND)
await page.locator(`[title="${MULTI_BINDING_COMMAND}"]`).click()
const expansionContent = getExpansionContent(page, MULTI_BINDING_COMMAND)
await expect(expansionContent).toBeVisible()
const bindingRows = expansionContent.getByTestId(
'keybinding-expansion-binding'
)
await expect
.poll(() => bindingRows.count(), {
message: 'Expected at least 2 bindings'
})
.toBeGreaterThanOrEqual(2)
const initialBindingCount = await bindingRows.count()
await bindingRows
.first()
.getByRole('button', { name: /Remove keybinding/i })
.click()
if (initialBindingCount === 2) {
// Expansion auto-collapses when bindings drop below 2
await expect(expansionContent).toBeHidden()
} else {
await expect(bindingRows).toHaveCount(initialBindingCount - 1)
}
})
})
test.describe('Reset All', () => {
test('Reset All button shows confirmation and resets on confirm', async ({
comfyPage
}) => {
const { page } = comfyPage
await searchKeybindings(page, SINGLE_BINDING_COMMAND)
const row = getCommandRow(page, SINGLE_BINDING_COMMAND)
await addKeybindingToRow(page, row, 'Control+Shift+F8')
await expect(row.getByRole('button', { name: /Reset/i })).toBeEnabled()
await clearSearch(page)
const resetAllButton = page
.locator('.keybinding-panel')
.getByRole('button', { name: /Reset All/i })
await resetAllButton.click()
const confirmDialog = getResetAllKeybindingsDialog(page)
await expect(confirmDialog).toBeVisible()
await expect(confirmDialog).toContainText(/Reset all keybindings/i)
await confirmDialog.getByRole('button', { name: /Reset All/i }).click()
await expect(comfyPage.toast.visibleToasts).toHaveCount(1)
await searchKeybindings(page, SINGLE_BINDING_COMMAND)
const rowAfterReset = getCommandRow(page, SINGLE_BINDING_COMMAND)
await expect(
rowAfterReset.getByRole('button', { name: /Reset/i })
).toBeDisabled()
})
test('Reset All confirmation can be cancelled', async ({ comfyPage }) => {
const { page } = comfyPage
const resetAllButton = page
.locator('.keybinding-panel')
.getByRole('button', { name: /Reset All/i })
await resetAllButton.click()
const confirmDialog = getResetAllKeybindingsDialog(page)
await expect(confirmDialog).toBeVisible()
await confirmDialog.getByRole('button', { name: /Cancel/i }).click()
await expect(confirmDialog).toBeHidden()
})
})
test.describe('Search Filter', () => {
test('Typing in search clears expanded rows', async ({ comfyPage }) => {
const { page } = comfyPage
await searchKeybindings(page, MULTI_BINDING_COMMAND)
await page.locator(`[title="${MULTI_BINDING_COMMAND}"]`).click()
const expansionContent = getExpansionContent(page, MULTI_BINDING_COMMAND)
await expect(expansionContent).toBeVisible()
// Changing the filter triggers watch(filters, ...) which clears expansion
await searchKeybindings(page, MULTI_BINDING_COMMAND + ' ')
await expect(expansionContent).toBeHidden()
})
})
})

Binary file not shown.

Before

Width:  |  Height:  |  Size: 57 KiB

After

Width:  |  Height:  |  Size: 53 KiB

View File

@@ -0,0 +1,62 @@
import {
comfyPageFixture as test,
comfyExpect as expect
} from '@e2e/fixtures/ComfyPage'
import { metadataFixturePath } from '@e2e/fixtures/utils/paths'
type MetadataFixture = {
fileName: string
parser: string
}
// Each fixture embeds the same single-KSampler workflow (see
// scripts/generate-embedded-metadata-test-files.py), exercising a different
// parser in src/scripts/metadata/. Dropping the file should import that
// workflow.
const FIXTURES: readonly MetadataFixture[] = [
{ fileName: 'with_metadata.png', parser: 'png' },
{ fileName: 'with_metadata.avif', parser: 'avif' },
{ fileName: 'with_metadata.webp', parser: 'webp' },
{ fileName: 'with_metadata_exif_prefix.webp', parser: 'webp (exif prefix)' },
{ fileName: 'with_metadata.flac', parser: 'flac' },
{ fileName: 'with_metadata.mp3', parser: 'mp3' },
{ fileName: 'with_metadata.opus', parser: 'ogg' },
{ fileName: 'with_metadata.mp4', parser: 'isobmff' },
{ fileName: 'with_metadata.webm', parser: 'ebml (webm)' }
] as const
test.describe(
'Metadata drop-to-load workflow import',
{ tag: ['@workflow'] },
() => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.nodeOps.clearGraph()
await expect.poll(() => comfyPage.nodeOps.getGraphNodesCount()).toBe(0)
})
for (const { fileName, parser } of FIXTURES) {
test(`loads embedded workflow from ${fileName} (${parser})`, async ({
comfyPage
}) => {
await test.step(`drop ${fileName} on canvas`, async () => {
await comfyPage.dragDrop.dragAndDropFilePath(
metadataFixturePath(fileName)
)
})
await test.step('graph contains only the embedded KSampler', async () => {
await expect
.poll(() => comfyPage.nodeOps.getGraphNodesCount())
.toBe(1)
const ksamplers =
await comfyPage.nodeOps.getNodeRefsByType('KSampler')
expect(
ksamplers,
'exactly one KSampler should have been loaded from the fixture'
).toHaveLength(1)
})
})
}
}
)

View File

@@ -692,19 +692,27 @@ test.describe('Painter', { tag: ['@widget', '@vue-nodes'] }, () => {
})
})
test('Controls collapse to single column in compact mode', async ({
test('Controls stack label above widget in compact mode', async ({
comfyPage
}) => {
const painterWidget = comfyPage.vueNodes
.getNodeLocator('1')
.locator('.widget-expands')
const toolLabel = painterWidget.getByText('Tool', { exact: true })
const brushButton = painterWidget.getByText('Brush', { exact: true })
await expect(
toolLabel,
'tool label should be visible in two-column layout'
'tool label should be visible in wide layout'
).toBeVisible()
const wideLabelBox = await toolLabel.boundingBox()
const wideBrushBox = await brushButton.boundingBox()
expect(
wideLabelBox && wideBrushBox && wideLabelBox.x < wideBrushBox.x,
'label should sit to the left of the brush button in wide layout'
).toBe(true)
await comfyPage.page.evaluate(() => {
const graph = window.graph as TestGraphAccess | undefined
const node = graph?._nodes_by_id?.['1']
@@ -716,8 +724,22 @@ test.describe('Painter', { tag: ['@widget', '@vue-nodes'] }, () => {
await expect(
toolLabel,
'tool label should hide in compact single-column layout'
).toBeHidden()
'tool label should remain visible in compact layout'
).toBeVisible()
await expect
.poll(
async () => {
const labelBox = await toolLabel.boundingBox()
const brushBox = await brushButton.boundingBox()
if (!labelBox || !brushBox) return false
return labelBox.y + labelBox.height <= brushBox.y
},
{
message: 'label should stack above the brush button in compact layout'
}
)
.toBe(true)
})
test('Multiple sequential strokes at different positions all accumulate', async ({

View File

@@ -351,6 +351,45 @@ test.describe('Performance', { tag: ['@perf'] }, () => {
})
})
test(
'subgraph transition (enter and exit)',
{ tag: ['@vue-nodes'] },
async ({ comfyPage }, testInfo) => {
// Heaviest perf test: loads an 80-node subgraph and pays ~30s/repeat.
// The signal is dominated by N=80 mount cost, so a single sample per
// CI invocation is sufficient — early-return on subsequent repeats.
if (testInfo.repeatEachIndex > 0) return
// Load workflow with a subgraph containing 80 interior nodes.
// Entering the subgraph unmounts root nodes and mounts all 80 interior
// nodes synchronously — this is the bottleneck we're measuring.
await comfyPage.workflow.loadWorkflow('subgraphs/large-subgraph-80-nodes')
await comfyPage.idleFrames(30)
await comfyPage.vueNodes.enterSubgraph()
await comfyPage.vueNodes.waitForNodes(80)
await comfyPage.idleFrames(30)
// Exit back to root graph before measuring a fresh enter/exit cycle
await comfyPage.subgraph.exitViaBreadcrumb()
await comfyPage.idleFrames(10)
// Start measuring the enter transition
await comfyPage.perf.startMeasuring()
await comfyPage.vueNodes.enterSubgraph()
await comfyPage.vueNodes.waitForNodes(80)
await comfyPage.idleFrames(30)
const m = await comfyPage.perf.stopMeasuring('subgraph-transition-enter')
recordMeasurement(m)
console.log(
`Subgraph enter (80 nodes): ${m.taskDurationMs.toFixed(0)}ms task, ${m.layouts} layouts, TBT=${m.totalBlockingTimeMs.toFixed(0)}ms`
)
}
)
test('workflow execution', async ({ comfyPage }) => {
// Uses lightweight PrimitiveString → PreviewAny workflow (no GPU needed)
await comfyPage.workflow.loadWorkflow('execution/partial_execution')

View File

@@ -0,0 +1,42 @@
import {
comfyPageFixture as test,
comfyExpect as expect
} from '../fixtures/ComfyPage'
test.describe('Preview as Text node', () => {
test('does not include preview widget values in the API prompt', async ({
comfyPage
}) => {
await comfyPage.page.evaluate(() => {
const node = window.LiteGraph!.createNode('PreviewAny')!
node.pos = [500, 200]
window.app!.graph.add(node)
})
// Simulate a previous execution: backend returned text and the frontend
// populated the preview widget values. The next prompt submission must
// NOT echo those values back as inputs (which would change the cache
// signature and trigger a redundant re-execution).
await comfyPage.page.evaluate(() => {
const node = window.app!.graph.nodes.find((n) => n.type === 'PreviewAny')!
for (const widget of node.widgets ?? []) {
if (widget.name?.startsWith('preview_')) {
widget.value = 'rendered preview content from previous execution'
}
}
})
const apiWorkflow = await comfyPage.workflow.getExportedWorkflow({
api: true
})
const previewEntry = Object.values(apiWorkflow).find(
(n) => n.class_type === 'PreviewAny'
)
expect(previewEntry).toBeDefined()
expect(previewEntry!.inputs).not.toHaveProperty('preview_markdown')
expect(previewEntry!.inputs).not.toHaveProperty('preview_text')
expect(previewEntry!.inputs).not.toHaveProperty('previewMode')
})
})

View File

@@ -0,0 +1,63 @@
import { expect } from '@playwright/test'
import type { PromptResponse } from '@/schemas/apiSchema'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
const queueModeLabels = ['Run', 'Run (On Change)', 'Run (Instant)']
const runOnChangeLabel = queueModeLabels[1]
test.describe('Queue button modes', { tag: '@ui' }, () => {
test('Run button is visible in topbar', async ({ comfyPage }) => {
await expect(comfyPage.actionbar.queueButton.primaryButton).toBeVisible()
})
test('Queue mode trigger menu is visible', async ({ comfyPage }) => {
await expect(comfyPage.actionbar.queueButton.dropdownButton).toBeVisible()
})
test('Clicking queue mode trigger opens mode menu', async ({ comfyPage }) => {
const options = await comfyPage.actionbar.queueButton.openOptions()
await expect(options.menu).toBeVisible()
})
test('Queue mode menu shows available modes', async ({ comfyPage }) => {
const options = await comfyPage.actionbar.queueButton.openOptions()
await expect(options.menu).toBeVisible()
await expect(options.modeItems).toHaveText(queueModeLabels)
})
test('Selecting a non-default mode updates the Run button label', async ({
comfyPage
}) => {
const queueButton = comfyPage.actionbar.queueButton
const options = await queueButton.openOptions()
await expect(options.menu).toBeVisible()
await options.selectMode(runOnChangeLabel)
await expect(queueButton.primaryButton).toContainText(runOnChangeLabel)
})
test('Run button sends prompt when clicked', async ({ comfyPage }) => {
let promptQueued = false
const mockResponse: PromptResponse = {
prompt_id: 'test-id',
node_errors: {},
error: ''
}
await comfyPage.page.route('**/api/prompt', async (route) => {
promptQueued = true
await route.fulfill({
status: 200,
body: JSON.stringify(mockResponse)
})
})
await comfyPage.actionbar.queueButton.primaryButton.click()
await expect.poll(() => promptQueued).toBe(true)
})
})

View File

@@ -16,7 +16,7 @@ test.describe(
await comfyPage.runButton.click()
const saveImageNode = comfyPage.vueNodes.getNodeByTitle('Save Image')
const saveWebmNode = comfyPage.vueNodes.getNodeByTitle('SaveWEBM')
const saveWebmNode = comfyPage.vueNodes.getNodeByTitle('Save WEBM')
// Wait for SaveImage to render an img inside .image-preview
await expect(saveImageNode.locator('.image-preview img')).toBeVisible({

Binary file not shown.

Before

Width:  |  Height:  |  Size: 90 KiB

After

Width:  |  Height:  |  Size: 90 KiB

View File

@@ -1,40 +0,0 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
const MULTI_INSTANCE_WORKFLOW =
'subgraphs/subgraph-multi-instance-promoted-text-values'
test.describe(
'Multi-instance subgraph promoted widget rendering in Vue mode',
{ tag: ['@subgraph', '@vue-nodes', '@widget'] },
() => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
})
test('Each subgraph instance renders its own promoted widget value, not the interior default', async ({
comfyPage
}) => {
const expectedByNodeId: Record<string, string> = {
'11': 'Alpha\n',
'12': 'Beta\n',
'13': 'Gamma\n'
}
await comfyPage.workflow.loadWorkflow(MULTI_INSTANCE_WORKFLOW)
await comfyPage.vueNodes.waitForNodes(3)
for (const [nodeId, expectedValue] of Object.entries(expectedByNodeId)) {
const subgraphNode = comfyPage.vueNodes.getNodeLocator(nodeId)
await expect(subgraphNode).toBeVisible()
const textarea = subgraphNode.getByRole('textbox', {
name: 'text',
exact: true
})
await expect(textarea).toHaveValue(expectedValue)
}
})
}
)

View File

@@ -535,6 +535,75 @@ test.describe(
.poll(() => getPromotedWidgetCount(comfyPage, '11'))
.toBeLessThan(initialWidgetCount)
})
test('Does not cleanup unconfigured Primitive', async ({ comfyPage }) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-link-and-proxied-primitive'
)
await expect
.poll(
() => getPromotedWidgetCount(comfyPage, '2'),
'Primitive widget is restored on load'
)
.toBe(2)
await comfyPage.page.evaluate(() => app!.canvas.setDirty(true))
const subgraphNode = await comfyPage.nodeOps.getFirstNodeRef()
const promotedPrimitive = await subgraphNode!.getWidget(1)
await expect
.poll(
() => promotedPrimitive.getValue(),
'Primitive widget is not in a disconnected state'
)
.toBe(0)
})
})
test.fail(
'Promoted text widget is removed when source node is deleted inside the subgraph',
{ tag: '@vue-nodes' },
async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
const clipFixture = await comfyPage.vueNodes.getFixtureByTitle(
'CLIP Text Encode (Prompt)'
)
await comfyPage.contextMenu.openForVueNode(clipFixture.header)
await comfyPage.contextMenu.clickMenuItemExact('Convert to Subgraph')
const subgraphNode = comfyPage.vueNodes
.getNodeByTitle('New Subgraph')
.first()
await expect(subgraphNode).toBeVisible()
const subgraphNodeId =
await comfyPage.vueNodes.getNodeIdByTitle('New Subgraph')
await expect
.poll(() => getPromotedWidgetNames(comfyPage, subgraphNodeId))
.toContain('text')
await expect(
subgraphNode.getByTestId(TestIds.widgets.domWidgetTextarea)
).toBeVisible()
await comfyPage.vueNodes.enterSubgraph(subgraphNodeId)
await expect.poll(() => comfyPage.subgraph.isInSubgraph()).toBe(true)
await comfyPage.vueNodes.waitForNodes()
const interiorClip = await comfyPage.vueNodes.getFixtureByTitle(
'CLIP Text Encode (Prompt)'
)
await interiorClip.delete()
await comfyPage.subgraph.exitViaBreadcrumb()
const subgraphNodeAfter =
comfyPage.vueNodes.getNodeLocator(subgraphNodeId)
await expect(subgraphNodeAfter).toBeVisible()
await expect(
subgraphNodeAfter.getByTestId(TestIds.widgets.domWidgetTextarea)
).toBeHidden()
}
)
}
)

View File

@@ -14,34 +14,6 @@ import {
const DUPLICATE_IDS_WORKFLOW = 'subgraphs/subgraph-nested-duplicate-ids'
const LEGACY_PREFIXED_WORKFLOW =
'subgraphs/nested-subgraph-legacy-prefixed-proxy-widgets'
const LEGACY_THREE_TUPLE_WORKFLOW = 'subgraphs/nested-duplicate-widget-names'
const MULTI_INSTANCE_WORKFLOW =
'subgraphs/subgraph-multi-instance-promoted-text-values'
async function getPromotedHostWidgetValues(
comfyPage: ComfyPage,
nodeIds: string[]
) {
return comfyPage.page.evaluate((ids) => {
const graph = window.app!.canvas.graph!
return ids.map((id) => {
const node = graph.getNodeById(id)
if (
!node ||
typeof node.isSubgraphNode !== 'function' ||
!node.isSubgraphNode()
) {
return { id, values: [] as unknown[] }
}
return {
id,
values: (node.widgets ?? []).map((widget) => widget.value)
}
})
}, nodeIds)
}
async function expectPromotedWidgetsToResolveToInteriorNodes(
comfyPage: ComfyPage,
@@ -526,63 +498,4 @@ test.describe('Subgraph Serialization', { tag: ['@subgraph'] }, () => {
})
}
)
test(
'Legacy 3-tuple proxyWidgets entries serialize back to 2-tuples after load',
{ tag: '@vue-nodes' },
async ({ comfyPage }) => {
await comfyPage.workflow.loadWorkflow(LEGACY_THREE_TUPLE_WORKFLOW)
const hostNode = comfyPage.vueNodes.getNodeLocator('4')
await expect(hostNode).toBeVisible()
const promotedTextbox = hostNode.getByRole('textbox', {
name: 'text',
exact: true
})
await expect(promotedTextbox).toHaveCount(1)
await expect(promotedTextbox).toHaveValue('22222222222')
await expect(hostNode.getByText('text', { exact: true })).toBeVisible()
const serializedProxyWidgets = await comfyPage.page.evaluate(() => {
const serialized = window.app!.graph!.serialize()
const hostNode = serialized.nodes.find((node) => node.id === 4)
const proxyWidgets = hostNode?.properties?.proxyWidgets
return Array.isArray(proxyWidgets) ? proxyWidgets : []
})
expect(serializedProxyWidgets).toEqual([['3', '3: 2: text']])
expect(
serializedProxyWidgets.every(
(entry) => Array.isArray(entry) && entry.length === 2
)
).toBe(true)
}
)
test('Multiple instances of the same subgraph keep distinct promoted widget values after load and reload', async ({
comfyPage
}) => {
const hostNodeIds = ['11', '12', '13']
const expectedValues = ['Alpha\n', 'Beta\n', 'Gamma\n']
await comfyPage.workflow.loadWorkflow(MULTI_INSTANCE_WORKFLOW)
const initialValues = await getPromotedHostWidgetValues(
comfyPage,
hostNodeIds
)
expect(initialValues.map(({ values }) => values[0])).toEqual(expectedValues)
await comfyPage.subgraph.serializeAndReload()
const reloadedValues = await getPromotedHostWidgetValues(
comfyPage,
hostNodeIds
)
expect(reloadedValues.map(({ values }) => values[0])).toEqual(
expectedValues
)
})
})

View File

@@ -1,4 +1,5 @@
import { expect } from '@playwright/test'
import type { Locator, Page } from '@playwright/test'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
@@ -188,4 +189,79 @@ test.describe('Workflow tabs', () => {
await topbar.closeWorkflowTab('Unsaved Workflow (2)')
await expect.poll(() => topbar.getTabNames()).toHaveLength(2)
})
test.describe('Closing a modified workflow tab (FE-419)', () => {
async function modifyActiveWorkflow(page: Page, activeTab: Locator) {
await page.evaluate(() => {
const graph = window.app?.graph
const node = window.LiteGraph?.createNode('Note')
if (graph && node) graph.add(node)
})
await expect(
activeTab.getByTestId('workflow-dirty-indicator')
).toHaveCount(1)
}
test('shows "Close anyway" label and no Cancel button on dirtyClose dialog', async ({
comfyPage
}) => {
const topbar = comfyPage.menu.topbar
await topbar.newWorkflowButton.click()
await expect.poll(() => topbar.getTabNames()).toHaveLength(2)
await modifyActiveWorkflow(comfyPage.page, topbar.getActiveTab())
await topbar.closeWorkflowTab('Unsaved Workflow (2)')
const dialog = comfyPage.page.getByRole('dialog')
await expect(dialog).toBeVisible()
await expect(
dialog.getByRole('button', { name: 'Close anyway' })
).toBeVisible()
await expect(dialog.getByRole('button', { name: 'Save' })).toBeVisible()
await expect(dialog.getByRole('button', { name: 'Cancel' })).toHaveCount(
0
)
})
test('clicking "Close anyway" closes the tab without saving', async ({
comfyPage
}) => {
const topbar = comfyPage.menu.topbar
await topbar.newWorkflowButton.click()
await expect.poll(() => topbar.getTabNames()).toHaveLength(2)
await modifyActiveWorkflow(comfyPage.page, topbar.getActiveTab())
await topbar.closeWorkflowTab('Unsaved Workflow (2)')
await comfyPage.page
.getByRole('dialog')
.getByRole('button', { name: 'Close anyway' })
.click()
await expect.poll(() => topbar.getTabNames()).toHaveLength(1)
await expect
.poll(() => topbar.getActiveTabName())
.toContain('Unsaved Workflow')
})
test('dismissing the dialog keeps the modified tab open', async ({
comfyPage
}) => {
const topbar = comfyPage.menu.topbar
await topbar.newWorkflowButton.click()
await expect.poll(() => topbar.getTabNames()).toHaveLength(2)
await modifyActiveWorkflow(comfyPage.page, topbar.getActiveTab())
await topbar.closeWorkflowTab('Unsaved Workflow (2)')
await expect(comfyPage.page.getByRole('dialog')).toBeVisible()
await comfyPage.page.keyboard.press('Escape')
await expect(comfyPage.page.getByRole('dialog')).toBeHidden()
await expect.poll(() => topbar.getTabNames()).toHaveLength(2)
})
})
})

View File

@@ -1,3 +1,5 @@
import type { Locator } from '@playwright/test'
import {
comfyExpect as expect,
comfyPageFixture as test
@@ -39,6 +41,19 @@ test.describe('Vue Node Moving', { tag: '@vue-nodes' }, () => {
expect(Math.abs(a.y - b.y)).toBeLessThanOrEqual(tol)
}
const dragFromTabButton = async (comfyPage: ComfyPage, button: Locator) => {
const box = await button.boundingBox()
if (!box) throw new Error('Tab button has no bounding box')
const start = {
x: box.x + box.width / 2,
y: box.y + box.height * 0.75
}
await comfyPage.canvasOps.dragAndDrop(start, {
x: start.x + 120,
y: start.y + 80
})
}
test('should allow moving nodes by dragging', async ({ comfyPage }) => {
const loadCheckpointHeaderPos = await getLoadCheckpointHeaderPos(comfyPage)
await comfyPage.canvasOps.dragAndDrop(loadCheckpointHeaderPos, {
@@ -90,6 +105,63 @@ test.describe('Vue Node Moving', { tag: '@vue-nodes' }, () => {
await expectPosChanged(headerPos, afterPos)
})
test('should not toggle advanced inputs when dragging by the Advanced button', async ({
comfyPage
}) => {
await comfyPage.settings.setSetting(
'Comfy.Node.AlwaysShowAdvancedWidgets',
false
)
await comfyPage.nodeOps.addNode(
'ModelSamplingFlux',
{},
{
x: 500,
y: 200
}
)
await comfyPage.vueNodes.waitForNodes()
const node = comfyPage.vueNodes.getNodeByTitle('ModelSamplingFlux')
const showButton = node.getByText('Show advanced inputs')
const widgets = node.locator('.lg-node-widget')
await expect(showButton).toBeVisible()
await expect(widgets).toHaveCount(2)
const beforePos = await node.boundingBox()
if (!beforePos) throw new Error('Node has no bounding box')
await dragFromTabButton(comfyPage, showButton)
await expect(showButton).toBeVisible()
await expect(node.getByText('Hide advanced inputs')).toBeHidden()
await expect(widgets).toHaveCount(2)
const afterPos = await node.boundingBox()
if (!afterPos) throw new Error('Node missing after drag')
await expectPosChanged(beforePos, afterPos)
})
test('should not enter subgraph when dragging by the Enter Subgraph button', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('2')
const beforePos = await subgraphNode.getPosition()
await dragFromTabButton(
comfyPage,
comfyPage.vueNodes.getSubgraphEnterButton('2')
)
expect(await comfyPage.subgraph.isInSubgraph()).toBe(false)
const afterPos = await subgraphNode.getPosition()
await expectPosChanged(beforePos, afterPos)
})
test('should move all selected nodes together when dragging one with Meta held', async ({
comfyPage
}) => {

View File

@@ -239,19 +239,18 @@ The design goal is to preserve ECS modularity while keeping render throughput wi
Companion architecture documents that expand on the design in this ADR:
| Document | Description |
| ------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------- |
| [Entity Interactions](../architecture/entity-interactions.md) | Maps all current entity relationships and interaction patterns — the ECS migration baseline |
| [Entity System Structural Problems](../architecture/entity-problems.md) | Detailed problem catalog with line-level code references motivating the ECS migration |
| [Proto-ECS Stores](../architecture/proto-ecs-stores.md) | Inventory of existing Pinia stores that already partially implement ECS patterns |
| [ECS Target Architecture](../architecture/ecs-target-architecture.md) | Full target architecture showing how entities and interactions transform under ECS |
| [ECS Migration Plan](../architecture/ecs-migration-plan.md) | Phased migration roadmap with shipping milestones and go/no-go criteria |
| [ECS Lifecycle Scenarios](../architecture/ecs-lifecycle-scenarios.md) | Before/after walkthroughs of lifecycle operations (node removal, link creation, etc.) |
| [World API and Command Layer](../architecture/ecs-world-command-api.md) | How each lifecycle scenario maps to a command in the World API |
| [Subgraph Boundaries and Widget Promotion](../architecture/subgraph-boundaries-and-promotion.md) | Design rationale for modeling subgraphs as node components, not separate entities |
| [Appendix: Critical Analysis](../architecture/appendix-critical-analysis.md) | Independent verification of the accuracy of the architecture documents |
| [Appendix: ECS Pattern Survey](../architecture/appendix-ecs-pattern-survey.md) | Survey of bitECS, miniplex, koota, ECSY, and Bevy — patterns adopted, departed, when to revisit |
| [Change Tracker](../architecture/change-tracker.md) | Documents the current undo/redo system that ECS cross-cutting concerns will replace |
| Document | Description |
| ------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------- |
| [Entity Interactions](../architecture/entity-interactions.md) | Maps all current entity relationships and interaction patterns — the ECS migration baseline |
| [Entity System Structural Problems](../architecture/entity-problems.md) | Detailed problem catalog with line-level code references motivating the ECS migration |
| [Proto-ECS Stores](../architecture/proto-ecs-stores.md) | Inventory of existing Pinia stores that already partially implement ECS patterns |
| [ECS Target Architecture](../architecture/ecs-target-architecture.md) | Full target architecture showing how entities and interactions transform under ECS |
| [ECS Migration Plan](../architecture/ecs-migration-plan.md) | Phased migration roadmap with shipping milestones and go/no-go criteria |
| [ECS Lifecycle Scenarios](../architecture/ecs-lifecycle-scenarios.md) | Before/after walkthroughs of lifecycle operations (node removal, link creation, etc.) |
| [World API and Command Layer](../architecture/ecs-world-command-api.md) | How each lifecycle scenario maps to a command in the World API |
| [Subgraph Boundaries and Widget Promotion](../architecture/subgraph-boundaries-and-promotion.md) | Design rationale for modeling subgraphs as node components, not separate entities |
| [Appendix: Critical Analysis](../architecture/appendix-critical-analysis.md) | Independent verification of the accuracy of the architecture documents |
| [Change Tracker](../architecture/change-tracker.md) | Documents the current undo/redo system that ECS cross-cutting concerns will replace |
## Notes

View File

@@ -1,279 +0,0 @@
# Appendix: ECS Pattern Survey
_A survey of mainstream Entity Component System libraries — bitECS, miniplex,
koota, ECSY, and Bevy — captured during the world-consolidation analysis that
shipped slice 1 of [ADR 0008](../adr/0008-entity-component-system.md). This
appendix records which structural patterns our `src/world/` substrate adopts,
which it deliberately departs from, and where the trade-offs are load-bearing
rather than incidental._
The in-code anchors for the load-bearing constraints discussed below are the
doc-comments in [src/world/world.ts](../../src/world/world.ts) (storage
strategy) and [src/world/entityIds.ts](../../src/world/entityIds.ts) (identity
contract) — see §3 below.
---
## 1. Survey Comparison
Five libraries were sampled for structural patterns: where component
definitions live relative to the substrate, how components are declared,
how entities are identified, and roughly how large the substrate's public
surface is. Sources: the linked READMEs and docs.
| Library | Component placement | Component definition style | Entity ID type | Approx. # core exports |
| ------------------------------------------------- | ------------------------------------ | ----------------------------- | -------------------- | ----------------------: |
| [bitECS](https://github.com/NateTheGreatt/bitECS) | Outside the substrate; user's choice | plain arrays / objects | `number` (unbranded) | ~12 |
| [miniplex](https://github.com/hmans/miniplex) | Colocated with the `Entity` type | properties on a TS type | plain object ref | ~5 |
| [koota](https://github.com/pmndrs/koota) | Colocated with the consumer | `trait({...})` factory | numeric `.id()` | ~15 (core) + ~8 (react) |
| [ECSY](https://github.com/ecsyjs/ecsy) | User's choice | `class extends Component` | `Entity` object | ~10 |
| [Bevy](https://bevyengine.org/) (Rust, for shape) | Plugin-owned (industry std) | `#[derive(Component)] struct` | `Entity(u64)` | n/a |
Two structural patterns are unanimous across the surveyed libraries:
1. **Component definitions live with the code that owns the data**, not
inside the substrate package. Whether by explicit recommendation
(Bevy plugins, koota's colocation guidance) or by default (bitECS,
miniplex), no surveyed substrate ships pre-defined component types.
2. **Substrate surface area is small** — bitECS at ~12 exports, koota at
~15, miniplex at ~5. ECSY is the outlier with a wider class hierarchy.
Our slice-1 end state — five source files under
[src/world/](../../src/world/), ~14 exported names total — sits squarely in
this band.
---
## 2. Patterns We Adopt
### 2.1 Substrate is deep; components live in domain code
The mainstream convention is that the ECS substrate exposes only the
machinery — entities, component keys, a World — and component definitions
live next to the system, store, or feature module that owns the data.
This is the Bevy / miniplex / koota convention by design and the bitECS /
ECSY convention by default.
Our substrate follows the same shape: `src/world/` contains entity-ID
brands, the `ComponentKey` definition primitive, and the `World`
interface, but no domain-specific component types. Slice 1 places
`WidgetValueComponent` and `WidgetContainerComponent` in
[src/stores/widgetComponents.ts](../../src/stores/widgetComponents.ts),
next to [widgetValueStore.ts](../../src/stores/widgetValueStore.ts) — the
module that already owns widget value state.
This keeps the substrate / domain seam crisp: the World knows how to store
and look up arbitrary components keyed by entity ID; the domain layer
knows what a "widget value" is. It also aligns with the AGENTS.md DDD
guidance to group code by bounded context. Future components follow the
same rule — `PositionComponent`, when it lands, will live with the layout
domain rather than inside the substrate.
### 2.2 Small public API
The substrate exports ~14 names — comparable to bitECS (~12) and koota
(~15), much smaller than ECSY's class hierarchy. This is a deliberate
target: every exported name is a contract a contributor must understand
before extending the World, and every export is a potential migration
cost when the substrate evolves.
The `Brand` / `EntityId` / `ComponentKey` / `World` / `worldInstance`
split keeps each module single-purpose. `Brand<T,Tag>` is 5
LOC and shared across all branded ID kinds. `ComponentKey<TData,TEntity>`
carries a two-parameter phantom that enables cross-kind compile-time
checking. `asGraphId` is a single named boundary cast. The two explicit
factories `nodeEntityId` / `widgetEntityId` are kept rather than collapsed
into a parameterized helper because slice 2/3/4 will add factories with
different parameter tuples (`rerouteEntityId`, `linkEntityId`,
`slotEntityId`); the explicit-factory pattern scales linearly with new
entity kinds without growing the helper's signature.
### 2.3 Reactive bridging via existing storage proxy
bitECS, koota, and miniplex bolt on a separate `onChange` event bus when
a consumer wants reactive notifications. koota's React layer
(`useTrait(entity, ComponentKey)`) is the closest analog to what
`useUpstreamValue` and future composables want.
Because our World stores values inside Vue's `reactive(Map<EntityId, ...>)`,
a plain `computed(() => world.getComponent(id, key))` already provides
fine-grained per-`(entity, component)` tracking — no separate event bus
is needed. **This is a real Vue-specific advantage.** The Vue tracker and
the ECS storage are the same mechanism, so reactivity falls out of the
storage choice rather than being layered on top.
### 2.4 Brand-typed entity IDs
No surveyed TypeScript ECS uses branded IDs. bitECS uses unbranded
`number`, miniplex uses plain object references, koota uses a numeric
`.id()`. Our `Brand<T, Tag>` over each entity kind enables the
type-level cross-kind isolation assertion in
[world.test.ts](../../src/world/world.test.ts) and documents slice-2/3/4
entity kinds at compile time.
This is a deliberate departure rather than an accident. It earns its keep
once `Position` lands on `NodeEntityId | RerouteEntityId` (slice 2) and
`Connectivity` lands on `SlotEntityId` (slice 4); without brands, those
component-key declarations would accept any numeric ID and silently allow
cross-kind misuse.
---
## 3. Patterns We Explicitly Do NOT Adopt
Each of the following is a real industry idiom we considered and rejected
on load-bearing grounds. None of these are pure performance trade-offs.
### 3.1 Replace-on-write usage idioms
koota's `entity.set(Position, {...})` and miniplex's `world.add(entity)`
**replace** component values with new objects on each write. Adopting
either would break
[BaseWidget.\_state](../../src/lib/litegraph/src/widgets/BaseWidget.ts)
shared reactive identity — the contract that lets DOM widget overrides,
`useProcessedWidgets` memoization, and the 40+ extension ecosystem all
read the same proxy. Our `setComponent(id, key, ref)` stores by reference
and the inner `reactive(Map)` keeps a stable cached proxy per
entity-component pair: every `getComponent` returns the same proxy,
regardless of how many writes intervene. `widgetValueStore.registerWidget`
returns that proxy (not the caller's input ref), so `BaseWidget._state`
and every other reader observe the same object. Replace-on-write idioms
would swap the cached proxy on each write and break that stability —
the reactive-identity test in
[widgetValueStore.test.ts](../../src/stores/widgetValueStore.test.ts)
locks in the contract.
### 3.2 SoA / archetype storage
bitECS, koota, and miniplex use sparse-set / archetype storage internally
for cache locality. Our `reactive(Map<EntityId, unknown>)` is closer to
ECSY's AoS — slower iteration but **integrates natively with Vue's
tracking**.
The surface trade-off is performance; the deeper trade-off is identity.
SoA storage spreads each component's fields across parallel typed arrays,
so the per-entity "row object" is reconstructed on read. **A future
migration to SoA would lose the proxy on the row object** — and with it
the shared-reactive-identity contract that `BaseWidget._state` and the
`widgetValueStore` facade rely on. This is a load-bearing constraint, not
just a perf optimization decision.
The contract is pinned in the doc-comment at the top of
[src/world/world.ts](../../src/world/world.ts) — copied here for
proximity:
```ts
/**
* `setComponent` stores values by reference (no clone). The inner
* `reactive(Map)` produces a single cached Vue proxy per entity-component
* pair: every `getComponent` call returns the same proxy, and mutations
* through it propagate to all readers. Note that the proxy is NOT `===`
* to the raw object passed to `setComponent` — read through `getComponent`
* (or a `registerWidget`-style helper that does so internally) and treat
* that proxy as canonical.
*
* `BaseWidget._state` and `widgetValueStore` rely on this stable-proxy
* invariant. Replace-on-write idioms (koota's `entity.set(...)`,
* miniplex's `world.add(entity)`) would swap the cached proxy on each
* write and break the contract; revisiting either consumer is required
* before changing storage semantics.
*/
```
### 3.3 Auto-generated opaque entity IDs
bitECS and koota assume IDs are opaque numbers — `lastId++`, with no
external structure. miniplex uses plain object references with the same
property.
Our `widgetEntityId(rootGraphId, nodeId, name)` is **deterministic and
content-addressed**. Consumers consistently pass `rootGraph.id`, so a
widget viewed at different subgraph depths shares identity with itself.
Migrating to opaque numeric IDs would break cross-subgraph value sharing —
the same widget at depth 0 and depth 2 would receive different IDs and
diverge.
The contract is pinned in the doc-comment at the top of
[src/world/entityIds.ts](../../src/world/entityIds.ts):
```ts
/**
* Entity IDs are deterministic, content-addressed, and string-prefix
* encoded — NOT opaque numeric IDs (cf. bitECS, koota, miniplex).
*
* `widgetEntityId(rootGraphId, nodeId, name)` is load-bearing:
* consumers consistently pass `rootGraph.id` so widgets viewed at
* different subgraph depths share identity. Migrating to numeric IDs
* would break cross-subgraph value sharing. See ADR 0008 and
* widgetValueStore for the canonical keying contract.
*/
```
### 3.4 Substrate-side parent/child relations
Bevy ships `Parent` / `Children` components at the substrate layer; Flecs
ships first-class relations. These are useful when many subsystems need
hierarchical traversal at storage-near speeds.
We treat hierarchical traversal as a domain-layer concern instead. The
only structural relation slice 1 needs is `node → widgets` forward
lookup, expressed as a domain component (`WidgetContainer.widgetIds` in
[src/stores/widgetComponents.ts](../../src/stores/widgetComponents.ts))
and surfaced through `getNodeWidgets()` on the
[widget value store](../../src/stores/widgetValueStore.ts). Reverse
`widget → node` lookup is not modeled in the World at all today —
existing call sites already hold a widget object and read `widget.node`
directly via the `BaseWidget` back-reference, so no substrate-side
parent component earns its keep yet. We may revisit this if multiple
slices need a shared traversal API; until then, keeping hierarchy
domain-local preserves the substrate's "no domain knowledge" property.
---
## 4. When to Revisit
The choices in §3 are deliberate but not eternal. Each has a revisit
threshold.
**SoA / archetype storage.** The break-even point against `reactive(Map)`
iteration is roughly **>10k entities per component** in steady-state hot
paths. ComfyUI's projected widget count through slice 4 stays well under
that. The watch signal is whether a render-loop or solver-loop pass
demonstrably dominates frame time on `entitiesWith(WidgetValueComponent)`
or any successor query — not just micro-benchmarks of `Map.get`.
If we cross that threshold, the migration is non-trivial: SoA loses the
proxy on the row object (see §3.2), so a SoA World must either
reconstruct proxies on read (defeating the perf gain) or move
shared-identity reads back to a domain-side cache. ADR 0008's
"Render-Loop Performance Implications and Mitigations" section already
enumerates the planned mitigations (frame-stable query caches, archetype
buckets, profiling-gated storage upgrades behind the World API).
**Replace-on-write idioms.** Revisitable only if the 40+ extension
ecosystem moves off `BaseWidget._state` shared identity entirely — a
separate, larger slice with explicit cost analysis (re-entry, DOM widget
options.getValue overrides, `linkedWidgets` fan-out,
`useProcessedWidgets` memoization invalidation), out of scope for the
current ADR 0008 implementation.
**Opaque entity IDs.** Revisitable only if the cross-subgraph identity
contract is dropped. Today widget value sharing across subgraph depths
depends on it; slice 2 may extend the same contract to `nodeEntityId`
for spatial reads. Until the product requirement changes, opaque IDs
would be a regression.
**Substrate-side parent/child relations.** Revisitable when ≥2 subsystems
need parent traversal. At one consumer it stays domain-local.
---
## 5. Cross-References
- [ADR 0008 — Entity Component System](../adr/0008-entity-component-system.md)
for the full target taxonomy and migration strategy.
- [ECS Target Architecture](./ecs-target-architecture.md) for the full
end-state shape.
- [ECS Migration Plan](./ecs-migration-plan.md) for shipping milestones.
- [Appendix: Critical Analysis](./appendix-critical-analysis.md) for the
independent verification of the architecture documents.

View File

@@ -1,637 +0,0 @@
# Entity ID Strategy: Opaque IDs and Normalized Identity Components
_A design proposal for migrating all six entity kinds in the ECS taxonomy
of [ADR 0008](../adr/0008-entity-component-system.md) — Node, Link, Widget,
Slot, Reroute, Group — from primitive or content-addressed identifiers to
**opaque UUIDs**, with identity data normalized into per-entity components
and the necessary secondary indices owned by domain stores. This document
is a follow-on to ADR 0008 and the [ECS Pattern Survey](appendix-ecs-pattern-survey.md).
Status: proposed, pending sign-off before implementation._
The motivating defect is in
[src/world/entityIds.ts](../../src/world/entityIds.ts) (the slice-1
identity contract for widgets and node containers), but the design
question generalizes: **identity should be a component, not encoded into
the entity-ID string, and entity IDs should be opaque across all kinds.**
This document defines that strategy uniformly so subsequent ECS migration
slices (per the [migration plan](ecs-migration-plan.md)) extend the same
pattern instead of recreating the choice for each kind.
---
## 1. Context
### 1.1 What ADR 0008 specifies
ADR 0008 §"Branded ID Design" specifies branded **numeric** IDs for all
six entity kinds:
```ts
type NodeEntityId = number & { readonly __brand: 'NodeEntityId' }
type LinkEntityId = number & { readonly __brand: 'LinkEntityId' }
type WidgetEntityId = number & { readonly __brand: 'WidgetEntityId' }
type SlotEntityId = number & { readonly __brand: 'SlotEntityId' }
type RerouteEntityId = number & { readonly __brand: 'RerouteEntityId' }
type GroupEntityId = number & { readonly __brand: 'GroupEntityId' }
type GraphId = string & { readonly __brand: 'GraphId' }
```
The ADR notes that widgets and slots currently lack independent IDs and
proposes to assign synthetic IDs at entity creation time via an
auto-incrementing counter (matching the pattern used by `lastNodeId`,
`lastLinkId` in `LGraphState`).
### 1.2 What the slice-1 implementation actually did
The first ECS slice ([src/world/](../../src/world/)) covers **widgets**
and **node containers**, and it departed from ADR 0008's "numeric ID"
contract in two ways:
1. IDs are **strings**, not numbers (`Brand<string, ...>`).
2. IDs are **content-addressed**`widgetEntityId(g, n, w)` is computed
from `(graphId, nodeId, widgetName)`, not minted from a counter:
```
node:${graphId}:${nodeId}
widget:${graphId}:${nodeId}:${name}
```
The departure was deliberate — content-addressing gives "an entity viewed
at different subgraph depths shares state" for free — but the rationale
was never recorded in an architecture doc, and it leaves the wire format
unauthorized by ADR 0008.
### 1.3 Three problems with the slice-1 scheme
1. **Silent corruption when nodeId contains a colon.**
`parseWidgetEntityId`'s regex `/^widget:([^:]+):([^:]+):(.*)$/` captures
`nodeId` as `[^:]+`, so a string nodeId like `"sg:42"` produces
`widget:g:sg:42:name`, which the regex mis-parses as `(g, sg, 42:name)`.
`widgetValueStore.getNodeWidgetsByName` then keys the returned `Map` by
the wrong name. The defect is latent today — verified: every production
producer stringifies a bare local NodeId — but the type
`NodeId = number | string` makes no runtime guarantee, and any future
migration toward `NodeLocatorId` (e.g. `<subgraph-uuid>:<id>`) trips
the bug.
2. **Identity is the address.** Renaming a widget mints a new entity
(different name → different `widgetEntityId`); rewriting `nodeId`
mints a new entity. There is no concept of "the widget formerly known
as X" — per-aspect components attach to the address-derived ID and
are abandoned at rename. For features that need stable identity
across relabels (promoted widgets, subgraph reparenting, CRDT
replication of label changes), this is a structural mismatch.
3. **Identity is recovered by parsing.** The regex is the only path by
which `getNodeWidgetsByName` knows which widget has which name. The
data has no representation in the World — it lives only in the string.
Every consumer that needs the name must round-trip through the
parser.
### 1.4 Why this generalizes
The same three problems apply, in principle, to every other entity kind
that lacks an independent ID. **Slot** identity today is `(parent node,
direction, index)`; if we encoded that as a colon-joined string, the same
parser hazards reappear. **Link** identity is `LinkId = number` (already
opaque), but its endpoints `(origin_id, origin_slot, target_id,
target_slot)` are content-shaped — and any identity migration will face
the same "do we encode this into the ID, or normalize it into a
component?" choice. The design decision is the same across all six
kinds; this document makes it once.
---
## 2. Decision
Adopt the **opaque-entity-id + identity-as-component** pattern for all
six entity kinds. The pattern matches what mainstream ECS libraries
converge on (Flecs `(ChildOf, parent)` pairs, Bevy `Parent`+`Children`,
koota `relation()`, EnTT `relationship.parent`, miniplex
`entity.livesOn`); see §5 for the survey citations.
### 2.1 Entity IDs are opaque UUIDs across all kinds
```ts
// src/world/entityIds.ts (proposed)
export type NodeEntityId = Brand<string, 'NodeEntityId'>
export type LinkEntityId = Brand<string, 'LinkEntityId'>
export type WidgetEntityId = Brand<string, 'WidgetEntityId'>
export type SlotEntityId = Brand<string, 'SlotEntityId'>
export type RerouteEntityId = Brand<string, 'RerouteEntityId'>
export type GroupEntityId = Brand<string, 'GroupEntityId'>
export type EntityId =
| NodeEntityId
| LinkEntityId
| WidgetEntityId
| SlotEntityId
| RerouteEntityId
| GroupEntityId
export const mintNodeId = (): NodeEntityId =>
crypto.randomUUID() as NodeEntityId
export const mintLinkId = (): LinkEntityId =>
crypto.randomUUID() as LinkEntityId
export const mintWidgetId = (): WidgetEntityId =>
crypto.randomUUID() as WidgetEntityId
export const mintSlotId = (): SlotEntityId =>
crypto.randomUUID() as SlotEntityId
export const mintRerouteId = (): RerouteEntityId =>
crypto.randomUUID() as RerouteEntityId
export const mintGroupId = (): GroupEntityId =>
crypto.randomUUID() as GroupEntityId
```
UUIDs (rather than numeric counters) for three reasons. First, no
coordination required — `crypto.randomUUID` is universally available and
collision-resistant without `LGraphState.lastWidgetId++` bookkeeping.
Second, they're stable across CRDT replication (per ADR 0003) without an
ID-mapping layer. Third, the `Brand<string, ...>` type machinery in
[src/world/brand.ts](../../src/world/brand.ts) already phantom-types over
`string`, so retaining string concrete types keeps zero friction with
existing component declarations.
This **amends ADR 0008 §"Branded ID Design"**: the `& { readonly __brand: ... }`
discipline is preserved, but the underlying type is `string` (UUID) for
all kinds, not `number`.
**Deletions from current code:** `nodeEntityId`, `widgetEntityId`,
`parseWidgetEntityId`, `graphNodePrefix`, `graphWidgetPrefix`,
`isNodeIdForGraph`, `isWidgetIdForGraph`, the regex. The wire-format
concept goes away.
**Litegraph-numeric IDs are preserved as data** — `LGraphState.lastNodeId`
and friends continue to assign legacy numeric IDs that workflow JSON
serialization depends on (per ADR 0008 §"The internal ECS model and the
serialization format are deliberately separate concerns"). The legacy
numeric ID becomes a field on the entity's identity component (§2.2),
not the entity's identity itself.
### 2.2 Identity components per entity kind
Each entity kind gets a "BelongsTo"-style component carrying its
structural relationships (parent pointers) and any legacy identifiers
needed for serialization parity. This is the canonical "parent pointer
on child" pattern; see §3.1 and §5.
#### Node
```ts
export const NodeBelongsTo = defineComponentKey<
{
graphId: GraphId
litegraphNodeId: NodeId // legacy `LGraphNode.id`, kept for serialization
},
NodeEntityId
>('NodeBelongsTo')
```
Nodes belong to a graph (their containing `LGraph` or `Subgraph`).
`litegraphNodeId` preserves the local-to-graph numeric ID that
`workflowSchema.json` round-tripping requires.
#### Link
```ts
export const LinkBelongsTo = defineComponentKey<
{
graphId: GraphId
litegraphLinkId: LinkId // legacy `LLink.id`
},
LinkEntityId
>('LinkBelongsTo')
export const LinkEndpoints = defineComponentKey<
{
origin: SlotEntityId
target: SlotEntityId
type: ISlotType
},
LinkEntityId
>('LinkEndpoints')
```
`LinkEndpoints` (already proposed in ADR 0008 §"Component Decomposition")
becomes the structural relationship; under the new scheme its fields are
opaque `SlotEntityId`s, not `(node_id, slot_index)` tuples.
#### Widget
```ts
export const WidgetBelongsTo = defineComponentKey<
{
graphId: GraphId
nodeId: NodeEntityId // parent pointer
name: string // identity within the parent
},
WidgetEntityId
>('WidgetBelongsTo')
```
The widget-on-node parent pointer is the bug-relevant case. `name`
identifies the widget within its parent node and replaces the
parser-recovered name in `widgetEntityId`'s wire format.
#### Slot
```ts
export const SlotBelongsTo = defineComponentKey<
{
graphId: GraphId
nodeId: NodeEntityId // parent pointer
direction: 'input' | 'output'
index: number // ordinal within the parent's input/output array
name: string
},
SlotEntityId
>('SlotBelongsTo')
```
Slots are an extreme case of "identity is currently the address" —
today's identity is literally `(node, direction, index)`, with the index
shifting whenever a slot is inserted or removed. Under opaque UUIDs, slot
identity persists across reorderings; the `index` field becomes a
position in an ordered children list, not a name.
#### Reroute
```ts
export const RerouteBelongsTo = defineComponentKey<
{
graphId: GraphId
parentRerouteId: RerouteEntityId | null // optional reroute parent
litegraphRerouteId: RerouteId // legacy `Reroute.id`
},
RerouteEntityId
>('RerouteBelongsTo')
```
Reroutes can chain (`Reroute.parentId`); the parent-pointer pattern
applies recursively. Reroutes also reference the link they belong to via
`RerouteLinks` (ADR 0008 §"Reroute"); under opaque IDs that becomes a
list of `LinkEntityId`s.
#### Group
```ts
export const GroupBelongsTo = defineComponentKey<
{
graphId: GraphId
litegraphGroupId: number // legacy `LGraphGroup` numeric id
},
GroupEntityId
>('GroupBelongsTo')
export const GroupChildren = defineComponentKey<
{ entityIds: (NodeEntityId | RerouteEntityId)[] },
GroupEntityId
>('GroupChildren')
```
Groups own a heterogeneous children list (nodes and reroutes within
their bounds). `GroupChildren` is the denormalized cache (§2.3); each
member also carries an optional `MemberOf` back-pointer if downward
iteration is hot (it isn't currently).
### 2.3 Children lists on parents — denormalized caches
Where downward iteration is hot, the parent gets a denormalized
children-list component. The existing
[`WidgetComponentContainer`](../../src/world/widgets/widgetComponents.ts)
on the node side is the canonical example; this generalizes to:
| Parent | Children component | Hot path justifying it |
| ------ | ------------------------------------------------------------------------------------- | ---------------------------------- |
| Node | `WidgetComponentContainer { widgetIds: [...] }` | Per-frame Vue-node rendering |
| Node | `SlotChildren { inputs: [...], outputs: [...] }` | Connection drawing, hit-testing |
| Graph | `GraphMembers { nodeIds: [...], linkIds: [...], rerouteIds: [...], groupIds: [...] }` | Top-level rendering, `clearGraph` |
| Group | `GroupChildren { entityIds: [...] }` | Group-bounding-box render |
| Link | `RerouteLinks { rerouteIds: [...] }` | Path rendering along reroute chain |
These are caches **derived from** the per-child `*BelongsTo` parent
pointer. They exist for performance and are maintained by the same
mutation API that owns the parent pointer (§2.4); callers outside that
API never write to them directly. This is the Bevy
`Parent`+`Children`-symmetric-management discipline.
### 2.4 Secondary indices live in domain stores
Under opaque UUIDs, the question "given some content-shaped key, find
the entity" is no longer answered by computing a string. Each domain
store keeps a private forward index whose key is built with
`makeCompositeKey` (already in
[src/utils/compositeKey.ts](../../src/utils/compositeKey.ts)) so the
encoding is injective by construction:
| Store | Forward index | Key shape |
| --------------------- | -------------------- | ------------------------------------------- |
| `widgetValueStore` | `widgetByAddress` | `ckey([graphId, nodeId, widgetName])` |
| `widgetValueStore` | `nodeByAddress` | `ckey([graphId, litegraphNodeId])` |
| (slot store, future) | `slotByAddress` | `ckey([graphId, nodeId, direction, index])` |
| (link store, future) | `linkByLitegraphId` | `ckey([graphId, litegraphLinkId])` |
| (group store, future) | `groupByLitegraphId` | `ckey([graphId, litegraphGroupId])` |
Plus per-graph entity sets for O(set-size) bulk clear, replacing the
slice-1 `isWidgetIdForGraph` startsWith-scan:
```ts
const widgetsByGraph = new Map<GraphId, Set<WidgetEntityId>>()
const nodesByGraph = new Map<GraphId, Set<NodeEntityId>>()
// (and analogous per-kind sets in each store as kinds migrate)
```
All indices are private and mutated only by the owning store's public
methods (`registerWidget` / `clearGraph` / a future `unregisterWidget`).
This is the centralized-mutation discipline that Bevy's hierarchy
plugin enforces through `Commands` extension methods — and that the ECS
literature unanimously identifies as the precondition for safely
denormalizing hierarchy state.
### 2.5 Surface area summary
| Layer | Primitive | Role |
| ----------------------------- | --------------------------------------------- | -------------------------------------- |
| `entityIds.ts` | `mint{Node,Link,Widget,Slot,Reroute,Group}Id` | Opaque ID generation |
| domain `*Components.ts` files | `*BelongsTo` components | Parent pointers (identity) |
| domain `*Components.ts` files | `*Children` / `*Container` components | Denormalized caches |
| domain stores | `*ByAddress` indices | Forward content-address lookup |
| domain stores | `*ByGraph` indices | Per-graph bulk clear |
| domain store public methods | `register*` / `clearGraph` / `unregister*` | Sole writers to indices and components |
---
## 3. Why this is the right shape
### 3.1 It's what the ECS literature converges on
Three independent surveys (Flecs/Bevy/EnTT, koota/miniplex, and
normalization-tradeoff literature) agree on the same finding:
**store the parent pointer on the child, add a denormalized children
list on the parent only when downward iteration is hot, and centralize
all mutations behind a small API**. This document proposes exactly that
pattern — uniformly across all six entity kinds.
### 3.2 It removes parsers as an attack surface — for every kind
`parseWidgetEntityId` does not exist in the new scheme. The temptation
to write a `parseSlotEntityId` (which would face the same hazards on
slot names) is removed before it appears. Identity is read structurally
from components.
### 3.3 It decouples address from identity
Today, `widget(g, n, "old-name")` and `widget(g, n, "new-name")` are
different entities. Slot reordering today changes slot identity
(because identity = ordinal index). Reparenting today rotates entity IDs
across graphs. Under opaque UUIDs none of these mutations rotates
identity — they're component edits. This unlocks features that need
identity continuity (promoted-widget identity preservation,
slot-reorder-without-reconnection, label-swapping in CRDT replication)
without touching the substrate.
### 3.4 It aligns with ADRs 0003 and 0008
ADR 0003's command pattern requires that all mutations be serializable
and replayable. Opaque UUIDs are easier to serialize coherently — they
don't carry a graphId-prefix that needs rewriting on subgraph remount,
and they don't depend on the serialization format encoding identity
tuples identically across versions. Component-resident identity makes
command shapes ("set `WidgetBelongsTo.name` on `<uuid>`") trivially
expressible.
ADR 0008's "world is the source of truth, serialization is a
translation" principle is reinforced: the World holds opaque UUIDs and
identity components; the SerializationSystem maps them to/from the
`workflowSchema.json` format that uses litegraph-numeric IDs.
### 3.5 It absorbs the colon-collision fix as a side effect
The bug that motivates this document is solved twice over by the design:
the parser is gone, AND the address index uses `makeCompositeKey`
(already injective). No assert, no regex, no caveat.
---
## 4. Costs and risks
### 4.1 Index hygiene — across more stores
For each migrated kind, the per-store invariant is the same: indices
must stay in lockstep with the World. The discipline that makes this
safe is API-confinement: **all mutation goes through the owning store's
public methods**. Direct `world.setComponent` calls bypass the indices
and corrupt them.
Mitigations (apply per-store):
- Document the contract in the file's top doc-comment.
- Add a unit test that asserts the indices match the World contents
after a randomized sequence of register/clear operations.
- Optionally, add a debug-build-only `world.assertConsistentIndices()`
check.
### 4.2 Debuggability of opaque IDs
UUIDs in stack traces require reading the corresponding `*BelongsTo`
component to recover the human-meaningful identity. This applies to
every kind, not just widgets.
Mitigations:
- Provide a `describeEntity(id)` helper per store that returns the
identity tuple for logging.
- Vue DevTools / Pinia inspector should surface the `*BelongsTo`
components for registered entities.
### 4.3 Migration effort, by kind
The slices below mirror the [migration plan](ecs-migration-plan.md)'s
incremental approach. Each kind ships independently:
| Kind | Status today | Migration cost |
| -------------- | ---------------------------------------------------- | ----------------------------------------------------------------------------------------------- |
| Widget | In World (slice 1, content-addressed strings) | High: rewrite `entityIds.ts`, `widgetValueStore.ts`, ~6 test files. ~+250/-80 prod, ~+100 test. |
| Node container | In World (slice 1, content-addressed strings) | Bundled with widget migration; same surface area. |
| Slot | Not in World; lives on `LGraphNode.{inputs,outputs}` | Per ADR 0008 migration plan. New: `slotEntityId`, `SlotBelongsTo`, slot store. |
| Link | Not in World; lives on `LGraph.links` | New: `linkEntityId`, `LinkBelongsTo`, `LinkEndpoints` (slot-keyed). |
| Reroute | Not in World; lives on `LGraph.reroutes` | New: `rerouteEntityId`, `RerouteBelongsTo`, `RerouteLinks`. |
| Group | Not in World; lives on `LGraph._groups` | New: `groupEntityId`, `GroupBelongsTo`, `GroupChildren`. |
The widget+node migration is the only slice that touches existing World
code and existing tests. All other kinds are greenfield extensions — the
strategy is fixed by this document so each slice executes mechanically
without re-litigating identity-format choices.
### 4.4 Re-registration after `clearGraph`
If `clearGraph(g)` cleans World components but leaves stale entries in
the per-store address indices, a subsequent `register*(g, ...)` returns
an old UUID and re-binds components on a "ghost" entity. This is
functionally equivalent to today's content-addressed re-use, but the
discipline must be enforced: **`clearGraph(g)` MUST clear all index
entries for graph `g` before returning.** Per-store unit tests should
include a `clear → re-register` round-trip case.
### 4.5 Tests that hand-construct entity IDs
Slice-1 tests synthesize `WidgetEntityId`/`NodeEntityId` strings directly
(e.g. `defineComponentKey<...>` typing assertions in
[world.test.ts](../../src/world/world.test.ts)). These will need to mint
UUIDs instead. The change is mechanical but touches several test files
and will recur for each subsequent slice.
### 4.6 Litegraph-numeric ID coexistence
`workflowSchema.json` round-tripping requires preserving the numeric IDs
emitted by `LGraphState.lastNodeId++` etc. Under opaque-UUID identity
those numbers become **data** on the `*BelongsTo` component
(`litegraphNodeId`, `litegraphLinkId`, `litegraphGroupId`,
`litegraphRerouteId`). The SerializationSystem reads them when emitting
JSON and assigns matching values when re-hydrating. This is the same
"World ID ≠ wire ID" decoupling that ADR 0008 requires.
---
## 5. Cross-references
### 5.1 ECS library survey
Detailed comparison in [appendix-ecs-pattern-survey.md](appendix-ecs-pattern-survey.md).
The pattern this document proposes — opaque IDs + parent-pointer-on-child
- denormalized children + centralized mutation — is what koota,
miniplex, EnTT, and Bevy all converge on for hierarchy modeling. Flecs
goes further by encoding the parent into the archetype, which JS ECSes
don't have access to without significant substrate work.
### 5.2 Source citations for §2 and §3.1
- Flecs hierarchies and pairs:
[Hierarchies Manual](https://www.flecs.dev/flecs/md_docs_2HierarchiesManual.html),
[Relationships Manual](https://www.flecs.dev/flecs/md_docs_2Relationships.html)
- Bevy parent/children components:
[bevy_hierarchy docs](https://docs.rs/bevy_hierarchy),
[Bevy Cheat Book hierarchy](https://bevy-cheatbook.github.io/fundamentals/hierarchy.html)
- EnTT relationship guidance:
skypjack, [ECS back and forth, part 4](https://skypjack.github.io/2019-09-25-ecs_baf_part_4/)
- koota relation primitive:
[koota/relation source](https://github.com/pmndrs/koota/blob/main/packages/core/src/relation/relation.ts)
- miniplex entity-reference pattern:
[miniplex README](https://github.com/hmans/miniplex)
- Normalization tradeoffs:
Mertens, [Building Games in ECS with Entity Relationships](https://ajmmertens.medium.com/building-games-in-ecs-with-entity-relationships-657275ba2c6c);
IceFall Games,
[Managing game object hierarchy in an ECS](https://mtnphil.wordpress.com/2014/06/09/managing-game-object-hierarchy-in-an-entity-component-system/)
### 5.3 Related ADRs and architecture docs
- [ADR 0003: Centralized Layout Management with CRDT](../adr/0003-crdt-based-layout-system.md) —
command-pattern requirement that opaque IDs simplify
- [ADR 0008: Entity Component System](../adr/0008-entity-component-system.md) —
this document amends §"Branded ID Design" by reinterpreting "numeric"
as "opaque UUID string" across all six entity kinds, and adds
`*BelongsTo` identity components to §"Component Decomposition"
- [ECS Pattern Survey](appendix-ecs-pattern-survey.md) — substrate-level
comparison
- [ECS Lifecycle Scenarios](ecs-lifecycle-scenarios.md) — concrete
before/after walkthroughs that this document's identity model affects
- [ECS Migration Plan](ecs-migration-plan.md) — the slice progression
this document's per-kind sections track against
- [ECS Target Architecture](ecs-target-architecture.md) — the world-shape
this document refines for identity
---
## 6. Migration plan
Two horizons: a near-term implementation slice that ships now, and a
strategy locked in for subsequent ADR 0008 slices.
### 6.1 Near-term: widgets and node containers (slice 1.5)
Ship as a single PR after the colon-collision consolidation
([entityIds-consolidation.md](../../temp/plans/entityIds-consolidation.md))
lands and is verified. Sequencing rationale: the consolidation is a
defensive 1-file fix that does not commit to this larger architectural
direction. If this proposal is rejected or amended, the consolidation
still stands as a strict improvement over the current state.
Phases inside the PR (single commit boundary, but reviewable as separate
hunks):
1. Add `WidgetBelongsTo`, `NodeBelongsTo` components and the four
indices in `widgetValueStore`. Populate them in `registerWidget`
alongside the existing entity-ID derivation. The string-format
`widgetEntityId` continues to exist; the new components are written
redundantly. All tests still pass.
2. Switch `getWidget` / `getNodeWidgets` / `getNodeWidgetsByName` to
read identity from `WidgetBelongsTo` instead of parsing the entity
ID. Delete `parseWidgetEntityId`. Delete the regex.
3. Switch `clearGraph` to consume `widgetsByGraph` / `nodesByGraph`
instead of `entitiesWith` + `isWidgetIdForGraph`. Delete the
`isXForGraph` helpers.
4. Replace `widgetEntityId` / `nodeEntityId` constructors with
`mintWidgetId` / `mintNodeId`. Switch the entity-ID format from
content-addressed strings to UUIDs.
5. Update test fixtures that hand-construct entity-ID strings.
Each phase ends in a green test suite. Quality gates:
`pnpm test:unit && pnpm typecheck && pnpm lint && pnpm knip`.
### 6.2 Subsequent slices: slots, links, reroutes, groups
Per the [migration plan](ecs-migration-plan.md), each remaining kind
moves into the World in its own slice. For each, the strategy is fixed
by this document:
1. Add the kind's `mint*Id` to `entityIds.ts` and the `*EntityId` brand.
2. Define the `*BelongsTo` identity component plus any `*Children` /
`*Endpoints` structural components per §2.2.
3. Create (or extend) the domain store that owns the per-graph and
forward-address indices, with the centralized-mutation API discipline.
4. Add the bridge layer (per ADR 0008 §"Migration Strategy" step 2) so
existing OOP consumers continue to read from the litegraph class
while ECS readers read from the World.
5. Migrate consumers piecewise per ADR 0008 §"Migration Strategy"
steps 35.
No new identity-format decisions are made per slice. The colon-collision
risk class is closed for the entire ECS, not just for widgets.
---
## 7. Open questions
1. **Should widgets and node containers ship in one PR or two?** Widget
identity is the bug-relevant case; node identity is symmetric but not
load-bearing. Splitting reduces review surface but leaves the
substrate temporarily inconsistent (widgets opaque, node containers
content-addressed). Recommendation: one PR, since both live in
`widgetValueStore` and the indices interlock.
2. **Should `*BelongsTo.name` / `*BelongsTo.index` be mutable post-creation?**
Today, rename mints a new entity; reorder rotates slot identity.
Under opaque IDs these mutations could either (a) preserve identity
(mutate component, rewrite address index) or (b) preserve current
semantics (delete + remint). (a) is the new capability this refactor
unlocks, but adopting it changes downstream behavior; should be a
deliberate follow-up per kind.
3. **Do we want explicit `Is{Widget,Node,Slot,…}` marker components?**
koota uses tag traits this way so `world.entitiesWith(IsWidget)` is
the canonical "iterate all widgets" query, replacing the implicit
`entitiesWith(WidgetComponentValue)` pattern. Optional ergonomic
improvement, not required by the bug fix; decide per slice.
4. **Where does name/ordinal uniqueness within a parent belong?**
For widgets: `(graphId, nodeId, name)` uniqueness is enforced by
`widgetByAddress`. For slots: `(graphId, nodeId, direction, index)`
uniqueness is enforced by `slotByAddress`. We should decide per kind
whether second-registration is a no-op (`getOrRegister` semantics),
an overwrite, or an error.
5. **Counter-based vs UUID-based minting.** ADR 0008 originally proposed
counter-based (`lastWidgetId++`). UUIDs are simpler (no shared state,
no CRDT mapping) but slightly larger and unpleasant in stack traces.
Recommendation: UUIDs for now (matching this document's §2.1); revisit
if profiling or debuggability demands otherwise.

View File

@@ -0,0 +1,128 @@
# v2 Extension API — Touch-Point Database
This directory is the **canonical compatibility-surface map** for the upcoming
v2 extension API redesign. Every API surface that real-world ComfyUI
extensions touch is enumerated here, weighted by usage frequency and ecosystem
star count, with citations to verifiable evidence (file paths and line
numbers in real custom-node repos).
It exists so the v2 redesign can answer two questions deterministically:
1. **What will silently break?** — every entry maps to a v2 replacement (or to
an explicit "deprecated, no replacement" decision).
2. **What does the v2 test framework need to cover?** — every entry maps to
≥1 test target so the test floor reflects real extension shapes.
## Artifacts
| File | Role |
|---|---|
| [`touch-points-plan.md`](./touch-points-plan.md) | Methodology, schema, surface-family enumeration, severity rubric |
| [`touch-points-database.yaml`](./touch-points-database.yaml) | Source of truth — 52 patterns × 15 surface families with evidence rows |
| [`touch-points-star-cache.yaml`](./touch-points-star-cache.yaml) | GitHub star/fork/last-commit snapshot for every cited repo (drift detection) |
| [`touch-points-rollup.yaml`](./touch-points-rollup.yaml) | Computed blast-radius scores per pattern (sorted) — the prioritization output |
| [`scripts/fetch-stars.sh`](./scripts/fetch-stars.sh) | Refresh the star cache via `gh api` |
| [`scripts/rollup-blast-radius.py`](./scripts/rollup-blast-radius.py) | Recompute blast radius from database + star cache |
| [`scripts/add-evidence.py`](./scripts/add-evidence.py) | Idempotently merge new evidence rows / new patterns into the database |
## The 15 surface families
| Family | One-liner |
|---|---|
| **S1** | `ComfyExtension` lifecycle hooks (`init`, `setup`, `nodeCreated`, `beforeRegisterNodeDef`, …) |
| **S2** | `LGraphNode.prototype` methods extensions monkey-patch (`onConnectionsChange`, `onSerialize`, `onDrawForeground`, …) |
| **S3** | `LGraphCanvas.prototype` methods extensions monkey-patch (`processKey`, `processContextMenu`, `drawNode`, …) |
| **S4** | Widget-level patterns — `.callback` chaining, `.value` r/w, `.serializeValue`, `.options.*`, DOM widgets |
| **S5** | `ComfyApi` / `app.api` event surfaces — execution lifecycle WebSocket events |
| **S6** | `ComfyApp` god-object touch points — `app.graphToPrompt`, `app.queuePrompt`, `app.api.fetchApi`, … |
| **S7** | Window / global escape hatches — `window.app`, `window.LiteGraph`, `globalThis.LGraphCanvas` |
| **S8** | Special node properties (magic flags) — `isVirtualNode`, `serialize_widgets`, `category`, `color_on` |
| **S9** | Non-Node entity kinds (per [ADR 0008](../decisions/0008-entity-taxonomy.md)) — subgraphs, groups, reroutes, links |
| **S10** | Dynamic node API — `addInput` / `removeInput` / `addOutput` / `removeOutput` slot mutation at runtime |
| **S11** | Graph-level state and change-tracking — `graph.add`, `graph.remove`, `graph.serialize`, version bumps |
| **S12** | Shell UI registries — `extensionManager.registerSidebarTab`, bottom panel, commands, toasts |
| **S13** | Schema interpretation — `ComfyNodeDef` / `InputSpec` consumers (validation, default values, type coercion) |
| **S14** | Identity / Locator scheme — node IDs, slot keys, widget identity across reload |
| **S15** | Output system — preview-image / preview-any / display-text axis (per `widget-api-thoughts.md`) |
Full details, schema, and severity rubric are in [`touch-points-plan.md`](./touch-points-plan.md).
## Top 12 patterns by blast radius
Computed from [`touch-points-rollup.yaml`](./touch-points-rollup.yaml). Blast
radius is `log10(1+stars)·1.0 + log10(1+occurrences)·0.7 +
(signature_count-1)·0.5 + silent_breakage·0.5 + lifecycle_coupling·0.4`.
| Rank | BR | ★ sum | occ | sig | Pattern | Surface |
|---:|---:|---:|---:|---:|---|---|
| 1 | 6.67 | 17 101 | 7 | 1 | `S6.A1` | `app.graphToPrompt` monkey-patching ⚠️ CRITICAL |
| 2 | 5.42 | 2 567 | 1 | 1 | `S9.SG1` | Subgraph "set/get virtual node" pattern (KJNodes-style) |
| 3 | 5.27 | 4 314 | 4 | 1 | `S11.G2` | `graph.add` / `graph.remove` / `graph.findNodesByType` / `graph.findNodeById` / `graph.serialize` / `graph.configure` |
| 4 | 5.23 | 1 808 | 3 | 1 | `S10.D1` | `node.addInput` / `node.removeInput` / `node.addOutput` / `node.removeOutput` dynamic slot mutation |
| 5 | 5.18 | 3 049 | 5 | 1 | `S2.N13` | `nodeType.prototype.onConnectOutput` patching |
| 6 | 5.08 | 6 147 | 4 | 1 | `S4.W2` | `node.addDOMWidget(name, type, element, options)` |
| 7 | 5.01 | 412 | 6 | 1 | `S2.N15` | `nodeType.prototype.serialize` / `node.serialize` direct method patching |
| 8 | 4.89 | 1 789 | 4 | 1 | `S2.N14` | `nodeType.prototype.onWidgetChanged` patching |
| 9 | 4.89 | 7 932 | 6 | 1 | `S2.N4` | `nodeType.prototype.onRemoved` patching (de-facto teardown) |
| 10 | 4.66 | 1 837 | 6 | 1 | `S4.W3` | `widget.serializeValue` direct assignment |
| 11 | 4.61 | 1 788 | 1 | 1 | `S2.N12` | `nodeType.prototype.onConnectInput` patching |
| 12 | 4.55 | 1 793 | 5 | 1 | `S6.A3` | `api.fetchApi` — extensions hit backend HTTP endpoints |
The top three pattern categories — graph mutation (`S11.G2`, `S10.D1`),
prototype patching (`S2.*`), and the `app.graphToPrompt` god-object — together
account for the majority of the blast radius and define the v2 API's
non-negotiable compatibility surfaces.
## Refresh workflow
The database is curated by hand; the star cache and rollup are derived.
```bash
# from this directory
bash scripts/fetch-stars.sh # refresh GitHub stars (needs `gh` auth)
python3 scripts/rollup-blast-radius.py # recompute touch-points-rollup.yaml
```
To add new evidence or new patterns discovered during a future MCP
code-search sweep, edit `scripts/add-evidence.py` (the inline `APPEND` and
`NEW_PATTERNS` blocks are the source of truth for reproducibility) and run:
```bash
python3 scripts/add-evidence.py
python3 scripts/rollup-blast-radius.py
```
## Source documents
The 52 patterns were derived from three primary inputs, then expanded by an
MCP code-search sweep across 87 ecosystem repos:
1. **`AGENTS.md` §5** in this repo — 40+ repo callouts for contributor
conventions and known extension surfaces.
2. **[ADR 0008 — Entity Taxonomy](../decisions/0008-entity-taxonomy.md)** —
defines the non-Node entity kinds (subgraphs, groups, reroutes, links)
that drive surface family **S9**.
3. **`widget-api-thoughts.md`** (in the cross-repo workspace) — the output
system axis and widget lifecycle dependencies that drive surface family
**S15** plus the lifecycle-coupling weight.
## Cross-references
This database is consumed by, and consumes, the rest of the ECS architecture
docs:
- [`../ecs-target-architecture.md`](../ecs-target-architecture.md) — the
target ECS shape this v2 API redesign serves
- [`../ecs-world-command-api.md`](../ecs-world-command-api.md) — the World /
Command API that v2 extensions will program against
- [`../ecs-migration-plan.md`](../ecs-migration-plan.md) — how we get from
today's monkey-patched LiteGraph to v2 + ECS
- [`../ecs-lifecycle-scenarios.md`](../ecs-lifecycle-scenarios.md) — the
lifecycle scenarios the test framework must cover (every touch-point row
here ⇒ ≥1 scenario there)
- [`../entity-interactions.md`](../entity-interactions.md) /
[`../entity-problems.md`](../entity-problems.md) — the entity-model
problems v2 must not perpetuate
- [`../change-tracker.md`](../change-tracker.md) — the change-tracking
contract that S11 (graph state) and S2 (`onSerialize`/`onDeserialize`
patches) must remain compatible with

View File

@@ -0,0 +1,265 @@
#!/usr/bin/env python3
# add-evidence-pass2.py — second MCP sweep. Appends evidence to under-evidenced
# patterns and adds new patterns discovered in pass-2 (graph batching seam,
# window.* globals, setDirtyCanvas redraw idiom).
#
# Idempotent: skips evidence already present (matched by repo+file+lines).
#
# Run: python3 scripts/add-evidence-pass2.py
from pathlib import Path
import yaml
ROOT = Path(__file__).resolve().parent.parent
DB = ROOT / "research" / "touch-points" / "database.yaml"
def url(repo: str, file: str, line: int) -> str:
return f"https://github.com/{repo}/blob/main/{file}#L{line}"
def ev(repo, file, lines, **kw):
e = {
"repo": repo,
"file": file,
"lines": lines if isinstance(lines, list) else [lines],
"url": url(repo, file, lines if isinstance(lines, int) else lines[0]),
}
e.update(kw)
return e
# ─── Evidence to append to existing patterns ──────────────────────────────
APPEND = {
"S2.N17": [ # onSelected / onDeselected
ev("nodelee733/ComfyUI-mxToolkit", "js/Slider.js", 1, variant="prototype-patch", breakage_class="silent",
notes="mxToolkit Slider patches onSelected for highlight state"),
ev("nodelee733/ComfyUI-mxToolkit", "js/Slider2D.js", 1, variant="prototype-patch", breakage_class="silent"),
],
"S2.N19": [ # onResize
ev("SKBv0/ComfyUI_SKBundle", "js/MultiFloat.js", 1, variant="prototype-patch", breakage_class="silent",
notes="MultiFloat widget syncs internal layout on resize"),
ev("PGCRT/CRT-Nodes", "js/Magic_Lora_Loader.js", 1, variant="prototype-patch", breakage_class="silent"),
ev("dorpxam/ComfyUI-LTX2-Microscope", "web/js/ui/visualizer.js", 1, variant="prototype-patch", breakage_class="silent",
notes="visualizer reflows DOM widget on resize"),
],
"S9.R1": [ # Reroute manipulation
ev("linjm8780860/ljm_comfyui", "src/utils/vintageClipboard.ts", 1, variant="graph.reroutes.values()", breakage_class="loud",
notes="iterates reroute map directly — fork of frontend, but represents real internal contract surface"),
ev("nodetool-ai/nodetool", "subgraphs.md", [1, 50], variant="documented-pattern", breakage_class="loud",
notes="external doc treats graph.reroutes as part of subgraph contract"),
],
"S9.SG1": [ # Set/Get virtual node
ev("krismasdev/ComfyUI-Flux-Continuum", "web/hint.js", 1, variant="virtual-node-companion", breakage_class="silent",
notes="Flux Continuum hint system depends on Set/Get virtual node graph"),
ev("SpaceWarpStudio/ComfyUI-SetInputGetOutput", "web/js/setinputgetoutput.js", 1, variant="full-implementation",
breakage_class="loud", notes="another SetInput/GetOutput pack — variant of KJNodes pattern"),
],
"S13.SC1": [ # ComfyNodeDef inspection
ev("xeinherjer-dev/ComfyUI-XENodes", "web/js/combo_selector.js", 1, variant="nodeData.input.optional",
breakage_class="silent", notes="reads nodeData.input.optional to drive UI generation"),
ev("StableLlama/ComfyUI-basic_data_handling", "web/js/dynamicnode.js", 1, variant="nodeData.input.optional",
breakage_class="silent"),
ev("IXIWORKS-KIMJUNGHO/comfyui-ixiworks-tools", "js/sb_concat.js", 1, variant="nodeData.input.optional",
breakage_class="silent"),
ev("BennyKok/comfyui-deploy", "web-plugin/index.js", 1, variant="nodeData.input.required",
breakage_class="silent", notes="comfyui-deploy is widely used; treats schema as a public contract"),
ev("egormly/ComfyUI-EG_Tools", "web/dynamic_inputs.js", 1, variant="nodeData.input.optional",
breakage_class="silent"),
],
"S3.C1": [ # LGraphCanvas.prototype.* monkey-patching — drawNodeShape variant
ev("yolain/ComfyUI-Easy-Use-Frontend", "src/extensions/ui.js", 1, variant="drawNodeShape-patch",
breakage_class="silent", notes="Easy-Use is a major pack; patches LGraphCanvas.prototype.drawNodeShape"),
ev("melMass/comfy_mtb", "web/note_plus.js", 1, variant="canvas-draw-patch", breakage_class="silent",
notes="comfy_mtb (popular pack) — note_plus draws decorations via canvas patching"),
ev("lucafoscili/lf-nodes", "web/src/nodes/reroute.ts", 1, variant="onDrawForeground+canvas-draw",
breakage_class="silent"),
ev("krismasdev/ComfyUI-Flux-Continuum", "web/outputgetnode.js", 1, variant="onDrawForeground",
breakage_class="silent"),
],
"S10.D2": [ # disconnectInput / disconnectOutput / connect
ev("MockbaTheBorg/ComfyUI-Mockba", "js/slider.js", 1, variant="programmatic-disconnect",
breakage_class="loud", notes="app.graph.getNodeById(tlink.target_id).disconnectInput(tlink.target_slot)"),
ev("vjumpkung/comfyui-infinitetalk-native-sampler", "README.md", [1, 50], variant="documented-as-API",
breakage_class="loud", notes="3rd-party docs treat node.disconnect* as a stable extension surface"),
],
"S8.P1": [ # isVirtualNode = true
ev("ComfyNodePRs/PR-comfyui-pkg39-ccab78b5", "js/libs/image.js", [541, 1382], variant="filter-by-virtual",
breakage_class="loud", notes="extension code filters nodes by isVirtualNode — treats it as discovery API"),
],
}
# ─── Brand-new patterns discovered in pass-2 ──────────────────────────────
NEW_PATTERNS = [
{
"pattern_id": "S11.G3",
"surface_family": "S11",
"surface": "graph.beforeChange / graph.afterChange — explicit batching seam for multi-step mutations",
"fingerprint": "graph.beforeChange(); ...mutations...; graph.afterChange();",
"semantic": (
"extensions wrap multi-node/multi-link mutations in beforeChange/afterChange so undo, "
"dirty-tracking, and re-render coalesce around the batch instead of per-mutation"
),
"v2_replacement": "world.batch(() => { ...mutations... }) — typed batching API",
"decision_ref": (
"First-class batching is required for any reactive layer that wants stable diffs; "
"v2 should expose this as a mandatory wrapper for multi-mutation operations"
),
"test_target": "GRAPH_BATCH_BOUNDARY",
"lifecycle_coupling": 1,
"severity": "HIGH",
"evidence_status": "swept",
"evidence": [
ev("nodetool-ai/nodetool", "subgraphs.md", [1, 50], variant="documented-pattern", breakage_class="loud",
notes="docs use beforeChange/afterChange around subgraph promotion"),
ev("linjm8780860/ljm_comfyui", "src/utils/vintageClipboard.ts", 1, variant="paste-undo-batch",
breakage_class="loud", notes="paste flow batches mutations across clipboard restore"),
],
},
{
"pattern_id": "S7.G1",
"surface_family": "S7",
"surface": "window.LiteGraph / window.comfyAPI.* — globals as public surface",
"fingerprint": "window.LiteGraph.createNode(...); window.comfyAPI.app.app",
"semantic": (
"extensions reach into the global namespace for LiteGraph constructors/enums or for the "
"module-as-global comfyAPI registry. This is the closest thing to a 'public ABI' today"
),
"v2_replacement": (
"explicit `import { app, graph, LiteGraph } from '@comfy/extension'` + a typed registry "
"keyed by extension name; window.* should remain as a deprecated read-only mirror"
),
"decision_ref": (
"Cannot break window.LiteGraph immediately — too much ecosystem code reaches for it. "
"Must ship typed import path first, then deprecate. Similar story to S11.G2 graph globals."
),
"test_target": "GLOBAL_NAMESPACE_COMPAT",
"lifecycle_coupling": 0,
"severity": "CRITICAL",
"evidence_status": "swept",
"evidence": [
ev("krismasdev/ComfyUI-Flux-Continuum", "web/hint.js", 1, variant="window.LiteGraph",
breakage_class="loud"),
ev("SpaceWarpStudio/ComfyUI-SetInputGetOutput", "web/js/setinputgetoutput.js", 1,
variant="window.LiteGraph", breakage_class="loud"),
ev("ArtHommage/HommageTools", "web/js/index.js", 1, variant="window.LiteGraph", breakage_class="loud"),
ev("PROJECTMAD/PROJECT-MAD-NODES", "web/js/index.js", 1, variant="window.LiteGraph", breakage_class="loud"),
ev("ryanontheinside/ComfyUI_RyanOnTheInside", "web/js/index.js", 1, variant="window.LiteGraph",
breakage_class="loud"),
ev("stavzszn/comfyui-teskors-utils", "web/js/index.js", 1, variant="window.LiteGraph",
breakage_class="loud"),
],
},
{
"pattern_id": "S11.G4",
"surface_family": "S11",
"surface": "graph.setDirtyCanvas(true, true) — imperative canvas-redraw trigger",
"fingerprint": "node.graph?.setDirtyCanvas?.(true, true); app.graph.setDirtyCanvas(true, true);",
"semantic": (
"after any imperative mutation extensions call setDirtyCanvas to force a redraw — the "
"ecosystem's de-facto 'reactivity flush' primitive. v2 reactivity should make this unnecessary"
),
"v2_replacement": (
"implicit — reactive system schedules redraw automatically when tracked entity mutates. "
"Provide an escape hatch `world.markDirty()` only for non-reactive third-party canvas use"
),
"decision_ref": (
"Replacing this surface is the strongest evidence that v2 reactivity actually buys something. "
"Should be in v2 'value proposition' demo extension"
),
"test_target": "REDRAW_NO_LONGER_NEEDED",
"lifecycle_coupling": 0,
"severity": "MEDIUM",
"evidence_status": "swept",
"evidence": [
ev("AlexZ1967/ComfyUI_ALEXZ_tools", "web/video_cut_match_upload.js", 111,
variant="post-mutation-redraw", breakage_class="silent"),
ev("AlexZ1967/ComfyUI_ALEXZ_tools", "web/widget_visibility_profiles.js", 285,
variant="post-mutation-redraw", breakage_class="silent"),
ev("AlexZ1967/ComfyUI_ALEXZ_tools", "web/ui/module_node_picker_node_factory.js", 189,
variant="post-mutation-redraw", breakage_class="silent"),
ev("akawana/ComfyUI-Folded-Prompts", "js/FPFoldedPrompts.js", [776, 1087],
variant="post-mutation-redraw", breakage_class="silent",
notes="multiple call sites — extension assumes manual flush is the contract"),
],
},
{
"pattern_id": "S10.D3",
"surface_family": "S10",
"surface": "node.setSize(node.computeSize()) — imperative resize after dynamic mutation",
"fingerprint": "node.setSize?.(node.computeSize())",
"semantic": (
"after dynamic widget/input/output mutation, extensions manually call computeSize+setSize "
"to reflow the node. Companion to S2.N11 (computeSize override) and S11.G4 (setDirtyCanvas)"
),
"v2_replacement": (
"automatic — reactive layout system recomputes node size when widget/slot collection changes. "
"Expose `nodeHandle.requestLayout()` only as escape hatch"
),
"decision_ref": "Pairs with S11.G4 — both are 'manual flush' idioms that v2 should obviate",
"test_target": "AUTO_RELAYOUT_ON_MUTATION",
"lifecycle_coupling": 0,
"severity": "MEDIUM",
"evidence_status": "swept",
"evidence": [
ev("AlexZ1967/ComfyUI_ALEXZ_tools", "web/widget_visibility_profiles.js", 283,
variant="setSize+computeSize", breakage_class="silent",
notes="exact 'node.setSize?.(node.computeSize())' canonical idiom"),
ev("zhupeter010903/ComfyUI-XYZ-prompt-library", "js/prompt_library_node.js", 466,
variant="manual-height", breakage_class="silent",
notes="commented-out manual setSize — shows the pattern is well-known"),
],
},
]
def normalize_evidence_key(e):
return (e.get("repo"), e.get("file"), tuple(e.get("lines") or []))
def main():
db = yaml.safe_load(DB.read_text())
appended = 0
skipped = 0
for pid, new_evs in APPEND.items():
for p in db["patterns"]:
if p["pattern_id"] == pid:
if "evidence" not in p or p["evidence"] is None:
p["evidence"] = []
existing = {normalize_evidence_key(e) for e in p["evidence"]}
for e in new_evs:
if normalize_evidence_key(e) in existing:
skipped += 1
continue
p["evidence"].append(e)
appended += 1
p["evidence_status"] = "swept"
break
else:
print(f"⚠️ pattern {pid} not found")
added_new = 0
existing_ids = {p["pattern_id"] for p in db["patterns"]}
for np in NEW_PATTERNS:
if np["pattern_id"] in existing_ids:
print(f"⚠️ pattern {np['pattern_id']} already exists — skipping")
continue
db["patterns"].append(np)
added_new += 1
db["meta"]["patterns_count"] = len(db["patterns"])
db["meta"]["sweep_status"] = "in-progress"
if "evidence-sweep-pass-2" not in db["meta"].get("sweeps_done", []):
db["meta"]["sweeps_done"].append("evidence-sweep-pass-2")
DB.write_text(yaml.safe_dump(db, sort_keys=False, width=200, allow_unicode=True))
print(f"✅ appended {appended} evidence rows ({skipped} dupes skipped)")
print(f"✅ added {added_new} new patterns")
print(f"✅ DB now has {len(db['patterns'])} patterns")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,213 @@
#!/usr/bin/env python3
# add-evidence.py — append evidence to existing patterns and add NEW patterns
# discovered during the MCP sweep. Idempotent: skips evidence already present
# (matched by repo+file+lines).
#
# Run: python3 scripts/add-evidence.py
#
# Source-of-truth for evidence is inline below — keeping it in version
# control makes the sweep reproducible and reviewable.
from pathlib import Path
import yaml
ROOT = Path(__file__).resolve().parent.parent
DB = ROOT / "touch-points-database.yaml"
def url(repo: str, file: str, line: int) -> str:
return f"https://github.com/{repo}/blob/main/{file}#L{line}"
def ev(repo, file, lines, **kw):
e = {
"repo": repo,
"file": file,
"lines": lines if isinstance(lines, list) else [lines],
"url": url(repo, file, lines if isinstance(lines, int) else lines[0]),
}
e.update(kw)
return e
# ─── Evidence to merge into existing patterns ─────────────────────────────
APPEND = {
"S2.N12": [
# already has core dynamicWidgets entry
],
"S2.N13": [
ev("rgthree/rgthree-comfy", "web/comfyui/node_mode_relay.js", [90, 92], variant="subclass-override", breakage_class="loud", notes="rgthree — major pack. Subclass override pattern (calls super)."),
ev("rgthree/rgthree-comfy", "web/comfyui/node_mode_repeater.js", [21, 24], variant="subclass-override", breakage_class="loud"),
ev("rgthree/rgthree-comfy", "src_web/comfyui/node_mode_relay.ts", [146, 153], variant="subclass-override-ts", breakage_class="loud"),
ev("rgthree/rgthree-comfy", "src_web/comfyui/node_mode_repeater.ts", [46, 56], variant="subclass-override-ts", breakage_class="loud"),
ev("rgthree/rgthree-comfy", "web/comfyui/base_any_input_connected_node.js", [136, 138], variant="subclass-override", breakage_class="loud"),
],
"S2.N14": [
ev("niknah/presentation-ComfyUI", "js/PresentationDropDown.js", [12, 75], variant="prototype-chain", breakage_class="silent", notes="captures original onWidgetChanged via prototype chain"),
ev("chyer/Chye-ComfyUI-Toolset", "web/comfyui/text_file_loader.js", [35, 115], variant="instance-method", breakage_class="silent"),
],
"S2.N15": [
ev("Azornes/Comfyui-LayerForge", "js/CanvasView.js", 1438, variant="prototype-replace", breakage_class="silent", notes="LayerForge (313★) — replaces serialize wholesale"),
ev("Azornes/Comfyui-LayerForge", "src/CanvasView.ts", 1657, variant="prototype-replace-ts", breakage_class="silent"),
ev("IAMCCS/IAMCCS-nodes", "web/iamccs_wan_motion_presets.js", 598, variant="prototype-replace", breakage_class="silent"),
ev("IAMCCS/IAMCCS-nodes", "web/iamccs_ltx2_extension_presets.js", 350, variant="prototype-replace", breakage_class="silent"),
ev("DazzleNodes/ComfyUI-Smart-Resolution-Calc", "web/utils/serialization.js", 32, variant="prototype-replace", breakage_class="silent"),
ev("alankent/ComfyUI-OA-360-Clip", "web/oa_360_clip.js", 900, variant="prototype-replace", breakage_class="silent"),
],
"S2.N16": [
ev("krismasdev/ComfyUI-Flux-Continuum", "web/outputgetnode.js", 328, variant="push", breakage_class="silent", notes="extension pushes to node.widgets directly"),
ev("max-dingsda/ComfyUI-AllinOne-LazyNode", "web/js/aio_core_preview.js", 170, variant="push", breakage_class="silent"),
ev("r-vage/ComfyUI_Eclipse", "js/eclipse-set-get.js", 9, variant="indexed-read", breakage_class="loud", notes="reads node.widgets[0].value to get name"),
ev("r-vage/ComfyUI_Eclipse", "js/eclipse-load-image.js", 56, variant="indexOf", breakage_class="loud"),
ev("viswamohankomati/ComfyUI-Copilot", "ComfyUI/custom_nodes/ComfyUI-Copilot/ui/src/utils/comfyuiWorkflowApi2Ui.ts", [305, 316], variant="widgets_values-push", breakage_class="silent", notes="touches node.widgets_values, the serialized array"),
],
"S11.G1": [
ev("FloyoAI/ComfyUI-SoundFlow", "js/PreviewAudio.js", 293, variant="post-mutation-bump", breakage_class="silent", notes="bumps version after node-internal mutation to trigger redraw"),
ev("krismasdev/ComfyUI-Flux-Continuum", "web/outputgetnode.js", 84, variant="post-mutation-bump", breakage_class="silent"),
ev("coeuskoalemoss/comfyUI-layerstyle-custom", "js/dz_mtb_widgets.js", 292, variant="post-mutation-bump", breakage_class="silent"),
ev("40740/ComfyUI_LayerStyle_Bmss", "js/dz_mtb_widgets.js", 292, variant="post-mutation-bump", breakage_class="silent", notes="duplicate-of-coeuskoalemoss pattern — fork"),
],
"S11.G2": [
ev("yolain/ComfyUI-Easy-Use", "web_version/v1/js/easy/easyExtraMenu.js", 439, variant="add+createNode", breakage_class="loud", notes="Easy-Use is a major pack; uses graph.add(LiteGraph.createNode(...))"),
ev("KumihoIO/kumiho-plugins", "comfyui/web/js/kumiho.js", 431, variant="add+createNode", breakage_class="loud"),
ev("r-vage/ComfyUI_Eclipse", "js/eclipse-ui-enhancements.js", 29, variant="remove-then-add", breakage_class="loud", notes="swap nodes by remove+add — preserves layout via savedProps"),
ev("Comfy-Org/ComfyUI_frontend", "browser_tests/tests/workflowPersistence.spec.ts", [351, 413], variant="add+createNode", breakage_class="loud", notes="OUR OWN E2E TESTS rely on window.app.graph.add(window.LiteGraph.createNode(...))"),
],
"S12.UI1": [
ev("robertvoy/ComfyUI-Distributed", "web/main.js", [269, 270], variant="extensionManager.registerSidebarTab", breakage_class="loud", notes="real call site for sidebar registration"),
ev("criskb/Comfypencil", "web/comfy_pencil_extension.js", [955, 956], variant="extensionManager.registerSidebarTab", breakage_class="loud"),
ev("maxi45274/ComfyUI_LinkFX", "js/LinkFX.js", [707, 709], variant="extensionManager.registerSidebarTab", breakage_class="loud"),
],
"S10.D1": [
ev("zhupeter010903/ComfyUI-XYZ-prompt-library", "js/node.js", [18, 53], variant="dynamic-addInput-loop", breakage_class="loud", notes="real-world dynamic input expansion: this.addInput('infix '+i,'STRING')"),
ev("r-vage/ComfyUI_Eclipse", "js/eclipse-mode-nodes.js", [42, 106], variant="virtual-node-setup", breakage_class="loud", notes="Eclipse uses addOutput within isVirtualNode setup"),
ev("Comfy-Org/ComfyUI_frontend", "src/lib/litegraph/src/canvas/LinkConnector.core.test.ts", [121, 158], variant="OUR-TESTS", breakage_class="loud", notes="OUR OWN TESTS depend on addOutput"),
],
"S9.S1": [
ev("lordwedggie/xcpNodes", "js/xcpDerpINT.js", 162, variant="output-color_on-assignment", breakage_class="silent", notes="this.outputs[0].color_on = templateSlotColorOn — direct slot visual override"),
ev("nodetool-ai/nodetool", "subgraphs.md", [267, 299], variant="documented-pattern", breakage_class="loud", notes="external docs reference color_on for subgraph slot inheritance"),
],
"S4.W4": [
ev("AlexZ1967/ComfyUI_ALEXZ_tools", "web/video_cut_match_upload.js", [24, 27], variant="includes-then-push", breakage_class="silent", notes="checks values then mutates"),
ev("zzggi2024/shaobkj", "js/dynamic_inputs.js", [374, 376], variant="snapshot-then-mutate", breakage_class="silent", notes="saves __originalValues snapshot before mutating widget.options.values"),
ev("EnragedAntelope/EA_LMStudio", "web/ea_lmstudio.js", 11, variant="documented-fallback", breakage_class="loud", notes="explicit comment: 'Legacy LiteGraph frontend: full support via widget.options.values'"),
],
}
# ─── Brand-new patterns discovered during sweep ───────────────────────────
NEW_PATTERNS = [
{
"pattern_id": "S6.A3",
"surface_family": "S6",
"surface": "api.fetchApi — extensions hit backend HTTP endpoints",
"fingerprint": "await api.fetchApi('/upload/image', { method: 'POST', body: data })",
"semantic": "extensions call ComfyAPI.fetchApi as the canonical way to reach backend HTTP routes (auth, base URL, error handling all handled)",
"v2_replacement": "ctx.api.fetch(path, init) typed wrapper; same semantics, narrower surface",
"decision_ref": "Pattern is widely used and CORRECT — keep contract, just type it",
"test_target": "BACKEND_HTTP_CLIENT",
"lifecycle_coupling": 0,
"severity": "HIGH",
"evidence_status": "swept",
"evidence": [
ev("AlexZ1967/ComfyUI_ALEXZ_tools", "web/video_cut_match_upload.js", 54, variant="POST-multipart", breakage_class="loud"),
ev("AlexZ1967/ComfyUI_ALEXZ_tools", "web/api/module_node_picker_api.js", 43, variant="generic-wrapper", breakage_class="loud"),
ev("akawana/ComfyUI-Folded-Prompts", "js/FPFoldedPrompts.js", 1227, variant="POST-upload", breakage_class="loud"),
ev("zhupeter010903/ComfyUI-XYZ-prompt-library", "js/prompt_library_window.js", 1379, variant="GET", breakage_class="loud"),
ev("Comfy-Org/ComfyUI_frontend", "src/components/common/BackgroundImageUpload.vue", 61, variant="POST-upload", breakage_class="loud", notes="OUR OWN UI uses api.fetchApi for image upload"),
],
},
{
"pattern_id": "S6.A4",
"surface_family": "S6",
"surface": "app.queuePrompt / app.api.queuePrompt patching or direct call",
"fingerprint": "const orig = window.app.api.queuePrompt; window.app.api.queuePrompt = async function(...args) {...; return orig(...args)}",
"semantic": "intercept or trigger workflow execution; auth tokens, custom payload mutation, sidebar 'Run' buttons",
"v2_replacement": "graph.run({ batch }) explicit API + app.on('beforeRun', payload => mutate(payload))",
"decision_ref": "Pairs with S6.A1 graphToPrompt as the OTHER half of the execute-pipeline interception story",
"test_target": "PROMPT_QUEUE_INTERCEPT",
"lifecycle_coupling": 2,
"severity": "CRITICAL",
"evidence_status": "swept",
"evidence": [
ev("gigici/ComfyUI_BlendPack", "js/ui/NodeUI.js", 99, variant="bind-then-replace", breakage_class="silent", notes="window.app.api.queuePrompt?.bind(window.app.api) — patches the API-level queue"),
ev("MajoorWaldi/ComfyUI-Majoor-AssetsManager", "js/features/viewer/workflowSidebar/sidebarRunButton.js", [317, 321], variant="multi-path-fallback", breakage_class="loud", notes="documents 4 distinct invocation paths: app.api.queuePrompt, app.queuePrompt, fetch /prompt, etc."),
ev("rohapa/comfyui-replay", "README.md", [497, 975], variant="call+fallback", breakage_class="loud", notes="app.queuePrompt(0,1) with raw fetch /prompt fallback"),
],
},
{
"pattern_id": "S5.A3",
"surface_family": "S5",
"surface": "api.addEventListener('execution_start' | 'execution_success' | 'execution_error' | 'execution_cached' | 'executing' | 'status' | 'reconnecting')",
"fingerprint": "api.addEventListener('execution_start', e => ...)",
"semantic": "extensions subscribe to backend execution lifecycle WebSocket events",
"v2_replacement": "ctx.execution.on('start' | 'success' | 'error' | 'cached', payload => ...) typed events",
"decision_ref": "Cross-references S5.A1 (existence-proof of events-everywhere)",
"test_target": "EXECUTION_LIFECYCLE_EVENTS",
"lifecycle_coupling": 0,
"severity": "HIGH",
"evidence_status": "swept",
"evidence": [
ev("zzw5516/ComfyUI-zw-tools", "entry/entry.js", [27, 28], variant="execution_start", breakage_class="loud"),
ev("flymyd/koishi-plugin-comfyui-client", "src/ComfyUINode.ts", 109, variant="execution_start-case", breakage_class="loud"),
ev("kyuz0/amd-strix-halo-comfyui-toolboxes", "scripts/benchmark_workflows.py", 52, variant="execution_start-message-type", breakage_class="loud"),
ev("philippjbauer/devint25-comfyui-api-demo", "README.md", [144, 179], variant="documented-event-list", breakage_class="loud"),
ev("philippjbauer/devint25-comfyui-api-demo", "Models/ComfyModels.cs", 159, variant="enum-of-event-names", breakage_class="loud", notes="C# wrapper enumerates the WebSocket event vocabulary as the public API"),
ev("huafitwjb/ComfyUI-GO-Mobile-app", "app/src/main/java/com/example/myapplication/util/Constants.kt", 26, variant="execution_success-const", breakage_class="loud"),
ev("hernantech/comfymcp", "src/comfymcp/client/types.py", 17, variant="execution_success-enum", breakage_class="loud"),
ev("choovin/comfyui-api", "README.md", [57, 1945], variant="execution_success-doc", breakage_class="loud", notes="explicit 'Sidecar-like tracing' depending on execution_* events as public API"),
],
},
]
def normalize_evidence_key(e):
return (e.get("repo"), e.get("file"), tuple(e.get("lines") or []))
def main():
db = yaml.safe_load(DB.read_text())
appended = 0
skipped = 0
for pid, new_evs in APPEND.items():
for p in db["patterns"]:
if p["pattern_id"] == pid:
existing = {normalize_evidence_key(e) for e in (p.get("evidence") or [])}
if "evidence" not in p or p["evidence"] is None:
p["evidence"] = []
for e in new_evs:
if normalize_evidence_key(e) in existing:
skipped += 1
continue
p["evidence"].append(e)
appended += 1
# Mark evidence_status as swept now that we've sourced real data
p["evidence_status"] = "swept"
break
else:
print(f"⚠️ pattern {pid} not found")
added_new = 0
existing_ids = {p["pattern_id"] for p in db["patterns"]}
for np in NEW_PATTERNS:
if np["pattern_id"] in existing_ids:
print(f"⚠️ pattern {np['pattern_id']} already exists — skipping")
continue
db["patterns"].append(np)
added_new += 1
db["meta"]["patterns_count"] = len(db["patterns"])
db["meta"]["sweep_status"] = "in-progress"
if "evidence-sweep-pass-1" not in db["meta"].get("sweeps_done", []):
db["meta"]["sweeps_done"].append("evidence-sweep-pass-1")
DB.write_text(yaml.safe_dump(db, sort_keys=False, width=200, allow_unicode=True))
print(f"✅ appended {appended} evidence rows ({skipped} dupes skipped)")
print(f"✅ added {added_new} new patterns")
print(f"✅ DB now has {len(db['patterns'])} patterns")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,67 @@
#!/usr/bin/env bash
# fetch-stars.sh — populate research/touch-points/star-cache.yaml
# Reads database.yaml, extracts unique repo: entries, queries gh api for stars.
# Usage: bash scripts/fetch-stars.sh
set -euo pipefail
DIR="$(cd "$(dirname "$0")" && pwd)"
DB="$DIR/../touch-points-database.yaml"
CACHE="$DIR/../touch-points-star-cache.yaml"
if ! command -v gh >/dev/null 2>&1; then
echo "❌ gh CLI not installed"
exit 1
fi
# Extract unique repo: entries from database
repos=$(grep -E '^\s*-\s*repo:\s' "$DB" | sed -E 's/^\s*-\s*repo:\s*//' | sort -u | grep -v '^$' || true)
today=$(date +%Y-%m-%d)
{
echo "# ───────────────────────────────────────────────────────────────────────"
echo "# GitHub star cache for repos referenced in database.yaml"
echo "# Refresh: bash scripts/fetch-stars.sh"
echo "# Asof dates allow drift detection"
echo "# ───────────────────────────────────────────────────────────────────────"
echo ""
echo "asof: $today"
echo "populated_via: scripts/fetch-stars.sh"
echo ""
echo "repos:"
} > "$CACHE.tmp"
count=0
err_count=0
for r in $repos; do
count=$((count + 1))
printf " [%3d] %s ... " "$count" "$r" >&2
if data=$(gh api "repos/$r" 2>/dev/null); then
stars=$(echo "$data" | jq -r '.stargazers_count')
archived=$(echo "$data" | jq -r '.archived')
forks=$(echo "$data" | jq -r '.forks_count')
last=$(echo "$data" | jq -r '.pushed_at' | cut -dT -f1)
echo "$stars" >&2
{
echo " - repo: $r"
echo " stars: $stars"
echo " archived: $archived"
echo " forks: $forks"
echo " last_commit: $last"
echo " asof: $today"
} >> "$CACHE.tmp"
else
err_count=$((err_count + 1))
echo "ERROR" >&2
{
echo " - repo: $r"
echo " stars: null"
echo " error: \"gh api failed (rate limit / repo missing / network)\""
echo " asof: $today"
} >> "$CACHE.tmp"
fi
done
mv "$CACHE.tmp" "$CACHE"
echo "" >&2
echo "✅ Wrote $CACHE$count repos, $err_count errors" >&2

View File

@@ -0,0 +1,221 @@
#!/usr/bin/env python3
# merge-staging-pass3.py — single-threaded merger for pass-3 staging files.
#
# Reads:
# research/touch-points/staging/r8-evidence.yaml (clone-grep)
# research/touch-points/staging/r9-security.yaml (security scan + proposed S16.* patterns)
# research/touch-points/staging/r9-guides.yaml (sanctioned surfaces from docs we ship)
# research/touch-points/staging/r9-cookiecutter.yaml (scaffolded = forced-public surfaces)
#
# Writes back to:
# research/touch-points/database.yaml
#
# Safe to re-run; per-(repo, file, lines) dedup is enforced.
# R8 evidence is capped at 6 rows per pattern (already capped per repo+pattern in producer).
#
# R9.popularity is metadata about repos, not evidence — skipped here.
# R9.qa is regression-scenario seeds for I-TF.3 — referenced but not merged into DB.
import sys
from pathlib import Path
import yaml
ROOT = Path(__file__).resolve().parent.parent
DB = ROOT / "research" / "touch-points" / "database.yaml"
STAGING = ROOT / "research" / "touch-points" / "staging"
R8 = STAGING / "r8-evidence.yaml"
R9_SEC = STAGING / "r9-security.yaml"
R9_GUIDES = STAGING / "r9-guides.yaml"
R9_CK = STAGING / "r9-cookiecutter.yaml"
CAP_PER_PATTERN_FROM_R8 = 8 # adjust if DB explodes
def normalize_lines(lines):
if isinstance(lines, str):
# R8 emitted strings like "[119, 131]" — convert
try:
return tuple(eval(lines, {"__builtins__": {}}, {}))
except Exception:
return (lines,)
if isinstance(lines, list):
return tuple(lines)
return (lines,)
def evkey(e):
return (e.get("repo"), e.get("file"), normalize_lines(e.get("lines")))
def append_dedup(target_evidence, new_rows, cap=None):
existing = {evkey(e) for e in target_evidence}
appended = 0
skipped = 0
rows_to_consider = list(new_rows)
if cap and len(rows_to_consider) > cap:
# Prefer rows from higher-star repos when capping.
# Order is producer-defined; keep first `cap`.
rows_to_consider = rows_to_consider[:cap]
for e in rows_to_consider:
# Normalize line representation
if isinstance(e.get("lines"), str):
e["lines"] = list(normalize_lines(e["lines"]))
if evkey(e) in existing:
skipped += 1
continue
target_evidence.append(e)
existing.add(evkey(e))
appended += 1
return appended, skipped
def main():
db = yaml.safe_load(DB.read_text())
patterns_by_id = {p["pattern_id"]: p for p in db["patterns"]}
total_appended = 0
total_skipped = 0
new_patterns_added = 0
# ─── R8 (clone-grep) ────────────────────────────────────────────
r8 = yaml.safe_load(R8.read_text())
print(f"R8: {sum(len(v) for v in r8.values())} total rows across {len(r8)} patterns")
for pid, rows in r8.items():
if pid not in patterns_by_id:
print(f" ⚠️ R8 pattern {pid} not in DB — skipping")
continue
p = patterns_by_id[pid]
if "evidence" not in p or p["evidence"] is None:
p["evidence"] = []
a, s = append_dedup(p["evidence"], rows, cap=CAP_PER_PATTERN_FROM_R8)
total_appended += a
total_skipped += s
p["evidence_status"] = "swept"
# ─── R9.security: proposed S16.* patterns ───────────────────────
sec = yaml.safe_load(R9_SEC.read_text())
for sp in sec.get("proposed_patterns", []):
pid = sp.get("proposed_pattern_id")
if not pid:
continue
if pid in patterns_by_id:
print(f" R9.sec pattern {pid} already exists — appending evidence only")
target = patterns_by_id[pid]
else:
# Materialize the new pattern
new_p = {
"pattern_id": pid,
"surface_family": sp.get("surface_family", "S16"),
"surface": sp.get("surface", ""),
"fingerprint": sp.get("fingerprint", ""),
"semantic": sp.get("semantic", ""),
"v2_replacement": sp.get("v2_replacement", ""),
"decision_ref": sp.get("rationale", ""),
"test_target": sp.get("test_target", ""),
"lifecycle_coupling": 0,
"severity": "MEDIUM",
"evidence_status": "swept",
"evidence": [],
}
db["patterns"].append(new_p)
patterns_by_id[pid] = new_p
target = new_p
new_patterns_added += 1
print(f" R9.sec NEW pattern {pid}: {sp.get('surface', '')[:60]}")
# Materialize evidence rows from R9.sec
evidence_field = sp.get("evidence")
if isinstance(evidence_field, str):
try:
evidence_field = eval(evidence_field, {"__builtins__": {}}, {})
except Exception:
evidence_field = []
if not isinstance(evidence_field, list):
evidence_field = []
rows = []
for e in evidence_field:
if not isinstance(e, dict):
continue
rows.append({
"pattern_id": pid,
"repo": e.get("repo", "unknown"),
"file": e.get("file", "unknown"),
"lines": e.get("lines", [1]) if isinstance(e.get("lines"), (list, int)) else [1],
"url": e.get("url", ""),
"rule": e.get("rule", ""),
"source": "security",
"variant": e.get("rule", "yara/bandit-hit"),
})
a, s = append_dedup(target["evidence"], rows)
total_appended += a
total_skipped += s
# ─── R9.cookiecutter: scaffolded surfaces ───────────────────────
ck = yaml.safe_load(R9_CK.read_text())
for entry in ck.get("scaffold_surfaces", []):
pid = entry.get("pattern_id")
if not pid or pid not in patterns_by_id:
continue
target = patterns_by_id[pid]
if "evidence" not in target or target["evidence"] is None:
target["evidence"] = []
rows = [{
"pattern_id": pid,
"repo": "cookiecutter-comfy-extension",
"file": entry.get("template_file", "unknown"),
"lines": entry.get("lines", [1]),
"url": "",
"source": "cookiecutter",
"variant": "scaffolded-by-default",
"excerpt": entry.get("excerpt", ""),
"notes": "FORCED-PUBLIC: this surface is generated by the default scaffold, so v2 cannot break it without breaking new-extension onboarding",
}]
a, s = append_dedup(target["evidence"], rows)
total_appended += a
total_skipped += s
# ─── R9.guides: surfaces we teach in docs ───────────────────────
guides = yaml.safe_load(R9_GUIDES.read_text())
for entry in guides.get("sanctioned_surfaces", []):
pid = entry.get("pattern_id")
if not pid or pid not in patterns_by_id:
continue
target = patterns_by_id[pid]
if "evidence" not in target or target["evidence"] is None:
target["evidence"] = []
rows = [{
"pattern_id": pid,
"repo": "comfyanonymous/custom-nodes-guides",
"file": entry.get("taught_in", "unknown"),
"lines": entry.get("lines", [1]),
"url": "",
"source": "guides",
"variant": "taught-in-official-docs",
"excerpt": entry.get("excerpt", ""),
"notes": "SANCTIONED-PUBLIC: this surface is taught in official docs we ship, so v2 must keep it stable",
}]
a, s = append_dedup(target["evidence"], rows)
total_appended += a
total_skipped += s
# ─── Update meta ────────────────────────────────────────────────
db["meta"]["patterns_count"] = len(db["patterns"])
db["meta"]["sweep_status"] = "in-progress"
sweeps = db["meta"].setdefault("sweeps_done", [])
if "evidence-sweep-pass-3" not in sweeps:
sweeps.append("evidence-sweep-pass-3")
DB.write_text(yaml.safe_dump(db, sort_keys=False, width=200, allow_unicode=True))
total_evidence = sum(len(p.get("evidence") or []) for p in db["patterns"])
print()
print(f"✅ appended {total_appended} rows ({total_skipped} dupes skipped)")
print(f"✅ added {new_patterns_added} new patterns")
print(f"✅ DB now: {len(db['patterns'])} patterns, {total_evidence} evidence rows")
return 0
if __name__ == "__main__":
sys.exit(main())

View File

@@ -0,0 +1,121 @@
#!/usr/bin/env python3
# rollup-blast-radius.py — compute per-pattern blast-radius metrics from
# database.yaml + star-cache.yaml, write to research/touch-points/rollup.yaml.
#
# Blast-radius formula (per PLAN.md):
# br = (log10(1 + cumulative_stars)) * w_stars (default 1.0)
# + (log10(1 + occurrence_count)) * w_occ (default 0.7)
# + (signature_count - 1) * w_sig (default 0.5)
# + silent_breakage_weight * w_silent (default 0.5)
# + lifecycle_coupling_weight * w_lifecycle (default 0.4)
#
# silent_breakage_weight & lifecycle_coupling_weight come from the per-pattern
# heuristics field; if absent they default to 0.
import math
import sys
from pathlib import Path
import yaml
ROOT = Path(__file__).resolve().parent.parent
DB = ROOT / "touch-points-database.yaml"
STARS = ROOT / "touch-points-star-cache.yaml"
OUT = ROOT / "touch-points-rollup.yaml"
W = {
"stars": 1.0,
"occ": 0.7,
"sig": 0.5,
"silent": 0.5,
"lifecycle": 0.4,
}
def load_stars() -> dict[str, int]:
if not STARS.exists():
return {}
cache = yaml.safe_load(STARS.read_text())
out = {}
for r in cache.get("repos", []) or []:
if r.get("stars") is not None:
out[r["repo"]] = int(r["stars"])
return out
def main() -> int:
db = yaml.safe_load(DB.read_text())
stars = load_stars()
rows = []
for p in db.get("patterns", []) or []:
evidence = p.get("evidence") or []
repos = []
for e in evidence:
r = e.get("repo")
if r:
repos.append(r)
unique_repos = sorted(set(repos))
cum_stars = sum(stars.get(r, 0) for r in unique_repos)
occ = len(evidence)
sig_count = p.get("signature_count") or len(p.get("signatures") or []) or 1
# Pattern fields can be top-level or under 'heuristics'
h = p.get("heuristics") or {}
sev_map = {"CRITICAL": 2, "HIGH": 1.5, "MEDIUM": 1, "LOW": 0.5}
silent_w = float(h.get("silent_breakage", sev_map.get(p.get("severity", ""), 0)))
life_w = float(h.get("lifecycle_coupling", p.get("lifecycle_coupling", 0)))
br = (
math.log10(1 + cum_stars) * W["stars"]
+ math.log10(1 + occ) * W["occ"]
+ max(0, sig_count - 1) * W["sig"]
+ silent_w * W["silent"]
+ life_w * W["lifecycle"]
)
rows.append(
{
"pattern_id": p["pattern_id"],
"surface_family": p.get("surface_family"),
"name": p.get("name") or p.get("surface") or p.get("semantic_intent") or p.get("semantic"),
"occurrences": occ,
"unique_repos": len(unique_repos),
"cumulative_stars": cum_stars,
"signature_count": sig_count,
"silent_breakage": silent_w,
"lifecycle_coupling": life_w,
"blast_radius": round(br, 3),
"top_repos": [
{"repo": r, "stars": stars.get(r, 0)}
for r in sorted(unique_repos, key=lambda x: -stars.get(x, 0))[:5]
],
}
)
rows.sort(key=lambda r: -r["blast_radius"])
out = {
"meta": {
"generated_from": ["database.yaml", "star-cache.yaml"],
"weights": W,
"patterns_count": len(rows),
},
"patterns": rows,
}
OUT.write_text(yaml.safe_dump(out, sort_keys=False, width=120))
print(f"✅ wrote {OUT.relative_to(ROOT)} ({len(rows)} patterns)")
print()
print("Top 12 by blast radius:")
print(f" {'rank':>4} {'br':>6} {'★sum':>6} {'occ':>3} {'sig':>3} pattern")
for i, r in enumerate(rows[:12], 1):
print(
f" {i:>4} {r['blast_radius']:>6.2f} {r['cumulative_stars']:>6} "
f"{r['occurrences']:>3} {r['signature_count']:>3} {r['pattern_id']} {r['name']}"
)
return 0
if __name__ == "__main__":
sys.exit(main())

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,313 @@
---
source: in-house (no external URL — synthesized from R4 + database.yaml + meeting transcript)
date_accessed: 2026-05-06
created: 2026-05-06
purpose: Plan + schema for the canonical touch-point database
status: active
---
# Touch-Point Database — Plan
## Why we are building this
The v2 extension API redesign (P1, D3.x) and the eventual test framework need a **shared evidence layer**: every API surface that real-world extensions touch, frequency-weighted by usage, with citations to verify. Without this, two failure modes are guaranteed:
1. **Silent regressions in v2.** Surfaces we don't know about can't be re-implemented or formally deprecated. The v2 service ships, big custom-node packs break, ComfyUI looks unstable.
2. **Test framework with the wrong floor.** Tests that don't reflect real extension shapes will pass v2 while production extensions break.
The database is the input for:
- v2 API gap analysis (D4 G1G13, plus future Gs surfaced here)
- Test framework design (widget-api-thoughts.md "Test Framework" section): every entry maps to ≥1 test case
- Migration guide writing (P3, DEP3, DEP4)
- "What can we actually delete" decisions (e.g., R4 found `loadedGraphNode` has 1 real call site)
## What the v2 POC shipped (CONTEXT for the audit)
There are 5 untracked v2 files in `ComfyUI_frontend` worktree (proof-of-concept):
- `src/types/extensionV2.ts``NodeHandle`, `WidgetHandle`, `defineNodeExtension`, `defineWidgetExtension` interfaces
- `src/services/extensionV2Service.ts` — scope registry, reactive mount system, handle factories (with inline open-question comments)
- `src/extensions/core/dynamicPrompts.v2.ts` — POC migration
- `src/extensions/core/imageCrop.v2.ts` — POC migration (13→12 lines)
- `src/extensions/core/previewAny.v2.ts` — POC migration (90→35 lines)
**Open questions left in v2 service comments** (touch-points must answer these):
- `setLabel` — special vs just an option? `setHidden` — same?
- `on('change')` watches `WidgetValue.value` only — how do extensions watch options/props?
- `setSerializeValue` callback — should be `on('serialize')` or `onBeforeSerialize`?
- Get/set vs getters/setters — should NodeHandle expose `get pos()` accessors?
- `getProperties` — current `properties` bag is heavily used by extensions for "persist across teardown"; v2 must verify that pattern still works
- `addWidget` returns by what mechanism? sync dispatch? promise?
- Widget figler tree / coverage report of "strangler-figged vs re-implemented vs unsupported"
These open questions become *test cases*: for each, the database tells us how many extensions in the wild touch the underlying surface.
## Comprehensive surface enumeration
The audit covers **8 surface families**. Each family contains specific patterns to search for.
### S1 — `ComfyExtension` lifecycle hooks (17 hooks)
From `src/types/comfy.ts`, lines 144-266:
| Hook | Core extension files using it | Replacement direction |
|---|---:|---|
| `init` | 16 | unchanged in v2 (ExtensionOptions.init) |
| `setup` | 3 | unchanged in v2 (ExtensionOptions.setup) |
| `addCustomNodeDefs` | 1 | unknown — may need v2 registration API |
| `getCustomWidgets` | 4 | replaced by `defineWidgetExtension` |
| `beforeRegisterNodeDef` | 10 | replaced by `nodeTypes` filter + `inspectNodeDef` (G1) |
| `beforeRegisterVueAppNodeDefs` | 0 | candidate for removal |
| `registerCustomNodes` | 3 | NO v2 equivalent (D4-G2 BLOCKER) |
| `loadedGraphNode` | 0 (core), 1 (entire wild corpus) | candidate for removal |
| `nodeCreated` | 12 | `defineNodeExtension({ nodeCreated })` |
| `beforeConfigureGraph` | 1 | needs decision — graph lifecycle hook |
| `afterConfigureGraph` | 0 | candidate for removal |
| `getSelectionToolboxCommands` | 0 | candidate for removal |
| `getCanvasMenuItems` | 4 | EXISTS — replaces canvas right-click monkey-patching |
| `getNodeMenuItems` | 4 | EXISTS — replaces node right-click monkey-patching (P6 in R4) |
| `onAuthUserResolved` | 1 | unchanged |
| `onAuthTokenRefreshed` | 1 | unchanged |
| `onAuthUserLogout` | 1 | unchanged |
### S2 — `LGraphNode.prototype` methods commonly patched
Already-confirmed (R4): `onNodeCreated`, `onExecuted`, `onConnectionsChange`, `onRemoved`, `getExtraMenuOptions`, `convertWidgetToInput`, `onGraphConfigured`, `onConfigure`, `onInputDblClick`.
Add to search: `onAdded`, `onSerialize`, `onDeserialize`, `onDrawForeground`, `onDrawBackground`, `onSelected`, `onDeselected`, `onMouseDown`, `onMouseEnter`, `onMouseLeave`, `onDblClick`, `onPropertyChanged`, `onWidgetChanged`, `onResize`, `onAction`, `onConnectInput`, `onConnectOutput`, `onConfigure`, `onWorkflowConfigure`, `onConnectionsChange`, `onConfigure`, `onCreate`, `clone`, `computeSize`.
### S3 — `LGraphCanvas.prototype` methods commonly patched
Confirmed (R4 P7): `processKey`, `processContextMenu`, `computeVisibleNodes`. Our own core: `processMouseDown`, `processMouseMove` (simpleTouchSupport.ts).
Add to search: `drawNode`, `drawNodeShape`, `drawConnections`, `onMouseDown`, `onDblClick`, `getCanvasMenuOptions`, `getNodeMenuOptions`, `getGroupMenuOptions`, `processNodeWidgets`, `selectNodes`, `deselectAllNodes`, `setSelectedNodes`.
### S4 — Widget-level patterns (the heart of widget-api-thoughts.md)
- `.callback` chaining (R4 P1) — the dominant value-change pattern
- `.value` direct reads/writes (R4 evidence: imageCompare, widgetInputs, customWidgets, saveImageExtraOutput)
- `.serializeValue` assignment (dynamicPrompts.v2 uses it)
- `.options.*` direct mutation
- `.computedHeight`, `.y`, `.last_y` — layout-level reads
- `.options.values` — combo widget values
- `.options.serialize`, `.options.hidden`, `.options.readonly` — option flags
- Custom widget types declared via `getCustomWidgets`
- `addDOMWidget(name, type, element, options)` — DOM widget contribution (R4 P9)
**Widget thoughts file flags lifecycle dependencies** (widget-api-thoughts.md:25-30):
- 3D widgets: file uploads
- Webcam widgets: heavy perf
- Webcam widgets: lifecycle-dependent serialization
- Widgets whose post-serialize value depends on lifecycle steps
These need explicit DB entries with `lifecycle_dependent: true` flag.
### S5 — `ComfyApi` / `app.api` event surfaces
Confirmed (R4 P8): `addEventListener('executed', …)`, custom `'extName.eventName'` events.
Add to search: `addEventListener('executing', …)`, `'progress'`, `'progress_state'`, `'status'`, `'reconnecting'`, `'reconnected'`, `'execution_start'`, `'execution_success'`, `'execution_error'`, `'execution_cached'`, `'b_preview'`, `'logs'`.
### S6 — `ComfyApp` god-object touch points
- `app.graph` — direct LiteGraph object access
- `app.canvas` — direct LGraphCanvas access
- `app.canvasManager` — newer wrapper
- `app.queuePrompt` — submit a workflow
- `app.graphToPrompt` — serialize current graph to API payload
- `app.loadGraphData` — load a workflow JSON
- `app.extensionManager` — ExtensionManager registry access
- `app.api` — see S5
- `app.getNodeDefs` — node definition registry
- `app.registerExtension` — the entry point itself
- `app.ui` — legacy UI shim
### S7 — Window / global escape hatches
- `window.app` — escape hatch documented in index.ts
- `window.graph` — escape hatch documented in index.ts
- `window.LiteGraph` — direct LiteGraph access
- `window.LGraphCanvas` — direct canvas class access
- `window.comfyAPI.modules[...]` — production-only shim mechanism (per extension-development-guide.md)
### S8 — Special node properties (magic flags)
- `nodeType.prototype.isVirtualNode` (R4 P10) — virtual node flag
- `nodeType.prototype.serialize_widgets` — serialization toggle
- `nodeType.prototype.color`, `bgcolor` — visual override
- `nodeType.prototype.shape` — node shape override
- `nodeType['@<input>']` — input-type metadata (Eclipse pattern)
- `nodeType.category` — menu category override
### S9 — Non-Node entity kinds (per ADR 0008)
ADR 0008 enumerates **six** entity kinds; the bulk of the ecosystem touches more than just `Node` and `Widget`. These touch points are largely undocumented in the v1 extension API.
- **Reroute** (`Reroute`, `RerouteId`) — `LiteGraph.createRerouteOnLink`, `graph.reroutes`, `node.connectByRerouteId`
- **Group** (`LGraphGroup`) — `graph.groups`, `group.color`, `group.font`, `group.font_size`, `group.children`
- **Link** (`LLink`, `LinkId`) — `link.color`, `link._pos`, `link._dragging`, `link.data`
- **Slot** (`SlotBase` / `INodeInputSlot` / `INodeOutputSlot`) — `slot.color_on/_off`, `slot.shape`, `slot.dir`, `slot.localized_name`
- **Subgraph virtual nodes** — set/get virtual node trick (KJNodes), `nodeType.isVirtualNode = true` (S8) coupled with `graphToPrompt` rewriting (S6.A1)
### S10 — Dynamic node API (slot/connection mutation at runtime)
- `node.addInput(name, type)` / `node.removeInput(slot)` — runtime input mutation (typically inside `onConnectionsChange`)
- `node.addOutput(name, type)` / `node.removeOutput(slot)` — runtime output mutation
- `node.connect(srcSlot, target, dstSlot)` / `node.disconnectInput(slot)` / `node.disconnectOutput(slot)` — programmatic linking
- `node.findOutputSlot(name)` / `node.findInputSlot(name)` — slot lookup by name
- `node.setDirtyCanvas(true, true)` — force redraw (extremely common after any mutation)
- `node.collapse()` / `node.setSize([w,h])` — imperative geometry
### S11 — Graph-level state and change-tracking
- `graph._version++` and `graph._version` reads — change-tracking signal **(project AGENTS.md §5: affects 40+ repos)**
- `graph.add(node)` / `graph.remove(node)` / `graph.findNodesByType(type)` / `graph.findNodeById(id)`
- `graph.serialize()` / `graph.configure(json)` — full-graph serialization (related to S6.A1 graphToPrompt but distinct)
- `graph.beforeChange()` / `graph.afterChange()` — explicit batching seam
- `graph.onNodeAdded` / `graph.onNodeRemoved` / `graph.onNodeConnectionChange` — graph-level callbacks (vs per-node)
### S12 — Shell UI registries (sidebar / bottom panel / commands / toasts)
These are *declarative* surfaces in v1 (extensions push registrations) but their semantics are still public API. Migration must preserve names and contracts.
- `extensionManager.registerSidebarTab(...)``SidebarTabExtension`
- `extensionManager.registerBottomPanelTab(...)``BottomPanelExtension`
- `commandManager.registerCommand(...)``CommandManager`
- `toastManager.add(...)` / `toastManager.remove(...)``ToastManager`
- `app.registerExtension({ settings: [...] })` — Settings system contributions
- `app.registerExtension({ keybindings: [...] })` — Keybinding contributions
- `app.registerExtension({ commands: [...], menuCommands: [...] })` — Menu/command contributions
### S13 — Schema interpretation (`ComfyNodeDef` / `InputSpec`)
Extensions inspect the node-def schema directly to drive UI/behavior — this is a public API by accident.
- `nodeData.input.required` / `nodeData.input.optional` / `nodeData.input.hidden` — input bag inspection
- `nodeData.output[]` / `nodeData.output_name[]` / `nodeData.output_is_list[]` — output schema inspection
- `nodeData.output_node` — special "output node" boolean flag
- `nodeData.category` / `nodeData.python_module` — origin metadata
- `InputSpec` sentinel objects — `["INT", { default, min, max, step }]`, `["STRING", { multiline }]`, `["COMBO", { values, default }]`, `["IMAGEUPLOAD", {...}]`, etc.
### S14 — Identity / Locator scheme
- `NodeLocatorId` — encodes `(graphScope, nodeId)` for cross-subgraph references
- `NodeExecutionId` — backend execution-graph identifier
- `parseNodeLocatorId` / `createNodeLocatorId` / `isNodeLocatorId` — public helpers exported from `src/types/index.ts`
- Implicit pattern: extensions resolve "node X in subgraph Y" — must work after subgraph promotion
### S15 — Output system (per `widget-api-thoughts.md`)
`widget-api-thoughts.md` flags this as a separate change axis from widgets:
- Dynamic output mutation via `node.addOutput` / `node.removeOutput` (cross-references S10)
- Schema-declared outputs (preferred end-state) — `OUTPUT_TYPES`-style explicit declaration
- `nodeData.output_node` flag — node is a terminal/sink
- `node.onExecuted({ images: [...] })` — output-display pattern (cross-references S2.N2)
- "Force declaration" goal: extensions must declare output types in the node schema, not mutate at runtime
## Database schema
Each entry is a YAML record:
```yaml
- pattern_id: P1.1 # stable ID for cross-reference
surface_family: S4 # S1-S8
surface: "widget.callback assignment" # human-readable name
fingerprint: 'w.callback = function(v) {...}' # regex-ish
semantic: "subscribe to widget value change" # what extensions are *trying* to do
v2_replacement: "widget.on('change', fn)" # proposed
decision_ref: D3.3 # which decision doc covers it
test_target: WIDGET_VALUE_CHANGE_LISTENER # test framework symbol
evidence:
- repo: crom8505/ComfyUI-Dynamic-Sigmas
file: web/js/graph_sigmas.js
lines: [79, 80]
url: https://github.com/crom8505/ComfyUI-Dynamic-Sigmas/blob/main/web/js/graph_sigmas.js#L79
stars: 12 # github stars (cached, asof date)
stars_asof: 2026-05-06
variant: canonical # canonical | unsafe | with-bind | tempCallback-swap | per-instance | prototype
breakage_class: silent # silent | loud | undefined-behavior | crash
notes: "fourteen instances in same file"
derived:
occurrences: 7 # rolled up from evidence
repos_touched: 5
cumulative_stars: 245
canonical_signatures: 1 # how many distinct shapes seen (P4 had 6 for onConnectionsChange!)
breakage_classes: [silent, undefined-behavior]
blast_radius: 3.2 # see formula
```
## Blast-radius scoring formula
Goal: rank patterns by how disruptive their breakage would be in v2 rollout.
```
blast_radius = 0.40 * log10(1 + cumulative_stars)
+ 0.20 * log10(1 + occurrences)
+ 0.15 * canonical_signatures # more shapes = more migration cases to support
+ 0.15 * silent_breakage_weight # silent > loud > crash for danger
+ 0.10 * lifecycle_coupling # 0/1/2; widgets that break on serialize timing get 2
```
Where:
- `silent_breakage_weight` = max over evidence: silent=1.0, undefined=0.6, loud=0.3, crash=0.2
- `lifecycle_coupling` = 0 (none) | 1 (depends on init/teardown order) | 2 (depends on serialization-timing or DOM-mount-timing)
Rationale:
- `log10` on stars + occurrences damps mega-popular packs from drowning out long-tail diversity
- Silent breakage scores higher than loud — these are the ones that destroy trust
- Lifecycle coupling captures widget-api-thoughts.md concerns (3D, webcam)
- Canonical signatures captures "the API has no schema" risk (R4 P4 with 6 sigs)
A blast_radius ≥ 3.0 = MUST have a v1-compat shim or the migration story breaks.
## Star-fetching strategy
For each unique repo:
```bash
gh api "repos/<owner>/<name>" --jq '.stargazers_count'
```
Cache in `research/touch-points/star-cache.yaml`:
```yaml
- repo: crom8505/ComfyUI-Dynamic-Sigmas
stars: 12
asof: 2026-05-06
```
Refresh quarterly. If gh CLI errors (rate limit, repo gone), record `stars: null` and `error: <reason>`.
## Workflow
1. **Plan + schema (this doc)**
2. **Build initial database** — start with the 12 patterns from R4, structured properly
3. **Sweep S1S8 systematically** — batched code search, populate evidence
4. **Star fetch pass**`gh api` for every unique repo, populate cache
5. **Compute derived fields** — script that rolls up evidence into derived metrics
6. **Generate ranked report**`database-by-blast-radius.md`
7. **Map to test framework** — each pattern_id → test symbol
## Dispatch strategy for queries
- ~50 queries needed across S1S8 (each surface gets 1-3 queries)
- Run in parallel batches of 4-6 (MCP tolerates this if no DNS error)
- Retry failed queries with 3-token reformulations (R4 workaround)
- After each batch: append findings to `database.yaml`, never overwrite
- After full sweep: run star-fetch script, run roll-up script
## Integration with the test framework
Each pattern in the database becomes a test triple:
1. **v1 contract test** (legacy): proves the v1 hook still works for shimmed extensions
2. **v2 contract test** (new): proves the v2 replacement covers the same semantic
3. **Migration test**: takes a real extension snippet from evidence, confirms it works in v2 (or fails with a documented compat error)
The test framework's "compatibility floor" is: every blast_radius ≥ 2.0 entry MUST pass all three tests before v2 ships.
## Out of scope (deferred)
- Sandboxing model (Chrome-extension-style isolation): noted in CONTEXT.md, deferred
- Performance benchmarks vs v1: separate workstream
- Documentation generation from the database: separate workstream
- npm package design for `@comfyui/extension-api`: separate workstream (per R4 P11 finding)

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,724 @@
# ───────────────────────────────────────────────────────────────────────
# GitHub star cache for repos referenced in database.yaml
# Refresh: bash scripts/fetch-stars.sh
# Asof dates allow drift detection
# ───────────────────────────────────────────────────────────────────────
asof: 2026-05-08
populated_via: scripts/fetch-stars.sh
repos:
- repo: 40740/ComfyUI_LayerStyle_Bmss
stars: 0
archived: false
forks: 0
last_commit: 2024-10-16
asof: 2026-05-08
- repo: 834t/ComfyUI_834t_scene_composer
stars: 5
archived: false
forks: 1
last_commit: 2026-04-03
asof: 2026-05-08
- repo: aicocoa981/WhatDreamsCost-ComfyUI-private
stars: 1
archived: false
forks: 0
last_commit: 2026-03-30
asof: 2026-05-08
- repo: AIGODLIKE/AIGODLIKE-ComfyUI-Studio
stars: 405
archived: false
forks: 25
last_commit: 2025-10-27
asof: 2026-05-08
- repo: akawana/ComfyUI-Folded-Prompts
stars: 4
archived: false
forks: 2
last_commit: 2026-04-30
asof: 2026-05-08
- repo: AkihaTatsu/ComfyUI-Simple-Utility-Nodes
stars: 0
archived: false
forks: 0
last_commit: 2026-04-11
asof: 2026-05-08
- repo: alankent/ComfyUI-OA-360-Clip
stars: 0
archived: false
forks: 1
last_commit: 2025-11-16
asof: 2026-05-08
- repo: AlexZ1967/ComfyUI_ALEXZ_tools
stars: 0
archived: false
forks: 0
last_commit: 2026-05-08
asof: 2026-05-08
- repo: ameliacode/comfyui-face3d
stars: 0
archived: false
forks: 0
last_commit: 2026-04-23
asof: 2026-05-08
- repo: andreszs/ComfyUI-Ultralytics-Studio
stars: 3
archived: false
forks: 1
last_commit: 2026-04-13
asof: 2026-05-08
- repo: ArtHommage/HommageTools
stars: 4
archived: false
forks: 1
last_commit: 2025-05-20
asof: 2026-05-08
- repo: Azornes/Comfyui-LayerForge
stars: 312
archived: false
forks: 16
last_commit: 2026-05-01
asof: 2026-05-08
- repo: becky3/comfyui-workspace
stars: 0
archived: false
forks: 0
last_commit: 2026-04-13
asof: 2026-05-08
- repo: BennyKok/comfyui-deploy
stars: 1508
archived: false
forks: 222
last_commit: 2025-11-13
asof: 2026-05-08
- repo: brycecovert/ComfyUI-compass-images
stars: 0
archived: false
forks: 0
last_commit: 2026-04-23
asof: 2026-05-08
- repo: choovin/comfyui-api
stars: 3
archived: false
forks: 0
last_commit: 2026-03-08
asof: 2026-05-08
- repo: chyer/Chye-ComfyUI-Toolset
stars: 0
archived: false
forks: 1
last_commit: 2026-03-10
asof: 2026-05-08
- repo: coeuskoalemoss/comfyUI-layerstyle-custom
stars: 0
archived: false
forks: 0
last_commit: 2025-06-23
asof: 2026-05-08
- repo: ComfyNodePRs/PR-comfyui-pkg39-ccab78b5
stars: 0
archived: false
forks: 0
last_commit: 2024-07-31
asof: 2026-05-08
- repo: Comfy-Org/ComfyUI_frontend
stars: 1787
archived: false
forks: 563
last_commit: 2026-05-08
asof: 2026-05-08
- repo: Comfy-Org/ComfyUI-Manager
stars: 14564
archived: false
forks: 2187
last_commit: 2026-05-08
asof: 2026-05-08
- repo: Comfy-Org/ComfyUI-test-framework
stars: 2
archived: false
forks: 1
last_commit: 2026-03-23
asof: 2026-05-08
- repo: ComfyUI-Kelin/ComfyUI_Image_Anything
stars: 3
archived: false
forks: 0
last_commit: 2026-04-20
asof: 2026-05-08
- repo: Creepybits/ComfyUI-Creepy_nodes
stars: 29
archived: false
forks: 5
last_commit: 2026-04-14
asof: 2026-05-08
- repo: criskb/Comfypencil
stars: 0
archived: false
forks: 1
last_commit: 2026-04-13
asof: 2026-05-08
- repo: criskb/Fancy_Grid
stars: 0
archived: false
forks: 1
last_commit: 2026-04-13
asof: 2026-05-08
- repo: crom8505/ComfyUI-Dynamic-Sigmas
stars: 8
archived: false
forks: 2
last_commit: 2026-03-30
asof: 2026-05-08
- repo: Damkohler/jlc-comfyui-nodes
stars: 16
archived: false
forks: 4
last_commit: 2026-04-17
asof: 2026-05-08
- repo: darth-veitcher/comfyui-ollama-model-manager
stars: 1
archived: false
forks: 1
last_commit: 2025-11-05
asof: 2026-05-08
- repo: DazzleNodes/ComfyUI-Smart-Resolution-Calc
stars: 7
archived: false
forks: 2
last_commit: 2026-04-22
asof: 2026-05-08
- repo: diodiogod/TTS-Audio-Suite
stars: 911
archived: false
forks: 101
last_commit: 2026-05-08
asof: 2026-05-08
- repo: dorpxam/ComfyUI-LTX2-Microscope
stars: 4
archived: false
forks: 1
last_commit: 2026-04-23
asof: 2026-05-08
- repo: DumiFlex/ComfyUI-Wildcard-Pipeline
stars: 4
archived: false
forks: 2
last_commit: 2026-04-08
asof: 2026-05-08
- repo: egormly/ComfyUI-EG_Tools
stars: 0
archived: false
forks: 0
last_commit: 2025-11-19
asof: 2026-05-08
- repo: EmanuelRiquelme/comfyui-art-venture
stars: 0
archived: false
forks: 0
last_commit: 2024-09-04
asof: 2026-05-08
- repo: EnragedAntelope/EA_LMStudio
stars: 7
archived: false
forks: 4
last_commit: 2026-04-22
asof: 2026-05-08
- repo: Firetheft/ComfyUI-Animate-Progress
stars: 3
archived: false
forks: 1
last_commit: 2025-09-09
asof: 2026-05-08
- repo: FloyoAI/ComfyUI-SoundFlow
stars: 0
archived: false
forks: 0
last_commit: 2025-11-21
asof: 2026-05-08
- repo: flymyd/koishi-plugin-comfyui-client
stars: 0
archived: false
forks: 1
last_commit: 2025-08-21
asof: 2026-05-08
- repo: FunnyFinger/Dynamic_Sliders_stack
stars: 4
archived: false
forks: 2
last_commit: 2025-04-22
asof: 2026-05-08
- repo: gigici/ComfyUI_BlendPack
stars: 1
archived: false
forks: 0
last_commit: 2026-05-08
asof: 2026-05-08
- repo: goodtab/ComfyUI-Custom-Scripts
stars: 0
archived: false
forks: 0
last_commit: 2024-09-02
asof: 2026-05-08
- repo: guido-gfv/gfv_pro_upgrade
stars: 0
archived: false
forks: 0
last_commit: 2026-04-20
asof: 2026-05-08
- repo: haohaocreates/PR-rk-comfy-nodes-36d8f0a5
stars: 0
archived: false
forks: 1
last_commit: 2024-05-22
asof: 2026-05-08
- repo: hernantech/comfymcp
stars: 0
archived: false
forks: 0
last_commit: 2026-01-03
asof: 2026-05-08
- repo: hhayiyuan/ComfyUI-FFmpegURLMedia
stars: 2
archived: false
forks: 2
last_commit: 2026-01-02
asof: 2026-05-08
- repo: huafitwjb/ComfyUI-GO-Mobile-app
stars: 6
archived: false
forks: 1
last_commit: 2026-03-04
asof: 2026-05-08
- repo: ialhabbal/compare
stars: 1
archived: false
forks: 2
last_commit: 2026-03-31
asof: 2026-05-08
- repo: IAMCCS/IAMCCS-nodes
stars: 92
archived: false
forks: 6
last_commit: 2026-05-04
asof: 2026-05-08
- repo: IXIWORKS-KIMJUNGHO/comfyui-ixiworks-tools
stars: 1
archived: false
forks: 2
last_commit: 2026-03-15
asof: 2026-05-08
- repo: JichaoLiang/Immortal_comfyui_public
stars: 0
archived: false
forks: 0
last_commit: 2025-12-05
asof: 2026-05-08
- repo: jiekouai/ComfyUI-JieKou-API
stars: 1
archived: false
forks: 0
last_commit: 2026-03-25
asof: 2026-05-08
- repo: jonstreeter/comfyui-compressed-metadata
stars: 0
archived: false
forks: 1
last_commit: 2025-10-12
asof: 2026-05-08
- repo: ketle-man/ComfyUI-Workflow-Studio
stars: 2
archived: false
forks: 0
last_commit: 2026-04-28
asof: 2026-05-08
- repo: kijai/ComfyUI-KJNodes
stars: 2569
archived: false
forks: 292
last_commit: 2026-05-08
asof: 2026-05-08
- repo: koshimazaki/ComfyUI-Koshi-Nodes
stars: 0
archived: false
forks: 0
last_commit: 2026-03-12
asof: 2026-05-08
- repo: krismasdev/ComfyUI-Flux-Continuum
stars: 0
archived: false
forks: 0
last_commit: 2025-08-04
asof: 2026-05-08
- repo: KumihoIO/kumiho-plugins
stars: 2
archived: false
forks: 0
last_commit: 2026-03-28
asof: 2026-05-08
- repo: kyuz0/amd-strix-halo-comfyui-toolboxes
stars: 109
archived: false
forks: 14
last_commit: 2026-02-13
asof: 2026-05-08
- repo: LaoMaoBoss/ComfyUI-WBLESS
stars: 0
archived: false
forks: 1
last_commit: 2026-04-07
asof: 2026-05-08
- repo: Lightricks/ComfyUI-LTXVideo
stars: 3587
archived: false
forks: 390
last_commit: 2026-04-26
asof: 2026-05-08
- repo: linjm8780860/ljm_comfyui
stars: 0
archived: false
forks: 0
last_commit: 2026-04-29
asof: 2026-05-08
- repo: lordwedggie/xcpNodes
stars: 0
archived: false
forks: 0
last_commit: 2026-05-08
asof: 2026-05-08
- repo: lucafoscili/lf-nodes
stars: 34
archived: false
forks: 3
last_commit: 2025-12-23
asof: 2026-05-08
- repo: m3rr/h4_Live
stars: 2
archived: false
forks: 2
last_commit: 2026-05-07
asof: 2026-05-08
- repo: MajoorWaldi/ComfyUI-Majoor-AssetsManager
stars: 97
archived: false
forks: 6
last_commit: 2026-05-08
asof: 2026-05-08
- repo: max-dingsda/ComfyUI-AllinOne-LazyNode
stars: 0
archived: false
forks: 1
last_commit: 2026-03-30
asof: 2026-05-08
- repo: maxi45274/ComfyUI_LinkFX
stars: 3
archived: false
forks: 0
last_commit: 2026-05-08
asof: 2026-05-08
- repo: melMass/comfy_mtb
stars: 702
archived: false
forks: 82
last_commit: 2026-03-19
asof: 2026-05-08
- repo: MockbaTheBorg/ComfyUI-Mockba
stars: 1
archived: false
forks: 0
last_commit: 2026-04-13
asof: 2026-05-08
- repo: mudknight/comfyui-mudknight-utils
stars: 0
archived: false
forks: 1
last_commit: 2026-05-08
asof: 2026-05-08
- repo: MuhammadMuradKhan/efficiency-nodes-comfyui
stars: 0
archived: false
forks: 0
last_commit: 2024-02-11
asof: 2026-05-08
- repo: niknah/presentation-ComfyUI
stars: 2
archived: false
forks: 2
last_commit: 2026-04-21
asof: 2026-05-08
- repo: nodelee733/ComfyUI-mxToolkit
stars: 0
archived: false
forks: 0
last_commit: 2026-04-27
asof: 2026-05-08
- repo: nodetool-ai/nodetool
stars: 332
archived: false
forks: 40
last_commit: 2026-05-08
asof: 2026-05-08
- repo: nvmax/aspect-ratio-resizer
stars: 5
archived: false
forks: 1
last_commit: 2026-04-23
asof: 2026-05-08
- repo: o-l-l-i/ComfyUI-Olm-ImageAdjust
stars: 45
archived: false
forks: 4
last_commit: 2025-08-09
asof: 2026-05-08
- repo: PGCRT/CRT-Nodes
stars: 108
archived: false
forks: 14
last_commit: 2026-05-03
asof: 2026-05-08
- repo: philippjbauer/devint25-comfyui-api-demo
stars: 0
archived: false
forks: 0
last_commit: 2025-10-09
asof: 2026-05-08
- repo: pictorialink/ComfyUI-Easy-Use
stars: 1
archived: false
forks: 0
last_commit: 2025-07-15
asof: 2026-05-08
- repo: PioneerMNDR/ComfyUI-Polza
stars: 0
archived: false
forks: 1
last_commit: 2026-04-23
asof: 2026-05-08
- repo: pixaroma/ComfyUI-Pixaroma
stars: 146
archived: false
forks: 9
last_commit: 2026-05-08
asof: 2026-05-08
- repo: PROJECTMAD/PROJECT-MAD-NODES
stars: 4
archived: false
forks: 1
last_commit: 2026-04-01
asof: 2026-05-08
- repo: Raykosan/ComfyUI_RaykoStudio
stars: 45
archived: false
forks: 2
last_commit: 2026-05-05
asof: 2026-05-08
- repo: rgthree/rgthree-comfy
stars: 3054
archived: false
forks: 226
last_commit: 2026-04-07
asof: 2026-05-08
- repo: robertvoy/ComfyUI-Distributed
stars: 544
archived: false
forks: 57
last_commit: 2026-04-26
asof: 2026-05-08
- repo: rohapa/comfyui-replay
stars: 0
archived: false
forks: 0
last_commit: 2026-03-27
asof: 2026-05-08
- repo: r-vage/ComfyUI_Eclipse
stars: 19
archived: false
forks: 2
last_commit: 2026-05-08
asof: 2026-05-08
- repo: ryanontheinside/ComfyUI_RyanOnTheInside
stars: 801
archived: false
forks: 50
last_commit: 2026-03-20
asof: 2026-05-08
- repo: sammykumar/ComfyUI-SwissArmyKnife
stars: 5
archived: false
forks: 1
last_commit: 2026-01-14
asof: 2026-05-08
- repo: SaturMars/ComfyUI-NVVFR
stars: 0
archived: false
forks: 1
last_commit: 2025-08-05
asof: 2026-05-08
- repo: ShakerSmith/ShakerNodesSuite
stars: 8
archived: false
forks: 0
last_commit: 2026-02-18
asof: 2026-05-08
- repo: shrimbly/willie-comfy-frontend
stars: 0
archived: false
forks: 0
last_commit: 2026-04-28
asof: 2026-05-08
- repo: SKBv0/ComfyUI_SKBundle
stars: 116
archived: false
forks: 7
last_commit: 2026-04-23
asof: 2026-05-08
- repo: SKBv0/ComfyUI_SpideyReroute
stars: 13
archived: false
forks: 2
last_commit: 2025-12-19
asof: 2026-05-08
- repo: sofakid/dandy
stars: 54
archived: false
forks: 4
last_commit: 2025-12-15
asof: 2026-05-08
- repo: SpaceWarpStudio/ComfyUI-SetInputGetOutput
stars: 0
archived: false
forks: 1
last_commit: 2026-03-30
asof: 2026-05-08
- repo: SparknightLLC/ComfyUI-EnumCombo
stars: 2
archived: false
forks: 1
last_commit: 2026-05-08
asof: 2026-05-08
- repo: StableLlama/ComfyUI-basic_data_handling
stars: 43
archived: false
forks: 7
last_commit: 2026-05-07
asof: 2026-05-08
- repo: stavzszn/comfyui-teskors-utils
stars: 0
archived: false
forks: 0
last_commit: 2026-03-29
asof: 2026-05-08
- repo: Stibo/comfyui-nifty-nodes
stars: 3
archived: false
forks: 1
last_commit: 2026-03-21
asof: 2026-05-08
- repo: Sunwood-ai-labs/ComfyUI-LTXLongAudio
stars: 0
archived: false
forks: 0
last_commit: 2026-04-01
asof: 2026-05-08
- repo: sypex6/ComfyUI_InstaRAW_Nodes
stars: 0
archived: false
forks: 0
last_commit: 2026-04-07
asof: 2026-05-08
- repo: tavyra/ComfyUI_Curves
stars: 1
archived: false
forks: 1
last_commit: 2025-05-08
asof: 2026-05-08
- repo: tetsuoo-online/Comfyui-TOO-Pack
stars: 4
archived: false
forks: 2
last_commit: 2026-04-02
asof: 2026-05-08
- repo: touge/ComfyUI-NCE_Utils
stars: 0
archived: false
forks: 0
last_commit: 2026-01-25
asof: 2026-05-08
- repo: treforyan-hue/comfyui-deploy
stars: 0
archived: false
forks: 0
last_commit: 2026-05-05
asof: 2026-05-08
- repo: Valiant-Cat/ComfyUI-WanMove-Trajectory
stars: 1
archived: false
forks: 0
last_commit: 2025-12-12
asof: 2026-05-08
- repo: viswamohankomati/ComfyUI-Copilot
stars: 0
archived: false
forks: 0
last_commit: 2025-09-17
asof: 2026-05-08
- repo: vjumpkung/comfyui-infinitetalk-native-sampler
stars: 1
archived: false
forks: 2
last_commit: 2026-03-31
asof: 2026-05-08
- repo: Winnougan/WINT8-ComfyUI
stars: 1
archived: false
forks: 2
last_commit: 2026-04-17
asof: 2026-05-08
- repo: xeinherjer-dev/ComfyUI-XENodes
stars: 1
archived: false
forks: 1
last_commit: 2026-05-07
asof: 2026-05-08
- repo: yardimli/SafetensorViewer
stars: 7
archived: false
forks: 0
last_commit: 2025-02-19
asof: 2026-05-08
- repo: yolain/ComfyUI-Easy-Use
stars: 2504
archived: false
forks: 195
last_commit: 2026-04-29
asof: 2026-05-08
- repo: yolain/ComfyUI-Easy-Use-Frontend
stars: 27
archived: false
forks: 9
last_commit: 2026-04-01
asof: 2026-05-08
- repo: yorkane/ComfyUI-KYNode
stars: 10
archived: false
forks: 4
last_commit: 2026-02-04
asof: 2026-05-08
- repo: zhupeter010903/ComfyUI-XYZ-prompt-library
stars: 1
archived: false
forks: 0
last_commit: 2026-04-26
asof: 2026-05-08
- repo: zzggi2024/shaobkj
stars: 1
archived: false
forks: 0
last_commit: 2026-04-27
asof: 2026-05-08
- repo: zzw5516/ComfyUI-zw-tools
stars: 6
archived: false
forks: 1
last_commit: 2025-12-03
asof: 2026-05-08

View File

@@ -1,6 +1,6 @@
{
"name": "@comfyorg/comfyui-frontend",
"version": "1.44.17",
"version": "1.45.0",
"private": true,
"description": "Official front-end implementation of ComfyUI",
"homepage": "https://comfy.org",
@@ -47,6 +47,9 @@
"test:browser:coverage": "cross-env COLLECT_COVERAGE=true pnpm test:browser",
"test:browser:local": "cross-env PLAYWRIGHT_LOCAL=1 PLAYWRIGHT_TEST_URL=http://localhost:5173 pnpm test:browser",
"test:coverage": "vitest run --coverage",
"test:extension-api": "vitest run --config vitest.extension-api.config.mts",
"test:extension-api:watch": "vitest --config vitest.extension-api.config.mts",
"test:extension-api:coverage": "vitest run --config vitest.extension-api.config.mts --coverage",
"test:unit": "nx run test",
"typecheck": "vue-tsc --noEmit",
"typecheck:browser": "vue-tsc --project browser_tests/tsconfig.json",

3
packages/extension-api/.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
docs-build/
build/
node_modules/

View File

@@ -0,0 +1,50 @@
# @comfyorg/extension-api
> **Status**: scaffolded. Package implementation pending PKG3 — see
> `../../../plans/P2-extension-api-package.md` and
> `../../../plans/prompts/PKG3-npm-package.md` in the workspace root.
The official TypeScript declaration package for ComfyUI extensions. This
package replaces the practice of vendoring `comfy.d.ts` files in custom
node repos.
## Install (post-publish)
```bash
pnpm add -D @comfyorg/extension-api
```
```ts
import { defineExtension } from '@comfyorg/extension-api'
export default defineExtension({
name: 'MyExtension',
setup(ctx) {
ctx.onNodeMounted((node) => {
// ...
})
}
})
```
## Source
This package is built from the source-of-truth folder
`../../src/extension-api/`. Do not edit the package's `build/` output
directly.
## Versioning
- `0.x.y` — experimental during parallel-paths transition (D6 Phase A).
- `1.0.0` — first stable release once D5/D6/D7/D8 are accepted and the
surface has stabilized.
- Breaking changes follow semver strictly from `1.0.0` onward.
## Cross-references
- `decisions/D6-parallel-paths-migration.md` — versioning rationale
- `plans/P2-extension-api-package.md` — package structure plan
- `plans/prompts/PKG3-npm-package.md` — implementation prompt
- `plans/prompts/PKG4-ci-workflows.md` — publish workflow
- `plans/prompts/PKG5-docgen-mdx.md` — docgen pipeline
- `plans/prompts/PKG6-docs-comfy-org.md` — docs.comfy.org integration

View File

@@ -0,0 +1,28 @@
{
"name": "@comfyorg/extension-api",
"version": "0.1.0",
"description": "Official TypeScript extension API for ComfyUI custom nodes",
"type": "module",
"exports": {
".": "./build/index.js"
},
"types": "./build/index.d.ts",
"scripts": {
"typecheck": "tsc --noEmit",
"build": "tsc --emitDeclarationOnly --outDir build",
"docs:build": "tsx scripts/build-docs.ts",
"docs:watch": "tsx scripts/build-docs.ts --watch"
},
"devDependencies": {
"tsx": "catalog:",
"typedoc": "0.28.19",
"typedoc-plugin-markdown": "^4.6.3",
"typescript": "catalog:"
},
"nx": {
"tags": [
"scope:shared",
"type:api"
]
}
}

View File

@@ -0,0 +1,470 @@
#!/usr/bin/env tsx
/**
* PKG5 docgen pipeline: TypeDoc → Mintlify MDX
*
* Steps:
* 1. Run TypeDoc with typedoc-plugin-markdown to emit raw markdown into docs-build/raw/
* 2. Post-process each markdown file:
* - Add Mintlify frontmatter (title, description, sidebarTitle, icon)
* - Convert ``` fences without lang tag → ```ts
* - Replace raw [TypeName] cross-refs with MDX relative links
* - Wrap @example blocks in proper code fences
* 3. Write final .mdx files to docs-build/mintlify/
* 4. Emit docs-build/mintlify/nav-snippet.json — merges into docs.comfy.org mint.json
*
* Run: pnpm --filter @comfyorg/extension-api docs:build
*/
import { execSync } from 'node:child_process'
import fs from 'node:fs'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const pkgRoot = path.resolve(__dirname, '..')
const rawDir = path.join(pkgRoot, 'docs-build', 'raw')
const mintlifyDir = path.join(pkgRoot, 'docs-build', 'mintlify')
const watchMode = process.argv.includes('--watch')
// ── Page metadata ────────────────────────────────────────────────────────────
// Controls frontmatter for each generated page. Key = TypeDoc output filename
// stem (lowercased). Unrecognised files get generic metadata.
interface PageMeta {
title: string
sidebarTitle?: string
description: string
icon?: string
group: 'core' | 'handles' | 'events' | 'shell' | 'identity' | 'root'
order: number
}
const PAGE_META: Record<string, PageMeta> = {
// Top-level overview
index: {
title: 'Extension API Overview',
description: 'TypeScript API reference for ComfyUI custom node extensions.',
icon: 'puzzle-piece',
group: 'root',
order: 0
},
// Lifecycle / registration
defineextension: {
title: 'defineExtension',
description: 'Register an app-scoped extension for init, setup, and shell UI contributions.',
icon: 'code',
group: 'core',
order: 1
},
definenodeextension: {
title: 'defineNodeExtension',
description: 'Register a node-scoped extension reacting to node lifecycle events.',
icon: 'code',
group: 'core',
order: 2
},
definewidgetextension: {
title: 'defineWidgetExtension',
description: 'Register a custom widget type with its own DOM rendering.',
icon: 'code',
group: 'core',
order: 3
},
extensionoptions: {
title: 'ExtensionOptions',
description: 'Options object for defineExtension — app-wide lifecycle and shell UI.',
group: 'core',
order: 4
},
nodeextensionoptions: {
title: 'NodeExtensionOptions',
description: 'Options object for defineNodeExtension — node lifecycle hooks.',
group: 'core',
order: 5
},
widgetextensionoptions: {
title: 'WidgetExtensionOptions',
description: 'Options object for defineWidgetExtension — custom widget rendering.',
group: 'core',
order: 6
},
onnoderemoved: {
title: 'onNodeRemoved',
sidebarTitle: 'onNodeRemoved',
description: 'Implicit-context lifecycle hook: fires when a node is removed from the graph.',
group: 'core',
order: 7
},
onnodemounted: {
title: 'onNodeMounted',
sidebarTitle: 'onNodeMounted',
description: 'Implicit-context lifecycle hook: fires when a node is fully mounted.',
group: 'core',
order: 8
},
// Handles
nodehandle: {
title: 'NodeHandle',
description: 'Controlled access to node state, mutations, slots, and events.',
icon: 'circle-nodes',
group: 'handles',
order: 10
},
widgethandle: {
title: 'WidgetHandle',
description: 'Controlled access to widget state, mutations, and events.',
icon: 'sliders',
group: 'handles',
order: 11
},
slotinfo: {
title: 'SlotInfo',
description: 'Read-only snapshot of a node slot (input or output).',
group: 'handles',
order: 12
},
// Events
nodeexecutedevent: {
title: 'NodeExecutedEvent',
description: 'Payload fired when a node finishes execution.',
group: 'events',
order: 20
},
nodeconnectedevent: {
title: 'NodeConnectedEvent',
description: 'Payload fired when a slot connection is made.',
group: 'events',
order: 21
},
nodedisconnectedevent: {
title: 'NodeDisconnectedEvent',
description: 'Payload fired when a slot connection is removed.',
group: 'events',
order: 22
},
nodepositionchangedevent: {
title: 'NodePositionChangedEvent',
description: 'Payload fired when a node is moved on the canvas.',
group: 'events',
order: 23
},
nodesizechangedevent: {
title: 'NodeSizeChangedEvent',
description: 'Payload fired when a node is resized.',
group: 'events',
order: 24
},
nodemodechangedevent: {
title: 'NodeModeChangedEvent',
description: 'Payload fired when a node execution mode changes.',
group: 'events',
order: 25
},
nodebeforeserializeevent: {
title: 'NodeBeforeSerializeEvent',
description: 'Pre-serialization hook payload — override or skip node data.',
group: 'events',
order: 26
},
widgetvaluechangeevent: {
title: 'WidgetValueChangeEvent',
description: 'Payload fired when a widget value changes.',
group: 'events',
order: 27
},
widgetbeforeserializeevent: {
title: 'WidgetBeforeSerializeEvent',
description: 'Pre-serialization hook payload — override or skip widget value.',
group: 'events',
order: 28
},
widgetbeforequeueevent: {
title: 'WidgetBeforeQueueEvent',
description: 'Pre-queue validation payload — call reject() to cancel queue.',
group: 'events',
order: 29
},
// Shell UI
sidebartabextension: {
title: 'SidebarTabExtension',
description: 'Register a custom sidebar tab.',
group: 'shell',
order: 40
},
bottompanelextension: {
title: 'BottomPanelExtension',
description: 'Register a custom bottom panel tab.',
group: 'shell',
order: 41
},
toastmanager: {
title: 'ToastManager',
description: 'Show toast notifications to the user.',
group: 'shell',
order: 42
},
commandmanager: {
title: 'CommandManager',
description: 'Register keyboard shortcuts and command palette entries.',
group: 'shell',
order: 43
},
extensionmanager: {
title: 'ExtensionManager',
description: 'Access shell UI registration APIs.',
group: 'shell',
order: 44
},
// Identity
nodelocatorid: {
title: 'NodeLocatorId',
description: 'Branded string ID that uniquely locates a node across graph snapshots.',
group: 'identity',
order: 50
},
nodeexecutionid: {
title: 'NodeExecutionId',
description: 'Branded string ID for a specific node execution run.',
group: 'identity',
order: 51
}
}
const GROUP_LABELS: Record<PageMeta['group'], string> = {
root: 'Extensions API',
core: 'Registration',
handles: 'Handles',
events: 'Events',
shell: 'Shell UI',
identity: 'Identity'
}
// ── Utilities ────────────────────────────────────────────────────────────────
function slug(stem: string): string {
return stem.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '')
}
function metaFor(stem: string): PageMeta {
const key = stem.toLowerCase().replace(/[^a-z]/g, '')
return (
PAGE_META[key] ?? {
title: stem,
description: `API reference for ${stem}.`,
group: 'core',
order: 99
}
)
}
/** Convert TypeDoc raw markdown to Mintlify-compatible MDX. */
function toMintlifyMdx(raw: string, stem: string): string {
const meta = metaFor(stem)
// Build frontmatter
const fm: string[] = [
`---`,
`title: "${meta.title}"`,
...(meta.sidebarTitle ? [`sidebarTitle: "${meta.sidebarTitle}"`] : []),
`description: "${meta.description}"`,
...(meta.icon ? [`icon: "${meta.icon}"`] : []),
`---`
]
let body = raw
// Strip TypeDoc breadcrumb header lines (e.g. "[**@comfyorg/...**](../index.md)\n\n***\n\n[@comfyorg...]...")
body = body.replace(/^\[.*?\]\(\.\.\/index\.md\)\n+\*+\n+/gm, '')
body = body.replace(/^\[.*?\]\(\.\.\/index\.md\).*\n+/gm, '')
// Remove the TypeDoc-generated H1 (we use frontmatter title instead)
body = body.replace(/^# .+\n+/, '')
// Ensure opening code fences that have no lang tag get `ts`
// Only match a ``` that is immediately followed by a newline (opening fence),
// not a closing fence (which also has just ``` + newline but we can detect
// by context: opening fences follow non-fence lines; closing fences follow content).
// Simpler heuristic: replace ``` at start of line only when not already closing a block.
// We track state via a flag pass instead of a single regex.
let inBlock = false
body = body
.split('\n')
.map((line) => {
if (inBlock) {
if (line.trim() === '```') { inBlock = false; return line }
return line
}
if (line.startsWith('```')) {
if (line.trim() === '```') {
// bare opening fence → add ts
inBlock = true
return '```ts'
}
// has a lang tag already
inBlock = true
return line
}
return line
})
.join('\n')
// TypeDoc emits `typescript` lang tag; normalize to `ts`
body = body.replace(/^```typescript\b/gm, '```ts')
// Fix TypeDoc cross-ref links: [TypeName](../type-alias/TypeName.md) → relative MDX paths
// Pattern: [Label](../category/FileName.md) → [Label](./filename)
body = body.replace(
/\[([^\]]+)\]\(\.\.\/([\w-]+)\/([\w-]+)\.md\)/g,
(_match, label, _category, file) => `[${label}](./${slug(file)})`
)
// Same-dir links
body = body.replace(
/\[([^\]]+)\]\(([\w-]+)\.md\)/g,
(_match, label, file) => `[${label}](./${slug(file)})`
)
// TypeDoc wraps @example content in a "## Example" heading; Mintlify prefers
// code examples to be directly under prose without a sub-heading.
// Flatten "## Example\n\n```ts" → "```ts"
body = body.replace(/^## Example\s*\n+/gm, '')
// Stability tags: render as a <Tip> callout
body = body.replace(
/\*\*Stability\*\*: `(stable|experimental|deprecated)`/g,
(_match, level) => {
const label =
level === 'stable'
? '<Tip>**Stability:** Stable — part of the public API contract.</Tip>'
: level === 'experimental'
? '<Warning>**Stability:** Experimental — may change before 1.0.</Warning>'
: '<Warning>**Stability:** Deprecated — will be removed. See migration guide.</Warning>'
return label
}
)
// @stability TSDoc tag (appears as plain text after TypeDoc strips tags)
body = body.replace(
/^Stability: (stable|experimental|deprecated)\s*$/gm,
(_match, level) => {
if (level === 'stable') return '<Tip>**Stability:** Stable</Tip>'
if (level === 'experimental') return '<Warning>**Stability:** Experimental</Warning>'
return '<Warning>**Stability:** Deprecated</Warning>'
}
)
return [...fm, '', body.trim(), ''].join('\n')
}
// ── Nav snippet builder ───────────────────────────────────────────────────────
interface NavPage {
group?: string
pages: (string | NavPage)[]
}
function buildNavSnippet(stems: string[]): NavPage {
const byGroup: Record<string, string[]> = {}
for (const stem of stems) {
const meta = metaFor(stem)
const group = meta.group
if (!byGroup[group]) byGroup[group] = []
byGroup[group].push(`extensions/api/${slug(stem)}`)
}
// Sort each group by order
const sortedStems = stems.slice().sort((a, b) => metaFor(a).order - metaFor(b).order)
const sortedByGroup: Record<string, string[]> = {}
for (const stem of sortedStems) {
const group = metaFor(stem).group
if (!sortedByGroup[group]) sortedByGroup[group] = []
sortedByGroup[group].push(`extensions/api/${slug(stem)}`)
}
const groupOrder: PageMeta['group'][] = ['root', 'core', 'handles', 'events', 'shell', 'identity']
const pages: (string | NavPage)[] = []
// Overview at top level
if (sortedByGroup['root']) {
for (const p of sortedByGroup['root']) pages.push(p)
}
for (const grp of groupOrder) {
if (grp === 'root') continue
const grpPages = sortedByGroup[grp]
if (!grpPages?.length) continue
pages.push({ group: GROUP_LABELS[grp], pages: grpPages })
}
return { group: 'Extensions API', pages }
}
// ── Main pipeline ────────────────────────────────────────────────────────────
function runTypedoc(): void {
console.log('▶ Running TypeDoc...')
execSync(
`npx typedoc --options ${path.join(pkgRoot, 'typedoc.json')} --out ${rawDir}`,
{ cwd: pkgRoot, stdio: 'inherit' }
)
}
function processFiles(): void {
if (!fs.existsSync(rawDir)) {
throw new Error(`TypeDoc output directory not found: ${rawDir}`)
}
fs.mkdirSync(mintlifyDir, { recursive: true })
const mdFiles = fs.readdirSync(rawDir, { recursive: true })
.filter((f): f is string => typeof f === 'string' && f.endsWith('.md'))
const stems: string[] = []
for (const relPath of mdFiles) {
const src = path.join(rawDir, relPath)
const stem = path.basename(relPath, '.md')
const raw = fs.readFileSync(src, 'utf8')
const mdx = toMintlifyMdx(raw, stem)
const destName = slug(stem) + '.mdx'
const dest = path.join(mintlifyDir, destName)
fs.writeFileSync(dest, mdx)
console.log(`${relPath} → mintlify/${destName}`)
stems.push(stem)
}
// Write nav snippet
const nav = buildNavSnippet(stems)
const navDest = path.join(mintlifyDir, 'nav-snippet.json')
fs.writeFileSync(navDest, JSON.stringify(nav, null, 2) + '\n')
console.log(` ✔ nav-snippet.json`)
console.log(`\n✅ Mintlify MDX written to: ${mintlifyDir}`)
console.log(` ${stems.length} pages + nav-snippet.json`)
}
function run(): void {
runTypedoc()
processFiles()
}
if (watchMode) {
// Simple watch: re-run on change to source files
console.log('👁 Watch mode — watching src/extension-api/**')
const srcDir = path.resolve(pkgRoot, '../../src/extension-api')
let debounce: ReturnType<typeof setTimeout> | null = null
run()
fs.watch(srcDir, { recursive: true }, () => {
if (debounce) clearTimeout(debounce)
debounce = setTimeout(() => {
console.log('\n🔄 Source changed — rebuilding...')
try { run() } catch (e) { console.error(e) }
}, 500)
})
} else {
run()
}

View File

@@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"skipLibCheck": true,
"noEmit": true,
"paths": {
"@/*": ["../../src/*"]
}
},
"include": [
"../../src/extension-api/**/*.ts"
],
"exclude": [
"../../src/**/*.test.ts",
"../../src/**/*.spec.ts",
"../../src/**/*.vue"
]
}

View File

@@ -0,0 +1,37 @@
{
"entryPoints": ["../../src/extension-api/index.ts"],
"tsconfig": "./tsconfig.docs.json",
"out": "./docs-build/raw",
"plugin": ["typedoc-plugin-markdown"],
"excludeInternal": true,
"excludePrivate": true,
"excludeProtected": true,
"readme": "none",
"skipErrorChecking": true,
"githubPages": false,
"blockTags": ["@stability", "@packageDocumentation", "@example", "@typeParam", "@returns", "@deprecated", "@remarks"],
"hideGenerator": true,
"useCodeBlocks": true,
"flattenOutputFiles": false,
"entryFileName": "index",
"fileExtension": ".md",
"outputFileStrategy": "members",
"hidePageHeader": false,
"hideBreadcrumbs": false,
"useHTMLAnchors": false,
"sanitizeComments": true,
"expandObjects": false,
"parametersFormat": "table",
"propertiesFormat": "table",
"typeDeclarationFormat": "table",
"indexFormat": "table",
"tableColumnSettings": {
"hideDefaults": false,
"hideInherited": false,
"hideModifiers": false,
"hideOverrides": false,
"hideSources": true,
"hideValues": false,
"leftAlignHeaders": false
}
}

View File

@@ -12575,12 +12575,16 @@ export interface components {
Rodin3DGenerateRequest: {
/** @description The reference images to generate 3D Assets. */
images: string;
/** @description Text prompt used by the upstream Rodin API. Required by upstream for text-to-3D requests (no images uploaded); optional for image-to-3D requests where it acts as additional guidance. */
prompt?: string;
/** @description Seed. */
seed?: number;
tier?: components["schemas"]["RodinTierType"];
material?: components["schemas"]["RodinMaterialType"];
quality?: components["schemas"]["RodinQualityType"];
mesh_mode?: components["schemas"]["RodinMeshModeType"];
/** @description Optional list of upstream addon flags (e.g. "HighPack"). */
addons?: string[];
};
/**
* @description Rodin Tier para options

28
pnpm-lock.yaml generated
View File

@@ -160,8 +160,8 @@ catalogs:
specifier: ^7.7.0
version: 7.7.0
'@types/three':
specifier: ^0.169.0
version: 0.169.0
specifier: ^0.170.0
version: 0.170.0
'@vee-validate/zod':
specifier: ^4.15.1
version: 4.15.1
@@ -339,6 +339,9 @@ catalogs:
tailwindcss-primeui:
specifier: ^0.6.1
version: 0.6.1
three:
specifier: ^0.170.0
version: 0.170.0
tsx:
specifier: ^4.15.6
version: 4.19.4
@@ -698,7 +701,7 @@ importers:
version: 7.7.0
'@types/three':
specifier: 'catalog:'
version: 0.169.0
version: 0.170.0
'@vitejs/plugin-vue':
specifier: 'catalog:'
version: 6.0.3(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))(vue@3.5.13(typescript@5.9.3))
@@ -964,6 +967,9 @@ importers:
posthog-js:
specifier: 'catalog:'
version: 1.358.1
three:
specifier: 'catalog:'
version: 0.170.0
vue:
specifier: 'catalog:'
version: 3.5.13(typescript@5.9.3)
@@ -4508,8 +4514,8 @@ packages:
'@types/stats.js@0.17.3':
resolution: {integrity: sha512-pXNfAD3KHOdif9EQXZ9deK82HVNaXP5ZIF5RP2QG6OQFNTaY2YIetfrE9t528vEreGQvEPRDDc8muaoYeK0SxQ==}
'@types/three@0.169.0':
resolution: {integrity: sha512-oan7qCgJBt03wIaK+4xPWclYRPG9wzcg7Z2f5T8xYTNEF95kh0t0lklxLLYBDo7gQiGLYzE6iF4ta7nXF2bcsw==}
'@types/three@0.170.0':
resolution: {integrity: sha512-CUm2uckq+zkCY7ZbFpviRttY+6f9fvwm6YqSqPfA5K22s9w7R4VnA3rzJse8kHVvuzLcTx+CjNCs2NYe0QFAyg==}
'@types/tough-cookie@4.0.5':
resolution: {integrity: sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==}
@@ -9883,8 +9889,8 @@ packages:
vue-component-type-helpers@3.2.6:
resolution: {integrity: sha512-O02tnvIfOQVmnvoWwuSydwRoHjZVt8UEBR+2p4rT35p8GAy5VTlWP8o5qXfJR/GWCN0nVZoYWsVUvx2jwgdBmQ==}
vue-component-type-helpers@3.2.7:
resolution: {integrity: sha512-+gPp5YGmhfsj1IN+xUo7y0fb4clfnOiiUA39y07yW1VzCRjzVgwLbtmdWlghh7mXrPsEaYc7rrIir/HT6C8vYQ==}
vue-component-type-helpers@3.2.8:
resolution: {integrity: sha512-9689efAXhN/EV86plgkL/XFiJSXhGtWPG6JDboZ+QnjlUWUUQrQ0ILKQtw4iQsuwIwu5k6Aw+JnehDe7161e7A==}
vue-demi@0.14.10:
resolution: {integrity: sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==}
@@ -13405,7 +13411,7 @@ snapshots:
storybook: 10.2.10(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
type-fest: 2.19.0
vue: 3.5.13(typescript@5.9.3)
vue-component-type-helpers: 3.2.7
vue-component-type-helpers: 3.2.8
'@swc/helpers@0.5.17':
dependencies:
@@ -13834,7 +13840,7 @@ snapshots:
'@types/stats.js@0.17.3': {}
'@types/three@0.169.0':
'@types/three@0.170.0':
dependencies:
'@tweenjs/tween.js': 23.1.3
'@types/stats.js': 0.17.3
@@ -14189,7 +14195,7 @@ snapshots:
sirv: 3.0.2
tinyglobby: 0.2.15
tinyrainbow: 3.0.3
vitest: 4.0.16(@opentelemetry/api@1.9.0)(@types/node@24.10.4)(@vitest/ui@4.0.16)(esbuild@0.27.3)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@27.4.0)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)
vitest: 4.0.16(@opentelemetry/api@1.9.0)(@types/node@25.0.3)(@vitest/ui@4.0.16)(esbuild@0.27.3)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@27.4.0)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)
'@vitest/utils@3.2.4':
dependencies:
@@ -20530,7 +20536,7 @@ snapshots:
vue-component-type-helpers@3.2.6: {}
vue-component-type-helpers@3.2.7: {}
vue-component-type-helpers@3.2.8: {}
vue-demi@0.14.10(vue@3.5.13(typescript@5.9.3)):
dependencies:

View File

@@ -54,7 +54,7 @@ catalog:
'@types/jsdom': ^21.1.7
'@types/node': ^24.1.0
'@types/semver': ^7.7.0
'@types/three': ^0.169.0
'@types/three': ^0.170.0
'@vee-validate/zod': ^4.15.1
'@vercel/analytics': ^2.0.1
'@vitejs/plugin-vue': ^6.0.0
@@ -113,6 +113,7 @@ catalog:
storybook: ^10.2.10
stylelint: ^16.26.1
tailwindcss: ^4.2.0
three: ^0.170.0
tailwindcss-primeui: ^0.6.1
tsx: ^4.15.6
tw-animate-css: ^1.3.8

View File

@@ -3,6 +3,7 @@
"LoadImage": 3474,
"CLIPTextEncode": 2435,
"SaveImage": 1762,
"SaveImageAdvanced": 1762,
"VAEDecode": 1754,
"KSampler": 1511,
"CheckpointLoaderSimple": 1293,

View File

@@ -19,6 +19,7 @@ import subprocess
import av
from PIL import Image
from PIL.PngImagePlugin import PngInfo
REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
FIXTURES_DIR = os.path.join(REPO_ROOT, 'src', 'scripts', 'metadata', '__fixtures__')
@@ -115,6 +116,15 @@ def generate_av_fixture(
report(name)
def generate_png():
img = make_1x1_image()
info = PngInfo()
info.add_text('workflow', WORKFLOW_JSON)
info.add_text('prompt', PROMPT_JSON)
img.save(out('with_metadata.png'), 'PNG', pnginfo=info)
report('with_metadata.png')
def generate_webp():
img = make_1x1_image()
exif = build_exif_bytes()
@@ -167,6 +177,7 @@ def generate_webm():
if __name__ == '__main__':
print('Generating fixtures...')
generate_png()
generate_webp()
generate_avif()
generate_flac()

View File

@@ -40,7 +40,10 @@
<template #contentFilter>
<div class="relative flex flex-wrap justify-between gap-2 px-6 pb-4">
<div class="flex flex-wrap gap-2">
<div
:ref="primeVueOverlay.overlayScopeRef"
class="flex flex-wrap gap-2"
>
<!-- Model Filter -->
<MultiSelect
v-model="selectedModelObjects"
@@ -48,6 +51,7 @@
class="w-[250px]"
:label="modelFilterLabel"
:options="modelOptions"
:content-style="selectContentStyle"
:show-search-box="true"
:show-selected-count="true"
:show-clear-button="true"
@@ -62,6 +66,7 @@
v-model="selectedUseCaseObjects"
:label="useCaseFilterLabel"
:options="useCaseOptions"
:content-style="selectContentStyle"
:show-search-box="true"
:show-selected-count="true"
:show-clear-button="true"
@@ -76,6 +81,7 @@
v-model="selectedRunsOnObjects"
:label="runsOnFilterLabel"
:options="runsOnOptions"
:content-style="selectContentStyle"
:show-search-box="true"
:show-selected-count="true"
:show-clear-button="true"
@@ -92,6 +98,7 @@
v-model="sortBy"
:label="$t('templateWorkflows.sorting', 'Sort by')"
:options="sortOptions"
:content-style="selectContentStyle"
class="w-62.5"
>
<template #icon>
@@ -416,6 +423,7 @@ import BaseModalLayout from '@/components/widget/layout/BaseModalLayout.vue'
import LeftSidePanel from '@/components/widget/panel/LeftSidePanel.vue'
import { useIntersectionObserver } from '@/composables/useIntersectionObserver'
import { useLazyPagination } from '@/composables/useLazyPagination'
import { usePrimeVueOverlayChildStyle } from '@/composables/usePopoverSizing'
import { useTemplateFiltering } from '@/composables/useTemplateFiltering'
import { isCloud } from '@/platform/distribution/types'
import { useTelemetry } from '@/platform/telemetry'
@@ -632,6 +640,8 @@ const selectedRunsOnObjects = computed({
const loadingTemplate = ref<string | null>(null)
const hoveredTemplate = ref<string | null>(null)
const cardRefs = ref<HTMLElement[]>([])
const primeVueOverlay = usePrimeVueOverlayChildStyle()
const selectContentStyle = primeVueOverlay.contentStyle
// Force re-render key for templates when sorting changes
const templateListKey = ref(0)

View File

@@ -0,0 +1,192 @@
import { createTestingPinia } from '@pinia/testing'
import { cleanup, render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { setActivePinia } from 'pinia'
import PrimeVue from 'primevue/config'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { defineComponent, h } from 'vue'
import { createI18n } from 'vue-i18n'
import GlobalDialog from '@/components/dialog/GlobalDialog.vue'
import { useDialogStore } from '@/stores/dialogStore'
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: { en: { g: { close: 'Close' } } },
missingWarn: false,
fallbackWarn: false
})
const Body = defineComponent({
name: 'Body',
setup: () => () => h('p', { 'data-testid': 'body' }, 'body content')
})
function mountDialog() {
return render(GlobalDialog, {
global: { plugins: [PrimeVue, i18n] }
})
}
describe('GlobalDialog renderer branching', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
})
afterEach(() => {
cleanup()
})
it('renders the PrimeVue branch when renderer is omitted', async () => {
mountDialog()
const store = useDialogStore()
store.showDialog({
key: 'primevue-default',
title: 'PrimeVue dialog',
component: Body
})
const dialogs = await screen.findAllByRole('dialog')
expect(dialogs.some((el) => el.classList.contains('p-dialog'))).toBe(true)
})
it('renders the Reka branch when renderer is reka', async () => {
mountDialog()
const store = useDialogStore()
store.showDialog({
key: 'reka-opt-in',
title: 'Reka dialog',
component: Body,
dialogComponentProps: { renderer: 'reka' }
})
const dialogs = await screen.findAllByRole('dialog')
expect(dialogs.length).toBeGreaterThan(0)
expect(dialogs.some((el) => el.classList.contains('p-dialog'))).toBe(false)
})
it('preserves the renderer flag on the dialog stack item', async () => {
mountDialog()
const store = useDialogStore()
store.showDialog({
key: 'reka-flag-check',
title: 'Reka',
component: Body,
dialogComponentProps: { renderer: 'reka' }
})
await screen.findByRole('dialog')
const item = store.dialogStack.find((d) => d.key === 'reka-flag-check')
expect(item?.dialogComponentProps.renderer).toBe('reka')
})
})
describe('GlobalDialog Reka parity with PrimeVue', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
})
afterEach(() => {
cleanup()
})
it('omits the close button when closable is false', async () => {
mountDialog()
const store = useDialogStore()
store.showDialog({
key: 'reka-not-closable',
title: 'No close',
component: Body,
dialogComponentProps: { renderer: 'reka', closable: false }
})
await screen.findByRole('dialog')
expect(screen.queryByRole('button', { name: 'Close' })).toBeNull()
})
it('renders the close button by default', async () => {
mountDialog()
const store = useDialogStore()
store.showDialog({
key: 'reka-closable',
title: 'Closable',
component: Body,
dialogComponentProps: { renderer: 'reka' }
})
await screen.findByRole('dialog')
expect(screen.getByRole('button', { name: 'Close' })).toBeInTheDocument()
})
it('omits the title when headless is true', async () => {
mountDialog()
const store = useDialogStore()
store.showDialog({
key: 'reka-headless',
title: 'Hidden title',
component: Body,
dialogComponentProps: { renderer: 'reka', headless: true }
})
await screen.findByRole('dialog')
expect(screen.queryByText('Hidden title')).toBeNull()
})
it('renders the title when headless is omitted', async () => {
mountDialog()
const store = useDialogStore()
store.showDialog({
key: 'reka-titled',
title: 'Visible title',
component: Body,
dialogComponentProps: { renderer: 'reka' }
})
await screen.findByRole('dialog')
expect(screen.getByText('Visible title')).toBeInTheDocument()
})
it('closes the dialog on Escape by default', async () => {
mountDialog()
const store = useDialogStore()
const user = userEvent.setup()
store.showDialog({
key: 'reka-esc-default',
title: 'Esc closes',
component: Body,
dialogComponentProps: { renderer: 'reka' }
})
await screen.findByRole('dialog')
await user.keyboard('{Escape}')
expect(store.isDialogOpen('reka-esc-default')).toBe(false)
})
it('does not close on Escape when closable is false', async () => {
mountDialog()
const store = useDialogStore()
const user = userEvent.setup()
store.showDialog({
key: 'reka-esc-blocked',
title: 'Esc blocked',
component: Body,
dialogComponentProps: { renderer: 'reka', closable: false }
})
await screen.findByRole('dialog')
await user.keyboard('{Escape}')
expect(store.isDialogOpen('reka-esc-blocked')).toBe(true)
})
})

View File

@@ -1,49 +1,106 @@
<!-- The main global dialog to show various things -->
<template>
<Dialog
v-for="item in dialogStore.dialogStack"
:key="item.key"
v-model:visible="item.visible"
class="global-dialog"
v-bind="item.dialogComponentProps"
:pt="getDialogPt(item)"
:aria-labelledby="item.key"
>
<template #header>
<div v-if="!item.dialogComponentProps?.headless">
<component
:is="item.headerComponent"
v-if="item.headerComponent"
v-bind="item.headerProps"
:id="item.key"
/>
<h3 v-else :id="item.key">
{{ item.title || ' ' }}
</h3>
</div>
</template>
<template v-for="item in dialogStore.dialogStack" :key="item.key">
<Dialog
v-if="isRekaItem(item)"
:open="item.visible"
:modal="item.dialogComponentProps.modal ?? true"
@update:open="(open) => onRekaOpenChange(item.key, open)"
>
<DialogPortal>
<DialogOverlay />
<DialogContent
:size="item.dialogComponentProps.size ?? 'md'"
:aria-labelledby="item.key"
@escape-key-down="
(e) =>
item.dialogComponentProps.closeOnEscape === false &&
e.preventDefault()
"
@pointer-down-outside="
(e) =>
item.dialogComponentProps.dismissableMask === false &&
e.preventDefault()
"
@mousedown="() => dialogStore.riseDialog({ key: item.key })"
>
<DialogHeader v-if="!item.dialogComponentProps.headless">
<component
:is="item.headerComponent"
v-if="item.headerComponent"
v-bind="item.headerProps"
:id="item.key"
/>
<DialogTitle v-else :id="item.key">
{{ item.title || ' ' }}
</DialogTitle>
<DialogClose v-if="item.dialogComponentProps.closable !== false" />
</DialogHeader>
<div class="flex-1 overflow-auto px-4 py-2">
<component
:is="item.component"
v-bind="item.contentProps"
:maximized="item.dialogComponentProps.maximized"
/>
</div>
<DialogFooter v-if="item.footerComponent">
<component :is="item.footerComponent" v-bind="item.footerProps" />
</DialogFooter>
</DialogContent>
</DialogPortal>
</Dialog>
<PrimeDialog
v-else
v-model:visible="item.visible"
class="global-dialog"
v-bind="item.dialogComponentProps"
:pt="getDialogPt(item)"
:aria-labelledby="item.key"
>
<template #header>
<div v-if="!item.dialogComponentProps?.headless">
<component
:is="item.headerComponent"
v-if="item.headerComponent"
v-bind="item.headerProps"
:id="item.key"
/>
<h3 v-else :id="item.key">
{{ item.title || ' ' }}
</h3>
</div>
</template>
<component
:is="item.component"
v-bind="item.contentProps"
:maximized="item.dialogComponentProps.maximized"
/>
<component
:is="item.component"
v-bind="item.contentProps"
:maximized="item.dialogComponentProps.maximized"
/>
<template v-if="item.footerComponent" #footer>
<component :is="item.footerComponent" v-bind="item.footerProps" />
</template>
</Dialog>
<template v-if="item.footerComponent" #footer>
<component :is="item.footerComponent" v-bind="item.footerProps" />
</template>
</PrimeDialog>
</template>
</template>
<script setup lang="ts">
import { merge } from 'es-toolkit/compat'
import Dialog from 'primevue/dialog'
import PrimeDialog from 'primevue/dialog'
import type { DialogPassThroughOptions } from 'primevue/dialog'
import { computed } from 'vue'
import Dialog from '@/components/ui/dialog/Dialog.vue'
import DialogClose from '@/components/ui/dialog/DialogClose.vue'
import DialogContent from '@/components/ui/dialog/DialogContent.vue'
import DialogFooter from '@/components/ui/dialog/DialogFooter.vue'
import DialogHeader from '@/components/ui/dialog/DialogHeader.vue'
import DialogOverlay from '@/components/ui/dialog/DialogOverlay.vue'
import DialogPortal from '@/components/ui/dialog/DialogPortal.vue'
import DialogTitle from '@/components/ui/dialog/DialogTitle.vue'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import { isCloud } from '@/platform/distribution/types'
import type { DialogComponentProps } from '@/stores/dialogStore'
import type { DialogComponentProps, DialogInstance } from '@/stores/dialogStore'
import { useDialogStore } from '@/stores/dialogStore'
const { flags } = useFeatureFlags()
@@ -53,6 +110,14 @@ const teamWorkspacesEnabled = computed(
const dialogStore = useDialogStore()
function isRekaItem(item: DialogInstance) {
return item.dialogComponentProps.renderer === 'reka'
}
function onRekaOpenChange(key: string, open: boolean) {
if (!open) dialogStore.closeDialog({ key })
}
function getDialogPt(item: {
key: string
dialogComponentProps: DialogComponentProps

View File

@@ -1,4 +1,5 @@
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { createPinia, setActivePinia } from 'pinia'
import PrimeVue from 'primevue/config'
import { beforeEach, describe, expect, it, vi } from 'vitest'
@@ -42,4 +43,43 @@ describe('ConfirmationDialogContent', () => {
renderComponent({ message: longFilename })
expect(screen.getByText(longFilename)).toBeInTheDocument()
})
it('omits the Cancel button when type is dirtyClose', () => {
renderComponent({ type: 'dirtyClose' })
expect(screen.queryByText('g.cancel')).not.toBeInTheDocument()
expect(screen.getByText('g.save')).toBeInTheDocument()
})
it('uses the provided denyLabel for the deny button on dirtyClose', () => {
renderComponent({ type: 'dirtyClose', denyLabel: 'Sign out anyway' })
expect(screen.getByText('Sign out anyway')).toBeInTheDocument()
expect(screen.queryByText('g.no')).not.toBeInTheDocument()
})
it('calls onConfirm(false) when deny is clicked on dirtyClose', async () => {
const onConfirm = vi.fn()
renderComponent({
type: 'dirtyClose',
denyLabel: 'Close anyway',
onConfirm
})
await userEvent.click(screen.getByRole('button', { name: 'Close anyway' }))
expect(onConfirm).toHaveBeenCalledWith(false)
})
it('calls onConfirm(true) when save is clicked on dirtyClose', async () => {
const onConfirm = vi.fn()
renderComponent({ type: 'dirtyClose', onConfirm })
await userEvent.click(screen.getByRole('button', { name: 'g.save' }))
expect(onConfirm).toHaveBeenCalledWith(true)
})
it('falls back to "no" label when denyLabel is not provided', () => {
renderComponent({ type: 'dirtyClose' })
expect(screen.getByText('g.no')).toBeInTheDocument()
})
})

View File

@@ -55,7 +55,7 @@
</div>
<Button
v-if="type !== 'info'"
v-if="type !== 'info' && type !== 'dirtyClose'"
variant="secondary"
autofocus
@click="onCancel"
@@ -86,9 +86,9 @@
<template v-else-if="type === 'dirtyClose'">
<Button variant="secondary" @click="onDeny">
<i class="pi pi-times" />
{{ $t('g.no') }}
{{ denyLabel ?? $t('g.no') }}
</Button>
<Button @click="onConfirm">
<Button autofocus @click="onConfirm">
<i class="pi pi-save" />
{{ $t('g.save') }}
</Button>
@@ -131,6 +131,7 @@ const props = defineProps<{
onConfirm: (value?: boolean) => void
itemList?: string[]
hint?: string
denyLabel?: string
}>()
const { t } = useI18n()

View File

@@ -1,5 +1,8 @@
<template>
<div class="keybinding-panel flex flex-col gap-2">
<div
:ref="primeVueOverlay.overlayScopeRef"
class="keybinding-panel flex flex-col gap-2"
>
<Teleport defer to="#keybinding-panel-header">
<SearchInput
v-model="filters['global'].value"
@@ -15,10 +18,12 @@
<div class="flex items-center gap-2">
<KeybindingPresetToolbar
:preset-names="presetNames"
:content-style="keybindingOverlayContentStyle"
@presets-changed="refreshPresetList"
/>
<DropdownMenu
:entries="menuEntries"
:style="keybindingOverlayContentStyle"
icon="icon-[lucide--ellipsis]"
item-class="text-sm gap-2"
button-size="unset"
@@ -193,11 +198,12 @@
</template>
</Column>
<template #expansion="slotProps">
<div class="pl-4">
<div class="pl-4" data-testid="keybinding-expansion-content">
<div
v-for="(binding, idx) in (slotProps.data as ICommandData)
.keybindings"
:key="binding.combo.serialize()"
data-testid="keybinding-expansion-binding"
class="flex items-center justify-between border-b border-border-subtle py-1.5 last:border-b-0"
>
<div class="flex items-center gap-4">
@@ -237,6 +243,7 @@
</ContextMenuTrigger>
<ContextMenuPortal>
<ContextMenuContent
:style="keybindingOverlayContentStyle"
class="z-1200 min-w-56 rounded-lg border border-border-subtle bg-base-background px-2 py-3 shadow-interface"
>
<ContextMenuItem
@@ -313,6 +320,7 @@ import { showConfirmDialog } from '@/components/dialog/confirm/confirmDialog'
import Button from '@/components/ui/button/Button.vue'
import SearchInput from '@/components/ui/search-input/SearchInput.vue'
import { useEditKeybindingDialog } from '@/composables/useEditKeybindingDialog'
import { usePrimeVueOverlayChildStyle } from '@/composables/usePopoverSizing'
import type { KeybindingImpl } from '@/platform/keybindings/keybinding'
import { useKeybindingService } from '@/platform/keybindings/keybindingService'
import { useKeybindingStore } from '@/platform/keybindings/keybindingStore'
@@ -336,6 +344,8 @@ const settingStore = useSettingStore()
const commandStore = useCommandStore()
const dialogStore = useDialogStore()
const { t } = useI18n()
const primeVueOverlay = usePrimeVueOverlayChildStyle()
const keybindingOverlayContentStyle = primeVueOverlay.contentStyle
const presetNames = ref<string[]>([])

View File

@@ -9,7 +9,10 @@
{{ displayLabel }}
</SelectValue>
</SelectTrigger>
<SelectContent class="max-w-64 min-w-0 **:[[role=listbox]]:gap-1">
<SelectContent
:style="contentStyle"
class="max-w-64 min-w-0 **:[[role=listbox]]:gap-1"
>
<div class="max-w-60">
<SelectItem
value="default"
@@ -46,6 +49,7 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import type { StyleValue } from 'vue'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
@@ -57,8 +61,9 @@ import SelectValue from '@/components/ui/select/SelectValue.vue'
import { useKeybindingPresetService } from '@/platform/keybindings/presetService'
import { useKeybindingStore } from '@/platform/keybindings/keybindingStore'
const { presetNames } = defineProps<{
const { presetNames, contentStyle } = defineProps<{
presetNames: string[]
contentStyle?: StyleValue
}>()
const emit = defineEmits<{

View File

@@ -0,0 +1,164 @@
import { cleanup, render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { defineComponent, h } from 'vue'
import { createI18n } from 'vue-i18n'
import enMessages from '@/locales/en/main.json' with { type: 'json' }
import HelpCenterMenuContent from './HelpCenterMenuContent.vue'
const distribution = vi.hoisted(() => ({
isCloud: false,
isDesktop: false,
isNightly: false
}))
const commandStoreExecute = vi.hoisted(() => vi.fn())
vi.mock('@/platform/distribution/types', () => ({
get isCloud() {
return distribution.isCloud
},
get isDesktop() {
return distribution.isDesktop
},
get isNightly() {
return distribution.isNightly
}
}))
vi.mock('@/composables/useExternalLink', () => ({
useExternalLink: () => ({
staticUrls: { discord: '', github: '' },
buildDocsUrl: () => 'https://docs.comfy.org'
})
}))
vi.mock('@/platform/settings/settingStore', () => ({
useSettingStore: () => ({
get: () => false
})
}))
vi.mock('@/platform/telemetry', () => ({
useTelemetry: () => ({
trackHelpResourceClicked: vi.fn(),
trackHelpCenterOpened: vi.fn(),
trackHelpCenterClosed: vi.fn()
})
}))
vi.mock('@/platform/updates/common/releaseStore', () => ({
useReleaseStore: () => ({
releases: [],
recentReleases: [],
isLoading: false,
fetchReleases: vi.fn().mockResolvedValue(undefined)
})
}))
vi.mock('@/stores/commandStore', () => ({
useCommandStore: () => ({ execute: commandStoreExecute })
}))
vi.mock('@/utils/envUtil', () => ({
electronAPI: () => null
}))
vi.mock(
'@/workbench/extensions/manager/composables/useConflictAcknowledgment',
() => ({
useConflictAcknowledgment: () => ({ shouldShowRedDot: { value: false } })
})
)
vi.mock('@/workbench/extensions/manager/composables/useManagerState', () => ({
useManagerState: () => ({ isNewManagerUI: { value: false } })
}))
vi.mock('@/workbench/extensions/manager/services/comfyManagerService', () => ({
useComfyManagerService: () => ({})
}))
vi.mock('primevue/usetoast', () => ({
useToast: () => ({ add: vi.fn() })
}))
vi.mock('@/components/icons/PuzzleIcon.vue', () => ({
default: defineComponent({
name: 'PuzzleIconStub',
render: () => h('div')
})
}))
function renderComponent() {
const user = userEvent.setup()
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: { en: enMessages }
})
const result = render(HelpCenterMenuContent, {
global: {
plugins: [i18n]
}
})
return { user, ...result }
}
describe('HelpCenterMenuContent feedback item', () => {
let openSpy: ReturnType<typeof vi.spyOn>
beforeEach(() => {
distribution.isCloud = false
distribution.isDesktop = false
distribution.isNightly = false
commandStoreExecute.mockReset()
openSpy = vi.spyOn(window, 'open').mockReturnValue(null)
})
afterEach(() => {
openSpy.mockRestore()
cleanup()
})
it('opens the Typeform survey tagged with help-center source on Cloud', async () => {
distribution.isCloud = true
const { user } = renderComponent()
await user.click(screen.getByRole('menuitem', { name: 'Give Feedback' }))
expect(openSpy).toHaveBeenCalledWith(
'https://form.typeform.com/to/q7azbWPi#distribution=ccloud&source=help-center',
'_blank',
'noopener,noreferrer'
)
expect(commandStoreExecute).not.toHaveBeenCalled()
})
it('opens the Typeform survey tagged with help-center source on Nightly', async () => {
distribution.isNightly = true
const { user } = renderComponent()
await user.click(screen.getByRole('menuitem', { name: 'Give Feedback' }))
expect(openSpy).toHaveBeenCalledWith(
'https://form.typeform.com/to/q7azbWPi#distribution=oss-nightly&source=help-center',
'_blank',
'noopener,noreferrer'
)
expect(commandStoreExecute).not.toHaveBeenCalled()
})
it('falls back to Comfy.ContactSupport on OSS builds', async () => {
const { user } = renderComponent()
await user.click(screen.getByRole('menuitem', { name: 'Give Feedback' }))
expect(openSpy).not.toHaveBeenCalled()
expect(commandStoreExecute).toHaveBeenCalledWith('Comfy.ContactSupport')
})
})

View File

@@ -163,6 +163,7 @@ import PuzzleIcon from '@/components/icons/PuzzleIcon.vue'
import { useExternalLink } from '@/composables/useExternalLink'
import { isCloud, isDesktop, isNightly } from '@/platform/distribution/types'
import { useSettingStore } from '@/platform/settings/settingStore'
import { buildFeedbackTypeformUrl } from '@/platform/support/config'
import { useTelemetry } from '@/platform/telemetry'
import type { ReleaseNote } from '@/platform/updates/common/releaseService'
import { useReleaseStore } from '@/platform/updates/common/releaseStore'
@@ -306,7 +307,7 @@ const menuItems = computed<MenuItem[]>(() => {
trackResourceClick('help_feedback', isCloud || isNightly)
if (isCloud || isNightly) {
window.open(
'https://form.typeform.com/to/q7azbWPi',
buildFeedbackTypeformUrl('help-center'),
'_blank',
'noopener,noreferrer'
)

View File

@@ -21,20 +21,42 @@
</Button>
<Select
v-model="selectedSpeed"
:options="speedOptions"
option-label="name"
option-value="value"
class="w-24"
/>
:model-value="selectedSpeed != null ? String(selectedSpeed) : undefined"
@update:model-value="(val) => (selectedSpeed = Number(val))"
>
<SelectTrigger size="md" class="w-24">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem
v-for="opt in speedOptions"
:key="opt.value"
:value="String(opt.value)"
>
{{ opt.name }}
</SelectItem>
</SelectContent>
</Select>
<Select
v-model="selectedAnimation"
:options="animations"
option-label="name"
option-value="index"
class="w-32"
/>
:model-value="
selectedAnimation != null ? String(selectedAnimation) : undefined
"
@update:model-value="(val) => (selectedAnimation = Number(val))"
>
<SelectTrigger size="md" class="w-32">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem
v-for="anim in animations"
:key="anim.index"
:value="String(anim.index)"
>
{{ anim.name }}
</SelectItem>
</SelectContent>
</Select>
</div>
<div class="flex w-full max-w-xs items-center gap-2 px-4">
@@ -54,10 +76,14 @@
</template>
<script setup lang="ts">
import Select from 'primevue/select'
import { computed } from 'vue'
import Button from '@/components/ui/button/Button.vue'
import Select from '@/components/ui/select/Select.vue'
import SelectContent from '@/components/ui/select/SelectContent.vue'
import SelectItem from '@/components/ui/select/SelectItem.vue'
import SelectTrigger from '@/components/ui/select/SelectTrigger.vue'
import SelectValue from '@/components/ui/select/SelectValue.vue'
import Slider from '@/components/ui/slider/Slider.vue'
type Animation = { name: string; index: number }

View File

@@ -5,20 +5,20 @@ import { ref } from 'vue'
import PopupSlider from '@/components/load3d/controls/PopupSlider.vue'
vi.mock('primevue/slider', () => ({
vi.mock('@/components/ui/slider/Slider.vue', () => ({
default: {
name: 'Slider',
name: 'UiSlider',
props: ['modelValue', 'min', 'max', 'step'],
emits: ['update:modelValue'],
template: `
<input
type="range"
role="slider"
:value="modelValue"
:value="Array.isArray(modelValue) ? modelValue[0] : modelValue"
:min="min"
:max="max"
:step="step"
@input="$emit('update:modelValue', Number($event.target.value))"
@input="$emit('update:modelValue', [Number($event.target.value)])"
/>
`
}

View File

@@ -15,21 +15,22 @@
class="absolute top-0 left-12 w-[150px] rounded-lg bg-interface-menu-surface p-4 shadow-lg"
>
<Slider
v-model="value"
:model-value="sliderValue"
class="w-full"
:min="min"
:max="max"
:step="step"
@update:model-value="onSliderUpdate"
/>
</div>
</div>
</template>
<script setup lang="ts">
import Slider from 'primevue/slider'
import { onMounted, onUnmounted, ref } from 'vue'
import { computed, onMounted, onUnmounted, ref } from 'vue'
import Button from '@/components/ui/button/Button.vue'
import Slider from '@/components/ui/slider/Slider.vue'
const {
icon = 'pi-expand',
@@ -47,6 +48,12 @@ const {
const value = defineModel<number>()
const showSlider = ref(false)
const sliderValue = computed(() => [value.value ?? min])
function onSliderUpdate(val: number[] | undefined) {
if (val?.length) value.value = val[0]
}
const toggleSlider = () => {
showSlider.value = !showSlider.value
}

View File

@@ -7,38 +7,81 @@ import { createI18n } from 'vue-i18n'
import ViewerCameraControls from '@/components/load3d/controls/viewer/ViewerCameraControls.vue'
import type { CameraType } from '@/extensions/core/load3d/interfaces'
vi.mock('primevue/select', () => ({
vi.mock('@/components/ui/select/Select.vue', async () => {
const { provide } = await import('vue')
return {
default: {
name: 'Select',
props: ['modelValue'],
emits: ['update:modelValue'],
setup(
props: { modelValue: string },
{ emit }: { emit: (event: string, value: string) => void }
) {
provide('selectModelValue', (): string => props.modelValue)
provide('selectUpdate', (v: string): void =>
emit('update:modelValue', v)
)
},
template: '<div><slot /></div>'
}
}
})
vi.mock('@/components/ui/select/SelectContent.vue', async () => {
const { inject, ref, onMounted } = await import('vue')
return {
default: {
name: 'SelectContent',
setup() {
const selectModelValue = inject<() => string>('selectModelValue')
const selectUpdate = inject<(v: string) => void>('selectUpdate')
const el = ref<HTMLSelectElement | null>(null)
onMounted(() => {
if (el.value) el.value.value = selectModelValue?.() ?? ''
})
return {
el,
onChange: (e: Event) => {
selectUpdate?.((e.target as HTMLSelectElement).value)
}
}
},
template: '<select ref="el" @change="onChange"><slot /></select>'
}
}
})
vi.mock('@/components/ui/select/SelectItem.vue', () => ({
default: {
name: 'Select',
props: ['modelValue', 'options', 'optionLabel', 'optionValue'],
emits: ['update:modelValue'],
template: `
<select
:value="modelValue"
@change="$emit('update:modelValue', $event.target.value)"
>
<option v-for="opt in options" :key="opt[optionValue]" :value="opt[optionValue]">
{{ opt[optionLabel] }}
</option>
</select>
`
name: 'SelectItem',
props: ['value'],
template: '<option :value="value"><slot /></option>'
}
}))
vi.mock('primevue/slider', () => ({
vi.mock('@/components/ui/select/SelectTrigger.vue', () => ({
default: { name: 'SelectTrigger', template: '<span />' }
}))
vi.mock('@/components/ui/select/SelectValue.vue', () => ({
default: { name: 'SelectValue', template: '<span />' }
}))
vi.mock('@/components/ui/slider/Slider.vue', () => ({
default: {
name: 'Slider',
props: ['modelValue', 'min', 'max', 'step', 'ariaLabel'],
name: 'UiSlider',
props: ['modelValue', 'min', 'max', 'step'],
emits: ['update:modelValue'],
template: `
<input
type="range"
:value="modelValue"
role="slider"
:value="Array.isArray(modelValue) ? modelValue[0] : modelValue"
:min="min"
:max="max"
:step="step"
:aria-label="ariaLabel"
@input="$emit('update:modelValue', Number($event.target.value))"
@input="$emit('update:modelValue', [Number($event.target.value)])"
/>
`
}

View File

@@ -2,34 +2,46 @@
<div class="space-y-4">
<div class="flex flex-col gap-2">
<label>{{ t('load3d.viewer.cameraType') }}</label>
<Select
v-model="cameraType"
:options="cameras"
option-label="title"
option-value="value"
>
<Select v-model="cameraType">
<SelectTrigger size="md">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem
v-for="cam in cameras"
:key="cam.value"
:value="cam.value"
>
{{ cam.title }}
</SelectItem>
</SelectContent>
</Select>
</div>
<div v-if="showFOVButton" class="flex flex-col gap-2">
<label>{{ t('load3d.fov') }}</label>
<Slider
v-model="fov"
:model-value="fovSliderValue"
:min="10"
:max="150"
:step="1"
:aria-label="t('load3d.fov')"
@update:model-value="onFovUpdate"
/>
</div>
</div>
</template>
<script setup lang="ts">
import Select from 'primevue/select'
import Slider from 'primevue/slider'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import Select from '@/components/ui/select/Select.vue'
import SelectContent from '@/components/ui/select/SelectContent.vue'
import SelectItem from '@/components/ui/select/SelectItem.vue'
import SelectTrigger from '@/components/ui/select/SelectTrigger.vue'
import SelectValue from '@/components/ui/select/SelectValue.vue'
import Slider from '@/components/ui/slider/Slider.vue'
import type { CameraType } from '@/extensions/core/load3d/interfaces'
const { t } = useI18n()
@@ -41,4 +53,10 @@ const cameras = [
const cameraType = defineModel<CameraType>('cameraType')
const fov = defineModel<number>('fov')
const showFOVButton = computed(() => cameraType.value === 'perspective')
const fovSliderValue = computed(() => [fov.value ?? 10])
function onFovUpdate(val: number[] | undefined) {
if (val?.length) fov.value = val[0]
}
</script>

View File

@@ -5,22 +5,65 @@ import { createI18n } from 'vue-i18n'
import ViewerExportControls from '@/components/load3d/controls/viewer/ViewerExportControls.vue'
vi.mock('primevue/select', () => ({
default: {
name: 'Select',
props: ['modelValue', 'options', 'optionLabel', 'optionValue'],
emits: ['update:modelValue'],
template: `
<select
:value="modelValue"
@change="$emit('update:modelValue', $event.target.value)"
>
<option v-for="opt in options" :key="opt[optionValue]" :value="opt[optionValue]">
{{ opt[optionLabel] }}
</option>
</select>
`
vi.mock('@/components/ui/select/Select.vue', async () => {
const { provide } = await import('vue')
return {
default: {
name: 'Select',
props: ['modelValue'],
emits: ['update:modelValue'],
setup(
props: { modelValue: string },
{ emit }: { emit: (event: string, value: string) => void }
) {
provide('selectModelValue', (): string => props.modelValue)
provide('selectUpdate', (v: string): void =>
emit('update:modelValue', v)
)
},
template: '<div><slot /></div>'
}
}
})
vi.mock('@/components/ui/select/SelectContent.vue', async () => {
const { inject, ref, onMounted } = await import('vue')
return {
default: {
name: 'SelectContent',
setup() {
const selectModelValue = inject<() => string>('selectModelValue')
const selectUpdate = inject<(v: string) => void>('selectUpdate')
const el = ref<HTMLSelectElement | null>(null)
onMounted(() => {
if (el.value) el.value.value = selectModelValue?.() ?? ''
})
return {
el,
onChange: (e: Event) => {
selectUpdate?.((e.target as HTMLSelectElement).value)
}
}
},
template: '<select ref="el" @change="onChange"><slot /></select>'
}
}
})
vi.mock('@/components/ui/select/SelectItem.vue', () => ({
default: {
name: 'SelectItem',
props: ['value'],
template: '<option :value="value"><slot /></option>'
}
}))
vi.mock('@/components/ui/select/SelectTrigger.vue', () => ({
default: { name: 'SelectTrigger', template: '<span />' }
}))
vi.mock('@/components/ui/select/SelectValue.vue', () => ({
default: { name: 'SelectValue', template: '<span />' }
}))
const i18n = createI18n({

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