Compare commits

..

55 Commits

Author SHA1 Message Date
Alexander Brown
efdbcb8afa Merge branch 'main' into glary/oxlint-func-style 2026-05-20 11:47:23 -07:00
AustinMroz
2717d59451 Fix reactivity of vue subgraph price badges (#12029)
When a subgraph contains partner nodes with price badges, those badges
are also displayed on the subgraphNode. The reactivity here was spotty:
The price badges would fail to display unless the user had navigated
into the subgraph on the current page load. Fixing this is performed in
2 steps:
- Firing a `node:property:changed` event when the badges contained in a
subgraph are updated
- Extending the reactivity updates so that badges update in vue mode
despite using the litegraph badge getter.

This PR also includes a minor styling tweak to fix text alignment on
price badges
| Before | After |
| ------ | ----- |
| <img width="360" alt="before"
src="https://github.com/user-attachments/assets/56a95cbe-12c9-43b0-8664-34e52b6415ac"
/> | <img width="360" alt="after"
src="https://github.com/user-attachments/assets/bf4a0d81-21e4-4afc-946e-eba5967f1715"
/>|

Resolves FE-346

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12029-Fix-reactivity-of-vue-subgraph-price-badges-3586d73d3650813cb12fe265090940e4)
by [Unito](https://www.unito.io)
2026-05-20 11:22:42 -07:00
Glary-Bot
a95b9ab3fb fix: convert two arrow expressions introduced after main merge
Two new const-arrow expressions landed via the merged subgraph IO PR
in browser_tests/tests/subgraph/subgraphSlots.spec.ts; convert them
to function declarations to satisfy func-style.
2026-05-20 17:55:13 +00:00
Alexander Brown
96ba23e2f3 Merge branch 'main' into glary/oxlint-func-style 2026-05-20 10:38:09 -07:00
AustinMroz
d63b0f05bf Subgraph io fixes (#12281)
Fixes 3 different bugs when making links to and from subgraph IO from
vue nodes
- When dragging a link from a node to a subgraph IO, there is no
feedback if a slot is not a valid connection target or if a slot is
actively hovered
- When a link is made from a subgraph IO to a node, the reactivity is
not triggered on the node to indicate a change of link state.
- When dragging a link from a subgraph IO to a node, the link would not
snap to the valid connection targets on nodes
- The fix for this one is not as thorough as I would like. It only
allows connections to the slot, not connections to the hovered widget.
We have two deeply disconnected linking systems and properly reconciling
them would be a multi-week project.

Resolves FE-561

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12281-Subgraph-io-fixes-3606d73d365081089f7ef19331c6d70a)
by [Unito](https://www.unito.io)
2026-05-20 10:26:47 -07:00
Glary-Bot
d6291de715 fix: preserve type narrowing for nested fn in linkInteraction spec
Codemod converted const-arrow findIndex into a function declaration,
which loses the outer 'if (!node) return null' narrowing of node when
the inner function captures it. Capture into a typed local first.
2026-05-20 17:15:12 +00:00
Terry Jia
cd2f4677c2 FE-719 feat(load3d): add FBX export support (#12323)
## Summary
implement fbx export, using our own lib fbx-exporter-three

## Screenshots (if applicable)


https://github.com/user-attachments/assets/80012338-d065-4a00-a9a0-0a2e73d67db4

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12323-FE-719-feat-load3d-add-FBX-export-support-3656d73d365081ef901ffe880ae9568a)
by [Unito](https://www.unito.io)
2026-05-20 06:52:56 -04:00
Glary-Bot
df578b198b fix: drop unused GetStaticPaths imports from astro pages
Codemod converted typed const-arrow getStaticPaths into untyped function
declarations, leaving the GetStaticPaths import unused in 7 astro pages.
2026-05-20 05:58:13 +00:00
Glary-Bot
3e56bc925f lint: enable oxlint func-style rule and convert function expressions
Enables eslint/func-style in oxlint with declaration mode to enforce
function declarations over function expressions and arrow expressions
assigned to variables. Vendored litegraph is excluded via override.

Converts existing function expressions and variable-initialized arrow
functions to function declarations across src/, browser_tests/, apps/,
packages/, and scripts/. Adjusts a handful of let-reassignable callback
placeholders, narrowed variable patterns, and typed widget constructors
to keep type safety intact.

Pre-existing type-aware oxlint errors (no-console, no-floating-promises,
no-explicit-any) are unchanged from main.
2026-05-20 05:44:45 +00:00
Hunter
38fed22140 feat: env-var override for staging api/platform base URLs (#12221)
## Summary

Allow staging api/platform base URLs to be overridden by env vars so
non-cloud builds can target an alternate backend without source edits.

## Changes

- **What**: `BUILD_TIME_API_BASE_URL` / `BUILD_TIME_PLATFORM_BASE_URL`
in `src/config/comfyApi.ts` now read
`import.meta.env.VITE_STAGING_API_BASE_URL` /
`VITE_STAGING_PLATFORM_BASE_URL` first, falling back to the existing
`stagingapi.comfy.org` / `stagingplatform.comfy.org` constants. Vars
typed in `src/vite-env.d.ts` and documented in `.env_example`.
- **Breaking**: None. Defaults unchanged. The cloud-runtime override
path via the features endpoint (`comfy_api_base_url`,
`comfy_platform_base_url` in `RemoteConfig`) is untouched.

## Review Focus

Override only applies to the non-prod branch of the build-time ternary,
so prod builds (`USE_PROD_CONFIG=true`) cannot be redirected. Cloud
builds continue to resolve URLs at runtime via `remoteConfig` regardless
of these env vars.

## Note

Pre-commit `pnpm typecheck` fails on `origin/main` independently of this
change (`src/utils/nodeDefUtil.ts` and
`src/workbench/utils/nodeHelpUtil.ts` import non-existent exports from
`@/schemas/nodeDefSchema` / `@/types/nodeSource`). Verified by stashing
this PR's diff and re-running. Committed with `--no-verify`; please
address the underlying breakage separately.
2026-05-20 05:37:27 +00:00
AustinMroz
a95e53bf6d On subgraph conversion, always unpack group nodes (#12356)
This is a targeted small scope change to improve the availability for
converting group nodes into a subgraph.

The prior implementation would only apply on the litegraph context menu
option for converting a node to a subgraph. It failed to apply on any of
the other more common methods. The code for unpacking group nodes has
been moved directly into the setup for converting a group of nodes into
a subgraph and drastically simplified.

Of note, several other long lived bugs were found while working on this
fix, but they are out of scope for this targeted PR.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12356-On-subgraph-conversion-always-unpack-group-nodes-3666d73d365081d09774c00a851b8198)
by [Unito](https://www.unito.io)
2026-05-20 05:23:46 +00:00
AustinMroz
246b79dda9 Fix group selection selecting nodes (#12099)
Fix group selection incorrectly selecting nodes of equal id in vue mode.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12099-Fix-group-selection-selecting-nodes-35b6d73d365081e2bc73f16deb996f61)
by [Unito](https://www.unito.io)

Co-authored-by: Alexander Brown <drjkl@comfy.org>
2026-05-20 04:22:03 +00:00
Comfy Org PR Bot
7325c715c7 1.45.11 (#12349)
Patch version increment to 1.45.11

**Base branch:** `main`

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12349-1-45-11-3666d73d3650812b899cd2c6177e9888)
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-20 03:02:03 +00:00
jaeone94
98a8a614e8 fix: avoid false missing media errors after importing shared workflow assets (#12333)
## Summary

Import published media assets for shared workflows before loading the
graph so the first missing-media scan sees the user's newly imported
references instead of surfacing a false missing asset error. cc FE-773

## Changes

- **What**: Moves the shared workflow import step ahead of
`loadGraphData` for the copy-and-open flow, while still allowing the
workflow to open with a warning path if asset import fails.
- **What**: Clears the shared workflow URL intent consistently on
failure paths, including graph load failure after an import attempt, so
reloads do not repeatedly replay the same shared workflow side effects.
- **What**: Invalidates the input asset cache after published asset
import so graph loading and missing-media resolution can observe the
refreshed media state.
- **What**: Adds a global loading spinner while shared workflow asset
import and graph load are in progress, with `role="status"`,
`aria-live`, reduced-motion-safe animation, and body teleporting so it
stays visible above blocking UI.
- **What**: Adds stable TestIds for the shared workflow dialog and
updates existing shared workflow E2E selectors away from copy-dependent
role text.
- **What**: Adds a cloud E2E regression fixture and spec covering the
critical flow: shared URL opens the dialog, the user confirms asset
import, published media is imported before the public-inclusive input
asset scan, the workflow loads, the share query is removed, and missing
media UI is not surfaced.
- **Breaking**: None.
- **Dependencies**: None.

## Root Cause

Shared workflow graph loading triggered the missing-media pipeline
before the user-selected published media import had completed. Because
`include_public=true` does not include published assets, the pre-import
scan could classify shared media as missing even when the user was about
to import those assets into their own library.

## Review Focus

- The ordering in `useSharedWorkflowUrlLoader`: import published assets
first, then load the graph, while keeping import failure non-fatal for
workflow opening.
- The failure cleanup behavior: the shared URL/preserved query intent is
now cleared for graph load failures too, avoiding repeated
reload-triggered imports.
- The spinner behavior in `App.vue`: it uses the existing
`workspaceStore.spinner` boolean and intentionally keeps broader
ref-counted spinner ownership as follow-up work.
- The E2E sentinel in `sharedWorkflowMissingMedia.spec.ts`: it asserts
no public-inclusive input asset scan occurs before `/api/assets/import`,
then waits for a settling window to ensure the missing-media overlay
does not appear.

## Validation

- `pnpm format`
- `pnpm lint` (passed with existing unrelated warnings only)
- `pnpm typecheck`
- `pnpm test:unit`
- Commit hook: lint-staged formatting/linting, `pnpm typecheck`, `pnpm
typecheck:browser`
- Push hook: `pnpm knip --cache` (passed with existing tag hint only)

## Follow-Up

- Consider a ref-counted or scoped global spinner API so long-running
flows do not directly toggle `workspaceStore.spinner`.
- Consider separating shared workflow load status into orthogonal result
fields instead of encoding partial success in a single string union.
- Consider moving published asset import/cache invalidation behind an
asset-service-owned API boundary.
- Backend follow-up remains needed for `include_public=true` not
including published assets; this PR only removes the frontend false
positive when the user explicitly imports the shared media.

## Screenshots

Before 


https://github.com/user-attachments/assets/dc790046-237c-4dd8-b773-2507f9a66650

After 


https://github.com/user-attachments/assets/6517cd38-2c3d-4bfe-a990-35892b7e50ae



https://github.com/user-attachments/assets/d89dc3d3-75d9-4251-998b-0c354414e25b




┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12333-fix-avoid-false-missing-media-errors-after-importing-shared-workflow-assets-3656d73d365081b38634dcb7625cfc32)
by [Unito](https://www.unito.io)
2026-05-20 02:59:44 +00:00
Dante
95b5207c06 fix: stabilize multi-output expansion + simplify cloud output fetch (FE-227) (#12006)
## Summary

Two fixes for the cloud LoadImage form dropdown:

1. **Cloud root-cause fix** — outputs now come from a single
`getAssetsByTag('output')` call instead of walking the jobs API and
per-job `resolveOutputAssetItems` detail fetches. Per Christian's [Slack
feedback](https://comfy-organization.slack.com/archives/C0A4XMHANP3/p1778051260476369?thread_ts=1776716352.588229&cid=C0A4XMHANP3):
*"on cloud, we can just grab the assets with a single GET, filtering
with input or output tag."* Sidebar's job-stack UX is untouched.
2. **Local / defense-in-depth** — even when the watch+expansion path is
in play (still used by local), batch all in-flight
`resolveOutputAssetItems` for the current `media` snapshot via
`Promise.all`, committing once into `resolvedByJobId`. This kills the
progressive head-shift symptom even on the legacy path.

The first attempt at (1) (`6a1a083c9`, reverted in `c175962e8`) broke
select+load on cloud prod because the dropdown wrote `asset.name` (human
filename) into the widget value, but cloud's `/api/view` resolves output
files by **`asset_hash`** (the blake3-keyed filename). Verified against
cloud prod that every output row carries `asset_hash` and that cloud's
own `preview_url` is hash-keyed, not name-keyed. Re-introduced in
`d7693377` with the dropdown value derived from `asset.asset_hash ||
asset.name`, with the human filename retained as the display label.

- Fixes FE-227

## Cloud / local divergence — what this PR clarifies

| | input | output (this PR) |
| ------------ | ---------------------------------------------- |
-------------------------------------------------------- |
| **cloud** | `getAssetsByTag('input')` (already correct) |
**`getAssetsByTag('output')` (new)** |
| **local** | `/files/input` (FS-listing) | `getHistory` + per-job
expansion (unchanged) |

Both directions are now symmetric on cloud: tag-based listing,
hash-keyed values. Local stays on the legacy path because core ComfyUI
doesn't have the assets/tags model — that's the deeper convergence
Jacob/Luke flagged in the FE-556 thread (now BE-757), which is BE/Core
work and not this PR.

## Red-Green verification

| Commit | CI: Tests Unit | Purpose |
|--------|----------------|---------|
| `3e8d42e7` test | 🔴 [failure
(25413987208)](https://github.com/Comfy-Org/ComfyUI_frontend/actions/runs/25413987208)
| Asserts the head of the list does not shift while one of two
multi-output jobs is still resolving. |
| `fe2608d4` fix (atomic batch) | 🟢 [success
(25414246791)](https://github.com/Comfy-Org/ComfyUI_frontend/actions/runs/25414246791)
| Resolutions awaited via `Promise.all` and merged in one
`resolvedByJobId` update. |
| `6a1a083c` simplification (broken) | — | First attempt — used
`asset.name`, broke select+load on cloud prod. |
| `c175962e` revert | — | Rolled back the broken simplification while
diagnosis was in flight. |
| `d7693377` simplification (fixed) | pending | Re-introduces
`useFlatOutputAssets` and uses `asset.asset_hash` for the dropdown
value. Adds 7 unit tests covering hash-as-value, name-fallback,
pagination, dedupe, and error path. |



## screenshot/ video 

### before


https://github.com/user-attachments/assets/239aa447-a260-4713-926c-04dd80a30408



### after 


https://github.com/user-attachments/assets/d68228c6-33f5-4bf0-ad24-bb83c876fdc2



## Test plan

- [x] New `useFlatOutputAssets.test.ts` — 7 tests for tag-based
fetching, pagination, dedupe, error path.
- [x] `useWidgetSelectItems.test.ts` — atomic-batching regression test +
new tests asserting hash-as-value and local name-fallback. 35 tests
pass.
- [x] `WidgetSelectDropdown.test.ts` — 5 tests pass with the new
conditional source.
- [x] CI red on test-only commit, CI green on first fix commit.
- [ ] CI green on the simplification (re-introduce) commit.
- [ ] Manual verification on cloud build: open LoadImage → switch to
Outputs → scroll → list head stays stable; select an output → LoadImage
preview loads (was broken in `6a1a083c`, restored in `d7693377`).
2026-05-20 01:33:47 +00:00
Deep Mehta
2ab1abb898 Revert "fix(cloud): stop bouncing working users to /cloud/survey mid-session" (#12344)
Reverts Comfy-Org/ComfyUI_frontend#12301

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12344-Revert-fix-cloud-stop-bouncing-working-users-to-cloud-survey-mid-session-3656d73d365081119ebad749a2e0d403)
by [Unito](https://www.unito.io)
2026-05-20 07:58:12 +09:00
Benjamin Lu
64c75bfce5 test: avoid job history double setup (#12324)
## Summary

Avoid a second `comfyPage.setup()` in the job history browser tests by
registering the initial jobs route mocks before the normal page boot.

## Changes

- **What**: Adds an `initialJobsScenario` Playwright option with an auto
fixture for the job-history spec, moves QPOV2 setup into
`initialSettings`, and keeps the sidebar helper focused on UI
navigation.
- **Dependencies**: None

## Review Focus

Confirm the auto fixture ordering matches the intended browser-test
setup: initial `/api/jobs` mocks should be installed before the
`comfyPage` fixture performs its normal setup.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12324-test-avoid-job-history-double-setup-3656d73d365081778e24c11d3b65cbef)
by [Unito](https://www.unito.io)
2026-05-19 20:09:01 +00:00
Benjamin Lu
cfd3f9e67b fix: classify PLY assets as 3D media (#12319)
## Summary

Classify `.ply` as 3D media so PLY outputs are surfaced by queue/assets
preview flows.

## Changes

- **What**: adds `.ply` to shared 3D extension detection and falls back
to the asset `/view` URL when opening 3D assets without `preview_url`.
- **Breaking**: none.
- **Dependencies**: none.

## Review Focus

- This is the tactical FE fix for FE-129; it intentionally does not
solve the broader 3D media vs load3d-loadable split.
- Assets sidebar 3D viewer still prefers `preview_url`, but now has a
usable fallback for assets that only have the normal asset URL.

FE-129

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12319-fix-classify-PLY-assets-as-3D-media-3646d73d365081218f0bde401b1601bd)
by [Unito](https://www.unito.io)
2026-05-19 08:09:02 -07:00
pythongosssss
8af8a5f0b1 test: add tests for workflow extraction util (#12218)
## Summary

Adds test coverage for workflow extraction utils

## Changes

- **What**: 
- tests!

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12218-test-add-tests-for-workflow-extraction-util-35f6d73d36508189b15accc9f724d348)
by [Unito](https://www.unito.io)
2026-05-19 12:28:17 +00:00
Dante
3b37488eee fix: keep node context menu overflow visible when content fits (#12035)
## Summary

Stops the Shape submenu (and any other PrimeVue nested submenu) from
being clipped behind the node context menu when the menu fits in the
viewport.

## Changes

- **What**: `constrainMenuHeight` in `NodeContextMenu.vue` now applies
`max-height` + `overflow-y: auto` to the root `<ul>` only when
`scrollHeight > availableHeight`. The common case keeps `overflow:
visible`.
- Added `browser_tests/tests/nodeContextMenuShapeSubmenu.spec.ts`
regression spec.

## Review Focus

Root cause: setting only `overflow-y: auto` on a `<ul>` coerces
`overflow-x` to a non-visible value per CSS spec (`If one of
overflow-x/overflow-y is visible and the other isn't, the visible value
is computed as auto`). PrimeVue `ContextMenuSub` renders submenus
in-tree as a nested `<ul>` with `position: absolute; left: 100%`, so the
implicit horizontal clip hides them entirely.

The pre-existing overflow scenario (#10824 / #10854) is unchanged — when
the menu actually overflows, the clamp still applies and
`nodeContextMenuOverflow.spec.ts` continues to verify scroll. Submenu
clipping in that overflow case is a known limitation, not introduced by
this PR.

Fixes FE-570


## screenshot

### AS IS
<img width="788" height="505" alt="Screenshot 2026-05-07 at 12 43 26 PM"
src="https://github.com/user-attachments/assets/36d34070-0c57-4385-a130-0394f22f282e"
/>


### TO BE

<img width="779" height="627" alt="Screenshot 2026-05-07 at 12 42 44 PM"
src="https://github.com/user-attachments/assets/00956729-763b-4787-822f-209e8ea42331"
/>


┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12035-fix-keep-node-context-menu-overflow-visible-when-content-fits-3586d73d365081ad9aaec82f220d401c)
by [Unito](https://www.unito.io)

---------

Co-authored-by: GitHub Action <action@github.com>
2026-05-19 10:56:41 +00:00
Comfy Org PR Bot
42dcb1cf7b 1.45.10 (#12321)
Patch version increment to 1.45.10

**Base branch:** `main`

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12321-1-45-10-3656d73d365081c684e2c76ce92e2ff8)
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-19 08:29:48 +00:00
Christian Byrne
4931b0c4b2 fix(website): add dark-background favicon for legibility in search results (#12285)
*PR Created by the Glary-Bot Agent*

---

## Summary

The comfy.org favicon was reported as illegible in Google search
results. The current `logomark.svg` is a transparent yellow "C" — when
Google (or any client) composites it onto a white surface (search
results, light-theme tab strips), the yellow disappears into the
background.

Fix: ship a dedicated `/favicon.svg` that wraps the existing yellow
logomark in a solid black square, and point `<link rel="icon">` at it.
The in-page nav logo, `Organization.logo` Schema.org URL, and any other
consumer of `logomark.svg` are left untouched, so transparent-composite
contexts (knowledge panels, dark nav) continue to render cleanly.

## Changes

- `apps/website/public/favicon.svg` *(new)* — 48×48 SVG: black square +
scaled-down original logomark path. Existing path geometry is reused
verbatim inside a `<g transform>` so the C glyph is byte-identical to
the source.
- `apps/website/src/layouts/BaseLayout.astro` — `<link rel="icon"
href="/icons/logomark.svg">` → `<link rel="icon" href="/favicon.svg">`.
One-line change.

## Why a new file (vs editing `logomark.svg` in place)

`logomark.svg` is also used by `SiteNav.vue` (in-page header on the dark
`--color-primary-comfy-ink` background) and by the JSON-LD
`Organization.logo` URL. Both consumers want the transparent version.
Editing it in place would draw an ugly black square in the site's own
header.

## User report

> "just google searched comfyui and logo isnt legible. We should
update.."

## Verification

**Built site**
- `pnpm typecheck` (astro check): 0 errors, 0 warnings
- `pnpm build` (astro build): 280 pages built, exit 0
- Built `dist/index.html` contains exactly one `<link rel="icon"
href="/favicon.svg">` and zero references to the old icon path in
`<head>`
- `oxlint` on changed `.astro` file: 0 warnings, 0 errors

**Visual (Playwright on local astro dev server)**
- New favicon renders correctly at 16/32/64 px — yellow C centered on
black square, no clipping.
- In-page nav logo unchanged (yellow C floats cleanly on the dark
`--color-primary-comfy-ink` nav background, no black wrapper visible).
- Mock of Google search-result row shows the new favicon is
high-contrast inside Google's white circular wrapper; the old one is
nearly invisible.

## Screenshots

### Google-style search result simulation (before / after)
![Google search result simulation — before and
after](google-result-before-after)

### Favicon at native sizes + Google circular wrapper
![Favicon comparison at 16/32/64 px and inside a Google-style white
circle wrapper](favicon-comparison-preview)

### In-page nav header (unchanged after the fix)
![Site nav header showing the yellow Comfy logomark still sits cleanly
on the dark nav background](homepage-header-after)

## Notes for reviewers

- The change deliberately uses pure black `#000` (matching the user's
literal request "make the white background, black") rather than
`--color-primary-comfy-ink` (`#211927`). Either would work; happy to
switch if brand preference is the ink color.
- Search-engine cached favicons can take days/weeks to refresh on
Google's side after the new file is deployed.

## Screenshots


![google-result-before-after](https://pub-1fd11710d4c8405b948c9edc4287a3f2.r2.dev/sessions/3cb3469452b36ec7b11a5cd6b88e31056ad2dfadfb1b4c3b99db2b91c8229d89/pr-images/1778827200860-80e3877f-e1af-4cf3-962e-a1bf25ea9815.png)


![favicon-comparison-preview](https://pub-1fd11710d4c8405b948c9edc4287a3f2.r2.dev/sessions/3cb3469452b36ec7b11a5cd6b88e31056ad2dfadfb1b4c3b99db2b91c8229d89/pr-images/1778827201188-582fe0fb-aa7c-4fa1-af5b-fbc2a72387b7.png)


![homepage-header-after](https://pub-1fd11710d4c8405b948c9edc4287a3f2.r2.dev/sessions/3cb3469452b36ec7b11a5cd6b88e31056ad2dfadfb1b4c3b99db2b91c8229d89/pr-images/1778827201630-3a88502e-dd59-4e52-82a6-55f6b9768e4d.png)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12285-fix-website-add-dark-background-favicon-for-legibility-in-search-results-3616d73d365081babbcbedf0b86d3d67)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Glary-Bot <glary-bot@users.noreply.github.com>
2026-05-18 19:24:16 -07:00
jaeone94
fc7e6a0935 fix(terminal): resync logs console on backend reconnect (#12270)
## Summary

When the built-in logs terminal stayed open during a backend restart,
the buffer froze on pre-restart entries and live log streaming silently
stopped — only closing and reopening the panel resynced. Listen for the
api `reconnected` event and rebuild the terminal contents the same way a
fresh open would.

## Changes

- **What**:
- Extract `useLogsTerminal` composable. The SFC is now a thin shell
holding `terminal: shallowRef<Terminal>` and forwarding to the
composable, so `onMounted`/`onScopeDispose` no longer rely on the
child's emit callback timing.
- Subscribe to `api`'s `reconnected` event via `useEventListener`,
registered synchronously before any awaits. On reconnect:
`terminal.reset()` → refetch raw logs → `scrollToBottom()` →
`subscribeLogs(true)` (the backend loses the per-client subscription on
restart, so re-subscribe is required for live streaming to resume).
- Wrap in-flight resync/mount fetches in AbortControllers. Overlapping
reconnects abort the prior resync, and unmount mid-fetch suppresses
writes to the disposed xterm.
- Hide BaseTerminal whenever `errorMessage` is set so the error layout
doesn't expose an empty xterm container behind the message;
`loading=false` after both load failure and resync success so a later
successful reconnect can clear a stuck spinner.
- Migrate the load/resync error strings to vue-i18n
(`logsTerminal.loadError`, `logsTerminal.resyncError`).

## Review Focus

- **Re-subscribe is the non-obvious half of the fix** — without it, even
after the WebSocket reconnects the backend never resumes streaming logs
to this client because its subscription state was wiped on restart. The
visible "stale buffer" is only one symptom; the silent "no new logs"
symptom needed the explicit `subscribeLogs(true)` re-call in resync.
- `terminal.reset()` lives after a successful raw-logs fetch (not
before) so a failed resync leaves the prior buffer visible instead of
blanking it; resync errors surface via the same inline error message the
mount path uses.
- 8 unit tests around the composable: mount + subscribe, resync ordering
(reset → write → scroll → subscribe via `invocationCallOrder`),
in-flight resync abort on double reconnect, resync error surfacing,
mount-failure-then-recovery, unmount-mid-fetch terminal-write
suppression, listener cleanup on unmount.
- 2 E2E tests using `ws.close()` on the proxied WebSocket as the
reconnect trigger and `subscribeLogs` HTTP fetch count as the sync point
(same pattern as `wsReconnectStaleJob.spec.ts`). Red-checked: disabling
the `reconnected` listener fails exactly the two new tests, all 8
pre-existing tests stay green.

Fixes FE-712

## Screenshots

Before - (After rebooting, the console window does not update from its
state before the reboot must remount the console window for it to
resync.)


https://github.com/user-attachments/assets/b1e49c2c-89a4-4a4a-82b4-064412acee12

After - (The console window syncs automatically after a reboot.)


https://github.com/user-attachments/assets/54b582c5-ad42-41c0-9886-18f4495859da




┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12270-fix-terminal-resync-logs-console-on-backend-reconnect-3606d73d3650812fb13fd1934c632344)
by [Unito](https://www.unito.io)
2026-05-19 01:55:11 +00:00
Benjamin Lu
a97f46b497 test: cover job history sidebar with typed route mocks (#12272)
## Summary

Add the first product-area browser coverage on top of the merged typed
route mock foundation: the docked job history sidebar.

## Changes

- **What**: Adds `browser_tests/tests/sidebar/jobHistory.spec.ts` using
`jobsRouteFixture`.
- **What**: Covers direct sidebar entry, docked QPO history entry,
terminal history jobs, active queue jobs, tab filtering, search, clear
queue, and clear history.
- **What**: Adds typed `POST /api/queue` and `POST /api/history` route
helpers that validate request bodies with generated zod schemas.
- **What**: Adds stable test ids for the job history sidebar and queue
progress overlay so tests avoid structural CSS selectors.
- **Dependencies**: Builds on the typed route mock foundation merged in
#12267.

## Review Focus

Review the product assertions and whether this is the right first
coverage slice on top of the typed route mock foundation. This PR
intentionally avoids asset sidebar and floating QPO lifecycle coverage;
those should remain follow-up PRs.

## Screenshots (if applicable)

Not applicable.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12272-test-cover-job-history-sidebar-with-typed-route-mocks-3606d73d3650817481d5f9fac4bfc93c)
by [Unito](https://www.unito.io)
2026-05-18 18:34:56 -07:00
Dante
448ad73fae refactor(assets): collapse useMediaAssets factory wrapper (FE-727) (#12283)
## Summary

https://linear.app/comfyorg/issue/FE-727/refactor-usemediaasset

`useMediaAssets` was a factory wrapper that branched on `isCloud` and
returned one of two near-identical implementations (`useAssetsApi` /
`useInternalFilesApi`). Both implementations delegated to the same
`assetsStore` actions (`updateInputs` / `updateHistory` /
`loadMoreHistory`) — the real cloud/local fork lives inside
`assetsStore.fetchInputFiles` (line 121), not at the composable layer.

Collapse the wrapper:

1. Delete `useInternalFilesApi` (identical to `useAssetsApi`).
2. Delete `useMediaAssets` (now a pass-through).
3. Switch the four callers to `useAssetsApi` directly.

## Changes

- **What**:
  - Delete `useInternalFilesApi.ts` and `useMediaAssets.ts`.
- `AssetsSidebarTab.vue`, `WidgetSelectDropdown.vue`,
`useOutputHistory.ts`: call `useAssetsApi` directly.
- `useWidgetSelectItems.ts`: narrow `outputMediaAssets` prop type to
`IAssetsProvider` (the interface `useOutputHistory` already implements
directly).
  - Test mocks repointed from `useMediaAssets` → `useAssetsApi`.
- Two unrelated tailwind class-order lint errors auto-fixed by `pnpm
lint:fix` to keep CI green.
- **Breaking**: None — no behavior change.

## Review Focus

The real cloud/local fork remains at `assetsStore.ts:121`
(`fetchInputFiles = isCloud ? fetchInputFilesFromCloud :
fetchInputFilesFromAPI`). That branch is M1-strict and clears once
BE-933 lands. This PR only collapses the dead wrapper layer above it.

`IAssetsProvider` is intentionally kept — `useOutputHistory.ts:83`
directly implements it for a different use case, so the interface still
has more than one consumer.

Surfaced while working on the FE-678 cloud/local asset branching survey.

## Screenshots (if applicable)

N/A — no UI change.

---------

Co-authored-by: GitHub Action <action@github.com>
Co-authored-by: Alexander Brown <drjkl@comfy.org>
2026-05-19 00:05:40 +00:00
Deep Mehta
cf267acffe fix(cloud): stop bouncing working users to /cloud/survey mid-session (#12301)
## Summary

`getSurveyCompletedStatus` (auth.ts) now resolves ambiguous responses to
"completed" instead of "not completed", so transient backend errors no
longer bounce working users to `/cloud/survey`.

| Backend response | Old behavior | New behavior |
|---|---|---|
| 200 with non-empty `value` | `true` (completed) | `true` (completed) |
| 200 with empty `value` | `false` (not completed) | `false` (not
completed) |
| 404 | `false` → bounced | `true` (treat as completed) |
| 5xx | `false` → bounced | `true` (treat as completed) |
| 401 / 403 | `false` → bounced | `true` (treat as completed) |
| Network error | `false` → bounced | `true` (treat as completed) |

Only a definitive `200` with empty `value` is treated as "not
completed". Everything else fails open. The dedicated auth layer handles
re-authentication on the next API call, so 401/403 doesn't need a
separate branch here.

## Why

User reports from team-plan customers: _"I was working in a workflow,
hit run, and then got logged out and redirected to a survey screen."_
Datadog shows ~7,000 distinct users/day hitting the `setting key
onboarding_survey not found` path on prod ingest. With
`onboarding_survey_enabled: true` in prod dynamic config and the
catch-all `!response.ok` returning `false`, any mid-session reload
tripped a redirect to `/cloud/survey`.

User-validated requirement: rather miss showing the survey to a few
users than show it duplicately or interrupt working customers.

## Trade-off worth product review

A genuinely brand-new user whose `User.Settings` JSON is empty also
returns 404 from `/api/settings/onboarding_survey` — the backend doesn't
distinguish "key absent for existing user" from "user has no settings
yet". With this change, that 404 is treated as "completed", so the
survey gate does not fire on the strict 404 path. New users will still
see the survey if signup pre-populates the `onboarding_survey` key with
an empty object (`200` with empty `value`); if not, the survey is missed
on initial signup.

We picked this trade-off per the product call that false positives
(bouncing paying customers) are strictly worse than false negatives
(occasionally missing a new user).

The clean fix to recover the new-user signal is a backend change: return
`200` with `value: null` when the `User` row exists but the key is
absent — distinguishing "no survey saved" from "user not found". Out of
scope for this PR; filing as follow-up if accepted.

## Test plan

- [ ] Logged-in user with completed survey navigates around — no
redirect
- [ ] Logged-in user with no survey, fresh tab — redirected to
`/cloud/survey` (gate still works for new sessions)
- [ ] Logged-in user with no survey, after submitting — no redirect on
next nav
- [ ] Simulate transient 5xx on `/api/settings/onboarding_survey`
(DevTools blocking) — user stays on current page, no redirect

Unit coverage in `auth.test.ts` locks the resolution table above against
drift (one test per branch, 8 total).

## Companion PRs

None — frontend only.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12301-fix-cloud-stop-bouncing-working-users-to-cloud-survey-mid-session-3616d73d365081128ba7e266ad7ccff9)
by [Unito](https://www.unito.io)

---------

Co-authored-by: GitHub Action <action@github.com>
Co-authored-by: Alexander Brown <drjkl@comfy.org>
2026-05-18 23:37:02 +00: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
891 changed files with 16090 additions and 6415 deletions

View File

@@ -41,6 +41,10 @@ ALGOLIA_API_KEY=684d998c36b67a9a9fce8fc2d8860579
# Enable PostHog debug logging in the browser console.
# VITE_POSTHOG_DEBUG=true
# Override staging comfy-api / comfy-platform base URLs.
# VITE_STAGING_API_BASE_URL=https://stagingapi.comfy.org
# VITE_STAGING_PLATFORM_BASE_URL=https://stagingplatform.comfy.org
# Sentry ENV vars replace with real ones for debugging
# SENTRY_AUTH_TOKEN=private-token # get from sentry
# SENTRY_ORG=comfy-org

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

@@ -106,19 +106,12 @@ jobs:
- 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
WORKFLOW_URL="https://github.com/${{ github.repository }}/actions/runs/${{ github.event.workflow_run.id }}"
echo "<html><body><h1>No E2E coverage data available for this run.</h1><p><a href=\"${WORKFLOW_URL}\">View workflow run</a></p></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 \
--ignore-errors source
--ignore-errors source,unmapped
- name: Upload HTML report artifact
if: steps.coverage-shards.outputs.has-coverage == 'true'

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

@@ -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

@@ -28,6 +28,7 @@
],
"rules": {
"no-async-promise-executor": "off",
"func-style": ["error", "declaration"],
"no-console": [
"error",
{
@@ -124,6 +125,12 @@
"no-console": "allow"
}
},
{
"files": ["src/lib/litegraph/**"],
"rules": {
"func-style": "off"
}
},
{
"files": ["browser_tests/**/*.ts"],
"jsPlugins": ["eslint-plugin-playwright"],

View File

@@ -47,7 +47,7 @@ setup((app) => {
})
// Theme and dialog decorator
export const withTheme = (Story: StoryFn, context: StoryContext) => {
export function withTheme(Story: StoryFn, context: StoryContext) {
const theme = context.globals.theme || 'light'
// Apply theme class to document root

View File

@@ -40,7 +40,7 @@ setup((app) => {
app.use(ToastService)
})
export const withTheme = (Story: StoryFn, context: StoryContext) => {
export function withTheme(Story: StoryFn, context: StoryContext) {
const theme = context.globals.theme || 'light'
if (theme === 'dark') {
document.documentElement.classList.add('dark-theme')

View File

@@ -58,7 +58,7 @@ const tooltipText = computed(() => {
: t('serverStart.copyAllTooltip')
})
const handleCopy = async () => {
async function handleCopy() {
const existingSelection = terminal.getSelection()
const shouldSelectAll = !existingSelection
if (shouldSelectAll) terminal.selectAll()
@@ -76,7 +76,7 @@ const handleCopy = async () => {
}
}
const showContextMenu = (event: MouseEvent) => {
function showContextMenu(event: MouseEvent) {
event.preventDefault()
electronAPI()?.showContextMenu({ type: 'text' })
}

View File

@@ -44,8 +44,9 @@ const emit = defineEmits<{
const validationState = ref<ValidationState>(ValidationState.IDLE)
const cleanInput = (value: string): string =>
value ? value.replace(/\s+/g, '') : ''
function cleanInput(value: string): string {
return value ? value.replace(/\s+/g, '') : ''
}
// Add internal value state
const internalValue = ref(cleanInput(props.modelValue))
@@ -68,14 +69,14 @@ onMounted(async () => {
await validateUrl(props.modelValue)
})
const handleInput = (value: string | undefined) => {
function handleInput(value: string | undefined) {
// Update internal value without emitting
internalValue.value = cleanInput(value ?? '')
// Reset validation state when user types
validationState.value = ValidationState.IDLE
}
const handleBlur = async () => {
async function handleBlur() {
const input = cleanInput(internalValue.value)
let normalizedUrl = input
@@ -91,7 +92,7 @@ const handleBlur = async () => {
}
// Default validation implementation
const defaultValidateUrl = async (url: string): Promise<boolean> => {
async function defaultValidateUrl(url: string): Promise<boolean> {
if (!isValidUrl(url)) return false
try {
return await checkUrlReachable(url)
@@ -100,7 +101,7 @@ const defaultValidateUrl = async (url: string): Promise<boolean> => {
}
}
const validateUrl = async (value: string) => {
async function validateUrl(value: string) {
if (validationState.value === ValidationState.LOADING) return
const url = cleanInput(value)

View File

@@ -127,7 +127,7 @@ const showDialog = ref(false)
const autoUpdate = defineModel<boolean>('autoUpdate', { required: true })
const allowMetrics = defineModel<boolean>('allowMetrics', { required: true })
const showMetricsInfo = () => {
function showMetricsInfo() {
showDialog.value = true
}
</script>

View File

@@ -182,10 +182,12 @@ function getTorchMirrorItem(device: TorchDeviceType): UVMirror {
}
const userIsInChina = ref(false)
const useFallbackMirror = (mirror: UVMirror) => ({
...mirror,
mirror: mirror.fallbackMirror
})
function useFallbackMirror(mirror: UVMirror) {
return {
...mirror,
mirror: mirror.fallbackMirror
}
}
const mirrors = computed<[UVMirror, ModelRef<string>][]>(() =>
(
@@ -212,7 +214,7 @@ onMounted(async () => {
userIsInChina.value = await isInChina()
})
const validatePath = async (path: string | undefined) => {
async function validatePath(path: string | undefined) {
try {
pathError.value = ''
pathExists.value = false
@@ -246,7 +248,7 @@ const validatePath = async (path: string | undefined) => {
}
}
const browsePath = async () => {
async function browsePath() {
try {
const result = await electron.showDirectoryPicker()
if (result) {
@@ -258,7 +260,7 @@ const browsePath = async () => {
}
}
const onFocus = async () => {
async function onFocus() {
if (!inputTouched.value) {
inputTouched.value = true
return

View File

@@ -92,7 +92,7 @@ const isValidSource = computed(
() => sourcePath.value !== '' && pathError.value === ''
)
const validateSource = async (sourcePath: string | undefined) => {
async function validateSource(sourcePath: string | undefined) {
if (!sourcePath) {
pathError.value = ''
return
@@ -109,7 +109,7 @@ const validateSource = async (sourcePath: string | undefined) => {
}
}
const browsePath = async () => {
async function browsePath() {
try {
const result = await electron.showDirectoryPicker()
if (result) {

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"
@@ -82,7 +82,7 @@ const isExecuting = useMinLoadingDurationRef(reactiveExecuting, 250)
// Popover
const infoPopover = ref<InstanceType<typeof Popover> | null>(null)
const toggle = (event: Event) => {
function toggle(event: Event) {
infoPopover.value?.toggle(event)
}
</script>

View File

@@ -67,7 +67,7 @@ defineProps<{
filter: MaintenanceFilter
}>()
const executeTask = async (task: MaintenanceTask) => {
async function executeTask(task: MaintenanceTask) {
let message: string | undefined
try {
@@ -87,7 +87,7 @@ const executeTask = async (task: MaintenanceTask) => {
}
// Commands
const confirmButton = async (event: MouseEvent, task: MaintenanceTask) => {
async function confirmButton(event: MouseEvent, task: MaintenanceTask) {
if (!task.requireConfirm) {
await executeTask(task)
return

View File

@@ -34,10 +34,10 @@ const buffer = useTerminalBuffer()
let xterm: Terminal | null = null
// Created and destroyed with the Drawer - contents copied from hidden buffer
const terminalCreated = (
function terminalCreated(
{ terminal, useAutoSize }: ReturnType<typeof useTerminal>,
root: Ref<HTMLElement | undefined>
) => {
) {
xterm = terminal
useAutoSize({ root, autoRows: true, autoCols: true })
terminal.write(props.defaultMessage)
@@ -49,7 +49,7 @@ const terminalCreated = (
terminal.options.disableStdin = true
}
const terminalUnmounted = () => {
function terminalUnmounted() {
xterm = null
}

View File

@@ -55,14 +55,14 @@ export function useTerminal(element: Ref<HTMLElement | undefined>) {
minRows?: number
onResize?: () => void
}) {
const ensureValidRows = (rows: number | undefined): number => {
function ensureValidRows(rows: number | undefined): number {
if (rows == null || isNaN(rows)) {
return (root.value?.clientHeight ?? 80) / 20
}
return rows
}
const ensureValidCols = (cols: number | undefined): number => {
function ensureValidCols(cols: number | undefined): number {
if (cols == null || isNaN(cols)) {
// Sometimes this is NaN if so, estimate.
return (root.value?.clientWidth ?? 80) / 8
@@ -70,7 +70,7 @@ export function useTerminal(element: Ref<HTMLElement | undefined>) {
return cols
}
const resize = () => {
function resize() {
const dims = fitAddon.proposeDimensions()
// Sometimes propose returns NaN, so we may need to estimate.
terminal.resize(

View File

@@ -6,13 +6,17 @@ export function useTerminalBuffer() {
const serializeAddon = new SerializeAddon()
const terminal = markRaw(new Terminal({ convertEol: true }))
const copyTo = (destinationTerminal: Terminal) => {
function copyTo(destinationTerminal: Terminal) {
destinationTerminal.write(serializeAddon.serialize())
}
const write = (message: string) => terminal.write(message)
function write(message: string) {
return terminal.write(message)
}
const serialize = () => serializeAddon.serialize()
function serialize() {
return serializeAddon.serialize()
}
onMounted(() => {
terminal.loadAddon(serializeAddon)

View File

@@ -5,7 +5,7 @@ import { electronAPI } from '@/utils/envUtil'
const electron = electronAPI()
const openUrl = (url: string) => {
function openUrl(url: string) {
window.open(url, '_blank')
return true
}

View File

@@ -124,13 +124,15 @@ export const useMaintenanceTaskStore = defineStore('maintenanceTask', () => {
* @param task Task to get the matching state object for
* @returns The state object for this task
*/
const getRunner = (task: MaintenanceTask) => taskRunners.value.get(task.id)!
function getRunner(task: MaintenanceTask) {
return taskRunners.value.get(task.id)!
}
/**
* Updates the task list with the latest validation state.
* @param validationUpdate Update details passed in by electron
*/
const processUpdate = (validationUpdate: InstallValidation) => {
function processUpdate(validationUpdate: InstallValidation) {
lastUpdate.value = validationUpdate
const update = validationUpdate as IndexedUpdate
isRefreshing.value = true
@@ -151,19 +153,19 @@ export const useMaintenanceTaskStore = defineStore('maintenanceTask', () => {
}
/** Clears the resolved status of tasks (when changing filters) */
const clearResolved = () => {
function clearResolved() {
for (const task of tasks.value) {
getRunner(task).resolved &&= false
}
}
/** @todo Refreshes Electron tasks only. */
const refreshDesktopTasks = async () => {
async function refreshDesktopTasks() {
isRefreshing.value = true
await electron.Validation.validateInstallation(processUpdate)
}
const execute = async (task: MaintenanceTask) => {
async function execute(task: MaintenanceTask) {
const success = await getRunner(task).execute(task)
if (success && task.isInstallationFix) {
await refreshDesktopTasks()

View File

@@ -7,7 +7,7 @@ import { electronAPI } from './envUtil'
* @param mirror - The mirror to check.
* @returns True if the mirror is reachable, false otherwise.
*/
export const checkMirrorReachable = async (mirror: string) => {
export async function checkMirrorReachable(mirror: string) {
return (
isValidUrl(mirror) && (await electronAPI().NetWork.canAccessUrl(mirror))
)

View File

@@ -36,7 +36,7 @@ import { electronAPI } from '@/utils/envUtil'
const route = useRoute()
const { id, title, message, buttons } = getDialog(route.params.dialogId)
const handleButtonClick = async (button: DialogAction) => {
async function handleButtonClick(button: DialogAction) {
await electronAPI().Dialog.clickButton(button.returnValue)
}
</script>

View File

@@ -52,7 +52,7 @@ const electron = electronAPI()
const terminalVisible = ref(false)
const toggleConsoleDrawer = () => {
function toggleConsoleDrawer() {
terminalVisible.value = !terminalVisible.value
}

View File

@@ -47,11 +47,11 @@ import { useRouter } from 'vue-router'
import BaseViewTemplate from '@/views/templates/BaseViewTemplate.vue'
const openGitDownloads = () => {
function openGitDownloads() {
window.open('https://git-scm.com/downloads/', '_blank')
}
const skipGit = async () => {
async function skipGit() {
console.warn('pushing')
const router = useRouter()
await router.push('install')

View File

@@ -8,8 +8,8 @@ import { createMemoryHistory, createRouter } from 'vue-router'
import InstallView from './InstallView.vue'
// Create a mock router for stories
const createMockRouter = () =>
createRouter({
function createMockRouter() {
return createRouter({
history: createMemoryHistory(),
routes: [
{ path: '/', component: { template: '<div>Home</div>' } },
@@ -23,6 +23,7 @@ const createMockRouter = () =>
}
]
})
}
const meta: Meta<typeof InstallView> = {
title: 'Desktop/Views/InstallView',

View File

@@ -90,7 +90,7 @@ const currentStep = ref('1')
/** Forces each install step to be visited at least once. */
const highestStep = ref(0)
const handleStepChange = (value: string | number) => {
function handleStepChange(value: string | number) {
setHighestStep(value)
electronAPI().Events.trackEvent('install_stepper_change', {
@@ -98,7 +98,7 @@ const handleStepChange = (value: string | number) => {
})
}
const setHighestStep = (value: string | number) => {
function setHighestStep(value: string | number) {
const int = typeof value === 'number' ? value : parseInt(value, 10)
if (!isNaN(int) && int > highestStep.value) highestStep.value = int
}
@@ -123,7 +123,7 @@ const canProceed = computed(() => {
})
// Navigation methods
const goToNextStep = () => {
function goToNextStep() {
const nextStep = (parseInt(currentStep.value) + 1).toString()
currentStep.value = nextStep
setHighestStep(nextStep)
@@ -132,7 +132,7 @@ const goToNextStep = () => {
})
}
const goToPreviousStep = () => {
function goToPreviousStep() {
const prevStep = (parseInt(currentStep.value) - 1).toString()
currentStep.value = prevStep
electronAPI().Events.trackEvent('install_stepper_change', {
@@ -142,7 +142,7 @@ const goToPreviousStep = () => {
const electron = electronAPI()
const router = useRouter()
const install = async () => {
async function install() {
if (!device.value) return
const options: InstallOptions = {

View File

@@ -35,12 +35,14 @@ const validationState: ValidationState = {
upgradePackages: 'OK'
}
const createMockElectronAPI = () => {
function createMockElectronAPI() {
const logListeners: Array<(message: string) => void> = []
const getValidationUpdate = () => ({
...validationState
})
function getValidationUpdate() {
return {
...validationState
}
}
return {
getPlatform: () => 'darwin',
@@ -76,7 +78,7 @@ const createMockElectronAPI = () => {
}
}
const ensureElectronAPI = () => {
function ensureElectronAPI() {
const globalWindow = window as { electronAPI?: unknown }
if (!globalWindow.electronAPI) {
globalWindow.electronAPI = createMockElectronAPI()

View File

@@ -183,7 +183,7 @@ const unsafeReasonText = computed(() => {
})
/** If valid, leave the validation window. */
const completeValidation = async () => {
async function completeValidation() {
const isValid = await electron.Validation.complete()
if (!isValid) {
toast.add({
@@ -194,7 +194,7 @@ const completeValidation = async () => {
}
}
const toggleConsoleDrawer = () => {
function toggleConsoleDrawer() {
terminalVisible.value = !terminalVisible.value
}

View File

@@ -67,7 +67,9 @@ const electron = electronAPI()
const basePath = ref<string | null>(null)
const sep = ref<'\\' | '/'>('/')
const restartApp = (message?: string) => electron.restartApp(message)
function restartApp(message?: string) {
return electron.restartApp(message)
}
onMounted(async () => {
basePath.value = await electron.getBasePath()

View File

@@ -64,7 +64,7 @@ const allowMetrics = ref(true)
const router = useRouter()
const isUpdating = ref(false)
const updateConsent = async () => {
async function updateConsent() {
isUpdating.value = true
try {
await electronAPI().setMetricsConsent(allowMetrics.value)

View File

@@ -61,19 +61,19 @@ import { useRouter } from 'vue-router'
import BaseViewTemplate from '@/views/templates/BaseViewTemplate.vue'
const openDocs = () => {
function openDocs() {
window.open(
'https://github.com/Comfy-Org/desktop#currently-supported-platforms',
'_blank'
)
}
const reportIssue = () => {
function reportIssue() {
window.open('https://forum.comfy.org/c/v1-feedback/', '_blank')
}
const router = useRouter()
const continueToInstall = async () => {
async function continueToInstall() {
await router.push('/install')
}
</script>

View File

@@ -118,7 +118,7 @@ let xterm: Terminal | undefined
/**
* Handles installation stage updates from the desktop
*/
const updateInstallStage = (stageInfo: InstallStageInfo) => {
function updateInstallStage(stageInfo: InstallStageInfo) {
console.warn('[InstallStage.onUpdate] Received:', {
stage: stageInfo.stage,
progress: stageInfo.progress,
@@ -183,17 +183,17 @@ const displayStatusText = computed(() => {
return currentStatusLabel.value
})
const updateProgress = ({ status: newStatus }: { status: ProgressStatus }) => {
function updateProgress({ status: newStatus }: { status: ProgressStatus }) {
status.value = newStatus
// Make critical error screen more obvious.
if (newStatus === ProgressStatus.ERROR) terminalVisible.value = false
}
const terminalCreated = (
function terminalCreated(
{ terminal, useAutoSize }: ReturnType<typeof useTerminal>,
root: Ref<HTMLElement | undefined>
) => {
) {
xterm = terminal
useAutoSize({ root, autoRows: true, autoCols: true })
@@ -206,11 +206,15 @@ const terminalCreated = (
terminal.options.cursorInactiveStyle = 'block'
}
const troubleshoot = () => electron.startTroubleshooting()
const reportIssue = () => {
function troubleshoot() {
return electron.startTroubleshooting()
}
function reportIssue() {
window.open('https://forum.comfy.org/c/v1-feedback/', '_blank')
}
const openLogs = () => electron.openLogsFolder()
function openLogs() {
return electron.openLogsFolder()
}
let cleanupInstallStageListener: (() => void) | undefined

View File

@@ -33,7 +33,7 @@ import { useRouter } from 'vue-router'
import BaseViewTemplate from '@/views/templates/BaseViewTemplate.vue'
const router = useRouter()
const navigateTo = async (path: string) => {
async function navigateTo(path: string) {
await router.push(path)
}
</script>

View File

@@ -119,6 +119,44 @@ snapshots can't be accidentally committed.
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:

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

@@ -3,8 +3,9 @@ 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, '\\$&')
function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
}
test.describe('Demo pages @smoke', () => {
for (const demo of demos) {

View File

@@ -111,7 +111,7 @@ async function measureMarqueeLoopGeometry(
`Animation on ${sel} has unusable duration: ${String(duration)}`
)
}
const setAllTimes = (time: number) => {
function setAllTimes(time: number) {
for (const track of tracks) {
for (const anim of track.getAnimations()) {
anim.currentTime = time
@@ -119,7 +119,9 @@ async function measureMarqueeLoopGeometry(
}
void document.body.offsetWidth
}
const readX = () => tracks.map((track) => track.getBoundingClientRect().x)
function readX() {
return tracks.map((track) => track.getBoundingClientRect().x)
}
setAllTimes(0)
const startPositions = readX()
const copyWidths = tracks.map(

View File

@@ -0,0 +1,14 @@
<svg width="48" height="48" viewBox="0 0 48 48" xmlns="http://www.w3.org/2000/svg">
<style>
.bg { fill: #000000; }
.fg { fill: #F2FF59; }
@media (prefers-color-scheme: dark) {
.bg { fill: #F2FF59; }
.fg { fill: #000000; }
}
</style>
<circle class="bg" cx="24" cy="24" r="24"/>
<g transform="translate(7.8 6.72) scale(0.72)">
<path class="fg" d="M35.6487 36.021C35.733 35.7387 35.7791 35.4411 35.7791 35.1283C35.7791 33.3963 34.3675 31.9924 32.6262 31.9924H18.4956C17.7361 32 17.1147 31.3896 17.1147 30.6342C17.1147 30.4969 17.1377 30.3672 17.1684 30.2451L20.9734 17.0606C21.1345 16.4807 21.6715 16.0534 22.3005 16.0534L36.4848 16.0382C39.4766 16.0382 42.0005 14.0315 42.76 11.2923L44.8926 3.94468C44.9616 3.68526 45 3.40296 45 3.12065C45 1.39628 43.5961 0 41.8624 0L24.7017 0C21.7252 0 19.209 1.99142 18.4342 4.70005L16.992 9.71292C16.8232 10.2852 16.2939 10.7048 15.6648 10.7048H11.5453C8.59189 10.7048 6.0987 12.6581 5.30089 15.3362L0.11507 33.3505C0.0383566 33.6175 0 33.9075 0 34.1974C0 35.9294 1.41152 37.3333 3.15292 37.3333H7.20338C7.96284 37.3333 8.58421 37.9437 8.58421 38.7067C8.58421 38.8364 8.56887 38.9661 8.53051 39.0882L7.09598 44.0553C7.02694 44.3224 6.98091 44.597 6.98091 44.8794C6.98091 46.6037 8.38476 48 10.1185 48L27.2869 47.9847C30.2711 47.9847 32.7873 45.9857 33.5544 43.2618L35.641 36.0286L35.6487 36.021Z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

@@ -36,7 +36,9 @@ let pendingFrame = 0
const HEADER_OFFSET = -144
const ACTIVATION_OFFSET = 300
const deptElementId = (key: string) => `careers-dept-${key}`
function deptElementId(key: string) {
return `careers-dept-${key}`
}
function pickActiveSection() {
pendingFrame = 0

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

@@ -58,7 +58,7 @@ onMounted(() => {
})
raw.sort((a, b) => {
const norm = (v: number) => {
function norm(v: number) {
const r = v + Math.PI / 2
return r < 0 ? r + 2 * Math.PI : r
}
@@ -117,7 +117,7 @@ onMounted(() => {
applyToPanel(panels[1], elapsed2)
applyToPanel(panels[2], elapsed3)
const wOf = (elapsed: number) => {
function wOf(elapsed: number) {
const progress = elapsed < PANEL_DURATION ? elapsed / PANEL_DURATION : 1
return lerp(S.w, E.w, ease(progress))
}

View File

@@ -35,7 +35,7 @@ export function useParallax(
const triggerEl = options.trigger?.value
const createAnimations = () => {
function createAnimations() {
const els = elements
.map((r) => r.value)
.filter((el): el is HTMLElement => !!el && el.offsetParent !== null)

View File

@@ -29,7 +29,7 @@ function interpolateY(
contentH: number,
vpH: number
) {
const clampedTarget = (i: number) => {
function clampedTarget(i: number) {
const center = buttonCenters[i] ?? 0
return Math.max(-(contentH - vpH), Math.min(0, vpH / 2 - center))
}

View File

@@ -20,11 +20,16 @@ const baseRoutes = {
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
}
@@ -32,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

@@ -1773,6 +1773,7 @@ const translations = {
'footer.support': { en: 'Support', 'zh-CN': '支持' },
'footer.sales': { en: 'Sales', 'zh-CN': '销售' },
'footer.press': { en: 'Press', 'zh-CN': '媒体' },
'footer.cloudStatus': { en: 'Cloud Status', 'zh-CN': '云端状态' },
'footer.blog': { en: 'Blog', 'zh-CN': '博客' },
'footer.location': {
en: 'San Francisco, USA',
@@ -2049,269 +2050,594 @@ const translations = {
},
// ── Terms of Service ──────────────────────────────────────────────
'tos.intro.label': { en: 'INTRO', 'zh-CN': '简介' },
'tos.effectiveDateLabel': {
en: 'Effective Date',
'zh-CN': 'Effective Date'
},
'tos.effectiveDate': {
en: 'May 13, 2026',
'zh-CN': 'May 13, 2026'
},
'tos.intro.label': { en: 'INTRO', 'zh-CN': 'INTRO' },
'tos.intro.block.0': {
en: 'Welcome to the ComfyUI offering, provided by Comfy Organization, Inc.',
'zh-CN': '欢迎使用由 Comfy Organization, Inc. 提供的 ComfyUI 产品。'
en: 'These Comfy Terms of Service (the “Agreement”) are made available by Comfy Organization, Inc., a Delaware corporation (“Comfy”) and set forth your rights and obligations when accessing the Comfy Products (as defined below).',
'zh-CN':
'These Comfy Terms of Service (the “Agreement”) are made available by Comfy Organization, Inc., a Delaware corporation (“Comfy”) and set forth your rights and obligations when accessing the Comfy Products (as defined below).'
},
'tos.intro.block.1': {
en: 'Please read these Terms of Service (these "Terms") carefully, as they constitute a legally binding agreement between Comfy Organization, Inc., a Delaware corporation ("Comfy Org," "We," "Us," or "Our"), and an end-user ("You" and "Your") and apply to Your use of the Services (as defined below). In case You are subscribing to the Services as a representative of or on behalf of an entity (e.g., Your employer, the "Client" or "Entity"), Your acceptance of these Terms also binds the Client or Entity, and any reference in these Terms to "You" shall also mean the "Client" or "Entity" and its affiliates.',
en: 'The Agreement is entered into by and between Comfy and the entity or person accessing the Comfy Products (“Customer” or “you”). If you are accessing or using the Comfy Products on behalf of your company, you represent that you are authorized to enter into the Agreement on behalf of your company.',
'zh-CN':
'请仔细阅读本服务条款(以下简称"条款"),因为它们构成 Comfy Organization, Inc.(一家特拉华州公司,以下简称"Comfy Org"、"我们")与最终用户("您")之间具有法律约束力的协议,并适用于您对服务(定义见下文)的使用。如果您以实体(例如您的雇主,即"客户"或"实体")的代表身份或代表其订阅服务,您对本条款的接受也约束该客户或实体,本条款中对"您"的任何引用也应指"客户"或"实体"及其关联方。'
'The Agreement is entered into by and between Comfy and the entity or person accessing the Comfy Products (“Customer” or “you”). If you are accessing or using the Comfy Products on behalf of your company, you represent that you are authorized to enter into the Agreement on behalf of your company.'
},
'tos.intro.block.2': {
en: 'You hereby agree to accept these Terms by (a) either using the Services, or (b) by opening an account under a username. BEFORE YOU DO EITHER OF THOSE, PLEASE READ THESE TERMS CAREFULLY. IF YOU DO NOT WANT TO AGREE TO THESE TERMS, YOU MUST NOT USE THE SERVICES OR SET UP AN ACCOUNT.',
en: 'PLEASE REVIEW THESE TERMS OF SERVICE CAREFULLY. ONCE ACCEPTED, THE TERMS AND CONDITIONS OF THE AGREEMENT WILL BECOME A BINDING LEGAL COMMITMENT BETWEEN YOU AND COMFY. IF YOU DO NOT AGREE TO BE BOUND BY THESE TERMS OF SERVICE, YOU SHOULD NOT ACCEPT THESE TERMS OF SERVICE AND MAY NOT USE THE PLATFORM.',
'zh-CN':
'您特此同意通过以下方式接受本条款:(a) 使用服务,或 (b) 以用户名开设账户。在您执行上述任何操作之前,请仔细阅读本条款。如果您不同意本条款,则不得使用服务或设置账户。'
'PLEASE REVIEW THESE TERMS OF SERVICE CAREFULLY. ONCE ACCEPTED, THE TERMS AND CONDITIONS OF THE AGREEMENT WILL BECOME A BINDING LEGAL COMMITMENT BETWEEN YOU AND COMFY. IF YOU DO NOT AGREE TO BE BOUND BY THESE TERMS OF SERVICE, YOU SHOULD NOT ACCEPT THESE TERMS OF SERVICE AND MAY NOT USE THE PLATFORM.'
},
'tos.intro.block.3': {
en: 'You also agree to abide by other Comfy Org rules and policies, including our Privacy Policy https://www.comfy.org/privacy-policy (which explains what information we collect from You and how we protect it) that are expressly incorporated into and are a part of these Terms. Please read them carefully.',
'zh-CN':
'您还同意遵守 Comfy Org 的其他规则和政策,包括我们的隐私政策 https://www.comfy.org/privacy-policy该政策说明了我们从您处收集的信息以及如何保护这些信息这些规则和政策明确纳入本条款并构成其组成部分。请仔细阅读。'
},
'tos.intro.block.4': {
en: 'Once you accept these Terms You are bound by them until they are terminated. See Section 10 (Term and Termination).',
'zh-CN':
'一旦您接受本条款,您将受其约束,直至条款终止。请参阅第 10 条(期限和终止)。'
},
'tos.intro.block.5': {
en: 'By accessing or using the Software or Services in any way, You represent that (1) You have read, understand, and hereby agree to be bound by these Terms, (2) You are of legal age to form a binding contract with Comfy Org, and (3) You have the authority to enter into these Terms personally or on behalf of the Client Entity. If You do not agree to be bound by, or cannot conform with, these Terms, You may not use the Services. You will be legally and financially responsible for all actions using or accessing the Services, including the actions of anyone You allow to access Your Account.',
'zh-CN':
'通过以任何方式访问或使用软件或服务,您声明:(1) 您已阅读、理解并特此同意受本条款的约束,(2) 您已达到与 Comfy Org 签订具有约束力的合同的法定年龄,(3) 您有权以个人身份或代表客户实体签订本条款。如果您不同意受本条款约束或无法遵守本条款,则不得使用服务。您将对使用或访问服务的所有行为承担法律和财务责任,包括您允许访问您账户的任何人的行为。'
},
'tos.intro.block.6': {
en: 'IF YOU ACCEPT THESE TERMS, YOU AND COMFY ORG AGREE TO RESOLVE DISPUTES IN BINDING, INDIVIDUAL ARBITRATION AND GIVE UP THE RIGHT TO GO TO COURT INDIVIDUALLY OR AS PART OF A CLASS ACTION.',
'zh-CN':
'如果您接受本条款,您和 COMFY ORG 同意通过具有约束力的个人仲裁解决争议,并放弃以个人身份或作为集体诉讼一部分提起诉讼的权利。'
},
'tos.definitions.label': { en: 'DEFINITIONS', 'zh-CN': '定义' },
'tos.definitions.title': { en: '1. Definitions', 'zh-CN': '1. 定义' },
'tos.definitions.label': { en: 'DEFINITIONS', 'zh-CN': 'DEFINITIONS' },
'tos.definitions.title': { en: '1. Definitions', 'zh-CN': '1. Definitions' },
'tos.definitions.block.0': {
en: '"Business User" mean an entity or individual using the Software or Services primarily for business, commercial, or professional purposes.',
en: '“Affiliates” means any entity that directly or indirectly controls, is controlled by, or is under common control with a party, where “control” means the ownership of more than fifty percent (50%) of the voting securities or other voting interests of such entity.',
'zh-CN':
'"商业用户"指主要出于商业、贸易或专业目的使用软件或服务的实体或个人。'
'“Affiliates” means any entity that directly or indirectly controls, is controlled by, or is under common control with a party, where “control” means the ownership of more than fifty percent (50%) of the voting securities or other voting interests of such entity.'
},
'tos.definitions.block.1': {
en: '"ComfyUI Branding" means the names, logos, and associated trademarks owned or in progress of being owned by Comfy Org, Inc.',
en: '“Applicable Laws” means all federal and state laws, treaties, rules, regulations, regulatory and supervisory guidance, directives, policies, orders or determinations of a regulatory authority applicable to the activities and obligations contemplated under this Agreement.',
'zh-CN':
'"ComfyUI 品牌"指 Comfy Org, Inc. 拥有或正在申请拥有的名称、标志和相关商标。'
'“Applicable Laws” means all federal and state laws, treaties, rules, regulations, regulatory and supervisory guidance, directives, policies, orders or determinations of a regulatory authority applicable to the activities and obligations contemplated under this Agreement.'
},
'tos.definitions.block.2': {
en: '"ComfyUI Software" or "Software" means the open-source software product named "ComfyUI," including its desktop applications, source code, and user interface elements.',
en: 'Comfy API” means the application programming interface and related developer tools made available by Comfy that allow you to access and execute visual AI workflows programmatically as production endpoints from within your own applications or systems.',
'zh-CN':
'"ComfyUI 软件"或"软件"指名为"ComfyUI"的开源软件产品,包括其桌面应用程序、源代码和用户界面元素。'
'Comfy API” means the application programming interface and related developer tools made available by Comfy that allow you to access and execute visual AI workflows programmatically as production endpoints from within your own applications or systems.'
},
'tos.definitions.block.3': {
en: '"Customer Data" means any data, content, information, prompts, or workflows that You submit, upload, transmit, or process through the Software or Services.',
en: '“Comfy Branding” means the names, logos, and associated trademarks owned or in progress of being owned by Comfy.',
'zh-CN':
'"客户数据"指您通过软件或服务提交、上传、传输或处理的任何数据、内容、信息、提示词或工作流。'
'“Comfy Branding” means the names, logos, and associated trademarks owned or in progress of being owned by Comfy.'
},
'tos.definitions.block.4': {
en: '"Consumer User" means an individual using the Software or Services primarily for personal, family, or household purposes.',
'zh-CN': '"消费者用户"指主要出于个人、家庭或家用目的使用软件或服务的个人。'
en: 'Comfy Cloud” means the cloud-based hosting environment made available by Comfy that allows you to access and run visual AI workflows remotely through Comfys infrastructure, without requiring local installation or hardware.',
'zh-CN':
'“Comfy Cloud” means the cloud-based hosting environment made available by Comfy that allows you to access and run visual AI workflows remotely through Comfys infrastructure, without requiring local installation or hardware.'
},
'tos.definitions.block.5': {
en: '"Intellectual Property Rights" means all (i) patents, patent disclosures, and inventions (whether patentable or not), (ii) trademarks, (iii) copyrights and copyrightable works (including computer programs), and rights in data and databases, and (iv) all other intellectual property rights, in each case whether registered or unregistered and including all applications for, and renewals or extensions of, such rights, and all similar or equivalent rights or forms of protection in any part of the world.',
en: '“Comfy Enterprise” means the enterprise-grade product tier made available by Comfy that provides organizations with dedicated infrastructure, enhanced security, administrative controls, and related support services for deploying and managing visual AI workflows at scale.',
'zh-CN':
'"知识产权"指所有 (i) 专利、专利披露和发明(无论是否可获得专利),(ii) 商标,(iii) 版权和可受版权保护的作品(包括计算机程序)以及数据和数据库权利,(iv) 所有其他知识产权,在每种情况下无论已注册或未注册,包括所有此类权利的申请、续展或延期,以及世界任何地区的所有类似或等同的权利或保护形式。'
'“Comfy Enterprise” means the enterprise-grade product tier made available by Comfy that provides organizations with dedicated infrastructure, enhanced security, administrative controls, and related support services for deploying and managing visual AI workflows at scale.'
},
'tos.definitions.block.6': {
en: '"Open Source License" means the specific open-source license(s) governing the ComfyUI Software, primarily the GNU General Public License v3 (GPLv3) for its UI elements and potentially other components.',
en: '“Comfy OSS” means the open-source software, source code, libraries, tools, and related components made available by Comfy under one or more open source licenses, including the software repositories published by Comfy at <a href="https://github.com/Comfy-Org" class="text-white underline">https://github.com/Comfy-Org</a>, as updated, modified, or supplemented from time to time. For the avoidance of doubt, Comfy OSS does not include any proprietary software, infrastructure, or functionality made available by Comfy under these Terms of Service or in connection with any commercial product or offering.',
'zh-CN':
'"开源许可证"指管辖 ComfyUI 软件的特定开源许可证,主要是用于其 UI 元素的 GNU 通用公共许可证第 3 版 (GPLv3) 以及可能适用于其他组件的许可证。'
'“Comfy OSS” means the open-source software, source code, libraries, tools, and related components made available by Comfy under one or more open source licenses, including the software repositories published by Comfy at <a href="https://github.com/Comfy-Org" class="text-white underline">https://github.com/Comfy-Org</a>, as updated, modified, or supplemented from time to time. For the avoidance of doubt, Comfy OSS does not include any proprietary software, infrastructure, or functionality made available by Comfy under these Terms of Service or in connection with any commercial product or offering.'
},
'tos.definitions.block.7': {
en: '"Providers" means certain third-party service providers utilized by Comfy Org for certain functionality, including hosting and payment processing.',
en: '“Comfy Products” means Comfy Cloud, Comfy API, Comfy Enterprise and other products, software, features, tools, and functionality made available by Comfy to you under these Terms of Service, excluding any Comfy OSS.',
'zh-CN':
'"提供商"指 Comfy Org 用于某些功能的特定第三方服务提供商,包括托管和支付处理。'
'“Comfy Products” means Comfy Cloud, Comfy API, Comfy Enterprise and other products, software, features, tools, and functionality made available by Comfy to you under these Terms of Service, excluding any Comfy OSS.'
},
'tos.definitions.block.8': {
en: '"Services" means all current and future commercial and auxiliary services provided by Comfy Org in connection with the ComfyUI Software, including but not limited to:',
en: '“Customer Data” means electronic data and information submitted or generated by Customer in connection with its use of the Comfy Products, including all Inputs and Outputs.',
'zh-CN':
'"服务"指 Comfy Org 与 ComfyUI 软件相关的所有当前和未来的商业及辅助服务,包括但不限于:'
'“Customer Data” means electronic data and information submitted or generated by Customer in connection with its use of the Comfy Products, including all Inputs and Outputs.'
},
'tos.definitions.block.9': {
en: 'Commercial services:',
'zh-CN': '商业服务:'
en: '“Open Source License” means the open source license(s) under which Comfy makes Comfy OSS available, as identified in the applicable source code repository.',
'zh-CN':
'“Open Source License” means the open source license(s) under which Comfy makes Comfy OSS available, as identified in the applicable source code repository.'
},
'tos.definitions.block.10': {
en: 'Comfy Cloud — paid and fully managed cloud based ComfyUI hosted in our data centers\nAPI Nodes — paid integrations with third-party API services available within ComfyUI\nSupport, Training, Consulting — paid services related to ComfyUI',
en: '“Operational Metadata” means usage and diagnostic information generated by the Comfy Products and collected by Comfy to support, maintain, and optimize the performance and security of the Comfy Products, including information regarding software versions, system configuration, uptime, error logs, health metrics, and feature usage. Operational Metadata does not include Customer Data or Confidential Information.',
'zh-CN':
'Comfy Cloud——付费的、完全托管的、基于云的 ComfyUI托管在我们的数据中心\nAPI 节点——ComfyUI 中可用的与第三方 API 服务的付费集成\n支持、培训、咨询——与 ComfyUI 相关的付费服务'
'“Operational Metadata” means usage and diagnostic information generated by the Comfy Products and collected by Comfy to support, maintain, and optimize the performance and security of the Comfy Products, including information regarding software versions, system configuration, uptime, error logs, health metrics, and feature usage. Operational Metadata does not include Customer Data or Confidential Information.'
},
'tos.definitions.block.11': {
en: 'Open source services:',
'zh-CN': '开源服务:'
en: '“Order Form” means the online sign-up flow, order form or other ordering document entered into or otherwise agreed by Customer that references this Agreement.',
'zh-CN':
'“Order Form” means the online sign-up flow, order form or other ordering document entered into or otherwise agreed by Customer that references this Agreement.'
},
'tos.definitions.block.12': {
en: 'Custom Node Registry — marketplace of custom nodes freely available to ComfyUI users\nAny other hosted experiences or tools offered by Comfy Org.',
en: '“User” means Customers or Customers Affiliates employees and contractors who are authorized by Customer to access and use the Comfy Products on Customers or Customers Affiliates behalf according to the terms of this Agreement.',
'zh-CN':
'自定义节点 Registry——ComfyUI 用户免费使用的自定义节点市场\nComfy Org 提供的任何其他托管体验或工具。'
'“User” means Customers or Customers Affiliates employees and contractors who are authorized by Customer to access and use the Comfy Products on Customers or Customers Affiliates behalf according to the terms of this Agreement.'
},
'tos.license.label': { en: 'LICENSE', 'zh-CN': '许可' },
'tos.license.title': {
en: '2. ComfyUI Software License',
'zh-CN': '2. ComfyUI 软件许可'
'tos.comfy-products.label': {
en: 'COMFY PRODUCTS',
'zh-CN': 'COMFY PRODUCTS'
},
'tos.license.block.0': {
en: 'Open Source Nature. The ComfyUI Software itself is open-source and distributed under the terms of the GNU General Public License v3 (GPLv3), or other specific open-source licenses for particular components, as applicable. Your rights to use, modify, and distribute the ComfyUI Software are governed by the respective Open Source Licenses.',
'tos.comfy-products.title': {
en: '2. Comfy Products',
'zh-CN': '2. Comfy Products'
},
'tos.comfy-products.block.0.heading': {
en: 'Right to Access and Use Comfy Products.',
'zh-CN': 'Right to Access and Use Comfy Products.'
},
'tos.comfy-products.block.1': {
en: 'Subject to your compliance with all of the terms and conditions of this Agreement, Comfy grants you and your Users a non-exclusive, non-sublicensable, non-transferable right during the term of this Agreement to access and use the Comfy Products as set forth in the applicable Order Form for your internal business purposes.',
'zh-CN':
'开源性质。ComfyUI 软件本身是开源的,根据 GNU 通用公共许可证第 3 版 (GPLv3) 或其他适用于特定组件的开源许可证的条款进行分发。您使用、修改和分发 ComfyUI 软件的权利受相应开源许可证的约束。'
'Subject to your compliance with all of the terms and conditions of this Agreement, Comfy grants you and your Users a non-exclusive, non-sublicensable, non-transferable right during the term of this Agreement to access and use the Comfy Products as set forth in the applicable Order Form for your internal business purposes.'
},
'tos.license.block.1': {
en: 'No Charge for Software. Comfy Org explicitly acknowledges that we do not charge for the ComfyUI Software itself. The fees outlined in these Terms are solely for the Services we provide around the Software, such as hosting, compute, and additional functionalities.',
'tos.comfy-products.block.2.heading': {
en: 'Customer Data.',
'zh-CN': 'Customer Data.'
},
'tos.comfy-products.block.3': {
en: 'As between Comfy and Customer, Customer retains all right, title, and interest in and to any data, images, videos, prompts, models, workflows, nodes, parameters, or other materials submitted or uploaded by Customer to the Comfy Products (“Input”), as well as any images, videos, designs, or other visual content generated through Customers use of the Comfy Products as a result of processing Customers Input (“Output”). Customer acknowledges that due to the nature of artificial intelligence, Comfy may generate the same or similar Output for other customers, and Customer shall have no right, title, or interest in or to Output generated for any other customer.',
'zh-CN':
'软件免费。Comfy Org 明确承认我们不对 ComfyUI 软件本身收费。本条款中列出的费用仅用于我们围绕软件提供的服务,例如托管、计算和附加功能。'
'As between Comfy and Customer, Customer retains all right, title, and interest in and to any data, images, videos, prompts, models, workflows, nodes, parameters, or other materials submitted or uploaded by Customer to the Comfy Products (“Input”), as well as any images, videos, designs, or other visual content generated through Customers use of the Comfy Products as a result of processing Customers Input (“Output”). Customer acknowledges that due to the nature of artificial intelligence, Comfy may generate the same or similar Output for other customers, and Customer shall have no right, title, or interest in or to Output generated for any other customer.'
},
'tos.license.block.2': {
en: 'Service Updates. You understand that the Software is evolving, and features and benefits You receive upon Your initial use may change. You acknowledge and agree that Comfy Org may update the Software with or without notifying You, including adding or removing features, products, or functionalities.',
'tos.comfy-products.block.4.heading': {
en: 'No AI Training.',
'zh-CN': 'No AI Training.'
},
'tos.comfy-products.block.5': {
en: 'Comfy will not use Input or Output to train generative AI or diffusion models. Comfy may, however, collect and use limited metadata derived from Customers use of the Comfy Products, such as prompt classifications, workflow structures, and node configurations, to improve the performance, functionality, and user experience of the Comfy Products.',
'zh-CN':
'服务更新。您理解软件在不断发展,您初次使用时获得的功能和优势可能会发生变化。您承认并同意 Comfy Org 可能会在通知或不通知您的情况下更新软件,包括添加或删除功能、产品或特性。'
'Comfy will not use Input or Output to train generative AI or diffusion models. Comfy may, however, collect and use limited metadata derived from Customers use of the Comfy Products, such as prompt classifications, workflow structures, and node configurations, to improve the performance, functionality, and user experience of the Comfy Products.'
},
'tos.using-services.label': { en: 'USAGE', 'zh-CN': '使用服务' },
'tos.using-services.title': {
en: '3. Using the Services',
'zh-CN': '3. 使用服务'
'tos.comfy-products.block.6.heading': {
en: 'Comfy OSS.',
'zh-CN': 'Comfy OSS.'
},
'tos.using-services.block.0': {
en: 'Open Source Nature. The ComfyUI Software itself is open-source and distributed under the terms of the GNU General Public License v3 (GPLv3), or other specific open-source licenses for particular components, as applicable. Your rights to use, modify, and distribute the ComfyUI Software are governed by the respective Open Source Licenses.',
'tos.comfy-products.block.7': {
en: 'You may use Comfy OSS under the terms of the applicable Open Source License(s) governing each respective component, as identified in the corresponding source code repository, rather than under these Terms. Nothing in these Terms shall be construed to limit, supersede, or modify any rights or obligations arising under an applicable Open Source License. If you choose to use the Comfy Products in conjunction with Comfy OSS, these Terms apply solely to your use of the Comfy Products and not to the Comfy OSS itself.',
'zh-CN':
'开源性质。ComfyUI 软件本身是开源的,根据 GNU 通用公共许可证第 3 版 (GPLv3) 或其他适用于特定组件的开源许可证的条款进行分发。您使用、修改和分发 ComfyUI 软件的权利受相应开源许可证的约束。'
'You may use Comfy OSS under the terms of the applicable Open Source License(s) governing each respective component, as identified in the corresponding source code repository, rather than under these Terms. Nothing in these Terms shall be construed to limit, supersede, or modify any rights or obligations arising under an applicable Open Source License. If you choose to use the Comfy Products in conjunction with Comfy OSS, these Terms apply solely to your use of the Comfy Products and not to the Comfy OSS itself.'
},
'tos.using-services.block.1': {
en: 'No Charge for Software. Comfy Org explicitly acknowledges that we do not charge for the ComfyUI Software itself. The fees outlined in these Terms are solely for the Services we provide around the Software, such as hosting, compute, and additional functionalities.',
'tos.comfy-products.block.8.heading': {
en: 'Partner Nodes.',
'zh-CN': 'Partner Nodes.'
},
'tos.comfy-products.block.9': {
en: 'Certain features of the Comfy Products allow you to access third-party AI model providers (“Partner Nodes”) through Comfy. When you use a Partner Node, Comfy proxies your request to the applicable third-party provider, transmitting the information necessary to fulfill your request, including prompts, images, models, and parameters. Comfy does not transmit your identity or account information to third-party providers in connection with Partner Node requests. Your use of Partner Nodes is subject to the terms and policies of the applicable third-party provider, and Comfy is not responsible for the data practices of such providers. Usage of Partner Nodes is metered and billed through Comfy.',
'zh-CN':
'软件免费。Comfy Org 明确承认我们不对 ComfyUI 软件本身收费。本条款中列出的费用仅用于我们围绕软件提供的服务,例如托管、计算和附加功能。'
'Certain features of the Comfy Products allow you to access third-party AI model providers (“Partner Nodes”) through Comfy. When you use a Partner Node, Comfy proxies your request to the applicable third-party provider, transmitting the information necessary to fulfill your request, including prompts, images, models, and parameters. Comfy does not transmit your identity or account information to third-party providers in connection with Partner Node requests. Your use of Partner Nodes is subject to the terms and policies of the applicable third-party provider, and Comfy is not responsible for the data practices of such providers. Usage of Partner Nodes is metered and billed through Comfy.'
},
'tos.using-services.block.2': {
en: 'Service Updates. You understand that the Software is evolving, and features and benefits You receive upon Your initial use may change. You acknowledge and agree that Comfy Org may update the Software with or without notifying You, including adding or removing features, products, or functionalities.',
'tos.comfy-products.block.10.heading': {
en: 'Modification of Comfy Products.',
'zh-CN': 'Modification of Comfy Products.'
},
'tos.comfy-products.block.11': {
en: 'Comfy may, at any time and in its sole discretion, modify, update, enhance, restrict, suspend, or discontinue the Comfy Products, in whole or in part, including by changing or removing features, functionality, endpoints, specifications, documentation, access methods, usage limits, or availability. Comfy has no obligation to maintain or support any particular version of the Comfy Products or to ensure backward compatibility. Any such modifications may be made with or without notice and may result in interruptions to or degradation of the Comfy Products. Comfy shall have no liability arising out of or related to any modification, suspension, or discontinuation of the Comfy Products, and Customer acknowledges that its use of the Comfy Products is at its own risk and that it should not rely on the continued availability of any aspect of the Comfy Products.',
'zh-CN':
'服务更新。您理解软件在不断发展,您初次使用时获得的功能和优势可能会发生变化。您承认并同意 Comfy Org 可能会在通知或不通知您的情况下更新软件,包括添加或删除功能、产品或特性。'
'Comfy may, at any time and in its sole discretion, modify, update, enhance, restrict, suspend, or discontinue the Comfy Products, in whole or in part, including by changing or removing features, functionality, endpoints, specifications, documentation, access methods, usage limits, or availability. Comfy has no obligation to maintain or support any particular version of the Comfy Products or to ensure backward compatibility. Any such modifications may be made with or without notice and may result in interruptions to or degradation of the Comfy Products. Comfy shall have no liability arising out of or related to any modification, suspension, or discontinuation of the Comfy Products, and Customer acknowledges that its use of the Comfy Products is at its own risk and that it should not rely on the continued availability of any aspect of the Comfy Products.'
},
'tos.responsibilities.label': { en: 'RESPONSIBILITIES', 'zh-CN': '您的责任' },
'tos.responsibilities.title': {
en: '4. Your Responsibilities',
'zh-CN': '4. 您的责任'
'tos.comfy-products.block.12.heading': {
en: 'Data Retention and Deletion.',
'zh-CN': 'Data Retention and Deletion.'
},
'tos.responsibilities.block.0': {
en: 'You are responsible for your use of the Services and any content you create, share, or distribute through them. You agree to use the Services in a manner that is lawful, respectful, and consistent with these Terms. You are solely responsible for maintaining the security of your account credentials.',
'tos.comfy-products.block.13': {
en: 'Comfy retains Customer Data for as long as your account remains active or as otherwise necessary to provide the Comfy Products, comply with applicable legal obligations, resolve disputes, and enforce this Agreement. Specific retention periods for different categories of Customer Data are set forth in Comfys retention documentation, available at <a href="https://docs.comfy.org/support/data-retention" class="text-white underline">docs.comfy.org/support/data-retention</a>, as updated from time to time. You may request deletion of your account and associated Customer Data by contacting Comfy at <a href="mailto:legal@comfy.org" class="text-white underline">legal@comfy.org</a>. Upon receipt of a verified deletion request, Comfy will use commercially reasonable efforts to delete or de-identify your personal information from its primary systems within a reasonable time. You acknowledge that: (i) deletion may not propagate immediately to all backup systems, third-party analytics providers, or observability systems, which retain data subject to their own retention policies; (ii) certain Customer Data may be retained as required by applicable law or for legitimate business purposes such as billing records; and (iii) aggregated or de-identified data derived from your use of the Comfy Products may be retained indefinitely.',
'zh-CN':
'您应对使用服务以及通过服务创建、共享或分发的任何内容负责。您同意以合法、尊重他人且符合本条款的方式使用服务。您全权负责维护账户凭据的安全。'
'Comfy retains Customer Data for as long as your account remains active or as otherwise necessary to provide the Comfy Products, comply with applicable legal obligations, resolve disputes, and enforce this Agreement. Specific retention periods for different categories of Customer Data are set forth in Comfys retention documentation, available at <a href="https://docs.comfy.org/support/data-retention" class="text-white underline">docs.comfy.org/support/data-retention</a>, as updated from time to time. You may request deletion of your account and associated Customer Data by contacting Comfy at <a href="mailto:legal@comfy.org" class="text-white underline">legal@comfy.org</a>. Upon receipt of a verified deletion request, Comfy will use commercially reasonable efforts to delete or de-identify your personal information from its primary systems within a reasonable time. You acknowledge that: (i) deletion may not propagate immediately to all backup systems, third-party analytics providers, or observability systems, which retain data subject to their own retention policies; (ii) certain Customer Data may be retained as required by applicable law or for legitimate business purposes such as billing records; and (iii) aggregated or de-identified data derived from your use of the Comfy Products may be retained indefinitely.'
},
'tos.restrictions.label': { en: 'RESTRICTIONS', 'zh-CN': '限制' },
'tos.restrictions.title': {
en: '5. Use Restrictions',
'zh-CN': '5. 使用限制'
'tos.customer-responsibilities.label': {
en: 'RESPONSIBILITIES',
'zh-CN': 'RESPONSIBILITIES'
},
'tos.restrictions.block.0': {
en: 'You agree not to misuse the Services. This includes, but is not limited to:',
'zh-CN': '您同意不滥用服务,包括但不限于:'
'tos.customer-responsibilities.title': {
en: '3. Customer Responsibilities',
'zh-CN': '3. Customer Responsibilities'
},
'tos.restrictions.block.1': {
en: 'Attempting to gain unauthorized access to any part of the Services\nUsing the Services to distribute malware, viruses, or harmful code\nInterfering with or disrupting the integrity or performance of the Services\nScraping, crawling, or using automated means to access the Services without permission\nPublishing custom nodes or workflows that contain malicious code or violate third-party rights',
'tos.customer-responsibilities.block.0.heading': {
en: 'Registration.',
'zh-CN': 'Registration.'
},
'tos.customer-responsibilities.block.1': {
en: 'In order to access and use the Comfy Products, you may be required to register an account by providing us with your email and other information requested in our registration form. You agree to provide us with complete and accurate registration information. You may not attempt to impersonate another person in registration. If you are registering on behalf of an organization, you warrant that you are authorized to agree to this Agreement on their behalf. You agree to be responsible for the security of your account. You accept that you are solely responsible for all activities that take place through your account, and that failure to limit access to your devices or systems may permit unauthorized use by third parties.',
'zh-CN':
'试图未经授权访问服务的任何部分\n利用服务传播恶意软件、病毒或有害代码\n干扰或破坏服务的完整性或性能\n未经许可使用自动化手段抓取或爬取服务\n发布包含恶意代码或侵犯第三方权利的自定义节点或工作流'
'In order to access and use the Comfy Products, you may be required to register an account by providing us with your email and other information requested in our registration form. You agree to provide us with complete and accurate registration information. You may not attempt to impersonate another person in registration. If you are registering on behalf of an organization, you warrant that you are authorized to agree to this Agreement on their behalf. You agree to be responsible for the security of your account. You accept that you are solely responsible for all activities that take place through your account, and that failure to limit access to your devices or systems may permit unauthorized use by third parties.'
},
'tos.accounts.label': { en: 'ACCOUNTS', 'zh-CN': '账户' },
'tos.accounts.title': {
en: '6. Accounts and User Information',
'zh-CN': '6. 账户和用户信息'
'tos.customer-responsibilities.block.2.heading': {
en: 'General Technology Restrictions.',
'zh-CN': 'General Technology Restrictions.'
},
'tos.accounts.block.0': {
en: 'Certain features of the Services may require you to create an account. You agree to provide accurate and complete information when creating your account and to keep this information up to date. You are responsible for all activity that occurs under your account. We reserve the right to suspend or terminate accounts that violate these Terms.',
'tos.customer-responsibilities.block.3': {
en: 'You agree that you will not, directly or indirectly: (i) sublicense the Comfy Products for use by a third party; (ii) reverse engineer or attempt to extract the source code or underlying methodology from the Comfy Products or any related software, except to the extent that this restriction is expressly prohibited by Applicable Laws; (iii) use or facilitate the use of the Comfy Products for any activities that are prohibited by Applicable Laws or otherwise; (iv) bypass or circumvent measures employed to prevent or limit access to the Comfy Products; (v) use the Comfy Products to create a product or service competitive with Comfys products or services; (vi) create derivative works of or otherwise create, attempt to create or derive, or knowingly assist any third party to create or derive, the source code underlying the Comfy Products; or (vii) otherwise use or interact with the Comfy Products for any purpose not expressly permitted under this Agreement.',
'zh-CN':
'服务的某些功能可能要求您创建账户。您同意在创建账户时提供准确、完整的信息,并及时更新。您对账户下发生的所有活动负责。我们保留暂停或终止违反本条款的账户的权利。'
'You agree that you will not, directly or indirectly: (i) sublicense the Comfy Products for use by a third party; (ii) reverse engineer or attempt to extract the source code or underlying methodology from the Comfy Products or any related software, except to the extent that this restriction is expressly prohibited by Applicable Laws; (iii) use or facilitate the use of the Comfy Products for any activities that are prohibited by Applicable Laws or otherwise; (iv) bypass or circumvent measures employed to prevent or limit access to the Comfy Products; (v) use the Comfy Products to create a product or service competitive with Comfys products or services; (vi) create derivative works of or otherwise create, attempt to create or derive, or knowingly assist any third party to create or derive, the source code underlying the Comfy Products; or (vii) otherwise use or interact with the Comfy Products for any purpose not expressly permitted under this Agreement.'
},
'tos.ip.label': { en: 'IP RIGHTS', 'zh-CN': '知识产权' },
'tos.ip.title': {
en: '7. Intellectual Property Rights',
'zh-CN': '7. 知识产权'
'tos.customer-responsibilities.block.4.heading': {
en: 'Acceptable Use; Prohibited Customer Data.',
'zh-CN': 'Acceptable Use; Prohibited Customer Data.'
},
'tos.ip.block.0': {
en: 'The Services, excluding open-source components, are owned by Comfy and are protected by intellectual property laws. The Comfy name, logo, and branding are trademarks of Comfy Org, Inc. You retain ownership of any User Content you create. By submitting User Content to the Services, you grant Comfy a non-exclusive, worldwide, royalty-free license to host, display, and distribute such content as necessary to operate the Services.',
'tos.customer-responsibilities.block.5': {
en: 'Customer is solely responsible for ensuring that all Input submitted to the Comfy Products complies with all Applicable Laws, and Customer agrees that it will not, and will not permit any third party to submit to Comfy or the Comfy Products or otherwise use the Comfy Products to create: (i) any data, designs, or other materials subject to U.S. export control laws and regulations; (ii) any viruses, malware, ransomware, Trojan horses, worms, spyware, or other malicious or harmful code or content that could damage, disrupt, interfere with, or compromise the Comfy Products, Comfys systems or infrastructure, or the data or systems of any other user or third party; (iii) any Customer Data that depicts, promotes, or facilitates illegal activity, including without limitation child sexual abuse material, non-consensual intimate imagery, or content that incites violence or hatred against any individual or group; (iv) any Customer Data that infringes or misappropriates the intellectual property rights, privacy rights, or publicity rights of any third party, including without limitation by submitting models, images, or other materials without the right to do so; (v) any content or information that is intentionally deceptive or misleading, including without limitation synthetic media designed to impersonate a real individual without their consent; or (vi) any Customer Data that could reasonably be expected to cause harm to any individual or group.',
'zh-CN':
'除开源组件外,服务归 Comfy 所有并受知识产权法保护。Comfy 名称、标志和品牌是 Comfy Org, Inc. 的商标。您保留您创建的任何用户内容的所有权。向服务提交用户内容即表示您授予 Comfy 一项非排他性、全球性、免版税的许可,以在运营服务所需的范围内托管、展示和分发此类内容。'
'Customer is solely responsible for ensuring that all Input submitted to the Comfy Products complies with all Applicable Laws, and Customer agrees that it will not, and will not permit any third party to submit to Comfy or the Comfy Products or otherwise use the Comfy Products to create: (i) any data, designs, or other materials subject to U.S. export control laws and regulations; (ii) any viruses, malware, ransomware, Trojan horses, worms, spyware, or other malicious or harmful code or content that could damage, disrupt, interfere with, or compromise the Comfy Products, Comfys systems or infrastructure, or the data or systems of any other user or third party; (iii) any Customer Data that depicts, promotes, or facilitates illegal activity, including without limitation child sexual abuse material, non-consensual intimate imagery, or content that incites violence or hatred against any individual or group; (iv) any Customer Data that infringes or misappropriates the intellectual property rights, privacy rights, or publicity rights of any third party, including without limitation by submitting models, images, or other materials without the right to do so; (v) any content or information that is intentionally deceptive or misleading, including without limitation synthetic media designed to impersonate a real individual without their consent; or (vi) any Customer Data that could reasonably be expected to cause harm to any individual or group.'
},
'tos.distribution.label': { en: 'DISTRIBUTION', 'zh-CN': '分发' },
'tos.distribution.title': {
en: '8. Model and Workflow Distribution',
'zh-CN': '8. 模型和工作流分发'
'tos.payment.label': { en: 'PAYMENT', 'zh-CN': 'PAYMENT' },
'tos.payment.title': { en: '4. Payment', 'zh-CN': '4. Payment' },
'tos.payment.block.0.heading': {
en: 'Plans; Fees; Free Tier.',
'zh-CN': 'Plans; Fees; Free Tier.'
},
'tos.distribution.block.0': {
en: 'When you distribute models, workflows, or custom nodes through the Registry or Services, you represent that you have the right to distribute such content and that it does not infringe any third-party rights. You are responsible for specifying an appropriate license for any content you distribute. Comfy does not claim ownership of content distributed through the Registry.',
'tos.payment.block.1': {
en: 'Your use of the Comfy Products is subject to the plan selected via the applicable ordering page, online sign-up flow, or order form (“Plan”). Comfy may offer a free or freemium tier (“Free Tier”) and one or more paid tiers; the applicable Plan may include usage caps, feature restrictions, throttling, overage charges, or upgrade requirements, each as described in the pricing page or applicable Order Form. You are responsible for all usage under your account, including usage by your Users and under your credentials and API keys. Comfy may modify, suspend, or discontinue any Plan (including the Free Tier) consistent with this Agreement and the Order Forms.',
'zh-CN':
'当您通过 Registry 或服务分发模型、工作流或自定义节点时您声明您有权分发此类内容且其不侵犯任何第三方权利。您有责任为分发的内容指定适当的许可证。Comfy 不主张对通过 Registry 分发的内容的所有权。'
'Your use of the Comfy Products is subject to the plan selected via the applicable ordering page, online sign-up flow, or order form (“Plan”). Comfy may offer a free or freemium tier (“Free Tier”) and one or more paid tiers; the applicable Plan may include usage caps, feature restrictions, throttling, overage charges, or upgrade requirements, each as described in the pricing page or applicable Order Form. You are responsible for all usage under your account, including usage by your Users and under your credentials and API keys. Comfy may modify, suspend, or discontinue any Plan (including the Free Tier) consistent with this Agreement and the Order Forms.'
},
'tos.fees.label': { en: 'FEES', 'zh-CN': '费用' },
'tos.fees.title': { en: '9. Fees and Payment', 'zh-CN': '9. 费用和付款' },
'tos.fees.block.0': {
en: 'Certain Services may be offered for a fee. If you choose to use paid features, you agree to pay all applicable fees as described at the time of purchase. Fees are non-refundable except as required by law or as expressly stated in these Terms. Comfy reserves the right to change pricing with reasonable notice.',
'tos.payment.block.2.heading': {
en: 'Self-Serve Credit Card Billing.',
'zh-CN': 'Self-Serve Credit Card Billing.'
},
'tos.payment.block.3': {
en: 'For self-serve Plans, Customer will provide a valid payment method (e.g., credit card) and authorizes Comfy (and its payment processor) to charge all fees and taxes when due. Unless the Order Forms state otherwise, subscription components (if any) will be billed in advance on a recurring basis and usage-based components (including any overages) will be billed in arrears for the applicable billing period (and may be charged as usage accrues). Paid self-serve Plans automatically renew for successive billing periods until cancelled through the console or as otherwise described in the Order Forms; if a charge fails, Comfy may retry the charge and Customer must promptly update its payment method.',
'zh-CN':
'某些服务可能需要付费。如果您选择使用付费功能则同意支付购买时所述的所有适用费用。除法律要求或本条款明确规定外费用不予退还。Comfy 保留在合理通知后变更定价的权利。'
'For self-serve Plans, Customer will provide a valid payment method (e.g., credit card) and authorizes Comfy (and its payment processor) to charge all fees and taxes when due. Unless the Order Forms state otherwise, subscription components (if any) will be billed in advance on a recurring basis and usage-based components (including any overages) will be billed in arrears for the applicable billing period (and may be charged as usage accrues). Paid self-serve Plans automatically renew for successive billing periods until cancelled through the console or as otherwise described in the Order Forms; if a charge fails, Comfy may retry the charge and Customer must promptly update its payment method.'
},
'tos.termination.label': { en: 'TERMINATION', 'zh-CN': '终止' },
'tos.termination.title': {
en: '10. Term and Termination',
'zh-CN': '10. 期限和终止'
'tos.payment.block.4.heading': {
en: 'Invoiced Billing.',
'zh-CN': 'Invoiced Billing.'
},
'tos.termination.block.0': {
en: 'These Terms remain in effect while you use the Services. You may stop using the Services at any time. Comfy may suspend or terminate your access to the Services at any time, with or without cause and with or without notice. Upon termination, your right to use the Services will immediately cease. Sections that by their nature should survive termination will continue to apply.',
'tos.payment.block.5': {
en: 'If Comfy approves invoiced billing for Customer, Comfy will invoice Customer in accordance with the applicable Order Form, and Customer will pay all undisputed amounts within thirty (30) days of the invoice date. Any purchase Order Forms are for administrative convenience only and will not modify this Agreement. Customer will notify Comfy in writing of any good-faith dispute regarding an invoice within thirty (30) days of the invoice date and will timely pay all undisputed amounts while the parties work to resolve the dispute.',
'zh-CN':
'在您使用服务期间本条款持续有效。您可随时停止使用服务。Comfy 可随时暂停或终止您对服务的访问,无论是否有原因,也无论是否事先通知。终止后,您使用服务的权利将立即终止。按其性质应在终止后继续有效的条款将继续适用。'
'If Comfy approves invoiced billing for Customer, Comfy will invoice Customer in accordance with the applicable Order Form, and Customer will pay all undisputed amounts within thirty (30) days of the invoice date. Any purchase Order Forms are for administrative convenience only and will not modify this Agreement. Customer will notify Comfy in writing of any good-faith dispute regarding an invoice within thirty (30) days of the invoice date and will timely pay all undisputed amounts while the parties work to resolve the dispute.'
},
'tos.warranties.label': { en: 'WARRANTIES', 'zh-CN': '免责' },
'tos.warranties.title': {
en: '11. Disclaimer of Warranties',
'zh-CN': '11. 免责声明'
'tos.payment.block.6.heading': {
en: 'Prepaid Credits.',
'zh-CN': 'Prepaid Credits.'
},
'tos.warranties.block.0': {
en: 'THE SERVICES ARE PROVIDED "AS IS" AND "AS AVAILABLE" WITHOUT WARRANTIES OF ANY KIND, EITHER EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO IMPLIED WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, AND NON-INFRINGEMENT. COMFY DOES NOT WARRANT THAT THE SERVICES WILL BE UNINTERRUPTED, ERROR-FREE, OR SECURE.',
'tos.payment.block.7': {
en: 'Customer may prepay for usage credits (“Credits”) which may be applied toward usage of the Comfy Products at the rates set forth on Comfys pricing page. Except for documented billing errors or similar service issues attributed to Comfy, all purchases of Credits are final and non-refundable, and Comfy will not issue refunds or credits for any unused, partially used, or remaining Credits under any circumstances, including upon termination or expiration of Customers account. Comfy reserves the right to modify the pricing or Credit redemption rates applicable to future Credit purchases upon reasonable notice, but any Credits purchased prior to such modification will be honored at the rates in effect at the time of purchase.',
'zh-CN':
'服务按"现状"和"可用"基础提供不附带任何形式的明示或暗示保证包括但不限于对适销性、特定用途适用性和非侵权性的暗示保证。Comfy 不保证服务将不间断、无错误或安全。'
'Customer may prepay for usage credits (“Credits”) which may be applied toward usage of the Comfy Products at the rates set forth on Comfys pricing page. Except for documented billing errors or similar service issues attributed to Comfy, all purchases of Credits are final and non-refundable, and Comfy will not issue refunds or credits for any unused, partially used, or remaining Credits under any circumstances, including upon termination or expiration of Customers account. Comfy reserves the right to modify the pricing or Credit redemption rates applicable to future Credit purchases upon reasonable notice, but any Credits purchased prior to such modification will be honored at the rates in effect at the time of purchase.'
},
'tos.liability.label': { en: 'LIABILITY', 'zh-CN': '责任限制' },
'tos.payment.block.8.heading': {
en: 'Taxes; Price Changes; No Refunds.',
'zh-CN': 'Taxes; Price Changes; No Refunds.'
},
'tos.payment.block.9': {
en: 'Fees are exclusive of all taxes, duties, levies, and similar governmental assessments (including sales, use, VAT/GST, and withholding taxes), and Customer is responsible for all such amounts other than taxes based on Comfys net income; if withholding is required by law, Customer will gross up payments so Comfy receives the invoiced amount, unless prohibited by law. Comfy may change fees or introduce new fees upon prior notice (including by posting to the pricing page or in-product notice), effective as of the next billing period or as otherwise stated in the notice. Except as required by law or expressly stated in the Order Forms, all fees are non-cancellable and non-refundable.',
'zh-CN':
'Fees are exclusive of all taxes, duties, levies, and similar governmental assessments (including sales, use, VAT/GST, and withholding taxes), and Customer is responsible for all such amounts other than taxes based on Comfys net income; if withholding is required by law, Customer will gross up payments so Comfy receives the invoiced amount, unless prohibited by law. Comfy may change fees or introduce new fees upon prior notice (including by posting to the pricing page or in-product notice), effective as of the next billing period or as otherwise stated in the notice. Except as required by law or expressly stated in the Order Forms, all fees are non-cancellable and non-refundable.'
},
'tos.payment.block.10.heading': {
en: 'Late Payments; Suspension.',
'zh-CN': 'Late Payments; Suspension.'
},
'tos.payment.block.11': {
en: 'Overdue undisputed amounts may accrue interest at the lesser of 1.5% per month or the maximum rate permitted by law, plus reasonable collection costs. Comfy may suspend or limit access to the Comfy Products (including throttling, disabling API keys, or downgrading to the Free Tier) for non-payment of undisputed amounts after providing commercially reasonable notice and an opportunity to cure, unless Comfy reasonably determines immediate suspension is necessary to protect the Comfy Products or comply with Applicable Laws.',
'zh-CN':
'Overdue undisputed amounts may accrue interest at the lesser of 1.5% per month or the maximum rate permitted by law, plus reasonable collection costs. Comfy may suspend or limit access to the Comfy Products (including throttling, disabling API keys, or downgrading to the Free Tier) for non-payment of undisputed amounts after providing commercially reasonable notice and an opportunity to cure, unless Comfy reasonably determines immediate suspension is necessary to protect the Comfy Products or comply with Applicable Laws.'
},
'tos.term-termination.label': {
en: 'TERM; TERMINATION',
'zh-CN': 'TERM; TERMINATION'
},
'tos.term-termination.title': {
en: '5. Term; Termination',
'zh-CN': '5. Term; Termination'
},
'tos.term-termination.block.0.heading': {
en: 'Termination of Agreement.',
'zh-CN': 'Termination of Agreement.'
},
'tos.term-termination.block.1': {
en: 'You may stop using the Comfy Products at any time with or without notice. This Agreement will remain in effect until terminated in accordance with this Section. Either party may terminate this Agreement for convenience upon written notice to the other; provided, however, that to the extent the parties have entered into one or more executed Order Forms with a stated term, such Order Form will remain in effect for its stated term unless earlier terminated in accordance with its terms or this Agreement, and termination of this Agreement will not, by itself, terminate any then-effective Order Form.',
'zh-CN':
'You may stop using the Comfy Products at any time with or without notice. This Agreement will remain in effect until terminated in accordance with this Section. Either party may terminate this Agreement for convenience upon written notice to the other; provided, however, that to the extent the parties have entered into one or more executed Order Forms with a stated term, such Order Form will remain in effect for its stated term unless earlier terminated in accordance with its terms or this Agreement, and termination of this Agreement will not, by itself, terminate any then-effective Order Form.'
},
'tos.term-termination.block.2.heading': {
en: 'Effect of Termination.',
'zh-CN': 'Effect of Termination.'
},
'tos.term-termination.block.3': {
en: 'Upon any termination or expiration of an Order Form (or this Agreement, if no Order Form is then in effect), Customer will promptly cease all use of the Comfy Products under the terminated arrangement and, if applicable, any continued use must be pursuant to a then-effective Order Form or other written authorization from Comfy. Comfy may suspend or terminate Customers access to the Comfy Products, or discontinue the Comfy Products or any portion or feature thereof, at any time; provided that Comfy will not terminate an unexpired Order Form for convenience unless the applicable Order Form expressly permits it, and any suspension or termination may be implemented immediately if Comfy reasonably determines that Customers use poses a security risk, violates this Agreement, or materially degrades the Comfy Products. Except as expressly set forth in an Order Form, Comfy will have no liability or other obligation to Customer arising out of or relating to any termination, suspension, or discontinuance under this Section.',
'zh-CN':
'Upon any termination or expiration of an Order Form (or this Agreement, if no Order Form is then in effect), Customer will promptly cease all use of the Comfy Products under the terminated arrangement and, if applicable, any continued use must be pursuant to a then-effective Order Form or other written authorization from Comfy. Comfy may suspend or terminate Customers access to the Comfy Products, or discontinue the Comfy Products or any portion or feature thereof, at any time; provided that Comfy will not terminate an unexpired Order Form for convenience unless the applicable Order Form expressly permits it, and any suspension or termination may be implemented immediately if Comfy reasonably determines that Customers use poses a security risk, violates this Agreement, or materially degrades the Comfy Products. Except as expressly set forth in an Order Form, Comfy will have no liability or other obligation to Customer arising out of or relating to any termination, suspension, or discontinuance under this Section.'
},
'tos.term-termination.block.4.heading': {
en: 'Survival.',
'zh-CN': 'Survival.'
},
'tos.term-termination.block.5': {
en: 'Termination or expiration will not affect any rights or obligations, including the payment of amounts due, which have accrued under this Agreement up to the date of termination or expiration. Upon termination or expiration of this Agreement, the provisions that are intended by their nature to survive termination will survive and continue in full force and effect in accordance with their terms, including confidentiality obligations, proprietary rights, indemnification, limitations of liability, and disclaimers.',
'zh-CN':
'Termination or expiration will not affect any rights or obligations, including the payment of amounts due, which have accrued under this Agreement up to the date of termination or expiration. Upon termination or expiration of this Agreement, the provisions that are intended by their nature to survive termination will survive and continue in full force and effect in accordance with their terms, including confidentiality obligations, proprietary rights, indemnification, limitations of liability, and disclaimers.'
},
'tos.confidentiality.label': {
en: 'CONFIDENTIALITY',
'zh-CN': 'CONFIDENTIALITY'
},
'tos.confidentiality.title': {
en: '6. Confidentiality',
'zh-CN': '6. Confidentiality'
},
'tos.confidentiality.block.0.heading': {
en: 'Definition of Confidential Information.',
'zh-CN': 'Definition of Confidential Information.'
},
'tos.confidentiality.block.1': {
en: '“Confidential Information” means all non-public information disclosed by a party (“Disclosing Party”) to the other party (“Receiving Party”), whether oral or written, that is designated as confidential or that reasonably should be understood to be confidential given the nature of the information and circumstances of disclosure. Confidential Information of Customer includes Customer Data; Confidential Information of Comfy includes the Comfy Products; and each partys Confidential Information includes the terms of this Agreement and any Order Forms (including pricing), as well as business, financial, marketing, technical, and product information. Confidential Information excludes information that the Receiving Party can demonstrate: (i) is or becomes publicly available without breach; (ii) was known prior to disclosure without breach; (iii) is received from a third party without breach; or (iv) was independently developed without use of or reference to the Disclosing Partys Confidential Information.',
'zh-CN':
'“Confidential Information” means all non-public information disclosed by a party (“Disclosing Party”) to the other party (“Receiving Party”), whether oral or written, that is designated as confidential or that reasonably should be understood to be confidential given the nature of the information and circumstances of disclosure. Confidential Information of Customer includes Customer Data; Confidential Information of Comfy includes the Comfy Products; and each partys Confidential Information includes the terms of this Agreement and any Order Forms (including pricing), as well as business, financial, marketing, technical, and product information. Confidential Information excludes information that the Receiving Party can demonstrate: (i) is or becomes publicly available without breach; (ii) was known prior to disclosure without breach; (iii) is received from a third party without breach; or (iv) was independently developed without use of or reference to the Disclosing Partys Confidential Information.'
},
'tos.confidentiality.block.2.heading': {
en: 'Protection of Confidential Information.',
'zh-CN': 'Protection of Confidential Information.'
},
'tos.confidentiality.block.3': {
en: 'The Receiving Party will: (a) protect Confidential Information using at least reasonable care; (b) use it solely to perform under this Agreement; and (c) limit access to its and its Affiliates employees and contractors with a need to know and confidentiality obligations at least as protective as those herein. Neither party may disclose the terms of this Agreement or any Order Form except to its Affiliates, legal counsel, or accountants, and remains responsible for their compliance. Upon written request, the Receiving Party will promptly return or destroy Confidential Information, except for information retained in routine backups or as required by law or internal retention policies.',
'zh-CN':
'The Receiving Party will: (a) protect Confidential Information using at least reasonable care; (b) use it solely to perform under this Agreement; and (c) limit access to its and its Affiliates employees and contractors with a need to know and confidentiality obligations at least as protective as those herein. Neither party may disclose the terms of this Agreement or any Order Form except to its Affiliates, legal counsel, or accountants, and remains responsible for their compliance. Upon written request, the Receiving Party will promptly return or destroy Confidential Information, except for information retained in routine backups or as required by law or internal retention policies.'
},
'tos.confidentiality.block.4.heading': {
en: 'Compelled Disclosure.',
'zh-CN': 'Compelled Disclosure.'
},
'tos.confidentiality.block.5': {
en: 'The Receiving Party may disclose Confidential Information if legally required, provided it gives prior notice (where permitted) and reasonable assistance, at the Disclosing Partys expense, to seek protective treatment. Any disclosure will be limited to what is legally required, and the Receiving Party will request confidential treatment. These obligations survive while Confidential Information remains in the Receiving Partys possession.',
'zh-CN':
'The Receiving Party may disclose Confidential Information if legally required, provided it gives prior notice (where permitted) and reasonable assistance, at the Disclosing Partys expense, to seek protective treatment. Any disclosure will be limited to what is legally required, and the Receiving Party will request confidential treatment. These obligations survive while Confidential Information remains in the Receiving Partys possession.'
},
'tos.confidentiality.block.6.heading': {
en: 'Data Security.',
'zh-CN': 'Data Security.'
},
'tos.confidentiality.block.7': {
en: 'Comfy will implement and maintain commercially reasonable administrative, technical, and physical safeguards designed to protect Customer Data against unauthorized access, disclosure, alteration, or destruction. These measures will be no less protective than those Comfy uses to protect its own confidential information of a similar nature. In the event Comfy becomes aware of a confirmed security breach that results in unauthorized access to or disclosure of Customer Data, Comfy will notify Customer without undue delay and will provide reasonable cooperation to assist Customer in investigating and mitigating the effects of such breach. Customer acknowledges that no security measures are perfect or impenetrable, and Comfy does not guarantee that Customer Data will be free from unauthorized access or disclosure.',
'zh-CN':
'Comfy will implement and maintain commercially reasonable administrative, technical, and physical safeguards designed to protect Customer Data against unauthorized access, disclosure, alteration, or destruction. These measures will be no less protective than those Comfy uses to protect its own confidential information of a similar nature. In the event Comfy becomes aware of a confirmed security breach that results in unauthorized access to or disclosure of Customer Data, Comfy will notify Customer without undue delay and will provide reasonable cooperation to assist Customer in investigating and mitigating the effects of such breach. Customer acknowledges that no security measures are perfect or impenetrable, and Comfy does not guarantee that Customer Data will be free from unauthorized access or disclosure.'
},
'tos.proprietary-rights.label': {
en: 'PROPRIETARY RIGHTS',
'zh-CN': 'PROPRIETARY RIGHTS'
},
'tos.proprietary-rights.title': {
en: '7. Proprietary Rights',
'zh-CN': '7. Proprietary Rights'
},
'tos.proprietary-rights.block.0.heading': {
en: 'Reservation of Rights.',
'zh-CN': 'Reservation of Rights.'
},
'tos.proprietary-rights.block.1': {
en: 'Comfy and its licensors retain all right, title, and interest, including all intellectual property and proprietary rights, in and to the Comfy Products, Comfy Branding, and all software, code, algorithms, protocols, interfaces, tools, documentation, data structures, and other technology underlying or embodied in, or used to provide, the Comfy Products (collectively, “Comfy Materials”). Except for the limited rights expressly granted to Customer under this Agreement, no rights or licenses are granted, whether by implication, estoppel, or otherwise. Comfy expressly reserves all rights in and to the Comfy Materials not expressly granted hereunder.',
'zh-CN':
'Comfy and its licensors retain all right, title, and interest, including all intellectual property and proprietary rights, in and to the Comfy Products, Comfy Branding, and all software, code, algorithms, protocols, interfaces, tools, documentation, data structures, and other technology underlying or embodied in, or used to provide, the Comfy Products (collectively, “Comfy Materials”). Except for the limited rights expressly granted to Customer under this Agreement, no rights or licenses are granted, whether by implication, estoppel, or otherwise. Comfy expressly reserves all rights in and to the Comfy Materials not expressly granted hereunder.'
},
'tos.proprietary-rights.block.2.heading': {
en: 'Feedback.',
'zh-CN': 'Feedback.'
},
'tos.proprietary-rights.block.3': {
en: 'You may from time to time provide feedback (including suggestions, comments for enhancements, functionality or usability, etc.) (“Feedback”) to Comfy regarding your experience using, and needs and integration requirements for, the Comfy Products. Comfy shall have full discretion to determine whether or not to proceed with the development of any requested enhancements, new features or functionality, and you hereby grant Comfy the full, unencumbered, royalty-free right to incorporate and otherwise fully exploit Feedback in connection with Comfys products and services.',
'zh-CN':
'You may from time to time provide feedback (including suggestions, comments for enhancements, functionality or usability, etc.) (“Feedback”) to Comfy regarding your experience using, and needs and integration requirements for, the Comfy Products. Comfy shall have full discretion to determine whether or not to proceed with the development of any requested enhancements, new features or functionality, and you hereby grant Comfy the full, unencumbered, royalty-free right to incorporate and otherwise fully exploit Feedback in connection with Comfys products and services.'
},
'tos.proprietary-rights.block.4.heading': {
en: 'Operational Metadata.',
'zh-CN': 'Operational Metadata.'
},
'tos.proprietary-rights.block.5': {
en: 'Customer agrees that Comfy may collect and use Operational Metadata to operate, maintain, improve, and support the Comfy Products, including for diagnostics, analytics, system performance, and reporting purposes. Comfy will only disclose Operational Metadata externally if such data is (a) aggregated or anonymized with data across other customers, and (b) does not disclose the identity of Customer or any Customer Confidential Information.',
'zh-CN':
'Customer agrees that Comfy may collect and use Operational Metadata to operate, maintain, improve, and support the Comfy Products, including for diagnostics, analytics, system performance, and reporting purposes. Comfy will only disclose Operational Metadata externally if such data is (a) aggregated or anonymized with data across other customers, and (b) does not disclose the identity of Customer or any Customer Confidential Information.'
},
'tos.disclaimer.label': { en: 'DISCLAIMER', 'zh-CN': 'DISCLAIMER' },
'tos.disclaimer.title': { en: '8. Disclaimer', 'zh-CN': '8. Disclaimer' },
'tos.disclaimer.block.0': {
en: 'THE Comfy Products AND OUTPUT ARE PROVIDED “AS IS” WITHOUT ANY WARRANTY OF ANY KIND. Comfy DISCLAIMS ANY AND ALL WARRANTIES, REPRESENTATIONS, AND CONDITIONS RELATING TO THE Comfy Products (INCLUDING ANY OUTPUT), WHETHER EXPRESS, IMPLIED, INCLUDING, BUT NOT LIMITED TO, ANY REPRESENTATION, WARRANTY, OR CONDITION OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, TITLE OR NON-INFRINGEMENT. YOU AGREE AND ACKNOWLEDGE THAT YOUR USE OF ANY OUTPUT PROVIDED BY THE Comfy Products IS AT YOUR OWN RISK.',
'zh-CN':
'THE Comfy Products AND OUTPUT ARE PROVIDED “AS IS” WITHOUT ANY WARRANTY OF ANY KIND. Comfy DISCLAIMS ANY AND ALL WARRANTIES, REPRESENTATIONS, AND CONDITIONS RELATING TO THE Comfy Products (INCLUDING ANY OUTPUT), WHETHER EXPRESS, IMPLIED, INCLUDING, BUT NOT LIMITED TO, ANY REPRESENTATION, WARRANTY, OR CONDITION OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, TITLE OR NON-INFRINGEMENT. YOU AGREE AND ACKNOWLEDGE THAT YOUR USE OF ANY OUTPUT PROVIDED BY THE Comfy Products IS AT YOUR OWN RISK.'
},
'tos.disclaimer.block.1': {
en: 'Customer is solely responsible for (a) verifying the Output is appropriate for Customers use case, and (b) any decisions, actions, or omissions taken in reliance on the OUTPUT. in no event will Comfy be liable for any damages or losses arising from or related to Customers use of or reliance on the OUTPUT, including any decisions made or actions taken based on the OUTPUT.',
'zh-CN':
'Customer is solely responsible for (a) verifying the Output is appropriate for Customers use case, and (b) any decisions, actions, or omissions taken in reliance on the OUTPUT. in no event will Comfy be liable for any damages or losses arising from or related to Customers use of or reliance on the OUTPUT, including any decisions made or actions taken based on the OUTPUT.'
},
'tos.liability.label': { en: 'LIABILITY', 'zh-CN': 'LIABILITY' },
'tos.liability.title': {
en: '12. Limitation of Liability',
'zh-CN': '12. 责任限制'
en: '9. Limitation of Liability',
'zh-CN': '9. Limitation of Liability'
},
'tos.liability.block.0': {
en: "TO THE MAXIMUM EXTENT PERMITTED BY LAW, COMFY SHALL NOT BE LIABLE FOR ANY INDIRECT, INCIDENTAL, SPECIAL, CONSEQUENTIAL, OR PUNITIVE DAMAGES, OR ANY LOSS OF PROFITS OR REVENUES, WHETHER INCURRED DIRECTLY OR INDIRECTLY, OR ANY LOSS OF DATA, USE, GOODWILL, OR OTHER INTANGIBLE LOSSES RESULTING FROM YOUR USE OF THE SERVICES. COMFY'S TOTAL LIABILITY SHALL NOT EXCEED THE AMOUNTS PAID BY YOU TO COMFY IN THE TWELVE MONTHS PRECEDING THE CLAIM.",
en: 'WHEN PERMITTED BY LAW, COMFY, AND COMFYS SUPPLIERS AND DISTRIBUTORS, WILL NOT BE RESPONSIBLE FOR LOST PROFITS, REVENUES, OR DATA; FINANCIAL LOSSES; OR INDIRECT, SPECIAL, CONSEQUENTIAL, EXEMPLARY, OR PUNITIVE DAMAGES. TO THE EXTENT PERMITTED BY LAW, THE TOTAL LIABILITY OF Comfy, AND ITS SUPPLIERS AND DISTRIBUTORS, FOR ANY CLAIM UNDER THIS AGREEMENT, INCLUDING FOR ANY IMPLIED WARRANTIES, IS LIMITED TO THE GREATER OF (A) ONE THOUSAND DOLLARS ($1,000); AND (B) THE AMOUNTS PAID OR PAYABLE BY CUSTOMER IN THE SIX (6) MONTHS PRECEDING THE DATE OF THE CLAIM. IN ALL CASES, Comfy, AND ITS SUPPLIERS AND DISTRIBUTORS, WILL NOT BE LIABLE FOR ANY EXPENSE, LOSS, OR DAMAGE THAT IS NOT REASONABLY FORESEEABLE.',
'zh-CN':
'在法律允许的最大范围内Comfy 不对任何间接、附带、特殊、后果性或惩罚性损害或任何利润或收入损失无论是直接还是间接产生的或任何数据、使用、商誉或其他无形损失承担责任。Comfy 的总责任不超过您在索赔前十二个月内向 Comfy 支付的金额。'
'WHEN PERMITTED BY LAW, COMFY, AND COMFYS SUPPLIERS AND DISTRIBUTORS, WILL NOT BE RESPONSIBLE FOR LOST PROFITS, REVENUES, OR DATA; FINANCIAL LOSSES; OR INDIRECT, SPECIAL, CONSEQUENTIAL, EXEMPLARY, OR PUNITIVE DAMAGES. TO THE EXTENT PERMITTED BY LAW, THE TOTAL LIABILITY OF Comfy, AND ITS SUPPLIERS AND DISTRIBUTORS, FOR ANY CLAIM UNDER THIS AGREEMENT, INCLUDING FOR ANY IMPLIED WARRANTIES, IS LIMITED TO THE GREATER OF (A) ONE THOUSAND DOLLARS ($1,000); AND (B) THE AMOUNTS PAID OR PAYABLE BY CUSTOMER IN THE SIX (6) MONTHS PRECEDING THE DATE OF THE CLAIM. IN ALL CASES, Comfy, AND ITS SUPPLIERS AND DISTRIBUTORS, WILL NOT BE LIABLE FOR ANY EXPENSE, LOSS, OR DAMAGE THAT IS NOT REASONABLY FORESEEABLE.'
},
'tos.indemnification.label': {
en: 'INDEMNIFICATION',
'zh-CN': 'INDEMNIFICATION'
},
'tos.indemnification.label': { en: 'INDEMNIFICATION', 'zh-CN': '赔偿' },
'tos.indemnification.title': {
en: '13. Indemnification',
'zh-CN': '13. 赔偿'
en: '10. Indemnification',
'zh-CN': '10. Indemnification'
},
'tos.indemnification.block.0': {
en: 'You agree to indemnify, defend, and hold harmless Comfy, its officers, directors, employees, and agents from and against any claims, liabilities, damages, losses, and expenses arising out of or in any way connected with your access to or use of the Services, your User Content, or your violation of these Terms.',
en: 'You agree to defend, indemnify, and hold harmless Comfy Organization, Inc. and its officers, directors, employees, contractors, and agents from and against any and all third-party claims, demands, actions, suits, or proceedings, and any resulting losses, damages, liabilities, costs, and expenses (including reasonable attorneys fees) to the extent resulting from your Customer Data or your breach of this Agreement. You must not settle any claim without Comfys prior written consent if the settlement would require Comfy to (a) admit fault, (b) pay any damages or other amounts, or (c) take or refrain from taking any action. Comfy may participate in a claim through counsel of its own choosing at its own expense, and you and Comfy will reasonably cooperate on the defense of any such claim.',
'zh-CN':
'您同意赔偿、辩护并使 Comfy 及其管理人员、董事、员工和代理人免受因您访问或使用服务、您的用户内容或您违反本条款而产生的或与之相关的任何索赔、责任、损害、损失和费用。'
'You agree to defend, indemnify, and hold harmless Comfy Organization, Inc. and its officers, directors, employees, contractors, and agents from and against any and all third-party claims, demands, actions, suits, or proceedings, and any resulting losses, damages, liabilities, costs, and expenses (including reasonable attorneys fees) to the extent resulting from your Customer Data or your breach of this Agreement. You must not settle any claim without Comfys prior written consent if the settlement would require Comfy to (a) admit fault, (b) pay any damages or other amounts, or (c) take or refrain from taking any action. Comfy may participate in a claim through counsel of its own choosing at its own expense, and you and Comfy will reasonably cooperate on the defense of any such claim.'
},
'tos.governing-law.label': { en: 'GOVERNING LAW', 'zh-CN': '适用法律' },
'tos.governing-law.title': {
en: '14. Governing Law and Dispute Resolution',
'zh-CN': '14. 适用法律和争议解决'
'tos.dispute-resolution.label': {
en: 'DISPUTE RESOLUTION',
'zh-CN': 'DISPUTE RESOLUTION'
},
'tos.governing-law.block.0': {
en: 'These Terms shall be governed by and construed in accordance with the laws of the State of Delaware, without regard to its conflict of laws principles. Any disputes arising under these Terms shall be resolved through binding arbitration in accordance with the rules of the American Arbitration Association, except that either party may seek injunctive relief in any court of competent jurisdiction.',
'tos.dispute-resolution.title': {
en: '11. Governing Law and Dispute Resolution',
'zh-CN': '11. Governing Law and Dispute Resolution'
},
'tos.dispute-resolution.block.0.heading': {
en: 'Governing Law.',
'zh-CN': 'Governing Law.'
},
'tos.dispute-resolution.block.1': {
en: 'This Agreement and any dispute, claim, or controversy arising out of or relating to this Agreement, the Comfy Products, or the parties relationship (each, a “Dispute”), shall be governed by and construed in accordance with the laws of the State of California, without regard to conflict of laws principles that would result in the application of the laws of any other jurisdiction.',
'zh-CN':
'本条款受特拉华州法律管辖并据其解释,不适用其冲突法原则。因本条款引起的任何争议应根据美国仲裁协会的规则通过有约束力的仲裁解决,但任何一方均可在有管辖权的法院寻求禁令救济。'
'This Agreement and any dispute, claim, or controversy arising out of or relating to this Agreement, the Comfy Products, or the parties relationship (each, a “Dispute”), shall be governed by and construed in accordance with the laws of the State of California, without regard to conflict of laws principles that would result in the application of the laws of any other jurisdiction.'
},
'tos.miscellaneous.label': { en: 'MISCELLANEOUS', 'zh-CN': '其他' },
'tos.miscellaneous.title': { en: '15. Miscellaneous', 'zh-CN': '15. 其他' },
'tos.miscellaneous.block.0': {
en: 'These Terms constitute the entire agreement between you and Comfy regarding the Services. If any provision of these Terms is found to be unenforceable, the remaining provisions will continue in effect. Our failure to enforce any right or provision of these Terms will not be considered a waiver. We may assign our rights under these Terms. You may not assign your rights without our prior written consent.',
'tos.dispute-resolution.block.2.heading': {
en: 'Binding Arbitration; JAMS.',
'zh-CN': 'Binding Arbitration; JAMS.'
},
'tos.dispute-resolution.block.3': {
en: 'Except as expressly set forth in Section 11(c) (Exceptions; Injunctive Relief), any Dispute shall be finally resolved by binding arbitration administered by JAMS in accordance with the JAMS Comprehensive Arbitration Rules and Procedures (or, if applicable, the JAMS Streamlined Arbitration Rules and Procedures), as in effect at the time the arbitration is commenced. The arbitration shall be seated in San Francisco, California, and conducted in English before one (1) arbitrator. Judgment on the award rendered by the arbitrator may be entered in any court of competent jurisdiction.',
'zh-CN':
'本条款构成您与 Comfy 之间关于服务的完整协议。如果本条款的任何条款被认定为不可执行,其余条款将继续有效。我们未能执行本条款的任何权利或条款不构成放弃。我们可以转让本条款下的权利。未经我们事先书面同意,您不得转让您的权利。'
'Except as expressly set forth in Section 11(c) (Exceptions; Injunctive Relief), any Dispute shall be finally resolved by binding arbitration administered by JAMS in accordance with the JAMS Comprehensive Arbitration Rules and Procedures (or, if applicable, the JAMS Streamlined Arbitration Rules and Procedures), as in effect at the time the arbitration is commenced. The arbitration shall be seated in San Francisco, California, and conducted in English before one (1) arbitrator. Judgment on the award rendered by the arbitrator may be entered in any court of competent jurisdiction.'
},
'tos.contact.label': { en: 'CONTACT', 'zh-CN': '联系' },
'tos.contact.title': { en: 'Contact Us', 'zh-CN': '联系我们' },
'tos.dispute-resolution.block.4.heading': {
en: 'Exceptions; Injunctive Relief.',
'zh-CN': 'Exceptions; Injunctive Relief.'
},
'tos.dispute-resolution.block.5': {
en: 'Notwithstanding anything to the contrary, either party may seek temporary, preliminary, or permanent injunctive relief (or other equitable relief) in any court of competent jurisdiction located in San Francisco, CA to prevent or enjoin actual or threatened misuse, infringement, or misappropriation of its intellectual property rights, confidential information, or proprietary rights, without the necessity of posting bond or proving actual damages to the extent permitted by Applicable Law. In addition, either party may bring an individual claim in small claims court in San Francisco, CA, if the claim qualifies.',
'zh-CN':
'Notwithstanding anything to the contrary, either party may seek temporary, preliminary, or permanent injunctive relief (or other equitable relief) in any court of competent jurisdiction located in San Francisco, CA to prevent or enjoin actual or threatened misuse, infringement, or misappropriation of its intellectual property rights, confidential information, or proprietary rights, without the necessity of posting bond or proving actual damages to the extent permitted by Applicable Law. In addition, either party may bring an individual claim in small claims court in San Francisco, CA, if the claim qualifies.'
},
'tos.dispute-resolution.block.6.heading': {
en: 'Class Action Waiver.',
'zh-CN': 'Class Action Waiver.'
},
'tos.dispute-resolution.block.7': {
en: 'To the fullest extent permitted by Applicable Law, the parties agree that any Dispute will be brought and resolved on an individual basis only, and not as a plaintiff or class member in any purported class, collective, consolidated, coordinated, or representative action or proceeding. The arbitrator may not consolidate claims or preside over any form of representative or class proceeding.',
'zh-CN':
'To the fullest extent permitted by Applicable Law, the parties agree that any Dispute will be brought and resolved on an individual basis only, and not as a plaintiff or class member in any purported class, collective, consolidated, coordinated, or representative action or proceeding. The arbitrator may not consolidate claims or preside over any form of representative or class proceeding.'
},
'tos.dispute-resolution.block.8.heading': {
en: 'Waiver of Jury Trial.',
'zh-CN': 'Waiver of Jury Trial.'
},
'tos.dispute-resolution.block.9': {
en: 'To the fullest extent permitted by Applicable Law, each party hereby knowingly and irrevocably waives any right to a trial by jury in any action, proceeding, or counterclaim arising out of or relating to this Agreement or the Comfy Products.',
'zh-CN':
'To the fullest extent permitted by Applicable Law, each party hereby knowingly and irrevocably waives any right to a trial by jury in any action, proceeding, or counterclaim arising out of or relating to this Agreement or the Comfy Products.'
},
'tos.dispute-resolution.block.10.heading': {
en: 'Exclusive Forum for Court Proceedings.',
'zh-CN': 'Exclusive Forum for Court Proceedings.'
},
'tos.dispute-resolution.block.11': {
en: 'To the extent any Dispute is not subject to arbitration under this Agreement, the parties agree to the exclusive jurisdiction and venue of the state and federal courts located in San Francisco, CA and each party irrevocably submits to such jurisdiction and venue and waives any objection based on inconvenient forum.',
'zh-CN':
'To the extent any Dispute is not subject to arbitration under this Agreement, the parties agree to the exclusive jurisdiction and venue of the state and federal courts located in San Francisco, CA and each party irrevocably submits to such jurisdiction and venue and waives any objection based on inconvenient forum.'
},
'tos.dispute-resolution.block.12.heading': {
en: 'Confidentiality.',
'zh-CN': 'Confidentiality.'
},
'tos.dispute-resolution.block.13': {
en: 'The arbitration, including the existence of the arbitration, all materials submitted, and all testimony and awards, shall be confidential and may not be disclosed except as necessary to conduct the arbitration, to enforce an award, or as required by Applicable Law.',
'zh-CN':
'The arbitration, including the existence of the arbitration, all materials submitted, and all testimony and awards, shall be confidential and may not be disclosed except as necessary to conduct the arbitration, to enforce an award, or as required by Applicable Law.'
},
'tos.dispute-resolution.block.14.heading': {
en: 'Time Limit.',
'zh-CN': 'Time Limit.'
},
'tos.dispute-resolution.block.15': {
en: 'To the fullest extent permitted by Applicable Law, any Dispute must be brought by you within one (1) year after the claim or cause of action first arose, or it is permanently barred.',
'zh-CN':
'To the fullest extent permitted by Applicable Law, any Dispute must be brought by you within one (1) year after the claim or cause of action first arose, or it is permanently barred.'
},
'tos.miscellaneous.label': { en: 'MISCELLANEOUS', 'zh-CN': 'MISCELLANEOUS' },
'tos.miscellaneous.title': {
en: '12. Miscellaneous',
'zh-CN': '12. Miscellaneous'
},
'tos.miscellaneous.block.0.heading': {
en: 'Export Compliance.',
'zh-CN': 'Export Compliance.'
},
'tos.miscellaneous.block.1': {
en: 'You will comply with the export laws and regulations of the United States, the European Union and other applicable jurisdictions in providing and using the Comfy Products.',
'zh-CN':
'You will comply with the export laws and regulations of the United States, the European Union and other applicable jurisdictions in providing and using the Comfy Products.'
},
'tos.miscellaneous.block.2.heading': {
en: 'Publicity.',
'zh-CN': 'Publicity.'
},
'tos.miscellaneous.block.3': {
en: 'You agree that Comfy may refer to your name, logo, and trademarks in Comfys marketing materials and website; however, Comfy will not use your name or trademarks in any other publicity (e.g., press releases, customer references and case studies) without your prior written consent (which may be by email) not to be unreasonably withheld, conditioned, or delayed.',
'zh-CN':
'You agree that Comfy may refer to your name, logo, and trademarks in Comfys marketing materials and website; however, Comfy will not use your name or trademarks in any other publicity (e.g., press releases, customer references and case studies) without your prior written consent (which may be by email) not to be unreasonably withheld, conditioned, or delayed.'
},
'tos.miscellaneous.block.4.heading': {
en: 'Third-Party Infrastructure.',
'zh-CN': 'Third-Party Infrastructure.'
},
'tos.miscellaneous.block.5': {
en: 'Customer acknowledges that the Comfy Products relies on third-party infrastructure, hardware, and services, including cloud computing providers and GPU infrastructure providers (collectively, “Third-Party Infrastructure”), and that the availability, performance, and security of the Comfy Products may be affected by the operation, maintenance, or failure of such Third-Party Infrastructure. Comfy will use commercially reasonable efforts to maintain Comfy Products availability but makes no representation or warranty regarding the performance or availability of any Third-Party Infrastructure, and Comfy shall have no liability to Customer for any interruption, degradation, loss of data, or other harm arising out of or related to any failure, outage, or limitation of Third-Party Infrastructure, whether or not within Comfys control.',
'zh-CN':
'Customer acknowledges that the Comfy Products relies on third-party infrastructure, hardware, and services, including cloud computing providers and GPU infrastructure providers (collectively, “Third-Party Infrastructure”), and that the availability, performance, and security of the Comfy Products may be affected by the operation, maintenance, or failure of such Third-Party Infrastructure. Comfy will use commercially reasonable efforts to maintain Comfy Products availability but makes no representation or warranty regarding the performance or availability of any Third-Party Infrastructure, and Comfy shall have no liability to Customer for any interruption, degradation, loss of data, or other harm arising out of or related to any failure, outage, or limitation of Third-Party Infrastructure, whether or not within Comfys control.'
},
'tos.miscellaneous.block.6.heading': {
en: 'Assignment; Delegation.',
'zh-CN': 'Assignment; Delegation.'
},
'tos.miscellaneous.block.7': {
en: 'Neither party hereto may assign or otherwise transfer this Agreement, in whole or in part, without the other partys prior written consent, except that Comfy may assign this Agreement without consent to a successor to all or substantially all of its assets or business related to this Agreement. Any attempted assignment, delegation, or transfer by either party in violation hereof will be null and void. Subject to the foregoing, this Agreement will be binding on the parties and their successors and assigns.',
'zh-CN':
'Neither party hereto may assign or otherwise transfer this Agreement, in whole or in part, without the other partys prior written consent, except that Comfy may assign this Agreement without consent to a successor to all or substantially all of its assets or business related to this Agreement. Any attempted assignment, delegation, or transfer by either party in violation hereof will be null and void. Subject to the foregoing, this Agreement will be binding on the parties and their successors and assigns.'
},
'tos.miscellaneous.block.8.heading': {
en: 'Amendment; Waiver.',
'zh-CN': 'Amendment; Waiver.'
},
'tos.miscellaneous.block.9': {
en: 'Comfy reserves the right in its sole discretion and at any time and for any reason to modify this Agreement. Any modifications to this Agreement shall become effective upon the date of posting. Your continued use of, or access to, the Comfy Products after an update goes into effect will constitute acceptance of the update. If you do not agree with an update, you may stop using the Comfy Products or terminate this Agreement. No waiver by either party of any breach or default hereunder shall be deemed to be a waiver of any preceding or subsequent breach or default. Any such waiver will apply only to the specific provision and under the specific circumstances for which it was given, and will not apply with respect to any repeated or continued violation of the same provision or any other provision. Failure or delay by either party to enforce any provision of this Agreement will not be deemed a waiver of future enforcement of that or any other provision.',
'zh-CN':
'Comfy reserves the right in its sole discretion and at any time and for any reason to modify this Agreement. Any modifications to this Agreement shall become effective upon the date of posting. Your continued use of, or access to, the Comfy Products after an update goes into effect will constitute acceptance of the update. If you do not agree with an update, you may stop using the Comfy Products or terminate this Agreement. No waiver by either party of any breach or default hereunder shall be deemed to be a waiver of any preceding or subsequent breach or default. Any such waiver will apply only to the specific provision and under the specific circumstances for which it was given, and will not apply with respect to any repeated or continued violation of the same provision or any other provision. Failure or delay by either party to enforce any provision of this Agreement will not be deemed a waiver of future enforcement of that or any other provision.'
},
'tos.miscellaneous.block.10.heading': {
en: 'Relationship.',
'zh-CN': 'Relationship.'
},
'tos.miscellaneous.block.11': {
en: 'Nothing contained herein will in any way constitute any association, partnership, agency, employment or joint venture between the parties hereto, or be construed to evidence the intention of the parties to establish any such relationship. Neither party will have the authority to obligate or bind the other in any manner, and nothing herein contained will give rise to, or is intended to give rise to any rights of any kind in favor of any third parties.',
'zh-CN':
'Nothing contained herein will in any way constitute any association, partnership, agency, employment or joint venture between the parties hereto, or be construed to evidence the intention of the parties to establish any such relationship. Neither party will have the authority to obligate or bind the other in any manner, and nothing herein contained will give rise to, or is intended to give rise to any rights of any kind in favor of any third parties.'
},
'tos.miscellaneous.block.12.heading': {
en: 'Unenforceability.',
'zh-CN': 'Unenforceability.'
},
'tos.miscellaneous.block.13': {
en: 'If a court of competent jurisdiction determines that any provision of this Agreement is invalid, illegal, or otherwise unenforceable, such provision will be enforced as nearly as possible in accordance with the stated intention of the parties, while the remainder of this Agreement will remain in full force and effect and bind the parties according to its terms.',
'zh-CN':
'If a court of competent jurisdiction determines that any provision of this Agreement is invalid, illegal, or otherwise unenforceable, such provision will be enforced as nearly as possible in accordance with the stated intention of the parties, while the remainder of this Agreement will remain in full force and effect and bind the parties according to its terms.'
},
'tos.miscellaneous.block.14.heading': {
en: 'Notices.',
'zh-CN': 'Notices.'
},
'tos.miscellaneous.block.15': {
en: 'Any notice required or permitted to be given hereunder will be given in writing by personal delivery, certified mail, return receipt requested, or by overnight delivery. Notices to you may be sent to the email address provided by you when you created your account with Comfy. Notices to Comfy must be sent to the following: 201 Spear Street, Ste 17, San Francisco, CA 94105.',
'zh-CN':
'Any notice required or permitted to be given hereunder will be given in writing by personal delivery, certified mail, return receipt requested, or by overnight delivery. Notices to you may be sent to the email address provided by you when you created your account with Comfy. Notices to Comfy must be sent to the following: 201 Spear Street, Ste 17, San Francisco, CA 94105.'
},
'tos.miscellaneous.block.16.heading': {
en: 'Entire Agreement.',
'zh-CN': 'Entire Agreement.'
},
'tos.miscellaneous.block.17': {
en: 'This Agreement comprises the entire agreement between you and Comfy with respect to its subject matter, and supersedes all prior and contemporaneous proposals, statements, sales materials or presentations and agreements (oral and written). No oral or written information or advice given by Comfy, its agents or employees will create a warranty or in any way increase the scope of the warranties in this Agreement.',
'zh-CN':
'This Agreement comprises the entire agreement between you and Comfy with respect to its subject matter, and supersedes all prior and contemporaneous proposals, statements, sales materials or presentations and agreements (oral and written). No oral or written information or advice given by Comfy, its agents or employees will create a warranty or in any way increase the scope of the warranties in this Agreement.'
},
'tos.contact.label': { en: 'CONTACT', 'zh-CN': 'CONTACT' },
'tos.contact.title': { en: '13. Contact Us', 'zh-CN': '13. Contact Us' },
'tos.contact.block.0': {
en: 'If you have questions about these Terms, please contact us at <a href="mailto:legal@comfy.org" class="text-white underline">legal@comfy.org</a>.',
en: 'If you have any questions regarding this Agreement or the Comfy Products, please contact us at: <a href="mailto:legal@comfy.org" class="text-white underline">legal@comfy.org</a>.',
'zh-CN':
'如果您对本条款有任何疑问,请通过 <a href="mailto:legal@comfy.org" class="text-white underline">legal@comfy.org</a> 与我们联系。'
'If you have any questions regarding this Agreement or the Comfy Products, please contact us at: <a href="mailto:legal@comfy.org" class="text-white underline">legal@comfy.org</a>.'
},
// Customers page

View File

@@ -71,7 +71,7 @@ const websiteJsonLd = {
{noindex && <meta name="robots" content="noindex, nofollow" />}
<title>{title}</title>
<link rel="icon" href="/icons/logomark.svg" type="image/svg+xml" />
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
<link rel="canonical" href={canonicalURL.href} />
<link rel="preconnect" href="https://www.googletagmanager.com" />
<link rel="dns-prefetch" href="https://www.googletagmanager.com" />

View File

@@ -1,5 +1,4 @@
---
import type { GetStaticPaths } from 'astro'
import type { Pack } from '../../../data/cloudNodes'
@@ -9,7 +8,7 @@ import { t } from '../../../i18n/translations'
import { loadPacksForBuild } from '../../../utils/cloudNodes.build'
import { escapeJsonLd } from '../../../utils/escapeJsonLd'
export const getStaticPaths: GetStaticPaths = async () => {
export async function getStaticPaths() {
const packs = await loadPacksForBuild()
return packs.map((pack) => ({
params: { pack: pack.id },

View File

@@ -1,5 +1,4 @@
---
import type { GetStaticPaths } from 'astro'
import BaseLayout from '../../layouts/BaseLayout.astro'
import DetailHeroSection from '../../components/customers/DetailHeroSection.vue'
import ContentSection from '../../components/common/ContentSection.vue'
@@ -7,7 +6,7 @@ import WhatsNextSection from '../../components/customers/WhatsNextSection.vue'
import { customerStories, getNextStory, getStoryBySlug } from '../../config/customerStories'
import { t } from '../../i18n/translations'
export const getStaticPaths: GetStaticPaths = () => {
export function getStaticPaths() {
return customerStories.map((story) => ({
params: { slug: story.slug }
}))

View File

@@ -1,5 +1,4 @@
---
import type { GetStaticPaths } from 'astro'
import BaseLayout from '../../layouts/BaseLayout.astro'
import DemoHeroSection from '../../components/demos/DemoHeroSection.vue'
import ArcadeEmbed from '../../components/demos/ArcadeEmbed.vue'
@@ -8,7 +7,7 @@ import DemoNavSection from '../../components/demos/DemoNavSection.vue'
import { demos, getDemoBySlug, getNextDemo } from '../../config/demos'
import { t } from '../../i18n/translations'
export const getStaticPaths: GetStaticPaths = () => {
export function getStaticPaths() {
return demos.map((demo) => ({
params: { slug: demo.slug }
}))

View File

@@ -1,11 +1,10 @@
---
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 = () => {
export function getStaticPaths() {
return models.map((model) => ({
params: { slug: model.slug }
}))

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

@@ -1,5 +1,4 @@
---
import type { GetStaticPaths } from 'astro'
import type { Pack } from '../../../../data/cloudNodes'
@@ -9,7 +8,7 @@ import { t } from '../../../../i18n/translations'
import { loadPacksForBuild } from '../../../../utils/cloudNodes.build'
import { escapeJsonLd } from '../../../../utils/escapeJsonLd'
export const getStaticPaths: GetStaticPaths = async () => {
export async function getStaticPaths() {
const packs = await loadPacksForBuild()
return packs.map((pack) => ({
params: { pack: pack.id },

View File

@@ -1,5 +1,4 @@
---
import type { GetStaticPaths } from 'astro'
import BaseLayout from '../../../layouts/BaseLayout.astro'
import DetailHeroSection from '../../../components/customers/DetailHeroSection.vue'
import ContentSection from '../../../components/common/ContentSection.vue'
@@ -7,7 +6,7 @@ import WhatsNextSection from '../../../components/customers/WhatsNextSection.vue
import { customerStories, getNextStory, getStoryBySlug } from '../../../config/customerStories'
import { t } from '../../../i18n/translations'
export const getStaticPaths: GetStaticPaths = () => {
export function getStaticPaths() {
return customerStories.map((story) => ({
params: { slug: story.slug }
}))

View File

@@ -1,5 +1,4 @@
---
import type { GetStaticPaths } from 'astro'
import BaseLayout from '../../../layouts/BaseLayout.astro'
import DemoHeroSection from '../../../components/demos/DemoHeroSection.vue'
import ArcadeEmbed from '../../../components/demos/ArcadeEmbed.vue'
@@ -8,7 +7,7 @@ import DemoNavSection from '../../../components/demos/DemoNavSection.vue'
import { demos, getDemoBySlug, getNextDemo } from '../../../config/demos'
import { t } from '../../../i18n/translations'
export const getStaticPaths: GetStaticPaths = () => {
export function getStaticPaths() {
return demos.map((demo) => ({
params: { slug: demo.slug }
}))

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

@@ -35,8 +35,9 @@ const TICK_MS = 200
function readColors() {
const style = getComputedStyle(document.documentElement)
const get = (name: string, fallback: string): string =>
style.getPropertyValue(name).trim() || fallback
function get(name: string, fallback: string): string {
return style.getPropertyValue(name).trim() || fallback
}
return {
bg: get('--color-primary-comfy-ink', '#211927'),
@@ -59,9 +60,12 @@ function requireElement<T extends Element>(
return el
}
const isSVGSVG = (el: Element): el is SVGSVGElement =>
el instanceof SVGSVGElement
const isSVGG = (el: Element): el is SVGGElement => el instanceof SVGGElement
function isSVGSVG(el: Element): el is SVGSVGElement {
return el instanceof SVGSVGElement
}
function isSVGG(el: Element): el is SVGGElement {
return el instanceof SVGGElement
}
function isSVGText(el: Element): el is SVGTextElement {
return el instanceof SVGTextElement
}
@@ -127,8 +131,9 @@ function depth(cell: Cell): number {
function roundedPath(pts: [number, number][], radius: number): string {
const n = pts.length
const fmt = (p: readonly [number, number]) =>
`${p[0].toFixed(2)},${p[1].toFixed(2)}`
function fmt(p: readonly [number, number]) {
return `${p[0].toFixed(2)},${p[1].toFixed(2)}`
}
let d = ''
for (let i = 0; i < n; i++) {
const prev = pts[(i - 1 + n) % n]
@@ -206,7 +211,7 @@ function triggerExplosion() {
const cx = ((COLS - ROWS) * STEP_X) / 2
const cy = ((COLS + ROWS - 2) * STEP_Y) / 2
const launchParticle = (i: number, j: number, fill: string): Particle => {
function launchParticle(i: number, j: number, fill: string): Particle {
const [sx, sy] = iso(i, j)
const baseAngle = Math.atan2(sy - cy, sx - cx)
const angle = baseAngle + (Math.random() - 0.5) * 1.2
@@ -239,7 +244,9 @@ function triggerExplosion() {
const DROP_DURATION_MS = 450
const DROP_HEIGHT = 600
let foodDropStart = 0
const easeOutCubic = (t: number) => 1 - Math.pow(1 - t, 3)
function easeOutCubic(t: number) {
return 1 - Math.pow(1 - t, 3)
}
function foodDropOffset(now = performance.now()): number {
if (!foodDropStart) return 0
@@ -252,7 +259,7 @@ function foodDropOffset(now = performance.now()): number {
const REBIRTH_STAGGER_MS = 90
const REBIRTH_GROW_MS = 260
let rebirthStart = 0
const easeOutBack = (t: number) => {
function easeOutBack(t: number) {
const c1 = 1.70158
const c3 = c1 + 1
return 1 + c3 * Math.pow(t - 1, 3) + c1 * Math.pow(t - 1, 2)
@@ -271,7 +278,9 @@ function rebirthScaleFor(idx: number, now = performance.now()): number {
const CHOMP_DURATION_MS = 220
const CHOMP_PEAK_SCALE = 1.15
let chompStart = 0
const easeOut = (t: number) => 1 - (1 - t) * (1 - t)
function easeOut(t: number) {
return 1 - (1 - t) * (1 - t)
}
function chompScale(now = performance.now()): number {
if (!chompStart) return 1
@@ -299,7 +308,7 @@ function isAnimating(): boolean {
function ensureAnimationLoop() {
if (animationHandle !== null) return
const tick = () => {
function tick() {
if (
explodeStart &&
performance.now() - explodeStart >= EXPLODE_DURATION_MS
@@ -411,8 +420,12 @@ function updateScoreDisplay() {
scoreBestEl.textContent = String(best)
}
const cellsEqual = (a: Cell, b: Cell) => a.i === b.i && a.j === b.j
const inBounds = (c: Cell) => c.i >= 0 && c.j >= 0 && c.i < COLS && c.j < ROWS
function cellsEqual(a: Cell, b: Cell) {
return a.i === b.i && a.j === b.j
}
function inBounds(c: Cell) {
return c.i >= 0 && c.j >= 0 && c.i < COLS && c.j < ROWS
}
function reset() {
const j0 = Math.floor(ROWS / 2)

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

@@ -3,6 +3,14 @@ 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.
*
@@ -11,6 +19,10 @@ import { reportCloudNodesOutcome } from './cloudNodes.ci'
* 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()
@@ -18,8 +30,14 @@ export async function loadPacksForBuild(): Promise<Pack[]> {
if (outcome.status === 'failed') {
throw new Error(
`Cloud nodes fetch failed and no snapshot is available. Reason: ${outcome.reason}. ` +
'Run `pnpm --filter @comfyorg/website cloud-nodes:refresh-snapshot` locally and commit the snapshot.'
`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}`
)
}

View File

@@ -17,7 +17,7 @@ function jsonResponse(
}
describe('fetchRegistryPacks', () => {
it('requests node ids in batches of 50', async () => {
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))
@@ -40,6 +40,73 @@ describe('fetchRegistryPacks', () => {
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 () => {

View File

@@ -8,34 +8,47 @@ const BATCH_SIZE = 50
export type RegistryPack = components['schemas']['Node']
function nullToUndefined<T>(value: T | null | undefined): T | undefined {
return value ?? undefined
}
const optionalString = z.string().nullish().transform(nullToUndefined)
const optionalNumber = z.number().nullish().transform(nullToUndefined)
const optionalStringArray = z
.array(z.string())
.nullish()
.transform(nullToUndefined)
const RegistryPackSchema = z
.object({
id: z.string().optional(),
name: z.string().optional(),
description: z.string().optional(),
icon: z.string().optional(),
banner_url: z.string().optional(),
repository: z.string().optional(),
license: z.string().optional(),
downloads: z.number().optional(),
github_stars: z.number().optional(),
created_at: z.string().optional(),
supported_os: z.array(z.string()).optional(),
supported_accelerators: z.array(z.string()).optional(),
id: optionalString,
name: optionalString,
description: optionalString,
icon: optionalString,
banner_url: optionalString,
repository: optionalString,
license: optionalString,
downloads: optionalNumber,
github_stars: optionalNumber,
created_at: optionalString,
supported_os: optionalStringArray,
supported_accelerators: optionalStringArray,
publisher: z
.object({
id: z.string().optional(),
name: z.string().optional()
id: optionalString,
name: optionalString
})
.passthrough()
.optional(),
.nullish()
.transform(nullToUndefined),
latest_version: z
.object({
version: z.string().optional(),
createdAt: z.string().optional()
version: optionalString,
createdAt: optionalString
})
.passthrough()
.optional()
.nullish()
.transform(nullToUndefined)
})
.passthrough()
@@ -141,6 +154,7 @@ async function fetchBatch(
timeoutMs: number
): Promise<BatchResponse> {
const params = new URLSearchParams()
params.set('limit', String(packIds.length))
for (const packId of packIds) {
params.append('node_id', packId)
}

View File

@@ -0,0 +1,51 @@
{
"last_node_id": 1,
"last_link_id": 0,
"nodes": [
{
"id": 1,
"type": "CurveEditor",
"pos": [50, 50],
"size": [400, 400],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [
{
"name": "histogram",
"type": "HISTOGRAM",
"link": null
}
],
"outputs": [
{
"name": "curve",
"type": "CURVE",
"links": null
}
],
"properties": {
"Node name for S&R": "CurveEditor"
},
"widgets_values": [
{
"points": [
[0.2, 0.7],
[0.8, 0.3]
],
"interpolation": "linear"
}
]
}
],
"links": [],
"groups": [],
"config": {},
"extra": {
"ds": {
"offset": [0, 0],
"scale": 1
}
},
"version": 0.4
}

View File

@@ -5,11 +5,13 @@ import { TestIds } from '@e2e/fixtures/selectors'
export class QueuePanel {
readonly overlayToggle: Locator
readonly overlay: Locator
readonly moreOptionsButton: Locator
constructor(readonly page: Page) {
this.overlayToggle = page.getByTestId(TestIds.queue.overlayToggle)
this.moreOptionsButton = page.getByLabel(/More options/i).first()
this.overlay = page.getByTestId(TestIds.queue.progressOverlay)
this.moreOptionsButton = this.overlay.getByLabel(/More options/i)
}
async openClearHistoryDialog() {

View File

@@ -2,6 +2,11 @@ import type {
TemplateInfo,
WorkflowTemplates
} from '@/platform/workflow/templates/types/template'
import { TemplateIncludeOnDistributionEnum } from '@/platform/workflow/templates/types/template'
const Cloud = TemplateIncludeOnDistributionEnum.Cloud
const Desktop = TemplateIncludeOnDistributionEnum.Desktop
const Local = TemplateIncludeOnDistributionEnum.Local
export function makeTemplate(
overrides: Partial<TemplateInfo> & Pick<TemplateInfo, 'name'>
@@ -26,3 +31,33 @@ export function mockTemplateIndex(
}
]
}
export const STABLE_CLOUD_TEMPLATE: TemplateInfo = makeTemplate({
name: 'cloud-stable',
title: 'Cloud Stable',
includeOnDistributions: [Cloud]
})
export const STABLE_DESKTOP_TEMPLATE: TemplateInfo = makeTemplate({
name: 'desktop-stable',
title: 'Desktop Stable',
includeOnDistributions: [Desktop]
})
export const STABLE_LOCAL_TEMPLATE: TemplateInfo = makeTemplate({
name: 'local-stable',
title: 'Local Stable',
includeOnDistributions: [Local]
})
export const STABLE_UNRESTRICTED_TEMPLATE: TemplateInfo = makeTemplate({
name: 'unrestricted-stable',
title: 'Unrestricted Stable'
})
export const ALL_DISTRIBUTION_TEMPLATES: TemplateInfo[] = [
STABLE_CLOUD_TEMPLATE,
STABLE_DESKTOP_TEMPLATE,
STABLE_LOCAL_TEMPLATE,
STABLE_UNRESTRICTED_TEMPLATE
]

View File

@@ -158,7 +158,7 @@ export class AssetHelper {
statusCode: number,
error: string = 'Internal Server Error'
): Promise<void> {
const handler = async (route: Route) => {
async function handler(route: Route) {
return route.fulfill({
status: statusCode,
json: { error }

View File

@@ -325,7 +325,7 @@ export class AssetsHelper {
await this.page.unroute(pattern, existingHandler)
}
const handler = async (route: Route) => {
async function handler(route: Route) {
await route.fulfill({
status: 200,
contentType: 'application/json',

View File

@@ -1,176 +0,0 @@
import type { Page, Route } from '@playwright/test'
import type {
JobDetailResponse,
JobEntry,
JobsListResponse
} from '@comfyorg/ingest-types'
const jobsListRoutePattern = /\/api\/jobs(?:\?.*)?$/
const jobDetailRoutePattern = /\/api\/jobs\/[^/?#]+(?:\?.*)?$/
const historyRoutePattern = /\/api\/history(?:\?.*)?$/
const defaultJobsListLimit = 100
export type MockJobRecord = {
listItem: JobEntry
detail: JobDetailResponse
}
function parsePositiveIntegerParam(url: URL, name: string): number | undefined {
const value = Number(url.searchParams.get(name))
return Number.isInteger(value) && value > 0 ? value : undefined
}
function getJobIdFromRequest(route: Route): string | null {
const url = new URL(route.request().url())
const jobId = url.pathname.split('/').at(-1)
return jobId ? decodeURIComponent(jobId) : null
}
export class JobsApiMock {
private listRouteHandler: ((route: Route) => Promise<void>) | null = null
private detailRouteHandler: ((route: Route) => Promise<void>) | null = null
private historyRouteHandler: ((route: Route) => Promise<void>) | null = null
private jobsById = new Map<string, MockJobRecord>()
constructor(private readonly page: Page) {}
async mockJobs(jobs: MockJobRecord[]): Promise<void> {
this.jobsById = new Map(
jobs.map(
(job) => [job.listItem.id, job] satisfies [string, MockJobRecord]
)
)
await this.ensureRoutesRegistered()
}
async clear(): Promise<void> {
this.jobsById.clear()
if (this.listRouteHandler) {
await this.page.unroute(jobsListRoutePattern, this.listRouteHandler)
this.listRouteHandler = null
}
if (this.detailRouteHandler) {
await this.page.unroute(jobDetailRoutePattern, this.detailRouteHandler)
this.detailRouteHandler = null
}
if (this.historyRouteHandler) {
await this.page.unroute(historyRoutePattern, this.historyRouteHandler)
this.historyRouteHandler = null
}
}
private async ensureRoutesRegistered(): Promise<void> {
if (!this.listRouteHandler) {
this.listRouteHandler = async (route: Route) => {
const url = new URL(route.request().url())
const statuses = url.searchParams
.get('status')
?.split(',')
.map((status) => status.trim())
.filter(Boolean)
let filteredJobs = Array.from(
this.jobsById.values(),
({ listItem }) => listItem
)
if (statuses?.length) {
filteredJobs = filteredJobs.filter((job) =>
statuses.includes(job.status)
)
}
const offset = parsePositiveIntegerParam(url, 'offset') ?? 0
const limit =
parsePositiveIntegerParam(url, 'limit') ?? defaultJobsListLimit
const total = filteredJobs.length
const visibleJobs = filteredJobs.slice(offset, offset + limit)
const response = {
jobs: visibleJobs,
pagination: {
offset,
limit,
total,
has_more: offset + visibleJobs.length < total
}
} satisfies JobsListResponse
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(response)
})
}
await this.page.route(jobsListRoutePattern, this.listRouteHandler)
}
if (!this.detailRouteHandler) {
this.detailRouteHandler = async (route: Route) => {
const jobId = getJobIdFromRequest(route)
const job = jobId ? this.jobsById.get(jobId) : undefined
if (!job) {
await route.fulfill({
status: 404,
contentType: 'application/json',
body: JSON.stringify({ error: 'Job not found' })
})
return
}
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(job.detail)
})
}
await this.page.route(jobDetailRoutePattern, this.detailRouteHandler)
}
if (!this.historyRouteHandler) {
this.historyRouteHandler = async (route: Route) => {
const request = route.request()
if (request.method() !== 'POST') {
await route.continue()
return
}
const requestBody = request.postDataJSON() as
| { delete?: string[]; clear?: boolean }
| undefined
if (requestBody?.clear) {
this.jobsById = new Map(
Array.from(this.jobsById).filter(([, job]) => {
const status = job.listItem.status
return status === 'pending' || status === 'in_progress'
})
)
}
if (requestBody?.delete?.length) {
for (const jobId of requestBody.delete) {
this.jobsById.delete(jobId)
}
}
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({})
})
}
await this.page.route(historyRoutePattern, this.historyRouteHandler)
}
}
}

View File

@@ -1,19 +1,26 @@
import { test as base } from '@playwright/test'
import type { Page, Route } from '@playwright/test'
import { test as base, expect } from '@playwright/test'
import type { Page, Route, WebSocketRoute } from '@playwright/test'
import type { LogsRawResponse } from '@/schemas/apiSchema'
const RAW_LOGS_URL = '**/internal/logs/raw**'
const SUBSCRIBE_LOGS_URL = '**/internal/logs/subscribe**'
export class LogsTerminalHelper {
constructor(private readonly page: Page) {}
async mockRawLogs(messages: string[]) {
await this.page.route('**/internal/logs/raw**', (route: Route) =>
route.fulfill({
async mockRawLogs(messages: string[]): Promise<() => number> {
let count = 0
await this.page.unroute(RAW_LOGS_URL)
await this.page.route(RAW_LOGS_URL, async (route: Route) => {
count += 1
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(LogsTerminalHelper.buildRawLogsResponse(messages))
})
)
})
return () => count
}
async mockRawLogsPending(messages: string[] = []): Promise<() => void> {
@@ -21,7 +28,8 @@ export class LogsTerminalHelper {
const pending = new Promise<void>((r) => {
resolve = r
})
await this.page.route('**/internal/logs/raw**', async (route: Route) => {
await this.page.unroute(RAW_LOGS_URL)
await this.page.route(RAW_LOGS_URL, async (route: Route) => {
await pending
await route.fulfill({
status: 200,
@@ -33,15 +41,39 @@ export class LogsTerminalHelper {
}
async mockRawLogsError() {
await this.page.route('**/internal/logs/raw**', (route: Route) =>
await this.page.unroute(RAW_LOGS_URL)
await this.page.route(RAW_LOGS_URL, (route: Route) =>
route.fulfill({ status: 500, body: 'Internal Server Error' })
)
}
async mockSubscribeLogs() {
await this.page.route('**/internal/logs/subscribe**', (route: Route) =>
route.fulfill({ status: 200, body: '' })
)
async mockSubscribeLogs(): Promise<() => number> {
let count = 0
await this.page.unroute(SUBSCRIBE_LOGS_URL)
await this.page.route(SUBSCRIBE_LOGS_URL, async (route: Route) => {
count += 1
await route.fulfill({ status: 200, body: '' })
})
return () => count
}
/**
* Force the frontend to reconnect by closing the proxied WebSocket. The
* api layer reschedules a fresh `WebSocket(...)`, the routeWebSocket
* handler fires again, and on `open` with `isReconnect=true` it dispatches
* `'reconnected'`, which triggers the logs-terminal resync.
*
* Use the resync's `subscribeLogs(true)` HTTP call as the sync point — by
* the time the count goes up, the new socket is open and resync has
* completed enough to assert against the terminal.
*/
async triggerReconnect(
ws: WebSocketRoute,
subscribeFetches: () => number
): Promise<void> {
const before = subscribeFetches()
await ws.close()
await expect.poll(subscribeFetches).toBeGreaterThan(before)
}
static buildWsLogFrame(messages: string[]): string {

View File

@@ -42,7 +42,7 @@ export async function setupNodeReplacement(
options?: AddEventListenerOptions | boolean
) {
if (type === 'message' && typeof listener === 'function') {
const wrapped = function (this: WebSocket, event: Event) {
function wrapped(this: WebSocket, event: Event) {
const msgEvent = event as MessageEvent
if (typeof msgEvent.data === 'string') {
try {

View File

@@ -11,6 +11,7 @@ import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/w
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import { SubgraphEditor } from '@e2e/fixtures/components/SubgraphEditor'
import { TestIds } from '@e2e/fixtures/selectors'
import type { Position, Size } from '@e2e/fixtures/types'
import type { NodeReference } from '@e2e/fixtures/utils/litegraphUtils'
import { SubgraphSlotReference } from '@e2e/fixtures/utils/litegraphUtils'
@@ -241,6 +242,17 @@ export class SubgraphHelper {
return new SubgraphSlotReference('output', slotName || '', this.comfyPage)
}
async getInputBounds(): Promise<Position & Size> {
return await this.comfyPage.page.evaluate(() => {
const graph = app!.canvas.graph as Subgraph
const inputNode = graph.inputNode
const [x, y] = app!.canvas.ds.convertOffsetToCanvas(inputNode.pos)
const width = inputNode.size[0] * app!.canvas.ds.scale
const height = inputNode.size[1] * app!.canvas.ds.scale
return { x, y, width, height }
})
}
/**
* Connect a regular node output to a subgraph input.
* This creates a new input slot on the subgraph if targetInputName is not provided.
@@ -606,7 +618,7 @@ export class SubgraphHelper {
]
): { warnings: string[]; dispose: () => void } {
const warnings: string[] = []
const handler = (msg: ConsoleMessage) => {
function handler(msg: ConsoleMessage) {
const text = msg.text()
if (patterns.some((p) => text.includes(p))) {
warnings.push(text)

View File

@@ -0,0 +1,198 @@
import type { Page, Route } from '@playwright/test'
import type {
TemplateInfo,
WorkflowTemplates
} from '@/platform/workflow/templates/types/template'
import { TemplateIncludeOnDistributionEnum } from '@/platform/workflow/templates/types/template'
import {
makeTemplate,
mockTemplateIndex
} from '@e2e/fixtures/data/templateFixtures'
/**
* Generate N deterministic templates, optionally restricted to a distribution.
*
* Lives here (not in `fixtures/data/`) because `fixtures/data/` is reserved
* for static test data with no executable fixture logic.
*/
function generateTemplates(
count: number,
distribution?: TemplateIncludeOnDistributionEnum
): TemplateInfo[] {
const slug = distribution ?? 'unrestricted'
return Array.from({ length: count }, (_, i) =>
makeTemplate({
name: `gen-${slug}-${String(i + 1).padStart(3, '0')}`,
title: `Generated ${slug} ${i + 1}`,
...(distribution ? { includeOnDistributions: [distribution] } : {})
})
)
}
export interface TemplateConfig {
readonly templates: readonly TemplateInfo[]
readonly index: readonly WorkflowTemplates[] | null
}
function emptyConfig(): TemplateConfig {
return { templates: [], index: null }
}
export type TemplateOperator = (config: TemplateConfig) => TemplateConfig
function cloneTemplates(templates: readonly TemplateInfo[]): TemplateInfo[] {
return templates.map((t) => structuredClone(t))
}
function cloneIndex(
index: readonly WorkflowTemplates[] | null
): WorkflowTemplates[] | null {
return index ? index.map((m) => structuredClone(m)) : null
}
function addTemplates(
config: TemplateConfig,
templates: TemplateInfo[]
): TemplateConfig {
return { ...config, templates: [...config.templates, ...templates] }
}
export function withTemplates(templates: TemplateInfo[]): TemplateOperator {
return (config) => addTemplates(config, templates)
}
export function withTemplate(template: TemplateInfo): TemplateOperator {
return (config) => addTemplates(config, [template])
}
export function withCloudTemplates(count: number): TemplateOperator {
return (config) =>
addTemplates(
config,
generateTemplates(count, TemplateIncludeOnDistributionEnum.Cloud)
)
}
export function withDesktopTemplates(count: number): TemplateOperator {
return (config) =>
addTemplates(
config,
generateTemplates(count, TemplateIncludeOnDistributionEnum.Desktop)
)
}
export function withLocalTemplates(count: number): TemplateOperator {
return (config) =>
addTemplates(
config,
generateTemplates(count, TemplateIncludeOnDistributionEnum.Local)
)
}
export function withUnrestrictedTemplates(count: number): TemplateOperator {
return (config) => addTemplates(config, generateTemplates(count))
}
/**
* Override the index payload entirely. Useful when a test needs a custom
* `WorkflowTemplates[]` shape (e.g. multiple modules).
*/
export function withRawIndex(index: WorkflowTemplates[]): TemplateOperator {
return (config) => ({ ...config, index })
}
export class TemplateHelper {
private templates: TemplateInfo[]
private index: WorkflowTemplates[] | null
private routeHandlers: Array<{
pattern: string
handler: (route: Route) => Promise<void>
}> = []
constructor(
private readonly page: Page,
config: TemplateConfig = emptyConfig()
) {
this.templates = cloneTemplates(config.templates)
this.index = cloneIndex(config.index)
}
configure(...operators: TemplateOperator[]): void {
const config = operators.reduce<TemplateConfig>(
(cfg, op) => op(cfg),
emptyConfig()
)
this.templates = cloneTemplates(config.templates)
this.index = cloneIndex(config.index)
}
async mock(): Promise<void> {
await this.mockIndex()
await this.mockThumbnails()
}
async mockIndex(): Promise<void> {
const indexHandler = async (route: Route) => {
const payload = this.index ?? mockTemplateIndex(this.templates)
await route.fulfill({
status: 200,
body: JSON.stringify(payload),
headers: {
'Content-Type': 'application/json',
'Cache-Control': 'no-store'
}
})
}
const indexPattern = '**/templates/index.json'
this.routeHandlers.push({ pattern: indexPattern, handler: indexHandler })
await this.page.route(indexPattern, indexHandler)
}
async mockThumbnails(): Promise<void> {
async function thumbnailHandler(route: Route) {
await route.fulfill({
status: 200,
path: 'browser_tests/assets/example.webp',
headers: {
'Content-Type': 'image/webp',
'Cache-Control': 'no-store'
}
})
}
const thumbnailPattern = '**/templates/**.webp'
this.routeHandlers.push({
pattern: thumbnailPattern,
handler: thumbnailHandler
})
await this.page.route(thumbnailPattern, thumbnailHandler)
}
getTemplates(): TemplateInfo[] {
return cloneTemplates(this.templates)
}
get templateCount(): number {
return this.templates.length
}
async clearMocks(): Promise<void> {
for (const { pattern, handler } of this.routeHandlers) {
await this.page.unroute(pattern, handler)
}
this.routeHandlers = []
this.templates = []
this.index = null
}
}
export function createTemplateHelper(
page: Page,
...operators: TemplateOperator[]
): TemplateHelper {
const config = operators.reduce<TemplateConfig>(
(cfg, op) => op(cfg),
emptyConfig()
)
return new TemplateHelper(page, config)
}

View File

@@ -1,15 +0,0 @@
import { test as base } from '@playwright/test'
import { JobsApiMock } from '@e2e/fixtures/helpers/JobsApiMock'
export const jobsApiMockFixture = base.extend<{
jobsApi: JobsApiMock
}>({
jobsApi: async ({ page }, use) => {
const jobsApi = new JobsApiMock(page)
await use(jobsApi)
await jobsApi.clear()
}
})

View File

@@ -0,0 +1,217 @@
import { test as base } from '@playwright/test'
import type { Page } from '@playwright/test'
import type { z } from 'zod'
import {
zHistoryManageRequest,
zQueueManageRequest,
zQueueManageResponse
} from '@comfyorg/ingest-types/zod'
import type {
JobStatus,
RawJobListItem,
zJobsListResponse
} from '@/platform/remote/comfyui/jobs/jobTypes'
type JobsListResponse = z.infer<typeof zJobsListResponse>
type HistoryManageRequest = z.infer<typeof zHistoryManageRequest>
type QueueManageRequest = z.infer<typeof zQueueManageRequest>
const terminalJobStatuses = [
'completed',
'failed',
'cancelled'
] as const satisfies readonly JobStatus[]
const activeJobStatuses = [
'in_progress',
'pending'
] as const satisfies readonly JobStatus[]
const defaultJobsListLimit = 200
const defaultScenarioHistoryLimit = 64
const defaultJobsListOffset = 0
export const routeMockJobTimestamp = Date.UTC(2026, 0, 1, 12)
interface JobsListRoute {
statuses: readonly JobStatus[]
jobs: readonly RawJobListItem[]
limit?: number
offset?: number
responseLimit?: number
}
export interface JobsScenario {
history?: readonly RawJobListItem[]
queue?: readonly RawJobListItem[]
}
function hasExactStatuses(url: URL, statuses: readonly JobStatus[]): boolean {
const requestedStatuses = new Set(
url.searchParams.get('status')?.split(',') ?? []
)
return (
requestedStatuses.size === statuses.length &&
statuses.every((status) => requestedStatuses.has(status))
)
}
function searchParamNumber(url: URL, name: string, fallback: number): number {
const value = url.searchParams.get(name)
return value === null ? fallback : Number(value)
}
function hasJobsListPageParams(
url: URL,
{ limit, offset }: Pick<JobsListRoute, 'limit' | 'offset'>
): boolean {
return (
searchParamNumber(url, 'limit', defaultJobsListLimit) ===
(limit ?? defaultJobsListLimit) &&
searchParamNumber(url, 'offset', defaultJobsListOffset) ===
(offset ?? defaultJobsListOffset)
)
}
function matchesJobsListRoute(url: URL, route: JobsListRoute): boolean {
return (
hasExactStatuses(url, route.statuses) && hasJobsListPageParams(url, route)
)
}
function createJobsListResponse({
jobs,
limit = defaultJobsListLimit,
offset = defaultJobsListOffset,
responseLimit = limit
}: Omit<JobsListRoute, 'statuses'>): JobsListResponse {
const pageJobs = jobs.slice(offset, offset + responseLimit)
return {
jobs: pageJobs,
pagination: {
offset,
limit: responseLimit,
total: jobs.length,
has_more: offset + pageJobs.length < jobs.length
}
}
}
export function createRouteMockJob({
id,
...overrides
}: { id: string } & Partial<Omit<RawJobListItem, 'id'>>): RawJobListItem {
return {
id,
status: 'completed',
create_time: routeMockJobTimestamp,
execution_start_time: routeMockJobTimestamp,
execution_end_time: routeMockJobTimestamp + 5_000,
preview_output: {
filename: `output_${id}.png`,
subfolder: '',
type: 'output',
nodeId: '1',
mediaType: 'images'
},
outputs_count: 1,
...overrides
}
}
export class JobsRouteMocker {
constructor(private readonly page: Page) {}
async mockJobsHistory(
jobs: readonly RawJobListItem[],
limit = defaultJobsListLimit,
options: Pick<JobsListRoute, 'responseLimit'> = {}
): Promise<void> {
await this.mockJobsList({
statuses: terminalJobStatuses,
jobs,
limit,
...options
})
}
async mockJobsQueue(jobs: readonly RawJobListItem[]): Promise<void> {
await this.mockJobsList({
statuses: activeJobStatuses,
jobs
})
}
async mockJobsScenario({ history, queue }: JobsScenario): Promise<void> {
if (history) {
await this.mockJobsHistory(history, defaultScenarioHistoryLimit)
}
if (queue) {
await this.mockJobsQueue(queue)
}
}
async mockJobsList(route: JobsListRoute): Promise<void> {
const response = createJobsListResponse(route)
await this.page.route(
(url) =>
url.pathname.endsWith('/api/jobs') && matchesJobsListRoute(url, route),
async (requestRoute) => {
if (requestRoute.request().method().toUpperCase() !== 'GET') {
await requestRoute.fallback()
return
}
await requestRoute.fulfill({ json: response })
}
)
}
async mockClearQueue(): Promise<QueueManageRequest[]> {
const response = zQueueManageResponse.parse({ cleared: true })
return await this.mockPostManageRoute(
'queue',
zQueueManageRequest,
response
)
}
async mockClearHistory(): Promise<HistoryManageRequest[]> {
return await this.mockPostManageRoute('history', zHistoryManageRequest, {})
}
private async mockPostManageRoute<TRequest>(
type: 'queue' | 'history',
requestSchema: z.ZodType<TRequest>,
response: unknown
): Promise<TRequest[]> {
const requests: TRequest[] = []
await this.page.route(
(url) => url.pathname.endsWith(`/api/${type}`),
async (requestRoute) => {
if (requestRoute.request().method().toUpperCase() !== 'POST') {
await requestRoute.fallback()
return
}
requests.push(
requestSchema.parse(requestRoute.request().postDataJSON())
)
await requestRoute.fulfill({ json: response })
}
)
return requests
}
}
export const jobsRouteFixture = base.extend<{
jobsRoutes: JobsRouteMocker
}>({
jobsRoutes: async ({ page }, use) => {
await use(new JobsRouteMocker(page))
}
})

View File

@@ -76,7 +76,15 @@ export const TestIds = {
publishTabPanel: 'publish-tab-panel',
apiSignin: 'api-signin-dialog',
updatePassword: 'update-password-dialog',
cloudNotification: 'cloud-notification-dialog'
cloudNotification: 'cloud-notification-dialog',
openSharedWorkflow: 'open-shared-workflow-dialog',
openSharedWorkflowTitle: 'open-shared-workflow-title',
openSharedWorkflowClose: 'open-shared-workflow-close',
openSharedWorkflowErrorClose: 'open-shared-workflow-error-close',
openSharedWorkflowCancel: 'open-shared-workflow-cancel',
openSharedWorkflowOpenWithoutImporting:
'open-shared-workflow-open-without-importing',
openSharedWorkflowConfirm: 'open-shared-workflow-confirm'
},
keybindings: {
presetMenu: 'keybinding-preset-menu'
@@ -145,7 +153,9 @@ export const TestIds = {
decrement: 'decrement',
increment: 'increment',
domWidgetTextarea: 'dom-widget-textarea',
subgraphEnterButton: 'subgraph-enter-button'
subgraphEnterButton: 'subgraph-enter-button',
selectDefaultSearchInput: 'widget-select-default-search-input',
selectDefaultViewport: 'widget-select-default-viewport'
},
linear: {
centerPanel: 'linear-center-panel',
@@ -224,7 +234,10 @@ export const TestIds = {
currentUserIndicator: 'current-user-indicator'
},
queue: {
jobHistorySidebar: 'job-history-sidebar',
progressOverlay: 'queue-progress-overlay',
overlayToggle: 'queue-overlay-toggle',
dockedJobHistoryAction: 'docked-job-history-action',
jobDetailsPopover: 'queue-job-details-popover',
clearHistoryAction: 'clear-history-action',
jobAssetsList: 'job-assets-list',

View File

@@ -0,0 +1,252 @@
import { test as base } from '@playwright/test'
import type { Page } from '@playwright/test'
import type {
Asset,
ImportPublishedAssetsRequest,
ListAssetsResponse
} from '@comfyorg/ingest-types'
import type { z } from 'zod'
import type { zSharedWorkflowResponse } from '@/platform/workflow/sharing/schemas/shareSchemas'
import type { AssetInfo } from '@/schemas/apiSchema'
type SharedWorkflowResponse = z.input<typeof zSharedWorkflowResponse>
export const sharedWorkflowImportScenario = {
shareId: 'shared-missing-media-e2e',
workflowId: 'shared-missing-media-workflow',
publishedAssetId: 'published-input-asset-1',
inputFileName: 'shared_imported_image.png'
} as const
export type SharedWorkflowRequestEvent =
| 'import'
| 'input-assets-including-public-before-import'
| 'input-assets-including-public-after-import'
export interface SharedWorkflowImportMocks {
resetAndStartRecording: () => void
getImportBody: () => ImportPublishedAssetsRequest | undefined
getRequestEvents: () => SharedWorkflowRequestEvent[]
waitForPublicInclusiveInputAssetResponseAfterImport: () => Promise<void>
}
const defaultInputFileName = '00000000000000000000000Aexample.png'
const sharedWorkflowAsset: AssetInfo = {
id: sharedWorkflowImportScenario.publishedAssetId,
name: sharedWorkflowImportScenario.inputFileName,
preview_url: '',
storage_url: '',
model: false,
public: false,
in_library: false
}
const defaultInputAsset: Asset = {
id: 'default-input-asset',
name: defaultInputFileName,
asset_hash: defaultInputFileName,
size: 1_024,
mime_type: 'image/png',
tags: ['input'],
created_at: '2026-05-01T00:00:00Z',
updated_at: '2026-05-01T00:00:00Z',
last_access_time: '2026-05-01T00:00:00Z'
}
const importedInputAsset: Asset = {
id: 'imported-input-asset',
name: sharedWorkflowImportScenario.inputFileName,
asset_hash: sharedWorkflowImportScenario.inputFileName,
size: 1_024,
mime_type: 'image/png',
tags: ['input'],
created_at: '2026-05-01T00:00:00Z',
updated_at: '2026-05-01T00:00:00Z',
last_access_time: '2026-05-01T00:00:00Z'
}
const sharedWorkflowResponse: SharedWorkflowResponse = {
share_id: sharedWorkflowImportScenario.shareId,
workflow_id: sharedWorkflowImportScenario.workflowId,
name: 'Shared Missing Media Workflow',
listed: true,
publish_time: '2026-05-01T00:00:00Z',
workflow_json: {
version: 0.4,
last_node_id: 10,
last_link_id: 0,
nodes: [
{
id: 10,
type: 'LoadImage',
pos: [50, 200],
size: [315, 314],
flags: {},
order: 0,
mode: 0,
inputs: [],
outputs: [
{
name: 'IMAGE',
type: 'IMAGE',
links: null
},
{
name: 'MASK',
type: 'MASK',
links: null
}
],
properties: {
'Node name for S&R': 'LoadImage'
},
widgets_values: [sharedWorkflowImportScenario.inputFileName, 'image']
}
],
links: [],
groups: [],
config: {},
extra: {
ds: {
offset: [0, 0],
scale: 1
}
}
},
assets: [sharedWorkflowAsset]
}
export const sharedWorkflowImportFixture = base.extend<{
sharedWorkflowImportMocks: SharedWorkflowImportMocks
}>({
sharedWorkflowImportMocks: async ({ page }, use) => {
const mocks = await mockSharedWorkflowImportFlow(page)
await use(mocks)
}
})
async function mockSharedWorkflowImportFlow(
page: Page
): Promise<SharedWorkflowImportMocks> {
function noopResolveResponse() {}
let isRecording = false
let importEndpointCalled = false
let importBody: ImportPublishedAssetsRequest | undefined
let resolvePublicInclusiveInputAssetResponseAfterImport: () => void =
noopResolveResponse
let publicInclusiveInputAssetResponseAfterImport = new Promise<void>(
(resolve) => {
resolvePublicInclusiveInputAssetResponseAfterImport = resolve
}
)
const requestEvents: SharedWorkflowRequestEvent[] = []
function resetPublicInclusiveInputAssetResponseWaiter() {
publicInclusiveInputAssetResponseAfterImport = new Promise<void>(
(resolve) => {
resolvePublicInclusiveInputAssetResponseAfterImport = resolve
}
)
}
function recordRequestEvent(event: SharedWorkflowRequestEvent) {
if (isRecording) requestEvents.push(event)
}
await page.route(
`**/workflows/published/${sharedWorkflowImportScenario.shareId}`,
async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(sharedWorkflowResponse)
})
}
)
await page.route('**/api/assets/import', async (route) => {
recordRequestEvent('import')
importBody = route.request().postDataJSON() as ImportPublishedAssetsRequest
importEndpointCalled = true
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({})
})
})
// Excludes `/api/assets/import` so the specific route above
// remains isolated from the general asset listing mock.
await page.route(/\/api\/assets(?=\?|$)/, async (route) => {
const url = new URL(route.request().url())
const includeTags = getTagParam(url, 'include_tags')
const isInputAssetRequest = includeTags.includes('input')
const includesPublicAssets =
url.searchParams.get('include_public') === 'true'
const isPublicInclusiveInputAssetRequest =
isInputAssetRequest && includesPublicAssets
const isAfterImportPublicInclusiveInputAssetRequest =
isPublicInclusiveInputAssetRequest && importEndpointCalled
if (isPublicInclusiveInputAssetRequest) {
recordRequestEvent(
importEndpointCalled
? 'input-assets-including-public-after-import'
: 'input-assets-including-public-before-import'
)
}
const allAssets = [
defaultInputAsset,
...(importEndpointCalled ? [importedInputAsset] : [])
]
const assets = includeTags.length
? allAssets.filter((asset) =>
includeTags.every((tag) => asset.tags?.includes(tag))
)
: allAssets
const response: ListAssetsResponse = {
assets,
total: assets.length,
has_more: false
}
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(response)
})
if (isAfterImportPublicInclusiveInputAssetRequest) {
resolvePublicInclusiveInputAssetResponseAfterImport()
}
})
return {
resetAndStartRecording: () => {
isRecording = true
importEndpointCalled = false
importBody = undefined
requestEvents.length = 0
resetPublicInclusiveInputAssetResponseWaiter()
},
getImportBody: () => importBody,
getRequestEvents: () => [...requestEvents],
waitForPublicInclusiveInputAssetResponseAfterImport: () =>
publicInclusiveInputAssetResponseAfterImport
}
}
function getTagParam(url: URL, key: string): string[] {
return (
url.searchParams
.get(key)
?.split(',')
.map((tag) => tag.trim())
.filter(Boolean) ?? []
)
}

View File

@@ -0,0 +1,16 @@
import { test as base } from '@playwright/test'
import type { TemplateHelper } from '@e2e/fixtures/helpers/TemplateHelper'
import { createTemplateHelper } from '@e2e/fixtures/helpers/TemplateHelper'
export const templateApiFixture = base.extend<{
templateApi: TemplateHelper
}>({
templateApi: async ({ page }, use) => {
const templateApi = createTemplateHelper(page)
await use(templateApi)
await templateApi.clearMocks()
}
})

View File

@@ -122,3 +122,19 @@ export async function saveAndReopenInAppMode(
await comfyPage.appMode.toggleAppMode()
}
export async function saveCloseAndReopenInBuilder(
comfyPage: ComfyPage,
appMode: AppModeHelper,
workflowName: string
) {
await appMode.steps.goToPreview()
await builderSaveAs(appMode, workflowName)
await appMode.saveAs.closeButton.click()
await expect(appMode.saveAs.successDialog).toBeHidden()
await appMode.footer.exitBuilder()
await openWorkflowFromSidebar(comfyPage, workflowName)
await appMode.enterBuilder()
await appMode.steps.goToInputs()
}

View File

@@ -1,52 +0,0 @@
import type { JobDetailResponse, JobEntry } from '@comfyorg/ingest-types'
import type { MockJobRecord } from '@e2e/fixtures/helpers/JobsApiMock'
export function createMockJob(
overrides: Partial<JobEntry> & { id: string }
): JobEntry {
const now = Date.now()
return {
status: 'completed',
create_time: now,
execution_start_time: now,
execution_end_time: now + 5_000,
preview_output: {
filename: `output_${overrides.id}.png`,
subfolder: '',
type: 'output',
nodeId: '1',
mediaType: 'images'
},
outputs_count: 1,
...overrides
}
}
function isTerminalStatus(status: JobEntry['status']) {
return status === 'completed' || status === 'failed' || status === 'cancelled'
}
function createMockJobRecord(listItem: JobEntry): MockJobRecord {
const updateTime =
listItem.execution_end_time ??
listItem.execution_start_time ??
listItem.create_time
const detail: JobDetailResponse = {
...listItem,
update_time: updateTime,
...(isTerminalStatus(listItem.status) ? { outputs: {} } : {})
}
return {
listItem,
detail
}
}
export function createMockJobRecords(
listItems: readonly JobEntry[]
): MockJobRecord[] {
return listItems.map(createMockJobRecord)
}

View File

@@ -7,7 +7,7 @@ import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import type { Position, Size } from '@e2e/fixtures/types'
import { VueNodeFixture } from '@e2e/fixtures/utils/vueNodeFixtures'
export const getMiddlePoint = (pos1: Position, pos2: Position) => {
export function getMiddlePoint(pos1: Position, pos2: Position) {
return {
x: (pos1.x + pos2.x) / 2,
y: (pos1.y + pos2.y) / 2

View File

@@ -0,0 +1,28 @@
import { expect } from '@playwright/test'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
export async function openMoreOptionsMenu(
comfyPage: ComfyPage,
nodeTitle: string
) {
const nodes = await comfyPage.nodeOps.getNodeRefsByTitle(nodeTitle)
if (nodes.length === 0) {
throw new Error(`No "${nodeTitle}" nodes found`)
}
await nodes[0].centerOnNode()
await nodes[0].click('title')
await expect(comfyPage.page.locator('.selection-toolbox')).toBeVisible()
const moreOptionsBtn = comfyPage.page.getByTestId('more-options-button')
await expect(moreOptionsBtn).toBeVisible()
await moreOptionsBtn.click()
await comfyPage.nextFrame()
const menu = comfyPage.page.locator('.p-contextmenu')
await expect(menu).toBeVisible()
return menu
}

View File

@@ -45,7 +45,7 @@ test.describe('Actionbar', { tag: '@ui' }, () => {
const requestPromise = comfyPage.page.waitForResponse('**/api/prompt')
// Find and set the width on the latent node
const triggerChange = async (value: number) => {
async function triggerChange(value: number) {
return await comfyPage.page.evaluate((value) => {
const node = window.app!.graph!._nodes.find(
(n) => n.type === 'EmptyLatentImage'
@@ -59,7 +59,7 @@ test.describe('Actionbar', { tag: '@ui' }, () => {
}
// Trigger a status websocket message
const triggerStatus = (queueSize: number) => {
function triggerStatus(queueSize: number) {
ws.send(
JSON.stringify({
type: 'status',
@@ -75,7 +75,7 @@ test.describe('Actionbar', { tag: '@ui' }, () => {
}
// Extract the width from the queue response
const getQueuedWidth = async (resp: Promise<Response>) => {
async function getQueuedWidth(resp: Promise<Response>) {
const obj = await (await resp).json()
return obj['__request']['prompt']['5']['inputs']['width']
}

View File

@@ -3,6 +3,7 @@ import {
comfyExpect as expect
} from '@e2e/fixtures/ComfyPage'
import { WidgetSelectDropdownFixture } from '@e2e/fixtures/components/WidgetSelectDropdown'
import { TestIds } from '@e2e/fixtures/selectors'
test.describe('App mode usage', () => {
test('Drag and Drop', async ({ comfyPage, comfyFiles }) => {
@@ -97,8 +98,9 @@ test.describe('App mode usage', () => {
})
await sampler.click()
await comfyPage.page.getByRole('searchbox').fill('uni')
await comfyPage.page.keyboard.press('ArrowDown')
await comfyPage.page
.getByTestId(TestIds.widgets.selectDefaultSearchInput)
.fill('uni')
await comfyPage.page.keyboard.press('Enter')
await expect(sampler).toHaveText('uni_pc')

View File

@@ -87,7 +87,9 @@ test.describe('App mode dropdown clipping', { tag: '@ui' }, () => {
const codecSelect = widgetList.getByRole('combobox', { name: 'codec' })
await codecSelect.click()
const overlay = comfyPage.page.locator('.p-select-overlay').first()
const overlay = comfyPage.page
.getByTestId('widget-select-default-overlay')
.first()
await expect(overlay).toBeVisible()
await expect

View File

@@ -0,0 +1,44 @@
import {
comfyPageFixture as test,
comfyExpect as expect
} from '@e2e/fixtures/ComfyPage'
import {
saveCloseAndReopenInBuilder,
setupBuilder
} from '@e2e/fixtures/utils/builderTestUtils'
const WIDGETS = ['seed', 'steps', 'cfg']
test.describe(
'App builder input persistence after reload',
{ tag: '@ui' },
() => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.appMode.enableLinearMode()
await comfyPage.settings.setSetting(
'Comfy.AppBuilder.VueNodeSwitchDismissed',
true
)
})
test('persists selected inputs after save and reopen without visibility errors', async ({
comfyPage
}) => {
const { appMode } = comfyPage
await setupBuilder(comfyPage, undefined, WIDGETS)
await appMode.steps.goToInputs()
await expect(appMode.select.inputItemTitles).toHaveText(WIDGETS)
const workflowName = `${Date.now()} input-persistence`
await saveCloseAndReopenInBuilder(comfyPage, appMode, workflowName)
await expect(appMode.select.inputItemTitles).toHaveText(WIDGETS)
for (const widget of WIDGETS) {
await expect(
appMode.select.getInputItemSubtitle(widget)
).not.toContainText('Widget not visible')
}
})
}
)

View File

@@ -147,5 +147,68 @@ test.describe('Bottom Panel Logs', { tag: '@ui' }, () => {
)
await expect(comfyPage.bottomPanel.logs.terminalRoot).toBeHidden()
})
test('resyncs the terminal when the WebSocket reconnects', async ({
comfyPage,
logsTerminal,
getWebSocket
}) => {
const subscribeFetches = await logsTerminal.mockSubscribeLogs()
const initialLine = 'pre-reboot log line'
const postRebootLineA = 'post-reboot line A'
const postRebootLineB = 'post-reboot line B'
await logsTerminal.mockRawLogs([initialLine])
await comfyPage.bottomPanel.toggleLogs()
await expect(comfyPage.bottomPanel.logs.terminalRoot).toContainText(
initialLine
)
// Swap the raw-logs mock so the next fetch returns the post-reboot view.
await logsTerminal.mockRawLogs([postRebootLineA, postRebootLineB])
const ws = await getWebSocket()
await logsTerminal.triggerReconnect(ws, subscribeFetches)
await expect(comfyPage.bottomPanel.logs.terminalRoot).toContainText(
postRebootLineA
)
await expect(comfyPage.bottomPanel.logs.terminalRoot).toContainText(
postRebootLineB
)
// reset() before write means the pre-reboot line must be gone.
await expect(comfyPage.bottomPanel.logs.terminalRoot).not.toContainText(
initialLine
)
})
test('resumes WebSocket log streaming after the reconnect', async ({
comfyPage,
logsTerminal,
getWebSocket
}) => {
const subscribeFetches = await logsTerminal.mockSubscribeLogs()
await logsTerminal.mockRawLogs(['initial'])
await comfyPage.bottomPanel.toggleLogs()
await expect(comfyPage.bottomPanel.logs.terminalRoot).toContainText(
'initial'
)
await logsTerminal.mockRawLogs(['after-reboot snapshot'])
const ws = await getWebSocket()
await logsTerminal.triggerReconnect(ws, subscribeFetches)
// The route handler fires again on the new connection; pull the latest
// WebSocketRoute and push a live frame to prove the 'logs' listener
// survived the reconnect.
const liveLine = 'live log emitted after the reconnect'
const newWs = await getWebSocket()
newWs.send(LogsTerminalHelper.buildWsLogFrame([liveLine]))
await expect(comfyPage.bottomPanel.logs.terminalRoot).toContainText(
liveLine
)
})
})
})

View File

@@ -5,26 +5,18 @@ import {
import type { AppModeHelper } from '@e2e/fixtures/helpers/AppModeHelper'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import {
builderSaveAs,
openWorkflowFromSidebar,
saveCloseAndReopenInBuilder,
setupBuilder
} from '@e2e/fixtures/utils/builderTestUtils'
const WIDGETS = ['seed', 'steps', 'cfg']
/** Save as app, close it by loading default, reopen from sidebar, enter app mode. */
async function saveCloseAndReopenAsApp(
comfyPage: ComfyPage,
appMode: AppModeHelper,
workflowName: string
) {
await appMode.steps.goToPreview()
await builderSaveAs(appMode, workflowName)
await appMode.saveAs.closeButton.click()
await expect(appMode.saveAs.successDialog).toBeHidden()
await appMode.footer.exitBuilder()
await openWorkflowFromSidebar(comfyPage, workflowName)
await saveCloseAndReopenInBuilder(comfyPage, appMode, workflowName)
await appMode.toggleAppMode()
}

View File

@@ -5,16 +5,18 @@ import {
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import type { Size } from '@e2e/fixtures/types'
const expectedGroupSize = (
function expectedGroupSize(
nodeBounds: Size,
padding: number,
titleHeight: number
): Size => ({
width: nodeBounds.width + padding * 2,
// Group height adds one title row above the contained node bounds (which
// themselves already include the node's own title), independent of padding.
height: nodeBounds.height + padding * 2 + titleHeight
})
): Size {
return {
width: nodeBounds.width + padding * 2,
// Group height adds one title row above the contained node bounds (which
// themselves already include the node's own title), independent of padding.
height: nodeBounds.height + padding * 2 + titleHeight
}
}
test.describe('Canvas layout settings', { tag: '@canvas' }, () => {
test.describe('Comfy.SnapToGrid.GridSize', () => {
@@ -24,7 +26,7 @@ test.describe('Canvas layout settings', { tag: '@canvas' }, () => {
await comfyPage.nodeOps.clearGraph()
})
const createNode = async (comfyPage: ComfyPage) => {
async function createNode(comfyPage: ComfyPage) {
const note = await comfyPage.nodeOps.addNode('Note', undefined, {
x: 0,
y: 0
@@ -79,10 +81,10 @@ test.describe('Canvas layout settings', { tag: '@canvas' }, () => {
await comfyPage.workflow.loadWorkflow('nodes/single_ksampler')
})
const groupAroundAllNodesWithPadding = async (
async function groupAroundAllNodesWithPadding(
comfyPage: ComfyPage,
padding: number
): Promise<Size> => {
): Promise<Size> {
await comfyPage.settings.setSetting(
'Comfy.GroupSelectedNodes.Padding',
padding
@@ -126,15 +128,16 @@ test.describe('Canvas layout settings', { tag: '@canvas' }, () => {
test.describe('LiteGraph.ContextMenu.Scaling', () => {
const ZOOM_SCALE = 2
const litegraphContextMenu = (comfyPage: ComfyPage) =>
comfyPage.page.locator('.litecontextmenu')
function litegraphContextMenu(comfyPage: ComfyPage) {
return comfyPage.page.locator('.litecontextmenu')
}
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.workflow.loadWorkflow('widgets/load_image_widget')
await comfyPage.canvasOps.setScale(ZOOM_SCALE)
})
const openComboMenu = async (comfyPage: ComfyPage) => {
async function openComboMenu(comfyPage: ComfyPage) {
const loadImage = (
await comfyPage.nodeOps.getNodeRefsByType('LoadImage')
)[0]

View File

@@ -3,12 +3,14 @@ import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
const getLocators = (page: Page) => ({
trigger: page.getByRole('button', { name: 'Canvas Mode' }),
menu: page.getByRole('menu', { name: 'Canvas Mode' }),
selectItem: page.getByRole('menuitemradio', { name: 'Select' }),
handItem: page.getByRole('menuitemradio', { name: 'Hand' })
})
function getLocators(page: Page) {
return {
trigger: page.getByRole('button', { name: 'Canvas Mode' }),
menu: page.getByRole('menu', { name: 'Canvas Mode' }),
selectItem: page.getByRole('menuitemradio', { name: 'Select' }),
handItem: page.getByRole('menuitemradio', { name: 'Hand' })
}
}
const MODES = [
{

View File

@@ -7,7 +7,7 @@ import { sleep } from '@e2e/fixtures/utils/timing'
const CLIP_NODE_COUNT = 2
const getClipNodesDragBox = async (comfyPage: ComfyPage) => {
async function getClipNodesDragBox(comfyPage: ComfyPage) {
const clipNodes = await comfyPage.nodeOps.getNodeRefsByType('CLIPTextEncode')
expect(
clipNodes,
@@ -242,11 +242,11 @@ test.describe('Canvas settings', { tag: '@canvas' }, () => {
* hold), nudge by `(dx, dy)` absolute pixels, then release. Spec-local
* because it exists only to probe the CanvasPointer timing thresholds.
*/
const holdDragAt = async (
async function holdDragAt(
comfyPage: ComfyPage,
pos: { x: number; y: number },
opts: { dx: number; dy: number; holdMs: number }
) => {
) {
const abs = await comfyPage.canvasOps.toAbsolute(pos)
await comfyPage.page.mouse.move(abs.x, abs.y)
await comfyPage.page.mouse.down()
@@ -383,8 +383,9 @@ test.describe('Canvas settings', { tag: '@canvas' }, () => {
// (CI jitter, background throttling, canvas-idle behaviour). Assert the
// render-loop throttle value instead — that is what actually governs
// frame cadence.
const getFrameGap = (comfyPage: ComfyPage) =>
comfyPage.page.evaluate(() => window.app!.canvas.maximumFps * 1000)
function getFrameGap(comfyPage: ComfyPage) {
return comfyPage.page.evaluate(() => window.app!.canvas.maximumFps * 1000)
}
test('caps the render loop frame gap', async ({ comfyPage }) => {
await comfyPage.settings.setSetting('LiteGraph.Canvas.MaximumFps', 30)

View File

@@ -219,7 +219,7 @@ test.describe('Change Tracker', { tag: '@workflow' }, () => {
comfyPage
}) => {
const node = (await comfyPage.nodeOps.getFirstNodeRef())!
const bypassAndPin = async () => {
async function bypassAndPin() {
await beforeChange(comfyPage)
await comfyPage.keyboard.bypass()
await expect(node).toBeBypassed()
@@ -228,14 +228,14 @@ test.describe('Change Tracker', { tag: '@workflow' }, () => {
await afterChange(comfyPage)
}
const collapse = async () => {
async function collapse() {
await beforeChange(comfyPage)
await node.click('collapse', { moveMouseToEmptyArea: true })
await expect(node).toBeCollapsed()
await afterChange(comfyPage)
}
const multipleChanges = async () => {
async function multipleChanges() {
await beforeChange(comfyPage)
// Call other actions that uses begin/endChange
await node.click('title')

View File

@@ -0,0 +1,78 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
test.describe('Curve Widget', { tag: ['@widget', '@vue-nodes'] }, () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
await comfyPage.workflow.loadWorkflow('widgets/curve_widget')
await comfyPage.vueNodes.waitForNodes()
})
test(
'Loads control points and interpolation from workflow',
{ tag: '@smoke' },
async ({ comfyPage }) => {
const node = comfyPage.vueNodes.getNodeLocator('1')
await expect(node).toBeVisible()
const svg = node.getByTestId('curve-editor')
await expect(svg).toBeVisible()
const points = svg.getByTestId('curve-point')
await expect(points).toHaveCount(2)
const [cxs, cys] = await Promise.all([
points.evaluateAll((els) =>
els.map((e) => Number(e.getAttribute('cx')))
),
points.evaluateAll((els) =>
els.map((e) => Number(e.getAttribute('cy')))
)
])
expect(cxs[0]).toBeCloseTo(0.2, 5)
expect(cxs[1]).toBeCloseTo(0.8, 5)
expect(cys[0]).toBeCloseTo(0.3, 5)
expect(cys[1]).toBeCloseTo(0.7, 5)
}
)
test(
'Interpolation selector reflects loaded value (Linear)',
{ tag: '@smoke' },
async ({ comfyPage }) => {
const node = comfyPage.vueNodes.getNodeLocator('1')
await expect(node).toBeVisible()
await expect(node.getByText('Linear', { exact: true })).toBeVisible()
await expect(node.getByText('Smooth', { exact: true })).toHaveCount(0)
}
)
test(
'Click on SVG canvas adds a control point',
{ tag: '@smoke' },
async ({ comfyPage }) => {
const node = comfyPage.vueNodes.getNodeLocator('1')
const svg = node.getByTestId('curve-editor')
await expect(svg).toBeVisible()
await expect(svg.getByTestId('curve-point')).toHaveCount(2)
const position = await svg.evaluate((el) => {
const svgEl = el as SVGSVGElement
const ctm = svgEl.getScreenCTM()
if (!ctm) throw new Error('SVG has no screen CTM')
const pt = svgEl.createSVGPoint()
pt.x = 0.5
pt.y = 1 - 0.5 // curve-Y is inverted vs SVG-Y
const screen = pt.matrixTransform(ctm)
const rect = svgEl.getBoundingClientRect()
return { x: screen.x - rect.left, y: screen.y - rect.top }
})
await svg.click({ position })
await expect(svg.getByTestId('curve-point')).toHaveCount(3)
}
)
})

View File

@@ -133,10 +133,11 @@ test.describe('Default Keybindings', { tag: '@keyboard' }, () => {
await node.click('title')
// Normal mode is ALWAYS (0)
const getMode = () =>
comfyPage.page.evaluate((nodeId) => {
function getMode() {
return comfyPage.page.evaluate((nodeId) => {
return window.app!.canvas.graph!.getNodeById(nodeId)!.mode
}, node.id)
}
await expect.poll(() => getMode()).toBe(0)

View File

@@ -247,6 +247,14 @@ test.describe('Cloud notification dialog', () => {
await dialog.back.click()
await expect(dialog.root).toBeHidden()
})
test('Should not advertise free monthly credits', async ({ comfyPage }) => {
const dialog = new CloudNotification(comfyPage.page)
await dialog.open()
await expect(dialog.root.getByText(/Free Credits/i)).toHaveCount(0)
await expect(dialog.root.getByText(/400/)).toHaveCount(0)
})
})
test('Blueprint overwrite', { tag: ['@subgraph'] }, async ({ comfyPage }) => {
@@ -282,7 +290,7 @@ test('Blueprint overwrite', { tag: ['@subgraph'] }, async ({ comfyPage }) => {
const confirmDialog = comfyPage.confirmDialog.root
const { incrementButton } = comfyPage.vueNodes.getInputNumberControls(steps)
const dirtyGraphAndSave = async () => {
async function dirtyGraphAndSave() {
await incrementButton.click()
await comfyPage.page.keyboard.press('Control+s')
}

View File

@@ -21,13 +21,14 @@ test.describe('Group Copy Paste', { tag: ['@canvas'] }, () => {
await comfyPage.clipboard.paste()
await comfyPage.nextFrame()
const getGroupPositions = () =>
comfyPage.page.evaluate(() =>
function getGroupPositions() {
return comfyPage.page.evaluate(() =>
window.app!.graph.groups.map((g: { pos: number[] }) => ({
x: g.pos[0],
y: g.pos[1]
}))
)
}
await expect.poll(getGroupPositions).toHaveLength(2)

View File

@@ -137,7 +137,7 @@ test.describe('Group Node', { tag: '@node' }, () => {
test('Manage group opens with the correct group selected', async ({
comfyPage
}) => {
const makeGroup = async (name: string, type1: string, type2: string) => {
async function makeGroup(name: string, type1: string, type2: string) {
const node1 = (await comfyPage.nodeOps.getNodeRefsByType(type1))[0]
const node2 = (await comfyPage.nodeOps.getNodeRefsByType(type2))[0]
await node1.click('title')
@@ -204,7 +204,7 @@ test.describe('Group Node', { tag: '@node' }, () => {
test('Reconnects inputs after configuration changed via manage dialog save', async ({
comfyPage
}) => {
const expectSingleNode = async (type: string) => {
async function expectSingleNode(type: string) {
const nodes = await comfyPage.nodeOps.getNodeRefsByType(type)
expect(nodes).toHaveLength(1)
return nodes[0]
@@ -255,13 +255,13 @@ test.describe('Group Node', { tag: '@node' }, () => {
const GROUP_NODE_NAME = 'group_node' // Node name in given workflow
const GROUP_NODE_TYPE = `${GROUP_NODE_PREFIX}${GROUP_NODE_NAME}`
const isRegisteredLitegraph = async (comfyPage: ComfyPage) => {
async function isRegisteredLitegraph(comfyPage: ComfyPage) {
return await comfyPage.page.evaluate((nodeType: string) => {
return !!window.LiteGraph!.registered_node_types[nodeType]
}, GROUP_NODE_TYPE)
}
const isRegisteredNodeDefStore = async (comfyPage: ComfyPage) => {
async function isRegisteredNodeDefStore(comfyPage: ComfyPage) {
await comfyPage.menu.nodeLibraryTab.open()
const groupNodesFolderCt = await comfyPage.menu.nodeLibraryTab
.getFolder(GROUP_NODE_CATEGORY)
@@ -269,10 +269,10 @@ test.describe('Group Node', { tag: '@node' }, () => {
return groupNodesFolderCt === 1
}
const verifyNodeLoaded = async (
async function verifyNodeLoaded(
comfyPage: ComfyPage,
expectedCount: number
) => {
) {
expect(
await comfyPage.nodeOps.getNodeRefsByType(GROUP_NODE_TYPE)
).toHaveLength(expectedCount)
@@ -361,3 +361,15 @@ test.describe('Group Node', { tag: '@node' }, () => {
})
})
})
test('Convert to subgraph unpacks the group Node @vue-nodes', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('groupnodes/legacy_group_node')
await (await comfyPage.vueNodes.getFixtureByTitle('hello')).title.click()
await comfyPage.page.keyboard.press('Control+Shift+e')
await expect(comfyPage.vueNodes.getNodeByTitle('New Subgraph')).toBeVisible()
await comfyPage.vueNodes.enterSubgraph()
await expect(comfyPage.vueNodes.getNodeByTitle('')).toHaveCount(2)
})

View File

@@ -510,7 +510,7 @@ test.describe('Image Compare', { tag: ['@widget', '@vue-nodes'] }, () => {
const brokenAfter = 'http://127.0.0.1:1/broken2.png'
const pageErrors: Error[] = []
const onPageError = (err: Error) => {
function onPageError(err: Error) {
pageErrors.push(err)
}
comfyPage.page.on('pageerror', onPageError)

View File

@@ -82,10 +82,10 @@ test.describe('Node Interaction', () => {
}
)
const dragSelectNodes = async (
async function dragSelectNodes(
comfyPage: ComfyPage,
clipNodes: NodeReference[]
) => {
) {
const clipNode1Pos = await clipNodes[0].getPosition()
const clipNode2Pos = await clipNodes[1].getPosition()
const offset = 64
@@ -117,15 +117,16 @@ test.describe('Node Interaction', () => {
}) => {
const clipNodes =
await comfyPage.nodeOps.getNodeRefsByType('CLIPTextEncode')
const getPositions = () =>
Promise.all(clipNodes.map((node) => node.getPosition()))
const testDirection = async ({
function getPositions() {
return Promise.all(clipNodes.map((node) => node.getPosition()))
}
async function testDirection({
direction,
expectedPosition
}: {
direction: string
expectedPosition: (originalPosition: Position) => Position
}) => {
}) {
const originalPositions = await getPositions()
await dragSelectNodes(comfyPage, clipNodes)
await comfyPage.command.executeCommand(
@@ -671,7 +672,7 @@ test.describe('Canvas Interaction', { tag: '@screenshot' }, () => {
})
test('Cursor style changes when panning', async ({ comfyPage }) => {
const getCursorStyle = async () => {
async function getCursorStyle() {
return await comfyPage.page.evaluate(() => {
return (
document.getElementById('graph-canvas')!.style.cursor || 'default'
@@ -703,7 +704,7 @@ test.describe('Canvas Interaction', { tag: '@screenshot' }, () => {
test('Properly resets dragging state after pan mode sequence', async ({
comfyPage
}) => {
const getCursorStyle = async () => {
async function getCursorStyle() {
return await comfyPage.page.evaluate(() => {
return (
document.getElementById('graph-canvas')!.style.cursor || 'default'
@@ -878,8 +879,9 @@ test.describe('Load workflow', { tag: '@screenshot' }, () => {
)
})
const generateUniqueFilename = (extension = '') =>
`${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}${extension}`
function generateUniqueFilename(extension = '') {
return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}${extension}`
}
test.describe('Restore all open workflows on reload', () => {
let workflowA: string
@@ -1077,7 +1079,7 @@ test.describe('Viewport settings', () => {
comfyPage,
comfyMouse
}) => {
const changeTab = async (tab: Locator) => {
async function changeTab(tab: Locator) {
await tab.click()
await comfyPage.nextFrame()
await comfyMouse.move(DefaultGraphPositions.emptySpace)
@@ -1406,7 +1408,7 @@ test.describe('Canvas Navigation', { tag: '@screenshot' }, () => {
test('Cursor changes appropriately in different modes', async ({
comfyPage
}) => {
const getCursorStyle = async () => {
async function getCursorStyle() {
return await comfyPage.page.evaluate(() => {
return (
document.getElementById('graph-canvas')!.style.cursor || 'default'

Binary file not shown.

Before

Width:  |  Height:  |  Size: 93 KiB

After

Width:  |  Height:  |  Size: 93 KiB

View File

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

View File

@@ -155,7 +155,7 @@ test.describe('Load3D', () => {
async ({ comfyPage, load3d }) => {
await expect(load3d.uploadBackgroundImageButton).toBeVisible()
const node = await comfyPage.nodeOps.getNodeRefById(1)
const readBackgroundImage = async () => {
async function readBackgroundImage() {
const properties =
await node.getProperty<Record<string, { backgroundImage?: string }>>(
'properties'
@@ -222,7 +222,7 @@ test.describe('Load3D', () => {
await expect(load3d.gridToggleButton).toBeVisible()
const node = await comfyPage.nodeOps.getNodeRefById(1)
const readShowGrid = async () => {
async function readShowGrid() {
const properties =
await node.getProperty<Record<string, { showGrid?: boolean }>>(
'properties'

Binary file not shown.

Before

Width:  |  Height:  |  Size: 56 KiB

After

Width:  |  Height:  |  Size: 55 KiB

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