Merge branch 'main' into webcam-capture
69
.github/workflows/cloud-backport-tag.yaml
vendored
Normal file
@@ -0,0 +1,69 @@
|
||||
---
|
||||
name: Cloud Backport Tag
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: ['closed']
|
||||
branches: [cloud/*]
|
||||
|
||||
jobs:
|
||||
create-tag:
|
||||
if: >
|
||||
github.event.pull_request.merged == true &&
|
||||
contains(github.event.pull_request.labels.*.name, 'backport')
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: read
|
||||
|
||||
steps:
|
||||
- name: Checkout merge commit
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.merge_commit_sha }}
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version-file: '.nvmrc'
|
||||
|
||||
- name: Create tag for cloud backport
|
||||
id: tag
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
BRANCH="${{ github.event.pull_request.base.ref }}"
|
||||
if [[ ! "$BRANCH" =~ ^cloud/([0-9]+)\.([0-9]+)$ ]]; then
|
||||
echo "::error::Base branch '$BRANCH' is not a cloud/x.y branch"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
MAJOR="${BASH_REMATCH[1]}"
|
||||
MINOR="${BASH_REMATCH[2]}"
|
||||
|
||||
VERSION=$(node -p "require('./package.json').version")
|
||||
if [[ "$VERSION" =~ ^${MAJOR}\.${MINOR}\.([0-9]+)(-.+)?$ ]]; then
|
||||
PATCH="${BASH_REMATCH[1]}"
|
||||
SUFFIX="${BASH_REMATCH[2]:-}"
|
||||
else
|
||||
echo "::error::Version '${VERSION}' does not match cloud branch '${BRANCH}'"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
TAG="cloud/v${VERSION}"
|
||||
|
||||
if git ls-remote --tags origin "${TAG}" | grep -Fq "refs/tags/${TAG}"; then
|
||||
echo "::notice::Tag ${TAG} already exists; skipping"
|
||||
echo "tag=${TAG}" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
git tag "${TAG}" "${{ github.event.pull_request.merge_commit_sha }}"
|
||||
git push origin "${TAG}"
|
||||
echo "tag=${TAG}" >> "$GITHUB_OUTPUT"
|
||||
{
|
||||
echo "Created tag: ${TAG}"
|
||||
echo "Branch: ${BRANCH}"
|
||||
echo "Version: ${VERSION}"
|
||||
echo "Commit: ${{ github.event.pull_request.merge_commit_sha }}"
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
14
.github/workflows/pr-backport.yaml
vendored
@@ -236,8 +236,8 @@ jobs:
|
||||
PR_TITLE=$(echo "$PR_DATA" | jq -r '.title')
|
||||
MERGE_COMMIT=$(echo "$PR_DATA" | jq -r '.mergeCommit.oid')
|
||||
else
|
||||
PR_TITLE="${{ github.event.pull_request.title }}"
|
||||
MERGE_COMMIT="${{ github.event.pull_request.merge_commit_sha }}"
|
||||
PR_TITLE=$(jq -r '.pull_request.title' "$GITHUB_EVENT_PATH")
|
||||
MERGE_COMMIT=$(jq -r '.pull_request.merge_commit_sha' "$GITHUB_EVENT_PATH")
|
||||
fi
|
||||
|
||||
for target in ${{ steps.filter-targets.outputs.pending-targets }}; do
|
||||
@@ -326,8 +326,8 @@ jobs:
|
||||
PR_TITLE=$(echo "$PR_DATA" | jq -r '.title')
|
||||
PR_AUTHOR=$(echo "$PR_DATA" | jq -r '.author.login')
|
||||
else
|
||||
PR_TITLE="${{ github.event.pull_request.title }}"
|
||||
PR_AUTHOR="${{ github.event.pull_request.user.login }}"
|
||||
PR_TITLE=$(jq -r '.pull_request.title' "$GITHUB_EVENT_PATH")
|
||||
PR_AUTHOR=$(jq -r '.pull_request.user.login' "$GITHUB_EVENT_PATH")
|
||||
fi
|
||||
|
||||
for backport in ${{ steps.backport.outputs.success }}; do
|
||||
@@ -364,9 +364,9 @@ jobs:
|
||||
PR_AUTHOR=$(echo "$PR_DATA" | jq -r '.author.login')
|
||||
MERGE_COMMIT=$(echo "$PR_DATA" | jq -r '.mergeCommit.oid')
|
||||
else
|
||||
PR_NUMBER="${{ github.event.pull_request.number }}"
|
||||
PR_AUTHOR="${{ github.event.pull_request.user.login }}"
|
||||
MERGE_COMMIT="${{ github.event.pull_request.merge_commit_sha }}"
|
||||
PR_NUMBER=$(jq -r '.pull_request.number' "$GITHUB_EVENT_PATH")
|
||||
PR_AUTHOR=$(jq -r '.pull_request.user.login' "$GITHUB_EVENT_PATH")
|
||||
MERGE_COMMIT=$(jq -r '.pull_request.merge_commit_sha' "$GITHUB_EVENT_PATH")
|
||||
fi
|
||||
|
||||
for failure in ${{ steps.backport.outputs.failed }}; do
|
||||
|
||||
@@ -64,7 +64,6 @@ const config: StorybookConfig = {
|
||||
deep: true,
|
||||
extensions: ['vue']
|
||||
})
|
||||
// Note: Explicitly NOT including generateImportMapPlugin to avoid externalization
|
||||
],
|
||||
server: {
|
||||
allowedHosts: true
|
||||
|
||||
18
CODEOWNERS
@@ -1,8 +1,11 @@
|
||||
# Global Ownership
|
||||
* @Comfy-org/comfy_frontend_devs
|
||||
|
||||
# Desktop/Electron
|
||||
/apps/desktop-ui/ @webfiltered
|
||||
/src/stores/electronDownloadStore.ts @webfiltered
|
||||
/src/extensions/core/electronAdapter.ts @webfiltered
|
||||
/vite.electron.config.mts @webfiltered
|
||||
/apps/desktop-ui/ @benceruleanlu
|
||||
/src/stores/electronDownloadStore.ts @benceruleanlu
|
||||
/src/extensions/core/electronAdapter.ts @benceruleanlu
|
||||
/vite.electron.config.mts @benceruleanlu
|
||||
|
||||
# Common UI Components
|
||||
/src/components/chip/ @viva-jinyi
|
||||
@@ -31,10 +34,7 @@
|
||||
/src/components/graph/selectionToolbox/ @Myestery
|
||||
|
||||
# Minimap
|
||||
/src/renderer/extensions/minimap/ @jtydhr88
|
||||
|
||||
# Assets
|
||||
/src/platform/assets/ @arjansingh
|
||||
/src/renderer/extensions/minimap/ @jtydhr88 @Myestery
|
||||
|
||||
# Workflow Templates
|
||||
/src/platform/workflow/templates/ @Myestery @christian-byrne @comfyui-wiki
|
||||
@@ -53,7 +53,7 @@
|
||||
/src/workbench/extensions/manager/ @viva-jinyi @christian-byrne @ltdrdata
|
||||
|
||||
# Translations
|
||||
/src/locales/ @Yorha4D @KarryCharon @shinshin86 @Comfy-Org/comfy_maintainer
|
||||
/src/locales/ @Yorha4D @KarryCharon @shinshin86 @Comfy-Org/comfy_maintainer @Comfy-org/comfy_frontend_devs
|
||||
|
||||
# LLM Instructions (blank on purpose)
|
||||
.claude/
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@comfyorg/desktop-ui",
|
||||
"version": "0.0.3",
|
||||
"version": "0.0.4",
|
||||
"type": "module",
|
||||
"nx": {
|
||||
"tags": [
|
||||
|
||||
|
Before Width: | Height: | Size: 126 KiB After Width: | Height: | Size: 126 KiB |
|
Before Width: | Height: | Size: 54 KiB After Width: | Height: | Size: 54 KiB |
|
Before Width: | Height: | Size: 34 KiB After Width: | Height: | Size: 36 KiB |
|
Before Width: | Height: | Size: 59 KiB After Width: | Height: | Size: 59 KiB |
|
Before Width: | Height: | Size: 57 KiB After Width: | Height: | Size: 57 KiB |
|
Before Width: | Height: | Size: 58 KiB After Width: | Height: | Size: 58 KiB |
|
Before Width: | Height: | Size: 60 KiB After Width: | Height: | Size: 60 KiB |
|
Before Width: | Height: | Size: 60 KiB After Width: | Height: | Size: 60 KiB |
|
Before Width: | Height: | Size: 59 KiB After Width: | Height: | Size: 59 KiB |
|
Before Width: | Height: | Size: 56 KiB After Width: | Height: | Size: 56 KiB |
|
Before Width: | Height: | Size: 56 KiB After Width: | Height: | Size: 56 KiB |
|
Before Width: | Height: | Size: 110 KiB After Width: | Height: | Size: 110 KiB |
|
Before Width: | Height: | Size: 120 KiB After Width: | Height: | Size: 120 KiB |
|
Before Width: | Height: | Size: 120 KiB After Width: | Height: | Size: 120 KiB |
|
Before Width: | Height: | Size: 150 KiB After Width: | Height: | Size: 151 KiB |
|
Before Width: | Height: | Size: 143 KiB After Width: | Height: | Size: 143 KiB |
@@ -1,49 +0,0 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '../../../fixtures/ComfyPage'
|
||||
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
|
||||
})
|
||||
|
||||
test.describe('Vue Nodes - LOD', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
await comfyPage.setup()
|
||||
await comfyPage.loadWorkflow('default')
|
||||
await comfyPage.setSetting('LiteGraph.Canvas.MinFontSizeForLOD', 8)
|
||||
})
|
||||
|
||||
test('should toggle LOD based on zoom threshold', async ({ comfyPage }) => {
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
|
||||
const initialNodeCount = await comfyPage.vueNodes.getNodeCount()
|
||||
expect(initialNodeCount).toBeGreaterThan(0)
|
||||
|
||||
await expect(comfyPage.canvas).toHaveScreenshot('vue-nodes-default.png')
|
||||
|
||||
const vueNodesContainer = comfyPage.vueNodes.nodes
|
||||
const textboxesInNodes = vueNodesContainer.getByRole('textbox')
|
||||
const comboboxesInNodes = vueNodesContainer.getByRole('combobox')
|
||||
|
||||
await expect(textboxesInNodes.first()).toBeVisible()
|
||||
await expect(comboboxesInNodes.first()).toBeVisible()
|
||||
|
||||
await comfyPage.zoom(120, 10)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await expect(comfyPage.canvas).toHaveScreenshot('vue-nodes-lod-active.png')
|
||||
|
||||
await expect(textboxesInNodes.first()).toBeHidden()
|
||||
await expect(comboboxesInNodes.first()).toBeHidden()
|
||||
|
||||
await comfyPage.zoom(-120, 10)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await expect(comfyPage.canvas).toHaveScreenshot(
|
||||
'vue-nodes-lod-inactive.png'
|
||||
)
|
||||
await expect(textboxesInNodes.first()).toBeVisible()
|
||||
await expect(comboboxesInNodes.first()).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
Before Width: | Height: | Size: 102 KiB After Width: | Height: | Size: 102 KiB |
|
Before Width: | Height: | Size: 100 KiB After Width: | Height: | Size: 100 KiB |
|
Before Width: | Height: | Size: 120 KiB After Width: | Height: | Size: 120 KiB |
|
Before Width: | Height: | Size: 81 KiB After Width: | Height: | Size: 81 KiB |
@@ -1,154 +0,0 @@
|
||||
import glob from 'fast-glob'
|
||||
import fs from 'fs-extra'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { type HtmlTagDescriptor, type Plugin, normalizePath } from 'vite'
|
||||
|
||||
interface ImportMapSource {
|
||||
name: string
|
||||
pattern: string | RegExp
|
||||
entry: string
|
||||
recursiveDependence?: boolean
|
||||
override?: Record<string, Partial<ImportMapSource>>
|
||||
}
|
||||
|
||||
const parseDeps = (root: string, pkg: string) => {
|
||||
const pkgPath = join(root, 'node_modules', pkg, 'package.json')
|
||||
if (fs.existsSync(pkgPath)) {
|
||||
const content = fs.readFileSync(pkgPath, 'utf-8')
|
||||
const pkg = JSON.parse(content)
|
||||
return Object.keys(pkg.dependencies || {})
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
/**
|
||||
* Vite plugin that generates an import map for vendor chunks.
|
||||
*
|
||||
* This plugin creates a browser-compatible import map that maps module specifiers
|
||||
* (like 'vue' or 'primevue') to their actual file locations in the build output.
|
||||
* This improves module loading in modern browsers and enables better caching.
|
||||
*
|
||||
* The plugin:
|
||||
* 1. Tracks vendor chunks during bundle generation
|
||||
* 2. Creates mappings between module names and their file paths
|
||||
* 3. Injects an import map script tag into the HTML head
|
||||
* 4. Configures manual chunk splitting for vendor libraries
|
||||
*
|
||||
* @param vendorLibraries - An array of vendor libraries to split into separate chunks
|
||||
* @returns {Plugin} A Vite plugin that generates and injects an import map
|
||||
*/
|
||||
export function generateImportMapPlugin(
|
||||
importMapSources: ImportMapSource[]
|
||||
): Plugin {
|
||||
const importMapEntries: Record<string, string> = {}
|
||||
const resolvedImportMapSources: Map<string, ImportMapSource> = new Map()
|
||||
const assetDir = 'assets/lib'
|
||||
let root: string
|
||||
|
||||
return {
|
||||
name: 'generate-import-map-plugin',
|
||||
|
||||
// Configure manual chunks during the build process
|
||||
configResolved(config) {
|
||||
root = config.root
|
||||
|
||||
if (config.build) {
|
||||
// Ensure rollupOptions exists
|
||||
if (!config.build.rollupOptions) {
|
||||
config.build.rollupOptions = {}
|
||||
}
|
||||
|
||||
for (const source of importMapSources) {
|
||||
resolvedImportMapSources.set(source.name, source)
|
||||
if (source.recursiveDependence) {
|
||||
const deps = parseDeps(root, source.name)
|
||||
|
||||
while (deps.length) {
|
||||
const dep = deps.shift()!
|
||||
const depSource = Object.assign({}, source, {
|
||||
name: dep,
|
||||
pattern: dep,
|
||||
...source.override?.[dep]
|
||||
})
|
||||
resolvedImportMapSources.set(depSource.name, depSource)
|
||||
|
||||
const _deps = parseDeps(root, depSource.name)
|
||||
deps.unshift(..._deps)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const external: (string | RegExp)[] = []
|
||||
for (const [, source] of resolvedImportMapSources) {
|
||||
external.push(source.pattern)
|
||||
}
|
||||
config.build.rollupOptions.external = external
|
||||
}
|
||||
},
|
||||
|
||||
generateBundle(_options) {
|
||||
for (const [, source] of resolvedImportMapSources) {
|
||||
if (source.entry) {
|
||||
const moduleFile = join(source.name, source.entry)
|
||||
const sourceFile = join(root, 'node_modules', moduleFile)
|
||||
const targetFile = join(root, 'dist', assetDir, moduleFile)
|
||||
|
||||
importMapEntries[source.name] =
|
||||
'./' + normalizePath(join(assetDir, moduleFile))
|
||||
|
||||
const targetDir = dirname(targetFile)
|
||||
if (!fs.existsSync(targetDir)) {
|
||||
fs.mkdirSync(targetDir, { recursive: true })
|
||||
}
|
||||
fs.copyFileSync(sourceFile, targetFile)
|
||||
}
|
||||
|
||||
if (source.recursiveDependence) {
|
||||
const files = glob.sync(['**/*.{js,mjs}'], {
|
||||
cwd: join(root, 'node_modules', source.name)
|
||||
})
|
||||
|
||||
for (const file of files) {
|
||||
const moduleFile = join(source.name, file)
|
||||
const sourceFile = join(root, 'node_modules', moduleFile)
|
||||
const targetFile = join(root, 'dist', assetDir, moduleFile)
|
||||
|
||||
importMapEntries[normalizePath(join(source.name, dirname(file)))] =
|
||||
'./' + normalizePath(join(assetDir, moduleFile))
|
||||
|
||||
const targetDir = dirname(targetFile)
|
||||
if (!fs.existsSync(targetDir)) {
|
||||
fs.mkdirSync(targetDir, { recursive: true })
|
||||
}
|
||||
fs.copyFileSync(sourceFile, targetFile)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
transformIndexHtml(html) {
|
||||
if (Object.keys(importMapEntries).length === 0) {
|
||||
console.warn(
|
||||
'[ImportMap Plugin] No vendor chunks found to create import map.'
|
||||
)
|
||||
return html
|
||||
}
|
||||
|
||||
const importMap = {
|
||||
imports: importMapEntries
|
||||
}
|
||||
|
||||
const importMapTag: HtmlTagDescriptor = {
|
||||
tag: 'script',
|
||||
attrs: { type: 'importmap' },
|
||||
children: JSON.stringify(importMap, null, 2),
|
||||
injectTo: 'head'
|
||||
}
|
||||
|
||||
return {
|
||||
html,
|
||||
tags: [importMapTag]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,2 +1 @@
|
||||
export { comfyAPIPlugin } from './comfyAPIPlugin'
|
||||
export { generateImportMapPlugin } from './generateImportMapPlugin'
|
||||
|
||||
@@ -191,6 +191,19 @@ export default defineConfig([
|
||||
'@intlify/vue-i18n/no-raw-text': [
|
||||
'error',
|
||||
{
|
||||
attributes: {
|
||||
'/.+/': [
|
||||
'aria-label',
|
||||
'aria-placeholder',
|
||||
'aria-roledescription',
|
||||
'aria-valuetext',
|
||||
'label',
|
||||
'placeholder',
|
||||
'title',
|
||||
'v-tooltip'
|
||||
],
|
||||
img: ['alt']
|
||||
},
|
||||
// Ignore strings that are:
|
||||
// 1. Less than 2 characters
|
||||
// 2. Only symbols/numbers/whitespace (no letters)
|
||||
@@ -200,24 +213,27 @@ export default defineConfig([
|
||||
ignoreNodes: ['md-icon', 'v-icon', 'pre', 'code', 'script', 'style'],
|
||||
// Brand names and technical terms that shouldn't be translated
|
||||
ignoreText: [
|
||||
'ComfyUI',
|
||||
'GitHub',
|
||||
'OpenAI',
|
||||
'API',
|
||||
'URL',
|
||||
'JSON',
|
||||
'YAML',
|
||||
'GPU',
|
||||
'CPU',
|
||||
'RAM',
|
||||
'GB',
|
||||
'MB',
|
||||
'KB',
|
||||
'ms',
|
||||
'fps',
|
||||
'px',
|
||||
'App Data:',
|
||||
'App Path:'
|
||||
'App Path:',
|
||||
'ComfyUI',
|
||||
'CPU',
|
||||
'fps',
|
||||
'GB',
|
||||
'GitHub',
|
||||
'GPU',
|
||||
'JSON',
|
||||
'KB',
|
||||
'LoRA',
|
||||
'MB',
|
||||
'ms',
|
||||
'OpenAI',
|
||||
'png',
|
||||
'px',
|
||||
'RAM',
|
||||
'URL',
|
||||
'YAML',
|
||||
'1.2 MB'
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@comfyorg/comfyui-frontend",
|
||||
"private": true,
|
||||
"version": "1.33.8",
|
||||
"version": "1.34.0",
|
||||
"type": "module",
|
||||
"repository": "https://github.com/Comfy-Org/ComfyUI_frontend",
|
||||
"homepage": "https://comfy.org",
|
||||
@@ -164,7 +164,6 @@
|
||||
"es-toolkit": "^1.39.9",
|
||||
"extendable-media-recorder": "^9.2.27",
|
||||
"extendable-media-recorder-wav-encoder": "^7.0.129",
|
||||
"fast-glob": "^3.3.3",
|
||||
"firebase": "catalog:",
|
||||
"fuse.js": "^7.0.0",
|
||||
"glob": "^11.0.3",
|
||||
|
||||
@@ -1329,57 +1329,6 @@ audio.comfy-audio.empty-audio-widget {
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
/* START LOD specific styles */
|
||||
/* LOD styles - Custom CSS avoids 100+ Tailwind selectors that would slow style recalculation when .isLOD toggles */
|
||||
|
||||
.isLOD .lg-node {
|
||||
box-shadow: none;
|
||||
filter: none;
|
||||
backdrop-filter: none;
|
||||
text-shadow: none;
|
||||
mask-image: none;
|
||||
clip-path: none;
|
||||
background-image: none;
|
||||
text-rendering: optimizeSpeed;
|
||||
border-radius: 0;
|
||||
contain: layout style;
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.isLOD .lg-node-header {
|
||||
border-radius: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.isLOD .lg-node-widgets {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.lod-toggle {
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
.isLOD .lod-toggle {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.lod-fallback {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.isLOD .lod-fallback {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.isLOD .image-preview img {
|
||||
image-rendering: pixelated;
|
||||
}
|
||||
|
||||
.isLOD .slot-dot {
|
||||
border-radius: 0;
|
||||
}
|
||||
/* END LOD specific styles */
|
||||
|
||||
/* ===================== Mask Editor Styles ===================== */
|
||||
/* To be migrated to Tailwind later */
|
||||
#maskEditor_brush {
|
||||
|
||||
@@ -75,6 +75,17 @@ export function formatSize(value?: number) {
|
||||
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(2))} ${sizes[i]}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats a commit hash by truncating long (40-char) hashes to 7 chars.
|
||||
* Returns the original string if not a valid full commit hash.
|
||||
*/
|
||||
export function formatCommitHash(value: string): string {
|
||||
if (/^[a-f0-9]{40}$/i.test(value)) {
|
||||
return value.slice(0, 7)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns various filename components.
|
||||
* Example:
|
||||
|
||||
17
pnpm-lock.yaml
generated
@@ -15,15 +15,9 @@ catalogs:
|
||||
'@eslint/js':
|
||||
specifier: ^9.35.0
|
||||
version: 9.35.0
|
||||
'@iconify-json/lucide':
|
||||
specifier: ^1.1.178
|
||||
version: 1.2.66
|
||||
'@iconify/json':
|
||||
specifier: ^2.2.380
|
||||
version: 2.2.380
|
||||
'@iconify/tailwind':
|
||||
specifier: ^1.1.3
|
||||
version: 1.2.0
|
||||
'@intlify/eslint-plugin-vue-i18n':
|
||||
specifier: ^4.1.0
|
||||
version: 4.1.0
|
||||
@@ -431,9 +425,6 @@ importers:
|
||||
extendable-media-recorder-wav-encoder:
|
||||
specifier: ^7.0.129
|
||||
version: 7.0.129
|
||||
fast-glob:
|
||||
specifier: ^3.3.3
|
||||
version: 3.3.3
|
||||
firebase:
|
||||
specifier: 'catalog:'
|
||||
version: 11.6.0
|
||||
@@ -7877,8 +7868,8 @@ packages:
|
||||
vue-component-type-helpers@3.1.1:
|
||||
resolution: {integrity: sha512-B0kHv7qX6E7+kdc5nsaqjdGZ1KwNKSUQDWGy7XkTYT7wFsOpkEyaJ1Vq79TjwrrtuLRgizrTV7PPuC4rRQo+vw==}
|
||||
|
||||
vue-component-type-helpers@3.1.4:
|
||||
resolution: {integrity: sha512-Uws7Ew1OzTTqHW8ZVl/qLl/HB+jf08M0NdFONbVWAx0N4gMLK8yfZDgeB77hDnBmaigWWEn5qP8T9BG59jIeyQ==}
|
||||
vue-component-type-helpers@3.1.5:
|
||||
resolution: {integrity: sha512-7V3yJuNWW7/1jxCcI1CswnpDsvs02Qcx/N43LkV+ZqhLj2PKj50slUflHAroNkN4UWiYfzMUUUXiNuv9khmSpQ==}
|
||||
|
||||
vue-demi@0.14.10:
|
||||
resolution: {integrity: sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==}
|
||||
@@ -10681,7 +10672,7 @@ snapshots:
|
||||
storybook: 9.1.6(@testing-library/dom@10.4.1)(prettier@3.6.2)(vite@5.4.19(@types/node@20.14.10)(lightningcss@1.30.1)(terser@5.39.2))
|
||||
type-fest: 2.19.0
|
||||
vue: 3.5.13(typescript@5.9.2)
|
||||
vue-component-type-helpers: 3.1.4
|
||||
vue-component-type-helpers: 3.1.5
|
||||
|
||||
'@swc/helpers@0.5.17':
|
||||
dependencies:
|
||||
@@ -16448,7 +16439,7 @@ snapshots:
|
||||
|
||||
vue-component-type-helpers@3.1.1: {}
|
||||
|
||||
vue-component-type-helpers@3.1.4: {}
|
||||
vue-component-type-helpers@3.1.5: {}
|
||||
|
||||
vue-demi@0.14.10(vue@3.5.13(typescript@5.9.2)):
|
||||
dependencies:
|
||||
|
||||
@@ -1 +1,9 @@
|
||||
Thanks to OpenArt (https://openart.ai) for providing the sorted-custom-node-map data, captured in September 2024.
|
||||
Node usage data merged from two sources:
|
||||
- Mixpanel "app:node_search_result_selected" events (Nov 2025): 1,112 nodes, 46,514 selections
|
||||
Reflects actual user search behavior - what users choose when searching.
|
||||
- OpenArt workflow data (Sept 2024): 2,600 nodes, 118,676 uses
|
||||
Reflects overall popularity - what's used in workflows.
|
||||
|
||||
Merge strategy: New data overwrites old for 514 overlapping nodes. Old data
|
||||
normalized by 2.55x scale factor to match new data. Total: 3,198 nodes.
|
||||
Search-selected nodes prioritized in ranking.
|
||||
9
public/assets/images/civitai.svg
Normal file
|
After Width: | Height: | Size: 1.5 MiB |
@@ -1,5 +1,10 @@
|
||||
<template>
|
||||
<div v-if="!workspaceStore.focusMode" class="ml-1 flex gap-x-0.5 pt-1">
|
||||
<div
|
||||
v-if="!workspaceStore.focusMode"
|
||||
class="ml-1 flex gap-x-0.5 pt-1"
|
||||
@mouseenter="isTopMenuHovered = true"
|
||||
@mouseleave="isTopMenuHovered = false"
|
||||
>
|
||||
<div class="min-w-0 flex-1">
|
||||
<SubgraphBreadcrumb />
|
||||
</div>
|
||||
@@ -40,7 +45,10 @@
|
||||
<CurrentUserButton v-if="isLoggedIn" class="shrink-0" />
|
||||
<LoginButton v-else-if="isDesktop" />
|
||||
</div>
|
||||
<QueueProgressOverlay v-model:expanded="isQueueOverlayExpanded" />
|
||||
<QueueProgressOverlay
|
||||
v-model:expanded="isQueueOverlayExpanded"
|
||||
:menu-hovered="isTopMenuHovered"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -69,6 +77,7 @@ const isDesktop = isElectron()
|
||||
const { t } = useI18n()
|
||||
const isQueueOverlayExpanded = ref(false)
|
||||
const queueStore = useQueueStore()
|
||||
const isTopMenuHovered = ref(false)
|
||||
const queuedCount = computed(() => queueStore.pendingTasks.length)
|
||||
const queueHistoryTooltipConfig = computed(() =>
|
||||
buildTooltipConfig(t('sideToolbar.queueProgressOverlay.viewJobHistory'))
|
||||
|
||||
@@ -257,7 +257,7 @@ watch(isDragging, (dragging) => {
|
||||
})
|
||||
const actionbarClass = computed(() =>
|
||||
cn(
|
||||
'w-[265px] border-dashed border-blue-500 opacity-80',
|
||||
'w-[200px] border-dashed border-blue-500 opacity-80',
|
||||
'm-1.5 flex items-center justify-center self-stretch',
|
||||
'rounded-md before:w-50 before:-ml-50 before:h-full',
|
||||
'pointer-events-auto',
|
||||
|
||||
@@ -9,29 +9,31 @@
|
||||
<div class="font-medium">
|
||||
{{ col.header }}
|
||||
</div>
|
||||
<div>{{ formatValue(systemInfo[col.field], col.field) }}</div>
|
||||
<div>{{ getDisplayValue(col) }}</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Divider />
|
||||
<template v-if="hasDevices">
|
||||
<Divider />
|
||||
|
||||
<div>
|
||||
<h2 class="mb-4 text-2xl font-semibold">
|
||||
{{ $t('g.devices') }}
|
||||
</h2>
|
||||
<TabView v-if="props.stats.devices.length > 1">
|
||||
<TabPanel
|
||||
v-for="device in props.stats.devices"
|
||||
:key="device.index"
|
||||
:header="device.name"
|
||||
:value="device.index"
|
||||
>
|
||||
<DeviceInfo :device="device" />
|
||||
</TabPanel>
|
||||
</TabView>
|
||||
<DeviceInfo v-else :device="props.stats.devices[0]" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 class="mb-4 text-2xl font-semibold">
|
||||
{{ $t('g.devices') }}
|
||||
</h2>
|
||||
<TabView v-if="props.stats.devices.length > 1">
|
||||
<TabPanel
|
||||
v-for="device in props.stats.devices"
|
||||
:key="device.index"
|
||||
:header="device.name"
|
||||
:value="device.index"
|
||||
>
|
||||
<DeviceInfo :device="device" />
|
||||
</TabPanel>
|
||||
</TabView>
|
||||
<DeviceInfo v-else :device="props.stats.devices[0]" />
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -42,8 +44,9 @@ import TabView from 'primevue/tabview'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import DeviceInfo from '@/components/common/DeviceInfo.vue'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import type { SystemStats } from '@/schemas/apiSchema'
|
||||
import { formatSize } from '@/utils/formatUtil'
|
||||
import { formatCommitHash, formatSize } from '@/utils/formatUtil'
|
||||
|
||||
const props = defineProps<{
|
||||
stats: SystemStats
|
||||
@@ -54,20 +57,53 @@ const systemInfo = computed(() => ({
|
||||
argv: props.stats.system.argv.join(' ')
|
||||
}))
|
||||
|
||||
const systemColumns: { field: keyof SystemStats['system']; header: string }[] =
|
||||
[
|
||||
{ field: 'os', header: 'OS' },
|
||||
{ field: 'python_version', header: 'Python Version' },
|
||||
{ field: 'embedded_python', header: 'Embedded Python' },
|
||||
{ field: 'pytorch_version', header: 'Pytorch Version' },
|
||||
{ field: 'argv', header: 'Arguments' },
|
||||
{ field: 'ram_total', header: 'RAM Total' },
|
||||
{ field: 'ram_free', header: 'RAM Free' }
|
||||
]
|
||||
const hasDevices = computed(() => props.stats.devices.length > 0)
|
||||
|
||||
const formatValue = (value: any, field: string) => {
|
||||
if (['ram_total', 'ram_free'].includes(field)) {
|
||||
return formatSize(value)
|
||||
type SystemInfoKey = keyof SystemStats['system']
|
||||
|
||||
type ColumnDef = {
|
||||
field: SystemInfoKey
|
||||
header: string
|
||||
format?: (value: string) => string
|
||||
formatNumber?: (value: number) => string
|
||||
}
|
||||
|
||||
/** Columns for local distribution */
|
||||
const localColumns: ColumnDef[] = [
|
||||
{ field: 'os', header: 'OS' },
|
||||
{ field: 'python_version', header: 'Python Version' },
|
||||
{ field: 'embedded_python', header: 'Embedded Python' },
|
||||
{ field: 'pytorch_version', header: 'Pytorch Version' },
|
||||
{ field: 'argv', header: 'Arguments' },
|
||||
{ field: 'ram_total', header: 'RAM Total', formatNumber: formatSize },
|
||||
{ field: 'ram_free', header: 'RAM Free', formatNumber: formatSize }
|
||||
]
|
||||
|
||||
/** Columns for cloud distribution */
|
||||
const cloudColumns: ColumnDef[] = [
|
||||
{ field: 'cloud_version', header: 'Cloud Version' },
|
||||
{
|
||||
field: 'comfyui_version',
|
||||
header: 'ComfyUI Version',
|
||||
format: formatCommitHash
|
||||
},
|
||||
{
|
||||
field: 'comfyui_frontend_version',
|
||||
header: 'Frontend Version',
|
||||
format: formatCommitHash
|
||||
},
|
||||
{ field: 'workflow_templates_version', header: 'Templates Version' }
|
||||
]
|
||||
|
||||
const systemColumns = computed(() => (isCloud ? cloudColumns : localColumns))
|
||||
|
||||
const getDisplayValue = (column: ColumnDef) => {
|
||||
const value = systemInfo.value[column.field]
|
||||
if (column.formatNumber && typeof value === 'number') {
|
||||
return column.formatNumber(value)
|
||||
}
|
||||
if (column.format && typeof value === 'string') {
|
||||
return column.format(value)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
@@ -152,7 +152,7 @@ const {
|
||||
popoverMaxWidth?: string
|
||||
}>()
|
||||
|
||||
const selectedItem = defineModel<string | null>({ required: true })
|
||||
const selectedItem = defineModel<string | undefined>({ required: true })
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
|
||||
@@ -80,7 +80,7 @@
|
||||
/>
|
||||
|
||||
<SliderControl
|
||||
label="Stepsize"
|
||||
:label="$t('maskEditor.stepSize')"
|
||||
:min="1"
|
||||
:max="100"
|
||||
:step="1"
|
||||
|
||||
@@ -25,7 +25,11 @@
|
||||
class="inline-block h-6 w-6 overflow-hidden rounded-[6px] border-0 bg-secondary-background"
|
||||
:style="{ marginLeft: idx === 0 ? '0' : '-12px' }"
|
||||
>
|
||||
<img :src="url" alt="preview" class="h-full w-full object-cover" />
|
||||
<img
|
||||
:src="url"
|
||||
:alt="$t('sideToolbar.queueProgressOverlay.preview')"
|
||||
class="h-full w-full object-cover"
|
||||
/>
|
||||
</span>
|
||||
</span>
|
||||
|
||||
|
||||
@@ -47,7 +47,7 @@
|
||||
v-tooltip.top="cancelJobTooltip"
|
||||
type="secondary"
|
||||
size="sm"
|
||||
class="size-6 bg-secondary-background hover:bg-destructive-background"
|
||||
class="size-6 bg-destructive-background hover:bg-destructive-background-hover"
|
||||
:aria-label="t('sideToolbar.queueProgressOverlay.interruptAll')"
|
||||
@click="$emit('interruptAll')"
|
||||
>
|
||||
|
||||
@@ -60,7 +60,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, ref } from 'vue'
|
||||
import { computed, nextTick, ref, withDefaults } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import QueueOverlayActive from '@/components/queue/QueueOverlayActive.vue'
|
||||
@@ -85,9 +85,15 @@ import { useSidebarTabStore } from '@/stores/workspace/sidebarTabStore'
|
||||
|
||||
type OverlayState = 'hidden' | 'empty' | 'active' | 'expanded'
|
||||
|
||||
const props = defineProps<{
|
||||
expanded?: boolean
|
||||
}>()
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
expanded?: boolean
|
||||
menuHovered?: boolean
|
||||
}>(),
|
||||
{
|
||||
menuHovered: false
|
||||
}
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:expanded', value: boolean): void
|
||||
@@ -110,6 +116,7 @@ const {
|
||||
currentNodeProgressStyle
|
||||
} = useQueueProgress()
|
||||
const isHovered = ref(false)
|
||||
const isOverlayHovered = computed(() => isHovered.value || props.menuHovered)
|
||||
const internalExpanded = ref(false)
|
||||
const isExpanded = computed({
|
||||
get: () =>
|
||||
@@ -142,7 +149,7 @@ const showBackground = computed(
|
||||
() =>
|
||||
overlayState.value === 'expanded' ||
|
||||
overlayState.value === 'empty' ||
|
||||
(overlayState.value === 'active' && isHovered.value)
|
||||
(overlayState.value === 'active' && isOverlayHovered.value)
|
||||
)
|
||||
|
||||
const isVisible = computed(() => overlayState.value !== 'hidden')
|
||||
@@ -156,7 +163,7 @@ const containerClass = computed(() =>
|
||||
const bottomRowClass = computed(
|
||||
() =>
|
||||
`flex items-center justify-end gap-4 transition-opacity duration-200 ease-in-out ${
|
||||
overlayState.value === 'active' && isHovered.value
|
||||
overlayState.value === 'active' && isOverlayHovered.value
|
||||
? 'opacity-100 pointer-events-auto'
|
||||
: 'opacity-0 pointer-events-none'
|
||||
}`
|
||||
|
||||
@@ -82,7 +82,10 @@
|
||||
:src="iconImageUrl"
|
||||
class="h-full w-full object-cover"
|
||||
/>
|
||||
<i v-else :class="[iconClass, 'size-4']" />
|
||||
<i
|
||||
v-else
|
||||
:class="cn(iconClass, 'size-4', shouldSpin && 'animate-spin')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -93,6 +96,23 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!--
|
||||
TODO: Refactor action buttons to use a declarative config system.
|
||||
|
||||
Instead of hardcoding button visibility logic in the template, define an array of
|
||||
action button configs with properties like:
|
||||
- icon, label, action, tooltip
|
||||
- visibleStates: JobState[] (which job states show this button)
|
||||
- alwaysVisible: boolean (show without hover)
|
||||
- destructive: boolean (use destructive styling)
|
||||
|
||||
Then render buttons in two groups:
|
||||
1. Always-visible buttons (outside Transition)
|
||||
2. Hover-only buttons (inside Transition)
|
||||
|
||||
This would eliminate the current duplication where the cancel button exists
|
||||
both outside (for running) and inside (for pending) the Transition.
|
||||
-->
|
||||
<div class="relative z-[1] flex items-center gap-2 text-text-secondary">
|
||||
<Transition
|
||||
mode="out-in"
|
||||
@@ -113,18 +133,22 @@
|
||||
v-tooltip.top="deleteTooltipConfig"
|
||||
type="transparent"
|
||||
size="sm"
|
||||
class="h-6 transform gap-1 rounded bg-modal-card-button-surface px-1 py-0 text-text-primary transition duration-150 ease-in-out hover:-translate-y-px hover:bg-destructive-background hover:opacity-95"
|
||||
class="size-6 transform gap-1 rounded bg-destructive-background text-text-primary transition duration-150 ease-in-out hover:-translate-y-px hover:bg-destructive-background-hover hover:opacity-95"
|
||||
:aria-label="t('g.delete')"
|
||||
@click.stop="emit('delete')"
|
||||
>
|
||||
<i class="icon-[lucide--trash-2] size-4" />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
v-else-if="props.state !== 'completed' && computedShowClear"
|
||||
v-else-if="
|
||||
props.state !== 'completed' &&
|
||||
props.state !== 'running' &&
|
||||
computedShowClear
|
||||
"
|
||||
v-tooltip.top="cancelTooltipConfig"
|
||||
type="transparent"
|
||||
size="sm"
|
||||
class="h-6 transform gap-1 rounded bg-modal-card-button-surface px-1 py-0 text-text-primary transition duration-150 ease-in-out hover:-translate-y-px hover:bg-destructive-background hover:opacity-95"
|
||||
class="size-6 transform gap-1 rounded bg-destructive-background text-text-primary transition duration-150 ease-in-out hover:-translate-y-px hover:bg-destructive-background-hover hover:opacity-95"
|
||||
:aria-label="t('g.cancel')"
|
||||
@click.stop="emit('cancel')"
|
||||
>
|
||||
@@ -143,17 +167,33 @@
|
||||
v-tooltip.top="moreTooltipConfig"
|
||||
type="transparent"
|
||||
size="sm"
|
||||
class="h-6 transform gap-1 rounded bg-modal-card-button-surface px-1 py-0 text-text-primary transition duration-150 ease-in-out hover:-translate-y-px hover:opacity-95"
|
||||
class="size-6 transform gap-1 rounded bg-modal-card-button-surface text-text-primary transition duration-150 ease-in-out hover:-translate-y-px hover:opacity-95"
|
||||
:aria-label="t('g.more')"
|
||||
@click.stop="emit('menu', $event)"
|
||||
>
|
||||
<i class="icon-[lucide--more-horizontal] size-4" />
|
||||
</IconButton>
|
||||
</div>
|
||||
<div v-else key="secondary" class="pr-2">
|
||||
<div
|
||||
v-else-if="props.state !== 'running'"
|
||||
key="secondary"
|
||||
class="pr-2"
|
||||
>
|
||||
<slot name="secondary">{{ props.rightText }}</slot>
|
||||
</div>
|
||||
</Transition>
|
||||
<!-- Running job cancel button - always visible -->
|
||||
<IconButton
|
||||
v-if="props.state === 'running' && computedShowClear"
|
||||
v-tooltip.top="cancelTooltipConfig"
|
||||
type="transparent"
|
||||
size="sm"
|
||||
class="size-6 transform gap-1 rounded bg-destructive-background text-text-primary transition duration-150 ease-in-out hover:-translate-y-px hover:bg-destructive-background-hover hover:opacity-95"
|
||||
:aria-label="t('g.cancel')"
|
||||
@click.stop="emit('cancel')"
|
||||
>
|
||||
<i class="icon-[lucide--x] size-4" />
|
||||
</IconButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -170,6 +210,7 @@ import QueueAssetPreview from '@/components/queue/job/QueueAssetPreview.vue'
|
||||
import { buildTooltipConfig } from '@/composables/useTooltipConfig'
|
||||
import type { JobState } from '@/types/queue'
|
||||
import { iconForJobState } from '@/utils/queueDisplay'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
@@ -302,6 +343,13 @@ const iconClass = computed(() => {
|
||||
return iconForJobState(props.state)
|
||||
})
|
||||
|
||||
const shouldSpin = computed(
|
||||
() =>
|
||||
props.state === 'pending' &&
|
||||
iconClass.value === iconForJobState('pending') &&
|
||||
!props.iconImageUrl
|
||||
)
|
||||
|
||||
const computedShowClear = computed(() => {
|
||||
if (props.showClear !== undefined) return props.showClear
|
||||
return props.state !== 'completed'
|
||||
|
||||
@@ -3,9 +3,12 @@
|
||||
v-if="showVueNodesBanner"
|
||||
class="pointer-events-auto relative w-full h-10 bg-gradient-to-r from-blue-600 to-blue-700 flex items-center justify-center px-4"
|
||||
>
|
||||
<div class="flex items-center">
|
||||
<div class="flex items-center text-sm">
|
||||
<i class="icon-[lucide--rocket]"></i>
|
||||
<span class="pl-2 text-sm">{{ $t('vueNodesBanner.message') }}</span>
|
||||
<span class="pl-2">{{ $t('vueNodesBanner.title') }}</span>
|
||||
<span class="pl-1.5 hidden md:inline">{{
|
||||
$t('vueNodesBanner.desc')
|
||||
}}</span>
|
||||
<Button
|
||||
class="cursor-pointer bg-transparent rounded h-7 px-3 border border-white text-white ml-4 text-xs"
|
||||
@click="handleTryItOut"
|
||||
@@ -63,7 +66,7 @@ const handleTryItOut = async (): Promise<void> => {
|
||||
try {
|
||||
await settingStore.set('Comfy.VueNodes.Enabled', true)
|
||||
} catch (error) {
|
||||
console.error('Failed to enable Vue nodes:', error)
|
||||
console.error('Failed to enable Nodes 2.0:', error)
|
||||
} finally {
|
||||
handleDismiss()
|
||||
}
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
<template #default="{ close }">
|
||||
<IconTextButton
|
||||
type="secondary"
|
||||
label="Settings"
|
||||
:label="$t('g.settings')"
|
||||
@click="
|
||||
() => {
|
||||
close()
|
||||
@@ -43,7 +43,7 @@
|
||||
</IconTextButton>
|
||||
<IconTextButton
|
||||
type="primary"
|
||||
label="Profile"
|
||||
:label="$t('g.profile')"
|
||||
@click="
|
||||
() => {
|
||||
close()
|
||||
@@ -65,7 +65,7 @@
|
||||
v-model="selectedFrameworks"
|
||||
v-model:search-query="searchText"
|
||||
class="w-[250px]"
|
||||
label="Select Frameworks"
|
||||
:label="$t('assetBrowser.selectFrameworks')"
|
||||
:options="frameworkOptions"
|
||||
:show-search-box="true"
|
||||
:show-selected-count="true"
|
||||
@@ -73,12 +73,12 @@
|
||||
/>
|
||||
<MultiSelect
|
||||
v-model="selectedProjects"
|
||||
label="Select Projects"
|
||||
:label="$t('assetBrowser.selectProjects')"
|
||||
:options="projectOptions"
|
||||
/>
|
||||
<SingleSelect
|
||||
v-model="selectedSort"
|
||||
label="Sorting Type"
|
||||
:label="$t('assetBrowser.sortingType')"
|
||||
:options="sortOptions"
|
||||
class="w-[135px]"
|
||||
>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useElementBounding, useRafFn } from '@vueuse/core'
|
||||
import { computed, onUnmounted, ref, watch } from 'vue'
|
||||
import { computed, onUnmounted, ref, watch, watchEffect } from 'vue'
|
||||
import type { Ref } from 'vue'
|
||||
|
||||
import { useSelectedLiteGraphItems } from '@/composables/canvas/useSelectedLiteGraphItems'
|
||||
@@ -157,6 +157,14 @@ 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,
|
||||
@@ -173,11 +181,6 @@ export function useSelectionToolboxPosition(
|
||||
}
|
||||
updateSelectionBounds()
|
||||
canvasStore.getCanvas().state.selectionChanged = false
|
||||
if (visible.value) {
|
||||
startSync()
|
||||
} else {
|
||||
stopSync()
|
||||
}
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
|
||||
@@ -3,7 +3,6 @@ import { shallowRef, watch } from 'vue'
|
||||
|
||||
import { useGraphNodeManager } from '@/composables/graph/useGraphNodeManager'
|
||||
import type { GraphNodeManager } from '@/composables/graph/useGraphNodeManager'
|
||||
import { useRenderModeSetting } from '@/composables/settings/useRenderModeSetting'
|
||||
import { useVueFeatureFlags } from '@/composables/useVueFeatureFlags'
|
||||
import { useVueNodesMigrationDismissed } from '@/composables/useVueNodesMigrationDismissed'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
@@ -26,11 +25,6 @@ function useVueNodeLifecycleIndividual() {
|
||||
|
||||
let hasShownMigrationToast = false
|
||||
|
||||
useRenderModeSetting(
|
||||
{ setting: 'LiteGraph.Canvas.MinFontSizeForLOD', vue: 0, litegraph: 8 },
|
||||
shouldRenderVueNodes
|
||||
)
|
||||
|
||||
const initializeNodeManager = () => {
|
||||
// Use canvas graph if available (handles subgraph contexts), fallback to app graph
|
||||
const activeGraph = comfyApp.canvas?.graph
|
||||
|
||||
@@ -303,6 +303,46 @@ const apiNodeCosts: Record<string, { displayPrice: string | PricingFunction }> =
|
||||
FluxProKontextMaxNode: {
|
||||
displayPrice: '$0.08/Run'
|
||||
},
|
||||
Flux2ProImageNode: {
|
||||
displayPrice: (node: LGraphNode): string => {
|
||||
const widthW = node.widgets?.find(
|
||||
(w) => w.name === 'width'
|
||||
) as IComboWidget
|
||||
const heightW = node.widgets?.find(
|
||||
(w) => w.name === 'height'
|
||||
) as IComboWidget
|
||||
|
||||
const w = Number(widthW?.value)
|
||||
const h = Number(heightW?.value)
|
||||
if (!Number.isFinite(w) || !Number.isFinite(h) || w <= 0 || h <= 0) {
|
||||
// global min/max for this node given schema bounds (1MP..4MP output)
|
||||
return '$0.03–$0.15/Run'
|
||||
}
|
||||
|
||||
// Is the 'images' input connected?
|
||||
const imagesInput = node.inputs?.find(
|
||||
(i) => i.name === 'images'
|
||||
) as INodeInputSlot
|
||||
const hasRefs =
|
||||
typeof imagesInput?.link !== 'undefined' && imagesInput.link != null
|
||||
|
||||
// Output cost: ceil((w*h)/MP); first MP $0.03, each additional $0.015
|
||||
const MP = 1024 * 1024
|
||||
const outMP = Math.max(1, Math.floor((w * h + MP - 1) / MP))
|
||||
const outputCost = 0.03 + 0.015 * Math.max(outMP - 1, 0)
|
||||
|
||||
if (hasRefs) {
|
||||
// Unknown ref count/size on the frontend:
|
||||
// min extra is $0.015, max extra is $0.120 (8 MP cap / 8 refs)
|
||||
const minTotal = outputCost + 0.015
|
||||
const maxTotal = outputCost + 0.12
|
||||
return `~$${parseFloat(minTotal.toFixed(3))}–$${parseFloat(maxTotal.toFixed(3))}/Run`
|
||||
}
|
||||
|
||||
// Precise text-to-image price
|
||||
return `$${parseFloat(outputCost.toFixed(3))}/Run`
|
||||
}
|
||||
},
|
||||
OpenAIVideoSora2: {
|
||||
displayPrice: sora2PricingCalculator
|
||||
},
|
||||
@@ -1197,6 +1237,40 @@ const apiNodeCosts: Record<string, { displayPrice: string | PricingFunction }> =
|
||||
return '$0.80-3.20/Run'
|
||||
}
|
||||
},
|
||||
Veo3FirstLastFrameNode: {
|
||||
displayPrice: (node: LGraphNode): string => {
|
||||
const modelWidget = node.widgets?.find(
|
||||
(w) => w.name === 'model'
|
||||
) as IComboWidget
|
||||
const generateAudioWidget = node.widgets?.find(
|
||||
(w) => w.name === 'generate_audio'
|
||||
) as IComboWidget
|
||||
const durationWidget = node.widgets?.find(
|
||||
(w) => w.name === 'duration'
|
||||
) as IComboWidget
|
||||
|
||||
if (!modelWidget || !generateAudioWidget || !durationWidget) {
|
||||
return '$0.40-3.20/Run (varies with model & audio generation)'
|
||||
}
|
||||
|
||||
const model = String(modelWidget.value)
|
||||
const generateAudio =
|
||||
String(generateAudioWidget.value).toLowerCase() === 'true'
|
||||
const seconds = parseFloat(String(durationWidget.value))
|
||||
|
||||
let pricePerSecond: number | null = null
|
||||
if (model.includes('veo-3.1-fast-generate')) {
|
||||
pricePerSecond = generateAudio ? 0.15 : 0.1
|
||||
} else if (model.includes('veo-3.1-generate')) {
|
||||
pricePerSecond = generateAudio ? 0.4 : 0.2
|
||||
}
|
||||
if (pricePerSecond === null) {
|
||||
return '$0.40-3.20/Run'
|
||||
}
|
||||
const cost = pricePerSecond * seconds
|
||||
return `$${cost.toFixed(2)}/Run`
|
||||
}
|
||||
},
|
||||
LumaImageNode: {
|
||||
displayPrice: (node: LGraphNode): string => {
|
||||
const modelWidget = node.widgets?.find(
|
||||
@@ -1809,8 +1883,10 @@ export const useNodePricing = () => {
|
||||
IdeogramV3: ['rendering_speed', 'num_images', 'character_image'],
|
||||
FluxProKontextProNode: [],
|
||||
FluxProKontextMaxNode: [],
|
||||
Flux2ProImageNode: ['width', 'height', 'images'],
|
||||
VeoVideoGenerationNode: ['duration_seconds'],
|
||||
Veo3VideoGenerationNode: ['model', 'generate_audio'],
|
||||
Veo3FirstLastFrameNode: ['model', 'generate_audio', 'duration'],
|
||||
LumaVideoNode: ['model', 'resolution', 'duration'],
|
||||
LumaImageToVideoNode: ['model', 'resolution', 'duration'],
|
||||
LumaImageNode: ['model', 'aspect_ratio'],
|
||||
|
||||
@@ -96,6 +96,7 @@ export function useJobList() {
|
||||
const executionStore = useExecutionStore()
|
||||
const workflowStore = useWorkflowStore()
|
||||
|
||||
const seenPendingIds = ref<Set<string>>(new Set())
|
||||
const recentlyAddedPendingIds = ref<Set<string>>(new Set())
|
||||
const addedHintTimeouts = new Map<string, ReturnType<typeof setTimeout>>()
|
||||
|
||||
@@ -126,23 +127,27 @@ export function useJobList() {
|
||||
.filter((id): id is string => !!id),
|
||||
(pendingIds) => {
|
||||
const pendingSet = new Set(pendingIds)
|
||||
const next = new Set(recentlyAddedPendingIds.value)
|
||||
const nextAdded = new Set(recentlyAddedPendingIds.value)
|
||||
const nextSeen = new Set(seenPendingIds.value)
|
||||
|
||||
pendingIds.forEach((id) => {
|
||||
if (!next.has(id)) {
|
||||
next.add(id)
|
||||
if (!nextSeen.has(id)) {
|
||||
nextSeen.add(id)
|
||||
nextAdded.add(id)
|
||||
scheduleAddedHintExpiry(id)
|
||||
}
|
||||
})
|
||||
|
||||
for (const id of Array.from(next)) {
|
||||
for (const id of Array.from(nextSeen)) {
|
||||
if (!pendingSet.has(id)) {
|
||||
next.delete(id)
|
||||
nextSeen.delete(id)
|
||||
nextAdded.delete(id)
|
||||
clearAddedHintTimeout(id)
|
||||
}
|
||||
}
|
||||
|
||||
recentlyAddedPendingIds.value = next
|
||||
recentlyAddedPendingIds.value = nextAdded
|
||||
seenPendingIds.value = nextSeen
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
@@ -157,6 +162,7 @@ export function useJobList() {
|
||||
onUnmounted(() => {
|
||||
addedHintTimeouts.forEach((timeoutId) => clearTimeout(timeoutId))
|
||||
addedHintTimeouts.clear()
|
||||
seenPendingIds.value = new Set<string>()
|
||||
recentlyAddedPendingIds.value = new Set<string>()
|
||||
})
|
||||
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
import type { ComputedRef } from 'vue'
|
||||
import { ref, watch } from 'vue'
|
||||
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import type { Settings } from '@/schemas/apiSchema'
|
||||
|
||||
interface RenderModeSettingConfig<TSettingKey extends keyof Settings> {
|
||||
setting: TSettingKey
|
||||
vue: Settings[TSettingKey]
|
||||
litegraph: Settings[TSettingKey]
|
||||
}
|
||||
|
||||
export function useRenderModeSetting<TSettingKey extends keyof Settings>(
|
||||
config: RenderModeSettingConfig<TSettingKey>,
|
||||
isVueMode: ComputedRef<boolean>
|
||||
) {
|
||||
const settingStore = useSettingStore()
|
||||
const vueValue = ref(config.vue)
|
||||
const litegraphValue = ref(config.litegraph)
|
||||
const lastWasVue = ref<boolean | null>(null)
|
||||
|
||||
const load = async (vue: boolean) => {
|
||||
if (lastWasVue.value === vue) return
|
||||
|
||||
if (lastWasVue.value !== null) {
|
||||
const currentValue = settingStore.get(config.setting)
|
||||
if (lastWasVue.value) {
|
||||
vueValue.value = currentValue
|
||||
} else {
|
||||
litegraphValue.value = currentValue
|
||||
}
|
||||
}
|
||||
|
||||
await settingStore.set(
|
||||
config.setting,
|
||||
vue ? vueValue.value : litegraphValue.value
|
||||
)
|
||||
lastWasVue.value = vue
|
||||
}
|
||||
|
||||
watch(isVueMode, load, { immediate: true })
|
||||
}
|
||||
@@ -330,7 +330,7 @@ export function useCoreCommands(): ComfyCommand[] {
|
||||
label: () =>
|
||||
`Experimental: ${
|
||||
useSettingStore().get('Comfy.VueNodes.Enabled') ? 'Disable' : 'Enable'
|
||||
} Vue Nodes`,
|
||||
} Nodes 2.0`,
|
||||
function: async () => {
|
||||
const settingStore = useSettingStore()
|
||||
const current = settingStore.get('Comfy.VueNodes.Enabled') ?? false
|
||||
|
||||
@@ -237,7 +237,7 @@
|
||||
"label": "Sign Out"
|
||||
},
|
||||
"Experimental_ToggleVueNodes": {
|
||||
"label": "Experimental: Enable Vue Nodes"
|
||||
"label": "Experimental: Enable Nodes 2.0"
|
||||
},
|
||||
"Workspace_CloseWorkflow": {
|
||||
"label": "Close Current Workflow"
|
||||
|
||||
@@ -105,6 +105,7 @@
|
||||
"dropYourFileOr": "Drop your file or",
|
||||
"back": "Back",
|
||||
"next": "Next",
|
||||
"submit": "Submit",
|
||||
"install": "Install",
|
||||
"installing": "Installing",
|
||||
"overwrite": "Overwrite",
|
||||
@@ -245,6 +246,7 @@
|
||||
"frameNodes": "Frame Nodes",
|
||||
"listening": "Listening...",
|
||||
"ready": "Ready",
|
||||
"playPause": "Play/Pause",
|
||||
"playRecording": "Play Recording",
|
||||
"playing": "Playing",
|
||||
"stopPlayback": "Stop Playback",
|
||||
@@ -253,7 +255,9 @@
|
||||
"halfSpeed": "0.5x",
|
||||
"1x": "1x",
|
||||
"2x": "2x",
|
||||
"beta": "BETA"
|
||||
"beta": "BETA",
|
||||
"profile": "Profile",
|
||||
"noItems": "No items"
|
||||
},
|
||||
"manager": {
|
||||
"title": "Custom Nodes Manager",
|
||||
@@ -700,6 +704,7 @@
|
||||
"currentNode": "Current node:",
|
||||
"viewAllJobs": "View all jobs",
|
||||
"running": "running",
|
||||
"preview": "Preview",
|
||||
"interruptAll": "Interrupt all running jobs",
|
||||
"moreOptions": "More options",
|
||||
"showAssets": "Show assets",
|
||||
@@ -941,6 +946,7 @@
|
||||
"thickness": "Thickness",
|
||||
"opacity": "Opacity",
|
||||
"hardness": "Hardness",
|
||||
"stepSize": "Step Size",
|
||||
"smoothingPrecision": "Smoothing Precision",
|
||||
"resetToDefault": "Reset to Default",
|
||||
"paintBucketSettings": "Paint Bucket Settings",
|
||||
@@ -1118,7 +1124,9 @@
|
||||
"Undo": "Undo",
|
||||
"Open Sign In Dialog": "Open Sign In Dialog",
|
||||
"Sign Out": "Sign Out",
|
||||
"Experimental: Enable Vue Nodes": "Experimental: Enable Vue Nodes",
|
||||
"Experimental: Enable Vue Nodes": "Experimental: Enable Nodes 2.0",
|
||||
"Experimental: Enable Nodes 2.0": "Experimental: Enable Nodes 2.0",
|
||||
"Experimental: Disable Nodes 2.0": "Experimental: Disable Nodes 2.0",
|
||||
"Close Current Workflow": "Close Current Workflow",
|
||||
"Next Opened Workflow": "Next Opened Workflow",
|
||||
"Previous Opened Workflow": "Previous Opened Workflow",
|
||||
@@ -1194,10 +1202,10 @@
|
||||
"API Nodes": "API Nodes",
|
||||
"Notification Preferences": "Notification Preferences",
|
||||
"3DViewer": "3DViewer",
|
||||
"Vue Nodes": "Vue Nodes",
|
||||
"Canvas Navigation": "Canvas Navigation",
|
||||
"PlanCredits": "Plan & Credits",
|
||||
"VueNodes": "Vue Nodes",
|
||||
"Vue Nodes": "Nodes 2.0",
|
||||
"VueNodes": "Nodes 2.0",
|
||||
"Nodes 2_0": "Nodes 2.0"
|
||||
},
|
||||
"serverConfigItems": {
|
||||
@@ -1844,6 +1852,7 @@
|
||||
"title": "Subscription",
|
||||
"titleUnsubscribed": "Subscribe to Comfy Cloud",
|
||||
"comfyCloud": "Comfy Cloud",
|
||||
"comfyCloudLogo": "Comfy Cloud Logo",
|
||||
"beta": "BETA",
|
||||
"perMonth": "USD / month",
|
||||
"renewsDate": "Renews {date}",
|
||||
@@ -2079,6 +2088,7 @@
|
||||
"cloudSurvey_steps_making": "What do you plan on making?",
|
||||
"assetBrowser": {
|
||||
"assets": "Assets",
|
||||
"assetCollection": "Asset collection",
|
||||
"checkpoints": "Checkpoints",
|
||||
"browseAssets": "Browse Assets",
|
||||
"noAssetsFound": "No assets found",
|
||||
@@ -2093,11 +2103,11 @@
|
||||
"uploadModelFailedToRetrieveMetadata": "Failed to retrieve metadata. Please check the link and try again.",
|
||||
"onlyCivitaiUrlsSupported": "Only Civitai URLs are supported",
|
||||
"uploadModelDescription1": "Paste a Civitai model download link to add it to your library.",
|
||||
"uploadModelDescription2": "Only links from https://civitai.com are supported at the moment",
|
||||
"uploadModelDescription3": "Max file size: 1 GB",
|
||||
"civitaiLinkLabel": "Civitai model download link",
|
||||
"uploadModelDescription2": "Only links from <a href=\"https://civitai.com\" target=\"_blank\" class=\"text-muted-foreground\">https://civitai.com</a> are supported at the moment",
|
||||
"uploadModelDescription3": "Max file size: <strong>1 GB</strong>",
|
||||
"civitaiLinkLabel": "Civitai model <span class=\"font-bold italic\">download</span> link",
|
||||
"civitaiLinkPlaceholder": "Paste link here",
|
||||
"civitaiLinkExample": "Example: https://civitai.com/api/download/models/833921?type=Model&format=SafeTensor",
|
||||
"civitaiLinkExample": "<strong>Example:</strong> <a href=\"https://civitai.com/api/download/models/833921?type=Model&format=SafeTensor\" target=\"_blank\" class=\"text-muted-foreground\">https://civitai.com/api/download/models/833921?type=Model&format=SafeTensor</a>",
|
||||
"confirmModelDetails": "Confirm Model Details",
|
||||
"fileName": "File Name",
|
||||
"fileSize": "File Size",
|
||||
@@ -2129,6 +2139,9 @@
|
||||
"sortZA": "Z-A",
|
||||
"sortRecent": "Recent",
|
||||
"sortPopular": "Popular",
|
||||
"selectFrameworks": "Select Frameworks",
|
||||
"selectProjects": "Select Projects",
|
||||
"sortingType": "Sorting Type",
|
||||
"errorFileTooLarge": "File exceeds the maximum allowed size limit",
|
||||
"errorFormatNotAllowed": "Only SafeTensor format is allowed",
|
||||
"errorUnsafePickleScan": "CivitAI detected potentially unsafe code in this file",
|
||||
@@ -2200,7 +2213,8 @@
|
||||
}
|
||||
},
|
||||
"vueNodesBanner": {
|
||||
"message": "Introducing Nodes 2.0 – More flexible workflows, powerful new widgets, built for extensibility",
|
||||
"title": "Introducing Nodes 2.0",
|
||||
"desc": "– More flexible workflows, powerful new widgets, built for extensibility",
|
||||
"tryItOut": "Try it out"
|
||||
},
|
||||
"vueNodesMigration": {
|
||||
@@ -2229,4 +2243,4 @@
|
||||
"replacementInstruction": "Install these nodes to run this workflow, or replace them with installed alternatives. Missing nodes are highlighted in red on the canvas."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -336,7 +336,7 @@
|
||||
},
|
||||
"Comfy_VueNodes_AutoScaleLayout": {
|
||||
"name": "Auto-scale layout (Nodes 2.0)",
|
||||
"tooltip": "Automatically scale node positions when switching to Vue rendering to prevent overlap"
|
||||
"tooltip": "Automatically scale node positions when switching to Nodes 2.0 rendering to prevent overlap"
|
||||
},
|
||||
"Comfy_VueNodes_Enabled": {
|
||||
"name": "Modern Node Design (Nodes 2.0)",
|
||||
|
||||
@@ -201,6 +201,12 @@ function handleUploadClick() {
|
||||
onUploadSuccess: async () => {
|
||||
await execute()
|
||||
}
|
||||
},
|
||||
dialogComponentProps: {
|
||||
pt: {
|
||||
header: 'py-0! pl-0!',
|
||||
content: 'p-0!'
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
data-component-id="AssetGrid"
|
||||
:style="gridStyle"
|
||||
role="grid"
|
||||
aria-label="Asset collection"
|
||||
:aria-label="$t('assetBrowser.assetCollection')"
|
||||
:aria-rowcount="-1"
|
||||
:aria-colcount="-1"
|
||||
:aria-setsize="assets.length"
|
||||
|
||||
@@ -59,35 +59,29 @@
|
||||
|
||||
<!-- Media actions - show on hover or when playing -->
|
||||
<IconGroup v-else-if="showActionsOverlay">
|
||||
<div v-tooltip.top="$t('mediaAsset.actions.inspect')">
|
||||
<IconButton
|
||||
size="sm"
|
||||
@click.stop="handleZoomClick"
|
||||
@mouseenter="handleOverlayMouseEnter"
|
||||
@mouseleave="handleOverlayMouseLeave"
|
||||
>
|
||||
<i class="icon-[lucide--zoom-in] size-4" />
|
||||
</IconButton>
|
||||
</div>
|
||||
<div v-tooltip.top="$t('mediaAsset.actions.more')">
|
||||
<MoreButton
|
||||
ref="moreButtonRef"
|
||||
size="sm"
|
||||
@menu-opened="handleMenuOpened"
|
||||
@menu-closed="handleMenuClosed"
|
||||
@mouseenter="handleOverlayMouseEnter"
|
||||
@mouseleave="handleOverlayMouseLeave"
|
||||
>
|
||||
<template #default="{ close }">
|
||||
<MediaAssetMoreMenu
|
||||
:close="close"
|
||||
:show-delete-button="showDeleteButton"
|
||||
@inspect="handleZoomClick"
|
||||
@asset-deleted="handleAssetDelete"
|
||||
/>
|
||||
</template>
|
||||
</MoreButton>
|
||||
</div>
|
||||
<IconButton
|
||||
v-tooltip.top="$t('mediaAsset.actions.inspect')"
|
||||
size="sm"
|
||||
@click.stop="handleZoomClick"
|
||||
>
|
||||
<i class="icon-[lucide--zoom-in] size-4" />
|
||||
</IconButton>
|
||||
<MoreButton
|
||||
ref="moreButtonRef"
|
||||
v-tooltip.top="$t('mediaAsset.actions.more')"
|
||||
size="sm"
|
||||
@menu-opened="handleMenuOpened"
|
||||
@menu-closed="handleMenuClosed"
|
||||
>
|
||||
<template #default="{ close }">
|
||||
<MediaAssetMoreMenu
|
||||
:close="close"
|
||||
:show-delete-button="showDeleteButton"
|
||||
@inspect="handleZoomClick"
|
||||
@asset-deleted="handleAssetDelete"
|
||||
/>
|
||||
</template>
|
||||
</MoreButton>
|
||||
</IconGroup>
|
||||
</template>
|
||||
|
||||
@@ -101,8 +95,6 @@
|
||||
size="sm"
|
||||
:label="String(outputCount)"
|
||||
@click.stop="handleOutputCountClick"
|
||||
@mouseenter="handleOverlayMouseEnter"
|
||||
@mouseleave="handleOverlayMouseLeave"
|
||||
>
|
||||
<template #icon>
|
||||
<i class="icon-[lucide--layers] size-4" />
|
||||
@@ -216,7 +208,6 @@ const moreButtonRef = ref<InstanceType<typeof MoreButton>>()
|
||||
const isVideoPlaying = ref(false)
|
||||
const isMenuOpen = ref(false)
|
||||
const showVideoControls = ref(false)
|
||||
const isOverlayHovered = ref(false)
|
||||
|
||||
// Store actual image dimensions
|
||||
const imageDimensions = ref<{ width: number; height: number } | undefined>()
|
||||
@@ -299,7 +290,7 @@ const durationChipClasses = computed(() => {
|
||||
})
|
||||
|
||||
const isCardOrOverlayHovered = computed(
|
||||
() => isHovered.value || isOverlayHovered.value || isMenuOpen.value
|
||||
() => isHovered.value || isMenuOpen.value
|
||||
)
|
||||
|
||||
// Show static chips when NOT hovered and NOT playing (normal state)
|
||||
@@ -320,14 +311,6 @@ const showActionsOverlay = computed(
|
||||
(isCardOrOverlayHovered.value || isVideoPlaying.value)
|
||||
)
|
||||
|
||||
const handleOverlayMouseEnter = () => {
|
||||
isOverlayHovered.value = true
|
||||
}
|
||||
|
||||
const handleOverlayMouseLeave = () => {
|
||||
isOverlayHovered.value = false
|
||||
}
|
||||
|
||||
const handleZoomClick = () => {
|
||||
if (asset) {
|
||||
emit('zoom', asset)
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
<IconTextButton
|
||||
v-if="asset?.kind !== '3D'"
|
||||
type="transparent"
|
||||
label="Inspect asset"
|
||||
:label="$t('queue.jobMenu.inspectAsset')"
|
||||
@click="handleInspect"
|
||||
>
|
||||
<template #icon>
|
||||
@@ -17,7 +17,7 @@
|
||||
<IconTextButton
|
||||
v-if="showAddToWorkflow"
|
||||
type="transparent"
|
||||
label="Add to current workflow"
|
||||
:label="$t('queue.jobMenu.addToCurrentWorkflow')"
|
||||
@click="handleAddToWorkflow"
|
||||
>
|
||||
<template #icon>
|
||||
@@ -25,7 +25,11 @@
|
||||
</template>
|
||||
</IconTextButton>
|
||||
|
||||
<IconTextButton type="transparent" label="Download" @click="handleDownload">
|
||||
<IconTextButton
|
||||
type="transparent"
|
||||
:label="$t('queue.jobMenu.download')"
|
||||
@click="handleDownload"
|
||||
>
|
||||
<template #icon>
|
||||
<i class="icon-[lucide--download] size-4" />
|
||||
</template>
|
||||
@@ -36,7 +40,7 @@
|
||||
<IconTextButton
|
||||
v-if="showWorkflowActions"
|
||||
type="transparent"
|
||||
label="Open as workflow in new tab"
|
||||
:label="$t('queue.jobMenu.openAsWorkflowNewTab')"
|
||||
@click="handleOpenWorkflow"
|
||||
>
|
||||
<template #icon>
|
||||
@@ -47,7 +51,7 @@
|
||||
<IconTextButton
|
||||
v-if="showWorkflowActions"
|
||||
type="transparent"
|
||||
label="Export workflow"
|
||||
:label="$t('queue.jobMenu.exportWorkflow')"
|
||||
@click="handleExportWorkflow"
|
||||
>
|
||||
<template #icon>
|
||||
@@ -60,7 +64,7 @@
|
||||
<IconTextButton
|
||||
v-if="showCopyJobId"
|
||||
type="transparent"
|
||||
label="Copy job ID"
|
||||
:label="$t('queue.jobMenu.copyJobId')"
|
||||
@click="handleCopyJobId"
|
||||
>
|
||||
<template #icon>
|
||||
@@ -73,7 +77,7 @@
|
||||
<IconTextButton
|
||||
v-if="shouldShowDeleteButton"
|
||||
type="transparent"
|
||||
label="Delete"
|
||||
:label="$t('queue.jobMenu.delete')"
|
||||
@click="handleDelete"
|
||||
>
|
||||
<template #icon>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<template>
|
||||
<div
|
||||
class="relative size-full overflow-hidden rounded bg-modal-card-placeholder-background"
|
||||
@dblclick="emit('view')"
|
||||
>
|
||||
<img
|
||||
v-if="!error"
|
||||
@@ -28,6 +29,7 @@ const { asset } = defineProps<{
|
||||
|
||||
const emit = defineEmits<{
|
||||
'image-loaded': [width: number, height: number]
|
||||
view: []
|
||||
}>()
|
||||
|
||||
const { state, error, isReady } = useImage({
|
||||
|
||||
@@ -1,22 +1,24 @@
|
||||
<template>
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="flex flex-col gap-4 text-sm text-muted-foreground">
|
||||
<!-- Model Info Section -->
|
||||
<div class="flex flex-col gap-2">
|
||||
<p class="text-sm text-muted m-0">
|
||||
<p class="m-0">
|
||||
{{ $t('assetBrowser.modelAssociatedWithLink') }}
|
||||
</p>
|
||||
<p class="text-sm mt-0">
|
||||
<p
|
||||
class="mt-0 bg-modal-card-background text-base-foreground p-3 rounded-lg"
|
||||
>
|
||||
{{ metadata?.name || metadata?.filename }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Model Type Selection -->
|
||||
<div class="flex flex-col gap-2">
|
||||
<label class="text-sm text-muted">
|
||||
<label class="">
|
||||
{{ $t('assetBrowser.modelTypeSelectorLabel') }}
|
||||
</label>
|
||||
<SingleSelect
|
||||
v-model="selectedModelType"
|
||||
v-model="modelValue"
|
||||
:label="
|
||||
isLoading
|
||||
? $t('g.loading')
|
||||
@@ -25,8 +27,8 @@
|
||||
:options="modelTypes"
|
||||
:disabled="isLoading"
|
||||
/>
|
||||
<div class="flex items-center gap-2 text-sm text-muted">
|
||||
<i class="icon-[lucide--info]" />
|
||||
<div class="flex items-center gap-2">
|
||||
<i class="icon-[lucide--circle-question-mark]" />
|
||||
<span>{{ $t('assetBrowser.notSureLeaveAsIs') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -34,25 +36,15 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
import SingleSelect from '@/components/input/SingleSelect.vue'
|
||||
import { useModelTypes } from '@/platform/assets/composables/useModelTypes'
|
||||
import type { AssetMetadata } from '@/platform/assets/schemas/assetSchema'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: string | undefined
|
||||
defineProps<{
|
||||
metadata: AssetMetadata | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: string | undefined]
|
||||
}>()
|
||||
const modelValue = defineModel<string | undefined>()
|
||||
|
||||
const { modelTypes, isLoading } = useModelTypes()
|
||||
|
||||
const selectedModelType = computed({
|
||||
get: () => props.modelValue ?? null,
|
||||
set: (value: string | null) => emit('update:modelValue', value ?? undefined)
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
<template>
|
||||
<div class="upload-model-dialog flex flex-col justify-between gap-6 p-4 pt-6">
|
||||
<div
|
||||
class="upload-model-dialog flex flex-col justify-between gap-6 p-4 pt-6 border-t-[1px] border-border-default"
|
||||
>
|
||||
<!-- Step 1: Enter URL -->
|
||||
<UploadModelUrlInput
|
||||
v-if="currentStep === 1"
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
<template>
|
||||
<div class="flex items-center gap-3 px-4 py-2 font-bold">
|
||||
<div class="flex items-center gap-2 p-4 font-bold">
|
||||
<img src="/assets/images/civitai.svg" class="size-4" />
|
||||
<span>{{ $t('assetBrowser.uploadModelFromCivitai') }}</span>
|
||||
<span
|
||||
class="rounded-full bg-white px-1.5 py-0 text-xxs font-medium uppercase text-black"
|
||||
class="rounded-full bg-white px-1.5 py-0 text-xxs font-inter font-semibold uppercase text-black"
|
||||
>
|
||||
{{ $t('g.beta') }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts"></script>
|
||||
|
||||
@@ -1,9 +1,26 @@
|
||||
<template>
|
||||
<div class="flex justify-end gap-2">
|
||||
<div class="flex justify-end gap-2 w-full">
|
||||
<span
|
||||
v-if="currentStep === 1"
|
||||
class="text-muted-foreground mr-auto underline flex items-center gap-2"
|
||||
>
|
||||
<i class="icon-[lucide--circle-question-mark]" />
|
||||
<a href="#" target="_blank" class="text-muted-foreground">{{
|
||||
$t('How do I find this?')
|
||||
}}</a>
|
||||
</span>
|
||||
<TextButton
|
||||
v-if="currentStep === 1"
|
||||
:label="$t('g.cancel')"
|
||||
type="transparent"
|
||||
size="md"
|
||||
:disabled="isFetchingMetadata || isUploading"
|
||||
@click="emit('close')"
|
||||
/>
|
||||
<TextButton
|
||||
v-if="currentStep !== 1 && currentStep !== 3"
|
||||
:label="$t('g.back')"
|
||||
type="secondary"
|
||||
type="transparent"
|
||||
size="md"
|
||||
:disabled="isFetchingMetadata || isUploading"
|
||||
@click="emit('back')"
|
||||
@@ -13,7 +30,7 @@
|
||||
<IconTextButton
|
||||
v-if="currentStep === 1"
|
||||
:label="$t('g.continue')"
|
||||
type="primary"
|
||||
type="secondary"
|
||||
size="md"
|
||||
:disabled="!canFetchMetadata || isFetchingMetadata"
|
||||
@click="emit('fetchMetadata')"
|
||||
@@ -28,7 +45,7 @@
|
||||
<IconTextButton
|
||||
v-else-if="currentStep === 2"
|
||||
:label="$t('assetBrowser.upload')"
|
||||
type="primary"
|
||||
type="secondary"
|
||||
size="md"
|
||||
:disabled="!canUploadModel || isUploading"
|
||||
@click="emit('upload')"
|
||||
@@ -43,7 +60,7 @@
|
||||
<TextButton
|
||||
v-else-if="currentStep === 3 && uploadStatus === 'success'"
|
||||
:label="$t('assetBrowser.finish')"
|
||||
type="primary"
|
||||
type="secondary"
|
||||
size="md"
|
||||
@click="emit('close')"
|
||||
/>
|
||||
|
||||
@@ -1,37 +1,38 @@
|
||||
<template>
|
||||
<div class="flex flex-1 flex-col gap-6">
|
||||
<div class="flex flex-1 flex-col gap-6 text-sm text-muted-foreground">
|
||||
<!-- Uploading State -->
|
||||
<div
|
||||
v-if="status === 'uploading'"
|
||||
class="flex flex-1 flex-col items-center justify-center gap-6"
|
||||
class="flex flex-1 flex-col items-center justify-center gap-2"
|
||||
>
|
||||
<i
|
||||
class="icon-[lucide--loader-circle] animate-spin text-6xl text-primary"
|
||||
class="icon-[lucide--loader-circle] animate-spin text-6xl text-muted-foreground"
|
||||
/>
|
||||
<div class="text-center">
|
||||
<p class="m-0 text-sm font-bold">
|
||||
<p class="m-0 font-bold">
|
||||
{{ $t('assetBrowser.uploadingModel') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Success State -->
|
||||
<div v-else-if="status === 'success'" class="flex flex-col gap-8">
|
||||
<div class="flex flex-col gap-4">
|
||||
<p class="text-sm text-muted m-0 font-bold">
|
||||
{{ $t('assetBrowser.modelUploaded') }}
|
||||
</p>
|
||||
<p class="text-sm text-muted m-0">
|
||||
{{ $t('assetBrowser.findInLibrary', { type: modelType }) }}
|
||||
</p>
|
||||
</div>
|
||||
<div v-else-if="status === 'success'" class="flex flex-col gap-2">
|
||||
<p class="m-0 font-bold">
|
||||
{{ $t('assetBrowser.modelUploaded') }}
|
||||
</p>
|
||||
<p class="m-0">
|
||||
{{ $t('assetBrowser.findInLibrary', { type: modelType }) }}
|
||||
</p>
|
||||
|
||||
<div class="flex flex-row items-start p-8 bg-neutral-800 rounded-lg">
|
||||
<div
|
||||
class="flex flex-row items-start p-4 bg-modal-card-background rounded-lg"
|
||||
>
|
||||
<div class="flex flex-col justify-center items-start gap-1 flex-1">
|
||||
<p class="text-sm m-0">
|
||||
<p class="text-base-foreground m-0">
|
||||
{{ metadata?.name || metadata?.filename }}
|
||||
</p>
|
||||
<p class="text-sm text-muted m-0">
|
||||
<!-- Going to want to add another translation here to get a nice display name. -->
|
||||
{{ modelType }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -1,30 +1,27 @@
|
||||
<template>
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="flex flex-col gap-6 text-sm text-muted-foreground">
|
||||
<div class="flex flex-col gap-2">
|
||||
<p class="text-sm text-muted m-0">
|
||||
<p class="m-0">
|
||||
{{ $t('assetBrowser.uploadModelDescription1') }}
|
||||
</p>
|
||||
<ul class="list-disc space-y-1 pl-5 mt-0 text-sm text-muted">
|
||||
<li>{{ $t('assetBrowser.uploadModelDescription2') }}</li>
|
||||
<li>{{ $t('assetBrowser.uploadModelDescription3') }}</li>
|
||||
<ul class="list-disc space-y-1 pl-5 mt-0">
|
||||
<li v-html="$t('assetBrowser.uploadModelDescription2')" />
|
||||
<li v-html="$t('assetBrowser.uploadModelDescription3')" />
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<label class="text-sm text-muted mb-0">
|
||||
{{ $t('assetBrowser.civitaiLinkLabel') }}
|
||||
</label>
|
||||
<label class="mb-0" v-html="$t('assetBrowser.civitaiLinkLabel')"> </label>
|
||||
<InputText
|
||||
v-model="url"
|
||||
autofocus
|
||||
:placeholder="$t('assetBrowser.civitaiLinkPlaceholder')"
|
||||
class="w-full"
|
||||
class="w-full bg-secondary-background border-0 p-4"
|
||||
/>
|
||||
<p v-if="error" class="text-xs text-error">
|
||||
{{ error }}
|
||||
</p>
|
||||
<p v-else class="text-xs text-muted">
|
||||
{{ $t('assetBrowser.civitaiLinkExample') }}
|
||||
</p>
|
||||
<p v-else v-html="$t('assetBrowser.civitaiLinkExample')"></p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -4,18 +4,18 @@ import { api } from '@/scripts/api'
|
||||
|
||||
/**
|
||||
* Format folder name to display name
|
||||
* Converts "upscale_models" -> "Upscale Models"
|
||||
* Converts "loras" -> "LoRAs"
|
||||
* Converts "upscale_models" -> "Upscale Model"
|
||||
* Converts "loras" -> "LoRA"
|
||||
*/
|
||||
function formatDisplayName(folderName: string): string {
|
||||
// Special cases for acronyms and proper nouns
|
||||
const specialCases: Record<string, string> = {
|
||||
loras: 'LoRAs',
|
||||
loras: 'LoRA',
|
||||
ipadapter: 'IP-Adapter',
|
||||
sams: 'SAMs',
|
||||
sams: 'SAM',
|
||||
clip_vision: 'CLIP Vision',
|
||||
animatediff_motion_lora: 'AnimateDiff Motion LoRA',
|
||||
animatediff_models: 'AnimateDiff Models',
|
||||
animatediff_models: 'AnimateDiff Model',
|
||||
vae: 'VAE',
|
||||
sam2: 'SAM 2',
|
||||
controlnet: 'ControlNet',
|
||||
|
||||
@@ -31,7 +31,7 @@ export function useUploadModelWizard(modelTypes: Ref<ModelTypeOption[]>) {
|
||||
tags: []
|
||||
})
|
||||
|
||||
const selectedModelType = ref<string | undefined>(undefined)
|
||||
const selectedModelType = ref<string>()
|
||||
|
||||
// Clear error when URL changes
|
||||
watch(
|
||||
|
||||
@@ -44,7 +44,7 @@
|
||||
<div class="flex justify-between pt-4">
|
||||
<span />
|
||||
<Button
|
||||
label="Next"
|
||||
:label="$t('g.next')"
|
||||
:disabled="!validStep1"
|
||||
class="h-10 w-full border-none text-white"
|
||||
@click="goTo(2, activateCallback)"
|
||||
@@ -84,20 +84,22 @@
|
||||
<InputText
|
||||
v-model="surveyData.useCaseOther"
|
||||
class="w-full"
|
||||
placeholder="Please specify"
|
||||
:placeholder="
|
||||
$t('cloudOnboarding.survey.options.industry.otherPlaceholder')
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-6 pt-4">
|
||||
<Button
|
||||
label="Back"
|
||||
:label="$t('g.back')"
|
||||
severity="secondary"
|
||||
class="flex-1 text-white"
|
||||
@click="goTo(1, activateCallback)"
|
||||
/>
|
||||
<Button
|
||||
label="Next"
|
||||
:label="$t('g.next')"
|
||||
:disabled="!validStep2"
|
||||
class="h-10 flex-1 text-white"
|
||||
@click="goTo(3, activateCallback)"
|
||||
@@ -137,20 +139,22 @@
|
||||
<InputText
|
||||
v-model="surveyData.industryOther"
|
||||
class="w-full"
|
||||
placeholder="Please specify"
|
||||
:placeholder="
|
||||
$t('cloudOnboarding.survey.options.industry.otherPlaceholder')
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-6 pt-4">
|
||||
<Button
|
||||
label="Back"
|
||||
:label="$t('g.back')"
|
||||
severity="secondary"
|
||||
class="flex-1 text-white"
|
||||
@click="goTo(2, activateCallback)"
|
||||
/>
|
||||
<Button
|
||||
label="Next"
|
||||
:label="$t('g.next')"
|
||||
:disabled="!validStep3"
|
||||
class="h-10 flex-1 border-none text-white"
|
||||
@click="goTo(4, activateCallback)"
|
||||
@@ -189,13 +193,13 @@
|
||||
|
||||
<div class="flex gap-6 pt-4">
|
||||
<Button
|
||||
label="Back"
|
||||
:label="$t('g.back')"
|
||||
severity="secondary"
|
||||
class="flex-1 text-white"
|
||||
@click="goTo(3, activateCallback)"
|
||||
/>
|
||||
<Button
|
||||
label="Submit"
|
||||
:label="$t('g.submit')"
|
||||
:disabled="!validStep4 || isSubmitting"
|
||||
:loading="isSubmitting"
|
||||
class="h-10 flex-1 border-none text-white"
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<div class="mx-auto flex h-[7%] max-h-[70px] w-5/6 items-end">
|
||||
<img
|
||||
src="/assets/images/comfy-cloud-logo.svg"
|
||||
alt="Comfy Cloud Logo"
|
||||
:alt="$t('subscription.comfyCloudLogo')"
|
||||
class="h-3/4 max-h-10 w-auto"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { TelemetryEventName } from '@/platform/telemetry/types'
|
||||
|
||||
/**
|
||||
* Server health alert configuration from the backend
|
||||
*/
|
||||
@@ -31,4 +33,5 @@ export type RemoteConfig = {
|
||||
comfy_api_base_url?: string
|
||||
comfy_platform_base_url?: string
|
||||
firebase_config?: FirebaseRuntimeConfig
|
||||
telemetry_disabled_events?: TelemetryEventName[]
|
||||
}
|
||||
|
||||
@@ -8,9 +8,11 @@ import {
|
||||
} from '@/platform/settings/settingStore'
|
||||
import type { ISettingGroup, SettingParams } from '@/platform/settings/types'
|
||||
import { normalizeI18nKey } from '@/utils/formatUtil'
|
||||
import { useVueFeatureFlags } from '@/composables/useVueFeatureFlags'
|
||||
|
||||
export function useSettingSearch() {
|
||||
const settingStore = useSettingStore()
|
||||
const { shouldRenderVueNodes } = useVueFeatureFlags()
|
||||
|
||||
const searchQuery = ref<string>('')
|
||||
const filteredSettingIds = ref<string[]>([])
|
||||
@@ -54,7 +56,11 @@ export function useSettingSearch() {
|
||||
const allSettings = Object.values(settingStore.settingsById)
|
||||
const filteredSettings = allSettings.filter((setting) => {
|
||||
// Filter out hidden and deprecated settings, just like in normal settings tree
|
||||
if (setting.type === 'hidden' || setting.deprecated) {
|
||||
if (
|
||||
setting.type === 'hidden' ||
|
||||
setting.deprecated ||
|
||||
(shouldRenderVueNodes.value && setting.hideInVueNodes)
|
||||
) {
|
||||
return false
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import type { SettingParams } from '@/platform/settings/types'
|
||||
import { isElectron } from '@/utils/envUtil'
|
||||
import { normalizeI18nKey } from '@/utils/formatUtil'
|
||||
import { buildTree } from '@/utils/treeUtil'
|
||||
import { useVueFeatureFlags } from '@/composables/useVueFeatureFlags'
|
||||
|
||||
interface SettingPanelItem {
|
||||
node: SettingTreeNode
|
||||
@@ -31,10 +32,14 @@ export function useSettingUI(
|
||||
const settingStore = useSettingStore()
|
||||
const activeCategory = ref<SettingTreeNode | null>(null)
|
||||
|
||||
const { shouldRenderVueNodes } = useVueFeatureFlags()
|
||||
|
||||
const settingRoot = computed<SettingTreeNode>(() => {
|
||||
const root = buildTree(
|
||||
Object.values(settingStore.settingsById).filter(
|
||||
(setting: SettingParams) => setting.type !== 'hidden'
|
||||
(setting: SettingParams) =>
|
||||
setting.type !== 'hidden' &&
|
||||
!(shouldRenderVueNodes.value && setting.hideInVueNodes)
|
||||
),
|
||||
(setting: SettingParams) => setting.category || setting.id.split('.')
|
||||
)
|
||||
|
||||
@@ -919,7 +919,8 @@ export const CORE_SETTINGS: SettingParams[] = [
|
||||
step: 1
|
||||
},
|
||||
defaultValue: 8,
|
||||
versionAdded: '1.26.7'
|
||||
versionAdded: '1.26.7',
|
||||
hideInVueNodes: true
|
||||
},
|
||||
{
|
||||
id: 'Comfy.Canvas.SelectionToolbox',
|
||||
@@ -1101,7 +1102,7 @@ export const CORE_SETTINGS: SettingParams[] = [
|
||||
category: ['Comfy', 'Nodes 2.0', 'AutoScaleLayout'],
|
||||
name: 'Auto-scale layout (Nodes 2.0)',
|
||||
tooltip:
|
||||
'Automatically scale node positions when switching to Vue rendering to prevent overlap',
|
||||
'Automatically scale node positions when switching to Nodes 2.0 rendering to prevent overlap',
|
||||
type: 'boolean',
|
||||
sortOrder: 50,
|
||||
experimental: true,
|
||||
|
||||
@@ -47,6 +47,7 @@ export interface SettingParams<TValue = unknown> extends FormItem {
|
||||
// sortOrder for sorting settings within a group. Higher values appear first.
|
||||
// Default is 0 if not specified.
|
||||
sortOrder?: number
|
||||
hideInVueNodes?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { OverridedMixpanel } from 'mixpanel-browser'
|
||||
import { watch } from 'vue'
|
||||
|
||||
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
|
||||
import {
|
||||
@@ -41,9 +42,30 @@ import type {
|
||||
WorkflowCreatedMetadata,
|
||||
WorkflowImportMetadata
|
||||
} from '../../types'
|
||||
import { remoteConfig } from '@/platform/remoteConfig/remoteConfig'
|
||||
import type { RemoteConfig } from '@/platform/remoteConfig/types'
|
||||
import { TelemetryEvents } from '../../types'
|
||||
import { normalizeSurveyResponses } from '../../utils/surveyNormalization'
|
||||
|
||||
const DEFAULT_DISABLED_EVENTS = [
|
||||
TelemetryEvents.WORKFLOW_OPENED,
|
||||
TelemetryEvents.PAGE_VISIBILITY_CHANGED,
|
||||
TelemetryEvents.TAB_COUNT_TRACKING,
|
||||
TelemetryEvents.NODE_SEARCH,
|
||||
TelemetryEvents.NODE_SEARCH_RESULT_SELECTED,
|
||||
TelemetryEvents.TEMPLATE_FILTER_CHANGED,
|
||||
TelemetryEvents.SETTING_CHANGED,
|
||||
TelemetryEvents.HELP_CENTER_OPENED,
|
||||
TelemetryEvents.HELP_RESOURCE_CLICKED,
|
||||
TelemetryEvents.HELP_CENTER_CLOSED,
|
||||
TelemetryEvents.WORKFLOW_CREATED,
|
||||
TelemetryEvents.UI_BUTTON_CLICKED
|
||||
] as const satisfies TelemetryEventName[]
|
||||
|
||||
const TELEMETRY_EVENT_SET = new Set<TelemetryEventName>(
|
||||
Object.values(TelemetryEvents) as TelemetryEventName[]
|
||||
)
|
||||
|
||||
interface QueuedEvent {
|
||||
eventName: TelemetryEventName
|
||||
properties?: TelemetryEventProperties
|
||||
@@ -67,8 +89,19 @@ export class MixpanelTelemetryProvider implements TelemetryProvider {
|
||||
private eventQueue: QueuedEvent[] = []
|
||||
private isInitialized = false
|
||||
private lastTriggerSource: ExecutionTriggerSource | undefined
|
||||
private disabledEvents = new Set<TelemetryEventName>(DEFAULT_DISABLED_EVENTS)
|
||||
|
||||
constructor() {
|
||||
this.configureDisabledEvents(
|
||||
(window.__CONFIG__ as Partial<RemoteConfig> | undefined) ?? null
|
||||
)
|
||||
watch(
|
||||
remoteConfig,
|
||||
(config) => {
|
||||
this.configureDisabledEvents(config)
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
const token = window.__CONFIG__?.mixpanel_token
|
||||
|
||||
if (token) {
|
||||
@@ -131,6 +164,10 @@ export class MixpanelTelemetryProvider implements TelemetryProvider {
|
||||
return
|
||||
}
|
||||
|
||||
if (this.disabledEvents.has(eventName)) {
|
||||
return
|
||||
}
|
||||
|
||||
const event: QueuedEvent = { eventName, properties }
|
||||
|
||||
if (this.isInitialized && this.mixpanel) {
|
||||
@@ -146,6 +183,27 @@ export class MixpanelTelemetryProvider implements TelemetryProvider {
|
||||
}
|
||||
}
|
||||
|
||||
private configureDisabledEvents(config: Partial<RemoteConfig> | null): void {
|
||||
const disabledSource =
|
||||
config?.telemetry_disabled_events ?? DEFAULT_DISABLED_EVENTS
|
||||
|
||||
this.disabledEvents = this.buildEventSet(disabledSource)
|
||||
}
|
||||
|
||||
private buildEventSet(values: TelemetryEventName[]): Set<TelemetryEventName> {
|
||||
return new Set(
|
||||
values.filter((value) => {
|
||||
const isValid = TELEMETRY_EVENT_SET.has(value)
|
||||
if (!isValid && import.meta.env.DEV) {
|
||||
console.warn(
|
||||
`Unknown telemetry event name in disabled list: ${value}`
|
||||
)
|
||||
}
|
||||
return isValid
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
trackSignupOpened(): void {
|
||||
this.trackEvent(TelemetryEvents.USER_SIGN_UP_OPENED)
|
||||
}
|
||||
|
||||
@@ -29,12 +29,6 @@ vi.mock('@/renderer/core/layout/transform/useTransformState', () => {
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/renderer/extensions/vueNodes/lod/useLOD', () => ({
|
||||
useLOD: vi.fn(() => ({
|
||||
isLOD: false
|
||||
}))
|
||||
}))
|
||||
|
||||
function createMockCanvas(): LGraphCanvas {
|
||||
return {
|
||||
canvas: {
|
||||
|
||||
@@ -4,8 +4,7 @@
|
||||
:class="
|
||||
cn(
|
||||
'absolute inset-0 w-full h-full pointer-events-none',
|
||||
isInteracting ? 'transform-pane--interacting' : 'will-change-auto',
|
||||
isLOD && 'isLOD'
|
||||
isInteracting ? 'transform-pane--interacting' : 'will-change-auto'
|
||||
)
|
||||
"
|
||||
:style="transformStyle"
|
||||
@@ -22,7 +21,6 @@ import { computed } from 'vue'
|
||||
import type { LGraphCanvas } from '@/lib/litegraph/src/litegraph'
|
||||
import { useTransformSettling } from '@/renderer/core/layout/transform/useTransformSettling'
|
||||
import { useTransformState } from '@/renderer/core/layout/transform/useTransformState'
|
||||
import { useLOD } from '@/renderer/extensions/vueNodes/lod/useLOD'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
interface TransformPaneProps {
|
||||
@@ -31,9 +29,7 @@ interface TransformPaneProps {
|
||||
|
||||
const props = defineProps<TransformPaneProps>()
|
||||
|
||||
const { camera, transformStyle, syncWithCanvas } = useTransformState()
|
||||
|
||||
const { isLOD } = useLOD(camera)
|
||||
const { transformStyle, syncWithCanvas } = useTransformState()
|
||||
|
||||
const canvasElement = computed(() => props.canvas?.canvas)
|
||||
const { isTransforming: isInteracting } = useTransformSettling(canvasElement, {
|
||||
|
||||
@@ -83,20 +83,17 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="relative">
|
||||
<!-- Video Dimensions -->
|
||||
<div class="mt-2 text-center text-xs text-white">
|
||||
<span v-if="videoError" class="text-red-400">
|
||||
{{ $t('g.errorLoadingVideo') }}
|
||||
</span>
|
||||
<span v-else-if="isLoading" class="text-smoke-400">
|
||||
{{ $t('g.loading') }}...
|
||||
</span>
|
||||
<span v-else>
|
||||
{{ actualDimensions || $t('g.calculatingDimensions') }}
|
||||
</span>
|
||||
</div>
|
||||
<LODFallback />
|
||||
<!-- Video Dimensions -->
|
||||
<div class="mt-2 text-center text-xs text-white">
|
||||
<span v-if="videoError" class="text-red-400">
|
||||
{{ $t('g.errorLoadingVideo') }}
|
||||
</span>
|
||||
<span v-else-if="isLoading" class="text-smoke-400">
|
||||
{{ $t('g.loading') }}...
|
||||
</span>
|
||||
<span v-else>
|
||||
{{ actualDimensions || $t('g.calculatingDimensions') }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -110,8 +107,6 @@ import { useI18n } from 'vue-i18n'
|
||||
import { downloadFile } from '@/base/common/downloadUtil'
|
||||
import { useNodeOutputStore } from '@/stores/imagePreviewStore'
|
||||
|
||||
import LODFallback from './components/LODFallback.vue'
|
||||
|
||||
interface VideoPreviewProps {
|
||||
/** Array of video URLs to display */
|
||||
readonly imageUrls: readonly string[] // Named imageUrls for consistency with parent components
|
||||
|
||||
@@ -93,20 +93,17 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="relative">
|
||||
<!-- Image Dimensions -->
|
||||
<div class="mt-2 text-center text-xs text-white">
|
||||
<span v-if="imageError" class="text-red-400">
|
||||
{{ $t('g.errorLoadingImage') }}
|
||||
</span>
|
||||
<span v-else-if="isLoading" class="text-smoke-400">
|
||||
{{ $t('g.loading') }}...
|
||||
</span>
|
||||
<span v-else>
|
||||
{{ actualDimensions || $t('g.calculatingDimensions') }}
|
||||
</span>
|
||||
</div>
|
||||
<LODFallback />
|
||||
<!-- Image Dimensions -->
|
||||
<div class="mt-2 text-center text-xs text-white">
|
||||
<span v-if="imageError" class="text-red-400">
|
||||
{{ $t('g.errorLoadingImage') }}
|
||||
</span>
|
||||
<span v-else-if="isLoading" class="text-smoke-400">
|
||||
{{ $t('g.loading') }}...
|
||||
</span>
|
||||
<span v-else>
|
||||
{{ actualDimensions || $t('g.calculatingDimensions') }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -122,8 +119,6 @@ import { app } from '@/scripts/app'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import { useNodeOutputStore } from '@/stores/imagePreviewStore'
|
||||
|
||||
import LODFallback from './LODFallback.vue'
|
||||
|
||||
interface ImagePreviewProps {
|
||||
/** Array of image URLs to display */
|
||||
readonly imageUrls: readonly string[]
|
||||
|
||||
@@ -10,14 +10,13 @@
|
||||
/>
|
||||
|
||||
<!-- Slot Name -->
|
||||
<div class="relative h-full flex items-center min-w-0">
|
||||
<div class="h-full flex items-center min-w-0">
|
||||
<span
|
||||
v-if="!dotOnly"
|
||||
:class="cn('truncate text-xs font-normal lod-toggle', labelClasses)"
|
||||
:class="cn('truncate text-xs font-normal', labelClasses)"
|
||||
>
|
||||
{{ slotData.localized_name || slotData.name || `Input ${index}` }}
|
||||
</span>
|
||||
<LODFallback />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -37,7 +36,6 @@ import { useSlotLinkInteraction } from '@/renderer/extensions/vueNodes/composabl
|
||||
import { useExecutionStore } from '@/stores/executionStore'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
import LODFallback from './LODFallback.vue'
|
||||
import SlotConnectionDot from './SlotConnectionDot.vue'
|
||||
|
||||
interface InputSlotProps {
|
||||
|
||||
@@ -99,18 +99,14 @@
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Node Body - rendered based on LOD level and collapsed state -->
|
||||
<div
|
||||
class="flex flex-1 flex-col gap-1 pb-2"
|
||||
:data-testid="`node-body-${nodeData.id}`"
|
||||
>
|
||||
<!-- Slots only rendered at full detail -->
|
||||
<NodeSlots :node-data="nodeData" />
|
||||
|
||||
<!-- Widgets rendered at reduced+ detail -->
|
||||
<NodeWidgets v-if="nodeData.widgets?.length" :node-data="nodeData" />
|
||||
|
||||
<!-- Custom content at reduced+ detail -->
|
||||
<div v-if="hasCustomContent" class="min-h-0 flex-1 flex">
|
||||
<NodeContent :node-data="nodeData" :media="nodeMedia" />
|
||||
</div>
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
<template>
|
||||
<div
|
||||
class="lod-fallback absolute inset-0 h-full w-full bg-node-component-widget-skeleton-surface"
|
||||
></div>
|
||||
</template>
|
||||
@@ -18,7 +18,7 @@
|
||||
<div class="flex items-center justify-between gap-2.5 min-w-0">
|
||||
<!-- Collapse/Expand Button -->
|
||||
<div class="relative grow-1 flex items-center gap-2.5 min-w-0 flex-1">
|
||||
<div class="lod-toggle flex shrink-0 items-center px-0.5">
|
||||
<div class="flex shrink-0 items-center px-0.5">
|
||||
<IconButton
|
||||
size="fit-content"
|
||||
type="transparent"
|
||||
@@ -44,7 +44,7 @@
|
||||
<!-- Node Title -->
|
||||
<div
|
||||
v-tooltip.top="tooltipConfig"
|
||||
class="lod-toggle flex min-w-0 flex-1 items-center gap-2 text-sm font-bold"
|
||||
class="flex min-w-0 flex-1 items-center gap-2 text-sm font-bold"
|
||||
data-testid="node-title"
|
||||
>
|
||||
<div class="truncate min-w-0 flex-1">
|
||||
@@ -57,10 +57,9 @@
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<LODFallback />
|
||||
</div>
|
||||
|
||||
<div class="lod-toggle flex shrink-0 items-center justify-between gap-2">
|
||||
<div class="flex shrink-0 items-center justify-between gap-2">
|
||||
<NodeBadge
|
||||
v-for="badge of nodeBadges"
|
||||
:key="badge.text"
|
||||
@@ -112,7 +111,6 @@ import {
|
||||
} from '@/utils/graphTraversalUtil'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
import LODFallback from './LODFallback.vue'
|
||||
import type { NodeBadgeProps } from './NodeBadge.vue'
|
||||
|
||||
interface NodeHeaderProps {
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
v-else
|
||||
:class="
|
||||
cn(
|
||||
'lg-node-widgets grid grid-cols-[min-content_minmax(80px,max-content)_minmax(125px,auto)] has-[.widget-expands]:flex-1 gap-1 pr-3',
|
||||
'lg-node-widgets grid grid-cols-[min-content_minmax(80px,max-content)_minmax(125px,auto)] has-[.widget-expands]:flex-1 gap-y-1 pr-3',
|
||||
shouldHandleNodePointerEvents
|
||||
? 'pointer-events-auto'
|
||||
: 'pointer-events-none'
|
||||
|
||||
@@ -5,11 +5,10 @@
|
||||
<!-- Slot Name -->
|
||||
<span
|
||||
v-if="!dotOnly"
|
||||
class="lod-toggle text-xs font-normal truncate text-node-component-slot-text"
|
||||
class="text-xs font-normal truncate text-node-component-slot-text"
|
||||
>
|
||||
{{ slotData.localized_name || slotData.name || `Output ${index}` }}
|
||||
</span>
|
||||
<LODFallback />
|
||||
</div>
|
||||
<!-- Connection Dot -->
|
||||
<SlotConnectionDot
|
||||
@@ -35,7 +34,6 @@ import { useSlotElementTracking } from '@/renderer/extensions/vueNodes/composabl
|
||||
import { useSlotLinkInteraction } from '@/renderer/extensions/vueNodes/composables/useSlotLinkInteraction'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
import LODFallback from './LODFallback.vue'
|
||||
import SlotConnectionDot from './SlotConnectionDot.vue'
|
||||
|
||||
interface OutputSlotProps {
|
||||
|
||||
@@ -1,141 +0,0 @@
|
||||
# ComfyUI Widget LOD System: Architecture and Implementation
|
||||
|
||||
## Executive Summary
|
||||
|
||||
The ComfyUI widget Level of Detail (LOD) system has evolved from a reactive, Vue-based approach to a CSS-driven, non-reactive implementation. This architectural shift was driven by performance requirements at scale (300-500+ nodes) and a deeper understanding of browser rendering pipelines. The current system prioritizes consistent performance over granular control, leveraging CSS visibility rules rather than component mounting/unmounting.
|
||||
|
||||
## The Two Approaches: Reactive vs. Static LOD
|
||||
|
||||
### Approach 1: Reactive LOD (Original Design)
|
||||
|
||||
The original design envisioned a system where each widget would reactively respond to zoom level changes, controlling its own detail level through Vue's reactivity system. Widgets would import LOD utilities, compute what to show based on zoom level, and conditionally render elements using `v-if` and `v-show` directives.
|
||||
|
||||
**The promise of this approach was compelling:** widgets could intelligently manage their complexity, progressively revealing detail as users zoomed in, much like how mapping applications work. Developers would have fine-grained control over performance optimization.
|
||||
|
||||
### Approach 2: Static LOD with CSS (Current Implementation)
|
||||
|
||||
The implemented system takes a fundamentally different approach. All widget content is loaded and remains in the DOM at all times. Visual simplification happens through CSS rules, primarily using `visibility: hidden` and simplified visual representations (gray rectangles) at distant zoom levels. No reactive updates occur when zoom changes—only CSS rules apply differently.
|
||||
|
||||
**This approach seems counterintuitive at first:** aren't we wasting resources by keeping everything loaded? The answer reveals a deeper truth about modern browser rendering.
|
||||
|
||||
## The GPU Texture Bottleneck
|
||||
|
||||
The key insight driving the current architecture comes from understanding how browsers handle CSS transforms:
|
||||
|
||||
When you apply a CSS transform to a parent element (the "transformpane" in ComfyUI's case), the browser promotes that entire subtree to a compositor layer. This creates a single GPU texture containing all the transformed content. Here's where traditional performance intuitions break down:
|
||||
|
||||
### Traditional Assumption
|
||||
|
||||
"If we render less content, we get better performance. Therefore, hiding complex widgets should improve zoom/pan performance."
|
||||
|
||||
### Actual Browser Behavior
|
||||
|
||||
When all nodes are children of a single transformed parent:
|
||||
|
||||
1. The browser creates one large GPU texture for the entire node graph
|
||||
2. The texture dimensions are determined by the bounding box of all content
|
||||
3. Whether individual pixels are simple (solid rectangles) or complex (detailed widgets) has minimal impact
|
||||
4. The performance bottleneck is the texture size itself, not the complexity of rasterization
|
||||
|
||||
This means that even if we reduce every node to a simple gray rectangle, we're still paying the cost of a massive GPU texture when viewing hundreds of nodes simultaneously. The texture dimensions remain the same whether it contains simple or complex content.
|
||||
|
||||
## Two Distinct Performance Concerns
|
||||
|
||||
The analysis reveals two often-conflated performance considerations that should be understood separately:
|
||||
|
||||
### 1. Rendering Performance
|
||||
|
||||
**Question:** How fast can the browser paint and composite the node graph during interactions?
|
||||
|
||||
**Traditional thinking:** Show less content → render faster
|
||||
**Reality with CSS transforms:** GPU texture size dominates performance, not content complexity
|
||||
|
||||
The CSS transform approach means that zoom, pan, and drag operations are already optimized—they're just transforming an existing GPU texture. The cost is in the initial rasterization and texture upload, which happens regardless of content complexity when texture dimensions are fixed.
|
||||
|
||||
### 2. Memory and Lifecycle Management
|
||||
|
||||
**Question:** How much memory do widget instances consume, and what's the cost of maintaining them?
|
||||
|
||||
This is where unmounting widgets might theoretically help:
|
||||
|
||||
- Complex widgets (3D viewers, chart renderers) might hold significant memory
|
||||
- Event listeners and reactive watchers consume resources
|
||||
- Some widgets might run background processes or animations
|
||||
|
||||
However, the cost of mounting/unmounting hundreds of widgets on zoom changes could create worse performance problems than the memory savings provide. Vue's virtual DOM diffing for hundreds of nodes is expensive, potentially causing noticeable lag during zoom transitions.
|
||||
|
||||
## Design Philosophy and Trade-offs
|
||||
|
||||
The current CSS-based approach makes several deliberate trade-offs:
|
||||
|
||||
### What We Optimize For
|
||||
|
||||
1. **Consistent, predictable performance** - No reactivity means no sudden performance cliffs
|
||||
2. **Smooth zoom/pan interactions** - CSS transforms are hardware-accelerated
|
||||
3. **Simple widget development** - Widget authors don't need to implement LOD logic
|
||||
4. **Reliable state preservation** - Widgets never lose state from unmounting
|
||||
|
||||
### What We Accept
|
||||
|
||||
1. **Higher baseline memory usage** - All widgets remain mounted
|
||||
2. **Less granular control** - Widgets can't optimize their own LOD behavior
|
||||
3. **Potential waste for exotic widgets** - A 3D renderer widget still runs when hidden
|
||||
|
||||
## Open Questions and Future Considerations
|
||||
|
||||
### Should widgets have any LOD control?
|
||||
|
||||
The current system provides a uniform gray rectangle fallback with CSS visibility hiding. This works for 99% of widgets, but raises questions:
|
||||
|
||||
**Scenario:** A widget renders a complex 3D scene or runs expensive computations
|
||||
**Current behavior:** Hidden via CSS but still mounted
|
||||
**Question:** Should such widgets be able to opt into unmounting at distance?
|
||||
|
||||
The challenge is that introducing selective unmounting would require:
|
||||
|
||||
- Maintaining widget state across mount/unmount cycles
|
||||
- Accepting the performance cost of remounting when zooming in
|
||||
- Adding complexity to the widget API
|
||||
|
||||
### Could we reduce GPU texture size?
|
||||
|
||||
Since texture dimensions are the limiting factor, could we:
|
||||
|
||||
- Use multiple compositor layers for different regions (chunk the transformpane)?
|
||||
- Render the nodes using the canvas fallback when 500+ nodes and < 30% zoom.
|
||||
|
||||
These approaches would require significant architectural changes and might introduce their own performance trade-offs.
|
||||
|
||||
### Is there a hybrid approach?
|
||||
|
||||
Could we identify specific threshold scenarios where reactive LOD makes sense?
|
||||
|
||||
- When node count is low (< 50 nodes)
|
||||
- For specifically registered "expensive" widgets
|
||||
- At extreme zoom levels only
|
||||
|
||||
## Implementation Guidelines
|
||||
|
||||
Given the current architecture, here's how to work within the system:
|
||||
|
||||
### For Widget Developers
|
||||
|
||||
1. **Build widgets assuming they're always visible** - Don't rely on mount/unmount for cleanup
|
||||
2. **Use CSS classes for zoom-responsive styling** - Let CSS handle visual changes
|
||||
3. **Minimize background processing** - Assume your widget is always running
|
||||
4. **Consider requestAnimationFrame throttling** - For animations that won't be visible when zoomed out
|
||||
|
||||
### For System Architects
|
||||
|
||||
1. **Monitor GPU memory usage** - The single texture approach has memory implications
|
||||
2. **Consider viewport culling** - Not rendering off-screen nodes could reduce texture size
|
||||
3. **Profile real-world workflows** - Theoretical performance differs from actual usage patterns
|
||||
4. **Document the architecture clearly** - The non-obvious performance characteristics need explanation
|
||||
|
||||
## Conclusion
|
||||
|
||||
The ComfyUI LOD system represents a pragmatic choice: accepting higher memory usage and less granular control in exchange for predictable performance and implementation simplicity. By understanding that GPU texture dimensions—not rasterization complexity—drive performance in a CSS-transform-based architecture, the team has chosen an approach that may seem counterintuitive but actually aligns with browser rendering realities.
|
||||
|
||||
The system works well for the common case of hundreds of relatively simple widgets. Edge cases involving genuinely expensive widgets may need future consideration, but the current approach provides a solid foundation that avoids the performance pitfalls of reactive LOD at scale.
|
||||
|
||||
The key insight—that showing less doesn't necessarily mean rendering faster when everything lives in a single GPU texture—challenges conventional web performance wisdom and demonstrates the importance of understanding the full rendering pipeline when making architectural decisions.
|
||||
@@ -1,34 +0,0 @@
|
||||
/**
|
||||
* Level of Detail (LOD) composable for Vue-based node rendering
|
||||
*
|
||||
* Provides dynamic quality adjustment based on zoom level to maintain
|
||||
* performance with large node graphs. Uses zoom threshold based on DPR
|
||||
* to determine how much detail to render for each node component.
|
||||
* Default minFontSize = 8px
|
||||
* Default zoomThreshold = 0.57 (On a DPR = 1 monitor)
|
||||
**/
|
||||
import { useDevicePixelRatio } from '@vueuse/core'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
|
||||
interface Camera {
|
||||
z: number // zoom level
|
||||
}
|
||||
|
||||
export function useLOD(camera: Camera) {
|
||||
const isLOD = computed(() => {
|
||||
const { pixelRatio } = useDevicePixelRatio()
|
||||
const baseFontSize = 14
|
||||
const dprAdjustment = Math.sqrt(pixelRatio.value)
|
||||
|
||||
const settingStore = useSettingStore()
|
||||
const minFontSize = settingStore.get('LiteGraph.Canvas.MinFontSizeForLOD') //default 8
|
||||
const threshold =
|
||||
Math.round((minFontSize / (baseFontSize * dprAdjustment)) * 100) / 100 //round to 2 decimal places i.e 0.86
|
||||
|
||||
return camera.z < threshold
|
||||
})
|
||||
|
||||
return { isLOD }
|
||||
}
|
||||
@@ -5,7 +5,7 @@
|
||||
>
|
||||
<!-- Display mode: Rendered markdown -->
|
||||
<div
|
||||
class="comfy-markdown-content lod-toggle size-full min-h-[60px] overflow-y-auto rounded-lg px-4 py-2 text-sm"
|
||||
class="comfy-markdown-content size-full min-h-[60px] overflow-y-auto rounded-lg text-sm"
|
||||
:class="isEditing === false ? 'visible' : 'invisible'"
|
||||
v-html="renderedHtml"
|
||||
/>
|
||||
@@ -27,7 +27,6 @@
|
||||
@click.stop
|
||||
@keydown.stop
|
||||
/>
|
||||
<LODFallback />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -38,8 +37,6 @@ import { computed, nextTick, ref } from 'vue'
|
||||
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
|
||||
import { renderMarkdownToHtml } from '@/utils/markdownRendererUtil'
|
||||
|
||||
import LODFallback from '../../components/LODFallback.vue'
|
||||
|
||||
const { widget } = defineProps<{
|
||||
widget: SimplifiedWidget<string>
|
||||
}>()
|
||||
|
||||
@@ -78,7 +78,6 @@
|
||||
@ended="playback.onPlaybackEnded"
|
||||
@loadedmetadata="playback.onMetadataLoaded"
|
||||
/>
|
||||
<LODFallback />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -91,7 +90,6 @@ import { t } from '@/i18n'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
import { useToastStore } from '@/platform/updates/common/toastStore'
|
||||
import LODFallback from '@/renderer/extensions/vueNodes/components/LODFallback.vue'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useAudioService } from '@/services/audioService'
|
||||
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
|
||||
|
||||
@@ -3,9 +3,7 @@
|
||||
<Textarea
|
||||
v-model="modelValue"
|
||||
v-bind="filteredProps"
|
||||
:class="
|
||||
cn(WidgetInputBaseClass, 'size-full text-xs lod-toggle resize-none')
|
||||
"
|
||||
:class="cn(WidgetInputBaseClass, 'size-full text-xs resize-none')"
|
||||
:placeholder="placeholder || widget.name || ''"
|
||||
:aria-label="widget.name"
|
||||
:readonly="widget.options?.read_only"
|
||||
@@ -17,7 +15,6 @@
|
||||
@pointerup.capture.stop
|
||||
@contextmenu.capture.stop
|
||||
/>
|
||||
<LODFallback />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -32,7 +29,6 @@ import {
|
||||
filterWidgetProps
|
||||
} from '@/utils/widgetPropFilter'
|
||||
|
||||
import LODFallback from '../../components/LODFallback.vue'
|
||||
import { WidgetInputBaseClass } from './layout'
|
||||
|
||||
const { widget, placeholder = '' } = defineProps<{
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
<div
|
||||
role="button"
|
||||
:tabindex="0"
|
||||
aria-label="Play/Pause"
|
||||
:aria-label="$t('g.playPause')"
|
||||
class="flex size-6 cursor-pointer items-center justify-center rounded hover:bg-interface-menu-component-surface-hovered"
|
||||
@click="togglePlayPause"
|
||||
>
|
||||
@@ -64,7 +64,7 @@
|
||||
<div
|
||||
role="button"
|
||||
:tabindex="0"
|
||||
aria-label="Volume"
|
||||
:aria-label="$t('g.volume')"
|
||||
class="flex size-6 cursor-pointer items-center justify-center rounded hover:bg-interface-menu-component-surface-hovered"
|
||||
@click="toggleMute"
|
||||
>
|
||||
@@ -85,7 +85,7 @@
|
||||
ref="optionsButtonRef"
|
||||
role="button"
|
||||
:tabindex="0"
|
||||
aria-label="More Options"
|
||||
:aria-label="$t('g.moreOptions')"
|
||||
class="flex size-6 cursor-pointer items-center justify-center rounded hover:bg-interface-menu-component-surface-hovered"
|
||||
@click="toggleOptionsMenu"
|
||||
>
|
||||
@@ -132,7 +132,6 @@
|
||||
</template>
|
||||
</TieredMenu>
|
||||
</div>
|
||||
<LODFallback />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -143,7 +142,6 @@ import { computed, nextTick, onUnmounted, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
import LODFallback from '@/renderer/extensions/vueNodes/components/LODFallback.vue'
|
||||
import { api } from '@/scripts/api'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useNodeOutputStore } from '@/stores/imagePreviewStore'
|
||||
|
||||
@@ -72,7 +72,7 @@ const searchQuery = defineModel<string>('searchQuery')
|
||||
class="absolute inset-0 flex items-center justify-center"
|
||||
>
|
||||
<i
|
||||
title="No items"
|
||||
:title="$t('g.noItems')"
|
||||
class="icon-[lucide--circle-off] size-30 text-zinc-500/20"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -67,7 +67,7 @@ function handleSortSelected(item: SortOption) {
|
||||
v-model="searchQuery"
|
||||
type="text"
|
||||
:class="resetInputStyle"
|
||||
placeholder="Search"
|
||||
:placeholder="$t('g.search')"
|
||||
/>
|
||||
</label>
|
||||
|
||||
|
||||
@@ -3,8 +3,6 @@ import { noop } from 'es-toolkit'
|
||||
|
||||
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
|
||||
|
||||
import LODFallback from '../../../components/LODFallback.vue'
|
||||
|
||||
defineProps<{
|
||||
widget: Pick<SimplifiedWidget<string | number | undefined>, 'name' | 'label'>
|
||||
}>()
|
||||
@@ -17,23 +15,21 @@ defineProps<{
|
||||
<div class="relative flex h-full min-w-0 items-center">
|
||||
<p
|
||||
v-if="widget.name"
|
||||
class="lod-toggle flex-1 truncate text-xs font-normal text-node-component-slot-text"
|
||||
class="flex-1 truncate text-xs font-normal text-node-component-slot-text"
|
||||
>
|
||||
{{ widget.label || widget.name }}
|
||||
</p>
|
||||
<LODFallback />
|
||||
</div>
|
||||
<!-- basis-full grow -->
|
||||
<div class="relative min-w-0 flex-1">
|
||||
<div
|
||||
class="lod-toggle cursor-default min-w-0"
|
||||
class="cursor-default min-w-0"
|
||||
@pointerdown.stop="noop"
|
||||
@pointermove.stop="noop"
|
||||
@pointerup.stop="noop"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
<LODFallback />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -334,7 +334,11 @@ const zSystemStats = z.object({
|
||||
required_frontend_version: z.string().optional(),
|
||||
argv: z.array(z.string()),
|
||||
ram_total: z.number(),
|
||||
ram_free: z.number()
|
||||
ram_free: z.number(),
|
||||
// Cloud-specific fields
|
||||
cloud_version: z.string().optional(),
|
||||
comfyui_frontend_version: z.string().optional(),
|
||||
workflow_templates_version: z.string().optional()
|
||||
}),
|
||||
devices: z.array(zDeviceStats)
|
||||
})
|
||||
|
||||
@@ -27,6 +27,7 @@ export const zBaseInputOptions = z
|
||||
.object({
|
||||
default: z.any().optional(),
|
||||
defaultInput: z.boolean().optional(),
|
||||
display_name: z.string().optional(),
|
||||
forceInput: z.boolean().optional(),
|
||||
tooltip: z.string().optional(),
|
||||
socketless: z.boolean().optional(),
|
||||
|
||||
@@ -95,10 +95,11 @@ export const useLitegraphService = () => {
|
||||
)
|
||||
if (widgetConstructor && !inputSpec.forceInput) return
|
||||
|
||||
node.addInput(inputName, inputSpec.type, {
|
||||
const input = node.addInput(inputName, inputSpec.type, {
|
||||
shape: inputSpec.isOptional ? RenderShape.HollowCircle : undefined,
|
||||
localized_name: st(nameKey, inputName)
|
||||
})
|
||||
input.label ??= inputSpec.display_name
|
||||
}
|
||||
/**
|
||||
* @internal Setup stroke styles for the node under various conditions.
|
||||
@@ -164,7 +165,10 @@ export const useLitegraphService = () => {
|
||||
) ?? {}
|
||||
|
||||
if (widget) {
|
||||
widget.label = st(nameKey, widget.label ?? inputName)
|
||||
widget.label = st(
|
||||
nameKey,
|
||||
widget.label ?? widgetInputSpec.display_name ?? inputName
|
||||
)
|
||||
widget.options ??= {}
|
||||
Object.assign(widget.options, {
|
||||
advanced: inputSpec.advanced,
|
||||
|
||||
@@ -2,8 +2,10 @@ import { defineStore } from 'pinia'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { useExternalLink } from '@/composables/useExternalLink'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import type { AboutPageBadge } from '@/types/comfy'
|
||||
import { electronAPI, isElectron } from '@/utils/envUtil'
|
||||
import { formatCommitHash } from '@/utils/formatUtil'
|
||||
|
||||
import { useExtensionStore } from './extensionStore'
|
||||
import { useSystemStatsStore } from './systemStatsStore'
|
||||
@@ -24,10 +26,10 @@ export const useAboutPanelStore = defineStore('aboutPanel', () => {
|
||||
label: `ComfyUI ${
|
||||
isElectron()
|
||||
? 'v' + electronAPI().getComfyUIVersion()
|
||||
: coreVersion.value
|
||||
: formatCommitHash(coreVersion.value)
|
||||
}`,
|
||||
url: staticUrls.github,
|
||||
icon: 'pi pi-github'
|
||||
url: isCloud ? staticUrls.comfyOrg : staticUrls.github,
|
||||
icon: isCloud ? 'pi pi-cloud' : 'pi pi-github'
|
||||
},
|
||||
{
|
||||
label: `ComfyUI_frontend v${frontendVersion}`,
|
||||
|
||||