Compare commits

...

44 Commits

Author SHA1 Message Date
filtered
5d87cf4f8a Increment version to 1.21.5 (#4049) 2025-06-02 06:33:32 +10:00
filtered
ec26daa020 Revert "[Dev] Add Playwright MCP for Local Development (#4028)" (#4048) 2025-06-01 13:24:57 -07:00
filtered
ca72839af9 Increment version to 1.21.4 (#4046) 2025-06-02 03:30:55 +10:00
filtered
bb400f181a Revert "[refactor] Refactor file handling (#3955)"
This reverts commit 30ee669f5c.
2025-06-01 09:56:45 -07:00
filtered
27dcc19a4b Revert "[fix] Remove dynamic import timing issue causing Playwright test flakiness (#4031)"
This reverts commit afac449f41.
2025-06-01 09:56:31 -07:00
Comfy Org PR Bot
6289ac9182 1.21.3 (#4035)
Co-authored-by: webfiltered <176114999+webfiltered@users.noreply.github.com>
2025-06-01 00:31:35 +00:00
Christian Byrne
86a7dd05a3 [Dev] Add Playwright MCP for Local Development (#4028)
Co-authored-by: github-actions <github-actions@github.com>
2025-05-31 13:51:37 -07:00
Christian Byrne
dee00edc5f [feat] Add node library sorting and grouping controls (#4024)
Co-authored-by: github-actions <github-actions@github.com>
2025-05-31 17:39:39 +10:00
Christian Byrne
afac449f41 [fix] Remove dynamic import timing issue causing Playwright test flakiness (#4031) 2025-05-31 14:01:13 +10:00
filtered
aca1a2a194 Revert "Allow extensions to define pinia stores" (#4027) 2025-05-31 04:12:59 +10:00
filtered
4dfe75d68b Add GH types to issue templates (#3991) 2025-05-30 02:57:10 -07:00
Christian Byrne
2c37dba143 [docs] Add Claude command for adding missing i18n strings (#4023) 2025-05-30 02:22:40 -07:00
Christian Byrne
3936454ffd [feat] Add logout button to user popover (#4022) 2025-05-30 02:17:00 -07:00
Christian Byrne
30ee669f5c [refactor] Refactor file handling (#3955) 2025-05-30 02:05:41 -07:00
Terry Jia
811ddd6165 Allow extensions to define pinia stores (#4018) 2025-05-30 12:05:03 +10:00
filtered
0cdaa512c8 Allow extensions to raise their own Vue dialogs (#4008) 2025-05-29 21:05:52 +10:00
filtered
3a514ca63b Fix dragging preview image does nothing (#4009) 2025-05-29 04:50:04 +10:00
Terry Jia
405b5fc5b7 Add copy url button (#4000)
Co-authored-by: github-actions <github-actions@github.com>
2025-05-28 17:55:57 +10:00
Comfy Org PR Bot
0eaf7d11b6 1.21.2 (#4003)
Co-authored-by: webfiltered <176114999+webfiltered@users.noreply.github.com>
2025-05-28 17:09:41 +10:00
Robin Huang
fa58c04b3a [fix] Disable serialization for text preview widget (#4004) 2025-05-28 04:20:26 +00:00
Comfy Org PR Bot
9c84c9e250 [chore] Update litegraph to 0.15.14 (#3998)
Co-authored-by: webfiltered <176114999+webfiltered@users.noreply.github.com>
2025-05-28 00:40:16 +00:00
Terry Jia
6f9f048b4a [3d] fix wrong hasRecording status (#3995) 2025-05-27 13:07:50 +00:00
filtered
768faeee7e [Test] Disable flaky test (#3994) 2025-05-27 21:03:49 +10:00
filtered
eba81efb4b [Test] Fix husky rejects all test file commits (#3993) 2025-05-27 20:50:15 +10:00
filtered
f9d92b8198 Fix native reroute chaining (#3989) 2025-05-27 16:57:36 +10:00
filtered
c4bbe7fee1 Update Claude rules: no @ts-expect-error (#3985) 2025-05-27 13:23:49 +10:00
Comfy Org PR Bot
8f4f5f8e5f [chore] Update litegraph to 0.15.13 (#3983)
Co-authored-by: webfiltered <176114999+webfiltered@users.noreply.github.com>
2025-05-26 22:34:53 +00:00
Comfy Org PR Bot
9e137d9924 1.21.1 (#3982)
Co-authored-by: webfiltered <176114999+webfiltered@users.noreply.github.com>
2025-05-26 08:31:56 +00:00
Comfy Org PR Bot
a084b55db7 [chore] Update litegraph to 0.15.12 (#3981)
Co-authored-by: webfiltered <176114999+webfiltered@users.noreply.github.com>
2025-05-26 07:39:07 +00:00
filtered
835f318999 Report if Forgot Password? cannot be processed (#3979) 2025-05-26 11:10:05 +10:00
filtered
c35d44c491 [TS] Fix workflow store type assertions (#3978) 2025-05-26 05:39:30 +10:00
filtered
38d3e15103 Never restore view when setting is disabled (#3975) 2025-05-24 22:47:08 +10: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
80 changed files with 2763 additions and 466 deletions

View File

@@ -0,0 +1,43 @@
# Add Missing i18n Translations
## Task: Add English translations for all new localized strings
### Step 1: Identify new translation keys
Find all translation keys that were added in the current branch's changes. These keys appear as arguments to translation functions: `t()`, `st()`, `$t()`, or similar i18n functions.
### Step 2: Add translations to English locale file
For each new translation key found, add the corresponding English text to the file `src/locales/en/main.json`.
### Key-to-JSON mapping rules:
- Translation keys use dot notation to represent nested JSON structure
- Convert dot notation to nested JSON objects when adding to the locale file
- Example: The key `g.user.name` maps to:
```json
{
"g": {
"user": {
"name": "User Name"
}
}
}
```
### Important notes:
1. **Only modify the English locale file** (`src/locales/en/main.json`)
2. **Do not modify other locale files** - translations for other languages are automatically generated by the `i18n.yaml` workflow
3. **Exception for manual translations**: Only add translations to non-English locale files if:
- You have specific domain knowledge that would produce a more accurate translation than the automated system
- The automated translation would likely be incorrect due to technical terminology or context-specific meaning
### Example workflow:
1. If you added `t('settings.advanced.enable')` in a Vue component
2. Add to `src/locales/en/main.json`:
```json
{
"settings": {
"advanced": {
"enable": "Enable advanced settings"
}
}
}
```

View File

@@ -1,7 +1,9 @@
name: Bug Report
description: "Something is not behaving as expected."
title: "[Bug]: "
description: 'Something is not behaving as expected.'
title: '[Bug]: '
labels: ['Potential Bug']
type: Bug
body:
- type: markdown
attributes:

View File

@@ -1,7 +1,8 @@
name: Feature Request
description: Suggest an idea for this project
title: "[Feature Request]: "
labels: ["enhancement"]
title: '[Feature Request]: '
labels: ['enhancement']
type: Feature
body:
- type: checkboxes

View File

@@ -1,6 +1,7 @@
- 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 +15,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()
@@ -32,3 +35,4 @@
- Follow Vue 3 style guide and naming conventions
- Use Vite for fast development and building
- Use vue-i18n in composition API for any string literals. Place new translation entries in src/locales/en/main.json.
- Avoid using `@ts-expect-error` to work around type issues. We needed to employ it to migrate to TypeScript, but it should not be viewed as an accepted practice or standard.

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

After

Width:  |  Height:  |  Size: 57 KiB

View File

@@ -32,7 +32,9 @@ test.describe('Templates', () => {
}
})
test('should have all required thumbnail media for each template', async ({
// TODO: Re-enable this test once issue resolved
// https://github.com/Comfy-Org/ComfyUI_frontend/issues/3992
test.skip('should have all required thumbnail media for each template', async ({
comfyPage
}) => {
test.slow()

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

@@ -24,7 +24,7 @@ export default [
},
parser: tseslint.parser,
parserOptions: {
project: './tsconfig.json',
project: ['./tsconfig.json', './tsconfig.eslint.json'],
ecmaVersion: 2020,
sourceType: 'module',
extraFileExtensions: ['.vue']

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.4",
"version": "1.21.5",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@comfyorg/comfyui-frontend",
"version": "1.20.4",
"version": "1.21.5",
"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.15.11",
"@comfyorg/litegraph": "^0.15.14",
"@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.15.11",
"resolved": "https://registry.npmjs.org/@comfyorg/litegraph/-/litegraph-0.15.11.tgz",
"integrity": "sha512-gU8KK9cid7dXSK1yh3ReUolG0HGT3piKgKLd8YDr21PWl64pQvzy8BIh7W1vKH8ZWictKmNBaG9IRKlsJ667Zw==",
"version": "0.15.14",
"resolved": "https://registry.npmjs.org/@comfyorg/litegraph/-/litegraph-0.15.14.tgz",
"integrity": "sha512-9yERUwRVFPFspXowyg5z97QyF6+UbHG6ZNygvxSOisTCVSPOUeX/E02xcnhB5BHk0bTZCJGg9v2iztXBE5brnA==",
"license": "MIT"
},
"node_modules/@cspotcode/source-map-support": {

View File

@@ -1,7 +1,7 @@
{
"name": "@comfyorg/comfyui-frontend",
"private": true,
"version": "1.20.4",
"version": "1.21.5",
"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.15.11",
"@comfyorg/litegraph": "^0.15.14",
"@primevue/forms": "^4.2.5",
"@primevue/themes": "^4.2.5",
"@sentry/vue": "^8.48.0",

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

@@ -30,6 +30,15 @@
@click="download.triggerBrowserDownload"
/>
</div>
<div>
<Button
:label="$t('g.copyURL')"
size="small"
outlined
:disabled="!!props.error"
@click="copyURL"
/>
</div>
</div>
</template>
@@ -38,6 +47,7 @@ import Button from 'primevue/button'
import Message from 'primevue/message'
import { computed } from 'vue'
import { useCopyToClipboard } from '@/composables/useCopyToClipboard'
import { useDownload } from '@/composables/useDownload'
import { formatSize } from '@/utils/formatUtil'
@@ -49,9 +59,15 @@ const props = defineProps<{
}>()
const label = computed(() => props.label || props.url.split('/').pop())
const hint = computed(() => props.hint || props.url)
const download = useDownload(props.url)
const fileSize = computed(() =>
download.fileSize.value ? formatSize(download.fileSize.value) : '?'
)
const copyURL = async () => {
await copyToClipboard(props.url)
}
const { copyToClipboard } = useCopyToClipboard()
</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

@@ -0,0 +1,293 @@
import { Form } from '@primevue/forms'
import { VueWrapper, mount } from '@vue/test-utils'
import Button from 'primevue/button'
import PrimeVue from 'primevue/config'
import InputText from 'primevue/inputtext'
import Password from 'primevue/password'
import ProgressSpinner from 'primevue/progressspinner'
import ToastService from 'primevue/toastservice'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick } from 'vue'
import { createI18n } from 'vue-i18n'
import enMessages from '@/locales/en/main.json'
import SignInForm from './SignInForm.vue'
type ComponentInstance = InstanceType<typeof SignInForm>
// Mock firebase auth modules
vi.mock('firebase/app', () => ({
initializeApp: vi.fn(),
getApp: vi.fn()
}))
vi.mock('firebase/auth', () => ({
getAuth: vi.fn(),
setPersistence: vi.fn(),
browserLocalPersistence: {},
onAuthStateChanged: vi.fn(),
signInWithEmailAndPassword: vi.fn(),
signOut: vi.fn(),
sendPasswordResetEmail: vi.fn()
}))
// Mock the auth composables and stores
const mockSendPasswordReset = vi.fn()
vi.mock('@/composables/auth/useFirebaseAuthActions', () => ({
useFirebaseAuthActions: vi.fn(() => ({
sendPasswordReset: mockSendPasswordReset
}))
}))
let mockLoading = false
vi.mock('@/stores/firebaseAuthStore', () => ({
useFirebaseAuthStore: vi.fn(() => ({
get loading() {
return mockLoading
}
}))
}))
// Mock toast
const mockToastAdd = vi.fn()
vi.mock('primevue/usetoast', () => ({
useToast: vi.fn(() => ({
add: mockToastAdd
}))
}))
describe('SignInForm', () => {
beforeEach(() => {
vi.clearAllMocks()
mockSendPasswordReset.mockReset()
mockToastAdd.mockReset()
mockLoading = false
})
const mountComponent = (
props = {},
options = {}
): VueWrapper<ComponentInstance> => {
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: { en: enMessages }
})
return mount(SignInForm, {
global: {
plugins: [PrimeVue, i18n, ToastService],
components: {
Form,
Button,
InputText,
Password,
ProgressSpinner
}
},
props,
...options
})
}
describe('Forgot Password Link', () => {
it('shows disabled style when email is empty', async () => {
const wrapper = mountComponent()
await nextTick()
const forgotPasswordSpan = wrapper.find(
'span.text-muted.text-base.font-medium.cursor-pointer'
)
expect(forgotPasswordSpan.classes()).toContain('text-link-disabled')
})
it('shows toast and focuses email input when clicked while disabled', async () => {
const wrapper = mountComponent()
const forgotPasswordSpan = wrapper.find(
'span.text-muted.text-base.font-medium.cursor-pointer'
)
// Mock getElementById to track focus
const mockFocus = vi.fn()
const mockElement = { focus: mockFocus }
vi.spyOn(document, 'getElementById').mockReturnValue(mockElement as any)
// Click forgot password link while email is empty
await forgotPasswordSpan.trigger('click')
await nextTick()
// Should show toast warning
expect(mockToastAdd).toHaveBeenCalledWith({
severity: 'warn',
summary: enMessages.auth.login.emailPlaceholder,
life: 5000
})
// Should focus email input
expect(document.getElementById).toHaveBeenCalledWith(
'comfy-org-sign-in-email'
)
expect(mockFocus).toHaveBeenCalled()
// Should NOT call sendPasswordReset
expect(mockSendPasswordReset).not.toHaveBeenCalled()
})
it('calls handleForgotPassword with email when link is clicked', async () => {
const wrapper = mountComponent()
const component = wrapper.vm as any
// Spy on handleForgotPassword
const handleForgotPasswordSpy = vi.spyOn(
component,
'handleForgotPassword'
)
const forgotPasswordSpan = wrapper.find(
'span.text-muted.text-base.font-medium.cursor-pointer'
)
// Click the forgot password link
await forgotPasswordSpan.trigger('click')
// Should call handleForgotPassword
expect(handleForgotPasswordSpy).toHaveBeenCalled()
})
})
describe('Form Submission', () => {
it('emits submit event when onSubmit is called with valid data', async () => {
const wrapper = mountComponent()
const component = wrapper.vm as any
// Call onSubmit directly with valid data
component.onSubmit({
valid: true,
values: { email: 'test@example.com', password: 'password123' }
})
// Check emitted event
expect(wrapper.emitted('submit')).toBeTruthy()
expect(wrapper.emitted('submit')?.[0]).toEqual([
{
email: 'test@example.com',
password: 'password123'
}
])
})
it('does not emit submit event when form is invalid', async () => {
const wrapper = mountComponent()
const component = wrapper.vm as any
// Call onSubmit with invalid form
component.onSubmit({ valid: false, values: {} })
// Should not emit submit event
expect(wrapper.emitted('submit')).toBeFalsy()
})
})
describe('Loading State', () => {
it('shows spinner when loading', async () => {
mockLoading = true
try {
const wrapper = mountComponent()
await nextTick()
expect(wrapper.findComponent(ProgressSpinner).exists()).toBe(true)
expect(wrapper.findComponent(Button).exists()).toBe(false)
} catch (error) {
// Fallback test - check HTML content if component rendering fails
mockLoading = true
const wrapper = mountComponent()
expect(wrapper.html()).toContain('p-progressspinner')
expect(wrapper.html()).not.toContain('<button')
}
})
it('shows button when not loading', () => {
mockLoading = false
const wrapper = mountComponent()
expect(wrapper.findComponent(ProgressSpinner).exists()).toBe(false)
expect(wrapper.findComponent(Button).exists()).toBe(true)
})
})
describe('Component Structure', () => {
it('renders email input with correct attributes', () => {
const wrapper = mountComponent()
const emailInput = wrapper.findComponent(InputText)
expect(emailInput.attributes('id')).toBe('comfy-org-sign-in-email')
expect(emailInput.attributes('autocomplete')).toBe('email')
expect(emailInput.attributes('name')).toBe('email')
expect(emailInput.attributes('type')).toBe('text')
})
it('renders password input with correct attributes', () => {
const wrapper = mountComponent()
const passwordInput = wrapper.findComponent(Password)
// Check props instead of attributes for Password component
expect(passwordInput.props('inputId')).toBe('comfy-org-sign-in-password')
// Password component passes name as prop, not attribute
expect(passwordInput.props('name')).toBe('password')
expect(passwordInput.props('feedback')).toBe(false)
expect(passwordInput.props('toggleMask')).toBe(true)
})
it('renders form with correct resolver', () => {
const wrapper = mountComponent()
const form = wrapper.findComponent(Form)
expect(form.props('resolver')).toBeDefined()
})
})
describe('Focus Behavior', () => {
it('focuses email input when handleForgotPassword is called with invalid email', async () => {
const wrapper = mountComponent()
const component = wrapper.vm as any
// Mock getElementById to track focus
const mockFocus = vi.fn()
const mockElement = { focus: mockFocus }
vi.spyOn(document, 'getElementById').mockReturnValue(mockElement as any)
// Call handleForgotPassword with no email
await component.handleForgotPassword('', false)
// Should focus email input
expect(document.getElementById).toHaveBeenCalledWith(
'comfy-org-sign-in-email'
)
expect(mockFocus).toHaveBeenCalled()
})
it('does not focus email input when valid email is provided', async () => {
const wrapper = mountComponent()
const component = wrapper.vm as any
// Mock getElementById
const mockFocus = vi.fn()
const mockElement = { focus: mockFocus }
vi.spyOn(document, 'getElementById').mockReturnValue(mockElement as any)
// Call handleForgotPassword with valid email
await component.handleForgotPassword('test@example.com', true)
// Should NOT focus email input
expect(document.getElementById).not.toHaveBeenCalled()
expect(mockFocus).not.toHaveBeenCalled()
// Should call sendPasswordReset
expect(mockSendPasswordReset).toHaveBeenCalledWith('test@example.com')
})
})
})

View File

@@ -7,15 +7,12 @@
>
<!-- Email Field -->
<div class="flex flex-col gap-2">
<label
class="opacity-80 text-base font-medium mb-2"
for="comfy-org-sign-in-email"
>
<label class="opacity-80 text-base font-medium mb-2" :for="emailInputId">
{{ t('auth.login.emailLabel') }}
</label>
<InputText
pt:root:id="comfy-org-sign-in-email"
pt:root:autocomplete="email"
:id="emailInputId"
autocomplete="email"
class="h-10"
name="email"
type="text"
@@ -37,8 +34,11 @@
{{ t('auth.login.passwordLabel') }}
</label>
<span
class="text-muted text-base font-medium cursor-pointer"
@click="handleForgotPassword($form.email?.value)"
class="text-muted text-base font-medium cursor-pointer select-none"
:class="{
'text-link-disabled': !$form.email?.value || $form.email?.invalid
}"
@click="handleForgotPassword($form.email?.value, $form.email?.valid)"
>
{{ t('auth.login.forgotPassword') }}
</span>
@@ -77,6 +77,7 @@ import Button from 'primevue/button'
import InputText from 'primevue/inputtext'
import Password from 'primevue/password'
import ProgressSpinner from 'primevue/progressspinner'
import { useToast } from 'primevue/usetoast'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
@@ -87,6 +88,7 @@ import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
const authStore = useFirebaseAuthStore()
const firebaseAuthActions = useFirebaseAuthActions()
const loading = computed(() => authStore.loading)
const toast = useToast()
const { t } = useI18n()
@@ -94,14 +96,34 @@ const emit = defineEmits<{
submit: [values: SignInData]
}>()
const emailInputId = 'comfy-org-sign-in-email'
const onSubmit = (event: FormSubmitEvent) => {
if (event.valid) {
emit('submit', event.values as SignInData)
}
}
const handleForgotPassword = async (email: string) => {
if (!email) return
const handleForgotPassword = async (
email: string,
isValid: boolean | undefined
) => {
if (!email || !isValid) {
toast.add({
severity: 'warn',
summary: t('auth.login.emailPlaceholder'),
life: 5_000
})
// Focus the email input
document.getElementById(emailInputId)?.focus?.()
return
}
await firebaseAuthActions.sendPasswordReset(email)
}
</script>
<style scoped>
.text-link-disabled {
@apply opacity-50 cursor-not-allowed;
}
</style>

View File

@@ -168,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) => {

View File

@@ -150,8 +150,8 @@ const handleStopRecording = () => {
if (load3DSceneRef.value?.load3d) {
load3DSceneRef.value.load3d.stopRecording()
isRecording.value = false
hasRecording.value = true
recordingDuration.value = load3DSceneRef.value.load3d.getRecordingDuration()
hasRecording.value = recordingDuration.value > 0
}
}
@@ -294,8 +294,8 @@ const listenRecordingStatusChange = (value: boolean) => {
isRecording.value = value
if (!value && load3DSceneRef.value?.load3d) {
hasRecording.value = true
recordingDuration.value = load3DSceneRef.value.load3d.getRecordingDuration()
hasRecording.value = recordingDuration.value > 0
}
}

View File

@@ -168,8 +168,8 @@ const handleStopRecording = () => {
if (sceneRef?.load3d) {
sceneRef.load3d.stopRecording()
isRecording.value = false
hasRecording.value = true
recordingDuration.value = sceneRef.load3d.getRecordingDuration()
hasRecording.value = recordingDuration.value > 0
}
}
@@ -197,8 +197,8 @@ const listenRecordingStatusChange = (value: boolean) => {
if (!value) {
const sceneRef = load3DAnimationSceneRef.value?.load3DSceneRef
if (sceneRef?.load3d) {
hasRecording.value = true
recordingDuration.value = sceneRef.load3d.getRecordingDuration()
hasRecording.value = recordingDuration.value > 0
}
}
}

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

@@ -99,8 +99,6 @@ const emit = defineEmits<{
}>()
const resizeNodeMatchOutput = () => {
console.log('resizeNodeMatchOutput')
const outputWidth = node.widgets?.find((w) => w.name === 'width')
const outputHeight = node.widgets?.find((w) => w.name === 'height')

View File

@@ -166,10 +166,11 @@ const showContextMenu = (e: CanvasPointerEvent) => {
showSearchBox(e)
}
}
const afterRerouteId = firstLink.fromReroute?.id
const connectionOptions =
toType === 'input'
? { nodeFrom: node, slotFrom: fromSlot }
: { nodeTo: node, slotTo: fromSlot }
? { nodeFrom: node, slotFrom: fromSlot, afterRerouteId }
: { nodeTo: node, slotTo: fromSlot, afterRerouteId }
const canvas = canvasStore.getCanvas()
const menu = canvas.showConnectionMenu({

View File

@@ -13,13 +13,58 @@
@click="nodeBookmarkTreeExplorerRef?.addNewBookmarkFolder()"
/>
<Button
v-tooltip.bottom="$t('sideToolbar.nodeLibraryTab.sortOrder')"
class="sort-button"
:icon="alphabeticalSort ? 'pi pi-sort-alpha-down' : 'pi pi-sort-alt'"
v-tooltip.bottom="$t('sideToolbar.nodeLibraryTab.groupBy')"
:icon="selectedGroupingIcon"
text
severity="secondary"
@click="alphabeticalSort = !alphabeticalSort"
@click="groupingPopover?.toggle($event)"
/>
<Button
v-tooltip.bottom="$t('sideToolbar.nodeLibraryTab.sortMode')"
:icon="selectedSortingIcon"
text
severity="secondary"
@click="sortingPopover?.toggle($event)"
/>
<Button
v-tooltip.bottom="$t('sideToolbar.nodeLibraryTab.resetView')"
icon="pi pi-refresh"
text
severity="secondary"
@click="resetOrganization"
/>
<Popover ref="groupingPopover">
<div class="flex flex-col gap-1 p-2">
<Button
v-for="option in groupingOptions"
:key="option.id"
:icon="option.icon"
:label="$t(option.label)"
text
:severity="
selectedGroupingId === option.id ? 'primary' : 'secondary'
"
class="justify-start"
@click="selectGrouping(option.id)"
/>
</div>
</Popover>
<Popover ref="sortingPopover">
<div class="flex flex-col gap-1 p-2">
<Button
v-for="option in sortingOptions"
:key="option.id"
:icon="option.icon"
:label="$t(option.label)"
text
:severity="
selectedSortingId === option.id ? 'primary' : 'secondary'
"
class="justify-start"
@click="selectSorting(option.id)"
/>
</div>
</Popover>
</template>
<template #header>
<SearchBox
@@ -62,6 +107,7 @@
</template>
<script setup lang="ts">
import { useLocalStorage } from '@vueuse/core'
import Button from 'primevue/button'
import Divider from 'primevue/divider'
import Popover from 'primevue/popover'
@@ -76,16 +122,20 @@ import SidebarTabTemplate from '@/components/sidebar/tabs/SidebarTabTemplate.vue
import NodeTreeLeaf from '@/components/sidebar/tabs/nodeLibrary/NodeTreeLeaf.vue'
import { useTreeExpansion } from '@/composables/useTreeExpansion'
import { useLitegraphService } from '@/services/litegraphService'
import { useNodeBookmarkStore } from '@/stores/nodeBookmarkStore'
import {
ComfyNodeDefImpl,
buildNodeDefTree,
useNodeDefStore
} from '@/stores/nodeDefStore'
DEFAULT_GROUPING_ID,
DEFAULT_SORTING_ID,
nodeOrganizationService
} from '@/services/nodeOrganizationService'
import { useNodeBookmarkStore } from '@/stores/nodeBookmarkStore'
import { ComfyNodeDefImpl, useNodeDefStore } from '@/stores/nodeDefStore'
import type {
GroupingStrategyId,
SortingStrategyId
} from '@/types/nodeOrganizationTypes'
import type { TreeNode } from '@/types/treeExplorerTypes'
import type { TreeExplorerNode } from '@/types/treeExplorerTypes'
import { FuseFilterWithValue } from '@/utils/fuseUtil'
import { sortedTree } from '@/utils/treeUtil'
import NodeBookmarkTreeExplorer from './nodeLibrary/NodeBookmarkTreeExplorer.vue'
@@ -98,13 +148,67 @@ const nodeBookmarkTreeExplorerRef = ref<InstanceType<
typeof NodeBookmarkTreeExplorer
> | null>(null)
const searchFilter = ref<InstanceType<typeof Popover> | null>(null)
const alphabeticalSort = ref(false)
const groupingPopover = ref<InstanceType<typeof Popover> | null>(null)
const sortingPopover = ref<InstanceType<typeof Popover> | null>(null)
const selectedGroupingId = useLocalStorage<GroupingStrategyId>(
'Comfy.NodeLibrary.GroupBy',
DEFAULT_GROUPING_ID
)
const selectedSortingId = useLocalStorage<SortingStrategyId>(
'Comfy.NodeLibrary.SortBy',
DEFAULT_SORTING_ID
)
const searchQuery = ref<string>('')
const groupingOptions = computed(() =>
nodeOrganizationService.getGroupingStrategies().map((strategy) => ({
id: strategy.id,
label: strategy.label,
icon: strategy.icon
}))
)
const sortingOptions = computed(() =>
nodeOrganizationService.getSortingStrategies().map((strategy) => ({
id: strategy.id,
label: strategy.label,
icon: strategy.icon
}))
)
const selectedGroupingIcon = computed(() =>
nodeOrganizationService.getGroupingIcon(selectedGroupingId.value)
)
const selectedSortingIcon = computed(() =>
nodeOrganizationService.getSortingIcon(selectedSortingId.value)
)
const selectGrouping = (groupingId: string) => {
selectedGroupingId.value = groupingId as GroupingStrategyId
groupingPopover.value?.hide()
}
const selectSorting = (sortingId: string) => {
selectedSortingId.value = sortingId as SortingStrategyId
sortingPopover.value?.hide()
}
const resetOrganization = () => {
selectedGroupingId.value = DEFAULT_GROUPING_ID
selectedSortingId.value = DEFAULT_SORTING_ID
}
const root = computed(() => {
const root = filteredRoot.value || nodeDefStore.nodeTree
return alphabeticalSort.value ? sortedTree(root, { groupLeaf: true }) : root
// Determine which nodes to use
const nodes =
filteredNodeDefs.value.length > 0
? filteredNodeDefs.value
: nodeDefStore.visibleNodeDefs
// Use the service to organize nodes
return nodeOrganizationService.organizeNodes(nodes, {
groupBy: selectedGroupingId.value,
sortBy: selectedSortingId.value
})
})
const renderedRoot = computed<TreeExplorerNode<ComfyNodeDefImpl>>(() => {
@@ -144,12 +248,6 @@ const renderedRoot = computed<TreeExplorerNode<ComfyNodeDefImpl>>(() => {
})
const filteredNodeDefs = ref<ComfyNodeDefImpl[]>([])
const filteredRoot = computed<TreeNode | null>(() => {
if (!filteredNodeDefs.value.length) {
return null
}
return buildNodeDefTree(filteredNodeDefs.value)
})
const filters: Ref<
(SearchFilter & { filter: FuseFilterWithValue<ComfyNodeDefImpl, string> })[]
> = ref([])
@@ -175,8 +273,10 @@ const handleSearch = async (query: string) => {
)
await nextTick()
// @ts-expect-error fixme ts strict error
expandNode(filteredRoot.value)
// Expand the search results tree
if (filteredNodeDefs.value.length > 0) {
expandNode(root.value)
}
}
const onAddFilter = async (

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

@@ -50,9 +50,11 @@ vi.mock('@/composables/auth/useCurrentUser', () => ({
}))
// Mock the useFirebaseAuthActions composable
const mockLogout = vi.fn()
vi.mock('@/composables/auth/useFirebaseAuthActions', () => ({
useFirebaseAuthActions: vi.fn(() => ({
fetchBalance: vi.fn().mockResolvedValue(undefined)
fetchBalance: vi.fn().mockResolvedValue(undefined),
logout: mockLogout
}))
}))
@@ -100,8 +102,7 @@ describe('CurrentUserPopover', () => {
global: {
plugins: [i18n],
stubs: {
Divider: true,
Button: true
Divider: true
}
}
})
@@ -114,6 +115,18 @@ describe('CurrentUserPopover', () => {
expect(wrapper.text()).toContain('test@example.com')
})
it('renders logout button with correct props', () => {
const wrapper = mountComponent()
// Find all buttons and get the logout button (second one)
const buttons = wrapper.findAllComponents(Button)
const logoutButton = buttons[1]
// Check that logout button has correct props
expect(logoutButton.props('label')).toBe('Log Out')
expect(logoutButton.props('icon')).toBe('pi pi-sign-out')
})
it('opens user settings and emits close event when settings button is clicked', async () => {
const wrapper = mountComponent()
@@ -132,12 +145,30 @@ describe('CurrentUserPopover', () => {
expect(wrapper.emitted('close')!.length).toBe(1)
})
it('calls logout function and emits close event when logout button is clicked', async () => {
const wrapper = mountComponent()
// Find all buttons and get the logout button (second one)
const buttons = wrapper.findAllComponents(Button)
const logoutButton = buttons[1]
// Click the logout button
await logoutButton.trigger('click')
// Verify logout was called
expect(mockLogout).toHaveBeenCalled()
// Verify close event was emitted
expect(wrapper.emitted('close')).toBeTruthy()
expect(wrapper.emitted('close')!.length).toBe(1)
})
it('opens API pricing docs and emits close event when API pricing button is clicked', async () => {
const wrapper = mountComponent()
// Find all buttons and get the API pricing button (second one)
// Find all buttons and get the API pricing button (third one now)
const buttons = wrapper.findAllComponents(Button)
const apiPricingButton = buttons[1]
const apiPricingButton = buttons[2]
// Click the API pricing button
await apiPricingButton.trigger('click')

View File

@@ -37,6 +37,18 @@
<Divider class="my-2" />
<Button
class="justify-start"
:label="$t('auth.signOut.signOut')"
icon="pi pi-sign-out"
text
fluid
severity="secondary"
@click="handleLogout"
/>
<Divider class="my-2" />
<Button
class="justify-start"
:label="$t('credits.apiPricing')"
@@ -90,6 +102,11 @@ const handleTopUp = () => {
emit('close')
}
const handleLogout = async () => {
await authActions.logout()
emit('close')
}
const handleOpenApiPricing = () => {
window.open('https://docs.comfy.org/tutorials/api-nodes/pricing', '_blank')
emit('close')

View File

@@ -1,11 +1,16 @@
import { type LGraphNode, LiteGraph } from '@comfyorg/litegraph'
import {
BaseWidget,
type CanvasPointer,
type LGraphNode,
LiteGraph
} from '@comfyorg/litegraph'
import type {
IBaseWidget,
ICustomWidget,
IWidgetOptions
} from '@comfyorg/litegraph/dist/types/widgets'
import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
import { app } from '@/scripts/app'
import { calculateImageGrid } from '@/scripts/ui/imagePreview'
import { ComfyWidgetConstructorV2 } from '@/scripts/widgets'
import { useCanvasStore } from '@/stores/graphStore'
@@ -235,34 +240,61 @@ const renderPreview = (
}
}
class ImagePreviewWidget implements ICustomWidget {
readonly type: 'custom'
readonly name: string
readonly options: IWidgetOptions<string | object>
/** Dummy value to satisfy type requirements. */
value: string
y: number = 0
/** Don't serialize the widget value. */
serialize: boolean = false
constructor(name: string, options: IWidgetOptions<string | object>) {
this.type = 'custom'
this.name = name
this.options = options
this.value = ''
}
draw(
ctx: CanvasRenderingContext2D,
class ImagePreviewWidget extends BaseWidget {
constructor(
node: LGraphNode,
_width: number,
y: number,
_height: number
): void {
renderPreview(ctx, node, y)
name: string,
options: IWidgetOptions<string | object>
) {
const widget: IBaseWidget = {
name,
options,
type: 'custom',
/** Dummy value to satisfy type requirements. */
value: '',
y: 0
}
super(widget, node)
// Don't serialize the widget value
this.serialize = false
}
computeLayoutSize(this: IBaseWidget) {
override drawWidget(ctx: CanvasRenderingContext2D): void {
renderPreview(ctx, this.node, this.y)
}
override onPointerDown(pointer: CanvasPointer, node: LGraphNode): boolean {
pointer.onDragStart = () => {
const { canvas } = app
const { graph } = canvas
canvas.emitBeforeChange()
graph?.beforeChange()
// Ensure that dragging is properly cleaned up, on success or failure.
pointer.finally = () => {
canvas.isDragging = false
graph?.afterChange()
canvas.emitAfterChange()
}
canvas.processSelect(node, pointer.eDown)
canvas.isDragging = true
}
pointer.onDragEnd = (e) => {
const { canvas } = app
if (e.shiftKey || LiteGraph.alwaysSnapToGrid)
canvas.graph?.snapToGrid(canvas.selectedItems)
canvas.setDirty(true, true)
}
return true
}
override onClick(): void {}
override computeLayoutSize() {
return {
minHeight: 220,
minWidth: 1
@@ -276,7 +308,7 @@ export const useImagePreviewWidget = () => {
inputSpec: InputSpec
) => {
return node.addCustomWidget(
new ImagePreviewWidget(inputSpec.name, {
new ImagePreviewWidget(node, inputSpec.name, {
serialize: false
})
)

View File

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

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

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

@@ -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,10 @@
"unknownError": "Unknown error",
"title": "Title",
"edit": "Edit",
"copy": "Copy"
"copy": "Copy",
"imageUrl": "Image URL",
"clear": "Clear",
"copyURL": "Copy URL"
},
"manager": {
"title": "Custom Nodes Manager",
@@ -410,7 +416,23 @@
"openWorkflow": "Open workflow in local file system",
"newBlankWorkflow": "Create a new blank workflow",
"nodeLibraryTab": {
"sortOrder": "Sort Order"
"groupBy": "Group By",
"sortMode": "Sort Mode",
"resetView": "Reset View to Default",
"groupStrategies": {
"category": "Category",
"categoryDesc": "Group by node category",
"module": "Module",
"moduleDesc": "Group by module source",
"source": "Source",
"sourceDesc": "Group by source type (Core, Custom, API)"
},
"sortBy": {
"original": "Original",
"originalDesc": "Keep original order",
"alphabetical": "Alphabetical",
"alphabeticalDesc": "Sort alphabetically within groups"
}
},
"modelLibrary": "Model Library",
"downloads": "Downloads",

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

@@ -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",
@@ -266,7 +268,9 @@
"control_before_generate": "control antes de generar",
"copy": "Copiar",
"copyToClipboard": "Copiar al portapapeles",
"copyURL": "Copiar URL",
"currentUser": "Usuario actual",
"customBackground": "Fondo personalizado",
"customize": "Personalizar",
"customizeFolder": "Personalizar carpeta",
"delete": "Eliminar",
@@ -292,6 +296,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 +347,7 @@
"searchNodes": "Buscar nodos",
"searchSettings": "Buscar configuraciones",
"searchWorkflows": "Buscar flujos de trabajo",
"setAsBackground": "Establecer como fondo",
"settings": "Configuraciones",
"showReport": "Mostrar informe",
"sort": "Ordenar",
@@ -1059,7 +1065,23 @@
"newBlankWorkflow": "Crear un nuevo flujo de trabajo en blanco",
"nodeLibrary": "Biblioteca de nodos",
"nodeLibraryTab": {
"sortOrder": "Orden de clasificación"
"groupBy": "Agrupar por",
"groupStrategies": {
"category": "Categoría",
"categoryDesc": "Agrupar por categoría de nodo",
"module": "Módulo",
"moduleDesc": "Agrupar por fuente del módulo",
"source": "Fuente",
"sourceDesc": "Agrupar por tipo de fuente (Core, Custom, API)"
},
"resetView": "Restablecer vista a la predeterminada",
"sortBy": {
"alphabetical": "Alfabético",
"alphabeticalDesc": "Ordenar alfabéticamente dentro de los grupos",
"original": "Original",
"originalDesc": "Mantener el orden original"
},
"sortMode": "Modo de ordenación"
},
"openWorkflow": "Abrir flujo de trabajo en el sistema de archivos local",
"queue": "Cola",

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

@@ -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",
@@ -266,7 +268,9 @@
"control_before_generate": "contrôle avant génération",
"copy": "Copier",
"copyToClipboard": "Copier dans le presse-papiers",
"copyURL": "Copier lURL",
"currentUser": "Utilisateur actuel",
"customBackground": "Arrière-plan personnalisé",
"customize": "Personnaliser",
"customizeFolder": "Personnaliser le dossier",
"delete": "Supprimer",
@@ -292,6 +296,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 +347,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",
@@ -1059,7 +1065,23 @@
"newBlankWorkflow": "Créer un nouveau flux de travail vierge",
"nodeLibrary": "Bibliothèque de nœuds",
"nodeLibraryTab": {
"sortOrder": "Ordre de tri"
"groupBy": "Grouper par",
"groupStrategies": {
"category": "Catégorie",
"categoryDesc": "Grouper par catégorie de nœud",
"module": "Module",
"moduleDesc": "Grouper par source du module",
"source": "Source",
"sourceDesc": "Grouper par type de source (Core, Custom, API)"
},
"resetView": "Réinitialiser la vue par défaut",
"sortBy": {
"alphabetical": "Alphabétique",
"alphabeticalDesc": "Trier alphabétiquement dans les groupes",
"original": "Original",
"originalDesc": "Conserver l'ordre d'origine"
},
"sortMode": "Mode de tri"
},
"openWorkflow": "Ouvrir le flux de travail dans le système de fichiers local",
"queue": "File d'attente",

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

@@ -248,11 +248,13 @@
"all": "すべて",
"amount": "量",
"apply": "適用する",
"audioFailedToLoad": "オーディオの読み込みに失敗しました",
"back": "戻る",
"cancel": "キャンセル",
"capture": "キャプチャ",
"category": "カテゴリ",
"choose_file_to_upload": "アップロードするファイルを選択",
"clear": "クリア",
"close": "閉じる",
"color": "色",
"comingSoon": "近日公開",
@@ -266,7 +268,9 @@
"control_before_generate": "生成前の制御",
"copy": "コピー",
"copyToClipboard": "クリップボードにコピー",
"copyURL": "URLをコピー",
"currentUser": "現在のユーザー",
"customBackground": "カスタム背景",
"customize": "カスタマイズ",
"customizeFolder": "フォルダーをカスタマイズ",
"delete": "削除",
@@ -292,6 +296,7 @@
"goToNode": "ノードに移動",
"icon": "アイコン",
"imageFailedToLoad": "画像の読み込みに失敗しました",
"imageUrl": "画像URL",
"import": "インポート",
"inProgress": "進行中",
"insert": "挿入",
@@ -342,6 +347,7 @@
"searchNodes": "ノードを検索",
"searchSettings": "設定を検索",
"searchWorkflows": "ワークフローを検索",
"setAsBackground": "背景として設定",
"settings": "設定",
"showReport": "レポートを表示",
"sort": "並び替え",
@@ -1059,7 +1065,23 @@
"newBlankWorkflow": "新しい空のワークフローを作成",
"nodeLibrary": "ノードライブラリ",
"nodeLibraryTab": {
"sortOrder": "並び順"
"groupBy": "グループ化",
"groupStrategies": {
"category": "カテゴリ",
"categoryDesc": "ノードカテゴリでグループ化",
"module": "モジュール",
"moduleDesc": "モジュールソースでグループ化",
"source": "ソース",
"sourceDesc": "ソースタイプCore、Custom、APIでグループ化"
},
"resetView": "ビューをデフォルトにリセット",
"sortBy": {
"alphabetical": "アルファベット順",
"alphabeticalDesc": "グループ内でアルファベット順に並び替え",
"original": "元の順序",
"originalDesc": "元の順序を維持"
},
"sortMode": "並び替えモード"
},
"openWorkflow": "ローカルでワークフローを開く",
"queue": "キュー",

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

@@ -248,11 +248,13 @@
"all": "모두",
"amount": "수량",
"apply": "적용",
"audioFailedToLoad": "오디오를 불러오지 못했습니다",
"back": "뒤로",
"cancel": "취소",
"capture": "캡처",
"category": "카테고리",
"choose_file_to_upload": "업로드할 파일 선택",
"clear": "지우기",
"close": "닫기",
"color": "색상",
"comingSoon": "곧 출시 예정",
@@ -266,7 +268,9 @@
"control_before_generate": "생성 전 제어",
"copy": "복사",
"copyToClipboard": "클립보드에 복사",
"copyURL": "URL 복사",
"currentUser": "현재 사용자",
"customBackground": "맞춤 배경",
"customize": "사용자 정의",
"customizeFolder": "폴더 사용자 정의",
"delete": "삭제",
@@ -292,6 +296,7 @@
"goToNode": "노드로 이동",
"icon": "아이콘",
"imageFailedToLoad": "이미지를 로드하지 못했습니다.",
"imageUrl": "이미지 URL",
"import": "가져오기",
"inProgress": "진행 중",
"insert": "삽입",
@@ -342,6 +347,7 @@
"searchNodes": "노드 검색",
"searchSettings": "설정 검색",
"searchWorkflows": "워크플로 검색",
"setAsBackground": "배경으로 설정",
"settings": "설정",
"showReport": "보고서 보기",
"sort": "정렬",
@@ -1059,7 +1065,23 @@
"newBlankWorkflow": "새 빈 워크플로 만들기",
"nodeLibrary": "노드 라이브러리",
"nodeLibraryTab": {
"sortOrder": "정렬 순서"
"groupBy": "그룹 기준",
"groupStrategies": {
"category": "카테고리",
"categoryDesc": "노드 카테고리별로 그룹화",
"module": "모듈",
"moduleDesc": "모듈 소스별로 그룹화",
"source": "소스",
"sourceDesc": "소스 유형(Core, Custom, API)별로 그룹화"
},
"resetView": "기본 보기로 재설정",
"sortBy": {
"alphabetical": "알파벳순",
"alphabeticalDesc": "그룹 내에서 알파벳순으로 정렬",
"original": "원본 순서",
"originalDesc": "원래 순서를 유지"
},
"sortMode": "정렬 방식"
},
"openWorkflow": "로컬 파일 시스템에서 워크플로 열기",
"queue": "실행 대기열",

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

@@ -248,11 +248,13 @@
"all": "Все",
"amount": "Количество",
"apply": "Применить",
"audioFailedToLoad": "Не удалось загрузить аудио",
"back": "Назад",
"cancel": "Отмена",
"capture": "захват",
"category": "Категория",
"choose_file_to_upload": "выберите файл для загрузки",
"clear": "Очистить",
"close": "Закрыть",
"color": "Цвет",
"comingSoon": "Скоро будет",
@@ -266,7 +268,9 @@
"control_before_generate": "управление до генерации",
"copy": "Копировать",
"copyToClipboard": "Скопировать в буфер обмена",
"copyURL": "Скопировать URL",
"currentUser": "Текущий пользователь",
"customBackground": "Пользовательский фон",
"customize": "Настроить",
"customizeFolder": "Настроить папку",
"delete": "Удалить",
@@ -292,6 +296,7 @@
"goToNode": "Перейти к ноде",
"icon": "Иконка",
"imageFailedToLoad": "Не удалось загрузить изображение",
"imageUrl": "URL изображения",
"import": "Импорт",
"inProgress": "В процессе",
"insert": "Вставить",
@@ -342,6 +347,7 @@
"searchNodes": "Поиск нод",
"searchSettings": "Поиск настроек",
"searchWorkflows": "Поиск рабочих процессов",
"setAsBackground": "Установить как фон",
"settings": "Настройки",
"showReport": "Показать отчёт",
"sort": "Сортировать",
@@ -1059,7 +1065,23 @@
"newBlankWorkflow": "Создайте новый пустой рабочий процесс",
"nodeLibrary": "Библиотека нод",
"nodeLibraryTab": {
"sortOrder": "Порядок сортировки"
"groupBy": "Группировать по",
"groupStrategies": {
"category": "Категория",
"categoryDesc": "Группировать по категории узла",
"module": "Модуль",
"moduleDesc": "Группировать по источнику модуля",
"source": "Источник",
"sourceDesc": "Группировать по типу источника (Core, Custom, API)"
},
"resetView": "Сбросить вид по умолчанию",
"sortBy": {
"alphabetical": "По алфавиту",
"alphabeticalDesc": "Сортировать по алфавиту внутри групп",
"original": "Оригинальный порядок",
"originalDesc": "Сохранять исходный порядок"
},
"sortMode": "Режим сортировки"
},
"openWorkflow": "Открыть рабочий процесс в локальной файловой системе",
"queue": "Очередь",

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

@@ -248,11 +248,13 @@
"all": "全部",
"amount": "数量",
"apply": "应用",
"audioFailedToLoad": "音频加载失败",
"back": "返回",
"cancel": "取消",
"capture": "捕获",
"category": "类别",
"choose_file_to_upload": "选择要上传的文件",
"clear": "清除",
"close": "关闭",
"color": "颜色",
"comingSoon": "即将推出",
@@ -266,7 +268,9 @@
"control_before_generate": "生成前控制",
"copy": "复制",
"copyToClipboard": "复制到剪贴板",
"copyURL": "复制链接",
"currentUser": "当前用户",
"customBackground": "自定义背景",
"customize": "自定义",
"customizeFolder": "自定义文件夹",
"delete": "删除",
@@ -292,6 +296,7 @@
"goToNode": "转到节点",
"icon": "图标",
"imageFailedToLoad": "图像加载失败",
"imageUrl": "图片网址",
"import": "导入",
"inProgress": "进行中",
"insert": "插入",
@@ -342,6 +347,7 @@
"searchNodes": "搜索节点",
"searchSettings": "搜索设置",
"searchWorkflows": "搜索工作流",
"setAsBackground": "设为背景",
"settings": "设置",
"showReport": "显示报告",
"sort": "排序",
@@ -1059,7 +1065,23 @@
"newBlankWorkflow": "创建空白工作流",
"nodeLibrary": "节点库",
"nodeLibraryTab": {
"sortOrder": "排序顺序"
"groupBy": "分组方式",
"groupStrategies": {
"category": "类别",
"categoryDesc": "按节点类别分组",
"module": "模块",
"moduleDesc": "按模块来源分组",
"source": "来源",
"sourceDesc": "按来源类型分组核心自定义API"
},
"resetView": "重置视图为默认",
"sortBy": {
"alphabetical": "字母顺序",
"alphabeticalDesc": "在分组内按字母顺序排序",
"original": "原始顺序",
"originalDesc": "保持原始顺序"
},
"sortMode": "排序模式"
},
"openWorkflow": "在本地文件系统中打开工作流",
"queue": "队列",

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

@@ -1074,11 +1074,11 @@ export class ComfyApp {
try {
// @ts-expect-error Discrepancies between zod and litegraph - in progress
this.graph.configure(graphData)
if (restore_view) {
if (
useSettingStore().get('Comfy.EnableWorkflowViewRestore') &&
graphData.extra?.ds
) {
if (
restore_view &&
useSettingStore().get('Comfy.EnableWorkflowViewRestore')
) {
if (graphData.extra?.ds) {
this.canvas.ds.offset = graphData.extra.ds.offset
this.canvas.ds.scale = graphData.extra.ds.scale
} else {

View File

@@ -32,7 +32,7 @@ export class ChangeTracker {
/**
* Whether the redo/undo restoring is in progress.
*/
private restoringState: boolean = false
_restoringState: boolean = false
ds?: { scale: number; offset: [number, number] }
nodeOutputs?: Record<string, any>
@@ -55,7 +55,7 @@ export class ChangeTracker {
*/
reset(state?: ComfyWorkflowJSON) {
// Do not reset the state if we are restoring.
if (this.restoringState) return
if (this._restoringState) return
logger.debug('Reset State')
if (state) this.activeState = clone(state)
@@ -124,7 +124,7 @@ export class ChangeTracker {
const prevState = source.pop()
if (prevState) {
target.push(this.activeState)
this.restoringState = true
this._restoringState = true
try {
await app.loadGraphData(prevState, false, false, this.workflow, {
showMissingModelsDialog: false,
@@ -134,7 +134,7 @@ export class ChangeTracker {
this.activeState = prevState
this.updateModified()
} finally {
this.restoringState = false
this._restoringState = false
}
}
}

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

@@ -379,6 +379,24 @@ export const useDialogService = () => {
})
}
/**
* Shows a dialog from a third party extension.
* @param options - The dialog options.
* @param options.key - The dialog key.
* @param options.title - The dialog title.
* @param options.headerComponent - The dialog header component.
* @param options.footerComponent - The dialog footer component.
* @param options.component - The dialog component.
* @param options.props - The dialog props.
* @returns The dialog instance and a function to close the dialog.
*/
function showExtensionDialog(options: ShowDialogOptions & { key: string }) {
return {
dialog: dialogStore.showExtensionDialog(options),
closeDialog: () => dialogStore.closeDialog({ key: options.key })
}
}
return {
showLoadWorkflowWarning,
showMissingModelsWarning,
@@ -394,6 +412,7 @@ export const useDialogService = () => {
showSignInDialog,
showTopUpCreditsDialog,
showUpdatePasswordDialog,
showExtensionDialog,
prompt,
confirm
}

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

@@ -0,0 +1,159 @@
import { ComfyNodeDefImpl, buildNodeDefTree } from '@/stores/nodeDefStore'
import type {
NodeGroupingStrategy,
NodeOrganizationOptions,
NodeSortStrategy
} from '@/types/nodeOrganizationTypes'
import { NodeSourceType } from '@/types/nodeSource'
import type { TreeNode } from '@/types/treeExplorerTypes'
import { sortedTree } from '@/utils/treeUtil'
const DEFAULT_ICON = 'pi pi-sort'
export const DEFAULT_GROUPING_ID = 'category' as const
export const DEFAULT_SORTING_ID = 'original' as const
export class NodeOrganizationService {
private readonly groupingStrategies: NodeGroupingStrategy[] = [
{
id: 'category',
label: 'sideToolbar.nodeLibraryTab.groupStrategies.category',
icon: 'pi pi-folder',
description: 'sideToolbar.nodeLibraryTab.groupStrategies.categoryDesc',
getNodePath: (nodeDef: ComfyNodeDefImpl) => {
const category = nodeDef.category || ''
const categoryParts = category ? category.split('/') : []
return [...categoryParts, nodeDef.name]
}
},
{
id: 'module',
label: 'sideToolbar.nodeLibraryTab.groupStrategies.module',
icon: 'pi pi-box',
description: 'sideToolbar.nodeLibraryTab.groupStrategies.moduleDesc',
getNodePath: (nodeDef: ComfyNodeDefImpl) => {
const pythonModule = nodeDef.python_module || ''
if (!pythonModule) {
return ['unknown_module', nodeDef.name]
}
// Split the module path into components
const parts = pythonModule.split('.')
// Remove common prefixes and organize
if (parts[0] === 'nodes') {
// Core nodes - just use 'core'
return ['core', nodeDef.name]
} else if (parts[0] === 'custom_nodes') {
// Custom nodes - use the package name as the folder
if (parts.length > 1) {
// Return the custom node package name
return [parts[1], nodeDef.name]
}
return ['custom_nodes', nodeDef.name]
}
// For other modules, use the full path structure plus node name
return [...parts, nodeDef.name]
}
},
{
id: 'source',
label: 'sideToolbar.nodeLibraryTab.groupStrategies.source',
icon: 'pi pi-server',
description: 'sideToolbar.nodeLibraryTab.groupStrategies.sourceDesc',
getNodePath: (nodeDef: ComfyNodeDefImpl) => {
if (nodeDef.api_node) {
return ['API nodes', nodeDef.name]
} else if (nodeDef.nodeSource.type === NodeSourceType.Core) {
return ['Core', nodeDef.name]
} else if (nodeDef.nodeSource.type === NodeSourceType.CustomNodes) {
return ['Custom nodes', nodeDef.name]
} else {
return ['Unknown', nodeDef.name]
}
}
}
]
private readonly sortingStrategies: NodeSortStrategy[] = [
{
id: 'original',
label: 'sideToolbar.nodeLibraryTab.sortBy.original',
icon: 'pi pi-sort-alt',
description: 'sideToolbar.nodeLibraryTab.sortBy.originalDesc',
compare: () => 0
},
{
id: 'alphabetical',
label: 'sideToolbar.nodeLibraryTab.sortBy.alphabetical',
icon: 'pi pi-sort-alpha-down',
description: 'sideToolbar.nodeLibraryTab.sortBy.alphabeticalDesc',
compare: (a: ComfyNodeDefImpl, b: ComfyNodeDefImpl) =>
(a.display_name ?? '').localeCompare(b.display_name ?? '')
}
]
getGroupingStrategies(): NodeGroupingStrategy[] {
return [...this.groupingStrategies]
}
getGroupingStrategy(id: string): NodeGroupingStrategy | undefined {
return this.groupingStrategies.find((strategy) => strategy.id === id)
}
getSortingStrategies(): NodeSortStrategy[] {
return [...this.sortingStrategies]
}
getSortingStrategy(id: string): NodeSortStrategy | undefined {
return this.sortingStrategies.find((strategy) => strategy.id === id)
}
organizeNodes(
nodes: ComfyNodeDefImpl[],
options: NodeOrganizationOptions = {}
): TreeNode {
const { groupBy = DEFAULT_GROUPING_ID, sortBy = DEFAULT_SORTING_ID } =
options
const groupingStrategy = this.getGroupingStrategy(groupBy)
const sortingStrategy = this.getSortingStrategy(sortBy)
if (!groupingStrategy) {
throw new Error(`Unknown grouping strategy: ${groupBy}`)
}
if (!sortingStrategy) {
throw new Error(`Unknown sorting strategy: ${sortBy}`)
}
const sortedNodes =
sortingStrategy.id !== 'original'
? [...nodes].sort(sortingStrategy.compare)
: nodes
const tree = buildNodeDefTree(sortedNodes, {
pathExtractor: groupingStrategy.getNodePath
})
if (sortBy === 'alphabetical') {
return sortedTree(tree, { groupLeaf: true })
}
return tree
}
getGroupingIcon(groupingId: string): string {
const strategy = this.getGroupingStrategy(groupingId)
return strategy?.icon || DEFAULT_ICON
}
getSortingIcon(sortingId: string): string {
const strategy = this.getSortingStrategy(sortingId)
return strategy?.icon || DEFAULT_ICON
}
}
export const nodeOrganizationService = new NodeOrganizationService()

View File

@@ -147,10 +147,33 @@ export const useDialogStore = defineStore('dialog', () => {
return dialog
}
/**
* Shows a dialog from a third party extension.
* Explicitly keys extension dialogs with `extension-` prefix,
* to avoid conflicts & prevent use of internal dialogs (available via `dialogService`).
*/
function showExtensionDialog(options: ShowDialogOptions & { key: string }) {
const { key } = options
if (!key) {
console.error('Extension dialog key is required')
return
}
const extKey = key.startsWith('extension-') ? key : `extension-${key}`
const dialog = dialogStack.value.find((d) => d.key === extKey)
if (!dialog) return createDialog({ ...options, key: extKey })
dialog.visible = true
riseDialog(dialog)
return dialog
}
return {
dialogStack,
riseDialog,
showDialog,
closeDialog
closeDialog,
showExtensionDialog
}
})

View File

@@ -216,10 +216,22 @@ export const SYSTEM_NODE_DEFS: Record<string, ComfyNodeDefV1> = {
}
}
export function buildNodeDefTree(nodeDefs: ComfyNodeDefImpl[]): TreeNode {
return buildTree(nodeDefs, (nodeDef: ComfyNodeDefImpl) =>
export interface BuildNodeDefTreeOptions {
/**
* Custom function to extract the tree path from a node definition.
* If not provided, uses the default path based on nodeDef.nodePath.
*/
pathExtractor?: (nodeDef: ComfyNodeDefImpl) => string[]
}
export function buildNodeDefTree(
nodeDefs: ComfyNodeDefImpl[],
options: BuildNodeDefTreeOptions = {}
): TreeNode {
const { pathExtractor } = options
const defaultPathExtractor = (nodeDef: ComfyNodeDefImpl) =>
nodeDef.nodePath.split('/')
)
return buildTree(nodeDefs, pathExtractor || defaultPathExtractor)
}
export function createDummyFolderNodeDef(folderPath: string): ComfyNodeDefImpl {

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

@@ -23,7 +23,7 @@ export class ComfyWorkflow extends UserFile {
/**
* Whether the workflow has been modified comparing to the initial state.
*/
private _isModified: boolean = false
_isModified: boolean = false
/**
* @param options The path, modified, and size of the workflow.
@@ -131,7 +131,7 @@ export interface WorkflowStore {
activeWorkflow: LoadedComfyWorkflow | null
isActive: (workflow: ComfyWorkflow) => boolean
openWorkflows: ComfyWorkflow[]
openedWorkflowIndexShift: (shift: number) => LoadedComfyWorkflow | null
openedWorkflowIndexShift: (shift: number) => ComfyWorkflow | null
openWorkflow: (workflow: ComfyWorkflow) => Promise<LoadedComfyWorkflow>
openWorkflowsInBackground: (paths: {
left?: string[]
@@ -477,7 +477,7 @@ export const useWorkflowStore = defineStore('workflow', () => {
isSubgraphActive,
updateActiveGraph
}
}) as () => WorkflowStore
}) satisfies () => WorkflowStore
export const useWorkflowBookmarkStore = defineStore('workflowBookmark', () => {
const bookmarks = ref<Set<string>>(new Set())

View File

@@ -0,0 +1,44 @@
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
export type GroupingStrategyId = 'category' | 'module' | 'source'
export type SortingStrategyId = 'original' | 'alphabetical'
/**
* Strategy for grouping nodes into tree structure
*/
export interface NodeGroupingStrategy {
/** Unique identifier for the grouping strategy */
id: string
/** Display name for UI (i18n key) */
label: string
/** Icon class for the grouping option */
icon: string
/** Description for tooltips (i18n key) */
description?: string
/** Function to extract the tree path from a node definition */
getNodePath: (nodeDef: ComfyNodeDefImpl) => string[]
}
/**
* Strategy for sorting nodes within groups
*/
export interface NodeSortStrategy {
/** Unique identifier for the sort strategy */
id: string
/** Display name for UI (i18n key) */
label: string
/** Icon class for the sort option */
icon: string
/** Description for tooltips (i18n key) */
description?: string
/** Compare function for sorting nodes within the same group */
compare: (a: ComfyNodeDefImpl, b: ComfyNodeDefImpl) => number
}
/**
* Options for organizing nodes
*/
export interface NodeOrganizationOptions {
groupBy?: string
sortBy?: string
}

View File

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

View File

@@ -0,0 +1,330 @@
import { describe, expect, it } from 'vitest'
import { nodeOrganizationService } from '@/services/nodeOrganizationService'
import { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
import { NodeSourceType } from '@/types/nodeSource'
describe('nodeOrganizationService', () => {
const createMockNodeDef = (overrides: any = {}) => {
const mockNodeDef = {
name: 'TestNode',
display_name: 'Test Node',
category: 'test/subcategory',
python_module: 'custom_nodes.MyPackage.nodes',
api_node: false,
nodeSource: {
type: NodeSourceType.CustomNodes,
className: 'comfy-custom',
displayText: 'Custom',
badgeText: 'C'
},
...overrides
}
Object.setPrototypeOf(mockNodeDef, ComfyNodeDefImpl.prototype)
return mockNodeDef as ComfyNodeDefImpl
}
describe('getGroupingStrategies', () => {
it('should return all grouping strategies', () => {
const strategies = nodeOrganizationService.getGroupingStrategies()
expect(strategies).toHaveLength(3)
expect(strategies.map((s) => s.id)).toEqual([
'category',
'module',
'source'
])
})
it('should return immutable copy', () => {
const strategies1 = nodeOrganizationService.getGroupingStrategies()
const strategies2 = nodeOrganizationService.getGroupingStrategies()
expect(strategies1).not.toBe(strategies2)
expect(strategies1).toEqual(strategies2)
})
})
describe('getGroupingStrategy', () => {
it('should return strategy by id', () => {
const strategy = nodeOrganizationService.getGroupingStrategy('category')
expect(strategy).toBeDefined()
expect(strategy?.id).toBe('category')
})
it('should return undefined for unknown id', () => {
const strategy = nodeOrganizationService.getGroupingStrategy('unknown')
expect(strategy).toBeUndefined()
})
})
describe('getSortingStrategies', () => {
it('should return all sorting strategies', () => {
const strategies = nodeOrganizationService.getSortingStrategies()
expect(strategies).toHaveLength(2)
expect(strategies.map((s) => s.id)).toEqual(['original', 'alphabetical'])
})
})
describe('getSortingStrategy', () => {
it('should return strategy by id', () => {
const strategy =
nodeOrganizationService.getSortingStrategy('alphabetical')
expect(strategy).toBeDefined()
expect(strategy?.id).toBe('alphabetical')
})
it('should return undefined for unknown id', () => {
const strategy = nodeOrganizationService.getSortingStrategy('unknown')
expect(strategy).toBeUndefined()
})
})
describe('organizeNodes', () => {
const mockNodes = [
createMockNodeDef({ name: 'NodeA', display_name: 'Zebra Node' }),
createMockNodeDef({ name: 'NodeB', display_name: 'Apple Node' })
]
it('should organize nodes with default options', () => {
const tree = nodeOrganizationService.organizeNodes(mockNodes)
expect(tree).toBeDefined()
expect(tree.children).toBeDefined()
})
it('should organize nodes with custom grouping', () => {
const tree = nodeOrganizationService.organizeNodes(mockNodes, {
groupBy: 'module'
})
expect(tree).toBeDefined()
expect(tree.children).toBeDefined()
})
it('should organize nodes with custom sorting', () => {
const tree = nodeOrganizationService.organizeNodes(mockNodes, {
sortBy: 'alphabetical'
})
expect(tree).toBeDefined()
expect(tree.children).toBeDefined()
})
it('should throw error for unknown grouping strategy', () => {
expect(() => {
nodeOrganizationService.organizeNodes(mockNodes, {
groupBy: 'unknown'
})
}).toThrow('Unknown grouping strategy: unknown')
})
it('should throw error for unknown sorting strategy', () => {
expect(() => {
nodeOrganizationService.organizeNodes(mockNodes, {
sortBy: 'unknown'
})
}).toThrow('Unknown sorting strategy: unknown')
})
})
describe('getGroupingIcon', () => {
it('should return strategy icon', () => {
const icon = nodeOrganizationService.getGroupingIcon('category')
expect(icon).toBe('pi pi-folder')
})
it('should return fallback icon for unknown strategy', () => {
const icon = nodeOrganizationService.getGroupingIcon('unknown')
expect(icon).toBe('pi pi-sort')
})
})
describe('getSortingIcon', () => {
it('should return strategy icon', () => {
const icon = nodeOrganizationService.getSortingIcon('alphabetical')
expect(icon).toBe('pi pi-sort-alpha-down')
})
it('should return fallback icon for unknown strategy', () => {
const icon = nodeOrganizationService.getSortingIcon('unknown')
expect(icon).toBe('pi pi-sort')
})
})
describe('grouping path extraction', () => {
const mockNodeDef = createMockNodeDef()
it('category grouping should use category path', () => {
const strategy = nodeOrganizationService.getGroupingStrategy('category')
const path = strategy?.getNodePath(mockNodeDef)
expect(path).toEqual(['test', 'subcategory', 'TestNode'])
})
it('module grouping should extract module path', () => {
const strategy = nodeOrganizationService.getGroupingStrategy('module')
const path = strategy?.getNodePath(mockNodeDef)
expect(path).toEqual(['MyPackage', 'TestNode'])
})
it('source grouping should categorize by source type', () => {
const strategy = nodeOrganizationService.getGroupingStrategy('source')
const path = strategy?.getNodePath(mockNodeDef)
expect(path).toEqual(['Custom nodes', 'TestNode'])
})
})
describe('edge cases', () => {
describe('module grouping edge cases', () => {
const strategy = nodeOrganizationService.getGroupingStrategy('module')
it('should handle empty python_module', () => {
const nodeDef = createMockNodeDef({ python_module: '' })
const path = strategy?.getNodePath(nodeDef)
expect(path).toEqual(['unknown_module', 'TestNode'])
})
it('should handle undefined python_module', () => {
const nodeDef = createMockNodeDef({ python_module: undefined })
const path = strategy?.getNodePath(nodeDef)
expect(path).toEqual(['unknown_module', 'TestNode'])
})
it('should handle modules with spaces in the name', () => {
const nodeDef = createMockNodeDef({
python_module: 'custom_nodes.My Package With Spaces.nodes'
})
const path = strategy?.getNodePath(nodeDef)
expect(path).toEqual(['My Package With Spaces', 'TestNode'])
})
it('should handle modules with special characters', () => {
const nodeDef = createMockNodeDef({
python_module: 'custom_nodes.my-package_v2.0.nodes'
})
const path = strategy?.getNodePath(nodeDef)
expect(path).toEqual(['my-package_v2', 'TestNode'])
})
it('should handle deeply nested modules', () => {
const nodeDef = createMockNodeDef({
python_module: 'custom_nodes.package.subpackage.module.nodes'
})
const path = strategy?.getNodePath(nodeDef)
expect(path).toEqual(['package', 'TestNode'])
})
it('should handle core nodes module path', () => {
const nodeDef = createMockNodeDef({ python_module: 'nodes' })
const path = strategy?.getNodePath(nodeDef)
expect(path).toEqual(['core', 'TestNode'])
})
it('should handle non-standard module paths', () => {
const nodeDef = createMockNodeDef({
python_module: 'some.other.module.path'
})
const path = strategy?.getNodePath(nodeDef)
expect(path).toEqual(['some', 'other', 'module', 'path', 'TestNode'])
})
})
describe('category grouping edge cases', () => {
const strategy = nodeOrganizationService.getGroupingStrategy('category')
it('should handle empty category', () => {
const nodeDef = createMockNodeDef({ category: '' })
const path = strategy?.getNodePath(nodeDef)
expect(path).toEqual(['TestNode'])
})
it('should handle undefined category', () => {
const nodeDef = createMockNodeDef({ category: undefined })
const path = strategy?.getNodePath(nodeDef)
expect(path).toEqual(['TestNode'])
})
it('should handle category with trailing slash', () => {
const nodeDef = createMockNodeDef({ category: 'test/subcategory/' })
const path = strategy?.getNodePath(nodeDef)
expect(path).toEqual(['test', 'subcategory', '', 'TestNode'])
})
it('should handle category with multiple consecutive slashes', () => {
const nodeDef = createMockNodeDef({ category: 'test//subcategory' })
const path = strategy?.getNodePath(nodeDef)
expect(path).toEqual(['test', '', 'subcategory', 'TestNode'])
})
})
describe('source grouping edge cases', () => {
const strategy = nodeOrganizationService.getGroupingStrategy('source')
it('should handle API nodes', () => {
const nodeDef = createMockNodeDef({
api_node: true,
nodeSource: {
type: NodeSourceType.Core,
className: 'comfy-core',
displayText: 'Core',
badgeText: 'C'
}
})
const path = strategy?.getNodePath(nodeDef)
expect(path).toEqual(['API nodes', 'TestNode'])
})
it('should handle unknown source type', () => {
const nodeDef = createMockNodeDef({
nodeSource: {
type: 'unknown' as any,
className: 'unknown',
displayText: 'Unknown',
badgeText: '?'
}
})
const path = strategy?.getNodePath(nodeDef)
expect(path).toEqual(['Unknown', 'TestNode'])
})
})
describe('node name edge cases', () => {
it('should handle nodes with special characters in name', () => {
const nodeDef = createMockNodeDef({
name: 'Test/Node:With*Special<Chars>'
})
const strategy = nodeOrganizationService.getGroupingStrategy('category')
const path = strategy?.getNodePath(nodeDef)
expect(path).toEqual([
'test',
'subcategory',
'Test/Node:With*Special<Chars>'
])
})
it('should handle nodes with very long names', () => {
const longName = 'A'.repeat(100)
const nodeDef = createMockNodeDef({ name: longName })
const strategy = nodeOrganizationService.getGroupingStrategy('category')
const path = strategy?.getNodePath(nodeDef)
expect(path).toEqual(['test', 'subcategory', longName])
})
})
})
describe('sorting comparison', () => {
it('original sort should keep order', () => {
const strategy = nodeOrganizationService.getSortingStrategy('original')
const nodeA = createMockNodeDef({ display_name: 'Zebra' })
const nodeB = createMockNodeDef({ display_name: 'Apple' })
expect(strategy?.compare(nodeA, nodeB)).toBe(0)
})
it('alphabetical sort should compare display names', () => {
const strategy =
nodeOrganizationService.getSortingStrategy('alphabetical')
const nodeA = createMockNodeDef({ display_name: 'Zebra' })
const nodeB = createMockNodeDef({ display_name: 'Apple' })
expect(strategy?.compare(nodeA, nodeB)).toBeGreaterThan(0)
expect(strategy?.compare(nodeB, nodeA)).toBeLessThan(0)
})
})
})

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

18
tsconfig.eslint.json Normal file
View File

@@ -0,0 +1,18 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
/* Test files should not be compiled */
"noEmit": true,
// "strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"resolveJsonModule": true
},
"include": [
"*.ts",
"*.mts",
"*.config.js",
"browser_tests/**/*.ts",
"tests-ui/**/*.ts"
]
}

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'