Compare commits

...

29 Commits

Author SHA1 Message Date
Comfy Org PR Bot
5e99faadfe 1.41.7 (#9264)
Patch version increment to 1.41.7

**Base branch:** `main`

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9264-1-41-7-3146d73d36508108a0d1c3e418216cd0)
by [Unito](https://www.unito.io)

---------

Co-authored-by: christian-byrne <72887196+christian-byrne@users.noreply.github.com>
Co-authored-by: github-actions <github-actions@github.com>
2026-02-26 19:31:49 -08:00
Benjamin Lu
f495f07469 fix: move active jobs button into actionbar (#9211)
## Summary

Move the top menu `N active` queue button into `ComfyActionbar` so it
stays attached to the actionbar when docked, dragged, or floating.

## Changes

- Moved the `queue-overlay-toggle` button UI from `TopMenuSection.vue`
into `ComfyActionbar.vue`
- Moved queue button behavior and context menu handling (`toggle`,
right-click clear queue, active count badge/label) into
`ComfyActionbar.vue`
- Removed now-unused queue button state/handlers/imports from
`TopMenuSection.vue`

<img width="513" height="101" alt="image"
src="https://github.com/user-attachments/assets/6ed85237-4293-47a6-a0fb-258d0182889d"
/>

<img width="778" height="145" alt="image"
src="https://github.com/user-attachments/assets/6e5ef423-c5fc-471f-b9d0-a4bd8dc5d072"
/>

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9211-fix-move-active-jobs-button-into-actionbar-3126d73d365081ceb553c172db479e3b)
by [Unito](https://www.unito.io)
2026-02-26 19:00:10 -08:00
Christian Byrne
1054ba8949 fix: batch updateClipPath via requestAnimationFrame (#9173)
## Summary

Batch `getBoundingClientRect()` calls in `updateClipPath` via
`requestAnimationFrame` to avoid forced synchronous layout.

## Changes

- **What**: Wrap the layout-reading portion of `updateClipPath` in
`requestAnimationFrame()` with cancellation. Multiple rapid calls within
the same frame are coalesced into a single layout read. Eliminates
~1,053 forced synchronous layouts per profiling session.

## Review Focus

- `getBoundingClientRect()` forces synchronous layout. When interleaved
with style mutations (from PrimeVue `useStyle`, cursor writes, Vue VDOM
patching), this creates layout thrashing — especially in Firefox where
Stylo aggressively invalidates the entire style cache.
- The RAF wrapper coalesces all calls within a frame into one, reading
layout only once per frame. The `cancelAnimationFrame` ensures only the
latest parameters are used.
- `willChange: 'clip-path'` is included to hint the browser to optimize
clip-path animations.

## Stack

4 of 4 in Firefox perf fix stack. Depends on #9170.

<!-- Fixes #ISSUE_NUMBER -->

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9173-fix-batch-updateClipPath-via-requestAnimationFrame-3116d73d3650810392f7fba7ea5ceb6f)
by [Unito](https://www.unito.io)
2026-02-26 18:53:14 -08:00
Christian Byrne
0698ec23c0 feat: wire essentials_category for Essentials tab display (#9091)
## Summary

Wire `essentials_category` through from backend to the Essentials tab
UI. Creates a single source of truth for node categorization and
ordering.

### Changes

**New file — `src/constants/essentialsNodes.ts`:**
- Single source of truth: `ESSENTIALS_NODES` (ordered nodes per
category), `ESSENTIALS_CATEGORIES` (folder display order),
`ESSENTIALS_CATEGORY_MAP` (flat lookup), `TOOLKIT_NOVEL_NODE_NAMES`
(telemetry), `TOOLKIT_BLUEPRINT_MODULES`

**Refactored files:**
- `src/types/nodeSource.ts`: Removed inline `ESSENTIALS_CATEGORY_MOCK`,
imports `ESSENTIALS_CATEGORY_MAP` from centralized constants
- `src/services/nodeOrganizationService.ts`: Removed inline
`NODE_ORDER_BY_FOLDER`, imports `ESSENTIALS_NODES` and
`ESSENTIALS_CATEGORIES`
- `src/constants/toolkitNodes.ts`: Re-exports from `essentialsNodes.ts`
instead of maintaining a separate list

**Subgraph passthrough:**
- `src/stores/subgraphStore.ts`: Passes `essentials_category` from
`GlobalSubgraphData` and extracts it from `definitions.subgraphs[0]` as
fallback
- `src/platform/workflow/validation/schemas/workflowSchema.ts`: Added
`essentials_category` to `SubgraphDefinitionBase` and
`zSubgraphDefinition`

**Tests:**
- `src/constants/essentialsNodes.test.ts`: 6 tests validating no
duplicates, complete coverage, basics exclusion
- `src/stores/subgraphStore.test.ts`: 2 tests for essentials_category
passthrough

All 43 relevant tests pass. Typecheck, lint, format clean.

**Depends on:** Comfy-Org/ComfyUI#12573

Fixes COM-15221

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9091-feat-wire-essentials_category-for-Essentials-tab-display-30f6d73d3650814ab3d4c06b451c273b)
by [Unito](https://www.unito.io)

---------

Co-authored-by: github-actions <github-actions@github.com>
2026-02-26 18:40:15 -08:00
Johnpaul Chiwetelu
54b710b239 [refactor] Rename queueIndex variables to reflect job.priority usage (#9258)
## Summary
Rename `lastHistoryQueueIndex` → `lastJobHistoryPriority` and
`currentQueueIndex` → `currentJobPriority` to reflect that these
variables now read `job.priority` directly.

## Changes
- **queueStore.ts**: `lastHistoryQueueIndex` → `lastJobHistoryPriority`
- **JobDetailsPopover.vue**: `currentQueueIndex` → `currentJobPriority`
- **queueStore.test.ts**: Updated references and test descriptions

Fixes #9246

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9258-refactor-Rename-queueIndex-variables-to-reflect-job-priority-usage-3136d73d36508126989dd464f7dad9a1)
by [Unito](https://www.unito.io)
2026-02-26 18:31:27 -08:00
jaeone94
1c3984a178 feat: add node replacement UI to Errors Tab (#9253)
## Summary

Adds a node replacement UI to the Errors Tab so users can swap missing
nodes with compatible alternatives directly from the error panel,
without opening a separate dialog.

## Changes

- **What**: New `SwapNodesCard` and `SwapNodeGroupRow` components render
swap groups in the Errors Tab; each group shows the missing node type,
its instances (with locate buttons), and a Replace button. Added
`useMissingNodeScan` composable to scan the graph for missing nodes and
populate `executionErrorStore`. Added `removeMissingNodesByType()` to
`executionErrorStore` so replaced nodes are pruned from the error list
reactively.

## Bug Fixes Found During Implementation

### Bug 1: Replaced nodes render as empty shells until page refresh

`replaceWithMapping()` directly mutates `_nodes[idx]`, bypassing the Vue
rendering pipeline entirely. Because the replacement node reuses the
same ID, `vueNodeData` retains the stale entry from the old placeholder
(`hasErrors: true`, empty widgets/inputs). `graph.setDirtyCanvas()` only
repaints the LiteGraph canvas and has no effect on Vue.

**Fix**: After `replaceWithMapping()`, manually call
`nodeGraph.onNodeAdded?.(newNode)` to trigger `handleNodeAdded` in
`useGraphNodeManager`, which runs `extractVueNodeData(newNode)` and
updates `vueNodeData` correctly. Also added a guard in `handleNodeAdded`
to skip `layoutStore.createNode()` when a layout for the same ID already
exists, preventing a duplicate `spatialIndex.insert()`.

### Bug 2: Missing node error list overwritten by incomplete server
response

Two compounding issues: (A) the server's `missing_node_type` error only
reports the *first* missing node — the old handler parsed this and
called `surfaceMissingNodes([singleNode])`, overwriting the full list
collected at load time. (B) `queuePrompt()` calls `clearAllErrors()`
before the API request; if the subsequent rescan used the stale
`has_errors` flag and found nothing, the missing nodes were permanently
lost.

**Fix**: Created `useMissingNodeScan.ts` which scans
`LiteGraph.registered_node_types` directly (not `has_errors`). The
`missing_node_type` catch block in `app.ts` now calls
`rescanAndSurfaceMissingNodes(this.rootGraph)` instead of parsing the
server's partial response.

## Review Focus

- `handleReplaceNode` removes the group from the store only when
`replaceNodesInPlace` returns at least one replaced node — should we
always clear, or only on full success?
- `useMissingNodeScan` re-scans on every execution-error change; confirm
no performance concerns for large graphs with many subgraphs.


## Screenshots 


https://github.com/user-attachments/assets/78310fc4-0424-4920-b369-cef60a123d50



https://github.com/user-attachments/assets/3d2fd5e1-5e85-4c20-86aa-8bf920e86987



┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9253-feat-add-node-replacement-UI-to-Errors-Tab-3136d73d365081718d4ddfd628cb4449)
by [Unito](https://www.unito.io)
2026-02-26 17:37:48 -08:00
Benjamin Lu
367d96715b fix: open target panel when toggling Docked Job History (#9215)
## Summary
Make the Docked Job History toggle deterministic so it opens the
expected UI target in both directions.

## Changes
- Update `JobHistoryActionsMenu` toggle behavior:
- When currently docked (`Comfy.Queue.QPOV2=true`), disable docked mode
and explicitly open floating QPO (`Comfy.Queue.History.Expanded=true`)
- When currently floating (`Comfy.Queue.QPOV2=false`), enable docked
mode and open the `job-history` sidebar tab
- Add/adjust unit tests in `QueueOverlayHeader.test.ts` to verify both
toggle directions and target panel behavior

## Testing
- `pnpm exec eslint src/components/queue/JobHistoryActionsMenu.vue
src/components/queue/QueueOverlayHeader.test.ts`
- `pnpm typecheck`
- `pnpm test:unit -- src/components/queue/QueueOverlayHeader.test.ts`

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9215-fix-open-target-panel-when-toggling-Docked-Job-History-3126d73d3650810eb409ff38e3a521f3)
by [Unito](https://www.unito.io)
2026-02-26 16:21:31 -08:00
Benjamin Lu
84fdf55902 fix: set queue job filter tabs to 32px (#9217)
## Summary
- Increase queue job filter tab button height from `sm` to `md` (`32px`
equivalent).
- Apply consistently to both floating Queue Progress Overlay and docked
Job History sidebar, since both use `JobFilterTabs`.

## Design
-
https://www.figma.com/board/R9eN9DHmDgX3qEJXsRKiRr/QA-Feedback--Alex-?node-id=273-111&t=OUCBdoZhwrOMsxXE-4

## Testing
- `pnpm typecheck`
- `pnpm lint` (passes with existing repo warnings only)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9217-fix-set-queue-job-filter-tabs-to-32px-3126d73d36508106a9c8e3786ab77aa5)
by [Unito](https://www.unito.io)
2026-02-26 16:09:26 -08:00
pythongosssss
9fb93a5b0a App mode - more updates & fixes (#9137)
## Summary

- fix sizing of sidebars in app mode
- update feedback button to match design
- update job queue notification
- clickable queue spinner item to allow clear queue
- refactor mode out of store to specific workflow instance
- support different saved vs active mode
- other styling/layout tweaks

## Changes

- **What**: Changes the store to a composable and moves the mode state
to the workflow.
- This enables switching between tabs and maintaining the mode they were
in

## Screenshots (if applicable)
<img width="1866" height="1455" alt="image"
src="https://github.com/user-attachments/assets/f9a8cd36-181f-4948-b48c-dd27bd9127cf"
/>

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9137-App-mode-more-updates-fixes-3106d73d365081a18ccff6ffe24fdec7)
by [Unito](https://www.unito.io)

---------

Co-authored-by: github-actions <github-actions@github.com>
2026-02-26 09:55:10 -08:00
Benjamin Lu
ac12a3d9b9 fix: preserve refill date slashes in subscription credits label (#9251)
### Motivation
- Subscription credit labels were rendering the refill date with
HTML-escaped separators (`&#x2F;`) because `vue-i18n` parameter escaping
was applied to the date interpolation.
- The goal is to render date-only parameters like `MM/DD/YY` with
literal slashes so the UI shows a human-readable date string.

### Description
- Disabled `vue-i18n` parameter escaping for the
`subscription.creditsRemainingThisMonth` and
`subscription.creditsRemainingThisYear` lookups in both subscription
panels by passing `{ escapeParameter: false }` to `t()` in
`SubscriptionPanelContentLegacy.vue` and
`SubscriptionPanelContentWorkspace.vue`.
- Adjusted the unit test i18n setup in `SubscriptionPanel.test.ts` to
include `escapeParameter: true` in the test `i18n` instance and updated
the test messages to use `Included (Refills {date})`.
- Added a regression unit test in `SubscriptionPanel.test.ts` asserting
the rendered label contains `Included (Refills 12/31/24)` and does not
contain the escaped entity `&#x2F;`.

### Testing
- Ran formatting with `pnpm format` which completed successfully.
- Ran lint via `pnpm lint` which passed with pre-existing warnings only
(no new errors).
- Ran type checking with `pnpm typecheck` (via `vue-tsc --noEmit`) which
completed successfully.
- Ran the modified unit tests with `pnpm vitest run
src/platform/cloud/subscription/components/SubscriptionPanel.test.ts`
and the test file passed (10 passed, 5 skipped).
- Attempted a Playwright-based visual capture of the running app but
Chromium crashed in this environment (SIGSEGV) before navigation, so no
screenshot was produced.

------
[Codex
Task](https://chatgpt.com/codex/tasks/task_e_69a0175f58788330b2256329a500e14b)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9251-fix-preserve-refill-date-slashes-in-subscription-credits-label-3136d73d36508182b770f5719a52d189)
by [Unito](https://www.unito.io)
2026-02-26 09:37:03 -08:00
Johnpaul Chiwetelu
45ca1beea2 fix: address small CodeRabbit issues (#9229)
## Summary

Address several small CodeRabbit-filed issues: clipboard simplification,
queue getter cleanup, pointer handling, and test parameterization.

## Changes

- **What**:
- Simplify `useCopyToClipboard` by using VueUse's built-in `legacy` mode
instead of a manual `document.execCommand` fallback
- Remove `queueIndex` getter alias from `TaskItemImpl`, replace all
usages with `job.priority`
- Add `pointercancel` event handling and try-catch around
`releasePointerCapture` in `useNodeResize` to prevent stuck resize state
- Parameterize repetitive `getNodeProvider` tests in
`modelToNodeStore.test.ts` using `it.each()`

- Fixes #9024
- Fixes #7955
- Fixes #7323
- Fixes #8703

## Review Focus

- `useCopyToClipboard`: VueUse's `legacy: true` enables the
`execCommand` fallback internally — verify browser compat is acceptable
- `useNodeResize`: cleanup logic extracted into shared function used by
both `pointerup` and `pointercancel`
2026-02-26 02:32:53 -08:00
Christian Byrne
aef299caf8 fix: add GLSLShader to canvas image preview node types (#9198)
## Summary

Add `GLSLShader` to `CANVAS_IMAGE_PREVIEW_NODE_TYPES` so GLSL shader
previews are promoted through subgraph nodes.

## Changes

- Add `'GLSLShader'` to the `CANVAS_IMAGE_PREVIEW_NODE_TYPES` set in
`src/composables/node/useNodeCanvasImagePreview.ts`

## Context

GLSLShader node previews were not showing on parent subgraph nodes
because `CANVAS_IMAGE_PREVIEW_NODE_TYPES` only included `PreviewImage`
and `SaveImage`. The `$$canvas-image-preview` pseudo-widget was never
created for GLSLShader nodes, so the promotion system had nothing to
promote. This degraded the UX of all 12 shipped GLSL blueprint subgraphs
— users couldn't see shader output previews without expanding the
subgraph.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9198-fix-add-GLSLShader-to-canvas-image-preview-node-types-3126d73d3650817dbe9beab4bdeaa414)
by [Unito](https://www.unito.io)
2026-02-26 01:15:24 -08:00
Johnpaul Chiwetelu
188fafa89a fix: address trivial CodeRabbit issues (#9196)
## Summary

Address several trivial CodeRabbit-filed issues: type guard extraction,
ESLint globals, curve editor optimizations, and type relocation.

## Changes

- **What**: Extract `isSingleImage()` type guard in WidgetImageCompare;
add `__DISTRIBUTION__`/`__IS_NIGHTLY__` to ESLint globals and remove
stale disable comments; remove unnecessary `toFixed(4)` from curve path
generation; optimize `histogramToPath` with array join; move
`CurvePoint` type to curve domain

- Fixes #9175
- Fixes #8281
- Fixes #9116
- Fixes #9145
- Fixes #9147

## Review Focus

All changes are mechanical/trivial. Curve path output changes from
fixed-precision to raw floats — SVG handles both fine.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9196-fix-address-trivial-CodeRabbit-issues-3126d73d365081f19a5ce20305403098)
by [Unito](https://www.unito.io)
2026-02-26 00:43:14 -08:00
Christian Byrne
3984408d05 docs: add comment explaining widget value store dom widgets getter nuance (#9202)
Adds comment explaining nuance with the differing registration semantics
between DOM widget vs base widet.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9202-fix-widget-value-store-dom-widgets-getter-3126d73d365081368b94f048efb101fa)
by [Unito](https://www.unito.io)
2026-02-25 23:44:33 -08:00
Christian Byrne
6034be9a6f fix: add GLSLShader to toolkit node telemetry tracking (#9197)
## Summary

Add `GLSLShader` to `TOOLKIT_NODE_NAMES` so Mixpanel telemetry tracks
GLSL shader node usage alongside other toolkit nodes.

## Changes

- Add `'GLSLShader'` to the `TOOLKIT_NODE_NAMES` set in
`src/constants/toolkitNodes.ts`

## Context

The Toolkit Nodes PRD defines success metrics that require tracking "%
of workflows using one of these nodes" and "how often each node is
used." GLSLShader was missing from the tracking list, so no
GLSL-specific telemetry was being collected despite 12 GLSL blueprints
shipping in prod (BlueprintsVersion 0.9.1).

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9197-fix-add-GLSLShader-to-toolkit-node-telemetry-tracking-3126d73d3650814dad05fa78382d5064)
by [Unito](https://www.unito.io)
2026-02-25 22:19:50 -08:00
Christian Byrne
6a08e4ddde Revert "fix: sync DOM widget values to widgetValueStore on registration" (#9205)
Reverts Comfy-Org/ComfyUI_frontend#9166

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9205-Revert-fix-sync-DOM-widget-values-to-widgetValueStore-on-registration-3126d73d365081df8944d3c6508d2372)
by [Unito](https://www.unito.io)
2026-02-25 21:36:52 -08:00
Hunter
9ff985a792 fix: sync DOM widget default values to widgetValueStore on registration (#9164)
## Description

DOM widgets (textarea/customtext) override the `value` getter via
`Object.defineProperty` to use `getValue()/setValue()` with a fallback
to `inputEl.value`. But `BaseWidget.setNodeId()` registered
`_state.value` (undefined from constructor) instead of `this.value` (the
actual getter).

This caused Vue nodes (Nodes 2.0) to read `undefined` from the store and
display empty textareas, while execution correctly fell back to
`inputEl.value`.

**Fix:** Use `this.value` in `setNodeId()` so the store is initialized
with the actual widget value.

**Impact:** Fixes Nano Banana / Nano Banana Pro `system_prompt` showing
empty in Nodes 2.0 while still sending the correct value during
execution.

## Thread

https://ampcode.com/threads/T-019c8e99-49ce-77f5-bf2a-a32320fac477

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9164-fix-sync-DOM-widget-default-values-to-widgetValueStore-on-registration-3116d73d36508169a2fbd8308d9eec91)
by [Unito](https://www.unito.io)
2026-02-25 21:35:59 -08:00
Terry Jia
5cfd1aa77e feat: add Painter Node (#8521)
## Summary
Add PainterNode widget for freehand mask drawing directly on the canvas,
with brush/eraser tools, opacity, hardness, and background color
controls.

need BE changes https://github.com/Comfy-Org/ComfyUI/pull/12294

## Screenshots (if applicable)


https://github.com/user-attachments/assets/7222063a-0e40-40bb-b72e-b42c8984beb9



┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8521-feat-add-Painter-Node-2fa6d73d36508124ab2ede449a0cc67a)
by [Unito](https://www.unito.io)
2026-02-25 21:08:49 -08:00
Christian Byrne
2cb4c5eff3 fix: textarea stays disabled after link disconnect on promoted widgets (#9199)
## Summary

Fix textarea widgets staying disabled after disconnecting a link on
promoted widgets in subgraphs.

## Changes

- **What**: `refreshNodeSlots` used `SafeWidgetData.name` for slot
metadata lookups, but for promoted widgets this is `sourceWidgetName`
(the interior widget name), which doesn't match the subgraph node's
input slot widget name. Added `slotName` field to `SafeWidgetData` to
track the original LiteGraph widget name, and updated `refreshNodeSlots`
to use `slotName ?? name` for correct matching.

## Review Focus

The key change is the `slotName` field on `SafeWidgetData` — it's only
populated when `name !== widget.name` (i.e., for promoted widgets). The
`refreshNodeSlots` function now uses `widget.slotName ?? widget.name` to
look up slot metadata, ensuring promoted widgets correctly update their
`linked` state on disconnect.

Fixes #8818

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9199-fix-textarea-stays-disabled-after-link-disconnect-on-promoted-widgets-3126d73d3650813db499c227e6587aca)
by [Unito](https://www.unito.io)
2026-02-25 20:50:11 -08:00
Benjamin Lu
b8cca4167b fix: show inline progress in QPOV2 despite stale overlay flag (#9214)
## Summary

Fix inline queue progress being hidden in QPOV2 mode when a stale
`Comfy.Queue.History.Expanded` setting remains true from legacy queue
overlay usage.

## Changes

- Update actionbar inline progress hide condition to respect
queue-overlay expansion only when QPOV2 is disabled
- Update top menu inline progress summary hide condition with the same
gate
- Keep legacy behavior unchanged for non-QPOV2 queue overlay mode

## Testing

- `pnpm exec eslint src/components/actionbar/ComfyActionbar.vue
src/components/TopMenuSection.vue` 
- `pnpm typecheck` 

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9214-fix-show-inline-progress-in-QPOV2-despite-stale-overlay-flag-3126d73d36508170ac27fbb26826dca9)
by [Unito](https://www.unito.io)
2026-02-25 20:42:17 -08:00
Benjamin Lu
d99d807c45 fix: open job history from top menu active jobs button (#9210)
## Summary

Make the top menu `N active` queue button open the Job History sidebar
tab when QPO V2 is enabled, so behavior matches the button label and
accessibility text.

## Changes

- Update `TopMenuSection.vue` so QPO V2 mode toggles `job-history`
instead of `assets`
- Update `aria-pressed` logic to track `job-history`
- Update `TopMenuSection` unit tests to assert `job-history`

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9210-fix-open-job-history-from-top-menu-active-jobs-button-3126d73d365081758987fa3806b4b0e7)
by [Unito](https://www.unito.io)
2026-02-25 20:40:11 -08:00
jaeone94
80fe51bb8c feat: show missing node packs in Errors Tab with install support (#9213)
## Summary

Surfaces missing node pack information in the Errors Tab, grouped by
registry pack, with one-click install support via ComfyUI Manager.

## Changes

- **What**: Errors Tab now groups missing nodes by their registry pack
and shows a `MissingPackGroupRow` with pack name, node/pack counts, and
an Install button that triggers Manager installation. A
`MissingNodeCard` shows individual unresolvable nodes that have no
associated pack. `useErrorGroups` was extended to resolve missing node
types to their registry packs using the `/api/workflow/missing_nodes`
endpoint. `executionErrorStore` was refactored to track missing node
types separately from execution errors and expose them reactively.
- **Breaking**: None

## Review Focus

- `useErrorGroups.ts` — the new `resolveMissingNodePacks` logic fetches
pack metadata and maps node types to pack IDs; edge cases around partial
resolution (some nodes have a pack, some don't) produce both
`MissingPackGroupRow` and `MissingNodeCard` entries
- `executionErrorStore.ts` — the store now separates `missingNodeTypes`
state from `errors`; the deferred-warnings path in `app.ts` now calls
`setMissingNodeTypes` so the Errors Tab is populated even when a
workflow loads without executing

## Screenshots (if applicable)


https://github.com/user-attachments/assets/97f8d009-0cac-4739-8740-fd3333b5a85b


┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9213-feat-show-missing-node-packs-in-Errors-Tab-with-install-support-3126d73d36508197bc4bf8ebfd2125c8)
by [Unito](https://www.unito.io)
2026-02-25 20:25:47 -08:00
Dante
c24c4ab607 feat: show loading spinner and uploading filename during image upload (#9189)
## Summary
- Show a canvas-based loading spinner on image upload nodes (LoadImage)
during file upload via drag-drop, paste, or file picker
- Display the uploading file's name immediately in the filename dropdown
instead of showing the previous file's name
- Show the uploading audio file's name immediately in the audio widget
during upload

## Changes
- **`useNodeImageUpload.ts`**: Add `isUploading` flag and
`onUploadStart` callback to the upload lifecycle; clear `node.imgs`
during upload to prevent stale previews
- **`useImagePreviewWidget.ts`**: Add `renderUploadSpinner` that draws
an animated arc spinner on the canvas when `node.isUploading` is true;
guard against empty `imgs` array
- **`useImageUploadWidget.ts`**: Set `fileComboWidget.value` to the new
filename on upload start; clear `node.imgs` on combo widget change
- **`uploadAudio.ts`**: Set `audioWidget.value` to the new filename on
upload start
- **`litegraph-augmentation.d.ts`**: Add `isUploading` property to
`LGraphNode`



https://github.com/user-attachments/assets/818ce529-cb83-428a-8c98-dd900a128343



## Test plan
- [x] Upload an image via file picker on LoadImage node — spinner shows
during upload, filename updates immediately
- [x] Drag-and-drop an image onto LoadImage node — same behavior
- [x] Paste an image onto LoadImage node — same behavior
- [x] Change the dropdown selection on LoadImage — old preview clears,
new image loads
- [x] Upload an audio file — filename updates immediately in the widget

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

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9189-feat-show-loading-spinner-and-uploading-filename-during-image-upload-3126d73d365081e4af27cd7252f34298)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 20:22:42 -08:00
Christian Byrne
8e215b3174 feat: add performance testing infrastructure with CDP metrics (#9170)
## Summary

Add a permanent, non-failing performance regression detection system
using Chrome DevTools Protocol metrics, with automatic PR commenting.

## Changes

- **What**: Performance testing infrastructure — `PerformanceHelper`
fixture class using CDP `Performance.getMetrics` to collect
`RecalcStyleCount`, `LayoutCount`, `LayoutDuration`, `TaskDuration`,
`JSHeapUsedSize`. Adds `@perf` Playwright project (Chromium-only,
single-threaded, 60s timeout), 4 baseline perf tests, CI workflow with
sticky PR comment reporting, and `perf-report.js` script for generating
markdown comparison tables.

## Review Focus

- `PerformanceHelper` uses `page.context().newCDPSession(page)` — CDP is
Chromium-only, so perf metrics are not collected on Firefox. This is
intentional since CDP gives us browser-level style recalc/layout counts
that `performance.mark/measure` cannot capture.
- The CI workflow uses `continue-on-error: true` so perf tests never
block merging.
- Baseline comparison uses `dawidd6/action-download-artifact` to
download metrics from the target branch, following the same pattern as
`pr-size-report.yaml`.

## Stack

This is the foundation PR for the Firefox performance fix stack:
1. **→ This PR: perf testing infrastructure**
2. `perf/fix-cursor-cache` — cursor style caching (depends on this)
3. `perf/fix-subgraph-svg` — SVG pre-rasterization (depends on this)
4. `perf/fix-clippath-raf` — RAF batching for clip-path (depends on
this)

PRs 2-4 are independent of each other.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9170-feat-add-performance-testing-infrastructure-with-CDP-metrics-3116d73d3650817cb43def6f8e9917f8)
by [Unito](https://www.unito.io)

---------

Co-authored-by: GitHub Action <action@github.com>
Co-authored-by: Alexander Brown <drjkl@comfy.org>
2026-02-25 20:09:57 -08:00
Benjamin Lu
c957841862 fix: open previewable assets from list preview click/double-click (#9077)
## Summary
- emit `preview-click` from `AssetsListItem` when clicking the preview
tile
- wire assets sidebar rows and queue job-history rows so preview-tile
click and row double-click open the viewer/gallery
- gate job-history preview opening by `taskRef.previewOutput` (not
`iconImageUrl`) and use preview output URL/type so video previews are
supported
- add/extend tests for preview click and double-click behavior in assets
list and job history

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9077-fix-open-previewable-assets-from-list-preview-click-double-click-30f6d73d3650810a873cfa2dc085bf97)
by [Unito](https://www.unito.io)
2026-02-25 18:03:07 -08:00
Comfy Org PR Bot
d23c8026d0 1.41.6 (#9222)
Patch version increment to 1.41.6

**Base branch:** `main`

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9222-1-41-6-3136d73d36508199bccbe6e08335bb19)
by [Unito](https://www.unito.io)

---------

Co-authored-by: christian-byrne <72887196+christian-byrne@users.noreply.github.com>
Co-authored-by: github-actions <github-actions@github.com>
2026-02-25 17:44:51 -08:00
AustinMroz
a309281ac5 Prevent serialization of progress text to prompt (#9221)
#8625 fixed a bug where `ProgressTextWidget`s would be serialized to
workflow data and, under rare circumstances, clobber over other widget
values on restore.

I was mistaken that the `serialize: false` being sent to options does
serve a purpose: preventing the widget value from being serialized to
the (api) prompt which is sent to the backend. This PR reverts the
removal so now both forms of disabling serialization apply.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9221-Prevent-serialization-of-progress-text-to-prompt-3126d73d365081c5b9ecc560f0a248d5)
by [Unito](https://www.unito.io)
2026-02-25 17:25:12 -08:00
Dante
e9bf113686 feat(settings): improve search to include nav items and show all results (#9195)
## Summary
- Settings search now matches sidebar navigation items (Keybinding,
About, Extension, etc.) and navigates to the corresponding panel
- Search results show all matching settings across all categories
instead of filtering to only the first matching category
- Search result group headers display parent category prefix (e.g.
"LiteGraph › Node") for clarity

## Test plan
- [x] Search "Keybinding" → sidebar highlights and navigates to
Keybinding panel
- [x] Search "badge" → shows all 4 badge settings (3 LiteGraph + 1
Comfy)
- [x] Search "canvas" → shows results from all categories
- [x] Clear search → returns to default category
- [x] Unit tests pass (`pnpm test:unit`)
<img width="1425" height="682" alt="스크린샷 2026-02-25 오후 3 01 05"
src="https://github.com/user-attachments/assets/956c4635-b140-4dff-8145-db312d295160"
/>



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

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9195-feat-settings-improve-search-to-include-nav-items-and-show-all-results-3126d73d3650814dbf3ce1d59ad962cf)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: GitHub Action <action@github.com>
2026-02-25 17:14:37 -08:00
AustinMroz
1ab48b42a7 Add App I/O selection system (#8965)
Adds a system for selecting the inputs and outputs which should be
displayed when inside linear mode. Functions only in litegraph
currently. Vue support will require a separate, larger PR.
Inputs and outputs can be re-ordered by dragging and dropping on the
side panel.

![builder_00001](https://github.com/user-attachments/assets/6345adbd-519e-455d-b71e-0020aa03c6b7)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8965-Add-App-I-O-selection-system-30b6d73d365081569b36c1682a1fdbc5)
by [Unito](https://www.unito.io)
2026-02-25 08:53:00 -08:00
175 changed files with 9448 additions and 1200 deletions

110
.github/workflows/ci-perf-report.yaml vendored Normal file
View File

@@ -0,0 +1,110 @@
name: 'CI: Performance Report'
on:
push:
branches: [main, core/*]
paths-ignore: ['**/*.md']
pull_request:
branches-ignore: [wip/*, draft/*, temp/*]
paths-ignore: ['**/*.md']
concurrency:
group: perf-${{ github.ref }}
cancel-in-progress: true
permissions:
contents: read
pull-requests: write
jobs:
perf-tests:
if: github.repository == 'Comfy-Org/ComfyUI_frontend'
runs-on: ubuntu-latest
timeout-minutes: 30
container:
image: ghcr.io/comfy-org/comfyui-ci-container:0.0.12
credentials:
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
permissions:
contents: read
packages: read
steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: Setup frontend
uses: ./.github/actions/setup-frontend
with:
include_build_step: true
- name: Start ComfyUI server
uses: ./.github/actions/start-comfyui-server
- name: Run performance tests
id: perf
continue-on-error: true
run: pnpm exec playwright test --project=performance --workers=1
- name: Upload perf metrics
if: always()
uses: actions/upload-artifact@v6
with:
name: perf-metrics
path: test-results/perf-metrics.json
retention-days: 30
if-no-files-found: warn
report:
needs: perf-tests
if: github.event_name == 'pull_request'
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: write
steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: Setup Node
uses: actions/setup-node@v6
with:
node-version: 22
- name: Download PR perf metrics
continue-on-error: true
uses: actions/download-artifact@v7
with:
name: perf-metrics
path: test-results/
- name: Download baseline perf metrics
uses: dawidd6/action-download-artifact@0bd50d53a6d7fb5cb921e607957e9cc12b4ce392 # v12
with:
branch: ${{ github.event.pull_request.base.ref }}
workflow: ci-perf-report.yaml
event: push
name: perf-metrics
path: temp/perf-baseline/
if_no_artifact_found: warn
- name: Generate perf report
run: npx --yes tsx scripts/perf-report.ts > perf-report.md
- name: Read perf report
id: perf-report
uses: juliangruber/read-file-action@b549046febe0fe86f8cb4f93c24e284433f9ab58 # v1.1.7
with:
path: ./perf-report.md
- name: Create or update PR comment
uses: actions-cool/maintain-one-comment@4b2dbf086015f892dcb5e8c1106f5fccd6c1476b # v3.2.0
with:
token: ${{ secrets.GITHUB_TOKEN }}
number: ${{ github.event.pull_request.number }}
body: |
${{ steps.perf-report.outputs.content }}
<!-- COMFYUI_FRONTEND_PERF -->
body-include: '<!-- COMFYUI_FRONTEND_PERF -->'

View File

@@ -24,6 +24,7 @@ import {
} from './components/SidebarTab'
import { Topbar } from './components/Topbar'
import { CanvasHelper } from './helpers/CanvasHelper'
import { PerformanceHelper } from './helpers/PerformanceHelper'
import { ClipboardHelper } from './helpers/ClipboardHelper'
import { CommandHelper } from './helpers/CommandHelper'
import { DragDropHelper } from './helpers/DragDropHelper'
@@ -185,6 +186,7 @@ export class ComfyPage {
public readonly dragDrop: DragDropHelper
public readonly command: CommandHelper
public readonly bottomPanel: BottomPanel
public readonly perf: PerformanceHelper
/** Worker index to test user ID */
public readonly userIds: string[] = []
@@ -229,6 +231,7 @@ export class ComfyPage {
this.dragDrop = new DragDropHelper(page, this.assetPath.bind(this))
this.command = new CommandHelper(page)
this.bottomPanel = new BottomPanel(page)
this.perf = new PerformanceHelper(page)
}
get visibleToasts() {
@@ -436,7 +439,13 @@ export const comfyPageFixture = base.extend<{
}
await comfyPage.setup()
const isPerf = testInfo.tags.includes('@perf')
if (isPerf) await comfyPage.perf.init()
await use(comfyPage)
if (isPerf) await comfyPage.perf.dispose()
},
comfyMouse: async ({ comfyPage }, use) => {
const comfyMouse = new ComfyMouse(comfyPage)

View File

@@ -0,0 +1,96 @@
import type { CDPSession, Page } from '@playwright/test'
interface PerfSnapshot {
RecalcStyleCount: number
RecalcStyleDuration: number
LayoutCount: number
LayoutDuration: number
TaskDuration: number
JSHeapUsedSize: number
Timestamp: number
}
export interface PerfMeasurement {
name: string
durationMs: number
styleRecalcs: number
styleRecalcDurationMs: number
layouts: number
layoutDurationMs: number
taskDurationMs: number
heapDeltaBytes: number
}
export class PerformanceHelper {
private cdp: CDPSession | null = null
private snapshot: PerfSnapshot | null = null
constructor(private readonly page: Page) {}
async init(): Promise<void> {
this.cdp = await this.page.context().newCDPSession(this.page)
await this.cdp.send('Performance.enable')
}
async dispose(): Promise<void> {
this.snapshot = null
if (this.cdp) {
try {
await this.cdp.send('Performance.disable')
} finally {
await this.cdp.detach()
this.cdp = null
}
}
}
private async getSnapshot(): Promise<PerfSnapshot> {
if (!this.cdp) throw new Error('PerformanceHelper not initialized')
const { metrics } = (await this.cdp.send('Performance.getMetrics')) as {
metrics: { name: string; value: number }[]
}
function get(name: string): number {
return metrics.find((m) => m.name === name)?.value ?? 0
}
return {
RecalcStyleCount: get('RecalcStyleCount'),
RecalcStyleDuration: get('RecalcStyleDuration'),
LayoutCount: get('LayoutCount'),
LayoutDuration: get('LayoutDuration'),
TaskDuration: get('TaskDuration'),
JSHeapUsedSize: get('JSHeapUsedSize'),
Timestamp: get('Timestamp')
}
}
async startMeasuring(): Promise<void> {
if (this.snapshot) {
throw new Error(
'Measurement already in progress — call stopMeasuring() first'
)
}
this.snapshot = await this.getSnapshot()
}
async stopMeasuring(name: string): Promise<PerfMeasurement> {
if (!this.snapshot) throw new Error('Call startMeasuring() first')
const after = await this.getSnapshot()
const before = this.snapshot
this.snapshot = null
function delta(key: keyof PerfSnapshot): number {
return after[key] - before[key]
}
return {
name,
durationMs: delta('Timestamp') * 1000,
styleRecalcs: delta('RecalcStyleCount'),
styleRecalcDurationMs: delta('RecalcStyleDuration') * 1000,
layouts: delta('LayoutCount'),
layoutDurationMs: delta('LayoutDuration') * 1000,
taskDurationMs: delta('TaskDuration') * 1000,
heapDeltaBytes: delta('JSHeapUsedSize')
}
}
}

View File

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

View File

@@ -0,0 +1,49 @@
import { mkdirSync, readdirSync, readFileSync, writeFileSync } from 'fs'
import { join } from 'path'
import type { PerfMeasurement } from '../fixtures/helpers/PerformanceHelper'
export interface PerfReport {
timestamp: string
gitSha: string
branch: string
measurements: PerfMeasurement[]
}
const TEMP_DIR = join('test-results', 'perf-temp')
export function recordMeasurement(m: PerfMeasurement) {
mkdirSync(TEMP_DIR, { recursive: true })
const filename = `${m.name}-${Date.now()}.json`
writeFileSync(join(TEMP_DIR, filename), JSON.stringify(m))
}
export function writePerfReport(
gitSha = process.env.GITHUB_SHA ?? 'local',
branch = process.env.GITHUB_HEAD_REF ?? 'local'
) {
if (!readdirSync('test-results', { withFileTypes: true }).length) return
let tempFiles: string[]
try {
tempFiles = readdirSync(TEMP_DIR).filter((f) => f.endsWith('.json'))
} catch {
return
}
if (tempFiles.length === 0) return
const measurements: PerfMeasurement[] = tempFiles.map((f) =>
JSON.parse(readFileSync(join(TEMP_DIR, f), 'utf-8'))
)
const report: PerfReport = {
timestamp: new Date().toISOString(),
gitSha,
branch,
measurements
}
writeFileSync(
join('test-results', 'perf-metrics.json'),
JSON.stringify(report, null, 2)
)
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 99 KiB

After

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 43 KiB

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 93 KiB

After

Width:  |  Height:  |  Size: 98 KiB

View File

@@ -0,0 +1,70 @@
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
import { recordMeasurement } from '../helpers/perfReporter'
test.describe('Performance', { tag: ['@perf'] }, () => {
test('canvas idle style recalculations', async ({ comfyPage }) => {
await comfyPage.workflow.loadWorkflow('default')
await comfyPage.perf.startMeasuring()
// Let the canvas idle for 2 seconds — no user interaction.
// Measures baseline style recalcs from reactive state + render loop.
for (let i = 0; i < 120; i++) {
await comfyPage.nextFrame()
}
const m = await comfyPage.perf.stopMeasuring('canvas-idle')
recordMeasurement(m)
console.log(
`Canvas idle: ${m.styleRecalcs} style recalcs, ${m.layouts} layouts`
)
})
test('canvas mouse interaction style recalculations', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('default')
await comfyPage.perf.startMeasuring()
const canvas = comfyPage.canvas
const box = await canvas.boundingBox()
if (!box) throw new Error('Canvas bounding box not available')
// Sweep mouse across the canvas — crosses nodes, empty space, slots
for (let i = 0; i < 100; i++) {
await comfyPage.page.mouse.move(
box.x + (box.width * i) / 100,
box.y + (box.height * (i % 3)) / 3
)
}
const m = await comfyPage.perf.stopMeasuring('canvas-mouse-sweep')
recordMeasurement(m)
console.log(
`Mouse sweep: ${m.styleRecalcs} style recalcs, ${m.layouts} layouts`
)
})
test('DOM widget clipping during node selection', async ({ comfyPage }) => {
// Load default workflow which has DOM widgets (text inputs, combos)
await comfyPage.workflow.loadWorkflow('default')
await comfyPage.perf.startMeasuring()
// Select and deselect nodes rapidly to trigger clipping recalculation
const canvas = comfyPage.canvas
const box = await canvas.boundingBox()
if (!box) throw new Error('Canvas bounding box not available')
for (let i = 0; i < 20; i++) {
// Click on canvas area (nodes occupy various positions)
await comfyPage.page.mouse.click(
box.x + box.width / 3 + (i % 5) * 30,
box.y + box.height / 3 + (i % 4) * 30
)
await comfyPage.nextFrame()
}
const m = await comfyPage.perf.stopMeasuring('dom-widget-clipping')
recordMeasurement(m)
console.log(`Clipping: ${m.layouts} forced layouts`)
})
})

Binary file not shown.

Before

Width:  |  Height:  |  Size: 90 KiB

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 94 KiB

After

Width:  |  Height:  |  Size: 93 KiB

View File

@@ -22,7 +22,9 @@ const extraFileExtensions = ['.vue']
const commonGlobals = {
...globals.browser,
__COMFYUI_FRONTEND_VERSION__: 'readonly'
__COMFYUI_FRONTEND_VERSION__: 'readonly',
__DISTRIBUTION__: 'readonly',
__IS_NIGHTLY__: 'readonly'
} as const
const settings = {

View File

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

View File

@@ -36,7 +36,18 @@ export default defineConfig({
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
timeout: 15000,
grepInvert: /@mobile/ // Run all tests except those tagged with @mobile
grepInvert: /@mobile|@perf/ // Run all tests except those tagged with @mobile or @perf
},
{
name: 'performance',
use: {
...devices['Desktop Chrome'],
trace: 'retain-on-failure'
},
timeout: 60_000,
grep: /@perf/,
fullyParallel: false
},
{

125
scripts/perf-report.ts Normal file
View File

@@ -0,0 +1,125 @@
import { existsSync, readFileSync } from 'node:fs'
interface PerfMeasurement {
name: string
durationMs: number
styleRecalcs: number
styleRecalcDurationMs: number
layouts: number
layoutDurationMs: number
taskDurationMs: number
heapDeltaBytes: number
}
interface PerfReport {
timestamp: string
gitSha: string
branch: string
measurements: PerfMeasurement[]
}
const CURRENT_PATH = 'test-results/perf-metrics.json'
const BASELINE_PATH = 'temp/perf-baseline/perf-metrics.json'
function formatDelta(pct: number): string {
if (pct >= 20) return `+${pct.toFixed(0)}% 🔴`
if (pct >= 10) return `+${pct.toFixed(0)}% 🟠`
if (pct > -10) return `${pct >= 0 ? '+' : ''}${pct.toFixed(0)}% ⚪`
return `${pct.toFixed(0)}% 🟢`
}
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`
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
}
function calcDelta(
baseline: number,
current: number
): { pct: number; isNew: boolean } {
if (baseline > 0) {
return { pct: ((current - baseline) / baseline) * 100, isNew: false }
}
return current > 0 ? { pct: Infinity, isNew: true } : { pct: 0, isNew: false }
}
function formatDeltaCell(delta: { pct: number; isNew: boolean }): string {
return delta.isNew ? 'new 🔴' : formatDelta(delta.pct)
}
function main() {
if (!existsSync(CURRENT_PATH)) {
process.stdout.write(
'## ⚡ Performance Report\n\nNo perf metrics found. Perf tests may not have run.\n'
)
process.exit(0)
}
const current: PerfReport = JSON.parse(readFileSync(CURRENT_PATH, 'utf-8'))
const baseline: PerfReport | null = existsSync(BASELINE_PATH)
? JSON.parse(readFileSync(BASELINE_PATH, 'utf-8'))
: null
const lines: string[] = []
lines.push('## ⚡ Performance Report\n')
if (baseline) {
lines.push(
'| Metric | Baseline | PR | Δ |',
'|--------|----------|-----|---|'
)
for (const m of current.measurements) {
const base = baseline.measurements.find((b) => b.name === m.name)
if (!base) {
lines.push(`| ${m.name}: style recalcs | — | ${m.styleRecalcs} | new |`)
lines.push(`| ${m.name}: layouts | — | ${m.layouts} | new |`)
lines.push(
`| ${m.name}: task duration | — | ${m.taskDurationMs.toFixed(0)}ms | new |`
)
continue
}
const recalcDelta = calcDelta(base.styleRecalcs, m.styleRecalcs)
lines.push(
`| ${m.name}: style recalcs | ${base.styleRecalcs} | ${m.styleRecalcs} | ${formatDeltaCell(recalcDelta)} |`
)
const layoutDelta = calcDelta(base.layouts, m.layouts)
lines.push(
`| ${m.name}: layouts | ${base.layouts} | ${m.layouts} | ${formatDeltaCell(layoutDelta)} |`
)
const taskDelta = calcDelta(base.taskDurationMs, m.taskDurationMs)
lines.push(
`| ${m.name}: task duration | ${base.taskDurationMs.toFixed(0)}ms | ${m.taskDurationMs.toFixed(0)}ms | ${formatDeltaCell(taskDelta)} |`
)
}
} else {
lines.push(
'No baseline found — showing absolute values.\n',
'| Metric | Value |',
'|--------|-------|'
)
for (const m of current.measurements) {
lines.push(`| ${m.name}: style recalcs | ${m.styleRecalcs} |`)
lines.push(`| ${m.name}: layouts | ${m.layouts} |`)
lines.push(
`| ${m.name}: task duration | ${m.taskDurationMs.toFixed(0)}ms |`
)
lines.push(`| ${m.name}: heap delta | ${formatBytes(m.heapDeltaBytes)} |`)
}
}
lines.push('\n<details><summary>Raw data</summary>\n')
lines.push('```json')
lines.push(JSON.stringify(current, null, 2))
lines.push('```')
lines.push('\n</details>')
process.stdout.write(lines.join('\n') + '\n')
}
main()

View File

@@ -51,7 +51,6 @@ onMounted(() => {
// See: https://vite.dev/guide/build#load-error-handling
window.addEventListener('vite:preloadError', (event) => {
event.preventDefault()
// eslint-disable-next-line no-undef
if (__DISTRIBUTION__ === 'cloud') {
captureException(event.payload, {
tags: { error_type: 'vite_preload_error' }

View File

@@ -25,7 +25,7 @@
<!-- First panel: sidebar when left, properties when right -->
<SplitterPanel
v-if="
!focusMode && (sidebarLocation === 'left' || rightSidePanelVisible)
!focusMode && (sidebarLocation === 'left' || showOffsideSplitter)
"
:class="
sidebarLocation === 'left'
@@ -85,7 +85,7 @@
<!-- Last panel: properties when left, sidebar when right -->
<SplitterPanel
v-if="
!focusMode && (sidebarLocation === 'right' || rightSidePanelVisible)
!focusMode && (sidebarLocation === 'right' || showOffsideSplitter)
"
:class="
sidebarLocation === 'right'
@@ -124,6 +124,7 @@ import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useAppMode } from '@/composables/useAppMode'
import { useBottomPanelStore } from '@/stores/workspace/bottomPanelStore'
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
import { useSidebarTabStore } from '@/stores/workspace/sidebarTabStore'
@@ -144,9 +145,13 @@ const unifiedWidth = computed(() =>
const { focusMode } = storeToRefs(workspaceStore)
const { mode } = useAppMode()
const { activeSidebarTabId, activeSidebarTab } = storeToRefs(sidebarTabStore)
const { bottomPanelVisible } = storeToRefs(useBottomPanelStore())
const { isOpen: rightSidePanelVisible } = storeToRefs(rightSidePanelStore)
const showOffsideSplitter = computed(
() => rightSidePanelVisible.value || mode.value === 'builder:select'
)
const sidebarPanelVisible = computed(() => activeSidebarTab.value !== null)

View File

@@ -262,7 +262,7 @@ describe('TopMenuSection', () => {
)
})
it('opens the assets sidebar tab when QPO V2 is enabled', async () => {
it('opens the job history sidebar tab when QPO V2 is enabled', async () => {
const pinia = createTestingPinia({ createSpy: vi.fn, stubActions: false })
const settingStore = useSettingStore(pinia)
vi.mocked(settingStore.get).mockImplementation((key) =>
@@ -273,10 +273,10 @@ describe('TopMenuSection', () => {
await wrapper.find('[data-testid="queue-overlay-toggle"]').trigger('click')
expect(sidebarTabStore.activeSidebarTabId).toBe('assets')
expect(sidebarTabStore.activeSidebarTabId).toBe('job-history')
})
it('toggles the assets sidebar tab when QPO V2 is enabled', async () => {
it('toggles the job history sidebar tab when QPO V2 is enabled', async () => {
const pinia = createTestingPinia({ createSpy: vi.fn, stubActions: false })
const settingStore = useSettingStore(pinia)
vi.mocked(settingStore.get).mockImplementation((key) =>
@@ -287,7 +287,7 @@ describe('TopMenuSection', () => {
const toggleButton = wrapper.find('[data-testid="queue-overlay-toggle"]')
await toggleButton.trigger('click')
expect(sidebarTabStore.activeSidebarTabId).toBe('assets')
expect(sidebarTabStore.activeSidebarTabId).toBe('job-history')
await toggleButton.trigger('click')
expect(sidebarTabStore.activeSidebarTabId).toBe(null)

View File

@@ -56,43 +56,6 @@
:queue-overlay-expanded="isQueueOverlayExpanded"
@update:progress-target="updateProgressTarget"
/>
<Button
v-tooltip.bottom="queueHistoryTooltipConfig"
type="destructive"
size="md"
:aria-pressed="
isQueuePanelV2Enabled
? activeSidebarTabId === 'assets'
: isQueueProgressOverlayEnabled
? isQueueOverlayExpanded
: undefined
"
class="relative px-3"
data-testid="queue-overlay-toggle"
@click="toggleQueueOverlay"
@contextmenu.stop.prevent="showQueueContextMenu"
>
<span class="text-sm font-normal tabular-nums">
{{ activeJobsLabel }}
</span>
<StatusBadge
v-if="activeJobsCount > 0"
data-testid="active-jobs-indicator"
variant="dot"
class="pointer-events-none absolute -top-0.5 -right-0.5 animate-pulse"
/>
<span class="sr-only">
{{
isQueuePanelV2Enabled
? t('sideToolbar.queueProgressOverlay.viewJobHistory')
: t('sideToolbar.queueProgressOverlay.expandCollapsedQueue')
}}
</span>
</Button>
<ContextMenu
ref="queueContextMenu"
:model="queueContextMenuItems"
/>
<CurrentUserButton
v-if="isLoggedIn && !isIntegratedTabBar"
class="shrink-0"
@@ -127,13 +90,15 @@
<div
class="pointer-events-none absolute left-0 right-0 top-full mt-1 flex justify-end pr-1"
>
<QueueInlineProgressSummary :hidden="isQueueOverlayExpanded" />
<QueueInlineProgressSummary
:hidden="shouldHideInlineProgressSummary"
/>
</div>
</Teleport>
<QueueInlineProgressSummary
v-else-if="shouldShowInlineProgressSummary && !isActionbarFloating"
class="pr-1"
:hidden="isQueueOverlayExpanded"
:hidden="shouldHideInlineProgressSummary"
/>
<QueueNotificationBannerHost
v-if="shouldShowQueueNotificationBanners"
@@ -146,14 +111,11 @@
<script setup lang="ts">
import { useLocalStorage } from '@vueuse/core'
import { storeToRefs } from 'pinia'
import ContextMenu from 'primevue/contextmenu'
import type { MenuItem } from 'primevue/menuitem'
import { computed, onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import ComfyActionbar from '@/components/actionbar/ComfyActionbar.vue'
import SubgraphBreadcrumb from '@/components/breadcrumb/SubgraphBreadcrumb.vue'
import StatusBadge from '@/components/common/StatusBadge.vue'
import QueueInlineProgressSummary from '@/components/queue/QueueInlineProgressSummary.vue'
import QueueNotificationBannerHost from '@/components/queue/QueueNotificationBannerHost.vue'
import QueueProgressOverlay from '@/components/queue/QueueProgressOverlay.vue'
@@ -167,12 +129,9 @@ import { useErrorHandling } from '@/composables/useErrorHandling'
import { buildTooltipConfig } from '@/composables/useTooltipConfig'
import { useSettingStore } from '@/platform/settings/settingStore'
import { app } from '@/scripts/app'
import { useCommandStore } from '@/stores/commandStore'
import { useExecutionStore } from '@/stores/executionStore'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { useQueueStore, useQueueUIStore } from '@/stores/queueStore'
import { useQueueUIStore } from '@/stores/queueStore'
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
import { useSidebarTabStore } from '@/stores/workspace/sidebarTabStore'
import { useWorkspaceStore } from '@/stores/workspaceStore'
import { isDesktop } from '@/platform/distribution/types'
import { useConflictAcknowledgment } from '@/workbench/extensions/manager/composables/useConflictAcknowledgment'
@@ -185,17 +144,11 @@ const workspaceStore = useWorkspaceStore()
const rightSidePanelStore = useRightSidePanelStore()
const managerState = useManagerState()
const { isLoggedIn } = useCurrentUser()
const { t, n } = useI18n()
const { t } = useI18n()
const { toastErrorHandler } = useErrorHandling()
const commandStore = useCommandStore()
const queueStore = useQueueStore()
const executionStore = useExecutionStore()
const executionErrorStore = useExecutionErrorStore()
const queueUIStore = useQueueUIStore()
const sidebarTabStore = useSidebarTabStore()
const { activeJobsCount } = storeToRefs(queueStore)
const { isOverlayExpanded: isQueueOverlayExpanded } = storeToRefs(queueUIStore)
const { activeSidebarTabId } = storeToRefs(sidebarTabStore)
const { shouldShowRedDot: shouldShowConflictRedDot } =
useConflictAcknowledgment()
const isTopMenuHovered = ref(false)
@@ -208,14 +161,6 @@ const isActionbarEnabled = computed(
const isActionbarFloating = computed(
() => isActionbarEnabled.value && !isActionbarDocked.value
)
const activeJobsLabel = computed(() => {
const count = activeJobsCount.value
return t(
'sideToolbar.queueProgressOverlay.activeJobsShort',
{ count: n(count) },
count
)
})
const isIntegratedTabBar = computed(
() => settingStore.get('Comfy.UI.TabBarLayout') === 'Integrated'
)
@@ -241,24 +186,12 @@ const inlineProgressSummaryTarget = computed(() => {
}
return progressTarget.value
})
const queueHistoryTooltipConfig = computed(() =>
buildTooltipConfig(t('sideToolbar.queueProgressOverlay.viewJobHistory'))
const shouldHideInlineProgressSummary = computed(
() => isQueueProgressOverlayEnabled.value && isQueueOverlayExpanded.value
)
const customNodesManagerTooltipConfig = computed(() =>
buildTooltipConfig(t('menu.manageExtensions'))
)
const queueContextMenu = ref<InstanceType<typeof ContextMenu> | null>(null)
const queueContextMenuItems = computed<MenuItem[]>(() => [
{
label: t('sideToolbar.queueProgressOverlay.clearQueueTooltip'),
icon: 'icon-[lucide--list-x] text-destructive-background',
class: '*:text-destructive-background',
disabled: queueStore.pendingTasks.length === 0,
command: () => {
void handleClearQueue()
}
}
])
const shouldShowRedDot = computed((): boolean => {
return shouldShowConflictRedDot.value
@@ -281,27 +214,6 @@ onMounted(() => {
}
})
const toggleQueueOverlay = () => {
if (isQueuePanelV2Enabled.value) {
sidebarTabStore.toggleSidebarTab('assets')
return
}
commandStore.execute('Comfy.Queue.ToggleOverlay')
}
const showQueueContextMenu = (event: MouseEvent) => {
queueContextMenu.value?.show(event)
}
const handleClearQueue = async () => {
const pendingJobIds = queueStore.pendingTasks
.map((task) => task.jobId)
.filter((id): id is string => typeof id === 'string' && id.length > 0)
await commandStore.execute('Comfy.ClearPendingTasks')
executionStore.clearInitializationByJobIds(pendingJobIds)
}
const openCustomNodeManager = async () => {
try {
await managerState.openManager({

View File

@@ -42,12 +42,44 @@
>
<i class="icon-[lucide--x] size-4" />
</Button>
<Button
v-tooltip.bottom="queueHistoryTooltipConfig"
variant="secondary"
size="md"
:aria-pressed="
isQueuePanelV2Enabled
? activeSidebarTabId === 'job-history'
: queueOverlayExpanded
"
class="relative px-3"
data-testid="queue-overlay-toggle"
@click="toggleQueueOverlay"
@contextmenu.stop.prevent="showQueueContextMenu"
>
<span class="text-sm font-normal tabular-nums">
{{ activeJobsLabel }}
</span>
<StatusBadge
v-if="activeJobsCount > 0"
data-testid="active-jobs-indicator"
variant="dot"
class="pointer-events-none absolute -top-0.5 -right-0.5 animate-pulse"
/>
<span class="sr-only">
{{
isQueuePanelV2Enabled
? t('sideToolbar.queueProgressOverlay.viewJobHistory')
: t('sideToolbar.queueProgressOverlay.expandCollapsedQueue')
}}
</span>
</Button>
<ContextMenu ref="queueContextMenu" :model="queueContextMenuItems" />
</div>
</Panel>
<Teleport v-if="inlineProgressTarget" :to="inlineProgressTarget">
<QueueInlineProgress
:hidden="queueOverlayExpanded"
:hidden="shouldHideInlineProgress"
:radius-class="cn(isDocked ? 'rounded-[7px]' : 'rounded-[5px]')"
data-testid="queue-inline-progress"
/>
@@ -65,11 +97,14 @@ import {
} from '@vueuse/core'
import { clamp } from 'es-toolkit/compat'
import { storeToRefs } from 'pinia'
import ContextMenu from 'primevue/contextmenu'
import type { MenuItem } from 'primevue/menuitem'
import Panel from 'primevue/panel'
import { computed, nextTick, ref, watch } from 'vue'
import type { ComponentPublicInstance } from 'vue'
import { useI18n } from 'vue-i18n'
import StatusBadge from '@/components/common/StatusBadge.vue'
import QueueInlineProgress from '@/components/queue/QueueInlineProgress.vue'
import Button from '@/components/ui/button/Button.vue'
import { buildTooltipConfig } from '@/composables/useTooltipConfig'
@@ -77,6 +112,8 @@ import { useSettingStore } from '@/platform/settings/settingStore'
import { useTelemetry } from '@/platform/telemetry'
import { useCommandStore } from '@/stores/commandStore'
import { useExecutionStore } from '@/stores/executionStore'
import { useQueueStore } from '@/stores/queueStore'
import { useSidebarTabStore } from '@/stores/workspace/sidebarTabStore'
import { cn } from '@/utils/tailwindUtil'
import ComfyRunButton from './ComfyRunButton'
@@ -92,8 +129,13 @@ const emit = defineEmits<{
const settingsStore = useSettingStore()
const commandStore = useCommandStore()
const { t } = useI18n()
const { isIdle: isExecutionIdle } = storeToRefs(useExecutionStore())
const executionStore = useExecutionStore()
const queueStore = useQueueStore()
const sidebarTabStore = useSidebarTabStore()
const { t, n } = useI18n()
const { isIdle: isExecutionIdle } = storeToRefs(executionStore)
const { activeJobsCount } = storeToRefs(queueStore)
const { activeSidebarTabId } = storeToRefs(sidebarTabStore)
const position = computed(() => settingsStore.get('Comfy.UseNewMenu'))
const visible = computed(() => position.value !== 'Disabled')
@@ -287,6 +329,9 @@ const inlineProgressTarget = computed(() => {
if (isDocked.value) return topMenuContainer ?? null
return panelElement.value
})
const shouldHideInlineProgress = computed(
() => !isQueuePanelV2Enabled.value && queueOverlayExpanded
)
watch(
panelElement,
(target) => {
@@ -315,11 +360,52 @@ watch(isDragging, (dragging) => {
const cancelJobTooltipConfig = computed(() =>
buildTooltipConfig(t('menu.interrupt'))
)
const queueHistoryTooltipConfig = computed(() =>
buildTooltipConfig(t('sideToolbar.queueProgressOverlay.viewJobHistory'))
)
const activeJobsLabel = computed(() => {
const count = activeJobsCount.value
return t(
'sideToolbar.queueProgressOverlay.activeJobsShort',
{ count: n(count) },
count
)
})
const queueContextMenu = ref<InstanceType<typeof ContextMenu> | null>(null)
const queueContextMenuItems = computed<MenuItem[]>(() => [
{
label: t('sideToolbar.queueProgressOverlay.clearQueueTooltip'),
icon: 'icon-[lucide--list-x] text-destructive-background',
class: '*:text-destructive-background',
disabled: queueStore.pendingTasks.length === 0,
command: () => {
void handleClearQueue()
}
}
])
const cancelCurrentJob = async () => {
if (isExecutionIdle.value) return
await commandStore.execute('Comfy.Interrupt')
}
const toggleQueueOverlay = () => {
if (isQueuePanelV2Enabled.value) {
sidebarTabStore.toggleSidebarTab('job-history')
return
}
commandStore.execute('Comfy.Queue.ToggleOverlay')
}
const showQueueContextMenu = (event: MouseEvent) => {
queueContextMenu.value?.show(event)
}
const handleClearQueue = async () => {
const pendingJobIds = queueStore.pendingTasks
.map((task) => task.jobId)
.filter((id): id is string => typeof id === 'string' && id.length > 0)
await commandStore.execute('Comfy.ClearPendingTasks')
executionStore.clearInitializationByJobIds(pendingJobIds)
}
const actionbarClass = computed(() =>
cn(

View File

@@ -8,12 +8,12 @@ import { useWorkflowTemplateSelectorDialog } from '@/composables/useWorkflowTemp
import { useCommandStore } from '@/stores/commandStore'
import { useWorkspaceStore } from '@/stores/workspaceStore'
import { cn } from '@/utils/tailwindUtil'
import { useAppModeStore } from '@/stores/appModeStore'
import { useAppMode } from '@/composables/useAppMode'
const { t } = useI18n()
const commandStore = useCommandStore()
const workspaceStore = useWorkspaceStore()
const appModeStore = useAppModeStore()
const { enableAppBuilder, setMode } = useAppMode()
const tooltipOptions = { showDelay: 300, hideDelay: 300 }
const isAssetsActive = computed(
@@ -24,7 +24,7 @@ const isWorkflowsActive = computed(
)
function enterBuilderMode() {
appModeStore.setMode('builder:select')
setMode('builder:select')
}
function openAssets() {
@@ -61,7 +61,7 @@ function openTemplates() {
</WorkflowActionsDropdown>
<Button
v-if="appModeStore.enableAppBuilder"
v-if="enableAppBuilder"
v-tooltip.right="{
value: t('linearMode.appModeToolbar.appBuilder'),
...tooltipOptions

View File

@@ -0,0 +1,324 @@
<script setup lang="ts">
import { remove } from 'es-toolkit'
import { computed, ref, toValue } from 'vue'
import type { MaybeRef } from 'vue'
import { useI18n } from 'vue-i18n'
import DraggableList from '@/components/common/DraggableList.vue'
import IoItem from '@/components/builder/IoItem.vue'
import PropertiesAccordionItem from '@/components/rightSidePanel/layout/PropertiesAccordionItem.vue'
import Button from '@/components/ui/button/Button.vue'
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
import type { LGraphNode, NodeId } from '@/lib/litegraph/src/LGraphNode'
import type { INodeInputSlot } from '@/lib/litegraph/src/interfaces'
import type { LGraphCanvas } from '@/lib/litegraph/src/LGraphCanvas'
import { TitleMode } from '@/lib/litegraph/src/types/globalEnums'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import { BaseWidget } from '@/lib/litegraph/src/widgets/BaseWidget'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useCanvasInteractions } from '@/renderer/core/canvas/useCanvasInteractions'
import TransformPane from '@/renderer/core/layout/transform/TransformPane.vue'
import { app } from '@/scripts/app'
import { DOMWidgetImpl } from '@/scripts/domWidget'
import { useDialogService } from '@/services/dialogService'
import { useAppModeStore } from '@/stores/appModeStore'
import { cn } from '@/utils/tailwindUtil'
type BoundStyle = { top: string; left: string; width: string; height: string }
const appModeStore = useAppModeStore()
const canvasInteractions = useCanvasInteractions()
const canvasStore = useCanvasStore()
const settingStore = useSettingStore()
const workflowStore = useWorkflowStore()
const { t } = useI18n()
const canvas: LGraphCanvas = canvasStore.getCanvas()
const hoveringSelectable = ref(false)
workflowStore.activeWorkflow?.changeTracker?.reset()
const inputsWithState = computed(() =>
appModeStore.selectedInputs.map(([nodeId, widgetName]) => {
const node = app.rootGraph.getNodeById(nodeId)
const widget = node?.widgets?.find((w) => w.name === widgetName)
if (!node || !widget) return { nodeId, widgetName }
const input = node.inputs.find((i) => i.widget?.name === widget.name)
const rename = input && (() => renameWidget(widget, input))
return {
nodeId,
widgetName,
label: widget.label,
subLabel: node.title,
rename
}
})
)
const outputsWithState = computed<[NodeId, string][]>(() =>
appModeStore.selectedOutputs.map((nodeId) => [
nodeId,
app.rootGraph.getNodeById(nodeId)?.title ?? String(nodeId)
])
)
async function renameWidget(widget: IBaseWidget, input: INodeInputSlot) {
const newLabel = await useDialogService().prompt({
title: t('g.rename'),
message: t('g.enterNewNamePrompt'),
defaultValue: widget.label,
placeholder: widget.name
})
if (newLabel === null) return
widget.label = newLabel || undefined
input.label = newLabel || undefined
widget.callback?.(widget.value)
useCanvasStore().canvas?.setDirty(true)
}
function getHovered(
e: MouseEvent
): undefined | [LGraphNode, undefined] | [LGraphNode, IBaseWidget] {
const { graph } = canvas
if (!canvas || !graph) return
if (settingStore.get('Comfy.VueNodes.Enabled')) return undefined
if (!e) return
canvas.adjustMouseEvent(e)
const node = graph.getNodeOnPos(e.canvasX, e.canvasY)
if (!node) return
const widget = node.getWidgetOnPos(e.canvasX, e.canvasY, false)
if (widget || node.constructor.nodeData?.output_node) return [node, widget]
}
function getBounding(nodeId: NodeId, widgetName?: string) {
if (settingStore.get('Comfy.VueNodes.Enabled')) return undefined
const node = app.rootGraph.getNodeById(nodeId)
if (!node) return
const titleOffset =
node.title_mode === TitleMode.NORMAL_TITLE ? LiteGraph.NODE_TITLE_HEIGHT : 0
if (!widgetName)
return {
width: `${node.size[0]}px`,
height: `${node.size[1] + titleOffset}px`,
left: `${node.pos[0]}px`,
top: `${node.pos[1] - titleOffset}px`
}
const widget = node.widgets?.find((w) => w.name === widgetName)
if (!widget) return
const margin = widget instanceof DOMWidgetImpl ? widget.margin : undefined
const marginX = margin ?? BaseWidget.margin
const height =
(widget.computedHeight !== undefined
? widget.computedHeight - 4
: LiteGraph.NODE_WIDGET_HEIGHT) - (margin ? 2 * margin - 4 : 0)
return {
width: `${node.size[0] - marginX * 2}px`,
height: `${height}px`,
left: `${node.pos[0] + marginX}px`,
top: `${node.pos[1] + widget.y + (margin ?? 0)}px`
}
}
function handleDown(e: MouseEvent) {
const [node] = getHovered(e) ?? []
if (!node || e.button > 0) canvasInteractions.forwardEventToCanvas(e)
}
function handleClick(e: MouseEvent) {
const [node, widget] = getHovered(e) ?? []
if (!node) return canvasInteractions.forwardEventToCanvas(e)
if (!widget) {
if (!node.constructor.nodeData?.output_node)
return canvasInteractions.forwardEventToCanvas(e)
const index = appModeStore.selectedOutputs.findIndex((id) => id === node.id)
if (index === -1) appModeStore.selectedOutputs.push(node.id)
else appModeStore.selectedOutputs.splice(index, 1)
return
}
const index = appModeStore.selectedInputs.findIndex(
([nodeId, widgetName]) => node.id === nodeId && widget.name === widgetName
)
if (index === -1) appModeStore.selectedInputs.push([node.id, widget.name])
else appModeStore.selectedInputs.splice(index, 1)
}
function nodeToDisplayTuple(
n: LGraphNode
): [NodeId, MaybeRef<BoundStyle> | undefined, boolean] {
return [
n.id,
getBounding(n.id),
appModeStore.selectedOutputs.some((id) => n.id === id)
]
}
const renderedOutputs = computed(() => {
void appModeStore.selectedOutputs.length
return canvas
.graph!.nodes.filter((n) => n.constructor.nodeData?.output_node)
.map(nodeToDisplayTuple)
})
const renderedInputs = computed<[string, MaybeRef<BoundStyle> | undefined][]>(
() =>
appModeStore.selectedInputs.map(([nodeId, widgetName]) => [
`${nodeId}: ${widgetName}`,
getBounding(nodeId, widgetName)
])
)
</script>
<template>
<div class="flex font-bold p-2 border-border-subtle border-b items-center">
{{ t('linearMode.builder.title') }}
<Button class="ml-auto" @click="appModeStore.exitBuilder">
{{ t('linearMode.builder.exit') }}
</Button>
</div>
<PropertiesAccordionItem
:label="t('nodeHelpPage.inputs')"
enable-empty-state
:disabled="!appModeStore.selectedInputs.length"
class="border-border-subtle border-b"
:tooltip="`${t('linearMode.builder.inputsDesc')}\n${t('linearMode.builder.inputsExample')}`"
>
<template #label>
<div class="flex gap-3">
{{ t('nodeHelpPage.inputs') }}
<i class="bg-muted-foreground icon-[lucide--circle-alert]" />
</div>
</template>
<template #empty>
<div
class="w-full p-4 pt-2 text-muted-foreground"
v-text="t('linearMode.builder.promptAddInputs')"
/>
</template>
<div
class="w-full p-4 pt-2 text-muted-foreground"
v-text="t('linearMode.builder.promptAddInputs')"
/>
<DraggableList v-slot="{ dragClass }" v-model="appModeStore.selectedInputs">
<IoItem
v-for="{
nodeId,
widgetName,
label,
subLabel,
rename
} in inputsWithState"
:key="`${nodeId}: ${widgetName}`"
:class="cn(dragClass, 'bg-primary-background/30 p-2 my-2 rounded-lg')"
:title="label ?? widgetName"
:sub-title="subLabel"
:rename
:remove="
() =>
remove(
appModeStore.selectedInputs,
([id, name]) => nodeId === id && widgetName === name
)
"
/>
</DraggableList>
</PropertiesAccordionItem>
<PropertiesAccordionItem
:label="t('nodeHelpPage.outputs')"
enable-empty-state
:disabled="!appModeStore.selectedOutputs.length"
:tooltip="`${t('linearMode.builder.outputsDesc')}\n${t('linearMode.builder.outputsExample')}`"
>
<template #label>
<div class="flex gap-3">
{{ t('nodeHelpPage.outputs') }}
<i class="bg-muted-foreground icon-[lucide--circle-alert]" />
</div>
</template>
<template #empty>
<div
class="w-full p-4 pt-2 text-muted-foreground"
v-text="t('linearMode.builder.promptAddOutputs')"
/>
</template>
<div
class="w-full p-4 pt-2 text-muted-foreground"
v-text="t('linearMode.builder.promptAddOutputs')"
/>
<DraggableList
v-slot="{ dragClass }"
v-model="appModeStore.selectedOutputs"
>
<IoItem
v-for="([key, title], index) in outputsWithState"
:key
:class="
cn(
dragClass,
'bg-warning-background/40 p-2 my-2 rounded-lg',
index === 0 && 'ring-warning-background ring-2'
)
"
:title
:sub-title="String(key)"
:remove="() => remove(appModeStore.selectedOutputs, (k) => k === key)"
/>
</DraggableList>
</PropertiesAccordionItem>
<Teleport to="body">
<div
:class="
cn(
'absolute w-full h-full pointer-events-auto',
hoveringSelectable ? 'cursor-pointer' : 'cursor-grab'
)
"
@pointerdown="handleDown"
@pointermove="hoveringSelectable = !!getHovered($event)"
@click="handleClick"
@wheel="canvasInteractions.forwardEventToCanvas"
>
<TransformPane :canvas="canvasStore.getCanvas()">
<div
v-for="[key, style] in renderedInputs"
:key
:style="toValue(style)"
class="fixed bg-primary-background/30 rounded-lg"
/>
<div
v-for="[key, style, isSelected] in renderedOutputs"
:key
:style="toValue(style)"
:class="
cn(
'fixed ring-warning-background ring-5 rounded-2xl',
!isSelected && 'ring-warning-background/50'
)
"
>
<div class="absolute top-0 right-0 size-8">
<div
v-if="isSelected"
class="absolute -top-1/2 -right-1/2 size-full p-2 bg-warning-background rounded-lg"
>
<i class="icon-[lucide--check] bg-text-foreground size-full" />
</div>
<div
v-else
class="absolute -top-1/2 -right-1/2 size-full ring-warning-background/50 ring-4 ring-inset bg-component-node-background rounded-lg"
/>
</div>
</div>
</TransformPane>
</div>
</Teleport>
</template>

View File

@@ -20,7 +20,7 @@
)
"
:aria-current="activeStep === step.id ? 'step' : undefined"
@click="appModeStore.setMode(step.id)"
@click="setMode(step.id)"
>
<StepBadge :step :index :model-value="activeStep" />
<StepLabel :step />
@@ -31,9 +31,9 @@
<!-- Save -->
<ConnectOutputPopover
v-if="!appModeStore.hasOutputs"
v-if="!hasOutputs"
:is-select-active="activeStep === 'builder:select'"
@switch="appModeStore.setMode('builder:select')"
@switch="setMode('builder:select')"
>
<button :class="cn(stepClasses, 'opacity-30 bg-transparent')">
<StepBadge :step="saveStep" :index="2" :model-value="activeStep" />
@@ -50,7 +50,7 @@
: 'hover:bg-secondary-background bg-transparent'
)
"
@click="appModeStore.setBuilderSaving(true)"
@click="setSaving(true)"
>
<StepBadge :step="saveStep" :index="2" :model-value="activeStep" />
<StepLabel :step="saveStep" />
@@ -63,21 +63,24 @@
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { useAppMode } from '@/composables/useAppMode'
import type { AppMode } from '@/composables/useAppMode'
import { useAppModeStore } from '@/stores/appModeStore'
import type { AppMode } from '@/stores/appModeStore'
import { cn } from '@/utils/tailwindUtil'
import { useBuilderSave } from './useBuilderSave'
import ConnectOutputPopover from './ConnectOutputPopover.vue'
import StepBadge from './StepBadge.vue'
import StepLabel from './StepLabel.vue'
import type { BuilderToolbarStep } from './types'
import { storeToRefs } from 'pinia'
const { t } = useI18n()
const appModeStore = useAppModeStore()
const { mode, setMode } = useAppMode()
const { hasOutputs } = storeToRefs(useAppModeStore())
const { saving, setSaving } = useBuilderSave()
const activeStep = computed(() =>
appModeStore.isBuilderSaving ? 'save' : appModeStore.mode
)
const activeStep = computed(() => (saving.value ? 'save' : mode.value))
const stepClasses =
'inline-flex h-14 min-h-8 cursor-pointer items-center gap-3 rounded-lg py-2 pr-4 pl-2 transition-colors border-none'

View File

@@ -0,0 +1,46 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import Popover from '@/components/ui/Popover.vue'
import Button from '@/components/ui/button/Button.vue'
const { t } = useI18n()
const { rename, remove } = defineProps<{
title: string
subTitle?: string
rename?: () => void
remove?: () => void
}>()
const entries = computed(() => {
const items = []
if (rename)
items.push({
label: t('g.rename'),
command: rename,
icon: 'icon-[lucide--pencil]'
})
if (remove)
items.push({
label: t('g.delete'),
command: remove,
icon: 'icon-[lucide--trash-2]'
})
return items
})
</script>
<template>
<div class="p-2 my-2 rounded-lg flex items-center-safe">
<span class="mr-auto" v-text="title" />
<span class="text-muted-foreground mr-2 text-end" v-text="subTitle" />
<Popover :entries>
<template #button>
<Button variant="muted-textonly">
<i class="icon-[lucide--ellipsis]" />
</Button>
</template>
</Popover>
</div>
</template>

View File

@@ -1,30 +1,36 @@
import { watch } from 'vue'
import { ref } from 'vue'
import { useErrorHandling } from '@/composables/useErrorHandling'
import { useWorkflowService } from '@/platform/workflow/core/services/workflowService'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import { useDialogService } from '@/services/dialogService'
import { useAppMode } from '@/composables/useAppMode'
import { useAppModeStore } from '@/stores/appModeStore'
import { useDialogStore } from '@/stores/dialogStore'
import BuilderSaveDialogContent from './BuilderSaveDialogContent.vue'
import BuilderSaveSuccessDialogContent from './BuilderSaveSuccessDialogContent.vue'
import { whenever } from '@vueuse/core'
const SAVE_DIALOG_KEY = 'builder-save'
const SUCCESS_DIALOG_KEY = 'builder-save-success'
export function useBuilderSave() {
const appModeStore = useAppModeStore()
const { setMode } = useAppMode()
const { toastErrorHandler } = useErrorHandling()
const workflowStore = useWorkflowStore()
const workflowService = useWorkflowService()
const dialogService = useDialogService()
const appModeStore = useAppModeStore()
const dialogStore = useDialogStore()
watch(
() => appModeStore.isBuilderSaving,
(saving) => {
if (saving) void onBuilderSave()
}
)
const saving = ref(false)
whenever(saving, onBuilderSave)
function setSaving(value: boolean) {
saving.value = value
}
async function onBuilderSave() {
const workflow = workflowStore.activeWorkflow
@@ -33,15 +39,14 @@ export function useBuilderSave() {
return
}
// TODO: Update this to show the save dialog if it is temp OR if the user has not saved app mode before.
// If they have saved app mode before, just save the workflow, but use the initial app mode state not current.
if (!workflow.isTemporary) {
if (!workflow.isTemporary && workflow.initialMode != null) {
// Re-save with the previously chosen mode — no dialog needed.
try {
workflow.changeTracker?.checkState()
appModeStore.flushSelections()
await workflowService.saveWorkflow(workflow)
showSuccessDialog(workflow.filename, appModeStore.isAppMode)
} catch {
showSuccessDialog(workflow.filename, workflow.initialMode === 'app')
} catch (e) {
toastErrorHandler(e)
resetSaving()
}
return
@@ -75,16 +80,19 @@ export function useBuilderSave() {
const workflow = workflowStore.activeWorkflow
if (!workflow) return
appModeStore.flushSelections()
const mode = openAsApp ? 'app' : 'graph'
const saved = await workflowService.saveWorkflowAs(workflow, {
filename,
openAsApp
initialMode: mode
})
if (!saved) return
closeSaveDialog()
showSuccessDialog(filename, openAsApp)
} catch {
} catch (e) {
toastErrorHandler(e)
closeSaveDialog()
resetSaving()
}
@@ -98,7 +106,7 @@ export function useBuilderSave() {
workflowName,
savedAsApp,
onViewApp: () => {
appModeStore.setMode('app')
setMode('app')
closeSuccessDialog()
},
onClose: closeSuccessDialog
@@ -119,6 +127,8 @@ export function useBuilderSave() {
}
function resetSaving() {
appModeStore.setBuilderSaving(false)
saving.value = false
}
return { saving, setSaving }
}

View File

@@ -0,0 +1,59 @@
<script setup lang="ts" generic="T">
import { onBeforeUnmount, ref, useTemplateRef, watchPostEffect } from 'vue'
import { DraggableList } from '@/scripts/ui/draggableList'
const modelValue = defineModel<T[]>({ required: true })
const draggableList = ref<DraggableList>()
const draggableItems = useTemplateRef('draggableItems')
watchPostEffect(() => {
void modelValue.value.length
draggableList.value?.dispose()
if (!draggableItems.value?.children?.length) return
draggableList.value = new DraggableList(
draggableItems.value,
'.draggable-item'
)
draggableList.value.applyNewItemsOrder = function () {
const reorderedItems = []
let oldPosition = -1
this.getAllItems().forEach((item, index) => {
if (item === this.draggableItem) {
oldPosition = index
return
}
if (!this.isItemToggled(item)) {
reorderedItems[index] = item
return
}
const newIndex = this.isItemAbove(item) ? index + 1 : index - 1
reorderedItems[newIndex] = item
})
for (let index = 0; index < this.getAllItems().length; index++) {
const item = reorderedItems[index]
if (typeof item === 'undefined') {
reorderedItems[index] = this.draggableItem
}
}
const newPosition = reorderedItems.indexOf(this.draggableItem)
const itemList = modelValue.value
const [item] = itemList.splice(oldPosition, 1)
itemList.splice(newPosition, 0, item)
modelValue.value = [...itemList]
}
})
onBeforeUnmount(() => {
draggableList.value?.dispose()
})
</script>
<template>
<div ref="draggableItems" class="pb-2 px-2 space-y-0.5 mt-0.5">
<slot
drag-class="draggable-item drag-handle cursor-grab [&.is-draggable]:cursor-grabbing"
/>
</div>
</template>

View File

@@ -1,7 +1,7 @@
import { mount } from '@vue/test-utils'
import { describe, expect, it } from 'vitest'
import type { CurvePoint } from '@/lib/litegraph/src/types/widgets'
import type { CurvePoint } from './types'
import CurveEditor from './CurveEditor.vue'

View File

@@ -77,7 +77,8 @@
import { computed, useTemplateRef } from 'vue'
import { useCurveEditor } from '@/composables/useCurveEditor'
import type { CurvePoint } from '@/lib/litegraph/src/types/widgets'
import type { CurvePoint } from './types'
import { histogramToPath } from './curveUtils'

View File

@@ -3,7 +3,7 @@
</template>
<script setup lang="ts">
import type { CurvePoint } from '@/lib/litegraph/src/types/widgets'
import type { CurvePoint } from './types'
import CurveEditor from './CurveEditor.vue'

View File

@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest'
import type { CurvePoint } from '@/lib/litegraph/src/types/widgets'
import type { CurvePoint } from './types'
import {
createMonotoneInterpolator,

View File

@@ -1,4 +1,4 @@
import type { CurvePoint } from '@/lib/litegraph/src/types/widgets'
import type { CurvePoint } from './types'
/**
* Monotone cubic Hermite interpolation.
@@ -95,15 +95,15 @@ export function histogramToPath(histogram: Uint32Array): string {
const max = sorted[Math.floor(255 * 0.995)]
if (max === 0) return ''
const step = 1 / 255
let d = 'M0,1'
const invMax = 1 / max
const parts: string[] = ['M0,1']
for (let i = 0; i < 256; i++) {
const x = i * step
const y = 1 - Math.min(1, histogram[i] / max)
d += ` L${x.toFixed(4)},${y.toFixed(4)}`
const x = i / 255
const y = 1 - Math.min(1, histogram[i] * invMax)
parts.push(`L${x},${y}`)
}
d += ' L1,1 Z'
return d
parts.push('L1,1 Z')
return parts.join(' ')
}
export function curvesToLUT(points: CurvePoint[]): Uint8Array {

View File

@@ -0,0 +1 @@
export type CurvePoint = [x: number, y: number]

View File

@@ -438,7 +438,6 @@ onMounted(() => {
const systemStatsStore = useSystemStatsStore()
const distributions = computed(() => {
// eslint-disable-next-line no-undef
switch (__DISTRIBUTION__) {
case 'cloud':
return [TemplateIncludeOnDistributionEnum.Cloud]

View File

@@ -21,7 +21,7 @@
</div>
</div>
</template>
<template v-if="showUI && !appModeStore.isBuilderMode" #side-toolbar>
<template v-if="showUI && !isBuilderMode" #side-toolbar>
<SideToolbar />
</template>
<template v-if="showUI" #side-bar-panel>
@@ -31,26 +31,24 @@
<ExtensionSlot v-if="activeSidebarTab" :extension="activeSidebarTab" />
</div>
</template>
<template v-if="showUI && !appModeStore.isBuilderMode" #topmenu>
<template v-if="showUI && !isBuilderMode" #topmenu>
<TopMenuSection />
</template>
<template v-if="showUI" #bottom-panel>
<BottomPanel />
</template>
<template v-if="showUI" #right-side-panel>
<NodePropertiesPanel v-if="!appModeStore.isBuilderMode" />
<AppBuilder v-if="mode === 'builder:select'" />
<NodePropertiesPanel v-else-if="!isBuilderMode" />
</template>
<template #graph-canvas-panel>
<GraphCanvasMenu
v-if="canvasMenuEnabled && !appModeStore.isBuilderMode"
v-if="canvasMenuEnabled && !isBuilderMode"
class="pointer-events-auto"
/>
<MiniMap
v-if="
comfyAppReady &&
minimapEnabled &&
betaMenuEnabled &&
!appModeStore.isBuilderMode
comfyAppReady && minimapEnabled && betaMenuEnabled && !isBuilderMode
"
class="pointer-events-auto"
/>
@@ -129,6 +127,7 @@ import { isMiddlePointerInput } from '@/base/pointerUtils'
import LiteGraphCanvasSplitterOverlay from '@/components/LiteGraphCanvasSplitterOverlay.vue'
import TopMenuSection from '@/components/TopMenuSection.vue'
import BottomPanel from '@/components/bottomPanel/BottomPanel.vue'
import AppBuilder from '@/components/builder/AppBuilder.vue'
import ExtensionSlot from '@/components/common/ExtensionSlot.vue'
import DomWidgets from '@/components/graph/DomWidgets.vue'
import GraphCanvasMenu from '@/components/graph/GraphCanvasMenu.vue'
@@ -182,7 +181,7 @@ import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { useNodeDefStore } from '@/stores/nodeDefStore'
import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
import { useSearchBoxStore } from '@/stores/workspace/searchBoxStore'
import { useAppModeStore } from '@/stores/appModeStore'
import { useAppMode } from '@/composables/useAppMode'
import { useWorkspaceStore } from '@/stores/workspaceStore'
import { isNativeWindow } from '@/utils/envUtil'
import { forEachNode } from '@/utils/graphTraversalUtil'
@@ -203,7 +202,7 @@ const nodeSearchboxPopoverRef = shallowRef<InstanceType<
const settingStore = useSettingStore()
const nodeDefStore = useNodeDefStore()
const workspaceStore = useWorkspaceStore()
const appModeStore = useAppModeStore()
const { mode, isBuilderMode } = useAppMode()
const canvasStore = useCanvasStore()
const workflowStore = useWorkflowStore()
const executionStore = useExecutionStore()

View File

@@ -0,0 +1,359 @@
<template>
<div
class="widget-expands flex h-full w-full flex-col gap-1"
@pointerdown.stop
@pointermove.stop
@pointerup.stop
>
<div
class="flex min-h-0 flex-1 items-center justify-center overflow-hidden rounded-lg bg-node-component-surface"
>
<div class="relative max-h-full w-full" :style="canvasContainerStyle">
<img
v-if="inputImageUrl"
:src="inputImageUrl"
class="absolute inset-0 size-full"
draggable="false"
@load="handleInputImageLoad"
@dragstart.prevent
/>
<canvas
ref="canvasEl"
class="absolute inset-0 size-full cursor-none touch-none"
@pointerdown="handlePointerDown"
@pointermove="handlePointerMove"
@pointerup="handlePointerUp"
@pointerenter="handlePointerEnter"
@pointerleave="handlePointerLeave"
/>
<div
v-show="cursorVisible"
class="pointer-events-none absolute left-0 top-0 rounded-full border border-black/60 shadow-[0_0_0_1px_rgba(255,255,255,0.8)]"
:style="cursorStyle"
/>
</div>
</div>
<div
v-if="isImageInputConnected"
class="text-center text-xs text-muted-foreground"
>
{{ canvasWidth }} x {{ canvasHeight }}
</div>
<div
ref="controlsEl"
:class="
cn(
'grid shrink-0 gap-x-1 gap-y-1',
compact ? 'grid-cols-1' : 'grid-cols-[auto_1fr]'
)
"
>
<div
v-if="!compact"
class="flex w-28 items-center truncate text-sm text-muted-foreground"
>
{{ $t('painter.tool') }}
</div>
<div
class="flex h-8 items-center gap-1 rounded-sm bg-component-node-widget-background p-1"
>
<Button
variant="textonly"
size="unset"
:class="
cn(
'flex-1 self-stretch px-2 text-xs transition-colors',
tool === PAINTER_TOOLS.BRUSH
? 'rounded-sm bg-component-node-widget-background-selected text-base-foreground'
: 'text-node-text-muted hover:text-node-text'
)
"
@click="tool = PAINTER_TOOLS.BRUSH"
>
{{ $t('painter.brush') }}
</Button>
<Button
variant="textonly"
size="unset"
:class="
cn(
'flex-1 self-stretch px-2 text-xs transition-colors',
tool === PAINTER_TOOLS.ERASER
? 'rounded-sm bg-component-node-widget-background-selected text-base-foreground'
: 'text-node-text-muted hover:text-node-text'
)
"
@click="tool = PAINTER_TOOLS.ERASER"
>
{{ $t('painter.eraser') }}
</Button>
</div>
<div
v-if="!compact"
class="flex w-28 items-center truncate text-sm text-muted-foreground"
>
{{ $t('painter.size') }}
</div>
<div
class="flex h-8 items-center gap-2 rounded-lg bg-component-node-widget-background pr-2 pl-3"
>
<Slider
:model-value="[brushSize]"
:min="1"
:max="200"
:step="1"
class="flex-1"
@update:model-value="(v) => v?.length && (brushSize = v[0])"
/>
<span class="w-8 text-center text-xs text-node-text-muted">{{
brushSize
}}</span>
</div>
<template v-if="tool === PAINTER_TOOLS.BRUSH">
<div
class="flex w-28 items-center truncate text-sm text-muted-foreground"
>
{{ $t('painter.color') }}
</div>
<div
class="flex h-8 w-full items-center gap-2 rounded-lg bg-component-node-widget-background px-4"
>
<input
type="color"
:value="brushColorDisplay"
class="h-4 w-8 cursor-pointer appearance-none overflow-hidden rounded-full border-none bg-transparent [&::-webkit-color-swatch-wrapper]:p-0 [&::-webkit-color-swatch]:border-none [&::-webkit-color-swatch]:rounded-full [&::-moz-color-swatch]:border-none [&::-moz-color-swatch]:rounded-full"
@input="
(e) => (brushColorDisplay = (e.target as HTMLInputElement).value)
"
/>
<span class="min-w-[4ch] truncate text-xs">{{
brushColorDisplay
}}</span>
<span class="ml-auto flex items-center text-xs text-node-text-muted">
<input
type="number"
:value="brushOpacityPercent"
min="0"
max="100"
step="1"
class="w-7 appearance-none border-0 bg-transparent text-right text-xs text-node-text-muted outline-none [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none [-moz-appearance:textfield]"
@click.prevent
@change="
(e) => {
const val = Math.min(
100,
Math.max(0, Number((e.target as HTMLInputElement).value))
)
brushOpacityPercent = val
;(e.target as HTMLInputElement).value = String(val)
}
"
/>%</span
>
</div>
<div
class="flex w-28 items-center truncate text-sm text-muted-foreground"
>
{{ $t('painter.hardness') }}
</div>
<div
class="flex h-8 items-center gap-2 rounded-lg bg-component-node-widget-background pr-2 pl-3"
>
<Slider
:model-value="[brushHardnessPercent]"
:min="0"
:max="100"
:step="1"
class="flex-1"
@update:model-value="
(v) => v?.length && (brushHardnessPercent = v[0])
"
/>
<span class="w-8 text-center text-xs text-node-text-muted"
>{{ brushHardnessPercent }}%</span
>
</div>
</template>
<template v-if="!isImageInputConnected">
<div
class="flex w-28 items-center truncate text-sm text-muted-foreground"
>
{{ $t('painter.width') }}
</div>
<div
class="flex h-8 items-center gap-2 rounded-lg bg-component-node-widget-background pr-2 pl-3"
>
<Slider
:model-value="[canvasWidth]"
:min="64"
:max="4096"
:step="64"
class="flex-1"
@update:model-value="(v) => v?.length && (canvasWidth = v[0])"
/>
<span class="w-10 text-center text-xs text-node-text-muted">{{
canvasWidth
}}</span>
</div>
<div
class="flex w-28 items-center truncate text-sm text-muted-foreground"
>
{{ $t('painter.height') }}
</div>
<div
class="flex h-8 items-center gap-2 rounded-lg bg-component-node-widget-background pr-2 pl-3"
>
<Slider
:model-value="[canvasHeight]"
:min="64"
:max="4096"
:step="64"
class="flex-1"
@update:model-value="(v) => v?.length && (canvasHeight = v[0])"
/>
<span class="w-10 text-center text-xs text-node-text-muted">{{
canvasHeight
}}</span>
</div>
<div
class="flex w-28 items-center truncate text-sm text-muted-foreground"
>
{{ $t('painter.background') }}
</div>
<div
class="flex h-8 w-full items-center gap-2 rounded-lg bg-component-node-widget-background px-4"
>
<input
type="color"
:value="backgroundColorDisplay"
class="h-4 w-8 cursor-pointer appearance-none overflow-hidden rounded-full border-none bg-transparent [&::-webkit-color-swatch-wrapper]:p-0 [&::-webkit-color-swatch]:border-none [&::-webkit-color-swatch]:rounded-full [&::-moz-color-swatch]:border-none [&::-moz-color-swatch]:rounded-full"
@input="
(e) =>
(backgroundColorDisplay = (e.target as HTMLInputElement).value)
"
/>
<span class="min-w-[4ch] truncate text-xs">{{
backgroundColorDisplay
}}</span>
</div>
</template>
<Button
variant="secondary"
size="md"
:class="
cn(
'gap-2 rounded-lg border border-component-node-border bg-component-node-background text-xs text-muted-foreground hover:text-base-foreground',
!compact && 'col-span-2'
)
"
@click="handleClear"
>
<i class="icon-[lucide--undo-2]" />
{{ $t('painter.clear') }}
</Button>
</div>
</div>
</template>
<script setup lang="ts">
import { useElementSize } from '@vueuse/core'
import { computed, useTemplateRef } from 'vue'
import Button from '@/components/ui/button/Button.vue'
import Slider from '@/components/ui/slider/Slider.vue'
import { PAINTER_TOOLS, usePainter } from '@/composables/painter/usePainter'
import { toHexFromFormat } from '@/utils/colorUtil'
import { cn } from '@/utils/tailwindUtil'
const { nodeId } = defineProps<{
nodeId: string
}>()
const modelValue = defineModel<string>({ default: '' })
const canvasEl = useTemplateRef<HTMLCanvasElement>('canvasEl')
const controlsEl = useTemplateRef<HTMLDivElement>('controlsEl')
const { width: controlsWidth } = useElementSize(controlsEl)
const compact = computed(
() => controlsWidth.value > 0 && controlsWidth.value < 350
)
const {
tool,
brushSize,
brushColor,
brushOpacity,
brushHardness,
backgroundColor,
canvasWidth,
canvasHeight,
cursorX,
cursorY,
cursorVisible,
displayBrushSize,
inputImageUrl,
isImageInputConnected,
handlePointerDown,
handlePointerMove,
handlePointerUp,
handlePointerEnter,
handlePointerLeave,
handleInputImageLoad,
handleClear
} = usePainter(nodeId, { canvasEl, modelValue })
const canvasContainerStyle = computed(() => ({
aspectRatio: `${canvasWidth.value} / ${canvasHeight.value}`,
backgroundColor: isImageInputConnected.value
? undefined
: backgroundColor.value
}))
const cursorStyle = computed(() => {
const size = displayBrushSize.value
const x = cursorX.value - size / 2
const y = cursorY.value - size / 2
return {
width: `${size}px`,
height: `${size}px`,
transform: `translate(${x}px, ${y}px)`
}
})
const brushOpacityPercent = computed({
get: () => Math.round(brushOpacity.value * 100),
set: (val: number) => {
brushOpacity.value = val / 100
}
})
const brushHardnessPercent = computed({
get: () => Math.round(brushHardness.value * 100),
set: (val: number) => {
brushHardness.value = val / 100
}
})
const brushColorDisplay = computed({
get: () => toHexFromFormat(brushColor.value, 'hex'),
set: (val: unknown) => {
brushColor.value = toHexFromFormat(val, 'hex')
}
})
const backgroundColorDisplay = computed({
get: () => toHexFromFormat(backgroundColor.value, 'hex'),
set: (val: unknown) => {
backgroundColor.value = toHexFromFormat(val, 'hex')
}
})
</script>

View File

@@ -79,6 +79,7 @@ import Button from '@/components/ui/button/Button.vue'
import { buildTooltipConfig } from '@/composables/useTooltipConfig'
import { isCloud } from '@/platform/distribution/types'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useSidebarTabStore } from '@/stores/workspace/sidebarTabStore'
const emit = defineEmits<{
(e: 'clearHistory'): void
@@ -86,6 +87,7 @@ const emit = defineEmits<{
const { t } = useI18n()
const settingStore = useSettingStore()
const sidebarTabStore = useSidebarTabStore()
const moreTooltipConfig = computed(() => buildTooltipConfig(t('g.more')))
const isQueuePanelV2Enabled = computed(() =>
@@ -99,6 +101,13 @@ const onClearHistoryFromMenu = (close: () => void) => {
}
const onToggleDockedJobHistory = async () => {
await settingStore.set('Comfy.Queue.QPOV2', !isQueuePanelV2Enabled.value)
if (isQueuePanelV2Enabled.value) {
await settingStore.set('Comfy.Queue.QPOV2', false)
await settingStore.set('Comfy.Queue.History.Expanded', true)
return
}
await settingStore.set('Comfy.Queue.QPOV2', true)
sidebarTabStore.activeSidebarTabId = 'job-history'
}
</script>

View File

@@ -23,10 +23,13 @@ vi.mock('@/components/ui/Popover.vue', () => {
return { default: PopoverStub }
})
const mockGetSetting = vi.fn((key: string) =>
const mockGetSetting = vi.fn<(key: string) => boolean | undefined>((key) =>
key === 'Comfy.Queue.QPOV2' ? true : undefined
)
const mockSetSetting = vi.fn()
const mockSidebarTabStore = {
activeSidebarTabId: null as string | null
}
vi.mock('@/platform/settings/settingStore', () => ({
useSettingStore: () => ({
@@ -35,6 +38,10 @@ vi.mock('@/platform/settings/settingStore', () => ({
})
}))
vi.mock('@/stores/workspace/sidebarTabStore', () => ({
useSidebarTabStore: () => mockSidebarTabStore
}))
import QueueOverlayHeader from './QueueOverlayHeader.vue'
import * as tooltipConfig from '@/composables/useTooltipConfig'
@@ -81,6 +88,10 @@ describe('QueueOverlayHeader', () => {
beforeEach(() => {
popoverCloseSpy.mockClear()
mockSetSetting.mockClear()
mockSidebarTabStore.activeSidebarTabId = null
mockGetSetting.mockImplementation((key: string) =>
key === 'Comfy.Queue.QPOV2' ? true : undefined
)
})
it('renders header title', () => {
@@ -125,7 +136,32 @@ describe('QueueOverlayHeader', () => {
expect(wrapper.emitted('clearHistory')).toHaveLength(1)
})
it('toggles docked job history setting from the menu', async () => {
it('opens floating queue progress overlay when disabling from the menu', async () => {
const wrapper = mountHeader()
const dockedJobHistoryButton = wrapper.get(
'[data-testid="docked-job-history-action"]'
)
await dockedJobHistoryButton.trigger('click')
expect(mockSetSetting).toHaveBeenCalledTimes(2)
expect(mockSetSetting).toHaveBeenNthCalledWith(
1,
'Comfy.Queue.QPOV2',
false
)
expect(mockSetSetting).toHaveBeenNthCalledWith(
2,
'Comfy.Queue.History.Expanded',
true
)
expect(mockSidebarTabStore.activeSidebarTabId).toBe(null)
})
it('opens docked job history sidebar when enabling from the menu', async () => {
mockGetSetting.mockImplementation((key: string) =>
key === 'Comfy.Queue.QPOV2' ? false : undefined
)
const wrapper = mountHeader()
const dockedJobHistoryButton = wrapper.get(
@@ -134,6 +170,7 @@ describe('QueueOverlayHeader', () => {
await dockedJobHistoryButton.trigger('click')
expect(mockSetSetting).toHaveBeenCalledTimes(1)
expect(mockSetSetting).toHaveBeenCalledWith('Comfy.Queue.QPOV2', false)
expect(mockSetSetting).toHaveBeenCalledWith('Comfy.Queue.QPOV2', true)
expect(mockSidebarTabStore.activeSidebarTabId).toBe('job-history')
})
})

View File

@@ -0,0 +1,146 @@
import { mount } from '@vue/test-utils'
import { describe, expect, it, vi } from 'vitest'
import type { JobGroup, JobListItem } from '@/composables/queue/useJobList'
import type { JobListItem as ApiJobListItem } from '@/platform/remote/comfyui/jobs/jobTypes'
import { ResultItemImpl, TaskItemImpl } from '@/stores/queueStore'
import JobAssetsList from './JobAssetsList.vue'
vi.mock('vue-i18n', () => {
return {
createI18n: () => ({
global: {
t: (key: string) => key,
te: () => true,
d: (value: string) => value
}
}),
useI18n: () => ({
t: (key: string) => key
})
}
})
const createResultItem = (
filename: string,
mediaType: string = 'images'
): ResultItemImpl => {
const item = new ResultItemImpl({
filename,
subfolder: '',
type: 'output',
nodeId: 'node-1',
mediaType
})
Object.defineProperty(item, 'url', {
get: () => `/api/view/${filename}`
})
return item
}
const createTaskRef = (preview?: ResultItemImpl): TaskItemImpl => {
const job: ApiJobListItem = {
id: `task-${Math.random().toString(36).slice(2)}`,
status: 'completed',
create_time: Date.now(),
preview_output: null,
outputs_count: preview ? 1 : 0,
priority: 0
}
const flatOutputs = preview ? [preview] : []
return new TaskItemImpl(job, {}, flatOutputs)
}
const buildJob = (overrides: Partial<JobListItem> = {}): JobListItem => ({
id: 'job-1',
title: 'Job 1',
meta: 'meta',
state: 'completed',
taskRef: createTaskRef(createResultItem('job-1.png')),
...overrides
})
const mountJobAssetsList = (jobs: JobListItem[]) => {
const displayedJobGroups: JobGroup[] = [
{
key: 'group-1',
label: 'Group 1',
items: jobs
}
]
return mount(JobAssetsList, {
props: { displayedJobGroups }
})
}
describe('JobAssetsList', () => {
it('emits viewItem on preview-click for completed jobs with preview', async () => {
const job = buildJob()
const wrapper = mountJobAssetsList([job])
const listItem = wrapper.findComponent({ name: 'AssetsListItem' })
listItem.vm.$emit('preview-click')
await wrapper.vm.$nextTick()
expect(wrapper.emitted('viewItem')).toEqual([[job]])
})
it('emits viewItem on double-click for completed jobs with preview', async () => {
const job = buildJob()
const wrapper = mountJobAssetsList([job])
const listItem = wrapper.findComponent({ name: 'AssetsListItem' })
await listItem.trigger('dblclick')
await wrapper.vm.$nextTick()
expect(wrapper.emitted('viewItem')).toEqual([[job]])
})
it('emits viewItem on double-click for completed video jobs without icon image', async () => {
const job = buildJob({
iconImageUrl: undefined,
taskRef: createTaskRef(createResultItem('job-1.webm', 'video'))
})
const wrapper = mountJobAssetsList([job])
const listItem = wrapper.findComponent({ name: 'AssetsListItem' })
expect(listItem.props('previewUrl')).toBe('/api/view/job-1.webm')
expect(listItem.props('isVideoPreview')).toBe(true)
await listItem.trigger('dblclick')
await wrapper.vm.$nextTick()
expect(wrapper.emitted('viewItem')).toEqual([[job]])
})
it('emits viewItem on icon click for completed 3D jobs without preview tile', async () => {
const job = buildJob({
iconImageUrl: undefined,
taskRef: createTaskRef(createResultItem('job-1.glb', 'model'))
})
const wrapper = mountJobAssetsList([job])
const listItem = wrapper.findComponent({ name: 'AssetsListItem' })
await listItem.find('i').trigger('click')
await wrapper.vm.$nextTick()
expect(wrapper.emitted('viewItem')).toEqual([[job]])
})
it('does not emit viewItem on double-click for non-completed jobs', async () => {
const job = buildJob({
state: 'running',
taskRef: createTaskRef(createResultItem('job-1.png'))
})
const wrapper = mountJobAssetsList([job])
const listItem = wrapper.findComponent({ name: 'AssetsListItem' })
await listItem.trigger('dblclick')
await wrapper.vm.$nextTick()
expect(wrapper.emitted('viewItem')).toBeUndefined()
})
})

View File

@@ -12,7 +12,8 @@
v-for="job in group.items"
:key="job.id"
class="w-full shrink-0 cursor-default text-text-primary transition-colors hover:bg-secondary-background-hover"
:preview-url="job.iconImageUrl"
:preview-url="getJobPreviewUrl(job)"
:is-video-preview="isVideoPreviewJob(job)"
:preview-alt="job.title"
:icon-name="job.iconName ?? iconForJobState(job.state)"
:icon-class="getJobIconClass(job)"
@@ -23,6 +24,8 @@
@mouseenter="hoveredJobId = job.id"
@mouseleave="onJobLeave(job.id)"
@contextmenu.prevent.stop="$emit('menu', job, $event)"
@dblclick.stop="emitViewItem(job)"
@preview-click="emitViewItem(job)"
@click.stop
>
<template v-if="hoveredJobId === job.id" #actions>
@@ -78,7 +81,7 @@ import { isActiveJobState } from '@/utils/queueUtil'
defineProps<{ displayedJobGroups: JobGroup[] }>()
defineEmits<{
const emit = defineEmits<{
(e: 'cancelItem', item: JobListItem): void
(e: 'deleteItem', item: JobListItem): void
(e: 'menu', item: JobListItem, ev: MouseEvent): void
@@ -100,6 +103,28 @@ const isCancelable = (job: JobListItem) =>
const isFailedDeletable = (job: JobListItem) =>
job.showClear !== false && job.state === 'failed'
const getPreviewOutput = (job: JobListItem) => job.taskRef?.previewOutput
const getJobPreviewUrl = (job: JobListItem) => {
const preview = getPreviewOutput(job)
if (preview?.isImage || preview?.isVideo) {
return preview.url
}
return job.iconImageUrl
}
const isVideoPreviewJob = (job: JobListItem) =>
job.state === 'completed' && !!getPreviewOutput(job)?.isVideo
const isPreviewableCompletedJob = (job: JobListItem) =>
job.state === 'completed' && !!getPreviewOutput(job)
const emitViewItem = (job: JobListItem) => {
if (isPreviewableCompletedJob(job)) {
emit('viewItem', job)
}
}
const getJobIconClass = (job: JobListItem): string | undefined => {
const iconName = job.iconName ?? iconForJobState(job.state)
if (!job.iconImageUrl && iconName === iconForJobState('pending')) {

View File

@@ -131,11 +131,11 @@ export const Queued: Story = {
const exec = useExecutionStore()
const jobId = 'job-queued-1'
const queueIndex = 104
const priority = 104
// Current job in pending
queue.pendingTasks = [
makePendingTask(jobId, queueIndex, Date.now() - 90_000)
makePendingTask(jobId, priority, Date.now() - 90_000)
]
// Add some other pending jobs to give context
queue.pendingTasks.push(
@@ -179,13 +179,13 @@ export const QueuedParallel: Story = {
const exec = useExecutionStore()
const jobId = 'job-queued-parallel'
const queueIndex = 210
const priority = 210
// Current job in pending with some ahead
queue.pendingTasks = [
makePendingTask('job-ahead-1', 200, Date.now() - 180_000),
makePendingTask('job-ahead-2', 205, Date.now() - 150_000),
makePendingTask(jobId, queueIndex, Date.now() - 120_000)
makePendingTask(jobId, priority, Date.now() - 120_000)
]
// Seen 2 minutes ago - set via prompt metadata above
@@ -238,9 +238,9 @@ export const Running: Story = {
const exec = useExecutionStore()
const jobId = 'job-running-1'
const queueIndex = 300
const priority = 300
queue.runningTasks = [
makeRunningTask(jobId, queueIndex, Date.now() - 65_000)
makeRunningTask(jobId, priority, Date.now() - 65_000)
]
queue.historyTasks = [
makeHistoryTask('hist-r1', 250, 30, true),
@@ -279,10 +279,10 @@ export const QueuedZeroAheadSingleRunning: Story = {
const exec = useExecutionStore()
const jobId = 'job-queued-zero-ahead-single'
const queueIndex = 510
const priority = 510
queue.pendingTasks = [
makePendingTask(jobId, queueIndex, Date.now() - 45_000)
makePendingTask(jobId, priority, Date.now() - 45_000)
]
queue.historyTasks = [
@@ -324,10 +324,10 @@ export const QueuedZeroAheadMultiRunning: Story = {
const exec = useExecutionStore()
const jobId = 'job-queued-zero-ahead-multi'
const queueIndex = 520
const priority = 520
queue.pendingTasks = [
makePendingTask(jobId, queueIndex, Date.now() - 20_000)
makePendingTask(jobId, priority, Date.now() - 20_000)
]
queue.historyTasks = [
@@ -380,8 +380,8 @@ export const Completed: Story = {
const queue = useQueueStore()
const jobId = 'job-completed-1'
const queueIndex = 400
queue.historyTasks = [makeHistoryTask(jobId, queueIndex, 37, true)]
const priority = 400
queue.historyTasks = [makeHistoryTask(jobId, priority, 37, true)]
return { args: { ...args, jobId } }
},
@@ -401,11 +401,11 @@ export const Failed: Story = {
const queue = useQueueStore()
const jobId = 'job-failed-1'
const queueIndex = 410
const priority = 410
queue.historyTasks = [
makeHistoryTask(
jobId,
queueIndex,
priority,
12,
false,
'Example error: invalid inputs for node X'

View File

@@ -166,16 +166,16 @@ const queuedAtValue = computed(() =>
: ''
)
const currentQueueIndex = computed<number | null>(() => {
const currentJobPriority = computed<number | null>(() => {
const task = taskForJob.value
return task ? Number(task.queueIndex) : null
return task ? Number(task.job.priority) : null
})
const jobsAhead = computed<number | null>(() => {
const idx = currentQueueIndex.value
const idx = currentJobPriority.value
if (idx == null) return null
const ahead = queueStore.pendingTasks.filter(
(t: TaskItemImpl) => Number(t.queueIndex) < idx
(t: TaskItemImpl) => Number(t.job.priority) < idx
)
return ahead.length
})

View File

@@ -5,7 +5,7 @@
v-for="tab in visibleJobTabs"
:key="tab"
:variant="selectedJobTab === tab ? 'secondary' : 'muted-textonly'"
size="sm"
size="md"
@click="$emit('update:selectedJobTab', tab)"
>
{{ tabLabel(tab) }}

View File

@@ -40,7 +40,8 @@ const rightSidePanelStore = useRightSidePanelStore()
const settingStore = useSettingStore()
const { t } = useI18n()
const { hasAnyError, allErrorExecutionIds } = storeToRefs(executionErrorStore)
const { hasAnyError, allErrorExecutionIds, activeMissingNodeGraphIds } =
storeToRefs(executionErrorStore)
const { findParentGroup } = useGraphHierarchy()
@@ -109,9 +110,21 @@ const hasContainerInternalError = computed(() => {
})
})
const hasMissingNodeSelected = computed(
() =>
hasSelection.value &&
selectedNodes.value.some((node) =>
activeMissingNodeGraphIds.value.has(String(node.id))
)
)
const hasRelevantErrors = computed(() => {
if (!hasSelection.value) return hasAnyError.value
return hasDirectNodeError.value || hasContainerInternalError.value
return (
hasDirectNodeError.value ||
hasContainerInternalError.value ||
hasMissingNodeSelected.value
)
})
const tabs = computed<RightSidePanelTabList>(() => {

View File

@@ -17,24 +17,26 @@
>
{{ card.nodeTitle }}
</span>
<Button
v-if="card.isSubgraphNode"
variant="secondary"
size="sm"
class="rounded-lg text-sm shrink-0"
@click.stop="handleEnterSubgraph"
>
{{ t('rightSidePanel.enterSubgraph') }}
</Button>
<Button
variant="textonly"
size="icon-sm"
class="size-7 text-muted-foreground hover:text-base-foreground shrink-0"
:aria-label="t('rightSidePanel.locateNode')"
@click.stop="handleLocateNode"
>
<i class="icon-[lucide--locate] size-3.5" />
</Button>
<div class="flex items-center shrink-0">
<Button
v-if="card.isSubgraphNode"
variant="secondary"
size="sm"
class="rounded-lg text-sm shrink-0 h-8"
@click.stop="handleEnterSubgraph"
>
{{ t('rightSidePanel.enterSubgraph') }}
</Button>
<Button
variant="textonly"
size="icon-sm"
class="size-8 text-muted-foreground hover:text-base-foreground shrink-0"
:aria-label="t('rightSidePanel.locateNode')"
@click.stop="handleLocateNode"
>
<i class="icon-[lucide--locate] size-4" />
</Button>
</div>
</div>
<!-- Multiple Errors within one Card -->

View File

@@ -0,0 +1,79 @@
<template>
<div class="px-4 pb-2">
<!-- Sub-label: cloud or OSS message shown above all pack groups -->
<p class="m-0 pb-5 text-sm text-muted-foreground leading-relaxed">
{{
isCloud
? t('rightSidePanel.missingNodePacks.cloudMessage')
: t('rightSidePanel.missingNodePacks.ossMessage')
}}
</p>
<MissingPackGroupRow
v-for="group in missingPackGroups"
:key="group.packId ?? '__unknown__'"
:group="group"
:show-info-button="showInfoButton"
:show-node-id-badge="showNodeIdBadge"
@locate-node="emit('locateNode', $event)"
@open-manager-info="emit('openManagerInfo', $event)"
/>
</div>
<!-- Apply Changes: shown when manager enabled and at least one pack install succeeded -->
<div v-if="shouldShowManagerButtons" class="px-4">
<Button
v-if="hasInstalledPacksPendingRestart"
variant="primary"
:disabled="isRestarting"
class="w-full h-9 justify-center gap-2 text-sm font-semibold mt-2"
@click="applyChanges()"
>
<DotSpinner v-if="isRestarting" duration="1s" :size="14" />
<i v-else class="icon-[lucide--refresh-cw] size-4 shrink-0" />
<span class="truncate min-w-0">{{
t('rightSidePanel.missingNodePacks.applyChanges')
}}</span>
</Button>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import DotSpinner from '@/components/common/DotSpinner.vue'
import { useApplyChanges } from '@/workbench/extensions/manager/composables/useApplyChanges'
import { useComfyManagerStore } from '@/workbench/extensions/manager/stores/comfyManagerStore'
import { isCloud } from '@/platform/distribution/types'
import { useManagerState } from '@/workbench/extensions/manager/composables/useManagerState'
import type { MissingPackGroup } from '@/components/rightSidePanel/errors/useErrorGroups'
import MissingPackGroupRow from '@/components/rightSidePanel/errors/MissingPackGroupRow.vue'
const props = defineProps<{
showInfoButton: boolean
showNodeIdBadge: boolean
missingPackGroups: MissingPackGroup[]
}>()
const emit = defineEmits<{
locateNode: [nodeId: string]
openManagerInfo: [packId: string]
}>()
const { t } = useI18n()
const comfyManagerStore = useComfyManagerStore()
const { isRestarting, applyChanges } = useApplyChanges()
const { shouldShowManagerButtons } = useManagerState()
/**
* Show Apply Changes when any pack from the error group is already installed
* on disk but ComfyUI hasn't restarted yet to load it.
* This is server-state based → persists across browser refreshes.
*/
const hasInstalledPacksPendingRestart = computed(() =>
props.missingPackGroups.some(
(g) => g.packId !== null && comfyManagerStore.isPackInstalled(g.packId)
)
)
</script>

View File

@@ -0,0 +1,250 @@
<template>
<div class="flex flex-col w-full mb-2">
<!-- Pack header row: pack name + info + chevron -->
<div class="flex h-8 items-center w-full">
<!-- Warning icon for unknown packs -->
<i
v-if="group.packId === null && !group.isResolving"
class="icon-[lucide--triangle-alert] size-4 text-warning-background shrink-0 mr-1.5"
/>
<p
class="flex-1 min-w-0 text-sm font-medium overflow-hidden text-ellipsis whitespace-nowrap"
:class="
group.packId === null && !group.isResolving
? 'text-warning-background'
: 'text-foreground'
"
>
<span v-if="group.isResolving" class="text-muted-foreground italic">
{{ t('g.loading') }}...
</span>
<span v-else>
{{
`${group.packId ?? t('rightSidePanel.missingNodePacks.unknownPack')} (${group.nodeTypes.length})`
}}
</span>
</p>
<Button
v-if="showInfoButton && group.packId !== null"
variant="textonly"
size="icon-sm"
class="size-8 text-muted-foreground hover:text-base-foreground shrink-0"
:aria-label="t('rightSidePanel.missingNodePacks.viewInManager')"
@click="emit('openManagerInfo', group.packId ?? '')"
>
<i class="icon-[lucide--info] size-4" />
</Button>
<Button
variant="textonly"
size="icon-sm"
:class="
cn(
'size-8 shrink-0 transition-transform duration-200 hover:bg-transparent',
{ 'rotate-180': expanded }
)
"
:aria-label="
expanded
? t('rightSidePanel.missingNodePacks.collapse')
: t('rightSidePanel.missingNodePacks.expand')
"
@click="toggleExpand"
>
<i
class="icon-[lucide--chevron-down] size-4 text-muted-foreground group-hover:text-base-foreground"
/>
</Button>
</div>
<!-- Sub-labels: individual node instances, each with their own Locate button -->
<TransitionCollapse>
<div
v-if="expanded"
class="flex flex-col gap-0.5 pl-2 mb-1 overflow-hidden"
>
<div
v-for="nodeType in group.nodeTypes"
:key="getKey(nodeType)"
class="flex h-7 items-center"
>
<span
v-if="
showNodeIdBadge &&
typeof nodeType !== 'string' &&
nodeType.nodeId != null
"
class="shrink-0 rounded-md bg-secondary-background-selected px-2 py-0.5 text-xs font-mono text-muted-foreground font-bold mr-1"
>
#{{ nodeType.nodeId }}
</span>
<p class="flex-1 min-w-0 text-xs text-muted-foreground truncate">
{{ getLabel(nodeType) }}
</p>
<Button
v-if="typeof nodeType !== 'string' && nodeType.nodeId != null"
variant="textonly"
size="icon-sm"
class="size-6 text-muted-foreground hover:text-base-foreground shrink-0 mr-1"
:aria-label="t('rightSidePanel.locateNode')"
@click="handleLocateNode(nodeType)"
>
<i class="icon-[lucide--locate] size-3" />
</Button>
</div>
</div>
</TransitionCollapse>
<!-- Install button: shown when manager enabled, registry knows the pack or it's already installed -->
<div
v-if="
shouldShowManagerButtons &&
group.packId !== null &&
(nodePack || comfyManagerStore.isPackInstalled(group.packId))
"
class="flex items-start w-full pt-1 pb-1"
>
<Button
variant="secondary"
size="md"
class="flex flex-1 w-full"
:disabled="
comfyManagerStore.isPackInstalled(group.packId) || isInstalling
"
@click="handlePackInstallClick"
>
<DotSpinner
v-if="isInstalling"
duration="1s"
:size="12"
class="mr-1.5 shrink-0"
/>
<i
v-else-if="comfyManagerStore.isPackInstalled(group.packId)"
class="icon-[lucide--check] size-4 text-foreground shrink-0 mr-1"
/>
<i
v-else
class="icon-[lucide--download] size-4 text-foreground shrink-0 mr-1"
/>
<span class="text-sm text-foreground truncate min-w-0">
{{
isInstalling
? t('rightSidePanel.missingNodePacks.installing')
: comfyManagerStore.isPackInstalled(group.packId)
? t('rightSidePanel.missingNodePacks.installed')
: t('rightSidePanel.missingNodePacks.installNodePack')
}}
</span>
</Button>
</div>
<!-- Registry still loading: packId known but result not yet available -->
<div
v-else-if="group.packId !== null && shouldShowManagerButtons && isLoading"
class="flex items-start w-full pt-1 pb-1"
>
<div
class="flex flex-1 h-8 items-center justify-center overflow-hidden p-2 rounded-lg min-w-0 bg-secondary-background opacity-60 cursor-not-allowed select-none"
>
<DotSpinner duration="1s" :size="12" class="mr-1.5 shrink-0" />
<span class="text-sm text-foreground truncate min-w-0">
{{ t('g.loading') }}
</span>
</div>
</div>
<!-- Search in Manager: fetch done but pack not found in registry -->
<div
v-else-if="group.packId !== null && shouldShowManagerButtons"
class="flex items-start w-full pt-1 pb-1"
>
<Button
variant="secondary"
size="md"
class="flex flex-1 w-full"
@click="
openManager({
initialTab: ManagerTab.All,
initialPackId: group.packId!
})
"
>
<i class="icon-[lucide--search] size-4 text-foreground shrink-0 mr-1" />
<span class="text-sm text-foreground truncate min-w-0">
{{ t('rightSidePanel.missingNodePacks.searchInManager') }}
</span>
</Button>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { cn } from '@/utils/tailwindUtil'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import DotSpinner from '@/components/common/DotSpinner.vue'
import TransitionCollapse from '@/components/rightSidePanel/layout/TransitionCollapse.vue'
import { useMissingNodes } from '@/workbench/extensions/manager/composables/nodePack/useMissingNodes'
import { usePackInstall } from '@/workbench/extensions/manager/composables/nodePack/usePackInstall'
import { useComfyManagerStore } from '@/workbench/extensions/manager/stores/comfyManagerStore'
import { useManagerState } from '@/workbench/extensions/manager/composables/useManagerState'
import { ManagerTab } from '@/workbench/extensions/manager/types/comfyManagerTypes'
import type { MissingNodeType } from '@/types/comfy'
import type { MissingPackGroup } from '@/components/rightSidePanel/errors/useErrorGroups'
const props = defineProps<{
group: MissingPackGroup
showInfoButton: boolean
showNodeIdBadge: boolean
}>()
const emit = defineEmits<{
locateNode: [nodeId: string]
openManagerInfo: [packId: string]
}>()
const { t } = useI18n()
const { missingNodePacks, isLoading } = useMissingNodes()
const comfyManagerStore = useComfyManagerStore()
const { shouldShowManagerButtons, openManager } = useManagerState()
const nodePack = computed(() => {
if (!props.group.packId) return null
return missingNodePacks.value.find((p) => p.id === props.group.packId) ?? null
})
const { isInstalling, installAllPacks } = usePackInstall(() =>
nodePack.value ? [nodePack.value] : []
)
function handlePackInstallClick() {
if (!props.group.packId) return
if (!comfyManagerStore.isPackInstalled(props.group.packId)) {
void installAllPacks()
}
}
const expanded = ref(false)
function toggleExpand() {
expanded.value = !expanded.value
}
function getKey(nodeType: MissingNodeType): string {
if (typeof nodeType === 'string') return nodeType
return nodeType.nodeId != null ? String(nodeType.nodeId) : nodeType.type
}
function getLabel(nodeType: MissingNodeType): string {
return typeof nodeType === 'string' ? nodeType : nodeType.type
}
function handleLocateNode(nodeType: MissingNodeType) {
if (typeof nodeType === 'string') return
if (nodeType.nodeId != null) {
emit('locateNode', String(nodeType.nodeId))
}
}
</script>

View File

@@ -0,0 +1,150 @@
<template>
<div class="flex flex-col w-full mb-4">
<!-- Type header row: type name + chevron -->
<div class="flex h-8 items-center w-full">
<p
class="flex-1 min-w-0 text-sm font-medium overflow-hidden text-ellipsis whitespace-nowrap text-foreground"
>
{{ `${group.type} (${group.nodeTypes.length})` }}
</p>
<Button
variant="textonly"
size="icon-sm"
:class="
cn(
'size-8 shrink-0 transition-transform duration-200 hover:bg-transparent',
{ 'rotate-180': expanded }
)
"
:aria-label="
expanded
? t('rightSidePanel.missingNodePacks.collapse', 'Collapse')
: t('rightSidePanel.missingNodePacks.expand', 'Expand')
"
@click="toggleExpand"
>
<i
class="icon-[lucide--chevron-down] size-4 text-muted-foreground group-hover:text-base-foreground"
/>
</Button>
</div>
<!-- Sub-labels: individual node instances, each with their own Locate button -->
<TransitionCollapse>
<div
v-if="expanded"
class="flex flex-col gap-0.5 pl-2 mb-2 overflow-hidden"
>
<div
v-for="nodeType in group.nodeTypes"
:key="getKey(nodeType)"
class="flex h-7 items-center"
>
<span
v-if="
showNodeIdBadge &&
typeof nodeType !== 'string' &&
nodeType.nodeId != null
"
class="shrink-0 rounded-md bg-secondary-background-selected px-2 py-0.5 text-xs font-mono text-muted-foreground font-bold mr-1"
>
#{{ nodeType.nodeId }}
</span>
<p class="flex-1 min-w-0 text-xs text-muted-foreground truncate">
{{ getLabel(nodeType) }}
</p>
<Button
v-if="typeof nodeType !== 'string' && nodeType.nodeId != null"
variant="textonly"
size="icon-sm"
class="size-6 text-muted-foreground hover:text-base-foreground shrink-0 mr-1"
:aria-label="t('rightSidePanel.locateNode', 'Locate Node')"
@click="handleLocateNode(nodeType)"
>
<i class="icon-[lucide--locate] size-3" />
</Button>
</div>
</div>
</TransitionCollapse>
<!-- Description rows: what it is replaced by -->
<div class="flex flex-col text-[13px] mb-2 mt-1 px-1 gap-0.5">
<span class="text-muted-foreground">{{
t('nodeReplacement.willBeReplacedBy', 'This node will be replaced by:')
}}</span>
<span class="font-bold text-foreground">{{
group.newNodeId ?? t('nodeReplacement.unknownNode', 'Unknown')
}}</span>
</div>
<!-- Replace Action Button -->
<div class="flex items-start w-full pt-1 pb-1">
<Button
variant="secondary"
size="md"
class="flex flex-1 w-full"
@click="handleReplaceNode"
>
<i class="icon-[lucide--repeat] size-4 text-foreground shrink-0 mr-1" />
<span class="text-sm text-foreground truncate min-w-0">
{{ t('nodeReplacement.replaceNode', 'Replace Node') }}
</span>
</Button>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { cn } from '@/utils/tailwindUtil'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import TransitionCollapse from '@/components/rightSidePanel/layout/TransitionCollapse.vue'
import type { MissingNodeType } from '@/types/comfy'
import type { SwapNodeGroup } from './useErrorGroups'
import { useNodeReplacement } from '@/platform/nodeReplacement/useNodeReplacement'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
const props = defineProps<{
group: SwapNodeGroup
showNodeIdBadge: boolean
}>()
const emit = defineEmits<{
'locate-node': [nodeId: string]
}>()
const { t } = useI18n()
const { replaceNodesInPlace } = useNodeReplacement()
const executionErrorStore = useExecutionErrorStore()
const expanded = ref(false)
function toggleExpand() {
expanded.value = !expanded.value
}
function getKey(nodeType: MissingNodeType): string {
if (typeof nodeType === 'string') return nodeType
return nodeType.nodeId != null ? String(nodeType.nodeId) : nodeType.type
}
function getLabel(nodeType: MissingNodeType): string {
return typeof nodeType === 'string' ? nodeType : nodeType.type
}
function handleLocateNode(nodeType: MissingNodeType) {
if (typeof nodeType === 'string') return
if (nodeType.nodeId != null) {
emit('locate-node', String(nodeType.nodeId))
}
}
function handleReplaceNode() {
const replaced = replaceNodesInPlace(props.group.nodeTypes)
if (replaced.length > 0) {
executionErrorStore.removeMissingNodesByType([props.group.type])
}
}
</script>

View File

@@ -0,0 +1,38 @@
<template>
<div class="px-4 pb-2 mt-2">
<!-- Sub-label: guidance message shown above all swap groups -->
<p class="m-0 pb-5 text-sm text-muted-foreground leading-relaxed">
{{
t(
'nodeReplacement.swapNodesGuide',
'The following nodes can be automatically replaced with compatible alternatives.'
)
}}
</p>
<!-- Group Rows -->
<SwapNodeGroupRow
v-for="group in swapNodeGroups"
:key="group.type"
:group="group"
:show-node-id-badge="showNodeIdBadge"
@locate-node="emit('locate-node', $event)"
/>
</div>
</template>
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import type { SwapNodeGroup } from './useErrorGroups'
import SwapNodeGroupRow from './SwapNodeGroupRow.vue'
const { t } = useI18n()
const { swapNodeGroups, showNodeIdBadge } = defineProps<{
swapNodeGroups: SwapNodeGroup[]
showNodeIdBadge: boolean
}>()
const emit = defineEmits<{
'locate-node': [nodeId: string]
}>()
</script>

View File

@@ -18,7 +18,8 @@ vi.mock('@/scripts/app', () => ({
vi.mock('@/utils/graphTraversalUtil', () => ({
getNodeByExecutionId: vi.fn(),
getRootParentNode: vi.fn(() => null),
forEachNode: vi.fn()
forEachNode: vi.fn(),
mapAllNodes: vi.fn(() => [])
}))
vi.mock('@/composables/useCopyToClipboard', () => ({

View File

@@ -27,6 +27,11 @@
:key="group.title"
:collapse="collapseState[group.title] ?? false"
class="border-b border-interface-stroke"
:size="
group.type === 'missing_node' || group.type === 'swap_nodes'
? 'lg'
: 'default'
"
@update:collapse="collapseState[group.title] = $event"
>
<template #label>
@@ -36,20 +41,78 @@
class="icon-[lucide--octagon-alert] size-4 text-destructive-background-hover shrink-0"
/>
<span class="text-destructive-background-hover truncate">
{{ group.title }}
{{
group.type === 'missing_node'
? `${group.title} (${missingPackGroups.length})`
: group.type === 'swap_nodes'
? `${group.title} (${swapNodeGroups.length})`
: group.title
}}
</span>
<span
v-if="group.cards.length > 1"
v-if="group.type === 'execution' && group.cards.length > 1"
class="text-destructive-background-hover"
>
({{ group.cards.length }})
</span>
</span>
<Button
v-if="
group.type === 'missing_node' &&
missingNodePacks.length > 0 &&
shouldShowInstallButton
"
variant="secondary"
size="sm"
class="shrink-0 mr-2 h-8 rounded-lg text-sm"
:disabled="isInstallingAll"
@click.stop="installAll"
>
<DotSpinner v-if="isInstallingAll" duration="1s" :size="12" />
{{
isInstallingAll
? t('rightSidePanel.missingNodePacks.installing')
: t('rightSidePanel.missingNodePacks.installAll')
}}
</Button>
<Button
v-else-if="group.type === 'swap_nodes'"
v-tooltip.top="
t(
'nodeReplacement.replaceAllWarning',
'Replaces all available nodes in this group.'
)
"
variant="secondary"
size="sm"
class="shrink-0 mr-2 h-8 rounded-lg text-sm"
@click.stop="handleReplaceAll()"
>
{{ t('nodeReplacement.replaceAll', 'Replace All') }}
</Button>
</div>
</template>
<!-- Cards in Group (default slot) -->
<div class="px-4 space-y-3">
<!-- Missing Node Packs -->
<MissingNodeCard
v-if="group.type === 'missing_node'"
:show-info-button="shouldShowManagerButtons"
:show-node-id-badge="showNodeIdBadge"
:missing-pack-groups="missingPackGroups"
@locate-node="handleLocateMissingNode"
@open-manager-info="handleOpenManagerInfo"
/>
<!-- Swap Nodes -->
<SwapNodesCard
v-else-if="group.type === 'swap_nodes'"
:swap-node-groups="swapNodeGroups"
:show-node-id-badge="showNodeIdBadge"
@locate-node="handleLocateMissingNode"
/>
<!-- Execution Errors -->
<div v-else-if="group.type === 'execution'" class="px-4 space-y-3">
<ErrorNodeCard
v-for="card in group.cards"
:key="card.id"
@@ -108,13 +171,22 @@ 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'
import { NodeBadgeMode } from '@/types/nodeSource'
import PropertiesAccordionItem from '../layout/PropertiesAccordionItem.vue'
import FormSearchInput from '@/renderer/extensions/vueNodes/widgets/components/form/FormSearchInput.vue'
import ErrorNodeCard from './ErrorNodeCard.vue'
import MissingNodeCard from './MissingNodeCard.vue'
import SwapNodesCard from './SwapNodesCard.vue'
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 { useErrorGroups } from './useErrorGroups'
import { useNodeReplacement } from '@/platform/nodeReplacement/useNodeReplacement'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
const { t } = useI18n()
const { copyToClipboard } = useCopyToClipboard()
@@ -122,6 +194,13 @@ const { focusNode, enterSubgraph } = useFocusNode()
const { staticUrls } = useExternalLink()
const settingStore = useSettingStore()
const rightSidePanelStore = useRightSidePanelStore()
const { shouldShowManagerButtons, shouldShowInstallButton, openManager } =
useManagerState()
const { missingNodePacks } = useMissingNodes()
const { isInstalling: isInstallingAll, installAllPacks: installAll } =
usePackInstall(() => missingNodePacks.value)
const { replaceNodesInPlace } = useNodeReplacement()
const executionErrorStore = useExecutionErrorStore()
const searchQuery = ref('')
@@ -136,7 +215,10 @@ const {
filteredGroups,
collapseState,
isSingleNodeSelected,
errorNodeCache
errorNodeCache,
missingNodeCache,
missingPackGroups,
swapNodeGroups
} = useErrorGroups(searchQuery, t)
/**
@@ -151,11 +233,13 @@ watch(
if (!graphNodeId) return
const prefix = `${graphNodeId}:`
for (const group of allErrorGroups.value) {
const hasMatch = group.cards.some(
(card) =>
card.graphNodeId === graphNodeId ||
(card.nodeId?.startsWith(prefix) ?? false)
)
const hasMatch =
group.type === 'execution' &&
group.cards.some(
(card) =>
card.graphNodeId === graphNodeId ||
(card.nodeId?.startsWith(prefix) ?? false)
)
collapseState[group.title] = !hasMatch
}
rightSidePanelStore.focusedErrorNodeId = null
@@ -167,6 +251,27 @@ function handleLocateNode(nodeId: string) {
focusNode(nodeId, errorNodeCache.value)
}
function handleLocateMissingNode(nodeId: string) {
focusNode(nodeId, missingNodeCache.value)
}
function handleOpenManagerInfo(packId: string) {
const isKnownToRegistry = missingNodePacks.value.some((p) => p.id === packId)
if (isKnownToRegistry) {
openManager({ initialTab: ManagerTab.Missing, initialPackId: packId })
} else {
openManager({ initialTab: ManagerTab.All, initialPackId: packId })
}
}
function handleReplaceAll() {
const allNodeTypes = swapNodeGroups.value.flatMap((g) => g.nodeTypes)
const replaced = replaceNodesInPlace(allNodeTypes)
if (replaced.length > 0) {
executionErrorStore.removeMissingNodesByType(replaced)
}
}
function handleEnterSubgraph(nodeId: string) {
enterSubgraph(nodeId, errorNodeCache.value)
}

View File

@@ -14,8 +14,12 @@ export interface ErrorCardData {
errors: ErrorItem[]
}
export interface ErrorGroup {
title: string
cards: ErrorCardData[]
priority: number
}
export type ErrorGroup =
| {
type: 'execution'
title: string
cards: ErrorCardData[]
priority: number
}
| { type: 'missing_node'; title: string; priority: number }
| { type: 'swap_nodes'; title: string; priority: number }

View File

@@ -1,11 +1,11 @@
import { computed, reactive } from 'vue'
import { computed, reactive, ref, watch } from 'vue'
import type { Ref } from 'vue'
import Fuse from 'fuse.js'
import type { IFuseOptions } from 'fuse.js'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { useComfyRegistryStore } from '@/stores/comfyRegistryStore'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { app } from '@/scripts/app'
import { isCloud } from '@/platform/distribution/types'
import { SubgraphNode } from '@/lib/litegraph/src/litegraph'
@@ -20,6 +20,7 @@ import { resolveNodeDisplayName } from '@/utils/nodeTitleUtil'
import { isLGraphNode } from '@/utils/litegraphUtil'
import { isGroupNode } from '@/utils/executableGroupNodeDto'
import { st } from '@/i18n'
import type { MissingNodeType } from '@/types/comfy'
import type { ErrorCardData, ErrorGroup, ErrorItem } from './types'
import type { NodeExecutionId } from '@/types/nodeIdentification'
import { isNodeExecutionId } from '@/types/nodeIdentification'
@@ -32,7 +33,23 @@ const KNOWN_PROMPT_ERROR_TYPES = new Set([
'server_error'
])
/** Sentinel: distinguishes "fetch in-flight" from "fetch done, pack not found (null)". */
const RESOLVING = '__RESOLVING__'
export interface MissingPackGroup {
packId: string | null
nodeTypes: MissingNodeType[]
isResolving: boolean
}
export interface SwapNodeGroup {
type: string
newNodeId: string | undefined
nodeTypes: MissingNodeType[]
}
interface GroupEntry {
type: 'execution'
priority: number
cards: Map<string, ErrorCardData>
}
@@ -76,7 +93,7 @@ function getOrCreateGroup(
): Map<string, ErrorCardData> {
let entry = groupsMap.get(title)
if (!entry) {
entry = { priority, cards: new Map() }
entry = { type: 'execution', priority, cards: new Map() }
groupsMap.set(title, entry)
}
return entry.cards
@@ -137,6 +154,7 @@ function addCardErrorToGroup(
function toSortedGroups(groupsMap: Map<string, GroupEntry>): ErrorGroup[] {
return Array.from(groupsMap.entries())
.map(([title, groupData]) => ({
type: 'execution' as const,
title,
cards: Array.from(groupData.cards.values()),
priority: groupData.priority
@@ -153,6 +171,7 @@ function searchErrorGroups(groups: ErrorGroup[], query: string) {
const searchableList: ErrorSearchItem[] = []
for (let gi = 0; gi < groups.length; gi++) {
const group = groups[gi]
if (group.type !== 'execution') continue
for (let ci = 0; ci < group.cards.length; ci++) {
const card = group.cards[ci]
searchableList.push({
@@ -160,8 +179,12 @@ function searchErrorGroups(groups: ErrorGroup[], query: string) {
cardIndex: ci,
searchableNodeId: card.nodeId ?? '',
searchableNodeTitle: card.nodeTitle ?? '',
searchableMessage: card.errors.map((e) => e.message).join(' '),
searchableDetails: card.errors.map((e) => e.details ?? '').join(' ')
searchableMessage: card.errors
.map((e: ErrorItem) => e.message)
.join(' '),
searchableDetails: card.errors
.map((e: ErrorItem) => e.details ?? '')
.join(' ')
})
}
}
@@ -184,11 +207,16 @@ function searchErrorGroups(groups: ErrorGroup[], query: string) {
)
return groups
.map((group, gi) => ({
...group,
cards: group.cards.filter((_, ci) => matchedCardKeys.has(`${gi}:${ci}`))
}))
.filter((group) => group.cards.length > 0)
.map((group, gi) => {
if (group.type !== 'execution') return group
return {
...group,
cards: group.cards.filter((_: ErrorCardData, ci: number) =>
matchedCardKeys.has(`${gi}:${ci}`)
)
}
})
.filter((group) => group.type !== 'execution' || group.cards.length > 0)
}
export function useErrorGroups(
@@ -197,6 +225,7 @@ export function useErrorGroups(
) {
const executionErrorStore = useExecutionErrorStore()
const canvasStore = useCanvasStore()
const { inferPackFromNodeName } = useComfyRegistryStore()
const collapseState = reactive<Record<string, boolean>>({})
const selectedNodeInfo = computed(() => {
@@ -237,6 +266,19 @@ export function useErrorGroups(
return map
})
const missingNodeCache = computed(() => {
const map = new Map<string, LGraphNode>()
const nodeTypes = executionErrorStore.missingNodesError?.nodeTypes ?? []
for (const nodeType of nodeTypes) {
if (typeof nodeType === 'string') continue
if (nodeType.nodeId == null) continue
const nodeId = String(nodeType.nodeId)
const node = getNodeByExecutionId(app.rootGraph, nodeId)
if (node) map.set(nodeId, node)
}
return map
})
function isErrorInSelection(executionNodeId: string): boolean {
const nodeIds = selectedNodeInfo.value.nodeIds
if (!nodeIds) return true
@@ -343,6 +385,173 @@ export function useErrorGroups(
)
}
// Async pack-ID resolution for missing node types that lack a cnrId
const asyncResolvedIds = ref<Map<string, string | null>>(new Map())
const pendingTypes = computed(() =>
(executionErrorStore.missingNodesError?.nodeTypes ?? []).filter(
(n): n is Exclude<MissingNodeType, string> =>
typeof n !== 'string' && !n.cnrId
)
)
watch(
pendingTypes,
async (pending, _, onCleanup) => {
const toResolve = pending.filter(
(n) => asyncResolvedIds.value.get(n.type) === undefined
)
if (!toResolve.length) return
const resolvingTypes = toResolve.map((n) => n.type)
let cancelled = false
onCleanup(() => {
cancelled = true
const next = new Map(asyncResolvedIds.value)
for (const type of resolvingTypes) {
if (next.get(type) === RESOLVING) next.delete(type)
}
asyncResolvedIds.value = next
})
const updated = new Map(asyncResolvedIds.value)
for (const type of resolvingTypes) updated.set(type, RESOLVING)
asyncResolvedIds.value = updated
const results = await Promise.allSettled(
toResolve.map(async (n) => ({
type: n.type,
packId: (await inferPackFromNodeName.call(n.type))?.id ?? null
}))
)
if (cancelled) return
const final = new Map(asyncResolvedIds.value)
for (const r of results) {
if (r.status === 'fulfilled') {
final.set(r.value.type, r.value.packId)
}
}
// Clear any remaining RESOLVING markers for failed lookups
for (const type of resolvingTypes) {
if (final.get(type) === RESOLVING) final.set(type, null)
}
asyncResolvedIds.value = final
},
{ immediate: true }
)
const missingPackGroups = computed<MissingPackGroup[]>(() => {
const nodeTypes = executionErrorStore.missingNodesError?.nodeTypes ?? []
const map = new Map<
string | null,
{ nodeTypes: MissingNodeType[]; isResolving: boolean }
>()
const resolvingKeys = new Set<string | null>()
for (const nodeType of nodeTypes) {
if (typeof nodeType !== 'string' && nodeType.isReplaceable) continue
let packId: string | null
if (typeof nodeType === 'string') {
packId = null
} else if (nodeType.cnrId) {
packId = nodeType.cnrId
} else {
const resolved = asyncResolvedIds.value.get(nodeType.type)
if (resolved === undefined || resolved === RESOLVING) {
packId = null
resolvingKeys.add(null)
} else {
packId = resolved
}
}
const existing = map.get(packId)
if (existing) {
existing.nodeTypes.push(nodeType)
} else {
map.set(packId, { nodeTypes: [nodeType], isResolving: false })
}
}
for (const key of resolvingKeys) {
const group = map.get(key)
if (group) group.isResolving = true
}
return Array.from(map.entries())
.sort(([packIdA], [packIdB]) => {
// null (Unknown Pack) always goes last
if (packIdA === null) return 1
if (packIdB === null) return -1
return packIdA.localeCompare(packIdB)
})
.map(([packId, { nodeTypes, isResolving }]) => ({
packId,
nodeTypes: [...nodeTypes].sort((a, b) => {
const typeA = typeof a === 'string' ? a : a.type
const typeB = typeof b === 'string' ? b : b.type
const typeCmp = typeA.localeCompare(typeB)
if (typeCmp !== 0) return typeCmp
const idA = typeof a === 'string' ? '' : String(a.nodeId ?? '')
const idB = typeof b === 'string' ? '' : String(b.nodeId ?? '')
return idA.localeCompare(idB, undefined, { numeric: true })
}),
isResolving
}))
})
const swapNodeGroups = computed<SwapNodeGroup[]>(() => {
const nodeTypes = executionErrorStore.missingNodesError?.nodeTypes ?? []
const map = new Map<string, SwapNodeGroup>()
for (const nodeType of nodeTypes) {
if (typeof nodeType === 'string' || !nodeType.isReplaceable) continue
const typeName = nodeType.type
const existing = map.get(typeName)
if (existing) {
existing.nodeTypes.push(nodeType)
} else {
map.set(typeName, {
type: typeName,
newNodeId: nodeType.replacement?.new_node_id,
nodeTypes: [nodeType]
})
}
}
return Array.from(map.values()).sort((a, b) => a.type.localeCompare(b.type))
})
/** Builds an ErrorGroup from missingNodesError. Returns [] when none present. */
function buildMissingNodeGroups(): ErrorGroup[] {
const error = executionErrorStore.missingNodesError
if (!error) return []
const groups: ErrorGroup[] = []
if (swapNodeGroups.value.length > 0) {
groups.push({
type: 'swap_nodes' as const,
title: st('nodeReplacement.swapNodesTitle', 'Swap Nodes'),
priority: 0
})
}
if (missingPackGroups.value.length > 0) {
groups.push({
type: 'missing_node' as const,
title: error.message,
priority: 1
})
}
return groups.sort((a, b) => a.priority - b.priority)
}
const allErrorGroups = computed<ErrorGroup[]>(() => {
const groupsMap = new Map<string, GroupEntry>()
@@ -350,7 +559,7 @@ export function useErrorGroups(
processNodeErrors(groupsMap)
processExecutionError(groupsMap)
return toSortedGroups(groupsMap)
return [...buildMissingNodeGroups(), ...toSortedGroups(groupsMap)]
})
const tabErrorGroups = computed<ErrorGroup[]>(() => {
@@ -360,9 +569,11 @@ export function useErrorGroups(
processNodeErrors(groupsMap, true)
processExecutionError(groupsMap, true)
return isSingleNodeSelected.value
const executionGroups = isSingleNodeSelected.value
? toSortedGroups(regroupByErrorMessage(groupsMap))
: toSortedGroups(groupsMap)
return [...buildMissingNodeGroups(), ...executionGroups]
})
const filteredGroups = computed<ErrorGroup[]>(() => {
@@ -373,10 +584,15 @@ export function useErrorGroups(
const groupedErrorMessages = computed<string[]>(() => {
const messages = new Set<string>()
for (const group of allErrorGroups.value) {
for (const card of group.cards) {
for (const err of card.errors) {
messages.add(err.message)
if (group.type === 'execution') {
for (const card of group.cards) {
for (const err of card.errors) {
messages.add(err.message)
}
}
} else {
// Groups without cards (e.g. missing_node) surface their title as the message.
messages.add(group.title)
}
}
return Array.from(messages)
@@ -389,6 +605,9 @@ export function useErrorGroups(
collapseState,
isSingleNodeSelected,
errorNodeCache,
groupedErrorMessages
missingNodeCache,
groupedErrorMessages,
missingPackGroups,
swapNodeGroups
}
}

View File

@@ -10,12 +10,14 @@ const {
label,
enableEmptyState,
tooltip,
size = 'default',
class: className
} = defineProps<{
disabled?: boolean
label?: string
enableEmptyState?: boolean
tooltip?: string
size?: 'default' | 'lg'
class?: string
}>()
@@ -39,7 +41,8 @@ const tooltipConfig = computed(() => {
type="button"
:class="
cn(
'group min-h-12 bg-transparent border-0 outline-0 ring-0 w-full text-left flex items-center justify-between pl-4 pr-3',
'group bg-transparent border-0 outline-0 ring-0 w-full text-left flex items-center justify-between pl-4 pr-3',
size === 'lg' ? 'min-h-16' : 'min-h-12',
!disabled && 'cursor-pointer'
)
"

View File

@@ -131,6 +131,12 @@ const nodeHasError = computed(() => {
return hasDirectError.value || hasContainerInternalError.value
})
const showSeeError = computed(
() =>
nodeHasError.value &&
useSettingStore().get('Comfy.RightSidePanel.ShowErrorsTab')
)
const parentGroup = computed<LGraphGroup | null>(() => {
if (!targetNode.value || !getNodeParentGroup) return null
return getNodeParentGroup(targetNode.value)
@@ -194,6 +200,7 @@ defineExpose({
:enable-empty-state
:disabled="isEmpty"
:tooltip
:size="showSeeError ? 'lg' : 'default'"
>
<template #label>
<div class="flex flex-wrap items-center gap-2 flex-1 min-w-0">
@@ -223,13 +230,10 @@ defineExpose({
</span>
</span>
<Button
v-if="
nodeHasError &&
useSettingStore().get('Comfy.RightSidePanel.ShowErrorsTab')
"
v-if="showSeeError"
variant="secondary"
size="sm"
class="shrink-0 rounded-lg text-sm"
class="shrink-0 rounded-lg text-sm h-8"
@click.stop="navigateToErrorTab"
>
{{ t('rightSidePanel.seeError') }}

View File

@@ -1,8 +1,9 @@
<script setup lang="ts">
import { storeToRefs } from 'pinia'
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { computed, onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
import DraggableList from '@/components/common/DraggableList.vue'
import Button from '@/components/ui/button/Button.vue'
import {
demoteWidget,
@@ -17,10 +18,10 @@ import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import FormSearchInput from '@/renderer/extensions/vueNodes/widgets/components/form/FormSearchInput.vue'
import { DraggableList } from '@/scripts/ui/draggableList'
import { useLitegraphService } from '@/services/litegraphService'
import { usePromotionStore } from '@/stores/promotionStore'
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
import { cn } from '@/utils/tailwindUtil'
import SubgraphNodeWidget from './SubgraphNodeWidget.vue'
@@ -30,9 +31,6 @@ const promotionStore = usePromotionStore()
const rightSidePanelStore = useRightSidePanelStore()
const { searchQuery } = storeToRefs(rightSidePanelStore)
const draggableList = ref<DraggableList | undefined>(undefined)
const draggableItems = ref()
const promotionEntries = computed(() => {
const node = activeNode.value
if (!node) return []
@@ -195,54 +193,9 @@ function showRecommended() {
}
}
function setDraggableState() {
draggableList.value?.dispose()
if (searchQuery.value || !draggableItems.value?.children?.length) return
draggableList.value = new DraggableList(
draggableItems.value,
'.draggable-item'
)
draggableList.value.applyNewItemsOrder = function () {
const reorderedItems = []
let oldPosition = -1
this.getAllItems().forEach((item, index) => {
if (item === this.draggableItem) {
oldPosition = index
return
}
if (!this.isItemToggled(item)) {
reorderedItems[index] = item
return
}
const newIndex = this.isItemAbove(item) ? index + 1 : index - 1
reorderedItems[newIndex] = item
})
for (let index = 0; index < this.getAllItems().length; index++) {
const item = reorderedItems[index]
if (typeof item === 'undefined') {
reorderedItems[index] = this.draggableItem
}
}
const newPosition = reorderedItems.indexOf(this.draggableItem)
const aw = activeWidgets.value
const [w] = aw.splice(oldPosition, 1)
aw.splice(newPosition, 0, w)
activeWidgets.value = aw
}
}
watch(filteredActive, () => {
setDraggableState()
})
onMounted(() => {
setDraggableState()
if (activeNode.value) pruneDisconnected(activeNode.value)
})
onBeforeUnmount(() => {
draggableList.value?.dispose()
})
</script>
<template>
@@ -280,19 +233,18 @@ onBeforeUnmount(() => {
{{ $t('subgraphStore.hideAll') }}</a
>
</div>
<div ref="draggableItems" class="pb-2 px-2 space-y-0.5 mt-0.5">
<DraggableList v-slot="{ dragClass }" v-model="activeWidgets">
<SubgraphNodeWidget
v-for="[node, widget] in filteredActive"
:key="toKey([node, widget])"
class="bg-comfy-menu-bg"
:class="cn(!searchQuery && dragClass, 'bg-comfy-menu-bg')"
:node-title="node.title"
:widget-name="widget.name"
:is-shown="true"
:is-draggable="!searchQuery"
:is-physical="node.id === -1"
:is-draggable="!searchQuery"
@toggle-visibility="demote([node, widget])"
/>
</div>
</DraggableList>
</div>
<div

View File

@@ -29,8 +29,7 @@ function getIcon() {
cn(
'flex py-1 px-2 break-all rounded items-center gap-1',
'bg-node-component-surface',
props.isDraggable &&
'draggable-item drag-handle cursor-grab [&.is-draggable]:cursor-grabbing hover:ring-1 ring-accent-background',
props.isDraggable && 'hover:ring-1 ring-accent-background',
props.class
)
"

View File

@@ -106,4 +106,42 @@ describe('AssetsSidebarListView', () => {
expect(assetListItem?.props('previewUrl')).toBe('')
expect(assetListItem?.props('isVideoPreview')).toBe(false)
})
it('emits preview-asset when item preview is clicked', async () => {
const imageAsset = {
...buildAsset('image-asset', 'image.png'),
preview_url: '/api/view/image.png',
user_metadata: {}
} satisfies AssetItem
const wrapper = mountListView([buildOutputItem(imageAsset)])
const listItems = wrapper.findAllComponents({ name: 'AssetsListItem' })
const assetListItem = listItems.at(-1)
expect(assetListItem).toBeDefined()
assetListItem!.vm.$emit('preview-click')
await wrapper.vm.$nextTick()
expect(wrapper.emitted('preview-asset')).toEqual([[imageAsset]])
})
it('emits preview-asset when item is double-clicked', async () => {
const imageAsset = {
...buildAsset('image-asset-dbl', 'image.png'),
preview_url: '/api/view/image.png',
user_metadata: {}
} satisfies AssetItem
const wrapper = mountListView([buildOutputItem(imageAsset)])
const listItems = wrapper.findAllComponents({ name: 'AssetsListItem' })
const assetListItem = listItems.at(-1)
expect(assetListItem).toBeDefined()
await assetListItem!.trigger('dblclick')
await wrapper.vm.$nextTick()
expect(wrapper.emitted('preview-asset')).toEqual([[imageAsset]])
})
})

View File

@@ -56,6 +56,8 @@
@mouseleave="onAssetLeave(item.asset.id)"
@contextmenu.prevent.stop="emit('context-menu', $event, item.asset)"
@click.stop="emit('select-asset', item.asset, selectableAssets)"
@dblclick.stop="emit('preview-asset', item.asset)"
@preview-click="emit('preview-asset', item.asset)"
@stack-toggle="void toggleStack(item.asset)"
>
<template v-if="hoveredAssetId === item.asset.id" #actions>
@@ -116,6 +118,7 @@ const assetsStore = useAssetsStore()
const emit = defineEmits<{
(e: 'select-asset', asset: AssetItem, assets?: AssetItem[]): void
(e: 'preview-asset', asset: AssetItem): void
(e: 'context-menu', event: MouseEvent, asset: AssetItem): void
(e: 'approach-end'): void
}>()

View File

@@ -95,6 +95,7 @@
:toggle-stack="toggleListViewStack"
:asset-type="activeTab"
@select-asset="handleAssetSelect"
@preview-asset="handleZoomClick"
@context-menu="handleAssetContextMenu"
@approach-end="handleApproachEnd"
/>
@@ -216,10 +217,6 @@ import {
import { useI18n } from 'vue-i18n'
import NoResultsPlaceholder from '@/components/common/NoResultsPlaceholder.vue'
// Lazy-loaded to avoid pulling THREE.js into the main bundle
const Load3dViewerContent = defineAsyncComponent(
() => import('@/components/load3d/Load3dViewerContent.vue')
)
import AssetsSidebarGridView from '@/components/sidebar/tabs/AssetsSidebarGridView.vue'
import AssetsSidebarListView from '@/components/sidebar/tabs/AssetsSidebarListView.vue'
import SidebarTabTemplate from '@/components/sidebar/tabs/SidebarTabTemplate.vue'
@@ -251,6 +248,10 @@ import {
} from '@/utils/formatUtil'
import { cn } from '@/utils/tailwindUtil'
const Load3dViewerContent = defineAsyncComponent(
() => import('@/components/load3d/Load3dViewerContent.vue')
)
const { t } = useI18n()
const emit = defineEmits<{ assetSelected: [asset: AssetItem] }>()

View File

@@ -67,7 +67,7 @@
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import { computed, defineAsyncComponent, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import JobFilterActions from '@/components/queue/job/JobFilterActions.vue'
@@ -86,11 +86,17 @@ import SidebarTabTemplate from '@/components/sidebar/tabs/SidebarTabTemplate.vue
import ResultGallery from '@/components/sidebar/tabs/queue/ResultGallery.vue'
import Button from '@/components/ui/button/Button.vue'
import { useCommandStore } from '@/stores/commandStore'
import { useDialogStore } from '@/stores/dialogStore'
import { useExecutionStore } from '@/stores/executionStore'
import { useQueueStore } from '@/stores/queueStore'
const Load3dViewerContent = defineAsyncComponent(
() => import('@/components/load3d/Load3dViewerContent.vue')
)
const { t, n } = useI18n()
const commandStore = useCommandStore()
const dialogStore = useDialogStore()
const executionStore = useExecutionStore()
const queueStore = useQueueStore()
const { showQueueClearHistoryDialog } = useQueueClearHistoryDialog()
@@ -151,6 +157,24 @@ const {
} = useResultGallery(() => filteredTasks.value)
const onViewItem = wrapWithErrorHandlingAsync(async (item: JobListItem) => {
const previewOutput = item.taskRef?.previewOutput
if (previewOutput?.is3D) {
dialogStore.showDialog({
key: 'asset-3d-viewer',
title: item.title,
component: Load3dViewerContent,
props: {
modelUrl: previewOutput.url || ''
},
dialogComponentProps: {
style: 'width: 80vw; height: 80vh;',
maximizable: true
}
})
return
}
await openResultGallery(item)
})

View File

@@ -3,7 +3,7 @@
ref="containerRef"
:class="
cn(
'comfy-vue-side-bar-container group/sidebar-tab flex h-full flex-col',
'comfy-vue-side-bar-container group/sidebar-tab flex h-full flex-col w-full',
props.class
)
"

View File

@@ -10,7 +10,7 @@
@click="handleClick"
>
<i
v-if="workflowOption.workflow.activeState?.extra?.linearMode"
v-if="workflowOption.workflow.initialMode === 'app'"
class="icon-[lucide--panels-top-left] bg-primary-background"
/>
<span

View File

@@ -25,15 +25,19 @@ whenever(feedbackRef, () => {
:href="`https://form.typeform.com/to/${dataTfWidget}`"
target="_blank"
variant="inverted"
class="rounded-full size-12"
class="flex h-10 items-center justify-center gap-2.5 px-3 py-2"
v-bind="$attrs"
>
<i class="icon-[lucide--circle-question-mark] size-6" />
<i class="icon-[lucide--circle-help] size-4" />
</Button>
<Popover v-else>
<template #button>
<Button variant="inverted" class="rounded-full size-12" v-bind="$attrs">
<i class="icon-[lucide--circle-question-mark] size-6" />
<Button
variant="inverted"
class="flex h-10 items-center justify-center gap-2.5 px-3 py-2"
v-bind="$attrs"
>
<i class="icon-[lucide--circle-help] size-4" />
</Button>
</template>
<div ref="feedbackRef" data-tf-auto-resize :data-tf-widget />

View File

@@ -0,0 +1,224 @@
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'
import { useDomClipping } from './useDomClipping'
function createMockElement(rect: {
left: number
top: number
width: number
height: number
}): HTMLElement {
return {
getBoundingClientRect: vi.fn(
() =>
({
...rect,
x: rect.left,
y: rect.top,
right: rect.left + rect.width,
bottom: rect.top + rect.height,
toJSON: () => ({})
}) as DOMRect
)
} as unknown as HTMLElement
}
function createMockCanvas(rect: {
left: number
top: number
width: number
height: number
}): HTMLCanvasElement {
return {
getBoundingClientRect: vi.fn(
() =>
({
...rect,
x: rect.left,
y: rect.top,
right: rect.left + rect.width,
bottom: rect.top + rect.height,
toJSON: () => ({})
}) as DOMRect
)
} as unknown as HTMLCanvasElement
}
describe('useDomClipping', () => {
let rafCallbacks: Map<number, FrameRequestCallback>
let nextRafId: number
beforeEach(() => {
rafCallbacks = new Map()
nextRafId = 1
vi.stubGlobal(
'requestAnimationFrame',
vi.fn((cb: FrameRequestCallback) => {
const id = nextRafId++
rafCallbacks.set(id, cb)
return id
})
)
vi.stubGlobal(
'cancelAnimationFrame',
vi.fn((id: number) => {
rafCallbacks.delete(id)
})
)
})
afterEach(() => {
vi.restoreAllMocks()
})
function flushRaf() {
const callbacks = [...rafCallbacks.values()]
rafCallbacks.clear()
for (const cb of callbacks) {
cb(performance.now())
}
}
it('coalesces multiple rapid calls into a single getBoundingClientRect read', () => {
const { updateClipPath } = useDomClipping()
const element = createMockElement({
left: 10,
top: 10,
width: 100,
height: 50
})
const canvas = createMockCanvas({
left: 0,
top: 0,
width: 800,
height: 600
})
updateClipPath(element, canvas, true)
updateClipPath(element, canvas, true)
updateClipPath(element, canvas, true)
expect(element.getBoundingClientRect).not.toHaveBeenCalled()
flushRaf()
expect(element.getBoundingClientRect).toHaveBeenCalledTimes(1)
expect(canvas.getBoundingClientRect).toHaveBeenCalledTimes(1)
})
it('updates style ref after RAF fires', () => {
const { style, updateClipPath } = useDomClipping()
const element = createMockElement({
left: 10,
top: 10,
width: 100,
height: 50
})
const canvas = createMockCanvas({
left: 0,
top: 0,
width: 800,
height: 600
})
updateClipPath(element, canvas, true)
expect(style.value).toEqual({})
flushRaf()
expect(style.value).toEqual({
clipPath: 'none',
willChange: 'clip-path'
})
})
it('cancels previous RAF when called again before it fires', () => {
const { style, updateClipPath } = useDomClipping()
const element1 = createMockElement({
left: 10,
top: 10,
width: 100,
height: 50
})
const element2 = createMockElement({
left: 20,
top: 20,
width: 200,
height: 100
})
const canvas = createMockCanvas({
left: 0,
top: 0,
width: 800,
height: 600
})
updateClipPath(element1, canvas, true)
updateClipPath(element2, canvas, true)
expect(cancelAnimationFrame).toHaveBeenCalledTimes(1)
flushRaf()
expect(element1.getBoundingClientRect).not.toHaveBeenCalled()
expect(element2.getBoundingClientRect).toHaveBeenCalledTimes(1)
expect(style.value).toEqual({
clipPath: 'none',
willChange: 'clip-path'
})
})
it('generates clip-path polygon when element intersects unselected area', () => {
const { style, updateClipPath } = useDomClipping()
const element = createMockElement({
left: 50,
top: 50,
width: 100,
height: 100
})
const canvas = createMockCanvas({
left: 0,
top: 0,
width: 800,
height: 600
})
const selectedArea = {
x: 40,
y: 40,
width: 200,
height: 200,
scale: 1,
offset: [0, 0] as [number, number]
}
updateClipPath(element, canvas, false, selectedArea)
flushRaf()
expect(style.value.clipPath).toContain('polygon')
expect(style.value.willChange).toBe('clip-path')
})
it('does not read layout before RAF fires', () => {
const { updateClipPath } = useDomClipping()
const element = createMockElement({
left: 0,
top: 0,
width: 50,
height: 50
})
const canvas = createMockCanvas({
left: 0,
top: 0,
width: 800,
height: 600
})
updateClipPath(element, canvas, true)
expect(element.getBoundingClientRect).not.toHaveBeenCalled()
expect(canvas.getBoundingClientRect).not.toHaveBeenCalled()
})
})

View File

@@ -85,8 +85,12 @@ export const useDomClipping = (options: ClippingOptions = {}) => {
return ''
}
let pendingRaf = 0
/**
* Updates the clip-path style based on element and selection information
* Updates the clip-path style based on element and selection information.
* Batched via requestAnimationFrame to avoid forcing synchronous layout
* from getBoundingClientRect() on every reactive state change.
*/
const updateClipPath = (
element: HTMLElement,
@@ -101,20 +105,24 @@ export const useDomClipping = (options: ClippingOptions = {}) => {
offset: [number, number]
}
) => {
const elementRect = element.getBoundingClientRect()
const canvasRect = canvasElement.getBoundingClientRect()
if (pendingRaf) cancelAnimationFrame(pendingRaf)
pendingRaf = requestAnimationFrame(() => {
pendingRaf = 0
const elementRect = element.getBoundingClientRect()
const canvasRect = canvasElement.getBoundingClientRect()
const clipPath = calculateClipPath(
elementRect,
canvasRect,
isSelected,
selectedArea
)
const clipPath = calculateClipPath(
elementRect,
canvasRect,
isSelected,
selectedArea
)
style.value = {
clipPath: clipPath || 'none',
willChange: 'clip-path'
}
style.value = {
clipPath: clipPath || 'none',
willChange: 'clip-path'
}
})
}
return {

View File

@@ -4,6 +4,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import { computed, nextTick, watch } from 'vue'
import { useGraphNodeManager } from '@/composables/graph/useGraphNodeManager'
import { createPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetView'
import { BaseWidget, LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
import {
createTestSubgraph,
@@ -82,6 +83,163 @@ describe('Node Reactivity', () => {
})
})
describe('Widget slotMetadata reactivity on link disconnect', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
})
function createWidgetInputGraph() {
const graph = new LGraph()
const node = new LGraphNode('test')
// Add a widget and an associated input slot (simulates "widget converted to input")
node.addWidget('string', 'prompt', 'hello', () => undefined, {})
const input = node.addInput('prompt', 'STRING')
// Associate the input slot with the widget (as widgetInputs extension does)
input.widget = { name: 'prompt' }
// Start with a connected link
input.link = 42
graph.add(node)
return { graph, node }
}
it('sets slotMetadata.linked to true when input has a link', () => {
const { graph, node } = createWidgetInputGraph()
const { vueNodeData } = useGraphNodeManager(graph)
const nodeData = vueNodeData.get(String(node.id))
const widgetData = nodeData?.widgets?.find((w) => w.name === 'prompt')
expect(widgetData?.slotMetadata).toBeDefined()
expect(widgetData?.slotMetadata?.linked).toBe(true)
})
it('updates slotMetadata.linked to false after link disconnect event', async () => {
const { graph, node } = createWidgetInputGraph()
const { vueNodeData } = useGraphNodeManager(graph)
const nodeData = vueNodeData.get(String(node.id))
const widgetData = nodeData?.widgets?.find((w) => w.name === 'prompt')
// Verify initially linked
expect(widgetData?.slotMetadata?.linked).toBe(true)
// Simulate link disconnection (as LiteGraph does before firing the event)
node.inputs[0].link = null
// Fire the trigger event that LiteGraph fires on disconnect
graph.trigger('node:slot-links:changed', {
nodeId: node.id,
slotType: NodeSlotType.INPUT,
slotIndex: 0,
connected: false,
linkId: 42
})
await nextTick()
// slotMetadata.linked should now be false
expect(widgetData?.slotMetadata?.linked).toBe(false)
})
it('reactively updates disabled state in a derived computed after disconnect', async () => {
const { graph, node } = createWidgetInputGraph()
const { vueNodeData } = useGraphNodeManager(graph)
const nodeData = vueNodeData.get(String(node.id))!
// Mimic what processedWidgets does in NodeWidgets.vue:
// derive disabled from slotMetadata.linked
const derivedDisabled = computed(() => {
const widgets = nodeData.widgets ?? []
const widget = widgets.find((w) => w.name === 'prompt')
return widget?.slotMetadata?.linked ? true : false
})
// Initially linked → disabled
expect(derivedDisabled.value).toBe(true)
// Track changes
const onChange = vi.fn()
watch(derivedDisabled, onChange)
// Simulate disconnect
node.inputs[0].link = null
graph.trigger('node:slot-links:changed', {
nodeId: node.id,
slotType: NodeSlotType.INPUT,
slotIndex: 0,
connected: false,
linkId: 42
})
await nextTick()
// The derived computed should now return false
expect(derivedDisabled.value).toBe(false)
expect(onChange).toHaveBeenCalledTimes(1)
})
it('updates slotMetadata for promoted widgets where SafeWidgetData.name differs from input.widget.name', async () => {
// Set up a subgraph with an interior node that has a "prompt" widget.
// createPromotedWidgetView resolves against this interior node.
const subgraph = createTestSubgraph()
const interiorNode = new LGraphNode('interior')
interiorNode.id = 10
interiorNode.addWidget('string', 'prompt', 'hello', () => undefined, {})
subgraph.add(interiorNode)
const subgraphNode = createTestSubgraphNode(subgraph, { id: 123 })
// Create a PromotedWidgetView with displayName="value" (subgraph input
// slot name) and sourceWidgetName="prompt" (interior widget name).
// PromotedWidgetView.name returns "value", but safeWidgetMapper sets
// SafeWidgetData.name to sourceWidgetName ("prompt").
const promotedView = createPromotedWidgetView(
subgraphNode,
'10',
'prompt',
'value'
)
// Host the promoted view on a regular node so we can control widgets
// directly (SubgraphNode.widgets is a synthetic getter).
const graph = new LGraph()
const hostNode = new LGraphNode('host')
hostNode.widgets = [promotedView]
const input = hostNode.addInput('value', 'STRING')
input.widget = { name: 'value' }
input.link = 42
graph.add(hostNode)
const { vueNodeData } = useGraphNodeManager(graph)
const nodeData = vueNodeData.get(String(hostNode.id))
// SafeWidgetData.name is "prompt" (sourceWidgetName), but the
// input slot widget name is "value" — slotName bridges this gap.
const widgetData = nodeData?.widgets?.find((w) => w.name === 'prompt')
expect(widgetData).toBeDefined()
expect(widgetData?.slotName).toBe('value')
expect(widgetData?.slotMetadata?.linked).toBe(true)
// Disconnect
hostNode.inputs[0].link = null
graph.trigger('node:slot-links:changed', {
nodeId: hostNode.id,
slotType: NodeSlotType.INPUT,
slotIndex: 0,
connected: false,
linkId: 42
})
await nextTick()
expect(widgetData?.slotMetadata?.linked).toBe(false)
})
})
describe('Subgraph Promoted Pseudo Widgets', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))

View File

@@ -14,6 +14,7 @@ import type {
} from '@/lib/litegraph/src/interfaces'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMutations'
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
import { LayoutSource } from '@/renderer/core/layout/types'
import type { NodeId } from '@/renderer/core/layout/types'
import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
@@ -69,6 +70,12 @@ export interface SafeWidgetData {
spec?: InputSpec
/** Input slot metadata (index and link status) */
slotMetadata?: WidgetSlotMetadata
/**
* Original LiteGraph widget name used for slot metadata matching.
* For promoted widgets, `name` is `sourceWidgetName` (interior widget name)
* which differs from the subgraph node's input slot widget name.
*/
slotName?: string
}
export interface VueNodeData {
@@ -238,7 +245,8 @@ function safeWidgetMapper(
options: isPromotedPseudoWidget
? { ...options, canvasOnly: true }
: options,
slotMetadata: slotInfo
slotMetadata: slotInfo,
slotName: name !== widget.name ? widget.name : undefined
}
} catch (error) {
return {
@@ -376,7 +384,7 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
// Update only widgets with new slot metadata, keeping other widget data intact
for (const widget of currentData.widgets ?? []) {
const slotInfo = slotMetadata.get(widget.name)
const slotInfo = slotMetadata.get(widget.slotName ?? widget.name)
if (slotInfo) widget.slotMetadata = slotInfo
}
}
@@ -435,6 +443,11 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
const nodePosition = { x: node.pos[0], y: node.pos[1] }
const nodeSize = { width: node.size[0], height: node.size[1] }
// Skip layout creation if it already exists
// (e.g. in-place node replacement where the old node's layout is reused for the new node with the same ID).
const existingLayout = layoutStore.getNodeLayoutRef(id).value
if (existingLayout) return
// Add node to layout store with final positions
setSource(LayoutSource.Canvas)
void createNode(id, {

View File

@@ -2,7 +2,11 @@ import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { useImagePreviewWidget } from '@/renderer/extensions/vueNodes/widgets/composables/useImagePreviewWidget'
export const CANVAS_IMAGE_PREVIEW_WIDGET = '$$canvas-image-preview'
const CANVAS_IMAGE_PREVIEW_NODE_TYPES = new Set(['PreviewImage', 'SaveImage'])
const CANVAS_IMAGE_PREVIEW_NODE_TYPES = new Set([
'PreviewImage',
'SaveImage',
'GLSLShader'
])
export function supportsVirtualCanvasImagePreview(node: LGraphNode): boolean {
return CANVAS_IMAGE_PREVIEW_NODE_TYPES.has(node.type)

View File

@@ -0,0 +1,175 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
const { mockFetchApi, mockAddAlert, mockUpdateInputs } = vi.hoisted(() => ({
mockFetchApi: vi.fn(),
mockAddAlert: vi.fn(),
mockUpdateInputs: vi.fn()
}))
let capturedDragOnDrop: (files: File[]) => Promise<string[]>
vi.mock('@/composables/node/useNodeDragAndDrop', () => ({
useNodeDragAndDrop: (
_node: LGraphNode,
opts: { onDrop: typeof capturedDragOnDrop }
) => {
capturedDragOnDrop = opts.onDrop
}
}))
vi.mock('@/composables/node/useNodeFileInput', () => ({
useNodeFileInput: () => ({ openFileSelection: vi.fn() })
}))
vi.mock('@/composables/node/useNodePaste', () => ({
useNodePaste: vi.fn()
}))
vi.mock('@/i18n', () => ({
t: (key: string) => key
}))
vi.mock('@/platform/updates/common/toastStore', () => ({
useToastStore: () => ({ addAlert: mockAddAlert })
}))
vi.mock('@/scripts/api', () => ({
api: { fetchApi: mockFetchApi }
}))
vi.mock('@/stores/assetsStore', () => ({
useAssetsStore: () => ({ updateInputs: mockUpdateInputs })
}))
function createMockNode(): LGraphNode {
return {
isUploading: false,
imgs: [new Image()],
graph: { setDirtyCanvas: vi.fn() },
size: [300, 400]
} as unknown as LGraphNode
}
function createFile(name = 'test.png'): File {
return new File(['data'], name, { type: 'image/png' })
}
function successResponse(name: string, subfolder?: string) {
return {
status: 200,
json: () => Promise.resolve({ name, subfolder })
}
}
function failResponse(status = 500) {
return {
status,
statusText: 'Server Error'
}
}
describe('useNodeImageUpload', () => {
let node: LGraphNode
let onUploadComplete: (paths: string[]) => void
let onUploadStart: (files: File[]) => void
let onUploadError: () => void
beforeEach(async () => {
vi.resetModules()
vi.clearAllMocks()
node = createMockNode()
onUploadComplete = vi.fn()
onUploadStart = vi.fn()
onUploadError = vi.fn()
const { useNodeImageUpload } = await import('./useNodeImageUpload')
useNodeImageUpload(node, {
onUploadComplete,
onUploadStart,
onUploadError,
folder: 'input'
})
})
it('sets isUploading true during upload and false after', async () => {
mockFetchApi.mockResolvedValueOnce(successResponse('test.png'))
const promise = capturedDragOnDrop([createFile()])
expect(node.isUploading).toBe(true)
await promise
expect(node.isUploading).toBe(false)
})
it('clears node.imgs on upload start', async () => {
mockFetchApi.mockResolvedValueOnce(successResponse('test.png'))
const promise = capturedDragOnDrop([createFile()])
expect(node.imgs).toBeUndefined()
await promise
})
it('calls onUploadStart with files', async () => {
mockFetchApi.mockResolvedValueOnce(successResponse('test.png'))
const files = [createFile()]
await capturedDragOnDrop(files)
expect(onUploadStart).toHaveBeenCalledWith(files)
})
it('calls onUploadComplete with valid paths on success', async () => {
mockFetchApi.mockResolvedValueOnce(successResponse('test.png'))
await capturedDragOnDrop([createFile()])
expect(onUploadComplete).toHaveBeenCalledWith(['test.png'])
})
it('includes subfolder in returned path', async () => {
mockFetchApi.mockResolvedValueOnce(successResponse('test.png', 'pasted'))
await capturedDragOnDrop([createFile()])
expect(onUploadComplete).toHaveBeenCalledWith(['pasted/test.png'])
})
it('calls onUploadError when all uploads fail', async () => {
mockFetchApi.mockResolvedValueOnce(failResponse())
await capturedDragOnDrop([createFile()])
expect(onUploadError).toHaveBeenCalled()
expect(onUploadComplete).not.toHaveBeenCalled()
})
it('resets isUploading even when upload fails', async () => {
mockFetchApi.mockRejectedValueOnce(new Error('Network error'))
await capturedDragOnDrop([createFile()])
expect(node.isUploading).toBe(false)
})
it('rejects concurrent uploads with a toast', async () => {
mockFetchApi.mockImplementation(
() =>
new Promise((resolve) =>
setTimeout(() => resolve(successResponse('a.png')), 50)
)
)
const first = capturedDragOnDrop([createFile('a.png')])
const second = await capturedDragOnDrop([createFile('b.png')])
expect(second).toEqual([])
expect(mockAddAlert).toHaveBeenCalledWith('g.uploadAlreadyInProgress')
await first
})
it('calls setDirtyCanvas on start and finish', async () => {
mockFetchApi.mockResolvedValueOnce(successResponse('test.png'))
await capturedDragOnDrop([createFile()])
expect(node.graph?.setDirtyCanvas).toHaveBeenCalledTimes(2)
})
})

View File

@@ -1,6 +1,7 @@
import { useNodeDragAndDrop } from '@/composables/node/useNodeDragAndDrop'
import { useNodeFileInput } from '@/composables/node/useNodeFileInput'
import { useNodePaste } from '@/composables/node/useNodePaste'
import { t } from '@/i18n'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { useToastStore } from '@/platform/updates/common/toastStore'
import type { ResultItemType } from '@/schemas/apiSchema'
@@ -62,6 +63,8 @@ interface ImageUploadOptions {
* @example 'input', 'output', 'temp'
*/
folder?: ResultItemType
onUploadStart?: (files: File[]) => void
onUploadError?: () => void
}
/**
@@ -90,10 +93,29 @@ export const useNodeImageUpload = (
}
const handleUploadBatch = async (files: File[]) => {
const paths = await Promise.all(files.map(handleUpload))
const validPaths = paths.filter((p): p is string => !!p)
if (validPaths.length) onUploadComplete(validPaths)
return validPaths
if (node.isUploading) {
useToastStore().addAlert(t('g.uploadAlreadyInProgress'))
return []
}
node.isUploading = true
try {
node.imgs = undefined
node.graph?.setDirtyCanvas(true)
options.onUploadStart?.(files)
const paths = await Promise.all(files.map(handleUpload))
const validPaths = paths.filter((p): p is string => !!p)
if (validPaths.length) {
onUploadComplete(validPaths)
} else {
options.onUploadError?.()
}
return validPaths
} finally {
node.isUploading = false
node.graph?.setDirtyCanvas(true)
}
}
// Handle drag & drop

View File

@@ -0,0 +1,780 @@
import type { Ref } from 'vue'
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { useElementSize } from '@vueuse/core'
import { useI18n } from 'vue-i18n'
import {
getEffectiveBrushSize,
getEffectiveHardness
} from '@/composables/maskeditor/brushUtils'
import { StrokeProcessor } from '@/composables/maskeditor/StrokeProcessor'
import { hexToRgb } from '@/utils/colorUtil'
import type { Point } from '@/extensions/core/maskeditor/types'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import { isCloud } from '@/platform/distribution/types'
import { useToastStore } from '@/platform/updates/common/toastStore'
import { api } from '@/scripts/api'
import { app } from '@/scripts/app'
import { useNodeOutputStore } from '@/stores/imagePreviewStore'
type PainterTool = 'brush' | 'eraser'
export const PAINTER_TOOLS: Record<string, PainterTool> = {
BRUSH: 'brush',
ERASER: 'eraser'
} as const
interface UsePainterOptions {
canvasEl: Ref<HTMLCanvasElement | null>
modelValue: Ref<string>
}
export function usePainter(nodeId: string, options: UsePainterOptions) {
const { canvasEl, modelValue } = options
const { t } = useI18n()
const nodeOutputStore = useNodeOutputStore()
const toastStore = useToastStore()
const isDirty = ref(false)
const canvasWidth = ref(512)
const canvasHeight = ref(512)
const cursorX = ref(0)
const cursorY = ref(0)
const cursorVisible = ref(false)
const inputImageUrl = ref<string | null>(null)
const isImageInputConnected = ref(false)
let isDrawing = false
let strokeProcessor: StrokeProcessor | null = null
let lastPoint: Point | null = null
let cachedRect: DOMRect | null = null
let mainCtx: CanvasRenderingContext2D | null = null
let strokeCanvas: HTMLCanvasElement | null = null
let strokeCtx: CanvasRenderingContext2D | null = null
let baseCanvas: HTMLCanvasElement | null = null
let baseCtx: CanvasRenderingContext2D | null = null
let hasBaseSnapshot = false
let hasStrokes = false
let dirtyX0 = 0
let dirtyY0 = 0
let dirtyX1 = 0
let dirtyY1 = 0
let hasDirtyRect = false
let strokeBrush: {
radius: number
effectiveRadius: number
effectiveHardness: number
hardness: number
r: number
g: number
b: number
} | null = null
const litegraphNode = computed(() => {
if (!nodeId || !app.canvas.graph) return null
return app.canvas.graph.getNodeById(nodeId) as LGraphNode | null
})
function getWidgetByName(name: string): IBaseWidget | undefined {
return litegraphNode.value?.widgets?.find(
(w: IBaseWidget) => w.name === name
)
}
const tool = ref<PainterTool>(PAINTER_TOOLS.BRUSH)
const brushSize = ref(20)
const brushColor = ref('#ffffff')
const brushOpacity = ref(1)
const brushHardness = ref(1)
const backgroundColor = ref('#000000')
function restoreSettingsFromProperties() {
const node = litegraphNode.value
if (!node) return
const props = node.properties
if (props.painterTool != null) tool.value = props.painterTool as PainterTool
if (props.painterBrushSize != null)
brushSize.value = props.painterBrushSize as number
if (props.painterBrushColor != null)
brushColor.value = props.painterBrushColor as string
if (props.painterBrushOpacity != null)
brushOpacity.value = props.painterBrushOpacity as number
if (props.painterBrushHardness != null)
brushHardness.value = props.painterBrushHardness as number
const bgColorWidget = getWidgetByName('bg_color')
if (bgColorWidget) backgroundColor.value = bgColorWidget.value as string
}
function saveSettingsToProperties() {
const node = litegraphNode.value
if (!node) return
node.properties.painterTool = tool.value
node.properties.painterBrushSize = brushSize.value
node.properties.painterBrushColor = brushColor.value
node.properties.painterBrushOpacity = brushOpacity.value
node.properties.painterBrushHardness = brushHardness.value
}
function syncCanvasSizeToWidgets() {
const widthWidget = getWidgetByName('width')
const heightWidget = getWidgetByName('height')
if (widthWidget && widthWidget.value !== canvasWidth.value) {
widthWidget.value = canvasWidth.value
widthWidget.callback?.(canvasWidth.value)
}
if (heightWidget && heightWidget.value !== canvasHeight.value) {
heightWidget.value = canvasHeight.value
heightWidget.callback?.(canvasHeight.value)
}
}
function syncBackgroundColorToWidget() {
const bgColorWidget = getWidgetByName('bg_color')
if (bgColorWidget && bgColorWidget.value !== backgroundColor.value) {
bgColorWidget.value = backgroundColor.value
bgColorWidget.callback?.(backgroundColor.value)
}
}
function updateInputImageUrl() {
const node = litegraphNode.value
if (!node) {
inputImageUrl.value = null
isImageInputConnected.value = false
return
}
isImageInputConnected.value = node.isInputConnected(0)
const inputNode = node.getInputNode(0)
if (!inputNode) {
inputImageUrl.value = null
return
}
const urls = nodeOutputStore.getNodeImageUrls(inputNode)
inputImageUrl.value = urls?.length ? urls[0] : null
}
function syncCanvasSizeFromWidgets() {
const w = getWidgetByName('width')
const h = getWidgetByName('height')
canvasWidth.value = (w?.value as number) ?? 512
canvasHeight.value = (h?.value as number) ?? 512
}
function activeHardness(): number {
return tool.value === PAINTER_TOOLS.ERASER ? 1 : brushHardness.value
}
const { width: canvasDisplayWidth } = useElementSize(canvasEl)
const displayBrushSize = computed(() => {
if (!canvasDisplayWidth.value || !canvasWidth.value) return brushSize.value
const radius = brushSize.value / 2
const effectiveRadius = getEffectiveBrushSize(radius, activeHardness())
const effectiveDiameter = effectiveRadius * 2
return effectiveDiameter * (canvasDisplayWidth.value / canvasWidth.value)
})
function getCtx() {
if (!mainCtx && canvasEl.value) {
mainCtx = canvasEl.value.getContext('2d') ?? null
}
return mainCtx
}
function cacheCanvasRect() {
const el = canvasEl.value
if (el) cachedRect = el.getBoundingClientRect()
}
function getCanvasPoint(e: PointerEvent): Point | null {
const el = canvasEl.value
if (!el) return null
const rect = cachedRect ?? el.getBoundingClientRect()
return {
x: ((e.clientX - rect.left) / rect.width) * el.width,
y: ((e.clientY - rect.top) / rect.height) * el.height
}
}
function expandDirtyRect(cx: number, cy: number, r: number) {
const x0 = cx - r
const y0 = cy - r
const x1 = cx + r
const y1 = cy + r
if (!hasDirtyRect) {
dirtyX0 = x0
dirtyY0 = y0
dirtyX1 = x1
dirtyY1 = y1
hasDirtyRect = true
} else {
if (x0 < dirtyX0) dirtyX0 = x0
if (y0 < dirtyY0) dirtyY0 = y0
if (x1 > dirtyX1) dirtyX1 = x1
if (y1 > dirtyY1) dirtyY1 = y1
}
}
function snapshotBrush() {
const radius = brushSize.value / 2
const hardness = activeHardness()
const effectiveRadius = getEffectiveBrushSize(radius, hardness)
strokeBrush = {
radius,
effectiveRadius,
effectiveHardness: getEffectiveHardness(
radius,
hardness,
effectiveRadius
),
hardness,
...hexToRgb(brushColor.value)
}
}
function drawCircle(ctx: CanvasRenderingContext2D, point: Point) {
const b = strokeBrush!
expandDirtyRect(point.x, point.y, b.effectiveRadius)
ctx.beginPath()
ctx.arc(point.x, point.y, b.effectiveRadius, 0, Math.PI * 2)
if (b.hardness < 1) {
const gradient = ctx.createRadialGradient(
point.x,
point.y,
0,
point.x,
point.y,
b.effectiveRadius
)
gradient.addColorStop(0, `rgba(${b.r}, ${b.g}, ${b.b}, 1)`)
gradient.addColorStop(
b.effectiveHardness,
`rgba(${b.r}, ${b.g}, ${b.b}, 1)`
)
gradient.addColorStop(1, `rgba(${b.r}, ${b.g}, ${b.b}, 0)`)
ctx.fillStyle = gradient
}
ctx.fill()
}
function drawSegment(ctx: CanvasRenderingContext2D, from: Point, to: Point) {
const b = strokeBrush!
if (b.hardness < 1) {
const dx = to.x - from.x
const dy = to.y - from.y
const dist = Math.hypot(dx, dy)
const step = Math.max(1, b.effectiveRadius / 2)
if (dist > 0) {
const steps = Math.ceil(dist / step)
const dabPoint: Point = { x: 0, y: 0 }
for (let i = 1; i <= steps; i++) {
const t = i / steps
dabPoint.x = from.x + dx * t
dabPoint.y = from.y + dy * t
drawCircle(ctx, dabPoint)
}
}
} else {
expandDirtyRect(from.x, from.y, b.effectiveRadius)
ctx.beginPath()
ctx.moveTo(from.x, from.y)
ctx.lineTo(to.x, to.y)
ctx.stroke()
drawCircle(ctx, to)
}
}
function applyBrushStyle(ctx: CanvasRenderingContext2D) {
const b = strokeBrush!
const color = `rgb(${b.r}, ${b.g}, ${b.b})`
ctx.globalCompositeOperation = 'source-over'
ctx.globalAlpha = 1
ctx.fillStyle = color
ctx.strokeStyle = color
ctx.lineWidth = b.effectiveRadius * 2
ctx.lineCap = 'round'
ctx.lineJoin = 'round'
}
function ensureStrokeCanvas() {
const el = canvasEl.value
if (!el) return null
if (
!strokeCanvas ||
strokeCanvas.width !== el.width ||
strokeCanvas.height !== el.height
) {
strokeCanvas = document.createElement('canvas')
strokeCanvas.width = el.width
strokeCanvas.height = el.height
strokeCtx = strokeCanvas.getContext('2d')
}
strokeCtx?.clearRect(0, 0, strokeCanvas.width, strokeCanvas.height)
return strokeCtx
}
function ensureBaseCanvas() {
const el = canvasEl.value
if (!el) return null
if (
!baseCanvas ||
baseCanvas.width !== el.width ||
baseCanvas.height !== el.height
) {
baseCanvas = document.createElement('canvas')
baseCanvas.width = el.width
baseCanvas.height = el.height
baseCtx = baseCanvas.getContext('2d')
}
return baseCtx
}
function compositeStrokeToMain(isPreview: boolean = false) {
const el = canvasEl.value
const ctx = getCtx()
if (!el || !ctx || !strokeCanvas) return
const useDirty = hasDirtyRect
const sx = Math.max(0, Math.floor(dirtyX0))
const sy = Math.max(0, Math.floor(dirtyY0))
const sr = Math.min(el.width, Math.ceil(dirtyX1))
const sb = Math.min(el.height, Math.ceil(dirtyY1))
const sw = sr - sx
const sh = sb - sy
hasDirtyRect = false
if (hasBaseSnapshot && baseCanvas) {
if (useDirty && sw > 0 && sh > 0) {
ctx.clearRect(sx, sy, sw, sh)
ctx.drawImage(baseCanvas, sx, sy, sw, sh, sx, sy, sw, sh)
} else {
ctx.clearRect(0, 0, el.width, el.height)
ctx.drawImage(baseCanvas, 0, 0)
}
}
ctx.save()
const isEraser = tool.value === PAINTER_TOOLS.ERASER
ctx.globalAlpha = isEraser ? 1 : brushOpacity.value
ctx.globalCompositeOperation = isEraser ? 'destination-out' : 'source-over'
if (useDirty && sw > 0 && sh > 0) {
ctx.drawImage(strokeCanvas, sx, sy, sw, sh, sx, sy, sw, sh)
} else {
ctx.drawImage(strokeCanvas, 0, 0)
}
ctx.restore()
if (!isPreview) {
hasBaseSnapshot = false
}
}
function startStroke(e: PointerEvent) {
const point = getCanvasPoint(e)
if (!point) return
const el = canvasEl.value
if (!el) return
const bCtx = ensureBaseCanvas()
if (bCtx) {
bCtx.clearRect(0, 0, el.width, el.height)
bCtx.drawImage(el, 0, 0)
hasBaseSnapshot = true
}
isDrawing = true
isDirty.value = true
hasStrokes = true
snapshotBrush()
strokeProcessor = new StrokeProcessor(Math.max(1, strokeBrush!.radius / 2))
strokeProcessor.addPoint(point)
lastPoint = point
const ctx = ensureStrokeCanvas()
if (!ctx) return
ctx.save()
applyBrushStyle(ctx)
drawCircle(ctx, point)
ctx.restore()
compositeStrokeToMain(true)
}
function continueStroke(e: PointerEvent) {
if (!isDrawing || !strokeProcessor || !strokeCtx) return
const point = getCanvasPoint(e)
if (!point) return
const points = strokeProcessor.addPoint(point)
if (points.length === 0 && lastPoint) {
points.push(point)
}
if (points.length === 0) return
strokeCtx.save()
applyBrushStyle(strokeCtx)
let prev = lastPoint ?? points[0]
for (const p of points) {
drawSegment(strokeCtx, prev, p)
prev = p
}
lastPoint = prev
strokeCtx.restore()
compositeStrokeToMain(true)
}
function endStroke() {
if (!isDrawing || !strokeProcessor) return
const points = strokeProcessor.endStroke()
if (strokeCtx && points.length > 0) {
strokeCtx.save()
applyBrushStyle(strokeCtx)
let prev = lastPoint ?? points[0]
for (const p of points) {
drawSegment(strokeCtx, prev, p)
prev = p
}
strokeCtx.restore()
}
compositeStrokeToMain()
isDrawing = false
strokeProcessor = null
strokeBrush = null
lastPoint = null
}
function resizeCanvas() {
const el = canvasEl.value
if (!el) return
let tmp: HTMLCanvasElement | null = null
if (el.width > 0 && el.height > 0) {
tmp = document.createElement('canvas')
tmp.width = el.width
tmp.height = el.height
tmp.getContext('2d')!.drawImage(el, 0, 0)
}
el.width = canvasWidth.value
el.height = canvasHeight.value
mainCtx = null
if (tmp) {
getCtx()?.drawImage(tmp, 0, 0)
}
strokeCanvas = null
strokeCtx = null
baseCanvas = null
baseCtx = null
hasBaseSnapshot = false
}
function handleClear() {
const el = canvasEl.value
const ctx = getCtx()
if (!el || !ctx) return
ctx.clearRect(0, 0, el.width, el.height)
isDirty.value = true
hasStrokes = false
}
function updateCursorPos(e: PointerEvent) {
cursorX.value = e.offsetX
cursorY.value = e.offsetY
}
function handlePointerDown(e: PointerEvent) {
if (e.button !== 0) return
;(e.target as HTMLElement).setPointerCapture(e.pointerId)
cacheCanvasRect()
updateCursorPos(e)
startStroke(e)
}
let pendingMoveEvent: PointerEvent | null = null
let rafId: number | null = null
function flushPendingStroke() {
if (pendingMoveEvent) {
continueStroke(pendingMoveEvent)
pendingMoveEvent = null
}
rafId = null
}
function handlePointerMove(e: PointerEvent) {
updateCursorPos(e)
if (!isDrawing) return
pendingMoveEvent = e
if (!rafId) {
rafId = requestAnimationFrame(flushPendingStroke)
}
}
function handlePointerUp(e: PointerEvent) {
if (e.button !== 0) return
if (rafId) {
cancelAnimationFrame(rafId)
flushPendingStroke()
}
;(e.target as HTMLElement).releasePointerCapture(e.pointerId)
endStroke()
}
function handlePointerLeave() {
cursorVisible.value = false
if (rafId) {
cancelAnimationFrame(rafId)
flushPendingStroke()
}
endStroke()
}
function handlePointerEnter() {
cursorVisible.value = true
}
function handleInputImageLoad(e: Event) {
const img = e.target as HTMLImageElement
const widthWidget = getWidgetByName('width')
const heightWidget = getWidgetByName('height')
if (widthWidget) {
widthWidget.value = img.naturalWidth
widthWidget.callback?.(img.naturalWidth)
}
if (heightWidget) {
heightWidget.value = img.naturalHeight
heightWidget.callback?.(img.naturalHeight)
}
canvasWidth.value = img.naturalWidth
canvasHeight.value = img.naturalHeight
}
function parseMaskFilename(value: string): {
filename: string
subfolder: string
type: string
} | null {
const trimmed = value?.trim()
if (!trimmed) return null
const typeMatch = trimmed.match(/^(.+?) \[([^\]]+)\]$/)
const pathPart = typeMatch ? typeMatch[1] : trimmed
const type = typeMatch ? typeMatch[2] : 'input'
const lastSlash = pathPart.lastIndexOf('/')
const subfolder = lastSlash !== -1 ? pathPart.substring(0, lastSlash) : ''
const filename =
lastSlash !== -1 ? pathPart.substring(lastSlash + 1) : pathPart
return { filename, subfolder, type }
}
function isCanvasEmpty(): boolean {
return !hasStrokes
}
async function serializeValue(): Promise<string> {
const el = canvasEl.value
if (!el) return ''
if (isCanvasEmpty()) return ''
if (!isDirty.value) return modelValue.value
const blob = await new Promise<Blob | null>((resolve) =>
el.toBlob(resolve, 'image/png')
)
if (!blob) return modelValue.value
const name = `painter-${nodeId}-${Date.now()}.png`
const body = new FormData()
body.append('image', blob, name)
if (!isCloud) body.append('subfolder', 'painter')
body.append('type', isCloud ? 'input' : 'temp')
let resp: Response
try {
resp = await api.fetchApi('/upload/image', {
method: 'POST',
body
})
} catch (e) {
const err = t('painter.uploadError', {
status: 0,
statusText: e instanceof Error ? e.message : String(e)
})
toastStore.addAlert(err)
throw new Error(err)
}
if (resp.status !== 200) {
const err = t('painter.uploadError', {
status: resp.status,
statusText: resp.statusText
})
toastStore.addAlert(err)
throw new Error(err)
}
let data: { name: string }
try {
data = await resp.json()
} catch (e) {
const err = t('painter.uploadError', {
status: resp.status,
statusText: e instanceof Error ? e.message : String(e)
})
toastStore.addAlert(err)
throw new Error(err)
}
const result = isCloud
? `${data.name} [input]`
: `painter/${data.name} [temp]`
modelValue.value = result
isDirty.value = false
return result
}
function registerWidgetSerialization() {
const node = litegraphNode.value
if (!node?.widgets) return
const targetWidget = node.widgets.find(
(w: IBaseWidget) => w.name === 'mask'
)
if (targetWidget) {
targetWidget.serializeValue = serializeValue
}
}
function restoreCanvas() {
const parsed = parseMaskFilename(modelValue.value)
if (!parsed) return
const params = new URLSearchParams()
params.set('filename', parsed.filename)
if (parsed.subfolder) params.set('subfolder', parsed.subfolder)
params.set('type', parsed.type)
const url = api.apiURL('/view?' + params.toString())
const img = new Image()
img.crossOrigin = 'anonymous'
img.onload = () => {
const el = canvasEl.value
if (!el) return
canvasWidth.value = img.naturalWidth
canvasHeight.value = img.naturalHeight
mainCtx = null
getCtx()?.drawImage(img, 0, 0)
isDirty.value = false
hasStrokes = true
}
img.onerror = () => {
modelValue.value = ''
}
img.src = url
}
watch(() => nodeOutputStore.nodeOutputs, updateInputImageUrl, { deep: true })
watch(() => nodeOutputStore.nodePreviewImages, updateInputImageUrl, {
deep: true
})
watch([canvasWidth, canvasHeight], resizeCanvas)
watch(
[tool, brushSize, brushColor, brushOpacity, brushHardness],
saveSettingsToProperties
)
watch([canvasWidth, canvasHeight], syncCanvasSizeToWidgets)
watch(backgroundColor, syncBackgroundColorToWidget)
function initialize() {
syncCanvasSizeFromWidgets()
resizeCanvas()
registerWidgetSerialization()
restoreSettingsFromProperties()
updateInputImageUrl()
restoreCanvas()
}
onMounted(initialize)
onUnmounted(() => {
if (rafId) {
cancelAnimationFrame(rafId)
rafId = null
}
})
return {
tool,
brushSize,
brushColor,
brushOpacity,
brushHardness,
backgroundColor,
canvasWidth,
canvasHeight,
cursorX,
cursorY,
cursorVisible,
displayBrushSize,
inputImageUrl,
isImageInputConnected,
handlePointerDown,
handlePointerMove,
handlePointerUp,
handlePointerEnter,
handlePointerLeave,
handleInputImageLoad,
handleClear
}
}

View File

@@ -11,7 +11,7 @@ import type { TaskItemImpl } from '@/stores/queueStore'
type TestTask = {
jobId: string
queueIndex: number
job: { priority: number }
mockState: JobState
executionTime?: number
executionEndTimestamp?: number
@@ -174,7 +174,7 @@ const createTask = (
overrides: Partial<TestTask> & { mockState?: JobState } = {}
): TestTask => ({
jobId: overrides.jobId ?? `task-${Math.random().toString(36).slice(2, 7)}`,
queueIndex: overrides.queueIndex ?? 0,
job: overrides.job ?? { priority: 0 },
mockState: overrides.mockState ?? 'pending',
executionTime: overrides.executionTime,
executionEndTimestamp: overrides.executionEndTimestamp,
@@ -258,7 +258,7 @@ describe('useJobList', () => {
it('tracks recently added pending jobs and clears the hint after expiry', async () => {
vi.useFakeTimers()
queueStoreMock.pendingTasks = [
createTask({ jobId: '1', queueIndex: 1, mockState: 'pending' })
createTask({ jobId: '1', job: { priority: 1 }, mockState: 'pending' })
]
const { jobItems } = initComposable()
@@ -287,7 +287,7 @@ describe('useJobList', () => {
vi.useFakeTimers()
const taskId = '2'
queueStoreMock.pendingTasks = [
createTask({ jobId: taskId, queueIndex: 1, mockState: 'pending' })
createTask({ jobId: taskId, job: { priority: 1 }, mockState: 'pending' })
]
const { jobItems } = initComposable()
@@ -300,7 +300,7 @@ describe('useJobList', () => {
vi.mocked(buildJobDisplay).mockClear()
queueStoreMock.pendingTasks = [
createTask({ jobId: taskId, queueIndex: 2, mockState: 'pending' })
createTask({ jobId: taskId, job: { priority: 2 }, mockState: 'pending' })
]
await flush()
jobItems.value
@@ -314,7 +314,7 @@ describe('useJobList', () => {
it('cleans up timeouts on unmount', async () => {
vi.useFakeTimers()
queueStoreMock.pendingTasks = [
createTask({ jobId: '3', queueIndex: 1, mockState: 'pending' })
createTask({ jobId: '3', job: { priority: 1 }, mockState: 'pending' })
]
initComposable()
@@ -331,7 +331,7 @@ describe('useJobList', () => {
queueStoreMock.pendingTasks = [
createTask({
jobId: 'p',
queueIndex: 1,
job: { priority: 1 },
mockState: 'pending',
createTime: 3000
})
@@ -339,7 +339,7 @@ describe('useJobList', () => {
queueStoreMock.runningTasks = [
createTask({
jobId: 'r',
queueIndex: 5,
job: { priority: 5 },
mockState: 'running',
createTime: 2000
})
@@ -347,7 +347,7 @@ describe('useJobList', () => {
queueStoreMock.historyTasks = [
createTask({
jobId: 'h',
queueIndex: 3,
job: { priority: 3 },
mockState: 'completed',
createTime: 1000,
executionEndTimestamp: 5000
@@ -366,9 +366,9 @@ describe('useJobList', () => {
it('filters by job tab and resets failed tab when failures disappear', async () => {
queueStoreMock.historyTasks = [
createTask({ jobId: 'c', queueIndex: 3, mockState: 'completed' }),
createTask({ jobId: 'f', queueIndex: 2, mockState: 'failed' }),
createTask({ jobId: 'p', queueIndex: 1, mockState: 'pending' })
createTask({ jobId: 'c', job: { priority: 3 }, mockState: 'completed' }),
createTask({ jobId: 'f', job: { priority: 2 }, mockState: 'failed' }),
createTask({ jobId: 'p', job: { priority: 1 }, mockState: 'pending' })
]
const instance = initComposable()
@@ -384,7 +384,7 @@ describe('useJobList', () => {
expect(instance.hasFailedJobs.value).toBe(true)
queueStoreMock.historyTasks = [
createTask({ jobId: 'c', queueIndex: 3, mockState: 'completed' })
createTask({ jobId: 'c', job: { priority: 3 }, mockState: 'completed' })
]
await flush()
@@ -396,13 +396,13 @@ describe('useJobList', () => {
queueStoreMock.pendingTasks = [
createTask({
jobId: 'wf-1',
queueIndex: 2,
job: { priority: 2 },
mockState: 'pending',
workflowId: 'workflow-1'
}),
createTask({
jobId: 'wf-2',
queueIndex: 1,
job: { priority: 1 },
mockState: 'pending',
workflowId: 'workflow-2'
})
@@ -426,14 +426,14 @@ describe('useJobList', () => {
queueStoreMock.historyTasks = [
createTask({
jobId: 'alpha',
queueIndex: 2,
job: { priority: 2 },
mockState: 'completed',
createTime: 2000,
executionEndTimestamp: 2000
}),
createTask({
jobId: 'beta',
queueIndex: 1,
job: { priority: 1 },
mockState: 'failed',
createTime: 1000,
executionEndTimestamp: 1000
@@ -471,13 +471,13 @@ describe('useJobList', () => {
queueStoreMock.runningTasks = [
createTask({
jobId: 'active',
queueIndex: 3,
job: { priority: 3 },
mockState: 'running',
executionTime: 7_200_000
}),
createTask({
jobId: 'other',
queueIndex: 2,
job: { priority: 2 },
mockState: 'running',
executionTime: 3_600_000
})
@@ -507,7 +507,7 @@ describe('useJobList', () => {
queueStoreMock.runningTasks = [
createTask({
jobId: 'live-preview',
queueIndex: 1,
job: { priority: 1 },
mockState: 'running'
})
]
@@ -526,7 +526,7 @@ describe('useJobList', () => {
queueStoreMock.runningTasks = [
createTask({
jobId: 'disabled-preview',
queueIndex: 1,
job: { priority: 1 },
mockState: 'running'
})
]
@@ -567,28 +567,28 @@ describe('useJobList', () => {
queueStoreMock.historyTasks = [
createTask({
jobId: 'today-small',
queueIndex: 4,
job: { priority: 4 },
mockState: 'completed',
executionEndTimestamp: Date.now(),
executionTime: 2_000
}),
createTask({
jobId: 'today-large',
queueIndex: 3,
job: { priority: 3 },
mockState: 'completed',
executionEndTimestamp: Date.now(),
executionTime: 5_000
}),
createTask({
jobId: 'yesterday',
queueIndex: 2,
job: { priority: 2 },
mockState: 'failed',
executionEndTimestamp: Date.now() - 86_400_000,
executionTime: 1_000
}),
createTask({
jobId: 'undated',
queueIndex: 1,
job: { priority: 1 },
mockState: 'pending'
})
]

View File

@@ -0,0 +1,43 @@
import { computed, ref } from 'vue'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
export type AppMode = 'graph' | 'app' | 'builder:select' | 'builder:arrange'
const enableAppBuilder = ref(true)
export function useAppMode() {
const workflowStore = useWorkflowStore()
const mode = computed(
() =>
workflowStore.activeWorkflow?.activeMode ??
workflowStore.activeWorkflow?.initialMode ??
'graph'
)
const isBuilderMode = computed(
() => mode.value === 'builder:select' || mode.value === 'builder:arrange'
)
const isAppMode = computed(
() => mode.value === 'app' || mode.value === 'builder:arrange'
)
const isGraphMode = computed(
() => mode.value === 'graph' || mode.value === 'builder:select'
)
function setMode(newMode: AppMode) {
if (newMode === mode.value) return
const workflow = workflowStore.activeWorkflow
if (workflow) workflow.activeMode = newMode
}
return {
mode,
enableAppBuilder,
isBuilderMode,
isAppMode,
isGraphMode,
setMode
}
}

View File

@@ -4,64 +4,32 @@ import { useToast } from 'primevue/usetoast'
import { t } from '@/i18n'
export function useCopyToClipboard() {
const { copy, copied } = useClipboard()
const { copy, copied } = useClipboard({ legacy: true })
const toast = useToast()
const showSuccessToast = () => {
toast.add({
severity: 'success',
summary: t('g.success'),
detail: t('clipboard.successMessage'),
life: 3000
})
}
const showErrorToast = () => {
toast.add({
severity: 'error',
summary: t('g.error'),
detail: t('clipboard.errorMessage')
})
}
function fallbackCopy(text: string) {
const textarea = document.createElement('textarea')
textarea.setAttribute('readonly', '')
textarea.value = text
textarea.style.position = 'absolute'
textarea.style.left = '-9999px'
textarea.setAttribute('aria-hidden', 'true')
textarea.setAttribute('tabindex', '-1')
textarea.style.width = '1px'
textarea.style.height = '1px'
document.body.appendChild(textarea)
textarea.select()
try {
// using legacy document.execCommand for fallback for old and linux browsers
const successful = document.execCommand('copy')
if (successful) {
showSuccessToast()
} else {
showErrorToast()
}
} catch (err) {
showErrorToast()
} finally {
textarea.remove()
}
}
const copyToClipboard = async (text: string) => {
async function copyToClipboard(text: string) {
try {
await copy(text)
if (copied.value) {
showSuccessToast()
toast.add({
severity: 'success',
summary: t('g.success'),
detail: t('clipboard.successMessage'),
life: 3000
})
} else {
// If VueUse copy failed, try fallback
fallbackCopy(text)
toast.add({
severity: 'error',
summary: t('g.error'),
detail: t('clipboard.errorMessage')
})
}
} catch (err) {
// VueUse copy failed, try fallback
fallbackCopy(text)
} catch {
toast.add({
severity: 'error',
summary: t('g.error'),
detail: t('clipboard.errorMessage')
})
}
}

View File

@@ -1338,8 +1338,6 @@ export function useCoreCommands(): ComfyCommand[] {
typeof metadata?.source === 'string' ? metadata.source : 'keybind'
const newMode = !canvasStore.linearMode
if (newMode) useTelemetry()?.trackEnterLinear({ source })
app.rootGraph.extra.linearMode = newMode
workflowStore.activeWorkflow?.changeTracker?.checkState()
canvasStore.linearMode = newMode
}
}

View File

@@ -2,7 +2,7 @@ import { computed, onBeforeUnmount, ref } from 'vue'
import type { Ref } from 'vue'
import { createMonotoneInterpolator } from '@/components/curve/curveUtils'
import type { CurvePoint } from '@/lib/litegraph/src/types/widgets'
import type { CurvePoint } from '@/components/curve/types'
interface UseCurveEditorOptions {
svgRef: Ref<SVGSVGElement | null>
@@ -21,11 +21,12 @@ export function useCurveEditor({ svgRef, modelValue }: UseCurveEditorOptions) {
const xMin = points[0][0]
const xMax = points[points.length - 1][0]
const segments = 128
const range = xMax - xMin
const parts: string[] = []
for (let i = 0; i <= segments; i++) {
const x = xMin + (xMax - xMin) * (i / segments)
const x = xMin + range * (i / segments)
const y = 1 - interpolate(x)
parts.push(`${i === 0 ? 'M' : 'L'}${x.toFixed(4)},${y.toFixed(4)}`)
parts.push(`${i === 0 ? 'M' : 'L'}${x},${y}`)
}
return parts.join('')
})

View File

@@ -0,0 +1,44 @@
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
import type { LGraph } from '@/lib/litegraph/src/litegraph'
import { useNodeReplacementStore } from '@/platform/nodeReplacement/nodeReplacementStore'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import type { MissingNodeType } from '@/types/comfy'
import {
collectAllNodes,
getExecutionIdByNode
} from '@/utils/graphTraversalUtil'
import { getCnrIdFromNode } from '@/workbench/extensions/manager/utils/missingNodeErrorUtil'
/** Scan the live graph for unregistered node types and build a full MissingNodeType list. */
function scanMissingNodes(rootGraph: LGraph): MissingNodeType[] {
const nodeReplacementStore = useNodeReplacementStore()
const missingNodeTypes: MissingNodeType[] = []
const allNodes = collectAllNodes(rootGraph)
for (const node of allNodes) {
const originalType = node.last_serialization?.type ?? node.type ?? 'Unknown'
if (originalType in LiteGraph.registered_node_types) continue
const cnrId = getCnrIdFromNode(node)
const replacement = nodeReplacementStore.getReplacementFor(originalType)
const executionId = getExecutionIdByNode(rootGraph, node)
missingNodeTypes.push({
type: originalType,
nodeId: executionId ?? String(node.id),
cnrId,
isReplaceable: replacement !== null,
replacement: replacement ?? undefined
})
}
return missingNodeTypes
}
/** Re-scan the graph for missing nodes and update the error store. */
export function rescanAndSurfaceMissingNodes(rootGraph: LGraph): void {
const types = scanMissingNodes(rootGraph)
useExecutionErrorStore().surfaceMissingNodes(types)
}

View File

@@ -0,0 +1,62 @@
import { describe, expect, it } from 'vitest'
import {
ESSENTIALS_CATEGORIES,
ESSENTIALS_CATEGORY_CANONICAL,
ESSENTIALS_CATEGORY_MAP,
ESSENTIALS_NODES,
TOOLKIT_BLUEPRINT_MODULES,
TOOLKIT_NOVEL_NODE_NAMES
} from './essentialsNodes'
describe('essentialsNodes', () => {
it('has no duplicate node names across categories', () => {
const seen = new Map<string, string>()
for (const [category, nodes] of Object.entries(ESSENTIALS_NODES)) {
for (const node of nodes) {
expect(
seen.has(node),
`"${node}" duplicated in "${category}" and "${seen.get(node)}"`
).toBe(false)
seen.set(node, category)
}
}
})
it('ESSENTIALS_CATEGORY_MAP covers every node in ESSENTIALS_NODES', () => {
for (const [category, nodes] of Object.entries(ESSENTIALS_NODES)) {
for (const node of nodes) {
expect(ESSENTIALS_CATEGORY_MAP[node]).toBe(category)
}
}
})
it('TOOLKIT_NOVEL_NODE_NAMES excludes basics nodes', () => {
for (const basicNode of ESSENTIALS_NODES.basics) {
expect(TOOLKIT_NOVEL_NODE_NAMES.has(basicNode)).toBe(false)
}
})
it('TOOLKIT_NOVEL_NODE_NAMES excludes SubgraphBlueprint-prefixed nodes', () => {
for (const name of TOOLKIT_NOVEL_NODE_NAMES) {
expect(name.startsWith('SubgraphBlueprint.')).toBe(false)
}
})
it('ESSENTIALS_NODES keys match ESSENTIALS_CATEGORIES', () => {
const nodeKeys = Object.keys(ESSENTIALS_NODES)
expect(nodeKeys).toEqual([...ESSENTIALS_CATEGORIES])
})
it('TOOLKIT_BLUEPRINT_MODULES contains comfy_essentials', () => {
expect(TOOLKIT_BLUEPRINT_MODULES.has('comfy_essentials')).toBe(true)
})
it('ESSENTIALS_CATEGORY_CANONICAL maps every category case-insensitively', () => {
for (const category of ESSENTIALS_CATEGORIES) {
expect(ESSENTIALS_CATEGORY_CANONICAL.get(category.toLowerCase())).toBe(
category
)
}
})
})

View File

@@ -0,0 +1,115 @@
/**
* Single source of truth for Essentials tab node categorization and ordering.
*
* Adding a new node to the Essentials tab? Add it here and nowhere else.
*
* Source: https://www.notion.so/comfy-org/2fe6d73d365080d0a951d14cdf540778
*/
export const ESSENTIALS_CATEGORIES = [
'basics',
'text generation',
'image generation',
'video generation',
'image tools',
'video tools',
'audio',
'3D'
] as const
export type EssentialsCategory = (typeof ESSENTIALS_CATEGORIES)[number]
/**
* Ordered list of nodes per category.
* Array order = display order in the Essentials tab.
* Presence in a category = the node's essentials_category mock fallback.
*/
export const ESSENTIALS_NODES: Record<EssentialsCategory, readonly string[]> = {
basics: [
'LoadImage',
'LoadVideo',
'Load3D',
'SaveImage',
'SaveVideo',
'SaveGLB',
'PrimitiveStringMultiline',
'PreviewImage'
],
'text generation': ['OpenAIChatNode'],
'image generation': [
'LoraLoader',
'LoraLoaderModelOnly',
'ConditioningCombine'
],
'video generation': [
'SubgraphBlueprint.pose_to_video_ltx_2_0',
'SubgraphBlueprint.canny_to_video_ltx_2_0',
'KlingLipSyncAudioToVideoNode',
'KlingOmniProEditVideoNode'
],
'image tools': [
'ImageBatch',
'ImageCrop',
'ImageCropV2',
'ImageScale',
'ImageScaleBy',
'ImageRotate',
'ImageBlur',
'ImageBlend',
'ImageInvert',
'ImageCompare',
'Canny',
'RecraftRemoveBackgroundNode',
'RecraftVectorizeImageNode',
'LoadImageMask',
'GLSLShader'
],
'video tools': ['GetVideoComponents', 'CreateVideo', 'Video Slice'],
audio: [
'LoadAudio',
'SaveAudio',
'SaveAudioMP3',
'StabilityTextToAudio',
'EmptyLatentAudio'
],
'3D': ['TencentTextToModelNode', 'TencentImageToModelNode']
}
/**
* Flat map: node name → category (derived from ESSENTIALS_NODES).
* Used as mock/fallback when backend doesn't provide essentials_category.
*/
export const ESSENTIALS_CATEGORY_MAP: Record<string, EssentialsCategory> =
Object.fromEntries(
Object.entries(ESSENTIALS_NODES).flatMap(([category, nodes]) =>
nodes.map((node) => [node, category])
)
) as Record<string, EssentialsCategory>
/**
* Case-insensitive lookup: lowercase category → canonical category.
* Used to normalize backend categories (which may be title-cased) to the
* canonical form used in ESSENTIALS_CATEGORIES.
*/
export const ESSENTIALS_CATEGORY_CANONICAL: ReadonlyMap<
string,
EssentialsCategory
> = new Map(ESSENTIALS_CATEGORIES.map((c) => [c.toLowerCase(), c]))
/**
* "Novel" toolkit nodes for telemetry — basics excluded.
* Derived from ESSENTIALS_NODES minus the 'basics' category.
*/
export const TOOLKIT_NOVEL_NODE_NAMES: ReadonlySet<string> = new Set(
Object.entries(ESSENTIALS_NODES)
.filter(([cat]) => cat !== 'basics')
.flatMap(([, nodes]) => nodes)
.filter((n) => !n.startsWith('SubgraphBlueprint.'))
)
/**
* python_module values that identify toolkit blueprint nodes.
*/
export const TOOLKIT_BLUEPRINT_MODULES: ReadonlySet<string> = new Set([
'comfy_essentials'
])

View File

@@ -1,38 +1,10 @@
/**
* Toolkit (Essentials) node detection constants.
*
* Re-exported from essentialsNodes.ts — the single source of truth.
* Used by telemetry to track toolkit node adoption and popularity.
* Only novel nodes — basic nodes (LoadImage, SaveImage, etc.) are excluded.
*
* Source: https://www.notion.so/comfy-org/2fe6d73d365080d0a951d14cdf540778
*/
/**
* Canonical node type names for individual toolkit nodes.
*/
export const TOOLKIT_NODE_NAMES: ReadonlySet<string> = new Set([
// Image Tools
'ImageCrop',
'ImageRotate',
'ImageBlur',
'ImageInvert',
'ImageCompare',
'Canny',
// Video Tools
'Video Slice',
// API Nodes
'RecraftRemoveBackgroundNode',
'RecraftVectorizeImageNode',
'KlingOmniProEditVideoNode'
])
/**
* python_module values that identify toolkit blueprint nodes.
* Essentials blueprints are registered with node_pack 'comfy_essentials',
* which maps to python_module on the node def.
*/
export const TOOLKIT_BLUEPRINT_MODULES: ReadonlySet<string> = new Set([
'comfy_essentials'
])
export {
TOOLKIT_NOVEL_NODE_NAMES as TOOLKIT_NODE_NAMES,
TOOLKIT_BLUEPRINT_MODULES
} from './essentialsNodes'

View File

@@ -201,7 +201,8 @@ class PromotedWidgetView implements IPromotedWidgetView {
projected.drawWidget(ctx, {
width: widgetWidth,
showText: !lowQuality,
suppressPromotedOutline: true
suppressPromotedOutline: true,
previewImages: resolved.node.imgs
})
projected.y = originalY

View File

@@ -184,6 +184,17 @@ describe('getPromotableWidgets', () => {
).toBe(true)
})
it('adds virtual canvas preview widget for GLSLShader nodes', () => {
const node = new LGraphNode('GLSLShader')
node.type = 'GLSLShader'
const widgets = getPromotableWidgets(node)
expect(
widgets.some((widget) => widget.name === CANVAS_IMAGE_PREVIEW_WIDGET)
).toBe(true)
})
it('does not add virtual canvas preview widget for non-image nodes', () => {
const node = new LGraphNode('TextNode')
node.addOutput('TEXT', 'STRING')
@@ -232,4 +243,25 @@ describe('promoteRecommendedWidgets', () => {
expect(updatePreviewsMock).not.toHaveBeenCalled()
})
it('eagerly promotes virtual preview widget for CANVAS_IMAGE_PREVIEW nodes', () => {
const subgraph = createTestSubgraph()
const subgraphNode = createTestSubgraphNode(subgraph)
const glslNode = new LGraphNode('GLSLShader')
glslNode.type = 'GLSLShader'
subgraph.add(glslNode)
promoteRecommendedWidgets(subgraphNode)
const store = usePromotionStore()
expect(
store.isPromoted(
subgraphNode.rootGraph.id,
subgraphNode.id,
String(glslNode.id),
CANVAS_IMAGE_PREVIEW_WIDGET
)
).toBe(true)
expect(updatePreviewsMock).not.toHaveBeenCalled()
})
})

View File

@@ -227,6 +227,29 @@ export function promoteRecommendedWidgets(subgraphNode: SubgraphNode) {
// defer. Core $$ preview widgets are the lazy path that needs updatePreviews.
if (hasPreviewWidget()) continue
// Nodes in CANVAS_IMAGE_PREVIEW_NODE_TYPES support a virtual $$
// preview widget. Eagerly promote it so getPseudoWidgetPreviewTargets
// includes this node and onDrawBackground can call updatePreviews on it
// once execution outputs arrive.
if (supportsVirtualCanvasImagePreview(node)) {
if (
!store.isPromoted(
subgraphNode.rootGraph.id,
subgraphNode.id,
String(node.id),
CANVAS_IMAGE_PREVIEW_WIDGET
)
) {
store.promote(
subgraphNode.rootGraph.id,
subgraphNode.id,
String(node.id),
CANVAS_IMAGE_PREVIEW_WIDGET
)
}
continue
}
// Also schedule a deferred check: core $$ widgets are created lazily by
// updatePreviews when node outputs are first loaded.
requestAnimationFrame(() => updatePreviews(node, promotePreviewWidget))

View File

@@ -19,6 +19,7 @@ if (!isCloud) {
await import('./nodeTemplates')
}
import './noteNode'
import './painter'
import './previewAny'
import './rerouteNode'
import './saveImageExtraOutput'

View File

@@ -0,0 +1,22 @@
import { useExtensionService } from '@/services/extensionService'
const HIDDEN_WIDGETS = new Set(['width', 'height', 'bg_color'])
useExtensionService().registerExtension({
name: 'Comfy.Painter',
nodeCreated(node) {
if (node.constructor.comfyClass !== 'Painter') return
const [oldWidth, oldHeight] = node.size
node.setSize([Math.max(oldWidth, 450), Math.max(oldHeight, 550)])
node.hideOutputImages = true
for (const widget of node.widgets ?? []) {
if (HIDDEN_WIDGETS.has(widget.name)) {
widget.hidden = true
}
}
}
})

View File

@@ -43,7 +43,7 @@ async function uploadFile(
file: File,
updateNode: boolean,
pasted: boolean = false
) {
): Promise<boolean> {
try {
// Wrap file in formdata so it includes filename
const body = new FormData()
@@ -76,12 +76,15 @@ async function uploadFile(
// Manually trigger the callback to update VueNodes
audioWidget.callback?.(path)
}
return true
} else {
useToastStore().addAlert(resp.status + ' - ' + resp.statusText)
return false
}
} catch (error) {
// @ts-expect-error fixme ts strict error
useToastStore().addAlert(error)
return false
}
}
@@ -232,7 +235,17 @@ app.registerExtension({
const handleUpload = async (files: File[]) => {
if (files?.length) {
uploadFile(audioWidget, audioUIWidget, files[0], true)
const previousValue = audioWidget.value
audioWidget.value = files[0].name
const success = await uploadFile(
audioWidget,
audioUIWidget,
files[0],
true
)
if (!success) {
audioWidget.value = previousValue
}
}
return files
}

View File

@@ -1,4 +1,5 @@
import type { Bounds } from '@/renderer/core/layout/types'
import type { CurvePoint } from '@/components/curve/types'
import type {
CanvasColour,
@@ -137,6 +138,7 @@ export type IWidget =
| IImageCropWidget
| IBoundingBoxWidget
| ICurveWidget
| IPainterWidget
export interface IBooleanWidget extends IBaseWidget<boolean, 'toggle'> {
type: 'toggle'
@@ -329,13 +331,16 @@ export interface IBoundingBoxWidget extends IBaseWidget<Bounds, 'boundingbox'> {
value: Bounds
}
export type CurvePoint = [x: number, y: number]
export interface ICurveWidget extends IBaseWidget<CurvePoint[], 'curve'> {
type: 'curve'
value: CurvePoint[]
}
export interface IPainterWidget extends IBaseWidget<string, 'painter'> {
type: 'painter'
value: string
}
/**
* Valid widget types. TS cannot provide easily extensible type safety for this at present.
* Override linkedWidgets[]
@@ -367,7 +372,6 @@ export interface IBaseWidget<
/** Widget type (see {@link TWidgetType}) */
type: TType
value?: TValue
vueTrack?: () => void
/**
* Whether the widget value is persisted in the workflow JSON

View File

@@ -162,6 +162,38 @@ describe('BaseWidget store integration', () => {
})
})
describe('DOM widget value registration', () => {
it('registers value from getter when value property is overridden', () => {
const defaultValue = 'You are an expert image-generation engine.'
const widget = createTestWidget(node, {
name: 'system_prompt',
value: undefined as unknown as number
})
// Simulate what addDOMWidget does: override value with getter/setter
// that falls back to a default (like inputEl.value for textarea widgets)
Object.defineProperty(widget, 'value', {
get() {
const graphId = widget.node.graph?.rootGraph.id
if (!graphId) return defaultValue
const state = store.getWidget(graphId, node.id, 'system_prompt')
return (state?.value as string) ?? defaultValue
},
set(v: string) {
const graphId = widget.node.graph?.rootGraph.id
if (!graphId) return
const state = store.getWidget(graphId, node.id, 'system_prompt')
if (state) state.value = v
}
})
widget.setNodeId(node.id)
const state = store.getWidget(graph.id, node.id, 'system_prompt')
expect(state?.value).toBe(defaultValue)
})
})
describe('fallback behavior', () => {
it('uses internal value before registration', () => {
const widget = createTestWidget(node, {

View File

@@ -27,6 +27,8 @@ export interface DrawWidgetOptions {
showText?: boolean
/** When true, suppresses the promoted outline color (e.g. for projected copies on SubgraphNode). */
suppressPromotedOutline?: boolean
/** Transient image source for preview widgets rendered on behalf of another node (e.g. subgraph promotion). */
previewImages?: HTMLImageElement[]
}
interface DrawTruncatingTextOptions extends DrawWidgetOptions {
@@ -140,6 +142,10 @@ export abstract class BaseWidget<TWidget extends IBaseWidget = IBaseWidget>
this._state = useWidgetValueStore().registerWidget(graphId, {
...this._state,
// BaseWidget: this.value getter returns this._state.value. So value: this.value === value: this._state.value.
// BaseDOMWidgetImpl: this.value getter returns options.getValue?.() ?? ''. Resolves the correct initial value instead of undefined.
// I.e., calls overriden getter -> options.getValue() -> correct value (https://github.com/Comfy-Org/ComfyUI_frontend/issues/9194).
value: this.value,
nodeId
})
}

View File

@@ -0,0 +1,22 @@
import type { IPainterWidget } from '../types/widgets'
import { BaseWidget } from './BaseWidget'
import type { DrawWidgetOptions, WidgetEventOptions } from './BaseWidget'
/**
* Widget for the Painter node canvas drawing tool.
* This is a widget that only has a Vue widgets implementation.
*/
export class PainterWidget
extends BaseWidget<IPainterWidget>
implements IPainterWidget
{
override type = 'painter' as const
drawWidget(ctx: CanvasRenderingContext2D, options: DrawWidgetOptions): void {
this.drawVueOnlyWarning(ctx, options, 'Painter')
}
onClick(_options: WidgetEventOptions): void {
// This is a widget that only has a Vue widgets implementation
}
}

View File

@@ -21,6 +21,7 @@ import { FileUploadWidget } from './FileUploadWidget'
import { GalleriaWidget } from './GalleriaWidget'
import { GradientSliderWidget } from './GradientSliderWidget'
import { ImageCompareWidget } from './ImageCompareWidget'
import { PainterWidget } from './PainterWidget'
import { ImageCropWidget } from './ImageCropWidget'
import { KnobWidget } from './KnobWidget'
import { LegacyWidget } from './LegacyWidget'
@@ -58,6 +59,7 @@ export type WidgetTypeMap = {
imagecrop: ImageCropWidget
boundingbox: BoundingBoxWidget
curve: CurveWidget
painter: PainterWidget
[key: string]: BaseWidget
}
@@ -136,6 +138,8 @@ export function toConcreteWidget<TWidget extends IWidget | IBaseWidget>(
return toClass(BoundingBoxWidget, narrowedWidget, node)
case 'curve':
return toClass(CurveWidget, narrowedWidget, node)
case 'painter':
return toClass(PainterWidget, narrowedWidget, node)
default: {
if (wrapLegacyWidgets) return toClass(LegacyWidget, widget, node)
}

View File

@@ -225,6 +225,7 @@
"login": {
"andText": "و",
"backToLogin": "العودة إلى تسجيل الدخول",
"backToSocialLogin": "سجّل باستخدام Google أو Github بدلاً من ذلك",
"confirmPasswordLabel": "تأكيد كلمة المرور",
"confirmPasswordPlaceholder": "أدخل نفس كلمة المرور مرة أخرى",
"didntReceiveEmail": "لم تستلم البريد الإلكتروني؟ اتصل بنا على",
@@ -233,6 +234,9 @@
"failed": "فشل تسجيل الدخول",
"forgotPassword": "هل نسيت كلمة المرور؟",
"forgotPasswordError": "فشل في إرسال بريد إعادة تعيين كلمة المرور",
"freeTierBadge": "مؤهل للخطة المجانية",
"freeTierDescription": "سجّل باستخدام Google للحصول على {credits} رصيد مجاني كل شهر. لا حاجة لبطاقة.",
"freeTierDescriptionGeneric": "سجّل باستخدام Google للحصول على رصيد مجاني كل شهر. لا حاجة لبطاقة.",
"insecureContextWarning": "هذا الاتصال غير آمن (HTTP) - قد يتم اعتراض بيانات اعتمادك من قبل المهاجمين إذا تابعت تسجيل الدخول.",
"loginButton": "تسجيل الدخول",
"loginWithGithub": "تسجيل الدخول باستخدام Github",
@@ -251,11 +255,13 @@
"sendResetLink": "إرسال رابط إعادة التعيين",
"signInOrSignUp": "تسجيل الدخول / إنشاء حساب",
"signUp": "إنشاء حساب",
"signUpFreeTierPromo": "جديد هنا؟ {signUp} باستخدام Google للحصول على {credits} رصيد مجاني كل شهر.",
"success": "تم تسجيل الدخول بنجاح",
"termsLink": "شروط الاستخدام",
"termsText": "بالنقر على \"التالي\" أو \"إنشاء حساب\"، فإنك توافق على",
"title": "تسجيل الدخول إلى حسابك",
"useApiKey": "مفتاح API الخاص بـ Comfy",
"useEmailInstead": "استخدم البريد الإلكتروني بدلاً من ذلك",
"userAvatar": "صورة المستخدم"
},
"loginButton": {
@@ -282,6 +288,7 @@
"signup": {
"alreadyHaveAccount": "هل لديك حساب بالفعل؟",
"emailLabel": "البريد الإلكتروني",
"emailNotEligibleForFreeTier": "التسجيل بالبريد الإلكتروني غير مؤهل للخطة المجانية.",
"emailPlaceholder": "أدخل بريدك الإلكتروني",
"passwordLabel": "كلمة المرور",
"passwordPlaceholder": "أدخل كلمة مرور جديدة",
@@ -692,6 +699,7 @@
"OPENAI_INPUT_FILES": "ملفات إدخال أوبن إيه آي",
"PHOTOMAKER": "صانع الصور",
"PIXVERSE_TEMPLATE": "قالب PixVerse",
"POSE_KEYPOINT": "نقطة مفتاحية للوضعية",
"RECRAFT_COLOR": "لون Recraft",
"RECRAFT_CONTROLS": "عناصر تحكم Recraft",
"RECRAFT_V3_STYLE": "نمط Recraft V3",
@@ -951,6 +959,7 @@
"imageUrl": "رابط الصورة",
"import": "استيراد",
"inProgress": "جارٍ التنفيذ",
"inSubgraph": "في الرسم البياني الفرعي '{name}'",
"increment": "زيادة",
"info": "معلومات العقدة",
"input": "إدخال",
@@ -1108,6 +1117,7 @@
"updated": "تم التحديث",
"updating": "جارٍ التحديث",
"upload": "رفع",
"uploadAlreadyInProgress": "الرفع جارٍ بالفعل",
"usageHint": "تلميح الاستخدام",
"use": "استخدم",
"user": "المستخدم",
@@ -1331,10 +1341,29 @@
"switchToSelectButton": "الانتقال إلى التحديد"
},
"beta": "وضع التطبيق تجريبي - أرسل ملاحظاتك",
"builder": {
"exit": "خروج من البناء",
"exitConfirmMessage": "لديك تغييرات غير محفوظة ستفقد\nهل تريد الخروج بدون حفظ؟",
"exitConfirmTitle": "الخروج من بناء التطبيق؟",
"inputsDesc": "سيتفاعل المستخدمون مع هذه المدخلات ويعدلونها لإنشاء النتائج.",
"inputsExample": "أمثلة: \"تحميل صورة\"، \"موجه نصي\"، \"خطوات\"",
"noInputs": "لم تتم إضافة أي مدخلات بعد",
"noOutputs": "لم تتم إضافة أي عقد إخراج بعد",
"outputsDesc": "وصل عقدة إخراج واحدة على الأقل حتى يتمكن المستخدمون من رؤية النتائج بعد التشغيل.",
"outputsExample": "أمثلة: \"حفظ صورة\" أو \"حفظ فيديو\"",
"promptAddInputs": "انقر على معلمات العقدة لإضافتها هنا كمدخلات",
"promptAddOutputs": "انقر على عقد الإخراج لإضافتها هنا. هذه ستكون النتائج المُولدة.",
"title": "وضع بناء التطبيق"
},
"downloadAll": "تنزيل الكل",
"dragAndDropImage": "اسحب وأسقط صورة",
"giveFeedback": "إعطاء ملاحظات",
"graphMode": "وضع الرسم البياني",
"linearMode": "وضع التطبيق",
"queue": {
"clear": "مسح قائمة الانتظار",
"clickToClear": "انقر لمسح قائمة الانتظار"
},
"rerun": "تشغيل مجدد",
"reuseParameters": "إعادة استخدام المعلمات",
"runCount": "عدد مرات التشغيل:",
@@ -1554,6 +1583,9 @@
"nodePack": "حزمة العقد",
"nodePackInfo": "معلومات حزمة العقد",
"notAvailable": "غير متوفر",
"packInstall": {
"nodeIdRequired": "معرّف العقدة مطلوب للتثبيت"
},
"packsSelected": "الحزم المحددة",
"repository": "المستودع",
"restartToApplyChanges": "لـتطبيق التغييرات، يرجى إعادة تشغيل ComfyUI",
@@ -1868,11 +1900,18 @@
"showLinks": "إظهار الروابط"
},
"missingModelsDialog": {
"customModelsInstruction": "ستحتاج إلى العثور عليها وتنزيلها يدويًا. ابحث عنها عبر الإنترنت (جرّب Civitai أو Hugging Face) أو تواصل مع مزود سير العمل الأصلي.",
"customModelsWarning": "بعض هذه النماذج مخصصة ولا نتعرف عليها.",
"description": "يتطلب سير العمل هذا نماذج لم تقم بتنزيلها بعد.",
"doNotAskAgain": "عدم العرض مرة أخرى",
"missingModels": "نماذج مفقودة",
"missingModelsMessage": "عند تحميل الرسم البياني، لم يتم العثور على النماذج التالية",
"downloadAll": "تنزيل الكل",
"downloadAvailable": "التنزيل متاح",
"footerDescription": "قم بتنزيل هذه النماذج وضعها في المجلد الصحيح.\nالعُقد التي تفتقد إلى النماذج مميزة باللون الأحمر على اللوحة.",
"gotIt": "حسنًا، فهمت",
"reEnableInSettings": "إعادة التفعيل في {link}",
"reEnableInSettingsLink": "الإعدادات"
"reEnableInSettingsLink": "الإعدادات",
"title": "هذا سير العمل يفتقد إلى النماذج",
"totalSize": "إجمالي حجم التنزيل:"
},
"missingNodes": {
"cloud": {
@@ -2044,7 +2083,10 @@
"openNodeManager": "فتح مدير العقد",
"quickFixAvailable": "إصلاح سريع متاح",
"redHighlight": "أحمر",
"replaceAll": "استبدال الكل",
"replaceAllWarning": "سيتم استبدال جميع العقد المتاحة في هذه المجموعة.",
"replaceFailed": "فشل في استبدال العقد",
"replaceNode": "استبدال العقدة",
"replaceSelected": "استبدال المحدد ({count})",
"replaceWarning": "سيؤدي هذا إلى تعديل سير العمل بشكل دائم. احفظ نسخة أولاً إذا لم تكن متأكدًا.",
"replaceable": "قابل للاستبدال",
@@ -2052,7 +2094,11 @@
"replacedAllNodes": "تم استبدال {count} نوع/أنواع من العقد",
"replacedNode": "تم استبدال العقدة: {nodeType}",
"selectAll": "تحديد الكل",
"skipForNow": "تخطي الآن"
"skipForNow": "تخطي الآن",
"swapNodesGuide": "يمكن استبدال العقد التالية تلقائيًا ببدائل متوافقة.",
"swapNodesTitle": "تبديل العقد",
"unknownNode": "غير معروف",
"willBeReplacedBy": "سيتم استبدال هذه العقدة بـ:"
},
"nodeTemplates": {
"enterName": "أدخل الاسم",
@@ -2071,6 +2117,19 @@
},
"title": "جهازك غير مدعوم"
},
"painter": {
"background": "الخلفية",
"brush": "فرشاة",
"clear": "مسح",
"color": "منتقي اللون",
"eraser": "ممحاة",
"hardness": "الصلابة",
"height": "الارتفاع",
"size": "حجم المؤشر",
"tool": "أداة",
"uploadError": "فشل في رفع صورة الرسام: {status} - {statusText}",
"width": "العرض"
},
"progressToast": {
"allDownloadsCompleted": "اكتملت جميع التنزيلات",
"downloadingModel": "جاري تنزيل النموذج...",
@@ -2191,6 +2250,22 @@
"inputsNone": "لا توجد مدخلات",
"inputsNoneTooltip": "العقدة ليس لديها مدخلات",
"locateNode": "تحديد موقع العقدة على اللوحة",
"missingNodePacks": {
"applyChanges": "تطبيق التغييرات",
"cloudMessage": "يتطلب سير العمل هذا عقدًا مخصصة غير متوفرة بعد على Comfy Cloud.",
"collapse": "طي",
"expand": "توسيع",
"installAll": "تثبيت الكل",
"installNodePack": "تثبيت حزمة العقد",
"installed": "تم التثبيت",
"installing": "جارٍ التثبيت...",
"ossMessage": "يستخدم سير العمل هذا عقدًا مخصصة لم تقم بتثبيتها بعد.",
"searchInManager": "البحث في مدير العقد",
"title": "حزم العقد المفقودة",
"unknownPack": "حزمة غير معروفة",
"unsupportedTitle": "حزم العقد غير المدعومة",
"viewInManager": "عرض في المدير"
},
"mute": "كتم",
"noErrors": "لا توجد أخطاء",
"noSelection": "حدد عقدة لعرض خصائصها ومعلوماتها.",
@@ -2684,7 +2759,9 @@
"addCreditsLabel": "أضف المزيد من الرصيد في أي وقت",
"benefits": {
"benefit1": "رصيد شهري للعقد الشريكة - تجديد عند الحاجة",
"benefit2": "حتى 30 دقيقة وقت تشغيل لكل مهمة"
"benefit1FreeTier": "رصيد شهري أكثر، مع إمكانية الشحن في أي وقت",
"benefit2": "حتى 30 دقيقة وقت تشغيل لكل مهمة",
"benefit3": "استخدم نماذجك الخاصة (Creator & Pro)"
},
"beta": "نسخة تجريبية",
"billedMonthly": "يتم الفوترة شهريًا",
@@ -2722,6 +2799,21 @@
"description": "اختر الخطة الأنسب لك",
"descriptionWorkspace": "اختر أفضل خطة لمساحة العمل الخاصة بك",
"expiresDate": "ينتهي في {date}",
"freeTier": {
"description": "تشمل خطتك المجانية {credits} رصيد شهري لتجربة Comfy Cloud.",
"descriptionGeneric": "تشمل خطتك المجانية رصيدًا شهريًا لتجربة Comfy Cloud.",
"nextRefresh": "سيتم تجديد رصيدك في {date}.",
"outOfCredits": {
"subtitle": "اشترك لفتح الشحن والمزيد",
"title": "لقد نفد رصيدك المجاني"
},
"subscribeCta": "اشترك للمزيد",
"title": "أنت على الخطة المجانية",
"topUpBlocked": {
"title": "افتح الشحن والمزيد"
},
"upgradeCta": "عرض الخطط"
},
"gpuLabel": "RTX 6000 Pro (ذاكرة 96GB VRAM)",
"haveQuestions": "هل لديك أسئلة أو ترغب في معرفة المزيد عن المؤسسات؟",
"invoiceHistory": "سجل الفواتير",
@@ -2732,6 +2824,7 @@
"maxDuration": {
"creator": "30 دقيقة",
"founder": "30 دقيقة",
"free": "٣٠ دقيقة",
"pro": "ساعة واحدة",
"standard": "30 دقيقة"
},
@@ -2804,6 +2897,9 @@
"founder": {
"name": "إصدار المؤسس"
},
"free": {
"name": "مجاني"
},
"pro": {
"name": "احترافي"
},

View File

@@ -2137,6 +2137,35 @@
}
}
},
"CropByBBoxes": {
"description": "قص وتغيير حجم المناطق من دفعة الصور المدخلة بناءً على مربعات التحديد المقدمة.",
"display_name": "CropByBBoxes",
"inputs": {
"bboxes": {
"name": "مربعات التحديد"
},
"image": {
"name": "الصورة"
},
"output_height": {
"name": "ارتفاع الناتج",
"tooltip": "الارتفاع الذي يتم تغيير حجم كل قص إليه."
},
"output_width": {
"name": "عرض الناتج",
"tooltip": "العرض الذي يتم تغيير حجم كل قص إليه."
},
"padding": {
"name": "هامش إضافي",
"tooltip": "هامش إضافي بالبكسل يُضاف على كل جانب من مربع التحديد قبل القص."
}
},
"outputs": {
"0": {
"tooltip": "جميع القصاصات مكدسة في دفعة صور واحدة."
}
}
},
"CropMask": {
"display_name": "قص القناع",
"inputs": {
@@ -3701,6 +3730,57 @@
}
}
},
"GeminiNanoBanana2": {
"description": "إنشاء أو تعديل الصور بشكل متزامن عبر Google Vertex API.",
"display_name": "Nano Banana 2",
"inputs": {
"aspect_ratio": {
"name": "aspect_ratio",
"tooltip": "إذا تم تعيينها إلى 'auto'، سيتم مطابقة نسبة العرض إلى الارتفاع لصورتك المدخلة؛ إذا لم يتم توفير صورة، يتم عادةً إنشاء صورة مربعة بنسبة 16:9."
},
"control_after_generate": {
"name": "control after generate"
},
"files": {
"name": "files",
"tooltip": "ملف (ملفات) اختيارية لاستخدامها كسياق للنموذج. يقبل المدخلات من عقدة Gemini Generate Content Input Files."
},
"images": {
"name": "images",
"tooltip": "صورة (صور) مرجعية اختيارية. لإضافة عدة صور، استخدم عقدة Batch Images (حتى ١٤ صورة)."
},
"model": {
"name": "model"
},
"prompt": {
"name": "prompt",
"tooltip": "وصف نصي للصورة المراد إنشاؤها أو التعديلات المطلوب تطبيقها. أدرج أي قيود أو أنماط أو تفاصيل يجب على النموذج اتباعها."
},
"resolution": {
"name": "resolution",
"tooltip": "دقة الإخراج المستهدفة. بالنسبة لـ 2K/4K يتم استخدام أداة التكبير الأصلية لـ Gemini."
},
"response_modalities": {
"name": "response_modalities"
},
"seed": {
"name": "seed",
"tooltip": "عند تثبيت قيمة seed على رقم محدد، يحاول النموذج تقديم نفس الاستجابة للطلبات المتكررة قدر الإمكان. لا يمكن ضمان نتائج حتمية. كما أن تغيير النموذج أو إعدادات المعلمات مثل درجة العشوائية قد يؤدي إلى اختلاف النتائج حتى مع نفس قيمة seed. بشكل افتراضي، يتم استخدام قيمة seed عشوائية."
},
"system_prompt": {
"name": "system_prompt",
"tooltip": "تعليمات أساسية تحدد سلوك الذكاء الاصطناعي."
},
"thinking_level": {
"name": "thinking_level"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"GeminiNode": {
"description": "إنشاء استجابات نصية باستخدام نموذج الذكاء الاصطناعي Gemini من Google. يمكنك تقديم أنواع متعددة من المدخلات (نص، صور، صوت، فيديو) كسياق لإنشاء استجابات أكثر صلة ومعنى.",
"display_name": "Google Gemini",
@@ -12978,6 +13058,90 @@
}
}
},
"SDPoseDrawKeypoints": {
"display_name": "SDPoseDrawKeypoints",
"inputs": {
"draw_body": {
"name": "رسم الجسم"
},
"draw_face": {
"name": "رسم الوجه"
},
"draw_feet": {
"name": "رسم القدمين"
},
"draw_hands": {
"name": "رسم اليدين"
},
"face_point_size": {
"name": "حجم نقطة الوجه"
},
"keypoints": {
"name": "النقاط الرئيسية"
},
"score_threshold": {
"name": "عتبة الدرجات"
},
"stick_width": {
"name": "عرض الخط"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"SDPoseFaceBBoxes": {
"display_name": "SDPoseFaceBBoxes",
"inputs": {
"force_square": {
"name": "إجبار الشكل المربع",
"tooltip": "توسيع المحور الأقصر لمربع التحديد بحيث تكون منطقة القص دائماً مربعة."
},
"keypoints": {
"name": "النقاط الرئيسية"
},
"scale": {
"name": "المقياس",
"tooltip": "معامل ضرب لمساحة مربع التحديد حول كل وجه مكتشف."
}
},
"outputs": {
"0": {
"name": "مربعات التحديد",
"tooltip": "مربعات تحديد الوجه لكل إطار، متوافقة مع مدخل مربعات التحديد في SDPoseKeypointExtractor."
}
}
},
"SDPoseKeypointExtractor": {
"description": "استخراج النقاط الرئيسية للوضعية من الصور باستخدام نموذج SDPose: https://huggingface.co/Comfy-Org/SDPose/tree/main/checkpoints",
"display_name": "SDPoseKeypointExtractor",
"inputs": {
"batch_size": {
"name": "حجم الدفعة"
},
"bboxes": {
"name": "مربعات التحديد",
"tooltip": "مربعات التحديد الاختيارية للحصول على كشف أدق. مطلوبة لاكتشاف عدة أشخاص."
},
"image": {
"name": "الصورة"
},
"model": {
"name": "النموذج"
},
"vae": {
"name": "vae"
}
},
"outputs": {
"0": {
"name": "النقاط الرئيسية",
"tooltip": "النقاط الرئيسية بتنسيق OpenPose (عرض اللوحة، ارتفاع اللوحة، الأشخاص)"
}
}
},
"SDTurboScheduler": {
"display_name": "جدول SD Turbo",
"inputs": {

View File

@@ -71,6 +71,7 @@
"error": "Error",
"enter": "Enter",
"enterSubgraph": "Enter Subgraph",
"inSubgraph": "in subgraph '{name}'",
"resizeFromBottomRight": "Resize from bottom-right corner",
"resizeFromTopRight": "Resize from top-right corner",
"resizeFromBottomLeft": "Resize from bottom-left corner",
@@ -174,6 +175,7 @@
"control_after_generate": "control after generate",
"control_before_generate": "control before generate",
"choose_file_to_upload": "choose file to upload",
"uploadAlreadyInProgress": "Upload already in progress",
"capture": "capture",
"nodes": "Nodes",
"nodesCount": "{count} nodes | {count} node | {count} nodes",
@@ -449,6 +451,9 @@
"import_failed": "Import Failed"
},
"warningTooltip": "This package may have compatibility issues with your current environment"
},
"packInstall": {
"nodeIdRequired": "Node ID is required for installation"
}
},
"importFailed": {
@@ -1154,8 +1159,8 @@
"queue": {
"initializingAlmostReady": "Initializing - Almost ready",
"inQueue": "In queue...",
"jobAddedToQueue": "Job added to queue",
"jobQueueing": "Job queueing",
"jobAddedToQueue": "Job queued",
"jobQueueing": "Job queuing",
"completedIn": "Finished in {duration}",
"jobMenu": {
"openAsWorkflowNewTab": "Open as workflow in new tab",
@@ -1698,6 +1703,7 @@
"OPENAI_INPUT_FILES": "OPENAI_INPUT_FILES",
"PHOTOMAKER": "PHOTOMAKER",
"PIXVERSE_TEMPLATE": "PIXVERSE_TEMPLATE",
"POSE_KEYPOINT": "POSE_KEYPOINT",
"RECRAFT_COLOR": "RECRAFT_COLOR",
"RECRAFT_CONTROLS": "RECRAFT_CONTROLS",
"RECRAFT_V3_STYLE": "RECRAFT_V3_STYLE",
@@ -1883,6 +1889,19 @@
"unlockRatio": "Unlock aspect ratio",
"custom": "Custom"
},
"painter": {
"tool": "Tool",
"brush": "Brush",
"eraser": "Eraser",
"size": "Cursor Size",
"color": "Color Picker",
"hardness": "Hardness",
"width": "Width",
"height": "Height",
"background": "Background",
"clear": "Clear",
"uploadError": "Failed to upload painter image: {status} - {statusText}"
},
"boundingBox": {
"x": "X",
"y": "Y",
@@ -2978,7 +2997,8 @@
},
"linearMode": {
"linearMode": "App Mode",
"beta": "App Mode in Beta - Feedback",
"beta": "App mode in beta",
"giveFeedback": "Give feedback",
"graphMode": "Graph Mode",
"dragAndDropImage": "Click to browse or drag an image",
"runCount": "Number of runs",
@@ -3007,6 +3027,24 @@
"switchToSelectButton": "Switch to Select",
"outputs": "Outputs",
"resultsLabel": "Results generated from the selected output node(s) will be shown here after running this app"
},
"builder": {
"title": "App builder mode",
"exit": "Exit builder",
"exitConfirmTitle": "Exit app builder?",
"exitConfirmMessage": "You have unsaved changes that will be lost\nExit without saving?",
"promptAddInputs": "Click on node parameters to add them here as inputs",
"noInputs": "No inputs added yet",
"inputsDesc": "Users will interact and adjust these to generate their outputs.",
"inputsExample": "Examples: “Load image”, “Text prompt”, “Steps”",
"promptAddOutputs": "Click on output nodes to add them here. These will be the generated results.",
"noOutputs": "No output nodes added yet",
"outputsDesc": "Connect at least one output node so users can see results after running.",
"outputsExample": "Examples: “Save Image” or “Save Video”"
},
"queue": {
"clickToClear": "Click to clear queue",
"clear": "Clear queue"
}
},
"missingNodes": {
@@ -3041,7 +3079,14 @@
"openNodeManager": "Open Node Manager",
"skipForNow": "Skip for Now",
"installMissingNodes": "Install Missing Nodes",
"replaceWarning": "This will permanently modify the workflow. Save a copy first if unsure."
"replaceWarning": "This will permanently modify the workflow. Save a copy first if unsure.",
"swapNodesGuide": "The following nodes can be automatically replaced with compatible alternatives.",
"willBeReplacedBy": "This node will be replaced by:",
"replaceNode": "Replace Node",
"replaceAll": "Replace All",
"unknownNode": "Unknown",
"replaceAllWarning": "Replaces all available nodes in this group.",
"swapNodesTitle": "Swap Nodes"
},
"rightSidePanel": {
"togglePanel": "Toggle properties panel",
@@ -3121,7 +3166,23 @@
"errorHelpGithub": "submit a GitHub issue",
"errorHelpSupport": "contact our support",
"resetToDefault": "Reset to default",
"resetAllParameters": "Reset all parameters"
"resetAllParameters": "Reset all parameters",
"missingNodePacks": {
"title": "Missing Node Packs",
"unsupportedTitle": "Unsupported Node Packs",
"cloudMessage": "This workflow requires custom nodes not yet available on Comfy Cloud.",
"ossMessage": "This workflow uses custom nodes you haven't installed yet.",
"installAll": "Install All",
"installNodePack": "Install node pack",
"unknownPack": "Unknown pack",
"installing": "Installing...",
"installed": "Installed",
"applyChanges": "Apply Changes",
"searchInManager": "Search in Node Manager",
"viewInManager": "View in Manager",
"collapse": "Collapse",
"expand": "Expand"
}
},
"errorOverlay": {
"errorCount": "{count} ERRORS | {count} ERROR | {count} ERRORS",

View File

@@ -673,7 +673,7 @@
}
},
"ByteDanceSeedreamNode": {
"display_name": "ByteDance Seedream 5.0",
"display_name": "ByteDance Seedream 4.5 & 5.0",
"description": "Unified text-to-image generation and precise single-sentence editing at up to 4K resolution.",
"inputs": {
"model": {
@@ -2137,6 +2137,35 @@
}
}
},
"CropByBBoxes": {
"display_name": "CropByBBoxes",
"description": "Crop and resize regions from the input image batch based on provided bounding boxes.",
"inputs": {
"image": {
"name": "image"
},
"bboxes": {
"name": "bboxes"
},
"output_width": {
"name": "output_width",
"tooltip": "Width each crop is resized to."
},
"output_height": {
"name": "output_height",
"tooltip": "Height each crop is resized to."
},
"padding": {
"name": "padding",
"tooltip": "Extra padding in pixels added on each side of the bbox before cropping."
}
},
"outputs": {
"0": {
"tooltip": "All crops stacked into a single image batch."
}
}
},
"CropMask": {
"display_name": "CropMask",
"inputs": {
@@ -3601,6 +3630,57 @@
}
}
},
"GeminiNanoBanana2": {
"display_name": "Nano Banana 2",
"description": "Generate or edit images synchronously via Google Vertex API.",
"inputs": {
"prompt": {
"name": "prompt",
"tooltip": "Text prompt describing the image to generate or the edits to apply. Include any constraints, styles, or details the model should follow."
},
"model": {
"name": "model"
},
"seed": {
"name": "seed",
"tooltip": "When the seed is fixed to a specific value, the model makes a best effort to provide the same response for repeated requests. Deterministic output isn't guaranteed. Also, changing the model or parameter settings, such as the temperature, can cause variations in the response even when you use the same seed value. By default, a random seed value is used."
},
"aspect_ratio": {
"name": "aspect_ratio",
"tooltip": "If set to 'auto', matches your input image's aspect ratio; if no image is provided, a 16:9 square is usually generated."
},
"resolution": {
"name": "resolution",
"tooltip": "Target output resolution. For 2K/4K the native Gemini upscaler is used."
},
"response_modalities": {
"name": "response_modalities"
},
"thinking_level": {
"name": "thinking_level"
},
"images": {
"name": "images",
"tooltip": "Optional reference image(s). To include multiple images, use the Batch Images node (up to 14)."
},
"files": {
"name": "files",
"tooltip": "Optional file(s) to use as context for the model. Accepts inputs from the Gemini Generate Content Input Files node."
},
"system_prompt": {
"name": "system_prompt",
"tooltip": "Foundational instructions that dictate an AI's behavior."
},
"control_after_generate": {
"name": "control after generate"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"GeminiNode": {
"display_name": "Google Gemini",
"description": "Generate text responses with Google's Gemini AI model. You can provide multiple types of inputs (text, images, audio, video) as context for generating more relevant and meaningful responses.",
@@ -13690,6 +13770,90 @@
}
}
},
"SDPoseDrawKeypoints": {
"display_name": "SDPoseDrawKeypoints",
"inputs": {
"keypoints": {
"name": "keypoints"
},
"draw_body": {
"name": "draw_body"
},
"draw_hands": {
"name": "draw_hands"
},
"draw_face": {
"name": "draw_face"
},
"draw_feet": {
"name": "draw_feet"
},
"stick_width": {
"name": "stick_width"
},
"face_point_size": {
"name": "face_point_size"
},
"score_threshold": {
"name": "score_threshold"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"SDPoseFaceBBoxes": {
"display_name": "SDPoseFaceBBoxes",
"inputs": {
"keypoints": {
"name": "keypoints"
},
"scale": {
"name": "scale",
"tooltip": "Multiplier for the bounding box area around each detected face."
},
"force_square": {
"name": "force_square",
"tooltip": "Expand the shorter bbox axis so the crop region is always square."
}
},
"outputs": {
"0": {
"name": "bboxes",
"tooltip": "Face bounding boxes per frame, compatible with SDPoseKeypointExtractor bboxes input."
}
}
},
"SDPoseKeypointExtractor": {
"display_name": "SDPoseKeypointExtractor",
"description": "Extract pose keypoints from images using the SDPose model: https://huggingface.co/Comfy-Org/SDPose/tree/main/checkpoints",
"inputs": {
"model": {
"name": "model"
},
"vae": {
"name": "vae"
},
"image": {
"name": "image"
},
"batch_size": {
"name": "batch_size"
},
"bboxes": {
"name": "bboxes",
"tooltip": "Optional bounding boxes for more accurate detections. Required for multi-person detection."
}
},
"outputs": {
"0": {
"name": "keypoints",
"tooltip": "Keypoints in OpenPose frame format (canvas_width, canvas_height, people)"
}
}
},
"SDTurboScheduler": {
"display_name": "SDTurboScheduler",
"inputs": {

View File

@@ -225,6 +225,7 @@
"login": {
"andText": "y",
"backToLogin": "Volver al inicio de sesión",
"backToSocialLogin": "Regístrate con Google o Github en su lugar",
"confirmPasswordLabel": "Confirmar contraseña",
"confirmPasswordPlaceholder": "Ingresa la misma contraseña nuevamente",
"didntReceiveEmail": "¿No recibiste el correo? Contáctanos en",
@@ -233,6 +234,9 @@
"failed": "Inicio de sesión fallido",
"forgotPassword": "¿Olvidaste tu contraseña?",
"forgotPasswordError": "No se pudo enviar el correo electrónico para restablecer la contraseña",
"freeTierBadge": "Elegible para el plan gratuito",
"freeTierDescription": "Regístrate con Google para obtener {credits} créditos gratis cada mes. No se necesita tarjeta.",
"freeTierDescriptionGeneric": "Regístrate con Google para obtener créditos gratis cada mes. No se necesita tarjeta.",
"insecureContextWarning": "Esta conexión no es segura (HTTP): tus credenciales pueden ser interceptadas por atacantes si continúas con el inicio de sesión.",
"loginButton": "Iniciar sesión",
"loginWithGithub": "Iniciar sesión con Github",
@@ -251,11 +255,13 @@
"sendResetLink": "Enviar enlace de restablecimiento",
"signInOrSignUp": "Iniciar sesión / Registrarse",
"signUp": "Regístrate",
"signUpFreeTierPromo": "¿Nuevo aquí? {signUp} con Google para obtener {credits} créditos gratis cada mes.",
"success": "Inicio de sesión exitoso",
"termsLink": "Términos de uso",
"termsText": "Al hacer clic en \"Siguiente\" o \"Registrarse\", aceptas nuestros",
"title": "Inicia sesión en tu cuenta",
"useApiKey": "Clave API de Comfy",
"useEmailInstead": "Usar correo electrónico en su lugar",
"userAvatar": "Avatar de usuario"
},
"loginButton": {
@@ -282,6 +288,7 @@
"signup": {
"alreadyHaveAccount": "¿Ya tienes una cuenta?",
"emailLabel": "Correo electrónico",
"emailNotEligibleForFreeTier": "El registro por correo electrónico no es elegible para el plan gratuito.",
"emailPlaceholder": "Ingresa tu correo electrónico",
"passwordLabel": "Contraseña",
"passwordPlaceholder": "Ingresa una nueva contraseña",
@@ -692,6 +699,7 @@
"OPENAI_INPUT_FILES": "ARCHIVOS_ENTRADA_OPENAI",
"PHOTOMAKER": "PHOTOMAKER",
"PIXVERSE_TEMPLATE": "PLANTILLA PIXVERSE",
"POSE_KEYPOINT": "POSE_KEYPOINT",
"RECRAFT_COLOR": "COLOR RECRAFT",
"RECRAFT_CONTROLS": "CONTROLES RECRAFT",
"RECRAFT_V3_STYLE": "ESTILO RECRAFT V3",
@@ -951,6 +959,7 @@
"imageUrl": "URL de la imagen",
"import": "Importar",
"inProgress": "En progreso",
"inSubgraph": "en subgrafo '{name}'",
"increment": "Incrementar",
"info": "Información del Nodo",
"input": "Entrada",
@@ -1108,6 +1117,7 @@
"updated": "Actualizado",
"updating": "Actualizando",
"upload": "Subir",
"uploadAlreadyInProgress": "La carga ya está en curso",
"usageHint": "Sugerencia de uso",
"use": "Usar",
"user": "Usuario",
@@ -1331,10 +1341,29 @@
"switchToSelectButton": "Cambiar a Seleccionar"
},
"beta": "Modo App Beta - Enviar comentarios",
"builder": {
"exit": "Salir del constructor",
"exitConfirmMessage": "Tienes cambios sin guardar que se perderán\n¿Salir sin guardar?",
"exitConfirmTitle": "¿Salir del constructor de aplicaciones?",
"inputsDesc": "Los usuarios interactuarán y ajustarán estos para generar sus resultados.",
"inputsExample": "Ejemplos: “Cargar imagen”, “Prompt de texto”, “Pasos”",
"noInputs": "Aún no se han agregado entradas",
"noOutputs": "Aún no se han agregado nodos de salida",
"outputsDesc": "Conecta al menos un nodo de salida para que los usuarios vean los resultados después de ejecutar.",
"outputsExample": "Ejemplos: “Guardar imagen” o “Guardar video”",
"promptAddInputs": "Haz clic en los parámetros del nodo para agregarlos aquí como entradas",
"promptAddOutputs": "Haz clic en los nodos de salida para agregarlos aquí. Estos serán los resultados generados.",
"title": "Modo constructor de aplicaciones"
},
"downloadAll": "Descargar todo",
"dragAndDropImage": "Arrastra y suelta una imagen",
"giveFeedback": "Enviar comentarios",
"graphMode": "Modo gráfico",
"linearMode": "Modo App",
"queue": {
"clear": "Limpiar cola",
"clickToClear": "Haz clic para limpiar la cola"
},
"rerun": "Volver a ejecutar",
"reuseParameters": "Reutilizar parámetros",
"runCount": "Número de ejecuciones:",
@@ -1554,6 +1583,9 @@
"nodePack": "Paquete de Nodos",
"nodePackInfo": "Información del paquete de nodos",
"notAvailable": "No Disponible",
"packInstall": {
"nodeIdRequired": "Se requiere el ID del nodo para la instalación"
},
"packsSelected": "Paquetes Seleccionados",
"repository": "Repositorio",
"restartToApplyChanges": "Para aplicar los cambios, por favor reinicia ComfyUI",
@@ -1868,11 +1900,18 @@
"showLinks": "Mostrar enlaces"
},
"missingModelsDialog": {
"customModelsInstruction": "Tendrás que encontrarlos y descargarlos manualmente. Búscalos en línea (prueba Civitai o Hugging Face) o contacta al proveedor original del flujo de trabajo.",
"customModelsWarning": "Algunos de estos son modelos personalizados que no reconocemos.",
"description": "Este flujo de trabajo requiere modelos que aún no has descargado.",
"doNotAskAgain": "No mostrar esto de nuevo",
"missingModels": "Modelos faltantes",
"missingModelsMessage": "Al cargar el gráfico, no se encontraron los siguientes modelos",
"downloadAll": "Descargar todo",
"downloadAvailable": "Descargar disponibles",
"footerDescription": "Descarga y coloca estos modelos en la carpeta correcta.\nLos nodos con modelos faltantes están resaltados en rojo en el lienzo.",
"gotIt": "Entendido",
"reEnableInSettings": "Vuelve a habilitar en {link}",
"reEnableInSettingsLink": "Configuración"
"reEnableInSettingsLink": "Configuración",
"title": "Faltan modelos en este flujo de trabajo",
"totalSize": "Tamaño total de descarga:"
},
"missingNodes": {
"cloud": {
@@ -2044,7 +2083,10 @@
"openNodeManager": "Abrir Administrador de Nodos",
"quickFixAvailable": "Solución rápida disponible",
"redHighlight": "rojo",
"replaceAll": "Reemplazar todo",
"replaceAllWarning": "Reemplaza todos los nodos disponibles en este grupo.",
"replaceFailed": "Error al reemplazar nodos",
"replaceNode": "Reemplazar nodo",
"replaceSelected": "Reemplazar seleccionados ({count})",
"replaceWarning": "Esto modificará permanentemente el flujo de trabajo. Guarda una copia primero si no estás seguro.",
"replaceable": "Reemplazable",
@@ -2052,7 +2094,11 @@
"replacedAllNodes": "Reemplazados {count} tipo(s) de nodo",
"replacedNode": "Nodo reemplazado: {nodeType}",
"selectAll": "Seleccionar todo",
"skipForNow": "Omitir por ahora"
"skipForNow": "Omitir por ahora",
"swapNodesGuide": "Los siguientes nodos pueden ser reemplazados automáticamente por alternativas compatibles.",
"swapNodesTitle": "Intercambiar nodos",
"unknownNode": "Desconocido",
"willBeReplacedBy": "Este nodo será reemplazado por:"
},
"nodeTemplates": {
"enterName": "Introduzca el nombre",
@@ -2071,6 +2117,19 @@
},
"title": "Tu dispositivo no es compatible"
},
"painter": {
"background": "Fondo",
"brush": "Pincel",
"clear": "Limpiar",
"color": "Selector de color",
"eraser": "Borrador",
"hardness": "Dureza",
"height": "Alto",
"size": "Tamaño del cursor",
"tool": "Herramienta",
"uploadError": "Error al cargar la imagen del pintor: {status} - {statusText}",
"width": "Ancho"
},
"progressToast": {
"allDownloadsCompleted": "Todas las descargas completadas",
"downloadingModel": "Descargando modelo...",
@@ -2191,6 +2250,22 @@
"inputsNone": "SIN ENTRADAS",
"inputsNoneTooltip": "El nodo no tiene entradas",
"locateNode": "Localizar nodo en el lienzo",
"missingNodePacks": {
"applyChanges": "Aplicar cambios",
"cloudMessage": "Este flujo de trabajo requiere nodos personalizados que aún no están disponibles en Comfy Cloud.",
"collapse": "Colapsar",
"expand": "Expandir",
"installAll": "Instalar todo",
"installNodePack": "Instalar paquete de nodos",
"installed": "Instalado",
"installing": "Instalando...",
"ossMessage": "Este flujo de trabajo utiliza nodos personalizados que aún no has instalado.",
"searchInManager": "Buscar en el Gestor de Nodos",
"title": "Paquetes de nodos faltantes",
"unknownPack": "Paquete desconocido",
"unsupportedTitle": "Paquetes de nodos no compatibles",
"viewInManager": "Ver en el Gestor"
},
"mute": "Silenciar",
"noErrors": "Sin errores",
"noSelection": "Selecciona un nodo para ver sus propiedades e información.",
@@ -2684,7 +2759,9 @@
"addCreditsLabel": "Agrega más créditos cuando quieras",
"benefits": {
"benefit1": "Créditos mensuales para Nodos de Socio — recarga cuando sea necesario",
"benefit2": "Hasta 30 min de tiempo de ejecución por trabajo"
"benefit1FreeTier": "Más créditos mensuales, recarga en cualquier momento",
"benefit2": "Hasta 30 min de tiempo de ejecución por trabajo",
"benefit3": "Usa tus propios modelos (Creator & Pro)"
},
"beta": "BETA",
"billedMonthly": "Facturado mensualmente",
@@ -2722,6 +2799,21 @@
"description": "Elige el mejor plan para ti",
"descriptionWorkspace": "Elige el mejor plan para tu espacio de trabajo",
"expiresDate": "Caduca el {date}",
"freeTier": {
"description": "Tu plan gratuito incluye {credits} créditos cada mes para probar Comfy Cloud.",
"descriptionGeneric": "Tu plan gratuito incluye una asignación mensual de créditos para probar Comfy Cloud.",
"nextRefresh": "Tus créditos se renovarán el {date}.",
"outOfCredits": {
"subtitle": "Suscríbete para desbloquear recargas y más",
"title": "Te has quedado sin créditos gratuitos"
},
"subscribeCta": "Suscríbete para más",
"title": "Estás en el plan gratuito",
"topUpBlocked": {
"title": "Desbloquea recargas y más"
},
"upgradeCta": "Ver planes"
},
"gpuLabel": "RTX 6000 Pro (96GB VRAM)",
"haveQuestions": "¿Tienes preguntas o buscas soluciones empresariales?",
"invoiceHistory": "Historial de facturas",
@@ -2732,6 +2824,7 @@
"maxDuration": {
"creator": "30 min",
"founder": "30 min",
"free": "30 min",
"pro": "1 h",
"standard": "30 min"
},
@@ -2804,6 +2897,9 @@
"founder": {
"name": "Edición Fundador"
},
"free": {
"name": "Gratis"
},
"pro": {
"name": "Pro"
},

View File

@@ -2137,6 +2137,35 @@
}
}
},
"CropByBBoxes": {
"description": "Recorta y redimensiona regiones del lote de imágenes de entrada según las cajas delimitadoras proporcionadas.",
"display_name": "CropByBBoxes",
"inputs": {
"bboxes": {
"name": "cajas delimitadoras"
},
"image": {
"name": "imagen"
},
"output_height": {
"name": "alto de salida",
"tooltip": "Alto al que se redimensiona cada recorte."
},
"output_width": {
"name": "ancho de salida",
"tooltip": "Ancho al que se redimensiona cada recorte."
},
"padding": {
"name": "relleno",
"tooltip": "Relleno extra en píxeles añadido a cada lado de la caja antes de recortar."
}
},
"outputs": {
"0": {
"tooltip": "Todos los recortes apilados en un solo lote de imágenes."
}
}
},
"CropMask": {
"display_name": "CropMask",
"inputs": {
@@ -3701,6 +3730,57 @@
}
}
},
"GeminiNanoBanana2": {
"description": "Genera o edita imágenes de forma síncrona a través de la API de Google Vertex.",
"display_name": "Nano Banana 2",
"inputs": {
"aspect_ratio": {
"name": "aspect_ratio",
"tooltip": "Si se establece en 'auto', coincide con la relación de aspecto de tu imagen de entrada; si no se proporciona imagen, normalmente se genera un cuadrado 16:9."
},
"control_after_generate": {
"name": "control after generate"
},
"files": {
"name": "files",
"tooltip": "Archivo(s) opcional(es) para usar como contexto para el modelo. Acepta entradas del nodo Gemini Generate Content Input Files."
},
"images": {
"name": "images",
"tooltip": "Imagen(es) de referencia opcional(es). Para incluir varias imágenes, utiliza el nodo Batch Images (hasta 14)."
},
"model": {
"name": "model"
},
"prompt": {
"name": "prompt",
"tooltip": "Texto descriptivo de la imagen a generar o de las ediciones a aplicar. Incluye cualquier restricción, estilo o detalle que el modelo deba seguir."
},
"resolution": {
"name": "resolution",
"tooltip": "Resolución de salida objetivo. Para 2K/4K se utiliza el escalador nativo de Gemini."
},
"response_modalities": {
"name": "response_modalities"
},
"seed": {
"name": "seed",
"tooltip": "Cuando la semilla se fija a un valor específico, el modelo intenta proporcionar la misma respuesta en solicitudes repetidas. No se garantiza una salida determinista. Además, cambiar el modelo o los parámetros, como la temperatura, puede causar variaciones en la respuesta incluso usando la misma semilla. Por defecto, se utiliza un valor de semilla aleatorio."
},
"system_prompt": {
"name": "system_prompt",
"tooltip": "Instrucciones fundamentales que dictan el comportamiento de la IA."
},
"thinking_level": {
"name": "thinking_level"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"GeminiNode": {
"description": "Genera respuestas de texto con el modelo de IA Gemini de Google. Puede proporcionar múltiples tipos de entradas (texto, imágenes, audio, video) como contexto para generar respuestas más relevantes y significativas.",
"display_name": "Google Gemini",
@@ -12978,6 +13058,90 @@
}
}
},
"SDPoseDrawKeypoints": {
"display_name": "SDPoseDrawKeypoints",
"inputs": {
"draw_body": {
"name": "dibujar cuerpo"
},
"draw_face": {
"name": "dibujar rostro"
},
"draw_feet": {
"name": "dibujar pies"
},
"draw_hands": {
"name": "dibujar manos"
},
"face_point_size": {
"name": "tamaño de punto facial"
},
"keypoints": {
"name": "puntos clave"
},
"score_threshold": {
"name": "umbral de puntuación"
},
"stick_width": {
"name": "ancho de línea"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"SDPoseFaceBBoxes": {
"display_name": "SDPoseFaceBBoxes",
"inputs": {
"force_square": {
"name": "forzar cuadrado",
"tooltip": "Expande el eje más corto de la caja para que la región recortada sea siempre cuadrada."
},
"keypoints": {
"name": "puntos clave"
},
"scale": {
"name": "escala",
"tooltip": "Multiplicador para el área de la caja delimitadora alrededor de cada rostro detectado."
}
},
"outputs": {
"0": {
"name": "cajas delimitadoras",
"tooltip": "Cajas delimitadoras de rostro por fotograma, compatibles con la entrada de cajas delimitadoras de SDPoseKeypointExtractor."
}
}
},
"SDPoseKeypointExtractor": {
"description": "Extrae puntos clave de pose de imágenes usando el modelo SDPose: https://huggingface.co/Comfy-Org/SDPose/tree/main/checkpoints",
"display_name": "SDPoseKeypointExtractor",
"inputs": {
"batch_size": {
"name": "tamaño de lote"
},
"bboxes": {
"name": "cajas delimitadoras",
"tooltip": "Cajas delimitadoras opcionales para detecciones más precisas. Requerido para la detección de múltiples personas."
},
"image": {
"name": "imagen"
},
"model": {
"name": "modelo"
},
"vae": {
"name": "vae"
}
},
"outputs": {
"0": {
"name": "puntos clave",
"tooltip": "Puntos clave en formato de marco OpenPose (ancho_lienzo, alto_lienzo, personas)"
}
}
},
"SDTurboScheduler": {
"display_name": "SDTurboScheduler",
"inputs": {

View File

@@ -225,6 +225,7 @@
"login": {
"andText": "و",
"backToLogin": "بازگشت به ورود",
"backToSocialLogin": "ثبت‌نام با Google یا Github",
"confirmPasswordLabel": "تأیید رمز عبور",
"confirmPasswordPlaceholder": "رمز عبور را مجدداً وارد کنید",
"didntReceiveEmail": "ایمیلی دریافت نکردید؟ با ما تماس بگیرید:",
@@ -233,6 +234,9 @@
"failed": "ورود ناموفق بود",
"forgotPassword": "رمز عبور را فراموش کرده‌اید؟",
"forgotPasswordError": "ارسال ایمیل بازیابی رمز عبور ناموفق بود",
"freeTierBadge": "واجد شرایط طرح رایگان",
"freeTierDescription": "با ثبت‌نام از طریق Google هر ماه {credits} اعتبار رایگان دریافت کنید. نیاز به کارت نیست.",
"freeTierDescriptionGeneric": "با ثبت‌نام از طریق Google هر ماه اعتبار رایگان دریافت کنید. نیاز به کارت نیست.",
"insecureContextWarning": "این اتصال ناامن است (HTTP) - در صورت ادامه ورود، اطلاعات شما ممکن است توسط مهاجمان رهگیری شود.",
"loginButton": "ورود",
"loginWithGithub": "ورود با Github",
@@ -251,11 +255,13 @@
"sendResetLink": "ارسال لینک بازیابی",
"signInOrSignUp": "ورود / ثبت‌نام",
"signUp": "ثبت‌نام",
"signUpFreeTierPromo": "جدید هستید؟ با {signUp} از طریق Google هر ماه {credits} اعتبار رایگان دریافت کنید.",
"success": "ورود موفقیت‌آمیز بود",
"termsLink": "شرایط استفاده",
"termsText": "با کلیک بر روی «بعدی» یا «ثبت‌نام»، شما با",
"title": "ورود به حساب کاربری",
"useApiKey": "کلید Comfy API",
"useEmailInstead": "استفاده از ایمیل به جای آن",
"userAvatar": "آواتار کاربر"
},
"loginButton": {
@@ -282,6 +288,7 @@
"signup": {
"alreadyHaveAccount": "قبلاً حساب کاربری دارید؟",
"emailLabel": "ایمیل",
"emailNotEligibleForFreeTier": "ثبت‌نام با ایمیل شامل طرح رایگان نمی‌شود.",
"emailPlaceholder": "ایمیل خود را وارد کنید",
"passwordLabel": "رمز عبور",
"passwordPlaceholder": "رمز عبور جدید را وارد کنید",
@@ -692,6 +699,7 @@
"OPENAI_INPUT_FILES": "فایل‌های ورودی OpenAI",
"PHOTOMAKER": "photomaker",
"PIXVERSE_TEMPLATE": "قالب Pixverse",
"POSE_KEYPOINT": "POSE_KEYPOINT",
"RECRAFT_COLOR": "رنگ Recraft",
"RECRAFT_CONTROLS": "کنترل‌های Recraft",
"RECRAFT_V3_STYLE": "سبک Recraft V3",
@@ -951,6 +959,7 @@
"imageUrl": "آدرس تصویر",
"import": "وارد کردن",
"inProgress": "در حال انجام",
"inSubgraph": "در زیرگراف «{name}»",
"increment": "افزایش",
"info": "اطلاعات node",
"input": "ورودی",
@@ -1108,6 +1117,7 @@
"updated": "به‌روزرسانی شد",
"updating": "در حال به‌روزرسانی {id}",
"upload": "بارگذاری",
"uploadAlreadyInProgress": "بارگذاری در حال انجام است",
"usageHint": "راهنمای استفاده",
"use": "استفاده",
"user": "کاربر",
@@ -1331,10 +1341,29 @@
"switchToSelectButton": "رفتن به انتخاب"
},
"beta": "حالت برنامه بتا - ارسال بازخورد",
"builder": {
"exit": "خروج از حالت ساخت",
"exitConfirmMessage": "تغییرات ذخیره‌نشده شما از بین خواهد رفت\nخروج بدون ذخیره؟",
"exitConfirmTitle": "خروج از حالت ساخت اپلیکیشن؟",
"inputsDesc": "کاربران می‌توانند این موارد را تنظیم کنند تا خروجی مورد نظر خود را تولید نمایند.",
"inputsExample": "مثال‌ها: «بارگذاری تصویر»، «متن راهنما»، «تعداد مراحل»",
"noInputs": "هنوز ورودی‌ای اضافه نشده است",
"noOutputs": "هنوز گره خروجی اضافه نشده است",
"outputsDesc": "حداقل یک گره خروجی متصل کنید تا کاربران پس از اجرا نتایج را مشاهده کنند.",
"outputsExample": "مثال‌ها: «ذخیره تصویر» یا «ذخیره ویدیو»",
"promptAddInputs": "برای افزودن پارامترها به عنوان ورودی، روی پارامترهای گره کلیک کنید",
"promptAddOutputs": "برای افزودن خروجی، روی گره‌های خروجی کلیک کنید. این‌ها نتایج تولیدشده خواهند بود.",
"title": "حالت ساخت اپلیکیشن"
},
"downloadAll": "دانلود همه",
"dragAndDropImage": "تصویر را بکشید و رها کنید",
"giveFeedback": "ارسال بازخورد",
"graphMode": "حالت گراف",
"linearMode": "حالت برنامه",
"queue": {
"clear": "پاک‌سازی صف",
"clickToClear": "برای پاک‌سازی صف کلیک کنید"
},
"rerun": "اجرای مجدد",
"reuseParameters": "استفاده مجدد از پارامترها",
"runCount": "تعداد اجرا: ",
@@ -1554,6 +1583,9 @@
"nodePack": "بسته نود",
"nodePackInfo": "اطلاعات Node Pack",
"notAvailable": "در دسترس نیست",
"packInstall": {
"nodeIdRequired": "شناسه node برای نصب الزامی است"
},
"packsSelected": "بسته انتخاب شد",
"repository": "مخزن",
"restartToApplyChanges": "برای اعمال تغییرات، لطفاً ComfyUI را مجدداً راه‌اندازی کنید",
@@ -1868,11 +1900,18 @@
"showLinks": "نمایش پیوندها"
},
"missingModelsDialog": {
"customModelsInstruction": "باید این مدل‌ها را به صورت دستی پیدا و دانلود کنید. آن‌ها را به صورت آنلاین جستجو کنید (Civitai یا Hugging Face را امتحان کنید) یا با ارائه‌دهنده اصلی گردش‌کار تماس بگیرید.",
"customModelsWarning": "برخی از این مدل‌ها سفارشی هستند و ما آن‌ها را نمی‌شناسیم.",
"description": "این گردش‌کار به مدل‌هایی نیاز دارد که هنوز آن‌ها را دانلود نکرده‌اید.",
"doNotAskAgain": "دیگر نمایش داده نشود",
"missingModels": "مدل‌های مفقود",
"missingModelsMessage": "هنگام بارگذاری گراف، مدل‌های زیر یافت نشدند",
"downloadAll": "دانلود همه",
"downloadAvailable": "دانلود موجود",
"footerDescription": "این مدل‌ها را دانلود کرده و در پوشه صحیح قرار دهید.\nگرههایی که مدل آن‌ها موجود نیست، روی بوم به رنگ قرمز نمایش داده می‌شوند.",
"gotIt": "متوجه شدم",
"reEnableInSettings": "فعال‌سازی مجدد در {link}",
"reEnableInSettingsLink": "تنظیمات"
"reEnableInSettingsLink": "تنظیمات",
"title": "این گردش‌کار فاقد مدل‌ها است",
"totalSize": "حجم کل دانلود:"
},
"missingNodes": {
"cloud": {
@@ -2044,7 +2083,10 @@
"openNodeManager": "باز کردن Node Manager",
"quickFixAvailable": "رفع سریع در دسترس است",
"redHighlight": "قرمز",
"replaceAll": "جایگزینی همه",
"replaceAllWarning": "همه گره‌های موجود در این گروه جایگزین خواهند شد.",
"replaceFailed": "جایگزینی نودها ناموفق بود",
"replaceNode": "جایگزینی گره",
"replaceSelected": "جایگزینی انتخاب‌شده‌ها ({count})",
"replaceWarning": "این کار workflow را به طور دائمی تغییر می‌دهد. اگر مطمئن نیستید، ابتدا یک نسخه ذخیره کنید.",
"replaceable": "قابل جایگزینی",
@@ -2052,7 +2094,11 @@
"replacedAllNodes": "{count} نوع نود جایگزین شد",
"replacedNode": "نود جایگزین شد: {nodeType}",
"selectAll": "انتخاب همه",
"skipForNow": "فعلاً رد شود"
"skipForNow": "فعلاً رد شود",
"swapNodesGuide": "گره‌های زیر می‌توانند به‌صورت خودکار با گزینه‌های سازگار جایگزین شوند.",
"swapNodesTitle": "جایگزینی گره‌ها",
"unknownNode": "ناشناخته",
"willBeReplacedBy": "این گره جایگزین خواهد شد با:"
},
"nodeTemplates": {
"enterName": "نام را وارد کنید",
@@ -2071,6 +2117,19 @@
},
"title": "دستگاه شما پشتیبانی نمی‌شود"
},
"painter": {
"background": "پس‌زمینه",
"brush": "براش",
"clear": "پاک‌سازی",
"color": "انتخاب رنگ",
"eraser": "پاک‌کن",
"hardness": "سختی",
"height": "ارتفاع",
"size": "اندازه نشانگر",
"tool": "ابزار",
"uploadError": "بارگذاری تصویر painter ناموفق بود: {status} - {statusText}",
"width": "عرض"
},
"progressToast": {
"allDownloadsCompleted": "همه دانلودها تکمیل شدند",
"downloadingModel": "در حال دانلود مدل...",
@@ -2191,6 +2250,22 @@
"inputsNone": "بدون ورودی",
"inputsNoneTooltip": "این نود ورودی ندارد",
"locateNode": "یافتن node در canvas",
"missingNodePacks": {
"applyChanges": "اعمال تغییرات",
"cloudMessage": "این workflow به nodeهای سفارشی نیاز دارد که هنوز در Comfy Cloud موجود نیستند.",
"collapse": "جمع کردن",
"expand": "باز کردن",
"installAll": "نصب همه",
"installNodePack": "نصب پک node",
"installed": "نصب شد",
"installing": "در حال نصب...",
"ossMessage": "این workflow از nodeهای سفارشی استفاده می‌کند که هنوز نصب نکرده‌اید.",
"searchInManager": "جستجو در Node Manager",
"title": "پک‌های node مفقود",
"unknownPack": "پک ناشناخته",
"unsupportedTitle": "پک‌های node پشتیبانی‌نشده",
"viewInManager": "مشاهده در Manager"
},
"mute": "بی‌صدا",
"noErrors": "بدون خطا",
"noSelection": "یک نود را انتخاب کنید تا ویژگی‌ها و اطلاعات آن نمایش داده شود.",
@@ -2696,7 +2771,9 @@
"addCreditsLabel": "هر زمان اعتبار بیشتری اضافه کنید",
"benefits": {
"benefit1": "۱۰ دلار اعتبار ماهانه برای Partner Nodes — در صورت نیاز شارژ کنید",
"benefit2": "تا ۳۰ دقیقه زمان اجرا برای هر کار"
"benefit1FreeTier": "اعتبار ماهانه بیشتر، شارژ مجدد در هر زمان",
"benefit2": "تا ۳۰ دقیقه زمان اجرا برای هر کار",
"benefit3": "امکان استفاده از مدل‌های شخصی (Creator و Pro)"
},
"beta": "بتا",
"billedMonthly": "صورتحساب ماهانه",
@@ -2734,6 +2811,21 @@
"description": "بهترین طرح را برای خود انتخاب کنید",
"descriptionWorkspace": "بهترین طرح را برای فضای کاری خود انتخاب کنید",
"expiresDate": "انقضا در {date}",
"freeTier": {
"description": "طرح رایگان شما شامل {credits} اعتبار در هر ماه برای استفاده از Comfy Cloud است.",
"descriptionGeneric": "طرح رایگان شما شامل اعتبار ماهانه برای استفاده از Comfy Cloud است.",
"nextRefresh": "اعتبار شما در تاریخ {date} به‌روزرسانی می‌شود.",
"outOfCredits": {
"subtitle": "با اشتراک، امکان شارژ مجدد و امکانات بیشتر را فعال کنید",
"title": "اعتبار رایگان شما تمام شده است"
},
"subscribeCta": "اشتراک برای اعتبار بیشتر",
"title": "شما در طرح رایگان هستید",
"topUpBlocked": {
"title": "امکان شارژ مجدد و امکانات بیشتر را فعال کنید"
},
"upgradeCta": "مشاهده طرح‌ها"
},
"gpuLabel": "RTX 6000 Pro (۹۶ گیگابایت VRAM)",
"haveQuestions": "سوالی دارید یا به دنبال راهکار سازمانی هستید؟",
"invoiceHistory": "تاریخچه فاکتورها",
@@ -2744,6 +2836,7 @@
"maxDuration": {
"creator": "۳۰ دقیقه",
"founder": "۳۰ دقیقه",
"free": "۳۰ دقیقه",
"pro": "۱ ساعت",
"standard": "۳۰ دقیقه"
},
@@ -2816,6 +2909,9 @@
"founder": {
"name": "نسخه بنیان‌گذاران"
},
"free": {
"name": "رایگان"
},
"pro": {
"name": "حرفه‌ای"
},

View File

@@ -2137,6 +2137,35 @@
}
}
},
"CropByBBoxes": {
"description": "برش و تغییر اندازه نواحی از دسته تصویر ورودی بر اساس جعبه‌های مرزی ارائه‌شده.",
"display_name": "CropByBBoxes",
"inputs": {
"bboxes": {
"name": "جعبه‌های مرزی"
},
"image": {
"name": "تصویر"
},
"output_height": {
"name": "ارتفاع خروجی",
"tooltip": "ارتفاعی که هر برش به آن تغییر اندازه داده می‌شود."
},
"output_width": {
"name": "عرض خروجی",
"tooltip": "عرضی که هر برش به آن تغییر اندازه داده می‌شود."
},
"padding": {
"name": "حاشیه",
"tooltip": "حاشیه اضافی (بر حسب پیکسل) که به هر طرف جعبه مرزی قبل از برش اضافه می‌شود."
}
},
"outputs": {
"0": {
"tooltip": "تمام برش‌ها به صورت یک دسته تصویر واحد جمع‌آوری شده‌اند."
}
}
},
"CropMask": {
"display_name": "برش Mask",
"inputs": {
@@ -3701,6 +3730,57 @@
}
}
},
"GeminiNanoBanana2": {
"description": "تولید یا ویرایش تصاویر به صورت همزمان از طریق Google Vertex API.",
"display_name": "Nano Banana ۲",
"inputs": {
"aspect_ratio": {
"name": "نسبت تصویر",
"tooltip": "اگر روی 'auto' تنظیم شود، با نسبت تصویر ورودی شما مطابقت دارد؛ اگر تصویری ارائه نشود، معمولاً یک مربع با نسبت ۱۶:۹ تولید می‌شود."
},
"control_after_generate": {
"name": "کنترل پس از تولید"
},
"files": {
"name": "فایل‌ها",
"tooltip": "فایل(های) اختیاری برای استفاده به عنوان زمینه برای مدل. ورودی‌ها را از node Gemini Generate Content Input Files می‌پذیرد."
},
"images": {
"name": "تصاویر",
"tooltip": "تصویر(های) مرجع اختیاری. برای افزودن چند تصویر، از node Batch Images استفاده کنید (تا ۱۴ تصویر)."
},
"model": {
"name": "مدل"
},
"prompt": {
"name": "پرامپت",
"tooltip": "توضیح متنی درباره تصویری که باید تولید شود یا ویرایش‌هایی که باید اعمال گردد. هرگونه محدودیت، سبک یا جزئیاتی که مدل باید رعایت کند را وارد کنید."
},
"resolution": {
"name": "وضوح تصویر",
"tooltip": "وضوح خروجی هدف. برای ۲K/۴K از upscaler بومی Gemini استفاده می‌شود."
},
"response_modalities": {
"name": "حالت‌های پاسخ"
},
"seed": {
"name": "seed",
"tooltip": "زمانی که مقدار seed ثابت باشد، مدل تلاش می‌کند تا پاسخ مشابهی برای درخواست‌های تکراری ارائه دهد. خروجی قطعی تضمین نمی‌شود. همچنین، تغییر مدل یا تنظیمات پارامترها مانند دما (temperature) می‌تواند باعث تغییر در پاسخ حتی با همان مقدار seed شود. به طور پیش‌فرض، مقدار seed به صورت تصادفی انتخاب می‌شود."
},
"system_prompt": {
"name": "پرامپت سیستمی",
"tooltip": "دستورالعمل‌های پایه‌ای که رفتار هوش مصنوعی را تعیین می‌کند."
},
"thinking_level": {
"name": "سطح تفکر"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"GeminiNode": {
"description": "تولید پاسخ متنی با مدل هوش مصنوعی Gemini گوگل. می‌توانید انواع مختلفی از ورودی‌ها (متن، تصویر، صوت، ویدئو) را به عنوان زمینه برای تولید پاسخ‌های مرتبط‌تر و معنادارتر ارائه دهید.",
"display_name": "Google Gemini",
@@ -12978,6 +13058,90 @@
}
}
},
"SDPoseDrawKeypoints": {
"display_name": "SDPoseDrawKeypoints",
"inputs": {
"draw_body": {
"name": "ترسیم بدن"
},
"draw_face": {
"name": "ترسیم صورت"
},
"draw_feet": {
"name": "ترسیم پاها"
},
"draw_hands": {
"name": "ترسیم دست‌ها"
},
"face_point_size": {
"name": "اندازه نقطه صورت"
},
"keypoints": {
"name": "نقاط کلیدی"
},
"score_threshold": {
"name": "آستانه امتیاز"
},
"stick_width": {
"name": "ضخامت خط"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"SDPoseFaceBBoxes": {
"display_name": "SDPoseFaceBBoxes",
"inputs": {
"force_square": {
"name": "اجبار به مربع بودن",
"tooltip": "محور کوتاه‌تر جعبه مرزی را گسترش می‌دهد تا ناحیه برش همیشه مربع باشد."
},
"keypoints": {
"name": "نقاط کلیدی"
},
"scale": {
"name": "مقیاس",
"tooltip": "ضریب بزرگنمایی برای ناحیه جعبه مرزی اطراف هر صورت شناسایی‌شده."
}
},
"outputs": {
"0": {
"name": "جعبه‌های مرزی",
"tooltip": "جعبه‌های مرزی صورت برای هر فریم، سازگار با ورودی bboxes در SDPoseKeypointExtractor."
}
}
},
"SDPoseKeypointExtractor": {
"description": "استخراج نقاط کلیدی ژست از تصاویر با استفاده از مدل SDPose: https://huggingface.co/Comfy-Org/SDPose/tree/main/checkpoints",
"display_name": "SDPoseKeypointExtractor",
"inputs": {
"batch_size": {
"name": "اندازه دسته"
},
"bboxes": {
"name": "جعبه‌های مرزی",
"tooltip": "جعبه‌های مرزی اختیاری برای تشخیص دقیق‌تر. برای تشخیص چند نفره الزامی است."
},
"image": {
"name": "تصویر"
},
"model": {
"name": "مدل"
},
"vae": {
"name": "vae"
}
},
"outputs": {
"0": {
"name": "نقاط کلیدی",
"tooltip": "نقاط کلیدی در قالب قاب OpenPose (عرض بوم، ارتفاع بوم، افراد)"
}
}
},
"SDTurboScheduler": {
"display_name": "زمان‌بندی SDTurbo",
"inputs": {

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