Compare commits

...

47 Commits

Author SHA1 Message Date
bymyself
6cb4a318c1 remove type asertion 2025-11-15 13:51:04 -08:00
Jin Yi
8dd5a9900b [refactor] Unify Cloud/OSS Missing Nodes modal (#6673)
## Summary
- Merged separate Cloud and OSS workflow warning modals into single
unified modal
- Removed legacy LoadWorkflowWarning.vue
- Renamed CloudMissingNodes* components to MissingNodes* for clarity
- Environment branching now handled internally via isCloud flag
- Restructured i18n: removed loadWorkflowWarning, added
missingNodes.cloud/oss sections
- Improved OSS button styling to match Cloud consistency

## Key Changes
- **OSS**: "Open Manager" + "Install All" buttons
- **Cloud**: "Learn More" + "Got It" buttons (unchanged)
- Single unified modal displays different UI/text based on environment

## 📝 Note on File Renames

This PR renames the following files:

- `CloudMissingNodesHeader.vue` → `MissingNodesHeader.vue` (R053, 53%
similarity)
- `CloudMissingNodesContent.vue` → `MissingNodesContent.vue` (R067, 67%
similarity)
- `LoadWorkflowWarning.vue` → `MissingNodesFooter.vue` (R051, 51%
similarity)
- `CloudMissingNodesFooter.vue` → Deleted (replaced by new
MissingNodesFooter)

**Why GitHub PR UI doesn't show renames properly:**

GitHub detects renames only when file similarity is above 70%. In this
PR, the Cloud/OSS unification significantly modified file contents,
resulting in 51-67% similarity.

However, **Git history correctly records these as renames**. You can
verify with:

```bash
git show <commit-hash> --name-status
```

While GitHub UI shows "additions/deletions", these are actually rename +
modification operations.

## Test Plan
- [x] Test OSS mode: missing nodes modal shows "Open Manager" and
"Install All" buttons
- [x] Test Cloud mode: missing nodes modal shows "Learn More" and "Got
It" buttons
- [x] Verify Install All button functionality in OSS
- [x] Verify modal closes automatically after all nodes are installed
(OSS)


[missingnodes.webm](https://github.com/user-attachments/assets/36d3b4b0-ff8b-4b45-824c-3bc15d93f1a2)

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

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6673-refactor-Unify-Cloud-OSS-Missing-Nodes-modal-2aa6d73d365081a88827d0fa85db4c63)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Claude <noreply@anthropic.com>
2025-11-15 12:09:44 -08:00
Terry Jia
7a11dc59b6 [refactor] remove node as dependency in 3d node (#6707)
## Summary

This PR refactors the Load3d 3D rendering system to remove its direct
dependency on LGraphNode, making it a more decoupled and reusable
component. The core rendering engine is now framework-agnostic and can
be used in any context, not just within LiteGraph nodes.

## Changes

1. Decoupled Load3d from LGraphNode
  - Before: Load3d directly accessed node.widgets and node.properties
- After: Load3d accepts optional parameters and callbacks, delegating
node integration to the calling code

2. Event-Driven State Management
  - Removed internal storage from Load3d core components
- Camera, controls, and view helper managers now emit cameraChanged
events instead of directly storing state
- External code (e.g., useLoad3d) listens to events and handles
persistence to node.properties

3. Reactive Dimension Updates

- Introduced getDimensions callback to support reactive dimension
updates
- Fixes the issue where dimension changes in vueNodes mode required a
refresh
- The callback is invoked on every render to get fresh width/height
values

4. Improved Configuration System

- Load3DConfiguration now accepts properties: Dictionary<NodeProperty |
undefined> instead of custom storage
  interface
  - Uses official LiteGraph type definitions (Dictionary, NodeProperty)
  - More semantic parameter naming: storage → properties

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6707-refactor-remove-node-as-dependency-in-3d-node-2ab6d73d365081ffac1cdce354781ce8)
by [Unito](https://www.unito.io)
2025-11-15 06:36:36 -05:00
Jin Yi
ba768c32f3 fix: Prevent text selection in MediaAssetCard (#6708)
## Summary
- Prevent text selection when clicking or dragging MediaAssetCard
- Add `select-none` Tailwind class to prevent unwanted text highlighting

## Changes
- Changed class from `gap-1` to `gap-1 select-none` in MediaAssetCard
container

## Problem
When users click or drag on a MediaAssetCard, the text content (tags,
titles, descriptions, buttons) gets selected and highlighted, which
creates a poor user experience.

## Solution
Added the `select-none` Tailwind CSS class which applies `user-select:
none` to prevent text selection within the card during mouse
interactions.

## Test plan
- [x] Click on MediaAssetCard and verify text is not selected
- [x] Drag across MediaAssetCard and verify text is not highlighted
- [x] Verify card selection still works properly
- [x] Verify buttons and interactive elements still work

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

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6708-fix-Prevent-text-selection-in-MediaAssetCard-2ac6d73d365081f6bec2ffebad7cb7ed)
by [Unito](https://www.unito.io)

Co-authored-by: Claude <noreply@anthropic.com>
2025-11-15 05:58:57 +00:00
Jin Yi
e3f19ab856 feat: Add media type filtering to Media Asset Panel (#6701) 2025-11-15 05:20:00 +00:00
Terry Jia
a9f416233d feat: support open 3d viewer in media asset panel (#6703)
## Summary

Add support for previewing 3D assets directly in the Media Asset Panel.

## Changes

- **3D Asset Preview**: Clicking on 3D assets (`.glb`, `.gltf`, etc.) in
the Media Asset Panel now opens the full
  3D viewer

## Screenshots



https://github.com/user-attachments/assets/38808712-acc8-42aa-9f11-8d8bf2387b20

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6703-feat-support-open-3d-viewer-in-media-asset-panel-2ab6d73d3650811dbff9ecb570a0a878)
by [Unito](https://www.unito.io)
2025-11-14 14:31:58 -05:00
AustinMroz
b23a92b442 Fix pointer events passing through sideToolbar (#6700)
#6103 Made it so clicks in the gaps on the side toolbar would pass
through to the canvas. Since the gap has been removed from the side
toolbar, this would incorrectly allow the canvas to be dragged through
the toolbar as shown.

![unfix-mouse_00003](https://github.com/user-attachments/assets/301f9d61-b73b-448f-bfaa-d236509d7f1a)

Credit to @Kosinkadink for finding this.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6700-Fix-pointer-events-passing-through-sideToolbar-2ab6d73d365081d2948fceba909b7164)
by [Unito](https://www.unito.io)
2025-11-14 01:17:46 -08:00
Jin Yi
8c3caa77d6 fix: Add 3D file support to Media Asset Panel (#6699)
## Summary
Fix bug where 3D files were not displayed in the Media Asset Panel's
Generated tab

## Problem
- 3D files (`.obj`, `.fbx`, `.gltf`, `.glb`) appear correctly in
QueueSidebarTab
- 3D files do not appear in Media Asset Panel's Generated tab

## Root Cause
`ResultItemImpl.supportsPreview` getter only checked for Image, Video,
and Audio files, excluding 3D files. This caused:
1. 3D files to be filtered out in `TaskItemImpl.previewOutput`
2. Items with undefined `previewOutput` to be skipped in
`mapHistoryToAssets`
3. 3D files not appearing in the Media Asset Panel

## Solution
- Add `is3D` getter to `ResultItemImpl`
- Include 3D file support in `supportsPreview`
- Use `getMediaTypeFromFilename` utility to detect 3D file types based
on extension

## Changes
- `src/stores/queueStore.ts`:
  - Import `getMediaTypeFromFilename`
  - Add `is3D` getter
  - Update `supportsPreview` to include `|| this.is3D`

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

Co-authored-by: Claude <noreply@anthropic.com>
2025-11-14 00:24:03 -08:00
Jin Yi
ad6dda4435 feat: Add generation time sort options to Media Asset Panel (#6698)
## Summary
Add generation time-based sorting options to the Media Asset Panel

## Changes
- **New sorting options**:
  - Generation time (longest first) - Sort by longest execution time
  - Generation time (fastest first) - Sort by shortest execution time

- **Show only in Generated tab**: 
- Generation time sorting is only meaningful for output assets with
`executionTimeInSeconds` metadata
  - Implemented conditional rendering via `showGenerationTimeSort` prop

## Technical Details
- `useMediaAssetFiltering.ts`: 
  - Added `'longest'` and `'fastest'` to `SortOption` type
  - Added `getAssetExecutionTime` helper function
  - Implemented sorting logic using switch-case pattern
  
- `MediaAssetSortMenu.vue`: 
  - Added `showGenerationTimeSort` prop
- Generation time sort buttons placed inside `<template
v-if="showGenerationTimeSort">`
  
- `MediaAssetFilterBar.vue`: 
- Receives `showGenerationTimeSort` prop and passes it to
`MediaAssetSortMenu`
  
- `AssetsSidebarTab.vue`: 
- Passes `showGenerationTimeSort` prop based on `activeTab === 'output'`
  
- `src/locales/en/main.json`: 
  - Added `sortLongestFirst`: "Generation time (longest first)"
  - Added `sortFastestFirst`: "Generation time (fastest first)"

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

Co-authored-by: Claude <noreply@anthropic.com>
2025-11-14 07:09:32 +00:00
Jin Yi
c43cd287bb fix: Hide delete button for input assets in OSS and fix cloud download (#6697)
## Summary
Fix delete button visibility for input assets in OSS environment and
resolve 404 error when downloading assets in cloud.

## Changes

### 1. Improved Delete Button Visibility Logic
- **Problem**: In OSS environment, input files are sourced from local
folders and cannot be deleted
- **Solution**: Added `shouldShowDeleteButton` computed property to
conditionally hide delete buttons
- **Impact**: 
  - Input tab + Cloud: Delete button shown 
  - Input tab + OSS: Delete button hidden 
  - Output tab (all environments): Delete button shown 

### 2. Fixed Cloud Download 404 Error
- **Problem**: Downloading files from imported tab in cloud returned 404
error for `/api/view` endpoint
- **Root Cause**: In cloud environment, files are stored in external
storage (e.g., GCS) and `/api/view` endpoint is not available
- **Solution**: 
  - Cloud: Use `preview_url` directly for downloads
  - OSS/localhost: Continue using `/api/view` endpoint as before
- Applied the same logic to both single and bulk download operations

## Test Plan
- [ ] Verify delete button is hidden in input tab on OSS environment
- [ ] Verify delete button is shown in input tab on cloud environment
- [ ] Verify file downloads work correctly in cloud for both input and
output tabs
- [ ] Verify file downloads work correctly in OSS for output tab

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

Co-authored-by: Claude <noreply@anthropic.com>
2025-11-13 22:57:43 -08:00
ComfyUI Wiki
b347dd1734 Centralized management of external links (#4471)
Update the desktop guide links to make them platform and locale-aware

Edited by Terry:
Refactor external link management by introducing a centralized
useExternalLink composable with automatic locale and platform detection
for documentation URLs.

- Created useExternalLink composable - A new centralized utility for
managing all external links
- Dynamic docs URL builder (buildDocsUrl) - Automatically constructs
docs.comfy.org URLs with:
  - Locale detection (Chinese vs English)
  - Platform detection (macOS vs Windows for desktop)
  - Flexible path construction with options

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-4471-Add-platform-and-locale-aware-desktop-guide-URL-2346d73d3650815ea4a4dd64be575bbe)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Terry Jia <terryjia88@gmail.com>
2025-11-13 21:35:28 -08:00
Terry Jia
1a6913c466 fully refactor mask editor into vue-based (#6629)
## Summary

This PR refactors the mask editor from a vanilla JavaScript
implementation to Vue 3 + Composition API, aligning it with the ComfyUI
frontend's modern architecture. This is a structural refactor without UI
changes - all visual appearances and user interactions remain identical.

Net change: +1,700 lines (mostly tests)

## Changes

- Converted from class-based managers to Vue 3 Composition API
- Migrated state management to Pinia stores (maskEditorStore,
maskEditorDataStore)
- Split monolithic managers into focused composables:
    - useBrushDrawing - Brush rendering and drawing logic
    - useCanvasManager - Canvas lifecycle and operations
    - useCanvasTools - Tool-specific canvas operations
    - usePanAndZoom - Pan and zoom functionality
    - useToolManager - Tool selection and coordination
    - useKeyboard - Keyboard shortcuts
    - useMaskEditorLoader/Saver - Data loading and saving
    - useCoordinateTransform - Coordinate system transformations
- Replaced imperative DOM manipulation with Vue components
- Added comprehensive test coverage

## What This PR Does NOT Change

  Preserved Original Styling:
  - Original CSS retained in packages/design-system/src/css/style.css
- Some generic controls (DropdownControl, SliderControl, ToggleControl)
preserved as-is
- Future migration to Tailwind and PrimeVue components is planned but
out of scope for this PR

  Preserved Core Functionality:
  - Drawing algorithms and brush rendering logic remain unchanged
  - Pan/zoom calculations preserved
  - Canvas operations (composite modes, image processing) unchanged
  - Tool behaviors (brush, color select, paint bucket) identical
  - No changes to mask generation or export logic

DO NOT Review:
  -  CSS styling choices (preserved from original)
  - Drawing algorithm implementations (unchanged)
  -  Canvas rendering logic (ported as-is)
  - UI/UX changes (none exist)
  - Component library choices (future work)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6629-fully-refactor-mask-editor-into-vue-based-2a46d73d36508114ab8bd2984b4b54e4)
by [Unito](https://www.unito.io)
2025-11-13 20:57:03 -08:00
Jin Yi
f80fc4cf9a feat: Add sort functionality to Media Asset Panel (#6695)
## Overview
Adds sort functionality to the Media Asset Panel. Users can sort assets
by creation time in Cloud environments.

## Key Changes

### 1. Sort Functionality (Cloud Only)
- "Newest first" (most recent)
- "Oldest first" (oldest)
- Sorting based on `create_time` field (output assets)
- Sorting based on `created_at` field (input assets)
- Sort button is only displayed in Cloud environments

### 2. create_time Field Integration
**Related PR**: #6092

Implemented sort functionality using the `create_time` field introduced
in PR #6092. Applied the code from that PR directly to the following
files:

- `src/schemas/apiSchema.ts`: Added `create_time` field to `zExtraData`
- `src/stores/queueStore.ts`: Added `createTime` getter to
`TaskItemImpl`
- `src/platform/remote/comfyui/history/types/historyV2Types.ts`: Added
`create_time` to History V2 API response types
- `src/platform/remote/comfyui/history/adapters/v2ToV1Adapter.ts`: Pass
through `create_time` in V2→V1 adapter
- `src/platform/assets/composables/media/assetMappers.ts`: Include
`create_time` in asset metadata

### 3. Component Structure Improvements
Created new components following existing component styles for
consistency:

- **`MediaAssetSearchBar.vue`**: Component combining existing SearchBox
with sort button
- **`AssetSortButton.vue`**: Same structure as `MoreButton.vue`
(IconButton + Popover)
- **`MediaAssetSortMenu.vue`**: Same style as `MediaAssetMoreMenu.vue`
(using IconTextButton)
- **`AssetsSidebarTab.vue`**: Refactored to use `MediaAssetSearchBar`

### 4. Utility Usage
- Improved sort logic using `es-toolkit`'s `sortBy`
- Follows project guidelines (CLAUDE.md)

## Technical Details

### History V2 API's create_time
- Cloud backend provides `create_time` (in milliseconds) through History
V2 API
- Enables accurate sorting by creation time
- For input assets, uses existing `created_at` (ISO string)

### Sort Implementation
Uses `es-toolkit`'s `sortBy` in `useMediaAssetFiltering` composable:

```typescript
// Get timestamp from asset (either create_time or created_at)
const getAssetTime = (asset: AssetItem): number => {
  return (
    (asset.user_metadata?.create_time as number) ??
    (asset.created_at ? new Date(asset.created_at).getTime() : 0)
  )
}

// Sort by time
if (sortBy.value === 'oldest') {
  return sortByUtil(searchFiltered.value, [getAssetTime])
} else {
  return sortByUtil(searchFiltered.value, [(asset) => -getAssetTime(asset)])
}
```

## Testing
-  Typecheck passed
-  Lint passed
-  Format passed

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

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6695-feat-Add-sort-functionality-to-Media-Asset-Panel-2ab6d73d3650818c818ff3559875d869)
by [Unito](https://www.unito.io)

Co-authored-by: Claude <noreply@anthropic.com>
2025-11-14 04:56:04 +00:00
Christian Byrne
8b8f3538bf fix: template query param stripped during login views (#6677)
Fixes issue where query params from
https://github.com/Comfy-Org/ComfyUI_frontend/pull/6593 are stripped
during the login/signup views/flow by storing initial params in session
storage via router plugin.



https://github.com/user-attachments/assets/51642e8c-af5c-43ef-ab7d-133bc7e511aa




┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6677-fix-template-query-param-stripped-during-login-views-2aa6d73d365081a1bdc7d22b35f72a77)
by [Unito](https://www.unito.io)
2025-11-13 20:37:37 -08:00
Simula_r
ecd87ae0f4 feat: vue nodes onboarding toggle in menu (#6671)
## Summary

Added Nodes 2.0 menu items with a toggle. 
Updated copy in banner and toast to be more descriptive.

## Screenshots (if applicable)


https://github.com/user-attachments/assets/85bf3ae4-0e0b-4e04-82c7-a26a73cbdd5b

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6671-feat-vue-nodes-onboarding-toggle-in-menu-2aa6d73d3650817d8e5bef0ad0f8bebb)
by [Unito](https://www.unito.io)

---------

Co-authored-by: github-actions <github-actions@github.com>
2025-11-13 20:19:18 -08:00
Christian Byrne
693d408c4a filter out templates that have custom nodes when not on cloud (temporary) (#6690)
Templates are being added that have custom nodes. As a temporary
measure, filter these out on local. In a followup, add a UX to allow
local users to do something like opt-in to use these or to show these on
the condition that the user has these custom nodes installed.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6690-filter-out-templates-that-have-custom-nodes-when-not-on-cloud-temporary-2ab6d73d3650819981c0e9b26d34acff)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Alexander Brown <drjkl@comfy.org>
Co-authored-by: GitHub Action <action@github.com>
2025-11-13 20:31:09 -07:00
Jin Yi
1feee48284 [feat] Add search functionality to Media Asset Panel (#6691)
## Summary

Add search functionality to the Media Asset Panel, allowing users to
search for assets by filename.

## Changes

### 1. Search Feature
- Added SearchBox component to AssetsSidebarTab header
- Implemented fuzzy search using Fuse.js
- Works in both Imported and Generated tabs
- Search also available in folder view

### 2. New Composable: `useMediaAssetFiltering`
- Location: `src/platform/assets/composables/useMediaAssetFiltering.ts`
- Encapsulates search logic in a reusable composable
- Extensible structure for future filter and sort features
- Debounced search (50ms)

### 3. UX Improvements
- Search query automatically clears when switching tabs
- Search query automatically clears when exiting folder view

## Testing

-  TypeScript type check passed
-  ESLint/Oxlint passed
-  Lint-staged pre-commit hooks passed

## Modified Files

- `src/components/sidebar/tabs/AssetsSidebarTab.vue` - Added SearchBox
- `src/platform/assets/composables/useMediaAssetFiltering.ts` - New file
- `src/locales/en/main.json` - Added i18n key
(`sideToolbar.searchAssets`)

## Future Plans

- Add filter functionality (file type, date, etc.)
- Add sort functionality
- Switch to server-side search for OSS/Cloud (after Asset API and Job
API release)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6691-feat-Add-search-functionality-to-Media-Asset-Panel-2ab6d73d3650817b8b95f3450179524f)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Claude <noreply@anthropic.com>
2025-11-13 20:00:46 -07:00
Jin Yi
bd6825a274 [style] Fix missing node modal styling (#6672)
## Summary
- Fix background and border colors in the missing nodes modal to use
semantic theme values
- Replace `ContentDivider` component with Tailwind border utilities for
cleaner code
- Update widget background from `bg-component-node-widget-background` to
`bg-secondary-background`
- Update text color from `text-text-secondary` to
`text-muted-foreground`
- Add root-level dialog styling to ensure proper background and border
colors

## Test plan
- [x] Open a workflow with missing nodes
- [x] Verify the missing nodes modal displays with correct background
colors
- [x] Verify border colors match the design system
- [x] Verify text is readable with proper contrast

Before
<img width="658" height="669" alt="before"
src="https://github.com/user-attachments/assets/1ad390ce-bffe-434f-90df-1b624bbb9d3b"
/>

After
<img width="749" height="647" alt="after"
src="https://github.com/user-attachments/assets/c8dccb44-99b8-4387-9e91-490769205979"
/>

🤖 Generated with [Claude Code](https://claude.com/claude-code)
┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6672-style-Fix-missing-node-modal-styling-2aa6d73d365081aea0f5eee35bc27ea7)
by [Unito](https://www.unito.io)

---------

Co-authored-by: GitHub Action <action@github.com>
Co-authored-by: Alexander Brown <drjkl@comfy.org>
2025-11-14 02:11:58 +00:00
Christian Byrne
f490b81be5 add telemetry event for subscription cancellation (#6684)
emits event after going to dashboard and returning to page and having
subscription status change from subscribed to not subscribed.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6684-add-telemetry-event-for-subscription-cancellation-2aa6d73d365081009770de6d1db2b701)
by [Unito](https://www.unito.io)
2025-11-13 18:41:08 -07:00
Alexander Brown
ddbd26c062 Style: Fix slot colors to pull values from the theme (#6688)
## Summary

Pull colors for the slots from the Theme.

## Screenshots

| Before | After |
| ------ | ----- |
| <img width="798" height="383" alt="image"
src="https://github.com/user-attachments/assets/6c9cad2c-87db-41e2-92b9-d5d14f60d55c"
/> | <img width="964" height="407" alt="image"
src="https://github.com/user-attachments/assets/932d6e61-2eb3-462b-9b64-f0d4ce1804db"
/> |

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6688-Style-Fix-slot-colors-to-pull-values-from-the-theme-2ab6d73d3650818d9a73ecc9ab26d0e8)
by [Unito](https://www.unito.io)

---------

Co-authored-by: github-actions <github-actions@github.com>
2025-11-13 17:26:01 -08:00
Alexander Brown
adfd2e514e [Style] Compact Modern Nodes (#6687)
## Summary

Simple and clean is the way that we're making the nodes tonight.

## Changes

- **What**: Smaller minimum widths for nodes and labels
- **What**: Smaller font for the labels
- **What**: Removed outlines for widgets
- **What**: Fixes a text/background issue with buttons on widgets
- **What**: Smaller header
- **What**: Less padding within the node itself

## Review Focus

Check out the new styles and how they align with the Designs.

## Screenshots

| Before | After |
| --- | --- |
| <img width="542" height="486" alt="image"
src="https://github.com/user-attachments/assets/41fe9801-7a43-49ac-87fc-36d3b2ee82fb"
/> | <img width="411" height="388" alt="image"
src="https://github.com/user-attachments/assets/a7c21120-bf67-4039-86b3-c348bcc4341b"
/> |

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

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6687-Style-Compact-Modern-Nodes-2aa6d73d365081c48db3c5491c556dc9)
by [Unito](https://www.unito.io)

---------

Co-authored-by: github-actions <github-actions@github.com>
2025-11-13 16:32:38 -08:00
Jin Yi
f0f554392d feat: Add pagination support for media assets history (#6373)
## Summary
- Implement pagination for media assets history to handle large datasets
efficiently
- Add infinite scroll support with approach-end event handler  
- Support offset parameter in history API for both V1 and V2 endpoints

## Changes
- Add offset parameter support to `api.getHistory()` method
- Update history fetchers (V1/V2) to include offset in API requests
- Implement `loadMoreHistory()` in assetsStore with pagination state
management
- Add `loadMore`, `hasMore`, and `isLoadingMore` to IAssetsProvider
interface
- Add approach-end handler in AssetsSidebarTab for infinite scroll
- Set BATCH_SIZE to 200 for efficient loading

## Implementation Improvements
Simplified offset-based pagination by removing unnecessary
reconciliation logic:
- Remove `reconcileHistory`, `taskItemsMap`, `lastKnownQueueIndex`
(offset is sufficient)
- Replace `assetItemsByPromptId` Map → `loadedIds` Set (store IDs only)
- Replace `findInsertionIndex` binary search → push + sort (faster for
batch operations)
- Replace `loadingPromise` → `isLoadingMore` boolean (simpler state
management)
- Fix memory leak by cleaning up Set together with array slice

## Test Plan
- [x] TypeScript compilation passes
- [x] ESLint and Prettier formatting applied
- [x] Test infinite scroll in media assets tab
- [x] Verify network requests include correct offset parameter
- [x] Confirm no duplicate items when loading more

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
2025-11-13 11:15:44 -08:00
Christian Byrne
e639577685 ci: add backport labels automatically when a new minor version is released (#6615)
add the `core/x.yy` and `cloud/x.yy` labels (used for backporting)
automatically when a minor version is released (and the previous version
is made into RC).

By "add labels" I mean add them into the repo's list of available labels
that can be used in the UI.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6615-ci-add-backport-labels-automatically-when-a-new-minor-version-is-released-2a36d73d365081ed8c56ef650b665078)
by [Unito](https://www.unito.io)
2025-11-13 10:50:28 -08:00
Christian Byrne
596add9f63 fix: npm link in release notification comment (#6683)
Changes to correct URL syntax for npm package link (types package).

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6683-fix-npm-link-in-release-notification-comment-2aa6d73d365081729c54efabc76a833a)
by [Unito](https://www.unito.io)
2025-11-13 11:25:23 -07:00
Christian Byrne
5e4965d131 ci: add yamllint (#6682)
adds yaml linting to CI and applies rules to existing yaml files.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6682-ci-add-yamllint-2aa6d73d365081b4b67ae9d9cc86760f)
by [Unito](https://www.unito.io)
2025-11-13 11:10:48 -07:00
Comfy Org PR Bot
63ca4a3779 1.32.5 (#6666)
Patch version increment to 1.32.5

**Base branch:** `main`

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6666-1-32-5-2a96d73d365081da8780d26bc1018806)
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>
2025-11-12 21:08:42 -07:00
Alexander Brown
60647fd5b9 devex: Add script to bake in local options for Playwright runs (#6668)
## Summary

Try it out: `pnpm test:browser:local`

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6668-devex-Add-script-to-bake-in-local-options-for-Playwright-runs-2aa6d73d36508130b1d6d0b2e79a4641)
by [Unito](https://www.unito.io)
2025-11-12 18:39:41 -08:00
Simula_r
bbfada561e Fix/vue nodes auto scale (#6664)
## Summary

**Problem:** ensureCorrectLayoutScale scales up LG -> Vue. But doesn't
scale down from Vue -> LG.

**Solution:** Bi directional scaling.

**Bonus:** fix edge cases such as subgraphs, groups, and reroutes. Also,
set auto scale: true now that we 'preserve' LG scale.

**IMPORTANT:** useVueNodeResizeTracking.ts sets vue node height -
Litegraph.NODE_TITLE_HEIGHT on workflow load using a resize observer.
Reloading the page (loading a workflow) in Vue mode, will subtract
height each time. This can look like a problem caused by
ensureCorrectLayoutScale. It is not. Need to fix. Here was an attempt by
[removing the Litegraph.NODE_TITLE_HEIGHT
entirely](https://github.com/Comfy-Org/ComfyUI_frontend/pull/6643).

## Review Focus

Full lifecycle of loading workflows and switching between vue and lg.
Race conditions could be present. For example switching the mode using
keybind very fast.

## Screenshots (if applicable)


https://github.com/user-attachments/assets/5576b760-13a8-45b9-b8f7-64e1caf443c1



https://github.com/user-attachments/assets/46d6f870-df76-4084-968a-53cb629fc123

---------

Co-authored-by: github-actions <github-actions@github.com>
2025-11-12 17:44:08 -07:00
Luke Mino-Altherr
c4477fc7ab [feat] Add feature-flagged upload button to asset browser (#6665)
## Summary
Adds an upload button to the asset browser modal, controlled by the
`model_upload_button_enabled` backend feature flag.

## Changes
- **What**: Added upload button with PrimeVue primary styling to asset
browser header
- **Feature Flag**: Button only appears when backend returns
`model_upload_button_enabled: true`
- **Localization**: Added `assetBrowser.uploadModel` translation key
- **Click Handler**: Currently logs to console (implementation pending)

## Review Focus
- Feature flag integration using `useFeatureFlags` composable
- Button styling matches PrimeVue primary color scheme
- Proper placement in header with flexbox layout

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6665-feat-Add-feature-flagged-upload-button-to-asset-browser-2a96d73d365081c7a05bdc33bed7f7fd)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Claude <noreply@anthropic.com>
2025-11-12 16:40:53 -08:00
Christian Byrne
ef46f0cbf4 ci: fix action that comments on Release PRs (#6663)
Fixes issues with the action that comments updates on merged release
PRs:

1. Multi-line LINKS_VALUE: Use '%s\n%s' command substitution instead of
literal newline
2. Heredoc delimiter: Changed to COMMENT_BODY_END_MARKER without quotes
3. Variable bug: Fixed incorrect variable (`$URL` not `$URL_TEMPLATE`)
4. Change from `printf` to `echo` (avoid weird printf gymnastics)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6663-ci-fix-action-that-comments-on-Release-PRs-2a96d73d365081b1b0c5c8e66f0e317f)
by [Unito](https://www.unito.io)
2025-11-12 14:58:02 -07:00
sno
02d303c039 [chore] Add Oxc linter to project (#6197)
## Summary
- Adds [Oxc linter](https://oxc.rs/docs/guide/usage/linter) as a dev
dependency
- Creates minimal `.oxlintrc.json` configuration file
- Integrates oxlint into the lint workflow (runs before ESLint)
- Adds `pnpm oxlint` script for standalone usage
- **NEW**: Adds
[eslint-plugin-oxlint](https://github.com/oxc-project/eslint-plugin-oxlint)
to disable redundant ESLint rules
- Updates `CLAUDE.md` documentation with oxlint command

## Motivation
Oxc is a high-performance Rust-based linter that is 50-100x faster than
ESLint. By integrating it into our lint workflow, we get:
- **Faster CI/CD pipelines** (5% improvement in this codebase)
- **Quicker local development feedback**
- **Additional code quality checks** that complement ESLint
- **Reduced duplicate work** by disabling ESLint rules that oxlint
already checks

## Changes
- **package.json**: Added `oxlint` and `eslint-plugin-oxlint` to
devDependencies, integrated into `lint`, `lint:fix`, and `lint:no-cache`
scripts
- **pnpm-workspace.yaml**: Added `eslint-plugin-oxlint` and
`mixpanel-browser` to catalog
- **eslint.config.ts**: Integrated `eslint-plugin-oxlint` to
automatically disable redundant ESLint rules
- **.oxlintrc.json**: Created minimal configuration file with schema
reference
- **CLAUDE.md**: Added `pnpm oxlint` to Quick Commands section
- **.gitignore**: Added `core` dump files

## CI/CD Performance Benchmark

Real-world CI/CD timing from GitHub Actions workflow runs:

### Baseline (ESLint only) - [Run
#18718911051](https://github.com/Comfy-Org/ComfyUI_frontend/actions/runs/18718911051)
- Run ESLint with auto-fix: **125s**
- Final validation (lint + format + knip): **16s**
- **Total: 141s**

### With Oxlint (oxlint + ESLint) - [Run
#18719037963](https://github.com/Comfy-Org/ComfyUI_frontend/actions/runs/18719037963)
- Run ESLint with auto-fix (includes oxlint): **118s**
- Final validation (includes oxlint + lint + format + knip): **16s**
- **Total: 134s**

### Results
 **7 seconds faster (5.0% improvement)** despite running an additional
linting pass

### Analysis
The oxlint integration actually **improves** CI/CD performance by ~5%.
This unexpected improvement is likely because:
1. **Oxlint catches issues early**: Some code that would have slowed
down ESLint's parsing/analysis is caught by oxlint first
2. **ESLint cache benefits**: The workflow uses `--cache`, and oxlint's
fast execution helps populate/validate the cache more efficiently
3. **Parallel processing**: Modern CI runners can overlap some of the
I/O operations between oxlint and ESLint

Even if oxlint added overhead, the value proposition would still be
strong given its additional code quality checks and local development
speed benefits. The fact that it actually speeds up the pipeline is a
bonus.

## eslint-plugin-oxlint Performance Impact

Benchmark comparing ESLint performance with and without
eslint-plugin-oxlint:

### Baseline (ESLint without plugin) - [Run
#18723242157](https://github.com/Comfy-Org/ComfyUI_frontend/actions/runs/18723242157)
- Run ESLint with auto-fix: **122s** (2m 2s)
- Final validation: **17s**

### With eslint-plugin-oxlint - [Run
#18723675903](https://github.com/Comfy-Org/ComfyUI_frontend/actions/runs/18723675903)
- Run ESLint with auto-fix: **129s** (2m 9s)
- Final validation: **12s**

### Results
**Performance: +7 seconds ESLint, -5 seconds validation (net +2
seconds)**

The eslint-plugin-oxlint integration has a **minimal performance
impact** (+2 seconds total). The slight increase in ESLint time is
likely due to the additional plugin configuration overhead, while the
validation step is faster because fewer redundant lint warnings need to
be processed.

### Benefits
The small performance cost is outweighed by important benefits:
1. **Prevents duplicate work**: Disables ~50 ESLint rules that oxlint
already checks (e.g., `no-constant-condition`, `no-debugger`,
`no-empty`, etc.)
2. **Reduces noise**: Eliminates redundant lint warnings from two tools
checking the same thing
3. **Cleaner workflow**: One authoritative source for each type of lint
check
4. **Best practice**: Recommended by the Oxc project for ESLint + oxlint
integration
5. **Consistent results**: Ensures both tools don't conflict or give
contradictory advice

## Usage
```bash
# Run oxlint standalone
pnpm oxlint

# Run full lint workflow (oxlint + ESLint)
pnpm lint
pnpm lint:fix
```

## Notes
- Oxlint now runs as part of the standard `pnpm lint` workflow
- The configuration uses minimal rules by default (Oxc's philosophy is
"catch erroneous or useless code without requiring any configurations by
default")
- Oxlint provides fast feedback while ESLint provides comprehensive
checks
- eslint-plugin-oxlint automatically manages rule conflicts between the
two tools
- Both tools complement each other in the linting pipeline

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

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6197-chore-Add-Oxc-linter-to-project-2946d73d3650818cbb55ef9c0abdb9b9)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: GitHub Action <action@github.com>
Co-authored-by: DrJKL <DrJKL0424@gmail.com>
2025-11-12 13:13:41 -08:00
AustinMroz
23b0d2eb7f Add front end support for type matching (#6582)
This PR implements front end logic to handle MatchType inputs and
outputs.
See  comfyanonymous/ComfyUI#10644

This allows for the implementation of nodes such as a "switch node"
where input types change based on the connections made.

![switch-node](https://github.com/user-attachments/assets/090515ba-484c-4295-b7b3-204b0c72fc4a)

As part of this implementation, significant cleanup is being performed
in the reroute code. Extra testing will be required to make sure these
changes don't introduce regressions.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6582-Add-front-end-support-for-type-matching-2a16d73d36508189b042cd23f82a332e)
by [Unito](https://www.unito.io)
2025-11-12 13:30:58 -07:00
AustinMroz
cfbd5361d3 Fix subgraph conversion of primitives (#6606)
![AnimateDiff_00001](https://github.com/user-attachments/assets/a40db1c7-5f0e-43b2-a7fc-a324188a3930)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6606-Fix-subgraph-conversion-of-primitives-2a36d73d3650818e9e74dd383a7f9007)
by [Unito](https://www.unito.io)
2025-11-12 13:25:43 -07:00
Johnpaul Chiwetelu
1e71eae177 Persist template filters (#6657)
This pull request adds persistent filter and sort settings to the
template library, allowing users' filter choices and sort preferences to
be saved and restored across sessions. The main changes include
integrating the settings store with the template filtering composable,
updating the schema and core settings, and ensuring filter changes are
saved efficiently.

**Template Library Filter Persistence:**

*
[`src/composables/useTemplateFiltering.ts`](diffhunk://#diff-a1ec9d65962033526942cbcabeac8538ef3cd723e2e9e889cf668ccf6270d167L1-R32):
The filter state (`selectedModels`, `selectedUseCases`,
`selectedRunsOn`, and `sortBy`) is now initialized from the settings
store and changes are persisted back using debounced watchers. This
ensures user preferences are saved and restored.
[[1]](diffhunk://#diff-a1ec9d65962033526942cbcabeac8538ef3cd723e2e9e889cf668ccf6270d167L1-R32)
[[2]](diffhunk://#diff-a1ec9d65962033526942cbcabeac8538ef3cd723e2e9e889cf668ccf6270d167R259-R291)
*
[`src/platform/settings/constants/coreSettings.ts`](diffhunk://#diff-9fb7e2cdcdc60a92bdb54698fb49909bd2a84a50ffb69e2b60529a948eeb9756R1056-R1083):
Added new hidden settings for template filter selections and sort
preference, with sensible defaults.
*
[`src/schemas/apiSchema.ts`](diffhunk://#diff-b769532e74f826ca909951c0c34331b9246efb3f6901ff95a856ecf01ad826beR504-R514):
Updated the settings schema to include the new template filter and sort
settings, ensuring type safety and validation.

**Default Behavior Adjustment:**

*
[`src/composables/useTemplateFiltering.ts`](diffhunk://#diff-a1ec9d65962033526942cbcabeac8538ef3cd723e2e9e889cf668ccf6270d167L200-R209):
Changed the default sort order when clearing filters to `'newest'` to
match the new default in settings.


https://github.com/user-attachments/assets/259e87e6-20b3-4c91-b1bf-4b7d70649878

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6657-Persist-template-filters-2a86d73d3650818ca46fda23a6528391)
by [Unito](https://www.unito.io)
2025-11-12 13:25:09 -07:00
Christian Byrne
2c3c97d4b5 ci: fix backport workflow not cleaning up branch on failure and not able to update existing PRs/branches on re-run (#6620)
Fixes issue in which a failed backport runs would not cleanup the branch
(issue 1) and then on the next backport attempt, it would bail out early
because it checks if a branch with that name already exists (issue 2).

The workflow now treats existing backport branches as reusable unless an
open PR already references them (issue 2 solution), force-updates any
reused branch with the latest cherry-pick, and records them so a new
cleanup step can delete the branch if the run fails (issue 1 solution).
That prevents stranded refs from blocking future backport runs while
keeping active backport PRs intact.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6620-ci-fix-backport-workflow-not-cleaning-up-branch-on-failure-and-not-able-to-update-existi-2a36d73d365081efbbcbfa75f0c1bbe7)
by [Unito](https://www.unito.io)
2025-11-12 12:22:19 -08:00
Jin Yi
f97cf77e75 Add cloud-specific missing nodes warning dialog (#6659)
## Summary
Implements a cloud-specific dialog to warn users when loading workflows
with unsupported custom nodes in Comfy Cloud. The new dialog follows the
visual style of the node conflict dialog and provides appropriate
messaging and actions.

## Changes
- Add `CloudMissingNodesHeader`, `CloudMissingNodesContent`, and
`CloudMissingNodesFooter` components
- Add `showCloudLoadWorkflowWarning` to dialogService
- Update app.ts to show cloud dialog when in cloud environment  
- Add `cloud.missingNodes` translations

## Screenshots
The dialog displays:
- Warning icon and title
- Description of the issue
- List of missing nodes
- "Learn more" link and "Ok, got it" button

## Test plan
1. Load a workflow with custom nodes in cloud environment
2. Verify cloud-specific dialog appears with appropriate styling
3. Verify "Learn more" button opens cloud documentation
4. Verify "Ok, got it" button closes dialog

## Notes
- Two unused i18n keys (`cloud.missingNodes.cannotRun` and
`cloud.missingNodes.missingNodes`) are included for future PR that will
add breadcrumb warning icons and run button disable functionality

<img width="1367" height="988" alt="스크린샷 2025-11-12 오후 4 33 38"
src="https://github.com/user-attachments/assets/75a6fced-959f-4e93-9b82-4e61b53a9ee4"
/>

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

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6659-Add-cloud-specific-missing-nodes-warning-dialog-2a96d73d36508161ae55fe157f55cd17)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Claude <noreply@anthropic.com>
2025-11-12 02:03:09 -08:00
Rizumu Ayaka
2cf3739236 refactor: use defineAsyncComponent for widget components (#6644)
currently, three.js is still being imported from the core extension. I'm
working on resolving this issue

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6644-refactor-use-defineAsyncComponent-for-widget-components-2a76d73d36508117a090eb5e4f0274e0)
by [Unito](https://www.unito.io)

---------

Co-authored-by: DrJKL <DrJKL0424@gmail.com>
2025-11-12 01:41:11 -08:00
ComfyUI Wiki
30fc784ae4 Complete locale translations (#6637)
### Summary
- filled missing keys across
`src/locales/*/{main,nodeDefs,settings,commands}.json` so every locale
now matches the English source set
- normalized pluralization templates and preserved placeholders during
the refresh
- synced newly translated strings for Arabic, Spanish, French, Japanese,
Korean, Russian, Turkish, and Traditional Chinese


### Chinese

<img width="3456" height="1994" alt="image"
src="https://github.com/user-attachments/assets/c7c1ab0d-638c-4570-96ed-a96abc0cacb5"
/>

### Japanese

<img width="3456" height="1986" alt="image"
src="https://github.com/user-attachments/assets/d34d557e-0725-4d1a-abde-195f8d78f4f2"
/>


### Korean

<img width="3456" height="1984" alt="image"
src="https://github.com/user-attachments/assets/c5ce31d9-1237-42e0-aa63-d7baaa1f9916"
/>

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6637-Complete-locale-translations-2a56d73d365081c68178dd180b8c6777)
by [Unito](https://www.unito.io)
2025-11-11 23:21:19 -05:00
Christian Byrne
d14c416cc4 cloud: fix credits tooltips (#6655)
Moves the refresh button's tooltip to the monthy bonus tooltip (correct,
intended tooltip assignments).

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6655-cloud-fix-credits-tooltips-2a86d73d365081b585ecf19af574a10a)
by [Unito](https://www.unito.io)
2025-11-11 17:45:13 -07:00
Christian Byrne
1f78b59afc ci: only run json check on PRs that change json files (#6656)
Changes pull_request trigger to only include paths: ['**/*.json'], so
JSON validation only runs on PRs whose diffs touch JSON files. Keeps the
push trigger for all updates to main to account for direct pushes that
bypass PR.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6656-ci-only-run-json-check-on-PRs-that-change-json-files-2a86d73d365081bc8743faee941022f4)
by [Unito](https://www.unito.io)
2025-11-11 16:29:41 -07:00
ComfyUI Wiki
cbe7e8967c Add the copy URL button for the missing models dialog to Desktop (#4472)
Terry only add the Copy url to Portable, so I bring this feature to
Desktop in order to solved this
[issue](https://github.com/comfyanonymous/ComfyUI/issues/8958)

<img width="1356" height="934" alt="image"
src="https://github.com/user-attachments/assets/21766551-e69a-4e0e-b3d6-91e2fd15f97f"
/>


https://github.com/user-attachments/assets/53d4ae33-4229-41c0-8379-0a864b68d37b

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-4472-Add-the-copy-url-button-for-Desktop-2346d73d3650816889e2e227f9d797b0)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Terry Jia <terryjia88@gmail.com>
2025-11-11 16:16:41 -07:00
Christian Byrne
2542449d45 chore: add missing i18n keys in sidebar, assets, toolbox, dropdowns (#6622)
This PR 

- adds missing locale keys for 3D viewer toast strings, assets sidebar
labels, and node error keys
- cleans up the selection toolbox, media previews, node components, and
widget uploader to rely on `$t`/`st` (exposed to template scope at
compile time) instead of importing from `useI18n`.
- updates `eslint.config.ts` to teach the Intlify rule about the locale
layout

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6622-chore-add-missing-i18n-keys-in-sidebar-assets-toolbox-dropdowns-2a36d73d365081ae8694eb4f8ebb822a)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Alexander Brown <drjkl@comfy.org>
2025-11-11 14:27:07 -07:00
Christian Byrne
3c550e953a fix: improve template URL loading UX and prevent re-triggering (#6593)
Fixes the janky UX when loading templates via URL query parameters by
moving the loading logic earlier in the app lifecycle (from
GraphView.onGraphReady to
useWorkflowPersistence.restorePreviousWorkflow). The saved workflow now
loads first as a background tab, then the template loads as the active
tab, eliminating the visual flash where the saved workflow briefly
appears before being replaced. After loading, the template and source
query parameters are removed from the URL using router.replace to
prevent the template from re-loading on page refresh. This preserves
user work by keeping both workflows open in separate tabs and matches
the existing behavior when clicking templates from the dialog. All 15
tests pass including 3 new tests for URL cleanup.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6593-fix-improve-template-URL-loading-UX-and-prevent-re-triggering-2a26d73d36508137a0cae6cf92c842fc)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Christian Byrne <c.byrne@comfy.org>
2025-11-11 13:51:56 -07:00
Johnpaul Chiwetelu
6541f5cda5 Unhide Properties panel (#6652)
This pull request makes a minor UI adjustment to the node settings panel
in the `LGraphCanvas` class. The panel's position is now set to
absolute, with specific top and left offsets to improve its placement on
the canvas.

* Set the `panel` element's CSS `position` to `absolute` and specified
`top` and `left` values for better UI alignment in `LGraphCanvas.ts`.

## Old look
<img width="1920" height="1032" alt="Screenshot 2025-11-11 154519"
src="https://github.com/user-attachments/assets/d16e6715-d934-4269-82fd-221a166ffbf5"
/>


## New Look
<img width="1920" height="1032" alt="Screenshot 2025-11-11 160015"
src="https://github.com/user-attachments/assets/7c1b9baa-0d78-4623-8be0-f02a0452aae6"
/>


┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6652-Unhide-Properties-panel-2a86d73d36508148bb0fd5568c3a007a)
by [Unito](https://www.unito.io)

---------

Co-authored-by: github-actions <github-actions@github.com>
2025-11-11 12:07:10 -07:00
Terry Jia
812f1797d5 fix warning log (#6650)
## Summary

we need to add root div to avoid warning

> Extraneous non-props attributes (data-v-inspector) were passed to
> component but could not be automatically inherited because component
renders fragment or text or teleport root
> nodes.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6650-fix-warning-log-2a86d73d365081a89c5af0c60cdaa618)
by [Unito](https://www.unito.io)
2025-11-11 11:45:11 -07:00
Christian Byrne
0be1da2041 feat: navigate to previously active tab when closing current tab (#6624)
When closing a tab, the UI now returns to the most recently active tab
instead of always going to the first/next tab. This matches standard
browser tab behavior and prevents accidental edits in the wrong
workflow. Implementation uses a lazy-cleanup history array (max 32
entries) that tracks tab activations and skips closed tabs when finding
the previous tab to switch to. Fixes #6599


https://github.com/user-attachments/assets/0bb87969-fd01-4e6b-96e8-c0f741f23ff8

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6624-feat-navigate-to-previously-active-tab-when-closing-current-tab-2a36d73d365081f5be95db51ff7a03f6)
by [Unito](https://www.unito.io)
2025-11-11 11:03:35 -07:00
Terry Jia
879cb8f1a8 support panoramic image in 3d node (#6638)
## Summary

Adds panoramic image support to the 3D node viewer, allowing users to
display equirectangular panoramic images as immersive backgrounds
alongside the existing tiled image mode.

## Changes

- Toggle between tiled and panorama rendering modes for background
images
- Field of view (FOV) control for panorama mode
- Refactored FOV slider into reusable PopupSlider component

## Screenshots


https://github.com/user-attachments/assets/8955d74b-b0e6-4b26-83ca-ccf902b43aa6

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6638-support-panoramic-image-in-3d-node-2a56d73d365081b98647f988130e312e)
by [Unito](https://www.unito.io)
2025-11-11 04:02:12 -05:00
308 changed files with 62191 additions and 8594 deletions

View File

@@ -36,9 +36,9 @@ body:
3. Click Queue Prompt
4. See error
value: |
1.
2.
3.
1.
2.
3.
validations:
required: true

View File

@@ -28,7 +28,7 @@ runs:
REPO="${{ github.repository }}"
if [[ -z "$VERSION_FILE" ]]; then
echo '::error::version_file input is required' >&2
echo "::error::version_file input is required" >&2
exit 1
fi
@@ -55,11 +55,13 @@ runs:
case "$VERSION_FILE" in
package.json)
LINKS_VALUE=$'PyPI|https://pypi.org/project/comfyui-frontend-package/{{version}}/\n''npm types|https://npm.im/@comfyorg/comfyui-frontend-types@{{version}}'
LINKS_VALUE=$(printf '%s\n%s' \
'PyPI|https://pypi.org/project/comfyui-frontend-package/{{version}}/' \
'npm types|https://www.npmjs.com/package/@comfyorg/comfyui-frontend-types/v/{{version}}')
;;
apps/desktop-ui/package.json)
MARKER='desktop-release-summary'
LINKS_VALUE='npm desktop UI|https://npm.im/@comfyorg/desktop-ui@{{version}}'
LINKS_VALUE='npm desktop UI|https://www.npmjs.com/package/@comfyorg/desktop-ui/v/{{version}}'
;;
esac
@@ -69,12 +71,13 @@ runs:
COMMENT_FILE=$(mktemp)
{
printf '<!--%s:%s%s-->\n' "$MARKER" "$DIFF_PREFIX" "$NEW_VERSION"
printf '%s\n\n' "$MESSAGE"
printf -- '- %s: [%s%s...%s%s](%s)\n' "$DIFF_LABEL" "$DIFF_PREFIX" "$PREV_VERSION" "$DIFF_PREFIX" "$NEW_VERSION" "$DIFF_URL"
echo "<!--$MARKER:$DIFF_PREFIX$NEW_VERSION-->"
echo "$MESSAGE"
echo ""
echo "- $DIFF_LABEL: [\`$DIFF_PREFIX$PREV_VERSION...$DIFF_PREFIX$NEW_VERSION\`]($DIFF_URL)"
while IFS= read -r RAW_LINE; do
LINE=$(printf '%s' "$RAW_LINE" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')
LINE=$(echo "$RAW_LINE" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')
[[ -z "$LINE" ]] && continue
if [[ "$LINE" != *"|"* ]]; then
echo "::warning::Skipping malformed link entry: $LINE" >&2
@@ -84,16 +87,16 @@ runs:
URL_TEMPLATE=${LINE#*|}
URL=${URL_TEMPLATE//\{\{version\}\}/$NEW_VERSION}
URL=${URL//\{\{prev_version\}\}/$PREV_VERSION}
printf -- '- %s: %s\n' "$LABEL" "$URL"
echo "- $LABEL: [\`$NEW_VERSION\`]($URL)"
done <<< "$LINKS_VALUE"
printf '\n'
echo ""
} > "$COMMENT_FILE"
{
echo "body<<'EOF'"
echo "body<<COMMENT_BODY_END_MARKER"
cat "$COMMENT_FILE"
echo 'EOF'
echo "COMMENT_BODY_END_MARKER"
} >> "$GITHUB_OUTPUT"
echo "prev_version=$PREV_VERSION" >> "$GITHUB_OUTPUT"
echo "marker_search=<!--$MARKER:" >> "$GITHUB_OUTPUT"

View File

@@ -105,4 +105,4 @@ jobs:
labels: Manager
delete-branch: true
add-paths: |
src/types/generatedManagerTypes.ts
src/types/generatedManagerTypes.ts

View File

@@ -6,6 +6,8 @@ on:
branches:
- main
pull_request:
paths:
- '**/*.json'
jobs:
json-lint:

View File

@@ -51,7 +51,7 @@ jobs:
if [ -n "$(git status --porcelain)" ]; then
echo "changed=true" >> $GITHUB_OUTPUT
else
echo "changed=false" >> $GITHUB_OUTPUT
echo "changed=false" >> $GITHUB_OUTPUT
fi
- name: Commit changes

View File

@@ -6,7 +6,7 @@ on:
paths:
- 'tools/devtools/**'
push:
branches: [ main ]
branches: [main]
paths:
- 'tools/devtools/**'

View File

@@ -13,7 +13,7 @@ jobs:
deploy-and-comment-forked-pr:
runs-on: ubuntu-latest
if: |
github.repository == 'Comfy-Org/ComfyUI_frontend' &&
github.repository == 'Comfy-Org/ComfyUI_frontend' &&
github.event.workflow_run.event == 'pull_request' &&
github.event.workflow_run.head_repository != null &&
github.event.workflow_run.repository != null &&
@@ -43,14 +43,14 @@ jobs:
repo: context.repo.repo,
state: 'open',
});
const pr = prs.find(p => p.head.sha === context.payload.workflow_run.head_sha);
if (!pr) {
console.log('No PR found for SHA:', context.payload.workflow_run.head_sha);
return null;
}
console.log(`Found PR #${pr.number} from fork: ${context.payload.workflow_run.head_repository.full_name}`);
return pr.number;
@@ -74,7 +74,7 @@ jobs:
run-id: ${{ github.event.workflow_run.id }}
pattern: playwright-report-*
path: reports
- name: Handle Test Completion
if: steps.pr.outputs.result != 'null' && github.event.action == 'completed'
env:
@@ -85,9 +85,9 @@ jobs:
# Rename merged report if exists
[ -d "reports/playwright-report-chromium-merged" ] && \
mv reports/playwright-report-chromium-merged reports/playwright-report-chromium
chmod +x scripts/cicd/pr-playwright-deploy-and-comment.sh
./scripts/cicd/pr-playwright-deploy-and-comment.sh \
"${{ steps.pr.outputs.result }}" \
"${{ github.event.workflow_run.head_branch }}" \
"completed"
"completed"

View File

@@ -29,7 +29,7 @@ jobs:
with:
include_build_step: true
- name: Setup Playwright
uses: ./.github/actions/setup-playwright # Setup Playwright and cache browsers
uses: ./.github/actions/setup-playwright # Setup Playwright and cache browsers
# Save the entire workspace as cache for later test jobs to restore
- name: Generate cache key

View File

@@ -13,7 +13,7 @@ jobs:
deploy-and-comment-forked-pr:
runs-on: ubuntu-latest
if: |
github.repository == 'Comfy-Org/ComfyUI_frontend' &&
github.repository == 'Comfy-Org/ComfyUI_frontend' &&
github.event.workflow_run.event == 'pull_request' &&
github.event.workflow_run.head_repository != null &&
github.event.workflow_run.repository != null &&
@@ -43,14 +43,14 @@ jobs:
repo: context.repo.repo,
state: 'open',
});
const pr = prs.find(p => p.head.sha === context.payload.workflow_run.head_sha);
if (!pr) {
console.log('No PR found for SHA:', context.payload.workflow_run.head_sha);
return null;
}
console.log(`Found PR #${pr.number} from fork: ${context.payload.workflow_run.head_repository.full_name}`);
return pr.number;
@@ -74,7 +74,7 @@ jobs:
run-id: ${{ github.event.workflow_run.id }}
name: storybook-static
path: storybook-static
- name: Handle Storybook Completion
if: steps.pr.outputs.result != 'null' && github.event.action == 'completed'
env:
@@ -88,4 +88,4 @@ jobs:
./scripts/cicd/pr-storybook-deploy-and-comment.sh \
"${{ steps.pr.outputs.result }}" \
"${{ github.event.workflow_run.head_branch }}" \
"completed"
"completed"

View File

@@ -2,7 +2,7 @@ name: "CI: Tests Storybook"
description: "Builds Storybook and runs visual regression testing via Chromatic, deploys previews to Cloudflare Pages"
on:
workflow_dispatch: # Allow manual triggering
workflow_dispatch: # Allow manual triggering
pull_request:
branches: [main]
@@ -16,7 +16,7 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v5
- name: Post starting comment
env:
GITHUB_TOKEN: ${{ github.token }}
@@ -89,7 +89,7 @@ jobs:
- name: Checkout code
uses: actions/checkout@v5
with:
fetch-depth: 0 # Required for Chromatic baseline
fetch-depth: 0 # Required for Chromatic baseline
- name: Install pnpm
uses: pnpm/action-setup@v4
@@ -111,9 +111,9 @@ jobs:
with:
projectToken: ${{ secrets.CHROMATIC_PROJECT_TOKEN }}
buildScriptName: build-storybook
autoAcceptChanges: 'main' # Auto-accept changes on main branch
exitOnceUploaded: true # Don't wait for UI tests to complete
onlyChanged: true # Only capture changed stories
autoAcceptChanges: 'main' # Auto-accept changes on main branch
exitOnceUploaded: true # Don't wait for UI tests to complete
onlyChanged: true # Only capture changed stories
- name: Set job status
id: job-status
@@ -138,17 +138,17 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v5
- name: Download Storybook build
if: needs.storybook-build.outputs.conclusion == 'success'
uses: actions/download-artifact@v4
with:
name: storybook-static
path: storybook-static
- name: Make deployment script executable
run: chmod +x scripts/cicd/pr-storybook-deploy-and-comment.sh
- name: Deploy Storybook and comment on PR
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
@@ -176,25 +176,25 @@ jobs:
script: |
const buildUrl = '${{ needs.chromatic-deployment.outputs.chromatic-build-url }}';
const storybookUrl = '${{ needs.chromatic-deployment.outputs.chromatic-storybook-url }}';
// Find the existing Storybook comment
const { data: comments } = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: ${{ github.event.pull_request.number }}
});
const storybookComment = comments.find(comment =>
const storybookComment = comments.find(comment =>
comment.body.includes('<!-- STORYBOOK_BUILD_STATUS -->')
);
if (storybookComment && buildUrl && storybookUrl) {
// Append Chromatic info to existing comment
const updatedBody = storybookComment.body.replace(
/---\n(.*)$/s,
`---\n### 🎨 Chromatic Visual Tests\n- 📊 [View Chromatic Build](${buildUrl})\n- 📚 [View Chromatic Storybook](${storybookUrl})\n\n$1`
);
await github.rest.issues.updateComment({
owner: context.repo.owner,
repo: context.repo.repo,

View File

@@ -0,0 +1,33 @@
name: "CI: YAML Validation"
description: "Validates YAML syntax and style using yamllint with relaxed rules"
on:
push:
branches:
- main
paths:
- '**/*.yml'
- '**/*.yaml'
pull_request:
paths:
- '**/*.yml'
- '**/*.yaml'
jobs:
yaml-lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.x'
- name: Install yamllint
run: |
python -m pip install --upgrade pip
python -m pip install yamllint
- name: Validate YAML syntax and style
run: ./scripts/cicd/check-yaml.sh

View File

@@ -2,11 +2,11 @@ name: "i18n: Update Core"
description: "Generates and updates translations for core ComfyUI components using OpenAI"
on:
# Manual dispatch for urgent translation updates
# Manual dispatch for urgent translation updates
workflow_dispatch:
# Only trigger on PRs to main/master - additional branch filtering in job condition
pull_request:
branches: [ main ]
branches: [main]
types: [opened, synchronize, reopened]
jobs:
@@ -15,45 +15,45 @@ jobs:
if: github.event_name == 'workflow_dispatch' || (github.event.pull_request.head.repo.full_name == github.repository && startsWith(github.head_ref, 'version-bump-'))
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v5
- name: Checkout repository
uses: actions/checkout@v5
# Setup playwright environment
- name: Setup ComfyUI Frontend
uses: ./.github/actions/setup-frontend
with:
include_build_step: true
- name: Setup ComfyUI Server
uses: ./.github/actions/setup-comfyui-server
with:
launch_server: true
- name: Setup Playwright
uses: ./.github/actions/setup-playwright
# Setup playwright environment
- name: Setup ComfyUI Frontend
uses: ./.github/actions/setup-frontend
with:
include_build_step: true
- name: Setup ComfyUI Server
uses: ./.github/actions/setup-comfyui-server
with:
launch_server: true
- name: Setup Playwright
uses: ./.github/actions/setup-playwright
- name: Start dev server
# Run electron dev server as it is a superset of the web dev server
# We do want electron specific UIs to be translated.
run: pnpm dev:electron &
- name: Start dev server
# Run electron dev server as it is a superset of the web dev server
# We do want electron specific UIs to be translated.
run: pnpm dev:electron &
# Update locales, collect new strings and update translations using OpenAI, then commit changes
- name: Update en.json
run: pnpm collect-i18n
env:
PLAYWRIGHT_TEST_URL: http://localhost:5173
- name: Update translations
run: pnpm locale
env:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
- name: Commit updated locales
run: |
git config --global user.name 'github-actions'
git config --global user.email 'github-actions@github.com'
git fetch origin ${{ github.head_ref }}
# Stash any local changes before checkout
git stash -u
git checkout -B ${{ github.head_ref }} origin/${{ github.head_ref }}
# Apply the stashed changes if any
git stash pop || true
git add src/locales/
git diff --staged --quiet || git commit -m "Update locales"
git push origin HEAD:${{ github.head_ref }}
# Update locales, collect new strings and update translations using OpenAI, then commit changes
- name: Update en.json
run: pnpm collect-i18n
env:
PLAYWRIGHT_TEST_URL: http://localhost:5173
- name: Update translations
run: pnpm locale
env:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
- name: Commit updated locales
run: |
git config --global user.name 'github-actions'
git config --global user.email 'github-actions@github.com'
git fetch origin ${{ github.head_ref }}
# Stash any local changes before checkout
git stash -u
git checkout -B ${{ github.head_ref }} origin/${{ github.head_ref }}
# Apply the stashed changes if any
git stash pop || true
git add src/locales/
git diff --staged --quiet || git commit -m "Update locales"
git push origin HEAD:${{ github.head_ref }}

View File

@@ -21,116 +21,116 @@ jobs:
update-locales:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v5
- name: Checkout repository
uses: actions/checkout@v5
# Setup playwright environment with custom node repository
- name: Setup ComfyUI Server (without launching)
uses: ./.github/actions/setup-comfyui-server
- name: Setup frontend
uses: ./.github/actions/setup-frontend
with:
include_build_step: 'true'
- name: Setup Playwright
uses: ./.github/actions/setup-playwright
# Setup playwright environment with custom node repository
- name: Setup ComfyUI Server (without launching)
uses: ./.github/actions/setup-comfyui-server
- name: Setup frontend
uses: ./.github/actions/setup-frontend
with:
include_build_step: 'true'
- name: Setup Playwright
uses: ./.github/actions/setup-playwright
# Install the custom node repository
- name: Checkout custom node repository
uses: actions/checkout@v5
with:
repository: ${{ inputs.owner }}/${{ inputs.repository }}
path: 'ComfyUI/custom_nodes/${{ inputs.repository }}'
- name: Install custom node Python requirements
working-directory: ComfyUI/custom_nodes/${{ inputs.repository }}
run: |
if [ -f "requirements.txt" ]; then
pip install -r requirements.txt
fi
# Install the custom node repository
- name: Checkout custom node repository
uses: actions/checkout@v5
with:
repository: ${{ inputs.owner }}/${{ inputs.repository }}
path: 'ComfyUI/custom_nodes/${{ inputs.repository }}'
- name: Install custom node Python requirements
working-directory: ComfyUI/custom_nodes/${{ inputs.repository }}
run: |
if [ -f "requirements.txt" ]; then
pip install -r requirements.txt
fi
# Start ComfyUI Server
- name: Start ComfyUI Server
shell: bash
working-directory: ComfyUI
run: |
python main.py --cpu --multi-user --front-end-root ../dist --custom-node-path ../ComfyUI/custom_nodes/${{ inputs.repository }} &
wait-for-it --service
# Start ComfyUI Server
- name: Start ComfyUI Server
shell: bash
working-directory: ComfyUI
run: |
python main.py --cpu --multi-user --front-end-root ../dist --custom-node-path ../ComfyUI/custom_nodes/${{ inputs.repository }} &
wait-for-it --service
- name: Start dev server
# Run electron dev server as it is a superset of the web dev server
# We do want electron specific UIs to be translated.
run: pnpm dev:electron &
- name: Capture base i18n
run: pnpm exec tsx scripts/diff-i18n capture
- name: Update en.json
run: pnpm collect-i18n
env:
PLAYWRIGHT_TEST_URL: http://localhost:5173
- name: Update translations
run: pnpm locale
env:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
- name: Diff base vs updated i18n
run: pnpm exec tsx scripts/diff-i18n diff
- name: Update i18n in custom node repository
run: |
LOCALE_DIR=ComfyUI/custom_nodes/${{ inputs.repository }}/locales/
install -d "$LOCALE_DIR"
cp -rf ComfyUI_frontend/temp/diff/* "$LOCALE_DIR"
# Git ops for pushing changes and creating PR
- name: Check and create fork of custom node repository
run: |
# Try to fork the repository
gh repo fork ${{ inputs.owner }}/${{ inputs.repository }} --clone=false || {
echo "Fork failed - repository might already be forked"
# Exit 0 to prevent the workflow from failing
exit 0
}
- name: Start dev server
# Run electron dev server as it is a superset of the web dev server
# We do want electron specific UIs to be translated.
run: pnpm dev:electron &
# Enable workflows on the forked repository
gh api \
--method PUT \
-H "Accept: application/vnd.github+json" \
"/repos/${{ inputs.fork_owner }}/${{ inputs.repository }}/actions/permissions/workflow" \
-F can_approve_pull_request_reviews=true \
-F default_workflow_permissions="write" \
-F enabled=true
env:
GH_TOKEN: ${{ secrets.PR_GH_TOKEN }}
- name: Capture base i18n
run: pnpm exec tsx scripts/diff-i18n capture
- name: Update en.json
run: pnpm collect-i18n
env:
PLAYWRIGHT_TEST_URL: http://localhost:5173
- name: Update translations
run: pnpm locale
env:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
- name: Diff base vs updated i18n
run: pnpm exec tsx scripts/diff-i18n diff
- name: Update i18n in custom node repository
run: |
LOCALE_DIR=ComfyUI/custom_nodes/${{ inputs.repository }}/locales/
install -d "$LOCALE_DIR"
cp -rf ComfyUI_frontend/temp/diff/* "$LOCALE_DIR"
- name: Commit changes
working-directory: ComfyUI/custom_nodes/${{ inputs.repository }}
run: |
git config --global user.name 'github-actions'
git config --global user.email 'github-actions@github.com'
# Git ops for pushing changes and creating PR
- name: Check and create fork of custom node repository
run: |
# Try to fork the repository
gh repo fork ${{ inputs.owner }}/${{ inputs.repository }} --clone=false || {
echo "Fork failed - repository might already be forked"
# Exit 0 to prevent the workflow from failing
exit 0
}
# Create and switch to new branch
git checkout -b update-locales
# Enable workflows on the forked repository
gh api \
--method PUT \
-H "Accept: application/vnd.github+json" \
"/repos/${{ inputs.fork_owner }}/${{ inputs.repository }}/actions/permissions/workflow" \
-F can_approve_pull_request_reviews=true \
-F default_workflow_permissions="write" \
-F enabled=true
env:
GH_TOKEN: ${{ secrets.PR_GH_TOKEN }}
# Stage and commit changes
git add -A
git commit -m "Update locales"
- name: Commit changes
working-directory: ComfyUI/custom_nodes/${{ inputs.repository }}
run: |
git config --global user.name 'github-actions'
git config --global user.email 'github-actions@github.com'
- name: Install SSH key For PUSH
uses: shimataro/ssh-key-action@d4fffb50872869abe2d9a9098a6d9c5aa7d16be4
with:
# PR private key from action server
key: ${{ secrets.PR_SSH_PRIVATE_KEY }}
# github public key to confirm it's github server
known_hosts: github.com ssh-rsa AAAAB3NzaC1yc2EAAAABIwAAAQEAq2A7hRGmdnm9tUDbO9IDSwBK6TbQa+PXYPCPy6rbTrTtw7PHkccKrpp0yVhp5HdEIcKr6pLlVDBfOLX9QUsyCOV0wzfjIJNlGEYsdlLJizHhbn2mUjvSAHQqZETYP81eFzLQNnPHt4EVVUh7VfDESU84KezmD5QlWpXLmvU31/yMf+Se8xhHTvKSCZIFImWwoG6mbUoWf9nzpIoaSjB+weqqUUmpaaasXVal72J+UX2B+2RPW3RcT0eOzQgqlJL3RKrTJvdsjE3JEAvGq3lGHSZXy28G3skua2SmVi/w4yCE6gbODqnTWlg7+wC604ydGXA8VJiS5ap43JXiUFFAaQ==
# Create and switch to new branch
git checkout -b update-locales
- name: Push changes
working-directory: ComfyUI/custom_nodes/${{ inputs.repository }}
run: |
# Force push to create the branch
echo "Pushing changes to ${{ inputs.fork_owner }}/${{ inputs.repository }}"
git push -f git@github.com:${{ inputs.fork_owner }}/${{ inputs.repository }}.git update-locales
# Stage and commit changes
git add -A
git commit -m "Update locales"
- name: Create PR
working-directory: ComfyUI/custom_nodes/${{ inputs.repository }}
run: |
# Create PR using gh cli
gh pr create --title "Update locales for ${{ inputs.repository }}" --repo ${{ inputs.owner }}/${{ inputs.repository }} --head ${{ inputs.fork_owner }}:update-locales --body "Update locales for ${{ inputs.repository }}"
env:
GH_TOKEN: ${{ secrets.PR_GH_TOKEN }}
- name: Install SSH key For PUSH
uses: shimataro/ssh-key-action@d4fffb50872869abe2d9a9098a6d9c5aa7d16be4
with:
# PR private key from action server
key: ${{ secrets.PR_SSH_PRIVATE_KEY }}
# github public key to confirm it's github server
known_hosts: github.com ssh-rsa AAAAB3NzaC1yc2EAAAABIwAAAQEAq2A7hRGmdnm9tUDbO9IDSwBK6TbQa+PXYPCPy6rbTrTtw7PHkccKrpp0yVhp5HdEIcKr6pLlVDBfOLX9QUsyCOV0wzfjIJNlGEYsdlLJizHhbn2mUjvSAHQqZETYP81eFzLQNnPHt4EVVUh7VfDESU84KezmD5QlWpXLmvU31/yMf+Se8xhHTvKSCZIFImWwoG6mbUoWf9nzpIoaSjB+weqqUUmpaaasXVal72J+UX2B+2RPW3RcT0eOzQgqlJL3RKrTJvdsjE3JEAvGq3lGHSZXy28G3skua2SmVi/w4yCE6gbODqnTWlg7+wC604ydGXA8VJiS5ap43JXiUFFAaQ==
- name: Push changes
working-directory: ComfyUI/custom_nodes/${{ inputs.repository }}
run: |
# Force push to create the branch
echo "Pushing changes to ${{ inputs.fork_owner }}/${{ inputs.repository }}"
git push -f git@github.com:${{ inputs.fork_owner }}/${{ inputs.repository }}.git update-locales
- name: Create PR
working-directory: ComfyUI/custom_nodes/${{ inputs.repository }}
run: |
# Create PR using gh cli
gh pr create --title "Update locales for ${{ inputs.repository }}" --repo ${{ inputs.owner }}/${{ inputs.repository }} --head ${{ inputs.fork_owner }}:update-locales --body "Update locales for ${{ inputs.repository }}"
env:
GH_TOKEN: ${{ secrets.PR_GH_TOKEN }}

View File

@@ -13,42 +13,42 @@ jobs:
update-locales:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v5
# Setup playwright environment
- name: Setup ComfyUI Server (and start)
uses: ./.github/actions/setup-comfyui-server
with:
launch_server: true
- name: Setup frontend
uses: ./.github/actions/setup-frontend
with:
include_build_step: true
- name: Setup Playwright
uses: ./.github/actions/setup-playwright
- name: Checkout repository
uses: actions/checkout@v5
# Setup playwright environment
- name: Setup ComfyUI Server (and start)
uses: ./.github/actions/setup-comfyui-server
with:
launch_server: true
- name: Setup frontend
uses: ./.github/actions/setup-frontend
with:
include_build_step: true
- name: Setup Playwright
uses: ./.github/actions/setup-playwright
- name: Start dev server
# Run electron dev server as it is a superset of the web dev server
# We do want electron specific UIs to be translated.
run: pnpm dev:electron &
- name: Update en.json
run: pnpm collect-i18n -- scripts/collect-i18n-node-defs.ts
env:
PLAYWRIGHT_TEST_URL: http://localhost:5173
- name: Update translations
run: pnpm locale
env:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
- name: Create Pull Request
uses: peter-evans/create-pull-request@271a8d0340265f705b14b6d32b9829c1cb33d45e
with:
token: ${{ secrets.PR_GH_TOKEN }}
commit-message: "Update locales for node definitions"
title: "Update locales for node definitions"
body: |
Automated PR to update locales for node definitions
- name: Start dev server
# Run electron dev server as it is a superset of the web dev server
# We do want electron specific UIs to be translated.
run: pnpm dev:electron &
- name: Update en.json
run: pnpm collect-i18n -- scripts/collect-i18n-node-defs.ts
env:
PLAYWRIGHT_TEST_URL: http://localhost:5173
- name: Update translations
run: pnpm locale
env:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
- name: Create Pull Request
uses: peter-evans/create-pull-request@271a8d0340265f705b14b6d32b9829c1cb33d45e
with:
token: ${{ secrets.PR_GH_TOKEN }}
commit-message: "Update locales for node definitions"
title: "Update locales for node definitions"
body: |
Automated PR to update locales for node definitions
This PR was created automatically by the frontend update workflow.
branch: update-locales-node-defs-${{ github.event.inputs.trigger_type }}-${{ github.run_id }}
base: main
labels: dependencies
This PR was created automatically by the frontend update workflow.
branch: update-locales-node-defs-${{ github.event.inputs.trigger_type }}-${{ github.run_id }}
base: main
labels: dependencies

View File

@@ -19,8 +19,8 @@ on:
jobs:
backport:
if: >
(github.event_name == 'pull_request_target' &&
github.event.pull_request.merged == true &&
(github.event_name == 'pull_request_target' &&
github.event.pull_request.merged == true &&
contains(github.event.pull_request.labels.*.name, 'needs-backport')) ||
github.event_name == 'workflow_dispatch'
runs-on: ubuntu-latest
@@ -38,19 +38,19 @@ jobs:
echo "::error::Invalid PR number format. Must be a positive integer."
exit 1
fi
# Validate PR exists and is merged
if ! gh pr view "${{ inputs.pr_number }}" --json merged >/dev/null 2>&1; then
echo "::error::PR #${{ inputs.pr_number }} not found or inaccessible."
exit 1
fi
MERGED=$(gh pr view "${{ inputs.pr_number }}" --json merged --jq '.merged')
if [ "$MERGED" != "true" ]; then
echo "::error::PR #${{ inputs.pr_number }} is not merged. Only merged PRs can be backported."
exit 1
fi
# Validate PR has needs-backport label
if ! gh pr view "${{ inputs.pr_number }}" --json labels --jq '.labels[].name' | grep -q "needs-backport"; then
echo "::error::PR #${{ inputs.pr_number }} does not have 'needs-backport' label."
@@ -164,6 +164,7 @@ jobs:
PENDING=()
SKIPPED=()
REUSED=()
for target in $REQUESTED_TARGETS; do
SAFE_TARGET=$(echo "$target" | tr '/' '-')
@@ -176,10 +177,22 @@ jobs:
if printf '%s\n' "${EXISTING_BRANCHES[@]:-}" |
grep -Fq "refs/heads/${BACKPORT_BRANCH}"; then
SKIPPED+=("$target")
else
PENDING+=("$target")
OPEN_PR=$(
gh pr list \
--state open \
--head "${BACKPORT_BRANCH}" \
--json number \
--jq 'if length > 0 then .[0].number else "" end'
)
if [ -n "$OPEN_PR" ]; then
SKIPPED+=("${target} (PR #${OPEN_PR})")
continue
fi
REUSED+=("$BACKPORT_BRANCH")
fi
PENDING+=("$target")
done
SKIPPED_JOINED="${SKIPPED[*]:-}"
@@ -187,16 +200,20 @@ jobs:
echo "already-exists=${SKIPPED_JOINED}" >> $GITHUB_OUTPUT
echo "pending-targets=${PENDING_JOINED}" >> $GITHUB_OUTPUT
echo "reused-branches=${REUSED[*]:-}" >> $GITHUB_OUTPUT
if [ -z "$PENDING_JOINED" ]; then
echo "skip=true" >> $GITHUB_OUTPUT
if [ -n "$SKIPPED_JOINED" ]; then
echo "::warning::Backport branches already exist for: ${SKIPPED_JOINED}"
echo "::warning::Backport branches exist: ${SKIPPED_JOINED}"
fi
else
echo "skip=false" >> $GITHUB_OUTPUT
if [ -n "$SKIPPED_JOINED" ]; then
echo "::notice::Skipping already backported targets: ${SKIPPED_JOINED}"
echo "::notice::Skipping backport targets: ${SKIPPED_JOINED}"
fi
if [ "${#REUSED[@]}" -gt 0 ]; then
echo "::notice::Reusing backport branches: ${REUSED[*]}"
fi
fi
@@ -208,7 +225,12 @@ jobs:
run: |
FAILED=""
SUCCESS=""
CREATED_BRANCHES_FILE="$(
mktemp "$RUNNER_TEMP/backport-branches-XXXXXX"
)"
echo "CREATED_BRANCHES_FILE=$CREATED_BRANCHES_FILE" >> "$GITHUB_ENV"
# Get PR data for manual triggers
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
PR_DATA=$(gh pr view ${{ inputs.pr_number }} --json title,mergeCommit)
@@ -223,6 +245,12 @@ jobs:
TARGET_BRANCH="${target}"
SAFE_TARGET=$(echo "$TARGET_BRANCH" | tr '/' '-')
BACKPORT_BRANCH="backport-${PR_NUMBER}-to-${SAFE_TARGET}"
REMOTE_BACKPORT_EXISTS=false
if git ls-remote --exit-code origin "${BACKPORT_BRANCH}" >/dev/null 2>&1; then
REMOTE_BACKPORT_EXISTS=true
echo "::notice::Updating existing branch ${BACKPORT_BRANCH}"
fi
echo "::group::Backporting to ${TARGET_BRANCH}"
@@ -247,7 +275,12 @@ jobs:
# Try cherry-pick
if git cherry-pick "${MERGE_COMMIT}"; then
git push origin "${BACKPORT_BRANCH}"
if [ "$REMOTE_BACKPORT_EXISTS" = true ]; then
git push --force-with-lease origin "${BACKPORT_BRANCH}"
else
git push origin "${BACKPORT_BRANCH}"
fi
echo "${BACKPORT_BRANCH}" >> "$CREATED_BRANCHES_FILE"
SUCCESS="${SUCCESS}${TARGET_BRANCH}:${BACKPORT_BRANCH} "
echo "Successfully created backport branch: ${BACKPORT_BRANCH}"
# Return to main (keep the branch, we need it for PR)
@@ -271,6 +304,13 @@ jobs:
echo "success=${SUCCESS}" >> $GITHUB_OUTPUT
echo "failed=${FAILED}" >> $GITHUB_OUTPUT
if [ -s "$CREATED_BRANCHES_FILE" ]; then
CREATED_LIST=$(paste -sd' ' "$CREATED_BRANCHES_FILE")
echo "created-branches=${CREATED_LIST}" >> $GITHUB_OUTPUT
else
echo "created-branches=" >> $GITHUB_OUTPUT
fi
if [ -n "${FAILED}" ]; then
exit 1
fi
@@ -290,7 +330,7 @@ jobs:
PR_TITLE="${{ github.event.pull_request.title }}"
PR_AUTHOR="${{ github.event.pull_request.user.login }}"
fi
for backport in ${{ steps.backport.outputs.success }}; do
IFS=':' read -r target branch <<< "${backport}"
@@ -348,6 +388,25 @@ jobs:
fi
done
- name: Cleanup stranded backport branches
if: steps.filter-targets.outputs.skip != 'true' && failure()
run: |
FILE="${CREATED_BRANCHES_FILE:-}"
if [ -z "$FILE" ] || [ ! -f "$FILE" ]; then
echo "No backport branches recorded for cleanup"
exit 0
fi
while IFS= read -r branch; do
[ -z "$branch" ] && continue
printf 'Deleting branch %s\n' "${branch}"
if ! git push origin --delete "$branch"; then
echo "::warning::Failed to delete ${branch}"
fi
done < "$FILE"
- name: Remove needs-backport label
if: steps.filter-targets.outputs.skip != 'true' && success()
run: gh pr edit ${{ github.event_name == 'workflow_dispatch' && inputs.pr_number || github.event.pull_request.number }} --remove-label "needs-backport"

View File

@@ -127,26 +127,26 @@ jobs:
echo "=========================================="
echo "STAGING CHANGED SNAPSHOTS (Shard ${{ matrix.shardIndex }})"
echo "=========================================="
# Get list of changed snapshot files
changed_files=$(git diff --name-only browser_tests/ 2>/dev/null | grep -E '\-snapshots/' || echo "")
if [ -z "$changed_files" ]; then
echo "No snapshot changes in this shard"
echo "has-changes=false" >> $GITHUB_OUTPUT
exit 0
fi
echo "✓ Found changed files:"
echo "$changed_files"
file_count=$(echo "$changed_files" | wc -l)
echo "Count: $file_count"
echo "has-changes=true" >> $GITHUB_OUTPUT
echo ""
# Create staging directory
mkdir -p /tmp/changed_snapshots_shard
# Copy only changed files, preserving directory structure
# Strip 'browser_tests/' prefix to avoid double nesting
echo "Copying changed files to staging directory..."
@@ -159,7 +159,7 @@ jobs:
cp "$file" "/tmp/changed_snapshots_shard/$file_without_prefix"
echo " → $file_without_prefix"
done <<< "$changed_files"
echo ""
echo "Staged files for upload:"
find /tmp/changed_snapshots_shard -type f
@@ -233,18 +233,18 @@ jobs:
shard_name=$(basename "$shard_dir")
file_count=$(find "$shard_dir" -type f | wc -l)
if [ "$file_count" -eq 0 ]; then
echo " $shard_name: no files"
continue
fi
echo "Processing $shard_name ($file_count file(s))..."
# Copy files directly, preserving directory structure
# Since files are already in correct structure (no browser_tests/ prefix), just copy them all
cp -v -r "$shard_dir"* browser_tests/ 2>&1 | sed 's/^/ /'
merged_count=$((merged_count + 1))
echo " ✓ Merged"
echo ""
@@ -272,25 +272,25 @@ jobs:
run: |
git config --global user.name 'github-actions'
git config --global user.email 'github-actions@github.com'
if git diff --quiet browser_tests/; then
echo "No changes to commit"
echo "has-changes=false" >> $GITHUB_OUTPUT
exit 0
fi
echo "=========================================="
echo "COMMITTING CHANGES"
echo "=========================================="
echo "has-changes=true" >> $GITHUB_OUTPUT
git add browser_tests/
git commit -m "[automated] Update test expectations"
echo "Pushing to ${{ needs.setup.outputs.branch }}..."
git push origin ${{ needs.setup.outputs.branch }}
echo "✓ Commit and push successful"
- name: Add Done Reaction
@@ -306,4 +306,4 @@ jobs:
if: always() && github.event_name == 'pull_request'
run: gh pr edit ${{ needs.setup.outputs.pr-number }} --remove-label "New Browser Test Expectations"
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}

View File

@@ -153,8 +153,78 @@ jobs:
echo "EOF"
} >> $GITHUB_OUTPUT
- name: Post summary
- name: Ensure release labels
if: steps.check_version.outputs.is_minor_bump == 'true'
env:
GH_TOKEN: ${{ secrets.PR_GH_TOKEN || secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
BRANCH_BASE="${{ steps.check_version.outputs.branch_base }}"
if [[ -z "$BRANCH_BASE" ]]; then
echo "::error::Branch base not set; unable to manage labels"
exit 1
fi
declare -A COLORS=(
[core]="4361ee"
[cloud]="4f6ef5"
)
for PREFIX in core cloud; do
LABEL="${PREFIX}/${BRANCH_BASE}"
COLOR="${COLORS[$PREFIX]}"
DESCRIPTION="Backport PRs for ${PREFIX} ${BRANCH_BASE}"
if gh label view "$LABEL" >/dev/null 2>&1; then
gh label edit "$LABEL" \
--color "$COLOR" \
--description "$DESCRIPTION"
echo "🔄 Updated label $LABEL"
else
gh label create "$LABEL" \
--color "$COLOR" \
--description "$DESCRIPTION"
echo "✨ Created label $LABEL"
fi
done
MIN_LABELS_TO_KEEP=3
MAX_LABELS_TO_FETCH=200
for PREFIX in core cloud; do
mapfile -t LABELS < <(
gh label list \
--json name \
--limit "$MAX_LABELS_TO_FETCH" \
--jq '.[].name' |
grep -E "^${PREFIX}/[0-9]+\.[0-9]+$" |
sort -t/ -k2,2V
)
TOTAL=${#LABELS[@]}
if (( TOTAL <= MIN_LABELS_TO_KEEP )); then
echo " Nothing to prune for $PREFIX labels"
continue
fi
REMOVE_COUNT=$((TOTAL - MIN_LABELS_TO_KEEP))
if (( REMOVE_COUNT > 1 )); then
REMOVE_COUNT=1
fi
for ((i=0; i<REMOVE_COUNT; i++)); do
OLD_LABEL="${LABELS[$i]}"
gh label delete "$OLD_LABEL" --yes
echo "🗑️ Removed old label $OLD_LABEL"
done
done
- name: Post summary
if: always() && steps.check_version.outputs.is_minor_bump == 'true'
run: |
CURRENT_VERSION="${{ steps.check_version.outputs.current_version }}"
RESULTS="${{ steps.create_branches.outputs.results }}"

View File

@@ -59,6 +59,7 @@ jobs:
uses: actions/setup-node@v4
with:
node-version: lts/*
cache: 'pnpm'
- name: Bump version
id: bump-version

View File

@@ -92,4 +92,3 @@ jobs:
base: ${{ github.event.inputs.branch }}
labels: |
Release

43
.oxlintrc.json Normal file
View File

@@ -0,0 +1,43 @@
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"ignorePatterns": [
".i18nrc.cjs",
"components.d.ts",
"lint-staged.config.js",
"vitest.setup.ts",
"**/vite.config.*.timestamp*",
"**/vitest.config.*.timestamp*",
"packages/registry-types/src/comfyRegistryTypes.ts",
"src/extensions/core/*",
"src/scripts/*",
"src/types/generatedManagerTypes.ts",
"src/types/vue-shim.d.ts"
],
"rules": {
"no-async-promise-executor": "off",
"no-control-regex": "off",
"no-eval": "off",
"no-self-assign": "allow",
"no-unused-expressions": "off",
"no-unused-private-class-members": "off",
"no-useless-rename": "off",
"typescript/no-this-alias": "off",
"typescript/no-unnecessary-parameter-property-assignment": "off",
"typescript/no-unsafe-declaration-merging": "off",
"typescript/no-unused-vars": "off",
"unicorn/no-empty-file": "off",
"unicorn/no-new-array": "off",
"unicorn/no-single-promise-in-promise-methods": "off",
"unicorn/no-useless-fallback-in-spread": "off",
"unicorn/no-useless-spread": "off",
"typescript/await-thenable": "off",
"typescript/no-base-to-string": "off",
"typescript/no-duplicate-type-constituents": "off",
"typescript/no-for-in-array": "off",
"typescript/no-meaningless-void-operator": "off",
"typescript/no-redundant-type-constituents": "off",
"typescript/restrict-template-expressions": "off",
"typescript/unbound-method": "off",
"typescript/no-floating-promises": "error"
}
}

10
.yamllint Normal file
View File

@@ -0,0 +1,10 @@
extends: default
ignore: |
node_modules/
dist/
rules:
line-length: disable
document-start: disable
truthy: disable

View File

@@ -17,6 +17,7 @@ This bootstraps the monorepo with dependencies, builds, tests, and dev server ve
- `pnpm typecheck`: Type checking
- `pnpm build`: Build for production (via nx)
- `pnpm lint`: Linting (via nx)
- `pnpm oxlint`: Fast Rust-based linting with Oxc
- `pnpm format`: Prettier formatting
- `pnpm test:unit`: Run all unit tests
- `pnpm test:browser`: Run E2E tests via Playwright

View File

@@ -71,8 +71,8 @@ const updateConsent = async () => {
} catch (error) {
toast.add({
severity: 'error',
summary: t('install.errorUpdatingConsent'),
detail: t('install.errorUpdatingConsentDetail'),
summary: t('install.settings.errorUpdatingConsent'),
detail: t('install.settings.errorUpdatingConsentDetail'),
life: 3000
})
} finally {

View File

@@ -3,9 +3,8 @@
"compilerOptions": {
"noEmit": true,
"allowImportingTsExtensions": true,
"baseUrl": ".",
"paths": {
"@/*": ["src/*"],
"@/*": ["./src/*"],
"@frontend-locales/*": ["../../src/locales/*"]
}
},

View File

@@ -88,6 +88,10 @@ test.describe('Missing models warning', () => {
const downloadButton = missingModelsWarning.getByLabel('Download')
await expect(downloadButton).toBeVisible()
// Check that the copy URL button is also visible for Desktop environment
const copyUrlButton = missingModelsWarning.getByLabel('Copy URL')
await expect(copyUrlButton).toBeVisible()
})
test('Should display a warning when missing models are found in node properties', async ({
@@ -101,6 +105,10 @@ test.describe('Missing models warning', () => {
const downloadButton = missingModelsWarning.getByLabel('Download')
await expect(downloadButton).toBeVisible()
// Check that the copy URL button is also visible for Desktop environment
const copyUrlButton = missingModelsWarning.getByLabel('Copy URL')
await expect(copyUrlButton).toBeVisible()
})
test('Should not display a warning when no missing models are found', async ({

Binary file not shown.

Before

Width:  |  Height:  |  Size: 98 KiB

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 111 KiB

After

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 56 KiB

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 64 KiB

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 61 KiB

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 63 KiB

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 65 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 64 KiB

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 61 KiB

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 98 KiB

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 113 KiB

After

Width:  |  Height:  |  Size: 115 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 111 KiB

After

Width:  |  Height:  |  Size: 114 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 108 KiB

After

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 104 KiB

After

Width:  |  Height:  |  Size: 129 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 99 KiB

After

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 99 KiB

After

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 112 KiB

After

Width:  |  Height:  |  Size: 115 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 77 KiB

After

Width:  |  Height:  |  Size: 82 KiB

View File

@@ -3,6 +3,7 @@ import pluginJs from '@eslint/js'
import pluginI18n from '@intlify/eslint-plugin-vue-i18n'
import { createTypeScriptImportResolver } from 'eslint-import-resolver-typescript'
import { importX } from 'eslint-plugin-import-x'
import oxlint from 'eslint-plugin-oxlint'
import eslintPluginPrettierRecommended from 'eslint-plugin-prettier/recommended'
import storybook from 'eslint-plugin-storybook'
import unusedImports from 'eslint-plugin-unused-imports'
@@ -33,7 +34,18 @@ const settings = {
],
noWarnOnMultipleProjects: true
})
]
],
'vue-i18n': {
localeDir: [
{
pattern: './src/locales/**/*.json',
localeKey: 'path',
localePattern:
/^\.?\/?src\/locales\/(?<locale>[A-Za-z0-9-]+)\/.+\.json$/
}
],
messageSyntaxVersion: '^9.0.0'
}
} as const
const commonParserOptions = {
@@ -94,19 +106,23 @@ export default defineConfig([
// @ts-ignore Bad types in the plugin
pluginVue.configs['flat/recommended'],
eslintPluginPrettierRecommended,
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore Type incompatibility between import-x plugin and ESLint config types
storybook.configs['flat/recommended'],
// @ts-expect-error Bad types in the plugin
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore Type incompatibility between import-x plugin and ESLint config types
importX.flatConfigs.recommended,
// @ts-expect-error Bad types in the plugin
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore Type incompatibility between import-x plugin and ESLint config types
importX.flatConfigs.typescript,
{
plugins: {
'unused-imports': unusedImports,
// @ts-expect-error Bad types in the plugin
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore Type incompatibility in i18n plugin
'@intlify/vue-i18n': pluginI18n
},
rules: {
'@typescript-eslint/no-floating-promises': 'error',
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/no-unused-vars': 'off',
'@typescript-eslint/prefer-as-const': 'off',
@@ -259,5 +275,7 @@ export default defineConfig([
'@typescript-eslint/no-floating-promises': 'off',
'no-console': 'off'
}
}
},
// Turn off ESLint rules that are already handled by oxlint
...oxlint.buildFromOxlintConfigFile('./.oxlintrc.json')
])

View File

@@ -1,7 +1,7 @@
{
"name": "@comfyorg/comfyui-frontend",
"private": true,
"version": "1.32.4",
"version": "1.32.5",
"type": "module",
"repository": "https://github.com/Comfy-Org/ComfyUI_frontend",
"homepage": "https://comfy.org",
@@ -28,13 +28,14 @@
"json-schema": "tsx scripts/generate-json-schema.ts",
"knip:no-cache": "knip",
"knip": "knip --cache",
"lint:fix:no-cache": "eslint src --fix",
"lint:fix": "eslint src --cache --fix",
"lint:no-cache": "eslint src",
"lint:fix:no-cache": "oxlint src --type-aware --fix && eslint src --fix",
"lint:fix": "oxlint src --type-aware --fix && eslint src --cache --fix",
"lint:no-cache": "oxlint src --type-aware && eslint src",
"lint:unstaged:fix": "git diff --name-only HEAD | grep -E '\\.(js|ts|vue|mts)$' | xargs -r eslint --cache --fix",
"lint:unstaged": "git diff --name-only HEAD | grep -E '\\.(js|ts|vue|mts)$' | xargs -r eslint --cache",
"lint": "eslint src --cache",
"lint": "oxlint src --type-aware && eslint src --cache",
"locale": "lobe-i18n locale",
"oxlint": "oxlint src --type-aware",
"preinstall": "pnpm dlx only-allow pnpm",
"prepare": "husky || true && git config blame.ignoreRevsFile .git-blame-ignore-revs || true",
"preview": "nx preview",
@@ -42,6 +43,7 @@
"stylelint:fix": "stylelint --cache --fix '{apps,packages,src}/**/*.{css,vue}'",
"stylelint": "stylelint --cache '{apps,packages,src}/**/*.{css,vue}'",
"test:browser": "pnpm exec nx e2e",
"test:browser:local": "cross-env PLAYWRIGHT_LOCAL=1 pnpm test:browser",
"test:unit": "nx run test",
"typecheck": "vue-tsc --noEmit",
"zipdist": "node scripts/zipdist.js",
@@ -78,6 +80,7 @@
"eslint-config-prettier": "catalog:",
"eslint-import-resolver-typescript": "catalog:",
"eslint-plugin-import-x": "catalog:",
"eslint-plugin-oxlint": "catalog:",
"eslint-plugin-prettier": "catalog:",
"eslint-plugin-storybook": "catalog:",
"eslint-plugin-unused-imports": "catalog:",
@@ -93,6 +96,8 @@
"markdown-table": "catalog:",
"mixpanel-browser": "catalog:",
"nx": "catalog:",
"oxlint": "catalog:",
"oxlint-tsgolint": "catalog:",
"picocolors": "catalog:",
"postcss-html": "catalog:",
"prettier": "catalog:",

View File

@@ -1346,3 +1346,466 @@ audio.comfy-audio.empty-audio-widget {
border-radius: 0;
}
/* END LOD specific styles */
/* ===================== Mask Editor Styles ===================== */
/* To be migrated to Tailwind later */
#maskEditor_brush {
position: absolute;
backgroundColor: transparent;
z-index: 8889;
pointer-events: none;
border-radius: 50%;
overflow: visible;
outline: 1px dashed black;
box-shadow: 0 0 0 1px white;
}
#maskEditor_brushPreviewGradient {
position: absolute;
width: 100%;
height: 100%;
border-radius: 50%;
display: none;
}
.maskEditor_sidePanelTitle {
text-align: center;
font-size: 15px;
font-family: sans-serif;
color: var(--descrip-text);
margin-top: 10px;
}
.maskEditor_sidePanelBrushShapeCircle {
width: 35px;
height: 35px;
border-radius: 50%;
border: 1px solid var(--border-color);
pointer-events: auto;
transition: background 0.1s;
margin-left: 7.5px;
}
.maskEditor_sidePanelBrushRange {
width: 180px;
appearance: none;
background: transparent;
cursor: pointer;
}
.maskEditor_sidePanelBrushRange::-webkit-slider-thumb {
height: 20px;
width: 20px;
border-radius: 50%;
cursor: grab;
margin-top: -8px;
background: var(--p-surface-700);
border: 1px solid var(--border-color);
}
.maskEditor_sidePanelBrushRange::-moz-range-thumb {
height: 20px;
width: 20px;
border-radius: 50%;
cursor: grab;
background: var(--p-surface-800);
border: 1px solid var(--border-color);
}
.maskEditor_sidePanelBrushRange::-webkit-slider-runnable-track {
background: var(--p-surface-700);
height: 3px;
}
.maskEditor_sidePanelBrushRange::-moz-range-track {
background: var(--p-surface-700);
height: 3px;
}
.maskEditor_sidePanelBrushShapeSquare {
width: 35px;
height: 35px;
margin: 5px;
border: 1px solid var(--border-color);
pointer-events: auto;
transition: background 0.1s;
}
.maskEditor_brushShape_dark {
background: transparent;
}
.maskEditor_brushShape_dark:hover {
background: var(--p-surface-900);
}
.maskEditor_brushShape_light {
background: transparent;
}
.maskEditor_brushShape_light:hover {
background: var(--comfy-menu-bg);
}
.maskEditor_sidePanelLayer {
display: flex;
width: 100%;
height: 50px;
}
.maskEditor_sidePanelLayerVisibilityContainer {
width: 50px;
height: 50px;
border-radius: 8px;
display: flex;
justify-content: center;
align-items: center;
}
.maskEditor_sidePanelVisibilityToggle {
width: 12px;
height: 12px;
border-radius: 50%;
pointer-events: auto;
}
.maskEditor_sidePanelLayerIconContainer {
width: 60px;
height: 50px;
border-radius: 8px;
display: flex;
justify-content: center;
align-items: center;
fill: var(--input-text);
}
.maskEditor_sidePanelLayerIconContainer svg {
width: 30px;
height: 30px;
}
.maskEditor_sidePanelBigButton {
width: 85px;
height: 30px;
background: rgb(0 0 0 / 0.2);
border: 1px solid var(--border-color);
color: var(--input-text);
font-family: sans-serif;
font-size: 15px;
pointer-events: auto;
transition: background-color 0.1s;
}
.maskEditor_sidePanelBigButton:hover {
background-color: var(--p-overlaybadge-outline-color);
border: none;
}
.maskEditor_toolPanelContainer {
width: 4rem;
height: 4rem;
display: flex;
justify-content: center;
align-items: center;
position: relative;
transition: background-color 0.2s;
}
.maskEditor_toolPanelContainerSelected svg {
fill: var(--p-button-text-primary-color) !important;
}
.maskEditor_toolPanelContainerSelected .maskEditor_toolPanelIndicator {
display: block;
}
.maskEditor_toolPanelContainer svg {
width: 75%;
aspect-ratio: 1/1;
fill: var(--p-button-text-secondary-color);
}
.maskEditor_toolPanelContainerDark:hover {
background-color: var(--p-surface-800);
}
.maskEditor_toolPanelContainerLight:hover {
background-color: var(--p-surface-300);
}
.maskEditor_toolPanelIndicator {
display: none;
height: 100%;
width: 4px;
position: absolute;
left: 0;
background: var(--p-button-text-primary-color);
}
.maskEditor_sidePanelSeparator {
width: 100%;
height: 2px;
background: var(--border-color);
margin-top: 1.5em;
margin-bottom: 5px;
}
#maskEditorCanvasContainer {
position: absolute;
width: 1000px;
height: 667px;
left: 359px;
top: 280px;
}
.maskEditor_topPanelIconButton_dark {
width: 50px;
height: 30px;
pointer-events: auto;
display: flex;
justify-content: center;
align-items: center;
transition: background-color 0.1s;
background: var(--p-surface-800);
border: 1px solid var(--p-form-field-border-color);
border-radius: 10px;
}
.maskEditor_topPanelIconButton_dark:hover {
background-color: var(--p-surface-900);
}
.maskEditor_topPanelIconButton_dark svg {
width: 25px;
height: 25px;
pointer-events: none;
fill: var(--input-text);
}
.maskEditor_topPanelIconButton_light {
width: 50px;
height: 30px;
pointer-events: auto;
display: flex;
justify-content: center;
align-items: center;
transition: background-color 0.1s;
background: var(--comfy-menu-bg);
border: 1px solid var(--p-form-field-border-color);
border-radius: 10px;
}
.maskEditor_topPanelIconButton_light:hover {
background-color: var(--p-surface-300);
}
.maskEditor_topPanelIconButton_light svg {
width: 25px;
height: 25px;
pointer-events: none;
fill: var(--input-text);
}
.maskEditor_topPanelButton_dark {
height: 30px;
background: var(--p-surface-800);
border: 1px solid var(--p-form-field-border-color);
border-radius: 10px;
color: var(--input-text);
font-family: sans-serif;
pointer-events: auto;
transition: 0.1s;
width: 60px;
}
.maskEditor_topPanelButton_dark:hover {
background-color: var(--p-surface-900);
}
.maskEditor_topPanelButton_light {
height: 30px;
background: var(--comfy-menu-bg);
border: 1px solid var(--p-form-field-border-color);
border-radius: 10px;
color: var(--input-text);
font-family: sans-serif;
pointer-events: auto;
transition: 0.1s;
width: 60px;
}
.maskEditor_topPanelButton_light:hover {
background-color: var(--p-surface-300);
}
.maskEditor_sidePanel_paintBucket_Container {
width: 180px;
display: flex;
flex-direction: column;
position: relative;
}
.maskEditor_sidePanel_colorSelect_Container {
display: flex;
width: 180px;
align-items: center;
gap: 5px;
height: 30px;
}
.maskEditor_sidePanel_colorSelect_tolerance_container {
display: flex;
flex-direction: column;
gap: 10px;
margin-bottom: 10px;
}
.maskEditor_sidePanelContainerColumn {
display: flex;
flex-direction: column;
gap: 12px;
padding-bottom: 12px;
}
.maskEditor_sidePanelContainerRow {
display: flex;
flex-direction: row;
gap: 10px;
align-items: center;
min-height: 24px;
position: relative;
}
.maskEditor_accent_bg_dark {
background: var(--p-surface-800);
}
.maskEditor_accent_bg_very_dark {
background: var(--p-surface-900);
}
.maskEditor_accent_bg_light {
background: var(--p-surface-300);
}
.maskEditor_accent_bg_very_light {
background: var(--comfy-menu-bg);
}
.maskEditor_sidePanelToggleContainer {
cursor: pointer;
display: inline-block;
position: absolute;
right: 0;
}
.maskEditor_sidePanelToggleSwitch {
display: inline-block;
border-radius: 16px;
width: 40px;
height: 24px;
position: relative;
vertical-align: middle;
transition: background 0.25s;
background: var(--p-surface-300);
}
.dark-theme .maskEditor_sidePanelToggleSwitch {
background: var(--p-surface-700);
}
.maskEditor_sidePanelToggleSwitch::before, .maskEditor_sidePanelToggleSwitch::after {
content: "";
}
.maskEditor_sidePanelToggleSwitch::before {
display: block;
background: linear-gradient(to bottom, #fff 0%, #eee 100%);
border-radius: 50%;
width: 16px;
height: 16px;
position: absolute;
top: 4px;
left: 4px;
transition: ease 0.2s;
}
.maskEditor_sidePanelToggleContainer:hover .maskEditor_sidePanelToggleSwitch::before {
background: linear-gradient(to bottom, #fff 0%, #fff 100%);
}
.maskEditor_sidePanelToggleCheckbox:checked + .maskEditor_sidePanelToggleSwitch {
background: var(--p-button-text-primary-color);
}
.maskEditor_sidePanelToggleCheckbox:checked + .maskEditor_sidePanelToggleSwitch::before {
background: var(--comfy-menu-bg);
left: 20px;
}
.dark-theme .maskEditor_sidePanelToggleCheckbox:checked + .maskEditor_sidePanelToggleSwitch::before {
background: var(--p-surface-900);
}
.maskEditor_sidePanelToggleCheckbox {
position: absolute;
visibility: hidden;
}
.maskEditor_sidePanelDropdown {
border: 1px solid var(--p-form-field-border-color);
background: var(--comfy-menu-bg);
height: 24px;
padding-left: 5px;
padding-right: 5px;
border-radius: 6px;
transition: background 0.1s;
}
.maskEditor_sidePanelDropdown option {
background: var(--comfy-menu-bg);
}
.maskEditor_sidePanelDropdown:focus {
outline: 1px solid var(--p-surface-300);
}
.maskEditor_sidePanelDropdown option:hover {
background: white;
}
.maskEditor_sidePanelDropdown option:active {
background: var(--p-surface-300);
}
.dark-theme .maskEditor_sidePanelDropdown {
background: var(--p-surface-900);
}
.dark-theme .maskEditor_sidePanelDropdown option {
background: var(--p-surface-900);
}
.dark-theme .maskEditor_sidePanelDropdown:focus {
outline: 1px solid var(--p-button-text-primary-color);
}
.dark-theme .maskEditor_sidePanelDropdown option:active {
background: var(--p-highlight-background);
}
.maskEditor_layerRow {
height: 50px;
width: 100%;
border-radius: 10px;
}
.maskEditor_sidePanelLayerPreviewContainer {
width: 40px;
height: 30px;
}
.maskEditor_sidePanelLayerPreviewContainer > svg{
width: 100%;
height: 100%;
object-fit: contain;
fill: var(--p-surface-100);
}
.maskEditor_sidePanelImageLayerImage {
width: 100%;
height: 100%;
object-fit: contain;
}
.maskEditor_sidePanelSubTitle {
text-align: left;
font-size: 12px;
font-family: sans-serif;
color: var(--descrip-text);
}
.maskEditor_containerDropdown {
position: absolute;
right: 0;
}
.maskEditor_sidePanelLayerCheckbox {
margin-left: 15px;
}
/* ===================== End of Mask Editor Styles ===================== */

View File

@@ -1,27 +1,32 @@
import { defineConfig, devices } from '@playwright/test'
import type { PlaywrightTestConfig } from '@playwright/test'
const maybeLocalOptions: PlaywrightTestConfig = process.env.PLAYWRIGHT_LOCAL
? {
// VERY HELPFUL: Skip screenshot tests locally
// grep: process.env.CI ? undefined : /^(?!.*screenshot).*$/,
timeout: 30_000, // Longer timeout for breakpoints
retries: 0, // No retries while debugging. Increase if writing new tests. that may be flaky.
workers: 1, // Single worker for easier debugging. Increase to match CPU cores if you want to run a lot of tests in parallel.
use: {
trace: 'on', // Always capture traces (CI uses 'on-first-retry')
video: 'on' // Always record video (CI uses 'retain-on-failure')
}
}
: {
retries: process.env.CI ? 3 : 0,
use: {
trace: 'on-first-retry'
}
}
export default defineConfig({
testDir: './browser_tests',
fullyParallel: true,
forbidOnly: !!process.env.CI,
reporter: 'html',
// /* // Toggle for [LOCAL] testing.
retries: process.env.CI ? 3 : 0,
use: {
trace: 'on-first-retry'
},
/*/ // [LOCAL]
// VERY HELPFUL: Skip screenshot tests locally
// grep: process.env.CI ? undefined : /^(?!.*screenshot).*$/,
timeout: 30_000, // Longer timeout for breakpoints
retries: 0, // No retries while debugging. Increase if writing new tests. that may be flaky.
workers: 4, // Single worker for easier debugging. Increase to match CPU cores if you want to run a lot of tests in parallel.
use: {
trace: 'on', // Always capture traces (CI uses 'on-first-retry')
video: 'on' // Always record video (CI uses 'retain-on-failure')
},
//*/
...maybeLocalOptions,
globalSetup: './browser_tests/globalSetup.ts',
globalTeardown: './browser_tests/globalTeardown.ts',

191
pnpm-lock.yaml generated
View File

@@ -12,9 +12,15 @@ catalogs:
'@eslint/js':
specifier: ^9.35.0
version: 9.35.0
'@iconify-json/lucide':
specifier: ^1.1.178
version: 1.2.66
'@iconify/json':
specifier: ^2.2.380
version: 2.2.380
'@iconify/tailwind':
specifier: ^1.1.3
version: 1.2.0
'@intlify/eslint-plugin-vue-i18n':
specifier: ^4.1.0
version: 4.1.0
@@ -141,6 +147,9 @@ catalogs:
eslint-plugin-import-x:
specifier: ^4.16.1
version: 4.16.1
eslint-plugin-oxlint:
specifier: 1.25.0
version: 1.25.0
eslint-plugin-prettier:
specifier: ^5.5.4
version: 5.5.4
@@ -186,6 +195,12 @@ catalogs:
nx:
specifier: 21.4.1
version: 21.4.1
oxlint:
specifier: ^1.25.0
version: 1.28.0
oxlint-tsgolint:
specifier: ^0.4.0
version: 0.4.0
picocolors:
specifier: ^1.1.1
version: 1.1.1
@@ -555,6 +570,9 @@ importers:
eslint-plugin-import-x:
specifier: 'catalog:'
version: 4.16.1(@typescript-eslint/utils@8.44.0(eslint@9.35.0(jiti@2.4.2))(typescript@5.9.2))(eslint-import-resolver-node@0.3.9)(eslint@9.35.0(jiti@2.4.2))
eslint-plugin-oxlint:
specifier: 'catalog:'
version: 1.25.0
eslint-plugin-prettier:
specifier: 'catalog:'
version: 5.5.4(eslint-config-prettier@10.1.8(eslint@9.35.0(jiti@2.4.2)))(eslint@9.35.0(jiti@2.4.2))(prettier@3.6.2)
@@ -600,6 +618,12 @@ importers:
nx:
specifier: 'catalog:'
version: 21.4.1
oxlint:
specifier: 'catalog:'
version: 1.28.0(oxlint-tsgolint@0.4.0)
oxlint-tsgolint:
specifier: 'catalog:'
version: 0.4.0
picocolors:
specifier: 'catalog:'
version: 1.1.1
@@ -2529,6 +2553,76 @@ packages:
cpu: [x64]
os: [win32]
'@oxlint-tsgolint/darwin-arm64@0.4.0':
resolution: {integrity: sha512-2jNvhxs6JJy93Z4SQ/VErODBzZtFKxQ+sybcKYcw5/K41tXOiBbJSwBMZ2PPvivCVkVcyOkJvfs5UXWW7o79uw==}
cpu: [arm64]
os: [darwin]
'@oxlint-tsgolint/darwin-x64@0.4.0':
resolution: {integrity: sha512-6A+YBecdZhk2NJ8Dh3kRkR6htNekDmAopFkdyrtNvsHJs5qNNuwUv5RZlVMYiaQTh/Y/tZ0YWE4+cVdqPIEyxQ==}
cpu: [x64]
os: [darwin]
'@oxlint-tsgolint/linux-arm64@0.4.0':
resolution: {integrity: sha512-JaX8JfQnY3UwX7l6BXIjhEaJAVeKVASELLFCdoo5+DOHgPuiiSKcxCVgTl92WPAuS0TYFXOgqOg31WXkvdi8bQ==}
cpu: [arm64]
os: [linux]
'@oxlint-tsgolint/linux-x64@0.4.0':
resolution: {integrity: sha512-iu106lxV1O64O4vK2eRoIuY2iHuil/hyDNKLRNVaTg1un+yoxN6/C5uxrJix/EJ+1O27P9c+sXmMplcmbXujtg==}
cpu: [x64]
os: [linux]
'@oxlint-tsgolint/win32-arm64@0.4.0':
resolution: {integrity: sha512-KTp9EzkTCGAh4/sL3l5a9otX63TvTs5riBcrcqu0jYS3P762rZSezzMMDc0Ld51x+I37125p9+bue2vmlH/KbQ==}
cpu: [arm64]
os: [win32]
'@oxlint-tsgolint/win32-x64@0.4.0':
resolution: {integrity: sha512-ioyBLHx0HA+hn5of8mhnA8W8DWQyJEHc7SBvwku0EW9bWt7zvBtWRJfx1YilvM+KVBdLVX731qeofdJT1fbJiQ==}
cpu: [x64]
os: [win32]
'@oxlint/darwin-arm64@1.28.0':
resolution: {integrity: sha512-H7J41/iKbgm7tTpdSnA/AtjEAhxyzNzCMKWtKU5wDuP2v39jrc3fasQEJruk6hj1YXPbJY4N+1nK/jE27GMGDQ==}
cpu: [arm64]
os: [darwin]
'@oxlint/darwin-x64@1.28.0':
resolution: {integrity: sha512-bGsSDEwpyYzNc6FIwhTmbhSK7piREUjMlmWBt7eoR3ract0+RfhZYYG4se1Ngs+4WOFC0B3gbv23fyF+cnbGGQ==}
cpu: [x64]
os: [darwin]
'@oxlint/linux-arm64-gnu@1.28.0':
resolution: {integrity: sha512-eNH/evMpV3xAA4jIS8dMLcGkM/LK0WEHM0RO9bxrHPAwfS72jhyPJtd0R7nZhvhG6U1bhn5jhoXbk1dn27XIAQ==}
cpu: [arm64]
os: [linux]
'@oxlint/linux-arm64-musl@1.28.0':
resolution: {integrity: sha512-ickvpcekNeRLND3llndiZOtJBb6LDZqNnZICIDkovURkOIWPGJGmAxsHUOI6yW6iny9gLmIEIGl/c1b5nFk6Ag==}
cpu: [arm64]
os: [linux]
'@oxlint/linux-x64-gnu@1.28.0':
resolution: {integrity: sha512-DkgAh4LQ8NR3DwTT7/LGMhaMau0RtZkih91Ez5Usk7H7SOxo1GDi84beE7it2Q+22cAzgY4hbw3c6svonQTjxg==}
cpu: [x64]
os: [linux]
'@oxlint/linux-x64-musl@1.28.0':
resolution: {integrity: sha512-VBnMi3AJ2w5p/kgeyrjcGOKNY8RzZWWvlGHjCJwzqPgob4MXu6T+5Yrdi7EVJyIlouL8E3LYPYjmzB9NBi9gZw==}
cpu: [x64]
os: [linux]
'@oxlint/win32-arm64@1.28.0':
resolution: {integrity: sha512-tomhIks+4dKs8axB+s4GXHy+ZWXhUgptf1XnG5cZg8CzRfX4JFX9k8l2fPUgFwytWnyyvZaaXLRPWGzoZ6yoHQ==}
cpu: [arm64]
os: [win32]
'@oxlint/win32-x64@1.28.0':
resolution: {integrity: sha512-4+VO5P/UJ2nq9sj6kQToJxFy5cKs7dGIN2DiUSQ7cqyUi7EKYNQKe+98HFcDOjtm33jQOQnc4kw8Igya5KPozg==}
cpu: [x64]
os: [win32]
'@phenomnomnominal/tsquery@5.0.1':
resolution: {integrity: sha512-3nVv+e2FQwsW8Aw6qTU6f+1rfcJ3hrcnvH/mu9i8YhxO+9sqbOfpL8m6PbET5+xKOlz/VSbp0RoYWYCtIsnmuA==}
peerDependencies:
@@ -4732,6 +4826,9 @@ packages:
'@typescript-eslint/parser':
optional: true
eslint-plugin-oxlint@1.25.0:
resolution: {integrity: sha512-grS4KdR9FAxoQC+wMkepeQHL4osMhoYfUI11Pot6Gitqr4wWi+JZrX0Shr8Bs9fjdWhEjtaZIV6cr4mbfytmyw==}
eslint-plugin-prettier@5.5.4:
resolution: {integrity: sha512-swNtI95SToIz05YINMA6Ox5R057IMAmWZ26GqPxusAp1TZzj+IdY9tXNWWD3vkF/wEqydCONcwjTFpxybBqZsg==}
engines: {node: ^14.18.0 || >=16.0.0}
@@ -5692,6 +5789,9 @@ packages:
jsonc-parser@3.2.0:
resolution: {integrity: sha512-gfFQZrcTc8CnKXp6Y4/CBT3fTc0OVuDofpre4aEeEpSBPV5X5v4+Vmx+8snU7RLPrNHPKSgLxGo9YuQzz20o+w==}
jsonc-parser@3.3.1:
resolution: {integrity: sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==}
jsondiffpatch@0.6.0:
resolution: {integrity: sha512-3QItJOXp2AP1uv7waBkao5nCvhEv+QmJAd38Ybq7wNI74Q+BBmnLn4EDKz6yI9xGAIQoUF87qHt+kc1IVxB4zQ==}
engines: {node: ^18.0.0 || >=20.0.0}
@@ -6386,6 +6486,20 @@ packages:
oxc-resolver@11.6.1:
resolution: {integrity: sha512-WQgmxevT4cM5MZ9ioQnEwJiHpPzbvntV5nInGAKo9NQZzegcOonHvcVcnkYqld7bTG35UFHEKeF7VwwsmA3cZg==}
oxlint-tsgolint@0.4.0:
resolution: {integrity: sha512-RpvLxPvSt0Xzr3frTiw5rP6HUW0djZ2uNdzHc8Pv556sbTnFWXmLdK8FRqqy7vVXZTkoVSdY3PsvOsVAqGjc+Q==}
hasBin: true
oxlint@1.28.0:
resolution: {integrity: sha512-gE97d0BcIlTTSJrim395B49mIbQ9VO8ZVoHdWai7Svl+lEeUAyCLTN4d7piw1kcB8VfgTp1JFVlAvMPD9GewMA==}
engines: {node: ^20.19.0 || >=22.12.0}
hasBin: true
peerDependencies:
oxlint-tsgolint: '>=0.4.0'
peerDependenciesMeta:
oxlint-tsgolint:
optional: true
p-limit@3.1.0:
resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==}
engines: {node: '>=10'}
@@ -7698,8 +7812,8 @@ packages:
vue-component-type-helpers@3.1.1:
resolution: {integrity: sha512-B0kHv7qX6E7+kdc5nsaqjdGZ1KwNKSUQDWGy7XkTYT7wFsOpkEyaJ1Vq79TjwrrtuLRgizrTV7PPuC4rRQo+vw==}
vue-component-type-helpers@3.1.2:
resolution: {integrity: sha512-ch3/SKBtxdZq18vsEntiGCdSszCRNfhX5QaTxjSacCAXLlNQRXfXo+ANjoQEYJMsJOJy1/vHF6Tkc4s85MS+zw==}
vue-component-type-helpers@3.1.3:
resolution: {integrity: sha512-V1dOD8XYfstOKCnXbWyEJIrhTBMwSyNjv271L1Jlx9ExpNlCSuqOs3OdWrGJ0V544zXufKbcYabi/o+gK8lyfQ==}
vue-demi@0.14.10:
resolution: {integrity: sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==}
@@ -10092,6 +10206,48 @@ snapshots:
'@oxc-resolver/binding-win32-x64-msvc@11.6.1':
optional: true
'@oxlint-tsgolint/darwin-arm64@0.4.0':
optional: true
'@oxlint-tsgolint/darwin-x64@0.4.0':
optional: true
'@oxlint-tsgolint/linux-arm64@0.4.0':
optional: true
'@oxlint-tsgolint/linux-x64@0.4.0':
optional: true
'@oxlint-tsgolint/win32-arm64@0.4.0':
optional: true
'@oxlint-tsgolint/win32-x64@0.4.0':
optional: true
'@oxlint/darwin-arm64@1.28.0':
optional: true
'@oxlint/darwin-x64@1.28.0':
optional: true
'@oxlint/linux-arm64-gnu@1.28.0':
optional: true
'@oxlint/linux-arm64-musl@1.28.0':
optional: true
'@oxlint/linux-x64-gnu@1.28.0':
optional: true
'@oxlint/linux-x64-musl@1.28.0':
optional: true
'@oxlint/win32-arm64@1.28.0':
optional: true
'@oxlint/win32-x64@1.28.0':
optional: true
'@phenomnomnominal/tsquery@5.0.1(typescript@5.9.2)':
dependencies:
esquery: 1.6.0
@@ -10458,7 +10614,7 @@ snapshots:
storybook: 9.1.6(@testing-library/dom@10.4.1)(prettier@3.6.2)(vite@5.4.19(@types/node@20.14.10)(lightningcss@1.30.1)(terser@5.39.2))
type-fest: 2.19.0
vue: 3.5.13(typescript@5.9.2)
vue-component-type-helpers: 3.1.2
vue-component-type-helpers: 3.1.3
'@swc/helpers@0.5.17':
dependencies:
@@ -12516,6 +12672,10 @@ snapshots:
- supports-color
optional: true
eslint-plugin-oxlint@1.25.0:
dependencies:
jsonc-parser: 3.3.1
eslint-plugin-prettier@5.5.4(eslint-config-prettier@10.1.8(eslint@9.35.0(jiti@2.4.2)))(eslint@9.35.0(jiti@2.4.2))(prettier@3.6.2):
dependencies:
eslint: 9.35.0(jiti@2.4.2)
@@ -13581,6 +13741,8 @@ snapshots:
jsonc-parser@3.2.0: {}
jsonc-parser@3.3.1: {}
jsondiffpatch@0.6.0:
dependencies:
'@types/diff-match-patch': 1.0.36
@@ -14548,6 +14710,27 @@ snapshots:
'@oxc-resolver/binding-win32-ia32-msvc': 11.6.1
'@oxc-resolver/binding-win32-x64-msvc': 11.6.1
oxlint-tsgolint@0.4.0:
optionalDependencies:
'@oxlint-tsgolint/darwin-arm64': 0.4.0
'@oxlint-tsgolint/darwin-x64': 0.4.0
'@oxlint-tsgolint/linux-arm64': 0.4.0
'@oxlint-tsgolint/linux-x64': 0.4.0
'@oxlint-tsgolint/win32-arm64': 0.4.0
'@oxlint-tsgolint/win32-x64': 0.4.0
oxlint@1.28.0(oxlint-tsgolint@0.4.0):
optionalDependencies:
'@oxlint/darwin-arm64': 1.28.0
'@oxlint/darwin-x64': 1.28.0
'@oxlint/linux-arm64-gnu': 1.28.0
'@oxlint/linux-arm64-musl': 1.28.0
'@oxlint/linux-x64-gnu': 1.28.0
'@oxlint/linux-x64-musl': 1.28.0
'@oxlint/win32-arm64': 1.28.0
'@oxlint/win32-x64': 1.28.0
oxlint-tsgolint: 0.4.0
p-limit@3.1.0:
dependencies:
yocto-queue: 0.1.0
@@ -16157,7 +16340,7 @@ snapshots:
vue-component-type-helpers@3.1.1: {}
vue-component-type-helpers@3.1.2: {}
vue-component-type-helpers@3.1.3: {}
vue-demi@0.14.10(vue@3.5.13(typescript@5.9.2)):
dependencies:

View File

@@ -50,6 +50,7 @@ catalog:
eslint-config-prettier: ^10.1.8
eslint-import-resolver-typescript: ^4.4.4
eslint-plugin-import-x: ^4.16.1
eslint-plugin-oxlint: 1.25.0
eslint-plugin-prettier: ^5.5.4
eslint-plugin-storybook: ^9.1.6
eslint-plugin-unused-imports: ^4.2.0
@@ -65,6 +66,8 @@ catalog:
markdown-table: ^3.0.4
mixpanel-browser: ^2.71.0
nx: 21.4.1
oxlint: ^1.25.0
oxlint-tsgolint: ^0.4.0
picocolors: ^1.1.1
pinia: ^2.1.7
postcss-html: ^1.8.0

14
scripts/cicd/check-yaml.sh Executable file
View File

@@ -0,0 +1,14 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(git rev-parse --show-toplevel)"
cd "$ROOT_DIR"
mapfile -t yaml_files < <(git ls-files '*.yml' '*.yaml')
if [[ ${#yaml_files[@]} -eq 0 ]]; then
echo "No YAML files found to lint"
exit 0
fi
yamllint --config-file .yamllint "${yaml_files[@]}"

View File

@@ -32,7 +32,7 @@ defineOptions({
interface IconTextButtonProps extends BaseButtonProps {
iconPosition?: 'left' | 'right'
label: string
onClick: () => void
onClick?: () => void
}
const {

View File

@@ -12,7 +12,7 @@
</div>
</div>
<div class="file-action">
<div class="file-action flex flex-row items-center gap-2">
<Button
v-if="status === null || status === 'error'"
class="file-action-button"
@@ -23,6 +23,13 @@
icon="pi pi-download"
@click="triggerDownload"
/>
<Button
v-if="(status === null || status === 'error') && !!props.url"
:label="$t('g.copyURL')"
size="small"
outlined
@click="copyURL"
/>
</div>
</div>
<div
@@ -80,6 +87,7 @@ import ProgressBar from 'primevue/progressbar'
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { useCopyToClipboard } from '@/composables/useCopyToClipboard'
import { useDownload } from '@/composables/useDownload'
import { useElectronDownloadStore } from '@/stores/electronDownloadStore'
import { formatSize } from '@/utils/formatUtil'
@@ -100,6 +108,7 @@ const status = ref<string | null>(null)
const fileSize = computed(() =>
download.fileSize.value ? formatSize(download.fileSize.value) : '?'
)
const { copyToClipboard } = useCopyToClipboard()
const electronDownloadStore = useElectronDownloadStore()
// @ts-expect-error fixme ts strict error
const [savePath, filename] = props.label.split('/')
@@ -126,4 +135,8 @@ const triggerDownload = async () => {
const triggerCancelDownload = () => electronDownloadStore.cancel(props.url)
const triggerPauseDownload = () => electronDownloadStore.pause(props.url)
const triggerResumeDownload = () => electronDownloadStore.resume(props.url)
const copyURL = async () => {
await copyToClipboard(props.url)
}
</script>

View File

@@ -29,7 +29,10 @@
import Button from 'primevue/button'
import { useI18n } from 'vue-i18n'
import { useExternalLink } from '@/composables/useExternalLink'
const { t } = useI18n()
const { buildDocsUrl } = useExternalLink()
const { apiNodeNames, onLogin, onCancel } = defineProps<{
apiNodeNames: string[]
@@ -38,6 +41,9 @@ const { apiNodeNames, onLogin, onCancel } = defineProps<{
}>()
const handleLearnMoreClick = () => {
window.open('https://docs.comfy.org/tutorials/api-nodes/faq', '_blank')
window.open(
buildDocsUrl('/tutorials/api-nodes/faq', { includeLocale: true }),
'_blank'
)
}
</script>

View File

@@ -49,14 +49,14 @@ import { computed } from 'vue'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { useSystemStatsStore } from '@/stores/systemStatsStore'
const props = defineProps<{
const { missingCoreNodes } = defineProps<{
missingCoreNodes: Record<string, LGraphNode[]>
}>()
const systemStatsStore = useSystemStatsStore()
const hasMissingCoreNodes = computed(() => {
return Object.keys(props.missingCoreNodes).length > 0
return Object.keys(missingCoreNodes).length > 0
})
// Use computed for reactive version tracking
@@ -66,7 +66,7 @@ const currentComfyUIVersion = computed<string | null>(() => {
})
const sortedMissingCoreNodes = computed(() => {
return Object.entries(props.missingCoreNodes).sort(([a], [b]) => {
return Object.entries(missingCoreNodes).sort(([a], [b]) => {
// Sort by version in descending order (newest first)
return compare(b, a) // Reversed for descending order
})

View File

@@ -0,0 +1,83 @@
<template>
<div
class="flex w-[490px] flex-col border-t-1 border-b-1 border-border-default"
>
<div class="flex h-full w-full flex-col gap-4 p-4">
<!-- Description -->
<div>
<p class="m-0 text-sm leading-4 text-muted-foreground">
{{
isCloud
? $t('missingNodes.cloud.description')
: $t('missingNodes.oss.description')
}}
</p>
</div>
<MissingCoreNodesMessage v-if="!isCloud" :missing-core-nodes />
<!-- Missing Nodes List Wrapper -->
<div
class="comfy-missing-nodes flex flex-col max-h-[256px] rounded-lg py-2 scrollbar-custom bg-secondary-background"
>
<div
v-for="(node, i) in uniqueNodes"
:key="i"
class="flex min-h-8 items-center justify-between px-4 py-2 bg-secondary-background text-muted-foreground"
>
<span class="text-xs">
{{ node.label }}
</span>
<span v-if="node.hint" class="text-xs">{{ node.hint }}</span>
</div>
</div>
<!-- Bottom instruction -->
<div>
<p class="m-0 text-sm leading-4 text-muted-foreground">
{{
isCloud
? $t('missingNodes.cloud.replacementInstruction')
: $t('missingNodes.oss.replacementInstruction')
}}
</p>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import MissingCoreNodesMessage from '@/components/dialog/content/MissingCoreNodesMessage.vue'
import { isCloud } from '@/platform/distribution/types'
import type { MissingNodeType } from '@/types/comfy'
import { useMissingNodes } from '@/workbench/extensions/manager/composables/nodePack/useMissingNodes'
const props = defineProps<{
missingNodeTypes: MissingNodeType[]
}>()
// Get missing core nodes for OSS mode
const { missingCoreNodes } = useMissingNodes()
const uniqueNodes = computed(() => {
const seenTypes = new Set()
return props.missingNodeTypes
.filter((node) => {
const type = typeof node === 'object' ? node.type : node
if (seenTypes.has(type)) return false
seenTypes.add(type)
return true
})
.map((node) => {
if (typeof node === 'object') {
return {
label: node.type,
hint: node.hint,
action: node.action
}
}
return { label: node }
})
})
</script>

View File

@@ -1,39 +1,42 @@
<template>
<NoResultsPlaceholder
class="pb-0"
icon="pi pi-exclamation-circle"
:title="$t('loadWorkflowWarning.missingNodesTitle')"
:message="$t('loadWorkflowWarning.missingNodesDescription')"
/>
<MissingCoreNodesMessage :missing-core-nodes="missingCoreNodes" />
<ListBox
:options="uniqueNodes"
option-label="label"
scroll-height="100%"
class="comfy-missing-nodes"
:pt="{
list: { class: 'border-none' }
}"
<!-- Cloud mode: Learn More + Got It buttons -->
<div
v-if="isCloud"
class="flex w-full items-center justify-between gap-2 py-2 px-4"
>
<template #option="slotProps">
<div class="align-items-center flex">
<span class="node-type">{{ slotProps.option.label }}</span>
<span v-if="slotProps.option.hint" class="node-hint">{{
slotProps.option.hint
}}</span>
<Button
v-if="slotProps.option.action"
:label="slotProps.option.action.text"
size="small"
outlined
@click="slotProps.option.action.callback"
/>
</div>
</template>
</ListBox>
<div v-if="showManagerButtons" class="flex justify-end py-3">
<IconTextButton
:label="$t('missingNodes.cloud.learnMore')"
type="transparent"
size="sm"
icon-position="left"
as="a"
href="https://www.comfy.org/cloud"
target="_blank"
rel="noopener noreferrer"
>
<template #icon>
<i class="icon-[lucide--info]"></i>
</template>
</IconTextButton>
<TextButton
:label="$t('missingNodes.cloud.gotIt')"
type="secondary"
size="md"
@click="handleGotItClick"
/>
</div>
<!-- OSS mode: Open Manager + Install All buttons -->
<div v-else-if="showManagerButtons" class="flex justify-end gap-1 py-2 px-4">
<TextButton
:label="$t('g.openManager')"
type="transparent"
size="sm"
@click="openManager"
/>
<PackInstallButton
v-if="showInstallAllButton"
type="secondary"
size="md"
:disabled="
isLoading || !!error || missingNodePacks.length === 0 || isInstalling
@@ -46,40 +49,32 @@
: $t('manager.installAllMissingNodes')
"
/>
<Button
:label="$t('g.openManager')"
size="small"
outlined
@click="openManager"
/>
</div>
</template>
<script setup lang="ts">
import Button from 'primevue/button'
import ListBox from 'primevue/listbox'
import { computed, nextTick, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import NoResultsPlaceholder from '@/components/common/NoResultsPlaceholder.vue'
import MissingCoreNodesMessage from '@/components/dialog/content/MissingCoreNodesMessage.vue'
import IconTextButton from '@/components/button/IconTextButton.vue'
import TextButton from '@/components/button/TextButton.vue'
import { isCloud } from '@/platform/distribution/types'
import { useToastStore } from '@/platform/updates/common/toastStore'
import { useDialogStore } from '@/stores/dialogStore'
import type { MissingNodeType } from '@/types/comfy'
import PackInstallButton from '@/workbench/extensions/manager/components/manager/button/PackInstallButton.vue'
import { useMissingNodes } from '@/workbench/extensions/manager/composables/nodePack/useMissingNodes'
import { useManagerState } from '@/workbench/extensions/manager/composables/useManagerState'
import { useComfyManagerStore } from '@/workbench/extensions/manager/stores/comfyManagerStore'
import { ManagerTab } from '@/workbench/extensions/manager/types/comfyManagerTypes'
const props = defineProps<{
missingNodeTypes: MissingNodeType[]
}>()
const dialogStore = useDialogStore()
const { t } = useI18n()
// Get missing node packs from workflow with loading and error states
const { missingNodePacks, isLoading, error, missingCoreNodes } =
useMissingNodes()
const handleGotItClick = () => {
dialogStore.closeDialog({ key: 'global-missing-nodes' })
}
const { missingNodePacks, isLoading, error } = useMissingNodes()
const comfyManagerStore = useComfyManagerStore()
const managerState = useManagerState()
@@ -91,27 +86,6 @@ const isInstalling = computed(() => {
)
})
const uniqueNodes = computed(() => {
const seenTypes = new Set()
return props.missingNodeTypes
.filter((node) => {
const type = typeof node === 'object' ? node.type : node
if (seenTypes.has(type)) return false
seenTypes.add(type)
return true
})
.map((node) => {
if (typeof node === 'object') {
return {
label: node.type,
hint: node.hint,
action: node.action
}
}
return { label: node }
})
})
// Show manager buttons unless manager is disabled
const showManagerButtons = computed(() => {
return managerState.shouldShowManagerButtons.value
@@ -129,9 +103,6 @@ const openManager = async () => {
})
}
const { t } = useI18n()
const dialogStore = useDialogStore()
// Computed to check if all missing nodes have been installed
const allMissingNodesInstalled = computed(() => {
return (
@@ -140,13 +111,14 @@ const allMissingNodesInstalled = computed(() => {
missingNodePacks.value?.length === 0
)
})
// Watch for completion and close dialog
// Watch for completion and close dialog (OSS mode only)
watch(allMissingNodesInstalled, async (allInstalled) => {
if (allInstalled && showInstallAllButton.value) {
if (!isCloud && allInstalled && showInstallAllButton.value) {
// Use nextTick to ensure state updates are complete
await nextTick()
dialogStore.closeDialog({ key: 'global-load-workflow-warning' })
dialogStore.closeDialog({ key: 'global-missing-nodes' })
// Show success toast
useToastStore().add({
@@ -158,20 +130,3 @@ watch(allMissingNodesInstalled, async (allInstalled) => {
}
})
</script>
<style scoped>
.comfy-missing-nodes {
max-height: 300px;
overflow-y: auto;
}
.node-hint {
margin-left: 0.5rem;
font-style: italic;
color: var(--text-color-secondary);
}
:deep(.p-button) {
margin-left: auto;
}
</style>

View File

@@ -0,0 +1,18 @@
<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>
<p class="m-0 text-sm">
{{
isCloud
? $t('missingNodes.cloud.title')
: $t('missingNodes.oss.title')
}}
</p>
</div>
</div>
</template>
<script setup lang="ts">
import { isCloud } from '@/platform/distribution/types'
</script>

View File

@@ -123,6 +123,7 @@ import { computed, ref, watch } from 'vue'
import UserCredit from '@/components/common/UserCredit.vue'
import UsageLogsTable from '@/components/dialog/content/setting/UsageLogsTable.vue'
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
import { useExternalLink } from '@/composables/useExternalLink'
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
import { useTelemetry } from '@/platform/telemetry'
import { useDialogService } from '@/services/dialogService'
@@ -137,6 +138,7 @@ interface CreditHistoryItemData {
isPositive: boolean
}
const { buildDocsUrl } = useExternalLink()
const dialogService = useDialogService()
const authStore = useFirebaseAuthStore()
const authActions = useFirebaseAuthActions()
@@ -183,12 +185,17 @@ const handleMessageSupport = async () => {
}
const handleFaqClick = () => {
window.open('https://docs.comfy.org/tutorials/api-nodes/faq', '_blank')
window.open(
buildDocsUrl('/tutorials/api-nodes/faq', { includeLocale: true }),
'_blank'
)
}
const handleOpenPartnerNodesInfo = () => {
window.open(
'https://docs.comfy.org/tutorials/api-nodes/overview#api-nodes',
buildDocsUrl('/tutorials/api-nodes/overview#api-nodes', {
includeLocale: true
}),
'_blank'
)
}

View File

@@ -452,10 +452,13 @@ onMounted(async () => {
'Comfy.CustomColorPalettes'
)
// Restore workflow and workflow tabs state from storage
await workflowPersistence.restorePreviousWorkflow()
// Restore saved workflow and workflow tabs state
await workflowPersistence.initializeWorkflow()
workflowPersistence.restoreWorkflowTabsState()
// Load template from URL if present
await workflowPersistence.loadTemplateFromUrlIfPresent()
// Initialize release store to fetch releases from comfy-api (fire-and-forget)
const { useReleaseStore } = await import(
'@/platform/updates/common/releaseStore'

View File

@@ -1,7 +1,7 @@
<template>
<Button
v-tooltip.top="{
value: t('commands.Comfy_Canvas_ToggleSelectedNodes_Bypass.label'),
value: $t('commands.Comfy_Canvas_ToggleSelectedNodes_Bypass.label'),
showDelay: 1000
}"
severity="secondary"
@@ -18,11 +18,9 @@
<script setup lang="ts">
import Button from 'primevue/button'
import { useI18n } from 'vue-i18n'
import { useCommandStore } from '@/stores/commandStore'
const { t } = useI18n()
const commandStore = useCommandStore()
const toggleBypass = async () => {

View File

@@ -1,7 +1,7 @@
<template>
<Button
v-tooltip.top="{
value: $t('Edit Subgraph Widgets'),
value: $t('commands.Comfy_Graph_EditSubgraphWidgets.label'),
showDelay: 1000
}"
severity="secondary"

View File

@@ -2,7 +2,7 @@
<Button
v-if="isUnpackVisible"
v-tooltip.top="{
value: t('commands.Comfy_Graph_UnpackSubgraph.label'),
value: $t('commands.Comfy_Graph_UnpackSubgraph.label'),
showDelay: 1000
}"
severity="secondary"
@@ -17,7 +17,7 @@
<Button
v-else-if="isConvertVisible"
v-tooltip.top="{
value: t('commands.Comfy_Graph_ConvertToSubgraph.label'),
value: $t('commands.Comfy_Graph_ConvertToSubgraph.label'),
showDelay: 1000
}"
severity="secondary"
@@ -34,12 +34,10 @@
<script setup lang="ts">
import Button from 'primevue/button'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { useSelectionState } from '@/composables/graph/useSelectionState'
import { useCommandStore } from '@/stores/commandStore'
const { t } = useI18n()
const commandStore = useCommandStore()
const { isSingleSubgraph, hasAnySelection } = useSelectionState()

View File

@@ -2,7 +2,7 @@
<Button
v-show="isDeletable"
v-tooltip.top="{
value: t('commands.Comfy_Canvas_DeleteSelectedItems.label'),
value: $t('commands.Comfy_Canvas_DeleteSelectedItems.label'),
showDelay: 1000
}"
severity="secondary"
@@ -17,13 +17,11 @@
<script setup lang="ts">
import Button from 'primevue/button'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { useSelectionState } from '@/composables/graph/useSelectionState'
import type { Positionable } from '@/lib/litegraph/src/interfaces'
import { useCommandStore } from '@/stores/commandStore'
const { t } = useI18n()
const commandStore = useCommandStore()
const { selectedItems } = useSelectionState()

View File

@@ -1,7 +1,7 @@
<template>
<Button
v-tooltip.top="{
value: t('commands.Comfy_3DViewer_Open3DViewer.label'),
value: $t('commands.Comfy_3DViewer_Open3DViewer.label'),
showDelay: 1000
}"
severity="secondary"
@@ -15,7 +15,6 @@
<script setup lang="ts">
import Button from 'primevue/button'
import { t } from '@/i18n'
import { useCommandStore } from '@/stores/commandStore'
const commandStore = useCommandStore()

View File

@@ -2,7 +2,7 @@
<Button
v-show="isSingleImageNode"
v-tooltip.top="{
value: t('commands.Comfy_MaskEditor_OpenMaskEditor.label'),
value: $t('commands.Comfy_MaskEditor_OpenMaskEditor.label'),
showDelay: 1000
}"
severity="secondary"
@@ -17,7 +17,6 @@
import Button from 'primevue/button'
import { useSelectionState } from '@/composables/graph/useSelectionState'
import { t } from '@/i18n'
import { useCommandStore } from '@/stores/commandStore'
const commandStore = useCommandStore()

View File

@@ -2,7 +2,7 @@
<Button
v-show="isVisible"
v-tooltip.top="{
value: t('commands.Comfy_PublishSubgraph.label'),
value: $t('commands.Comfy_PublishSubgraph.label'),
showDelay: 1000
}"
severity="secondary"
@@ -18,13 +18,11 @@
<script setup lang="ts">
import Button from 'primevue/button'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { SubgraphNode } from '@/lib/litegraph/src/litegraph'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useCommandStore } from '@/stores/commandStore'
const { t } = useI18n()
const commandStore = useCommandStore()
const canvasStore = useCanvasStore()

View File

@@ -143,6 +143,7 @@ import type { CSSProperties, Component } from 'vue'
import { useI18n } from 'vue-i18n'
import PuzzleIcon from '@/components/icons/PuzzleIcon.vue'
import { useExternalLink } from '@/composables/useExternalLink'
import { isCloud } from '@/platform/distribution/types'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useTelemetry } from '@/platform/telemetry'
@@ -168,15 +169,6 @@ interface MenuItem {
}
// Constants
const EXTERNAL_LINKS = {
DOCS: 'https://docs.comfy.org/',
DISCORD: 'https://www.comfy.org/discord',
GITHUB: 'https://github.com/comfyanonymous/ComfyUI',
DESKTOP_GUIDE_WINDOWS: 'https://docs.comfy.org/installation/desktop/windows',
DESKTOP_GUIDE_MACOS: 'https://docs.comfy.org/installation/desktop/macos',
UPDATE_GUIDE: 'https://docs.comfy.org/installation/update_comfyui'
} as const
const TIME_UNITS = {
MINUTE: 60 * 1000,
HOUR: 60 * 60 * 1000,
@@ -193,7 +185,8 @@ const SUBMENU_CONFIG = {
} as const
// Composables
const { t, locale } = useI18n()
const { t } = useI18n()
const { staticUrls, buildDocsUrl } = useExternalLink()
const releaseStore = useReleaseStore()
const commandStore = useCommandStore()
const settingStore = useSettingStore()
@@ -230,11 +223,12 @@ const moreItems = computed<MenuItem[]>(() => {
visible: isElectron(),
action: () => {
trackResourceClick('docs', true)
const docsUrl =
electronAPI().getPlatform() === 'darwin'
? EXTERNAL_LINKS.DESKTOP_GUIDE_MACOS
: EXTERNAL_LINKS.DESKTOP_GUIDE_WINDOWS
openExternalLink(docsUrl)
openExternalLink(
buildDocsUrl('/installation/desktop', {
includeLocale: true,
platform: true
})
)
emit('close')
}
},
@@ -286,7 +280,7 @@ const menuItems = computed<MenuItem[]>(() => {
label: t('helpCenter.docs'),
action: () => {
trackResourceClick('docs', true)
openExternalLink(EXTERNAL_LINKS.DOCS)
openExternalLink(buildDocsUrl('/', { includeLocale: true }))
emit('close')
}
},
@@ -297,7 +291,7 @@ const menuItems = computed<MenuItem[]>(() => {
label: 'Discord',
action: () => {
trackResourceClick('discord', true)
openExternalLink(EXTERNAL_LINKS.DISCORD)
openExternalLink(staticUrls.discord)
emit('close')
}
},
@@ -308,7 +302,7 @@ const menuItems = computed<MenuItem[]>(() => {
label: t('helpCenter.github'),
action: () => {
trackResourceClick('github', true)
openExternalLink(EXTERNAL_LINKS.GITHUB)
openExternalLink(staticUrls.github)
emit('close')
}
},
@@ -533,25 +527,19 @@ const onReleaseClick = (release: ReleaseNote): void => {
trackResourceClick('release_notes', true)
void releaseStore.handleShowChangelog(release.version)
const versionAnchor = formatVersionAnchor(release.version)
const changelogUrl = `${getChangelogUrl()}#${versionAnchor}`
const changelogUrl = `${buildDocsUrl('/changelog', { includeLocale: true })}#${versionAnchor}`
openExternalLink(changelogUrl)
emit('close')
}
const onUpdate = (_: ReleaseNote): void => {
trackResourceClick('docs', true)
openExternalLink(EXTERNAL_LINKS.UPDATE_GUIDE)
openExternalLink(
buildDocsUrl('/installation/update_comfyui', { includeLocale: true })
)
emit('close')
}
// Generate language-aware changelog URL
const getChangelogUrl = (): string => {
const isChineseLocale = locale.value === 'zh'
return isChineseLocale
? 'https://docs.comfy.org/zh-CN/changelog'
: 'https://docs.comfy.org/changelog'
}
// Lifecycle
onMounted(async () => {
telemetry?.trackHelpCenterOpened({ source: 'sidebar' })

View File

@@ -39,6 +39,8 @@
v-model:show-grid="sceneConfig!.showGrid"
v-model:background-color="sceneConfig!.backgroundColor"
v-model:background-image="sceneConfig!.backgroundImage"
v-model:background-render-mode="sceneConfig!.backgroundRenderMode"
v-model:fov="cameraConfig!.fov"
@update-background-image="handleBackgroundImageUpdate"
/>

View File

@@ -34,7 +34,10 @@
<SceneControls
v-model:background-color="viewer.backgroundColor.value"
v-model:show-grid="viewer.showGrid.value"
v-model:background-render-mode="viewer.backgroundRenderMode.value"
v-model:fov="viewer.fov.value"
:has-background-image="viewer.hasBackgroundImage.value"
:disable-background-upload="viewer.isStandaloneMode.value"
@update-background-image="viewer.handleBackgroundImageUpdate"
/>
</div>
@@ -89,13 +92,15 @@ import LightControls from '@/components/load3d/controls/viewer/ViewerLightContro
import ModelControls from '@/components/load3d/controls/viewer/ViewerModelControls.vue'
import SceneControls from '@/components/load3d/controls/viewer/ViewerSceneControls.vue'
import { useLoad3dDrag } from '@/composables/useLoad3dDrag'
import { useLoad3dViewer } from '@/composables/useLoad3dViewer'
import { t } from '@/i18n'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import { useLoad3dService } from '@/services/load3dService'
import { useDialogStore } from '@/stores/dialogStore'
const props = defineProps<{
node: LGraphNode
node?: LGraphNode
modelUrl?: string
}>()
const viewerContentRef = ref<HTMLDivElement>()
@@ -104,20 +109,30 @@ const mainContentRef = ref<HTMLDivElement>()
const maximized = ref(false)
const mutationObserver = ref<MutationObserver | null>(null)
const viewer = useLoad3dService().getOrCreateViewer(toRaw(props.node))
const isStandaloneMode = !props.node && props.modelUrl
const viewer = props.node
? useLoad3dService().getOrCreateViewer(toRaw(props.node))
: useLoad3dViewer()
const { isDragging, dragMessage, handleDragOver, handleDragLeave, handleDrop } =
useLoad3dDrag({
onModelDrop: async (file) => {
await viewer.handleModelDrop(file)
},
disabled: viewer.isPreview
disabled: viewer.isPreview.value || isStandaloneMode
})
onMounted(async () => {
const source = useLoad3dService().getLoad3d(props.node)
if (source && containerRef.value) {
await viewer.initializeViewer(containerRef.value, source)
if (!containerRef.value) return
if (isStandaloneMode && props.modelUrl) {
await viewer.initializeStandaloneViewer(containerRef.value, props.modelUrl)
} else if (props.node) {
const source = useLoad3dService().getLoad3d(props.node)
if (source) {
await viewer.initializeViewer(containerRef.value, source)
}
}
if (viewerContentRef.value) {
@@ -148,7 +163,9 @@ onMounted(async () => {
})
const handleCancel = () => {
viewer.restoreInitialState()
if (!isStandaloneMode) {
viewer.restoreInitialState()
}
useDialogStore().closeDialog()
}

View File

@@ -6,65 +6,30 @@
value: $t('load3d.switchCamera'),
showDelay: 300
}"
:class="['pi', getCameraIcon, 'text-lg text-white']"
:class="['pi', 'pi-camera', 'text-lg text-white']"
/>
</Button>
<div v-if="showFOVButton" class="show-fov relative">
<Button class="p-button-rounded p-button-text" @click="toggleFOV">
<i
v-tooltip.right="{ value: $t('load3d.fov'), showDelay: 300 }"
class="pi pi-expand text-lg text-white"
/>
</Button>
<div
v-show="showFOV"
class="absolute top-0 left-12 rounded-lg bg-black/50 p-4 shadow-lg"
style="width: 150px"
>
<Slider v-model="fov" class="w-full" :min="10" :max="150" :step="1" />
</div>
</div>
<PopupSlider
v-if="showFOVButton"
v-model="fov"
:tooltip-text="$t('load3d.fov')"
/>
</div>
</template>
<script setup lang="ts">
import Button from 'primevue/button'
import Slider from 'primevue/slider'
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { computed } from 'vue'
import PopupSlider from '@/components/load3d/controls/PopupSlider.vue'
import type { CameraType } from '@/extensions/core/load3d/interfaces'
const showFOV = ref(false)
const cameraType = defineModel<CameraType>('cameraType')
const fov = defineModel<number>('fov')
const showFOVButton = computed(() => cameraType.value === 'perspective')
const getCameraIcon = computed(() => {
return cameraType.value === 'perspective' ? 'pi-camera' : 'pi-camera'
})
const toggleFOV = () => {
showFOV.value = !showFOV.value
}
const switchCamera = () => {
cameraType.value =
cameraType.value === 'perspective' ? 'orthographic' : 'perspective'
}
const closeCameraSlider = (e: MouseEvent) => {
const target = e.target as HTMLElement
if (!target.closest('.show-fov')) {
showFOV.value = false
}
}
onMounted(() => {
document.addEventListener('click', closeCameraSlider)
})
onUnmounted(() => {
document.removeEventListener('click', closeCameraSlider)
})
</script>

View File

@@ -0,0 +1,64 @@
<template>
<div class="relative show-slider">
<Button class="p-button-rounded p-button-text" @click="toggleSlider">
<i
v-tooltip.right="{ value: tooltipText, showDelay: 300 }"
:class="['pi', icon, 'text-lg text-white']"
/>
</Button>
<div
v-show="showSlider"
class="absolute top-0 left-12 rounded-lg bg-black/50 p-4 shadow-lg w-[150px]"
>
<Slider
v-model="value"
class="w-full"
:min="min"
:max="max"
:step="step"
/>
</div>
</div>
</template>
<script setup lang="ts">
import Button from 'primevue/button'
import Slider from 'primevue/slider'
import { onMounted, onUnmounted, ref } from 'vue'
const {
icon = 'pi-expand',
min = 10,
max = 150,
step = 1
} = defineProps<{
icon?: string
tooltipText: string
min?: number
max?: number
step?: number
}>()
const value = defineModel<number>()
const showSlider = ref(false)
const toggleSlider = () => {
showSlider.value = !showSlider.value
}
const closeSlider = (e: MouseEvent) => {
const target = e.target as HTMLElement
if (!target.closest('.show-slider')) {
showSlider.value = false
}
}
onMounted(() => {
document.addEventListener('click', closeSlider)
})
onUnmounted(() => {
document.removeEventListener('click', closeSlider)
})
</script>

View File

@@ -51,6 +51,28 @@
</Button>
</div>
<div v-if="hasBackgroundImage">
<Button
class="p-button-rounded p-button-text"
:class="{ 'p-button-outlined': backgroundRenderMode === 'panorama' }"
@click="toggleBackgroundRenderMode"
>
<i
v-tooltip.right="{
value: $t('load3d.panoramaMode'),
showDelay: 300
}"
class="pi pi-globe text-lg text-white"
/>
</Button>
</div>
<PopupSlider
v-if="hasBackgroundImage && backgroundRenderMode === 'panorama'"
v-model="fov"
:tooltip-text="$t('load3d.fov')"
/>
<div v-if="hasBackgroundImage">
<Button
class="p-button-rounded p-button-text"
@@ -72,6 +94,9 @@
import Button from 'primevue/button'
import { computed, ref } from 'vue'
import PopupSlider from '@/components/load3d/controls/PopupSlider.vue'
import type { BackgroundRenderModeType } from '@/extensions/core/load3d/interfaces'
const emit = defineEmits<{
(e: 'updateBackgroundImage', file: File | null): void
}>()
@@ -79,6 +104,11 @@ const emit = defineEmits<{
const showGrid = defineModel<boolean>('showGrid')
const backgroundColor = defineModel<string>('backgroundColor')
const backgroundImage = defineModel<string>('backgroundImage')
const backgroundRenderMode = defineModel<BackgroundRenderModeType>(
'backgroundRenderMode',
{ default: 'tiled' }
)
const fov = defineModel<number>('fov')
const hasBackgroundImage = computed(
() => backgroundImage.value && backgroundImage.value !== ''
)
@@ -113,4 +143,9 @@ const uploadBackgroundImage = (event: Event) => {
const removeBackgroundImage = () => {
emit('updateBackgroundImage', null)
}
const toggleBackgroundRenderMode = () => {
backgroundRenderMode.value =
backgroundRenderMode.value === 'panorama' ? 'tiled' : 'panorama'
}
</script>

View File

@@ -1,26 +1,28 @@
<template>
<div class="space-y-4">
<label>
{{ t('load3d.viewer.cameraType') }}
</label>
<Select
v-model="cameraType"
:options="cameras"
option-label="title"
option-value="value"
>
</Select>
</div>
<div class="space-y-4">
<label>
{{ t('load3d.viewer.cameraType') }}
</label>
<Select
v-model="cameraType"
:options="cameras"
option-label="title"
option-value="value"
>
</Select>
</div>
<div v-if="showFOVButton" class="space-y-4">
<label>{{ t('load3d.fov') }}</label>
<Slider
v-model="fov"
:min="10"
:max="150"
:step="1"
:aria-label="t('load3d.fov')"
/>
<div v-if="showFOVButton" class="space-y-4">
<label>{{ t('load3d.fov') }}</label>
<Slider
v-model="fov"
:min="10"
:max="150"
:step="1"
:aria-label="t('load3d.fov')"
/>
</div>
</div>
</template>

View File

@@ -1,15 +1,22 @@
<template>
<Select
v-model="exportFormat"
:options="exportFormats"
option-label="label"
option-value="value"
>
</Select>
<div class="space-y-4">
<Select
v-model="exportFormat"
:options="exportFormats"
option-label="label"
option-value="value"
>
</Select>
<Button severity="secondary" text rounded @click="exportModel(exportFormat)">
{{ $t('load3d.export') }}
</Button>
<Button
severity="secondary"
text
rounded
@click="exportModel(exportFormat)"
>
{{ $t('load3d.export') }}
</Button>
</div>
</template>
<script setup lang="ts">

View File

@@ -1,13 +1,15 @@
<template>
<label>{{ $t('load3d.lightIntensity') }}</label>
<div class="space-y-4">
<label>{{ $t('load3d.lightIntensity') }}</label>
<Slider
v-model="lightIntensity"
class="w-full"
:min="lightIntensityMinimum"
:max="lightIntensityMaximum"
:step="lightAdjustmentIncrement"
/>
<Slider
v-model="lightIntensity"
class="w-full"
:min="lightIntensityMinimum"
:max="lightIntensityMaximum"
:step="lightAdjustmentIncrement"
/>
</div>
</template>
<script setup lang="ts">

View File

@@ -14,7 +14,7 @@
</label>
</div>
<div v-if="!hasBackgroundImage">
<div v-if="!hasBackgroundImage && !disableBackgroundUpload">
<Button
severity="secondary"
:label="$t('load3d.uploadBackgroundImage')"
@@ -32,6 +32,24 @@
</div>
<div v-if="hasBackgroundImage" class="space-y-2">
<div class="flex gap-2">
<Button
:severity="backgroundRenderMode === 'tiled' ? 'primary' : 'secondary'"
:label="$t('load3d.tiledMode')"
icon="pi pi-th-large"
class="flex-1"
@click="setBackgroundRenderMode('tiled')"
/>
<Button
:severity="
backgroundRenderMode === 'panorama' ? 'primary' : 'secondary'
"
:label="$t('load3d.panoramaMode')"
icon="pi pi-globe"
class="flex-1"
@click="setBackgroundRenderMode('panorama')"
/>
</div>
<Button
severity="secondary"
:label="$t('load3d.removeBackgroundImage')"
@@ -50,9 +68,13 @@ import { ref } from 'vue'
const backgroundColor = defineModel<string>('backgroundColor')
const showGrid = defineModel<boolean>('showGrid')
const backgroundRenderMode = defineModel<'tiled' | 'panorama'>(
'backgroundRenderMode'
)
defineProps<{
hasBackgroundImage?: boolean
disableBackgroundUpload?: boolean
}>()
const emit = defineEmits<{
@@ -77,4 +99,8 @@ const handleImageUpload = (event: Event) => {
const removeBackgroundImage = () => {
emit('updateBackgroundImage', null)
}
const setBackgroundRenderMode = (mode: 'tiled' | 'panorama') => {
backgroundRenderMode.value = mode
}
</script>

View File

@@ -0,0 +1,97 @@
<template>
<div
id="maskEditor_brush"
:style="{
position: 'absolute',
opacity: brushOpacity,
width: `${brushSize}px`,
height: `${brushSize}px`,
left: `${brushLeft}px`,
top: `${brushTop}px`,
borderRadius: borderRadius,
pointerEvents: 'none',
zIndex: 1000
}"
>
<div
id="maskEditor_brushPreviewGradient"
:style="{
display: gradientVisible ? 'block' : 'none',
background: gradientBackground
}"
></div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { BrushShape } from '@/extensions/core/maskeditor/types'
import { useMaskEditorStore } from '@/stores/maskEditorStore'
const { containerRef } = defineProps<{
containerRef?: HTMLElement
}>()
const store = useMaskEditorStore()
const brushOpacity = computed(() => {
return store.brushVisible ? '1' : '0'
})
const brushRadius = computed(() => {
return store.brushSettings.size * store.zoomRatio
})
const brushSize = computed(() => {
return brushRadius.value * 2
})
const brushLeft = computed(() => {
const dialogRect = containerRef?.getBoundingClientRect()
const dialogOffsetLeft = dialogRect?.left || 0
return (
store.cursorPoint.x +
store.panOffset.x -
brushRadius.value -
dialogOffsetLeft
)
})
const brushTop = computed(() => {
const dialogRect = containerRef?.getBoundingClientRect()
const dialogOffsetTop = dialogRect?.top || 0
return (
store.cursorPoint.y +
store.panOffset.y -
brushRadius.value -
dialogOffsetTop
)
})
const borderRadius = computed(() => {
return store.brushSettings.type === BrushShape.Rect ? '0%' : '50%'
})
const gradientVisible = computed(() => {
return store.brushPreviewGradientVisible
})
const gradientBackground = computed(() => {
const hardness = store.brushSettings.hardness
if (hardness === 1) {
return 'rgba(255, 0, 0, 0.5)'
}
const midStop = hardness * 100
const outerStop = 100
return `radial-gradient(
circle,
rgba(255, 0, 0, 0.5) 0%,
rgba(255, 0, 0, 0.25) ${midStop}%,
rgba(255, 0, 0, 0) ${outerStop}%
)`
})
</script>

View File

@@ -0,0 +1,129 @@
<template>
<div class="flex flex-col gap-3 pb-3">
<h3
class="text-center text-[15px] font-sans text-[var(--descrip-text)] mt-2.5"
>
{{ t('maskEditor.brushSettings') }}
</h3>
<button
class="w-45 h-7.5 border-none bg-black/20 border border-[var(--border-color)] text-[var(--input-text)] font-sans text-[15px] pointer-events-auto transition-colors duration-100 hover:bg-[var(--p-overlaybadge-outline-color)] hover:border-none"
@click="resetToDefault"
>
{{ t('maskEditor.resetToDefault') }}
</button>
<div class="flex flex-col gap-3 pb-3">
<span class="text-left text-xs font-sans text-[var(--descrip-text)]">{{
t('maskEditor.brushShape')
}}</span>
<div
class="flex flex-row gap-2.5 items-center min-h-6 relative h-[50px] w-full rounded-[10px] bg-[var(--p-surface-300)] dark-theme:bg-[var(--p-surface-800)]"
>
<div
class="maskEditor_sidePanelBrushShapeCircle bg-transparent hover:bg-[var(--comfy-menu-bg)] dark-theme:hover:bg-[var(--p-surface-900)]"
:class="{ active: store.brushSettings.type === BrushShape.Arc }"
:style="{
background:
store.brushSettings.type === BrushShape.Arc
? 'var(--p-button-text-primary-color)'
: ''
}"
@click="setBrushShape(BrushShape.Arc)"
></div>
<div
class="maskEditor_sidePanelBrushShapeSquare bg-transparent hover:bg-[var(--comfy-menu-bg)] dark-theme:hover:bg-[var(--p-surface-900)]"
:class="{ active: store.brushSettings.type === BrushShape.Rect }"
:style="{
background:
store.brushSettings.type === BrushShape.Rect
? 'var(--p-button-text-primary-color)'
: ''
}"
@click="setBrushShape(BrushShape.Rect)"
></div>
</div>
</div>
<div class="flex flex-col gap-3 pb-3">
<span class="text-left text-xs font-sans text-[var(--descrip-text)]">{{
t('maskEditor.colorSelector')
}}</span>
<input type="color" :value="store.rgbColor" @input="onColorChange" />
</div>
<SliderControl
:label="t('maskEditor.thickness')"
:min="1"
:max="100"
:step="1"
:model-value="store.brushSettings.size"
@update:model-value="onThicknessChange"
/>
<SliderControl
:label="t('maskEditor.opacity')"
:min="0"
:max="1"
:step="0.01"
:model-value="store.brushSettings.opacity"
@update:model-value="onOpacityChange"
/>
<SliderControl
:label="t('maskEditor.hardness')"
:min="0"
:max="1"
:step="0.01"
:model-value="store.brushSettings.hardness"
@update:model-value="onHardnessChange"
/>
<SliderControl
:label="t('maskEditor.smoothingPrecision')"
:min="1"
:max="100"
:step="1"
:model-value="store.brushSettings.smoothingPrecision"
@update:model-value="onSmoothingPrecisionChange"
/>
</div>
</template>
<script setup lang="ts">
import { BrushShape } from '@/extensions/core/maskeditor/types'
import { t } from '@/i18n'
import { useMaskEditorStore } from '@/stores/maskEditorStore'
import SliderControl from './controls/SliderControl.vue'
const store = useMaskEditorStore()
const setBrushShape = (shape: BrushShape) => {
store.brushSettings.type = shape
}
const onColorChange = (event: Event) => {
store.rgbColor = (event.target as HTMLInputElement).value
}
const onThicknessChange = (value: number) => {
store.setBrushSize(value)
}
const onOpacityChange = (value: number) => {
store.setBrushOpacity(value)
}
const onHardnessChange = (value: number) => {
store.setBrushHardness(value)
}
const onSmoothingPrecisionChange = (value: number) => {
store.setBrushSmoothingPrecision(value)
}
const resetToDefault = () => {
store.resetBrushToDefault()
}
</script>

View File

@@ -0,0 +1,103 @@
<template>
<div class="flex flex-col gap-3 pb-3">
<h3
class="text-center text-[15px] font-sans text-[var(--descrip-text)] mt-2.5"
>
{{ t('maskEditor.colorSelectSettings') }}
</h3>
<SliderControl
:label="t('maskEditor.tolerance')"
:min="0"
:max="255"
:step="1"
:model-value="store.colorSelectTolerance"
@update:model-value="onToleranceChange"
/>
<SliderControl
:label="t('maskEditor.selectionOpacity')"
:min="0"
:max="100"
:step="1"
:model-value="store.selectionOpacity"
@update:model-value="onSelectionOpacityChange"
/>
<ToggleControl
:label="t('maskEditor.livePreview')"
:model-value="store.colorSelectLivePreview"
@update:model-value="onLivePreviewChange"
/>
<ToggleControl
:label="t('maskEditor.applyToWholeImage')"
:model-value="store.applyWholeImage"
@update:model-value="onWholeImageChange"
/>
<DropdownControl
:label="t('maskEditor.method')"
:options="methodOptions"
:model-value="store.colorComparisonMethod"
@update:model-value="onMethodChange"
/>
<ToggleControl
:label="t('maskEditor.stopAtMask')"
:model-value="store.maskBoundary"
@update:model-value="onMaskBoundaryChange"
/>
<SliderControl
:label="t('maskEditor.maskTolerance')"
:min="0"
:max="255"
:step="1"
:model-value="store.maskTolerance"
@update:model-value="onMaskToleranceChange"
/>
</div>
</template>
<script setup lang="ts">
import { ColorComparisonMethod } from '@/extensions/core/maskeditor/types'
import { t } from '@/i18n'
import { useMaskEditorStore } from '@/stores/maskEditorStore'
import DropdownControl from './controls/DropdownControl.vue'
import SliderControl from './controls/SliderControl.vue'
import ToggleControl from './controls/ToggleControl.vue'
const store = useMaskEditorStore()
const methodOptions = Object.values(ColorComparisonMethod)
const onToleranceChange = (value: number) => {
store.setColorSelectTolerance(value)
}
const onSelectionOpacityChange = (value: number) => {
store.setSelectionOpacity(value)
}
const onLivePreviewChange = (value: boolean) => {
store.colorSelectLivePreview = value
}
const onWholeImageChange = (value: boolean) => {
store.applyWholeImage = value
}
const onMethodChange = (value: string | number) => {
store.colorComparisonMethod = value as ColorComparisonMethod
}
const onMaskBoundaryChange = (value: boolean) => {
store.maskBoundary = value
}
const onMaskToleranceChange = (value: number) => {
store.setMaskTolerance(value)
}
</script>

View File

@@ -0,0 +1,227 @@
<template>
<div class="flex flex-col gap-3 pb-3">
<h3
class="text-center text-[15px] font-sans text-[var(--descrip-text)] mt-2.5"
>
{{ t('maskEditor.layers') }}
</h3>
<SliderControl
:label="t('maskEditor.maskOpacity')"
:min="0"
:max="1"
:step="0.01"
:model-value="store.maskOpacity"
@update:model-value="onMaskOpacityChange"
/>
<span class="text-left text-xs font-sans text-[var(--descrip-text)]">{{
t('maskEditor.maskBlendingOptions')
}}</span>
<div
class="flex flex-row gap-2.5 items-center min-h-6 relative h-[50px] w-full rounded-[10px] -mt-2 -mb-1.5"
>
<select
class="maskEditor_sidePanelDropdown"
:value="store.maskBlendMode"
@change="onBlendModeChange"
>
<option value="black">{{ t('maskEditor.black') }}</option>
<option value="white">{{ t('maskEditor.white') }}</option>
<option value="negative">{{ t('maskEditor.negative') }}</option>
</select>
</div>
<span class="text-left text-xs font-sans text-[var(--descrip-text)]">{{
t('maskEditor.maskLayer')
}}</span>
<div
class="flex flex-row gap-2.5 items-center min-h-6 relative h-[50px] w-full rounded-[10px] bg-[var(--p-surface-300)] dark-theme:bg-[var(--p-surface-800)]"
:style="{
border: store.activeLayer === 'mask' ? '2px solid #007acc' : 'none'
}"
>
<input
type="checkbox"
class="maskEditor_sidePanelLayerCheckbox"
:checked="maskLayerVisible"
@change="onMaskLayerVisibilityChange"
/>
<div class="maskEditor_sidePanelLayerPreviewContainer">
<svg viewBox="0 0 20 20" style="">
<path
class="cls-1"
d="M1.31,5.32v9.36c0,.55.45,1,1,1h15.38c.55,0,1-.45,1-1V5.32c0-.55-.45-1-1-1H2.31c-.55,0-1,.45-1,1ZM11.19,13.44c-2.91.94-5.57-1.72-4.63-4.63.34-1.05,1.19-1.9,2.24-2.24,2.91-.94,5.57,1.72,4.63,4.63-.34,1.05-1.19-1.9-2.24,2.24Z"
/>
</svg>
</div>
<button
style="font-size: 12px"
:style="{ opacity: store.activeLayer === 'mask' ? '0.5' : '1' }"
:disabled="store.activeLayer === 'mask'"
@click="setActiveLayer('mask')"
>
{{ t('maskEditor.activateLayer') }}
</button>
</div>
<span class="text-left text-xs font-sans text-[var(--descrip-text)]">{{
t('maskEditor.paintLayer')
}}</span>
<div
class="flex flex-row gap-2.5 items-center min-h-6 relative h-[50px] w-full rounded-[10px] bg-[var(--p-surface-300)] dark-theme:bg-[var(--p-surface-800)]"
:style="{
border: store.activeLayer === 'rgb' ? '2px solid #007acc' : 'none'
}"
>
<input
type="checkbox"
class="maskEditor_sidePanelLayerCheckbox"
:checked="paintLayerVisible"
@change="onPaintLayerVisibilityChange"
/>
<div class="maskEditor_sidePanelLayerPreviewContainer">
<svg viewBox="0 0 20 20">
<path
class="cls-1"
d="M 17 6.965 c 0 0.235 -0.095 0.47 -0.275 0.655 l -6.51 6.52 c -0.045 0.035 -0.09 0.075 -0.135 0.11 c -0.035 -0.695 -0.605 -1.24 -1.305 -1.245 c 0.035 -0.06 0.08 -0.12 0.135 -0.17 l 6.52 -6.52 c 0.36 -0.36 0.945 -0.36 1.3 0 c 0.175 0.175 0.275 0.415 0.275 0.65 Z"
/>
<path
class="cls-1"
d="M 9.82 14.515 c 0 2.23 -3.23 1.59 -4.82 0 c 1.65 -0.235 2.375 -1.29 3.53 -1.29 c 0.715 0 1.29 0.58 1.29 1.29 Z"
/>
</svg>
</div>
<button
style="font-size: 12px"
:style="{
opacity: store.activeLayer === 'rgb' ? '0.5' : '1',
display: showLayerButtons ? 'block' : 'none'
}"
:disabled="store.activeLayer === 'rgb'"
@click="setActiveLayer('rgb')"
>
{{ t('maskEditor.activateLayer') }}
</button>
</div>
<span class="text-left text-xs font-sans text-[var(--descrip-text)]">{{
t('maskEditor.baseImageLayer')
}}</span>
<div
class="flex flex-row gap-2.5 items-center min-h-6 relative h-[50px] w-full rounded-[10px] bg-[var(--p-surface-300)] dark-theme:bg-[var(--p-surface-800)]"
>
<input
type="checkbox"
class="maskEditor_sidePanelLayerCheckbox"
:checked="baseImageLayerVisible"
@change="onBaseImageLayerVisibilityChange"
/>
<div class="maskEditor_sidePanelLayerPreviewContainer">
<img
class="maskEditor_sidePanelImageLayerImage"
:src="baseImageSrc"
:alt="t('maskEditor.baseLayerPreview')"
/>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useCanvasManager } from '@/composables/maskeditor/useCanvasManager'
import type { useToolManager } from '@/composables/maskeditor/useToolManager'
import type { ImageLayer } from '@/extensions/core/maskeditor/types'
import { MaskBlendMode, Tools } from '@/extensions/core/maskeditor/types'
import { t } from '@/i18n'
import { useMaskEditorStore } from '@/stores/maskEditorStore'
import SliderControl from './controls/SliderControl.vue'
const { toolManager } = defineProps<{
toolManager?: ReturnType<typeof useToolManager>
}>()
const store = useMaskEditorStore()
const canvasManager = useCanvasManager()
const maskLayerVisible = ref(true)
const paintLayerVisible = ref(true)
const baseImageLayerVisible = ref(true)
const baseImageSrc = computed(() => {
return store.image?.src ?? ''
})
const showLayerButtons = computed(() => {
return store.currentTool === Tools.Eraser
})
const onMaskLayerVisibilityChange = (event: Event) => {
const checked = (event.target as HTMLInputElement).checked
maskLayerVisible.value = checked
const maskCanvas = store.maskCanvas
if (maskCanvas) {
maskCanvas.style.opacity = checked ? String(store.maskOpacity) : '0'
}
}
const onPaintLayerVisibilityChange = (event: Event) => {
const checked = (event.target as HTMLInputElement).checked
paintLayerVisible.value = checked
const rgbCanvas = store.rgbCanvas
if (rgbCanvas) {
rgbCanvas.style.opacity = checked ? '1' : '0'
}
}
const onBaseImageLayerVisibilityChange = (event: Event) => {
const checked = (event.target as HTMLInputElement).checked
baseImageLayerVisible.value = checked
const imgCanvas = store.imgCanvas
if (imgCanvas) {
imgCanvas.style.opacity = checked ? '1' : '0'
}
}
const onMaskOpacityChange = (value: number) => {
store.setMaskOpacity(value)
const maskCanvas = store.maskCanvas
if (maskCanvas) {
maskCanvas.style.opacity = String(value)
}
maskLayerVisible.value = value !== 0
}
const onBlendModeChange = async (event: Event) => {
const value = (event.target as HTMLSelectElement).value
let blendMode: MaskBlendMode
switch (value) {
case 'white':
blendMode = MaskBlendMode.White
break
case 'negative':
blendMode = MaskBlendMode.Negative
break
default:
blendMode = MaskBlendMode.Black
}
store.maskBlendMode = blendMode
await canvasManager.updateMaskColor()
}
const setActiveLayer = (layer: ImageLayer) => {
toolManager?.setActiveLayer(layer)
}
</script>

View File

@@ -0,0 +1,209 @@
<template>
<div
ref="containerRef"
class="maskEditor-dialog-root flex h-full w-full flex-col"
@contextmenu.prevent
@dragstart="handleDragStart"
>
<div
id="maskEditorCanvasContainer"
ref="canvasContainerRef"
@contextmenu.prevent
>
<canvas
ref="imgCanvasRef"
class="absolute top-0 left-0 w-full h-full"
@contextmenu.prevent
/>
<canvas
ref="rgbCanvasRef"
class="absolute top-0 left-0 w-full h-full"
@contextmenu.prevent
/>
<canvas
ref="maskCanvasRef"
class="absolute top-0 left-0 w-full h-full"
@contextmenu.prevent
/>
<div ref="canvasBackgroundRef" class="bg-white w-full h-full" />
</div>
<div class="maskEditor-ui-container flex min-h-0 flex-1 flex-col">
<div class="flex min-h-0 flex-1 overflow-hidden">
<ToolPanel
v-if="initialized"
ref="toolPanelRef"
:tool-manager="toolManager!"
/>
<PointerZone
v-if="initialized"
:tool-manager="toolManager!"
:pan-zoom="panZoom!"
/>
<SidePanel
v-if="initialized"
ref="sidePanelRef"
:tool-manager="toolManager!"
/>
</div>
</div>
<BrushCursor v-if="initialized" :container-ref="containerRef" />
</div>
</template>
<script setup lang="ts">
import { onBeforeUnmount, onMounted, ref } from 'vue'
import { useImageLoader } from '@/composables/maskeditor/useImageLoader'
import { useKeyboard } from '@/composables/maskeditor/useKeyboard'
import { useMaskEditorLoader } from '@/composables/maskeditor/useMaskEditorLoader'
import { usePanAndZoom } from '@/composables/maskeditor/usePanAndZoom'
import { useToolManager } from '@/composables/maskeditor/useToolManager'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { useDialogStore } from '@/stores/dialogStore'
import { useMaskEditorDataStore } from '@/stores/maskEditorDataStore'
import { useMaskEditorStore } from '@/stores/maskEditorStore'
import BrushCursor from './BrushCursor.vue'
import PointerZone from './PointerZone.vue'
import SidePanel from './SidePanel.vue'
import ToolPanel from './ToolPanel.vue'
const { node } = defineProps<{
node: LGraphNode
}>()
const store = useMaskEditorStore()
const dataStore = useMaskEditorDataStore()
const dialogStore = useDialogStore()
const loader = useMaskEditorLoader()
const containerRef = ref<HTMLElement>()
const canvasContainerRef = ref<HTMLDivElement>()
const imgCanvasRef = ref<HTMLCanvasElement>()
const maskCanvasRef = ref<HTMLCanvasElement>()
const rgbCanvasRef = ref<HTMLCanvasElement>()
const canvasBackgroundRef = ref<HTMLDivElement>()
const toolPanelRef = ref<InstanceType<typeof ToolPanel>>()
const sidePanelRef = ref<InstanceType<typeof SidePanel>>()
const initialized = ref(false)
const keyboard = useKeyboard()
const panZoom = usePanAndZoom()
let toolManager: ReturnType<typeof useToolManager> | null = null
let resizeObserver: ResizeObserver | null = null
const handleDragStart = (event: DragEvent) => {
if (event.ctrlKey) {
event.preventDefault()
}
}
const initUI = async () => {
if (!containerRef.value) {
console.error(
'[MaskEditorContent] Cannot initialize - missing required refs'
)
return
}
if (
!imgCanvasRef.value ||
!maskCanvasRef.value ||
!rgbCanvasRef.value ||
!canvasContainerRef.value ||
!canvasBackgroundRef.value
) {
console.error('[MaskEditorContent] Cannot initialize - missing canvas refs')
return
}
store.maskCanvas = maskCanvasRef.value
store.rgbCanvas = rgbCanvasRef.value
store.imgCanvas = imgCanvasRef.value
store.canvasContainer = canvasContainerRef.value
store.canvasBackground = canvasBackgroundRef.value
try {
await loader.loadFromNode(node)
toolManager = useToolManager(keyboard, panZoom)
const imageLoader = useImageLoader()
const image = await imageLoader.loadImages()
await panZoom.initializeCanvasPanZoom(
image,
containerRef.value,
toolPanelRef.value?.$el as HTMLElement | undefined,
sidePanelRef.value?.$el as HTMLElement | undefined
)
store.canvasHistory.saveInitialState()
initialized.value = true
} catch (error) {
console.error('[MaskEditorContent] Initialization failed:', error)
dialogStore.closeDialog()
}
}
onMounted(() => {
keyboard.addListeners()
if (containerRef.value) {
resizeObserver = new ResizeObserver(async () => {
if (panZoom) {
await panZoom.invalidatePanZoom()
}
})
resizeObserver.observe(containerRef.value)
}
void initUI()
})
onBeforeUnmount(() => {
toolManager?.brushDrawing.saveBrushSettings()
keyboard?.removeListeners()
if (resizeObserver) {
resizeObserver.disconnect()
resizeObserver = null
}
store.canvasHistory.clearStates()
store.resetState()
dataStore.reset()
})
</script>
<style scoped>
.maskEditor-dialog-root {
position: relative;
overflow: hidden;
}
.maskEditor-ui-container {
position: relative;
z-index: 1;
}
:deep(#maskEditorCanvasContainer) {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 0;
}
</style>

View File

@@ -0,0 +1,44 @@
<template>
<div class="flex flex-col gap-3 pb-3">
<h3
class="text-center text-[15px] font-sans text-[var(--descrip-text)] mt-2.5"
>
{{ t('maskEditor.paintBucketSettings') }}
</h3>
<SliderControl
:label="t('maskEditor.tolerance')"
:min="0"
:max="255"
:step="1"
:model-value="store.paintBucketTolerance"
@update:model-value="onToleranceChange"
/>
<SliderControl
:label="t('maskEditor.fillOpacity')"
:min="0"
:max="100"
:step="1"
:model-value="store.fillOpacity"
@update:model-value="onFillOpacityChange"
/>
</div>
</template>
<script setup lang="ts">
import { t } from '@/i18n'
import { useMaskEditorStore } from '@/stores/maskEditorStore'
import SliderControl from './controls/SliderControl.vue'
const store = useMaskEditorStore()
const onToleranceChange = (value: number) => {
store.setPaintBucketTolerance(value)
}
const onFillOpacityChange = (value: number) => {
store.setFillOpacity(value)
}
</script>

View File

@@ -0,0 +1,95 @@
<template>
<div
ref="pointerZoneRef"
class="w-[calc(100%-4rem-220px)] h-full"
@pointerdown="handlePointerDown"
@pointermove="handlePointerMove"
@pointerup="handlePointerUp"
@pointerleave="handlePointerLeave"
@pointerenter="handlePointerEnter"
@touchstart="handleTouchStart"
@touchmove="handleTouchMove"
@touchend="handleTouchEnd"
@wheel="handleWheel"
@contextmenu.prevent
/>
</template>
<script setup lang="ts">
import { onMounted, ref, watch } from 'vue'
import type { usePanAndZoom } from '@/composables/maskeditor/usePanAndZoom'
import type { useToolManager } from '@/composables/maskeditor/useToolManager'
import { useMaskEditorStore } from '@/stores/maskEditorStore'
const { toolManager, panZoom } = defineProps<{
toolManager: ReturnType<typeof useToolManager>
panZoom: ReturnType<typeof usePanAndZoom>
}>()
const store = useMaskEditorStore()
const pointerZoneRef = ref<HTMLDivElement>()
onMounted(() => {
if (!pointerZoneRef.value) {
console.error('[PointerZone] Pointer zone ref not initialized')
return
}
store.pointerZone = pointerZoneRef.value
})
watch(
() => store.isPanning,
(isPanning) => {
if (!pointerZoneRef.value) return
if (isPanning) {
pointerZoneRef.value.style.cursor = 'grabbing'
} else {
toolManager.updateCursor()
}
}
)
const handlePointerDown = async (event: PointerEvent) => {
await toolManager.handlePointerDown(event)
}
const handlePointerMove = async (event: PointerEvent) => {
await toolManager.handlePointerMove(event)
}
const handlePointerUp = (event: PointerEvent) => {
void toolManager.handlePointerUp(event)
}
const handlePointerLeave = () => {
store.brushVisible = false
if (pointerZoneRef.value) {
pointerZoneRef.value.style.cursor = ''
}
}
const handlePointerEnter = () => {
toolManager.updateCursor()
}
const handleTouchStart = (event: TouchEvent) => {
panZoom.handleTouchStart(event)
}
const handleTouchMove = async (event: TouchEvent) => {
await panZoom.handleTouchMove(event)
}
const handleTouchEnd = (event: TouchEvent) => {
panZoom.handleTouchEnd(event)
}
const handleWheel = async (event: WheelEvent) => {
await panZoom.zoom(event)
const newCursorPoint = { x: event.clientX, y: event.clientY }
panZoom.updateCursorPosition(newCursorPoint)
}
</script>

View File

@@ -0,0 +1,31 @@
<template>
<div class="maskEditor_sidePanel">
<div class="maskEditor_sidePanelContainer">
<component :is="currentPanelComponent" />
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import type { Component } from 'vue'
import { Tools } from '@/extensions/core/maskeditor/types'
import { useMaskEditorStore } from '@/stores/maskEditorStore'
import BrushSettingsPanel from './BrushSettingsPanel.vue'
import ColorSelectSettingsPanel from './ColorSelectSettingsPanel.vue'
import PaintBucketSettingsPanel from './PaintBucketSettingsPanel.vue'
const currentPanelComponent = computed<Component>(() => {
const tool = useMaskEditorStore().currentTool
if (tool === Tools.MaskBucket) {
return PaintBucketSettingsPanel
} else if (tool === Tools.MaskColorFill) {
return ColorSelectSettingsPanel
} else {
return BrushSettingsPanel
}
})
</script>

View File

@@ -0,0 +1,24 @@
<template>
<div
class="flex flex-col gap-3 pb-3 h-full !items-stretch bg-[var(--comfy-menu-bg)] overflow-y-auto w-55 px-2.5"
>
<div class="w-full min-h-full">
<SettingsPanelContainer />
<div class="w-full h-0.5 bg-[var(--border-color)] mt-6 mb-1.5" />
<ImageLayerSettingsPanel :tool-manager="toolManager" />
</div>
</div>
</template>
<script setup lang="ts">
import type { useToolManager } from '@/composables/maskeditor/useToolManager'
import ImageLayerSettingsPanel from './ImageLayerSettingsPanel.vue'
import SettingsPanelContainer from './SettingsPanelContainer.vue'
const { toolManager } = defineProps<{
toolManager?: ReturnType<typeof useToolManager>
}>()
</script>

View File

@@ -0,0 +1,69 @@
<template>
<div
class="h-full z-[8888] flex flex-col justify-between bg-[var(--comfy-menu-bg)]"
>
<div class="flex flex-col">
<div
v-for="tool in allTools"
:key="tool"
:class="[
'maskEditor_toolPanelContainer hover:bg-[var(--p-surface-300)] dark-theme:hover:bg-[var(--p-surface-800)]',
{ maskEditor_toolPanelContainerSelected: currentTool === tool }
]"
@click="onToolSelect(tool)"
>
<div
class="flex items-center justify-center"
v-html="iconsHtml[tool]"
></div>
<div class="maskEditor_toolPanelIndicator"></div>
</div>
</div>
<div
class="flex flex-col items-center cursor-pointer rounded-md mb-2 transition-colors duration-200 hover:bg-[var(--p-surface-300)] dark-theme:hover:bg-[var(--p-surface-800)]"
:title="t('maskEditor.clickToResetZoom')"
@click="onResetZoom"
>
<span class="text-sm text-[var(--p-button-text-secondary-color)]">{{
zoomText
}}</span>
<span class="text-xs text-[var(--p-button-text-secondary-color)]">{{
dimensionsText
}}</span>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import type { useToolManager } from '@/composables/maskeditor/useToolManager'
import { iconsHtml } from '@/extensions/core/maskeditor/constants'
import type { Tools } from '@/extensions/core/maskeditor/types'
import { allTools } from '@/extensions/core/maskeditor/types'
import { t } from '@/i18n'
import { useMaskEditorStore } from '@/stores/maskEditorStore'
const { toolManager } = defineProps<{
toolManager: ReturnType<typeof useToolManager>
}>()
const store = useMaskEditorStore()
const onToolSelect = (tool: Tools) => {
toolManager.switchTool(tool)
}
const currentTool = computed(() => store.currentTool)
const zoomText = computed(() => `${Math.round(store.displayZoomRatio * 100)}%`)
const dimensionsText = computed(() => {
const img = store.image
return img ? `${img.width}x${img.height}` : ' '
})
const onResetZoom = () => {
store.resetZoom()
}
</script>

View File

@@ -0,0 +1,55 @@
<template>
<div class="flex flex-row gap-2.5 items-center min-h-6 relative">
<span class="text-left text-xs font-sans text-[var(--descrip-text)]">{{
label
}}</span>
<select
class="absolute right-0 h-6 px-1.5 rounded-md border border-[var(--p-form-field-border-color)] transition-colors duration-100 bg-[var(--comfy-menu-bg)] focus:outline focus:outline-1 focus:outline-[var(--p-surface-300)] dark-theme:bg-[var(--p-surface-900)] dark-theme:focus:outline-[var(--p-button-text-primary-color)]"
:value="modelValue"
@change="onChange"
>
<option
v-for="option in normalizedOptions"
:key="option.value"
:value="option.value"
>
{{ option.label }}
</option>
</select>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
interface DropdownOption {
label: string
value: string | number
}
interface Props {
label: string
options: string[] | DropdownOption[]
modelValue: string | number
}
const props = defineProps<Props>()
const emit = defineEmits<{
'update:modelValue': [value: string | number]
}>()
const normalizedOptions = computed((): DropdownOption[] => {
return props.options.map((option) => {
if (typeof option === 'string') {
return { label: option, value: option }
}
return option
})
})
const onChange = (event: Event) => {
const value = (event.target as HTMLSelectElement).value
emit('update:modelValue', value)
}
</script>

View File

@@ -0,0 +1,39 @@
<template>
<div class="flex flex-col gap-3 pb-3">
<span class="text-left text-xs font-sans text-[var(--descrip-text)]">{{
label
}}</span>
<input
type="range"
class="maskEditor_sidePanelBrushRange"
:min="min"
:max="max"
:step="step"
:value="modelValue"
@input="onInput"
/>
</div>
</template>
<script setup lang="ts">
interface Props {
label: string
min: number
max: number
step?: number
modelValue: number
}
withDefaults(defineProps<Props>(), {
step: 1
})
const emit = defineEmits<{
'update:modelValue': [value: number]
}>()
const onInput = (event: Event) => {
const value = Number((event.target as HTMLInputElement).value)
emit('update:modelValue', value)
}
</script>

View File

@@ -0,0 +1,34 @@
<template>
<div class="flex flex-row gap-2.5 items-center min-h-6 relative">
<span class="text-left text-xs font-sans text-[var(--descrip-text)]">{{
label
}}</span>
<label class="maskEditor_sidePanelToggleContainer">
<input
type="checkbox"
class="maskEditor_sidePanelToggleCheckbox"
:checked="modelValue"
@change="onChange"
/>
<div class="maskEditor_sidePanelToggleSwitch"></div>
</label>
</div>
</template>
<script setup lang="ts">
interface Props {
label: string
modelValue: boolean
}
defineProps<Props>()
const emit = defineEmits<{
'update:modelValue': [value: boolean]
}>()
const onChange = (event: Event) => {
const checked = (event.target as HTMLInputElement).checked
emit('update:modelValue', checked)
}
</script>

View File

@@ -0,0 +1,126 @@
<template>
<div class="flex w-full items-center justify-between gap-3">
<div class="flex items-center gap-3">
<h3 class="m-0 text-lg font-semibold">{{ t('maskEditor.title') }}</h3>
<div class="flex items-center gap-4">
<button
:class="iconButtonClass"
:title="t('maskEditor.undo')"
@click="onUndo"
>
<svg
viewBox="0 0 15 15"
class="h-6.25 w-6.25 pointer-events-none fill-[var(--input-text)]"
>
<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"
/>
</svg>
</button>
<button
:class="iconButtonClass"
:title="t('maskEditor.redo')"
@click="onRedo"
>
<svg
viewBox="0 0 15 15"
class="h-6.25 w-6.25 pointer-events-none fill-[var(--input-text)]"
>
<path
class="cls-1"
d="M6.23,12.18c-1.97,0-3.58-1.61-3.58-3.58,0-2.11,1.71-3.58,4.17-3.58h3.98l-1.43-1.43c-.18-.18-.18-.47,0-.64.18-.18.46-.18.64,0l2.21,2.21c.09.09.13.2.13.32s-.05.24-.13.32l-2.21,2.21c-.18.18-.47.18-.64,0-.18-.18-.18-.47,0-.64l1.43-1.43h-3.98c-1.92,0-3.26,1.1-3.26,2.67,0,1.47,1.2,2.67,2.67,2.67.25,0,.46.2.46.46s-.2.46-.46.46Z"
/>
</svg>
</button>
<button :class="textButtonClass" @click="onInvert">
{{ t('maskEditor.invert') }}
</button>
<button :class="textButtonClass" @click="onClear">
{{ t('maskEditor.clear') }}
</button>
</div>
</div>
<div class="flex gap-3">
<Button
:label="saveButtonText"
icon="pi pi-check"
size="small"
:disabled="!saveEnabled"
@click="handleSave"
/>
<Button
:label="t('g.cancel')"
icon="pi pi-times"
size="small"
severity="secondary"
@click="handleCancel"
/>
</div>
</div>
</template>
<script setup lang="ts">
import Button from 'primevue/button'
import { ref } from 'vue'
import { useCanvasTools } from '@/composables/maskeditor/useCanvasTools'
import { useMaskEditorSaver } from '@/composables/maskeditor/useMaskEditorSaver'
import { t } from '@/i18n'
import { useDialogStore } from '@/stores/dialogStore'
import { useMaskEditorStore } from '@/stores/maskEditorStore'
const store = useMaskEditorStore()
const dialogStore = useDialogStore()
const canvasTools = useCanvasTools()
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-[var(--p-surface-300)] dark-theme:bg-[var(--p-surface-800)] dark-theme:hover:bg-[var(--p-surface-900)]'
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-[var(--p-surface-300)] dark-theme:bg-[var(--p-surface-800)] dark-theme:hover:bg-[var(--p-surface-900)]'
const onUndo = () => {
store.canvasHistory.undo()
}
const onRedo = () => {
store.canvasHistory.redo()
}
const onInvert = () => {
canvasTools.invertMask()
}
const onClear = () => {
canvasTools.clearMask()
}
const handleSave = async () => {
saveButtonText.value = t('g.saving')
saveEnabled.value = false
try {
store.brushVisible = false
await saver.save()
dialogStore.closeDialog()
} catch (error) {
console.error('[TopBarHeader] Save failed:', error)
store.brushVisible = true
saveButtonText.value = t('g.save')
saveEnabled.value = true
}
}
const handleCancel = () => {
dialogStore.closeDialog({ key: 'global-mask-editor' })
}
</script>

View File

@@ -1,5 +1,6 @@
<template>
<div
ref="menuButtonRef"
v-tooltip="{
value: t('sideToolbar.labels.menu'),
showDelay: 300,
@@ -29,6 +30,7 @@
>
<template #item="{ item, props }">
<a
v-if="item.key !== 'nodes-2.0-toggle'"
class="p-menubar-item-link px-4 py-2"
v-bind="props.action"
:href="item.url"
@@ -65,6 +67,34 @@
</span>
<i v-if="item.items" class="pi pi-angle-right ml-auto" />
</a>
<div
v-else
class="flex items-center justify-between px-4 py-2"
@click.stop="handleNodes2ToggleClick"
>
<span class="p-menubar-item-label text-nowrap">{{ item.label }}</span>
<ToggleSwitch
v-model="nodes2Enabled"
class="ml-4"
:aria-label="item.label"
:pt="{
root: {
style: {
width: '38px',
height: '20px'
}
},
handle: {
style: {
width: '16px',
height: '16px'
}
}
}"
@click.stop
@update:model-value="onNodes2ToggleChange"
/>
</div>
</template>
</TieredMenu>
</template>
@@ -73,6 +103,7 @@
import type { MenuItem } from 'primevue/menuitem'
import TieredMenu from 'primevue/tieredmenu'
import type { TieredMenuMethods, TieredMenuState } from 'primevue/tieredmenu'
import ToggleSwitch from 'primevue/toggleswitch'
import { computed, nextTick, ref } from 'vue'
import { useI18n } from 'vue-i18n'
@@ -80,6 +111,7 @@ import SettingDialogHeader from '@/components/dialog/header/SettingDialogHeader.
import ComfyLogo from '@/components/icons/ComfyLogo.vue'
import { useWorkflowTemplateSelectorDialog } from '@/composables/useWorkflowTemplateSelectorDialog'
import SettingDialogContent from '@/platform/settings/components/SettingDialogContent.vue'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useTelemetry } from '@/platform/telemetry'
import { useColorPaletteService } from '@/services/colorPaletteService'
import { useCommandStore } from '@/stores/commandStore'
@@ -98,10 +130,19 @@ const colorPaletteStore = useColorPaletteStore()
const colorPaletteService = useColorPaletteService()
const dialogStore = useDialogStore()
const managerState = useManagerState()
const settingStore = useSettingStore()
const menuRef = ref<
({ dirty: boolean } & TieredMenuMethods & TieredMenuState) | null
>(null)
const menuButtonRef = ref<HTMLElement | null>(null)
const nodes2Enabled = computed({
get: () => settingStore.get('Comfy.VueNodes.Enabled') ?? false,
set: async (value: boolean) => {
await settingStore.set('Comfy.VueNodes.Enabled', value)
}
})
const telemetry = useTelemetry()
@@ -164,6 +205,10 @@ const extraMenuItems = computed(() => [
label: t('menu.theme'),
items: themeMenuItems.value
},
{
key: 'nodes-2.0-toggle',
label: 'Nodes 2.0'
},
{ separator: true },
{
key: 'browse-templates',
@@ -281,6 +326,17 @@ const hasActiveStateSiblings = (item: MenuItem): boolean => {
menuItemStore.menuItemHasActiveStateChildren[item.parentPath])
)
}
const handleNodes2ToggleClick = () => {
return false
}
const onNodes2ToggleChange = async (value: boolean) => {
await settingStore.set('Comfy.VueNodes.Enabled', value)
telemetry?.trackUiButtonClicked({
button_id: `menu_nodes_2.0_toggle_${value ? 'enabled' : 'disabled'}`
})
}
</script>
<style scoped>

View File

@@ -1,7 +1,7 @@
<template>
<nav
ref="sideToolbarRef"
class="side-tool-bar-container flex h-full flex-col items-center bg-transparent [.floating-sidebar]:-mr-2"
class="side-tool-bar-container flex h-full flex-col items-center bg-transparent [.floating-sidebar]:-mr-2 pointer-events-auto"
:class="{
'small-sidebar': isSmall,
'connected-sidebar': isConnected,
@@ -145,7 +145,7 @@ const getTabTooltipSuffix = (tab: SidebarTabExtension) => {
const isOverflowing = ref(false)
const groupClasses = computed(() =>
cn(
'sidebar-item-group pointer-events-auto flex flex-col items-center overflow-hidden flex-shrink-0' +
'sidebar-item-group flex flex-col items-center overflow-hidden flex-shrink-0' +
(isConnected.value ? '' : ' rounded-lg shadow-interface')
)
)

View File

@@ -10,12 +10,12 @@
>
<slot name="top" />
</div>
<div v-if="slots.header" class="px-4">
<div v-if="slots.header" class="px-4 pb-4">
<slot name="header" />
</div>
</div>
<!-- h-0 to force scrollpanel to grow -->
<ScrollPanel class="h-0 grow">
<!-- min-h-0 to force scrollpanel to grow -->
<ScrollPanel class="min-h-0 grow">
<slot name="body" />
</ScrollPanel>
<div v-if="slots.footer">

View File

@@ -2,7 +2,7 @@
<AssetsSidebarTemplate>
<template #top>
<span v-if="!isInFolderView" class="font-bold">
{{ $t('sideToolbar.mediaAssets') }}
{{ $t('sideToolbar.mediaAssets.title') }}
</span>
<div v-else class="flex w-full items-center justify-between gap-2">
<div class="flex items-center gap-2">
@@ -36,21 +36,47 @@
</div>
<!-- Normal Tab View -->
<TabList v-else v-model="activeTab" class="pt-4 pb-1">
<Tab value="input">{{ $t('sideToolbar.labels.imported') }}</Tab>
<Tab value="output">{{ $t('sideToolbar.labels.generated') }}</Tab>
<Tab value="input">{{ $t('sideToolbar.labels.imported') }}</Tab>
</TabList>
<!-- Filter Bar -->
<MediaAssetFilterBar
v-model:search-query="searchQuery"
v-model:sort-by="sortBy"
v-model:media-type-filters="mediaTypeFilters"
:show-generation-time-sort="activeTab === 'output'"
/>
</template>
<template #body>
<div v-if="displayAssets.length" class="relative size-full">
<!-- Loading state -->
<div v-if="loading">
<ProgressSpinner class="absolute left-1/2 w-[50px] -translate-x-1/2" />
</div>
<!-- Empty state -->
<div v-else-if="!displayAssets.length">
<NoResultsPlaceholder
icon="pi pi-info-circle"
:title="
$t(
activeTab === 'input'
? 'sideToolbar.noImportedFiles'
: 'sideToolbar.noGeneratedFiles'
)
"
:message="$t('sideToolbar.noFilesFoundMessage')"
/>
</div>
<!-- Content -->
<div v-else class="relative size-full">
<VirtualGrid
v-if="displayAssets.length"
:items="mediaAssetsWithKey"
:grid-style="{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(200px, 1fr))',
padding: '0.5rem',
padding: '0 0.5rem',
gap: '0.5rem'
}"
@approach-end="handleApproachEnd"
>
<template #item="{ item }">
<MediaAssetCard
@@ -58,7 +84,7 @@
:selected="isSelected(item.id)"
:show-output-count="shouldShowOutputCount(item)"
:output-count="getOutputCount(item)"
:show-delete-button="!isInFolderView"
:show-delete-button="shouldShowDeleteButton"
@click="handleAssetSelect(item)"
@zoom="handleZoomClick(item)"
@output-count-click="enterFolderView(item)"
@@ -66,29 +92,11 @@
/>
</template>
</VirtualGrid>
<div v-else-if="loading">
<ProgressSpinner
class="absolute left-1/2 w-[50px] -translate-x-1/2"
/>
</div>
<div v-else>
<NoResultsPlaceholder
icon="pi pi-info-circle"
:title="
$t(
activeTab === 'input'
? 'sideToolbar.noImportedFiles'
: 'sideToolbar.noGeneratedFiles'
)
"
:message="$t('sideToolbar.noFilesFoundMessage')"
/>
</div>
</div>
</template>
<template #footer>
<div
v-if="hasSelection && activeTab === 'output'"
v-if="hasSelection"
class="flex h-18 w-full items-center justify-between px-4"
>
<div>
@@ -116,7 +124,7 @@
</div>
<div class="flex gap-2">
<IconTextButton
v-if="!isInFolderView"
v-if="shouldShowDeleteButton"
:label="$t('mediaAsset.selection.deleteSelected')"
type="secondary"
icon-position="right"
@@ -147,6 +155,7 @@
</template>
<script setup lang="ts">
import { useDebounceFn } from '@vueuse/core'
import ProgressSpinner from 'primevue/progressspinner'
import { useToast } from 'primevue/usetoast'
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
@@ -155,16 +164,21 @@ import IconTextButton from '@/components/button/IconTextButton.vue'
import TextButton from '@/components/button/TextButton.vue'
import NoResultsPlaceholder from '@/components/common/NoResultsPlaceholder.vue'
import VirtualGrid from '@/components/common/VirtualGrid.vue'
import Load3dViewerContent from '@/components/load3d/Load3dViewerContent.vue'
import ResultGallery from '@/components/sidebar/tabs/queue/ResultGallery.vue'
import Tab from '@/components/tab/Tab.vue'
import TabList from '@/components/tab/TabList.vue'
import { t } from '@/i18n'
import MediaAssetCard from '@/platform/assets/components/MediaAssetCard.vue'
import MediaAssetFilterBar from '@/platform/assets/components/MediaAssetFilterBar.vue'
import { useMediaAssets } from '@/platform/assets/composables/media/useMediaAssets'
import { useAssetSelection } from '@/platform/assets/composables/useAssetSelection'
import { useMediaAssetActions } from '@/platform/assets/composables/useMediaAssetActions'
import { useMediaAssetFiltering } from '@/platform/assets/composables/useMediaAssetFiltering'
import { getOutputAssetMetadata } from '@/platform/assets/schemas/assetMetadataSchema'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import { isCloud } from '@/platform/distribution/types'
import { useDialogStore } from '@/stores/dialogStore'
import { ResultItemImpl } from '@/stores/queueStore'
import { formatDuration, getMediaTypeFromFilename } from '@/utils/formatUtil'
@@ -175,6 +189,13 @@ const folderPromptId = ref<string | null>(null)
const folderExecutionTime = ref<number | undefined>(undefined)
const isInFolderView = computed(() => folderPromptId.value !== null)
// Determine if delete button should be shown
// Hide delete button when in input tab and not in cloud (OSS mode - files are from local folders)
const shouldShowDeleteButton = computed(() => {
if (activeTab.value === 'input' && !isCloud) return false
return true
})
const getOutputCount = (item: AssetItem): number => {
const count = item.user_metadata?.outputCount
return typeof count === 'number' && count > 0 ? count : 0
@@ -226,13 +247,22 @@ const currentGalleryAssetId = ref<string | null>(null)
const folderAssets = ref<AssetItem[]>([])
const displayAssets = computed(() => {
// Base assets before search filtering
const baseAssets = computed(() => {
if (isInFolderView.value) {
return folderAssets.value
}
return mediaAssets.value
})
// Use media asset filtering composable
const { searchQuery, sortBy, mediaTypeFilters, filteredAssets } =
useMediaAssetFiltering(baseAssets)
const displayAssets = computed(() => {
return filteredAssets.value
})
watch(displayAssets, (newAssets) => {
if (currentGalleryAssetId.value && galleryActiveIndex.value !== -1) {
const newIndex = newAssets.findIndex(
@@ -291,6 +321,9 @@ watch(
activeTab,
() => {
clearSelection()
// Clear search when switching tabs
searchQuery.value = ''
// Reset pagination state when tab changes
void refreshAssets()
},
{ immediate: true }
@@ -302,6 +335,25 @@ const handleAssetSelect = (asset: AssetItem) => {
}
const handleZoomClick = (asset: AssetItem) => {
const mediaType = getMediaTypeFromFilename(asset.name)
if (mediaType === '3D') {
const dialogStore = useDialogStore()
dialogStore.showDialog({
key: 'asset-3d-viewer',
title: asset.name,
component: Load3dViewerContent,
props: {
modelUrl: asset.preview_url || ''
},
dialogComponentProps: {
style: 'width: 80vw; height: 80vh;',
maximizable: true
}
})
return
}
currentGalleryAssetId.value = asset.id
const index = displayAssets.value.findIndex((a) => a.id === asset.id)
if (index !== -1) {
@@ -347,6 +399,7 @@ const exitFolderView = () => {
folderPromptId.value = null
folderExecutionTime.value = undefined
folderAssets.value = []
searchQuery.value = ''
clearSelection()
}
@@ -395,4 +448,15 @@ const handleDeleteSelected = async () => {
await deleteMultipleAssets(selectedAssets)
clearSelection()
}
const handleApproachEnd = useDebounceFn(async () => {
if (
activeTab.value === 'output' &&
!isInFolderView.value &&
outputAssets.hasMore.value &&
!outputAssets.isLoadingMore.value
) {
await outputAssets.loadMore()
}
}, 300)
</script>

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