Compare commits

..

40 Commits

Author SHA1 Message Date
Deep Mehta
1bb8873e05 merge: resolve conflicts with main (MODEL_NODE_MAPPINGS refactor) 2026-03-25 17:26:13 -07:00
Yourz
6c7c3ea006 fix: tree explorer row height and width overflow (#10501)
## Summary

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

## Changes

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

## Review Focus

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

## Screenshots (if applicable)

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

N/A

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

---------

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

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

## Changes

- **What**: add extra binding entry

## Screenshots (if applicable)

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

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10528-fix-App-mode-workaround-for-alt-m-producing-alt-on-mac-32e6d73d36508176b7d6d918c5bb88f3)
by [Unito](https://www.unito.io)
2026-03-25 16:15:41 -07:00
Comfy Org PR Bot
c5e9e52e5f 1.43.5 (#10489)
Patch version increment to 1.43.5

**Base branch:** `main`

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

---------

Co-authored-by: christian-byrne <72887196+christian-byrne@users.noreply.github.com>
Co-authored-by: Christian Byrne <cbyrne@comfy.org>
Co-authored-by: github-actions <github-actions@github.com>
2026-03-25 14:55:05 -07:00
Christian Byrne
fa1ffcba01 fix: handle clipboard errors in Copy Image and useCopyToClipboard (#9299)
## Summary

Fix unhandled promise rejection ("Document is not focused") in Copy
Image and improve clipboard fallback reliability.

## Changes

- **What**: Two clipboard fixes:
1. `litegraphService.ts`: The "Copy Image" context menu passed async
`writeImage` as a callback to `canvas.toBlob()` without awaiting —
errors became unhandled promise rejections reported in [Sentry
CLOUD-FRONTEND-STAGING-AQ](https://comfy-org.sentry.io/issues/6948073569/).
Extracted `convertToPngBlob` helper that wraps `toBlob` in a proper
Promise so errors propagate to the existing outer try/catch and surface
as a user-facing toast instead of a silent Sentry error.
2. `useCopyToClipboard.ts`: Replaced `useClipboard({ legacy: true })`
with explicit modern→legacy fallback that checks
`document.execCommand('copy')` return value. VueUse's `legacyCopy` sets
`copied.value = true` regardless of whether `execCommand` succeeded,
causing false success toasts.

## Review Focus

- The `convertToPngBlob` helper does the same canvas→PNG work as the old
inline code but properly awaited
- The happy path (PNG clipboard write succeeds first try) is unchanged
- No public API surface changes — verified zero custom node dependencies
via ecosystem code search

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9299-fix-handle-clipboard-errors-in-Copy-Image-and-useCopyToClipboard-3156d73d3650817c8608cba861ee64a9)
by [Unito](https://www.unito.io)
2026-03-25 12:20:33 -07:00
Christian Byrne
f1db1122f3 fix: allow URI drops to bubble from Vue nodes to document handler (#9463)
## Summary

Fix URI drops (e.g. dragging `<img>` thumbnails) onto Vue-rendered nodes
by letting unhandled drops bubble to the document-level `text/uri-list`
fallback in `app.ts`.

## Changes

- **What**: Removed unconditional `.stop` modifier from `@drop` in
`LGraphNode.vue`. `stopPropagation()` is now called conditionally — only
when `onDragDrop` returns `true` (file drop handled). Made `handleDrop`
synchronous since `onDragDrop` returns a plain boolean.

## Review Focus

The key insight is that `onDragDrop` (from `useNodeDragAndDrop`) returns
`false` synchronously for URI drags (no files in `DataTransfer`), so the
event must bubble to reach the document handler that fetches the URI.
The original `async` + `await` pattern would have deferred
`stopPropagation` past the synchronous propagation phase, so
`handleDrop` is now synchronous.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9463-fix-allow-URI-drops-to-bubble-from-Vue-nodes-to-document-handler-31b6d73d36508196a1b3f17e7e4837a9)
by [Unito](https://www.unito.io)
2026-03-25 12:19:38 -07:00
Christian Byrne
f56abb3ecf test: add regression tests for subgraph slot label propagation (#10013)
## Summary

Add regression tests for subgraph slot label propagation. The
OutputSlot.vue fix (adding `slotData.label` to the display template) was
already merged via another PR — this adds tests to prevent future
regressions.

## Changes

- **What**: Two new test files covering the label/localized_name
fallback chain in OutputSlot.vue and SubgraphNode label propagation
through configure() and rename event paths.

## Review Focus

Tests only — no production code changes. Verifies that renamed subgraph
inputs/outputs display correctly in Nodes 2.0 mode.

Fixes #9998

<!-- Pipeline-Ticket: 7d887122-eea5-45f1-b6eb-aed94f708555 -->
2026-03-25 12:19:08 -07:00
Benjamin Lu
95c6811f59 fix: remove unused Playwright hook config args (#10513)
## Summary

Remove the unused `_config` parameter from the Playwright global
setup/teardown hooks and drop the now-unused `FullConfig` imports.

## Changes

- **What**: Simplified `browser_tests/globalSetup.ts` and
`browser_tests/globalTeardown.ts` to match actual usage.

## Review Focus

Verify that removing the unused hook argument does not change Playwright
behavior.

## Screenshots (if applicable)

N/A

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10513-fix-remove-unused-Playwright-hook-config-args-32e6d73d365081d59b63dbbca0596025)
by [Unito](https://www.unito.io)

Co-authored-by: Alexander Brown <drjkl@comfy.org>
2026-03-25 19:18:15 +00:00
Christian Byrne
88079250eb chore: add CI safety rules to backport-management skill (#10164)
Adds lessons learned from a bulk backport session where 69 PRs were
admin-merged without CI checks, shipping 3 test failures to core/1.41.

**Changes:**
- **SKILL.md**: CI Safety Rules section, wave verification with `pnpm
test:unit`, continuous backporting recommendation, Never Admin-Merge
Without CI lesson
- **execution.md**: Wait-for-CI step after automation, `gh pr checks
--watch` for manual cherry-picks, CI Failure Triage section with common
failure categories
- **logging.md**: Wave verification log template, CI failure report
table in session report

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10164-chore-add-CI-safety-rules-to-backport-management-skill-3266d73d365081aa856de1fb85a31887)
by [Unito](https://www.unito.io)

---------

Co-authored-by: GitHub Action <action@github.com>
2026-03-25 12:17:43 -07:00
Alexander Brown
08ea013c51 fix: prune stale proxyWidgets referencing nodes removed by nested subgraph packing (#10390)
## Summary

Prune stale proxyWidgets entries that reference grandchild nodes no
longer present in the outer subgraph after nested packing.

## Changes

- **What**: Filter out proxyWidgets entries during hydration when the
source node doesn't exist in the subgraph. Also skip missing-node
entries in `_pruneStaleAliasFallbackEntries` as defense-in-depth. Write
back cleaned entries so stale data doesn't persist.

## Review Focus

The fix touches two codepaths in `SubgraphNode.ts`:
1. **Hydration** (`_internalConfigureAfterSlots`): Added `getNodeById`
guard before accepting a proxyWidget entry, and broadened the write-back
condition from legacy-only to any filtered entries.
2. **Runtime pruning** (`_pruneStaleAliasFallbackEntries`): Added
early-exit for entries whose source node no longer exists — previously
these survived because failed resolution returned `undefined` which
bypassed the concrete-key comparison.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10390-fix-prune-stale-proxyWidgets-referencing-nodes-removed-by-nested-subgraph-packing-32b6d73d365081e69eedcb2b67d7043d)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Amp <amp@ampcode.com>
2026-03-25 12:15:52 -07:00
Benjamin Lu
4aae52c2fc test: move getNodeDefs spec into src/scripts (#10503)
## Summary

Move the `getNodeDefs` unit test out of deprecated `tests-ui` and into
`src/scripts` so Vitest discovers and runs it.

## Changes

- **What**: Renamed `tests-ui/tests/scripts/app.getNodeDefs.test.ts` to
`src/scripts/app.getNodeDefs.test.ts`

## Review Focus

Confirm the spec now follows the colocated test convention and is
included by the existing Vitest `include` globs.

## Screenshots (if applicable)

N/A

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10503-test-move-getNodeDefs-spec-into-src-scripts-32e6d73d3650816f9211dc4c20daba4b)
by [Unito](https://www.unito.io)
2026-03-25 12:15:18 -07:00
Benjamin Lu
6b7691422b fix: use named dotenv config imports (#10514)
## Summary

Use named `dotenv` config imports where we were calling
`dotenv.config()` so ESLint and IDEs stop flagging
`import-x/no-named-as-default-member`.

## Changes

- **What**: Replace default `dotenv` imports plus `.config()` member
access with `import { config as dotenvConfig } from 'dotenv'` in browser
test setup/fixture files and the desktop Vite config.
- **What**: Keep behavior unchanged while aligning those files with the
cleaner import form already used elsewhere in the repo.

## Review Focus

This is a no-behavior-change cleanup. The issue was that `dotenv`
exposes `config` both as a named export and as a property on the
default-exported module object, so `import dotenv from 'dotenv';
dotenv.config()` triggers `import-x/no-named-as-default-member` even
though it works at runtime.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10514-fix-use-named-dotenv-config-imports-32e6d73d36508195b346dbcab764a6b8)
by [Unito](https://www.unito.io)
2026-03-25 12:10:20 -07:00
Christian Byrne
437f41c553 perf: add layout/GC metrics + reduce false positives in regression detection (#10477)
## Summary

Add layout duration, style recalc duration, and heap usage metrics to CI
perf reports, while improving statistical reliability to reduce false
positive regressions.

## Changes

- **What**:
- Collect `layoutDurationMs`, `styleRecalcDurationMs`, `heapUsedBytes`
(absolute snapshot) alongside existing metrics
- Add effect size gate (`minAbsDelta`) for integer-quantized count
metrics (style recalcs, layouts, DOM nodes, event listeners) — prevents
z=7.2 false positives from e.g. 11→12 style recalcs
- Switch from mean to **median** for PR metric aggregation — robust to
outlier CI runs that dominate n=3 mean
- Increase historical baseline window from **5 to 15 runs** for more
stable σ estimates
- Reorder reported metrics: layout/style duration first (actionable),
counts and heap after (informational)

## Review Focus

The effect size gate in `classifyChange()` — it now requires both z > 2
AND absolute delta ≥ `minAbsDelta` (when configured) to flag a
regression. This addresses the core false positive issue where integer
metrics with near-zero historical variance produce extreme z-scores for
trivial changes.

Median vs mean tradeoff: median is more robust to outliers but less
sensitive to real shifts — acceptable given n=3 and CI noise levels.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10477-perf-add-layout-GC-metrics-reduce-false-positives-in-regression-detection-32d6d73d365081daa72cec96d8a07b90)
by [Unito](https://www.unito.io)
2026-03-25 10:16:56 -07:00
ComfyUI Wiki
975393b48b fix: restore is_template tracking for app mode templates (#10252)
## Summary

App mode templates (names ending in `.app`, e.g.
`templates-qwen_multiangle.app`) were never counted as template
executions in Mixpanel because `getExecutionContext` used
`activeWorkflow.filename` for the `knownTemplateNames` lookup — but
`getFilenameDetails` treats `.app.json` as a compound extension and
strips it entirely, leaving `"templates-qwen_multiangle"` instead of
`"templates-qwen_multiangle.app"`. The set lookup always returned
`false`, so every execution was sent with `is_template: false`.

## Changes

- **Fix**: derive the template lookup key from
`fullFilename.replace(/\.json$/i, '')` instead of `filename`, which
preserves the `.app` suffix and correctly matches `knownTemplateNames`
- **Also fixes**: `workflow_name`, `getTemplateByName`, and
`getEnglishMetadata` calls in the same branch now use the corrected name
- **Tests**: three new cases in `MixpanelTelemetryProvider.test.ts` —
regular template, `.app` template (regression), and non-template

## Before / After

| Template name in index | `activeWorkflow.filename` | `fullFilename` →
stripped | `is_template` |
|---|---|---|---|
| `flux-dev` | `flux-dev` | `flux-dev` |  true |
| `templates-qwen_multiangle.app` | `templates-qwen_multiangle`  |
`templates-qwen_multiangle.app`  | fixed: true |

## Review Focus

The change is confined to `getExecutionContext.ts`. `fullFilename` is
always set (it is assigned in `UserFile` constructor from
`getPathDetails`), so no null-safety issue.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10252-fix-restore-is_template-tracking-for-app-mode-templates-3276d73d365081d4b998edc62ad010dc)
by [Unito](https://www.unito.io)
2026-03-25 21:31:09 +08:00
Luke Mino-Altherr
a44fa1fdd5 fix: tighten date detection regex in formatJsonValue() (#10110)
`formatJsonValue()` uses a loose regex `^\d{4}-\d{2}-\d{2}` to detect
date-like strings, which matches non-date strings like
`"2024-01-01-beta"`.

Changes:
- Require ISO 8601 `T` separator: `/^\d{4}-\d{2}-\d{2}T/`
- Validate parse result with `!Number.isNaN(date.getTime())`
- Use `d()` i18n formatter for consistency with `formatDate()` in the
same file
2026-03-24 19:46:58 -07:00
Christian Byrne
cc3acebceb feat: scaffold Astro 5 website app + design-system base.css [1/3] (#10140)
## Summary
Scaffolds the new apps/website/ Astro 5 + Vue 3 marketing site inside
the monorepo.

## Changes
- apps/website/ with package.json, astro.config.mjs, tsconfig, Nx
targets
- @comfyorg/design-system/css/base.css — brand tokens + fonts (no
PrimeVue)
- pnpm-workspace.yaml catalog entries for Astro deps
- .gitignore and env.d.ts for Astro

## Stack (via Graphite)
- **[1/3] Scaffold** ← this PR
- #10141 [2/3] Layout Shell
- #10142 [3/3] Homepage Sections

Part of the comfy.org website refresh (replacing Framer).

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10140-feat-scaffold-Astro-5-website-app-design-system-base-css-1-3-3266d73d365081688dcee0220a03eca4)
by [Unito](https://www.unito.io)
2026-03-24 19:02:10 -07:00
Alexander Brown
23c22e4c52 🧙 feat: wire ComfyHub publish wizard with profile gate, asset upload, and submission (#10128)
## Summary 🎯

Wire the ComfyHub publish flow end-to-end: profile gate, multi-step
wizard (describe, examples, finish), asset upload, and workflow
submission via hub API.

> *A wizard of steps, from describe to the end,*
> *Upload your assets, your workflows you'll send!*
> *With tags neatly slugged and thumbnails in place,*
> *Your ComfyHub publish is ready to race!* 🏁

## Changes 🔧

- 🌐 **Hub Service** — `comfyHubService` for profile CRUD, presigned
asset uploads, and workflow publish
- 📦 **Submission** — `useComfyHubPublishSubmission` orchestrates file
uploads → publish in one flow
- 🧙 **Wizard Steps** — Describe (name/description/tags) → Examples
(drag-drop reorderable images) → Thumbnail → Finish (profile card +
private-asset warnings)
- 🖼️ **ReorderableExampleImage** — Drag-drop *and* keyboard reordering,
accessible and fun
- 🏷️ **Tag Normalization** — `normalizeTags` slugifies before publishing
- 🔄 **Re-publish Prefill** — Fetches hub workflow metadata on
re-publish, with in-memory cache fallback
- 📐 **Schema Split** — Publish-record schema separated from
hub-workflow-metadata schema
- 🙈 **Unlisted Skip** — No hub-detail prefill fetch for unlisted records
- 👤 **Profile Gate** — Username validation in `useComfyHubProfileGate`
- 🧪 **Tests Galore** — New suites for DescribeStep, ExamplesStep,
WizardContent, PublishSubmission, comfyHubService, normalizeTags, plus
expanded PublishDialog & workflowShareService coverage

## Review Focus 🔍

> *Check the service, the schema, the Zod validation too,*
> *The upload orchestration — does it carry things through?*
> *The prefill fetch strategy: status → detail → cache,*
> *And drag-drop reordering — is it keeping its place?* 🤔

- 🌐 `comfyHubService.ts` — API contract shape, error handling, Zod
parsing
- 📦 `useComfyHubPublishSubmission.ts` — Upload-then-publish flow, edge
cases (no profile, no workflow)
- 🗂️ `ComfyHubPublishDialog.vue` — Prefill fetch strategy (publish
status → hub detail → cache)
- 🖼️ `ReorderableExampleImage.vue` — Drag-drop + keyboard a11y

## Testing 🧪

```bash
pnpm test:unit -- src/platform/workflow/sharing/
pnpm typecheck
```

> *If the tests all turn green and the types all align,*
> *Then merge it on in — this publish flow's fine!* 

---------

Co-authored-by: Amp <amp@ampcode.com>
Co-authored-by: Dante <bunggl@naver.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: bymyself <cbyrne@comfy.org>
Co-authored-by: GitHub Action <action@github.com>
2026-03-25 09:30:25 +09:00
Comfy Org PR Bot
88b54c6775 1.43.4 (#10411)
Patch version increment to 1.43.4

**Base branch:** `main`

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

---------

Co-authored-by: christian-byrne <72887196+christian-byrne@users.noreply.github.com>
Co-authored-by: github-actions <github-actions@github.com>
Co-authored-by: Christian Byrne <cbyrne@comfy.org>
2026-03-24 16:34:02 -07:00
Christian Byrne
e40995fb6c fix: restore Firebase getAdditionalUserInfo for sign-up telemetry OR logic (#10453)
## Summary

Restores `getAdditionalUserInfo` from Firebase Auth so sign-up telemetry
fires when *either* Firebase or the UI context identifies a new user,
fixing a regression from #10388.

## Changes

- **What**: In `loginWithGoogle` and `loginWithGithub`, call
`getAdditionalUserInfo(result)` and OR it with the UI-provided
`options?.isNewUser` flag: `is_new_user: options?.isNewUser ||
additionalUserInfo?.isNewUser || false`. Added 8 parameterized unit
tests covering the OR truth table (Firebase true, UI true, both false,
null result).

## Review Focus

The OR semantics: if either source says new user, we send `sign_up`
telemetry. Previously only the UI flag was checked, which missed cases
where the user lands directly on the OAuth provider without going
through the sign-up view.

## Testing

Unit tests cover all branches of the OR logic. An e2e test is not
feasible here because it would require completing a real OAuth flow with
Google/GitHub (interactive popup, valid credentials, CAPTCHA) and
intercepting the resulting `getAdditionalUserInfo` response from
Firebase — none of which can be reliably automated in a headless
Playwright environment without a live Firebase project seeded with
disposable accounts.

Fixes #10447
2026-03-24 12:39:48 -07:00
Christian Byrne
908a3ea418 fix: resolve subgraph node slot link misalignment during workflow load (#9121)
## Summary

Fix subgraph node slot connector links appearing misaligned after
workflow load, caused by a transform desync between LiteGraph's internal
canvas transform and the Vue TransformPane's CSS transform.

## Changes

- **What**: Changed `syncNodeSlotLayoutsFromDOM` to use DOM-relative
measurement (slot position relative to its parent `[data-node-id]`
element) instead of absolute canvas-space conversion via
`clientPosToCanvasPos`. This makes the slot offset calculation
independent of the global canvas transform, eliminating the frame-lag
desync that occurred when `fitView()` updated `lgCanvas.ds` before the
Vue CSS transform caught up.
- **Cleanup**: Removed the unreachable fallback path that still used
`clientPosToCanvasPos` when the parent node element wasn't found (every
slot element is necessarily a child of a `[data-node-id]` element — if
`closest()` fails the element is detached and measuring is meaningless).
This also removed the `conv` parameter from `syncNodeSlotLayoutsFromDOM`
and `flushScheduledSlotLayoutSync`, and the
`useSharedCanvasPositionConversion` import.
- **Test**: Added a Playwright browser test that loads a subgraph
workflow with `workflowRendererVersion: "LG"` (triggering the 1.2x scale
in `ensureCorrectLayoutScale`) as a template (triggering `fitView`), and
verifies all slot connector positions are within bounds of their parent
node element.

## Review Focus

- The core change is in `useSlotElementTracking.ts` — the new
measurement approach uses `getBoundingClientRect()` on both the slot and
its parent node element, then divides by `currentScale` to get
canvas-space offsets. This is simpler and more robust than the previous
approach.
- SubgraphNodes were disproportionately affected because they are
relatively static and don't often trigger `ResizeObserver`-based
re-syncs that would eventually correct stale offsets.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9121-fix-resolve-subgraph-node-slot-link-misalignment-during-workflow-load-3106d73d365081eca413c84f2e0571d6)
by [Unito](https://www.unito.io)

---------

Co-authored-by: GitHub Action <action@github.com>
Co-authored-by: Alexander Brown <448862+DrJKL@users.noreply.github.com>
Co-authored-by: Amp <amp@ampcode.com>
Co-authored-by: Alexander Brown <drjkl@comfy.org>
2026-03-24 12:28:12 -07:00
AustinMroz
0ae4b78cbc Use native context menu for focused textareas (#10454)
The custom context menu provided by the frontend exposes widget specific
options. In order to support renaming, promotion, and favoriting, there
needs to be a way to access this context menu when targeting a textarea.
However, always displaying this custom context menu will cause the user
to lose access to browser specific functionality like spell checking,
translation, and the ability to copy paste text.

This PR updates the behaviour so that the native browser context menu
will display when the text area already has focus. Our custom frontend
context menu will continue to display when it does not.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10454-Use-native-context-menu-for-focused-textareas-32d6d73d365081909673d81d6a6ba054)
by [Unito](https://www.unito.io)
2026-03-24 12:25:35 -07:00
Christian Byrne
5f14276159 [feat] Improve group title layout (#9839)
Rebased and adopted from #5774 by @felixturner.

## Changes
- Remove unused font-size properties (`NODE_TEXT_SIZE`,
`NODE_SUBTEXT_SIZE`, `DEFAULT_GROUP_FONT`) from theme palettes and color
palette schema
- Replace `DEFAULT_GROUP_FONT`/`DEFAULT_GROUP_FONT_SIZE` with a single
`GROUP_TEXT_SIZE = 20` constant (reduced from 24px)
- Use `NODE_TITLE_HEIGHT` for group header height instead of `font_size
* 1.4`
- Vertically center group title text using `textBaseline = 'middle'`
- Use `GROUP_TEXT_SIZE` directly in TitleEditor instead of per-group
`font_size`
- Remove `font_size` from group serialization (no longer per-group
configurable)

## Original PR
Closes #5774

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9839-feat-Improve-group-title-layout-3216d73d36508112a0edc2a370af20ba)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Felix Turner <felixturner@gmail.com>
Co-authored-by: github-actions <github-actions@github.com>
Co-authored-by: Alexander Brown <drjkl@comfy.org>
2026-03-24 12:25:18 -07:00
Christian Byrne
df81c66725 fix: prevent blueprint cache corruption on repeated placement (#9897)
Add `structuredClone()` in `getBlueprint()` so `_deserializeItems()`
mutations (node IDs, subgraph UUIDs) don't corrupt cached data.

Includes regression test verifying cache immutability.

Fixes COM-16026

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9897-fix-prevent-blueprint-cache-corruption-on-repeated-placement-3226d73d3650815c8594fa8d266e680f)
by [Unito](https://www.unito.io)
2026-03-24 12:24:41 -07:00
Christian Byrne
c5ad6cf1aa fix: migrate V1 tab state pointers during V1→V2 draft migration (#10007)
## Problem

When upgrading from V1 to V2 draft persistence, users' open workflow
tabs were lost. V1 stored tab state (open paths + active index) in
localStorage via `setStorageValue` fallback, but the V1→V2 migration
only migrated draft payloads — not these tab state pointers.

This meant that after upgrading, all previously open tabs disappeared
and users had to manually reopen their workflows.

## Solution

Add `migrateV1TabState()` to the V1→V2 migration path. After draft
payloads are migrated, the function reads the V1 localStorage keys
(`Comfy.OpenWorkflowsPaths` and `Comfy.ActiveWorkflowIndex`) and writes
them to V2's sessionStorage format via `writeOpenPaths()`.

The `clientId` is threaded from `useWorkflowPersistenceV2` (which has
access to `api.clientId`) through to `migrateV1toV2()`.

## Changes

- **`migrateV1toV2.ts`**: Added `migrateV1TabState()` + V1 key constants
for tab state
- **`useWorkflowPersistenceV2.ts`**: Pass `api.clientId` to migration
call
- **`migrateV1toV2.test.ts`**: Two new tests proving tab state migration
works

## Testing

TDD approach — RED commit shows the test failing, GREEN commit shows it
passing.

All 123 persistence tests pass.

- Fixes #9974

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10007-fix-migrate-V1-tab-state-pointers-during-V1-V2-draft-migration-3256d73d36508103b619e521c1b603f5)
by [Unito](https://www.unito.io)
2026-03-24 12:23:59 -07:00
Terry Jia
fe1fae4de1 fix: disable preload error toast triggered by third-party plugin failures (#10445)
## Summary
The preload error toast fires whenever any custom node extension fails
to load via dynamic `import()`. In practice, this is almost always
caused by third-party plugin bugs rather than ComfyUI core issues.
Common triggers include:

- Bare module specifiers (e.g., `import from "vue"`) that the browser
cannot resolve without an import map
- Incorrect relative paths to `scripts/app.js` due to nested web
directory structures
  - Missing dependencies on other extensions (e.g., `clipspace.js`)

Since many users have multiple custom nodes installed, the toast
frequently appears on startup — sometimes multiple times — with a
generic message that offers no actionable guidance. This creates
unnecessary alarm and support burden.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10445-fix-disable-preload-error-toast-triggered-by-third-party-plugin-failures-32d6d73d365081f281efcd6fe90642a5)
by [Unito](https://www.unito.io)
2026-03-24 13:34:34 -04:00
Johnpaul Chiwetelu
38aad02fb9 test: add e2e tests for all default keybindings (#10440)
## Summary
- Add 21 Playwright E2E tests covering all previously untested default
keybindings
- Add `isReadOnly()` and `getOffset()` helpers to `CanvasHelper`

### Tests added
| Group | Keybindings | Count |
|-------|------------|-------|
| Sidebar toggles | `w`, `n`, `m`, `a` | 4 |
| Canvas controls | `Alt+=`, `Alt+-`, `.`, `h`, `v` | 5 |
| Node state | `Alt+c`, `Ctrl+m` | 2 |
| Mode/panel toggles | `Alt+m`, `Alt+Shift+m`, `` Ctrl+` `` | 3 |
| Queue/execution | `Ctrl+Enter`, `Ctrl+Shift+Enter`, `Ctrl+Alt+Enter` |
3 |
| File operations | `Ctrl+s`, `Ctrl+o` | 2 |
| Graph operations | `Ctrl+Shift+e`, `r` | 2 |

## Test plan
- [x] `pnpm exec playwright test defaultKeybindings.spec.ts` — 21/21
pass
- [x] `pnpm typecheck:browser` — clean
- [x] `pnpm lint` — clean

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10440-test-add-e2e-tests-for-all-default-keybindings-32d6d73d36508124a510d4742f9fbdbb)
by [Unito](https://www.unito.io)
2026-03-24 16:12:59 +01:00
pythongosssss
7c4234cf93 feat: App mode - add execution status messages (#10369)
## Summary

Adds custom status messages that are shown under the previews in order
to provide additional progress feedback to the user

Nodes matching the words:

Save, Preview -> Saving
Load, Loader -> Loading
Encode -> Encoding
Decode -> Decoding
Compile, Conditioning, Merge, -> Processing
Upscale, Resize -> Resizing
ToVideo -> Generating video

Specific nodes:
KSampler, KSamplerAdvanced, SamplerCustom, SamplerCustomAdvanced ->
Generating
Video Slice, GetVideoComponents, CreateVideo -> Processing video
TrainLoraNode -> Training


## Changes

- **What**: 
- add specific node lookups for non-easily matchable patterns
- add regex based matching for common patterns
- show on both latent preview & skeleton preview 
- allow app mode workflow authors to override status with custom
property `Execution Message` (no UI for doing this)

## Review Focus

This is purely pattern/lookup based, in future we could update the
backend node schema to allow nodes to define their own status key.

## Screenshots (if applicable)

<img width="757" height="461" alt="image"
src="https://github.com/user-attachments/assets/2b32cc54-c4e7-4aeb-912d-b39ac8428be7"
/>

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10369-feat-App-mode-add-execution-status-messages-32a6d73d3650814e8ca2da5eb33f3b65)
by [Unito](https://www.unito.io)

---------

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2026-03-24 07:58:35 -07:00
jaeone94
4bfc730a0e fix: restore workflow tabs on browser restart (#10336)
## Problem

Since PR #8520 (`feat(persistence): fix QuotaExceededError and
cross-workspace draft leakage`), all workflow tabs are lost when the
browser is closed and reopened.

PR #8520 moved tab pointers (`ActivePath`, `OpenPaths`) from
`localStorage` to `sessionStorage` for per-tab isolation. However,
`sessionStorage` is cleared when the browser closes, so the open tab
list is lost on restart. The draft data itself survives in
`localStorage` — only the pointers to which tabs were open are lost.

Reported in
[Comfy-Org/ComfyUI#12984](https://github.com/Comfy-Org/ComfyUI/issues/12984).
Confirmed via binary search: v1.40.9 (last good) → v1.40.10 (first bad).

## Changes

Dual-write tab pointers to both storage layers:

- **sessionStorage** (scoped by `clientId`) — used for in-session
refresh, preserves per-tab isolation
- **localStorage** (scoped by `workspaceId`) — fallback for browser
restart when sessionStorage is empty

Also adds:
- `storageAvailable` guard on write functions for consistency with
`writeIndex`/`writePayload`
- `isValidPointer` validation on localStorage reads to reject stale or
malformed data

## Benefits

- Workflow tabs survive browser restart (restores V1 behavior)
- Per-tab isolation is preserved for in-session use (sessionStorage is
still preferred when available)

## Trade-offs

- On browser restart, the restored tabs come from whichever browser tab
wrote last to localStorage. If Tab A had workflows 1,2,3 and Tab B had
4,5 — the user gets whichever tab wrote most recently. This is the same
limitation V1 had with `Comfy.OpenWorkflowsPaths` in localStorage.
- Previously (post-#8520), opening a new browser tab would only restore
the single most recent draft. With this fix, a new tab restores the full
set of open tabs from the last session. This may be surprising for
multi-tab users who expect a clean slate in new tabs.

## Test plan

- [x] `pnpm typecheck` passes
- [x] `pnpm lint` passes
- [x] All 121 persistence tests pass
- [x] Manual: open multiple workflow tabs → close browser → reopen →
tabs restored
- [x] Manual: open two browser tabs with different workflows → refresh
each → correct tabs in each

Fixes Comfy-Org/ComfyUI#12984

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10336-fix-restore-workflow-tabs-on-browser-restart-3296d73d365081b7a7d3e91427d08d17)
by [Unito](https://www.unito.io)
<!-- QA_REPORT_SECTION -->
---
## 🔍 Automated QA Report

| | |
|---|---|
| **Status** |  Complete |
| **Report** |
[sno-qa-10336.comfy-qa.pages.dev](https://sno-qa-10336.comfy-qa.pages.dev/)
|
| **CI Run** | [View
workflow](https://github.com/Comfy-Org/ComfyUI_frontend/actions/runs/23373697656)
|

Before/after video recordings with **Behavior Changes** and **Timeline
Comparison** tables.
2026-03-24 17:30:36 +09:00
Yourz
001916edf6 refactor: clean up essentials node organization logic (#10433)
## Summary

Refactor essentials tab node organization to eliminate duplicated logic
and restrict essentials to core nodes only.

## Changes

- **What**: 
- Extract `resolveEssentialsCategory` to centralize category resolution
(was duplicated between filter and pathExtractor).
- Add `isCoreNode` guard so third-party nodes never appear in
essentials.
- Replace `indexOf`-based sorting with precomputed rank maps
(`ESSENTIALS_CATEGORY_RANK`, `ESSENTIALS_NODE_RANK`).

<img width="589" height="769" alt="image"
src="https://github.com/user-attachments/assets/66f41f35-aef5-4e12-97d5-0f33baf0ac45"
/>


## Review Focus

- The `isCoreNode` guard in `resolveEssentialsCategory` — ensures only
core nodes can appear in essentials even if a custom node sets
`essentials_category`.
- Rank map precomputation vs previous `indexOf` — functionally
equivalent but O(1) lookup.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10433-refactor-clean-up-essentials-node-organization-logic-32d6d73d36508193a4d1f7f9c18fcef7)
by [Unito](https://www.unito.io)

Co-authored-by: Amp <amp@ampcode.com>
2026-03-24 16:22:09 +08:00
jaeone94
66daa6d645 refactor: error system cleanup — store separation, DDD fix, test improvements (#10302)
## Summary

Refactors the error system to improve separation of concerns, fix DDD
layer violations, and address code quality issues.

- Extract `missingNodesErrorStore` from `executionErrorStore`, removing
the delegation pattern that coupled missing-node logic into the
execution error store
- Extract `useNodeErrorFlagSync` composable for node error flag
reconciliation (previously inlined)
- Extract `useErrorClearingHooks` composable with explicit callback
cleanup on node removal
- Extract `useErrorActions` composable to deduplicate telemetry+command
patterns across error card components
- Move `getCnrIdFromNode`/`getCnrIdFromProperties` to
`platform/nodeReplacement` layer (DDD fix)
- Move `missingNodesErrorStore` to `platform/nodeReplacement` (DDD
alignment)
- Add unmount cancellation guard to `useErrorReport` async `onMounted`
- Return watch stop handle from `useNodeErrorFlagSync`
- Add `asyncResolvedIds` eviction on `missingNodesError` reset
- Add `console.warn` to silent catch blocks and empty array guard
- Hoist `useCommandStore` to setup scope, fix floating promises
- Add `data-testid` to error groups, image/video error spans, copy
button
- Update E2E tests to use scoped locators and testids
- Add unit tests for `onNodeRemoved` restoration and double-install
guard

Fixes #9875, Fixes #10027, Fixes #10033, Fixes #10085

## Test plan

- [x] Existing unit tests pass with updated imports and mocks
- [x] New unit tests for `useErrorClearingHooks` (callback restoration,
double-install guard)
- [x] E2E tests updated to use scoped locators and `data-testid`
- [ ] Manual: verify error tab shows runtime errors and missing nodes
correctly
- [ ] Manual: verify "Find on GitHub", "Copy", and "Get Help" buttons
work in error cards

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10302-refactor-error-system-cleanup-store-separation-DDD-fix-test-improvements-3286d73d365081838279d045b8dd957a)
by [Unito](https://www.unito.io)

---------

Co-authored-by: GitHub Action <action@github.com>
2026-03-24 16:43:22 +09:00
jaeone94
32a53eeaee fix: sync advanced inputs button color with node header (#10427)
## Summary
- The "Show Advanced Inputs" footer button was missing `headerColor`
style binding, causing it to not sync with the node header color (unlike
the "Enter Subgraph" button which already had it)
- Extracted the repeated `{ backgroundColor: headerColor }` inline style
(4 occurrences) into a `headerColorStyle` computed

## Screenshots 
before 
<img width="211" height="286" alt="스크린샷 2026-03-24 154312"
src="https://github.com/user-attachments/assets/edfd9480-04fa-4cd4-813d-a95adffbe2d3"
/>

after 
<img width="261" height="333" alt="스크린샷 2026-03-24 154622"
src="https://github.com/user-attachments/assets/eab28717-889e-4a6b-8775-bfc08fa727ff"
/>

## Test plan
- [x] Set a custom color on a node with advanced inputs and verify the
footer button matches the header color
- [x] Verify subgraph enter button still syncs correctly
- [x] Verify dual-tab layouts (error + advanced, error + subgraph) both
show correct colors

### Why no E2E test
Node header color is applied as an inline style via `headerColor` prop,
which is already passed and tested through the existing subgraph enter
button path. This change simply extends the same binding to the advanced
inputs buttons — no new data flow or interaction is introduced, so a
screenshot-based E2E test would add maintenance cost without meaningful
regression coverage.
2026-03-24 16:00:55 +09:00
Deep Mehta
a52579dc2d refactor: remove redundant exact match check - loop handles it 2026-03-18 10:14:11 -07:00
Deep Mehta
b41e0009ae Merge branch 'fix/model-to-node-nested-directory-fallback' of https://github.com/Comfy-Org/ComfyUI_frontend into fix/model-to-node-nested-directory-fallback 2026-03-18 10:05:57 -07:00
Deep Mehta
0049de780d refactor: simplify fallback loop to start at segments.length 2026-03-18 10:05:09 -07:00
Deep Mehta
df772c6201 Merge branch 'main' into fix/model-to-node-nested-directory-fallback 2026-03-18 09:45:36 -07:00
Deep Mehta
a624caed6c Merge branch 'fix/model-to-node-nested-directory-fallback' of https://github.com/Comfy-Org/ComfyUI_frontend into fix/model-to-node-nested-directory-fallback 2026-03-18 09:41:31 -07:00
Deep Mehta
013a50ce13 test: add progressive hierarchical fallback tests for 1-4 level paths 2026-03-18 09:40:41 -07:00
Alexander Brown
0d257c79fb Merge branch 'main' into fix/model-to-node-nested-directory-fallback 2026-03-17 23:08:54 -07:00
Deep Mehta
cc4cca616e fix: use empty key for CogVideo HF-download node registrations
DownloadAndLoadCogVideoControlNet and DownloadAndLoadCogVideoModel use
HuggingFace repo names, not file-based assets. Registering with
key='model' causes shouldUseAssetBrowser to replace the combo dropdown
with the asset browser, which finds no matching assets and shows an
empty list.

Use empty key '' to keep "Use" button working while preventing asset
browser takeover — matching the pattern used by other HF-download nodes
(Chatterbox, FlashVSR, LivePortrait, etc).
2026-03-17 21:10:19 -07:00
Deep Mehta
fae8f2024a fix: support progressive fallback for deeply nested model directories
Previously, modelToNode fallback only tried exact match then top-level
segment (e.g., "a/b/c" → "a"), skipping intermediate paths. Now tries
all parent paths progressively ("a/b/c" → "a/b" → "a"), fixing custom
nodes with longer nested directory structures.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 10:07:56 -07:00
190 changed files with 10203 additions and 1619 deletions

View File

@@ -18,12 +18,20 @@ Cherry-pick backport management for Comfy-Org/ComfyUI_frontend stable release br
## System Context
| Item | Value |
| -------------- | ------------------------------------------------- |
| Repo | `~/ComfyUI_frontend` (Comfy-Org/ComfyUI_frontend) |
| Merge strategy | Squash merge (`gh pr merge --squash --admin`) |
| Automation | `pr-backport.yaml` GitHub Action (label-driven) |
| Tracking dir | `~/temp/backport-session/` |
| Item | Value |
| -------------- | --------------------------------------------------------------------------- |
| Repo | `~/ComfyUI_frontend` (Comfy-Org/ComfyUI_frontend) |
| Merge strategy | Auto-merge via workflow (`--auto --squash`); `--admin` only after CI passes |
| Automation | `pr-backport.yaml` GitHub Action (label-driven, auto-merge enabled) |
| Tracking dir | `~/temp/backport-session/` |
## CI Safety Rules
**NEVER merge a backport PR without all CI checks passing.** This applies to both automation-created and manual cherry-pick PRs.
- **Automation PRs:** The `pr-backport.yaml` workflow now enables `gh pr merge --auto --squash`, so clean PRs auto-merge once CI passes. Monitor with polling (`gh pr list --base TARGET_BRANCH --state open`). Do not intervene unless CI fails.
- **Manual cherry-pick PRs:** After `gh pr create`, wait for CI before merging. Poll with `gh pr checks $PR --watch` or use a sleep+check loop. Only merge after all checks pass.
- **CI failures:** DO NOT use `--admin` to bypass failing CI. Analyze the failure, present it to the user with possible causes (test backported without implementation, missing dependency, flaky test), and let the user decide the next step.
## Branch Scope Rules
@@ -108,11 +116,15 @@ git fetch origin TARGET_BRANCH
# Quick smoke check: does the branch build?
git worktree add /tmp/verify-TARGET origin/TARGET_BRANCH
cd /tmp/verify-TARGET
source ~/.nvm/nvm.sh && nvm use 24 && pnpm install && pnpm typecheck
source ~/.nvm/nvm.sh && nvm use 24 && pnpm install && pnpm typecheck && pnpm test:unit
git worktree remove /tmp/verify-TARGET --force
```
If typecheck fails, stop and investigate before continuing. A broken branch after wave N means all subsequent waves will compound the problem.
If typecheck or tests fail, stop and investigate before continuing. A broken branch after wave N means all subsequent waves will compound the problem.
### Never Admin-Merge Without CI
In a previous bulk session, all 69 backport PRs were merged with `gh pr merge --squash --admin`, bypassing required CI checks. This shipped 3 test failures to a release branch. **Lesson: `--admin` skips all branch protection, including required status checks.** Only use `--admin` after confirming CI has passed (e.g., `gh pr checks $PR` shows all green), or rely on auto-merge (`--auto --squash`) which waits for CI by design.
## Continuous Backporting Recommendation

View File

@@ -19,23 +19,44 @@ done
# Wait 3 minutes for automation
sleep 180
# Check which got auto-PRs
# Check which got auto-PRs (auto-merge is enabled, so clean ones will self-merge after CI)
gh pr list --base TARGET_BRANCH --state open --limit 50 --json number,title
```
## Step 2: Review & Merge Clean Auto-PRs
> **Note:** The `pr-backport.yaml` workflow now enables `gh pr merge --auto --squash` on automation-created PRs. Clean PRs will auto-merge once CI passes — no manual merge needed for those.
## Step 2: Wait for CI & Merge Clean Auto-PRs
Most automation PRs will auto-merge once CI passes (via `--auto --squash` in the workflow). Monitor and handle failures:
```bash
for pr in $AUTO_PRS; do
# Check size
gh pr view $pr --json title,additions,deletions,changedFiles \
--jq '"Files: \(.changedFiles), +\(.additions)/-\(.deletions)"'
# Admin merge
gh pr merge $pr --squash --admin
sleep 3
# Wait for CI to complete (~45 minutes for full suite)
sleep 2700
# Check which PRs are still open (CI may have failed, or auto-merge succeeded)
STILL_OPEN_PRS=$(gh pr list --base TARGET_BRANCH --state open --limit 50 --json number --jq '.[].number')
RECENTLY_MERGED=$(gh pr list --base TARGET_BRANCH --state merged --limit 50 --json number,title,mergedAt)
# For PRs still open, check CI status
for pr in $STILL_OPEN_PRS; do
CI_FAILED=$(gh pr checks $pr --json name,state --jq '[.[] | select(.state == "FAILURE")] | length')
CI_PENDING=$(gh pr checks $pr --json name,state --jq '[.[] | select(.state == "PENDING" or .state == "QUEUED")] | length')
if [ "$CI_FAILED" != "0" ]; then
# CI failed — collect details for triage
echo "PR #$pr — CI FAILED:"
gh pr checks $pr --json name,state,link --jq '.[] | select(.state == "FAILURE") | "\(.name): \(.state)"'
elif [ "$CI_PENDING" != "0" ]; then
echo "PR #$pr — CI still running ($CI_PENDING checks pending)"
else
# All checks passed but didn't auto-merge (race condition or label issue)
gh pr merge $pr --squash --admin
sleep 3
fi
done
```
**⚠️ If CI fails: DO NOT admin-merge to bypass.** See "CI Failure Triage" below.
## Step 3: Manual Worktree for Conflicts
```bash
@@ -63,6 +84,13 @@ for PR in ${CONFLICT_PRS[@]}; do
NEW_PR=$(gh pr create --base TARGET_BRANCH --head backport-$PR-to-TARGET \
--title "[backport TARGET] TITLE (#$PR)" \
--body "Backport of #$PR..." | grep -oP '\d+$')
# Wait for CI before merging — NEVER admin-merge without CI passing
echo "Waiting for CI on PR #$NEW_PR..."
gh pr checks $NEW_PR --watch --fail-fast || {
echo "⚠️ CI failed on PR #$NEW_PR — skipping merge, needs triage"
continue
}
gh pr merge $NEW_PR --squash --admin
sleep 3
done
@@ -82,7 +110,7 @@ After completing all PRs in a wave for a target branch:
git fetch origin TARGET_BRANCH
git worktree add /tmp/verify-TARGET origin/TARGET_BRANCH
cd /tmp/verify-TARGET
source ~/.nvm/nvm.sh && nvm use 24 && pnpm install && pnpm typecheck
source ~/.nvm/nvm.sh && nvm use 24 && pnpm install && pnpm typecheck && pnpm test:unit
git worktree remove /tmp/verify-TARGET --force
```
@@ -132,7 +160,8 @@ git rebase origin/TARGET_BRANCH
# Resolve new conflicts
git push --force origin backport-$PR-to-TARGET
sleep 20 # Wait for GitHub to recompute merge state
gh pr merge $PR --squash --admin
# Wait for CI after rebase before merging
gh pr checks $PR --watch --fail-fast && gh pr merge $PR --squash --admin
```
## Lessons Learned
@@ -146,5 +175,31 @@ gh pr merge $PR --squash --admin
7. **appModeStore.ts, painter files, GLSLShader files** don't exist on core/1.40 — `git rm` these
8. **Always validate JSON** after resolving locale file conflicts
9. **Dep refresh PRs** — skip on stable branches. Risk of transitive dep regressions outweighs audit cleanup. Cherry-pick individual CVE fixes instead.
10. **Verify after each wave** — run `pnpm typecheck` on the target branch after merging a batch. Catching breakage early prevents compounding errors.
10. **Verify after each wave** — run `pnpm typecheck && pnpm test:unit` on the target branch after merging a batch. Catching breakage early prevents compounding errors.
11. **Cloud-only PRs don't belong on core/\* branches** — app mode, cloud auth, and cloud-specific UI changes are irrelevant to local users. Always check PR scope against branch scope before backporting.
12. **Never admin-merge without CI**`--admin` bypasses all branch protections including required status checks. A bulk session of 69 admin-merges shipped 3 test failures. Always wait for CI to pass first, or use `--auto --squash` which waits by design.
## CI Failure Triage
When CI fails on a backport PR, present failures to the user using this template:
```markdown
### PR #XXXX — CI Failed
- **Failing check:** test / lint / typecheck
- **Error:** (summary of the failure message)
- **Likely cause:** test backported without implementation / missing dependency / flaky test / snapshot mismatch
- **Recommendation:** backport PR #YYYY first / skip this PR / rerun CI after fixing prerequisites
```
Common failure categories:
| Category | Example | Resolution |
| --------------------------- | ---------------------------------------- | ----------------------------------------- |
| Test without implementation | Test references function not on branch | Backport the implementation PR first |
| Missing dependency | Import from module not on branch | Backport the dependency PR first, or skip |
| Snapshot mismatch | Screenshot test differs | Usually safe — update snapshots on branch |
| Flaky test | Passes on retry | Re-run CI, merge if green on retry |
| Type error | Interface changed on main but not branch | May need manual adaptation |
**Never assume a failure is safe to skip.** Present all failures to the user with analysis.

View File

@@ -5,9 +5,9 @@
Maintain `execution-log.md` with per-branch tables:
```markdown
| PR# | Title | Status | Backport PR | Notes |
| ----- | ----- | --------------------------------- | ----------- | ------- |
| #XXXX | Title | ✅ Merged / ⏭️ Skip / ⏸️ Deferred | #YYYY | Details |
| PR# | Title | CI Status | Status | Backport PR | Notes |
| ----- | ----- | ------------------------------ | --------------------------------- | ----------- | ------- |
| #XXXX | Title | ✅ Pass / ❌ Fail / ⏳ Pending | ✅ Merged / ⏭️ Skip / ⏸️ Deferred | #YYYY | Details |
```
## Wave Verification Log
@@ -19,6 +19,7 @@ Track verification results per wave:
- PRs merged: #A, #B, #C
- Typecheck: ✅ Pass / ❌ Fail
- Unit tests: ✅ Pass / ❌ Fail
- Issues found: (if any)
- Human review needed: (list any non-trivial conflict resolutions)
```
@@ -41,6 +42,11 @@ Track verification results per wave:
| PR# | Branch | Conflict Type | Resolution Summary |
## CI Failure Report
| PR# | Branch | Failing Check | Error Summary | Cause | Resolution |
| --- | ------ | ------------- | ------------- | ----- | ---------- |
## Automation Performance
| Metric | Value |

View File

@@ -180,7 +180,7 @@ jobs:
if git ls-remote --exit-code origin perf-data >/dev/null 2>&1; then
git fetch origin perf-data --depth=1
mkdir -p temp/perf-history
for file in $(git ls-tree --name-only origin/perf-data baselines/ 2>/dev/null | sort -r | head -10); do
for file in $(git ls-tree --name-only origin/perf-data baselines/ 2>/dev/null | sort -r | head -15); do
git show "origin/perf-data:${file}" > "temp/perf-history/$(basename "$file")" 2>/dev/null || true
done
echo "Loaded $(ls temp/perf-history/*.json 2>/dev/null | wc -l) historical baselines"

View File

@@ -1,6 +1,6 @@
import tailwindcss from '@tailwindcss/vite'
import vue from '@vitejs/plugin-vue'
import dotenv from 'dotenv'
import { config as dotenvConfig } from 'dotenv'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import { FileSystemIconLoader } from 'unplugin-icons/loaders'
@@ -11,7 +11,7 @@ import { defineConfig } from 'vite'
import { createHtmlPlugin } from 'vite-plugin-html'
import vueDevTools from 'vite-plugin-vue-devtools'
dotenv.config()
dotenvConfig()
const projectRoot = fileURLToPath(new URL('.', import.meta.url))

2
apps/website/.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
dist/
.astro/

View File

@@ -0,0 +1,24 @@
import { defineConfig } from 'astro/config'
import vue from '@astrojs/vue'
import tailwindcss from '@tailwindcss/vite'
export default defineConfig({
site: 'https://comfy.org',
output: 'static',
integrations: [vue()],
vite: {
plugins: [tailwindcss()]
},
build: {
assetsPrefix: process.env.VERCEL_URL
? `https://${process.env.VERCEL_URL}`
: undefined
},
i18n: {
locales: ['en', 'zh-CN'],
defaultLocale: 'en',
routing: {
prefixDefaultLocale: false
}
}
})

80
apps/website/package.json Normal file
View File

@@ -0,0 +1,80 @@
{
"name": "@comfyorg/website",
"version": "0.0.1",
"private": true,
"type": "module",
"scripts": {
"dev": "astro dev",
"build": "astro build",
"preview": "astro preview"
},
"dependencies": {
"@comfyorg/design-system": "workspace:*",
"@vercel/analytics": "catalog:",
"vue": "catalog:"
},
"devDependencies": {
"@astrojs/vue": "catalog:",
"@tailwindcss/vite": "catalog:",
"astro": "catalog:",
"tailwindcss": "catalog:",
"typescript": "catalog:"
},
"nx": {
"tags": [
"scope:website",
"type:app"
],
"targets": {
"dev": {
"executor": "nx:run-commands",
"continuous": true,
"options": {
"cwd": "apps/website",
"command": "astro dev"
}
},
"serve": {
"executor": "nx:run-commands",
"continuous": true,
"options": {
"cwd": "apps/website",
"command": "astro dev"
}
},
"build": {
"executor": "nx:run-commands",
"cache": true,
"dependsOn": [
"^build"
],
"options": {
"cwd": "apps/website",
"command": "astro build"
},
"outputs": [
"{projectRoot}/dist"
]
},
"preview": {
"executor": "nx:run-commands",
"continuous": true,
"dependsOn": [
"build"
],
"options": {
"cwd": "apps/website",
"command": "astro preview"
}
},
"typecheck": {
"executor": "nx:run-commands",
"cache": true,
"options": {
"cwd": "apps/website",
"command": "astro check"
}
}
}
}
}

Binary file not shown.

Binary file not shown.

1
apps/website/src/env.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
/// <reference types="astro/client" />

View File

@@ -0,0 +1,2 @@
@import 'tailwindcss';
@import '@comfyorg/design-system/css/base.css';

View File

@@ -0,0 +1,9 @@
{
"extends": "astro/tsconfigs/strict",
"compilerOptions": {
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["src/**/*", "astro.config.mjs"]
}

View File

@@ -0,0 +1,555 @@
{
"id": "b04d981f-6857-48cc-ac6e-429ab2f6bc8d",
"revision": 0,
"last_node_id": 11,
"last_link_id": 16,
"nodes": [
{
"id": 9,
"type": "SaveImage",
"pos": [1451.0058559453123, 189.0019842294924],
"size": [400, 200],
"flags": {},
"order": 2,
"mode": 0,
"inputs": [
{
"name": "images",
"type": "IMAGE",
"link": 13
}
],
"outputs": [],
"properties": {},
"widgets_values": ["ComfyUI"]
},
{
"id": 4,
"type": "CheckpointLoaderSimple",
"pos": [25.988896564209426, 473.9973077158204],
"size": [400, 200],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [
{
"name": "MODEL",
"type": "MODEL",
"slot_index": 0,
"links": [11]
},
{
"name": "CLIP",
"type": "CLIP",
"slot_index": 1,
"links": [10]
},
{
"name": "VAE",
"type": "VAE",
"slot_index": 2,
"links": [12]
}
],
"properties": {
"Node name for S&R": "CheckpointLoaderSimple"
},
"widgets_values": ["v1-5-pruned-emaonly-fp16.safetensors"]
},
{
"id": 10,
"type": "d14ff4cf-e5cb-4c84-941f-7c2457476424",
"pos": [711.776576770508, 420.55569028417983],
"size": [400, 293],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [
{
"name": "clip",
"type": "CLIP",
"link": 10
},
{
"name": "model",
"type": "MODEL",
"link": 11
},
{
"name": "vae",
"type": "VAE",
"link": 12
}
],
"outputs": [
{
"name": "IMAGE",
"type": "IMAGE",
"links": [13]
}
],
"properties": {
"proxyWidgets": [
["7", "text"],
["6", "text"],
["3", "seed"]
]
},
"widgets_values": []
}
],
"links": [
[10, 4, 1, 10, 0, "CLIP"],
[11, 4, 0, 10, 1, "MODEL"],
[12, 4, 2, 10, 2, "VAE"],
[13, 10, 0, 9, 0, "IMAGE"]
],
"groups": [],
"definitions": {
"subgraphs": [
{
"id": "d14ff4cf-e5cb-4c84-941f-7c2457476424",
"version": 1,
"state": {
"lastGroupId": 0,
"lastNodeId": 11,
"lastLinkId": 16,
"lastRerouteId": 0
},
"revision": 0,
"config": {},
"name": "New Subgraph",
"inputNode": {
"id": -10,
"bounding": [233, 404.5, 120, 100]
},
"outputNode": {
"id": -20,
"bounding": [1494, 424.5, 120, 60]
},
"inputs": [
{
"id": "85b7a46b-f14c-4297-8b86-3fc73a41da2b",
"name": "clip",
"type": "CLIP",
"linkIds": [14],
"localized_name": "clip",
"pos": [333, 424.5]
},
{
"id": "b4040cb7-0457-416e-ad6e-14890b871dd2",
"name": "model",
"type": "MODEL",
"linkIds": [1],
"localized_name": "model",
"pos": [333, 444.5]
},
{
"id": "e61199fa-9113-4532-a3d9-879095969171",
"name": "vae",
"type": "VAE",
"linkIds": [8],
"localized_name": "vae",
"pos": [333, 464.5]
}
],
"outputs": [
{
"id": "a4705fa5-a5e6-4c4e-83c2-dfb875861466",
"name": "IMAGE",
"type": "IMAGE",
"linkIds": [9],
"localized_name": "IMAGE",
"pos": [1514, 444.5]
}
],
"widgets": [],
"nodes": [
{
"id": 5,
"type": "EmptyLatentImage",
"pos": [473.007643669922, 609.0214689174805],
"size": [400, 200],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [
{
"localized_name": "LATENT",
"name": "LATENT",
"type": "LATENT",
"slot_index": 0,
"links": [2]
}
],
"properties": {
"Node name for S&R": "EmptyLatentImage"
},
"widgets_values": [512, 512, 1]
},
{
"id": 3,
"type": "KSampler",
"pos": [862.990643669922, 185.9853293300783],
"size": [400, 317],
"flags": {},
"order": 2,
"mode": 0,
"inputs": [
{
"localized_name": "model",
"name": "model",
"type": "MODEL",
"link": 1
},
{
"localized_name": "positive",
"name": "positive",
"type": "CONDITIONING",
"link": 16
},
{
"localized_name": "negative",
"name": "negative",
"type": "CONDITIONING",
"link": 15
},
{
"localized_name": "latent_image",
"name": "latent_image",
"type": "LATENT",
"link": 2
}
],
"outputs": [
{
"localized_name": "LATENT",
"name": "LATENT",
"type": "LATENT",
"slot_index": 0,
"links": [7]
}
],
"properties": {
"Node name for S&R": "KSampler"
},
"widgets_values": [
156680208700286,
"randomize",
20,
8,
"euler",
"normal",
1
]
},
{
"id": 8,
"type": "VAEDecode",
"pos": [1209.0062878349609, 188.00400724755877],
"size": [400, 200],
"flags": {},
"order": 3,
"mode": 0,
"inputs": [
{
"localized_name": "samples",
"name": "samples",
"type": "LATENT",
"link": 7
},
{
"localized_name": "vae",
"name": "vae",
"type": "VAE",
"link": 8
}
],
"outputs": [
{
"localized_name": "IMAGE",
"name": "IMAGE",
"type": "IMAGE",
"slot_index": 0,
"links": [9]
}
],
"properties": {
"Node name for S&R": "VAEDecode"
},
"widgets_values": []
},
{
"id": 11,
"type": "3b9b7fb9-a8f6-4b4e-ac13-b68156afe8f6",
"pos": [485.5190761650391, 283.9247189174806],
"size": [400, 237],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [
{
"localized_name": "clip",
"name": "clip",
"type": "CLIP",
"link": 14
}
],
"outputs": [
{
"localized_name": "CONDITIONING",
"name": "CONDITIONING",
"type": "CONDITIONING",
"links": [15]
},
{
"localized_name": "CONDITIONING_1",
"name": "CONDITIONING_1",
"type": "CONDITIONING",
"links": [16]
}
],
"properties": {
"proxyWidgets": [
["7", "text"],
["6", "text"]
]
},
"widgets_values": []
}
],
"groups": [],
"links": [
{
"id": 2,
"origin_id": 5,
"origin_slot": 0,
"target_id": 3,
"target_slot": 3,
"type": "LATENT"
},
{
"id": 7,
"origin_id": 3,
"origin_slot": 0,
"target_id": 8,
"target_slot": 0,
"type": "LATENT"
},
{
"id": 1,
"origin_id": -10,
"origin_slot": 1,
"target_id": 3,
"target_slot": 0,
"type": "MODEL"
},
{
"id": 8,
"origin_id": -10,
"origin_slot": 2,
"target_id": 8,
"target_slot": 1,
"type": "VAE"
},
{
"id": 9,
"origin_id": 8,
"origin_slot": 0,
"target_id": -20,
"target_slot": 0,
"type": "IMAGE"
},
{
"id": 14,
"origin_id": -10,
"origin_slot": 0,
"target_id": 11,
"target_slot": 0,
"type": "CLIP"
},
{
"id": 15,
"origin_id": 11,
"origin_slot": 0,
"target_id": 3,
"target_slot": 2,
"type": "CONDITIONING"
},
{
"id": 16,
"origin_id": 11,
"origin_slot": 1,
"target_id": 3,
"target_slot": 1,
"type": "CONDITIONING"
}
],
"extra": {}
},
{
"id": "3b9b7fb9-a8f6-4b4e-ac13-b68156afe8f6",
"version": 1,
"state": {
"lastGroupId": 0,
"lastNodeId": 11,
"lastLinkId": 16,
"lastRerouteId": 0
},
"revision": 0,
"config": {},
"name": "New Subgraph",
"inputNode": {
"id": -10,
"bounding": [233.01228575000005, 332.7902770140076, 120, 60]
},
"outputNode": {
"id": -20,
"bounding": [
898.2956109453125, 322.7902770140076, 138.31666564941406, 80
]
},
"inputs": [
{
"id": "e5074a9c-3b33-4998-b569-0638817e81e7",
"name": "clip",
"type": "CLIP",
"linkIds": [5, 3],
"localized_name": "clip",
"pos": [55, 20]
}
],
"outputs": [
{
"id": "5fd778da-7ff1-4a0b-9282-d11a2e332e15",
"name": "CONDITIONING",
"type": "CONDITIONING",
"linkIds": [6],
"localized_name": "CONDITIONING",
"pos": [20, 20]
},
{
"id": "1e02089f-6491-45fa-aa0a-24458100f8ae",
"name": "CONDITIONING_1",
"type": "CONDITIONING",
"linkIds": [4],
"localized_name": "CONDITIONING_1",
"pos": [20, 40]
}
],
"widgets": [],
"nodes": [
{
"id": 7,
"type": "CLIPTextEncode",
"pos": [413.01228575000005, 388.98593823266606],
"size": [425, 200],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [
{
"localized_name": "clip",
"name": "clip",
"type": "CLIP",
"link": 5
}
],
"outputs": [
{
"localized_name": "CONDITIONING",
"name": "CONDITIONING",
"type": "CONDITIONING",
"slot_index": 0,
"links": [6]
}
],
"properties": {
"Node name for S&R": "CLIPTextEncode"
},
"widgets_values": ["text, watermark"]
},
{
"id": 6,
"type": "CLIPTextEncode",
"pos": [414.99053247091683, 185.9946096918335],
"size": [423, 200],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [
{
"localized_name": "clip",
"name": "clip",
"type": "CLIP",
"link": 3
}
],
"outputs": [
{
"localized_name": "CONDITIONING",
"name": "CONDITIONING",
"type": "CONDITIONING",
"slot_index": 0,
"links": [4]
}
],
"properties": {
"Node name for S&R": "CLIPTextEncode"
},
"widgets_values": [
"beautiful scenery nature glass bottle landscape, , purple galaxy bottle,"
]
}
],
"groups": [],
"links": [
{
"id": 5,
"origin_id": -10,
"origin_slot": 0,
"target_id": 7,
"target_slot": 0,
"type": "CLIP"
},
{
"id": 3,
"origin_id": -10,
"origin_slot": 0,
"target_id": 6,
"target_slot": 0,
"type": "CLIP"
},
{
"id": 6,
"origin_id": 7,
"origin_slot": 0,
"target_id": -20,
"target_slot": 0,
"type": "CONDITIONING"
},
{
"id": 4,
"origin_id": 6,
"origin_slot": 0,
"target_id": -20,
"target_slot": 1,
"type": "CONDITIONING"
}
],
"extra": {}
}
]
},
"config": {},
"extra": {
"ds": {
"scale": 0.6830134553650709,
"offset": [-203.70966200000038, 259.92420099999975]
},
"frontendVersion": "1.43.2"
},
"version": 0.4
}

View File

@@ -5,7 +5,7 @@ import type {
Page
} from '@playwright/test'
import { test as base, expect } from '@playwright/test'
import dotenv from 'dotenv'
import { config as dotenvConfig } from 'dotenv'
import { TestIds } from './selectors'
import { NodeBadgeMode } from '../../src/types/nodeSource'
@@ -40,7 +40,7 @@ import { WorkflowHelper } from './helpers/WorkflowHelper'
import type { NodeReference } from './utils/litegraphUtils'
import type { WorkspaceStore } from '../types/globals'
dotenv.config()
dotenvConfig()
class ComfyPropertiesPanel {
readonly root: Locator

View File

@@ -91,6 +91,12 @@ export class CanvasHelper {
await this.page.mouse.move(10, 10)
}
async isReadOnly(): Promise<boolean> {
return this.page.evaluate(() => {
return window.app!.canvas.state.readOnly
})
}
async getScale(): Promise<number> {
return this.page.evaluate(() => {
return window.app!.canvas.ds.scale

View File

@@ -25,13 +25,15 @@ export class DragDropHelper {
url?: string
dropPosition?: Position
waitForUpload?: boolean
preserveNativePropagation?: boolean
} = {}
): Promise<void> {
const {
dropPosition = { x: 100, y: 100 },
fileName,
url,
waitForUpload = false
waitForUpload = false,
preserveNativePropagation = false
} = options
if (!fileName && !url)
@@ -43,7 +45,8 @@ export class DragDropHelper {
fileType?: string
buffer?: Uint8Array | number[]
url?: string
} = { dropPosition }
preserveNativePropagation: boolean
} = { dropPosition, preserveNativePropagation }
if (fileName) {
const filePath = this.assetPath(fileName)
@@ -115,15 +118,17 @@ export class DragDropHelper {
)
}
Object.defineProperty(dropEvent, 'preventDefault', {
value: () => {},
writable: false
})
if (!params.preserveNativePropagation) {
Object.defineProperty(dropEvent, 'preventDefault', {
value: () => {},
writable: false
})
Object.defineProperty(dropEvent, 'stopPropagation', {
value: () => {},
writable: false
})
Object.defineProperty(dropEvent, 'stopPropagation', {
value: () => {},
writable: false
})
}
targetElement.dispatchEvent(dragOverEvent)
targetElement.dispatchEvent(dropEvent)
@@ -154,7 +159,10 @@ export class DragDropHelper {
async dragAndDropURL(
url: string,
options: { dropPosition?: Position } = {}
options: {
dropPosition?: Position
preserveNativePropagation?: boolean
} = {}
): Promise<void> {
return this.dragAndDropExternalResource({ url, ...options })
}

View File

@@ -23,6 +23,7 @@ export interface PerfMeasurement {
layoutDurationMs: number
taskDurationMs: number
heapDeltaBytes: number
heapUsedBytes: number
domNodes: number
jsHeapTotalBytes: number
scriptDurationMs: number
@@ -190,6 +191,7 @@ export class PerformanceHelper {
layoutDurationMs: delta('LayoutDuration') * 1000,
taskDurationMs: delta('TaskDuration') * 1000,
heapDeltaBytes: delta('JSHeapUsedSize'),
heapUsedBytes: after.JSHeapUsedSize,
domNodes: delta('Nodes'),
jsHeapTotalBytes: delta('JSHeapTotalSize'),
scriptDurationMs: delta('ScriptDuration') * 1000,

View File

@@ -28,10 +28,15 @@ export const TestIds = {
settingsTabAbout: 'settings-tab-about',
confirm: 'confirm-dialog',
errorOverlay: 'error-overlay',
errorOverlaySeeErrors: 'error-overlay-see-errors',
runtimeErrorPanel: 'runtime-error-panel',
missingNodeCard: 'missing-node-card',
errorCardFindOnGithub: 'error-card-find-on-github',
errorCardCopy: 'error-card-copy',
about: 'about-panel',
whatsNewSection: 'whats-new-section'
whatsNewSection: 'whats-new-section',
missingNodePacksGroup: 'error-group-missing-node',
missingModelsGroup: 'error-group-missing-model'
},
keybindings: {
presetMenu: 'keybinding-preset-menu'
@@ -57,6 +62,8 @@ export const TestIds = {
colorRed: 'red'
},
widgets: {
container: 'node-widgets',
widget: 'node-widget',
decrement: 'decrement',
increment: 'increment',
domWidgetTextarea: 'dom-widget-textarea',
@@ -76,6 +83,10 @@ export const TestIds = {
},
user: {
currentUserIndicator: 'current-user-indicator'
},
errors: {
imageLoadError: 'error-loading-image',
videoLoadError: 'error-loading-video'
}
} as const
@@ -101,3 +112,4 @@ export type TestIdValue =
(id: string) => string
>
| (typeof TestIds.user)[keyof typeof TestIds.user]
| (typeof TestIds.errors)[keyof typeof TestIds.errors]

View File

@@ -1,11 +1,10 @@
import type { FullConfig } from '@playwright/test'
import dotenv from 'dotenv'
import { config as dotenvConfig } from 'dotenv'
import { backupPath } from './utils/backupUtils'
dotenv.config()
dotenvConfig()
export default function globalSetup(_config: FullConfig) {
export default function globalSetup() {
if (!process.env.CI) {
if (process.env.TEST_COMFYUI_DIR) {
backupPath([process.env.TEST_COMFYUI_DIR, 'user'])

View File

@@ -1,12 +1,11 @@
import type { FullConfig } from '@playwright/test'
import dotenv from 'dotenv'
import { config as dotenvConfig } from 'dotenv'
import { writePerfReport } from './helpers/perfReporter'
import { restorePath } from './utils/backupUtils'
dotenv.config()
dotenvConfig()
export default function globalTeardown(_config: FullConfig) {
export default function globalTeardown() {
writePerfReport()
if (!process.env.CI && process.env.TEST_COMFYUI_DIR) {

View File

@@ -38,16 +38,13 @@ const customColorPalettes = {
CLEAR_BACKGROUND_COLOR: '#222222',
NODE_TITLE_COLOR: 'rgba(255,255,255,.75)',
NODE_SELECTED_TITLE_COLOR: '#FFF',
NODE_TEXT_SIZE: 14,
NODE_TEXT_COLOR: '#b8b8b8',
NODE_SUBTEXT_SIZE: 12,
NODE_DEFAULT_COLOR: 'rgba(0,0,0,.8)',
NODE_DEFAULT_BGCOLOR: 'rgba(22,22,22,.8)',
NODE_DEFAULT_BOXCOLOR: 'rgba(255,255,255,.75)',
NODE_DEFAULT_SHAPE: 'box',
NODE_BOX_OUTLINE_COLOR: '#236692',
DEFAULT_SHADOW_COLOR: 'rgba(0,0,0,0)',
DEFAULT_GROUP_FONT: 24,
WIDGET_BGCOLOR: '#242424',
WIDGET_OUTLINE_COLOR: '#333',
WIDGET_TEXT_COLOR: '#a3a3a8',
@@ -102,16 +99,13 @@ const customColorPalettes = {
CLEAR_BACKGROUND_COLOR: '#000',
NODE_TITLE_COLOR: 'rgba(255,255,255,.75)',
NODE_SELECTED_TITLE_COLOR: '#FFF',
NODE_TEXT_SIZE: 14,
NODE_TEXT_COLOR: '#b8b8b8',
NODE_SUBTEXT_SIZE: 12,
NODE_DEFAULT_COLOR: 'rgba(0,0,0,.8)',
NODE_DEFAULT_BGCOLOR: 'rgba(22,22,22,.8)',
NODE_DEFAULT_BOXCOLOR: 'rgba(255,255,255,.75)',
NODE_DEFAULT_SHAPE: 'box',
NODE_BOX_OUTLINE_COLOR: '#236692',
DEFAULT_SHADOW_COLOR: 'rgba(0,0,0,0)',
DEFAULT_GROUP_FONT: 24,
WIDGET_BGCOLOR: '#242424',
WIDGET_OUTLINE_COLOR: '#333',
WIDGET_TEXT_COLOR: '#a3a3a8',

View File

@@ -0,0 +1,318 @@
import { expect } from '@playwright/test'
import type { ComfyPage } from '../fixtures/ComfyPage'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
async function pressKeyAndExpectRequest(
comfyPage: ComfyPage,
key: string,
urlPattern: string,
method: string = 'POST'
) {
const requestPromise = comfyPage.page.waitForRequest(
(req) => req.url().includes(urlPattern) && req.method() === method,
{ timeout: 5000 }
)
await comfyPage.page.keyboard.press(key)
return requestPromise
}
test.describe('Default Keybindings', { tag: '@keyboard' }, () => {
test.describe('Sidebar Toggle Shortcuts', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
await comfyPage.canvas.click({ position: { x: 400, y: 400 } })
await comfyPage.nextFrame()
})
const sidebarTabs = [
{ key: 'KeyW', tabId: 'workflows', label: 'workflows' },
{ key: 'KeyN', tabId: 'node-library', label: 'node library' },
{ key: 'KeyM', tabId: 'model-library', label: 'model library' },
{ key: 'KeyA', tabId: 'assets', label: 'assets' }
] as const
for (const { key, tabId, label } of sidebarTabs) {
test(`'${key}' toggles ${label} sidebar`, async ({ comfyPage }) => {
const selectedButton = comfyPage.page.locator(
`.${tabId}-tab-button.side-bar-button-selected`
)
await expect(selectedButton).not.toBeVisible()
await comfyPage.canvas.press(key)
await comfyPage.nextFrame()
await expect(selectedButton).toBeVisible()
await comfyPage.canvas.press(key)
await comfyPage.nextFrame()
await expect(selectedButton).not.toBeVisible()
})
}
})
test.describe('Canvas View Controls', () => {
test("'Alt+=' zooms in", async ({ comfyPage }) => {
const initialScale = await comfyPage.canvasOps.getScale()
await comfyPage.canvas.press('Alt+Equal')
await comfyPage.nextFrame()
const newScale = await comfyPage.canvasOps.getScale()
expect(newScale).toBeGreaterThan(initialScale)
})
test("'Alt+-' zooms out", async ({ comfyPage }) => {
const initialScale = await comfyPage.canvasOps.getScale()
await comfyPage.canvas.press('Alt+Minus')
await comfyPage.nextFrame()
const newScale = await comfyPage.canvasOps.getScale()
expect(newScale).toBeLessThan(initialScale)
})
test("'.' fits view to nodes", async ({ comfyPage }) => {
// Set scale very small so fit-view will zoom back to fit nodes
await comfyPage.canvasOps.setScale(0.1)
const scaleBefore = await comfyPage.canvasOps.getScale()
expect(scaleBefore).toBeCloseTo(0.1, 1)
// Click canvas to ensure focus is within graph-canvas-container
await comfyPage.canvas.click({ position: { x: 400, y: 400 } })
await comfyPage.nextFrame()
await comfyPage.canvas.press('Period')
await comfyPage.nextFrame()
const scaleAfter = await comfyPage.canvasOps.getScale()
expect(scaleAfter).toBeGreaterThan(scaleBefore)
})
test("'h' locks canvas", async ({ comfyPage }) => {
expect(await comfyPage.canvasOps.isReadOnly()).toBe(false)
await comfyPage.canvas.press('KeyH')
await comfyPage.nextFrame()
expect(await comfyPage.canvasOps.isReadOnly()).toBe(true)
})
test("'v' unlocks canvas", async ({ comfyPage }) => {
// Lock first
await comfyPage.command.executeCommand('Comfy.Canvas.Lock')
await comfyPage.nextFrame()
expect(await comfyPage.canvasOps.isReadOnly()).toBe(true)
await comfyPage.canvas.press('KeyV')
await comfyPage.nextFrame()
expect(await comfyPage.canvasOps.isReadOnly()).toBe(false)
})
})
test.describe('Node State Toggles', () => {
test("'Alt+c' collapses and expands selected nodes", async ({
comfyPage
}) => {
const nodes = await comfyPage.nodeOps.getNodeRefsByType('CLIPTextEncode')
expect(nodes.length).toBeGreaterThan(0)
const node = nodes[0]
await node.click('title')
await comfyPage.nextFrame()
expect(await node.isCollapsed()).toBe(false)
await comfyPage.canvas.press('Alt+KeyC')
await comfyPage.nextFrame()
expect(await node.isCollapsed()).toBe(true)
await comfyPage.canvas.press('Alt+KeyC')
await comfyPage.nextFrame()
expect(await node.isCollapsed()).toBe(false)
})
test("'Ctrl+m' mutes and unmutes selected nodes", async ({ comfyPage }) => {
const nodes = await comfyPage.nodeOps.getNodeRefsByType('CLIPTextEncode')
expect(nodes.length).toBeGreaterThan(0)
const node = nodes[0]
await node.click('title')
await comfyPage.nextFrame()
// Normal mode is ALWAYS (0)
const getMode = () =>
comfyPage.page.evaluate((nodeId) => {
return window.app!.canvas.graph!.getNodeById(nodeId)!.mode
}, node.id)
expect(await getMode()).toBe(0)
await comfyPage.canvas.press('Control+KeyM')
await comfyPage.nextFrame()
// NEVER (2) = muted
expect(await getMode()).toBe(2)
await comfyPage.canvas.press('Control+KeyM')
await comfyPage.nextFrame()
expect(await getMode()).toBe(0)
})
})
test.describe('Mode and Panel Toggles', () => {
test("'Alt+m' toggles app mode", async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
// Set up linearData so app mode has something to show
await comfyPage.appMode.enterAppModeWithInputs([])
await expect(comfyPage.appMode.linearWidgets).toBeVisible()
// Toggle off with Alt+m
await comfyPage.page.keyboard.press('Alt+KeyM')
await comfyPage.nextFrame()
await expect(comfyPage.appMode.linearWidgets).not.toBeVisible()
// Toggle on again
await comfyPage.page.keyboard.press('Alt+KeyM')
await comfyPage.nextFrame()
await expect(comfyPage.appMode.linearWidgets).toBeVisible()
})
test("'Alt+Shift+m' toggles minimap", async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
await comfyPage.settings.setSetting('Comfy.Minimap.Visible', true)
await comfyPage.settings.setSetting('Comfy.Graph.CanvasMenu', true)
await comfyPage.workflow.loadWorkflow('default')
const minimap = comfyPage.page.locator('.litegraph-minimap')
await expect(minimap).toBeVisible()
await comfyPage.page.keyboard.press('Alt+Shift+KeyM')
await comfyPage.nextFrame()
await expect(minimap).not.toBeVisible()
await comfyPage.page.keyboard.press('Alt+Shift+KeyM')
await comfyPage.nextFrame()
await expect(minimap).toBeVisible()
})
test("'Ctrl+`' toggles terminal/logs panel", async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
await expect(comfyPage.bottomPanel.root).not.toBeVisible()
await comfyPage.page.keyboard.press('Control+Backquote')
await comfyPage.nextFrame()
await expect(comfyPage.bottomPanel.root).toBeVisible()
await comfyPage.page.keyboard.press('Control+Backquote')
await comfyPage.nextFrame()
await expect(comfyPage.bottomPanel.root).not.toBeVisible()
})
})
test.describe('Queue and Execution', () => {
test("'Ctrl+Enter' queues prompt", async ({ comfyPage }) => {
const request = await pressKeyAndExpectRequest(
comfyPage,
'Control+Enter',
'/prompt',
'POST'
)
expect(request.url()).toContain('/prompt')
})
test("'Ctrl+Shift+Enter' queues prompt to front", async ({ comfyPage }) => {
const request = await pressKeyAndExpectRequest(
comfyPage,
'Control+Shift+Enter',
'/prompt',
'POST'
)
const body = request.postDataJSON()
expect(body.front).toBe(true)
})
test("'Ctrl+Alt+Enter' interrupts execution", async ({ comfyPage }) => {
const request = await pressKeyAndExpectRequest(
comfyPage,
'Control+Alt+Enter',
'/interrupt',
'POST'
)
expect(request.url()).toContain('/interrupt')
})
})
test.describe('File Operations', () => {
test("'Ctrl+s' triggers save workflow", async ({ comfyPage }) => {
// On a new unsaved workflow, Ctrl+s triggers Save As dialog.
// The dialog appearing proves the keybinding was intercepted by the app.
await comfyPage.page.keyboard.press('Control+s')
await comfyPage.nextFrame()
// The Save As dialog should appear (p-dialog overlay)
const dialogOverlay = comfyPage.page.locator('.p-dialog-mask')
await expect(dialogOverlay).toBeVisible({ timeout: 3000 })
// Dismiss the dialog
await comfyPage.page.keyboard.press('Escape')
await comfyPage.nextFrame()
})
test("'Ctrl+o' triggers open workflow", async ({ comfyPage }) => {
// Ctrl+o calls app.ui.loadFile() which clicks a hidden file input.
// Detect the file input click via an event listener.
await comfyPage.page.evaluate(() => {
window.TestCommand = false
const fileInputs =
document.querySelectorAll<HTMLInputElement>('input[type="file"]')
for (const input of fileInputs) {
input.addEventListener('click', () => {
window.TestCommand = true
})
}
})
await comfyPage.page.keyboard.press('Control+o')
await comfyPage.nextFrame()
expect(await comfyPage.page.evaluate(() => window.TestCommand)).toBe(true)
})
})
test.describe('Graph Operations', () => {
test("'Ctrl+Shift+e' converts selection to subgraph", async ({
comfyPage
}) => {
const initialCount = await comfyPage.nodeOps.getGraphNodesCount()
expect(initialCount).toBeGreaterThan(1)
// Select all nodes
await comfyPage.canvas.press('Control+a')
await comfyPage.nextFrame()
await comfyPage.page.keyboard.press('Control+Shift+KeyE')
await comfyPage.nextFrame()
// After conversion, node count should decrease
// (multiple nodes replaced by single subgraph node)
await expect
.poll(() => comfyPage.nodeOps.getGraphNodesCount(), {
timeout: 5000
})
.toBeLessThan(initialCount)
})
test("'r' refreshes node definitions", async ({ comfyPage }) => {
const request = await pressKeyAndExpectRequest(
comfyPage,
'KeyR',
'/object_info',
'GET'
)
expect(request.url()).toContain('/object_info')
})
})
})

View File

@@ -28,7 +28,7 @@ test.describe('Missing nodes in Error Overlay', { tag: '@ui' }, () => {
)
await expect(errorOverlay).toBeVisible()
const missingNodesTitle = comfyPage.page.getByText(/Missing Node Packs/)
const missingNodesTitle = errorOverlay.getByText(/Missing Node Packs/)
await expect(missingNodesTitle).toBeVisible()
})
@@ -42,11 +42,13 @@ test.describe('Missing nodes in Error Overlay', { tag: '@ui' }, () => {
)
await expect(errorOverlay).toBeVisible()
const missingNodesTitle = comfyPage.page.getByText(/Missing Node Packs/)
const missingNodesTitle = errorOverlay.getByText(/Missing Node Packs/)
await expect(missingNodesTitle).toBeVisible()
// Click "See Errors" to open the errors tab and verify subgraph node content
await errorOverlay.getByRole('button', { name: 'See Errors' }).click()
await errorOverlay
.getByTestId(TestIds.dialogs.errorOverlaySeeErrors)
.click()
await expect(errorOverlay).not.toBeVisible()
const missingNodeCard = comfyPage.page.getByTestId(
@@ -75,7 +77,9 @@ test.describe('Missing nodes in Error Overlay', { tag: '@ui' }, () => {
await expect(errorOverlay).toBeVisible()
// Click "See Errors" to open the right side panel errors tab
await errorOverlay.getByRole('button', { name: 'See Errors' }).click()
await errorOverlay
.getByTestId(TestIds.dialogs.errorOverlaySeeErrors)
.click()
await expect(errorOverlay).not.toBeVisible()
// Verify MissingNodeCard is rendered in the errors tab
@@ -165,17 +169,19 @@ test.describe('Error actions in Errors Tab', { tag: '@ui' }, () => {
TestIds.dialogs.errorOverlay
)
await expect(errorOverlay).toBeVisible()
await errorOverlay.getByRole('button', { name: 'See Errors' }).click()
await errorOverlay
.getByTestId(TestIds.dialogs.errorOverlaySeeErrors)
.click()
await expect(errorOverlay).not.toBeVisible()
// Verify Find on GitHub button is present in the error card
const findOnGithubButton = comfyPage.page.getByRole('button', {
name: 'Find on GitHub'
})
const findOnGithubButton = comfyPage.page.getByTestId(
TestIds.dialogs.errorCardFindOnGithub
)
await expect(findOnGithubButton).toBeVisible()
// Verify Copy button is present in the error card
const copyButton = comfyPage.page.getByRole('button', { name: 'Copy' })
const copyButton = comfyPage.page.getByTestId(TestIds.dialogs.errorCardCopy)
await expect(copyButton).toBeVisible()
})
})
@@ -204,7 +210,7 @@ test.describe('Missing models in Error Tab', () => {
)
await expect(errorOverlay).toBeVisible()
const missingModelsTitle = comfyPage.page.getByText(/Missing Models/)
const missingModelsTitle = errorOverlay.getByText(/Missing Models/)
await expect(missingModelsTitle).toBeVisible()
})
@@ -220,7 +226,7 @@ test.describe('Missing models in Error Tab', () => {
)
await expect(errorOverlay).toBeVisible()
const missingModelsTitle = comfyPage.page.getByText(/Missing Models/)
const missingModelsTitle = errorOverlay.getByText(/Missing Models/)
await expect(missingModelsTitle).toBeVisible()
})
@@ -231,13 +237,10 @@ test.describe('Missing models in Error Tab', () => {
'missing/model_metadata_widget_mismatch'
)
const missingModelsTitle = comfyPage.page.getByText(/Missing Models/)
await expect(missingModelsTitle).not.toBeVisible()
const errorOverlay = comfyPage.page.getByTestId(
TestIds.dialogs.errorOverlay
)
await expect(errorOverlay).not.toBeVisible()
await expect(
comfyPage.page.getByTestId(TestIds.dialogs.errorOverlay)
).not.toBeVisible()
await expect(comfyPage.page.getByText(/Missing Models/)).not.toBeVisible()
})
// Flaky test after parallelization

View File

@@ -764,13 +764,13 @@ test.describe('Load workflow', { tag: '@screenshot' }, () => {
)
})
const generateUniqueFilename = (extension = '') =>
`${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}${extension}`
test.describe('Restore all open workflows on reload', () => {
let workflowA: string
let workflowB: string
const generateUniqueFilename = (extension = '') =>
`${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}${extension}`
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
@@ -829,6 +829,82 @@ test.describe('Load workflow', { tag: '@screenshot' }, () => {
})
})
test.describe('Restore workflow tabs after browser restart', () => {
let workflowA: string
let workflowB: string
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
workflowA = generateUniqueFilename()
await comfyPage.menu.topbar.saveWorkflow(workflowA)
workflowB = generateUniqueFilename()
await comfyPage.menu.topbar.triggerTopbarCommand(['New'])
await comfyPage.menu.topbar.saveWorkflow(workflowB)
// Wait for localStorage fallback pointers to be written
await comfyPage.page.waitForFunction(() => {
for (let i = 0; i < window.localStorage.length; i++) {
const key = window.localStorage.key(i)
if (key?.startsWith('Comfy.Workflow.LastOpenPaths:')) {
return true
}
}
return false
})
// Simulate browser restart: clear sessionStorage (lost on close)
// but keep localStorage (survives browser restart)
await comfyPage.page.evaluate(() => {
sessionStorage.clear()
})
await comfyPage.setup({ clearStorage: false })
})
test('Restores topbar workflow tabs after browser restart', async ({
comfyPage
}) => {
await comfyPage.settings.setSetting(
'Comfy.Workflow.WorkflowTabsPosition',
'Topbar'
)
// Wait for both restored tabs to render (localStorage fallback is async)
await expect(
comfyPage.page.locator('.workflow-tabs .workflow-label', {
hasText: workflowA
})
).toBeVisible()
const tabs = await comfyPage.menu.topbar.getTabNames()
const activeWorkflowName = await comfyPage.menu.topbar.getActiveTabName()
expect(tabs).toEqual(expect.arrayContaining([workflowA, workflowB]))
expect(tabs.indexOf(workflowA)).toBeLessThan(tabs.indexOf(workflowB))
expect(activeWorkflowName).toEqual(workflowB)
})
test('Restores sidebar workflows after browser restart', async ({
comfyPage
}) => {
await comfyPage.settings.setSetting(
'Comfy.Workflow.WorkflowTabsPosition',
'Sidebar'
)
await comfyPage.menu.workflowsTab.open()
const openWorkflows =
await comfyPage.menu.workflowsTab.getOpenedWorkflowNames()
const activeWorkflowName =
await comfyPage.menu.workflowsTab.getActiveWorkflowName()
expect(openWorkflows).toEqual(
expect.arrayContaining([workflowA, workflowB])
)
expect(openWorkflows.indexOf(workflowA)).toBeLessThan(
openWorkflows.indexOf(workflowB)
)
expect(activeWorkflowName).toEqual(workflowB)
})
})
test('Auto fit view after loading workflow', async ({ comfyPage }) => {
await comfyPage.settings.setSetting(
'Comfy.EnableWorkflowViewRestore',

Binary file not shown.

Before

Width:  |  Height:  |  Size: 64 KiB

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 101 KiB

After

Width:  |  Height:  |  Size: 101 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 80 KiB

After

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 77 KiB

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 77 KiB

After

Width:  |  Height:  |  Size: 72 KiB

View File

@@ -67,5 +67,44 @@ test.describe(
)
})
})
test('Load workflow from URL dropped onto Vue node', async ({
comfyPage
}) => {
const fakeUrl = 'https://example.com/workflow.png'
await comfyPage.page.route(fakeUrl, (route) =>
route.fulfill({
path: comfyPage.assetPath('workflowInMedia/workflow_itxt.png')
})
)
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
await comfyPage.vueNodes.waitForNodes()
const initialNodeCount = await comfyPage.nodeOps.getGraphNodesCount()
const node = comfyPage.vueNodes.getNodeByTitle('KSampler')
const box = await node.boundingBox()
expect(box).not.toBeNull()
const dropPosition = {
x: box!.x + box!.width / 2,
y: box!.y + box!.height / 2
}
await comfyPage.dragDrop.dragAndDropURL(fakeUrl, {
dropPosition,
preserveNativePropagation: true
})
await comfyPage.page.waitForFunction(
(prevCount) => window.app!.graph.nodes.length !== prevCount,
initialNodeCount,
{ timeout: 10000 }
)
const newNodeCount = await comfyPage.nodeOps.getGraphNodesCount()
expect(newNodeCount).not.toBe(initialNodeCount)
})
}
)

Binary file not shown.

Before

Width:  |  Height:  |  Size: 93 KiB

After

Width:  |  Height:  |  Size: 93 KiB

View File

@@ -0,0 +1,51 @@
import {
comfyPageFixture as test,
comfyExpect as expect
} from '../fixtures/ComfyPage'
import { TestIds } from '../fixtures/selectors'
const WORKFLOW = 'subgraphs/nested-subgraph-stale-proxy-widgets'
/**
* Regression test for nested subgraph packing leaving stale proxyWidgets
* on the outer SubgraphNode.
*
* When two CLIPTextEncode nodes (ids 6, 7) inside the outer subgraph are
* packed into a nested subgraph (node 11), the outer SubgraphNode (id 10)
* must drop the now-stale ["7","text"] and ["6","text"] proxy entries.
* Only ["3","seed"] (KSampler) should remain.
*
* Stale entries render as "Disconnected" placeholder widgets (type "button").
*
* See: https://github.com/Comfy-Org/ComfyUI_frontend/pull/10390
*/
test.describe(
'Nested subgraph stale proxyWidgets',
{ tag: ['@subgraph', '@widget'] },
() => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
})
test('Outer subgraph node has no stale proxyWidgets after nested packing', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(WORKFLOW)
await comfyPage.vueNodes.waitForNodes()
const outerNode = comfyPage.vueNodes.getNodeLocator('10')
await expect(outerNode).toBeVisible()
const widgets = outerNode.getByTestId(TestIds.widgets.widget)
// Only the KSampler seed widget should be present — no stale
// "Disconnected" placeholders from the packed CLIPTextEncode nodes.
await expect(widgets).toHaveCount(1)
await expect(widgets.first()).toBeVisible()
// Verify the seed widget is present via its label
const seedWidget = outerNode.getByLabel('seed', { exact: true })
await expect(seedWidget).toBeVisible()
})
}
)

View File

@@ -0,0 +1,132 @@
import { readFileSync } from 'fs'
import { resolve } from 'path'
import { expect } from '@playwright/test'
import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
interface SlotMeasurement {
key: string
offsetX: number
offsetY: number
}
interface NodeSlotData {
nodeId: string
isSubgraph: boolean
nodeW: number
nodeH: number
slots: SlotMeasurement[]
}
/**
* Regression test for link misalignment on SubgraphNodes when loading
* workflows with workflowRendererVersion: "LG".
*
* Root cause: ensureCorrectLayoutScale scales nodes by 1.2x for LG workflows,
* and fitView() updates lgCanvas.ds immediately. The Vue TransformPane's CSS
* transform lags by a frame, causing clientPosToCanvasPos to produce wrong
* slot offsets. The fix uses DOM-relative measurement instead.
*/
test.describe(
'Subgraph slot alignment after LG layout scale',
{ tag: ['@subgraph', '@canvas'] },
() => {
test('slot positions stay within node bounds after loading LG workflow', async ({
comfyPage
}) => {
const SLOT_BOUNDS_MARGIN = 20
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
const workflowPath = resolve(
import.meta.dirname,
'../assets/subgraphs/basic-subgraph.json'
)
const workflow = JSON.parse(
readFileSync(workflowPath, 'utf-8')
) as ComfyWorkflowJSON
workflow.extra = {
...workflow.extra,
workflowRendererVersion: 'LG'
}
await comfyPage.page.evaluate(
(wf) =>
window.app!.loadGraphData(wf as ComfyWorkflowJSON, true, true, null, {
openSource: 'template'
}),
workflow
)
await comfyPage.nextFrame()
// Wait for slot elements to appear in DOM
await comfyPage.page.locator('[data-slot-key]').first().waitFor()
const result: NodeSlotData[] = await comfyPage.page.evaluate(() => {
const nodes = window.app!.graph._nodes
const slotData: NodeSlotData[] = []
for (const node of nodes) {
const nodeId = String(node.id)
const nodeEl = document.querySelector(
`[data-node-id="${nodeId}"]`
) as HTMLElement | null
if (!nodeEl) continue
const slotEls = nodeEl.querySelectorAll('[data-slot-key]')
if (slotEls.length === 0) continue
const slots: SlotMeasurement[] = []
const nodeRect = nodeEl.getBoundingClientRect()
for (const slotEl of slotEls) {
const slotRect = slotEl.getBoundingClientRect()
const slotKey = (slotEl as HTMLElement).dataset.slotKey ?? 'unknown'
slots.push({
key: slotKey,
offsetX: slotRect.left + slotRect.width / 2 - nodeRect.left,
offsetY: slotRect.top + slotRect.height / 2 - nodeRect.top
})
}
slotData.push({
nodeId,
isSubgraph: !!node.isSubgraphNode?.(),
nodeW: nodeRect.width,
nodeH: nodeRect.height,
slots
})
}
return slotData
})
const subgraphNodes = result.filter((n) => n.isSubgraph)
expect(subgraphNodes.length).toBeGreaterThan(0)
for (const node of subgraphNodes) {
for (const slot of node.slots) {
expect(
slot.offsetX,
`Slot ${slot.key} on node ${node.nodeId}: X offset ${slot.offsetX} outside node width ${node.nodeW}`
).toBeGreaterThanOrEqual(-SLOT_BOUNDS_MARGIN)
expect(
slot.offsetX,
`Slot ${slot.key} on node ${node.nodeId}: X offset ${slot.offsetX} outside node width ${node.nodeW}`
).toBeLessThanOrEqual(node.nodeW + SLOT_BOUNDS_MARGIN)
expect(
slot.offsetY,
`Slot ${slot.key} on node ${node.nodeId}: Y offset ${slot.offsetY} outside node height ${node.nodeH}`
).toBeGreaterThanOrEqual(-SLOT_BOUNDS_MARGIN)
expect(
slot.offsetY,
`Slot ${slot.key} on node ${node.nodeId}: Y offset ${slot.offsetY} outside node height ${node.nodeH}`
).toBeLessThanOrEqual(node.nodeH + SLOT_BOUNDS_MARGIN)
}
}
})
}
)

Binary file not shown.

Before

Width:  |  Height:  |  Size: 103 KiB

After

Width:  |  Height:  |  Size: 101 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 113 KiB

After

Width:  |  Height:  |  Size: 112 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 57 KiB

After

Width:  |  Height:  |  Size: 57 KiB

View File

@@ -2,6 +2,7 @@ import {
comfyExpect as expect,
comfyPageFixture as test
} from '../../../../fixtures/ComfyPage'
import { TestIds } from '../../../../fixtures/selectors'
test.describe('Vue Upload Widgets', () => {
test.beforeEach(async ({ comfyPage }) => {
@@ -19,10 +20,14 @@ test.describe('Vue Upload Widgets', () => {
).not.toBeVisible()
await expect
.poll(() => comfyPage.page.getByText('Error loading image').count())
.poll(() =>
comfyPage.page.getByTestId(TestIds.errors.imageLoadError).count()
)
.toBeGreaterThan(0)
await expect
.poll(() => comfyPage.page.getByText('Error loading video').count())
.poll(() =>
comfyPage.page.getByTestId(TestIds.errors.videoLoadError).count()
)
.toBeGreaterThan(0)
})
})

View File

@@ -46,4 +46,16 @@ test.describe('Vue Multiline String Widget', () => {
await expect(textarea).toHaveValue('Keep me around')
})
test('should use native context menu when focused', async ({ comfyPage }) => {
const textarea = getFirstMultilineStringWidget(comfyPage)
const vueContextMenu = comfyPage.page.locator('.p-contextmenu')
await textarea.focus()
await textarea.click({ button: 'right' })
await expect(vueContextMenu).not.toBeVisible()
await textarea.blur()
await textarea.click({ button: 'right' })
await expect(vueContextMenu).toBeVisible()
})
})

View File

@@ -27,6 +27,17 @@ const config: KnipConfig = {
},
'packages/ingest-types': {
project: ['src/**/*.{js,ts}']
},
'apps/website': {
entry: [
'src/pages/**/*.astro',
'src/layouts/**/*.astro',
'src/components/**/*.vue',
'src/styles/global.css',
'astro.config.ts'
],
project: ['src/**/*.{astro,vue,ts}', '*.{js,ts,mjs}'],
ignoreDependencies: ['@comfyorg/design-system', '@vercel/analytics']
}
},
ignoreBinaries: ['python3'],

View File

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

View File

@@ -0,0 +1,46 @@
/*
* Design System Base — Brand tokens + fonts only.
* For marketing sites that don't use PrimeVue or the node editor.
* Import the full style.css instead for the desktop app.
*/
@import './fonts.css';
@theme {
/* Font Families */
--font-inter: 'Inter', sans-serif;
/* Palette Colors */
--color-charcoal-100: #55565e;
--color-charcoal-200: #494a50;
--color-charcoal-300: #3c3d42;
--color-charcoal-400: #313235;
--color-charcoal-500: #2d2e32;
--color-charcoal-600: #262729;
--color-charcoal-700: #202121;
--color-charcoal-800: #171718;
--color-neutral-550: #636363;
--color-ash-300: #bbbbbb;
--color-ash-500: #828282;
--color-ash-800: #444444;
--color-smoke-100: #f3f3f3;
--color-smoke-200: #e9e9e9;
--color-smoke-300: #e1e1e1;
--color-smoke-400: #d9d9d9;
--color-smoke-500: #c5c5c5;
--color-smoke-600: #b4b4b4;
--color-smoke-700: #a0a0a0;
--color-smoke-800: #8a8a8a;
--color-white: #ffffff;
--color-black: #000000;
/* Brand Colors */
--color-electric-400: #f0ff41;
--color-sapphire-700: #172dd7;
--color-brand-yellow: var(--color-electric-400);
--color-brand-blue: var(--color-sapphire-700);
}

1665
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -4,6 +4,7 @@ packages:
catalog:
'@alloc/quick-lru': ^5.2.0
'@astrojs/vue': ^5.0.0
'@comfyorg/comfyui-electron-types': 0.6.2
'@eslint/js': ^9.39.1
'@formkit/auto-animate': ^0.9.0
@@ -50,6 +51,7 @@ catalog:
'@types/node': ^24.1.0
'@types/semver': ^7.7.0
'@types/three': ^0.169.0
'@vercel/analytics': ^2.0.1
'@vitejs/plugin-vue': ^6.0.0
'@vitest/coverage-v8': ^4.0.16
'@vitest/ui': ^4.0.16
@@ -58,6 +60,7 @@ catalog:
'@vueuse/integrations': ^14.2.0
'@webgpu/types': ^0.1.66
algoliasearch: ^5.21.0
astro: ^5.10.0
axios: ^1.13.5
cross-env: ^10.1.0
cva: 1.0.0-beta.4

View File

@@ -22,6 +22,7 @@ interface PerfMeasurement {
layoutDurationMs: number
taskDurationMs: number
heapDeltaBytes: number
heapUsedBytes: number
domNodes: number
jsHeapTotalBytes: number
scriptDurationMs: number
@@ -43,22 +44,46 @@ const HISTORY_DIR = 'temp/perf-history'
type MetricKey =
| 'styleRecalcs'
| 'styleRecalcDurationMs'
| 'layouts'
| 'layoutDurationMs'
| 'taskDurationMs'
| 'domNodes'
| 'scriptDurationMs'
| 'eventListeners'
| 'totalBlockingTimeMs'
| 'frameDurationMs'
const REPORTED_METRICS: { key: MetricKey; label: string; unit: string }[] = [
{ key: 'styleRecalcs', label: 'style recalcs', unit: '' },
{ key: 'layouts', label: 'layouts', unit: '' },
| 'heapUsedBytes'
interface MetricDef {
key: MetricKey
label: string
unit: string
/** Minimum absolute delta to consider meaningful (effect size gate) */
minAbsDelta?: number
}
const REPORTED_METRICS: MetricDef[] = [
{ key: 'layoutDurationMs', label: 'layout duration', unit: 'ms' },
{
key: 'styleRecalcDurationMs',
label: 'style recalc duration',
unit: 'ms'
},
{ key: 'layouts', label: 'layout count', unit: '', minAbsDelta: 5 },
{
key: 'styleRecalcs',
label: 'style recalc count',
unit: '',
minAbsDelta: 5
},
{ key: 'taskDurationMs', label: 'task duration', unit: 'ms' },
{ key: 'domNodes', label: 'DOM nodes', unit: '' },
{ key: 'scriptDurationMs', label: 'script duration', unit: 'ms' },
{ key: 'eventListeners', label: 'event listeners', unit: '' },
{ key: 'totalBlockingTimeMs', label: 'TBT', unit: 'ms' },
{ key: 'frameDurationMs', label: 'frame duration', unit: 'ms' }
{ key: 'frameDurationMs', label: 'frame duration', unit: 'ms' },
{ key: 'heapUsedBytes', label: 'heap used', unit: 'bytes' },
{ key: 'domNodes', label: 'DOM nodes', unit: '', minAbsDelta: 5 },
{ key: 'eventListeners', label: 'event listeners', unit: '', minAbsDelta: 5 }
]
function groupByName(
@@ -134,7 +159,9 @@ function computeCV(stats: MetricStats): number {
}
function formatValue(value: number, unit: string): string {
return unit === 'ms' ? `${value.toFixed(0)}ms` : `${value.toFixed(0)}`
if (unit === 'ms') return `${value.toFixed(0)}ms`
if (unit === 'bytes') return formatBytes(value)
return `${value.toFixed(0)}`
}
function formatDelta(pct: number | null): string {
@@ -159,6 +186,21 @@ function meanMetric(samples: PerfMeasurement[], key: MetricKey): number | null {
return values.reduce((sum, v) => sum + v, 0) / values.length
}
function medianMetric(
samples: PerfMeasurement[],
key: MetricKey
): number | null {
const values = samples
.map((s) => getMetricValue(s, key))
.filter((v): v is number => v !== null)
.sort((a, b) => a - b)
if (values.length === 0) return null
const mid = Math.floor(values.length / 2)
return values.length % 2 === 0
? (values[mid - 1] + values[mid]) / 2
: values[mid]
}
function formatBytes(bytes: number): string {
if (Math.abs(bytes) < 1024) return `${bytes} B`
if (Math.abs(bytes) < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
@@ -173,7 +215,7 @@ function renderFullReport(
const lines: string[] = []
const baselineGroups = groupByName(baseline.measurements)
const tableHeader = [
'| Metric | Baseline | PR (n=3) | Δ | Sig |',
'| Metric | Baseline | PR (median) | Δ | Sig |',
'|--------|----------|----------|---|-----|'
]
@@ -183,36 +225,38 @@ function renderFullReport(
for (const [testName, prSamples] of prGroups) {
const baseSamples = baselineGroups.get(testName)
for (const { key, label, unit } of REPORTED_METRICS) {
const prMean = meanMetric(prSamples, key)
if (prMean === null) continue
for (const { key, label, unit, minAbsDelta } of REPORTED_METRICS) {
// Use median for PR values — robust to outlier runs in CI
const prVal = medianMetric(prSamples, key)
if (prVal === null) continue
const histStats = getHistoricalStats(historical, testName, key)
const cv = computeCV(histStats)
if (!baseSamples?.length) {
allRows.push(
`| ${testName}: ${label} | — | ${formatValue(prMean, unit)} | new | — |`
`| ${testName}: ${label} | — | ${formatValue(prVal, unit)} | new | — |`
)
continue
}
const baseVal = meanMetric(baseSamples, key)
const baseVal = medianMetric(baseSamples, key)
if (baseVal === null) {
allRows.push(
`| ${testName}: ${label} | — | ${formatValue(prMean, unit)} | new | — |`
`| ${testName}: ${label} | — | ${formatValue(prVal, unit)} | new | — |`
)
continue
}
const absDelta = prVal - baseVal
const deltaPct =
baseVal === 0
? prMean === 0
? prVal === 0
? 0
: null
: ((prMean - baseVal) / baseVal) * 100
const z = zScore(prMean, histStats)
const sig = classifyChange(z, cv)
: ((prVal - baseVal) / baseVal) * 100
const z = zScore(prVal, histStats)
const sig = classifyChange(z, cv, absDelta, minAbsDelta)
const row = `| ${testName}: ${label} | ${formatValue(baseVal, unit)} | ${formatValue(prMean, unit)} | ${formatDelta(deltaPct)} | ${formatSignificance(sig, z)} |`
const row = `| ${testName}: ${label} | ${formatValue(baseVal, unit)} | ${formatValue(prVal, unit)} | ${formatDelta(deltaPct)} | ${formatSignificance(sig, z)} |`
allRows.push(row)
if (isNoteworthy(sig)) {
flaggedRows.push(row)
@@ -299,7 +343,7 @@ function renderColdStartReport(
const lines: string[] = []
const baselineGroups = groupByName(baseline.measurements)
lines.push(
`> Collecting baseline variance data (${historicalCount}/5 runs). Significance will appear after 2 main branch runs.`,
`> Collecting baseline variance data (${historicalCount}/15 runs). Significance will appear after 2 main branch runs.`,
'',
'| Metric | Baseline | PR | Δ |',
'|--------|----------|-----|---|'
@@ -309,31 +353,31 @@ function renderColdStartReport(
const baseSamples = baselineGroups.get(testName)
for (const { key, label, unit } of REPORTED_METRICS) {
const prMean = meanMetric(prSamples, key)
if (prMean === null) continue
const prVal = medianMetric(prSamples, key)
if (prVal === null) continue
if (!baseSamples?.length) {
lines.push(
`| ${testName}: ${label} | — | ${formatValue(prMean, unit)} | new |`
`| ${testName}: ${label} | — | ${formatValue(prVal, unit)} | new |`
)
continue
}
const baseVal = meanMetric(baseSamples, key)
const baseVal = medianMetric(baseSamples, key)
if (baseVal === null) {
lines.push(
`| ${testName}: ${label} | — | ${formatValue(prMean, unit)} | new |`
`| ${testName}: ${label} | — | ${formatValue(prVal, unit)} | new |`
)
continue
}
const deltaPct =
baseVal === 0
? prMean === 0
? prVal === 0
? 0
: null
: ((prMean - baseVal) / baseVal) * 100
: ((prVal - baseVal) / baseVal) * 100
lines.push(
`| ${testName}: ${label} | ${formatValue(baseVal, unit)} | ${formatValue(prMean, unit)} | ${formatDelta(deltaPct)} |`
`| ${testName}: ${label} | ${formatValue(baseVal, unit)} | ${formatValue(prVal, unit)} | ${formatDelta(deltaPct)} |`
)
}
}
@@ -352,14 +396,10 @@ function renderNoBaselineReport(
)
for (const [testName, prSamples] of prGroups) {
for (const { key, label, unit } of REPORTED_METRICS) {
const prMean = meanMetric(prSamples, key)
if (prMean === null) continue
lines.push(`| ${testName}: ${label} | ${formatValue(prMean, unit)} |`)
const prVal = medianMetric(prSamples, key)
if (prVal === null) continue
lines.push(`| ${testName}: ${label} | ${formatValue(prVal, unit)} |`)
}
const heapMean =
prSamples.reduce((sum, s) => sum + (s.heapDeltaBytes ?? 0), 0) /
prSamples.length
lines.push(`| ${testName}: heap delta | ${formatBytes(heapMean)} |`)
}
return lines
}

View File

@@ -99,6 +99,21 @@ describe('classifyChange', () => {
expect(classifyChange(2, 10)).toBe('neutral')
expect(classifyChange(-2, 10)).toBe('neutral')
})
it('returns neutral when absDelta below minAbsDelta despite high z', () => {
// z=7.2 but only 1 unit change with minAbsDelta=5
expect(classifyChange(7.2, 10, 1, 5)).toBe('neutral')
expect(classifyChange(-7.2, 10, -1, 5)).toBe('neutral')
})
it('returns regression when absDelta meets minAbsDelta', () => {
expect(classifyChange(3, 10, 10, 5)).toBe('regression')
})
it('ignores effect size gate when minAbsDelta not provided', () => {
expect(classifyChange(3, 10)).toBe('regression')
expect(classifyChange(3, 10, 1)).toBe('regression')
})
})
describe('formatSignificance', () => {

View File

@@ -31,12 +31,28 @@ export function zScore(value: number, stats: MetricStats): number | null {
export type Significance = 'regression' | 'improvement' | 'neutral' | 'noisy'
/**
* Classify a metric change as regression/improvement/neutral/noisy.
*
* Uses both statistical significance (z-score) and practical significance
* (effect size gate via minAbsDelta) to reduce false positives from
* integer-quantized metrics with near-zero variance.
*/
export function classifyChange(
z: number | null,
historicalCV: number
historicalCV: number,
absDelta?: number,
minAbsDelta?: number
): Significance {
if (historicalCV > 50) return 'noisy'
if (z === null) return 'neutral'
// Effect size gate: require minimum absolute change for count metrics
// to avoid flagging e.g. 11→12 style recalcs as z=7.2 regression.
if (minAbsDelta !== undefined && absDelta !== undefined) {
if (Math.abs(absDelta) < minAbsDelta) return 'neutral'
}
if (z > 2) return 'regression'
if (z < -2) return 'improvement'
return 'neutral'

View File

@@ -9,13 +9,10 @@ import { captureException } from '@sentry/vue'
import BlockUI from 'primevue/blockui'
import { computed, onMounted, onUnmounted, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import GlobalDialog from '@/components/dialog/GlobalDialog.vue'
import config from '@/config'
import { isDesktop } from '@/platform/distribution/types'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useToastStore } from '@/platform/updates/common/toastStore'
import { app } from '@/scripts/app'
import { useWorkspaceStore } from '@/stores/workspaceStore'
import { electronAPI } from '@/utils/envUtil'
@@ -23,7 +20,6 @@ import { parsePreloadError } from '@/utils/preloadErrorUtil'
import { useDialogService } from '@/services/dialogService'
import { useConflictDetection } from '@/workbench/extensions/manager/composables/useConflictDetection'
const { t } = useI18n()
const workspaceStore = useWorkspaceStore()
app.extensionManager = useWorkspaceStore()
@@ -98,12 +94,17 @@ onMounted(() => {
}
})
}
useToastStore().add({
severity: 'error',
summary: t('g.preloadErrorTitle'),
detail: t('g.preloadError'),
life: 10000
})
// Disabled: Third-party custom node extensions frequently trigger this toast
// (e.g., bare "vue" imports, wrong relative paths to scripts/app.js, missing
// core dependencies). These are plugin bugs, not ComfyUI core failures, but
// the generic error message alarms users and offers no actionable guidance.
// The console.error above still logs the details for developers to debug.
// useToastStore().add({
// severity: 'error',
// summary: t('g.preloadErrorTitle'),
// detail: t('g.preloadError'),
// life: 10000
// })
})
// Capture resource load failures (CSS, scripts) in non-localhost distributions

View File

@@ -34,9 +34,7 @@
"CLEAR_BACKGROUND_COLOR": "#2b2f38",
"NODE_TITLE_COLOR": "#b2b7bd",
"NODE_SELECTED_TITLE_COLOR": "#FFF",
"NODE_TEXT_SIZE": 14,
"NODE_TEXT_COLOR": "#AAA",
"NODE_SUBTEXT_SIZE": 12,
"NODE_DEFAULT_COLOR": "#2b2f38",
"NODE_DEFAULT_BGCOLOR": "#242730",
"NODE_DEFAULT_BOXCOLOR": "#6e7581",
@@ -45,7 +43,6 @@
"NODE_BYPASS_BGCOLOR": "#FF00FF",
"NODE_ERROR_COLOUR": "#E00",
"DEFAULT_SHADOW_COLOR": "rgba(0,0,0,0.5)",
"DEFAULT_GROUP_FONT": 22,
"WIDGET_BGCOLOR": "#2b2f38",
"WIDGET_OUTLINE_COLOR": "#6e7581",
"WIDGET_TEXT_COLOR": "#DDD",

View File

@@ -25,10 +25,8 @@
"CLEAR_BACKGROUND_COLOR": "#141414",
"NODE_TITLE_COLOR": "#999",
"NODE_SELECTED_TITLE_COLOR": "#FFF",
"NODE_TEXT_SIZE": 14,
"NODE_TEXT_COLOR": "#AAA",
"NODE_TEXT_HIGHLIGHT_COLOR": "#FFF",
"NODE_SUBTEXT_SIZE": 12,
"NODE_DEFAULT_COLOR": "#333",
"NODE_DEFAULT_BGCOLOR": "#353535",
"NODE_DEFAULT_BOXCOLOR": "#666",
@@ -37,7 +35,6 @@
"NODE_BYPASS_BGCOLOR": "#FF00FF",
"NODE_ERROR_COLOUR": "#E00",
"DEFAULT_SHADOW_COLOR": "rgba(0,0,0,0.5)",
"DEFAULT_GROUP_FONT": 24,
"WIDGET_BGCOLOR": "#222",
"WIDGET_OUTLINE_COLOR": "#666",
"WIDGET_TEXT_COLOR": "#DDD",

View File

@@ -34,9 +34,7 @@
"CLEAR_BACKGROUND_COLOR": "#040506",
"NODE_TITLE_COLOR": "#999",
"NODE_SELECTED_TITLE_COLOR": "#e5eaf0",
"NODE_TEXT_SIZE": 14,
"NODE_TEXT_COLOR": "#bcc2c8",
"NODE_SUBTEXT_SIZE": 12,
"NODE_DEFAULT_COLOR": "#161b22",
"NODE_DEFAULT_BGCOLOR": "#13171d",
"NODE_DEFAULT_BOXCOLOR": "#30363d",
@@ -45,7 +43,6 @@
"NODE_BYPASS_BGCOLOR": "#FF00FF",
"NODE_ERROR_COLOUR": "#E00",
"DEFAULT_SHADOW_COLOR": "rgba(0,0,0,0.5)",
"DEFAULT_GROUP_FONT": 24,
"WIDGET_BGCOLOR": "#161b22",
"WIDGET_OUTLINE_COLOR": "#30363d",
"WIDGET_TEXT_COLOR": "#bcc2c8",

View File

@@ -26,10 +26,8 @@
"CLEAR_BACKGROUND_COLOR": "lightgray",
"NODE_TITLE_COLOR": "#222",
"NODE_SELECTED_TITLE_COLOR": "#000",
"NODE_TEXT_SIZE": 14,
"NODE_TEXT_COLOR": "#444",
"NODE_TEXT_HIGHLIGHT_COLOR": "#1e293b",
"NODE_SUBTEXT_SIZE": 12,
"NODE_DEFAULT_COLOR": "#F7F7F7",
"NODE_DEFAULT_BGCOLOR": "#F5F5F5",
"NODE_DEFAULT_BOXCOLOR": "#CCC",
@@ -38,7 +36,6 @@
"NODE_BYPASS_BGCOLOR": "#FF00FF",
"NODE_ERROR_COLOUR": "#E00",
"DEFAULT_SHADOW_COLOR": "rgba(0,0,0,0.1)",
"DEFAULT_GROUP_FONT": 24,
"WIDGET_BGCOLOR": "#D4D4D4",
"WIDGET_OUTLINE_COLOR": "#999",
"WIDGET_TEXT_COLOR": "#222",

View File

@@ -34,9 +34,7 @@
"CLEAR_BACKGROUND_COLOR": "#212732",
"NODE_TITLE_COLOR": "#999",
"NODE_SELECTED_TITLE_COLOR": "#e5eaf0",
"NODE_TEXT_SIZE": 14,
"NODE_TEXT_COLOR": "#bcc2c8",
"NODE_SUBTEXT_SIZE": 12,
"NODE_DEFAULT_COLOR": "#2e3440",
"NODE_DEFAULT_BGCOLOR": "#161b22",
"NODE_DEFAULT_BOXCOLOR": "#545d70",
@@ -45,7 +43,6 @@
"NODE_BYPASS_BGCOLOR": "#FF00FF",
"NODE_ERROR_COLOUR": "#E00",
"DEFAULT_SHADOW_COLOR": "rgba(0,0,0,0.5)",
"DEFAULT_GROUP_FONT": 24,
"WIDGET_BGCOLOR": "#2e3440",
"WIDGET_OUTLINE_COLOR": "#545d70",
"WIDGET_TEXT_COLOR": "#bcc2c8",

View File

@@ -19,9 +19,7 @@
"litegraph_base": {
"NODE_TITLE_COLOR": "#fdf6e3",
"NODE_SELECTED_TITLE_COLOR": "#A9D400",
"NODE_TEXT_SIZE": 14,
"NODE_TEXT_COLOR": "#657b83",
"NODE_SUBTEXT_SIZE": 12,
"NODE_DEFAULT_COLOR": "#094656",
"NODE_DEFAULT_BGCOLOR": "#073642",
"NODE_DEFAULT_BOXCOLOR": "#839496",
@@ -30,7 +28,6 @@
"NODE_BYPASS_BGCOLOR": "#FF00FF",
"NODE_ERROR_COLOUR": "#E00",
"DEFAULT_SHADOW_COLOR": "rgba(0,0,0,0.5)",
"DEFAULT_GROUP_FONT": 24,
"WIDGET_BGCOLOR": "#002b36",
"WIDGET_OUTLINE_COLOR": "#839496",
"WIDGET_TEXT_COLOR": "#fdf6e3",

View File

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

View File

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

View File

@@ -19,10 +19,7 @@ const props = defineProps<{
const queryString = computed(() => props.errorMessage + ' is:issue')
/**
* Open GitHub issues search and track telemetry.
*/
const openGitHubIssues = () => {
function openGitHubIssues() {
useTelemetry()?.trackUiButtonClicked({
button_id: 'error_dialog_find_existing_issues_clicked'
})

View File

@@ -49,7 +49,12 @@
<Button variant="muted-textonly" size="unset" @click="dismiss">
{{ t('g.dismiss') }}
</Button>
<Button variant="secondary" size="lg" @click="seeErrors">
<Button
variant="secondary"
size="lg"
data-testid="error-overlay-see-errors"
@click="seeErrors"
>
{{
appMode ? t('linearMode.error.goto') : t('errorOverlay.seeErrors')
}}

View File

@@ -84,7 +84,9 @@ watch(
pos: group.pos,
size: [group.size[0], group.titleHeight]
})
inputFontStyle.value = { fontSize: `${group.font_size * scale}px` }
inputFontStyle.value = {
fontSize: `${LiteGraph.GROUP_TEXT_SIZE * scale}px`
}
} else if (target instanceof LGraphNode) {
const node = target
const [x, y] = node.getBounding()

View File

@@ -9,12 +9,15 @@ import TabList from '@/components/tab/TabList.vue'
import Button from '@/components/ui/button/Button.vue'
import { useGraphHierarchy } from '@/composables/graph/useGraphHierarchy'
import { st } from '@/i18n'
import { app } from '@/scripts/app'
import { getActiveGraphNodeIds } from '@/utils/graphTraversalUtil'
import { SubgraphNode } from '@/lib/litegraph/src/litegraph'
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 { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { useMissingNodesErrorStore } from '@/platform/nodeReplacement/missingNodesErrorStore'
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
import type { RightSidePanelTab } from '@/stores/workspace/rightSidePanelStore'
import { resolveNodeDisplayName } from '@/utils/nodeTitleUtil'
@@ -38,12 +41,21 @@ import TabErrors from './errors/TabErrors.vue'
const canvasStore = useCanvasStore()
const executionErrorStore = useExecutionErrorStore()
const missingModelStore = useMissingModelStore()
const missingNodesErrorStore = useMissingNodesErrorStore()
const rightSidePanelStore = useRightSidePanelStore()
const settingStore = useSettingStore()
const { t } = useI18n()
const { hasAnyError, allErrorExecutionIds, activeMissingNodeGraphIds } =
storeToRefs(executionErrorStore)
const { hasAnyError, allErrorExecutionIds } = storeToRefs(executionErrorStore)
const activeMissingNodeGraphIds = computed<Set<string>>(() => {
if (!app.isGraphReady) return new Set()
return getActiveGraphNodeIds(
app.rootGraph,
canvasStore.currentGraph ?? app.rootGraph,
missingNodesErrorStore.missingAncestorExecutionIds
)
})
const { activeMissingModelGraphIds } = storeToRefs(missingModelStore)

View File

@@ -237,6 +237,11 @@ describe('ErrorNodeCard.vue', () => {
// Report is still generated with fallback log message
expect(mockGenerateErrorReport).toHaveBeenCalledOnce()
expect(mockGenerateErrorReport).toHaveBeenCalledWith(
expect.objectContaining({
serverLogs: 'Failed to retrieve server logs'
})
)
expect(wrapper.text()).toContain('ComfyUI Error Report')
})

View File

@@ -90,6 +90,7 @@
variant="secondary"
size="sm"
class="h-8 w-2/3 justify-center gap-1 rounded-lg text-xs"
data-testid="error-card-find-on-github"
@click="handleCheckGithub(error)"
>
{{ t('g.findOnGithub') }}
@@ -99,6 +100,7 @@
variant="secondary"
size="sm"
class="h-8 w-1/3 justify-center gap-1 rounded-lg text-xs"
data-testid="error-card-copy"
@click="handleCopyError(idx)"
>
{{ t('g.copy') }}
@@ -125,12 +127,10 @@
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import { useExternalLink } from '@/composables/useExternalLink'
import { useTelemetry } from '@/platform/telemetry'
import { useCommandStore } from '@/stores/commandStore'
import { cn } from '@/utils/tailwindUtil'
import type { ErrorCardData, ErrorItem } from './types'
import { useErrorActions } from './useErrorActions'
import { useErrorReport } from './useErrorReport'
const {
@@ -154,10 +154,8 @@ const emit = defineEmits<{
}>()
const { t } = useI18n()
const telemetry = useTelemetry()
const { staticUrls } = useExternalLink()
const commandStore = useCommandStore()
const { displayedDetailsMap } = useErrorReport(() => card)
const { findOnGitHub, contactSupport: handleGetHelp } = useErrorActions()
function handleLocateNode() {
if (card.nodeId) {
@@ -178,23 +176,6 @@ function handleCopyError(idx: number) {
}
function handleCheckGithub(error: ErrorItem) {
telemetry?.trackUiButtonClicked({
button_id: 'error_tab_find_existing_issues_clicked'
})
const query = encodeURIComponent(error.message + ' is:issue')
window.open(
`${staticUrls.githubIssues}?q=${query}`,
'_blank',
'noopener,noreferrer'
)
}
function handleGetHelp() {
telemetry?.trackHelpResourceClicked({
resource_type: 'help_feedback',
is_external: true,
source: 'error_dialog'
})
commandStore.execute('Comfy.ContactSupport')
findOnGitHub(error.message)
}
</script>

View File

@@ -1,7 +1,5 @@
import { mount } from '@vue/test-utils'
import { createTestingPinia } from '@pinia/testing'
import PrimeVue from 'primevue/config'
import { ref } from 'vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
@@ -42,23 +40,25 @@ vi.mock('@/stores/systemStatsStore', () => ({
})
}))
const mockApplyChanges = vi.fn()
const mockIsRestarting = ref(false)
const mockApplyChanges = vi.hoisted(() => vi.fn())
const mockIsRestarting = vi.hoisted(() => ({ value: false }))
vi.mock('@/workbench/extensions/manager/composables/useApplyChanges', () => ({
useApplyChanges: () => ({
isRestarting: mockIsRestarting,
get isRestarting() {
return mockIsRestarting.value
},
applyChanges: mockApplyChanges
})
}))
const mockIsPackInstalled = vi.fn(() => false)
const mockIsPackInstalled = vi.hoisted(() => vi.fn(() => false))
vi.mock('@/workbench/extensions/manager/stores/comfyManagerStore', () => ({
useComfyManagerStore: () => ({
isPackInstalled: mockIsPackInstalled
})
}))
const mockShouldShowManagerButtons = { value: false }
const mockShouldShowManagerButtons = vi.hoisted(() => ({ value: false }))
vi.mock('@/workbench/extensions/manager/composables/useManagerState', () => ({
useManagerState: () => ({
shouldShowManagerButtons: mockShouldShowManagerButtons
@@ -128,7 +128,7 @@ function mountCard(
...props
},
global: {
plugins: [createTestingPinia({ createSpy: vi.fn }), PrimeVue, i18n],
plugins: [createTestingPinia({ createSpy: vi.fn }), i18n],
stubs: {
DotSpinner: { template: '<span role="status" aria-label="loading" />' }
}

View File

@@ -209,12 +209,9 @@ describe('TabErrors.vue', () => {
}
})
// Find the copy button by text (rendered inside ErrorNodeCard)
const copyButton = wrapper
.findAll('button')
.find((btn) => btn.text().includes('Copy'))
expect(copyButton).toBeTruthy()
await copyButton!.trigger('click')
const copyButton = wrapper.find('[data-testid="error-card-copy"]')
expect(copyButton.exists()).toBe(true)
await copyButton.trigger('click')
expect(mockCopy).toHaveBeenCalledWith('Test message\n\nTest details')
})
@@ -245,5 +242,9 @@ describe('TabErrors.vue', () => {
// Should render in the dedicated runtime error panel, not inside accordion
const runtimePanel = wrapper.find('[data-testid="runtime-error-panel"]')
expect(runtimePanel.exists()).toBe(true)
// Verify the error message appears exactly once (not duplicated in accordion)
expect(
wrapper.text().match(/RuntimeError: Out of memory/g) ?? []
).toHaveLength(1)
})
})

View File

@@ -53,6 +53,7 @@
<PropertiesAccordionItem
v-for="group in filteredGroups"
:key="group.title"
:data-testid="'error-group-' + group.type.replaceAll('_', '-')"
:collapse="isSectionCollapsed(group.title) && !isSearching"
class="border-b border-interface-stroke"
:size="getGroupSize(group)"
@@ -209,12 +210,9 @@
import { computed, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useCommandStore } from '@/stores/commandStore'
import { useCopyToClipboard } from '@/composables/useCopyToClipboard'
import { useFocusNode } from '@/composables/canvas/useFocusNode'
import { useExternalLink } from '@/composables/useExternalLink'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useTelemetry } from '@/platform/telemetry'
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
import { useManagerState } from '@/workbench/extensions/manager/composables/useManagerState'
import { ManagerTab } from '@/workbench/extensions/manager/types/comfyManagerTypes'
@@ -238,6 +236,7 @@ import Button from '@/components/ui/button/Button.vue'
import DotSpinner from '@/components/common/DotSpinner.vue'
import { usePackInstall } from '@/workbench/extensions/manager/composables/nodePack/usePackInstall'
import { useMissingNodes } from '@/workbench/extensions/manager/composables/nodePack/useMissingNodes'
import { useErrorActions } from './useErrorActions'
import { useErrorGroups } from './useErrorGroups'
import type { SwapNodeGroup } from './useErrorGroups'
import type { ErrorGroup } from './types'
@@ -246,7 +245,7 @@ import { useNodeReplacement } from '@/platform/nodeReplacement/useNodeReplacemen
const { t } = useI18n()
const { copyToClipboard } = useCopyToClipboard()
const { focusNode, enterSubgraph } = useFocusNode()
const { staticUrls } = useExternalLink()
const { openGitHubIssues, contactSupport } = useErrorActions()
const settingStore = useSettingStore()
const rightSidePanelStore = useRightSidePanelStore()
const { shouldShowManagerButtons, shouldShowInstallButton, openManager } =
@@ -372,13 +371,13 @@ watch(
if (!graphNodeId) return
const prefix = `${graphNodeId}:`
for (const group of allErrorGroups.value) {
const hasMatch =
group.type === 'execution' &&
group.cards.some(
(card) =>
card.graphNodeId === graphNodeId ||
(card.nodeId?.startsWith(prefix) ?? false)
)
if (group.type !== 'execution') continue
const hasMatch = group.cards.some(
(card) =>
card.graphNodeId === graphNodeId ||
(card.nodeId?.startsWith(prefix) ?? false)
)
setSectionCollapsed(group.title, !hasMatch)
}
rightSidePanelStore.focusedErrorNodeId = null
@@ -418,20 +417,4 @@ function handleReplaceAll() {
function handleEnterSubgraph(nodeId: string) {
enterSubgraph(nodeId, errorNodeCache.value)
}
function openGitHubIssues() {
useTelemetry()?.trackUiButtonClicked({
button_id: 'error_tab_github_issues_clicked'
})
window.open(staticUrls.githubIssues, '_blank', 'noopener,noreferrer')
}
async function contactSupport() {
useTelemetry()?.trackHelpResourceClicked({
resource_type: 'help_feedback',
is_external: true,
source: 'error_dialog'
})
useCommandStore().execute('Comfy.ContactSupport')
}
</script>

View File

@@ -47,7 +47,7 @@ vi.mock('@/utils/executableGroupNodeDto', () => ({
isGroupNode: vi.fn(() => false)
}))
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { useMissingNodesErrorStore } from '@/platform/nodeReplacement/missingNodesErrorStore'
import { useErrorGroups } from './useErrorGroups'
function makeMissingNodeType(
@@ -80,8 +80,7 @@ describe('swapNodeGroups computed', () => {
})
function getSwapNodeGroups(nodeTypes: MissingNodeType[]) {
const store = useExecutionErrorStore()
store.surfaceMissingNodes(nodeTypes)
useMissingNodesErrorStore().surfaceMissingNodes(nodeTypes)
const searchQuery = ref('')
const t = (key: string) => key

View File

@@ -0,0 +1,39 @@
import { useCommandStore } from '@/stores/commandStore'
import { useExternalLink } from '@/composables/useExternalLink'
import { useTelemetry } from '@/platform/telemetry'
export function useErrorActions() {
const telemetry = useTelemetry()
const commandStore = useCommandStore()
const { staticUrls } = useExternalLink()
function openGitHubIssues() {
telemetry?.trackUiButtonClicked({
button_id: 'error_tab_github_issues_clicked'
})
window.open(staticUrls.githubIssues, '_blank', 'noopener,noreferrer')
}
function contactSupport() {
telemetry?.trackHelpResourceClicked({
resource_type: 'help_feedback',
is_external: true,
source: 'error_dialog'
})
void commandStore.execute('Comfy.ContactSupport')
}
function findOnGitHub(errorMessage: string) {
telemetry?.trackUiButtonClicked({
button_id: 'error_tab_find_existing_issues_clicked'
})
const query = encodeURIComponent(errorMessage + ' is:issue')
window.open(
`${staticUrls.githubIssues}?q=${query}`,
'_blank',
'noopener,noreferrer'
)
}
return { openGitHubIssues, contactSupport, findOnGitHub }
}

View File

@@ -58,6 +58,7 @@ vi.mock(
)
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { useMissingNodesErrorStore } from '@/platform/nodeReplacement/missingNodesErrorStore'
import { useErrorGroups } from './useErrorGroups'
function makeMissingNodeType(
@@ -126,8 +127,9 @@ describe('useErrorGroups', () => {
})
it('groups non-replaceable nodes by cnrId', async () => {
const { store, groups } = createErrorGroups()
store.setMissingNodeTypes([
const { groups } = createErrorGroups()
const missingNodesStore = useMissingNodesErrorStore()
missingNodesStore.setMissingNodeTypes([
makeMissingNodeType('NodeA', { cnrId: 'pack-1' }),
makeMissingNodeType('NodeB', { cnrId: 'pack-1', nodeId: '2' }),
makeMissingNodeType('NodeC', { cnrId: 'pack-2', nodeId: '3' })
@@ -146,8 +148,9 @@ describe('useErrorGroups', () => {
})
it('excludes replaceable nodes from missingPackGroups', async () => {
const { store, groups } = createErrorGroups()
store.setMissingNodeTypes([
const { groups } = createErrorGroups()
const missingNodesStore = useMissingNodesErrorStore()
missingNodesStore.setMissingNodeTypes([
makeMissingNodeType('OldNode', {
isReplaceable: true,
replacement: { new_node_id: 'NewNode' }
@@ -164,8 +167,9 @@ describe('useErrorGroups', () => {
})
it('groups nodes without cnrId under null packId', async () => {
const { store, groups } = createErrorGroups()
store.setMissingNodeTypes([
const { groups } = createErrorGroups()
const missingNodesStore = useMissingNodesErrorStore()
missingNodesStore.setMissingNodeTypes([
makeMissingNodeType('UnknownNode', { nodeId: '1' }),
makeMissingNodeType('AnotherUnknown', { nodeId: '2' })
])
@@ -177,8 +181,9 @@ describe('useErrorGroups', () => {
})
it('sorts groups alphabetically with null packId last', async () => {
const { store, groups } = createErrorGroups()
store.setMissingNodeTypes([
const { groups } = createErrorGroups()
const missingNodesStore = useMissingNodesErrorStore()
missingNodesStore.setMissingNodeTypes([
makeMissingNodeType('NodeA', { cnrId: 'zebra-pack' }),
makeMissingNodeType('NodeB', { nodeId: '2' }),
makeMissingNodeType('NodeC', { cnrId: 'alpha-pack', nodeId: '3' })
@@ -190,8 +195,9 @@ describe('useErrorGroups', () => {
})
it('sorts nodeTypes within each group alphabetically by type then nodeId', async () => {
const { store, groups } = createErrorGroups()
store.setMissingNodeTypes([
const { groups } = createErrorGroups()
const missingNodesStore = useMissingNodesErrorStore()
missingNodesStore.setMissingNodeTypes([
makeMissingNodeType('NodeB', { cnrId: 'pack-1', nodeId: '2' }),
makeMissingNodeType('NodeA', { cnrId: 'pack-1', nodeId: '3' }),
makeMissingNodeType('NodeA', { cnrId: 'pack-1', nodeId: '1' })
@@ -206,8 +212,9 @@ describe('useErrorGroups', () => {
})
it('handles string nodeType entries', async () => {
const { store, groups } = createErrorGroups()
store.setMissingNodeTypes([
const { groups } = createErrorGroups()
const missingNodesStore = useMissingNodesErrorStore()
missingNodesStore.setMissingNodeTypes([
'StringGroupNode' as unknown as MissingNodeType
])
await nextTick()
@@ -224,8 +231,9 @@ describe('useErrorGroups', () => {
})
it('includes missing_node group when missing nodes exist', async () => {
const { store, groups } = createErrorGroups()
store.setMissingNodeTypes([
const { groups } = createErrorGroups()
const missingNodesStore = useMissingNodesErrorStore()
missingNodesStore.setMissingNodeTypes([
makeMissingNodeType('NodeA', { cnrId: 'pack-1' })
])
await nextTick()
@@ -237,8 +245,9 @@ describe('useErrorGroups', () => {
})
it('includes swap_nodes group when replaceable nodes exist', async () => {
const { store, groups } = createErrorGroups()
store.setMissingNodeTypes([
const { groups } = createErrorGroups()
const missingNodesStore = useMissingNodesErrorStore()
missingNodesStore.setMissingNodeTypes([
makeMissingNodeType('OldNode', {
isReplaceable: true,
replacement: { new_node_id: 'NewNode' }
@@ -253,8 +262,9 @@ describe('useErrorGroups', () => {
})
it('includes both swap_nodes and missing_node when both exist', async () => {
const { store, groups } = createErrorGroups()
store.setMissingNodeTypes([
const { groups } = createErrorGroups()
const missingNodesStore = useMissingNodesErrorStore()
missingNodesStore.setMissingNodeTypes([
makeMissingNodeType('OldNode', {
isReplaceable: true,
replacement: { new_node_id: 'NewNode' }
@@ -272,8 +282,9 @@ describe('useErrorGroups', () => {
})
it('swap_nodes has lower priority than missing_node', async () => {
const { store, groups } = createErrorGroups()
store.setMissingNodeTypes([
const { groups } = createErrorGroups()
const missingNodesStore = useMissingNodesErrorStore()
missingNodesStore.setMissingNodeTypes([
makeMissingNodeType('OldNode', {
isReplaceable: true,
replacement: { new_node_id: 'NewNode' }
@@ -533,13 +544,18 @@ describe('useErrorGroups', () => {
})
it('includes missing node group title as message', async () => {
const { store, groups } = createErrorGroups()
store.setMissingNodeTypes([
const { groups } = createErrorGroups()
const missingNodesStore = useMissingNodesErrorStore()
missingNodesStore.setMissingNodeTypes([
makeMissingNodeType('NodeA', { cnrId: 'pack-1' })
])
await nextTick()
expect(groups.groupedErrorMessages.value.length).toBeGreaterThan(0)
const missingGroup = groups.allErrorGroups.value.find(
(g) => g.type === 'missing_node'
)
expect(missingGroup).toBeDefined()
expect(groups.groupedErrorMessages.value).toContain(missingGroup!.title)
})
})

View File

@@ -5,6 +5,7 @@ import type { IFuseOptions } from 'fuse.js'
import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { useMissingNodesErrorStore } from '@/platform/nodeReplacement/missingNodesErrorStore'
import { useComfyRegistryStore } from '@/stores/comfyRegistryStore'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { app } from '@/scripts/app'
@@ -195,12 +196,8 @@ function searchErrorGroups(groups: ErrorGroup[], query: string) {
cardIndex: ci,
searchableNodeId: card.nodeId ?? '',
searchableNodeTitle: card.nodeTitle ?? '',
searchableMessage: card.errors
.map((e: ErrorItem) => e.message)
.join(' '),
searchableDetails: card.errors
.map((e: ErrorItem) => e.details ?? '')
.join(' ')
searchableMessage: card.errors.map((e) => e.message).join(' '),
searchableDetails: card.errors.map((e) => e.details ?? '').join(' ')
})
}
}
@@ -240,6 +237,7 @@ export function useErrorGroups(
t: (key: string) => string
) {
const executionErrorStore = useExecutionErrorStore()
const missingNodesStore = useMissingNodesErrorStore()
const missingModelStore = useMissingModelStore()
const canvasStore = useCanvasStore()
const { inferPackFromNodeName } = useComfyRegistryStore()
@@ -285,7 +283,7 @@ export function useErrorGroups(
const missingNodeCache = computed(() => {
const map = new Map<string, LGraphNode>()
const nodeTypes = executionErrorStore.missingNodesError?.nodeTypes ?? []
const nodeTypes = missingNodesStore.missingNodesError?.nodeTypes ?? []
for (const nodeType of nodeTypes) {
if (typeof nodeType === 'string') continue
if (nodeType.nodeId == null) continue
@@ -407,7 +405,7 @@ export function useErrorGroups(
const asyncResolvedIds = ref<Map<string, string | null>>(new Map())
const pendingTypes = computed(() =>
(executionErrorStore.missingNodesError?.nodeTypes ?? []).filter(
(missingNodesStore.missingNodesError?.nodeTypes ?? []).filter(
(n): n is Exclude<MissingNodeType, string> =>
typeof n !== 'string' && !n.cnrId
)
@@ -448,6 +446,8 @@ export function useErrorGroups(
for (const r of results) {
if (r.status === 'fulfilled') {
final.set(r.value.type, r.value.packId)
} else {
console.warn('Failed to resolve pack ID:', r.reason)
}
}
// Clear any remaining RESOLVING markers for failed lookups
@@ -459,8 +459,18 @@ export function useErrorGroups(
{ immediate: true }
)
// Evict stale entries when missing nodes are cleared
watch(
() => missingNodesStore.missingNodesError,
(error) => {
if (!error && asyncResolvedIds.value.size > 0) {
asyncResolvedIds.value = new Map()
}
}
)
const missingPackGroups = computed<MissingPackGroup[]>(() => {
const nodeTypes = executionErrorStore.missingNodesError?.nodeTypes ?? []
const nodeTypes = missingNodesStore.missingNodesError?.nodeTypes ?? []
const map = new Map<
string | null,
{ nodeTypes: MissingNodeType[]; isResolving: boolean }
@@ -522,7 +532,7 @@ export function useErrorGroups(
})
const swapNodeGroups = computed<SwapNodeGroup[]>(() => {
const nodeTypes = executionErrorStore.missingNodesError?.nodeTypes ?? []
const nodeTypes = missingNodesStore.missingNodesError?.nodeTypes ?? []
const map = new Map<string, SwapNodeGroup>()
for (const nodeType of nodeTypes) {
@@ -546,7 +556,7 @@ export function useErrorGroups(
/** Builds an ErrorGroup from missingNodesError. Returns [] when none present. */
function buildMissingNodeGroups(): ErrorGroup[] {
const error = executionErrorStore.missingNodesError
const error = missingNodesStore.missingNodesError
if (!error) return []
const groups: ErrorGroup[] = []

View File

@@ -2,6 +2,8 @@ import { computed, onMounted, onUnmounted, reactive, toValue } from 'vue'
import type { MaybeRefOrGetter } from 'vue'
import { until } from '@vueuse/core'
import { api } from '@/scripts/api'
import { app } from '@/scripts/app'
import { useSystemStatsStore } from '@/stores/systemStatsStore'
@@ -40,24 +42,33 @@ export function useErrorReport(cardSource: MaybeRefOrGetter<ErrorCardData>) {
if (runtimeErrors.length === 0) return
if (!systemStatsStore.systemStats) {
try {
await systemStatsStore.refetchSystemStats()
} catch {
return
if (systemStatsStore.isLoading) {
await until(systemStatsStore.isLoading).toBe(false)
} else {
try {
await systemStatsStore.refetchSystemStats()
} catch (e) {
console.warn('Failed to fetch system stats for error report:', e)
return
}
}
}
if (cancelled || !systemStatsStore.systemStats) return
let logs: string
try {
logs = await api.getLogs()
} catch {
logs = 'Failed to retrieve server logs'
}
if (!systemStatsStore.systemStats || cancelled) return
const logs = await api
.getLogs()
.catch(() => 'Failed to retrieve server logs')
if (cancelled) return
const workflow = app.rootGraph.serialize()
const workflow = (() => {
try {
return app.rootGraph.serialize()
} catch (e) {
console.warn('Failed to serialize workflow for error report:', e)
return null
}
})()
if (!workflow) return
for (const { error, idx } of runtimeErrors) {
try {
@@ -72,8 +83,8 @@ export function useErrorReport(cardSource: MaybeRefOrGetter<ErrorCardData>) {
workflow
})
enrichedDetails[idx] = report
} catch {
// Fallback: keep original error.details
} catch (e) {
console.warn('Failed to generate error report:', e)
}
}
})

View File

@@ -315,6 +315,45 @@ describe('installErrorClearingHooks lifecycle', () => {
cleanup()
expect(graph.onNodeAdded).toBe(originalHook)
})
it('restores original node callbacks when a node is removed', () => {
const graph = new LGraph()
const node = new LGraphNode('test')
node.addInput('clip', 'CLIP')
node.addWidget('number', 'steps', 20, () => undefined, {})
const originalOnConnectionsChange = vi.fn()
const originalOnWidgetChanged = vi.fn()
node.onConnectionsChange = originalOnConnectionsChange
node.onWidgetChanged = originalOnWidgetChanged
graph.add(node)
installErrorClearingHooks(graph)
// Callbacks should be chained (not the originals)
expect(node.onConnectionsChange).not.toBe(originalOnConnectionsChange)
expect(node.onWidgetChanged).not.toBe(originalOnWidgetChanged)
// Simulate node removal via the graph hook
graph.onNodeRemoved!(node)
// Original callbacks should be restored
expect(node.onConnectionsChange).toBe(originalOnConnectionsChange)
expect(node.onWidgetChanged).toBe(originalOnWidgetChanged)
})
it('does not double-wrap callbacks when installErrorClearingHooks is called twice', () => {
const graph = new LGraph()
const node = new LGraphNode('test')
node.addInput('clip', 'CLIP')
graph.add(node)
installErrorClearingHooks(graph)
const chainedAfterFirst = node.onConnectionsChange
// Install again on the same graph — should be a no-op for existing nodes
installErrorClearingHooks(graph)
expect(node.onConnectionsChange).toBe(chainedAfterFirst)
})
})
describe('clearWidgetRelatedErrors parameter routing', () => {

View File

@@ -35,10 +35,22 @@ function resolvePromotedExecId(
const hookedNodes = new WeakSet<LGraphNode>()
type OriginalCallbacks = {
onConnectionsChange: LGraphNode['onConnectionsChange']
onWidgetChanged: LGraphNode['onWidgetChanged']
}
const originalCallbacks = new WeakMap<LGraphNode, OriginalCallbacks>()
function installNodeHooks(node: LGraphNode): void {
if (hookedNodes.has(node)) return
hookedNodes.add(node)
originalCallbacks.set(node, {
onConnectionsChange: node.onConnectionsChange,
onWidgetChanged: node.onWidgetChanged
})
node.onConnectionsChange = useChainCallback(
node.onConnectionsChange,
function (type, slotIndex, isConnected) {
@@ -82,6 +94,15 @@ function installNodeHooks(node: LGraphNode): void {
)
}
function restoreNodeHooks(node: LGraphNode): void {
const originals = originalCallbacks.get(node)
if (!originals) return
node.onConnectionsChange = originals.onConnectionsChange
node.onWidgetChanged = originals.onWidgetChanged
originalCallbacks.delete(node)
hookedNodes.delete(node)
}
function installNodeHooksRecursive(node: LGraphNode): void {
installNodeHooks(node)
if (node.isSubgraphNode?.()) {
@@ -91,6 +112,15 @@ function installNodeHooksRecursive(node: LGraphNode): void {
}
}
function restoreNodeHooksRecursive(node: LGraphNode): void {
restoreNodeHooks(node)
if (node.isSubgraphNode?.()) {
for (const innerNode of node.subgraph._nodes ?? []) {
restoreNodeHooksRecursive(innerNode)
}
}
}
export function installErrorClearingHooks(graph: LGraph): () => void {
for (const node of graph._nodes ?? []) {
installNodeHooksRecursive(node)
@@ -102,7 +132,17 @@ export function installErrorClearingHooks(graph: LGraph): () => void {
originalOnNodeAdded?.call(this, node)
}
const originalOnNodeRemoved = graph.onNodeRemoved
graph.onNodeRemoved = function (node: LGraphNode) {
restoreNodeHooksRecursive(node)
originalOnNodeRemoved?.call(this, node)
}
return () => {
for (const node of graph._nodes ?? []) {
restoreNodeHooksRecursive(node)
}
graph.onNodeAdded = originalOnNodeAdded || undefined
graph.onNodeRemoved = originalOnNodeRemoved || undefined
}
}

View File

@@ -0,0 +1,111 @@
import type { Ref } from 'vue'
import { computed, watch } from 'vue'
import type { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
import { useSettingStore } from '@/platform/settings/settingStore'
import { app } from '@/scripts/app'
import type { NodeError } from '@/schemas/apiSchema'
import { getParentExecutionIds } from '@/types/nodeIdentification'
import { forEachNode, getNodeByExecutionId } from '@/utils/graphTraversalUtil'
function setNodeHasErrors(node: LGraphNode, hasErrors: boolean): void {
if (node.has_errors === hasErrors) return
const oldValue = node.has_errors
node.has_errors = hasErrors
node.graph?.trigger('node:property:changed', {
type: 'node:property:changed',
nodeId: node.id,
property: 'has_errors',
oldValue,
newValue: hasErrors
})
}
/**
* Single-pass reconciliation of node error flags.
* Collects the set of nodes that should have errors, then walks all nodes
* once, setting each flag exactly once. This avoids the redundant
* true→false→true transition (and duplicate events) that a clear-then-apply
* approach would cause.
*/
function reconcileNodeErrorFlags(
rootGraph: LGraph,
nodeErrors: Record<string, NodeError> | null,
missingModelExecIds: Set<string>
): void {
// Collect nodes and slot info that should be flagged
// Includes both error-owning nodes and their ancestor containers
const flaggedNodes = new Set<LGraphNode>()
const errorSlots = new Map<LGraphNode, Set<string>>()
if (nodeErrors) {
for (const [executionId, nodeError] of Object.entries(nodeErrors)) {
const node = getNodeByExecutionId(rootGraph, executionId)
if (!node) continue
flaggedNodes.add(node)
const slotNames = new Set<string>()
for (const error of nodeError.errors) {
const name = error.extra_info?.input_name
if (name) slotNames.add(name)
}
if (slotNames.size > 0) errorSlots.set(node, slotNames)
for (const parentId of getParentExecutionIds(executionId)) {
const parentNode = getNodeByExecutionId(rootGraph, parentId)
if (parentNode) flaggedNodes.add(parentNode)
}
}
}
for (const execId of missingModelExecIds) {
const node = getNodeByExecutionId(rootGraph, execId)
if (node) flaggedNodes.add(node)
}
forEachNode(rootGraph, (node) => {
setNodeHasErrors(node, flaggedNodes.has(node))
if (node.inputs) {
const nodeSlotNames = errorSlots.get(node)
for (const slot of node.inputs) {
slot.hasErrors = !!nodeSlotNames?.has(slot.name)
}
}
})
}
export function useNodeErrorFlagSync(
lastNodeErrors: Ref<Record<string, NodeError> | null>,
missingModelStore: ReturnType<typeof useMissingModelStore>
): () => void {
const settingStore = useSettingStore()
const showErrorsTab = computed(() =>
settingStore.get('Comfy.RightSidePanel.ShowErrorsTab')
)
const stop = watch(
[
lastNodeErrors,
() => missingModelStore.missingModelNodeIds,
showErrorsTab
],
() => {
if (!app.isGraphReady) return
// Legacy (LGraphNode) only: suppress missing-model error flags when
// the Errors tab is hidden, since legacy nodes lack the per-widget
// red highlight that Vue nodes use to indicate *why* a node has errors.
// Vue nodes compute hasAnyError independently and are unaffected.
reconcileNodeErrorFlags(
app.rootGraph,
lastNodeErrors.value,
showErrorsTab.value
? missingModelStore.missingModelAncestorExecutionIds
: new Set()
)
},
{ flush: 'post' }
)
return stop
}

View File

@@ -0,0 +1,94 @@
import { computed, ref } from 'vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const mockCopy = vi.fn()
const mockToastAdd = vi.fn()
vi.mock('@vueuse/core', () => ({
useClipboard: vi.fn(() => ({
copy: mockCopy,
copied: ref(false),
isSupported: computed(() => true)
}))
}))
vi.mock('primevue/usetoast', () => ({
useToast: vi.fn(() => ({
add: mockToastAdd
}))
}))
vi.mock('@/i18n', () => ({
t: (key: string) => key
}))
import { useClipboard } from '@vueuse/core'
import { useCopyToClipboard } from '@/composables/useCopyToClipboard'
describe('useCopyToClipboard', () => {
beforeEach(() => {
vi.resetAllMocks()
vi.mocked(useClipboard).mockReturnValue({
copy: mockCopy,
copied: ref(false),
isSupported: computed(() => true),
text: ref('')
})
})
it('shows success toast when modern clipboard succeeds', async () => {
mockCopy.mockResolvedValue(undefined)
const { copyToClipboard } = useCopyToClipboard()
await copyToClipboard('hello')
expect(mockCopy).toHaveBeenCalledWith('hello')
expect(mockToastAdd).toHaveBeenCalledWith(
expect.objectContaining({ severity: 'success' })
)
})
it('falls back to legacy when modern clipboard fails', async () => {
mockCopy.mockRejectedValue(new Error('Not allowed'))
document.execCommand = vi.fn(() => true)
const { copyToClipboard } = useCopyToClipboard()
await copyToClipboard('hello')
expect(document.execCommand).toHaveBeenCalledWith('copy')
expect(mockToastAdd).toHaveBeenCalledWith(
expect.objectContaining({ severity: 'success' })
)
})
it('shows error toast when both modern and legacy fail', async () => {
mockCopy.mockRejectedValue(new Error('Not allowed'))
document.execCommand = vi.fn(() => false)
const { copyToClipboard } = useCopyToClipboard()
await copyToClipboard('hello')
expect(mockToastAdd).toHaveBeenCalledWith(
expect.objectContaining({ severity: 'error' })
)
})
it('falls through to legacy when isSupported is false', async () => {
vi.mocked(useClipboard).mockReturnValue({
copy: mockCopy,
copied: ref(false),
isSupported: computed(() => false),
text: ref('')
})
document.execCommand = vi.fn(() => true)
const { copyToClipboard } = useCopyToClipboard()
await copyToClipboard('hello')
expect(mockCopy).not.toHaveBeenCalled()
expect(document.execCommand).toHaveBeenCalledWith('copy')
expect(mockToastAdd).toHaveBeenCalledWith(
expect.objectContaining({ severity: 'success' })
)
})
})

View File

@@ -3,34 +3,60 @@ import { useToast } from 'primevue/usetoast'
import { t } from '@/i18n'
function legacyCopy(text: string): boolean {
const textarea = document.createElement('textarea')
textarea.setAttribute('readonly', '')
textarea.value = text
textarea.style.position = 'fixed'
textarea.style.left = '-9999px'
textarea.style.top = '-9999px'
document.body.appendChild(textarea)
textarea.select()
try {
return document.execCommand('copy')
} finally {
textarea.remove()
}
}
export function useCopyToClipboard() {
const { copy, copied } = useClipboard({ legacy: true })
const { copy, isSupported } = useClipboard()
const toast = useToast()
async function copyToClipboard(text: string) {
let success = false
try {
await copy(text)
if (copied.value) {
toast.add({
severity: 'success',
summary: t('g.success'),
detail: t('clipboard.successMessage'),
life: 3000
})
} else {
toast.add({
severity: 'error',
summary: t('g.error'),
detail: t('clipboard.errorMessage')
})
if (isSupported.value) {
await copy(text)
success = true
}
} catch {
toast.add({
severity: 'error',
summary: t('g.error'),
detail: t('clipboard.errorMessage')
})
// Modern clipboard API failed, fall through to legacy
}
if (!success) {
try {
success = legacyCopy(text)
} catch {
// Legacy also failed
}
}
toast.add(
success
? {
severity: 'success',
summary: t('g.success'),
detail: t('clipboard.successMessage'),
life: 3000
}
: {
severity: 'error',
summary: t('g.error'),
detail: t('clipboard.errorMessage')
}
)
}
return {

View File

@@ -107,6 +107,27 @@ export const ESSENTIALS_CATEGORY_CANONICAL: ReadonlyMap<
EssentialsCategory
> = new Map(ESSENTIALS_CATEGORIES.map((c) => [c.toLowerCase(), c]))
/**
* Precomputed rank map: category → display order index.
* Used for sorting essentials folders in their canonical order.
*/
export const ESSENTIALS_CATEGORY_RANK: ReadonlyMap<string, number> = new Map(
ESSENTIALS_CATEGORIES.map((c, i) => [c, i])
)
/**
* Precomputed rank maps: category → (node name → display order index).
* Used for sorting nodes within each essentials folder.
*/
export const ESSENTIALS_NODE_RANK: Partial<
Record<EssentialsCategory, ReadonlyMap<string, number>>
> = Object.fromEntries(
Object.entries(ESSENTIALS_NODES).map(([category, nodes]) => [
category,
new Map(nodes.map((name, i) => [name, i]))
])
)
/**
* "Novel" toolkit nodes for telemetry — basics excluded.
* Derived from ESSENTIALS_NODES minus the 'basics' category.

View File

@@ -751,7 +751,7 @@ describe('SubgraphNode.widgets getter', () => {
])
})
test('full linked coverage does not prune unresolved independent fallback promotions', () => {
test('full linked coverage prunes promotions referencing non-existent nodes', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'widgetA', type: '*' }]
})
@@ -776,9 +776,9 @@ describe('SubgraphNode.widgets getter', () => {
subgraphNode.rootGraph.id,
subgraphNode.id
)
// Node 9999 does not exist in the subgraph, so its entry is pruned
expect(promotions).toStrictEqual([
{ sourceNodeId: String(liveNode.id), sourceWidgetName: 'widgetA' },
{ sourceNodeId: '9999', sourceWidgetName: 'widgetA' }
{ sourceNodeId: String(liveNode.id), sourceWidgetName: 'widgetA' }
])
})

View File

@@ -2616,8 +2616,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
}
pointer.finally = () => (this.resizingGroup = null)
} else {
const f = group.font_size || LiteGraph.DEFAULT_GROUP_FONT_SIZE
const headerHeight = f * 1.4
const headerHeight = LiteGraph.NODE_TITLE_HEIGHT
if (
isInRectangle(
x,

View File

@@ -40,7 +40,7 @@ export class LGraphGroup implements Positionable, IPinnable, IColorable {
color?: string
title: string
font?: string
font_size: number = LiteGraph.DEFAULT_GROUP_FONT || 24
font_size: number = LiteGraph.GROUP_TEXT_SIZE
_bounding = new Rectangle(10, 10, LGraphGroup.minWidth, LGraphGroup.minHeight)
_pos: Point = this._bounding.pos
@@ -116,7 +116,7 @@ export class LGraphGroup implements Positionable, IPinnable, IColorable {
}
get titleHeight() {
return this.font_size * 1.4
return LiteGraph.NODE_TITLE_HEIGHT
}
get children(): ReadonlySet<Positionable> {
@@ -148,7 +148,6 @@ export class LGraphGroup implements Positionable, IPinnable, IColorable {
this._bounding.set(o.bounding)
this.color = o.color
this.flags = o.flags || this.flags
if (o.font_size) this.font_size = o.font_size
}
serialize(): ISerialisedGroup {
@@ -158,7 +157,6 @@ export class LGraphGroup implements Positionable, IPinnable, IColorable {
title: this.title,
bounding: [...b],
color: this.color,
font_size: this.font_size,
flags: this.flags
}
}
@@ -170,7 +168,7 @@ export class LGraphGroup implements Positionable, IPinnable, IColorable {
*/
draw(graphCanvas: LGraphCanvas, ctx: CanvasRenderingContext2D): void {
const { padding, resizeLength, defaultColour } = LGraphGroup
const font_size = this.font_size || LiteGraph.DEFAULT_GROUP_FONT_SIZE
const font_size = LiteGraph.GROUP_TEXT_SIZE
const [x, y] = this._pos
const [width, height] = this._size
@@ -181,7 +179,7 @@ export class LGraphGroup implements Positionable, IPinnable, IColorable {
ctx.fillStyle = color
ctx.strokeStyle = color
ctx.beginPath()
ctx.rect(x + 0.5, y + 0.5, width, font_size * 1.4)
ctx.rect(x + 0.5, y + 0.5, width, LiteGraph.NODE_TITLE_HEIGHT)
ctx.fill()
// Group background, border
@@ -203,11 +201,13 @@ export class LGraphGroup implements Positionable, IPinnable, IColorable {
// Title
ctx.font = `${font_size}px ${LiteGraph.GROUP_FONT}`
ctx.textAlign = 'left'
ctx.textBaseline = 'middle'
ctx.fillText(
this.title + (this.pinned ? '📌' : ''),
x + padding,
y + font_size
x + font_size / 2,
y + LiteGraph.NODE_TITLE_HEIGHT / 2 + 1
)
ctx.textBaseline = 'alphabetic'
if (LiteGraph.highlight_selected_group && this.selected) {
strokeShape(ctx, this._bounding, {

View File

@@ -72,8 +72,7 @@ export class LiteGraphGlobal {
DEFAULT_FONT = 'Inter'
DEFAULT_SHADOW_COLOR = 'rgba(0,0,0,0.5)'
DEFAULT_GROUP_FONT = 24
DEFAULT_GROUP_FONT_SIZE = 24
GROUP_TEXT_SIZE = 20
GROUP_FONT = 'Inter'
WIDGET_BGCOLOR = '#222'

View File

@@ -18,7 +18,6 @@ exports[`LGraph > supports schema v0.4 graphs > oldSchemaGraph 1`] = `
],
"color": "#6029aa",
"flags": {},
"font_size": 14,
"id": 123,
"title": "A group to test with",
},

View File

@@ -10,7 +10,6 @@ exports[`LGraphGroup > serializes to the existing format > Basic 1`] = `
],
"color": "#3f789e",
"flags": {},
"font_size": 24,
"id": 929,
"title": "title",
}

View File

@@ -21,8 +21,6 @@ LiteGraphGlobal {
"ContextMenu": [Function],
"CurveEditor": [Function],
"DEFAULT_FONT": "Inter",
"DEFAULT_GROUP_FONT": 24,
"DEFAULT_GROUP_FONT_SIZE": 24,
"DEFAULT_POSITION": [
100,
100,
@@ -34,6 +32,7 @@ LiteGraphGlobal {
"EVENT_LINK_COLOR": "#A86",
"GRID_SHAPE": 6,
"GROUP_FONT": "Inter",
"GROUP_TEXT_SIZE": 20,
"Globals": {},
"HIDDEN_LINK": -1,
"INPUT": 1,

View File

@@ -958,3 +958,69 @@ describe('SubgraphNode promotion view keys', () => {
expect(firstKey).not.toBe(secondKey)
})
})
describe('SubgraphNode label propagation', () => {
it('should preserve input labels from configure path', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'steps', type: 'number' }]
})
subgraph.inputs[0].label = 'Steps Count'
const subgraphNode = createTestSubgraphNode(subgraph)
expect(subgraphNode.inputs[0].label).toBe('Steps Count')
expect(subgraphNode.inputs[0].name).toBe('steps')
})
it('should preserve output labels from configure path', () => {
const subgraph = createTestSubgraph({
outputs: [{ name: 'result', type: 'number' }]
})
subgraph.outputs[0].label = 'Final Result'
const subgraphNode = createTestSubgraphNode(subgraph)
expect(subgraphNode.outputs[0].label).toBe('Final Result')
expect(subgraphNode.outputs[0].name).toBe('result')
})
it('should propagate label via renaming-input event', () => {
const subgraph = createTestSubgraph()
const subgraphNode = createTestSubgraphNode(subgraph)
subgraph.addInput('steps', 'number')
expect(subgraphNode.inputs[0].label).toBeUndefined()
subgraph.renameInput(subgraph.inputs[0], 'Steps Count')
expect(subgraphNode.inputs[0].label).toBe('Steps Count')
expect(subgraphNode.inputs[0].name).toBe('steps')
})
it('should propagate label via renaming-output event', () => {
const subgraph = createTestSubgraph()
const subgraphNode = createTestSubgraphNode(subgraph)
subgraph.addOutput('result', 'number')
expect(subgraphNode.outputs[0].label).toBeUndefined()
subgraph.renameOutput(subgraph.outputs[0], 'Final Result')
expect(subgraphNode.outputs[0].label).toBe('Final Result')
expect(subgraphNode.outputs[0].name).toBe('result')
})
it('should preserve localized_name from configure path', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'steps', type: 'number' }],
outputs: [{ name: 'result', type: 'number' }]
})
subgraph.inputs[0].localized_name = 'ステップ'
subgraph.outputs[0].localized_name = '結果'
const subgraphNode = createTestSubgraphNode(subgraph)
expect(subgraphNode.inputs[0].localized_name).toBe('ステップ')
expect(subgraphNode.outputs[0].localized_name).toBe('結果')
})
})

View File

@@ -515,6 +515,8 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
const prunedEntries: PromotedWidgetSource[] = []
for (const entry of fallbackStoredEntries) {
if (!this.subgraph.getNodeById(entry.sourceNodeId)) continue
const concreteKey = this._resolveConcretePromotionEntryKey(entry)
if (concreteKey && linkedConcreteKeys.has(concreteKey)) continue
@@ -1063,6 +1065,7 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
}
return null
}
if (!this.subgraph.getNodeById(nodeId)) return null
const entry: PromotedWidgetSource = {
sourceNodeId: nodeId,
sourceWidgetName: widgetName,
@@ -1074,8 +1077,8 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
store.setPromotions(this.rootGraph.id, this.id, entries)
// Write back resolved entries so legacy -1 format doesn't persist
if (raw.some(([id]) => id === '-1')) {
// Write back resolved entries so legacy or stale entries don't persist
if (entries.length !== raw.length) {
this.properties.proxyWidgets = this._serializeEntries(entries)
}

View File

@@ -8,6 +8,7 @@ import type {
TWidgetType
} from '@/lib/litegraph/src/litegraph'
import { BaseWidget, LGraphNode } from '@/lib/litegraph/src/litegraph'
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
import {
createEventCapture,
@@ -263,6 +264,62 @@ describe('SubgraphWidgetPromotion', () => {
})
})
describe('Nested Subgraph Widget Promotion', () => {
it('should prune proxyWidgets referencing nodes not in subgraph on configure', () => {
// Reproduces the bug where packing nodes into a nested subgraph leaves
// stale proxyWidgets on the outer subgraph node referencing grandchild
// node IDs that no longer exist directly in the outer subgraph.
// Uses 3 inputs with only 1 having a linked widget entry, matching the
// real workflow structure where model/vae inputs don't resolve widgets.
const subgraph = createTestSubgraph({
inputs: [
{ name: 'clip', type: 'CLIP' },
{ name: 'model', type: 'MODEL' },
{ name: 'vae', type: 'VAE' }
]
})
const { node: samplerNode } = createNodeWithWidget(
'Sampler',
'number',
42,
'number'
)
subgraph.add(samplerNode)
subgraph.inputNode.slots[1].connect(samplerNode.inputs[0], samplerNode)
// Add nodes without widget-connected inputs for the other slots
const modelNode = new LGraphNode('ModelNode')
modelNode.addInput('model', 'MODEL')
subgraph.add(modelNode)
const vaeNode = new LGraphNode('VAENode')
vaeNode.addInput('vae', 'VAE')
subgraph.add(vaeNode)
const outerNode = createTestSubgraphNode(subgraph)
// Inject stale proxyWidgets referencing nodes that don't exist in
// this subgraph (they were packed into a nested subgraph)
outerNode.properties.proxyWidgets = [
['999', 'text'],
['998', 'text'],
[String(samplerNode.id), 'widget']
]
outerNode.configure(outerNode.serialize())
// Check widgets getter — stale entries should not produce views
const widgetSourceIds = outerNode.widgets
.filter(isPromotedWidgetView)
.filter((w) => !w.name.startsWith('$$'))
.map((w) => w.sourceNodeId)
expect(widgetSourceIds).not.toContain('999')
expect(widgetSourceIds).not.toContain('998')
})
})
describe('Tooltip Promotion', () => {
it('should preserve widget tooltip when promoting', () => {
const subgraph = createTestSubgraph({

View File

@@ -564,19 +564,26 @@
"uploadCover": "+ رفع صورة الغلاف",
"uploadProfilePicture": "+ رفع صورة الملف الشخصي",
"uploadWorkflowButton": "رفع سير عملي",
"usernameError": "٣–٤٢ حرفًا صغيرًا أو رقمًا أو شرطة، ويجب أن يبدأ وينتهي بحرف أو رقم",
"usernameLabel": "اسم المستخدم (إجباري)",
"usernamePlaceholder": "@"
},
"comfyHubPublish": {
"additionalInfo": "معلومات إضافية",
"back": "رجوع",
"createProfileCta": "إنشاء ملف شخصي",
"createProfileToPublish": "أنشئ ملفًا شخصيًا للنشر على ComfyHub",
"exampleImage": "صورة نموذجية {index}",
"exampleImagePosition": "الصورة النموذجية {index} من {total}",
"examplesDescription": "أضف حتى {total} صورة نموذجية إضافية",
"maxExamples": "يمكنك اختيار حتى {max} أمثلة",
"next": "التالي",
"publishButton": "النشر على ComfyHub",
"publishFailedDescription": "حدث خطأ أثناء نشر سير العمل الخاص بك. يرجى المحاولة مرة أخرى.",
"publishFailedTitle": "فشل النشر",
"removeExampleImage": "إزالة الصورة النموذجية",
"selectAThumbnail": "اختر صورة مصغرة",
"shareAs": "مشاركة كـ",
"showLessTags": "عرض أقل...",
"showMoreTags": "عرض المزيد...",
"stepDescribe": "وصف سير العمل",
@@ -591,6 +598,7 @@
"thumbnailPreview": "معاينة الصورة المصغرة",
"thumbnailVideo": "فيديو",
"title": "النشر على ComfyHub",
"unsavedDescription": "يجب حفظ سير العمل الخاص بك قبل النشر على ComfyHub. احفظه الآن للمتابعة.",
"uploadAnImage": "انقر للاستعراض أو اسحب صورة",
"uploadComparison": "رفع صورة قبل وبعد",
"uploadComparisonAfterPrompt": "بعد",
@@ -606,13 +614,7 @@
"workflowDescription": "وصف سير العمل",
"workflowDescriptionPlaceholder": "ما الذي يجعل سير عملك مميزًا ومثيرًا؟ كن محددًا حتى يعرف الآخرون ما يمكن توقعه.",
"workflowName": "اسم سير العمل",
"workflowNamePlaceholder": "نصيحة: أدخل اسمًا وصفيًا يسهل البحث عنه",
"workflowType": "نوع سير العمل",
"workflowTypeEditing": "تحرير",
"workflowTypeImageGeneration": "توليد الصور",
"workflowTypePlaceholder": "اختر النوع",
"workflowTypeUpscaling": "تحسين الجودة",
"workflowTypeVideoGeneration": "توليد الفيديو"
"workflowNamePlaceholder": "نصيحة: أدخل اسمًا وصفيًا يسهل البحث عنه"
},
"commands": {
"clear": "مسح سير العمل",
@@ -780,6 +782,7 @@
"COMFY_MATCHTYPE_V3": "COMFY_MATCHTYPE_V3",
"CONDITIONING": "تكييف",
"CONTROL_NET": "ControlNet",
"CURVE": "منحنى",
"ELEVENLABS_VOICE": "ELEVENLABS_VOICE",
"FILE_3D": "ملف ثلاثي الأبعاد",
"FILE_3D_FBX": "ملف FBX ثلاثي الأبعاد",
@@ -793,6 +796,7 @@
"GEMINI_INPUT_FILES": "ملفات إدخال جيميني",
"GLIGEN": "GLIGEN",
"GUIDER": "موجه",
"HISTOGRAM": "مخطط بياني",
"HOOKS": "معالجات",
"HOOK_KEYFRAMES": "مفاتيح المعالجات",
"IMAGE": "صورة",
@@ -884,13 +888,13 @@
"resume": "استئناف التنزيل"
},
"errorDialog": {
"accessRestrictedMessage": "Your account is not authorized for this feature.",
"accessRestrictedTitle": "Access Restricted",
"defaultTitle": "حدث خطأ",
"extensionFileHint": "قد يكون السبب هو السكربت التالي",
"loadWorkflowTitle": "تم إلغاء التحميل بسبب خطأ في إعادة تحميل بيانات سير العمل",
"noStackTrace": "لا توجد تتبع للمكدس متاحة",
"promptExecutionError": "فشل تنفيذ الطلب",
"accessRestrictedTitle": "Access Restricted",
"accessRestrictedMessage": "Your account is not authorized for this feature."
"promptExecutionError": "فشل تنفيذ الطلب"
},
"errorOverlay": {
"errorCount": "{count} خطأ | {count} أخطاء",
@@ -934,6 +938,19 @@
"textToImage": "تحويل نص إلى صورة",
"textToVideo": "تحويل نص إلى فيديو"
},
"execution": {
"decoding": "جارٍ فك الترميز…",
"encoding": "جارٍ الترميز…",
"generating": "جارٍ التوليد…",
"generatingVideo": "جارٍ توليد الفيديو…",
"loading": "جارٍ التحميل…",
"processing": "جارٍ المعالجة…",
"processingVideo": "جارٍ معالجة الفيديو…",
"resizing": "جارٍ تغيير الحجم…",
"running": "جارٍ التشغيل…",
"saving": "جارٍ الحفظ…",
"training": "جارٍ التدريب…"
},
"exportToast": {
"allExportsCompleted": "اكتملت جميع عمليات التصدير",
"downloadExport": "تحميل التصدير",
@@ -1090,6 +1107,7 @@
"icon": "أيقونة",
"imageDoesNotExist": "الصورة غير موجودة",
"imageFailedToLoad": "فشل تحميل الصورة",
"imageGallery": "معرض الصور",
"imageLightbox": "معاينة الصورة",
"imagePreview": "معاينة الصورة - استخدم مفاتيح الأسهم للتنقل بين الصور",
"imageUrl": "رابط الصورة",
@@ -1104,7 +1122,6 @@
"installed": "مثبت",
"installing": "جارٍ التثبيت",
"interrupted": "تمت المقاطعة",
"itemSelected": "تم تحديد عنصر واحد",
"itemsCopiedToClipboard": "تم نسخ العناصر إلى الحافظة",
"itemsSelected": "تم تحديد {selectedCount} عناصر",
"job": "مهمة",
@@ -1310,6 +1327,7 @@
"versionMismatchWarningMessage": "{warning}: {detail} زر https://docs.comfy.org/installation/update_comfyui#common-update-issues للحصول على تعليمات التحديث.",
"videoFailedToLoad": "فشل تحميل الفيديو",
"videoPreview": "معاينة الفيديو - استخدم مفاتيح الأسهم للتنقل بين الفيديوهات",
"viewGrid": "عرض الشبكة",
"viewImageOfTotal": "عرض الصورة {index} من {total}",
"viewVideoOfTotal": "عرض الفيديو {index} من {total}",
"volume": "مستوى الصوت",

View File

@@ -1438,6 +1438,22 @@
}
}
},
"ComfyNumberConvert": {
"display_name": "تحويل الرقم",
"inputs": {
"value": {
"name": "القيمة"
}
},
"outputs": {
"0": {
"tooltip": null
},
"1": {
"tooltip": null
}
}
},
"ComfySwitchNode": {
"display_name": "مفتاح التحويل",
"inputs": {
@@ -2218,6 +2234,23 @@
}
}
},
"CurveEditor": {
"display_name": "محرر المنحنى",
"inputs": {
"curve": {
"name": "منحنى"
},
"histogram": {
"name": "مخطط بياني"
}
},
"outputs": {
"0": {
"name": "منحنى",
"tooltip": null
}
}
},
"CustomCombo": {
"display_name": "توليفة مخصصة",
"inputs": {
@@ -4092,6 +4125,39 @@
}
}
},
"GrokVideoExtendNode": {
"description": "تمديد فيديو موجود باستمرار سلس بناءً على وصف نصي.",
"display_name": "تمديد فيديو Grok",
"inputs": {
"control_after_generate": {
"name": "التحكم بعد التوليد"
},
"model": {
"name": "النموذج",
"tooltip": "النموذج المستخدم لتمديد الفيديو."
},
"model_duration": {
"name": "المدة"
},
"prompt": {
"name": "الوصف النصي",
"tooltip": "وصف نصي لما يجب أن يحدث بعد ذلك في الفيديو."
},
"seed": {
"name": "البذرة",
"tooltip": "بذرة لتحديد ما إذا كان يجب إعادة تشغيل العقدة؛ النتائج الفعلية غير حتمية بغض النظر عن البذرة."
},
"video": {
"name": "فيديو",
"tooltip": "الفيديو المصدر للتمديد. صيغة MP4، من ٢ إلى ١٥ ثانية."
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"GrokVideoNode": {
"description": "توليد فيديو من مطالبة أو صورة",
"display_name": "فيديو Grok",
@@ -4132,6 +4198,41 @@
}
}
},
"GrokVideoReferenceNode": {
"description": "توليد فيديو موجه بواسطة صور مرجعية كمرجع للأسلوب والمحتوى.",
"display_name": "Grok من مرجع إلى فيديو",
"inputs": {
"control_after_generate": {
"name": "التحكم بعد التوليد"
},
"model": {
"name": "النموذج",
"tooltip": "النموذج المستخدم لتوليد الفيديو."
},
"model_aspect_ratio": {
"name": "نسبة العرض إلى الارتفاع"
},
"model_duration": {
"name": "المدة"
},
"model_resolution": {
"name": "الدقة"
},
"prompt": {
"name": "الوصف النصي",
"tooltip": "وصف نصي للفيديو المطلوب."
},
"seed": {
"name": "البذرة",
"tooltip": "بذرة لتحديد ما إذا كان يجب إعادة تشغيل العقدة؛ النتائج الفعلية غير حتمية بغض النظر عن البذرة."
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"GrowMask": {
"display_name": "توسيع القناع",
"inputs": {
@@ -6748,6 +6849,54 @@
}
}
},
"LTXVReferenceAudio": {
"description": "تعيين صوت مرجعي لنقل هوية المتحدث باستخدام ID-LoRA. يقوم بترميز مقطع صوتي مرجعي إلى التكييف، ويمكنه أيضًا تعديل النموذج بتوجيه الهوية (تمرير إضافي للأمام بدون المرجع، مما يعزز تأثير هوية المتحدث).",
"display_name": "LTXV مرجع الصوت (ID-LoRA)",
"inputs": {
"audio_vae": {
"name": "audio_vae",
"tooltip": "LTXV Audio VAE للترميز."
},
"end_percent": {
"name": "نسبة النهاية",
"tooltip": "نهاية نطاق سيغما حيث يكون توجيه الهوية نشطًا."
},
"identity_guidance_scale": {
"name": "مقياس توجيه الهوية",
"tooltip": "قوة توجيه الهوية. ينفذ تمريرًا إضافيًا للأمام بدون المرجع في كل خطوة لتعزيز هوية المتحدث. اضبط على ٠ للتعطيل (بدون تمرير إضافي)."
},
"model": {
"name": "النموذج"
},
"negative": {
"name": "سلبي"
},
"positive": {
"name": "إيجابي"
},
"reference_audio": {
"name": "الصوت_المرجعي",
"tooltip": "مقطع صوتي مرجعي لنقل هوية المتحدث. يُوصى بأن يكون حوالي ٥ ثوانٍ (مدة التدريب). المقاطع الأقصر أو الأطول قد تؤثر سلبًا على نقل هوية الصوت."
},
"start_percent": {
"name": "نسبة البداية",
"tooltip": "بداية نطاق سيغما حيث يكون توجيه الهوية نشطًا."
}
},
"outputs": {
"0": {
"tooltip": null
},
"1": {
"name": "إيجابي",
"tooltip": null
},
"2": {
"name": "سلبي",
"tooltip": null
}
}
},
"LTXVScheduler": {
"display_name": "LTXV المجدول",
"inputs": {
@@ -16091,6 +16240,10 @@
"name": "إيجابي",
"tooltip": "التكييف الإيجابي المستخدم في التدريب."
},
"quantized_backward": {
"name": "quantized_backward",
"tooltip": "عند استخدام نوع التدريب 'none' والتدريب على نموذج كمي، يتم تنفيذ عملية الرجوع للخلف باستخدام ضرب المصفوفات الكمي عند التفعيل."
},
"rank": {
"name": "الرتبة",
"tooltip": "رتبة طبقات LoRA."

View File

@@ -1766,6 +1766,7 @@
"COMFY_MATCHTYPE_V3": "COMFY_MATCHTYPE_V3",
"CONDITIONING": "CONDITIONING",
"CONTROL_NET": "CONTROL_NET",
"CURVE": "CURVE",
"ELEVENLABS_VOICE": "ELEVENLABS_VOICE",
"FILE_3D": "FILE_3D",
"FILE_3D_FBX": "FILE_3D_FBX",
@@ -1779,6 +1780,7 @@
"GEMINI_INPUT_FILES": "GEMINI_INPUT_FILES",
"GLIGEN": "GLIGEN",
"GUIDER": "GUIDER",
"HISTOGRAM": "HISTOGRAM",
"HOOK_KEYFRAMES": "HOOK_KEYFRAMES",
"HOOKS": "HOOKS",
"IMAGE": "IMAGE",
@@ -3165,6 +3167,7 @@
},
"comfyHubPublish": {
"title": "Publish to ComfyHub",
"unsavedDescription": "You must save your workflow before publishing to ComfyHub. Save it now to continue.",
"stepDescribe": "Describe your workflow",
"stepExamples": "Add output examples",
"stepFinish": "Finish publishing",
@@ -3172,12 +3175,6 @@
"workflowNamePlaceholder": "Tip: enter a descriptive name that's easy to search",
"workflowDescription": "Workflow description",
"workflowDescriptionPlaceholder": "What makes your workflow exciting and special? Be specific so people know what to expect.",
"workflowType": "Workflow type",
"workflowTypePlaceholder": "Select the type",
"workflowTypeImageGeneration": "Image generation",
"workflowTypeVideoGeneration": "Video generation",
"workflowTypeUpscaling": "Upscaling",
"workflowTypeEditing": "Editing",
"tags": "Tags",
"tagsDescription": "Select tags so people can find your workflow faster",
"tagsPlaceholder": "Enter tags that match your workflow to help people find it e.g #nanobanana, #anime or #faceswap",
@@ -3204,11 +3201,17 @@
"examplesDescription": "Add up to {total} additional sample images",
"uploadAnImage": "Click to browse or drag an image",
"uploadExampleImage": "Upload example image",
"removeExampleImage": "Remove example image",
"exampleImage": "Example image {index}",
"exampleImagePosition": "Example image {index} of {total}",
"videoPreview": "Video thumbnail preview",
"maxExamples": "You can select up to {max} examples",
"shareAs": "Share as",
"additionalInfo": "Additional information",
"createProfileToPublish": "Create a profile to publish to ComfyHub",
"createProfileCta": "Create a profile"
"createProfileCta": "Create a profile",
"publishFailedTitle": "Publish failed",
"publishFailedDescription": "Something went wrong while publishing your workflow. Please try again."
},
"comfyHubProfile": {
"checkingAccess": "Checking your publishing access...",
@@ -3227,6 +3230,7 @@
"namePlaceholder": "Enter your name here",
"usernameLabel": "Your username (required)",
"usernamePlaceholder": "@",
"usernameError": "342 lowercase alphanumeric characters and hyphens, must start and end with a letter or number",
"descriptionLabel": "Your description",
"descriptionPlaceholder": "Tell the community about yourself...",
"createProfile": "Create profile",
@@ -3706,5 +3710,18 @@
"footer": "ComfyUI stays free and open source. Cloud is optional.",
"continueLocally": "Continue Locally",
"exploreCloud": "Try Cloud for Free"
},
"execution": {
"generating": "Generating…",
"saving": "Saving…",
"loading": "Loading…",
"encoding": "Encoding…",
"decoding": "Decoding…",
"processing": "Processing…",
"resizing": "Resizing…",
"generatingVideo": "Generating video…",
"training": "Training…",
"processingVideo": "Processing video…",
"running": "Running…"
}
}

View File

@@ -1438,6 +1438,22 @@
}
}
},
"ComfyNumberConvert": {
"display_name": "Number Convert",
"inputs": {
"value": {
"name": "value"
}
},
"outputs": {
"0": {
"tooltip": null
},
"1": {
"tooltip": null
}
}
},
"ComfySwitchNode": {
"display_name": "Switch",
"inputs": {
@@ -2218,6 +2234,23 @@
}
}
},
"CurveEditor": {
"display_name": "Curve Editor",
"inputs": {
"curve": {
"name": "curve"
},
"histogram": {
"name": "histogram"
}
},
"outputs": {
"0": {
"name": "curve",
"tooltip": null
}
}
},
"CustomCombo": {
"display_name": "Custom Combo",
"inputs": {
@@ -4092,6 +4125,39 @@
}
}
},
"GrokVideoExtendNode": {
"display_name": "Grok Video Extend",
"description": "Extend an existing video with a seamless continuation based on a text prompt.",
"inputs": {
"prompt": {
"name": "prompt",
"tooltip": "Text description of what should happen next in the video."
},
"video": {
"name": "video",
"tooltip": "Source video to extend. MP4 format, 2-15 seconds."
},
"model": {
"name": "model",
"tooltip": "The model to use for video extension."
},
"seed": {
"name": "seed",
"tooltip": "Seed to determine if node should re-run; actual results are nondeterministic regardless of seed."
},
"control_after_generate": {
"name": "control after generate"
},
"model_duration": {
"name": "duration"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"GrokVideoNode": {
"display_name": "Grok Video",
"description": "Generate video from a prompt or an image",
@@ -4132,6 +4198,41 @@
}
}
},
"GrokVideoReferenceNode": {
"display_name": "Grok Reference-to-Video",
"description": "Generate video guided by reference images as style and content references.",
"inputs": {
"prompt": {
"name": "prompt",
"tooltip": "Text description of the desired video."
},
"model": {
"name": "model",
"tooltip": "The model to use for video generation."
},
"seed": {
"name": "seed",
"tooltip": "Seed to determine if node should re-run; actual results are nondeterministic regardless of seed."
},
"control_after_generate": {
"name": "control after generate"
},
"model_aspect_ratio": {
"name": "aspect_ratio"
},
"model_duration": {
"name": "duration"
},
"model_resolution": {
"name": "resolution"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"GrowMask": {
"display_name": "Grow Mask",
"inputs": {
@@ -7623,6 +7724,54 @@
}
}
},
"LTXVReferenceAudio": {
"display_name": "LTXV Reference Audio (ID-LoRA)",
"description": "Set reference audio for ID-LoRA speaker identity transfer. Encodes a reference audio clip into the conditioning and optionally patches the model with identity guidance (extra forward pass without reference, amplifying the speaker identity effect).",
"inputs": {
"model": {
"name": "model"
},
"positive": {
"name": "positive"
},
"negative": {
"name": "negative"
},
"reference_audio": {
"name": "reference_audio",
"tooltip": "Reference audio clip whose speaker identity to transfer. ~5 seconds recommended (training duration). Shorter or longer clips may degrade voice identity transfer."
},
"audio_vae": {
"name": "audio_vae",
"tooltip": "LTXV Audio VAE for encoding."
},
"identity_guidance_scale": {
"name": "identity_guidance_scale",
"tooltip": "Strength of identity guidance. Runs an extra forward pass without reference each step to amplify speaker identity. Set to 0 to disable (no extra pass)."
},
"start_percent": {
"name": "start_percent",
"tooltip": "Start of the sigma range where identity guidance is active."
},
"end_percent": {
"name": "end_percent",
"tooltip": "End of the sigma range where identity guidance is active."
}
},
"outputs": {
"0": {
"tooltip": null
},
"1": {
"name": "positive",
"tooltip": null
},
"2": {
"name": "negative",
"tooltip": null
}
}
},
"LTXVScheduler": {
"display_name": "LTXVScheduler",
"inputs": {
@@ -16076,6 +16225,10 @@
"name": "lora_dtype",
"tooltip": "The dtype to use for lora."
},
"quantized_backward": {
"name": "quantized_backward",
"tooltip": "When using training_dtype 'none' and training on quantized model, doing backward with quantized matmul when enabled."
},
"algorithm": {
"name": "algorithm",
"tooltip": "The algorithm to use for training."

View File

@@ -564,19 +564,26 @@
"uploadCover": "+ Subir una portada",
"uploadProfilePicture": "+ Subir una foto de perfil",
"uploadWorkflowButton": "Subir mi flujo de trabajo",
"usernameError": "De 3 a 42 caracteres alfanuméricos en minúsculas y guiones, debe comenzar y terminar con una letra o número",
"usernameLabel": "Tu nombre de usuario (requerido)",
"usernamePlaceholder": "@"
},
"comfyHubPublish": {
"additionalInfo": "Información adicional",
"back": "Atrás",
"createProfileCta": "Crear un perfil",
"createProfileToPublish": "Crea un perfil para publicar en ComfyHub",
"exampleImage": "Imagen de ejemplo {index}",
"exampleImagePosition": "Imagen de ejemplo {index} de {total}",
"examplesDescription": "Agrega hasta {total} imágenes de ejemplo adicionales",
"maxExamples": "Puedes seleccionar hasta {max} ejemplos",
"next": "Siguiente",
"publishButton": "Publicar en ComfyHub",
"publishFailedDescription": "Ocurrió un error al publicar tu flujo de trabajo. Por favor, inténtalo de nuevo.",
"publishFailedTitle": "Error al publicar",
"removeExampleImage": "Eliminar imagen de ejemplo",
"selectAThumbnail": "Selecciona una miniatura",
"shareAs": "Compartir como",
"showLessTags": "Mostrar menos...",
"showMoreTags": "Mostrar más...",
"stepDescribe": "Describe tu flujo de trabajo",
@@ -591,6 +598,7 @@
"thumbnailPreview": "Vista previa de la miniatura",
"thumbnailVideo": "Video",
"title": "Publicar en ComfyHub",
"unsavedDescription": "Debes guardar tu flujo de trabajo antes de publicarlo en ComfyHub. Guárdalo ahora para continuar.",
"uploadAnImage": "Haz clic para buscar o arrastra una imagen",
"uploadComparison": "Subir antes y después",
"uploadComparisonAfterPrompt": "Después",
@@ -606,13 +614,7 @@
"workflowDescription": "Descripción del flujo de trabajo",
"workflowDescriptionPlaceholder": "¿Qué hace que tu flujo de trabajo sea emocionante y especial? Sé específico para que las personas sepan qué esperar.",
"workflowName": "Nombre del flujo de trabajo",
"workflowNamePlaceholder": "Consejo: ingresa un nombre descriptivo y fácil de buscar",
"workflowType": "Tipo de flujo de trabajo",
"workflowTypeEditing": "Edición",
"workflowTypeImageGeneration": "Generación de imágenes",
"workflowTypePlaceholder": "Selecciona el tipo",
"workflowTypeUpscaling": "Aumento de resolución",
"workflowTypeVideoGeneration": "Generación de video"
"workflowNamePlaceholder": "Consejo: ingresa un nombre descriptivo y fácil de buscar"
},
"commands": {
"clear": "Limpiar flujo de trabajo",
@@ -780,6 +782,7 @@
"COMFY_MATCHTYPE_V3": "COMFY_MATCHTYPE_V3",
"CONDITIONING": "ACONDICIONAMIENTO",
"CONTROL_NET": "RED_DE_CONTROL",
"CURVE": "CURVA",
"ELEVENLABS_VOICE": "ELEVENLABS_VOICE",
"FILE_3D": "ARCHIVO_3D",
"FILE_3D_FBX": "ARCHIVO_3D_FBX",
@@ -793,6 +796,7 @@
"GEMINI_INPUT_FILES": "ARCHIVOS_ENTRADA_GEMINI",
"GLIGEN": "GLIGEN",
"GUIDER": "GUÍA",
"HISTOGRAM": "HISTOGRAMA",
"HOOKS": "GANCHOS",
"HOOK_KEYFRAMES": "GANCHO_FOTOGRAMAS_CLAVE",
"IMAGE": "IMAGEN",
@@ -884,13 +888,13 @@
"resume": "Reanudar descarga"
},
"errorDialog": {
"accessRestrictedMessage": "Your account is not authorized for this feature.",
"accessRestrictedTitle": "Access Restricted",
"defaultTitle": "Ocurrió un error",
"extensionFileHint": "Esto puede deberse al siguiente script",
"loadWorkflowTitle": "La carga se interrumpió debido a un error al recargar los datos del flujo de trabajo",
"noStackTrace": "No hay seguimiento de pila disponible",
"promptExecutionError": "La ejecución del prompt falló",
"accessRestrictedTitle": "Access Restricted",
"accessRestrictedMessage": "Your account is not authorized for this feature."
"promptExecutionError": "La ejecución del prompt falló"
},
"errorOverlay": {
"errorCount": "{count} ERROR | {count} ERRORES",
@@ -934,6 +938,19 @@
"textToImage": "Texto a imagen",
"textToVideo": "Texto a video"
},
"execution": {
"decoding": "Decodificando…",
"encoding": "Codificando…",
"generating": "Generando…",
"generatingVideo": "Generando video…",
"loading": "Cargando…",
"processing": "Procesando…",
"processingVideo": "Procesando video…",
"resizing": "Redimensionando…",
"running": "Ejecutando…",
"saving": "Guardando…",
"training": "Entrenando…"
},
"exportToast": {
"allExportsCompleted": "Todas las exportaciones completadas",
"downloadExport": "Descargar exportación",
@@ -1090,6 +1107,7 @@
"icon": "Icono",
"imageDoesNotExist": "La imagen no existe",
"imageFailedToLoad": "Falló la carga de la imagen",
"imageGallery": "galería de imágenes",
"imageLightbox": "Vista previa de imagen",
"imagePreview": "Vista previa de imagen - Usa las teclas de flecha para navegar entre imágenes",
"imageUrl": "URL de la imagen",
@@ -1104,7 +1122,6 @@
"installed": "Instalado",
"installing": "Instalando",
"interrupted": "Interrumpido",
"itemSelected": "{selectedCount} elemento seleccionado",
"itemsCopiedToClipboard": "Elementos copiados al portapapeles",
"itemsSelected": "{selectedCount} elementos seleccionados",
"job": "Tarea",
@@ -1310,6 +1327,7 @@
"versionMismatchWarningMessage": "{warning}: {detail} Visita https://docs.comfy.org/installation/update_comfyui#common-update-issues para obtener instrucciones de actualización.",
"videoFailedToLoad": "Falló la carga del video",
"videoPreview": "Vista previa de video - Usa las teclas de flecha para navegar entre videos",
"viewGrid": "Vista de cuadrícula",
"viewImageOfTotal": "Ver imagen {index} de {total}",
"viewVideoOfTotal": "Ver video {index} de {total}",
"volume": "Volumen",

View File

@@ -1438,6 +1438,22 @@
}
}
},
"ComfyNumberConvert": {
"display_name": "Conversión de número",
"inputs": {
"value": {
"name": "valor"
}
},
"outputs": {
"0": {
"tooltip": null
},
"1": {
"tooltip": null
}
}
},
"ComfySwitchNode": {
"display_name": "Interruptor",
"inputs": {
@@ -2218,6 +2234,23 @@
}
}
},
"CurveEditor": {
"display_name": "Editor de curvas",
"inputs": {
"curve": {
"name": "curva"
},
"histogram": {
"name": "histograma"
}
},
"outputs": {
"0": {
"name": "curva",
"tooltip": null
}
}
},
"CustomCombo": {
"display_name": "Combinación personalizada",
"inputs": {
@@ -4092,6 +4125,39 @@
}
}
},
"GrokVideoExtendNode": {
"description": "Extiende un video existente con una continuación fluida basada en un prompt de texto.",
"display_name": "Extender video Grok",
"inputs": {
"control_after_generate": {
"name": "control después de generar"
},
"model": {
"name": "modelo",
"tooltip": "El modelo a utilizar para la extensión de video."
},
"model_duration": {
"name": "duración"
},
"prompt": {
"name": "prompt",
"tooltip": "Descripción en texto de lo que debe suceder a continuación en el video."
},
"seed": {
"name": "semilla",
"tooltip": "Semilla para determinar si el nodo debe ejecutarse de nuevo; los resultados reales son no deterministas independientemente de la semilla."
},
"video": {
"name": "video",
"tooltip": "Video fuente a extender. Formato MP4, 2-15 segundos."
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"GrokVideoNode": {
"description": "Genera video a partir de una indicación o una imagen",
"display_name": "Video Grok",
@@ -4132,6 +4198,41 @@
}
}
},
"GrokVideoReferenceNode": {
"description": "Genera video guiado por imágenes de referencia como referencias de estilo y contenido.",
"display_name": "Referencia a video Grok",
"inputs": {
"control_after_generate": {
"name": "control después de generar"
},
"model": {
"name": "modelo",
"tooltip": "El modelo a utilizar para la generación de video."
},
"model_aspect_ratio": {
"name": "relación de aspecto"
},
"model_duration": {
"name": "duración"
},
"model_resolution": {
"name": "resolución"
},
"prompt": {
"name": "prompt",
"tooltip": "Descripción en texto del video deseado."
},
"seed": {
"name": "semilla",
"tooltip": "Semilla para determinar si el nodo debe ejecutarse de nuevo; los resultados reales son no deterministas independientemente de la semilla."
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"GrowMask": {
"display_name": "GrowMask",
"inputs": {
@@ -6748,6 +6849,54 @@
}
}
},
"LTXVReferenceAudio": {
"description": "Establece el audio de referencia para la transferencia de identidad de locutor con ID-LoRA. Codifica un clip de audio de referencia en el condicionamiento y, opcionalmente, modifica el modelo con una guía de identidad (pase adicional sin referencia, amplificando el efecto de identidad del locutor).",
"display_name": "LTXV Reference Audio (ID-LoRA)",
"inputs": {
"audio_vae": {
"name": "audio_vae",
"tooltip": "LTXV Audio VAE para la codificación."
},
"end_percent": {
"name": "porcentaje_fin",
"tooltip": "Fin del rango sigma donde la guía de identidad está activa."
},
"identity_guidance_scale": {
"name": "escala_guía_identidad",
"tooltip": "Intensidad de la guía de identidad. Ejecuta un pase adicional sin referencia en cada paso para amplificar la identidad del locutor. Establece en 0 para desactivar (sin pase adicional)."
},
"model": {
"name": "modelo"
},
"negative": {
"name": "negativo"
},
"positive": {
"name": "positivo"
},
"reference_audio": {
"name": "audio_referencia",
"tooltip": "Clip de audio de referencia cuya identidad de locutor se va a transferir. Se recomienda ~5 segundos (duración de entrenamiento). Clips más cortos o largos pueden degradar la transferencia de identidad de voz."
},
"start_percent": {
"name": "porcentaje_inicio",
"tooltip": "Inicio del rango sigma donde la guía de identidad está activa."
}
},
"outputs": {
"0": {
"tooltip": null
},
"1": {
"name": "positivo",
"tooltip": null
},
"2": {
"name": "negativo",
"tooltip": null
}
}
},
"LTXVScheduler": {
"display_name": "LTXVProgramador",
"inputs": {
@@ -16091,6 +16240,10 @@
"name": "positivo",
"tooltip": "El condicionamiento positivo a utilizar para el entrenamiento."
},
"quantized_backward": {
"name": "quantized_backward",
"tooltip": "Al usar 'training_dtype' como 'none' y entrenar en un modelo cuantizado, realiza el retropropagado con multiplicación de matrices cuantizadas cuando está activado."
},
"rank": {
"name": "rango",
"tooltip": "El rango de las capas LoRA."

View File

@@ -564,19 +564,26 @@
"uploadCover": "+ بارگذاری کاور",
"uploadProfilePicture": "+ بارگذاری تصویر پروفایل",
"uploadWorkflowButton": "بارگذاری جریان‌کار من",
"usernameError": "۳ تا ۴۲ کاراکتر حرفی یا عددی کوچک و خط تیره، باید با حرف یا عدد شروع و پایان یابد",
"usernameLabel": "نام کاربری شما (الزامی)",
"usernamePlaceholder": "@"
},
"comfyHubPublish": {
"additionalInfo": "اطلاعات تکمیلی",
"back": "بازگشت",
"createProfileCta": "ساخت پروفایل",
"createProfileToPublish": "برای انتشار در ComfyHub یک پروفایل بسازید",
"exampleImage": "تصویر نمونه {index}",
"exampleImagePosition": "تصویر نمونه {index} از {total}",
"examplesDescription": "تا {total} تصویر نمونه اضافی اضافه کنید",
"maxExamples": "شما می‌توانید تا {max} نمونه انتخاب کنید",
"next": "بعدی",
"publishButton": "انتشار در ComfyHub",
"publishFailedDescription": "در هنگام انتشار گردش‌کار شما مشکلی پیش آمد. لطفاً دوباره تلاش کنید.",
"publishFailedTitle": "انتشار ناموفق بود",
"removeExampleImage": "حذف تصویر نمونه",
"selectAThumbnail": "یک تصویر بندانگشتی انتخاب کنید",
"shareAs": "اشتراک‌گذاری به عنوان",
"showLessTags": "نمایش کمتر...",
"showMoreTags": "نمایش بیشتر...",
"stepDescribe": "شرح جریان‌کار خود را وارد کنید",
@@ -591,6 +598,7 @@
"thumbnailPreview": "پیش‌نمایش بندانگشتی",
"thumbnailVideo": "ویدیو",
"title": "انتشار در ComfyHub",
"unsavedDescription": "شما باید گردش‌کار خود را قبل از انتشار در ComfyHub ذخیره کنید. اکنون ذخیره کنید تا ادامه دهید.",
"uploadAnImage": "برای انتخاب کلیک کنید یا تصویر را بکشید",
"uploadComparison": "بارگذاری قبل و بعد",
"uploadComparisonAfterPrompt": "بعد",
@@ -606,13 +614,7 @@
"workflowDescription": "توضیحات جریان‌کار",
"workflowDescriptionPlaceholder": "چه چیزی جریان‌کار شما را هیجان‌انگیز و خاص می‌کند؟ مشخص توضیح دهید تا کاربران بدانند چه انتظاری داشته باشند.",
"workflowName": "نام جریان‌کار",
"workflowNamePlaceholder": "نکته: یک نام توصیفی وارد کنید که به راحتی قابل جستجو باشد",
"workflowType": "نوع جریان‌کار",
"workflowTypeEditing": "ویرایش",
"workflowTypeImageGeneration": "تولید تصویر",
"workflowTypePlaceholder": "نوع را انتخاب کنید",
"workflowTypeUpscaling": "افزایش کیفیت",
"workflowTypeVideoGeneration": "تولید ویدیو"
"workflowNamePlaceholder": "نکته: یک نام توصیفی وارد کنید که به راحتی قابل جستجو باشد"
},
"commands": {
"clear": "پاک‌سازی workflow",
@@ -780,6 +782,7 @@
"COMFY_MATCHTYPE_V3": "Comfy MatchType V3",
"CONDITIONING": "شرط‌گذاری",
"CONTROL_NET": "controlnet",
"CURVE": "CURVE",
"ELEVENLABS_VOICE": "ELEVENLABS_VOICE",
"FILE_3D": "FILE_3D",
"FILE_3D_FBX": "FILE_3D_FBX",
@@ -793,6 +796,7 @@
"GEMINI_INPUT_FILES": "فایل‌های ورودی Gemini",
"GLIGEN": "GLIGEN",
"GUIDER": "راهنما",
"HISTOGRAM": "HISTOGRAM",
"HOOKS": "hookها",
"HOOK_KEYFRAMES": "کلیدفریم‌های hook",
"IMAGE": "تصویر",
@@ -884,13 +888,13 @@
"resume": "ادامه دانلود"
},
"errorDialog": {
"accessRestrictedMessage": "Your account is not authorized for this feature.",
"accessRestrictedTitle": "Access Restricted",
"defaultTitle": "خطایی رخ داد",
"extensionFileHint": "این ممکن است به دلیل اسکریپت زیر باشد",
"loadWorkflowTitle": "بارگذاری به دلیل خطا در بارگذاری مجدد داده‌های workflow متوقف شد",
"noStackTrace": "هیچ stacktraceی موجود نیست",
"promptExecutionError": "اجرای prompt با شکست مواجه شد",
"accessRestrictedTitle": "Access Restricted",
"accessRestrictedMessage": "Your account is not authorized for this feature."
"promptExecutionError": "اجرای prompt با شکست مواجه شد"
},
"errorOverlay": {
"errorCount": "{count} خطا",
@@ -934,6 +938,19 @@
"textToImage": "تبدیل متن به تصویر",
"textToVideo": "تبدیل متن به ویدیو"
},
"execution": {
"decoding": "در حال رمزگشایی…",
"encoding": "در حال کدگذاری…",
"generating": "در حال تولید…",
"generatingVideo": "در حال تولید ویدیو…",
"loading": "در حال بارگذاری…",
"processing": "در حال پردازش…",
"processingVideo": "در حال پردازش ویدیو…",
"resizing": "در حال تغییر اندازه…",
"running": "در حال اجرا…",
"saving": "در حال ذخیره‌سازی…",
"training": "در حال آموزش…"
},
"exportToast": {
"allExportsCompleted": "همه خروجی‌ها تکمیل شد",
"downloadExport": "دانلود خروجی",
@@ -1090,6 +1107,7 @@
"icon": "آیکون",
"imageDoesNotExist": "تصویر وجود ندارد",
"imageFailedToLoad": "بارگذاری تصویر ناموفق بود",
"imageGallery": "گالری تصاویر",
"imageLightbox": "پیش‌نمایش تصویر",
"imagePreview": "پیش‌نمایش تصویر - برای جابجایی بین تصاویر از کلیدهای جهت‌دار استفاده کنید",
"imageUrl": "آدرس تصویر",
@@ -1104,7 +1122,6 @@
"installed": "نصب شده",
"installing": "در حال نصب",
"interrupted": "متوقف شده",
"itemSelected": "{selectedCount} مورد انتخاب شد",
"itemsCopiedToClipboard": "موارد در کلیپ‌بورد کپی شدند",
"itemsSelected": "{selectedCount} مورد انتخاب شدند",
"job": "وظیفه",
@@ -1310,6 +1327,7 @@
"versionMismatchWarningMessage": "{warning}: {detail} برای راهنمای به‌روزرسانی به https://docs.comfy.org/installation/update_comfyui#common-update-issues مراجعه کنید.",
"videoFailedToLoad": "بارگذاری ویدیو ناموفق بود",
"videoPreview": "پیش‌نمایش ویدیو - برای جابجایی بین ویدیوها از کلیدهای جهت‌دار استفاده کنید",
"viewGrid": "نمای شبکه‌ای",
"viewImageOfTotal": "مشاهده تصویر {index} از {total}",
"viewVideoOfTotal": "مشاهده ویدیو {index} از {total}",
"volume": "حجم صدا",

View File

@@ -1438,6 +1438,22 @@
}
}
},
"ComfyNumberConvert": {
"display_name": "تبدیل عدد",
"inputs": {
"value": {
"name": "مقدار"
}
},
"outputs": {
"0": {
"tooltip": null
},
"1": {
"tooltip": null
}
}
},
"ComfySwitchNode": {
"display_name": "سوئیچ",
"inputs": {
@@ -2218,6 +2234,23 @@
}
}
},
"CurveEditor": {
"display_name": "ویرایشگر منحنی",
"inputs": {
"curve": {
"name": "منحنی"
},
"histogram": {
"name": "هیستوگرام"
}
},
"outputs": {
"0": {
"name": "منحنی",
"tooltip": null
}
}
},
"CustomCombo": {
"display_name": "ترکیب سفارشی",
"inputs": {
@@ -4092,6 +4125,39 @@
}
}
},
"GrokVideoExtendNode": {
"description": "گسترش یک ویدیوی موجود با ادامه‌ای یکپارچه بر اساس یک متن راهنما.",
"display_name": "گسترش ویدیو Grok",
"inputs": {
"control_after_generate": {
"name": "کنترل پس از تولید"
},
"model": {
"name": "مدل",
"tooltip": "مدلی که برای گسترش ویدیو استفاده می‌شود."
},
"model_duration": {
"name": "مدت زمان"
},
"prompt": {
"name": "راهنمای متنی",
"tooltip": "توضیح متنی درباره آنچه باید در ادامه ویدیو رخ دهد."
},
"seed": {
"name": "seed",
"tooltip": "seed برای تعیین اینکه node باید دوباره اجرا شود یا خیر؛ نتایج واقعی صرف‌نظر از seed غیرقطعی هستند."
},
"video": {
"name": "ویدیو",
"tooltip": "ویدیوی منبع برای گسترش. فرمت MP4، بین ۲ تا ۱۵ ثانیه."
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"GrokVideoNode": {
"description": "تولید ویدیو از یک راهنما یا تصویر",
"display_name": "ویدیو Grok",
@@ -4132,6 +4198,41 @@
}
}
},
"GrokVideoReferenceNode": {
"description": "تولید ویدیو با راهنمایی تصاویر مرجع به عنوان سبک و محتوا.",
"display_name": "تولید ویدیو با مرجع Grok",
"inputs": {
"control_after_generate": {
"name": "کنترل پس از تولید"
},
"model": {
"name": "مدل",
"tooltip": "مدلی که برای تولید ویدیو استفاده می‌شود."
},
"model_aspect_ratio": {
"name": "نسبت تصویر"
},
"model_duration": {
"name": "مدت زمان"
},
"model_resolution": {
"name": "وضوح"
},
"prompt": {
"name": "راهنمای متنی",
"tooltip": "توضیح متنی درباره ویدیوی مورد نظر."
},
"seed": {
"name": "seed",
"tooltip": "seed برای تعیین اینکه node باید دوباره اجرا شود یا خیر؛ نتایج واقعی صرف‌نظر از seed غیرقطعی هستند."
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"GrowMask": {
"display_name": "گسترش ماسک",
"inputs": {
@@ -6748,6 +6849,54 @@
}
}
},
"LTXVReferenceAudio": {
"description": "تنظیم صدای مرجع برای انتقال هویت گوینده با استفاده از ID-LoRA. یک کلیپ صوتی مرجع را به صورت شرطی رمزگذاری می‌کند و در صورت نیاز مدل را با راهنمایی هویتی (یک عبور اضافی بدون مرجع برای تقویت اثر هویت گوینده) اصلاح می‌کند.",
"display_name": "LTXV Reference Audio (ID-LoRA)",
"inputs": {
"audio_vae": {
"name": "audio_vae",
"tooltip": "LTXV Audio VAE برای رمزگذاری."
},
"end_percent": {
"name": "end_percent",
"tooltip": "پایان بازه سیگما که راهنمایی هویتی در آن فعال است."
},
"identity_guidance_scale": {
"name": "identity_guidance_scale",
"tooltip": "شدت راهنمایی هویتی. در هر مرحله یک عبور اضافی بدون مرجع اجرا می‌شود تا هویت گوینده تقویت شود. برای غیرفعال کردن، مقدار را روی ۰ قرار دهید (بدون عبور اضافی)."
},
"model": {
"name": "مدل"
},
"negative": {
"name": "منفی"
},
"positive": {
"name": "مثبت"
},
"reference_audio": {
"name": "reference_audio",
"tooltip": "کلیپ صوتی مرجع که هویت گوینده آن منتقل می‌شود. مدت زمان پیشنهادی حدود ۵ ثانیه (مدت زمان آموزش). کلیپ‌های کوتاه‌تر یا بلندتر ممکن است کیفیت انتقال هویت صدا را کاهش دهند."
},
"start_percent": {
"name": "start_percent",
"tooltip": "آغاز بازه سیگما که راهنمایی هویتی در آن فعال است."
}
},
"outputs": {
"0": {
"tooltip": null
},
"1": {
"name": "مثبت",
"tooltip": null
},
"2": {
"name": "منفی",
"tooltip": null
}
}
},
"LTXVScheduler": {
"display_name": "LTXVScheduler",
"inputs": {
@@ -16091,6 +16240,10 @@
"name": "شرط مثبت",
"tooltip": "شرط مثبت مورد استفاده برای آموزش."
},
"quantized_backward": {
"name": "quantized_backward",
"tooltip": "زمانی که training_dtype روی 'none' تنظیم شده و آموزش روی مدل quantized انجام می‌شود، در صورت فعال بودن، عملیات backward با quantized matmul انجام می‌شود."
},
"rank": {
"name": "رتبه",
"tooltip": "رتبه لایه‌های LoRA."

View File

@@ -564,19 +564,26 @@
"uploadCover": "+ Télécharger une couverture",
"uploadProfilePicture": "+ Télécharger une photo de profil",
"uploadWorkflowButton": "Télécharger mon workflow",
"usernameError": "3 à 42 caractères alphanumériques minuscules et tirets, doit commencer et se terminer par une lettre ou un chiffre",
"usernameLabel": "Votre nom d'utilisateur (obligatoire)",
"usernamePlaceholder": "@"
},
"comfyHubPublish": {
"additionalInfo": "Informations supplémentaires",
"back": "Retour",
"createProfileCta": "Créer un profil",
"createProfileToPublish": "Créez un profil pour publier sur ComfyHub",
"exampleImage": "Image d'exemple {index}",
"exampleImagePosition": "Image dexemple {index} sur {total}",
"examplesDescription": "Ajoutez jusqu'à {total} images d'exemple supplémentaires",
"maxExamples": "Vous pouvez sélectionner jusqu'à {max} exemples",
"next": "Suivant",
"publishButton": "Publier sur ComfyHub",
"publishFailedDescription": "Une erreur sest produite lors de la publication de votre workflow. Veuillez réessayer.",
"publishFailedTitle": "Échec de la publication",
"removeExampleImage": "Supprimer limage dexemple",
"selectAThumbnail": "Sélectionner une miniature",
"shareAs": "Partager en tant que",
"showLessTags": "Afficher moins...",
"showMoreTags": "Afficher plus...",
"stepDescribe": "Décrivez votre workflow",
@@ -591,6 +598,7 @@
"thumbnailPreview": "Aperçu de la miniature",
"thumbnailVideo": "Vidéo",
"title": "Publier sur ComfyHub",
"unsavedDescription": "Vous devez enregistrer votre workflow avant de le publier sur ComfyHub. Enregistrez-le maintenant pour continuer.",
"uploadAnImage": "Cliquez pour parcourir ou faites glisser une image",
"uploadComparison": "Télécharger avant et après",
"uploadComparisonAfterPrompt": "Après",
@@ -606,13 +614,7 @@
"workflowDescription": "Description du workflow",
"workflowDescriptionPlaceholder": "Qu'est-ce qui rend votre workflow passionnant et unique ? Soyez précis pour que les utilisateurs sachent à quoi s'attendre.",
"workflowName": "Nom du workflow",
"workflowNamePlaceholder": "Astuce : saisissez un nom descriptif facile à rechercher",
"workflowType": "Type de workflow",
"workflowTypeEditing": "Édition",
"workflowTypeImageGeneration": "Génération d'image",
"workflowTypePlaceholder": "Sélectionnez le type",
"workflowTypeUpscaling": "Upscaling",
"workflowTypeVideoGeneration": "Génération de vidéo"
"workflowNamePlaceholder": "Astuce : saisissez un nom descriptif facile à rechercher"
},
"commands": {
"clear": "Effacer le workflow",
@@ -780,6 +782,7 @@
"COMFY_MATCHTYPE_V3": "COMFY_MATCHTYPE_V3",
"CONDITIONING": "CONDITIONNEMENT",
"CONTROL_NET": "RESEAU_DE_CONTROLE",
"CURVE": "COURBE",
"ELEVENLABS_VOICE": "ELEVENLABS_VOICE",
"FILE_3D": "FICHIER_3D",
"FILE_3D_FBX": "FICHIER_3D_FBX",
@@ -793,6 +796,7 @@
"GEMINI_INPUT_FILES": "FICHIERS_ENTRÉE_GEMINI",
"GLIGEN": "GLIGEN",
"GUIDER": "GUIDE",
"HISTOGRAM": "HISTOGRAMME",
"HOOKS": "CROCHETS",
"HOOK_KEYFRAMES": "CLEFS_DE_CROCHET",
"IMAGE": "IMAGE",
@@ -884,13 +888,13 @@
"resume": "Reprendre le téléchargement"
},
"errorDialog": {
"accessRestrictedMessage": "Your account is not authorized for this feature.",
"accessRestrictedTitle": "Access Restricted",
"defaultTitle": "Une erreur est survenue",
"extensionFileHint": "Cela peut être dû au script suivant",
"loadWorkflowTitle": "Chargement interrompu en raison d'une erreur de rechargement des données de workflow",
"noStackTrace": "Aucune trace de pile disponible",
"promptExecutionError": "L'exécution de l'invite a échoué",
"accessRestrictedTitle": "Access Restricted",
"accessRestrictedMessage": "Your account is not authorized for this feature."
"promptExecutionError": "L'exécution de l'invite a échoué"
},
"errorOverlay": {
"errorCount": "{count} ERREUR | {count} ERREURS",
@@ -934,6 +938,19 @@
"textToImage": "Texte vers image",
"textToVideo": "Texte vers vidéo"
},
"execution": {
"decoding": "Décodage…",
"encoding": "Encodage…",
"generating": "Génération…",
"generatingVideo": "Génération de la vidéo…",
"loading": "Chargement…",
"processing": "Traitement…",
"processingVideo": "Traitement de la vidéo…",
"resizing": "Redimensionnement…",
"running": "Exécution…",
"saving": "Enregistrement…",
"training": "Entraînement…"
},
"exportToast": {
"allExportsCompleted": "Toutes les exportations sont terminées",
"downloadExport": "Télécharger lexport",
@@ -1090,6 +1107,7 @@
"icon": "Icône",
"imageDoesNotExist": "Limage nexiste pas",
"imageFailedToLoad": "Échec du chargement de l'image",
"imageGallery": "galerie dimages",
"imageLightbox": "Aperçu de l'image",
"imagePreview": "Aperçu de l'image - Utilisez les flèches pour naviguer entre les images",
"imageUrl": "URL de l'image",
@@ -1104,7 +1122,6 @@
"installed": "Installé",
"installing": "Installation",
"interrupted": "Interrompu",
"itemSelected": "{selectedCount} élément sélectionné",
"itemsCopiedToClipboard": "Éléments copiés dans le presse-papiers",
"itemsSelected": "{selectedCount} éléments sélectionnés",
"job": "Tâche",
@@ -1310,6 +1327,7 @@
"versionMismatchWarningMessage": "{warning} : {detail} Consultez https://docs.comfy.org/installation/update_comfyui#common-update-issues pour les instructions de mise à jour.",
"videoFailedToLoad": "Échec du chargement de la vidéo",
"videoPreview": "Aperçu de la vidéo - Utilisez les flèches pour naviguer entre les vidéos",
"viewGrid": "Vue grille",
"viewImageOfTotal": "Voir l'image {index} sur {total}",
"viewVideoOfTotal": "Voir la vidéo {index} sur {total}",
"volume": "Volume",

View File

@@ -1438,6 +1438,22 @@
}
}
},
"ComfyNumberConvert": {
"display_name": "Conversion de nombre",
"inputs": {
"value": {
"name": "valeur"
}
},
"outputs": {
"0": {
"tooltip": null
},
"1": {
"tooltip": null
}
}
},
"ComfySwitchNode": {
"display_name": "Commutateur",
"inputs": {
@@ -2218,6 +2234,23 @@
}
}
},
"CurveEditor": {
"display_name": "Éditeur de courbe",
"inputs": {
"curve": {
"name": "courbe"
},
"histogram": {
"name": "histogramme"
}
},
"outputs": {
"0": {
"name": "courbe",
"tooltip": null
}
}
},
"CustomCombo": {
"display_name": "Combo personnalisé",
"inputs": {
@@ -4092,6 +4125,39 @@
}
}
},
"GrokVideoExtendNode": {
"description": "Prolongez une vidéo existante avec une continuation fluide basée sur une invite textuelle.",
"display_name": "Extension vidéo Grok",
"inputs": {
"control_after_generate": {
"name": "contrôle après génération"
},
"model": {
"name": "modèle",
"tooltip": "Le modèle à utiliser pour lextension vidéo."
},
"model_duration": {
"name": "durée"
},
"prompt": {
"name": "invite",
"tooltip": "Description textuelle de ce qui doit se passer ensuite dans la vidéo."
},
"seed": {
"name": "graine",
"tooltip": "Graine pour déterminer si le nœud doit être relancé ; les résultats réels sont non déterministes quel que soit la graine."
},
"video": {
"name": "vidéo",
"tooltip": "Vidéo source à prolonger. Format MP4, 2-15 secondes."
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"GrokVideoNode": {
"description": "Générez une vidéo à partir d'une invite ou d'une image",
"display_name": "Grok Video",
@@ -4132,6 +4198,41 @@
}
}
},
"GrokVideoReferenceNode": {
"description": "Générez une vidéo guidée par des images de référence comme références de style et de contenu.",
"display_name": "Grok Référence-vers-Vidéo",
"inputs": {
"control_after_generate": {
"name": "contrôle après génération"
},
"model": {
"name": "modèle",
"tooltip": "Le modèle à utiliser pour la génération vidéo."
},
"model_aspect_ratio": {
"name": "ratio daspect"
},
"model_duration": {
"name": "durée"
},
"model_resolution": {
"name": "résolution"
},
"prompt": {
"name": "invite",
"tooltip": "Description textuelle de la vidéo souhaitée."
},
"seed": {
"name": "graine",
"tooltip": "Graine pour déterminer si le nœud doit être relancé ; les résultats réels sont non déterministes quel que soit la graine."
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"GrowMask": {
"display_name": "GrowMask",
"inputs": {
@@ -6748,6 +6849,54 @@
}
}
},
"LTXVReferenceAudio": {
"description": "Définir un audio de référence pour le transfert d'identité de locuteur ID-LoRA. Encode un clip audio de référence dans le conditionnement et, en option, applique un guidage d'identité au modèle (passe supplémentaire sans référence, amplifiant l'effet d'identité du locuteur).",
"display_name": "LTXV Reference Audio (ID-LoRA)",
"inputs": {
"audio_vae": {
"name": "audio_vae",
"tooltip": "LTXV Audio VAE pour l'encodage."
},
"end_percent": {
"name": "pourcentage_fin",
"tooltip": "Fin de la plage sigma où le guidage d'identité est actif."
},
"identity_guidance_scale": {
"name": "échelle_guidage_identité",
"tooltip": "Intensité du guidage d'identité. Effectue une passe supplémentaire sans référence à chaque étape pour amplifier l'identité du locuteur. Mettre à 0 pour désactiver (pas de passe supplémentaire)."
},
"model": {
"name": "modèle"
},
"negative": {
"name": "négatif"
},
"positive": {
"name": "positif"
},
"reference_audio": {
"name": "audio_de_référence",
"tooltip": "Clip audio de référence dont l'identité du locuteur sera transférée. ~5 secondes recommandées (durée d'entraînement). Des clips plus courts ou plus longs peuvent dégrader le transfert d'identité vocale."
},
"start_percent": {
"name": "pourcentage_début",
"tooltip": "Début de la plage sigma où le guidage d'identité est actif."
}
},
"outputs": {
"0": {
"tooltip": null
},
"1": {
"name": "positif",
"tooltip": null
},
"2": {
"name": "négatif",
"tooltip": null
}
}
},
"LTXVScheduler": {
"display_name": "LTXVScheduler",
"inputs": {
@@ -16091,6 +16240,10 @@
"name": "positif",
"tooltip": "Le conditionnement positif à utiliser pour l'entraînement."
},
"quantized_backward": {
"name": "quantized_backward",
"tooltip": "Lorsque le paramètre training_dtype est défini sur 'none' et que l'entraînement se fait sur un modèle quantifié, effectue la rétropropagation avec une multiplication matricielle quantifiée si activé."
},
"rank": {
"name": "rang",
"tooltip": "Le rang des couches LoRA."

View File

@@ -564,19 +564,26 @@
"uploadCover": "+ カバー画像をアップロード",
"uploadProfilePicture": "+ プロフィール画像をアップロード",
"uploadWorkflowButton": "ワークフローをアップロード",
"usernameError": "342文字の半角英数字とハイフン、小文字で始まり終わる必要があります",
"usernameLabel": "ユーザー名(必須)",
"usernamePlaceholder": "@"
},
"comfyHubPublish": {
"additionalInfo": "追加情報",
"back": "戻る",
"createProfileCta": "プロフィールを作成",
"createProfileToPublish": "ComfyHubに公開するにはプロフィールを作成してください",
"exampleImage": "サンプル画像 {index}",
"exampleImagePosition": "サンプル画像 {index} / {total}",
"examplesDescription": "追加サンプル画像は最大{total}枚まで",
"maxExamples": "最大{max}件まで選択できます",
"next": "次へ",
"publishButton": "ComfyHub へ公開",
"publishFailedDescription": "ワークフローの公開中に問題が発生しました。もう一度お試しください。",
"publishFailedTitle": "公開に失敗しました",
"removeExampleImage": "サンプル画像を削除",
"selectAThumbnail": "サムネイルを選択",
"shareAs": "次の形式で共有",
"showLessTags": "表示を減らす...",
"showMoreTags": "さらに表示...",
"stepDescribe": "ワークフローを説明する",
@@ -591,6 +598,7 @@
"thumbnailPreview": "サムネイルプレビュー",
"thumbnailVideo": "動画",
"title": "ComfyHub へ公開",
"unsavedDescription": "ComfyHub に公開する前にワークフローを保存する必要があります。続行するには今すぐ保存してください。",
"uploadAnImage": "クリックして選択 または画像をドラッグ",
"uploadComparison": "ビフォーアフターをアップロード",
"uploadComparisonAfterPrompt": "アフター",
@@ -606,13 +614,7 @@
"workflowDescription": "ワークフローの説明",
"workflowDescriptionPlaceholder": "あなたのワークフローの魅力や特徴は何ですか?具体的に記載することで、利用者が内容を理解しやすくなります。",
"workflowName": "ワークフロー名",
"workflowNamePlaceholder": "ヒント:検索しやすい説明的な名前を入力してください",
"workflowType": "ワークフロータイプ",
"workflowTypeEditing": "編集",
"workflowTypeImageGeneration": "画像生成",
"workflowTypePlaceholder": "タイプを選択してください",
"workflowTypeUpscaling": "アップスケーリング",
"workflowTypeVideoGeneration": "動画生成"
"workflowNamePlaceholder": "ヒント:検索しやすい説明的な名前を入力してください"
},
"commands": {
"clear": "ワークフローをクリア",
@@ -780,6 +782,7 @@
"COMFY_MATCHTYPE_V3": "COMFY_MATCHTYPE_V3",
"CONDITIONING": "条件付け",
"CONTROL_NET": "コントロールネット",
"CURVE": "カーブ",
"ELEVENLABS_VOICE": "ELEVENLABS_VOICE",
"FILE_3D": "FILE_3D",
"FILE_3D_FBX": "FILE_3D_FBX",
@@ -793,6 +796,7 @@
"GEMINI_INPUT_FILES": "GEMINI入力ファイル",
"GLIGEN": "GLIGEN",
"GUIDER": "ガイダー",
"HISTOGRAM": "ヒストグラム",
"HOOKS": "フック",
"HOOK_KEYFRAMES": "フックキーフレーム",
"IMAGE": "画像",
@@ -884,13 +888,13 @@
"resume": "ダウンロードを再開"
},
"errorDialog": {
"accessRestrictedMessage": "Your account is not authorized for this feature.",
"accessRestrictedTitle": "Access Restricted",
"defaultTitle": "エラーが発生しました",
"extensionFileHint": "これは次のスクリプトが原因かもしれません",
"loadWorkflowTitle": "ワークフローデータの再読み込みエラーにより、読み込みが中止されました",
"noStackTrace": "スタックトレースは利用できません",
"promptExecutionError": "プロンプトの実行に失敗しました",
"accessRestrictedTitle": "Access Restricted",
"accessRestrictedMessage": "Your account is not authorized for this feature."
"promptExecutionError": "プロンプトの実行に失敗しました"
},
"errorOverlay": {
"errorCount": "{count} 件のエラー",
@@ -934,6 +938,19 @@
"textToImage": "テキストから画像へ",
"textToVideo": "テキストから動画へ"
},
"execution": {
"decoding": "デコード中…",
"encoding": "エンコード中…",
"generating": "生成中…",
"generatingVideo": "ビデオ生成中…",
"loading": "読み込み中…",
"processing": "処理中…",
"processingVideo": "ビデオ処理中…",
"resizing": "リサイズ中…",
"running": "実行中…",
"saving": "保存中…",
"training": "トレーニング中…"
},
"exportToast": {
"allExportsCompleted": "すべてのエクスポートが完了しました",
"downloadExport": "エクスポートをダウンロード",
@@ -1090,6 +1107,7 @@
"icon": "アイコン",
"imageDoesNotExist": "画像が存在しません",
"imageFailedToLoad": "画像の読み込みに失敗しました",
"imageGallery": "画像ギャラリー",
"imageLightbox": "画像プレビュー",
"imagePreview": "画像プレビュー - 矢印キーで画像を切り替え",
"imageUrl": "画像URL",
@@ -1104,7 +1122,6 @@
"installed": "インストール済み",
"installing": "インストール中",
"interrupted": "中断されました",
"itemSelected": "{selectedCount}件選択済み",
"itemsCopiedToClipboard": "項目をクリップボードにコピーしました",
"itemsSelected": "{selectedCount}件選択済み",
"job": "ジョブ",
@@ -1310,6 +1327,7 @@
"versionMismatchWarningMessage": "{warning}: {detail} 更新手順については https://docs.comfy.org/installation/update_comfyui#common-update-issues をご覧ください。",
"videoFailedToLoad": "ビデオの読み込みに失敗しました",
"videoPreview": "ビデオプレビュー - 矢印キーでビデオを切り替え",
"viewGrid": "グリッド表示",
"viewImageOfTotal": "画像 {index} / {total} を表示",
"viewVideoOfTotal": "ビデオ {index} / {total} を表示",
"volume": "音量",

View File

@@ -1438,6 +1438,22 @@
}
}
},
"ComfyNumberConvert": {
"display_name": "数値変換",
"inputs": {
"value": {
"name": "値"
}
},
"outputs": {
"0": {
"tooltip": null
},
"1": {
"tooltip": null
}
}
},
"ComfySwitchNode": {
"display_name": "スイッチ",
"inputs": {
@@ -2218,6 +2234,23 @@
}
}
},
"CurveEditor": {
"display_name": "カーブエディター",
"inputs": {
"curve": {
"name": "カーブ"
},
"histogram": {
"name": "ヒストグラム"
}
},
"outputs": {
"0": {
"name": "カーブ",
"tooltip": null
}
}
},
"CustomCombo": {
"display_name": "カスタムコンボ",
"inputs": {
@@ -4092,6 +4125,39 @@
}
}
},
"GrokVideoExtendNode": {
"description": "テキストプロンプトに基づいて、既存のビデオをシームレスに継続します。",
"display_name": "Grokビデオ拡張",
"inputs": {
"control_after_generate": {
"name": "生成後のコントロール"
},
"model": {
"name": "モデル",
"tooltip": "ビデオ拡張に使用するモデル。"
},
"model_duration": {
"name": "継続時間"
},
"prompt": {
"name": "プロンプト",
"tooltip": "ビデオの次に何が起こるべきかのテキスト説明。"
},
"seed": {
"name": "シード",
"tooltip": "ノードを再実行するかどうかを決定するシード。実際の結果はシードに関係なく非決定的です。"
},
"video": {
"name": "ビデオ",
"tooltip": "拡張する元のビデオ。MP4形式、215秒。"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"GrokVideoNode": {
"description": "プロンプトまたは画像から動画を生成します",
"display_name": "Grok動画生成",
@@ -4132,6 +4198,41 @@
}
}
},
"GrokVideoReferenceNode": {
"description": "リファレンス画像をスタイルや内容の参考として、ビデオを生成します。",
"display_name": "Grokリファレンス→ビデオ",
"inputs": {
"control_after_generate": {
"name": "生成後のコントロール"
},
"model": {
"name": "モデル",
"tooltip": "ビデオ生成に使用するモデル。"
},
"model_aspect_ratio": {
"name": "アスペクト比"
},
"model_duration": {
"name": "継続時間"
},
"model_resolution": {
"name": "解像度"
},
"prompt": {
"name": "プロンプト",
"tooltip": "希望するビデオのテキスト説明。"
},
"seed": {
"name": "シード",
"tooltip": "ノードを再実行するかどうかを決定するシード。実際の結果はシードに関係なく非決定的です。"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"GrowMask": {
"display_name": "マスクを拡大",
"inputs": {
@@ -6748,6 +6849,54 @@
}
}
},
"LTXVReferenceAudio": {
"description": "ID-LoRA話者識別転送のためのリファレンス音声を設定します。リファレンス音声クリップをコンディショニングにエンコードし、必要に応じてモデルにアイデンティティガイダンスリファレンスなしの追加フォワードパスで話者識別効果を強調を適用します。",
"display_name": "LTXV Reference Audio (ID-LoRA)",
"inputs": {
"audio_vae": {
"name": "audio_vae",
"tooltip": "エンコード用のLTXV Audio VAE。"
},
"end_percent": {
"name": "end_percent",
"tooltip": "アイデンティティガイダンスが有効になるシグマ範囲の終了点。"
},
"identity_guidance_scale": {
"name": "identity_guidance_scale",
"tooltip": "アイデンティティガイダンスの強さ。各ステップでリファレンスなしの追加フォワードパスを実行し、話者識別を強調します。0に設定すると無効化されます追加パスなし。"
},
"model": {
"name": "model"
},
"negative": {
"name": "negative"
},
"positive": {
"name": "positive"
},
"reference_audio": {
"name": "reference_audio",
"tooltip": "転送したい話者識別を持つリファレンス音声クリップ。約5秒間学習時間が推奨されます。短すぎたり長すぎたりすると話者識別転送の品質が低下する場合があります。"
},
"start_percent": {
"name": "start_percent",
"tooltip": "アイデンティティガイダンスが有効になるシグマ範囲の開始点。"
}
},
"outputs": {
"0": {
"tooltip": null
},
"1": {
"name": "positive",
"tooltip": null
},
"2": {
"name": "negative",
"tooltip": null
}
}
},
"LTXVScheduler": {
"display_name": "LTXVスケジューラー",
"inputs": {
@@ -16091,6 +16240,10 @@
"name": "ポジティブ条件付け",
"tooltip": "学習に使用するポジティブな条件付け。"
},
"quantized_backward": {
"name": "quantized_backward",
"tooltip": "training_dtype を「none」に設定し、量子化モデルでトレーニングする場合、有効にすると逆伝播時に量子化された行列積を使用します。"
},
"rank": {
"name": "ランク",
"tooltip": "LoRAレイヤーのランク。"

View File

@@ -564,19 +564,26 @@
"uploadCover": "+ 커버 업로드",
"uploadProfilePicture": "+ 프로필 사진 업로드",
"uploadWorkflowButton": "내 워크플로우 업로드하기",
"usernameError": "3~42자의 소문자 영문, 숫자, 하이픈만 사용 가능하며, 반드시 영문 또는 숫자로 시작하고 끝나야 합니다.",
"usernameLabel": "사용자 이름 (필수)",
"usernamePlaceholder": "@"
},
"comfyHubPublish": {
"additionalInfo": "추가 정보",
"back": "뒤로",
"createProfileCta": "프로필 생성하기",
"createProfileToPublish": "ComfyHub에 게시하려면 프로필을 생성하세요",
"exampleImage": "예시 이미지 {index}",
"exampleImagePosition": "예시 이미지 {index}/{total}",
"examplesDescription": "최대 {total}개의 추가 샘플 이미지를 추가할 수 있습니다",
"maxExamples": "최대 {max}개의 예시를 선택할 수 있습니다",
"next": "다음",
"publishButton": "ComfyHub에 게시하기",
"publishFailedDescription": "워크플로우를 게시하는 중에 문제가 발생했습니다. 다시 시도해 주세요.",
"publishFailedTitle": "게시 실패",
"removeExampleImage": "예시 이미지 제거",
"selectAThumbnail": "썸네일 선택",
"shareAs": "다음으로 공유",
"showLessTags": "간단히 보기...",
"showMoreTags": "더 보기...",
"stepDescribe": "워크플로우 설명하기",
@@ -591,6 +598,7 @@
"thumbnailPreview": "썸네일 미리보기",
"thumbnailVideo": "비디오",
"title": "ComfyHub에 게시하기",
"unsavedDescription": "워크플로우를 ComfyHub에 게시하기 전에 저장해야 합니다. 계속하려면 지금 저장하세요.",
"uploadAnImage": "클릭하여 탐색하거나 이미지를 드래그하세요",
"uploadComparison": "비교 이미지 업로드",
"uploadComparisonAfterPrompt": "이후",
@@ -606,13 +614,7 @@
"workflowDescription": "워크플로우 설명",
"workflowDescriptionPlaceholder": "무엇이 워크플로우를 흥미롭고 특별하게 만드는지 구체적으로 작성해 주세요. 사람들이 무엇을 기대할 수 있는지 알 수 있도록 해주세요.",
"workflowName": "워크플로우 이름",
"workflowNamePlaceholder": "팁: 검색하기 쉬운 설명적인 이름을 입력하세요",
"workflowType": "워크플로우 유형",
"workflowTypeEditing": "편집",
"workflowTypeImageGeneration": "이미지 생성",
"workflowTypePlaceholder": "유형 선택",
"workflowTypeUpscaling": "업스케일링",
"workflowTypeVideoGeneration": "비디오 생성"
"workflowNamePlaceholder": "팁: 검색하기 쉬운 설명적인 이름을 입력하세요"
},
"commands": {
"clear": "워크플로 지우기",
@@ -780,6 +782,7 @@
"COMFY_MATCHTYPE_V3": "COMFY_MATCHTYPE_V3",
"CONDITIONING": "조건",
"CONTROL_NET": "컨트롤넷",
"CURVE": "곡선",
"ELEVENLABS_VOICE": "ELEVENLABS_VOICE",
"FILE_3D": "FILE_3D",
"FILE_3D_FBX": "FILE_3D_FBX",
@@ -793,6 +796,7 @@
"GEMINI_INPUT_FILES": "GEMINI_INPUT_FILES",
"GLIGEN": "GLIGEN",
"GUIDER": "가이드",
"HISTOGRAM": "히스토그램",
"HOOKS": "후크",
"HOOK_KEYFRAMES": "후크 키프레임",
"IMAGE": "이미지",
@@ -884,13 +888,13 @@
"resume": "다운로드 재개"
},
"errorDialog": {
"accessRestrictedMessage": "Your account is not authorized for this feature.",
"accessRestrictedTitle": "Access Restricted",
"defaultTitle": "오류가 발생했습니다",
"extensionFileHint": "다음 스크립트 때문일 수 있습니다",
"loadWorkflowTitle": "워크플로 데이터를 다시 로드하는 중 오류로 인해 로드가 중단되었습니다",
"noStackTrace": "스택 추적을 사용할 수 없습니다",
"promptExecutionError": "프롬프트 실행 실패",
"accessRestrictedTitle": "Access Restricted",
"accessRestrictedMessage": "Your account is not authorized for this feature."
"promptExecutionError": "프롬프트 실행 실패"
},
"errorOverlay": {
"errorCount": "{count}개 오류",
@@ -934,6 +938,19 @@
"textToImage": "텍스트 → 이미지",
"textToVideo": "텍스트 → 비디오"
},
"execution": {
"decoding": "디코딩 중…",
"encoding": "인코딩 중…",
"generating": "생성 중…",
"generatingVideo": "비디오 생성 중…",
"loading": "불러오는 중…",
"processing": "처리 중…",
"processingVideo": "비디오 처리 중…",
"resizing": "크기 조정 중…",
"running": "실행 중…",
"saving": "저장 중…",
"training": "학습 중…"
},
"exportToast": {
"allExportsCompleted": "모든 내보내기 완료",
"downloadExport": "내보내기 다운로드",
@@ -1090,6 +1107,7 @@
"icon": "아이콘",
"imageDoesNotExist": "이미지가 존재하지 않습니다",
"imageFailedToLoad": "이미지를 로드하지 못했습니다.",
"imageGallery": "이미지 갤러리",
"imageLightbox": "이미지 미리보기",
"imagePreview": "이미지 미리보기 - 화살표 키를 사용하여 이미지 간 이동",
"imageUrl": "이미지 URL",
@@ -1104,7 +1122,6 @@
"installed": "설치됨",
"installing": "설치 중",
"interrupted": "중단됨",
"itemSelected": "{selectedCount}개 선택됨",
"itemsCopiedToClipboard": "항목이 클립보드에 복사되었습니다",
"itemsSelected": "{selectedCount}개 선택됨",
"job": "작업",
@@ -1310,6 +1327,7 @@
"versionMismatchWarningMessage": "{warning}: {detail} 업데이트 지침은 https://docs.comfy.org/installation/update_comfyui#common-update-issues 를 방문하세요.",
"videoFailedToLoad": "비디오를 로드하지 못했습니다.",
"videoPreview": "비디오 미리보기 - 화살표 키를 사용하여 비디오 간 이동",
"viewGrid": "그리드 보기",
"viewImageOfTotal": "이미지 {index}/{total} 보기",
"viewVideoOfTotal": "비디오 {index}/{total} 보기",
"volume": "볼륨",

View File

@@ -1438,6 +1438,22 @@
}
}
},
"ComfyNumberConvert": {
"display_name": "숫자 변환",
"inputs": {
"value": {
"name": "값"
}
},
"outputs": {
"0": {
"tooltip": null
},
"1": {
"tooltip": null
}
}
},
"ComfySwitchNode": {
"display_name": "스위치",
"inputs": {
@@ -2218,6 +2234,23 @@
}
}
},
"CurveEditor": {
"display_name": "곡선 편집기",
"inputs": {
"curve": {
"name": "곡선"
},
"histogram": {
"name": "히스토그램"
}
},
"outputs": {
"0": {
"name": "곡선",
"tooltip": null
}
}
},
"CustomCombo": {
"display_name": "사용자 지정 콤보",
"inputs": {
@@ -4092,6 +4125,39 @@
}
}
},
"GrokVideoExtendNode": {
"description": "텍스트 프롬프트를 기반으로 기존 비디오를 자연스럽게 이어서 확장합니다.",
"display_name": "Grok 비디오 확장",
"inputs": {
"control_after_generate": {
"name": "생성 후 제어"
},
"model": {
"name": "모델",
"tooltip": "비디오 확장에 사용할 모델입니다."
},
"model_duration": {
"name": "지속 시간"
},
"prompt": {
"name": "프롬프트",
"tooltip": "비디오에서 다음에 일어나야 할 일을 설명하는 텍스트입니다."
},
"seed": {
"name": "시드",
"tooltip": "노드가 다시 실행되어야 하는지 결정하는 시드입니다. 실제 결과는 시드와 관계없이 비결정적입니다."
},
"video": {
"name": "비디오",
"tooltip": "확장할 원본 비디오입니다. MP4 형식, 2-15초."
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"GrokVideoNode": {
"description": "프롬프트 또는 이미지를 통해 비디오를 생성합니다",
"display_name": "Grok 비디오",
@@ -4132,6 +4198,41 @@
}
}
},
"GrokVideoReferenceNode": {
"description": "참조 이미지를 스타일 및 콘텐츠 참조로 사용하여 비디오를 생성합니다.",
"display_name": "Grok 참조-비디오",
"inputs": {
"control_after_generate": {
"name": "생성 후 제어"
},
"model": {
"name": "모델",
"tooltip": "비디오 생성에 사용할 모델입니다."
},
"model_aspect_ratio": {
"name": "종횡비"
},
"model_duration": {
"name": "지속 시간"
},
"model_resolution": {
"name": "해상도"
},
"prompt": {
"name": "프롬프트",
"tooltip": "원하는 비디오를 설명하는 텍스트입니다."
},
"seed": {
"name": "시드",
"tooltip": "노드가 다시 실행되어야 하는지 결정하는 시드입니다. 실제 결과는 시드와 관계없이 비결정적입니다."
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"GrowMask": {
"display_name": "마스크 확장",
"inputs": {
@@ -6748,6 +6849,54 @@
}
}
},
"LTXVReferenceAudio": {
"description": "ID-LoRA 화자 정체성 전이를 위한 참조 오디오를 설정합니다. 참조 오디오 clip을 컨디셔닝에 인코딩하고, 선택적으로 모델에 정체성 가이던스를 적용합니다(참조 없이 추가로 한 번 더 forward pass를 실행하여 화자 정체성 효과를 증폭).",
"display_name": "LTXV Reference Audio (ID-LoRA)",
"inputs": {
"audio_vae": {
"name": "audio_vae",
"tooltip": "인코딩을 위한 LTXV Audio VAE."
},
"end_percent": {
"name": "end_percent",
"tooltip": "정체성 가이던스가 활성화되는 sigma 범위의 끝 지점입니다."
},
"identity_guidance_scale": {
"name": "identity_guidance_scale",
"tooltip": "정체성 가이던스의 강도입니다. 각 단계마다 참조 없이 추가로 forward pass를 실행하여 화자 정체성을 증폭합니다. 0으로 설정하면 비활성화됩니다(추가 pass 없음)."
},
"model": {
"name": "model"
},
"negative": {
"name": "negative"
},
"positive": {
"name": "positive"
},
"reference_audio": {
"name": "reference_audio",
"tooltip": "전이할 화자 정체성을 가진 참조 오디오 clip입니다. 약 5초(학습 시간)를 권장합니다. 더 짧거나 긴 clip은 음성 정체성 전이 품질이 저하될 수 있습니다."
},
"start_percent": {
"name": "start_percent",
"tooltip": "정체성 가이던스가 활성화되는 sigma 범위의 시작 지점입니다."
}
},
"outputs": {
"0": {
"tooltip": null
},
"1": {
"name": "positive",
"tooltip": null
},
"2": {
"name": "negative",
"tooltip": null
}
}
},
"LTXVScheduler": {
"display_name": "LTXV 스케줄러",
"inputs": {
@@ -16091,6 +16240,10 @@
"name": "긍정 조건",
"tooltip": "학습에 사용할 긍정 조건입니다."
},
"quantized_backward": {
"name": "quantized_backward",
"tooltip": "training_dtype가 'none'이고 양자화된 모델에서 학습할 때, 활성화 시 양자화된 matmul로 역전파를 수행합니다."
},
"rank": {
"name": "랭크",
"tooltip": "LoRA 계층의 랭크입니다."

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