Compare commits

..

4 Commits

Author SHA1 Message Date
coderabbitai[bot]
6d61dcbbe1 CodeRabbit Generated Unit Tests: Add tests for ensureCorrectLayoutScale and LGraph types 2025-11-21 22:40:57 +00:00
DrJKL
405fcb3ee5 fix: Misplaced parentheses 2025-11-21 14:25:05 -08:00
DrJKL
177c12fcdc refactor: More naming alignment 2025-11-21 14:25:05 -08:00
DrJKL
640fb97760 refactor: Reorganizing scaling logic.
Consistent names and order of operations
2025-11-21 14:25:05 -08:00
204 changed files with 5771 additions and 8669 deletions

View File

@@ -1,5 +1,5 @@
# Description: When upstream electron API is updated, click dispatch to update the TypeScript type definitions in this repo
name: 'Api: Update Electron API Types'
description: 'When upstream electron API is updated, click dispatch to update the TypeScript type definitions in this repo'
on:
workflow_dispatch:

View File

@@ -1,5 +1,5 @@
# Description: When upstream ComfyUI-Manager API is updated, click dispatch to update the TypeScript type definitions in this repo
name: 'Api: Update Manager API Types'
description: 'When upstream ComfyUI-Manager API is updated, click dispatch to update the TypeScript type definitions in this repo'
on:
# Manual trigger

View File

@@ -1,5 +1,5 @@
# Description: When upstream comfy-api is updated, click dispatch to update the TypeScript type definitions in this repo
name: 'Api: Update Registry API Types'
description: 'When upstream comfy-api is updated, click dispatch to update the TypeScript type definitions in this repo'
on:
# Manual trigger

View File

@@ -1,5 +1,5 @@
# Description: Validates JSON syntax in all tracked .json files (excluding tsconfig*.json) using jq
name: "CI: JSON Validation"
description: "Validates JSON syntax in all tracked .json files (excluding tsconfig*.json) using jq"
on:
push:

View File

@@ -1,5 +1,5 @@
# Description: Linting and code formatting validation for pull requests
name: "CI: Lint Format"
description: "Linting and code formatting validation for pull requests"
on:
pull_request:

View File

@@ -1,5 +1,5 @@
# Description: Validates Python code in tools/devtools directory
name: "CI: Python Validation"
description: "Validates Python code in tools/devtools directory"
on:
pull_request:

View File

@@ -1,5 +1,5 @@
# Description: Deploys test results from forked PRs (forks can't access deployment secrets)
name: "CI: Tests E2E (Deploy for Forks)"
description: "Deploys test results from forked PRs (forks can't access deployment secrets)"
on:
workflow_run:

View File

@@ -1,5 +1,5 @@
# Description: End-to-end testing with Playwright across multiple browsers, deploys test reports to Cloudflare Pages
name: "CI: Tests E2E"
description: "End-to-end testing with Playwright across multiple browsers, deploys test reports to Cloudflare Pages"
on:
push:

View File

@@ -1,5 +1,5 @@
# Description: Deploys Storybook previews from forked PRs (forks can't access deployment secrets)
name: "CI: Tests Storybook (Deploy for Forks)"
description: "Deploys Storybook previews from forked PRs (forks can't access deployment secrets)"
on:
workflow_run:

View File

@@ -1,9 +1,10 @@
# Description: Builds Storybook and runs visual regression testing via Chromatic, deploys previews to Cloudflare Pages
name: "CI: Tests Storybook"
description: "Builds Storybook and runs visual regression testing via Chromatic, deploys previews to Cloudflare Pages"
on:
workflow_dispatch: # Allow manual triggering
pull_request:
branches: [main]
jobs:
# Post starting comment for non-forked PRs

View File

@@ -1,5 +1,5 @@
# Description: Unit and component testing with Vitest
name: "CI: Tests Unit"
description: "Unit and component testing with Vitest"
on:
push:

View File

@@ -1,5 +1,5 @@
# Description: Validates YAML syntax and style using yamllint with relaxed rules
name: "CI: YAML Validation"
description: "Validates YAML syntax and style using yamllint with relaxed rules"
on:
push:

View File

@@ -1,69 +0,0 @@
---
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"

View File

@@ -1,5 +1,5 @@
# Description: Generates and updates translations for core ComfyUI components using OpenAI
name: "i18n: Update Core"
description: "Generates and updates translations for core ComfyUI components using OpenAI"
on:
# Manual dispatch for urgent translation updates

View File

@@ -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=$(jq -r '.pull_request.title' "$GITHUB_EVENT_PATH")
MERGE_COMMIT=$(jq -r '.pull_request.merge_commit_sha' "$GITHUB_EVENT_PATH")
PR_TITLE="${{ github.event.pull_request.title }}"
MERGE_COMMIT="${{ github.event.pull_request.merge_commit_sha }}"
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=$(jq -r '.pull_request.title' "$GITHUB_EVENT_PATH")
PR_AUTHOR=$(jq -r '.pull_request.user.login' "$GITHUB_EVENT_PATH")
PR_TITLE="${{ github.event.pull_request.title }}"
PR_AUTHOR="${{ github.event.pull_request.user.login }}"
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=$(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")
PR_NUMBER="${{ github.event.pull_request.number }}"
PR_AUTHOR="${{ github.event.pull_request.user.login }}"
MERGE_COMMIT="${{ github.event.pull_request.merge_commit_sha }}"
fi
for failure in ${{ steps.backport.outputs.failed }}; do

View File

@@ -1,5 +1,5 @@
# Description: AI-powered code review triggered by adding the 'claude-review' label to a PR
name: "PR: Claude Review"
description: "AI-powered code review triggered by adding the 'claude-review' label to a PR"
permissions:
contents: read

View File

@@ -148,10 +148,10 @@ jobs:
done
{
echo "results<<EOF"
echo "results<<'EOF'"
cat "$RESULTS_FILE"
echo "EOF"
} >> "$GITHUB_OUTPUT"
} >> $GITHUB_OUTPUT
- name: Ensure release labels
if: steps.check_version.outputs.is_minor_bump == 'true'

View File

@@ -1,5 +1,5 @@
# Description: Manual workflow to increment package version with semantic versioning support
name: "Release: Version Bump"
description: "Manual workflow to increment package version with semantic versioning support"
on:
workflow_dispatch:

View File

@@ -1,5 +1,5 @@
# Description: Automated weekly documentation accuracy check and update via Claude
name: "Weekly Documentation Check"
description: "Automated weekly documentation accuracy check and update via Claude"
permissions:
contents: write

View File

@@ -64,6 +64,7 @@ const config: StorybookConfig = {
deep: true,
extensions: ['vue']
})
// Note: Explicitly NOT including generateImportMapPlugin to avoid externalization
],
server: {
allowedHosts: true

View File

@@ -1,11 +1,8 @@
# Global Ownership
* @Comfy-org/comfy_frontend_devs
# Desktop/Electron
/apps/desktop-ui/ @benceruleanlu
/src/stores/electronDownloadStore.ts @benceruleanlu
/src/extensions/core/electronAdapter.ts @benceruleanlu
/vite.electron.config.mts @benceruleanlu
/apps/desktop-ui/ @webfiltered
/src/stores/electronDownloadStore.ts @webfiltered
/src/extensions/core/electronAdapter.ts @webfiltered
/vite.electron.config.mts @webfiltered
# Common UI Components
/src/components/chip/ @viva-jinyi
@@ -34,7 +31,10 @@
/src/components/graph/selectionToolbox/ @Myestery
# Minimap
/src/renderer/extensions/minimap/ @jtydhr88 @Myestery
/src/renderer/extensions/minimap/ @jtydhr88
# Assets
/src/platform/assets/ @arjansingh
# 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 @Comfy-org/comfy_frontend_devs
/src/locales/ @Yorha4D @KarryCharon @shinshin86 @Comfy-Org/comfy_maintainer
# LLM Instructions (blank on purpose)
.claude/

View File

@@ -1,229 +0,0 @@
# 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

View File

@@ -1,196 +0,0 @@
# 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

View File

@@ -1,6 +1,6 @@
{
"name": "@comfyorg/desktop-ui",
"version": "0.0.4",
"version": "0.0.3",
"type": "module",
"nx": {
"tags": [

View File

@@ -22,11 +22,7 @@
<h1 v-if="title" class="font-inter font-bold text-3xl text-neutral-300">
{{ title }}
</h1>
<p
v-if="statusText"
class="text-lg text-neutral-400"
data-testid="startup-status-text"
>
<p v-if="statusText" class="text-lg text-neutral-400">
{{ statusText }}
</p>
</div>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 130 KiB

After

Width:  |  Height:  |  Size: 125 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 54 KiB

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 59 KiB

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 61 KiB

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 57 KiB

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 56 KiB

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 112 KiB

After

Width:  |  Height:  |  Size: 109 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

After

Width:  |  Height:  |  Size: 37 KiB

View File

@@ -1,54 +0,0 @@
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)
})
})

Binary file not shown.

Before

Width:  |  Height:  |  Size: 122 KiB

After

Width:  |  Height:  |  Size: 119 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 122 KiB

After

Width:  |  Height:  |  Size: 119 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 150 KiB

After

Width:  |  Height:  |  Size: 150 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 142 KiB

After

Width:  |  Height:  |  Size: 142 KiB

View File

@@ -0,0 +1,49 @@
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.setSetting('LiteGraph.Canvas.MinFontSizeForLOD', 8)
await comfyPage.setup()
await comfyPage.loadWorkflow('default')
})
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()
})
})

Binary file not shown.

Before

Width:  |  Height:  |  Size: 102 KiB

After

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 100 KiB

After

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 122 KiB

After

Width:  |  Height:  |  Size: 119 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 81 KiB

After

Width:  |  Height:  |  Size: 82 KiB

View File

@@ -88,14 +88,12 @@ export function comfyAPIPlugin(isDev: boolean): Plugin {
if (result.exports.length > 0) {
const projectRoot = process.cwd()
const relativePath = path
.relative(path.join(projectRoot, 'src'), id)
.replace(/\\/g, '/')
const relativePath = path.relative(path.join(projectRoot, 'src'), id)
const shimFileName = relativePath.replace(/\.ts$/, '.js')
let shimContent = `// Shim for ${relativePath}\n`
const fileKey = relativePath.replace(/\.ts$/, '')
const fileKey = relativePath.replace(/\.ts$/, '').replace(/\\/g, '/')
const warningMessage = getWarningMessage(fileKey, shimFileName)
if (warningMessage) {

View File

@@ -0,0 +1,154 @@
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]
}
}
}
}

View File

@@ -1 +1,2 @@
export { comfyAPIPlugin } from './comfyAPIPlugin'
export { generateImportMapPlugin } from './generateImportMapPlugin'

View File

@@ -1,24 +0,0 @@
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 doesnt hit this because it still reads from `widget.options.values` immediately.

View File

@@ -191,19 +191,6 @@ 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)
@@ -213,27 +200,24 @@ export default defineConfig([
ignoreNodes: ['md-icon', 'v-icon', 'pre', 'code', 'script', 'style'],
// Brand names and technical terms that shouldn't be translated
ignoreText: [
'API',
'App Data:',
'App Path:',
'ComfyUI',
'CPU',
'fps',
'GB',
'GitHub',
'GPU',
'JSON',
'KB',
'LoRA',
'MB',
'ms',
'OpenAI',
'png',
'px',
'RAM',
'API',
'URL',
'JSON',
'YAML',
'1.2 MB'
'GPU',
'CPU',
'RAM',
'GB',
'MB',
'KB',
'ms',
'fps',
'px',
'App Data:',
'App Path:'
]
}
]

