From e4308a72583a6b2051c40ce8faaee3b2b09c70ee Mon Sep 17 00:00:00 2001 From: Alexander Brown Date: Fri, 16 Jan 2026 13:32:18 -0800 Subject: [PATCH 01/45] refactor: rename CLAUDE.md to AGENTS.md (#8052) ## Summary Pure rename of CLAUDE.md files to AGENTS.md (no content changes). ## Changes | Old Path | New Path | |----------|----------| | `.github/CLAUDE.md` | `.github/AGENTS.md` | | `.storybook/CLAUDE.md` | `.storybook/AGENTS.md` | | `browser_tests/CLAUDE.md` | `browser_tests/AGENTS.md` | | `src/CLAUDE.md` | `src/AGENTS.md` | | `src/components/CLAUDE.md` | `src/components/AGENTS.md` | | `src/lib/litegraph/CLAUDE.md` | `src/lib/litegraph/AGENTS.md` | Root `CLAUDE.md` deleted (content will be merged into `AGENTS.md` in follow-up PR). ## Follow-up A second PR will add glob-based guidance files and consolidate redundancies. --------- Co-authored-by: Amp Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> Co-authored-by: Christian Byrne --- .cursor/rules/unit-test.mdc | 21 ---- .github/AGENTS.md | 14 +++ .github/CLAUDE.md | 39 +------ .storybook/AGENTS.md | 17 +++ .storybook/CLAUDE.md | 200 +------------------------------- AGENTS.md | 17 +++ CLAUDE.md | 31 +---- browser_tests/AGENTS.md | 8 ++ browser_tests/CLAUDE.md | 20 +--- docs/guidance/playwright.md | 33 ++++++ docs/guidance/storybook.md | 55 +++++++++ docs/guidance/typescript.md | 37 ++++++ docs/guidance/vitest.md | 36 ++++++ docs/guidance/vue-components.md | 46 ++++++++ src/AGENTS.md | 26 +++++ src/CLAUDE.md | 60 +--------- src/components/AGENTS.md | 6 + src/components/CLAUDE.md | 48 +------- src/lib/litegraph/AGENTS.md | 41 +++++++ src/lib/litegraph/CLAUDE.md | 65 +---------- 20 files changed, 355 insertions(+), 465 deletions(-) delete mode 100644 .cursor/rules/unit-test.mdc create mode 100644 .github/AGENTS.md create mode 100644 .storybook/AGENTS.md create mode 100644 browser_tests/AGENTS.md create mode 100644 docs/guidance/playwright.md create mode 100644 docs/guidance/storybook.md create mode 100644 docs/guidance/typescript.md create mode 100644 docs/guidance/vitest.md create mode 100644 docs/guidance/vue-components.md create mode 100644 src/AGENTS.md create mode 100644 src/components/AGENTS.md create mode 100644 src/lib/litegraph/AGENTS.md diff --git a/.cursor/rules/unit-test.mdc b/.cursor/rules/unit-test.mdc deleted file mode 100644 index 2c6704f3e..000000000 --- a/.cursor/rules/unit-test.mdc +++ /dev/null @@ -1,21 +0,0 @@ ---- -description: Creating unit tests -globs: -alwaysApply: false ---- - -# Creating unit tests - -- This project uses `vitest` for unit testing -- Tests are stored in the `test/` directory -- Tests should be cross-platform compatible; able to run on Windows, macOS, and linux - - e.g. the use of `path.resolve`, or `path.join` and `path.sep` to ensure that tests work the same on all platforms -- Tests should be mocked properly - - Mocks should be cleanly written and easy to understand - - Mocks should be re-usable where possible - -## Unit test style - -- Prefer the use of `test.extend` over loose variables - - To achieve this, import `test as baseTest` from `vitest` -- Never use `it`; `test` should be used in place of this \ No newline at end of file diff --git a/.github/AGENTS.md b/.github/AGENTS.md new file mode 100644 index 000000000..0b64ac21b --- /dev/null +++ b/.github/AGENTS.md @@ -0,0 +1,14 @@ +# PR Review Context + +Context for automated PR review system. + +## Review Scope + +This automated review performs comprehensive analysis: +- Architecture and design patterns +- Security vulnerabilities +- Performance implications +- Code quality and maintainability +- Integration concerns + +For implementation details, see `.claude/commands/comprehensive-pr-review.md`. diff --git a/.github/CLAUDE.md b/.github/CLAUDE.md index 9a95d8cd0..3c928db39 100644 --- a/.github/CLAUDE.md +++ b/.github/CLAUDE.md @@ -1,36 +1,3 @@ -# ComfyUI Frontend - Claude Review Context - -This file provides additional context for the automated PR review system. - -## Quick Reference - -### PrimeVue Component Migrations - -When reviewing, flag these deprecated components: -- `Dropdown` → Use `Select` from 'primevue/select' -- `OverlayPanel` → Use `Popover` from 'primevue/popover' -- `Calendar` → Use `DatePicker` from 'primevue/datepicker' -- `InputSwitch` → Use `ToggleSwitch` from 'primevue/toggleswitch' -- `Sidebar` → Use `Drawer` from 'primevue/drawer' -- `Chips` → Use `AutoComplete` with multiple enabled and typeahead disabled -- `TabMenu` → Use `Tabs` without panels -- `Steps` → Use `Stepper` without panels -- `InlineMessage` → Use `Message` component - -### API Utilities Reference - -- `api.apiURL()` - Backend API calls (/prompt, /queue, /view, etc.) -- `api.fileURL()` - Static file access (templates, extensions) -- `$t()` / `i18n.global.t()` - Internationalization -- `DOMPurify.sanitize()` - HTML sanitization - -## Review Scope - -This automated review performs comprehensive analysis including: -- Architecture and design patterns -- Security vulnerabilities -- Performance implications -- Code quality and maintainability -- Integration concerns - -For implementation details, see `.claude/commands/comprehensive-pr-review.md`. \ No newline at end of file + +@AGENTS.md diff --git a/.storybook/AGENTS.md b/.storybook/AGENTS.md new file mode 100644 index 000000000..5f6373f4b --- /dev/null +++ b/.storybook/AGENTS.md @@ -0,0 +1,17 @@ +# Storybook Guidelines + +See `@docs/guidance/storybook.md` for story patterns (auto-loaded for `*.stories.ts`). + +## Available Context + +Stories have access to: +- All ComfyUI stores +- PrimeVue with ComfyUI theming +- i18n system +- CSS variables and styling + +## Troubleshooting + +1. **Import Errors**: Verify `@/` alias works +2. **Missing Styles**: Check CSS imports in `preview.ts` +3. **Store Errors**: Check store initialization in setup diff --git a/.storybook/CLAUDE.md b/.storybook/CLAUDE.md index ca8248784..320b78815 100644 --- a/.storybook/CLAUDE.md +++ b/.storybook/CLAUDE.md @@ -1,197 +1,3 @@ -# Storybook Development Guidelines for Claude - -## Quick Commands - -- `pnpm storybook`: Start Storybook development server -- `pnpm build-storybook`: Build static Storybook -- `pnpm test:unit`: Run unit tests (includes Storybook components) - -## Development Workflow for Storybook - -1. **Creating New Stories**: - - Place `*.stories.ts` files alongside components - - Follow the naming pattern: `ComponentName.stories.ts` - - Use realistic mock data that matches ComfyUI schemas - -2. **Testing Stories**: - - Verify stories render correctly in Storybook UI - - Test different component states and edge cases - - Ensure proper theming and styling - -3. **Code Quality**: - - Run `pnpm typecheck` to verify TypeScript - - Run `pnpm lint` to check for linting issues - - Follow existing story patterns and conventions - -## Story Creation Guidelines - -### Basic Story Structure - -```typescript -import type { Meta, StoryObj } from '@storybook/vue3' -import ComponentName from './ComponentName.vue' - -const meta: Meta = { - title: 'Category/ComponentName', - component: ComponentName, - parameters: { - layout: 'centered' // or 'fullscreen', 'padded' - } -} - -export default meta -type Story = StoryObj - -export const Default: Story = { - args: { - // Component props - } -} -``` - -### Mock Data Patterns - -For ComfyUI components, use realistic mock data: - -```typescript -// Node definition mock -const mockNodeDef = { - input: { - required: { - prompt: ["STRING", { multiline: true }] - } - }, - output: ["CONDITIONING"], - output_is_list: [false], - category: "conditioning" -} - -// Component instance mock -const mockComponent = { - id: "1", - type: "CLIPTextEncode", - // ... other properties -} -``` - -### Common Story Variants - -Always include these story variants when applicable: - -- **Default**: Basic component with minimal props -- **WithData**: Component with realistic data -- **Loading**: Component in loading state -- **Error**: Component with error state -- **LongContent**: Component with edge case content -- **Empty**: Component with no data - -### Storybook-Specific Code Patterns - -#### Store Access -```typescript -// In stories, access stores through the setup function -export const WithStore: Story = { - render: () => ({ - setup() { - const store = useMyStore() - return { store } - }, - template: '' - }) -} -``` - -#### Event Testing -```typescript -export const WithEvents: Story = { - args: { - onUpdate: fn() // Use Storybook's fn() for action logging - } -} -``` - -## Configuration Notes - -### Vue App Setup -The Storybook preview is configured with: -- Pinia stores initialized -- PrimeVue with ComfyUI theme -- i18n internationalization -- All necessary CSS imports - -### Build Configuration -- Vite integration with proper alias resolution -- Manual chunking for better performance -- TypeScript support with strict checking -- CSS processing for Vue components - -## Troubleshooting - -### Common Issues - -1. **Import Errors**: Verify `@/` alias is working correctly -2. **Missing Styles**: Ensure CSS imports are in `preview.ts` -3. **Store Errors**: Check store initialization in setup -4. **Type Errors**: Use proper TypeScript types for story args - -### Debug Commands - -```bash -# Check TypeScript issues -pnpm typecheck - -# Lint Storybook files -pnpm lint .storybook/ - -# Build to check for production issues -pnpm build-storybook -``` - -## File Organization - -``` -.storybook/ -├── main.ts # Core configuration -├── preview.ts # Global setup and decorators -├── README.md # User documentation -└── CLAUDE.md # This file - Claude guidelines - -src/ -├── components/ -│ └── MyComponent/ -│ ├── MyComponent.vue -│ └── MyComponent.stories.ts -``` - -## Integration with ComfyUI - -### Available Context - -Stories have access to: -- All ComfyUI stores (widgetStore, colorPaletteStore, etc.) -- PrimeVue components with ComfyUI theming -- Internationalization system -- ComfyUI CSS variables and styling - -### Testing Components - -When testing ComfyUI-specific components: -1. Use realistic node definitions and data structures -2. Test with different node types (sampling, conditioning, etc.) -3. Verify proper CSS theming and dark/light modes -4. Check component behavior with various input combinations - -### Performance Considerations - -- Use manual chunking for large dependencies -- Minimize bundle size by avoiding unnecessary imports -- Leverage Storybook's lazy loading capabilities -- Profile build times and optimize as needed - -## Best Practices - -1. **Keep Stories Focused**: Each story should demonstrate one specific use case -2. **Use Descriptive Names**: Story names should clearly indicate what they show -3. **Document Complex Props**: Use JSDoc comments for complex prop types -4. **Test Edge Cases**: Create stories for unusual but valid use cases -5. **Maintain Consistency**: Follow established patterns in existing stories \ No newline at end of file + +@AGENTS.md diff --git a/AGENTS.md b/AGENTS.md index 743572be3..da2953783 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,5 +1,7 @@ # Repository Guidelines +See @docs/guidance/*.md for file-type-specific conventions (auto-loaded by glob). + ## Project Structure & Module Organization - Source: `src/` @@ -46,6 +48,21 @@ The project uses **Nx** for build orchestration and task management - `pnpm lint` / `pnpm lint:fix`: Lint (ESLint) - `pnpm format` / `pnpm format:check`: Prettier - `pnpm typecheck`: Vue TSC type checking +- `pnpm storybook`: Start Storybook development server + +## Development Workflow + +1. Make code changes +2. Run relevant tests +3. Run `pnpm typecheck`, `pnpm lint`, `pnpm format` +4. Check if README updates are needed +5. Suggest docs.comfy.org updates for user-facing changes + +## Git Conventions + +- Use `prefix:` format: `feat:`, `fix:`, `test:` +- Add "Fixes #n" to PR descriptions +- Never mention Claude/AI in commits ## Coding Style & Naming Conventions diff --git a/CLAUDE.md b/CLAUDE.md index 7ceadf85c..43c994c2d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,30 +1 @@ -# Claude Code specific instructions - -@Agents.md - -## Repository Setup - -For first-time setup, use the Claude command: - -```sh -/setup_repo -``` - -This bootstraps the monorepo with dependencies, builds, tests, and dev server verification. - -**Prerequisites:** Node.js >= 24, Git repository, available ports for dev server, storybook, etc. - -## Development Workflow - -1. **First-time setup**: Run `/setup_repo` Claude command -2. Make code changes -3. Run tests (see subdirectory CLAUDE.md files) -4. Run typecheck, lint, format -5. Check README updates -6. Consider docs.comfy.org updates - -## Git Conventions - -- Use `prefix:` format: `feat:`, `fix:`, `test:` -- Add "Fixes #n" to PR descriptions -- Never mention Claude/AI in commits +@AGENTS.md diff --git a/browser_tests/AGENTS.md b/browser_tests/AGENTS.md new file mode 100644 index 000000000..177529ee1 --- /dev/null +++ b/browser_tests/AGENTS.md @@ -0,0 +1,8 @@ +# E2E Testing Guidelines + +See `@docs/guidance/playwright.md` for Playwright best practices (auto-loaded for `*.spec.ts`). + +## Directory Structure + +- `assets/` - Test data (JSON workflows, fixtures) +- Tests use premade JSON workflows to load desired graph state diff --git a/browser_tests/CLAUDE.md b/browser_tests/CLAUDE.md index cc42b392b..62a682148 100644 --- a/browser_tests/CLAUDE.md +++ b/browser_tests/CLAUDE.md @@ -1,17 +1,3 @@ -# E2E Testing Guidelines - -## Browser Tests -- Test user workflows -- Use Playwright fixtures -- Follow naming conventions - -## Best Practices -- Check assets/ for test data -- Prefer specific selectors -- Test across viewports - -## Testing Process -After code changes: -1. Create browser tests as appropriate -2. Run tests until passing -3. Then run typecheck, lint, format \ No newline at end of file + +@AGENTS.md diff --git a/docs/guidance/playwright.md b/docs/guidance/playwright.md new file mode 100644 index 000000000..f8ec8eabf --- /dev/null +++ b/docs/guidance/playwright.md @@ -0,0 +1,33 @@ +--- +globs: + - '**/*.spec.ts' +--- + +# Playwright E2E Test Conventions + +See `docs/testing/*.md` for detailed patterns. + +## Best Practices + +- Follow [Playwright Best Practices](https://playwright.dev/docs/best-practices) +- Do NOT use `waitForTimeout` - use Locator actions and retrying assertions +- Prefer specific selectors (role, label, test-id) +- Test across viewports + +## Test Tags + +Tags are respected by config: +- `@mobile` - Mobile viewport tests +- `@2x` - High DPI tests + +## Test Data + +- Check `browser_tests/assets/` for test data and fixtures +- Use realistic ComfyUI workflows for E2E tests + +## Running Tests + +```bash +pnpm test:browser # Run all E2E tests +pnpm test:browser -- --ui # Interactive UI mode +``` diff --git a/docs/guidance/storybook.md b/docs/guidance/storybook.md new file mode 100644 index 000000000..70bdb6c53 --- /dev/null +++ b/docs/guidance/storybook.md @@ -0,0 +1,55 @@ +--- +globs: + - '**/*.stories.ts' +--- + +# Storybook Conventions + +## File Placement + +Place `*.stories.ts` files alongside their components: +``` +src/components/MyComponent/ +├── MyComponent.vue +└── MyComponent.stories.ts +``` + +## Story Structure + +```typescript +import type { Meta, StoryObj } from '@storybook/vue3' +import ComponentName from './ComponentName.vue' + +const meta: Meta = { + title: 'Category/ComponentName', + component: ComponentName, + parameters: { layout: 'centered' } +} + +export default meta +type Story = StoryObj + +export const Default: Story = { + args: { /* props */ } +} +``` + +## Required Story Variants + +Include when applicable: +- **Default** - Minimal props +- **WithData** - Realistic data +- **Loading** - Loading state +- **Error** - Error state +- **Empty** - No data + +## Mock Data + +Use realistic ComfyUI schemas for mocks (node definitions, components). + +## Running Storybook + +```bash +pnpm storybook # Development server +pnpm build-storybook # Production build +``` diff --git a/docs/guidance/typescript.md b/docs/guidance/typescript.md new file mode 100644 index 000000000..9a6dd102b --- /dev/null +++ b/docs/guidance/typescript.md @@ -0,0 +1,37 @@ +--- +globs: + - '**/*.ts' + - '**/*.tsx' + - '**/*.vue' +--- + +# TypeScript Conventions + +## Type Safety + +- Never use `any` type - use proper TypeScript types +- Never use `as any` type assertions - fix the underlying type issue +- Type assertions are a last resort; they lead to brittle code +- Avoid `@ts-expect-error` - fix the underlying issue instead + +## Utility Libraries + +- Use `es-toolkit` for utility functions (not lodash) + +## API Utilities + +When making API calls in `src/`: + +```typescript +// ✅ Correct - use api helpers +const response = await api.get(api.apiURL('/prompt')) +const template = await fetch(api.fileURL('/templates/default.json')) + +// ❌ Wrong - direct URL construction +const response = await fetch('/api/prompt') +``` + +## Security + +- Sanitize HTML with `DOMPurify.sanitize()` +- Never log secrets or sensitive data diff --git a/docs/guidance/vitest.md b/docs/guidance/vitest.md new file mode 100644 index 000000000..90fe19673 --- /dev/null +++ b/docs/guidance/vitest.md @@ -0,0 +1,36 @@ +--- +globs: + - '**/*.test.ts' +--- + +# Vitest Unit Test Conventions + +See `docs/testing/*.md` for detailed patterns. + +## Test Quality + +- Do not write change detector tests (tests that just assert defaults) +- Do not write tests dependent on non-behavioral features (styles, classes) +- Do not write tests that just test mocks - ensure real code is exercised +- Be parsimonious; avoid redundant tests + +## Mocking + +- Use Vitest's mocking utilities (`vi.mock`, `vi.spyOn`) +- Keep module mocks contained - no global mutable state +- Use `vi.hoisted()` for per-test mock manipulation +- Don't mock what you don't own + +## Component Testing + +- Use Vue Test Utils for component tests +- Follow advice about making components easy to test +- Wait for reactivity with `await nextTick()` after state changes + +## Running Tests + +```bash +pnpm test:unit # Run all unit tests +pnpm test:unit -- path/to/file # Run specific test +pnpm test:unit -- --watch # Watch mode +``` diff --git a/docs/guidance/vue-components.md b/docs/guidance/vue-components.md new file mode 100644 index 000000000..24bcf22fe --- /dev/null +++ b/docs/guidance/vue-components.md @@ -0,0 +1,46 @@ +--- +globs: + - '**/*.vue' +--- + +# Vue Component Conventions + +Applies to all `.vue` files anywhere in the codebase. + +## Vue 3 Composition API + +- Use ` diff --git a/src/components/sidebar/tabs/nodeLibrary/NodeTreeLeaf.vue b/src/components/sidebar/tabs/nodeLibrary/NodeTreeLeaf.vue index fc3cabfcc..325cf44f5 100644 --- a/src/components/sidebar/tabs/nodeLibrary/NodeTreeLeaf.vue +++ b/src/components/sidebar/tabs/nodeLibrary/NodeTreeLeaf.vue @@ -57,7 +57,7 @@ variant="muted-textonly" size="icon-sm" :aria-label="$t('g.learnMore')" - @click.stop="props.openNodeHelp(nodeDef)" + @click.stop="onHelpClick" > @@ -85,6 +85,7 @@ import TreeExplorerTreeNode from '@/components/common/TreeExplorerTreeNode.vue' import NodePreview from '@/components/node/NodePreview.vue' import Button from '@/components/ui/button/Button.vue' import { useSettingStore } from '@/platform/settings/settingStore' +import { useTelemetry } from '@/platform/telemetry' import { useNodeBookmarkStore } from '@/stores/nodeBookmarkStore' import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore' import { useSubgraphStore } from '@/stores/subgraphStore' @@ -112,6 +113,13 @@ const sidebarLocation = computed<'left' | 'right'>(() => const toggleBookmark = async () => { await nodeBookmarkStore.toggleBookmark(nodeDef.value) } + +const onHelpClick = () => { + useTelemetry()?.trackUiButtonClicked({ + button_id: 'node_library_help_button' + }) + props.openNodeHelp(nodeDef.value) +} const editBlueprint = async () => { if (!props.node.data) throw new Error( diff --git a/src/composables/useNodeHelpContent.test.ts b/src/composables/useNodeHelpContent.test.ts new file mode 100644 index 000000000..02b3d102f --- /dev/null +++ b/src/composables/useNodeHelpContent.test.ts @@ -0,0 +1,381 @@ +import { flushPromises } from '@vue/test-utils' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { nextTick, ref } from 'vue' + +import { useNodeHelpContent } from '@/composables/useNodeHelpContent' +import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore' + +function createMockNode( + overrides: Partial +): ComfyNodeDefImpl { + return { + name: 'TestNode', + display_name: 'Test Node', + description: 'A test node', + category: 'test', + python_module: 'comfy.test_node', + inputs: {}, + outputs: [], + deprecated: false, + experimental: false, + output_node: false, + api_node: false, + ...overrides + } as ComfyNodeDefImpl +} + +vi.mock('@/scripts/api', () => ({ + api: { + fileURL: vi.fn((url) => url) + } +})) + +vi.mock('vue-i18n', () => ({ + useI18n: () => ({ + locale: ref('en') + }) +})) + +vi.mock('@/types/nodeSource', () => ({ + NodeSourceType: { + Core: 'core', + CustomNodes: 'custom_nodes' + }, + getNodeSource: vi.fn((pythonModule) => { + if (pythonModule?.startsWith('custom_nodes.')) { + return { type: 'custom_nodes' } + } + return { type: 'core' } + }) +})) + +describe('useNodeHelpContent', () => { + const mockCoreNode = createMockNode({ + name: 'TestNode', + display_name: 'Test Node', + description: 'A test node', + python_module: 'comfy.test_node' + }) + + const mockCustomNode = createMockNode({ + name: 'CustomNode', + display_name: 'Custom Node', + description: 'A custom node', + python_module: 'custom_nodes.test_module.custom@1.0.0' + }) + + const mockFetch = vi.fn() + + beforeEach(() => { + mockFetch.mockReset() + vi.stubGlobal('fetch', mockFetch) + }) + + afterEach(() => { + vi.unstubAllGlobals() + }) + + it('should generate correct baseUrl for core nodes', async () => { + const nodeRef = ref(mockCoreNode) + mockFetch.mockResolvedValueOnce({ + ok: true, + text: async () => '# Test' + }) + + const { baseUrl } = useNodeHelpContent(nodeRef) + await nextTick() + + expect(baseUrl.value).toBe(`/docs/${mockCoreNode.name}/`) + }) + + it('should generate correct baseUrl for custom nodes', async () => { + const nodeRef = ref(mockCustomNode) + mockFetch.mockResolvedValueOnce({ + ok: true, + text: async () => '# Test' + }) + + const { baseUrl } = useNodeHelpContent(nodeRef) + await nextTick() + + expect(baseUrl.value).toBe('/extensions/test_module/docs/') + }) + + it('should render markdown content correctly', async () => { + const nodeRef = ref(mockCoreNode) + mockFetch.mockResolvedValueOnce({ + ok: true, + text: async () => '# Test Help\nThis is test help content' + }) + + const { renderedHelpHtml } = useNodeHelpContent(nodeRef) + await flushPromises() + + expect(renderedHelpHtml.value).toContain('This is test help content') + }) + + it('should handle fetch errors and fall back to description', async () => { + const nodeRef = ref(mockCoreNode) + mockFetch.mockResolvedValueOnce({ + ok: false, + statusText: 'Not Found' + }) + + const { error, renderedHelpHtml } = useNodeHelpContent(nodeRef) + await flushPromises() + + expect(error.value).toBe('Not Found') + expect(renderedHelpHtml.value).toContain(mockCoreNode.description) + }) + + it('should include alt attribute for images', async () => { + const nodeRef = ref(mockCustomNode) + mockFetch.mockResolvedValueOnce({ + ok: true, + text: async () => '![image](test.jpg)' + }) + + const { renderedHelpHtml } = useNodeHelpContent(nodeRef) + await flushPromises() + + expect(renderedHelpHtml.value).toContain('alt="image"') + }) + + it('should prefix relative video src in custom nodes', async () => { + const nodeRef = ref(mockCustomNode) + mockFetch.mockResolvedValueOnce({ + ok: true, + text: async () => '' + }) + + const { renderedHelpHtml } = useNodeHelpContent(nodeRef) + await flushPromises() + + expect(renderedHelpHtml.value).toContain( + 'src="/extensions/test_module/docs/video.mp4"' + ) + }) + + it('should prefix relative video src for core nodes with node-specific base URL', async () => { + const nodeRef = ref(mockCoreNode) + mockFetch.mockResolvedValueOnce({ + ok: true, + text: async () => '' + }) + + const { renderedHelpHtml } = useNodeHelpContent(nodeRef) + await flushPromises() + + expect(renderedHelpHtml.value).toContain( + `src="/docs/${mockCoreNode.name}/video.mp4"` + ) + }) + + it('should handle loading state', async () => { + const nodeRef = ref(mockCoreNode) + mockFetch.mockImplementationOnce(() => new Promise(() => {})) // Never resolves + + const { isLoading } = useNodeHelpContent(nodeRef) + await nextTick() + + expect(isLoading.value).toBe(true) + }) + + it('should try fallback URL for custom nodes', async () => { + const nodeRef = ref(mockCustomNode) + mockFetch + .mockResolvedValueOnce({ + ok: false, + statusText: 'Not Found' + }) + .mockResolvedValueOnce({ + ok: true, + text: async () => '# Fallback content' + }) + + useNodeHelpContent(nodeRef) + await flushPromises() + + expect(mockFetch).toHaveBeenCalledTimes(2) + expect(mockFetch).toHaveBeenCalledWith( + '/extensions/test_module/docs/CustomNode/en.md' + ) + expect(mockFetch).toHaveBeenCalledWith( + '/extensions/test_module/docs/CustomNode.md' + ) + }) + + it('should prefix relative source src in custom nodes', async () => { + const nodeRef = ref(mockCustomNode) + mockFetch.mockResolvedValueOnce({ + ok: true, + text: async () => + '' + }) + + const { renderedHelpHtml } = useNodeHelpContent(nodeRef) + await flushPromises() + + expect(renderedHelpHtml.value).toContain( + 'src="/extensions/test_module/docs/video.mp4"' + ) + }) + + it('should prefix relative source src for core nodes with node-specific base URL', async () => { + const nodeRef = ref(mockCoreNode) + mockFetch.mockResolvedValueOnce({ + ok: true, + text: async () => + '' + }) + + const { renderedHelpHtml } = useNodeHelpContent(nodeRef) + await flushPromises() + + expect(renderedHelpHtml.value).toContain( + `src="/docs/${mockCoreNode.name}/video.webm"` + ) + }) + + it('should prefix relative img src in raw HTML for custom nodes', async () => { + const nodeRef = ref(mockCustomNode) + mockFetch.mockResolvedValueOnce({ + ok: true, + text: async () => '# Test\nTest image' + }) + + const { renderedHelpHtml } = useNodeHelpContent(nodeRef) + await flushPromises() + + expect(renderedHelpHtml.value).toContain( + 'src="/extensions/test_module/docs/image.png"' + ) + expect(renderedHelpHtml.value).toContain('alt="Test image"') + }) + + it('should prefix relative img src in raw HTML for core nodes', async () => { + const nodeRef = ref(mockCoreNode) + mockFetch.mockResolvedValueOnce({ + ok: true, + text: async () => '# Test\nTest image' + }) + + const { renderedHelpHtml } = useNodeHelpContent(nodeRef) + await flushPromises() + + expect(renderedHelpHtml.value).toContain( + `src="/docs/${mockCoreNode.name}/image.png"` + ) + expect(renderedHelpHtml.value).toContain('alt="Test image"') + }) + + it('should not prefix absolute img src in raw HTML', async () => { + const nodeRef = ref(mockCustomNode) + mockFetch.mockResolvedValueOnce({ + ok: true, + text: async () => 'Absolute' + }) + + const { renderedHelpHtml } = useNodeHelpContent(nodeRef) + await flushPromises() + + expect(renderedHelpHtml.value).toContain('src="/absolute/image.png"') + expect(renderedHelpHtml.value).toContain('alt="Absolute"') + }) + + it('should not prefix external img src in raw HTML', async () => { + const nodeRef = ref(mockCustomNode) + mockFetch.mockResolvedValueOnce({ + ok: true, + text: async () => + 'External' + }) + + const { renderedHelpHtml } = useNodeHelpContent(nodeRef) + await flushPromises() + + expect(renderedHelpHtml.value).toContain( + 'src="https://example.com/image.png"' + ) + expect(renderedHelpHtml.value).toContain('alt="External"') + }) + + it('should handle various quote styles in media src attributes', async () => { + const nodeRef = ref(mockCoreNode) + mockFetch.mockResolvedValueOnce({ + ok: true, + text: async () => `# Media Test + +Testing quote styles in properly formed HTML: + + + +Double quotes +Single quotes + + + +The MEDIA_SRC_REGEX handles both single and double quotes in img, video and source tags.` + }) + + const { renderedHelpHtml } = useNodeHelpContent(nodeRef) + await flushPromises() + + // All media src attributes should be prefixed correctly + // Note: marked normalizes quotes to double quotes in output + expect(renderedHelpHtml.value).toContain( + `src="/docs/${mockCoreNode.name}/video1.mp4"` + ) + expect(renderedHelpHtml.value).toContain( + `src="/docs/${mockCoreNode.name}/video2.mp4"` + ) + expect(renderedHelpHtml.value).toContain( + `src="/docs/${mockCoreNode.name}/image1.png"` + ) + expect(renderedHelpHtml.value).toContain( + `src="/docs/${mockCoreNode.name}/image2.png"` + ) + expect(renderedHelpHtml.value).toContain( + `src="/docs/${mockCoreNode.name}/video3.mp4"` + ) + expect(renderedHelpHtml.value).toContain( + `src="/docs/${mockCoreNode.name}/video3.webm"` + ) + }) + + it('should ignore stale requests when node changes', async () => { + const nodeRef = ref(mockCoreNode) + let resolveFirst: (value: unknown) => void + const firstRequest = new Promise((resolve) => { + resolveFirst = resolve + }) + + mockFetch + .mockImplementationOnce(() => firstRequest) + .mockResolvedValueOnce({ + ok: true, + text: async () => '# Second node content' + }) + + const { helpContent } = useNodeHelpContent(nodeRef) + await nextTick() + + // Change node before first request completes + nodeRef.value = mockCustomNode + await nextTick() + await flushPromises() + + // Now resolve the first (stale) request + resolveFirst!({ + ok: true, + text: async () => '# First node content' + }) + await flushPromises() + + // Should have second node's content, not first + expect(helpContent.value).toBe('# Second node content') + }) +}) diff --git a/src/composables/useNodeHelpContent.ts b/src/composables/useNodeHelpContent.ts new file mode 100644 index 000000000..81ef9ae47 --- /dev/null +++ b/src/composables/useNodeHelpContent.ts @@ -0,0 +1,79 @@ +import type { MaybeRefOrGetter } from 'vue' +import { computed, ref, toValue, watch } from 'vue' +import { useI18n } from 'vue-i18n' + +import { nodeHelpService } from '@/services/nodeHelpService' +import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore' +import { renderMarkdownToHtml } from '@/utils/markdownRendererUtil' +import { getNodeHelpBaseUrl } from '@/workbench/utils/nodeHelpUtil' + +/** + * Composable for fetching and rendering node help content. + * Creates independent state for each usage, allowing multiple panels + * to show help content without interfering with each other. + * + * @param nodeRef - Reactive reference to the node to show help for + * @returns Reactive help content state and rendered HTML + */ +export function useNodeHelpContent( + nodeRef: MaybeRefOrGetter +) { + const { locale } = useI18n() + + const helpContent = ref('') + const isLoading = ref(false) + const error = ref(null) + + let currentRequest: Promise | null = null + + const baseUrl = computed(() => { + const node = toValue(nodeRef) + if (!node) return '' + return getNodeHelpBaseUrl(node) + }) + + const renderedHelpHtml = computed(() => { + return renderMarkdownToHtml(helpContent.value, baseUrl.value) + }) + + // Watch for node changes and fetch help content + watch( + () => toValue(nodeRef), + async (node) => { + helpContent.value = '' + error.value = null + + if (node) { + isLoading.value = true + const request = (currentRequest = nodeHelpService.fetchNodeHelp( + node, + locale.value || 'en' + )) + + try { + const content = await request + if (currentRequest !== request) return + helpContent.value = content + } catch (e: unknown) { + if (currentRequest !== request) return + error.value = e instanceof Error ? e.message : String(e) + helpContent.value = node.description || '' + } finally { + if (currentRequest === request) { + currentRequest = null + isLoading.value = false + } + } + } + }, + { immediate: true } + ) + + return { + helpContent, + isLoading, + error, + baseUrl, + renderedHelpHtml + } +} diff --git a/src/stores/workspace/nodeHelpStore.test.ts b/src/stores/workspace/nodeHelpStore.test.ts index dc8b7b466..0514e9029 100644 --- a/src/stores/workspace/nodeHelpStore.test.ts +++ b/src/stores/workspace/nodeHelpStore.test.ts @@ -1,74 +1,9 @@ -import { flushPromises } from '@vue/test-utils' import { createPinia, setActivePinia } from 'pinia' -import { beforeEach, describe, expect, it, vi } from 'vitest' -import { nextTick } from 'vue' +import { beforeEach, describe, expect, it } from 'vitest' import { useNodeHelpStore } from '@/stores/workspace/nodeHelpStore' -vi.mock('@/scripts/api', () => ({ - api: { - fileURL: vi.fn((url) => url) - } -})) - -vi.mock('@/i18n', () => ({ - i18n: { - global: { - locale: { - value: 'en' - } - } - } -})) - -vi.mock('@/types/nodeSource', () => ({ - NodeSourceType: { - Core: 'core', - CustomNodes: 'custom_nodes' - }, - getNodeSource: vi.fn((pythonModule) => { - if (pythonModule?.startsWith('custom_nodes.')) { - return { type: 'custom_nodes' } - } - return { type: 'core' } - }) -})) - -vi.mock('dompurify', () => ({ - default: { - sanitize: vi.fn((html) => html) - } -})) - -vi.mock('marked', () => ({ - marked: { - parse: vi.fn((markdown, options) => { - if (options?.renderer) { - if (markdown.includes('![')) { - const matches = markdown.match(/!\[(.*?)\]\((.*?)\)/) - if (matches) { - const [, text, href] = matches - return options.renderer.image({ href, text, title: '' }) - } - } - } - return `

${markdown}

` - }) - }, - Renderer: class Renderer { - image = vi.fn( - ({ href, title, text }) => - `${text}` - ) - link = vi.fn( - ({ href, title, text }) => - `${text}` - ) - } -})) - describe('nodeHelpStore', () => { - // Define a mock node for testing const mockCoreNode = { name: 'TestNode', display_name: 'Test Node', @@ -78,23 +13,8 @@ describe('nodeHelpStore', () => { python_module: 'comfy.test_node' } - const mockCustomNode = { - name: 'CustomNode', - display_name: 'Custom Node', - description: 'A custom node', - inputs: {}, - outputs: [], - python_module: 'custom_nodes.test_module.custom@1.0.0' - } - - // Mock fetch responses - const mockFetch = vi.fn() - global.fetch = mockFetch - beforeEach(() => { - // Setup Pinia setActivePinia(createPinia()) - mockFetch.mockReset() }) it('should initialize with empty state', () => { @@ -122,278 +42,4 @@ describe('nodeHelpStore', () => { expect(nodeHelpStore.currentHelpNode).toBeNull() expect(nodeHelpStore.isHelpOpen).toBe(false) }) - - it('should generate correct baseUrl for core nodes', async () => { - const nodeHelpStore = useNodeHelpStore() - - nodeHelpStore.openHelp(mockCoreNode as any) - await nextTick() - - expect(nodeHelpStore.baseUrl).toBe(`/docs/${mockCoreNode.name}/`) - }) - - it('should generate correct baseUrl for custom nodes', async () => { - const nodeHelpStore = useNodeHelpStore() - - nodeHelpStore.openHelp(mockCustomNode as any) - await nextTick() - - expect(nodeHelpStore.baseUrl).toBe('/extensions/test_module/docs/') - }) - - it('should render markdown content correctly', async () => { - const nodeHelpStore = useNodeHelpStore() - - mockFetch.mockResolvedValueOnce({ - ok: true, - text: async () => '# Test Help\nThis is test help content' - }) - - nodeHelpStore.openHelp(mockCoreNode as any) - await flushPromises() - - expect(nodeHelpStore.renderedHelpHtml).toContain( - 'This is test help content' - ) - }) - - it('should handle fetch errors and fall back to description', async () => { - const nodeHelpStore = useNodeHelpStore() - - mockFetch.mockResolvedValueOnce({ - ok: false, - statusText: 'Not Found' - }) - - nodeHelpStore.openHelp(mockCoreNode as any) - await flushPromises() - - expect(nodeHelpStore.error).toBe('Not Found') - expect(nodeHelpStore.renderedHelpHtml).toContain(mockCoreNode.description) - }) - - it('should include alt attribute for images', async () => { - const nodeHelpStore = useNodeHelpStore() - - mockFetch.mockResolvedValueOnce({ - ok: true, - text: async () => '![image](test.jpg)' - }) - - nodeHelpStore.openHelp(mockCustomNode as any) - await flushPromises() - expect(nodeHelpStore.renderedHelpHtml).toContain('alt="image"') - }) - - it('should prefix relative video src in custom nodes', async () => { - const nodeHelpStore = useNodeHelpStore() - - mockFetch.mockResolvedValueOnce({ - ok: true, - text: async () => '' - }) - - nodeHelpStore.openHelp(mockCustomNode as any) - await flushPromises() - expect(nodeHelpStore.renderedHelpHtml).toContain( - 'src="/extensions/test_module/docs/video.mp4"' - ) - }) - - it('should prefix relative video src for core nodes with node-specific base URL', async () => { - const nodeHelpStore = useNodeHelpStore() - - mockFetch.mockResolvedValueOnce({ - ok: true, - text: async () => '' - }) - - nodeHelpStore.openHelp(mockCoreNode as any) - await flushPromises() - expect(nodeHelpStore.renderedHelpHtml).toContain( - `src="/docs/${mockCoreNode.name}/video.mp4"` - ) - }) - - it('should prefix relative source src in custom nodes', async () => { - const nodeHelpStore = useNodeHelpStore() - - mockFetch.mockResolvedValueOnce({ - ok: true, - text: async () => - '' - }) - - nodeHelpStore.openHelp(mockCustomNode as any) - await flushPromises() - expect(nodeHelpStore.renderedHelpHtml).toContain( - 'src="/extensions/test_module/docs/video.mp4"' - ) - }) - - it('should prefix relative source src for core nodes with node-specific base URL', async () => { - const nodeHelpStore = useNodeHelpStore() - - mockFetch.mockResolvedValueOnce({ - ok: true, - text: async () => - '' - }) - - nodeHelpStore.openHelp(mockCoreNode as any) - await flushPromises() - expect(nodeHelpStore.renderedHelpHtml).toContain( - `src="/docs/${mockCoreNode.name}/video.webm"` - ) - }) - - it('should handle loading state', async () => { - const nodeHelpStore = useNodeHelpStore() - - mockFetch.mockImplementationOnce(() => new Promise(() => {})) // Never resolves - - nodeHelpStore.openHelp(mockCoreNode as any) - await nextTick() - - expect(nodeHelpStore.isLoading).toBe(true) - }) - - it('should try fallback URL for custom nodes', async () => { - const nodeHelpStore = useNodeHelpStore() - - mockFetch - .mockResolvedValueOnce({ - ok: false, - statusText: 'Not Found' - }) - .mockResolvedValueOnce({ - ok: true, - text: async () => '# Fallback content' - }) - - nodeHelpStore.openHelp(mockCustomNode as any) - await flushPromises() - - expect(mockFetch).toHaveBeenCalledTimes(2) - expect(mockFetch).toHaveBeenCalledWith( - '/extensions/test_module/docs/CustomNode/en.md' - ) - expect(mockFetch).toHaveBeenCalledWith( - '/extensions/test_module/docs/CustomNode.md' - ) - }) - - it('should prefix relative img src in raw HTML for custom nodes', async () => { - const nodeHelpStore = useNodeHelpStore() - - mockFetch.mockResolvedValueOnce({ - ok: true, - text: async () => '# Test\nTest image' - }) - - nodeHelpStore.openHelp(mockCustomNode as any) - await flushPromises() - expect(nodeHelpStore.renderedHelpHtml).toContain( - 'src="/extensions/test_module/docs/image.png"' - ) - expect(nodeHelpStore.renderedHelpHtml).toContain('alt="Test image"') - }) - - it('should prefix relative img src in raw HTML for core nodes', async () => { - const nodeHelpStore = useNodeHelpStore() - - mockFetch.mockResolvedValueOnce({ - ok: true, - text: async () => '# Test\nTest image' - }) - - nodeHelpStore.openHelp(mockCoreNode as any) - await flushPromises() - expect(nodeHelpStore.renderedHelpHtml).toContain( - `src="/docs/${mockCoreNode.name}/image.png"` - ) - expect(nodeHelpStore.renderedHelpHtml).toContain('alt="Test image"') - }) - - it('should not prefix absolute img src in raw HTML', async () => { - const nodeHelpStore = useNodeHelpStore() - - mockFetch.mockResolvedValueOnce({ - ok: true, - text: async () => 'Absolute' - }) - - nodeHelpStore.openHelp(mockCustomNode as any) - await flushPromises() - expect(nodeHelpStore.renderedHelpHtml).toContain( - 'src="/absolute/image.png"' - ) - expect(nodeHelpStore.renderedHelpHtml).toContain('alt="Absolute"') - }) - - it('should not prefix external img src in raw HTML', async () => { - const nodeHelpStore = useNodeHelpStore() - - mockFetch.mockResolvedValueOnce({ - ok: true, - text: async () => - 'External' - }) - - nodeHelpStore.openHelp(mockCustomNode as any) - await flushPromises() - expect(nodeHelpStore.renderedHelpHtml).toContain( - 'src="https://example.com/image.png"' - ) - expect(nodeHelpStore.renderedHelpHtml).toContain('alt="External"') - }) - - it('should handle various quote styles in media src attributes', async () => { - const nodeHelpStore = useNodeHelpStore() - - mockFetch.mockResolvedValueOnce({ - ok: true, - text: async () => `# Media Test - -Testing quote styles in properly formed HTML: - - - -Double quotes -Single quotes - - - -The MEDIA_SRC_REGEX handles both single and double quotes in img, video and source tags.` - }) - - nodeHelpStore.openHelp(mockCoreNode as any) - await flushPromises() - - // Check that all media elements with different quote styles are prefixed correctly - // Double quotes remain as double quotes - expect(nodeHelpStore.renderedHelpHtml).toContain( - `src="/docs/${mockCoreNode.name}/video1.mp4"` - ) - expect(nodeHelpStore.renderedHelpHtml).toContain( - `src="/docs/${mockCoreNode.name}/image1.png"` - ) - expect(nodeHelpStore.renderedHelpHtml).toContain( - `src="/docs/${mockCoreNode.name}/video3.mp4"` - ) - - // Single quotes remain as single quotes in the output - expect(nodeHelpStore.renderedHelpHtml).toContain( - `src='/docs/${mockCoreNode.name}/video2.mp4'` - ) - expect(nodeHelpStore.renderedHelpHtml).toContain( - `src='/docs/${mockCoreNode.name}/image2.png'` - ) - expect(nodeHelpStore.renderedHelpHtml).toContain( - `src='/docs/${mockCoreNode.name}/video3.webm'` - ) - }) }) diff --git a/src/stores/workspace/nodeHelpStore.ts b/src/stores/workspace/nodeHelpStore.ts index a04d6d829..4e6765040 100644 --- a/src/stores/workspace/nodeHelpStore.ts +++ b/src/stores/workspace/nodeHelpStore.ts @@ -1,18 +1,11 @@ import { defineStore } from 'pinia' -import { computed, ref, watch } from 'vue' +import { computed, ref } from 'vue' -import { i18n } from '@/i18n' -import { nodeHelpService } from '@/services/nodeHelpService' import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore' -import { renderMarkdownToHtml } from '@/utils/markdownRendererUtil' -import { getNodeHelpBaseUrl } from '@/workbench/utils/nodeHelpUtil' export const useNodeHelpStore = defineStore('nodeHelp', () => { const currentHelpNode = ref(null) const isHelpOpen = computed(() => currentHelpNode.value !== null) - const helpContent = ref('') - const isLoading = ref(false) - const errorMsg = ref(null) function openHelp(nodeDef: ComfyNodeDefImpl) { currentHelpNode.value = nodeDef @@ -22,48 +15,10 @@ export const useNodeHelpStore = defineStore('nodeHelp', () => { currentHelpNode.value = null } - // Base URL for relative assets in node docs markdown - const baseUrl = computed(() => { - const node = currentHelpNode.value - if (!node) return '' - return getNodeHelpBaseUrl(node) - }) - - // Watch for help node changes and fetch its docs markdown - watch( - () => currentHelpNode.value, - async (node) => { - helpContent.value = '' - errorMsg.value = null - - if (node) { - isLoading.value = true - try { - const locale = i18n.global.locale.value || 'en' - helpContent.value = await nodeHelpService.fetchNodeHelp(node, locale) - } catch (e: any) { - errorMsg.value = e.message - helpContent.value = node.description || '' - } finally { - isLoading.value = false - } - } - }, - { immediate: true } - ) - - const renderedHelpHtml = computed(() => { - return renderMarkdownToHtml(helpContent.value, baseUrl.value) - }) - return { currentHelpNode, isHelpOpen, openHelp, - closeHelp, - baseUrl, - renderedHelpHtml, - isLoading, - error: errorMsg + closeHelp } }) From 7b3a9b40a5bffd496d0d337cc77ff4145d40485c Mon Sep 17 00:00:00 2001 From: Comfy Org PR Bot Date: Sat, 17 Jan 2026 14:27:37 +0900 Subject: [PATCH 08/45] 1.38.4 (#8116) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Patch version increment to 1.38.4 **Base branch:** `main` ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-8116-1-38-4-2eb6d73d36508140a132e7d14241e851) by [Unito](https://www.unito.io) --------- Co-authored-by: christian-byrne <72887196+christian-byrne@users.noreply.github.com> Co-authored-by: github-actions Co-authored-by: Alexander Brown --- package.json | 2 +- src/locales/ar/nodeDefs.json | 12 ++++++++++++ src/locales/ar/settings.json | 4 ++++ src/locales/en/main.json | 2 +- src/locales/en/nodeDefs.json | 12 ++++++++++++ src/locales/en/settings.json | 4 ++++ src/locales/es/nodeDefs.json | 12 ++++++++++++ src/locales/es/settings.json | 4 ++++ src/locales/fa/nodeDefs.json | 12 ++++++++++++ src/locales/fa/settings.json | 4 ++++ src/locales/fr/nodeDefs.json | 12 ++++++++++++ src/locales/fr/settings.json | 4 ++++ src/locales/ja/nodeDefs.json | 12 ++++++++++++ src/locales/ja/settings.json | 4 ++++ src/locales/ko/nodeDefs.json | 12 ++++++++++++ src/locales/ko/settings.json | 4 ++++ src/locales/pt-BR/nodeDefs.json | 12 ++++++++++++ src/locales/pt-BR/settings.json | 4 ++++ src/locales/ru/nodeDefs.json | 12 ++++++++++++ src/locales/ru/settings.json | 4 ++++ src/locales/tr/nodeDefs.json | 12 ++++++++++++ src/locales/tr/settings.json | 4 ++++ src/locales/zh-TW/nodeDefs.json | 12 ++++++++++++ src/locales/zh-TW/settings.json | 4 ++++ src/locales/zh/nodeDefs.json | 12 ++++++++++++ src/locales/zh/settings.json | 4 ++++ 26 files changed, 194 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index a4db1d77d..8f1ab6628 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@comfyorg/comfyui-frontend", "private": true, - "version": "1.38.3", + "version": "1.38.4", "type": "module", "repository": "https://github.com/Comfy-Org/ComfyUI_frontend", "homepage": "https://comfy.org", diff --git a/src/locales/ar/nodeDefs.json b/src/locales/ar/nodeDefs.json index 5e81dd838..a1d6aed86 100644 --- a/src/locales/ar/nodeDefs.json +++ b/src/locales/ar/nodeDefs.json @@ -351,6 +351,10 @@ "name": "الإطار_الأول", "tooltip": "الإطار الأول الذي سيتم استخدامه للفيديو." }, + "generate_audio": { + "name": "توليد الصوت", + "tooltip": "يتم تجاهل هذا المعامل لجميع النماذج باستثناء seedance-1-5-pro." + }, "last_frame": { "name": "الإطار_الأخير", "tooltip": "الإطار الأخير الذي سيتم استخدامه للفيديو." @@ -527,6 +531,10 @@ "name": "duration", "tooltip": "مدة الفيديو الناتج بالثواني." }, + "generate_audio": { + "name": "توليد الصوت", + "tooltip": "يتم تجاهل هذا المعامل لجميع النماذج باستثناء seedance-1-5-pro." + }, "image": { "name": "image", "tooltip": "الإطار الأول الذي سيتم استخدامه للفيديو." @@ -634,6 +642,10 @@ "name": "المدة", "tooltip": "مدة الفيديو الناتج بالثواني." }, + "generate_audio": { + "name": "توليد الصوت", + "tooltip": "يتم تجاهل هذا المعامل لجميع النماذج باستثناء seedance-1-5-pro." + }, "model": { "name": "النموذج" }, diff --git a/src/locales/ar/settings.json b/src/locales/ar/settings.json index ff6e99066..edc90fde5 100644 --- a/src/locales/ar/settings.json +++ b/src/locales/ar/settings.json @@ -332,6 +332,10 @@ "name": "حجم تاريخ قائمة الانتظار", "tooltip": "العدد الأقصى للمهام المعروضة في تاريخ قائمة الانتظار." }, + "Comfy_Queue_QPOV2": { + "name": "استخدم قائمة انتظار المهام الموحدة في لوحة الأصول الجانبية", + "tooltip": "يستبدل لوحة قائمة انتظار المهام العائمة بقائمة انتظار مهام مكافئة مدمجة في لوحة الأصول الجانبية. يمكنك تعطيل هذا الخيار للعودة إلى تخطيط اللوحة العائمة." + }, "Comfy_Sidebar_Location": { "name": "موقع الشريط الجانبي", "options": { diff --git a/src/locales/en/main.json b/src/locales/en/main.json index 17dad737f..e8d3b19f5 100644 --- a/src/locales/en/main.json +++ b/src/locales/en/main.json @@ -2608,4 +2608,4 @@ "tokenExchangeFailed": "Failed to authenticate with workspace: {error}" } } -} +} \ No newline at end of file diff --git a/src/locales/en/nodeDefs.json b/src/locales/en/nodeDefs.json index a304444ed..c745a0db0 100644 --- a/src/locales/en/nodeDefs.json +++ b/src/locales/en/nodeDefs.json @@ -371,6 +371,10 @@ "name": "watermark", "tooltip": "Whether to add an \"AI generated\" watermark to the video." }, + "generate_audio": { + "name": "generate_audio", + "tooltip": "This parameter is ignored for any model except seedance-1-5-pro." + }, "control_after_generate": { "name": "control after generate" } @@ -547,6 +551,10 @@ "name": "watermark", "tooltip": "Whether to add an \"AI generated\" watermark to the video." }, + "generate_audio": { + "name": "generate_audio", + "tooltip": "This parameter is ignored for any model except seedance-1-5-pro." + }, "control_after_generate": { "name": "control after generate" } @@ -650,6 +658,10 @@ "name": "watermark", "tooltip": "Whether to add an \"AI generated\" watermark to the video." }, + "generate_audio": { + "name": "generate_audio", + "tooltip": "This parameter is ignored for any model except seedance-1-5-pro." + }, "control_after_generate": { "name": "control after generate" } diff --git a/src/locales/en/settings.json b/src/locales/en/settings.json index 3fdd3dfa7..6fbb8e105 100644 --- a/src/locales/en/settings.json +++ b/src/locales/en/settings.json @@ -328,6 +328,10 @@ "name": "Queue history size", "tooltip": "The maximum number of tasks that show in the queue history." }, + "Comfy_Queue_QPOV2": { + "name": "Use the unified job queue in the Assets side panel", + "tooltip": "Replaces the floating job queue panel with an equivalent job queue embedded in the Assets side panel. You can disable this to return to the floating panel layout." + }, "Comfy_QueueButton_BatchCountLimit": { "name": "Batch count limit", "tooltip": "The maximum number of tasks added to the queue at one button click" diff --git a/src/locales/es/nodeDefs.json b/src/locales/es/nodeDefs.json index 78ee2246d..88b814044 100644 --- a/src/locales/es/nodeDefs.json +++ b/src/locales/es/nodeDefs.json @@ -351,6 +351,10 @@ "name": "primer_fotograma", "tooltip": "Primer fotograma que se utilizará para el video." }, + "generate_audio": { + "name": "generate_audio", + "tooltip": "Este parámetro se ignora para cualquier modelo excepto seedance-1-5-pro." + }, "last_frame": { "name": "último_fotograma", "tooltip": "Último fotograma que se utilizará para el video." @@ -527,6 +531,10 @@ "name": "duración", "tooltip": "La duración del video de salida en segundos." }, + "generate_audio": { + "name": "generate_audio", + "tooltip": "Este parámetro se ignora para cualquier modelo excepto seedance-1-5-pro." + }, "image": { "name": "imagen", "tooltip": "Primer fotograma a usar para el video." @@ -634,6 +642,10 @@ "name": "duración", "tooltip": "La duración del video de salida en segundos." }, + "generate_audio": { + "name": "generate_audio", + "tooltip": "Este parámetro se ignora para cualquier modelo excepto seedance-1-5-pro." + }, "model": { "name": "modelo" }, diff --git a/src/locales/es/settings.json b/src/locales/es/settings.json index 1d02ade05..c4126c0bb 100644 --- a/src/locales/es/settings.json +++ b/src/locales/es/settings.json @@ -332,6 +332,10 @@ "name": "Tamaño del historial de la cola", "tooltip": "El número máximo de tareas que se muestran en el historial de la cola." }, + "Comfy_Queue_QPOV2": { + "name": "Usar la cola de trabajos unificada en el panel lateral de Activos", + "tooltip": "Reemplaza el panel flotante de la cola de trabajos por una cola de trabajos equivalente integrada en el panel lateral de Activos. Puedes desactivar esto para volver al diseño del panel flotante." + }, "Comfy_Sidebar_Location": { "name": "Ubicación de la barra lateral", "options": { diff --git a/src/locales/fa/nodeDefs.json b/src/locales/fa/nodeDefs.json index f9859059c..fb3914db2 100644 --- a/src/locales/fa/nodeDefs.json +++ b/src/locales/fa/nodeDefs.json @@ -351,6 +351,10 @@ "name": "اولین فریم", "tooltip": "اولین فریم مورد استفاده برای ویدیو." }, + "generate_audio": { + "name": "تولید صدا", + "tooltip": "این پارامتر فقط برای مدل seedance-1-5-pro معتبر است و برای سایر مدل‌ها نادیده گرفته می‌شود." + }, "last_frame": { "name": "آخرین فریم", "tooltip": "آخرین فریم مورد استفاده برای ویدیو." @@ -527,6 +531,10 @@ "name": "مدت زمان", "tooltip": "مدت زمان ویدیوی خروجی بر حسب ثانیه." }, + "generate_audio": { + "name": "تولید صدا", + "tooltip": "این پارامتر فقط برای مدل seedance-1-5-pro معتبر است و برای سایر مدل‌ها نادیده گرفته می‌شود." + }, "image": { "name": "تصویر", "tooltip": "اولین فریم مورد استفاده برای ویدیو." @@ -634,6 +642,10 @@ "name": "مدت زمان", "tooltip": "مدت زمان ویدیوی خروجی بر حسب ثانیه." }, + "generate_audio": { + "name": "تولید صدا", + "tooltip": "این پارامتر فقط برای مدل seedance-1-5-pro معتبر است و برای سایر مدل‌ها نادیده گرفته می‌شود." + }, "model": { "name": "مدل" }, diff --git a/src/locales/fa/settings.json b/src/locales/fa/settings.json index 68bbbc70d..58760c376 100644 --- a/src/locales/fa/settings.json +++ b/src/locales/fa/settings.json @@ -332,6 +332,10 @@ "name": "اندازه تاریخچه صف", "tooltip": "حداکثر تعداد taskهایی که در تاریخچه صف نمایش داده می‌شوند." }, + "Comfy_Queue_QPOV2": { + "name": "استفاده از صف کار یکپارچه در پنل کناری دارایی‌ها", + "tooltip": "پنل شناور صف کار را با صف کاری معادل که در پنل کناری دارایی‌ها قرار دارد جایگزین می‌کند. می‌توانید این گزینه را غیرفعال کنید تا به چیدمان پنل شناور بازگردید." + }, "Comfy_Sidebar_Location": { "name": "محل نوار کناری", "options": { diff --git a/src/locales/fr/nodeDefs.json b/src/locales/fr/nodeDefs.json index f7c51cde4..fd58ab7fc 100644 --- a/src/locales/fr/nodeDefs.json +++ b/src/locales/fr/nodeDefs.json @@ -351,6 +351,10 @@ "name": "première_image", "tooltip": "Première image à utiliser pour la vidéo." }, + "generate_audio": { + "name": "générer_audio", + "tooltip": "Ce paramètre est ignoré pour tout modèle sauf seedance-1-5-pro." + }, "last_frame": { "name": "dernière_image", "tooltip": "Dernière image à utiliser pour la vidéo." @@ -527,6 +531,10 @@ "name": "durée", "tooltip": "La durée de la vidéo en sortie en secondes." }, + "generate_audio": { + "name": "générer_audio", + "tooltip": "Ce paramètre est ignoré pour tout modèle sauf seedance-1-5-pro." + }, "image": { "name": "image", "tooltip": "Première image à utiliser pour la vidéo." @@ -634,6 +642,10 @@ "name": "duration", "tooltip": "La durée de la vidéo de sortie en secondes." }, + "generate_audio": { + "name": "générer_audio", + "tooltip": "Ce paramètre est ignoré pour tout modèle sauf seedance-1-5-pro." + }, "model": { "name": "model" }, diff --git a/src/locales/fr/settings.json b/src/locales/fr/settings.json index 3f5b6d01b..b69f8f20a 100644 --- a/src/locales/fr/settings.json +++ b/src/locales/fr/settings.json @@ -332,6 +332,10 @@ "name": "Taille de l'historique de la file d'attente", "tooltip": "Le nombre maximum de tâches qui s'affichent dans l'historique de la file d'attente." }, + "Comfy_Queue_QPOV2": { + "name": "Utiliser la file d’attente unifiée dans le panneau latéral des ressources", + "tooltip": "Remplace le panneau flottant de la file d’attente des tâches par une file d’attente équivalente intégrée dans le panneau latéral des ressources. Vous pouvez désactiver cette option pour revenir à la disposition du panneau flottant." + }, "Comfy_Sidebar_Location": { "name": "Emplacement de la barre latérale", "options": { diff --git a/src/locales/ja/nodeDefs.json b/src/locales/ja/nodeDefs.json index 2471197e8..ab87de52e 100644 --- a/src/locales/ja/nodeDefs.json +++ b/src/locales/ja/nodeDefs.json @@ -351,6 +351,10 @@ "name": "最初のフレーム", "tooltip": "動画に使用する最初のフレーム。" }, + "generate_audio": { + "name": "generate_audio", + "tooltip": "このパラメータは、seedance-1-5-pro 以外のモデルでは無視されます。" + }, "last_frame": { "name": "最後のフレーム", "tooltip": "動画に使用する最後のフレーム。" @@ -527,6 +531,10 @@ "name": "duration", "tooltip": "出力動画の長さ(秒単位)。" }, + "generate_audio": { + "name": "generate_audio", + "tooltip": "このパラメータは、seedance-1-5-pro 以外のモデルでは無視されます。" + }, "image": { "name": "image", "tooltip": "動画の最初のフレームとして使用する画像。" @@ -634,6 +642,10 @@ "name": "長さ", "tooltip": "出力動画の長さ(秒単位)。" }, + "generate_audio": { + "name": "generate_audio", + "tooltip": "このパラメータは、seedance-1-5-pro 以外のモデルでは無視されます。" + }, "model": { "name": "モデル" }, diff --git a/src/locales/ja/settings.json b/src/locales/ja/settings.json index 02559179b..3b60db826 100644 --- a/src/locales/ja/settings.json +++ b/src/locales/ja/settings.json @@ -332,6 +332,10 @@ "name": "キュー履歴サイズ", "tooltip": "キュー履歴に表示されるタスクの最大数。" }, + "Comfy_Queue_QPOV2": { + "name": "アセットサイドパネルで統一ジョブキューを使用", + "tooltip": "フローティングジョブキューパネルを、アセットサイドパネルに埋め込まれた同等のジョブキューに置き換えます。無効にすると、フローティングパネルのレイアウトに戻ります。" + }, "Comfy_Sidebar_Location": { "name": "サイドバーの位置", "options": { diff --git a/src/locales/ko/nodeDefs.json b/src/locales/ko/nodeDefs.json index e531b55dc..78f414856 100644 --- a/src/locales/ko/nodeDefs.json +++ b/src/locales/ko/nodeDefs.json @@ -351,6 +351,10 @@ "name": "first_frame", "tooltip": "비디오에 사용될 첫 번째 프레임입니다." }, + "generate_audio": { + "name": "generate_audio", + "tooltip": "이 매개변수는 seedance-1-5-pro 모델을 제외한 모든 모델에서 무시됩니다." + }, "last_frame": { "name": "last_frame", "tooltip": "비디오에 사용될 마지막 프레임입니다." @@ -527,6 +531,10 @@ "name": "지속 시간", "tooltip": "출력 비디오의 지속 시간(초)입니다." }, + "generate_audio": { + "name": "generate_audio", + "tooltip": "이 매개변수는 seedance-1-5-pro 모델을 제외한 모든 모델에서 무시됩니다." + }, "image": { "name": "이미지", "tooltip": "비디오에 사용할 첫 번째 프레임입니다." @@ -634,6 +642,10 @@ "name": "duration", "tooltip": "출력 비디오의 지속 시간(초)입니다." }, + "generate_audio": { + "name": "generate_audio", + "tooltip": "이 매개변수는 seedance-1-5-pro 모델을 제외한 모든 모델에서 무시됩니다." + }, "model": { "name": "model" }, diff --git a/src/locales/ko/settings.json b/src/locales/ko/settings.json index 415d742b8..176c4afed 100644 --- a/src/locales/ko/settings.json +++ b/src/locales/ko/settings.json @@ -332,6 +332,10 @@ "name": "실행 큐 기록 갯수", "tooltip": "실행 큐 기록에 표시되는 최대 작업 수입니다." }, + "Comfy_Queue_QPOV2": { + "name": "에셋 사이드 패널에서 통합 작업 큐 사용", + "tooltip": "떠다니는 작업 큐 패널을 에셋 사이드 패널에 내장된 동등한 작업 큐로 대체합니다. 이 옵션을 비활성화하면 기존의 떠다니는 패널 레이아웃으로 돌아갈 수 있습니다." + }, "Comfy_Sidebar_Location": { "name": "사이드바 위치", "options": { diff --git a/src/locales/pt-BR/nodeDefs.json b/src/locales/pt-BR/nodeDefs.json index 410528631..22ee42862 100644 --- a/src/locales/pt-BR/nodeDefs.json +++ b/src/locales/pt-BR/nodeDefs.json @@ -351,6 +351,10 @@ "name": "first_frame", "tooltip": "Primeiro quadro a ser usado para o vídeo." }, + "generate_audio": { + "name": "generate_audio", + "tooltip": "Este parâmetro é ignorado para qualquer modelo, exceto seedance-1-5-pro." + }, "last_frame": { "name": "last_frame", "tooltip": "Último quadro a ser usado para o vídeo." @@ -527,6 +531,10 @@ "name": "duração", "tooltip": "A duração do vídeo de saída em segundos." }, + "generate_audio": { + "name": "generate_audio", + "tooltip": "Este parâmetro é ignorado para qualquer modelo, exceto seedance-1-5-pro." + }, "image": { "name": "imagem", "tooltip": "Primeiro quadro a ser usado para o vídeo." @@ -634,6 +642,10 @@ "name": "duration", "tooltip": "A duração do vídeo de saída em segundos." }, + "generate_audio": { + "name": "generate_audio", + "tooltip": "Este parâmetro é ignorado para qualquer modelo, exceto seedance-1-5-pro." + }, "model": { "name": "model" }, diff --git a/src/locales/pt-BR/settings.json b/src/locales/pt-BR/settings.json index ec7108ca2..0de32678b 100644 --- a/src/locales/pt-BR/settings.json +++ b/src/locales/pt-BR/settings.json @@ -332,6 +332,10 @@ "name": "Tamanho do histórico da fila", "tooltip": "O número máximo de tarefas exibidas no histórico da fila." }, + "Comfy_Queue_QPOV2": { + "name": "Usar a fila de tarefas unificada no painel lateral de Assets", + "tooltip": "Substitui o painel flutuante de fila de tarefas por uma fila de tarefas equivalente incorporada ao painel lateral de Assets. Você pode desativar isso para voltar ao layout do painel flutuante." + }, "Comfy_Sidebar_Location": { "name": "Localização da barra lateral", "options": { diff --git a/src/locales/ru/nodeDefs.json b/src/locales/ru/nodeDefs.json index e8c555f7e..54d762c47 100644 --- a/src/locales/ru/nodeDefs.json +++ b/src/locales/ru/nodeDefs.json @@ -351,6 +351,10 @@ "name": "first_frame", "tooltip": "Первый кадр, который будет использоваться для видео." }, + "generate_audio": { + "name": "generate_audio", + "tooltip": "Этот параметр игнорируется для всех моделей, кроме seedance-1-5-pro." + }, "last_frame": { "name": "last_frame", "tooltip": "Последний кадр, который будет использоваться для видео." @@ -527,6 +531,10 @@ "name": "duration", "tooltip": "Продолжительность видео в секундах." }, + "generate_audio": { + "name": "generate_audio", + "tooltip": "Этот параметр игнорируется для всех моделей, кроме seedance-1-5-pro." + }, "image": { "name": "image", "tooltip": "Первый кадр, который будет использоваться для видео." @@ -634,6 +642,10 @@ "name": "длительность", "tooltip": "Длительность выходного видео в секундах." }, + "generate_audio": { + "name": "generate_audio", + "tooltip": "Этот параметр игнорируется для всех моделей, кроме seedance-1-5-pro." + }, "model": { "name": "модель" }, diff --git a/src/locales/ru/settings.json b/src/locales/ru/settings.json index 99f123856..9b7cbed29 100644 --- a/src/locales/ru/settings.json +++ b/src/locales/ru/settings.json @@ -332,6 +332,10 @@ "name": "Размер истории очереди", "tooltip": "Максимальное количество задач, отображаемых в истории очереди." }, + "Comfy_Queue_QPOV2": { + "name": "Использовать объединённую очередь заданий в боковой панели ресурсов", + "tooltip": "Заменяет плавающую панель очереди заданий на аналогичную очередь, встроенную в боковую панель ресурсов. Вы можете отключить эту опцию, чтобы вернуться к плавающей панели." + }, "Comfy_Sidebar_Location": { "name": "Расположение боковой панели", "options": { diff --git a/src/locales/tr/nodeDefs.json b/src/locales/tr/nodeDefs.json index 33863da69..46abd7112 100644 --- a/src/locales/tr/nodeDefs.json +++ b/src/locales/tr/nodeDefs.json @@ -351,6 +351,10 @@ "name": "ilk_kare", "tooltip": "Video için kullanılacak ilk kare." }, + "generate_audio": { + "name": "ses_oluştur", + "tooltip": "Bu parametre, seedance-1-5-pro modeli dışında tüm modeller için yok sayılır." + }, "last_frame": { "name": "son_kare", "tooltip": "Video için kullanılacak son kare." @@ -527,6 +531,10 @@ "name": "süre", "tooltip": "Oluşturulan videonun saniye cinsinden süresi." }, + "generate_audio": { + "name": "ses_oluştur", + "tooltip": "Bu parametre, seedance-1-5-pro modeli dışında tüm modeller için yok sayılır." + }, "image": { "name": "görüntü", "tooltip": "Video için kullanılacak ilk kare." @@ -634,6 +642,10 @@ "name": "süre", "tooltip": "Çıktı videosunun saniye cinsinden süresi." }, + "generate_audio": { + "name": "ses_oluştur", + "tooltip": "Bu parametre, seedance-1-5-pro modeli dışında tüm modeller için yok sayılır." + }, "model": { "name": "model" }, diff --git a/src/locales/tr/settings.json b/src/locales/tr/settings.json index 6cf098949..6d24897f4 100644 --- a/src/locales/tr/settings.json +++ b/src/locales/tr/settings.json @@ -332,6 +332,10 @@ "name": "Kuyruk geçmişi boyutu", "tooltip": "Kuyruk geçmişinde gösterilen maksimum görev sayısı." }, + "Comfy_Queue_QPOV2": { + "name": "Varlıklar yan panelinde birleşik iş kuyruğunu kullan", + "tooltip": "Kayan iş kuyruğu panelini, Varlıklar yan paneline gömülü eşdeğer bir iş kuyruğu ile değiştirir. Kayan panel düzenine dönmek için bunu devre dışı bırakabilirsiniz." + }, "Comfy_Sidebar_Location": { "name": "Kenar çubuğu konumu", "options": { diff --git a/src/locales/zh-TW/nodeDefs.json b/src/locales/zh-TW/nodeDefs.json index e281a7029..d35495b14 100644 --- a/src/locales/zh-TW/nodeDefs.json +++ b/src/locales/zh-TW/nodeDefs.json @@ -351,6 +351,10 @@ "name": "首幀", "tooltip": "用於影片的首幀。" }, + "generate_audio": { + "name": "generate_audio", + "tooltip": "此參數僅適用於 seedance-1-5-pro,其他模型將會忽略。" + }, "last_frame": { "name": "尾幀", "tooltip": "用於影片的尾幀。" @@ -527,6 +531,10 @@ "name": "duration", "tooltip": "輸出影片的持續時間(以秒為單位)。" }, + "generate_audio": { + "name": "generate_audio", + "tooltip": "此參數僅適用於 seedance-1-5-pro,其他模型將會忽略。" + }, "image": { "name": "image", "tooltip": "用於影片的第一幀圖片。" @@ -634,6 +642,10 @@ "name": "持續時間", "tooltip": "輸出影片的持續時間(秒)。" }, + "generate_audio": { + "name": "generate_audio", + "tooltip": "此參數僅適用於 seedance-1-5-pro,其他模型將會忽略。" + }, "model": { "name": "模型" }, diff --git a/src/locales/zh-TW/settings.json b/src/locales/zh-TW/settings.json index 060c8fe0e..8f769dc7b 100644 --- a/src/locales/zh-TW/settings.json +++ b/src/locales/zh-TW/settings.json @@ -332,6 +332,10 @@ "name": "佇列歷史記錄大小", "tooltip": "佇列歷史中顯示的最大任務數量。" }, + "Comfy_Queue_QPOV2": { + "name": "在資產側邊欄中使用統一任務佇列", + "tooltip": "將浮動任務佇列面板替換為嵌入資產側邊欄的等效任務佇列。您可以停用此選項以恢復浮動面板佈局。" + }, "Comfy_Sidebar_Location": { "name": "側邊欄位置", "options": { diff --git a/src/locales/zh/nodeDefs.json b/src/locales/zh/nodeDefs.json index 868bf4b20..342b056b2 100644 --- a/src/locales/zh/nodeDefs.json +++ b/src/locales/zh/nodeDefs.json @@ -351,6 +351,10 @@ "name": "第一帧", "tooltip": "用于视频的第一帧。" }, + "generate_audio": { + "name": "generate_audio", + "tooltip": "此参数仅对 seedance-1-5-pro 模型有效,其他模型将被忽略。" + }, "last_frame": { "name": "最后一帧", "tooltip": "用于视频的最后一帧。" @@ -527,6 +531,10 @@ "name": "时长", "tooltip": "输出视频的时长(以秒为单位)。" }, + "generate_audio": { + "name": "generate_audio", + "tooltip": "此参数仅对 seedance-1-5-pro 模型有效,其他模型将被忽略。" + }, "image": { "name": "图片", "tooltip": "用于视频的第一帧。" @@ -634,6 +642,10 @@ "name": "时长", "tooltip": "输出视频的时长(秒)。" }, + "generate_audio": { + "name": "generate_audio", + "tooltip": "此参数仅对 seedance-1-5-pro 模型有效,其他模型将被忽略。" + }, "model": { "name": "模型" }, diff --git a/src/locales/zh/settings.json b/src/locales/zh/settings.json index cbde4cc8c..b7533899a 100644 --- a/src/locales/zh/settings.json +++ b/src/locales/zh/settings.json @@ -332,6 +332,10 @@ "name": "队列历史大小", "tooltip": "队列历史中显示的最大任务数量。" }, + "Comfy_Queue_QPOV2": { + "name": "在资源侧边栏中使用统一作业队列", + "tooltip": "将浮动作业队列面板替换为嵌入资源侧边栏的等效作业队列。您可以禁用此选项以恢复为浮动面板布局。" + }, "Comfy_Sidebar_Location": { "name": "侧边栏位置", "options": { From 851e8beb292b223ebccb743b9954e6b36eb78694 Mon Sep 17 00:00:00 2001 From: pythongosssss <125205205+pythongosssss@users.noreply.github.com> Date: Fri, 16 Jan 2026 21:50:54 -0800 Subject: [PATCH 09/45] Fix dragging Vue nodes into canvas from library (#8118) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Updates the node preview rendering to use the same app context as the main app so it can access the same plugins ## Changes Assigns manually created vnode app context to the current instances context ## Review Focus This is using somewhat advanced/almost-internal Vue functionality, however I couldn't come up with a better alternative that didn't require recreating an entirely new app and re-registering all dependencies or redoing how draggable node previews are done. The draggable image needs to be rendered synchronously, so rendering a node in the active app and capturing that isn't possible to guarantee to be done synchronously (afaik - suggestions welcome) ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-8118-Fix-dragging-Vue-nodes-into-canvas-from-library-2eb6d73d365081a0a956d8280e009592) by [Unito](https://www.unito.io) --- .../sidebar/tabs/NodeLibrarySidebarTab.vue | 13 ++++++++++++- .../tabs/nodeLibrary/NodeBookmarkTreeExplorer.vue | 13 ++++++++++++- 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/src/components/sidebar/tabs/NodeLibrarySidebarTab.vue b/src/components/sidebar/tabs/NodeLibrarySidebarTab.vue index 2240a60b1..ef9cf5dd0 100644 --- a/src/components/sidebar/tabs/NodeLibrarySidebarTab.vue +++ b/src/components/sidebar/tabs/NodeLibrarySidebarTab.vue @@ -139,7 +139,15 @@ import { storeToRefs } from 'pinia' import Divider from 'primevue/divider' import Popover from 'primevue/popover' import type { Ref } from 'vue' -import { computed, h, nextTick, onMounted, ref, render } from 'vue' +import { + computed, + getCurrentInstance, + h, + nextTick, + onMounted, + ref, + render +} from 'vue' import SearchBox from '@/components/common/SearchBox.vue' import type { SearchFilter } from '@/components/common/SearchFilterChip.vue' @@ -171,6 +179,8 @@ import type { FuseFilterWithValue } from '@/utils/fuseUtil' import NodeBookmarkTreeExplorer from './nodeLibrary/NodeBookmarkTreeExplorer.vue' +const instance = getCurrentInstance()! +const appContext = instance.appContext const nodeDefStore = useNodeDefStore() const nodeBookmarkStore = useNodeBookmarkStore() const nodeHelpStore = useNodeHelpStore() @@ -272,6 +282,7 @@ const renderedRoot = computed>(() => { draggable: node.leaf, renderDragPreview(container) { const vnode = h(NodePreview, { nodeDef: node.data }) + vnode.appContext = appContext render(vnode, container) return () => { render(null, container) diff --git a/src/components/sidebar/tabs/nodeLibrary/NodeBookmarkTreeExplorer.vue b/src/components/sidebar/tabs/nodeLibrary/NodeBookmarkTreeExplorer.vue index 646f841bb..766641386 100644 --- a/src/components/sidebar/tabs/nodeLibrary/NodeBookmarkTreeExplorer.vue +++ b/src/components/sidebar/tabs/nodeLibrary/NodeBookmarkTreeExplorer.vue @@ -22,7 +22,15 @@ diff --git a/src/components/imagecrop/WidgetImageCrop.vue b/src/components/imagecrop/WidgetImageCrop.vue new file mode 100644 index 000000000..4a1c39ef6 --- /dev/null +++ b/src/components/imagecrop/WidgetImageCrop.vue @@ -0,0 +1,100 @@ + + + diff --git a/src/composables/useImageCrop.ts b/src/composables/useImageCrop.ts new file mode 100644 index 000000000..da637ba0a --- /dev/null +++ b/src/composables/useImageCrop.ts @@ -0,0 +1,469 @@ +import { useResizeObserver } from '@vueuse/core' +import type { Ref } from 'vue' +import { computed, onMounted, ref, watch } from 'vue' + +import type { LGraphNode, NodeId } from '@/lib/litegraph/src/LGraphNode' +import type { Bounds } from '@/renderer/core/layout/types' +import { app } from '@/scripts/app' +import { useNodeOutputStore } from '@/stores/imagePreviewStore' + +type ResizeDirection = + | 'top' + | 'bottom' + | 'left' + | 'right' + | 'nw' + | 'ne' + | 'sw' + | 'se' + +const HANDLE_SIZE = 8 +const CORNER_SIZE = 10 +const MIN_CROP_SIZE = 16 +const CROP_BOX_BORDER = 2 + +interface UseImageCropOptions { + imageEl: Ref + containerEl: Ref + modelValue: Ref +} + +export function useImageCrop(nodeId: NodeId, options: UseImageCropOptions) { + const { imageEl, containerEl, modelValue } = options + const nodeOutputStore = useNodeOutputStore() + + const node = ref(null) + + const imageUrl = ref(null) + const isLoading = ref(false) + + const naturalWidth = ref(0) + const naturalHeight = ref(0) + const displayedWidth = ref(0) + const displayedHeight = ref(0) + const scaleFactor = ref(1) + const imageOffsetX = ref(0) + const imageOffsetY = ref(0) + + const cropX = computed({ + get: () => modelValue.value.x, + set: (v: number) => { + modelValue.value.x = v + } + }) + + const cropY = computed({ + get: () => modelValue.value.y, + set: (v: number) => { + modelValue.value.y = v + } + }) + + const cropWidth = computed({ + get: () => modelValue.value.width || 512, + set: (v: number) => { + modelValue.value.width = v + } + }) + + const cropHeight = computed({ + get: () => modelValue.value.height || 512, + set: (v: number) => { + modelValue.value.height = v + } + }) + + const isDragging = ref(false) + const dragStartX = ref(0) + const dragStartY = ref(0) + const dragStartCropX = ref(0) + const dragStartCropY = ref(0) + + const isResizing = ref(false) + const resizeDirection = ref(null) + const resizeStartX = ref(0) + const resizeStartY = ref(0) + const resizeStartCropX = ref(0) + const resizeStartCropY = ref(0) + const resizeStartCropWidth = ref(0) + const resizeStartCropHeight = ref(0) + + useResizeObserver(containerEl, () => { + if (imageEl.value && imageUrl.value) { + updateDisplayedDimensions() + } + }) + + const getInputImageUrl = (): string | null => { + if (!node.value) return null + + const inputNode = node.value.getInputNode(0) + + if (!inputNode) return null + + const urls = nodeOutputStore.getNodeImageUrls(inputNode) + + if (urls?.length) { + return urls[0] + } + + return null + } + + const updateImageUrl = () => { + imageUrl.value = getInputImageUrl() + } + + const updateDisplayedDimensions = () => { + if (!imageEl.value || !containerEl.value) return + + const img = imageEl.value + const container = containerEl.value + + naturalWidth.value = img.naturalWidth + naturalHeight.value = img.naturalHeight + + if (naturalWidth.value <= 0 || naturalHeight.value <= 0) { + scaleFactor.value = 1 + return + } + + const containerWidth = container.clientWidth + const containerHeight = container.clientHeight + + const imageAspect = naturalWidth.value / naturalHeight.value + const containerAspect = containerWidth / containerHeight + + if (imageAspect > containerAspect) { + displayedWidth.value = containerWidth + displayedHeight.value = containerWidth / imageAspect + imageOffsetX.value = 0 + imageOffsetY.value = (containerHeight - displayedHeight.value) / 2 + } else { + displayedHeight.value = containerHeight + displayedWidth.value = containerHeight * imageAspect + imageOffsetX.value = (containerWidth - displayedWidth.value) / 2 + imageOffsetY.value = 0 + } + + if (naturalWidth.value <= 0 || displayedWidth.value <= 0) { + scaleFactor.value = 1 + } else { + scaleFactor.value = displayedWidth.value / naturalWidth.value + } + } + + const getEffectiveScale = (): number => { + const container = containerEl.value + + if (!container || naturalWidth.value <= 0 || displayedWidth.value <= 0) { + return 1 + } + + const rect = container.getBoundingClientRect() + const clientWidth = container.clientWidth + + if (!clientWidth || !rect.width) return 1 + + const renderedDisplayedWidth = + (displayedWidth.value / clientWidth) * rect.width + + return renderedDisplayedWidth / naturalWidth.value + } + + const cropBoxStyle = computed(() => ({ + left: `${imageOffsetX.value + cropX.value * scaleFactor.value - CROP_BOX_BORDER}px`, + top: `${imageOffsetY.value + cropY.value * scaleFactor.value - CROP_BOX_BORDER}px`, + width: `${cropWidth.value * scaleFactor.value}px`, + height: `${cropHeight.value * scaleFactor.value}px` + })) + + const cropImageStyle = computed(() => { + if (!imageUrl.value) return {} + + return { + backgroundImage: `url(${imageUrl.value})`, + backgroundSize: `${displayedWidth.value}px ${displayedHeight.value}px`, + backgroundPosition: `-${cropX.value * scaleFactor.value}px -${cropY.value * scaleFactor.value}px`, + backgroundRepeat: 'no-repeat' + } + }) + + interface ResizeHandle { + direction: ResizeDirection + class: string + style: { + left: string + top: string + width?: string + height?: string + } + } + + const resizeHandles = computed(() => { + const x = imageOffsetX.value + cropX.value * scaleFactor.value + const y = imageOffsetY.value + cropY.value * scaleFactor.value + const w = cropWidth.value * scaleFactor.value + const h = cropHeight.value * scaleFactor.value + + return [ + { + direction: 'top', + class: 'h-2 cursor-ns-resize', + style: { + left: `${x + HANDLE_SIZE}px`, + top: `${y - HANDLE_SIZE / 2}px`, + width: `${Math.max(0, w - HANDLE_SIZE * 2)}px` + } + }, + { + direction: 'bottom', + class: 'h-2 cursor-ns-resize', + style: { + left: `${x + HANDLE_SIZE}px`, + top: `${y + h - HANDLE_SIZE / 2}px`, + width: `${Math.max(0, w - HANDLE_SIZE * 2)}px` + } + }, + { + direction: 'left', + class: 'w-2 cursor-ew-resize', + style: { + left: `${x - HANDLE_SIZE / 2}px`, + top: `${y + HANDLE_SIZE}px`, + height: `${Math.max(0, h - HANDLE_SIZE * 2)}px` + } + }, + { + direction: 'right', + class: 'w-2 cursor-ew-resize', + style: { + left: `${x + w - HANDLE_SIZE / 2}px`, + top: `${y + HANDLE_SIZE}px`, + height: `${Math.max(0, h - HANDLE_SIZE * 2)}px` + } + }, + { + direction: 'nw', + class: 'cursor-nwse-resize rounded-sm bg-white/80', + style: { + left: `${x - CORNER_SIZE / 2}px`, + top: `${y - CORNER_SIZE / 2}px`, + width: `${CORNER_SIZE}px`, + height: `${CORNER_SIZE}px` + } + }, + { + direction: 'ne', + class: 'cursor-nesw-resize rounded-sm bg-white/80', + style: { + left: `${x + w - CORNER_SIZE / 2}px`, + top: `${y - CORNER_SIZE / 2}px`, + width: `${CORNER_SIZE}px`, + height: `${CORNER_SIZE}px` + } + }, + { + direction: 'sw', + class: 'cursor-nesw-resize rounded-sm bg-white/80', + style: { + left: `${x - CORNER_SIZE / 2}px`, + top: `${y + h - CORNER_SIZE / 2}px`, + width: `${CORNER_SIZE}px`, + height: `${CORNER_SIZE}px` + } + }, + { + direction: 'se', + class: 'cursor-nwse-resize rounded-sm bg-white/80', + style: { + left: `${x + w - CORNER_SIZE / 2}px`, + top: `${y + h - CORNER_SIZE / 2}px`, + width: `${CORNER_SIZE}px`, + height: `${CORNER_SIZE}px` + } + } + ] + }) + + const handleImageLoad = () => { + isLoading.value = false + updateDisplayedDimensions() + } + + const handleImageError = () => { + isLoading.value = false + imageUrl.value = null + } + + const capturePointer = (e: PointerEvent) => + (e.target as HTMLElement).setPointerCapture(e.pointerId) + + const releasePointer = (e: PointerEvent) => + (e.target as HTMLElement).releasePointerCapture(e.pointerId) + + const handleDragStart = (e: PointerEvent) => { + if (!imageUrl.value) return + + isDragging.value = true + dragStartX.value = e.clientX + dragStartY.value = e.clientY + dragStartCropX.value = cropX.value + dragStartCropY.value = cropY.value + capturePointer(e) + } + + const handleDragMove = (e: PointerEvent) => { + if (!isDragging.value) return + + const effectiveScale = getEffectiveScale() + if (effectiveScale === 0) return + + const deltaX = (e.clientX - dragStartX.value) / effectiveScale + const deltaY = (e.clientY - dragStartY.value) / effectiveScale + + const maxX = naturalWidth.value - cropWidth.value + const maxY = naturalHeight.value - cropHeight.value + + cropX.value = Math.round( + Math.max(0, Math.min(maxX, dragStartCropX.value + deltaX)) + ) + cropY.value = Math.round( + Math.max(0, Math.min(maxY, dragStartCropY.value + deltaY)) + ) + } + + const handleDragEnd = (e: PointerEvent) => { + if (!isDragging.value) return + + isDragging.value = false + releasePointer(e) + } + + const handleResizeStart = (e: PointerEvent, direction: ResizeDirection) => { + if (!imageUrl.value) return + + e.stopPropagation() + isResizing.value = true + resizeDirection.value = direction + + resizeStartX.value = e.clientX + resizeStartY.value = e.clientY + resizeStartCropX.value = cropX.value + resizeStartCropY.value = cropY.value + resizeStartCropWidth.value = cropWidth.value + resizeStartCropHeight.value = cropHeight.value + capturePointer(e) + } + + const handleResizeMove = (e: PointerEvent) => { + if (!isResizing.value || !resizeDirection.value) return + + const effectiveScale = getEffectiveScale() + if (effectiveScale === 0) return + + const dir = resizeDirection.value + const deltaX = (e.clientX - resizeStartX.value) / effectiveScale + const deltaY = (e.clientY - resizeStartY.value) / effectiveScale + + const affectsLeft = dir === 'left' || dir === 'nw' || dir === 'sw' + const affectsRight = dir === 'right' || dir === 'ne' || dir === 'se' + const affectsTop = dir === 'top' || dir === 'nw' || dir === 'ne' + const affectsBottom = dir === 'bottom' || dir === 'sw' || dir === 'se' + + let newX = resizeStartCropX.value + let newY = resizeStartCropY.value + let newWidth = resizeStartCropWidth.value + let newHeight = resizeStartCropHeight.value + + if (affectsLeft) { + const maxDeltaX = resizeStartCropWidth.value - MIN_CROP_SIZE + const minDeltaX = -resizeStartCropX.value + const clampedDeltaX = Math.max(minDeltaX, Math.min(maxDeltaX, deltaX)) + newX = resizeStartCropX.value + clampedDeltaX + newWidth = resizeStartCropWidth.value - clampedDeltaX + } else if (affectsRight) { + const maxWidth = naturalWidth.value - resizeStartCropX.value + newWidth = Math.max( + MIN_CROP_SIZE, + Math.min(maxWidth, resizeStartCropWidth.value + deltaX) + ) + } + + if (affectsTop) { + const maxDeltaY = resizeStartCropHeight.value - MIN_CROP_SIZE + const minDeltaY = -resizeStartCropY.value + const clampedDeltaY = Math.max(minDeltaY, Math.min(maxDeltaY, deltaY)) + newY = resizeStartCropY.value + clampedDeltaY + newHeight = resizeStartCropHeight.value - clampedDeltaY + } else if (affectsBottom) { + const maxHeight = naturalHeight.value - resizeStartCropY.value + newHeight = Math.max( + MIN_CROP_SIZE, + Math.min(maxHeight, resizeStartCropHeight.value + deltaY) + ) + } + + if (affectsLeft || affectsRight) { + cropX.value = Math.round(newX) + cropWidth.value = Math.round(newWidth) + } + if (affectsTop || affectsBottom) { + cropY.value = Math.round(newY) + cropHeight.value = Math.round(newHeight) + } + } + + const handleResizeEnd = (e: PointerEvent) => { + if (!isResizing.value) return + + isResizing.value = false + resizeDirection.value = null + releasePointer(e) + } + + const initialize = () => { + if (nodeId != null) { + node.value = app.rootGraph?.getNodeById(nodeId) || null + } + + updateImageUrl() + } + + watch( + () => nodeOutputStore.nodeOutputs, + () => updateImageUrl(), + { deep: true } + ) + + watch( + () => nodeOutputStore.nodePreviewImages, + () => updateImageUrl(), + { deep: true } + ) + + onMounted(initialize) + + return { + imageUrl, + isLoading, + + cropX, + cropY, + cropWidth, + cropHeight, + + cropBoxStyle, + cropImageStyle, + resizeHandles, + + handleImageLoad, + handleImageError, + handleDragStart, + handleDragMove, + handleDragEnd, + handleResizeStart, + handleResizeMove, + handleResizeEnd + } +} diff --git a/src/extensions/core/imageCrop.ts b/src/extensions/core/imageCrop.ts new file mode 100644 index 000000000..38cf6692a --- /dev/null +++ b/src/extensions/core/imageCrop.ts @@ -0,0 +1,12 @@ +import { useExtensionService } from '@/services/extensionService' + +useExtensionService().registerExtension({ + name: 'Comfy.ImageCrop', + + async nodeCreated(node) { + if (node.constructor.comfyClass !== 'ImageCrop') return + + const [oldWidth, oldHeight] = node.size + node.setSize([Math.max(oldWidth, 300), Math.max(oldHeight, 450)]) + } +}) diff --git a/src/extensions/core/index.ts b/src/extensions/core/index.ts index ba36f847a..f50493d82 100644 --- a/src/extensions/core/index.ts +++ b/src/extensions/core/index.ts @@ -10,6 +10,7 @@ import './groupNode' import './groupNodeManage' import './groupOptions' import './imageCompare' +import './imageCrop' import './load3d' import './maskeditor' import './nodeTemplates' diff --git a/src/lib/litegraph/src/types/widgets.ts b/src/lib/litegraph/src/types/widgets.ts index 1003981b4..d3c249ba3 100644 --- a/src/lib/litegraph/src/types/widgets.ts +++ b/src/lib/litegraph/src/types/widgets.ts @@ -1,3 +1,5 @@ +import type { Bounds } from '@/renderer/core/layout/types' + import type { CanvasColour, Point, RequiredProps, Size } from '../interfaces' import type { CanvasPointer, LGraphCanvas, LGraphNode } from '../litegraph' import type { CanvasPointerEvent } from './events' @@ -88,6 +90,8 @@ export type IWidget = | ISelectButtonWidget | ITextareaWidget | IAssetWidget + | IImageCropWidget + | IBoundingBoxWidget export interface IBooleanWidget extends IBaseWidget { type: 'toggle' @@ -259,6 +263,18 @@ export interface IAssetWidget extends IBaseWidget< value: string } +/** Image crop widget for cropping image */ +export interface IImageCropWidget extends IBaseWidget { + type: 'imagecrop' + value: Bounds +} + +/** Bounding box widget for defining regions with numeric inputs */ +export interface IBoundingBoxWidget extends IBaseWidget { + type: 'boundingbox' + value: Bounds +} + /** * Valid widget types. TS cannot provide easily extensible type safety for this at present. * Override linkedWidgets[] diff --git a/src/lib/litegraph/src/widgets/BaseWidget.ts b/src/lib/litegraph/src/widgets/BaseWidget.ts index a580fc69a..ee32e71d8 100644 --- a/src/lib/litegraph/src/widgets/BaseWidget.ts +++ b/src/lib/litegraph/src/widgets/BaseWidget.ts @@ -1,3 +1,4 @@ +import { t } from '@/i18n' import { drawTextInArea } from '@/lib/litegraph/src/draw' import { Rectangle } from '@/lib/litegraph/src/infrastructure/Rectangle' import type { Point } from '@/lib/litegraph/src/interfaces' @@ -227,6 +228,41 @@ export abstract class BaseWidget< if (showText && !this.computedDisabled) ctx.stroke() } + /** + * Draws a placeholder for widgets that only have a Vue implementation. + * @param ctx The canvas context + * @param options The options for drawing the widget + * @param label The label to display (e.g., "ImageCrop", "BoundingBox") + */ + protected drawVueOnlyWarning( + ctx: CanvasRenderingContext2D, + { width }: DrawWidgetOptions, + label: string + ): void { + const { y, height } = this + + ctx.save() + + ctx.fillStyle = this.background_color + ctx.fillRect(15, y, width - 30, height) + + ctx.strokeStyle = this.outline_color + ctx.strokeRect(15, y, width - 30, height) + + ctx.fillStyle = this.text_color + ctx.font = '11px monospace' + ctx.textAlign = 'center' + ctx.textBaseline = 'middle' + + ctx.fillText( + `${label}: ${t('widgets.node2only')}`, + width / 2, + y + height / 2 + ) + + ctx.restore() + } + /** * A shared routine for drawing a label and value as text, truncated * if they exceed the available width. diff --git a/src/lib/litegraph/src/widgets/BoundingBoxWidget.ts b/src/lib/litegraph/src/widgets/BoundingBoxWidget.ts new file mode 100644 index 000000000..d571f744f --- /dev/null +++ b/src/lib/litegraph/src/widgets/BoundingBoxWidget.ts @@ -0,0 +1,22 @@ +import type { IBoundingBoxWidget } from '../types/widgets' +import { BaseWidget } from './BaseWidget' +import type { DrawWidgetOptions, WidgetEventOptions } from './BaseWidget' + +/** + * Widget for defining bounding box regions. + * This widget only has a Vue implementation. + */ +export class BoundingBoxWidget + extends BaseWidget + implements IBoundingBoxWidget +{ + override type = 'boundingbox' as const + + drawWidget(ctx: CanvasRenderingContext2D, options: DrawWidgetOptions): void { + this.drawVueOnlyWarning(ctx, options, 'BoundingBox') + } + + onClick(_options: WidgetEventOptions): void { + // This widget only has a Vue implementation + } +} diff --git a/src/lib/litegraph/src/widgets/ImageCropWidget.ts b/src/lib/litegraph/src/widgets/ImageCropWidget.ts new file mode 100644 index 000000000..a81cd580c --- /dev/null +++ b/src/lib/litegraph/src/widgets/ImageCropWidget.ts @@ -0,0 +1,22 @@ +import type { IImageCropWidget } from '../types/widgets' +import { BaseWidget } from './BaseWidget' +import type { DrawWidgetOptions, WidgetEventOptions } from './BaseWidget' + +/** + * Widget for displaying an image crop preview. + * This widget only has a Vue implementation. + */ +export class ImageCropWidget + extends BaseWidget + implements IImageCropWidget +{ + override type = 'imagecrop' as const + + drawWidget(ctx: CanvasRenderingContext2D, options: DrawWidgetOptions): void { + this.drawVueOnlyWarning(ctx, options, 'ImageCrop') + } + + onClick(_options: WidgetEventOptions): void { + // This widget only has a Vue implementation + } +} diff --git a/src/lib/litegraph/src/widgets/TextareaWidget.ts b/src/lib/litegraph/src/widgets/TextareaWidget.ts index f29ccc5e5..c6b83e13f 100644 --- a/src/lib/litegraph/src/widgets/TextareaWidget.ts +++ b/src/lib/litegraph/src/widgets/TextareaWidget.ts @@ -1,12 +1,10 @@ -import { t } from '@/i18n' - import type { ITextareaWidget } from '../types/widgets' import { BaseWidget } from './BaseWidget' import type { DrawWidgetOptions, WidgetEventOptions } from './BaseWidget' /** - * Widget for multi-line text input - * This is a widget that only has a Vue widgets implementation + * Widget for multi-line text input. + * This widget only has a Vue implementation. */ export class TextareaWidget extends BaseWidget @@ -15,35 +13,10 @@ export class TextareaWidget override type = 'textarea' as const drawWidget(ctx: CanvasRenderingContext2D, options: DrawWidgetOptions): void { - const { width } = options - const { y, height } = this - - const { fillStyle, strokeStyle, textAlign, textBaseline, font } = ctx - - ctx.fillStyle = this.background_color - ctx.fillRect(15, y, width - 30, height) - - ctx.strokeStyle = this.outline_color - ctx.strokeRect(15, y, width - 30, height) - - ctx.fillStyle = this.text_color - ctx.font = '11px monospace' - ctx.textAlign = 'center' - ctx.textBaseline = 'middle' - - const text = `Textarea: ${t('widgets.node2only')}` - ctx.fillText(text, width / 2, y + height / 2) - - Object.assign(ctx, { - fillStyle, - strokeStyle, - textAlign, - textBaseline, - font - }) + this.drawVueOnlyWarning(ctx, options, 'Textarea') } onClick(_options: WidgetEventOptions): void { - // This is a widget that only has a Vue widgets implementation + // This widget only has a Vue implementation } } diff --git a/src/lib/litegraph/src/widgets/TreeSelectWidget.ts b/src/lib/litegraph/src/widgets/TreeSelectWidget.ts index ee78d5919..78586cb6f 100644 --- a/src/lib/litegraph/src/widgets/TreeSelectWidget.ts +++ b/src/lib/litegraph/src/widgets/TreeSelectWidget.ts @@ -1,12 +1,10 @@ -import { t } from '@/i18n' - import type { ITreeSelectWidget } from '../types/widgets' import { BaseWidget } from './BaseWidget' import type { DrawWidgetOptions, WidgetEventOptions } from './BaseWidget' /** - * Widget for hierarchical tree selection - * This is a widget that only has a Vue widgets implementation + * Widget for hierarchical tree selection. + * This widget only has a Vue implementation. */ export class TreeSelectWidget extends BaseWidget @@ -15,35 +13,10 @@ export class TreeSelectWidget override type = 'treeselect' as const drawWidget(ctx: CanvasRenderingContext2D, options: DrawWidgetOptions): void { - const { width } = options - const { y, height } = this - - const { fillStyle, strokeStyle, textAlign, textBaseline, font } = ctx - - ctx.fillStyle = this.background_color - ctx.fillRect(15, y, width - 30, height) - - ctx.strokeStyle = this.outline_color - ctx.strokeRect(15, y, width - 30, height) - - ctx.fillStyle = this.text_color - ctx.font = '11px monospace' - ctx.textAlign = 'center' - ctx.textBaseline = 'middle' - - const text = `TreeSelect: ${t('widgets.node2only')}` - ctx.fillText(text, width / 2, y + height / 2) - - Object.assign(ctx, { - fillStyle, - strokeStyle, - textAlign, - textBaseline, - font - }) + this.drawVueOnlyWarning(ctx, options, 'TreeSelect') } onClick(_options: WidgetEventOptions): void { - // This is a widget that only has a Vue widgets implementation + // This widget only has a Vue implementation } } diff --git a/src/lib/litegraph/src/widgets/widgetMap.ts b/src/lib/litegraph/src/widgets/widgetMap.ts index 0e6a34fe5..37b906efb 100644 --- a/src/lib/litegraph/src/widgets/widgetMap.ts +++ b/src/lib/litegraph/src/widgets/widgetMap.ts @@ -11,6 +11,7 @@ import { toClass } from '@/lib/litegraph/src/utils/type' import { AssetWidget } from './AssetWidget' import { BaseWidget } from './BaseWidget' import { BooleanWidget } from './BooleanWidget' +import { BoundingBoxWidget } from './BoundingBoxWidget' import { ButtonWidget } from './ButtonWidget' import { ChartWidget } from './ChartWidget' import { ColorWidget } from './ColorWidget' @@ -18,6 +19,7 @@ import { ComboWidget } from './ComboWidget' import { FileUploadWidget } from './FileUploadWidget' import { GalleriaWidget } from './GalleriaWidget' import { ImageCompareWidget } from './ImageCompareWidget' +import { ImageCropWidget } from './ImageCropWidget' import { KnobWidget } from './KnobWidget' import { LegacyWidget } from './LegacyWidget' import { MarkdownWidget } from './MarkdownWidget' @@ -50,6 +52,8 @@ export type WidgetTypeMap = { selectbutton: SelectButtonWidget textarea: TextareaWidget asset: AssetWidget + imagecrop: ImageCropWidget + boundingbox: BoundingBoxWidget [key: string]: BaseWidget } @@ -120,6 +124,10 @@ export function toConcreteWidget( return toClass(TextareaWidget, narrowedWidget, node) case 'asset': return toClass(AssetWidget, narrowedWidget, node) + case 'imagecrop': + return toClass(ImageCropWidget, narrowedWidget, node) + case 'boundingbox': + return toClass(BoundingBoxWidget, narrowedWidget, node) default: { if (wrapLegacyWidgets) return toClass(LegacyWidget, widget, node) } diff --git a/src/locales/en/main.json b/src/locales/en/main.json index e8d3b19f5..fa557ee96 100644 --- a/src/locales/en/main.json +++ b/src/locales/en/main.json @@ -1718,6 +1718,17 @@ "unsupportedFileType": "Unsupported file type (supports .gltf, .glb, .obj, .fbx, .stl)", "uploadingModel": "Uploading 3D model..." }, + "imageCrop": { + "loading": "Loading...", + "noInputImage": "No input image connected", + "cropPreviewAlt": "Crop preview" + }, + "boundingBox": { + "x": "X", + "y": "Y", + "width": "Width", + "height": "Height" + }, "toastMessages": { "nothingToQueue": "Nothing to queue", "pleaseSelectOutputNodes": "Please select output nodes", diff --git a/src/renderer/extensions/vueNodes/widgets/composables/useBoundingBoxWidget.ts b/src/renderer/extensions/vueNodes/widgets/composables/useBoundingBoxWidget.ts new file mode 100644 index 000000000..ad1c25d80 --- /dev/null +++ b/src/renderer/extensions/vueNodes/widgets/composables/useBoundingBoxWidget.ts @@ -0,0 +1,103 @@ +import type { LGraphNode } from '@/lib/litegraph/src/litegraph' +import type { + IBaseWidget, + IBoundingBoxWidget, + IImageCropWidget, + INumericWidget +} from '@/lib/litegraph/src/types/widgets' +import type { Bounds } from '@/renderer/core/layout/types' +import type { + BoundingBoxInputSpec, + InputSpec as InputSpecV2 +} from '@/schemas/nodeDef/nodeDefSchemaV2' +import type { ComfyWidgetConstructorV2 } from '@/scripts/widgets' + +function isBoundingBoxLikeWidget( + widget: IBaseWidget +): widget is IBoundingBoxWidget | IImageCropWidget { + return widget.type === 'boundingbox' || widget.type === 'imagecrop' +} + +function isNumericWidget(widget: IBaseWidget): widget is INumericWidget { + return widget.type === 'number' +} + +export const useBoundingBoxWidget = (): ComfyWidgetConstructorV2 => { + return ( + node: LGraphNode, + inputSpec: InputSpecV2 + ): IBoundingBoxWidget | IImageCropWidget => { + const spec = inputSpec as BoundingBoxInputSpec + const { name, component } = spec + const defaultValue: Bounds = spec.default ?? { + x: 0, + y: 0, + width: 512, + height: 512 + } + + const widgetType = component === 'ImageCrop' ? 'imagecrop' : 'boundingbox' + + const fields: (keyof Bounds)[] = ['x', 'y', 'width', 'height'] + const subWidgets: INumericWidget[] = [] + + const rawWidget = node.addWidget( + widgetType, + name, + { ...defaultValue }, + null, + { + serialize: true, + canvasOnly: false + } + ) + + if (!isBoundingBoxLikeWidget(rawWidget)) { + throw new Error(`Unexpected widget type: ${rawWidget.type}`) + } + + const widget = rawWidget + + widget.callback = () => { + for (let i = 0; i < fields.length; i++) { + const field = fields[i] + const subWidget = subWidgets[i] + if (subWidget) { + subWidget.value = widget.value[field] + } + } + } + + for (const field of fields) { + const subWidget = node.addWidget( + 'number', + field, + defaultValue[field], + function (this: INumericWidget, v: number) { + this.value = Math.round(v) + widget.value[field] = this.value + widget.callback?.(widget.value) + }, + { + min: field === 'width' || field === 'height' ? 1 : 0, + max: 8192, + step: 10, + step2: 1, + precision: 0, + serialize: false, + canvasOnly: true + } + ) + + if (!isNumericWidget(subWidget)) { + throw new Error(`Unexpected widget type: ${subWidget.type}`) + } + + subWidgets.push(subWidget) + } + + widget.linkedWidgets = subWidgets + + return widget + } +} diff --git a/src/renderer/extensions/vueNodes/widgets/registry/widgetRegistry.ts b/src/renderer/extensions/vueNodes/widgets/registry/widgetRegistry.ts index 14ea96daa..c82a4d395 100644 --- a/src/renderer/extensions/vueNodes/widgets/registry/widgetRegistry.ts +++ b/src/renderer/extensions/vueNodes/widgets/registry/widgetRegistry.ts @@ -54,6 +54,12 @@ const WidgetAudioUI = defineAsyncComponent( const Load3D = defineAsyncComponent( () => import('@/components/load3d/Load3D.vue') ) +const WidgetImageCrop = defineAsyncComponent( + () => import('@/components/imagecrop/WidgetImageCrop.vue') +) +const WidgetBoundingBox = defineAsyncComponent( + () => import('@/components/boundingbox/WidgetBoundingBox.vue') +) export const FOR_TESTING = { WidgetAudioUI, @@ -157,7 +163,23 @@ const coreWidgetDefinitions: Array<[string, WidgetDefinition]> = [ essential: false } ], - ['load3D', { component: Load3D, aliases: ['LOAD_3D'], essential: false }] + ['load3D', { component: Load3D, aliases: ['LOAD_3D'], essential: false }], + [ + 'imagecrop', + { + component: WidgetImageCrop, + aliases: ['IMAGECROP'], + essential: false + } + ], + [ + 'boundingbox', + { + component: WidgetBoundingBox, + aliases: ['BOUNDINGBOX'], + essential: false + } + ] ] const getComboWidgetAdditions = (): Map => { diff --git a/src/schemas/nodeDef/nodeDefSchemaV2.ts b/src/schemas/nodeDef/nodeDefSchemaV2.ts index 9e319c163..4743164a1 100644 --- a/src/schemas/nodeDef/nodeDefSchemaV2.ts +++ b/src/schemas/nodeDef/nodeDefSchemaV2.ts @@ -64,6 +64,21 @@ const zImageCompareInputSpec = zBaseInputOptions.extend({ options: z.record(z.unknown()).optional() }) +const zBoundingBoxInputSpec = zBaseInputOptions.extend({ + type: z.literal('BOUNDINGBOX'), + name: z.string(), + isOptional: z.boolean().optional(), + component: z.enum(['ImageCrop']).optional(), + default: z + .object({ + x: z.number(), + y: z.number(), + width: z.number(), + height: z.number() + }) + .optional() +}) + const zMarkdownInputSpec = zBaseInputOptions.extend({ type: z.literal('MARKDOWN'), name: z.string(), @@ -126,6 +141,7 @@ const zInputSpec = z.union([ zColorInputSpec, zImageInputSpec, zImageCompareInputSpec, + zBoundingBoxInputSpec, zMarkdownInputSpec, zChartInputSpec, zGalleriaInputSpec, @@ -169,6 +185,7 @@ type StringInputSpec = z.infer export type ComboInputSpec = z.infer export type ColorInputSpec = z.infer export type ImageCompareInputSpec = z.infer +export type BoundingBoxInputSpec = z.infer export type ChartInputSpec = z.infer export type GalleriaInputSpec = z.infer export type TextareaInputSpec = z.infer diff --git a/src/scripts/widgets.ts b/src/scripts/widgets.ts index 505c88a6c..408555a19 100644 --- a/src/scripts/widgets.ts +++ b/src/scripts/widgets.ts @@ -8,6 +8,7 @@ import type { import { useSettingStore } from '@/platform/settings/settingStore' import { dynamicWidgets } from '@/core/graph/widgets/dynamicWidgets' import { useBooleanWidget } from '@/renderer/extensions/vueNodes/widgets/composables/useBooleanWidget' +import { useBoundingBoxWidget } from '@/renderer/extensions/vueNodes/widgets/composables/useBoundingBoxWidget' import { useChartWidget } from '@/renderer/extensions/vueNodes/widgets/composables/useChartWidget' import { useColorWidget } from '@/renderer/extensions/vueNodes/widgets/composables/useColorWidget' import { useComboWidget } from '@/renderer/extensions/vueNodes/widgets/composables/useComboWidget' @@ -301,6 +302,7 @@ export const ComfyWidgets = { IMAGEUPLOAD: useImageUploadWidget(), COLOR: transformWidgetConstructorV2ToV1(useColorWidget()), IMAGECOMPARE: transformWidgetConstructorV2ToV1(useImageCompareWidget()), + BOUNDINGBOX: transformWidgetConstructorV2ToV1(useBoundingBoxWidget()), CHART: transformWidgetConstructorV2ToV1(useChartWidget()), GALLERIA: transformWidgetConstructorV2ToV1(useGalleriaWidget()), TEXTAREA: transformWidgetConstructorV2ToV1(useTextareaWidget()), From c9d74777ba0c61d8c12e9ef81362ccfb9d00f73a Mon Sep 17 00:00:00 2001 From: AustinMroz Date: Sat, 17 Jan 2026 19:13:05 -0800 Subject: [PATCH 15/45] Migrate parentIds when converting to subgraph (#5708) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The parentId property on links and reroutes was not handled at all in the "Convert to Subgraph" code. This needs to be addressed in 4 cases - A new external input link must have parentId set to the first non-migrated reroute - A new external output link must have the parentId of it's eldest remaining child set to undefined - A new internal input link must have the parentId of it's eldest remaining child set to undefined - A new internal output link must have the parentId set to the first migrated reroute This is handled in two parts by adding logic where the boundry links is created - The change involves mutation of inputs (which isn't great) but the function here was already mutating inputs into an invalid state - @DrJKL Do you see a quick way to better fix both these cases? Looks like litegraph tests aren't enabled and cursory glance shows multiple need to be updated to reflect recent changes. I'll still try to add some tests anyways. EDIT: Tests are non functional. Seems the subgraph conversion call requires the rest of the frontend is running and has event listeners to register the subgraph node def. More work than anticipated, best revisited later Resolves #5669 ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-5708-Migrate-parentIds-when-converting-to-subgraph-2746d73d365081f78acff4454092c74a) by [Unito](https://www.unito.io) --------- Co-authored-by: Alexander Brown --- src/lib/litegraph/src/LGraph.ts | 46 +++++++++++++++---- .../litegraph/src/subgraph/subgraphUtils.ts | 30 ++++++++++-- 2 files changed, 65 insertions(+), 11 deletions(-) diff --git a/src/lib/litegraph/src/LGraph.ts b/src/lib/litegraph/src/LGraph.ts index 04f2a4a4f..486831e13 100644 --- a/src/lib/litegraph/src/LGraph.ts +++ b/src/lib/litegraph/src/LGraph.ts @@ -1571,8 +1571,21 @@ export class LGraph // Inputs, outputs, and links const links = internalLinks.map((x) => x.asSerialisable()) - const inputs = mapSubgraphInputsAndLinks(resolvedInputLinks, links) - const outputs = mapSubgraphOutputsAndLinks(resolvedOutputLinks, links) + + const internalReroutes = new Map([...reroutes].map((r) => [r.id, r])) + const externalReroutes = new Map( + [...this.reroutes].filter(([id]) => !internalReroutes.has(id)) + ) + const inputs = mapSubgraphInputsAndLinks( + resolvedInputLinks, + links, + internalReroutes + ) + const outputs = mapSubgraphOutputsAndLinks( + resolvedOutputLinks, + links, + externalReroutes + ) // Prepare subgraph data const data = { @@ -1714,10 +1727,10 @@ export class LGraph // Reconnect output links in parent graph i = 0 for (const [, connections] of outputsGroupedByOutput.entries()) { - // Special handling: Subgraph output node i++ for (const connection of connections) { const { input, inputNode, link, subgraphOutput } = connection + // Special handling: Subgraph output node if (link.target_id === SUBGRAPH_OUTPUT_ID) { link.origin_id = subgraphNode.id link.origin_slot = i - 1 @@ -2013,33 +2026,50 @@ export class LGraph while (parentId) { instance.parentId = parentId instance = this.reroutes.get(parentId) - if (!instance) throw new Error('Broken Id link when unpacking') + if (!instance) { + console.error('Broken Id link when unpacking') + break + } if (instance.linkIds.has(linkInstance.id)) throw new Error('Infinite parentId loop') instance.linkIds.add(linkInstance.id) parentId = instance.parentId } } + if (!instance) continue parentId = newLink.iparent while (parentId) { const migratedId = rerouteIdMap.get(parentId) - if (!migratedId) throw new Error('Broken Id link when unpacking') + if (!migratedId) { + console.error('Broken Id link when unpacking') + break + } instance.parentId = migratedId instance = this.reroutes.get(migratedId) - if (!instance) throw new Error('Broken Id link when unpacking') + if (!instance) { + console.error('Broken Id link when unpacking') + break + } if (instance.linkIds.has(linkInstance.id)) throw new Error('Infinite parentId loop') instance.linkIds.add(linkInstance.id) const oldReroute = subgraphNode.subgraph.reroutes.get(parentId) - if (!oldReroute) throw new Error('Broken Id link when unpacking') + if (!oldReroute) { + console.error('Broken Id link when unpacking') + break + } parentId = oldReroute.parentId } + if (!instance) break if (!newLink.externalFirst) { parentId = newLink.eparent while (parentId) { instance.parentId = parentId instance = this.reroutes.get(parentId) - if (!instance) throw new Error('Broken Id link when unpacking') + if (!instance) { + console.error('Broken Id link when unpacking') + break + } if (instance.linkIds.has(linkInstance.id)) throw new Error('Infinite parentId loop') instance.linkIds.add(linkInstance.id) diff --git a/src/lib/litegraph/src/subgraph/subgraphUtils.ts b/src/lib/litegraph/src/subgraph/subgraphUtils.ts index 38f74efaa..518869503 100644 --- a/src/lib/litegraph/src/subgraph/subgraphUtils.ts +++ b/src/lib/litegraph/src/subgraph/subgraphUtils.ts @@ -4,6 +4,7 @@ import { LGraphNode } from '@/lib/litegraph/src/LGraphNode' import { LLink } from '@/lib/litegraph/src/LLink' import type { ResolvedConnection } from '@/lib/litegraph/src/LLink' import { Reroute } from '@/lib/litegraph/src/Reroute' +import type { RerouteId } from '@/lib/litegraph/src/Reroute' import { SUBGRAPH_INPUT_ID, SUBGRAPH_OUTPUT_ID @@ -259,10 +260,29 @@ export function groupResolvedByOutput( return groupedByOutput } +function mapReroutes( + link: SerialisableLLink, + reroutes: Map +) { + let child: SerialisableLLink | Reroute = link + let nextReroute = + child.parentId === undefined ? undefined : reroutes.get(child.parentId) + + while (child.parentId !== undefined && nextReroute) { + child = nextReroute + nextReroute = + child.parentId === undefined ? undefined : reroutes.get(child.parentId) + } + + const lastId = child.parentId + child.parentId = undefined + return lastId +} export function mapSubgraphInputsAndLinks( resolvedInputLinks: ResolvedConnection[], - links: SerialisableLLink[] + links: SerialisableLLink[], + reroutes: Map ): SubgraphIO[] { // Group matching links const groupedByOutput = groupResolvedByOutput(resolvedInputLinks) @@ -279,8 +299,10 @@ export function mapSubgraphInputsAndLinks( if (!input) continue const linkData = link.asSerialisable() + link.parentId = mapReroutes(link, reroutes) linkData.origin_id = SUBGRAPH_INPUT_ID linkData.origin_slot = inputs.length + links.push(linkData) inputLinks.push(linkData) } @@ -340,7 +362,8 @@ export function mapSubgraphInputsAndLinks( */ export function mapSubgraphOutputsAndLinks( resolvedOutputLinks: ResolvedConnection[], - links: SerialisableLLink[] + links: SerialisableLLink[], + reroutes: Map ): SubgraphIO[] { // Group matching links const groupedByOutput = groupResolvedByOutput(resolvedOutputLinks) @@ -355,10 +378,11 @@ export function mapSubgraphOutputsAndLinks( const { link, output } = resolved if (!output) continue - // Link const linkData = link.asSerialisable() + linkData.parentId = mapReroutes(link, reroutes) linkData.target_id = SUBGRAPH_OUTPUT_ID linkData.target_slot = outputs.length + links.push(linkData) outputLinks.push(linkData) } From 82c3cd3cd29feafe097709209aab0ecce82b70b0 Mon Sep 17 00:00:00 2001 From: Terry Jia Date: Sat, 17 Jan 2026 22:32:32 -0500 Subject: [PATCH 16/45] add thumbnail for 3d generation (#8129) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary add thrumbnail for 3d genations, feature requested by @PabloWiedemann ## Screenshots https://github.com/user-attachments/assets/4fb9b88b-dd7b-4a69-a70c-e850472d3498 ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-8129-add-thumbnail-for-3d-generation-2eb6d73d365081f2a30bc698a4fde6e0) by [Unito](https://www.unito.io) --- src/composables/useLoad3d.ts | 16 +++++ src/extensions/core/load3d/Load3d.ts | 54 +++++++++++++++ src/extensions/core/load3d/Load3dUtils.ts | 65 +++++++++++++++++++ src/extensions/core/saveMesh.ts | 12 ++++ src/platform/assets/components/Media3DTop.vue | 29 ++++++++- 5 files changed, 173 insertions(+), 3 deletions(-) diff --git a/src/composables/useLoad3d.ts b/src/composables/useLoad3d.ts index c8312051a..b07daba2e 100644 --- a/src/composables/useLoad3d.ts +++ b/src/composables/useLoad3d.ts @@ -511,6 +511,22 @@ export const useLoad3d = (nodeOrRef: MaybeRef) => { hasSkeleton.value = load3d?.hasSkeleton() ?? false // Reset skeleton visibility when loading new model modelConfig.value.showSkeleton = false + + if (load3d) { + const node = nodeRef.value + + const modelWidget = node?.widgets?.find( + (w) => w.name === 'model_file' || w.name === 'image' + ) + const value = modelWidget?.value + if (typeof value === 'string') { + void Load3dUtils.generateThumbnailIfNeeded( + load3d, + value, + isPreview.value ? 'output' : 'input' + ) + } + } }, skeletonVisibilityChange: (value: boolean) => { modelConfig.value.showSkeleton = value diff --git a/src/extensions/core/load3d/Load3d.ts b/src/extensions/core/load3d/Load3d.ts index 657a1796b..60690e4f8 100644 --- a/src/extensions/core/load3d/Load3d.ts +++ b/src/extensions/core/load3d/Load3d.ts @@ -754,6 +754,60 @@ class Load3d { this.forceRender() } + public async captureThumbnail( + width: number = 256, + height: number = 256 + ): Promise { + if (!this.modelManager.currentModel) { + throw new Error('No model loaded for thumbnail capture') + } + + const savedState = this.cameraManager.getCameraState() + const savedCameraType = this.cameraManager.getCurrentCameraType() + const savedGridVisible = this.sceneManager.gridHelper.visible + + try { + this.sceneManager.gridHelper.visible = false + + if (savedCameraType !== 'perspective') { + this.cameraManager.toggleCamera('perspective') + } + + const box = new THREE.Box3().setFromObject(this.modelManager.currentModel) + const size = box.getSize(new THREE.Vector3()) + const center = box.getCenter(new THREE.Vector3()) + + const maxDim = Math.max(size.x, size.y, size.z) + const distance = maxDim * 1.5 + + const cameraPosition = new THREE.Vector3( + center.x - distance * 0.8, + center.y + distance * 0.4, + center.z + distance * 0.3 + ) + + this.cameraManager.perspectiveCamera.position.copy(cameraPosition) + this.cameraManager.perspectiveCamera.lookAt(center) + this.cameraManager.perspectiveCamera.updateProjectionMatrix() + + if (this.controlsManager.controls) { + this.controlsManager.controls.target.copy(center) + this.controlsManager.controls.update() + } + + const result = await this.sceneManager.captureScene(width, height) + return result.scene + } finally { + this.sceneManager.gridHelper.visible = savedGridVisible + + if (savedCameraType !== 'perspective') { + this.cameraManager.toggleCamera(savedCameraType) + } + this.cameraManager.setCameraState(savedState) + this.controlsManager.controls?.update() + } + } + public remove(): void { if (this.contextMenuAbortController) { this.contextMenuAbortController.abort() diff --git a/src/extensions/core/load3d/Load3dUtils.ts b/src/extensions/core/load3d/Load3dUtils.ts index 13095ac96..ba7c36e55 100644 --- a/src/extensions/core/load3d/Load3dUtils.ts +++ b/src/extensions/core/load3d/Load3dUtils.ts @@ -1,9 +1,34 @@ +import type Load3d from '@/extensions/core/load3d/Load3d' import { t } from '@/i18n' import { useToastStore } from '@/platform/updates/common/toastStore' import { api } from '@/scripts/api' import { app } from '@/scripts/app' class Load3dUtils { + static async generateThumbnailIfNeeded( + load3d: Load3d, + modelPath: string, + folderType: 'input' | 'output' + ): Promise { + const [subfolder, filename] = this.splitFilePath(modelPath) + const thumbnailFilename = this.getThumbnailFilename(filename) + + const exists = await this.fileExists( + subfolder, + thumbnailFilename, + folderType + ) + if (exists) return + + const imageData = await load3d.captureThumbnail(256, 256) + await this.uploadThumbnail( + imageData, + subfolder, + thumbnailFilename, + folderType + ) + } + static async uploadTempImage( imageData: string, prefix: string, @@ -122,6 +147,46 @@ class Load3dUtils { await Promise.all(uploadPromises) } + + static getThumbnailFilename(modelFilename: string): string { + return `${modelFilename}.png` + } + + static async fileExists( + subfolder: string, + filename: string, + type: string = 'input' + ): Promise { + try { + const url = api.apiURL(this.getResourceURL(subfolder, filename, type)) + const response = await fetch(url, { method: 'HEAD' }) + return response.ok + } catch { + return false + } + } + + static async uploadThumbnail( + imageData: string, + subfolder: string, + filename: string, + type: string = 'input' + ): Promise { + const blob = await fetch(imageData).then((r) => r.blob()) + const file = new File([blob], filename, { type: 'image/png' }) + + const body = new FormData() + body.append('image', file) + body.append('subfolder', subfolder) + body.append('type', type) + + const resp = await api.fetchApi('/upload/image', { + method: 'POST', + body + }) + + return resp.status === 200 + } } export default Load3dUtils diff --git a/src/extensions/core/saveMesh.ts b/src/extensions/core/saveMesh.ts index ae94a8609..947120467 100644 --- a/src/extensions/core/saveMesh.ts +++ b/src/extensions/core/saveMesh.ts @@ -4,6 +4,7 @@ import Load3D from '@/components/load3d/Load3D.vue' import { useLoad3d } from '@/composables/useLoad3d' import { createExportMenuItems } from '@/extensions/core/load3d/exportMenuHelper' import Load3DConfiguration from '@/extensions/core/load3d/Load3DConfiguration' +import Load3dUtils from '@/extensions/core/load3d/Load3dUtils' import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode' import type { IContextMenuValue } from '@/lib/litegraph/src/interfaces' import type { NodeOutputWith, ResultItem } from '@/schemas/apiSchema' @@ -94,6 +95,17 @@ useExtensionService().registerExtension({ const config = new Load3DConfiguration(load3d, node.properties) const loadFolder = fileInfo.type as 'input' | 'output' + + const onModelLoaded = () => { + load3d.removeEventListener('modelLoadingEnd', onModelLoaded) + void Load3dUtils.generateThumbnailIfNeeded( + load3d, + filePath, + loadFolder + ) + } + load3d.addEventListener('modelLoadingEnd', onModelLoaded) + config.configureForSaveMesh(loadFolder, filePath) } }) diff --git a/src/platform/assets/components/Media3DTop.vue b/src/platform/assets/components/Media3DTop.vue index a4cc141db..b5b7f0c60 100644 --- a/src/platform/assets/components/Media3DTop.vue +++ b/src/platform/assets/components/Media3DTop.vue @@ -1,12 +1,35 @@ + + From 54db655a230b3371bf5320617d725611c9615e53 Mon Sep 17 00:00:00 2001 From: Christian Byrne Date: Sat, 17 Jan 2026 19:43:24 -0800 Subject: [PATCH 17/45] feat: make subgraphs blueprints appear higher in node library sidebar (#8140) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Changes insertion order so subgraph blueprints are inserted first and therefore appear highest in node library sidebar (when using default 'original' ordering). image ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-8140-feat-make-subgraphs-blueprints-appear-higher-in-node-library-sidebar-2ec6d73d3650816f8164f0991b81c116) by [Unito](https://www.unito.io) --- src/stores/nodeDefStore.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/stores/nodeDefStore.ts b/src/stores/nodeDefStore.ts index 849865941..27a46ad21 100644 --- a/src/stores/nodeDefStore.ts +++ b/src/stores/nodeDefStore.ts @@ -299,9 +299,10 @@ export const useNodeDefStore = defineStore('nodeDef', () => { const nodeDefs = computed(() => { const subgraphStore = useSubgraphStore() + // Blueprints first for discoverability in the node library sidebar return [ - ...Object.values(nodeDefsByName.value), - ...subgraphStore.subgraphBlueprints + ...subgraphStore.subgraphBlueprints, + ...Object.values(nodeDefsByName.value) ] }) const nodeDataTypes = computed(() => { From 7fcef2ba8944a67914e0313705794076f27db70b Mon Sep 17 00:00:00 2001 From: Comfy Org PR Bot Date: Sun, 18 Jan 2026 12:55:35 +0900 Subject: [PATCH 18/45] 1.38.5 (#8138) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Patch version increment to 1.38.5 **Base branch:** `main` ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-8138-1-38-5-2ec6d73d365081b6bf57fd29cb56998c) by [Unito](https://www.unito.io) --------- Co-authored-by: christian-byrne <72887196+christian-byrne@users.noreply.github.com> Co-authored-by: github-actions Co-authored-by: Alexander Brown --- package.json | 2 +- src/locales/ar/main.json | 11 +++++++++++ src/locales/es/main.json | 11 +++++++++++ src/locales/fa/main.json | 11 +++++++++++ src/locales/fr/main.json | 11 +++++++++++ src/locales/ja/main.json | 11 +++++++++++ src/locales/ko/main.json | 11 +++++++++++ src/locales/pt-BR/main.json | 11 +++++++++++ src/locales/ru/main.json | 11 +++++++++++ src/locales/tr/main.json | 11 +++++++++++ src/locales/zh-TW/main.json | 11 +++++++++++ src/locales/zh/main.json | 11 +++++++++++ 12 files changed, 122 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index 60b37f1da..78ac9e373 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@comfyorg/comfyui-frontend", "private": true, - "version": "1.38.4", + "version": "1.38.5", "type": "module", "repository": "https://github.com/Comfy-Org/ComfyUI_frontend", "homepage": "https://comfy.org", diff --git a/src/locales/ar/main.json b/src/locales/ar/main.json index e3d369349..122883cd9 100644 --- a/src/locales/ar/main.json +++ b/src/locales/ar/main.json @@ -238,6 +238,12 @@ "title": "إنشاء حساب" } }, + "boundingBox": { + "height": "الارتفاع", + "width": "العرض", + "x": "س", + "y": "ص" + }, "breadcrumbsMenu": { "clearWorkflow": "مسح سير العمل", "deleteBlueprint": "حذف المخطط", @@ -982,6 +988,11 @@ "imageCompare": { "noImages": "لا توجد صور للمقارنة" }, + "imageCrop": { + "cropPreviewAlt": "معاينة الاقتصاص", + "loading": "جارٍ التحميل...", + "noInputImage": "لا توجد صورة إدخال متصلة" + }, "importFailed": { "copyError": "خطأ في النسخ", "title": "فشل الاستيراد" diff --git a/src/locales/es/main.json b/src/locales/es/main.json index cd59b5bf8..cbbb1770b 100644 --- a/src/locales/es/main.json +++ b/src/locales/es/main.json @@ -238,6 +238,12 @@ "title": "Crea una cuenta" } }, + "boundingBox": { + "height": "Alto", + "width": "Ancho", + "x": "X", + "y": "Y" + }, "breadcrumbsMenu": { "clearWorkflow": "Limpiar flujo de trabajo", "deleteBlueprint": "Eliminar Plano", @@ -982,6 +988,11 @@ "imageCompare": { "noImages": "No hay imágenes para comparar" }, + "imageCrop": { + "cropPreviewAlt": "Vista previa del recorte", + "loading": "Cargando...", + "noInputImage": "No hay imagen de entrada conectada" + }, "importFailed": { "copyError": "Error al copiar", "title": "Error de importación" diff --git a/src/locales/fa/main.json b/src/locales/fa/main.json index d4c673259..9d48620d5 100644 --- a/src/locales/fa/main.json +++ b/src/locales/fa/main.json @@ -238,6 +238,12 @@ "title": "ایجاد حساب کاربری" } }, + "boundingBox": { + "height": "ارتفاع", + "width": "عرض", + "x": "ایکس", + "y": "وای" + }, "breadcrumbsMenu": { "clearWorkflow": "پاک‌سازی workflow", "deleteBlueprint": "حذف blueprint", @@ -982,6 +988,11 @@ "imageCompare": { "noImages": "تصویری برای مقایسه وجود ندارد" }, + "imageCrop": { + "cropPreviewAlt": "پیش‌نمایش برش", + "loading": "در حال بارگذاری...", + "noInputImage": "هیچ تصویر ورودی متصل نیست" + }, "importFailed": { "copyError": "خطا در کپی", "title": "وارد کردن ناموفق بود" diff --git a/src/locales/fr/main.json b/src/locales/fr/main.json index 014f2ea0e..04db65b9e 100644 --- a/src/locales/fr/main.json +++ b/src/locales/fr/main.json @@ -238,6 +238,12 @@ "title": "Créer un compte" } }, + "boundingBox": { + "height": "Hauteur", + "width": "Largeur", + "x": "X", + "y": "Y" + }, "breadcrumbsMenu": { "clearWorkflow": "Effacer le workflow", "deleteBlueprint": "Supprimer le plan", @@ -982,6 +988,11 @@ "imageCompare": { "noImages": "Aucune image à comparer" }, + "imageCrop": { + "cropPreviewAlt": "Aperçu du recadrage", + "loading": "Chargement...", + "noInputImage": "Aucune image d'entrée connectée" + }, "importFailed": { "copyError": "Erreur de copie", "title": "Échec de l’importation" diff --git a/src/locales/ja/main.json b/src/locales/ja/main.json index 7791475f5..ef3a14b51 100644 --- a/src/locales/ja/main.json +++ b/src/locales/ja/main.json @@ -238,6 +238,12 @@ "title": "アカウントを作成する" } }, + "boundingBox": { + "height": "高さ", + "width": "幅", + "x": "X", + "y": "Y" + }, "breadcrumbsMenu": { "clearWorkflow": "ワークフローをクリア", "deleteBlueprint": "ブループリントを削除", @@ -982,6 +988,11 @@ "imageCompare": { "noImages": "比較する画像がありません" }, + "imageCrop": { + "cropPreviewAlt": "切り抜きプレビュー", + "loading": "読み込み中...", + "noInputImage": "入力画像が接続されていません" + }, "importFailed": { "copyError": "コピーエラー", "title": "インポート失敗" diff --git a/src/locales/ko/main.json b/src/locales/ko/main.json index 60b2f9711..939f3ae53 100644 --- a/src/locales/ko/main.json +++ b/src/locales/ko/main.json @@ -238,6 +238,12 @@ "title": "계정 생성" } }, + "boundingBox": { + "height": "높이", + "width": "너비", + "x": "X", + "y": "Y" + }, "breadcrumbsMenu": { "clearWorkflow": "워크플로 내용 지우기", "deleteBlueprint": "블루프린트 삭제", @@ -982,6 +988,11 @@ "imageCompare": { "noImages": "비교할 이미지가 없습니다" }, + "imageCrop": { + "cropPreviewAlt": "자르기 미리보기", + "loading": "로딩 중...", + "noInputImage": "입력 이미지가 연결되지 않았습니다" + }, "importFailed": { "copyError": "복사 오류", "title": "가져오기 실패" diff --git a/src/locales/pt-BR/main.json b/src/locales/pt-BR/main.json index 160f525f5..37ef79592 100644 --- a/src/locales/pt-BR/main.json +++ b/src/locales/pt-BR/main.json @@ -238,6 +238,12 @@ "title": "Criar uma conta" } }, + "boundingBox": { + "height": "Altura", + "width": "Largura", + "x": "X", + "y": "Y" + }, "breadcrumbsMenu": { "clearWorkflow": "Limpar Fluxo de Trabalho", "deleteBlueprint": "Excluir Blueprint", @@ -982,6 +988,11 @@ "imageCompare": { "noImages": "Nenhuma imagem para comparar" }, + "imageCrop": { + "cropPreviewAlt": "Pré-visualização do recorte", + "loading": "Carregando...", + "noInputImage": "Nenhuma imagem de entrada conectada" + }, "importFailed": { "copyError": "Erro ao Copiar", "title": "Falha na Importação" diff --git a/src/locales/ru/main.json b/src/locales/ru/main.json index 60155d334..cf8a16623 100644 --- a/src/locales/ru/main.json +++ b/src/locales/ru/main.json @@ -238,6 +238,12 @@ "title": "Создать аккаунт" } }, + "boundingBox": { + "height": "Высота", + "width": "Ширина", + "x": "X", + "y": "Y" + }, "breadcrumbsMenu": { "clearWorkflow": "Очистить рабочий процесс", "deleteBlueprint": "Удалить схему", @@ -982,6 +988,11 @@ "imageCompare": { "noImages": "Нет изображений для сравнения" }, + "imageCrop": { + "cropPreviewAlt": "Предпросмотр обрезки", + "loading": "Загрузка...", + "noInputImage": "Входное изображение не подключено" + }, "importFailed": { "copyError": "Ошибка копирования", "title": "Ошибка импорта" diff --git a/src/locales/tr/main.json b/src/locales/tr/main.json index 870cee220..d4aa99bd0 100644 --- a/src/locales/tr/main.json +++ b/src/locales/tr/main.json @@ -238,6 +238,12 @@ "title": "Hesap oluşturun" } }, + "boundingBox": { + "height": "Yükseklik", + "width": "Genişlik", + "x": "X", + "y": "Y" + }, "breadcrumbsMenu": { "clearWorkflow": "İş Akışını Temizle", "deleteBlueprint": "Taslağı Sil", @@ -982,6 +988,11 @@ "imageCompare": { "noImages": "Karşılaştırılacak görsel yok" }, + "imageCrop": { + "cropPreviewAlt": "Kırpma önizlemesi", + "loading": "Yükleniyor...", + "noInputImage": "Bağlı giriş görseli yok" + }, "importFailed": { "copyError": "Kopyalama Hatası", "title": "İçe Aktarma Başarısız" diff --git a/src/locales/zh-TW/main.json b/src/locales/zh-TW/main.json index 0a148681a..84bcf62b6 100644 --- a/src/locales/zh-TW/main.json +++ b/src/locales/zh-TW/main.json @@ -238,6 +238,12 @@ "title": "建立帳戶" } }, + "boundingBox": { + "height": "高度", + "width": "寬度", + "x": "X", + "y": "Y" + }, "breadcrumbsMenu": { "clearWorkflow": "清除工作流程", "deleteBlueprint": "刪除藍圖", @@ -982,6 +988,11 @@ "imageCompare": { "noImages": "沒有可比較的圖像" }, + "imageCrop": { + "cropPreviewAlt": "裁切預覽", + "loading": "載入中...", + "noInputImage": "未連接輸入影像" + }, "importFailed": { "copyError": "複製錯誤", "title": "匯入失敗" diff --git a/src/locales/zh/main.json b/src/locales/zh/main.json index 1108b1702..3551fbb92 100644 --- a/src/locales/zh/main.json +++ b/src/locales/zh/main.json @@ -238,6 +238,12 @@ "title": "创建一个账户" } }, + "boundingBox": { + "height": "高度", + "width": "宽度", + "x": "X", + "y": "Y" + }, "breadcrumbsMenu": { "clearWorkflow": "清除工作流", "deleteBlueprint": "删除蓝图", @@ -982,6 +988,11 @@ "imageCompare": { "noImages": "没有可以对比的图像" }, + "imageCrop": { + "cropPreviewAlt": "裁剪预览", + "loading": "加载中...", + "noInputImage": "未连接输入图像" + }, "importFailed": { "copyError": "复制错误", "title": "导入失败" From 284bdce61b85f4e64e36e9975ef68ebcb8f81a25 Mon Sep 17 00:00:00 2001 From: AustinMroz Date: Sat, 17 Jan 2026 20:28:48 -0800 Subject: [PATCH 19/45] Add a slider indicator for number widgets in vue mode. (#8122) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sometimes it's difficult to gauge the valid range of values for a widget. Litegraph includes a "slider" widget which displays the distance from the min and max values as a colored bar. However, this implementation is rather strongly disliked because it prevents entering an exact number. Vue mode makes it simple to add just the indicator onto our existing widget. In addition to requiring both min and max be set, not every widget would want this functionality. It's not useful information for seed, but also has potential to cause confusion on widgets like CFG, that allow inputting numbers up to 100 even though values beyond ~15 are rarely desirable. As a proposed heuristic, the ratio of "step" to distance between min and max is currently used, but this could fairly easily be changed to an opt-in only system. image ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-8122-Add-a-slider-indicator-for-number-widgets-in-vue-mode-2eb6d73d365081218fc8e86f37001958) by [Unito](https://www.unito.io) --- .../components/WidgetInputNumberInput.vue | 23 +++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/src/renderer/extensions/vueNodes/widgets/components/WidgetInputNumberInput.vue b/src/renderer/extensions/vueNodes/widgets/components/WidgetInputNumberInput.vue index 1add11f4a..d14dd2167 100644 --- a/src/renderer/extensions/vueNodes/widgets/components/WidgetInputNumberInput.vue +++ b/src/renderer/extensions/vueNodes/widgets/components/WidgetInputNumberInput.vue @@ -41,7 +41,8 @@ const modelValue = defineModel({ default: 0 }) const formattedValue = computed(() => { const unformattedValue = dragValue.value ?? modelValue.value - if (!isFinite(unformattedValue)) return `${unformattedValue}` + if ((unformattedValue as unknown) === '' || !isFinite(unformattedValue)) + return `${unformattedValue}` return n(unformattedValue, { useGrouping: useGrouping.value, @@ -175,6 +176,20 @@ const buttonTooltip = computed(() => { } return null }) + +const sliderWidth = computed(() => { + const { max, min, step } = filteredProps.value + if ( + min === undefined || + max === undefined || + step === undefined || + (max - min) / step >= 100 + ) + return 0 + const value = dragValue.value ?? modelValue.value + const ratio = (value - min) / (max - min) + return (ratio * 100).toFixed(0) +}) diff --git a/src/renderer/extensions/vueNodes/widgets/components/WidgetWithControl.vue b/src/renderer/extensions/vueNodes/widgets/components/WidgetWithControl.vue index f571d6dad..e4e1220bf 100644 --- a/src/renderer/extensions/vueNodes/widgets/components/WidgetWithControl.vue +++ b/src/renderer/extensions/vueNodes/widgets/components/WidgetWithControl.vue @@ -2,6 +2,7 @@ import { computed, defineAsyncComponent, ref, watch } from 'vue' import type { Component } from 'vue' +import Popover from '@/components/ui/Popover.vue' import Button from '@/components/ui/button/Button.vue' import type { SimplifiedControlWidget, @@ -19,8 +20,6 @@ const props = defineProps<{ const modelValue = defineModel() -const popover = ref() - const controlModel = ref(props.widget.controlWidget.value) const controlButtonIcon = computed(() => { @@ -37,24 +36,24 @@ const controlButtonIcon = computed(() => { }) watch(controlModel, props.widget.controlWidget.update) - -const togglePopover = (event: Event) => { - popover.value.toggle(event) -} - From a8b4928accb21b198f8c5bac82eceac9a5f4a39b Mon Sep 17 00:00:00 2001 From: Christian Byrne Date: Mon, 19 Jan 2026 20:57:08 -0800 Subject: [PATCH 31/45] feat(canvas): show 'Show Advanced' button on nodes with advanced widgets (#8148) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extends the existing 'Show Advanced' button (previously subgraph-only) to also appear on regular nodes that have widgets marked with `options.advanced = true`. ## Changes - Updates `showAdvancedInputsButton` computed to check for advanced widgets on regular nodes - Updates `handleShowAdvancedInputs` to set `node.showAdvanced = true` and trigger canvas redraw for regular nodes ## Related - Backend PR that adds `advanced` flag: comfyanonymous/ComfyUI#11939 - Canvas hide PR: feat/advanced-widgets-canvas-hide (this PR provides the toggle for that) ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-8148-feat-canvas-show-Show-Advanced-button-on-nodes-with-advanced-widgets-2ec6d73d36508155a8adfa0a8ec84d46) by [Unito](https://www.unito.io) --- .../vueNodes/components/LGraphNode.vue | 26 ++++++++++++++----- 1 file changed, 19 insertions(+), 7 deletions(-) diff --git a/src/renderer/extensions/vueNodes/components/LGraphNode.vue b/src/renderer/extensions/vueNodes/components/LGraphNode.vue index b990c957a..1d96cda83 100644 --- a/src/renderer/extensions/vueNodes/components/LGraphNode.vue +++ b/src/renderer/extensions/vueNodes/components/LGraphNode.vue @@ -482,18 +482,30 @@ const lgraphNode = computed(() => { const showAdvancedInputsButton = computed(() => { const node = lgraphNode.value - if (!node || !(node instanceof SubgraphNode)) return false + if (!node) return false - // Check if there are hidden inputs (widgets not promoted) - const interiorNodes = node.subgraph.nodes - const allInteriorWidgets = interiorNodes.flatMap((n) => n.widgets ?? []) + // For subgraph nodes: check for unpromoted widgets + if (node instanceof SubgraphNode) { + const interiorNodes = node.subgraph.nodes + const allInteriorWidgets = interiorNodes.flatMap((n) => n.widgets ?? []) + return allInteriorWidgets.some((w) => !w.computedDisabled && !w.promoted) + } - return allInteriorWidgets.some((w) => !w.computedDisabled && !w.promoted) + // For regular nodes: show button if there are advanced widgets and they're currently hidden + const hasAdvancedWidgets = nodeData.widgets?.some((w) => w.options?.advanced) + return hasAdvancedWidgets && !node.showAdvanced }) function handleShowAdvancedInputs() { - const rightSidePanelStore = useRightSidePanelStore() - rightSidePanelStore.focusSection('advanced-inputs') + const node = lgraphNode.value + if (!node) return + + if (node instanceof SubgraphNode) { + const rightSidePanelStore = useRightSidePanelStore() + rightSidePanelStore.focusSection('advanced-inputs') + } else { + node.showAdvanced = true + } } const nodeMedia = computed(() => { From b5f91977c842e447d4c962431f131788289bf1b7 Mon Sep 17 00:00:00 2001 From: Jin Yi Date: Tue, 20 Jan 2026 14:48:44 +0900 Subject: [PATCH 32/45] [bugfix] Add spacing between action buttons in node library sidebar (#8172) --- src/components/common/TreeExplorerTreeNode.vue | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/common/TreeExplorerTreeNode.vue b/src/components/common/TreeExplorerTreeNode.vue index cea8ba451..450e8d9d9 100644 --- a/src/components/common/TreeExplorerTreeNode.vue +++ b/src/components/common/TreeExplorerTreeNode.vue @@ -28,7 +28,7 @@ />
From 916c1248e3948d76babb60b5ea0b4dc57d03e989 Mon Sep 17 00:00:00 2001 From: Jin Yi Date: Wed, 21 Jan 2026 06:10:31 +0900 Subject: [PATCH 33/45] [bugfix] Fix search bar height alignment in MediaAssetFilterBar (#8171) --- .../assets/components/MediaAssetFilterBar.vue | 48 +++++++------------ 1 file changed, 17 insertions(+), 31 deletions(-) diff --git a/src/platform/assets/components/MediaAssetFilterBar.vue b/src/platform/assets/components/MediaAssetFilterBar.vue index 6a0fcdb93..0442f5f56 100644 --- a/src/platform/assets/components/MediaAssetFilterBar.vue +++ b/src/platform/assets/components/MediaAssetFilterBar.vue @@ -1,40 +1,26 @@ From 79d3b2c291c4bfe1170fb100ca0fc4b3b1bfe8f3 Mon Sep 17 00:00:00 2001 From: AustinMroz Date: Tue, 20 Jan 2026 13:31:56 -0800 Subject: [PATCH 34/45] Fix properties context menu (#8188) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A tiny fix for a regression introduced in #7817 that prevented changing a node's properties through the litegraph context menu. image ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-8188-Fix-properties-context-menu-2ee6d73d365081ba8844dd3c8d74432d) by [Unito](https://www.unito.io) --- src/lib/litegraph/src/LGraphCanvas.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/lib/litegraph/src/LGraphCanvas.ts b/src/lib/litegraph/src/LGraphCanvas.ts index 30d7a3fd6..40893174f 100644 --- a/src/lib/litegraph/src/LGraphCanvas.ts +++ b/src/lib/litegraph/src/LGraphCanvas.ts @@ -1350,12 +1350,12 @@ export class LGraphCanvas implements CustomEventDispatcher }) function inner_clicked( - this: ContextMenu, + this: ContextMenuDivElement, v?: string | IContextMenuValue ) { if (!node || typeof v === 'string' || !v?.value) return - const rect = this.root.getBoundingClientRect() + const rect = this.getBoundingClientRect() canvas.showEditPropertyValue(node, v.value, { position: [rect.left, rect.top] }) From 5df793b721c02ef93e7278b4d7bbbcf26ccc11fc Mon Sep 17 00:00:00 2001 From: Christian Byrne Date: Tue, 20 Jan 2026 13:35:54 -0800 Subject: [PATCH 35/45] feat: add feature usage tracker for nightly surveys (#8175) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduces `useFeatureUsageTracker` composable that tracks how many times a user has used a specific feature, along with first and last usage timestamps. Data persists to localStorage using `@vueuse/core`'s `useStorage`. This composable provides the foundation for triggering surveys after a configurable number of feature uses. Includes comprehensive unit tests. ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-8175-feat-add-feature-usage-tracker-for-nightly-surveys-2ee6d73d36508118859ece6fcf17561d) by [Unito](https://www.unito.io) --- .../surveys/useFeatureUsageTracker.test.ts | 131 ++++++++++++++++++ .../surveys/useFeatureUsageTracker.ts | 46 ++++++ 2 files changed, 177 insertions(+) create mode 100644 src/platform/surveys/useFeatureUsageTracker.test.ts create mode 100644 src/platform/surveys/useFeatureUsageTracker.ts diff --git a/src/platform/surveys/useFeatureUsageTracker.test.ts b/src/platform/surveys/useFeatureUsageTracker.test.ts new file mode 100644 index 000000000..5313e9eaf --- /dev/null +++ b/src/platform/surveys/useFeatureUsageTracker.test.ts @@ -0,0 +1,131 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const STORAGE_KEY = 'Comfy.FeatureUsage' + +describe('useFeatureUsageTracker', () => { + beforeEach(() => { + localStorage.clear() + vi.resetModules() + }) + + afterEach(() => { + localStorage.clear() + }) + + it('initializes with zero count for new feature', async () => { + const { useFeatureUsageTracker } = await import('./useFeatureUsageTracker') + const { useCount } = useFeatureUsageTracker('test-feature') + + expect(useCount.value).toBe(0) + }) + + it('increments count on trackUsage', async () => { + const { useFeatureUsageTracker } = await import('./useFeatureUsageTracker') + const { useCount, trackUsage } = useFeatureUsageTracker('test-feature') + + expect(useCount.value).toBe(0) + + trackUsage() + expect(useCount.value).toBe(1) + + trackUsage() + expect(useCount.value).toBe(2) + }) + + it('sets firstUsed only on first use', async () => { + vi.useFakeTimers() + const firstTs = 1000000 + vi.setSystemTime(firstTs) + try { + const { useFeatureUsageTracker } = + await import('./useFeatureUsageTracker') + const { usage, trackUsage } = useFeatureUsageTracker('test-feature') + + trackUsage() + expect(usage.value?.firstUsed).toBe(firstTs) + + vi.setSystemTime(firstTs + 5000) + trackUsage() + expect(usage.value?.firstUsed).toBe(firstTs) + } finally { + vi.useRealTimers() + } + }) + + it('updates lastUsed on each use', async () => { + vi.useFakeTimers() + try { + const { useFeatureUsageTracker } = + await import('./useFeatureUsageTracker') + const { usage, trackUsage } = useFeatureUsageTracker('test-feature') + + trackUsage() + const firstLastUsed = usage.value?.lastUsed ?? 0 + + vi.advanceTimersByTime(10) + trackUsage() + + expect(usage.value?.lastUsed).toBeGreaterThan(firstLastUsed) + } finally { + vi.useRealTimers() + } + }) + + it('reset clears feature data', async () => { + const { useFeatureUsageTracker } = await import('./useFeatureUsageTracker') + const { useCount, trackUsage, reset } = + useFeatureUsageTracker('test-feature') + + trackUsage() + trackUsage() + expect(useCount.value).toBe(2) + + reset() + expect(useCount.value).toBe(0) + }) + + it('tracks multiple features independently', async () => { + const { useFeatureUsageTracker } = await import('./useFeatureUsageTracker') + const featureA = useFeatureUsageTracker('feature-a') + const featureB = useFeatureUsageTracker('feature-b') + + featureA.trackUsage() + featureA.trackUsage() + featureB.trackUsage() + + expect(featureA.useCount.value).toBe(2) + expect(featureB.useCount.value).toBe(1) + }) + + it('persists to localStorage', async () => { + vi.useFakeTimers() + try { + const { useFeatureUsageTracker } = + await import('./useFeatureUsageTracker') + const { trackUsage } = useFeatureUsageTracker('persisted-feature') + + trackUsage() + await vi.runAllTimersAsync() + + const stored = JSON.parse(localStorage.getItem(STORAGE_KEY) ?? '{}') + expect(stored['persisted-feature']?.useCount).toBe(1) + } finally { + vi.useRealTimers() + } + }) + + it('loads existing data from localStorage', async () => { + localStorage.setItem( + STORAGE_KEY, + JSON.stringify({ + 'existing-feature': { useCount: 5, firstUsed: 1000, lastUsed: 2000 } + }) + ) + + vi.resetModules() + const { useFeatureUsageTracker } = await import('./useFeatureUsageTracker') + const { useCount } = useFeatureUsageTracker('existing-feature') + + expect(useCount.value).toBe(5) + }) +}) diff --git a/src/platform/surveys/useFeatureUsageTracker.ts b/src/platform/surveys/useFeatureUsageTracker.ts new file mode 100644 index 000000000..b8d825db4 --- /dev/null +++ b/src/platform/surveys/useFeatureUsageTracker.ts @@ -0,0 +1,46 @@ +import { useStorage } from '@vueuse/core' +import { computed } from 'vue' + +interface FeatureUsage { + useCount: number + firstUsed: number + lastUsed: number +} + +type FeatureUsageRecord = Record + +const STORAGE_KEY = 'Comfy.FeatureUsage' + +/** + * Tracks feature usage for survey eligibility. + * Persists to localStorage. + */ +export function useFeatureUsageTracker(featureId: string) { + const usageData = useStorage(STORAGE_KEY, {}) + + const usage = computed(() => usageData.value[featureId]) + + const useCount = computed(() => usage.value?.useCount ?? 0) + + function trackUsage() { + const now = Date.now() + const existing = usageData.value[featureId] + + usageData.value[featureId] = { + useCount: (existing?.useCount ?? 0) + 1, + firstUsed: existing?.firstUsed ?? now, + lastUsed: now + } + } + + function reset() { + delete usageData.value[featureId] + } + + return { + usage, + useCount, + trackUsage, + reset + } +} From e8b45204f2922fe46d2b25c0f0b0894f13e926a8 Mon Sep 17 00:00:00 2001 From: Christian Byrne Date: Tue, 20 Jan 2026 14:22:25 -0800 Subject: [PATCH 36/45] feat(panel): add collapsible Advanced Inputs section for widgets marked advanced (#8146) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a collapsible 'Advanced Inputs' section to the right-side panel that displays widgets marked with `options.advanced = true`. image ## Changes - Filters normal widgets to exclude advanced ones - Adds new `advancedWidgetsSectionDataList` computed for advanced widgets - Renders a collapsible section (collapsed by default) for advanced widgets ## Related - Backend PR that adds `advanced` flag: comfyanonymous/ComfyUI#11939 ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-8146-feat-panel-add-collapsible-Advanced-Inputs-section-for-widgets-marked-advanced-2ec6d73d36508120af1af27110a6fb96) by [Unito](https://www.unito.io) --------- Co-authored-by: GitHub Action Co-authored-by: Rizumu Ayaka --- .../parameters/TabNormalInputs.vue | 38 ++++++++++++++++++- 1 file changed, 37 insertions(+), 1 deletion(-) diff --git a/src/components/rightSidePanel/parameters/TabNormalInputs.vue b/src/components/rightSidePanel/parameters/TabNormalInputs.vue index 30689f1a4..add128876 100644 --- a/src/components/rightSidePanel/parameters/TabNormalInputs.vue +++ b/src/components/rightSidePanel/parameters/TabNormalInputs.vue @@ -25,13 +25,31 @@ const widgetsSectionDataList = computed((): NodeWidgetsListList => { return nodes.map((node) => { const { widgets = [] } = node const shownWidgets = widgets - .filter((w) => !(w.options?.canvasOnly || w.options?.hidden)) + .filter( + (w) => + !(w.options?.canvasOnly || w.options?.hidden || w.options?.advanced) + ) .map((widget) => ({ node, widget })) return { widgets: shownWidgets, node } }) }) +const advancedWidgetsSectionDataList = computed((): NodeWidgetsListList => { + return nodes + .map((node) => { + const { widgets = [] } = node + const advancedWidgets = widgets + .filter( + (w) => + !(w.options?.canvasOnly || w.options?.hidden) && w.options?.advanced + ) + .map((widget) => ({ node, widget })) + return { widgets: advancedWidgets, node } + }) + .filter(({ widgets }) => widgets.length > 0) +}) + const isMultipleNodesSelected = computed( () => widgetsSectionDataList.value.length > 1 ) @@ -56,6 +74,12 @@ const label = computed(() => { : t('rightSidePanel.inputsNone') : undefined // SectionWidgets display node titles by default }) + +const advancedLabel = computed(() => { + return !mustShowNodeTitle && !isMultipleNodesSelected.value + ? t('rightSidePanel.advancedInputs') + : undefined // SectionWidgets display node titles by default +}) From f5a784e5619dda3e81ca1771650a3edf1e1520c3 Mon Sep 17 00:00:00 2001 From: Christian Byrne Date: Tue, 20 Jan 2026 15:52:40 -0800 Subject: [PATCH 37/45] fix: add plurilization to node pack count in custom node manager dialog (#8191) --- src/locales/en/main.json | 1 + .../manager/packCard/PackCard.test.ts | 25 ++++++++++++++++--- .../components/manager/packCard/PackCard.vue | 9 ++++--- 3 files changed, 29 insertions(+), 6 deletions(-) diff --git a/src/locales/en/main.json b/src/locales/en/main.json index fa557ee96..e87121414 100644 --- a/src/locales/en/main.json +++ b/src/locales/en/main.json @@ -158,6 +158,7 @@ "choose_file_to_upload": "choose file to upload", "capture": "capture", "nodes": "Nodes", + "nodesCount": "{count} nodes | {count} node | {count} nodes", "community": "Community", "all": "All", "versionMismatchWarning": "Version Compatibility Warning", diff --git a/src/workbench/extensions/manager/components/manager/packCard/PackCard.test.ts b/src/workbench/extensions/manager/components/manager/packCard/PackCard.test.ts index 3e5116739..b3a92e5a3 100644 --- a/src/workbench/extensions/manager/components/manager/packCard/PackCard.test.ts +++ b/src/workbench/extensions/manager/components/manager/packCard/PackCard.test.ts @@ -10,15 +10,22 @@ import type { RegistryPack } from '@/workbench/extensions/manager/types/comfyManagerTypes' +const translateMock = vi.hoisted(() => + vi.fn((key: string, choice?: number) => + typeof choice === 'number' ? `${key}-${choice}` : key + ) +) +const dateMock = vi.hoisted(() => vi.fn(() => '2024. 1. 1.')) + // Mock dependencies vi.mock('vue-i18n', () => ({ useI18n: vi.fn(() => ({ - d: vi.fn(() => '2024. 1. 1.'), - t: vi.fn((key: string) => key) + d: dateMock, + t: translateMock })), createI18n: vi.fn(() => ({ global: { - t: vi.fn((key: string) => key), + t: translateMock, te: vi.fn(() => true) } })) @@ -187,6 +194,18 @@ describe('PackCard', () => { // Should still render without errors expect(wrapper.exists()).toBe(true) }) + + it('should use localized singular/plural nodes label', () => { + const packWithNodes = { + ...mockNodePack, + comfy_nodes: ['node-a'] + } as MergedNodePack + + const wrapper = createWrapper({ nodePack: packWithNodes }) + + expect(wrapper.text()).toContain('g.nodesCount-1') + expect(translateMock).toHaveBeenCalledWith('g.nodesCount', 1) + }) }) describe('component structure', () => { diff --git a/src/workbench/extensions/manager/components/manager/packCard/PackCard.vue b/src/workbench/extensions/manager/components/manager/packCard/PackCard.vue index 6900e71df..17ffdd102 100644 --- a/src/workbench/extensions/manager/components/manager/packCard/PackCard.vue +++ b/src/workbench/extensions/manager/components/manager/packCard/PackCard.vue @@ -36,8 +36,8 @@

-
- {{ nodesCount }} {{ $t('g.nodes') }} +
+ {{ nodesLabel }}
() -const { d } = useI18n() +const { d, t } = useI18n() const colorPaletteStore = useColorPaletteStore() const isLightTheme = computed( @@ -115,6 +115,9 @@ const isDisabled = computed( const nodesCount = computed(() => isMergedNodePack(nodePack) ? nodePack.comfy_nodes?.length : undefined ) +const nodesLabel = computed(() => + nodesCount.value ? t('g.nodesCount', nodesCount.value) : '' +) const publisherName = computed(() => { if (!nodePack) return null From e6ef99e92ca446bbd869190e725ae2f219ce1a88 Mon Sep 17 00:00:00 2001 From: Simula_r <18093452+simula-r@users.noreply.github.com> Date: Tue, 20 Jan 2026 16:13:54 -0800 Subject: [PATCH 38/45] feat: add isCloud guard to team workspaces feature flag (#8192) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ensures the team_workspaces_enabled feature flag only returns true when running in cloud environment, preventing the feature from activating in local/desktop installations. ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-8192-feat-add-isCloud-guard-to-team-workspaces-feature-flag-2ee6d73d3650810bb1d7c1721ebcdd44) by [Unito](https://www.unito.io) --------- Co-authored-by: Claude Opus 4.5 Co-authored-by: GitHub Action --- src/composables/useFeatureFlags.ts | 3 +++ .../assets/components/MediaAssetFilterBar.vue | 26 +++++++++++++++---- 2 files changed, 24 insertions(+), 5 deletions(-) diff --git a/src/composables/useFeatureFlags.ts b/src/composables/useFeatureFlags.ts index 136b7ccd1..ca54bb9c6 100644 --- a/src/composables/useFeatureFlags.ts +++ b/src/composables/useFeatureFlags.ts @@ -1,5 +1,6 @@ import { computed, reactive, readonly } from 'vue' +import { isCloud } from '@/platform/distribution/types' import { remoteConfig } from '@/platform/remoteConfig/remoteConfig' import { api } from '@/scripts/api' @@ -95,6 +96,8 @@ export function useFeatureFlags() { ) }, get teamWorkspacesEnabled() { + if (!isCloud) return false + return ( remoteConfig.value.team_workspaces_enabled ?? api.getServerFeature(ServerFeatureFlag.TEAM_WORKSPACES_ENABLED, false) diff --git a/src/platform/assets/components/MediaAssetFilterBar.vue b/src/platform/assets/components/MediaAssetFilterBar.vue index 0442f5f56..6e4ec5e4c 100644 --- a/src/platform/assets/components/MediaAssetFilterBar.vue +++ b/src/platform/assets/components/MediaAssetFilterBar.vue @@ -6,20 +6,36 @@ @update:model-value="handleSearchChange" />
- + - + - +
From b1dfbfaa09cd5f76ca8c11715a913283dfd4b3c3 Mon Sep 17 00:00:00 2001 From: Alexander Brown Date: Tue, 20 Jan 2026 16:44:08 -0800 Subject: [PATCH 39/45] chore: Replace prettier with oxfmt (#8177) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Configure oxfmt ignorePatterns to exclude non-JS/TS files (md, json, css, yaml, etc.) to match previous Prettier behavior. ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-8177-chore-configure-oxfmt-to-format-only-JS-TS-Vue-files-2ee6d73d3650815080f3cc8a4a932109) by [Unito](https://www.unito.io) --------- Co-authored-by: Amp --- .claude/commands/setup_repo.md | 2 +- .github/workflows/ci-lint-format.yaml | 6 +- .i18nrc.cjs | 18 +- .oxfmtrc.json | 20 ++ .prettierignore | 2 - .prettierrc | 11 - .vscode/extensions.json | 15 +- AGENTS.md | 8 +- eslint.config.ts | 6 +- lint-staged.config.mjs | 25 -- lint-staged.config.ts | 5 +- package.json | 10 +- pnpm-lock.yaml | 323 ++++++------------- pnpm-workspace.yaml | 4 +- src/composables/useContextMenuTranslation.ts | 3 +- vite.config.mts | 3 +- 16 files changed, 158 insertions(+), 303 deletions(-) create mode 100644 .oxfmtrc.json delete mode 100644 .prettierignore delete mode 100644 .prettierrc delete mode 100644 lint-staged.config.mjs diff --git a/.claude/commands/setup_repo.md b/.claude/commands/setup_repo.md index d82e22ec6..71dee96a5 100644 --- a/.claude/commands/setup_repo.md +++ b/.claude/commands/setup_repo.md @@ -122,7 +122,7 @@ echo " pnpm build - Build for production" echo " pnpm test:unit - Run unit tests" echo " pnpm typecheck - Run TypeScript checks" echo " pnpm lint - Run ESLint" -echo " pnpm format - Format code with Prettier" +echo " pnpm format - Format code with oxfmt" echo "" echo "Next steps:" echo "1. Run 'pnpm dev' to start developing" diff --git a/.github/workflows/ci-lint-format.yaml b/.github/workflows/ci-lint-format.yaml index 3ce6d6aa9..c97f6255c 100644 --- a/.github/workflows/ci-lint-format.yaml +++ b/.github/workflows/ci-lint-format.yaml @@ -42,7 +42,7 @@ jobs: - name: Run Stylelint with auto-fix run: pnpm stylelint:fix - - name: Run Prettier with auto-format + - name: Run oxfmt with auto-format run: pnpm format - name: Check for changes @@ -60,7 +60,7 @@ jobs: git config --local user.email "action@github.com" git config --local user.name "GitHub Action" git add . - git commit -m "[automated] Apply ESLint and Prettier fixes" + git commit -m "[automated] Apply ESLint and Oxfmt fixes" git push - name: Final validation @@ -80,7 +80,7 @@ jobs: issue_number: context.issue.number, owner: context.repo.owner, repo: context.repo.repo, - body: '## 🔧 Auto-fixes Applied\n\nThis PR has been automatically updated to fix linting and formatting issues.\n\n**⚠️ Important**: Your local branch is now behind. Run `git pull` before making additional changes to avoid conflicts.\n\n### Changes made:\n- ESLint auto-fixes\n- Prettier formatting' + body: '## 🔧 Auto-fixes Applied\n\nThis PR has been automatically updated to fix linting and formatting issues.\n\n**⚠️ Important**: Your local branch is now behind. Run `git pull` before making additional changes to avoid conflicts.\n\n### Changes made:\n- ESLint auto-fixes\n- Oxfmt formatting' }) - name: Comment on PR about manual fix needed diff --git a/.i18nrc.cjs b/.i18nrc.cjs index 86ce06eaa..4369f0a70 100644 --- a/.i18nrc.cjs +++ b/.i18nrc.cjs @@ -1,7 +1,7 @@ // This file is intentionally kept in CommonJS format (.cjs) // to resolve compatibility issues with dependencies that require CommonJS. // Do not convert this file to ESModule format unless all dependencies support it. -const { defineConfig } = require('@lobehub/i18n-cli'); +const { defineConfig } = require('@lobehub/i18n-cli') module.exports = defineConfig({ modelName: 'gpt-4.1', @@ -10,7 +10,19 @@ module.exports = defineConfig({ entry: 'src/locales/en', entryLocale: 'en', output: 'src/locales', - outputLocales: ['zh', 'zh-TW', 'ru', 'ja', 'ko', 'fr', 'es', 'ar', 'tr', 'pt-BR', 'fa'], + outputLocales: [ + 'zh', + 'zh-TW', + 'ru', + 'ja', + 'ko', + 'fr', + 'es', + 'ar', + 'tr', + 'pt-BR', + 'fa' + ], reference: `Special names to keep untranslated: flux, photomaker, clip, vae, cfg, stable audio, stable cascade, stable zero, controlnet, lora, HiDream, Civitai, Hugging Face. 'latent' is the short form of 'latent space'. 'mask' is in the context of image processing. @@ -26,4 +38,4 @@ module.exports = defineConfig({ - Use Arabic-Indic numerals (۰-۹) for numbers where appropriate. - Maintain consistency with terminology used in Persian software and design applications. ` -}); +}) diff --git a/.oxfmtrc.json b/.oxfmtrc.json new file mode 100644 index 000000000..5da4febe2 --- /dev/null +++ b/.oxfmtrc.json @@ -0,0 +1,20 @@ +{ + "$schema": "./node_modules/oxfmt/configuration_schema.json", + "singleQuote": true, + "tabWidth": 2, + "semi": false, + "trailingComma": "none", + "printWidth": 80, + "ignorePatterns": [ + "packages/registry-types/src/comfyRegistryTypes.ts", + "src/types/generatedManagerTypes.ts", + "**/*.md", + "**/*.json", + "**/*.css", + "**/*.yaml", + "**/*.yml", + "**/*.html", + "**/*.svg", + "**/*.xml" + ] +} diff --git a/.prettierignore b/.prettierignore deleted file mode 100644 index 4403edd8e..000000000 --- a/.prettierignore +++ /dev/null @@ -1,2 +0,0 @@ -packages/registry-types/src/comfyRegistryTypes.ts -src/types/generatedManagerTypes.ts diff --git a/.prettierrc b/.prettierrc deleted file mode 100644 index aa43a43ac..000000000 --- a/.prettierrc +++ /dev/null @@ -1,11 +0,0 @@ -{ - "singleQuote": true, - "tabWidth": 2, - "semi": false, - "trailingComma": "none", - "printWidth": 80, - "importOrder": ["^@core/(.*)$", "", "^@/(.*)$", "^[./]"], - "importOrderSeparation": true, - "importOrderSortSpecifiers": true, - "plugins": ["@prettier/plugin-oxc", "@trivago/prettier-plugin-sort-imports"] -} diff --git a/.vscode/extensions.json b/.vscode/extensions.json index 54f28d400..9cbac42d7 100644 --- a/.vscode/extensions.json +++ b/.vscode/extensions.json @@ -1,25 +1,22 @@ { "recommendations": [ + "antfu.vite", "austenc.tailwind-docs", "bradlc.vscode-tailwindcss", "davidanson.vscode-markdownlint", "dbaeumer.vscode-eslint", + "donjayamanne.githistory", "eamodio.gitlens", - "esbenp.prettier-vscode", - "figma.figma-vscode-extension", "github.vscode-github-actions", "github.vscode-pull-request-github", "hbenl.vscode-test-explorer", + "kisstkondoros.vscode-codemetrics", "lokalise.i18n-ally", "ms-playwright.playwright", + "oxc.oxc-vscode", + "sonarsource.sonarlint-vscode", "vitest.explorer", "vue.volar", - "sonarsource.sonarlint-vscode", - "deque-systems.vscode-axe-linter", - "kisstkondoros.vscode-codemetrics", - "donjayamanne.githistory", - "wix.vscode-import-cost", - "prograhammer.tslint-vue", - "antfu.vite" + "wix.vscode-import-cost" ] } diff --git a/AGENTS.md b/AGENTS.md index da2953783..9938865a9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -27,10 +27,10 @@ See @docs/guidance/*.md for file-type-specific conventions (auto-loaded by glob) - Build output: `dist/` - Configs - `vite.config.mts` - - `vitest.config.ts` - `playwright.config.ts` - `eslint.config.ts` - - `.prettierrc` + - `.oxfmtrc.json` + - `.oxlintrc.json` - etc. ## Monorepo Architecture @@ -46,7 +46,7 @@ The project uses **Nx** for build orchestration and task management - `pnpm test:unit`: Run Vitest unit tests - `pnpm test:browser`: Run Playwright E2E tests (`browser_tests/`) - `pnpm lint` / `pnpm lint:fix`: Lint (ESLint) -- `pnpm format` / `pnpm format:check`: Prettier +- `pnpm format` / `pnpm format:check`: oxfmt - `pnpm typecheck`: Vue TSC type checking - `pnpm storybook`: Start Storybook development server @@ -72,7 +72,7 @@ The project uses **Nx** for build orchestration and task management - Composition API only - Tailwind 4 styling - Avoid ` diff --git a/src/components/sidebar/SidebarIcon.test.ts b/src/components/sidebar/SidebarIcon.test.ts index 7564e7bcd..284a29825 100644 --- a/src/components/sidebar/SidebarIcon.test.ts +++ b/src/components/sidebar/SidebarIcon.test.ts @@ -1,6 +1,5 @@ import { mount } from '@vue/test-utils' import PrimeVue from 'primevue/config' -import OverlayBadge from 'primevue/overlaybadge' import Tooltip from 'primevue/tooltip' import { describe, expect, it } from 'vitest' import { createI18n } from 'vue-i18n' @@ -33,8 +32,7 @@ describe('SidebarIcon', () => { return mount(SidebarIcon, { global: { plugins: [PrimeVue, i18n], - directives: { tooltip: Tooltip }, - components: { OverlayBadge } + directives: { tooltip: Tooltip } }, props: { ...exampleProps, ...props }, ...options @@ -54,9 +52,9 @@ describe('SidebarIcon', () => { it('creates badge when iconBadge prop is set', () => { const badge = '2' const wrapper = mountSidebarIcon({ iconBadge: badge }) - const badgeEl = wrapper.findComponent(OverlayBadge) + const badgeEl = wrapper.find('.sidebar-icon-badge') expect(badgeEl.exists()).toBe(true) - expect(badgeEl.find('.p-badge').text()).toEqual(badge) + expect(badgeEl.text()).toEqual(badge) }) it('shows tooltip on hover', async () => { diff --git a/src/components/sidebar/SidebarIcon.vue b/src/components/sidebar/SidebarIcon.vue index 88900c1a7..10dfca8f8 100644 --- a/src/components/sidebar/SidebarIcon.vue +++ b/src/components/sidebar/SidebarIcon.vue @@ -17,22 +17,28 @@ >