Compare commits

...

44 Commits

Author SHA1 Message Date
Terry Jia
15582f055f fix: sync litegraph node.setSize() to layoutStore 2026-01-11 21:57:42 -05:00
Terry Jia
f6d39dbfc8 fix: stop pointer/mouse event propagation in vueNodes widget containers (#7953)
## Summary
Prevents custom widget drag interactions from triggering node drag in
vueNodes mode. Custom plugins like KJNodes Points Editor use their own
drag handlers which were bubbling up to the node container.

## Screenshots (if applicable)
before



https://github.com/user-attachments/assets/bc4c3095-d454-45f6-a4ec-60178e8f47df


after


https://github.com/user-attachments/assets/a32a5591-120e-4842-a0e0-3dd972127376

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7953-fix-stop-pointer-mouse-event-propagation-in-vueNodes-widget-containers-2e56d73d3650810398f2c582a91d767b)
by [Unito](https://www.unito.io)
2026-01-11 20:34:22 -05:00
pythongosssss
97ca9f489e Integrated tab bar UI elements (#7853)
## Summary

The current help / feedback is often overlooked by users, this adds a
setting that makes it more visible moving it up into the tab bar and
moves the user login/profile button out of the "action bar" into the tab
bar.

## Changes
- Add 'Comfy.UI.TabBarLayout' setting with Default/Integrated options
- Move Help & User controls to tab bar when Integrated mode is enabled
- Extract help center logic into shared useHelpCenter composable

## Screenshots (if applicable)

<img width="515" height="540" alt="image"
src="https://github.com/user-attachments/assets/c9e6057f-4fb1-4da6-b25d-9df4b19be31a"
/>
<img width="835" height="268" alt="image"
src="https://github.com/user-attachments/assets/24afc0e8-97eb-45cf-af86-15a9b464e9a8"
/>

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7853-Integrated-tab-bar-UI-elements-2df6d73d365081b1beb8f7c641c2fa43)
by [Unito](https://www.unito.io)

---------

Co-authored-by: GitHub Action <action@github.com>
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2026-01-11 00:11:50 -07:00
Godwin Iheuwa
7b274b74f1 fix: add beforeChange/afterChange to convertToSubgraph for proper undo (#7791)
## Summary
- Adds missing `beforeChange()` and `afterChange()` lifecycle calls to
`convertToSubgraph()` method

## Problem
When converting nodes to a subgraph and then pressing Ctrl+Z to undo,
the node positions were being changed from their original locations
instead of being properly restored.

## Root Cause
The `convertToSubgraph()` method in `LGraph.ts` was missing the
`beforeChange()` and `afterChange()` lifecycle calls that are needed for
proper undo/redo state tracking. These calls record the graph state
before and after modifications.

The inverse operation `unpackSubgraph()` already has these calls (see
line 1742), so this is simply matching the existing pattern.

## Solution
Add `beforeChange()` at the start of the method (after validation) and
`afterChange()` before the return.

## Testing
1. Create a workflow with several nodes positioned in specific locations
2. Select 2-3 nodes
3. Right-click → "Convert Selection to Subgraph"
4. Press Ctrl+Z to undo
5. Verify nodes return to their exact original positions

Fixes comfyanonymous/ComfyUI#11514

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7791-fix-add-beforeChange-afterChange-to-convertToSubgraph-for-proper-undo-2d86d73d36508125a2c4e4a412cced4a)
by [Unito](https://www.unito.io)

---------

Co-authored-by: RUiNtheExtinct <deepkarma001@gmail.com>
2026-01-11 00:11:08 -07:00
AustinMroz
44c317fd05 Fix reactivity washing in refreshNodeSlots (#7802)
Creating a copy with spread resulted in a copy which was not reactive.

Solves a bug where all widgets on a node in vue mode would cease to be
reactive after any connection is made to the node.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7802-Fix-reactivity-washing-in-refreshNodeSlots-2d96d73d3650819e842ff46030bebfa1)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Alexander Brown <drjkl@comfy.org>
2026-01-11 07:01:34 +00:00
newraina
23e9b39593 fix: context menu appears at wrong position on first click after canvas move (#7821)
## Summary

Fixed context menu positioning bug where menu appeared below mouse
cursor on first right-click after moving canvas, causing viewport
overflow.

## Changes

Initialize lastScale/lastOffset* to current canvas transform values when
opening menu, preventing updateMenuPosition from overwriting PrimeVue's
flip-adjusted position on the first RAF tick.

## Review Focus

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

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

## Screenshots (if applicable)

Please pay attention to the first right-click in each video — that’s
where the fix makes a difference.

**Before**


https://github.com/user-attachments/assets/29621621-a05e-414a-a4cc-5aa5a31b5041

**After**


https://github.com/user-attachments/assets/5f46aa69-97a0-44a4-9894-b205fe3d58ed

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7821-fix-context-menu-appears-at-wrong-position-on-first-click-after-canvas-move-2db6d73d365081e4a8ebc0d91e3f927b)
by [Unito](https://www.unito.io)

Co-authored-by: Alexander Brown <drjkl@comfy.org>
2026-01-11 06:37:50 +00:00
Yourz
dcfa53fd7d feat: add dynamic Fuse.js options loading for template filtering (#7822)
## Summary

PRD:
https://www.notion.so/comfy-org/Implement-Move-search-config-to-templates-repo-for-template-owner-adjustability-2c76d73d365081ad81c4ed33332eda09

Move search config to templates repo for template owner adjustability

## Changes

- **What**: 
- Made `fuseOptions` reactive in `useTemplateFiltering` composable to
support dynamic updates
- Added `getFuseOptions()` API method to fetch Fuse.js configuration
from `/templates/fuse_options.json`
- Added `loadFuseOptions()` function to `useTemplateFiltering` that
fetches and applies server-provided options
- Removed unused `templateFuse` computed property from
`workflowTemplatesStore`
- Added comprehensive unit tests covering success, null response, error
handling, and Fuse instance recreation scenarios

- **Breaking**: None

- **Dependencies**: None (uses existing `fuse.js` and `axios`
dependencies)

## Review Focus

- Verify that the API endpoint path `/templates/fuse_options.json` is
correct and accessible
- Confirm that the reactive `fuseOptions` properly triggers Fuse
instance recreation when updated
- Check that error handling gracefully falls back to default options
when server fetch fails
- Ensure the watch on `fuseOptions` is necessary or can be removed
(currently just recreates Fuse via computed)
- Review test coverage to ensure all edge cases are handled

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7822-feat-add-dynamic-Fuse-js-options-loading-for-template-filtering-2db6d73d365081828103d8ee70844b2e)
by [Unito](https://www.unito.io)

---------

Co-authored-by: GitHub Action <action@github.com>
2026-01-10 23:24:43 -07:00
Csongor Czezar
2d5d18c020 feat: improved playwright comment format (#7882)
### Description
Improve Playwright PR comment format

### Problem
The current Playwright PR comment format is verbose and doesn't provide
easy access to failing test details.
Developers need to navigate multiple levels deep to:
Find which tests failed
Access test source code
View trace files for debugging
This makes debugging test failures tedious and time-consuming.

### Solution
Improved the Playwright PR comment format to be concise and actionable
by:
Modified extract-playwright-counts.ts to extract detailed failure
information from Playwright JSON reports including test names, file
paths, and trace URLs
Updated pr-playwright-deploy-and-comment.sh to generate concise comments
with failed tests listed upfront
Modified ci-tests-e2e.yaml to pass GITHUB_SHA for source code links
Modified ci-tests-e2e-forks.yaml to pass GITHUB_SHA for forked PR
workflow

**Before:**
Large multi-section layout with emoji-heavy headers
Summary section listing all counts vertically
Browser results displayed prominently with detailed counts
Failed test details only accessible through report links
No direct links to test source code or traces

**After:**
Concise single-line header with status 
Single-line summary: "X passed, Y failed, Z flaky, W skipped (Total: N)"
Failed tests section (only shown when tests fail) with:
Direct links to test source code on GitHub
Direct links to trace viewer for each failure
Browser details collapsed in details section
Overall roughly half size reduction in visible text

### Testing
Verified TypeScript extraction logic for parsing Playwright JSON reports
Validated shell script syntax
Confirmed GitHub workflow changes are properly formatted
Will be fully tested on next PR with actual test failures

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7882-feat-improved-playwright-comment-format-2e16d73d365081609078e34773063511)
by [Unito](https://www.unito.io)
2026-01-10 23:09:18 -07:00
danialshirali16
6883241e50 Add Persian (Farsi) language support (#7876)
## Description

This PR adds Persian (Farsi) language support to ComfyUI. 

## Changes

- Added `fa` to output locales in `.i18nrc.cjs` with Persian-specific
translation guidelines
- Added Persian loaders for all translation files (main, nodeDefs,
commands, settings) in `src/i18n.ts`
- Added Persian (فارسی) option to language settings dropdown in
`src/platform/settings/constants/coreSettings.ts`
- Created empty Persian locale files in `src/locales/fa/` directory
(will be populated by the CI translation system)

## Translation Guidelines

The Persian translation will follow these guidelines:
- Use formal Persian (فارسی رسمی) for professional tone throughout the
UI
- Keep commonly used technical terms in English when they are standard
in Persian software (e.g., node, workflow)
- Use Arabic-Indic numerals (۰-۹) for numbers where appropriate
- Maintain consistency with terminology used in Persian software and
design applications

## Testing

The configuration has been tested to ensure:
- TypeScript compilation succeeds
- All four translation files are properly referenced
- Language option appears correctly in settings

## Notes

Following the contribution guidelines in `src/locales/CONTRIBUTING.md`,
the empty translation files will be automatically populated by the CI
system using OpenAI. Persian-speaking contributors can review and refine
these translations after the automated generation.

---

Special names to keep untranslated: flux, photomaker, clip, vae, cfg,
stable audio, stable cascade, stable zero, controlnet, lora, HiDream,
Civitai, Hugging Face

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7876-Add-Persian-Farsi-language-support-2e16d73d365081f69df0e50048ce87ba)
by [Unito](https://www.unito.io)

Co-authored-by: danialshirali16 <danialshirali16@users.noreply.github.com>
2026-01-10 23:02:16 -07:00
Johnpaul Chiwetelu
e3906a0656 chore: bump CI container to 0.0.10 (#7881)
Updates CI container from `0.0.8` to `0.0.10`

**Triggered by:** [Tag
0.0.10](https://github.com/Comfy-Org/comfyui-ci-container/tree/0.0.10)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7881-chore-bump-CI-container-to-0-0-10-2e16d73d3650814aa715cb4e12eaec9d)
by [Unito](https://www.unito.io)
2026-01-11 06:53:15 +01:00
Kelly Yang
3ce588ad42 Update viewjobhistorycommand (#7911)
## Summary

Add the key binding to the schema and mark the setting as hidden.

https://github.com/Comfy-Org/ComfyUI_frontend/pull/7805#pullrequestreview-3627969654

## Changes

**What**: 
- Added a new `shortcuts` field to the user settings database model.
- Marked the `shortcuts` field as `hidden` in the `API/Schema` to ensure
it remains internal for now, as suggested by the reviewer @benceruleanlu
.
- Migrated shortcut storage logic from frontend-only (store) to
persistent backend storage.
- **Breaking**: None
- **Dependencies**:  None

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7911-Update-viewjobhistorycommand-2e26d73d3650813297c3f9f7deb53b14)
by [Unito](https://www.unito.io)
2026-01-10 22:45:53 -07:00
Benjamin Lu
818c5c32e5 [QPOv2] Add stories for list view and general job card (#7743)
Add stories for the media assets sidebar tab for easier prototyping.

Includes mocks for storybook.

Because some functions in the mocks are only used in the storybook
main.ts resolve, knip flags them as unused because it doesn't check that
path. So knipIgnoreUnusedButUsedByStorybook was added.

Part of the QPO v2 iteration, figma design can be found
[here](https://www.figma.com/design/LVilZgHGk5RwWOkVN6yCEK/Queue-Progress-Modal?node-id=3330-37286&m=dev).
This will be implemented in a series of stacked PRs that can be reviewed
and merged individually.

main <-- #7737, #7743, #7745

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7743-QPOv2-Add-stories-for-list-view-and-general-job-card-2d26d73d365081bca59afa925fb232d7)
by [Unito](https://www.unito.io)

---------

Co-authored-by: GitHub Action <action@github.com>
2026-01-10 22:34:37 -07:00
Alexander Brown
dbb0bd961f Chore: TypeScript cleanup - remove 254 @ts-expect-error suppressions (#7884)
## Summary

Removes **254** `@ts-expect-error` suppressions through proper type
fixes rather than type assertions.

## Key Changes

### Type System Improvements
- Add `globalDefs` and `groupNodes` types to `ComfyAppWindowExtension`
- Extract interfaces for group node handling (`GroupNodeHandler`,
`InnerNodeOutput`, etc.)
- Add `getHandler()` helper to consolidate GROUP symbol access pattern

### Files Fixed
- **pnginfo.ts**: 39 suppressions removed via proper typing of
workflow/prompt data
- **app.ts**: 39 suppressions removed via interface extraction and type
narrowing
- **Tier 1 files**: 17 suppressions removed (maskeditor, imageDrawer,
groupNode, etc.)
- **groupNode.ts**: Major refactoring with proper interface organization

## Approach

Following established constraints:
- No `any` types
- No `as unknown as T` casts (except legacy API boundaries)
- Priority: Fix actual types > Type narrowing > Targeted suppressions as
last resort
- Prefix unused callback parameters with underscore
- Extract repeated inline types into named interfaces

## Validation

-  `pnpm typecheck` passes
-  `pnpm lint` passes
-  `pnpm knip` passes

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7884-Chore-TypeScript-cleanup-remove-254-ts-expect-error-suppressions-2e26d73d3650812e9b48da203ce1d296)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Amp <amp@ampcode.com>
2026-01-10 21:17:31 -08:00
Comfy Org PR Bot
11bd9022c8 1.37.9 (#7951)
Patch version increment to 1.37.9

**Base branch:** `main`

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7951-1-37-9-2e56d73d36508115bca9f9f8934ef189)
by [Unito](https://www.unito.io)

---------

Co-authored-by: christian-byrne <72887196+christian-byrne@users.noreply.github.com>
Co-authored-by: GitHub Action <action@github.com>
Co-authored-by: github-actions <github-actions@github.com>
2026-01-10 16:26:51 -08:00
Brian Jemilo II
df1eb32907 Drag image to load image (#7898)
## Summary

<!-- One sentence describing what changed and why. -->
Added feature to drag image into workflow to create a load image node if
the image does not have workflow meta data.

Also added tests for usePaste.ts as I extracted code to be reusable
there and there wasn't any tests.

## Changes

- **What**: <!-- Core functionality added/modified -->
app.ts handleFile updated,
usePaste.ts usePaste updated with new method pasteImageNode

## Review Focus
<!-- Fixes #ISSUE_NUMBER -->
Not sure if it has an issue, just has a notion task.

https://www.notion.so/comfy-org/Drag-in-an-image-that-s-not-a-workflow-and-being-able-to-directly-loading-it-as-Load-Image-2156d73d365080c4851ffc1425e06caf

## Screenshots (if applicable)

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


https://github.com/user-attachments/assets/0403e4f1-2a99-4939-bf01-3d9e8f9834bb

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7898-Drag-image-to-load-image-2e26d73d36508187abdff986e8087370)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Alexander Brown <drjkl@comfy.org>
2026-01-10 20:55:19 +00:00
kaalibro
52b94e06a1 fix(sidebar): Fix sidebar pointer events for interaction (#7905)
## Summary

A small fix for #6700

Fixes pointer events handling in sidebar by moving `pointer-events-auto`
from the main container to specific states (Connected and Floating),
preventing unintended event blocking.

## Changes

- **What**: Relocated `pointer-events-auto` class from the main sidebar
container (`.side-tool-bar-container`) to conditional states:
- Applied to `connected-sidebar` class when sidebar is connected (line
7)
- Applied to floating `sidebar-item-group` elements when sidebar is
floating (line 151)

## Screenshots

### Before:


https://github.com/user-attachments/assets/f93badad-e248-49f0-9cde-99e364f4773d


### After:


https://github.com/user-attachments/assets/16f8511c-cbc0-4e2d-bac1-33f932e979aa

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7905-fix-sidebar-Fix-sidebar-pointer-events-for-interaction-2e26d73d365081218361e79010c3347c)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Alexander Brown <drjkl@comfy.org>
2026-01-10 12:48:01 -08:00
brucew4yn3rp
7bc6334065 Added MaskEditor Rotate and Mirror Functions (#7841)
# Canvas Rotation and Mirroring

## Overview
Adds rotation (90° left/right) and mirroring (horizontal/vertical)
capabilities to the mask editor canvas. All three layers (image, mask,
RGB) transform together. Redo and Undo respect transformations as new
states. Keyboard shortcuts also added for all four functions in
Keybinding settings.

Additionally, fixed the issue of ctrl+z and ctrl+y keyboard commands not
restricting to the mask editor canvas while opened.


https://github.com/user-attachments/assets/fb8d5347-b357-4a3a-840a-721cdf8a6125

## What Changed

### New Files
- **`src/composables/maskeditor/useCanvasTransform.ts`**
  - Core transformation logic for rotation and mirroring
  - GPU texture recreation after transformations

### Modified Files
#### **`src/composables/useCoreCommands.ts`**
- Added check to see if Mask Editor is opened for undo and redo commands

#### **`src/stores/maskEditorStore.ts`**
- Added GPU texture recreation signals

#### **`src/composables/maskeditor/useBrushDrawing.ts`**
- Added watcher for `gpuTexturesNeedRecreation` signal
- Handles GPU texture recreation when canvas dimensions change
- Recreates textures with new dimensions after rotation
- Updates preview canvas and readback buffers accordingly
- Ensures proper ArrayBuffer backing for WebGPU compatibility

#### **`src/components/maskeditor/TopBarHeader.vue`**
- Added 4 new transform buttons with icons:
  - Rotate Left (counter-clockwise)
  - Rotate Right (clockwise)
  - Mirror Horizontal
  - Mirror Vertical
- Added visual separators between button groups

#### **`src/extensions/core/maskEditor.ts`**
- Added keyboard shortcut settings for rotate and mirror

#### **Translation Files** (e.g., `src/locales/en.json`)
- Added i18n keys:

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7841-Added-MaskEditor-Rotate-and-Mirror-Functions-2de6d73d365081bc9b84ea4919a3c6a1)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Alexander Brown <drjkl@comfy.org>
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2026-01-10 12:45:08 -08:00
Benjamin Lu
8086f977c9 [QPOv2] Add list view to assets sidepanel (#7737)
This adds the list view to the media assets sidepanel, while also adding
the active jobs to be displayed right now.

The design for this is actually changing, which is why it is in draft
right now. There are technical limitations of the virtual grid that
doesn't make it easy for both the active jobs and generated assets to
exist on the same container. Currently WIP right now.


Part of the QPO v2 iteration, figma design can be found
[here](https://www.figma.com/design/LVilZgHGk5RwWOkVN6yCEK/Queue-Progress-Modal?node-id=3330-37286&m=dev).
This will be implemented in a series of stacked PRs that can be reviewed
and merged individually.

main <-- #7737, #7743, #7745

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7737-QPOv2-Add-list-view-to-assets-sidepanel-2d26d73d365081858e22c48902bd56e2)
by [Unito](https://www.unito.io)

---------

Co-authored-by: GitHub Action <action@github.com>
2026-01-10 11:56:29 -07:00
Alexander Piskun
f843d779c2 feat(price-badges): add price badges for Vidu2 nodes (#7927)
## Summary

Price badges for the new nodes.

## Screenshots (if applicable)

<img width="1427" height="1084" alt="Screenshot From 2026-01-09
13-29-59"
src="https://github.com/user-attachments/assets/b805d138-fa72-4987-8aa0-ff9ee10ac9a7"
/>

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7927-feat-price-badges-add-price-badges-for-Vidu2-nodes-2e36d73d365081a0b6f4de768dce11e4)
by [Unito](https://www.unito.io)
2026-01-10 20:51:16 +02:00
Alexander Brown
bce4f876f4 fix(UploadModel): truncate long filenames in wizard (#7939)
## Summary

Truncate long filenames in the model upload wizard to prevent dialog
overflow.

## Changes

- **UploadModelConfirmation**: Added `min-w-0 flex-1 truncate` to model
filename display
- **UploadModelProgress**: Added truncation to both processing and
success state filename displays

## Testing

1. Import a model with a very long filename
2. Verify the filename truncates with ellipsis instead of expanding the
dialog

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7939-fix-UploadModel-truncate-long-filenames-in-wizard-2e46d73d365081a0a60bd6326129b9a4)
by [Unito](https://www.unito.io)
2026-01-09 19:56:35 -08:00
Alexander Brown
4b095f3701 fix: Model upload UI improvements (#7938)
## Summary

Polishing improvements for the model upload (BYOM) experience.

## Changes

- **HoneyToast z-index**: Increased from `z-50` to `z-9999` so the
ModelImportProgressDialog appears above modal backdrops
- **VideoHelpDialog**: Removed pixel-based max-width constraint, now
uses `90vw` to fill more of the viewport
- **UploadModelDialog responsive layout**: Added `max-height: 90vh` and
scrollable content area to prevent footer buttons from underflowing on
small screens
- **URL validity indicator**: Added green checkmark icon inside the URL
input when a valid Civitai or HuggingFace URL is entered

## Testing

- Open the model upload dialog and verify buttons remain accessible on
small viewport heights
- Enter a valid Civitai/HuggingFace URL and confirm the green checkmark
appears
- Open the help video and verify it uses more of the viewport
- Start a model download and verify the progress toast appears above any
open modals

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7938-fix-Model-upload-UI-improvements-2e46d73d365081a292f5fda70c6db0f5)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Amp <amp@ampcode.com>
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2026-01-09 19:25:34 -08:00
Comfy Org PR Bot
c8e181c841 1.37.8 (#7937)
Patch version increment to 1.37.8

**Base branch:** `main`

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7937-1-37-8-2e46d73d3650814bb126d7a0bc385a44)
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-01-09 18:49:52 -07:00
Alexander Brown
41ffb7c627 feat: add polling fallback for stale asset downloads (#7926)
## Summary

Adds a polling fallback mechanism to recover from dropped WebSocket
messages during model downloads.

## Problem

When downloading models via the asset download service, status updates
are received over WebSocket. Sometimes these messages are dropped
(network issues, reconnection, etc.), causing downloads to appear
"stuck" even when they've completed on the backend.

## Solution

Periodically poll for stale downloads using the existing REST API:

- Track `lastUpdate` timestamp on each download
- Downloads without updates for 10s are considered "stale"
- Poll stale downloads every 10s via `GET /tasks/{task_id}` to check if
the asset exists
- If the asset exists with size > 0, mark the download as completed

## Implementation

- Added `lastUpdate` field to `AssetDownload` interface
- Use VueUse's `useIntervalFn` with a `watch` to auto start/stop polling
based on active downloads
- Reuse existing `handleAssetDownload` for completion (synthetic event)
- Added 9 unit tests covering the polling behavior

## Testing

- All existing tests pass
- New tests cover:
  - Basic download tracking
  - Completion/failure handling  
  - Duplicate message prevention
  - Stale download polling
  - Polling error handling

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7926-feat-add-polling-fallback-for-stale-asset-downloads-2e36d73d3650810ea966f5480f08b60c)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Amp <amp@ampcode.com>
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2026-01-09 16:23:12 -08:00
Comfy Org PR Bot
5029a0b32c 1.37.7 (#7913)
Patch version increment to 1.37.7

**Base branch:** `main`

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7913-1-37-7-2e36d73d365081c7aac1f4c689a03769)
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-01-09 16:42:52 -07:00
Simula_r
f240ecaaff fix: UX nits and styles (#7933)
## Summary

- Fix UX nits

## Screenshots


https://github.com/user-attachments/assets/f224a710-5cfd-4aad-a617-20ec56a37370

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7933-fix-UX-nits-and-styles-2e36d73d365081379a48e1030b7d4340)
by [Unito](https://www.unito.io)
2026-01-09 15:40:25 -08:00
Terry Jia
6a1da7a7af fix: image compare height mismatch between before and after images (#7931)
## Summary

Add relative and size-full classes to the inner container div to ensure
both images share the same positioning context and size constraints.

fix https://github.com/Comfy-Org/ComfyUI_frontend/issues/7928

## Screenshots (if applicable)
before
<img width="666" height="369" alt="image"
src="https://github.com/user-attachments/assets/2118685c-412a-4689-aac4-c0e592e47678"
/>

after
<img width="500" height="505" alt="image"
src="https://github.com/user-attachments/assets/c773147a-a28a-4145-a26a-6f19dafad50f"
/>

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7931-fix-image-compare-height-mismatch-between-before-and-after-images-2e36d73d365081b69b49dfdd63d242a9)
by [Unito](https://www.unito.io)
2026-01-09 16:02:32 -05:00
Johnpaul Chiwetelu
a6ca2bcd42 fix: improve type safety in litegraph library layer - No Explicit Any mission (PR 2) (#7401)
## Summary

Part 2 of the type safety remediation plan. This PR focuses on the
Litegraph Library Layer as part of the **No Explicit Any** mission.

### Changes

**LiteGraphGlobal.ts:**
- `DEFAULT_GROUP_FONT_SIZE`: Changed from `any` (with no value) to
`number = 24`. The internal fallback was already 24, so the constant was
effectively useless without an assigned value.
- `getParameterNames`: Replace `any` with `unknown` in function
signature
- `extendClass`: Replace deprecated
`__lookupGetter__`/`__defineGetter__` with modern
`Object.getOwnPropertyDescriptor`/`defineProperty` and add proper Record
types

**LGraphNodeProperties.ts:**
- Replace `any` with `unknown` for property values
- Use `Record<string, unknown>` with proper type assertions for dynamic
property access

**types/widgets.ts & BaseWidget.ts:**
- Change `callback` value parameter from `any` to properly typed
(`unknown` in interface, `TWidget['value']` in implementation)

**Consuming code fixes:**
- `previewAny.ts`: Add explicit `boolean` type annotation for callback
value
- `ButtonWidget.ts`: Pass widget value instead of widget instance to
callback (matching the interface signature)

## Breaking Change Analysis (Sourcegraph Verified)

### ButtonWidget callback fix (`this` → `this.value`)
This PR fixes the ButtonWidget callback to pass `value` instead of
`this`, matching the interface definition.

**Verification via Sourcegraph** - all external usages are safe:
-
[comfyui-ollama](https://cs.comfy.org/github.com/stavsap/comfyui-ollama/-/blob/web/js/OllamaNode.js?L84)
- doesn't use callback args
-
[ComfyLab-Pack](https://cs.comfy.org/github.com/bugltd/ComfyLab-Pack/-/blob/dist/js/nodes/list.js?L8)
- doesn't use callback args
-
[ComfyUI_PaintingCoderUtils](https://cs.comfy.org/github.com/jammyfu/ComfyUI_PaintingCoderUtils/-/blob/web/js/click_popup.js?L18)
- doesn't use callback args
-
[ComfyUI-ShaderNoiseKSampler](https://cs.comfy.org/github.com/AEmotionStudio/ComfyUI-ShaderNoiseKSampler/-/blob/web/matrix_button.js?L3055-3056)
- was already working around this bug

---------

Co-authored-by: GitHub Action <action@github.com>
2026-01-09 21:58:39 +01:00
Terry Jia
886fe07de9 fix: respect node resizable property in vueNodes mode (#7934)
## Summary
Custom nodes like ComfyUI-KJNodes set `this.resizable = false` to
disable resizing. This worked in litegraph but was ignored in vueNodes
mode.

Extract the resizable property from LGraphNode to VueNodeData and use it
to conditionally render the resize handle and block resize interactions.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7934-fix-respect-node-resizable-property-in-vueNodes-mode-2e36d73d365081a0a92ade8b23ee3ce8)
by [Unito](https://www.unito.io)
2026-01-09 15:57:50 -05:00
Jin Yi
43c162a862 feat: add bulk context menu for multi-asset selection (#7923) 2026-01-09 15:43:17 +09:00
Terry Jia
92f21c14d4 fix: remove negative margin from legacy widget canvas (#7925)
## Summary

Removes mt-[-13px] from WidgetLegacy canvas to fix legacy widgets
overlapping with output slots in vueNodes mode.

## Screenshots
before
<img width="2560" height="939" alt="image"
src="https://github.com/user-attachments/assets/cbee2bee-b6b2-4d21-b1b6-78d1a8e09949"
/>

after
<img width="1213" height="920" alt="image"
src="https://github.com/user-attachments/assets/a3a26514-b425-4771-b234-06da65f525bc"
/>

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7925-fix-remove-negative-margin-from-legacy-widget-canvas-2e36d73d36508113aea4f2301cccff3c)
by [Unito](https://www.unito.io)
2026-01-08 23:22:40 -05:00
Simula_r
1bf5b5397d Feat(cloud)/new top up dialog (#7899)
## Summary

- Implement the new add credits (top up) dialog. 
- Refactor the subscription dialog to make different credit types easier
to understand

## Changes

- **What**: TopUpCreditsDialogContent.vue, SubscriptionPanel.vue,
/en/main.json
- **Breaking**: <!-- Any breaking changes (if none, remove this line)
-->
- **Dependencies**: <!-- New dependencies (if none, remove this line)
-->

## Review Focus

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

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


https://github.com/user-attachments/assets/a6454651-e195-4430-bfcc-0f2a8c1dc80b

Relevant notion links:

https://www.notion.so/comfy-org/Implement-New-Top-Up-Dialog-with-Custom-Amount-Input-2df6d73d36508142b901fc0edb0d1fc1?source=copy_link

https://www.notion.so/comfy-org/Implement-Update-confusing-credits-remaining-this-month-message-2df6d73d36508168b7e5ed46754cec60?source=copy_link
2026-01-08 19:22:50 -08:00
Alexander Brown
51a7654a39 perf(AssetBrowserModal): virtualize asset grid to reduce network requests (#7919)
## Problem

The `AssetBrowserModal` triggers hundreds of network requests when
opened because `AssetGrid.vue` renders all asset cards immediately using
a simple `v-for` loop. Each `AssetCard` loads its thumbnail image,
causing a flood of simultaneous requests.

## Solution

Replace the simple `v-for` with the existing `VirtualGrid` component
(already used in `AssetsSidebarTab.vue` and `ManagerDialogContent.vue`)
to only render visible items plus a small buffer.

## Changes

- **`AssetGrid.vue`**: Use `VirtualGrid` with computed `assetsWithKey`
that adds the required `key` property from `asset.id`
- **`BaseModalLayout.vue`**: Add `flex-1` to content container for
proper height calculation (required for `VirtualGrid` to work)

## Testing

- All 130 asset-related tests pass
- TypeScript and lint checks pass

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7919-perf-AssetBrowserModal-virtualize-asset-grid-to-reduce-network-requests-2e36d73d365081a1be18d0eb33b7ef8a)
by [Unito](https://www.unito.io)

Co-authored-by: Amp <amp@ampcode.com>
2026-01-08 19:05:55 -08:00
Alexander Brown
644a8bc60c fix: Button sizing in modals and asset browser (#7920)
## Summary

Fix button sizing inconsistencies in modal dialogs and the asset
browser.

## Changes

- **What**: Fix Import button using responsive size (`lg`/`icon` based
on breakpoint) and ensure Modal Close button has explicit `w-10` width
for consistent sizing.

## Review Focus

Button sizing consistency across the modal UI.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7920-fix-Button-sizing-in-modals-and-asset-browser-2e36d73d365081fc997af8be1e928049)
by [Unito](https://www.unito.io)
2026-01-09 03:02:59 +00:00
Johnpaul Chiwetelu
9e434a1002 fix: replace text-white with theme-aware color tokens (#7908)
## Summary
- Replace hardcoded `text-white` class with theme-aware alternatives to
fix invisible text on light themes
- Update Load3D control backgrounds to use semantic tokens
- Update dropdown menus to use `bg-interface-menu-surface`
- Update overlay backgrounds to use `bg-backdrop` with opacity

## Changes
| Component | Old | New |
|-----------|-----|-----|
| Text on primary bg | `text-white` | `text-base-foreground` |
| Dropdown menus | `bg-black/50` | `bg-interface-menu-surface` |
| Control panels | `bg-smoke-700/30` | `bg-backdrop/30` |
| Loading overlays | `bg-black bg-opacity-50` | `bg-backdrop/50` |
| Selected states | `bg-smoke-600` | `bg-button-active-surface` |

## Files Modified (14)
- `src/components/TopMenuSection.vue`
- `src/components/input/MultiSelect.vue`
- `src/components/load3d/*.vue` (12 files)
- `src/renderer/extensions/vueNodes/VideoPreview.vue`

## Test plan
- [ ] Verify text visibility in light theme
- [ ] Verify text visibility in dark theme
- [ ] Test Load3D viewer controls functionality
- [ ] Test MultiSelect dropdown checkbox visibility

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7908-fix-replace-text-white-with-theme-aware-color-tokens-2e26d73d36508107bb01d1d6e3b74f6a)
by [Unito](https://www.unito.io)
2026-01-09 02:40:15 +01:00
Jin Yi
a2e0c3d596 feature: model browser folder grouping (#7892) 2026-01-08 16:58:06 -08:00
Alexander Brown
e26e1f0c9e feat: add HoneyToast component for persistent progress notifications (#7902)
## Summary

Add HoneyToast, a persistent bottom-anchored notification component for
long-running task progress, and migrate existing progress dialogs to use
it.

## Changes

- **What**: 
- New `HoneyToast` component with slot-based API, Teleport, transitions,
and accessibility
  - Migrated `ModelImportProgressDialog` to use HoneyToast
- Created `ManagerProgressToast` combining the old Header/Content/Footer
components
- Deleted deprecated `ManagerProgressDialogContent`,
`ManagerProgressHeader`, `ManagerProgressFooter`, and
`useManagerProgressDialogStore`
- Removed no-op
`showManagerProgressDialog`/`toggleManagerProgressDialog` functions
  - Added Storybook stories for HoneyToast and ProgressToastItem

## Review Focus

- HoneyToast component design and slot API
- ManagerProgressToast self-contained state management (auto-shows when
`comfyManagerStore.taskLogs.length > 0`)
- Accessibility attributes on the toast component

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7902-feat-add-HoneyToast-component-for-persistent-progress-notifications-2e26d73d365081c78ae6edc5accb326e)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Amp <amp@ampcode.com>
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
Co-authored-by: sno <snomiao@gmail.com>
Co-authored-by: github-actions <github-actions@github.com>
Co-authored-by: GitHub Action <action@github.com>
2026-01-08 16:49:56 -08:00
Christian Byrne
af094ebefc Fix run badge anchoring (#7912)
## Summary
Restore the shared button's positioning context so the run-queue badge
anchors to the correct spot.

## Changes
- **What**: add `position: relative` back to `button.variants.ts` so
badge overlays stay attached to their buttons

## Review Focus
- Make sure no buttons rely on being `position: static` (should be
unaffected) and that the run badge now sits beside the Run button
instead of the window edge.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7912-Fix-run-badge-anchoring-2e26d73d365081aa8fefe5381f37cfa4)
by [Unito](https://www.unito.io)

---------

Co-authored-by: github-actions <github-actions@github.com>
2026-01-08 17:07:01 -07:00
Jin Yi
eea24166e0 Refactor/code-reivew (#7893)
## Summary

<!-- One sentence describing what changed and why. -->
https://github.com/Comfy-Org/ComfyUI_frontend/pull/7871
https://github.com/Comfy-Org/ComfyUI_frontend/pull/7858
I refactored the code based on the reviews I received on those two PRs.

## Changes

- **What**: 

1. Updated IconGroup to address the backgroundClass handling.
2. Replaced text-gold-600 with a semantic color token.
3. Replaced PrimeVue Icon with a lucide icon.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7893-Refactor-code-reivew-2e26d73d365081e68a44e89ed1163062)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Alexander Brown <drjkl@comfy.org>
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
Co-authored-by: GitHub Action <action@github.com>
2026-01-08 15:52:49 -07:00
sno
3bd74dcf39 fix: enable immediate file saving for i18n translations (#7785)
## Summary

Fixes the pt-BR locale generation issue by enabling immediate file
persistence in the lobe-i18n configuration.

## Problem

The pt-BR locale was added in PR #6943 with proper infrastructure, but
translation files have remained empty (`{}`) despite the i18n workflow
running successfully on version-bump PRs.

### Root Cause

The `lobe-i18n` tool has a `saveImmediately` configuration option
(defaults to `false`) that controls whether translations are persisted
to disk immediately during generation. When bootstrapping from
completely empty `{}` JSON files, without `saveImmediately: true`, the
tool generates translations in memory but doesn't write them to disk,
resulting in empty files.

**Evidence:**
- All other locales: ~1,931 lines each (previously bootstrapped)
- pt-BR before fix: 1 line (`{}` in all 4 files)
- CI workflow runs successfully but pt-BR files remain empty
- After adding `saveImmediately: true`: 18,787 lines generated across
all 4 pt-BR files

## Solution

Add `saveImmediately: true` to `.i18nrc.cjs` configuration:

```javascript
module.exports = defineConfig({
  modelName: 'gpt-4.1',
  splitToken: 1024,
  saveImmediately: true,  // ← Enables immediate file persistence
  entry: 'src/locales/en',
  entryLocale: 'en',
  output: 'src/locales',
  outputLocales: ['zh', 'zh-TW', 'ru', 'ja', 'ko', 'fr', 'es', 'ar', 'tr', 'pt-BR'],
  // ...
});
```

This ensures that when lobe-i18n generates translations from empty
files, they are immediately written to disk rather than kept only in
memory.

## Validation

This PR's commit history demonstrates the fix works:

1. **Commit `22e6e28f5`**: Applied the `saveImmediately: true` fix
2. **Commit `cd7e93786`**: Temporarily enabled i18n workflow for this
branch (for testing)
3. **Commit `84545c218`**: CI successfully generated complete pt-BR
translations:
   - `commands.json`: 327 lines
   - `main.json`: 2,458 lines
   - `nodeDefs.json`: 15,539 lines
   - `settings.json`: 463 lines
   - **Total: 18,787 lines of Portuguese translations**
4. **Commits `85f282f98` & `05d097f7b`**: Reverted test commits to keep
PR minimal

## Changes

- `.i18nrc.cjs`: Added `saveImmediately: true` configuration option (+1
line)

## Impact

After this fix is merged, future `version-bump-*` PRs will automatically
generate and persist pt-BR translations alongside all other locales,
keeping Portuguese (Brazil) translations up-to-date with the codebase.

## References

- Original issue:
https://github.com/Comfy-Org/ComfyUI_frontend/pull/6943#issuecomment-3679664466
- Related PR: #6943 (Portuguese (Brazil) locale addition)
- lobe-i18n documentation:
https://github.com/lobehub/lobe-cli-toolbox/tree/master/packages/lobe-i18n

Fixes #6943 (comment)

---------

Co-authored-by: github-actions <github-actions@github.com>
Co-authored-by: Alexander Brown <drjkl@comfy.org>
2026-01-08 15:46:12 -07:00
Alexander Brown
405e756d4c feat: add model download progress dialog (#7897)
## Summary

Add a progress dialog for model downloads that appears when downloads
are active.

## Changes

- Add `ModelImportProgressDialog` component for showing download
progress
- Add `ProgressToastItem` component for individual download job display
- Add `StatusBadge` component for status indicators
- Extend `assetDownloadStore` with:
  - `finishedDownloads` computed for completed/failed jobs
  - `hasDownloads` computed for dialog visibility
  - `clearFinishedDownloads()` to dismiss finished downloads
- Dialog visibility driven by store state
- Closing dialog clears finished downloads
- Filter dropdown to show all/completed/failed downloads
- Expandable/collapsible UI with animated transitions
- Update AGENTS.md with import type convention and pluralization note

## Testing

- Start a model download and verify the dialog appears
- Verify expand/collapse animation works
- Verify filter dropdown works
- Verify closing the dialog clears finished downloads
- Verify dialog hides when no downloads remain

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7897-feat-add-model-download-progress-dialog-2e26d73d36508116960eff6fbe7dc392)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Amp <amp@ampcode.com>
2026-01-08 14:29:02 -08:00
Comfy Org PR Bot
0ca27f3d9b 1.37.6 (#7885)
Patch version increment to 1.37.6

**Base branch:** `main`

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7885-1-37-6-2e26d73d3650814e8b57dcdf452461e5)
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-01-08 14:37:12 -07:00
Jin Yi
b54ed97557 feat: add red dot indicator to top menu custom nodes manager button (#7896) 2026-01-08 13:27:27 -08:00
Alexander Piskun
15a05afc27 fix(price-badges): improve Gemini and OpenAI chat nodes (#7900)
## Summary

Added `~` to the price badges and a correct separator.

## Screenshots (if applicable)

Before commit:

<img width="1163" height="516" alt="Screenshot From 2026-01-08 09-53-00"
src="https://github.com/user-attachments/assets/8f5afa87-0b25-4748-a254-5ae09990f83f"
/>


After:

<img width="1163" height="516" alt="Screenshot From 2026-01-08 09-52-09"
src="https://github.com/user-attachments/assets/f4332e4a-4943-4c0d-8ed5-9ec0c119d0b4"
/>

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7900-fix-price-badges-improve-Gemini-and-OpenAI-chat-nodes-2e26d73d3650812093f2d173de50052d)
by [Unito](https://www.unito.io)
2026-01-08 14:17:25 -07:00
Alexander Piskun
1bde87838d fix(price-badges): add missing badge for WanReferenceVideoApi node (#7901)
## Screenshots

<img width="1179" height="593" alt="Screenshot From 2026-01-08 10-11-05"
src="https://github.com/user-attachments/assets/368fe0ba-86f9-479f-a78e-61498d16eed0"
/>

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7901-fix-price-badges-add-missing-badge-for-WanReferenceVideoApi-node-2e26d73d365081c2b043d265343e90c0)
by [Unito](https://www.unito.io)
2026-01-08 22:21:23 +02:00
203 changed files with 56315 additions and 4002 deletions

View File

@@ -1,9 +1,9 @@
# Description: Deploys test results from forked PRs (forks can't access deployment secrets)
name: "CI: Tests E2E (Deploy for Forks)"
name: 'CI: Tests E2E (Deploy for Forks)'
on:
workflow_run:
workflows: ["CI: Tests E2E"]
workflows: ['CI: Tests E2E']
types: [requested, completed]
env:
@@ -81,6 +81,7 @@ jobs:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
GITHUB_TOKEN: ${{ github.token }}
GITHUB_SHA: ${{ github.event.workflow_run.head_sha }}
run: |
# Rename merged report if exists
[ -d "reports/playwright-report-chromium-merged" ] && \

View File

@@ -1,5 +1,5 @@
# Description: End-to-end testing with Playwright across multiple browsers, deploys test reports to Cloudflare Pages
name: "CI: Tests E2E"
name: 'CI: Tests E2E'
on:
push:
@@ -37,7 +37,7 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 60
container:
image: ghcr.io/comfy-org/comfyui-ci-container:0.0.8
image: ghcr.io/comfy-org/comfyui-ci-container:0.0.10
credentials:
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
@@ -85,7 +85,7 @@ jobs:
needs: setup
runs-on: ubuntu-latest
container:
image: ghcr.io/comfy-org/comfyui-ci-container:0.0.8
image: ghcr.io/comfy-org/comfyui-ci-container:0.0.10
credentials:
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
@@ -222,6 +222,7 @@ jobs:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
GITHUB_TOKEN: ${{ github.token }}
GITHUB_SHA: ${{ github.event.pull_request.head.sha }}
run: |
bash ./scripts/cicd/pr-playwright-deploy-and-comment.sh \
"${{ github.event.pull_request.number }}" \

View File

@@ -77,7 +77,7 @@ jobs:
needs: setup
runs-on: ubuntu-latest
container:
image: ghcr.io/comfy-org/comfyui-ci-container:0.0.8
image: ghcr.io/comfy-org/comfyui-ci-container:0.0.10
credentials:
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}

View File

@@ -6,10 +6,11 @@ const { defineConfig } = require('@lobehub/i18n-cli');
module.exports = defineConfig({
modelName: 'gpt-4.1',
splitToken: 1024,
saveImmediately: true,
entry: 'src/locales/en',
entryLocale: 'en',
output: 'src/locales',
outputLocales: ['zh', 'zh-TW', 'ru', 'ja', 'ko', 'fr', 'es', 'ar', 'tr', 'pt-BR'],
outputLocales: ['zh', 'zh-TW', 'ru', 'ja', 'ko', 'fr', 'es', 'ar', 'tr', 'pt-BR', 'fa'],
reference: `Special names to keep untranslated: flux, photomaker, clip, vae, cfg, stable audio, stable cascade, stable zero, controlnet, lora, HiDream, Civitai, Hugging Face.
'latent' is the short form of 'latent space'.
'mask' is in the context of image processing.
@@ -18,5 +19,11 @@ module.exports = defineConfig({
- For 'zh' locale: Use ONLY Simplified Chinese characters (简体中文). Common examples: 节点 (not 節點), 画布 (not 畫布), 图像 (not 圖像), 选择 (not 選擇), 减小 (not 減小).
- For 'zh-TW' locale: Use ONLY Traditional Chinese characters (繁體中文) with Taiwan-specific terminology.
- NEVER mix Simplified and Traditional Chinese characters within the same locale.
IMPORTANT Persian Translation Guidelines:
- For 'fa' locale: Use formal Persian (فارسی رسمی) for professional tone throughout the UI.
- Keep commonly used technical terms in English when they are standard in Persian software (e.g., node, workflow).
- Use Arabic-Indic numerals (۰-۹) for numbers where appropriate.
- Maintain consistency with terminology used in Persian software and design applications.
`
});

View File

@@ -7,7 +7,7 @@ import type { InlineConfig } from 'vite'
const config: StorybookConfig = {
stories: ['../src/**/*.stories.@(js|jsx|mjs|ts|tsx)'],
addons: ['@storybook/addon-docs'],
addons: ['@storybook/addon-docs', '@storybook/addon-mcp'],
framework: {
name: '@storybook/vue3-vite',
options: {}
@@ -69,9 +69,32 @@ const config: StorybookConfig = {
allowedHosts: true
},
resolve: {
alias: {
'@': process.cwd() + '/src'
}
alias: [
{
find: '@/composables/queue/useJobList',
replacement: process.cwd() + '/src/storybook/mocks/useJobList.ts'
},
{
find: '@/composables/queue/useJobActions',
replacement: process.cwd() + '/src/storybook/mocks/useJobActions.ts'
},
{
find: '@/utils/formatUtil',
replacement:
process.cwd() +
'/packages/shared-frontend-utils/src/formatUtil.ts'
},
{
find: '@/utils/networkUtil',
replacement:
process.cwd() +
'/packages/shared-frontend-utils/src/networkUtil.ts'
},
{
find: '@',
replacement: process.cwd() + '/src'
}
]
},
esbuild: {
// Prevent minification of identifiers to preserve _sfc_main

View File

@@ -63,6 +63,9 @@ The project uses **Nx** for build orchestration and task management
- Imports:
- sorted/grouped by plugin
- run `pnpm format` before committing
- use separate `import type` statements, not inline `type` in mixed imports
-`import type { Foo } from './foo'` + `import { bar } from './foo'`
-`import { bar, type Foo } from './foo'`
- ESLint:
- Vue + TS rules
- no floating promises
@@ -119,7 +122,10 @@ The project uses **Nx** for build orchestration and task management
- Prefer reactive props destructuring to `const props = defineProps<...>`
- Do not use `withDefaults` or runtime props declaration
- Do not import Vue macros unnecessarily
- Prefer `useModel` to separately defining a prop and emit
- Prefer `defineModel` to separately defining a prop and emit for v-model bindings
- Define slots via template usage, not `defineSlots`
- Use same-name shorthand for slot prop bindings: `:isExpanded` instead of `:is-expanded="isExpanded"`
- Derive component types using `vue-component-type-helpers` (`ComponentProps`, `ComponentSlots`) instead of separate type files
- Be judicious with addition of new refs or other state
- If it's possible to accomplish the design goals with just a prop, don't add a `ref`
- If it's possible to use the `ref` or prop directly, don't add a `computed`
@@ -137,7 +143,7 @@ The project uses **Nx** for build orchestration and task management
8. Implement proper error handling
9. Follow Vue 3 style guide and naming conventions
10. Use Vite for fast development and building
11. Use vue-i18n in composition API for any string literals. Place new translation entries in src/locales/en/main.json
11. Use vue-i18n in composition API for any string literals. Place new translation entries in src/locales/en/main.json. Use the plurals system in i18n instead of hardcoding pluralization in templates.
12. Avoid new usage of PrimeVue components
13. Write tests for all changes, especially bug fixes to catch future regressions
14. Write code that is expressive and self-documenting to the furthest degree possible. This reduces the need for code comments which can get out of sync with the code itself. Try to avoid comments unless absolutely necessary
@@ -155,6 +161,8 @@ The project uses **Nx** for build orchestration and task management
## Testing Guidelines
See @docs/testing/*.md for detailed patterns.
- Frameworks:
- Vitest (unit/component, happy-dom)
- Playwright (E2E)
@@ -268,6 +276,8 @@ When referencing Comfy-Org repos:
- Use `cn()` inline in the template when feasible instead of creating a `computed` to hold the value
- NEVER use `!important` or the `!` important prefix for tailwind classes
- Find existing `!important` classes that are interfering with the styling and propose corrections of those instead.
- NEVER use arbitrary percentage values like `w-[80%]` when a Tailwind fraction utility exists
- Use `w-4/5` instead of `w-[80%]`, `w-1/2` instead of `w-[50%]`, etc.
## Agent-only rules

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 108 KiB

After

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 81 KiB

After

Width:  |  Height:  |  Size: 81 KiB

View File

@@ -0,0 +1,138 @@
---
globs:
- '**/*.test.ts'
- '**/*.spec.ts'
---
# Vitest Patterns
## Setup
Use `createTestingPinia` from `@pinia/testing`, not `createPinia`:
```typescript
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
describe('MyStore', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
vi.useFakeTimers()
vi.resetAllMocks()
})
afterEach(() => {
vi.useRealTimers()
})
})
```
**Why `stubActions: false`?** By default, testing pinia stubs all actions. Set to `false` when testing actual store behavior.
## Mock Patterns
### Reset all mocks at once
```typescript
beforeEach(() => {
vi.resetAllMocks() // Not individual mock.mockReset() calls
})
```
### Module mocks with vi.mock()
```typescript
vi.mock('@/scripts/api', () => ({
api: {
addEventListener: vi.fn(),
fetchData: vi.fn()
}
}))
vi.mock('@/services/myService', () => ({
myService: {
doThing: vi.fn()
}
}))
```
### Configure mocks in tests
```typescript
import { api } from '@/scripts/api'
import { myService } from '@/services/myService'
it('handles success', () => {
vi.mocked(myService.doThing).mockResolvedValue({ data: 'test' })
// ... test code
})
```
## Testing Event Listeners
When a store registers event listeners at module load time:
```typescript
function getEventHandler() {
const call = vi.mocked(api.addEventListener).mock.calls.find(
([event]) => event === 'my_event'
)
return call?.[1] as (e: CustomEvent<MyEventType>) => void
}
function dispatch(data: MyEventType) {
const handler = getEventHandler()
handler(new CustomEvent('my_event', { detail: data }))
}
it('handles events', () => {
const store = useMyStore()
dispatch({ field: 'value' })
expect(store.items).toHaveLength(1)
})
```
## Testing with Fake Timers
For stores with intervals, timeouts, or polling:
```typescript
beforeEach(() => {
vi.useFakeTimers()
})
afterEach(() => {
vi.useRealTimers()
})
it('polls after delay', async () => {
const store = useMyStore()
store.startPolling()
await vi.advanceTimersByTimeAsync(30000)
expect(mockService.fetch).toHaveBeenCalled()
})
```
## Assertion Style
Prefer `.toHaveLength()` over `.length.toBe()`:
```typescript
// Good
expect(store.items).toHaveLength(1)
// Avoid
expect(store.items.length).toBe(1)
```
Use `.toMatchObject()` for partial matching:
```typescript
expect(store.completedItems[0]).toMatchObject({
id: 'task-123',
status: 'done'
})
```

View File

@@ -8,7 +8,8 @@ const config: KnipConfig = {
'src/assets/css/style.css',
'src/main.ts',
'src/scripts/ui/menu/index.ts',
'src/types/index.ts'
'src/types/index.ts',
'src/storybook/mocks/**/*.ts'
],
project: ['**/*.{js,ts,vue}', '*.{js,ts,mts}']
},

View File

@@ -1,7 +1,7 @@
{
"name": "@comfyorg/comfyui-frontend",
"private": true,
"version": "1.37.5",
"version": "1.37.9",
"type": "module",
"repository": "https://github.com/Comfy-Org/ComfyUI_frontend",
"homepage": "https://comfy.org",
@@ -66,6 +66,7 @@
"@prettier/plugin-oxc": "catalog:",
"@sentry/vite-plugin": "catalog:",
"@storybook/addon-docs": "catalog:",
"@storybook/addon-mcp": "catalog:",
"@storybook/vue3": "catalog:",
"@storybook/vue3-vite": "catalog:",
"@tailwindcss/vite": "catalog:",

134
pnpm-lock.yaml generated
View File

@@ -84,6 +84,9 @@ catalogs:
'@storybook/addon-docs':
specifier: ^10.1.9
version: 10.1.9
'@storybook/addon-mcp':
specifier: 0.1.6
version: 0.1.6
'@storybook/vue3':
specifier: ^10.1.9
version: 10.1.9
@@ -549,6 +552,9 @@ importers:
'@storybook/addon-docs':
specifier: 'catalog:'
version: 10.1.9(@types/react@19.1.9)(esbuild@0.27.1)(rollup@4.53.5)(storybook@10.1.9(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))
'@storybook/addon-mcp':
specifier: 'catalog:'
version: 0.1.6(storybook@10.1.9(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(typescript@5.9.3)
'@storybook/vue3':
specifier: 'catalog:'
version: 10.1.9(storybook@10.1.9(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(vue@3.5.13(typescript@5.9.3))
@@ -3148,6 +3154,11 @@ packages:
peerDependencies:
storybook: ^10.1.9
'@storybook/addon-mcp@0.1.6':
resolution: {integrity: sha512-+EagCHqwIb9tg3DKskEsXpsqQVnMljxgR5Tt3Bu0ZpWweB1HdMy+ok128gzNfTZ3r+5ljksr0q66YCEkrQwdDA==}
peerDependencies:
storybook: ^9.1.16 || ^10.0.0 || ^10.1.0-0 || ^10.2.0-0 || ^10.3.0-0 || ^10.4.0-0
'@storybook/builder-vite@10.1.9':
resolution: {integrity: sha512-rUILpjGV7gKfXrUeZzpNAer9PspB3LJI1d+gJHISx2Gs24bdneA3y/gu0fWw46ccOSIcwb91xoK5QxliJcWsWg==}
peerDependencies:
@@ -3181,6 +3192,9 @@ packages:
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
'@storybook/mcp@0.1.1':
resolution: {integrity: sha512-+AivFDms1XkY2VUvZBBYy0co5qvRh20eYXYwhaDPQXX2Q4y96arSkWn22e/l3DQwA9Ywzv481vj4gl4zPrCQkg==}
'@storybook/react-dom-shim@10.1.9':
resolution: {integrity: sha512-gJsR6fI1gG4DSin6sQx8RmGDQF8Lije0cZbxHyVedNleBsveGXIPFUKFVi+pRNdwBPni1Z2g/gYyHzkOEqPD2w==}
peerDependencies:
@@ -3453,6 +3467,26 @@ packages:
'@tiptap/starter-kit@2.10.4':
resolution: {integrity: sha512-tu/WCs9Mkr5Nt8c3/uC4VvAbQlVX0OY7ygcqdzHGUeG9zP3twdW7o5xM3kyDKR2++sbVzqu5Ll5qNU+1JZvPGQ==}
'@tmcp/adapter-valibot@0.1.5':
resolution: {integrity: sha512-9P2wrVYPngemNK0UvPb/opC722/jfd09QxXmme1TRp/wPsl98vpSk/MXt24BCMqBRv4Dvs0xxJH4KHDcjXW52Q==}
peerDependencies:
tmcp: ^1.17.0
valibot: ^1.1.0
'@tmcp/session-manager@0.2.1':
resolution: {integrity: sha512-DOGy9LfufXCy1wfpGHZ6qPSDQtRnTVwOb71+41ffovTqzLMZlK3iLK/LIsekHxIiku+iIAUiqEKN+DHbqEm8IA==}
peerDependencies:
tmcp: ^1.16.3
'@tmcp/transport-http@0.8.3':
resolution: {integrity: sha512-gnoBjDBd8/ppl4WRrNKPKHlioCxE8D0zTyNUOzqUjsg0s6GRsyB5iMirh9lC4QjQt0NEOrI+sIJdz+9ymf0MDA==}
peerDependencies:
'@tmcp/auth': ^0.3.3 || ^0.4.0
tmcp: ^1.18.0
peerDependenciesMeta:
'@tmcp/auth':
optional: true
'@trivago/prettier-plugin-sort-imports@5.2.2':
resolution: {integrity: sha512-fYDQA9e6yTNmA13TLVSA+WMQRc5Bn/c0EUBditUHNfMMxN7M82c38b1kEggVE3pLpZ0FwkwJkUEKMiOi52JXFA==}
engines: {node: '>18.12'}
@@ -3786,6 +3820,11 @@ packages:
cpu: [x64]
os: [win32]
'@valibot/to-json-schema@1.5.0':
resolution: {integrity: sha512-GE7DmSr1C2UCWPiV0upRH6mv0cCPsqYGs819fb6srCS1tWhyXrkGGe+zxUiwzn/L1BOfADH4sNjY/YHCuP8phQ==}
peerDependencies:
valibot: ^1.2.0
'@vitejs/plugin-vue@6.0.3':
resolution: {integrity: sha512-TlGPkLFLVOY3T7fZrwdvKpjprR3s4fxRln0ORDo1VQ7HHyxJwTlrjKU3kpVWTlaAjIEuCTokmjkZnr8Tpc925w==}
engines: {node: ^20.19.0 || >=22.12.0}
@@ -5170,6 +5209,9 @@ packages:
jiti:
optional: true
esm-env@1.2.2:
resolution: {integrity: sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==}
esm-resolve@1.0.11:
resolution: {integrity: sha512-LxF0wfUQm3ldUDHkkV2MIbvvY0TgzIpJ420jHSV1Dm+IlplBEWiJTKWM61GtxUfvjV6iD4OtTYFGAGM2uuIUWg==}
@@ -5978,6 +6020,9 @@ packages:
json-parse-even-better-errors@2.3.1:
resolution: {integrity: sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==}
json-rpc-2.0@1.7.1:
resolution: {integrity: sha512-JqZjhjAanbpkXIzFE7u8mE/iFblawwlXtONaCvRqI+pyABVz7B4M1EUNpyVW+dZjqgQ2L5HFmZCmOCgUKm00hg==}
json-schema-traverse@0.4.1:
resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==}
@@ -7386,6 +7431,9 @@ packages:
sprintf-js@1.0.3:
resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==}
sqids@0.3.0:
resolution: {integrity: sha512-lOQK1ucVg+W6n3FhRwwSeUijxe93b51Bfz5PMRMihVf1iVkl82ePQG7V5vwrhzB11v0NtsR25PSZRGiSomJaJw==}
stable-hash-x@0.2.0:
resolution: {integrity: sha512-o3yWv49B/o4QZk5ZcsALc6t0+eCelPc44zZsLtCQnZPDwFpDYSWcDnrv2TtMmMbQ7uKo3J0HTURCqckw23czNQ==}
engines: {node: '>=12.0.0'}
@@ -7613,6 +7661,9 @@ packages:
resolution: {integrity: sha512-8PWx8tvC4jDB39BQw1m4x8y5MH1BcQ5xHeL2n7UVFulMPH/3Q0uiamahFJ3lXA0zO2SUyRXuVVbWSDmstlt9YA==}
hasBin: true
tmcp@1.19.0:
resolution: {integrity: sha512-wOY449EdaWDo7wLZEOVjeH9fn/AqfFF4f+3pDerCI8xHpy2Z8msUjAF0Vkg01aEFIdFMmiNDiY4hu6E7jVX79w==}
tmp@0.2.5:
resolution: {integrity: sha512-voyz6MApa1rQGUxT3E+BK7/ROe8itEx7vD8/HEvt4xwXucvQ5G5oeEiHkmHZJuBO21RpOf+YYm9MOivj709jow==}
engines: {node: '>=14.14'}
@@ -7858,6 +7909,9 @@ packages:
uri-js@4.4.1:
resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==}
uri-template-matcher@1.1.2:
resolution: {integrity: sha512-uZc1h12jdO3m/R77SfTEOuo6VbMhgWznaawKpBjRGSJb7i91x5PgI37NQJtG+Cerxkk0yr1pylBY2qG1kQ+aEQ==}
use-sync-external-store@1.6.0:
resolution: {integrity: sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==}
peerDependencies:
@@ -7873,6 +7927,14 @@ packages:
resolution: {integrity: sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==}
hasBin: true
valibot@1.2.0:
resolution: {integrity: sha512-mm1rxUsmOxzrwnX5arGS+U4T25RdvpPjPN4yR0u9pUBov9+zGVtO84tif1eY4r6zWxVxu3KzIyknJy3rxfRZZg==}
peerDependencies:
typescript: '>=5'
peerDependenciesMeta:
typescript:
optional: true
vfile-message@4.0.3:
resolution: {integrity: sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==}
@@ -8019,6 +8081,9 @@ packages:
vue-component-type-helpers@3.2.1:
resolution: {integrity: sha512-gKV7XOkQl4urSuLHNY1tnVQf7wVgtb/mKbRyxSLWGZUY9RK7aDPhBenTjm+i8ZFe0zC2PZeHMPtOZXZfyaFOzQ==}
vue-component-type-helpers@3.2.2:
resolution: {integrity: sha512-x8C2nx5XlUNM0WirgfTkHjJGO/ABBxlANZDtHw2HclHtQnn+RFPTnbjMJn8jHZW4TlUam0asHcA14lf1C6Jb+A==}
vue-demi@0.14.10:
resolution: {integrity: sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==}
engines: {node: '>=12'}
@@ -10949,6 +11014,18 @@ snapshots:
- vite
- webpack
'@storybook/addon-mcp@0.1.6(storybook@10.1.9(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(typescript@5.9.3)':
dependencies:
'@storybook/mcp': 0.1.1(typescript@5.9.3)
'@tmcp/adapter-valibot': 0.1.5(tmcp@1.19.0(typescript@5.9.3))(valibot@1.2.0(typescript@5.9.3))
'@tmcp/transport-http': 0.8.3(tmcp@1.19.0(typescript@5.9.3))
storybook: 10.1.9(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
tmcp: 1.19.0(typescript@5.9.3)
valibot: 1.2.0(typescript@5.9.3)
transitivePeerDependencies:
- '@tmcp/auth'
- typescript
'@storybook/builder-vite@10.1.9(esbuild@0.27.1)(rollup@4.53.5)(storybook@10.1.9(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))':
dependencies:
'@storybook/csf-plugin': 10.1.9(esbuild@0.27.1)(rollup@4.53.5)(storybook@10.1.9(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))
@@ -10978,6 +11055,16 @@ snapshots:
react: 19.2.3
react-dom: 19.2.3(react@19.2.3)
'@storybook/mcp@0.1.1(typescript@5.9.3)':
dependencies:
'@tmcp/adapter-valibot': 0.1.5(tmcp@1.19.0(typescript@5.9.3))(valibot@1.2.0(typescript@5.9.3))
'@tmcp/transport-http': 0.8.3(tmcp@1.19.0(typescript@5.9.3))
tmcp: 1.19.0(typescript@5.9.3)
valibot: 1.2.0(typescript@5.9.3)
transitivePeerDependencies:
- '@tmcp/auth'
- typescript
'@storybook/react-dom-shim@10.1.9(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(storybook@10.1.9(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))':
dependencies:
react: 19.2.3
@@ -11007,7 +11094,7 @@ snapshots:
storybook: 10.1.9(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
type-fest: 2.19.0
vue: 3.5.13(typescript@5.9.3)
vue-component-type-helpers: 3.2.1
vue-component-type-helpers: 3.2.2
'@swc/helpers@0.5.17':
dependencies:
@@ -11275,6 +11362,23 @@ snapshots:
'@tiptap/extension-text-style': 2.10.4(@tiptap/core@2.10.4(@tiptap/pm@2.10.4))
'@tiptap/pm': 2.10.4
'@tmcp/adapter-valibot@0.1.5(tmcp@1.19.0(typescript@5.9.3))(valibot@1.2.0(typescript@5.9.3))':
dependencies:
'@standard-schema/spec': 1.1.0
'@valibot/to-json-schema': 1.5.0(valibot@1.2.0(typescript@5.9.3))
tmcp: 1.19.0(typescript@5.9.3)
valibot: 1.2.0(typescript@5.9.3)
'@tmcp/session-manager@0.2.1(tmcp@1.19.0(typescript@5.9.3))':
dependencies:
tmcp: 1.19.0(typescript@5.9.3)
'@tmcp/transport-http@0.8.3(tmcp@1.19.0(typescript@5.9.3))':
dependencies:
'@tmcp/session-manager': 0.2.1(tmcp@1.19.0(typescript@5.9.3))
esm-env: 1.2.2
tmcp: 1.19.0(typescript@5.9.3)
'@trivago/prettier-plugin-sort-imports@5.2.2(@vue/compiler-sfc@3.5.25)(prettier@3.7.4)':
dependencies:
'@babel/generator': 7.28.5
@@ -11623,6 +11727,10 @@ snapshots:
'@unrs/resolver-binding-win32-x64-msvc@1.11.1':
optional: true
'@valibot/to-json-schema@1.5.0(valibot@1.2.0(typescript@5.9.3))':
dependencies:
valibot: 1.2.0(typescript@5.9.3)
'@vitejs/plugin-vue@6.0.3(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))(vue@3.5.13(typescript@5.9.3))':
dependencies:
'@rolldown/pluginutils': 1.0.0-beta.53
@@ -13303,6 +13411,8 @@ snapshots:
transitivePeerDependencies:
- supports-color
esm-env@1.2.2: {}
esm-resolve@1.0.11: {}
espree@10.4.0:
@@ -14189,6 +14299,8 @@ snapshots:
json-parse-even-better-errors@2.3.1: {}
json-rpc-2.0@1.7.1: {}
json-schema-traverse@0.4.1: {}
json-schema-traverse@1.0.0: {}
@@ -16055,6 +16167,8 @@ snapshots:
sprintf-js@1.0.3: {}
sqids@0.3.0: {}
stable-hash-x@0.2.0: {}
stack-utils@2.0.6:
@@ -16347,6 +16461,16 @@ snapshots:
dependencies:
tldts-core: 7.0.19
tmcp@1.19.0(typescript@5.9.3):
dependencies:
'@standard-schema/spec': 1.1.0
json-rpc-2.0: 1.7.1
sqids: 0.3.0
uri-template-matcher: 1.1.2
valibot: 1.2.0(typescript@5.9.3)
transitivePeerDependencies:
- typescript
tmp@0.2.5: {}
to-regex-range@5.0.1:
@@ -16644,6 +16768,8 @@ snapshots:
dependencies:
punycode: 2.3.1
uri-template-matcher@1.1.2: {}
use-sync-external-store@1.6.0(react@19.2.3):
dependencies:
react: 19.2.3
@@ -16654,6 +16780,10 @@ snapshots:
uuid@11.1.0: {}
valibot@1.2.0(typescript@5.9.3):
optionalDependencies:
typescript: 5.9.3
vfile-message@4.0.3:
dependencies:
'@types/unist': 3.0.3
@@ -16914,6 +17044,8 @@ snapshots:
vue-component-type-helpers@3.2.1: {}
vue-component-type-helpers@3.2.2: {}
vue-demi@0.14.10(vue@3.5.13(typescript@5.9.3)):
dependencies:
vue: 3.5.13(typescript@5.9.3)

View File

@@ -29,6 +29,7 @@ catalog:
'@sentry/vue': ^10.32.1
'@sparkjsdev/spark': ^0.1.10
'@storybook/addon-docs': ^10.1.9
'@storybook/addon-mcp': 0.1.6
'@storybook/vue3': ^10.1.9
'@storybook/vue3-vite': ^10.1.9
'@tailwindcss/vite': ^4.1.12

View File

@@ -10,37 +10,158 @@ interface TestStats {
finished?: number
}
interface TestResult {
status: string
duration?: number
error?: {
message?: string
stack?: string
}
attachments?: Array<{
name: string
path?: string
contentType: string
}>
}
interface TestCase {
title: string
ok: boolean
outcome: string
results: TestResult[]
}
interface Suite {
title: string
file: string
suites?: Suite[]
tests?: TestCase[]
}
interface FullReportData {
stats?: TestStats
suites?: Suite[]
}
interface ReportData {
stats?: TestStats
}
interface FailedTest {
name: string
file: string
traceUrl?: string
error?: string
}
interface TestCounts {
passed: number
failed: number
flaky: number
skipped: number
total: number
failures?: FailedTest[]
}
/**
* Extract failed test details from Playwright report
*/
function extractFailedTests(
reportData: FullReportData,
baseUrl?: string
): FailedTest[] {
const failures: FailedTest[] = []
function processTest(test: TestCase, file: string, suitePath: string[]) {
// Check if test failed or is flaky
const hasFailed = test.results.some(
(r) => r.status === 'failed' || r.status === 'timedOut'
)
const isFlaky = test.outcome === 'flaky'
if (hasFailed || isFlaky) {
const fullTestName = [...suitePath, test.title]
.filter(Boolean)
.join(' ')
const failedResult = test.results.find(
(r) => r.status === 'failed' || r.status === 'timedOut'
)
// Find trace attachment
let traceUrl: string | undefined
if (failedResult?.attachments) {
const traceAttachment = failedResult.attachments.find(
(a) => a.name === 'trace' && a.contentType === 'application/zip'
)
if (traceAttachment?.path) {
// Convert local path to URL path
const tracePath = traceAttachment.path.replace(/\\/g, '/')
const traceFile = path.basename(tracePath)
if (baseUrl) {
// Construct trace viewer URL
const traceDataUrl = `${baseUrl}/data/${traceFile}`
traceUrl = `${baseUrl}/trace/?trace=${encodeURIComponent(traceDataUrl)}`
}
}
}
failures.push({
name: fullTestName,
file: file,
traceUrl,
error: failedResult?.error?.message
})
}
}
function processSuite(suite: Suite, parentPath: string[] = []) {
const suitePath = suite.title ? [...parentPath, suite.title] : parentPath
// Process tests in this suite
if (suite.tests) {
for (const test of suite.tests) {
processTest(test, suite.file, suitePath)
}
}
// Recursively process nested suites
if (suite.suites) {
for (const childSuite of suite.suites) {
processSuite(childSuite, suitePath)
}
}
}
if (reportData.suites) {
for (const suite of reportData.suites) {
processSuite(suite)
}
}
return failures
}
/**
* Extract test counts from Playwright HTML report
* @param reportDir - Path to the playwright-report directory
* @returns Test counts { passed, failed, flaky, skipped, total }
* @param baseUrl - Base URL of the deployed report (for trace links)
* @returns Test counts { passed, failed, flaky, skipped, total, failures }
*/
function extractTestCounts(reportDir: string): TestCounts {
function extractTestCounts(reportDir: string, baseUrl?: string): TestCounts {
const counts: TestCounts = {
passed: 0,
failed: 0,
flaky: 0,
skipped: 0,
total: 0
total: 0,
failures: []
}
try {
// First, try to find report.json which Playwright generates with JSON reporter
const jsonReportFile = path.join(reportDir, 'report.json')
if (fs.existsSync(jsonReportFile)) {
const reportJson: ReportData = JSON.parse(
const reportJson: FullReportData = JSON.parse(
fs.readFileSync(jsonReportFile, 'utf-8')
)
if (reportJson.stats) {
@@ -54,6 +175,12 @@ function extractTestCounts(reportDir: string): TestCounts {
counts.failed = stats.unexpected || 0
counts.flaky = stats.flaky || 0
counts.skipped = stats.skipped || 0
// Extract detailed failure information
if (counts.failed > 0 || counts.flaky > 0) {
counts.failures = extractFailedTests(reportJson, baseUrl)
}
return counts
}
}
@@ -169,15 +296,18 @@ function extractTestCounts(reportDir: string): TestCounts {
// Main execution
const reportDir = process.argv[2]
const baseUrl = process.argv[3] // Optional: base URL for trace links
if (!reportDir) {
console.error('Usage: extract-playwright-counts.ts <report-directory>')
console.error(
'Usage: extract-playwright-counts.ts <report-directory> [base-url]'
)
process.exit(1)
}
const counts = extractTestCounts(reportDir)
const counts = extractTestCounts(reportDir, baseUrl)
// Output as JSON for easy parsing in shell script
console.log(JSON.stringify(counts))
process.stdout.write(JSON.stringify(counts) + '\n')
export { extractTestCounts }
export { extractTestCounts, extractFailedTests }

View File

@@ -134,23 +134,22 @@ post_comment() {
# Main execution
if [ "$STATUS" = "starting" ]; then
# Post starting comment
# Post concise starting comment
comment=$(cat <<EOF
$COMMENT_MARKER
## 🎭 Playwright Test Results
## 🎭 Playwright Tests: ⏳ Running...
<img alt='loading' src='https://github.com/user-attachments/assets/755c86ee-e445-4ea8-bc2c-cca85df48686' width='14px' height='14px'/> **Tests are starting...**
Tests started at $START_TIME UTC
⏰ Started at: $START_TIME UTC
<details>
<summary>📊 Browser Tests</summary>
### 🚀 Running Tests
- 🧪 **chromium**: Running tests...
- 🧪 **chromium-0.5x**: Running tests...
- 🧪 **chromium-2x**: Running tests...
- 🧪 **mobile-chrome**: Running tests...
- **chromium**: Running...
- **chromium-0.5x**: Running...
- **chromium-2x**: Running...
- **mobile-chrome**: Running...
---
⏱️ Please wait while tests are running...
</details>
EOF
)
post_comment "$comment"
@@ -189,7 +188,8 @@ else
if command -v tsx > /dev/null 2>&1 && [ -f "$EXTRACT_SCRIPT" ]; then
echo "Extracting counts from $REPORT_DIR using $EXTRACT_SCRIPT" >&2
counts=$(tsx "$EXTRACT_SCRIPT" "$REPORT_DIR" 2>&1 || echo '{}')
# Pass the base URL so we can generate trace links
counts=$(tsx "$EXTRACT_SCRIPT" "$REPORT_DIR" "$url" 2>&1 || echo '{}')
echo "Extracted counts for $browser: $counts" >&2
echo "$counts" > "$temp_dir/$i.counts"
else
@@ -286,43 +286,74 @@ else
# Determine overall status
if [ $total_failed -gt 0 ]; then
status_icon="❌"
status_text="Some tests failed"
status_text="Failed"
elif [ $total_flaky -gt 0 ]; then
status_icon="⚠️"
status_text="Tests passed with flaky tests"
status_text="Passed with flaky tests"
elif [ $total_tests -gt 0 ]; then
status_icon="✅"
status_text="All tests passed!"
status_text="Passed"
else
status_icon="🕵🏻"
status_text="No test results found"
status_text="No test results"
fi
# Generate completion comment
# Generate concise completion comment
comment="$COMMENT_MARKER
## 🎭 Playwright Test Results
$status_icon **$status_text**
⏰ Completed at: $(date -u '+%m/%d/%Y, %I:%M:%S %p') UTC"
## 🎭 Playwright Tests: $status_icon **$status_text**"
# Add summary counts if we have test data
if [ $total_tests -gt 0 ]; then
comment="$comment
### 📈 Summary
- **Total Tests:** $total_tests
- **Passed:** $total_passed
- **Failed:** $total_failed $([ $total_failed -gt 0 ] && echo '❌' || echo '')
- **Flaky:** $total_flaky $([ $total_flaky -gt 0 ] && echo '⚠️' || echo '')
- **Skipped:** $total_skipped $([ $total_skipped -gt 0 ] && echo '⏭️' || echo '')"
**Results:** $total_passed passed, $total_failed failed, $total_flaky flaky, $total_skipped skipped (Total: $total_tests)"
fi
# Extract and display failed tests from all browsers
if [ $total_failed -gt 0 ] || [ $total_flaky -gt 0 ]; then
comment="$comment
### ❌ Failed Tests"
# Process each browser's failures
for counts_json in "${counts_array[@]}"; do
[ -z "$counts_json" ] || [ "$counts_json" = "{}" ] && continue
if command -v jq > /dev/null 2>&1; then
# Extract failures array from JSON
failures=$(echo "$counts_json" | jq -r '.failures // [] | .[]? | "\(.name)|\(.file)|\(.traceUrl // "")"')
if [ -n "$failures" ]; then
while IFS='|' read -r test_name test_file trace_url; do
[ -z "$test_name" ] && continue
# Convert file path to GitHub URL (relative to repo root)
github_file_url="https://github.com/$GITHUB_REPOSITORY/blob/$GITHUB_SHA/$test_file"
# Build the failed test line
test_line="- [$test_name]($github_file_url)"
if [ -n "$trace_url" ] && [ "$trace_url" != "null" ]; then
test_line="$test_line: [View trace]($trace_url)"
fi
comment="$comment
$test_line"
done <<< "$failures"
fi
fi
done
fi
# Add browser reports in collapsible section
comment="$comment
### 📊 Test Reports by Browser"
<details>
<summary>📊 Browser Reports</summary>
"
# Add browser results with individual counts
# Add browser results
i=0
IFS=' ' read -r -a browser_array <<< "$BROWSERS"
IFS=' ' read -r -a url_array <<< "$urls"
@@ -349,7 +380,7 @@ $status_icon **$status_text**
fi
if [ -n "$b_total" ] && [ "$b_total" != "0" ]; then
counts_str=" $b_passed / ❌ $b_failed / ⚠️ $b_flaky / ⏭️ $b_skipped"
counts_str=" ($b_passed / ❌ $b_failed / ⚠️ $b_flaky / ⏭️ $b_skipped)"
else
counts_str=""
fi
@@ -358,10 +389,10 @@ $status_icon **$status_text**
fi
comment="$comment
- **${browser}**: [View Report](${url})${counts_str}"
- **${browser}**: [View Report](${url})${counts_str}"
else
comment="$comment
- **${browser}**: Deployment failed"
- **${browser}**: Deployment failed"
fi
i=$((i + 1))
done
@@ -369,8 +400,7 @@ $status_icon **$status_text**
comment="$comment
---
🎉 Click on the links above to view detailed test results for each browser configuration."
</details>"
post_comment "$comment"
fi

View File

@@ -20,9 +20,14 @@
variant="secondary"
size="icon"
:aria-label="t('menu.customNodesManager')"
class="relative"
@click="openCustomNodeManager"
>
<i class="icon-[lucide--puzzle] size-4" />
<span
v-if="shouldShowRedDot"
class="absolute top-0.5 right-1 size-2 rounded-full bg-red-500"
/>
</Button>
</div>
@@ -49,13 +54,16 @@
<i class="icon-[lucide--history] size-4" />
<span
v-if="queuedCount > 0"
class="absolute -top-1 -right-1 min-w-[16px] rounded-full bg-primary-background py-0.25 text-[10px] font-medium leading-[14px] text-white"
class="absolute -top-1 -right-1 min-w-[16px] rounded-full bg-primary-background py-0.25 text-[10px] font-medium leading-[14px] text-base-foreground"
>
{{ queuedCount }}
</span>
</Button>
<CurrentUserButton v-if="isLoggedIn" class="shrink-0" />
<LoginButton v-else-if="isDesktop" />
<CurrentUserButton
v-if="isLoggedIn && !isIntegratedTabBar"
class="shrink-0"
/>
<LoginButton v-else-if="isDesktop && !isIntegratedTabBar" />
<Button
v-if="!isRightSidePanelOpen"
v-tooltip.bottom="rightSidePanelTooltipConfig"
@@ -91,15 +99,19 @@ import Button from '@/components/ui/button/Button.vue'
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
import { useErrorHandling } from '@/composables/useErrorHandling'
import { buildTooltipConfig } from '@/composables/useTooltipConfig'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useReleaseStore } from '@/platform/updates/common/releaseStore'
import { app } from '@/scripts/app'
import { useCommandStore } from '@/stores/commandStore'
import { useQueueStore, useQueueUIStore } from '@/stores/queueStore'
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
import { useWorkspaceStore } from '@/stores/workspaceStore'
import { isElectron } from '@/utils/envUtil'
import { useConflictAcknowledgment } from '@/workbench/extensions/manager/composables/useConflictAcknowledgment'
import { useManagerState } from '@/workbench/extensions/manager/composables/useManagerState'
import { ManagerTab } from '@/workbench/extensions/manager/types/comfyManagerTypes'
const settingStore = useSettingStore()
const workspaceStore = useWorkspaceStore()
const rightSidePanelStore = useRightSidePanelStore()
const managerState = useManagerState()
@@ -111,8 +123,15 @@ const commandStore = useCommandStore()
const queueStore = useQueueStore()
const queueUIStore = useQueueUIStore()
const { isOverlayExpanded: isQueueOverlayExpanded } = storeToRefs(queueUIStore)
const releaseStore = useReleaseStore()
const { shouldShowRedDot: showReleaseRedDot } = storeToRefs(releaseStore)
const { shouldShowRedDot: shouldShowConflictRedDot } =
useConflictAcknowledgment()
const isTopMenuHovered = ref(false)
const queuedCount = computed(() => queueStore.pendingTasks.length)
const isIntegratedTabBar = computed(
() => settingStore.get('Comfy.UI.TabBarLayout') === 'Integrated'
)
const queueHistoryTooltipConfig = computed(() =>
buildTooltipConfig(t('sideToolbar.queueProgressOverlay.viewJobHistory'))
)
@@ -120,6 +139,12 @@ const customNodesManagerTooltipConfig = computed(() =>
buildTooltipConfig(t('menu.customNodesManager'))
)
// Use either release red dot or conflict red dot
const shouldShowRedDot = computed((): boolean => {
const releaseRedDot = showReleaseRedDot.value
return releaseRedDot || shouldShowConflictRedDot.value
})
// Right side panel toggle
const { isOpen: isRightSidePanelOpen } = storeToRefs(rightSidePanelStore)
const rightSidePanelTooltipConfig = computed(() =>

View File

@@ -2,8 +2,8 @@
<div
:class="
cn(
'flex justify-center items-center shrink-0 outline-hidden border-none p-0 rounded-lg shadow-sm transition-all duration-200 cursor-pointer',
backgroundClass || 'bg-secondary-background'
'flex justify-center items-center shrink-0 outline-hidden border-none p-0 rounded-lg shadow-sm transition-all duration-200 cursor-pointer bg-secondary-background',
backgroundClass
)
"
>

View File

@@ -0,0 +1,30 @@
<script setup lang="ts">
type Severity = 'default' | 'secondary' | 'warn' | 'danger' | 'contrast'
const { label, severity = 'default' } = defineProps<{
label: string
severity?: Severity
}>()
function badgeClasses(sev: Severity): string {
const baseClasses =
'inline-flex h-3.5 items-center justify-center rounded-full px-1 text-xxxs font-semibold uppercase'
switch (sev) {
case 'danger':
return `${baseClasses} bg-destructive-background text-white`
case 'contrast':
return `${baseClasses} bg-base-foreground text-base-background`
case 'warn':
return `${baseClasses} bg-warning-background text-base-background`
case 'secondary':
return `${baseClasses} bg-secondary-background text-base-foreground`
default:
return `${baseClasses} bg-primary-background text-base-foreground`
}
}
</script>
<template>
<span :class="badgeClasses(severity)">{{ label }}</span>
</template>

View File

@@ -563,7 +563,8 @@ const {
availableRunsOn,
filteredCount,
totalCount,
resetFilters
resetFilters,
loadFuseOptions
} = useTemplateFiltering(navigationFilteredTemplates)
/**
@@ -815,10 +816,10 @@ const pageTitle = computed(() => {
// Initialize templates loading with useAsyncState
const { isLoading } = useAsyncState(
async () => {
// Run all operations in parallel for better performance
await Promise.all([
loadTemplates(),
workflowTemplatesStore.loadWorkflowTemplates()
workflowTemplatesStore.loadWorkflowTemplates(),
loadFuseOptions()
])
return true
},

View File

@@ -1,7 +1,7 @@
<template>
<div class="flex w-full items-center justify-between p-4">
<div class="flex items-center gap-2">
<i class="icon-[lucide--triangle-alert] text-gold-600"></i>
<i class="icon-[lucide--triangle-alert] text-warning-background"></i>
<p class="m-0 text-sm">
{{
isCloud

View File

@@ -1,180 +1,260 @@
<template>
<div class="flex w-112 flex-col gap-8 p-8">
<div
class="flex min-w-[460px] flex-col rounded-2xl border border-border-default bg-base-background shadow-[1px_1px_8px_0_rgba(0,0,0,0.4)]"
>
<!-- Header -->
<div class="flex flex-col gap-4">
<h1 class="text-2xl font-semibold text-base-foreground m-0">
<div class="flex py-8 items-center justify-between px-8">
<h2 class="text-lg font-bold text-base-foreground m-0">
{{
isInsufficientCredits
? $t('credits.topUp.addMoreCreditsToRun')
: $t('credits.topUp.addMoreCredits')
}}
</h1>
<div v-if="isInsufficientCredits" class="flex flex-col gap-2">
<p class="text-sm text-muted-foreground m-0 w-96">
{{ $t('credits.topUp.insufficientWorkflowMessage') }}
</p>
</div>
<div v-else class="flex flex-col gap-2">
<p class="text-sm text-muted-foreground m-0">
{{ $t('credits.topUp.creditsDescription') }}
</p>
</div>
</h2>
<button
class="cursor-pointer rounded border-none bg-transparent p-0 text-muted-foreground transition-colors hover:text-base-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-secondary-foreground"
@click="() => handleClose()"
>
<i class="icon-[lucide--x] size-6" />
</button>
</div>
<p
v-if="isInsufficientCredits"
class="text-sm text-muted-foreground m-0 px-8"
>
{{ $t('credits.topUp.insufficientWorkflowMessage') }}
</p>
<!-- Current Balance Section -->
<div class="flex flex-col gap-4">
<div class="flex items-baseline gap-2">
<UserCredit text-class="text-3xl font-bold" show-credits-only />
<span class="text-sm text-muted-foreground">{{
$t('credits.creditsAvailable')
}}</span>
</div>
<div v-if="formattedRenewalDate" class="text-sm text-muted-foreground">
{{ $t('credits.refreshes', { date: formattedRenewalDate }) }}
</div>
</div>
<!-- Credit Options Section -->
<div class="flex flex-col gap-4">
<span class="text-sm text-muted-foreground">
{{ $t('credits.topUp.howManyCredits') }}
</span>
<div class="flex flex-col gap-2">
<CreditTopUpOption
v-for="option in creditOptions"
:key="option.credits"
:credits="option.credits"
:description="option.description"
:selected="selectedCredits === option.credits"
@select="selectedCredits = option.credits"
/>
</div>
<div class="flex flex-row items-center gap-2 group pt-2">
<i
class="pi pi-question-circle text-xs text-muted-foreground group-hover:text-base-foreground"
/>
<span
class="text-sm font-normal text-muted-foreground cursor-pointer group-hover:text-base-foreground"
@click="togglePopover"
<!-- Preset amount buttons -->
<div class="px-8">
<h3 class="m-0 text-sm font-normal text-muted-foreground">
{{ $t('credits.topUp.selectAmount') }}
</h3>
<div class="flex gap-2 pt-3">
<Button
v-for="amount in PRESET_AMOUNTS"
:key="amount"
:autofocus="amount === 50"
variant="secondary"
size="lg"
:class="
cn(
'h-10 text-base font-medium w-full focus-visible:ring-secondary-foreground',
selectedPreset === amount && 'bg-secondary-background-selected'
)
"
@click="handlePresetClick(amount)"
>
{{ t('subscription.videoTemplateBasedCredits') }}
</span>
${{ amount }}
</Button>
</div>
</div>
<!-- Amount (USD) / Credits -->
<div class="flex gap-2 px-8 pt-8">
<!-- You Pay -->
<div class="flex flex-1 flex-col gap-3">
<div class="text-sm text-muted-foreground">
{{ $t('credits.topUp.youPay') }}
</div>
<FormattedNumberStepper
:model-value="payAmount"
:min="0"
:max="MAX_AMOUNT"
:step="getStepAmount"
@update:model-value="handlePayAmountChange"
@max-reached="showCeilingWarning = true"
>
<template #prefix>
<span class="shrink-0 text-base font-semibold text-base-foreground"
>$</span
>
</template>
</FormattedNumberStepper>
</div>
<!-- Buy Button -->
<!-- You Get -->
<div class="flex flex-1 flex-col gap-3">
<div class="text-sm text-muted-foreground">
{{ $t('credits.topUp.youGet') }}
</div>
<FormattedNumberStepper
v-model="creditsModel"
:min="0"
:max="usdToCredits(MAX_AMOUNT)"
:step="getCreditsStepAmount"
@max-reached="showCeilingWarning = true"
>
<template #prefix>
<i class="icon-[lucide--component] size-4 shrink-0 text-gold-500" />
</template>
</FormattedNumberStepper>
</div>
</div>
<!-- Warnings -->
<p
v-if="isBelowMin"
class="text-sm text-red-500 m-0 px-8 pt-4 text-center flex items-center justify-center gap-1"
>
<i class="icon-[lucide--component] size-4" />
{{
$t('credits.topUp.minRequired', {
credits: formatNumber(usdToCredits(MIN_AMOUNT))
})
}}
</p>
<p
v-if="showCeilingWarning"
class="text-sm text-gold-500 m-0 px-8 pt-4 text-center flex items-center justify-center gap-1"
>
<i class="icon-[lucide--component] size-4" />
{{
$t('credits.topUp.maxAllowed', {
credits: formatNumber(usdToCredits(MAX_AMOUNT))
})
}}
<span>{{ $t('credits.topUp.needMore') }}</span>
<a
href="https://www.comfy.org/cloud/enterprise"
target="_blank"
class="ml-1 text-inherit"
>{{ $t('credits.topUp.contactUs') }}</a
>
</p>
<div class="pt-8 pb-8 flex flex-col gap-8 px-8">
<Button
:disabled="!selectedCredits || loading"
:disabled="!isValidAmount || loading"
:loading="loading"
variant="primary"
:class="cn('w-full', (!selectedCredits || loading) && 'opacity-30')"
size="lg"
class="h-10 justify-center"
@click="handleBuy"
>
{{ $t('credits.topUp.buy') }}
{{ $t('credits.topUp.buyCredits') }}
</Button>
</div>
<Popover
ref="popover"
append-to="body"
:auto-z-index="true"
:base-z-index="1000"
:dismissable="true"
:close-on-escape="true"
unstyled
:pt="{
root: {
class:
'rounded-lg border border-interface-stroke bg-interface-panel-surface shadow-lg p-4 max-w-xs'
}
}"
>
<div class="flex flex-col gap-2">
<p class="text-sm text-base-foreground leading-normal">
{{ t('subscription.videoEstimateExplanation') }}
</p>
<div class="flex items-center justify-center gap-1">
<a
href="https://cloud.comfy.org/?template=video_wan2_2_14B_fun_camera"
:href="pricingUrl"
target="_blank"
rel="noopener noreferrer"
class="text-sm text-azure-600 hover:text-azure-400 no-underline flex gap-1"
class="flex items-center gap-1 text-sm text-muted-foreground no-underline transition-colors hover:text-base-foreground"
>
<span class="underline">
{{ t('subscription.videoEstimateTryTemplate') }}
</span>
<span class="no-underline" v-html="'&rarr;'"></span>
{{ $t('credits.topUp.viewPricing') }}
<i class="icon-[lucide--external-link] size-4" />
</a>
</div>
</Popover>
</div>
</div>
</template>
<script setup lang="ts">
import { Popover } from 'primevue'
import { useToast } from 'primevue/usetoast'
import { ref } from 'vue'
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { creditsToUsd } from '@/base/credits/comfyCredits'
import UserCredit from '@/components/common/UserCredit.vue'
import { creditsToUsd, usdToCredits } from '@/base/credits/comfyCredits'
import Button from '@/components/ui/button/Button.vue'
import FormattedNumberStepper from '@/components/ui/stepper/FormattedNumberStepper.vue'
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
import { useExternalLink } from '@/composables/useExternalLink'
import { useTelemetry } from '@/platform/telemetry'
import { clearTopupTracking } from '@/platform/telemetry/topupTracker'
import { useDialogService } from '@/services/dialogService'
import { useDialogStore } from '@/stores/dialogStore'
import { cn } from '@/utils/tailwindUtil'
import CreditTopUpOption from './credit/CreditTopUpOption.vue'
interface CreditOption {
credits: number
description: string
}
const { isInsufficientCredits = false } = defineProps<{
isInsufficientCredits?: boolean
}>()
const { formattedRenewalDate } = useSubscription()
const { t } = useI18n()
const authActions = useFirebaseAuthActions()
const dialogStore = useDialogStore()
const dialogService = useDialogService()
const telemetry = useTelemetry()
const toast = useToast()
const { buildDocsUrl, docsPaths } = useExternalLink()
const selectedCredits = ref<number | null>(null)
// Constants
const PRESET_AMOUNTS = [10, 25, 50, 100]
const MIN_AMOUNT = 5
const MAX_AMOUNT = 10000
// State
const selectedPreset = ref<number | null>(50)
const payAmount = ref(50)
const showCeilingWarning = ref(false)
const loading = ref(false)
const popover = ref()
// Computed
const pricingUrl = computed(() =>
buildDocsUrl(docsPaths.partnerNodesPricing, { includeLocale: true })
)
const togglePopover = (event: Event) => {
popover.value.toggle(event)
const creditsModel = computed({
get: () => usdToCredits(payAmount.value),
set: (newCredits: number) => {
payAmount.value = Math.round(creditsToUsd(newCredits))
selectedPreset.value = null
}
})
const isValidAmount = computed(
() => payAmount.value >= MIN_AMOUNT && payAmount.value <= MAX_AMOUNT
)
const isBelowMin = computed(() => payAmount.value < MIN_AMOUNT)
// Utility functions
function formatNumber(num: number): string {
return num.toLocaleString('en-US')
}
const creditOptions: CreditOption[] = [
{
credits: 1055, // $5.00
description: t('credits.topUp.videosEstimate', { count: 30 })
},
{
credits: 2110, // $10.00
description: t('credits.topUp.videosEstimate', { count: 60 })
},
{
credits: 4220, // $20.00
description: t('credits.topUp.videosEstimate', { count: 120 })
},
{
credits: 10550, // $50.00
description: t('credits.topUp.videosEstimate', { count: 301 })
}
]
// Step amount functions
function getStepAmount(currentAmount: number): number {
if (currentAmount < 100) return 5
if (currentAmount < 1000) return 50
return 100
}
const handleBuy = async () => {
if (!selectedCredits.value) return
function getCreditsStepAmount(currentCredits: number): number {
const usdAmount = creditsToUsd(currentCredits)
return usdToCredits(getStepAmount(usdAmount))
}
// Event handlers
function handlePayAmountChange(value: number) {
payAmount.value = value
selectedPreset.value = null
showCeilingWarning.value = false
}
function handlePresetClick(amount: number) {
showCeilingWarning.value = false
payAmount.value = amount
selectedPreset.value = amount
}
function handleClose(clearTracking = true) {
if (clearTracking) {
clearTopupTracking()
}
dialogStore.closeDialog({ key: 'top-up-credits' })
}
async function handleBuy() {
// Prevent double-clicks
if (loading.value || !isValidAmount.value) return
loading.value = true
try {
const usdAmount = creditsToUsd(selectedCredits.value)
telemetry?.trackApiCreditTopupButtonPurchaseClicked(usdAmount)
await authActions.purchaseCredits(usdAmount)
telemetry?.trackApiCreditTopupButtonPurchaseClicked(payAmount.value)
await authActions.purchaseCredits(payAmount.value)
// Close top-up dialog (keep tracking) and open subscription panel to show updated credits
handleClose(false)
dialogService.showSettingsDialog('subscription')
} catch (error) {
console.error('Purchase failed:', error)

View File

@@ -220,6 +220,12 @@ function show(event: MouseEvent) {
y: screenY / scale - offset[1]
}
// Initialize last* values to current transform to prevent updateMenuPosition
// from overwriting PrimeVue's flip-adjusted position on the first RAF tick
lastScale = scale
lastOffsetX = offset[0]
lastOffsetY = offset[1]
isOpen.value = true
contextMenu.value?.show(event)
}

View File

@@ -0,0 +1,134 @@
<template>
<!-- Help Center Popup positioned within canvas area -->
<Teleport to="#graph-canvas-container">
<div
v-if="isHelpCenterVisible"
class="help-center-popup"
:class="{
'sidebar-left':
triggerLocation === 'sidebar' && sidebarLocation === 'left',
'sidebar-right':
triggerLocation === 'sidebar' && sidebarLocation === 'right',
'topbar-right': triggerLocation === 'topbar',
'small-sidebar': isSmall
}"
>
<HelpCenterMenuContent @close="closeHelpCenter" />
</div>
</Teleport>
<!-- Release Notification Toast positioned within canvas area -->
<Teleport to="#graph-canvas-container">
<ReleaseNotificationToast
:class="{
'sidebar-left': sidebarLocation === 'left',
'sidebar-right': sidebarLocation === 'right',
'small-sidebar': isSmall
}"
/>
</Teleport>
<!-- WhatsNew Popup positioned within canvas area -->
<Teleport to="#graph-canvas-container">
<WhatsNewPopup
:class="{
'sidebar-left': sidebarLocation === 'left',
'sidebar-right': sidebarLocation === 'right',
'small-sidebar': isSmall
}"
@whats-new-dismissed="handleWhatsNewDismissed"
/>
</Teleport>
<!-- Backdrop to close popup when clicking outside -->
<Teleport to="body">
<div
v-if="isHelpCenterVisible"
class="help-center-backdrop"
@click="closeHelpCenter"
/>
</Teleport>
</template>
<script setup lang="ts">
import { useHelpCenter } from '@/composables/useHelpCenter'
import ReleaseNotificationToast from '@/platform/updates/components/ReleaseNotificationToast.vue'
import WhatsNewPopup from '@/platform/updates/components/WhatsNewPopup.vue'
import HelpCenterMenuContent from './HelpCenterMenuContent.vue'
const { isSmall = false } = defineProps<{
isSmall?: boolean
}>()
const {
isHelpCenterVisible,
triggerLocation,
sidebarLocation,
closeHelpCenter,
handleWhatsNewDismissed
} = useHelpCenter()
</script>
<style scoped>
.help-center-backdrop {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 9999;
background: transparent;
}
.help-center-popup {
position: absolute;
bottom: 1rem;
z-index: 10000;
animation: slideInUp 0.2s ease-out;
pointer-events: auto;
}
.help-center-popup.sidebar-left {
left: 1rem;
}
.help-center-popup.sidebar-left.small-sidebar {
left: 1rem;
}
.help-center-popup.sidebar-right {
right: 1rem;
}
.help-center-popup.topbar-right {
top: 2rem;
right: 1rem;
bottom: auto;
animation: slideInDown 0.2s ease-out;
}
@keyframes slideInDown {
from {
opacity: 0;
transform: translateY(-20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes slideInUp {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
</style>

View File

@@ -0,0 +1,293 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import { ref } from 'vue'
import ProgressToastItem from '@/components/toast/ProgressToastItem.vue'
import Button from '@/components/ui/button/Button.vue'
import type { AssetDownload } from '@/stores/assetDownloadStore'
import { cn } from '@/utils/tailwindUtil'
import HoneyToast from './HoneyToast.vue'
function createMockJob(overrides: Partial<AssetDownload> = {}): AssetDownload {
return {
taskId: 'task-1',
assetId: 'asset-1',
assetName: 'model-v1.safetensors',
bytesTotal: 1000000,
bytesDownloaded: 0,
progress: 0,
status: 'created',
lastUpdate: Date.now(),
...overrides
}
}
const meta: Meta<typeof HoneyToast> = {
title: 'Toast/HoneyToast',
component: HoneyToast,
parameters: {
layout: 'fullscreen'
},
decorators: [
() => ({
template: '<div class="h-screen bg-base-background p-8"><story /></div>'
})
]
}
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {
render: () => ({
components: { HoneyToast, Button, ProgressToastItem },
setup() {
const isExpanded = ref(false)
const jobs = [
createMockJob({
taskId: 'task-1',
assetName: 'model-v1.safetensors',
status: 'completed',
progress: 1
}),
createMockJob({
taskId: 'task-2',
assetName: 'lora-style.safetensors',
status: 'running',
progress: 0.45
}),
createMockJob({
taskId: 'task-3',
assetName: 'vae-decoder.safetensors',
status: 'created'
})
]
return { isExpanded, cn, jobs }
},
template: `
<HoneyToast v-model:expanded="isExpanded" :visible="true">
<template #default>
<div class="flex h-12 items-center justify-between border-b border-border-default px-4">
<h3 class="text-sm font-bold text-base-foreground">Download Queue</h3>
</div>
<div class="relative max-h-[300px] overflow-y-auto px-4 py-4">
<div class="flex flex-col gap-2">
<ProgressToastItem v-for="job in jobs" :key="job.taskId" :job="job" />
</div>
</div>
</template>
<template #footer="{ toggle }">
<div class="flex h-12 items-center justify-between border-t border-border-default px-4">
<div class="flex items-center gap-2 text-sm">
<i class="icon-[lucide--loader-circle] size-4 animate-spin text-muted-foreground" />
<span class="font-bold text-base-foreground">lora-style.safetensors</span>
</div>
<div class="flex items-center gap-2">
<span class="text-sm text-muted-foreground">1 of 3</span>
<div class="flex items-center">
<Button variant="muted-textonly" size="icon" @click.stop="toggle">
<i :class="cn('size-4', isExpanded ? 'icon-[lucide--chevron-down]' : 'icon-[lucide--chevron-up]')" />
</Button>
</div>
</div>
</div>
</template>
</HoneyToast>
`
})
}
export const Expanded: Story = {
render: () => ({
components: { HoneyToast, Button, ProgressToastItem },
setup() {
const isExpanded = ref(true)
const jobs = [
createMockJob({
taskId: 'task-1',
assetName: 'model-v1.safetensors',
status: 'completed',
progress: 1
}),
createMockJob({
taskId: 'task-2',
assetName: 'lora-style.safetensors',
status: 'running',
progress: 0.45
}),
createMockJob({
taskId: 'task-3',
assetName: 'vae-decoder.safetensors',
status: 'created'
})
]
return { isExpanded, cn, jobs }
},
template: `
<HoneyToast v-model:expanded="isExpanded" :visible="true">
<template #default>
<div class="flex h-12 items-center justify-between border-b border-border-default px-4">
<h3 class="text-sm font-bold text-base-foreground">Download Queue</h3>
</div>
<div class="relative max-h-[300px] overflow-y-auto px-4 py-4">
<div class="flex flex-col gap-2">
<ProgressToastItem v-for="job in jobs" :key="job.taskId" :job="job" />
</div>
</div>
</template>
<template #footer="{ toggle }">
<div class="flex h-12 items-center justify-between border-t border-border-default px-4">
<div class="flex items-center gap-2 text-sm">
<i class="icon-[lucide--loader-circle] size-4 animate-spin text-muted-foreground" />
<span class="font-bold text-base-foreground">lora-style.safetensors</span>
</div>
<div class="flex items-center gap-2">
<span class="text-sm text-muted-foreground">1 of 3</span>
<div class="flex items-center">
<Button variant="muted-textonly" size="icon" @click.stop="toggle">
<i :class="cn('size-4', isExpanded ? 'icon-[lucide--chevron-down]' : 'icon-[lucide--chevron-up]')" />
</Button>
</div>
</div>
</div>
</template>
</HoneyToast>
`
})
}
export const Completed: Story = {
render: () => ({
components: { HoneyToast, Button, ProgressToastItem },
setup() {
const isExpanded = ref(false)
const jobs = [
createMockJob({
taskId: 'task-1',
assetName: 'model-v1.safetensors',
bytesDownloaded: 1000000,
progress: 1,
status: 'completed'
}),
createMockJob({
taskId: 'task-2',
assetId: 'asset-2',
assetName: 'lora-style.safetensors',
bytesTotal: 500000,
bytesDownloaded: 500000,
progress: 1,
status: 'completed'
})
]
return { isExpanded, cn, jobs }
},
template: `
<HoneyToast v-model:expanded="isExpanded" :visible="true">
<template #default>
<div class="flex h-12 items-center justify-between border-b border-border-default px-4">
<h3 class="text-sm font-bold text-base-foreground">Download Queue</h3>
</div>
<div class="relative max-h-[300px] overflow-y-auto px-4 py-4">
<div class="flex flex-col gap-2">
<ProgressToastItem v-for="job in jobs" :key="job.taskId" :job="job" />
</div>
</div>
</template>
<template #footer="{ toggle }">
<div class="flex h-12 items-center justify-between border-t border-border-default px-4">
<div class="flex items-center gap-2 text-sm">
<i class="icon-[lucide--check-circle] size-4 text-jade-600" />
<span class="font-bold text-base-foreground">All downloads completed</span>
</div>
<div class="flex items-center">
<Button variant="muted-textonly" size="icon" @click.stop="toggle">
<i :class="cn('size-4', isExpanded ? 'icon-[lucide--chevron-down]' : 'icon-[lucide--chevron-up]')" />
</Button>
<Button variant="muted-textonly" size="icon">
<i class="icon-[lucide--x] size-4" />
</Button>
</div>
</div>
</template>
</HoneyToast>
`
})
}
export const WithError: Story = {
render: () => ({
components: { HoneyToast, Button, ProgressToastItem },
setup() {
const isExpanded = ref(true)
const jobs = [
createMockJob({
taskId: 'task-1',
assetName: 'model-v1.safetensors',
status: 'failed',
progress: 0.23
}),
createMockJob({
taskId: 'task-2',
assetName: 'lora-style.safetensors',
status: 'completed',
progress: 1
})
]
return { isExpanded, cn, jobs }
},
template: `
<HoneyToast v-model:expanded="isExpanded" :visible="true">
<template #default>
<div class="flex h-12 items-center justify-between border-b border-border-default px-4">
<h3 class="text-sm font-bold text-base-foreground">Download Queue</h3>
</div>
<div class="relative max-h-[300px] overflow-y-auto px-4 py-4">
<div class="flex flex-col gap-2">
<ProgressToastItem v-for="job in jobs" :key="job.taskId" :job="job" />
</div>
</div>
</template>
<template #footer="{ toggle }">
<div class="flex h-12 items-center justify-between border-t border-border-default px-4">
<div class="flex items-center gap-2 text-sm">
<i class="icon-[lucide--circle-alert] size-4 text-destructive-background" />
<span class="font-bold text-base-foreground">1 download failed</span>
</div>
<div class="flex items-center">
<Button variant="muted-textonly" size="icon" @click.stop="toggle">
<i :class="cn('size-4', isExpanded ? 'icon-[lucide--chevron-down]' : 'icon-[lucide--chevron-up]')" />
</Button>
<Button variant="muted-textonly" size="icon">
<i class="icon-[lucide--x] size-4" />
</Button>
</div>
</div>
</template>
</HoneyToast>
`
})
}
export const Hidden: Story = {
render: () => ({
components: { HoneyToast },
template: `
<div>
<p class="text-base-foreground">HoneyToast is hidden when visible=false. Nothing appears at the bottom.</p>
<HoneyToast :visible="false">
<template #default>
<div class="px-4 py-4">Content</div>
</template>
<template #footer>
<div class="h-12 px-4">Footer</div>
</template>
</HoneyToast>
</div>
`
})
}

View File

@@ -0,0 +1,137 @@
import type { VueWrapper } from '@vue/test-utils'
import { mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { defineComponent, h, nextTick, ref } from 'vue'
import HoneyToast from './HoneyToast.vue'
describe('HoneyToast', () => {
beforeEach(() => {
vi.clearAllMocks()
document.body.innerHTML = ''
})
function mountComponent(
props: { visible: boolean; expanded?: boolean } = { visible: true }
): VueWrapper {
return mount(HoneyToast, {
props,
slots: {
default: (slotProps: { isExpanded: boolean }) =>
h(
'div',
{ 'data-testid': 'content' },
slotProps.isExpanded ? 'expanded' : 'collapsed'
),
footer: (slotProps: { isExpanded: boolean; toggle: () => void }) =>
h(
'button',
{
'data-testid': 'toggle-btn',
onClick: slotProps.toggle
},
slotProps.isExpanded ? 'Collapse' : 'Expand'
)
},
attachTo: document.body
})
}
it('renders when visible is true', async () => {
const wrapper = mountComponent({ visible: true })
await nextTick()
const toast = document.body.querySelector('[role="status"]')
expect(toast).toBeTruthy()
wrapper.unmount()
})
it('does not render when visible is false', async () => {
const wrapper = mountComponent({ visible: false })
await nextTick()
const toast = document.body.querySelector('[role="status"]')
expect(toast).toBeFalsy()
wrapper.unmount()
})
it('passes is-expanded=false to slots by default', async () => {
const wrapper = mountComponent({ visible: true })
await nextTick()
const content = document.body.querySelector('[data-testid="content"]')
expect(content?.textContent).toBe('collapsed')
wrapper.unmount()
})
it('applies collapsed max-height class when collapsed', async () => {
const wrapper = mountComponent({ visible: true, expanded: false })
await nextTick()
const expandableArea = document.body.querySelector(
'[role="status"] > div:first-child'
)
expect(expandableArea?.classList.contains('max-h-0')).toBe(true)
wrapper.unmount()
})
it('has aria-live="polite" for accessibility', async () => {
const wrapper = mountComponent({ visible: true })
await nextTick()
const toast = document.body.querySelector('[role="status"]')
expect(toast?.getAttribute('aria-live')).toBe('polite')
wrapper.unmount()
})
it('supports v-model:expanded with reactive parent state', async () => {
const TestWrapper = defineComponent({
components: { HoneyToast },
setup() {
const expanded = ref(false)
return { expanded }
},
template: `
<HoneyToast :visible="true" v-model:expanded="expanded">
<template #default="slotProps">
<div data-testid="content">{{ slotProps.isExpanded ? 'expanded' : 'collapsed' }}</div>
</template>
<template #footer="slotProps">
<button data-testid="toggle-btn" @click="slotProps.toggle">
{{ slotProps.isExpanded ? 'Collapse' : 'Expand' }}
</button>
</template>
</HoneyToast>
`
})
const wrapper = mount(TestWrapper, { attachTo: document.body })
await nextTick()
const content = document.body.querySelector('[data-testid="content"]')
expect(content?.textContent).toBe('collapsed')
const toggleBtn = document.body.querySelector(
'[data-testid="toggle-btn"]'
) as HTMLButtonElement
expect(toggleBtn?.textContent?.trim()).toBe('Expand')
toggleBtn?.click()
await nextTick()
expect(content?.textContent).toBe('expanded')
expect(toggleBtn?.textContent?.trim()).toBe('Collapse')
const expandableArea = document.body.querySelector(
'[role="status"] > div:first-child'
)
expect(expandableArea?.classList.contains('max-h-[400px]')).toBe(true)
wrapper.unmount()
})
})

View File

@@ -0,0 +1,46 @@
<script setup lang="ts">
import { cn } from '@/utils/tailwindUtil'
const { visible } = defineProps<{
visible: boolean
}>()
const isExpanded = defineModel<boolean>('expanded', { default: false })
function toggle() {
isExpanded.value = !isExpanded.value
}
</script>
<template>
<Teleport to="body">
<Transition
enter-active-class="transition-all duration-300 ease-out"
enter-from-class="translate-y-full opacity-0"
enter-to-class="translate-y-0 opacity-100"
leave-active-class="transition-all duration-200 ease-in"
leave-from-class="translate-y-0 opacity-100"
leave-to-class="translate-y-full opacity-0"
>
<div
v-if="visible"
role="status"
aria-live="polite"
class="fixed inset-x-0 bottom-6 z-9999 mx-auto w-4/5 max-w-3xl overflow-hidden rounded-lg border border-border-default bg-base-background shadow-lg"
>
<div
:class="
cn(
'overflow-hidden transition-all duration-300',
isExpanded ? 'max-h-[400px]' : 'max-h-0'
)
"
>
<slot :is-expanded />
</div>
<slot name="footer" :is-expanded :toggle />
</div>
</Transition>
</Teleport>
</template>

View File

@@ -160,7 +160,7 @@
>
<i
v-if="slotProps.selected"
class="text-bold icon-[lucide--check] text-xs text-white"
class="text-bold icon-[lucide--check] text-xs text-base-foreground"
/>
</div>
<span>

View File

@@ -1,6 +1,6 @@
<template>
<div
class="pointer-events-auto absolute top-12 left-2 z-20 flex flex-col rounded-lg bg-smoke-700/30"
class="pointer-events-auto absolute top-12 left-2 z-20 flex flex-col rounded-lg bg-backdrop/30"
@pointerdown.stop
@pointermove.stop
@pointerup.stop
@@ -14,12 +14,12 @@
class="rounded-full"
@click="toggleMenu"
>
<i class="pi pi-bars text-lg text-white" />
<i class="pi pi-bars text-lg text-base-foreground" />
</Button>
<div
v-show="isMenuOpen"
class="absolute top-0 left-12 rounded-lg bg-black/50 shadow-lg"
class="absolute top-0 left-12 rounded-lg bg-interface-menu-surface shadow-lg"
>
<div class="flex flex-col">
<Button
@@ -29,13 +29,13 @@
:class="
cn(
'flex w-full items-center justify-start',
activeCategory === category && 'bg-smoke-600'
activeCategory === category && 'bg-button-active-surface'
)
"
@click="selectCategory(category)"
>
<i :class="getCategoryIcon(category)" />
<span class="whitespace-nowrap text-white">{{
<span class="whitespace-nowrap text-base-foreground">{{
$t(categoryLabels[category])
}}</span>
</Button>
@@ -169,7 +169,7 @@ const getCategoryIcon = (category: string) => {
export: 'pi pi-download'
}
// @ts-expect-error fixme ts strict error
return `${icons[category]} text-white text-lg`
return `${icons[category]} text-base-foreground text-lg`
}
const emit = defineEmits<{

View File

@@ -2,11 +2,11 @@
<Transition name="fade">
<div
v-if="loading"
class="bg-opacity-50 absolute inset-0 z-50 flex items-center justify-center bg-black"
class="absolute inset-0 z-50 flex items-center justify-center bg-backdrop/50"
>
<div class="flex flex-col items-center">
<div class="spinner" />
<div class="mt-4 text-lg text-white">
<div class="mt-4 text-lg text-base-foreground">
{{ loadingMessage }}
</div>
</div>

View File

@@ -15,7 +15,7 @@
:class="[
'pi',
playing ? 'pi-pause' : 'pi-play',
'text-lg text-white'
'text-lg text-base-foreground'
]"
/>
</Button>
@@ -46,7 +46,7 @@
class="flex-1"
@update:model-value="handleSliderChange"
/>
<span class="min-w-16 text-xs text-white">
<span class="min-w-16 text-xs text-base-foreground">
{{ formatTime(currentTime) }} / {{ formatTime(animationDuration) }}
</span>
</div>

View File

@@ -11,7 +11,7 @@
:aria-label="$t('load3d.switchCamera')"
@click="switchCamera"
>
<i :class="['pi', 'pi-camera', 'text-lg text-white']" />
<i :class="['pi', 'pi-camera', 'text-lg text-base-foreground']" />
</Button>
<PopupSlider
v-if="showFOVButton"

View File

@@ -12,18 +12,18 @@
:aria-label="$t('load3d.exportModel')"
@click="toggleExportFormats"
>
<i class="pi pi-download text-lg text-white" />
<i class="pi pi-download text-lg text-base-foreground" />
</Button>
<div
v-show="showExportFormats"
class="absolute top-0 left-12 rounded-lg bg-black/50 shadow-lg"
class="absolute top-0 left-12 rounded-lg bg-interface-menu-surface shadow-lg"
>
<div class="flex flex-col">
<Button
v-for="format in exportFormats"
:key="format.value"
variant="textonly"
class="text-white"
class="text-base-foreground"
@click="exportModel(format.value)"
>
{{ format.label }}

View File

@@ -12,7 +12,7 @@
:aria-label="$t('load3d.lightIntensity')"
@click="toggleLightIntensity"
>
<i class="pi pi-sun text-lg text-white" />
<i class="pi pi-sun text-lg text-base-foreground" />
</Button>
<div
v-show="showLightIntensity"

View File

@@ -12,11 +12,11 @@
:aria-label="t('load3d.upDirection')"
@click="toggleUpDirection"
>
<i class="pi pi-arrow-up text-lg text-white" />
<i class="pi pi-arrow-up text-lg text-base-foreground" />
</Button>
<div
v-show="showUpDirection"
class="absolute top-0 left-12 rounded-lg bg-black/50 shadow-lg"
class="absolute top-0 left-12 rounded-lg bg-interface-menu-surface shadow-lg"
>
<div class="flex flex-col">
<Button
@@ -24,7 +24,10 @@
:key="direction"
variant="textonly"
:class="
cn('text-white', upDirection === direction && 'bg-blue-500')
cn(
'text-base-foreground',
upDirection === direction && 'bg-blue-500'
)
"
@click="selectUpDirection(direction)"
>
@@ -46,11 +49,11 @@
:aria-label="t('load3d.materialMode')"
@click="toggleMaterialMode"
>
<i class="pi pi-box text-lg text-white" />
<i class="pi pi-box text-lg text-base-foreground" />
</Button>
<div
v-show="showMaterialMode"
class="absolute top-0 left-12 rounded-lg bg-black/50 shadow-lg"
class="absolute top-0 left-12 rounded-lg bg-interface-menu-surface shadow-lg"
>
<div class="flex flex-col">
<Button
@@ -59,7 +62,7 @@
variant="textonly"
:class="
cn(
'whitespace-nowrap text-white',
'whitespace-nowrap text-base-foreground',
materialMode === mode && 'bg-blue-500'
)
"
@@ -83,7 +86,7 @@
:aria-label="t('load3d.showSkeleton')"
@click="showSkeleton = !showSkeleton"
>
<i class="pi pi-sitemap text-lg text-white" />
<i class="pi pi-sitemap text-lg text-base-foreground" />
</Button>
</div>
</div>

View File

@@ -8,11 +8,11 @@
:aria-label="tooltipText"
@click="toggleSlider"
>
<i :class="['pi', icon, 'text-lg text-white']" />
<i :class="['pi', icon, 'text-lg text-base-foreground']" />
</Button>
<div
v-show="showSlider"
class="absolute top-0 left-12 rounded-lg bg-black/50 p-4 shadow-lg w-[150px]"
class="absolute top-0 left-12 rounded-lg bg-interface-menu-surface p-4 shadow-lg w-[150px]"
>
<Slider
v-model="value"

View File

@@ -1,5 +1,5 @@
<template>
<div class="relative rounded-lg bg-smoke-700/30">
<div class="relative rounded-lg bg-backdrop/30">
<div class="flex flex-col gap-2">
<Button
v-tooltip.right="{
@@ -25,7 +25,7 @@
:class="[
'pi',
isRecording ? 'pi-circle-fill' : 'pi-video',
'text-lg text-white'
'text-lg text-base-foreground'
]"
/>
</Button>
@@ -42,7 +42,7 @@
:aria-label="$t('load3d.exportRecording')"
@click="handleExportRecording"
>
<i class="pi pi-download text-lg text-white" />
<i class="pi pi-download text-lg text-base-foreground" />
</Button>
<Button
@@ -57,12 +57,12 @@
:aria-label="$t('load3d.clearRecording')"
@click="handleClearRecording"
>
<i class="pi pi-trash text-lg text-white" />
<i class="pi pi-trash text-lg text-base-foreground" />
</Button>
<div
v-if="recordingDuration && recordingDuration > 0 && !isRecording"
class="mt-1 text-center text-xs text-white"
class="mt-1 text-center text-xs text-base-foreground"
>
{{ formatDuration(recordingDuration) }}
</div>

View File

@@ -8,7 +8,7 @@
:aria-label="$t('load3d.showGrid')"
@click="toggleGrid"
>
<i class="pi pi-table text-lg text-white" />
<i class="pi pi-table text-lg text-base-foreground" />
</Button>
<div v-if="!hasBackgroundImage">
@@ -23,7 +23,7 @@
:aria-label="$t('load3d.backgroundColor')"
@click="openColorPicker"
>
<i class="pi pi-palette text-lg text-white" />
<i class="pi pi-palette text-lg text-base-foreground" />
<input
ref="colorPickerRef"
type="color"
@@ -48,7 +48,7 @@
:aria-label="$t('load3d.uploadBackgroundImage')"
@click="openImagePicker"
>
<i class="pi pi-image text-lg text-white" />
<i class="pi pi-image text-lg text-base-foreground" />
<input
ref="imagePickerRef"
type="file"
@@ -76,7 +76,7 @@
:aria-label="$t('load3d.panoramaMode')"
@click="toggleBackgroundRenderMode"
>
<i class="pi pi-globe text-lg text-white" />
<i class="pi pi-globe text-lg text-base-foreground" />
</Button>
</div>
@@ -98,7 +98,7 @@
:aria-label="$t('load3d.removeBackgroundImage')"
@click="removeBackgroundImage"
>
<i class="pi pi-times text-lg text-white" />
<i class="pi pi-times text-lg text-base-foreground" />
</Button>
</div>
</div>

View File

@@ -1,5 +1,5 @@
<template>
<div class="relative rounded-lg bg-smoke-700/30">
<div class="relative rounded-lg bg-backdrop/30">
<div class="flex flex-col gap-2">
<Button
v-tooltip.right="{
@@ -12,7 +12,7 @@
:aria-label="t('load3d.openIn3DViewer')"
@click="openIn3DViewer"
>
<i class="pi pi-expand text-lg text-white" />
<i class="pi pi-expand text-lg text-base-foreground" />
</Button>
</div>
</div>

View File

@@ -4,6 +4,7 @@
class="maskEditor-dialog-root flex h-full w-full flex-col"
@contextmenu.prevent
@dragstart="handleDragStart"
@keydown.stop
>
<div
id="maskEditorCanvasContainer"

View File

@@ -11,7 +11,7 @@
>
<svg
viewBox="0 0 15 15"
class="h-6.25 w-6.25 pointer-events-none fill-[var(--input-text)]"
class="h-6.25 w-6.25 pointer-events-none fill-current"
>
<path
d="M8.77,12.18c-.25,0-.46-.2-.46-.46s.2-.46.46-.46c1.47,0,2.67-1.2,2.67-2.67,0-1.57-1.34-2.67-3.26-2.67h-3.98l1.43,1.43c.18.18.18.47,0,.64-.18.18-.47.18-.64,0l-2.21-2.21c-.18-.18-.18-.47,0-.64l2.21-2.21c.18-.18.47-.18.64,0,.18.18.18.47,0,.64l-1.43,1.43h3.98c2.45,0,4.17,1.47,4.17,3.58,0,1.97-1.61,3.58-3.58,3.58Z"
@@ -35,6 +35,74 @@
</svg>
</button>
<div class="h-5 border-l border-border" />
<button
:class="iconButtonClass"
:title="t('maskEditor.rotateLeft')"
@click="onRotateLeft"
>
<svg
viewBox="-6 -7 15 15"
class="h-6.25 w-6.25 pointer-events-none fill-[var(--input-text)]"
>
<path
d="m2.25-2.625c.3452 0 .625.2798.625.625v5c0 .3452-.2798.625-.625.625h-5c-.3452 0-.625-.2798-.625-.625v-5c0-.3452.2798-.625.625-.625h5zm1.25.625v5c0 .6904-.5596 1.25-1.25 1.25h-5c-.6904 0-1.25-.5596-1.25-1.25v-5c0-.6904.5596-1.25 1.25-1.25h5c.6904 0 1.25.5596 1.25 1.25zm-.1673-2.3757-.4419.4419-1.5246-1.5246 1.5416-1.5417.442.4419-.7871.7872h.9373c1.3807 0 2.5 1.1193 2.5 2.5h-.625c0-1.0355-.8395-1.875-1.875-1.875h-.9375l.7702.7702z"
/>
</svg>
</button>
<button
:class="iconButtonClass"
:title="t('maskEditor.rotateRight')"
@click="onRotateRight"
>
<svg
viewBox="-9 -7 15 15"
class="h-6.25 w-6.25 pointer-events-none fill-[var(--input-text)]"
>
<g transform="scale(-1, 1)">
<path
d="m2.25-2.625c.3452 0 .625.2798.625.625v5c0 .3452-.2798.625-.625.625h-5c-.3452 0-.625-.2798-.625-.625v-5c0-.3452.2798-.625.625-.625h5zm1.25.625v5c0 .6904-.5596 1.25-1.25 1.25h-5c-.6904 0-1.25-.5596-1.25-1.25v-5c0-.6904.5596-1.25 1.25-1.25h5c.6904 0 1.25.5596 1.25 1.25zm-.1673-2.3757-.4419.4419-1.5246-1.5246 1.5416-1.5417.442.4419-.7871.7872h.9373c1.3807 0 2.5 1.1193 2.5 2.5h-.625c0-1.0355-.8395-1.875-1.875-1.875h-.9375l.7702.7702z"
/>
</g>
</svg>
</button>
<button
:class="iconButtonClass"
:title="t('maskEditor.mirrorHorizontal')"
@click="onMirrorHorizontal"
>
<svg
viewBox="0 0 15 15"
class="h-6.25 w-6.25 pointer-events-none fill-[var(--input-text)]"
>
<path
d="M7.5,1.5c-.28,0-.5.22-.5.5v11c0,.28.22.5.5.5s.5-.22.5-.5v-11c0-.28-.22-.5-.5-.5Z"
/>
<path d="M3.5,4.5l-2,3,2,3v-6ZM11.5,4.5v6l2-3-2-3Z" />
</svg>
</button>
<button
:class="iconButtonClass"
:title="t('maskEditor.mirrorVertical')"
@click="onMirrorVertical"
>
<svg
viewBox="0 0 15 15"
class="h-6.25 w-6.25 pointer-events-none fill-[var(--input-text)]"
>
<path
d="M2,7.5c0-.28.22-.5.5-.5h11c.28,0,.5.22.5.5s-.22.5-.5.5h-11c-.28,0-.5-.22-.5-.5Z"
/>
<path d="M4.5,4.5l3-2,3,2h-6ZM4.5,10.5h6l-3,2-3-2Z" />
</svg>
</button>
<div class="h-5 w-px bg-[var(--p-form-field-border-color)]" />
<button :class="textButtonClass" @click="onInvert">
{{ t('maskEditor.invert') }}
</button>
@@ -63,6 +131,7 @@ import { ref } from 'vue'
import Button from '@/components/ui/button/Button.vue'
import { useCanvasTools } from '@/composables/maskeditor/useCanvasTools'
import { useCanvasTransform } from '@/composables/maskeditor/useCanvasTransform'
import { useMaskEditorSaver } from '@/composables/maskeditor/useMaskEditorSaver'
import { t } from '@/i18n'
import { useDialogStore } from '@/stores/dialogStore'
@@ -71,16 +140,17 @@ import { useMaskEditorStore } from '@/stores/maskEditorStore'
const store = useMaskEditorStore()
const dialogStore = useDialogStore()
const canvasTools = useCanvasTools()
const canvasTransform = useCanvasTransform()
const saver = useMaskEditorSaver()
const saveButtonText = ref(t('g.save'))
const saveEnabled = ref(true)
const iconButtonClass =
'flex h-7.5 w-12.5 items-center justify-center rounded-[10px] border border-[var(--p-form-field-border-color)] pointer-events-auto transition-colors duration-100 bg-[var(--comfy-menu-bg)] hover:bg-secondary-background-hover'
'flex h-7.5 w-12.5 items-center justify-center rounded-[10px] border border-border-default pointer-events-auto transition-colors duration-100 bg-comfy-menu-bg hover:bg-secondary-background-hover'
const textButtonClass =
'h-7.5 w-15 rounded-[10px] border border-[var(--p-form-field-border-color)] text-[var(--input-text)] font-sans pointer-events-auto transition-colors duration-100 bg-[var(--comfy-menu-bg)] hover:bg-secondary-background-hover'
'h-7.5 w-15 rounded-[10px] border border-border-default text-current font-sans pointer-events-auto transition-colors duration-100 bg-comfy-menu-bg hover:bg-secondary-background-hover'
const onUndo = () => {
store.canvasHistory.undo()
@@ -90,6 +160,38 @@ const onRedo = () => {
store.canvasHistory.redo()
}
const onRotateLeft = async () => {
try {
await canvasTransform.rotateCounterclockwise()
} catch (error) {
console.error('[TopBarHeader] Rotate left failed:', error)
}
}
const onRotateRight = async () => {
try {
await canvasTransform.rotateClockwise()
} catch (error) {
console.error('[TopBarHeader] Rotate right failed:', error)
}
}
const onMirrorHorizontal = async () => {
try {
await canvasTransform.mirrorHorizontal()
} catch (error) {
console.error('[TopBarHeader] Mirror horizontal failed:', error)
}
}
const onMirrorVertical = async () => {
try {
await canvasTransform.mirrorVertical()
} catch (error) {
console.error('[TopBarHeader] Mirror vertical failed:', error)
}
}
const onInvert = () => {
canvasTools.invertMask()
}

View File

@@ -50,20 +50,22 @@
<div
v-if="
props.state === 'running' &&
(props.progressTotalPercent !== undefined ||
props.progressCurrentPercent !== undefined)
hasAnyProgressPercent(
props.progressTotalPercent,
props.progressCurrentPercent
)
"
class="absolute inset-0"
:class="progressBarContainerClass"
>
<div
v-if="props.progressTotalPercent !== undefined"
class="pointer-events-none absolute inset-y-0 left-0 h-full bg-interface-panel-job-progress-primary transition-[width]"
:style="{ width: `${props.progressTotalPercent}%` }"
v-if="hasProgressPercent(props.progressTotalPercent)"
:class="progressBarPrimaryClass"
:style="progressPercentStyle(props.progressTotalPercent)"
/>
<div
v-if="props.progressCurrentPercent !== undefined"
class="pointer-events-none absolute inset-y-0 left-0 h-full bg-interface-panel-job-progress-secondary transition-[width]"
:style="{ width: `${props.progressCurrentPercent}%` }"
v-if="hasProgressPercent(props.progressCurrentPercent)"
:class="progressBarSecondaryClass"
:style="progressPercentStyle(props.progressCurrentPercent)"
/>
</div>
@@ -201,6 +203,7 @@ import { useI18n } from 'vue-i18n'
import JobDetailsPopover from '@/components/queue/job/JobDetailsPopover.vue'
import QueueAssetPreview from '@/components/queue/job/QueueAssetPreview.vue'
import Button from '@/components/ui/button/Button.vue'
import { useProgressBarBackground } from '@/composables/useProgressBarBackground'
import { buildTooltipConfig } from '@/composables/useTooltipConfig'
import type { JobState } from '@/types/queue'
import { iconForJobState } from '@/utils/queueDisplay'
@@ -245,6 +248,14 @@ const emit = defineEmits<{
}>()
const { t } = useI18n()
const {
progressBarContainerClass,
progressBarPrimaryClass,
progressBarSecondaryClass,
hasProgressPercent,
hasAnyProgressPercent,
progressPercentStyle
} = useProgressBarBackground()
const cancelTooltipConfig = computed(() => buildTooltipConfig(t('g.cancel')))
const deleteTooltipConfig = computed(() => buildTooltipConfig(t('g.delete')))

View File

@@ -1,10 +1,10 @@
<template>
<nav
ref="sideToolbarRef"
class="side-tool-bar-container flex h-full flex-col items-center bg-transparent [.floating-sidebar]:-mr-2 pointer-events-auto"
class="side-tool-bar-container flex h-full flex-col items-center bg-transparent [.floating-sidebar]:-mr-2"
:class="{
'small-sidebar': isSmall,
'connected-sidebar': isConnected,
'connected-sidebar pointer-events-auto': isConnected,
'floating-sidebar': !isConnected,
'overflowing-sidebar': isOverflowing,
'border-r border-[var(--interface-stroke)] shadow-interface': isConnected
@@ -40,12 +40,13 @@
v-if="userStore.isMultiUserServer"
:is-small="isSmall"
/>
<SidebarHelpCenterIcon :is-small="isSmall" />
<SidebarHelpCenterIcon v-if="!isIntegratedTabBar" :is-small="isSmall" />
<SidebarBottomPanelToggleButton :is-small="isSmall" />
<SidebarShortcutsToggleButton :is-small="isSmall" />
<SidebarSettingsButton :is-small="isSmall" />
</div>
</div>
<HelpCenterPopups :is-small="isSmall" />
</nav>
</template>
@@ -54,6 +55,7 @@ import { useResizeObserver } from '@vueuse/core'
import { debounce } from 'es-toolkit/compat'
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import HelpCenterPopups from '@/components/helpcenter/HelpCenterPopups.vue'
import ComfyMenuButton from '@/components/sidebar/ComfyMenuButton.vue'
import SidebarBottomPanelToggleButton from '@/components/sidebar/SidebarBottomPanelToggleButton.vue'
import SidebarSettingsButton from '@/components/sidebar/SidebarSettingsButton.vue'
@@ -89,6 +91,9 @@ const sidebarLocation = computed<'left' | 'right'>(() =>
settingStore.get('Comfy.Sidebar.Location')
)
const sidebarStyle = computed(() => settingStore.get('Comfy.Sidebar.Style'))
const isIntegratedTabBar = computed(
() => settingStore.get('Comfy.UI.TabBarLayout') === 'Integrated'
)
const isConnected = computed(
() =>
selectedTab.value ||
@@ -145,8 +150,8 @@ const getTabTooltipSuffix = (tab: SidebarTabExtension) => {
const isOverflowing = ref(false)
const groupClasses = computed(() =>
cn(
'sidebar-item-group flex flex-col items-center overflow-hidden flex-shrink-0' +
(isConnected.value ? '' : ' rounded-lg shadow-interface')
'sidebar-item-group flex flex-col items-center overflow-hidden flex-shrink-0',
!isConnected.value && 'rounded-lg shadow-interface pointer-events-auto'
)
)

View File

@@ -1,204 +1,28 @@
<template>
<div>
<SidebarIcon
icon="pi pi-question-circle"
class="comfy-help-center-btn"
:label="$t('menu.help')"
:tooltip="$t('sideToolbar.helpCenter')"
:icon-badge="shouldShowRedDot ? '' : ''"
:is-small="isSmall"
@click="toggleHelpCenter"
/>
<!-- Help Center Popup positioned within canvas area -->
<Teleport to="#graph-canvas-container">
<div
v-if="isHelpCenterVisible"
class="help-center-popup"
:class="{
'sidebar-left': sidebarLocation === 'left',
'sidebar-right': sidebarLocation === 'right',
'small-sidebar': isSmall
}"
>
<HelpCenterMenuContent @close="closeHelpCenter" />
</div>
</Teleport>
<!-- Release Notification Toast positioned within canvas area -->
<Teleport to="#graph-canvas-container">
<ReleaseNotificationToast
:class="{
'sidebar-left': sidebarLocation === 'left',
'sidebar-right': sidebarLocation === 'right',
'small-sidebar': isSmall
}"
/>
</Teleport>
<!-- WhatsNew Popup positioned within canvas area -->
<Teleport to="#graph-canvas-container">
<WhatsNewPopup
:class="{
'sidebar-left': sidebarLocation === 'left',
'sidebar-right': sidebarLocation === 'right',
'small-sidebar': isSmall
}"
@whats-new-dismissed="handleWhatsNewDismissed"
/>
</Teleport>
<!-- Backdrop to close popup when clicking outside -->
<Teleport to="body">
<div
v-if="isHelpCenterVisible"
class="help-center-backdrop"
@click="closeHelpCenter"
/>
</Teleport>
</div>
<SidebarIcon
icon="pi pi-question-circle"
class="comfy-help-center-btn"
:label="$t('menu.help')"
:tooltip="$t('sideToolbar.helpCenter')"
:icon-badge="shouldShowRedDot ? '' : ''"
:is-small="isSmall"
@click="toggleHelpCenter"
/>
</template>
<script setup lang="ts">
import { storeToRefs } from 'pinia'
import { computed, onMounted, toRefs } from 'vue'
import HelpCenterMenuContent from '@/components/helpcenter/HelpCenterMenuContent.vue'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useTelemetry } from '@/platform/telemetry'
import { useReleaseStore } from '@/platform/updates/common/releaseStore'
import ReleaseNotificationToast from '@/platform/updates/components/ReleaseNotificationToast.vue'
import WhatsNewPopup from '@/platform/updates/components/WhatsNewPopup.vue'
import { useDialogService } from '@/services/dialogService'
import { useHelpCenterStore } from '@/stores/helpCenterStore'
import { useConflictAcknowledgment } from '@/workbench/extensions/manager/composables/useConflictAcknowledgment'
import { useConflictDetection } from '@/workbench/extensions/manager/composables/useConflictDetection'
import { useHelpCenter } from '@/composables/useHelpCenter'
import SidebarIcon from './SidebarIcon.vue'
const settingStore = useSettingStore()
const releaseStore = useReleaseStore()
const helpCenterStore = useHelpCenterStore()
const { isVisible: isHelpCenterVisible } = storeToRefs(helpCenterStore)
const { shouldShowRedDot: showReleaseRedDot } = storeToRefs(releaseStore)
const conflictDetection = useConflictDetection()
const { showNodeConflictDialog } = useDialogService()
// Use conflict acknowledgment state from composable - call only once
const { shouldShowRedDot: shouldShowConflictRedDot, markConflictsAsSeen } =
useConflictAcknowledgment()
const props = defineProps<{
defineProps<{
isSmall: boolean
}>()
const { isSmall } = toRefs(props)
// Use either release red dot or conflict red dot
const shouldShowRedDot = computed((): boolean => {
const releaseRedDot = showReleaseRedDot.value
return releaseRedDot || shouldShowConflictRedDot.value
})
const sidebarLocation = computed(() =>
settingStore.get('Comfy.Sidebar.Location')
)
/**
* Toggle Help Center and track UI button click.
*/
const toggleHelpCenter = () => {
useTelemetry()?.trackUiButtonClicked({
button_id: 'sidebar_help_center_toggled'
})
helpCenterStore.toggle()
}
const closeHelpCenter = () => {
helpCenterStore.hide()
}
/**
* Handle What's New popup dismissal
* Check if conflict modal should be shown after ComfyUI update
*/
const handleWhatsNewDismissed = async () => {
try {
// Check if conflict modal should be shown after update
const shouldShow =
await conflictDetection.shouldShowConflictModalAfterUpdate()
if (shouldShow) {
showConflictModal()
}
} catch (error) {
console.error('[HelpCenter] Error checking conflict modal:', error)
}
}
/**
* Show the node conflict dialog with current conflict data
*/
const showConflictModal = () => {
showNodeConflictDialog({
showAfterWhatsNew: true,
dialogComponentProps: {
onClose: () => {
markConflictsAsSeen()
}
}
})
}
// Initialize release store on mount
onMounted(async () => {
// Initialize release store to fetch releases for toast and popup
await releaseStore.initialize()
})
const { shouldShowRedDot, toggleHelpCenter } = useHelpCenter()
</script>
<style scoped>
.help-center-backdrop {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 9999;
background: transparent;
}
.help-center-popup {
position: absolute;
bottom: 1rem;
z-index: 10000;
animation: slideInUp 0.2s ease-out;
pointer-events: auto;
}
.help-center-popup.sidebar-left {
left: 1rem;
}
.help-center-popup.sidebar-left.small-sidebar {
left: 1rem;
}
.help-center-popup.sidebar-right {
right: 1rem;
}
@keyframes slideInUp {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
:deep(.p-badge) {
background: #ff3b30;
color: #ff3b30;

View File

@@ -0,0 +1,154 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import type { JobAction } from '@/composables/queue/useJobActions'
import type { JobListItem } from '@/composables/queue/useJobList'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import { setMockJobActions } from '@/storybook/mocks/useJobActions'
import { setMockJobItems } from '@/storybook/mocks/useJobList'
import { iconForJobState } from '@/utils/queueDisplay'
import AssetsSidebarListView from './AssetsSidebarListView.vue'
type StoryArgs = {
assets: AssetItem[]
jobs: JobListItem[]
selectedAssetIds?: string[]
actionsByJobId?: Record<string, JobAction[]>
}
function baseDecorator() {
return {
template: `
<div class="bg-base-background p-6">
<story />
</div>
`
}
}
const meta: Meta<StoryArgs> = {
title: 'Components/Sidebar/AssetsSidebarListView',
component: AssetsSidebarListView,
parameters: {
layout: 'centered'
},
decorators: [baseDecorator]
}
export default meta
type Story = StoryObj<typeof meta>
const baseTimestamp = '2024-01-15T10:00:00Z'
const sampleJobs: JobListItem[] = [
{
id: 'job-pending-1',
title: 'In queue',
meta: '8:59:30pm',
state: 'pending',
iconName: iconForJobState('pending'),
showClear: true
},
{
id: 'job-init-1',
title: 'Initializing...',
meta: '8:59:35pm',
state: 'initialization',
iconName: iconForJobState('initialization'),
showClear: true
},
{
id: 'job-running-1',
title: 'Total: 30%',
meta: 'KSampler: 70%',
state: 'running',
iconName: iconForJobState('running'),
showClear: true,
progressTotalPercent: 30,
progressCurrentPercent: 70
}
]
const sampleAssets: AssetItem[] = [
{
id: 'asset-image-1',
name: 'image-032.png',
created_at: baseTimestamp,
preview_url: '/assets/images/comfy-logo-single.svg',
size: 1887437,
tags: [],
user_metadata: {
promptId: 'job-running-1',
nodeId: 12,
executionTimeInSeconds: 1.84
}
},
{
id: 'asset-video-1',
name: 'clip-01.mp4',
created_at: baseTimestamp,
preview_url: '/assets/images/default-template.png',
size: 8394820,
tags: [],
user_metadata: {
duration: 132000
}
},
{
id: 'asset-audio-1',
name: 'soundtrack-01.mp3',
created_at: baseTimestamp,
size: 5242880,
tags: [],
user_metadata: {
duration: 200000
}
},
{
id: 'asset-3d-1',
name: 'scene-01.glb',
created_at: baseTimestamp,
size: 134217728,
tags: []
}
]
const cancelAction: JobAction = {
icon: 'icon-[lucide--x]',
label: 'Cancel',
variant: 'destructive'
}
export const RunningAndGenerated: Story = {
args: {
assets: sampleAssets,
jobs: sampleJobs,
actionsByJobId: {
'job-pending-1': [cancelAction],
'job-init-1': [cancelAction],
'job-running-1': [cancelAction]
}
},
render: renderAssetsSidebarListView
}
function renderAssetsSidebarListView(args: StoryArgs) {
return {
components: { AssetsSidebarListView },
setup() {
setMockJobItems(args.jobs)
setMockJobActions(args.actionsByJobId ?? {})
const selectedIds = new Set(args.selectedAssetIds ?? [])
function isSelected(assetId: string) {
return selectedIds.has(assetId)
}
return { args, isSelected }
},
template: `
<div class="h-[520px] w-[320px] overflow-hidden rounded-lg border border-panel-border">
<AssetsSidebarListView :assets="args.assets" :is-selected="isSelected" />
</div>
`
}
}

View File

@@ -0,0 +1,203 @@
<template>
<div class="flex h-full flex-col">
<div
v-if="activeJobItems.length"
class="flex max-h-[50%] flex-col gap-2 overflow-y-auto px-2"
>
<AssetsListItem
v-for="job in activeJobItems"
:key="job.id"
:class="
cn(
'w-full shrink-0 text-text-primary transition-colors hover:bg-secondary-background-hover',
'cursor-default'
)
"
:preview-url="job.iconImageUrl"
:preview-alt="job.title"
:icon-name="job.iconName"
:icon-class="getJobIconClass(job)"
:primary-text="job.title"
:secondary-text="job.meta"
:progress-total-percent="job.progressTotalPercent"
:progress-current-percent="job.progressCurrentPercent"
@mouseenter="onJobEnter(job.id)"
@mouseleave="onJobLeave(job.id)"
@click.stop
>
<template v-if="hoveredJobId === job.id" #actions>
<Button
v-if="canCancelJob"
:variant="cancelAction.variant"
size="icon"
:aria-label="cancelAction.label"
@click.stop="runCancelJob()"
>
<i :class="cancelAction.icon" class="size-4" />
</Button>
</template>
</AssetsListItem>
</div>
<div
v-if="assets.length"
:class="cn('px-2', activeJobItems.length && 'mt-2')"
>
<div
class="flex items-center py-2 text-sm font-normal leading-normal text-muted-foreground font-inter"
>
{{ t('sideToolbar.generatedAssetsHeader') }}
</div>
</div>
<VirtualGrid
class="flex-1"
:items="assetItems"
:grid-style="listGridStyle"
@approach-end="emit('approach-end')"
>
<template #item="{ item }">
<AssetsListItem
role="button"
tabindex="0"
:aria-label="
t('assetBrowser.ariaLabel.assetCard', {
name: item.asset.name,
type: getMediaTypeFromFilename(item.asset.name)
})
"
:class="getAssetCardClass(isSelected(item.asset.id))"
:preview-url="item.asset.preview_url"
:preview-alt="item.asset.name"
:icon-name="
iconForMediaType(getMediaTypeFromFilename(item.asset.name))
"
:primary-text="getAssetPrimaryText(item.asset)"
:secondary-text="getAssetSecondaryText(item.asset)"
@click.stop="emit('select-asset', item.asset)"
/>
</template>
</VirtualGrid>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import VirtualGrid from '@/components/common/VirtualGrid.vue'
import Button from '@/components/ui/button/Button.vue'
import { useJobActions } from '@/composables/queue/useJobActions'
import type { JobListItem } from '@/composables/queue/useJobList'
import { useJobList } from '@/composables/queue/useJobList'
import AssetsListItem from '@/platform/assets/components/AssetsListItem.vue'
import { getOutputAssetMetadata } from '@/platform/assets/schemas/assetMetadataSchema'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import { iconForMediaType } from '@/platform/assets/utils/mediaIconUtil'
import type { JobState } from '@/types/queue'
import {
formatDuration,
formatSize,
getMediaTypeFromFilename,
truncateFilename
} from '@/utils/formatUtil'
import { iconForJobState } from '@/utils/queueDisplay'
import { cn } from '@/utils/tailwindUtil'
const { assets, isSelected } = defineProps<{
assets: AssetItem[]
isSelected: (assetId: string) => boolean
}>()
const emit = defineEmits<{
(e: 'select-asset', asset: AssetItem): void
(e: 'approach-end'): void
}>()
const { t } = useI18n()
const { jobItems } = useJobList()
const hoveredJobId = ref<string | null>(null)
type AssetListItem = { key: string; asset: AssetItem }
const activeJobItems = computed(() =>
jobItems.value.filter((item) => isActiveJobState(item.state))
)
const hoveredJob = computed(() =>
hoveredJobId.value
? (activeJobItems.value.find((job) => job.id === hoveredJobId.value) ??
null)
: null
)
const { cancelAction, canCancelJob, runCancelJob } = useJobActions(hoveredJob)
const assetItems = computed<AssetListItem[]>(() =>
assets.map((asset) => ({
key: `asset-${asset.id}`,
asset
}))
)
const listGridStyle = {
display: 'grid',
gridTemplateColumns: 'minmax(0, 1fr)',
padding: '0 0.5rem',
gap: '0.5rem'
}
function isActiveJobState(state: JobState): boolean {
return (
state === 'pending' || state === 'initialization' || state === 'running'
)
}
function getAssetPrimaryText(asset: AssetItem): string {
return truncateFilename(asset.name)
}
function getAssetSecondaryText(asset: AssetItem): string {
const metadata = getOutputAssetMetadata(asset.user_metadata)
if (typeof metadata?.executionTimeInSeconds === 'number') {
return `${metadata.executionTimeInSeconds.toFixed(2)}s`
}
const duration = asset.user_metadata?.duration
if (typeof duration === 'number') {
return formatDuration(duration)
}
if (typeof asset.size === 'number') {
return formatSize(asset.size)
}
return ''
}
function getAssetCardClass(selected: boolean): string {
return cn(
'w-full text-text-primary transition-colors hover:bg-secondary-background-hover',
'cursor-pointer',
selected &&
'bg-secondary-background-hover ring-1 ring-inset ring-modal-card-border-highlighted'
)
}
function onJobEnter(jobId: string) {
hoveredJobId.value = jobId
}
function onJobLeave(jobId: string) {
if (hoveredJobId.value === jobId) {
hoveredJobId.value = null
}
}
function getJobIconClass(job: JobListItem): string | undefined {
const classes = []
const iconName = job.iconName ?? iconForJobState(job.state)
if (!job.iconImageUrl && iconName === iconForJobState('pending')) {
classes.push('animate-spin')
}
return classes.length ? classes.join(' ') : undefined
}
</script>

View File

@@ -79,10 +79,10 @@
<Divider v-else type="dashed" class="my-2" />
</template>
<template #body>
<div v-if="loading && !displayAssets.length">
<div v-if="showLoadingState">
<ProgressSpinner class="absolute left-1/2 w-[50px] -translate-x-1/2" />
</div>
<div v-else-if="!loading && !displayAssets.length">
<div v-else-if="showEmptyState">
<NoResultsPlaceholder
icon="pi pi-info-circle"
:title="
@@ -96,7 +96,15 @@
/>
</div>
<div v-else class="relative size-full" @click="handleEmptySpaceClick">
<AssetsSidebarListView
v-if="isListView"
:assets="displayAssets"
:is-selected="isSelected"
@select-asset="handleAssetSelect"
@approach-end="handleApproachEnd"
/>
<VirtualGrid
v-else
:items="mediaAssetsWithKey"
:grid-style="{
display: 'grid',
@@ -114,11 +122,15 @@
:output-count="getOutputCount(item)"
:show-delete-button="shouldShowDeleteButton"
:open-context-menu-id="openContextMenuId"
:selected-assets="getSelectedAssets(displayAssets)"
:has-selection="hasSelection"
@click="handleAssetSelect(item)"
@zoom="handleZoomClick(item)"
@output-count-click="enterFolderView(item)"
@asset-deleted="refreshAssets"
@context-menu-opened="openContextMenuId = item.id"
@bulk-download="handleBulkDownload"
@bulk-delete="handleBulkDelete"
/>
</template>
</VirtualGrid>
@@ -134,7 +146,6 @@
<div ref="selectionCountButtonRef" class="inline-flex w-48">
<Button
variant="secondary"
size="lg"
:class="cn(isCompact && 'text-left')"
@click="handleDeselectAll"
>
@@ -198,6 +209,7 @@ import { useI18n } from 'vue-i18n'
import NoResultsPlaceholder from '@/components/common/NoResultsPlaceholder.vue'
import VirtualGrid from '@/components/common/VirtualGrid.vue'
import Load3dViewerContent from '@/components/load3d/Load3dViewerContent.vue'
import AssetsSidebarListView from '@/components/sidebar/tabs/AssetsSidebarListView.vue'
import SidebarTabTemplate from '@/components/sidebar/tabs/SidebarTabTemplate.vue'
import ResultGallery from '@/components/sidebar/tabs/queue/ResultGallery.vue'
import Tab from '@/components/tab/Tab.vue'
@@ -232,6 +244,9 @@ const viewMode = ref<'list' | 'grid'>('grid')
const isQueuePanelV2Enabled = computed(() =>
settingStore.get('Comfy.Queue.QPOV2')
)
const isListView = computed(
() => isQueuePanelV2Enabled.value && viewMode.value === 'list'
)
// Track which asset's context menu is open (for single-instance context menu management)
const openContextMenuId = ref<string | null>(null)
@@ -243,11 +258,6 @@ const shouldShowDeleteButton = computed(() => {
return true
})
const getOutputCount = (item: AssetItem): number => {
const count = item.user_metadata?.outputCount
return typeof count === 'number' && count > 0 ? count : 1
}
const shouldShowOutputCount = (item: AssetItem): boolean => {
if (activeTab.value !== 'output' || isInFolderView.value) {
return false
@@ -285,6 +295,8 @@ const {
hasSelection,
clearSelection,
getSelectedAssets,
getOutputCount,
getTotalOutputCount,
activate: activateSelection,
deactivate: deactivateSelection
} = useAssetSelection()
@@ -316,7 +328,7 @@ const isHoveringSelectionCount = useElementHover(selectionCountButtonRef)
// Total output count for all selected assets
const totalOutputCount = computed(() => {
const selectedAssets = getSelectedAssets(displayAssets.value)
return selectedAssets.reduce((sum, asset) => sum + getOutputCount(asset), 0)
return getTotalOutputCount(selectedAssets)
})
const currentAssets = computed(() =>
@@ -347,6 +359,20 @@ const displayAssets = computed(() => {
return filteredAssets.value
})
const showLoadingState = computed(
() =>
loading.value &&
displayAssets.value.length === 0 &&
(!isListView.value || activeJobsCount.value === 0)
)
const showEmptyState = computed(
() =>
!loading.value &&
displayAssets.value.length === 0 &&
(!isListView.value || activeJobsCount.value === 0)
)
watch(displayAssets, (newAssets) => {
if (currentGalleryAssetId.value && galleryActiveIndex.value !== -1) {
const newIndex = newAssets.findIndex(
@@ -537,6 +563,16 @@ const handleDeleteSelected = async () => {
clearSelection()
}
const handleBulkDownload = (assets: AssetItem[]) => {
downloadMultipleAssets(assets)
clearSelection()
}
const handleBulkDelete = async (assets: AssetItem[]) => {
await deleteMultipleAssets(assets)
clearSelection()
}
const handleClearQueue = async () => {
await commandStore.execute('Comfy.ClearPendingTasks')
}

View File

@@ -0,0 +1,95 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import type { AssetDownload } from '@/stores/assetDownloadStore'
import ProgressToastItem from './ProgressToastItem.vue'
const meta: Meta<typeof ProgressToastItem> = {
title: 'Toast/ProgressToastItem',
component: ProgressToastItem,
parameters: {
layout: 'padded'
},
decorators: [
() => ({
template: '<div class="w-[400px] bg-base-background p-4"><story /></div>'
})
]
}
export default meta
type Story = StoryObj<typeof meta>
function createMockJob(overrides: Partial<AssetDownload> = {}): AssetDownload {
return {
taskId: 'task-1',
assetId: 'asset-1',
assetName: 'model-v1.safetensors',
bytesTotal: 1000000,
bytesDownloaded: 0,
progress: 0,
status: 'created',
lastUpdate: Date.now(),
...overrides
}
}
export const Pending: Story = {
args: {
job: createMockJob({
status: 'created',
assetName: 'sd-xl-base-1.0.safetensors'
})
}
}
export const Running: Story = {
args: {
job: createMockJob({
status: 'running',
progress: 0.45,
assetName: 'lora-detail-enhancer.safetensors'
})
}
}
export const RunningAlmostComplete: Story = {
args: {
job: createMockJob({
status: 'running',
progress: 0.92,
assetName: 'vae-ft-mse-840000.safetensors'
})
}
}
export const Completed: Story = {
args: {
job: createMockJob({
status: 'completed',
progress: 1,
assetName: 'controlnet-canny.safetensors'
})
}
}
export const Failed: Story = {
args: {
job: createMockJob({
status: 'failed',
progress: 0.23,
assetName: 'unreachable-model.safetensors'
})
}
}
export const LongFileName: Story = {
args: {
job: createMockJob({
status: 'running',
progress: 0.67,
assetName:
'very-long-model-name-with-lots-of-descriptive-text-v2.1-final-release.safetensors'
})
}
}

View File

@@ -0,0 +1,65 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import StatusBadge from '@/components/common/StatusBadge.vue'
import type { AssetDownload } from '@/stores/assetDownloadStore'
import { cn } from '@/utils/tailwindUtil'
const { job } = defineProps<{
job: AssetDownload
}>()
const { t } = useI18n()
const progressPercent = computed(() => Math.round(job.progress * 100))
const isCompleted = computed(() => job.status === 'completed')
const isFailed = computed(() => job.status === 'failed')
const isRunning = computed(() => job.status === 'running')
const isPending = computed(() => job.status === 'created')
</script>
<template>
<div
:class="
cn(
'flex items-center justify-between rounded-lg bg-modal-card-background px-4 py-3',
isCompleted && 'opacity-50'
)
"
>
<div class="min-w-0 flex-1">
<span class="block truncate text-sm text-base-foreground">{{
job.assetName
}}</span>
</div>
<div class="flex flex-shrink-0 items-center gap-2">
<template v-if="isFailed">
<i
class="icon-[lucide--circle-alert] size-4 text-destructive-background"
/>
<StatusBadge :label="t('progressToast.failed')" severity="danger" />
</template>
<template v-else-if="isCompleted">
<StatusBadge :label="t('progressToast.finished')" severity="contrast" />
</template>
<template v-else-if="isRunning">
<i
class="icon-[lucide--loader-circle] size-4 animate-spin text-base-foreground"
/>
<span class="text-xs text-base-foreground">
{{ progressPercent }}%
</span>
</template>
<template v-else-if="isPending">
<span class="text-xs text-muted-foreground">
{{ t('progressToast.pending') }}
</span>
</template>
</div>
</div>
</template>

View File

@@ -9,11 +9,16 @@
@click="popover?.toggle($event)"
>
<div
class="flex items-center gap-1 rounded-full hover:bg-interface-button-hover-surface"
:class="
cn(
'flex items-center gap-1 rounded-full hover:bg-interface-button-hover-surface justify-center',
compact && 'size-full '
)
"
>
<UserAvatar :photo-url="photoURL" />
<UserAvatar :photo-url="photoURL" :class="compact && 'size-full'" />
<i class="icon-[lucide--chevron-down] size-3 px-1" />
<i v-if="showArrow" class="icon-[lucide--chevron-down] size-3 px-1" />
</div>
</Button>
@@ -38,9 +43,15 @@ import { computed, ref } from 'vue'
import UserAvatar from '@/components/common/UserAvatar.vue'
import Button from '@/components/ui/button/Button.vue'
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
import { cn } from '@/utils/tailwindUtil'
import CurrentUserPopover from './CurrentUserPopover.vue'
const { showArrow = true, compact = false } = defineProps<{
showArrow?: boolean
compact?: boolean
}>()
const { isLoggedIn, userPhotoUrl } = useCurrentUser()
const popover = ref<InstanceType<typeof Popover> | null>(null)

View File

@@ -1,15 +1,19 @@
<template>
<Button
v-if="!isLoggedIn"
variant="secondary"
variant="textonly"
size="icon"
class="rounded-full bg-secondary-background text-base-foreground hover:bg-secondary-background-hover"
:class="cn('group rounded-full text-base-foreground p-0', className)"
:aria-label="t('g.login')"
@click="handleSignIn()"
@mouseenter="showPopover"
@mouseleave="hidePopover"
>
<i class="icon-[lucide--user] size-4" />
<span
class="flex size-full items-center justify-center rounded-full bg-secondary-background transition-colors group-hover:bg-transparent"
>
<i class="icon-[lucide--user] size-4" />
</span>
</Button>
<Popover
ref="popoverRef"
@@ -31,12 +35,18 @@
<script setup lang="ts">
import Popover from 'primevue/popover'
import type { HTMLAttributes } from 'vue'
import { ref } from 'vue'
import Button from '@/components/ui/button/Button.vue'
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
import { useExternalLink } from '@/composables/useExternalLink'
import { t } from '@/i18n'
import { cn } from '@/utils/tailwindUtil'
const { class: className } = defineProps<{
class?: HTMLAttributes['class']
}>()
const { isLoggedIn, handleSignIn } = useCurrentUser()
const { buildDocsUrl } = useExternalLink()

View File

@@ -0,0 +1,21 @@
<template>
<Button
class="comfy-help-center-btn relative text-base-foreground"
variant="textonly"
@click="toggleHelpCenter"
>
{{ $t('menu.helpAndFeedback') }}
<i class="icon-[lucide--circle-help] ml-0.5" />
<span
v-if="shouldShowRedDot"
class="absolute top-[7px] right-[7px] size-1.5 rounded-full bg-[#ff3b30]"
/>
</Button>
</template>
<script setup lang="ts">
import Button from '@/components/ui/button/Button.vue'
import { useHelpCenter } from '@/composables/useHelpCenter'
const { shouldShowRedDot, toggleHelpCenter } = useHelpCenter('topbar')
</script>

View File

@@ -161,7 +161,7 @@ describe('TopbarBadge', () => {
)
expect(wrapper.find('.bg-gold-600').exists()).toBe(true)
expect(wrapper.find('.text-gold-600').exists()).toBe(true)
expect(wrapper.find('.text-warning-background').exists()).toBe(true)
})
it('uses default error icon for error variant', () => {
@@ -185,7 +185,9 @@ describe('TopbarBadge', () => {
'full'
)
expect(wrapper.find('.pi-exclamation-triangle').exists()).toBe(true)
expect(wrapper.find('.icon-\\[lucide--triangle-alert\\]').exists()).toBe(
true
)
})
})

View File

@@ -174,7 +174,7 @@ const textClasses = computed(() => {
case 'error':
return 'text-danger-100'
case 'warning':
return 'text-gold-600'
return 'text-warning-background'
case 'info':
default:
return 'text-text-primary'
@@ -191,7 +191,7 @@ const iconClass = computed(() => {
case 'error':
return 'pi pi-exclamation-circle'
case 'warning':
return 'pi pi-exclamation-triangle'
return 'icon-[lucide--triangle-alert]'
case 'info':
default:
return undefined

View File

@@ -67,6 +67,19 @@
>
<i class="pi pi-plus" />
</Button>
<div
v-if="isIntegratedTabBar"
class="ml-auto flex shrink-0 items-center gap-2 px-2"
>
<TopMenuHelpButton />
<CurrentUserButton
v-if="isLoggedIn"
:show-arrow="false"
compact
class="shrink-0 p-1"
/>
<LoginButton v-else-if="isDesktop" class="p-1" />
</div>
<ContextMenu ref="menu" :model="contextMenuItems" />
<div v-if="isDesktop" class="window-actions-spacer app-drag shrink-0" />
</div>
@@ -81,9 +94,14 @@ import { computed, nextTick, onUpdated, ref, watch } from 'vue'
import type { WatchStopHandle } from 'vue'
import { useI18n } from 'vue-i18n'
import CurrentUserButton from '@/components/topbar/CurrentUserButton.vue'
import LoginButton from '@/components/topbar/LoginButton.vue'
import TopMenuHelpButton from '@/components/topbar/TopMenuHelpButton.vue'
import WorkflowTab from '@/components/topbar/WorkflowTab.vue'
import Button from '@/components/ui/button/Button.vue'
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
import { useOverflowObserver } from '@/composables/element/useOverflowObserver'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useWorkflowService } from '@/platform/workflow/core/services/workflowService'
import type { ComfyWorkflow } from '@/platform/workflow/management/stores/workflowStore'
import {
@@ -107,10 +125,16 @@ const props = defineProps<{
}>()
const { t } = useI18n()
const settingStore = useSettingStore()
const workspaceStore = useWorkspaceStore()
const workflowStore = useWorkflowStore()
const workflowBookmarkStore = useWorkflowBookmarkStore()
const workflowService = useWorkflowService()
const { isLoggedIn } = useCurrentUser()
const isIntegratedTabBar = computed(
() => settingStore.get('Comfy.UI.TabBarLayout') === 'Integrated'
)
const rightClickedTab = ref<WorkflowOption | undefined>()
const menu = ref()

View File

@@ -2,7 +2,7 @@ import type { VariantProps } from 'cva'
import { cva } from 'cva'
export const buttonVariants = cva({
base: 'inline-flex items-center justify-center gap-2 cursor-pointer whitespace-nowrap appearance-none border-none rounded-md text-sm font-medium font-inter transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',
base: 'relative inline-flex items-center justify-center gap-2 cursor-pointer whitespace-nowrap appearance-none border-none rounded-md text-sm font-medium font-inter transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',
variants: {
variant: {
secondary:

View File

@@ -0,0 +1,181 @@
<template>
<label
:for="inputId"
:class="
cn(
'flex h-10 cursor-text items-center rounded-lg bg-secondary-background text-secondary-foreground hover:bg-secondary-background-hover focus-within:ring-1 focus-within:ring-secondary-foreground',
disabled && 'opacity-50 pointer-events-none'
)
"
>
<button
type="button"
class="flex h-full w-8 cursor-pointer items-center justify-center rounded-l-lg border-none bg-transparent text-muted-foreground transition-colors hover:text-base-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-inset focus-visible:ring-secondary-foreground disabled:opacity-30"
:disabled="disabled || modelValue <= min"
:aria-label="$t('g.decrement')"
@click="handleStep(-1)"
>
<i class="icon-[lucide--minus] size-4" />
</button>
<div
class="flex flex-1 items-center justify-center gap-0.5 overflow-hidden"
>
<slot name="prefix" />
<input
:id="inputId"
ref="inputRef"
v-model="inputValue"
type="text"
inputmode="numeric"
:style="{ width: `${inputWidth}ch` }"
class="min-w-0 rounded border-none bg-transparent text-center text-base-foreground font-medium text-lg focus-visible:outline-none"
:disabled="disabled"
@input="handleInputChange"
@blur="handleInputBlur"
@focus="handleInputFocus"
/>
<slot name="suffix" />
</div>
<button
type="button"
class="flex h-full w-8 cursor-pointer items-center justify-center rounded-r-lg border-none bg-transparent text-muted-foreground transition-colors hover:text-base-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-inset focus-visible:ring-secondary-foreground disabled:opacity-30"
:disabled="disabled || modelValue >= max"
:aria-label="$t('g.increment')"
@click="handleStep(1)"
>
<i class="icon-[lucide--plus] size-4" />
</button>
</label>
</template>
<script setup lang="ts">
import { computed, ref, useId, watch } from 'vue'
import { cn } from '@/utils/tailwindUtil'
const {
min = 0,
max = Infinity,
step = 1,
formatOptions = { useGrouping: true },
disabled = false
} = defineProps<{
min?: number
max?: number
step?: number | ((value: number) => number)
formatOptions?: Intl.NumberFormatOptions
disabled?: boolean
}>()
const emit = defineEmits<{
'max-reached': []
}>()
const modelValue = defineModel<number>({ required: true })
const inputId = useId()
const inputRef = ref<HTMLInputElement | null>(null)
const inputValue = ref(formatNumber(modelValue.value))
const inputWidth = computed(() =>
Math.min(Math.max(inputValue.value.length, 1) + 0.5, 9)
)
watch(modelValue, (newValue) => {
if (document.activeElement !== inputRef.value) {
inputValue.value = formatNumber(newValue)
}
})
function formatNumber(num: number): string {
return num.toLocaleString('en-US', formatOptions)
}
function parseFormattedNumber(str: string): number {
const cleaned = str.replace(/[^0-9]/g, '')
return cleaned === '' ? 0 : parseInt(cleaned, 10)
}
function clamp(value: number, minVal: number, maxVal: number): number {
return Math.min(Math.max(value, minVal), maxVal)
}
function formatWithCursor(
value: string,
cursorPos: number
): { formatted: string; newCursor: number } {
const num = parseFormattedNumber(value)
const formatted = formatNumber(num)
const digitsBeforeCursor = value
.slice(0, cursorPos)
.replace(/[^0-9]/g, '').length
let digitCount = 0
let newCursor = 0
for (let i = 0; i < formatted.length; i++) {
if (/[0-9]/.test(formatted[i])) {
digitCount++
}
if (digitCount >= digitsBeforeCursor) {
newCursor = i + 1
break
}
}
if (digitCount < digitsBeforeCursor) {
newCursor = formatted.length
}
return { formatted, newCursor }
}
function getStepAmount(): number {
return typeof step === 'function' ? step(modelValue.value) : step
}
function handleInputChange(e: Event) {
const input = e.target as HTMLInputElement
const raw = input.value
const cursorPos = input.selectionStart ?? raw.length
const num = parseFormattedNumber(raw)
const clamped = Math.min(num, max)
const wasClamped = num > max
if (wasClamped) {
emit('max-reached')
}
modelValue.value = clamped
const { formatted, newCursor } = formatWithCursor(
wasClamped ? formatNumber(clamped) : raw,
wasClamped ? formatNumber(clamped).length : cursorPos
)
inputValue.value = formatted
requestAnimationFrame(() => {
inputRef.value?.setSelectionRange(newCursor, newCursor)
})
}
function handleInputBlur() {
const clamped = clamp(modelValue.value, min, max)
modelValue.value = clamped
inputValue.value = formatNumber(clamped)
}
function handleInputFocus(e: FocusEvent) {
const input = e.target as HTMLInputElement
const len = input.value.length
input.setSelectionRange(len, len)
}
function handleStep(direction: 1 | -1) {
const stepAmount = getStepAmount()
const newValue = clamp(modelValue.value + stepAmount * direction, min, max)
modelValue.value = newValue
inputValue.value = formatNumber(newValue)
}
</script>

View File

@@ -13,10 +13,11 @@
<i class="icon-[lucide--panel-right] text-sm" />
</Button>
<Button
class="absolute top-4 right-6 z-10 transition-opacity duration-200"
size="lg"
class="absolute top-4 right-6 z-10 transition-opacity duration-200 w-10"
@click="closeDialog"
>
<i class="pi pi-times text-sm"></i>
<i class="pi pi-times" />
</Button>
<div class="flex h-full w-full">
<Transition name="slide-panel">
@@ -80,7 +81,9 @@
>
{{ contentTitle }}
</h2>
<div class="min-h-0 px-6 pt-0 pb-10 overflow-y-auto">
<div
class="min-h-0 flex-1 px-6 pt-0 pb-10 overflow-y-auto scrollbar-custom"
>
<slot name="content"></slot>
</div>
</main>

View File

@@ -9,7 +9,7 @@
role="button"
@click="onClick"
>
<div v-if="icon" class="py-0.5">
<div v-if="icon" class="pt-0.5">
<NavIcon :icon="icon" />
</div>
<i v-else class="text-neutral icon-[lucide--folder] text-xs shrink-0" />

View File

@@ -75,6 +75,7 @@ export interface VueNodeData {
hasErrors?: boolean
inputs?: INodeInputSlot[]
outputs?: INodeOutputSlot[]
resizable?: boolean
shape?: number
subgraphId?: string | null
titleMode?: TitleMode
@@ -244,17 +245,10 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
})
// Update only widgets with new slot metadata, keeping other widget data intact
const updatedWidgets = currentData.widgets?.map((widget) => {
for (const widget of currentData.widgets ?? []) {
const slotInfo = slotMetadata.get(widget.name)
return slotInfo ? { ...widget, slotMetadata: slotInfo } : widget
})
vueNodeData.set(nodeId, {
...currentData,
widgets: updatedWidgets,
inputs: nodeRef.inputs ? [...nodeRef.inputs] : undefined,
outputs: nodeRef.outputs ? [...nodeRef.outputs] : undefined
})
if (slotInfo) widget.slotMetadata = slotInfo
}
}
// Extract safe data from LiteGraph node for Vue consumption
@@ -325,6 +319,7 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
flags: node.flags ? { ...node.flags } : undefined,
color: node.color || undefined,
bgcolor: node.bgcolor || undefined,
resizable: node.resizable,
shape: node.shape
}
}

View File

@@ -1,3 +1,4 @@
/// <reference types="@webgpu/types" />
import { ref, watch, nextTick, onUnmounted } from 'vue'
import QuickLRU from '@alloc/quick-lru'
import { debounce } from 'es-toolkit/compat'
@@ -233,6 +234,128 @@ export function useBrushDrawing(initialSettings?: {
}
)
const isRecreatingTextures = ref(false)
watch(
() => store.gpuTexturesNeedRecreation,
async (needsRecreation) => {
if (
!needsRecreation ||
!device ||
!store.maskCanvas ||
isRecreatingTextures.value
)
return
isRecreatingTextures.value = true
const width = store.gpuTextureWidth
const height = store.gpuTextureHeight
try {
// Destroy old textures
if (maskTexture) {
maskTexture.destroy()
maskTexture = null
}
if (rgbTexture) {
rgbTexture.destroy()
rgbTexture = null
}
// Create new textures with updated dimensions
maskTexture = device.createTexture({
size: [width, height],
format: 'rgba8unorm',
usage:
GPUTextureUsage.TEXTURE_BINDING |
GPUTextureUsage.STORAGE_BINDING |
GPUTextureUsage.RENDER_ATTACHMENT |
GPUTextureUsage.COPY_DST |
GPUTextureUsage.COPY_SRC
})
rgbTexture = device.createTexture({
size: [width, height],
format: 'rgba8unorm',
usage:
GPUTextureUsage.TEXTURE_BINDING |
GPUTextureUsage.STORAGE_BINDING |
GPUTextureUsage.RENDER_ATTACHMENT |
GPUTextureUsage.COPY_DST |
GPUTextureUsage.COPY_SRC
})
// Upload pending data if available
if (store.pendingGPUMaskData && store.pendingGPURgbData) {
device.queue.writeTexture(
{ texture: maskTexture },
store.pendingGPUMaskData,
{ bytesPerRow: width * 4 },
{ width, height }
)
device.queue.writeTexture(
{ texture: rgbTexture },
store.pendingGPURgbData,
{ bytesPerRow: width * 4 },
{ width, height }
)
} else {
// Fallback: read from canvas
await updateGPUFromCanvas()
}
// Update preview canvas if it exists
if (previewCanvas && renderer) {
previewCanvas.width = width
previewCanvas.height = height
}
// Recreate readback buffers with new size
const bufferSize = width * height * 4
if (currentBufferSize !== bufferSize) {
readbackStorageMask?.destroy()
readbackStorageRgb?.destroy()
readbackStagingMask?.destroy()
readbackStagingRgb?.destroy()
readbackStorageMask = device.createBuffer({
size: bufferSize,
usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_SRC
})
readbackStorageRgb = device.createBuffer({
size: bufferSize,
usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_SRC
})
readbackStagingMask = device.createBuffer({
size: bufferSize,
usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ
})
readbackStagingRgb = device.createBuffer({
size: bufferSize,
usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ
})
currentBufferSize = bufferSize
}
} catch (error) {
console.error(
'[useBrushDrawing] Failed to recreate GPU textures:',
error
)
} finally {
// Clear the recreation flag and pending data
store.gpuTexturesNeedRecreation = false
store.gpuTextureWidth = 0
store.gpuTextureHeight = 0
store.pendingGPUMaskData = null
store.pendingGPURgbData = null
isRecreatingTextures.value = false
}
}
)
// Cleanup GPU resources on unmount
onUnmounted(() => {
if (renderer) {

View File

@@ -2,23 +2,70 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick } from 'vue'
import { useCanvasHistory } from '@/composables/maskeditor/useCanvasHistory'
let mockMaskCanvas: any
let mockRgbCanvas: any
let mockMaskCtx: any
let mockRgbCtx: any
// Define the store shape to avoid 'any' and cast to the expected type
interface MaskEditorStoreState {
maskCanvas: HTMLCanvasElement | null
rgbCanvas: HTMLCanvasElement | null
imgCanvas: HTMLCanvasElement | null
maskCtx: CanvasRenderingContext2D | null
rgbCtx: CanvasRenderingContext2D | null
imgCtx: CanvasRenderingContext2D | null
}
const mockStore = {
maskCanvas: null as any,
rgbCanvas: null as any,
maskCtx: null as any,
rgbCtx: null as any
// Use vi.hoisted to create isolated mock state container
const mockRefs = vi.hoisted(() => ({
maskCanvas: null as HTMLCanvasElement | null,
rgbCanvas: null as HTMLCanvasElement | null,
imgCanvas: null as HTMLCanvasElement | null,
maskCtx: null as CanvasRenderingContext2D | null,
rgbCtx: null as CanvasRenderingContext2D | null,
imgCtx: null as CanvasRenderingContext2D | null
}))
const mockStore: MaskEditorStoreState = {
get maskCanvas() {
return mockRefs.maskCanvas
},
set maskCanvas(val) {
mockRefs.maskCanvas = val
},
get rgbCanvas() {
return mockRefs.rgbCanvas
},
set rgbCanvas(val) {
mockRefs.rgbCanvas = val
},
get imgCanvas() {
return mockRefs.imgCanvas
},
set imgCanvas(val) {
mockRefs.imgCanvas = val
},
get maskCtx() {
return mockRefs.maskCtx
},
set maskCtx(val) {
mockRefs.maskCtx = val
},
get rgbCtx() {
return mockRefs.rgbCtx
},
set rgbCtx(val) {
mockRefs.rgbCtx = val
},
get imgCtx() {
return mockRefs.imgCtx
},
set imgCtx(val) {
mockRefs.imgCtx = val
}
}
vi.mock('@/stores/maskEditorStore', () => ({
useMaskEditorStore: vi.fn(() => mockStore)
}))
// Mock ImageBitmap for test environment
// Mock ImageBitmap using safe global augmentation pattern
if (typeof globalThis.ImageBitmap === 'undefined') {
globalThis.ImageBitmap = class ImageBitmap {
width: number
@@ -28,7 +75,7 @@ if (typeof globalThis.ImageBitmap === 'undefined') {
this.height = height
}
close() {}
} as any
} as unknown as typeof globalThis.ImageBitmap
}
describe('useCanvasHistory', () => {
@@ -43,9 +90,8 @@ describe('useCanvasHistory', () => {
return rafCallCount
}
)
vi.stubGlobal('alert', () => {})
const createMockImageData = () => {
const createMockImageData = (): ImageData => {
return {
data: new Uint8ClampedArray(100 * 100 * 4),
width: 100,
@@ -53,34 +99,43 @@ describe('useCanvasHistory', () => {
} as ImageData
}
mockMaskCtx = {
// Mock contexts using explicit partial-cast pattern
mockRefs.maskCtx = {
getImageData: vi.fn(() => createMockImageData()),
putImageData: vi.fn(),
clearRect: vi.fn(),
drawImage: vi.fn()
}
} as Partial<CanvasRenderingContext2D> as CanvasRenderingContext2D
mockRgbCtx = {
mockRefs.rgbCtx = {
getImageData: vi.fn(() => createMockImageData()),
putImageData: vi.fn(),
clearRect: vi.fn(),
drawImage: vi.fn()
}
} as Partial<CanvasRenderingContext2D> as CanvasRenderingContext2D
mockMaskCanvas = {
mockRefs.imgCtx = {
getImageData: vi.fn(() => createMockImageData()),
putImageData: vi.fn(),
clearRect: vi.fn(),
drawImage: vi.fn()
} as Partial<CanvasRenderingContext2D> as CanvasRenderingContext2D
// Mock canvases using explicit partial-cast pattern
mockRefs.maskCanvas = {
width: 100,
height: 100
}
} as Partial<HTMLCanvasElement> as HTMLCanvasElement
mockRgbCanvas = {
mockRefs.rgbCanvas = {
width: 100,
height: 100
}
} as Partial<HTMLCanvasElement> as HTMLCanvasElement
mockStore.maskCanvas = mockMaskCanvas
mockStore.rgbCanvas = mockRgbCanvas
mockStore.maskCtx = mockMaskCtx
mockStore.rgbCtx = mockRgbCtx
mockRefs.imgCanvas = {
width: 100,
height: 100
} as Partial<HTMLCanvasElement> as HTMLCanvasElement
})
describe('initialization', () => {
@@ -96,8 +151,14 @@ describe('useCanvasHistory', () => {
history.saveInitialState()
expect(mockMaskCtx.getImageData).toHaveBeenCalledWith(0, 0, 100, 100)
expect(mockRgbCtx.getImageData).toHaveBeenCalledWith(0, 0, 100, 100)
expect(mockRefs.maskCtx!.getImageData).toHaveBeenCalledWith(
0,
0,
100,
100
)
expect(mockRefs.rgbCtx!.getImageData).toHaveBeenCalledWith(0, 0, 100, 100)
expect(mockRefs.imgCtx!.getImageData).toHaveBeenCalledWith(0, 0, 100, 100)
expect(history.canUndo.value).toBe(false)
expect(history.canRedo.value).toBe(false)
})
@@ -105,27 +166,47 @@ describe('useCanvasHistory', () => {
it('should wait for canvas to be ready', () => {
const rafSpy = vi.spyOn(window, 'requestAnimationFrame')
mockStore.maskCanvas = { ...mockMaskCanvas, width: 0, height: 0 }
mockRefs.maskCanvas = {
...mockRefs.maskCanvas,
width: 0,
height: 0
} as Partial<HTMLCanvasElement> as HTMLCanvasElement
const history = useCanvasHistory()
history.saveInitialState()
expect(rafSpy).toHaveBeenCalled()
mockStore.maskCanvas = mockMaskCanvas
mockRefs.maskCanvas = {
width: 100,
height: 100
} as Partial<HTMLCanvasElement> as HTMLCanvasElement
})
it('should wait for context to be ready', () => {
const rafSpy = vi.spyOn(window, 'requestAnimationFrame')
mockStore.maskCtx = null
mockRefs.maskCtx = null
const history = useCanvasHistory()
history.saveInitialState()
expect(rafSpy).toHaveBeenCalled()
mockStore.maskCtx = mockMaskCtx
const createMockImageData = (): ImageData => {
return {
data: new Uint8ClampedArray(100 * 100 * 4),
width: 100,
height: 100
} as ImageData
}
mockRefs.maskCtx = {
getImageData: vi.fn(() => createMockImageData()),
putImageData: vi.fn(),
clearRect: vi.fn(),
drawImage: vi.fn()
} as Partial<CanvasRenderingContext2D> as CanvasRenderingContext2D
})
})
@@ -134,13 +215,20 @@ describe('useCanvasHistory', () => {
const history = useCanvasHistory()
history.saveInitialState()
mockMaskCtx.getImageData.mockClear()
mockRgbCtx.getImageData.mockClear()
vi.mocked(mockRefs.maskCtx!.getImageData).mockClear()
vi.mocked(mockRefs.rgbCtx!.getImageData).mockClear()
vi.mocked(mockRefs.imgCtx!.getImageData).mockClear()
history.saveState()
expect(mockMaskCtx.getImageData).toHaveBeenCalledWith(0, 0, 100, 100)
expect(mockRgbCtx.getImageData).toHaveBeenCalledWith(0, 0, 100, 100)
expect(mockRefs.maskCtx!.getImageData).toHaveBeenCalledWith(
0,
0,
100,
100
)
expect(mockRefs.rgbCtx!.getImageData).toHaveBeenCalledWith(0, 0, 100, 100)
expect(mockRefs.imgCtx!.getImageData).toHaveBeenCalledWith(0, 0, 100, 100)
expect(history.canUndo.value).toBe(true)
})
@@ -184,8 +272,9 @@ describe('useCanvasHistory', () => {
history.saveState()
expect(mockMaskCtx.getImageData).toHaveBeenCalled()
expect(mockRgbCtx.getImageData).toHaveBeenCalled()
expect(mockRefs.maskCtx!.getImageData).toHaveBeenCalled()
expect(mockRefs.rgbCtx!.getImageData).toHaveBeenCalled()
expect(mockRefs.imgCtx!.getImageData).toHaveBeenCalled()
})
it('should not save state if context is missing', () => {
@@ -193,15 +282,17 @@ describe('useCanvasHistory', () => {
history.saveInitialState()
mockStore.maskCtx = null
mockMaskCtx.getImageData.mockClear()
mockRgbCtx.getImageData.mockClear()
const savedMaskCtx = mockRefs.maskCtx
mockRefs.maskCtx = null
vi.mocked(savedMaskCtx!.getImageData).mockClear()
vi.mocked(mockRefs.rgbCtx!.getImageData).mockClear()
vi.mocked(mockRefs.imgCtx!.getImageData).mockClear()
history.saveState()
expect(mockMaskCtx.getImageData).not.toHaveBeenCalled()
expect(savedMaskCtx!.getImageData).not.toHaveBeenCalled()
mockStore.maskCtx = mockMaskCtx
mockRefs.maskCtx = savedMaskCtx
})
})
@@ -214,20 +305,27 @@ describe('useCanvasHistory', () => {
history.undo()
expect(mockMaskCtx.putImageData).toHaveBeenCalled()
expect(mockRgbCtx.putImageData).toHaveBeenCalled()
expect(mockRefs.maskCtx!.putImageData).toHaveBeenCalled()
expect(mockRefs.rgbCtx!.putImageData).toHaveBeenCalled()
expect(mockRefs.imgCtx!.putImageData).toHaveBeenCalled()
expect(history.canUndo.value).toBe(false)
expect(history.canRedo.value).toBe(true)
})
it('should show alert when no undo states available', () => {
const alertSpy = vi.spyOn(window, 'alert')
it('should not undo when no undo states available', () => {
const history = useCanvasHistory()
history.saveInitialState()
vi.mocked(mockRefs.maskCtx!.putImageData).mockClear()
vi.mocked(mockRefs.rgbCtx!.putImageData).mockClear()
vi.mocked(mockRefs.imgCtx!.putImageData).mockClear()
history.undo()
expect(alertSpy).toHaveBeenCalledWith('No more undo states available')
expect(mockRefs.maskCtx!.putImageData).not.toHaveBeenCalled()
expect(mockRefs.rgbCtx!.putImageData).not.toHaveBeenCalled()
expect(mockRefs.imgCtx!.putImageData).not.toHaveBeenCalled()
})
it('should undo multiple times', () => {
@@ -249,16 +347,22 @@ describe('useCanvasHistory', () => {
})
it('should not undo beyond first state', () => {
const alertSpy = vi.spyOn(window, 'alert')
const history = useCanvasHistory()
history.saveInitialState()
history.saveState()
history.undo()
vi.mocked(mockRefs.maskCtx!.putImageData).mockClear()
vi.mocked(mockRefs.rgbCtx!.putImageData).mockClear()
vi.mocked(mockRefs.imgCtx!.putImageData).mockClear()
history.undo()
expect(alertSpy).toHaveBeenCalled()
expect(mockRefs.maskCtx!.putImageData).not.toHaveBeenCalled()
expect(mockRefs.rgbCtx!.putImageData).not.toHaveBeenCalled()
expect(mockRefs.imgCtx!.putImageData).not.toHaveBeenCalled()
})
})
@@ -270,25 +374,33 @@ describe('useCanvasHistory', () => {
history.saveState()
history.undo()
mockMaskCtx.putImageData.mockClear()
mockRgbCtx.putImageData.mockClear()
vi.mocked(mockRefs.maskCtx!.putImageData).mockClear()
vi.mocked(mockRefs.rgbCtx!.putImageData).mockClear()
vi.mocked(mockRefs.imgCtx!.putImageData).mockClear()
history.redo()
expect(mockMaskCtx.putImageData).toHaveBeenCalled()
expect(mockRgbCtx.putImageData).toHaveBeenCalled()
expect(mockRefs.maskCtx!.putImageData).toHaveBeenCalled()
expect(mockRefs.rgbCtx!.putImageData).toHaveBeenCalled()
expect(mockRefs.imgCtx!.putImageData).toHaveBeenCalled()
expect(history.canRedo.value).toBe(false)
expect(history.canUndo.value).toBe(true)
})
it('should show alert when no redo states available', () => {
const alertSpy = vi.spyOn(window, 'alert')
it('should not redo when no redo states available', () => {
const history = useCanvasHistory()
history.saveInitialState()
vi.mocked(mockRefs.maskCtx!.putImageData).mockClear()
vi.mocked(mockRefs.rgbCtx!.putImageData).mockClear()
vi.mocked(mockRefs.imgCtx!.putImageData).mockClear()
history.redo()
expect(alertSpy).toHaveBeenCalledWith('No more redo states available')
expect(mockRefs.maskCtx!.putImageData).not.toHaveBeenCalled()
expect(mockRefs.rgbCtx!.putImageData).not.toHaveBeenCalled()
expect(mockRefs.imgCtx!.putImageData).not.toHaveBeenCalled()
})
it('should redo multiple times', () => {
@@ -314,7 +426,6 @@ describe('useCanvasHistory', () => {
})
it('should not redo beyond last state', () => {
const alertSpy = vi.spyOn(window, 'alert')
const history = useCanvasHistory()
history.saveInitialState()
@@ -322,9 +433,16 @@ describe('useCanvasHistory', () => {
history.undo()
history.redo()
vi.mocked(mockRefs.maskCtx!.putImageData).mockClear()
vi.mocked(mockRefs.rgbCtx!.putImageData).mockClear()
vi.mocked(mockRefs.imgCtx!.putImageData).mockClear()
history.redo()
expect(alertSpy).toHaveBeenCalled()
expect(mockRefs.maskCtx!.putImageData).not.toHaveBeenCalled()
expect(mockRefs.rgbCtx!.putImageData).not.toHaveBeenCalled()
expect(mockRefs.imgCtx!.putImageData).not.toHaveBeenCalled()
})
})
@@ -348,13 +466,15 @@ describe('useCanvasHistory', () => {
history.saveInitialState()
history.clearStates()
mockMaskCtx.getImageData.mockClear()
mockRgbCtx.getImageData.mockClear()
vi.mocked(mockRefs.maskCtx!.getImageData).mockClear()
vi.mocked(mockRefs.rgbCtx!.getImageData).mockClear()
vi.mocked(mockRefs.imgCtx!.getImageData).mockClear()
history.saveInitialState()
expect(mockMaskCtx.getImageData).toHaveBeenCalled()
expect(mockRgbCtx.getImageData).toHaveBeenCalled()
expect(mockRefs.maskCtx!.getImageData).toHaveBeenCalled()
expect(mockRefs.rgbCtx!.getImageData).toHaveBeenCalled()
expect(mockRefs.imgCtx!.getImageData).toHaveBeenCalled()
})
})
@@ -446,15 +566,17 @@ describe('useCanvasHistory', () => {
history.saveInitialState()
history.saveState()
mockStore.maskCtx = null
mockMaskCtx.putImageData.mockClear()
mockRgbCtx.putImageData.mockClear()
const savedMaskCtx = mockRefs.maskCtx
mockRefs.maskCtx = null
vi.mocked(savedMaskCtx!.putImageData).mockClear()
vi.mocked(mockRefs.rgbCtx!.putImageData).mockClear()
vi.mocked(mockRefs.imgCtx!.putImageData).mockClear()
history.undo()
expect(mockMaskCtx.putImageData).not.toHaveBeenCalled()
expect(savedMaskCtx!.putImageData).not.toHaveBeenCalled()
mockStore.maskCtx = mockMaskCtx
mockRefs.maskCtx = savedMaskCtx
})
})
@@ -499,8 +621,12 @@ describe('useCanvasHistory', () => {
})
it('should handle zero-sized canvas', () => {
mockMaskCanvas.width = 0
mockMaskCanvas.height = 0
if (mockRefs.maskCanvas) {
mockRefs.maskCanvas = {
width: 0,
height: 0
} as Partial<HTMLCanvasElement> as HTMLCanvasElement
}
const history = useCanvasHistory()

View File

@@ -1,12 +1,17 @@
import { ref, computed } from 'vue'
import { useMaskEditorStore } from '@/stores/maskEditorStore'
// Define the state interface for better readability
interface CanvasState {
mask: ImageData | ImageBitmap
rgb: ImageData | ImageBitmap
img: ImageData | ImageBitmap
}
export function useCanvasHistory(maxStates = 20) {
const store = useMaskEditorStore()
const states = ref<
{ mask: ImageData | ImageBitmap; rgb: ImageData | ImageBitmap }[]
>([])
const states = ref<CanvasState[]>([])
const currentStateIndex = ref(-1)
const initialized = ref(false)
@@ -22,22 +27,29 @@ export function useCanvasHistory(maxStates = 20) {
})
const saveInitialState = () => {
const maskCtx = store.maskCtx
const rgbCtx = store.rgbCtx
const maskCanvas = store.maskCanvas
const rgbCanvas = store.rgbCanvas
const { maskCtx, rgbCtx, imgCtx, maskCanvas, rgbCanvas, imgCanvas } = store
if (!maskCtx || !rgbCtx || !maskCanvas || !rgbCanvas) {
// Ensure all 3 contexts and canvases are ready
if (
!maskCtx ||
!rgbCtx ||
!imgCtx ||
!maskCanvas ||
!rgbCanvas ||
!imgCanvas
) {
requestAnimationFrame(saveInitialState)
return
}
if (!maskCanvas.width || !rgbCanvas.width) {
if (!maskCanvas.width || !rgbCanvas.width || !imgCanvas.width) {
requestAnimationFrame(saveInitialState)
return
}
states.value = []
// Capture all three layers
const maskState = maskCtx.getImageData(
0,
0,
@@ -50,35 +62,51 @@ export function useCanvasHistory(maxStates = 20) {
rgbCanvas.width,
rgbCanvas.height
)
states.value.push({ mask: maskState, rgb: rgbState })
const imgState = imgCtx.getImageData(
0,
0,
imgCanvas.width,
imgCanvas.height
)
states.value.push({ mask: maskState, rgb: rgbState, img: imgState })
currentStateIndex.value = 0
initialized.value = true
}
const saveState = (
providedMaskData?: ImageData | ImageBitmap,
providedRgbData?: ImageData | ImageBitmap
providedRgbData?: ImageData | ImageBitmap,
providedImgData?: ImageData | ImageBitmap
) => {
const maskCtx = store.maskCtx
const rgbCtx = store.rgbCtx
const maskCanvas = store.maskCanvas
const rgbCanvas = store.rgbCanvas
const { maskCtx, rgbCtx, imgCtx, maskCanvas, rgbCanvas, imgCanvas } = store
if (!maskCtx || !rgbCtx || !maskCanvas || !rgbCanvas) return
if (
!maskCtx ||
!rgbCtx ||
!imgCtx ||
!maskCanvas ||
!rgbCanvas ||
!imgCanvas
)
return
if (!initialized.value || currentStateIndex.value === -1) {
saveInitialState()
return
}
// Clear redo history
states.value = states.value.slice(0, currentStateIndex.value + 1)
let maskState: ImageData | ImageBitmap
let rgbState: ImageData | ImageBitmap
let imgState: ImageData | ImageBitmap
if (providedMaskData && providedRgbData) {
if (providedMaskData && providedRgbData && providedImgData) {
maskState = providedMaskData
rgbState = providedRgbData
imgState = providedImgData
} else {
maskState = maskCtx.getImageData(
0,
@@ -87,71 +115,84 @@ export function useCanvasHistory(maxStates = 20) {
maskCanvas.height
)
rgbState = rgbCtx.getImageData(0, 0, rgbCanvas.width, rgbCanvas.height)
imgState = imgCtx.getImageData(0, 0, imgCanvas.width, imgCanvas.height)
}
states.value.push({ mask: maskState, rgb: rgbState })
states.value.push({ mask: maskState, rgb: rgbState, img: imgState })
currentStateIndex.value++
// Maintain max history size and clean up memory
if (states.value.length > maxStates) {
const removed = states.value.shift()
// Cleanup ImageBitmaps to avoid memory leaks
if (removed) {
if (removed.mask instanceof ImageBitmap) removed.mask.close()
if (removed.rgb instanceof ImageBitmap) removed.rgb.close()
cleanupState(removed)
}
currentStateIndex.value--
}
}
const undo = () => {
if (!canUndo.value) {
alert('No more undo states available')
return
}
if (!canUndo.value) return
currentStateIndex.value--
restoreState(states.value[currentStateIndex.value])
}
const redo = () => {
if (!canRedo.value) {
alert('No more redo states available')
return
}
if (!canRedo.value) return
currentStateIndex.value++
restoreState(states.value[currentStateIndex.value])
}
const restoreState = (state: {
mask: ImageData | ImageBitmap
rgb: ImageData | ImageBitmap
}) => {
const maskCtx = store.maskCtx
const rgbCtx = store.rgbCtx
if (!maskCtx || !rgbCtx) return
const restoreState = (state: CanvasState) => {
const { maskCtx, rgbCtx, imgCtx, maskCanvas, rgbCanvas, imgCanvas } = store
if (
!maskCtx ||
!rgbCtx ||
!imgCtx ||
!maskCanvas ||
!rgbCanvas ||
!imgCanvas
)
return
if (state.mask instanceof ImageBitmap) {
maskCtx.clearRect(0, 0, state.mask.width, state.mask.height)
maskCtx.drawImage(state.mask, 0, 0)
} else {
maskCtx.putImageData(state.mask, 0, 0)
// Update canvas dimensions to match state (handles rotation undo/redo)
const refData = state.mask
const newWidth = refData.width
const newHeight = refData.height
if (maskCanvas.width !== newWidth || maskCanvas.height !== newHeight) {
maskCanvas.width = newWidth
maskCanvas.height = newHeight
rgbCanvas.width = newWidth
rgbCanvas.height = newHeight
imgCanvas.width = newWidth
imgCanvas.height = newHeight
}
if (state.rgb instanceof ImageBitmap) {
rgbCtx.clearRect(0, 0, state.rgb.width, state.rgb.height)
rgbCtx.drawImage(state.rgb, 0, 0)
} else {
rgbCtx.putImageData(state.rgb, 0, 0)
}
const layers = [
{ ctx: maskCtx, data: state.mask },
{ ctx: rgbCtx, data: state.rgb },
{ ctx: imgCtx, data: state.img }
]
layers.forEach(({ ctx, data }) => {
if (data instanceof ImageBitmap) {
ctx.clearRect(0, 0, data.width, data.height)
ctx.drawImage(data, 0, 0)
} else {
ctx.putImageData(data, 0, 0)
}
})
}
const cleanupState = (state: CanvasState) => {
if (state.mask instanceof ImageBitmap) state.mask.close()
if (state.rgb instanceof ImageBitmap) state.rgb.close()
if (state.img instanceof ImageBitmap) state.img.close()
}
const clearStates = () => {
// Cleanup bitmaps
states.value.forEach((state) => {
if (state.mask instanceof ImageBitmap) state.mask.close()
if (state.rgb instanceof ImageBitmap) state.rgb.close()
})
states.value.forEach(cleanupState)
states.value = []
currentStateIndex.value = -1
initialized.value = false

View File

@@ -0,0 +1,683 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useCanvasTransform } from '@/composables/maskeditor/useCanvasTransform'
interface IMockCanvas {
width: number
height: number
}
interface IMockContext {
getImageData: ReturnType<typeof vi.fn>
putImageData: ReturnType<typeof vi.fn>
clearRect: ReturnType<typeof vi.fn>
drawImage: ReturnType<typeof vi.fn>
}
interface IMockCanvasHistory {
saveState: ReturnType<typeof vi.fn>
}
interface IMockStore {
maskCanvas: IMockCanvas | null
rgbCanvas: IMockCanvas | null
imgCanvas: IMockCanvas | null
maskCtx: IMockContext | null
rgbCtx: IMockContext | null
imgCtx: IMockContext | null
tgpuRoot: unknown
canvasHistory: IMockCanvasHistory
gpuTexturesNeedRecreation: boolean
gpuTextureWidth: number
gpuTextureHeight: number
pendingGPUMaskData: Uint8ClampedArray | null
pendingGPURgbData: Uint8ClampedArray | null
}
const { mockStore, mockCanvasHistory } = vi.hoisted(() => {
const mockCanvasHistory: IMockCanvasHistory = {
saveState: vi.fn()
}
const mockStore: IMockStore = {
maskCanvas: null,
rgbCanvas: null,
imgCanvas: null,
maskCtx: null,
rgbCtx: null,
imgCtx: null,
tgpuRoot: null,
canvasHistory: mockCanvasHistory,
gpuTexturesNeedRecreation: false,
gpuTextureWidth: 0,
gpuTextureHeight: 0,
pendingGPUMaskData: null,
pendingGPURgbData: null
}
return { mockStore, mockCanvasHistory }
})
vi.mock('@/stores/maskEditorStore', () => ({
useMaskEditorStore: vi.fn(() => mockStore)
}))
// Mock ImageData with improved type safety
if (typeof globalThis.ImageData === 'undefined') {
globalThis.ImageData = class ImageData {
data: Uint8ClampedArray
width: number
height: number
constructor(
dataOrWidth: Uint8ClampedArray | number,
widthOrHeight?: number,
height?: number
) {
if (dataOrWidth instanceof Uint8ClampedArray) {
// Constructor overload: new ImageData(data, width, height)
if (widthOrHeight === undefined || height === undefined) {
throw new Error(
'ImageData constructor requires width and height when data is provided'
)
}
this.data = dataOrWidth
this.width = widthOrHeight
this.height = height
} else {
// Constructor overload: new ImageData(width, height)
if (widthOrHeight === undefined) {
throw new Error(
'ImageData constructor requires height when width is provided'
)
}
this.width = dataOrWidth
this.height = widthOrHeight
this.data = new Uint8ClampedArray(dataOrWidth * widthOrHeight * 4)
}
}
} as unknown as typeof globalThis.ImageData
}
// Mock ImageBitmap for test environment using safe type casting
if (typeof globalThis.ImageBitmap === 'undefined') {
globalThis.ImageBitmap = class ImageBitmap {
width: number
height: number
constructor(width = 100, height = 100) {
this.width = width
this.height = height
}
close() {}
} as unknown as typeof globalThis.ImageBitmap
}
describe('useCanvasTransform', () => {
let mockMaskCanvas: IMockCanvas
let mockRgbCanvas: IMockCanvas
let mockImgCanvas: IMockCanvas
let mockMaskCtx: IMockContext
let mockRgbCtx: IMockContext
let mockImgCtx: IMockContext
beforeEach(() => {
vi.clearAllMocks()
const createMockImageData = (width: number, height: number) => {
const data = new Uint8ClampedArray(width * height * 4)
for (let i = 0; i < data.length; i += 4) {
data[i] = 255 // R
data[i + 1] = 0 // G
data[i + 2] = 0 // B
data[i + 3] = 255 // A
}
return {
data,
width,
height
} as ImageData
}
mockMaskCtx = {
getImageData: vi.fn((_x, _y, w, h) => createMockImageData(w, h)),
putImageData: vi.fn(),
clearRect: vi.fn(),
drawImage: vi.fn()
}
mockRgbCtx = {
getImageData: vi.fn((_x, _y, w, h) => createMockImageData(w, h)),
putImageData: vi.fn(),
clearRect: vi.fn(),
drawImage: vi.fn()
}
mockImgCtx = {
getImageData: vi.fn((_x, _y, w, h) => createMockImageData(w, h)),
putImageData: vi.fn(),
clearRect: vi.fn(),
drawImage: vi.fn()
}
mockMaskCanvas = {
width: 100,
height: 50
}
mockRgbCanvas = {
width: 100,
height: 50
}
mockImgCanvas = {
width: 100,
height: 50
}
mockStore.maskCanvas = mockMaskCanvas
mockStore.rgbCanvas = mockRgbCanvas
mockStore.imgCanvas = mockImgCanvas
mockStore.maskCtx = mockMaskCtx
mockStore.rgbCtx = mockRgbCtx
mockStore.imgCtx = mockImgCtx
mockStore.tgpuRoot = null
mockStore.gpuTexturesNeedRecreation = false
mockStore.gpuTextureWidth = 0
mockStore.gpuTextureHeight = 0
mockStore.pendingGPUMaskData = null
mockStore.pendingGPURgbData = null
})
describe('rotateClockwise', () => {
it('should rotate canvas 90 degrees clockwise', async () => {
const transform = useCanvasTransform()
await transform.rotateClockwise()
expect(mockMaskCanvas.width).toBe(50)
expect(mockMaskCanvas.height).toBe(100)
expect(mockRgbCanvas.width).toBe(50)
expect(mockRgbCanvas.height).toBe(100)
expect(mockImgCanvas.width).toBe(50)
expect(mockImgCanvas.height).toBe(100)
})
it('should call getImageData with original dimensions', async () => {
const transform = useCanvasTransform()
await transform.rotateClockwise()
expect(mockMaskCtx.getImageData).toHaveBeenCalledWith(0, 0, 100, 50)
expect(mockRgbCtx.getImageData).toHaveBeenCalledWith(0, 0, 100, 50)
expect(mockImgCtx.getImageData).toHaveBeenCalledWith(0, 0, 100, 50)
})
it('should call putImageData with rotated data', async () => {
const transform = useCanvasTransform()
await transform.rotateClockwise()
expect(mockMaskCtx.putImageData).toHaveBeenCalled()
expect(mockRgbCtx.putImageData).toHaveBeenCalled()
expect(mockImgCtx.putImageData).toHaveBeenCalled()
const maskCall = mockMaskCtx.putImageData.mock.calls[0][0]
expect(maskCall.width).toBe(50)
expect(maskCall.height).toBe(100)
})
it('should save transformed state to history', async () => {
const transform = useCanvasTransform()
await transform.rotateClockwise()
expect(mockCanvasHistory.saveState).toHaveBeenCalled()
const savedArgs = mockCanvasHistory.saveState.mock.calls[0]
expect(savedArgs).toHaveLength(3)
expect(savedArgs[0].width).toBe(50)
expect(savedArgs[0].height).toBe(100)
expect(savedArgs[1].width).toBe(50)
expect(savedArgs[1].height).toBe(100)
expect(savedArgs[2].width).toBe(50)
expect(savedArgs[2].height).toBe(100)
})
it('should log error when canvas contexts not ready', async () => {
const consoleErrorSpy = vi
.spyOn(console, 'error')
.mockImplementation(() => {})
mockStore.maskCanvas = null
const transform = useCanvasTransform()
await transform.rotateClockwise()
expect(consoleErrorSpy).toHaveBeenCalledWith(
'[useCanvasTransform] Canvas contexts not ready'
)
consoleErrorSpy.mockRestore()
})
it('should handle GPU texture recreation when GPU is active', async () => {
mockStore.tgpuRoot = {}
const transform = useCanvasTransform()
await transform.rotateClockwise()
expect(mockStore.gpuTexturesNeedRecreation).toBe(true)
expect(mockStore.gpuTextureWidth).toBe(50)
expect(mockStore.gpuTextureHeight).toBe(100)
})
it('should not recreate GPU textures when GPU is inactive', async () => {
mockStore.tgpuRoot = null
const transform = useCanvasTransform()
await transform.rotateClockwise()
expect(mockStore.gpuTexturesNeedRecreation).toBe(false)
})
it('should correctly rotate pixels clockwise at pixel level', async () => {
mockMaskCanvas.width = 2
mockMaskCanvas.height = 2
const createTestPattern = () => {
const data = new Uint8ClampedArray(2 * 2 * 4)
// TL (0,0): Red
data[0] = 255
data[1] = 0
data[2] = 0
data[3] = 255
// TR (1,0): Green
data[4] = 0
data[5] = 255
data[6] = 0
data[7] = 255
// BL (0,1): Blue
data[8] = 0
data[9] = 0
data[10] = 255
data[11] = 255
// BR (1,1): Yellow
data[12] = 255
data[13] = 255
data[14] = 0
data[15] = 255
return { data, width: 2, height: 2 } as ImageData
}
mockMaskCtx.getImageData = vi.fn(() => createTestPattern())
const transform = useCanvasTransform()
await transform.rotateClockwise()
const result = mockMaskCtx.putImageData.mock.calls[0][0] as ImageData
// After clockwise rotation:
// New TL should be old BL (Blue)
expect(result.data[0]).toBe(0) // R
expect(result.data[1]).toBe(0) // G
expect(result.data[2]).toBe(255) // B
expect(result.data[3]).toBe(255) // A
// New TR should be old TL (Red)
expect(result.data[4]).toBe(255) // R
expect(result.data[5]).toBe(0) // G
expect(result.data[6]).toBe(0) // B
expect(result.data[7]).toBe(255) // A
// New BL should be old BR (Yellow)
expect(result.data[8]).toBe(255) // R
expect(result.data[9]).toBe(255) // G
expect(result.data[10]).toBe(0) // B
expect(result.data[11]).toBe(255) // A
// New BR should be old TR (Green)
expect(result.data[12]).toBe(0) // R
expect(result.data[13]).toBe(255) // G
expect(result.data[14]).toBe(0) // B
expect(result.data[15]).toBe(255) // A
})
})
describe('rotateCounterclockwise', () => {
it('should rotate canvas 90 degrees counterclockwise', async () => {
const transform = useCanvasTransform()
await transform.rotateCounterclockwise()
expect(mockMaskCanvas.width).toBe(50)
expect(mockMaskCanvas.height).toBe(100)
})
it('should call getImageData with original dimensions', async () => {
const transform = useCanvasTransform()
await transform.rotateCounterclockwise()
expect(mockMaskCtx.getImageData).toHaveBeenCalledWith(0, 0, 100, 50)
})
it('should correctly rotate pixels counterclockwise at pixel level', async () => {
mockMaskCanvas.width = 2
mockMaskCanvas.height = 2
const createTestPattern = () => {
const data = new Uint8ClampedArray(2 * 2 * 4)
// TL (0,0): Red
data[0] = 255
data[1] = 0
data[2] = 0
data[3] = 255
// TR (1,0): Green
data[4] = 0
data[5] = 255
data[6] = 0
data[7] = 255
// BL (0,1): Blue
data[8] = 0
data[9] = 0
data[10] = 255
data[11] = 255
// BR (1,1): Yellow
data[12] = 255
data[13] = 255
data[14] = 0
data[15] = 255
return { data, width: 2, height: 2 } as ImageData
}
mockMaskCtx.getImageData = vi.fn(() => createTestPattern())
const transform = useCanvasTransform()
await transform.rotateCounterclockwise()
const result = mockMaskCtx.putImageData.mock.calls[0][0] as ImageData
// After counterclockwise rotation:
// New TL should be old TR (Green)
expect(result.data[0]).toBe(0) // R
expect(result.data[1]).toBe(255) // G
expect(result.data[2]).toBe(0) // B
expect(result.data[3]).toBe(255) // A
// New TR should be old BR (Yellow)
expect(result.data[4]).toBe(255) // R
expect(result.data[5]).toBe(255) // G
expect(result.data[6]).toBe(0) // B
expect(result.data[7]).toBe(255) // A
// New BL should be old TL (Red)
expect(result.data[8]).toBe(255) // R
expect(result.data[9]).toBe(0) // G
expect(result.data[10]).toBe(0) // B
expect(result.data[11]).toBe(255) // A
// New BR should be old BL (Blue)
expect(result.data[12]).toBe(0) // R
expect(result.data[13]).toBe(0) // G
expect(result.data[14]).toBe(255) // B
expect(result.data[15]).toBe(255) // A
})
it('should produce different result than clockwise rotation', async () => {
const transform = useCanvasTransform()
const createAsymmetricImageData = (width: number, height: number) => {
const data = new Uint8ClampedArray(width * height * 4)
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
const i = (y * width + x) * 4
if (x < width / 2 && y < height / 2) {
data[i] = 255
data[i + 3] = 255
} else {
data[i + 3] = 255
}
}
}
return { data, width, height } as ImageData
}
mockMaskCtx.getImageData = vi.fn(() => createAsymmetricImageData(100, 50))
await transform.rotateCounterclockwise()
const ccwResult = mockMaskCtx.putImageData.mock.calls[0][0] as ImageData
mockMaskCanvas.width = 100
mockMaskCanvas.height = 50
mockMaskCtx.putImageData.mockClear()
mockMaskCtx.getImageData = vi.fn(() => createAsymmetricImageData(100, 50))
await transform.rotateClockwise()
const cwResult = mockMaskCtx.putImageData.mock.calls[0][0] as ImageData
let pixelDifferences = 0
for (let i = 0; i < ccwResult.data.length; i++) {
if (ccwResult.data[i] !== cwResult.data[i]) {
pixelDifferences++
}
}
expect(pixelDifferences).toBeGreaterThan(0)
})
})
describe('mirrorHorizontal', () => {
it('should mirror canvas horizontally', async () => {
const transform = useCanvasTransform()
await transform.mirrorHorizontal()
expect(mockMaskCanvas.width).toBe(100)
expect(mockMaskCanvas.height).toBe(50)
})
it('should handle GPU texture recreation when GPU is active', async () => {
mockStore.tgpuRoot = {}
const transform = useCanvasTransform()
await transform.mirrorHorizontal()
expect(mockStore.gpuTexturesNeedRecreation).toBe(true)
expect(mockStore.gpuTextureWidth).toBe(100)
expect(mockStore.gpuTextureHeight).toBe(50)
})
it('should correctly flip pixels horizontally at pixel level', async () => {
mockMaskCanvas.width = 2
mockMaskCanvas.height = 2
const createTestPattern = () => {
const data = new Uint8ClampedArray(2 * 2 * 4)
// TL (0,0): Red
data[0] = 255
data[1] = 0
data[2] = 0
data[3] = 255
// TR (1,0): Green
data[4] = 0
data[5] = 255
data[6] = 0
data[7] = 255
// BL (0,1): Blue
data[8] = 0
data[9] = 0
data[10] = 255
data[11] = 255
// BR (1,1): Yellow
data[12] = 255
data[13] = 255
data[14] = 0
data[15] = 255
return { data, width: 2, height: 2 } as ImageData
}
mockMaskCtx.getImageData = vi.fn(() => createTestPattern())
const transform = useCanvasTransform()
await transform.mirrorHorizontal()
const result = mockMaskCtx.putImageData.mock.calls[0][0] as ImageData
// After horizontal flip:
// New TL should be old TR (Green)
expect(result.data[0]).toBe(0)
expect(result.data[1]).toBe(255)
// New TR should be old TL (Red)
expect(result.data[4]).toBe(255)
expect(result.data[5]).toBe(0)
})
})
describe('mirrorVertical', () => {
it('should mirror canvas vertically', async () => {
const transform = useCanvasTransform()
await transform.mirrorVertical()
expect(mockMaskCanvas.width).toBe(100)
expect(mockMaskCanvas.height).toBe(50)
})
it('should handle GPU texture recreation when GPU is active', async () => {
mockStore.tgpuRoot = {}
const transform = useCanvasTransform()
await transform.mirrorVertical()
expect(mockStore.gpuTexturesNeedRecreation).toBe(true)
expect(mockStore.gpuTextureWidth).toBe(100)
expect(mockStore.gpuTextureHeight).toBe(50)
})
it('should correctly flip pixels vertically at pixel level', async () => {
mockMaskCanvas.width = 2
mockMaskCanvas.height = 2
const createTestPattern = () => {
const data = new Uint8ClampedArray(2 * 2 * 4)
// TL (0,0): Red
data[0] = 255
data[1] = 0
data[2] = 0
data[3] = 255
// TR (1,0): Green
data[4] = 0
data[5] = 255
data[6] = 0
data[7] = 255
// BL (0,1): Blue
data[8] = 0
data[9] = 0
data[10] = 255
data[11] = 255
// BR (1,1): Yellow
data[12] = 255
data[13] = 255
data[14] = 0
data[15] = 255
return { data, width: 2, height: 2 } as ImageData
}
mockMaskCtx.getImageData = vi.fn(() => createTestPattern())
const transform = useCanvasTransform()
await transform.mirrorVertical()
const result = mockMaskCtx.putImageData.mock.calls[0][0] as ImageData
// After vertical flip:
// New TL should be old BL (Blue)
expect(result.data[0]).toBe(0) // R
expect(result.data[1]).toBe(0) // G
expect(result.data[2]).toBe(255) // B
expect(result.data[3]).toBe(255) // A
// New TR should be old BR (Yellow)
expect(result.data[4]).toBe(255) // R
expect(result.data[5]).toBe(255) // G
expect(result.data[6]).toBe(0) // B
expect(result.data[7]).toBe(255) // A
// New BL should be old TL (Red)
expect(result.data[8]).toBe(255) // R
expect(result.data[9]).toBe(0) // G
expect(result.data[10]).toBe(0) // B
expect(result.data[11]).toBe(255) // A
// New BR should be old TR (Green)
expect(result.data[12]).toBe(0) // R
expect(result.data[13]).toBe(255) // G
expect(result.data[14]).toBe(0) // B
expect(result.data[15]).toBe(255) // A
})
it('should log error when canvas contexts not ready', async () => {
const consoleErrorSpy = vi
.spyOn(console, 'error')
.mockImplementation(() => {})
mockStore.maskCanvas = null
const transform = useCanvasTransform()
await transform.mirrorVertical()
expect(consoleErrorSpy).toHaveBeenCalledWith(
'[useCanvasTransform] Canvas contexts not ready'
)
consoleErrorSpy.mockRestore()
})
})
describe('GPU integration', () => {
it('should set GPU recreation flags for rotation', async () => {
mockStore.tgpuRoot = {}
mockMaskCanvas.width = 100
mockMaskCanvas.height = 50
const transform = useCanvasTransform()
await transform.rotateClockwise()
expect(mockStore.gpuTexturesNeedRecreation).toBe(true)
expect(mockStore.gpuTextureWidth).toBe(50)
expect(mockStore.gpuTextureHeight).toBe(100)
expect(mockStore.pendingGPUMaskData!.length).toBe(50 * 100 * 4)
expect(mockStore.pendingGPURgbData!.length).toBe(50 * 100 * 4)
})
it('should premultiply alpha when preparing GPU data', async () => {
mockStore.tgpuRoot = {}
mockMaskCanvas.width = 1
mockMaskCanvas.height = 1
// Create 1x1 ImageData with semi-transparent pixel
const createSemiTransparentImageData = () => {
const data = new Uint8ClampedArray(1 * 1 * 4)
data[0] = 200 // R
data[1] = 100 // G
data[2] = 50 // B
data[3] = 128 // A (50% opacity)
return { data, width: 1, height: 1 } as ImageData
}
mockMaskCtx.getImageData = vi.fn(() => createSemiTransparentImageData())
mockRgbCtx.getImageData = vi.fn(() => createSemiTransparentImageData())
mockImgCtx.getImageData = vi.fn(() => createSemiTransparentImageData())
const transform = useCanvasTransform()
await transform.rotateClockwise()
// Verify pendingGPUMaskData contains premultiplied values
expect(mockStore.pendingGPUMaskData).not.toBeNull()
const maskData = mockStore.pendingGPUMaskData!
// Expected premultiplied values: RGB * alpha / 255
// R: 200 * 128 / 255 ≈ 100
// G: 100 * 128 / 255 ≈ 50
// B: 50 * 128 / 255 ≈ 25
// A: 128 (preserved)
expect(maskData[0]).toBeCloseTo(100, 0) // R premultiplied
expect(maskData[1]).toBeCloseTo(50, 0) // G premultiplied
expect(maskData[2]).toBeCloseTo(25, 0) // B premultiplied
expect(maskData[3]).toBe(128) // A preserved
// Also verify RGB canvas data
expect(mockStore.pendingGPURgbData).not.toBeNull()
const rgbData = mockStore.pendingGPURgbData!
expect(rgbData[0]).toBeCloseTo(100, 0)
expect(rgbData[1]).toBeCloseTo(50, 0)
expect(rgbData[2]).toBeCloseTo(25, 0)
expect(rgbData[3]).toBe(128)
})
})
})

View File

@@ -0,0 +1,359 @@
import { useMaskEditorStore } from '@/stores/maskEditorStore'
/**
* Composable for canvas transformation operations (rotate, mirror)
*/
export function useCanvasTransform() {
const store = useMaskEditorStore()
/**
* Rotates a canvas 90 degrees clockwise or counter-clockwise
*/
const rotateCanvas = (
ctx: CanvasRenderingContext2D,
canvas: HTMLCanvasElement,
clockwise: boolean
): ImageData => {
const width = canvas.width
const height = canvas.height
// Get current canvas data
const sourceData = ctx.getImageData(0, 0, width, height)
// Create new ImageData with swapped dimensions
const rotatedData = new ImageData(height, width)
const src = sourceData.data
const dst = rotatedData.data
// Rotate pixel by pixel
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
const srcIdx = (y * width + x) * 4
// Calculate destination coordinates
let dstX: number, dstY: number
if (clockwise) {
// Rotate 90° clockwise: (x,y) -> (height-1-y, x)
dstX = height - 1 - y
dstY = x
} else {
// Rotate 90° counter-clockwise: (x,y) -> (y, width-1-x)
dstX = y
dstY = width - 1 - x
}
const dstIdx = (dstY * height + dstX) * 4
// Copy RGBA values
dst[dstIdx] = src[srcIdx]
dst[dstIdx + 1] = src[srcIdx + 1]
dst[dstIdx + 2] = src[srcIdx + 2]
dst[dstIdx + 3] = src[srcIdx + 3]
}
}
return rotatedData
}
/**
* Mirrors a canvas horizontally or vertically
*/
const mirrorCanvas = (
ctx: CanvasRenderingContext2D,
canvas: HTMLCanvasElement,
horizontal: boolean
): ImageData => {
const width = canvas.width
const height = canvas.height
// Get current canvas data
const sourceData = ctx.getImageData(0, 0, width, height)
const mirroredData = new ImageData(width, height)
const src = sourceData.data
const dst = mirroredData.data
// Mirror pixel by pixel
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
const srcIdx = (y * width + x) * 4
// Calculate destination coordinates
let dstX: number, dstY: number
if (horizontal) {
// Mirror horizontally: flip X axis
dstX = width - 1 - x
dstY = y
} else {
// Mirror vertically: flip Y axis
dstX = x
dstY = height - 1 - y
}
const dstIdx = (dstY * width + dstX) * 4
// Copy RGBA values
dst[dstIdx] = src[srcIdx]
dst[dstIdx + 1] = src[srcIdx + 1]
dst[dstIdx + 2] = src[srcIdx + 2]
dst[dstIdx + 3] = src[srcIdx + 3]
}
}
return mirroredData
}
/**
* Premultiplies alpha for GPU upload
*/
const premultiplyData = (data: Uint8ClampedArray): void => {
for (let i = 0; i < data.length; i += 4) {
const a = data[i + 3] / 255
data[i] = Math.round(data[i] * a)
data[i + 1] = Math.round(data[i + 1] * a)
data[i + 2] = Math.round(data[i + 2] * a)
}
}
/**
* Recreates and updates GPU textures after transformation
* This is required because GPU textures have immutable dimensions
*/
const recreateGPUTextures = async (
width: number,
height: number
): Promise<void> => {
if (
!store.tgpuRoot ||
!store.maskCanvas ||
!store.rgbCanvas ||
!store.maskCtx ||
!store.rgbCtx
) {
return
}
// Get references to GPU resources from useBrushDrawing
// These are stored as module-level variables in useBrushDrawing
// We need to trigger a reinitialization through the store
// Signal to useBrushDrawing that textures need recreation
store.gpuTexturesNeedRecreation = true
store.gpuTextureWidth = width
store.gpuTextureHeight = height
// Get current canvas data
const maskImageData = store.maskCtx.getImageData(0, 0, width, height)
const rgbImageData = store.rgbCtx.getImageData(0, 0, width, height)
// Create new Uint8ClampedArray with ArrayBuffer (not SharedArrayBuffer)
// This ensures compatibility with WebGPU writeTexture
const maskData = new Uint8ClampedArray(
new ArrayBuffer(maskImageData.data.length)
)
const rgbData = new Uint8ClampedArray(
new ArrayBuffer(rgbImageData.data.length)
)
// Copy data
maskData.set(maskImageData.data)
rgbData.set(rgbImageData.data)
// Runtime check to ensure we have ArrayBuffer backing
if (
maskData.buffer instanceof SharedArrayBuffer ||
rgbData.buffer instanceof SharedArrayBuffer
) {
console.error(
'[useCanvasTransform] SharedArrayBuffer detected, WebGPU writeTexture will fail'
)
return
}
// Premultiply alpha for GPU
premultiplyData(maskData)
premultiplyData(rgbData)
// Store the premultiplied data for useBrushDrawing to pick up
store.pendingGPUMaskData = maskData
store.pendingGPURgbData = rgbData
}
/**
* Rotates all canvas layers 90 degrees clockwise and updates GPU
*/
const rotateClockwise = async (): Promise<void> => {
const { maskCanvas, maskCtx, rgbCanvas, rgbCtx, imgCanvas, imgCtx } = store
if (
!maskCanvas ||
!maskCtx ||
!rgbCanvas ||
!rgbCtx ||
!imgCanvas ||
!imgCtx
) {
console.error('[useCanvasTransform] Canvas contexts not ready')
return
}
// Store original dimensions
const origWidth = maskCanvas.width
const origHeight = maskCanvas.height
// Rotate all three layers clockwise
const rotatedMask = rotateCanvas(maskCtx, maskCanvas, true)
const rotatedRgb = rotateCanvas(rgbCtx, rgbCanvas, true)
const rotatedImg = rotateCanvas(imgCtx, imgCanvas, true)
// Update canvas dimensions (swap width/height)
maskCanvas.width = origHeight
maskCanvas.height = origWidth
rgbCanvas.width = origHeight
rgbCanvas.height = origWidth
imgCanvas.width = origHeight
imgCanvas.height = origWidth
// Apply rotated data
maskCtx.putImageData(rotatedMask, 0, 0)
rgbCtx.putImageData(rotatedRgb, 0, 0)
imgCtx.putImageData(rotatedImg, 0, 0)
// Recreate GPU textures with new dimensions if GPU is active
if (store.tgpuRoot) {
await recreateGPUTextures(origHeight, origWidth)
}
// Save to history
store.canvasHistory.saveState(rotatedMask, rotatedRgb, rotatedImg)
}
/**
* Rotates all canvas layers 90 degrees counter-clockwise and updates GPU
*/
const rotateCounterclockwise = async (): Promise<void> => {
const { maskCanvas, maskCtx, rgbCanvas, rgbCtx, imgCanvas, imgCtx } = store
if (
!maskCanvas ||
!maskCtx ||
!rgbCanvas ||
!rgbCtx ||
!imgCanvas ||
!imgCtx
) {
console.error('[useCanvasTransform] Canvas contexts not ready')
return
}
// Store original dimensions
const origWidth = maskCanvas.width
const origHeight = maskCanvas.height
// Rotate all three layers counter-clockwise
const rotatedMask = rotateCanvas(maskCtx, maskCanvas, false)
const rotatedRgb = rotateCanvas(rgbCtx, rgbCanvas, false)
const rotatedImg = rotateCanvas(imgCtx, imgCanvas, false)
// Update canvas dimensions (swap width/height)
maskCanvas.width = origHeight
maskCanvas.height = origWidth
rgbCanvas.width = origHeight
rgbCanvas.height = origWidth
imgCanvas.width = origHeight
imgCanvas.height = origWidth
// Apply rotated data
maskCtx.putImageData(rotatedMask, 0, 0)
rgbCtx.putImageData(rotatedRgb, 0, 0)
imgCtx.putImageData(rotatedImg, 0, 0)
// Recreate GPU textures with new dimensions if GPU is active
if (store.tgpuRoot) {
await recreateGPUTextures(origHeight, origWidth)
}
// Save to history
store.canvasHistory.saveState(rotatedMask, rotatedRgb, rotatedImg)
}
/**
* Mirrors all canvas layers horizontally and updates GPU
*/
const mirrorHorizontal = async (): Promise<void> => {
const { maskCanvas, maskCtx, rgbCanvas, rgbCtx, imgCanvas, imgCtx } = store
if (
!maskCanvas ||
!maskCtx ||
!rgbCanvas ||
!rgbCtx ||
!imgCanvas ||
!imgCtx
) {
console.error('[useCanvasTransform] Canvas contexts not ready')
return
}
// Mirror all three layers horizontally
const mirroredMask = mirrorCanvas(maskCtx, maskCanvas, true)
const mirroredRgb = mirrorCanvas(rgbCtx, rgbCanvas, true)
const mirroredImg = mirrorCanvas(imgCtx, imgCanvas, true)
// Apply mirrored data (dimensions stay the same)
maskCtx.putImageData(mirroredMask, 0, 0)
rgbCtx.putImageData(mirroredRgb, 0, 0)
imgCtx.putImageData(mirroredImg, 0, 0)
// Update GPU textures if GPU is active (dimensions unchanged, just data)
if (store.tgpuRoot) {
await recreateGPUTextures(maskCanvas.width, maskCanvas.height)
}
// Save to history
store.canvasHistory.saveState(mirroredMask, mirroredRgb, mirroredImg)
}
/**
* Mirrors all canvas layers vertically and updates GPU
*/
const mirrorVertical = async (): Promise<void> => {
const { maskCanvas, maskCtx, rgbCanvas, rgbCtx, imgCanvas, imgCtx } = store
if (
!maskCanvas ||
!maskCtx ||
!rgbCanvas ||
!rgbCtx ||
!imgCanvas ||
!imgCtx
) {
console.error('[useCanvasTransform] Canvas contexts not ready')
return
}
// Mirror all three layers vertically
const mirroredMask = mirrorCanvas(maskCtx, maskCanvas, false)
const mirroredRgb = mirrorCanvas(rgbCtx, rgbCanvas, false)
const mirroredImg = mirrorCanvas(imgCtx, imgCanvas, false)
// Apply mirrored data (dimensions stay the same)
maskCtx.putImageData(mirroredMask, 0, 0)
rgbCtx.putImageData(mirroredRgb, 0, 0)
imgCtx.putImageData(mirroredImg, 0, 0)
// Update GPU textures if GPU is active (dimensions unchanged, just data)
if (store.tgpuRoot) {
await recreateGPUTextures(maskCanvas.width, maskCanvas.height)
}
// Save to history
store.canvasHistory.saveState(mirroredMask, mirroredRgb, mirroredImg)
}
return {
rotateClockwise,
rotateCounterclockwise,
mirrorHorizontal,
mirrorVertical
}
}

View File

@@ -1664,31 +1664,41 @@ describe('useNodePricing', () => {
{
model: 'gemini-2.5-pro-preview-05-06',
expected: creditsListLabel([0.00125, 0.01], {
suffix: ' per 1K tokens'
suffix: ' per 1K tokens',
approximate: true,
separator: '-'
})
},
{
model: 'gemini-2.5-pro',
expected: creditsListLabel([0.00125, 0.01], {
suffix: ' per 1K tokens'
suffix: ' per 1K tokens',
approximate: true,
separator: '-'
})
},
{
model: 'gemini-3-pro-preview',
expected: creditsListLabel([0.002, 0.012], {
suffix: ' per 1K tokens'
suffix: ' per 1K tokens',
approximate: true,
separator: '-'
})
},
{
model: 'gemini-2.5-flash-preview-04-17',
expected: creditsListLabel([0.0003, 0.0025], {
suffix: ' per 1K tokens'
suffix: ' per 1K tokens',
approximate: true,
separator: '-'
})
},
{
model: 'gemini-2.5-flash',
expected: creditsListLabel([0.0003, 0.0025], {
suffix: ' per 1K tokens'
suffix: ' per 1K tokens',
approximate: true,
separator: '-'
})
},
{ model: 'unknown-gemini-model', expected: 'Token-based' }
@@ -1702,16 +1712,6 @@ describe('useNodePricing', () => {
})
})
it('should return per-second pricing for Gemini Veo models', () => {
const { getNodeDisplayPrice } = useNodePricing()
const node = createMockNode('GeminiNode', [
{ name: 'model', value: 'veo-2.0-generate-001' }
])
const price = getNodeDisplayPrice(node)
expect(price).toBe(creditsLabel(0.5, { suffix: '/second' }))
})
it('should return fallback for GeminiNode without model widget', () => {
const { getNodeDisplayPrice } = useNodePricing()
const node = createMockNode('GeminiNode', [])
@@ -1737,73 +1737,97 @@ describe('useNodePricing', () => {
{
model: 'o4-mini',
expected: creditsListLabel([0.0011, 0.0044], {
suffix: ' per 1K tokens'
suffix: ' per 1K tokens',
approximate: true,
separator: '-'
})
},
{
model: 'o1-pro',
expected: creditsListLabel([0.15, 0.6], {
suffix: ' per 1K tokens'
suffix: ' per 1K tokens',
approximate: true,
separator: '-'
})
},
{
model: 'o1',
expected: creditsListLabel([0.015, 0.06], {
suffix: ' per 1K tokens'
suffix: ' per 1K tokens',
approximate: true,
separator: '-'
})
},
{
model: 'o3-mini',
expected: creditsListLabel([0.0011, 0.0044], {
suffix: ' per 1K tokens'
suffix: ' per 1K tokens',
approximate: true,
separator: '-'
})
},
{
model: 'o3',
expected: creditsListLabel([0.01, 0.04], {
suffix: ' per 1K tokens'
suffix: ' per 1K tokens',
approximate: true,
separator: '-'
})
},
{
model: 'gpt-4o',
expected: creditsListLabel([0.0025, 0.01], {
suffix: ' per 1K tokens'
suffix: ' per 1K tokens',
approximate: true,
separator: '-'
})
},
{
model: 'gpt-4.1-nano',
expected: creditsListLabel([0.0001, 0.0004], {
suffix: ' per 1K tokens'
suffix: ' per 1K tokens',
approximate: true,
separator: '-'
})
},
{
model: 'gpt-4.1-mini',
expected: creditsListLabel([0.0004, 0.0016], {
suffix: ' per 1K tokens'
suffix: ' per 1K tokens',
approximate: true,
separator: '-'
})
},
{
model: 'gpt-4.1',
expected: creditsListLabel([0.002, 0.008], {
suffix: ' per 1K tokens'
suffix: ' per 1K tokens',
approximate: true,
separator: '-'
})
},
{
model: 'gpt-5-nano',
expected: creditsListLabel([0.00005, 0.0004], {
suffix: ' per 1K tokens'
suffix: ' per 1K tokens',
approximate: true,
separator: '-'
})
},
{
model: 'gpt-5-mini',
expected: creditsListLabel([0.00025, 0.002], {
suffix: ' per 1K tokens'
suffix: ' per 1K tokens',
approximate: true,
separator: '-'
})
},
{
model: 'gpt-5',
expected: creditsListLabel([0.00125, 0.01], {
suffix: ' per 1K tokens'
suffix: ' per 1K tokens',
approximate: true,
separator: '-'
})
}
]
@@ -1824,37 +1848,49 @@ describe('useNodePricing', () => {
{
model: 'gpt-4.1-nano-test',
expected: creditsListLabel([0.0001, 0.0004], {
suffix: ' per 1K tokens'
suffix: ' per 1K tokens',
approximate: true,
separator: '-'
})
},
{
model: 'gpt-4.1-mini-test',
expected: creditsListLabel([0.0004, 0.0016], {
suffix: ' per 1K tokens'
suffix: ' per 1K tokens',
approximate: true,
separator: '-'
})
},
{
model: 'gpt-4.1-test',
expected: creditsListLabel([0.002, 0.008], {
suffix: ' per 1K tokens'
suffix: ' per 1K tokens',
approximate: true,
separator: '-'
})
},
{
model: 'o1-pro-test',
expected: creditsListLabel([0.15, 0.6], {
suffix: ' per 1K tokens'
suffix: ' per 1K tokens',
approximate: true,
separator: '-'
})
},
{
model: 'o1-test',
expected: creditsListLabel([0.015, 0.06], {
suffix: ' per 1K tokens'
suffix: ' per 1K tokens',
approximate: true,
separator: '-'
})
},
{
model: 'o3-mini-test',
expected: creditsListLabel([0.0011, 0.0044], {
suffix: ' per 1K tokens'
suffix: ' per 1K tokens',
approximate: true,
separator: '-'
})
},
{ model: 'unknown-model', expected: 'Token-based' }

View File

@@ -1823,28 +1823,35 @@ const apiNodeCosts: Record<string, { displayPrice: string | PricingFunction }> =
const model = String(modelWidget.value)
// Google Veo video generation
if (model.includes('veo-2.0')) {
return formatCreditsLabel(0.5, { suffix: '/second' })
} else if (model.includes('gemini-2.5-flash-preview-04-17')) {
if (model.includes('gemini-2.5-flash-preview-04-17')) {
return formatCreditsListLabel([0.0003, 0.0025], {
suffix: ' per 1K tokens'
suffix: ' per 1K tokens',
approximate: true,
separator: '-'
})
} else if (model.includes('gemini-2.5-flash')) {
return formatCreditsListLabel([0.0003, 0.0025], {
suffix: ' per 1K tokens'
suffix: ' per 1K tokens',
approximate: true,
separator: '-'
})
} else if (model.includes('gemini-2.5-pro-preview-05-06')) {
return formatCreditsListLabel([0.00125, 0.01], {
suffix: ' per 1K tokens'
suffix: ' per 1K tokens',
approximate: true,
separator: '-'
})
} else if (model.includes('gemini-2.5-pro')) {
return formatCreditsListLabel([0.00125, 0.01], {
suffix: ' per 1K tokens'
suffix: ' per 1K tokens',
approximate: true,
separator: '-'
})
} else if (model.includes('gemini-3-pro-preview')) {
return formatCreditsListLabel([0.002, 0.012], {
suffix: ' per 1K tokens'
suffix: ' per 1K tokens',
approximate: true,
separator: '-'
})
}
// For other Gemini models, show token-based pricing info
@@ -1899,51 +1906,75 @@ const apiNodeCosts: Record<string, { displayPrice: string | PricingFunction }> =
// Specific pricing for exposed models based on official pricing data (converted to per 1K tokens)
if (model.includes('o4-mini')) {
return formatCreditsListLabel([0.0011, 0.0044], {
suffix: ' per 1K tokens'
suffix: ' per 1K tokens',
approximate: true,
separator: '-'
})
} else if (model.includes('o1-pro')) {
return formatCreditsListLabel([0.15, 0.6], {
suffix: ' per 1K tokens'
suffix: ' per 1K tokens',
approximate: true,
separator: '-'
})
} else if (model.includes('o1')) {
return formatCreditsListLabel([0.015, 0.06], {
suffix: ' per 1K tokens'
suffix: ' per 1K tokens',
approximate: true,
separator: '-'
})
} else if (model.includes('o3-mini')) {
return formatCreditsListLabel([0.0011, 0.0044], {
suffix: ' per 1K tokens'
suffix: ' per 1K tokens',
approximate: true,
separator: '-'
})
} else if (model.includes('o3')) {
return formatCreditsListLabel([0.01, 0.04], {
suffix: ' per 1K tokens'
suffix: ' per 1K tokens',
approximate: true,
separator: '-'
})
} else if (model.includes('gpt-4o')) {
return formatCreditsListLabel([0.0025, 0.01], {
suffix: ' per 1K tokens'
suffix: ' per 1K tokens',
approximate: true,
separator: '-'
})
} else if (model.includes('gpt-4.1-nano')) {
return formatCreditsListLabel([0.0001, 0.0004], {
suffix: ' per 1K tokens'
suffix: ' per 1K tokens',
approximate: true,
separator: '-'
})
} else if (model.includes('gpt-4.1-mini')) {
return formatCreditsListLabel([0.0004, 0.0016], {
suffix: ' per 1K tokens'
suffix: ' per 1K tokens',
approximate: true,
separator: '-'
})
} else if (model.includes('gpt-4.1')) {
return formatCreditsListLabel([0.002, 0.008], {
suffix: ' per 1K tokens'
suffix: ' per 1K tokens',
approximate: true,
separator: '-'
})
} else if (model.includes('gpt-5-nano')) {
return formatCreditsListLabel([0.00005, 0.0004], {
suffix: ' per 1K tokens'
suffix: ' per 1K tokens',
approximate: true,
separator: '-'
})
} else if (model.includes('gpt-5-mini')) {
return formatCreditsListLabel([0.00025, 0.002], {
suffix: ' per 1K tokens'
suffix: ' per 1K tokens',
approximate: true,
separator: '-'
})
} else if (model.includes('gpt-5')) {
return formatCreditsListLabel([0.00125, 0.01], {
suffix: ' per 1K tokens'
suffix: ' per 1K tokens',
approximate: true,
separator: '-'
})
}
return 'Token-based'
@@ -2101,6 +2132,267 @@ const apiNodeCosts: Record<string, { displayPrice: string | PricingFunction }> =
},
LtxvApiImageToVideo: {
displayPrice: ltxvPricingCalculator
},
WanReferenceVideoApi: {
displayPrice: (node: LGraphNode): string => {
const durationWidget = node.widgets?.find(
(w) => w.name === 'duration'
) as IComboWidget
const sizeWidget = node.widgets?.find(
(w) => w.name === 'size'
) as IComboWidget
if (!durationWidget || !sizeWidget) {
return formatCreditsRangeLabel(0.7, 1.5, {
note: '(varies with size & duration)'
})
}
const seconds = parseFloat(String(durationWidget.value))
const sizeStr = String(sizeWidget.value).toLowerCase()
const rate = sizeStr.includes('1080p') ? 0.15 : 0.1
const inputMin = 2 * rate
const inputMax = 5 * rate
const outputPrice = seconds * rate
const minTotal = inputMin + outputPrice
const maxTotal = inputMax + outputPrice
return formatCreditsRangeLabel(minTotal, maxTotal)
}
},
Vidu2TextToVideoNode: {
displayPrice: (node: LGraphNode): string => {
const durationWidget = node.widgets?.find(
(w) => w.name === 'duration'
) as IComboWidget
const resolutionWidget = node.widgets?.find(
(w) => w.name === 'resolution'
) as IComboWidget
if (!durationWidget || !resolutionWidget) {
return formatCreditsRangeLabel(0.075, 0.6, {
note: '(varies with duration & resolution)'
})
}
const duration = parseFloat(String(durationWidget.value))
const resolution = String(resolutionWidget.value).toLowerCase()
// Text-to-Video uses Q2 model only
// 720P: Starts at $0.075, +$0.025/sec
// 1080P: Starts at $0.10, +$0.05/sec
let basePrice: number
let pricePerSecond: number
if (resolution.includes('1080')) {
basePrice = 0.1
pricePerSecond = 0.05
} else {
// 720P default
basePrice = 0.075
pricePerSecond = 0.025
}
if (!Number.isFinite(duration) || duration <= 0) {
return formatCreditsRangeLabel(0.075, 0.6, {
note: '(varies with duration & resolution)'
})
}
const cost = basePrice + pricePerSecond * (duration - 1)
return formatCreditsLabel(cost)
}
},
Vidu2ImageToVideoNode: {
displayPrice: (node: LGraphNode): string => {
const modelWidget = node.widgets?.find(
(w) => w.name === 'model'
) as IComboWidget
const durationWidget = node.widgets?.find(
(w) => w.name === 'duration'
) as IComboWidget
const resolutionWidget = node.widgets?.find(
(w) => w.name === 'resolution'
) as IComboWidget
if (!modelWidget || !durationWidget || !resolutionWidget) {
return formatCreditsRangeLabel(0.04, 1.0, {
note: '(varies with model, duration & resolution)'
})
}
const model = String(modelWidget.value).toLowerCase()
const duration = parseFloat(String(durationWidget.value))
const resolution = String(resolutionWidget.value).toLowerCase()
const is1080p = resolution.includes('1080')
let basePrice: number
let pricePerSecond: number
if (model.includes('q2-pro-fast')) {
// Q2-pro-fast: 720P $0.04+$0.01/sec, 1080P $0.08+$0.02/sec
basePrice = is1080p ? 0.08 : 0.04
pricePerSecond = is1080p ? 0.02 : 0.01
} else if (model.includes('q2-pro')) {
// Q2-pro: 720P $0.075+$0.05/sec, 1080P $0.275+$0.075/sec
basePrice = is1080p ? 0.275 : 0.075
pricePerSecond = is1080p ? 0.075 : 0.05
} else if (model.includes('q2-turbo')) {
// Q2-turbo: 720P special pricing, 1080P $0.175+$0.05/sec
if (is1080p) {
basePrice = 0.175
pricePerSecond = 0.05
} else {
// 720P: $0.04 at 1s, $0.05 at 2s, +$0.05/sec beyond 2s
if (duration <= 1) {
return formatCreditsLabel(0.04)
}
if (duration <= 2) {
return formatCreditsLabel(0.05)
}
const cost = 0.05 + 0.05 * (duration - 2)
return formatCreditsLabel(cost)
}
} else {
return formatCreditsRangeLabel(0.04, 1.0, {
note: '(varies with model, duration & resolution)'
})
}
if (!Number.isFinite(duration) || duration <= 0) {
return formatCreditsRangeLabel(0.04, 1.0, {
note: '(varies with model, duration & resolution)'
})
}
const cost = basePrice + pricePerSecond * (duration - 1)
return formatCreditsLabel(cost)
}
},
Vidu2ReferenceVideoNode: {
displayPrice: (node: LGraphNode): string => {
const durationWidget = node.widgets?.find(
(w) => w.name === 'duration'
) as IComboWidget
const resolutionWidget = node.widgets?.find(
(w) => w.name === 'resolution'
) as IComboWidget
const audioWidget = node.widgets?.find(
(w) => w.name === 'audio'
) as IComboWidget
if (!durationWidget) {
return formatCreditsRangeLabel(0.125, 1.5, {
note: '(varies with duration, resolution & audio)'
})
}
const duration = parseFloat(String(durationWidget.value))
const resolution = String(resolutionWidget?.value ?? '').toLowerCase()
const is1080p = resolution.includes('1080')
// Check if audio is enabled (adds $0.75)
const audioValue = audioWidget?.value
const hasAudio =
audioValue !== undefined &&
audioValue !== null &&
String(audioValue).toLowerCase() !== 'false' &&
String(audioValue).toLowerCase() !== 'none' &&
audioValue !== ''
// Reference-to-Video uses Q2 model
// 720P: Starts at $0.125, +$0.025/sec
// 1080P: Starts at $0.375, +$0.05/sec
let basePrice: number
let pricePerSecond: number
if (is1080p) {
basePrice = 0.375
pricePerSecond = 0.05
} else {
// 720P default
basePrice = 0.125
pricePerSecond = 0.025
}
let cost = basePrice
if (Number.isFinite(duration) && duration > 0) {
cost = basePrice + pricePerSecond * (duration - 1)
}
// Audio adds $0.75 on top
if (hasAudio) {
cost += 0.075
}
return formatCreditsLabel(cost)
}
},
Vidu2StartEndToVideoNode: {
displayPrice: (node: LGraphNode): string => {
const modelWidget = node.widgets?.find(
(w) => w.name === 'model'
) as IComboWidget
const durationWidget = node.widgets?.find(
(w) => w.name === 'duration'
) as IComboWidget
const resolutionWidget = node.widgets?.find(
(w) => w.name === 'resolution'
) as IComboWidget
if (!modelWidget || !durationWidget || !resolutionWidget) {
return formatCreditsRangeLabel(0.04, 1.0, {
note: '(varies with model, duration & resolution)'
})
}
const model = String(modelWidget.value).toLowerCase()
const duration = parseFloat(String(durationWidget.value))
const resolution = String(resolutionWidget.value).toLowerCase()
const is1080p = resolution.includes('1080')
let basePrice: number
let pricePerSecond: number
if (model.includes('q2-pro-fast')) {
// Q2-pro-fast: 720P $0.04+$0.01/sec, 1080P $0.08+$0.02/sec
basePrice = is1080p ? 0.08 : 0.04
pricePerSecond = is1080p ? 0.02 : 0.01
} else if (model.includes('q2-pro')) {
// Q2-pro: 720P $0.075+$0.05/sec, 1080P $0.275+$0.075/sec
basePrice = is1080p ? 0.275 : 0.075
pricePerSecond = is1080p ? 0.075 : 0.05
} else if (model.includes('q2-turbo')) {
// Q2-turbo: 720P special pricing, 1080P $0.175+$0.05/sec
if (is1080p) {
basePrice = 0.175
pricePerSecond = 0.05
} else {
// 720P: $0.04 at 1s, $0.05 at 2s, +$0.05/sec beyond 2s
if (!Number.isFinite(duration) || duration <= 1) {
return formatCreditsLabel(0.04)
}
if (duration <= 2) {
return formatCreditsLabel(0.05)
}
const cost = 0.05 + 0.05 * (duration - 2)
return formatCreditsLabel(cost)
}
} else {
return formatCreditsRangeLabel(0.04, 1.0, {
note: '(varies with model, duration & resolution)'
})
}
if (!Number.isFinite(duration) || duration <= 0) {
return formatCreditsLabel(basePrice)
}
const cost = basePrice + pricePerSecond * (duration - 1)
return formatCreditsLabel(cost)
}
}
}
@@ -2254,8 +2546,13 @@ export const useNodePricing = () => {
ByteDanceImageReferenceNode: ['model', 'duration', 'resolution'],
WanTextToVideoApi: ['duration', 'size'],
WanImageToVideoApi: ['duration', 'resolution'],
WanReferenceVideoApi: ['duration', 'size'],
LtxvApiTextToVideo: ['model', 'duration', 'resolution'],
LtxvApiImageToVideo: ['model', 'duration', 'resolution']
LtxvApiImageToVideo: ['model', 'duration', 'resolution'],
Vidu2TextToVideoNode: ['model', 'duration', 'resolution'],
Vidu2ImageToVideoNode: ['model', 'duration', 'resolution'],
Vidu2ReferenceVideoNode: ['audio', 'duration', 'resolution'],
Vidu2StartEndToVideoNode: ['model', 'duration', 'resolution']
}
return widgetMap[nodeType] || []
}

View File

@@ -0,0 +1,59 @@
import { computed, toValue } from 'vue'
import type { MaybeRefOrGetter } from 'vue'
import { useI18n } from 'vue-i18n'
import { useErrorHandling } from '@/composables/useErrorHandling'
import type { JobListItem } from '@/composables/queue/useJobList'
import { useJobMenu } from '@/composables/queue/useJobMenu'
import type { JobState } from '@/types/queue'
export type JobAction = {
icon: string
label: string
variant: 'destructive' | 'secondary' | 'textonly'
}
export function useJobActions(
job: MaybeRefOrGetter<JobListItem | null | undefined>
) {
const { t } = useI18n()
const { wrapWithErrorHandlingAsync } = useErrorHandling()
const { cancelJob } = useJobMenu()
const cancelAction: JobAction = {
icon: 'icon-[lucide--x]',
label: t('sideToolbar.queueProgressOverlay.cancelJobTooltip'),
variant: 'destructive'
}
const cancellableStates: JobState[] = ['pending', 'initialization', 'running']
const jobRef = computed(() => toValue(job) ?? null)
const canCancelJob = computed(() => {
const currentJob = jobRef.value
if (!currentJob) {
return false
}
return (
currentJob.showClear !== false &&
cancellableStates.includes(currentJob.state)
)
})
const runCancelJob = wrapWithErrorHandlingAsync(async () => {
const currentJob = jobRef.value
if (!currentJob) {
return
}
await cancelJob(currentJob)
})
return {
cancelAction,
canCancelJob,
runCancelJob
}
}

View File

@@ -41,7 +41,7 @@ export type MenuEntry =
* @param onInspectAsset Callback to trigger when inspecting a completed job's asset
*/
export function useJobMenu(
currentMenuItem: () => JobListItem | null,
currentMenuItem: () => JobListItem | null = () => null,
onInspectAsset?: (item: JobListItem) => void
) {
const workflowStore = useWorkflowStore()
@@ -52,37 +52,40 @@ export function useJobMenu(
const nodeDefStore = useNodeDefStore()
const mediaAssetActions = useMediaAssetActions()
const openJobWorkflow = async () => {
const item = currentMenuItem()
if (!item) return
const data = item.taskRef?.workflow
const resolveItem = (item?: JobListItem | null): JobListItem | null =>
item ?? currentMenuItem()
const openJobWorkflow = async (item?: JobListItem | null) => {
const target = resolveItem(item)
if (!target) return
const data = target.taskRef?.workflow
if (!data) return
const filename = `Job ${item.id}.json`
const filename = `Job ${target.id}.json`
const temp = workflowStore.createTemporary(filename, data)
await workflowService.openWorkflow(temp)
}
const copyJobId = async () => {
const item = currentMenuItem()
if (!item) return
await copyToClipboard(item.id)
const copyJobId = async (item?: JobListItem | null) => {
const target = resolveItem(item)
if (!target) return
await copyToClipboard(target.id)
}
const cancelJob = async () => {
const item = currentMenuItem()
if (!item) return
if (item.state === 'running' || item.state === 'initialization') {
await api.interrupt(item.id)
} else if (item.state === 'pending') {
await api.deleteItem('queue', item.id)
const cancelJob = async (item?: JobListItem | null) => {
const target = resolveItem(item)
if (!target) return
if (target.state === 'running' || target.state === 'initialization') {
await api.interrupt(target.id)
} else if (target.state === 'pending') {
await api.deleteItem('queue', target.id)
}
await queueStore.update()
}
const copyErrorMessage = async () => {
const item = currentMenuItem()
if (!item) return
const msgs = item.taskRef?.status?.messages as any[] | undefined
const copyErrorMessage = async (item?: JobListItem | null) => {
const target = resolveItem(item)
if (!target) return
const msgs = target.taskRef?.status?.messages as any[] | undefined
const err = msgs?.find((m: any) => m?.[0] === 'execution_error')?.[1] as
| ExecutionErrorWsMessage
| undefined
@@ -90,10 +93,10 @@ export function useJobMenu(
if (message) await copyToClipboard(String(message))
}
const reportError = () => {
const item = currentMenuItem()
if (!item) return
const msgs = item.taskRef?.status?.messages as any[] | undefined
const reportError = (item?: JobListItem | null) => {
const target = resolveItem(item)
if (!target) return
const msgs = target.taskRef?.status?.messages as any[] | undefined
const err = msgs?.find((m: any) => m?.[0] === 'execution_error')?.[1] as
| ExecutionErrorWsMessage
| undefined
@@ -102,10 +105,10 @@ export function useJobMenu(
// This is very magical only because it matches the respective backend implementation
// There is or will be a better way to do this
const addOutputLoaderNode = async () => {
const item = currentMenuItem()
if (!item) return
const result: ResultItemImpl | undefined = item.taskRef?.previewOutput
const addOutputLoaderNode = async (item?: JobListItem | null) => {
const target = resolveItem(item)
if (!target) return
const result: ResultItemImpl | undefined = target.taskRef?.previewOutput
if (!result) return
let nodeType: 'LoadImage' | 'LoadVideo' | 'LoadAudio' | null = null
@@ -153,10 +156,10 @@ export function useJobMenu(
/**
* Trigger a download of the job's previewable output asset.
*/
const downloadPreviewAsset = () => {
const item = currentMenuItem()
if (!item) return
const result: ResultItemImpl | undefined = item.taskRef?.previewOutput
const downloadPreviewAsset = (item?: JobListItem | null) => {
const target = resolveItem(item)
if (!target) return
const result: ResultItemImpl | undefined = target.taskRef?.previewOutput
if (!result) return
downloadFile(result.url)
}
@@ -164,14 +167,14 @@ export function useJobMenu(
/**
* Export the workflow JSON attached to the job.
*/
const exportJobWorkflow = async () => {
const item = currentMenuItem()
if (!item) return
const data = item.taskRef?.workflow
const exportJobWorkflow = async (item?: JobListItem | null) => {
const target = resolveItem(item)
if (!target) return
const data = target.taskRef?.workflow
if (!data) return
const settingStore = useSettingStore()
let filename = `Job ${item.id}.json`
let filename = `Job ${target.id}.json`
if (settingStore.get('Comfy.PromptFilename')) {
const input = await useDialogService().prompt({
@@ -188,10 +191,10 @@ export function useJobMenu(
downloadBlob(filename, blob)
}
const deleteJobAsset = async () => {
const item = currentMenuItem()
if (!item) return
const task = item.taskRef as TaskItemImpl | undefined
const deleteJobAsset = async (item?: JobListItem | null) => {
const target = resolveItem(item)
if (!target) return
const task = target.taskRef as TaskItemImpl | undefined
const preview = task?.previewOutput
if (!task || !preview) return
@@ -202,8 +205,8 @@ export function useJobMenu(
}
}
const removeFailedJob = async () => {
const task = currentMenuItem()?.taskRef as TaskItemImpl | undefined
const removeFailedJob = async (item?: JobListItem | null) => {
const task = resolveItem(item)?.taskRef as TaskItemImpl | undefined
if (!task) return
await queueStore.delete(task)
}
@@ -234,8 +237,8 @@ export function useJobMenu(
icon: 'icon-[lucide--zoom-in]',
onClick: onInspectAsset
? () => {
const item = currentMenuItem()
if (item) onInspectAsset(item)
const current = resolveItem()
if (current) onInspectAsset(current)
}
: undefined
},
@@ -246,33 +249,33 @@ export function useJobMenu(
'Add to current workflow'
),
icon: 'icon-[comfy--node]',
onClick: addOutputLoaderNode
onClick: () => addOutputLoaderNode(resolveItem())
},
{
key: 'download',
label: st('queue.jobMenu.download', 'Download'),
icon: 'icon-[lucide--download]',
onClick: downloadPreviewAsset
onClick: () => downloadPreviewAsset(resolveItem())
},
{ kind: 'divider', key: 'd1' },
{
key: 'open-workflow',
label: jobMenuOpenWorkflowLabel.value,
icon: 'icon-[comfy--workflow]',
onClick: openJobWorkflow
onClick: () => openJobWorkflow(resolveItem())
},
{
key: 'export-workflow',
label: st('queue.jobMenu.exportWorkflow', 'Export workflow'),
icon: 'icon-[comfy--file-output]',
onClick: exportJobWorkflow
onClick: () => exportJobWorkflow(resolveItem())
},
{ kind: 'divider', key: 'd2' },
{
key: 'copy-id',
label: jobMenuCopyJobIdLabel.value,
icon: 'icon-[lucide--copy]',
onClick: copyJobId
onClick: () => copyJobId(resolveItem())
},
{ kind: 'divider', key: 'd3' },
...(hasDeletableAsset
@@ -281,7 +284,7 @@ export function useJobMenu(
key: 'delete',
label: st('queue.jobMenu.deleteAsset', 'Delete asset'),
icon: 'icon-[lucide--trash-2]',
onClick: deleteJobAsset
onClick: () => deleteJobAsset(resolveItem())
}
]
: [])
@@ -293,33 +296,33 @@ export function useJobMenu(
key: 'open-workflow',
label: jobMenuOpenWorkflowFailedLabel.value,
icon: 'icon-[comfy--workflow]',
onClick: openJobWorkflow
onClick: () => openJobWorkflow(resolveItem())
},
{ kind: 'divider', key: 'd1' },
{
key: 'copy-id',
label: jobMenuCopyJobIdLabel.value,
icon: 'icon-[lucide--copy]',
onClick: copyJobId
onClick: () => copyJobId(resolveItem())
},
{
key: 'copy-error',
label: st('queue.jobMenu.copyErrorMessage', 'Copy error message'),
icon: 'icon-[lucide--copy]',
onClick: copyErrorMessage
onClick: () => copyErrorMessage(resolveItem())
},
{
key: 'report-error',
label: st('queue.jobMenu.reportError', 'Report error'),
icon: 'icon-[lucide--message-circle-warning]',
onClick: reportError
onClick: () => reportError(resolveItem())
},
{ kind: 'divider', key: 'd2' },
{
key: 'delete',
label: st('queue.jobMenu.removeJob', 'Remove job'),
icon: 'icon-[lucide--circle-minus]',
onClick: removeFailedJob
onClick: () => removeFailedJob(resolveItem())
}
]
}
@@ -328,21 +331,21 @@ export function useJobMenu(
key: 'open-workflow',
label: jobMenuOpenWorkflowLabel.value,
icon: 'icon-[comfy--workflow]',
onClick: openJobWorkflow
onClick: () => openJobWorkflow(resolveItem())
},
{ kind: 'divider', key: 'd1' },
{
key: 'copy-id',
label: jobMenuCopyJobIdLabel.value,
icon: 'icon-[lucide--copy]',
onClick: copyJobId
onClick: () => copyJobId(resolveItem())
},
{ kind: 'divider', key: 'd2' },
{
key: 'cancel-job',
label: jobMenuCancelLabel.value,
icon: 'icon-[lucide--x]',
onClick: cancelJob
onClick: () => cancelJob(resolveItem())
}
]
})

View File

@@ -64,6 +64,9 @@ import { ManagerTab } from '@/workbench/extensions/manager/types/comfyManagerTyp
import { useWorkflowTemplateSelectorDialog } from './useWorkflowTemplateSelectorDialog'
import { useMaskEditorStore } from '@/stores/maskEditorStore'
import { useDialogStore } from '@/stores/dialogStore'
const { isActiveSubscription, showSubscriptionDialog } = useSubscription()
const moveSelectedNodesVersionAdded = '1.22.2'
@@ -82,6 +85,9 @@ export function useCoreCommands(): ComfyCommand[] {
const bottomPanelStore = useBottomPanelStore()
const dialogStore = useDialogStore()
const maskEditorStore = useMaskEditorStore()
const { getSelectedNodes, toggleSelectedNodesMode } =
useSelectedLiteGraphItems()
const getTracker = () => workflowStore.activeWorkflow?.changeTracker
@@ -207,7 +213,12 @@ export function useCoreCommands(): ComfyCommand[] {
label: 'Undo',
category: 'essentials' as const,
function: async () => {
await getTracker()?.undo?.()
// If Mask Editor is open, use its history instead of the graph
if (dialogStore.isDialogOpen('global-mask-editor')) {
maskEditorStore.canvasHistory.undo()
} else {
await getTracker()?.undo?.()
}
}
},
{
@@ -216,7 +227,11 @@ export function useCoreCommands(): ComfyCommand[] {
label: 'Redo',
category: 'essentials' as const,
function: async () => {
await getTracker()?.redo?.()
if (dialogStore.isDialogOpen('global-mask-editor')) {
maskEditorStore.canvasHistory.redo()
} else {
await getTracker()?.redo?.()
}
}
},
{
@@ -905,15 +920,6 @@ export function useCoreCommands(): ComfyCommand[] {
})
}
},
{
id: 'Comfy.Manager.ToggleManagerProgressDialog',
icon: 'pi pi-spinner',
label: 'Toggle the Custom Nodes Manager Progress Bar',
versionAdded: '1.13.9',
function: () => {
dialogService.toggleManagerProgressDialog()
}
},
{
id: 'Comfy.User.OpenSignInDialog',
icon: 'pi pi-user',

View File

@@ -0,0 +1,100 @@
import { storeToRefs } from 'pinia'
import { computed, onMounted } from 'vue'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useTelemetry } from '@/platform/telemetry'
import { useReleaseStore } from '@/platform/updates/common/releaseStore'
import { useDialogService } from '@/services/dialogService'
import { useHelpCenterStore } from '@/stores/helpCenterStore'
import type { HelpCenterTriggerLocation } from '@/stores/helpCenterStore'
import { useConflictAcknowledgment } from '@/workbench/extensions/manager/composables/useConflictAcknowledgment'
import { useConflictDetection } from '@/workbench/extensions/manager/composables/useConflictDetection'
export function useHelpCenter(
triggerFrom: HelpCenterTriggerLocation = 'sidebar'
) {
const settingStore = useSettingStore()
const releaseStore = useReleaseStore()
const helpCenterStore = useHelpCenterStore()
const { isVisible: isHelpCenterVisible, triggerLocation } =
storeToRefs(helpCenterStore)
const { shouldShowRedDot: showReleaseRedDot } = storeToRefs(releaseStore)
const conflictDetection = useConflictDetection()
const { showNodeConflictDialog } = useDialogService()
// Use conflict acknowledgment state from composable - call only once
const { shouldShowRedDot: shouldShowConflictRedDot, markConflictsAsSeen } =
useConflictAcknowledgment()
// Use either release red dot or conflict red dot
const shouldShowRedDot = computed((): boolean => {
const releaseRedDot = showReleaseRedDot.value
return releaseRedDot || shouldShowConflictRedDot.value
})
const sidebarLocation = computed(() =>
settingStore.get('Comfy.Sidebar.Location')
)
/**
* Toggle Help Center and track UI button click.
*/
const toggleHelpCenter = () => {
useTelemetry()?.trackUiButtonClicked({
button_id: `${triggerFrom}_help_center_toggled`
})
helpCenterStore.toggle(triggerFrom)
}
const closeHelpCenter = () => {
helpCenterStore.hide()
}
/**
* Handle What's New popup dismissal
* Check if conflict modal should be shown after ComfyUI update
*/
const handleWhatsNewDismissed = async () => {
try {
// Check if conflict modal should be shown after update
const shouldShow =
await conflictDetection.shouldShowConflictModalAfterUpdate()
if (shouldShow) {
showConflictModal()
}
} catch (error) {
console.error('[HelpCenter] Error checking conflict modal:', error)
}
}
/**
* Show the node conflict dialog with current conflict data
*/
const showConflictModal = () => {
showNodeConflictDialog({
showAfterWhatsNew: true,
dialogComponentProps: {
onClose: () => {
markConflictsAsSeen()
}
}
})
}
// Initialize release store on mount
onMounted(async () => {
// Initialize release store to fetch releases for toast and popup
await releaseStore.initialize()
})
return {
isHelpCenterVisible,
triggerLocation,
shouldShowRedDot,
sidebarLocation,
toggleHelpCenter,
closeHelpCenter,
handleWhatsNewDismissed
}
}

View File

@@ -0,0 +1,314 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type {
LGraphCanvas,
LGraph,
LGraphGroup,
LGraphNode
} from '@/lib/litegraph/src/litegraph'
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
import { app } from '@/scripts/app'
import { isImageNode } from '@/utils/litegraphUtil'
import { pasteImageNode, usePaste } from './usePaste'
function createMockNode() {
return {
pos: [0, 0],
pasteFile: vi.fn(),
pasteFiles: vi.fn()
}
}
function createImageFile(
name: string = 'test.png',
type: string = 'image/png'
): File {
return new File([''], name, { type })
}
function createAudioFile(
name: string = 'test.mp3',
type: string = 'audio/mpeg'
): File {
return new File([''], name, { type })
}
function createDataTransfer(files: File[] = []): DataTransfer {
const dataTransfer = new DataTransfer()
files.forEach((file) => dataTransfer.items.add(file))
return dataTransfer
}
const mockCanvas = {
current_node: null as LGraphNode | null,
graph: {
add: vi.fn(),
change: vi.fn()
} as Partial<LGraph> as LGraph,
graph_mouse: [100, 200],
pasteFromClipboard: vi.fn(),
_deserializeItems: vi.fn()
} as Partial<LGraphCanvas> as LGraphCanvas
const mockCanvasStore = {
canvas: mockCanvas,
getCanvas: vi.fn(() => mockCanvas)
}
const mockWorkspaceStore = {
shiftDown: false
}
vi.mock('@vueuse/core', () => ({
useEventListener: vi.fn((target, event, handler) => {
target.addEventListener(event, handler)
return () => target.removeEventListener(event, handler)
})
}))
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
useCanvasStore: () => mockCanvasStore
}))
vi.mock('@/stores/workspaceStore', () => ({
useWorkspaceStore: () => mockWorkspaceStore
}))
vi.mock('@/scripts/app', () => ({
app: {
loadGraphData: vi.fn()
}
}))
vi.mock('@/lib/litegraph/src/litegraph', () => ({
LiteGraph: {
createNode: vi.fn()
}
}))
vi.mock('@/utils/litegraphUtil', () => ({
isAudioNode: vi.fn(),
isImageNode: vi.fn(),
isVideoNode: vi.fn()
}))
vi.mock('@/workbench/eventHelpers', () => ({
shouldIgnoreCopyPaste: vi.fn()
}))
describe('pasteImageNode', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.mocked(mockCanvas.graph!.add).mockImplementation(
(node: LGraphNode | LGraphGroup) => node as LGraphNode
)
})
it('should create new LoadImage node when no image node provided', () => {
const mockNode = createMockNode()
vi.mocked(LiteGraph.createNode).mockReturnValue(
mockNode as unknown as LGraphNode
)
const file = createImageFile()
const dataTransfer = createDataTransfer([file])
pasteImageNode(mockCanvas as unknown as LGraphCanvas, dataTransfer.items)
expect(LiteGraph.createNode).toHaveBeenCalledWith('LoadImage')
expect(mockNode.pos).toEqual([100, 200])
expect(mockCanvas.graph!.add).toHaveBeenCalledWith(mockNode)
expect(mockCanvas.graph!.change).toHaveBeenCalled()
expect(mockNode.pasteFile).toHaveBeenCalledWith(file)
})
it('should use existing image node when provided', () => {
const mockNode = createMockNode()
const file = createImageFile()
const dataTransfer = createDataTransfer([file])
pasteImageNode(
mockCanvas as unknown as LGraphCanvas,
dataTransfer.items,
mockNode as unknown as LGraphNode
)
expect(mockNode.pasteFile).toHaveBeenCalledWith(file)
expect(mockNode.pasteFiles).toHaveBeenCalledWith([file])
})
it('should handle multiple image files', () => {
const mockNode = createMockNode()
const file1 = createImageFile('test1.png')
const file2 = createImageFile('test2.jpg', 'image/jpeg')
const dataTransfer = createDataTransfer([file1, file2])
pasteImageNode(
mockCanvas as unknown as LGraphCanvas,
dataTransfer.items,
mockNode as unknown as LGraphNode
)
expect(mockNode.pasteFile).toHaveBeenCalledWith(file1)
expect(mockNode.pasteFiles).toHaveBeenCalledWith([file1, file2])
})
it('should do nothing when no image files present', () => {
const mockNode = createMockNode()
const dataTransfer = createDataTransfer()
pasteImageNode(
mockCanvas as unknown as LGraphCanvas,
dataTransfer.items,
mockNode as unknown as LGraphNode
)
expect(mockNode.pasteFile).not.toHaveBeenCalled()
expect(mockNode.pasteFiles).not.toHaveBeenCalled()
})
it('should filter non-image items', () => {
const mockNode = createMockNode()
const imageFile = createImageFile()
const textFile = new File([''], 'test.txt', { type: 'text/plain' })
const dataTransfer = createDataTransfer([textFile, imageFile])
pasteImageNode(
mockCanvas as unknown as LGraphCanvas,
dataTransfer.items,
mockNode as unknown as LGraphNode
)
expect(mockNode.pasteFile).toHaveBeenCalledWith(imageFile)
expect(mockNode.pasteFiles).toHaveBeenCalledWith([imageFile])
})
})
describe('usePaste', () => {
beforeEach(() => {
vi.clearAllMocks()
mockCanvas.current_node = null
mockWorkspaceStore.shiftDown = false
vi.mocked(mockCanvas.graph!.add).mockImplementation(
(node: LGraphNode | LGraphGroup) => node as LGraphNode
)
})
it('should handle image paste', async () => {
const mockNode = createMockNode()
vi.mocked(LiteGraph.createNode).mockReturnValue(
mockNode as unknown as LGraphNode
)
usePaste()
const file = createImageFile()
const dataTransfer = createDataTransfer([file])
const event = new ClipboardEvent('paste', { clipboardData: dataTransfer })
document.dispatchEvent(event)
await vi.waitFor(() => {
expect(LiteGraph.createNode).toHaveBeenCalledWith('LoadImage')
expect(mockNode.pasteFile).toHaveBeenCalledWith(file)
})
})
it('should handle audio paste', async () => {
const mockNode = createMockNode()
vi.mocked(LiteGraph.createNode).mockReturnValue(
mockNode as unknown as LGraphNode
)
usePaste()
const file = createAudioFile()
const dataTransfer = createDataTransfer([file])
const event = new ClipboardEvent('paste', { clipboardData: dataTransfer })
document.dispatchEvent(event)
await vi.waitFor(() => {
expect(LiteGraph.createNode).toHaveBeenCalledWith('LoadAudio')
expect(mockNode.pasteFile).toHaveBeenCalledWith(file)
})
})
it('should handle workflow JSON paste', async () => {
const workflow = { version: '1.0', nodes: [], extra: {} }
usePaste()
const dataTransfer = new DataTransfer()
dataTransfer.setData('text/plain', JSON.stringify(workflow))
const event = new ClipboardEvent('paste', { clipboardData: dataTransfer })
document.dispatchEvent(event)
await vi.waitFor(() => {
expect(app.loadGraphData).toHaveBeenCalledWith(workflow)
})
})
it('should ignore paste when shift is down', () => {
mockWorkspaceStore.shiftDown = true
usePaste()
const file = createImageFile()
const dataTransfer = createDataTransfer([file])
const event = new ClipboardEvent('paste', { clipboardData: dataTransfer })
document.dispatchEvent(event)
expect(LiteGraph.createNode).not.toHaveBeenCalled()
})
it('should use existing image node when selected', () => {
const mockNode = {
is_selected: true,
pasteFile: vi.fn(),
pasteFiles: vi.fn()
} as unknown as Partial<LGraphNode> as LGraphNode
mockCanvas.current_node = mockNode
vi.mocked(isImageNode).mockReturnValue(true)
usePaste()
const file = createImageFile()
const dataTransfer = createDataTransfer([file])
const event = new ClipboardEvent('paste', { clipboardData: dataTransfer })
document.dispatchEvent(event)
expect(mockNode.pasteFile).toHaveBeenCalledWith(file)
})
it('should call canvas pasteFromClipboard for non-workflow text', () => {
usePaste()
const dataTransfer = new DataTransfer()
dataTransfer.setData('text/plain', 'just some text')
const event = new ClipboardEvent('paste', { clipboardData: dataTransfer })
document.dispatchEvent(event)
expect(mockCanvas.pasteFromClipboard).toHaveBeenCalled()
})
it('should handle clipboard items with metadata', async () => {
const data = { test: 'data' }
const encoded = btoa(JSON.stringify(data))
const html = `<div data-metadata="${encoded}"></div>`
usePaste()
const dataTransfer = new DataTransfer()
dataTransfer.setData('text/html', html)
const event = new ClipboardEvent('paste', { clipboardData: dataTransfer })
document.dispatchEvent(event)
await vi.waitFor(() => {
expect(mockCanvas._deserializeItems).toHaveBeenCalledWith(
data,
expect.any(Object)
)
})
})
})

View File

@@ -1,7 +1,7 @@
import { useEventListener } from '@vueuse/core'
import type { LGraphCanvas, LGraphNode } from '@/lib/litegraph/src/litegraph'
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { app } from '@/scripts/app'
@@ -26,6 +26,51 @@ function pasteClipboardItems(data: DataTransfer): boolean {
return false
}
function pasteItemsOnNode(
items: DataTransferItemList,
node: LGraphNode | null,
contentType: string
): void {
if (!node) return
const filteredItems = Array.from(items).filter((item) =>
item.type.startsWith(contentType)
)
const blob = filteredItems[0]?.getAsFile()
if (!blob) return
node.pasteFile?.(blob)
node.pasteFiles?.(
Array.from(filteredItems)
.map((i) => i.getAsFile())
.filter((f) => f !== null)
)
}
export function pasteImageNode(
canvas: LGraphCanvas,
items: DataTransferItemList,
imageNode: LGraphNode | null = null
): void {
const {
graph,
graph_mouse: [posX, posY]
} = canvas
if (!imageNode) {
// No image node selected: add a new one
const newNode = LiteGraph.createNode('LoadImage')
if (newNode) {
newNode.pos = [posX, posY]
imageNode = graph?.add(newNode) ?? null
}
graph?.change()
}
pasteItemsOnNode(items, imageNode, 'image')
}
/**
* Adds a handler on paste that extracts and loads images or workflows from pasted JSON data
*/
@@ -33,28 +78,6 @@ export const usePaste = () => {
const workspaceStore = useWorkspaceStore()
const canvasStore = useCanvasStore()
const pasteItemsOnNode = (
items: DataTransferItemList,
node: LGraphNode | null,
contentType: string
) => {
if (!node) return
const filteredItems = Array.from(items).filter((item) =>
item.type.startsWith(contentType)
)
const blob = filteredItems[0]?.getAsFile()
if (!blob) return
node.pasteFile?.(blob)
node.pasteFiles?.(
Array.from(filteredItems)
.map((i) => i.getAsFile())
.filter((f) => f !== null)
)
}
useEventListener(document, 'paste', async (e) => {
if (shouldIgnoreCopyPaste(e.target)) {
// Default system copy
@@ -80,8 +103,10 @@ export const usePaste = () => {
const isVideoNodeSelected = isNodeSelected && isVideoNode(currentNode)
const isAudioNodeSelected = isNodeSelected && isAudioNode(currentNode)
let imageNode: LGraphNode | null = isImageNodeSelected ? currentNode : null
let audioNode: LGraphNode | null = isAudioNodeSelected ? currentNode : null
const imageNode: LGraphNode | null = isImageNodeSelected
? currentNode
: null
const videoNode: LGraphNode | null = isVideoNodeSelected
? currentNode
: null
@@ -89,16 +114,7 @@ export const usePaste = () => {
// Look for image paste data
for (const item of items) {
if (item.type.startsWith('image/')) {
if (!imageNode) {
// No image node selected: add a new one
const newNode = LiteGraph.createNode('LoadImage')
if (newNode) {
newNode.pos = [canvas.graph_mouse[0], canvas.graph_mouse[1]]
imageNode = graph?.add(newNode) ?? null
}
graph?.change()
}
pasteItemsOnNode(items, imageNode, 'image')
pasteImageNode(canvas as LGraphCanvas, items, imageNode)
return
} else if (item.type.startsWith('video/')) {
if (!videoNode) {

View File

@@ -0,0 +1,47 @@
type ProgressPercent = number | undefined
const progressBarContainerClass = 'absolute inset-0'
const progressBarBaseClass =
'pointer-events-none absolute inset-y-0 left-0 h-full transition-[width]'
const progressBarPrimaryClass = `${progressBarBaseClass} bg-interface-panel-job-progress-primary`
const progressBarSecondaryClass = `${progressBarBaseClass} bg-interface-panel-job-progress-secondary`
function clampPercent(value: number) {
return Math.min(100, Math.max(0, value))
}
function normalizeProgressPercent(value: ProgressPercent) {
if (value === undefined || !Number.isFinite(value)) return undefined
return clampPercent(value)
}
function hasProgressPercent(value: ProgressPercent) {
return normalizeProgressPercent(value) !== undefined
}
function hasAnyProgressPercent(
totalPercent: ProgressPercent,
currentPercent: ProgressPercent
) {
return hasProgressPercent(totalPercent) || hasProgressPercent(currentPercent)
}
function progressPercentStyle(value: ProgressPercent) {
const normalized = normalizeProgressPercent(value)
if (normalized === undefined) return undefined
return { width: `${normalized}%` }
}
export function useProgressBarBackground() {
return {
progressBarContainerClass,
progressBarPrimaryClass,
progressBarSecondaryClass,
hasProgressPercent,
hasAnyProgressPercent,
progressPercentStyle
}
}

View File

@@ -1,6 +1,7 @@
import { createPinia, setActivePinia } from 'pinia'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick, ref } from 'vue'
import type { IFuseOptions } from 'fuse.js'
import type { TemplateInfo } from '@/platform/workflow/templates/types/template'
@@ -42,6 +43,13 @@ vi.mock('@/platform/telemetry', () => ({
}))
}))
const mockGetFuseOptions = vi.hoisted(() => vi.fn())
vi.mock('@/scripts/api', () => ({
api: {
getFuseOptions: mockGetFuseOptions
}
}))
const { useTemplateFiltering } =
await import('@/composables/useTemplateFiltering')
@@ -49,6 +57,7 @@ describe('useTemplateFiltering', () => {
beforeEach(() => {
setActivePinia(createPinia())
vi.clearAllMocks()
mockGetFuseOptions.mockResolvedValue(null)
})
afterEach(() => {
@@ -272,4 +281,118 @@ describe('useTemplateFiltering', () => {
'beta-pro'
])
})
describe('loadFuseOptions', () => {
it('updates fuseOptions when getFuseOptions returns valid options', async () => {
const templates = ref<TemplateInfo[]>([
{
name: 'test-template',
description: 'Test template',
mediaType: 'image',
mediaSubtype: 'png'
}
])
const customFuseOptions: IFuseOptions<TemplateInfo> = {
keys: [
{ name: 'name', weight: 0.5 },
{ name: 'description', weight: 0.5 }
],
threshold: 0.4,
includeScore: true
}
mockGetFuseOptions.mockResolvedValueOnce(customFuseOptions)
const { loadFuseOptions, filteredTemplates } =
useTemplateFiltering(templates)
await loadFuseOptions()
expect(mockGetFuseOptions).toHaveBeenCalledTimes(1)
expect(filteredTemplates.value).toBeDefined()
})
it('does not update fuseOptions when getFuseOptions returns null', async () => {
const templates = ref<TemplateInfo[]>([
{
name: 'test-template',
description: 'Test template',
mediaType: 'image',
mediaSubtype: 'png'
}
])
mockGetFuseOptions.mockResolvedValueOnce(null)
const { loadFuseOptions, filteredTemplates } =
useTemplateFiltering(templates)
const initialResults = filteredTemplates.value
await loadFuseOptions()
expect(mockGetFuseOptions).toHaveBeenCalledTimes(1)
expect(filteredTemplates.value).toEqual(initialResults)
})
it('handles errors when getFuseOptions fails', async () => {
const templates = ref<TemplateInfo[]>([
{
name: 'test-template',
description: 'Test template',
mediaType: 'image',
mediaSubtype: 'png'
}
])
mockGetFuseOptions.mockRejectedValueOnce(new Error('Network error'))
const { loadFuseOptions, filteredTemplates } =
useTemplateFiltering(templates)
const initialResults = filteredTemplates.value
await expect(loadFuseOptions()).rejects.toThrow('Network error')
expect(filteredTemplates.value).toEqual(initialResults)
})
it('recreates Fuse instance when fuseOptions change', async () => {
const templates = ref<TemplateInfo[]>([
{
name: 'searchable-template',
description: 'This is a searchable template',
mediaType: 'image',
mediaSubtype: 'png'
},
{
name: 'another-template',
description: 'Another template',
mediaType: 'image',
mediaSubtype: 'png'
}
])
const { loadFuseOptions, searchQuery, filteredTemplates } =
useTemplateFiltering(templates)
const customFuseOptions = {
keys: [{ name: 'name', weight: 1.0 }],
threshold: 0.2,
includeScore: true,
includeMatches: true
}
mockGetFuseOptions.mockResolvedValueOnce(customFuseOptions)
await loadFuseOptions()
await nextTick()
searchQuery.value = 'searchable'
await nextTick()
expect(filteredTemplates.value.length).toBeGreaterThan(0)
expect(mockGetFuseOptions).toHaveBeenCalledTimes(1)
})
})
})

View File

@@ -1,5 +1,6 @@
import { refDebounced, watchDebounced } from '@vueuse/core'
import Fuse from 'fuse.js'
import type { IFuseOptions } from 'fuse.js'
import { computed, ref, watch } from 'vue'
import type { Ref } from 'vue'
@@ -8,6 +9,21 @@ import { useTelemetry } from '@/platform/telemetry'
import type { TemplateInfo } from '@/platform/workflow/templates/types/template'
import { useTemplateRankingStore } from '@/stores/templateRankingStore'
import { debounce } from 'es-toolkit/compat'
import { api } from '@/scripts/api'
// Fuse.js configuration for fuzzy search
const defaultFuseOptions: IFuseOptions<TemplateInfo> = {
keys: [
{ name: 'name', weight: 0.3 },
{ name: 'title', weight: 0.3 },
{ name: 'description', weight: 0.1 },
{ name: 'tags', weight: 0.2 },
{ name: 'models', weight: 0.3 }
],
threshold: 0.33,
includeScore: true,
includeMatches: true
}
export function useTemplateFiltering(
templates: Ref<TemplateInfo[]> | TemplateInfo[]
@@ -35,26 +51,14 @@ export function useTemplateFiltering(
| 'model-size-low-to-high'
>(settingStore.get('Comfy.Templates.SortBy'))
const fuseOptions = ref<IFuseOptions<TemplateInfo>>(defaultFuseOptions)
const templatesArray = computed(() => {
const templateData = 'value' in templates ? templates.value : templates
return Array.isArray(templateData) ? templateData : []
})
// Fuse.js configuration for fuzzy search
const fuseOptions = {
keys: [
{ name: 'name', weight: 0.3 },
{ name: 'title', weight: 0.3 },
{ name: 'description', weight: 0.1 },
{ name: 'tags', weight: 0.2 },
{ name: 'models', weight: 0.3 }
],
threshold: 0.33,
includeScore: true,
includeMatches: true
}
const fuse = computed(() => new Fuse(templatesArray.value, fuseOptions))
const fuse = computed(() => new Fuse(templatesArray.value, fuseOptions.value))
const availableModels = computed(() => {
const modelSet = new Set<string>()
@@ -272,6 +276,13 @@ export function useTemplateFiltering(
})
}, 500)
const loadFuseOptions = async () => {
const fetchedOptions = await api.getFuseOptions()
if (fetchedOptions) {
fuseOptions.value = fetchedOptions
}
}
// Watch for filter changes and track them
watch(
[searchQuery, selectedModels, selectedUseCases, selectedRunsOn, sortBy],
@@ -344,6 +355,7 @@ export function useTemplateFiltering(
resetFilters,
removeModelFilter,
removeUseCaseFilter,
removeRunsOnFilter
removeRunsOnFilter,
loadFuseOptions
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -483,23 +483,30 @@ export class ManageGroupDialog extends ComfyDialog<HTMLDialogElement> {
// Rewrite links
for (const l of type.links) {
// @ts-expect-error l[0]/l[2] used as node index
if (l[0] != null) l[0] = type.nodes[l[0]].index
// @ts-expect-error l[0]/l[2] used as node index
if (l[2] != null) l[2] = type.nodes[l[2]].index
}
// Rewrite externals
if (type.external) {
for (const ext of type.external) {
ext[0] = type.nodes[ext[0]]
if (ext[0] != null) {
// @ts-expect-error ext[0] used as node index
ext[0] = type.nodes[ext[0]].index
}
}
}
// Rewrite modifications
for (const id of keys) {
// @ts-expect-error id used as node index
if (config[id]) {
// @ts-expect-error fixme ts strict error
orderedConfig[type.nodes[id].index] = config[id]
}
// @ts-expect-error id used as config key
delete config[id]
}
@@ -529,7 +536,7 @@ export class ManageGroupDialog extends ComfyDialog<HTMLDialogElement> {
if (nodes) recreateNodes.push(...nodes)
}
await GroupNodeConfig.registerFromWorkflow(types, {})
await GroupNodeConfig.registerFromWorkflow(types, [])
for (const node of recreateNodes) {
node.recreate()

View File

@@ -5,6 +5,7 @@ import { app, ComfyApp } from '@/scripts/app'
import { useMaskEditorStore } from '@/stores/maskEditorStore'
import { useDialogStore } from '@/stores/dialogStore'
import { useMaskEditor } from '@/composables/maskeditor/useMaskEditor'
import { useCanvasTransform } from '@/composables/maskeditor/useCanvasTransform'
function openMaskEditor(node: LGraphNode): void {
if (!node) {
@@ -109,6 +110,42 @@ app.registerExtension({
const store = useMaskEditorStore()
store.colorInput?.click()
}
},
{
id: 'Comfy.MaskEditor.Rotate.Right',
icon: 'pi pi-refresh',
label: 'Rotate Right in MaskEditor',
function: async () => {
if (!isOpened()) return
await useCanvasTransform().rotateClockwise()
}
},
{
id: 'Comfy.MaskEditor.Rotate.Left',
icon: 'pi pi-undo',
label: 'Rotate Left in MaskEditor',
function: async () => {
if (!isOpened()) return
await useCanvasTransform().rotateCounterclockwise()
}
},
{
id: 'Comfy.MaskEditor.Mirror.Horizontal',
icon: 'pi pi-arrows-h',
label: 'Mirror Horizontal in MaskEditor',
function: async () => {
if (!isOpened()) return
await useCanvasTransform().mirrorHorizontal()
}
},
{
id: 'Comfy.MaskEditor.Mirror.Vertical',
icon: 'pi pi-arrows-v',
label: 'Mirror Vertical in MaskEditor',
function: async () => {
if (!isOpened()) return
await useCanvasTransform().mirrorVertical()
}
}
],
init() {

View File

@@ -370,9 +370,10 @@ const ext: ComfyExtension = {
const node = app.canvas.graph?.getNodeById(nodeIds[i])
const nodeData = node?.constructor.nodeData
let groupData = GroupNodeHandler.getGroupData(node)
if (groupData) {
groupData = groupData.nodeData
if (!node) continue
const groupConfig = GroupNodeHandler.getGroupData(node)
if (groupConfig) {
const groupData = groupConfig.nodeData
// @ts-expect-error
if (!data.groupNodes) {
// @ts-expect-error
@@ -402,7 +403,10 @@ const ext: ComfyExtension = {
callback: () => {
clipboardAction(async () => {
const data = JSON.parse(t.data)
await GroupNodeConfig.registerFromWorkflow(data.groupNodes, {})
await GroupNodeConfig.registerFromWorkflow(
data.groupNodes ?? {},
[]
)
// Check for old clipboard format
if (!data.reroutes) {

View File

@@ -41,7 +41,7 @@ useExtensionService().registerExtension({
app
)
showAsPlaintextWidget.widget.callback = (value) => {
showAsPlaintextWidget.widget.callback = (value: boolean) => {
showValueWidget.hidden = !value
showValueWidget.options.hidden = !value
showValueWidgetPlain.hidden = value

View File

@@ -30,6 +30,7 @@ const localeLoaders: Record<
> = {
ar: () => import('./locales/ar/main.json'),
es: () => import('./locales/es/main.json'),
fa: () => import('./locales/fa/main.json'),
fr: () => import('./locales/fr/main.json'),
ja: () => import('./locales/ja/main.json'),
ko: () => import('./locales/ko/main.json'),
@@ -46,6 +47,7 @@ const nodeDefsLoaders: Record<
> = {
ar: () => import('./locales/ar/nodeDefs.json'),
es: () => import('./locales/es/nodeDefs.json'),
fa: () => import('./locales/fa/nodeDefs.json'),
fr: () => import('./locales/fr/nodeDefs.json'),
ja: () => import('./locales/ja/nodeDefs.json'),
ko: () => import('./locales/ko/nodeDefs.json'),
@@ -62,6 +64,7 @@ const commandsLoaders: Record<
> = {
ar: () => import('./locales/ar/commands.json'),
es: () => import('./locales/es/commands.json'),
fa: () => import('./locales/fa/commands.json'),
fr: () => import('./locales/fr/commands.json'),
ja: () => import('./locales/ja/commands.json'),
ko: () => import('./locales/ko/commands.json'),
@@ -78,6 +81,7 @@ const settingsLoaders: Record<
> = {
ar: () => import('./locales/ar/settings.json'),
es: () => import('./locales/es/settings.json'),
fa: () => import('./locales/fa/settings.json'),
fr: () => import('./locales/fr/settings.json'),
ja: () => import('./locales/ja/settings.json'),
ko: () => import('./locales/ko/settings.json'),

View File

@@ -15,7 +15,7 @@ import { LGraphGroup } from './LGraphGroup'
import { LGraphNode } from './LGraphNode'
import type { NodeId } from './LGraphNode'
import { LLink } from './LLink'
import type { LinkId } from './LLink'
import type { LinkId, SerialisedLLinkArray } from './LLink'
import { MapProxyHandler } from './MapProxyHandler'
import { Reroute } from './Reroute'
import type { RerouteId } from './Reroute'
@@ -102,11 +102,24 @@ export interface LGraphConfig {
links_ontop?: any
}
export interface GroupNodeWorkflowData {
external: (number | string)[][]
links: SerialisedLLinkArray[]
nodes: {
index?: number
type?: string
inputs?: unknown[]
outputs?: unknown[]
}[]
config?: Record<number, unknown>
}
export interface LGraphExtra extends Dictionary<unknown> {
reroutes?: SerialisableReroute[]
linkExtensions?: { id: number; parentId: number | undefined }[]
ds?: DragAndScaleState
workflowRendererVersion?: RendererType
groupNodes?: Record<string, GroupNodeWorkflowData>
}
export interface BaseLGraph {
@@ -1509,6 +1522,22 @@ export class LGraph
} {
if (items.size === 0)
throw new Error('Cannot convert to subgraph: nothing to convert')
// Record state before conversion for proper undo support
this.beforeChange()
try {
return this._convertToSubgraphImpl(items)
} finally {
// Mark state change complete for proper undo support
this.afterChange()
}
}
private _convertToSubgraphImpl(items: Set<Positionable>): {
subgraph: Subgraph
node: SubgraphNode
} {
const { state, revision, config } = this
const firstChild = [...items][0]
if (items.size === 1 && firstChild instanceof LGraphGroup) {
@@ -1715,6 +1744,7 @@ export class LGraph
subgraphNode._setConcreteSlots()
subgraphNode.arrange()
this.canvasAction((c) =>
c.canvas.dispatchEvent(
new CustomEvent('subgraph-converted', {
@@ -1733,9 +1763,23 @@ export class LGraph
if (!(subgraphNode instanceof SubgraphNode))
throw new Error('Can only unpack Subgraph Nodes')
// Record state before unpacking for proper undo support
this.beforeChange()
try {
this._unpackSubgraphImpl(subgraphNode, options)
} finally {
// Mark state change complete for proper undo support
this.afterChange()
}
}
private _unpackSubgraphImpl(
subgraphNode: SubgraphNode,
options?: { skipMissingNodes?: boolean }
) {
const skipMissingNodes = options?.skipMissingNodes ?? false
this.beforeChange()
//NOTE: Create bounds can not be called on positionables directly as the subgraph is not being displayed and boundingRect is not initialized.
//NOTE: NODE_TITLE_HEIGHT is explicitly excluded here
const positionables = [
@@ -2006,7 +2050,6 @@ export class LGraph
}
this.canvasAction((c) => c.selectItems(toSelect))
this.afterChange()
}
/**

View File

@@ -1,5 +1,9 @@
import type { LGraphNode } from './LGraphNode'
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null
}
/**
* Default properties to track
*/
@@ -11,7 +15,6 @@ const DEFAULT_TRACKED_PROPERTIES: string[] = [
'color',
'bgcolor'
]
/**
* Manages node properties with optional change tracking and instrumentation.
*/
@@ -37,6 +40,34 @@ export class LGraphNodeProperties {
}
}
#resolveTargetObject(parts: string[]): {
targetObject: Record<string, unknown>
propertyName: string
} {
// LGraphNode supports dynamic property access at runtime
let targetObject: Record<string, unknown> = this.node as unknown as Record<
string,
unknown
>
if (parts.length === 1) {
return { targetObject, propertyName: parts[0] }
}
for (let i = 0; i < parts.length - 1; i++) {
const key = parts[i]
const next = targetObject[key]
if (isRecord(next)) {
targetObject = next
}
}
return {
targetObject,
propertyName: parts[parts.length - 1]
}
}
/**
* Instruments a single property to track changes
*/
@@ -47,15 +78,7 @@ export class LGraphNodeProperties {
this.#ensureNestedPath(path)
}
let targetObject: any = this.node
let propertyName = parts[0]
if (parts.length > 1) {
for (let i = 0; i < parts.length - 1; i++) {
targetObject = targetObject[parts[i]]
}
propertyName = parts.at(-1)!
}
const { targetObject, propertyName } = this.#resolveTargetObject(parts)
const hasProperty = Object.prototype.hasOwnProperty.call(
targetObject,
@@ -64,11 +87,11 @@ export class LGraphNodeProperties {
const currentValue = targetObject[propertyName]
if (!hasProperty) {
let value: any = undefined
let value: unknown = undefined
Object.defineProperty(targetObject, propertyName, {
get: () => value,
set: (newValue: any) => {
set: (newValue: unknown) => {
const oldValue = value
value = newValue
this.#emitPropertyChange(path, oldValue, newValue)
@@ -108,13 +131,20 @@ export class LGraphNodeProperties {
*/
#createInstrumentedDescriptor(
propertyPath: string,
initialValue: any
initialValue: unknown
): PropertyDescriptor {
let value = initialValue
return this.#createInstrumentedDescriptorTyped(propertyPath, initialValue)
}
#createInstrumentedDescriptorTyped<TValue>(
propertyPath: string,
initialValue: TValue
): PropertyDescriptor {
let value: TValue = initialValue
return {
get: () => value,
set: (newValue: any) => {
set: (newValue: TValue) => {
const oldValue = value
value = newValue
this.#emitPropertyChange(propertyPath, oldValue, newValue)
@@ -129,8 +159,16 @@ export class LGraphNodeProperties {
*/
#emitPropertyChange(
propertyPath: string,
oldValue: any,
newValue: any
oldValue: unknown,
newValue: unknown
): void {
this.#emitPropertyChangeTyped(propertyPath, oldValue, newValue)
}
#emitPropertyChangeTyped<TValue>(
propertyPath: string,
oldValue: TValue,
newValue: TValue
): void {
this.node.graph?.trigger('node:property:changed', {
nodeId: this.node.id,
@@ -145,7 +183,11 @@ export class LGraphNodeProperties {
*/
#ensureNestedPath(path: string): void {
const parts = path.split('.')
let current: any = this.node
// LGraphNode supports dynamic property access at runtime
let current: Record<string, unknown> = this.node as unknown as Record<
string,
unknown
>
// Create all parent objects except the last property
for (let i = 0; i < parts.length - 1; i++) {
@@ -153,7 +195,10 @@ export class LGraphNodeProperties {
if (!current[part]) {
current[part] = {}
}
current = current[part]
const next = current[part]
if (isRecord(next)) {
current = next
}
}
}
@@ -175,7 +220,7 @@ export class LGraphNodeProperties {
* Custom toJSON method for JSON.stringify
* Returns undefined to exclude from serialization since we only use defaults
*/
toJSON(): any {
toJSON(): undefined {
return undefined
}
}

View File

@@ -67,7 +67,7 @@ export class LiteGraphGlobal {
DEFAULT_SHADOW_COLOR = 'rgba(0,0,0,0.5)'
DEFAULT_GROUP_FONT = 24
DEFAULT_GROUP_FONT_SIZE?: any
DEFAULT_GROUP_FONT_SIZE = 24
GROUP_FONT = 'Inter'
WIDGET_BGCOLOR = '#222'
@@ -716,7 +716,7 @@ export class LiteGraphGlobal {
}
// used to create nodes from wrapping functions
getParameterNames(func: (...args: any) => any): string[] {
getParameterNames(func: (...args: unknown[]) => unknown): string[] {
return String(func)
.replaceAll(/\/\/.*$/gm, '') // strip single-line comments
.replaceAll(/\s+/g, '') // strip white space
@@ -971,7 +971,10 @@ export class LiteGraphGlobal {
}
}
extendClass(target: any, origin: any): void {
extendClass(
target: Record<string, unknown> & { prototype?: object },
origin: Record<string, unknown> & { prototype?: object }
): void {
for (const i in origin) {
// copy class properties
// eslint-disable-next-line no-prototype-builtins
@@ -979,33 +982,24 @@ export class LiteGraphGlobal {
target[i] = origin[i]
}
if (origin.prototype) {
if (origin.prototype && target.prototype) {
const originProto = origin.prototype as Record<string, unknown>
const targetProto = target.prototype as Record<string, unknown>
// copy prototype properties
for (const i in origin.prototype) {
for (const i in originProto) {
// only enumerable
// eslint-disable-next-line no-prototype-builtins
if (!origin.prototype.hasOwnProperty(i)) continue
if (!originProto.hasOwnProperty(i)) continue
// avoid overwriting existing ones
// eslint-disable-next-line no-prototype-builtins
if (target.prototype.hasOwnProperty(i)) continue
if (targetProto.hasOwnProperty(i)) continue
// copy getters
if (origin.prototype.__lookupGetter__(i)) {
target.prototype.__defineGetter__(
i,
origin.prototype.__lookupGetter__(i)
)
} else {
target.prototype[i] = origin.prototype[i]
}
// and setters
if (origin.prototype.__lookupSetter__(i)) {
target.prototype.__defineSetter__(
i,
origin.prototype.__lookupSetter__(i)
)
// Use Object.getOwnPropertyDescriptor to copy getters/setters properly
const descriptor = Object.getOwnPropertyDescriptor(originProto, i)
if (descriptor) {
Object.defineProperty(targetProto, i, descriptor)
}
}
}

View File

@@ -30,7 +30,7 @@ export class MapProxyHandler<V> implements ProxyHandler<
return [...target.keys()].map(String)
}
get(target: Map<number | string, V>, p: string | symbol): any {
get(target: Map<number | string, V>, p: string | symbol): V | undefined {
// Workaround does not support link IDs of "values", "entries", "constructor", etc.
if (p in target) return Reflect.get(target, p, target)
if (typeof p === 'symbol') return
@@ -42,7 +42,7 @@ export class MapProxyHandler<V> implements ProxyHandler<
set(
target: Map<number | string, V>,
p: string | symbol,
newValue: any
newValue: V
): boolean {
if (typeof p === 'symbol') return false
@@ -55,7 +55,7 @@ export class MapProxyHandler<V> implements ProxyHandler<
return target.delete(p as number | string)
}
static bindAllMethods(map: Map<any, any>): void {
static bindAllMethods(map: Map<unknown, unknown>): void {
map.clear = map.clear.bind(map)
map.delete = map.delete.bind(map)
map.forEach = map.forEach.bind(map)

View File

@@ -22,7 +22,7 @@ LiteGraphGlobal {
"CurveEditor": [Function],
"DEFAULT_FONT": "Inter",
"DEFAULT_GROUP_FONT": 24,
"DEFAULT_GROUP_FONT_SIZE": undefined,
"DEFAULT_GROUP_FONT_SIZE": 24,
"DEFAULT_POSITION": [
100,
100,

View File

@@ -62,7 +62,7 @@ export interface LGraphNodeConstructor<T extends LGraphNode = LGraphNode> {
size?: Size
min_height?: number
slot_start_y?: number
widgets_info?: any
widgets_info?: Record<string, unknown>
collapsable?: boolean
color?: string
bgcolor?: string

View File

@@ -343,7 +343,7 @@ export interface IBaseWidget<
// TODO: Confirm this format
callback?(
value: any,
value: unknown,
canvas?: LGraphCanvas,
node?: LGraphNode,
pos?: Point,

View File

@@ -78,7 +78,7 @@ export abstract class BaseWidget<
tooltip?: string
element?: HTMLElement
callback?(
value: any,
value: TWidget['value'],
canvas?: LGraphCanvas,
node?: LGraphNode,
pos?: Point,

View File

@@ -65,7 +65,7 @@ export class ButtonWidget
this.clicked = true
canvas.setDirty(true)
// Call the callback with widget instance and other context
this.callback?.(this, canvas, node, pos, e)
// Call the callback with widget value
this.callback?.(this.value, canvas, node, pos, e)
}
}

View File

@@ -1,4 +1,40 @@
{
"Comfy-Desktop_CheckForUpdates": {
"label": "التحقق من التحديثات"
},
"Comfy-Desktop_Folders_OpenCustomNodesFolder": {
"label": "فتح مجلد العقد المخصصة"
},
"Comfy-Desktop_Folders_OpenInputsFolder": {
"label": "فتح مجلد المدخلات"
},
"Comfy-Desktop_Folders_OpenLogsFolder": {
"label": "فتح مجلد السجلات"
},
"Comfy-Desktop_Folders_OpenModelConfig": {
"label": "فتح extra_model_paths.yaml"
},
"Comfy-Desktop_Folders_OpenModelsFolder": {
"label": "فتح مجلد النماذج"
},
"Comfy-Desktop_Folders_OpenOutputsFolder": {
"label": "فتح مجلد المخرجات"
},
"Comfy-Desktop_OpenDevTools": {
"label": "فتح أدوات المطور"
},
"Comfy-Desktop_OpenUserGuide": {
"label": "دليل مستخدم سطح المكتب"
},
"Comfy-Desktop_Quit": {
"label": "خروج"
},
"Comfy-Desktop_Reinstall": {
"label": "إعادة التثبيت"
},
"Comfy-Desktop_Restart": {
"label": "إعادة التشغيل"
},
"Comfy_3DViewer_Open3DViewer": {
"label": "فتح عارض ثلاثي الأبعاد (بيتا) للعقدة المحددة"
},
@@ -155,18 +191,30 @@
"Comfy_Manager_ShowUpdateAvailablePacks": {
"label": "التحقق من تحديثات العقد المخصصة"
},
"Comfy_Manager_ToggleManagerProgressDialog": {
"label": "تبديل شريط تقدم مدير العقد المخصصة"
},
"Comfy_MaskEditor_BrushSize_Decrease": {
"label": "تقليل حجم الفرشاة في محرر القناع"
},
"Comfy_MaskEditor_BrushSize_Increase": {
"label": "زيادة حجم الفرشاة في محرر القناع"
},
"Comfy_MaskEditor_ColorPicker": {
"label": "فتح منتقي الألوان في محرر القناع"
},
"Comfy_MaskEditor_Mirror_Horizontal": {
"label": "انعكاس أفقي في محرر القناع"
},
"Comfy_MaskEditor_Mirror_Vertical": {
"label": "انعكاس عمودي في محرر القناع"
},
"Comfy_MaskEditor_OpenMaskEditor": {
"label": "فتح محرر القناع للعقدة المحددة"
},
"Comfy_MaskEditor_Rotate_Left": {
"label": "تدوير لليسار في محرر القناع"
},
"Comfy_MaskEditor_Rotate_Right": {
"label": "تدوير لليمين في محرر القناع"
},
"Comfy_Memory_UnloadModels": {
"label": "تفريغ النماذج"
},
@@ -197,6 +245,9 @@
"Comfy_QueueSelectedOutputNodes": {
"label": "إدراج عقد الإخراج المحددة في قائمة الانتظار"
},
"Comfy_Queue_ToggleOverlay": {
"label": "تبديل سجل المهام"
},
"Comfy_Redo": {
"label": "إعادة"
},
@@ -221,6 +272,12 @@
"Comfy_ToggleHelpCenter": {
"label": "مركز المساعدة"
},
"Comfy_ToggleLinear": {
"label": "تبديل الوضع الخطي"
},
"Comfy_ToggleQPOV2": {
"label": "تبديل لوحة قائمة الانتظار V2"
},
"Comfy_ToggleTheme": {
"label": "تبديل النمط (فاتح/داكن)"
},

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -79,6 +79,17 @@
"Comfy_EnableWorkflowViewRestore": {
"name": "حفظ واستعادة موقع اللوحة ومستوى التكبير في سير العمل"
},
"Comfy_Execution_PreviewMethod": {
"name": "طريقة المعاينة الحية",
"options": {
"auto": "تلقائي",
"default": "default",
"latent2rgb": "latent2rgb",
"none": "بدون",
"taesd": "taesd"
},
"tooltip": "طريقة المعاينة الحية أثناء توليد الصورة. \"default\" تستخدم إعداد الخادم CLI."
},
"Comfy_FloatRoundingPrecision": {
"name": "عدد أرقام التقريب العشرية لأدوات التحكم العائمة [0 = تلقائي]",
"tooltip": "(يتطلب إعادة تحميل الصفحة)"
@@ -100,6 +111,10 @@
"None": "لا شيء"
}
},
"Comfy_Graph_LiveSelection": {
"name": "تحديد مباشر",
"tooltip": "عند التفعيل، يتم تحديد/إلغاء تحديد العقد في الوقت الفعلي أثناء سحب مستطيل التحديد، كما في أدوات التصميم الأخرى."
},
"Comfy_Graph_ZoomSpeed": {
"name": "سرعة تكبير اللوحة"
},
@@ -166,6 +181,15 @@
"name": "أدنى شدة إضاءة",
"tooltip": "يحدد الحد الأدنى المسموح به لشدة الإضاءة في المشاهد ثلاثية الأبعاد."
},
"Comfy_Load3D_PLYEngine": {
"name": "محرك PLY",
"options": {
"fastply": "fastply",
"sparkjs": "sparkjs",
"threejs": "threejs"
},
"tooltip": "اختر المحرك لتحميل ملفات PLY. \"threejs\" يستخدم PLYLoader الأصلي من Three.js (الأفضل لملفات PLY الشبكية). \"fastply\" يستخدم محمل محسن لملفات PLY السحابية النقطية بنسق ASCII. \"sparkjs\" يستخدم Spark.js لملفات PLY الخاصة بتوزيع Gaussian ثلاثي الأبعاد."
},
"Comfy_Load3D_ShowGrid": {
"name": "رؤية الشبكة الابتدائية",
"tooltip": "يتحكم في ظهور الشبكة بشكل افتراضي عند إنشاء عنصر ثلاثي الأبعاد جديد."

View File

@@ -191,9 +191,6 @@
"Comfy_Manager_ShowUpdateAvailablePacks": {
"label": "Check for Custom Node Updates"
},
"Comfy_Manager_ToggleManagerProgressDialog": {
"label": "Toggle the Custom Nodes Manager Progress Bar"
},
"Comfy_MaskEditor_BrushSize_Decrease": {
"label": "Decrease Brush Size in MaskEditor"
},
@@ -203,9 +200,21 @@
"Comfy_MaskEditor_ColorPicker": {
"label": "Open Color Picker in MaskEditor"
},
"Comfy_MaskEditor_Mirror_Horizontal": {
"label": "Mirror Horizontal in MaskEditor"
},
"Comfy_MaskEditor_Mirror_Vertical": {
"label": "Mirror Vertical in MaskEditor"
},
"Comfy_MaskEditor_OpenMaskEditor": {
"label": "Open Mask Editor for Selected Node"
},
"Comfy_MaskEditor_Rotate_Left": {
"label": "Rotate Left in MaskEditor"
},
"Comfy_MaskEditor_Rotate_Right": {
"label": "Rotate Right in MaskEditor"
},
"Comfy_Memory_UnloadModels": {
"label": "Unload Models"
},

View File

@@ -10,8 +10,10 @@
"downloadVideo": "Download video",
"editOrMaskImage": "Edit or mask image",
"editImage": "Edit image",
"decrement": "Decrement",
"deleteImage": "Delete image",
"deleteAudioFile": "Delete audio file",
"increment": "Increment",
"removeImage": "Remove image",
"removeVideo": "Remove video",
"chart": "Chart",
@@ -694,6 +696,7 @@
"noFilesFound": "No files found",
"noImportedFiles": "No imported files found",
"noGeneratedFiles": "No generated files found",
"generatedAssetsHeader": "Generated assets",
"noFilesFoundMessage": "Upload files or generate content to see them here",
"browseTemplates": "Browse example templates",
"openWorkflow": "Open workflow in local file system",
@@ -835,6 +838,7 @@
"customNodesManager": "Custom Nodes Manager",
"settings": "Settings",
"help": "Help",
"helpAndFeedback": "Help & Feedback",
"queue": "Queue Panel"
},
"tabMenu": {
@@ -974,6 +978,10 @@
"clear": "Clear",
"undo": "Undo",
"redo": "Redo",
"rotateLeft": "Rotate Left",
"rotateRight": "Rotate Right",
"mirrorHorizontal": "Mirror Horizontal",
"mirrorVertical": "Mirror Vertical",
"clickToResetZoom": "Click to reset zoom",
"brushSettings": "Brush Settings",
"brushShape": "Brush Shape",
@@ -1147,11 +1155,14 @@
"Manager Menu (Legacy)": "Manager Menu (Legacy)",
"Install Missing Custom Nodes": "Install Missing Custom Nodes",
"Check for Custom Node Updates": "Check for Custom Node Updates",
"Toggle the Custom Nodes Manager Progress Bar": "Toggle the Custom Nodes Manager Progress Bar",
"Decrease Brush Size in MaskEditor": "Decrease Brush Size in MaskEditor",
"Increase Brush Size in MaskEditor": "Increase Brush Size in MaskEditor",
"Open Color Picker in MaskEditor": "Open Color Picker in MaskEditor",
"Mirror Horizontal in MaskEditor": "Mirror Horizontal in MaskEditor",
"Mirror Vertical in MaskEditor": "Mirror Vertical in MaskEditor",
"Open Mask Editor for Selected Node": "Open Mask Editor for Selected Node",
"Rotate Left in MaskEditor": "Rotate Left in MaskEditor",
"Rotate Right in MaskEditor": "Rotate Right in MaskEditor",
"Unload Models": "Unload Models",
"Unload Models and Execution Cache": "Unload Models and Execution Cache",
"New": "New",
@@ -1515,6 +1526,7 @@
"HOOK_KEYFRAMES": "HOOK_KEYFRAMES",
"HOOKS": "HOOKS",
"IMAGE": "IMAGE",
"IMAGECOMPARE": "IMAGECOMPARE",
"INT": "INT",
"LATENT": "LATENT",
"LATENT_OPERATION": "LATENT_OPERATION",
@@ -1918,12 +1930,27 @@
"insufficientWorkflowMessage": "You don't have enough credits to run this workflow.",
"creditsDescription": "Credits are used to run workflows or partner nodes.",
"howManyCredits": "How many credits would you like to add?",
"videosEstimate": "~{count} videos",
"usdAmount": "${amount}",
"videosEstimate": "~{count} videos*",
"templateNote": "*Generated with Wan Fun Control template",
"buy": "Buy",
"purchaseError": "Purchase Failed",
"purchaseErrorDetail": "Failed to purchase credits: {error}",
"unknownError": "An unknown error occurred"
"unknownError": "An unknown error occurred",
"viewPricing": "View pricing details",
"youPay": "Amount (USD)",
"youGet": "Credits",
"buyCredits": "Continue to payment",
"minimumPurchase": "${amount} minimum ({credits} credits)",
"maximumAmount": "${amount} max.",
"minRequired": "{credits} credits minimum",
"maxAllowed": "{credits} credits maximum.",
"creditsPerDollar": "credits per dollar",
"amountToPayLabel": "Amount to pay in dollars",
"creditsToReceiveLabel": "Credits to receive",
"selectAmount": "Select amount",
"needMore": "Need more?",
"contactUs": "Contact us"
},
"creditsAvailable": "Credits available",
"refreshes": "Refreshes {date}",
@@ -1960,9 +1987,9 @@
"monthlyBonusDescription": "Monthly credit bonus",
"prepaidDescription": "Pre-paid credits",
"prepaidCreditsInfo": "Pre-paid credits expire after 1 year from purchase date.",
"creditsRemainingThisMonth": "Credits remaining this month",
"creditsRemainingThisYear": "Credits remaining this year",
"creditsYouveAdded": "Credits you've added",
"creditsRemainingThisMonth": "Included (Refills {date})",
"creditsRemainingThisYear": "Included (Refills {date})",
"creditsYouveAdded": "Additional",
"monthlyCreditsInfo": "These credits refresh monthly and don't roll over",
"viewMoreDetailsPlans": "View more details about plans & pricing",
"nextBillingCycle": "next billing cycle",
@@ -2017,7 +2044,7 @@
"subscribeTo": "Subscribe to {plan}",
"monthlyCreditsLabel": "Monthly credits",
"yearlyCreditsLabel": "Total yearly credits",
"maxDurationLabel": "Max duration of each workflow run",
"maxDurationLabel": "Max run duration",
"gpuLabel": "RTX 6000 Pro (96GB VRAM)",
"addCreditsLabel": "Add more credits whenever",
"customLoRAsLabel": "Import your own LoRAs",
@@ -2395,9 +2422,12 @@
},
"selection": {
"selectedCount": "Assets Selected: {count}",
"multipleSelectedAssets": "Multiple assets selected",
"deselectAll": "Deselect all",
"downloadSelected": "Download",
"downloadSelectedAll": "Download all",
"deleteSelected": "Delete",
"deleteSelectedAll": "Delete all",
"downloadStarted": "Downloading {count} files...",
"downloadsStarted": "Started downloading {count} file(s)",
"assetsDeletedSuccessfully": "{count} asset(s) deleted successfully",
@@ -2480,5 +2510,21 @@
"help": {
"recentReleases": "Recent releases",
"helpCenterMenu": "Help Center Menu"
},
"progressToast": {
"importingModels": "Importing Models",
"downloadingModel": "Downloading model...",
"downloadsFailed": "{count} downloads failed | {count} download failed | {count} downloads failed",
"allDownloadsCompleted": "All downloads completed",
"noImportsInQueue": "No {filter} in queue",
"failed": "Failed",
"finished": "Finished",
"pending": "Pending",
"progressCount": "{completed} of {total}",
"filter": {
"all": "All",
"completed": "Completed",
"failed": "Failed"
}
}
}

View File

@@ -3876,6 +3876,21 @@
}
}
},
"ImageCompare": {
"display_name": "Image Compare",
"description": "Compares two images side by side with a slider.",
"inputs": {
"compare_view": {
"name": "compare_view"
},
"image_a": {
"name": "image_a"
},
"image_b": {
"name": "image_b"
}
}
},
"ImageCompositeMasked": {
"display_name": "ImageCompositeMasked",
"inputs": {
@@ -4383,6 +4398,24 @@
}
}
},
"JoinAudioChannels": {
"display_name": "Join Audio Channels",
"description": "Joins left and right mono audio channels into a stereo audio.",
"inputs": {
"audio_left": {
"name": "audio_left"
},
"audio_right": {
"name": "audio_right"
}
},
"outputs": {
"0": {
"name": "audio",
"tooltip": null
}
}
},
"JoinImageWithAlpha": {
"display_name": "Join Image with Alpha",
"inputs": {
@@ -6088,6 +6121,9 @@
},
"ckpt_name": {
"name": "ckpt_name"
},
"device": {
"name": "device"
}
},
"outputs": {
@@ -14318,6 +14354,166 @@
}
}
},
"Vidu2ImageToVideoNode": {
"display_name": "Vidu2 Image-to-Video Generation",
"description": "Generate a video from an image and an optional prompt.",
"inputs": {
"model": {
"name": "model"
},
"image": {
"name": "image",
"tooltip": "An image to be used as the start frame of the generated video."
},
"prompt": {
"name": "prompt",
"tooltip": "An optional text prompt for video generation (max 2000 characters)."
},
"duration": {
"name": "duration"
},
"seed": {
"name": "seed"
},
"resolution": {
"name": "resolution"
},
"movement_amplitude": {
"name": "movement_amplitude",
"tooltip": "The movement amplitude of objects in the frame."
},
"control_after_generate": {
"name": "control after generate"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"Vidu2ReferenceVideoNode": {
"display_name": "Vidu2 Reference-to-Video Generation",
"description": "Generate a video from multiple reference images and a prompt.",
"inputs": {
"model": {
"name": "model"
},
"subjects": {
"name": "subjects",
"tooltip": "For each subject, provide up to 3 reference images (7 images total across all subjects). Reference them in prompts via @subject{subject_id}."
},
"prompt": {
"name": "prompt",
"tooltip": "When enabled, the video will include generated speech and background music based on the prompt."
},
"audio": {
"name": "audio",
"tooltip": "When enabled video will contain generated speech and background music based on the prompt."
},
"duration": {
"name": "duration"
},
"seed": {
"name": "seed"
},
"aspect_ratio": {
"name": "aspect_ratio"
},
"resolution": {
"name": "resolution"
},
"movement_amplitude": {
"name": "movement_amplitude",
"tooltip": "The movement amplitude of objects in the frame."
},
"control_after_generate": {
"name": "control after generate"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"Vidu2StartEndToVideoNode": {
"display_name": "Vidu2 Start/End Frame-to-Video Generation",
"description": "Generate a video from a start frame, an end frame, and a prompt.",
"inputs": {
"model": {
"name": "model"
},
"first_frame": {
"name": "first_frame"
},
"end_frame": {
"name": "end_frame"
},
"prompt": {
"name": "prompt",
"tooltip": "Prompt description (max 2000 characters)."
},
"duration": {
"name": "duration"
},
"seed": {
"name": "seed"
},
"resolution": {
"name": "resolution"
},
"movement_amplitude": {
"name": "movement_amplitude",
"tooltip": "The movement amplitude of objects in the frame."
},
"control_after_generate": {
"name": "control after generate"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"Vidu2TextToVideoNode": {
"display_name": "Vidu2 Text-to-Video Generation",
"description": "Generate video from a text prompt",
"inputs": {
"model": {
"name": "model"
},
"prompt": {
"name": "prompt",
"tooltip": "A textual description for video generation, with a maximum length of 2000 characters."
},
"duration": {
"name": "duration"
},
"seed": {
"name": "seed"
},
"aspect_ratio": {
"name": "aspect_ratio"
},
"resolution": {
"name": "resolution"
},
"background_music": {
"name": "background_music",
"tooltip": "Whether to add background music to the generated video."
},
"control_after_generate": {
"name": "control after generate"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"ViduImageToVideoNode": {
"display_name": "Vidu Image To Video Generation",
"description": "Generate video from image and optional prompt",
@@ -14362,7 +14558,7 @@
},
"ViduReferenceVideoNode": {
"display_name": "Vidu Reference To Video Generation",
"description": "Generate video from multiple images and prompt",
"description": "Generate video from multiple images and a prompt",
"inputs": {
"model": {
"name": "model",
@@ -14454,7 +14650,7 @@
},
"ViduTextToVideoNode": {
"display_name": "Vidu Text To Video Generation",
"description": "Generate video from text prompt",
"description": "Generate video from a text prompt",
"inputs": {
"model": {
"name": "model",
@@ -15365,6 +15561,51 @@
}
}
},
"WanReferenceVideoApi": {
"display_name": "Wan Reference to Video",
"description": "Use the character and voice from input videos, combined with a prompt, to generate a new video that maintains character consistency.",
"inputs": {
"model": {
"name": "model"
},
"prompt": {
"name": "prompt",
"tooltip": "Prompt describing the elements and visual features. Supports English and Chinese. Use identifiers such as `character1` and `character2` to refer to the reference characters."
},
"negative_prompt": {
"name": "negative_prompt",
"tooltip": "Negative prompt describing what to avoid."
},
"reference_videos": {
"name": "reference_videos"
},
"size": {
"name": "size"
},
"duration": {
"name": "duration"
},
"seed": {
"name": "seed"
},
"shot_type": {
"name": "shot_type",
"tooltip": "Specifies the shot type for the generated video, that is, whether the video is a single continuous shot or multiple shots with cuts."
},
"watermark": {
"name": "watermark",
"tooltip": "Whether to add an AI-generated watermark to the result."
},
"control_after_generate": {
"name": "control after generate"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"WanSoundImageToVideo": {
"display_name": "WanSoundImageToVideo",
"inputs": {

View File

@@ -369,6 +369,14 @@
"Comfy_TreeExplorer_ItemPadding": {
"name": "Tree explorer item padding"
},
"Comfy_UI_TabBarLayout": {
"name": "Tab Bar Layout",
"tooltip": "Controls the layout of the tab bar. \"Integrated\" moves Help and User controls into the tab bar area.",
"options": {
"Default": "Default",
"Integrated": "Integrated"
}
},
"Comfy_UseNewMenu": {
"name": "Use new menu",
"tooltip": "Enable the redesigned top menu bar.",

View File

@@ -1,4 +1,40 @@
{
"Comfy-Desktop_CheckForUpdates": {
"label": "Buscar actualizaciones"
},
"Comfy-Desktop_Folders_OpenCustomNodesFolder": {
"label": "Abrir carpeta de nodos personalizados"
},
"Comfy-Desktop_Folders_OpenInputsFolder": {
"label": "Abrir carpeta de entradas"
},
"Comfy-Desktop_Folders_OpenLogsFolder": {
"label": "Abrir carpeta de registros"
},
"Comfy-Desktop_Folders_OpenModelConfig": {
"label": "Abrir extra_model_paths.yaml"
},
"Comfy-Desktop_Folders_OpenModelsFolder": {
"label": "Abrir carpeta de modelos"
},
"Comfy-Desktop_Folders_OpenOutputsFolder": {
"label": "Abrir carpeta de salidas"
},
"Comfy-Desktop_OpenDevTools": {
"label": "Abrir herramientas de desarrollo"
},
"Comfy-Desktop_OpenUserGuide": {
"label": "Guía de usuario de escritorio"
},
"Comfy-Desktop_Quit": {
"label": "Salir"
},
"Comfy-Desktop_Reinstall": {
"label": "Reinstalar"
},
"Comfy-Desktop_Restart": {
"label": "Reiniciar"
},
"Comfy_3DViewer_Open3DViewer": {
"label": "Abrir visor 3D (Beta) para el nodo seleccionado"
},
@@ -155,18 +191,30 @@
"Comfy_Manager_ShowUpdateAvailablePacks": {
"label": "Buscar actualizaciones"
},
"Comfy_Manager_ToggleManagerProgressDialog": {
"label": "Alternar diálogo de progreso del administrador"
},
"Comfy_MaskEditor_BrushSize_Decrease": {
"label": "Disminuir tamaño del pincel en MaskEditor"
},
"Comfy_MaskEditor_BrushSize_Increase": {
"label": "Aumentar tamaño del pincel en MaskEditor"
},
"Comfy_MaskEditor_ColorPicker": {
"label": "Abrir selector de color en MaskEditor"
},
"Comfy_MaskEditor_Mirror_Horizontal": {
"label": "Espejar horizontalmente en MaskEditor"
},
"Comfy_MaskEditor_Mirror_Vertical": {
"label": "Espejar verticalmente en MaskEditor"
},
"Comfy_MaskEditor_OpenMaskEditor": {
"label": "Abrir editor de máscara para el nodo seleccionado"
},
"Comfy_MaskEditor_Rotate_Left": {
"label": "Girar a la izquierda en MaskEditor"
},
"Comfy_MaskEditor_Rotate_Right": {
"label": "Girar a la derecha en MaskEditor"
},
"Comfy_Memory_UnloadModels": {
"label": "Descargar modelos"
},
@@ -197,6 +245,9 @@
"Comfy_QueueSelectedOutputNodes": {
"label": "Encolar nodos de salida seleccionados"
},
"Comfy_Queue_ToggleOverlay": {
"label": "Alternar historial de trabajos"
},
"Comfy_Redo": {
"label": "Rehacer"
},
@@ -221,6 +272,12 @@
"Comfy_ToggleHelpCenter": {
"label": "Centro de ayuda"
},
"Comfy_ToggleLinear": {
"label": "alternar modo lineal"
},
"Comfy_ToggleQPOV2": {
"label": "Alternar Panel de Cola V2"
},
"Comfy_ToggleTheme": {
"label": "Cambiar Tema (Oscuro/Claro)"
},

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