Compare commits

...

27 Commits

Author SHA1 Message Date
jaeone94
4dc67b375b fix: node replacement fails after execution and modal sync
- Detect missing nodes by unregistered type instead of has_errors flag,
  which gets cleared by clearAllNodeErrorFlags during execution
- Sync modal replace action with executionErrorStore so Errors Tab
  updates immediately when nodes are replaced from the dialog
2026-02-27 10:37:47 +09:00
jaeone94
1d8a01cdf8 fix: ensure node replacement data loads before workflow processing
Await nodeReplacementStore.load() before collectMissingNodesAndModels
to prevent race condition where replacement mappings are not yet
available when determining isReplaceable flag.
2026-02-27 00:14:43 +09:00
jaeone94
b585dfa4fc fix: address review feedback for handleReplaceAll
- Remove redundant parameter that shadowed composable ref
- Only remove actually replaced types from error list on partial success
2026-02-26 23:05:12 +09:00
jaeone94
1be6d27024 refactor: Destructure defineProps in SwapNodesCard.vue 2026-02-26 22:03:42 +09:00
jaeone94
5aa4baf116 fix: address review feedback for node replacement
- Use i18n key for 'Swap Nodes' group title
- Preserve partial replacement results on error instead of returning empty array
2026-02-26 21:53:00 +09:00
jaeone94
7d69a0db5b fix: remove unused export from scanMissingNodes 2026-02-26 20:28:10 +09:00
jaeone94
83bb4300e3 fix: address code review feedback on node replacement
- Add error toast in replaceNodesInPlace for user-visible failure
  feedback, returning empty array on error instead of throwing
- Guard removeMissingNodesByType behind replacement success check
  (replaced.length > 0) to prevent stale error list updates
- Sort buildMissingNodeGroups by priority for deterministic UI order
  (Swap Nodes 0 → Missing Node Packs 1 → Execution Errors)
- Add aux_id fallback and cnr_id precedence tests for getCnrIdFromNode
- Split replaceAllWarning from replaceWarning to fix i18n key mismatch
  between TabErrors tooltip and MissingNodesContent dialog
2026-02-26 20:24:56 +09:00
jaeone94
0d58a92e34 feat: add node replacement UI to Errors Tab
Integrate the existing node replacement functionality into the Errors
Tab, allowing users to replace missing nodes directly from the side
panel without opening the modal dialog.
New components:
- SwapNodesCard: container with guidance label and grouped rows
- SwapNodeGroupRow: per-type replacement row with expand/collapse,
  node instance list, locate button, and replace action
Bug fixes discovered during implementation:
- Fix stale canvas rendering after replacement by calling onNodeAdded
  to refresh VueNodeData (bypassed by replaceWithMapping)
- Guard initializeVueNodeLayout against duplicate layout creation
- Fix missing node list being overwritten by incomplete server 400
  response — replaced with full graph rescan via useMissingNodeScan