View File

@@ -1,56 +0,0 @@
<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>

View File

@@ -1,7 +1,7 @@
{
"name": "@comfyorg/comfyui-frontend",
"private": true,
"version": "1.33.9",
"version": "1.33.5",
"type": "module",
"repository": "https://github.com/Comfy-Org/ComfyUI_frontend",
"homepage": "https://comfy.org",
@@ -75,7 +75,6 @@
"@vitest/coverage-v8": "catalog:",
"@vitest/ui": "catalog:",
"@vue/test-utils": "catalog:",
"@webgpu/types": "catalog:",
"cross-env": "catalog:",
"eslint": "catalog:",
"eslint-config-prettier": "catalog:",
@@ -113,7 +112,6 @@
"typescript": "catalog:",
"typescript-eslint": "catalog:",
"unplugin-icons": "catalog:",
"unplugin-typegpu": "catalog:",
"unplugin-vue-components": "catalog:",
"uuid": "^11.1.0",
"vite": "catalog:",
@@ -164,6 +162,7 @@
"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",
@@ -177,7 +176,6 @@
"semver": "^7.7.2",
"three": "^0.170.0",
"tiptap-markdown": "^0.8.10",
"typegpu": "catalog:",
"vue": "catalog:",
"vue-i18n": "catalog:",
"vue-router": "catalog:",

View File

@@ -1329,6 +1329,57 @@ 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 {

View File

@@ -75,17 +75,6 @@ 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:

103
pnpm-lock.yaml generated
View File

@@ -15,9 +15,15 @@ catalogs:
'@eslint/js':
specifier: ^9.35.0
version: 9.35.0
'@iconify-json/lucide':
specifier: ^1.1.178
version: 1.2.66
'@iconify/json':
specifier: ^2.2.380
version: 2.2.380
'@iconify/tailwind':
specifier: ^1.1.3
version: 1.2.0
'@intlify/eslint-plugin-vue-i18n':
specifier: ^4.1.0
version: 4.1.0
@@ -120,9 +126,6 @@ catalogs:
'@vueuse/integrations':
specifier: ^13.9.0
version: 13.9.0
'@webgpu/types':
specifier: ^0.1.66
version: 0.1.66
algoliasearch:
specifier: ^5.21.0
version: 5.21.0
@@ -243,9 +246,6 @@ catalogs:
tw-animate-css:
specifier: ^1.3.8
version: 1.3.8
typegpu:
specifier: ^0.8.2
version: 0.8.2
typescript:
specifier: ^5.9.2
version: 5.9.2
@@ -255,9 +255,6 @@ catalogs:
unplugin-icons:
specifier: ^0.22.0
version: 0.22.0
unplugin-typegpu:
specifier: 0.8.0
version: 0.8.0
unplugin-vue-components:
specifier: ^0.28.0
version: 0.28.0
@@ -425,6 +422,9 @@ 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
@@ -464,9 +464,6 @@ importers:
tiptap-markdown:
specifier: ^0.8.10
version: 0.8.10(@tiptap/core@2.10.4(@tiptap/pm@2.10.4))
typegpu:
specifier: 'catalog:'
version: 0.8.2
vue:
specifier: 'catalog:'
version: 3.5.13(typescript@5.9.2)
@@ -564,9 +561,6 @@ importers:
'@vue/test-utils':
specifier: 'catalog:'
version: 2.4.6
'@webgpu/types':
specifier: 'catalog:'
version: 0.1.66
cross-env:
specifier: 'catalog:'
version: 10.1.0
@@ -678,9 +672,6 @@ importers:
unplugin-icons:
specifier: 'catalog:'
version: 0.22.0(@vue/compiler-sfc@3.5.13)
unplugin-typegpu:
specifier: 'catalog:'
version: 0.8.0(typegpu@0.8.2)
unplugin-vue-components:
specifier: 'catalog:'
version: 0.28.0(@babel/parser@7.28.4)(rollup@4.22.4)(vue@3.5.13(typescript@5.9.2))
@@ -1440,10 +1431,6 @@ packages:
resolution: {integrity: sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==}
engines: {node: '>=6.9.0'}
'@babel/standalone@7.28.5':
resolution: {integrity: sha512-1DViPYJpRU50irpGMfLBQ9B4kyfQuL6X7SS7pwTeWeZX0mNkjzPi0XFqxCjSdddZXUQy4AhnQnnesA/ZHnvAdw==}
engines: {node: '>=6.9.0'}
'@babel/template@7.27.2':
resolution: {integrity: sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==}
engines: {node: '>=6.9.0'}
@@ -3803,8 +3790,8 @@ packages:
peerDependencies:
vue: ^3.5.0
'@webgpu/types@0.1.66':
resolution: {integrity: sha512-YA2hLrwLpDsRueNDXIMqN9NTzD6bCDkuXbOSe0heS+f8YE8usA6Gbv1prj81pzVHrbaAma7zObnIC+I6/sXJgA==}
'@webgpu/types@0.1.51':
resolution: {integrity: sha512-ktR3u64NPjwIViNCck+z9QeyN0iPkQCUOQ07ZCV1RzlkfP+olLTeEZ95O1QHS+v4w9vJeY9xj/uJuSphsHy5rQ==}
'@xstate/fsm@1.6.5':
resolution: {integrity: sha512-b5o1I6aLNeYlU/3CPlj/Z91ybk1gUsKT+5NAJI+2W4UjvS5KLG28K9v5UvNoFVjHV8PajVZ00RH3vnjyQO7ZAw==}
@@ -6051,10 +6038,6 @@ packages:
resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==}
hasBin: true
magic-string-ast@1.0.3:
resolution: {integrity: sha512-CvkkH1i81zl7mmb94DsRiFeG9V2fR2JeuK8yDgS8oiZSFa++wWLEgZ5ufEOyLHbvSbD1gTRKv9NdX69Rnvr9JA==}
engines: {node: '>=20.19.0'}
magic-string@0.30.19:
resolution: {integrity: sha512-2N21sPY9Ws53PZvsEpVtNuSW+ScYbQdp4b9qUaL+9QkHUrGFKo56Lg9Emg5s9V/qrtNBmiR01sYhUOwu3H+VOw==}
@@ -7428,14 +7411,6 @@ packages:
tinybench@2.9.0:
resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==}
tinyest-for-wgsl@0.1.3:
resolution: {integrity: sha512-Wm5ADG1UyDxykf42S1gLYP4U9e1QP/TdtJeovQi6y68zttpiFLKqQGioHmPs9Mjysh7YMSAr/Lpuk0cD2MVdGA==}
engines: {node: '>=12.20.0'}
tinyest@0.1.2:
resolution: {integrity: sha512-aHRmouyowIq1P5jrTF+YK6pGX+WuvFtSCLbqk91yHnU3SWQRIcNIamZLM5XF6lLqB13AWz0PGPXRff2QGDsxIg==}
engines: {node: '>=12.20.0'}
tinyexec@0.3.2:
resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==}
@@ -7562,13 +7537,6 @@ packages:
resolution: {integrity: sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg==}
engines: {node: '>= 0.4'}
typed-binary@4.3.2:
resolution: {integrity: sha512-HT3pIBM2njCZUmeczDaQUUErGiM6GXFCqMsHegE12HCoBtvHCkfR10JJni0TeGOTnLilTd6YFyj+YhflqQDrDQ==}
typegpu@0.8.2:
resolution: {integrity: sha512-wkMJWhJE0pSkw2G/FesjqjbtHkREyOKu1Zmyj19xfmaX5+65YFwgfQNKSK8CxqN4kJkP7JFelLDJTSYY536TYg==}
engines: {node: '>=12.20.0'}
typescript-eslint@8.44.0:
resolution: {integrity: sha512-ib7mCkYuIzYonCq9XWF5XNw+fkj2zg629PSa9KNIQ47RXFF763S5BIX4wqz1+FLPogTZoiw8KmCiRPRa8bL3qw==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
@@ -7673,11 +7641,6 @@ packages:
vue-template-es2015-compiler:
optional: true
unplugin-typegpu@0.8.0:
resolution: {integrity: sha512-VJHdXSXGOkAx0WhwFczhVUjAI6HyDkrQXk20HnwyuzIE3FdqE5l9sJTCYZzoVGo3z8i/IA5TMHCDzzP0Bc97Cw==}
peerDependencies:
typegpu: ^0.8.0
unplugin-vue-components@0.28.0:
resolution: {integrity: sha512-jiTGtJ3JsRFBjgvyilfrX7yUoGKScFgbdNw+6p6kEXU+Spf/rhxzgvdfuMcvhCcLmflB/dY3pGQshYBVGOUx7Q==}
engines: {node: '>=14'}
@@ -7868,8 +7831,8 @@ packages:
vue-component-type-helpers@3.1.1:
resolution: {integrity: sha512-B0kHv7qX6E7+kdc5nsaqjdGZ1KwNKSUQDWGy7XkTYT7wFsOpkEyaJ1Vq79TjwrrtuLRgizrTV7PPuC4rRQo+vw==}
vue-component-type-helpers@3.1.5:
resolution: {integrity: sha512-7V3yJuNWW7/1jxCcI1CswnpDsvs02Qcx/N43LkV+ZqhLj2PKj50slUflHAroNkN4UWiYfzMUUUXiNuv9khmSpQ==}
vue-component-type-helpers@3.1.4:
resolution: {integrity: sha512-Uws7Ew1OzTTqHW8ZVl/qLl/HB+jf08M0NdFONbVWAx0N4gMLK8yfZDgeB77hDnBmaigWWEn5qP8T9BG59jIeyQ==}
vue-demi@0.14.10:
resolution: {integrity: sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==}
@@ -9006,8 +8969,6 @@ snapshots:
'@babel/runtime@7.28.4': {}
'@babel/standalone@7.28.5': {}
'@babel/template@7.27.2':
dependencies:
'@babel/code-frame': 7.27.1
@@ -10672,7 +10633,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.5
vue-component-type-helpers: 3.1.4
'@swc/helpers@0.5.17':
dependencies:
@@ -11055,7 +11016,7 @@ snapshots:
'@tweenjs/tween.js': 23.1.3
'@types/stats.js': 0.17.3
'@types/webxr': 0.5.20
'@webgpu/types': 0.1.66
'@webgpu/types': 0.1.51
fflate: 0.8.2
meshoptimizer: 0.18.1
@@ -11558,7 +11519,7 @@ snapshots:
dependencies:
vue: 3.5.13(typescript@5.9.2)
'@webgpu/types@0.1.66': {}
'@webgpu/types@0.1.51': {}
'@xstate/fsm@1.6.5': {}
@@ -14039,10 +14000,6 @@ snapshots:
lz-string@1.5.0: {}
magic-string-ast@1.0.3:
dependencies:
magic-string: 0.30.19
magic-string@0.30.19:
dependencies:
'@jridgewell/sourcemap-codec': 1.5.5
@@ -15907,12 +15864,6 @@ snapshots:
tinybench@2.9.0: {}
tinyest-for-wgsl@0.1.3:
dependencies:
tinyest: 0.1.2
tinyest@0.1.2: {}
tinyexec@0.3.2: {}
tinyexec@1.0.1: {}
@@ -16044,13 +15995,6 @@ snapshots:
reflect.getprototypeof: 1.0.10
optional: true
typed-binary@4.3.2: {}
typegpu@0.8.2:
dependencies:
tinyest: 0.1.2
typed-binary: 4.3.2
typescript-eslint@8.44.0(eslint@9.35.0(jiti@2.4.2))(typescript@5.9.2):
dependencies:
'@typescript-eslint/eslint-plugin': 8.44.0(@typescript-eslint/parser@8.44.0(eslint@9.35.0(jiti@2.4.2))(typescript@5.9.2))(eslint@9.35.0(jiti@2.4.2))(typescript@5.9.2)
@@ -16146,19 +16090,6 @@ snapshots:
transitivePeerDependencies:
- supports-color
unplugin-typegpu@0.8.0(typegpu@0.8.2):
dependencies:
'@babel/standalone': 7.28.5
defu: 6.1.4
estree-walker: 3.0.3
magic-string-ast: 1.0.3
pathe: 2.0.3
picomatch: 4.0.3
tinyest: 0.1.2
tinyest-for-wgsl: 0.1.3
typegpu: 0.8.2
unplugin: 2.3.5
unplugin-vue-components@0.28.0(@babel/parser@7.28.4)(rollup@4.22.4)(vue@3.5.13(typescript@5.9.2)):
dependencies:
'@antfu/utils': 0.7.10
@@ -16439,7 +16370,7 @@ snapshots:
vue-component-type-helpers@3.1.1: {}
vue-component-type-helpers@3.1.5: {}
vue-component-type-helpers@3.1.4: {}
vue-demi@0.14.10(vue@3.5.13(typescript@5.9.2)):
dependencies:

View File

@@ -43,7 +43,6 @@ catalog:
'@vue/test-utils': ^2.4.6
'@vueuse/core': ^11.0.0
'@vueuse/integrations': ^13.9.0
'@webgpu/types': ^0.1.66
algoliasearch: ^5.21.0
axios: ^1.8.2
cross-env: ^10.1.0
@@ -84,11 +83,9 @@ catalog:
tailwindcss-primeui: ^0.6.1
tsx: ^4.15.6
tw-animate-css: ^1.3.8
typegpu: ^0.8.2
typescript: ^5.9.2
typescript-eslint: ^8.44.0
unplugin-icons: ^0.22.0
unplugin-typegpu: 0.8.0
unplugin-vue-components: ^0.28.0
vite: ^5.4.19
vite-plugin-dts: ^4.5.4

View File

@@ -1,9 +1 @@
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.
Thanks to OpenArt (https://openart.ai) for providing the sorted-custom-node-map data, captured in September 2024.

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 1.5 MiB

File diff suppressed because it is too large Load Diff

View File

@@ -1,10 +1,5 @@
<template>
<div
v-if="!workspaceStore.focusMode"
class="ml-1 flex gap-x-0.5 pt-1"
@mouseenter="isTopMenuHovered = true"
@mouseleave="isTopMenuHovered = false"
>
<div v-if="!workspaceStore.focusMode" class="ml-1 flex gap-x-0.5 pt-1">
<div class="min-w-0 flex-1">
<SubgraphBreadcrumb />
</div>
@@ -45,10 +40,7 @@
<CurrentUserButton v-if="isLoggedIn" class="shrink-0" />
<LoginButton v-else-if="isDesktop" />
</div>
<QueueProgressOverlay
v-model:expanded="isQueueOverlayExpanded"
:menu-hovered="isTopMenuHovered"
/>
<QueueProgressOverlay v-model:expanded="isQueueOverlayExpanded" />
</div>
</div>
</template>
@@ -77,7 +69,6 @@ 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'))

View File

@@ -257,7 +257,7 @@ watch(isDragging, (dragging) => {
})
const actionbarClass = computed(() =>
cn(
'w-[200px] border-dashed border-blue-500 opacity-80',
'w-[265px] 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',

View File

@@ -1,42 +1,35 @@
<template>
<div class="queue-button-group flex">
<FeatureFlaggedRunButton
flag-key="demo-run-button-experiment"
:on-click="queuePrompt"
<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"
>
<template #control>
<SplitButton
v-tooltip.bottom="{
value: queueButtonTooltip,
<template #icon>
<i :class="iconClass" />
</template>
<template #item="{ item }">
<Button
v-tooltip="{
value: item.tooltip,
showDelay: 600
}"
class="comfyui-queue-button"
:label="String(activeQueueModeMenuItem?.label ?? '')"
severity="primary"
:label="String(item.label ?? '')"
:icon="item.icon"
:severity="item.key === queueMode ? 'primary' : 'secondary'"
size="small"
: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>
text
/>
</template>
</FeatureFlaggedRunButton>
</SplitButton>
<BatchCountEdit />
</div>
</template>
@@ -51,23 +44,17 @@ 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 { graphHasMissingNodes } from '@/workbench/extensions/manager/utils/graphHasMissingNodes'
import { useMissingNodes } from '@/workbench/extensions/manager/composables/nodePack/useMissingNodes'
import BatchCountEdit from '../BatchCountEdit.vue'
import FeatureFlaggedRunButton from './FeatureFlaggedRunButton.vue'
const workspaceStore = useWorkspaceStore()
const { mode: queueMode, batchCount } = storeToRefs(useQueueSettingsStore())
const nodeDefStore = useNodeDefStore()
const hasMissingNodes = computed(() =>
graphHasMissingNodes(app.graph, nodeDefStore.nodeDefsByName)
)
const { hasMissingNodes } = useMissingNodes()
const { t } = useI18n()
const queueModeMenuItemLookup = computed(() => {

View File

@@ -1,252 +0,0 @@
<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>

View File

@@ -1,58 +0,0 @@
<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>

View File

@@ -64,13 +64,11 @@ 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 { graphHasMissingNodes } from '@/workbench/extensions/manager/utils/graphHasMissingNodes'
import { useMissingNodes } from '@/workbench/extensions/manager/composables/nodePack/useMissingNodes'
interface Props {
item: MenuItem
@@ -81,10 +79,7 @@ const props = withDefaults(defineProps<Props>(), {
isActive: false
})
const nodeDefStore = useNodeDefStore()
const hasMissingNodes = computed(() =>
graphHasMissingNodes(app.graph, nodeDefStore.nodeDefsByName)
)
const { hasMissingNodes } = useMissingNodes()
const { t } = useI18n()
const menu = ref<InstanceType<typeof Menu> & MenuState>()

View File

@@ -9,31 +9,29 @@
<div class="font-medium">
{{ col.header }}
</div>
<div>{{ getDisplayValue(col) }}</div>
<div>{{ formatValue(systemInfo[col.field], col.field) }}</div>
</template>
</div>
</div>
<template v-if="hasDevices">
<Divider />
<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>
</template>
<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>
</template>
@@ -44,9 +42,8 @@ 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 { formatCommitHash, formatSize } from '@/utils/formatUtil'
import { formatSize } from '@/utils/formatUtil'
const props = defineProps<{
stats: SystemStats
@@ -57,53 +54,20 @@ const systemInfo = computed(() => ({
argv: props.stats.system.argv.join(' ')
}))
const hasDevices = computed(() => props.stats.devices.length > 0)
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' }
]
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)
const formatValue = (value: any, field: string) => {
if (['ram_total', 'ram_free'].includes(field)) {
return formatSize(value)
}
return value
}

View File

@@ -92,7 +92,7 @@
class="w-62.5"
>
<template #icon>
<i class="icon-[lucide--arrow-up-down] text-muted-foreground" />
<i class="icon-[lucide--arrow-up-down]" />
</template>
</SingleSelect>
</div>

View File

@@ -17,7 +17,7 @@
root: ({ props }: MultiSelectPassThroughMethodOptions) => ({
class: cn(
'h-10 relative inline-flex cursor-pointer select-none',
'rounded-lg bg-secondary-background text-base-foreground',
'rounded-lg bg-base-background text-base-foreground',
'transition-all duration-200 ease-in-out',
'border-[2.5px] border-solid',
selectedCount > 0
@@ -83,7 +83,7 @@
role="combobox"
:aria-expanded="false"
aria-haspopup="listbox"
:tabindex="0"
tabindex="0"
>
<template
v-if="showSearchBox || showSelectedCount || showClearButton"
@@ -127,7 +127,7 @@
<!-- Trigger value (keep text scale identical) -->
<template #value>
<span class="text-sm">
<span class="text-sm text-muted-foreground">
{{ label }}
</span>
<span
@@ -140,7 +140,7 @@
<!-- Chevron size identical to current -->
<template #dropdownicon>
<i class="icon-[lucide--chevron-down] text-muted-foreground" />
<i class="icon-[lucide--chevron-down] text-lg text-neutral-400" />
</template>
<!-- Custom option row: square checkbox + label (unchanged layout/colors) -->

View File

@@ -1,6 +1,6 @@
<template>
<div :class="wrapperStyle" @click="focusInput">
<i class="icon-[lucide--search] text-muted-foreground" />
<i class="icon-[lucide--search] text-muted" />
<InputText
ref="input"
v-model="internalSearchQuery"
@@ -73,7 +73,7 @@ onMounted(() => autofocus && focusInput())
const wrapperStyle = computed(() => {
const baseClasses =
'relative flex w-full items-center gap-2 bg-secondary-background cursor-text'
'relative flex w-full items-center gap-2 bg-base-background cursor-text'
if (showBorder) {
return cn(

View File

@@ -20,7 +20,7 @@
'h-10 relative inline-flex cursor-pointer select-none items-center',
// trigger surface
'rounded-lg',
'bg-secondary-background text-base-foreground',
'bg-base-background text-base-foreground',
'border-[2.5px] border-solid border-transparent',
'transition-all duration-200 ease-in-out',
'focus-within:border-node-component-border',
@@ -84,7 +84,7 @@
>
<!-- Trigger value -->
<template #value="slotProps">
<div class="flex items-center gap-2 text-sm">
<div class="flex items-center gap-2 text-sm text-neutral-500">
<slot name="icon" />
<span
v-if="slotProps.value !== null && slotProps.value !== undefined"
@@ -100,7 +100,7 @@
<!-- Trigger caret -->
<template #dropdownicon>
<i class="icon-[lucide--chevron-down] text-muted-foreground" />
<i class="icon-[lucide--chevron-down] text-base text-neutral-500" />
</template>
<!-- Option row -->
@@ -152,7 +152,7 @@ const {
popoverMaxWidth?: string
}>()
const selectedItem = defineModel<string | undefined>({ required: true })
const selectedItem = defineModel<string | null>({ required: true })
const { t } = useI18n()

View File

@@ -26,10 +26,6 @@
<script setup lang="ts">
import { computed } from 'vue'
import {
getEffectiveBrushSize,
getEffectiveHardness
} from '@/composables/maskeditor/brushUtils'
import { BrushShape } from '@/extensions/core/maskeditor/types'
import { useMaskEditorStore } from '@/stores/maskEditorStore'
@@ -40,14 +36,11 @@ const { containerRef } = defineProps<{
const store = useMaskEditorStore()
const brushOpacity = computed(() => {
return store.brushVisible ? 1 : 0
return store.brushVisible ? '1' : '0'
})
const brushRadius = computed(() => {
const size = store.brushSettings.size
const hardness = store.brushSettings.hardness
const effectiveSize = getEffectiveBrushSize(size, hardness)
return effectiveSize * store.zoomRatio
return store.brushSettings.size * store.zoomRatio
})
const brushSize = computed(() => {
@@ -85,26 +78,19 @@ const gradientVisible = computed(() => {
})
const gradientBackground = computed(() => {
const size = store.brushSettings.size
const hardness = store.brushSettings.hardness
const effectiveSize = getEffectiveBrushSize(size, hardness)
const effectiveHardness = getEffectiveHardness(size, hardness, effectiveSize)
if (effectiveHardness === 1) {
if (hardness === 1) {
return 'rgba(255, 0, 0, 0.5)'
}
const midStop = effectiveHardness * 100
const midStop = hardness * 100
const outerStop = 100
// Add an intermediate stop to approximate the squared falloff
// At 50% of the fade region, squared falloff is 0.25 (relative to max)
const fadeMidStop = midStop + (outerStop - midStop) * 0.5
return `radial-gradient(
circle,
rgba(255, 0, 0, 0.5) 0%,
rgba(255, 0, 0, 0.5) ${midStop}%,
rgba(255, 0, 0, 0.125) ${fadeMidStop}%,
rgba(255, 0, 0, 0.25) ${midStop}%,
rgba(255, 0, 0, 0) ${outerStop}%
)`
})

View File

@@ -55,7 +55,7 @@
<SliderControl
:label="t('maskEditor.thickness')"
:min="1"
:max="500"
:max="100"
:step="1"
:model-value="store.brushSettings.size"
@update:model-value="onThicknessChange"
@@ -80,12 +80,12 @@
/>
<SliderControl
:label="$t('maskEditor.stepSize')"
:label="t('maskEditor.smoothingPrecision')"
:min="1"
:max="100"
:step="1"
:model-value="store.brushSettings.stepSize"
@update:model-value="onStepSizeChange"
:model-value="store.brushSettings.smoothingPrecision"
@update:model-value="onSmoothingPrecisionChange"
/>
</div>
</template>
@@ -119,8 +119,8 @@ const onHardnessChange = (value: number) => {
store.setBrushHardness(value)
}
const onStepSizeChange = (value: number) => {
store.setBrushStepSize(value)
const onSmoothingPrecisionChange = (value: number) => {
store.setBrushSmoothingPrecision(value)
}
const resetToDefault = () => {

View File

@@ -12,28 +12,19 @@
>
<canvas
ref="imgCanvasRef"
class="absolute top-0 left-0 w-full h-full z-0"
class="absolute top-0 left-0 w-full h-full"
@contextmenu.prevent
/>
<canvas
ref="rgbCanvasRef"
class="absolute top-0 left-0 w-full h-full z-10"
class="absolute top-0 left-0 w-full h-full"
@contextmenu.prevent
/>
<canvas
ref="maskCanvasRef"
class="absolute top-0 left-0 w-full h-full z-30"
class="absolute top-0 left-0 w-full h-full"
@contextmenu.prevent
/>
<!-- GPU Preview Canvas -->
<canvas
ref="gpuCanvasRef"
class="absolute top-0 left-0 w-full h-full pointer-events-none"
:class="{
'z-20': store.activeLayer === 'rgb',
'z-40': store.activeLayer === 'mask'
}"
/>
<div ref="canvasBackgroundRef" class="bg-white w-full h-full" />
</div>
@@ -96,7 +87,6 @@ const canvasContainerRef = ref<HTMLDivElement>()
const imgCanvasRef = ref<HTMLCanvasElement>()
const maskCanvasRef = ref<HTMLCanvasElement>()
const rgbCanvasRef = ref<HTMLCanvasElement>()
const gpuCanvasRef = ref<HTMLCanvasElement>()
const canvasBackgroundRef = ref<HTMLDivElement>()
const toolPanelRef = ref<InstanceType<typeof ToolPanel>>()
@@ -107,7 +97,7 @@ const initialized = ref(false)
const keyboard = useKeyboard()
const panZoom = usePanAndZoom()
const toolManager = useToolManager(keyboard, panZoom)
let toolManager: ReturnType<typeof useToolManager> | null = null
let resizeObserver: ResizeObserver | null = null
@@ -145,6 +135,8 @@ const initUI = async () => {
try {
await loader.loadFromNode(node)
toolManager = useToolManager(keyboard, panZoom)
const imageLoader = useImageLoader()
const image = await imageLoader.loadImages()
@@ -157,18 +149,6 @@ const initUI = async () => {
store.canvasHistory.saveInitialState()
// Initialize GPU resources
if (toolManager.brushDrawing) {
await toolManager.brushDrawing.initGPUResources()
if (gpuCanvasRef.value && toolManager.brushDrawing.initPreviewCanvas) {
// Match preview canvas resolution to mask canvas
gpuCanvasRef.value.width = maskCanvasRef.value.width
gpuCanvasRef.value.height = maskCanvasRef.value.height
toolManager.brushDrawing.initPreviewCanvas(gpuCanvasRef.value)
}
}
initialized.value = true
} catch (error) {
console.error('[MaskEditorContent] Initialization failed:', error)
@@ -192,7 +172,7 @@ onMounted(() => {
})
onBeforeUnmount(() => {
toolManager.brushDrawing.saveBrushSettings()
toolManager?.brushDrawing.saveBrushSettings()
keyboard?.removeListeners()

View File

@@ -102,7 +102,6 @@ const onInvert = () => {
const onClear = () => {
canvasTools.clearMask()
store.triggerClear()
}
const handleSave = async () => {

View File

@@ -25,11 +25,7 @@
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="$t('sideToolbar.queueProgressOverlay.preview')"
class="h-full w-full object-cover"
/>
<img :src="url" alt="preview" class="h-full w-full object-cover" />
</span>
</span>

View File

@@ -47,7 +47,7 @@
v-tooltip.top="cancelJobTooltip"
type="secondary"
size="sm"
class="size-6 bg-destructive-background hover:bg-destructive-background-hover"
class="size-6 bg-secondary-background hover:bg-destructive-background"
:aria-label="t('sideToolbar.queueProgressOverlay.interruptAll')"
@click="$emit('interruptAll')"
>

View File

@@ -60,7 +60,7 @@
</template>
<script setup lang="ts">
import { computed, nextTick, ref, withDefaults } from 'vue'
import { computed, nextTick, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import QueueOverlayActive from '@/components/queue/QueueOverlayActive.vue'
@@ -85,15 +85,9 @@ import { useSidebarTabStore } from '@/stores/workspace/sidebarTabStore'
type OverlayState = 'hidden' | 'empty' | 'active' | 'expanded'
const props = withDefaults(
defineProps<{
expanded?: boolean
menuHovered?: boolean
}>(),
{
menuHovered: false
}
)
const props = defineProps<{
expanded?: boolean
}>()
const emit = defineEmits<{
(e: 'update:expanded', value: boolean): void
@@ -116,7 +110,6 @@ const {
currentNodeProgressStyle
} = useQueueProgress()
const isHovered = ref(false)
const isOverlayHovered = computed(() => isHovered.value || props.menuHovered)
const internalExpanded = ref(false)
const isExpanded = computed({
get: () =>
@@ -149,7 +142,7 @@ const showBackground = computed(
() =>
overlayState.value === 'expanded' ||
overlayState.value === 'empty' ||
(overlayState.value === 'active' && isOverlayHovered.value)
(overlayState.value === 'active' && isHovered.value)
)
const isVisible = computed(() => overlayState.value !== 'hidden')
@@ -163,7 +156,7 @@ const containerClass = computed(() =>
const bottomRowClass = computed(
() =>
`flex items-center justify-end gap-4 transition-opacity duration-200 ease-in-out ${
overlayState.value === 'active' && isOverlayHovered.value
overlayState.value === 'active' && isHovered.value
? 'opacity-100 pointer-events-auto'
: 'opacity-0 pointer-events-none'
}`

View File

@@ -82,10 +82,7 @@
:src="iconImageUrl"
class="h-full w-full object-cover"
/>
<i
v-else
:class="cn(iconClass, 'size-4', shouldSpin && 'animate-spin')"
/>
<i v-else :class="[iconClass, 'size-4']" />
</div>
</div>
</div>
@@ -96,23 +93,6 @@
</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"
@@ -133,22 +113,18 @@
v-tooltip.top="deleteTooltipConfig"
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"
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"
: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' &&
props.state !== 'running' &&
computedShowClear
"
v-else-if="props.state !== 'completed' && 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"
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"
:aria-label="t('g.cancel')"
@click.stop="emit('cancel')"
>
@@ -167,33 +143,17 @@
v-tooltip.top="moreTooltipConfig"
type="transparent"
size="sm"
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"
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"
:aria-label="t('g.more')"
@click.stop="emit('menu', $event)"
>
<i class="icon-[lucide--more-horizontal] size-4" />
</IconButton>
</div>
<div
v-else-if="props.state !== 'running'"
key="secondary"
class="pr-2"
>
<div v-else 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>
@@ -210,7 +170,6 @@ 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<{
@@ -343,13 +302,6 @@ 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'

View File

@@ -73,7 +73,6 @@
@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"
@@ -102,7 +101,6 @@
<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'

View File

@@ -44,7 +44,6 @@
<SidebarHelpCenterIcon :is-small="isSmall" />
<SidebarBottomPanelToggleButton :is-small="isSmall" />
<SidebarShortcutsToggleButton :is-small="isSmall" />
<SidebarSettingsButton :is-small="isSmall" />
</div>
</div>
</nav>
@@ -57,7 +56,6 @@ import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import ComfyMenuButton from '@/components/sidebar/ComfyMenuButton.vue'
import SidebarBottomPanelToggleButton from '@/components/sidebar/SidebarBottomPanelToggleButton.vue'
import SidebarSettingsButton from '@/components/sidebar/SidebarSettingsButton.vue'
import SidebarShortcutsToggleButton from '@/components/sidebar/SidebarShortcutsToggleButton.vue'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useTelemetry } from '@/platform/telemetry'

View File

@@ -1,39 +0,0 @@
<template>
<SidebarIcon
:label="$t('g.settings')"
:tooltip="tooltipText"
@click="showSettingsDialog"
>
<template #icon>
<i class="icon-[lucide--settings]" />
</template>
</SidebarIcon>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { useTelemetry } from '@/platform/telemetry'
import { useCommandStore } from '@/stores/commandStore'
import SidebarIcon from './SidebarIcon.vue'
const { t } = useI18n()
const { getCommand, formatKeySequence } = useCommandStore()
const command = getCommand('Comfy.ShowSettingsDialog')
const tooltipText = computed(
() => `${t('g.settings')} (${formatKeySequence(command)})`
)
/**
* Toggle keyboard shortcuts panel and track UI button click.
*/
const showSettingsDialog = () => {
command.function()
useTelemetry()?.trackUiButtonClicked({
button_id: 'sidebar_settings_button_clicked'
})
}
</script>

View File

@@ -214,7 +214,7 @@ const shouldShowDeleteButton = computed(() => {
const getOutputCount = (item: AssetItem): number => {
const count = item.user_metadata?.outputCount
return typeof count === 'number' && count > 0 ? count : 1
return typeof count === 'number' && count > 0 ? count : 0
}
const shouldShowOutputCount = (item: AssetItem): boolean => {

View File

@@ -3,12 +3,9 @@
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 text-sm">
<div class="flex items-center">
<i class="icon-[lucide--rocket]"></i>
<span class="pl-2">{{ $t('vueNodesBanner.title') }}</span>
<span class="pl-1.5 hidden md:inline">{{
$t('vueNodesBanner.desc')
}}</span>
<span class="pl-2 text-sm">{{ $t('vueNodesBanner.message') }}</span>
<Button
class="cursor-pointer bg-transparent rounded h-7 px-3 border border-white text-white ml-4 text-xs"
@click="handleTryItOut"
@@ -66,7 +63,7 @@ const handleTryItOut = async (): Promise<void> => {
try {
await settingStore.set('Comfy.VueNodes.Enabled', true)
} catch (error) {
console.error('Failed to enable Nodes 2.0:', error)
console.error('Failed to enable Vue nodes:', error)
} finally {
handleDismiss()
}

View File

@@ -30,7 +30,7 @@
<template #default="{ close }">
<IconTextButton
type="secondary"
:label="$t('g.settings')"
label="Settings"
@click="
() => {
close()
@@ -43,7 +43,7 @@
</IconTextButton>
<IconTextButton
type="primary"
:label="$t('g.profile')"
label="Profile"
@click="
() => {
close()
@@ -65,7 +65,7 @@
v-model="selectedFrameworks"
v-model:search-query="searchText"
class="w-[250px]"
:label="$t('assetBrowser.selectFrameworks')"
label="Select Frameworks"
:options="frameworkOptions"
:show-search-box="true"
:show-selected-count="true"
@@ -73,12 +73,12 @@
/>
<MultiSelect
v-model="selectedProjects"
:label="$t('assetBrowser.selectProjects')"
label="Select Projects"
:options="projectOptions"
/>
<SingleSelect
v-model="selectedSort"
:label="$t('assetBrowser.sortingType')"
label="Sorting Type"
:options="sortOptions"
class="w-[135px]"
>

View File

@@ -3,6 +3,7 @@ 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'
@@ -10,7 +11,6 @@ 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,6 +26,11 @@ 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
@@ -39,10 +44,7 @@ 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], removeNodeTitleHeight(node.size[1])] as [
number,
number
]
size: [node.size[0], node.size[1]] as [number, number]
}))
layoutStore.initializeFromLiteGraph(nodes)

View File

@@ -1,84 +0,0 @@
import { describe, it, expect } from 'vitest'
import { resampleSegment } from './splineUtils'
import type { Point } from '@/extensions/core/maskeditor/types'
describe('Shift+Click Drawing Logic', () => {
it('should generate equidistant points across connected segments', () => {
const spacing = 4
let remainder = spacing // Simulate start point already painted
const outputPoints: Point[] = []
// Define points: A -> B -> C
// A(0,0) -> B(10,0) -> C(20,0)
// Total length 20. Spacing 4.
// Expected points at x = 4, 8, 12, 16, 20
const pA = { x: 0, y: 0 }
const pB = { x: 10, y: 0 }
const pC = { x: 20, y: 0 }
// Segment 1: A -> B
const result1 = resampleSegment([pA, pB], spacing, remainder)
outputPoints.push(...result1.points)
remainder = result1.remainder
// Verify intermediate state
// Length 10. Spacing 4. Start offset 4.
// Points at 4, 8. Next at 12.
// Remainder = 12 - 10 = 2.
expect(result1.points.length).toBe(2)
expect(result1.points[0].x).toBeCloseTo(4)
expect(result1.points[1].x).toBeCloseTo(8)
expect(remainder).toBeCloseTo(2)
// Segment 2: B -> C
const result2 = resampleSegment([pB, pC], spacing, remainder)
outputPoints.push(...result2.points)
remainder = result2.remainder
// Verify final state
// Start offset 2. Points at 2, 6, 10 (relative to B).
// Absolute x: 12, 16, 20.
expect(result2.points.length).toBe(3)
expect(result2.points[0].x).toBeCloseTo(12)
expect(result2.points[1].x).toBeCloseTo(16)
expect(result2.points[2].x).toBeCloseTo(20)
// Verify all distances
// Note: The first point is at distance `spacing` from start (0,0)
// Subsequent points are `spacing` apart.
let prevX = 0
for (const p of outputPoints) {
const dist = p.x - prevX
expect(dist).toBeCloseTo(spacing)
prevX = p.x
}
})
it('should handle segments shorter than spacing', () => {
const spacing = 10
let remainder = spacing // Simulate start point already painted
// A(0,0) -> B(5,0) -> C(15,0)
const pA = { x: 0, y: 0 }
const pB = { x: 5, y: 0 }
const pC = { x: 15, y: 0 }
// Segment 1: A -> B (Length 5)
// Spacing 10. No points should be generated.
// Remainder should be 5 (next point needs 5 more units).
const result1 = resampleSegment([pA, pB], spacing, remainder)
expect(result1.points.length).toBe(0)
expect(result1.remainder).toBeCloseTo(5)
remainder = result1.remainder
// Segment 2: B -> C (Length 10)
// Start offset 5. First point at 5 (relative to B).
// Absolute x = 10.
// Next point at 15 (relative to B). Segment ends at 10.
// Remainder = 15 - 10 = 5.
const result2 = resampleSegment([pB, pC], spacing, remainder)
expect(result2.points.length).toBe(1)
expect(result2.points[0].x).toBeCloseTo(10)
expect(result2.remainder).toBeCloseTo(5)
})
})

View File

@@ -1,108 +0,0 @@
import { describe, it, expect } from 'vitest'
import { StrokeProcessor } from './StrokeProcessor'
import type { Point } from '@/extensions/core/maskeditor/types'
describe('StrokeProcessor', () => {
it('should generate equidistant points from irregular input', () => {
const spacing = 10
const processor = new StrokeProcessor(spacing)
const outputPoints: Point[] = []
// Simulate a horizontal line drawn with irregular speed
// Points: (0,0) -> (5,0) -> (25,0) -> (30,0) -> (100,0)
const inputPoints: Point[] = [
{ x: 0, y: 0 },
{ x: 5, y: 0 }, // dist 5
{ x: 25, y: 0 }, // dist 20
{ x: 30, y: 0 }, // dist 5
{ x: 100, y: 0 } // dist 70
]
for (const p of inputPoints) {
outputPoints.push(...processor.addPoint(p))
}
outputPoints.push(...processor.endStroke())
// Verify we have points
expect(outputPoints.length).toBeGreaterThan(0)
// Verify spacing
// Note: The first few points might be affected by the start condition,
// but the middle section should be perfectly spaced.
// Also, Catmull-Rom splines don't necessarily pass through control points in a straight line
// if the points are collinear, they should be straight.
// Let's check distances between consecutive points
const distances: number[] = []
for (let i = 1; i < outputPoints.length; i++) {
const dx = outputPoints[i].x - outputPoints[i - 1].x
const dy = outputPoints[i].y - outputPoints[i - 1].y
distances.push(Math.hypot(dx, dy))
}
// Check that distances are close to spacing
// We allow a small epsilon because of floating point and spline approximation
// Filter out the very last segment which might be shorter (remainder)
// But wait, our logic doesn't output the last point if it's not a full spacing step?
// resampleSegment outputs points at [start + spacing, start + 2*spacing, ...]
// It does NOT output the end point of the segment.
// So all distances between output points should be exactly `spacing`.
// EXCEPT possibly if the spline curvature makes the straight-line distance slightly different
// from the arc length. But for a straight line input, it should be exact.
// However, catmull-rom with collinear points IS a straight line.
// Let's log the distances for debugging if test fails
// console.log('Distances:', distances)
// All distances should be approximately equal to spacing
// We might have a gap between segments if the logic isn't perfect,
// but within a segment it's guaranteed by resampleSegment.
// The critical part is the transition between segments.
for (let i = 0; i < distances.length; i++) {
const d = distances[i]
if (Math.abs(d - spacing) > 0.5) {
console.log(
`Distance mismatch at index ${i}: ${d} (expected ${spacing})`
)
console.log(`Point ${i}:`, outputPoints[i])
console.log(`Point ${i + 1}:`, outputPoints[i + 1])
}
expect(d).toBeCloseTo(spacing, 1)
}
})
it('should handle a simple 3-point stroke', () => {
const spacing = 5
const processor = new StrokeProcessor(spacing)
const points: Point[] = []
points.push(...processor.addPoint({ x: 0, y: 0 }))
points.push(...processor.addPoint({ x: 10, y: 0 }))
points.push(...processor.addPoint({ x: 20, y: 0 }))
points.push(...processor.endStroke())
expect(points.length).toBeGreaterThan(0)
// Check distances
for (let i = 1; i < points.length; i++) {
const dx = points[i].x - points[i - 1].x
const dy = points[i].y - points[i - 1].y
const d = Math.hypot(dx, dy)
expect(d).toBeCloseTo(spacing, 1)
}
})
it('should handle a single point click', () => {
const spacing = 5
const processor = new StrokeProcessor(spacing)
const points: Point[] = []
points.push(...processor.addPoint({ x: 100, y: 100 }))
points.push(...processor.endStroke())
expect(points.length).toBe(1)
expect(points[0]).toEqual({ x: 100, y: 100 })
})
})

View File

@@ -1,115 +0,0 @@
import type { Point } from '@/extensions/core/maskeditor/types'
import { catmullRomSpline, resampleSegment } from './splineUtils'
export class StrokeProcessor {
private controlPoints: Point[] = []
private remainder: number = 0
private spacing: number
private isFirstPoint: boolean = true
private hasProcessedSegment: boolean = false
constructor(spacing: number) {
this.spacing = spacing
}
/**
* Adds a point to the stroke and returns any new equidistant points generated.
* Maintain a sliding window of 4 control points for spline generation
*/
public addPoint(point: Point): Point[] {
// Initialize buffer with the first point
if (this.isFirstPoint) {
this.controlPoints.push(point) // p0: phantom start point
this.controlPoints.push(point) // p1: actual start point
this.isFirstPoint = false
return [] // Wait for more points to form a segment
}
this.controlPoints.push(point)
// Require 4 points for a spline segment
if (this.controlPoints.length < 4) {
return []
}
// Generate segment p1->p2
const p0 = this.controlPoints[0]
const p1 = this.controlPoints[1]
const p2 = this.controlPoints[2]
const p3 = this.controlPoints[3]
const newPoints = this.processSegment(p0, p1, p2, p3)
// Slide window
this.controlPoints.shift()
return newPoints
}
/**
* End stroke and flush remaining segments
*/
public endStroke(): Point[] {
if (this.controlPoints.length < 2) {
// Insufficient points for a segment
return []
}
// Process remaining segments by duplicating the last point
const newPoints: Point[] = []
// Flush the buffer by processing the final segment
while (this.controlPoints.length >= 3) {
const p0 = this.controlPoints[0]
const p1 = this.controlPoints[1]
const p2 = this.controlPoints[2]
const p3 = p2 // Duplicate last point as phantom end
const points = this.processSegment(p0, p1, p2, p3)
newPoints.push(...points)
this.controlPoints.shift()
}
// Handle single point click
if (!this.hasProcessedSegment && this.controlPoints.length >= 2) {
// Process zero-length segment for single point
const p = this.controlPoints[1]
const points = this.processSegment(p, p, p, p)
newPoints.push(...points)
}
return newPoints
}
private processSegment(p0: Point, p1: Point, p2: Point, p3: Point): Point[] {
this.hasProcessedSegment = true
// Generate dense points for the segment
const densePoints: Point[] = []
// Adaptive sampling based on segment length
const dist = Math.hypot(p2.x - p1.x, p2.y - p1.y)
// Use 1 sample per pixel, but at least 5 samples to ensure smoothness for short segments
// and cap at a reasonable maximum if needed (though not strictly necessary with density)
const samples = Math.max(5, Math.ceil(dist))
for (let i = 0; i < samples; i++) {
const t = i / samples
densePoints.push(catmullRomSpline(p0, p1, p2, p3, t))
}
// Add segment end point
densePoints.push(p2)
// Resample points with carried-over remainder
const { points, remainder } = resampleSegment(
densePoints,
this.spacing,
this.remainder
)
this.remainder = remainder
return points
}
}

View File

@@ -1,47 +0,0 @@
import { describe, it, expect } from 'vitest'
import { getEffectiveBrushSize, getEffectiveHardness } from './brushUtils'
describe('brushUtils', () => {
describe('getEffectiveBrushSize', () => {
it('should return original size when hardness is 1.0', () => {
const size = 100
const hardness = 1.0
expect(getEffectiveBrushSize(size, hardness)).toBe(100)
})
it('should return 1.5x size when hardness is 0.0', () => {
const size = 100
const hardness = 0.0
expect(getEffectiveBrushSize(size, hardness)).toBe(150)
})
it('should interpolate linearly', () => {
const size = 100
const hardness = 0.5
// Scale should be 1.0 + 0.5 * 0.5 = 1.25
expect(getEffectiveBrushSize(size, hardness)).toBe(125)
})
})
describe('getEffectiveHardness', () => {
it('should return same hardness if effective size matches size', () => {
const size = 100
const hardness = 0.8
const effectiveSize = 100
expect(getEffectiveHardness(size, hardness, effectiveSize)).toBe(0.8)
})
it('should scale hardness down as effective size increases', () => {
const size = 100
const hardness = 0.5
// Effective size at 0.5 hardness is 125
const effectiveSize = 125
// Hard core radius = 50. New hardness = 50 / 125 = 0.4
expect(getEffectiveHardness(size, hardness, effectiveSize)).toBe(0.4)
})
it('should return 0 if effective size is 0', () => {
expect(getEffectiveHardness(100, 0.5, 0)).toBe(0)
})
})
})

View File

@@ -1,34 +0,0 @@
/**
* Calculates the effective brush size based on the base size and hardness.
* As hardness decreases, the effective size increases to allow for a softer falloff.
*
* @param size - The base radius of the brush
* @param hardness - The hardness of the brush (0.0 to 1.0)
* @returns The effective radius of the brush
*/
export function getEffectiveBrushSize(size: number, hardness: number): number {
// Scale factor for maximum softness
const MAX_SCALE = 1.5
const scale = 1.0 + (1.0 - hardness) * (MAX_SCALE - 1.0)
return size * scale
}
/**
* Calculates the effective hardness to maintain the visual "hard core" of the brush.
* Since the effective size is larger, we need to adjust the hardness value so that
* the inner hard circle remains at the same physical radius as the original size * hardness.
*
* @param size - The base radius of the brush
* @param hardness - The base hardness of the brush
* @param effectiveSize - The effective radius (calculated by getEffectiveBrushSize)
* @returns The adjusted hardness value (0.0 to 1.0)
*/
export function getEffectiveHardness(
size: number,
hardness: number,
effectiveSize: number
): number {
if (effectiveSize <= 0) return 0
// Adjust hardness to maintain the physical radius of the hard core
return (size * hardness) / effectiveSize
}

View File

@@ -1,805 +0,0 @@
import * as d from 'typegpu/data'
import { StrokePoint } from './gpuSchema'
import {
brushFragment,
brushVertex,
blitShader,
compositeShader,
readbackShader
} from './brushShaders'
// ... (rest of the file)
const QUAD_VERTS = new Float32Array([-1, -1, 1, -1, 1, 1, -1, 1])
const QUAD_INDICES = new Uint16Array([0, 1, 2, 0, 2, 3])
const UNIFORM_SIZE = 48 // Uniform buffer size aligned to 16 bytes
const STROKE_STRIDE = d.sizeOf(StrokePoint) // 16
const MAX_STROKES = 10000
export class GPUBrushRenderer {
private device: GPUDevice
// Buffers
private quadVertexBuffer: GPUBuffer
private indexBuffer: GPUBuffer
private instanceBuffer: GPUBuffer
private uniformBuffer: GPUBuffer
// Pipelines
private renderPipeline: GPURenderPipeline // Standard alpha blending pipeline
private accumulatePipeline: GPURenderPipeline // SourceOver blending pipeline for stroke accumulation
private blitPipeline: GPURenderPipeline
private compositePipeline: GPURenderPipeline // Composite pipeline that applies opacity
private compositePipelinePreview: GPURenderPipeline // Pipeline for rendering to the preview canvas
private erasePipeline: GPURenderPipeline // Pipeline for erasing (Destination Out)
private erasePipelinePreview: GPURenderPipeline // Eraser pipeline for the preview canvas
readbackPipeline: GPUComputePipeline // Compute pipeline for texture readback
// Bind Group Layouts
private uniformBindGroupLayout: GPUBindGroupLayout
private textureBindGroupLayout: GPUBindGroupLayout
// Shared Bind Groups
private mainUniformBindGroup: GPUBindGroup
// Textures
private currentStrokeTexture: GPUTexture | null = null
private currentStrokeView: GPUTextureView | null = null
// Cached Bind Groups
private compositeTextureBindGroup: GPUBindGroup | null = null
private previewTextureBindGroup: GPUBindGroup | null = null
// Removed separate uniform bind groups as we will use mainUniformBindGroup
private lastReadbackTexture: GPUTexture | null = null
private lastReadbackBuffer: GPUBuffer | null = null
private readbackBindGroup: GPUBindGroup | null = null
private lastBackgroundTexture: GPUTexture | null = null
private backgroundBindGroup: GPUBindGroup | null = null
constructor(
device: GPUDevice,
presentationFormat: GPUTextureFormat = 'rgba8unorm'
) {
this.device = device
// --- 1. Initialize Buffers ---
this.quadVertexBuffer = device.createBuffer({
size: QUAD_VERTS.byteLength,
usage: GPUBufferUsage.VERTEX,
mappedAtCreation: true
})
new Float32Array(this.quadVertexBuffer.getMappedRange()).set(QUAD_VERTS)
this.quadVertexBuffer.unmap()
this.indexBuffer = device.createBuffer({
size: QUAD_INDICES.byteLength,
usage: GPUBufferUsage.INDEX,
mappedAtCreation: true
})
new Uint16Array(this.indexBuffer.getMappedRange()).set(QUAD_INDICES)
this.indexBuffer.unmap()
this.instanceBuffer = device.createBuffer({
size: MAX_STROKES * STROKE_STRIDE,
usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST
})
this.uniformBuffer = device.createBuffer({
size: UNIFORM_SIZE,
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST
})
// --- 2. Brush Shader (Drawing) ---
const brushModuleV = device.createShaderModule({ code: brushVertex })
const brushModuleF = device.createShaderModule({ code: brushFragment })
// Create explicit bind group layouts
this.uniformBindGroupLayout = device.createBindGroupLayout({
entries: [
{
binding: 0,
visibility: GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT,
buffer: { type: 'uniform' }
}
]
})
this.textureBindGroupLayout = device.createBindGroupLayout({
entries: [
{
binding: 0,
visibility: GPUShaderStage.FRAGMENT,
texture: {} // default is float, 2d
}
]
})
this.mainUniformBindGroup = device.createBindGroup({
layout: this.uniformBindGroupLayout,
entries: [{ binding: 0, resource: { buffer: this.uniformBuffer } }]
})
const renderPipelineLayout = device.createPipelineLayout({
bindGroupLayouts: [this.uniformBindGroupLayout]
})
// Standard Render Pipeline (Alpha Blend)
this.renderPipeline = device.createRenderPipeline({
layout: renderPipelineLayout,
vertex: {
module: brushModuleV,
entryPoint: 'vs',
buffers: [
{
arrayStride: 8,
stepMode: 'vertex',
attributes: [{ shaderLocation: 0, offset: 0, format: 'float32x2' }] // Quad vertex attributes
},
{
arrayStride: 16,
stepMode: 'instance',
attributes: [
{ shaderLocation: 1, offset: 0, format: 'float32x2' }, // Instance attributes: position
{ shaderLocation: 2, offset: 8, format: 'float32' }, // size
{ shaderLocation: 3, offset: 12, format: 'float32' } // pressure
]
}
]
},
fragment: {
module: brushModuleF,
entryPoint: 'fs',
targets: [
{
format: 'rgba8unorm',
blend: {
color: {
srcFactor: 'one',
dstFactor: 'one-minus-src-alpha',
operation: 'add'
},
alpha: {
srcFactor: 'one',
dstFactor: 'one-minus-src-alpha',
operation: 'add'
}
}
}
]
},
primitive: { topology: 'triangle-list' }
})
// Accumulate strokes using SourceOver blending to ensure smooth intersections.
this.accumulatePipeline = device.createRenderPipeline({
layout: renderPipelineLayout,
vertex: {
module: brushModuleV,
entryPoint: 'vs',
buffers: [
{
arrayStride: 8,
stepMode: 'vertex',
attributes: [{ shaderLocation: 0, offset: 0, format: 'float32x2' }]
},
{
arrayStride: 16,
stepMode: 'instance',
attributes: [
{ shaderLocation: 1, offset: 0, format: 'float32x2' },
{ shaderLocation: 2, offset: 8, format: 'float32' },
{ shaderLocation: 3, offset: 12, format: 'float32' }
]
}
]
},
fragment: {
module: brushModuleF,
entryPoint: 'fs',
targets: [
{
format: 'rgba8unorm',
blend: {
// Use SourceOver blending for smooth stroke intersections.
color: {
srcFactor: 'one',
dstFactor: 'one-minus-src-alpha',
operation: 'add'
},
alpha: {
srcFactor: 'one',
dstFactor: 'one-minus-src-alpha',
operation: 'add'
}
}
}
]
},
primitive: { topology: 'triangle-list' }
})
// --- 3. Blit Pipeline (For Preview) ---
const blitPipelineLayout = device.createPipelineLayout({
bindGroupLayouts: [this.textureBindGroupLayout]
})
this.blitPipeline = device.createRenderPipeline({
layout: blitPipelineLayout,
vertex: {
module: device.createShaderModule({ code: blitShader }),
entryPoint: 'vs'
},
fragment: {
module: device.createShaderModule({ code: blitShader }),
entryPoint: 'fs',
targets: [
{
format: presentationFormat, // Use the presentation format
blend: {
color: {
srcFactor: 'one',
dstFactor: 'one-minus-src-alpha',
operation: 'add'
},
alpha: {
srcFactor: 'one',
dstFactor: 'one-minus-src-alpha',
operation: 'add'
}
}
}
]
},
primitive: { topology: 'triangle-list' }
})
// --- 4. Composite Pipeline ---
const compositePipelineLayout = device.createPipelineLayout({
bindGroupLayouts: [
this.textureBindGroupLayout,
this.uniformBindGroupLayout
]
})
// Standard composite pipeline for offscreen textures
this.compositePipeline = device.createRenderPipeline({
layout: compositePipelineLayout,
vertex: {
module: device.createShaderModule({ code: compositeShader }),
entryPoint: 'vs'
},
fragment: {
module: device.createShaderModule({ code: compositeShader }),
entryPoint: 'fs',
targets: [
{
format: 'rgba8unorm',
blend: {
color: {
srcFactor: 'one',
dstFactor: 'one-minus-src-alpha',
operation: 'add'
},
alpha: {
srcFactor: 'one',
dstFactor: 'one-minus-src-alpha',
operation: 'add'
}
}
}
]
},
primitive: { topology: 'triangle-list' }
})
// Composite pipeline for the preview canvas
this.compositePipelinePreview = device.createRenderPipeline({
layout: compositePipelineLayout,
vertex: {
module: device.createShaderModule({ code: compositeShader }),
entryPoint: 'vs'
},
fragment: {
module: device.createShaderModule({ code: compositeShader }),
entryPoint: 'fs',
targets: [
{
format: presentationFormat,
blend: {
color: {
srcFactor: 'one',
dstFactor: 'one-minus-src-alpha',
operation: 'add'
},
alpha: {
srcFactor: 'one',
dstFactor: 'one-minus-src-alpha',
operation: 'add'
}
}
}
]
},
primitive: { topology: 'triangle-list' }
})
// --- 5. Erase Pipeline (Destination Out) ---
// Standard erase pipeline for offscreen textures
this.erasePipeline = device.createRenderPipeline({
layout: compositePipelineLayout,
vertex: {
module: device.createShaderModule({ code: compositeShader }),
entryPoint: 'vs'
},
fragment: {
module: device.createShaderModule({ code: compositeShader }),
entryPoint: 'fs',
targets: [
{
format: 'rgba8unorm',
blend: {
color: {
srcFactor: 'zero',
dstFactor: 'one-minus-src-alpha', // dst * (1 - src_alpha)
operation: 'add'
},
alpha: {
srcFactor: 'zero',
dstFactor: 'one-minus-src-alpha', // dst_alpha * (1 - src_alpha)
operation: 'add'
}
}
}
]
},
primitive: { topology: 'triangle-list' }
})
// Erase pipeline for the preview canvas
this.erasePipelinePreview = device.createRenderPipeline({
layout: compositePipelineLayout,
vertex: {
module: device.createShaderModule({ code: compositeShader }),
entryPoint: 'vs'
},
fragment: {
module: device.createShaderModule({ code: compositeShader }),
entryPoint: 'fs',
targets: [
{
format: presentationFormat,
blend: {
color: {
srcFactor: 'zero',
dstFactor: 'one-minus-src-alpha',
operation: 'add'
},
alpha: {
srcFactor: 'zero',
dstFactor: 'one-minus-src-alpha',
operation: 'add'
}
}
}
]
},
primitive: { topology: 'triangle-list' }
})
// --- 6. Readback Pipeline (Compute) ---
this.readbackPipeline = device.createComputePipeline({
layout: 'auto',
compute: {
module: device.createShaderModule({ code: readbackShader }),
entryPoint: 'main'
}
})
}
public prepareStroke(width: number, height: number) {
// Initialize or resize the accumulation texture
if (
!this.currentStrokeTexture ||
this.currentStrokeTexture.width !== width ||
this.currentStrokeTexture.height !== height
) {
if (this.currentStrokeTexture) this.currentStrokeTexture.destroy()
this.currentStrokeTexture = this.device.createTexture({
size: [width, height],
format: 'rgba8unorm',
usage:
GPUTextureUsage.RENDER_ATTACHMENT |
GPUTextureUsage.TEXTURE_BINDING |
GPUTextureUsage.COPY_SRC
})
this.currentStrokeView = this.currentStrokeTexture.createView()
// Invalidate texture-dependent bind groups
this.compositeTextureBindGroup = null
this.previewTextureBindGroup = null
// Readback bind group might also be invalid if it was using the old texture
if (this.lastReadbackTexture === this.currentStrokeTexture) {
this.readbackBindGroup = null
this.lastReadbackTexture = null
}
}
// Clear the accumulation texture
const encoder = this.device.createCommandEncoder()
const pass = encoder.beginRenderPass({
colorAttachments: [
{
view: this.currentStrokeView!,
loadOp: 'clear',
clearValue: { r: 0, g: 0, b: 0, a: 0 },
storeOp: 'store'
}
]
})
pass.end()
this.device.queue.submit([encoder.finish()])
}
public renderStrokeToAccumulator(
points: { x: number; y: number; pressure: number }[],
settings: {
size: number
opacity: number
hardness: number
color: [number, number, number]
width: number
height: number
brushShape: number
}
) {
if (!this.currentStrokeView) return
// Render stroke using accumulation pipeline
this.renderStrokeInternal(
this.currentStrokeView,
this.accumulatePipeline,
points,
settings
)
}
public compositeStroke(
targetView: GPUTextureView,
settings: {
opacity: number
color: [number, number, number]
hardness: number // Required for uniform buffer layout
screenSize: [number, number]
brushShape: number
isErasing?: boolean
}
) {
if (!this.currentStrokeTexture) return
// Update uniforms for the composite pass
const buffer = new ArrayBuffer(UNIFORM_SIZE)
const f32 = new Float32Array(buffer)
const u32 = new Uint32Array(buffer)
f32[0] = settings.color[0]
f32[1] = settings.color[1]
f32[2] = settings.color[2]
f32[3] = settings.opacity
f32[4] = settings.hardness
f32[5] = 0 // Padding
f32[6] = settings.screenSize[0]
f32[7] = settings.screenSize[1]
u32[8] = settings.brushShape // Brush shape: 0=Circle, 1=Square
this.device.queue.writeBuffer(this.uniformBuffer, 0, buffer)
const encoder = this.device.createCommandEncoder()
// Choose pipeline based on operation
const pipeline = settings.isErasing
? this.erasePipeline
: this.compositePipeline
// 1. Texture Bind Group (Group 0)
if (!this.compositeTextureBindGroup) {
this.compositeTextureBindGroup = this.device.createBindGroup({
layout: this.textureBindGroupLayout,
entries: [
{ binding: 0, resource: this.currentStrokeTexture.createView() }
]
})
}
// 2. Uniform Bind Group (Group 1) - Use shared mainUniformBindGroup
// It is compatible because we used the same layout
const pass = encoder.beginRenderPass({
colorAttachments: [
{
view: targetView,
loadOp: 'load',
storeOp: 'store'
}
]
})
pass.setPipeline(pipeline)
pass.setBindGroup(0, this.compositeTextureBindGroup)
pass.setBindGroup(1, this.mainUniformBindGroup)
pass.draw(3)
pass.end()
this.device.queue.submit([encoder.finish()])
}
// Direct rendering method
public renderStroke(
targetView: GPUTextureView,
points: { x: number; y: number; pressure: number }[],
settings: {
size: number
opacity: number
hardness: number
color: [number, number, number]
width: number
height: number
brushShape: number
}
) {
this.renderStrokeInternal(targetView, this.renderPipeline, points, settings)
}
private renderStrokeInternal(
targetView: GPUTextureView,
pipeline: GPURenderPipeline,
points: { x: number; y: number; pressure: number }[],
settings: {
size: number
opacity: number
hardness: number
color: [number, number, number]
width: number
height: number
brushShape: number
}
) {
if (points.length === 0) return
// 1. Update Uniforms
const buffer = new ArrayBuffer(UNIFORM_SIZE)
const f32 = new Float32Array(buffer)
const u32 = new Uint32Array(buffer)
f32[0] = settings.color[0]
f32[1] = settings.color[1]
f32[2] = settings.color[2]
f32[3] = settings.opacity
f32[4] = settings.hardness
f32[5] = 0 // Padding
f32[6] = settings.width
f32[7] = settings.height
u32[8] = settings.brushShape
this.device.queue.writeBuffer(this.uniformBuffer, 0, buffer)
// 2. Batch Rendering
let processedPoints = 0
while (processedPoints < points.length) {
const batchSize = Math.min(points.length - processedPoints, MAX_STROKES)
const iData = new Float32Array(batchSize * 4)
for (let i = 0; i < batchSize; i++) {
const p = points[processedPoints + i]
iData[i * 4 + 0] = p.x
iData[i * 4 + 1] = p.y
iData[i * 4 + 2] = settings.size
iData[i * 4 + 3] = p.pressure
}
this.device.queue.writeBuffer(this.instanceBuffer, 0, iData)
// 3. Render Pass
const encoder = this.device.createCommandEncoder()
const pass = encoder.beginRenderPass({
colorAttachments: [
{
view: targetView,
loadOp: 'load',
storeOp: 'store'
}
]
})
pass.setPipeline(pipeline)
pass.setBindGroup(0, this.mainUniformBindGroup)
pass.setVertexBuffer(0, this.quadVertexBuffer)
pass.setVertexBuffer(1, this.instanceBuffer)
pass.setIndexBuffer(this.indexBuffer, 'uint16')
pass.drawIndexed(6, batchSize)
pass.end()
this.device.queue.submit([encoder.finish()])
processedPoints += batchSize
}
}
// Blit the accumulated stroke to the preview canvas
public blitToCanvas(
destinationCtx: GPUCanvasContext,
settings: {
opacity: number
color: [number, number, number]
hardness: number
screenSize: [number, number]
brushShape: number
isErasing?: boolean
},
backgroundTexture?: GPUTexture
) {
const encoder = this.device.createCommandEncoder()
const destView = destinationCtx.getCurrentTexture().createView()
if (backgroundTexture) {
// Draw background texture to allow erasing effect on existing content
if (
this.lastBackgroundTexture !== backgroundTexture ||
!this.backgroundBindGroup
) {
this.backgroundBindGroup = this.device.createBindGroup({
layout: this.textureBindGroupLayout,
entries: [{ binding: 0, resource: backgroundTexture.createView() }]
})
this.lastBackgroundTexture = backgroundTexture
}
const pass = encoder.beginRenderPass({
colorAttachments: [
{
view: destView,
loadOp: 'clear', // Clear attachment before drawing
clearValue: { r: 0, g: 0, b: 0, a: 0 },
storeOp: 'store'
}
]
})
pass.setPipeline(this.blitPipeline)
pass.setBindGroup(0, this.backgroundBindGroup)
pass.draw(3)
pass.end()
} else {
// Clear the destination texture
const clearPass = encoder.beginRenderPass({
colorAttachments: [
{
view: destView,
loadOp: 'clear',
clearValue: { r: 0, g: 0, b: 0, a: 0 },
storeOp: 'store'
}
]
})
clearPass.end()
}
// Draw the accumulated stroke
if (this.currentStrokeTexture) {
// Update uniforms for the preview pass
const buffer = new ArrayBuffer(UNIFORM_SIZE)
const f32 = new Float32Array(buffer)
const u32 = new Uint32Array(buffer)
f32[0] = settings.color[0]
f32[1] = settings.color[1]
f32[2] = settings.color[2]
f32[3] = settings.opacity
f32[4] = settings.hardness
f32[5] = 0 // Padding
f32[6] = settings.screenSize[0]
f32[7] = settings.screenSize[1]
u32[8] = settings.brushShape
this.device.queue.writeBuffer(this.uniformBuffer, 0, buffer)
// Select preview pipeline based on operation
const pipeline = settings.isErasing
? this.erasePipelinePreview
: this.compositePipelinePreview
// 1. Texture Bind Group (Group 0)
if (!this.previewTextureBindGroup) {
this.previewTextureBindGroup = this.device.createBindGroup({
layout: this.textureBindGroupLayout,
entries: [
{ binding: 0, resource: this.currentStrokeTexture.createView() }
]
})
}
// 2. Uniform Bind Group (Group 1) - Use shared mainUniformBindGroup
const passStroke = encoder.beginRenderPass({
colorAttachments: [
{
view: destView,
loadOp: 'load', // Load the previous pass result
storeOp: 'store'
}
]
})
passStroke.setPipeline(pipeline)
passStroke.setBindGroup(0, this.previewTextureBindGroup)
passStroke.setBindGroup(1, this.mainUniformBindGroup)
passStroke.draw(3)
passStroke.end()
}
this.device.queue.submit([encoder.finish()])
}
// Clear the preview canvas
public clearPreview(destinationCtx: GPUCanvasContext) {
const encoder = this.device.createCommandEncoder()
const pass = encoder.beginRenderPass({
colorAttachments: [
{
view: destinationCtx.getCurrentTexture().createView(),
loadOp: 'clear',
clearValue: { r: 0, g: 0, b: 0, a: 0 },
storeOp: 'store'
}
]
})
pass.end()
this.device.queue.submit([encoder.finish()])
}
public prepareReadback(texture: GPUTexture, outputBuffer: GPUBuffer) {
if (
this.lastReadbackTexture !== texture ||
this.lastReadbackBuffer !== outputBuffer ||
!this.readbackBindGroup
) {
this.readbackBindGroup = this.device.createBindGroup({
layout: this.readbackPipeline.getBindGroupLayout(0),
entries: [
{ binding: 0, resource: texture.createView() },
{ binding: 1, resource: { buffer: outputBuffer } }
]
})
this.lastReadbackTexture = texture
this.lastReadbackBuffer = outputBuffer
}
const encoder = this.device.createCommandEncoder()
const pass = encoder.beginComputePass()
pass.setPipeline(this.readbackPipeline)
pass.setBindGroup(0, this.readbackBindGroup)
const width = texture.width
const height = texture.height
// Dispatch workgroups based on texture dimensions (8x8 block size)
pass.dispatchWorkgroups(Math.ceil(width / 8), Math.ceil(height / 8))
pass.end()
this.device.queue.submit([encoder.finish()])
}
public destroy() {
this.quadVertexBuffer.destroy()
this.indexBuffer.destroy()
this.instanceBuffer.destroy()
this.uniformBuffer.destroy()
if (this.currentStrokeTexture) this.currentStrokeTexture.destroy()
// Clear cached bind groups
this.compositeTextureBindGroup = null
this.previewTextureBindGroup = null
this.readbackBindGroup = null
this.backgroundBindGroup = null
this.lastReadbackTexture = null
this.lastReadbackBuffer = null
this.lastBackgroundTexture = null
}
}

View File

@@ -1,171 +0,0 @@
import tgpu from 'typegpu'
import * as d from 'typegpu/data'
import { BrushUniforms } from './gpuSchema'
const VertexOutput = d.struct({
position: d.builtin.position,
localUV: d.location(0, d.vec2f),
color: d.location(1, d.vec3f),
opacity: d.location(2, d.f32),
hardness: d.location(3, d.f32)
})
const brushVertexTemplate = `
@group(0) @binding(0) var<uniform> globals: BrushUniforms;
@vertex
fn vs(
@location(0) quadPos: vec2<f32>,
@location(1) pos: vec2<f32>,
@location(2) size: f32,
@location(3) pressure: f32
) -> VertexOutput {
// Convert diameter to radius
let radius = size * pressure;
let pixelPos = pos + (quadPos * radius);
// Convert pixel coordinates to Normalized Device Coordinates (NDC)
let ndcX = (pixelPos.x / globals.screenSize.x) * 2.0 - 1.0;
let ndcY = 1.0 - ((pixelPos.y / globals.screenSize.y) * 2.0); // Flip Y axis for WebGPU coordinate system
return VertexOutput(
vec4<f32>(ndcX, ndcY, 0.0, 1.0),
quadPos,
globals.brushColor,
pressure * globals.brushOpacity,
globals.hardness
);
}
`
export const brushVertex = tgpu.resolve({
template: brushVertexTemplate,
externals: {
BrushUniforms,
VertexOutput
}
})
const brushFragmentTemplate = `
@group(0) @binding(0) var<uniform> globals: BrushUniforms;
@fragment
fn fs(v: VertexOutput) -> @location(0) vec4<f32> {
var dist: f32;
if (globals.brushShape == 1u) {
// Calculate Chebyshev distance for square shape
dist = max(abs(v.localUV.x), abs(v.localUV.y));
} else {
// Calculate Euclidean distance for circle shape
dist = length(v.localUV);
}
if (dist > 1.0) { discard; }
// Calculate alpha with hardness and anti-aliasing
let edgeWidth = fwidth(dist);
let startFade = min(v.hardness, 1.0 - edgeWidth * 2.0);
let linearAlpha = 1.0 - smoothstep(startFade, 1.0, dist);
// Apply quadratic falloff for smoother edges
let alphaShape = pow(linearAlpha, 2.0);
// Return premultiplied alpha color
let alpha = alphaShape * v.opacity;
return vec4<f32>(v.color * alpha, alpha);
}
`
export const brushFragment = tgpu.resolve({
template: brushFragmentTemplate,
externals: {
VertexOutput,
BrushUniforms
}
})
const blitShaderTemplate = `
@vertex fn vs(@builtin(vertex_index) vIdx: u32) -> @builtin(position) vec4<f32> {
var pos = array<vec2<f32>, 3>(
vec2<f32>(-1.0, -1.0), vec2<f32>(3.0, -1.0), vec2<f32>(-1.0, 3.0)
);
return vec4<f32>(pos[vIdx], 0.0, 1.0);
}
@group(0) @binding(0) var myTexture: texture_2d<f32>;
@fragment fn fs(@builtin(position) pos: vec4<f32>) -> @location(0) vec4<f32> {
let c = textureLoad(myTexture, vec2<i32>(pos.xy), 0);
// Treat texture as premultiplied to prevent double-darkening on overlaps
return c;
}
`
export const blitShader = tgpu.resolve({
template: blitShaderTemplate,
externals: {}
})
const compositeShaderTemplate = `
@vertex fn vs(@builtin(vertex_index) vIdx: u32) -> @builtin(position) vec4<f32> {
var pos = array<vec2<f32>, 3>(
vec2<f32>(-1.0, -1.0), vec2<f32>(3.0, -1.0), vec2<f32>(-1.0, 3.0)
);
return vec4<f32>(pos[vIdx], 0.0, 1.0);
}
@group(0) @binding(0) var myTexture: texture_2d<f32>;
@group(1) @binding(0) var<uniform> globals: BrushUniforms;
@fragment fn fs(@builtin(position) pos: vec4<f32>) -> @location(0) vec4<f32> {
let sampled = textureLoad(myTexture, vec2<i32>(pos.xy), 0);
// Apply global brush opacity to accumulated coverage
return sampled * globals.brushOpacity;
}
`
export const compositeShader = tgpu.resolve({
template: compositeShaderTemplate,
externals: {
BrushUniforms
}
})
const readbackShaderTemplate = `
@group(0) @binding(0) var inputTex: texture_2d<f32>;
@group(0) @binding(1) var<storage, read_write> outputBuf: array<u32>;
@compute @workgroup_size(8, 8)
fn main(@builtin(global_invocation_id) id: vec3<u32>) {
let dims = textureDimensions(inputTex);
if (id.x >= dims.x || id.y >= dims.y) { return; }
let color = textureLoad(inputTex, vec2<i32>(id.xy), 0);
var r = color.r;
var g = color.g;
var b = color.b;
let a = color.a;
if (a > 0.0) {
r = r / a;
g = g / a;
b = b / a;
}
let ir = u32(clamp(r * 255.0, 0.0, 255.0));
let ig = u32(clamp(g * 255.0, 0.0, 255.0));
let ib = u32(clamp(b * 255.0, 0.0, 255.0));
let ia = u32(clamp(a * 255.0, 0.0, 255.0));
// Pack RGBA channels into a single u32 (Little Endian)
let packed = ir | (ig << 8u) | (ib << 16u) | (ia << 24u);
let index = id.y * dims.x + id.x;
outputBuf[index] = packed;
}
`
export const readbackShader = tgpu.resolve({
template: readbackShaderTemplate,
externals: {}
})

View File

@@ -1,17 +0,0 @@
import * as d from 'typegpu/data'
// Global brush uniforms
export const BrushUniforms = d.struct({
brushColor: d.vec3f,
brushOpacity: d.f32,
hardness: d.f32,
screenSize: d.vec2f,
brushShape: d.u32 // 0: Circle, 1: Square
})
// Per-point instance data
export const StrokePoint = d.struct({
pos: d.location(0, d.vec2f), // Center position
size: d.location(1, d.f32), // Brush radius
pressure: d.location(2, d.f32) // Pressure value (0.0 - 1.0)
})

View File

@@ -1,126 +0,0 @@
import type { Point } from '@/extensions/core/maskeditor/types'
/**
* Evaluates a Catmull-Rom spline at parameter t between p1 and p2
* @param p0 Previous control point
* @param p1 Start point of the curve segment
* @param p2 End point of the curve segment
* @param p3 Next control point
* @param t Parameter in range [0, 1]
* @returns Interpolated point on the curve
*/
export function catmullRomSpline(
p0: Point,
p1: Point,
p2: Point,
p3: Point,
t: number
): Point {
// Centripetal Catmull-Rom Spline (alpha = 0.5) to prevent loops and overshoots
const alpha = 0.5
const getT = (t: number, p0: Point, p1: Point) => {
const d = Math.hypot(p1.x - p0.x, p1.y - p0.y)
return t + Math.pow(d, alpha)
}
const t0 = 0
const t1 = getT(t0, p0, p1)
const t2 = getT(t1, p1, p2)
const t3 = getT(t2, p2, p3)
// Map normalized t to parameter range
const tInterp = t1 + (t2 - t1) * t
// Safe interpolation for coincident points
const interp = (
pA: Point,
pB: Point,
tA: number,
tB: number,
t: number
): Point => {
if (Math.abs(tB - tA) < 0.0001) return pA
const k = (t - tA) / (tB - tA)
return add(mul(pA, 1 - k), mul(pB, k))
}
// Barry-Goldman pyramidal interpolation
const A1 = interp(p0, p1, t0, t1, tInterp)
const A2 = interp(p1, p2, t1, t2, tInterp)
const A3 = interp(p2, p3, t2, t3, tInterp)
const B1 = interp(A1, A2, t0, t2, tInterp)
const B2 = interp(A2, A3, t1, t3, tInterp)
const C = interp(B1, B2, t1, t2, tInterp)
return C
}
function add(p1: Point, p2: Point): Point {
return { x: p1.x + p2.x, y: p1.y + p2.y }
}
function mul(p: Point, s: number): Point {
return { x: p.x * s, y: p.y * s }
}
/**
* Resamples a curve segment with a starting offset (remainder from previous segment).
* Returns the resampled points and the new remainder distance.
*
* @param points Points defining the curve segment
* @param spacing Desired spacing between points
* @param startOffset Distance to travel before placing the first point (remainder)
* @returns Object containing points and new remainder
*/
export function resampleSegment(
points: Point[],
spacing: number,
startOffset: number
): { points: Point[]; remainder: number } {
if (points.length === 0) return { points: [], remainder: startOffset }
const result: Point[] = []
let currentDist = 0
let nextSampleDist = startOffset
// Iterate through segment points
for (let i = 0; i < points.length - 1; i++) {
const p1 = points[i]
const p2 = points[i + 1]
const dx = p2.x - p1.x
const dy = p2.y - p1.y
const segmentLen = Math.hypot(dx, dy)
// Handle zero-length segments
if (segmentLen < 0.0001) {
while (nextSampleDist <= currentDist) {
result.push(p1)
nextSampleDist += spacing
}
continue
}
// Generate samples within the segment
while (nextSampleDist <= currentDist + segmentLen) {
const t = (nextSampleDist - currentDist) / segmentLen
// Interpolate
const x = p1.x + t * dx
const y = p1.y + t * dy
result.push({ x, y })
nextSampleDist += spacing
}
currentDist += segmentLen
}
// Calculate remainder distance for the next segment
const remainder = nextSampleDist - currentDist
return { points: result, remainder }
}

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