Compare commits
38 Commits
fix/queue-
...
posthog-fe
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8d90716662 | ||
|
|
0b0af89321 | ||
|
|
d314172b98 | ||
|
|
6c3408592e | ||
|
|
b6632443dc | ||
|
|
c8a1df3a05 | ||
|
|
39204135ba | ||
|
|
ffa55cb92b | ||
|
|
3856e0deea | ||
|
|
7b589b5502 | ||
|
|
72a2581068 | ||
|
|
22aea29a0d | ||
|
|
637c1995b4 | ||
|
|
550ca0c911 | ||
|
|
896867b03c | ||
|
|
0cd0218946 | ||
|
|
334404aa3b | ||
|
|
31d842217b | ||
|
|
3dd3e26003 | ||
|
|
e6332046b0 | ||
|
|
5fa76e23d9 | ||
|
|
fcfb5437a9 | ||
|
|
5ff3a0ed52 | ||
|
|
5fa0295ff5 | ||
|
|
1348a0934a | ||
|
|
01f8e77251 | ||
|
|
31c03b669e | ||
|
|
c9da19b5b5 | ||
|
|
10222860eb | ||
|
|
4597b7e600 | ||
|
|
6782d04f00 | ||
|
|
4bb5c12fac | ||
|
|
8b5cfe7e55 | ||
|
|
135169003f | ||
|
|
d58a464c9c | ||
|
|
e54b972550 | ||
|
|
9bd63dbe6a | ||
|
|
a21c813d11 |
69
.github/workflows/cloud-backport-tag.yaml
vendored
Normal file
@@ -0,0 +1,69 @@
|
||||
---
|
||||
name: Cloud Backport Tag
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: ['closed']
|
||||
branches: [cloud/*]
|
||||
|
||||
jobs:
|
||||
create-tag:
|
||||
if: >
|
||||
github.event.pull_request.merged == true &&
|
||||
contains(github.event.pull_request.labels.*.name, 'backport')
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: read
|
||||
|
||||
steps:
|
||||
- name: Checkout merge commit
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.merge_commit_sha }}
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version-file: '.nvmrc'
|
||||
|
||||
- name: Create tag for cloud backport
|
||||
id: tag
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
BRANCH="${{ github.event.pull_request.base.ref }}"
|
||||
if [[ ! "$BRANCH" =~ ^cloud/([0-9]+)\.([0-9]+)$ ]]; then
|
||||
echo "::error::Base branch '$BRANCH' is not a cloud/x.y branch"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
MAJOR="${BASH_REMATCH[1]}"
|
||||
MINOR="${BASH_REMATCH[2]}"
|
||||
|
||||
VERSION=$(node -p "require('./package.json').version")
|
||||
if [[ "$VERSION" =~ ^${MAJOR}\.${MINOR}\.([0-9]+)(-.+)?$ ]]; then
|
||||
PATCH="${BASH_REMATCH[1]}"
|
||||
SUFFIX="${BASH_REMATCH[2]:-}"
|
||||
else
|
||||
echo "::error::Version '${VERSION}' does not match cloud branch '${BRANCH}'"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
TAG="cloud/v${VERSION}"
|
||||
|
||||
if git ls-remote --tags origin "${TAG}" | grep -Fq "refs/tags/${TAG}"; then
|
||||
echo "::notice::Tag ${TAG} already exists; skipping"
|
||||
echo "tag=${TAG}" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
git tag "${TAG}" "${{ github.event.pull_request.merge_commit_sha }}"
|
||||
git push origin "${TAG}"
|
||||
echo "tag=${TAG}" >> "$GITHUB_OUTPUT"
|
||||
{
|
||||
echo "Created tag: ${TAG}"
|
||||
echo "Branch: ${BRANCH}"
|
||||
echo "Version: ${VERSION}"
|
||||
echo "Commit: ${{ github.event.pull_request.merge_commit_sha }}"
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
14
.github/workflows/pr-backport.yaml
vendored
@@ -236,8 +236,8 @@ jobs:
|
||||
PR_TITLE=$(echo "$PR_DATA" | jq -r '.title')
|
||||
MERGE_COMMIT=$(echo "$PR_DATA" | jq -r '.mergeCommit.oid')
|
||||
else
|
||||
PR_TITLE="${{ github.event.pull_request.title }}"
|
||||
MERGE_COMMIT="${{ github.event.pull_request.merge_commit_sha }}"
|
||||
PR_TITLE=$(jq -r '.pull_request.title' "$GITHUB_EVENT_PATH")
|
||||
MERGE_COMMIT=$(jq -r '.pull_request.merge_commit_sha' "$GITHUB_EVENT_PATH")
|
||||
fi
|
||||
|
||||
for target in ${{ steps.filter-targets.outputs.pending-targets }}; do
|
||||
@@ -326,8 +326,8 @@ jobs:
|
||||
PR_TITLE=$(echo "$PR_DATA" | jq -r '.title')
|
||||
PR_AUTHOR=$(echo "$PR_DATA" | jq -r '.author.login')
|
||||
else
|
||||
PR_TITLE="${{ github.event.pull_request.title }}"
|
||||
PR_AUTHOR="${{ github.event.pull_request.user.login }}"
|
||||
PR_TITLE=$(jq -r '.pull_request.title' "$GITHUB_EVENT_PATH")
|
||||
PR_AUTHOR=$(jq -r '.pull_request.user.login' "$GITHUB_EVENT_PATH")
|
||||
fi
|
||||
|
||||
for backport in ${{ steps.backport.outputs.success }}; do
|
||||
@@ -364,9 +364,9 @@ jobs:
|
||||
PR_AUTHOR=$(echo "$PR_DATA" | jq -r '.author.login')
|
||||
MERGE_COMMIT=$(echo "$PR_DATA" | jq -r '.mergeCommit.oid')
|
||||
else
|
||||
PR_NUMBER="${{ github.event.pull_request.number }}"
|
||||
PR_AUTHOR="${{ github.event.pull_request.user.login }}"
|
||||
MERGE_COMMIT="${{ github.event.pull_request.merge_commit_sha }}"
|
||||
PR_NUMBER=$(jq -r '.pull_request.number' "$GITHUB_EVENT_PATH")
|
||||
PR_AUTHOR=$(jq -r '.pull_request.user.login' "$GITHUB_EVENT_PATH")
|
||||
MERGE_COMMIT=$(jq -r '.pull_request.merge_commit_sha' "$GITHUB_EVENT_PATH")
|
||||
fi
|
||||
|
||||
for failure in ${{ steps.backport.outputs.failed }}; do
|
||||
|
||||
@@ -64,7 +64,6 @@ const config: StorybookConfig = {
|
||||
deep: true,
|
||||
extensions: ['vue']
|
||||
})
|
||||
// Note: Explicitly NOT including generateImportMapPlugin to avoid externalization
|
||||
],
|
||||
server: {
|
||||
allowedHosts: true
|
||||
|
||||
18
CODEOWNERS
@@ -1,8 +1,11 @@
|
||||
# Global Ownership
|
||||
* @Comfy-org/comfy_frontend_devs
|
||||
|
||||
# Desktop/Electron
|
||||
/apps/desktop-ui/ @webfiltered
|
||||
/src/stores/electronDownloadStore.ts @webfiltered
|
||||
/src/extensions/core/electronAdapter.ts @webfiltered
|
||||
/vite.electron.config.mts @webfiltered
|
||||
/apps/desktop-ui/ @benceruleanlu
|
||||
/src/stores/electronDownloadStore.ts @benceruleanlu
|
||||
/src/extensions/core/electronAdapter.ts @benceruleanlu
|
||||
/vite.electron.config.mts @benceruleanlu
|
||||
|
||||
# Common UI Components
|
||||
/src/components/chip/ @viva-jinyi
|
||||
@@ -31,10 +34,7 @@
|
||||
/src/components/graph/selectionToolbox/ @Myestery
|
||||
|
||||
# Minimap
|
||||
/src/renderer/extensions/minimap/ @jtydhr88
|
||||
|
||||
# Assets
|
||||
/src/platform/assets/ @arjansingh
|
||||
/src/renderer/extensions/minimap/ @jtydhr88 @Myestery
|
||||
|
||||
# Workflow Templates
|
||||
/src/platform/workflow/templates/ @Myestery @christian-byrne @comfyui-wiki
|
||||
@@ -53,7 +53,7 @@
|
||||
/src/workbench/extensions/manager/ @viva-jinyi @christian-byrne @ltdrdata
|
||||
|
||||
# Translations
|
||||
/src/locales/ @Yorha4D @KarryCharon @shinshin86 @Comfy-Org/comfy_maintainer
|
||||
/src/locales/ @Yorha4D @KarryCharon @shinshin86 @Comfy-Org/comfy_maintainer @Comfy-org/comfy_frontend_devs
|
||||
|
||||
# LLM Instructions (blank on purpose)
|
||||
.claude/
|
||||
|
||||
229
FEATURE_FLAGS_EXPLANATION.md
Normal file
@@ -0,0 +1,229 @@
|
||||
# Feature Flags System Explanation
|
||||
|
||||
## Overview
|
||||
|
||||
The `useFeatureFlag` hook (actually named `useFeatureFlags`) is a Vue 3 composable that provides **reactive access to server-side feature flags** received via WebSocket from the backend. It enables capability negotiation between frontend and backend, allowing the UI to adapt based on what features the server supports.
|
||||
|
||||
## Architecture Flow
|
||||
|
||||
```
|
||||
1. Frontend connects via WebSocket
|
||||
2. Frontend sends client feature flags (first message)
|
||||
3. Backend responds with server feature flags
|
||||
4. Frontend stores flags in api.serverFeatureFlags
|
||||
5. Components use useFeatureFlags() to access flags reactively
|
||||
```
|
||||
|
||||
## Core Implementation
|
||||
|
||||
### 1. The `useFeatureFlags` Composable
|
||||
|
||||
**Location:** `src/composables/useFeatureFlags.ts`
|
||||
|
||||
The composable returns two things:
|
||||
|
||||
#### A. Predefined `flags` Object
|
||||
A reactive object with getter properties for commonly-used feature flags:
|
||||
|
||||
```typescript
|
||||
const { flags } = useFeatureFlags()
|
||||
|
||||
// Access predefined flags
|
||||
flags.supportsPreviewMetadata // boolean | undefined
|
||||
flags.maxUploadSize // number | undefined
|
||||
flags.supportsManagerV4 // boolean | undefined
|
||||
flags.modelUploadButtonEnabled // boolean (checks remoteConfig first)
|
||||
flags.assetUpdateOptionsEnabled // boolean (checks remoteConfig first)
|
||||
```
|
||||
|
||||
**Key Points:**
|
||||
- Uses Vue's `reactive()` to make the object reactive
|
||||
- Each getter calls `api.getServerFeature()` which reads from `api.serverFeatureFlags`
|
||||
- Some flags (like `modelUploadButtonEnabled`) check `remoteConfig` first (from `/api/features` endpoint) before falling back to WebSocket flags
|
||||
- Returns a `readonly()` wrapper to prevent external mutation
|
||||
|
||||
#### B. Generic `featureFlag` Function
|
||||
A function that creates a computed ref for any feature flag path:
|
||||
|
||||
```typescript
|
||||
const { featureFlag } = useFeatureFlags()
|
||||
|
||||
// Create a reactive computed ref for any flag
|
||||
const myFlag = featureFlag('custom.feature.path', false) // defaultValue is optional
|
||||
// myFlag is a ComputedRef that updates when serverFeatureFlags changes
|
||||
```
|
||||
|
||||
**Key Points:**
|
||||
- Accepts any string path (supports dot notation for nested values)
|
||||
- Returns a `computed()` ref that automatically updates when flags change
|
||||
- Generic type parameter allows type safety: `featureFlag<boolean>('flag', false)`
|
||||
|
||||
### 2. The Underlying API Layer
|
||||
|
||||
**Location:** `src/scripts/api.ts`
|
||||
|
||||
The `ComfyApi` class manages feature flags:
|
||||
|
||||
```typescript
|
||||
class ComfyApi {
|
||||
// Stores flags received from backend
|
||||
serverFeatureFlags: Record<string, unknown> = {}
|
||||
|
||||
// Retrieves a flag value using dot notation
|
||||
getServerFeature<T>(featureName: string, defaultValue?: T): T {
|
||||
return get(this.serverFeatureFlags, featureName, defaultValue) as T
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**How Flags Are Received:**
|
||||
1. WebSocket connection is established
|
||||
2. Frontend sends client feature flags as first message
|
||||
3. Backend responds with a `feature_flags` message type
|
||||
4. The message handler stores it: `this.serverFeatureFlags = msg.data`
|
||||
|
||||
**The `get` Function:**
|
||||
- Uses `es-toolkit/compat`'s `get` function (lodash-style)
|
||||
- Supports dot notation: `'extension.manager.supports_v4'` accesses nested objects
|
||||
- Returns `defaultValue` if the path doesn't exist
|
||||
|
||||
### 3. Remote Config Integration
|
||||
|
||||
**Location:** `src/platform/remoteConfig/remoteConfig.ts`
|
||||
|
||||
Some flags check `remoteConfig` first (loaded from `/api/features` endpoint):
|
||||
|
||||
```typescript
|
||||
// Example from modelUploadButtonEnabled
|
||||
return (
|
||||
remoteConfig.value.model_upload_button_enabled ?? // Check remote config first
|
||||
api.getServerFeature(ServerFeatureFlag.MODEL_UPLOAD_BUTTON_ENABLED, false) // Fallback
|
||||
)
|
||||
```
|
||||
|
||||
**Why Two Sources?**
|
||||
- `remoteConfig`: Fetched via HTTP at app startup, can be updated without WebSocket
|
||||
- WebSocket flags: Real-time capability negotiation, updated on reconnection
|
||||
|
||||
## Usage Patterns
|
||||
|
||||
### Pattern 1: Using Predefined Flags
|
||||
|
||||
```typescript
|
||||
import { useFeatureFlags } from '@/composables/useFeatureFlags'
|
||||
|
||||
const { flags } = useFeatureFlags()
|
||||
|
||||
// In template
|
||||
if (flags.supportsPreviewMetadata) {
|
||||
// Use enhanced preview feature
|
||||
}
|
||||
|
||||
// In script
|
||||
const maxSize = flags.maxUploadSize ?? 100 * 1024 * 1024 // Default 100MB
|
||||
```
|
||||
|
||||
### Pattern 2: Using Generic featureFlag Function
|
||||
|
||||
```typescript
|
||||
import { useFeatureFlags } from '@/composables/useFeatureFlags'
|
||||
|
||||
const { featureFlag } = useFeatureFlags()
|
||||
|
||||
// Create a reactive computed ref
|
||||
const customFeature = featureFlag<boolean>('extension.custom.feature', false)
|
||||
|
||||
// Use in template (automatically reactive)
|
||||
// <div v-if="customFeature">New Feature UI</div>
|
||||
|
||||
// Use in script
|
||||
watch(customFeature, (enabled) => {
|
||||
if (enabled) {
|
||||
// Feature was enabled
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
### Pattern 3: Direct API Access (Non-Reactive)
|
||||
|
||||
```typescript
|
||||
import { api } from '@/scripts/api'
|
||||
|
||||
// Direct access (not reactive, use sparingly)
|
||||
if (api.serverSupportsFeature('supports_preview_metadata')) {
|
||||
// Feature is supported
|
||||
}
|
||||
|
||||
const maxSize = api.getServerFeature('max_upload_size', 100 * 1024 * 1024)
|
||||
```
|
||||
|
||||
## Reactivity Explained
|
||||
|
||||
The composable is **reactive** because:
|
||||
|
||||
1. **Predefined flags**: Use `reactive()` with getters, so when `api.serverFeatureFlags` changes, Vue's reactivity system detects it
|
||||
2. **Generic featureFlag**: Returns `computed()`, which automatically tracks `api.getServerFeature()` calls and re-evaluates when flags change
|
||||
3. **WebSocket updates**: When flags are updated via WebSocket, `api.serverFeatureFlags` is reassigned, triggering reactivity
|
||||
|
||||
## Adding New Feature Flags
|
||||
|
||||
### Step 1: Add to Enum (if it's a core flag)
|
||||
|
||||
```typescript
|
||||
// In useFeatureFlags.ts
|
||||
export enum ServerFeatureFlag {
|
||||
// ... existing flags
|
||||
MY_NEW_FEATURE = 'my_new_feature'
|
||||
}
|
||||
```
|
||||
|
||||
### Step 2: Add to flags Object (if commonly used)
|
||||
|
||||
```typescript
|
||||
// In useFeatureFlags.ts flags object
|
||||
get myNewFeature() {
|
||||
return api.getServerFeature(ServerFeatureFlag.MY_NEW_FEATURE, false)
|
||||
}
|
||||
```
|
||||
|
||||
### Step 3: Use in Components
|
||||
|
||||
```typescript
|
||||
const { flags } = useFeatureFlags()
|
||||
if (flags.myNewFeature) {
|
||||
// Use the feature
|
||||
}
|
||||
```
|
||||
|
||||
**OR** use the generic function without modifying the composable:
|
||||
|
||||
```typescript
|
||||
const { featureFlag } = useFeatureFlags()
|
||||
const myFeature = featureFlag('my_new_feature', false)
|
||||
```
|
||||
|
||||
## Important Notes
|
||||
|
||||
1. **Flags are server-driven**: The backend controls which flags are available
|
||||
2. **Default values**: Always provide sensible defaults when using `getServerFeature()`
|
||||
3. **Reactivity**: The composable ensures UI updates automatically when flags change (e.g., on WebSocket reconnection)
|
||||
4. **Type safety**: Use TypeScript generics with `featureFlag<T>()` for type safety
|
||||
5. **Dot notation**: Feature flags can be nested, use dot notation: `'extension.manager.supports_v4'`
|
||||
6. **Remote config priority**: Some flags check `remoteConfig` first, then fall back to WebSocket flags
|
||||
|
||||
## Testing
|
||||
|
||||
See `tests-ui/tests/composables/useFeatureFlags.test.ts` for examples of:
|
||||
- Mocking `api.getServerFeature()`
|
||||
- Testing reactive behavior
|
||||
- Testing default values
|
||||
- Testing nested paths
|
||||
|
||||
## Related Files
|
||||
|
||||
- `src/composables/useFeatureFlags.ts` - The main composable
|
||||
- `src/scripts/api.ts` - API layer with `getServerFeature()` method
|
||||
- `src/platform/remoteConfig/remoteConfig.ts` - Remote config integration
|
||||
- `docs/FEATURE_FLAGS.md` - Full system documentation
|
||||
- `tests-ui/tests/composables/useFeatureFlags.test.ts` - Unit tests
|
||||
|
||||
196
FEATURE_FLAG_PAYLOAD.md
Normal file
@@ -0,0 +1,196 @@
|
||||
# Feature Flag Payload Shape
|
||||
|
||||
## Feature Flag Key
|
||||
`demo-run-button-experiment`
|
||||
|
||||
## Expected Structure
|
||||
|
||||
The feature flag value should be either:
|
||||
|
||||
### Control (Original Button)
|
||||
- `false`
|
||||
- `null`
|
||||
- `undefined`
|
||||
- Not set
|
||||
|
||||
When any of these values are present, the original SplitButton will be displayed.
|
||||
|
||||
### Experiment (Experimental Button)
|
||||
An object with the following structure:
|
||||
|
||||
```typescript
|
||||
{
|
||||
variant: string, // Required: variant name (e.g., "bold-gradient", "animated", "playful", "minimal")
|
||||
payload?: { // Optional: styling and content overrides
|
||||
label?: string, // Button text (default: "Run")
|
||||
icon?: string, // Icon class (default: variant-specific)
|
||||
backgroundColor?: string, // Background color/class (default: variant-specific)
|
||||
textColor?: string, // Text color/class (default: variant-specific)
|
||||
borderRadius?: string, // Border radius class (default: variant-specific)
|
||||
padding?: string // Padding class (default: "px-4 py-2")
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Example Payloads
|
||||
|
||||
### Bold Gradient Variant
|
||||
```json
|
||||
{
|
||||
"variant": "bold-gradient",
|
||||
"payload": {
|
||||
"label": "Run",
|
||||
"icon": "icon-[lucide--zap]",
|
||||
"backgroundColor": "transparent",
|
||||
"textColor": "white",
|
||||
"borderRadius": "rounded-xl"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Animated Variant
|
||||
```json
|
||||
{
|
||||
"variant": "animated",
|
||||
"payload": {
|
||||
"label": "Launch",
|
||||
"icon": "icon-[lucide--rocket]",
|
||||
"backgroundColor": "bg-primary-background",
|
||||
"textColor": "white",
|
||||
"borderRadius": "rounded-full"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Playful Variant
|
||||
```json
|
||||
{
|
||||
"variant": "playful",
|
||||
"payload": {
|
||||
"label": "Go!",
|
||||
"icon": "icon-[lucide--sparkles]",
|
||||
"backgroundColor": "bg-gradient-to-br from-yellow-400 to-orange-500",
|
||||
"textColor": "white",
|
||||
"borderRadius": "rounded-2xl"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Minimal Variant
|
||||
```json
|
||||
{
|
||||
"variant": "minimal",
|
||||
"payload": {
|
||||
"label": "Run",
|
||||
"icon": "icon-[lucide--play]",
|
||||
"backgroundColor": "bg-white",
|
||||
"textColor": "text-gray-800",
|
||||
"borderRadius": "rounded-md"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Payload Properties
|
||||
|
||||
### `variant` (required)
|
||||
- Type: `string`
|
||||
- Description: The variant name that determines the base styling and behavior
|
||||
- Supported values:
|
||||
- `"bold-gradient"` - Gradient background with animated effect
|
||||
- `"animated"` - Pulsing animation effect
|
||||
- `"playful"` - Sparkle effects with playful styling
|
||||
- `"minimal"` - Clean, minimal design
|
||||
- Any custom variant name (will use default styling)
|
||||
|
||||
### `payload.label` (optional)
|
||||
- Type: `string`
|
||||
- Default: `"Run"` (or variant-specific default)
|
||||
- Description: The text displayed on the button
|
||||
|
||||
### `payload.icon` (optional)
|
||||
- Type: `string`
|
||||
- Default: Variant-specific icon
|
||||
- Description: Icon class name (e.g., `"icon-[lucide--play]"`)
|
||||
- Examples:
|
||||
- `"icon-[lucide--play]"` - Play icon
|
||||
- `"icon-[lucide--zap]"` - Lightning bolt
|
||||
- `"icon-[lucide--rocket]"` - Rocket
|
||||
- `"icon-[lucide--sparkles]"` - Sparkles
|
||||
|
||||
### `payload.backgroundColor` (optional)
|
||||
- Type: `string`
|
||||
- Default: Variant-specific background
|
||||
- Description: Tailwind CSS class or CSS color value for background
|
||||
- Examples:
|
||||
- `"bg-primary-background"` - Primary background color
|
||||
- `"bg-white"` - White background
|
||||
- `"transparent"` - Transparent (for gradient overlays)
|
||||
- `"bg-gradient-to-br from-yellow-400 to-orange-500"` - Gradient
|
||||
|
||||
### `payload.textColor` (optional)
|
||||
- Type: `string`
|
||||
- Default: Variant-specific text color
|
||||
- Description: Tailwind CSS class or CSS color value for text
|
||||
- Examples:
|
||||
- `"white"` - White text (CSS color)
|
||||
- `"text-white"` - White text (Tailwind class)
|
||||
- `"text-gray-800"` - Dark gray text
|
||||
|
||||
### `payload.borderRadius` (optional)
|
||||
- Type: `string`
|
||||
- Default: Variant-specific border radius
|
||||
- Description: Tailwind CSS border radius class
|
||||
- Examples:
|
||||
- `"rounded-md"` - Medium border radius
|
||||
- `"rounded-xl"` - Extra large border radius
|
||||
- `"rounded-full"` - Fully rounded (pill shape)
|
||||
- `"rounded-2xl"` - 2x extra large border radius
|
||||
|
||||
### `payload.padding` (optional)
|
||||
- Type: `string`
|
||||
- Default: `"px-4 py-2"`
|
||||
- Description: Tailwind CSS padding classes
|
||||
- Examples:
|
||||
- `"px-4 py-2"` - Standard padding
|
||||
- `"px-6 py-3"` - Larger padding
|
||||
- `"px-2 py-1"` - Smaller padding
|
||||
|
||||
## Backend Integration
|
||||
|
||||
The backend should send this feature flag via WebSocket in the `feature_flags` message:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "feature_flags",
|
||||
"data": {
|
||||
"demo-run-button-experiment": {
|
||||
"variant": "bold-gradient",
|
||||
"payload": {
|
||||
"label": "Run",
|
||||
"icon": "icon-[lucide--zap]",
|
||||
"textColor": "white",
|
||||
"borderRadius": "rounded-xl"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Or to show the control (original button):
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "feature_flags",
|
||||
"data": {
|
||||
"demo-run-button-experiment": false
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
- All `payload` properties are optional - if omitted, variant-specific defaults will be used
|
||||
- The `variant` property is required when the flag is truthy
|
||||
- Color values can be either Tailwind classes (e.g., `"text-white"`) or CSS color values (e.g., `"white"`)
|
||||
- The component will automatically handle both formats
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@comfyorg/desktop-ui",
|
||||
"version": "0.0.3",
|
||||
"version": "0.0.4",
|
||||
"type": "module",
|
||||
"nx": {
|
||||
"tags": [
|
||||
|
||||
|
Before Width: | Height: | Size: 126 KiB After Width: | Height: | Size: 130 KiB |
|
Before Width: | Height: | Size: 54 KiB After Width: | Height: | Size: 54 KiB |
|
Before Width: | Height: | Size: 34 KiB After Width: | Height: | Size: 36 KiB |
|
Before Width: | Height: | Size: 59 KiB After Width: | Height: | Size: 60 KiB |
|
Before Width: | Height: | Size: 57 KiB After Width: | Height: | Size: 58 KiB |
|
Before Width: | Height: | Size: 58 KiB After Width: | Height: | Size: 59 KiB |
|
Before Width: | Height: | Size: 60 KiB After Width: | Height: | Size: 60 KiB |
|
Before Width: | Height: | Size: 60 KiB After Width: | Height: | Size: 61 KiB |
|
Before Width: | Height: | Size: 59 KiB After Width: | Height: | Size: 60 KiB |
|
Before Width: | Height: | Size: 56 KiB After Width: | Height: | Size: 57 KiB |
|
Before Width: | Height: | Size: 56 KiB After Width: | Height: | Size: 56 KiB |
|
Before Width: | Height: | Size: 110 KiB After Width: | Height: | Size: 112 KiB |
@@ -0,0 +1,54 @@
|
||||
import {
|
||||
comfyExpect as expect,
|
||||
comfyPageFixture as test
|
||||
} from '../../../../fixtures/ComfyPage'
|
||||
|
||||
test.describe('Vue Node Resizing', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
})
|
||||
|
||||
test('should resize node without position drift after selecting', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
// Get a Vue node fixture
|
||||
const node = await comfyPage.vueNodes.getFixtureByTitle('Load Checkpoint')
|
||||
const initialBox = await node.boundingBox()
|
||||
if (!initialBox) throw new Error('Node bounding box not found')
|
||||
|
||||
// Select the node first (this was causing the bug)
|
||||
await node.header.click()
|
||||
await comfyPage.page.waitForTimeout(100) // Brief pause after selection
|
||||
|
||||
// Get position after selection
|
||||
const selectedBox = await node.boundingBox()
|
||||
if (!selectedBox)
|
||||
throw new Error('Node bounding box not found after select')
|
||||
|
||||
// Verify position unchanged after selection
|
||||
expect(selectedBox.x).toBeCloseTo(initialBox.x, 1)
|
||||
expect(selectedBox.y).toBeCloseTo(initialBox.y, 1)
|
||||
|
||||
// Now resize from bottom-right corner
|
||||
const resizeStartX = selectedBox.x + selectedBox.width - 5
|
||||
const resizeStartY = selectedBox.y + selectedBox.height - 5
|
||||
|
||||
await comfyPage.page.mouse.move(resizeStartX, resizeStartY)
|
||||
await comfyPage.page.mouse.down()
|
||||
await comfyPage.page.mouse.move(resizeStartX + 50, resizeStartY + 30)
|
||||
await comfyPage.page.mouse.up()
|
||||
|
||||
// Get final position and size
|
||||
const finalBox = await node.boundingBox()
|
||||
if (!finalBox) throw new Error('Node bounding box not found after resize')
|
||||
|
||||
// Position should NOT have changed (the bug was position drift)
|
||||
expect(finalBox.x).toBeCloseTo(initialBox.x, 1)
|
||||
expect(finalBox.y).toBeCloseTo(initialBox.y, 1)
|
||||
|
||||
// Size should have increased
|
||||
expect(finalBox.width).toBeGreaterThan(initialBox.width)
|
||||
expect(finalBox.height).toBeGreaterThan(initialBox.height)
|
||||
})
|
||||
})
|
||||
|
Before Width: | Height: | Size: 120 KiB After Width: | Height: | Size: 122 KiB |
|
Before Width: | Height: | Size: 120 KiB After Width: | Height: | Size: 122 KiB |
|
Before Width: | Height: | Size: 150 KiB After Width: | Height: | Size: 150 KiB |
|
Before Width: | Height: | Size: 143 KiB After Width: | Height: | Size: 142 KiB |
@@ -1,49 +0,0 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '../../../fixtures/ComfyPage'
|
||||
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
|
||||
})
|
||||
|
||||
test.describe('Vue Nodes - LOD', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
await comfyPage.setup()
|
||||
await comfyPage.loadWorkflow('default')
|
||||
await comfyPage.setSetting('LiteGraph.Canvas.MinFontSizeForLOD', 8)
|
||||
})
|
||||
|
||||
test('should toggle LOD based on zoom threshold', async ({ comfyPage }) => {
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
|
||||
const initialNodeCount = await comfyPage.vueNodes.getNodeCount()
|
||||
expect(initialNodeCount).toBeGreaterThan(0)
|
||||
|
||||
await expect(comfyPage.canvas).toHaveScreenshot('vue-nodes-default.png')
|
||||
|
||||
const vueNodesContainer = comfyPage.vueNodes.nodes
|
||||
const textboxesInNodes = vueNodesContainer.getByRole('textbox')
|
||||
const comboboxesInNodes = vueNodesContainer.getByRole('combobox')
|
||||
|
||||
await expect(textboxesInNodes.first()).toBeVisible()
|
||||
await expect(comboboxesInNodes.first()).toBeVisible()
|
||||
|
||||
await comfyPage.zoom(120, 10)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await expect(comfyPage.canvas).toHaveScreenshot('vue-nodes-lod-active.png')
|
||||
|
||||
await expect(textboxesInNodes.first()).toBeHidden()
|
||||
await expect(comboboxesInNodes.first()).toBeHidden()
|
||||
|
||||
await comfyPage.zoom(-120, 10)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await expect(comfyPage.canvas).toHaveScreenshot(
|
||||
'vue-nodes-lod-inactive.png'
|
||||
)
|
||||
await expect(textboxesInNodes.first()).toBeVisible()
|
||||
await expect(comboboxesInNodes.first()).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
Before Width: | Height: | Size: 102 KiB After Width: | Height: | Size: 102 KiB |
|
Before Width: | Height: | Size: 100 KiB After Width: | Height: | Size: 100 KiB |
|
Before Width: | Height: | Size: 120 KiB After Width: | Height: | Size: 122 KiB |
|
Before Width: | Height: | Size: 81 KiB After Width: | Height: | Size: 81 KiB |
@@ -88,12 +88,14 @@ export function comfyAPIPlugin(isDev: boolean): Plugin {
|
||||
|
||||
if (result.exports.length > 0) {
|
||||
const projectRoot = process.cwd()
|
||||
const relativePath = path.relative(path.join(projectRoot, 'src'), id)
|
||||
const relativePath = path
|
||||
.relative(path.join(projectRoot, 'src'), id)
|
||||
.replace(/\\/g, '/')
|
||||
const shimFileName = relativePath.replace(/\.ts$/, '.js')
|
||||
|
||||
let shimContent = `// Shim for ${relativePath}\n`
|
||||
|
||||
const fileKey = relativePath.replace(/\.ts$/, '').replace(/\\/g, '/')
|
||||
const fileKey = relativePath.replace(/\.ts$/, '')
|
||||
const warningMessage = getWarningMessage(fileKey, shimFileName)
|
||||
|
||||
if (warningMessage) {
|
||||
|
||||
@@ -1,154 +0,0 @@
|
||||
import glob from 'fast-glob'
|
||||
import fs from 'fs-extra'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { type HtmlTagDescriptor, type Plugin, normalizePath } from 'vite'
|
||||
|
||||
interface ImportMapSource {
|
||||
name: string
|
||||
pattern: string | RegExp
|
||||
entry: string
|
||||
recursiveDependence?: boolean
|
||||
override?: Record<string, Partial<ImportMapSource>>
|
||||
}
|
||||
|
||||
const parseDeps = (root: string, pkg: string) => {
|
||||
const pkgPath = join(root, 'node_modules', pkg, 'package.json')
|
||||
if (fs.existsSync(pkgPath)) {
|
||||
const content = fs.readFileSync(pkgPath, 'utf-8')
|
||||
const pkg = JSON.parse(content)
|
||||
return Object.keys(pkg.dependencies || {})
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
/**
|
||||
* Vite plugin that generates an import map for vendor chunks.
|
||||
*
|
||||
* This plugin creates a browser-compatible import map that maps module specifiers
|
||||
* (like 'vue' or 'primevue') to their actual file locations in the build output.
|
||||
* This improves module loading in modern browsers and enables better caching.
|
||||
*
|
||||
* The plugin:
|
||||
* 1. Tracks vendor chunks during bundle generation
|
||||
* 2. Creates mappings between module names and their file paths
|
||||
* 3. Injects an import map script tag into the HTML head
|
||||
* 4. Configures manual chunk splitting for vendor libraries
|
||||
*
|
||||
* @param vendorLibraries - An array of vendor libraries to split into separate chunks
|
||||
* @returns {Plugin} A Vite plugin that generates and injects an import map
|
||||
*/
|
||||
export function generateImportMapPlugin(
|
||||
importMapSources: ImportMapSource[]
|
||||
): Plugin {
|
||||
const importMapEntries: Record<string, string> = {}
|
||||
const resolvedImportMapSources: Map<string, ImportMapSource> = new Map()
|
||||
const assetDir = 'assets/lib'
|
||||
let root: string
|
||||
|
||||
return {
|
||||
name: 'generate-import-map-plugin',
|
||||
|
||||
// Configure manual chunks during the build process
|
||||
configResolved(config) {
|
||||
root = config.root
|
||||
|
||||
if (config.build) {
|
||||
// Ensure rollupOptions exists
|
||||
if (!config.build.rollupOptions) {
|
||||
config.build.rollupOptions = {}
|
||||
}
|
||||
|
||||
for (const source of importMapSources) {
|
||||
resolvedImportMapSources.set(source.name, source)
|
||||
if (source.recursiveDependence) {
|
||||
const deps = parseDeps(root, source.name)
|
||||
|
||||
while (deps.length) {
|
||||
const dep = deps.shift()!
|
||||
const depSource = Object.assign({}, source, {
|
||||
name: dep,
|
||||
pattern: dep,
|
||||
...source.override?.[dep]
|
||||
})
|
||||
resolvedImportMapSources.set(depSource.name, depSource)
|
||||
|
||||
const _deps = parseDeps(root, depSource.name)
|
||||
deps.unshift(..._deps)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const external: (string | RegExp)[] = []
|
||||
for (const [, source] of resolvedImportMapSources) {
|
||||
external.push(source.pattern)
|
||||
}
|
||||
config.build.rollupOptions.external = external
|
||||
}
|
||||
},
|
||||
|
||||
generateBundle(_options) {
|
||||
for (const [, source] of resolvedImportMapSources) {
|
||||
if (source.entry) {
|
||||
const moduleFile = join(source.name, source.entry)
|
||||
const sourceFile = join(root, 'node_modules', moduleFile)
|
||||
const targetFile = join(root, 'dist', assetDir, moduleFile)
|
||||
|
||||
importMapEntries[source.name] =
|
||||
'./' + normalizePath(join(assetDir, moduleFile))
|
||||
|
||||
const targetDir = dirname(targetFile)
|
||||
if (!fs.existsSync(targetDir)) {
|
||||
fs.mkdirSync(targetDir, { recursive: true })
|
||||
}
|
||||
fs.copyFileSync(sourceFile, targetFile)
|
||||
}
|
||||
|
||||
if (source.recursiveDependence) {
|
||||
const files = glob.sync(['**/*.{js,mjs}'], {
|
||||
cwd: join(root, 'node_modules', source.name)
|
||||
})
|
||||
|
||||
for (const file of files) {
|
||||
const moduleFile = join(source.name, file)
|
||||
const sourceFile = join(root, 'node_modules', moduleFile)
|
||||
const targetFile = join(root, 'dist', assetDir, moduleFile)
|
||||
|
||||
importMapEntries[normalizePath(join(source.name, dirname(file)))] =
|
||||
'./' + normalizePath(join(assetDir, moduleFile))
|
||||
|
||||
const targetDir = dirname(targetFile)
|
||||
if (!fs.existsSync(targetDir)) {
|
||||
fs.mkdirSync(targetDir, { recursive: true })
|
||||
}
|
||||
fs.copyFileSync(sourceFile, targetFile)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
transformIndexHtml(html) {
|
||||
if (Object.keys(importMapEntries).length === 0) {
|
||||
console.warn(
|
||||
'[ImportMap Plugin] No vendor chunks found to create import map.'
|
||||
)
|
||||
return html
|
||||
}
|
||||
|
||||
const importMap = {
|
||||
imports: importMapEntries
|
||||
}
|
||||
|
||||
const importMapTag: HtmlTagDescriptor = {
|
||||
tag: 'script',
|
||||
attrs: { type: 'importmap' },
|
||||
children: JSON.stringify(importMap, null, 2),
|
||||
injectTo: 'head'
|
||||
}
|
||||
|
||||
return {
|
||||
html,
|
||||
tags: [importMapTag]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,2 +1 @@
|
||||
export { comfyAPIPlugin } from './comfyAPIPlugin'
|
||||
export { generateImportMapPlugin } from './generateImportMapPlugin'
|
||||
|
||||
24
cloud-loader-dropdown.md
Normal file
@@ -0,0 +1,24 @@
|
||||
Fixes loader dropdown placeholder
|
||||
===============================
|
||||
|
||||
Cloud loader dropdowns hydrate via `useAssetWidgetData(nodeType)`, so `dropdownItems` stays empty until the Asset API returns friendly filenames. Meanwhile `modelValue` already holds the saved asset and the watcher at [WidgetSelectDropdown.vue#L215-L227](https://github.com/Comfy-Org/ComfyUI_frontend/blob/main/src/renderer/extensions/vueNodes/widgets/components/WidgetSelectDropdown.vue#L215-L227) only tracks `modelValue`. It runs before assets load, fails to find a match, clears `selectedSet`, and the placeholder persists.
|
||||
|
||||
```ts
|
||||
watch(
|
||||
modelValue,
|
||||
(currentValue) => {
|
||||
if (currentValue === undefined) {
|
||||
selectedSet.value.clear()
|
||||
return
|
||||
}
|
||||
const item = dropdownItems.value.find((item) => item.name === currentValue)
|
||||
if (item) {
|
||||
selectedSet.value.clear()
|
||||
selectedSet.value.add(item.id)
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
```
|
||||
|
||||
Once the API resolves, `dropdownItems` recomputes but nothing resyncs because the watcher never sees that change. Desktop doesn’t hit this because it still reads from `widget.options.values` immediately.
|
||||
@@ -191,6 +191,19 @@ export default defineConfig([
|
||||
'@intlify/vue-i18n/no-raw-text': [
|
||||
'error',
|
||||
{
|
||||
attributes: {
|
||||
'/.+/': [
|
||||
'aria-label',
|
||||
'aria-placeholder',
|
||||
'aria-roledescription',
|
||||
'aria-valuetext',
|
||||
'label',
|
||||
'placeholder',
|
||||
'title',
|
||||
'v-tooltip'
|
||||
],
|
||||
img: ['alt']
|
||||
},
|
||||
// Ignore strings that are:
|
||||
// 1. Less than 2 characters
|
||||
// 2. Only symbols/numbers/whitespace (no letters)
|
||||
@@ -200,24 +213,27 @@ export default defineConfig([
|
||||
ignoreNodes: ['md-icon', 'v-icon', 'pre', 'code', 'script', 'style'],
|
||||
// Brand names and technical terms that shouldn't be translated
|
||||
ignoreText: [
|
||||
'ComfyUI',
|
||||
'GitHub',
|
||||
'OpenAI',
|
||||
'API',
|
||||
'URL',
|
||||
'JSON',
|
||||
'YAML',
|
||||
'GPU',
|
||||
'CPU',
|
||||
'RAM',
|
||||
'GB',
|
||||
'MB',
|
||||
'KB',
|
||||
'ms',
|
||||
'fps',
|
||||
'px',
|
||||
'App Data:',
|
||||
'App Path:'
|
||||
'App Path:',
|
||||
'ComfyUI',
|
||||
'CPU',
|
||||
'fps',
|
||||
'GB',
|
||||
'GitHub',
|
||||
'GPU',
|
||||
'JSON',
|
||||
'KB',
|
||||
'LoRA',
|
||||
'MB',
|
||||
'ms',
|
||||
'OpenAI',
|
||||
'png',
|
||||
'px',
|
||||
'RAM',
|
||||
'URL',
|
||||
'YAML',
|
||||
'1.2 MB'
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
<template>
|
||||
<!-- If user has active subscription, show ComfyQueueButton (which already has FeatureFlaggedRunButton) -->
|
||||
<ComfyQueueButton v-if="isActiveSubscription" />
|
||||
|
||||
<!-- If subscription required but not active, wrap SubscribeToRunButton with feature flag -->
|
||||
<FeatureFlaggedRunButton
|
||||
v-else
|
||||
flag-key="demo-run-button-experiment"
|
||||
:on-click="handleSubscribeClick"
|
||||
>
|
||||
<template #control>
|
||||
<!-- SubscribeToRunButton handles its own click (shows subscription dialog) -->
|
||||
<SubscribeToRunButton />
|
||||
</template>
|
||||
</FeatureFlaggedRunButton>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
|
||||
import ComfyQueueButton from './ComfyQueueButton.vue'
|
||||
import FeatureFlaggedRunButton from './FeatureFlaggedRunButton.vue'
|
||||
import SubscribeToRunButton from '@/platform/cloud/subscription/components/SubscribeToRun.vue'
|
||||
|
||||
// Get subscription status - replace with actual subscription store/composable
|
||||
const isActiveSubscription = computed(() => {
|
||||
// TODO: Replace with actual subscription check
|
||||
// Example: return useSubscriptionStore().isActiveSubscription
|
||||
// For now, this would typically come from a store or composable
|
||||
return false
|
||||
})
|
||||
|
||||
const commandStore = useCommandStore()
|
||||
|
||||
// Handle click for experimental button when SubscribeToRunButton is shown
|
||||
// For experimental variants, trigger queue prompt (same as ComfyQueueButton)
|
||||
// For control, SubscribeToRunButton handles its own click (shows subscription dialog)
|
||||
const handleSubscribeClick = async (e: Event) => {
|
||||
// If this is called from the experimental button, trigger queue prompt
|
||||
// If called from SubscribeToRunButton (control), it will handle its own logic
|
||||
const isShiftPressed = 'shiftKey' in e && e.shiftKey
|
||||
const commandId = isShiftPressed
|
||||
? 'Comfy.QueuePromptFront'
|
||||
: 'Comfy.QueuePrompt'
|
||||
|
||||
await commandStore.execute(commandId, {
|
||||
metadata: {
|
||||
subscribe_to_run: false,
|
||||
trigger_source: 'button'
|
||||
}
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@comfyorg/comfyui-frontend",
|
||||
"private": true,
|
||||
"version": "1.33.8",
|
||||
"version": "1.33.9",
|
||||
"type": "module",
|
||||
"repository": "https://github.com/Comfy-Org/ComfyUI_frontend",
|
||||
"homepage": "https://comfy.org",
|
||||
@@ -164,7 +164,6 @@
|
||||
"es-toolkit": "^1.39.9",
|
||||
"extendable-media-recorder": "^9.2.27",
|
||||
"extendable-media-recorder-wav-encoder": "^7.0.129",
|
||||
"fast-glob": "^3.3.3",
|
||||
"firebase": "catalog:",
|
||||
"fuse.js": "^7.0.0",
|
||||
"glob": "^11.0.3",
|
||||
|
||||
@@ -1329,57 +1329,6 @@ audio.comfy-audio.empty-audio-widget {
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
/* START LOD specific styles */
|
||||
/* LOD styles - Custom CSS avoids 100+ Tailwind selectors that would slow style recalculation when .isLOD toggles */
|
||||
|
||||
.isLOD .lg-node {
|
||||
box-shadow: none;
|
||||
filter: none;
|
||||
backdrop-filter: none;
|
||||
text-shadow: none;
|
||||
mask-image: none;
|
||||
clip-path: none;
|
||||
background-image: none;
|
||||
text-rendering: optimizeSpeed;
|
||||
border-radius: 0;
|
||||
contain: layout style;
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.isLOD .lg-node-header {
|
||||
border-radius: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.isLOD .lg-node-widgets {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.lod-toggle {
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
.isLOD .lod-toggle {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.lod-fallback {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.isLOD .lod-fallback {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.isLOD .image-preview img {
|
||||
image-rendering: pixelated;
|
||||
}
|
||||
|
||||
.isLOD .slot-dot {
|
||||
border-radius: 0;
|
||||
}
|
||||
/* END LOD specific styles */
|
||||
|
||||
/* ===================== Mask Editor Styles ===================== */
|
||||
/* To be migrated to Tailwind later */
|
||||
#maskEditor_brush {
|
||||
|
||||
@@ -75,6 +75,17 @@ export function formatSize(value?: number) {
|
||||
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(2))} ${sizes[i]}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats a commit hash by truncating long (40-char) hashes to 7 chars.
|
||||
* Returns the original string if not a valid full commit hash.
|
||||
*/
|
||||
export function formatCommitHash(value: string): string {
|
||||
if (/^[a-f0-9]{40}$/i.test(value)) {
|
||||
return value.slice(0, 7)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns various filename components.
|
||||
* Example:
|
||||
|
||||
17
pnpm-lock.yaml
generated
@@ -15,15 +15,9 @@ 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
|
||||
@@ -431,9 +425,6 @@ importers:
|
||||
extendable-media-recorder-wav-encoder:
|
||||
specifier: ^7.0.129
|
||||
version: 7.0.129
|
||||
fast-glob:
|
||||
specifier: ^3.3.3
|
||||
version: 3.3.3
|
||||
firebase:
|
||||
specifier: 'catalog:'
|
||||
version: 11.6.0
|
||||
@@ -7877,8 +7868,8 @@ packages:
|
||||
vue-component-type-helpers@3.1.1:
|
||||
resolution: {integrity: sha512-B0kHv7qX6E7+kdc5nsaqjdGZ1KwNKSUQDWGy7XkTYT7wFsOpkEyaJ1Vq79TjwrrtuLRgizrTV7PPuC4rRQo+vw==}
|
||||
|
||||
vue-component-type-helpers@3.1.4:
|
||||
resolution: {integrity: sha512-Uws7Ew1OzTTqHW8ZVl/qLl/HB+jf08M0NdFONbVWAx0N4gMLK8yfZDgeB77hDnBmaigWWEn5qP8T9BG59jIeyQ==}
|
||||
vue-component-type-helpers@3.1.5:
|
||||
resolution: {integrity: sha512-7V3yJuNWW7/1jxCcI1CswnpDsvs02Qcx/N43LkV+ZqhLj2PKj50slUflHAroNkN4UWiYfzMUUUXiNuv9khmSpQ==}
|
||||
|
||||
vue-demi@0.14.10:
|
||||
resolution: {integrity: sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==}
|
||||
@@ -10681,7 +10672,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.4
|
||||
vue-component-type-helpers: 3.1.5
|
||||
|
||||
'@swc/helpers@0.5.17':
|
||||
dependencies:
|
||||
@@ -16448,7 +16439,7 @@ snapshots:
|
||||
|
||||
vue-component-type-helpers@3.1.1: {}
|
||||
|
||||
vue-component-type-helpers@3.1.4: {}
|
||||
vue-component-type-helpers@3.1.5: {}
|
||||
|
||||
vue-demi@0.14.10(vue@3.5.13(typescript@5.9.2)):
|
||||
dependencies:
|
||||
|
||||
@@ -1 +1,9 @@
|
||||
Thanks to OpenArt (https://openart.ai) for providing the sorted-custom-node-map data, captured in September 2024.
|
||||
Node usage data merged from two sources:
|
||||
- Mixpanel "app:node_search_result_selected" events (Nov 2025): 1,112 nodes, 46,514 selections
|
||||
Reflects actual user search behavior - what users choose when searching.
|
||||
- OpenArt workflow data (Sept 2024): 2,600 nodes, 118,676 uses
|
||||
Reflects overall popularity - what's used in workflows.
|
||||
|
||||
Merge strategy: New data overwrites old for 514 overlapping nodes. Old data
|
||||
normalized by 2.55x scale factor to match new data. Total: 3,198 nodes.
|
||||
Search-selected nodes prioritized in ranking.
|
||||
9
public/assets/images/civitai.svg
Normal file
|
After Width: | Height: | Size: 1.5 MiB |
@@ -1,5 +1,10 @@
|
||||
<template>
|
||||
<div v-if="!workspaceStore.focusMode" class="ml-1 flex gap-x-0.5 pt-1">
|
||||
<div
|
||||
v-if="!workspaceStore.focusMode"
|
||||
class="ml-1 flex gap-x-0.5 pt-1"
|
||||
@mouseenter="isTopMenuHovered = true"
|
||||
@mouseleave="isTopMenuHovered = false"
|
||||
>
|
||||
<div class="min-w-0 flex-1">
|
||||
<SubgraphBreadcrumb />
|
||||
</div>
|
||||
@@ -40,7 +45,10 @@
|
||||
<CurrentUserButton v-if="isLoggedIn" class="shrink-0" />
|
||||
<LoginButton v-else-if="isDesktop" />
|
||||
</div>
|
||||
<QueueProgressOverlay v-model:expanded="isQueueOverlayExpanded" />
|
||||
<QueueProgressOverlay
|
||||
v-model:expanded="isQueueOverlayExpanded"
|
||||
:menu-hovered="isTopMenuHovered"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -69,6 +77,7 @@ const isDesktop = isElectron()
|
||||
const { t } = useI18n()
|
||||
const isQueueOverlayExpanded = ref(false)
|
||||
const queueStore = useQueueStore()
|
||||
const isTopMenuHovered = ref(false)
|
||||
const queuedCount = computed(() => queueStore.pendingTasks.length)
|
||||
const queueHistoryTooltipConfig = computed(() =>
|
||||
buildTooltipConfig(t('sideToolbar.queueProgressOverlay.viewJobHistory'))
|
||||
|
||||
@@ -257,7 +257,7 @@ watch(isDragging, (dragging) => {
|
||||
})
|
||||
const actionbarClass = computed(() =>
|
||||
cn(
|
||||
'w-[265px] border-dashed border-blue-500 opacity-80',
|
||||
'w-[200px] border-dashed border-blue-500 opacity-80',
|
||||
'm-1.5 flex items-center justify-center self-stretch',
|
||||
'rounded-md before:w-50 before:-ml-50 before:h-full',
|
||||
'pointer-events-auto',
|
||||
|
||||
@@ -1,35 +1,42 @@
|
||||
<template>
|
||||
<div class="queue-button-group flex">
|
||||
<SplitButton
|
||||
v-tooltip.bottom="{
|
||||
value: queueButtonTooltip,
|
||||
showDelay: 600
|
||||
}"
|
||||
class="comfyui-queue-button"
|
||||
:label="String(activeQueueModeMenuItem?.label ?? '')"
|
||||
severity="primary"
|
||||
size="small"
|
||||
:model="queueModeMenuItems"
|
||||
data-testid="queue-button"
|
||||
@click="queuePrompt"
|
||||
<FeatureFlaggedRunButton
|
||||
flag-key="demo-run-button-experiment"
|
||||
:on-click="queuePrompt"
|
||||
>
|
||||
<template #icon>
|
||||
<i :class="iconClass" />
|
||||
</template>
|
||||
<template #item="{ item }">
|
||||
<Button
|
||||
v-tooltip="{
|
||||
value: item.tooltip,
|
||||
<template #control>
|
||||
<SplitButton
|
||||
v-tooltip.bottom="{
|
||||
value: queueButtonTooltip,
|
||||
showDelay: 600
|
||||
}"
|
||||
:label="String(item.label ?? '')"
|
||||
:icon="item.icon"
|
||||
:severity="item.key === queueMode ? 'primary' : 'secondary'"
|
||||
class="comfyui-queue-button"
|
||||
:label="String(activeQueueModeMenuItem?.label ?? '')"
|
||||
severity="primary"
|
||||
size="small"
|
||||
text
|
||||
/>
|
||||
:model="queueModeMenuItems"
|
||||
data-testid="queue-button"
|
||||
@click="queuePrompt"
|
||||
>
|
||||
<template #icon>
|
||||
<i :class="iconClass" />
|
||||
</template>
|
||||
<template #item="{ item }">
|
||||
<Button
|
||||
v-tooltip="{
|
||||
value: item.tooltip,
|
||||
showDelay: 600
|
||||
}"
|
||||
:label="String(item.label ?? '')"
|
||||
:icon="item.icon"
|
||||
:severity="item.key === queueMode ? 'primary' : 'secondary'"
|
||||
size="small"
|
||||
text
|
||||
/>
|
||||
</template>
|
||||
</SplitButton>
|
||||
</template>
|
||||
</SplitButton>
|
||||
</FeatureFlaggedRunButton>
|
||||
<BatchCountEdit />
|
||||
</div>
|
||||
</template>
|
||||
@@ -44,17 +51,23 @@ import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import { useNodeDefStore } from '@/stores/nodeDefStore'
|
||||
import { useQueueSettingsStore } from '@/stores/queueStore'
|
||||
import { useWorkspaceStore } from '@/stores/workspaceStore'
|
||||
import { useMissingNodes } from '@/workbench/extensions/manager/composables/nodePack/useMissingNodes'
|
||||
import { graphHasMissingNodes } from '@/workbench/extensions/manager/utils/graphHasMissingNodes'
|
||||
|
||||
import BatchCountEdit from '../BatchCountEdit.vue'
|
||||
import FeatureFlaggedRunButton from './FeatureFlaggedRunButton.vue'
|
||||
|
||||
const workspaceStore = useWorkspaceStore()
|
||||
const { mode: queueMode, batchCount } = storeToRefs(useQueueSettingsStore())
|
||||
|
||||
const { hasMissingNodes } = useMissingNodes()
|
||||
const nodeDefStore = useNodeDefStore()
|
||||
const hasMissingNodes = computed(() =>
|
||||
graphHasMissingNodes(app.graph, nodeDefStore.nodeDefsByName)
|
||||
)
|
||||
|
||||
const { t } = useI18n()
|
||||
const queueModeMenuItemLookup = computed(() => {
|
||||
|
||||
@@ -0,0 +1,252 @@
|
||||
<template>
|
||||
<div
|
||||
:class="buttonClasses"
|
||||
:style="buttonStyles"
|
||||
class="experimental-run-button relative overflow-hidden transition-all duration-300"
|
||||
@click="handleClick"
|
||||
>
|
||||
<!-- Animated background for gradient variant -->
|
||||
<div
|
||||
v-if="variantName === 'bold-gradient'"
|
||||
class="absolute inset-0 animate-gradient bg-gradient-to-r from-purple-500 via-pink-500 to-orange-500 opacity-90"
|
||||
/>
|
||||
|
||||
<!-- Pulsing animation for animated variant -->
|
||||
<div
|
||||
v-if="variantName === 'animated'"
|
||||
class="absolute inset-0 animate-pulse bg-primary-background opacity-20"
|
||||
/>
|
||||
|
||||
<!-- Sparkle effect for playful variant -->
|
||||
<div
|
||||
v-if="variantName === 'playful'"
|
||||
class="absolute inset-0 overflow-hidden"
|
||||
>
|
||||
<i
|
||||
v-for="i in 3"
|
||||
:key="i"
|
||||
:class="sparkleClasses[i - 1]"
|
||||
class="absolute animate-sparkle text-yellow-300"
|
||||
:style="sparkleStyles[i - 1]"
|
||||
>
|
||||
✨
|
||||
</i>
|
||||
</div>
|
||||
|
||||
<!-- Button content -->
|
||||
<div class="relative z-10 flex items-center justify-center gap-2">
|
||||
<i :class="iconClass" class="text-lg" />
|
||||
<span :class="labelClasses" :style="labelStyles" class="font-semibold">
|
||||
{{ label }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
import type { FeatureFlagVariant } from '@/composables/useFeatureFlags'
|
||||
|
||||
const props = defineProps<{
|
||||
variant: FeatureFlagVariant
|
||||
onClick: () => void
|
||||
}>()
|
||||
|
||||
const variantName = computed(() => props.variant.variant)
|
||||
const payload = computed(() => props.variant.payload || {})
|
||||
|
||||
// Extract styling from payload with defaults
|
||||
const backgroundColor = computed(
|
||||
() => (payload.value.backgroundColor as string) || getDefaultColor()
|
||||
)
|
||||
const textColor = computed(
|
||||
() => (payload.value.textColor as string) || getDefaultTextColor()
|
||||
)
|
||||
const borderRadius = computed(
|
||||
() => (payload.value.borderRadius as string) || getDefaultBorderRadius()
|
||||
)
|
||||
const icon = computed(() => (payload.value.icon as string) || getDefaultIcon())
|
||||
const label = computed(() => (payload.value.label as string) || 'Run')
|
||||
const padding = computed(() => (payload.value.padding as string) || 'px-4 py-2')
|
||||
|
||||
// Size matching - should match PrimeVue small button size
|
||||
const buttonSize = computed(() => {
|
||||
return 'text-sm' // Match PrimeVue small button
|
||||
})
|
||||
|
||||
function getDefaultColor(): string {
|
||||
switch (variantName.value) {
|
||||
case 'bold-gradient':
|
||||
return 'transparent' // Gradient overlay handles it
|
||||
case 'animated':
|
||||
return 'bg-primary-background'
|
||||
case 'playful':
|
||||
return 'bg-gradient-to-br from-yellow-400 to-orange-500'
|
||||
case 'minimal':
|
||||
return 'bg-white border-2 border-gray-300'
|
||||
default:
|
||||
return 'bg-primary-background'
|
||||
}
|
||||
}
|
||||
|
||||
function getDefaultTextColor(): string {
|
||||
switch (variantName.value) {
|
||||
case 'bold-gradient':
|
||||
return 'text-white'
|
||||
case 'animated':
|
||||
return 'text-white'
|
||||
case 'playful':
|
||||
return 'text-white'
|
||||
case 'minimal':
|
||||
return 'text-gray-800'
|
||||
default:
|
||||
return 'text-white'
|
||||
}
|
||||
}
|
||||
|
||||
function getDefaultBorderRadius(): string {
|
||||
switch (variantName.value) {
|
||||
case 'bold-gradient':
|
||||
return 'rounded-xl'
|
||||
case 'animated':
|
||||
return 'rounded-full'
|
||||
case 'playful':
|
||||
return 'rounded-2xl'
|
||||
case 'minimal':
|
||||
return 'rounded-md'
|
||||
default:
|
||||
return 'rounded-lg'
|
||||
}
|
||||
}
|
||||
|
||||
function getDefaultIcon(): string {
|
||||
switch (variantName.value) {
|
||||
case 'bold-gradient':
|
||||
return 'icon-[lucide--zap]'
|
||||
case 'animated':
|
||||
return 'icon-[lucide--rocket]'
|
||||
case 'playful':
|
||||
return 'icon-[lucide--sparkles]'
|
||||
case 'minimal':
|
||||
return 'icon-[lucide--play]'
|
||||
default:
|
||||
return 'icon-[lucide--play]'
|
||||
}
|
||||
}
|
||||
|
||||
const buttonClasses = computed(() => {
|
||||
const base = [
|
||||
'cursor-pointer',
|
||||
'select-none',
|
||||
'flex',
|
||||
'items-center',
|
||||
'justify-center',
|
||||
padding.value,
|
||||
borderRadius.value,
|
||||
'shadow-lg',
|
||||
'hover:scale-105',
|
||||
'active:scale-95',
|
||||
'transition-transform',
|
||||
buttonSize.value
|
||||
]
|
||||
|
||||
// Add variant-specific classes
|
||||
if (variantName.value === 'bold-gradient') {
|
||||
base.push('text-white', 'font-bold')
|
||||
} else if (variantName.value === 'animated') {
|
||||
base.push('text-white', 'font-bold', 'hover:shadow-2xl')
|
||||
} else if (variantName.value === 'playful') {
|
||||
base.push('text-white', 'font-bold', 'hover:rotate-1')
|
||||
} else if (variantName.value === 'minimal') {
|
||||
base.push('bg-white', 'text-gray-800', 'hover:bg-gray-50')
|
||||
} else {
|
||||
base.push(backgroundColor.value, textColor.value)
|
||||
}
|
||||
|
||||
return base.join(' ')
|
||||
})
|
||||
|
||||
const buttonStyles = computed(() => {
|
||||
const styles: Record<string, string> = {}
|
||||
|
||||
// Apply custom styles from payload
|
||||
if (payload.value.backgroundColor && variantName.value !== 'bold-gradient') {
|
||||
styles.backgroundColor = backgroundColor.value
|
||||
}
|
||||
if (payload.value.textColor) {
|
||||
styles.color = textColor.value
|
||||
}
|
||||
if (payload.value.borderRadius && !borderRadius.value.includes('rounded')) {
|
||||
styles.borderRadius = borderRadius.value
|
||||
}
|
||||
|
||||
return styles
|
||||
})
|
||||
|
||||
const iconClass = computed(() => icon.value)
|
||||
|
||||
// Text color handling - can be a CSS class or a color value
|
||||
const labelClasses = computed(() => {
|
||||
// If textColor is a Tailwind class, return it; otherwise it's handled by inline styles
|
||||
if (textColor.value.startsWith('text-')) {
|
||||
return textColor.value
|
||||
}
|
||||
return ''
|
||||
})
|
||||
|
||||
const labelStyles = computed(() => {
|
||||
const styles: Record<string, string> = {}
|
||||
// If textColor is not a Tailwind class, use it as a color value
|
||||
if (!textColor.value.startsWith('text-')) {
|
||||
styles.color = textColor.value
|
||||
}
|
||||
return styles
|
||||
})
|
||||
|
||||
// Sparkle animation positions for playful variant
|
||||
const sparkleClasses = ['top-2 left-4', 'top-4 right-6', 'bottom-2 left-1/2']
|
||||
|
||||
const sparkleStyles = [
|
||||
{ animationDelay: '0s', animationDuration: '2s' },
|
||||
{ animationDelay: '0.5s', animationDuration: '2.5s' },
|
||||
{ animationDelay: '1s', animationDuration: '3s' }
|
||||
]
|
||||
|
||||
function handleClick() {
|
||||
props.onClick()
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@keyframes gradient {
|
||||
0%,
|
||||
100% {
|
||||
background-position: 0% 50%;
|
||||
}
|
||||
50% {
|
||||
background-position: 100% 50%;
|
||||
}
|
||||
}
|
||||
|
||||
.animate-gradient {
|
||||
background-size: 200% 200%;
|
||||
animation: gradient 3s ease infinite;
|
||||
}
|
||||
|
||||
@keyframes sparkle {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 0;
|
||||
transform: scale(0) rotate(0deg);
|
||||
}
|
||||
50% {
|
||||
opacity: 1;
|
||||
transform: scale(1) rotate(180deg);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-sparkle {
|
||||
animation: sparkle 2s ease-in-out infinite;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,58 @@
|
||||
<template>
|
||||
<div class="feature-flagged-run-button">
|
||||
<!-- Control: Original button -->
|
||||
<slot v-if="!isExperimentActive" name="control" />
|
||||
|
||||
<!-- Experiment: Experimental button -->
|
||||
<ExperimentalRunButton
|
||||
v-else-if="variant"
|
||||
:variant="variant"
|
||||
:on-click="handleClick"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { useFeatureFlags } from '@/composables/useFeatureFlags'
|
||||
import type { FeatureFlagVariant } from '@/composables/useFeatureFlags'
|
||||
|
||||
import ExperimentalRunButton from './ExperimentalRunButton.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
flagKey?: string
|
||||
onClick: (e: Event) => void | Promise<void>
|
||||
}>()
|
||||
|
||||
const flagKey = computed(() => props.flagKey || 'demo-run-button-experiment')
|
||||
|
||||
const { featureFlag } = useFeatureFlags()
|
||||
const flagValue = featureFlag<FeatureFlagVariant | boolean | null>(
|
||||
flagKey.value,
|
||||
false
|
||||
)
|
||||
|
||||
const variant = computed<FeatureFlagVariant | null>(() => {
|
||||
const value = flagValue.value
|
||||
if (
|
||||
typeof value === 'object' &&
|
||||
value !== null &&
|
||||
'variant' in value &&
|
||||
typeof (value as FeatureFlagVariant).variant === 'string'
|
||||
) {
|
||||
return value as FeatureFlagVariant
|
||||
}
|
||||
return null
|
||||
})
|
||||
|
||||
const isExperimentActive = computed(() => {
|
||||
return variant.value !== null
|
||||
})
|
||||
|
||||
const handleClick = () => {
|
||||
// Create a synthetic event for the onClick handler
|
||||
const syntheticEvent = new Event('click') as Event
|
||||
props.onClick(syntheticEvent)
|
||||
}
|
||||
</script>
|
||||
@@ -64,11 +64,13 @@ import {
|
||||
ComfyWorkflow,
|
||||
useWorkflowStore
|
||||
} from '@/platform/workflow/management/stores/workflowStore'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import { useNodeDefStore } from '@/stores/nodeDefStore'
|
||||
import { useSubgraphNavigationStore } from '@/stores/subgraphNavigationStore'
|
||||
import { appendJsonExt } from '@/utils/formatUtil'
|
||||
import { useMissingNodes } from '@/workbench/extensions/manager/composables/nodePack/useMissingNodes'
|
||||
import { graphHasMissingNodes } from '@/workbench/extensions/manager/utils/graphHasMissingNodes'
|
||||
|
||||
interface Props {
|
||||
item: MenuItem
|
||||
@@ -79,7 +81,10 @@ const props = withDefaults(defineProps<Props>(), {
|
||||
isActive: false
|
||||
})
|
||||
|
||||
const { hasMissingNodes } = useMissingNodes()
|
||||
const nodeDefStore = useNodeDefStore()
|
||||
const hasMissingNodes = computed(() =>
|
||||
graphHasMissingNodes(app.graph, nodeDefStore.nodeDefsByName)
|
||||
)
|
||||
|
||||
const { t } = useI18n()
|
||||
const menu = ref<InstanceType<typeof Menu> & MenuState>()
|
||||
|
||||
@@ -9,29 +9,31 @@
|
||||
<div class="font-medium">
|
||||
{{ col.header }}
|
||||
</div>
|
||||
<div>{{ formatValue(systemInfo[col.field], col.field) }}</div>
|
||||
<div>{{ getDisplayValue(col) }}</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Divider />
|
||||
<template v-if="hasDevices">
|
||||
<Divider />
|
||||
|
||||
<div>
|
||||
<h2 class="mb-4 text-2xl font-semibold">
|
||||
{{ $t('g.devices') }}
|
||||
</h2>
|
||||
<TabView v-if="props.stats.devices.length > 1">
|
||||
<TabPanel
|
||||
v-for="device in props.stats.devices"
|
||||
:key="device.index"
|
||||
:header="device.name"
|
||||
:value="device.index"
|
||||
>
|
||||
<DeviceInfo :device="device" />
|
||||
</TabPanel>
|
||||
</TabView>
|
||||
<DeviceInfo v-else :device="props.stats.devices[0]" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 class="mb-4 text-2xl font-semibold">
|
||||
{{ $t('g.devices') }}
|
||||
</h2>
|
||||
<TabView v-if="props.stats.devices.length > 1">
|
||||
<TabPanel
|
||||
v-for="device in props.stats.devices"
|
||||
:key="device.index"
|
||||
:header="device.name"
|
||||
:value="device.index"
|
||||
>
|
||||
<DeviceInfo :device="device" />
|
||||
</TabPanel>
|
||||
</TabView>
|
||||
<DeviceInfo v-else :device="props.stats.devices[0]" />
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -42,8 +44,9 @@ import TabView from 'primevue/tabview'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import DeviceInfo from '@/components/common/DeviceInfo.vue'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import type { SystemStats } from '@/schemas/apiSchema'
|
||||
import { formatSize } from '@/utils/formatUtil'
|
||||
import { formatCommitHash, formatSize } from '@/utils/formatUtil'
|
||||
|
||||
const props = defineProps<{
|
||||
stats: SystemStats
|
||||
@@ -54,20 +57,53 @@ const systemInfo = computed(() => ({
|
||||
argv: props.stats.system.argv.join(' ')
|
||||
}))
|
||||
|
||||
const systemColumns: { field: keyof SystemStats['system']; header: string }[] =
|
||||
[
|
||||
{ field: 'os', header: 'OS' },
|
||||
{ field: 'python_version', header: 'Python Version' },
|
||||
{ field: 'embedded_python', header: 'Embedded Python' },
|
||||
{ field: 'pytorch_version', header: 'Pytorch Version' },
|
||||
{ field: 'argv', header: 'Arguments' },
|
||||
{ field: 'ram_total', header: 'RAM Total' },
|
||||
{ field: 'ram_free', header: 'RAM Free' }
|
||||
]
|
||||
const hasDevices = computed(() => props.stats.devices.length > 0)
|
||||
|
||||
const formatValue = (value: any, field: string) => {
|
||||
if (['ram_total', 'ram_free'].includes(field)) {
|
||||
return formatSize(value)
|
||||
type SystemInfoKey = keyof SystemStats['system']
|
||||
|
||||
type ColumnDef = {
|
||||
field: SystemInfoKey
|
||||
header: string
|
||||
format?: (value: string) => string
|
||||
formatNumber?: (value: number) => string
|
||||
}
|
||||
|
||||
/** Columns for local distribution */
|
||||
const localColumns: ColumnDef[] = [
|
||||
{ field: 'os', header: 'OS' },
|
||||
{ field: 'python_version', header: 'Python Version' },
|
||||
{ field: 'embedded_python', header: 'Embedded Python' },
|
||||
{ field: 'pytorch_version', header: 'Pytorch Version' },
|
||||
{ field: 'argv', header: 'Arguments' },
|
||||
{ field: 'ram_total', header: 'RAM Total', formatNumber: formatSize },
|
||||
{ field: 'ram_free', header: 'RAM Free', formatNumber: formatSize }
|
||||
]
|
||||
|
||||
/** Columns for cloud distribution */
|
||||
const cloudColumns: ColumnDef[] = [
|
||||
{ field: 'cloud_version', header: 'Cloud Version' },
|
||||
{
|
||||
field: 'comfyui_version',
|
||||
header: 'ComfyUI Version',
|
||||
format: formatCommitHash
|
||||
},
|
||||
{
|
||||
field: 'comfyui_frontend_version',
|
||||
header: 'Frontend Version',
|
||||
format: formatCommitHash
|
||||
},
|
||||
{ field: 'workflow_templates_version', header: 'Templates Version' }
|
||||
]
|
||||
|
||||
const systemColumns = computed(() => (isCloud ? cloudColumns : localColumns))
|
||||
|
||||
const getDisplayValue = (column: ColumnDef) => {
|
||||
const value = systemInfo.value[column.field]
|
||||
if (column.formatNumber && typeof value === 'number') {
|
||||
return column.formatNumber(value)
|
||||
}
|
||||
if (column.format && typeof value === 'string') {
|
||||
return column.format(value)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
@@ -152,7 +152,7 @@ const {
|
||||
popoverMaxWidth?: string
|
||||
}>()
|
||||
|
||||
const selectedItem = defineModel<string | null>({ required: true })
|
||||
const selectedItem = defineModel<string | undefined>({ required: true })
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
|
||||
@@ -80,7 +80,7 @@
|
||||
/>
|
||||
|
||||
<SliderControl
|
||||
label="Stepsize"
|
||||
:label="$t('maskEditor.stepSize')"
|
||||
:min="1"
|
||||
:max="100"
|
||||
:step="1"
|
||||
|
||||
@@ -25,7 +25,11 @@
|
||||
class="inline-block h-6 w-6 overflow-hidden rounded-[6px] border-0 bg-secondary-background"
|
||||
:style="{ marginLeft: idx === 0 ? '0' : '-12px' }"
|
||||
>
|
||||
<img :src="url" alt="preview" class="h-full w-full object-cover" />
|
||||
<img
|
||||
:src="url"
|
||||
:alt="$t('sideToolbar.queueProgressOverlay.preview')"
|
||||
class="h-full w-full object-cover"
|
||||
/>
|
||||
</span>
|
||||
</span>
|
||||
|
||||
|
||||
@@ -47,7 +47,7 @@
|
||||
v-tooltip.top="cancelJobTooltip"
|
||||
type="secondary"
|
||||
size="sm"
|
||||
class="size-6 bg-secondary-background hover:bg-destructive-background"
|
||||
class="size-6 bg-destructive-background hover:bg-destructive-background-hover"
|
||||
:aria-label="t('sideToolbar.queueProgressOverlay.interruptAll')"
|
||||
@click="$emit('interruptAll')"
|
||||
>
|
||||
|
||||
@@ -60,7 +60,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, ref } from 'vue'
|
||||
import { computed, nextTick, ref, withDefaults } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import QueueOverlayActive from '@/components/queue/QueueOverlayActive.vue'
|
||||
@@ -85,9 +85,15 @@ import { useSidebarTabStore } from '@/stores/workspace/sidebarTabStore'
|
||||
|
||||
type OverlayState = 'hidden' | 'empty' | 'active' | 'expanded'
|
||||
|
||||
const props = defineProps<{
|
||||
expanded?: boolean
|
||||
}>()
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
expanded?: boolean
|
||||
menuHovered?: boolean
|
||||
}>(),
|
||||
{
|
||||
menuHovered: false
|
||||
}
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:expanded', value: boolean): void
|
||||
@@ -110,6 +116,7 @@ const {
|
||||
currentNodeProgressStyle
|
||||
} = useQueueProgress()
|
||||
const isHovered = ref(false)
|
||||
const isOverlayHovered = computed(() => isHovered.value || props.menuHovered)
|
||||
const internalExpanded = ref(false)
|
||||
const isExpanded = computed({
|
||||
get: () =>
|
||||
@@ -142,7 +149,7 @@ const showBackground = computed(
|
||||
() =>
|
||||
overlayState.value === 'expanded' ||
|
||||
overlayState.value === 'empty' ||
|
||||
(overlayState.value === 'active' && isHovered.value)
|
||||
(overlayState.value === 'active' && isOverlayHovered.value)
|
||||
)
|
||||
|
||||
const isVisible = computed(() => overlayState.value !== 'hidden')
|
||||
@@ -156,7 +163,7 @@ const containerClass = computed(() =>
|
||||
const bottomRowClass = computed(
|
||||
() =>
|
||||
`flex items-center justify-end gap-4 transition-opacity duration-200 ease-in-out ${
|
||||
overlayState.value === 'active' && isHovered.value
|
||||
overlayState.value === 'active' && isOverlayHovered.value
|
||||
? 'opacity-100 pointer-events-auto'
|
||||
: 'opacity-0 pointer-events-none'
|
||||
}`
|
||||
|
||||
@@ -82,7 +82,10 @@
|
||||
:src="iconImageUrl"
|
||||
class="h-full w-full object-cover"
|
||||
/>
|
||||
<i v-else :class="[iconClass, 'size-4']" />
|
||||
<i
|
||||
v-else
|
||||
:class="cn(iconClass, 'size-4', shouldSpin && 'animate-spin')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -93,6 +96,23 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!--
|
||||
TODO: Refactor action buttons to use a declarative config system.
|
||||
|
||||
Instead of hardcoding button visibility logic in the template, define an array of
|
||||
action button configs with properties like:
|
||||
- icon, label, action, tooltip
|
||||
- visibleStates: JobState[] (which job states show this button)
|
||||
- alwaysVisible: boolean (show without hover)
|
||||
- destructive: boolean (use destructive styling)
|
||||
|
||||
Then render buttons in two groups:
|
||||
1. Always-visible buttons (outside Transition)
|
||||
2. Hover-only buttons (inside Transition)
|
||||
|
||||
This would eliminate the current duplication where the cancel button exists
|
||||
both outside (for running) and inside (for pending) the Transition.
|
||||
-->
|
||||
<div class="relative z-[1] flex items-center gap-2 text-text-secondary">
|
||||
<Transition
|
||||
mode="out-in"
|
||||
@@ -113,18 +133,22 @@
|
||||
v-tooltip.top="deleteTooltipConfig"
|
||||
type="transparent"
|
||||
size="sm"
|
||||
class="h-6 transform gap-1 rounded bg-modal-card-button-surface px-1 py-0 text-text-primary transition duration-150 ease-in-out hover:-translate-y-px hover:bg-destructive-background hover:opacity-95"
|
||||
class="size-6 transform gap-1 rounded bg-destructive-background text-text-primary transition duration-150 ease-in-out hover:-translate-y-px hover:bg-destructive-background-hover hover:opacity-95"
|
||||
:aria-label="t('g.delete')"
|
||||
@click.stop="emit('delete')"
|
||||
>
|
||||
<i class="icon-[lucide--trash-2] size-4" />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
v-else-if="props.state !== 'completed' && computedShowClear"
|
||||
v-else-if="
|
||||
props.state !== 'completed' &&
|
||||
props.state !== 'running' &&
|
||||
computedShowClear
|
||||
"
|
||||
v-tooltip.top="cancelTooltipConfig"
|
||||
type="transparent"
|
||||
size="sm"
|
||||
class="h-6 transform gap-1 rounded bg-modal-card-button-surface px-1 py-0 text-text-primary transition duration-150 ease-in-out hover:-translate-y-px hover:bg-destructive-background hover:opacity-95"
|
||||
class="size-6 transform gap-1 rounded bg-destructive-background text-text-primary transition duration-150 ease-in-out hover:-translate-y-px hover:bg-destructive-background-hover hover:opacity-95"
|
||||
:aria-label="t('g.cancel')"
|
||||
@click.stop="emit('cancel')"
|
||||
>
|
||||
@@ -143,17 +167,33 @@
|
||||
v-tooltip.top="moreTooltipConfig"
|
||||
type="transparent"
|
||||
size="sm"
|
||||
class="h-6 transform gap-1 rounded bg-modal-card-button-surface px-1 py-0 text-text-primary transition duration-150 ease-in-out hover:-translate-y-px hover:opacity-95"
|
||||
class="size-6 transform gap-1 rounded bg-modal-card-button-surface text-text-primary transition duration-150 ease-in-out hover:-translate-y-px hover:opacity-95"
|
||||
:aria-label="t('g.more')"
|
||||
@click.stop="emit('menu', $event)"
|
||||
>
|
||||
<i class="icon-[lucide--more-horizontal] size-4" />
|
||||
</IconButton>
|
||||
</div>
|
||||
<div v-else key="secondary" class="pr-2">
|
||||
<div
|
||||
v-else-if="props.state !== 'running'"
|
||||
key="secondary"
|
||||
class="pr-2"
|
||||
>
|
||||
<slot name="secondary">{{ props.rightText }}</slot>
|
||||
</div>
|
||||
</Transition>
|
||||
<!-- Running job cancel button - always visible -->
|
||||
<IconButton
|
||||
v-if="props.state === 'running' && computedShowClear"
|
||||
v-tooltip.top="cancelTooltipConfig"
|
||||
type="transparent"
|
||||
size="sm"
|
||||
class="size-6 transform gap-1 rounded bg-destructive-background text-text-primary transition duration-150 ease-in-out hover:-translate-y-px hover:bg-destructive-background-hover hover:opacity-95"
|
||||
:aria-label="t('g.cancel')"
|
||||
@click.stop="emit('cancel')"
|
||||
>
|
||||
<i class="icon-[lucide--x] size-4" />
|
||||
</IconButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -170,6 +210,7 @@ import QueueAssetPreview from '@/components/queue/job/QueueAssetPreview.vue'
|
||||
import { buildTooltipConfig } from '@/composables/useTooltipConfig'
|
||||
import type { JobState } from '@/types/queue'
|
||||
import { iconForJobState } from '@/utils/queueDisplay'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
@@ -302,6 +343,13 @@ const iconClass = computed(() => {
|
||||
return iconForJobState(props.state)
|
||||
})
|
||||
|
||||
const shouldSpin = computed(
|
||||
() =>
|
||||
props.state === 'pending' &&
|
||||
iconClass.value === iconForJobState('pending') &&
|
||||
!props.iconImageUrl
|
||||
)
|
||||
|
||||
const computedShowClear = computed(() => {
|
||||
if (props.showClear !== undefined) return props.showClear
|
||||
return props.state !== 'completed'
|
||||
|
||||
@@ -73,6 +73,7 @@
|
||||
@click.stop="handleNodes2ToggleClick"
|
||||
>
|
||||
<span class="p-menubar-item-label text-nowrap">{{ item.label }}</span>
|
||||
<Tag severity="info" class="ml-2 text-xs">{{ $t('g.beta') }}</Tag>
|
||||
<ToggleSwitch
|
||||
v-model="nodes2Enabled"
|
||||
class="ml-4"
|
||||
@@ -101,6 +102,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { MenuItem } from 'primevue/menuitem'
|
||||
import Tag from 'primevue/tag'
|
||||
import TieredMenu from 'primevue/tieredmenu'
|
||||
import type { TieredMenuMethods, TieredMenuState } from 'primevue/tieredmenu'
|
||||
import ToggleSwitch from 'primevue/toggleswitch'
|
||||
|
||||
@@ -3,9 +3,12 @@
|
||||
v-if="showVueNodesBanner"
|
||||
class="pointer-events-auto relative w-full h-10 bg-gradient-to-r from-blue-600 to-blue-700 flex items-center justify-center px-4"
|
||||
>
|
||||
<div class="flex items-center">
|
||||
<div class="flex items-center text-sm">
|
||||
<i class="icon-[lucide--rocket]"></i>
|
||||
<span class="pl-2 text-sm">{{ $t('vueNodesBanner.message') }}</span>
|
||||
<span class="pl-2">{{ $t('vueNodesBanner.title') }}</span>
|
||||
<span class="pl-1.5 hidden md:inline">{{
|
||||
$t('vueNodesBanner.desc')
|
||||
}}</span>
|
||||
<Button
|
||||
class="cursor-pointer bg-transparent rounded h-7 px-3 border border-white text-white ml-4 text-xs"
|
||||
@click="handleTryItOut"
|
||||
@@ -63,7 +66,7 @@ const handleTryItOut = async (): Promise<void> => {
|
||||
try {
|
||||
await settingStore.set('Comfy.VueNodes.Enabled', true)
|
||||
} catch (error) {
|
||||
console.error('Failed to enable Vue nodes:', error)
|
||||
console.error('Failed to enable Nodes 2.0:', error)
|
||||
} finally {
|
||||
handleDismiss()
|
||||
}
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
<template #default="{ close }">
|
||||
<IconTextButton
|
||||
type="secondary"
|
||||
label="Settings"
|
||||
:label="$t('g.settings')"
|
||||
@click="
|
||||
() => {
|
||||
close()
|
||||
@@ -43,7 +43,7 @@
|
||||
</IconTextButton>
|
||||
<IconTextButton
|
||||
type="primary"
|
||||
label="Profile"
|
||||
:label="$t('g.profile')"
|
||||
@click="
|
||||
() => {
|
||||
close()
|
||||
@@ -65,7 +65,7 @@
|
||||
v-model="selectedFrameworks"
|
||||
v-model:search-query="searchText"
|
||||
class="w-[250px]"
|
||||
label="Select Frameworks"
|
||||
:label="$t('assetBrowser.selectFrameworks')"
|
||||
:options="frameworkOptions"
|
||||
:show-search-box="true"
|
||||
:show-selected-count="true"
|
||||
@@ -73,12 +73,12 @@
|
||||
/>
|
||||
<MultiSelect
|
||||
v-model="selectedProjects"
|
||||
label="Select Projects"
|
||||
:label="$t('assetBrowser.selectProjects')"
|
||||
:options="projectOptions"
|
||||
/>
|
||||
<SingleSelect
|
||||
v-model="selectedSort"
|
||||
label="Sorting Type"
|
||||
:label="$t('assetBrowser.sortingType')"
|
||||
:options="sortOptions"
|
||||
class="w-[135px]"
|
||||
>
|
||||
|
||||
@@ -3,7 +3,6 @@ import { shallowRef, watch } from 'vue'
|
||||
|
||||
import { useGraphNodeManager } from '@/composables/graph/useGraphNodeManager'
|
||||
import type { GraphNodeManager } from '@/composables/graph/useGraphNodeManager'
|
||||
import { useRenderModeSetting } from '@/composables/settings/useRenderModeSetting'
|
||||
import { useVueFeatureFlags } from '@/composables/useVueFeatureFlags'
|
||||
import { useVueNodesMigrationDismissed } from '@/composables/useVueNodesMigrationDismissed'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
@@ -11,6 +10,7 @@ import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMutations'
|
||||
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
|
||||
import { useLayoutSync } from '@/renderer/core/layout/sync/useLayoutSync'
|
||||
import { removeNodeTitleHeight } from '@/renderer/core/layout/utils/nodeSizeUtil'
|
||||
import { ensureCorrectLayoutScale } from '@/renderer/extensions/vueNodes/layout/ensureCorrectLayoutScale'
|
||||
import { app as comfyApp } from '@/scripts/app'
|
||||
import { useToastStore } from '@/platform/updates/common/toastStore'
|
||||
@@ -26,11 +26,6 @@ function useVueNodeLifecycleIndividual() {
|
||||
|
||||
let hasShownMigrationToast = false
|
||||
|
||||
useRenderModeSetting(
|
||||
{ setting: 'LiteGraph.Canvas.MinFontSizeForLOD', vue: 0, litegraph: 8 },
|
||||
shouldRenderVueNodes
|
||||
)
|
||||
|
||||
const initializeNodeManager = () => {
|
||||
// Use canvas graph if available (handles subgraph contexts), fallback to app graph
|
||||
const activeGraph = comfyApp.canvas?.graph
|
||||
@@ -44,7 +39,10 @@ function useVueNodeLifecycleIndividual() {
|
||||
const nodes = activeGraph._nodes.map((node: LGraphNode) => ({
|
||||
id: node.id.toString(),
|
||||
pos: [node.pos[0], node.pos[1]] as [number, number],
|
||||
size: [node.size[0], node.size[1]] as [number, number]
|
||||
size: [node.size[0], removeNodeTitleHeight(node.size[1])] as [
|
||||
number,
|
||||
number
|
||||
]
|
||||
}))
|
||||
layoutStore.initializeFromLiteGraph(nodes)
|
||||
|
||||
|
||||
@@ -49,6 +49,21 @@ const calculateRunwayDurationPrice = (node: LGraphNode): string => {
|
||||
return `$${cost}/Run`
|
||||
}
|
||||
|
||||
const makeOmniProDurationCalculator =
|
||||
(pricePerSecond: number): PricingFunction =>
|
||||
(node: LGraphNode): string => {
|
||||
const durationWidget = node.widgets?.find(
|
||||
(w) => w.name === 'duration'
|
||||
) as IComboWidget
|
||||
if (!durationWidget) return `$${pricePerSecond.toFixed(3)}/second`
|
||||
|
||||
const seconds = parseFloat(String(durationWidget.value))
|
||||
if (!Number.isFinite(seconds)) return `$${pricePerSecond.toFixed(3)}/second`
|
||||
|
||||
const cost = pricePerSecond * seconds
|
||||
return `$${cost.toFixed(2)}/Run`
|
||||
}
|
||||
|
||||
const pixversePricingCalculator = (node: LGraphNode): string => {
|
||||
const durationWidget = node.widgets?.find(
|
||||
(w) => w.name === 'duration_seconds'
|
||||
@@ -131,6 +146,11 @@ const byteDanceVideoPricingCalculator = (node: LGraphNode): string => {
|
||||
'720p': [0.51, 0.56],
|
||||
'1080p': [1.18, 1.22]
|
||||
},
|
||||
'seedance-1-0-pro-fast': {
|
||||
'480p': [0.09, 0.1],
|
||||
'720p': [0.21, 0.23],
|
||||
'1080p': [0.47, 0.49]
|
||||
},
|
||||
'seedance-1-0-lite': {
|
||||
'480p': [0.17, 0.18],
|
||||
'720p': [0.37, 0.41],
|
||||
@@ -138,11 +158,13 @@ const byteDanceVideoPricingCalculator = (node: LGraphNode): string => {
|
||||
}
|
||||
}
|
||||
|
||||
const modelKey = model.includes('seedance-1-0-pro')
|
||||
? 'seedance-1-0-pro'
|
||||
: model.includes('seedance-1-0-lite')
|
||||
? 'seedance-1-0-lite'
|
||||
: ''
|
||||
const modelKey = model.includes('seedance-1-0-pro-fast')
|
||||
? 'seedance-1-0-pro-fast'
|
||||
: model.includes('seedance-1-0-pro')
|
||||
? 'seedance-1-0-pro'
|
||||
: model.includes('seedance-1-0-lite')
|
||||
? 'seedance-1-0-lite'
|
||||
: ''
|
||||
|
||||
const resKey = resolution.includes('1080')
|
||||
? '1080p'
|
||||
@@ -303,6 +325,46 @@ const apiNodeCosts: Record<string, { displayPrice: string | PricingFunction }> =
|
||||
FluxProKontextMaxNode: {
|
||||
displayPrice: '$0.08/Run'
|
||||
},
|
||||
Flux2ProImageNode: {
|
||||
displayPrice: (node: LGraphNode): string => {
|
||||
const widthW = node.widgets?.find(
|
||||
(w) => w.name === 'width'
|
||||
) as IComboWidget
|
||||
const heightW = node.widgets?.find(
|
||||
(w) => w.name === 'height'
|
||||
) as IComboWidget
|
||||
|
||||
const w = Number(widthW?.value)
|
||||
const h = Number(heightW?.value)
|
||||
if (!Number.isFinite(w) || !Number.isFinite(h) || w <= 0 || h <= 0) {
|
||||
// global min/max for this node given schema bounds (1MP..4MP output)
|
||||
return '$0.03–$0.15/Run'
|
||||
}
|
||||
|
||||
// Is the 'images' input connected?
|
||||
const imagesInput = node.inputs?.find(
|
||||
(i) => i.name === 'images'
|
||||
) as INodeInputSlot
|
||||
const hasRefs =
|
||||
typeof imagesInput?.link !== 'undefined' && imagesInput.link != null
|
||||
|
||||
// Output cost: ceil((w*h)/MP); first MP $0.03, each additional $0.015
|
||||
const MP = 1024 * 1024
|
||||
const outMP = Math.max(1, Math.floor((w * h + MP - 1) / MP))
|
||||
const outputCost = 0.03 + 0.015 * Math.max(outMP - 1, 0)
|
||||
|
||||
if (hasRefs) {
|
||||
// Unknown ref count/size on the frontend:
|
||||
// min extra is $0.015, max extra is $0.120 (8 MP cap / 8 refs)
|
||||
const minTotal = outputCost + 0.015
|
||||
const maxTotal = outputCost + 0.12
|
||||
return `~$${parseFloat(minTotal.toFixed(3))}–$${parseFloat(maxTotal.toFixed(3))}/Run`
|
||||
}
|
||||
|
||||
// Precise text-to-image price
|
||||
return `$${parseFloat(outputCost.toFixed(3))}/Run`
|
||||
}
|
||||
},
|
||||
OpenAIVideoSora2: {
|
||||
displayPrice: sora2PricingCalculator
|
||||
},
|
||||
@@ -659,6 +721,21 @@ const apiNodeCosts: Record<string, { displayPrice: string | PricingFunction }> =
|
||||
KlingVirtualTryOnNode: {
|
||||
displayPrice: '$0.07/Run'
|
||||
},
|
||||
KlingOmniProTextToVideoNode: {
|
||||
displayPrice: makeOmniProDurationCalculator(0.112)
|
||||
},
|
||||
KlingOmniProFirstLastFrameNode: {
|
||||
displayPrice: makeOmniProDurationCalculator(0.112)
|
||||
},
|
||||
KlingOmniProImageToVideoNode: {
|
||||
displayPrice: makeOmniProDurationCalculator(0.112)
|
||||
},
|
||||
KlingOmniProVideoToVideoNode: {
|
||||
displayPrice: makeOmniProDurationCalculator(0.168)
|
||||
},
|
||||
KlingOmniProEditVideoNode: {
|
||||
displayPrice: '$0.168/second'
|
||||
},
|
||||
LumaImageToVideoNode: {
|
||||
displayPrice: (node: LGraphNode): string => {
|
||||
// Same pricing as LumaVideoNode per CSV
|
||||
@@ -1197,6 +1274,40 @@ const apiNodeCosts: Record<string, { displayPrice: string | PricingFunction }> =
|
||||
return '$0.80-3.20/Run'
|
||||
}
|
||||
},
|
||||
Veo3FirstLastFrameNode: {
|
||||
displayPrice: (node: LGraphNode): string => {
|
||||
const modelWidget = node.widgets?.find(
|
||||
(w) => w.name === 'model'
|
||||
) as IComboWidget
|
||||
const generateAudioWidget = node.widgets?.find(
|
||||
(w) => w.name === 'generate_audio'
|
||||
) as IComboWidget
|
||||
const durationWidget = node.widgets?.find(
|
||||
(w) => w.name === 'duration'
|
||||
) as IComboWidget
|
||||
|
||||
if (!modelWidget || !generateAudioWidget || !durationWidget) {
|
||||
return '$0.40-3.20/Run (varies with model & audio generation)'
|
||||
}
|
||||
|
||||
const model = String(modelWidget.value)
|
||||
const generateAudio =
|
||||
String(generateAudioWidget.value).toLowerCase() === 'true'
|
||||
const seconds = parseFloat(String(durationWidget.value))
|
||||
|
||||
let pricePerSecond: number | null = null
|
||||
if (model.includes('veo-3.1-fast-generate')) {
|
||||
pricePerSecond = generateAudio ? 0.15 : 0.1
|
||||
} else if (model.includes('veo-3.1-generate')) {
|
||||
pricePerSecond = generateAudio ? 0.4 : 0.2
|
||||
}
|
||||
if (pricePerSecond === null) {
|
||||
return '$0.40-3.20/Run'
|
||||
}
|
||||
const cost = pricePerSecond * seconds
|
||||
return `$${cost.toFixed(2)}/Run`
|
||||
}
|
||||
},
|
||||
LumaImageNode: {
|
||||
displayPrice: (node: LGraphNode): string => {
|
||||
const modelWidget = node.widgets?.find(
|
||||
@@ -1799,6 +1910,10 @@ export const useNodePricing = () => {
|
||||
KlingDualCharacterVideoEffectNode: ['mode', 'model_name', 'duration'],
|
||||
KlingSingleImageVideoEffectNode: ['effect_scene'],
|
||||
KlingStartEndFrameNode: ['mode', 'model_name', 'duration'],
|
||||
KlingOmniProTextToVideoNode: ['duration'],
|
||||
KlingOmniProFirstLastFrameNode: ['duration'],
|
||||
KlingOmniProImageToVideoNode: ['duration'],
|
||||
KlingOmniProVideoToVideoNode: ['duration'],
|
||||
MinimaxHailuoVideoNode: ['resolution', 'duration'],
|
||||
OpenAIDalle3: ['size', 'quality'],
|
||||
OpenAIDalle2: ['size', 'n'],
|
||||
@@ -1809,8 +1924,10 @@ export const useNodePricing = () => {
|
||||
IdeogramV3: ['rendering_speed', 'num_images', 'character_image'],
|
||||
FluxProKontextProNode: [],
|
||||
FluxProKontextMaxNode: [],
|
||||
Flux2ProImageNode: ['width', 'height', 'images'],
|
||||
VeoVideoGenerationNode: ['duration_seconds'],
|
||||
Veo3VideoGenerationNode: ['model', 'generate_audio'],
|
||||
Veo3FirstLastFrameNode: ['model', 'generate_audio', 'duration'],
|
||||
LumaVideoNode: ['model', 'resolution', 'duration'],
|
||||
LumaImageToVideoNode: ['model', 'resolution', 'duration'],
|
||||
LumaImageNode: ['model', 'aspect_ratio'],
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { useQueueProgress } from '@/composables/queue/useQueueProgress'
|
||||
import { st } from '@/i18n'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import { useExecutionStore } from '@/stores/executionStore'
|
||||
import { useQueueStore } from '@/stores/queueStore'
|
||||
@@ -96,6 +97,7 @@ export function useJobList() {
|
||||
const executionStore = useExecutionStore()
|
||||
const workflowStore = useWorkflowStore()
|
||||
|
||||
const seenPendingIds = ref<Set<string>>(new Set())
|
||||
const recentlyAddedPendingIds = ref<Set<string>>(new Set())
|
||||
const addedHintTimeouts = new Map<string, ReturnType<typeof setTimeout>>()
|
||||
|
||||
@@ -126,23 +128,27 @@ export function useJobList() {
|
||||
.filter((id): id is string => !!id),
|
||||
(pendingIds) => {
|
||||
const pendingSet = new Set(pendingIds)
|
||||
const next = new Set(recentlyAddedPendingIds.value)
|
||||
const nextAdded = new Set(recentlyAddedPendingIds.value)
|
||||
const nextSeen = new Set(seenPendingIds.value)
|
||||
|
||||
pendingIds.forEach((id) => {
|
||||
if (!next.has(id)) {
|
||||
next.add(id)
|
||||
if (!nextSeen.has(id)) {
|
||||
nextSeen.add(id)
|
||||
nextAdded.add(id)
|
||||
scheduleAddedHintExpiry(id)
|
||||
}
|
||||
})
|
||||
|
||||
for (const id of Array.from(next)) {
|
||||
for (const id of Array.from(nextSeen)) {
|
||||
if (!pendingSet.has(id)) {
|
||||
next.delete(id)
|
||||
nextSeen.delete(id)
|
||||
nextAdded.delete(id)
|
||||
clearAddedHintTimeout(id)
|
||||
}
|
||||
}
|
||||
|
||||
recentlyAddedPendingIds.value = next
|
||||
recentlyAddedPendingIds.value = nextAdded
|
||||
seenPendingIds.value = nextSeen
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
@@ -157,6 +163,7 @@ export function useJobList() {
|
||||
onUnmounted(() => {
|
||||
addedHintTimeouts.forEach((timeoutId) => clearTimeout(timeoutId))
|
||||
addedHintTimeouts.clear()
|
||||
seenPendingIds.value = new Set<string>()
|
||||
recentlyAddedPendingIds.value = new Set<string>()
|
||||
})
|
||||
|
||||
@@ -257,7 +264,8 @@ export function useJobList() {
|
||||
totalPercent: isActive ? totalPercent.value : undefined,
|
||||
currentNodePercent: isActive ? currentNodePercent.value : undefined,
|
||||
currentNodeName: isActive ? currentNodeName.value : undefined,
|
||||
showAddedHint
|
||||
showAddedHint,
|
||||
isCloud
|
||||
})
|
||||
|
||||
return {
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
import type { ComputedRef } from 'vue'
|
||||
import { ref, watch } from 'vue'
|
||||
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import type { Settings } from '@/schemas/apiSchema'
|
||||
|
||||
interface RenderModeSettingConfig<TSettingKey extends keyof Settings> {
|
||||
setting: TSettingKey
|
||||
vue: Settings[TSettingKey]
|
||||
litegraph: Settings[TSettingKey]
|
||||
}
|
||||
|
||||
export function useRenderModeSetting<TSettingKey extends keyof Settings>(
|
||||
config: RenderModeSettingConfig<TSettingKey>,
|
||||
isVueMode: ComputedRef<boolean>
|
||||
) {
|
||||
const settingStore = useSettingStore()
|
||||
const vueValue = ref(config.vue)
|
||||
const litegraphValue = ref(config.litegraph)
|
||||
const lastWasVue = ref<boolean | null>(null)
|
||||
|
||||
const load = async (vue: boolean) => {
|
||||
if (lastWasVue.value === vue) return
|
||||
|
||||
if (lastWasVue.value !== null) {
|
||||
const currentValue = settingStore.get(config.setting)
|
||||
if (lastWasVue.value) {
|
||||
vueValue.value = currentValue
|
||||
} else {
|
||||
litegraphValue.value = currentValue
|
||||
}
|
||||
}
|
||||
|
||||
await settingStore.set(
|
||||
config.setting,
|
||||
vue ? vueValue.value : litegraphValue.value
|
||||
)
|
||||
lastWasVue.value = vue
|
||||
}
|
||||
|
||||
watch(isVueMode, load, { immediate: true })
|
||||
}
|
||||
@@ -330,7 +330,7 @@ export function useCoreCommands(): ComfyCommand[] {
|
||||
label: () =>
|
||||
`Experimental: ${
|
||||
useSettingStore().get('Comfy.VueNodes.Enabled') ? 'Disable' : 'Enable'
|
||||
} Vue Nodes`,
|
||||
} Nodes 2.0`,
|
||||
function: async () => {
|
||||
const settingStore = useSettingStore()
|
||||
const current = settingStore.get('Comfy.VueNodes.Enabled') ?? false
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { computed, reactive, readonly } from 'vue'
|
||||
import { computed, reactive, readonly, ref } from 'vue'
|
||||
|
||||
import { remoteConfig } from '@/platform/remoteConfig/remoteConfig'
|
||||
import { api } from '@/scripts/api'
|
||||
|
||||
/**
|
||||
@@ -9,9 +10,21 @@ export enum ServerFeatureFlag {
|
||||
SUPPORTS_PREVIEW_METADATA = 'supports_preview_metadata',
|
||||
MAX_UPLOAD_SIZE = 'max_upload_size',
|
||||
MANAGER_SUPPORTS_V4 = 'extension.manager.supports_v4',
|
||||
MODEL_UPLOAD_BUTTON_ENABLED = 'model_upload_button_enabled'
|
||||
MODEL_UPLOAD_BUTTON_ENABLED = 'model_upload_button_enabled',
|
||||
ASSET_UPDATE_OPTIONS_ENABLED = 'asset_update_options_enabled'
|
||||
}
|
||||
|
||||
/**
|
||||
* Feature flag variant structure for experiments
|
||||
*/
|
||||
export interface FeatureFlagVariant {
|
||||
variant: string
|
||||
payload?: Record<string, unknown>
|
||||
}
|
||||
|
||||
// Demo mode: allows manual override for demonstration
|
||||
const demoOverrides = ref<Record<string, unknown>>({})
|
||||
|
||||
/**
|
||||
* Composable for reactive access to server-side feature flags
|
||||
*/
|
||||
@@ -27,15 +40,57 @@ export function useFeatureFlags() {
|
||||
return api.getServerFeature(ServerFeatureFlag.MANAGER_SUPPORTS_V4)
|
||||
},
|
||||
get modelUploadButtonEnabled() {
|
||||
return api.getServerFeature(
|
||||
ServerFeatureFlag.MODEL_UPLOAD_BUTTON_ENABLED,
|
||||
false
|
||||
// Check remote config first (from /api/features), fall back to websocket feature flags
|
||||
return (
|
||||
remoteConfig.value.model_upload_button_enabled ??
|
||||
api.getServerFeature(
|
||||
ServerFeatureFlag.MODEL_UPLOAD_BUTTON_ENABLED,
|
||||
false
|
||||
)
|
||||
)
|
||||
},
|
||||
get assetUpdateOptionsEnabled() {
|
||||
// Check remote config first (from /api/features), fall back to websocket feature flags
|
||||
return (
|
||||
remoteConfig.value.asset_update_options_enabled ??
|
||||
api.getServerFeature(
|
||||
ServerFeatureFlag.ASSET_UPDATE_OPTIONS_ENABLED,
|
||||
false
|
||||
)
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
const featureFlag = <T = unknown>(featurePath: string, defaultValue?: T) =>
|
||||
computed(() => api.getServerFeature(featurePath, defaultValue))
|
||||
computed(() => {
|
||||
// Check demo overrides first
|
||||
if (demoOverrides.value[featurePath] !== undefined) {
|
||||
return demoOverrides.value[featurePath] as T
|
||||
}
|
||||
// Check remote config (from /api/features) - convert hyphens to underscores for lookup
|
||||
const remoteConfigKey = featurePath.replace(/-/g, '_')
|
||||
const remoteValue = (remoteConfig.value as Record<string, unknown>)[
|
||||
remoteConfigKey
|
||||
]
|
||||
if (remoteValue !== undefined) {
|
||||
return remoteValue as T
|
||||
}
|
||||
// Fall back to server feature flags (WebSocket) - try both hyphen and underscore versions
|
||||
const wsValue = api.getServerFeature(featurePath, undefined)
|
||||
if (wsValue !== undefined) {
|
||||
return wsValue as T
|
||||
}
|
||||
// Try underscore version for WebSocket flags
|
||||
const wsValueUnderscore = api.getServerFeature(
|
||||
featurePath.replace(/-/g, '_'),
|
||||
undefined
|
||||
)
|
||||
if (wsValueUnderscore !== undefined) {
|
||||
return wsValueUnderscore as T
|
||||
}
|
||||
// Return default if nothing found
|
||||
return defaultValue as T
|
||||
})
|
||||
|
||||
return {
|
||||
flags: readonly(flags),
|
||||
|
||||
@@ -9,7 +9,7 @@ useExtensionService().registerExtension({
|
||||
name: 'Comfy.Cloud.RemoteConfig',
|
||||
|
||||
setup: async () => {
|
||||
// Poll for config updates every 30 seconds
|
||||
setInterval(() => void loadRemoteConfig(), 30000)
|
||||
// Poll for config updates every 10 minutes
|
||||
setInterval(() => void loadRemoteConfig(), 600_000)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -7,9 +7,9 @@ import type {
|
||||
INodeInputSlot,
|
||||
INodeOutputSlot,
|
||||
ISlotType,
|
||||
LLink,
|
||||
Point
|
||||
LLink
|
||||
} from '@/lib/litegraph/src/litegraph'
|
||||
import { NodeSlot } from '@/lib/litegraph/src/node/NodeSlot'
|
||||
import type { CanvasPointerEvent } from '@/lib/litegraph/src/types/events'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
import type { InputSpec } from '@/schemas/nodeDefSchema'
|
||||
@@ -37,15 +37,15 @@ export class PrimitiveNode extends LGraphNode {
|
||||
}
|
||||
|
||||
override applyToGraph(extraLinks: LLink[] = []) {
|
||||
if (!this.outputs[0].links?.length) return
|
||||
if (!this.outputs[0].links?.length || !this.graph) return
|
||||
|
||||
const links = [
|
||||
...this.outputs[0].links.map((l) => app.graph.links[l]),
|
||||
...this.outputs[0].links.map((l) => this.graph!.links[l]),
|
||||
...extraLinks
|
||||
]
|
||||
let v = this.widgets?.[0].value
|
||||
if (v && this.properties[replacePropertyName]) {
|
||||
v = applyTextReplacements(app.graph, v as string)
|
||||
v = applyTextReplacements(this.graph, v as string)
|
||||
}
|
||||
|
||||
// For each output link copy our value over the original widget value
|
||||
@@ -331,13 +331,13 @@ export class PrimitiveNode extends LGraphNode {
|
||||
const config1 = (output.widget?.[GET_CONFIG] as () => InputSpec)?.()
|
||||
if (!config1) return
|
||||
const isNumber = config1[0] === 'INT' || config1[0] === 'FLOAT'
|
||||
if (!isNumber) return
|
||||
if (!isNumber || !this.graph) return
|
||||
|
||||
for (const linkId of links) {
|
||||
const link = app.graph.links[linkId]
|
||||
const link = this.graph.links[linkId]
|
||||
if (!link) continue // Can be null when removing a node
|
||||
|
||||
const theirNode = app.graph.getNodeById(link.target_id)
|
||||
const theirNode = this.graph.getNodeById(link.target_id)
|
||||
if (!theirNode) continue
|
||||
const theirInput = theirNode.inputs[link.target_slot]
|
||||
|
||||
@@ -441,10 +441,7 @@ function getWidgetType(config: InputSpec) {
|
||||
return { type }
|
||||
}
|
||||
|
||||
export function setWidgetConfig(
|
||||
slot: INodeInputSlot | INodeOutputSlot,
|
||||
config?: InputSpec
|
||||
) {
|
||||
export function setWidgetConfig(slot: INodeInputSlot, config?: InputSpec) {
|
||||
if (!slot.widget) return
|
||||
if (config) {
|
||||
slot.widget[GET_CONFIG] = () => config
|
||||
@@ -452,19 +449,18 @@ export function setWidgetConfig(
|
||||
delete slot.widget
|
||||
}
|
||||
|
||||
if ('link' in slot) {
|
||||
const link = app.graph.links[slot.link ?? -1]
|
||||
if (link) {
|
||||
const originNode = app.graph.getNodeById(link.origin_id)
|
||||
if (originNode && isPrimitiveNode(originNode)) {
|
||||
if (config) {
|
||||
originNode.recreateWidget()
|
||||
} else if (!app.configuringGraph) {
|
||||
originNode.disconnectOutput(0)
|
||||
originNode.onLastDisconnect()
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!(slot instanceof NodeSlot)) return
|
||||
const graph = slot.node.graph
|
||||
if (!graph) return
|
||||
const link = graph.links[slot.link ?? -1]
|
||||
if (!link) return
|
||||
const originNode = graph.getNodeById(link.origin_id)
|
||||
if (!originNode || !isPrimitiveNode(originNode)) return
|
||||
if (config) {
|
||||
originNode.recreateWidget()
|
||||
} else if (!app.configuringGraph) {
|
||||
originNode.disconnectOutput(0)
|
||||
originNode.onLastDisconnect()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -555,15 +551,6 @@ app.registerExtension({
|
||||
}
|
||||
)
|
||||
|
||||
function isNodeAtPos(pos: Point) {
|
||||
for (const n of app.graph.nodes) {
|
||||
if (n.pos[0] === pos[0] && n.pos[1] === pos[1]) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Double click a widget input to automatically attach a primitive
|
||||
const origOnInputDblClick = nodeType.prototype.onInputDblClick
|
||||
nodeType.prototype.onInputDblClick = function (
|
||||
@@ -589,18 +576,18 @@ app.registerExtension({
|
||||
|
||||
// Create a primitive node
|
||||
const node = LiteGraph.createNode('PrimitiveNode')
|
||||
if (!node) return r
|
||||
const graph = app.canvas.graph
|
||||
if (!node || !graph) return r
|
||||
|
||||
this.graph?.add(node)
|
||||
graph?.add(node)
|
||||
|
||||
// Calculate a position that won't directly overlap another node
|
||||
const pos: [number, number] = [
|
||||
this.pos[0] - node.size[0] - 30,
|
||||
this.pos[1]
|
||||
]
|
||||
while (isNodeAtPos(pos)) {
|
||||
while (graph.getNodeOnPos(pos[0], pos[1], graph.nodes))
|
||||
pos[1] += LiteGraph.NODE_TITLE_HEIGHT
|
||||
}
|
||||
|
||||
node.pos = pos
|
||||
node.connect(0, this, slot)
|
||||
|
||||
@@ -7,6 +7,7 @@ import { getSlotPosition } from '@/renderer/core/canvas/litegraph/slotCalculatio
|
||||
import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMutations'
|
||||
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
|
||||
import { LayoutSource } from '@/renderer/core/layout/types'
|
||||
import { removeNodeTitleHeight } from '@/renderer/core/layout/utils/nodeSizeUtil'
|
||||
|
||||
import { CanvasPointer } from './CanvasPointer'
|
||||
import type { ContextMenu } from './ContextMenu'
|
||||
@@ -4043,16 +4044,25 @@ export class LGraphCanvas
|
||||
|
||||
// TODO: Report failures, i.e. `failedNodes`
|
||||
|
||||
const newPositions = created.map((node) => ({
|
||||
nodeId: String(node.id),
|
||||
bounds: {
|
||||
x: node.pos[0],
|
||||
y: node.pos[1],
|
||||
width: node.size?.[0] ?? 100,
|
||||
height: node.size?.[1] ?? 200
|
||||
}
|
||||
}))
|
||||
const newPositions = created
|
||||
.filter((item): item is LGraphNode => item instanceof LGraphNode)
|
||||
.map((node) => {
|
||||
const fullHeight = node.size?.[1] ?? 200
|
||||
const layoutHeight = LiteGraph.vueNodesMode
|
||||
? removeNodeTitleHeight(fullHeight)
|
||||
: fullHeight
|
||||
return {
|
||||
nodeId: String(node.id),
|
||||
bounds: {
|
||||
x: node.pos[0],
|
||||
y: node.pos[1],
|
||||
width: node.size?.[0] ?? 100,
|
||||
height: layoutHeight
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
if (newPositions.length) layoutStore.setSource(LayoutSource.Canvas)
|
||||
layoutStore.batchUpdateNodeBounds(newPositions)
|
||||
|
||||
this.selectItems(created)
|
||||
|
||||
@@ -32,6 +32,7 @@ export interface IWidgetOptions<TValues = unknown[]> {
|
||||
/** Optional function to format values for display (e.g., hash → human-readable name) */
|
||||
getOptionLabel?: (value?: string | null) => string
|
||||
callback?: IWidget['callback']
|
||||
iconClass?: string
|
||||
}
|
||||
|
||||
interface IWidgetSliderOptions extends IWidgetOptions<number[]> {
|
||||
|
||||
@@ -237,7 +237,7 @@
|
||||
"label": "Sign Out"
|
||||
},
|
||||
"Experimental_ToggleVueNodes": {
|
||||
"label": "Experimental: Enable Vue Nodes"
|
||||
"label": "Experimental: Enable Nodes 2.0"
|
||||
},
|
||||
"Workspace_CloseWorkflow": {
|
||||
"label": "Close Current Workflow"
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
{
|
||||
"g": {
|
||||
"beta": "Beta",
|
||||
"user": "User",
|
||||
"currentUser": "Current user",
|
||||
"empty": "Empty",
|
||||
@@ -105,6 +106,7 @@
|
||||
"dropYourFileOr": "Drop your file or",
|
||||
"back": "Back",
|
||||
"next": "Next",
|
||||
"submit": "Submit",
|
||||
"install": "Install",
|
||||
"installing": "Installing",
|
||||
"overwrite": "Overwrite",
|
||||
@@ -234,6 +236,7 @@
|
||||
"frameNodes": "Frame Nodes",
|
||||
"listening": "Listening...",
|
||||
"ready": "Ready",
|
||||
"playPause": "Play/Pause",
|
||||
"playRecording": "Play Recording",
|
||||
"playing": "Playing",
|
||||
"stopPlayback": "Stop Playback",
|
||||
@@ -242,7 +245,9 @@
|
||||
"halfSpeed": "0.5x",
|
||||
"1x": "1x",
|
||||
"2x": "2x",
|
||||
"beta": "BETA"
|
||||
"beta": "BETA",
|
||||
"profile": "Profile",
|
||||
"noItems": "No items"
|
||||
},
|
||||
"manager": {
|
||||
"title": "Custom Nodes Manager",
|
||||
@@ -689,6 +694,7 @@
|
||||
"currentNode": "Current node:",
|
||||
"viewAllJobs": "View all jobs",
|
||||
"running": "running",
|
||||
"preview": "Preview",
|
||||
"interruptAll": "Interrupt all running jobs",
|
||||
"moreOptions": "More options",
|
||||
"showAssets": "Show assets",
|
||||
@@ -930,6 +936,7 @@
|
||||
"thickness": "Thickness",
|
||||
"opacity": "Opacity",
|
||||
"hardness": "Hardness",
|
||||
"stepSize": "Step Size",
|
||||
"smoothingPrecision": "Smoothing Precision",
|
||||
"resetToDefault": "Reset to Default",
|
||||
"paintBucketSettings": "Paint Bucket Settings",
|
||||
@@ -978,6 +985,7 @@
|
||||
"initializingAlmostReady": "Initializing - Almost ready",
|
||||
"inQueue": "In queue...",
|
||||
"jobAddedToQueue": "Job added to queue",
|
||||
"completedIn": "Finished in {duration}",
|
||||
"jobMenu": {
|
||||
"openAsWorkflowNewTab": "Open as workflow in new tab",
|
||||
"openWorkflowNewTab": "Open workflow in new tab",
|
||||
@@ -1107,7 +1115,9 @@
|
||||
"Undo": "Undo",
|
||||
"Open Sign In Dialog": "Open Sign In Dialog",
|
||||
"Sign Out": "Sign Out",
|
||||
"Experimental: Enable Vue Nodes": "Experimental: Enable Vue Nodes",
|
||||
"Experimental: Enable Vue Nodes": "Experimental: Enable Nodes 2.0",
|
||||
"Experimental: Enable Nodes 2.0": "Experimental: Enable Nodes 2.0",
|
||||
"Experimental: Disable Nodes 2.0": "Experimental: Disable Nodes 2.0",
|
||||
"Close Current Workflow": "Close Current Workflow",
|
||||
"Next Opened Workflow": "Next Opened Workflow",
|
||||
"Previous Opened Workflow": "Previous Opened Workflow",
|
||||
@@ -1183,10 +1193,10 @@
|
||||
"API Nodes": "API Nodes",
|
||||
"Notification Preferences": "Notification Preferences",
|
||||
"3DViewer": "3DViewer",
|
||||
"Vue Nodes": "Vue Nodes",
|
||||
"Canvas Navigation": "Canvas Navigation",
|
||||
"PlanCredits": "Plan & Credits",
|
||||
"VueNodes": "Vue Nodes",
|
||||
"Vue Nodes": "Nodes 2.0",
|
||||
"VueNodes": "Nodes 2.0",
|
||||
"Nodes 2_0": "Nodes 2.0"
|
||||
},
|
||||
"serverConfigItems": {
|
||||
@@ -1833,6 +1843,7 @@
|
||||
"title": "Subscription",
|
||||
"titleUnsubscribed": "Subscribe to Comfy Cloud",
|
||||
"comfyCloud": "Comfy Cloud",
|
||||
"comfyCloudLogo": "Comfy Cloud Logo",
|
||||
"beta": "BETA",
|
||||
"perMonth": "USD / month",
|
||||
"renewsDate": "Renews {date}",
|
||||
@@ -2068,6 +2079,7 @@
|
||||
"cloudSurvey_steps_making": "What do you plan on making?",
|
||||
"assetBrowser": {
|
||||
"assets": "Assets",
|
||||
"assetCollection": "Asset collection",
|
||||
"checkpoints": "Checkpoints",
|
||||
"browseAssets": "Browse Assets",
|
||||
"noAssetsFound": "No assets found",
|
||||
@@ -2082,11 +2094,11 @@
|
||||
"uploadModelFailedToRetrieveMetadata": "Failed to retrieve metadata. Please check the link and try again.",
|
||||
"onlyCivitaiUrlsSupported": "Only Civitai URLs are supported",
|
||||
"uploadModelDescription1": "Paste a Civitai model download link to add it to your library.",
|
||||
"uploadModelDescription2": "Only links from https://civitai.com are supported at the moment",
|
||||
"uploadModelDescription3": "Max file size: 1 GB",
|
||||
"civitaiLinkLabel": "Civitai model download link",
|
||||
"uploadModelDescription2": "Only links from <a href=\"https://civitai.com\" target=\"_blank\" class=\"text-muted-foreground\">https://civitai.com</a> are supported at the moment",
|
||||
"uploadModelDescription3": "Max file size: <strong>1 GB</strong>",
|
||||
"civitaiLinkLabel": "Civitai model <span class=\"font-bold italic\">download</span> link",
|
||||
"civitaiLinkPlaceholder": "Paste link here",
|
||||
"civitaiLinkExample": "Example: https://civitai.com/api/download/models/833921?type=Model&format=SafeTensor",
|
||||
"civitaiLinkExample": "<strong>Example:</strong> <a href=\"https://civitai.com/api/download/models/833921?type=Model&format=SafeTensor\" target=\"_blank\" class=\"text-muted-foreground\">https://civitai.com/api/download/models/833921?type=Model&format=SafeTensor</a>",
|
||||
"confirmModelDetails": "Confirm Model Details",
|
||||
"fileName": "File Name",
|
||||
"fileSize": "File Size",
|
||||
@@ -2118,6 +2130,9 @@
|
||||
"sortZA": "Z-A",
|
||||
"sortRecent": "Recent",
|
||||
"sortPopular": "Popular",
|
||||
"selectFrameworks": "Select Frameworks",
|
||||
"selectProjects": "Select Projects",
|
||||
"sortingType": "Sorting Type",
|
||||
"errorFileTooLarge": "File exceeds the maximum allowed size limit",
|
||||
"errorFormatNotAllowed": "Only SafeTensor format is allowed",
|
||||
"errorUnsafePickleScan": "CivitAI detected potentially unsafe code in this file",
|
||||
@@ -2189,7 +2204,8 @@
|
||||
}
|
||||
},
|
||||
"vueNodesBanner": {
|
||||
"message": "Introducing Nodes 2.0 – More flexible workflows, powerful new widgets, built for extensibility",
|
||||
"title": "Introducing Nodes 2.0",
|
||||
"desc": "– More flexible workflows, powerful new widgets, built for extensibility",
|
||||
"tryItOut": "Try it out"
|
||||
},
|
||||
"vueNodesMigration": {
|
||||
@@ -2218,4 +2234,4 @@
|
||||
"replacementInstruction": "Install these nodes to run this workflow, or replace them with installed alternatives. Missing nodes are highlighted in red on the canvas."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -336,7 +336,7 @@
|
||||
},
|
||||
"Comfy_VueNodes_AutoScaleLayout": {
|
||||
"name": "Auto-scale layout (Nodes 2.0)",
|
||||
"tooltip": "Automatically scale node positions when switching to Vue rendering to prevent overlap"
|
||||
"tooltip": "Automatically scale node positions when switching to Nodes 2.0 rendering to prevent overlap"
|
||||
},
|
||||
"Comfy_VueNodes_Enabled": {
|
||||
"name": "Modern Node Design (Nodes 2.0)",
|
||||
|
||||
@@ -201,6 +201,12 @@ function handleUploadClick() {
|
||||
onUploadSuccess: async () => {
|
||||
await execute()
|
||||
}
|
||||
},
|
||||
dialogComponentProps: {
|
||||
pt: {
|
||||
header: 'py-0! pl-0!',
|
||||
content: 'p-0!'
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
data-component-id="AssetGrid"
|
||||
:style="gridStyle"
|
||||
role="grid"
|
||||
aria-label="Asset collection"
|
||||
:aria-label="$t('assetBrowser.assetCollection')"
|
||||
:aria-rowcount="-1"
|
||||
:aria-colcount="-1"
|
||||
:aria-setsize="assets.length"
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
<IconTextButton
|
||||
v-if="asset?.kind !== '3D'"
|
||||
type="transparent"
|
||||
label="Inspect asset"
|
||||
:label="$t('queue.jobMenu.inspectAsset')"
|
||||
@click="handleInspect"
|
||||
>
|
||||
<template #icon>
|
||||
@@ -17,7 +17,7 @@
|
||||
<IconTextButton
|
||||
v-if="showAddToWorkflow"
|
||||
type="transparent"
|
||||
label="Add to current workflow"
|
||||
:label="$t('queue.jobMenu.addToCurrentWorkflow')"
|
||||
@click="handleAddToWorkflow"
|
||||
>
|
||||
<template #icon>
|
||||
@@ -25,7 +25,11 @@
|
||||
</template>
|
||||
</IconTextButton>
|
||||
|
||||
<IconTextButton type="transparent" label="Download" @click="handleDownload">
|
||||
<IconTextButton
|
||||
type="transparent"
|
||||
:label="$t('queue.jobMenu.download')"
|
||||
@click="handleDownload"
|
||||
>
|
||||
<template #icon>
|
||||
<i class="icon-[lucide--download] size-4" />
|
||||
</template>
|
||||
@@ -36,7 +40,7 @@
|
||||
<IconTextButton
|
||||
v-if="showWorkflowActions"
|
||||
type="transparent"
|
||||
label="Open as workflow in new tab"
|
||||
:label="$t('queue.jobMenu.openAsWorkflowNewTab')"
|
||||
@click="handleOpenWorkflow"
|
||||
>
|
||||
<template #icon>
|
||||
@@ -47,7 +51,7 @@
|
||||
<IconTextButton
|
||||
v-if="showWorkflowActions"
|
||||
type="transparent"
|
||||
label="Export workflow"
|
||||
:label="$t('queue.jobMenu.exportWorkflow')"
|
||||
@click="handleExportWorkflow"
|
||||
>
|
||||
<template #icon>
|
||||
@@ -60,7 +64,7 @@
|
||||
<IconTextButton
|
||||
v-if="showCopyJobId"
|
||||
type="transparent"
|
||||
label="Copy job ID"
|
||||
:label="$t('queue.jobMenu.copyJobId')"
|
||||
@click="handleCopyJobId"
|
||||
>
|
||||
<template #icon>
|
||||
@@ -73,7 +77,7 @@
|
||||
<IconTextButton
|
||||
v-if="shouldShowDeleteButton"
|
||||
type="transparent"
|
||||
label="Delete"
|
||||
:label="$t('queue.jobMenu.delete')"
|
||||
@click="handleDelete"
|
||||
>
|
||||
<template #icon>
|
||||
|
||||
@@ -1,22 +1,24 @@
|
||||
<template>
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="flex flex-col gap-4 text-sm text-muted-foreground">
|
||||
<!-- Model Info Section -->
|
||||
<div class="flex flex-col gap-2">
|
||||
<p class="text-sm text-muted m-0">
|
||||
<p class="m-0">
|
||||
{{ $t('assetBrowser.modelAssociatedWithLink') }}
|
||||
</p>
|
||||
<p class="text-sm mt-0">
|
||||
<p
|
||||
class="mt-0 bg-modal-card-background text-base-foreground p-3 rounded-lg"
|
||||
>
|
||||
{{ metadata?.name || metadata?.filename }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Model Type Selection -->
|
||||
<div class="flex flex-col gap-2">
|
||||
<label class="text-sm text-muted">
|
||||
<label class="">
|
||||
{{ $t('assetBrowser.modelTypeSelectorLabel') }}
|
||||
</label>
|
||||
<SingleSelect
|
||||
v-model="selectedModelType"
|
||||
v-model="modelValue"
|
||||
:label="
|
||||
isLoading
|
||||
? $t('g.loading')
|
||||
@@ -25,8 +27,8 @@
|
||||
:options="modelTypes"
|
||||
:disabled="isLoading"
|
||||
/>
|
||||
<div class="flex items-center gap-2 text-sm text-muted">
|
||||
<i class="icon-[lucide--info]" />
|
||||
<div class="flex items-center gap-2">
|
||||
<i class="icon-[lucide--circle-question-mark]" />
|
||||
<span>{{ $t('assetBrowser.notSureLeaveAsIs') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -34,25 +36,15 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
import SingleSelect from '@/components/input/SingleSelect.vue'
|
||||
import { useModelTypes } from '@/platform/assets/composables/useModelTypes'
|
||||
import type { AssetMetadata } from '@/platform/assets/schemas/assetSchema'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: string | undefined
|
||||
defineProps<{
|
||||
metadata: AssetMetadata | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: string | undefined]
|
||||
}>()
|
||||
const modelValue = defineModel<string | undefined>()
|
||||
|
||||
const { modelTypes, isLoading } = useModelTypes()
|
||||
|
||||
const selectedModelType = computed({
|
||||
get: () => props.modelValue ?? null,
|
||||
set: (value: string | null) => emit('update:modelValue', value ?? undefined)
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
<template>
|
||||
<div class="upload-model-dialog flex flex-col justify-between gap-6 p-4 pt-6">
|
||||
<div
|
||||
class="upload-model-dialog flex flex-col justify-between gap-6 p-4 pt-6 border-t-[1px] border-border-default"
|
||||
>
|
||||
<!-- Step 1: Enter URL -->
|
||||
<UploadModelUrlInput
|
||||
v-if="currentStep === 1"
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
<template>
|
||||
<div class="flex items-center gap-3 px-4 py-2 font-bold">
|
||||
<div class="flex items-center gap-2 p-4 font-bold">
|
||||
<img src="/assets/images/civitai.svg" class="size-4" />
|
||||
<span>{{ $t('assetBrowser.uploadModelFromCivitai') }}</span>
|
||||
<span
|
||||
class="rounded-full bg-white px-1.5 py-0 text-xxs font-medium uppercase text-black"
|
||||
class="rounded-full bg-white px-1.5 py-0 text-xxs font-inter font-semibold uppercase text-black"
|
||||
>
|
||||
{{ $t('g.beta') }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts"></script>
|
||||
|
||||
@@ -1,9 +1,26 @@
|
||||
<template>
|
||||
<div class="flex justify-end gap-2">
|
||||
<div class="flex justify-end gap-2 w-full">
|
||||
<span
|
||||
v-if="currentStep === 1"
|
||||
class="text-muted-foreground mr-auto underline flex items-center gap-2"
|
||||
>
|
||||
<i class="icon-[lucide--circle-question-mark]" />
|
||||
<a href="#" target="_blank" class="text-muted-foreground">{{
|
||||
$t('How do I find this?')
|
||||
}}</a>
|
||||
</span>
|
||||
<TextButton
|
||||
v-if="currentStep === 1"
|
||||
:label="$t('g.cancel')"
|
||||
type="transparent"
|
||||
size="md"
|
||||
:disabled="isFetchingMetadata || isUploading"
|
||||
@click="emit('close')"
|
||||
/>
|
||||
<TextButton
|
||||
v-if="currentStep !== 1 && currentStep !== 3"
|
||||
:label="$t('g.back')"
|
||||
type="secondary"
|
||||
type="transparent"
|
||||
size="md"
|
||||
:disabled="isFetchingMetadata || isUploading"
|
||||
@click="emit('back')"
|
||||
@@ -13,7 +30,7 @@
|
||||
<IconTextButton
|
||||
v-if="currentStep === 1"
|
||||
:label="$t('g.continue')"
|
||||
type="primary"
|
||||
type="secondary"
|
||||
size="md"
|
||||
:disabled="!canFetchMetadata || isFetchingMetadata"
|
||||
@click="emit('fetchMetadata')"
|
||||
@@ -28,7 +45,7 @@
|
||||
<IconTextButton
|
||||
v-else-if="currentStep === 2"
|
||||
:label="$t('assetBrowser.upload')"
|
||||
type="primary"
|
||||
type="secondary"
|
||||
size="md"
|
||||
:disabled="!canUploadModel || isUploading"
|
||||
@click="emit('upload')"
|
||||
@@ -43,7 +60,7 @@
|
||||
<TextButton
|
||||
v-else-if="currentStep === 3 && uploadStatus === 'success'"
|
||||
:label="$t('assetBrowser.finish')"
|
||||
type="primary"
|
||||
type="secondary"
|
||||
size="md"
|
||||
@click="emit('close')"
|
||||
/>
|
||||
|
||||
@@ -1,37 +1,38 @@
|
||||
<template>
|
||||
<div class="flex flex-1 flex-col gap-6">
|
||||
<div class="flex flex-1 flex-col gap-6 text-sm text-muted-foreground">
|
||||
<!-- Uploading State -->
|
||||
<div
|
||||
v-if="status === 'uploading'"
|
||||
class="flex flex-1 flex-col items-center justify-center gap-6"
|
||||
class="flex flex-1 flex-col items-center justify-center gap-2"
|
||||
>
|
||||
<i
|
||||
class="icon-[lucide--loader-circle] animate-spin text-6xl text-primary"
|
||||
class="icon-[lucide--loader-circle] animate-spin text-6xl text-muted-foreground"
|
||||
/>
|
||||
<div class="text-center">
|
||||
<p class="m-0 text-sm font-bold">
|
||||
<p class="m-0 font-bold">
|
||||
{{ $t('assetBrowser.uploadingModel') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Success State -->
|
||||
<div v-else-if="status === 'success'" class="flex flex-col gap-8">
|
||||
<div class="flex flex-col gap-4">
|
||||
<p class="text-sm text-muted m-0 font-bold">
|
||||
{{ $t('assetBrowser.modelUploaded') }}
|
||||
</p>
|
||||
<p class="text-sm text-muted m-0">
|
||||
{{ $t('assetBrowser.findInLibrary', { type: modelType }) }}
|
||||
</p>
|
||||
</div>
|
||||
<div v-else-if="status === 'success'" class="flex flex-col gap-2">
|
||||
<p class="m-0 font-bold">
|
||||
{{ $t('assetBrowser.modelUploaded') }}
|
||||
</p>
|
||||
<p class="m-0">
|
||||
{{ $t('assetBrowser.findInLibrary', { type: modelType }) }}
|
||||
</p>
|
||||
|
||||
<div class="flex flex-row items-start p-8 bg-neutral-800 rounded-lg">
|
||||
<div
|
||||
class="flex flex-row items-start p-4 bg-modal-card-background rounded-lg"
|
||||
>
|
||||
<div class="flex flex-col justify-center items-start gap-1 flex-1">
|
||||
<p class="text-sm m-0">
|
||||
<p class="text-base-foreground m-0">
|
||||
{{ metadata?.name || metadata?.filename }}
|
||||
</p>
|
||||
<p class="text-sm text-muted m-0">
|
||||
<!-- Going to want to add another translation here to get a nice display name. -->
|
||||
{{ modelType }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -1,30 +1,27 @@
|
||||
<template>
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="flex flex-col gap-6 text-sm text-muted-foreground">
|
||||
<div class="flex flex-col gap-2">
|
||||
<p class="text-sm text-muted m-0">
|
||||
<p class="m-0">
|
||||
{{ $t('assetBrowser.uploadModelDescription1') }}
|
||||
</p>
|
||||
<ul class="list-disc space-y-1 pl-5 mt-0 text-sm text-muted">
|
||||
<li>{{ $t('assetBrowser.uploadModelDescription2') }}</li>
|
||||
<li>{{ $t('assetBrowser.uploadModelDescription3') }}</li>
|
||||
<ul class="list-disc space-y-1 pl-5 mt-0">
|
||||
<li v-html="$t('assetBrowser.uploadModelDescription2')" />
|
||||
<li v-html="$t('assetBrowser.uploadModelDescription3')" />
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<label class="text-sm text-muted mb-0">
|
||||
{{ $t('assetBrowser.civitaiLinkLabel') }}
|
||||
</label>
|
||||
<label class="mb-0" v-html="$t('assetBrowser.civitaiLinkLabel')"> </label>
|
||||
<InputText
|
||||
v-model="url"
|
||||
autofocus
|
||||
:placeholder="$t('assetBrowser.civitaiLinkPlaceholder')"
|
||||
class="w-full"
|
||||
class="w-full bg-secondary-background border-0 p-4"
|
||||
/>
|
||||
<p v-if="error" class="text-xs text-error">
|
||||
{{ error }}
|
||||
</p>
|
||||
<p v-else class="text-xs text-muted">
|
||||
{{ $t('assetBrowser.civitaiLinkExample') }}
|
||||
</p>
|
||||
<p v-else v-html="$t('assetBrowser.civitaiLinkExample')"></p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -4,18 +4,18 @@ import { api } from '@/scripts/api'
|
||||
|
||||
/**
|
||||
* Format folder name to display name
|
||||
* Converts "upscale_models" -> "Upscale Models"
|
||||
* Converts "loras" -> "LoRAs"
|
||||
* Converts "upscale_models" -> "Upscale Model"
|
||||
* Converts "loras" -> "LoRA"
|
||||
*/
|
||||
function formatDisplayName(folderName: string): string {
|
||||
// Special cases for acronyms and proper nouns
|
||||
const specialCases: Record<string, string> = {
|
||||
loras: 'LoRAs',
|
||||
loras: 'LoRA',
|
||||
ipadapter: 'IP-Adapter',
|
||||
sams: 'SAMs',
|
||||
sams: 'SAM',
|
||||
clip_vision: 'CLIP Vision',
|
||||
animatediff_motion_lora: 'AnimateDiff Motion LoRA',
|
||||
animatediff_models: 'AnimateDiff Models',
|
||||
animatediff_models: 'AnimateDiff Model',
|
||||
vae: 'VAE',
|
||||
sam2: 'SAM 2',
|
||||
controlnet: 'ControlNet',
|
||||
|
||||
@@ -31,7 +31,7 @@ export function useUploadModelWizard(modelTypes: Ref<ModelTypeOption[]>) {
|
||||
tags: []
|
||||
})
|
||||
|
||||
const selectedModelType = ref<string | undefined>(undefined)
|
||||
const selectedModelType = ref<string>()
|
||||
|
||||
// Clear error when URL changes
|
||||
watch(
|
||||
|
||||
@@ -44,7 +44,7 @@
|
||||
<div class="flex justify-between pt-4">
|
||||
<span />
|
||||
<Button
|
||||
label="Next"
|
||||
:label="$t('g.next')"
|
||||
:disabled="!validStep1"
|
||||
class="h-10 w-full border-none text-white"
|
||||
@click="goTo(2, activateCallback)"
|
||||
@@ -84,20 +84,22 @@
|
||||
<InputText
|
||||
v-model="surveyData.useCaseOther"
|
||||
class="w-full"
|
||||
placeholder="Please specify"
|
||||
:placeholder="
|
||||
$t('cloudOnboarding.survey.options.industry.otherPlaceholder')
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-6 pt-4">
|
||||
<Button
|
||||
label="Back"
|
||||
:label="$t('g.back')"
|
||||
severity="secondary"
|
||||
class="flex-1 text-white"
|
||||
@click="goTo(1, activateCallback)"
|
||||
/>
|
||||
<Button
|
||||
label="Next"
|
||||
:label="$t('g.next')"
|
||||
:disabled="!validStep2"
|
||||
class="h-10 flex-1 text-white"
|
||||
@click="goTo(3, activateCallback)"
|
||||
@@ -137,20 +139,22 @@
|
||||
<InputText
|
||||
v-model="surveyData.industryOther"
|
||||
class="w-full"
|
||||
placeholder="Please specify"
|
||||
:placeholder="
|
||||
$t('cloudOnboarding.survey.options.industry.otherPlaceholder')
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-6 pt-4">
|
||||
<Button
|
||||
label="Back"
|
||||
:label="$t('g.back')"
|
||||
severity="secondary"
|
||||
class="flex-1 text-white"
|
||||
@click="goTo(2, activateCallback)"
|
||||
/>
|
||||
<Button
|
||||
label="Next"
|
||||
:label="$t('g.next')"
|
||||
:disabled="!validStep3"
|
||||
class="h-10 flex-1 border-none text-white"
|
||||
@click="goTo(4, activateCallback)"
|
||||
@@ -189,13 +193,13 @@
|
||||
|
||||
<div class="flex gap-6 pt-4">
|
||||
<Button
|
||||
label="Back"
|
||||
:label="$t('g.back')"
|
||||
severity="secondary"
|
||||
class="flex-1 text-white"
|
||||
@click="goTo(3, activateCallback)"
|
||||
/>
|
||||
<Button
|
||||
label="Submit"
|
||||
:label="$t('g.submit')"
|
||||
:disabled="!validStep4 || isSubmitting"
|
||||
:loading="isSubmitting"
|
||||
class="h-10 flex-1 border-none text-white"
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<div class="mx-auto flex h-[7%] max-h-[70px] w-5/6 items-end">
|
||||
<img
|
||||
src="/assets/images/comfy-cloud-logo.svg"
|
||||
alt="Comfy Cloud Logo"
|
||||
:alt="$t('subscription.comfyCloudLogo')"
|
||||
class="h-3/4 max-h-10 w-auto"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { TelemetryEventName } from '@/platform/telemetry/types'
|
||||
|
||||
/**
|
||||
* Server health alert configuration from the backend
|
||||
*/
|
||||
@@ -31,4 +33,7 @@ export type RemoteConfig = {
|
||||
comfy_api_base_url?: string
|
||||
comfy_platform_base_url?: string
|
||||
firebase_config?: FirebaseRuntimeConfig
|
||||
telemetry_disabled_events?: TelemetryEventName[]
|
||||
model_upload_button_enabled?: boolean
|
||||
asset_update_options_enabled?: boolean
|
||||
}
|
||||
|
||||
@@ -8,9 +8,11 @@ import {
|
||||
} from '@/platform/settings/settingStore'
|
||||
import type { ISettingGroup, SettingParams } from '@/platform/settings/types'
|
||||
import { normalizeI18nKey } from '@/utils/formatUtil'
|
||||
import { useVueFeatureFlags } from '@/composables/useVueFeatureFlags'
|
||||
|
||||
export function useSettingSearch() {
|
||||
const settingStore = useSettingStore()
|
||||
const { shouldRenderVueNodes } = useVueFeatureFlags()
|
||||
|
||||
const searchQuery = ref<string>('')
|
||||
const filteredSettingIds = ref<string[]>([])
|
||||
@@ -54,7 +56,11 @@ export function useSettingSearch() {
|
||||
const allSettings = Object.values(settingStore.settingsById)
|
||||
const filteredSettings = allSettings.filter((setting) => {
|
||||
// Filter out hidden and deprecated settings, just like in normal settings tree
|
||||
if (setting.type === 'hidden' || setting.deprecated) {
|
||||
if (
|
||||
setting.type === 'hidden' ||
|
||||
setting.deprecated ||
|
||||
(shouldRenderVueNodes.value && setting.hideInVueNodes)
|
||||
) {
|
||||
return false
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import type { SettingParams } from '@/platform/settings/types'
|
||||
import { isElectron } from '@/utils/envUtil'
|
||||
import { normalizeI18nKey } from '@/utils/formatUtil'
|
||||
import { buildTree } from '@/utils/treeUtil'
|
||||
import { useVueFeatureFlags } from '@/composables/useVueFeatureFlags'
|
||||
|
||||
interface SettingPanelItem {
|
||||
node: SettingTreeNode
|
||||
@@ -31,10 +32,14 @@ export function useSettingUI(
|
||||
const settingStore = useSettingStore()
|
||||
const activeCategory = ref<SettingTreeNode | null>(null)
|
||||
|
||||
const { shouldRenderVueNodes } = useVueFeatureFlags()
|
||||
|
||||
const settingRoot = computed<SettingTreeNode>(() => {
|
||||
const root = buildTree(
|
||||
Object.values(settingStore.settingsById).filter(
|
||||
(setting: SettingParams) => setting.type !== 'hidden'
|
||||
(setting: SettingParams) =>
|
||||
setting.type !== 'hidden' &&
|
||||
!(shouldRenderVueNodes.value && setting.hideInVueNodes)
|
||||
),
|
||||
(setting: SettingParams) => setting.category || setting.id.split('.')
|
||||
)
|
||||
|
||||
@@ -919,7 +919,8 @@ export const CORE_SETTINGS: SettingParams[] = [
|
||||
step: 1
|
||||
},
|
||||
defaultValue: 8,
|
||||
versionAdded: '1.26.7'
|
||||
versionAdded: '1.26.7',
|
||||
hideInVueNodes: true
|
||||
},
|
||||
{
|
||||
id: 'Comfy.Canvas.SelectionToolbox',
|
||||
@@ -1101,7 +1102,7 @@ export const CORE_SETTINGS: SettingParams[] = [
|
||||
category: ['Comfy', 'Nodes 2.0', 'AutoScaleLayout'],
|
||||
name: 'Auto-scale layout (Nodes 2.0)',
|
||||
tooltip:
|
||||
'Automatically scale node positions when switching to Vue rendering to prevent overlap',
|
||||
'Automatically scale node positions when switching to Nodes 2.0 rendering to prevent overlap',
|
||||
type: 'boolean',
|
||||
sortOrder: 50,
|
||||
experimental: true,
|
||||
|
||||
@@ -47,6 +47,7 @@ export interface SettingParams<TValue = unknown> extends FormItem {
|
||||
// sortOrder for sorting settings within a group. Higher values appear first.
|
||||
// Default is 0 if not specified.
|
||||
sortOrder?: number
|
||||
hideInVueNodes?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useRoute, useRouter } from 'vue-router'
|
||||
|
||||
import { clearPreservedQuery } from '@/platform/navigation/preservedQueryManager'
|
||||
import { PRESERVED_QUERY_NAMESPACES } from '@/platform/navigation/preservedQueryNamespaces'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
|
||||
import { useTemplateWorkflows } from './useTemplateWorkflows'
|
||||
|
||||
@@ -13,9 +14,10 @@ import { useTemplateWorkflows } from './useTemplateWorkflows'
|
||||
* Supports URLs like:
|
||||
* - /?template=flux_simple (loads with default source)
|
||||
* - /?template=flux_simple&source=custom (loads from custom source)
|
||||
* - /?template=flux_simple&mode=linear (loads template in linear mode)
|
||||
*
|
||||
* Input validation:
|
||||
* - Template and source parameters must match: ^[a-zA-Z0-9_-]+$
|
||||
* - Template, source, and mode parameters must match: ^[a-zA-Z0-9_-]+$
|
||||
* - Invalid formats are rejected with console warnings
|
||||
*/
|
||||
export function useTemplateUrlLoader() {
|
||||
@@ -24,7 +26,10 @@ export function useTemplateUrlLoader() {
|
||||
const { t } = useI18n()
|
||||
const toast = useToast()
|
||||
const templateWorkflows = useTemplateWorkflows()
|
||||
const canvasStore = useCanvasStore()
|
||||
const TEMPLATE_NAMESPACE = PRESERVED_QUERY_NAMESPACES.TEMPLATE
|
||||
const SUPPORTED_MODES = ['linear'] as const
|
||||
type SupportedMode = (typeof SUPPORTED_MODES)[number]
|
||||
|
||||
/**
|
||||
* Validates parameter format to prevent path traversal and injection attacks
|
||||
@@ -34,12 +39,20 @@ export function useTemplateUrlLoader() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes template and source parameters from URL
|
||||
* Type guard to check if a value is a supported mode
|
||||
*/
|
||||
const isSupportedMode = (mode: string): mode is SupportedMode => {
|
||||
return SUPPORTED_MODES.includes(mode as SupportedMode)
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes template, source, and mode parameters from URL
|
||||
*/
|
||||
const cleanupUrlParams = () => {
|
||||
const newQuery = { ...route.query }
|
||||
delete newQuery.template
|
||||
delete newQuery.source
|
||||
delete newQuery.mode
|
||||
void router.replace({ query: newQuery })
|
||||
}
|
||||
|
||||
@@ -70,6 +83,24 @@ export function useTemplateUrlLoader() {
|
||||
return
|
||||
}
|
||||
|
||||
const modeParam = route.query.mode as string | undefined
|
||||
|
||||
if (
|
||||
modeParam &&
|
||||
(typeof modeParam !== 'string' || !isValidParameter(modeParam))
|
||||
) {
|
||||
console.warn(
|
||||
`[useTemplateUrlLoader] Invalid mode parameter format: ${modeParam}`
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
if (modeParam && !isSupportedMode(modeParam)) {
|
||||
console.warn(
|
||||
`[useTemplateUrlLoader] Unsupported mode parameter: ${modeParam}. Supported modes: ${SUPPORTED_MODES.join(', ')}`
|
||||
)
|
||||
}
|
||||
|
||||
try {
|
||||
await templateWorkflows.loadTemplates()
|
||||
|
||||
@@ -87,6 +118,9 @@ export function useTemplateUrlLoader() {
|
||||
}),
|
||||
life: 3000
|
||||
})
|
||||
} else if (modeParam === 'linear') {
|
||||
// Set linear mode after successful template load
|
||||
canvasStore.linearMode = true
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(
|
||||
|
||||
@@ -29,12 +29,6 @@ vi.mock('@/renderer/core/layout/transform/useTransformState', () => {
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/renderer/extensions/vueNodes/lod/useLOD', () => ({
|
||||
useLOD: vi.fn(() => ({
|
||||
isLOD: false
|
||||
}))
|
||||
}))
|
||||
|
||||
function createMockCanvas(): LGraphCanvas {
|
||||
return {
|
||||
canvas: {
|
||||
|
||||
@@ -9,6 +9,8 @@ import { computed, customRef, ref } from 'vue'
|
||||
import type { ComputedRef, Ref } from 'vue'
|
||||
import * as Y from 'yjs'
|
||||
|
||||
import { removeNodeTitleHeight } from '@/renderer/core/layout/utils/nodeSizeUtil'
|
||||
|
||||
import { ACTOR_CONFIG } from '@/renderer/core/layout/constants'
|
||||
import { LayoutSource } from '@/renderer/core/layout/types'
|
||||
import type {
|
||||
@@ -136,6 +138,8 @@ class LayoutStoreImpl implements LayoutStore {
|
||||
|
||||
// Vue dragging state for selection toolbox (public ref for direct mutation)
|
||||
public isDraggingVueNodes = ref(false)
|
||||
// Vue resizing state to prevent drag from activating during resize
|
||||
public isResizingVueNodes = ref(false)
|
||||
|
||||
constructor() {
|
||||
// Initialize Yjs data structures
|
||||
@@ -1414,8 +1418,8 @@ class LayoutStoreImpl implements LayoutStore {
|
||||
batchUpdateNodeBounds(updates: NodeBoundsUpdate[]): void {
|
||||
if (updates.length === 0) return
|
||||
|
||||
// Set source to Vue for these DOM-driven updates
|
||||
const originalSource = this.currentSource
|
||||
const shouldNormalizeHeights = originalSource === LayoutSource.DOM
|
||||
this.currentSource = LayoutSource.Vue
|
||||
|
||||
const nodeIds: NodeId[] = []
|
||||
@@ -1426,8 +1430,15 @@ class LayoutStoreImpl implements LayoutStore {
|
||||
if (!ynode) continue
|
||||
const currentLayout = yNodeToLayout(ynode)
|
||||
|
||||
const normalizedBounds = shouldNormalizeHeights
|
||||
? {
|
||||
...bounds,
|
||||
height: removeNodeTitleHeight(bounds.height)
|
||||
}
|
||||
: bounds
|
||||
|
||||
boundsRecord[nodeId] = {
|
||||
bounds,
|
||||
bounds: normalizedBounds,
|
||||
previousBounds: currentLayout.bounds
|
||||
}
|
||||
nodeIds.push(nodeId)
|
||||
|
||||
@@ -8,6 +8,7 @@ import { onUnmounted, ref } from 'vue'
|
||||
|
||||
import type { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
|
||||
import { addNodeTitleHeight } from '@/renderer/core/layout/utils/nodeSizeUtil'
|
||||
|
||||
/**
|
||||
* Composable for syncing LiteGraph with the Layout system
|
||||
@@ -43,12 +44,13 @@ export function useLayoutSync() {
|
||||
liteNode.pos[1] = layout.position.y
|
||||
}
|
||||
|
||||
const targetHeight = addNodeTitleHeight(layout.size.height)
|
||||
if (
|
||||
liteNode.size[0] !== layout.size.width ||
|
||||
liteNode.size[1] !== layout.size.height
|
||||
liteNode.size[1] !== targetHeight
|
||||
) {
|
||||
// Use setSize() to trigger onResize callback
|
||||
liteNode.setSize([layout.size.width, layout.size.height])
|
||||
liteNode.setSize([layout.size.width, targetHeight])
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,8 +4,7 @@
|
||||
:class="
|
||||
cn(
|
||||
'absolute inset-0 w-full h-full pointer-events-none',
|
||||
isInteracting ? 'transform-pane--interacting' : 'will-change-auto',
|
||||
isLOD && 'isLOD'
|
||||
isInteracting ? 'transform-pane--interacting' : 'will-change-auto'
|
||||
)
|
||||
"
|
||||
:style="transformStyle"
|
||||
@@ -22,7 +21,6 @@ import { computed } from 'vue'
|
||||
import type { LGraphCanvas } from '@/lib/litegraph/src/litegraph'
|
||||
import { useTransformSettling } from '@/renderer/core/layout/transform/useTransformSettling'
|
||||
import { useTransformState } from '@/renderer/core/layout/transform/useTransformState'
|
||||
import { useLOD } from '@/renderer/extensions/vueNodes/lod/useLOD'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
interface TransformPaneProps {
|
||||
@@ -31,9 +29,7 @@ interface TransformPaneProps {
|
||||
|
||||
const props = defineProps<TransformPaneProps>()
|
||||
|
||||
const { camera, transformStyle, syncWithCanvas } = useTransformState()
|
||||
|
||||
const { isLOD } = useLOD(camera)
|
||||
const { transformStyle, syncWithCanvas } = useTransformState()
|
||||
|
||||
const canvasElement = computed(() => props.canvas?.canvas)
|
||||
const { isTransforming: isInteracting } = useTransformSettling(canvasElement, {
|
||||
|
||||
@@ -10,6 +10,7 @@ import type { ComputedRef, Ref } from 'vue'
|
||||
export enum LayoutSource {
|
||||
Canvas = 'canvas',
|
||||
Vue = 'vue',
|
||||
DOM = 'dom',
|
||||
External = 'external'
|
||||
}
|
||||
|
||||
|
||||
7
src/renderer/core/layout/utils/nodeSizeUtil.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
|
||||
|
||||
export const removeNodeTitleHeight = (height: number) =>
|
||||
Math.max(0, height - (LiteGraph.NODE_TITLE_HEIGHT || 0))
|
||||
|
||||
export const addNodeTitleHeight = (height: number) =>
|
||||
height + LiteGraph.NODE_TITLE_HEIGHT
|
||||
@@ -83,20 +83,17 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="relative">
|
||||
<!-- Video Dimensions -->
|
||||
<div class="mt-2 text-center text-xs text-white">
|
||||
<span v-if="videoError" class="text-red-400">
|
||||
{{ $t('g.errorLoadingVideo') }}
|
||||
</span>
|
||||
<span v-else-if="isLoading" class="text-smoke-400">
|
||||
{{ $t('g.loading') }}...
|
||||
</span>
|
||||
<span v-else>
|
||||
{{ actualDimensions || $t('g.calculatingDimensions') }}
|
||||
</span>
|
||||
</div>
|
||||
<LODFallback />
|
||||
<!-- Video Dimensions -->
|
||||
<div class="mt-2 text-center text-xs text-white">
|
||||
<span v-if="videoError" class="text-red-400">
|
||||
{{ $t('g.errorLoadingVideo') }}
|
||||
</span>
|
||||
<span v-else-if="isLoading" class="text-smoke-400">
|
||||
{{ $t('g.loading') }}...
|
||||
</span>
|
||||
<span v-else>
|
||||
{{ actualDimensions || $t('g.calculatingDimensions') }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -110,8 +107,6 @@ import { useI18n } from 'vue-i18n'
|
||||
import { downloadFile } from '@/base/common/downloadUtil'
|
||||
import { useNodeOutputStore } from '@/stores/imagePreviewStore'
|
||||
|
||||
import LODFallback from './components/LODFallback.vue'
|
||||
|
||||
interface VideoPreviewProps {
|
||||
/** Array of video URLs to display */
|
||||
readonly imageUrls: readonly string[] // Named imageUrls for consistency with parent components
|
||||
|
||||
@@ -93,20 +93,17 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="relative">
|
||||
<!-- Image Dimensions -->
|
||||
<div class="mt-2 text-center text-xs text-white">
|
||||
<span v-if="imageError" class="text-red-400">
|
||||
{{ $t('g.errorLoadingImage') }}
|
||||
</span>
|
||||
<span v-else-if="isLoading" class="text-smoke-400">
|
||||
{{ $t('g.loading') }}...
|
||||
</span>
|
||||
<span v-else>
|
||||
{{ actualDimensions || $t('g.calculatingDimensions') }}
|
||||
</span>
|
||||
</div>
|
||||
<LODFallback />
|
||||
<!-- Image Dimensions -->
|
||||
<div class="mt-2 text-center text-xs text-white">
|
||||
<span v-if="imageError" class="text-red-400">
|
||||
{{ $t('g.errorLoadingImage') }}
|
||||
</span>
|
||||
<span v-else-if="isLoading" class="text-smoke-400">
|
||||
{{ $t('g.loading') }}...
|
||||
</span>
|
||||
<span v-else>
|
||||
{{ actualDimensions || $t('g.calculatingDimensions') }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -122,8 +119,6 @@ import { app } from '@/scripts/app'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import { useNodeOutputStore } from '@/stores/imagePreviewStore'
|
||||
|
||||
import LODFallback from './LODFallback.vue'
|
||||
|
||||
interface ImagePreviewProps {
|
||||
/** Array of image URLs to display */
|
||||
readonly imageUrls: readonly string[]
|
||||
|
||||
@@ -10,14 +10,13 @@
|
||||
/>
|
||||
|
||||
<!-- Slot Name -->
|
||||
<div class="relative h-full flex items-center min-w-0">
|
||||
<div class="h-full flex items-center min-w-0">
|
||||
<span
|
||||
v-if="!dotOnly"
|
||||
:class="cn('truncate text-xs font-normal lod-toggle', labelClasses)"
|
||||
:class="cn('truncate text-xs font-normal', labelClasses)"
|
||||
>
|
||||
{{ slotData.localized_name || slotData.name || `Input ${index}` }}
|
||||
</span>
|
||||
<LODFallback />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -37,7 +36,6 @@ import { useSlotLinkInteraction } from '@/renderer/extensions/vueNodes/composabl
|
||||
import { useExecutionStore } from '@/stores/executionStore'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
import LODFallback from './LODFallback.vue'
|
||||
import SlotConnectionDot from './SlotConnectionDot.vue'
|
||||
|
||||
interface InputSlotProps {
|
||||
|
||||