Compare commits

..

24 Commits

Author SHA1 Message Date
Jin Yi
07e892bcc5 fix: align teleported dropdown right edge to trigger right edge
Amp-Thread-ID: https://ampcode.com/threads/T-019d6fd8-61fd-76ed-975a-71e146c1dd5e
Co-authored-by: Amp <amp@ampcode.com>
2026-04-09 18:54:33 +09:00
Jin Yi
b77f0551bd fix: align teleported dropdown to trigger left edge instead of right
Amp-Thread-ID: https://ampcode.com/threads/T-019d6fd8-61fd-76ed-975a-71e146c1dd5e
Co-authored-by: Amp <amp@ampcode.com>
2026-04-09 18:00:10 +09:00
Jin Yi
04a172c880 chore: trigger re-review
Amp-Thread-ID: https://ampcode.com/threads/T-019d6fd8-61fd-76ed-975a-71e146c1dd5e
Co-authored-by: Amp <amp@ampcode.com>
2026-04-09 17:56:44 +09:00
Jin Yi
b29c56dd55 fix: resolve test type errors and remove assertion on closed dropdown
Amp-Thread-ID: https://ampcode.com/threads/T-019d6fd8-61fd-76ed-975a-71e146c1dd5e
Co-authored-by: Amp <amp@ampcode.com>
2026-04-09 17:53:51 +09:00
Jin Yi
8f000fe8da fix: use shared MENU_HEIGHT/MENU_WIDTH constants in FormDropdownMenu
Use the shared constants from types.ts instead of hardcoded Tailwind
classes so dimension changes are reflected in both the menu component
and the positioning logic in FormDropdown.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 17:47:46 +09:00
Jin Yi
8618661bab fix: clamp teleported dropdown position to viewport bounds
When neither upward nor downward direction has enough space for the
full menu height, clamp the position so the menu stays within the
viewport instead of overflowing.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 17:47:46 +09:00
Jin Yi
43a24cc869 fix: prevent teleported dropdown from overflowing viewport top
Amp-Thread-ID: https://ampcode.com/threads/T-019d2d64-af34-7489-abd5-cde23ead7105
Co-authored-by: Amp <amp@ampcode.com>
2026-04-09 17:47:46 +09:00
Jin Yi
3f3d6e4ebe fix: extract MENU_HEIGHT/MENU_WIDTH as shared constants, drop computed for shouldTeleport
Amp-Thread-ID: https://ampcode.com/threads/T-019d2d37-f1a3-7421-90b9-b4d8d058bedb
Co-authored-by: Amp <amp@ampcode.com>
2026-04-09 17:47:46 +09:00
Jin Yi
0a4d3e307b fix: flip teleported dropdown upward when near viewport bottom
Apply the same openUpward logic for both teleported and local cases.
When teleported, use bottom CSS property to open upward.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 17:47:45 +09:00
Jin Yi
83da733a5f fix: teleport FormDropdown to body in app mode with bottom-right positioning
Inject OverlayAppendToKey to detect app mode vs canvas. In app mode,
use Teleport to body with position:fixed at the trigger's bottom-right
corner, clamped to viewport bounds. In canvas, keep local absolute
positioning for correct zoom scaling.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 17:47:45 +09:00
Jin Yi
eed1fbbb8a Revert "fix: restore teleport for FormDropdown in app mode"
This reverts commit 8a88e40c40.
2026-04-09 17:47:45 +09:00
Jin Yi
c514b6a825 fix: restore teleport for FormDropdown in app mode
Inject OverlayAppendToKey to detect app mode ('body') vs canvas
('self'). In app mode, use <Teleport to="body"> with position:fixed
to escape overflow-hidden/overflow-y-auto ancestors. In canvas, keep
local absolute positioning for correct zoom scaling.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 17:47:45 +09:00
Jin Yi
88dfe6d749 fix: prefer direction with more available space for dropdown
Compare space above vs below the trigger and open toward whichever
side has more room. Prevents flipping upward when the menu would
overflow even more in that direction.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 17:47:45 +09:00
Jin Yi
e98df0a577 fix: flip dropdown upward when near viewport bottom
Use getBoundingClientRect() only for direction detection (not
positioning), so it works safely even inside CSS transform chains.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 17:47:45 +09:00
Jin Yi
15226f7730 fix: stabilize E2E tests for FormDropdown positioning
- Replace fragile CSS selectors with data-testid for trigger button
- Update appModeDropdownClipping to use getByTestId after Popover removal
- Change zoom test from 0.5 to 0.75 to avoid too-small click targets

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 17:47:45 +09:00
Jin Yi
b495372511 fix: formdropdown position 2026-04-09 17:47:45 +09:00
jaeone94
809da9c11c fix: use cloud assets for asset widget default value (#10983)
## Summary

In cloud mode, asset-supported nodes (e.g. CheckpointLoaderSimple) used
the server's `object_info` combo options as their default widget value.
These options list local files on the backend which may not exist in the
user's cloud asset library. When the missing-model pipeline runs (on
undo, reload, or tab switch), it checks widget values against cloud
assets and correctly flags these local-only files as missing — producing
errors that appear to be false positives but are actually valid
detections of unusable defaults.

This PR changes the default value source from server combo options to
the cloud assets store.

## Default Value Behavior (Before → After)

### Cloud + asset-supported widgets (changed)

| Condition | Before | After |
|-----------|--------|-------|
| Assets cached, `inputSpec.default` in assets | `inputSpec.default` |
`inputSpec.default` |
| Assets cached, `inputSpec.default` not in assets | `inputSpec.default`
| `assets[0]` |
| Assets cached, no `inputSpec.default`, `options` exist | `options[0]`
| `assets[0]` |
| Assets not cached, `inputSpec.default` exists | `inputSpec.default` |
`undefined` → "Select model" |
| Assets not cached, no `inputSpec.default`, `options` exist |
`options[0]` | `undefined` → "Select model" |
| Assets not cached, no `inputSpec.default`, no `options` | `undefined`
→ "Select model" | `undefined` → "Select model" |

### Cloud + non-asset widgets (unchanged)

| Condition | Behavior |
|-----------|----------|
| `inputSpec.default` exists | `inputSpec.default` |
| `options` exist | `options[0]` |
| `remote` input | `"Loading..."` |
| None | `undefined` |

### OSS (unchanged)

| Condition | Behavior |
|-----------|----------|
| `inputSpec.default` exists | `inputSpec.default` |
| `options` exist | `options[0]` |
| `remote` input | `"Loading..."` |
| None | `undefined` |

## Root Cause

1. `addComboWidget` called `getDefaultValue(inputSpec)` which returns
`inputSpec.options[0]` — a local file from `object_info`
2. In cloud mode, `shouldUseAssetBrowser()` creates an asset widget with
this local filename as default
3. The model (e.g.
`dynamicrafter/controlnet/dc-sketch_encoder_fp16.safetensors`) exists on
the server but not in the user's cloud asset library
4. On undo/reload, `verifyAssetSupportedCandidates()` checks the widget
value against cloud assets → not found → marked as missing

## Changes

### Production (`useComboWidget.ts`)
- New `resolveCloudDefault(nodeType, specDefault)` function encapsulates
cloud default resolution
- Default priority: `inputSpec.default` (if found in cloud assets) →
first cloud asset → `undefined` (shows "Select model" placeholder)
- Edge case guards: `!= null` check for falsy defaults, `|| undefined`
for empty `getAssetFilename` return
- Server combo options (`object_info`) are no longer used as defaults
for asset widgets

### Unit Tests (`useComboWidget.test.ts`)
- 6 scenarios covering all default value paths:
  - Cloud assets loaded, no `inputSpec.default` → `assets[0]`
  - Cloud assets loaded, `inputSpec.default` in assets → uses default
  - Cloud assets loaded, `inputSpec.default` not in assets → `assets[0]`
  - No cloud assets, with `inputSpec.default` → placeholder
  - No cloud assets, with server options → placeholder
  - Asset widget creation verification
- Test helper refactored: assertions moved from helper to each test for
clarity

### E2E Test (`cloud-asset-default.spec.ts`)
- New `@cloud` tagged test verifying CheckpointLoaderSimple uses first
cloud asset, not server default
- Fixture extension stubs `/api/assets` before app loads (local backend
returns 503 for this endpoint)
- Uses typed mock data from existing `assetFixtures.ts`

## Scope

- **Cloud only**: All changes gated behind `isCloud` +
`shouldUseAssetBrowser()`
- **OSS impact**: None — code path is not entered in non-cloud builds
- **Breaking changes**: None — `useComboWidget` export signature
unchanged

## Review Focus
- Should the `/api/assets` stub in the E2E fixture extension be moved
into `ComfyPage` for all `@cloud` tests?

## Record
Before 


https://github.com/user-attachments/assets/994162a0-b56a-4e84-9b1c-d0f0068196d5



After


