mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-03-27 07:47:43 +00:00
Compare commits
42 Commits
fix/progre
...
conditiona
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
637795a973 | ||
|
|
405b5fc5b7 | ||
|
|
0eaf7d11b6 | ||
|
|
fa58c04b3a | ||
|
|
9c84c9e250 | ||
|
|
6f9f048b4a | ||
|
|
768faeee7e | ||
|
|
eba81efb4b | ||
|
|
f9d92b8198 | ||
|
|
c4bbe7fee1 | ||
|
|
8f4f5f8e5f | ||
|
|
9e137d9924 | ||
|
|
a084b55db7 | ||
|
|
835f318999 | ||
|
|
c35d44c491 | ||
|
|
38d3e15103 | ||
|
|
674d04c9cf | ||
|
|
8209765eec | ||
|
|
9d48638464 | ||
|
|
0095f02f46 | ||
|
|
178c79e559 | ||
|
|
7c0040bfec | ||
|
|
77f91dea10 | ||
|
|
4ad6475283 | ||
|
|
b531d34027 | ||
|
|
55ad207345 | ||
|
|
ccc1039abb | ||
|
|
49400c69b6 | ||
|
|
32605eeb8f | ||
|
|
a77c954353 | ||
|
|
f8c556feb3 | ||
|
|
da6c62aa80 | ||
|
|
2cdddf221b | ||
|
|
1003bd61a0 | ||
|
|
a74dc0cde2 | ||
|
|
9673560ced | ||
|
|
9e43303846 | ||
|
|
ff83bbd4da | ||
|
|
9272179bce | ||
|
|
d366a1e8ef | ||
|
|
758721753f | ||
|
|
422da7e7d6 |
@@ -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.
|
||||
|
||||
251
browser_tests/tests/backgroundImageUpload.spec.ts
Normal file
251
browser_tests/tests/backgroundImageUpload.spec.ts
Normal 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 |
@@ -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 |
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,2 @@
|
||||
export { addElementVnodeExportPlugin } from './addElementVnodeExportPlugin'
|
||||
export { comfyAPIPlugin } from './comfyAPIPlugin'
|
||||
export { generateImportMapPlugin } from './generateImportMapPlugin'
|
||||
|
||||
@@ -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']
|
||||
|
||||
@@ -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
12
package-lock.json
generated
@@ -1,18 +1,18 @@
|
||||
{
|
||||
"name": "@comfyorg/comfyui-frontend",
|
||||
"version": "1.20.3",
|
||||
"version": "1.21.2",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@comfyorg/comfyui-frontend",
|
||||
"version": "1.20.3",
|
||||
"version": "1.21.2",
|
||||
"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": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@comfyorg/comfyui-frontend",
|
||||
"private": true,
|
||||
"version": "1.20.3",
|
||||
"version": "1.21.2",
|
||||
"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",
|
||||
|
||||
103
src/components/common/BackgroundImageUpload.vue
Normal file
103
src/components/common/BackgroundImageUpload.vue
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
293
src/components/dialog/content/signin/SignInForm.spec.ts
Normal file
293
src/components/dialog/content/signin/SignInForm.spec.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
|
||||
|
||||
@@ -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')
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
|
||||
19
src/components/sidebar/tabs/queue/ResultAudio.vue
Normal file
19
src/components/sidebar/tabs/queue/ResultAudio.vue
Normal 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>
|
||||
177
src/components/sidebar/tabs/queue/ResultGallery.spec.ts
Normal file
177
src/components/sidebar/tabs/queue/ResultGallery.spec.ts
Normal 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
|
||||
})
|
||||
@@ -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>
|
||||
|
||||
@@ -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<{
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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'],
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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 l’URL",
|
||||
"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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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": "並び替え",
|
||||
|
||||
@@ -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": "音声を読み込む",
|
||||
|
||||
@@ -25,6 +25,10 @@
|
||||
},
|
||||
"tooltip": "システムタイトルバーを非表示にするにはカスタムオプションを選択してください"
|
||||
},
|
||||
"Comfy_Canvas_BackgroundImage": {
|
||||
"name": "キャンバス背景画像",
|
||||
"tooltip": "キャンバスの背景画像のURLです。出力パネルで画像を右クリックし、「背景として設定」を選択すると使用できます。"
|
||||
},
|
||||
"Comfy_Canvas_SelectionToolbox": {
|
||||
"name": "選択ツールボックスを表示"
|
||||
},
|
||||
|
||||
@@ -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": "정렬",
|
||||
|
||||
@@ -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": "오디오 로드",
|
||||
|
||||
@@ -25,6 +25,10 @@
|
||||
},
|
||||
"tooltip": "시스템 제목 표시 줄을 숨기려면 사용자 정의 옵션을 선택하세요"
|
||||
},
|
||||
"Comfy_Canvas_BackgroundImage": {
|
||||
"name": "캔버스 배경 이미지",
|
||||
"tooltip": "캔버스 배경에 사용할 이미지 URL입니다. 출력 패널에서 이미지를 마우스 오른쪽 버튼으로 클릭한 후 \"배경으로 설정\"을 선택해 사용할 수 있습니다."
|
||||
},
|
||||
"Comfy_Canvas_SelectionToolbox": {
|
||||
"name": "선택 도구 상자 표시"
|
||||
},
|
||||
|
||||
@@ -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": "Сортировать",
|
||||
|
||||
@@ -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": "Загрузить аудио",
|
||||
|
||||
@@ -25,6 +25,10 @@
|
||||
},
|
||||
"tooltip": "Выберите пользовательский вариант, чтобы скрыть системную строку заголовка"
|
||||
},
|
||||
"Comfy_Canvas_BackgroundImage": {
|
||||
"name": "Фоновое изображение холста",
|
||||
"tooltip": "URL изображения для фона холста. Вы можете кликнуть правой кнопкой мыши на изображении в панели результатов и выбрать «Установить как фон», чтобы использовать его."
|
||||
},
|
||||
"Comfy_Canvas_SelectionToolbox": {
|
||||
"name": "Показать панель инструментов выбора"
|
||||
},
|
||||
|
||||
@@ -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": "排序",
|
||||
|
||||
@@ -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": "加载音频",
|
||||
|
||||
@@ -25,6 +25,10 @@
|
||||
},
|
||||
"tooltip": "选择自定义选项以隐藏系统标题栏"
|
||||
},
|
||||
"Comfy_Canvas_BackgroundImage": {
|
||||
"name": "画布背景图像",
|
||||
"tooltip": "画布背景的图像 URL。你可以在输出面板中右键点击一张图片,并选择“设为背景”来使用它。"
|
||||
},
|
||||
"Comfy_Canvas_SelectionToolbox": {
|
||||
"name": "显示选择工具箱"
|
||||
},
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -17,7 +17,8 @@ export const zKeybinding = z.object({
|
||||
// Note: Currently only used to distinguish between global keybindings
|
||||
// and litegraph canvas keybindings.
|
||||
// Do NOT use this field in extensions as it has no effect.
|
||||
targetElementId: z.string().optional()
|
||||
targetElementId: z.string().optional(),
|
||||
condition: z.string().optional()
|
||||
})
|
||||
|
||||
// Infer types from schemas
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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`
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { CORE_KEYBINDINGS } from '@/constants/coreKeybindings'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import { useContextKeyStore } from '@/stores/contextKeyStore'
|
||||
import {
|
||||
KeyComboImpl,
|
||||
KeybindingImpl,
|
||||
@@ -11,6 +12,7 @@ export const useKeybindingService = () => {
|
||||
const keybindingStore = useKeybindingStore()
|
||||
const commandStore = useCommandStore()
|
||||
const settingStore = useSettingStore()
|
||||
const contextKeyStore = useContextKeyStore()
|
||||
|
||||
const keybindHandler = async function (event: KeyboardEvent) {
|
||||
const keyCombo = KeyComboImpl.fromEvent(event)
|
||||
@@ -32,6 +34,14 @@ export const useKeybindingService = () => {
|
||||
|
||||
const keybinding = keybindingStore.getKeybinding(keyCombo)
|
||||
if (keybinding && keybinding.targetElementId !== 'graph-canvas') {
|
||||
// If condition exists and evaluates to false
|
||||
// TODO: Complex context key evaluation
|
||||
if (
|
||||
keybinding.condition &&
|
||||
contextKeyStore.evaluateCondition(keybinding.condition) !== true
|
||||
) {
|
||||
return
|
||||
}
|
||||
// Prevent default browser behavior first, then execute the command
|
||||
event.preventDefault()
|
||||
await commandStore.execute(keybinding.commandId)
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
63
src/stores/contextKeyStore.ts
Normal file
63
src/stores/contextKeyStore.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import { get, set, unset } from 'lodash'
|
||||
import { defineStore } from 'pinia'
|
||||
import { reactive } from 'vue'
|
||||
|
||||
import { ContextValue, evaluateExpression } from '@/utils/expressionParserUtil'
|
||||
|
||||
export const useContextKeyStore = defineStore('contextKeys', () => {
|
||||
const contextKeys = reactive<Record<string, ContextValue>>({})
|
||||
|
||||
/**
|
||||
* Get a stored context key by path.
|
||||
* @param {string} path - The dot-separated path to the context key (e.g., 'a.b.c').
|
||||
* @returns {ContextValue | undefined} The value of the context key, or undefined if not found.
|
||||
*/
|
||||
function getContextKey(path: string): ContextValue | undefined {
|
||||
return get(contextKeys, path)
|
||||
}
|
||||
|
||||
/**
|
||||
* Set or update a context key value at a given path.
|
||||
* @param {string} path - The dot-separated path to the context key (e.g., 'a.b.c').
|
||||
* @param {ContextValue} value - The value to set for the context key.
|
||||
*/
|
||||
function setContextKey(path: string, value: ContextValue) {
|
||||
set(contextKeys, path, value)
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a context key by path.
|
||||
* @param {string} path - The dot-separated path to the context key to remove (e.g., 'a.b.c').
|
||||
*/
|
||||
function removeContextKey(path: string) {
|
||||
unset(contextKeys, path)
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all context keys from the store.
|
||||
*/
|
||||
function clearAllContextKeys() {
|
||||
for (const key in contextKeys) {
|
||||
delete contextKeys[key]
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluates a context key expression string using the current context keys.
|
||||
* Returns false if the expression is invalid or if any referenced key is undefined.
|
||||
* @param {string} expr - The expression string to evaluate (e.g., "key1 && !key2 || (key3 == 'type2')").
|
||||
* @returns {boolean} The result of the expression evaluation. Returns false if the expression is invalid.
|
||||
*/
|
||||
function evaluateCondition(expr: string): boolean {
|
||||
return evaluateExpression(expr, getContextKey)
|
||||
}
|
||||
|
||||
return {
|
||||
contextKeys,
|
||||
getContextKey,
|
||||
setContextKey,
|
||||
removeContextKey,
|
||||
clearAllContextKeys,
|
||||
evaluateCondition
|
||||
}
|
||||
})
|
||||
@@ -9,11 +9,13 @@ export class KeybindingImpl implements Keybinding {
|
||||
commandId: string
|
||||
combo: KeyComboImpl
|
||||
targetElementId?: string
|
||||
condition?: string
|
||||
|
||||
constructor(obj: Keybinding) {
|
||||
this.commandId = obj.commandId
|
||||
this.combo = new KeyComboImpl(obj.combo)
|
||||
this.targetElementId = obj.targetElementId
|
||||
this.condition = obj.condition
|
||||
}
|
||||
|
||||
equals(other: unknown): boolean {
|
||||
@@ -22,7 +24,8 @@ export class KeybindingImpl implements Keybinding {
|
||||
return raw instanceof KeybindingImpl
|
||||
? this.commandId === raw.commandId &&
|
||||
this.combo.equals(raw.combo) &&
|
||||
this.targetElementId === raw.targetElementId
|
||||
this.targetElementId === raw.targetElementId &&
|
||||
this.condition === raw.condition
|
||||
: false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -11,6 +11,7 @@ export type SettingInputType =
|
||||
| 'color'
|
||||
| 'url'
|
||||
| 'hidden'
|
||||
| 'backgroundImage'
|
||||
|
||||
export type SettingCustomRenderer = (
|
||||
name: string,
|
||||
|
||||
273
src/utils/expressionParserUtil.ts
Normal file
273
src/utils/expressionParserUtil.ts
Normal file
@@ -0,0 +1,273 @@
|
||||
type Token = { t: string }
|
||||
interface IdentifierNode {
|
||||
type: 'Identifier'
|
||||
name: string
|
||||
}
|
||||
interface UnaryNode {
|
||||
type: 'Unary'
|
||||
op: '!'
|
||||
left?: never
|
||||
right?: never
|
||||
arg: ASTNode
|
||||
}
|
||||
interface BinaryNode {
|
||||
type: 'Binary'
|
||||
op: '&&' | '||' | '==' | '!=' | '<' | '>' | '<=' | '>='
|
||||
left: ASTNode
|
||||
right: ASTNode
|
||||
}
|
||||
interface LiteralNode {
|
||||
type: 'Literal'
|
||||
value: ContextValue
|
||||
}
|
||||
type ASTNode = IdentifierNode | UnaryNode | BinaryNode | LiteralNode
|
||||
export type ContextValue = string | number | boolean
|
||||
|
||||
const OP_PRECEDENCE: Record<string, number> = {
|
||||
'||': 1,
|
||||
'&&': 2,
|
||||
'==': 3,
|
||||
'!=': 3,
|
||||
'<': 3,
|
||||
'>': 3,
|
||||
'<=': 3,
|
||||
'>=': 3
|
||||
}
|
||||
|
||||
// Regular expression for tokenizing expressions
|
||||
const TOKEN_REGEX =
|
||||
/\s*("(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|==|!=|<=|>=|&&|\|\||<|>|[A-Za-z0-9_.]+|!|\(|\))\s*/g
|
||||
|
||||
// Cache for storing parsed ASTs to improve performance
|
||||
const astCache = new Map<string, ASTNode>()
|
||||
|
||||
/**
|
||||
* Tokenizes a context key expression string into an array of tokens.
|
||||
*
|
||||
* This function breaks down an expression string into smaller components (tokens)
|
||||
* that can be parsed into an Abstract Syntax Tree (AST).
|
||||
*
|
||||
* @param {string} expr - The expression string to tokenize (e.g., "key1 && !key2 || (key3 && key4)").
|
||||
* @returns {Token[]} An array of tokens representing the components of the expression.
|
||||
* @throws {Error} If invalid characters are found in the expression.
|
||||
*/
|
||||
export function tokenize(expr: string): Token[] {
|
||||
const tokens: Token[] = []
|
||||
let pos = 0
|
||||
const re = new RegExp(TOKEN_REGEX) // Clone/reset regex state
|
||||
let m: RegExpExecArray | null
|
||||
while ((m = re.exec(expr))) {
|
||||
if (m.index !== pos) {
|
||||
throw new Error(`Invalid character in expression at position ${pos}`)
|
||||
}
|
||||
tokens.push({ t: m[1] })
|
||||
pos = re.lastIndex
|
||||
}
|
||||
if (pos !== expr.length) {
|
||||
throw new Error(`Invalid character in expression at position ${pos}`)
|
||||
}
|
||||
return tokens
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses a sequence of tokens into an Abstract Syntax Tree (AST).
|
||||
*
|
||||
* This function implements a recursive descent parser for boolean expressions
|
||||
* with support for operator precedence and parentheses.
|
||||
*
|
||||
* @param {Token[]} tokens - The array of tokens generated by `tokenize`.
|
||||
* @returns {ASTNode} The root node of the parsed AST.
|
||||
* @throws {Error} If there are syntax errors, such as mismatched parentheses or unexpected tokens.
|
||||
*/
|
||||
export function parseAST(tokens: Token[]): ASTNode {
|
||||
let i = 0
|
||||
|
||||
function peek(): string | undefined {
|
||||
return tokens[i]?.t
|
||||
}
|
||||
|
||||
function consume(expected?: string): string {
|
||||
const tok = tokens[i++]?.t
|
||||
if (expected && tok !== expected) {
|
||||
throw new Error(`Expected ${expected}, got ${tok ?? 'end of input'}`)
|
||||
}
|
||||
if (!tok) {
|
||||
throw new Error(`Expected ${expected}, got end of input`)
|
||||
}
|
||||
return tok
|
||||
}
|
||||
|
||||
function parsePrimary(): ASTNode {
|
||||
if (peek() === '!') {
|
||||
consume('!')
|
||||
return { type: 'Unary', op: '!', arg: parsePrimary() }
|
||||
}
|
||||
if (peek() === '(') {
|
||||
consume('(')
|
||||
const expr = parseExpression(0)
|
||||
consume(')')
|
||||
return expr
|
||||
}
|
||||
const tok = consume()
|
||||
// string literal?
|
||||
if (
|
||||
(tok[0] === '"' && tok[tok.length - 1] === '"') ||
|
||||
(tok[0] === "'" && tok[tok.length - 1] === "'")
|
||||
) {
|
||||
const raw = tok.slice(1, -1).replace(/\\(.)/g, '$1')
|
||||
return { type: 'Literal', value: raw }
|
||||
}
|
||||
// numeric literal?
|
||||
if (/^\d+(\.\d+)?$/.test(tok)) {
|
||||
return { type: 'Literal', value: Number(tok) }
|
||||
}
|
||||
// identifier
|
||||
if (!/^[A-Za-z0-9_.]+$/.test(tok)) {
|
||||
throw new Error(`Invalid identifier: ${tok}`)
|
||||
}
|
||||
return { type: 'Identifier', name: tok }
|
||||
}
|
||||
|
||||
function parseExpression(minPrec: number): ASTNode {
|
||||
let left = parsePrimary()
|
||||
while (true) {
|
||||
const tok = peek()
|
||||
const prec = tok ? OP_PRECEDENCE[tok] : undefined
|
||||
if (prec === undefined || prec < minPrec) break
|
||||
consume(tok)
|
||||
const right = parseExpression(prec + 1)
|
||||
left = { type: 'Binary', op: tok as BinaryNode['op'], left, right }
|
||||
}
|
||||
return left
|
||||
}
|
||||
|
||||
const ast = parseExpression(0)
|
||||
if (i < tokens.length) {
|
||||
throw new Error(`Unexpected token ${peek()}`)
|
||||
}
|
||||
return ast
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a ContextValue or undefined to a boolean value.
|
||||
*
|
||||
* This utility ensures consistent truthy/falsy evaluation for different types of values.
|
||||
*
|
||||
* @param {ContextValue | undefined} val - The value to convert.
|
||||
* @returns {boolean} The boolean representation of the value.
|
||||
*/
|
||||
function toBoolean(val: ContextValue | undefined): boolean {
|
||||
if (val === undefined) return false
|
||||
if (typeof val === 'boolean') return val
|
||||
if (typeof val === 'number') return val !== 0
|
||||
if (typeof val === 'string') return val.length > 0
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves the raw value of an AST node for equality checks.
|
||||
*
|
||||
* This function resolves the value of a node, whether it's a literal, identifier,
|
||||
* or a nested expression, for comparison purposes.
|
||||
*
|
||||
* @param {ASTNode} node - The AST node to evaluate.
|
||||
* @param {(key: string) => ContextValue | undefined} getContextKey - A function to retrieve context key values.
|
||||
* @returns {ContextValue | boolean} The raw value of the node.
|
||||
*/
|
||||
function getRawValue(
|
||||
node: ASTNode,
|
||||
getContextKey: (key: string) => ContextValue | undefined
|
||||
): ContextValue | boolean {
|
||||
if (node.type === 'Literal') return node.value
|
||||
if (node.type === 'Identifier') {
|
||||
const val = getContextKey(node.name)
|
||||
return val === undefined ? false : val
|
||||
}
|
||||
return evalAst(node, getContextKey)
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluates an AST node recursively to compute its boolean value.
|
||||
*
|
||||
* This function traverses the AST and evaluates each node based on its type
|
||||
* (e.g., literal, identifier, unary, or binary).
|
||||
*
|
||||
* @param {ASTNode} node - The AST node to evaluate.
|
||||
* @param {(key: string) => ContextValue | undefined} getContextKey - A function to retrieve context key values.
|
||||
* @returns {boolean} The boolean result of the evaluation.
|
||||
* @throws {Error} If the AST node type is unknown or unsupported.
|
||||
*/
|
||||
export function evalAst(
|
||||
node: ASTNode,
|
||||
getContextKey: (key: string) => ContextValue | undefined
|
||||
): boolean {
|
||||
switch (node.type) {
|
||||
case 'Literal':
|
||||
return toBoolean(node.value)
|
||||
case 'Identifier':
|
||||
return toBoolean(getContextKey(node.name))
|
||||
case 'Unary':
|
||||
return !evalAst(node.arg, getContextKey)
|
||||
case 'Binary': {
|
||||
const { op, left, right } = node
|
||||
if (op === '&&' || op === '||') {
|
||||
const l = evalAst(left, getContextKey)
|
||||
const r = evalAst(right, getContextKey)
|
||||
return op === '&&' ? l && r : l || r
|
||||
}
|
||||
const lRaw = getRawValue(left, getContextKey)
|
||||
const rRaw = getRawValue(right, getContextKey)
|
||||
switch (op) {
|
||||
case '==':
|
||||
return lRaw === rRaw
|
||||
case '!=':
|
||||
return lRaw !== rRaw
|
||||
case '<':
|
||||
return (lRaw as any) < (rRaw as any)
|
||||
case '>':
|
||||
return (lRaw as any) > (rRaw as any)
|
||||
case '<=':
|
||||
return (lRaw as any) <= (rRaw as any)
|
||||
case '>=':
|
||||
return (lRaw as any) >= (rRaw as any)
|
||||
default:
|
||||
throw new Error(`Unsupported operator: ${op}`)
|
||||
}
|
||||
}
|
||||
default:
|
||||
throw new Error(`Unknown AST node type: ${(node as ASTNode).type}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses and evaluates a context key expression string.
|
||||
*
|
||||
* This function combines tokenization, parsing, and evaluation to compute
|
||||
* the boolean result of an expression. It also caches parsed ASTs for performance.
|
||||
*
|
||||
* @param {string} expr - The expression string to evaluate (e.g., "key1 && !key2").
|
||||
* @param {(key: string) => ContextValue | undefined} getContextKey - A function to resolve context key identifiers.
|
||||
* @returns {boolean} The boolean result of the expression.
|
||||
* @throws {Error} If there are parsing or evaluation errors.
|
||||
*/
|
||||
export function evaluateExpression(
|
||||
expr: string,
|
||||
getContextKey: (key: string) => ContextValue | undefined
|
||||
): boolean {
|
||||
if (!expr) return true
|
||||
|
||||
try {
|
||||
let ast: ASTNode
|
||||
if (astCache.has(expr)) {
|
||||
ast = astCache.get(expr)!
|
||||
} else {
|
||||
const tokens = tokenize(expr)
|
||||
ast = parseAST(tokens)
|
||||
astCache.set(expr, ast)
|
||||
}
|
||||
return evalAst(ast, getContextKey)
|
||||
} catch (error) {
|
||||
console.error(`Error evaluating expression "${expr}":`, error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
37
tests-ui/tests/store/contextKeyStore.spec.ts
Normal file
37
tests-ui/tests/store/contextKeyStore.spec.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
|
||||
import { useContextKeyStore } from '@/stores/contextKeyStore'
|
||||
|
||||
describe('contextKeyStore', () => {
|
||||
let store: ReturnType<typeof useContextKeyStore>
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
store = useContextKeyStore()
|
||||
})
|
||||
|
||||
it('should set and get a context key', () => {
|
||||
store.setContextKey('key1', true)
|
||||
expect(store.getContextKey('key1')).toBe(true)
|
||||
})
|
||||
|
||||
it('should remove a context key', () => {
|
||||
store.setContextKey('key1', true)
|
||||
store.removeContextKey('key1')
|
||||
expect(store.getContextKey('key1')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should clear all context keys', () => {
|
||||
store.setContextKey('key1', true)
|
||||
store.setContextKey('key2', false)
|
||||
store.clearAllContextKeys()
|
||||
expect(Object.keys(store.contextKeys)).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('should evaluate a simple condition', () => {
|
||||
store.setContextKey('key1', true)
|
||||
store.setContextKey('key2', false)
|
||||
expect(store.evaluateCondition('key1 && !key2')).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
128
tests-ui/tests/utils/expressionParserUtil.test.ts
Normal file
128
tests-ui/tests/utils/expressionParserUtil.test.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
ContextValue,
|
||||
evaluateExpression,
|
||||
parseAST,
|
||||
tokenize
|
||||
} from '@/utils/expressionParserUtil'
|
||||
|
||||
describe('tokenize()', () => {
|
||||
it('splits identifiers, literals, operators and parentheses', () => {
|
||||
const tokens = tokenize('a && !b || (c == "d")')
|
||||
expect(tokens.map((t) => t.t)).toEqual([
|
||||
'a',
|
||||
'&&',
|
||||
'!',
|
||||
'b',
|
||||
'||',
|
||||
'(',
|
||||
'c',
|
||||
'==',
|
||||
'"d"',
|
||||
')'
|
||||
])
|
||||
})
|
||||
|
||||
it('throws on encountering invalid characters', () => {
|
||||
expect(() => tokenize('a & b')).toThrowError(/Invalid character/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('parseAST()', () => {
|
||||
it('parses a single identifier', () => {
|
||||
const ast = parseAST(tokenize('x'))
|
||||
expect(ast).toEqual({ type: 'Identifier', name: 'x' })
|
||||
})
|
||||
|
||||
it('respects default precedence (&& over ||)', () => {
|
||||
const ast = parseAST(tokenize('a || b && c'))
|
||||
expect(ast).toEqual({
|
||||
type: 'Binary',
|
||||
op: '||',
|
||||
left: { type: 'Identifier', name: 'a' },
|
||||
right: {
|
||||
type: 'Binary',
|
||||
op: '&&',
|
||||
left: { type: 'Identifier', name: 'b' },
|
||||
right: { type: 'Identifier', name: 'c' }
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('honors parentheses to override precedence', () => {
|
||||
const ast = parseAST(tokenize('(a || b) && c'))
|
||||
expect(ast).toEqual({
|
||||
type: 'Binary',
|
||||
op: '&&',
|
||||
left: {
|
||||
type: 'Binary',
|
||||
op: '||',
|
||||
left: { type: 'Identifier', name: 'a' },
|
||||
right: { type: 'Identifier', name: 'b' }
|
||||
},
|
||||
right: { type: 'Identifier', name: 'c' }
|
||||
})
|
||||
})
|
||||
|
||||
it('parses unary NOT correctly', () => {
|
||||
const ast = parseAST(tokenize('!a && b'))
|
||||
expect(ast).toEqual({
|
||||
type: 'Binary',
|
||||
op: '&&',
|
||||
left: { type: 'Unary', op: '!', arg: { type: 'Identifier', name: 'a' } },
|
||||
right: { type: 'Identifier', name: 'b' }
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('evaluateExpression()', () => {
|
||||
const context: Record<string, ContextValue> = {
|
||||
a: true,
|
||||
b: false,
|
||||
c: true,
|
||||
d: '',
|
||||
num1: 1,
|
||||
num2: 2,
|
||||
num3: 3
|
||||
}
|
||||
const getContextKey = (key: string) => context[key]
|
||||
|
||||
it('returns true for empty expression', () => {
|
||||
expect(evaluateExpression('', getContextKey)).toBe(true)
|
||||
})
|
||||
|
||||
it('evaluates literals and basic comparisons', () => {
|
||||
expect(evaluateExpression('"hi"', getContextKey)).toBe(true)
|
||||
expect(evaluateExpression("''", getContextKey)).toBe(false)
|
||||
expect(evaluateExpression('1', getContextKey)).toBe(true)
|
||||
expect(evaluateExpression('0', getContextKey)).toBe(false)
|
||||
expect(evaluateExpression('1 == 1', getContextKey)).toBe(true)
|
||||
expect(evaluateExpression('1 != 2', getContextKey)).toBe(true)
|
||||
expect(evaluateExpression("'x' == 'y'", getContextKey)).toBe(false)
|
||||
})
|
||||
|
||||
it('evaluates logical AND, OR and NOT', () => {
|
||||
expect(evaluateExpression('a && b', getContextKey)).toBe(false)
|
||||
expect(evaluateExpression('a || b', getContextKey)).toBe(true)
|
||||
expect(evaluateExpression('!b', getContextKey)).toBe(true)
|
||||
})
|
||||
|
||||
it('evaluates comparison operators correctly', () => {
|
||||
expect(evaluateExpression('num1 < num2', getContextKey)).toBe(true)
|
||||
expect(evaluateExpression('num1 > num2', getContextKey)).toBe(false)
|
||||
expect(evaluateExpression('num1 <= num1', getContextKey)).toBe(true)
|
||||
expect(evaluateExpression('num3 >= num2', getContextKey)).toBe(true)
|
||||
})
|
||||
|
||||
it('respects operator precedence and parentheses', () => {
|
||||
expect(evaluateExpression('a || b && c', getContextKey)).toBe(true)
|
||||
expect(evaluateExpression('(a || b) && c', getContextKey)).toBe(true)
|
||||
expect(evaluateExpression('!(a && b) || c', getContextKey)).toBe(true)
|
||||
})
|
||||
|
||||
it('safely handles syntax errors by returning false', () => {
|
||||
expect(evaluateExpression('a &&', getContextKey)).toBe(false)
|
||||
expect(evaluateExpression('foo $ bar', getContextKey)).toBe(false)
|
||||
})
|
||||
})
|
||||
18
tsconfig.eslint.json
Normal file
18
tsconfig.eslint.json
Normal 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"
|
||||
]
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
Reference in New Issue
Block a user