Merge branch 'main' into webcam-capture

This commit is contained in:
Johnpaul Chiwetelu
2025-11-26 23:29:30 +01:00
committed by GitHub
107 changed files with 3925 additions and 3518 deletions

View 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"

View File

@@ -236,8 +236,8 @@ jobs:
PR_TITLE=$(echo "$PR_DATA" | jq -r '.title')
MERGE_COMMIT=$(echo "$PR_DATA" | jq -r '.mergeCommit.oid')
else
PR_TITLE="${{ 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

View File

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

View File

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

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 126 KiB

After

Width:  |  Height:  |  Size: 126 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 54 KiB

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 59 KiB

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 57 KiB

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 59 KiB

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 56 KiB

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 56 KiB

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 110 KiB

After

Width:  |  Height:  |  Size: 110 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 120 KiB

After

Width:  |  Height:  |  Size: 120 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 120 KiB

After

Width:  |  Height:  |  Size: 120 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 150 KiB

After

Width:  |  Height:  |  Size: 151 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 143 KiB

After

Width:  |  Height:  |  Size: 143 KiB

View File

@@ -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()
})
})

Binary file not shown.

Before

Width:  |  Height:  |  Size: 102 KiB

After

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 100 KiB

After

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 120 KiB

After

Width:  |  Height:  |  Size: 120 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 81 KiB

After

Width:  |  Height:  |  Size: 81 KiB

View File

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

View File

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

View File

@@ -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'
]
}
]

View File

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

View File

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

View File

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

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

View File

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

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 1.5 MiB

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

@@ -152,7 +152,7 @@ const {
popoverMaxWidth?: string
}>()
const selectedItem = defineModel<string | null>({ required: true })
const selectedItem = defineModel<string | undefined>({ required: true })
const { t } = useI18n()

View File

@@ -80,7 +80,7 @@
/>
<SliderControl
label="Stepsize"
:label="$t('maskEditor.stepSize')"
:min="1"
:max="100"
:step="1"

View File

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

View File

@@ -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')"
>

View File

@@ -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'
}`

View File

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

View File

@@ -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()
}

View File

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

View File

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

View File

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

View File

@@ -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'],

View File

@@ -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>()
})

View File

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

View File

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

View File

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

View File

@@ -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."
}
}
}
}

View File

@@ -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)",

View File

@@ -201,6 +201,12 @@ function handleUploadClick() {
onUploadSuccess: async () => {
await execute()
}
},
dialogComponentProps: {
pt: {
header: 'py-0! pl-0!',
content: 'p-0!'
}
}
})
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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')"
/>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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[]
}

View File

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

View File

@@ -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('.')
)

View File

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

View File

@@ -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
}
/**

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,5 +0,0 @@
<template>
<div
class="lod-fallback absolute inset-0 h-full w-full bg-node-component-widget-skeleton-surface"
></div>
</template>

View File

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

View File

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

View File

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

View File

@@ -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 dimensionsnot rasterization complexitydrive 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 insightthat showing less doesn't necessarily mean rendering faster when everything lives in a single GPU texturechallenges conventional web performance wisdom and demonstrates the importance of understanding the full rendering pipeline when making architectural decisions.

View File

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

View File

@@ -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>
}>()

View File

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

View File

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

View File

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

View File

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

View File

@@ -67,7 +67,7 @@ function handleSortSelected(item: SortOption) {
v-model="searchQuery"
type="text"
:class="resetInputStyle"
placeholder="Search"
:placeholder="$t('g.search')"
/>
</label>

View File

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

View File

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

View File

@@ -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(),

View File

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

View File

@@ -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}`,

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