- Add removeMissingNodesByType to prune replaced types from error list
Cleanup:
- Remove dead code: buildMissingNodeHint, createMissingNodeTypeFromError
2026-02-26 20:24:44 +09: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
123 changed files with 5542 additions and 734 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)
)
}

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`)
})
})

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.6",
"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 { useAppModeStore } from '@/stores/appModeStore'
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 appModeStore = useAppModeStore()
const { activeSidebarTabId, activeSidebarTab } = storeToRefs(sidebarTabStore)
const { bottomPanelVisible } = storeToRefs(useBottomPanelStore())
const { isOpen: rightSidePanelVisible } = storeToRefs(rightSidePanelStore)
const showOffsideSplitter = computed(
() => rightSidePanelVisible.value || appModeStore.mode === '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

@@ -62,7 +62,7 @@
size="md"
:aria-pressed="
isQueuePanelV2Enabled
? activeSidebarTabId === 'assets'
? activeSidebarTabId === 'job-history'
: isQueueProgressOverlayEnabled
? isQueueOverlayExpanded
: undefined
@@ -127,13 +127,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"
@@ -241,6 +243,9 @@ const inlineProgressSummaryTarget = computed(() => {
}
return progressTarget.value
})
const shouldHideInlineProgressSummary = computed(
() => isQueueProgressOverlayEnabled.value && isQueueOverlayExpanded.value
)
const queueHistoryTooltipConfig = computed(() =>
buildTooltipConfig(t('sideToolbar.queueProgressOverlay.viewJobHistory'))
)
@@ -283,7 +288,7 @@ onMounted(() => {
const toggleQueueOverlay = () => {
if (isQueuePanelV2Enabled.value) {
sidebarTabStore.toggleSidebarTab('assets')
sidebarTabStore.toggleSidebarTab('job-history')
return
}
commandStore.execute('Comfy.Queue.ToggleOverlay')

View File

@@ -47,7 +47,7 @@
<Teleport v-if="inlineProgressTarget" :to="inlineProgressTarget">
<QueueInlineProgress
:hidden="queueOverlayExpanded"
:hidden="shouldHideInlineProgress"
:radius-class="cn(isDocked ? 'rounded-[7px]' : 'rounded-[5px]')"
data-testid="queue-inline-progress"
/>
@@ -287,6 +287,9 @@ const inlineProgressTarget = computed(() => {
if (isDocked.value) return topMenuContainer ?? null
return panelElement.value
})
const shouldHideInlineProgress = computed(
() => !isQueuePanelV2Enabled.value && queueOverlayExpanded
)
watch(
panelElement,
(target) => {

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

@@ -62,6 +62,7 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { useEventListener } from '@vueuse/core'
import { useAppModeStore } from '@/stores/appModeStore'
import type { AppMode } from '@/stores/appModeStore'
@@ -75,6 +76,14 @@ import type { BuilderToolbarStep } from './types'
const { t } = useI18n()
const appModeStore = useAppModeStore()
useEventListener(document, 'keydown', (e: KeyboardEvent) => {
if (e.key !== 'Escape') return
e.preventDefault()
e.stopPropagation()
void appModeStore.exitBuilder()
})
const activeStep = computed(() =>
appModeStore.isBuilderSaving ? 'save' : appModeStore.mode
)

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

@@ -33,12 +33,10 @@ 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.activeState.extra?.linearMode) {
try {
workflow.changeTracker?.checkState()
appModeStore.saveSelectedToWorkflow()
await workflowService.saveWorkflow(workflow)
showSuccessDialog(workflow.filename, appModeStore.isAppMode)
} catch {
@@ -75,6 +73,7 @@ export function useBuilderSave() {
const workflow = workflowStore.activeWorkflow
if (!workflow) return
appModeStore.saveSelectedToWorkflow()
const saved = await workflowService.saveWorkflowAs(workflow, {
filename,
openAsApp

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

@@ -234,6 +234,7 @@ import { isCloud } from '@/platform/distribution/types'
import type { NodeReplacement } from '@/platform/nodeReplacement/types'
import { useNodeReplacement } from '@/platform/nodeReplacement/useNodeReplacement'
import { useDialogStore } from '@/stores/dialogStore'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import type { MissingNodeType } from '@/types/comfy'
import { cn } from '@/utils/tailwindUtil'
import { useMissingNodes } from '@/workbench/extensions/manager/composables/nodePack/useMissingNodes'
@@ -245,6 +246,7 @@ const { missingNodeTypes } = defineProps<{
const { missingCoreNodes } = useMissingNodes()
const { replaceNodesInPlace } = useNodeReplacement()
const dialogStore = useDialogStore()
const executionErrorStore = useExecutionErrorStore()
interface ProcessedNode {
label: string
@@ -339,6 +341,11 @@ function handleReplaceSelected() {
replacedTypes.value = nextReplaced
selectedTypes.value = nextSelected
// Sync with execution error store so the Errors Tab updates immediately
if (result.length > 0) {
executionErrorStore.removeMissingNodesByType(result)
}
// Auto-close when all replaceable nodes replaced and no non-replaceable remain
const allReplaced = replaceableNodes.value.every((n) =>
nextReplaced.has(n.label)

View File

@@ -38,7 +38,8 @@
<BottomPanel />
</template>
<template v-if="showUI" #right-side-panel>
<NodePropertiesPanel v-if="!appModeStore.isBuilderMode" />
<AppBuilder v-if="appModeStore.mode === 'builder:select'" />
<NodePropertiesPanel v-else-if="!appModeStore.isBuilderMode" />
</template>
<template #graph-canvas-panel>
<GraphCanvasMenu
@@ -126,6 +127,7 @@ import {
import { useI18n } from 'vue-i18n'
import { isMiddlePointerInput } from '@/base/pointerUtils'
import AppBuilder from '@/components/builder/AppBuilder.vue'
import LiteGraphCanvasSplitterOverlay from '@/components/LiteGraphCanvasSplitterOverlay.vue'
import TopMenuSection from '@/components/TopMenuSection.vue'
import BottomPanel from '@/components/bottomPanel/BottomPanel.vue'

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

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

@@ -168,14 +168,14 @@ const queuedAtValue = computed(() =>
const currentQueueIndex = 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
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

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

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

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

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

@@ -25,7 +25,10 @@ export const TOOLKIT_NODE_NAMES: ReadonlySet<string> = new Set([
// API Nodes
'RecraftRemoveBackgroundNode',
'RecraftVectorizeImageNode',
'KlingOmniProEditVideoNode'
'KlingOmniProEditVideoNode',
// Shader Nodes
'GLSLShader'
])
/**

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": "أدخل كلمة مرور جديدة",
@@ -1331,6 +1338,20 @@
"switchToSelectButton": "الانتقال إلى التحديد"
},
"beta": "وضع التطبيق تجريبي - أرسل ملاحظاتك",
"builder": {
"exit": "خروج من البناء",
"exitConfirmMessage": "لديك تغييرات غير محفوظة ستفقد\nهل تريد الخروج بدون حفظ؟",
"exitConfirmTitle": "الخروج من بناء التطبيق؟",
"inputsDesc": "سيتفاعل المستخدمون مع هذه المدخلات ويعدلونها لإنشاء النتائج.",
"inputsExample": "أمثلة: \"تحميل صورة\"، \"موجه نصي\"، \"خطوات\"",
"noInputs": "لم تتم إضافة أي مدخلات بعد",
"noOutputs": "لم تتم إضافة أي عقد إخراج بعد",
"outputsDesc": "وصل عقدة إخراج واحدة على الأقل حتى يتمكن المستخدمون من رؤية النتائج بعد التشغيل.",
"outputsExample": "أمثلة: \"حفظ صورة\" أو \"حفظ فيديو\"",
"promptAddInputs": "انقر على معلمات العقدة لإضافتها هنا كمدخلات",
"promptAddOutputs": "انقر على عقد الإخراج لإضافتها هنا. هذه ستكون النتائج المُولدة.",
"title": "وضع بناء التطبيق"
},
"downloadAll": "تنزيل الكل",
"dragAndDropImage": "اسحب وأسقط صورة",
"graphMode": "وضع الرسم البياني",
@@ -1868,11 +1889,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": {
@@ -2684,7 +2712,9 @@
"addCreditsLabel": "أضف المزيد من الرصيد في أي وقت",
"benefits": {
"benefit1": "رصيد شهري للعقد الشريكة - تجديد عند الحاجة",
"benefit2": "حتى 30 دقيقة وقت تشغيل لكل مهمة"
"benefit1FreeTier": "رصيد شهري أكثر، مع إمكانية الشحن في أي وقت",
"benefit2": "حتى 30 دقيقة وقت تشغيل لكل مهمة",
"benefit3": "استخدم نماذجك الخاصة (Creator & Pro)"
},
"beta": "نسخة تجريبية",
"billedMonthly": "يتم الفوترة شهريًا",
@@ -2722,6 +2752,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 +2777,7 @@
"maxDuration": {
"creator": "30 دقيقة",
"founder": "30 دقيقة",
"free": "٣٠ دقيقة",
"pro": "ساعة واحدة",
"standard": "30 دقيقة"
},
@@ -2804,6 +2850,9 @@
"founder": {
"name": "إصدار المؤسس"
},
"free": {
"name": "مجاني"
},
"pro": {
"name": "احترافي"
},

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": {
@@ -1883,6 +1888,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",
@@ -3007,6 +3025,20 @@
"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”"
}
},
"missingNodes": {
@@ -3041,7 +3073,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 +3160,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

@@ -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",
@@ -1331,6 +1338,20 @@
"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",
"graphMode": "Modo gráfico",
@@ -1868,11 +1889,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": {
@@ -2684,7 +2712,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 +2752,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 +2777,7 @@
"maxDuration": {
"creator": "30 min",
"founder": "30 min",
"free": "30 min",
"pro": "1 h",
"standard": "30 min"
},
@@ -2804,6 +2850,9 @@
"founder": {
"name": "Edición Fundador"
},
"free": {
"name": "Gratis"
},
"pro": {
"name": "Pro"
},

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": "رمز عبور جدید را وارد کنید",
@@ -1331,6 +1338,20 @@
"switchToSelectButton": "رفتن به انتخاب"
},
"beta": "حالت برنامه بتا - ارسال بازخورد",
"builder": {
"exit": "خروج از حالت ساخت",
"exitConfirmMessage": "تغییرات ذخیره‌نشده شما از بین خواهد رفت\nخروج بدون ذخیره؟",
"exitConfirmTitle": "خروج از حالت ساخت اپلیکیشن؟",
"inputsDesc": "کاربران می‌توانند این موارد را تنظیم کنند تا خروجی مورد نظر خود را تولید نمایند.",
"inputsExample": "مثال‌ها: «بارگذاری تصویر»، «متن راهنما»، «تعداد مراحل»",
"noInputs": "هنوز ورودی‌ای اضافه نشده است",
"noOutputs": "هنوز گره خروجی اضافه نشده است",
"outputsDesc": "حداقل یک گره خروجی متصل کنید تا کاربران پس از اجرا نتایج را مشاهده کنند.",
"outputsExample": "مثال‌ها: «ذخیره تصویر» یا «ذخیره ویدیو»",
"promptAddInputs": "برای افزودن پارامترها به عنوان ورودی، روی پارامترهای گره کلیک کنید",
"promptAddOutputs": "برای افزودن خروجی، روی گره‌های خروجی کلیک کنید. این‌ها نتایج تولیدشده خواهند بود.",
"title": "حالت ساخت اپلیکیشن"
},
"downloadAll": "دانلود همه",
"dragAndDropImage": "تصویر را بکشید و رها کنید",
"graphMode": "حالت گراف",
@@ -1868,11 +1889,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": {
@@ -2696,7 +2724,9 @@
"addCreditsLabel": "هر زمان اعتبار بیشتری اضافه کنید",
"benefits": {
"benefit1": "۱۰ دلار اعتبار ماهانه برای Partner Nodes — در صورت نیاز شارژ کنید",
"benefit2": "تا ۳۰ دقیقه زمان اجرا برای هر کار"
"benefit1FreeTier": "اعتبار ماهانه بیشتر، شارژ مجدد در هر زمان",
"benefit2": "تا ۳۰ دقیقه زمان اجرا برای هر کار",
"benefit3": "امکان استفاده از مدل‌های شخصی (Creator و Pro)"
},
"beta": "بتا",
"billedMonthly": "صورتحساب ماهانه",
@@ -2734,6 +2764,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 +2789,7 @@
"maxDuration": {
"creator": "۳۰ دقیقه",
"founder": "۳۰ دقیقه",
"free": "۳۰ دقیقه",
"pro": "۱ ساعت",
"standard": "۳۰ دقیقه"
},
@@ -2816,6 +2862,9 @@
"founder": {
"name": "نسخه بنیان‌گذاران"
},
"free": {
"name": "رایگان"
},
"pro": {
"name": "حرفه‌ای"
},

View File

@@ -225,6 +225,7 @@
"login": {
"andText": "et",
"backToLogin": "Retour à la connexion",
"backToSocialLogin": "Inscrivez-vous avec Google ou Github à la place",
"confirmPasswordLabel": "Confirmer le mot de passe",
"confirmPasswordPlaceholder": "Entrez à nouveau le même mot de passe",
"didntReceiveEmail": "Vous n'avez pas reçu d'e-mail ? Contactez-nous à",
@@ -233,6 +234,9 @@
"failed": "Échec de la connexion",
"forgotPassword": "Mot de passe oublié?",
"forgotPasswordError": "Échec de l'envoi de l'e-mail de réinitialisation du mot de passe",
"freeTierBadge": "Éligible à loffre gratuite",
"freeTierDescription": "Inscrivez-vous avec Google pour obtenir {credits} crédits gratuits chaque mois. Aucune carte requise.",
"freeTierDescriptionGeneric": "Inscrivez-vous avec Google pour obtenir des crédits gratuits chaque mois. Aucune carte requise.",
"insecureContextWarning": "Cette connexion n'est pas sécurisée (HTTP) - vos identifiants pourraient être interceptés par des attaquants si vous continuez à vous connecter.",
"loginButton": "Se connecter",
"loginWithGithub": "Se connecter avec Github",
@@ -251,11 +255,13 @@
"sendResetLink": "Envoyer le lien de réinitialisation",
"signInOrSignUp": "Se connecter / Sinscrire",
"signUp": "S'inscrire",
"signUpFreeTierPromo": "Nouveau ici ? {signUp} avec Google pour obtenir {credits} crédits gratuits chaque mois.",
"success": "Connexion réussie",
"termsLink": "Conditions d'utilisation",
"termsText": "En cliquant sur \"Suivant\" ou \"S'inscrire\", vous acceptez nos",
"title": "Connectez-vous à votre compte",
"useApiKey": "Clé API Comfy",
"useEmailInstead": "Utiliser le-mail à la place",
"userAvatar": "Avatar utilisateur"
},
"loginButton": {
@@ -282,6 +288,7 @@
"signup": {
"alreadyHaveAccount": "Vous avez déjà un compte?",
"emailLabel": "Email",
"emailNotEligibleForFreeTier": "Linscription par e-mail nest pas éligible à loffre gratuite.",
"emailPlaceholder": "Entrez votre email",
"passwordLabel": "Mot de passe",
"passwordPlaceholder": "Entrez un nouveau mot de passe",
@@ -1331,6 +1338,20 @@
"switchToSelectButton": "Passer à Sélectionner"
},
"beta": "Mode App Bêta - Donnez votre avis",
"builder": {
"exit": "Quitter le mode créateur",
"exitConfirmMessage": "Vous avez des modifications non enregistrées qui seront perdues\nQuitter sans enregistrer ?",
"exitConfirmTitle": "Quitter le créateur dapplication ?",
"inputsDesc": "Les utilisateurs interagiront avec ces paramètres pour générer leurs résultats.",
"inputsExample": "Exemples : « Charger une image », « Prompt texte », « Étapes »",
"noInputs": "Aucune entrée ajoutée pour le moment",
"noOutputs": "Aucun nœud de sortie ajouté pour le moment",
"outputsDesc": "Connectez au moins un nœud de sortie pour que les utilisateurs voient les résultats après lexécution.",
"outputsExample": "Exemples : « Enregistrer limage » ou « Enregistrer la vidéo »",
"promptAddInputs": "Cliquez sur les paramètres du nœud pour les ajouter ici comme entrées",
"promptAddOutputs": "Cliquez sur les nœuds de sortie pour les ajouter ici. Ce seront les résultats générés.",
"title": "Mode créateur dapplication"
},
"downloadAll": "Tout télécharger",
"dragAndDropImage": "Glissez-déposez une image",
"graphMode": "Mode graphique",
@@ -1868,11 +1889,18 @@
"showLinks": "Afficher les liens"
},
"missingModelsDialog": {
"customModelsInstruction": "Vous devrez les trouver et les télécharger manuellement. Cherchez-les en ligne (essayez Civitai ou Hugging Face) ou contactez le créateur du workflow d'origine.",
"customModelsWarning": "Certains de ces modèles sont personnalisés et nous ne les reconnaissons pas.",
"description": "Ce workflow nécessite des modèles que vous n'avez pas encore téléchargés.",
"doNotAskAgain": "Ne plus afficher ce message",
"missingModels": "Modèles manquants",
"missingModelsMessage": "Lors du chargement du graphique, les modèles suivants n'ont pas été trouvés",
"downloadAll": "Tout télécharger",
"downloadAvailable": "Téléchargement disponible",
"footerDescription": "Téléchargez et placez ces modèles dans le dossier approprié.\nLes nœuds avec des modèles manquants sont surlignés en rouge sur le canevas.",
"gotIt": "Ok, compris",
"reEnableInSettings": "Réactiver dans {link}",
"reEnableInSettingsLink": "Paramètres"
"reEnableInSettingsLink": "Paramètres",
"title": "Ce workflow est incomplet : modèles manquants",
"totalSize": "Taille totale du téléchargement :"
},
"missingNodes": {
"cloud": {
@@ -2684,7 +2712,9 @@
"addCreditsLabel": "Ajoutez des crédits à tout moment",
"benefits": {
"benefit1": "Crédits mensuels pour les Nœuds Partenaires — rechargez si nécessaire",
"benefit2": "Jusqu'à 30 min d'exécution par tâche"
"benefit1FreeTier": "Plus de crédits mensuels, recharge à tout moment",
"benefit2": "Jusqu'à 30 min d'exécution par tâche",
"benefit3": "Utilisez vos propres modèles (Creator & Pro)"
},
"beta": "BÊTA",
"billedMonthly": "Facturé mensuellement",
@@ -2722,6 +2752,21 @@
"description": "Choisissez le forfait qui vous convient",
"descriptionWorkspace": "Choisissez la meilleure offre pour votre espace de travail",
"expiresDate": "Expire le {date}",
"freeTier": {
"description": "Votre plan gratuit inclut {credits} crédits chaque mois pour essayer Comfy Cloud.",
"descriptionGeneric": "Votre plan gratuit inclut une allocation mensuelle de crédits pour essayer Comfy Cloud.",
"nextRefresh": "Vos crédits seront renouvelés le {date}.",
"outOfCredits": {
"subtitle": "Abonnez-vous pour débloquer les recharges et plus encore",
"title": "Vous navez plus de crédits gratuits"
},
"subscribeCta": "Abonnez-vous pour plus",
"title": "Vous êtes sur le plan Gratuit",
"topUpBlocked": {
"title": "Débloquez les recharges et plus encore"
},
"upgradeCta": "Voir les offres"
},
"gpuLabel": "RTX 6000 Pro (96GB VRAM)",
"haveQuestions": "Des questions ou besoin d'une offre entreprise ?",
"invoiceHistory": "Historique des factures",
@@ -2732,6 +2777,7 @@
"maxDuration": {
"creator": "30 min",
"founder": "30 min",
"free": "30 min",
"pro": "1 h",
"standard": "30 min"
},
@@ -2804,6 +2850,9 @@
"founder": {
"name": "Édition Fondateur"
},
"free": {
"name": "Gratuit"
},
"pro": {
"name": "Pro"
},

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": "初めての方はこちら。Googleで{signUp}して、毎月{credits}の無料クレジットを獲得しましょう。",
"success": "ログイン成功",
"termsLink": "利用規約",
"termsText": "「次へ」または「サインアップ」をクリックすると、私たちの",
"title": "アカウントにログインする",
"useApiKey": "Comfy APIキー",
"useEmailInstead": "メールアドレスを使用する",
"userAvatar": "ユーザーアバター"
},
"loginButton": {
@@ -282,6 +288,7 @@
"signup": {
"alreadyHaveAccount": "すでにアカウントをお持ちですか?",
"emailLabel": "メール",
"emailNotEligibleForFreeTier": "メールでのサインアップは無料プランの対象外です。",
"emailPlaceholder": "メールアドレスを入力してください",
"passwordLabel": "パスワード",
"passwordPlaceholder": "新しいパスワードを入力してください",
@@ -1331,6 +1338,20 @@
"switchToSelectButton": "選択に切り替え"
},
"beta": "アプリモード ベータ版 - フィードバックを送る",
"builder": {
"exit": "ビルダーを終了",
"exitConfirmMessage": "保存されていない変更は失われます。\n保存せずに終了しますか",
"exitConfirmTitle": "アプリビルダーを終了しますか?",
"inputsDesc": "ユーザーはこれらを操作して出力を生成します。",
"inputsExample": "例:「画像を読み込む」「テキストプロンプト」「ステップ数」",
"noInputs": "まだ入力が追加されていません",
"noOutputs": "まだ出力ノードが追加されていません",
"outputsDesc": "少なくとも1つの出力ードを接続すると、ユーザーが実行後に結果を確認できます。",
"outputsExample": "例:「画像を保存」「動画を保存」",
"promptAddInputs": "ノードのパラメータをクリックして、ここに入力として追加してください",
"promptAddOutputs": "出力ノードをクリックしてここに追加してください。これが生成される結果となります。",
"title": "アプリビルダーモード"
},
"downloadAll": "すべてダウンロード",
"dragAndDropImage": "画像をドラッグ&ドロップ",
"graphMode": "グラフモード",
@@ -1868,11 +1889,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": {
@@ -2684,7 +2712,9 @@
"addCreditsLabel": "いつでもクレジット追加可能",
"benefits": {
"benefit1": "パートナーノード用月間クレジット — 必要に応じて追加購入可能",
"benefit2": "ジョブあたり最大30分の実行時間"
"benefit1FreeTier": "毎月のクレジット増加、いつでもチャージ可能",
"benefit2": "ジョブあたり最大30分の実行時間",
"benefit3": "独自のモデルを利用可能Creator & Pro"
},
"beta": "ベータ版",
"billedMonthly": "毎月請求",
@@ -2722,6 +2752,21 @@
"description": "あなたに最適なプランを選択してください",
"descriptionWorkspace": "ワークスペースに最適なプランを選択してください",
"expiresDate": "{date} に期限切れ",
"freeTier": {
"description": "無料プランには、Comfy Cloudをお試しいただける毎月{credits}クレジットが含まれています。",
"descriptionGeneric": "無料プランには、Comfy Cloudをお試しいただける毎月のクレジット枠が含まれています。",
"nextRefresh": "クレジットは{date}にリフレッシュされます。",
"outOfCredits": {
"subtitle": "サブスクリプションでチャージや追加機能を利用しましょう",
"title": "無料クレジットがなくなりました"
},
"subscribeCta": "さらに詳しく",
"title": "無料プランをご利用中です",
"topUpBlocked": {
"title": "チャージや追加機能をアンロック"
},
"upgradeCta": "プランを見る"
},
"gpuLabel": "RTX 6000 Pro96GB VRAM",
"haveQuestions": "ご質問やエンタープライズについてのお問い合わせはこちら",
"invoiceHistory": "請求履歴",
@@ -2732,6 +2777,7 @@
"maxDuration": {
"creator": "30分",
"founder": "30分",
"free": "30分",
"pro": "1時間",
"standard": "30分"
},
@@ -2804,6 +2850,9 @@
"founder": {
"name": "ファウンダーエディション"
},
"free": {
"name": "無料"
},
"pro": {
"name": "プロ"
},

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": "처음이신가요? Google로 {signUp} 하여 매월 {credits} 무료 크레딧을 받으세요.",
"success": "로그인 성공",
"termsLink": "이용 약관",
"termsText": "\"다음\" 또는 \"가입하기\"를 클릭하면 우리의",
"title": "계정에 로그인",
"useApiKey": "Comfy API 키",
"useEmailInstead": "이메일로 계속하기",
"userAvatar": "사용자 아바타"
},
"loginButton": {
@@ -282,6 +288,7 @@
"signup": {
"alreadyHaveAccount": "이미 계정이 있으신가요?",
"emailLabel": "이메일",
"emailNotEligibleForFreeTier": "이메일 가입은 무료 등급에 해당되지 않습니다.",
"emailPlaceholder": "이메일을 입력하세요",
"passwordLabel": "비밀번호",
"passwordPlaceholder": "새 비밀번호를 입력하세요",
@@ -1331,6 +1338,20 @@
"switchToSelectButton": "선택으로 전환"
},
"beta": "앱 모드 베타 - 피드백 보내기",
"builder": {
"exit": "빌더 종료",
"exitConfirmMessage": "저장되지 않은 변경사항이 사라집니다\n저장하지 않고 종료하시겠습니까?",
"exitConfirmTitle": "앱 빌더를 종료할까요?",
"inputsDesc": "사용자가 이 항목을 조정하여 결과를 생성할 수 있습니다.",
"inputsExample": "예시: “이미지 불러오기”, “텍스트 프롬프트”, “스텝 수”",
"noInputs": "아직 입력값이 추가되지 않았습니다",
"noOutputs": "아직 출력 노드가 추가되지 않았습니다",
"outputsDesc": "최소 한 개 이상의 출력 노드를 연결해야 실행 후 결과를 볼 수 있습니다.",
"outputsExample": "예시: “이미지 저장” 또는 “비디오 저장”",
"promptAddInputs": "노드 파라미터를 클릭하여 입력값으로 추가하세요",
"promptAddOutputs": "출력 노드를 클릭하여 여기에 추가하세요. 이들이 생성된 결과가 됩니다.",
"title": "앱 빌더 모드"
},
"downloadAll": "모두 다운로드",
"dragAndDropImage": "이미지를 드래그 앤 드롭하세요",
"graphMode": "그래프 모드",
@@ -1868,11 +1889,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": {
@@ -2684,7 +2712,9 @@
"addCreditsLabel": "언제든지 크레딧 추가 가능",
"benefits": {
"benefit1": "파트너 노드 월간 크레딧 — 필요 시 충전",
"benefit2": "작업당 최대 30분 실행 시간"
"benefit1FreeTier": "더 많은 월간 크레딧, 언제든지 충전 가능",
"benefit2": "작업당 최대 30분 실행 시간",
"benefit3": "직접 모델 가져오기(Creator & Pro)"
},
"beta": "베타",
"billedMonthly": "매월 결제",
@@ -2722,6 +2752,21 @@
"description": "가장 적합한 플랜을 선택하세요",
"descriptionWorkspace": "워크스페이스에 가장 적합한 플랜을 선택하세요",
"expiresDate": "만료일 {date}",
"freeTier": {
"description": "무료 플랜에는 Comfy Cloud를 체험할 수 있도록 매월 {credits} 크레딧이 포함되어 있습니다.",
"descriptionGeneric": "무료 플랜에는 Comfy Cloud를 체험할 수 있는 월간 크레딧이 포함되어 있습니다.",
"nextRefresh": "크레딧은 {date}에 새로 고침됩니다.",
"outOfCredits": {
"subtitle": "충전 및 추가 혜택을 위해 구독하세요",
"title": "무료 크레딧이 모두 소진되었습니다"
},
"subscribeCta": "더 많은 혜택 구독하기",
"title": "무료 플랜을 사용 중입니다",
"topUpBlocked": {
"title": "충전 및 추가 혜택 잠금 해제"
},
"upgradeCta": "플랜 보기"
},
"gpuLabel": "RTX 6000 Pro (96GB VRAM)",
"haveQuestions": "질문이 있거나 엔터프라이즈가 궁금하신가요?",
"invoiceHistory": "청구서 기록",
@@ -2732,6 +2777,7 @@
"maxDuration": {
"creator": "30분",
"founder": "30분",
"free": "30분",
"pro": "1시간",
"standard": "30분"
},
@@ -2804,6 +2850,9 @@
"founder": {
"name": "Founder's Edition"
},
"free": {
"name": "무료"
},
"pro": {
"name": "Pro"
},

View File

@@ -225,6 +225,7 @@
"login": {
"andText": "e",
"backToLogin": "Voltar para login",
"backToSocialLogin": "Cadastre-se com Google ou Github em vez disso",
"confirmPasswordLabel": "Confirmar senha",
"confirmPasswordPlaceholder": "Digite a mesma senha novamente",
"didntReceiveEmail": "Não recebeu o e-mail? Entre em contato conosco em",
@@ -233,6 +234,9 @@
"failed": "Falha no login",
"forgotPassword": "Esqueceu a senha?",
"forgotPasswordError": "Falha ao enviar e-mail de redefinição de senha",
"freeTierBadge": "Elegível para o Plano Gratuito",
"freeTierDescription": "Cadastre-se com Google para ganhar {credits} créditos gratuitos todo mês. Não precisa de cartão.",
"freeTierDescriptionGeneric": "Cadastre-se com Google para ganhar créditos gratuitos todo mês. Não precisa de cartão.",
"insecureContextWarning": "Esta conexão é insegura (HTTP) - suas credenciais podem ser interceptadas por invasores se você continuar.",
"loginButton": "Entrar",
"loginWithGithub": "Entrar com Github",
@@ -251,11 +255,13 @@
"sendResetLink": "Enviar link de redefinição",
"signInOrSignUp": "Entrar / Cadastrar-se",
"signUp": "Cadastrar-se",
"signUpFreeTierPromo": "Novo por aqui? {signUp} com Google para ganhar {credits} créditos gratuitos todo mês.",
"success": "Login realizado com sucesso",
"termsLink": "Termos de Uso",
"termsText": "Ao clicar em \"Próximo\" ou \"Cadastrar-se\", você concorda com nossos",
"title": "Faça login na sua conta",
"useApiKey": "Chave de API Comfy",
"useEmailInstead": "Usar e-mail em vez disso",
"userAvatar": "Avatar do usuário"
},
"loginButton": {
@@ -282,6 +288,7 @@
"signup": {
"alreadyHaveAccount": "Já tem uma conta?",
"emailLabel": "E-mail",
"emailNotEligibleForFreeTier": "Cadastro por e-mail não é elegível para o Plano Gratuito.",
"emailPlaceholder": "Digite seu e-mail",
"passwordLabel": "Senha",
"passwordPlaceholder": "Digite uma nova senha",
@@ -1331,6 +1338,20 @@
"switchToSelectButton": "Ir para Selecionar"
},
"beta": "Modo App Beta - Envie seu feedback",
"builder": {
"exit": "Sair do construtor",
"exitConfirmMessage": "Você tem alterações não salvas que serão perdidas\nSair sem salvar?",
"exitConfirmTitle": "Sair do construtor de app?",
"inputsDesc": "Os usuários irão interagir e ajustar estes para gerar seus resultados.",
"inputsExample": "Exemplos: “Carregar imagem”, “Prompt de texto”, “Passos”",
"noInputs": "Nenhuma entrada adicionada ainda",
"noOutputs": "Nenhum nó de saída adicionado ainda",
"outputsDesc": "Conecte pelo menos um nó de saída para que os usuários vejam os resultados após executar.",
"outputsExample": "Exemplos: “Salvar imagem” ou “Salvar vídeo”",
"promptAddInputs": "Clique nos parâmetros do nó para adicioná-los aqui como entradas",
"promptAddOutputs": "Clique nos nós de saída para adicioná-los aqui. Estes serão os resultados gerados.",
"title": "Modo construtor de app"
},
"downloadAll": "Baixar tudo",
"dragAndDropImage": "Arraste e solte uma imagem",
"graphMode": "Modo Gráfico",
@@ -1868,11 +1889,18 @@
"showLinks": "Mostrar Conexões"
},
"missingModelsDialog": {
"customModelsInstruction": "Você precisará encontrá-los e baixá-los manualmente. Procure por eles online (tente Civitai ou Hugging Face) ou entre em contato com o provedor original do fluxo de trabalho.",
"customModelsWarning": "Alguns desses são modelos personalizados que não reconhecemos.",
"description": "Este fluxo de trabalho requer modelos que você ainda não baixou.",
"doNotAskAgain": "Não mostrar novamente",
"missingModels": "Modelos ausentes",
"missingModelsMessage": "Ao carregar o grafo, os seguintes modelos não foram encontrados",
"downloadAll": "Baixar todos",
"downloadAvailable": "Baixar disponíveis",
"footerDescription": "Baixe e coloque esses modelos na pasta correta.\nNós com modelos ausentes estão destacados em vermelho no canvas.",
"gotIt": "Ok, entendi",
"reEnableInSettings": "Reativar em {link}",
"reEnableInSettingsLink": "Configurações"
"reEnableInSettingsLink": "Configurações",
"title": "Este fluxo de trabalho está sem modelos",
"totalSize": "Tamanho total do download:"
},
"missingNodes": {
"cloud": {
@@ -2696,7 +2724,9 @@
"addCreditsLabel": "Adicione mais créditos quando quiser",
"benefits": {
"benefit1": "$10 em créditos mensais para Partner Nodes — recarregue quando necessário",
"benefit2": "Até 30 min de execução por tarefa"
"benefit1FreeTier": "Mais créditos mensais, recarregue a qualquer momento",
"benefit2": "Até 30 min de execução por tarefa",
"benefit3": "Use seus próprios modelos (Creator & Pro)"
},
"beta": "BETA",
"billedMonthly": "Cobrado mensalmente",
@@ -2734,6 +2764,21 @@
"description": "Escolha o melhor plano para você",
"descriptionWorkspace": "Escolha o melhor plano para seu workspace",
"expiresDate": "Expira em {date}",
"freeTier": {
"description": "Seu plano gratuito inclui {credits} créditos por mês para testar o Comfy Cloud.",
"descriptionGeneric": "Seu plano gratuito inclui uma cota mensal de créditos para testar o Comfy Cloud.",
"nextRefresh": "Seus créditos serão renovados em {date}.",
"outOfCredits": {
"subtitle": "Assine para liberar recargas e mais benefícios",
"title": "Você ficou sem créditos gratuitos"
},
"subscribeCta": "Assine para mais",
"title": "Você está no plano Gratuito",
"topUpBlocked": {
"title": "Desbloqueie recargas e mais benefícios"
},
"upgradeCta": "Ver planos"
},
"gpuLabel": "RTX 6000 Pro (96GB VRAM)",
"haveQuestions": "Tem dúvidas ou interesse em soluções empresariais?",
"invoiceHistory": "Histórico de faturas",
@@ -2744,6 +2789,7 @@
"maxDuration": {
"creator": "30 min",
"founder": "30 min",
"free": "30 min",
"pro": "1 h",
"standard": "30 min"
},
@@ -2816,6 +2862,9 @@
"founder": {
"name": "Edição do Fundador"
},
"free": {
"name": "Gratuito"
},
"pro": {
"name": "Pro"
},

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": "Введите новый пароль",
@@ -1331,6 +1338,20 @@
"switchToSelectButton": "Переключиться на выбор"
},
"beta": "Режим приложения Бета - Оставить отзыв",
"builder": {
"exit": "Выйти из конструктора",
"exitConfirmMessage": "У вас есть несохранённые изменения, которые будут потеряны\nВыйти без сохранения?",
"exitConfirmTitle": "Выйти из конструктора приложений?",
"inputsDesc": "Пользователи будут взаимодействовать с этими параметрами и настраивать их для генерации результата.",
"inputsExample": "Примеры: «Загрузить изображение», «Текстовый промпт», «Шаги»",
"noInputs": "Входные данные ещё не добавлены",
"noOutputs": "Выходные узлы ещё не добавлены",
"outputsDesc": "Подключите хотя бы один выходной узел, чтобы пользователи видели результаты после запуска.",
"outputsExample": "Примеры: «Сохранить изображение» или «Сохранить видео»",
"promptAddInputs": "Нажмите на параметры узла, чтобы добавить их сюда как входные данные",
"promptAddOutputs": "Нажмите на выходные узлы, чтобы добавить их сюда. Это будут сгенерированные результаты.",
"title": "Режим конструктора приложений"
},
"downloadAll": "Скачать всё",
"dragAndDropImage": "Перетащите изображение",
"graphMode": "Графовый режим",
@@ -1868,11 +1889,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": {
@@ -2684,7 +2712,9 @@
"addCreditsLabel": "Добавляйте кредиты в любое время",
"benefits": {
"benefit1": "Ежемесячные кредиты для Партнёрских узлов — пополняйте по необходимости",
"benefit2": "До 30 минут выполнения на задание"
"benefit1FreeTier": "Больше кредитов в месяц, пополнение в любое время",
"benefit2": "До 30 минут выполнения на задание",
"benefit3": "Используйте свои модели (Creator & Pro)"
},
"beta": "БЕТА",
"billedMonthly": "Оплата ежемесячно",
@@ -2722,6 +2752,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 (96ГБ VRAM)",
"haveQuestions": "Есть вопросы или интересует корпоративное решение?",
"invoiceHistory": "История счетов",
@@ -2732,6 +2777,7 @@
"maxDuration": {
"creator": "30 мин",
"founder": "30 мин",
"free": "30 мин",
"pro": "1 ч",
"standard": "30 мин"
},
@@ -2804,6 +2850,9 @@
"founder": {
"name": "Founder's Edition"
},
"free": {
"name": "Бесплатно"
},
"pro": {
"name": "Pro"
},

View File

@@ -225,6 +225,7 @@
"login": {
"andText": "ve",
"backToLogin": "Girişe dön",
"backToSocialLogin": "Bunun yerine Google veya Github ile kaydolun",
"confirmPasswordLabel": "Şifreyi Onayla",
"confirmPasswordPlaceholder": "Aynı şifreyi tekrar girin",
"didntReceiveEmail": "E-posta almadınız mı? Bize şu adresten ulaşın:",
@@ -233,6 +234,9 @@
"failed": "Giriş başarısız",
"forgotPassword": "Şifrenizi mi unuttunuz?",
"forgotPasswordError": "Şifre sıfırlama e-postası gönderilemedi",
"freeTierBadge": "Ücretsiz Katman Uygun",
"freeTierDescription": "Google ile kaydolun, her ay {credits} ücretsiz kredi kazanın. Kart gerekmez.",
"freeTierDescriptionGeneric": "Google ile kaydolun, her ay ücretsiz kredi kazanın. Kart gerekmez.",
"insecureContextWarning": "Bu bağlantı güvensiz (HTTP) - giriş yapmaya devam ederseniz kimlik bilgileriniz saldırganlar tarafından ele geçirilebilir.",
"loginButton": "Giriş Yap",
"loginWithGithub": "Github ile giriş yap",
@@ -251,11 +255,13 @@
"sendResetLink": "Sıfırlama bağlantısını gönder",
"signInOrSignUp": "Giriş Yap / Kaydol",
"signUp": "Kaydol",
"signUpFreeTierPromo": "Yeni misiniz? Her ay {credits} ücretsiz kredi almak için Google ile {signUp} olun.",
"success": "Giriş başarılı",
"termsLink": "Kullanım Koşullarımızı",
"termsText": "\"İleri\" veya \"Kaydol\" düğmesine tıklayarak,",
"title": "Hesabınıza giriş yapın",
"useApiKey": "Comfy API Anahtarı",
"useEmailInstead": "Bunun yerine e-posta kullan",
"userAvatar": "Kullanıcı Avatarı"
},
"loginButton": {
@@ -282,6 +288,7 @@
"signup": {
"alreadyHaveAccount": "Zaten bir hesabınız var mı?",
"emailLabel": "E-posta",
"emailNotEligibleForFreeTier": "E-posta ile kayıt Ücretsiz Katman için uygun değildir.",
"emailPlaceholder": "E-postanızı girin",
"passwordLabel": "Şifre",
"passwordPlaceholder": "Yeni şifre girin",
@@ -1331,6 +1338,20 @@
"switchToSelectButton": "Seç'e Geç"
},
"beta": "Uygulama Modu Beta - Geri Bildirim Verin",
"builder": {
"exit": "Oluşturucudan çık",
"exitConfirmMessage": "Kaydedilmemiş değişiklikleriniz kaybolacak\nKaydetmeden çıkılsın mı?",
"exitConfirmTitle": "Uygulama oluşturucudan çıkılsın mı?",
"inputsDesc": "Kullanıcılar bunlarla etkileşime geçip ayarlayarak çıktılarını oluşturacak.",
"inputsExample": "Örnekler: “Resim yükle”, “Metin istemi”, “Adımlar”",
"noInputs": "Henüz giriş eklenmedi",
"noOutputs": "Henüz çıktı düğümü eklenmedi",
"outputsDesc": "Kullanıcıların çalıştırdıktan sonra sonuçları görebilmesi için en az bir çıktı düğümü bağlayın.",
"outputsExample": "Örnekler: “Resmi Kaydet” veya “Videoyu Kaydet”",
"promptAddInputs": "Girdi olarak eklemek için düğüm parametrelerine tıklayın",
"promptAddOutputs": ıktı olarak eklemek için çıktı düğümlerine tıklayın. Bunlar oluşturulan sonuçlar olacak.",
"title": "Uygulama oluşturucu modu"
},
"downloadAll": "Tümünü İndir",
"dragAndDropImage": "Bir görseli sürükleyip bırakın",
"graphMode": "Grafik Modu",
@@ -1868,11 +1889,18 @@
"showLinks": "Bağlantıları Göster"
},
"missingModelsDialog": {
"customModelsInstruction": "Bunları manuel olarak bulup indirmeniz gerekecek. İnternette arayın (Civitai veya Hugging Face deneyin) ya da orijinal iş akışı sağlayıcısıyla iletişime geçin.",
"customModelsWarning": "Bunlardan bazıları tanımadığımız özel modellerdir.",
"description": "Bu iş akışı, henüz indirmediğiniz modellere ihtiyaç duyuyor.",
"doNotAskAgain": "Bunu bir daha gösterme",
"missingModels": "Eksik Modeller",
"missingModelsMessage": "Grafik yüklenirken aşağıdaki modeller bulunamadı",
"downloadAll": "Hepsini indir",
"downloadAvailable": "İndirilebilir",
"footerDescription": "Bu modelleri indirip doğru klasöre yerleştirin.\nEksik modeli olan düğümler tuvalde kırmızı ile vurgulanır.",
"gotIt": "Tamam, anladım",
"reEnableInSettings": "{link} içinde tekrar etkinleştir",
"reEnableInSettingsLink": "Ayarlar"
"reEnableInSettingsLink": "Ayarlar",
"title": "Bu iş akışında eksik modeller var",
"totalSize": "Toplam indirme boyutu:"
},
"missingNodes": {
"cloud": {
@@ -2684,7 +2712,9 @@
"addCreditsLabel": "İstediğiniz zaman kredi ekleyin",
"benefits": {
"benefit1": "Partner Düğümleri için aylık krediler — ihtiyaç duyulduğunda yükleyin",
"benefit2": "İş başına en fazla 30 dakika çalışma süresi"
"benefit1FreeTier": "Daha fazla aylık kredi, istediğiniz zaman yükleyin",
"benefit2": "İş başına en fazla 30 dakika çalışma süresi",
"benefit3": "Kendi modellerinizi getirin (Creator & Pro)"
},
"beta": "BETA",
"billedMonthly": "Aylık faturalandırılır",
@@ -2722,6 +2752,21 @@
"description": "Sizin için en iyi planı seçin",
"descriptionWorkspace": "Çalışma alanınız için en iyi planı seçin",
"expiresDate": "{date} tarihinde sona erer",
"freeTier": {
"description": "Ücretsiz planınız, Comfy Cloud'u denemek için her ay {credits} kredi içerir.",
"descriptionGeneric": "Ücretsiz planınız, Comfy Cloud'u denemek için aylık kredi hakkı içerir.",
"nextRefresh": "Kredileriniz {date} tarihinde yenilenecek.",
"outOfCredits": {
"subtitle": "Yeniden yükleme ve daha fazlasının kilidini açmak için abone olun",
"title": "Ücretsiz kredileriniz bitti"
},
"subscribeCta": "Daha fazlası için abone olun",
"title": "Ücretsiz plandasınız",
"topUpBlocked": {
"title": "Yeniden yükleme ve daha fazlasının kilidini açın"
},
"upgradeCta": "Planları görüntüle"
},
"gpuLabel": "RTX 6000 Pro (96GB VRAM)",
"haveQuestions": "Sorularınız mı var veya kurumsal çözüm mü arıyorsunuz?",
"invoiceHistory": "Fatura geçmişi",
@@ -2732,6 +2777,7 @@
"maxDuration": {
"creator": "30 dk",
"founder": "30 dk",
"free": "30 dk",
"pro": "1 sa",
"standard": "30 dk"
},
@@ -2804,6 +2850,9 @@
"founder": {
"name": "Kurucu Sürümü"
},
"free": {
"name": "Ücretsiz"
},
"pro": {
"name": "Pro"
},

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": "請輸入新密碼",
@@ -1331,6 +1338,20 @@
"switchToSelectButton": "切換到選擇"
},
"beta": "App 模式 Beta - 提供回饋",
"builder": {
"exit": "離開建構器",
"exitConfirmMessage": "您有尚未儲存的變更將會遺失\n確定要不儲存直接離開嗎",
"exitConfirmTitle": "要離開應用程式建構器嗎?",
"inputsDesc": "使用者可調整這些參數以產生輸出。",
"inputsExample": "例如:「載入圖像」、「文字提示」、「步數」",
"noInputs": "尚未新增任何輸入",
"noOutputs": "尚未新增任何輸出節點",
"outputsDesc": "請至少連接一個輸出節點,讓使用者在執行後能看到結果。",
"outputsExample": "例如:「儲存圖像」或「儲存影片」",
"promptAddInputs": "點擊節點參數,將其新增為輸入",
"promptAddOutputs": "點擊輸出節點,將其新增於此。這些將是產生的結果。",
"title": "應用程式建構模式"
},
"downloadAll": "全部下載",
"dragAndDropImage": "拖曳圖片到此",
"graphMode": "圖形模式",
@@ -1868,11 +1889,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": {
@@ -2684,7 +2712,9 @@
"addCreditsLabel": "隨時可儲值點數",
"benefits": {
"benefit1": "合作節點每月點數 — 需要時可隨時加值",
"benefit2": "每項任務最多運行 30 分鐘"
"benefit1FreeTier": "每月更多點數,隨時加值",
"benefit2": "每項任務最多運行 30 分鐘",
"benefit3": "可自帶模型Creator & Pro"
},
"beta": "測試版",
"billedMonthly": "每月收費",
@@ -2722,6 +2752,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 Pro96GB VRAM",
"haveQuestions": "有疑問或想了解企業方案?",
"invoiceHistory": "發票記錄",
@@ -2732,6 +2777,7 @@
"maxDuration": {
"creator": "30 分鐘",
"founder": "30 分鐘",
"free": "30 分鐘",
"pro": "1 小時",
"standard": "30 分鐘"
},
@@ -2804,6 +2850,9 @@
"founder": {
"name": "創始版"
},
"free": {
"name": "免費"
},
"pro": {
"name": "專業版"
},

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": "新用户?使用 Google {signUp},每月可获得 {credits} 免费积分。",
"success": "登录成功",
"termsLink": "使用条款",
"termsText": "点击“下一步”或“注册”即表示您同意我们的",
"title": "登录您的账户",
"useApiKey": "Comfy API 密钥",
"useEmailInstead": "改用邮箱",
"userAvatar": "用户头像"
},
"loginButton": {
@@ -282,6 +288,7 @@
"signup": {
"alreadyHaveAccount": "已经有账户了?",
"emailLabel": "电子邮件",
"emailNotEligibleForFreeTier": "邮箱注册不支持免费套餐。",
"emailPlaceholder": "输入您的电子邮件",
"passwordLabel": "密码",
"passwordPlaceholder": "输入新密码",
@@ -1331,6 +1338,20 @@
"switchToSelectButton": "切换到选择"
},
"beta": "App 模式测试版 - 提供反馈",
"builder": {
"exit": "退出构建器",
"exitConfirmMessage": "您有未保存的更改将会丢失\n确定不保存直接退出吗",
"exitConfirmTitle": "退出应用构建器?",
"inputsDesc": "用户可通过这些输入项进行交互和调整,以生成输出结果。",
"inputsExample": "示例:“加载图像”、“文本提示”、“步数”",
"noInputs": "尚未添加输入项",
"noOutputs": "尚未添加输出节点",
"outputsDesc": "请至少连接一个输出节点,用户运行后才能看到结果。",
"outputsExample": "示例:“保存图像”或“保存视频”",
"promptAddInputs": "点击节点参数,将其添加为输入项",
"promptAddOutputs": "点击输出节点,将其添加到此处。这些将作为生成结果。",
"title": "应用构建模式"
},
"downloadAll": "全部下载",
"dragAndDropImage": "拖拽图片到此处",
"graphMode": "图形模式",
@@ -1868,11 +1889,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": {
@@ -2696,7 +2724,9 @@
"addCreditsLabel": "随时获取更多积分",
"benefits": {
"benefit1": "合作伙伴节点的月度积分 — 按需充值",
"benefit2": "每个队列最长运行 30 分钟"
"benefit1FreeTier": "每月更多积分,随时补充",
"benefit2": "每个队列最长运行 30 分钟",
"benefit3": "支持自带模型Creator & Pro"
},
"beta": "测试版",
"billedMonthly": "每月付款",
@@ -2734,6 +2764,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": "发票历史",
@@ -2744,6 +2789,7 @@
"maxDuration": {
"creator": "30 分钟",
"founder": "30 分钟",
"free": "30 分钟",
"pro": "1 小时",
"standard": "30 分钟"
},
@@ -2816,6 +2862,9 @@
"founder": {
"name": "Founder's Edition"
},
"free": {
"name": "免费"
},
"pro": {
"name": "Pro"
},

View File

@@ -35,4 +35,29 @@ describe('AssetsListItem', () => {
expect(wrapper.find('video').exists()).toBe(false)
expect(wrapper.find('.icon-\\[lucide--play\\]').exists()).toBe(false)
})
it('emits preview-click when preview is clicked', async () => {
const wrapper = mount(AssetsListItem, {
props: {
previewUrl: 'https://example.com/preview.jpg',
previewAlt: 'image.png'
}
})
await wrapper.find('img').trigger('click')
expect(wrapper.emitted('preview-click')).toHaveLength(1)
})
it('emits preview-click when fallback icon is clicked', async () => {
const wrapper = mount(AssetsListItem, {
props: {
iconName: 'icon-[lucide--box]'
}
})
await wrapper.find('i').trigger('click')
expect(wrapper.emitted('preview-click')).toHaveLength(1)
})
})

View File

@@ -35,7 +35,11 @@
:icon-class="iconClass"
:icon-aria-label="iconAriaLabel"
>
<div v-if="previewUrl" class="relative size-full">
<div
v-if="previewUrl"
class="relative size-full"
@click="emit('preview-click')"
>
<template v-if="isVideoPreview">
<video
:src="previewUrl"
@@ -53,7 +57,11 @@
class="size-full object-cover"
/>
</div>
<div v-else class="flex size-full items-center justify-center">
<div
v-else
class="flex size-full items-center justify-center"
@click="emit('preview-click')"
>
<i
aria-hidden="true"
:class="
@@ -135,6 +143,7 @@ import VideoPlayOverlay from './VideoPlayOverlay.vue'
const emit = defineEmits<{
'stack-toggle': []
'preview-click': []
}>()
const {

View File

@@ -282,7 +282,6 @@ const getCheckoutTier = (
const getCheckoutAttributionForCloud =
async (): Promise<CheckoutAttributionMetadata> => {
// eslint-disable-next-line no-undef
if (__DISTRIBUTION__ !== 'cloud') {
return {}
}

View File

@@ -229,7 +229,12 @@ export function useNodeReplacement() {
try {
const placeholders = collectAllNodes(
graph,
(n) => !!n.has_errors && !!n.last_serialization
(n) =>
!!n.last_serialization &&
!(
(n.last_serialization.type ?? n.type ?? '') in
LiteGraph.registered_node_types
)
)
for (const node of placeholders) {
@@ -261,6 +266,10 @@ export function useNodeReplacement() {
}
replaceWithMapping(node, newNode, effectiveReplacement, nodeGraph, idx)
// Refresh Vue node data — replaceWithMapping bypasses graph.add()
// so onNodeAdded must be called explicitly to update VueNodeData.
nodeGraph.onNodeAdded?.(newNode)
if (!replacedTypes.includes(match.type)) {
replacedTypes.push(match.type)
}
@@ -279,6 +288,19 @@ export function useNodeReplacement() {
life: 3000
})
}
} catch (error) {
console.error('Failed to replace nodes:', error)
if (replacedTypes.length > 0) {
graph.updateExecutionOrder()
graph.setDirtyCanvas(true, true)
}
toastStore.add({
severity: 'error',
summary: t('g.error', 'Error'),
detail: t('nodeReplacement.replaceFailed', 'Failed to replace nodes'),
life: 5000
})
return replacedTypes
} finally {
changeTracker?.afterChange()
}

View File

@@ -44,17 +44,7 @@
<template #header />
<template #content>
<template v-if="inSearch">
<SettingsPanel :setting-groups="searchResults" />
</template>
<template v-else-if="activeSettingCategory">
<CurrentUserMessage v-if="activeSettingCategory.label === 'Comfy'" />
<ColorPaletteMessage
v-if="activeSettingCategory.label === 'Appearance'"
/>
<SettingsPanel :setting-groups="sortedGroups(activeSettingCategory)" />
</template>
<template v-else-if="activePanel">
<template v-if="activePanel">
<Suspense>
<component :is="activePanel.component" v-bind="activePanel.props" />
<template #fallback>
@@ -64,6 +54,16 @@
</template>
</Suspense>
</template>
<template v-else-if="inSearch">
<SettingsPanel :setting-groups="searchResults" />
</template>
<template v-else-if="activeSettingCategory">
<CurrentUserMessage v-if="activeSettingCategory.label === 'Comfy'" />
<ColorPaletteMessage
v-if="activeSettingCategory.label === 'Appearance'"
/>
<SettingsPanel :setting-groups="sortedGroups(activeSettingCategory)" />
</template>
</template>
</BaseModalLayout>
</template>
@@ -110,6 +110,7 @@ const {
searchQuery,
inSearch,
searchResultsCategories,
matchedNavItemKeys,
handleSearch: handleSearchBase,
getSearchResults
} = useSettingSearch()
@@ -119,16 +120,29 @@ const authActions = useFirebaseAuthActions()
const navRef = ref<HTMLElement | null>(null)
const activeCategoryKey = ref<string | null>(defaultCategory.value?.key ?? null)
watch(searchResultsCategories, (categories) => {
if (!inSearch.value || categories.size === 0) return
const firstMatch = navGroups.value
.flatMap((g) => g.items)
.find((item) => {
const node = findCategoryByKey(item.id)
return node && categories.has(node.label)
})
activeCategoryKey.value = firstMatch?.id ?? null
})
const searchableNavItems = computed(() =>
navGroups.value.flatMap((g) =>
g.items.map((item) => ({
key: item.id,
label: item.label
}))
)
)
watch(
[searchResultsCategories, matchedNavItemKeys],
([categories, navKeys]) => {
if (!inSearch.value || (categories.size === 0 && navKeys.size === 0)) return
const firstMatch = navGroups.value
.flatMap((g) => g.items)
.find((item) => {
if (navKeys.has(item.id)) return true
const node = findCategoryByKey(item.id)
return node && categories.has(node.label)
})
activeCategoryKey.value = firstMatch?.id ?? null
}
)
const activeSettingCategory = computed<SettingTreeNode | null>(() => {
if (!activeCategoryKey.value) return null
@@ -163,7 +177,7 @@ function sortedGroups(category: SettingTreeNode): ISettingGroup[] {
}
function handleSearch(query: string) {
handleSearchBase(query.trim())
handleSearchBase(query.trim(), searchableNavItems.value)
if (query) {
activeCategoryKey.value = null
} else if (!activeCategoryKey.value) {
@@ -175,12 +189,7 @@ function onNavItemClick(id: string) {
activeCategoryKey.value = id
}
const searchResults = computed<ISettingGroup[]>(() => {
const category = activeCategoryKey.value
? findCategoryByKey(activeCategoryKey.value)
: null
return getSearchResults(category)
})
const searchResults = computed<ISettingGroup[]>(() => getSearchResults(null))
// Scroll to and highlight the target setting once the correct category renders.
if (scrollToSettingId) {

View File

@@ -2,6 +2,15 @@
<div class="setting-group">
<Divider v-if="divider" />
<h3>
<span v-if="group.category" class="text-muted">
{{
$t(
`settingsCategories.${normalizeI18nKey(group.category)}`,
group.category
)
}}
&#8250;
</span>
{{
$t(`settingsCategories.${normalizeI18nKey(group.label)}`, group.label)
}}
@@ -27,6 +36,7 @@ import { normalizeI18nKey } from '@/utils/formatUtil'
defineProps<{
group: {
label: string
category?: string
settings: SettingParams[]
}
divider?: boolean

View File

@@ -299,10 +299,12 @@ describe('useSettingSearch', () => {
expect(results).toEqual([
{
label: 'Basic',
category: 'Category',
settings: [mockSettings['Category.Setting1']]
},
{
label: 'Advanced',
category: 'Category',
settings: [mockSettings['Category.Setting2']]
}
])
@@ -332,15 +334,50 @@ describe('useSettingSearch', () => {
expect(results).toEqual([
{
label: 'Basic',
category: 'Category',
settings: [mockSettings['Category.Setting1']]
},
{
label: 'SubCategory',
category: 'Other',
settings: [mockSettings['Other.Setting3']]
}
])
})
it('returns results from all categories when searching cross-category term', () => {
// Simulates the "badge" scenario: same term matches settings in
// multiple categories (e.g. LiteGraph and Comfy)
mockSettings['LiteGraph.BadgeSetting'] = {
id: 'LiteGraph.BadgeSetting',
name: 'Node source badge mode',
type: 'combo',
defaultValue: 'default',
category: ['LiteGraph', 'Node']
}
mockSettings['Comfy.BadgeSetting'] = {
id: 'Comfy.BadgeSetting',
name: 'Show API node pricing badge',
type: 'boolean',
defaultValue: true,
category: ['Comfy', 'API Nodes']
}
const search = useSettingSearch()
search.handleSearch('badge')
expect(search.filteredSettingIds.value).toContain(
'LiteGraph.BadgeSetting'
)
expect(search.filteredSettingIds.value).toContain('Comfy.BadgeSetting')
// getSearchResults(null) should return both categories' results
const results = search.getSearchResults(null)
const labels = results.map((g) => g.label)
expect(labels).toContain('Node')
expect(labels).toContain('API Nodes')
})
it('returns empty array when no filtered results', () => {
const search = useSettingSearch()
search.filteredSettingIds.value = []
@@ -372,6 +409,7 @@ describe('useSettingSearch', () => {
expect(results).toEqual([
{
label: 'Basic',
category: 'Category',
settings: [
mockSettings['Category.Setting1'],
mockSettings['Category.Setting4']
@@ -381,6 +419,75 @@ describe('useSettingSearch', () => {
})
})
describe('nav item matching', () => {
const navItems = [
{ key: 'keybinding', label: 'Keybinding' },
{ key: 'about', label: 'About' },
{ key: 'extension', label: 'Extension' },
{ key: 'Comfy', label: 'Comfy' }
]
it('matches nav items by key', () => {
const search = useSettingSearch()
search.handleSearch('keybinding', navItems)
expect(search.matchedNavItemKeys.value.has('keybinding')).toBe(true)
})
it('matches nav items by translated label (case insensitive)', () => {
const search = useSettingSearch()
search.handleSearch('ABOUT', navItems)
expect(search.matchedNavItemKeys.value.has('about')).toBe(true)
})
it('matches partial nav item labels', () => {
const search = useSettingSearch()
search.handleSearch('ext', navItems)
expect(search.matchedNavItemKeys.value.has('extension')).toBe(true)
})
it('clears matched nav item keys on empty query', () => {
const search = useSettingSearch()
search.handleSearch('keybinding', navItems)
expect(search.matchedNavItemKeys.value.size).toBeGreaterThan(0)
search.handleSearch('', navItems)
expect(search.matchedNavItemKeys.value.size).toBe(0)
})
it('can match both settings and nav items simultaneously', () => {
const search = useSettingSearch()
search.handleSearch('other', navItems)
expect(search.filteredSettingIds.value).toContain('Other.Setting3')
expect(search.matchedNavItemKeys.value.size).toBe(0)
})
it('matches nav items with translated labels different from key', () => {
const translatedNavItems = [{ key: 'keybinding', label: '키 바인딩' }]
const search = useSettingSearch()
search.handleSearch('키 바인딩', translatedNavItems)
expect(search.matchedNavItemKeys.value.has('keybinding')).toBe(true)
})
it('does not match nav items when no nav items provided', () => {
const search = useSettingSearch()
search.handleSearch('keybinding')
expect(search.matchedNavItemKeys.value.size).toBe(0)
})
})
describe('edge cases', () => {
it('handles empty settings store', () => {
mockSettingStore.settingsById = {}

View File

@@ -10,12 +10,18 @@ import type { ISettingGroup, SettingParams } from '@/platform/settings/types'
import { normalizeI18nKey } from '@/utils/formatUtil'
import { useVueFeatureFlags } from '@/composables/useVueFeatureFlags'
interface SearchableNavItem {
key: string
label: string
}
export function useSettingSearch() {
const settingStore = useSettingStore()
const { shouldRenderVueNodes } = useVueFeatureFlags()
const searchQuery = ref<string>('')
const filteredSettingIds = ref<string[]>([])
const matchedNavItemKeys = ref<Set<string>>(new Set())
const searchInProgress = ref<boolean>(false)
watch(searchQuery, () => (searchInProgress.value = true))
@@ -46,7 +52,9 @@ export function useSettingSearch() {
/**
* Handle search functionality
*/
const handleSearch = (query: string) => {
const handleSearch = (query: string, navItems?: SearchableNavItem[]) => {
matchedNavItemKeys.value = new Set()
if (!query) {
filteredSettingIds.value = []
return
@@ -89,6 +97,17 @@ export function useSettingSearch() {
)
})
if (navItems) {
for (const item of navItems) {
if (
item.key.toLocaleLowerCase().includes(queryLower) ||
item.label.toLocaleLowerCase().includes(queryLower)
) {
matchedNavItemKeys.value.add(item.key)
}
}
}
filteredSettingIds.value = filteredSettings.map((x) => x.id)
searchInProgress.value = false
}
@@ -99,30 +118,42 @@ export function useSettingSearch() {
const getSearchResults = (
activeCategory: SettingTreeNode | null
): ISettingGroup[] => {
const groupedSettings: { [key: string]: SettingParams[] } = {}
const groupedSettings: {
[key: string]: { category: string; settings: SettingParams[] }
} = {}
filteredSettingIds.value.forEach((id) => {
const setting = settingStore.settingsById[id]
const info = getSettingInfo(setting)
const groupLabel = info.subCategory
const groupKey =
activeCategory === null
? `${info.category}/${info.subCategory}`
: info.subCategory
if (activeCategory === null || activeCategory.label === info.category) {
if (!groupedSettings[groupLabel]) {
groupedSettings[groupLabel] = []
if (!groupedSettings[groupKey]) {
groupedSettings[groupKey] = {
category: info.category,
settings: []
}
}
groupedSettings[groupLabel].push(setting)
groupedSettings[groupKey].settings.push(setting)
}
})
return Object.entries(groupedSettings).map(([label, settings]) => ({
label,
settings
}))
return Object.entries(groupedSettings).map(
([key, { category, settings }]) => ({
label: activeCategory === null ? key.split('/')[1] : key,
...(activeCategory === null ? { category } : {}),
settings
})
)
}
return {
searchQuery,
filteredSettingIds,
matchedNavItemKeys,
searchInProgress,
searchResultsCategories,
queryIsEmpty,

View File

@@ -62,6 +62,7 @@ export interface FormItem {
export interface ISettingGroup {
label: string
category?: string
settings: SettingParams[]
}

View File

@@ -21,6 +21,7 @@ import { useMissingModelsDialog } from '@/composables/useMissingModelsDialog'
import { useMissingNodesDialog } from '@/composables/useMissingNodesDialog'
import { useDialogService } from '@/services/dialogService'
import { useDomWidgetStore } from '@/stores/domWidgetStore'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { useWorkspaceStore } from '@/stores/workspaceStore'
import { appendJsonExt } from '@/utils/formatUtil'
@@ -33,6 +34,7 @@ export const useWorkflowService = () => {
const missingNodesDialog = useMissingNodesDialog()
const workflowThumbnail = useWorkflowThumbnail()
const domWidgetStore = useDomWidgetStore()
const executionErrorStore = useExecutionErrorStore()
const workflowDraftStore = useWorkflowDraftStore()
async function getFilename(defaultName: string): Promise<string | null> {
@@ -467,12 +469,15 @@ export const useWorkflowService = () => {
const { missingNodeTypes, missingModels } = wf.pendingWarnings
wf.pendingWarnings = null
if (
missingNodeTypes?.length &&
settingStore.get('Comfy.Workflow.ShowMissingNodesWarning')
) {
missingNodesDialog.show({ missingNodeTypes })
if (missingNodeTypes?.length) {
// Remove modal once Node Replacement is implemented in TabErrors.
if (settingStore.get('Comfy.Workflow.ShowMissingNodesWarning')) {
missingNodesDialog.show({ missingNodeTypes })
}
executionErrorStore.surfaceMissingNodes(missingNodeTypes)
}
if (
missingModels &&
settingStore.get('Comfy.Workflow.ShowMissingModelsWarning')

View File

@@ -277,7 +277,13 @@ const zExtra = z
reroutes: z.array(zReroute).optional(),
workflowRendererVersion: zRendererType.optional(),
BlueprintDescription: z.string().optional(),
BlueprintSearchAliases: z.array(z.string()).optional()
BlueprintSearchAliases: z.array(z.string()).optional(),
linearData: z
.object({
inputs: z.array(z.tuple([zNodeId, z.string()])).optional(),
outputs: z.array(zNodeId).optional()
})
.optional()
})
.passthrough()
@@ -547,7 +553,6 @@ export type ComfyApiWorkflow = z.infer<typeof zComfyApiWorkflow>
* where that definition is instantiated in the workflow.
*
* "def-A" → ["5", "10"] for each container node instantiating that subgraph definition.
* @knipIgnoreUsedByStackedPR
*/
export function buildSubgraphExecutionPaths(
rootNodes: ComfyNode[],

View File

@@ -1,6 +1,6 @@
<script setup lang="ts">
import { useEventListener, useTimeout } from '@vueuse/core'
import { partition } from 'es-toolkit'
import { partition, remove, takeWhile } from 'es-toolkit'
import { storeToRefs } from 'pinia'
import { computed, ref, shallowRef } from 'vue'
import { useI18n } from 'vue-i18n'
@@ -55,6 +55,27 @@ useEventListener(
() => (graphNodes.value = app.rootGraph.nodes)
)
const mappedSelections = computed(() => {
let unprocessedInputs = [...appModeStore.selectedInputs]
//FIXME strict typing here
const processedInputs: ReturnType<typeof nodeToNodeData>[] = []
while (unprocessedInputs.length) {
const nodeId = unprocessedInputs[0][0]
const inputGroup = takeWhile(
unprocessedInputs,
([id]) => id === nodeId
).map(([, widgetName]) => widgetName)
unprocessedInputs = unprocessedInputs.slice(inputGroup.length)
const node = app.rootGraph.getNodeById(nodeId)
if (!node) continue
const nodeData = nodeToNodeData(node)
remove(nodeData.widgets ?? [], (w) => !inputGroup.includes(w.name))
processedInputs.push(nodeData)
}
return processedInputs
})
function getDropIndicator(node: LGraphNode) {
if (node.type !== 'LoadImage') return undefined
@@ -231,11 +252,13 @@ defineExpose({ runButtonClick })
class="grow-1 md:overflow-y-auto md:contain-size"
>
<template
v-for="(nodeData, index) of partitionedNodes[1]"
v-for="(nodeData, index) of appModeStore.selectedInputs.length
? mappedSelections
: partitionedNodes[0]"
:key="nodeData.id"
>
<div
v-if="index !== 0"
v-if="index !== 0 && !appModeStore.selectedInputs.length"
class="w-full border-t-1 border-node-component-border"
/>
<DropZone

View File

@@ -356,7 +356,8 @@ const hasAnyError = computed((): boolean => {
error ||
executionErrorStore.getNodeErrors(nodeLocatorId.value) ||
(lgraphNode.value &&
executionErrorStore.isContainerWithInternalError(lgraphNode.value))
(executionErrorStore.isContainerWithInternalError(lgraphNode.value) ||
executionErrorStore.isContainerWithMissingNode(lgraphNode.value)))
)
})

View File

@@ -189,25 +189,36 @@ export function useNodeResize(
}
}
const cleanup = () => {
if (!isResizing.value) return
isResizing.value = false
layoutStore.isResizingVueNodes.value = false
resizeStartPointer.value = null
resizeStartSize.value = null
resizeStartPosition.value = null
// Stop tracking shift key state
stopShiftSync()
stopMoveListen()
stopUpListen()
stopCancelListen()
}
const handlePointerUp = (upEvent: PointerEvent) => {
if (isResizing.value) {
isResizing.value = false
layoutStore.isResizingVueNodes.value = false
resizeStartPointer.value = null
resizeStartSize.value = null
resizeStartPosition.value = null
// Stop tracking shift key state
stopShiftSync()
target.releasePointerCapture(upEvent.pointerId)
stopMoveListen()
stopUpListen()
try {
target.releasePointerCapture(upEvent.pointerId)
} catch {
// Pointer capture may already be released
}
cleanup()
}
}
const stopMoveListen = useEventListener('pointermove', handlePointerMove)
const stopUpListen = useEventListener('pointerup', handlePointerUp)
const stopCancelListen = useEventListener('pointercancel', cleanup)
}
return {

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