Compare commits

...

137 Commits

Author SHA1 Message Date
Alexander Brown
1151359364 Merge branch 'main' into glary/fix-primitive-refresh-combo-options 2026-05-18 16:35:07 -07:00
nav-tej
4e07fe3a43 feat(website): update Terms of Service to legal-approved 2026-05-13 copy (#12286)
*PR Created by the Glary-Bot Agent*

---

## Summary

Replaces the `tos.*` i18n keys in
`apps/website/src/i18n/translations.ts` with the legal-approved Terms of
Service copy from `Comfy - Terms of Service (GP 5.12.26).docx` and
surfaces an effective date below the hero on `/terms-of-service`.

- Restructures the ToS into 14 sections (intro + 13 numbered sections)
to match the new legal-approved structure.
- Adds two new keys, `tos.effectiveDateLabel` and `tos.effectiveDate`,
rendered as a centered `Effective Date: May 13, 2026` line between the
hero and the content (matches the pattern used on the Affiliate Program
Terms page).
- Subsection labels (*Right to Access and Use Comfy Products.*,
*Customer Data.*, etc.) render as h3 headings via the existing
`block.N.heading` shape — no changes to `ContentSection.vue` or
`contentSections.ts`.
- English page meta description tightened to reflect the new scope
(Comfy Products: Cloud, API, Enterprise — explicitly excluding Comfy
OSS).

## Verbatim legal copy

Per request, the copy is **verbatim from the legal-approved `.docx`**,
including:

- `[URL]` placeholder in §2.7 (Data Retention) where the legal doc has a
placeholder pending the real docs page.
- `[Address]` placeholder in §12.8 (Notices) where the legal doc has a
placeholder pending the finalized mailing address.
- Mixed casing in §8 (Disclaimer) and §9 (Limitation of Liability) —
e.g. `THE Comfy Products AND OUTPUT…`, `…TOTAL LIABILITY OF Comfy…` —
preserved exactly as the legal doc presents it.
- §11(c) cross-reference left as written.

These are intentional and flagged for follow-up with legal/docs before
publishing. I have **not** silently substituted real values for the
placeholders or normalized casing — that would be editing legal-approved
text.

## Chinese (zh-CN) handling

The legal-approved copy was provided in English only. To avoid serving
English text under a Chinese page shell:

- `apps/website/src/pages/zh-CN/terms-of-service.astro` is **removed**.
- `getRoutes()` in `apps/website/src/config/routes.ts` treats
`termsOfService` as locale-invariant, so the Chinese footer link emits
`/terms-of-service` directly — no redirect hop.
- `astro.config.ts` adds a redirect from `/zh-CN/terms-of-service` →
`/terms-of-service` as a safety net for any stale external/cached links.
- All `zh-CN` values on the new `tos.*` keys are filler (mirrored from
English) so the `Record<Locale, string>` type contract holds; they are
never served.

## Files changed

- `apps/website/src/i18n/translations.ts` — 73 old `tos.*` keys removed,
136 new keys added matching the .docx structure.
- `apps/website/src/pages/terms-of-service.astro` — imports `t`, renders
effective date, updates meta description.
- `apps/website/src/pages/zh-CN/terms-of-service.astro` — **removed**.
- `apps/website/astro.config.ts` — adds `/zh-CN/terms-of-service` →
`/terms-of-service` redirect.
- `apps/website/src/config/routes.ts` — `termsOfService` route stays
un-prefixed in non-English locales.

## Verification

- `pnpm --filter=@comfyorg/website typecheck` — 0 errors (2 pre-existing
hints in unrelated files).
- `pnpm --filter=@comfyorg/website build` — 279 pages built,
`/terms-of-service/` (English page) and `/zh-CN/terms-of-service/`
(redirect stub with `noindex` + `canonical`) both emitted.
- Pre-commit lint-staged ran `oxfmt`, `oxlint --type-aware`, `eslint
--fix`, and `pnpm typecheck` on every commit — all green.
- Rendered HTML spot-checked: English `/terms-of-service` contains the
new content with verbatim `[URL]` and `[Address]` placeholders; zh-CN
homepage footer now links directly to `/terms-of-service` (no redirect
hop); `/zh-CN/privacy-policy` and other locale routes still correctly
emit `/zh-CN/…` prefixes.
- Manual visual check via `astro preview` + Playwright — sidebar nav, h2
section titles, h3 subsection headings, paragraph wrapping, and inline
mailto/href anchors all render correctly. Screenshots attached.

## Code-review follow-ups addressed

- **zh-CN regression** — Page removed, route override added, redirect
kept as safety net.
- **Page description mismatch** — Updated meta description to reflect
new scope.
- **`docs.comfy.org/data-retention` 404** — Now matches the docx
placeholder `[URL]`; flagged to legal/docs.
- **Disclaimer / Liability casing** — Restored to match docx verbatim.
- **Mailing address** — Now matches the docx placeholder `[Address]`;
flagged to legal.
- **Section 11(c) cross-reference** — Left verbatim per legal doc.

## Scope notes

- English-only legal update per request — no Chinese rewrite, no schema
changes, no acceptance-tracking infrastructure.
- The signup-flow link on `platform.comfy.org` (`website` repo) already
points at `https://www.comfy.org/terms-of-service` and renders the new
copy at the same URL — no change needed there.

## Screenshots

![Top of /terms-of-service showing 'Effective Date: May 13, 2026' below
the hero and new section
nav](https://pub-1fd11710d4c8405b948c9edc4287a3f2.r2.dev/sessions/57f1c6025f3f147cdc8916ecdd3ecbba847dcc10a971e40c830996f5d3685373/pr-images/1778833284897-43ef1f3a-5c7b-46eb-a73f-58bf38e857be.png)

![Section 2 (Comfy Products) showing h2 title and yellow-italic h3
subsection headings rendered from new tos.*.block.N.heading
keys](https://pub-1fd11710d4c8405b948c9edc4287a3f2.r2.dev/sessions/57f1c6025f3f147cdc8916ecdd3ecbba847dcc10a971e40c830996f5d3685373/pr-images/1778833285304-47094d22-2dea-4192-a00c-2a857d92e0ab.png)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12286-feat-website-update-Terms-of-Service-to-legal-approved-2026-05-13-copy-3616d73d3650815b9262f84d12655dfa)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Glary-Bot <glary-bot@users.noreply.github.com>
2026-05-18 21:11:25 +00:00
Benjamin Lu
3e31de5bbb test: migrate MaxHistoryItems browser coverage (#12298)
## Summary

Migrates the MaxHistoryItems browser coverage to the accepted jobs route
fixture pattern.

## Changes

- **What**: Composes `jobsRouteFixture` into the queue settings spec and
removes the old `AssetsHelper` route setup.
- **What**: Adds a `responseLimit` option to `jobsRouteFixture` so tests
can match a requested history limit while intentionally returning more
jobs.
- **Dependencies**: None.

## Review Focus

The key behavior is preserving both FE-501 acceptance cases: `/api/jobs`
still receives the configured `limit`, and the queue panel still caps
rendered history even if the mocked backend returns more rows than
requested.

Fixes FE-501

## Screenshots (if applicable)

Not applicable.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12298-test-migrate-MaxHistoryItems-browser-coverage-3616d73d365081d6bf77fb205fcd51d4)
by [Unito](https://www.unito.io)
2026-05-18 18:59:20 +00:00
jaeone94
0558740c78 refactor: migrate default combo widget select to Reka (#12288)
## Summary

Migrate the default combo widget select from the PrimeVue `SelectPlus`
wrapper to a Reka `Combobox` implementation while preserving the
existing Comfy combo widget contract and the node-canvas dropdown
behavior.

## Changes

- **What**: Rewrites `WidgetSelectDefault.vue` on top of Reka
`ComboboxRoot`, `ComboboxTrigger`, `ComboboxInput`, `ComboboxContent`,
and `ComboboxItem`.
- **What**: Preserves the default combo widget surface: `v-model`,
`widget` prop, `aria-label` from the widget name/label,
`data-capture-wheel`, disabled state, placeholder/filter placeholder,
default slot controls, invalid current value display, array values,
dynamic/factory values, and `getOptionLabel` fallback behavior.
- **What**: Keeps dynamic `values` compatibility by refreshing
function-backed options when the dropdown opens, without re-evaluating
the factory on every search keystroke.
- **What**: Deletes the now-unused PrimeVue `SelectPlus.vue` wrapper and
removes the PrimeVue test plugin/stub path from the default widget
select tests.
- **What**: Updates App Mode dropdown clipping coverage and combo-widget
browser coverage to target the new Reka overlay/viewport structure.
- **Breaking**: No breaking change is intended for the documented Comfy
combo widget contract. This migration does not preserve incidental
PrimeVue `Select` prop pass-through from `widget.options`; that was a
side effect of wrapping PrimeVue rather than a stable widget API.
- **Dependencies**: No new dependencies.

## Review Focus

### Compatibility choices

The goal of this PR is a migration PR, not a broad behavior redesign.
The new implementation keeps the Comfy-specific combo contract rather
than attempting to emulate PrimeVue internals. In particular:

- `values` still accepts arrays and functions, and function values are
re-read on open to support dynamic/custom node option sources.
- `getOptionLabel(value) || value` is intentionally preserved to match
the sibling dropdown path and avoid turning an empty-string label into a
blank rendered option.
- Invalid/current values that are not present in the option list are
still rendered in the trigger instead of disappearing.
- `WidgetWithControl` continues to render its default slot in the
control area, with trigger text truncation preserved.
- App Mode `OverlayAppendToKey='body'` continues to map to a body portal
to avoid panel clipping.

### Visual alignment and screenshot updates

The previous PrimeVue implementation passed `size="small"`, which
injected internal `.p-select-sm .p-select-label` styling. That internal
PrimeVue style used its own small-select font size and padding,
overriding the surrounding widget sizing intent and making the select
trigger subtly taller with slightly larger text than nearby inline node
widget controls.

The Reka implementation intentionally keeps the normal widget styling
path instead of recreating that PrimeVue-specific internal override.
This means the trigger follows the same inline widget sizing direction
as neighboring controls rather than preserving the incidental PrimeVue
height/text-size delta. Because this is an expected visual difference
from the migration, the affected E2E screenshots should be recaptured
instead of treating the old PrimeVue select height as the target.

### Scrollbar and focus behavior

Reka provides the combobox/listbox semantics we want, including search,
arrow navigation, highlighted items, and Enter selection. The tricky
part is the canvas dropdown scrollbar behavior. The native Reka viewport
path hides/owns scrollbar behavior in a way that made it hard to
preserve the previous widget dropdown affordances, especially visible
scrollbars and mouse wheel capture over the node canvas.

To keep the previous behavior, this PR renders a dedicated scrollable
viewport inside `ComboboxContent` with the project scrollbar utilities
(`scrollbar-thin`, stable gutter, transparent track). That preserves
visible scroll affordance and allows wheel events over the dropdown to
scroll the list instead of zooming the canvas.

There was one Reka interaction to account for: pressing the native
scrollbar can be treated as a focus-outside event from the search input,
which previously closed the dropdown on mouse down or caused subsequent
wheel events to leak back to the canvas. The new
`useRestoreFocusOnViewportPointer` composable handles only that short
pointer gesture:

- viewport pointerdown marks a short-lived scrollbar/viewport
interaction,
- the next focus-outside event is prevented only if the search input can
be restored,
- the guard is cleared by `pointerup`, `pointercancel`, and a timeout so
normal outside clicks still close the dropdown.

### Tests and regression coverage

Unit coverage was updated around the new Reka implementation:

- option sources from arrays and functions,
- dynamic values refreshed on open but not on each search keystroke,
- selection updates and blank/undefined Reka emissions being ignored,
- search filtering and Reka keyboard selection behavior,
- disabled state, invalid current values, `getOptionLabel`, empty
results status, and WidgetWithControl slot preservation,
- composable coverage for pointerup, pointercancel, repeated pointerdown
listener cleanup, and no-input/no-op behavior.

Browser regression coverage now checks the canvas-specific interaction
surface:

- opening and selecting default combo widget options,
- wheel over the dropdown scrolls the list instead of zooming the
canvas,
- pressing the scrollbar does not close the dropdown,
- wheel capture still works after pressing the scrollbar,
- opening another node widget closes the previous dropdown,
- switching between node widgets preserves dropdown scroll capture,
- serialize/reload retains selected combo values.

## Screenshots (if applicable)
New
<img width="527" height="753" alt="스크린샷 2026-05-18 오전 1 36 27"
src="https://github.com/user-attachments/assets/2293d510-6965-4b84-9b12-b8528f8a734f"
/>

Old 
<img width="496" height="473" alt="스크린샷 2026-05-18 오전 1 35 57"
src="https://github.com/user-attachments/assets/47c0e28a-27df-44a6-81a8-14fcc1f3bd8f"
/>

Reka Supports Auto highlight top item on search (Search -> Enter ->
Select 👍)


https://github.com/user-attachments/assets/9d633dfc-c23a-4e7a-8d39-b044c219f1f3

The default combo widget trigger has a small intentional visual delta
from the old PrimeVue path because the Reka implementation does not
recreate PrimeVue's internal `size="small"` label override.


https://github.com/user-attachments/assets/a9053a14-e39e-4d5e-a846-dcf9aeb0caed



## Validation

- `pnpm format`
- `pnpm lint` (passes; existing warning-only lint output remains in
unrelated tests)
- `pnpm typecheck`
- `pnpm typecheck:browser`
- `pnpm test:unit`

- `PLAYWRIGHT_LOCAL=1 PLAYWRIGHT_TEST_URL=http://127.0.0.1:5174 pnpm
exec playwright test
browser_tests/tests/vueNodes/widgets/combo/comboWidget.spec.ts
--project=chromium`

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12288-refactor-migrate-default-combo-widget-select-to-Reka-3616d73d365081fd8742c038a7dc7851)
by [Unito](https://www.unito.io)

---------

Co-authored-by: GitHub Action <action@github.com>
Co-authored-by: github-actions <github-actions@github.com>
2026-05-18 02:58:37 +00:00
Comfy Org PR Bot
8562816ffa 1.45.9 (#12303)
Patch version increment to 1.45.9

**Base branch:** `main`

---------

Co-authored-by: christian-byrne <72887196+christian-byrne@users.noreply.github.com>
Co-authored-by: github-actions <github-actions@github.com>
2026-05-18 00:44:24 +00:00
Terry Jia
5133ab6cf7 FE-416: route 3D node outputs to owner workflow on tab switch (#12295)
## Summary
When a workflow queues a 3D job (Preview3D / Load3D / SaveGLB) and the
user switches to another tab before the job completes, the WS `executed`
payload was silently dropped: `getNodeByExecutionId(app.rootGraph, …)`
resolves against the currently-visible workflow's graph, so the node
isn't found, `onExecuted` never fires, and the workflow JSON never
learns the path of the asset the backend just saved.

Worse, `setNodeOutputsByExecutionId` for top-level execution ids returns
the id verbatim without consulting rootGraph, so the old handler also
wrote the payload into the *active* (wrong) workflow's
`app.nodeOutputs`, polluting it and getting mis-attributed by
`ChangeTracker` snapshot/restore on subsequent tab switches.

This change introduces a single routing point — no new public API, no
new in-memory cache layer — that re-uses three pieces of infrastructure
that already exist independently:

1. `executionStore.jobIdToSessionWorkflowPath` already records which
workflow queued each prompt_id.
2. Each `ComfyWorkflow.ChangeTracker` already snapshots `nodeOutputs` on
`deactivate()` and replays it through the `app.nodeOutputs = …` setter
on `restore()`, which fires `onNodeOutputsUpdated` for every extension.
3. Audio nodes already implement `onNodeOutputsUpdated` to rehydrate
their widget from `nodeOutputs`. They have always been silently broken
in this same scenario (no one noticed because missing audio is less
visible than a missing 3D model); the fix here repairs audio for free as
a side effect.

The new behaviour:

- `app.ts` looks up the node against the active rootGraph *first*. Only
when found do we touch `nodeOutputStore` and call `node.onExecuted`
(existing path, unchanged). Moving the `nodeOutputStore` write inside
`if (node)` is the part that eliminates the cross-workflow pollution
above.
- When the node is not in the active rootGraph, the new
`routeExecutedToInactiveOwner` helper finds the owner workflow via
`jobIdToSessionWorkflowPath`, then writes the payload directly into
`owner.changeTracker.nodeOutputs` — the same field that `restore()`
already drains on tab switch back.
- Preview3D / SaveGLB add `onNodeOutputsUpdated` (mirroring the pattern
audio uses), normalise the path, write `node.properties['Last Time Model
File']` (already workflow-JSON-serialised), and apply it to the viewer.
The legacy `node.onExecuted` chain stays intact for live active-workflow
executions — both paths are idempotent.

## Screenshots (if applicable)
Before


https://github.com/user-attachments/assets/3803af1f-2eb6-41af-87ed-ac885a2eaad6


After


https://github.com/user-attachments/assets/72e1bed9-5f94-414d-ac31-fc925651d11b

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12295-FE-416-route-3D-node-outputs-to-owner-workflow-on-tab-switch-3616d73d3650817b908de48a32b1d6bd)
by [Unito](https://www.unito.io)
2026-05-17 20:55:44 -04:00
Terry Jia
058acfe592 test: add basic E2E tests for CurveEditor widget (#10730)
## Summary
Add Playwright tests covering default rendering, interpolation selector,
and click-to-add-point interaction. Includes test workflow asset.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10730-test-add-basic-E2E-tests-for-CurveEditor-widget-3336d73d365081f5a403df837737bc12)
by [Unito](https://www.unito.io)
2026-05-17 20:54:07 -04:00
Christian Byrne
3b79917011 fix(website): restore registry metadata for cloud nodes catalog (#12307)
*PR Created by the Glary-Bot Agent*

---

## Summary

The
[`/cloud/supported-nodes`](https://comfy-website-preview-pr-12271.vercel.app/cloud/supported-nodes)
page was rendering packs without descriptions, icons, or download counts
(PR #12271 preview, [`Release: Website`
run](https://github.com/Comfy-Org/ComfyUI_frontend/actions/runs/25866708684/job/76010642686)).
The registry enrichment in
`apps/website/src/utils/cloudNodes.registry.ts` was silently failing for
**two** reasons:

1. **Missing `limit` query parameter.** `api.comfy.org /nodes` applies a
default page size of `10` when no `limit` is sent. Each batch of up to
50 `node_id` filters was therefore truncated to 10 results, dropping
metadata for every pack past the first ten.
2. **Schema rejected `null` for optional arrays.** The registry
serializes empty server-side slices as JSON `null`, so any pack with
`supported_os: null` or `supported_accelerators: null` failed Zod
validation — and because parse failure is not retryable, the **entire
batch** got `null` enrichment.

Both bugs produce the same user-visible symptom ("packs fetched, but no
metadata"), so they were entangled.

## Changes (2 files)

- `cloudNodes.registry.ts`: send `?limit=<batch length>` on every batch
and accept `null` for all optional registry fields. The schema
normalizes `null → undefined` at the parse boundary via `.transform()`,
so the parsed shape continues to match the generated OpenAPI `Node` type
contract; downstream code (`toDomainPack`, the rendered `Pack`) is
unchanged.
- `cloudNodes.registry.test.ts`: two new regression tests:
- Server-side default page size: simulates the pre-fix behavior (default
`limit=10` truncates) and asserts all 30 batched IDs are enriched.
- `null` registry fields: asserts `null` values are normalized to
`undefined` on the parsed pack.

## Verification

End-to-end fetch against the live registry on this branch (14 packs from
the current snapshot):

```
Requested: 14, enriched: 14
  comfyui-kjnodes                downloads=3,404,416
  rgthree-comfy                  downloads=3,105,034
  comfyui-easy-use               downloads=2,829,702
  comfyui-impact-pack            downloads=2,680,589
  comfyui_essentials             downloads=2,418,367  (supported_os null → undefined)
  ComfyUI-Crystools              downloads=1,729,087
  comfyui_layerstyle             downloads=1,696,809
  comfyui_ultimatesdupscale      downloads=1,478,763
  comfyui_ipadapter_plus         downloads=1,236,442  (supported_os null → undefined)
  was-node-suite-comfyui         downloads=993,960    (supported_os null → undefined)
  comfyui-advanced-controlnet    downloads=600,849
  comfyui-animatediff-evolved    downloads=503,831
  comfyui-cogvideoxwrapper       downloads=121,716    (supported_os null → undefined)
  comfyui_steudio                downloads=58,470     (supported_os null → undefined)
```

5 of 14 packs returned `null` arrays — all parse cleanly now.
Sort-by-downloads (already implemented in `useFilteredPacks.ts`) becomes
meaningful again once `downloads` is populated.

Quality gates:

- `pnpm --filter @comfyorg/website test:unit` → 77/77 pass (includes 2
new regression tests)
- `pnpm --filter @comfyorg/website typecheck` → 0 errors, 0 warnings on
changed files
- `pnpm exec eslint` on changed files → clean
- `pnpm exec oxfmt --check` on changed files → clean

## Follow-ups (separate tickets)

- "New" badge + `dateAdded` field for newly added packs.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12307-fix-website-restore-registry-metadata-for-cloud-nodes-catalog-3626d73d365081288a2cfc30003160cf)
by [Unito](https://www.unito.io)

Co-authored-by: Glary-Bot <glary-bot@users.noreply.github.com>
2026-05-16 20:27:24 +00:00
Dante
d955625c20 fix(model-library): auto-refresh after upload, plus 'r' key fallback (FE-695) (#12257)
## Summary

Model Library sidebar now refreshes automatically when an upload
completes, and the `r` keybinding refreshes the library in addition to
refreshing combo widgets inside graph nodes.

## Changes

- **What**:
- `modelStore`: new `refreshModelFolder(name)` for surgical reset+reload
of one folder, and `refresh()` that re-loads any folders that had been
loaded
- `ModelLibrarySidebarTab.vue`: watches
`assetDownloadStore.lastCompletedDownload` and refreshes the affected
folder; the in-panel refresh button now routes through `refresh()`
- `Comfy.RefreshNodeDefinitions` (`r` key): also calls
`modelStore.refresh()` so the keyboard fallback actually refreshes the
Model Library list

## Review Focus

- Both `modelStore` and `assetsStore` exist; the upload wizard was only
refreshing the latter, which is what caused the bug. Confirm the new
watcher path is the right hook (rather than wiring it inside the wizard)
— chose this so it also covers completions that happen after the wizard
has been closed.
- `refreshModelFolder` falls back to `refresh()` (not raw
`loadModelFolders()`) for unknown folder types, to avoid dropping other
folders' loaded contents.
- Generated tab half of the ticket is intentionally **deferred** until
BE-885 (cursor pagination on `GET /api/jobs`) lands — AC items around
"no duplicates" and "cursor state maintained" depend on it.

Fixes FE-695 (Model Library half).

## Screenshots (if applicable)

N/A — behavior change verified by unit tests.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12257-fix-model-library-auto-refresh-after-upload-plus-r-key-fallback-3606d73d3650811a8895ef6e3ef2b4b8)
by [Unito](https://www.unito.io)
2026-05-16 13:41:25 +00:00
Terry Jia
861d737041 FE-702: rehydrate 3D viewer on subgraph re-entry via persistent ready hook (#12294)
## Summary
When a Preview3D / Load3D / SaveGLB node lives inside a subgraph, the 3D
viewer correctly displays the model the first time you enter the
subgraph but is blank after exiting and re-entering — even though
`node.properties['Last Time Model File']` is still populated and the
underlying file is on disk.

Fix: introduce a persistent companion to `waitForLoad3d` in
`useLoad3d.ts`:

- `onLoad3dReady(callback)` — registers a callback that fires on *every*
(re-)initialization of the `Load3d` instance for a given node, not just
the first one. Cleared automatically when the node is removed from the
graph (chained into `node.onRemoved` alongside the existing
`pendingCallbacks` cleanup).
- `waitForLoad3d` keeps its original one-shot semantics so callbacks
that install per-node side effects (e.g. wrapping `node.onExecuted`,
setting `sceneWidget.serializeValue`) do not chain on remount.
- When `onLoad3dReady` is registered after a `Load3d` instance already
exists, the callback fires synchronously as well, so the same code path
covers both initial setup and subsequent rehydrations.

Preview3D / Load3D / SaveGLB move the "reapply state from
`node.properties` / `model_file` widget to the Load3d viewer" block from
`waitForLoad3d` to `onLoad3dReady`.
First mount and every subsequent remount now run identical rehydration
code, with `node.properties['Last Time Model File']` (already
workflow-JSON-serialised) as the single source of truth.

## Screenshots (if applicable)
before

https://github.com/user-attachments/assets/e4b0fe6f-c898-4210-b545-7ad6883ed722

after

https://github.com/user-attachments/assets/a4a28490-071d-4694-87a8-5eaa501ac168

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12294-FE-702-rehydrate-3D-viewer-on-subgraph-re-entry-via-persistent-ready-hook-3616d73d3650811e93e7dedb32762711)
by [Unito](https://www.unito.io)
2026-05-16 05:42:04 -04:00
Csongor Czezar
7160a9ee3f fix: QPO progress bar now shows node name in subgraphs (#7688)
## Summary

Resolve the queue progress node label from queued prompt metadata so
subgraph execution IDs show the correct node name without depending on
the live canvas.

## Changes

- **What**: Store a prompt-scoped `executionId -> { title, type }`
lookup from `p.output` when queueing a job, and use that lookup for the
active job's executing node label.
- **What**: Reuse the same job-scoped node info for the browser tab
title so it stays aligned with the queue overlay.
- **What**: Add unit coverage for root and subgraph execution IDs, and
merge the branch forward to current `main`.

## Review Focus

This keeps the fix scoped to the existing singular `activeJobId` path.
It fixes subgraph labels and avoids the workflow-switching regression
from resolving against `app.rootGraph`, but it does not redesign
concurrent multi-job selection yet.

Longer term, the cleaner solution is still prompt-scoped execution
metadata from the backend rather than frontend reconstruction.

## Screenshots (if applicable)

N/A

---------

Co-authored-by: Alexander Brown <drjkl@comfy.org>
Co-authored-by: Benjamin Lu <benjaminlu1107@gmail.com>
2026-05-15 14:00:33 -07:00
Rizumu Ayaka
71092b2011 fix: stop trackpad pinch/swipe gestures from breaking the UI (#12052)
## Summary

On macOS trackpads, several browser default gestures were leaking
through and breaking the workflow:

- **Pinch-zoom on a focused textarea widget** triggered page-level zoom,
pushing `position: fixed` UI (notably `ComfyActionbar`) off-screen until
a reload.
- **Horizontal swipe on a focused textarea widget** triggered browser
back/forward, leaving the workflow.
- **Pinch / horizontal swipe inside the image picker dropdown** had the
same two issues, because PrimeVue `Popover` teleports content to
`document.body` and the `LGraphNode` wheel handler never sees the
events.

Fixes FE-292.

## Why

- **`overscroll-behavior: none` on `html, body`** — horizontal swipe to
back/forward is decided by the browser at gesture start; JS
preventDefault can't reliably beat it. `overscroll-behavior` is the
standards-track signal for opting out, and ComfyUI is a full-screen
editor that never benefits from native overscroll.
- **`useCanvasInteractions` now treats pinch-zoom and
horizontal-dominant wheel as canvas gestures** that override widget
wheel consumption, so the gesture pans/zooms the canvas instead of
falling through to destructive browser defaults. The check is exported
as `isCanvasGestureWheel` for reuse.
- **`FormDropdownMenu` has its own minimal `onWheel`** that only
preventDefaults destructive gestures and deliberately does not forward
to the canvas. The dropdown is its own scroll container and shouldn't
leak interactions into the editor; vertical scrolling stays native.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12052-fix-stop-trackpad-pinch-swipe-gestures-from-breaking-the-UI-3596d73d3650810aac3fcd8a71b29f9e)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Alexander Brown <drjkl@comfy.org>
2026-05-15 14:35:54 +00:00
Comfy Org PR Bot
d05ec230bf 1.45.8 (#12282)
Patch version increment to 1.45.8

**Base branch:** `main`

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12282-1-45-8-3616d73d365081efa628de04190d2a92)
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>
2026-05-15 02:07:09 +00:00
Dante
d6f632477f feat(dialog): migrate Error / NodeSearchBox / SecretForm / VideoHelp / Customization to Reka-UI (Phase 2) (#12109)
## Summary

Phase 2 of the dialog migration kicked off in #11719 and continued in
#12041. Migrates four medium-complexity dialogs to the Reka-UI
primitives. Public API of `useDialogService` / `dialogStore` is
unchanged.

Parent:
[FE-571](https://linear.app/comfyorg/issue/FE-571/dialog-system-migration-primevue-reka-ui-parent)
This phase:
[FE-574](https://linear.app/comfyorg/issue/FE-574/phase-2-migrate-error-nodesearchbox-secretform-videohelp-customization)
Predecessors: #11719 (Phase 0, merged), #12041 (Phase 1, merged)

> **NodeSearchBoxPopover deferred** — host of an inner PrimeVue Dialog
(filter panel) that teleports to body and conflicts with Reka's
DismissableLayer outside-pointer detection (CI dismissed the outer
dialog mid-interaction). Tracking as a follow-up PR; FE-574 stays open
for it.

## Changes

### `src/services/dialogService.ts`
| Call site | Renderer | Size |
| --- | --- | --- |
| `showExecutionErrorDialog()` | `'reka'` | `lg` |
| `showErrorDialog()` | `'reka'` | `lg` |

### `src/components/dialog/content/ErrorDialogContent.vue`
- Drops `import Divider from 'primevue/divider'` and `import ScrollPanel
from 'primevue/scrollpanel'`
- Replaces with `<hr class="border-t border-border-subtle">` + `<div
class="h-[400px] w-full max-w-[80vw] overflow-auto">`

### Direct PrimeVue → Reka swaps (no `dialogStore` involvement)
| File | Notes |
| --- | --- |
| `src/components/common/CustomizationDialog.vue` | Reka primitives +
DialogTitle/Header/Footer; drops PrimeVue Divider; `:modal="false"` and
`pointer-down-outside` overlay guard so the PrimeVue ColorPicker overlay
(teleported to body) does not auto-dismiss the dialog |
| `src/platform/assets/components/VideoHelpDialog.vue` | Headless Reka
content; preserves capture-phase ESC by stopping propagation on
`escape-key-down`; `VisuallyHidden` title for a11y |
| `src/platform/secrets/components/SecretFormDialog.vue` | Reka
primitives, retains `v-model:visible`, autofocus on the provider
trigger, form submit/validation |

### Tests
- `src/services/dialogService.renderer.test.ts`: extends the regression
net to cover both error-dialog call sites (renderer `'reka'`, size
`'lg'`)
- `src/components/common/CustomizationDialog.test.ts`: swaps PrimeVue
Dialog stub for Reka primitive stubs



### screenshot
<img width="1236" height="761" alt="Screenshot 2026-05-11 at 10 26
51 PM"
src="https://github.com/user-attachments/assets/086cb73f-a98d-41f8-96ee-21922da8dd73"
/>
<img width="1161" height="786" alt="Screenshot 2026-05-12 at 1 26 39 PM"
src="https://github.com/user-attachments/assets/db7383d8-f737-4472-91c0-dab5aa41547b"
/>


## Quality gates

- [x] `pnpm typecheck` — clean
- [x] `pnpm lint` — 0 errors (3 pre-existing warnings unrelated to this
PR)
- [x] `pnpm format` — applied
- [x] `pnpm test:unit` (touched + adjacent areas):
  - `dialogService.renderer.test.ts` — 5/5
  - `CustomizationDialog.test.ts` — 4/4
  - All `src/components/dialog` tests — 73/73
  - `src/platform/secrets` tests — 39/39
  - `NodeBookmarkTreeExplorer.test.ts` — 7/7
- [ ] CI Playwright matrix

## Public API impact

None. `useDialogService` / `dialogStore` signatures unchanged.
Custom-node extensions calling `app.extensionManager.dialog.*` continue
to work.

## Out of scope (later phases)

- NodeSearchBoxPopover — follow-up PR under FE-574
- Settings dialog — Phase 3 (FE-575)
- Manager dialog — Phase 4 (FE-576)
- `ConfirmDialog` callers (`SecretsPanel`, `BaseWorkflowsSidebarTab`) —
Phase 5 (FE-577)
- Removing PrimeVue `Dialog`/`<style>` overrides in `GlobalDialog.vue` —
Phase 6 (FE-578)

## Test plan

- [x] Unit: 73/73 dialog-area, 39/39 secrets
- [ ] CI: full Vitest + Playwright matrix
- [ ] Manual on a backend:
- Trigger an execution error → error dialog opens through Reka, scroll
body, copy-to-clipboard, close
- Add/edit a secret → form submits, validation errors render, ESC and
cancel close
- Open VideoHelpDialog from `UploadModelFooter` while inside the asset
modal → ESC closes only the help dialog
- Customize a node bookmark color/icon → apply/reset, color picker
overlay works
2026-05-15 02:05:46 +00:00
Alexander Brown
1ab63807e9 fix: reserve scrollbar gutter on textareas to prevent hover reflow (#12280)
*PR Created by the Glary-Bot Agent*

---

Adds the Tailwind 4.3 `scrollbar-gutter-stable` utility to the shared
shadcn `Textarea` wrapper and the `ModelInfoPanel` description textarea
so the layout no longer shifts when a vertical scrollbar appears on
hover or focus.

The wrapper edit propagates to all four consumers (`WidgetTextarea`,
`WidgetMarkdown`, `ComfyHubDescribeStep`, `ComfyHubCreateProfileForm`).
The `ModelInfoPanel` site uses a native `<textarea>` that bypasses the
wrapper, so it is fixed inline.

The `overflow-hidden hover:overflow-auto` performance pattern from
#10804 is intentionally preserved.

## Verification

- Typecheck, eslint, oxlint, oxfmt, stylelint clean on changed files
- `Textarea.test.ts`, `WidgetTextarea.test.ts`,
`WidgetMarkdown.test.ts`, `ComfyHubDescribeStep.test.ts`,
`ModelInfoPanel.test.ts` — all 68 tests pass
- `pnpm build` succeeds; the production CSS bundle contains the
`scrollbar-gutter:stable` rule
- Storybook (`UI/Textarea`): confirmed
`getComputedStyle(textarea).scrollbarGutter === 'stable'` on Default,
WithLabel, and overflowing-content scenarios

Fixes FE-697

## Screenshots

![Default Textarea story with scrollbar-gutter: stable
applied](https://pub-1fd11710d4c8405b948c9edc4287a3f2.r2.dev/sessions/be88568f9650862f90204bac94d0f79bf00a04fbfd32d37fcd46ad3c1fd3307b/pr-images/1778801341192-90974ee3-876c-4b1d-844c-487f687d80cd.png)

![Focused textarea showing vertical scrollbar with stable
gutter](https://pub-1fd11710d4c8405b948c9edc4287a3f2.r2.dev/sessions/be88568f9650862f90204bac94d0f79bf00a04fbfd32d37fcd46ad3c1fd3307b/pr-images/1778801341517-a6f52715-3ed8-4854-919e-02b5667f28b7.png)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12280-fix-reserve-scrollbar-gutter-on-textareas-to-prevent-hover-reflow-3606d73d3650816f9947e20134abf59e)
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-15 01:48:35 +00:00
Christian Byrne
e35bea51d6 refactor(browser-tests): centralize template mocking via TemplateHelper (#11837)
## Summary

Centralize template route mocking by mirroring the existing
`AssetHelper` fixture pattern, so tests no longer hand-roll
`page.route('**/templates/index.json', ...)` and
`page.route('**/templates/**.webp', ...)` blocks.

## Changes

- **What**:
- Expand `browser_tests/fixtures/data/templateFixtures.ts` with stable
distribution exports (`STABLE_CLOUD_TEMPLATE`,
`STABLE_DESKTOP_TEMPLATE`, `STABLE_LOCAL_TEMPLATE`,
`STABLE_UNRESTRICTED_TEMPLATE`), an `ALL_DISTRIBUTION_TEMPLATES` set,
and a `generateTemplates(count, distribution?)` bulk generator.
`makeTemplate` and `mockTemplateIndex` are preserved.
- Add `browser_tests/fixtures/helpers/TemplateHelper.ts` modeled on
`AssetHelper`: an immutable `TemplateConfig` driven by composable
operators (`withTemplates`, `withTemplate`, `withCloudTemplates`,
`withDesktopTemplates`, `withLocalTemplates`,
`withUnrestrictedTemplates`, `withRawIndex`), a `TemplateHelper` class
with `mock()` / `mockIndex()` / `mockThumbnails()` / `clearMocks()`, and
a `createTemplateHelper(page, ...operators)` factory.
- Add `browser_tests/fixtures/templateApiFixture.ts` exposing
`templateApi: TemplateHelper` as a Playwright fixture with automatic
`clearMocks()` teardown (mirrors `assetApiFixture`).
- Migrate `browser_tests/tests/templateFilteringCount.spec.ts` to
`mergeTests(comfyPageFixture, templateApiFixture)` and
`templateApi.configure(withTemplates(...))` + `templateApi.mockIndex()`.
The webp thumbnail mock moves into the helper.
- **Breaking**: None. `makeTemplate` and `mockTemplateIndex` exports
remain.

## Review Focus

- `TemplateHelper.mock()` is split into `mockIndex()` and
`mockThumbnails()` so the spec can install the thumbnail handler in
`beforeEach` (where it has no per-test data) and the index handler
per-test (after `configure(...)`). Both still register through
`routeHandlers` so `clearMocks()` unroutes everything.
- Operators are pure (`(config) => config`) and the helper deep-copies
the resulting `templates` array, matching the AssetHelper immutability
pattern.
- The thumbnail route is `**/templates/**.webp` and serves
`browser_tests/assets/example.webp` — identical to the previous inline
behavior.

Fixes #11431

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11837-refactor-browser-tests-centralize-template-mocking-via-TemplateHelper-3546d73d365081beac00d80c2ab5ea1c)
by [Unito](https://www.unito.io)
2026-05-14 15:54:40 -07:00
Rizumu Ayaka
f429e1e0c4 refactor: promote FormSearchInput to shared ui as AsyncSearchInput (#12185)
## Summary

Follow-up to #12183: move the debounced, searcher-driven search input
out of `src/renderer/...` and into the shared primitives folder, so both
the graph (form dropdown node widget) and the shell UI (templates
dialog, right side panel tabs) can use it without crossing the renderer
layer.

## Changes

- **What**: Renamed and relocated `FormSearchInput` → `AsyncSearchInput`
at `src/components/ui/search-input/AsyncSearchInput.vue`, joining the
existing `SearchInput` / `SearchAutocomplete` siblings.
- **What**: Updated all 9 callers (graph form dropdown, right side panel
tabs, templates dialog) to import from the new path/name. Test file
moved alongside the component.
- **Breaking**: None — pure rename + relocate, behavior is identical.

## Review Focus

- New name reflects the component's distinguishing feature (the async
`searcher` lifecycle: debounce + cancellation + loading state); see
Slack thread.
- Slack thread captured the discussion — splitting from #12183 so the
perf fix can backport cleanly to the release line.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12185-refactor-promote-FormSearchInput-to-shared-ui-as-AsyncSearchInput-35e6d73d365081c585d8d421ea4353fa)
by [Unito](https://www.unito.io)

Co-authored-by: Christian Byrne <cbyrne@comfy.org>
2026-05-14 21:14:22 +00:00
Alexander Brown
d6b4137eec chore: upgrade tailwindcss to 4.3 (#12275)
*PR Created by the Glary-Bot Agent*

---

## Summary

Bump `tailwindcss` and `@tailwindcss/vite` from `^4.2.0` to `^4.3.0` in
the workspace catalog so the new first-class scrollbar utilities
(notably `scrollbar-gutter-stable`) are available — the missing utility
behind the FE-697 workaround. Release notes:
https://tailwindcss.com/blog/tailwindcss-v4-3.

## Changes

- **What**:
- `pnpm-workspace.yaml` catalog: `tailwindcss` and `@tailwindcss/vite` →
`^4.3.0` (lockfile resolves to `4.3.0` for `tailwindcss`,
`@tailwindcss/vite`, `@tailwindcss/node`, and all `@tailwindcss/oxide-*`
native packages).
- Auto-fix 21 lint errors that `eslint-plugin-better-tailwindcss` now
reports because the new utilities have canonical forms:
- `VirtualGrid.vue`: `[scrollbar-gutter:stable]` →
`scrollbar-gutter-stable` (the FE-697 case)
- `VirtualGrid.vue` + `RightSidePanel.vue`: `scrollbar-thin` class-order
fix
    - `TopBarHeader.vue`: `h-6.25 w-6.25` → `size-6.25` (6 button cells)
- `Dialogue.vue`: `translate-x-[-50%] translate-y-[-50%]` →
`translate-[-50%]`
- Minor `-mx-[…]`, `-inset-[…]`, `-ml-[…]` arbitrary-value reorderings
in `NodeSearchFilterBar.vue`, `LGraphNode.vue`,
`CloudNotificationContent.vue`
- All 21 are auto-fixes; all existed on `main` and would have started
failing CI on next merge.
- **Breaking**: None. v4.3 is purely additive; existing config
(CSS-first `@theme`/`@utility`/`@plugin`, custom `lucideStrokePlugin`,
`tailwindcss-primeui`, `@iconify/tailwind4`, `tw-animate-css`) is
unchanged.

## Review Focus

- Confirmed `scrollbar-gutter: stable` compiles into
`dist/assets/index-*.css` from `VirtualGrid.vue` after the
canonicalization.
- Runtime probe via Playwright (preview build) confirmed
`scrollbar-gutter-stable`, `scrollbar-thin`, `tab-4`, and `zoom-100` all
apply.
- `pnpm typecheck`, `pnpm lint`, `pnpm build`, `pnpm knip` all pass.
- `pnpm test:unit`: 10687/10696 pass. 1 failure in `GraphView.test.ts`
("reconnect wiring") confirmed to fail identically on `main` (flaky
`toHaveBeenCalledTimes(1)` getting `3`/`4`) — unrelated to this change.
- Oracle code review: 0 findings.

Refs FE-697.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12275-chore-upgrade-tailwindcss-to-4-3-3606d73d3650813185cece1c7315e1c2)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Glary-Bot <glary-bot@users.noreply.github.com>
2026-05-14 14:28:11 -07:00
Christian Byrne
b578147714 fix(ci): add unmapped to genhtml ignore-errors, remove unreachable placeholder (#12273)
## Summary

- Add `unmapped` to genhtml `--ignore-errors` flag to fix GH Pages
deploy failure
- Remove unreachable placeholder block (dead code cleanup)

## Problem

PR #11381 added `--ignore-errors source` but genhtml is now failing with
a different error:

```
genhtml: ERROR: no data for line:4291, TLA:GNC, file:src/lib/litegraph/src/LGraphNode.ts
(use "genhtml --ignore-errors unmapped ..." to bypass this error)
```

This happens when LCOV data references lines that don't map to source
(from V8 coverage instrumentation).

## Changes

1. **Add `unmapped` to ignore-errors** — `--ignore-errors
source,unmapped` handles both missing source files and unmapped line
data

2. **Remove unreachable placeholder block** — The `if [ ! -s
coverage/playwright/coverage.lcov ]` check is dead code because the step
is already gated on `has-coverage == 'true'`, which only triggers when
the merged LCOV exists and is non-empty

## Test plan

- [ ] Verify workflow completes successfully on next push to main
- [ ] Verify https://comfy-org.github.io/ComfyUI_frontend/ returns 200

Fixes #12229

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

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12273-fix-ci-add-unmapped-to-genhtml-ignore-errors-remove-unreachable-placeholder-3606d73d36508198a4ffddfce3354d4a)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
Co-authored-by: GitHub Action <action@github.com>
2026-05-14 20:11:45 +00:00
Benjamin Lu
06e09df673 test: replace jobs mock fixture with typed route mocks (#12267)
## Summary

Replace the merged stateful jobs API browser mock fixture with a small
declarative typed route-mock foundation.

## Changes

- **What**: Removes `JobsApiMock`, `jobsApiMockFixture`, and the old
shared `jobFixtures` helper.
- **What**: Adds a generic `RouteMocker` primitive for explicit typed
JSON route responses.
- **What**: Adds `jobsRouteFixture`, which registers explicit
`/api/jobs` list/detail responses without filtering, mutation handling,
or hidden in-memory backend behavior.
- **What**: Migrates the current queue overlay and missing-media runtime
specs onto the new jobs route fixture.
- **What**: Keeps `./browser_tests/tsconfig.json` in the ESLint
TypeScript resolver config.
- **Dependencies**: None.

## Review Focus

This is intended to be the foundation PR for the test-strategy reset:
old stateful helper out, typed declarative route mocks in. It
intentionally does not add the full asset sidebar, job history sidebar,
or floating QPO coverage suite; those should stack on top after this
fixture shape is accepted.

The boundary this PR is trying to preserve: route mocks may describe
frontend-visible API responses, but should not implement Core
queue/history mutation semantics.

Context:
https://www.notion.so/comfy-org/E2E-Test-Strategy-for-Assets-Job-History-and-Queue-Progress-Overlay-35f6d73d365081209bc5f10e6c7eb8de

## Screenshots (if applicable)

Not applicable.
2026-05-14 20:09:48 +00:00
Christian Byrne
e972d658d3 feat(settings): lower Comfy.Pointer.ClickBufferTime default from 150ms to 32ms (#12032)
*PR Created by the Glary-Bot Agent*

---

## Summary

Click vs drag is disambiguated by two thresholds: distance
(`Comfy.Pointer.ClickDrift`, 6px) and time
(`Comfy.Pointer.ClickBufferTime`). Distance does the real work — any
intentional drag immediately exceeds 6px on the first move. The time
threshold only matters in the corner case where the pointer is held
still then released without crossing the distance threshold.

The previous 150ms default forces every pointerdown to wait up to 150ms
before drag begins, even when the user is clearly dragging. This is
visible as lag when click+dragging an unselected node, where the wait
stacks on top of selection-state work that runs at drag start
([FE-558](https://linear.app/comfyorg/issue/FE-558/bug-delay-when-clickdragging-an-unselected-node)).

32ms (~2 frames at 60fps) is well below human-perceptible click latency
and still safely above incidental jitter on pointerdown.

## Changes

- `src/platform/settings/constants/coreSettings.ts` —
`Comfy.Pointer.ClickBufferTime`:
  - `defaultValue: 150` → `32`
  - `versionAdded: '1.4.3'` retained, add `versionModified: '1.44.19'`
- Slider `step: 25` → `1` (the old step made `32` unrepresentable on the
slider)
- Tooltip extended with rationale (why distance is the real
disambiguator, why this should be small)
- `src/lib/litegraph/src/CanvasPointer.ts` — Static `bufferTime = 150` →
`32` for consistency with the user-setting default; the runtime
`watchEffect` in `useLitegraphSettings.ts` continues to override at
boot. JSDoc explains the rationale and that the user setting overrides
at runtime.

## Verification

- `pnpm typecheck` clean.
- Litegraph + settings test suites: 998/998 pass.
- `pnpm format` clean (lint-staged enforced on commit).

## Why not just leave it user-overridable?

It already is. But the default is what every user — including ones who
never open settings — experiences. 150ms was never a justified value (no
comment explains it, no benchmark, and 6px distance covers the
disambiguation). Defaults should reflect best UX.

Refs FE-558.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12032-feat-settings-lower-Comfy-Pointer-ClickBufferTime-default-from-150ms-to-32ms-3586d73d365081f5a29cdc84b4b736e3)
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-14 20:06:51 +00:00
Christian Byrne
f090ea3d28 fix: preserve app builder inputs through graph reconfiguration (#11422)
*PR Created by the Glary-Bot Agent*

---

## Summary

- Fixes app mode bug where custom combo inputs (and other widget inputs)
unselect and show "Widget not visible" after refreshing or reopening a
saved app workflow
- Root cause: `resetSelectedToWorkflow()` loads from
`changeTracker.activeState` which may not have linearData yet after
refresh, and `pruneLinearData()` prunes valid entries during graph
loading when nodes aren't yet in the graph
- Two defensive guards: fallback to `initialState` for authoritative
data, and skip pruning during graph loading

## Changes

- `appModeStore.ts`: `resetSelectedToWorkflow()` now falls back to
`initialState.extra.linearData` when `activeState` has none
- `appModeStore.ts`: `pruneLinearData()` skips node-existence checks
when `ChangeTracker.isLoadingGraph` is true
- Unit tests: 4 new tests covering both fix paths (pruning during
loading, fallback to initialState)
- E2E test: Save-as → close → reopen → verify all inputs persist with no
"Widget not visible"

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11422-fix-preserve-app-builder-inputs-through-graph-reconfiguration-3476d73d36508166a563f7df3967665c)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Glary-Bot <glary-bot@users.noreply.github.com>
Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-05-14 12:28:21 -07:00
Christian Byrne
daab936d15 feat: add Cloud Status link to website footer (#12237)
*PR Created by the Glary-Bot Agent*

---

## Summary

Adds a "Cloud Status" link to the Contact column of the website footer,
pointing to https://status.comfy.org so users can discover the service
status page from any page on the marketing site.

## Changes

- **What**: New external link in the Contact column of `SiteFooter.vue`,
plus matching i18n keys (`footer.cloudStatus` in `en`/`zh-CN`) and a
`cloudStatus` entry in the `externalLinks` config.

## Review Focus

- Placement: Slotted between Support and Press in the Contact column
(alongside the other support/operational links). Open to moving to the
Resources column instead if preferred.
- URL: `https://status.comfy.org` — assumed convention; swap if a
different status page URL is preferred.
- Also tightened the `contactColumn` type annotation to `{ title:
string; links: FooterLink[] }` to match `companyColumn` / `topColumns`
so the `external: true` field is properly typed.

## Screenshots

Desktop (Contact column, right side):

![Footer desktop](.glary/screenshots/footer-desktop.png)

Mobile (2×2 grid, Contact column bottom-right):

![Footer mobile](.glary/screenshots/footer-mobile.png)

## Screenshots

![Site footer desktop view showing Cloud Status link in the Contact
column with external arrow
icon](https://pub-1fd11710d4c8405b948c9edc4287a3f2.r2.dev/sessions/9ba647494446aa0233ca1f81ce96aa1778a59c5f4bbec394dc8074d4cff611a9/pr-images/1778717012660-56f4efd5-0f20-4570-8a19-0390b573b274.png)

![Site footer mobile view showing Cloud Status link in the Contact
column](https://pub-1fd11710d4c8405b948c9edc4287a3f2.r2.dev/sessions/9ba647494446aa0233ca1f81ce96aa1778a59c5f4bbec394dc8074d4cff611a9/pr-images/1778717013059-fe6803d3-94ae-49ed-bcd9-b1eeee8f5ebd.png)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12237-feat-add-Cloud-Status-link-to-website-footer-3606d73d36508190a906fdde6d86706d)
by [Unito](https://www.unito.io)

Co-authored-by: Glary-Bot <glary-bot@users.noreply.github.com>
2026-05-14 15:02:41 +00:00
Christian Byrne
f176d18fe0 fix(website): refresh cloud nodes snapshot in release workflow + strict production builds (#12219)
*PR Created by the Glary-Bot Agent*

---

## Summary

`Release: Website` only refreshed the Ashby snapshot, so the cloud-nodes
snapshot (`apps/website/src/data/cloud-nodes.snapshot.json`) was stale
on every release. `loadPacksForBuild()` then silently fell back to that
snapshot because `WEBSITE_CLOUD_API_KEY` was never plumbed through CI or
Vercel, leaving production at `/cloud/supported-nodes` with placeholder
data (e.g. `rgthree-comfy` listed as supported when it isn't — visible
at line 104 of the committed snapshot, last fetched 2026-05-04).

## Changes

- **New composite action `.github/actions/cloud-nodes-pull`** mirroring
`ashby-pull`: runs `pnpm --filter @comfyorg/website
cloud-nodes:refresh-snapshot` with `WEBSITE_CLOUD_API_KEY`. The script
already `process.exit(1)`s on any non-`fresh` outcome, so refresh
failures are loud.
- **`release-website.yaml`** now runs both refreshes and opens a single
PR with both updated snapshots. Renamed the job to `refresh-snapshots`,
updated branch/commit/title/body for the wider scope, and kept the
existing `Release:Website` label so downstream automation is unaffected.
- **`cloudNodes.build.ts`** throws when the outcome is `'stale'` **and**
`VERCEL_ENV === 'production'`. Preview / local builds keep the snapshot
fallback so contributors without key access are unaffected. The CI
reporter still runs first so the GitHub annotation explaining *why* it's
stale is visible in the failed job.
- **`ci-vercel-website-preview.yaml`**: passes `WEBSITE_CLOUD_API_KEY`
to `vercel build` in both preview and production jobs, and adds a
preflight step on `deploy-production` that hard-fails before `vercel
build --prod` if the secret is missing — surfacing config drift with a
maintainer-friendly error annotation instead of mid-build.
- **`apps/website/README.md`**: documents the production-strictness
behavior, the new required secret (GitHub Actions + Vercel env), and the
manual refresh path.
- **New unit tests** in `cloudNodes.build.test.ts` (6 cases): fresh,
stale-no-VERCEL_ENV, stale-on-preview, stale-on-production,
failed-regardless, and "still reports on stale-in-production before
throwing".

## Manual / one-time steps required before merging

This PR cannot finish the job alone. A maintainer must also:

1. Add `WEBSITE_CLOUD_API_KEY` as a **GitHub Actions repo secret** in
`Comfy-Org/ComfyUI_frontend`.
2. Add `WEBSITE_CLOUD_API_KEY` to the **Vercel project environment**
(`production` env at minimum; `preview` recommended).
3. Investigate why `rgthree-comfy` is in the current snapshot — either
the Cloud API was actually returning it on 2026-05-04, the snapshot was
generated against a non-production environment, or it was hand-edited.
The first manual run of `Release: Website` after this PR merges will
confirm.

Without step 1, the new `Release: Website` job will fail loudly (the
refresh script exits 1 with `missing WEBSITE_CLOUD_API_KEY`). Without
step 2, the new preflight will fail the production deploy with a clear
error annotation pointing at `apps/website/README.md`. Both failure
modes are intentional — they replace today's silent stale snapshot.

## Related (out of scope for this PR)

The other half of the original report — production 404s on
`/p/supported-models/*`, `/cloud/supported-nodes/*`,
`/demos/community-workflows` from PRs #11892 / #11903 / #11942 — is a
`comfy-router` allow-list gap (those paths exist in the Vercel build as
pre-rendered static HTML). That fix needs to land in
`Comfy-Org/comfy-router` and is being handled separately since glary
doesn't have access to that repo.

## Verification

- `pnpm --filter @comfyorg/website test:unit` — 75/75 pass (6 new in
`cloudNodes.build.test.ts`)
- `pnpm --filter @comfyorg/website typecheck` — 0 errors, 0 warnings (2
pre-existing hints unrelated to this PR)
- `pnpm format` + `pnpm exec eslint` on changed files — clean
- `js-yaml` validates `release-website.yaml`,
`cloud-nodes-pull/action.yaml`, `ci-vercel-website-preview.yaml`
- Oracle code review (round 1) raised 1 warning + 1 suggestion; both
addressed in commit 2.

**Manual verification not applicable**: the runtime changes are GitHub
Actions workflows and a Vercel-env-gated branch in a build-time module —
they cannot meaningfully run outside of GitHub Actions / Vercel, and the
strict-on-stale path is exhaustively covered by the 6 unit tests
(including the exact assertions a manual run would check: throws on
`VERCEL_ENV=production` + stale, passes on preview, reports
observability annotation before throwing). The end-to-end behavior will
be verified by the first `Release: Website` dispatch and the next
production deploy after the maintainer adds the secret.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12219-fix-website-refresh-cloud-nodes-snapshot-in-release-workflow-strict-production-build-35f6d73d3650816d8f32d403cb39d733)
by [Unito](https://www.unito.io)

---------

Co-authored-by: glary <bot@glary.dev>
2026-05-14 09:49:00 +00:00
Christian Byrne
01742672bb feat: extend version warning to all comfy_package_versions entries (#12167)
*PR Created by the Glary-Bot Agent*

---

## Summary

Extend the existing frontend version-mismatch warning UI to consume the
new `comfy_package_versions` array now exposed by ComfyUI's
`/system_stats` endpoint. The backend ships installed/required versions
for every `comfy*` package pinned in `requirements.txt` (frontend,
workflow-templates, embedded-docs, kitchen, aimdo, …); the frontend now
surfaces one toast per outdated package, reusing the existing N=1
frontend-version warning shape.

Backend PR: https://github.com/Comfy-Org/ComfyUI/pull/13875

## Changes

- `src/schemas/apiSchema.ts` — add `comfy_package_versions: Array<{name,
installed, required}>` to the `SystemStats` schema and export a
`ComfyPackageVersion` type. The field is `.optional()` so older backends
remain compatible.
- `src/platform/updates/common/versionCompatibilityStore.ts` — new
`outdatedComfyPackages` / `packageWarningMessages` computeds that mirror
the existing semver `gt` comparison (same `valid` guard). Skip
`comfyui-frontend-package` because the dedicated frontend warning above
already covers it (and uses the running bundle's version rather than the
installed pip version). Outdated packages are sorted by
`name`/`installed`/`required` before being folded into the dismissal
storage key so the key is stable across response orderings — a fresh
package bump re-shows the warning, but the same outdated set in a
different order does not.
- `src/platform/updates/common/useFrontendVersionMismatchWarning.ts` —
emit one toast per outdated package in addition to the existing frontend
toast, reusing the same i18n wrapper and the existing `hasShownWarning`
once-per-session guard.
- `src/locales/en/main.json` — new `g.comfyPackageOutdated` string.
- Unit tests — added coverage for outdated/skip/invalid-version paths,
dismissal-key inclusion, and stable ordering. Existing 21 store + 8
composable tests untouched and still passing.

CI suppression is unchanged: warnings still gate on
`versionCompatibilityStore.shouldShowWarning` (which respects the
`Comfy.VersionCompatibility.DisableWarnings` setting and the 7-day
dismissal cache), and unit tests continue to mock the store the same
way.

## Verification

- `pnpm vitest run` for the version-warning module: **31/31** passing.
- Targeted sweep across `src/platform/updates`,
`src/stores/systemStatsStore.test.ts`, `src/schemas`: **160/160**
passing.
- `pnpm typecheck`: clean.
- `pnpm lint`: 0 errors (3 pre-existing warnings in unrelated 3D test
files).
- `pnpm format`: applied, no incidental changes.
- **Manual Playwright run** against the real dev server with
`/api/system_stats` intercepted to return outdated package data —
produced exactly the expected toasts and correctly skipped
`comfyui-frontend-package` and the up-to-date `comfy-kitchen` entry.
Same run with all-up-to-date data produced zero toasts.

### Toasts produced (manual verification)

The fixture used: `required_frontend_version=99.99.99`, plus
`comfy_package_versions=[frontend-package (outdated, skipped),
workflow-templates 0.9.0→0.9.5 (outdated), embedded-docs 0.4.0→0.5.0
(outdated), comfy-kitchen 0.2.8→0.2.8 (up to date)]`.

## Screenshots

![Three version warning toasts displayed: outdated frontend (1.45.1 <
99.99.99), outdated workflow-templates (0.9.0 < 0.9.5), and outdated
embedded-docs (0.4.0 < 0.5.0). The skipped comfyui-frontend-package
entry and the up-to-date comfy-kitchen entry produced no extra
toasts.](https://pub-1fd11710d4c8405b948c9edc4287a3f2.r2.dev/sessions/986a510ae8e587d3ced862efffa622d9d476c40b9ac1423b89f56e85c0ce51c1/pr-images/1778562152770-11307c64-2863-41af-a42d-1c42fb332ca6.png)

![Zoomed view of the three Alert toasts in the top-right toast
stack.](https://pub-1fd11710d4c8405b948c9edc4287a3f2.r2.dev/sessions/986a510ae8e587d3ced862efffa622d9d476c40b9ac1423b89f56e85c0ce51c1/pr-images/1778562153211-df80e3b5-48d5-416e-a11f-3a6d25b6618c.png)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12167-feat-extend-version-warning-to-all-comfy_package_versions-entries-35e6d73d365081e7b993d0f06c9e5c98)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Glary-Bot <glary-bot@users.noreply.github.com>
2026-05-14 08:24:32 +00:00
Christian Byrne
b7fe0365af fix: remove "400 Free Credits Monthly" line from cloud paywall modal (#12251)
*PR Created by the Glary-Bot Agent*

---

## Summary

The in-app cloud paywall modal shown on first install of ComfyUI Desktop
1.0 advertised "400 Free Credits Monthly" as a benefit, but the free
tier is currently disabled, making that copy misleading.

Per the thread direction, this skips the feature-flag plumbing (which
isn't available on Desktop anyway) and simply removes the line.

## Changes

- `CloudNotificationContent.vue`: change the feature loop from `n in 4`
to `n in [2, 3, 4]` so feature1 is skipped.
- `src/locales/*/main.json` (12 locales): remove the now-unused
`cloudNotification.feature1Title` key.
- `browser_tests/tests/dialog.spec.ts`: add a regression assertion that
"400" / "Free Credits" copy is no longer present in the modal.

## Test plan

- `pnpm typecheck` — passes
- `pnpm typecheck:browser` — passes
- `pnpm exec vitest run
src/platform/cloud/notification/components/DesktopCloudNotificationController.test.ts`
— 3/3 pass
- Pre-commit hooks (oxfmt, oxlint, eslint, stylelint, typecheck) — all
pass
- Manual: triggered `showCloudNotification()` against the dev server and
confirmed only 3 benefit rows render (screenshot attached); the "400
Free Credits Monthly" line is gone.

- Fixes DESK2-90

## Screenshots

![Cloud paywall modal after fix — shows 3 benefits without the '400 Free
Credits Monthly'
line](https://pub-1fd11710d4c8405b948c9edc4287a3f2.r2.dev/sessions/d96d51ee1f37a5138014eeff4c46a901e5178c013e0412f288e49c17bddb88e4/pr-images/1778728094191-423f01df-f004-4ae5-a939-7d28413f6fbd.png)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12251-fix-remove-400-Free-Credits-Monthly-line-from-cloud-paywall-modal-3606d73d365081eaa572e6cd995278d8)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Glary-Bot <glary-bot@users.noreply.github.com>
2026-05-13 22:39:32 -07:00
Dante
bdb92c845e fix: include share_id when importing published assets (FE-603) (#12055)
*PR Created by the Glary-Bot Agent*

https://comfy-organization.slack.com/archives/C0A4XMHANP3/p1778150003248989
FE-603
---

## Summary

Send `share_id` alongside `published_asset_ids` from
`workflowShareService.importPublishedAssets`, and type / zod-validate
the body against `ImportPublishedAssetsRequest` +
`zImportPublishedAssetsRequest` from `@comfyorg/ingest-types`.

This is a **parameter-schema change**, not a live bug fix. The original
`BAD_REQUEST: share_id is required` failure is **no longer reproducible
in production** — the backend rolled back the required-field enforcement
in [BE-855](https://linear.app/comfyorg/issue/BE-855) (cloud
[#3587](https://github.com/Comfy-Org/cloud/pull/3587) /
[#3588](https://github.com/Comfy-Org/cloud/pull/3588)). Today the import
succeeds without `share_id`.

- Closes FE-603

## Why we still ship this

Frontend goes first so the backend can later re-tighten the contract
without breaking shared-workflow imports a second time. Agreed sequence
in
[Slack](https://comfy-organization.slack.com/archives/C0A4XMHANP3/p1778535349236419)
— BE-6 was split into two sub-issues to make the chain explicit:

1. **[BE-898](https://linear.app/comfyorg/issue/BE-898)** (blocks
FE-603) — BE makes `share_id` **optional**, validating when present.
[cloud PR #3633](https://github.com/Comfy-Org/cloud/pull/3633), In
Review.
2. **FE-603** (this PR) — FE sends `share_id`.
3. **[BE-899](https://linear.app/comfyorg/issue/BE-899)** (blocked by
FE-603) — BE flips `share_id` back to **required** after FE-603 deploys.

Steps 1 and 2 ship in order: BE-898 → FE-603 → BE-899.

## Changes

- `workflowShareService.importPublishedAssets` now takes `shareId:
string` and types the request body with `ImportPublishedAssetsRequest`.
The body is parsed through `zImportPublishedAssetsRequest` before the
network call so future contract drift surfaces as a typecheck or zod
parse failure rather than a silent runtime 400.
- `useSharedWorkflowUrlLoader.ts` threads `payload.shareId` (already in
scope from `SharedWorkflowPayload`) into the import call.
- Unit tests assert `share_id` is sent and that an empty `share_id` is
rejected before fetch.

## Verification

- `pnpm typecheck` — clean
- `pnpm lint` — 0 errors / 0 warnings on changed files (3 pre-existing
warnings in unrelated files)
- `pnpm format` — applied
- `pnpm vitest run` on both affected files — 34/34 passing

### Live in-browser smoke test (Vite dev server)

Loaded the built module in the browser, intercepted `fetch`, and
exercised the new code path. The new `importPublishedAssets` produces
the correct wire format and zod rejects bad input before fetch:

```json
// Happy path — sent to /api/assets/import (POST)
{
  "published_asset_ids": ["pa-1", "pa-2", "pa-3"],
  "share_id": "share-abc"
}
```

```text
// Empty share_id — zod rejects, fetch is never called
[ { code: "too_small", minimum: 1, path: ["share_id"], message: "String must contain at least 1 character(s)" } ]
fetchCallsBetweenAttempts: 0
```

## Why typecheck didn't catch the original miss

`api.fetchApi(route: string, options?: RequestInit)` accepts the
standard DOM `RequestInit`, so `body` is just `BodyInit | null`. Once
the call site does `JSON.stringify({ ... })`, the inline object is
erased into a string and TS has no schema to enforce. There was no
`@ts-ignore` or `as any` — the generated types were simply never
imported. This PR plugs that one call site; the same pattern should be
applied wherever the frontend hits ingest endpoints (the broader hey-zod
migration gap mentioned in #bug-dump).

Reported in #bug-dump.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12055-fix-include-share_id-when-importing-published-assets-3596d73d365081c0a1c7e69102f5cfcc)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Glary-Bot <glary-bot@users.noreply.github.com>
Co-authored-by: Alexander Brown <drjkl@comfy.org>
2026-05-14 04:13:16 +00:00
Christian Byrne
d96be3d668 feat(#3410): add centralized assert() utility in src/base/ (#11824)
## Summary

Add a shared `assert(condition, message)` utility in `src/base/` that
centralizes DEV-throw / Desktop-Sentry / nightly-toast / `console.error`
policy for invariant reporting across the codebase.

## Changes

- **`src/base/assert.ts`**: New `assert()` utility with
`setAssertReporter()` registration pattern
  - `console.error` always fires on failure
  - Throws `Error` in DEV mode (surfaces bugs immediately)
  - Delegates to registered reporter otherwise (Sentry, toast, etc.)
- No imports from `platform/` — respects layer architecture (`base →
platform → workbench → renderer`)
- **`src/main.ts`**: Registers Sentry + nightly-toast reporter after
`Sentry.init()`
- **`src/scripts/changeTracker.ts`**: Migrates
`reportInactiveTrackerCall()` to use `assert()`, removing inline
`Sentry.captureMessage` + `console.warn` calls
- **`src/scripts/changeTracker.test.ts`**: Mocks `@/base/assert` to
prevent DEV-mode throws in existing no-op tests

## Testing

### Automated

- `src/base/assert.test.ts` — 6 tests covering: no-op on true,
console.error on false, DEV throw, non-DEV no-throw, reporter
invocation, reporter not called on true
- `src/scripts/changeTracker.test.ts` — 16 tests all pass (pre-existing)
- Coverage: 100% for assert.ts

### E2E Verification Steps

1. Run `pnpm test:unit` — all tests pass
2. Build the app and open browser devtools
3. In DEV mode: trigger a lifecycle violation (call an inactive tracker
method) — should see error thrown in console
4. In production build: same trigger — should see `console.error` only,
no throw

## Review Focus

- `setAssertReporter()` is called in `main.ts` once at startup —
appropriate for a singleton reporter. In tests that import `assert`, the
reporter is reset to a no-op in `afterEach`.
- Layer architecture respected: `base/assert.ts` has zero imports, upper
layers wire in side effects via `setAssertReporter()`.

Fixes #11373

<!-- Pipeline-Ticket: pick-issue-3410 -->

┆Issue is synchronized with this [Notion
page](https://app.notion.com/p/PR-11824-feat-3410-add-centralized-assert-utility-in-src-base-3546d73d3650819d96afdf4018161c26)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: GitHub Action <action@github.com>
Co-authored-by: Connor Byrne <c.byrne@comfy.org>
2026-05-14 02:42:16 +00:00
Dante
1869416185 fix: surface error dialog when Open Workflow from Job Queue fails (FE-215) (#12071)
## Summary

`openJobWorkflow` (Job Queue → "Open Workflow in New Tab") had no error
handling around `workflowService.openWorkflow`. When workflow JSON
contained nodes that fail `LiteGraph.configure()` (e.g. rgthree
`DisplayAny`, stale `GetNode/SetNode aux_id`), the menu action appeared
to do nothing — either an early return after a generic "Load Workflow
Error" dialog (`app.ts:1340-1347`) or a context-less toast from the
surrounding `wrapWithErrorHandlingAsync`.

This PR catches the error inside `openJobWorkflow` and routes it to
`dialogService.showErrorDialog` with a Job-Queue-specific `reportType`
so users get a clear, actionable message tied to the action they
invoked.

- Fixes #8841
- Linear:
[FE-215](https://linear.app/comfyorg/issue/FE-215/open-workflow-from-frontend-not-working)

## Red-Green Verification

| Commit | CI Status | Run |
|--------|-----------|-----|
| `test: add failing test for openJobWorkflow swallowing load errors`
(a422b392d) | 🔴 failure |
https://github.com/Comfy-Org/ComfyUI_frontend/actions/runs/25531063427 |
| `fix: surface error dialog when Open Workflow from Job Queue fails`
(86b2a3a9) | 🟢 success |
https://github.com/Comfy-Org/ComfyUI_frontend/actions/runs/25531374096 |

## Test Plan

- [x] CI red on test-only commit
- [x] CI green on fix commit
- [ ] Manual: load a workflow containing rgthree `DisplayAny` from Job
Queue → "Open Workflow in New Tab" → confirm error dialog appears with
Job-Queue context instead of nothing
<img width="972" height="662" alt="Screenshot 2026-05-13 at 5 58 31 PM"
src="https://github.com/user-attachments/assets/bf1d8d96-85b7-47a8-89d8-b3bb69a526cb"
/>
2026-05-14 02:32:32 +00:00
jaeone94
a3106c4d53 fix: open node info panel from context menu (#12205)
## Summary

Replaces #12164.

Right-clicking a Vue node, using the selection toolbox More Options
menu, or clicking the selection toolbox Node Info button now opens the
right-side Info tab only when the new-menu UI makes that panel
available. Legacy-menu contexts hide the no-op action even when the
legacy node library design is selected; node-library help remains
isolated to the node library itself. The existing
`selection_toolbox_node_info_opened` telemetry fires only after the
toolbox button successfully opens node info. No new context-menu
telemetry event is added in this PR.

## Changes

- **What**: Share the node-info availability/action path across the
context menu and selection toolbox, keep legacy-menu state out of the
right-side panel public store API, tighten node-info settings tests, and
add unit plus E2E regression coverage for new-menu and legacy-menu
modes.
- **Dependencies**: None

## Review Focus

Confirm the node context menu, selection toolbox direct Info button, and
selection toolbox More Options entry all respect right-side panel
availability, including legacy menu + legacy node library mode, while
node-library help behavior remains isolated to the node library.

## Validation

- Self-review: checked production path, unit mocks, and Playwright
coverage; only gap found was weak E2E coverage for the toolbox direct
Info path, now strengthened.
- `pnpm test:unit -- src/composables/graph/useSelectionState.test.ts
src/components/graph/SelectionToolbox.test.ts
src/components/graph/selectionToolbox/InfoButton.test.ts`
- `pnpm test:browser:local -- --project=chromium
browser_tests/tests/selectionToolboxActions.spec.ts
browser_tests/tests/selectionToolboxSubmenus.spec.ts
browser_tests/tests/vueNodes/interactions/node/contextMenu.spec.ts
--grep "info button opens the right-side info tab|info button is
hidden|hides Node Info|should open node info"`
- `pnpm typecheck:browser`
- `pnpm exec oxlint --type-aware
browser_tests/tests/selectionToolboxActions.spec.ts`
- `pnpm exec eslint --cache --no-warn-ignored
browser_tests/tests/selectionToolboxActions.spec.ts`
- `pnpm exec oxfmt --check
browser_tests/tests/selectionToolboxActions.spec.ts`
- `git diff --check`
- Commit hooks: lint-staged + `pnpm typecheck` + `pnpm
typecheck:browser`
- Push hook: `knip --cache` (existing tag hint only)

## Screenshots (if applicable)
Before 


https://github.com/user-attachments/assets/4b1f6ddb-a01c-4958-81ab-36167f434e59


https://github.com/user-attachments/assets/83433f0d-24f1-46b7-a81d-f0f065812496

After 


https://github.com/user-attachments/assets/30bd61e5-f8d4-48b7-97e0-26c93e3cb362


https://github.com/user-attachments/assets/afce9f51-a43d-434f-a006-6b357a61ac8f

---------

Co-authored-by: github-actions <github-actions@github.com>
2026-05-14 02:26:11 +00:00
Comfy Org PR Bot
b59b342a43 1.45.7 (#12238)
Patch version increment to 1.45.7

**Base branch:** `main`

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12238-1-45-7-3606d73d36508138981ee641470344e4)
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>
2026-05-14 02:21:20 +00:00
jaeone94
95e616b894 fix: clear media upload errors via widget change (#12212)
## Summary

Clear missing media validation errors after paste/drop media uploads by
emitting the existing widget-change event path.

## Changes

- **What**: Emit `node.onWidgetChanged` after image/video upload
completion updates the file combo widget.
- **What**: Emit the same widget-change path after Load Audio upload
completion.
- **What**: Add unit coverage for upload completion emitting
`onWidgetChanged` and for missing media clearing through that existing
hook path.
- **What**: Add E2E coverage for Load Image drag/drop and paste clearing
validation rings, with red/green verified from a fresh `main` base.
- **Dependencies**: None.

## Review Focus

Please check that paste/drop upload paths now reuse the existing
widget-change error-clearing path instead of expanding `widget.callback`
patching.

Also check the Load Image E2E helper path for synthetic paste/drop
behavior.

Supersedes #12207.

Ref: FE-687

## Screenshots

Before 


https://github.com/user-attachments/assets/2cee52bc-b1c8-4dff-8a02-5b18a69ae639

After 


https://github.com/user-attachments/assets/e1ecd147-1d8a-470e-b77d-13345d473ef3



┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12212-fix-clear-media-upload-errors-via-widget-change-35f6d73d365081bcb1a0dfc042d417eb)
by [Unito](https://www.unito.io)
2026-05-14 01:28:41 +00:00
Alexis Rolland
5738c7a539 Add SaveAudioAdvanced to whitelisted nodes (#12213)
## Summary

Add `SaveAudioAdvanced` to whitelisted nodes in order to display the
audio player. This PR goes with the core PR:
https://github.com/Comfy-Org/ComfyUI/pull/13871

## Changes

- **What**: Display audio player on new node `SaveAudioAdvanced`

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12213-Add-SaveAudioAdvanced-to-whitelisted-nodes-35f6d73d3650815783fac52d3d37e1e1)
by [Unito](https://www.unito.io)
2026-05-14 00:10:06 +00:00
Christian Byrne
8abfa678a3 fix: harden e2e coverage workflow and fix GH Pages deploy (#11381)
## Problem

The GH Pages coverage deploy has been failing since #11291 merged —
every `CI: E2E Coverage` workflow run errors out and
https://comfy-org.github.io/ComfyUI_frontend/ returns 404.

Additionally, two correctness/security issues were identified in the
workflow (filed as #11374 and #11375).

## Changes

1. **`--ignore-errors source` on genhtml** — merged LCOV data includes
paths like `localhost-8188/assets/main-BRkC1B8m.js` from Playwright V8
coverage instrumented runtime bundles that don't exist as source files
in CI, causing genhtml to error out
2. **Pin checkout to `workflow_run.head_sha`** — in `workflow_run`
context, the default checkout ref points to the default branch, not the
commit that triggered the upstream run; genhtml could annotate against
wrong source files (#11375)
3. **Gate deploy on `event == 'push'`** — a fork branch named `main`
could satisfy the branch check and overwrite production coverage; adding
the event guard prevents this (#11375)
4. **Include workflow run link in placeholder HTML** — when no coverage
data is available, the placeholder page now links back to the workflow
run for debugging (#11374)

## Fixes

- Fixes the GH Pages 404 caused by #11291
- Fixes #11374
- Fixes #11375
2026-05-13 23:06:36 +00:00
Christian Byrne
b36b601a1c refactor(litegraph): prune dead surfaces (AUDIT-LG implementation, draft) (#12228)
Implements the AUDIT-LG verdicts (#12223, #12224, #12225) as a single
deletion PR off main.

> **DRAFT — sequencing.** Per the AUDIT-LG framing doc, deletions land
**after Phase B ECS migration** (Alex's #11939 + #11811). Open early to
capture the diff and let CI cascade-flag any unused-export fallout. Flip
to ready-for-review post-Alex-rebase.

## Status

| commit | scope | status |
|---|---|---|
| 1 | Delete 6 LGraph stepping hooks (`onAfterStep`, `onBeforeStep`,
`onPlayEvent`, `onStopEvent`, `onAfterExecute`, `onExecuteStep`) + their
dispatch sites in `start()`/`stop()`/`runStep()` |  landed (this
commit) |
| 2 | Delete the rest of the dead executor cluster
(`start()`/`stop()`/`runStep()`/`sendEventToAllNodes()` + state fields +
`STATUS_*` constants) | follow-up |
| 3 | Delete `LGraphNode` dead event hooks (~22 fields per #12224) |
follow-up |
| 4 | Delete trigger/action subsystem (~22 symbols, #12223) | follow-up
|
| 5 | `ON_EVENT` deprecation cycle, release N (#12225) | follow-up |

## Verification (commit 1)

```
$ pnpm lint && pnpm format:check && pnpm knip
✓ format: All matched files use the correct format
✓ lint: pre-existing icon-name warnings only
✓ knip: no new unused exports flagged
```

## Verdicts the deletion is grounded in

- AUDIT-LG.7 master verdict table (146 surfaces classified DELETE-NOW /
DEPRECATE / KEEP)
- AUDIT-LG.9 per-symbol attribution sweep (confirmed zero functional
callers for the trigger cluster + the dead hooks)

For each surface in this PR:

- **`internal_count` from rg over `src/`, `browser_tests/`, `packages/`
excluding `lib/litegraph/`:** 0
- **External use from touch-points DB:** 0 (per AUDIT-LG.3 + AUDIT-LG.9
per-symbol attribution)
- **Host methods (`start()`/`stop()`/`runStep()`):** `@deprecated 'Will
be removed in 0.9'` already

The dispatch sites (`this.onAfterStep?.()` etc.) are inside the
deprecated host methods — removing the dispatchers does not change
observable behaviour because no listener is attached to begin with.

## Why batched, why draft

Per AUDIT-LG framing, the deletion sequences behind Alex's PR #11939
(ECS world-combo). Opening as draft lets CI run early and flags any
unused-export cascades the audit script missed. Each follow-up commit
will be its own atomic deletion (one feature per commit) so any single
one can be reverted in isolation if needed.

cc @drjkl @christian-byrne

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12228-refactor-litegraph-prune-dead-surfaces-AUDIT-LG-implementation-draft-35f6d73d365081e0b72cd292228e2ca6)
by [Unito](https://www.unito.io)

Co-authored-by: Connor Byrne <c.byrne@comfy.org>
2026-05-13 23:03:32 +00:00
jaeone94
ad63f7cb9b test: cover missing media runtime sources (#12126)
## Summary

Adds browser coverage for the missing-media runtime paths introduced by
#12069 and #12111:

- OSS: annotated `[output]` media is resolved from job history.
- Cloud: compact `[output]` media is resolved from output assets.
- OSS and Cloud: dropped video uploads do not surface a missing-media
error while the upload is still in progress.

This PR is now rebased directly onto `main`; the parent fix PRs have
been squash-merged, so this branch only contains the E2E coverage
commit.

## Test Fixtures

- Adds workflow fixtures for OSS spaced output annotations and Cloud
compact output annotations.
- Adds a small plain MP4 fixture for video drag/drop upload coverage.

## Validation

- `pnpm exec oxfmt --check
browser_tests/tests/propertiesPanel/errorsTabMissingMediaRuntime.spec.ts
browser_tests/assets/missing/missing_media_cloud_output_annotation.json
browser_tests/assets/missing/missing_media_output_annotations.json`
- `pnpm typecheck:browser`
- `pnpm exec oxlint
browser_tests/tests/propertiesPanel/errorsTabMissingMediaRuntime.spec.ts
--type-aware`
- `git diff --check origin/main..HEAD`

Note: before the rebase, the Cloud project target for this spec passed
locally. Local OSS project execution against the currently running Cloud
dist did not reach the test body because `ComfyPage.waitForAppReady`
timed out in `beforeEach`.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12126-test-cover-missing-media-runtime-sources-35d6d73d365081f0a981c02f33c0ff84)
by [Unito](https://www.unito.io)
2026-05-13 22:46:47 +00:00
Terry Jia
f7ef563b46 FE-657: prevent browser zoom on ctrl+wheel in mask editor (#12215)
## Summary

Wheel events on the mask editor pointer zone now call preventDefault,
matching the main canvas behavior so ctrl+wheel only zooms the mask
canvas instead of also triggering page zoom.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12215-FE-657-prevent-browser-zoom-on-ctrl-wheel-in-mask-editor-35f6d73d36508131a9b8dbf2f6640d72)
by [Unito](https://www.unito.io)
2026-05-13 18:24:22 -04:00
AustinMroz
9cc09cd46c Add additional subgraph test fixtures and tests (#11806)
- Adds functions to SubgraphHelper to perform widget promotion by
standard user means
  - Right Click -> Promote
  - Properties Panel
- Adds new slot fixture code that works with simple `locator.dragTo`
operations.
- Adds multiple subgraph tests with a focus on historically difficult
operations.
- Fixes a bug where the litegraph `node.selected` state would not be
unset when switching graphs. This made it so 'Selecting a node ->
leaving subgraph -> re-enter subgraph -> right click on node' would fail
to select the node because it is marked as already selected.

┆Issue is synchronized with this [Notion
page](https://app.notion.com/p/PR-11806-Add-helper-functions-for-widget-promotion-3536d73d365081f58dd9cd730c1a91a9)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Alexander Brown <drjkl@comfy.org>
2026-05-13 20:35:57 +00:00
pythongosssss
de1c1ee1f2 fix: add support for parsing python generated json with NaN/infinite (#12217)
## Summary

API and other legacy JSON generated by python `json.dumps` can contain
`NaN` and `Infinity` which cannot be parsed with JS `JSON.parse`. This
adds regex to replace these invalid tokens with `null`.

## Changes

- **What**: 
- add regex replace on bare NaN/infinity tokens after JSON.parse fails
- update call sites
- tests

## Review Focus
- The regex should only rewrite bare NaN/-Infinity/Infinity and not
touch string values or other invalid tokens.
- A small regex was chosen over JSON5 due to package size (30.3kB
Minified, 9kB Minified + Gzipped) or a manual parser due to the
unnecessarily complexity vs a single regex replace.
- The happy path is run first, the safe parse is only executed if that
failed, meaning no overhead the vast majority of the time and no
possiblity of corrupting valid workflows due to a bug in the fallback
parser
- Multiple call sites had to be updated due to pre-existing architecture
of the various parsers, an issue for unifying these is logged for future
cleanup
- New binary fixtures added for validating e2e import using real files

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12217-fix-add-support-for-parsing-python-generated-json-with-NaN-infinite-35f6d73d365081889fc7f4af823f29c1)
by [Unito](https://www.unito.io)
2026-05-13 20:33:19 +00:00
AustinMroz
86b1e1a965 Fix descriptions on core blueprints (#12220)
Core blueprints were storing the description under a different key than
expected, which resulted in them displaying a placeholder description.
When initializing the description for a subgraph, this alternative field
is also checked.

| Before | After |
| ------ | ----- |
| <img width="360" alt="before"
src="https://github.com/user-attachments/assets/ed51c4a8-00cf-4927-9cba-880532a9e926"
/> | <img width="360" alt="after"
src="https://github.com/user-attachments/assets/f19bf80d-adcc-4e9b-a9ba-a5ac8e089e2d"
/>|

Resolves FE-681

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12220-Austin-blueprint-descriptions-35f6d73d3650812fa04df48c203bebd1)
by [Unito](https://www.unito.io)
2026-05-13 20:30:45 +00:00
pythongosssss
4321013798 fix: resolve widget input link position drift on reload (#12214)
## Summary
The position of a link relative to its slot was able to drift on load,
due to widgets inside a node being able to resize without triggering an
node-level resize event (min-height node with space at the bottom could
have widgets expand into free space, causing misalignment).

Recreation:
1. Add KSampler
2. Add Float
3. Connect Float to KSamper.denoise
4. Reload workflow (F5)
5. Observe misalignment

## Changes

- **What**: 
- track widget grid element as signal only that triggers resync
- node bound calculations skipped for widget signals
- prevent setDirty on non-graph nodes (e.g. LGraphNodePreview)
- tests

## Review Focus
This is a small focused approach to fix the reported issue - it does not
address the underlying issue of the layout not being a SSOT. This fix is
a small bandaid and investigation into resolving the layout SOT issue is
not impacted by this.

## Screenshots (if applicable)

Before:
<img width="673" height="374" alt="image"
src="https://github.com/user-attachments/assets/2d34b8e3-0731-4fd2-8553-4dd429010ced"
/>

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12214-fix-resolve-widget-input-link-position-drift-on-reload-35f6d73d3650814eb31bebb3042ff58b)
by [Unito](https://www.unito.io)
2026-05-13 20:00:50 +00:00
pythongosssss
7ce0973386 fix: prevent first user template popup when following shared link (#12024)
## Summary

When a user who has not used the app before first loads up, they are
presented with the template selection dialog. This conflicts when the
first-time user visits the app via a share link - both the share &
template dialog are triggered.

## Changes

- **What**: 
- Skip the templates browser when share param is in URL
- Tests
- Add `url` to `setup`/`goto` to allow specifying the `share` parameter

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12024-fix-prevent-first-user-template-popup-when-following-shared-link-3586d73d365081cbbcecdba45a1ad1ea)
by [Unito](https://www.unito.io)
2026-05-13 19:19:54 +00:00
Yourz
6e9be7b164 feat: add Anthropic partner icon (#12216)
*PR Created by the Glary-Bot Agent*

---

## Summary

Adds the Anthropic logo to the partner-node icon set so nodes whose
category ends in `Anthropic` (e.g. the Claude node added in
Comfy-Org/ComfyUI#13867 with `category="api node/text/Anthropic"`)
render the correct provider badge in the node library.

## Changes

- `packages/design-system/src/icons/anthropic.svg` — new auto-discovered
partner icon (Anthropic A glyph, sourced from
[lobehub/lobe-icons](https://github.com/lobehub/lobe-icons), uses
`fill="currentColor"` for theme adaptation)
- `src/utils/categoryUtil.ts` — register Anthropic's brand coral
`#D97757` as the badge border color
- `packages/design-system/src/css/style.css` — add `anthropic` to the
dynamic comfy-icon safelist so Tailwind/Iconify emits CSS for
`icon-[comfy--anthropic]` in production builds
- `src/utils/categoryUtil.test.ts` — regression tests for
`getProviderIcon('Anthropic')` and `getProviderBorderStyle('Anthropic')`

## Verification

- `pnpm typecheck` ✓
- `pnpm lint` ✓ (0 errors; 3 pre-existing warnings in unrelated files)
- `pnpm format:check` ✓
- `pnpm test:unit -- src/utils/categoryUtil.test.ts` ✓ (13/13)
- `pnpm build` ✓ — confirmed `comfy--anthropic` class is emitted into
`dist/assets/index-*.css`
- Manual visual check via Playwright against `pnpm dev`: injected `<i
class="icon-[comfy--anthropic]">` elements at badge size (10px) and 48px
alongside the existing OpenAI and BFL icons and confirmed the Anthropic
"A" glyph renders correctly in coral. See screenshot.

End-to-end visual verification of the live badge in the node library
requires Comfy-Org/ComfyUI#13867 to land first (the Claude node is what
produces the `Anthropic` category that triggers the icon lookup).

Related: Comfy-Org/ComfyUI#13867

## Screenshots

![Anthropic icon rendered in coral alongside OpenAI and BFL partner
icons at badge and large
sizes](https://pub-1fd11710d4c8405b948c9edc4287a3f2.r2.dev/sessions/c0076decedd8863eec6253b44e583da6b3eaacc20081d126aaf5267c72c8cc84/pr-images/1778683078329-49e37a7b-86ed-4ef2-988f-5702433f8412.png)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12216-feat-add-Anthropic-partner-icon-35f6d73d36508133a134fcafaf72f4f8)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Glary-Bot <glary-bot@users.noreply.github.com>
2026-05-13 17:01:49 +00:00
Terry Jia
4b5b184cad FE-566: fix Painter mask submission edge cases on cloud (#12196)
## Summary
Rework the painter always hands the backend a valid asset reference:
- Drop the `hasStrokes` flag and the `isCanvasEmpty` check.
- `serializeValue` falls back to the existing `modelValue` when the
canvas element is transiently unmounted, reuses the cached upload when
not dirty and a value is present, and otherwise uploads the current
canvas (a fully transparent PNG is a valid no-op mask, Painter's Python
`execute()` treats painter_alpha=0 the same as "no mask painted").
- `handleClear` now also clears `modelValue` so a user-initiated clear
doesn't resurrect a stale upload on the next serialize.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12196-FE-566-fix-Painter-mask-submission-edge-cases-on-cloud-35e6d73d365081dd8856ddb785952526)
by [Unito](https://www.unito.io)
2026-05-13 10:19:39 -04:00
pythongosssss
129bfd9f1b fix: fix drop location and zindex of dragged in images (#12194)
## Summary

Images dragged into the canvas were placed at the last graph mouse
position, which is not updated during the drag event - meaning nodes
were created in "random" locations. Additionally, the z-index was not
set so newly created nodes can appear under other nodes.

## Changes

- **What**: 
- ensure added nodes are at top level
- update graph mouse pos with position from drop event
- tests

## Screenshots (if applicable)


Before / After

https://github.com/user-attachments/assets/34b4652e-a834-4c22-b191-2875a2404ac5

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12194-fix-fix-drop-location-and-zindex-of-dragged-in-images-35e6d73d3650814781edc9f4b4b5b223)
by [Unito](https://www.unito.io)
2026-05-13 10:35:16 +00:00
Christian Byrne
e60ae14bc0 feat(website): hide Free tier behind SHOW_FREE_TIER flag (#12165)
*PR Created by the Glary-Bot Agent*

---

## Summary

Disables the Free tier on the public marketing website
(`comfy.org/cloud/pricing` and `comfy.org/cloud`) behind a single
boolean flag so re-enabling is a one-line change.

The Free tier was already removed from the Comfy Cloud sign-up flow;
this PR removes the matching promotional surfaces on the marketing site
so users hit the paywall directly.

## Changes

- **New** `apps/website/src/config/features.ts` — exports
`SHOW_FREE_TIER` (currently `false`). Flip to `true` to restore the
previous UX.
- **`apps/website/src/components/pricing/PriceSection.vue`** — when
`SHOW_FREE_TIER` is `false`:
  - drops the Free plan card from the pricing array
  - desktop grid collapses from `lg:grid-cols-4` to `lg:grid-cols-3`
- Standard plan's "Everything in Free, plus:" intro is replaced with an
aria-hidden spacer so the three remaining cards stay vertically aligned
- **`apps/website/src/components/product/cloud/PricingSection.vue`** —
hides the "Start free. Upgrade when you're ready." tagline on the
`/cloud` pricing teaser.
- **New** `apps/website/e2e/pricing.spec.ts` — three @smoke tests
asserting the paid tiers + Enterprise are visible and that all Free-tier
surfaces are absent.

All translation strings (`pricing.plan.free.*`, `cloud.pricing.tagline`)
are retained so re-enabling requires no copy work.

## Verification

- `pnpm typecheck` — clean (0 errors, 0 warnings; pre-existing hint
unrelated)
- `pnpm lint` / `oxfmt` — clean
- `pnpm test:unit` — 30/30 passing
- Playwright e2e (desktop project) — `pricing.spec.ts` 3/3 passing,
`cloud.spec.ts` 13/13 still passing
- Visual: desktop and mobile pricing pages render with balanced 3-column
layout; `/cloud` teaser card still proportional.

Screenshot below.

## Screenshots

![/cloud/pricing desktop with Free tier hidden: balanced 3-column layout
(Standard, Creator, Pro) and Enterprise section
below](https://pub-1fd11710d4c8405b948c9edc4287a3f2.r2.dev/sessions/732492b43e4794dae051b2b86643e7a5d3daebce028765f27470561681d4f1db/pr-images/1778561413584-9c1a82d1-781f-49d4-9a26-cc7523c903c4.png)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12165-feat-website-hide-Free-tier-behind-SHOW_FREE_TIER-flag-35e6d73d36508164b4dfcfe9fee6b5e7)
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-13 05:40:09 +00:00
Comfy Org PR Bot
b172534f55 [chore] Update Ingest API types from cloud@9619326 (#12202)
## Automated Ingest API Type Update

This PR updates the Ingest API TypeScript types and Zod schemas from the
latest cloud OpenAPI specification.

- Cloud commit: 9619326
- Generated using @hey-api/openapi-ts with Zod plugin

These types cover cloud-only endpoints (workspaces, billing, secrets,
assets, tasks, etc.).
Overlapping endpoints shared with the local ComfyUI Python backend are
excluded.

---------

Co-authored-by: MillerMedia <7741082+MillerMedia@users.noreply.github.com>
Co-authored-by: GitHub Action <action@github.com>
2026-05-13 03:47:03 +00:00
Comfy Org PR Bot
a0150ffe17 1.45.6 (#12204)
Patch version increment to 1.45.6

**Base branch:** `main`

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12204-1-45-6-35f6d73d365081fc8539ca25a55aac74)
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>
2026-05-13 02:01:42 +00:00
Christian Byrne
c92030b158 refactor: deduplicate Civitai hostname logic in getSourceName (#11822)
## Summary

- Extract `isCivitaiHost` private helper from `isCivitaiModelUrl` in
`formatUtil.ts` for DRY hostname checking
- Add `isCivitaiUrl` exported function for hostname-only Civitai URL
detection (distinct from `isCivitaiModelUrl` which also validates the
path format)
- Refactor `getSourceName` in `assetMetadataUtils.ts` to use the shared
`isCivitaiUrl` instead of inline duplicate hostname checks
- Add tests for `isCivitaiUrl` covering `.com`, `.red`, subdomain, and
invalid URL cases

## Changes

- `packages/shared-frontend-utils/src/formatUtil.ts` — add
`isCivitaiHost` private helper + export `isCivitaiUrl`; refactor
`isCivitaiModelUrl` to use helper
- `packages/shared-frontend-utils/src/formatUtil.test.ts` — add
`isCivitaiUrl` test suite
- `src/platform/assets/utils/assetMetadataUtils.ts` — import
`isCivitaiUrl` from `@/utils/formatUtil`; remove inline hostname logic
from `getSourceName`

## Testing

### Automated

- Added `isCivitaiUrl` test suite (6 cases: `.com`, `.red`, subdomains,
non-Civitai, invalid URL)
- All 71 existing `formatUtil` tests pass
- All 53 existing `assetMetadataUtils` tests pass (behavior preserved)
- TypeScript typecheck passes

### E2E Verification Steps

1. Run unit tests: `npx vitest run
packages/shared-frontend-utils/src/formatUtil.test.ts
src/platform/assets/utils/assetMetadataUtils.test.ts`
2. Expected: all tests pass
3. Verify `getSourceName('https://civitai.red/models/123')` returns
`'Civitai'`
4. Verify `isCivitaiUrl('https://civitai.com/models/any-path')` returns
`true`
5. Verify `isCivitaiModelUrl` still rejects non-API paths while
`isCivitaiUrl` accepts them

## Review Focus

`isCivitaiUrl` (new, hostname-only) vs `isCivitaiModelUrl` (existing,
hostname+path format): `getSourceName` needs to recognize ANY Civitai
URL as a source, so using `isCivitaiModelUrl` directly would incorrectly
reject valid browse URLs like `civitai.com/models/123`.

Closes #11357

┆Issue is synchronized with this [Notion
page](https://app.notion.com/p/PR-11822-refactor-deduplicate-Civitai-hostname-logic-in-getSourceName-3546d73d36508110974ccc3b7384d82b)
by [Unito](https://www.unito.io)

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 18:33:33 -07:00
Dante
988d532467 fix(queue): contain JobDetailsPopover error message overflow (#12173)
## Summary

Cap the Job Details "Error message" block at `max-h-96` (24rem / 384px)
with an internal scroll, wrap long unbreakable tokens (filenames, JSON),
and preserve newlines so the failed-job popover no longer grows
unbounded.

## Changes

- **What**: Added `max-h-96 overflow-y-auto whitespace-pre-wrap
wrap-break-word` to the error message container in
`JobDetailsPopover.vue`, plus a `FailedWithLongError` Storybook story
covering the overflow case.

## Review Focus

- 24rem cap was set per Alex's spec in the [Slack
thread](https://comfy-organization.slack.com/archives/C0A4XMHANP3/p1778506109115989).
- `wrap-break-word` (Tailwind 4 canonical of `break-words`) is needed
because long underscore-joined filenames don't break naturally;
`whitespace-pre-wrap` preserves any newlines in the raw error.
- Not in scope: the popover z-index clipping issue Alex flagged later in
the same thread — that's a separate follow-up.

Fixes FE-660

## Screenshots

**Before** — error block grows unbounded with the panel:


![before](https://github.com/Comfy-Org/ComfyUI_frontend/raw/jaewon/fe-660-contain-textarea-overflow-and-cap-its-max-height/.github/pr-images/fe-660-before.png)

**After** — error block capped at 384px and internally scrollable:


![after](https://github.com/Comfy-Org/ComfyUI_frontend/raw/jaewon/fe-660-contain-textarea-overflow-and-cap-its-max-height/.github/pr-images/fe-660-after.png)

Reproduce locally via Storybook: `pnpm storybook` → Queue →
JobDetailsPopover → **FailedWithLongError**.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12173-fix-queue-contain-JobDetailsPopover-error-message-overflow-35e6d73d3650812d9873e5d163cad0c6)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Benjamin Lu <benjaminlu1107@gmail.com>
2026-05-12 17:51:52 -07:00
AustinMroz
fe08ad2fcd Fix pre-commit linter skipping type checks (#12203)
Adds the `--type-aware` so that the typechecks performed by precommit
hooks have parity with the results output by a full `pnpm lint`

Most notably, unawaited promises would not be caught by the precommit
hooks prior to this PR.
```
    × typescript-eslint(no-floating-promises): Promises must be awaited, add void operator to ignore.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                       
      ╭─[browser_tests/fixtures/utils/vueNodeFixtures.ts:45:5]                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                              
   44 │   async select() {                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                  
   45 │     this.header.click()                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                             
      ·     ───────────────────                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                             
   46 │   }                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                 
      ╰──── 
```

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12203-Fix-pre-commit-linter-skipping-type-checks-35e6d73d365081a4adade833294df7ed)
by [Unito](https://www.unito.io)
2026-05-12 23:56:12 +00:00
Alexander Brown
93edf166d0 fix(website): link careers page to Ashby job description, not application form (#12200)
*PR Created by the Glary-Bot Agent*

---

## Summary

The careers page at comfy.org/careers was linking every role to its
Ashby application form (`.../{id}/application`) instead of the job
description page (`.../{id}/`). Users expect to first read the role
description, not land on the submit-resume page.

Ashby's job board API returns both `jobUrl` (description) and `applyUrl`
(application form). `toDomainRole` was preferring `applyUrl`; this PR
switches to `jobUrl` and renames the `Role` field accordingly so the
field name matches its meaning.

## Changes

- `apps/website/src/utils/ashby.ts`: use `job.jobUrl` directly instead
of `job.applyUrl ?? job.jobUrl`.
- `apps/website/src/data/roles.ts`: rename `Role.applyUrl` →
`Role.jobUrl`.
- `apps/website/src/components/careers/RolesSection.vue`: update the `<a
:href>` binding.
- `apps/website/src/data/ashby-roles.snapshot.json`: regenerated
fallback snapshot — URLs stripped of `/application`, `id`s recomputed
from the new URLs.
- Unit + E2E tests updated; new E2E assertion that links do not end in
`/application` prevents regressions.

The Ashby schema (`ashby.schema.ts`) still accepts `applyUrl` since the
API returns it — we just no longer consume it.

## Verification

- `pnpm test:unit` — 70/70 pass
- `pnpm typecheck` — 0 errors
- `pnpm build` — succeeds; inspected `dist/careers/index.html`, all 19
Ashby links now point to description URLs and zero contain
`/application`
- Oracle code review — 0 issues

Fixes user report in #hiring-ideas (Slack).

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12200-fix-website-link-careers-page-to-Ashby-job-description-not-application-form-35e6d73d3650815cbedadf974f7d3364)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Glary-Bot <glary-bot@users.noreply.github.com>
2026-05-12 23:32:15 +00:00
Dante
9fe19a2afb fix(settings): unify settings item heights and use 14px label text (#12180)
## Summary

Body text in the settings dialog was still rendering at the inherited
16px (browser default) instead of the 14px design spec, and rows with
different control types (toggle, slider, dropdown, radio) collapsed to
different heights — making the list look uneven and cramped.

## Changes

- **What**: `FormItem` label now uses `text-sm` (14px) and the row
enforces `min-h-8` (32px) so toggle/slider/dropdown/radio rows align.
`SettingGroup` bumps inter-item margin from `mb-2` to `mb-3` for
breathing room between settings.

## Review Focus

`FormItem` is also used by `ServerConfigPanel`, so the 14px/32px row
also applies there — consistent with the same settings-dialog visual
language, but worth a glance.

Fixes #FE-525

## Screenshots

Lite Graph panel (1280×900 viewport) showing
toggle/slider/dropdown/radio rows side-by-side:

| Before (`origin/main`) | After (this PR) |
| --- | --- |
| <img
src="https://raw.githubusercontent.com/Comfy-Org/ComfyUI_frontend/pr-12180-screenshots/before-litegraph.png"
width="480"> | <img
src="https://raw.githubusercontent.com/Comfy-Org/ComfyUI_frontend/pr-12180-screenshots/after-litegraph.png"
width="480"> |

Before: label text inherits 16px from `<body>`; toggle-only rows (e.g.
"Always snap to grid", "Live selection") shrink to ~24px while
dropdown/slider rows stay ~32px, so the list looks uneven and cramped.
After: labels are 14px; every row is at least 32px tall so
toggles/sliders/dropdowns/radios line up; `mb-3` adds 4px of breathing
room between rows.

## References

- Linear:
https://linear.app/comfyorg/issue/FE-525/verify-settings-text-size-and-item-heights
- Figma:
https://www.figma.com/design/vALUV83vIdBzEsTJAhQgXq/Comfy-Design-System?node-id=6290-75412
- Origin thread:
https://comfy-organization.slack.com/archives/C075ANWQ8KS/p1777657610484679?thread_ts=1776808927.654249

---------

Co-authored-by: github-actions <github-actions@github.com>
Co-authored-by: Benjamin Lu <benjaminlu1107@gmail.com>
2026-05-12 20:33:05 +00:00
Comfy Org PR Bot
6845d57a80 chore(website): refresh Ashby roles snapshot (#12191)
Automated refresh of `apps/website/src/data/ashby-roles.snapshot.json`
from the Ashby job board API.

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

The snapshot fallback in `apps/website/src/utils/ashby.ts` remains
intact: builds without `WEBSITE_ASHBY_API_KEY` continue to use the
committed snapshot.

Triggered by workflow run `25746888214`.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12191-chore-website-refresh-Ashby-roles-snapshot-35e6d73d365081f4b2e1d802dd412a72)
by [Unito](https://www.unito.io)

Co-authored-by: Yourz <8287689+Yourz@users.noreply.github.com>
2026-05-12 16:18:14 +00:00
Christian Byrne
469a5edf99 feat: cloud-nodes catalog at /cloud/supported-nodes (#11903)
*PR Created by the Glary-Bot Agent*

---

## Summary

Adds a comfy.org page that lists every custom-node pack supported on
Comfy Cloud, with per-pack detail subpages. Data is fetched at build
time from `cloud.comfy.org/api/object_info` (gated by
`WEBSITE_CLOUD_API_KEY`), sanitized of user content, joined with public
registry metadata from `api.comfy.org/nodes`, and falls back to a
committed snapshot — mirroring the existing Ashby careers integration
pattern.

- Index: `/cloud/supported-nodes` (en) and
`/zh-CN/cloud/supported-nodes` (zh-CN)
- Detail: `/cloud/supported-nodes/[pack]` and
`/zh-CN/cloud/supported-nodes/[pack]`, generated via `getStaticPaths()`
from the same fetcher as the index so the two routes can never diverge.

## What's new

**Shared package (extracted)**
- `@comfyorg/object-info-parser` — Zod schemas (`zComfyNodeDef`,
`validateComfyNodeDef`), node-source classifier (`getNodeSource`,
`isCustomNode`, `CORE_NODE_MODULES`), and helpers (`groupNodesByPack`,
`sanitizeUserContent`). `src/schemas/nodeDefSchema.ts` and
`src/types/nodeSource.ts` become 1-line re-export shims; existing
imports keep compiling.

**Build-time pipeline**
- `apps/website/src/utils/cloudNodes.ts` — Ashby-style fetcher:
retry/backoff `[1s, 2s, 4s]`, 10 s timeout via AbortController, Zod
envelope + per-node validation, snapshot fallback, memoized via
module-level `inflight` promise.
- `apps/website/src/utils/cloudNodes.registry.ts` — Public registry
enrichment (no auth, batches of 50, single retry, soft-fail).
- `apps/website/src/utils/cloudNodes.ci.ts` — GitHub Actions annotations
+ step summary mirroring the Ashby reporter.
- `apps/website/src/utils/cloudNodes.build.ts` — Single
`loadPacksForBuild()` consumed by both index and detail pages so they
share one source of truth.
- `apps/website/scripts/refresh-cloud-nodes-snapshot.ts` — atomic-rename
refresh CLI that walks pack/node string fields with a user-content
extension regex *before* renaming the snapshot into place.
- Mandatory user-content sanitization strips uploaded filenames from
combo lists (`LoadImage`, `LoadImageMask`, `LoadImageOutput`,
`LoadVideo`, `LoadAudio` zeroed; any combo value matching
`/\.(png|jpe?g|webp|gif|mp4|mov|webm|wav|mp3|flac|ogg|safetensors|ckpt|pt)$/i`
filtered).

**Page + components**
- `apps/website/src/pages/cloud/supported-nodes.astro` (en) + zh-CN
twin.
- `apps/website/src/pages/cloud/supported-nodes/[pack].astro` detail
(en) + zh-CN twin, async `getStaticPaths` driven by
`loadPacksForBuild()`.
-
`apps/website/src/components/cloud-nodes/{HeroSection,PackGridSection,PackCard,PackBanner,NodeList,PackDetail}.vue`
— Vue 3.5 destructured props, `cn()` from `@comfyorg/tailwind-utils`,
design-system tokens only, no PrimeVue.
- Pack card name links to its detail page; banner uses the shared
`fallback-gradient-avatar.svg` asset (copied into
`apps/website/public/assets/images/`) when `banner_url` and `icon` are
missing.
- 25 new `cloudNodes.*` i18n keys in `en` + `zh-CN`.

**Tests**
- 33 unit tests in `@comfyorg/object-info-parser` (schemas, classifier,
sanitizer, grouping).
- 19 new website unit tests covering fetcher (10), CI reporter (6),
registry enrichment (3) — Ashby patterns mirrored.
- E2E: index smoke + search + banner + detail click-through + direct
visit + zh-CN parity.

## Required maintainer follow-up

GitHub Apps cannot push `.github/workflows/*` changes (push was rejected
with `refusing to allow a GitHub App to create or update workflow …
without workflows permission`), so the workflow edits prepared in this
branch were reverted in commit `9be2abce8`. The intended diffs are
documented as copy-paste-ready snippets in `apps/website/README.md`
under the new "Cloud nodes integration → CI wiring" section.

A maintainer must:

1. Provision `WEBSITE_CLOUD_API_KEY` in the repo secrets and the Vercel
project env.
2. Apply the `ci-website-build.yaml` and
`ci-vercel-website-preview.yaml` diffs documented in the README directly
to `main` (or as a follow-up commit on this branch with a maintainer
account).

The committed snapshot lets builds succeed without the secret while the
maintainer step is pending — pages render from
`apps/website/src/data/cloud-nodes.snapshot.json`.

## Self-review (Oracle)

Two warnings caught and fixed in commits `deba5ab02` and `99dfc3381`:
- Index/detail pages now share a single source of truth
(`loadPacksForBuild`), so a fresh fetch can't expose packs whose detail
routes weren't generated.
- Refresh script validates parsed snapshot fields *before* the atomic
rename, instead of regex-scanning the serialized JSON after the file is
already in place.

## Quality gates (local)

```
pnpm --filter @comfyorg/object-info-parser test  →  33 passed
pnpm --filter @comfyorg/website test:unit         →  42 passed
pnpm --filter @comfyorg/website typecheck         →  0 errors
pnpm --filter @comfyorg/website build             →  47 pages built (incl. 6 cloud-nodes routes)
pnpm lint                                         →  0 errors (1 pre-existing warning in unrelated test file)
pnpm knip                                         →  0 errors (1 pre-existing tag hint in unrelated file)
```

E2E (`pnpm --filter @comfyorg/website test:e2e`) is intended to be run
by the Vercel/CI pipelines.

## Manual verification

Built `dist/`, served locally on port 4321, drove with Playwright:
- `/cloud/supported-nodes` renders both pack cards, search input, sort
dropdown
- `/cloud/supported-nodes/comfyui-impact-pack` renders the metadata grid
(publisher, downloads, stars, version, license, last updated) and 3
categorized node sections with 5 nodes total
- `/zh-CN/cloud/supported-nodes` localizes hero (`Comfy Cloud 上的自定义节点`),
label (`云端节点目录`), search placeholder (`搜索节点包或节点名称`), sort
- `/zh-CN/cloud/supported-nodes/comfyui-controlnet-aux` localizes every
metadata label (`查看仓库`, `发布者`, `下载量`, `GitHub 星标`, `最新版本`, `许可证`,
`最后更新`) and renders dates with `Intl.DateTimeFormat('zh-CN')`
(`2026年4月27日`)
- Search input narrows pack count from 2 to 1 when typing `impact`
(verified via DOM count)

Banners render the shared `fallback-gradient-avatar.svg` when the
snapshot's image URL doesn't resolve — expected in the local sandbox.

## Preview URL (after CI completes)

`https://comfy-website-preview-pr-{N}.vercel.app/cloud/supported-nodes`

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11903-feat-cloud-nodes-catalog-at-cloud-supported-nodes-3566d73d36508194afdec5f389897585)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Glary-Bot <glary-bot@users.noreply.github.com>
Co-authored-by: GitHub Action <action@github.com>
2026-05-12 15:51:24 +00:00
Christian Byrne
35443e94f5 feat(website): SEO model pages — 207 models, FAQ JSON-LD, partner node support (#11892)
## Summary

- Adds programmatic SEO model pages at `/p/supported-models/[slug]` for
**207 models** auto-generated from `workflow_templates` (180 local + 27
partner nodes)
- 3-file architecture: `generated-models.json` (auto-generated, checked
in) + `model-metadata.ts` (editorial overrides) + `models.ts` (65-line
merger)
- Full JSON-LD per page: `SoftwareApplication` + `BreadcrumbList` +
`FAQPage` (targeting AI Overviews / People Also Ask)
- Partner node support: `directory: 'partner_nodes'` hides Download
button, shows VIEW TUTORIAL
- `generate-models.ts`: walks `workflow_templates` for local models +
`API_PROVIDER_MAP` for 30+ partner integrations (Kling, Meshy, Luma,
Runway, Stability AI, ByteDance, Google, etc.)
- Weekly GH Actions workflow opens issue when new models appear in
`workflow_templates` but not in `generated-models.json`
- `add-model-page` Claude skill for Slack-driven model page PRs

## Files changed

| File | Purpose |
|------|---------|
| `apps/website/src/config/generated-models.json` | Auto-generated, 207
models (27 partner + 180 local) |
| `apps/website/src/config/model-metadata.ts` | Editorial overrides:
docsUrl, blogUrl, featured (9 entries) |
| `apps/website/src/config/models.ts` | 65-line merger — imports JSON +
overrides, exports `models` + `getModelBySlug` |
| `apps/website/scripts/generate-models.ts` | Build-time parser; run
with `pnpm generate:models` |
| `apps/website/src/i18n/translations.ts` | ~30 UI keys added (no
per-model keys — displayName is plain string) |
| `apps/website/src/pages/p/supported-models/[slug].astro` | Dynamic
route with 3x JSON-LD schemas |
| `apps/website/src/pages/p/supported-models/index.astro` | Model grid
index page |
| `apps/website/src/components/models/ModelHeroSection.vue` | Hero
component |
| `.github/workflows/model-page-discovery.yaml` | Weekly auto-discovery
workflow |
| `.claude/skills/add-model-page/SKILL.md` | Claude skill for
adding/updating model pages |

## Test plan

- [ ] `pnpm build` passes in `apps/website`
- [ ] `/p/supported-models` index renders 207 model cards
- [ ] `/p/supported-models/kling-ai` shows Partner Node eyebrow, no
Download button, VIEW TUTORIAL CTA
- [ ] `/p/supported-models/flux-1-dev` shows Diffusion Model eyebrow,
Download + Tutorial buttons
- [ ] `/p/supported-models/umt5-xxl-fp8-e4m3fn-scaled` redirects 301 to
`umt5-xxl-fp16` (canonicalSlug)
- [ ] Structured data validator shows FAQPage + SoftwareApplication +
BreadcrumbList valid

Fixes FE-421

---------

Co-authored-by: GitHub Action <action@github.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 14:50:23 +00:00
Dante
e05e6cd2fb test: FE-230 e2e regression for asset-delete clearing Load Image preview (#12131)
## Summary

Follow-up to #11493 — adds a `@cloud` Playwright spec that drives the
asset-deletion flow end-to-end and asserts the four FE-230 outcomes
wired up in `useMediaAssetActions.deleteAssets`.

- Path: `browser_tests/tests/assetDeleteClearsLoadImage.spec.ts`
- Flow: load `widgets/load_image_widget`, seed `widget.value` +
`node.imgs`, reset changeTracker, open assets sidebar → Imported tab →
right-click card → Delete → confirm dialog.
- Asserts (auto-retrying): DELETE `/assets/:id` request issued,
`widget.value === ''`, `node.imgs.length === 0`, `workflow.isModified
=== true`.
- Tagged `@cloud` because input-asset deletion is gated on `isCloud` in
`deleteAssetApi`.

Addresses [this review
thread](https://github.com/Comfy-Org/ComfyUI_frontend/pull/11493#pullrequestreview-4262129237)
from #11493.

## Test plan

- [ ] `pnpm build:cloud && pnpm exec playwright test --project=cloud
browser_tests/tests/assetDeleteClearsLoadImage.spec.ts --reporter=list`
passes locally with a running ComfyUI backend
- [ ] CI `cloud` matrix passes

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12131-test-FE-230-e2e-regression-for-asset-delete-clearing-Load-Image-preview-35d6d73d36508192b4a2df7860f48c44)
by [Unito](https://www.unito.io)
2026-05-12 14:42:31 +00:00
pythongosssss
681915275e fix: remove failed to export toast when cancelling export workflow (#12134)
## Summary

An incorrect error toast currently shows when cancelling the workflow
export from an asset

## Changes

- **What**: 
- skip toast on cancel
- add e2e & unit tests
- refactor asset tab open helper to wait by default & cleanup usage

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12134-fix-remove-failed-to-export-toast-when-cancelling-export-workflow-35d6d73d3650815b839ff26edd70a472)
by [Unito](https://www.unito.io)
2026-05-12 09:00:46 +00:00
pythongosssss
3d9c9ce327 fix: update color widget colors (#12133)
## Summary

The widget was using a different fg/background compared to all other
widgets, this updates to use the standard classes.

## Changes

- **What**: 
- update styles

## Screenshots (if applicable)

Before
<img width="570" height="597" alt="Screen Shot 2026-05-11 at 07 19 12"
src="https://github.com/user-attachments/assets/18a5330f-5e9a-4d16-b3f0-0acfab5d6f99"
/>

After
<img width="570" height="597" alt="Screen Shot 2026-05-11 at 07 15 46"
src="https://github.com/user-attachments/assets/81c9da58-fdda-4539-ae1e-98727f12b9ac"
/>

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12133-fix-update-color-widget-colors-35d6d73d365081da8515cd48a8e8ecc2)
by [Unito](https://www.unito.io)

No e2e tests for this as they would be simple screenshot asserts on the
color of nodes, which is not worthwhile.
2026-05-12 09:00:22 +00:00
jaeone94
e765eb1bb2 fix: suppress missing media scan during uploads (#12111)
## Summary
- Prevent missing media detection from scanning media loader nodes while
a drag/drop, paste, or file-select upload is still in progress.
- Align LoadAudio with the existing media upload lifecycle by setting
`node.isUploading`, blocking concurrent uploads, and clearing the flag
after upload completion.
- Keep added-node model and missing-node scans on the original
one-microtask path, while deferring added-node media scanning by one
extra microtask so upload handlers can mark transient upload state
before the scan reads widget values.

## Why
Drag/drop and paste can create media loader nodes before the backing
upload has settled. During that short window, the widget may contain a
local filename that is not yet backend-resolvable, so missing media
detection can surface a false missing asset. Refreshing works because
the upload has completed by then.

## Follow-up
- E2E coverage for this upload race will be handled in a follow-up PR
together with E2E coverage for the annotated output-media path changes
from #12069.

## Validation
- `pnpm format`
- `pnpm lint`
- `pnpm typecheck`
- `pnpm vitest run src/composables/graph/useErrorClearingHooks.test.ts
src/platform/missingMedia/missingMediaScan.test.ts
src/extensions/core/uploadAudio.test.ts
src/composables/node/useNodeImageUpload.test.ts`
- Re-ran `pnpm typecheck` after rebasing onto latest `main`
- Pre-push `knip` hook passed

Fixes FE-620

## Screenshots 

Before


https://github.com/user-attachments/assets/db7891de-a4b5-4cde-aa76-6340e6cdf7b2

After


https://github.com/user-attachments/assets/9b99bb13-0d5b-4ff7-8f52-66eea6e417ec



┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12111-fix-suppress-missing-media-scan-during-uploads-35b6d73d365081f3b54eed02874ccaa4)
by [Unito](https://www.unito.io)
2026-05-12 08:54:11 +00:00
Rizumu Ayaka
56434ae9ac perf: debounce template search input to keep typing responsive (#12183)
## Summary

Route the templates dialog search through `FormSearchInput`'s debounced
searcher so per-keystroke work no longer trips the heavy filter/render
path.

## Changes

- **What**: `WorkflowTemplateSelectorDialog` now uses `FormSearchInput`
instead of `SearchInput`. The raw input is bound to a local ref; the
actual `searchQuery` consumed by `useTemplateFiltering` is only written
after the input debounce settles, so dependent computeds (notably
`shouldUsePagination`, which used to flip on every keystroke and force a
full grid rebuild) stay stable while typing.
- **What**: `FormSearchInput` gains optional `debounceMs` (default
`250`) and `debounceMaxWaitMs` (default `1000`) props. Existing callers
are unchanged; the templates dialog passes `400` / `4000` to match the
feel tuned in this PR.

## Review Focus

- Reset path: `searchQuery` is still owned by `useTemplateFiltering` and
cleared by `resetFilters`; a watch syncs the visible input back to empty
when that happens.
- `FormSearchInput` is currently under `src/renderer/...` but already
imported by workbench-level components (rightSidePanel tabs). This PR
follows that existing precedent rather than relocating the component.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12183-perf-debounce-template-search-input-to-keep-typing-responsive-35e6d73d365081b7a11ec4a84323095f)
by [Unito](https://www.unito.io)
2026-05-12 08:15:31 +00:00
Alexander Brown
25c2d828c0 test: enable vitest/consistent-each-for and migrate .each → .for (#12161)
*PR Created by the Glary-Bot Agent*

---

Enables the oxlint rule `vitest/consistent-each-for` (configured to
prefer `.for` for `test`, `it`, `describe`, and `suite`) and migrates
every `.each` parameterized test in the repo to `.for`. Using `.for`
avoids accidentally splatting tuple elements into separate callback
arguments and exposes `TestContext` as the second callback argument.

The first commit covers the 38 lint-detected files (88 callsites):
renames `.each` → `.for` and updates callback signatures to destructure
when the data is an array of tuples (objects/primitives already work
unchanged with `.for`).

The follow-up commit addresses code review feedback: oxlint's rule does
not recognize `test.each` on extended test bases
(`baseTest.extend(...)`) and skips files in `ignorePatterns`
(`src/extensions/core/*`). These were converted manually so the policy
is uniform across the codebase.

## Verification

- `node_modules/.bin/oxlint src` — 0 errors, 0 `consistent-each-for`
violations
- `pnpm typecheck` — passes
- `pnpm test:unit` — all modified test files pass; pre-existing
environmental flakes (`GraphView.test.ts`, `ColorWidget.test.ts`, etc.,
unchanged here and flaky on `main` in this sandbox) are unrelated
- `pnpm lint` / `pnpm knip` — clean
- Manual verification: 362 tests across 6 representative converted
suites re-run in an interactive shell — all passing

Manual UI verification (Playwright/screenshots) is not applicable:
changes are test-file-only refactors with no production runtime or UI
behavior change.

## Notes on `.for` semantics

- Array-of-tuples (`[[a, b], ...]`) passes the tuple as a single arg, so
callbacks were changed from `(a, b) => …` to `([a, b]) => …`.
- Array-of-objects (`[{a}, …]`) already used destructuring — unchanged.
- Array-of-primitives (`['a', …]`) — callback signature unchanged.
- A handful of complex cases use a small `type Case = [...]` alias plus
`it.for<Case>([...])` to preserve tuple inference where TS narrowed
unions otherwise broke parameter types.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12161-test-enable-vitest-consistent-each-for-and-migrate-each-for-35e6d73d3650810c9417e07bdd9f27a2)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Glary-Bot <glary-bot@users.noreply.github.com>
2026-05-11 23:11:51 -07:00
Dante
ceb9936058 fix(i18n): clamp unsupported browser locales to a shipped tag (#11712)
## Summary

Sidebar buttons rendered literal i18n keys (e.g.
`sideToolbar.labels.assets`) on a fresh install when the user's
`navigator.language` base tag wasn't one of the 12 shipped locales —
German/Italian/Polish/Dutch/Brazilian-Portuguese users among others.

## Changes

- **What**: Add `resolveSupportedLocale()` that tries the full BCP-47
tag first (preserves `zh-TW`, `pt-BR`), then the base tag, then `'en'`.
Wire through both entry points (`createI18n`'s initial locale,
`Comfy.Locale`'s `defaultValue`) and clamp inside `loadLocale`,
propagating the resolved tag to `GraphView` so a stale stored
`Comfy.Locale='de'` from older builds also recovers.
- **Side benefit**: Brazilian Portuguese users were previously falling
through `pt-BR` → `pt` (unshipped) → broken. The full-tag-first lookup
now correctly lands them on the `pt-BR` bundle.
- **Breaking**: None.
- **Dependencies**: None.

## Root Cause

Three-link chain:

1. `Comfy.Locale`'s default was `() => navigator.language.split('-')[0]
|| 'en'`. German → `'de'` (unshipped).
2. `loadLocale('de')` silently `console.warn`'d and returned without
throwing.
3. `GraphView` then ran `i18n.global.locale.value = 'de'` anyway.
4. `st(key, fallback) = te(key) ? t(key) : fallback`. vue-i18n's `te()`
checks **only** the current locale and ignores `fallbackLocale` — every
key missed → `st()` returned the literal key string.

Two pathways reached the broken state (defaultValue path, and
unset-setting path through `createI18n`'s own `navigator.language`
snapshot); the new helper closes both.

## Review Focus

- `loadLocale` now returns `SupportedLocale` (was `void`). Old `void`
callers continue to compile; the only change is `GraphView` consuming
the return value.
- Unit-tested in `src/i18n.test.ts` (added `resolveSupportedLocale`
block + updated the `loadLocale` unsupported-locale case from "warn" to
"clamp to en").
- Self-reproduced via Playwright with `navigator.language='de-DE'` +
fresh-install state on both `main` (shows the bug) and this branch
(shows the fix). Spec saved at
`temp/scripts/issue-10563-locale-bug.spec.ts`.

Fixes #10563
FE-480 — https://linear.app/comfyorg/issue/FE-480

## Screenshots

**Before** (from #10563, on `main`):

<img width="258" height="399" alt="Sidebar with literal i18n keys"
src="https://github.com/user-attachments/assets/098d1d76-8e89-4237-813f-5f030b34e51e"
/>

**After** (this branch, same `navigator.language='de-DE'`):

<img width="367" height="793" alt="Screenshot 2026-04-28 at 2 07 38 PM"
src="https://github.com/user-attachments/assets/9d279de3-50a8-4774-999f-ab4c3018a9ef"
/>


┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11712-fix-i18n-clamp-unsupported-browser-locales-to-a-shipped-tag-3506d73d3650812f89d2f0fe3199de3a)
by [Unito](https://www.unito.io)
2026-05-12 15:06:13 +09:00
Deep Mehta
bb420fe2c7 feat: add model-to-node mappings for new model directories (#12151)
## Summary

Add entries to `MODEL_NODE_MAPPINGS` so the model browser's "Use" button
correctly creates a loader node for five model directories that
currently have no mapping.

## Changes

- **What**: 5 new entries in
`src/platform/assets/mappings/modelNodeMappings.ts`:
- `background_removal` → `LoadBackgroundRemovalModel` /
`bg_removal_name` (ComfyUI v0.21+ core)
- `frame_interpolation` → `FrameInterpolationModelLoader` / `model_name`
(ComfyUI v0.21+ core)
  - `film` → `FILM VFI` / `ckpt_name` (ComfyUI-Frame-Interpolation)
- `ultralytics/bbox` → `UltralyticsDetectorProvider` / `model_name`
(ComfyUI-Impact-Pack)
- `ultralytics/segm` → `UltralyticsDetectorProvider` / `model_name`
(ComfyUI-Impact-Pack)
- **Breaking**: none

## Review Focus

- Node class names and input keys were cross-checked against the ComfyUI
v0.21.0 source and the published Impact-Pack / Frame-Interpolation node
definitions
- Both `ultralytics/bbox` and `ultralytics/segm` map to the same node
(`UltralyticsDetectorProvider`); its `model_name` combo accepts values
from both subdirectories (`bbox/...` and `segm/...`)
- `film` and `frame_interpolation` are separate directories serving
different node packs — keeping them as distinct entries rather than
collapsing under a parent

## Test plan

- [ ] In the model browser, clicking "Use" on `birefnet.safetensors`
creates a `LoadBackgroundRemovalModel` node with the model preselected
- [ ] Same for one model in each of: `frame_interpolation`, `film`,
`ultralytics/bbox`, `ultralytics/segm`

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12151-feat-add-model-to-node-mappings-for-new-model-directories-35d6d73d365081ff834bf6eb610da160)
by [Unito](https://www.unito.io)
2026-05-12 02:28:51 +00:00
pythongosssss
4504256f11 test: add test for custom node i18n (#12132)
## Summary

Adds e2e test for custom node i18n

## Changes

- **What**: 
- add e2e regression for previous fix with no e2e

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12132-test-add-test-for-custom-node-i18n-35d6d73d365081f7bed2db39af38f855)
by [Unito](https://www.unito.io)
2026-05-12 01:04:38 +00:00
Comfy Org PR Bot
1290bbd359 1.45.5 (#12152)
Patch version increment to 1.45.5

**Base branch:** `main`

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12152-1-45-5-35e6d73d365081c39aecddf42d8f7a2a)
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>
2026-05-12 00:44:22 +00:00
Yourz
7ddf71d91b fix(website): center GitHubStarBadge text in Safari (#12138)
*PR Created by the Glary-Bot Agent*

---

## Summary

The 10px star count text inside the desktop nav GitHub star badge
rendered vertically off-center in Safari/WebKit. The text was visibly
shifted upward inside the yellow badge body, while Chromium rendered it
centered correctly.

## Root cause

`NodeBadge.vue` centers its inner text span by setting `flex
items-center justify-center` on the segment, then nudging the text with
`translate-y-1` (or `translate-y-0` for the small variant). The text
span itself is an `inline-block` with no explicit `line-height`, so it
inherits the default `line-height: 1.5` (15px for a 10px font).

Safari and Chromium distribute that extra leading differently for the
`PP Formula Narrow` custom font: Safari pushes the glyph higher inside
its 15px line box, while Chromium positions it near the middle. With
only a 5px gap above/below the glyph to play with, that browser-specific
divergence is enough to make the badge look misaligned in Safari.

## Fix

Add `leading-none` to the star count text class so the inline-block's
line box equals the font size (10px) and the parent flex container's
`items-center` produces deterministic vertical centering across
browsers.

Verified at lg breakpoint (1440×900) in both WebKit and Chromium via
Playwright; the badge now renders identically and is properly centered.

## Verification

- `pnpm typecheck` (website) — clean
- `pnpm build` (website) — 51 pages built successfully
- Pre-commit hooks (stylelint, oxfmt, oxlint, eslint, typecheck,
typecheck:website) — all passed
- Visual inspection in WebKit and Chromium via Playwright

Fixes FE-648

## Screenshots

![BEFORE (Safari/WebKit): star count text displaced upward in yellow
badge](https://pub-1fd11710d4c8405b948c9edc4287a3f2.r2.dev/sessions/364b285ccc004b7d3778b6129dab4410e0bffe9db5ef124e37443414f9790d70/pr-images/1778513564903-c047c9ac-4719-479e-8442-f15ad389be02.png)

![AFTER (Safari/WebKit): star count text properly centered in yellow
badge](https://pub-1fd11710d4c8405b948c9edc4287a3f2.r2.dev/sessions/364b285ccc004b7d3778b6129dab4410e0bffe9db5ef124e37443414f9790d70/pr-images/1778513565238-a4f00af8-54d7-4131-a5fd-849105e427d6.png)

![Zoomed Safari/WebKit view showing the fixed badge with centered 85K
text](https://pub-1fd11710d4c8405b948c9edc4287a3f2.r2.dev/sessions/364b285ccc004b7d3778b6129dab4410e0bffe9db5ef124e37443414f9790d70/pr-images/1778513565587-f6aca193-5ced-45aa-b692-3340629f64be.png)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12138-fix-website-center-GitHubStarBadge-text-in-Safari-35d6d73d3650818aa0e8e0f341b60378)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Glary-Bot <glary-bot@users.noreply.github.com>
2026-05-12 00:38:49 +00:00
Glary-Bot
410bfc2360 test: convert primitive refresh helper to function declaration
Per repo conventions (no function expressions when a declaration works).
2026-05-11 20:25:46 +00:00
Glary-Bot
fc284fc903 test: add e2e coverage for primitive combo refresh
Adds an E2E test that loads a Primitive wired to KSampler.sampler_name,
presses 'r' (Refresh Node Definitions), and asserts the primitive's
combo options stay a non-empty array containing the connected input's
values. Reproduces the regression this PR fixes — before the fix, the
options array was clobbered with undefined.
2026-05-11 20:20:22 +00:00
Christian Byrne
74caeb0b0b fix: detect V1/V2 draft storage keys in new-user check (#11728)
## Problem

`checkIsNewUser()` in `useNewUserService` only checked legacy pre-V1
localStorage keys (`workflow`, `Comfy.PreviousWorkflow`) to determine if
a user had prior workflow history. A returning user who had only ever
used the V1 or V2 draft persistence system would have neither of those
keys set, causing `isNewUser()` to return `true` and the getting-started
tab to appear in the workflow templates dialog after a settings reset.

## Solution

Extend the check to also cover:
- **V1 draft store keys**: `Comfy.Workflow.Drafts`,
`Comfy.Workflow.DraftOrder`
- **V2 draft index key**: `Comfy.Workflow.DraftIndex.v2:personal`

The `personal` scope is hardcoded for the V2 check because at the time
`checkIsNewUser()` runs, the cloud workspace ID (stored in
sessionStorage) may not be set yet. This is fine — any genuine new user
will have no personal workspace index regardless.

The original legacy keys are preserved for users who may still have them
from older installs.

## Tests

Added three new test cases covering V1 draft store keys, V1 draft order
key, and V2 draft index key.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11728-fix-detect-V1-V2-draft-storage-keys-in-new-user-check-3506d73d3650819ca4cfc8e83d95c258)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Connor Byrne <c.byrne@comfy.org>
2026-05-11 20:16:04 +00:00
Alexander Brown
ced7c93e63 testing: Improve custom checks in .coderabbit.yaml (#12141)
Update coderabbit end-to-end check logic.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12141-testing-Improve-custom-checks-in-coderabbit-yaml-35d6d73d3650818e8be2f0b7d403683b)
by [Unito](https://www.unito.io)

---------

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2026-05-11 19:05:48 +00:00
Christian Byrne
759ed3d4e2 feat(website): add community-workflows demo page (#11942)
*PR Created by the Glary-Bot Agent*

---

Adds a new interactive demo page at
`comfy.org/demos/community-workflows` for the [Explore and Use a
Community Workflow from the
Hub](https://app.arcade.software/flows/mqZh17oWDuWIyhK0xwEV/view) Arcade
walkthrough.

Built on top of the demo infrastructure merged in #11436.

## Changes

- `apps/website/src/config/demos.ts` — register the new demo
- `apps/website/src/i18n/translations.ts` — add en + zh-CN strings
(title, description, transcript)
- `apps/website/public/images/demos/community-workflows-og.png` —
1200×630 OG image so email/social previews render correctly
- `apps/website/public/images/demos/community-workflows-thumb.webp` —
1280×720 WebP thumbnail
- `apps/website/e2e/demos.spec.ts` — refactored to iterate `demos` from
config so every demo (current + future) is exercised in both en and
zh-CN, and the iframe `src` is asserted to contain the correct Arcade ID

Adding a new demo only requires editing `demos.ts` + `translations.ts`
going forward; the e2e refactor is a one-time generalization that gives
future demos coverage automatically.

## Verification

- `pnpm typecheck:website`: 0 errors, 0 warnings, 0 hints
- Pre-commit hook ran `pnpm typecheck`, `oxfmt`, `oxlint`, `eslint` —
all clean on staged files
- `npx astro build`: 53 pages built; `/demos/community-workflows/` and
`/zh-CN/demos/community-workflows/` generated and present in
`sitemap-0.xml`
- Page rendered in Playwright preview: hero (title, GETTING STARTED,
BEGINNER, ~2 min), Arcade embed loads, transcript section present,
"What's Next" links to `image-to-video`
- zh-CN page shows localized title (探索并使用社区工作流), description, badges,
and "What's Next" heading
- OG meta tag references the new 1200×630 PNG

## Screenshots

![English demo page at /demos/community-workflows showing hero, embedded
Arcade walkthrough, transcript section, and What's Next
navigation](https://pub-1fd11710d4c8405b948c9edc4287a3f2.r2.dev/sessions/9e9c345d23c5dd7674edc13e935c656681feb2a09b0928339594232502fa74db/pr-images/1777945965156-23d92854-3b6a-4470-92fb-86facb28a915.png)

![Chinese localized demo page at /zh-CN/demos/community-workflows with
translated title, category, badges, and
navigation](https://pub-1fd11710d4c8405b948c9edc4287a3f2.r2.dev/sessions/9e9c345d23c5dd7674edc13e935c656681feb2a09b0928339594232502fa74db/pr-images/1777945965557-f981dfb3-4080-41e5-b93c-30801b4e1e0c.png)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11942-feat-website-add-community-workflows-demo-page-3576d73d36508139b647c774b1d39323)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Glary-Bot <glary-bot@users.noreply.github.com>
2026-05-11 12:21:58 -07:00
pythongosssss
5d53e75d23 test: add tests for deprecated & api node badge visibility settings (#11681)
## Summary

Adds coverage for untested node badges

## Changes

- **What**: 
- add shared addNode helper - will follow up to standardize across tests
- add deprecated & api node badge tests in LiteGraph & Vue nodes

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11681-test-add-tests-for-deprecated-api-node-badge-visibility-settings-34f6d73d365081569129ecffa608122e)
by [Unito](https://www.unito.io)

---------

Co-authored-by: github-actions <github-actions@github.com>
2026-05-11 18:51:52 +00:00
Comfy Org PR Bot
d23e86d9a4 [chore] Update Ingest API types from cloud@0a03f3a (#12043)
## Automated Ingest API Type Update

This PR updates the Ingest API TypeScript types and Zod schemas from the
latest cloud OpenAPI specification.

- Cloud commit: 0a03f3a
- Generated using @hey-api/openapi-ts with Zod plugin

These types cover cloud-only endpoints (workspaces, billing, secrets,
assets, tasks, etc.).
Overlapping endpoints shared with the local ComfyUI Python backend are
excluded.

---------

Co-authored-by: MillerMedia <7741082+MillerMedia@users.noreply.github.com>
Co-authored-by: GitHub Action <action@github.com>
2026-05-11 18:47:18 +00:00
Yourz
d901c63a0b feat: convert careers CategoryNav to scroll-spy locator (#12110)
*PR Created by the Glary-Bot Agent*

---

Converts the `CategoryNav` in the careers `RolesSection` from a
click-to-filter button into a scroll-spy section locator, matching the
pattern already used by `ContentSection.vue` (customer story details,
TOS, privacy policy).

## Changes

- **`apps/website/src/components/careers/RolesSection.vue`**
- Replaced category-based filtering with anchor navigation: clicking a
department in the sidebar smooth-scrolls (via existing Lenis/GSAP
`scrollTo` helper) to that department's section with a `-144px` header
offset.
- Removed the `ALL` button — every department is always rendered as its
own scroll target with `id="careers-dept-{key}"`.
- Added `useIntersectionObserver` (rootMargin `-20% 0px -60% 0px`) that
updates the active nav item as the user scrolls. An `isScrolling` guard
prevents the observer from fighting click-jumps mid-animation.
- Added a viewport-entry fade/slide-up animation on each department
section, gated by `motion-safe:` so users with `prefers-reduced-motion`
see content immediately. The reveal state is sticky (one-way) so
sections don't disappear once revealed.
- Active state is driven by raw department keys; both the nav model and
the observer's id-to-key mapping use a single consistent identifier.

- **`apps/website/e2e/careers.spec.ts`**
- Replaced the obsolete "ENGINEERING filter narrows the list" test with
one that validates locator behavior: clicking the department button
scrolls the section into the viewport, sets `aria-pressed="true"`, and
keeps the full role list rendered.

## Verification

- `pnpm --filter @comfyorg/website typecheck` — clean.
- `pnpm exec oxfmt` / `pnpm exec eslint` / `pnpm exec oxlint` — clean.
- Pre-commit lint-staged hooks (stylelint, oxfmt, oxlint, eslint,
typecheck) — passing.
- Manual smoke test via Playwright on `astro dev`: careers page renders
all departments stacked vertically, active department in the sidebar
highlights based on viewport position (DESIGN active on initial scroll),
nav items reflect each department instead of including an `ALL` button.

## Screenshots

![Careers page with new locator: sidebar shows all 4 departments as
anchor buttons (DESIGN highlighted as active), all roles rendered
without
filtering](https://pub-1fd11710d4c8405b948c9edc4287a3f2.r2.dev/sessions/7f5c6359393f3f1f03c628d10074b13924f36c44bbce73e6a2729dd8279d9304/pr-images/1778336774660-dae22a86-d4f3-4509-bc66-eb3d00ec308c.png)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12110-feat-convert-careers-CategoryNav-to-scroll-spy-locator-35b6d73d3650818a9226e5dcb1244756)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Glary <glary@bot.local>
2026-05-11 18:42:25 +00:00
Yourz
5ca9f3e7e6 feat(website): remove left-edge fade-out from local hero illustration (#12137)
*PR Created by the Glary-Bot Agent*

---

Removes the left-edge fade-out gradient overlay on the hexagonal hero
illustration on the `/download` (local) page. The hex cluster now reads
fully edge-to-edge instead of being blended into the page background on
the left side.

Tracked in [FE-650: Remove fade-out effect on local page hero
illustration](https://linear.app/comfyorg/issue/FE-650/remove-fade-out-effect-on-local-page-hero-illustration).

## Change

Drops the `<!-- Left-edge fade -->` `<rect>` and its
`<defs><linearGradient id="localHeroFadeLeft">…</linearGradient></defs>`
from `apps/website/src/components/product/local/HeroSection.vue`.
Animation logic (panel expansion + hex ring rotation) is untouched.

## Verification

- `pnpm nx typecheck website` — pass (0 errors)
- `pnpm nx build website` — pass (51 pages built)
- `pnpm exec eslint
apps/website/src/components/product/local/HeroSection.vue` — clean
- `pnpm format:check
apps/website/src/components/product/local/HeroSection.vue` — clean
- Manual: `pnpm dev` + visited `/download` at 390×844 (mobile),
1280×800, and 1440×900. Mobile screenshots clearly show the fade is
gone; the leftmost hexagons are now fully visible.

## Screenshots

Mobile (390×844), before — note the dark fade obscuring the left side of
the hex cluster:

## Screenshots

![Mobile /download hero before — fade visible on left edge of hex
illustration](https://pub-1fd11710d4c8405b948c9edc4287a3f2.r2.dev/sessions/105d446470b90b36fa6b9a79fa686c98d107b8a1a7bc8cd5ace92e73fe590e94/pr-images/1778512980323-c51be60d-d64c-4f62-8b07-916206e7704d.png)

![Mobile /download hero after — fade removed, leftmost hexagons fully
visible](https://pub-1fd11710d4c8405b948c9edc4287a3f2.r2.dev/sessions/105d446470b90b36fa6b9a79fa686c98d107b8a1a7bc8cd5ace92e73fe590e94/pr-images/1778512980688-6848a1ca-f24b-4821-be57-1704bf5ceae7.png)

![Desktop /download hero after at
1280×800](https://pub-1fd11710d4c8405b948c9edc4287a3f2.r2.dev/sessions/105d446470b90b36fa6b9a79fa686c98d107b8a1a7bc8cd5ace92e73fe590e94/pr-images/1778512981012-02a221c7-0c9c-4b2c-a6e0-97d32f3fa306.png)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12137-feat-website-remove-left-edge-fade-out-from-local-hero-illustration-35d6d73d365081cba690ed7d46a19882)
by [Unito](https://www.unito.io)

Co-authored-by: Glary Bot <bot@glary.dev>
2026-05-11 18:37:31 +00:00
Yourz
6d5fa743b3 fix: seamless SocialProofBar marquee loop (#12139)
*PR Created by the Glary-Bot Agent*

---

## Summary

The partner-logo marquee on the homepage `SocialProofBar` glitched on
every loop restart — a visible jump where the strip snapped back to the
start.

## Root cause

The previous implementation rendered all logos as siblings of a single
flex container and animated the track from `translateX(0)` to
`translateX(-50%)`. Because the `gap` utility inserts spacing between
every adjacent pair of items (including the seam between the two
duplicated halves), `-50%` of the total width does not equal the
distance from one half-start to the next half-start. The mismatch (`gap
/ 2`) is exactly what the eye sees as a jump.

## Fix

- Wrap each duplicated half in its own flex group.
- Place the two groups as siblings of an outer `flex w-max gap-X` track
with a gap that matches the inner gap.
- Animate each group by `translateX(calc(-100% - var(--marquee-gap)))`,
where `--marquee-gap` is set inline to the same value as the Tailwind
gap class.
- Scope the `animation` declaration to `@media (prefers-reduced-motion:
no-preference)` so reduced-motion users get a stable, non-animated
client list instead of the global "snap to 0.01ms" jump.

At `t = end`, the second group sits at `x = 0` — exactly where the first
group started — so the next animation cycle is visually
indistinguishable from the previous frame. The duplicate carries
`aria-hidden="true"` so screen readers don't read the client list twice.

## Verification

- `pnpm typecheck`, `pnpm format`, `npx eslint` on changed files: clean.
- Geometry verified at runtime on desktop (1440×900) and mobile
(390×844): copy widths match, second copy lands at `x = 0` at animation
end.
- New Playwright regression tests
(`apps/website/e2e/responsive.spec.ts`) pause the CSS animation, sample
bounding rects at `t=0` and `t≈duration`, and assert the seam invariant
— covering desktop forward, mobile forward, and mobile reverse marquees.
All 5 SocialProofBar tests pass on both `desktop` and `mobile` projects.
- Reduced-motion behavior verified in the browser: `animationName:
none`, `transform: none`, tracks at their natural positions.

Fixes FE-649

## Screenshots

![Desktop SocialProofBar marquee — continuous
strip](https://pub-1fd11710d4c8405b948c9edc4287a3f2.r2.dev/sessions/46e0ea18461e4d9b4410ff1bda9be669b44aeee095b2ac5a976280e1df3867dc/pr-images/1778515303716-158840bf-9182-4928-b095-c38f5284419b.png)

![Desktop marquee at the loop boundary — seamless, no visible
jump](https://pub-1fd11710d4c8405b948c9edc4287a3f2.r2.dev/sessions/46e0ea18461e4d9b4410ff1bda9be669b44aeee095b2ac5a976280e1df3867dc/pr-images/1778515304106-c46ab421-7214-4123-a4d0-e819af2e1b49.png)

![Mobile SocialProofBar — two stacked marquee
rows](https://pub-1fd11710d4c8405b948c9edc4287a3f2.r2.dev/sessions/46e0ea18461e4d9b4410ff1bda9be669b44aeee095b2ac5a976280e1df3867dc/pr-images/1778515304521-b0fe828f-90ba-4cf4-bb63-65be4d28f627.png)

![Reduced-motion fallback — stable static client
list](https://pub-1fd11710d4c8405b948c9edc4287a3f2.r2.dev/sessions/46e0ea18461e4d9b4410ff1bda9be669b44aeee095b2ac5a976280e1df3867dc/pr-images/1778515304824-6f3262ab-127c-46e4-bc04-6e7b8850545b.png)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12139-fix-seamless-SocialProofBar-marquee-loop-35d6d73d36508141b6ccf0167016b8c8)
by [Unito](https://www.unito.io)

Co-authored-by: Glary-Bot <glary-bot@users.noreply.github.com>
2026-05-11 18:36:40 +00:00
Henry Lee
603dd3eb4e fix(assets): recognise m4v and mkv as video extensions (#12088)
## Summary

`.m4v` and `.mkv` files render as a generic file icon in the assets
sidebar instead of a video preview because `VIDEO_EXTENSIONS` doesn't
list them, even though both formats are widely produced by ComfyUI
custom nodes and are browser-playable when written with common codecs.

## Changes

- **What**: Add `m4v` and `mkv` to `VIDEO_EXTENSIONS` in
`packages/shared-frontend-utils/src/formatUtil.ts` and extend the
existing test cases. Aligns with ComfyUI core's canonical video
extension list
(`tests-unit/folder_paths_test/filter_by_content_types_test.py:13`),
which includes both. The frontend's format registry at
`src/platform/workflow/core/types/formats.ts` also lists `.m4v` with
mime `video/x-m4v` — `formatUtil.ts` was the inconsistent surface.
- **Breaking**: None.
- **Dependencies**: None.

## Review Focus

`m4v` is Apple's MP4 container variant; `mkv` is the Matroska container.
ComfyUI custom nodes most commonly produce both with H.264/VP9 codecs,
which play natively in modern browsers via `<video>`. Adding the
extensions routes those files through the existing `MediaVideoTop`
component without any new rendering logic.

If a user's `.mkv` happens to use an exotic codec the browser can't play
(e.g. H.265/HEVC in Chrome), they get the same controllable failure mode
as today's `.avi` entries — a `<video>` element with the browser's
native unsupported-source UI. That is no worse than the current "show a
generic file icon" behavior, and strictly better in the common case.

## Screenshots (if applicable)

> **Note**: Screenshots taken from the OSS *input* assets sidebar with
[#12086](https://github.com/Comfy-Org/ComfyUI_frontend/pull/12086) also
applied locally. That PR fixes a separate regression where OSS input
filenames carry a `[input]` annotation suffix that breaks all
extension-based media detection — without it, `m4v`/`mkv` files (and
every other file in that sidebar) still render as the generic icon. This
PR alone is sufficient for cloud assets and OSS output history; the
input-sidebar previews require both PRs.

Before:
<img width="1197" height="714" alt="2026-05-09-031123_hyprshot"
src="https://github.com/user-attachments/assets/5c6ebc2d-aac2-411f-a2e4-51a111033184"
/>

After:
<img width="1042" height="723" alt="2026-05-09-031005_hyprshot"
src="https://github.com/user-attachments/assets/f0acc2cf-8571-4fd0-b0cd-2b8b87ff9b74"
/>
2026-05-11 12:03:35 -07:00
Yourz
d767a325a2 FE-604: fix(website): activate last section badge when scrolled to bottom (#12057)
*PR Created by the Glary-Bot Agent*

---

## Summary

Fixes the bug where the last badge in `ContentSection`'s sticky sidebar
nav stays unhighlighted when the user scrolls to the very bottom of the
page on tall viewports (reported on a 14" MacBook M4 Pro at 3024×1964 /
2016×1310 logical, both Chrome and Safari).

## Root cause

The scroll-spy uses an IntersectionObserver with `rootMargin: '-20% 0px
-60% 0px'`, which makes only a 20%–40% horizontal band from the viewport
top "active". When multiple intersecting entries are reported, the
callback picks the one whose `boundingClientRect.top` is smallest
(highest up on screen).

On tall viewports, when the page is scrolled to the absolute bottom, the
last *and* the second-to-last sections frequently both sit inside that
20%–40% band at the same time. The "smallest top" tiebreak then selects
the second-to-last section, leaving the last badge inactive even though
the user has reached the end of the page.

## Fix

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

1. Add `isAtBottom()` — true when the viewport bottom has reached the
document bottom (within 4px to absorb sub-pixel rounding).
2. The IntersectionObserver callback bails out when `isAtBottom()` so it
cannot overwrite the choice below.
3. A passive `scroll` listener (and a one-shot `onMounted` call) sets
`activeSection` to the last section whenever the page is at the bottom —
including when the component mounts already at the bottom (e.g. hash
navigation to a trailing anchor, restored scroll position, or a page
shorter than the viewport).
4. Both the scroll handler and the IO callback honor the existing
`isScrolling` flag, so click-driven smooth scroll-to-section behavior is
unchanged.

## Verification

Reproduced the bug at viewport 2016×1310 (14" M4 Pro "More Space" mode)
on `/privacy-policy`:

- Before fix: at absolute bottom, IntersectionObserver picks
`australian-privacy` (second-to-last) — bug confirmed via DOM inspection
that showed multiple sections intersecting the active band, with the
second-to-last winning the "smallest top" tiebreak.
- After fix:
  - Scrolled to bottom → last badge `CONTACT` is active.
  - Scrolled to top → first badge `INTRO` is active.
  - Scrolled mid-page → correct mid-section is active.
  - Click on a badge → smooth scrolls and that badge becomes active.
- Initial render at bottom (loaded `/privacy-policy#contact`, browser
scrolls to the bottom on mount) → `CONTACT` active immediately.

`pnpm typecheck` and `pnpm typecheck:website` pass; `pnpm lint` reports
0 errors; existing website unit tests pass.

Note: The website app currently has no Vue component test setup
(`vitest.config.ts` is configured for `node` env, no DOM). Adding
component tests for this scroll-spy interaction would require setting up
`happy-dom` and `@testing-library/vue` for the website app, which is out
of scope for this bug fix.

Fixes FE-604

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12057-FE-604-fix-website-activate-last-section-badge-when-scrolled-to-bottom-3596d73d365081faa243f4dd8e6ee54a)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Glary-Bot <glary-bot@users.noreply.github.com>
2026-05-11 12:00:43 -07:00
Christian Byrne
39b2bb5eab [chore] Update Comfy Registry API types from comfy-api@dfcca37 (#12087)
## Automated API Type Update

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

- API commit: dfcca37
- Generated on: 2026-05-07T19:11:35Z

These types are automatically generated using openapi-typescript.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12087-chore-Update-Comfy-Registry-API-types-from-comfy-api-dfcca37-35a6d73d365081f49beec09f418d7c1a)
by [Unito](https://www.unito.io)

Co-authored-by: coderfromthenorth93 <213232275+coderfromthenorth93@users.noreply.github.com>
2026-05-11 11:47:43 -07:00
pythongosssss
c643438601 fix: hide image buttons if load failed (#12136)
## Summary

When an image fails to load in the image preview, the context buttons
are still visible - clicking these does not work (Mask editor opens and
closes, download does nothing) - this hides the buttons if load fails.

## Changes

- **What**: 
- hide buttons if load failed
- tests

## Review Focus

<!-- Critical design decisions or edge cases that need attention -->

<!-- If this PR fixes an issue, uncomment and update the line below -->
<!-- Fixes #ISSUE_NUMBER -->

## Screenshots (if applicable)
Current:
<img width="622" height="857" alt="image"
src="https://github.com/user-attachments/assets/26e391a0-5538-4c6c-ac8a-b6f2b6acabae"
/>

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12136-fix-hide-image-buttons-if-load-failed-35d6d73d365081579c71f1849b9ab1bd)
by [Unito](https://www.unito.io)
2026-05-11 17:33:34 +00:00
Dante
02e1ba2968 fix: Load Image preview retains deleted asset (FE-230) (#11493)
## Summary

After deleting an asset, the Load Image node kept displaying the deleted
thumbnail — both in the node body and in the picker dropdown (All /
Imported / Generated tabs), even after a workflow reload.

- Fixes FE-230
- Source: Slack
https://comfy-organization.slack.com/archives/C0A4XMHANP3/p1776715727656809

## Root Cause

Three distinct paths kept the deleted asset visible:

1. **Node-body preview cache** — `useMediaAssetActions.deleteAssets`
never cleared `node.imgs` / `node.videoContainer` / the
`nodeOutputStore` Vue ref, so the canvas renderer kept its cached frame.
2. **Live-delete dropdown gap** — the picker reads from
`outputMediaAssets.media` (the asset list) and from
`missingMediaStore.missingMediaCandidates` (verified-missing names). On
live delete, neither was updated for the deleted asset, so the dropdown
filter had nothing to drop.
3. **Synthetic "selected" placeholder** —
`useWidgetSelectItems.missingValueItem` rebuilt any orphaned
`modelValue` as a fake item with a `/api/view?filename=...` preview URL.
Browsers had cached that URL pre-delete, so the deleted thumbnail still
rendered with a blue checkmark even after the filter dropped the real
asset entry.

A subtler issue compounded #2/#3: candidate names stored in
`missingMediaStore` are raw widget values (e.g. `sub/foo.png [output]`),
but the dropdown computed comparison keys differently per source (asset
list uses bare `asset.name`, widget option list uses bare filename).
Names with a subfolder prefix slipped through the filter.

## Fix

- **`clearNodePreviewCacheForFilenames`** (existing helper, refactored):
exports `findNodesReferencingFilenames` +
`extractFilenameFromWidgetValue`. Uses
`nodeOutputStore.removeNodeOutputs` so the **reactive** Pinia ref
updates, not just the legacy `app.nodeOutputs` mirror. Also clears
`node.videoContainer` for Load Video.
- **`markDeletedAssetsAsMissingMedia`** (new): on successful deletion,
surfaces the affected widgets through `missingMediaStore` immediately so
the dropdown filter has something to drop without waiting for
verification.
- **`useMissingMediaPreviewSync`** (new): watches `missingMediaStore`
and clears `node.imgs` / `node.videoContainer` / Vue preview source for
nodes referencing confirmed-missing media on workflow load — covers the
post-reload case.
- **`useWidgetSelectItems`**: normalizes both sides of the missing-media
filter via `extractFilenameFromWidgetValue` (strips
`[input|output|temp]` annotation + subfolder prefix), and suppresses
`missingValueItem` when the value is in the missing-media store so the
cached-thumbnail "selected" placeholder doesn't appear.

## Red-Green Verification

| Commit | CI Status | Run |
|--------|-----------|-----|
| `test: FE-230 add failing test for Load Image preview cache clearing`
| 🔴 Failure — test caught the bug |
https://github.com/Comfy-Org/ComfyUI_frontend/actions/runs/24700188700 |
| `fix: FE-230 clear Load Image preview cache when asset is deleted` |
🟢 Success |
https://github.com/Comfy-Org/ComfyUI_frontend/actions/runs/24700265884 |

## Test Plan

- [x] Unit coverage: 78 tests across 5 files (preview-cache helper,
mark-deleted-as-missing, missing-media-preview-sync, widget-select-items
missing-media filter incl. subfolder-prefix case, useMediaAssetActions
integration)
- [x] Live delete: Load Image node preview clears, dropdown drops the
asset across All / Imported / Generated, no synthetic "selected"
placeholder
- [x] Post-reload: missing-media verification →
`useMissingMediaPreviewSync` clears the preview, dropdown drops the
asset
- [x] Linear FE-230 auto-links via the Source line

## Scope note

In-session and session-restore are both covered. If the backend/CDN
continues serving the deleted `filename`/`asset_hash` after deletion, a
cross-session reopen may still render stale bytes from cache — that's a
backend/CDN concern tracked separately.


## demo

### before



https://github.com/user-attachments/assets/e4d3a40e-0d46-43ad-985c-22ce7e0d3faf


### after



https://github.com/user-attachments/assets/fcac9387-4c07-4be2-bcdd-d1a6192fe962
2026-05-11 12:53:53 +00:00
pythongosssss
15b8771cc2 fix: clear active job on reconnect if no longer in queue (#12067)
## Summary

When a socket disconnects messages can be missed and lead to a stale UI
state, this updates the state on reconnect and clears the active job if
it is no longer running

## Changes

- **What**: 
- add call to update queue on reconnect
- clear active job if job not in queue response
- tests

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12067-fix-clear-active-job-on-reconnect-if-no-longer-in-queue-3596d73d365081f79d42d73966420c50)
by [Unito](https://www.unito.io)
2026-05-11 09:28:23 +00:00
Comfy Org PR Bot
e68d50e677 1.45.4 (#12118)
Patch version increment to 1.45.4

**Base branch:** `main`

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12118-1-45-4-35d6d73d365081fcb5f5d06dec17bb59)
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>
2026-05-11 04:43:31 +00:00
Comfy Org PR Bot
48b5e0165a 1.45.3 (#12113)
Patch version increment to 1.45.3

**Base branch:** `main`

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12113-1-45-3-35c6d73d365081468180cefef02dca03)
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>
2026-05-11 00:01:07 +00:00
Christian Byrne
fe1de3b254 refactor: remove dedup complexity from reportInactiveTrackerCall (#11833)
## Summary

Remove the module-level `reportedInactiveCalls: Set<string>` and the
early-return dedup check from `reportInactiveTrackerCall()` in
`src/scripts/changeTracker.ts`. Every invocation now emits
`console.warn` and (on Desktop) `Sentry.captureMessage` unconditionally.

## Why

The dedup was added in #11328 but is unnecessary:
- Every first-party call site already goes through the
`activeWorkflow?.changeTracker` guard, so flooding from in-repo code is
unlikely.
- Repeated identical alerts may actually provide more diagnostic signal
than the first-only approach suppresses.

## Changes

- Drop `reportedInactiveCalls` Set
- Drop the per-`(method, workflowPath)` early-return
- Trim the JSDoc accordingly

No behavior change for callers (`deactivate`, `captureCanvasState`);
only the reporting frequency increases.

## Verification

- `pnpm test:unit -- src/scripts/changeTracker.test.ts` — 16/16 passing
- `pnpm typecheck` — clean
- ESLint / oxfmt — clean

- Fixes #11372

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11833-refactor-remove-dedup-complexity-from-reportInactiveTrackerCall-3546d73d365081fabf57cbf1fa17051f)
by [Unito](https://www.unito.io)
2026-05-10 05:22:30 +00:00
Christian Byrne
1c2ae70343 chore(#11843): replace bare string NodeId typings in parameters tab components (#12014)
## Summary

Replace `nodeId: string` with canonical `NodeId` type in right-side
panel parameters tab components, eliminating redundant `String()`
conversions at call sites.

## Changes

- `TabNodes.vue`: `isSectionCollapsed` and `setSectionCollapsed` now
accept `NodeId` instead of `string`; callers updated to pass `node.id`
directly (removing `String()` wrapping)
- `TabNormalInputs.vue`: same pattern

## Notes

The other 6 files listed in the issue use `nodeId` parameters that carry
execution IDs (`NodeExecutionId = string`), not graph node IDs (`NodeId
= number | string`). Changing those to `NodeId` would be semantically
incorrect. The two files changed here are the clear-cut cases where
`node.id` (a `NodeId`) was being unnecessarily stringified before being
passed.

## Testing

### Automated

- `pnpm typecheck` — passes
- `pnpm lint` — passes (0 warnings, 0 errors)
- `pnpm format:check` — passes

### E2E Verification Steps

1. Open ComfyUI frontend
2. Load a workflow with multiple nodes
3. Open the right side panel (Parameters tab)
4. Verify node sections collapse/expand correctly per node
5. Verify "Collapse All" / "Expand All" toggle works correctly
6. Repeat with both TabNodes and TabNormalInputs views

Fixes #11843

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12014-chore-11843-replace-bare-string-NodeId-typings-in-parameters-tab-components-3586d73d365081ed84caf560277f0553)
by [Unito](https://www.unito.io)
2026-05-10 05:21:36 +00:00
jaeone94
8f68be5699 fix: handle annotated output media paths in missing media scan (#12069)
## Summary

This PR fixes missing-media false positives for annotated media widget
values such as:

```txt
photo.png [output]
clip.mp4 [input]
147257c95a3e957e0deee73a077cfec89da2d906dd086ca70a2b0c897a9591d6e.png [output]
clip.mp4[input]  // Cloud compact form
```

The change is intentionally scoped to the missing-media detection
pipeline for:

- `LoadImage`
- `LoadImageMask`
- `LoadVideo`
- `LoadAudio`

It preserves the raw widget value on `MissingMediaCandidate.name` for UI
display, grouping, replacement, and user-facing missing-media rows.
Normalized values are used only as comparison keys during verification.

## Diff Size

`main...HEAD` line diff is currently:

- Production/runtime code: `+478 / -37` (`515` changed lines)
- Unit test code: `+960 / -47` (`1,007` changed lines)
- Total: `+1,438 / -84` (`1,522` changed lines)

The PR looks large mostly because it locks both Cloud and OSS/Core
runtime paths with unit coverage; the production/runtime change is about
one third of the total diff.

## What Changed

- Added missing-media-scoped annotation helpers for detection-only path
normalization.
  - Core/OSS recognizes spaced suffixes like `file.png [output]`.
  - Cloud also recognizes compact suffixes like `file.png[output]`.
- User-selectable trailing `input` and `output` annotations are
normalized for matching.
- Unknown annotations and middle-of-filename annotations are left
unchanged.
- Added shared file-path helpers in `formatUtil`:
  - `joinFilePath(subfolder, filename)`
  - `getFilePathSeparatorVariants(filepath)`
- Updated media verification to compare candidates against both raw and
normalized match keys.
- Kept input candidates and generated output candidates in separate
identifier sets so an input asset cannot accidentally satisfy an output
reference with the same name.
- Moved missing-media source loading into `missingMediaAssetResolver` so
`missingMediaScan` remains focused on scan/verification orchestration.
- Updated Cloud generated-media verification to use the Cloud assets API
instead of job history:
  - Cloud input candidates use input/public assets.
  - Cloud output candidates use `output` tagged assets.
- Kept OSS/Core generated-media verification history-based, matching the
current generated-picker/widget availability model.

## Runtime Verification Paths

### Cloud

Cloud stores generated outputs as asset records. For an annotated output
value, this PR verifies against the `output` asset tag rather than job
history.

```txt
Widget value
  "147257...d6e.png [output]"
        |
        v
Detection keys
  "147257...d6e.png [output]"
  "147257...d6e.png"
        |
        v
Cloud asset sources
  input candidates  -> /api/assets?include_tags=input&include_public=true
  output candidates -> /api/assets?include_tags=output&include_public=true
        |
        v
Match against
  asset.name
  asset.asset_hash
  subfolder/asset.name
  subfolder/asset.asset_hash
  slash and backslash separator variants
```

Example:

```ts
candidate.name = 'abc123.png [output]'
asset.name = 'ComfyUI_00001_.png'
asset.asset_hash = 'abc123.png'
asset.tags = ['output']

// Result: not missing
```

### OSS / Core

Core widget options for the normal loader nodes are input-folder based.
Annotated output values are resolved by Core through
`folder_paths.get_annotated_filepath()`, but the current generated
picker path is history-backed. This PR keeps OSS generated verification
aligned with that widget availability model instead of treating the full
output folder as the source of truth.

```txt
Widget value
  "subfolder/photo.png [output]"
        |
        v
Detection keys
  "subfolder/photo.png [output]"
  "subfolder/photo.png"
        |
        v
OSS generated source
  fetchHistoryPage(...)
        |
        v
History preview_output
  filename: "photo.png"
  subfolder: "subfolder"
        |
        v
Generated match keys
  "subfolder/photo.png"
  "subfolder\\photo.png"
```

This means OSS/Core verification is about whether the generated media is
currently available through the same generated/history-backed path the
widget uses, not a full disk-level executability check across the entire
output directory.

## Why Not Consolidate All Annotated Path Parsers

There are existing annotated-path parsers in image widget, Load3D, and
path creation code. This PR does not replace them.

The helper added here is detection-only: it strips annotations to build
comparison keys for missing-media verification. Parser consolidation
across widget implementations is intentionally left out of scope to keep
this fix narrow.

## Known Follow-Ups / Out Of Scope

- FE-620 tracks the separate video drag-and-drop upload race between
upload completion and missing-media detection.
- Published/shared workflow assets are still not fully represented by
`/api/assets?include_public=true`; that remains a backend/API contract
issue.
- A future backend/API contract that answers “is this workflow media
executable?” would be preferable to stitching together runtime-specific
FE sources.
- OSS/Core full output-folder scanning via `/internal/files/output` was
considered, but that endpoint is internal, shallow (`os.scandir`), and
not the same source currently used by the generated picker flow.

## Validation

- `pnpm test:unit -- missingMediaAssetResolver missingMediaScan
mediaPathDetectionUtil formatUtil`
- touched files `oxfmt`
- touched files `oxlint --fix`
- touched files `eslint --cache --fix --no-warn-ignored`
- `pnpm typecheck`
- pre-commit `pnpm knip --cache`
- pre-push `pnpm knip --cache`

`knip` passes with the existing tag hint:

```txt
Unused tag in src/scripts/metadata/flac.ts: getFromFlacBuffer → @knipIgnoreUnusedButUsedByCustomNodes
```

## Screenshots

Before 


https://github.com/user-attachments/assets/50eab565-3160-4a57-a758-87ec2c09071e


After 


https://github.com/user-attachments/assets/08adcbbd-c3fc-43f9-b86c-327e4eb5abd8


┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12069-fix-handle-annotated-output-media-paths-in-missing-media-scan-3596d73d365081f4afa3d4dd45cad3da)
by [Unito](https://www.unito.io)
2026-05-09 05:36:09 +00:00
Terry Jia
653ef1a4f0 Handle Load3D "none" model selection in frontend (#11178)
## Summary
Load3D now supports panoramic images and HDRI loading, it can serve as a
viewer for those without requiring a 3D model. Previously, the node
required a model file to execute. Rather than making the input optional
(which would break existing workflows that rely on it being required), a
"none" option is added to the combo list, allowing users to run Load3D
with no model loaded.

BE change is https://github.com/Comfy-Org/ComfyUI/pull/13379

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11178-Handle-Load3D-none-model-selection-in-frontend-3416d73d365081e589b3d89bc67f75e7)
by [Unito](https://www.unito.io)
2026-05-09 01:26:37 -04:00
Christian Byrne
c16052e2e3 feat: sort right-click context menu categories alphabetically (#12039)
*PR Created by the Glary-Bot Agent*

---

## Summary

Sort the canvas right-click "Add Node" context menu by display name
(case-insensitive, natural numeric). Previously, both category submenus
and leaf nodes appeared in node-registration order, making the menu hard
to scan for users browsing for nodes.

This change is scoped specifically to the **smaller right-click
contextual menu**. It does NOT affect the double-click search menu or
the left-side Nodes panel.

## Changes

- `src/lib/litegraph/src/LGraphCanvas.ts` — In `onMenuAdd` →
`inner_onMenuAdded`, sort the deduplicated category submenu entries and
the leaf-node entries by `content` using `localeCompare` with `{
numeric: true, sensitivity: 'base' }`. Categories still appear before
leaf nodes within a level (preserves existing UX).
- `src/lib/litegraph/src/LGraphCanvas.onMenuAdd.test.ts` — New unit
tests that mock `LiteGraph.ContextMenu` and assert: case-insensitive
sort, natural numeric ordering (`Cat1`, `Cat2`, `Cat10`), leaf-node
sorting inside a category, and category-before-leaf placement.

## Verification

- `pnpm vitest run src/lib/litegraph/src/LGraphCanvas.onMenuAdd.test.ts`
— 4/4 pass
- `pnpm typecheck` — clean (ran via pre-commit hook on initial commit)
- `oxfmt` / `oxlint` / `eslint` — clean
- Oracle review against `main` returned 0 critical / 1 warning (test
coverage) / 1 suggestion (numeric sort) — both addressed in this PR.

## Notes

- The sort is applied at the menu-build site rather than inside
`LiteGraphGlobal.getNodeTypesCategories`/`getNodeTypesInCategory` to
keep the change scoped to the menu UX and avoid changing the iteration
order seen by extensions that consume those public methods.
- Per user request, this is opening as a draft PR for self-review +
CodeRabbit feedback in a single follow-up pass; manual browser
verification (right-click screenshots) was deferred to that pass.
- Slack thread context: user reported the contextual menu is "a mess"
for discovering native nodes; alphabetical sorting addresses the
discoverability problem without touching the search-oriented menus.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12039-feat-sort-right-click-context-menu-categories-alphabetically-3596d73d36508107a87ffec1c353994e)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Glary-Bot <glary-bot@users.noreply.github.com>
Co-authored-by: Alexis Rolland <alexis@comfy.org>
2026-05-09 03:31:11 +00:00
Comfy Org PR Bot
3e94459340 1.45.2 (#12096)
Patch version increment to 1.45.2

**Base branch:** `main`

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12096-1-45-2-35b6d73d36508193be00c1c878d42c2a)
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>
2026-05-09 02:07:47 +00:00
Henry Lee
ca54877f9d fix(assets): strip directory annotation from input filenames (#12086)
## Summary

Imported assets render as a generic check-check icon instead of a
thumbnail because the OSS `/internal/files/{type}` endpoint returns
annotated filenames (`photo.png [input]`) that the assets-sidebar mapper
passes through verbatim, which breaks extension-based media-type
detection.

## Changes

- **What**: Strip ComfyUI's trailing directory-type annotation (`
[input]`, ` [output]`, `[temp]`) in `mapInputFileToAssetItem` so `name`,
`id`, and the generated `/view?filename=…` URL all use the canonical
on-disk filename. Adds a focused unit test.
- **Breaking**: None.
- **Dependencies**: None.

## Review Focus

### Root cause

ComfyUI core PR
[comfyanonymous/ComfyUI#13078](https://github.com/comfyanonymous/ComfyUI/pull/13078)
(April 2026) changed `/internal/files/{type}` to append the directory
type to each entry:

```python
# api_server/routes/internal/internal_routes.py
return web.json_response(
    [f"{entry.name} [{directory_type}]" for entry in sorted_files], status=200
)
```

The annotation is the wire format `LoadImage`-style widgets expect, so
the backend change is correct. The assets-sidebar mapper treated the
response strings as raw filenames. After
[#8914](https://github.com/Comfy-Org/ComfyUI_frontend/pull/8914) changed
`getMediaTypeFromFilename` to default unknown extensions to `'other'`,
every input asset now routes to `MediaOtherTop` and renders as
`icon-[lucide--check-check]`:

```
getMediaTypeFromFilename("photo.png [input]").split('.').pop() === "png [input]" → 'other'
```

The strip happens at data ingestion so every consumer of
`AssetItem.name` (sidebar grid, list, filter, gallery, drag-drop,
delete) gets the canonical filename automatically. OSS-only — Cloud
paths get clean names from the cloud API and are unaffected.

Reproduces locally on stock OSS ComfyUI on `main` of both repos; no
public issue tracker entry.

## Screenshots (if applicable)
Before:
<img width="1091" height="718" alt="image"
src="https://github.com/user-attachments/assets/ff1f070d-da39-4e5a-bc6d-99b7214f7da8"
/>

After:
<img width="1089" height="716" alt="image"
src="https://github.com/user-attachments/assets/7123d9bf-f7dd-4430-b6f7-f6702b70baaa"
/>


┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12086-fix-assets-strip-directory-annotation-from-input-filenames-35a6d73d365081e9b9eed7d8630d6f0b)
by [Unito](https://www.unito.io)
2026-05-08 21:31:57 +00:00
Simon Pinfold
a4faaa0159 fix: disable ultralytics asset-browser registration (#12075)
*PR Created by the Glary-Bot Agent*

---

## Summary

Disable the `UltralyticsDetectorProvider` model-to-node mapping so the
node falls back to the static combo populated from `/api/object_info`,
restoring pre-#8468 behavior on cloud.

## Why

PR #8468 opted `UltralyticsDetectorProvider` into the cloud asset-widget
path, which exposed a latent mismatch in cloud asset metadata for
nested-directory model folders. The bug has two independent halves, and
a fix that addresses only one will still leave the workflow broken at
execution time:

- **Tag lookup mismatch.** Cloud stores tags as combined values like
`ultralytics/bbox`, while the asset query asks for split tags (`models`
+ `ultralytics`) with exact-match filtering — so the dropdown returns no
results.
- **Submitted value mismatch.** Cloud stores filenames as basenames, but
the node expects subdirectory-prefixed values (e.g.
`bbox/face_yolov8m.pt`) that the static combo path normally produces.

Both halves require cloud-side fixes (asset ingestion + metadata) before
the asset-browser registration can be safely re-enabled. Until then,
removing the registration restores the working static-combo behavior so
users are unblocked.

## Changes

- `src/platform/assets/mappings/modelNodeMappings.ts`: comment out the
`['ultralytics', 'UltralyticsDetectorProvider', 'model_name']` entry
with a note pointing at BE-689 and the re-enablement criteria.
- `src/stores/modelToNodeStore.test.ts`: drop the now-stale ultralytics
expectations from `EXPECTED_DEFAULT_TYPES`, `MOCK_NODE_NAMES`, and the
hierarchical-fallback `it.each` cases.

## Verification

Local quality gates:
- `pnpm typecheck` — clean
- `pnpm lint` — clean (3 pre-existing warnings, 0 errors)
- `pnpm format:check` — clean
- `pnpm knip` — clean (1 pre-existing warning unrelated to this change)
- `pnpm test:unit -- src/stores/modelToNodeStore.test.ts` — 51/51
passing

Manual runtime verification (dev server + Playwright against the live
module):
- `MODEL_NODE_MAPPINGS` no longer contains any entry where `[0] ===
'ultralytics'` or `[1] === 'UltralyticsDetectorProvider'` (84 entries
total, 0 ultralytics).
- `useModelToNodeStore().getNodeProvider('ultralytics')` returns `null`
after `registerDefaults()`, so the asset-widget path is no longer
triggered for this node.
- `getNodeProvider('ultralytics/bbox')` also returns `null`, confirming
hierarchical fallback no longer resolves to the disabled mapping.
- `getNodeProvider('checkpoints')` still resolves to
`CheckpointLoaderSimple`, confirming unrelated mappings are intact.

End-to-end cloud verification (actually exercising the asset-browser
path against cloud-seeded ultralytics metadata) is not possible in the
local sandbox, since the regression depends on the cloud's asset
ingestion data shape. The change is a single mapping-table removal that
reverts to the well-exercised static-combo path covered by the updated
unit tests.

Long-term cloud-side fix is tracked in BE-689.

- Fixes BE-689

## Screenshots

![ComfyUI dev server loaded with the patched
build](https://pub-1fd11710d4c8405b948c9edc4287a3f2.r2.dev/sessions/7282fc1868f7d89220fa1fbf9f0dcd5dbd55713288d3a3310e99d1cc5768e7d7/pr-images/1778229906648-a825191d-85d8-4a09-adc4-4fb3402d3e92.png)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12075-fix-disable-ultralytics-asset-browser-registration-35a6d73d36508179b394f0915e69742e)
by [Unito](https://www.unito.io)

Co-authored-by: Glary-Bot <glary-bot@users.noreply.github.com>
2026-05-08 13:53:22 -07:00
Dante
8108967d49 feat(dialog): migrate Prompt + Confirmation dialogs to Reka-UI (Phase 1) (#12041)
## Summary

Phase 1 of the dialog migration kicked off in #11719. Migrates the two
simplest production dialogs — `PromptDialogContent` and
`ConfirmationDialogContent` — from PrimeVue `Dialog` onto the Reka-UI
primitives landed in Phase 0. Public API of `useDialogService` /
`dialogStore` is unchanged.

Parent:
[FE-571](https://linear.app/comfyorg/issue/FE-571/dialog-system-migration-primevue-reka-ui-parent)
This phase:
[FE-573](https://linear.app/comfyorg/issue/FE-573/phase-1-migrate-promptdialog-confirmationdialog-closes-11688)
Predecessor: #11719 (merged at `0788e7139`)

Refs #11688 (closed manually after Phase 0; the actual user-visible
max-width fix ships in this PR)

## Changes

### `src/services/dialogService.ts`
| Call site | Renderer | Size | Width override |
| --- | --- | --- | --- |
| `prompt()` | `'reka'` | `md` | — |
| `confirm()` | `'reka'` | `md` | — |
| `showBillingComingSoonDialog()` | `'reka'` | `sm` | `contentClass:
'max-w-[360px]'` |

### `src/components/dialog/content/ConfirmationDialogContent.vue`
- Drops `import Message from 'primevue/message'` — the only PrimeVue
dependency in the component
- Replaces `<Message>` with a Tailwind `role="status"` alert keeping the
`pi pi-info-circle` icon and muted-foreground severity

### `src/stores/dialogStore.ts` +
`src/components/dialog/GlobalDialog.vue`
- Adds `contentClass?: HTMLAttributes['class']` on
`CustomDialogComponentProps`
- Forwards it to `<DialogContent :class="...">` on the Reka branch
(PrimeVue path keeps using `pt`)

## Why this scope

1. **Smallest content surface** — `PromptDialogContent` is 43 LOC; the
only PrimeVue dependency in `ConfirmationDialogContent` is the
`<Message>` info banner.
2. **Closes #11688 ergonomics** — Reka's `md` size = `max-w-xl` (576px /
36rem), exactly the max-width the issue reporter asked for.
3. **Three known callers** — all in `dialogService.ts`. No other callers
needed to change.
4. **Renderer branch is already proven by Phase 0**; this PR just flips
the flag.

## Visual proof

Verified live in Storybook (`Components / Dialog / Dialog → Default` and
`… → All Sizes`) at viewport `1920×1080`. DOM inspection confirms the
rendered widths match the design intent:

| Story | size | Rendered width | Computed `max-width` |
| --- | --- | --- | --- |
| `Default` | `md` | **576 px** | **576 px (= 36rem)** |
| `All Sizes` (sm slot) | `sm` | 384 px | 384 px (= 24rem) |

The `md` measurement directly answers the #11688 reporter screenshot
(1558 px wide PrimeVue dialog → 576 px Reka dialog on the same display).
Local screenshot artifacts (not committed):
`temp/screenshots/phase1-md-576px-1920w.png`,
`temp/screenshots/phase1-md-allsizes-1920w.png`,
`temp/screenshots/phase1-sm-384px-1920w.png` — drag-drop into the PR
body before marking ready for review.

## Quality gates

- [x] `pnpm typecheck` — clean
- [x] `pnpm lint` — clean
- [x] `pnpm format` — applied (oxfmt)
- [x] `pnpm test:unit` (touched files): **26/26 passed**
- `ConfirmationDialogContent.test.ts` (9 tests, no longer needs PrimeVue
plugin)
  - `PromptDialogContent.test.ts` (5 tests, unchanged)
- `GlobalDialog.test.ts` (9 tests, Phase 0 coverage still passes after
the contentClass forwarder addition)
- `dialogService.renderer.test.ts` **new** — 3 tests asserting each call
site sets `renderer: 'reka'` (regression net)
- [ ] `pnpm test:browser:local --grep "@mobile confirm dialog"` —
**could not run locally** (no ComfyUI Python backend on `localhost:8188`
in this session); CI will gate the existing fixture, which is already
renderer-agnostic (`getByRole('dialog')` + `getByRole('button', ...)` in
`browser_tests/fixtures/components/ConfirmDialog.ts`).

## Public API impact

None. `useDialogService().prompt(...)` / `confirm(...)` /
`showBillingComingSoonDialog(...)` keep their existing signatures.
Custom-node extensions calling `app.extensionManager.dialog.*` continue
to work.

## Out of scope (later phases)

- `ErrorDialogContent`, `NodeSearchBox`, `SecretFormDialog`,
`VideoHelpDialog`, `CustomizationDialog` — Phase 2 (FE-574)
- Settings dialog — Phase 3 (FE-575)
- Manager dialog — Phase 4 (FE-576)
- `ConfirmDialog` callers (`SecretsPanel`, `BaseWorkflowsSidebarTab`) —
Phase 5 (FE-577)
- Removing PrimeVue `Dialog` imports + `<style>` cleanup in
`GlobalDialog.vue` — Phase 6 (FE-578)
- Legacy `ComfyDialog` (`src/scripts/ui/dialog.ts`)
- Deduplicating `Dialogue.vue` / `ImageLightbox.vue`


## Screenshot
<img width="865" height="497" alt="Screenshot 2026-05-08 at 4 35 45 PM"
src="https://github.com/user-attachments/assets/6aead2ad-2e0b-478a-9154-bb632a6bf3d1"
/>
<img width="1363" height="964" alt="Screenshot 2026-05-08 at 4 38 16 PM"
src="https://github.com/user-attachments/assets/10647752-a063-4901-a206-842799cc5d7a"
/>
<img width="889" height="486" alt="Screenshot 2026-05-08 at 4 46 57 PM"
src="https://github.com/user-attachments/assets/81899a81-205a-46f2-bddd-7639624607f6"
/>



## Test plan

- [x] Unit: 26/26 pass on touched files
- [ ] CI: `@mobile confirm dialog` spec on the migrated path
- [ ] Manual (post-CI on a real backend): open prompt and confirm
dialogs on 1920×1080 viewport, verify ≤ 36rem max-width, ESC closes,
backdrop click closes, Enter submits prompt, focus trap holds
- [ ] Manual: open Billing Coming Soon dialog — verify it stays at the
existing `max-w-[360px]` width
2026-05-08 12:11:06 +00:00
Dante
0ef98de8eb fix: make credits help icon a tooltip button in cloud user popover (FE-617) (#12072)
## Summary

The help icon (lucide circle-help) next to the credits balance in the
cloud user popover was a bare `<i>` with `v-tooltip` and `cursor-help`.
PrimeVue tooltip on a bare `<i>` did not fire reliably and the icon had
no focus/keyboard semantics, so users saw "no hover action and not
clickable".

Wrap the icon in `<Button variant="muted-textonly" size="icon-sm">`,
matching the existing pattern in `InfoButton.vue` and
`MissingPackGroupRow.vue`. Same change applied to
`CurrentUserPopoverLegacy.vue` and `CurrentUserPopoverWorkspace.vue`,
which shared the broken pattern.

- Fixes FE-617
-
https://comfy-organization.slack.com/archives/C0A4XMHANP3/p1778191473621829

## Red-Green CI Verification

The branch was force-pushed back to the test-only commit so CI could run
against it, then restored to the fix commit.

| Commit | CI: Tests Unit | Outcome |
|--------|---------------|---------|
| `test:` (e7c83abd0) — adds the regression test only |
https://github.com/Comfy-Org/ComfyUI_frontend/actions/runs/25532935842 |
failed — `Unable to find an element by:
[data-testid="credits-info-button"]` |
| `fix:` (64ec4cda4) — wraps the icon in `<Button>` |
https://github.com/Comfy-Org/ComfyUI_frontend/actions/runs/25533224195 |
passed |



<img width="434" height="364" alt="Screenshot 2026-05-08 at 5 32 47 PM"
src="https://github.com/user-attachments/assets/d3088b90-813f-4a0f-ba35-0f040fc79a6a"
/>

## Test Plan

- [x] Component test asserts the icon renders as an interactive
`<button>` with the unified-credits tooltip text as `aria-label`
- [x] Red CI failed with the expected error on the test-only commit
- [x] Green CI passed on the fix commit
- [ ] Manual verification on `pnpm dev:cloud` — hover the help icon next
to the credits balance and confirm the unification tooltip appears
2026-05-08 11:56:35 +00:00
Dante
88866fc564 fix: restore nightly publish_types build (#12073)
## Summary

The nightly `Release Draft Create` -> `publish_types / Build types` job
has been failing on every run since 1.45.1 ([failed
run](https://github.com/Comfy-Org/ComfyUI_frontend/actions/runs/25531376650)).
Reproduced locally on `main` (`c8c0e5386`).

## Root cause

`pnpm build:types` ends with `FATAL ERROR: Reached heap limit Allocation
failed - JavaScript heap out of memory`. The `vite-plugin-dts` rolled-up
type generation now exceeds Node's default ~4GB heap on the GH runner.
The `TS2742`/`TS4082` warnings printed earlier are non-fatal diagnostics
from the plugin pre-pass — the api-extractor rollup itself completes
once the heap is large enough.

## Fix

Set `NODE_OPTIONS='--max-old-space-size=8192'` in `build:types`,
matching the existing pattern already used by `build` and `build:cloud`
(only one-line change).

## Verification

- `pnpm build:types` exits 0 locally with the change (built in ~40s).
- `dist/index.d.ts` (1.9MB) emitted with the public types intact:
`ComfyExtension`, `ComfyApi`, `ComfyApp`, `ComfyNodeDef`, `InputSpec`,
`DOMWidget`, etc.
- `dist/package.json` correctly produced by `scripts/prepare-types.js`.
- Reverting the change reproduces the OOM crash.

## Test plan

- [ ] Trigger `Release NPM Types` workflow manually (or wait for next
nightly) and confirm `Build types` step succeeds.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12073-fix-restore-nightly-publish_types-build-35a6d73d3650819e95aecfbd8a66847c)
by [Unito](https://www.unito.io)
2026-05-08 03:06:03 +00:00
Alexander Brown
1f4a4af079 docs: add subgraph promoted widgets ADR (#11997)
## Summary

Adds an ADR documenting the canonical subgraph promoted-widget model and
legacy proxy-widget ratchet.

## Changes

- **What**: Defines linked `SubgraphInput` promoted widgets, host-owned
sparse value overlays, proxy-widget repair/quarantine behavior,
primitive-node repair, and separate display-only preview exposures.
- **Breaking**: None; documentation only.
- **Dependencies**: None.

## Review Focus

- Whether the ADR cleanly separates value-owning promoted widgets from
display-only preview exposures.
- Whether the legacy ratchet, quarantine, primitive repair, and UI
identity decisions are clear enough for implementation review.

## Screenshots (if applicable)

N/A

@Coderabbitai why would a docs update need an end-to-end test?

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11997-docs-add-subgraph-promoted-widgets-ADR-3576d73d36508133bf1ee8d49282cac1)
by [Unito](https://www.unito.io)

---------

Co-authored-by: GitHub Action <action@github.com>
2026-05-07 19:51:18 -07:00
jaeone94
c8c0e53865 fix: remove asset hash verification (#12061)
## Summary

This PR removes the `/api/assets/hash/:hash` verification path from
missing media/model detection.

I decided to remove this path for two reasons:

1. The Cloud runtime implementation and the OpenAPI/generated FE
contract do not agree on the hash format that this endpoint represents.
In current Cloud data, the dominant asset_hash shape is
<64-hex>.<extension> (for example, abc123....png), while the
OpenAPI/generated FE contract expects a blake3:<hash> style value. That
makes this path either dead code that should never be reached, or, when
it is reached, a request that always returns 400 and only adds
unnecessary noise.

2. Even if the format is reconciled, the Cloud implementation is a
global deduplication-oriented lookup, not an access-aware check for
whether the current workflow can use a resource. In theory, it can
return success for another user's personal asset, so it is the wrong
primitive for missing asset detection.

Because of that, this PR makes the existing asset list/store based
checks the primary verification path and removes the hash-specific
helpers, service method, and tests.

## Known follow-ups

These are known issues that are intentionally not solved in this PR:

1. Published assets are not exposed through
`/api/assets?...include_public=true`. This is a backend issue and can
still cause mismatch between missing-asset detection and resources that
preview/run successfully.
2. Shared workflow import has an ordering issue. The API contract issue
is being hotfixed separately under FE-603.
3. Annotated media paths can still be detected incorrectly.

I will prepare follow-up PRs for these, starting with the annotated
media path issue because that is the most critical frontend-side gap.

## Validation

- `pnpm exec vitest run
src/platform/assets/services/assetService.test.ts
src/platform/missingMedia/missingMediaScan.test.ts
src/platform/missingModel/missingModelScan.test.ts`
- `pnpm lint:unstaged`
- `pnpm typecheck`
- `pnpm knip`

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12061-fix-remove-asset-hash-verification-3596d73d365081a088f8dfc874724c1d)
by [Unito](https://www.unito.io)
2026-05-08 01:56:19 +00:00
Comfy Org PR Bot
c8360a092f 1.45.1 (#12070)
Patch version increment to 1.45.1

**Base branch:** `main`

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12070-1-45-1-35a6d73d365081e9a4bffc19d791b727)
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>
2026-05-08 01:10:40 +00: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
Glary-Bot
89ac191cbc fix: address review feedback for refreshComboInNode
- Distinguish 'no result' (undefined) from 'empty options' ([]) so a
  legitimate empty combo list propagates to the widget instead of being
  treated as a no-op.
- Fall back to input.name when the connected target's input has no
  widget locator, mirroring _onFirstConnection.
- Add regression tests for V2 combo specs, empty option propagation,
  and the widgetless-input fallback path.
2026-05-02 02:10:37 +00:00
Glary-Bot
d0fd56bf9a fix: guard PrimitiveNode.refreshComboInNode against missing slot config
Pressing 'r' (Refresh Node Definitions) calls refreshComboInNode on every
node. For primitives, the previous override re-derived combo values from
this.outputs[0].widget[GET_CONFIG]()[0] without null checks. When that
returned undefined (stale slot widget reference after subgraph traversal
rebuilds widgets), widget.options.values was clobbered with undefined
and the dropdown went blank.

Now the override:
- Uses the freshly passed defs to resolve combo values from the connected
  node + input, matching what reloadNodeDefs does for normal combo widgets.
- Falls back to outputs[0].widget[GET_CONFIG]() when defs are unavailable
  or do not include the target type.
- Preserves existing options if both lookups fail rather than overwriting
  with undefined.

Add regression tests covering the defs-driven refresh, the slot-config
fallback, the missing-config preservation case, value reset when the
current value is no longer valid, no-op when it is still valid, and
non-combo widgets being skipped.
2026-04-29 05:00:37 +00:00
613 changed files with 44780 additions and 4951 deletions

View File

@@ -0,0 +1,173 @@
---
name: add-model-page
description: 'add, update, or remove a model page entry on the comfy org website. creates a PR to Comfy-Org/ComfyUI_frontend apps/website folder with the change and posts a Vercel preview link back to Slack.'
---
# add-model-page
add, update, or remove model pages in the ComfyUI website.
## Trigger phrases
- `Add a model page for <model-name>`
- `Update the model page for <model-name>`
- `Remove <model-name> from model pages`
## Phase 1 — Parse the request
Extract:
- **action**: `add` | `update` | `remove`
- **model-name**: raw string (e.g. `flux1-schnell`, `flux1_dev.safetensors`)
Normalize to a slug: lowercase, replace `_` and `.` with `-`, strip file extensions.
Example: `flux1_dev.safetensors``flux1-dev`
## Architecture overview
Models come from two sources merged at build time:
| File | Purpose |
| ----------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------- |
| `apps/website/src/config/generated-models.json` | Auto-generated from workflow_templates (slug, name, directory, huggingFaceUrl, workflowCount, displayName, thumbnailUrl, docsUrl) |
| `apps/website/src/config/model-metadata.ts` | Hand-curated overrides (docsUrl, blogUrl, featured) — only add entries that need overrides |
| `apps/website/src/config/models.ts` | Merges the two above; exports typed `Model[]` |
To regenerate the JSON from workflow_templates:
```bash
pnpm tsx apps/website/scripts/generate-models.ts
```
This writes `apps/website/src/config/generated-models.json` directly.
Thumbnails are populated from local `.webp` files in `workflow_templates/templates/` — no network access needed.
---
## Phase 2 — Gather model data (ADD / UPDATE)
Run the generator to get fresh data, then find the model:
```bash
pnpm tsx apps/website/scripts/generate-models.ts
jq '.[] | select(.slug | contains("MODEL_SLUG"))' \
apps/website/src/config/generated-models.json
```
The JSON fields are:
- `slug` — URL slug
- `name` — exact filename or display name for partner nodes
- `huggingFaceUrl` — download URL (empty for partner nodes)
- `directory``diffusion_models` | `loras` | … | `partner_nodes`
- `workflowCount` — integer
- `displayName` — human-readable name
If no match and it is a known API/partner model, add it to `API_PROVIDER_MAP` in
`generate-models.ts` and re-run. Otherwise tell the user.
---
## Phase 3 — Check for existing entry
```bash
jq --arg slug "${SLUG}" '.[] | select(.slug == $slug)' \
apps/website/src/config/generated-models.json
```
- Match found + action is `add` → switch to UPDATE flow automatically
- No match + action is `update` → stop and tell the user
---
## Phase 4A — ADD: new partner/API model not in workflow_templates
For partner nodes (no local file), add an entry to `API_PROVIDER_MAP` in
`apps/website/scripts/generate-models.ts`:
```typescript
mymodel: { name: 'My Model', slug: 'my-model' },
```
Then re-run `pnpm tsx apps/website/scripts/generate-models.ts` — it will appear
in `generated-models.json` automatically.
If you also want a `docsUrl`, `blogUrl`, or a link to the hub model page, add an entry to `model-metadata.ts`:
```typescript
'my-model': {
docsUrl: 'https://docs.comfy.org/tutorials/...',
blogUrl: 'https://blog.comfy.org/...',
hubSlug: 'my-model', // slug at comfy.org/workflows/model/{hubSlug} — only set if the page exists (returns 200)
featured: true
}
```
No changes to `models.ts` or `translations.ts` are needed.
---
## Phase 4B — UPDATE: edit existing entry
Only `model-metadata.ts` needs editing for most updates (docsUrl, blogUrl,
featured). For `displayName` or `directory` changes, edit the entry directly in
`generated-models.json` (until the next generator run would overwrite it — then
fix the source in `generate-models.ts`).
---
## Phase 4C — REMOVE: delete entry
Remove the entry from `generated-models.json` (or mark it with `canonicalSlug`
pointing to the replacement). No translation file changes needed.
---
## Phase 5 — Verify TypeScript
```bash
pnpm typecheck 2>&1 | grep -E "error|warning" | head -20
```
Fix any type errors before proceeding. Common issues:
- `ModelDirectory` type not matching a new `directory` value — add it to the union
- JSON import shape mismatch — `generated-models.json` must match `OutputModel`
---
## Phase 6 — Create PR
```bash
BRANCH="add-model-page-MODEL-SLUG" # or update- / remove-
git checkout -b $BRANCH
git add apps/website/src/config/generated-models.json \
apps/website/scripts/generate-models.ts \
apps/website/src/config/model-metadata.ts
git commit -m "feat(models): add model page for MODEL-SLUG"
git push -u origin $BRANCH
gh pr create \
--title "Add model page: MODEL-SLUG" \
--body "$(cat <<'EOF'
Adds a new model page entry for MODEL-SLUG.
## Changes
- `generated-models.json`: regenerated with new entry (workflowCount N, directory DIRECTORY)
- `model-metadata.ts`: editorial overrides (docsUrl, featured) if needed
EOF
)"
```
For UPDATE use branch `update-model-page-MODEL-SLUG`.
For REMOVE use `remove-model-page-MODEL-SLUG`.
---
## Error states
| Situation | Response |
| ------------------------------- | ---------------------------------------------------------------- |
| Model not in workflow templates | Ask user to verify spelling or add it manually as a partner node |
| Slug already exists (add) | Switch to update flow automatically |
| Slug not found (update/remove) | Stop and ask user to confirm |
| Typecheck fails | Fix the error before pushing |

View File

@@ -19,15 +19,26 @@ reviews:
- name: End-to-end regression coverage for fixes
mode: error
instructions: |
Use only PR metadata already available in the review context: the PR title, commit subjects in this PR, the files changed in this PR relative to the PR base (equivalent to `base...head`), and the PR description.
Do not rely on shell commands. Do not inspect reverse diffs, files changed only on the base branch, or files outside this PR. If the changed-file list or commit subjects are unavailable, mark the check inconclusive instead of guessing.
Use only PR metadata already available in the review context:
- the PR title
- commit subjects in this PR
- The files changed in this PR relative to the PR base (equivalent to `base...head`)
- the PR description.
Do not rely on shell commands.
Do not inspect reverse diffs, files changed only on the base branch, or files outside this PR.
If the changed-file list or commit subjects are unavailable, mark the check inconclusive instead of guessing.
Pass if at least one of the following is true:
1. Neither the PR title nor any commit subject in the PR uses bug-fix language such as `fix`, `fixed`, `fixes`, `fixing`, `bugfix`, or `hotfix`.
2. The PR changes at least one file under `browser_tests/`.
3. The PR description includes a concrete, non-placeholder explanation of why an end-to-end regression test was not added.
Fail if all of the following are true:
1. The PR title and/or any commit subject in the PR uses bug-fix language such as `fix`, `fixed`, `fixes`, `fixing`, `bugfix`, or `hotfix`.
2. The PR changes files under `src/` or `packages/` related to the main frontend application but the PR does not change at least one file under `browser_tests/`.
3. The PR description lacks a concrete explanation of why an end-to-end regression test was not added.
Do not fail if the changes are exclusively in `apps/website`, just documentation changes, or changes related to CI processes.
The goal is to make sure that fixes include End-to-End regression tests. Do not insist on tests when the PR is not fixing a bug.
Pass otherwise.
When failing, mention which bug-fix signal you found and ask the author to either add or update a Playwright regression test under `browser_tests/` or add a concrete explanation in the PR description of why an end-to-end regression test is not practical.
Fail otherwise. When failing, mention which bug-fix signal you found and ask the author to either add or update a Playwright regression test under `browser_tests/` or add a concrete explanation in the PR description of why an end-to-end regression test is not practical.
- name: ADR compliance for entity/litegraph changes
mode: warning
instructions: |

View File

@@ -0,0 +1,19 @@
name: Cloud Nodes Pull
description: 'Refresh the apps/website cloud nodes snapshot from the Comfy Cloud /api/object_info endpoint'
inputs:
api_key:
description: 'Comfy Cloud API key (WEBSITE_CLOUD_API_KEY).'
required: true
runs:
using: 'composite'
steps:
# Note: this action assumes the frontend repo is checked out at the workspace root.
- name: Setup frontend
uses: ./.github/actions/setup-frontend
- name: Refresh cloud nodes snapshot
shell: bash
env:
WEBSITE_CLOUD_API_KEY: ${{ inputs.api_key }}
run: pnpm --filter @comfyorg/website cloud-nodes:refresh-snapshot

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,20 +104,17 @@ 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."
mkdir -p coverage/html
echo '<html><body><h1>No E2E coverage data available for this run.</h1></body></html>' > coverage/html/index.html
exit 0
fi
genhtml coverage/playwright/coverage.lcov \
-o coverage/html \
--title "ComfyUI E2E Coverage" \
--no-function-coverage \
--precision 1
--precision 1 \
--ignore-errors source,unmapped
- 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 +123,10 @@ 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' &&
github.event.workflow_run.event == 'push'
runs-on: ubuntu-latest
permissions:
pages: write

View File

@@ -58,6 +58,7 @@ jobs:
env:
WEBSITE_ASHBY_API_KEY: ${{ secrets.WEBSITE_ASHBY_API_KEY }}
WEBSITE_ASHBY_JOB_BOARD_NAME: ${{ secrets.WEBSITE_ASHBY_JOB_BOARD_NAME }}
WEBSITE_CLOUD_API_KEY: ${{ secrets.WEBSITE_CLOUD_API_KEY }}
run: vercel build
- name: Fetch head commit metadata
@@ -151,10 +152,20 @@ jobs:
- name: Pull Vercel environment information
run: vercel pull --yes --environment=production
- name: Verify WEBSITE_CLOUD_API_KEY is present for production build
env:
WEBSITE_CLOUD_API_KEY: ${{ secrets.WEBSITE_CLOUD_API_KEY }}
run: |
if [ -z "${WEBSITE_CLOUD_API_KEY:-}" ]; then
echo "::error title=Missing WEBSITE_CLOUD_API_KEY::Production builds require WEBSITE_CLOUD_API_KEY so /cloud/supported-nodes is generated from fresh Cloud API data. Add it as a GitHub Actions repo secret and to the Vercel project environment. See apps/website/README.md."
exit 1
fi
- name: Build project artifacts
env:
WEBSITE_ASHBY_API_KEY: ${{ secrets.WEBSITE_ASHBY_API_KEY }}
WEBSITE_ASHBY_JOB_BOARD_NAME: ${{ secrets.WEBSITE_ASHBY_JOB_BOARD_NAME }}
WEBSITE_CLOUD_API_KEY: ${{ secrets.WEBSITE_CLOUD_API_KEY }}
run: vercel build --prod
- name: Deploy project artifacts to Vercel

View File

@@ -0,0 +1,123 @@
name: Model Page Discovery
on:
schedule:
- cron: '0 9 * * 1'
workflow_dispatch:
jobs:
discover:
runs-on: ubuntu-latest
permissions:
contents: read
issues: write
steps:
- name: Fetch model labels from hub API
id: hub
shell: bash
run: |
set -euo pipefail
curl -fsSL 'https://comfy.org/api/hub/labels?type=model' -o hub-labels.json
echo "Fetched $(jq '.labels | length' hub-labels.json) model labels from hub"
- name: Checkout ComfyUI_frontend
uses: actions/checkout@v6
with:
sparse-checkout: apps/website/src/config/generated-models.json
- name: Compare against existing models
id: compare
shell: bash
run: |
set -euo pipefail
HUB_SLUGS=$(jq -r '[.labels[].name]' hub-labels.json)
EXISTING_SLUGS=$(node -e "
const fs = require('fs');
const models = JSON.parse(
fs.readFileSync(
'apps/website/src/config/generated-models.json',
'utf8'
)
);
console.log(JSON.stringify(models.map(m => m.slug)));
" 2>/dev/null || echo '[]')
ADDED_SLUGS=$(node -e "
const hub = $HUB_SLUGS;
const existing = new Set($EXISTING_SLUGS);
console.log(JSON.stringify(hub.filter(s => !existing.has(s))));
")
COUNT=$(node -e "console.log($ADDED_SLUGS.length)")
echo "new_count=$COUNT" >> \$GITHUB_OUTPUT
echo "new_slugs=$ADDED_SLUGS" >> \$GITHUB_OUTPUT
if [ "\$COUNT" -eq 0 ]; then
echo "No new models found."
else
echo "Found \$COUNT new model(s)"
fi
- name: Check for existing open discovery issue
id: existing_issue
if: steps.compare.outputs.new_count != '0'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
shell: bash
run: |
COUNT=$(gh issue list \
--repo "$GITHUB_REPOSITORY" \
--state open \
--search 'in:title "New models detected"' \
--json number \
--jq 'length')
echo "open_count=$COUNT" >> $GITHUB_OUTPUT
- name: Open GitHub issue for new models
if: |
steps.compare.outputs.new_count != '0' &&
steps.existing_issue.outputs.open_count == '0'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
NEW_SLUGS: ${{ steps.compare.outputs.new_slugs }}
NEW_COUNT: ${{ steps.compare.outputs.new_count }}
shell: bash
run: |
SLUG_LIST=$(node -e "
const slugs = $NEW_SLUGS;
console.log(slugs.map(s => '- \`' + s + '\`').join('\n'));
")
gh issue create \
--repo "$GITHUB_REPOSITORY" \
--title "New models detected — add to model pages" \
--body "## $NEW_COUNT new model(s) found in hub
The weekly model discovery scan found model labels on the hub not yet in
\`apps/website/src/config/generated-models.json\`.
### New slugs ($NEW_COUNT)
$SLUG_LIST
### Next steps
1. Review which of these warrant an SEO model page
2. For local models: run \`SKIP_THUMBNAILS=1 pnpm generate:models\` and commit the result
3. For partner/API models: add to \`API_PROVIDER_MAP\` in \`generate-models.ts\`, regenerate, commit
---
*Generated by the [model-page-discovery workflow](https://github.com/$GITHUB_REPOSITORY/actions/workflows/model-page-discovery.yaml)*"
- name: Skip — open issue already exists
if: |
steps.compare.outputs.new_count != '0' &&
steps.existing_issue.outputs.open_count != '0'
run: echo "An open discovery issue already exists — skipping creation."
- name: No new models found
if: steps.compare.outputs.new_count == '0'
run: echo "No new models found — nothing to do."

View File

@@ -1,6 +1,6 @@
# Description: Manual workflow to refresh the apps/website Ashby roles snapshot
# and open a PR. Merging the PR triggers the existing Vercel website production
# deploy via ci-vercel-website-preview.yaml.
# Description: Manual workflow to refresh the apps/website Ashby roles and
# cloud nodes snapshots and open a PR. Merging the PR triggers the existing
# Vercel website production deploy via ci-vercel-website-preview.yaml.
name: 'Release: Website'
on:
@@ -11,7 +11,7 @@ concurrency:
cancel-in-progress: true
jobs:
refresh-snapshot:
refresh-snapshots:
if: github.repository == 'Comfy-Org/ComfyUI_frontend'
runs-on: ubuntu-latest
permissions:
@@ -31,28 +31,39 @@ jobs:
api_key: ${{ secrets.WEBSITE_ASHBY_API_KEY }}
job_board_name: ${{ secrets.WEBSITE_ASHBY_JOB_BOARD_NAME }}
- name: Refresh cloud nodes snapshot
uses: ./.github/actions/cloud-nodes-pull
with:
api_key: ${{ secrets.WEBSITE_CLOUD_API_KEY }}
- name: Create Pull Request
uses: peter-evans/create-pull-request@c0f553fe549906ede9cf27b5156039d195d2ece0 # v8.1.0
with:
token: ${{ secrets.PR_GH_TOKEN }}
commit-message: 'chore(website): refresh Ashby roles snapshot'
title: 'chore(website): refresh Ashby roles snapshot'
commit-message: 'chore(website): refresh Ashby and cloud nodes snapshots'
title: 'chore(website): refresh Ashby and cloud nodes snapshots'
body: |
Automated refresh of `apps/website/src/data/ashby-roles.snapshot.json`
from the Ashby job board API.
Automated refresh of remote-data snapshots used by the website
build:
- `apps/website/src/data/ashby-roles.snapshot.json` — Ashby job
board API
- `apps/website/src/data/cloud-nodes.snapshot.json` — Comfy Cloud
`/api/object_info`
**Flow:**
1. `Release: Website` workflow ran (manual trigger).
2. This PR opens with the regenerated snapshot.
2. This PR opens with the regenerated snapshots.
3. `CI: Vercel Website Preview` deploys a preview for review.
4. Merging to `main` triggers the production Vercel deploy.
The snapshot fallback in `apps/website/src/utils/ashby.ts` remains
intact: builds without `WEBSITE_ASHBY_API_KEY` continue to use the
committed snapshot.
The snapshot fallback in `apps/website/src/utils/ashby.ts` and
`apps/website/src/utils/cloudNodes.ts` remains intact: builds
without the respective API keys continue to use the committed
snapshot (with a warning annotation in CI).
Triggered by workflow run `${{ github.run_id }}`.
branch: chore/refresh-ashby-snapshot-${{ github.run_id }}
branch: chore/refresh-website-snapshots-${{ github.run_id }}
base: main
labels: |
Release:Website

View File

@@ -85,6 +85,15 @@
"typescript/no-unused-vars": "off",
"unicorn/no-empty-file": "off",
"vitest/require-mock-type-parameters": "off",
"vitest/consistent-each-for": [
"error",
{
"test": "for",
"it": "for",
"describe": "for",
"suite": "for"
}
],
"unicorn/no-new-array": "off",
"unicorn/no-single-promise-in-promise-methods": "off",
"unicorn/no-useless-fallback-in-spread": "off",

View File

@@ -1,12 +1,12 @@
<template>
<tr
class="border-neutral-700 border-solid border-y"
class="border-y border-solid border-neutral-700"
:class="{
'opacity-50': runner.resolved,
'opacity-75': isLoading && runner.resolved
}"
>
<td class="text-center w-16">
<td class="w-16 text-center">
<TaskListStatusIcon :state="runner.state" :loading="isLoading" />
</td>
<td>
@@ -14,7 +14,7 @@
{{ task.name }}
</p>
<Button
class="inline-block mx-2"
class="mx-2 inline-block"
type="button"
:icon="PrimeIcons.INFO_CIRCLE"
severity="secondary"
@@ -22,11 +22,11 @@
@click="toggle"
/>
<Popover ref="infoPopover" class="block m-1 max-w-64 min-w-32">
<Popover ref="infoPopover" class="m-1 block max-w-64 min-w-32">
<span class="whitespace-pre-line">{{ task.description }}</span>
</Popover>
</td>
<td class="text-right px-4">
<td class="px-4 text-right">
<Button
:icon="task.button?.icon"
:label="task.button?.text"

View File

@@ -9,6 +9,7 @@ import en from '@frontend-locales/en/main.json' with { type: 'json' }
import enNodes from '@frontend-locales/en/nodeDefs.json' with { type: 'json' }
import enSettings from '@frontend-locales/en/settings.json' with { type: 'json' }
import { getDefaultLocale } from '@frontend-locales/localeConfig'
import { createI18n } from 'vue-i18n'
function buildLocale<
@@ -167,7 +168,7 @@ const messages: Record<string, LocaleMessages> = {
export const i18n = createI18n({
// Must set `false`, as Vue I18n Legacy API is for Vue 2
legacy: false,
locale: navigator.language.split('-')[0] || 'en',
locale: getDefaultLocale(),
fallbackLocale: 'en',
messages,
// Ignore warnings for locale options as each option is in its own language.

View File

@@ -113,6 +113,50 @@ git commit apps/website/src/data/ashby-roles.snapshot.json
The script exits non-zero on any non-fresh outcome so stale/empty
snapshots can't be accidentally committed.
## Cloud nodes integration
`/cloud/supported-nodes` (and `/zh-CN/`) lists custom-node packs preinstalled on Comfy Cloud, joined with public metadata from the [ComfyUI Custom Node Registry](https://registry.comfy.org) ([`api.comfy.org`](https://api.comfy.org)). See [`src/pages/cloud/supported-nodes/AGENTS.md`](src/pages/cloud/supported-nodes/AGENTS.md) for the build pipeline, source-file map, and key invariants.
Build-time env var: `WEBSITE_CLOUD_API_KEY` (Cloud `/api/object_info` auth; the build falls back to the committed snapshot when unset). Must also be set in the Vercel project environment.
### Production strictness
`src/utils/cloudNodes.build.ts` throws when `fetchCloudNodesForBuild()` returns
`{ status: 'stale' }` **and** `process.env.VERCEL_ENV === 'production'`. This
prevents the production deploy from silently shipping an out-of-date snapshot
when the Cloud API is unreachable or `WEBSITE_CLOUD_API_KEY` is missing. Preview
and local builds continue to use the committed snapshot with a warning
annotation.
### Required GitHub Actions / Vercel secrets
| Name | Where | Purpose |
| ----------------------- | ----------------------------------------------- | ---------------------------------------------------------------------- |
| `WEBSITE_CLOUD_API_KEY` | GitHub Actions repo secret + Vercel project env | Auth for Cloud `/api/object_info`. Required for fresh production data. |
The `Release: Website` workflow uses the GitHub Actions secret to regenerate
`apps/website/src/data/cloud-nodes.snapshot.json` via
`.github/actions/cloud-nodes-pull/action.yaml`. The Vercel environment value is
read at build time by `vercel build` in `ci-vercel-website-preview.yaml`; the
`deploy-production` job hard-fails before `vercel build --prod` if the secret
is missing.
### Refreshing the snapshot
To update the committed snapshot manually (e.g. after onboarding new packs
to Comfy Cloud):
```bash
WEBSITE_CLOUD_API_KEY=\
pnpm --filter @comfyorg/website cloud-nodes:refresh-snapshot
git commit apps/website/src/data/cloud-nodes.snapshot.json
```
The script exits non-zero on any non-fresh outcome so stale/empty snapshots
can't be accidentally committed. Otherwise the `Release: Website` GitHub
Actions workflow runs the same step on every manual dispatch and opens a PR
with the refreshed snapshot.
## HubSpot contact form
The contact page uses HubSpot's hosted form embed for the interest form:
@@ -146,3 +190,4 @@ renders the documented embed container.
- `pnpm test:unit` — Vitest unit tests
- `pnpm test:e2e` — Playwright E2E tests (requires `pnpm build` first)
- `pnpm ashby:refresh-snapshot` — refresh the committed careers snapshot
- `pnpm cloud-nodes:refresh-snapshot` — refresh the committed cloud nodes snapshot

View File

@@ -28,7 +28,8 @@ export default defineConfig({
'/cloud/enterprise-case-studies/comfyui-at-architectural-scale-how-moment-factory-reimagined-3d-projection-mapping':
'/customers/moment-factory/',
'/cloud/enterprise-case-studies/how-series-entertainment-rebuilt-game-and-video-production-with-comfyui':
'/customers/series-entertainment/'
'/customers/series-entertainment/',
'/zh-CN/terms-of-service': '/terms-of-service'
},
build: {
assets: '_website'

View File

@@ -23,26 +23,50 @@ test.describe('Careers page @smoke', () => {
expect(await roles.count()).toBeGreaterThan(0)
})
test('each role links to jobs.ashbyhq.com', async ({ page }) => {
test('clicking a department button scrolls to and activates that section', async ({
page
}) => {
const rolesSection = page.getByTestId('careers-roles')
await rolesSection.scrollIntoViewIfNeeded()
await expect(rolesSection).toBeVisible()
const allCount = await page.getByTestId('careers-role-link').count()
const engineeringButton = page.getByRole('button', {
name: 'ENGINEERING',
exact: true
})
// RolesSection is hydrated via `client:visible`. Once the button responds
// to a click by flipping aria-pressed, Vue is hydrated and the rest of
// the locator logic is in effect.
await expect(async () => {
await engineeringButton.click()
await expect(engineeringButton).toHaveAttribute('aria-pressed', 'true', {
timeout: 1_000
})
}).toPass({ timeout: 10_000 })
const engineeringSection = page.locator('#careers-dept-engineering')
await expect(engineeringSection).toBeInViewport()
expect(await page.getByTestId('careers-role-link').count()).toBe(allCount)
})
})
test.describe('Careers page role links', () => {
test('each role links to the Ashby job description page, not the application form', async ({
page
}) => {
await page.goto('/careers')
const roles = page.getByTestId('careers-role-link')
const count = await roles.count()
for (let i = 0; i < count; i++) {
const href = await roles.nth(i).getAttribute('href')
expect(href).toMatch(/^https:\/\/jobs\.ashbyhq\.com\//)
expect(href).not.toMatch(/\/application\/?$/)
}
})
test('ENGINEERING category filter narrows the role list', async ({
page
}) => {
const allCount = await page.getByTestId('careers-role-link').count()
await page.getByRole('button', { name: 'ENGINEERING', exact: true }).click()
const engineeringLocator = page.getByTestId('careers-role-link')
await expect(engineeringLocator.first()).toBeVisible()
const engineeringCount = await engineeringLocator.count()
expect(engineeringCount).toBeLessThanOrEqual(allCount)
expect(engineeringCount).toBeGreaterThan(0)
})
})
test.describe('Careers page (zh-CN) @smoke', () => {

View File

@@ -0,0 +1,169 @@
import { expect } from '@playwright/test'
import { test } from './fixtures/blockExternalMedia'
test.describe('Cloud nodes page @smoke', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/cloud/supported-nodes')
})
test('has correct title', async ({ page }) => {
await expect(page).toHaveTitle(
'Custom-node packs on Comfy Cloud — supported by default'
)
})
test('renders at least one pack card', async ({ page }) => {
const cards = page.getByTestId('cloud-node-pack-card')
await expect(cards.first()).toBeVisible()
expect(await cards.count()).toBeGreaterThan(0)
})
test('search input filters cards down', async ({ page }) => {
const cards = page.getByTestId('cloud-node-pack-card')
const initialCount = await cards.count()
await page.getByTestId('cloud-nodes-search').fill('impact')
const filteredCards = page.getByTestId('cloud-node-pack-card')
await expect(filteredCards.first()).toBeVisible()
const filteredCount = await filteredCards.count()
expect(filteredCount).toBeGreaterThan(0)
expect(filteredCount).toBeLessThanOrEqual(initialCount)
})
test('renders banner image or fallback element', async ({ page }) => {
const banners = page.getByTestId('cloud-node-pack-banner')
await expect(banners.first()).toBeVisible()
})
test('opens pack detail page from first card', async ({ page }) => {
const firstCard = page.getByTestId('cloud-node-pack-card').first()
await expect(firstCard).toBeVisible()
await firstCard.locator('a').first().click()
await expect(page).toHaveURL(/\/cloud\/supported-nodes\/[a-z0-9-]+$/)
await expect(page.getByTestId('cloud-node-pack-detail')).toBeVisible()
})
test('direct pack detail route renders node entries', async ({ page }) => {
await page.goto('/cloud/supported-nodes/comfyui-impact-pack')
await expect(page.getByTestId('cloud-node-pack-detail')).toBeVisible()
await expect(
page.getByTestId('cloud-node-pack-detail-node').first()
).toBeVisible()
})
test('search with no matches shows empty state', async ({ page }) => {
await page
.getByTestId('cloud-nodes-search')
.fill('zzzz-this-pack-does-not-exist')
await expect(page.getByTestId('cloud-node-pack-card')).toHaveCount(0)
await expect(page.getByText('No matching packs')).toBeVisible()
})
test('clearing search restores the full list', async ({ page }) => {
const cards = page.getByTestId('cloud-node-pack-card')
const initialCount = await cards.count()
await page.getByTestId('cloud-nodes-search').fill('impact')
await expect(cards.first()).toBeVisible()
await page.getByTestId('cloud-nodes-search').fill('')
await expect(cards).toHaveCount(initialCount)
})
test('search matches against node display names, not just pack names', async ({
page
}) => {
await page.getByTestId('cloud-nodes-search').fill('FaceDetailer')
await expect(page.getByTestId('cloud-node-pack-card')).toHaveCount(1)
await expect(
page.getByTestId('cloud-node-pack-card-link').first()
).toContainText('Impact Pack')
})
test('switching sort to A → Z reorders cards alphabetically', async ({
page
}) => {
await page.locator('#cloud-nodes-sort').selectOption('az')
const firstName = await page
.getByTestId('cloud-node-pack-card-link')
.first()
.textContent()
expect(firstName?.trim().toLowerCase().charAt(0)).toMatch(/^[a-c]/)
})
test('list grid carries a localized aria-label', async ({ page }) => {
await expect(
page.getByRole('list', {
name: 'Custom-node packs supported on Comfy Cloud'
})
).toBeVisible()
})
test('clicking the back link returns to the index from a detail page', async ({
page
}) => {
await page.goto('/cloud/supported-nodes/comfyui-impact-pack')
await page.getByRole('link', { name: 'Back to all packs' }).click()
await expect(page).toHaveURL(/\/cloud\/supported-nodes\/?$/)
await expect(page.getByTestId('cloud-node-pack-card').first()).toBeVisible()
})
test('detail page renders publisher and external repo link', async ({
page
}) => {
await page.goto('/cloud/supported-nodes/comfyui-impact-pack')
const repoLink = page.getByRole('link', {
name: /github\.com\/ltdrdata\/ComfyUI-Impact-Pack/
})
await expect(repoLink).toBeVisible()
await expect(repoLink).toHaveAttribute('rel', /noopener/)
})
test('unknown pack slug 404s', async ({ page }) => {
const response = await page.goto(
'/cloud/supported-nodes/this-pack-does-not-exist'
)
expect(response?.status()).toBe(404)
})
test('JSON-LD ItemList is emitted on the index page', async ({ page }) => {
const jsonLd = page.locator('script[type="application/ld+json"]')
const ldBlocks = await jsonLd.allTextContents()
expect(ldBlocks.some((b) => b.includes('"@type":"ItemList"'))).toBeTruthy()
})
test('JSON-LD payload escapes <-sequences', async ({ page }) => {
const ldBlocks = await page
.locator('script[type="application/ld+json"]')
.allTextContents()
for (const block of ldBlocks) {
expect(block).not.toContain('</script')
}
})
})
test.describe('Cloud nodes page (zh-CN) @smoke', () => {
test('renders localized title and packs', async ({ page }) => {
await page.goto('/zh-CN/cloud/supported-nodes')
await expect(page).toHaveTitle('Comfy Cloud 自定义节点包合集——开箱即用')
await expect(page.getByTestId('cloud-node-pack-card').first()).toBeVisible()
await expect(
page.getByTestId('cloud-node-pack-banner').first()
).toBeVisible()
})
test('opens pack detail page from first card', async ({ page }) => {
await page.goto('/zh-CN/cloud/supported-nodes')
const firstCard = page.getByTestId('cloud-node-pack-card').first()
await expect(firstCard).toBeVisible()
await firstCard.locator('a').first().click()
await expect(page).toHaveURL(/\/zh-CN\/cloud\/supported-nodes\/[a-z0-9-]+$/)
await expect(page.getByTestId('cloud-node-pack-detail')).toBeVisible()
})
})

View File

@@ -0,0 +1,61 @@
import { expect } from '@playwright/test'
import { test } from './fixtures/blockExternalMedia'
const M4_PRO_14_INCH_VIEWPORT = { width: 2016, height: 1310 }
const LAST_SECTION_HASH = '#contact'
test.describe(
'ContentSection scroll-spy @smoke',
{
annotation: [
{
type: 'issue',
description:
'https://linear.app/comfyorg/issue/FE-604/bug-bottom-badge-not-activating-on-scroll-at-high-resolution-3024x1964'
},
{
type: 'environment',
description:
'14" MacBook M4 Pro logical viewport reported in FE-604; /privacy-policy reproduces because of its short trailing sections'
}
]
},
() => {
test.use({ viewport: M4_PRO_14_INCH_VIEWPORT })
test('activates the last badge when user scrolls to the bottom', async ({
page
}) => {
await page.goto('/privacy-policy')
const sidebarNav = page.getByRole('navigation', {
name: 'Category filter'
})
const badges = sidebarNav.getByRole('button')
const lastBadge = badges.last()
await expect(badges.first()).toHaveAttribute('aria-pressed', 'true')
await expect(lastBadge).toHaveAttribute('aria-pressed', 'false')
await page.evaluate(() =>
window.scrollTo(0, document.documentElement.scrollHeight)
)
await expect(lastBadge).toHaveAttribute('aria-pressed', 'true')
})
test('activates the last badge when page mounts already at the bottom via trailing hash', async ({
page
}) => {
await page.goto(`/privacy-policy${LAST_SECTION_HASH}`)
const sidebarNav = page.getByRole('navigation', {
name: 'Category filter'
})
const lastBadge = sidebarNav.getByRole('button').last()
await expect(lastBadge).toHaveAttribute('aria-pressed', 'true')
})
}
)

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

@@ -1,27 +1,71 @@
import { expect, test } from '@playwright/test'
import { demos, getNextDemo } from '../src/config/demos'
import { t } from '../src/i18n/translations'
const escapeRegExp = (value: string): string =>
value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
test.describe('Demo pages @smoke', () => {
test('demo detail page renders hero and embed', async ({ page }) => {
await page.goto('/demos/image-to-video')
await expect(page.getByRole('heading', { level: 1 })).toBeVisible()
await expect(page.getByRole('heading', { level: 1 })).toContainText(
'Create a Video from an Image'
)
const iframe = page.locator('iframe[title*="Interactive demo"]')
await expect(iframe).toBeAttached()
})
for (const demo of demos) {
const nextDemo = getNextDemo(demo.slug)
test('demo detail page has transcript section', async ({ page }) => {
await page.goto('/demos/image-to-video')
await expect(
page.getByRole('button', { name: /demo transcript/i })
).toBeVisible()
})
test(`/demos/${demo.slug} renders hero, embed, transcript, and next-demo nav`, async ({
page
}) => {
await page.goto(`/demos/${demo.slug}`)
test('demo detail page has next demo navigation', async ({ page }) => {
await page.goto('/demos/image-to-video')
await expect(page.getByText(/what's next/i)).toBeVisible()
})
const heading = page.getByRole('heading', { level: 1 })
await expect(heading).toBeVisible()
await expect(heading).toContainText(t(demo.title, 'en'))
const ogImage = page.locator('head meta[property="og:image"]')
await expect(ogImage).toHaveAttribute(
'content',
new RegExp(`${escapeRegExp(demo.slug)}-og\\.png`)
)
const iframe = page.locator(
`iframe[title*="${t('demos.embed.label', 'en')}"]`
)
await expect(iframe).toBeAttached()
await expect(iframe).toHaveAttribute(
'src',
new RegExp(escapeRegExp(demo.arcadeId))
)
await expect(
page.getByRole('button', { name: /demo transcript/i })
).toBeVisible()
await expect(
page.getByText(t(nextDemo.title, 'en')).first()
).toBeVisible()
const nextThumb = page.locator(`img[src="${nextDemo.thumbnail}"]`).first()
await expect(nextThumb).toBeAttached()
await expect(nextThumb).toBeVisible()
const naturalWidth = await nextThumb.evaluate(
(img) => (img as HTMLImageElement).naturalWidth
)
expect(naturalWidth).toBeGreaterThan(1)
})
test(`/zh-CN/demos/${demo.slug} renders localized content`, async ({
page
}) => {
await page.goto(`/zh-CN/demos/${demo.slug}`)
await expect(page).toHaveURL(/\/zh-CN\/demos\//)
const heading = page.getByRole('heading', { level: 1 })
await expect(heading).toContainText(t(demo.title, 'zh-CN'))
await expect(heading).toContainText(/[\u4E00-\u9FFF]/)
await expect(
page.getByText(t(nextDemo.title, 'zh-CN')).first()
).toBeVisible()
})
}
test('demo library page renders', async ({ page }) => {
await page.goto('/demos')
@@ -32,13 +76,4 @@ test.describe('Demo pages @smoke', () => {
const response = await page.goto('/demos/nonexistent')
expect(response?.status()).toBe(404)
})
test('zh-CN demo page renders localized content', async ({ page }) => {
await page.goto('/zh-CN/demos/image-to-video')
await expect(page.getByRole('heading', { level: 1 })).toContainText(
'从图片创建视频'
)
const nextDemoLink = page.locator('a[href*="/zh-CN/demos/"]').first()
await expect(nextDemoLink).toBeAttached()
})
})

View File

@@ -0,0 +1,56 @@
import { expect } from '@playwright/test'
import { test } from './fixtures/blockExternalMedia'
test.describe('Pricing page @smoke', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/cloud/pricing')
})
test('shows the three paid tiers and Enterprise', async ({ page }) => {
const pricingGrid = page
.locator('section', {
has: page.getByRole('heading', { name: /Pricing/i })
})
.locator('.lg\\:grid')
for (const label of ['STANDARD', 'CREATOR', 'PRO']) {
await expect(
pricingGrid.locator('span', { hasText: new RegExp(`^${label}$`) })
).toBeVisible()
}
await expect(
page.getByRole('heading', { name: /Looking for Enterprise Solutions/i })
).toBeVisible()
})
test('does not show the Free tier when SHOW_FREE_TIER is disabled', async ({
page
}) => {
const pricingGrid = page
.locator('section', {
has: page.getByRole('heading', { name: /Pricing/i })
})
.locator('.lg\\:grid')
await expect(
pricingGrid.locator('span', { hasText: /^FREE$/ })
).toHaveCount(0)
await expect(page.getByRole('link', { name: /^START FREE$/ })).toHaveCount(
0
)
await expect(page.getByText(/Everything in Free, plus:/i)).toHaveCount(0)
})
})
test.describe('Cloud pricing teaser @smoke', () => {
test('does not show the "Start free" tagline when SHOW_FREE_TIER is disabled', async ({
page
}) => {
await page.goto('/cloud')
await expect(
page.getByText(/Start free\.\s*Upgrade when you're ready\./i)
).toHaveCount(0)
})
})

View File

@@ -1,3 +1,4 @@
import type { Page } from '@playwright/test'
import { expect } from '@playwright/test'
import { test } from './fixtures/blockExternalMedia'
@@ -47,4 +48,105 @@ test.describe('Mobile layout @mobile', () => {
const mobileContainer = page.getByTestId('social-proof-mobile')
await expect(mobileContainer).toBeVisible()
})
test.describe('SocialProofBar seamless marquee', () => {
test.use({ contextOptions: { reducedMotion: 'no-preference' } })
test('mobile forward marquee loops seamlessly', async ({ page }) => {
const geometry = await measureMarqueeLoopGeometry(
page,
'[data-testid="social-proof-mobile"] .animate-marquee'
)
expectSeamlessForwardLoop(geometry)
})
test('mobile reverse marquee loops seamlessly', async ({ page }) => {
const geometry = await measureMarqueeLoopGeometry(
page,
'[data-testid="social-proof-mobile"] .animate-marquee-reverse'
)
expectSeamlessReverseLoop(geometry)
})
})
})
test.describe('Desktop SocialProofBar @smoke', () => {
test.use({ contextOptions: { reducedMotion: 'no-preference' } })
test.beforeEach(async ({ page }) => {
await page.goto('/')
})
test('desktop marquee loops seamlessly', async ({ page }) => {
const geometry = await measureMarqueeLoopGeometry(
page,
'[data-testid="social-proof-desktop"] .animate-marquee'
)
expectSeamlessForwardLoop(geometry)
})
})
type MarqueeGeometry = {
copyWidths: number[]
startPositions: number[]
endPositions: number[]
}
async function measureMarqueeLoopGeometry(
page: Page,
selector: string
): Promise<MarqueeGeometry> {
await page.locator(selector).first().waitFor()
return page.evaluate((sel) => {
const tracks = Array.from(
document.querySelectorAll<HTMLElement>(sel)
).slice(0, 2)
const firstAnimation = tracks[0]?.getAnimations()[0]
if (!firstAnimation) {
throw new Error(`No CSS animation found on ${sel}`)
}
const duration = firstAnimation.effect?.getTiming().duration
if (typeof duration !== 'number' || duration <= 1) {
throw new Error(
`Animation on ${sel} has unusable duration: ${String(duration)}`
)
}
const setAllTimes = (time: number) => {
for (const track of tracks) {
for (const anim of track.getAnimations()) {
anim.currentTime = time
}
}
void document.body.offsetWidth
}
const readX = () => tracks.map((track) => track.getBoundingClientRect().x)
setAllTimes(0)
const startPositions = readX()
const copyWidths = tracks.map(
(track) => track.getBoundingClientRect().width
)
setAllTimes(duration - 0.1)
const endPositions = readX()
return { copyWidths, startPositions, endPositions }
}, selector)
}
function expectTwoMatchingCopies(geometry: MarqueeGeometry) {
const { copyWidths } = geometry
expect(copyWidths.length, 'expected two duplicate marquee tracks').toBe(2)
expect(copyWidths[0]).toBeGreaterThan(0)
expect(copyWidths[1]).toBeCloseTo(copyWidths[0], 0)
}
function expectSeamlessForwardLoop(geometry: MarqueeGeometry) {
expectTwoMatchingCopies(geometry)
// Copy 2 ends the cycle exactly where copy 1 started, so the restart
// (when copy 1 jumps back to its start position) is visually indistinguishable.
expect(geometry.endPositions[1]).toBeCloseTo(geometry.startPositions[0], 0)
}
function expectSeamlessReverseLoop(geometry: MarqueeGeometry) {
expectTwoMatchingCopies(geometry)
// Reverse marquee: copy 1 ends the cycle where copy 2 started.
expect(geometry.endPositions[0]).toBeCloseTo(geometry.startPositions[1], 0)
}

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: 44 KiB

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 57 KiB

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 92 KiB

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 96 KiB

After

Width:  |  Height:  |  Size: 91 KiB

View File

@@ -15,11 +15,15 @@
"test:e2e:local": "cross-env PLAYWRIGHT_LOCAL=1 playwright test",
"test:visual": "playwright test --project visual",
"test:visual:update": "playwright test --project visual --update-snapshots",
"ashby:refresh-snapshot": "tsx ./scripts/refresh-ashby-snapshot.ts"
"ashby:refresh-snapshot": "tsx ./scripts/refresh-ashby-snapshot.ts",
"cloud-nodes:refresh-snapshot": "tsx ./scripts/refresh-cloud-nodes-snapshot.ts",
"generate:models": "tsx ./scripts/generate-models.ts"
},
"dependencies": {
"@astrojs/sitemap": "catalog:",
"@comfyorg/design-system": "workspace:*",
"@comfyorg/object-info-parser": "workspace:*",
"@comfyorg/shared-frontend-utils": "workspace:*",
"@comfyorg/tailwind-utils": "workspace:*",
"@vercel/analytics": "catalog:",
"@vueuse/core": "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: {

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 270 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 69 B

After

Width:  |  Height:  |  Size: 119 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 69 B

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 69 B

After

Width:  |  Height:  |  Size: 119 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 69 B

After

Width:  |  Height:  |  Size: 20 KiB

View File

@@ -0,0 +1,396 @@
import { existsSync, readdirSync, readFileSync, writeFileSync } from 'node:fs'
import { join } from 'node:path'
import { fileURLToPath } from 'node:url'
const WORKFLOW_TEMPLATES_BASE =
'https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates'
const TEMPLATES_DIR = fileURLToPath(
new URL('../../../../workflow_templates/templates', import.meta.url)
)
const QUANT_SUFFIXES = [
'_fp8_e4m3fn_scaled',
'_fp8_e4m3fn',
'_fp8_scaled',
'_fp4_mixed',
'_fp8mixed',
'_fp8',
'_fp16',
'_fp4',
'_bf16',
'_int8'
]
interface RawModel {
name: string
url: string
directory: string
}
interface ModelData {
url: string
directory: string
templates: Set<string>
firstTemplate?: string
}
interface OutputModel {
slug: string
name: string
huggingFaceUrl: string
directory: string
workflowCount: number
displayName: string
docsUrl?: string
thumbnailUrl?: string
canonicalSlug?: string
}
// Maps api_*.json filename prefix to a canonical display name and slug.
// Add entries here as new partner integrations land in workflow_templates.
const API_PROVIDER_MAP: Record<string, { name: string; slug: string }> = {
nano: { name: 'Nano Banana', slug: 'nano-banana' },
kling: { name: 'Kling AI', slug: 'kling-ai' },
kling2: { name: 'Kling AI', slug: 'kling-ai' },
meshy: { name: 'Meshy AI', slug: 'meshy-ai' },
luma: { name: 'Luma Dream Machine', slug: 'luma-dream-machine' },
runway: { name: 'Runway', slug: 'runway' },
vidu: { name: 'Vidu', slug: 'vidu' },
bfl: { name: 'Flux (API)', slug: 'flux-api' },
grok: { name: 'Grok Image', slug: 'grok-image' },
stability: { name: 'Stability AI', slug: 'stability-ai' },
bytedance: { name: 'Seedance (ByteDance)', slug: 'seedance-bytedance' },
bytedace: { name: 'Seedance (ByteDance)', slug: 'seedance-bytedance' },
google: { name: 'Gemini Image', slug: 'gemini-image' },
hailuo: { name: 'Hailuo MiniMax', slug: 'hailuo-minimax' },
ideogram: { name: 'Ideogram', slug: 'ideogram' },
pixverse: { name: 'Pixverse', slug: 'pixverse' },
rodin: { name: 'Rodin 3D', slug: 'rodin-3d' },
magnific: { name: 'Magnific AI', slug: 'magnific-ai' },
bria: { name: 'Bria AI', slug: 'bria-ai' },
tripo: { name: 'Tripo 3D', slug: 'tripo-3d' },
tripo3: { name: 'Tripo 3D', slug: 'tripo-3d' },
hunyuan3d: { name: 'Hunyuan 3D', slug: 'hunyuan-3d' },
recraft: { name: 'Recraft', slug: 'recraft' },
topaz: { name: 'Topaz Labs', slug: 'topaz-labs' },
moonvalley: { name: 'Moonvalley', slug: 'moonvalley' },
ltxv: { name: 'LTX Video (API)', slug: 'ltxv-api' },
openai: { name: 'OpenAI DALL-E', slug: 'openai-dall-e' },
wan: { name: 'Wan (API)', slug: 'wan-api' },
wan2: { name: 'Wan (API)', slug: 'wan-api' },
veo2: { name: 'Veo 2', slug: 'veo-2' },
veo3: { name: 'Veo 3', slug: 'veo-3' },
flux2: { name: 'Flux 2 (API)', slug: 'flux-2-api' },
wavespeed: { name: 'Wavespeed', slug: 'wavespeed' },
wavespped: { name: 'Wavespeed', slug: 'wavespeed' }
}
function stripExt(name: string): string {
return name.replace(/\.(safetensors|ckpt|pt|bin)$/, '')
}
function stripQuant(base: string): string {
for (const suffix of QUANT_SUFFIXES) {
if (base.endsWith(suffix)) return base.slice(0, -suffix.length)
}
return base
}
function makeSlug(name: string): string {
const base = stripExt(name)
return base
.toLowerCase()
.replace(/[_.]/g, '-')
.replace(/-+/g, '-')
.replace(/^-|-$/g, '')
}
function makeDisplayName(name: string): string {
const base = stripExt(name)
return base
.split(/[_-]/)
.map((part) => {
if (/^(fp\d+|bf\d+|int\d+)$/i.test(part)) return part.toUpperCase()
if (/^(e4m3fn|scaled|mixed|fp8mixed)$/i.test(part)) return part
if (/^\d+(\.\d+)?[bBkKmM]?$/.test(part)) return part
return part.charAt(0).toUpperCase() + part.slice(1)
})
.join(' ')
}
function extractModels(
obj: unknown,
templateName: string,
models: Map<string, ModelData>
): void {
if (obj === null || typeof obj !== 'object') return
if (Array.isArray(obj)) {
for (const item of obj) extractModels(item, templateName, models)
return
}
const record = obj as Record<string, unknown>
if (Array.isArray(record['models'])) {
for (const m of record['models'] as unknown[]) {
if (m === null || typeof m !== 'object' || Array.isArray(m)) continue
const model = m as Record<string, unknown>
if (typeof model['name'] !== 'string') continue
const name = model['name']
const url = typeof model['url'] === 'string' ? model['url'] : ''
const directory =
typeof model['directory'] === 'string' ? model['directory'] : ''
if (!models.has(name)) {
models.set(name, {
url,
directory,
templates: new Set(),
firstTemplate: templateName
})
}
models.get(name)!.templates.add(templateName)
}
}
for (const value of Object.values(record)) {
extractModels(value, templateName, models)
}
}
interface ApiModelData {
slug: string
name: string
directory: 'partner_nodes'
templateCount: number
}
function extractApiModels(files: string[]): ApiModelData[] {
const counts = new Map<string, number>()
for (const file of files) {
if (!file.startsWith('api_')) continue
const prefix = file.slice(4).split('_')[0]
const entry = API_PROVIDER_MAP[prefix]
if (!entry) continue
counts.set(entry.slug, (counts.get(entry.slug) ?? 0) + 1)
}
return [...counts.entries()].map(([slug, count]) => {
const found = Object.values(API_PROVIDER_MAP).find((e) => e.slug === slug)!
return {
slug,
name: found.name,
directory: 'partner_nodes' as const,
templateCount: count
}
})
}
// Reads all locale index.json files to build a map of
// raw model filename → tutorialUrl. Index entries name the template file;
// that file's embedded model objects give the actual filenames.
function buildTutorialUrlMap(templatesDir: string): Map<string, string> {
const map = new Map<string, string>()
const indexFiles = readdirSync(templatesDir).filter(
(f) =>
f.startsWith('index') &&
f.endsWith('.json') &&
!f.includes('schema') &&
!f.includes('logo')
)
// Collect template-name → tutorialUrl from all locale indexes (first wins)
const templateTutorialMap = new Map<string, string>()
const sorted = ['index.json', ...indexFiles.filter((f) => f !== 'index.json')]
for (const file of sorted) {
let data: unknown
try {
data = JSON.parse(readFileSync(join(templatesDir, file), 'utf8'))
} catch {
continue
}
if (!Array.isArray(data)) continue
for (const cat of data as unknown[]) {
if (typeof cat !== 'object' || cat === null) continue
const templates = (cat as Record<string, unknown>)['templates']
if (!Array.isArray(templates)) continue
for (const t of templates) {
if (typeof t !== 'object' || t === null) continue
const entry = t as Record<string, unknown>
const tutorialUrl =
typeof entry['tutorialUrl'] === 'string'
? entry['tutorialUrl']
: undefined
const templateName =
typeof entry['name'] === 'string' ? entry['name'] : undefined
if (
tutorialUrl &&
templateName &&
!templateTutorialMap.has(templateName)
) {
templateTutorialMap.set(templateName, tutorialUrl)
}
}
}
}
// For each template with a tutorialUrl, open the template file and map
// every embedded model filename to that tutorialUrl
for (const [templateName, tutorialUrl] of templateTutorialMap) {
const filePath = join(templatesDir, `${templateName}.json`)
let data: unknown
try {
data = JSON.parse(readFileSync(filePath, 'utf8'))
} catch {
continue
}
function extractModelNames(obj: unknown): void {
if (obj === null || typeof obj !== 'object') return
if (Array.isArray(obj)) {
for (const item of obj) extractModelNames(item)
return
}
const record = obj as Record<string, unknown>
if (Array.isArray(record['models'])) {
for (const m of record['models'] as unknown[]) {
if (m === null || typeof m !== 'object' || Array.isArray(m)) continue
const model = m as Record<string, unknown>
if (typeof model['name'] === 'string' && !map.has(model['name'])) {
map.set(model['name'], tutorialUrl)
}
}
}
for (const value of Object.values(record)) {
extractModelNames(value)
}
}
extractModelNames(data)
}
return map
}
function templateThumbnailUrl(
firstTemplate: string | undefined,
templatesDir: string
): string | undefined {
if (!firstTemplate) return undefined
const base = firstTemplate.replace(/\.json$/, '')
const localPath = join(templatesDir, `${base}-1.webp`)
if (!existsSync(localPath)) return undefined
return `${WORKFLOW_TEMPLATES_BASE}/${encodeURIComponent(base)}-1.webp`
}
function run(): void {
const models = new Map<string, ModelData>()
const files = readdirSync(TEMPLATES_DIR).filter((f) => f.endsWith('.json'))
for (const file of files) {
const filePath = join(TEMPLATES_DIR, file)
try {
const raw = readFileSync(filePath, 'utf8')
const data: unknown = JSON.parse(raw)
extractModels(data, file, models)
} catch (error) {
throw new Error(
`Failed to parse ${file}: ${
error instanceof Error ? error.message : String(error)
}`
)
}
}
const apiModels = extractApiModels(files)
const tutorialUrlMap = buildTutorialUrlMap(TEMPLATES_DIR)
const sorted = [...models.entries()].sort(
([, a], [, b]) => b.templates.size - a.templates.size
)
// Build quant convergence map
const groups = new Map<string, Array<[string, ModelData]>>()
for (const [name, data] of sorted) {
const base = stripExt(name)
const canonicalBase = stripQuant(base)
if (!groups.has(canonicalBase)) groups.set(canonicalBase, [])
groups.get(canonicalBase)!.push([name, data])
}
const canonicalMap = new Map<string, string | null>()
for (const members of groups.values()) {
if (members.length > 1) {
const membersSorted = [...members].sort(
([, a], [, b]) => b.templates.size - a.templates.size
)
const canonicalName = membersSorted[0][0]
canonicalMap.set(canonicalName, null)
for (const [name] of membersSorted.slice(1)) {
canonicalMap.set(name, canonicalName)
}
} else {
canonicalMap.set(members[0][0], null)
}
}
const output: OutputModel[] = sorted.map(([name, data]) => {
const canonicalRaw = canonicalMap.get(name) ?? null
const result: OutputModel = {
slug: makeSlug(name),
name,
huggingFaceUrl: data.url,
directory: data.directory,
workflowCount: data.templates.size,
displayName: makeDisplayName(name)
}
const docsUrl = tutorialUrlMap.get(name)
if (docsUrl) result.docsUrl = docsUrl
const thumb = templateThumbnailUrl(data.firstTemplate, TEMPLATES_DIR)
if (thumb) result.thumbnailUrl = thumb
if (canonicalRaw !== null) {
result.canonicalSlug = makeSlug(canonicalRaw)
}
return result
})
const apiOutput: OutputModel[] = apiModels
.sort((a, b) => b.templateCount - a.templateCount)
.map((m) => ({
slug: m.slug,
name: m.name,
huggingFaceUrl: '',
directory: m.directory,
workflowCount: m.templateCount,
displayName: m.name
}))
const combined = [...apiOutput, ...output]
const withThumbs = combined.filter((m) => m.thumbnailUrl).length
process.stdout.write(
` ${withThumbs}/${combined.length} models have thumbnails\n`
)
const defaultOut = join(
fileURLToPath(new URL('.', import.meta.url)),
'../src/config/generated-models.json'
)
const outputArg = process.argv[2] ?? defaultOut
const json = JSON.stringify(combined, null, 2) + '\n'
writeFileSync(outputArg, json, 'utf8')
process.stdout.write(
`Written ${combined.length} models ` +
`(${apiOutput.length} partner, ${output.length} local) to ${outputArg}\n`
)
}
try {
run()
} catch (err) {
process.stderr.write(`${err instanceof Error ? err.message : String(err)}\n`)
process.exit(1)
}

View File

@@ -0,0 +1,32 @@
import { renameSync, writeFileSync } from 'node:fs'
import { fileURLToPath } from 'node:url'
import { fetchCloudNodesForBuild } from '../src/utils/cloudNodes'
const snapshotPath = fileURLToPath(
new URL('../src/data/cloud-nodes.snapshot.json', import.meta.url)
)
const tempPath = `${snapshotPath}.tmp`
const outcome = await fetchCloudNodesForBuild()
if (outcome.status !== 'fresh') {
const reason = 'reason' in outcome ? outcome.reason : '(none)'
console.error(
`Snapshot refresh aborted. Outcome: ${outcome.status}; reason: ${reason}`
)
process.exit(1)
}
const serialized = JSON.stringify(outcome.snapshot, null, 2) + '\n'
writeFileSync(tempPath, serialized, 'utf8')
renameSync(tempPath, snapshotPath)
const totalNodes = outcome.snapshot.packs.reduce(
(n, pack) => n + pack.nodes.length,
0
)
process.stdout.write(
`Wrote snapshot with ${outcome.snapshot.packs.length} pack(s) and ${totalNodes} node(s) to ${snapshotPath}\n`
)

View File

@@ -1,10 +1,13 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useEventListener, useTemplateRefsList } from '@vueuse/core'
import { computed, onMounted, ref } from 'vue'
import type { Department } from '../../data/roles'
import type { Locale } from '../../i18n/translations'
import { prefersReducedMotion } from '../../composables/useReducedMotion'
import { t } from '../../i18n/translations'
import { scrollTo } from '../../scripts/smoothScroll'
import CategoryNav from '../common/CategoryNav.vue'
import SectionLabel from '../common/SectionLabel.vue'
@@ -13,24 +16,72 @@ const { locale = 'en', departments = [] } = defineProps<{
departments?: readonly Department[]
}>()
const activeCategory = ref('all')
const visibleDepartments = computed(() =>
departments.filter((d) => d.roles.length > 0)
)
const categories = computed(() => [
{ label: 'ALL', value: 'all' },
...visibleDepartments.value.map((d) => ({ label: d.name, value: d.key }))
])
const filteredDepartments = computed(() =>
activeCategory.value === 'all'
? visibleDepartments.value
: visibleDepartments.value.filter((d) => d.key === activeCategory.value)
const categories = computed(() =>
visibleDepartments.value.map((d) => ({ label: d.name, value: d.key }))
)
const hasRoles = computed(() => visibleDepartments.value.length > 0)
const activeCategory = ref('')
const sectionRefs = useTemplateRefsList<HTMLElement>()
let isScrolling = false
let pendingFrame = 0
const HEADER_OFFSET = -144
const ACTIVATION_OFFSET = 300
const deptElementId = (key: string) => `careers-dept-${key}`
function pickActiveSection() {
pendingFrame = 0
if (isScrolling) return
const sections = sectionRefs.value as HTMLElement[]
if (sections.length === 0) return
let active = sections[0]
for (const el of sections) {
if (el.getBoundingClientRect().top - ACTIVATION_OFFSET <= 0) {
active = el
} else {
break
}
}
activeCategory.value = active.id.replace(/^careers-dept-/, '')
}
function scheduleUpdate() {
if (pendingFrame !== 0) return
pendingFrame = requestAnimationFrame(pickActiveSection)
}
onMounted(pickActiveSection)
useEventListener('scroll', scheduleUpdate, { passive: true })
useEventListener('resize', scheduleUpdate, { passive: true })
function scrollToDepartment(deptKey: string) {
activeCategory.value = deptKey
isScrolling = true
const el = document.getElementById(deptElementId(deptKey))
if (!el) {
isScrolling = false
return
}
scrollTo(el, {
offset: HEADER_OFFSET,
duration: 0.8,
immediate: prefersReducedMotion(),
onComplete: () => {
isScrolling = false
pickActiveSection()
}
})
}
</script>
<template>
@@ -48,9 +99,10 @@ const hasRoles = computed(() => visibleDepartments.value.length > 0)
</h2>
<CategoryNav
v-if="hasRoles"
v-model="activeCategory"
:categories="categories"
:model-value="activeCategory"
class="mt-4"
@update:model-value="scrollToDepartment"
/>
</div>
</div>
@@ -65,9 +117,11 @@ const hasRoles = computed(() => visibleDepartments.value.length > 0)
</p>
<div
v-for="dept in filteredDepartments"
v-for="dept in visibleDepartments"
:id="deptElementId(dept.key)"
:ref="sectionRefs.set"
:key="dept.key"
class="mb-12 last:mb-0"
class="mb-12 scroll-mt-24 last:mb-0 md:scroll-mt-36"
>
<SectionLabel>
{{ dept.name }}
@@ -76,7 +130,7 @@ const hasRoles = computed(() => visibleDepartments.value.length > 0)
<a
v-for="role in dept.roles"
:key="role.id"
:href="role.applyUrl"
:href="role.jobUrl"
target="_blank"
rel="noopener noreferrer"
class="border-primary-warm-gray/20 group flex items-center justify-between border-b py-5"

View File

@@ -0,0 +1,30 @@
<script setup lang="ts">
import type { Locale } from '../../i18n/translations'
import { t } from '../../i18n/translations'
import SectionLabel from '../common/SectionLabel.vue'
const { locale = 'en' } = defineProps<{
locale?: Locale
}>()
</script>
<template>
<section class="px-6 pt-24 pb-14 md:px-20 md:pt-32 md:pb-20">
<div class="mx-auto flex w-full max-w-6xl flex-col gap-7">
<SectionLabel>
{{ t('cloudNodes.hero.label', locale) }}
</SectionLabel>
<h1
class="text-primary-comfy-canvas max-w-4xl text-4xl/tight md:text-6xl"
>
{{ t('cloudNodes.hero.heading', locale) }}
</h1>
<p class="text-primary-warm-gray max-w-3xl text-base/relaxed md:text-lg">
{{ t('cloudNodes.hero.body', locale) }}
</p>
</div>
</section>
</template>

View File

@@ -0,0 +1,49 @@
<script setup lang="ts">
import type { PackNode } from '../../data/cloudNodes'
import type { Locale } from '../../i18n/translations'
import { useNodesByCategory } from '../../composables/useNodesByCategory'
import { t } from '../../i18n/translations'
const { locale = 'en', nodes } = defineProps<{
locale?: Locale
nodes: readonly PackNode[]
}>()
const { groupedNodes } = useNodesByCategory(() => nodes)
</script>
<template>
<details
class="group border-primary-warm-gray/20 rounded-2xl border px-4 py-3"
>
<summary
class="text-primary-comfy-canvas cursor-pointer list-none text-sm font-semibold"
>
{{ t('cloudNodes.card.nodesHeading', locale) }}
</summary>
<div class="mt-4 flex flex-col gap-5">
<div
v-for="group in groupedNodes"
:key="group.category"
class="flex flex-col gap-2"
>
<h4
class="text-primary-warm-gray text-xs font-semibold tracking-widest uppercase"
>
{{ group.category }}
</h4>
<ul class="flex flex-col gap-1">
<li
v-for="node in group.nodes"
:key="node.name"
class="text-primary-comfy-canvas text-sm/relaxed"
>
{{ node.displayName }}
</li>
</ul>
</div>
</div>
</details>
</template>

View File

@@ -0,0 +1,52 @@
<script setup lang="ts">
import { useBannerImage } from '../../composables/useBannerImage'
const { bannerUrl, iconUrl, name } = defineProps<{
bannerUrl?: string
iconUrl?: string
name: string
}>()
const {
DEFAULT_BANNER,
isImageError,
showDefaultBanner,
imgSrc,
onImageError
} = useBannerImage({
bannerUrl: () => bannerUrl,
iconUrl: () => iconUrl
})
</script>
<template>
<div
class="z-0 aspect-7/3 w-full overflow-hidden"
data-testid="cloud-node-pack-banner"
>
<div v-if="showDefaultBanner" class="size-full">
<img
:src="DEFAULT_BANNER"
:alt="`${name} banner`"
class="size-full object-cover"
/>
</div>
<div v-else class="relative size-full">
<div
v-if="imgSrc && !isImageError"
class="absolute inset-0 bg-cover bg-center bg-no-repeat opacity-30"
:style="{ backgroundImage: `url(${imgSrc})`, filter: 'blur(10px)' }"
/>
<img
:src="isImageError ? DEFAULT_BANNER : imgSrc"
:alt="`${name} banner`"
:class="
isImageError
? 'relative z-10 size-full object-cover'
: 'relative z-10 size-full object-contain'
"
@error="onImageError"
/>
</div>
</div>
</template>

View File

@@ -0,0 +1,80 @@
<script setup lang="ts">
import type { Pack } from '../../data/cloudNodes'
import type { Locale } from '../../i18n/translations'
import { t } from '../../i18n/translations'
import NodeList from './NodeList.vue'
import PackBanner from './PackBanner.vue'
const { locale = 'en', pack } = defineProps<{
locale?: Locale
pack: Pack
}>()
const detailHref =
locale === 'zh-CN'
? `/zh-CN/cloud/supported-nodes/${pack.id}`
: `/cloud/supported-nodes/${pack.id}`
function nodeCountLabel(nodeCount: number): string {
const key =
new Intl.PluralRules(locale).select(nodeCount) === 'one'
? 'cloudNodes.card.nodeCountOne'
: 'cloudNodes.card.nodeCountOther'
return t(key, locale).replace('{count}', String(nodeCount))
}
</script>
<template>
<article
class="bg-transparency-white-t5 border-primary-warm-gray/20 flex h-full flex-col overflow-hidden rounded-3xl border"
data-testid="cloud-node-pack-card"
>
<PackBanner
:banner-url="pack.bannerUrl"
:icon-url="pack.iconUrl"
:name="pack.displayName"
/>
<div class="flex flex-1 flex-col gap-5 p-5 md:p-6">
<div class="flex flex-col gap-2">
<h3 class="text-primary-comfy-canvas text-2xl/tight font-semibold">
<a
:href="detailHref"
class="hover:text-primary-comfy-yellow"
data-testid="cloud-node-pack-card-link"
>
{{ pack.displayName }}
</a>
</h3>
<p class="text-primary-warm-gray text-sm/relaxed">
{{
pack.description ||
t('cloudNodes.card.unavailableDescription', locale)
}}
</p>
</div>
<div class="flex flex-wrap items-center gap-3 text-sm">
<a
v-if="pack.repoUrl"
:href="pack.repoUrl"
target="_blank"
rel="noopener noreferrer"
class="text-primary-comfy-yellow hover:text-primary-comfy-yellow/85 font-semibold underline"
>
{{ t('cloudNodes.card.viewRepo', locale) }}
</a>
<span v-else class="text-primary-warm-gray">
{{ t('cloudNodes.card.viewRepo', locale) }}
</span>
<span class="text-primary-warm-gray"></span>
<span class="text-primary-comfy-canvas">{{
nodeCountLabel(pack.nodes.length)
}}</span>
</div>
<NodeList :locale="locale" :nodes="pack.nodes" class="mt-auto" />
</div>
</article>
</template>

View File

@@ -0,0 +1,203 @@
<script setup lang="ts">
import { cn } from '@comfyorg/tailwind-utils'
import type { Pack } from '../../data/cloudNodes'
import type { Locale } from '../../i18n/translations'
import {
formatLocalizedMediumDate,
formatLocalizedNumber
} from '@comfyorg/shared-frontend-utils/formatUtil'
import { useNodesByCategory } from '../../composables/useNodesByCategory'
import { t } from '../../i18n/translations'
import PackBanner from './PackBanner.vue'
const { pack, locale = 'en' } = defineProps<{
pack: Pack
locale?: Locale
}>()
const backHref =
locale === 'zh-CN' ? '/zh-CN/cloud/supported-nodes' : '/cloud/supported-nodes'
const { groupedNodes } = useNodesByCategory(() => pack.nodes)
</script>
<template>
<article
class="px-6 pb-20 md:px-20 md:pb-28"
data-testid="cloud-node-pack-detail"
>
<div class="mx-auto flex w-full max-w-6xl flex-col gap-8">
<a
:href="backHref"
class="text-primary-comfy-yellow hover:text-primary-comfy-yellow/85 w-fit text-sm font-semibold underline"
>
{{ t('cloudNodes.detail.back', locale) }}
</a>
<div
class="bg-transparency-white-t5 border-primary-warm-gray/20 overflow-hidden rounded-3xl border"
>
<PackBanner
:banner-url="pack.bannerUrl"
:icon-url="pack.iconUrl"
:name="pack.displayName"
/>
<div class="flex flex-col gap-7 p-5 md:p-6">
<header class="flex flex-col gap-2">
<h1
class="text-primary-comfy-canvas text-3xl/tight font-semibold md:text-4xl"
>
{{ pack.displayName }}
</h1>
<p
class="text-primary-warm-gray text-sm/relaxed md:text-base/relaxed"
>
{{
pack.description ||
t('cloudNodes.card.unavailableDescription', locale)
}}
</p>
</header>
<dl class="grid grid-cols-1 gap-4 text-sm md:grid-cols-2">
<div class="flex flex-col gap-1">
<dt class="text-primary-warm-gray">
{{ t('cloudNodes.card.viewRepo', locale) }}
</dt>
<dd>
<a
v-if="pack.repoUrl"
:href="pack.repoUrl"
target="_blank"
rel="noopener noreferrer"
class="text-primary-comfy-yellow hover:text-primary-comfy-yellow/85 font-semibold underline"
>
{{ pack.repoUrl }}
</a>
<span v-else class="text-primary-comfy-canvas"></span>
</dd>
</div>
<div class="flex flex-col gap-1">
<dt class="text-primary-warm-gray">
{{ t('cloudNodes.detail.publisher', locale) }}
</dt>
<dd class="text-primary-comfy-canvas">
{{ pack.publisher?.name || pack.publisher?.id || '—' }}
</dd>
</div>
<div class="flex flex-col gap-1">
<dt class="text-primary-warm-gray">
{{ t('cloudNodes.detail.downloads', locale) }}
</dt>
<dd class="text-primary-comfy-canvas">
{{ formatLocalizedNumber(pack.downloads, locale) }}
</dd>
</div>
<div class="flex flex-col gap-1">
<dt class="text-primary-warm-gray">
{{ t('cloudNodes.detail.stars', locale) }}
</dt>
<dd class="text-primary-comfy-canvas">
{{ formatLocalizedNumber(pack.githubStars, locale) }}
</dd>
</div>
<div class="flex flex-col gap-1">
<dt class="text-primary-warm-gray">
{{ t('cloudNodes.detail.latestVersion', locale) }}
</dt>
<dd class="text-primary-comfy-canvas">
{{ pack.latestVersion || '—' }}
</dd>
</div>
<div class="flex flex-col gap-1">
<dt class="text-primary-warm-gray">
{{ t('cloudNodes.detail.license', locale) }}
</dt>
<dd class="text-primary-comfy-canvas">
{{ pack.license || '—' }}
</dd>
</div>
<div class="flex flex-col gap-1">
<dt class="text-primary-warm-gray">
{{ t('cloudNodes.detail.lastUpdated', locale) }}
</dt>
<dd class="text-primary-comfy-canvas">
{{ formatLocalizedMediumDate(pack.lastUpdated, locale) }}
</dd>
</div>
</dl>
<section class="flex flex-col gap-4">
<h2
class="text-primary-comfy-canvas text-xl font-semibold md:text-2xl"
>
{{ t('cloudNodes.detail.nodesHeading', locale) }}
</h2>
<section
v-for="group in groupedNodes"
:key="group.category"
class="border-primary-warm-gray/20 rounded-2xl border p-4"
>
<h3 class="text-primary-comfy-canvas text-base font-semibold">
{{ group.category }}
</h3>
<ul class="mt-3 flex flex-col gap-3">
<li
v-for="node in group.nodes"
:key="node.name"
class="border-primary-warm-gray/20 rounded-xl border p-3"
data-testid="cloud-node-pack-detail-node"
>
<div class="flex flex-wrap items-center gap-2">
<span
class="text-primary-comfy-canvas text-sm font-semibold"
>
{{ node.displayName }}
</span>
<span
v-if="node.experimental"
:class="
cn(
'bg-primary-comfy-yellow/20 text-primary-comfy-canvas rounded-full px-2 py-0.5 text-xs font-semibold'
)
"
>
{{ t('cloudNodes.detail.experimental', locale) }}
</span>
<span
v-if="node.deprecated"
:class="
cn(
'text-primary-comfy-canvas rounded-full bg-red-500/15 px-2 py-0.5 text-xs font-semibold'
)
"
>
{{ t('cloudNodes.detail.deprecated', locale) }}
</span>
</div>
<p
v-if="node.description"
class="text-primary-warm-gray mt-2 text-sm/relaxed"
>
{{ node.description }}
</p>
</li>
</ul>
</section>
</section>
</div>
</div>
</div>
</article>
</template>

View File

@@ -0,0 +1,113 @@
<script setup lang="ts">
import { ref } from 'vue'
import { cn } from '@comfyorg/tailwind-utils'
import type { Pack } from '../../data/cloudNodes'
import type { Locale } from '../../i18n/translations'
import { useFilteredPacks } from '../../composables/useFilteredPacks'
import type { PackSortMode } from '../../composables/useFilteredPacks'
import { t } from '../../i18n/translations'
import SectionLabel from '../common/SectionLabel.vue'
import PackCard from './PackCard.vue'
const { locale = 'en', packs } = defineProps<{
locale?: Locale
packs: readonly Pack[]
}>()
const query = defineModel<string>('query', { default: '' })
const sortMode = ref<PackSortMode>('downloads')
const { filteredPacks } = useFilteredPacks({
packs: () => packs,
query,
sortMode
})
</script>
<template>
<section class="px-6 pb-20 md:px-20 md:pb-32">
<div class="mx-auto flex w-full max-w-6xl flex-col gap-8">
<div class="flex flex-col gap-3">
<SectionLabel>
{{ t('cloudNodes.hero.label', locale) }}
</SectionLabel>
<h2
class="text-primary-comfy-canvas text-3xl/tight font-medium md:text-4xl"
>
{{ t('cloudNodes.section.heading', locale) }}
</h2>
</div>
<div
class="flex flex-col gap-4 md:flex-row md:items-center md:justify-between"
>
<label for="cloud-nodes-search" class="sr-only">
{{ t('cloudNodes.search.label', locale) }}
</label>
<input
id="cloud-nodes-search"
v-model="query"
:placeholder="t('cloudNodes.search.placeholder', locale)"
:class="
cn(
'bg-transparency-white-t5 border-primary-warm-gray/30 text-primary-comfy-canvas placeholder:text-primary-warm-gray/80 w-full rounded-2xl border px-4 py-3 text-sm md:max-w-md'
)
"
data-testid="cloud-nodes-search"
/>
<label for="cloud-nodes-sort" class="sr-only">
{{ t('cloudNodes.sort.label', locale) }}
</label>
<select
id="cloud-nodes-sort"
v-model="sortMode"
class="bg-transparency-white-t5 border-primary-warm-gray/30 text-primary-comfy-canvas w-full appearance-none rounded-2xl border bg-size-[0.65rem_0.65rem] bg-position-[right_1rem_center] bg-no-repeat py-3 pr-12 pl-4 text-sm md:w-64"
:style="{
backgroundImage:
'url(\'data:image/svg+xml;utf8,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 12 12%22 fill=%22%23a39b8d%22><path d=%22M6 9.2L1.4 4.6 2.8 3.2 6 6.4l3.2-3.2 1.4 1.4z%22/></svg>\')'
}"
>
<option value="downloads">
{{ t('cloudNodes.sort.downloads', locale) }}
</option>
<option value="mostNodes">
{{ t('cloudNodes.sort.mostNodes', locale) }}
</option>
<option value="az">{{ t('cloudNodes.sort.az', locale) }}</option>
<option value="recentlyUpdated">
{{ t('cloudNodes.sort.recentlyUpdated', locale) }}
</option>
</select>
</div>
<p
v-if="filteredPacks.length === 0"
class="text-primary-warm-gray rounded-2xl border border-dashed border-current/30 px-5 py-6 text-sm"
>
<span class="text-primary-comfy-canvas block text-base font-semibold">
{{ t('cloudNodes.empty.heading', locale) }}
</span>
<span class="mt-2 block">{{ t('cloudNodes.empty.body', locale) }}</span>
</p>
<div
v-else
class="grid grid-cols-1 gap-5 md:grid-cols-2"
role="list"
:aria-label="t('cloudNodes.list.ariaLabel', locale)"
>
<PackCard
v-for="pack in filteredPacks"
:key="pack.id"
:pack="pack"
:locale="locale"
role="listitem"
/>
</div>
</div>
</section>
</template>

View File

@@ -1,7 +1,11 @@
<script setup lang="ts">
import { cn } from '@comfyorg/tailwind-utils'
import { useIntersectionObserver, useTemplateRefsList } from '@vueuse/core'
import { computed, ref } from 'vue'
import {
useEventListener,
useIntersectionObserver,
useTemplateRefsList
} from '@vueuse/core'
import { computed, onMounted, ref } from 'vue'
import type { Locale, TranslationKey } from '../../i18n/translations'
@@ -40,13 +44,25 @@ const activeSection = ref(sections[0]?.id ?? '')
const sectionRefs = useTemplateRefsList<HTMLElement>()
let isScrolling = false
let scrollSafetyTimer: ReturnType<typeof setTimeout> | undefined
const HEADER_OFFSET = -144
const BOTTOM_THRESHOLD_PX = 4
const SCROLL_SAFETY_MS = 1500
function clearScrollLock() {
isScrolling = false
if (scrollSafetyTimer !== undefined) {
clearTimeout(scrollSafetyTimer)
scrollSafetyTimer = undefined
}
}
useIntersectionObserver(
sectionRefs,
(entries) => {
if (isScrolling) return
if (isAtBottom()) return
let best: IntersectionObserverEntry | null = null
for (const entry of entries) {
if (!entry.isIntersecting) continue
@@ -58,22 +74,39 @@ useIntersectionObserver(
{ rootMargin: '-20% 0px -60% 0px' }
)
function isAtBottom(): boolean {
const scrollBottom = window.scrollY + window.innerHeight
return (
scrollBottom >= document.documentElement.scrollHeight - BOTTOM_THRESHOLD_PX
)
}
function activateLastIfAtBottom() {
if (isScrolling) return
if (!isAtBottom()) return
const lastId = sections[sections.length - 1]?.id
if (lastId) activeSection.value = lastId
}
onMounted(activateLastIfAtBottom)
useEventListener('scroll', activateLastIfAtBottom, { passive: true })
function scrollToSection(id: string) {
activeSection.value = id
clearScrollLock()
isScrolling = true
scrollSafetyTimer = setTimeout(clearScrollLock, SCROLL_SAFETY_MS)
const el = document.getElementById(id)
if (el) {
scrollTo(el, {
offset: HEADER_OFFSET,
duration: 0.8,
immediate: prefersReducedMotion(),
onComplete: () => {
isScrolling = false
}
onComplete: clearScrollLock
})
return
}
isScrolling = false
clearScrollLock()
}
</script>

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

@@ -26,7 +26,7 @@ const {
<img
src="/icons/node-left.svg"
alt=""
class="-mx-px self-stretch"
class="-mx-px h-full w-auto self-stretch"
aria-hidden="true"
/>
@@ -38,7 +38,7 @@ const {
v-if="i > 0"
src="/icons/node-union.svg"
alt=""
class="-mx-px self-stretch"
class="-mx-px h-full w-auto self-stretch"
aria-hidden="true"
/>
<span
@@ -72,7 +72,7 @@ const {
<img
src="/icons/node-right.svg"
alt=""
class="-mx-px self-stretch"
class="-mx-px h-full w-auto self-stretch"
aria-hidden="true"
/>
</div>

View File

@@ -82,7 +82,7 @@ const companyColumn: { title: string; links: FooterLink[] } = {
]
}
const contactColumn = {
const contactColumn: { title: string; links: FooterLink[] } = {
title: t('footer.contact', locale),
links: [
{ label: t('footer.sales', locale), href: routes.contact },
@@ -91,6 +91,11 @@ const contactColumn = {
href: externalLinks.support,
external: true
},
{
label: t('footer.cloudStatus', locale),
href: externalLinks.cloudStatus,
external: true
},
{ label: t('footer.press', locale), href: 'mailto:press@comfy.org' }
]
}

View File

@@ -14,23 +14,28 @@ const logos = [
'Ubisoft'
]
const desktopLogos = Array.from({ length: 4 }, () => logos).flat()
const row1 = logos.slice(0, 6)
const mobileRow1 = [...row1, ...row1]
const row2 = logos.slice(6)
const mobileRow2 = [...row2, ...row2]
const mobileRow1Logos = logos.slice(0, 6)
const mobileRow2Logos = logos.slice(6)
</script>
<template>
<section class="overflow-hidden py-12">
<!-- Single row on desktop -->
<div class="animate-marquee hidden items-center gap-2 md:flex">
<div data-testid="social-proof-desktop" class="hidden w-max gap-2 md:flex">
<div
v-for="(logo, i) in desktopLogos"
:key="`${logo}-${i}`"
class="flex h-20 w-50 shrink-0 items-center justify-center"
v-for="copy in 2"
:key="copy"
class="animate-marquee flex shrink-0 items-center gap-2"
style="--marquee-gap: 0.5rem"
:aria-hidden="copy === 2 ? 'true' : undefined"
>
<img :src="`/icons/clients/${logo}.svg`" :alt="logo" />
<div
v-for="logo in logos"
:key="logo"
class="flex h-20 w-50 shrink-0 items-center justify-center"
>
<img :src="`/icons/clients/${logo}.svg`" :alt="logo" />
</div>
</div>
</div>
@@ -39,22 +44,38 @@ const mobileRow2 = [...row2, ...row2]
data-testid="social-proof-mobile"
class="flex flex-col gap-8 md:hidden"
>
<div class="animate-marquee flex items-center gap-8">
<div class="flex w-max gap-8">
<div
v-for="(logo, i) in mobileRow1"
:key="`${logo}-${i}`"
class="flex h-14 w-40 shrink-0 items-center justify-center"
v-for="copy in 2"
:key="copy"
class="animate-marquee flex shrink-0 items-center gap-8"
style="--marquee-gap: 2rem"
:aria-hidden="copy === 2 ? 'true' : undefined"
>
<img :src="`/icons/clients/${logo}.svg`" :alt="logo" />
<div
v-for="logo in mobileRow1Logos"
:key="logo"
class="flex h-14 w-40 shrink-0 items-center justify-center"
>
<img :src="`/icons/clients/${logo}.svg`" :alt="logo" />
</div>
</div>
</div>
<div class="animate-marquee-reverse flex items-center gap-8">
<div class="flex w-max gap-8">
<div
v-for="(logo, i) in mobileRow2"
:key="`${logo}-${i}`"
class="flex h-14 w-40 shrink-0 items-center justify-center"
v-for="copy in 2"
:key="copy"
class="animate-marquee-reverse flex shrink-0 items-center gap-8"
style="--marquee-gap: 2rem"
:aria-hidden="copy === 2 ? 'true' : undefined"
>
<img :src="`/icons/clients/${logo}.svg`" :alt="logo" />
<div
v-for="logo in mobileRow2Logos"
:key="logo"
class="flex h-14 w-40 shrink-0 items-center justify-center"
>
<img :src="`/icons/clients/${logo}.svg`" :alt="logo" />
</div>
</div>
</div>
</div>

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

@@ -8,10 +8,12 @@ import { t } from '../../i18n/translations'
const {
arcadeId,
title,
aspectRatio = 16 / 9,
locale = 'en'
} = defineProps<{
arcadeId: string
title: string
aspectRatio?: number
locale?: Locale
}>()
@@ -24,7 +26,8 @@ const loaded = ref(false)
:aria-label="t('demos.embed.label', locale)"
>
<div
class="relative mx-auto aspect-video max-w-6xl overflow-hidden rounded-4xl border border-white/10"
class="relative mx-auto max-w-6xl overflow-hidden rounded-4xl border border-white/10"
:style="{ aspectRatio }"
>
<div
v-if="!loaded"

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

@@ -0,0 +1,137 @@
<script setup lang="ts">
import { cn } from '@comfyorg/tailwind-utils'
import BrandButton from '../common/BrandButton.vue'
import { t } from '../../i18n/translations'
const {
displayName,
huggingFaceUrl,
docsUrl,
blogUrl,
hubSlug,
workflowCount,
directory
} = defineProps<{
displayName: string
huggingFaceUrl: string
docsUrl?: string
blogUrl?: string
hubSlug?: string
workflowCount: number
directory: string
}>()
const workflowsUrl = hubSlug
? `https://www.comfy.org/workflows/model/${hubSlug}`
: null
const dirDisplayMap: Record<string, string> = {
diffusion_models: 'Diffusion Model',
checkpoints: 'Checkpoint',
loras: 'LoRA',
controlnet: 'ControlNet',
clip_vision: 'CLIP Vision',
model_patches: 'Model Patch',
vae: 'VAE',
text_encoders: 'Text Encoder',
audio_encoders: 'Audio Encoder',
latent_upscale_models: 'Latent Upscale Model',
upscale_models: 'Upscale Model',
style_models: 'Style Model',
partner_nodes: 'Partner Node'
}
const eyebrow = dirDisplayMap[directory] ?? directory
const isPartnerNode = directory === 'partner_nodes'
</script>
<template>
<section
:class="
cn(
'mx-auto flex max-w-7xl flex-col gap-8 px-6 py-16',
'lg:flex-row lg:items-center lg:gap-16 lg:px-8 lg:py-24'
)
"
>
<div class="flex max-w-2xl flex-1 flex-col gap-6">
<p
class="text-sm font-medium uppercase tracking-widest text-primary-comfy-yellow"
>
{{ eyebrow }}
</p>
<h1 class="text-4xl font-bold text-primary-comfy-canvas lg:text-6xl">
{{ displayName }} in ComfyUI
</h1>
<p class="text-sm text-primary-comfy-canvas/60">
{{
t('models.hero.workflowCount').replace(
'{count}',
String(workflowCount)
)
}}
</p>
<div class="flex flex-col gap-3 sm:flex-row">
<BrandButton
v-if="workflowsUrl"
:href="workflowsUrl"
variant="solid"
size="lg"
class="w-full uppercase sm:w-auto sm:min-w-48"
>
{{ t('models.hero.primaryCta') }}
</BrandButton>
<BrandButton
v-if="!isPartnerNode && huggingFaceUrl"
:href="huggingFaceUrl"
target="_blank"
rel="noopener noreferrer"
:variant="workflowsUrl ? 'outline' : 'solid'"
size="lg"
class="w-full uppercase sm:w-auto sm:min-w-48"
>
{{ t('models.hero.secondaryCta') }}
</BrandButton>
<BrandButton
v-if="!workflowsUrl"
href="https://www.comfy.org/cloud"
target="_blank"
rel="noopener noreferrer"
:variant="huggingFaceUrl && !isPartnerNode ? 'outline' : 'solid'"
size="lg"
class="w-full uppercase sm:w-auto sm:min-w-48"
>
{{ t('models.hero.cloudCta') }}
</BrandButton>
<BrandButton
v-if="docsUrl"
:href="docsUrl"
target="_blank"
rel="noopener noreferrer"
variant="outline"
size="lg"
class="w-full uppercase sm:w-auto sm:min-w-48"
>
{{ t('models.hero.tutorialCta') }}
</BrandButton>
</div>
<div v-if="blogUrl" class="text-sm text-primary-comfy-canvas/60">
<a
:href="blogUrl"
target="_blank"
rel="noopener noreferrer"
class="hover:text-primary-comfy-canvas underline"
>
{{ t('models.hero.blogLink') }}
</a>
</div>
</div>
</section>
</template>

View File

@@ -7,6 +7,7 @@ import { ref } from 'vue'
import BrandButton from '../common/BrandButton.vue'
import PricingPlanFeatureList from './PricingPlanFeatureList.vue'
import PricingTierCard from './PricingTierCard.vue'
import { SHOW_FREE_TIER } from '../../config/features'
import { externalLinks, getRoutes } from '../../config/routes'
import { t } from '../../i18n/translations'
@@ -37,21 +38,23 @@ interface PricingPlan {
isEnterprise?: boolean
}
const freePlan: PricingPlan = {
id: 'free',
labelKey: 'pricing.plan.free.label',
summaryKey: 'pricing.plan.free.summary',
priceKey: 'pricing.plan.free.price',
creditsKey: 'pricing.plan.free.credits',
estimateKey: 'pricing.plan.free.estimate',
ctaKey: 'pricing.plan.free.cta',
ctaHref: externalLinks.cloud,
features: [
{ text: 'pricing.plan.free.feature1' },
{ text: 'pricing.plan.free.feature2' }
]
}
const plans: PricingPlan[] = [
{
id: 'free',
labelKey: 'pricing.plan.free.label',
summaryKey: 'pricing.plan.free.summary',
priceKey: 'pricing.plan.free.price',
creditsKey: 'pricing.plan.free.credits',
estimateKey: 'pricing.plan.free.estimate',
ctaKey: 'pricing.plan.free.cta',
ctaHref: externalLinks.cloud,
features: [
{ text: 'pricing.plan.free.feature1' },
{ text: 'pricing.plan.free.feature2' }
]
},
...(SHOW_FREE_TIER ? [freePlan] : []),
{
id: 'standard',
labelKey: 'pricing.plan.standard.label',
@@ -61,7 +64,9 @@ const plans: PricingPlan[] = [
estimateKey: 'pricing.plan.standard.estimate',
ctaKey: 'pricing.plan.standard.cta',
ctaHref: subscribeUrl('standard'),
featureIntroKey: 'pricing.plan.standard.featureIntro',
featureIntroKey: SHOW_FREE_TIER
? 'pricing.plan.standard.featureIntro'
: undefined,
features: [
{ text: 'pricing.plan.standard.feature1' },
{ text: 'pricing.plan.standard.feature2' }
@@ -77,7 +82,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 +98,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',
@@ -144,9 +155,14 @@ const activePlanIndex = ref(0)
</button>
</div>
<!-- Desktop: 4-column grid / Mobile: single card -->
<!-- Desktop: dynamic grid (3 or 4 columns) / Mobile: single card -->
<div
class="rounded-5xl bg-transparency-white-t4 hidden p-2 lg:grid lg:grid-cols-4 lg:gap-2"
:class="
cn(
'rounded-5xl bg-transparency-white-t4 hidden p-2 lg:grid lg:gap-2',
standardPlans.length === 4 ? 'lg:grid-cols-4' : 'lg:grid-cols-3'
)
"
>
<PricingTierCard v-for="plan in standardPlans" :key="plan.id">
<!-- Label + badge -->
@@ -217,10 +233,18 @@ const activePlanIndex = ref(0)
<!-- Features -->
<div v-if="plan.features.length" class="px-6 py-3">
<p class="text-primary-comfy-canvas mb-2 text-sm font-semibold">
{{
plan.featureIntroKey ? t(plan.featureIntroKey, locale) : '&nbsp;'
}}
<p
v-if="plan.featureIntroKey"
class="text-primary-comfy-canvas mb-2 text-sm font-semibold"
>
{{ t(plan.featureIntroKey, locale) }}
</p>
<p
v-else
class="text-primary-comfy-canvas mb-2 text-sm font-semibold"
aria-hidden="true"
>
&nbsp;
</p>
<ul class="space-y-2">
<li

View File

@@ -1,6 +1,7 @@
<script setup lang="ts">
import type { Locale } from '../../../i18n/translations'
import { SHOW_FREE_TIER } from '../../../config/features'
import { getRoutes } from '../../../config/routes'
import { t } from '../../../i18n/translations'
@@ -25,7 +26,10 @@ const { locale = 'en' } = defineProps<{ locale?: Locale }>()
{{ t('cloud.pricing.description', locale) }}
</p>
<p class="text-primary-comfy-ink mt-4 text-base font-bold">
<p
v-if="SHOW_FREE_TIER"
class="text-primary-comfy-ink mt-4 text-base font-bold"
>
{{ t('cloud.pricing.tagline', locale) }}
</p>
</div>

View File

@@ -276,29 +276,6 @@ onUnmounted(() => {
fill="#211927"
/>
</g>
<!-- Left-edge fade -->
<rect
x="300"
y="150"
width="250"
height="900"
fill="url(#localHeroFadeLeft)"
/>
<defs>
<linearGradient
id="localHeroFadeLeft"
x1="550"
y1="600"
x2="300"
y2="600"
gradientUnits="userSpaceOnUse"
>
<stop stop-color="#211927" stop-opacity="0" />
<stop offset="1" stop-color="#211927" />
</linearGradient>
</defs>
</svg>
</div>

View File

@@ -0,0 +1,52 @@
import { describe, expect, it } from 'vitest'
import { ref } from 'vue'
import { useBannerImage } from './useBannerImage'
describe('useBannerImage', () => {
it('shows the default banner when neither url is provided', () => {
const { showDefaultBanner, imgSrc } = useBannerImage({})
expect(showDefaultBanner.value).toBe(true)
expect(imgSrc.value).toBeUndefined()
})
it('prefers bannerUrl over iconUrl when both are provided', () => {
const { showDefaultBanner, imgSrc } = useBannerImage({
bannerUrl: 'https://example.com/banner.png',
iconUrl: 'https://example.com/icon.png'
})
expect(showDefaultBanner.value).toBe(false)
expect(imgSrc.value).toBe('https://example.com/banner.png')
})
it('falls back to iconUrl when bannerUrl is missing', () => {
const { imgSrc } = useBannerImage({
iconUrl: 'https://example.com/icon.png'
})
expect(imgSrc.value).toBe('https://example.com/icon.png')
})
it('reactively updates when sources change', () => {
const banner = ref<string | undefined>(undefined)
const { showDefaultBanner, imgSrc } = useBannerImage({ bannerUrl: banner })
expect(showDefaultBanner.value).toBe(true)
banner.value = 'https://example.com/new.png'
expect(showDefaultBanner.value).toBe(false)
expect(imgSrc.value).toBe('https://example.com/new.png')
})
it('flips isImageError when onImageError is called', () => {
const { isImageError, onImageError } = useBannerImage({
bannerUrl: 'x'
})
expect(isImageError.value).toBe(false)
onImageError()
expect(isImageError.value).toBe(true)
})
it('exposes the default banner constant for consumers', () => {
const { DEFAULT_BANNER } = useBannerImage({})
expect(DEFAULT_BANNER).toBe('/assets/images/fallback-gradient-avatar.svg')
})
})

View File

@@ -0,0 +1,30 @@
import { computed, ref, toValue } from 'vue'
import type { MaybeRefOrGetter } from 'vue'
const DEFAULT_BANNER = '/assets/images/fallback-gradient-avatar.svg'
interface UseBannerImageInput {
bannerUrl?: MaybeRefOrGetter<string | undefined>
iconUrl?: MaybeRefOrGetter<string | undefined>
}
export function useBannerImage({ bannerUrl, iconUrl }: UseBannerImageInput) {
const isImageError = ref(false)
const showDefaultBanner = computed(
() => !toValue(bannerUrl) && !toValue(iconUrl)
)
const imgSrc = computed(() => toValue(bannerUrl) || toValue(iconUrl))
function onImageError() {
isImageError.value = true
}
return {
DEFAULT_BANNER,
isImageError,
showDefaultBanner,
imgSrc,
onImageError
}
}

View File

@@ -0,0 +1,144 @@
import { describe, expect, it } from 'vitest'
import { ref } from 'vue'
import type { Pack, PackNode } from '../data/cloudNodes'
import { useFilteredPacks } from './useFilteredPacks'
import type { PackSortMode } from './useFilteredPacks'
function pack(overrides: Partial<Pack> = {}): Pack {
return {
id: overrides.id ?? 'pack',
displayName: overrides.displayName ?? 'Pack',
nodes: overrides.nodes ?? [],
downloads: overrides.downloads,
lastUpdated: overrides.lastUpdated,
...overrides
}
}
function node(name: string, displayName: string): PackNode {
return { name, displayName, category: 'x' }
}
describe('useFilteredPacks', () => {
const packs: readonly Pack[] = [
pack({
id: 'a',
displayName: 'Alpha',
downloads: 100,
lastUpdated: '2025-01-01T00:00:00Z',
nodes: [node('aa', 'Aardvark')]
}),
pack({
id: 'b',
displayName: 'Beta',
downloads: 300,
lastUpdated: '2025-06-01T00:00:00Z',
nodes: [node('bb', 'Beaver'), node('bb2', 'Bumblebee')]
}),
pack({
id: 'c',
displayName: 'Gamma',
downloads: 200,
lastUpdated: '2025-03-01T00:00:00Z',
nodes: [
node('cc', 'Cat'),
node('cc2', 'Crocodile'),
node('cc3', 'Capybara')
]
})
]
it('sorts by downloads desc by default', () => {
const { filteredPacks } = useFilteredPacks({
packs,
query: '',
sortMode: 'downloads' as PackSortMode
})
expect(filteredPacks.value.map((p) => p.id)).toEqual(['b', 'c', 'a'])
})
it('sorts most-nodes places highest count first', () => {
const { filteredPacks } = useFilteredPacks({
packs,
query: '',
sortMode: 'mostNodes' as PackSortMode
})
expect(filteredPacks.value.map((p) => p.id)).toEqual(['c', 'b', 'a'])
})
it('sorts A → Z by display name', () => {
const { filteredPacks } = useFilteredPacks({
packs,
query: '',
sortMode: 'az' as PackSortMode
})
expect(filteredPacks.value.map((p) => p.displayName)).toEqual([
'Alpha',
'Beta',
'Gamma'
])
})
it('sorts recently updated newest first', () => {
const { filteredPacks } = useFilteredPacks({
packs,
query: '',
sortMode: 'recentlyUpdated' as PackSortMode
})
expect(filteredPacks.value.map((p) => p.id)).toEqual(['b', 'c', 'a'])
})
it('treats invalid lastUpdated as 0', () => {
const broken = [
pack({ id: 'x', lastUpdated: 'nonsense' }),
pack({ id: 'y', lastUpdated: '2025-01-01T00:00:00Z' })
]
const { filteredPacks } = useFilteredPacks({
packs: broken,
query: '',
sortMode: 'recentlyUpdated' as PackSortMode
})
expect(filteredPacks.value[0].id).toBe('y')
})
it('matches the search query against pack display names', () => {
const { filteredPacks } = useFilteredPacks({
packs,
query: 'beta',
sortMode: 'az' as PackSortMode
})
expect(filteredPacks.value.map((p) => p.id)).toEqual(['b'])
})
it('matches the search query against node display names', () => {
const { filteredPacks } = useFilteredPacks({
packs,
query: 'CAPYBARA',
sortMode: 'az' as PackSortMode
})
expect(filteredPacks.value.map((p) => p.id)).toEqual(['c'])
})
it('returns empty when nothing matches', () => {
const { filteredPacks } = useFilteredPacks({
packs,
query: 'zzz-no-such-thing',
sortMode: 'az' as PackSortMode
})
expect(filteredPacks.value).toHaveLength(0)
})
it('reacts when the query ref changes', () => {
const query = ref('beta')
const { filteredPacks } = useFilteredPacks({
packs,
query,
sortMode: 'az' as PackSortMode
})
expect(filteredPacks.value).toHaveLength(1)
query.value = ''
expect(filteredPacks.value).toHaveLength(3)
})
})

View File

@@ -0,0 +1,53 @@
import { computed, toValue } from 'vue'
import type { MaybeRefOrGetter } from 'vue'
import type { Pack } from '../data/cloudNodes'
export type PackSortMode = 'downloads' | 'mostNodes' | 'az' | 'recentlyUpdated'
interface UseFilteredPacksInput {
packs: MaybeRefOrGetter<readonly Pack[]>
query: MaybeRefOrGetter<string>
sortMode: MaybeRefOrGetter<PackSortMode>
}
function matchesQuery(pack: Pack, normalizedQuery: string): boolean {
if (pack.displayName.toLowerCase().includes(normalizedQuery)) return true
return pack.nodes.some((node) =>
node.displayName.toLowerCase().includes(normalizedQuery)
)
}
function safeTimestamp(value: string | undefined): number {
if (!value) return 0
const ts = Date.parse(value)
return Number.isNaN(ts) ? 0 : ts
}
export function useFilteredPacks(input: UseFilteredPacksInput) {
const filteredPacks = computed<Pack[]>(() => {
const allPacks = toValue(input.packs)
const normalizedQuery = toValue(input.query).trim().toLowerCase()
const matching =
normalizedQuery.length === 0
? [...allPacks]
: allPacks.filter((pack) => matchesQuery(pack, normalizedQuery))
const mode = toValue(input.sortMode)
if (mode === 'az') {
return matching.sort((a, b) => a.displayName.localeCompare(b.displayName))
}
if (mode === 'recentlyUpdated') {
return matching.sort(
(a, b) => safeTimestamp(b.lastUpdated) - safeTimestamp(a.lastUpdated)
)
}
if (mode === 'mostNodes') {
return matching.sort((a, b) => b.nodes.length - a.nodes.length)
}
return matching.sort((a, b) => (b.downloads ?? 0) - (a.downloads ?? 0))
})
return { filteredPacks }
}

View File

@@ -0,0 +1,68 @@
import { describe, expect, it } from 'vitest'
import { ref } from 'vue'
import type { PackNode } from '../data/cloudNodes'
import { useNodesByCategory } from './useNodesByCategory'
function node(name: string, displayName: string, category: string): PackNode {
return { name, displayName, category }
}
describe('useNodesByCategory', () => {
it('groups nodes by category', () => {
const { groupedNodes } = useNodesByCategory(() => [
node('A', 'A', 'cat-1'),
node('B', 'B', 'cat-2'),
node('C', 'C', 'cat-1')
])
expect(groupedNodes.value).toHaveLength(2)
expect(groupedNodes.value[0]).toMatchObject({
category: 'cat-1',
nodes: [
expect.objectContaining({ name: 'A' }),
expect.objectContaining({ name: 'C' })
]
})
})
it('sorts nodes alphabetically by display name within a category', () => {
const { groupedNodes } = useNodesByCategory(() => [
node('z', 'Zulu', 'x'),
node('a', 'Alpha', 'x'),
node('m', 'Mike', 'x')
])
expect(groupedNodes.value[0].nodes.map((n) => n.displayName)).toEqual([
'Alpha',
'Mike',
'Zulu'
])
})
it('sorts categories alphabetically', () => {
const { groupedNodes } = useNodesByCategory(() => [
node('a', 'A', 'beta'),
node('b', 'B', 'alpha'),
node('c', 'C', 'gamma')
])
expect(groupedNodes.value.map((g) => g.category)).toEqual([
'alpha',
'beta',
'gamma'
])
})
it('falls back to a placeholder for missing categories', () => {
const { groupedNodes } = useNodesByCategory(() => [node('a', 'A', '')])
expect(groupedNodes.value[0].category).toBe('—')
})
it('reacts to ref changes', () => {
const nodes = ref<PackNode[]>([node('a', 'A', 'x')])
const { groupedNodes } = useNodesByCategory(nodes)
expect(groupedNodes.value).toHaveLength(1)
nodes.value = [node('a', 'A', 'x'), node('b', 'B', 'y')]
expect(groupedNodes.value).toHaveLength(2)
})
})

View File

@@ -0,0 +1,40 @@
import { computed, toValue } from 'vue'
import type { MaybeRefOrGetter } from 'vue'
import type { PackNode } from '../data/cloudNodes'
const UNCATEGORIZED = '—'
interface NodeCategoryGroup {
category: string
nodes: PackNode[]
}
export function useNodesByCategory(
nodes: MaybeRefOrGetter<readonly PackNode[]>
) {
const groupedNodes = computed<NodeCategoryGroup[]>(() => {
const byCategory = new Map<string, PackNode[]>()
for (const node of toValue(nodes)) {
const category = node.category || UNCATEGORIZED
const existing = byCategory.get(category)
if (existing) {
existing.push(node)
continue
}
byCategory.set(category, [node])
}
return [...byCategory.entries()]
.map(([category, items]) => ({
category,
nodes: [...items].sort((a, b) =>
a.displayName.localeCompare(b.displayName)
)
}))
.sort((a, b) => a.category.localeCompare(b.category))
})
return { groupedNodes }
}

View File

@@ -15,6 +15,14 @@ interface Demo {
readonly transcript?: TranslationKey
readonly publishedDate: string
readonly modifiedDate: string
/**
* Width / height of the Arcade demo's source recording (e.g. 1.93 for a
* landscape screencast). Sizes the embed container to match so rounded
* corners hug the content instead of empty letterbox space. Source from
* Arcade's `_serializablePublicFlow.aspectRatio` (which is height/width —
* invert it). Defaults to 16/9 if omitted.
*/
readonly aspectRatio?: number
}
export const demos: readonly Demo[] = [
@@ -32,7 +40,8 @@ export const demos: readonly Demo[] = [
difficulty: 'beginner',
tags: ['templates', 'image', 'video'],
publishedDate: '2026-04-19',
modifiedDate: '2026-04-19'
modifiedDate: '2026-04-19',
aspectRatio: 1.931
},
{
slug: 'workflow-templates',
@@ -48,7 +57,25 @@ export const demos: readonly Demo[] = [
difficulty: 'beginner',
tags: ['getting-started', 'templates', 'workflow'],
publishedDate: '2026-04-19',
modifiedDate: '2026-04-19'
modifiedDate: '2026-04-19',
aspectRatio: 1.931
},
{
slug: 'community-workflows',
arcadeId: 'mqZh17oWDuWIyhK0xwEV',
category: 'demos.category.gettingStarted',
title: 'demos.community-workflows.title',
description: 'demos.community-workflows.description',
transcript: 'demos.community-workflows.transcript',
ogImage: '/images/demos/community-workflows-og.png',
thumbnail: '/images/demos/community-workflows-thumb.webp',
estimatedTime: 'demos.duration.2min',
durationIso: 'PT2M',
difficulty: 'beginner',
tags: ['getting-started', 'community', 'workflow', 'hub'],
publishedDate: '2026-05-04',
modifiedDate: '2026-05-04',
aspectRatio: 1.931
}
]

View File

@@ -0,0 +1 @@
export const SHOW_FREE_TIER = false

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,208 @@
interface ModelOverride {
docsUrl?: string
blogUrl?: string
featured?: boolean
// Slug used on comfy.org/workflows/model/{hubSlug}. Only set when the page exists.
hubSlug?: string
}
export const modelMetadata: Record<string, ModelOverride> = {
'nano-banana': {
docsUrl:
'https://docs.comfy.org/tutorials/partner-nodes/google/nano-banana-pro',
hubSlug: 'nano-banana',
featured: true
},
'kling-ai': {
docsUrl:
'https://docs.comfy.org/tutorials/partner-nodes/kling/kling-motion-control',
hubSlug: 'kling',
featured: true
},
'meshy-ai': {
docsUrl: 'https://docs.comfy.org/tutorials/partner-nodes/meshy/meshy-6',
hubSlug: 'meshy',
featured: true
},
'openai-dall-e': {
docsUrl: 'https://docs.comfy.org/tutorials/partner-nodes/openai/dall-e-3',
hubSlug: 'openai',
featured: true
},
'ltxv-api': {
docsUrl: 'https://docs.comfy.org/tutorials/video/ltxv',
hubSlug: 'ltx-2-3',
featured: true
},
'wan-api': {
docsUrl: 'https://docs.comfy.org/tutorials/video/wan/wan2_2',
hubSlug: 'wan',
featured: true
},
'wan-2-2': {
docsUrl: 'https://docs.comfy.org/tutorials/video/wan/wan2_2',
hubSlug: 'wan',
featured: true
},
'wan-2-1': {
docsUrl: 'https://docs.comfy.org/tutorials/video/wan/wan-video',
hubSlug: 'wan',
featured: true
},
'flux-1-kontext-dev': {
docsUrl:
'https://docs.comfy.org/tutorials/partner-nodes/black-forest-labs/flux-1-kontext',
hubSlug: 'flux-1-kontext',
featured: true
},
'flux1-dev': {
docsUrl: 'https://docs.comfy.org/tutorials/flux/flux-1-text-to-image',
hubSlug: 'flux-1',
featured: true
},
'flux1-schnell': {
hubSlug: 'flux-1',
featured: true
},
'hunyuan-video': {
docsUrl: 'https://docs.comfy.org/tutorials/video/hunyuan/hunyuan-video',
hubSlug: 'hunyuan-video',
featured: true
},
'hunyuan-3d': {
docsUrl: 'https://docs.comfy.org/tutorials/3d/hunyuan3D-2',
hubSlug: 'hunyuan-3d',
featured: true
},
vidu: {
hubSlug: 'vidu',
featured: true
},
runway: {
hubSlug: 'runway',
featured: true
},
'stability-ai': {
hubSlug: 'stability',
featured: true
},
'seedance-bytedance': {
hubSlug: 'seedance',
featured: true
},
'grok-image': {
hubSlug: 'grok',
featured: false
},
'luma-dream-machine': {
hubSlug: 'luma',
featured: false
},
moonvalley: {
hubSlug: 'moonvalley',
featured: false
},
'magnific-ai': {
hubSlug: 'magnific',
featured: false
},
pixverse: {
hubSlug: 'pixverse',
featured: false
},
'rodin-3d': {
hubSlug: 'rodin',
featured: false
},
recraft: {
hubSlug: 'recraft',
featured: false
},
'bria-ai': {
hubSlug: 'bria',
featured: false
},
'topaz-labs': {
hubSlug: 'topaz',
featured: false
},
wavespeed: {
hubSlug: 'wavespeed',
featured: false
},
ideogram: {
hubSlug: 'ideogram',
featured: false
},
'veo-2': {
hubSlug: 'veo',
featured: false
},
'veo-3': {
hubSlug: 'veo',
featured: false
},
'flux-2-api': {
hubSlug: 'flux-2',
featured: false
},
'ace-step-v1-3-5b': {
docsUrl: 'https://docs.comfy.org/tutorials/audio/ace-step/ace-step-v1',
hubSlug: 'ace-step',
featured: false
},
'hidream-i1-dev-fp8': {
docsUrl: 'https://docs.comfy.org/tutorials/image/hidream/hidream-i1',
hubSlug: 'hidream',
featured: false
},
'omnigen2-fp16': {
hubSlug: 'omnigen',
featured: false
},
'sd-xl-base-1-0': {
hubSlug: 'sdxl',
featured: false
},
'z-image-bf16': {
hubSlug: 'z-image',
featured: false
},
'z-image-turbo-bf16': {
hubSlug: 'z-image',
featured: false
},
'svd-xt': {
hubSlug: 'svd',
featured: false
},
'flux1-dev-kontext-fp8-scaled': {
docsUrl: 'https://docs.comfy.org/tutorials/flux/flux-1-kontext-dev',
hubSlug: 'flux-1-kontext',
featured: false
},
'ltx-2-19b-dev-fp8': {
hubSlug: 'ltx-2',
featured: false
},
'ltx-2-19b-distilled': {
hubSlug: 'ltx-2',
featured: false
},
'flux1-fill-dev': {
hubSlug: 'flux-1',
featured: false
},
'flux-2-klein-base-9b-fp8': {
hubSlug: 'flux-2',
featured: false
},
'qwen-image-fp8-e4m3fn': {
hubSlug: 'qwen',
featured: false
},
'qwen-image-edit-2509-fp8-e4m3fn': {
hubSlug: 'qwen',
featured: false
}
}

View File

@@ -0,0 +1,81 @@
import generatedModels from './generated-models.json'
import { modelMetadata } from './model-metadata'
type ModelDirectory =
| 'diffusion_models'
| 'checkpoints'
| 'loras'
| 'controlnet'
| 'clip_vision'
| 'model_patches'
| 'vae'
| 'text_encoders'
| 'audio_encoders'
| 'latent_upscale_models'
| 'upscale_models'
| 'style_models'
| 'partner_nodes'
interface Model {
readonly slug: string
readonly canonicalSlug?: string
readonly name: string
readonly displayName: string
readonly directory: ModelDirectory
readonly huggingFaceUrl: string
readonly thumbnailUrl?: string
readonly docsUrl?: string
readonly blogUrl?: string
readonly hubSlug?: string
readonly featured: boolean
readonly workflowCount: number
}
export const models: readonly Model[] = (
generatedModels as Array<{
slug: string
canonicalSlug?: string
name: string
displayName: string
directory: string
huggingFaceUrl: string
docsUrl?: string
thumbnailUrl?: string
workflowCount: number
}>
).map((m) => ({
slug: m.slug,
...(m.canonicalSlug ? { canonicalSlug: m.canonicalSlug } : {}),
name: m.name,
displayName: m.displayName,
directory: m.directory as ModelDirectory,
huggingFaceUrl: m.huggingFaceUrl,
...(m.docsUrl ? { docsUrl: m.docsUrl } : {}),
...(m.thumbnailUrl ? { thumbnailUrl: m.thumbnailUrl } : {}),
featured: false,
workflowCount: m.workflowCount,
...modelMetadata[m.slug]
}))
const slugSet = new Set(models.map((m) => m.slug))
if (slugSet.size !== models.length) {
for (const model of models) {
if (models.filter((m) => m.slug === model.slug).length > 1) {
throw new Error(`Duplicate model slug: ${model.slug}`)
}
}
}
for (const model of models) {
if (
model.canonicalSlug !== undefined &&
(!slugSet.has(model.canonicalSlug) || model.canonicalSlug === model.slug)
) {
throw new Error(
`Invalid canonicalSlug "${model.canonicalSlug}" on "${model.slug}"`
)
}
}
export function getModelBySlug(slug: string): Model | undefined {
return models.find((m) => m.slug === slug)
}

View File

@@ -14,16 +14,22 @@ const baseRoutes = {
demos: '/demos',
termsOfService: '/terms-of-service',
privacyPolicy: '/privacy-policy',
contact: '/contact'
contact: '/contact',
models: '/p/supported-models'
} as const
type Routes = typeof baseRoutes
const localeInvariantRouteKeys = new Set<keyof Routes>(['termsOfService'])
export function getRoutes(locale: Locale = 'en'): Routes {
if (locale === 'en') return baseRoutes
const prefix = `/${locale}`
return Object.fromEntries(
Object.entries(baseRoutes).map(([k, v]) => [k, `${prefix}${v}`])
Object.entries(baseRoutes).map(([k, v]) => [
k,
localeInvariantRouteKeys.has(k as keyof Routes) ? v : `${prefix}${v}`
])
) as unknown as Routes
}
@@ -31,6 +37,7 @@ export const externalLinks = {
apiKeys: 'https://platform.comfy.org/profile/api-keys',
blog: 'https://blog.comfy.org/',
cloud: 'https://cloud.comfy.org',
cloudStatus: 'https://status.comfy.org',
discord: 'https://discord.com/invite/comfyorg',
docs: 'https://docs.comfy.org/',
docsApi: 'https://docs.comfy.org/api-reference/cloud',

View File

@@ -1,37 +1,30 @@
{
"fetchedAt": "2026-05-02T20:15:18.321Z",
"fetchedAt": "2026-05-12T16:10:34.114Z",
"departments": [
{
"name": "DESIGN",
"key": "design",
"roles": [
{
"id": "e915f2c78b17f93b",
"id": "18743530eb448c99",
"title": "Senior Product Designer",
"department": "Design",
"location": "San Francisco",
"applyUrl": "https://jobs.ashbyhq.com/comfy-org/b2e864c6-4754-4e04-8f46-1022baa103c3/application"
"jobUrl": "https://jobs.ashbyhq.com/comfy-org/b2e864c6-4754-4e04-8f46-1022baa103c3"
},
{
"id": "b9f9a23219be7cd4",
"title": "Design Engineer",
"department": "Design",
"location": "San Francisco",
"applyUrl": "https://jobs.ashbyhq.com/comfy-org/abc787b9-ad85-421c-8218-debd23bea096/application"
},
{
"id": "547b6ba622c800a5",
"id": "8718d17012f26fa2",
"title": "Senior Product Designer - Craft",
"department": "Design",
"location": "San Francisco",
"applyUrl": "https://jobs.ashbyhq.com/comfy-org/a32c6769-b791-41f4-9225-50bbd8a1cf0f/application"
"jobUrl": "https://jobs.ashbyhq.com/comfy-org/a32c6769-b791-41f4-9225-50bbd8a1cf0f"
},
{
"id": "7bb02634a24763bc",
"id": "1e181b9ed8fb2e86",
"title": "Staff Product Designer - Systems",
"department": "Design",
"location": "San Francisco",
"applyUrl": "https://jobs.ashbyhq.com/comfy-org/0bc8356b-615e-4f40-b632-fd3b2691be34/application"
"jobUrl": "https://jobs.ashbyhq.com/comfy-org/0bc8356b-615e-4f40-b632-fd3b2691be34"
}
]
},
@@ -40,67 +33,67 @@
"key": "engineering",
"roles": [
{
"id": "102d58e35a8a9817",
"id": "6a6d865eeb3c10a8",
"title": "Senior Software Engineer, Frontend",
"department": "Engineering",
"location": "San Francisco",
"applyUrl": "https://jobs.ashbyhq.com/comfy-org/c3e0584d-5490-491f-aae4-b5922ef63fd2/application"
"jobUrl": "https://jobs.ashbyhq.com/comfy-org/c3e0584d-5490-491f-aae4-b5922ef63fd2"
},
{
"id": "d01d69fba7743905",
"id": "1b4f7f1da9616e14",
"title": "Senior Software Engineer, Backend Generalist",
"department": "Engineering",
"location": "San Francisco",
"applyUrl": "https://jobs.ashbyhq.com/comfy-org/732f8b39-076d-4847-afe3-f54d4451607e/application"
"jobUrl": "https://jobs.ashbyhq.com/comfy-org/732f8b39-076d-4847-afe3-f54d4451607e"
},
{
"id": "f36f60cfd5bb5910",
"id": "a6d8269c66e37c5c",
"title": "Senior/Staff Applied Machine Learning Engineer",
"department": "Engineering",
"location": "San Francisco",
"applyUrl": "https://jobs.ashbyhq.com/comfy-org/5cc4d0bc-97b0-463b-8466-3ec1d07f6ac0/application"
"jobUrl": "https://jobs.ashbyhq.com/comfy-org/5cc4d0bc-97b0-463b-8466-3ec1d07f6ac0"
},
{
"id": "9d8ec4c65e20b19e",
"id": "841da783e6e41928",
"title": "Software Engineer, Frontend",
"department": "Engineering",
"location": "Remote",
"applyUrl": "https://jobs.ashbyhq.com/comfy-org/99dc26c7-51ca-43cd-a1ba-7d475a0f4a40/application"
"jobUrl": "https://jobs.ashbyhq.com/comfy-org/99dc26c7-51ca-43cd-a1ba-7d475a0f4a40"
},
{
"id": "be94b193d1f4d482",
"id": "5d01d58b03870d7a",
"title": "Tech Lead Manager, Frontend",
"department": "Engineering",
"location": "San Francisco",
"applyUrl": "https://jobs.ashbyhq.com/comfy-org/a0665088-3314-457a-aa7b-12ca5c3eb261/application"
"jobUrl": "https://jobs.ashbyhq.com/comfy-org/a0665088-3314-457a-aa7b-12ca5c3eb261"
},
{
"id": "ab48f5db6bd1783c",
"id": "91604c4182a1bc3c",
"title": "Software Engineer, Core ComfyUI Contributor",
"department": "Engineering",
"location": "San Francisco",
"applyUrl": "https://jobs.ashbyhq.com/comfy-org/7d4062d6-d500-445a-9a5f-014971af259f/application"
"jobUrl": "https://jobs.ashbyhq.com/comfy-org/7d4062d6-d500-445a-9a5f-014971af259f"
},
{
"id": "c5dff4ee628bdcd1",
"id": "a1dbc0576ab14034",
"title": "Software Engineer, ComfyUI Desktop",
"department": "Engineering",
"location": "San Francisco",
"applyUrl": "https://jobs.ashbyhq.com/comfy-org/ad2f76cb-a787-47d8-81c5-7e7f917747c0/application"
"jobUrl": "https://jobs.ashbyhq.com/comfy-org/ad2f76cb-a787-47d8-81c5-7e7f917747c0"
},
{
"id": "4302a7aaa87e16e3",
"id": "0b8f4fecd89c3b11",
"title": "Product Manager, ComfyUI",
"department": "Engineering",
"location": "San Francisco",
"applyUrl": "https://jobs.ashbyhq.com/comfy-org/9e4b9029-c3e9-436b-82c4-a1a9f1b8c16e/application"
"jobUrl": "https://jobs.ashbyhq.com/comfy-org/9e4b9029-c3e9-436b-82c4-a1a9f1b8c16e"
},
{
"id": "2eb53e8943cc9396",
"id": "2f6bac39d723dfef",
"title": "Growth Engineer",
"department": "Engineering",
"location": "San Francisco",
"applyUrl": "https://jobs.ashbyhq.com/comfy-org/f1fdde76-84ae-48c1-b0f9-9654dd8e7de5/application"
"jobUrl": "https://jobs.ashbyhq.com/comfy-org/f1fdde76-84ae-48c1-b0f9-9654dd8e7de5"
}
]
},
@@ -109,39 +102,39 @@
"key": "marketing",
"roles": [
{
"id": "4c5d6afb78652df7",
"id": "23dd98cab77ff459",
"title": "Freelance Motion Designer",
"department": "Marketing",
"location": "San Francisco",
"applyUrl": "https://jobs.ashbyhq.com/comfy-org/a7ccc2b4-4d9d-4e04-b39c-28a711995b5b/application"
"jobUrl": "https://jobs.ashbyhq.com/comfy-org/a7ccc2b4-4d9d-4e04-b39c-28a711995b5b"
},
{
"id": "0f5256cf302e552b",
"id": "a998b9fc973ff3c0",
"title": "Creative Artist",
"department": "Marketing",
"location": "San Francisco",
"applyUrl": "https://jobs.ashbyhq.com/comfy-org/19ba10aa-4961-45e8-8473-66a8a7a8079d/application"
"jobUrl": "https://jobs.ashbyhq.com/comfy-org/19ba10aa-4961-45e8-8473-66a8a7a8079d"
},
{
"id": "5746486d87874937",
"id": "3e730938026d6e70",
"title": "Graphic Designer",
"department": "Marketing",
"location": "San Francisco",
"applyUrl": "https://jobs.ashbyhq.com/comfy-org/49fa0b07-3fa1-4a3a-b2c6-d2cc684ad63f/application"
"jobUrl": "https://jobs.ashbyhq.com/comfy-org/49fa0b07-3fa1-4a3a-b2c6-d2cc684ad63f"
},
{
"id": "b5803a0d4785d406",
"id": "6f771af6858283aa",
"title": "Lifecycle Growth Marketer",
"department": "Marketing",
"location": "San Francisco",
"applyUrl": "https://jobs.ashbyhq.com/comfy-org/be74d210-3b50-408c-9f61-8fee8833ce64/application"
"jobUrl": "https://jobs.ashbyhq.com/comfy-org/be74d210-3b50-408c-9f61-8fee8833ce64"
},
{
"id": "130d7218d7895bdb",
"id": "527a47e82970afc1",
"title": "Partnership & Events Marketing Manager",
"department": "Marketing",
"location": "San Francisco",
"applyUrl": "https://jobs.ashbyhq.com/comfy-org/89d3ff75-2055-4e92-9c69-81feff55627c/application"
"jobUrl": "https://jobs.ashbyhq.com/comfy-org/89d3ff75-2055-4e92-9c69-81feff55627c"
}
]
},
@@ -150,25 +143,18 @@
"key": "operations",
"roles": [
{
"id": "ec68ae44dd5943c9",
"title": "Talent Lead",
"id": "0c6cc3685194ab7a",
"title": "Head of Talent",
"department": "Operations",
"location": "San Francisco",
"applyUrl": "https://jobs.ashbyhq.com/comfy-org/d5008532-c45d-46e6-ba2c-20489d364362/application"
"jobUrl": "https://jobs.ashbyhq.com/comfy-org/d5008532-c45d-46e6-ba2c-20489d364362"
},
{
"id": "16f556001ce1cef4",
"title": "BizOps Strategist",
"department": "Operations",
"location": "San Francisco",
"applyUrl": "https://jobs.ashbyhq.com/comfy-org/145b8558-0ab4-43e8-8fac-b59059cf2537/application"
},
{
"id": "8e773a72c1b8e099",
"id": "82bd6ed26adab1c3",
"title": "Founding Customer Success Manager",
"department": "Operations",
"location": "San Francisco",
"applyUrl": "https://jobs.ashbyhq.com/comfy-org/a1c5c5ed-62ac-4767-af57-a3ba4e0bf5e4/application"
"jobUrl": "https://jobs.ashbyhq.com/comfy-org/a1c5c5ed-62ac-4767-af57-a3ba4e0bf5e4"
}
]
}

View File

@@ -0,0 +1,394 @@
{
"fetchedAt": "2026-05-04T16:29:55.587Z",
"packs": [
{
"id": "comfyui-impact-pack",
"registryId": "comfyui-impact-pack",
"displayName": "ComfyUI Impact Pack",
"description": "Production-grade detailer, detector, and SEG (segmentation) tooling. The most-used pack for face restoration, region-based refinement, and iterative upscaling on Comfy Cloud.",
"repoUrl": "https://github.com/ltdrdata/ComfyUI-Impact-Pack",
"publisher": {
"id": "drltdata",
"name": "Dr.Lt.Data"
},
"downloads": 2618646,
"githubStars": 3092,
"latestVersion": "8.28.3",
"license": "See repository LICENSE",
"lastUpdated": "2026-04-19T17:08:04.993918Z",
"nodes": [
{
"name": "FaceDetailer",
"displayName": "FaceDetailer",
"category": "ImpactPack/Detailer",
"description": "Detect and refine faces with iterative passes."
},
{
"name": "DetailerForEach",
"displayName": "DetailerForEach",
"category": "ImpactPack/Detailer",
"description": "Run iterative detail refinement over detected SEG regions."
},
{
"name": "UltralyticsDetectorProvider",
"displayName": "UltralyticsDetectorProvider",
"category": "ImpactPack/Detector",
"description": "Provide detector models powered by Ultralytics YOLO."
},
{
"name": "SAMLoader",
"displayName": "SAMLoader",
"category": "ImpactPack/Detector",
"description": "Load Segment Anything models for high-fidelity masking."
},
{
"name": "MaskToSEGS",
"displayName": "MaskToSEGS",
"category": "ImpactPack/Operation",
"description": "Convert binary masks into SEGS regions for the detailer pipeline."
}
]
},
{
"id": "ComfyUI-Crystools",
"registryId": "ComfyUI-Crystools",
"displayName": "ComfyUI-Crystools",
"description": "Live system monitoring (GPU, RAM, disk) and rich image inspection inside your workflow. The most-installed quality-of-life pack on the registry.",
"iconUrl": "https://raw.githubusercontent.com/crystian/ComfyUI-Crystools/main/docs/screwdriver.png",
"repoUrl": "https://github.com/crystian/ComfyUI-Crystools",
"publisher": {
"id": "crystian",
"name": "Crystian"
},
"downloads": 1671447,
"githubStars": 1855,
"latestVersion": "1.27.4",
"license": "See repository LICENSE",
"lastUpdated": "2025-10-26T19:11:09.943366Z",
"supportedOs": ["OS Independent"],
"supportedAccelerators": ["GPU :: NVIDIA CUDA"],
"nodes": [
{
"name": "CCrystools_Show_Resources",
"displayName": "CCrystools_Show_Resources",
"category": "crystools/show",
"description": "Display GPU, RAM and disk usage live in the workflow."
},
{
"name": "CCrystools_Show_Image",
"displayName": "CCrystools_Show_Image",
"category": "crystools/show",
"description": "Inspect images at full resolution with metadata overlays."
},
{
"name": "CCrystools_Json",
"displayName": "CCrystools_Json",
"category": "crystools/json",
"description": "Compose and parse JSON inline for advanced workflows."
},
{
"name": "CCrystools_Pipe_To_Any",
"displayName": "CCrystools_Pipe_To_Any",
"category": "crystools/pipe",
"description": "Convert a pipe bus into individual outputs."
},
{
"name": "CCrystools_Save_Metadata",
"displayName": "CCrystools_Save_Metadata",
"category": "crystools/save",
"description": "Save images with workflow metadata embedded."
}
]
},
{
"id": "rgthree-comfy",
"registryId": "rgthree-comfy",
"displayName": "rgthree-comfy",
"description": "Quality-of-life nodes that make complex workflows readable: Power Lora Loader, group bypassers, smarter reroutes, and inline debug widgets.",
"iconUrl": "https://comfy.rgthree.com/media/rgthree.svg",
"repoUrl": "https://github.com/rgthree/rgthree-comfy",
"publisher": {
"id": "rgthree"
},
"downloads": 3025389,
"githubStars": 3028,
"latestVersion": "1.0.2604070017",
"license": "See repository LICENSE",
"lastUpdated": "2026-04-07T04:19:24.689627Z",
"nodes": [
{
"name": "Power Lora Loader (rgthree)",
"displayName": "Power Lora Loader (rgthree)",
"category": "rgthree",
"description": "Stack multiple LoRAs in a single, foldable widget."
},
{
"name": "Fast Groups Bypasser (rgthree)",
"displayName": "Fast Groups Bypasser (rgthree)",
"category": "rgthree",
"description": "Toggle whole groups on or off without rewiring."
},
{
"name": "Seed (rgthree)",
"displayName": "Seed (rgthree)",
"category": "rgthree",
"description": "A predictable seed control with quick reset."
},
{
"name": "Reroute (rgthree)",
"displayName": "Reroute (rgthree)",
"category": "rgthree",
"description": "A clean reroute alternative with persistent labels."
},
{
"name": "Display Any (rgthree)",
"displayName": "Display Any (rgthree)",
"category": "rgthree",
"description": "Inspect any value with a compact debug widget."
}
]
},
{
"id": "comfyui-kjnodes",
"registryId": "comfyui-kjnodes",
"displayName": "ComfyUI-KJNodes",
"description": "Daily-driver utilities for image, latent and string handling: color matching, batch counters, resize helpers, and prompt presets.",
"iconUrl": "https://avatars.githubusercontent.com/u/40791699",
"repoUrl": "https://github.com/kijai/ComfyUI-KJNodes",
"publisher": {
"id": "kijai",
"name": "Kijai"
},
"downloads": 3319866,
"githubStars": 2544,
"latestVersion": "1.3.9",
"license": "See repository LICENSE",
"lastUpdated": "2026-04-24T09:32:28.326616Z",
"nodes": [
{
"name": "ColorMatch",
"displayName": "ColorMatch",
"category": "KJNodes/image",
"description": "Match the colors of one image to another using statistics."
},
{
"name": "ImageResizeKJ",
"displayName": "ImageResizeKJ",
"category": "KJNodes/image",
"description": "Resize images with intuitive size and divisibility controls."
},
{
"name": "StringConstantMultiline",
"displayName": "StringConstantMultiline",
"category": "KJNodes/string",
"description": "A multi-line string constant suitable for prompts."
},
{
"name": "EmptyLatentImagePresets",
"displayName": "EmptyLatentImagePresets",
"category": "KJNodes/latent",
"description": "Quickly create empty latents at common resolutions."
},
{
"name": "GetImageSizeAndCount",
"displayName": "GetImageSizeAndCount",
"category": "KJNodes/image",
"description": "Read width, height and batch size from an image input."
}
]
},
{
"id": "comfyui-easy-use",
"registryId": "comfyui-easy-use",
"displayName": "ComfyUI-Easy-Use",
"description": "Simplified, opinionated nodes that bundle common patterns into single drop-ins — full loader, pre-sampling, easy KSampler, and XY plotting.",
"iconUrl": "https://mintlify.s3.us-west-1.amazonaws.com/yolain/images/logo.svg",
"repoUrl": "https://github.com/yolain/ComfyUI-Easy-Use",
"publisher": {
"id": "yolain",
"name": "yolain"
},
"downloads": 2767609,
"githubStars": 2500,
"latestVersion": "1.3.6",
"license": "See repository LICENSE",
"lastUpdated": "2026-01-23T06:19:17.505188Z",
"nodes": [
{
"name": "easy fullLoader",
"displayName": "easy fullLoader",
"category": "EasyUse/Loaders",
"description": "Combined checkpoint, VAE and CLIP loader with sensible defaults."
},
{
"name": "easy preSampling",
"displayName": "easy preSampling",
"category": "EasyUse/PreSampling",
"description": "A unified pre-sampling node bundling common settings."
},
{
"name": "easy kSampler",
"displayName": "easy kSampler",
"category": "EasyUse/KSampler",
"description": "A simplified KSampler with extra quality-of-life options."
},
{
"name": "easy showAnything",
"displayName": "easy showAnything",
"category": "EasyUse/Util",
"description": "Display any value inline for debugging."
},
{
"name": "easy XYPlot",
"displayName": "easy XYPlot",
"category": "EasyUse/XYPlot",
"description": "Compose XY plots over arbitrary parameters."
}
]
},
{
"id": "comfyui-advanced-controlnet",
"registryId": "comfyui-advanced-controlnet",
"displayName": "ComfyUI-Advanced-ControlNet",
"description": "ControlNet with timestep keyframes, per-frame masks, and advanced strength scheduling — essential for animation and batched-latent workflows.",
"repoUrl": "https://github.com/Kosinkadink/ComfyUI-Advanced-ControlNet",
"publisher": {
"id": "kosinkadink",
"name": "Kosinkadink"
},
"downloads": 590539,
"githubStars": 967,
"latestVersion": "1.5.7",
"license": "See repository LICENSE",
"lastUpdated": "2026-03-30T01:40:06.836236Z",
"nodes": [
{
"name": "Apply Advanced ControlNet",
"displayName": "Apply Advanced ControlNet",
"category": "Adv-ControlNet/conditioning",
"description": "Apply ControlNet with timestep keyframes and per-frame masks."
},
{
"name": "ControlNetLoaderAdvanced",
"displayName": "ControlNetLoaderAdvanced",
"category": "Adv-ControlNet/loaders",
"description": "Load ControlNet models with the advanced wrapper."
},
{
"name": "Latent Keyframe Group",
"displayName": "Latent Keyframe Group",
"category": "Adv-ControlNet/keyframes",
"description": "Schedule ControlNet strength over a batch of latents."
},
{
"name": "Timestep Keyframe",
"displayName": "Timestep Keyframe",
"category": "Adv-ControlNet/keyframes",
"description": "Set ControlNet strength at a specific timestep."
},
{
"name": "Scaled Soft Mask",
"displayName": "Scaled Soft Mask",
"category": "Adv-ControlNet/masks",
"description": "Apply a soft attention mask to ControlNet conditioning."
}
]
},
{
"id": "was-node-suite-comfyui",
"registryId": "was-node-suite-comfyui",
"displayName": "WAS Node Suite",
"description": "A broad utility suite covering image adjustments, compositing, text, math, and I/O — the original \"kitchen sink\" pack still relied on by thousands of workflows.",
"repoUrl": "https://github.com/WASasquatch/was-node-suite-comfyui",
"publisher": {
"id": "was",
"name": "WAS"
},
"downloads": 981051,
"githubStars": 1777,
"latestVersion": "1.0.1",
"license": "See repository LICENSE",
"lastUpdated": "2024-08-01T05:28:23.655235Z",
"nodes": [
{
"name": "Image Filter Adjustments",
"displayName": "Image Filter Adjustments",
"category": "WAS Suite/Image/Adjustment",
"description": "Adjust brightness, contrast, saturation and more."
},
{
"name": "Image Blending Mode",
"displayName": "Image Blending Mode",
"category": "WAS Suite/Image/Compositing",
"description": "Composite two images with Photoshop-style blend modes."
},
{
"name": "Text String",
"displayName": "Text String",
"category": "WAS Suite/Text",
"description": "A reusable text constant suitable for prompts."
},
{
"name": "Number to Float",
"displayName": "Number to Float",
"category": "WAS Suite/Number",
"description": "Cast integer or string values to a float."
},
{
"name": "Image Save",
"displayName": "Image Save",
"category": "WAS Suite/IO",
"description": "Save an image to disk with rich filename templating."
}
]
},
{
"id": "comfyui_ipadapter_plus",
"registryId": "comfyui_ipadapter_plus",
"displayName": "ComfyUI_IPAdapter_plus",
"description": "Reference-image conditioning with IPAdapter — style transfer, Face ID, and multi-image embeddings. The most-installed conditioning pack on the registry, used in countless portrait, product, and animation workflows.",
"repoUrl": "https://github.com/cubiq/ComfyUI_IPAdapter_plus",
"publisher": {
"id": "matteo",
"name": "Matteo"
},
"downloads": 1208394,
"githubStars": 5938,
"latestVersion": "2.0.0",
"license": "GPL-3.0 license",
"lastUpdated": "2024-06-05T06:57:13.485481Z",
"nodes": [
{
"name": "IPAdapterUnifiedLoader",
"displayName": "IPAdapterUnifiedLoader",
"category": "ipadapter",
"description": "Load IPAdapter, image encoder and CLIP vision in one node."
},
{
"name": "IPAdapterFaceID",
"displayName": "IPAdapterFaceID",
"category": "ipadapter/faceid",
"description": "Apply Face ID embeddings for high-fidelity portrait reference."
},
{
"name": "IPAdapterStyleComposition",
"displayName": "IPAdapterStyleComposition",
"category": "ipadapter",
"description": "Reference an image for style without copying its content."
},
{
"name": "IPAdapterAdvanced",
"displayName": "IPAdapterAdvanced",
"category": "ipadapter",
"description": "Full-control IPAdapter with masking, weights, and noise injection."
},
{
"name": "IPAdapterEncoder",
"displayName": "IPAdapterEncoder",
"category": "ipadapter/embeds",
"description": "Encode reference images into IPAdapter embeddings for reuse."
}
]
}
]
}

View File

@@ -0,0 +1,52 @@
export interface PackNode {
name: string
displayName: string
category: string
description?: string
deprecated?: boolean
experimental?: boolean
}
export interface Pack {
id: string
registryId?: string
displayName: string
description?: string
bannerUrl?: string
iconUrl?: string
repoUrl?: string
publisher?: {
id: string
name?: string
}
downloads?: number
githubStars?: number
latestVersion?: string
license?: string
lastUpdated?: string
supportedOs?: string[]
supportedAccelerators?: string[]
nodes: PackNode[]
}
export interface NodesSnapshot {
fetchedAt: string
packs: Pack[]
}
export function isNodesSnapshot(value: unknown): value is NodesSnapshot {
if (value === null || typeof value !== 'object') return false
const candidate = value as { fetchedAt?: unknown; packs?: unknown }
if (typeof candidate.fetchedAt !== 'string') return false
if (!Array.isArray(candidate.packs)) return false
return candidate.packs.every((pack) => {
if (pack === null || typeof pack !== 'object') return false
const p = pack as { id?: unknown; displayName?: unknown; nodes?: unknown }
return (
typeof p.id === 'string' &&
typeof p.displayName === 'string' &&
Array.isArray(p.nodes)
)
})
}

View File

@@ -3,7 +3,7 @@ export interface Role {
title: string
department: string
location: string
applyUrl: string
jobUrl: string
}
export interface Department {

File diff suppressed because it is too large Load Diff

View File

@@ -5,6 +5,7 @@ import '../styles/global.css'
import type { Locale } from '../i18n/translations'
import SiteFooter from '../components/common/SiteFooter.vue'
import SiteNav from '../components/common/SiteNav.vue'
import { escapeJsonLd } from '../utils/escapeJsonLd'
import { fetchGitHubStars, formatStarCount } from '../utils/github'
interface Props {
@@ -94,8 +95,11 @@ const websiteJsonLd = {
<meta name="twitter:image" content={ogImageURL.href} />
<!-- Structured Data -->
<script is:inline type="application/ld+json" set:html={JSON.stringify(organizationJsonLd)} />
<script is:inline type="application/ld+json" set:html={JSON.stringify(websiteJsonLd)} />
<script is:inline type="application/ld+json" set:html={escapeJsonLd(organizationJsonLd)} />
<script is:inline type="application/ld+json" set:html={escapeJsonLd(websiteJsonLd)} />
<slot name="head" />
<slot name="head" />
<!-- Google Tag Manager -->
{gtmEnabled && (

View File

@@ -0,0 +1,42 @@
---
import BaseLayout from '../../layouts/BaseLayout.astro'
import HeroSection from '../../components/cloud-nodes/HeroSection.vue'
import PackGridSection from '../../components/cloud-nodes/PackGridSection.vue'
import { t } from '../../i18n/translations'
import { loadPacksForBuild } from '../../utils/cloudNodes.build'
import { escapeJsonLd } from '../../utils/escapeJsonLd'
const packs = await loadPacksForBuild()
const siteBase = Astro.site ?? new URL('https://comfy.org')
const pageUrl = new URL('/cloud/supported-nodes', siteBase).href
const itemListJsonLd = {
'@context': 'https://schema.org',
'@type': 'ItemList',
name: 'Custom-node packs supported on Comfy Cloud',
url: pageUrl,
numberOfItems: packs.length,
itemListElement: packs.map((pack, index) => ({
'@type': 'ListItem',
position: index + 1,
url: new URL(`/cloud/supported-nodes/${pack.id}`, siteBase).href,
name: pack.displayName,
image: pack.bannerUrl || pack.iconUrl
}))
}
---
<BaseLayout
title={t('cloudNodes.meta.title', 'en')}
description={t('cloudNodes.meta.description', 'en')}
>
<script
is:inline
slot="head"
type="application/ld+json"
set:html={escapeJsonLd(itemListJsonLd)}
/>
<HeroSection client:visible />
<PackGridSection packs={packs} client:visible />
</BaseLayout>

View File

@@ -0,0 +1,48 @@
# Cloud Nodes Pages
Build-time catalog of custom-node packs preinstalled on Comfy Cloud. Index at `/cloud/supported-nodes`, per-pack details at `/cloud/supported-nodes/[pack]`, both also under `/zh-CN/`.
## Sources
- **Cloud `/api/object_info`** — authoritative list of nodes available on Comfy Cloud (auth: `WEBSITE_CLOUD_API_KEY`)
- **ComfyUI Custom Node Registry** ([dashboard](https://registry.comfy.org), API at `https://api.comfy.org/nodes`) — public pack metadata (banner, icon, description, downloads, stars, license, version, repo, publisher)
The registry is the same one the in-app Manager dialog reads from. For reference and additional reading, see the existing client wrappers in `src/`:
- [`src/services/comfyRegistryService.ts`](../../../../../../src/services/comfyRegistryService.ts) — typed wrappers around `/nodes`, `/nodes/search`, `/nodes/{id}`, `/nodes/{id}/versions/{version}/comfy-nodes`, etc.
- [`src/stores/comfyRegistryStore.ts`](../../../../../../src/stores/comfyRegistryStore.ts) — cached store + `getPacksByIds` batch helper
- [`packages/registry-types/src/comfyRegistryTypes.ts`](../../../../../../packages/registry-types/src/comfyRegistryTypes.ts) — generated OpenAPI types
- Public docs: <https://docs.comfy.org/registry>
## Build pipeline
| File | Role |
| ----------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------- |
| [`pages/cloud/supported-nodes.astro`](./supported-nodes.astro) and [`[pack].astro`](./supported-nodes/%5Bpack%5D.astro) | Page shells (and `zh-CN` twins) |
| [`utils/cloudNodes.build.ts`](../../../utils/cloudNodes.build.ts) | `loadPacksForBuild()` shared by index + detail routes |
| [`utils/cloudNodes.ts`](../../../utils/cloudNodes.ts) | Cloud `object_info` fetcher with retry, sanitization, snapshot fallback |
| [`utils/cloudNodes.registry.ts`](../../../utils/cloudNodes.registry.ts) | Registry enrichment (batches of 50, soft-fail) |
| [`utils/cloudNodes.ci.ts`](../../../utils/cloudNodes.ci.ts) | GitHub Actions annotations + step summary |
| [`utils/escapeJsonLd.ts`](../../../utils/escapeJsonLd.ts) | XSS-safe `<script type="application/ld+json">` serializer |
| [`composables/useFilteredPacks.ts`](../../../composables/useFilteredPacks.ts) | Search + sort logic for the index page |
| [`composables/useNodesByCategory.ts`](../../../composables/useNodesByCategory.ts) | Group nodes by category, alphabetized |
| [`scripts/refresh-cloud-nodes-snapshot.ts`](../../../../scripts/refresh-cloud-nodes-snapshot.ts) | Manual `pnpm cloud-nodes:refresh-snapshot` |
| [`packages/object-info-parser`](../../../../../../packages/object-info-parser) | Shared Zod schemas, classifier, `sanitizeUserContent` |
## Key invariants
- **Sanitization is mandatory.** The cloud `/api/object_info` endpoint mixes the calling user's uploaded files into combo input lists; `sanitizeUserContent()` strips them before the snapshot is written. Tests in [`packages/object-info-parser/src/__tests__/sanitizeUserContent.test.ts`](../../../../../../packages/object-info-parser/src/__tests__/sanitizeUserContent.test.ts).
- **All remote data is Zod-validated.** `cloudNodes.schema.ts` validates the cloud envelope; `@comfyorg/object-info-parser` validates each node def; `cloudNodes.registry.ts` validates the registry response shape with a passthrough Zod schema (defense-in-depth on top of the generated OpenAPI types). The fetcher never trusts a network response.
- **JSON-LD must be escaped.** Pack metadata is registry-controlled; route every `<script type="application/ld+json">` payload through `escapeJsonLd()`.
- **Index and detail share one fetch.** Both call `loadPacksForBuild()` so the static routes can never diverge from the rendered list.
- **No `PUBLIC_` prefix on env vars.** Astro inlines `PUBLIC_*` into the client bundle; build-time secrets never start with that prefix.
## Why `Pack` is a domain projection, not the raw registry `Node`
`apps/website/src/data/cloudNodes.ts` defines its own `Pack` and `PackNode` types instead of re-exporting `components['schemas']['Node']` from `@comfyorg/registry-types`. That is intentional:
- A `Pack` joins **two upstream sources** — the cloud `object_info` shape (which has `python_module`, `category`, `display_name`, etc.) and the registry `Node` shape (which has `banner_url`, `downloads`, `github_stars`, etc.). Neither shape on its own captures both.
- A `Pack` is **post-sanitization**. Combo input lists, user-uploaded filenames, and any other build-time-stripped data are guaranteed gone by the time the type appears. The raw `Node` carries fields we never expose.
- A `Pack` adds **safe-URL invariants** (banner / icon / repo all flow through `safeExternalUrl()` before becoming part of the type), which the registry's optional `string` fields don't encode.
The `Pack`/`PackNode` types live next to the snapshot they describe; the validated build-time projections feed Astro's static routes directly. The OpenAPI-generated `Node` type is still consumed inside `cloudNodes.registry.ts` as `RegistryPack` for the upstream fetch.

View File

@@ -0,0 +1,63 @@
---
import type { GetStaticPaths } from 'astro'
import type { Pack } from '../../../data/cloudNodes'
import PackDetail from '../../../components/cloud-nodes/PackDetail.vue'
import BaseLayout from '../../../layouts/BaseLayout.astro'
import { t } from '../../../i18n/translations'
import { loadPacksForBuild } from '../../../utils/cloudNodes.build'
import { escapeJsonLd } from '../../../utils/escapeJsonLd'
export const getStaticPaths: GetStaticPaths = async () => {
const packs = await loadPacksForBuild()
return packs.map((pack) => ({
params: { pack: pack.id },
props: { pack }
}))
}
const { pack } = Astro.props as { pack: Pack }
const fallbackDescription = t('cloudNodes.card.unavailableDescription', 'en')
const description = pack.description || fallbackDescription
const title = t('cloudNodes.detail.metaTitle', 'en').replace(
'{pack}',
pack.displayName
)
const metaDescription = t('cloudNodes.detail.metaDescription', 'en')
.replace('{pack}', pack.displayName)
.replace('{nodeCount}', String(pack.nodes.length))
.replace('{description}', description)
const siteBase = Astro.site ?? new URL('https://comfy.org')
const pageUrl = new URL(`/cloud/supported-nodes/${pack.id}`, siteBase).href
const softwareJsonLd = {
'@context': 'https://schema.org',
'@type': 'SoftwareApplication',
name: pack.displayName,
applicationCategory: 'DeveloperApplication',
applicationSubCategory: 'ComfyUI custom-node pack',
operatingSystem: 'Comfy Cloud (managed)',
url: pageUrl,
description,
image: pack.bannerUrl || pack.iconUrl,
softwareVersion: pack.latestVersion,
license: pack.license,
codeRepository: pack.repoUrl,
author: pack.publisher?.name
? { '@type': 'Person', name: pack.publisher.name }
: undefined,
offers: { '@type': 'Offer', price: 0, priceCurrency: 'USD' }
}
---
<BaseLayout title={title} description={metaDescription} ogImage={pack.bannerUrl}>
<script
is:inline
slot="head"
type="application/ld+json"
set:html={escapeJsonLd(softwareJsonLd)}
/>
<PackDetail pack={pack} />
</BaseLayout>

View File

@@ -121,6 +121,7 @@ const breadcrumbJsonLd = {
<ArcadeEmbed
arcadeId={demo.arcadeId}
title={title}
aspectRatio={demo.aspectRatio}
client:load
/>

View File

@@ -0,0 +1,185 @@
---
import type { GetStaticPaths } from 'astro'
import BaseLayout from '../../../layouts/BaseLayout.astro'
import ModelHeroSection from '../../../components/models/ModelHeroSection.vue'
import { models, getModelBySlug } from '../../../config/models'
import { t } from '../../../i18n/translations'
export const getStaticPaths: GetStaticPaths = () => {
return models.map((model) => ({
params: { slug: model.slug }
}))
}
const { slug } = Astro.params
const model = getModelBySlug(slug as string)!
if (model.canonicalSlug) {
return Astro.redirect(`/p/supported-models/${model.canonicalSlug}`, 301)
}
const { displayName } = model
const canonicalURL = new URL(`/p/supported-models/${model.slug}`, Astro.site)
const dirDescriptions: Record<string, string> = {
diffusion_models: 'a diffusion model that generates images or video from text and image prompts',
checkpoints: 'an all-in-one checkpoint model that bundles a diffusion model, text encoder, and VAE',
loras: 'a LoRA (Low-Rank Adaptation) that fine-tunes an existing model for a specific style or subject',
controlnet: 'a ControlNet that steers image generation using structural guides like depth maps, edges, or poses',
clip_vision: 'a CLIP Vision encoder that converts images into embeddings for conditioning or style transfer',
vae: 'a VAE (Variational Autoencoder) that encodes and decodes latent representations',
text_encoders: 'a text encoder that converts prompts into embeddings used to guide generation',
audio_encoders: 'an audio encoder that converts audio into embeddings for audio-conditioned generation',
upscale_models: 'an upscale model that increases image resolution while preserving or enhancing detail',
latent_upscale_models: 'a latent upscale model that refines latents at higher resolution before decoding',
style_models: 'a style model that transfers artistic style onto generated images',
model_patches: 'a model patch that modifies or extends the behavior of an existing base model',
partner_nodes: 'a cloud API model accessible through ComfyUI partner nodes without local hardware requirements'
}
const dirDesc = dirDescriptions[model.directory] ?? 'an AI model'
const whatIsDescription = `${displayName} is ${dirDesc}. You can run it locally in ComfyUI with full control over every parameter, or access it through Comfy Cloud. ComfyUI's node-based workflow editor lets you connect ${displayName} with ControlNets, LoRAs, upscalers, and custom nodes to build any pipeline you need. There are ${model.workflowCount} community workflow templates using ${displayName} on Comfy Hub, ready to load and customize.`
const softwareAppJsonLd = {
'@context': 'https://schema.org',
'@type': 'SoftwareApplication',
name: displayName,
applicationCategory: 'MultimediaApplication',
operatingSystem: 'Any',
url: canonicalURL.href,
author: {
'@type': 'Organization',
name: 'Comfy Org',
url: 'https://comfy.org'
}
}
const breadcrumbJsonLd = {
'@context': 'https://schema.org',
'@type': 'BreadcrumbList',
itemListElement: [
{
'@type': 'ListItem',
position: 1,
name: t('models.breadcrumb.home'),
item: 'https://comfy.org'
},
{
'@type': 'ListItem',
position: 2,
name: t('models.breadcrumb.models'),
item: 'https://comfy.org/p/supported-models'
},
{
'@type': 'ListItem',
position: 3,
name: displayName
}
]
}
const faqJsonLd = {
'@context': 'https://schema.org',
'@type': 'FAQPage',
mainEntity: [
{
'@type': 'Question',
name: `What is ${displayName}?`,
acceptedAnswer: {
'@type': 'Answer',
text: whatIsDescription
}
},
{
'@type': 'Question',
name: `How do I use ${displayName} in ComfyUI?`,
acceptedAnswer: {
'@type': 'Answer',
text: model.docsUrl
? `Follow the step-by-step tutorial at ${model.docsUrl}. You can also load any of the ${model.workflowCount} community workflow templates that use ${displayName} directly in ComfyUI.`
: `Open ComfyUI and browse the ${model.workflowCount} community workflow templates that use ${displayName}. Load one as a starting point, then customize the nodes and parameters to fit your use case.`
}
},
{
'@type': 'Question',
name: `How many ComfyUI workflows use ${displayName}?`,
acceptedAnswer: {
'@type': 'Answer',
text: `There are ${model.workflowCount} community workflow templates that use ${displayName} on Comfy Hub. Each template is ready to run in ComfyUI and can be customized to suit your project.`
}
},
{
'@type': 'Question',
name: `Is ${displayName} free to use in ComfyUI?`,
acceptedAnswer: {
'@type': 'Answer',
text: `ComfyUI is free and open source. ${model.huggingFaceUrl ? `${displayName} weights are available to download from Hugging Face.` : `${displayName} is available as a cloud API through Comfy Cloud.`} You only pay for compute when running on Comfy Cloud; local inference on your own hardware is always free.`
}
}
]
}
const pageTitle = `${displayName} in ComfyUI`
const pageDescription = `Run ${displayName} in ComfyUI with full parameter control. ${model.workflowCount} community workflow templates, step-by-step tutorials, and free local inference.`
---
<BaseLayout
title={`${pageTitle} — Comfy`}
description={pageDescription}
ogImage={model.thumbnailUrl}
>
<Fragment slot="head">
<script
is:inline
type="application/ld+json"
set:html={JSON.stringify(softwareAppJsonLd)}
/>
<script
is:inline
type="application/ld+json"
set:html={JSON.stringify(breadcrumbJsonLd)}
/>
<script
is:inline
type="application/ld+json"
set:html={JSON.stringify(faqJsonLd)}
/>
</Fragment>
<ModelHeroSection
displayName={displayName}
huggingFaceUrl={model.huggingFaceUrl}
docsUrl={model.docsUrl}
blogUrl={model.blogUrl}
hubSlug={model.hubSlug}
workflowCount={model.workflowCount}
directory={model.directory}
/>
<section class="mx-auto max-w-7xl px-6 py-16 lg:px-8">
<h2 class="text-2xl font-bold text-primary-comfy-canvas lg:text-3xl">
{t('models.whatIs.heading').replace('{name}', displayName)}
</h2>
<p class="mt-4 max-w-3xl text-base/relaxed text-primary-comfy-canvas/70 lg:text-lg/relaxed">
{whatIsDescription}
</p>
{model.docsUrl && (
<a
href={model.docsUrl}
target="_blank"
rel="noopener noreferrer"
class="mt-6 inline-block text-primary-comfy-yellow hover:underline"
>
{t('models.whatIs.tutorialLink')}
</a>
)}
</section>
<section>
{/* TODO: workflow gallery — pulls from hub API */}
</section>
<section>
{/* TODO: shared HowComfyWorks component */}
</section>
</BaseLayout>

View File

@@ -0,0 +1,68 @@
---
import BaseLayout from '../../../layouts/BaseLayout.astro'
import { models } from '../../../config/models'
import { t } from '../../../i18n/translations'
const title = t('models.index.title')
const subtitle = t('models.index.subtitle')
const dirLabel: Record<string, string> = {
diffusion_models: 'Diffusion',
checkpoints: 'Checkpoint',
loras: 'LoRA',
controlnet: 'ControlNet',
clip_vision: 'CLIP Vision',
model_patches: 'Patch',
vae: 'VAE',
text_encoders: 'Text Encoder',
audio_encoders: 'Audio Encoder',
latent_upscale_models: 'Latent Upscale',
upscale_models: 'Upscale',
style_models: 'Style',
partner_nodes: 'Partner Node'
}
---
<BaseLayout
title={`${title} — Comfy`}
description={subtitle}
>
<div class="mx-auto max-w-7xl px-6 py-16 lg:px-8 lg:py-24">
<header class="mb-12">
<p
class="mb-2 text-sm font-medium uppercase tracking-widest text-primary-comfy-yellow"
>
{t('models.hero.eyebrow')}
</p>
<h1 class="text-4xl font-bold text-primary-comfy-canvas lg:text-6xl">
{title}
</h1>
<p class="mt-4 max-w-2xl text-lg text-primary-comfy-canvas/70">
{subtitle}
</p>
</header>
<ul class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{models.map((model) => (
<li>
<a
href={`/p/supported-models/${model.slug}`}
class="flex h-full flex-col gap-3 rounded-xl border border-white/10 p-5 transition-colors hover:border-white/25 hover:bg-white/5"
>
<div class="flex items-start justify-between gap-2">
<span class="text-base font-semibold leading-tight text-primary-comfy-canvas">
{model.displayName}
</span>
<span class="shrink-0 rounded-full bg-white/10 px-2 py-0.5 text-xs font-medium text-primary-comfy-canvas/70">
{dirLabel[model.directory] ?? model.directory}
</span>
</div>
<p class="mt-auto text-xs text-primary-comfy-canvas/50">
{model.workflowCount} workflows
</p>
</a>
</li>
))}
</ul>
</div>
</BaseLayout>

View File

@@ -2,13 +2,17 @@
import BaseLayout from '../layouts/BaseLayout.astro'
import ContentSection from '../components/common/ContentSection.vue'
import HeroSection from '../components/legal/HeroSection.vue'
import { t } from '../i18n/translations'
---
<BaseLayout
title="Terms of Service — Comfy"
description="Terms of Service for ComfyUI and related Comfy services."
description="Terms of Service governing use of the Comfy Products, including Comfy Cloud, Comfy API, and Comfy Enterprise."
noindex
>
<HeroSection title="Terms of Service" />
<p class="text-primary-warm-gray mt-2 text-center text-sm">
{t('tos.effectiveDateLabel')}: {t('tos.effectiveDate')}
</p>
<ContentSection prefix="tos" client:load />
</BaseLayout>

View File

@@ -0,0 +1,42 @@
---
import BaseLayout from '../../../layouts/BaseLayout.astro'
import HeroSection from '../../../components/cloud-nodes/HeroSection.vue'
import PackGridSection from '../../../components/cloud-nodes/PackGridSection.vue'
import { t } from '../../../i18n/translations'
import { loadPacksForBuild } from '../../../utils/cloudNodes.build'
import { escapeJsonLd } from '../../../utils/escapeJsonLd'
const packs = await loadPacksForBuild()
const siteBase = Astro.site ?? new URL('https://comfy.org')
const pageUrl = new URL('/zh-CN/cloud/supported-nodes', siteBase).href
const itemListJsonLd = {
'@context': 'https://schema.org',
'@type': 'ItemList',
name: 'Comfy Cloud 支持的自定义节点包',
url: pageUrl,
numberOfItems: packs.length,
itemListElement: packs.map((pack, index) => ({
'@type': 'ListItem',
position: index + 1,
url: new URL(`/zh-CN/cloud/supported-nodes/${pack.id}`, siteBase).href,
name: pack.displayName,
image: pack.bannerUrl || pack.iconUrl
}))
}
---
<BaseLayout
title={t('cloudNodes.meta.title', 'zh-CN')}
description={t('cloudNodes.meta.description', 'zh-CN')}
>
<script
is:inline
slot="head"
type="application/ld+json"
set:html={escapeJsonLd(itemListJsonLd)}
/>
<HeroSection locale="zh-CN" client:visible />
<PackGridSection locale="zh-CN" packs={packs} client:visible />
</BaseLayout>

View File

@@ -0,0 +1,63 @@
---
import type { GetStaticPaths } from 'astro'
import type { Pack } from '../../../../data/cloudNodes'
import PackDetail from '../../../../components/cloud-nodes/PackDetail.vue'
import BaseLayout from '../../../../layouts/BaseLayout.astro'
import { t } from '../../../../i18n/translations'
import { loadPacksForBuild } from '../../../../utils/cloudNodes.build'
import { escapeJsonLd } from '../../../../utils/escapeJsonLd'
export const getStaticPaths: GetStaticPaths = async () => {
const packs = await loadPacksForBuild()
return packs.map((pack) => ({
params: { pack: pack.id },
props: { pack }
}))
}
const { pack } = Astro.props as { pack: Pack }
const fallbackDescription = t('cloudNodes.card.unavailableDescription', 'zh-CN')
const description = pack.description || fallbackDescription
const title = t('cloudNodes.detail.metaTitle', 'zh-CN').replace(
'{pack}',
pack.displayName
)
const metaDescription = t('cloudNodes.detail.metaDescription', 'zh-CN')
.replace('{pack}', pack.displayName)
.replace('{nodeCount}', String(pack.nodes.length))
.replace('{description}', description)
const siteBase = Astro.site ?? new URL('https://comfy.org')
const pageUrl = new URL(`/zh-CN/cloud/supported-nodes/${pack.id}`, siteBase).href
const softwareJsonLd = {
'@context': 'https://schema.org',
'@type': 'SoftwareApplication',
name: pack.displayName,
applicationCategory: 'DeveloperApplication',
applicationSubCategory: 'ComfyUI custom-node pack',
operatingSystem: 'Comfy Cloud (managed)',
url: pageUrl,
description,
image: pack.bannerUrl || pack.iconUrl,
softwareVersion: pack.latestVersion,
license: pack.license,
codeRepository: pack.repoUrl,
author: pack.publisher?.name
? { '@type': 'Person', name: pack.publisher.name }
: undefined,
offers: { '@type': 'Offer', price: 0, priceCurrency: 'USD' }
}
---
<BaseLayout title={title} description={metaDescription} ogImage={pack.bannerUrl}>
<script
is:inline
slot="head"
type="application/ld+json"
set:html={escapeJsonLd(softwareJsonLd)}
/>
<PackDetail pack={pack} locale="zh-CN" />
</BaseLayout>

View File

@@ -122,6 +122,7 @@ const breadcrumbJsonLd = {
<ArcadeEmbed
arcadeId={demo.arcadeId}
title={title}
aspectRatio={demo.aspectRatio}
locale="zh-CN"
client:load
/>

View File

@@ -1,14 +0,0 @@
---
import BaseLayout from '../../layouts/BaseLayout.astro'
import ContentSection from '../../components/common/ContentSection.vue'
import HeroSection from '../../components/legal/HeroSection.vue'
---
<BaseLayout
title="服务条款 — Comfy"
description="ComfyUI 及相关 Comfy 服务的服务条款。"
noindex
>
<HeroSection title="服务条款" />
<ContentSection prefix="tos" locale="zh-CN" client:load />
</BaseLayout>

View File

@@ -101,13 +101,13 @@
transform: translateX(0);
}
100% {
transform: translateX(-50%);
transform: translateX(calc(-100% - var(--marquee-gap, 0px)));
}
}
@keyframes marquee-reverse {
0% {
transform: translateX(-50%);
transform: translateX(calc(-100% - var(--marquee-gap, 0px)));
}
100% {
transform: translateX(0);
@@ -115,11 +115,15 @@
}
@utility animate-marquee {
animation: marquee 30s linear infinite;
@media (prefers-reduced-motion: no-preference) {
animation: marquee 30s linear infinite;
}
}
@utility animate-marquee-reverse {
animation: marquee-reverse 30s linear infinite;
@media (prefers-reduced-motion: no-preference) {
animation: marquee-reverse 30s linear infinite;
}
}
@keyframes ripple-effect {

View File

@@ -22,7 +22,7 @@ function baseSnapshot(): RolesSnapshot {
title: 'Design Engineer',
department: 'Engineering',
location: 'San Francisco',
applyUrl: 'https://jobs.ashbyhq.com/comfy-org/x'
jobUrl: 'https://jobs.ashbyhq.com/comfy-org/x'
}
]
}
@@ -50,7 +50,7 @@ function freshOutcome(droppedCount = 0): FetchOutcome {
title: 'Design Engineer',
department: 'Engineering',
location: 'San Francisco',
applyUrl: 'https://jobs.ashbyhq.com/comfy-org/x'
jobUrl: 'https://jobs.ashbyhq.com/comfy-org/x'
}
]
}

View File

@@ -40,7 +40,7 @@ function makeSnapshot(roleCount = 2): RolesSnapshot {
title: `Snapshot Role ${i}`,
department: 'Engineering',
location: 'San Francisco',
applyUrl: `https://jobs.ashbyhq.com/comfy-org/snapshot-${i}`
jobUrl: `https://jobs.ashbyhq.com/comfy-org/snapshot-${i}`
}))
return {
fetchedAt: '2026-04-01T00:00:00.000Z',
@@ -85,26 +85,7 @@ describe('fetchRolesForBuild', () => {
if (outcome.status !== 'fresh') return
expect(outcome.droppedCount).toBe(0)
expect(outcome.snapshot.departments).toHaveLength(1)
expect(outcome.snapshot.departments[0]!.roles[0]!.applyUrl).toMatch(
/design-engineer\/apply$/
)
})
it('falls back to jobUrl when applyUrl is missing and keeps the role', async () => {
const job = validJob()
delete (job as Record<string, unknown>).applyUrl
const fetchImpl = vi.fn(async () =>
response({ apiVersion: '1', jobs: [job] })
)
const outcome = await fetchRolesForBuild({
apiKey: KEY,
boardName: BOARD,
baseUrl: BASE_URL,
fetchImpl: fetchImpl as unknown as typeof fetch
})
expect(outcome.status).toBe('fresh')
if (outcome.status !== 'fresh') return
expect(outcome.snapshot.departments[0]!.roles[0]!.applyUrl).toBe(
expect(outcome.snapshot.departments[0]!.roles[0]!.jobUrl).toBe(
'https://jobs.ashbyhq.com/comfy-org/design-engineer'
)
})

View File

@@ -243,13 +243,13 @@ function groupByDepartment(jobs: readonly AshbyJobPosting[]): Department[] {
}
function toDomainRole(job: AshbyJobPosting, department: string): Role {
const applyUrl = job.applyUrl ?? job.jobUrl
const jobUrl = job.jobUrl
return {
id: createHash('sha1').update(applyUrl).digest('hex').slice(0, 16),
id: createHash('sha1').update(jobUrl).digest('hex').slice(0, 16),
title: job.title,
department: capitalize(department),
location: (job.location ?? '').trim() || DEFAULT_LOCATION,
applyUrl
jobUrl
}
}

View File

@@ -0,0 +1,128 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { FetchOutcome } from './cloudNodes'
import type { NodesSnapshot } from '../data/cloudNodes'
const fetchCloudNodesMock = vi.hoisted(() =>
vi.fn<() => Promise<FetchOutcome>>()
)
const reportCloudNodesOutcomeMock = vi.hoisted(() => vi.fn())
vi.mock('./cloudNodes', () => ({
fetchCloudNodesForBuild: fetchCloudNodesMock
}))
vi.mock('./cloudNodes.ci', () => ({
reportCloudNodesOutcome: reportCloudNodesOutcomeMock
}))
import { loadPacksForBuild } from './cloudNodes.build'
const SNAPSHOT: NodesSnapshot = {
fetchedAt: '2026-04-01T00:00:00.000Z',
packs: [
{
id: 'snapshot-pack',
displayName: 'Snapshot Pack',
nodes: [
{ name: 'SnapshotNode', displayName: 'Snapshot Node', category: 'x' }
]
}
]
}
describe('loadPacksForBuild', () => {
const savedVercelEnv = process.env.VERCEL_ENV
beforeEach(() => {
fetchCloudNodesMock.mockReset()
reportCloudNodesOutcomeMock.mockReset()
delete process.env.VERCEL_ENV
})
afterEach(() => {
if (savedVercelEnv === undefined) {
delete process.env.VERCEL_ENV
return
}
process.env.VERCEL_ENV = savedVercelEnv
})
it('returns packs when fetch is fresh', async () => {
fetchCloudNodesMock.mockResolvedValue({
status: 'fresh',
snapshot: SNAPSHOT,
droppedCount: 0,
droppedNodes: []
})
const packs = await loadPacksForBuild()
expect(packs).toBe(SNAPSHOT.packs)
expect(reportCloudNodesOutcomeMock).toHaveBeenCalledTimes(1)
})
it('returns snapshot packs when outcome is stale outside production', async () => {
fetchCloudNodesMock.mockResolvedValue({
status: 'stale',
snapshot: SNAPSHOT,
reason: 'missing WEBSITE_CLOUD_API_KEY'
})
const packs = await loadPacksForBuild()
expect(packs).toBe(SNAPSHOT.packs)
expect(reportCloudNodesOutcomeMock).toHaveBeenCalledTimes(1)
})
it('returns snapshot packs when outcome is stale on Vercel preview', async () => {
process.env.VERCEL_ENV = 'preview'
fetchCloudNodesMock.mockResolvedValue({
status: 'stale',
snapshot: SNAPSHOT,
reason: 'HTTP 503'
})
const packs = await loadPacksForBuild()
expect(packs).toBe(SNAPSHOT.packs)
expect(reportCloudNodesOutcomeMock).toHaveBeenCalledTimes(1)
})
it('throws when outcome is stale on Vercel production', async () => {
process.env.VERCEL_ENV = 'production'
fetchCloudNodesMock.mockResolvedValue({
status: 'stale',
snapshot: SNAPSHOT,
reason: 'missing WEBSITE_CLOUD_API_KEY'
})
await expect(loadPacksForBuild()).rejects.toThrow(
/stale data in a production build/
)
await expect(loadPacksForBuild()).rejects.toThrow(
/missing WEBSITE_CLOUD_API_KEY/
)
})
it('throws when outcome is failed regardless of environment', async () => {
fetchCloudNodesMock.mockResolvedValue({
status: 'failed',
reason: 'network error: ECONNREFUSED'
})
await expect(loadPacksForBuild()).rejects.toThrow(
/Cloud nodes fetch failed and no snapshot is available/
)
await expect(loadPacksForBuild()).rejects.toThrow(/ECONNREFUSED/)
})
it('still reports outcome before throwing on stale-in-production', async () => {
process.env.VERCEL_ENV = 'production'
fetchCloudNodesMock.mockResolvedValue({
status: 'stale',
snapshot: SNAPSHOT,
reason: 'HTTP 503'
})
await expect(loadPacksForBuild()).rejects.toThrow()
expect(reportCloudNodesOutcomeMock).toHaveBeenCalledTimes(1)
})
})

View File

@@ -0,0 +1,45 @@
import type { Pack } from '../data/cloudNodes'
import { fetchCloudNodesForBuild } from './cloudNodes'
import { reportCloudNodesOutcome } from './cloudNodes.ci'
const REFRESH_HINT =
'Run `pnpm --filter @comfyorg/website cloud-nodes:refresh-snapshot` locally and commit the snapshot, ' +
'or re-run the `Release: Website` workflow with a valid WEBSITE_CLOUD_API_KEY.'
function isProductionBuild(): boolean {
return process.env.VERCEL_ENV === 'production'
}
/**
* Resolve the list of packs to render at build time.
*
* Used by both the index page and the per-pack detail pages so that the
* static index and the static detail routes are always derived from the
* same source. `fetchCloudNodesForBuild` is memoized on a module-level
* `inflight` promise, so repeated calls in the same build process share a
* single network round-trip and the same outcome.
*
* Production builds (VERCEL_ENV=production) fail hard on a stale outcome
* to prevent silently shipping out-of-date snapshot data. Preview and
* local builds continue to use the committed snapshot.
*/
export async function loadPacksForBuild(): Promise<Pack[]> {
const outcome = await fetchCloudNodesForBuild()
reportCloudNodesOutcome(outcome)
if (outcome.status === 'failed') {
throw new Error(
`Cloud nodes fetch failed and no snapshot is available. Reason: ${outcome.reason}. ${REFRESH_HINT}`
)
}
if (outcome.status === 'stale' && isProductionBuild()) {
throw new Error(
`Cloud nodes fetch returned stale data in a production build (VERCEL_ENV=production). ` +
`Reason: ${outcome.reason}. ${REFRESH_HINT}`
)
}
return outcome.snapshot.packs
}

View File

@@ -0,0 +1,121 @@
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { FetchOutcome } from './cloudNodes'
import type { NodesSnapshot } from '../data/cloudNodes'
import {
reportCloudNodesOutcome,
resetCloudNodesReporterForTests
} from './cloudNodes.ci'
function baseSnapshot(): NodesSnapshot {
return {
fetchedAt: new Date().toISOString(),
packs: [
{
id: 'pack-id',
displayName: 'Pack',
nodes: [
{
name: 'NodeClass',
displayName: 'Node Class',
category: 'misc'
}
]
}
]
}
}
function freshOutcome(droppedCount = 0): FetchOutcome {
return {
status: 'fresh',
droppedCount,
droppedNodes:
droppedCount === 0
? []
: [{ name: 'BadNode', reason: 'invalid display_name' }],
snapshot: baseSnapshot()
}
}
describe('reportCloudNodesOutcome', () => {
let writeSpy: ReturnType<typeof vi.spyOn>
let summaryDir: string
let summaryPath: string
const originalSummary = process.env.GITHUB_STEP_SUMMARY
beforeEach(() => {
resetCloudNodesReporterForTests()
writeSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true)
summaryDir = mkdtempSync(join(tmpdir(), 'cloud-nodes-summary-'))
summaryPath = join(summaryDir, 'summary.md')
writeFileSync(summaryPath, '')
process.env.GITHUB_STEP_SUMMARY = summaryPath
})
afterEach(() => {
writeSpy.mockRestore()
rmSync(summaryDir, { recursive: true, force: true })
if (originalSummary === undefined) delete process.env.GITHUB_STEP_SUMMARY
else process.env.GITHUB_STEP_SUMMARY = originalSummary
})
it('emits nothing on a clean fresh outcome', () => {
reportCloudNodesOutcome(freshOutcome(0))
expect(writeSpy).not.toHaveBeenCalled()
expect(readFileSync(summaryPath, 'utf8')).toContain('Fresh')
})
it('emits exactly one set of annotations across repeated calls', () => {
reportCloudNodesOutcome(freshOutcome(1))
reportCloudNodesOutcome(freshOutcome(1))
expect(writeSpy).toHaveBeenCalledTimes(1)
const annotation = writeSpy.mock.calls[0]?.[0] as string
expect(annotation).toContain(
'::warning title=Cloud nodes: dropped 1 invalid'
)
expect(readFileSync(summaryPath, 'utf8')).toContain('Dropped')
})
it('emits ::error for auth failures in a stale outcome', () => {
reportCloudNodesOutcome({
status: 'stale',
reason: 'HTTP 401 Unauthorized',
snapshot: baseSnapshot()
})
const annotation = writeSpy.mock.calls[0]?.[0] as string
expect(annotation).toContain(
'::error title=Cloud nodes authentication failed'
)
})
it('emits ::warning for missing-env stale outcomes', () => {
reportCloudNodesOutcome({
status: 'stale',
reason: 'missing WEBSITE_CLOUD_API_KEY',
snapshot: baseSnapshot()
})
const annotation = writeSpy.mock.calls[0]?.[0] as string
expect(annotation).toContain('::warning title=Cloud nodes integration')
})
it('emits ::error for a failed outcome and writes no fresh-only sections', () => {
reportCloudNodesOutcome({
status: 'failed',
reason: 'HTTP 500 Server Error'
})
const annotation = writeSpy.mock.calls[0]?.[0] as string
expect(annotation).toContain('::error title=Cloud nodes fetch failed')
expect(readFileSync(summaryPath, 'utf8')).toContain('Failed')
})
it('does not throw when GITHUB_STEP_SUMMARY is not set', () => {
delete process.env.GITHUB_STEP_SUMMARY
expect(() => reportCloudNodesOutcome(freshOutcome(0))).not.toThrow()
})
})

View File

@@ -0,0 +1,115 @@
import { appendFileSync } from 'node:fs'
import type { FetchOutcome } from './cloudNodes'
let hasReported = false
export function resetCloudNodesReporterForTests(): void {
hasReported = false
}
export function reportCloudNodesOutcome(outcome: FetchOutcome): void {
if (hasReported) return
hasReported = true
const lines = buildAnnotations(outcome)
for (const line of lines) {
process.stdout.write(`${line}\n`)
}
const summaryPath = process.env.GITHUB_STEP_SUMMARY
if (summaryPath) {
try {
appendFileSync(summaryPath, buildStepSummary(outcome))
} catch {
// Writing the summary is best-effort; do not fail the build if the
// runner's summary file is unavailable (e.g. local dev).
}
}
}
function buildAnnotations(outcome: FetchOutcome): string[] {
if (outcome.status === 'fresh') {
if (outcome.droppedCount === 0) return []
const nodeCount = outcome.droppedCount === 1 ? 'node' : 'nodes'
const drops = outcome.droppedNodes
.map((d) => {
const name = escapeAnnotation(d.name ? `"${d.name}"` : '(unnamed)')
const reason = escapeAnnotation(d.reason)
return ` - ${name}: ${reason}`
})
.join('%0A')
return [
`::warning title=Cloud nodes: dropped ${outcome.droppedCount} invalid ${nodeCount}::Dropped nodes:%0A${drops}%0A%0AAction items:%0A 1. Verify node definitions returned by cloud /api/object_info.%0A 2. If a valid node shape changed, update @comfyorg/object-info-parser/src/schemas/nodeDefSchema.ts and add tests.%0A 3. Dropped nodes are not shown on /cloud/supported-nodes until fixed.`
]
}
if (outcome.status === 'stale') {
return [staleAnnotation(outcome.reason)]
}
return [
`::error title=Cloud nodes fetch failed and no snapshot is available::Cannot build cloud nodes page without data.%0A%0AReason: ${escapeAnnotation(outcome.reason)}%0A%0AAction items:%0A 1. Run \`pnpm --filter @comfyorg/website cloud-nodes:refresh-snapshot\` locally with a valid WEBSITE_CLOUD_API_KEY.%0A 2. Commit apps/website/src/data/cloud-nodes.snapshot.json.%0A 3. Push and re-run CI.`
]
}
function staleAnnotation(reason: string): string {
const escaped = escapeAnnotation(reason)
if (reason.startsWith('missing ')) {
return `::warning title=Cloud nodes integration::${escaped}. Falling back to committed snapshot.%0A%0AAction items:%0A 1. If you're a contributor without key access, this is expected. The snapshot will be used.%0A 2. If this is CI, check that the \`WEBSITE_CLOUD_API_KEY\` secret exists in the repo and is referenced in .github/workflows/ci-website-build.yaml.`
}
if (reason.startsWith('HTTP 401') || reason.startsWith('HTTP 403')) {
return `::error title=Cloud nodes authentication failed::${escaped}. The WEBSITE_CLOUD_API_KEY is missing, invalid, or revoked. Build continues with the last-known-good snapshot.%0A%0AAction items:%0A 1. Verify the Cloud API key is active and scoped for /api/object_info.%0A 2. Update the \`WEBSITE_CLOUD_API_KEY\` secret in GitHub Actions and Vercel.%0A 3. Re-run this workflow.`
}
if (reason.startsWith('envelope')) {
return `::error title=Cloud nodes schema mismatch::${escaped}. The Cloud API contract likely changed. Build continues with the snapshot, but future updates will fail until schema is fixed.%0A%0AAction items:%0A 1. Check cloud/services/ingest/openapi.yaml for /api/object_info changes.%0A 2. Update apps/website/src/utils/cloudNodes.schema.ts and @comfyorg/object-info-parser schema as needed.`
}
return `::warning title=Cloud nodes API unavailable::${escaped}. Using last-known-good snapshot.%0A%0AAction items:%0A 1. Check cloud service health.%0A 2. Re-run this workflow once cloud.comfy.org is healthy.`
}
function escapeAnnotation(value: string): string {
return value.replace(/%/g, '%25').replace(/\r/g, '%0D').replace(/\n/g, '%0A')
}
function buildStepSummary(outcome: FetchOutcome): string {
const header = '## ☁️ Cloud nodes\n'
const rows: Array<[string, string]> = []
if (outcome.status === 'fresh') {
rows.push(['Status', '✅ Fresh (fetched from Cloud API)'])
rows.push(['Packs', String(outcome.snapshot.packs.length)])
rows.push([
'Nodes',
String(outcome.snapshot.packs.reduce((n, p) => n + p.nodes.length, 0))
])
rows.push(['Dropped', String(outcome.droppedCount)])
} else if (outcome.status === 'stale') {
rows.push(['Status', '⚠️ Stale (using snapshot — Cloud fetch failed)'])
rows.push(['Packs', String(outcome.snapshot.packs.length)])
rows.push([
'Nodes',
String(outcome.snapshot.packs.reduce((n, p) => n + p.nodes.length, 0))
])
rows.push(['Reason', outcome.reason])
rows.push(['Snapshot age', describeSnapshotAge(outcome.snapshot.fetchedAt)])
} else {
rows.push(['Status', '❌ Failed (no snapshot available)'])
rows.push(['Reason', outcome.reason])
}
const table =
'| | |\n|---|---|\n' +
rows.map(([k, v]) => `| **${k}** | ${v} |`).join('\n') +
'\n'
return `${header}${table}\n`
}
function describeSnapshotAge(fetchedAt: string): string {
const fetched = new Date(fetchedAt).getTime()
if (Number.isNaN(fetched)) return 'unknown'
const days = Math.floor((Date.now() - fetched) / 86_400_000)
if (days <= 0) return 'today'
if (days === 1) return '1 day'
return `${days} days`
}

View File

@@ -0,0 +1,144 @@
import { describe, expect, it, vi } from 'vitest'
import {
DEFAULT_REGISTRY_BASE_URL,
fetchRegistryPacks
} from './cloudNodes.registry'
function jsonResponse(
body: unknown,
init: Partial<ResponseInit> = {}
): Response {
return new Response(JSON.stringify(body), {
status: 200,
headers: { 'content-type': 'application/json' },
...init
})
}
describe('fetchRegistryPacks', () => {
it('requests node ids in batches of 50 with matching limit param', async () => {
const ids = Array.from({ length: 120 }, (_, i) => `pack-${i}`)
const fetchImpl = vi.fn(async (input: RequestInfo | URL) => {
const url = new URL(String(input))
const batchIds = url.searchParams.getAll('node_id')
return jsonResponse({
nodes: batchIds.map((id) => ({ id, name: id })),
total: batchIds.length,
page: 1,
limit: 50
})
})
const result = await fetchRegistryPacks(ids, {
fetchImpl: fetchImpl as typeof fetch
})
expect(fetchImpl).toHaveBeenCalledTimes(3)
expect(result.size).toBe(120)
const firstCallUrl = new URL(String(fetchImpl.mock.calls[0]?.[0]))
expect(firstCallUrl.origin).toBe(DEFAULT_REGISTRY_BASE_URL)
expect(firstCallUrl.pathname).toBe('/nodes')
expect(firstCallUrl.searchParams.getAll('node_id')).toHaveLength(50)
expect(firstCallUrl.searchParams.get('limit')).toBe('50')
const lastCallUrl = new URL(String(fetchImpl.mock.calls[2]?.[0]))
expect(lastCallUrl.searchParams.getAll('node_id')).toHaveLength(20)
expect(lastCallUrl.searchParams.get('limit')).toBe('20')
})
it('survives the server defaulting to a small page size (regression for missing limit)', async () => {
// Mock applies the server's pre-fix behavior: default limit=10 silently
// truncates batches with more node_id filters than the page size.
const ids = Array.from({ length: 30 }, (_, i) => `pack-${i}`)
const fetchImpl = vi.fn(async (input: RequestInfo | URL) => {
const url = new URL(String(input))
const requestedLimit = Number(url.searchParams.get('limit') ?? '10')
const batchIds = url.searchParams
.getAll('node_id')
.slice(0, requestedLimit)
return jsonResponse({
nodes: batchIds.map((id) => ({ id, name: id })),
total: batchIds.length,
page: 1,
limit: requestedLimit
})
})
const result = await fetchRegistryPacks(ids, {
fetchImpl: fetchImpl as typeof fetch
})
const enriched = [...result.values()].filter((pack) => pack !== null)
expect(enriched).toHaveLength(30)
})
it('accepts null values for optional registry fields and normalizes them to undefined', async () => {
const fetchImpl = vi.fn(async () =>
jsonResponse({
nodes: [
{
id: 'pack-with-nulls',
name: 'Pack With Nulls',
description: null,
icon: null,
banner_url: null,
supported_os: null,
supported_accelerators: null,
publisher: null,
latest_version: null,
downloads: 42
}
],
total: 1,
page: 1,
limit: 50
})
)
const result = await fetchRegistryPacks(['pack-with-nulls'], {
fetchImpl: fetchImpl as typeof fetch
})
const pack = result.get('pack-with-nulls')
expect(pack).not.toBeNull()
expect(pack?.downloads).toBe(42)
expect(pack?.description).toBeUndefined()
expect(pack?.supported_os).toBeUndefined()
expect(pack?.supported_accelerators).toBeUndefined()
expect(pack?.publisher).toBeUndefined()
expect(pack?.latest_version).toBeUndefined()
})
it('retries a failed batch once and then succeeds', async () => {
const fetchImpl = vi
.fn<(input: RequestInfo | URL, init?: RequestInit) => Promise<Response>>()
.mockResolvedValueOnce(new Response('{}', { status: 503 }))
.mockResolvedValueOnce(
jsonResponse({
nodes: [{ id: 'pack-1', name: 'Pack One' }],
total: 1,
page: 1,
limit: 50
})
)
const result = await fetchRegistryPacks(['pack-1'], {
fetchImpl: fetchImpl as unknown as typeof fetch
})
expect(fetchImpl).toHaveBeenCalledTimes(2)
expect(result.get('pack-1')?.name).toBe('Pack One')
})
it('returns an empty map when all batches fail (soft failure)', async () => {
const ids = ['pack-a', 'pack-b']
const fetchImpl = vi.fn(async () => new Response('{}', { status: 500 }))
const result = await fetchRegistryPacks(ids, {
fetchImpl: fetchImpl as typeof fetch
})
expect(fetchImpl).toHaveBeenCalledTimes(2)
expect(result.size).toBe(0)
})
})

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