https://github.com/user-attachments/assets/ba299990-9bd3-4565-bd09-bffac3db60a9
2026-04-09 15:44:04 +09:00
Dante
65d1313443 fix: preserve CustomCombo options through clone and paste (#10853)
## Summary

- Fix `CustomCombo` copy/paste so the combo keeps its option list and
selected value
- Scope the fix to `src/extensions/core/customWidgets.ts` instead of
changing LiteGraph core deserialization
- Replace the previous round-trip test with a regression test that
exercises the actual clone/paste lifecycle

- Fixes #9927

## Root Cause

`CustomCombo` option widgets override `value` to read from
`widgetValueStore`.
During `node.clone()` and clipboard paste, `configure()` restores widget
values before the new node is added to the graph and before those
widgets are registered in the store.
That meant the option widgets read back as empty while `updateCombo()`
was rebuilding the combo state, so `comboWidget.options.values` became
blank on the pasted node.

## Fix

Keep a local fallback value for each generated `option*` widget in
`customWidgets.ts`.
The getter now returns the store-backed value when available and falls
back to the locally restored value before store registration.
This preserves the option list during `clone().serialize()` and paste
without hard-coding `CustomCombo` behavior into
`LGraphNode.configure()`.

## Why No E2E Test

This regression happens in the internal LiteGraph clipboard lifecycle:
`clone() -> serialize() -> createNode() -> configure() -> graph.add()`.
The failing state is the transient pre-add relationship between
`CustomCombo`'s store-backed option widgets and
`comboWidget.options.values`, which is not directly exposed through a
stable DOM assertion in the current Playwright suite.
A focused unit regression test is the most direct way to cover that
lifecycle without depending on brittle canvas interaction timing.

## Test Plan

- [x] Regression test covers `clone().serialize() -> createNode() ->
configure() -> graph.add()` for `CustomCombo`
- [ ] CI on the latest two commits (`81ac6d2ce`, `94147caf1`)
- [ ] Manual: create `CustomCombo` -> add `alpha`, `beta`, `gamma` ->
select `beta` -> copy/paste -> verify the pasted combo still shows all
three options and keeps `beta` selected
2026-04-09 12:35:20 +09:00
Alexander Brown
f90d6cf607 test: migrate 132 test files from @vue/test-utils to @testing-library/vue (#10965)
## Summary

Migrate 132 test files from `@vue/test-utils` (VTU) to
`@testing-library/vue` (VTL) with `@testing-library/user-event`,
adopting user-centric behavioral testing patterns across the codebase.

## Changes

- **What**: Systematic migration of component/unit tests from VTU's
`mount`/`wrapper` API to VTL's `render`/`screen`/`userEvent` API across
132 files in `src/`
- **Breaking**: None — test-only changes, no production code affected

### Migration breakdown

| Batch | Files | Description |
|-------|-------|-------------|
| 1 | 19 | Simple render/assert tests |
| 2A | 16 | Interactive tests with user events |
| 2B-1 | 14 | Interactive tests (continued) |
| 2B-2 | 32 | Interactive tests (continued) |
| 3A–3E | 51 | Complex tests (stores, composables, heavy mocking) |
| Lint fix | 7 | `await` on `fireEvent` calls for `no-floating-promises`
|
| Review fixes | 15 | Address CodeRabbit feedback (3 rounds) |

### Review feedback addressed

- Removed class-based assertions (`text-ellipsis`, `pr-3`, `.pi-save`,
`.skeleton`, `.bg-black\/15`, Tailwind utilities) in favor of
behavioral/accessible queries
- Added null guards before `querySelector` casts
- Added `expect(roots).toHaveLength(N)` guards before indexed NodeList
access
- Wrapped fake timer tests in `try/finally` for guaranteed cleanup
- Split double-render tests into focused single-render tests
- Replaced CSS class selectors with
`screen.getByText`/`screen.getByRole` queries
- Updated stubs to use semantic `role`/`aria-label` instead of CSS
classes
- Consolidated redundant edge-case tests
- Removed manual `document.body.appendChild` in favor of VTL container
management
- Used distinct mock return values to verify command wiring

### VTU holdouts (2 files)

These files intentionally retain `@vue/test-utils` because their
components use `<script setup>` without `defineExpose`, making internal
computed properties and methods inaccessible via VTL:

1. **`NodeWidgets.test.ts`** — partial VTU for `vm.processedWidgets`
2. **`WidgetSelectDropdown.test.ts`** — full VTU for heavy
`wrapper.vm.*` access

## Follow-up

Deferred items (`ComponentProps` typing, camelCase listener props)
tracked in #10966.

## Review Focus

- Test correctness: all migrated tests preserve original behavioral
coverage
- VTL idioms: proper use of `screen` queries, `userEvent`, and
accessibility-based selectors
- The 2 VTU holdout files are intentional, not oversights

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10965-test-migrate-132-test-files-from-vue-test-utils-to-testing-library-vue-33c6d73d36508199a6a7e513cf5d8296)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Amp <amp@ampcode.com>
Co-authored-by: Christian Byrne <cbyrne@comfy.org>
2026-04-08 19:21:42 -07:00
Christian Byrne
2c34d955cb feat(website): add zh-CN translations for homepage and secondary pages (#10157)
## Summary

<!-- One sentence describing what changed and why. -->

## Changes

- **What**: <!-- Core functionality added/modified -->
- **Breaking**: <!-- Any breaking changes (if none, remove this line)
-->
- **Dependencies**: <!-- New dependencies (if none, remove this line)
-->

## Review Focus

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

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

## Screenshots (if applicable)

<!-- Add screenshots or video recording to help explain your changes -->

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10157-feat-website-add-zh-CN-translations-for-homepage-and-secondary-pages-3266d73d3650811f918cc35eca62a4bc)
by [Unito](https://www.unito.io)

---------

Co-authored-by: GitHub Action <action@github.com>
2026-04-08 19:18:19 -07:00
Christian Byrne
8b6c1b3649 refactor: consolidate SubscriptionTier type (#10487)
## Summary

Consolidate the `SubscriptionTier` type from 3 independent definitions
into a single source of truth in `tierPricing.ts`.

## Changes

- **What**: Exported `SubscriptionTier` from `tierPricing.ts`. Removed
hand-written unions from `workspaceApi.ts` (lines 80-88),
`PricingTable.vue`, and `PricingTableWorkspace.vue`. All now import from
the canonical location.
- **Files**: 4 files changed (type-only, ~5 net lines)

## Review Focus

- This is a type-only change — `pnpm typecheck` is the primary
validation
- If the OpenAPI schema ever adds tiers, there is now one place to
update

## Stack

PR 5/5: #10483#10484#10485#10486 → **→ This PR**
2026-04-08 19:17:44 -07:00
Christian Byrne
026aeb71b2 refactor: decompose MembersPanelContent into focused components (#10486)
## Summary

Decompose the 562-line `MembersPanelContent.vue` into focused
single-responsibility components.

## Changes

- **What**: Extracted `RoleBadge.vue`, `MemberListItem.vue`,
`PendingInvitesList.vue`, and `MemberUpsellBanner.vue` from
`MembersPanelContent.vue`. Added `RoleBadge.test.ts`. The parent
component is slimmed from 562 → ~120 lines.
- **Files**: 6 files changed (4 new components + 1 new test + 1
refactored)

## Review Focus

- Component boundaries — each extracted component has a clear single
responsibility
- `MembersPanelContent.vue` still orchestrates all behavior; extracted
components are presentational
- Visual QA needed: workspace settings panel should look and behave
identically

## Stack

PR 4/5: #10483#10484#10485 → **→ This PR** → #10487

---------

Co-authored-by: Alexander Brown <drjkl@comfy.org>
Co-authored-by: GitHub Action <action@github.com>
2026-04-08 18:57:11 -07:00
Alexander Brown
d96a7d2b32 fix: resolve lint/knip warnings and upgrade oxlint, oxfmt, knip (#10973)
## Changes

- Fix unsafe optional chaining warnings in 2 test files
- Promote `no-unsafe-optional-chaining` to error in oxlintrc
- Remove stale knip ignores (useGLSLRenderer, website deps, astro entry)
- Remove `vue/no-dupe-keys` from oxlintrc (removed from oxlint vue
plugin; `eslint/no-dupe-keys` covers it)
- Un-export unused `UniformSource`/`UniformSources` interfaces
- Dedupe pnpm lockfile

## Dependency Upgrades

| Package | Before | After |
|---------|--------|-------|
| knip | 6.0.1 | 6.3.1 |
| oxlint | 1.55.0 | 1.59.0 |
| oxfmt | 0.40.0 | 0.44.0 |
| eslint-plugin-oxlint | 1.55.0 | 1.59.0 |
| oxlint-tsgolint | 0.17.0 | 0.20.0 |

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10973-fix-resolve-lint-knip-warnings-and-upgrade-oxlint-oxfmt-knip-33c6d73d36508135a773f0a174471cf9)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Amp <amp@ampcode.com>
2026-04-08 18:30:37 -07:00
Comfy Org PR Bot
1720aa0286 1.44.0 (#10974)
Minor version increment to 1.44.0

**Base branch:** `main`

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10974-1-44-0-33c6d73d365081d98a3bd646d3374b3b)
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-04-08 18:13:31 -07:00
165 changed files with 1735 additions and 8173 deletions

View File

@@ -64,17 +64,15 @@ jobs:
- name: Download and Deploy Reports
if: steps.pr.outputs.result != 'null' && github.event.action == 'completed'
uses: dawidd6/action-download-artifact@0bd50d53a6d7fb5cb921e607957e9cc12b4ce392 # v12
uses: actions/download-artifact@v7
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
run_id: ${{ github.event.workflow_run.id }}
name: playwright-report-.*
name_is_regexp: true
github-token: ${{ secrets.GITHUB_TOKEN }}
run-id: ${{ github.event.workflow_run.id }}
pattern: playwright-report-*
path: reports
if_no_artifact_found: warn
- name: Handle Test Completion
if: steps.pr.outputs.result != 'null' && github.event.action == 'completed' && hashFiles('reports/**') != ''
if: steps.pr.outputs.result != 'null' && github.event.action == 'completed'
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}

View File

@@ -7,6 +7,7 @@ on:
paths-ignore: ['**/*.md']
pull_request:
branches-ignore: [wip/*, draft/*, temp/*]
paths-ignore: ['**/*.md']
workflow_dispatch:
concurrency:
@@ -14,26 +15,7 @@ concurrency:
cancel-in-progress: true
jobs:
changes:
runs-on: ubuntu-latest
if: github.event_name == 'pull_request'
permissions:
pull-requests: read
outputs:
should_run: ${{ steps.filter.outputs.should_run }}
steps:
- name: Checkout repository
uses: actions/checkout@v6
- uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2
id: filter
with:
filters: |
should_run:
- '!(**.md)'
setup:
needs: changes
if: github.event_name != 'pull_request' || needs.changes.outputs.should_run == 'true'
runs-on: ubuntu-latest
steps:
- name: Checkout repository
@@ -171,9 +153,9 @@ jobs:
# Merge sharded test reports (no container needed - only runs CLI)
merge-reports:
needs: [changes, playwright-tests-chromium-sharded]
needs: [playwright-tests-chromium-sharded]
runs-on: ubuntu-latest
if: ${{ !cancelled() && (github.event_name != 'pull_request' || needs.changes.outputs.should_run == 'true') }}
if: ${{ !cancelled() }}
steps:
- name: Install pnpm
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
@@ -208,9 +190,8 @@ jobs:
# Post starting comment for non-forked PRs
comment-on-pr-start:
needs: changes
runs-on: ubuntu-latest
if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == false && needs.changes.outputs.should_run == 'true'
if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == false
permissions:
pull-requests: write
steps:
@@ -229,9 +210,9 @@ jobs:
# Deploy and comment for non-forked PRs only
deploy-and-comment:
needs: [changes, playwright-tests, merge-reports]
needs: [playwright-tests, merge-reports]
runs-on: ubuntu-latest
if: always() && github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == false && needs.changes.outputs.should_run == 'true'
if: always() && github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == false
permissions:
pull-requests: write
contents: read
@@ -256,24 +237,4 @@ jobs:
"${{ github.event.pull_request.number }}" \
"${{ github.head_ref }}" \
"completed"
e2e-status:
if: always()
needs: [changes, playwright-tests-chromium-sharded, playwright-tests]
runs-on: ubuntu-latest
steps:
- name: Determine e2e outcome
run: |
if [[ "${{ needs.changes.outputs.should_run }}" != "true" && "${{ github.event_name }}" == "pull_request" ]]; then
echo "E2E tests skipped (no relevant changes)"
exit 0
fi
if [[ "${{ needs.playwright-tests-chromium-sharded.result }}" == "success" && "${{ needs.playwright-tests.result }}" == "success" ]]; then
echo "All E2E tests passed"
exit 0
fi
echo "E2E tests failed or were cancelled"
echo " chromium-sharded: ${{ needs.playwright-tests-chromium-sharded.result }}"
echo " playwright-tests: ${{ needs.playwright-tests.result }}"
exit 1
#### END Deployment and commenting (non-forked PRs only)

View File

@@ -1,90 +0,0 @@
---
name: 'CI: Vercel Website Preview'
on:
pull_request:
types: [opened, synchronize, reopened]
paths:
- 'apps/website/**'
- 'packages/design-system/**'
- 'packages/tailwind-utils/**'
push:
branches: [main]
paths:
- 'apps/website/**'
- 'packages/design-system/**'
- 'packages/tailwind-utils/**'
env:
VERCEL_ORG_ID: ${{ secrets.VERCEL_WEBSITE_ORG_ID }}
VERCEL_PROJECT_ID: ${{ secrets.VERCEL_WEBSITE_PROJECT_ID }}
jobs:
deploy-preview:
if: github.event_name == 'pull_request'
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: Install pnpm
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
- name: Install Vercel CLI
run: npm install --global vercel@latest
- name: Pull Vercel environment information
run: vercel pull --yes --environment=preview --token=${{ secrets.VERCEL_WEBSITE_TOKEN }}
- name: Build project artifacts
run: vercel build --token=${{ secrets.VERCEL_WEBSITE_TOKEN }}
- name: Deploy project artifacts to Vercel
id: deploy
run: |
URL=$(vercel deploy --prebuilt --token=${{ secrets.VERCEL_WEBSITE_TOKEN }})
echo "url=$URL" >> "$GITHUB_OUTPUT"
- name: Add deployment URL to summary
run: echo "**Preview:** ${{ steps.deploy.outputs.url }}" >> "$GITHUB_STEP_SUMMARY"
- name: Save PR metadata
run: |
mkdir -p temp/vercel-preview
echo "${{ steps.deploy.outputs.url }}" > temp/vercel-preview/url.txt
- name: Upload preview metadata
uses: actions/upload-artifact@v6
with:
name: vercel-preview
path: temp/vercel-preview
deploy-production:
if: github.event_name == 'push'
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: Install pnpm
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
- name: Install Vercel CLI
run: npm install --global vercel@latest
- name: Pull Vercel environment information
run: vercel pull --yes --environment=production --token=${{ secrets.VERCEL_WEBSITE_TOKEN }}
- name: Build project artifacts
run: vercel build --prod --token=${{ secrets.VERCEL_WEBSITE_TOKEN }}
- name: Deploy project artifacts to Vercel
id: deploy
run: |
URL=$(vercel deploy --prebuilt --prod --token=${{ secrets.VERCEL_WEBSITE_TOKEN }})
echo "url=$URL" >> "$GITHUB_OUTPUT"
- name: Add deployment URL to summary
run: echo "**Production:** ${{ steps.deploy.outputs.url }}" >> "$GITHUB_STEP_SUMMARY"

View File

@@ -1,74 +0,0 @@
---
name: 'PR: Vercel Website Preview'
on:
workflow_run:
workflows: ['CI: Vercel Website Preview']
types:
- completed
permissions:
contents: read
pull-requests: write
actions: read
jobs:
comment:
runs-on: ubuntu-latest
if: >
github.repository == 'Comfy-Org/ComfyUI_frontend' &&
github.event.workflow_run.event == 'pull_request' &&
github.event.workflow_run.conclusion == 'success'
steps:
- uses: actions/checkout@v6
- name: Download preview metadata
uses: dawidd6/action-download-artifact@0bd50d53a6d7fb5cb921e607957e9cc12b4ce392 # v12
with:
name: vercel-preview
run_id: ${{ github.event.workflow_run.id }}
path: temp/vercel-preview
- name: Resolve PR number from workflow_run context
id: pr-meta
uses: actions/github-script@v8
with:
script: |
let pr = context.payload.workflow_run.pull_requests?.[0];
if (!pr) {
const { data: prs } = await github.rest.repos.listPullRequestsAssociatedWithCommit({
owner: context.repo.owner,
repo: context.repo.repo,
commit_sha: context.payload.workflow_run.head_sha,
});
pr = prs.find(p => p.state === 'open');
}
if (!pr) {
core.info('No open PR found for this workflow run — skipping.');
core.setOutput('skip', 'true');
return;
}
core.setOutput('skip', 'false');
core.setOutput('number', String(pr.number));
- name: Read preview URL
if: steps.pr-meta.outputs.skip != 'true'
id: meta
run: |
echo "url=$(cat temp/vercel-preview/url.txt)" >> "$GITHUB_OUTPUT"
- name: Write report
if: steps.pr-meta.outputs.skip != 'true'
run: |
echo "**Website Preview:** ${{ steps.meta.outputs.url }}" > preview-report.md
- name: Post PR comment
if: steps.pr-meta.outputs.skip != 'true'
uses: ./.github/actions/post-pr-report-comment
with:
pr-number: ${{ steps.pr-meta.outputs.number }}
report-file: ./preview-report.md
comment-marker: '<!-- VERCEL_WEBSITE_PREVIEW -->'
token: ${{ secrets.GITHUB_TOKEN }}

View File

@@ -318,9 +318,6 @@ When referencing Comfy-Org repos:
- Find existing `!important` classes that are interfering with the styling and propose corrections of those instead.
- NEVER use arbitrary percentage values like `w-[80%]` when a Tailwind fraction utility exists
- Use `w-4/5` instead of `w-[80%]`, `w-1/2` instead of `w-[50%]`, etc.
- NEVER use font-size classes (`text-xs`, `text-sm`, etc.) to size `icon-[...]` (iconify) icons
- Iconify icons size via `width`/`height: 1.2em`, so font-size produces unpredictable results
- Use `size-*` classes for explicit sizing, or set font-size on the **parent** container and let `1.2em` scale naturally
## Agent-only rules

View File

@@ -1,9 +1,15 @@
<script setup lang="ts">
const features = [
{ icon: '📚', label: 'Guided Tutorials' },
{ icon: '🎥', label: 'Video Courses' },
{ icon: '🛠️', label: 'Hands-on Projects' }
]
import { computed } from 'vue'
import type { Locale } from '../i18n/translations'
import { t } from '../i18n/translations'
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
const features = computed(() => [
{ icon: '📚', label: t('academy.tutorials', locale) },
{ icon: '🎥', label: t('academy.videos', locale) },
{ icon: '🛠️', label: t('academy.projects', locale) }
])
</script>
<template>
@@ -13,14 +19,15 @@ const features = [
<span
class="inline-block rounded-full bg-brand-yellow/10 px-4 py-1.5 text-xs uppercase tracking-widest text-brand-yellow"
>
COMFY ACADEMY
{{ t('academy.badge', locale) }}
</span>
<h2 class="mt-6 text-3xl font-bold text-white">Master AI Workflows</h2>
<h2 class="mt-6 text-3xl font-bold text-white">
{{ t('academy.heading', locale) }}
</h2>
<p class="mt-4 text-smoke-700">
Learn to build professional AI workflows with guided tutorials, video
courses, and hands-on projects.
{{ t('academy.body', locale) }}
</p>
<!-- Feature bullets -->
@@ -40,7 +47,7 @@ const features = [
href="/academy"
class="mt-8 inline-block rounded-full bg-brand-yellow px-8 py-3 text-sm font-semibold text-black transition-opacity hover:opacity-90"
>
EXPLORE ACADEMY
{{ t('academy.cta', locale) }}
</a>
</div>
</section>

View File

@@ -1,37 +1,43 @@
<script setup lang="ts">
const cards = [
import { computed } from 'vue'
import type { Locale } from '../i18n/translations'
import { t } from '../i18n/translations'
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
const cards = computed(() => [
{
icon: '🖥️',
title: 'Comfy Desktop',
description: 'Full power on your local machine. Free and open source.',
cta: 'DOWNLOAD',
title: t('cta.desktop.title', locale),
description: t('cta.desktop.desc', locale),
cta: t('cta.desktop.cta', locale),
href: '/download',
outlined: false
},
{
icon: '☁️',
title: 'Comfy Cloud',
description: 'Run workflows in the cloud. No GPU required.',
cta: 'TRY CLOUD',
title: t('cta.cloud.title', locale),
description: t('cta.cloud.desc', locale),
cta: t('cta.cloud.cta', locale),
href: 'https://app.comfy.org',
outlined: false
},
{
icon: '⚡',
title: 'Comfy API',
description: 'Integrate AI generation into your applications.',
cta: 'VIEW DOCS',
title: t('cta.api.title', locale),
description: t('cta.api.desc', locale),
cta: t('cta.api.cta', locale),
href: 'https://docs.comfy.org',
outlined: true
}
]
])
</script>
<template>
<section class="bg-charcoal-800 py-24">
<div class="mx-auto max-w-5xl px-6">
<h2 class="text-center text-3xl font-bold text-white">
Choose Your Way to Comfy
{{ t('cta.heading', locale) }}
</h2>
<!-- CTA cards -->

View File

@@ -1,30 +1,37 @@
<script setup lang="ts">
const steps = [
import { computed } from 'vue'
import type { Locale } from '../i18n/translations'
import { t } from '../i18n/translations'
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
const steps = computed(() => [
{
number: '1',
title: 'Download & Sign Up',
description: 'Get Comfy Desktop for free or create a Cloud account'
title: t('getStarted.step1.title', locale),
description: t('getStarted.step1.desc', locale)
},
{
number: '2',
title: 'Load a Workflow',
description:
'Choose from thousands of community workflows or build your own'
title: t('getStarted.step2.title', locale),
description: t('getStarted.step2.desc', locale)
},
{
number: '3',
title: 'Generate',
description: 'Hit run and watch your AI workflow come to life'
title: t('getStarted.step3.title', locale),
description: t('getStarted.step3.desc', locale)
}
]
])
</script>
<template>
<section class="border-t border-white/10 bg-black py-24">
<div class="mx-auto max-w-7xl px-6 text-center">
<h2 class="text-3xl font-bold text-white">Get Started in Minutes</h2>
<h2 class="text-3xl font-bold text-white">
{{ t('getStarted.heading', locale) }}
</h2>
<p class="mt-4 text-smoke-700">
From download to your first AI-generated output in three simple steps
{{ t('getStarted.subheading', locale) }}
</p>
<!-- Steps -->
@@ -55,7 +62,7 @@ const steps = [
href="/download"
class="mt-12 inline-block rounded-full bg-brand-yellow px-8 py-3 text-sm font-semibold text-black transition-opacity hover:opacity-90"
>
DOWNLOAD COMFY
{{ t('getStarted.cta', locale) }}
</a>
</div>
</section>

View File

@@ -1,16 +1,23 @@
<script setup lang="ts">
const ctaButtons = [
import { computed } from 'vue'
import type { Locale } from '../i18n/translations'
import { t } from '../i18n/translations'
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
const ctaButtons = computed(() => [
{
label: 'GET STARTED',
label: t('hero.cta.getStarted', locale),
href: 'https://app.comfy.org',
variant: 'solid' as const
},
{
label: 'LEARN MORE',
label: t('hero.cta.learnMore', locale),
href: '/about',
variant: 'outline' as const
}
]
])
</script>
<template>
@@ -39,12 +46,11 @@ const ctaButtons = [
<h1
class="text-5xl font-bold leading-tight tracking-tight text-white md:text-6xl lg:text-7xl"
>
Professional Control of Visual AI
{{ t('hero.headline', locale) }}
</h1>
<p class="mt-6 max-w-lg text-lg text-smoke-700">
Comfy is the AI creation engine for visual professionals who demand
control over every model, every parameter, and every output.
{{ t('hero.subheadline', locale) }}
</p>
<div class="mt-8 flex flex-wrap gap-4">

View File

@@ -1,3 +1,10 @@
<script setup lang="ts">
import type { Locale } from '../i18n/translations'
import { t } from '../i18n/translations'
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
</script>
<template>
<section class="bg-black py-24">
<div class="mx-auto max-w-4xl px-6 text-center">
@@ -7,13 +14,11 @@
</span>
<h2 class="text-4xl font-bold text-white md:text-5xl">
Method, Not Magic
{{ t('manifesto.heading', locale) }}
</h2>
<p class="mx-auto mt-6 max-w-2xl text-lg leading-relaxed text-smoke-700">
We believe in giving creators real control over AI. Not black boxes. Not
magic buttons. But transparent, reproducible, node-by-node control over
every step of the creative process.
{{ t('manifesto.body', locale) }}
</p>
<!-- Separator line -->

View File

@@ -1,6 +1,16 @@
<!-- TODO: Replace with actual workflow demo content -->
<script setup lang="ts">
const features = ['Node-Based Editor', 'Real-Time Preview', 'Version Control']
import { computed } from 'vue'
import type { Locale } from '../i18n/translations'
import { t } from '../i18n/translations'
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
const features = computed(() => [
t('showcase.nodeEditor', locale),
t('showcase.realTimePreview', locale),
t('showcase.versionControl', locale)
])
</script>
<template>
@@ -8,9 +18,11 @@ const features = ['Node-Based Editor', 'Real-Time Preview', 'Version Control']
<div class="mx-auto max-w-7xl px-6">
<!-- Section header -->
<div class="text-center">
<h2 class="text-3xl font-bold text-white">See Comfy in Action</h2>
<h2 class="text-3xl font-bold text-white">
{{ t('showcase.heading', locale) }}
</h2>
<p class="mx-auto mt-4 max-w-2xl text-smoke-700">
Watch how professionals build AI workflows with unprecedented control
{{ t('showcase.subheading', locale) }}
</p>
</div>
@@ -28,7 +40,9 @@ const features = ['Node-Based Editor', 'Real-Time Preview', 'Version Control']
class="ml-1 h-0 w-0 border-y-8 border-l-[14px] border-y-transparent border-l-white"
/>
</div>
<p class="text-sm text-smoke-700">Workflow Demo Coming Soon</p>
<p class="text-sm text-smoke-700">
{{ t('showcase.placeholder', locale) }}
</p>
</div>
</div>

View File

@@ -1,39 +1,73 @@
<script setup lang="ts">
const columns = [
import { computed } from 'vue'
import type { Locale } from '../i18n/translations'
import { localePath, t } from '../i18n/translations'
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
const columns = computed(() => [
{
title: 'Product',
title: t('footer.product', locale),
links: [
{ label: 'Comfy Desktop', href: '/download' },
{ label: 'Comfy Cloud', href: 'https://app.comfy.org' },
{ label: 'ComfyHub', href: 'https://hub.comfy.org' },
{ label: 'Pricing', href: '/pricing' }
{
label: t('footer.comfyDesktop', locale),
href: localePath('/download', locale)
},
{ label: t('footer.comfyCloud', locale), href: 'https://app.comfy.org' },
{ label: t('footer.comfyHub', locale), href: 'https://hub.comfy.org' },
{
label: t('footer.pricing', locale),
href: localePath('/pricing', locale)
}
]
},
{
title: 'Resources',
title: t('footer.resources', locale),
links: [
{ label: 'Documentation', href: 'https://docs.comfy.org' },
{ label: 'Blog', href: 'https://blog.comfy.org' },
{ label: 'Gallery', href: '/gallery' },
{ label: 'GitHub', href: 'https://github.com/comfyanonymous/ComfyUI' }
{
label: t('footer.documentation', locale),
href: 'https://docs.comfy.org'
},
{ label: t('footer.blog', locale), href: 'https://blog.comfy.org' },
{
label: t('footer.gallery', locale),
href: localePath('/gallery', locale)
},
{
label: t('footer.github', locale),
href: 'https://github.com/comfyanonymous/ComfyUI'
}
]
},
{
title: 'Company',
title: t('footer.company', locale),
links: [
{ label: 'About', href: '/about' },
{ label: 'Careers', href: '/careers' },
{ label: 'Enterprise', href: '/enterprise' }
{ label: t('footer.about', locale), href: localePath('/about', locale) },
{
label: t('footer.careers', locale),
href: localePath('/careers', locale)
},
{
label: t('footer.enterprise', locale),
href: localePath('/enterprise', locale)
}
]
},
{
title: 'Legal',
title: t('footer.legal', locale),
links: [
{ label: 'Terms of Service', href: '/terms-of-service' },
{ label: 'Privacy Policy', href: '/privacy-policy' }
{
label: t('footer.terms', locale),
href: localePath('/terms-of-service', locale)
},
{
label: t('footer.privacy', locale),
href: localePath('/privacy-policy', locale)
}
]
}
]
])
const socials = [
{
@@ -76,11 +110,16 @@ const socials = [
>
<!-- Brand -->
<div class="lg:col-span-1">
<a href="/" class="text-2xl font-bold text-brand-yellow italic">
<!-- eslint-disable @intlify/vue-i18n/no-raw-text -->
<a
:href="localePath('/', locale)"
class="text-2xl font-bold text-brand-yellow italic"
>
Comfy
</a>
<!-- eslint-enable @intlify/vue-i18n/no-raw-text -->
<p class="mt-4 text-sm text-smoke-700">
Professional control of visual AI.
{{ t('footer.tagline', locale) }}
</p>
</div>
@@ -113,7 +152,8 @@ const socials = [
class="mx-auto flex max-w-7xl flex-col items-center justify-between gap-4 p-6 sm:flex-row"
>
<p class="text-sm text-smoke-700">
&copy; {{ new Date().getFullYear() }} Comfy Org. All rights reserved.
&copy; {{ new Date().getFullYear() }}
{{ t('footer.copyright', locale) }}
</p>
<!-- Social icons -->

View File

@@ -1,15 +1,23 @@
<script setup lang="ts">
import { onMounted, onUnmounted, ref } from 'vue'
import { computed, onMounted, onUnmounted, ref } from 'vue'
import type { Locale } from '../i18n/translations'
import { localePath, t } from '../i18n/translations'
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
const mobileMenuOpen = ref(false)
const currentPath = ref('')
const navLinks = [
{ label: 'ENTERPRISE', href: '/enterprise' },
{ label: 'GALLERY', href: '/gallery' },
{ label: 'ABOUT', href: '/about' },
{ label: 'CAREERS', href: '/careers' }
]
const navLinks = computed(() => [
{
label: t('nav.enterprise', locale),
href: localePath('/enterprise', locale)
},
{ label: t('nav.gallery', locale), href: localePath('/gallery', locale) },
{ label: t('nav.about', locale), href: localePath('/about', locale) },
{ label: t('nav.careers', locale), href: localePath('/careers', locale) }
])
const ctaLinks = [
{
@@ -49,14 +57,19 @@ onUnmounted(() => {
<template>
<nav
class="fixed top-0 left-0 right-0 z-50 bg-black/80 backdrop-blur-md"
aria-label="Main navigation"
class="fixed inset-x-0 top-0 z-50 bg-black/80 backdrop-blur-md"
:aria-label="t('nav.ariaLabel', locale)"
>
<div class="mx-auto flex max-w-7xl items-center justify-between px-6 py-4">
<!-- Logo -->
<a href="/" class="text-2xl font-bold italic text-brand-yellow">
<!-- eslint-disable @intlify/vue-i18n/no-raw-text -->
<a
:href="localePath('/', locale)"
class="text-2xl font-bold text-brand-yellow italic"
>
Comfy
</a>
<!-- eslint-enable @intlify/vue-i18n/no-raw-text -->
<!-- Desktop nav links -->
<div class="hidden items-center gap-8 md:flex">
@@ -77,8 +90,8 @@ onUnmounted(() => {
:href="cta.href"
:class="
cta.primary
? 'bg-brand-yellow text-black hover:opacity-90 transition-opacity'
: 'border border-brand-yellow text-brand-yellow hover:bg-brand-yellow hover:text-black transition-colors'
? 'bg-brand-yellow text-black transition-opacity hover:opacity-90'
: 'border border-brand-yellow text-brand-yellow transition-colors hover:bg-brand-yellow hover:text-black'
"
class="rounded-full px-5 py-2 text-sm font-semibold"
>
@@ -90,7 +103,7 @@ onUnmounted(() => {
<!-- Mobile hamburger -->
<button
class="flex flex-col gap-1.5 md:hidden"
aria-label="Toggle menu"
:aria-label="t('nav.toggleMenu', locale)"
aria-controls="site-mobile-menu"
:aria-expanded="mobileMenuOpen"
@click="mobileMenuOpen = !mobileMenuOpen"
@@ -135,8 +148,8 @@ onUnmounted(() => {
:href="cta.href"
:class="
cta.primary
? 'bg-brand-yellow text-black hover:opacity-90 transition-opacity'
: 'border border-brand-yellow text-brand-yellow hover:bg-brand-yellow hover:text-black transition-colors'
? 'bg-brand-yellow text-black transition-opacity hover:opacity-90'
: 'border border-brand-yellow text-brand-yellow transition-colors hover:bg-brand-yellow hover:text-black'
"
class="rounded-full px-5 py-2 text-center text-sm font-semibold"
>

View File

@@ -1,4 +1,10 @@
<script setup lang="ts">
import { computed } from 'vue'
import type { Locale } from '../i18n/translations'
import { t } from '../i18n/translations'
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
const logos = [
'Harman',
'Tencent',
@@ -14,11 +20,11 @@ const logos = [
'EA'
]
const metrics = [
{ value: '60K+', label: 'Custom Nodes' },
{ value: '106K+', label: 'GitHub Stars' },
{ value: '500K+', label: 'Community Members' }
]
const metrics = computed(() => [
{ value: '60K+', label: t('social.customNodes', locale) },
{ value: '106K+', label: t('social.githubStars', locale) },
{ value: '500K+', label: t('social.communityMembers', locale) }
])
</script>
<template>
@@ -28,7 +34,7 @@ const metrics = [
<p
class="text-center text-xs font-medium uppercase tracking-widest text-smoke-700"
>
Trusted by Industry Leaders
{{ t('social.heading', locale) }}
</p>
<!-- Logo row -->

View File

@@ -1,9 +1,28 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
const activeFilter = ref('All')
import type { Locale } from '../i18n/translations'
import { t } from '../i18n/translations'
const industries = ['All', 'VFX', 'Gaming', 'Advertising', 'Photography']
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
const industryKeys = [
'All',
'VFX',
'Gaming',
'Advertising',
'Photography'
] as const
const industryLabels = computed(() => ({
All: t('testimonials.all', locale),
VFX: t('testimonials.vfx', locale),
Gaming: t('testimonials.gaming', locale),
Advertising: t('testimonials.advertising', locale),
Photography: t('testimonials.photography', locale)
}))
const activeFilter = ref<(typeof industryKeys)[number]>('All')
const testimonials = [
{
@@ -12,7 +31,7 @@ const testimonials = [
name: 'Sarah Chen',
title: 'Lead Technical Artist',
company: 'Studio Alpha',
industry: 'VFX'
industry: 'VFX' as const
},
{
quote:
@@ -20,7 +39,7 @@ const testimonials = [
name: 'Marcus Rivera',
title: 'Creative Director',
company: 'PixelForge',
industry: 'Gaming'
industry: 'Gaming' as const
},
{
quote:
@@ -28,7 +47,7 @@ const testimonials = [
name: 'Yuki Tanaka',
title: 'Head of AI',
company: 'CreativeX',
industry: 'Advertising'
industry: 'Advertising' as const
}
]
@@ -42,13 +61,13 @@ const filteredTestimonials = computed(() => {
<section class="bg-black py-24">
<div class="mx-auto max-w-7xl px-6">
<h2 class="text-center text-3xl font-bold text-white">
What Professionals Say
{{ t('testimonials.heading', locale) }}
</h2>
<!-- Industry filter pills -->
<div class="mt-8 flex flex-wrap items-center justify-center gap-3">
<button
v-for="industry in industries"
v-for="industry in industryKeys"
:key="industry"
type="button"
:aria-pressed="activeFilter === industry"
@@ -60,7 +79,7 @@ const filteredTestimonials = computed(() => {
"
@click="activeFilter = industry"
>
{{ industry }}
{{ industryLabels[industry] }}
</button>
</div>
@@ -85,7 +104,7 @@ const filteredTestimonials = computed(() => {
<span
class="mt-3 inline-block rounded-full bg-white/5 px-2 py-0.5 text-xs text-smoke-700"
>
{{ testimonial.industry }}
{{ industryLabels[testimonial.industry] ?? testimonial.industry }}
</span>
</article>
</div>

View File

@@ -1,14 +1,18 @@
<!-- TODO: Wire category content swap when final assets arrive -->
<script setup lang="ts">
import { ref } from 'vue'
import { computed, ref } from 'vue'
import type { Locale } from '../i18n/translations'
import { t } from '../i18n/translations'
const categories = [
'VFX & Animation',
'Creative Agencies',
'Gaming',
'eCommerce & Fashion',
'Community & Hobbyists'
]
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
const categories = computed(() => [
t('useCase.vfx', locale),
t('useCase.agencies', locale),
t('useCase.gaming', locale),
t('useCase.ecommerce', locale),
t('useCase.community', locale)
])
const activeCategory = ref(0)
</script>
@@ -27,7 +31,7 @@ const activeCategory = ref(0)
<!-- Center content -->
<div class="flex flex-col items-center text-center lg:flex-[2]">
<h2 class="text-3xl font-bold text-white">
Built for Every Creative Industry
{{ t('useCase.heading', locale) }}
</h2>
<nav
@@ -52,15 +56,14 @@ const activeCategory = ref(0)
</nav>
<p class="mt-10 max-w-lg text-smoke-700">
Powered by 60,000+ nodes, thousands of workflows, and a community
that builds faster than any one company could.
{{ t('useCase.body', locale) }}
</p>
<a
href="/workflows"
class="mt-8 rounded-full border border-brand-yellow px-8 py-3 text-sm font-semibold text-brand-yellow transition-colors hover:bg-brand-yellow hover:text-black"
>
EXPLORE WORKFLOWS
{{ t('useCase.cta', locale) }}
</a>
</div>

View File

@@ -1,34 +1,37 @@
<script setup lang="ts">
const pillars = [
import { computed } from 'vue'
import type { Locale } from '../i18n/translations'
import { t } from '../i18n/translations'
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
const pillars = computed(() => [
{
icon: '⚡',
title: 'Build',
description:
'Design complex AI workflows visually with our node-based editor'
title: t('pillars.buildTitle', locale),
description: t('pillars.buildDesc', locale)
},
{
icon: '🎨',
title: 'Customize',
description: 'Fine-tune every parameter across any model architecture'
title: t('pillars.customizeTitle', locale),
description: t('pillars.customizeDesc', locale)
},
{
icon: '🔧',
title: 'Refine',
description:
'Iterate on outputs with precision controls and real-time preview'
title: t('pillars.refineTitle', locale),
description: t('pillars.refineDesc', locale)
},
{
icon: '⚙️',
title: 'Automate',
description:
'Scale your workflows with batch processing and API integration'
title: t('pillars.automateTitle', locale),
description: t('pillars.automateDesc', locale)
},
{
icon: '🚀',
title: 'Run',
description: 'Deploy locally or in the cloud with identical results'
title: t('pillars.runTitle', locale),
description: t('pillars.runDesc', locale)
}
]
])
</script>
<template>
@@ -36,10 +39,10 @@ const pillars = [
<div class="mx-auto max-w-7xl">
<header class="mb-16 text-center">
<h2 class="text-3xl font-bold text-white md:text-4xl">
The Building Blocks of AI Production
{{ t('pillars.heading', locale) }}
</h2>
<p class="mt-4 text-smoke-700">
Five powerful capabilities that give you complete control
{{ t('pillars.subheading', locale) }}
</p>
</header>

View File

@@ -0,0 +1,253 @@
type Locale = 'en' | 'zh-CN'
const translations = {
// HeroSection
'hero.headline': {
en: 'Professional Control of Visual AI',
'zh-CN': '视觉 AI 的专业控制'
},
'hero.subheadline': {
en: 'Comfy is the AI creation engine for visual professionals who demand control over every model, every parameter, and every output.',
'zh-CN':
'Comfy 是面向视觉专业人士的 AI 创作引擎,让您掌控每个模型、每个参数和每个输出。'
},
'hero.cta.getStarted': { en: 'GET STARTED', 'zh-CN': '立即开始' },
'hero.cta.learnMore': { en: 'LEARN MORE', 'zh-CN': '了解更多' },
// SocialProofBar
'social.heading': {
en: 'Trusted by Industry Leaders',
'zh-CN': '受到行业领导者的信赖'
},
'social.customNodes': { en: 'Custom Nodes', 'zh-CN': '自定义节点' },
'social.githubStars': { en: 'GitHub Stars', 'zh-CN': 'GitHub 星标' },
'social.communityMembers': {
en: 'Community Members',
'zh-CN': '社区成员'
},
// ProductShowcase
'showcase.heading': { en: 'See Comfy in Action', 'zh-CN': '观看 Comfy 实战' },
'showcase.subheading': {
en: 'Watch how professionals build AI workflows with unprecedented control',
'zh-CN': '观看专业人士如何以前所未有的控制力构建 AI 工作流'
},
'showcase.placeholder': {
en: 'Workflow Demo Coming Soon',
'zh-CN': '工作流演示即将推出'
},
'showcase.nodeEditor': { en: 'Node-Based Editor', 'zh-CN': '节点编辑器' },
'showcase.realTimePreview': {
en: 'Real-Time Preview',
'zh-CN': '实时预览'
},
'showcase.versionControl': {
en: 'Version Control',
'zh-CN': '版本控制'
},
// ValuePillars
'pillars.heading': {
en: 'The Building Blocks of AI Production',
'zh-CN': 'AI 制作的基本要素'
},
'pillars.subheading': {
en: 'Five powerful capabilities that give you complete control',
'zh-CN': '五大强大功能,让您完全掌控'
},
'pillars.buildTitle': { en: 'Build', 'zh-CN': '构建' },
'pillars.buildDesc': {
en: 'Design complex AI workflows visually with our node-based editor',
'zh-CN': '使用节点编辑器直观地设计复杂的 AI 工作流'
},
'pillars.customizeTitle': { en: 'Customize', 'zh-CN': '自定义' },
'pillars.customizeDesc': {
en: 'Fine-tune every parameter across any model architecture',
'zh-CN': '在任何模型架构中微调每个参数'
},
'pillars.refineTitle': { en: 'Refine', 'zh-CN': '优化' },
'pillars.refineDesc': {
en: 'Iterate on outputs with precision controls and real-time preview',
'zh-CN': '通过精确控制和实时预览迭代输出'
},
'pillars.automateTitle': { en: 'Automate', 'zh-CN': '自动化' },
'pillars.automateDesc': {
en: 'Scale your workflows with batch processing and API integration',
'zh-CN': '通过批处理和 API 集成扩展工作流'
},
'pillars.runTitle': { en: 'Run', 'zh-CN': '运行' },
'pillars.runDesc': {
en: 'Deploy locally or in the cloud with identical results',
'zh-CN': '在本地或云端部署,获得相同的结果'
},
// UseCaseSection
'useCase.heading': {
en: 'Built for Every Creative Industry',
'zh-CN': '为每个创意行业而生'
},
'useCase.vfx': { en: 'VFX & Animation', 'zh-CN': '视觉特效与动画' },
'useCase.agencies': { en: 'Creative Agencies', 'zh-CN': '创意机构' },
'useCase.gaming': { en: 'Gaming', 'zh-CN': '游戏' },
'useCase.ecommerce': {
en: 'eCommerce & Fashion',
'zh-CN': '电商与时尚'
},
'useCase.community': {
en: 'Community & Hobbyists',
'zh-CN': '社区与爱好者'
},
'useCase.body': {
en: 'Powered by 60,000+ nodes, thousands of workflows, and a community that builds faster than any one company could.',
'zh-CN':
'由 60,000+ 节点、数千个工作流和一个比任何公司都更快构建的社区驱动。'
},
'useCase.cta': { en: 'EXPLORE WORKFLOWS', 'zh-CN': '探索工作流' },
// CaseStudySpotlight
'caseStudy.heading': { en: 'Customer Stories', 'zh-CN': '客户故事' },
'caseStudy.subheading': {
en: 'See how leading studios use Comfy in production',
'zh-CN': '了解领先工作室如何在生产中使用 Comfy'
},
'caseStudy.readMore': { en: 'READ CASE STUDY', 'zh-CN': '阅读案例' },
// TestimonialsSection
'testimonials.heading': {
en: 'What Professionals Say',
'zh-CN': '专业人士的评价'
},
'testimonials.all': { en: 'All', 'zh-CN': '全部' },
'testimonials.vfx': { en: 'VFX', 'zh-CN': '特效' },
'testimonials.gaming': { en: 'Gaming', 'zh-CN': '游戏' },
'testimonials.advertising': { en: 'Advertising', 'zh-CN': '广告' },
'testimonials.photography': { en: 'Photography', 'zh-CN': '摄影' },
// GetStartedSection
'getStarted.heading': {
en: 'Get Started in Minutes',
'zh-CN': '几分钟即可开始'
},
'getStarted.subheading': {
en: 'From download to your first AI-generated output in three simple steps',
'zh-CN': '从下载到首次 AI 生成输出,只需三个简单步骤'
},
'getStarted.step1.title': {
en: 'Download & Sign Up',
'zh-CN': '下载与注册'
},
'getStarted.step1.desc': {
en: 'Get Comfy Desktop for free or create a Cloud account',
'zh-CN': '免费获取 Comfy Desktop 或创建云端账号'
},
'getStarted.step2.title': {
en: 'Load a Workflow',
'zh-CN': '加载工作流'
},
'getStarted.step2.desc': {
en: 'Choose from thousands of community workflows or build your own',
'zh-CN': '从数千个社区工作流中选择,或自行构建'
},
'getStarted.step3.title': { en: 'Generate', 'zh-CN': '生成' },
'getStarted.step3.desc': {
en: 'Hit run and watch your AI workflow come to life',
'zh-CN': '点击运行,观看 AI 工作流生动呈现'
},
'getStarted.cta': { en: 'DOWNLOAD COMFY', 'zh-CN': '下载 COMFY' },
// CTASection
'cta.heading': {
en: 'Choose Your Way to Comfy',
'zh-CN': '选择您的 Comfy 方式'
},
'cta.desktop.title': { en: 'Comfy Desktop', 'zh-CN': 'Comfy Desktop' },
'cta.desktop.desc': {
en: 'Full power on your local machine. Free and open source.',
'zh-CN': '在本地机器上释放全部性能。免费开源。'
},
'cta.desktop.cta': { en: 'DOWNLOAD', 'zh-CN': '下载' },
'cta.cloud.title': { en: 'Comfy Cloud', 'zh-CN': 'Comfy Cloud' },
'cta.cloud.desc': {
en: 'Run workflows in the cloud. No GPU required.',
'zh-CN': '在云端运行工作流,无需 GPU。'
},
'cta.cloud.cta': { en: 'TRY CLOUD', 'zh-CN': '试用云端' },
'cta.api.title': { en: 'Comfy API', 'zh-CN': 'Comfy API' },
'cta.api.desc': {
en: 'Integrate AI generation into your applications.',
'zh-CN': '将 AI 生成功能集成到您的应用程序中。'
},
'cta.api.cta': { en: 'VIEW DOCS', 'zh-CN': '查看文档' },
// ManifestoSection
'manifesto.heading': { en: 'Method, Not Magic', 'zh-CN': '方法,而非魔法' },
'manifesto.body': {
en: 'We believe in giving creators real control over AI. Not black boxes. Not magic buttons. But transparent, reproducible, node-by-node control over every step of the creative process.',
'zh-CN':
'我们相信应赋予创作者对 AI 的真正控制权。没有黑箱,没有魔法按钮,而是对创作过程每一步的透明、可复现、逐节点控制。'
},
// AcademySection
'academy.badge': { en: 'COMFY ACADEMY', 'zh-CN': 'COMFY 学院' },
'academy.heading': {
en: 'Master AI Workflows',
'zh-CN': '掌握 AI 工作流'
},
'academy.body': {
en: 'Learn to build professional AI workflows with guided tutorials, video courses, and hands-on projects.',
'zh-CN': '通过指导教程、视频课程和实践项目,学习构建专业的 AI 工作流。'
},
'academy.tutorials': { en: 'Guided Tutorials', 'zh-CN': '指导教程' },
'academy.videos': { en: 'Video Courses', 'zh-CN': '视频课程' },
'academy.projects': { en: 'Hands-on Projects', 'zh-CN': '实践项目' },
'academy.cta': { en: 'EXPLORE ACADEMY', 'zh-CN': '探索学院' },
// SiteNav
'nav.ariaLabel': { en: 'Main navigation', 'zh-CN': '主导航' },
'nav.toggleMenu': { en: 'Toggle menu', 'zh-CN': '切换菜单' },
'nav.enterprise': { en: 'ENTERPRISE', 'zh-CN': '企业版' },
'nav.gallery': { en: 'GALLERY', 'zh-CN': '画廊' },
'nav.about': { en: 'ABOUT', 'zh-CN': '关于' },
'nav.careers': { en: 'CAREERS', 'zh-CN': '招聘' },
'nav.cloud': { en: 'COMFY CLOUD', 'zh-CN': 'COMFY 云端' },
'nav.hub': { en: 'COMFY HUB', 'zh-CN': 'COMFY HUB' },
// SiteFooter
'footer.tagline': {
en: 'Professional control of visual AI.',
'zh-CN': '视觉 AI 的专业控制。'
},
'footer.product': { en: 'Product', 'zh-CN': '产品' },
'footer.resources': { en: 'Resources', 'zh-CN': '资源' },
'footer.company': { en: 'Company', 'zh-CN': '公司' },
'footer.legal': { en: 'Legal', 'zh-CN': '法律' },
'footer.copyright': {
en: 'Comfy Org. All rights reserved.',
'zh-CN': 'Comfy Org. 保留所有权利。'
},
'footer.comfyDesktop': { en: 'Comfy Desktop', 'zh-CN': 'Comfy Desktop' },
'footer.comfyCloud': { en: 'Comfy Cloud', 'zh-CN': 'Comfy Cloud' },
'footer.comfyHub': { en: 'ComfyHub', 'zh-CN': 'ComfyHub' },
'footer.pricing': { en: 'Pricing', 'zh-CN': '价格' },
'footer.documentation': { en: 'Documentation', 'zh-CN': '文档' },
'footer.blog': { en: 'Blog', 'zh-CN': '博客' },
'footer.gallery': { en: 'Gallery', 'zh-CN': '画廊' },
'footer.github': { en: 'GitHub', 'zh-CN': 'GitHub' },
'footer.about': { en: 'About', 'zh-CN': '关于' },
'footer.careers': { en: 'Careers', 'zh-CN': '招聘' },
'footer.enterprise': { en: 'Enterprise', 'zh-CN': '企业版' },
'footer.terms': { en: 'Terms of Service', 'zh-CN': '服务条款' },
'footer.privacy': { en: 'Privacy Policy', 'zh-CN': '隐私政策' }
} as const satisfies Record<string, Record<Locale, string>>
type TranslationKey = keyof typeof translations
export function t(key: TranslationKey, locale: Locale = 'en'): string {
return translations[key][locale] ?? translations[key].en
}
export function localePath(path: string, locale: Locale): string {
return locale === 'en' ? path : `/${locale}${path}`
}
export type { Locale }

View File

@@ -4,89 +4,89 @@ import SiteNav from '../../components/SiteNav.vue'
import SiteFooter from '../../components/SiteFooter.vue'
const team = [
{ name: 'comfyanonymous', role: 'Creator of ComfyUI, cofounder' },
{ name: 'Dr.Lt.Data', role: 'Creator of ComfyUI-Manager and Impact/Inspire Pack' },
{ name: 'pythongosssss', role: 'Major contributor, creator of ComfyUI-Custom-Scripts' },
{ name: 'yoland68', role: 'Creator of ComfyCLI, cofounder, ex-Google' },
{ name: 'robinjhuang', role: 'Maintains Comfy Registry, cofounder, ex-Google Cloud' },
{ name: 'jojodecay', role: 'ComfyUI event series host, community & partnerships' },
{ name: 'christian-byrne', role: 'Fullstack developer' },
{ name: 'Kosinkadink', role: 'Creator of AnimateDiff-Evolved and Advanced-ControlNet' },
{ name: 'webfiltered', role: 'Overhauled Litegraph library' },
{ name: 'Pablo', role: 'Product Design, ex-AI startup founder' },
{ name: 'ComfyUI Wiki (Daxiong)', role: 'Official docs and templates' },
{ name: 'ctrlbenlu (Ben)', role: 'Software engineer, ex-robotics' },
{ name: 'Purz Beats', role: 'Motion graphics designer and ML Engineer' },
{ name: 'Ricyu (Rich)', role: 'Software engineer, ex-Meta' },
{ name: 'comfyanonymous', role: 'ComfyUI 创始人、联合创始人' },
{ name: 'Dr.Lt.Data', role: 'ComfyUI-Manager Impact/Inspire Pack 作者' },
{ name: 'pythongosssss', role: '核心贡献者、ComfyUI-Custom-Scripts 作者' },
{ name: 'yoland68', role: 'ComfyCLI 作者、联合创始人、前 Google' },
{ name: 'robinjhuang', role: 'Comfy Registry 维护者、联合创始人、前 Google Cloud' },
{ name: 'jojodecay', role: 'ComfyUI 活动主持人、社区与合作关系' },
{ name: 'christian-byrne', role: '全栈开发工程师' },
{ name: 'Kosinkadink', role: 'AnimateDiff-Evolved Advanced-ControlNet 作者' },
{ name: 'webfiltered', role: 'Litegraph 库重构者' },
{ name: 'Pablo', role: '产品设计、前 AI 初创公司创始人' },
{ name: 'ComfyUI Wiki (Daxiong)', role: '官方文档和模板' },
{ name: 'ctrlbenlu (Ben)', role: '软件工程师、前机器人领域' },
{ name: 'Purz Beats', role: '动效设计师和机器学习工程师' },
{ name: 'Ricyu (Rich)', role: '软件工程师、前 Meta' },
]
const collaborators = [
{ name: 'Yogo', role: 'Collaborator' },
{ name: 'Fill (Machine Delusions)', role: 'Collaborator' },
{ name: 'Julien (MJM)', role: 'Collaborator' },
{ name: 'Yogo', role: '协作者' },
{ name: 'Fill (Machine Delusions)', role: '协作者' },
{ name: 'Julien (MJM)', role: '协作者' },
]
const projects = [
{ name: 'ComfyUI', description: 'The core node-based interface for generative AI workflows.' },
{ name: 'ComfyUI Manager', description: 'Install, update, and manage custom nodes with one click.' },
{ name: 'Comfy Registry', description: 'The official registry for publishing and discovering custom nodes.' },
{ name: 'Frontends', description: 'The desktop and web frontends that power the ComfyUI experience.' },
{ name: 'Docs', description: 'Official documentation, guides, and tutorials.' },
{ name: 'ComfyUI', description: '生成式 AI 工作流的核心节点式界面。' },
{ name: 'ComfyUI Manager', description: '一键安装、更新和管理自定义节点。' },
{ name: 'Comfy Registry', description: '发布和发现自定义节点的官方注册表。' },
{ name: 'Frontends', description: '驱动 ComfyUI 体验的桌面端和 Web 前端。' },
{ name: 'Docs', description: '官方文档、指南和教程。' },
]
const faqs = [
{
q: 'Is ComfyUI free?',
a: 'Yes. ComfyUI is free and open-source under the GPL-3.0 license. You can use it for personal and commercial projects.',
q: 'ComfyUI 免费吗?',
a: '是的。ComfyUI 是免费开源的,基于 GPL-3.0 许可证。您可以将其用于个人和商业项目。',
},
{
q: 'Who is behind ComfyUI?',
a: 'ComfyUI was created by comfyanonymous and is maintained by a small, dedicated team of developers and community contributors.',
q: '谁在开发 ComfyUI',
a: 'ComfyUI comfyanonymous 创建,由一个小而专注的开发团队和社区贡献者共同维护。',
},
{
q: 'How can I contribute?',
a: 'Check out our GitHub repositories to report issues, submit pull requests, or build custom nodes. Join our Discord community to connect with other contributors.',
q: '如何参与贡献?',
a: '查看我们的 GitHub 仓库来报告问题、提交 Pull Request 或构建自定义节点。加入我们的 Discord 社区与其他贡献者交流。',
},
{
q: 'What are the future plans?',
a: 'We are focused on making ComfyUI the operating system for generative AI — improving performance, expanding model support, and building better tools for creators and developers.',
q: '未来有什么计划?',
a: '我们专注于让 ComfyUI 成为生成式 AI 的操作系统——提升性能、扩展模型支持,为创作者和开发者打造更好的工具。',
},
]
---
<BaseLayout title="关于我们 — Comfy" description="Learn about the team and mission behind ComfyUI, the open-source generative AI platform.">
<SiteNav client:load />
<BaseLayout title="关于我们 — Comfy" description="了解 ComfyUI 背后的团队和使命——开源的生成式 AI 平台。">
<SiteNav locale="zh-CN" client:load />
<main>
<!-- Hero -->
<!-- 主页横幅 -->
<section class="px-6 pb-24 pt-40 text-center">
<h1 class="mx-auto max-w-4xl text-4xl font-bold leading-tight md:text-6xl">
Crafting the next frontier of visual and audio media
开创视觉与音频媒体的下一个前沿
</h1>
<p class="mx-auto mt-6 max-w-2xl text-lg text-smoke-700">
An open-source community and company building the most powerful tools for generative AI creators.
一个开源社区和公司,致力于为生成式 AI 创作者打造最强大的工具。
</p>
</section>
<!-- Our Mission -->
<!-- 我们的使命 -->
<section class="bg-charcoal-800 px-6 py-24">
<div class="mx-auto max-w-3xl text-center">
<h2 class="text-sm font-semibold uppercase tracking-widest text-brand-yellow">Our Mission</h2>
<h2 class="text-sm font-semibold uppercase tracking-widest text-brand-yellow">我们的使命</h2>
<p class="mt-6 text-3xl font-bold md:text-4xl">
We want to build the operating system for Gen AI.
我们想打造生成式 AI 的操作系统。
</p>
<p class="mt-6 text-lg leading-relaxed text-smoke-700">
We're building the foundational tools that give creators full control over generative AI.
From image and video synthesis to audio generation, ComfyUI provides a modular,
node-based environment where professionals and enthusiasts can craft, iterate,
and deploy production-quality workflows — without black boxes.
我们正在构建让创作者完全掌控生成式 AI 的基础工具。
从图像和视频合成到音频生成ComfyUI 提供了一个模块化的
节点式环境,让专业人士和爱好者可以创建、迭代
和部署生产级工作流——没有黑箱。
</p>
</div>
</section>
<!-- What Do We Do? -->
<!-- 我们做什么? -->
<section class="px-6 py-24">
<div class="mx-auto max-w-5xl">
<h2 class="text-center text-3xl font-bold md:text-4xl">What Do We Do?</h2>
<h2 class="text-center text-3xl font-bold md:text-4xl">我们做什么?</h2>
<div class="mt-12 grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
{projects.map((project) => (
<div class="rounded-xl border border-white/10 bg-charcoal-600 p-6">
@@ -98,24 +98,23 @@ const faqs = [
</div>
</section>
<!-- Who We Are -->
<!-- 我们是谁 -->
<section class="bg-charcoal-800 px-6 py-24">
<div class="mx-auto max-w-3xl text-center">
<h2 class="text-3xl font-bold md:text-4xl">Who We Are</h2>
<h2 class="text-3xl font-bold md:text-4xl">我们是谁</h2>
<p class="mt-6 text-lg leading-relaxed text-smoke-700">
ComfyUI started as a personal project by comfyanonymous and grew into a global community
of creators, developers, and researchers. Today, Comfy Org is a small, flat team based in
San Francisco, backed by investors who believe in open-source AI tooling. We work
alongside an incredible community of contributors who build custom nodes, share workflows,
and push the boundaries of what's possible with generative AI.
ComfyUI 最初是 comfyanonymous 的个人项目,后来发展成为一个全球性的
创作者、开发者和研究者社区。今天Comfy Org 是一个位于旧金山的小型扁平化团队,
由相信开源 AI 工具的投资者支持。我们与令人难以置信的贡献者社区一起工作,
他们构建自定义节点、分享工作流,并不断突破生成式 AI 的边界。
</p>
</div>
</section>
<!-- Team -->
<!-- 团队 -->
<section class="px-6 py-24">
<div class="mx-auto max-w-6xl">
<h2 class="text-center text-3xl font-bold md:text-4xl">Team</h2>
<h2 class="text-center text-3xl font-bold md:text-4xl">团队</h2>
<div class="mt-12 grid gap-6 grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4">
{team.map((member) => (
<div class="rounded-xl border border-white/10 p-5 text-center">
@@ -128,10 +127,10 @@ const faqs = [
</div>
</section>
<!-- Collaborators -->
<!-- 协作者 -->
<section class="bg-charcoal-800 px-6 py-16">
<div class="mx-auto max-w-4xl text-center">
<h2 class="text-2xl font-bold">Collaborators</h2>
<h2 class="text-2xl font-bold">协作者</h2>
<div class="mt-8 flex flex-wrap items-center justify-center gap-8">
{collaborators.map((person) => (
<div class="text-center">
@@ -143,10 +142,10 @@ const faqs = [
</div>
</section>
<!-- FAQs -->
<!-- 常见问题 -->
<section class="px-6 py-24">
<div class="mx-auto max-w-3xl">
<h2 class="text-center text-3xl font-bold md:text-4xl">FAQs</h2>
<h2 class="text-center text-3xl font-bold md:text-4xl">常见问题</h2>
<div class="mt-12 space-y-10">
{faqs.map((faq) => (
<div>
@@ -158,19 +157,19 @@ const faqs = [
</div>
</section>
<!-- Join Our Team CTA -->
<!-- 加入我们 CTA -->
<section class="bg-charcoal-800 px-6 py-24 text-center">
<h2 class="text-3xl font-bold md:text-4xl">Join Our Team</h2>
<h2 class="text-3xl font-bold md:text-4xl">加入我们的团队</h2>
<p class="mx-auto mt-4 max-w-xl text-smoke-700">
We're looking for people who are passionate about open-source, generative AI, and building great developer tools.
我们正在寻找热衷于开源、生成式 AI 和打造优秀开发者工具的人。
</p>
<a
href="/careers"
class="mt-8 inline-block rounded-full bg-brand-yellow px-8 py-3 text-sm font-semibold text-black transition-opacity hover:opacity-90"
>
View Open Positions
查看开放职位
</a>
</section>
</main>
<SiteFooter />
<SiteFooter locale="zh-CN" />
</BaseLayout>

View File

@@ -78,7 +78,7 @@ const questions = [
title="招聘 — Comfy"
description="加入构建生成式 AI 操作系统的团队。工程、设计、市场营销等岗位开放招聘中。"
>
<SiteNav client:load />
<SiteNav locale="zh-CN" client:load />
<main>
<!-- Hero -->
<section class="px-6 pb-24 pt-40">
@@ -196,5 +196,5 @@ const questions = [
</div>
</section>
</main>
<SiteFooter />
<SiteFooter locale="zh-CN" />
</BaseLayout>

View File

@@ -32,7 +32,7 @@ const cards = [
---
<BaseLayout title="下载 — Comfy">
<SiteNav client:load />
<SiteNav locale="zh-CN" client:load />
<main class="mx-auto max-w-5xl px-6 py-32 text-center">
<h1 class="text-4xl font-bold text-white md:text-5xl">
下载 ComfyUI
@@ -76,5 +76,5 @@ const cards = [
</p>
</div>
</main>
<SiteFooter />
<SiteFooter locale="zh-CN" />
</BaseLayout>

View File

@@ -5,7 +5,7 @@ import SiteFooter from '../../components/SiteFooter.vue'
---
<BaseLayout title="作品集 — Comfy">
<SiteNav client:load />
<SiteNav locale="zh-CN" client:load />
<main class="bg-black text-white">
<!-- Hero -->
<section class="mx-auto max-w-5xl px-6 pb-16 pt-32 text-center">
@@ -39,5 +39,5 @@ import SiteFooter from '../../components/SiteFooter.vue'
</a>
</section>
</main>
<SiteFooter />
<SiteFooter locale="zh-CN" />
</BaseLayout>

View File

@@ -16,19 +16,19 @@ import SiteFooter from '../../components/SiteFooter.vue'
---
<BaseLayout title="Comfy — 视觉 AI 的专业控制">
<SiteNav client:load />
<SiteNav locale="zh-CN" client:load />
<main>
<HeroSection />
<SocialProofBar />
<ProductShowcase />
<ValuePillars />
<UseCaseSection client:visible />
<CaseStudySpotlight />
<TestimonialsSection client:visible />
<GetStartedSection />
<CTASection />
<ManifestoSection />
<AcademySection />
<HeroSection locale="zh-CN" />
<SocialProofBar locale="zh-CN" />
<ProductShowcase locale="zh-CN" />
<ValuePillars locale="zh-CN" />
<UseCaseSection locale="zh-CN" client:visible />
<CaseStudySpotlight locale="zh-CN" />
<TestimonialsSection locale="zh-CN" client:visible />
<GetStartedSection locale="zh-CN" />
<CTASection locale="zh-CN" />
<ManifestoSection locale="zh-CN" />
<AcademySection locale="zh-CN" />
</main>
<SiteFooter />
<SiteFooter locale="zh-CN" />
</BaseLayout>

View File

@@ -9,7 +9,7 @@ import SiteFooter from '../../components/SiteFooter.vue'
description="Comfy 隐私政策。了解我们如何收集、使用和保护您的个人信息。"
noindex
>
<SiteNav client:load />
<SiteNav locale="zh-CN" client:load />
<main class="mx-auto max-w-3xl px-6 py-24">
<h1 class="text-3xl font-bold text-white">隐私政策</h1>
<p class="mt-2 text-sm text-smoke-500">生效日期2025年4月18日</p>
@@ -229,5 +229,5 @@ import SiteFooter from '../../components/SiteFooter.vue'
</p>
</section>
</main>
<SiteFooter />
<SiteFooter locale="zh-CN" />
</BaseLayout>

View File

@@ -9,7 +9,7 @@ import SiteFooter from '../../components/SiteFooter.vue'
description="ComfyUI 及相关 Comfy 服务的服务条款。"
noindex
>
<SiteNav client:load />
<SiteNav locale="zh-CN" client:load />
<main class="mx-auto max-w-3xl px-6 py-24 sm:py-32">
<header class="mb-16">
<h1 class="text-3xl font-bold text-white">服务条款</h1>
@@ -216,5 +216,5 @@ import SiteFooter from '../../components/SiteFooter.vue'
</div>
</section>
</main>
<SiteFooter />
<SiteFooter locale="zh-CN" />
</BaseLayout>

View File

@@ -4,9 +4,6 @@
"outputDirectory": "apps/website/dist",
"installCommand": "pnpm install --frozen-lockfile",
"framework": null,
"github": {
"enabled": false
},
"redirects": [
{
"source": "/pricing",

View File

@@ -1,34 +0,0 @@
{
"last_node_id": 10,
"last_link_id": 0,
"nodes": [
{
"id": 10,
"type": "LoadImage",
"pos": [50, 200],
"size": [315, 314],
"flags": {},
"order": 0,
"mode": 4,
"inputs": [],
"outputs": [
{ "name": "IMAGE", "type": "IMAGE", "links": null },
{ "name": "MASK", "type": "MASK", "links": null }
],
"properties": {
"Node name for S&R": "LoadImage"
},
"widgets_values": ["nonexistent_test_image_12345.png", "image"]
}
],
"links": [],
"groups": [],
"config": {},
"extra": {
"ds": {
"offset": [0, 0],
"scale": 1
}
},
"version": 0.4
}

View File

@@ -5,7 +5,7 @@
{
"id": 10,
"type": "LoadImage",
"pos": [50, 200],
"pos": [50, 50],
"size": [315, 314],
"flags": {},
"order": 0,
@@ -31,7 +31,7 @@
{
"id": 11,
"type": "LoadImage",
"pos": [450, 200],
"pos": [450, 50],
"size": [315, 314],
"flags": {},
"order": 1,

View File

@@ -5,7 +5,7 @@
{
"id": 10,
"type": "LoadImage",
"pos": [50, 200],
"pos": [50, 50],
"size": [315, 314],
"flags": {},
"order": 0,

View File

@@ -1,27 +1,7 @@
{
"last_node_id": 1,
"last_node_id": 0,
"last_link_id": 0,
"nodes": [
{
"id": 1,
"type": "CheckpointLoaderSimple",
"pos": [256, 256],
"size": [315, 98],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [
{ "name": "MODEL", "type": "MODEL", "links": null },
{ "name": "CLIP", "type": "CLIP", "links": null },
{ "name": "VAE", "type": "VAE", "links": null }
],
"properties": {
"Node name for S&R": "CheckpointLoaderSimple"
},
"widgets_values": ["fake_model.safetensors"]
}
],
"nodes": [],
"links": [],
"groups": [],
"config": {},
@@ -35,7 +15,7 @@
{
"name": "fake_model.safetensors",
"url": "http://localhost:8188/api/devtools/fake_model.safetensors",
"directory": "checkpoints"
"directory": "text_encoders"
}
],
"version": 0.4

View File

@@ -1,42 +0,0 @@
{
"last_node_id": 1,
"last_link_id": 0,
"nodes": [
{
"id": 1,
"type": "CheckpointLoaderSimple",
"pos": [256, 256],
"size": [315, 98],
"flags": {},
"order": 0,
"mode": 4,
"inputs": [],
"outputs": [
{ "name": "MODEL", "type": "MODEL", "links": null },
{ "name": "CLIP", "type": "CLIP", "links": null },
{ "name": "VAE", "type": "VAE", "links": null }
],
"properties": {
"Node name for S&R": "CheckpointLoaderSimple"
},
"widgets_values": ["fake_model.safetensors"]
}
],
"links": [],
"groups": [],
"config": {},
"extra": {
"ds": {
"scale": 1,
"offset": [0, 0]
}
},
"models": [
{
"name": "fake_model.safetensors",
"url": "http://localhost:8188/api/devtools/fake_model.safetensors",
"directory": "checkpoints"
}
],
"version": 0.4
}

View File

@@ -1,66 +0,0 @@
{
"last_node_id": 2,
"last_link_id": 0,
"nodes": [
{
"id": 1,
"type": "CheckpointLoaderSimple",
"pos": [100, 100],
"size": [400, 200],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [
{ "name": "MODEL", "type": "MODEL", "links": null },
{ "name": "CLIP", "type": "CLIP", "links": null },
{ "name": "VAE", "type": "VAE", "links": null }
],
"properties": {
"Node name for S&R": "CheckpointLoaderSimple"
},
"widgets_values": ["fake_model_a.safetensors"]
},
{
"id": 2,
"type": "CheckpointLoaderSimple",
"pos": [500, 100],
"size": [400, 200],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [],
"outputs": [
{ "name": "MODEL", "type": "MODEL", "links": null },
{ "name": "CLIP", "type": "CLIP", "links": null },
{ "name": "VAE", "type": "VAE", "links": null }
],
"properties": {
"Node name for S&R": "CheckpointLoaderSimple"
},
"widgets_values": ["fake_model_b.safetensors"]
}
],
"links": [],
"groups": [],
"config": {},
"extra": {
"ds": {
"scale": 1,
"offset": [0, 0]
}
},
"models": [
{
"name": "fake_model_a.safetensors",
"url": "http://localhost:8188/api/devtools/fake_model.safetensors",
"directory": "checkpoints"
},
{
"name": "fake_model_b.safetensors",
"url": "http://localhost:8188/api/devtools/fake_model.safetensors",
"directory": "checkpoints"
}
],
"version": 0.4
}

View File

@@ -34,7 +34,7 @@
{
"name": "fake_model.safetensors",
"url": "http://localhost:8188/api/devtools/fake_model.safetensors",
"directory": "checkpoints"
"directory": "text_encoders"
}
]
},

View File

@@ -1,141 +0,0 @@
{
"id": "test-missing-models-in-bypassed-subgraph",
"revision": 0,
"last_node_id": 2,
"last_link_id": 0,
"nodes": [
{
"id": 1,
"type": "KSampler",
"pos": [100, 100],
"size": [400, 262],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [
{ "name": "model", "type": "MODEL", "link": null },
{ "name": "positive", "type": "CONDITIONING", "link": null },
{ "name": "negative", "type": "CONDITIONING", "link": null },
{ "name": "latent_image", "type": "LATENT", "link": null }
],
"outputs": [{ "name": "LATENT", "type": "LATENT", "links": [] }],
"properties": {
"Node name for S&R": "KSampler"
},
"widgets_values": [0, "randomize", 20, 8, "euler", "simple", 1]
},
{
"id": 2,
"type": "subgraph-with-missing-model",
"pos": [450, 100],
"size": [400, 200],
"flags": {},
"order": 0,
"mode": 4,
"inputs": [{ "name": "model", "type": "MODEL", "link": null }],
"outputs": [{ "name": "MODEL", "type": "MODEL", "links": null }],
"properties": {},
"widgets_values": []
}
],
"links": [],
"groups": [],
"definitions": {
"subgraphs": [
{
"id": "subgraph-with-missing-model",
"version": 1,
"state": {
"lastGroupId": 0,
"lastNodeId": 1,
"lastLinkId": 2,
"lastRerouteId": 0
},
"revision": 0,
"config": {},
"name": "Subgraph with Missing Model",
"inputNode": {
"id": -10,
"bounding": [100, 200, 120, 60]
},
"outputNode": {
"id": -20,
"bounding": [500, 200, 120, 60]
},
"inputs": [
{
"id": "input1-id",
"name": "model",
"type": "MODEL",
"linkIds": [1],
"pos": { "0": 150, "1": 220 }
}
],
"outputs": [
{
"id": "output1-id",
"name": "MODEL",
"type": "MODEL",
"linkIds": [2],
"pos": { "0": 520, "1": 220 }
}
],
"widgets": [],
"nodes": [
{
"id": 1,
"type": "CheckpointLoaderSimple",
"pos": [250, 180],
"size": [400, 200],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [
{ "name": "MODEL", "type": "MODEL", "links": [2] },
{ "name": "CLIP", "type": "CLIP", "links": null },
{ "name": "VAE", "type": "VAE", "links": null }
],
"properties": {
"Node name for S&R": "CheckpointLoaderSimple"
},
"widgets_values": ["fake_model.safetensors"]
}
],
"links": [
{
"id": 1,
"origin_id": -10,
"origin_slot": 0,
"target_id": 1,
"target_slot": 0,
"type": "MODEL"
},
{
"id": 2,
"origin_id": 1,
"origin_slot": 0,
"target_id": -20,
"target_slot": 0,
"type": "MODEL"
}
]
}
]
},
"config": {},
"extra": {
"ds": {
"scale": 1,
"offset": [0, 0]
}
},
"models": [
{
"name": "fake_model.safetensors",
"url": "http://localhost:8188/api/devtools/fake_model.safetensors",
"directory": "checkpoints"
}
],
"version": 0.4
}

View File

@@ -1,141 +0,0 @@
{
"id": "test-missing-models-in-subgraph",
"revision": 0,
"last_node_id": 2,
"last_link_id": 0,
"nodes": [
{
"id": 1,
"type": "KSampler",
"pos": [100, 100],
"size": [270, 262],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [
{ "name": "model", "type": "MODEL", "link": null },
{ "name": "positive", "type": "CONDITIONING", "link": null },
{ "name": "negative", "type": "CONDITIONING", "link": null },
{ "name": "latent_image", "type": "LATENT", "link": null }
],
"outputs": [{ "name": "LATENT", "type": "LATENT", "links": [] }],
"properties": {
"Node name for S&R": "KSampler"
},
"widgets_values": [0, "randomize", 20, 8, "euler", "simple", 1]
},
{
"id": 2,
"type": "subgraph-with-missing-model",
"pos": [450, 100],
"size": [400, 200],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [{ "name": "model", "type": "MODEL", "link": null }],
"outputs": [{ "name": "MODEL", "type": "MODEL", "links": null }],
"properties": {},
"widgets_values": []
}
],
"links": [],
"groups": [],
"definitions": {
"subgraphs": [
{
"id": "subgraph-with-missing-model",
"version": 1,
"state": {
"lastGroupId": 0,
"lastNodeId": 1,
"lastLinkId": 2,
"lastRerouteId": 0
},
"revision": 0,
"config": {},
"name": "Subgraph with Missing Model",
"inputNode": {
"id": -10,
"bounding": [100, 200, 120, 60]
},
"outputNode": {
"id": -20,
"bounding": [500, 200, 120, 60]
},
"inputs": [
{
"id": "input1-id",
"name": "model",
"type": "MODEL",
"linkIds": [1],
"pos": { "0": 150, "1": 220 }
}
],
"outputs": [
{
"id": "output1-id",
"name": "MODEL",
"type": "MODEL",
"linkIds": [2],
"pos": { "0": 520, "1": 220 }
}
],
"widgets": [],
"nodes": [
{
"id": 1,
"type": "CheckpointLoaderSimple",
"pos": [250, 180],
"size": [315, 98],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [
{ "name": "MODEL", "type": "MODEL", "links": [2] },
{ "name": "CLIP", "type": "CLIP", "links": null },
{ "name": "VAE", "type": "VAE", "links": null }
],
"properties": {
"Node name for S&R": "CheckpointLoaderSimple"
},
"widgets_values": ["fake_model.safetensors"]
}
],
"links": [
{
"id": 1,
"origin_id": -10,
"origin_slot": 0,
"target_id": 1,
"target_slot": 0,
"type": "MODEL"
},
{
"id": 2,
"origin_id": 1,
"origin_slot": 0,
"target_id": -20,
"target_slot": 0,
"type": "MODEL"
}
]
}
]
},
"config": {},
"extra": {
"ds": {
"scale": 1,
"offset": [0, 0]
}
},
"models": [
{
"name": "fake_model.safetensors",
"url": "http://localhost:8188/api/devtools/fake_model.safetensors",
"directory": "checkpoints"
}
],
"version": 0.4
}

View File

@@ -78,7 +78,7 @@
{
"name": "fake_model.safetensors",
"url": "http://localhost:8188/api/devtools/fake_model.safetensors",
"directory": "checkpoints"
"directory": "text_encoders"
}
],
"version": 0.4

View File

@@ -2,13 +2,9 @@ import type { Locator, Page } from '@playwright/test'
export class TemplatesDialog {
public readonly root: Locator
public readonly modelFilter: Locator
public readonly resultsCount: Locator
constructor(public readonly page: Page) {
this.root = page.getByRole('dialog')
this.modelFilter = this.root.getByRole('button', { name: /Model Filter/ })
this.resultsCount = this.root.getByText(/Showing.*of.*templates/i)
}
filterByHeading(name: string): Locator {
@@ -20,10 +16,4 @@ export class TemplatesDialog {
getCombobox(name: RegExp | string): Locator {
return this.root.getByRole('combobox', { name })
}
async selectModelOption(name: string): Promise<void> {
await this.modelFilter.click()
await this.page.getByRole('option', { name }).click()
await this.page.keyboard.press('Escape')
}
}

View File

@@ -1,28 +0,0 @@
import type {
TemplateInfo,
WorkflowTemplates
} from '@/platform/workflow/templates/types/template'
export function makeTemplate(
overrides: Partial<TemplateInfo> & Pick<TemplateInfo, 'name'>
): TemplateInfo {
return {
description: overrides.name,
mediaType: 'image',
mediaSubtype: 'webp',
...overrides
}
}
export function mockTemplateIndex(
templates: TemplateInfo[]
): WorkflowTemplates[] {
return [
{
moduleName: 'default',
title: 'Test Templates',
type: 'image',
templates
}
]
}

View File

@@ -79,8 +79,7 @@ export const TestIds = {
bookmarksSection: 'node-library-bookmarks-section'
},
propertiesPanel: {
root: 'properties-panel',
errorsTab: 'panel-tab-errors'
root: 'properties-panel'
},
subgraphEditor: {
toggle: 'subgraph-editor-toggle',
@@ -113,7 +112,9 @@ export const TestIds = {
decrement: 'decrement',
increment: 'increment',
domWidgetTextarea: 'dom-widget-textarea',
subgraphEnterButton: 'subgraph-enter-button'
subgraphEnterButton: 'subgraph-enter-button',
formDropdownMenu: 'form-dropdown-menu',
formDropdownTrigger: 'form-dropdown-trigger'
},
builder: {
footerNav: 'builder-footer-nav',

View File

@@ -4,6 +4,7 @@ import {
comfyPageFixture as test,
comfyExpect as expect
} from '@e2e/fixtures/ComfyPage'
import { TestIds } from '@e2e/fixtures/selectors'
/**
* Default workflow widget inputs as [nodeId, widgetName] tuples.
@@ -137,12 +138,12 @@ test.describe('App mode dropdown clipping', { tag: '@ui' }, () => {
const dropdownButton = imageRow.locator('button:has(> span)').first()
await dropdownButton.click()
// The unstyled PrimeVue Popover renders with role="dialog".
// Locate the one containing the image grid (filter buttons like "All", "Inputs").
const popover = comfyPage.appMode.imagePickerPopover
await expect(popover).toBeVisible({ timeout: 5000 })
const menu = comfyPage.page
.getByTestId(TestIds.widgets.formDropdownMenu)
.first()
await expect(menu).toBeVisible({ timeout: 5000 })
const isInViewport = await popover.evaluate((el) => {
const isInViewport = await menu.evaluate((el) => {
const rect = el.getBoundingClientRect()
return (
rect.top >= 0 &&
@@ -153,7 +154,7 @@ test.describe('App mode dropdown clipping', { tag: '@ui' }, () => {
})
expect(isInViewport).toBe(true)
const isClipped = await popover.evaluate(isClippedByAnyAncestor)
const isClipped = await menu.evaluate(isClippedByAnyAncestor)
expect(isClipped).toBe(false)
})
})

View File

@@ -131,38 +131,6 @@ test.describe('Settings dialog', { tag: '@ui' }, () => {
expect(switched).toBe(true)
})
test('Boolean setting persists after page reload', async ({ comfyPage }) => {
const settingId = 'Comfy.Node.MiddleClickRerouteNode'
const initialValue = await comfyPage.settings.getSetting<boolean>(settingId)
try {
await comfyPage.settings.setSetting(settingId, !initialValue)
await expect
.poll(() => comfyPage.settings.getSetting<boolean>(settingId))
.toBe(!initialValue)
await comfyPage.page.reload({ waitUntil: 'domcontentloaded' })
await comfyPage.page.waitForFunction(
() => window.app && window.app.extensionManager
)
await expect
.poll(() => comfyPage.settings.getSetting<boolean>(settingId))
.toBe(!initialValue)
await expect
.poll(() =>
comfyPage.page.evaluate(
() => window.LiteGraph!.middle_click_slot_add_default_node
)
)
.toBe(!initialValue)
} finally {
await comfyPage.settings.setSetting(settingId, initialValue)
}
})
test('Dropdown setting can be changed and persists', async ({
comfyPage
}) => {

View File

@@ -5,7 +5,6 @@ import {
comfyExpect as expect
} from '@e2e/fixtures/ComfyPage'
import { TestIds } from '@e2e/fixtures/selectors'
import { cleanupFakeModel } from '@e2e/tests/propertiesPanel/ErrorsTabHelper'
test.describe('Error overlay', { tag: '@ui' }, () => {
test.beforeEach(async ({ comfyPage }) => {
@@ -48,7 +47,11 @@ test.describe('Error overlay', { tag: '@ui' }, () => {
test('Should display "Show missing models" button for missing model errors', async ({
comfyPage
}) => {
await cleanupFakeModel(comfyPage)
const cleanupOk = await comfyPage.page.evaluate(async (url: string) => {
const response = await fetch(`${url}/api/devtools/cleanup_fake_model`)
return response.ok
}, comfyPage.url)
expect(cleanupOk).toBeTruthy()
await comfyPage.workflow.loadWorkflow('missing/missing_models')
@@ -92,7 +95,7 @@ test.describe('Error overlay', { tag: '@ui' }, () => {
await errorOverlay
.getByTestId(TestIds.dialogs.errorOverlayDismiss)
.click()
await expect(errorOverlay).toBeHidden()
await expect(errorOverlay).not.toBeVisible()
await comfyPage.canvas.click()
await comfyPage.nextFrame()
@@ -104,37 +107,10 @@ test.describe('Error overlay', { tag: '@ui' }, () => {
await comfyPage.nextFrame()
await comfyPage.keyboard.undo()
await expect(errorOverlay).toBeHidden()
await expect(errorOverlay).not.toBeVisible({ timeout: 5000 })
await comfyPage.keyboard.redo()
await expect(errorOverlay).toBeHidden()
})
test('Does not resurface error overlay when switching back to workflow with missing nodes', async ({
comfyPage
}) => {
await comfyPage.settings.setSetting(
'Comfy.Workflow.WorkflowTabsPosition',
'Sidebar'
)
await comfyPage.menu.workflowsTab.open()
await comfyPage.workflow.loadWorkflow('missing/missing_nodes')
const errorOverlay = getOverlay(comfyPage.page)
await expect(errorOverlay).toBeVisible()
await errorOverlay
.getByTestId(TestIds.dialogs.errorOverlayDismiss)
.click()
await expect(errorOverlay).toBeHidden()
await comfyPage.menu.workflowsTab.open()
await comfyPage.command.executeCommand('Comfy.NewBlankWorkflow')
await comfyPage.menu.workflowsTab.switchToWorkflow('missing_nodes')
await expect(errorOverlay).toBeHidden()
await expect(errorOverlay).not.toBeVisible({ timeout: 5000 })
})
})
@@ -175,7 +151,6 @@ test.describe('Error overlay', { tag: '@ui' }, () => {
await overlay.getByTestId(TestIds.dialogs.errorOverlaySeeErrors).click()
await expect(overlay).toBeHidden()
await expect(comfyPage.page.getByTestId('properties-panel')).toBeVisible()
})
@@ -187,7 +162,7 @@ test.describe('Error overlay', { tag: '@ui' }, () => {
await overlay.getByTestId(TestIds.dialogs.errorOverlaySeeErrors).click()
await expect(overlay).toBeHidden()
await expect(overlay).not.toBeVisible()
})
test('"Dismiss" closes overlay without opening panel', async ({
@@ -200,8 +175,10 @@ test.describe('Error overlay', { tag: '@ui' }, () => {
await overlay.getByTestId(TestIds.dialogs.errorOverlayDismiss).click()
await expect(overlay).toBeHidden()
await expect(comfyPage.page.getByTestId('properties-panel')).toBeHidden()
await expect(overlay).not.toBeVisible()
await expect(
comfyPage.page.getByTestId('properties-panel')
).not.toBeVisible()
})
test('Close button (X) dismisses overlay', async ({ comfyPage }) => {
@@ -212,37 +189,7 @@ test.describe('Error overlay', { tag: '@ui' }, () => {
await overlay.getByRole('button', { name: /close/i }).click()
await expect(overlay).toBeHidden()
})
})
test.describe('Count independence from node selection', () => {
test.beforeEach(async ({ comfyPage }) => {
await cleanupFakeModel(comfyPage)
})
test.afterEach(async ({ comfyPage }) => {
await cleanupFakeModel(comfyPage)
})
test('missing model count stays constant when a node is selected', async ({
comfyPage
}) => {
// Regression: ErrorOverlay previously read the selection-filtered
// missingModelGroups from useErrorGroups, so selecting one of two
// missing-model nodes would shrink the overlay label from
// "2 required models are missing" to "1". The overlay must show
// the workflow total regardless of canvas selection.
await comfyPage.workflow.loadWorkflow('missing/missing_models_distinct')
const overlay = getOverlay(comfyPage.page)
await expect(overlay).toBeVisible()
await expect(overlay).toContainText(/2 required models are missing/i)
const node = await comfyPage.nodeOps.getNodeRefById('1')
await node.click('title')
await expect(overlay).toContainText(/2 required models are missing/i)
await expect(overlay).not.toBeVisible()
})
})
})

View File

@@ -1,105 +0,0 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
import type { ComfyPage } from '../fixtures/ComfyPage'
test.describe(
'Node context menu viewport overflow (#10824)',
{ tag: '@ui' },
() => {
test.beforeEach(async ({ comfyPage }) => {
// Keep the viewport well below the menu content height so overflow is guaranteed.
await comfyPage.page.setViewportSize({ width: 1280, height: 520 })
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled')
await comfyPage.settings.setSetting('Comfy.Canvas.SelectionToolbox', true)
await comfyPage.workflow.loadWorkflow('nodes/single_ksampler')
await comfyPage.nextFrame()
})
async function openMoreOptions(comfyPage: ComfyPage) {
const ksamplerNodes =
await comfyPage.nodeOps.getNodeRefsByTitle('KSampler')
if (ksamplerNodes.length === 0) {
throw new Error('No KSampler nodes found')
}
// Drag the KSampler toward the lower-left so the menu has limited space below it.
const nodePos = await ksamplerNodes[0].getPosition()
const viewportSize = comfyPage.page.viewportSize()!
const centerX = viewportSize.width / 3
const centerY = viewportSize.height * 0.75
await comfyPage.canvasOps.dragAndDrop(
{ x: nodePos.x, y: nodePos.y },
{ x: centerX, y: centerY }
)
await comfyPage.nextFrame()
await ksamplerNodes[0].click('title')
await comfyPage.nextFrame()
await expect(comfyPage.page.locator('.selection-toolbox')).toBeVisible({
timeout: 5000
})
const moreOptionsBtn = comfyPage.page.locator(
'[data-testid="more-options-button"]'
)
await expect(moreOptionsBtn).toBeVisible({ timeout: 3000 })
await moreOptionsBtn.click()
await comfyPage.nextFrame()
const menu = comfyPage.page.locator('.p-contextmenu')
await expect(menu).toBeVisible({ timeout: 3000 })
// Wait for constrainMenuHeight (runs via requestAnimationFrame in onMenuShow)
await comfyPage.nextFrame()
return menu
}
test('last menu item "Remove" is reachable via scroll', async ({
comfyPage
}) => {
const menu = await openMoreOptions(comfyPage)
const rootList = menu.locator(':scope > ul')
await expect
.poll(
() => rootList.evaluate((el) => el.scrollHeight > el.clientHeight),
{
message:
'Menu should overflow vertically so this test exercises the viewport clamp',
timeout: 3000
}
)
.toBe(true)
// "Remove" is the last item in the More Options menu.
// It must become reachable by scrolling the bounded menu list.
const removeItem = menu.getByText('Remove', { exact: true })
const didScroll = await rootList.evaluate((el) => {
const previousScrollTop = el.scrollTop
el.scrollTo({ top: el.scrollHeight })
return el.scrollTop > previousScrollTop
})
expect(didScroll).toBe(true)
await expect(removeItem).toBeVisible()
})
test('last menu item "Remove" is clickable and removes the node', async ({
comfyPage
}) => {
const menu = await openMoreOptions(comfyPage)
const removeItem = menu.getByText('Remove', { exact: true })
await removeItem.scrollIntoViewIfNeeded()
await removeItem.click()
await comfyPage.nextFrame()
// The node should be removed from the graph
await expect
.poll(() => comfyPage.nodeOps.getGraphNodesCount(), { timeout: 3000 })
.toBe(0)
})
}
)

View File

@@ -2,9 +2,8 @@ import { expect } from '@playwright/test'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import { TestIds } from '@e2e/fixtures/selectors'
import { PropertiesPanelHelper } from '@e2e/tests/propertiesPanel/PropertiesPanelHelper'
export async function loadWorkflowAndOpenErrorsTab(
export async function openErrorsTabViaSeeErrors(
comfyPage: ComfyPage,
workflow: string
) {
@@ -16,30 +15,3 @@ export async function loadWorkflowAndOpenErrorsTab(
await errorOverlay.getByTestId(TestIds.dialogs.errorOverlaySeeErrors).click()
await expect(errorOverlay).not.toBeVisible()
}
export async function openErrorsTab(comfyPage: ComfyPage) {
const panel = new PropertiesPanelHelper(comfyPage.page)
await panel.open(comfyPage.actionbar.propertiesButton)
const errorsTab = comfyPage.page.getByTestId(
TestIds.propertiesPanel.errorsTab
)
await expect(errorsTab).toBeVisible()
await errorsTab.click()
}
/**
* Remove the fake model file from the backend so it is detected as missing.
* Fixture URLs (e.g. http://localhost:8188/...) are not actually downloaded
* during tests — they only serve as metadata for the missing model UI.
*/
export async function cleanupFakeModel(comfyPage: ComfyPage) {
await expect
.poll(() =>
comfyPage.page.evaluate(async (url: string) => {
const response = await fetch(`${url}/api/devtools/cleanup_fake_model`)
return response.ok
}, comfyPage.url)
)
.toBeTruthy()
}

View File

@@ -3,7 +3,7 @@ import { expect } from '@playwright/test'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
import { TestIds } from '@e2e/fixtures/selectors'
import { loadWorkflowAndOpenErrorsTab } from '@e2e/tests/propertiesPanel/ErrorsTabHelper'
import { openErrorsTabViaSeeErrors } from '@e2e/tests/propertiesPanel/ErrorsTabHelper'
async function uploadFileViaDropzone(comfyPage: ComfyPage) {
const dropzone = comfyPage.page.getByTestId(
@@ -47,10 +47,7 @@ test.describe('Errors tab - Missing media', { tag: '@ui' }, () => {
test.describe('Detection', () => {
test('Shows missing media group in errors tab', async ({ comfyPage }) => {
await loadWorkflowAndOpenErrorsTab(
comfyPage,
'missing/missing_media_single'
)
await openErrorsTabViaSeeErrors(comfyPage, 'missing/missing_media_single')
await expect(
comfyPage.page.getByTestId(TestIds.dialogs.missingMediaGroup)
@@ -60,7 +57,7 @@ test.describe('Errors tab - Missing media', { tag: '@ui' }, () => {
test('Shows correct number of missing media rows', async ({
comfyPage
}) => {
await loadWorkflowAndOpenErrorsTab(
await openErrorsTabViaSeeErrors(
comfyPage,
'missing/missing_media_multiple'
)
@@ -71,10 +68,7 @@ test.describe('Errors tab - Missing media', { tag: '@ui' }, () => {
test('Shows upload dropzone and library select for each missing item', async ({
comfyPage
}) => {
await loadWorkflowAndOpenErrorsTab(
comfyPage,
'missing/missing_media_single'
)
await openErrorsTabViaSeeErrors(comfyPage, 'missing/missing_media_single')
await expect(getDropzone(comfyPage)).toBeVisible()
await expect(
@@ -87,10 +81,7 @@ test.describe('Errors tab - Missing media', { tag: '@ui' }, () => {
test('Upload via file picker shows status card then allows confirm', async ({
comfyPage
}) => {
await loadWorkflowAndOpenErrorsTab(
comfyPage,
'missing/missing_media_single'
)
await openErrorsTabViaSeeErrors(comfyPage, 'missing/missing_media_single')
await uploadFileViaDropzone(comfyPage)
await expect(getStatusCard(comfyPage)).toBeVisible()
@@ -104,10 +95,7 @@ test.describe('Errors tab - Missing media', { tag: '@ui' }, () => {
test('Selecting from library shows status card then allows confirm', async ({
comfyPage
}) => {
await loadWorkflowAndOpenErrorsTab(
comfyPage,
'missing/missing_media_single'
)
await openErrorsTabViaSeeErrors(comfyPage, 'missing/missing_media_single')
const librarySelect = comfyPage.page.getByTestId(
TestIds.dialogs.missingMediaLibrarySelect
@@ -133,10 +121,7 @@ test.describe('Errors tab - Missing media', { tag: '@ui' }, () => {
test('Cancelling pending selection returns to upload/library UI', async ({
comfyPage
}) => {
await loadWorkflowAndOpenErrorsTab(
comfyPage,
'missing/missing_media_single'
)
await openErrorsTabViaSeeErrors(comfyPage, 'missing/missing_media_single')
await uploadFileViaDropzone(comfyPage)
await expect(getStatusCard(comfyPage)).toBeVisible()
@@ -155,10 +140,7 @@ test.describe('Errors tab - Missing media', { tag: '@ui' }, () => {
test('Missing Inputs group disappears when all items are resolved', async ({
comfyPage
}) => {
await loadWorkflowAndOpenErrorsTab(
comfyPage,
'missing/missing_media_single'
)
await openErrorsTabViaSeeErrors(comfyPage, 'missing/missing_media_single')
await uploadFileViaDropzone(comfyPage)
await confirmPendingSelection(comfyPage)
@@ -172,10 +154,7 @@ test.describe('Errors tab - Missing media', { tag: '@ui' }, () => {
test('Locate button navigates canvas to the missing media node', async ({
comfyPage
}) => {
await loadWorkflowAndOpenErrorsTab(
comfyPage,
'missing/missing_media_single'
)
await openErrorsTabViaSeeErrors(comfyPage, 'missing/missing_media_single')
const offsetBefore = await comfyPage.page.evaluate(() => {
const canvas = window['app']?.canvas

View File

@@ -6,10 +6,7 @@ import {
interceptClipboardWrite,
getClipboardText
} from '@e2e/helpers/clipboardSpy'
import {
cleanupFakeModel,
loadWorkflowAndOpenErrorsTab
} from '@e2e/tests/propertiesPanel/ErrorsTabHelper'
import { openErrorsTabViaSeeErrors } from '@e2e/tests/propertiesPanel/ErrorsTabHelper'
test.describe('Errors tab - Missing models', { tag: '@ui' }, () => {
test.beforeEach(async ({ comfyPage }) => {
@@ -18,13 +15,17 @@ test.describe('Errors tab - Missing models', { tag: '@ui' }, () => {
'Comfy.RightSidePanel.ShowErrorsTab',
true
)
await cleanupFakeModel(comfyPage)
const cleanupOk = await comfyPage.page.evaluate(async (url: string) => {
const response = await fetch(`${url}/api/devtools/cleanup_fake_model`)
return response.ok
}, comfyPage.url)
expect(cleanupOk).toBeTruthy()
})
test('Should show missing models group in errors tab', async ({
comfyPage
}) => {
await loadWorkflowAndOpenErrorsTab(comfyPage, 'missing/missing_models')
await openErrorsTabViaSeeErrors(comfyPage, 'missing/missing_models')
await expect(
comfyPage.page.getByTestId(TestIds.dialogs.missingModelsGroup)
@@ -34,7 +35,7 @@ test.describe('Errors tab - Missing models', { tag: '@ui' }, () => {
test('Should display model name with referencing node count', async ({
comfyPage
}) => {
await loadWorkflowAndOpenErrorsTab(comfyPage, 'missing/missing_models')
await openErrorsTabViaSeeErrors(comfyPage, 'missing/missing_models')
const modelsGroup = comfyPage.page.getByTestId(
TestIds.dialogs.missingModelsGroup
@@ -45,7 +46,7 @@ test.describe('Errors tab - Missing models', { tag: '@ui' }, () => {
test('Should expand model row to show referencing nodes', async ({
comfyPage
}) => {
await loadWorkflowAndOpenErrorsTab(
await openErrorsTabViaSeeErrors(
comfyPage,
'missing/missing_models_with_nodes'
)
@@ -53,7 +54,7 @@ test.describe('Errors tab - Missing models', { tag: '@ui' }, () => {
const locateButton = comfyPage.page.getByTestId(
TestIds.dialogs.missingModelLocate
)
await expect(locateButton.first()).toBeHidden()
await expect(locateButton.first()).not.toBeVisible()
const expandButton = comfyPage.page.getByTestId(
TestIds.dialogs.missingModelExpand
@@ -65,14 +66,14 @@ test.describe('Errors tab - Missing models', { tag: '@ui' }, () => {
})
test('Should copy model name to clipboard', async ({ comfyPage }) => {
await loadWorkflowAndOpenErrorsTab(comfyPage, 'missing/missing_models')
await openErrorsTabViaSeeErrors(comfyPage, 'missing/missing_models')
await interceptClipboardWrite(comfyPage.page)
const copyButton = comfyPage.page.getByTestId(
TestIds.dialogs.missingModelCopyName
)
await expect(copyButton.first()).toBeVisible()
await copyButton.first().dispatchEvent('click')
await copyButton.first().click()
const copiedText = await getClipboardText(comfyPage.page)
expect(copiedText).toContain('fake_model.safetensors')
@@ -82,7 +83,7 @@ test.describe('Errors tab - Missing models', { tag: '@ui' }, () => {
test('Should show Copy URL button for non-asset models', async ({
comfyPage
}) => {
await loadWorkflowAndOpenErrorsTab(comfyPage, 'missing/missing_models')
await openErrorsTabViaSeeErrors(comfyPage, 'missing/missing_models')
const copyUrlButton = comfyPage.page.getByTestId(
TestIds.dialogs.missingModelCopyUrl
@@ -93,7 +94,7 @@ test.describe('Errors tab - Missing models', { tag: '@ui' }, () => {
test('Should show Download button for downloadable models', async ({
comfyPage
}) => {
await loadWorkflowAndOpenErrorsTab(comfyPage, 'missing/missing_models')
await openErrorsTabViaSeeErrors(comfyPage, 'missing/missing_models')
const downloadButton = comfyPage.page.getByTestId(
TestIds.dialogs.missingModelDownload

View File

@@ -2,7 +2,7 @@ import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
import { TestIds } from '@e2e/fixtures/selectors'
import { loadWorkflowAndOpenErrorsTab } from '@e2e/tests/propertiesPanel/ErrorsTabHelper'
import { openErrorsTabViaSeeErrors } from '@e2e/tests/propertiesPanel/ErrorsTabHelper'
test.describe('Errors tab - Missing nodes', { tag: '@ui' }, () => {
test.beforeEach(async ({ comfyPage }) => {
@@ -14,7 +14,7 @@ test.describe('Errors tab - Missing nodes', { tag: '@ui' }, () => {
})
test('Should show MissingNodeCard in errors tab', async ({ comfyPage }) => {
await loadWorkflowAndOpenErrorsTab(comfyPage, 'missing/missing_nodes')
await openErrorsTabViaSeeErrors(comfyPage, 'missing/missing_nodes')
await expect(
comfyPage.page.getByTestId(TestIds.dialogs.missingNodeCard)
@@ -22,7 +22,7 @@ test.describe('Errors tab - Missing nodes', { tag: '@ui' }, () => {
})
test('Should show missing node packs group', async ({ comfyPage }) => {
await loadWorkflowAndOpenErrorsTab(comfyPage, 'missing/missing_nodes')
await openErrorsTabViaSeeErrors(comfyPage, 'missing/missing_nodes')
await expect(
comfyPage.page.getByTestId(TestIds.dialogs.missingNodePacksGroup)
@@ -32,7 +32,7 @@ test.describe('Errors tab - Missing nodes', { tag: '@ui' }, () => {
test('Should expand pack group to reveal node type names', async ({
comfyPage
}) => {
await loadWorkflowAndOpenErrorsTab(
await openErrorsTabViaSeeErrors(
comfyPage,
'missing/missing_nodes_in_subgraph'
)
@@ -52,7 +52,7 @@ test.describe('Errors tab - Missing nodes', { tag: '@ui' }, () => {
})
test('Should collapse expanded pack group', async ({ comfyPage }) => {
await loadWorkflowAndOpenErrorsTab(
await openErrorsTabViaSeeErrors(
comfyPage,
'missing/missing_nodes_in_subgraph'
)
@@ -80,7 +80,7 @@ test.describe('Errors tab - Missing nodes', { tag: '@ui' }, () => {
test('Locate node button is visible for expanded pack nodes', async ({
comfyPage
}) => {
await loadWorkflowAndOpenErrorsTab(
await openErrorsTabViaSeeErrors(
comfyPage,
'missing/missing_nodes_in_subgraph'
)

View File

@@ -1,599 +0,0 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
import { TestIds } from '@e2e/fixtures/selectors'
import {
cleanupFakeModel,
openErrorsTab,
loadWorkflowAndOpenErrorsTab
} from '@e2e/tests/propertiesPanel/ErrorsTabHelper'
test.describe('Errors tab - Mode-aware errors', { tag: '@ui' }, () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
await comfyPage.settings.setSetting(
'Comfy.RightSidePanel.ShowErrorsTab',
true
)
})
test.describe('Missing nodes', () => {
test('Deleting a missing node removes its error from the errors tab', async ({
comfyPage
}) => {
await loadWorkflowAndOpenErrorsTab(comfyPage, 'missing/missing_nodes')
const missingNodeGroup = comfyPage.page.getByTestId(
TestIds.dialogs.missingNodePacksGroup
)
await expect(missingNodeGroup).toBeVisible()
const node = await comfyPage.nodeOps.getNodeRefById('1')
await node.delete()
await expect(missingNodeGroup).toBeHidden()
})
test('Undo after bypass restores error without showing overlay', async ({
comfyPage
}) => {
await loadWorkflowAndOpenErrorsTab(comfyPage, 'missing/missing_nodes')
const missingNodeGroup = comfyPage.page.getByTestId(
TestIds.dialogs.missingNodePacksGroup
)
const errorOverlay = comfyPage.page.getByTestId(
TestIds.dialogs.errorOverlay
)
await expect(missingNodeGroup).toBeVisible()
const node = await comfyPage.nodeOps.getNodeRefById('1')
await node.click('title')
await comfyPage.keyboard.bypass()
await expect.poll(() => node.isBypassed()).toBeTruthy()
await expect(missingNodeGroup).toBeHidden()
await comfyPage.keyboard.undo()
await expect.poll(() => node.isBypassed()).toBeFalsy()
await expect(errorOverlay).toBeHidden()
await openErrorsTab(comfyPage)
await expect(missingNodeGroup).toBeVisible()
await comfyPage.keyboard.redo()
await expect.poll(() => node.isBypassed()).toBeTruthy()
await expect(missingNodeGroup).toBeHidden()
})
})
test.describe('Missing models', () => {
test.beforeEach(async ({ comfyPage }) => {
await cleanupFakeModel(comfyPage)
})
test.afterEach(async ({ comfyPage }) => {
await cleanupFakeModel(comfyPage)
})
test('Loading a workflow with all nodes bypassed shows no errors', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('missing/missing_models_bypassed')
const errorOverlay = comfyPage.page.getByTestId(
TestIds.dialogs.errorOverlay
)
await expect(errorOverlay).toBeHidden()
await comfyPage.actionbar.propertiesButton.click()
await expect(
comfyPage.page.getByTestId(TestIds.propertiesPanel.errorsTab)
).toBeHidden()
})
test('Bypassing a node hides its error, un-bypassing restores it', async ({
comfyPage
}) => {
await loadWorkflowAndOpenErrorsTab(comfyPage, 'missing/missing_models')
const missingModelGroup = comfyPage.page.getByTestId(
TestIds.dialogs.missingModelsGroup
)
await expect(missingModelGroup).toBeVisible()
const node = await comfyPage.nodeOps.getNodeRefById('1')
await node.click('title')
await comfyPage.keyboard.bypass()
await expect.poll(() => node.isBypassed()).toBeTruthy()
await expect(missingModelGroup).toBeHidden()
await node.click('title')
await comfyPage.keyboard.bypass()
await expect.poll(() => node.isBypassed()).toBeFalsy()
await openErrorsTab(comfyPage)
await expect(missingModelGroup).toBeVisible()
})
test('Bypass/un-bypass cycle preserves Copy URL button on the restored row', async ({
comfyPage
}) => {
// Regression: on un-bypass, the realtime scan produced a fresh
// candidate without url/hash/directory — those fields were only
// attached by the full pipeline's enrichWithEmbeddedMetadata. The
// row's Copy URL button (v-if gated on representative.url) then
// disappeared. Per-node scan now enriches from node.properties.models
// which persists across mode toggles. Uses the `_from_node_properties`
// fixture because the enrichment source is per-node metadata, not
// the workflow-level `models[]` array (which the realtime scan
// path does not see).
await loadWorkflowAndOpenErrorsTab(
comfyPage,
'missing/missing_models_from_node_properties'
)
const copyUrlButton = comfyPage.page.getByTestId(
TestIds.dialogs.missingModelCopyUrl
)
await expect(copyUrlButton.first()).toBeVisible()
const node = await comfyPage.nodeOps.getNodeRefById('1')
await node.click('title')
await comfyPage.keyboard.bypass()
await expect.poll(() => node.isBypassed()).toBeTruthy()
await node.click('title')
await comfyPage.keyboard.bypass()
await expect.poll(() => node.isBypassed()).toBeFalsy()
await openErrorsTab(comfyPage)
await expect(copyUrlButton.first()).toBeVisible()
})
test('Pasting a node with missing model increases referencing node count', async ({
comfyPage
}) => {
await loadWorkflowAndOpenErrorsTab(comfyPage, 'missing/missing_models')
const missingModelGroup = comfyPage.page.getByTestId(
TestIds.dialogs.missingModelsGroup
)
await expect(missingModelGroup).toBeVisible()
await expect(missingModelGroup).toContainText(
/fake_model\.safetensors\s*\(1\)/
)
const node = await comfyPage.nodeOps.getNodeRefById('1')
await node.click('title')
await comfyPage.clipboard.copy()
await comfyPage.clipboard.paste()
await expect.poll(() => comfyPage.nodeOps.getNodeCount()).toBe(2)
await comfyPage.canvas.click()
await expect(missingModelGroup).toContainText(
/fake_model\.safetensors\s*\(2\)/
)
})
test('Pasting a bypassed node does not add a new error', async ({
comfyPage
}) => {
await loadWorkflowAndOpenErrorsTab(comfyPage, 'missing/missing_models')
const missingModelGroup = comfyPage.page.getByTestId(
TestIds.dialogs.missingModelsGroup
)
const node = await comfyPage.nodeOps.getNodeRefById('1')
await node.click('title')
await comfyPage.keyboard.bypass()
await expect.poll(() => node.isBypassed()).toBeTruthy()
await expect(missingModelGroup).toBeHidden()
await comfyPage.clipboard.copy()
await comfyPage.clipboard.paste()
await expect.poll(() => comfyPage.nodeOps.getNodeCount()).toBe(2)
await expect(missingModelGroup).toBeHidden()
})
test('Deleting a node with missing model removes its error', async ({
comfyPage
}) => {
await loadWorkflowAndOpenErrorsTab(comfyPage, 'missing/missing_models')
const missingModelGroup = comfyPage.page.getByTestId(
TestIds.dialogs.missingModelsGroup
)
await expect(missingModelGroup).toBeVisible()
const node = await comfyPage.nodeOps.getNodeRefById('1')
await node.delete()
await expect(missingModelGroup).toBeHidden()
})
test('Undo after bypass restores error without showing overlay', async ({
comfyPage
}) => {
await loadWorkflowAndOpenErrorsTab(comfyPage, 'missing/missing_models')
const missingModelGroup = comfyPage.page.getByTestId(
TestIds.dialogs.missingModelsGroup
)
const errorOverlay = comfyPage.page.getByTestId(
TestIds.dialogs.errorOverlay
)
await expect(missingModelGroup).toBeVisible()
const node = await comfyPage.nodeOps.getNodeRefById('1')
await node.click('title')
await comfyPage.keyboard.bypass()
await expect.poll(() => node.isBypassed()).toBeTruthy()
await expect(missingModelGroup).toBeHidden()
await comfyPage.keyboard.undo()
await expect.poll(() => node.isBypassed()).toBeFalsy()
await expect(errorOverlay).toBeHidden()
await openErrorsTab(comfyPage)
await expect(missingModelGroup).toBeVisible()
await comfyPage.keyboard.redo()
await expect.poll(() => node.isBypassed()).toBeTruthy()
await expect(missingModelGroup).toBeHidden()
})
test('Selecting a node filters errors tab to only that node', async ({
comfyPage
}) => {
await loadWorkflowAndOpenErrorsTab(
comfyPage,
'missing/missing_models_with_nodes'
)
const missingModelGroup = comfyPage.page.getByTestId(
TestIds.dialogs.missingModelsGroup
)
await expect(missingModelGroup).toContainText(/\(2\)/)
const node1 = await comfyPage.nodeOps.getNodeRefById('1')
await node1.click('title')
await expect(missingModelGroup).toContainText(/\(1\)/)
await comfyPage.canvas.click()
await expect(missingModelGroup).toContainText(/\(2\)/)
})
})
test.describe('Missing media', () => {
test('Loading a workflow with all nodes bypassed shows no errors', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('missing/missing_media_bypassed')
const errorOverlay = comfyPage.page.getByTestId(
TestIds.dialogs.errorOverlay
)
await expect(errorOverlay).toBeHidden()
await comfyPage.actionbar.propertiesButton.click()
await expect(
comfyPage.page.getByTestId(TestIds.propertiesPanel.errorsTab)
).toBeHidden()
})
test('Bypassing a node hides its error, un-bypassing restores it', async ({
comfyPage
}) => {
await loadWorkflowAndOpenErrorsTab(
comfyPage,
'missing/missing_media_single'
)
const missingMediaGroup = comfyPage.page.getByTestId(
TestIds.dialogs.missingMediaGroup
)
await expect(missingMediaGroup).toBeVisible()
const node = await comfyPage.nodeOps.getNodeRefById('10')
await node.click('title')
await comfyPage.keyboard.bypass()
await expect.poll(() => node.isBypassed()).toBeTruthy()
await expect(missingMediaGroup).toBeHidden()
await node.click('title')
await comfyPage.keyboard.bypass()
await expect.poll(() => node.isBypassed()).toBeFalsy()
await openErrorsTab(comfyPage)
await expect(missingMediaGroup).toBeVisible()
})
test('Pasting a bypassed node does not add a new error', async ({
comfyPage
}) => {
await loadWorkflowAndOpenErrorsTab(
comfyPage,
'missing/missing_media_single'
)
const missingMediaGroup = comfyPage.page.getByTestId(
TestIds.dialogs.missingMediaGroup
)
const node = await comfyPage.nodeOps.getNodeRefById('10')
await node.click('title')
await comfyPage.keyboard.bypass()
await expect.poll(() => node.isBypassed()).toBeTruthy()
await expect(missingMediaGroup).toBeHidden()
await comfyPage.clipboard.copy()
await comfyPage.clipboard.paste()
await expect.poll(() => comfyPage.nodeOps.getNodeCount()).toBe(2)
await expect(missingMediaGroup).toBeHidden()
})
test('Selecting a node filters errors tab to only that node', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('missing/missing_media_multiple')
const errorOverlay = comfyPage.page.getByTestId(
TestIds.dialogs.errorOverlay
)
await expect(errorOverlay).toBeVisible()
await errorOverlay
.getByTestId(TestIds.dialogs.errorOverlayDismiss)
.click()
const mediaRows = comfyPage.page.getByTestId(
TestIds.dialogs.missingMediaRow
)
await openErrorsTab(comfyPage)
await expect(mediaRows).toHaveCount(2)
const node = await comfyPage.nodeOps.getNodeRefById('10')
await node.click('title')
await expect(mediaRows).toHaveCount(1)
await comfyPage.canvas.click({ position: { x: 400, y: 600 } })
await expect(mediaRows).toHaveCount(2)
})
})
test.describe('Subgraph', () => {
test.beforeEach(async ({ comfyPage }) => {
await cleanupFakeModel(comfyPage)
})
test.afterEach(async ({ comfyPage }) => {
await cleanupFakeModel(comfyPage)
})
test('Bypassing a subgraph hides interior errors, un-bypassing restores them', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'missing/missing_models_in_subgraph'
)
const errorOverlay = comfyPage.page.getByTestId(
TestIds.dialogs.errorOverlay
)
await expect(errorOverlay).toBeVisible()
await errorOverlay
.getByTestId(TestIds.dialogs.errorOverlayDismiss)
.click()
const missingModelGroup = comfyPage.page.getByTestId(
TestIds.dialogs.missingModelsGroup
)
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('2')
const errorsTab = comfyPage.page.getByTestId(
TestIds.propertiesPanel.errorsTab
)
await comfyPage.keyboard.selectAll()
await comfyPage.keyboard.bypass()
await expect.poll(() => subgraphNode.isBypassed()).toBeTruthy()
await comfyPage.actionbar.propertiesButton.click()
await expect(errorsTab).toBeHidden()
await comfyPage.keyboard.selectAll()
await comfyPage.keyboard.bypass()
await expect.poll(() => subgraphNode.isBypassed()).toBeFalsy()
await openErrorsTab(comfyPage)
await expect(missingModelGroup).toBeVisible()
})
test('Deleting a node inside a subgraph removes its missing model error', async ({
comfyPage
}) => {
// Regression: before the execId fix, onNodeRemoved fell back to the
// interior node's local id (e.g. "1") when node.graph was already
// null, so the error keyed under "2:1" was never removed.
await comfyPage.workflow.loadWorkflow(
'missing/missing_models_in_subgraph'
)
const errorOverlay = comfyPage.page.getByTestId(
TestIds.dialogs.errorOverlay
)
await expect(errorOverlay).toBeVisible()
await errorOverlay
.getByTestId(TestIds.dialogs.errorOverlayDismiss)
.click()
const missingModelGroup = comfyPage.page.getByTestId(
TestIds.dialogs.missingModelsGroup
)
await openErrorsTab(comfyPage)
await expect(missingModelGroup).toBeVisible()
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('2')
await subgraphNode.navigateIntoSubgraph()
// Select-all + Delete: interior node IDs may be reassigned during
// subgraph configure when they collide with root-graph IDs, so
// looking up by static id can fail.
await comfyPage.keyboard.selectAll()
await comfyPage.page.keyboard.press('Delete')
await expect(missingModelGroup).toBeHidden()
})
test('Deleting a node inside a subgraph removes its missing node-type error', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('missing/missing_nodes_in_subgraph')
const errorOverlay = comfyPage.page.getByTestId(
TestIds.dialogs.errorOverlay
)
await expect(errorOverlay).toBeVisible()
await errorOverlay
.getByTestId(TestIds.dialogs.errorOverlayDismiss)
.click()
const missingNodeGroup = comfyPage.page.getByTestId(
TestIds.dialogs.missingNodePacksGroup
)
await openErrorsTab(comfyPage)
await expect(missingNodeGroup).toBeVisible()
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('2')
await subgraphNode.navigateIntoSubgraph()
// Select-all + Delete: interior node IDs may be reassigned during
// subgraph configure when they collide with root-graph IDs, so
// looking up by static id can fail.
await comfyPage.keyboard.selectAll()
await comfyPage.page.keyboard.press('Delete')
await expect(missingNodeGroup).toBeHidden()
})
test('Bypassing a node inside a subgraph hides its error, un-bypassing restores it', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'missing/missing_models_in_subgraph'
)
const errorOverlay = comfyPage.page.getByTestId(
TestIds.dialogs.errorOverlay
)
await expect(errorOverlay).toBeVisible()
await errorOverlay
.getByTestId(TestIds.dialogs.errorOverlayDismiss)
.click()
const missingModelGroup = comfyPage.page.getByTestId(
TestIds.dialogs.missingModelsGroup
)
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('2')
await subgraphNode.navigateIntoSubgraph()
await comfyPage.keyboard.selectAll()
await comfyPage.keyboard.bypass()
const errorsTab = comfyPage.page.getByTestId(
TestIds.propertiesPanel.errorsTab
)
await comfyPage.actionbar.propertiesButton.click()
await expect(errorsTab).toBeHidden()
await comfyPage.keyboard.selectAll()
await comfyPage.keyboard.bypass()
await openErrorsTab(comfyPage)
await expect(missingModelGroup).toBeVisible()
})
test('Loading a workflow with bypassed subgraph suppresses interior missing model error', async ({
comfyPage
}) => {
// Regression: the initial scan pipeline only checked each node's
// own mode, so interior nodes of a bypassed subgraph container
// surfaced errors even though the container was excluded from
// execution. The pipeline now post-filters candidates whose
// ancestor path is not fully active.
await comfyPage.workflow.loadWorkflow(
'missing/missing_models_in_bypassed_subgraph'
)
const errorOverlay = comfyPage.page.getByTestId(
TestIds.dialogs.errorOverlay
)
await expect(errorOverlay).toBeHidden()
await comfyPage.actionbar.propertiesButton.click()
await expect(
comfyPage.page.getByTestId(TestIds.propertiesPanel.errorsTab)
).toBeHidden()
})
test('Entering a bypassed subgraph does not resurface interior missing model error', async ({
comfyPage
}) => {
// Regression: useGraphNodeManager replays graph.onNodeAdded for
// each interior node on subgraph entry; without an ancestor-aware
// guard in scanSingleNodeErrors, that re-scan reintroduced the
// error that the initial pipeline had correctly suppressed.
await comfyPage.workflow.loadWorkflow(
'missing/missing_models_in_bypassed_subgraph'
)
const errorsTab = comfyPage.page.getByTestId(
TestIds.propertiesPanel.errorsTab
)
await comfyPage.actionbar.propertiesButton.click()
await expect(errorsTab).toBeHidden()
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('2')
await subgraphNode.navigateIntoSubgraph()
await expect(errorsTab).toBeHidden()
})
})
test.describe('Workflow switching', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting(
'Comfy.Workflow.WorkflowTabsPosition',
'Sidebar'
)
await comfyPage.menu.workflowsTab.open()
})
test('Restores missing nodes in errors tab when switching back to workflow', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('missing/missing_nodes')
const errorOverlay = comfyPage.page.getByTestId(
TestIds.dialogs.errorOverlay
)
await expect(errorOverlay).toBeVisible()
await errorOverlay
.getByTestId(TestIds.dialogs.errorOverlayDismiss)
.click()
const missingNodeGroup = comfyPage.page.getByTestId(
TestIds.dialogs.missingNodePacksGroup
)
await openErrorsTab(comfyPage)
await expect(missingNodeGroup).toBeVisible()
await comfyPage.menu.workflowsTab.open()
await comfyPage.command.executeCommand('Comfy.NewBlankWorkflow')
await expect(missingNodeGroup).toBeHidden()
await comfyPage.menu.workflowsTab.switchToWorkflow('missing_nodes')
await openErrorsTab(comfyPage)
await expect(missingNodeGroup).toBeVisible()
})
})
})

Binary file not shown.

Before

Width:  |  Height:  |  Size: 90 KiB

After

Width:  |  Height:  |  Size: 90 KiB

View File

@@ -2,7 +2,6 @@ import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
import { TestIds } from '@e2e/fixtures/selectors'
import { openErrorsTab } from '@e2e/tests/propertiesPanel/ErrorsTabHelper'
test.describe('Workflows sidebar', () => {
test.beforeEach(async ({ comfyPage }) => {
@@ -233,7 +232,7 @@ test.describe('Workflows sidebar', () => {
.toEqual('workflow1')
})
test('Restores missing nodes errors silently when switching back to workflow', async ({
test('Reports missing nodes warning again when switching back to workflow', async ({
comfyPage
}) => {
await comfyPage.settings.setSetting(
@@ -255,17 +254,11 @@ test.describe('Workflows sidebar', () => {
await comfyPage.menu.workflowsTab.open()
await comfyPage.command.executeCommand('Comfy.NewBlankWorkflow')
// Switch back to the missing_nodes workflow — overlay should NOT
// reappear (silent restore), but errors tab should have content
// Switch back to the missing_nodes workflow — overlay should reappear
// so users can install missing node packs without a page reload
await comfyPage.menu.workflowsTab.switchToWorkflow('missing_nodes')
await expect(errorOverlay).toBeHidden()
// Errors tab should still show missing nodes after silent restore
await openErrorsTab(comfyPage)
await expect(
comfyPage.page.getByTestId(TestIds.dialogs.missingNodePacksGroup)
).toBeVisible()
await expect(errorOverlay).toBeVisible()
})
test('Can close saved-workflows from the open workflows section', async ({

View File

@@ -1,290 +0,0 @@
import { expect } from '@playwright/test'
import type { TemplateInfo } from '@/platform/workflow/templates/types/template'
import { TemplateIncludeOnDistributionEnum } from '@/platform/workflow/templates/types/template'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
import {
makeTemplate,
mockTemplateIndex
} from '@e2e/fixtures/data/templateFixtures'
import { TestIds } from '@e2e/fixtures/selectors'
const Cloud = TemplateIncludeOnDistributionEnum.Cloud
const Desktop = TemplateIncludeOnDistributionEnum.Desktop
const Local = TemplateIncludeOnDistributionEnum.Local
test.describe(
'Template distribution filtering count',
{ tag: '@cloud' },
() => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.Templates.SelectedModels', [])
await comfyPage.settings.setSetting(
'Comfy.Templates.SelectedUseCases',
[]
)
await comfyPage.settings.setSetting('Comfy.Templates.SelectedRunsOn', [])
await comfyPage.settings.setSetting('Comfy.Templates.SortBy', 'default')
await comfyPage.page.route('**/templates/**.webp', async (route) => {
await route.fulfill({
status: 200,
path: 'browser_tests/assets/example.webp',
headers: {
'Content-Type': 'image/webp',
'Cache-Control': 'no-store'
}
})
})
})
test('displayed count matches visible cards when distribution filter excludes templates', async ({
comfyPage
}) => {
const templates: TemplateInfo[] = [
makeTemplate({
name: 'cloud-1',
title: 'Cloud One',
includeOnDistributions: [Cloud]
}),
makeTemplate({
name: 'cloud-2',
title: 'Cloud Two',
includeOnDistributions: [Cloud]
}),
makeTemplate({
name: 'desktop-hidden',
title: 'Desktop Hidden',
includeOnDistributions: [Desktop]
}),
makeTemplate({
name: 'universal',
title: 'Universal'
})
]
await comfyPage.page.route('**/templates/index.json', async (route) => {
await route.fulfill({
status: 200,
body: JSON.stringify(mockTemplateIndex(templates)),
headers: {
'Content-Type': 'application/json',
'Cache-Control': 'no-store'
}
})
})
await comfyPage.command.executeCommand('Comfy.BrowseTemplates')
await expect(comfyPage.templates.content).toBeVisible()
await expect(comfyPage.templates.allTemplateCards).toHaveCount(3)
const desktopCard = comfyPage.templatesDialog.root.getByTestId(
TestIds.templates.workflowCard('desktop-hidden')
)
await expect(desktopCard).toHaveCount(0)
})
test('filtered count reflects distribution + model filter together', async ({
comfyPage
}) => {
const templates: TemplateInfo[] = [
makeTemplate({
name: 'wan-cloud-1',
title: 'Wan Cloud 1',
models: ['Wan 2.2'],
includeOnDistributions: [Cloud]
}),
makeTemplate({
name: 'wan-cloud-2',
title: 'Wan Cloud 2',
models: ['Wan 2.2'],
includeOnDistributions: [Cloud]
}),
makeTemplate({
name: 'wan-desktop',
title: 'Wan Desktop',
models: ['Wan 2.2'],
includeOnDistributions: [Desktop]
}),
makeTemplate({
name: 'flux-cloud',
title: 'Flux Cloud',
models: ['Flux'],
includeOnDistributions: [Cloud]
})
]
await comfyPage.page.route('**/templates/index.json', async (route) => {
await route.fulfill({
status: 200,
body: JSON.stringify(mockTemplateIndex(templates)),
headers: {
'Content-Type': 'application/json',
'Cache-Control': 'no-store'
}
})
})
await comfyPage.command.executeCommand('Comfy.BrowseTemplates')
await expect(comfyPage.templates.content).toBeVisible()
await comfyPage.templatesDialog.selectModelOption('Wan 2.2')
await expect(comfyPage.templates.allTemplateCards).toHaveCount(2)
const wanDesktopCard = comfyPage.templatesDialog.root.getByTestId(
TestIds.templates.workflowCard('wan-desktop')
)
await expect(wanDesktopCard).toHaveCount(0)
await expect(comfyPage.templatesDialog.resultsCount).toHaveText(
/Showing 2 of 3 templates/i
)
})
test('desktop-only templates never leak into DOM on cloud distribution', async ({
comfyPage
}) => {
const templates: TemplateInfo[] = [
makeTemplate({
name: 'cloud-visible',
title: 'Cloud Visible',
includeOnDistributions: [Cloud]
}),
makeTemplate({
name: 'desktop-leak-check',
title: 'Desktop Leak Check',
includeOnDistributions: [Desktop]
}),
makeTemplate({
name: 'local-leak-check',
title: 'Local Leak Check',
includeOnDistributions: [Local]
})
]
await comfyPage.page.route('**/templates/index.json', async (route) => {
await route.fulfill({
status: 200,
body: JSON.stringify(mockTemplateIndex(templates)),
headers: {
'Content-Type': 'application/json',
'Cache-Control': 'no-store'
}
})
})
await comfyPage.command.executeCommand('Comfy.BrowseTemplates')
await expect(comfyPage.templates.content).toBeVisible()
await expect(comfyPage.templates.allTemplateCards).toHaveCount(1)
await expect(
comfyPage.templatesDialog.root.getByTestId(
TestIds.templates.workflowCard('cloud-visible')
)
).toBeVisible()
await expect(
comfyPage.templatesDialog.root.getByTestId(
TestIds.templates.workflowCard('desktop-leak-check')
)
).toHaveCount(0)
await expect(
comfyPage.templatesDialog.root.getByTestId(
TestIds.templates.workflowCard('local-leak-check')
)
).toHaveCount(0)
})
test('templates without includeOnDistributions are visible on cloud', async ({
comfyPage
}) => {
const templates: TemplateInfo[] = [
makeTemplate({ name: 'unrestricted-1', title: 'Unrestricted 1' }),
makeTemplate({ name: 'unrestricted-2', title: 'Unrestricted 2' }),
makeTemplate({
name: 'cloud-only',
title: 'Cloud Only',
includeOnDistributions: [Cloud]
})
]
await comfyPage.page.route('**/templates/index.json', async (route) => {
await route.fulfill({
status: 200,
body: JSON.stringify(mockTemplateIndex(templates)),
headers: {
'Content-Type': 'application/json',
'Cache-Control': 'no-store'
}
})
})
await comfyPage.command.executeCommand('Comfy.BrowseTemplates')
await expect(comfyPage.templates.content).toBeVisible()
await expect(comfyPage.templates.allTemplateCards).toHaveCount(3)
await expect(comfyPage.templatesDialog.resultsCount).toHaveText(
/Showing 3 of 3 templates/i
)
})
test('clear filters button resets to correct distribution-filtered total', async ({
comfyPage
}) => {
const templates: TemplateInfo[] = [
makeTemplate({
name: 'wan-cloud',
title: 'Wan Cloud',
models: ['Wan 2.2'],
includeOnDistributions: [Cloud]
}),
makeTemplate({
name: 'flux-cloud',
title: 'Flux Cloud',
models: ['Flux'],
includeOnDistributions: [Cloud]
}),
makeTemplate({
name: 'wan-desktop',
title: 'Wan Desktop',
models: ['Wan 2.2'],
includeOnDistributions: [Desktop]
})
]
await comfyPage.page.route('**/templates/index.json', async (route) => {
await route.fulfill({
status: 200,
body: JSON.stringify(mockTemplateIndex(templates)),
headers: {
'Content-Type': 'application/json',
'Cache-Control': 'no-store'
}
})
})
await comfyPage.command.executeCommand('Comfy.BrowseTemplates')
await expect(comfyPage.templates.content).toBeVisible()
await comfyPage.templatesDialog.selectModelOption('Wan 2.2')
await expect(comfyPage.templates.allTemplateCards).toHaveCount(1)
const clearButton = comfyPage.templatesDialog.root.getByRole('button', {
name: /Clear Filters/i
})
await clearButton.click()
await expect(comfyPage.templates.allTemplateCards).toHaveCount(2)
await expect(comfyPage.templatesDialog.resultsCount).toHaveText(
/Showing 2 of 2 templates/i
)
})
}
)

Binary file not shown.

Before

Width:  |  Height:  |  Size: 112 KiB

After

Width:  |  Height:  |  Size: 112 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 57 KiB

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 64 KiB

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 63 KiB

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 63 KiB

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 59 KiB

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 61 KiB

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 63 KiB

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 108 KiB

After

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 107 KiB

After

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 138 KiB

After

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 107 KiB

After

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 52 KiB

After

Width:  |  Height:  |  Size: 52 KiB

View File

@@ -0,0 +1,116 @@
import {
comfyExpect as expect,
comfyPageFixture as test
} from '../../../../fixtures/ComfyPage'
import { TestIds } from '../../../../fixtures/selectors'
test.describe(
'FormDropdown positioning in Vue nodes',
{ tag: ['@widget', '@node'] },
() => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
await comfyPage.workflow.loadWorkflow('widgets/load_image_widget')
await comfyPage.vueNodes.waitForNodes()
})
test('dropdown menu appears directly below the trigger', async ({
comfyPage
}) => {
const node = comfyPage.vueNodes.getNodeByTitle('Load Image')
await expect(node).toBeVisible()
const trigger = node.getByTestId(TestIds.widgets.formDropdownTrigger)
await trigger.first().click()
const menu = comfyPage.page.getByTestId(TestIds.widgets.formDropdownMenu)
await expect(menu).toBeVisible({ timeout: 5000 })
const triggerBox = await trigger.first().boundingBox()
const menuBox = await menu.boundingBox()
expect(triggerBox).toBeTruthy()
expect(menuBox).toBeTruthy()
// Menu top should be near the trigger bottom (within 20px tolerance for padding)
expect(menuBox!.y).toBeGreaterThanOrEqual(
triggerBox!.y + triggerBox!.height - 5
)
expect(menuBox!.y).toBeLessThanOrEqual(
triggerBox!.y + triggerBox!.height + 20
)
// Menu left should be near the trigger left (within 10px tolerance)
expect(menuBox!.x).toBeGreaterThanOrEqual(triggerBox!.x - 10)
expect(menuBox!.x).toBeLessThanOrEqual(triggerBox!.x + 10)
})
test('dropdown menu appears correctly at different zoom levels', async ({
comfyPage
}) => {
for (const zoom of [0.75, 1.5]) {
// Set zoom via canvas
await comfyPage.page.evaluate((scale) => {
const canvas = window.app!.canvas
canvas.ds.scale = scale
canvas.setDirty(true, true)
}, zoom)
await comfyPage.nextFrame()
const node = comfyPage.vueNodes.getNodeByTitle('Load Image')
await expect(node).toBeVisible()
const trigger = node.getByTestId(TestIds.widgets.formDropdownTrigger)
await trigger.first().click()
const menu = comfyPage.page.getByTestId(
TestIds.widgets.formDropdownMenu
)
await expect(menu).toBeVisible({ timeout: 5000 })
const triggerBox = await trigger.first().boundingBox()
const menuBox = await menu.boundingBox()
expect(triggerBox).toBeTruthy()
expect(menuBox).toBeTruthy()
// Menu top should still be near trigger bottom regardless of zoom
expect(menuBox!.y).toBeGreaterThanOrEqual(
triggerBox!.y + triggerBox!.height - 5
)
expect(menuBox!.y).toBeLessThanOrEqual(
triggerBox!.y + triggerBox!.height + 20 * zoom
)
// Close dropdown before next iteration
await comfyPage.page.keyboard.press('Escape')
await expect(menu).not.toBeVisible()
}
})
test('dropdown closes on outside click', async ({ comfyPage }) => {
const node = comfyPage.vueNodes.getNodeByTitle('Load Image')
const trigger = node.getByTestId(TestIds.widgets.formDropdownTrigger)
await trigger.first().click()
const menu = comfyPage.page.getByTestId(TestIds.widgets.formDropdownMenu)
await expect(menu).toBeVisible({ timeout: 5000 })
// Click outside the node
await comfyPage.page.mouse.click(10, 10)
await expect(menu).not.toBeVisible()
})
test('dropdown closes on Escape key', async ({ comfyPage }) => {
const node = comfyPage.vueNodes.getNodeByTitle('Load Image')
const trigger = node.getByTestId(TestIds.widgets.formDropdownTrigger)
await trigger.first().click()
const menu = comfyPage.page.getByTestId(TestIds.widgets.formDropdownMenu)
await expect(menu).toBeVisible({ timeout: 5000 })
await comfyPage.page.keyboard.press('Escape')
await expect(menu).not.toBeVisible()
})
}
)

View File

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

View File

@@ -32,14 +32,21 @@
:aria-label="$t('g.delete')"
@click.stop="deleteBlueprint"
>
<i class="icon-[lucide--trash-2]" />
<i class="icon-[lucide--trash-2] text-xs" />
</button>
<button
:class="cn(ACTION_BTN_CLASS, 'text-muted-foreground')"
:aria-label="$t('icon.bookmark')"
@click.stop="toggleBookmark"
>
<i :class="isBookmarked ? 'pi pi-bookmark-fill' : 'pi pi-bookmark'" />
<i
:class="
cn(
isBookmarked ? 'pi pi-bookmark-fill' : 'pi pi-bookmark',
'text-xs'
)
"
/>
</button>
</div>
</div>
@@ -108,7 +115,7 @@ const ROW_CLASS =
'group/tree-node flex w-full min-w-0 cursor-pointer select-none items-center gap-3 overflow-hidden py-2 outline-none hover:bg-comfy-input rounded'
const ACTION_BTN_CLASS =
'flex size-4 shrink-0 cursor-pointer items-center justify-center rounded-sm border-none bg-transparent text-sm opacity-0 group-hover/tree-node:opacity-100 hover:text-foreground'
'flex size-4 shrink-0 cursor-pointer items-center justify-center rounded-sm border-none bg-transparent opacity-0 group-hover/tree-node:opacity-100 hover:text-foreground'
const { item } = defineProps<{
item: FlattenedItem<RenderedTreeExplorerNode<ComfyNodeDefImpl>>

View File

@@ -175,6 +175,7 @@
<!-- Actual Template Cards -->
<CardContainer
v-for="template in isLoading ? [] : displayTemplates"
v-show="isTemplateVisibleOnDistribution(template)"
:key="template.name"
ref="cardRefs"
size="tall"
@@ -422,6 +423,8 @@ import { useTelemetry } from '@/platform/telemetry'
import { useTemplateWorkflows } from '@/platform/workflow/templates/composables/useTemplateWorkflows'
import type { TemplateInfo } from '@/platform/workflow/templates/types/template'
import { useWorkflowTemplatesStore } from '@/platform/workflow/templates/repositories/workflowTemplatesStore'
import { TemplateIncludeOnDistributionEnum } from '@/platform/workflow/templates/types/template'
import { useSystemStatsStore } from '@/stores/systemStatsStore'
import type { NavGroupData, NavItemData } from '@/types/navTypes'
import { OnCloseKey } from '@/types/widgetTypes'
import { createGridStyle } from '@/utils/gridUtil'
@@ -441,6 +444,29 @@ onMounted(() => {
sessionStartTime.value = Date.now()
})
const systemStatsStore = useSystemStatsStore()
const distributions = computed(() => {
switch (__DISTRIBUTION__) {
case 'cloud':
return [TemplateIncludeOnDistributionEnum.Cloud]
case 'localhost':
return [TemplateIncludeOnDistributionEnum.Local]
case 'desktop':
default:
if (systemStatsStore.systemStats?.system.os === 'darwin') {
return [
TemplateIncludeOnDistributionEnum.Desktop,
TemplateIncludeOnDistributionEnum.Mac
]
}
return [
TemplateIncludeOnDistributionEnum.Desktop,
TemplateIncludeOnDistributionEnum.Windows
]
}
})
// Wrap onClose to track session end
const onClose = () => {
if (isCloud) {
@@ -560,7 +586,7 @@ const {
totalCount,
resetFilters,
loadFuseOptions
} = useTemplateFiltering(navigationFilteredTemplates)
} = useTemplateFiltering(navigationFilteredTemplates, selectedNavItem)
/**
* Coordinates state between the selected navigation item and the sort order to
@@ -826,6 +852,14 @@ const { isLoading } = useAsyncState(
}
)
const isTemplateVisibleOnDistribution = (template: TemplateInfo) => {
return (template.includeOnDistributions?.length ?? 0) > 0
? distributions.value.some((d) =>
template.includeOnDistributions?.includes(d)
)
: true
}
onBeforeUnmount(() => {
cardRefs.value = [] // Release DOM refs
})

View File

@@ -260,26 +260,8 @@ function handleColorSelect(subOption: SubMenuOption) {
hide()
}
function constrainMenuHeight() {
const menuInstance = contextMenu.value as unknown as {
container?: HTMLElement
}
const rootList = menuInstance?.container?.querySelector(
':scope > ul'
) as HTMLElement | null
if (!rootList) return
const rect = rootList.getBoundingClientRect()
const maxHeight = window.innerHeight - rect.top - 8
if (maxHeight > 0) {
rootList.style.maxHeight = `${maxHeight}px`
rootList.style.overflowY = 'auto'
}
}
function onMenuShow() {
isOpen.value = true
requestAnimationFrame(constrainMenuHeight)
}
function onMenuHide() {

View File

@@ -16,7 +16,6 @@ import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
import { useMissingMediaStore } from '@/platform/missingMedia/missingMediaStore'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { useMissingNodesErrorStore } from '@/platform/nodeReplacement/missingNodesErrorStore'
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
@@ -42,7 +41,6 @@ import TabErrors from './errors/TabErrors.vue'
const canvasStore = useCanvasStore()
const executionErrorStore = useExecutionErrorStore()
const missingModelStore = useMissingModelStore()
const missingMediaStore = useMissingMediaStore()
const missingNodesErrorStore = useMissingNodesErrorStore()
const rightSidePanelStore = useRightSidePanelStore()
const settingStore = useSettingStore()
@@ -60,7 +58,6 @@ const activeMissingNodeGraphIds = computed<Set<string>>(() => {
})
const { activeMissingModelGraphIds } = storeToRefs(missingModelStore)
const { activeMissingMediaGraphIds } = storeToRefs(missingMediaStore)
const { findParentGroup } = useGraphHierarchy()
@@ -145,22 +142,13 @@ const hasMissingModelSelected = computed(
)
)
const hasMissingMediaSelected = computed(
() =>
hasSelection.value &&
selectedNodes.value.some((node) =>
activeMissingMediaGraphIds.value.has(String(node.id))
)
)
const hasRelevantErrors = computed(() => {
if (!hasSelection.value) return hasAnyError.value
return (
hasDirectNodeError.value ||
hasContainerInternalError.value ||
hasMissingNodeSelected.value ||
hasMissingModelSelected.value ||
hasMissingMediaSelected.value
hasMissingModelSelected.value
)
})
@@ -299,14 +287,11 @@ function handleTitleCancel() {
@cancel="handleTitleCancel"
@click="isEditing = true"
/>
<button
<i
v-if="!isEditing"
:aria-label="t('rightSidePanel.editTitle')"
class="relative top-[2px] ml-2 size-4 shrink-0 cursor-pointer content-center text-muted-foreground hover:text-base-foreground"
class="relative top-[2px] ml-2 icon-[lucide--pencil] size-4 shrink-0 cursor-pointer content-center text-muted-foreground hover:text-base-foreground"
@click="isEditing = true"
>
<i aria-hidden="true" class="icon-[lucide--pencil] size-4" />
</button>
/>
</template>
<template v-else>
{{ panelTitle }}
@@ -319,7 +304,6 @@ function handleTitleCancel() {
variant="secondary"
size="icon"
data-testid="subgraph-editor-toggle"
:aria-label="t('rightSidePanel.editSubgraph')"
:class="cn(isEditingSubgraph && 'bg-secondary-background-selected')"
@click="
rightSidePanelStore.openPanel(
@@ -354,7 +338,6 @@ function handleTitleCancel() {
:key="tab.value"
class="px-2 py-1 font-inter text-sm transition-all active:scale-95"
:value="tab.value"
:data-testid="`panel-tab-${tab.value}`"
>
{{ tab.label() }}
<i

View File

@@ -104,7 +104,7 @@
<Button
v-else-if="
group.type === 'missing_model' &&
downloadableModels.length > 1
downloadableModels.length > 0
"
variant="secondary"
size="sm"
@@ -293,8 +293,8 @@ const {
errorNodeCache,
missingNodeCache,
missingPackGroups,
filteredMissingModelGroups: missingModelGroups,
filteredMissingMediaGroups: missingMediaGroups,
missingModelGroups,
missingMediaGroups,
swapNodeGroups
} = useErrorGroups(searchQuery, t)

View File

@@ -58,10 +58,8 @@ vi.mock(
})
)
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { useMissingNodesErrorStore } from '@/platform/nodeReplacement/missingNodesErrorStore'
import { isLGraphNode } from '@/utils/litegraphUtil'
import { useErrorGroups } from './useErrorGroups'
function makeMissingNodeType(
@@ -756,48 +754,4 @@ describe('useErrorGroups', () => {
).toBe(true)
})
})
describe('unfiltered vs selection-filtered model/media groups', () => {
it('exposes both unfiltered (missingModelGroups) and filtered (filteredMissingModelGroups)', () => {
const { groups } = createErrorGroups()
expect(groups.missingModelGroups).toBeDefined()
expect(groups.filteredMissingModelGroups).toBeDefined()
expect(groups.missingMediaGroups).toBeDefined()
expect(groups.filteredMissingMediaGroups).toBeDefined()
})
it('missingModelGroups returns total candidates regardless of selection (ErrorOverlay contract)', async () => {
const { store, groups } = createErrorGroups()
store.surfaceMissingModels([
makeModel('a.safetensors', { nodeId: '1', directory: 'checkpoints' }),
makeModel('b.safetensors', { nodeId: '2', directory: 'checkpoints' })
])
// Simulate canvas selection of a single node so the filtered
// variant actually narrows. Without this, both sides return the
// same value trivially and the test can't prove the contract.
vi.mocked(isLGraphNode).mockReturnValue(true)
const canvasStore = useCanvasStore()
canvasStore.selectedItems = fromAny<
typeof canvasStore.selectedItems,
unknown
>([{ id: '1' }])
await nextTick()
// Unfiltered total stays at one group of two models regardless of
// the selection — ErrorOverlay reads this for the overlay label
// and must not shrink with canvas selection.
expect(groups.missingModelGroups.value).toHaveLength(1)
expect(groups.missingModelGroups.value[0].models).toHaveLength(2)
// Filtered variant does narrow under the same selection state —
// this is how the errors tab scopes cards to the selected node.
// Exact filtered output depends on the app.rootGraph lookup
// (mocked to return undefined here); what matters is that the
// filtered shape is a different reference and does not blindly
// mirror the unfiltered one.
expect(groups.filteredMissingModelGroups.value).not.toBe(
groups.missingModelGroups.value
)
})
})
})

View File

@@ -660,106 +660,6 @@ export function useErrorGroups(
]
}
function isAssetErrorInSelection(executionNodeId: string): boolean {
const nodeIds = selectedNodeInfo.value.nodeIds
if (!nodeIds) return true
// Try missing node cache first
const cachedNode = missingNodeCache.value.get(executionNodeId)
if (cachedNode && nodeIds.has(String(cachedNode.id))) return true
// Resolve from graph for model/media candidates
if (app.rootGraph) {
const graphNode = getNodeByExecutionId(app.rootGraph, executionNodeId)
if (graphNode && nodeIds.has(String(graphNode.id))) return true
}
for (const containerExecId of selectedNodeInfo.value
.containerExecutionIds) {
if (executionNodeId.startsWith(`${containerExecId}:`)) return true
}
return false
}
const filteredMissingModelGroups = computed(() => {
if (!selectedNodeInfo.value.nodeIds) return missingModelGroups.value
const candidates = missingModelStore.missingModelCandidates
if (!candidates?.length) return []
const filtered = candidates.filter(
(c) => c.nodeId != null && isAssetErrorInSelection(String(c.nodeId))
)
if (!filtered.length) return []
const map = new Map<
string | null | typeof UNSUPPORTED,
{ candidates: MissingModelCandidate[]; isAssetSupported: boolean }
>()
for (const c of filtered) {
const groupKey =
c.isAssetSupported || !isCloud ? c.directory || null : UNSUPPORTED
const existing = map.get(groupKey)
if (existing) {
existing.candidates.push(c)
} else {
map.set(groupKey, {
candidates: [c],
isAssetSupported: c.isAssetSupported
})
}
}
return Array.from(map.entries())
.sort(([dirA], [dirB]) => {
if (dirA === UNSUPPORTED) return 1
if (dirB === UNSUPPORTED) return -1
if (dirA === null) return 1
if (dirB === null) return -1
return dirA.localeCompare(dirB)
})
.map(([key, { candidates: groupCandidates, isAssetSupported }]) => ({
directory: typeof key === 'string' ? key : null,
models: groupCandidatesByName(groupCandidates),
isAssetSupported
}))
})
const filteredMissingMediaGroups = computed(() => {
if (!selectedNodeInfo.value.nodeIds) return missingMediaGroups.value
const candidates = missingMediaStore.missingMediaCandidates
if (!candidates?.length) return []
const filtered = candidates.filter(
(c) => c.nodeId != null && isAssetErrorInSelection(String(c.nodeId))
)
if (!filtered.length) return []
return groupCandidatesByMediaType(filtered)
})
function buildMissingModelGroupsFiltered(): ErrorGroup[] {
if (!filteredMissingModelGroups.value.length) return []
return [
{
type: 'missing_model' as const,
title: `${t('rightSidePanel.missingModels.missingModelsTitle')} (${filteredMissingModelGroups.value.reduce((count, group) => count + group.models.length, 0)})`,
priority: 2
}
]
}
function buildMissingMediaGroupsFiltered(): ErrorGroup[] {
if (!filteredMissingMediaGroups.value.length) return []
const totalItems = filteredMissingMediaGroups.value.reduce(
(count, group) => count + group.items.length,
0
)
return [
{
type: 'missing_media' as const,
title: `${t('rightSidePanel.missingMedia.missingMediaTitle')} (${totalItems})`,
priority: 3
}
]
}
const allErrorGroups = computed<ErrorGroup[]>(() => {
const groupsMap = new Map<string, GroupEntry>()
@@ -786,18 +686,10 @@ export function useErrorGroups(
? toSortedGroups(regroupByErrorMessage(groupsMap))
: toSortedGroups(groupsMap)
const filterByNode = selectedNodeInfo.value.nodeIds !== null
// Missing nodes are intentionally unfiltered — they represent
// pack-level problems relevant regardless of which node is selected.
return [
...buildMissingNodeGroups(),
...(filterByNode
? buildMissingModelGroupsFiltered()
: buildMissingModelGroups()),
...(filterByNode
? buildMissingMediaGroupsFiltered()
: buildMissingMediaGroups()),
...buildMissingModelGroups(),
...buildMissingMediaGroups(),
...executionGroups
]
})
@@ -835,8 +727,6 @@ export function useErrorGroups(
missingPackGroups,
missingModelGroups,
missingMediaGroups,
filteredMissingModelGroups,
filteredMissingMediaGroups,
swapNodeGroups
}
}

View File

@@ -25,7 +25,7 @@
:aria-label="$t('g.delete')"
@click.stop="deleteBlueprint"
>
<i class="icon-[lucide--trash-2] size-4" />
<i class="icon-[lucide--trash-2] size-3.5" />
</Button>
<Button
variant="muted-textonly"
@@ -33,7 +33,7 @@
:aria-label="$t('g.edit')"
@click.stop="editBlueprint"
>
<i class="icon-[lucide--square-pen] size-4" />
<i class="icon-[lucide--square-pen] size-3.5" />
</Button>
</template>
<template v-else #actions>

View File

@@ -117,7 +117,7 @@ export const useAuthActions = () => {
const accessBillingPortal = wrapWithErrorHandlingAsync<
[targetTier?: BillingPortalTargetTier, openInNewTab?: boolean],
boolean
void
>(async (targetTier, openInNewTab = true) => {
const response = await authStore.accessBillingPortal(targetTier)
if (!response.billing_portal_url) {
@@ -128,11 +128,10 @@ export const useAuthActions = () => {
)
}
if (openInNewTab) {
return window.open(response.billing_portal_url, '_blank') !== null
}
window.open(response.billing_portal_url, '_blank')
} else {
globalThis.location.href = response.billing_portal_url
return true
}
}, reportError)
const fetchBalance = wrapWithErrorHandlingAsync(async () => {

View File

@@ -9,15 +9,7 @@ import {
createTestSubgraph,
createTestSubgraphNode
} from '@/lib/litegraph/src/subgraph/__fixtures__/subgraphHelpers'
import {
LGraphEventMode,
NodeSlotType
} from '@/lib/litegraph/src/types/globalEnums'
import * as missingMediaScan from '@/platform/missingMedia/missingMediaScan'
import { useMissingMediaStore } from '@/platform/missingMedia/missingMediaStore'
import * as missingModelScan from '@/platform/missingModel/missingModelScan'
import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
import { useMissingNodesErrorStore } from '@/platform/nodeReplacement/missingNodesErrorStore'
import { NodeSlotType } from '@/lib/litegraph/src/types/globalEnums'
import { app } from '@/scripts/app'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
@@ -365,474 +357,6 @@ describe('installErrorClearingHooks lifecycle', () => {
})
})
describe('onNodeRemoved clears missing asset errors by execution ID', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
vi.spyOn(app, 'isGraphReady', 'get').mockReturnValue(false)
})
it('removes root-level node missing model error using its local id', () => {
const graph = new LGraph()
const node = new LGraphNode('CheckpointLoaderSimple')
graph.add(node)
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graph)
installErrorClearingHooks(graph)
const modelStore = useMissingModelStore()
modelStore.setMissingModels([
fromAny<
Parameters<typeof modelStore.setMissingModels>[0][number],
unknown
>({
nodeId: String(node.id),
nodeType: 'CheckpointLoaderSimple',
widgetName: 'ckpt_name',
isAssetSupported: false,
name: 'model.safetensors',
isMissing: true
})
])
graph.remove(node)
expect(modelStore.missingModelCandidates).toBeNull()
})
it('removes subgraph interior node missing model error using parentId:nodeId', () => {
// Regression: node.graph is nulled before onNodeRemoved fires, so
// getExecutionIdByNode returned null and removal fell back to the
// local node id. Errors stored under "parentId:nodeId" were never
// removed for subgraph interior nodes.
const subgraph = createTestSubgraph()
const interiorNode = new LGraphNode('CheckpointLoaderSimple')
subgraph.add(interiorNode)
const subgraphNode = createTestSubgraphNode(subgraph, { id: 65 })
const rootGraph = subgraphNode.graph as LGraph
rootGraph.add(subgraphNode)
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(rootGraph)
// Hooks are installed on whichever graph is currently active in
// the canvas; when the user is inside the subgraph, that is the
// graph whose onNodeRemoved fires for interior deletions.
installErrorClearingHooks(subgraph)
const interiorExecId = `${subgraphNode.id}:${interiorNode.id}`
const modelStore = useMissingModelStore()
modelStore.setMissingModels([
fromAny<
Parameters<typeof modelStore.setMissingModels>[0][number],
unknown
>({
nodeId: interiorExecId,
nodeType: 'CheckpointLoaderSimple',
widgetName: 'ckpt_name',
isAssetSupported: false,
name: 'model.safetensors',
isMissing: true
})
])
subgraph.remove(interiorNode)
expect(modelStore.missingModelCandidates).toBeNull()
})
it('removes subgraph interior node missing media and missing node errors', () => {
const subgraph = createTestSubgraph()
const interiorNode = new LGraphNode('LoadImage')
subgraph.add(interiorNode)
const subgraphNode = createTestSubgraphNode(subgraph, { id: 65 })
const rootGraph = subgraphNode.graph as LGraph
rootGraph.add(subgraphNode)
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(rootGraph)
installErrorClearingHooks(subgraph)
const interiorExecId = `${subgraphNode.id}:${interiorNode.id}`
const mediaStore = useMissingMediaStore()
mediaStore.setMissingMedia([
fromAny<
Parameters<typeof mediaStore.setMissingMedia>[0][number],
unknown
>({
nodeId: interiorExecId,
nodeType: 'LoadImage',
widgetName: 'image',
mediaType: 'image',
name: 'cat.png',
isMissing: true
})
])
const nodesStore = useMissingNodesErrorStore()
nodesStore.surfaceMissingNodes([
{
type: 'LoadImage',
nodeId: interiorExecId,
cnrId: undefined,
isReplaceable: false,
replacement: undefined
}
])
subgraph.remove(interiorNode)
expect(mediaStore.missingMediaCandidates).toBeNull()
expect(nodesStore.missingNodesError).toBeNull()
})
})
describe('realtime scan verifies pending cloud candidates', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
vi.spyOn(app, 'isGraphReady', 'get').mockReturnValue(false)
})
it('un-bypass path surfaces pending model candidates after verification', async () => {
const graph = new LGraph()
const node = new LGraphNode('CheckpointLoaderSimple')
graph.add(node)
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graph)
// Cloud mode returns candidates with isMissing: undefined until
// verifyAssetSupportedCandidates resolves them against the assets store.
vi.spyOn(missingModelScan, 'scanNodeModelCandidates').mockReturnValue([
{
nodeId: String(node.id),
nodeType: 'CheckpointLoaderSimple',
widgetName: 'ckpt_name',
isAssetSupported: true,
name: 'cloud_model.safetensors',
isMissing: undefined
}
])
const verifySpy = vi
.spyOn(missingModelScan, 'verifyAssetSupportedCandidates')
.mockImplementation(async (candidates) => {
for (const c of candidates) c.isMissing = true
})
vi.spyOn(missingMediaScan, 'scanNodeMediaCandidates').mockReturnValue([])
installErrorClearingHooks(graph)
// Simulate un-bypass (BYPASS → NEVER_BY_USER is not active; use 0 = active)
node.mode = LGraphEventMode.ALWAYS
graph.onTrigger?.({
type: 'node:property:changed',
nodeId: node.id,
property: 'mode',
oldValue: LGraphEventMode.BYPASS,
newValue: LGraphEventMode.ALWAYS
})
await vi.waitFor(() => {
expect(verifySpy).toHaveBeenCalledOnce()
})
await vi.waitFor(() => {
const store = useMissingModelStore()
expect(store.missingModelCandidates).toHaveLength(1)
expect(store.missingModelCandidates![0].name).toBe(
'cloud_model.safetensors'
)
})
})
it('un-bypass path surfaces pending media candidates after verification', async () => {
const graph = new LGraph()
const node = new LGraphNode('LoadImage')
graph.add(node)
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graph)
vi.spyOn(missingModelScan, 'scanNodeModelCandidates').mockReturnValue([])
vi.spyOn(missingMediaScan, 'scanNodeMediaCandidates').mockReturnValue([
{
nodeId: String(node.id),
nodeType: 'LoadImage',
widgetName: 'image',
mediaType: 'image',
name: 'cloud_image.png',
isMissing: undefined
}
])
const verifySpy = vi
.spyOn(missingMediaScan, 'verifyCloudMediaCandidates')
.mockImplementation(async (candidates) => {
for (const c of candidates) c.isMissing = true
})
installErrorClearingHooks(graph)
node.mode = LGraphEventMode.ALWAYS
graph.onTrigger?.({
type: 'node:property:changed',
nodeId: node.id,
property: 'mode',
oldValue: LGraphEventMode.BYPASS,
newValue: LGraphEventMode.ALWAYS
})
await vi.waitFor(() => {
expect(verifySpy).toHaveBeenCalledOnce()
})
await vi.waitFor(() => {
const store = useMissingMediaStore()
expect(store.missingMediaCandidates).toHaveLength(1)
expect(store.missingMediaCandidates![0].name).toBe('cloud_image.png')
})
})
it('does not add candidates that remain confirmed-present after verification', async () => {
const graph = new LGraph()
const node = new LGraphNode('CheckpointLoaderSimple')
graph.add(node)
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graph)
vi.spyOn(missingModelScan, 'scanNodeModelCandidates').mockReturnValue([
{
nodeId: String(node.id),
nodeType: 'CheckpointLoaderSimple',
widgetName: 'ckpt_name',
isAssetSupported: true,
name: 'present.safetensors',
isMissing: undefined
}
])
vi.spyOn(
missingModelScan,
'verifyAssetSupportedCandidates'
).mockImplementation(async (candidates) => {
for (const c of candidates) c.isMissing = false
})
vi.spyOn(missingMediaScan, 'scanNodeMediaCandidates').mockReturnValue([])
installErrorClearingHooks(graph)
node.mode = LGraphEventMode.ALWAYS
graph.onTrigger?.({
type: 'node:property:changed',
nodeId: node.id,
property: 'mode',
oldValue: LGraphEventMode.BYPASS,
newValue: LGraphEventMode.ALWAYS
})
await new Promise((r) => setTimeout(r, 0))
expect(useMissingModelStore().missingModelCandidates).toBeNull()
})
})
describe('realtime verification staleness guards', () => {
beforeEach(() => {
vi.restoreAllMocks()
setActivePinia(createTestingPinia({ stubActions: false }))
vi.spyOn(app, 'isGraphReady', 'get').mockReturnValue(false)
})
it('skips adding verified model when node was bypassed before verification resolved', async () => {
const graph = new LGraph()
const node = new LGraphNode('CheckpointLoaderSimple')
graph.add(node)
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graph)
vi.spyOn(missingModelScan, 'scanNodeModelCandidates').mockReturnValue([
{
nodeId: String(node.id),
nodeType: 'CheckpointLoaderSimple',
widgetName: 'ckpt_name',
isAssetSupported: true,
name: 'stale_model.safetensors',
isMissing: undefined
}
])
let resolveVerify: (() => void) | undefined
const verifyPromise = new Promise<void>((r) => (resolveVerify = r))
const verifySpy = vi
.spyOn(missingModelScan, 'verifyAssetSupportedCandidates')
.mockImplementation(async (candidates) => {
await verifyPromise
for (const c of candidates) c.isMissing = true
})
vi.spyOn(missingMediaScan, 'scanNodeMediaCandidates').mockReturnValue([])
installErrorClearingHooks(graph)
// Un-bypass: kicks off verification (still pending)
node.mode = LGraphEventMode.ALWAYS
graph.onTrigger?.({
type: 'node:property:changed',
nodeId: node.id,
property: 'mode',
oldValue: LGraphEventMode.BYPASS,
newValue: LGraphEventMode.ALWAYS
})
await vi.waitFor(() => expect(verifySpy).toHaveBeenCalledOnce())
// Bypass again before verification resolves
node.mode = LGraphEventMode.BYPASS
// Verification now resolves with isMissing: true, but staleness
// check must drop the add because node is currently bypassed.
resolveVerify!()
await new Promise((r) => setTimeout(r, 0))
expect(useMissingModelStore().missingModelCandidates).toBeNull()
})
it('skips adding verified media when node is deleted before verification resolved', async () => {
const graph = new LGraph()
const node = new LGraphNode('LoadImage')
graph.add(node)
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graph)
vi.spyOn(missingModelScan, 'scanNodeModelCandidates').mockReturnValue([])
vi.spyOn(missingMediaScan, 'scanNodeMediaCandidates').mockReturnValue([
{
nodeId: String(node.id),
nodeType: 'LoadImage',
widgetName: 'image',
mediaType: 'image',
name: 'deleted_image.png',
isMissing: undefined
}
])
let resolveVerify: (() => void) | undefined
const verifyPromise = new Promise<void>((r) => (resolveVerify = r))
const verifySpy = vi
.spyOn(missingMediaScan, 'verifyCloudMediaCandidates')
.mockImplementation(async (candidates) => {
await verifyPromise
for (const c of candidates) c.isMissing = true
})
installErrorClearingHooks(graph)
node.mode = LGraphEventMode.ALWAYS
graph.onTrigger?.({
type: 'node:property:changed',
nodeId: node.id,
property: 'mode',
oldValue: LGraphEventMode.BYPASS,
newValue: LGraphEventMode.ALWAYS
})
await vi.waitFor(() => expect(verifySpy).toHaveBeenCalledOnce())
// Delete the node before verification completes
graph.remove(node)
resolveVerify!()
await new Promise((r) => setTimeout(r, 0))
expect(useMissingMediaStore().missingMediaCandidates).toBeNull()
})
it('skips adding verified model when rootGraph switched before verification resolved', async () => {
// Workflow A has a pending candidate on node id=1. A is replaced
// by workflow B (fresh LGraph, potentially has a node with the
// same id). Late verification from A must not leak into B.
const graphA = new LGraph()
const nodeA = new LGraphNode('CheckpointLoaderSimple')
graphA.add(nodeA)
const rootSpy = vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graphA)
vi.spyOn(missingModelScan, 'scanNodeModelCandidates').mockReturnValue([
{
nodeId: String(nodeA.id),
nodeType: 'CheckpointLoaderSimple',
widgetName: 'ckpt_name',
isAssetSupported: true,
name: 'stale_from_A.safetensors',
isMissing: undefined
}
])
let resolveVerify: (() => void) | undefined
const verifyPromise = new Promise<void>((r) => (resolveVerify = r))
const verifySpy = vi
.spyOn(missingModelScan, 'verifyAssetSupportedCandidates')
.mockImplementation(async (candidates) => {
await verifyPromise
for (const c of candidates) c.isMissing = true
})
vi.spyOn(missingMediaScan, 'scanNodeMediaCandidates').mockReturnValue([])
installErrorClearingHooks(graphA)
nodeA.mode = LGraphEventMode.ALWAYS
graphA.onTrigger?.({
type: 'node:property:changed',
nodeId: nodeA.id,
property: 'mode',
oldValue: LGraphEventMode.BYPASS,
newValue: LGraphEventMode.ALWAYS
})
await vi.waitFor(() => expect(verifySpy).toHaveBeenCalledOnce())
// Workflow swap: app.rootGraph now points at graphB.
const graphB = new LGraph()
const nodeB = new LGraphNode('CheckpointLoaderSimple')
graphB.add(nodeB)
rootSpy.mockReturnValue(graphB)
resolveVerify!()
await new Promise((r) => setTimeout(r, 0))
// A's verification finished but rootGraph is now B — the late
// result must not be added to the store.
expect(useMissingModelStore().missingModelCandidates).toBeNull()
})
})
describe('scan skips interior of bypassed subgraph containers', () => {
beforeEach(() => {
vi.restoreAllMocks()
setActivePinia(createTestingPinia({ stubActions: false }))
vi.spyOn(app, 'isGraphReady', 'get').mockReturnValue(false)
})
it('does not surface interior missing model when entering a bypassed subgraph', async () => {
// Repro: root has a bypassed subgraph container, interior node is
// itself active. useGraphNodeManager replays `onNodeAdded` for each
// interior node on subgraph entry, which previously reached
// scanSingleNodeErrors without an ancestor check and resurfaced the
// error that the initial pipeline post-filter had correctly dropped.
const subgraph = createTestSubgraph()
const interiorNode = new LGraphNode('CheckpointLoaderSimple')
subgraph.add(interiorNode)
const subgraphNode = createTestSubgraphNode(subgraph, { id: 65 })
subgraphNode.mode = LGraphEventMode.BYPASS
const rootGraph = subgraphNode.graph as LGraph
rootGraph.add(subgraphNode)
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(rootGraph)
// Any scanner output would surface the error if the ancestor guard
// didn't short-circuit first — return a concrete missing candidate.
vi.spyOn(missingModelScan, 'scanNodeModelCandidates').mockReturnValue([
{
nodeId: `${subgraphNode.id}:${interiorNode.id}`,
nodeType: 'CheckpointLoaderSimple',
widgetName: 'ckpt_name',
isAssetSupported: false,
name: 'fake.safetensors',
isMissing: true
}
])
vi.spyOn(missingMediaScan, 'scanNodeMediaCandidates').mockReturnValue([])
installErrorClearingHooks(subgraph)
// Simulate useGraphNodeManager replaying onNodeAdded for existing
// interior nodes after Vue node manager init on subgraph entry.
subgraph.onNodeAdded?.(interiorNode)
await new Promise((r) => setTimeout(r, 0))
expect(useMissingModelStore().missingModelCandidates).toBeNull()
})
})
describe('clearWidgetRelatedErrors parameter routing', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))

View File

@@ -8,42 +8,12 @@
import { useChainCallback } from '@/composables/functional/useChainCallback'
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
import { resolveConcretePromotedWidget } from '@/core/graph/subgraph/resolveConcretePromotedWidget'
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
import type { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import {
LGraphEventMode,
NodeSlotType
} from '@/lib/litegraph/src/types/globalEnums'
import type { LGraphTriggerEvent } from '@/lib/litegraph/src/types/graphTriggers'
import { ChangeTracker } from '@/scripts/changeTracker'
import { isCloud } from '@/platform/distribution/types'
import { assetService } from '@/platform/assets/services/assetService'
import type { MissingMediaCandidate } from '@/platform/missingMedia/types'
import type { MissingModelCandidate } from '@/platform/missingModel/types'
import {
scanNodeModelCandidates,
verifyAssetSupportedCandidates
} from '@/platform/missingModel/missingModelScan'
import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
import {
scanNodeMediaCandidates,
verifyCloudMediaCandidates
} from '@/platform/missingMedia/missingMediaScan'
import { useMissingMediaStore } from '@/platform/missingMedia/missingMediaStore'
import { useMissingNodesErrorStore } from '@/platform/nodeReplacement/missingNodesErrorStore'
import { useNodeReplacementStore } from '@/platform/nodeReplacement/nodeReplacementStore'
import { getCnrIdFromNode } from '@/platform/nodeReplacement/cnrIdUtil'
import { NodeSlotType } from '@/lib/litegraph/src/types/globalEnums'
import { app } from '@/scripts/app'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { useModelToNodeStore } from '@/stores/modelToNodeStore'
import {
collectAllNodes,
getExecutionIdByNode,
getExecutionIdForNodeInGraph,
getNodeByExecutionId,
isAncestorPathActive
} from '@/utils/graphTraversalUtil'
import { getExecutionIdByNode } from '@/utils/graphTraversalUtil'
function resolvePromotedExecId(
rootGraph: LGraph,
@@ -151,210 +121,6 @@ function restoreNodeHooksRecursive(node: LGraphNode): void {
}
}
function isNodeInactive(mode: number): boolean {
return mode === LGraphEventMode.NEVER || mode === LGraphEventMode.BYPASS
}
/** Scan a single node and add confirmed missing model/media to stores.
* For subgraph containers, also scans all active interior nodes. */
function scanAndAddNodeErrors(node: LGraphNode): void {
if (!app.rootGraph) return
if (node.isSubgraphNode?.() && node.subgraph) {
for (const innerNode of collectAllNodes(node.subgraph)) {
if (isNodeInactive(innerNode.mode)) continue
scanSingleNodeErrors(innerNode)
}
return
}
scanSingleNodeErrors(node)
}
function scanSingleNodeErrors(node: LGraphNode): void {
if (!app.rootGraph) return
// Skip when any enclosing subgraph is muted/bypassed. Callers only
// verify each node's own mode; entering a bypassed subgraph (via
// useGraphNodeManager replaying onNodeAdded for existing interior
// nodes) reaches this point without the ancestor check. A null
// execId means the node has no current graph (e.g. detached mid
// lifecycle) — also skip, since we cannot verify its scope.
const execId = getExecutionIdByNode(app.rootGraph, node)
if (!execId || !isAncestorPathActive(app.rootGraph, execId)) return
const modelCandidates = scanNodeModelCandidates(
app.rootGraph,
node,
isCloud
? (nodeType, widgetName) =>
assetService.shouldUseAssetBrowser(nodeType, widgetName)
: () => false,
(nodeType) => useModelToNodeStore().getCategoryForNodeType(nodeType)
)
const confirmedModels = modelCandidates.filter((c) => c.isMissing === true)
if (confirmedModels.length) {
useMissingModelStore().addMissingModels(confirmedModels)
}
// Cloud scans return isMissing: undefined for asset-browser-supported
// widgets until async verification resolves. Without this, realtime
// add/un-bypass paths would silently drop those candidates.
const pendingModels = modelCandidates.filter((c) => c.isMissing === undefined)
if (pendingModels.length) {
void verifyAndAddPendingModels(pendingModels)
}
const mediaCandidates = scanNodeMediaCandidates(app.rootGraph, node, isCloud)
const confirmedMedia = mediaCandidates.filter((c) => c.isMissing === true)
if (confirmedMedia.length) {
useMissingMediaStore().addMissingMedia(confirmedMedia)
}
// Cloud media scans always return isMissing: undefined pending
// verification against the input-assets list.
const pendingMedia = mediaCandidates.filter((c) => c.isMissing === undefined)
if (pendingMedia.length) {
void verifyAndAddPendingMedia(pendingMedia)
}
// Check for missing node type
const originalType = node.last_serialization?.type ?? node.type ?? 'Unknown'
if (!(originalType in LiteGraph.registered_node_types)) {
const execId = getExecutionIdByNode(app.rootGraph, node)
if (execId) {
const nodeReplacementStore = useNodeReplacementStore()
const replacement = nodeReplacementStore.getReplacementFor(originalType)
const store = useMissingNodesErrorStore()
const existing = store.missingNodesError?.nodeTypes ?? []
store.surfaceMissingNodes([
...existing,
{
type: originalType,
nodeId: execId,
cnrId: getCnrIdFromNode(node),
isReplaceable: replacement !== null,
replacement: replacement ?? undefined
}
])
}
}
}
/**
* True when the candidate's node still exists in the current root graph
* and is active. Filters out late verification results for nodes that
* have been bypassed, deleted, or belong to a workflow that is no
* longer current — any of which would reintroduce stale errors.
*/
function isCandidateStillActive(nodeId: unknown): boolean {
if (!app.rootGraph || nodeId == null) return false
const execId = String(nodeId)
const node = getNodeByExecutionId(app.rootGraph, execId)
if (!node) return false
if (isNodeInactive(node.mode)) return false
// Also reject if any enclosing subgraph was bypassed between scan
// kick-off and verification resolving — mirrors the pipeline-level
// ancestor post-filter so realtime and initial-load paths stay
// symmetric.
return isAncestorPathActive(app.rootGraph, execId)
}
async function verifyAndAddPendingModels(
pending: MissingModelCandidate[]
): Promise<void> {
// Capture rootGraph at scan time so a late verification for workflow
// A cannot leak into workflow B after a switch — execution IDs (esp.
// root-level like "1") collide across workflows.
const rootGraphAtScan = app.rootGraph
try {
await verifyAssetSupportedCandidates(pending)
if (app.rootGraph !== rootGraphAtScan) return
const verified = pending.filter(
(c) => c.isMissing === true && isCandidateStillActive(c.nodeId)
)
if (verified.length) useMissingModelStore().addMissingModels(verified)
} catch (error: unknown) {
console.warn('[useErrorClearingHooks] model verification failed:', error)
}
}
async function verifyAndAddPendingMedia(
pending: MissingMediaCandidate[]
): Promise<void> {
const rootGraphAtScan = app.rootGraph
try {
await verifyCloudMediaCandidates(pending)
if (app.rootGraph !== rootGraphAtScan) return
const verified = pending.filter(
(c) => c.isMissing === true && isCandidateStillActive(c.nodeId)
)
if (verified.length) useMissingMediaStore().addMissingMedia(verified)
} catch (error: unknown) {
console.warn('[useErrorClearingHooks] media verification failed:', error)
}
}
function scanAddedNode(node: LGraphNode): void {
if (!app.rootGraph || ChangeTracker.isLoadingGraph) return
if (isNodeInactive(node.mode)) return
scanAndAddNodeErrors(node)
}
function handleNodeModeChange(
localGraph: LGraph,
nodeId: number,
oldMode: number,
newMode: number
): void {
if (!app.rootGraph) return
const wasInactive = isNodeInactive(oldMode)
const isNowInactive = isNodeInactive(newMode)
if (wasInactive === isNowInactive) return
// Find the node by local ID in the graph that fired the event,
// then compute its execution ID relative to the root graph.
const node = localGraph.getNodeById(nodeId)
if (!node) return
const execId = getExecutionIdByNode(app.rootGraph, node)
if (!execId) return
if (isNowInactive) {
removeNodeErrors(node, execId)
} else {
scanAndAddNodeErrors(node)
if (
useMissingModelStore().hasMissingModels ||
useMissingMediaStore().hasMissingMedia ||
useMissingNodesErrorStore().hasMissingNodes
) {
useExecutionErrorStore().showErrorOverlay()
}
}
}
/** Remove all missing asset errors for a node and, if it's a subgraph
* container, for all interior nodes (prefix match on execution ID). */
function removeNodeErrors(node: LGraphNode, execId: string): void {
const modelStore = useMissingModelStore()
const mediaStore = useMissingMediaStore()
const nodesStore = useMissingNodesErrorStore()
modelStore.removeMissingModelsByNodeId(execId)
mediaStore.removeMissingMediaByNodeId(execId)
nodesStore.removeMissingNodesByNodeId(execId)
// For subgraph containers, also remove errors from interior nodes.
// The trailing colon in the prefix is load-bearing: it prevents sibling
// IDs sharing a numeric prefix (e.g. "705" vs "70") from being matched.
if (node.isSubgraphNode?.() && node.subgraph) {
const prefix = `${execId}:`
modelStore.removeMissingModelsByPrefix(prefix)
mediaStore.removeMissingMediaByPrefix(prefix)
nodesStore.removeMissingNodesByPrefix(prefix)
}
}
export function installErrorClearingHooks(graph: LGraph): () => void {
for (const node of graph._nodes ?? []) {
installNodeHooksRecursive(node)
@@ -363,54 +129,20 @@ export function installErrorClearingHooks(graph: LGraph): () => void {
const originalOnNodeAdded = graph.onNodeAdded
graph.onNodeAdded = function (node: LGraphNode) {
installNodeHooksRecursive(node)
// Scan pasted/duplicated nodes for missing models/media.
// Skip during loadGraphData (undo/redo/tab switch) — those are
// handled by the full pipeline or cache restore.
// Deferred to microtask because onNodeAdded fires before
// node.configure() restores widget values.
if (!ChangeTracker.isLoadingGraph) {
queueMicrotask(() => scanAddedNode(node))
}
originalOnNodeAdded?.call(this, node)
}
const originalOnNodeRemoved = graph.onNodeRemoved
graph.onNodeRemoved = function (node: LGraphNode) {
// node.graph is already null by the time onNodeRemoved fires, so
// derive the execution ID from the graph the hook is installed on
// plus node.id. For subgraph interior nodes this yields the full
// "parentId:...:nodeId" path that matches how missing asset errors
// are keyed; without this, removal falls back to the local ID and
// misses subgraph entries.
const execId = app.rootGraph
? getExecutionIdForNodeInGraph(app.rootGraph, graph, node.id)
: String(node.id)
removeNodeErrors(node, execId)
restoreNodeHooksRecursive(node)
originalOnNodeRemoved?.call(this, node)
}
const originalOnTrigger = graph.onTrigger
graph.onTrigger = (event: LGraphTriggerEvent) => {
if (event.type === 'node:property:changed' && event.property === 'mode') {
handleNodeModeChange(
graph,
event.nodeId as number,
event.oldValue as number,
event.newValue as number
)
}
originalOnTrigger?.(event)
}
return () => {
for (const node of graph._nodes ?? []) {
restoreNodeHooksRecursive(node)
}
graph.onNodeAdded = originalOnNodeAdded || undefined
graph.onNodeRemoved = originalOnNodeRemoved || undefined
graph.onTrigger = originalOnTrigger || undefined
}
}

View File

@@ -1,138 +0,0 @@
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { useReconnectingNotification } from '@/composables/useReconnectingNotification'
const mockToastAdd = vi.fn()
const mockToastRemove = vi.fn()
vi.mock('primevue/usetoast', () => ({
useToast: () => ({
add: mockToastAdd,
remove: mockToastRemove
})
}))
vi.mock('vue-i18n', () => ({
useI18n: () => ({
t: (key: string) => key
})
}))
const settingMocks = vi.hoisted(() => ({
disableToast: false
}))
vi.mock('@/platform/settings/settingStore', () => ({
useSettingStore: vi.fn(() => ({
get: vi.fn((key: string) => {
if (key === 'Comfy.Toast.DisableReconnectingToast')
return settingMocks.disableToast
return undefined
})
}))
}))
describe('useReconnectingNotification', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
vi.useFakeTimers()
vi.clearAllMocks()
settingMocks.disableToast = false
})
afterEach(() => {
vi.useRealTimers()
})
it('does not show toast immediately on reconnecting', () => {
const { onReconnecting } = useReconnectingNotification()
onReconnecting()
expect(mockToastAdd).not.toHaveBeenCalled()
})
it('shows error toast after delay', () => {
const { onReconnecting } = useReconnectingNotification()
onReconnecting()
vi.advanceTimersByTime(1500)
expect(mockToastAdd).toHaveBeenCalledWith(
expect.objectContaining({
severity: 'error',
summary: 'g.reconnecting'
})
)
})
it('suppresses toast when reconnected before delay expires', () => {
const { onReconnecting, onReconnected } = useReconnectingNotification()
onReconnecting()
vi.advanceTimersByTime(500)
onReconnected()
vi.advanceTimersByTime(1500)
expect(mockToastAdd).not.toHaveBeenCalled()
expect(mockToastRemove).not.toHaveBeenCalled()
})
it('removes toast and shows success when reconnected after delay', () => {
const { onReconnecting, onReconnected } = useReconnectingNotification()
onReconnecting()
vi.advanceTimersByTime(1500)
mockToastAdd.mockClear()
onReconnected()
expect(mockToastRemove).toHaveBeenCalledWith(
expect.objectContaining({
severity: 'error',
summary: 'g.reconnecting'
})
)
expect(mockToastAdd).toHaveBeenCalledWith(
expect.objectContaining({
severity: 'success',
summary: 'g.reconnected',
life: 2000
})
)
})
it('does nothing when toast is disabled via setting', () => {
settingMocks.disableToast = true
const { onReconnecting, onReconnected } = useReconnectingNotification()
onReconnecting()
vi.advanceTimersByTime(1500)
onReconnected()
expect(mockToastAdd).not.toHaveBeenCalled()
expect(mockToastRemove).not.toHaveBeenCalled()
})
it('does nothing when onReconnected is called without prior onReconnecting', () => {
const { onReconnected } = useReconnectingNotification()
onReconnected()
expect(mockToastAdd).not.toHaveBeenCalled()
expect(mockToastRemove).not.toHaveBeenCalled()
})
it('handles multiple reconnecting events without duplicating toasts', () => {
const { onReconnecting } = useReconnectingNotification()
onReconnecting()
vi.advanceTimersByTime(1500) // first toast fires
onReconnecting() // second reconnecting event
vi.advanceTimersByTime(1500) // second toast fires
expect(mockToastAdd).toHaveBeenCalledTimes(2)
})
})

View File

@@ -1,52 +0,0 @@
import { useTimeoutFn } from '@vueuse/core'
import type { ToastMessageOptions } from 'primevue/toast'
import { useToast } from 'primevue/usetoast'
import { ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { useSettingStore } from '@/platform/settings/settingStore'
const RECONNECT_TOAST_DELAY_MS = 1500
export function useReconnectingNotification() {
const { t } = useI18n()
const toast = useToast()
const settingStore = useSettingStore()
const reconnectingMessage: ToastMessageOptions = {
severity: 'error',
summary: t('g.reconnecting')
}
const reconnectingToastShown = ref(false)
const { start, stop } = useTimeoutFn(
() => {
toast.add(reconnectingMessage)
reconnectingToastShown.value = true
},
RECONNECT_TOAST_DELAY_MS,
{ immediate: false }
)
function onReconnecting() {
if (settingStore.get('Comfy.Toast.DisableReconnectingToast')) return
start()
}
function onReconnected() {
stop()
if (reconnectingToastShown.value) {
toast.remove(reconnectingMessage)
toast.add({
severity: 'success',
summary: t('g.reconnected'),
life: 2000
})
reconnectingToastShown.value = false
}
}
return { onReconnecting, onReconnected }
}

View File

@@ -4,7 +4,6 @@ import { nextTick, ref } from 'vue'
import type { IFuseOptions } from 'fuse.js'
import type { TemplateInfo } from '@/platform/workflow/templates/types/template'
import { TemplateIncludeOnDistributionEnum } from '@/platform/workflow/templates/types/template'
import { useTemplateFiltering } from '@/composables/useTemplateFiltering'
const defaultSettingStore = {
@@ -31,14 +30,6 @@ const defaultRankingStore = {
isLoaded: { value: false }
}
const mockSystemStatsStore = {
systemStats: {
system: {
os: 'linux'
}
}
}
vi.mock('@/platform/settings/settingStore', () => ({
useSettingStore: vi.fn(() => defaultSettingStore)
}))
@@ -47,10 +38,6 @@ vi.mock('@/stores/templateRankingStore', () => ({
useTemplateRankingStore: vi.fn(() => defaultRankingStore)
}))
vi.mock('@/stores/systemStatsStore', () => ({
useSystemStatsStore: vi.fn(() => mockSystemStatsStore)
}))
vi.mock('@/platform/telemetry', () => ({
useTelemetry: vi.fn(() => ({
trackTemplateFilterChanged: vi.fn()
@@ -68,14 +55,11 @@ describe('useTemplateFiltering', () => {
beforeEach(() => {
setActivePinia(createPinia())
vi.clearAllMocks()
vi.stubGlobal('__DISTRIBUTION__', 'localhost')
mockSystemStatsStore.systemStats.system.os = 'linux'
mockGetFuseOptions.mockResolvedValue(null)
})
afterEach(() => {
vi.useRealTimers()
vi.unstubAllGlobals()
})
it('sorts templates by VRAM from low to high and pushes missing values last', () => {
@@ -423,12 +407,14 @@ describe('useTemplateFiltering', () => {
}
])
const currentScope = ref('image')
const {
selectedModels,
activeModels,
inactiveModels,
filteredTemplates
} = useTemplateFiltering(templates)
} = useTemplateFiltering(templates, currentScope)
// Select models from both image and video domains
selectedModels.value = ['Flux', 'Luma']
@@ -439,6 +425,8 @@ describe('useTemplateFiltering', () => {
expect(filteredTemplates.value).toHaveLength(1)
expect(filteredTemplates.value[0].name).toBe('flux-template')
// Switch to video scope with only video templates
currentScope.value = 'video'
templates.value = [
{
name: 'luma-template',
@@ -467,7 +455,11 @@ describe('useTemplateFiltering', () => {
}
])
const { selectedModels, activeModels } = useTemplateFiltering(templates)
const currentScope = ref('image')
const { selectedModels, activeModels } = useTemplateFiltering(
templates,
currentScope
)
// Select a model
selectedModels.value = ['Model1', 'Model2']
@@ -476,358 +468,12 @@ describe('useTemplateFiltering', () => {
expect(activeModels.value).toEqual(['Model1'])
expect(selectedModels.value).toEqual(['Model1', 'Model2'])
// Change scope - selected models should persist
currentScope.value = 'video'
templates.value = []
expect(selectedModels.value).toEqual(['Model1', 'Model2'])
expect(activeModels.value).toEqual([])
})
})
describe('Distribution filtering', () => {
const setDistribution = (distribution: 'desktop' | 'localhost' | 'cloud') =>
vi.stubGlobal('__DISTRIBUTION__', distribution)
const cloudTemplate: TemplateInfo = {
name: 'cloud-only',
description: 'Cloud template',
mediaType: 'image',
mediaSubtype: 'png',
models: ['Wan 2.2'],
includeOnDistributions: [TemplateIncludeOnDistributionEnum.Cloud]
}
const desktopTemplate: TemplateInfo = {
name: 'desktop-only',
description: 'Desktop template',
mediaType: 'image',
mediaSubtype: 'png',
models: ['Wan 2.2'],
includeOnDistributions: [TemplateIncludeOnDistributionEnum.Desktop]
}
const universalTemplate: TemplateInfo = {
name: 'universal',
description: 'Universal template',
mediaType: 'image',
mediaSubtype: 'png',
models: ['Wan 2.2']
}
const multiDistTemplate: TemplateInfo = {
name: 'multi-dist',
description: 'Multi-distribution',
mediaType: 'image',
mediaSubtype: 'png',
includeOnDistributions: [
TemplateIncludeOnDistributionEnum.Cloud,
TemplateIncludeOnDistributionEnum.Desktop
]
}
it('excludes templates not matching the distribution filter', () => {
setDistribution('cloud')
const templates = ref([cloudTemplate, desktopTemplate, universalTemplate])
const { filteredTemplates, filteredCount, totalCount } =
useTemplateFiltering(templates)
expect(filteredTemplates.value.map((t) => t.name)).toEqual([
'cloud-only',
'universal'
])
expect(filteredCount.value).toBe(2)
expect(totalCount.value).toBe(2)
})
it('keeps filteredCount and totalCount consistent with model + distribution filters', () => {
setDistribution('cloud')
const fluxCloudTemplate: TemplateInfo = {
name: 'flux-cloud',
description: 'Flux on cloud',
mediaType: 'image',
mediaSubtype: 'png',
models: ['Flux'],
includeOnDistributions: [TemplateIncludeOnDistributionEnum.Cloud]
}
const templates = ref([cloudTemplate, desktopTemplate, fluxCloudTemplate])
const { selectedModels, filteredTemplates, filteredCount, totalCount } =
useTemplateFiltering(templates)
expect(totalCount.value).toBe(2)
selectedModels.value = ['Wan 2.2']
expect(filteredCount.value).toBe(1)
expect(filteredTemplates.value[0].name).toBe('cloud-only')
})
it('shows all templates when templates have no distribution constraints', () => {
setDistribution('localhost')
const templates = ref([
{
name: 'template-a',
description: 'Template A',
mediaType: 'image',
mediaSubtype: 'png'
},
{
name: 'template-b',
description: 'Template B',
mediaType: 'image',
mediaSubtype: 'png'
}
])
const { filteredCount, totalCount } = useTemplateFiltering(templates)
expect(filteredCount.value).toBe(2)
expect(totalCount.value).toBe(2)
})
it('shows local templates on localhost distribution', () => {
setDistribution('localhost')
const localTemplate: TemplateInfo = {
name: 'local-only',
description: 'Local template',
mediaType: 'image',
mediaSubtype: 'png',
includeOnDistributions: [TemplateIncludeOnDistributionEnum.Local]
}
const templates = ref([localTemplate, cloudTemplate, desktopTemplate])
const { filteredTemplates, filteredCount, totalCount } =
useTemplateFiltering(templates)
expect(filteredCount.value).toBe(1)
expect(totalCount.value).toBe(1)
expect(filteredTemplates.value[0].name).toBe('local-only')
})
it('includes templates with multiple distributions when any match', () => {
setDistribution('cloud')
const templates = ref([multiDistTemplate])
const { filteredCount } = useTemplateFiltering(templates)
expect(filteredCount.value).toBe(1)
})
it('excludes templates with multiple distributions when none match', () => {
setDistribution('localhost')
const templates = ref([multiDistTemplate])
const { filteredCount } = useTemplateFiltering(templates)
expect(filteredCount.value).toBe(0)
})
it('reflects distribution changes after re-creating the composable', () => {
const templates = ref([cloudTemplate, desktopTemplate, universalTemplate])
setDistribution('cloud')
const { filteredTemplates, filteredCount, totalCount } =
useTemplateFiltering(templates)
expect(filteredTemplates.value.map((t) => t.name)).toEqual([
'cloud-only',
'universal'
])
expect(filteredCount.value).toBe(2)
expect(totalCount.value).toBe(2)
setDistribution('desktop')
const {
filteredTemplates: desktopFilteredTemplates,
filteredCount: desktopFilteredCount,
totalCount: desktopTotalCount
} = useTemplateFiltering(templates)
expect(desktopFilteredTemplates.value.map((t) => t.name)).toEqual([
'desktop-only',
'universal'
])
expect(desktopFilteredCount.value).toBe(2)
expect(desktopTotalCount.value).toBe(2)
})
it('excludes desktop-only models and use cases from filter options on cloud', () => {
setDistribution('cloud')
const cloudFlux: TemplateInfo = {
name: 'cloud-flux',
description: 'Flux on cloud',
mediaType: 'image',
mediaSubtype: 'png',
models: ['Flux'],
tags: ['Image Gen'],
includeOnDistributions: [TemplateIncludeOnDistributionEnum.Cloud]
}
const desktopSD: TemplateInfo = {
name: 'desktop-sd',
description: 'SD on desktop',
mediaType: 'image',
mediaSubtype: 'png',
models: ['SD 1.5'],
tags: ['Inpainting'],
includeOnDistributions: [TemplateIncludeOnDistributionEnum.Desktop]
}
const templates = ref([cloudFlux, desktopSD])
const { availableModels, availableUseCases } =
useTemplateFiltering(templates)
expect(availableModels.value).toEqual(['Flux'])
expect(availableModels.value).not.toContain('SD 1.5')
expect(availableUseCases.value).toEqual(['Image Gen'])
expect(availableUseCases.value).not.toContain('Inpainting')
})
it('distribution filter composes with search filter', async () => {
vi.useFakeTimers()
setDistribution('cloud')
const searchableTemplate: TemplateInfo = {
name: 'searchable-cloud',
description: 'A very unique searchable description',
mediaType: 'image',
mediaSubtype: 'png',
includeOnDistributions: [TemplateIncludeOnDistributionEnum.Cloud]
}
const searchableDesktopTemplate: TemplateInfo = {
name: 'searchable-desktop',
description: 'A very unique searchable description',
mediaType: 'image',
mediaSubtype: 'png',
includeOnDistributions: [TemplateIncludeOnDistributionEnum.Desktop]
}
const templates = ref([searchableTemplate, searchableDesktopTemplate])
const { searchQuery, filteredTemplates, filteredCount } =
useTemplateFiltering(templates)
searchQuery.value = 'unique searchable'
await nextTick()
await vi.runOnlyPendingTimersAsync()
await nextTick()
expect(filteredCount.value).toBe(1)
expect(filteredTemplates.value[0].name).toBe('searchable-cloud')
})
it('distribution filter composes with use case filter', () => {
setDistribution('cloud')
const taggedCloudTemplate: TemplateInfo = {
name: 'tagged-cloud',
description: 'Tagged cloud',
mediaType: 'image',
mediaSubtype: 'png',
tags: ['Video'],
includeOnDistributions: [TemplateIncludeOnDistributionEnum.Cloud]
}
const taggedDesktopTemplate: TemplateInfo = {
name: 'tagged-desktop',
description: 'Tagged desktop',
mediaType: 'image',
mediaSubtype: 'png',
tags: ['Video'],
includeOnDistributions: [TemplateIncludeOnDistributionEnum.Desktop]
}
const templates = ref([taggedCloudTemplate, taggedDesktopTemplate])
const { selectedUseCases, filteredTemplates, filteredCount } =
useTemplateFiltering(templates)
selectedUseCases.value = ['Video']
expect(filteredCount.value).toBe(1)
expect(filteredTemplates.value[0].name).toBe('tagged-cloud')
})
it('distribution filter composes with runsOn filter', () => {
setDistribution('cloud')
const apiCloudTemplate: TemplateInfo = {
name: 'api-cloud',
description: 'API cloud',
mediaType: 'image',
mediaSubtype: 'png',
openSource: false,
includeOnDistributions: [TemplateIncludeOnDistributionEnum.Cloud]
}
const apiDesktopTemplate: TemplateInfo = {
name: 'api-desktop',
description: 'API desktop',
mediaType: 'image',
mediaSubtype: 'png',
openSource: false,
includeOnDistributions: [TemplateIncludeOnDistributionEnum.Desktop]
}
const templates = ref([apiCloudTemplate, apiDesktopTemplate])
const { selectedRunsOn, filteredTemplates, filteredCount } =
useTemplateFiltering(templates)
selectedRunsOn.value = ['External or Remote API']
expect(filteredCount.value).toBe(1)
expect(filteredTemplates.value[0].name).toBe('api-cloud')
})
it('stale persisted model selection does not cause zero results', () => {
setDistribution('cloud')
const cloudFlux: TemplateInfo = {
name: 'cloud-flux',
description: 'Flux on cloud',
mediaType: 'image',
mediaSubtype: 'png',
models: ['Flux'],
includeOnDistributions: [TemplateIncludeOnDistributionEnum.Cloud]
}
const templates = ref([cloudFlux])
const {
selectedModels,
activeModels,
inactiveModels,
filteredTemplates,
filteredCount
} = useTemplateFiltering(templates)
selectedModels.value = ['SD 1.5']
expect(activeModels.value).toEqual([])
expect(inactiveModels.value).toEqual(['SD 1.5'])
expect(filteredCount.value).toBe(1)
expect(filteredTemplates.value[0].name).toBe('cloud-flux')
})
it('mac distribution matches templates with mac includeOnDistributions', () => {
setDistribution('desktop')
mockSystemStatsStore.systemStats.system.os = 'darwin'
const macTemplate: TemplateInfo = {
name: 'mac-template',
description: 'Mac only',
mediaType: 'image',
mediaSubtype: 'png',
includeOnDistributions: [TemplateIncludeOnDistributionEnum.Mac]
}
const templates = ref([macTemplate, cloudTemplate])
const { filteredTemplates, filteredCount } =
useTemplateFiltering(templates)
expect(filteredCount.value).toBe(1)
expect(filteredTemplates.value[0].name).toBe('mac-template')
})
})
})

View File

@@ -6,25 +6,11 @@ import type { Ref } from 'vue'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useTelemetry } from '@/platform/telemetry'
import { TemplateIncludeOnDistributionEnum } from '@/platform/workflow/templates/types/template'
import type { TemplateInfo } from '@/platform/workflow/templates/types/template'
import { useSystemStatsStore } from '@/stores/systemStatsStore'
import { useTemplateRankingStore } from '@/stores/templateRankingStore'
import { debounce } from 'es-toolkit/compat'
import { api } from '@/scripts/api'
/**
* Checks whether a template is visible for the given set of distributions.
* Templates without `includeOnDistributions` are visible everywhere.
*/
function isTemplateVisibleForDistributions(
template: TemplateInfo,
distributions: TemplateIncludeOnDistributionEnum[]
): boolean {
if (!template.includeOnDistributions?.length) return true
return distributions.some((d) => template.includeOnDistributions!.includes(d))
}
// Fuse.js configuration for fuzzy search
const defaultFuseOptions: IFuseOptions<TemplateInfo> = {
keys: [
@@ -40,10 +26,10 @@ const defaultFuseOptions: IFuseOptions<TemplateInfo> = {
}
export function useTemplateFiltering(
templates: Ref<TemplateInfo[]> | TemplateInfo[]
templates: Ref<TemplateInfo[]> | TemplateInfo[],
currentScope?: Ref<string | null>
) {
const settingStore = useSettingStore()
const systemStatsStore = useSystemStatsStore()
const rankingStore = useTemplateRankingStore()
const searchQuery = ref('')
@@ -73,40 +59,11 @@ export function useTemplateFiltering(
return Array.isArray(templateData) ? templateData : []
})
const distributions = computed<TemplateIncludeOnDistributionEnum[]>(() => {
switch (__DISTRIBUTION__) {
case 'cloud':
return [TemplateIncludeOnDistributionEnum.Cloud]
case 'localhost':
return [TemplateIncludeOnDistributionEnum.Local]
case 'desktop':
default:
if (systemStatsStore.systemStats?.system.os === 'darwin') {
return [
TemplateIncludeOnDistributionEnum.Desktop,
TemplateIncludeOnDistributionEnum.Mac
]
}
return [
TemplateIncludeOnDistributionEnum.Desktop,
TemplateIncludeOnDistributionEnum.Windows
]
}
})
const visibleTemplates = computed(() => {
return templatesArray.value.filter((t) =>
isTemplateVisibleForDistributions(t, distributions.value)
)
})
const fuse = computed(
() => new Fuse(visibleTemplates.value, fuseOptions.value)
)
const fuse = computed(() => new Fuse(templatesArray.value, fuseOptions.value))
const availableModels = computed(() => {
const modelSet = new Set<string>()
visibleTemplates.value.forEach((template) => {
templatesArray.value.forEach((template) => {
if (Array.isArray(template.models)) {
template.models.forEach((model) => modelSet.add(model))
}
@@ -116,7 +73,7 @@ export function useTemplateFiltering(
const availableUseCases = computed(() => {
const tagSet = new Set<string>()
visibleTemplates.value.forEach((template) => {
templatesArray.value.forEach((template) => {
if (template.tags && Array.isArray(template.tags)) {
template.tags.forEach((tag) => tagSet.add(tag))
}
@@ -129,35 +86,44 @@ export function useTemplateFiltering(
})
// Compute which selected filters are actually applicable to the current scope
const activeModels = computed(() =>
selectedModels.value.filter((model) =>
const activeModels = computed(() => {
if (!currentScope) {
return selectedModels.value
}
return selectedModels.value.filter((model) =>
availableModels.value.includes(model)
)
)
})
const activeUseCases = computed(() =>
selectedUseCases.value.filter((useCase) =>
const activeUseCases = computed(() => {
if (!currentScope) {
return selectedUseCases.value
}
return selectedUseCases.value.filter((useCase) =>
availableUseCases.value.includes(useCase)
)
)
})
const inactiveModels = computed(() =>
selectedModels.value.filter(
// Track which filters are inactive (selected but not applicable)
const inactiveModels = computed(() => {
if (!currentScope) return []
return selectedModels.value.filter(
(model) => !availableModels.value.includes(model)
)
)
})
const inactiveUseCases = computed(() =>
selectedUseCases.value.filter(
const inactiveUseCases = computed(() => {
if (!currentScope) return []
return selectedUseCases.value.filter(
(useCase) => !availableUseCases.value.includes(useCase)
)
)
})
const debouncedSearchQuery = refDebounced(searchQuery, 150)
const filteredBySearch = computed(() => {
if (!debouncedSearchQuery.value.trim()) {
return visibleTemplates.value
return templatesArray.value
}
const results = fuse.value.search(debouncedSearchQuery.value)
@@ -333,7 +299,7 @@ export function useTemplateFiltering(
}
const filteredCount = computed(() => filteredTemplates.value.length)
const totalCount = computed(() => visibleTemplates.value.length)
const totalCount = computed(() => templatesArray.value.length)
// Template filter tracking (debounced to avoid excessive events)
const debouncedTrackFilterChange = debounce(() => {

View File

@@ -182,40 +182,6 @@ describe('Autogrow', () => {
await nextTick()
expect(node.inputs.length).toBe(5)
})
test('Multi-group autogrow shifts second group indices on first group growth', () => {
const graph = new LGraph()
const node = testNode()
graph.add(node)
const imageSpec = { required: { image: ['IMAGE', {}] } }
const videoSpec = { required: { video: ['VIDEO', {}] } }
addAutogrow(node, { min: 1, input: imageSpec, prefix: 'img' })
addAutogrow(node, { min: 1, input: videoSpec, prefix: 'vid' })
expect(node.inputs.map((i) => i.name)).toStrictEqual([
'0.img0',
'0.img1',
'2.vid0',
'2.vid1'
])
connectInput(node, 1, graph)
expect(node.inputs.map((i) => i.name)).toStrictEqual([
'0.img0',
'0.img1',
'0.img2',
'2.vid0',
'2.vid1'
])
const vid0Index = node.inputs.findIndex((i) => i.name === '2.vid0')
expect(vid0Index).toBe(3)
connectInput(node, vid0Index, graph)
const vid0Link = node.inputs[vid0Index].link
expect(vid0Link).not.toBeNull()
expect(graph.links[vid0Link!].target_slot).toBe(vid0Index)
})
test('Can deserialize a complex node', async () => {
const graph = new LGraph()
const node = testNode()

View File

@@ -18,6 +18,7 @@ app.registerExtension({
suggestionsNumber: null,
init(this: SlotDefaultsExtension) {
LiteGraph.search_filter_enabled = true
LiteGraph.middle_click_slot_add_default_node = true
this.suggestionsNumber = app.ui.settings.addSetting({
id: 'Comfy.NodeSuggestions.number',
category: ['Comfy', 'Node Search Box', 'NodeSuggestions'],

View File

@@ -1,7 +1,6 @@
import { t } from '@/i18n'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import { useToastStore } from '@/platform/updates/common/toastStore'
import { useNodeOutputStore } from '@/stores/nodeOutputStore'
import { api } from '../../scripts/api'
import { app } from '../../scripts/app'
@@ -85,7 +84,6 @@ app.registerExtension({
)
const canvas = document.createElement('canvas')
const nodeOutputStore = useNodeOutputStore()
const capture = () => {
// @ts-expect-error widget value type narrow down
@@ -100,7 +98,6 @@ app.registerExtension({
const img = new Image()
img.onload = () => {
node.imgs = [img]
nodeOutputStore.setNodePreviewsByNodeId(node.id, [data])
app.canvas.setDirty(true)
}
img.src = data
@@ -146,10 +143,9 @@ app.registerExtension({
throw new Error(err)
}
const data = await resp.json()
const serverName = data.name || name
const subfolder = data.subfolder || 'webcam'
const type = data.type || 'temp'
return `${subfolder}/${serverName} [${type}]`
const serverName = data.name ?? name
const subfolder = data.subfolder ?? 'webcam'
return `${subfolder}/${serverName} [temp]`
}
// @ts-expect-error fixme ts strict error

View File

@@ -1331,6 +1331,7 @@
"updating": "جارٍ التحديث",
"upload": "رفع",
"uploadAlreadyInProgress": "الرفع جارٍ بالفعل",
"uploadTimedOut": "انتهت مهلة التحميل. يرجى المحاولة مرة أخرى.",
"usageHint": "تلميح الاستخدام",
"use": "استخدم",
"user": "المستخدم",

View File

@@ -3403,8 +3403,6 @@
},
"rightSidePanel": {
"togglePanel": "Toggle properties panel",
"editTitle": "Edit title",
"editSubgraph": "Edit subgraph",
"noSelection": "Select a node to see its properties and info.",
"workflowOverview": "Workflow Overview",
"title": "No item(s) selected | 1 item selected | {count} items selected",

View File

@@ -1331,6 +1331,7 @@
"updating": "Actualizando",
"upload": "Subir",
"uploadAlreadyInProgress": "La carga ya está en curso",
"uploadTimedOut": "La carga ha excedido el tiempo de espera. Por favor, inténtalo de nuevo.",
"usageHint": "Sugerencia de uso",
"use": "Usar",
"user": "Usuario",

View File

@@ -1331,6 +1331,7 @@
"updating": "در حال به‌روزرسانی {id}",
"upload": "بارگذاری",
"uploadAlreadyInProgress": "بارگذاری در حال انجام است",
"uploadTimedOut": "زمان بارگذاری به پایان رسید. لطفاً دوباره تلاش کنید.",
"usageHint": "راهنمای استفاده",
"use": "استفاده",
"user": "کاربر",

View File

@@ -1331,6 +1331,7 @@
"updating": "Mise à jour",
"upload": "Téléverser",
"uploadAlreadyInProgress": "Téléversement déjà en cours",
"uploadTimedOut": "Le téléchargement a expiré. Veuillez réessayer.",
"usageHint": "Conseil d'utilisation",
"use": "Utiliser",
"user": "Utilisateur",

View File

@@ -1331,6 +1331,7 @@
"updating": "更新中",
"upload": "アップロード",
"uploadAlreadyInProgress": "アップロードはすでに進行中です",
"uploadTimedOut": "アップロードがタイムアウトしました。もう一度お試しください。",
"usageHint": "使用ヒント",
"use": "使用",
"user": "ユーザー",

View File

@@ -1331,6 +1331,7 @@
"updating": "업데이트 중",
"upload": "업로드",
"uploadAlreadyInProgress": "업로드가 이미 진행 중입니다",
"uploadTimedOut": "업로드 시간이 초과되었습니다. 다시 시도해 주세요.",
"usageHint": "사용 힌트",
"use": "사용",
"user": "사용자",

View File

@@ -1331,6 +1331,7 @@
"updating": "Atualizando {id}",
"upload": "Enviar",
"uploadAlreadyInProgress": "O upload já está em andamento",
"uploadTimedOut": "O envio excedeu o tempo limite. Por favor, tente novamente.",
"usageHint": "Dica de uso",
"use": "Usar",
"user": "Usuário",

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