Compare commits

..

15 Commits

Author SHA1 Message Date
github-actions
da7df96a27 Update locales [skip ci] 2025-05-24 13:15:23 +00:00
Terry Jia
2591d39da3 add fetch node 2025-05-24 09:03:10 -04:00
Terry Jia
674d04c9cf Export vue new (#3966)
Co-authored-by: hayden <48267247+hayden-fr@users.noreply.github.com>
2025-05-23 18:24:33 -07:00
Terry Jia
8209765eec [3d] improve mtl support logic (#3965) 2025-05-23 18:22:13 -07:00
Terry Jia
9d48638464 [3d] fix wrong generated language translation for 3d node output (#3967)
Co-authored-by: github-actions <github-actions@github.com>
2025-05-22 16:49:48 -07:00
Comfy Org PR Bot
0095f02f46 1.21.0 (#3962)
Co-authored-by: huchenlei <20929282+huchenlei@users.noreply.github.com>
2025-05-21 21:35:34 -04:00
Christian Byrne
178c79e559 [fix] Make gallery navigation buttons visible on mobile devices (#3953) 2025-05-21 21:34:13 -04:00
Christian Byrne
7c0040bfec Move user.css to user data (#3952) 2025-05-21 21:33:11 -04:00
Christian Byrne
77f91dea10 [Dev Tool] Add claude directives (#3960) 2025-05-21 21:32:18 -04:00
Christian Byrne
4ad6475283 [Feature] Add audio preview support to queue sidebar (#3954)
Co-authored-by: github-actions <github-actions@github.com>
2025-05-21 21:31:38 -04:00
Terry Jia
b531d34027 [3d] performance improve (#3961) 2025-05-21 21:29:52 -04:00
Christian Byrne
55ad207345 Trigger browser test expectations update (#3959)
Co-authored-by: github-actions <github-actions@github.com>
2025-05-21 17:20:55 -07:00
Christian Byrne
ccc1039abb [feat] Add file upload support to canvas background image setting (#3958)
Co-authored-by: github-actions <github-actions@github.com>
2025-05-21 16:06:16 -07:00
Christian Byrne
49400c69b6 Set output as background (#3079)
Co-authored-by: github-actions <github-actions@github.com>
2025-05-21 12:15:15 -04:00
Comfy Org PR Bot
32605eeb8f 1.20.4 (#3951)
Co-authored-by: huchenlei <20929282+huchenlei@users.noreply.github.com>
2025-05-20 23:03:12 -04:00
89 changed files with 1635 additions and 1478 deletions

View File

@@ -1,6 +1,8 @@
- Be sure to run unit tests, component tests, browser tests then typecheck, lint, format (with prettier) when you're done making a series of code changes. You can find the scripts for all these things in the package.json.
- use npm run to see what commands are available
- After making code changes, follow this general process: (1) Create unit tests, component tests, browser tests (if appropriate for each), (2) run unit tests, component tests, and browser tests until passing, (3) run typecheck, lint, format (with prettier) -- you can use `npm run` command to see the scripts available, (4) check if any READMEs (including nested) or documentation needs to be updated, (5) Decide whether the changes are worth adding new content to the external documentation for (or would requires changes to the external documentation) at https://docs.comfy.org, then present your suggestion
- When referencing PrimeVue, you can get all the docs here: https://primevue.org. Do this instead of making up or inferring names of Components
- When trying to set tailwind classes for dark theme, use "dark-theme:" prefix rather than "dark:"
- Never add lines to PR descriptions that say "Generated with Claude Code"
- When making PR names and commit messages, if you are going to add a prefix like "docs:", "feat:", "bugfix:", use square brackets around the prefix term and do not use a colon (e.g., should be "[docs]" rather than "docs:").
- When I reference GitHub Repos related to Comfy-Org, you should proactively fetch or read the associated information in the repo. To do so, you should exhaust all options: (1) Check if we have a local copy of the repo, (2) Use the GitHub API to fetch the information; you may want to do this IN ADDITION to the other options, especially for reading speicifc branches/PRs/comments/reviews/metadata, and (3) curl the GitHub website and parse the html or json responses
@@ -14,6 +16,8 @@
- IMPORTANT: Never add Co-Authored by Claude or any refrence to Claude or Claude Code in commit messages, PR descriptions, titles, or any documentation whatsoever
- The npm script to type check is called "typecheck" NOT "type check"
- Use the Vue 3 Composition API instead of the Options API when writing Vue components. An exception is when overriding or extending a PrimeVue component for compatibility, you may use the Options API.
- when we are solving an issue we know the link/number for, we should add "Fixes #n" (where n is the issue number) to the PR description.
- Never write css if you can accomplish the same thing with tailwind utility classes
- Use setup() function for component logic
- Utilize ref and reactive for reactive state
- Implement computed properties with computed()

View File

@@ -0,0 +1,251 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
test.describe('Background Image Upload', () => {
test.beforeEach(async ({ comfyPage }) => {
// Reset the background image setting before each test
await comfyPage.setSetting('Comfy.Canvas.BackgroundImage', '')
})
test.afterEach(async ({ comfyPage }) => {
// Clean up background image setting after each test
await comfyPage.setSetting('Comfy.Canvas.BackgroundImage', '')
})
test('should show background image upload component in settings', async ({
comfyPage
}) => {
// Open settings dialog
await comfyPage.page.keyboard.press('Control+,')
// Navigate to Appearance category
const appearanceOption = comfyPage.page.locator('text=Appearance')
await appearanceOption.click()
// Find the background image setting
const backgroundImageSetting = comfyPage.page.locator(
'#Comfy\\.Canvas\\.BackgroundImage'
)
await expect(backgroundImageSetting).toBeVisible()
// Verify the component has the expected elements using semantic selectors
const urlInput = backgroundImageSetting.locator('input[type="text"]')
await expect(urlInput).toBeVisible()
await expect(urlInput).toHaveAttribute('placeholder')
const uploadButton = backgroundImageSetting.locator(
'button:has(.pi-upload)'
)
await expect(uploadButton).toBeVisible()
const clearButton = backgroundImageSetting.locator('button:has(.pi-trash)')
await expect(clearButton).toBeVisible()
await expect(clearButton).toBeDisabled() // Should be disabled when no image
})
test('should upload image file and set as background', async ({
comfyPage
}) => {
// Open settings dialog
await comfyPage.page.keyboard.press('Control+,')
// Navigate to Appearance category
const appearanceOption = comfyPage.page.locator('text=Appearance')
await appearanceOption.click()
// Find the background image setting
const backgroundImageSetting = comfyPage.page.locator(
'#Comfy\\.Canvas\\.BackgroundImage'
)
// Click the upload button to trigger file input
const uploadButton = backgroundImageSetting.locator(
'button:has(.pi-upload)'
)
// Set up file upload handler
const fileChooserPromise = comfyPage.page.waitForEvent('filechooser')
await uploadButton.click()
const fileChooser = await fileChooserPromise
// Upload the test image
await fileChooser.setFiles(comfyPage.assetPath('image32x32.webp'))
// Wait for upload to complete and verify the setting was updated
await comfyPage.page.waitForTimeout(500) // Give time for file reading
// Verify the URL input now has an API URL
const urlInput = backgroundImageSetting.locator('input[type="text"]')
const inputValue = await urlInput.inputValue()
expect(inputValue).toMatch(/^\/api\/view\?.*subfolder=backgrounds/)
// Verify clear button is now enabled
const clearButton = backgroundImageSetting.locator('button:has(.pi-trash)')
await expect(clearButton).toBeEnabled()
// Verify the setting value was actually set
const settingValue = await comfyPage.getSetting(
'Comfy.Canvas.BackgroundImage'
)
expect(settingValue).toMatch(/^\/api\/view\?.*subfolder=backgrounds/)
})
test('should accept URL input for background image', async ({
comfyPage
}) => {
const testImageUrl = 'https://example.com/test-image.png'
// Open settings dialog
await comfyPage.page.keyboard.press('Control+,')
// Navigate to Appearance category
const appearanceOption = comfyPage.page.locator('text=Appearance')
await appearanceOption.click()
// Find the background image setting
const backgroundImageSetting = comfyPage.page.locator(
'#Comfy\\.Canvas\\.BackgroundImage'
)
// Enter URL in the input field
const urlInput = backgroundImageSetting.locator('input[type="text"]')
await urlInput.fill(testImageUrl)
// Trigger blur event to ensure the value is set
await urlInput.blur()
// Verify clear button is now enabled
const clearButton = backgroundImageSetting.locator('button:has(.pi-trash)')
await expect(clearButton).toBeEnabled()
// Verify the setting value was updated
const settingValue = await comfyPage.getSetting(
'Comfy.Canvas.BackgroundImage'
)
expect(settingValue).toBe(testImageUrl)
})
test('should clear background image when clear button is clicked', async ({
comfyPage
}) => {
const testImageUrl = 'https://example.com/test-image.png'
// First set a background image
await comfyPage.setSetting('Comfy.Canvas.BackgroundImage', testImageUrl)
// Open settings dialog
await comfyPage.page.keyboard.press('Control+,')
// Navigate to Appearance category
const appearanceOption = comfyPage.page.locator('text=Appearance')
await appearanceOption.click()
// Find the background image setting
const backgroundImageSetting = comfyPage.page.locator(
'#Comfy\\.Canvas\\.BackgroundImage'
)
// Verify the input has the test URL
const urlInput = backgroundImageSetting.locator('input[type="text"]')
await expect(urlInput).toHaveValue(testImageUrl)
// Verify clear button is enabled
const clearButton = backgroundImageSetting.locator('button:has(.pi-trash)')
await expect(clearButton).toBeEnabled()
// Click the clear button
await clearButton.click()
// Verify the input is now empty
await expect(urlInput).toHaveValue('')
// Verify clear button is now disabled
await expect(clearButton).toBeDisabled()
// Verify the setting value was cleared
const settingValue = await comfyPage.getSetting(
'Comfy.Canvas.BackgroundImage'
)
expect(settingValue).toBe('')
})
test('should show tooltip on upload and clear buttons', async ({
comfyPage
}) => {
// Open settings dialog
await comfyPage.page.keyboard.press('Control+,')
// Navigate to Appearance category
const appearanceOption = comfyPage.page.locator('text=Appearance')
await appearanceOption.click()
// Find the background image setting
const backgroundImageSetting = comfyPage.page.locator(
'#Comfy\\.Canvas\\.BackgroundImage'
)
// Hover over upload button and verify tooltip appears
const uploadButton = backgroundImageSetting.locator(
'button:has(.pi-upload)'
)
await uploadButton.hover()
// Wait for tooltip to appear and verify it exists
await comfyPage.page.waitForTimeout(700) // Tooltip delay
const uploadTooltip = comfyPage.page.locator('.p-tooltip:visible')
await expect(uploadTooltip).toBeVisible()
// Move away to hide tooltip
await comfyPage.page.locator('body').hover()
await comfyPage.page.waitForTimeout(100)
// Set a background to enable clear button
const urlInput = backgroundImageSetting.locator('input[type="text"]')
await urlInput.fill('https://example.com/test.png')
await urlInput.blur()
// Hover over clear button and verify tooltip appears
const clearButton = backgroundImageSetting.locator('button:has(.pi-trash)')
await clearButton.hover()
// Wait for tooltip to appear and verify it exists
await comfyPage.page.waitForTimeout(700) // Tooltip delay
const clearTooltip = comfyPage.page.locator('.p-tooltip:visible')
await expect(clearTooltip).toBeVisible()
})
test('should maintain reactive updates between URL input and clear button state', async ({
comfyPage
}) => {
// Open settings dialog
await comfyPage.page.keyboard.press('Control+,')
// Navigate to Appearance category
const appearanceOption = comfyPage.page.locator('text=Appearance')
await appearanceOption.click()
// Find the background image setting
const backgroundImageSetting = comfyPage.page.locator(
'#Comfy\\.Canvas\\.BackgroundImage'
)
const urlInput = backgroundImageSetting.locator('input[type="text"]')
const clearButton = backgroundImageSetting.locator('button:has(.pi-trash)')
// Initially clear button should be disabled
await expect(clearButton).toBeDisabled()
// Type some text - clear button should become enabled
await urlInput.fill('test')
await expect(clearButton).toBeEnabled()
// Clear the text manually - clear button should become disabled again
await urlInput.fill('')
await expect(clearButton).toBeDisabled()
// Add text again - clear button should become enabled
await urlInput.fill('https://example.com/image.png')
await expect(clearButton).toBeEnabled()
// Use clear button - should clear input and disable itself
await clearButton.click()
await expect(urlInput).toHaveValue('')
await expect(clearButton).toBeDisabled()
})
})

Binary file not shown.

Before

Width:  |  Height:  |  Size: 193 KiB

After

Width:  |  Height:  |  Size: 191 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 245 KiB

After

Width:  |  Height:  |  Size: 239 KiB

View File

@@ -1,59 +0,0 @@
import { Plugin } from 'vite'
/**
* Vite plugin that adds an alias export for Vue's createBaseVNode as createElementVNode.
*
* This plugin addresses compatibility issues where some components or libraries
* might be using the older createElementVNode function name instead of createBaseVNode.
* It modifies the Vue vendor chunk during build to add the alias export.
*
* @returns {Plugin} A Vite plugin that modifies the Vue vendor chunk exports
*/
export function addElementVnodeExportPlugin(): Plugin {
return {
name: 'add-element-vnode-export-plugin',
renderChunk(code, chunk, _options) {
if (chunk.name.startsWith('vendor-vue')) {
const exportRegex = /(export\s*\{)([^}]*)(\}\s*;?\s*)$/
const match = code.match(exportRegex)
if (match) {
const existingExports = match[2].trim()
const exportsArray = existingExports
.split(',')
.map((e) => e.trim())
.filter(Boolean)
const hasCreateBaseVNode = exportsArray.some((e) =>
e.startsWith('createBaseVNode')
)
const hasCreateElementVNode = exportsArray.some((e) =>
e.includes('createElementVNode')
)
if (hasCreateBaseVNode && !hasCreateElementVNode) {
const newExportStatement = `${match[1]} ${existingExports ? existingExports + ',' : ''} createBaseVNode as createElementVNode ${match[3]}`
const newCode = code.replace(exportRegex, newExportStatement)
console.log(
`[add-element-vnode-export-plugin] Added 'createBaseVNode as createElementVNode' export to vendor-vue chunk.`
)
return { code: newCode, map: null }
} else if (!hasCreateBaseVNode) {
console.warn(
`[add-element-vnode-export-plugin] Warning: 'createBaseVNode' not found in exports of vendor-vue chunk. Cannot add alias.`
)
}
} else {
console.warn(
`[add-element-vnode-export-plugin] Warning: Could not find expected export block format in vendor-vue chunk.`
)
}
}
return null
}
}
}

View File

@@ -1,9 +1,24 @@
import type { OutputOptions } from 'rollup'
import { HtmlTagDescriptor, Plugin } from 'vite'
import glob from 'fast-glob'
import fs from 'fs-extra'
import { dirname, join } from 'node:path'
import { HtmlTagDescriptor, Plugin, normalizePath } from 'vite'
interface VendorLibrary {
interface ImportMapSource {
name: string
pattern: RegExp
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 []
}
/**
@@ -23,53 +38,89 @@ interface VendorLibrary {
* @returns {Plugin} A Vite plugin that generates and injects an import map
*/
export function generateImportMapPlugin(
vendorLibraries: VendorLibrary[]
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 = {}
}
const outputOptions: OutputOptions = {
manualChunks: (id: string) => {
for (const lib of vendorLibraries) {
if (lib.pattern.test(id)) {
return `vendor-${lib.name}`
}
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)
}
return null
},
// Disable minification of internal exports to preserve function names
minifyInternalExports: false
}
}
config.build.rollupOptions.output = outputOptions
const external: (string | RegExp)[] = []
for (const [, source] of resolvedImportMapSources) {
external.push(source.pattern)
}
config.build.rollupOptions.external = external
}
},
generateBundle(_options, bundle) {
for (const fileName in bundle) {
const chunk = bundle[fileName]
if (chunk.type === 'chunk' && !chunk.isEntry) {
// Find matching vendor library by chunk name
const vendorLib = vendorLibraries.find(
(lib) => chunk.name === `vendor-${lib.name}`
)
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)
if (vendorLib) {
const relativePath = `./${chunk.fileName.replace(/\\/g, '/')}`
importMapEntries[vendorLib.name] = relativePath
importMapEntries[source.name] =
'./' + normalizePath(join(assetDir, moduleFile))
console.log(
`[ImportMap Plugin] Found chunk: ${chunk.name} -> Mapped '${vendorLib.name}' to '${relativePath}'`
)
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)
}
}
}

View File

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

View File

@@ -4,8 +4,9 @@
<meta charset="UTF-8">
<title>ComfyUI</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
<link rel="stylesheet" type="text/css" href="user.css" />
<link rel="stylesheet" type="text/css" href="materialdesignicons.min.css" />
<link rel="stylesheet" type="text/css" href="user.css" />
<link rel="stylesheet" type="text/css" href="api/userdata/user.css" />
<!-- Fullscreen mode on iOS -->
<meta name="apple-mobile-web-app-capable" content="yes">

12
package-lock.json generated
View File

@@ -1,18 +1,18 @@
{
"name": "@comfyorg/comfyui-frontend",
"version": "1.20.3",
"version": "1.21.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@comfyorg/comfyui-frontend",
"version": "1.20.3",
"version": "1.21.0",
"license": "GPL-3.0-only",
"dependencies": {
"@alloc/quick-lru": "^5.2.0",
"@atlaskit/pragmatic-drag-and-drop": "^1.3.1",
"@comfyorg/comfyui-electron-types": "^0.4.43",
"@comfyorg/litegraph": "^0.16.0-sub.5",
"@comfyorg/litegraph": "^0.15.11",
"@primevue/forms": "^4.2.5",
"@primevue/themes": "^4.2.5",
"@sentry/vue": "^8.48.0",
@@ -788,9 +788,9 @@
"license": "GPL-3.0-only"
},
"node_modules/@comfyorg/litegraph": {
"version": "0.16.0-sub.5",
"resolved": "https://registry.npmjs.org/@comfyorg/litegraph/-/litegraph-0.16.0-sub.5.tgz",
"integrity": "sha512-EgJpmSXOlr/zk22gi2WrOzpxh27yE0B5/nCrCXJP95UJRYPb+1pgdT28rSYYi5KrniVYUrAknQrqF0taqQxwMg==",
"version": "0.15.11",
"resolved": "https://registry.npmjs.org/@comfyorg/litegraph/-/litegraph-0.15.11.tgz",
"integrity": "sha512-gU8KK9cid7dXSK1yh3ReUolG0HGT3piKgKLd8YDr21PWl64pQvzy8BIh7W1vKH8ZWictKmNBaG9IRKlsJ667Zw==",
"license": "MIT"
},
"node_modules/@cspotcode/source-map-support": {

View File

@@ -1,7 +1,7 @@
{
"name": "@comfyorg/comfyui-frontend",
"private": true,
"version": "1.20.3",
"version": "1.21.0",
"type": "module",
"repository": "https://github.com/Comfy-Org/ComfyUI_frontend",
"homepage": "https://comfy.org",
@@ -74,7 +74,7 @@
"@alloc/quick-lru": "^5.2.0",
"@atlaskit/pragmatic-drag-and-drop": "^1.3.1",
"@comfyorg/comfyui-electron-types": "^0.4.43",
"@comfyorg/litegraph": "^0.16.0-sub.5",
"@comfyorg/litegraph": "^0.15.11",
"@primevue/forms": "^4.2.5",
"@primevue/themes": "^4.2.5",
"@sentry/vue": "^8.48.0",

View File

@@ -1,5 +1,8 @@
<template>
<div v-if="workflowStore.isSubgraphActive" class="p-2 subgraph-breadcrumb">
<div
v-if="workflowStore.isSubgraphActive"
class="fixed top-[var(--comfy-topbar-height)] left-[var(--sidebar-width)] p-2 subgraph-breadcrumb"
>
<Breadcrumb
class="bg-transparent"
:home="home"
@@ -11,30 +14,28 @@
</template>
<script setup lang="ts">
import { useEventListener } from '@vueuse/core'
import { useEventListener, whenever } from '@vueuse/core'
import Breadcrumb from 'primevue/breadcrumb'
import type { MenuItem, MenuItemCommandEvent } from 'primevue/menuitem'
import { computed } from 'vue'
import { useWorkflowService } from '@/services/workflowService'
import { useCanvasStore } from '@/stores/graphStore'
import { useSubgraphNavigationStore } from '@/stores/subgraphNavigationStore'
import { useWorkflowStore } from '@/stores/workflowStore'
const workflowService = useWorkflowService()
const workflowStore = useWorkflowStore()
const navigationStore = useSubgraphNavigationStore()
const workflowName = computed(() => workflowStore.activeWorkflow?.filename)
const items = computed(() => {
if (!navigationStore.navigationStack.length) return []
if (!workflowStore.subgraphNamePath.length) return []
return navigationStore.navigationStack.map<MenuItem>((subgraph) => ({
label: subgraph.name,
command: () => {
const canvas = useCanvasStore().getCanvas()
if (!canvas.graph) throw new TypeError('Canvas has no graph')
canvas.setGraph(subgraph)
return workflowStore.subgraphNamePath.map<MenuItem>((name) => ({
label: name,
command: async () => {
const workflow = workflowStore.getWorkflowByPath(name)
if (workflow) await workflowService.openWorkflow(workflow)
}
}))
})
@@ -42,7 +43,7 @@ const items = computed(() => {
const home = computed(() => ({
label: workflowName.value,
icon: 'pi pi-home',
command: () => {
command: async () => {
const canvas = useCanvasStore().getCanvas()
if (!canvas.graph) throw new TypeError('Canvas has no graph')
@@ -54,17 +55,14 @@ const handleItemClick = (event: MenuItemCommandEvent) => {
event.item.command?.(event)
}
// Escape exits from the current subgraph.
useEventListener(document, 'keydown', (event) => {
if (event.key === 'Escape') {
const canvas = useCanvasStore().getCanvas()
if (!canvas.graph) throw new TypeError('Canvas has no graph')
canvas.setGraph(
navigationStore.navigationStack.at(-2) ?? canvas.graph.rootGraph
)
whenever(
() => useCanvasStore().canvas,
(canvas) => {
useEventListener(canvas.canvas, 'litegraph:set-graph', () => {
useWorkflowStore().updateActiveGraph()
})
}
})
)
</script>
<style>

View File

@@ -0,0 +1,103 @@
<template>
<div class="flex gap-2">
<InputText
v-model="modelValue"
class="flex-1"
:placeholder="$t('g.imageUrl')"
/>
<Button
v-tooltip="$t('g.upload')"
:icon="isUploading ? 'pi pi-spin pi-spinner' : 'pi pi-upload'"
size="small"
:disabled="isUploading"
@click="triggerFileInput"
/>
<Button
v-tooltip="$t('g.clear')"
outlined
icon="pi pi-trash"
severity="danger"
size="small"
:disabled="!modelValue"
@click="clearImage"
/>
<input
ref="fileInput"
type="file"
class="hidden"
accept="image/*"
@change="handleFileUpload"
/>
</div>
</template>
<script setup lang="ts">
import Button from 'primevue/button'
import InputText from 'primevue/inputtext'
import { ref } from 'vue'
import { api } from '@/scripts/api'
import { useToastStore } from '@/stores/toastStore'
const modelValue = defineModel<string>()
const fileInput = ref<HTMLInputElement | null>(null)
const isUploading = ref(false)
const triggerFileInput = () => {
fileInput.value?.click()
}
const uploadFile = async (file: File): Promise<string | null> => {
const body = new FormData()
body.append('image', file)
body.append('subfolder', 'backgrounds')
const resp = await api.fetchApi('/upload/image', {
method: 'POST',
body
})
if (resp.status !== 200) {
useToastStore().addAlert(
`Upload failed: ${resp.status} - ${resp.statusText}`
)
return null
}
const data = await resp.json()
return data.subfolder ? `${data.subfolder}/${data.name}` : data.name
}
const handleFileUpload = async (event: Event) => {
const target = event.target as HTMLInputElement
if (target.files && target.files[0]) {
const file = target.files[0]
isUploading.value = true
try {
const uploadedPath = await uploadFile(file)
if (uploadedPath) {
// Set the value to the API view URL with subfolder parameter
const params = new URLSearchParams({
filename: uploadedPath,
type: 'input',
subfolder: 'backgrounds'
})
modelValue.value = `/api/view?${params.toString()}`
}
} catch (error) {
useToastStore().addAlert(`Upload error: ${String(error)}`)
} finally {
isUploading.value = false
}
}
}
const clearImage = () => {
modelValue.value = ''
if (fileInput.value) {
fileInput.value.value = ''
}
}
</script>

View File

@@ -36,6 +36,7 @@ import Select from 'primevue/select'
import ToggleSwitch from 'primevue/toggleswitch'
import { type Component, markRaw } from 'vue'
import BackgroundImageUpload from '@/components/common/BackgroundImageUpload.vue'
import CustomFormValue from '@/components/common/CustomFormValue.vue'
import FormColorPicker from '@/components/common/FormColorPicker.vue'
import FormImageUpload from '@/components/common/FormImageUpload.vue'
@@ -102,6 +103,8 @@ function getFormComponent(item: FormItem): Component {
return FormColorPicker
case 'url':
return UrlInput
case 'backgroundImage':
return BackgroundImageUpload
default:
return InputText
}

View File

@@ -21,14 +21,16 @@ import { useDomWidgetStore } from '@/stores/domWidgetStore'
import { useCanvasStore } from '@/stores/graphStore'
const domWidgetStore = useDomWidgetStore()
const widgetStates = computed(() => domWidgetStore.activeWidgetStates)
const widgetStates = computed(() =>
Array.from(domWidgetStore.widgetStates.values())
)
const updateWidgets = () => {
const lgCanvas = canvasStore.canvas
if (!lgCanvas) return
const lowQuality = lgCanvas.low_quality
for (const widgetState of widgetStates.value) {
for (const widgetState of domWidgetStore.widgetStates.values()) {
const widget = widgetState.widget
const node = widget.node as LGraphNode

View File

@@ -12,12 +12,10 @@
<BottomPanel />
</template>
<template #graph-canvas-panel>
<div class="absolute top-0 left-0 w-auto max-w-full pointer-events-auto">
<SecondRowWorkflowTabs
v-if="workflowTabsPosition === 'Topbar (2nd-row)'"
/>
<SubgraphBreadcrumb />
</div>
<SecondRowWorkflowTabs
v-if="workflowTabsPosition === 'Topbar (2nd-row)'"
class="pointer-events-auto"
/>
<GraphCanvasMenu v-if="canvasMenuEnabled" class="pointer-events-auto" />
</template>
</LiteGraphCanvasSplitterOverlay>
@@ -41,11 +39,12 @@
</SelectionOverlay>
<DomWidgets />
</template>
<SubgraphBreadcrumb />
</template>
<script setup lang="ts">
import type { LGraphNode } from '@comfyorg/litegraph'
import { useEventListener, whenever } from '@vueuse/core'
import { useEventListener } from '@vueuse/core'
import { computed, onMounted, ref, watch, watchEffect } from 'vue'
import LiteGraphCanvasSplitterOverlay from '@/components/LiteGraphCanvasSplitterOverlay.vue'
@@ -85,7 +84,6 @@ import { useCanvasStore } from '@/stores/graphStore'
import { useNodeDefStore } from '@/stores/nodeDefStore'
import { useSettingStore } from '@/stores/settingStore'
import { useToastStore } from '@/stores/toastStore'
import { useWorkflowStore } from '@/stores/workflowStore'
import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
import { useWorkspaceStore } from '@/stores/workspaceStore'
@@ -170,6 +168,20 @@ watch(
await colorPaletteService.loadColorPalette(currentPaletteId)
}
)
watch(
() => settingStore.get('Comfy.Canvas.BackgroundImage'),
async () => {
if (!canvasStore.canvas) return
const currentPaletteId = colorPaletteStore.activePaletteId
if (!currentPaletteId) return
// Reload color palette to apply background image
await colorPaletteService.loadColorPalette(currentPaletteId)
// Mark background canvas as dirty
canvasStore.canvas.setDirty(false, true)
}
)
watch(
() => colorPaletteStore.activePaletteId,
async (newValue) => {
@@ -322,16 +334,6 @@ onMounted(async () => {
}
)
whenever(
() => useCanvasStore().canvas,
(canvas) => {
useEventListener(canvas.canvas, 'litegraph:set-graph', () => {
useWorkflowStore().updateActiveGraph()
})
},
{ immediate: true }
)
emit('ready')
})
</script>

View File

@@ -11,7 +11,6 @@
<BypassButton />
<PinButton />
<MaskEditorButton />
<ConvertToSubgraphButton />
<DeleteButton />
<RefreshButton />
<ExtensionCommandButton
@@ -28,7 +27,6 @@ import { computed } from 'vue'
import BypassButton from '@/components/graph/selectionToolbox/BypassButton.vue'
import ColorPickerButton from '@/components/graph/selectionToolbox/ColorPickerButton.vue'
import ConvertToSubgraphButton from '@/components/graph/selectionToolbox/ConvertToSubgraphButton.vue'
import DeleteButton from '@/components/graph/selectionToolbox/DeleteButton.vue'
import ExecuteButton from '@/components/graph/selectionToolbox/ExecuteButton.vue'
import ExtensionCommandButton from '@/components/graph/selectionToolbox/ExtensionCommandButton.vue'

View File

@@ -1,22 +0,0 @@
<template>
<Button
v-tooltip.top="{
value: t('commands.Comfy_Graph_ConvertToSubgraph.label'),
showDelay: 1000
}"
severity="secondary"
text
icon="pi pi-box"
@click="() => commandStore.execute('Comfy.Graph.ConvertToSubgraph')"
/>
</template>
<script setup lang="ts">
import Button from 'primevue/button'
import { useI18n } from 'vue-i18n'
import { useCommandStore } from '@/stores/commandStore'
const { t } = useI18n()
const commandStore = useCommandStore()
</script>

View File

@@ -1,6 +1,5 @@
<template>
<Button
v-show="isDeletable"
v-tooltip.top="{
value: t('commands.Comfy_Canvas_DeleteSelectedItems.label'),
showDelay: 1000
@@ -14,17 +13,10 @@
<script setup lang="ts">
import Button from 'primevue/button'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { useCommandStore } from '@/stores/commandStore'
import { useCanvasStore } from '@/stores/graphStore'
const { t } = useI18n()
const commandStore = useCommandStore()
const canvasStore = useCanvasStore()
const isDeletable = computed(() =>
canvasStore.selectedItems.some((x) => x.removable !== false)
)
</script>

View File

@@ -25,9 +25,8 @@ const commandStore = useCommandStore()
const canvasStore = useCanvasStore()
const isSingleImageNode = computed(() => {
const { selectedItems } = canvasStore
const item = selectedItems[0]
return selectedItems.length === 1 && isLGraphNode(item) && isImageNode(item)
const nodes = canvasStore.selectedItems.filter(isLGraphNode)
return nodes.length === 1 && nodes.some(isImageNode)
})
const openMaskEditor = () => {

View File

@@ -6,7 +6,7 @@
<script setup lang="ts">
import { LGraphNode } from '@comfyorg/litegraph'
import { onMounted, onUnmounted, ref, toRaw, watch, watchEffect } from 'vue'
import { onMounted, onUnmounted, ref, toRaw, watch } from 'vue'
import LoadingOverlay from '@/components/load3d/LoadingOverlay.vue'
import Load3d from '@/extensions/core/load3d/Load3d'
@@ -76,18 +76,71 @@ const eventConfig = {
emit('recordingStatusChange', value)
} as const
watchEffect(() => {
if (load3d.value) {
const rawLoad3d = toRaw(load3d.value) as Load3d
watch(
() => props.showPreview,
(newValue) => {
if (load3d.value) {
const rawLoad3d = toRaw(load3d.value) as Load3d
rawLoad3d.setBackgroundColor(props.backgroundColor)
rawLoad3d.toggleGrid(props.showGrid)
rawLoad3d.setLightIntensity(props.lightIntensity)
rawLoad3d.setFOV(props.fov)
rawLoad3d.toggleCamera(props.cameraType)
rawLoad3d.togglePreview(props.showPreview)
rawLoad3d.togglePreview(newValue)
}
}
})
)
watch(
() => props.cameraType,
(newValue) => {
if (load3d.value) {
const rawLoad3d = toRaw(load3d.value) as Load3d
rawLoad3d.toggleCamera(newValue)
}
}
)
watch(
() => props.fov,
(newValue) => {
if (load3d.value) {
const rawLoad3d = toRaw(load3d.value) as Load3d
rawLoad3d.setFOV(newValue)
}
}
)
watch(
() => props.lightIntensity,
(newValue) => {
if (load3d.value) {
const rawLoad3d = toRaw(load3d.value) as Load3d
rawLoad3d.setLightIntensity(newValue)
}
}
)
watch(
() => props.showGrid,
(newValue) => {
if (load3d.value) {
const rawLoad3d = toRaw(load3d.value) as Load3d
rawLoad3d.toggleGrid(newValue)
}
}
)
watch(
() => props.backgroundColor,
(newValue) => {
if (load3d.value) {
const rawLoad3d = toRaw(load3d.value) as Load3d
rawLoad3d.setBackgroundColor(newValue)
}
}
)
watch(
() => props.backgroundImage,
@@ -164,12 +217,13 @@ const handleEvents = (action: 'add' | 'remove') => {
}
onMounted(() => {
load3d.value = useLoad3dService().registerLoad3d(
node.value as LGraphNode,
// @ts-expect-error fixme ts strict error
container.value,
props.inputSpec
)
if (container.value) {
load3d.value = useLoad3dService().registerLoad3d(
node.value as LGraphNode,
container.value,
props.inputSpec
)
}
handleEvents('add')
})

View File

@@ -95,14 +95,12 @@ const addNode = (nodeDef: ComfyNodeDefImpl) => {
return
}
disconnectOnReset = false
const node = litegraphService.addNodeOnGraph(nodeDef, {
pos: getNewNodeLocation()
})
if (disconnectOnReset) {
canvasStore.getCanvas().linkConnector.connectToNode(node, triggerEvent)
}
disconnectOnReset = false
canvasStore.getCanvas().linkConnector.connectToNode(node, triggerEvent)
// Notify changeTracker - new step should be added
useWorkflowStore().activeWorkflow?.changeTracker?.checkState()

View File

@@ -197,30 +197,46 @@ const confirmRemoveAll = (event: Event) => {
const menu = ref<InstanceType<typeof ContextMenu> | null>(null)
const menuTargetTask = ref<TaskItemImpl | null>(null)
const menuTargetNode = ref<ComfyNode | null>(null)
const menuItems = computed<MenuItem[]>(() => [
{
label: t('g.delete'),
icon: 'pi pi-trash',
command: () => menuTargetTask.value && removeTask(menuTargetTask.value),
disabled: isExpanded.value || isInFolderView.value
},
{
label: t('g.loadWorkflow'),
icon: 'pi pi-file-export',
command: () => menuTargetTask.value?.loadWorkflow(app),
disabled: !menuTargetTask.value?.workflow
},
{
label: t('g.goToNode'),
icon: 'pi pi-arrow-circle-right',
command: () => {
if (!menuTargetNode.value) return
useLitegraphService().goToNode(menuTargetNode.value.id)
const menuItems = computed<MenuItem[]>(() => {
const items: MenuItem[] = [
{
label: t('g.delete'),
icon: 'pi pi-trash',
command: () => menuTargetTask.value && removeTask(menuTargetTask.value),
disabled: isExpanded.value || isInFolderView.value
},
visible: !!menuTargetNode.value
{
label: t('g.loadWorkflow'),
icon: 'pi pi-file-export',
command: () => menuTargetTask.value?.loadWorkflow(app),
disabled: !menuTargetTask.value?.workflow
},
{
label: t('g.goToNode'),
icon: 'pi pi-arrow-circle-right',
command: () => {
if (!menuTargetNode.value) return
useLitegraphService().goToNode(menuTargetNode.value.id)
},
visible: !!menuTargetNode.value
}
]
if (menuTargetTask.value?.previewOutput?.mediaType === 'images') {
items.push({
label: t('g.setAsBackground'),
icon: 'pi pi-image',
command: () => {
const url = menuTargetTask.value?.previewOutput?.url
if (url) {
void settingStore.set('Comfy.Canvas.BackgroundImage', url)
}
}
})
}
])
return items
})
const handleContextMenu = ({
task,

View File

@@ -0,0 +1,19 @@
<template>
<audio controls width="100%" height="100%">
<source :src="url" :type="htmlAudioType" />
{{ $t('g.audioFailedToLoad') }}
</audio>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { ResultItemImpl } from '@/stores/queueStore'
const { result } = defineProps<{
result: ResultItemImpl
}>()
const url = computed(() => result.url)
const htmlAudioType = computed(() => result.htmlAudioType)
</script>

View File

@@ -0,0 +1,177 @@
import { mount } from '@vue/test-utils'
import PrimeVue from 'primevue/config'
import Galleria from 'primevue/galleria'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { createApp, nextTick } from 'vue'
import type { NodeId } from '@/schemas/comfyWorkflowSchema'
import type { ResultItemImpl } from '@/stores/queueStore'
import ResultGallery from './ResultGallery.vue'
type MockResultItem = Partial<ResultItemImpl> & {
filename: string
subfolder: string
type: string
nodeId: NodeId
mediaType: string
id?: string
url?: string
isImage?: boolean
isVideo?: boolean
}
describe('ResultGallery', () => {
// Mock ComfyImage and ResultVideo components
const mockComfyImage = {
name: 'ComfyImage',
template: '<div class="mock-comfy-image" data-testid="comfy-image"></div>',
props: ['src', 'contain', 'alt']
}
const mockResultVideo = {
name: 'ResultVideo',
template:
'<div class="mock-result-video" data-testid="result-video"></div>',
props: ['result']
}
// Sample gallery items - using mock instances with only required properties
const mockGalleryItems: MockResultItem[] = [
{
filename: 'image1.jpg',
subfolder: 'outputs',
type: 'output',
nodeId: '123' as NodeId,
mediaType: 'images',
isImage: true,
isVideo: false,
url: 'image1.jpg',
id: '1'
},
{
filename: 'image2.jpg',
subfolder: 'outputs',
type: 'output',
nodeId: '456' as NodeId,
mediaType: 'images',
isImage: true,
isVideo: false,
url: 'image2.jpg',
id: '2'
}
]
beforeEach(() => {
const app = createApp({})
app.use(PrimeVue)
// Create mock elements for Galleria to find
document.body.innerHTML = `
<div id="app"></div>
`
})
afterEach(() => {
// Clean up any elements added to body
document.body.innerHTML = ''
vi.restoreAllMocks()
})
const mountGallery = (props = {}) => {
return mount(ResultGallery, {
global: {
plugins: [PrimeVue],
components: {
Galleria,
ComfyImage: mockComfyImage,
ResultVideo: mockResultVideo
},
stubs: {
teleport: true
}
},
props: {
allGalleryItems: mockGalleryItems as unknown as ResultItemImpl[],
activeIndex: 0,
...props
},
attachTo: document.getElementById('app') || undefined
})
}
it('renders Galleria component with correct props', async () => {
const wrapper = mountGallery()
await nextTick() // Wait for component to mount
const galleria = wrapper.findComponent(Galleria)
expect(galleria.exists()).toBe(true)
expect(galleria.props('value')).toEqual(mockGalleryItems)
expect(galleria.props('showIndicators')).toBe(false)
expect(galleria.props('showItemNavigators')).toBe(true)
expect(galleria.props('fullScreen')).toBe(true)
})
it('shows gallery when activeIndex changes from -1', async () => {
const wrapper = mountGallery({ activeIndex: -1 })
// Initially galleryVisible should be false
const vm: any = wrapper.vm
expect(vm.galleryVisible).toBe(false)
// Change activeIndex
await wrapper.setProps({ activeIndex: 0 })
await nextTick()
// galleryVisible should become true
expect(vm.galleryVisible).toBe(true)
})
it('should render the component properly', () => {
// This is a meta-test to confirm the component mounts properly
const wrapper = mountGallery()
// We can't directly test the compiled CSS, but we can verify the component renders
expect(wrapper.exists()).toBe(true)
// Verify that the Galleria component exists and is properly mounted
const galleria = wrapper.findComponent(Galleria)
expect(galleria.exists()).toBe(true)
})
it('ensures correct configuration for mobile viewport', async () => {
// Mock window.matchMedia to simulate mobile viewport
Object.defineProperty(window, 'matchMedia', {
writable: true,
value: vi.fn().mockImplementation((query) => ({
matches: query.includes('max-width: 768px'),
media: query,
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn()
}))
})
const wrapper = mountGallery()
await nextTick()
// Verify mobile media query is working
expect(window.matchMedia('(max-width: 768px)').matches).toBe(true)
// Check if the component renders with Galleria
const galleria = wrapper.findComponent(Galleria)
expect(galleria.exists()).toBe(true)
// Check that our PT props for positioning work correctly
const pt = galleria.props('pt') as any
expect(pt?.prevButton?.style).toContain('position: fixed')
expect(pt?.nextButton?.style).toContain('position: fixed')
})
// Additional tests for interaction could be added once we can reliably
// test Galleria component in fullscreen mode
})

View File

@@ -35,6 +35,7 @@
class="galleria-image"
/>
<ResultVideo v-else-if="item.isVideo" :result="item" />
<ResultAudio v-else-if="item.isAudio" :result="item" />
</template>
</Galleria>
</template>
@@ -46,6 +47,7 @@ import { onMounted, onUnmounted, ref, watch } from 'vue'
import ComfyImage from '@/components/common/ComfyImage.vue'
import { ResultItemImpl } from '@/stores/queueStore'
import ResultAudio from './ResultAudio.vue'
import ResultVideo from './ResultVideo.vue'
const galleryVisible = ref(false)
@@ -142,4 +144,12 @@ img.galleria-image {
/* Set z-index so the close button doesn't get hidden behind the image when image is large */
z-index: 1;
}
/* Mobile/tablet specific fixes */
@media screen and (max-width: 768px) {
.p-galleria-prev-button,
.p-galleria-next-button {
z-index: 2;
}
}
</style>

View File

@@ -12,6 +12,7 @@
:alt="result.filename"
/>
<ResultVideo v-else-if="result.isVideo" :result="result" />
<ResultAudio v-else-if="result.isAudio" :result="result" />
<div v-else class="task-result-preview">
<i class="pi pi-file" />
<span>{{ result.mediaType }}</span>
@@ -26,6 +27,7 @@ import ComfyImage from '@/components/common/ComfyImage.vue'
import { ResultItemImpl } from '@/stores/queueStore'
import { useSettingStore } from '@/stores/settingStore'
import ResultAudio from './ResultAudio.vue'
import ResultVideo from './ResultVideo.vue'
const props = defineProps<{

View File

@@ -1,5 +1,5 @@
<template>
<div class="w-auto max-w-full">
<div class="absolute top-0 left-0 w-auto max-w-full">
<WorkflowTabs />
</div>
</template>

View File

@@ -17,7 +17,7 @@ import { useDialogService } from '@/services/dialogService'
import { useLitegraphService } from '@/services/litegraphService'
import { useWorkflowService } from '@/services/workflowService'
import type { ComfyCommand } from '@/stores/commandStore'
import { useCanvasStore, useTitleEditorStore } from '@/stores/graphStore'
import { useTitleEditorStore } from '@/stores/graphStore'
import { useQueueSettingsStore, useQueueStore } from '@/stores/queueStore'
import { useSettingStore } from '@/stores/settingStore'
import { useToastStore } from '@/stores/toastStore'
@@ -34,7 +34,6 @@ export function useCoreCommands(): ComfyCommand[] {
const colorPaletteStore = useColorPaletteStore()
const firebaseAuthActions = useFirebaseAuthActions()
const toastStore = useToastStore()
const canvasStore = useCanvasStore()
const getTracker = () => workflowStore.activeWorkflow?.changeTracker
const getSelectedNodes = (): LGraphNode[] => {
@@ -674,30 +673,6 @@ export function useCoreCommands(): ComfyCommand[] {
function: async () => {
await firebaseAuthActions.logout()
}
},
{
id: 'Comfy.Graph.ConvertToSubgraph',
icon: 'pi pi-sitemap',
label: 'Convert Selection to Subgraph',
versionAdded: '1.20.1',
function: () => {
const canvas = canvasStore.getCanvas()
const graph = canvas.subgraph ?? canvas.graph
if (!graph) throw new TypeError('Canvas has no graph or subgraph set.')
const res = graph.convertToSubgraph(canvas.selectedItems)
if (!res) {
toastStore.add({
severity: 'error',
summary: t('toastMessages.cannotCreateSubgraph'),
detail: t('toastMessages.failedToConvertToSubgraph'),
life: 3000
})
return
}
const { node } = res
canvas.select(node)
}
}
]

View File

@@ -28,8 +28,7 @@ export const useTextPreviewWidget = (
setValue: (value: string | object) => {
widgetValue.value = typeof value === 'string' ? value : String(value)
},
getMinHeight: () => options.minHeight ?? 42 + PADDING,
serialize: false
getMinHeight: () => options.minHeight ?? 42 + PADDING
}
})
addWidget(node, widget)

View File

@@ -173,13 +173,5 @@ export const CORE_KEYBINDINGS: Keybinding[] = [
key: 'f'
},
commandId: 'Workspace.ToggleFocusMode'
},
{
combo: {
key: 'e',
ctrl: true,
shift: true
},
commandId: 'Comfy.Graph.ConvertToSubgraph'
}
]

View File

@@ -826,6 +826,17 @@ export const CORE_SETTINGS: SettingParams[] = [
defaultValue: false,
versionAdded: '1.18.0'
},
{
id: 'Comfy.Canvas.BackgroundImage',
category: ['Appearance', 'Canvas', 'Background'],
name: 'Canvas background image',
type: 'backgroundImage',
tooltip:
'Image URL for the canvas background. You can right-click an image in the outputs panel and select "Set as Background" to use it, or upload your own image using the upload button.',
defaultValue: '',
versionAdded: '1.20.4',
versionModified: '1.20.5'
},
{
id: 'LiteGraph.Pointer.TrackpadGestures',
category: ['LiteGraph', 'Pointer', 'Trackpad Gestures'],

View File

@@ -0,0 +1,97 @@
import { t } from '@/i18n'
import { api } from '@/scripts/api'
import { app } from '@/scripts/app'
import { useExtensionService } from '@/services/extensionService'
import { useToastStore } from '@/stores/toastStore'
useExtensionService().registerExtension({
name: 'Comfy.FetchApi',
async nodeCreated(node) {
if (node.constructor.comfyClass !== 'FetchApi') return
const onExecuted = node.onExecuted
const msg = t('toastMessages.unableToFetchFile')
const downloadFile = async (
typeValue: string,
subfolderValue: string,
filenameValue: string
) => {
try {
const params = [
'filename=' + encodeURIComponent(filenameValue),
'type=' + encodeURIComponent(typeValue),
'subfolder=' + encodeURIComponent(subfolderValue),
app.getRandParam().substring(1)
].join('&')
const fetchURL = `/view?${params}`
const response = await api.fetchApi(fetchURL)
if (!response.ok) {
console.error(response)
useToastStore().addAlert(msg)
return false
}
const blob = await response.blob()
const downloadFilename = filenameValue
const url = window.URL.createObjectURL(blob)
const a = document.createElement('a')
a.style.display = 'none'
a.href = url
a.download = downloadFilename
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
window.URL.revokeObjectURL(url)
return true
} catch (error) {
console.error(error)
useToastStore().addAlert(msg)
return false
}
}
const type = node.widgets?.find((w) => w.name === 'type')
const subfolder = node.widgets?.find((w) => w.name === 'subfolder')
const filename = node.widgets?.find((w) => w.name === 'filename')
node.onExecuted = function (message: any) {
onExecuted?.apply(this, arguments as any)
const typeInput = message.result[0]
const subfolderInput = message.result[1]
const filenameInput = message.result[2]
const autoDownload = node.widgets?.find((w) => w.name === 'auto_download')
if (type && subfolder && filename) {
type.value = typeInput
subfolder.value = subfolderInput
filename.value = filenameInput
if (autoDownload && autoDownload.value) {
downloadFile(typeInput, subfolderInput, filenameInput)
}
}
}
node.addWidget('button', 'download', 'download', async () => {
if (type && subfolder && filename) {
await downloadFile(
type.value as string,
subfolder.value as string,
filename.value as string
)
} else {
console.error(msg)
useToastStore().addAlert(msg)
}
})
}
})

View File

@@ -1,4 +1,4 @@
import { LiteGraph } from '@comfyorg/litegraph'
import { LGraphCanvas, LiteGraph } from '@comfyorg/litegraph'
import { LGraphNode, type NodeId } from '@comfyorg/litegraph/dist/LGraphNode'
import { t } from '@/i18n'
@@ -1583,6 +1583,57 @@ export class GroupNodeHandler {
}
}
function addConvertToGroupOptions() {
// @ts-expect-error fixme ts strict error
function addConvertOption(options, index) {
const selected = Object.values(app.canvas.selected_nodes ?? {})
const disabled =
selected.length < 2 ||
selected.find((n) => GroupNodeHandler.isGroupNode(n))
options.splice(index, null, {
content: `Convert to Group Node`,
disabled,
callback: convertSelectedNodesToGroupNode
})
}
// @ts-expect-error fixme ts strict error
function addManageOption(options, index) {
const groups = app.graph.extra?.groupNodes
const disabled = !groups || !Object.keys(groups).length
options.splice(index, null, {
content: `Manage Group Nodes`,
disabled,
callback: () => manageGroupNodes()
})
}
// Add to canvas
const getCanvasMenuOptions = LGraphCanvas.prototype.getCanvasMenuOptions
LGraphCanvas.prototype.getCanvasMenuOptions = function () {
// @ts-expect-error fixme ts strict error
const options = getCanvasMenuOptions.apply(this, arguments)
const index = options.findIndex((o) => o?.content === 'Add Group')
const insertAt = index === -1 ? options.length - 1 : index + 2
addConvertOption(options, insertAt)
addManageOption(options, insertAt + 1)
return options
}
// Add to nodes
const getNodeMenuOptions = LGraphCanvas.prototype.getNodeMenuOptions
LGraphCanvas.prototype.getNodeMenuOptions = function (node) {
// @ts-expect-error fixme ts strict error
const options = getNodeMenuOptions.apply(this, arguments)
if (!GroupNodeHandler.isGroupNode(node)) {
const index = options.findIndex((o) => o?.content === 'Properties')
const insertAt = index === -1 ? options.length - 1 : index
addConvertOption(options, insertAt)
}
return options
}
}
const replaceLegacySeparators = (nodes: ComfyNode[]): void => {
for (const node of nodes) {
if (typeof node.type === 'string' && node.type.startsWith('workflow/')) {
@@ -1672,6 +1723,9 @@ const ext: ComfyExtension = {
}
}
],
setup() {
addConvertToGroupOptions()
},
async beforeConfigureGraph(
graphData: ComfyWorkflowJSON,
missingNodeTypes: string[]

View File

@@ -1,3 +1,4 @@
import './apiNode'
import './clipspace'
import './contextMenuFilter'
import './dynamicPrompts'

View File

@@ -112,7 +112,7 @@ useExtensionService().registerExtension({
LOAD_3D(node) {
const fileInput = document.createElement('input')
fileInput.type = 'file'
fileInput.accept = '.gltf,.glb,.obj,.mtl,.fbx,.stl'
fileInput.accept = '.gltf,.glb,.obj,.fbx,.stl'
fileInput.style.display = 'none'
fileInput.onchange = async () => {
@@ -195,9 +195,7 @@ useExtensionService().registerExtension({
await nextTick()
const load3d = useLoad3dService().getLoad3d(node)
if (load3d) {
useLoad3dService().waitForLoad3d(node, (load3d) => {
let cameraState = node.properties['Camera Info']
const config = new Load3DConfiguration(load3d)
@@ -256,7 +254,7 @@ useExtensionService().registerExtension({
return returnVal
}
}
}
})
}
})
@@ -268,7 +266,7 @@ useExtensionService().registerExtension({
LOAD_3D_ANIMATION(node) {
const fileInput = document.createElement('input')
fileInput.type = 'file'
fileInput.accept = '.fbx,glb,gltf'
fileInput.accept = '.gltf,.glb,.fbx'
fileInput.style.display = 'none'
fileInput.onchange = async () => {
if (fileInput.files?.length) {
@@ -346,67 +344,65 @@ useExtensionService().registerExtension({
await nextTick()
const sceneWidget = node.widgets?.find((w) => w.name === 'image')
useLoad3dService().waitForLoad3d(node, (load3d) => {
const sceneWidget = node.widgets?.find((w) => w.name === 'image')
const modelWidget = node.widgets?.find((w) => w.name === 'model_file')
let cameraState = node.properties['Camera Info']
const width = node.widgets?.find((w) => w.name === 'width')
const height = node.widgets?.find((w) => w.name === 'height')
const load3d = useLoad3dService().getLoad3d(node) as Load3dAnimation
if (modelWidget && width && height && sceneWidget && load3d) {
const config = new Load3DConfiguration(load3d)
const modelWidget = node.widgets?.find((w) => w.name === 'model_file')
config.configure('input', modelWidget, cameraState, width, height)
let cameraState = node.properties['Camera Info']
sceneWidget.serializeValue = async () => {
node.properties['Camera Info'] = load3d.getCameraState()
const width = node.widgets?.find((w) => w.name === 'width')
const height = node.widgets?.find((w) => w.name === 'height')
const load3dAnimation = load3d as Load3dAnimation
load3dAnimation.toggleAnimation(false)
if (modelWidget && width && height && sceneWidget && load3d) {
const config = new Load3DConfiguration(load3d)
if (load3dAnimation.isRecording()) {
load3dAnimation.stopRecording()
}
config.configure('input', modelWidget, cameraState, width, height)
const {
scene: imageData,
mask: maskData,
normal: normalData
} = await load3dAnimation.captureScene(
width.value as number,
height.value as number
)
sceneWidget.serializeValue = async () => {
node.properties['Camera Info'] = load3d.getCameraState()
load3d.toggleAnimation(false)
if (load3d.isRecording()) {
load3d.stopRecording()
}
const {
scene: imageData,
mask: maskData,
normal: normalData
} = await load3d.captureScene(
width.value as number,
height.value as number
)
const [data, dataMask, dataNormal] = await Promise.all([
Load3dUtils.uploadTempImage(imageData, 'scene'),
Load3dUtils.uploadTempImage(maskData, 'scene_mask'),
Load3dUtils.uploadTempImage(normalData, 'scene_normal')
])
load3d.handleResize()
const returnVal = {
image: `threed/${data.name} [temp]`,
mask: `threed/${dataMask.name} [temp]`,
normal: `threed/${dataNormal.name} [temp]`,
camera_info: node.properties['Camera Info'],
recording: ''
}
const recordingData = load3d.getRecordingData()
if (recordingData) {
const [recording] = await Promise.all([
Load3dUtils.uploadTempImage(recordingData, 'recording', 'mp4')
const [data, dataMask, dataNormal] = await Promise.all([
Load3dUtils.uploadTempImage(imageData, 'scene'),
Load3dUtils.uploadTempImage(maskData, 'scene_mask'),
Load3dUtils.uploadTempImage(normalData, 'scene_normal')
])
returnVal['recording'] = `threed/${recording.name} [temp]`
}
return returnVal
load3dAnimation.handleResize()
const returnVal = {
image: `threed/${data.name} [temp]`,
mask: `threed/${dataMask.name} [temp]`,
normal: `threed/${dataNormal.name} [temp]`,
camera_info: node.properties['Camera Info'],
recording: ''
}
const recordingData = load3dAnimation.getRecordingData()
if (recordingData) {
const [recording] = await Promise.all([
Load3dUtils.uploadTempImage(recordingData, 'recording', 'mp4')
])
returnVal['recording'] = `threed/${recording.name} [temp]`
}
return returnVal
}
}
}
})
}
})
@@ -456,31 +452,43 @@ useExtensionService().registerExtension({
const onExecuted = node.onExecuted
node.onExecuted = function (message: any) {
onExecuted?.apply(this, arguments as any)
let filePath = message.result[0]
if (!filePath) {
const msg = t('toastMessages.unableToGetModelFilePath')
console.error(msg)
useToastStore().addAlert(msg)
}
const load3d = useLoad3dService().getLoad3d(node)
let cameraState = message.result[1]
useLoad3dService().waitForLoad3d(node, (load3d) => {
const config = new Load3DConfiguration(load3d)
const modelWidget = node.widgets?.find((w) => w.name === 'model_file')
if (load3d && modelWidget) {
modelWidget.value = filePath.replaceAll('\\', '/')
if (modelWidget) {
const lastTimeModelFile = node.properties['Last Time Model File']
const config = new Load3DConfiguration(load3d)
if (lastTimeModelFile) {
modelWidget.value = lastTimeModelFile
config.configure('output', modelWidget, cameraState)
const cameraState = node.properties['Camera Info']
config.configure('output', modelWidget, cameraState)
}
node.onExecuted = function (message: any) {
onExecuted?.apply(this, arguments as any)
let filePath = message.result[0]
if (!filePath) {
const msg = t('toastMessages.unableToGetModelFilePath')
console.error(msg)
useToastStore().addAlert(msg)
}
let cameraState = message.result[1]
modelWidget.value = filePath.replaceAll('\\', '/')
node.properties['Last Time Model File'] = modelWidget.value
config.configure('output', modelWidget, cameraState)
}
}
}
})
}
})
@@ -530,29 +538,42 @@ useExtensionService().registerExtension({
const onExecuted = node.onExecuted
node.onExecuted = function (message: any) {
onExecuted?.apply(this, arguments as any)
let filePath = message.result[0]
if (!filePath) {
const msg = t('toastMessages.unableToGetModelFilePath')
console.error(msg)
useToastStore().addAlert(msg)
}
let cameraState = message.result[1]
const load3d = useLoad3dService().getLoad3d(node)
useLoad3dService().waitForLoad3d(node, (load3d) => {
const config = new Load3DConfiguration(load3d)
const modelWidget = node.widgets?.find((w) => w.name === 'model_file')
if (load3d && modelWidget) {
modelWidget.value = filePath.replaceAll('\\', '/')
const config = new Load3DConfiguration(load3d)
if (modelWidget) {
const lastTimeModelFile = node.properties['Last Time Model File']
config.configure('output', modelWidget, cameraState)
if (lastTimeModelFile) {
modelWidget.value = lastTimeModelFile
const cameraState = node.properties['Camera Info']
config.configure('output', modelWidget, cameraState)
}
node.onExecuted = function (message: any) {
onExecuted?.apply(this, arguments as any)
let filePath = message.result[0]
if (!filePath) {
const msg = t('toastMessages.unableToGetModelFilePath')
console.error(msg)
useToastStore().addAlert(msg)
}
let cameraState = message.result[1]
modelWidget.value = filePath.replaceAll('\\', '/')
node.properties['Last Time Model File'] = modelWidget.value
config.configure('output', modelWidget, cameraState)
}
}
}
})
}
})

View File

@@ -486,6 +486,14 @@ class Load3d {
}
public remove(): void {
this.renderer.forceContextLoss()
const canvas = this.renderer.domElement
const event = new Event('webglcontextlost', {
bubbles: true,
cancelable: true
})
canvas.dispatchEvent(event)
if (this.animationFrameId !== null) {
cancelAnimationFrame(this.animationFrameId)
}

View File

@@ -132,6 +132,14 @@ export class LoaderManager implements LoaderManagerInterface {
if (this.modelManager.materialMode === 'original') {
const mtlUrl = url.replace(/(filename=.*?)\.obj/, '$1.mtl')
const subfolderMatch = url.match(/[?&]subfolder=([^&]*)/)
const subfolder = subfolderMatch
? decodeURIComponent(subfolderMatch[1])
: '3d'
this.mtlLoader.setSubfolder(subfolder)
try {
const materials = await this.mtlLoader.loadAsync(mtlUrl)
materials.preload()

View File

@@ -62,6 +62,14 @@ export class PreviewManager implements PreviewManagerInterface {
dispose(): void {
if (this.previewRenderer) {
this.previewRenderer.forceContextLoss()
const canvas = this.previewRenderer.domElement
const event = new Event('webglcontextlost', {
bubbles: true,
cancelable: true
})
canvas.dispatchEvent(event)
this.previewRenderer.dispose()
}

View File

@@ -38,6 +38,10 @@ class OverrideMTLLoader extends Loader {
this.loadRootFolder = loadRootFolder
}
setSubfolder(subfolder) {
this.subfolder = subfolder
}
/**
* Starts loading from the given URL and passes the loaded MTL asset
* to the `onLoad()` callback.
@@ -135,7 +139,8 @@ class OverrideMTLLoader extends Loader {
const materialCreator = new OverrideMaterialCreator(
this.resourcePath || path,
this.materialOptions,
this.loadRootFolder
this.loadRootFolder,
this.subfolder
)
materialCreator.setCrossOrigin(this.crossOrigin)
materialCreator.setManager(this.manager)
@@ -155,7 +160,7 @@ class OverrideMTLLoader extends Loader {
*/
class OverrideMaterialCreator {
constructor(baseUrl = '', options = {}, loadRootFolder) {
constructor(baseUrl = '', options = {}, loadRootFolder, subfolder) {
this.baseUrl = baseUrl
this.options = options
this.materialsInfo = {}
@@ -164,6 +169,7 @@ class OverrideMaterialCreator {
this.nameLookup = {}
this.loadRootFolder = loadRootFolder
this.subfolder = subfolder
this.crossOrigin = 'anonymous'
@@ -283,16 +289,25 @@ class OverrideMaterialCreator {
/**
* Override for ComfyUI api url
*/
function resolveURL(baseUrl, url, loadRootFolder) {
function resolveURL(baseUrl, url, loadRootFolder, subfolder) {
if (typeof url !== 'string' || url === '') return ''
if (baseUrl.endsWith('/')) {
baseUrl = baseUrl.slice(0, -1)
}
if (!baseUrl.endsWith('api')) {
baseUrl = '/api'
}
baseUrl =
baseUrl +
'/view?filename=' +
url +
'&type=' +
loadRootFolder +
'&subfolder=3d'
'&subfolder=' +
subfolder
return baseUrl
}
@@ -302,7 +317,12 @@ class OverrideMaterialCreator {
const texParams = scope.getTextureParams(value, params)
const map = scope.loadTexture(
resolveURL(scope.baseUrl, texParams.url, scope.loadRootFolder)
resolveURL(
scope.baseUrl,
texParams.url,
scope.loadRootFolder,
scope.subfolder
)
)
map.repeat.copy(texParams.scale)

View File

@@ -98,9 +98,6 @@
"Comfy_Feedback": {
"label": "Give Feedback"
},
"Comfy_Graph_ConvertToSubgraph": {
"label": "Convert Selection to Subgraph"
},
"Comfy_Graph_FitGroupToContents": {
"label": "Fit Group To Contents"
},

View File

@@ -13,6 +13,7 @@
"terminal": "Terminal",
"logs": "Logs",
"videoFailedToLoad": "Video failed to load",
"audioFailedToLoad": "Audio failed to load",
"extensionName": "Extension Name",
"reloadToApplyChanges": "Reload to apply changes",
"insert": "Insert",
@@ -57,6 +58,8 @@
"deprecated": "DEPR",
"loadWorkflow": "Load Workflow",
"goToNode": "Go to Node",
"setAsBackground": "Set as Background",
"customBackground": "Custom Background",
"settings": "Settings",
"searchWorkflows": "Search Workflows",
"searchSettings": "Search Settings",
@@ -116,7 +119,9 @@
"unknownError": "Unknown error",
"title": "Title",
"edit": "Edit",
"copy": "Copy"
"copy": "Copy",
"imageUrl": "Image URL",
"clear": "Clear"
},
"manager": {
"title": "Custom Nodes Manager",
@@ -788,7 +793,6 @@
"Export": "Export",
"Export (API)": "Export (API)",
"Give Feedback": "Give Feedback",
"Convert Selection to Subgraph": "Convert Selection to Subgraph",
"Fit Group To Contents": "Fit Group To Contents",
"Group Selected Nodes": "Group Selected Nodes",
"Convert selected nodes to group node": "Convert selected nodes to group node",
@@ -1270,8 +1274,7 @@
"unauthorizedDomain": "Your domain {domain} is not authorized to use this service. Please contact {email} to add your domain to the whitelist.",
"useApiKeyTip": "Tip: Can't access normal login? Use the Comfy API Key option.",
"nothingSelected": "Nothing selected",
"cannotCreateSubgraph": "Cannot create subgraph",
"failedToConvertToSubgraph": "Failed to convert items to subgraph"
"unableToFetchFile": "Unable to fetch file"
},
"auth": {
"apiKey": {

View File

@@ -25,6 +25,10 @@
"custom": "custom"
}
},
"Comfy_Canvas_BackgroundImage": {
"name": "Canvas background image",
"tooltip": "Image URL for the canvas background. You can right-click an image in the outputs panel and select \"Set as Background\" to use it, or upload your own image using the upload button."
},
"Comfy_Canvas_SelectionToolbox": {
"name": "Show selection toolbox"
},

View File

@@ -98,9 +98,6 @@
"Comfy_Feedback": {
"label": "Dar retroalimentación"
},
"Comfy_Graph_ConvertToSubgraph": {
"label": "Convertir selección en subgrafo"
},
"Comfy_Graph_FitGroupToContents": {
"label": "Ajustar grupo al contenido"
},

View File

@@ -248,11 +248,13 @@
"all": "Todo",
"amount": "Cantidad",
"apply": "Aplicar",
"audioFailedToLoad": "No se pudo cargar el audio",
"back": "Atrás",
"cancel": "Cancelar",
"capture": "captura",
"category": "Categoría",
"choose_file_to_upload": "elige archivo para subir",
"clear": "Limpiar",
"close": "Cerrar",
"color": "Color",
"comingSoon": "Próximamente",
@@ -267,6 +269,7 @@
"copy": "Copiar",
"copyToClipboard": "Copiar al portapapeles",
"currentUser": "Usuario actual",
"customBackground": "Fondo personalizado",
"customize": "Personalizar",
"customizeFolder": "Personalizar carpeta",
"delete": "Eliminar",
@@ -292,6 +295,7 @@
"goToNode": "Ir al nodo",
"icon": "Icono",
"imageFailedToLoad": "Falló la carga de la imagen",
"imageUrl": "URL de la imagen",
"import": "Importar",
"inProgress": "En progreso",
"insert": "Insertar",
@@ -342,6 +346,7 @@
"searchNodes": "Buscar nodos",
"searchSettings": "Buscar configuraciones",
"searchWorkflows": "Buscar flujos de trabajo",
"setAsBackground": "Establecer como fondo",
"settings": "Configuraciones",
"showReport": "Mostrar informe",
"sort": "Ordenar",
@@ -686,7 +691,6 @@
"ComfyUI Forum": "Foro de ComfyUI",
"ComfyUI Issues": "Problemas de ComfyUI",
"Contact Support": "Contactar soporte",
"Convert Selection to Subgraph": "Convertir selección en subgrafo",
"Convert selected nodes to group node": "Convertir nodos seleccionados en nodo de grupo",
"Custom Nodes Manager": "Gestor de nodos personalizados",
"Delete Selected Items": "Eliminar elementos seleccionados",
@@ -1326,7 +1330,6 @@
"title": "Comienza con una Plantilla"
},
"toastMessages": {
"cannotCreateSubgraph": "No se puede crear el subgrafo",
"couldNotDetermineFileType": "No se pudo determinar el tipo de archivo",
"dropFileError": "No se puede procesar el elemento soltado: {error}",
"emptyCanvas": "Lienzo vacío",
@@ -1335,7 +1338,6 @@
"errorSaveSetting": "Error al guardar la configuración {id}: {err}",
"failedToAccessBillingPortal": "No se pudo acceder al portal de facturación: {error}",
"failedToApplyTexture": "Error al aplicar textura",
"failedToConvertToSubgraph": "No se pudo convertir los elementos en subgrafo",
"failedToCreateCustomer": "No se pudo crear el cliente: {error}",
"failedToDownloadFile": "Error al descargar el archivo",
"failedToExportModel": "Error al exportar modelo como {format}",
@@ -1357,6 +1359,7 @@
"pendingTasksDeleted": "Tareas pendientes eliminadas",
"pleaseSelectNodesToGroup": "Por favor, seleccione los nodos (u otros grupos) para crear un grupo para",
"pleaseSelectOutputNodes": "Por favor, selecciona los nodos de salida",
"unableToFetchFile": "No se pudo obtener el archivo",
"unableToGetModelFilePath": "No se puede obtener la ruta del archivo del modelo",
"unauthorizedDomain": "Tu dominio {domain} no está autorizado para usar este servicio. Por favor, contacta a {email} para agregar tu dominio a la lista blanca.",
"updateRequested": "Actualización solicitada",

View File

@@ -3403,7 +3403,7 @@
"clear": {
},
"height": {
"name": "altura"
"name": "alto"
},
"image": {
"name": "imagen"
@@ -3417,20 +3417,26 @@
"name": "ancho"
}
},
"outputs": [
null,
null,
null,
{
"outputs": {
"0": {
"name": "imagen"
},
"1": {
"name": "mask"
},
"2": {
"name": "ruta_malla"
},
"3": {
"name": "normal"
},
{
"4": {
"name": "lineart"
},
{
"name": "camera_info"
"5": {
"name": "info_cámara"
}
]
}
},
"Load3DAnimation": {
"display_name": "Cargar 3D - Animación",
@@ -3438,7 +3444,7 @@
"clear": {
},
"height": {
"name": "altura"
"name": "alto"
},
"image": {
"name": "imagen"
@@ -3452,17 +3458,23 @@
"name": "ancho"
}
},
"outputs": [
null,
null,
null,
{
"outputs": {
"0": {
"name": "imagen"
},
"1": {
"name": "mask"
},
"2": {
"name": "ruta_malla"
},
"3": {
"name": "normal"
},
{
"name": "camera_info"
"4": {
"name": "info_cámara"
}
]
}
},
"LoadAudio": {
"display_name": "CargarAudio",

View File

@@ -25,6 +25,10 @@
},
"tooltip": "Personalizado: Reemplace la barra de título del sistema con el menú superior de ComfyUI"
},
"Comfy_Canvas_BackgroundImage": {
"name": "Imagen de fondo del lienzo",
"tooltip": "URL de la imagen para el fondo del lienzo. Puedes hacer clic derecho en una imagen del panel de resultados y seleccionar \"Establecer como fondo\" para usarla."
},
"Comfy_Canvas_SelectionToolbox": {
"name": "Mostrar caja de herramientas de selección"
},

View File

@@ -98,9 +98,6 @@
"Comfy_Feedback": {
"label": "Retour d'information"
},
"Comfy_Graph_ConvertToSubgraph": {
"label": "Convertir la sélection en sous-graphe"
},
"Comfy_Graph_FitGroupToContents": {
"label": "Ajuster le groupe au contenu"
},

View File

@@ -248,11 +248,13 @@
"all": "Tout",
"amount": "Quantité",
"apply": "Appliquer",
"audioFailedToLoad": "Échec du chargement de l'audio",
"back": "Retour",
"cancel": "Annuler",
"capture": "capture",
"category": "Catégorie",
"choose_file_to_upload": "choisissez le fichier à télécharger",
"clear": "Effacer",
"close": "Fermer",
"color": "Couleur",
"comingSoon": "Bientôt disponible",
@@ -267,6 +269,7 @@
"copy": "Copier",
"copyToClipboard": "Copier dans le presse-papiers",
"currentUser": "Utilisateur actuel",
"customBackground": "Arrière-plan personnalisé",
"customize": "Personnaliser",
"customizeFolder": "Personnaliser le dossier",
"delete": "Supprimer",
@@ -292,6 +295,7 @@
"goToNode": "Aller au nœud",
"icon": "Icône",
"imageFailedToLoad": "Échec du chargement de l'image",
"imageUrl": "URL de l'image",
"import": "Importer",
"inProgress": "En cours",
"insert": "Insérer",
@@ -342,6 +346,7 @@
"searchNodes": "Rechercher des nœuds",
"searchSettings": "Rechercher des paramètres",
"searchWorkflows": "Rechercher des flux de travail",
"setAsBackground": "Définir comme arrière-plan",
"settings": "Paramètres",
"showReport": "Afficher le rapport",
"sort": "Trier",
@@ -686,7 +691,6 @@
"ComfyUI Forum": "Forum ComfyUI",
"ComfyUI Issues": "Problèmes de ComfyUI",
"Contact Support": "Contacter le support",
"Convert Selection to Subgraph": "Convertir la sélection en sous-graphe",
"Convert selected nodes to group node": "Convertir les nœuds sélectionnés en nœud de groupe",
"Custom Nodes Manager": "Gestionnaire de Nœuds Personnalisés",
"Delete Selected Items": "Supprimer les éléments sélectionnés",
@@ -1326,7 +1330,6 @@
"title": "Commencez avec un modèle"
},
"toastMessages": {
"cannotCreateSubgraph": "Impossible de créer le sous-graphe",
"couldNotDetermineFileType": "Impossible de déterminer le type de fichier",
"dropFileError": "Impossible de traiter l'élément déposé : {error}",
"emptyCanvas": "Toile vide",
@@ -1335,7 +1338,6 @@
"errorSaveSetting": "Erreur lors de l'enregistrement du paramètre {id}: {err}",
"failedToAccessBillingPortal": "Échec de l'accès au portail de facturation : {error}",
"failedToApplyTexture": "Échec de l'application de la texture",
"failedToConvertToSubgraph": "Échec de la conversion des éléments en sous-graphe",
"failedToCreateCustomer": "Échec de la création du client : {error}",
"failedToDownloadFile": "Échec du téléchargement du fichier",
"failedToExportModel": "Échec de l'exportation du modèle en {format}",
@@ -1357,6 +1359,7 @@
"pendingTasksDeleted": "Tâches en attente supprimées",
"pleaseSelectNodesToGroup": "Veuillez sélectionner les nœuds (ou autres groupes) pour créer un groupe pour",
"pleaseSelectOutputNodes": "Veuillez sélectionner les nœuds de sortie",
"unableToFetchFile": "Impossible de récupérer le fichier",
"unableToGetModelFilePath": "Impossible d'obtenir le chemin du fichier modèle",
"unauthorizedDomain": "Votre domaine {domain} n'est pas autorisé à utiliser ce service. Veuillez contacter {email} pour ajouter votre domaine à la liste blanche.",
"updateRequested": "Mise à jour demandée",

View File

@@ -3417,20 +3417,26 @@
"name": "largeur"
}
},
"outputs": [
null,
null,
null,
{
"outputs": {
"0": {
"name": "image"
},
"1": {
"name": "masque"
},
"2": {
"name": "chemin_maillage"
},
"3": {
"name": "normale"
},
{
"name": "ligne artistique"
"4": {
"name": "lineart"
},
{
"name": "informations caméra"
"5": {
"name": "info_caméra"
}
]
}
},
"Load3DAnimation": {
"display_name": "Charger 3D - Animation",
@@ -3452,17 +3458,23 @@
"name": "largeur"
}
},
"outputs": [
null,
null,
null,
{
"name": "normal"
"outputs": {
"0": {
"name": "image"
},
{
"name": "camera_info"
"1": {
"name": "masque"
},
"2": {
"name": "chemin_maillage"
},
"3": {
"name": "normale"
},
"4": {
"name": "info_caméra"
}
]
}
},
"LoadAudio": {
"display_name": "ChargerAudio",

View File

@@ -25,6 +25,10 @@
},
"tooltip": "Choisissez l'option personnalisée pour masquer la barre de titre du système"
},
"Comfy_Canvas_BackgroundImage": {
"name": "Image de fond du canevas",
"tooltip": "URL de l'image pour le fond du canevas. Vous pouvez faire un clic droit sur une image dans le panneau de sortie et sélectionner « Définir comme fond » pour l'utiliser."
},
"Comfy_Canvas_SelectionToolbox": {
"name": "Afficher la boîte à outils de sélection"
},

View File

@@ -98,9 +98,6 @@
"Comfy_Feedback": {
"label": "フィードバック"
},
"Comfy_Graph_ConvertToSubgraph": {
"label": "選択範囲をサブグラフに変換"
},
"Comfy_Graph_FitGroupToContents": {
"label": "グループを内容に合わせて調整"
},

View File

@@ -248,11 +248,13 @@
"all": "すべて",
"amount": "量",
"apply": "適用する",
"audioFailedToLoad": "オーディオの読み込みに失敗しました",
"back": "戻る",
"cancel": "キャンセル",
"capture": "キャプチャ",
"category": "カテゴリ",
"choose_file_to_upload": "アップロードするファイルを選択",
"clear": "クリア",
"close": "閉じる",
"color": "色",
"comingSoon": "近日公開",
@@ -267,6 +269,7 @@
"copy": "コピー",
"copyToClipboard": "クリップボードにコピー",
"currentUser": "現在のユーザー",
"customBackground": "カスタム背景",
"customize": "カスタマイズ",
"customizeFolder": "フォルダーをカスタマイズ",
"delete": "削除",
@@ -292,6 +295,7 @@
"goToNode": "ノードに移動",
"icon": "アイコン",
"imageFailedToLoad": "画像の読み込みに失敗しました",
"imageUrl": "画像URL",
"import": "インポート",
"inProgress": "進行中",
"insert": "挿入",
@@ -342,6 +346,7 @@
"searchNodes": "ノードを検索",
"searchSettings": "設定を検索",
"searchWorkflows": "ワークフローを検索",
"setAsBackground": "背景として設定",
"settings": "設定",
"showReport": "レポートを表示",
"sort": "並び替え",
@@ -686,7 +691,6 @@
"ComfyUI Forum": "ComfyUI フォーラム",
"ComfyUI Issues": "ComfyUIの問題",
"Contact Support": "サポートに連絡",
"Convert Selection to Subgraph": "選択範囲をサブグラフに変換",
"Convert selected nodes to group node": "選択したノードをグループノードに変換",
"Custom Nodes Manager": "カスタムノードマネージャ",
"Delete Selected Items": "選択したアイテムを削除",
@@ -1326,7 +1330,6 @@
"title": "テンプレートを利用して開始"
},
"toastMessages": {
"cannotCreateSubgraph": "サブグラフを作成できません",
"couldNotDetermineFileType": "ファイルタイプを判断できませんでした",
"dropFileError": "ドロップされたアイテムを処理できません: {error}",
"emptyCanvas": "キャンバスが空です",
@@ -1335,7 +1338,6 @@
"errorSaveSetting": "設定{id}の保存エラー: {err}",
"failedToAccessBillingPortal": "請求ポータルへのアクセスに失敗しました: {error}",
"failedToApplyTexture": "テクスチャの適用に失敗しました",
"failedToConvertToSubgraph": "アイテムをサブグラフに変換できませんでした",
"failedToCreateCustomer": "顧客の作成に失敗しました: {error}",
"failedToDownloadFile": "ファイルのダウンロードに失敗しました",
"failedToExportModel": "{format}としてモデルのエクスポートに失敗しました",
@@ -1357,6 +1359,7 @@
"pendingTasksDeleted": "保留中のタスクが削除されました",
"pleaseSelectNodesToGroup": "グループを作成するためのノード(または他のグループ)を選択してください",
"pleaseSelectOutputNodes": "出力ノードを選択してください",
"unableToFetchFile": "ファイルを取得できません",
"unableToGetModelFilePath": "モデルファイルのパスを取得できません",
"unauthorizedDomain": "あなたのドメイン {domain} はこのサービスを利用する権限がありません。ご利用のドメインをホワイトリストに追加するには、{email} までご連絡ください。",
"updateRequested": "更新が要求されました",

View File

@@ -3417,23 +3417,29 @@
"name": "幅"
}
},
"outputs": [
null,
null,
null,
{
"outputs": {
"0": {
"name": "画像"
},
"1": {
"name": "マスク"
},
"2": {
"name": "メッシュパス"
},
"3": {
"name": "法線"
},
{
"4": {
"name": "線画"
},
{
"5": {
"name": "カメラ情報"
}
]
}
},
"Load3DAnimation": {
"display_name": "3D読み込 - アニメーション",
"display_name": "3D読み込 - アニメーション",
"inputs": {
"clear": {
},
@@ -3452,17 +3458,23 @@
"name": "幅"
}
},
"outputs": [
null,
null,
null,
{
"outputs": {
"0": {
"name": "画像"
},
"1": {
"name": "マスク"
},
"2": {
"name": "メッシュパス"
},
"3": {
"name": "法線"
},
{
"4": {
"name": "カメラ情報"
}
]
}
},
"LoadAudio": {
"display_name": "音声を読み込む",

View File

@@ -25,6 +25,10 @@
},
"tooltip": "システムタイトルバーを非表示にするにはカスタムオプションを選択してください"
},
"Comfy_Canvas_BackgroundImage": {
"name": "キャンバス背景画像",
"tooltip": "キャンバスの背景画像のURLです。出力パネルで画像を右クリックし、「背景として設定」を選択すると使用できます。"
},
"Comfy_Canvas_SelectionToolbox": {
"name": "選択ツールボックスを表示"
},

View File

@@ -98,9 +98,6 @@
"Comfy_Feedback": {
"label": "피드백"
},
"Comfy_Graph_ConvertToSubgraph": {
"label": "선택 영역을 서브그래프로 변환"
},
"Comfy_Graph_FitGroupToContents": {
"label": "그룹을 내용에 맞게 맞추기"
},

View File

@@ -248,11 +248,13 @@
"all": "모두",
"amount": "수량",
"apply": "적용",
"audioFailedToLoad": "오디오를 불러오지 못했습니다",
"back": "뒤로",
"cancel": "취소",
"capture": "캡처",
"category": "카테고리",
"choose_file_to_upload": "업로드할 파일 선택",
"clear": "지우기",
"close": "닫기",
"color": "색상",
"comingSoon": "곧 출시 예정",
@@ -267,6 +269,7 @@
"copy": "복사",
"copyToClipboard": "클립보드에 복사",
"currentUser": "현재 사용자",
"customBackground": "맞춤 배경",
"customize": "사용자 정의",
"customizeFolder": "폴더 사용자 정의",
"delete": "삭제",
@@ -292,6 +295,7 @@
"goToNode": "노드로 이동",
"icon": "아이콘",
"imageFailedToLoad": "이미지를 로드하지 못했습니다.",
"imageUrl": "이미지 URL",
"import": "가져오기",
"inProgress": "진행 중",
"insert": "삽입",
@@ -342,6 +346,7 @@
"searchNodes": "노드 검색",
"searchSettings": "설정 검색",
"searchWorkflows": "워크플로 검색",
"setAsBackground": "배경으로 설정",
"settings": "설정",
"showReport": "보고서 보기",
"sort": "정렬",
@@ -686,7 +691,6 @@
"ComfyUI Forum": "ComfyUI 포럼",
"ComfyUI Issues": "ComfyUI 이슈 페이지",
"Contact Support": "고객 지원 문의",
"Convert Selection to Subgraph": "선택 영역을 서브그래프로 변환",
"Convert selected nodes to group node": "선택한 노드를 그룹 노드로 변환",
"Custom Nodes Manager": "사용자 정의 노드 관리자",
"Delete Selected Items": "선택한 항목 삭제",
@@ -1326,7 +1330,6 @@
"title": "템플릿으로 시작하기"
},
"toastMessages": {
"cannotCreateSubgraph": "서브그래프를 생성할 수 없습니다",
"couldNotDetermineFileType": "파일 유형을 결정할 수 없습니다",
"dropFileError": "드롭된 항목을 처리할 수 없습니다: {error}",
"emptyCanvas": "빈 캔버스",
@@ -1335,7 +1338,6 @@
"errorSaveSetting": "설정 {id} 저장 오류: {err}",
"failedToAccessBillingPortal": "결제 포털에 접근하지 못했습니다: {error}",
"failedToApplyTexture": "텍스처 적용에 실패했습니다",
"failedToConvertToSubgraph": "항목을 서브그래프로 변환하지 못했습니다",
"failedToCreateCustomer": "고객 생성에 실패했습니다: {error}",
"failedToDownloadFile": "파일 다운로드에 실패했습니다",
"failedToExportModel": "{format} 형식으로 모델 내보내기에 실패했습니다",
@@ -1357,6 +1359,7 @@
"pendingTasksDeleted": "보류 중인 작업이 삭제되었습니다",
"pleaseSelectNodesToGroup": "그룹을 만들기 위해 노드(또는 다른 그룹)를 선택해 주세요",
"pleaseSelectOutputNodes": "출력 노드를 선택해 주세요",
"unableToFetchFile": "파일을 가져올 수 없습니다",
"unableToGetModelFilePath": "모델 파일 경로를 가져올 수 없습니다",
"unauthorizedDomain": "귀하의 도메인 {domain}은(는) 이 서비스를 사용할 수 있는 권한이 없습니다. 도메인을 허용 목록에 추가하려면 {email}로 문의해 주세요.",
"updateRequested": "업데이트 요청됨",

View File

@@ -3398,7 +3398,7 @@
}
},
"Load3D": {
"display_name": "3D 로드",
"display_name": "3D 불러오기",
"inputs": {
"clear": {
},
@@ -3417,23 +3417,29 @@
"name": "너비"
}
},
"outputs": [
null,
null,
null,
{
"outputs": {
"0": {
"name": "이미지"
},
"1": {
"name": "마스크"
},
"2": {
"name": "메시 경로"
},
"3": {
"name": "노멀"
},
{
"4": {
"name": "라인아트"
},
{
"5": {
"name": "카메라 정보"
}
]
}
},
"Load3DAnimation": {
"display_name": "3D 로드 - 애니메이션",
"display_name": "3D 불러오기 - 애니메이션",
"inputs": {
"clear": {
},
@@ -3452,17 +3458,23 @@
"name": "너비"
}
},
"outputs": [
null,
null,
null,
{
"outputs": {
"0": {
"name": "이미지"
},
"1": {
"name": "마스크"
},
"2": {
"name": "메시 경로"
},
"3": {
"name": "노멀"
},
{
"4": {
"name": "카메라 정보"
}
]
}
},
"LoadAudio": {
"display_name": "오디오 로드",

View File

@@ -25,6 +25,10 @@
},
"tooltip": "시스템 제목 표시 줄을 숨기려면 사용자 정의 옵션을 선택하세요"
},
"Comfy_Canvas_BackgroundImage": {
"name": "캔버스 배경 이미지",
"tooltip": "캔버스 배경에 사용할 이미지 URL입니다. 출력 패널에서 이미지를 마우스 오른쪽 버튼으로 클릭한 후 \"배경으로 설정\"을 선택해 사용할 수 있습니다."
},
"Comfy_Canvas_SelectionToolbox": {
"name": "선택 도구 상자 표시"
},

View File

@@ -98,9 +98,6 @@
"Comfy_Feedback": {
"label": "Обратная связь"
},
"Comfy_Graph_ConvertToSubgraph": {
"label": "Преобразовать выделенное в подграф"
},
"Comfy_Graph_FitGroupToContents": {
"label": "Подогнать группу к содержимому"
},

View File

@@ -248,11 +248,13 @@
"all": "Все",
"amount": "Количество",
"apply": "Применить",
"audioFailedToLoad": "Не удалось загрузить аудио",
"back": "Назад",
"cancel": "Отмена",
"capture": "захват",
"category": "Категория",
"choose_file_to_upload": "выберите файл для загрузки",
"clear": "Очистить",
"close": "Закрыть",
"color": "Цвет",
"comingSoon": "Скоро будет",
@@ -267,6 +269,7 @@
"copy": "Копировать",
"copyToClipboard": "Скопировать в буфер обмена",
"currentUser": "Текущий пользователь",
"customBackground": "Пользовательский фон",
"customize": "Настроить",
"customizeFolder": "Настроить папку",
"delete": "Удалить",
@@ -292,6 +295,7 @@
"goToNode": "Перейти к ноде",
"icon": "Иконка",
"imageFailedToLoad": "Не удалось загрузить изображение",
"imageUrl": "URL изображения",
"import": "Импорт",
"inProgress": "В процессе",
"insert": "Вставить",
@@ -342,6 +346,7 @@
"searchNodes": "Поиск нод",
"searchSettings": "Поиск настроек",
"searchWorkflows": "Поиск рабочих процессов",
"setAsBackground": "Установить как фон",
"settings": "Настройки",
"showReport": "Показать отчёт",
"sort": "Сортировать",
@@ -686,7 +691,6 @@
"ComfyUI Forum": "Форум ComfyUI",
"ComfyUI Issues": "Проблемы ComfyUI",
"Contact Support": "Связаться с поддержкой",
"Convert Selection to Subgraph": "Преобразовать выделенное в подграф",
"Convert selected nodes to group node": "Преобразовать выбранные ноды в групповую ноду",
"Custom Nodes Manager": "Менеджер Пользовательских Узлов",
"Delete Selected Items": "Удалить выбранные элементы",
@@ -1326,7 +1330,6 @@
"title": "Начните с шаблона"
},
"toastMessages": {
"cannotCreateSubgraph": "Невозможно создать подграф",
"couldNotDetermineFileType": "Не удалось определить тип файла",
"dropFileError": "Не удалось обработать перетаскиваемый элемент: {error}",
"emptyCanvas": "Пустой холст",
@@ -1335,7 +1338,6 @@
"errorSaveSetting": "Ошибка сохранения настройки {id}: {err}",
"failedToAccessBillingPortal": "Не удалось получить доступ к биллинговому порталу: {error}",
"failedToApplyTexture": "Не удалось применить текстуру",
"failedToConvertToSubgraph": "Не удалось преобразовать элементы в подграф",
"failedToCreateCustomer": "Не удалось создать клиента: {error}",
"failedToDownloadFile": "Не удалось скачать файл",
"failedToExportModel": "Не удалось экспортировать модель как {format}",
@@ -1357,6 +1359,7 @@
"pendingTasksDeleted": "Ожидающие задачи удалены",
"pleaseSelectNodesToGroup": "Пожалуйста, выберите узлы (или другие группы) для создания группы",
"pleaseSelectOutputNodes": "Пожалуйста, выберите выходные узлы",
"unableToFetchFile": "Не удалось получить файл",
"unableToGetModelFilePath": "Не удалось получить путь к файлу модели",
"unauthorizedDomain": "Ваш домен {domain} не авторизован для использования этого сервиса. Пожалуйста, свяжитесь с {email}, чтобы добавить ваш домен в белый список.",
"updateRequested": "Запрошено обновление",

View File

@@ -3409,7 +3409,7 @@
"name": "изображение"
},
"model_file": {
"name": "файл_модели"
"name": "файл модели"
},
"upload 3d model": {
},
@@ -3417,23 +3417,29 @@
"name": "ширина"
}
},
"outputs": [
null,
null,
null,
{
"outputs": {
"0": {
"name": "изображение"
},
"1": {
"name": "mask"
},
"2": {
"name": "путь к mesh"
},
"3": {
"name": "нормаль"
},
{
"name": "линеарт"
"4": {
"name": "линейный рисунок"
},
{
"name": "информация_камеры"
"5": {
"name": "информация о камере"
}
]
}
},
"Load3DAnimation": {
"display_name": "Загрузить 3D Анимация",
"display_name": "Загрузить 3D - Анимация",
"inputs": {
"clear": {
},
@@ -3452,17 +3458,23 @@
"name": "ширина"
}
},
"outputs": [
null,
null,
null,
{
"outputs": {
"0": {
"name": "изображение"
},
"1": {
"name": "mask"
},
"2": {
"name": "путь_к_модели"
},
"3": {
"name": "нормаль"
},
{
"name": "информация_камеры"
"4": {
"name": "информация_о_камере"
}
]
}
},
"LoadAudio": {
"display_name": "Загрузить аудио",

View File

@@ -25,6 +25,10 @@
},
"tooltip": "Выберите пользовательский вариант, чтобы скрыть системную строку заголовка"
},
"Comfy_Canvas_BackgroundImage": {
"name": "Фоновое изображение холста",
"tooltip": "URL изображения для фона холста. Вы можете кликнуть правой кнопкой мыши на изображении в панели результатов и выбрать «Установить как фон», чтобы использовать его."
},
"Comfy_Canvas_SelectionToolbox": {
"name": "Показать панель инструментов выбора"
},

View File

@@ -98,9 +98,6 @@
"Comfy_Feedback": {
"label": "反馈"
},
"Comfy_Graph_ConvertToSubgraph": {
"label": "将选区转换为子图"
},
"Comfy_Graph_FitGroupToContents": {
"label": "适应节点框到内容"
},

View File

@@ -248,11 +248,13 @@
"all": "全部",
"amount": "数量",
"apply": "应用",
"audioFailedToLoad": "音频加载失败",
"back": "返回",
"cancel": "取消",
"capture": "捕获",
"category": "类别",
"choose_file_to_upload": "选择要上传的文件",
"clear": "清除",
"close": "关闭",
"color": "颜色",
"comingSoon": "即将推出",
@@ -267,6 +269,7 @@
"copy": "复制",
"copyToClipboard": "复制到剪贴板",
"currentUser": "当前用户",
"customBackground": "自定义背景",
"customize": "自定义",
"customizeFolder": "自定义文件夹",
"delete": "删除",
@@ -292,6 +295,7 @@
"goToNode": "转到节点",
"icon": "图标",
"imageFailedToLoad": "图像加载失败",
"imageUrl": "图片网址",
"import": "导入",
"inProgress": "进行中",
"insert": "插入",
@@ -342,6 +346,7 @@
"searchNodes": "搜索节点",
"searchSettings": "搜索设置",
"searchWorkflows": "搜索工作流",
"setAsBackground": "设为背景",
"settings": "设置",
"showReport": "显示报告",
"sort": "排序",
@@ -686,7 +691,6 @@
"ComfyUI Forum": "ComfyUI 论坛",
"ComfyUI Issues": "ComfyUI 问题",
"Contact Support": "联系支持",
"Convert Selection to Subgraph": "将选中内容转换为子图",
"Convert selected nodes to group node": "将选中节点转换为组节点",
"Custom Nodes Manager": "自定义节点管理器",
"Delete Selected Items": "删除选定的项目",
@@ -1326,7 +1330,6 @@
"title": "从模板开始"
},
"toastMessages": {
"cannotCreateSubgraph": "无法创建子图",
"couldNotDetermineFileType": "无法确定文件类型",
"dropFileError": "无法处理掉落的项目:{error}",
"emptyCanvas": "画布为空",
@@ -1335,7 +1338,6 @@
"errorSaveSetting": "保存设置 {id} 出错:{err}",
"failedToAccessBillingPortal": "访问账单门户失败:{error}",
"failedToApplyTexture": "应用纹理失败",
"failedToConvertToSubgraph": "无法将项目转换为子图",
"failedToCreateCustomer": "创建客户失败:{error}",
"failedToDownloadFile": "文件下载失败",
"failedToExportModel": "无法将模型导出为 {format}",
@@ -1357,6 +1359,7 @@
"pendingTasksDeleted": "待处理任务已删除",
"pleaseSelectNodesToGroup": "请选取节点(或其他组)以创建分组",
"pleaseSelectOutputNodes": "请选择输出节点",
"unableToFetchFile": "无法获取文件",
"unableToGetModelFilePath": "无法获取模型文件路径",
"unauthorizedDomain": "您的域名 {domain} 未被授权使用此服务。请联系 {email} 将您的域名添加到白名单。",
"updateRequested": "已请求更新",

View File

@@ -3417,20 +3417,26 @@
"name": "宽度"
}
},
"outputs": [
null,
null,
null,
{
"name": "法线"
"outputs": {
"0": {
"name": "image"
},
{
"name": "线稿"
"1": {
"name": "mask"
},
{
"name": "相机信息"
"2": {
"name": "mesh_path"
},
"3": {
"name": "normal"
},
"4": {
"name": "lineart"
},
"5": {
"name": "camera_info"
}
]
}
},
"Load3DAnimation": {
"display_name": "加载3D动画",
@@ -3452,17 +3458,23 @@
"name": "宽度"
}
},
"outputs": [
null,
null,
null,
{
"outputs": {
"0": {
"name": "图像"
},
"1": {
"name": "遮罩"
},
"2": {
"name": "mesh_path"
},
"3": {
"name": "法线"
},
{
"4": {
"name": "相机信息"
}
]
}
},
"LoadAudio": {
"display_name": "加载音频",

View File

@@ -25,6 +25,10 @@
},
"tooltip": "选择自定义选项以隐藏系统标题栏"
},
"Comfy_Canvas_BackgroundImage": {
"name": "画布背景图像",
"tooltip": "画布背景的图像 URL。你可以在输出面板中右键点击一张图片并选择“设为背景”来使用它。"
},
"Comfy_Canvas_SelectionToolbox": {
"name": "显示选择工具箱"
},

View File

@@ -350,6 +350,7 @@ const zNodeBadgeMode = z.enum(
const zSettings = z.object({
'Comfy.ColorPalette': z.string(),
'Comfy.CustomColorPalettes': colorPalettesSchema,
'Comfy.Canvas.BackgroundImage': z.string().optional(),
'Comfy.ConfirmClear': z.boolean(),
'Comfy.DevMode': z.boolean(),
'Comfy.Workflow.ShowMissingNodesWarning': z.boolean(),

View File

@@ -41,10 +41,10 @@ const zModelFile = z.object({
const zGraphState = z
.object({
lastGroupId: z.number(),
lastNodeId: z.number(),
lastLinkId: z.number(),
lastRerouteId: z.number()
lastGroupid: z.number().optional(),
lastNodeId: z.number().optional(),
lastLinkId: z.number().optional(),
lastRerouteId: z.number().optional()
})
.passthrough()
@@ -214,32 +214,6 @@ const zComfyNode = z
})
.passthrough()
export const zSubgraphIO = zNodeInput.extend({
/** Slot ID (internal; never changes once instantiated). */
id: z.string().uuid(),
/** The data type this slot uses. Unlike nodes, this does not support legacy numeric types. */
type: z.string(),
/** Links connected to this slot, or `undefined` if not connected. An ouptut slot should only ever have one link. */
linkIds: z.array(z.number()).optional()
})
const zSubgraphInstance = z
.object({
id: zNodeId,
type: z.string().uuid(),
pos: zVector2,
size: zVector2,
flags: zFlags,
order: z.number(),
mode: z.number(),
inputs: z.array(zSubgraphIO).optional(),
outputs: z.array(zSubgraphIO).optional(),
widgets_values: zWidgetValues.optional(),
color: z.string().optional(),
bgcolor: z.string().optional()
})
.passthrough()
const zGroup = z
.object({
id: z.number().optional(),
@@ -274,22 +248,9 @@ const zExtra = z
})
.passthrough()
export const zGraphDefinitions = z.object({
subgraphs: z.lazy(() => z.array(zSubgraphDefinition))
})
export const zBaseExportableGraph = z.object({
/** Unique graph ID. Automatically generated if not provided. */
id: z.string().uuid().optional(),
revision: z.number().optional(),
config: zConfig.optional().nullable(),
/** Details of the appearance and location of subgraphs shown in this graph. Similar to */
subgraphs: z.array(zSubgraphInstance).optional()
})
/** Schema version 0.4 */
export const zComfyWorkflow = zBaseExportableGraph
.extend({
export const zComfyWorkflow = z
.object({
id: z.string().uuid().optional(),
revision: z.number().optional(),
last_node_id: zNodeId,
@@ -301,47 +262,13 @@ export const zComfyWorkflow = zBaseExportableGraph
config: zConfig.optional().nullable(),
extra: zExtra.optional().nullable(),
version: z.number(),
models: z.array(zModelFile).optional(),
definitions: zGraphDefinitions.optional()
models: z.array(zModelFile).optional()
})
.passthrough()
/** Required for recursive definition of subgraphs. */
interface ComfyWorkflow1BaseType {
id?: string
revision?: number
version: 1
models?: z.infer<typeof zModelFile>[]
state: z.infer<typeof zGraphState>
}
/** Required for recursive definition of subgraphs w/ZodEffects. */
interface ComfyWorkflow1BaseInput extends ComfyWorkflow1BaseType {
groups?: z.input<typeof zGroup>[]
nodes: z.input<typeof zComfyNode>[]
links?: z.input<typeof zComfyLinkObject>[]
floatingLinks?: z.input<typeof zComfyLinkObject>[]
reroutes?: z.input<typeof zReroute>[]
definitions?: {
subgraphs: SubgraphDefinitionBase<ComfyWorkflow1BaseInput>[]
}
}
/** Required for recursive definition of subgraphs w/ZodEffects. */
interface ComfyWorkflow1BaseOutput extends ComfyWorkflow1BaseType {
groups?: z.output<typeof zGroup>[]
nodes: z.output<typeof zComfyNode>[]
links?: z.output<typeof zComfyLinkObject>[]
floatingLinks?: z.output<typeof zComfyLinkObject>[]
reroutes?: z.output<typeof zReroute>[]
definitions?: {
subgraphs: SubgraphDefinitionBase<ComfyWorkflow1BaseOutput>[]
}
}
/** Schema version 1 */
export const zComfyWorkflow1 = zBaseExportableGraph
.extend({
export const zComfyWorkflow1 = z
.object({
id: z.string().uuid().optional(),
revision: z.number().optional(),
version: z.literal(1),
@@ -353,96 +280,7 @@ export const zComfyWorkflow1 = zBaseExportableGraph
floatingLinks: z.array(zComfyLinkObject).optional(),
reroutes: z.array(zReroute).optional(),
extra: zExtra.optional().nullable(),
models: z.array(zModelFile).optional(),
definitions: z
.object({
subgraphs: z.lazy(
(): z.ZodArray<
z.ZodType<
SubgraphDefinitionBase<ComfyWorkflow1BaseOutput>,
z.ZodTypeDef,
SubgraphDefinitionBase<ComfyWorkflow1BaseInput>
>,
'many'
> => z.array(zSubgraphDefinition)
)
})
.optional()
})
.passthrough()
export const zExportedSubgraphIONode = z.object({
id: zNodeId,
bounding: z.tuple([z.number(), z.number(), z.number(), z.number()]),
pinned: z.boolean().optional()
})
export const zExposedWidget = z.object({
id: z.string(),
name: z.string()
})
interface SubgraphDefinitionBase<
T extends ComfyWorkflow1BaseInput | ComfyWorkflow1BaseOutput
> {
/** Unique graph ID. Automatically generated if not provided. */
id: string
revision: number
name: string
inputNode: T extends ComfyWorkflow1BaseInput
? z.input<typeof zExportedSubgraphIONode>
: z.output<typeof zExportedSubgraphIONode>
outputNode: T extends ComfyWorkflow1BaseInput
? z.input<typeof zExportedSubgraphIONode>
: z.output<typeof zExportedSubgraphIONode>
/** Ordered list of inputs to the subgraph itself. Similar to a reroute, with the input side in the graph, and the output side in the subgraph. */
inputs?: T extends ComfyWorkflow1BaseInput
? z.input<typeof zSubgraphIO>[]
: z.output<typeof zSubgraphIO>[]
/** Ordered list of outputs from the subgraph itself. Similar to a reroute, with the input side in the subgraph, and the output side in the graph. */
outputs?: T extends ComfyWorkflow1BaseInput
? z.input<typeof zSubgraphIO>[]
: z.output<typeof zSubgraphIO>[]
/** A list of node widgets displayed in the parent graph, on the subgraph object. */
widgets?: T extends ComfyWorkflow1BaseInput
? z.input<typeof zExposedWidget>[]
: z.output<typeof zExposedWidget>[]
definitions?: {
subgraphs: SubgraphDefinitionBase<T>[]
}
}
/** A subgraph definition `worfklow.definitions.subgraphs` */
export const zSubgraphDefinition = zComfyWorkflow1
.extend({
/** Unique graph ID. Automatically generated if not provided. */
id: z.string().uuid(),
revision: z.number(),
name: z.string(),
inputNode: zExportedSubgraphIONode,
outputNode: zExportedSubgraphIONode,
/** Ordered list of inputs to the subgraph itself. Similar to a reroute, with the input side in the graph, and the output side in the subgraph. */
inputs: z.array(zSubgraphIO).optional(),
/** Ordered list of outputs from the subgraph itself. Similar to a reroute, with the input side in the subgraph, and the output side in the graph. */
outputs: z.array(zSubgraphIO).optional(),
/** A list of node widgets displayed in the parent graph, on the subgraph object. */
widgets: z.array(zExposedWidget).optional(),
definitions: z
.object({
subgraphs: z.lazy(
(): z.ZodArray<
z.ZodType<
SubgraphDefinitionBase<ComfyWorkflow1BaseInput>,
z.ZodTypeDef,
SubgraphDefinitionBase<ComfyWorkflow1BaseInput>
>,
'many'
> => zSubgraphDefinition.array()
)
})
.optional()
models: z.array(zModelFile).optional()
})
.passthrough()

View File

@@ -39,11 +39,9 @@ import { getSvgMetadata } from '@/scripts/metadata/svg'
import { useDialogService } from '@/services/dialogService'
import { useExtensionService } from '@/services/extensionService'
import { useLitegraphService } from '@/services/litegraphService'
import { useSubgraphService } from '@/services/subgraphService'
import { useWorkflowService } from '@/services/workflowService'
import { useApiKeyAuthStore } from '@/stores/apiKeyAuthStore'
import { useCommandStore } from '@/stores/commandStore'
import { useDomWidgetStore } from '@/stores/domWidgetStore'
import { useExecutionStore } from '@/stores/executionStore'
import { useExtensionStore } from '@/stores/extensionStore'
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
@@ -73,6 +71,7 @@ import { deserialiseAndCreate } from '@/utils/vintageClipboard'
import { type ComfyApi, PromptExecutionError, api } from './api'
import { defaultGraph } from './defaultGraph'
import { pruneWidgets } from './domWidget'
import {
getFlacMetadata,
getLatentMetadata,
@@ -715,23 +714,25 @@ export class ComfyApp {
}
#addAfterConfigureHandler() {
const { graph } = this
const { onConfigure } = graph
graph.onConfigure = function (...args) {
const app = this
const onConfigure = app.graph.onConfigure
app.graph.onConfigure = function (this: LGraph, ...args) {
fixLinkInputSlots(this)
// Fire callbacks before the onConfigure, this is used by widget inputs to setup the config
for (const node of graph.nodes) {
for (const node of app.graph.nodes) {
node.onGraphConfigured?.()
}
const r = onConfigure?.apply(this, args)
// Fire after onConfigure, used by primitives to generate widget using input nodes config
for (const node of graph.nodes) {
for (const node of app.graph.nodes) {
node.onAfterGraphConfigured?.()
}
pruneWidgets(this.nodes)
return r
}
}
@@ -763,21 +764,6 @@ export class ComfyApp {
this.#graph = new LGraph()
// Register the subgraph - adds type wrapper for Litegraph's `createNode` factory
this.graph.events.addEventListener('subgraph-created', (e) => {
try {
const { subgraph, data } = e.detail
useSubgraphService().registerNewSubgraph(subgraph, data)
} catch (err) {
console.error('Failed to register subgraph', err)
useToastStore().add({
severity: 'error',
summary: 'Failed to register subgraph',
detail: err instanceof Error ? err.message : String(err)
})
}
})
this.#addAfterConfigureHandler()
this.canvas = new LGraphCanvas(canvasEl, this.graph)
@@ -790,30 +776,6 @@ export class ComfyApp {
LiteGraph.alt_drag_do_clone_nodes = true
LiteGraph.macGesturesRequireMac = false
this.canvas.canvas.addEventListener<'litegraph:set-graph'>(
'litegraph:set-graph',
(e) => {
// Assertion: Not yet defined in litegraph.
const { newGraph } = e.detail
const nodeSet = new Set(newGraph.nodes)
const widgetStore = useDomWidgetStore()
// Assertions: UnwrapRef
for (const { widget } of widgetStore.activeWidgetStates) {
if (!nodeSet.has(widget.node)) {
widgetStore.deactivateWidget(widget.id)
}
}
for (const { widget } of widgetStore.inactiveWidgetStates) {
if (nodeSet.has(widget.node)) {
widgetStore.activateWidget(widget.id)
}
}
}
)
this.graph.start()
// Ensure the canvas fills the window
@@ -1050,7 +1012,6 @@ export class ComfyApp {
})
}
useWorkflowService().beforeLoadNewGraph()
useSubgraphService().loadSubgraphs(graphData)
const missingNodeTypes: MissingNodeType[] = []
const missingModels: ModelFile[] = []
@@ -1246,9 +1207,6 @@ export class ComfyApp {
// Allow widgets to run callbacks before a prompt has been queued
// e.g. random seed before every gen
executeWidgetsCallback(this.graph.nodes, 'beforeQueued')
for (const subgraph of this.graph.subgraphs.values()) {
executeWidgetsCallback(subgraph.nodes, 'beforeQueued')
}
const p = await this.graphToPrompt(this.graph, { queueNodeIds })
try {
@@ -1291,13 +1249,9 @@ export class ComfyApp {
executeWidgetsCallback(
p.workflow.nodes
.map((n) => this.graph.getNodeById(n.id))
.filter((n) => !!n),
.filter((n) => !!n) as LGraphNode[],
'afterQueued'
)
for (const subgraph of this.graph.subgraphs.values()) {
executeWidgetsCallback(subgraph.nodes, 'afterQueued')
}
this.canvas.draw(true, true)
await this.ui.queue.update()
}
@@ -1694,8 +1648,6 @@ export class ComfyApp {
const executionStore = useExecutionStore()
executionStore.lastNodeErrors = null
executionStore.lastExecutionError = null
useDomWidgetStore().clear()
}
clientPosToCanvasPos(pos: Vector2): Vector2 {

View File

@@ -6,7 +6,6 @@ import log from 'loglevel'
import type { ExecutedWsMessage } from '@/schemas/apiSchema'
import type { ComfyWorkflowJSON } from '@/schemas/comfyWorkflowSchema'
import { useExecutionStore } from '@/stores/executionStore'
import { useSubgraphNavigationStore } from '@/stores/subgraphNavigationStore'
import { ComfyWorkflow, useWorkflowStore } from '@/stores/workflowStore'
import { api } from './api'
@@ -38,10 +37,6 @@ export class ChangeTracker {
ds?: { scale: number; offset: [number, number] }
nodeOutputs?: Record<string, any>
private subgraphState?: {
navigation: string[]
}
constructor(
/**
* The workflow that this change tracker is tracking
@@ -72,8 +67,6 @@ export class ChangeTracker {
scale: app.canvas.ds.scale,
offset: [app.canvas.ds.offset[0], app.canvas.ds.offset[1]]
}
const navigation = useSubgraphNavigationStore().exportState()
this.subgraphState = navigation.length ? { navigation } : undefined
}
restore() {
@@ -84,16 +77,6 @@ export class ChangeTracker {
if (this.nodeOutputs) {
app.nodeOutputs = this.nodeOutputs
}
if (this.subgraphState) {
const { navigation } = this.subgraphState
useSubgraphNavigationStore().restoreState(navigation)
const activeId = navigation.at(-1)
if (activeId) {
const subgraph = app.graph.subgraphs.get(activeId)
if (subgraph) app.canvas.setGraph(subgraph)
}
}
}
updateModified() {
@@ -393,14 +376,7 @@ export class ChangeTracker {
return false
// Compare other properties normally
for (const key of [
'links',
'floatingLinks',
'reroutes',
'groups',
'definitions',
'subgraphs'
]) {
for (const key of ['links', 'floatingLinks', 'reroutes', 'groups']) {
if (!_.isEqual(a[key], b[key])) {
return false
}
@@ -416,12 +392,7 @@ export class ChangeTracker {
function sortGraphNodes(graph: ComfyWorkflowJSON) {
return {
links: graph.links,
floatingLinks: graph.floatingLinks,
reroutes: graph.reroutes,
groups: graph.groups,
extra: graph.extra,
definitions: graph.definitions,
subgraphs: graph.subgraphs,
nodes: graph.nodes.sort((a, b) => {
if (typeof a.id === 'number' && typeof b.id === 'number') {
return a.id - b.id

View File

@@ -11,7 +11,7 @@ import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
import { useDomWidgetStore } from '@/stores/domWidgetStore'
import { generateUUID } from '@/utils/formatUtil'
export interface BaseDOMWidget<V extends object | string = object | string>
export interface BaseDOMWidget<V extends object | string>
extends IBaseWidget<V, string, DOMWidgetOptions<V>> {
// ICustomWidget properties
type: string
@@ -330,8 +330,9 @@ LGraphNode.prototype.addDOMWidget = function <
export const pruneWidgets = (nodes: LGraphNode[]) => {
const nodeSet = new Set(nodes)
const domWidgetStore = useDomWidgetStore()
for (const { widget } of domWidgetStore.widgetStates.values()) {
if (!nodeSet.has(widget.node)) {
for (const widgetState of domWidgetStore.widgetStates.values()) {
const widget = widgetState.widget
if (!nodeSet.has(widget.node as LGraphNode)) {
domWidgetStore.unregisterWidget(widget.id)
}
}

View File

@@ -93,8 +93,13 @@ export const useColorPaletteService = () => {
// Sets the colors of the LiteGraph objects
app.canvas.node_title_color = palette.NODE_TITLE_COLOR
app.canvas.default_link_color = palette.LINK_COLOR
app.canvas.background_image = palette.BACKGROUND_IMAGE
app.canvas.clear_background_color = palette.CLEAR_BACKGROUND_COLOR
const backgroundImage = settingStore.get('Comfy.Canvas.BackgroundImage')
if (backgroundImage) {
app.canvas.clear_background_color = 'transparent'
} else {
app.canvas.background_image = palette.BACKGROUND_IMAGE
app.canvas.clear_background_color = palette.CLEAR_BACKGROUND_COLOR
}
app.canvas._pattern = undefined
for (const [key, value] of Object.entries(palette)) {
@@ -126,6 +131,13 @@ export const useColorPaletteService = () => {
for (const [key, value] of Object.entries(comfyColorPalette)) {
rootStyle.setProperty('--' + key, value)
}
const backgroundImage = settingStore.get('Comfy.Canvas.BackgroundImage')
if (backgroundImage) {
rootStyle.setProperty(
'--bg-img',
`url('${backgroundImage}') no-repeat center /cover`
)
}
}
}

View File

@@ -5,13 +5,10 @@ import {
LGraphNode,
LiteGraph,
RenderShape,
type Subgraph,
SubgraphNode,
type Vector2,
createBounds
} from '@comfyorg/litegraph'
import type {
ExportedSubgraphInstance,
ISerialisableNodeInput,
ISerialisableNodeOutput,
ISerialisedNode
@@ -38,7 +35,6 @@ import { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
import { useSettingStore } from '@/stores/settingStore'
import { useToastStore } from '@/stores/toastStore'
import { useWidgetStore } from '@/stores/widgetStore'
import { useWorkflowStore } from '@/stores/workflowStore'
import { normalizeI18nKey } from '@/utils/formatUtil'
import {
isImageNode,
@@ -60,260 +56,6 @@ export const useLitegraphService = () => {
const widgetStore = useWidgetStore()
const canvasStore = useCanvasStore()
// TODO: Dedupe `registerNodeDef`; this should remain synchronous.
function registerSubgraphNodeDef(
nodeDefV1: ComfyNodeDefV1,
subgraph: Subgraph,
instanceData: ExportedSubgraphInstance
) {
const node = class ComfyNode extends SubgraphNode {
static comfyClass: string
static override title: string
static override category: string
static nodeData: ComfyNodeDefV1 & ComfyNodeDefV2
/**
* @internal The initial minimum size of the node.
*/
#initialMinSize = { width: 1, height: 1 }
/**
* @internal The key for the node definition in the i18n file.
*/
get #nodeKey(): string {
return `nodeDefs.${normalizeI18nKey(ComfyNode.nodeData.name)}`
}
constructor() {
super(app.graph, subgraph, instanceData)
this.#setupStrokeStyles()
this.#addInputs(ComfyNode.nodeData.inputs)
this.#addOutputs(ComfyNode.nodeData.outputs)
this.#setInitialSize()
this.serialize_widgets = true
void extensionService.invokeExtensionsAsync('nodeCreated', this)
}
/**
* @internal Setup stroke styles for the node under various conditions.
*/
#setupStrokeStyles() {
this.strokeStyles['running'] = function (this: LGraphNode) {
if (this.id == app.runningNodeId) {
return { color: '#0f0' }
}
}
this.strokeStyles['nodeError'] = function (this: LGraphNode) {
if (app.lastNodeErrors?.[this.id]?.errors) {
return { color: 'red' }
}
}
this.strokeStyles['dragOver'] = function (this: LGraphNode) {
if (app.dragOverNode?.id == this.id) {
return { color: 'dodgerblue' }
}
}
this.strokeStyles['executionError'] = function (this: LGraphNode) {
if (app.lastExecutionError?.node_id == this.id) {
return { color: '#f0f', lineWidth: 2 }
}
}
}
/**
* @internal Add input sockets to the node. (No widget)
*/
#addInputSocket(inputSpec: InputSpec) {
const inputName = inputSpec.name
const nameKey = `${this.#nodeKey}.inputs.${normalizeI18nKey(inputName)}.name`
const widgetConstructor = widgetStore.widgets.get(
inputSpec.widgetType ?? inputSpec.type
)
if (widgetConstructor && !inputSpec.forceInput) return
this.addInput(inputName, inputSpec.type, {
shape: inputSpec.isOptional ? RenderShape.HollowCircle : undefined,
localized_name: st(nameKey, inputName)
})
}
/**
* @internal Add a widget to the node. For both primitive types and custom widgets
* (unless `socketless`), an input socket is also added.
*/
#addInputWidget(inputSpec: InputSpec) {
const widgetInputSpec = { ...inputSpec }
if (inputSpec.widgetType) {
widgetInputSpec.type = inputSpec.widgetType
}
const inputName = inputSpec.name
const nameKey = `${this.#nodeKey}.inputs.${normalizeI18nKey(inputName)}.name`
const widgetConstructor = widgetStore.widgets.get(widgetInputSpec.type)
if (!widgetConstructor || inputSpec.forceInput) return
const {
widget,
minWidth = 1,
minHeight = 1
} = widgetConstructor(
this,
inputName,
transformInputSpecV2ToV1(widgetInputSpec),
app
) ?? {}
if (widget) {
widget.label = st(nameKey, widget.label ?? inputName)
widget.options ??= {}
Object.assign(widget.options, {
advanced: inputSpec.advanced,
hidden: inputSpec.hidden
})
}
if (!widget?.options?.socketless) {
const inputSpecV1 = transformInputSpecV2ToV1(widgetInputSpec)
this.addInput(inputName, inputSpec.type, {
shape: inputSpec.isOptional ? RenderShape.HollowCircle : undefined,
localized_name: st(nameKey, inputName),
widget: { name: inputName, [GET_CONFIG]: () => inputSpecV1 }
})
}
this.#initialMinSize.width = Math.max(
this.#initialMinSize.width,
minWidth
)
this.#initialMinSize.height = Math.max(
this.#initialMinSize.height,
minHeight
)
}
/**
* @internal Add inputs to the node.
*/
#addInputs(inputs: Record<string, InputSpec>) {
for (const inputSpec of Object.values(inputs))
this.#addInputSocket(inputSpec)
for (const inputSpec of Object.values(inputs))
this.#addInputWidget(inputSpec)
}
/**
* @internal Add outputs to the node.
*/
#addOutputs(outputs: OutputSpec[]) {
for (const output of outputs) {
const { name, type, is_list } = output
const shapeOptions = is_list ? { shape: LiteGraph.GRID_SHAPE } : {}
const nameKey = `${this.#nodeKey}.outputs.${output.index}.name`
const typeKey = `dataTypes.${normalizeI18nKey(type)}`
const outputOptions = {
...shapeOptions,
// If the output name is different from the output type, use the output name.
// e.g.
// - type ("INT"); name ("Positive") => translate name
// - type ("FLOAT"); name ("FLOAT") => translate type
localized_name:
type !== name ? st(nameKey, name) : st(typeKey, name)
}
this.addOutput(name, type, outputOptions)
}
}
/**
* @internal Set the initial size of the node.
*/
#setInitialSize() {
const s = this.computeSize()
// Expand the width a little to fit widget values on screen.
const pad =
this.widgets?.length &&
!useSettingStore().get('LiteGraph.Node.DefaultPadding')
s[0] = Math.max(this.#initialMinSize.width, s[0] + (pad ? 60 : 0))
s[1] = Math.max(this.#initialMinSize.height, s[1])
this.setSize(s)
}
/**
* Configure the node from a serialised node. Keep 'name', 'type', 'shape',
* and 'localized_name' information from the original node definition.
*/
override configure(data: ISerialisedNode): void {
const RESERVED_KEYS = ['name', 'type', 'shape', 'localized_name']
// Note: input name is unique in a node definition, so we can lookup
// input by name.
const inputByName = new Map<string, ISerialisableNodeInput>(
data.inputs?.map((input) => [input.name, input]) ?? []
)
// Inputs defined by the node definition.
const definedInputNames = new Set(
this.inputs.map((input) => input.name)
)
const definedInputs = this.inputs.map((input) => {
const inputData = inputByName.get(input.name)
return inputData
? {
...inputData,
// Whether the input has associated widget follows the
// original node definition.
..._.pick(input, RESERVED_KEYS.concat('widget'))
}
: input
})
// Extra inputs that potentially dynamically added by custom js logic.
const extraInputs = data.inputs?.filter(
(input) => !definedInputNames.has(input.name)
)
data.inputs = [...definedInputs, ...(extraInputs ?? [])]
// Note: output name is not unique, so we cannot lookup output by name.
// Use index instead.
data.outputs = _.zip(this.outputs, data.outputs).map(
([output, outputData]) => {
// If there are extra outputs in the serialised node, use them directly.
// There are currently custom nodes that dynamically add outputs via
// js logic.
if (!output) return outputData as ISerialisableNodeOutput
return outputData
? {
...outputData,
..._.pick(output, RESERVED_KEYS)
}
: output
}
)
data.widgets_values = migrateWidgetsValues(
ComfyNode.nodeData.inputs,
this.widgets ?? [],
data.widgets_values ?? []
)
super.configure(data)
}
}
addNodeContextMenuHandler(node)
addDrawBackgroundHandler(node)
addNodeKeyHandler(node)
// Note: Some extensions expects node.comfyClass to be set in
// `beforeRegisterNodeDef`.
node.prototype.comfyClass = nodeDefV1.name
node.comfyClass = nodeDefV1.name
const nodeDef = new ComfyNodeDefImpl(nodeDefV1)
node.nodeData = nodeDef
LiteGraph.registerNodeType(subgraph.id, node)
// Note: Do not following assignments before `LiteGraph.registerNodeType`
// because `registerNodeType` will overwrite the assignments.
node.category = nodeDef.category
node.title = nodeDef.display_name || nodeDef.name
}
async function registerNodeDef(nodeId: string, nodeDefV1: ComfyNodeDefV1) {
const node = class ComfyNode extends LGraphNode {
static comfyClass: string
@@ -880,10 +622,8 @@ export const useLitegraphService = () => {
options
)
const graph = useWorkflowStore().activeSubgraph ?? app.graph
// @ts-expect-error fixme ts strict error
graph.add(node)
app.graph.add(node)
// @ts-expect-error fixme ts strict error
return node
}
@@ -925,7 +665,6 @@ export const useLitegraphService = () => {
return {
registerNodeDef,
registerSubgraphNodeDef,
addNodeOnGraph,
getCanvasCenter,
goToNode,

View File

@@ -5,9 +5,12 @@ import Load3d from '@/extensions/core/load3d/Load3d'
import Load3dAnimation from '@/extensions/core/load3d/Load3dAnimation'
import type { CustomInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
type Load3dReadyCallback = (load3d: Load3d | Load3dAnimation) => void
export class Load3dService {
private static instance: Load3dService
private nodeToLoad3dMap = new Map<LGraphNode, Load3d | Load3dAnimation>()
private pendingCallbacks = new Map<LGraphNode, Load3dReadyCallback[]>()
private constructor() {}
@@ -60,6 +63,13 @@ export class Load3dService {
this.nodeToLoad3dMap.set(rawNode, instance)
const callbacks = this.pendingCallbacks.get(rawNode)
if (callbacks) {
callbacks.forEach((callback) => callback(instance))
this.pendingCallbacks.delete(rawNode)
}
return instance
}
@@ -69,6 +79,24 @@ export class Load3dService {
return this.nodeToLoad3dMap.get(rawNode) || null
}
waitForLoad3d(node: LGraphNode, callback: Load3dReadyCallback): void {
const rawNode = toRaw(node)
const existingInstance = this.nodeToLoad3dMap.get(rawNode)
if (existingInstance) {
callback(existingInstance)
return
}
if (!this.pendingCallbacks.has(rawNode)) {
this.pendingCallbacks.set(rawNode, [])
}
this.pendingCallbacks.get(rawNode)!.push(callback)
}
getNodeByLoad3d(load3d: Load3d | Load3dAnimation): LGraphNode | null {
for (const [node, instance] of this.nodeToLoad3dMap) {
if (instance === load3d) {
@@ -88,12 +116,15 @@ export class Load3dService {
this.nodeToLoad3dMap.delete(rawNode)
}
this.pendingCallbacks.delete(rawNode)
}
clear() {
for (const [node] of this.nodeToLoad3dMap) {
this.removeLoad3d(node)
}
this.pendingCallbacks.clear()
}
}

View File

@@ -1,91 +0,0 @@
import {
type ExportedSubgraph,
type ExportedSubgraphInstance,
type Subgraph
} from '@comfyorg/litegraph'
import type { ComfyWorkflowJSON } from '@/schemas/comfyWorkflowSchema'
import type { ComfyNodeDef as ComfyNodeDefV1 } from '@/schemas/nodeDefSchema'
import { app as comfyApp } from '@/scripts/app'
import { useNodeDefStore } from '@/stores/nodeDefStore'
import { useLitegraphService } from './litegraphService'
export const useSubgraphService = () => {
const nodeDefStore = useNodeDefStore()
/** Loads a single subgraph definition and registers it with the node def store */
function registerLitegraphNode(
nodeDef: ComfyNodeDefV1,
subgraph: Subgraph,
exportedSubgraph: ExportedSubgraph
) {
const instanceData: ExportedSubgraphInstance = {
id: -1,
type: exportedSubgraph.id,
pos: [0, 0],
size: [100, 100],
inputs: [],
outputs: [],
flags: {},
order: 0,
mode: 0
}
useLitegraphService().registerSubgraphNodeDef(
nodeDef,
subgraph,
instanceData
)
}
function createNodeDef(exportedSubgraph: ExportedSubgraph) {
const { id, name } = exportedSubgraph
const nodeDef: ComfyNodeDefV1 = {
input: { required: {} },
output: [],
output_is_list: [],
output_name: [],
output_tooltips: [],
name: id,
display_name: name,
description: `Subgraph node for ${name}`,
category: 'subgraph',
output_node: false,
python_module: 'nodes'
}
nodeDefStore.addNodeDef(nodeDef)
return nodeDef
}
/** Loads all exported subgraph definitions from workflow */
function loadSubgraphs(graphData: ComfyWorkflowJSON) {
const subgraphs = graphData.definitions?.subgraphs
if (!subgraphs) return
// Assertion: overriding Zod schema
for (const subgraphData of subgraphs as ExportedSubgraph[]) {
const subgraph =
comfyApp.graph.subgraphs.get(subgraphData.id) ??
comfyApp.graph.createSubgraph(subgraphData)
registerNewSubgraph(subgraph, subgraphData)
}
}
/** Registers a new subgraph (e.g. user converted from nodes) */
function registerNewSubgraph(
subgraph: Subgraph,
exportedSubgraph: ExportedSubgraph
) {
const nodeDef = createNodeDef(exportedSubgraph)
registerLitegraphNode(nodeDef, subgraph, exportedSubgraph)
}
return {
loadSubgraphs,
registerNewSubgraph
}
}

View File

@@ -7,7 +7,6 @@ import { ComfyWorkflowJSON } from '@/schemas/comfyWorkflowSchema'
import { app } from '@/scripts/app'
import { blankGraph, defaultGraph } from '@/scripts/defaultGraph'
import { downloadBlob } from '@/scripts/utils'
import { useDomWidgetStore } from '@/stores/domWidgetStore'
import { useSettingStore } from '@/stores/settingStore'
import { useToastStore } from '@/stores/toastStore'
import { ComfyWorkflow, useWorkflowStore } from '@/stores/workflowStore'
@@ -21,7 +20,6 @@ export const useWorkflowService = () => {
const workflowStore = useWorkflowStore()
const toastStore = useToastStore()
const dialogService = useDialogService()
const domWidgetStore = useDomWidgetStore()
async function getFilename(defaultName: string): Promise<string | null> {
if (settingStore.get('Comfy.PromptFilename')) {
@@ -287,8 +285,11 @@ export const useWorkflowService = () => {
*/
const beforeLoadNewGraph = () => {
// Use workspaceStore here as it is patched in unit tests.
useWorkspaceStore().workflow.activeWorkflow?.changeTracker?.store()
domWidgetStore.clear()
const workflowStore = useWorkspaceStore().workflow
const activeWorkflow = workflowStore.activeWorkflow
if (activeWorkflow) {
activeWorkflow.changeTracker.store()
}
}
/**
@@ -344,7 +345,8 @@ export const useWorkflowService = () => {
options: { position?: Vector2 } = {}
) => {
const loadedWorkflow = await workflow.load()
const workflowJSON = toRaw(loadedWorkflow.initialState)
const data = loadedWorkflow.initialState
const workflowJSON = data
const old = localStorage.getItem('litegrapheditor_clipboard')
// unknown conversion: ComfyWorkflowJSON is stricter than LiteGraph's
// serialisation schema.

View File

@@ -2,7 +2,7 @@
* Stores all DOM widgets that are used in the canvas.
*/
import { defineStore } from 'pinia'
import { type Raw, computed, markRaw, ref } from 'vue'
import { type Raw, markRaw, ref } from 'vue'
import type { PositionConfig } from '@/composables/element/useAbsolutePosition'
import type { BaseDOMWidget } from '@/scripts/domWidget'
@@ -13,20 +13,11 @@ export interface DomWidgetState extends PositionConfig {
visible: boolean
readonly: boolean
zIndex: number
/** If the widget belongs to the current graph/subgraph. */
active: boolean
}
export const useDomWidgetStore = defineStore('domWidget', () => {
const widgetStates = ref<Map<string, DomWidgetState>>(new Map())
const activeWidgetStates = computed(() =>
[...widgetStates.value.values()].filter((state) => state.active)
)
const inactiveWidgetStates = computed(() =>
[...widgetStates.value.values()].filter((state) => !state.active)
)
// Register a widget with the store
const registerWidget = <V extends object | string>(
widget: BaseDOMWidget<V>
@@ -37,8 +28,7 @@ export const useDomWidgetStore = defineStore('domWidget', () => {
readonly: false,
zIndex: 0,
pos: [0, 0],
size: [0, 0],
active: true
size: [0, 0]
})
}
@@ -47,28 +37,9 @@ export const useDomWidgetStore = defineStore('domWidget', () => {
widgetStates.value.delete(widgetId)
}
const activateWidget = (widgetId: string) => {
const state = widgetStates.value.get(widgetId)
if (state) state.active = true
}
const deactivateWidget = (widgetId: string) => {
const state = widgetStates.value.get(widgetId)
if (state) state.active = false
}
const clear = () => {
widgetStates.value.clear()
}
return {
widgetStates,
activeWidgetStates,
inactiveWidgetStates,
registerWidget,
unregisterWidget,
activateWidget,
deactivateWidget,
clear
unregisterWidget
}
})

View File

@@ -292,7 +292,8 @@ export const useNodeDefStore = defineStore('nodeDef', () => {
}
function fromLGraphNode(node: LGraphNode): ComfyNodeDefImpl | null {
// Frontend-only nodes don't have nodeDef
// @ts-expect-error Optional chaining used in index
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore Optional chaining used in index
return nodeDefsByName.value[node.constructor?.nodeData?.name] ?? null
}

View File

@@ -106,6 +106,22 @@ export class ResultItemImpl {
return undefined
}
get htmlAudioType(): string | undefined {
if (this.isMp3) {
return 'audio/mpeg'
}
if (this.isWav) {
return 'audio/wav'
}
if (this.isOgg) {
return 'audio/ogg'
}
if (this.isFlac) {
return 'audio/flac'
}
return undefined
}
get isGif(): boolean {
return this.filename.endsWith('.gif')
}
@@ -130,21 +146,55 @@ export class ResultItemImpl {
return this.isGif || this.isWebp
}
get isMp3(): boolean {
return this.filename.endsWith('.mp3')
}
get isWav(): boolean {
return this.filename.endsWith('.wav')
}
get isOgg(): boolean {
return this.filename.endsWith('.ogg')
}
get isFlac(): boolean {
return this.filename.endsWith('.flac')
}
get isAudioBySuffix(): boolean {
return this.isMp3 || this.isWav || this.isOgg || this.isFlac
}
get isVideo(): boolean {
const isVideoByType =
this.mediaType === 'video' || !!this.format?.startsWith('video/')
return this.isVideoBySuffix || (isVideoByType && !this.isImageBySuffix)
return (
this.isVideoBySuffix ||
(isVideoByType && !this.isImageBySuffix && !this.isAudioBySuffix)
)
}
get isImage(): boolean {
return (
this.isImageBySuffix ||
(this.mediaType === 'images' && !this.isVideoBySuffix)
(this.mediaType === 'images' &&
!this.isVideoBySuffix &&
!this.isAudioBySuffix)
)
}
get isAudio(): boolean {
const isAudioByType =
this.mediaType === 'audio' || !!this.format?.startsWith('audio/')
return (
this.isAudioBySuffix ||
(isAudioByType && !this.isImageBySuffix && !this.isVideoBySuffix)
)
}
get supportsPreview(): boolean {
return this.isImage || this.isVideo
return this.isImage || this.isVideo || this.isAudio
}
}

View File

@@ -1,87 +0,0 @@
import type { Subgraph } from '@comfyorg/litegraph'
import { defineStore } from 'pinia'
import { computed, shallowReactive, shallowRef, watch } from 'vue'
import { app } from '@/scripts/app'
import { isNonNullish } from '@/utils/typeGuardUtil'
import { useWorkflowStore } from './workflowStore'
/**
* Stores the current subgraph navigation state; a stack representing subgraph
* navigation history from the root graph to the subgraph that is currently
* open.
*/
export const useSubgraphNavigationStore = defineStore(
'subgraphNavigation',
() => {
const workflowStore = useWorkflowStore()
/** The currently opened subgraph. */
const activeSubgraph = shallowRef<Subgraph>()
/** The stack of subgraph IDs from the root graph to the currently opened subgraph. */
const idStack = shallowReactive<string[]>([])
/**
* A stack representing subgraph navigation history from the root graph to
* the current opened subgraph.
*/
const navigationStack = computed(() =>
idStack.map((id) => app.graph.subgraphs.get(id)).filter(isNonNullish)
)
/**
* Restore the navigation stack from a list of subgraph IDs.
* @param subgraphIds The list of subgraph IDs to restore the navigation stack from.
* @see exportState
*/
const restoreState = (subgraphIds: string[]) => {
idStack.length = 0
for (const id of subgraphIds) idStack.push(id)
}
/**
* Export the navigation stack as a list of subgraph IDs.
* @returns The list of subgraph IDs, ending with the currently active subgraph.
* @see restoreState
*/
const exportState = () => [...idStack]
// Reset on workflow change
watch(
() => workflowStore.activeWorkflow,
() => (idStack.length = 0)
)
// Update navigation stack when opened subgraph changes
watch(
() => workflowStore.activeSubgraph,
(subgraph) => {
// Navigated back to the root graph
if (!subgraph) {
idStack.length = 0
return
}
const index = idStack.lastIndexOf(subgraph.id)
const lastIndex = idStack.length - 1
if (index === -1) {
// Opened a new subgraph
idStack.push(subgraph.id)
} else if (index !== lastIndex) {
// Navigated to a different subgraph
idStack.splice(index + 1, lastIndex - index)
}
}
)
return {
activeSubgraph,
navigationStack,
restoreState,
exportState
}
}
)

View File

@@ -1,7 +1,6 @@
import type { Subgraph } from '@comfyorg/litegraph'
import _ from 'lodash'
import { defineStore } from 'pinia'
import { computed, markRaw, ref, shallowRef, watch } from 'vue'
import { computed, markRaw, ref, watch } from 'vue'
import { ComfyWorkflowJSON } from '@/schemas/comfyWorkflowSchema'
import { api } from '@/scripts/api'
@@ -157,9 +156,10 @@ export interface WorkflowStore {
syncWorkflows: (dir?: string) => Promise<void>
reorderWorkflows: (from: number, to: number) => void
/** An ordered list of all parent subgraphs, ending with the current subgraph. */
subgraphNamePath: string[]
/** `true` if any subgraph is currently being viewed. */
isSubgraphActive: boolean
activeSubgraph: Subgraph | undefined
/** Updates the {@link subgraphNamePath} and {@link isSubgraphActive} values. */
updateActiveGraph: () => void
}
@@ -427,19 +427,25 @@ export const useWorkflowStore = defineStore('workflow', () => {
}
}
/** @see WorkflowStore.subgraphNamePath */
const subgraphNamePath = ref<string[]>([])
/** @see WorkflowStore.isSubgraphActive */
const isSubgraphActive = ref(false)
/** @see WorkflowStore.activeSubgraph */
const activeSubgraph = shallowRef<Subgraph>()
/** @see WorkflowStore.updateActiveGraph */
const updateActiveGraph = () => {
activeSubgraph.value = comfyApp.canvas?.subgraph
if (!comfyApp.canvas) return
const { subgraph } = comfyApp.canvas
isSubgraphActive.value = isSubgraph(subgraph)
if (subgraph) {
const [, ...pathFromRoot] = subgraph.pathToRootGraph
subgraphNamePath.value = pathFromRoot.map((graph) => graph.name)
} else {
subgraphNamePath.value = []
}
}
watch(activeWorkflow, updateActiveGraph)
@@ -467,8 +473,8 @@ export const useWorkflowStore = defineStore('workflow', () => {
getWorkflowByPath,
syncWorkflows,
subgraphNamePath,
isSubgraphActive,
activeSubgraph,
updateActiveGraph
}
}) as () => WorkflowStore

View File

@@ -75,22 +75,6 @@ declare module '@comfyorg/litegraph' {
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
interface BaseWidget extends IBaseWidget {}
/** Actual members required for execution. */
type ExecutableLGraphNode = Pick<
LGraphNode,
| 'id'
| 'type'
| 'comfyClass'
| 'title'
| 'mode'
| 'inputs'
| 'widgets'
| 'isVirtualNode'
| 'applyToGraph'
| 'getInputNode'
| 'getInputLink'
>
interface LGraphNode {
constructor: LGraphNodeConstructor
@@ -104,10 +88,7 @@ declare module '@comfyorg/litegraph' {
/** @deprecated groupNode */
setInnerNodes?(nodes: LGraphNode[]): void
/** Originally a group node API. */
getInnerNodes?(
nodes?: ExecutableLGraphNode[],
subgraphs?: WeakSet<LGraphNode>
): ExecutableLGraphNode[]
getInnerNodes?(): LGraphNode[]
/** @deprecated groupNode */
convertToNodes?(): LGraphNode[]
recreate?(): Promise<LGraphNode>

View File

@@ -11,6 +11,7 @@ export type SettingInputType =
| 'color'
| 'url'
| 'hidden'
| 'backgroundImage'
export type SettingCustomRenderer = (
name: string,

View File

@@ -21,9 +21,3 @@ export const isAbortError = (
export const isSubgraph = (
item: LGraph | Subgraph | undefined | null
): item is Subgraph => item?.isRootGraph === false
/**
* Check if an item is non-nullish.
*/
export const isNonNullish = <T>(item: T | undefined | null): item is T =>
item != null

View File

@@ -1,127 +0,0 @@
import { mount } from '@vue/test-utils'
import PrimeVue from 'primevue/config'
import Panel from 'primevue/panel'
import { describe, expect, it, vi } from 'vitest'
import SelectionToolbox from '@/components/graph/SelectionToolbox.vue'
// Mock all child components
vi.mock('@/components/graph/selectionToolbox/BypassButton.vue', () => ({
default: {
name: 'BypassButton',
render: () => null
}
}))
vi.mock('@/components/graph/selectionToolbox/ColorPickerButton.vue', () => ({
default: {
name: 'ColorPickerButton',
render: () => null
}
}))
vi.mock('@/components/graph/selectionToolbox/DeleteButton.vue', () => ({
default: {
name: 'DeleteButton',
render: () => null
}
}))
vi.mock('@/components/graph/selectionToolbox/ExecuteButton.vue', () => ({
default: {
name: 'ExecuteButton',
render: () => null
}
}))
vi.mock('@/components/graph/selectionToolbox/MaskEditorButton.vue', () => ({
default: {
name: 'MaskEditorButton',
render: () => null
}
}))
vi.mock('@/components/graph/selectionToolbox/PinButton.vue', () => ({
default: {
name: 'PinButton',
render: () => null
}
}))
vi.mock('@/components/graph/selectionToolbox/RefreshButton.vue', () => ({
default: {
name: 'RefreshButton',
render: () => null
}
}))
vi.mock(
'@/components/graph/selectionToolbox/ConvertToSubgraphButton.vue',
() => ({
default: {
name: 'ConvertToSubgraphButton',
render: () => null
}
})
)
vi.mock(
'@/components/graph/selectionToolbox/ExtensionCommandButton.vue',
() => ({
default: {
name: 'ExtensionCommandButton',
props: ['command'],
render: () => null
}
})
)
// Mock extension service and stores
vi.mock('@/services/extensionService', () => ({
useExtensionService: vi.fn(() => ({
invokeExtensions: vi.fn(() => [])
}))
}))
vi.mock('@/stores/commandStore', () => ({
useCommandStore: vi.fn(() => ({
getCommand: vi.fn()
}))
}))
vi.mock('@/stores/graphStore', () => ({
useCanvasStore: vi.fn(() => ({
selectedItems: []
}))
}))
describe('SelectionToolbox', () => {
const mountComponent = () => {
return mount(SelectionToolbox, {
global: {
plugins: [PrimeVue],
components: { Panel }
}
})
}
it('renders all toolbox buttons', () => {
const wrapper = mountComponent()
// Verify all buttons are rendered
expect(wrapper.findComponent({ name: 'ExecuteButton' }).exists()).toBe(true)
expect(wrapper.findComponent({ name: 'ColorPickerButton' }).exists()).toBe(
true
)
expect(wrapper.findComponent({ name: 'BypassButton' }).exists()).toBe(true)
expect(wrapper.findComponent({ name: 'PinButton' }).exists()).toBe(true)
expect(wrapper.findComponent({ name: 'MaskEditorButton' }).exists()).toBe(
true
)
expect(wrapper.findComponent({ name: 'DeleteButton' }).exists()).toBe(true)
expect(wrapper.findComponent({ name: 'RefreshButton' }).exists()).toBe(true)
expect(
wrapper.findComponent({ name: 'ConvertToSubgraphButton' }).exists()
).toBe(true)
})
})

View File

@@ -1,47 +0,0 @@
import { mount } from '@vue/test-utils'
import Button from 'primevue/button'
import PrimeVue from 'primevue/config'
import Tooltip from 'primevue/tooltip'
import { describe, expect, it, vi } from 'vitest'
import ConvertToSubgraphButton from '@/components/graph/selectionToolbox/ConvertToSubgraphButton.vue'
// Mock the command store
vi.mock('@/stores/commandStore', () => ({
useCommandStore: vi.fn(() => ({
execute: vi.fn()
}))
}))
// Mock i18n
vi.mock('vue-i18n', () => ({
useI18n: vi.fn(() => ({
t: vi.fn((key) => key)
}))
}))
describe('ConvertToSubgraphButton', () => {
const mountComponent = () => {
return mount(ConvertToSubgraphButton, {
global: {
plugins: [PrimeVue],
directives: { tooltip: Tooltip },
components: { Button }
}
})
}
it('renders correctly', () => {
const wrapper = mountComponent()
expect(wrapper.find('button').exists()).toBe(true)
expect(wrapper.find('.pi-box').exists()).toBe(true)
})
it('has the correct tooltip', () => {
const wrapper = mountComponent()
const buttonElement = wrapper.find('button')
// Check that the tooltip directive is applied
expect(buttonElement.attributes('data-pd-tooltip')).toBe('true')
})
})

View File

@@ -115,4 +115,42 @@ describe('TaskItemImpl', () => {
expect(output.isVideo).toBe(true)
expect(output.isImage).toBe(false)
})
describe('audio format detection', () => {
const audioFormats = [
{ extension: 'mp3', mimeType: 'audio/mpeg' },
{ extension: 'wav', mimeType: 'audio/wav' },
{ extension: 'ogg', mimeType: 'audio/ogg' },
{ extension: 'flac', mimeType: 'audio/flac' }
]
audioFormats.forEach(({ extension, mimeType }) => {
it(`should recognize ${extension} audio`, () => {
const taskItem = new TaskItemImpl(
'History',
[0, 'prompt-id', {}, { client_id: 'client-id' }, []],
{ status_str: 'success', messages: [], completed: true },
{
'node-1': {
audio: [
{
filename: `test.${extension}`,
type: 'output',
subfolder: ''
}
]
}
}
)
const output = taskItem.flatOutputs[0]
expect(output.htmlAudioType).toBe(mimeType)
expect(output.isAudio).toBe(true)
expect(output.isVideo).toBe(false)
expect(output.isImage).toBe(false)
expect(output.supportsPreview).toBe(true)
})
})
})
})

View File

@@ -492,7 +492,7 @@ describe('useWorkflowStore', () => {
// Assert
console.debug(store.isSubgraphActive)
expect(store.isSubgraphActive).toBe(false) // Should default to false
expect(store.activeSubgraph).toBeUndefined() // Should default to empty
expect(store.subgraphNamePath).toEqual([]) // Should default to empty
})
it('should correctly update state when the root graph is active', async () => {
@@ -505,7 +505,7 @@ describe('useWorkflowStore', () => {
// Assert: Check store state
expect(store.isSubgraphActive).toBe(false)
expect(store.activeSubgraph).toBeUndefined()
expect(store.subgraphNamePath).toEqual([]) // Path is empty for root graph
})
it('should correctly update state when a subgraph is active', async () => {
@@ -527,7 +527,10 @@ describe('useWorkflowStore', () => {
// Assert: Check store state
expect(store.isSubgraphActive).toBe(true)
expect(store.activeSubgraph).toEqual(mockSubgraph)
expect(store.subgraphNamePath).toEqual([
'Level 1 Subgraph',
'Level 2 Subgraph'
]) // Path excludes the root
})
it('should update automatically when activeWorkflow changes', async () => {
@@ -545,7 +548,7 @@ describe('useWorkflowStore', () => {
// Verify initial state
expect(store.isSubgraphActive).toBe(true)
expect(store.activeSubgraph).toEqual(initialSubgraph)
expect(store.subgraphNamePath).toEqual(['Initial Subgraph'])
// Act: Change the active workflow
const workflow2 = store.createTemporary('workflow2.json')
@@ -566,7 +569,7 @@ describe('useWorkflowStore', () => {
// Assert: Check that the state was updated by the watcher based on the *new* canvas state
expect(store.isSubgraphActive).toBe(false) // Should reflect the change to undefined subgraph
expect(store.activeSubgraph).toBeUndefined()
expect(store.subgraphNamePath).toEqual([]) // Path should be empty for root
})
})
})

View File

@@ -8,11 +8,7 @@ import { createHtmlPlugin } from 'vite-plugin-html'
import vueDevTools from 'vite-plugin-vue-devtools'
import type { UserConfigExport } from 'vitest/config'
import {
addElementVnodeExportPlugin,
comfyAPIPlugin,
generateImportMapPlugin
} from './build/plugins'
import { comfyAPIPlugin, generateImportMapPlugin } from './build/plugins'
dotenv.config()
@@ -77,11 +73,40 @@ export default defineConfig({
: [vue()]),
comfyAPIPlugin(IS_DEV),
generateImportMapPlugin([
{ name: 'vue', pattern: /[\\/]node_modules[\\/]vue[\\/]/ },
{ name: 'primevue', pattern: /[\\/]node_modules[\\/]primevue[\\/]/ },
{ name: 'vue-i18n', pattern: /[\\/]node_modules[\\/]vue-i18n[\\/]/ }
{
name: 'vue',
pattern: 'vue',
entry: './dist/vue.esm-browser.prod.js'
},
{
name: 'vue-i18n',
pattern: 'vue-i18n',
entry: './dist/vue-i18n.esm-browser.prod.js'
},
{
name: 'primevue',
pattern: /^primevue\/?.*/,
entry: './index.mjs',
recursiveDependence: true
},
{
name: '@primevue/themes',
pattern: /^@primevue\/themes\/?.*/,
entry: './index.mjs',
recursiveDependence: true
},
{
name: '@primevue/forms',
pattern: /^@primevue\/forms\/?.*/,
entry: './index.mjs',
recursiveDependence: true,
override: {
'@primeuix/forms': {
entry: ''
}
}
}
]),
addElementVnodeExportPlugin(),
Icons({
compiler: 'vue3'