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
205 changed files with 5787 additions and 7999 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

@@ -9,7 +9,7 @@ module.exports = defineConfig({
entry: 'src/locales/en',
entryLocale: 'en',
output: 'src/locales',
outputLocales: ['zh', 'zh-TW', 'ru', 'ja', 'ko', 'fr', 'es', 'ar', 'tr', 'pt-BR'],
outputLocales: ['zh', 'zh-TW', 'ru', 'ja', 'ko', 'fr', 'es', 'ar', 'tr'],
reference: `Special names to keep untranslated: flux, photomaker, clip, vae, cfg, stable audio, stable cascade, stable zero, controlnet, lora, HiDream.
'latent' is the short form of 'latent space'.
'mask' is in the context of image processing.

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,12 +53,11 @@
/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/pt-BR/ @JonatanAtila @Yorha4D @KarryCharon @shinshin86
/src/locales/ @Yorha4D @KarryCharon @shinshin86 @Comfy-Org/comfy_maintainer
# LLM Instructions (blank on purpose)
.claude/
.cursor/
.cursorrules
**/AGENTS.md
**/CLAUDE.md
**/CLAUDE.md

View File

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

View File

@@ -59,8 +59,7 @@ const LOCALES = [
['fr', 'Français'],
['es', 'Español'],
['ar', 'عربي'],
['tr', 'Türkçe'],
['pt-BR', 'Português (BR)']
['tr', 'Türkçe']
] as const satisfies ReadonlyArray<[string, string]>
type SupportedLocale = (typeof LOCALES)[number][0]

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>

View File

@@ -40,8 +40,7 @@ const localeLoaders: Record<
ru: () => import('@frontend-locales/ru/main.json'),
tr: () => import('@frontend-locales/tr/main.json'),
zh: () => import('@frontend-locales/zh/main.json'),
'zh-TW': () => import('@frontend-locales/zh-TW/main.json'),
'pt-BR': () => import('@frontend-locales/pt-BR/main.json')
'zh-TW': () => import('@frontend-locales/zh-TW/main.json')
}
const nodeDefsLoaders: Record<
@@ -56,8 +55,7 @@ const nodeDefsLoaders: Record<
ru: () => import('@frontend-locales/ru/nodeDefs.json'),
tr: () => import('@frontend-locales/tr/nodeDefs.json'),
zh: () => import('@frontend-locales/zh/nodeDefs.json'),
'zh-TW': () => import('@frontend-locales/zh-TW/nodeDefs.json'),
'pt-BR': () => import('@frontend-locales/pt-BR/nodeDefs.json')
'zh-TW': () => import('@frontend-locales/zh-TW/nodeDefs.json')
}
const commandsLoaders: Record<
@@ -72,8 +70,7 @@ const commandsLoaders: Record<
ru: () => import('@frontend-locales/ru/commands.json'),
tr: () => import('@frontend-locales/tr/commands.json'),
zh: () => import('@frontend-locales/zh/commands.json'),
'zh-TW': () => import('@frontend-locales/zh-TW/commands.json'),
'pt-BR': () => import('@frontend-locales/pt-BR/commands.json')
'zh-TW': () => import('@frontend-locales/zh-TW/commands.json')
}
const settingsLoaders: Record<
@@ -88,8 +85,7 @@ const settingsLoaders: Record<
ru: () => import('@frontend-locales/ru/settings.json'),
tr: () => import('@frontend-locales/tr/settings.json'),
zh: () => import('@frontend-locales/zh/settings.json'),
'zh-TW': () => import('@frontend-locales/zh-TW/settings.json'),
'pt-BR': () => import('@frontend-locales/pt-BR/settings.json')
'zh-TW': () => import('@frontend-locales/zh-TW/settings.json')
}
// Track which locales have been loaded

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

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: 151 KiB

After

Width:  |  Height:  |  Size: 150 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 143 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

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

@@ -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,7 +1,7 @@
{
"name": "@comfyorg/comfyui-frontend",
"private": true,
"version": "1.34.2",
"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

@@ -194,7 +194,7 @@
--node-component-executing: var(--color-blue-500);
--node-component-header: var(--fg-color);
--node-component-header-icon: var(--color-ash-800);
--node-component-header-surface: var(--color-smoke-400);
--node-component-header-surface: var(--color-white);
--node-component-outline: var(--color-black);
--node-component-ring: rgb(from var(--color-smoke-500) r g b / 50%);
--node-component-slot-dot-outline-opacity-mult: 1;
@@ -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

@@ -44,22 +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'
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

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

@@ -6,7 +6,10 @@
{{ t('maskEditor.brushSettings') }}
</h3>
<button :class="textButtonClass" @click="resetToDefault">
<button
class="w-45 h-7.5 border-none bg-black/20 border border-[var(--border-color)] text-[var(--input-text)] font-sans text-[15px] pointer-events-auto transition-colors duration-100 hover:bg-[var(--p-overlaybadge-outline-color)] hover:border-none"
@click="resetToDefault"
>
{{ t('maskEditor.resetToDefault') }}
</button>
@@ -52,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"
@@ -77,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>
@@ -96,9 +99,6 @@ import SliderControl from './controls/SliderControl.vue'
const store = useMaskEditorStore()
const textButtonClass =
'h-7.5 w-32 rounded-[10px] border border-[var(--p-form-field-border-color)] text-[var(--input-text)] font-sans pointer-events-auto transition-colors duration-100 bg-[var(--comfy-menu-bg)] hover:bg-secondary-background-hover'
const setBrushShape = (shape: BrushShape) => {
store.brushSettings.type = shape
}
@@ -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

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

@@ -85,12 +85,13 @@
:show-output-count="shouldShowOutputCount(item)"
:output-count="getOutputCount(item)"
:show-delete-button="shouldShowDeleteButton"
:open-context-menu-id="openContextMenuId"
:open-popover-id="openPopoverId"
@click="handleAssetSelect(item)"
@zoom="handleZoomClick(item)"
@output-count-click="enterFolderView(item)"
@asset-deleted="refreshAssets"
@context-menu-opened="openContextMenuId = item.id"
@popover-opened="openPopoverId = item.id"
@popover-closed="openPopoverId = null"
/>
</template>
</VirtualGrid>
@@ -112,7 +113,7 @@
count: totalOutputCount
})
"
type="secondary"
type="transparent"
:class="isCompact ? 'text-left' : ''"
@click="handleDeselectAll"
/>
@@ -201,8 +202,8 @@ const folderPromptId = ref<string | null>(null)
const folderExecutionTime = ref<number | undefined>(undefined)
const isInFolderView = computed(() => folderPromptId.value !== null)
// Track which asset's context menu is open (for single-instance context menu management)
const openContextMenuId = ref<string | null>(null)
// Track which asset's popover is open (for single-instance popover management)
const openPopoverId = ref<string | null>(null)
// Determine if delete button should be shown
// Hide delete button when in input tab and not in cloud (OSS mode - files are from local folders)
@@ -213,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 text-white">
<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"
@@ -17,7 +14,7 @@
</Button>
</div>
<Button
class="cursor-pointer bg-transparent border-0 outline-0 grid place-items-center absolute right-4 text-white"
class="cursor-pointer bg-transparent border-0 outline-0 grid place-items-center absolute right-4"
unstyled
@click="handleDismiss"
>
@@ -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

@@ -1,5 +1,5 @@
import { useElementBounding, useRafFn } from '@vueuse/core'
import { computed, onUnmounted, ref, watch, watchEffect } from 'vue'
import { computed, onUnmounted, ref, watch } from 'vue'
import type { Ref } from 'vue'
import { useSelectedLiteGraphItems } from '@/composables/canvas/useSelectedLiteGraphItems'
@@ -157,14 +157,6 @@ export function useSelectionToolboxPosition(
// Sync with canvas transform
const { resume: startSync, pause: stopSync } = useRafFn(updateTransform)
watchEffect(() => {
if (visible.value) {
startSync()
} else {
stopSync()
}
})
// Watch for selection changes
watch(
() => canvasStore.getCanvas().state.selectionChanged,
@@ -181,6 +173,11 @@ export function useSelectionToolboxPosition(
}
updateSelectionBounds()
canvasStore.getCanvas().state.selectionChanged = false
if (visible.value) {
startSync()
} else {
stopSync()
}
}
},
{ immediate: true }

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

File diff suppressed because it is too large Load Diff

View File

@@ -4,9 +4,7 @@ import { useMaskEditorStore } from '@/stores/maskEditorStore'
export function useCanvasHistory(maxStates = 20) {
const store = useMaskEditorStore()
const states = ref<
{ mask: ImageData | ImageBitmap; rgb: ImageData | ImageBitmap }[]
>([])
const states = ref<{ mask: ImageData; rgb: ImageData }[]>([])
const currentStateIndex = ref(-1)
const initialized = ref(false)
@@ -55,10 +53,7 @@ export function useCanvasHistory(maxStates = 20) {
initialized.value = true
}
const saveState = (
providedMaskData?: ImageData | ImageBitmap,
providedRgbData?: ImageData | ImageBitmap
) => {
const saveState = () => {
const maskCtx = store.maskCtx
const rgbCtx = store.rgbCtx
const maskCanvas = store.maskCanvas
@@ -73,32 +68,23 @@ export function useCanvasHistory(maxStates = 20) {
states.value = states.value.slice(0, currentStateIndex.value + 1)
let maskState: ImageData | ImageBitmap
let rgbState: ImageData | ImageBitmap
if (providedMaskData && providedRgbData) {
maskState = providedMaskData
rgbState = providedRgbData
} else {
maskState = maskCtx.getImageData(
0,
0,
maskCanvas.width,
maskCanvas.height
)
rgbState = rgbCtx.getImageData(0, 0, rgbCanvas.width, rgbCanvas.height)
}
const maskState = maskCtx.getImageData(
0,
0,
maskCanvas.width,
maskCanvas.height
)
const rgbState = rgbCtx.getImageData(
0,
0,
rgbCanvas.width,
rgbCanvas.height
)
states.value.push({ mask: maskState, rgb: rgbState })
currentStateIndex.value++
if (states.value.length > maxStates) {
const removed = states.value.shift()
// Cleanup ImageBitmaps to avoid memory leaks
if (removed) {
if (removed.mask instanceof ImageBitmap) removed.mask.close()
if (removed.rgb instanceof ImageBitmap) removed.rgb.close()
}
states.value.shift()
currentStateIndex.value--
}
}
@@ -123,35 +109,16 @@ export function useCanvasHistory(maxStates = 20) {
restoreState(states.value[currentStateIndex.value])
}
const restoreState = (state: {
mask: ImageData | ImageBitmap
rgb: ImageData | ImageBitmap
}) => {
const restoreState = (state: { mask: ImageData; rgb: ImageData }) => {
const maskCtx = store.maskCtx
const rgbCtx = store.rgbCtx
if (!maskCtx || !rgbCtx) return
if (state.mask instanceof ImageBitmap) {
maskCtx.clearRect(0, 0, state.mask.width, state.mask.height)
maskCtx.drawImage(state.mask, 0, 0)
} else {
maskCtx.putImageData(state.mask, 0, 0)
}
if (state.rgb instanceof ImageBitmap) {
rgbCtx.clearRect(0, 0, state.rgb.width, state.rgb.height)
rgbCtx.drawImage(state.rgb, 0, 0)
} else {
rgbCtx.putImageData(state.rgb, 0, 0)
}
maskCtx.putImageData(state.mask, 0, 0)
rgbCtx.putImageData(state.rgb, 0, 0)
}
const clearStates = () => {
// Cleanup bitmaps
states.value.forEach((state) => {
if (state.mask instanceof ImageBitmap) state.mask.close()
if (state.rgb instanceof ImageBitmap) state.rgb.close()
})
states.value = []
currentStateIndex.value = -1
initialized.value = false
@@ -160,7 +127,6 @@ export function useCanvasHistory(maxStates = 20) {
return {
canUndo,
canRedo,
currentStateIndex,
saveInitialState,
saveState,
undo,

View File

@@ -1,48 +0,0 @@
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import { useDialogStore } from '@/stores/dialogStore'
import TopBarHeader from '@/components/maskeditor/dialog/TopBarHeader.vue'
import MaskEditorContent from '@/components/maskeditor/MaskEditorContent.vue'
export function useMaskEditor() {
const openMaskEditor = (node: LGraphNode) => {
if (!node) {
console.error('[MaskEditor] No node provided')
return
}
if (!node.imgs?.length && node.previewMediaType !== 'image') {
console.error('[MaskEditor] Node has no images')
return
}
useDialogStore().showDialog({
key: 'global-mask-editor',
headerComponent: TopBarHeader,
component: MaskEditorContent,
props: {
node
},
dialogComponentProps: {
style: 'width: 90vw; height: 90vh;',
modal: true,
maximizable: true,
closable: true,
pt: {
root: {
class: 'mask-editor-dialog flex flex-col'
},
content: {
class: 'flex flex-col min-h-0 flex-1 !p-0'
},
header: {
class: '!p-2'
}
}
}
})
}
return {
openMaskEditor
}
}

View File

@@ -80,64 +80,21 @@ export function useMaskEditorLoader() {
try {
validateNode(node)
let nodeImageUrl = getNodeImageUrl(node)
const nodeImageUrl = getNodeImageUrl(node)
let nodeImageRef = parseImageRef(nodeImageUrl)
const nodeImageRef = parseImageRef(nodeImageUrl)
let widgetFilename: string | undefined
if (node.widgets) {
const imageWidget = node.widgets.find((w) => w.name === 'image')
if (imageWidget) {
if (typeof imageWidget.value === 'string') {
widgetFilename = imageWidget.value
} else if (
typeof imageWidget.value === 'object' &&
imageWidget.value &&
'filename' in imageWidget.value &&
typeof imageWidget.value.filename === 'string'
) {
widgetFilename = imageWidget.value.filename
}
}
}
// If we have a widget filename, we should prioritize it over the node image
// because the node image might be stale (e.g. from a previous save)
// while the widget value reflects the current selection.
if (widgetFilename) {
try {
// Parse the widget value which might be in format "subfolder/filename [type]" or just "filename"
let filename = widgetFilename
let subfolder: string | undefined = undefined
let type: string | undefined = 'input' // Default to input for widget values
// Check for type in brackets at the end
const typeMatch = filename.match(/ \[([^\]]+)\]$/)
if (typeMatch) {
type = typeMatch[1]
filename = filename.substring(
0,
filename.length - typeMatch[0].length
)
}
// Check for subfolder (forward slash separator)
const lastSlashIndex = filename.lastIndexOf('/')
if (lastSlashIndex !== -1) {
subfolder = filename.substring(0, lastSlashIndex)
filename = filename.substring(lastSlashIndex + 1)
}
nodeImageRef = {
filename,
type,
subfolder
}
// We also need to update nodeImageUrl to match this new ref so subsequent logic works
nodeImageUrl = mkFileUrl({ ref: nodeImageRef })
} catch (e) {
console.warn('Failed to parse widget filename as ref', e)
if (
imageWidget &&
typeof imageWidget.value === 'object' &&
imageWidget.value &&
'filename' in imageWidget.value &&
typeof imageWidget.value.filename === 'string'
) {
widgetFilename = imageWidget.value.filename
}
}

View File

@@ -303,46 +303,6 @@ const apiNodeCosts: Record<string, { displayPrice: string | PricingFunction }> =
FluxProKontextMaxNode: {
displayPrice: '$0.08/Run'
},
Flux2ProImageNode: {
displayPrice: (node: LGraphNode): string => {
const widthW = node.widgets?.find(
(w) => w.name === 'width'
) as IComboWidget
const heightW = node.widgets?.find(
(w) => w.name === 'height'
) as IComboWidget
const w = Number(widthW?.value)
const h = Number(heightW?.value)
if (!Number.isFinite(w) || !Number.isFinite(h) || w <= 0 || h <= 0) {
// global min/max for this node given schema bounds (1MP..4MP output)
return '$0.03$0.15/Run'
}
// Is the 'images' input connected?
const imagesInput = node.inputs?.find(
(i) => i.name === 'images'
) as INodeInputSlot
const hasRefs =
typeof imagesInput?.link !== 'undefined' && imagesInput.link != null
// Output cost: ceil((w*h)/MP); first MP $0.03, each additional $0.015
const MP = 1024 * 1024
const outMP = Math.max(1, Math.floor((w * h + MP - 1) / MP))
const outputCost = 0.03 + 0.015 * Math.max(outMP - 1, 0)
if (hasRefs) {
// Unknown ref count/size on the frontend:
// min extra is $0.015, max extra is $0.120 (8 MP cap / 8 refs)
const minTotal = outputCost + 0.015
const maxTotal = outputCost + 0.12
return `~$${parseFloat(minTotal.toFixed(3))}$${parseFloat(maxTotal.toFixed(3))}/Run`
}
// Precise text-to-image price
return `$${parseFloat(outputCost.toFixed(3))}/Run`
}
},
OpenAIVideoSora2: {
displayPrice: sora2PricingCalculator
},
@@ -623,12 +583,7 @@ const apiNodeCosts: Record<string, { displayPrice: string | PricingFunction }> =
const modeValue = String(modeWidget.value)
// Same pricing matrix as KlingTextToVideoNode
if (modeValue.includes('v2-5-turbo')) {
if (modeValue.includes('10')) {
return '$0.70/Run'
}
return '$0.35/Run' // 5s default
} else if (modeValue.includes('v2-1')) {
if (modeValue.includes('v2-1')) {
if (modeValue.includes('10s')) {
return '$0.98/Run' // pro, 10s
}
@@ -1242,40 +1197,6 @@ const apiNodeCosts: Record<string, { displayPrice: string | PricingFunction }> =
return '$0.80-3.20/Run'
}
},
Veo3FirstLastFrameNode: {
displayPrice: (node: LGraphNode): string => {
const modelWidget = node.widgets?.find(
(w) => w.name === 'model'
) as IComboWidget
const generateAudioWidget = node.widgets?.find(
(w) => w.name === 'generate_audio'
) as IComboWidget
const durationWidget = node.widgets?.find(
(w) => w.name === 'duration'
) as IComboWidget
if (!modelWidget || !generateAudioWidget || !durationWidget) {
return '$0.40-3.20/Run (varies with model & audio generation)'
}
const model = String(modelWidget.value)
const generateAudio =
String(generateAudioWidget.value).toLowerCase() === 'true'
const seconds = parseFloat(String(durationWidget.value))
let pricePerSecond: number | null = null
if (model.includes('veo-3.1-fast-generate')) {
pricePerSecond = generateAudio ? 0.15 : 0.1
} else if (model.includes('veo-3.1-generate')) {
pricePerSecond = generateAudio ? 0.4 : 0.2
}
if (pricePerSecond === null) {
return '$0.40-3.20/Run'
}
const cost = pricePerSecond * seconds
return `$${cost.toFixed(2)}/Run`
}
},
LumaImageNode: {
displayPrice: (node: LGraphNode): string => {
const modelWidget = node.widgets?.find(
@@ -1888,10 +1809,8 @@ export const useNodePricing = () => {
IdeogramV3: ['rendering_speed', 'num_images', 'character_image'],
FluxProKontextProNode: [],
FluxProKontextMaxNode: [],
Flux2ProImageNode: ['width', 'height', 'images'],
VeoVideoGenerationNode: ['duration_seconds'],
Veo3VideoGenerationNode: ['model', 'generate_audio'],
Veo3FirstLastFrameNode: ['model', 'generate_audio', 'duration'],
LumaVideoNode: ['model', 'resolution', 'duration'],
LumaImageToVideoNode: ['model', 'resolution', 'duration'],
LumaImageNode: ['model', 'aspect_ratio'],

View File

@@ -96,7 +96,6 @@ export function useJobList() {
const executionStore = useExecutionStore()
const workflowStore = useWorkflowStore()
const seenPendingIds = ref<Set<string>>(new Set())
const recentlyAddedPendingIds = ref<Set<string>>(new Set())
const addedHintTimeouts = new Map<string, ReturnType<typeof setTimeout>>()
@@ -127,27 +126,23 @@ export function useJobList() {
.filter((id): id is string => !!id),
(pendingIds) => {
const pendingSet = new Set(pendingIds)
const nextAdded = new Set(recentlyAddedPendingIds.value)
const nextSeen = new Set(seenPendingIds.value)
const next = new Set(recentlyAddedPendingIds.value)
pendingIds.forEach((id) => {
if (!nextSeen.has(id)) {
nextSeen.add(id)
nextAdded.add(id)
if (!next.has(id)) {
next.add(id)
scheduleAddedHintExpiry(id)
}
})
for (const id of Array.from(nextSeen)) {
for (const id of Array.from(next)) {
if (!pendingSet.has(id)) {
nextSeen.delete(id)
nextAdded.delete(id)
next.delete(id)
clearAddedHintTimeout(id)
}
}
recentlyAddedPendingIds.value = nextAdded
seenPendingIds.value = nextSeen
recentlyAddedPendingIds.value = next
},
{ immediate: true }
)
@@ -162,7 +157,6 @@ export function useJobList() {
onUnmounted(() => {
addedHintTimeouts.forEach((timeoutId) => clearTimeout(timeoutId))
addedHintTimeouts.clear()
seenPendingIds.value = new Set<string>()
recentlyAddedPendingIds.value = new Set<string>()
})

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