Compare commits

..

10 Commits

Author SHA1 Message Date
huchenlei
5da195c925 nit 2025-08-10 14:06:52 -04:00
huchenlei
d3398944d5 Fix test 2025-08-10 14:05:40 -04:00
huchenlei
b70b2c89b2 Fix highlight 2025-08-10 12:53:37 -04:00
huchenlei
c0303a6553 tailwind style 2025-08-10 12:49:03 -04:00
huchenlei
1e6803fd65 Support i18n 2025-08-10 12:36:40 -04:00
huchenlei
38a77abecb nit 2025-08-10 12:17:18 -04:00
github-actions
fcffa51a24 Update locales [skip ci] 2025-08-10 16:16:10 +00:00
huchenlei
d0ef1bb81a nit 2025-08-10 11:01:46 -04:00
huchenlei
34b7fe14c4 Fix mode switch 2025-08-09 22:50:03 -04:00
huchenlei
a4d7b4dd55 Basic commandbox 2025-08-09 22:50:03 -04:00
74 changed files with 773 additions and 2793 deletions

View File

@@ -210,52 +210,29 @@ echo "Last stable release: $LAST_STABLE"
echo "WARNING: PR #$PR not on main branch!"
done
```
3. Create standardized release notes using this exact template:
3. Create comprehensive release notes including:
- **Version Change**: Show version bump details
- **Changelog** grouped by type:
- 🚀 **Features** (feat:)
- 🐛 **Bug Fixes** (fix:)
- 💥 **Breaking Changes** (BREAKING CHANGE)
- 📚 **Documentation** (docs:)
- 🔧 **Maintenance** (chore:, refactor:)
- ⬆️ **Dependencies** (deps:, dependency updates)
- **Litegraph Changes** (if version updated):
- 🚀 Features: ${LITEGRAPH_FEATURES}
- 🐛 Bug Fixes: ${LITEGRAPH_FIXES}
- 💥 Breaking Changes: ${LITEGRAPH_BREAKING}
- 🔧 Other Changes: ${LITEGRAPH_OTHER}
- **Other Major Dependencies**: ${OTHER_DEP_CHANGES}
- Include PR numbers and links
- Add issue references (Fixes #123)
4. **Save release notes:**
```bash
cat > release-notes-${NEW_VERSION}.md << 'EOF'
## ⚠️ Breaking Changes
<!-- List breaking changes if any, otherwise remove this entire section -->
- Breaking change description (#PR_NUMBER)
---
## What's Changed
### 🚀 Features
<!-- List features here, one per line with PR reference -->
- Feature description (#PR_NUMBER)
### 🐛 Bug Fixes
<!-- List bug fixes here, one per line with PR reference -->
- Bug fix description (#PR_NUMBER)
### 🔧 Maintenance
<!-- List refactoring, chore, and other maintenance items -->
- Maintenance item description (#PR_NUMBER)
### 📚 Documentation
<!-- List documentation changes if any, remove section if empty -->
- Documentation update description (#PR_NUMBER)
### ⬆️ Dependencies
<!-- List dependency updates -->
- Updated dependency from vX.X.X to vY.Y.Y (#PR_NUMBER)
**Full Changelog**: https://github.com/Comfy-Org/ComfyUI_frontend/compare/${BASE_TAG}...v${NEW_VERSION}
EOF
# Save release notes for PR and GitHub release
echo "$RELEASE_NOTES" > release-notes-${NEW_VERSION}.md
```
4. **Parse commits and populate template:**
- Group commits by conventional commit type (feat:, fix:, chore:, etc.)
- Extract PR numbers from commit messages
- For breaking changes, analyze if changes affect:
- Public APIs (app object, api module)
- Extension/workspace manager APIs
- Node schema, workflow schema, or other public schemas
- Any other public-facing interfaces
- For dependency updates, list version changes with PR numbers
- Remove empty sections (e.g., if no documentation changes)
- Ensure consistent bullet format: `- Description (#PR_NUMBER)`
5. **CONTENT REVIEW**: Release notes follow standard format?
5. **CONTENT REVIEW**: Release notes clear and comprehensive with dependency details?
### Step 9: Create Version Bump PR
@@ -296,12 +273,38 @@ echo "Workflow triggered. Waiting for PR creation..."
--body-file release-notes-${NEW_VERSION}.md \
--label "Release"
```
3. **Update PR with release notes:**
3. **Add required sections to PR body:**
```bash
# For workflow-created PRs, update the body with our release notes
gh pr edit ${PR_NUMBER} --body-file release-notes-${NEW_VERSION}.md
# Create PR body with release notes plus required sections
cat > pr-body.md << EOF
${RELEASE_NOTES}
## Breaking Changes
${BREAKING_CHANGES:-None}
## Testing Performed
- ✅ Full test suite (unit, component)
- ✅ TypeScript compilation
- ✅ Linting checks
- ✅ Build verification
- ✅ Security audit
## Distribution Channels
- GitHub Release (with dist.zip)
- PyPI Package (comfyui-frontend-package)
- npm Package (@comfyorg/comfyui-frontend-types)
## Post-Release Tasks
- [ ] Verify all distribution channels
- [ ] Update external documentation
- [ ] Monitor for issues
EOF
```
4. **PR REVIEW**: Version bump PR created with standardized release notes?
4. Update PR with enhanced description:
```bash
gh pr edit ${PR_NUMBER} --body-file pr-body.md
```
5. **PR REVIEW**: Version bump PR created and enhanced correctly?
### Step 10: Critical Release PR Verification
@@ -589,46 +592,55 @@ The command implements multiple quality gates:
- Draft release status
- Python package specs require that prereleases use alpha/beta/rc as the preid
## Critical Implementation Notes
## Common Issues and Solutions
When executing this release process, pay attention to these key aspects:
### Issue: Pre-release Version Confusion
**Problem**: Not sure whether to promote pre-release or create new version
**Solution**:
- Follow semver standards: a prerelease version is followed by a normal release. It should have the same major, minor, and patch versions as the prerelease.
### Version Handling
- For pre-release versions (e.g., 1.24.0-rc.1), the next stable release should be the same version without the suffix (1.24.0)
- Never skip version numbers - follow semantic versioning strictly
### Issue: Wrong Commit Count
**Problem**: Changelog includes commits from other branches
**Solution**: Always use `--first-parent` flag with git log
### Commit History Analysis
- **ALWAYS** use `--first-parent` flag with git log to avoid including commits from merged feature branches
- Verify PR merge targets before including them in changelogs:
```bash
gh pr view ${PR_NUMBER} --json baseRefName
```
**Update**: Sometimes update-locales doesn't add [skip ci] - always verify!
### Release Workflow Triggers
- The "Release" label on the PR is **CRITICAL** - without it, PyPI/npm publishing won't occur
- Check for `[skip ci]` in commit messages before merging - this blocks the release workflow
- If you encounter `[skip ci]`, push an empty commit to override it:
```bash
git commit --allow-empty -m "Trigger release workflow"
```
### Issue: Missing PRs in Changelog
**Problem**: PR was merged to different branch
**Solution**: Verify PR merge target with:
```bash
gh pr view ${PR_NUMBER} --json baseRefName
```
### PR Creation Details
- Version bump PRs come from `comfy-pr-bot`, not `github-actions`
- The workflow typically completes in 20-30 seconds
- Always wait for the PR to be created before trying to edit it
### Issue: Incomplete Dependency Changelog
**Problem**: Litegraph or other dependency updates only show version bump, not actual changes
**Solution**: The command now automatically:
- Detects litegraph version changes between releases
- Clones the litegraph repository temporarily
- Extracts and categorizes changes between versions
- Includes detailed litegraph changelog in release notes
- Cleans up temporary files after analysis
### Breaking Changes Detection
- Analyze changes to public-facing APIs:
- The `app` object and its methods
- The `api` module exports
- Extension and workspace manager interfaces
- Node schema, workflow schema, and other public schemas
- Any modifications to these require marking as breaking changes
### Issue: Release Failed Due to [skip ci]
**Problem**: Release workflow didn't trigger after merge
**Prevention**: Always avoid this scenario
- Ensure that `[skip ci]` or similar flags are NOT in the `HEAD` commit message of the PR
- Push a new, empty commit to the PR
- Always double-check this immediately before merging
### Recovery Procedures
If the release workflow fails to trigger:
1. Create a revert PR to restore the previous version
2. Merge the revert
3. Re-run the version bump workflow
4. This approach is cleaner than creating extra version numbers
**Recovery Strategy**:
1. Revert version in a new PR (e.g., 1.24.0 → 1.24.0-1)
2. Merge the revert PR
3. Run version bump workflow again
4. This creates a fresh PR without [skip ci]
Benefits: Cleaner than creating extra version numbers
## Key Learnings & Notes
1. **PR Author**: Version bump PRs are created by `comfy-pr-bot`, not `github-actions`
2. **Workflow Speed**: Version bump workflow typically completes in ~20-30 seconds
3. **Update-locales Behavior**: Inconsistent - sometimes adds [skip ci], sometimes doesn't
4. **Recovery Options**: Reverting version is cleaner than creating extra versions
5. **Dependency Tracking**: Command now automatically includes litegraph and major dependency changes in changelogs
6. **Litegraph Integration**: Temporary cloning of litegraph repo provides detailed change analysis between versions

View File

@@ -138,50 +138,14 @@ For each commit:
```bash
gh pr create --base core/X.Y --head release/1.23.5 \
--title "[Release] v1.23.5" \
--body "Release notes will be added shortly..." \
--body "..." \
--label "Release"
```
3. **CRITICAL**: Verify "Release" label is added
4. Create standardized release notes:
```bash
cat > release-notes-${NEW_VERSION}.md << 'EOF'
## ⚠️ Breaking Changes
<!-- List breaking changes if any, otherwise remove this entire section -->
- Breaking change description (#PR_NUMBER)
---
## What's Changed
### 🚀 Features
<!-- List features here, one per line with PR reference -->
- Feature description (#PR_NUMBER)
### 🐛 Bug Fixes
<!-- List bug fixes here, one per line with PR reference -->
- Bug fix description (#PR_NUMBER)
### 🔧 Maintenance
<!-- List refactoring, chore, and other maintenance items -->
- Maintenance item description (#PR_NUMBER)
### 📚 Documentation
<!-- List documentation changes if any, remove section if empty -->
- Documentation update description (#PR_NUMBER)
### ⬆️ Dependencies
<!-- List dependency updates -->
- Updated dependency from vX.X.X to vY.Y.Y (#PR_NUMBER)
**Full Changelog**: https://github.com/Comfy-Org/ComfyUI_frontend/compare/v${CURRENT_VERSION}...v${NEW_VERSION}
EOF
```
- For hotfixes, typically only populate the "Bug Fixes" section
- Include links to the cherry-picked PRs/commits
- Update the PR body with the release notes:
```bash
gh pr edit ${PR_NUMBER} --body-file release-notes-${NEW_VERSION}.md
```
4. PR description should include:
- Version: `1.23.4` → `1.23.5`
- Included fixes (link to previous PR)
- Release notes for users
5. **CONFIRMATION REQUIRED**: Release PR has "Release" label?
### Step 11: Monitor Release Process

View File

@@ -1,131 +0,0 @@
# Create PR
Automate PR creation with proper tags, labels, and concise summary.
## Step 1: Check Prerequisites
```bash
# Ensure you have uncommitted changes
git status
# If changes exist, commit them first
git add .
git commit -m "[tag] Your commit message"
```
## Step 2: Push and Create PR
You'll create the PR with the following structure:
### PR Tags (use in title)
- `[feat]` - New features → label: `enhancement`
- `[bugfix]` - Bug fixes → label: `verified bug`
- `[refactor]` - Code restructuring → label: `enhancement`
- `[docs]` - Documentation → label: `documentation`
- `[test]` - Test changes → label: `enhancement`
- `[ci]` - CI/CD changes → label: `enhancement`
### Label Mapping
#### General Labels
- Feature/Enhancement: `enhancement`
- Bug fixes: `verified bug`
- Documentation: `documentation`
- Dependencies: `dependencies`
- Performance: `Performance`
- Desktop app: `Electron`
#### Product Area Labels
**Core Features**
- `area:nodes` - Node-related functionality
- `area:workflows` - Workflow management
- `area:queue` - Queue system
- `area:models` - Model handling
- `area:templates` - Template system
- `area:subgraph` - Subgraph functionality
**UI Components**
- `area:ui` - General user interface improvements
- `area:widgets` - Widget system
- `area:dom-widgets` - DOM-based widgets
- `area:links` - Connection links between nodes
- `area:groups` - Node grouping functionality
- `area:reroutes` - Reroute nodes
- `area:previews` - Preview functionality
- `area:minimap` - Minimap navigation
- `area:floating-toolbox` - Floating toolbar
- `area:mask-editor` - Mask editing tools
**Navigation & Organization**
- `area:navigation` - Navigation system
- `area:search` - Search functionality
- `area:workspace-management` - Workspace features
- `area:topbar-menu` - Top bar menu
- `area:help-menu` - Help menu system
**System Features**
- `area:settings` - Settings/preferences
- `area:hotkeys` - Keyboard shortcuts
- `area:undo-redo` - Undo/redo system
- `area:customization` - Customization features
- `area:auth` - Authentication
- `area:comms` - Communication/networking
**Development & Infrastructure**
- `area:CI/CD` - CI/CD pipeline
- `area:testing` - Testing infrastructure
- `area:vue-migration` - Vue migration work
- `area:manager` - ComfyUI Manager integration
**Platform-Specific**
- `area:mobile` - Mobile support
- `area:3d` - 3D-related features
**Special Areas**
- `area:i18n` - Translation/internationalization
- `area:CNR` - Comfy Node Registry
## Step 3: Execute PR Creation
```bash
# First, push your branch
git push -u origin $(git branch --show-current)
# Then create the PR (replace placeholders)
gh pr create \
--title "[TAG] Brief description" \
--body "$(cat <<'EOF'
## Summary
One sentence describing what changed and why.
## Changes
- **What**: Core functionality added/modified
- **Breaking**: Any breaking changes (if none, omit this line)
- **Dependencies**: New dependencies (if none, omit this line)
## Review Focus
- Critical design decisions or edge cases that need attention
Fixes #ISSUE_NUMBER
EOF
)" \
--label "APPROPRIATE_LABEL" \
--base main
```
## Additional Options
- Add multiple labels: `--label "enhancement,Performance"`
- Request reviewers: `--reviewer @username`
- Mark as draft: `--draft`
- Open in browser after creation: `--web`

View File

@@ -1,20 +0,0 @@
## Summary
<!-- One sentence describing what changed and why. -->
## Changes
- **What**: <!-- Core functionality added/modified -->
- **Breaking**: <!-- Any breaking changes (if none, remove this line) -->
- **Dependencies**: <!-- New dependencies (if none, remove this line) -->
## Review Focus
<!-- Critical design decisions or edge cases that need attention -->
<!-- If this PR fixes an issue, uncomment and update the line below -->
<!-- Fixes #ISSUE_NUMBER -->
## Screenshots (if applicable)
<!-- Add screenshots or video recording to help explain your changes -->

1
.gitignore vendored
View File

@@ -41,6 +41,7 @@ tests-ui/workflows/examples
/blob-report/
/playwright/.cache/
browser_tests/**/*-win32.png
browser_tests/**/*-darwin.png
.env

View File

@@ -0,0 +1,161 @@
import {
comfyExpect as expect,
comfyPageFixture as test
} from '../fixtures/ComfyPage'
test.describe('Command search box', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.NodeSearchBoxImpl', 'default')
})
test('Can trigger command mode with ">" prefix', async ({ comfyPage }) => {
await comfyPage.doubleClickCanvas()
await expect(comfyPage.searchBox.input).toHaveCount(1)
// Type ">" to enter command mode
await comfyPage.searchBox.input.fill('>')
// Verify filter button is hidden in command mode
const filterButton = comfyPage.page.locator('.filter-button')
await expect(filterButton).not.toBeVisible()
// Verify placeholder text changes
await expect(comfyPage.searchBox.input).toHaveAttribute(
'placeholder',
expect.stringContaining('Search Commands')
)
})
test('Shows command list when entering command mode', async ({
comfyPage
}) => {
await comfyPage.doubleClickCanvas()
await comfyPage.searchBox.input.fill('>')
// Wait for dropdown to appear
await comfyPage.searchBox.dropdown.waitFor({ state: 'visible' })
// Check that commands are shown
const firstItem = comfyPage.searchBox.dropdown.locator('li').first()
await expect(firstItem).toBeVisible()
// Verify it shows a command item with icon
const commandIcon = firstItem.locator('.item-icon')
await expect(commandIcon).toBeVisible()
})
test('Can search and filter commands', async ({ comfyPage }) => {
await comfyPage.doubleClickCanvas()
await comfyPage.searchBox.input.fill('>save')
await comfyPage.searchBox.dropdown.waitFor({ state: 'visible' })
await comfyPage.page.waitForTimeout(500) // Wait for search to complete
// Get all visible command items
const items = comfyPage.searchBox.dropdown.locator('li')
const count = await items.count()
// Should have filtered results
expect(count).toBeGreaterThan(0)
expect(count).toBeLessThan(10) // Should be filtered, not showing all
// Verify first result contains "save"
const firstLabel = await items.first().locator('.item-label').textContent()
expect(firstLabel?.toLowerCase()).toContain('save')
})
test('Shows keybindings for commands that have them', async ({
comfyPage
}) => {
await comfyPage.doubleClickCanvas()
await comfyPage.searchBox.input.fill('>undo')
await comfyPage.searchBox.dropdown.waitFor({ state: 'visible' })
await comfyPage.page.waitForTimeout(500)
// Find the undo command
const undoItem = comfyPage.searchBox.dropdown
.locator('li')
.filter({ hasText: 'Undo' })
.first()
// Check if keybinding is shown (if configured)
const keybinding = undoItem.locator('.item-keybinding')
const keybindingCount = await keybinding.count()
// Keybinding might or might not be present depending on configuration
if (keybindingCount > 0) {
await expect(keybinding).toBeVisible()
}
})
test('Executes command on selection', async ({ comfyPage }) => {
await comfyPage.doubleClickCanvas()
await comfyPage.searchBox.input.fill('>new blank')
await comfyPage.searchBox.dropdown.waitFor({ state: 'visible' })
await comfyPage.page.waitForTimeout(500)
// Count nodes before
const nodesBefore = await comfyPage.page
.locator('.litegraph.litenode')
.count()
// Select the new blank workflow command
const newBlankItem = comfyPage.searchBox.dropdown
.locator('li')
.filter({ hasText: 'New Blank Workflow' })
.first()
await newBlankItem.click()
// Search box should close
await expect(comfyPage.searchBox.input).not.toBeVisible()
// Verify workflow was cleared (no nodes)
const nodesAfter = await comfyPage.page
.locator('.litegraph.litenode')
.count()
expect(nodesAfter).toBe(0)
})
test('Returns to node search when removing ">"', async ({ comfyPage }) => {
await comfyPage.doubleClickCanvas()
// Enter command mode
await comfyPage.searchBox.input.fill('>')
await expect(comfyPage.page.locator('.filter-button')).not.toBeVisible()
// Return to node search by filling with empty string to trigger search
await comfyPage.searchBox.input.fill('')
// Small wait for UI update
await comfyPage.page.waitForTimeout(200)
// Filter button should be visible again
await expect(comfyPage.page.locator('.filter-button')).toBeVisible()
// Placeholder should change back
await expect(comfyPage.searchBox.input).toHaveAttribute(
'placeholder',
expect.stringContaining('Search Nodes')
)
})
test('Command search is case insensitive', async ({ comfyPage }) => {
await comfyPage.doubleClickCanvas()
// Search with lowercase
await comfyPage.searchBox.input.fill('>SAVE')
await comfyPage.searchBox.dropdown.waitFor({ state: 'visible' })
await comfyPage.page.waitForTimeout(500)
// Should find save commands
const items = comfyPage.searchBox.dropdown.locator('li')
const count = await items.count()
expect(count).toBeGreaterThan(0)
// Verify it found save-related commands
const firstLabel = await items.first().locator('.item-label').textContent()
expect(firstLabel?.toLowerCase()).toContain('save')
})
})

View File

@@ -704,103 +704,4 @@ test.describe('Subgraph Operations', () => {
expect(finalCount).toBe(parentCount)
})
})
test.describe('Navigation Hotkeys', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.UseNewMenu', 'Top')
})
test('Navigation hotkey can be customized', async ({ comfyPage }) => {
await comfyPage.loadWorkflow('basic-subgraph')
await comfyPage.nextFrame()
// Change the Exit Subgraph keybinding from Escape to Alt+Q
await comfyPage.setSetting('Comfy.Keybinding.NewBindings', [
{
commandId: 'Comfy.Graph.ExitSubgraph',
combo: {
key: 'q',
ctrl: false,
alt: true,
shift: false
}
}
])
await comfyPage.setSetting('Comfy.Keybinding.UnsetBindings', [
{
commandId: 'Comfy.Graph.ExitSubgraph',
combo: {
key: 'Escape',
ctrl: false,
alt: false,
shift: false
}
}
])
// Reload the page
await comfyPage.page.reload()
await comfyPage.page.waitForTimeout(1024)
// Navigate into subgraph
const subgraphNode = await comfyPage.getNodeRefById('2')
await subgraphNode.navigateIntoSubgraph()
await comfyPage.page.waitForSelector(SELECTORS.breadcrumb)
// Verify we're in a subgraph
expect(await isInSubgraph(comfyPage)).toBe(true)
// Test that Escape no longer exits subgraph
await comfyPage.page.keyboard.press('Escape')
await comfyPage.nextFrame()
if (!(await isInSubgraph(comfyPage))) {
throw new Error('Not in subgraph')
}
// Test that Alt+Q now exits subgraph
await comfyPage.page.keyboard.press('Alt+q')
await comfyPage.nextFrame()
expect(await isInSubgraph(comfyPage)).toBe(false)
})
test('Escape prioritizes closing dialogs over exiting subgraph', async ({
comfyPage
}) => {
await comfyPage.loadWorkflow('basic-subgraph')
await comfyPage.nextFrame()
const subgraphNode = await comfyPage.getNodeRefById('2')
await subgraphNode.navigateIntoSubgraph()
await comfyPage.page.waitForSelector(SELECTORS.breadcrumb)
// Verify we're in a subgraph
if (!(await isInSubgraph(comfyPage))) {
throw new Error('Not in subgraph')
}
// Open settings dialog using hotkey
await comfyPage.page.keyboard.press('Control+,')
await comfyPage.page.waitForSelector('.settings-container', {
state: 'visible'
})
// Press Escape - should close dialog, not exit subgraph
await comfyPage.page.keyboard.press('Escape')
await comfyPage.nextFrame()
// Dialog should be closed
await expect(
comfyPage.page.locator('.settings-container')
).not.toBeVisible()
// Should still be in subgraph
expect(await isInSubgraph(comfyPage)).toBe(true)
// Press Escape again - now should exit subgraph
await comfyPage.page.keyboard.press('Escape')
await comfyPage.nextFrame()
expect(await isInSubgraph(comfyPage)).toBe(false)
})
})
})

View File

@@ -616,8 +616,7 @@ audio.comfy-audio.empty-audio-widget {
.comfy-load-3d canvas,
.comfy-load-3d-animation canvas,
.comfy-preview-3d canvas,
.comfy-preview-3d-animation canvas,
.comfy-load-3d-viewer canvas{
.comfy-preview-3d-animation canvas{
display: flex;
width: 100% !important;
height: 100% !important;

View File

@@ -20,7 +20,7 @@
>
<div class="shortcut-info flex-grow pr-4">
<div class="shortcut-name text-sm font-medium">
{{ command.label || command.id }}
{{ command.getTranslatedLabel() }}
</div>
</div>

View File

@@ -32,6 +32,7 @@
</template>
<script setup lang="ts">
import { useEventListener } from '@vueuse/core'
import Breadcrumb from 'primevue/breadcrumb'
import type { MenuItem } from 'primevue/menuitem'
import { computed, onUpdated, ref, watch } from 'vue'
@@ -97,6 +98,18 @@ const home = computed(() => ({
}
}))
// Escape exits from the current subgraph.
useEventListener(document, 'keydown', (event) => {
if (event.key === 'Escape') {
const canvas = useCanvasStore().getCanvas()
if (!canvas.graph) throw new TypeError('Canvas has no graph')
canvas.setGraph(
navigationStore.navigationStack.at(-2) ?? canvas.graph.rootGraph
)
}
})
// Check for overflow on breadcrumb items and collapse/expand the breadcrumb to fit
let overflowObserver: ReturnType<typeof useOverflowObserver> | undefined
watch(breadcrumbElement, (el) => {

View File

@@ -139,7 +139,6 @@ import Message from 'primevue/message'
import Tag from 'primevue/tag'
import { useToast } from 'primevue/usetoast'
import { computed, ref, watchEffect } from 'vue'
import { useI18n } from 'vue-i18n'
import SearchBox from '@/components/common/SearchBox.vue'
import { useKeybindingService } from '@/services/keybindingService'
@@ -149,7 +148,6 @@ import {
KeybindingImpl,
useKeybindingStore
} from '@/stores/keybindingStore'
import { normalizeI18nKey } from '@/utils/formatUtil'
import PanelTemplate from './PanelTemplate.vue'
import KeyComboDisplay from './keybinding/KeyComboDisplay.vue'
@@ -161,7 +159,6 @@ const filters = ref({
const keybindingStore = useKeybindingStore()
const keybindingService = useKeybindingService()
const commandStore = useCommandStore()
const { t } = useI18n()
interface ICommandData {
id: string
@@ -173,10 +170,7 @@ interface ICommandData {
const commandsData = computed<ICommandData[]>(() => {
return Object.values(commandStore.commands).map((command) => ({
id: command.id,
label: t(
`commands.${normalizeI18nKey(command.id)}.label`,
command.label ?? ''
),
label: command.getTranslatedLabel(),
keybinding: keybindingStore.getKeybindingByCommandId(command.id),
source: command.source
}))

View File

@@ -13,7 +13,6 @@
<BypassButton />
<PinButton />
<EditModelButton />
<Load3DViewerButton />
<MaskEditorButton />
<ConvertToSubgraphButton />
<DeleteButton />
@@ -39,7 +38,6 @@ import EditModelButton from '@/components/graph/selectionToolbox/EditModelButton
import ExecuteButton from '@/components/graph/selectionToolbox/ExecuteButton.vue'
import ExtensionCommandButton from '@/components/graph/selectionToolbox/ExtensionCommandButton.vue'
import HelpButton from '@/components/graph/selectionToolbox/HelpButton.vue'
import Load3DViewerButton from '@/components/graph/selectionToolbox/Load3DViewerButton.vue'
import MaskEditorButton from '@/components/graph/selectionToolbox/MaskEditorButton.vue'
import PinButton from '@/components/graph/selectionToolbox/PinButton.vue'
import RefreshSelectionButton from '@/components/graph/selectionToolbox/RefreshSelectionButton.vue'

View File

@@ -1,38 +0,0 @@
<template>
<Button
v-show="is3DNode"
v-tooltip.top="{
value: t('commands.Comfy_3DViewer_Open3DViewer.label'),
showDelay: 1000
}"
severity="secondary"
text
icon="pi pi-pencil"
@click="open3DViewer"
/>
</template>
<script setup lang="ts">
import Button from 'primevue/button'
import { computed } from 'vue'
import { t } from '@/i18n'
import { useCommandStore } from '@/stores/commandStore'
import { useCanvasStore } from '@/stores/graphStore'
import { useSettingStore } from '@/stores/settingStore'
import { isLGraphNode, isLoad3dNode } from '@/utils/litegraphUtil'
const commandStore = useCommandStore()
const canvasStore = useCanvasStore()
const is3DNode = computed(() => {
const enable3DViewer = useSettingStore().get('Comfy.Load3D.3DViewerEnable')
const nodes = canvasStore.selectedItems.filter(isLGraphNode)
return nodes.length === 1 && nodes.some(isLoad3dNode) && enable3DViewer
})
const open3DViewer = () => {
void commandStore.execute('Comfy.3DViewer.Open3DViewer')
}
</script>

View File

@@ -57,20 +57,9 @@
@update-edge-threshold="handleUpdateEdgeThreshold"
@export-model="handleExportModel"
/>
<div
v-if="enable3DViewer"
class="absolute top-12 right-2 z-20 pointer-events-auto"
>
<ViewerControls :node="node" />
</div>
<div
v-if="showRecordingControls"
class="absolute right-2 z-20 pointer-events-auto"
:class="{
'top-12': !enable3DViewer,
'top-24': enable3DViewer
}"
class="absolute top-12 right-2 z-20 pointer-events-auto"
>
<RecordingControls
:node="node"
@@ -93,7 +82,6 @@ import { useI18n } from 'vue-i18n'
import Load3DControls from '@/components/load3d/Load3DControls.vue'
import Load3DScene from '@/components/load3d/Load3DScene.vue'
import RecordingControls from '@/components/load3d/controls/RecordingControls.vue'
import ViewerControls from '@/components/load3d/controls/ViewerControls.vue'
import Load3dUtils from '@/extensions/core/load3d/Load3dUtils'
import {
CameraType,
@@ -103,7 +91,6 @@ import {
} from '@/extensions/core/load3d/interfaces'
import type { CustomInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
import type { ComponentWidget } from '@/scripts/domWidget'
import { useSettingStore } from '@/stores/settingStore'
import { useToastStore } from '@/stores/toastStore'
const { t } = useI18n()
@@ -134,9 +121,6 @@ const isRecording = ref(false)
const hasRecording = ref(false)
const recordingDuration = ref(0)
const showRecordingControls = ref(!inputSpec.isPreview)
const enable3DViewer = computed(() =>
useSettingStore().get('Comfy.Load3D.3DViewerEnable')
)
const showPreviewButton = computed(() => {
return !type.includes('Preview')

View File

@@ -1,149 +0,0 @@
<template>
<div
ref="viewerContentRef"
class="flex w-full"
:class="[maximized ? 'h-full' : 'h-[70vh]']"
@mouseenter="viewer.handleMouseEnter"
@mouseleave="viewer.handleMouseLeave"
>
<div ref="mainContentRef" class="flex-1 relative">
<div
ref="containerRef"
class="absolute w-full h-full comfy-load-3d-viewer"
@resize="viewer.handleResize"
/>
</div>
<div class="w-72 flex flex-col">
<div class="flex-1 overflow-y-auto p-4">
<div class="space-y-2">
<div class="p-2 space-y-4">
<SceneControls
v-model:background-color="viewer.backgroundColor.value"
v-model:show-grid="viewer.showGrid.value"
:has-background-image="viewer.hasBackgroundImage.value"
@update-background-image="viewer.handleBackgroundImageUpdate"
/>
</div>
<div class="p-2 space-y-4">
<ModelControls
v-model:up-direction="viewer.upDirection.value"
v-model:material-mode="viewer.materialMode.value"
/>
</div>
<div class="p-2 space-y-4">
<CameraControls
v-model:camera-type="viewer.cameraType.value"
v-model:fov="viewer.fov.value"
/>
</div>
<div class="p-2 space-y-4">
<LightControls
v-model:light-intensity="viewer.lightIntensity.value"
/>
</div>
<div class="p-2 space-y-4">
<ExportControls @export-model="viewer.exportModel" />
</div>
</div>
</div>
<div class="p-4">
<div class="flex gap-2">
<Button
icon="pi pi-times"
severity="secondary"
:label="t('g.cancel')"
@click="handleCancel"
/>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import Button from 'primevue/button'
import { onBeforeUnmount, onMounted, ref, toRaw } from 'vue'
import CameraControls from '@/components/load3d/controls/viewer/CameraControls.vue'
import ExportControls from '@/components/load3d/controls/viewer/ExportControls.vue'
import LightControls from '@/components/load3d/controls/viewer/LightControls.vue'
import ModelControls from '@/components/load3d/controls/viewer/ModelControls.vue'
import SceneControls from '@/components/load3d/controls/viewer/SceneControls.vue'
import { t } from '@/i18n'
import { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import { useLoad3dService } from '@/services/load3dService'
import { useDialogStore } from '@/stores/dialogStore'
const props = defineProps<{
node: LGraphNode
}>()
const viewerContentRef = ref<HTMLDivElement>()
const containerRef = ref<HTMLDivElement>()
const mainContentRef = ref<HTMLDivElement>()
const maximized = ref(false)
const mutationObserver = ref<MutationObserver | null>(null)
const viewer = useLoad3dService().getOrCreateViewer(toRaw(props.node))
onMounted(async () => {
const source = useLoad3dService().getLoad3d(props.node)
if (source && containerRef.value) {
await viewer.initializeViewer(containerRef.value, source)
}
if (viewerContentRef.value) {
mutationObserver.value = new MutationObserver((mutations) => {
mutations.forEach((mutation) => {
if (
mutation.type === 'attributes' &&
mutation.attributeName === 'maximized'
) {
maximized.value =
(mutation.target as HTMLElement).getAttribute('maximized') ===
'true'
setTimeout(() => {
viewer.refreshViewport()
}, 0)
}
})
})
mutationObserver.value.observe(viewerContentRef.value, {
attributes: true,
attributeFilter: ['maximized']
})
}
window.addEventListener('resize', viewer.handleResize)
})
const handleCancel = () => {
viewer.restoreInitialState()
useDialogStore().closeDialog()
}
onBeforeUnmount(() => {
window.removeEventListener('resize', viewer.handleResize)
if (mutationObserver.value) {
mutationObserver.value.disconnect()
mutationObserver.value = null
}
// we will manually cleanup the viewer in dialog close handler
})
</script>
<style scoped>
:deep(.p-panel-content) {
padding: 0;
}
</style>

View File

@@ -1,52 +0,0 @@
<template>
<div class="relative bg-gray-700 bg-opacity-30 rounded-lg">
<div class="flex flex-col gap-2">
<Button class="p-button-rounded p-button-text" @click="openIn3DViewer">
<i
v-tooltip.right="{
value: t('load3d.openIn3DViewer'),
showDelay: 300
}"
class="pi pi-expand text-white text-lg"
/>
</Button>
</div>
</div>
</template>
<script setup lang="ts">
import { Tooltip } from 'primevue'
import Button from 'primevue/button'
import Load3DViewerContent from '@/components/load3d/Load3dViewerContent.vue'
import { t } from '@/i18n'
import { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import { useLoad3dService } from '@/services/load3dService'
import { useDialogStore } from '@/stores/dialogStore'
const vTooltip = Tooltip
const { node } = defineProps<{
node: LGraphNode
}>()
const openIn3DViewer = () => {
const props = { node: node }
useDialogStore().showDialog({
key: 'global-load3d-viewer',
title: t('load3d.viewer.title'),
component: Load3DViewerContent,
props: props,
dialogComponentProps: {
style: 'width: 80vw; height: 80vh;',
maximizable: true,
onClose: async () => {
await useLoad3dService().handleViewerClose(props.node)
}
}
})
}
</script>
<style scoped></style>

View File

@@ -1,37 +0,0 @@
<template>
<div class="space-y-4">
<label>
{{ t('load3d.viewer.cameraType') }}
</label>
<Select
v-model="cameraType"
:options="cameras"
option-label="title"
option-value="value"
>
</Select>
</div>
<div v-if="showFOVButton" class="space-y-4">
<label>{{ t('load3d.fov') }}</label>
<Slider v-model="fov" :min="10" :max="150" :step="1" aria-label="fov" />
</div>
</template>
<script setup lang="ts">
import Select from 'primevue/select'
import Slider from 'primevue/slider'
import { computed } from 'vue'
import { CameraType } from '@/extensions/core/load3d/interfaces'
import { t } from '@/i18n'
const cameras = [
{ title: t('load3d.cameraType.perspective'), value: 'perspective' },
{ title: t('load3d.cameraType.orthographic'), value: 'orthographic' }
]
const cameraType = defineModel<CameraType>('cameraType')
const fov = defineModel<number>('fov')
const showFOVButton = computed(() => cameraType.value === 'perspective')
</script>

View File

@@ -1,37 +0,0 @@
<template>
<Select
v-model="exportFormat"
:options="exportFormats"
option-label="label"
option-value="value"
>
</Select>
<Button severity="secondary" text rounded @click="exportModel(exportFormat)">
{{ t('load3d.export') }}
</Button>
</template>
<script setup lang="ts">
import Button from 'primevue/button'
import Select from 'primevue/select'
import { ref } from 'vue'
import { t } from '@/i18n'
const emit = defineEmits<{
(e: 'exportModel', format: string): void
}>()
const exportFormats = [
{ label: 'GLB', value: 'glb' },
{ label: 'OBJ', value: 'obj' },
{ label: 'STL', value: 'stl' }
]
const exportFormat = ref('obj')
const exportModel = (format: string) => {
emit('exportModel', format)
}
</script>

View File

@@ -1,30 +0,0 @@
<template>
<label>{{ t('load3d.lightIntensity') }}</label>
<Slider
v-model="lightIntensity"
class="w-full"
:min="lightIntensityMinimum"
:max="lightIntensityMaximum"
:step="lightAdjustmentIncrement"
/>
</template>
<script setup lang="ts">
import Slider from 'primevue/slider'
import { t } from '@/i18n'
import { useSettingStore } from '@/stores/settingStore'
const lightIntensity = defineModel<number>('lightIntensity')
const lightIntensityMaximum = useSettingStore().get(
'Comfy.Load3D.LightIntensityMaximum'
)
const lightIntensityMinimum = useSettingStore().get(
'Comfy.Load3D.LightIntensityMinimum'
)
const lightAdjustmentIncrement = useSettingStore().get(
'Comfy.Load3D.LightAdjustmentIncrement'
)
</script>

View File

@@ -1,52 +0,0 @@
<template>
<div class="space-y-4">
<div>
<label>{{ t('load3d.upDirection') }}</label>
<Select
v-model="upDirection"
:options="upDirectionOptions"
option-label="label"
option-value="value"
/>
</div>
<div>
<label>{{ t('load3d.materialMode') }}</label>
<Select
v-model="materialMode"
:options="materialModeOptions"
option-label="label"
option-value="value"
/>
</div>
</div>
</template>
<script setup lang="ts">
import Select from 'primevue/select'
import { computed } from 'vue'
import { MaterialMode, UpDirection } from '@/extensions/core/load3d/interfaces'
import { t } from '@/i18n'
const upDirection = defineModel<UpDirection>('upDirection')
const materialMode = defineModel<MaterialMode>('materialMode')
const upDirectionOptions = [
{ label: t('load3d.upDirections.original'), value: 'original' },
{ label: '-X', value: '-x' },
{ label: '+X', value: '+x' },
{ label: '-Y', value: '-y' },
{ label: '+Y', value: '+y' },
{ label: '-Z', value: '-z' },
{ label: '+Z', value: '+z' }
]
const materialModeOptions = computed(() => {
return [
{ label: t('load3d.materialModes.original'), value: 'original' },
{ label: t('load3d.materialModes.normal'), value: 'normal' },
{ label: t('load3d.materialModes.wireframe'), value: 'wireframe' }
]
})
</script>

View File

@@ -1,82 +0,0 @@
<template>
<div class="space-y-4">
<div v-if="!hasBackgroundImage">
<label>
{{ t('load3d.backgroundColor') }}
</label>
<input v-model="backgroundColor" type="color" class="w-full" />
</div>
<div>
<Checkbox v-model="showGrid" input-id="showGrid" binary name="showGrid" />
<label for="showGrid" class="pl-2">
{{ t('load3d.showGrid') }}
</label>
</div>
<div v-if="!hasBackgroundImage">
<Button
severity="secondary"
:label="t('load3d.uploadBackgroundImage')"
icon="pi pi-image"
class="w-full"
@click="openImagePicker"
/>
<input
ref="imagePickerRef"
type="file"
accept="image/*"
class="hidden"
@change="handleImageUpload"
/>
</div>
<div v-if="hasBackgroundImage" class="space-y-2">
<Button
severity="secondary"
:label="t('load3d.removeBackgroundImage')"
icon="pi pi-times"
class="w-full"
@click="removeBackgroundImage"
/>
</div>
</div>
</template>
<script setup lang="ts">
import Button from 'primevue/button'
import Checkbox from 'primevue/checkbox'
import { ref } from 'vue'
import { t } from '@/i18n'
const backgroundColor = defineModel<string>('backgroundColor')
const showGrid = defineModel<boolean>('showGrid')
defineProps<{
hasBackgroundImage?: boolean
}>()
const emit = defineEmits<{
(e: 'updateBackgroundImage', file: File | null): void
}>()
const imagePickerRef = ref<HTMLInputElement | null>(null)
const openImagePicker = () => {
imagePickerRef.value?.click()
}
const handleImageUpload = (event: Event) => {
const input = event.target as HTMLInputElement
if (input.files && input.files[0]) {
emit('updateBackgroundImage', input.files[0])
}
input.value = ''
}
const removeBackgroundImage = () => {
emit('updateBackgroundImage', null)
}
</script>

View File

@@ -0,0 +1,50 @@
<template>
<div class="flex items-center gap-3 px-3 py-2 w-full">
<span
class="flex-shrink-0 w-5 text-center text-muted item-icon"
:class="command.icon ?? 'pi pi-chevron-right'"
/>
<span
class="flex-grow overflow-hidden text-ellipsis whitespace-nowrap item-label"
>
<span
v-html="highlightQuery(command.getTranslatedLabel(), currentQuery)"
/>
</span>
<span
v-if="command.keybinding"
class="flex-shrink-0 text-xs px-1.5 py-0.5 border rounded font-mono keybinding-badge"
>
{{ command.keybinding.combo.toString() }}
</span>
</div>
</template>
<script setup lang="ts">
import type { ComfyCommandImpl } from '@/stores/commandStore'
import { highlightQuery } from '@/utils/formatUtil'
const { command, currentQuery } = defineProps<{
command: ComfyCommandImpl
currentQuery: string
}>()
</script>
<style scoped>
:deep(.highlight) {
background-color: var(--p-primary-color);
color: var(--p-primary-contrast-color);
font-weight: bold;
border-radius: 0.25rem;
padding: 0 0.125rem;
margin: -0.125rem 0.125rem;
}
.keybinding-badge {
border-color: var(--p-content-border-color);
background-color: var(--p-content-hover-background);
color: var(--p-text-muted-color);
}
</style>

View File

@@ -3,7 +3,7 @@
class="comfy-vue-node-search-container flex justify-center items-center w-full min-w-96"
>
<div
v-if="enableNodePreview"
v-if="enableNodePreview && !isCommandMode"
class="comfy-vue-node-preview-container absolute left-[-350px] top-[50px]"
>
<NodePreview
@@ -14,6 +14,7 @@
</div>
<Button
v-if="!isCommandMode"
icon="pi pi-filter"
severity="secondary"
class="filter-button z-10"
@@ -49,13 +50,24 @@
auto-option-focus
force-selection
multiple
:option-label="'display_name'"
:option-label="getOptionLabel"
@complete="search($event.query)"
@option-select="emit('addNode', $event.value)"
@option-select="onOptionSelect($event.value)"
@focused-option-changed="setHoverSuggestion($event)"
@input="handleInput"
>
<template #option="{ option }">
<NodeSearchItem :node-def="option" :current-query="currentQuery" />
<!-- Command search item, Remove the '>' prefix from the query -->
<CommandSearchItem
v-if="isCommandMode"
:command="option"
:current-query="currentQuery.substring(1)"
/>
<NodeSearchItem
v-else
:node-def="option"
:current-query="currentQuery"
/>
</template>
<!-- FilterAndValue -->
<template #chip="{ value }">
@@ -80,13 +92,16 @@
<script setup lang="ts">
import Button from 'primevue/button'
import Dialog from 'primevue/dialog'
import { computed, nextTick, onMounted, ref } from 'vue'
import { computed, nextTick, onMounted, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import NodePreview from '@/components/node/NodePreview.vue'
import AutoCompletePlus from '@/components/primevueOverride/AutoCompletePlus.vue'
import CommandSearchItem from '@/components/searchbox/CommandSearchItem.vue'
import NodeSearchFilter from '@/components/searchbox/NodeSearchFilter.vue'
import NodeSearchItem from '@/components/searchbox/NodeSearchItem.vue'
import { CommandSearchService } from '@/services/commandSearchService'
import { type ComfyCommandImpl, useCommandStore } from '@/stores/commandStore'
import {
ComfyNodeDefImpl,
useNodeDefStore,
@@ -99,6 +114,7 @@ import SearchFilterChip from '../common/SearchFilterChip.vue'
const settingStore = useSettingStore()
const { t } = useI18n()
const commandStore = useCommandStore()
const enableNodePreview = computed(() =>
settingStore.get('Comfy.NodeSearchBoxImpl.NodePreview')
@@ -111,18 +127,50 @@ const { filters, searchLimit = 64 } = defineProps<{
const nodeSearchFilterVisible = ref(false)
const inputId = `comfy-vue-node-search-box-input-${Math.random()}`
const suggestions = ref<ComfyNodeDefImpl[]>([])
const suggestions = ref<ComfyNodeDefImpl[] | ComfyCommandImpl[]>([])
const hoveredSuggestion = ref<ComfyNodeDefImpl | null>(null)
const currentQuery = ref('')
const isCommandMode = ref(false)
// Initialize command search service
const commandSearchService = ref<CommandSearchService | null>(null)
const placeholder = computed(() => {
if (isCommandMode.value) {
return t('g.searchCommands', 'Search commands') + '...'
}
return filters.length === 0 ? t('g.searchNodes') + '...' : ''
})
const nodeDefStore = useNodeDefStore()
const nodeFrequencyStore = useNodeFrequencyStore()
// Initialize command search service with commands
watch(
() => commandStore.commands,
(commands) => {
commandSearchService.value = new CommandSearchService(commands)
},
{ immediate: true }
)
const search = (query: string) => {
const queryIsEmpty = query === '' && filters.length === 0
currentQuery.value = query
// Check if we're in command mode (query starts with ">")
if (query.startsWith('>')) {
isCommandMode.value = true
if (commandSearchService.value) {
suggestions.value = commandSearchService.value.searchCommands(query, {
limit: searchLimit
})
}
return
}
// Normal node search mode
isCommandMode.value = false
const queryIsEmpty = query === '' && filters.length === 0
suggestions.value = queryIsEmpty
? nodeFrequencyStore.topNodeDefs
: [
@@ -132,7 +180,18 @@ const search = (query: string) => {
]
}
const emit = defineEmits(['addFilter', 'removeFilter', 'addNode'])
const emit = defineEmits<{
(
e: 'addFilter',
filterAndValue: FuseFilterWithValue<ComfyNodeDefImpl, string>
): void
(
e: 'removeFilter',
filterAndValue: FuseFilterWithValue<ComfyNodeDefImpl, string>
): void
(e: 'addNode', nodeDef: ComfyNodeDefImpl): void
(e: 'executeCommand', command: ComfyCommandImpl): void
}>()
let inputElement: HTMLInputElement | null = null
const reFocusInput = async () => {
@@ -160,11 +219,47 @@ const onRemoveFilter = async (
await reFocusInput()
}
const setHoverSuggestion = (index: number) => {
if (index === -1) {
if (index === -1 || isCommandMode.value) {
hoveredSuggestion.value = null
return
}
const value = suggestions.value[index]
const value = suggestions.value[index] as ComfyNodeDefImpl
hoveredSuggestion.value = value
}
const onOptionSelect = (option: ComfyNodeDefImpl | ComfyCommandImpl) => {
if (isCommandMode.value) {
emit('executeCommand', option as ComfyCommandImpl)
} else {
emit('addNode', option as ComfyNodeDefImpl)
}
}
const getOptionLabel = (
option: ComfyNodeDefImpl | ComfyCommandImpl
): string => {
if ('display_name' in option) {
return option.display_name
}
return option.label || option.id
}
/**
* Handles direct input changes on the AutoCompletePlus component.
* This ensures search mode switching works properly when users clear the input
* or modify it directly, as the @complete event may not always trigger.
*
* @param event - The input event from the AutoCompletePlus component
* @note Known issue on empty input complete state:
* https://github.com/Comfy-Org/ComfyUI_frontend/issues/4887
*/
const handleInput = (event: Event) => {
const target = event.target as HTMLInputElement
const inputValue = target.value
// Trigger search to handle mode switching between node and command search
if (inputValue === '') {
search('')
}
}
</script>

View File

@@ -26,6 +26,7 @@
@add-filter="addFilter"
@remove-filter="removeFilter"
@add-node="addNode"
@execute-command="executeCommand"
/>
</template>
</Dialog>
@@ -46,6 +47,7 @@ import {
} from '@/lib/litegraph/src/litegraph'
import type { CanvasPointerEvent } from '@/lib/litegraph/src/types/events'
import { useLitegraphService } from '@/services/litegraphService'
import { type ComfyCommandImpl, useCommandStore } from '@/stores/commandStore'
import { useCanvasStore } from '@/stores/graphStore'
import { ComfyNodeDefImpl, useNodeDefStore } from '@/stores/nodeDefStore'
import { useSettingStore } from '@/stores/settingStore'
@@ -62,6 +64,7 @@ let disconnectOnReset = false
const settingStore = useSettingStore()
const litegraphService = useLitegraphService()
const commandStore = useCommandStore()
const { visible } = storeToRefs(useSearchBoxStore())
const dismissable = ref(true)
@@ -109,6 +112,14 @@ const addNode = (nodeDef: ComfyNodeDefImpl) => {
window.requestAnimationFrame(closeDialog)
}
const executeCommand = async (command: ComfyCommandImpl) => {
// Close the dialog immediately
closeDialog()
// Execute the command
await commandStore.execute(command.id)
}
const newSearchBoxEnabled = computed(
() => settingStore.get('Comfy.NodeSearchBoxImpl') === 'default'
)

View File

@@ -23,7 +23,6 @@ import { useExecutionStore } from '@/stores/executionStore'
import { useCanvasStore, useTitleEditorStore } from '@/stores/graphStore'
import { useQueueSettingsStore, useQueueStore } from '@/stores/queueStore'
import { useSettingStore } from '@/stores/settingStore'
import { useSubgraphNavigationStore } from '@/stores/subgraphNavigationStore'
import { useToastStore } from '@/stores/toastStore'
import { type ComfyWorkflow, useWorkflowStore } from '@/stores/workflowStore'
import { useBottomPanelStore } from '@/stores/workspace/bottomPanelStore'
@@ -806,21 +805,6 @@ export function useCoreCommands(): ComfyCommand[] {
function: () => {
bottomPanelStore.togglePanel('shortcuts')
}
},
{
id: 'Comfy.Graph.ExitSubgraph',
icon: 'pi pi-arrow-up',
label: 'Exit Subgraph',
versionAdded: '1.20.1',
function: () => {
const canvas = useCanvasStore().getCanvas()
const navigationStore = useSubgraphNavigationStore()
if (!canvas.graph) return
canvas.setGraph(
navigationStore.navigationStack.at(-2) ?? canvas.graph.rootGraph
)
}
}
]

View File

@@ -1,376 +0,0 @@
import { ref, toRaw, watch } from 'vue'
import Load3d from '@/extensions/core/load3d/Load3d'
import Load3dUtils from '@/extensions/core/load3d/Load3dUtils'
import {
CameraType,
MaterialMode,
UpDirection
} from '@/extensions/core/load3d/interfaces'
import { t } from '@/i18n'
import { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import { useLoad3dService } from '@/services/load3dService'
import { useToastStore } from '@/stores/toastStore'
interface Load3dViewerState {
backgroundColor: string
showGrid: boolean
cameraType: CameraType
fov: number
lightIntensity: number
cameraState: any
backgroundImage: string
upDirection: UpDirection
materialMode: MaterialMode
edgeThreshold: number
}
export const useLoad3dViewer = (node: LGraphNode) => {
const backgroundColor = ref('')
const showGrid = ref(true)
const cameraType = ref<CameraType>('perspective')
const fov = ref(75)
const lightIntensity = ref(1)
const backgroundImage = ref('')
const hasBackgroundImage = ref(false)
const upDirection = ref<UpDirection>('original')
const materialMode = ref<MaterialMode>('original')
const edgeThreshold = ref(85)
const needApplyChanges = ref(true)
let load3d: Load3d | null = null
let sourceLoad3d: Load3d | null = null
const initialState = ref<Load3dViewerState>({
backgroundColor: '#282828',
showGrid: true,
cameraType: 'perspective',
fov: 75,
lightIntensity: 1,
cameraState: null,
backgroundImage: '',
upDirection: 'original',
materialMode: 'original',
edgeThreshold: 85
})
watch(backgroundColor, (newColor) => {
if (!load3d) return
try {
load3d.setBackgroundColor(newColor)
} catch (error) {
console.error('Error updating background color:', error)
useToastStore().addAlert(
t('toastMessages.failedToUpdateBackgroundColor', { color: newColor })
)
}
})
watch(showGrid, (newValue) => {
if (!load3d) return
try {
load3d.toggleGrid(newValue)
} catch (error) {
console.error('Error toggling grid:', error)
useToastStore().addAlert(
t('toastMessages.failedToToggleGrid', { show: newValue ? 'on' : 'off' })
)
}
})
watch(cameraType, (newCameraType) => {
if (!load3d) return
try {
load3d.toggleCamera(newCameraType)
} catch (error) {
console.error('Error toggling camera:', error)
useToastStore().addAlert(
t('toastMessages.failedToToggleCamera', { camera: newCameraType })
)
}
})
watch(fov, (newFov) => {
if (!load3d) return
try {
load3d.setFOV(Number(newFov))
} catch (error) {
console.error('Error updating FOV:', error)
useToastStore().addAlert(
t('toastMessages.failedToUpdateFOV', { fov: newFov })
)
}
})
watch(lightIntensity, (newValue) => {
if (!load3d) return
try {
load3d.setLightIntensity(Number(newValue))
} catch (error) {
console.error('Error updating light intensity:', error)
useToastStore().addAlert(
t('toastMessages.failedToUpdateLightIntensity', { intensity: newValue })
)
}
})
watch(backgroundImage, async (newValue) => {
if (!load3d) return
try {
await load3d.setBackgroundImage(newValue)
hasBackgroundImage.value = !!newValue
} catch (error) {
console.error('Error updating background image:', error)
useToastStore().addAlert(t('toastMessages.failedToUpdateBackgroundImage'))
}
})
watch(upDirection, (newValue) => {
if (!load3d) return
try {
load3d.setUpDirection(newValue)
} catch (error) {
console.error('Error updating up direction:', error)
useToastStore().addAlert(
t('toastMessages.failedToUpdateUpDirection', { direction: newValue })
)
}
})
watch(materialMode, (newValue) => {
if (!load3d) return
try {
load3d.setMaterialMode(newValue)
} catch (error) {
console.error('Error updating material mode:', error)
useToastStore().addAlert(
t('toastMessages.failedToUpdateMaterialMode', { mode: newValue })
)
}
})
watch(edgeThreshold, (newValue) => {
if (!load3d) return
try {
load3d.setEdgeThreshold(Number(newValue))
} catch (error) {
console.error('Error updating edge threshold:', error)
useToastStore().addAlert(
t('toastMessages.failedToUpdateEdgeThreshold', { threshold: newValue })
)
}
})
const initializeViewer = async (
containerRef: HTMLElement,
source: Load3d
) => {
if (!containerRef) return
sourceLoad3d = source
try {
load3d = new Load3d(containerRef, {
node: node,
disablePreview: true,
isViewerMode: true
})
await useLoad3dService().copyLoad3dState(source, load3d)
const sourceCameraType = source.getCurrentCameraType()
const sourceCameraState = source.getCameraState()
cameraType.value = sourceCameraType
backgroundColor.value = source.sceneManager.currentBackgroundColor
showGrid.value = source.sceneManager.gridHelper.visible
lightIntensity.value = (node.properties['Light Intensity'] as number) || 1
const backgroundInfo = source.sceneManager.getCurrentBackgroundInfo()
if (
backgroundInfo.type === 'image' &&
node.properties['Background Image']
) {
backgroundImage.value = node.properties['Background Image'] as string
hasBackgroundImage.value = true
} else {
backgroundImage.value = ''
hasBackgroundImage.value = false
}
if (sourceCameraType === 'perspective') {
fov.value = source.cameraManager.perspectiveCamera.fov
}
upDirection.value = source.modelManager.currentUpDirection
materialMode.value = source.modelManager.materialMode
edgeThreshold.value = (node.properties['Edge Threshold'] as number) || 85
initialState.value = {
backgroundColor: backgroundColor.value,
showGrid: showGrid.value,
cameraType: cameraType.value,
fov: fov.value,
lightIntensity: lightIntensity.value,
cameraState: sourceCameraState,
backgroundImage: backgroundImage.value,
upDirection: upDirection.value,
materialMode: materialMode.value,
edgeThreshold: edgeThreshold.value
}
const width = node.widgets?.find((w) => w.name === 'width')
const height = node.widgets?.find((w) => w.name === 'height')
if (width && height) {
load3d.setTargetSize(
toRaw(width).value as number,
toRaw(height).value as number
)
}
} catch (error) {
console.error('Error initializing Load3d viewer:', error)
useToastStore().addAlert(
t('toastMessages.failedToInitializeLoad3dViewer')
)
}
}
const exportModel = async (format: string) => {
if (!load3d) return
try {
await load3d.exportModel(format)
} catch (error) {
console.error('Error exporting model:', error)
useToastStore().addAlert(
t('toastMessages.failedToExportModel', { format: format.toUpperCase() })
)
}
}
const handleResize = () => {
load3d?.handleResize()
}
const handleMouseEnter = () => {
load3d?.updateStatusMouseOnViewer(true)
}
const handleMouseLeave = () => {
load3d?.updateStatusMouseOnViewer(false)
}
const restoreInitialState = () => {
const nodeValue = node
needApplyChanges.value = false
if (nodeValue.properties) {
nodeValue.properties['Background Color'] =
initialState.value.backgroundColor
nodeValue.properties['Show Grid'] = initialState.value.showGrid
nodeValue.properties['Camera Type'] = initialState.value.cameraType
nodeValue.properties['FOV'] = initialState.value.fov
nodeValue.properties['Light Intensity'] =
initialState.value.lightIntensity
nodeValue.properties['Camera Info'] = initialState.value.cameraState
nodeValue.properties['Background Image'] =
initialState.value.backgroundImage
}
}
const applyChanges = async () => {
if (!sourceLoad3d || !load3d) return false
const viewerCameraState = load3d.getCameraState()
const nodeValue = node
if (nodeValue.properties) {
nodeValue.properties['Background Color'] = backgroundColor.value
nodeValue.properties['Show Grid'] = showGrid.value
nodeValue.properties['Camera Type'] = cameraType.value
nodeValue.properties['FOV'] = fov.value
nodeValue.properties['Light Intensity'] = lightIntensity.value
nodeValue.properties['Camera Info'] = viewerCameraState
nodeValue.properties['Background Image'] = backgroundImage.value
}
await useLoad3dService().copyLoad3dState(load3d, sourceLoad3d)
if (backgroundImage.value) {
await sourceLoad3d.setBackgroundImage(backgroundImage.value)
}
sourceLoad3d.forceRender()
if (nodeValue.graph) {
nodeValue.graph.setDirtyCanvas(true, true)
}
return true
}
const refreshViewport = () => {
useLoad3dService().handleViewportRefresh(load3d)
}
const handleBackgroundImageUpdate = async (file: File | null) => {
if (!file) {
backgroundImage.value = ''
hasBackgroundImage.value = false
return
}
try {
const resourceFolder =
(node.properties['Resource Folder'] as string) || ''
const subfolder = resourceFolder.trim()
? `3d/${resourceFolder.trim()}`
: '3d'
const uploadPath = await Load3dUtils.uploadFile(file, subfolder)
if (uploadPath) {
backgroundImage.value = uploadPath
hasBackgroundImage.value = true
}
} catch (error) {
console.error('Error uploading background image:', error)
useToastStore().addAlert(t('toastMessages.failedToUploadBackgroundImage'))
}
}
const cleanup = () => {
load3d?.remove()
load3d = null
sourceLoad3d = null
}
return {
// State
backgroundColor,
showGrid,
cameraType,
fov,
lightIntensity,
backgroundImage,
hasBackgroundImage,
upDirection,
materialMode,
edgeThreshold,
needApplyChanges,
// Methods
initializeViewer,
exportModel,
handleResize,
handleMouseEnter,
handleMouseLeave,
restoreInitialState,
applyChanges,
refreshViewport,
handleBackgroundImageUpdate,
cleanup
}
}

View File

@@ -190,11 +190,5 @@ export const CORE_KEYBINDINGS: Keybinding[] = [
key: 'k'
},
commandId: 'Workspace.ToggleBottomPanel.Shortcuts'
},
{
combo: {
key: 'Escape'
},
commandId: 'Comfy.Graph.ExitSubgraph'
}
]

View File

@@ -2,7 +2,6 @@ import { nextTick } from 'vue'
import Load3D from '@/components/load3d/Load3D.vue'
import Load3DAnimation from '@/components/load3d/Load3DAnimation.vue'
import Load3DViewerContent from '@/components/load3d/Load3dViewerContent.vue'
import Load3DConfiguration from '@/extensions/core/load3d/Load3DConfiguration'
import Load3dAnimation from '@/extensions/core/load3d/Load3dAnimation'
import Load3dUtils from '@/extensions/core/load3d/Load3dUtils'
@@ -10,13 +9,10 @@ import { t } from '@/i18n'
import type { IStringWidget } from '@/lib/litegraph/src/types/widgets'
import { CustomInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
import { api } from '@/scripts/api'
import { ComfyApp, app } from '@/scripts/app'
import { ComponentWidgetImpl, addWidget } from '@/scripts/domWidget'
import { useExtensionService } from '@/services/extensionService'
import { useLoad3dService } from '@/services/load3dService'
import { useDialogStore } from '@/stores/dialogStore'
import { useToastStore } from '@/stores/toastStore'
import { isLoad3dNode } from '@/utils/litegraphUtil'
async function handleModelUpload(files: FileList, node: any) {
if (!files?.length) return
@@ -178,51 +174,6 @@ useExtensionService().registerExtension({
},
defaultValue: 0.5,
experimental: true
},
{
id: 'Comfy.Load3D.3DViewerEnable',
category: ['3D', '3DViewer', 'Enable'],
name: 'Enable 3D Viewer (Beta)',
tooltip:
'Enables the 3D Viewer (Beta) for selected nodes. This feature allows you to visualize and interact with 3D models directly within the full size 3d viewer.',
type: 'boolean',
defaultValue: false,
experimental: true
}
],
commands: [
{
id: 'Comfy.3DViewer.Open3DViewer',
icon: 'pi pi-pencil',
label: 'Open 3D Viewer (Beta) for Selected Node',
function: () => {
const selectedNodes = app.canvas.selected_nodes
if (!selectedNodes || Object.keys(selectedNodes).length !== 1) return
const selectedNode = selectedNodes[Object.keys(selectedNodes)[0]]
if (!isLoad3dNode(selectedNode)) return
ComfyApp.copyToClipspace(selectedNode)
// @ts-expect-error clipspace_return_node is an extension property added at runtime
ComfyApp.clipspace_return_node = selectedNode
const props = { node: selectedNode }
useDialogStore().showDialog({
key: 'global-load3d-viewer',
title: t('load3d.viewer.title'),
component: Load3DViewerContent,
props: props,
dialogComponentProps: {
style: 'width: 80vw; height: 80vh;',
maximizable: true,
onClose: async () => {
await useLoad3dService().handleViewerClose(props.node)
}
}
})
}
}
],
getCustomWidgets() {

View File

@@ -179,16 +179,12 @@ export class CameraManager implements CameraManagerInterface {
}
handleResize(width: number, height: number): void {
const aspect = width / height
this.updateAspectRatio(aspect)
}
updateAspectRatio(aspect: number): void {
if (this.activeCamera === this.perspectiveCamera) {
this.perspectiveCamera.aspect = aspect
this.perspectiveCamera.aspect = width / height
this.perspectiveCamera.updateProjectionMatrix()
} else {
const frustumSize = 10
const aspect = width / height
this.orthographicCamera.left = (-frustumSize * aspect) / 2
this.orthographicCamera.right = (frustumSize * aspect) / 2
this.orthographicCamera.top = frustumSize / 2

View File

@@ -9,11 +9,11 @@ import { EventManager } from './EventManager'
import { LightingManager } from './LightingManager'
import { LoaderManager } from './LoaderManager'
import { ModelExporter } from './ModelExporter'
import { ModelManager } from './ModelManager'
import { NodeStorage } from './NodeStorage'
import { PreviewManager } from './PreviewManager'
import { RecordingManager } from './RecordingManager'
import { SceneManager } from './SceneManager'
import { SceneModelManager } from './SceneModelManager'
import { ViewHelperManager } from './ViewHelperManager'
import {
CameraState,
@@ -29,28 +29,22 @@ class Load3d {
protected animationFrameId: number | null = null
node: LGraphNode
eventManager: EventManager
nodeStorage: NodeStorage
sceneManager: SceneManager
cameraManager: CameraManager
controlsManager: ControlsManager
lightingManager: LightingManager
viewHelperManager: ViewHelperManager
previewManager: PreviewManager
loaderManager: LoaderManager
modelManager: SceneModelManager
recordingManager: RecordingManager
protected eventManager: EventManager
protected nodeStorage: NodeStorage
protected sceneManager: SceneManager
protected cameraManager: CameraManager
protected controlsManager: ControlsManager
protected lightingManager: LightingManager
protected viewHelperManager: ViewHelperManager
protected previewManager: PreviewManager
protected loaderManager: LoaderManager
protected modelManager: ModelManager
protected recordingManager: RecordingManager
STATUS_MOUSE_ON_NODE: boolean
STATUS_MOUSE_ON_SCENE: boolean
STATUS_MOUSE_ON_VIEWER: boolean
INITIAL_RENDER_DONE: boolean = false
targetWidth: number = 512
targetHeight: number = 512
targetAspectRatio: number = 1
isViewerMode: boolean = false
constructor(
container: Element | HTMLElement,
options: Load3DOptions = {
@@ -60,16 +54,6 @@ class Load3d {
) {
this.node = options.node || ({} as LGraphNode)
this.clock = new THREE.Clock()
this.isViewerMode = options.isViewerMode || false
const widthWidget = this.node.widgets?.find((w) => w.name === 'width')
const heightWidget = this.node.widgets?.find((w) => w.name === 'height')
if (widthWidget && heightWidget) {
this.targetWidth = widthWidget.value as number
this.targetHeight = heightWidget.value as number
this.targetAspectRatio = this.targetWidth / this.targetHeight
}
this.renderer = new THREE.WebGLRenderer({ alpha: true, antialias: true })
this.renderer.setSize(300, 300)
@@ -125,11 +109,7 @@ class Load3d {
this.sceneManager.backgroundCamera
)
if (options.disablePreview) {
this.previewManager.togglePreview(false)
}
this.modelManager = new SceneModelManager(
this.modelManager = new ModelManager(
this.sceneManager.scene,
this.renderer,
this.eventManager,
@@ -162,7 +142,6 @@ class Load3d {
this.STATUS_MOUSE_ON_NODE = false
this.STATUS_MOUSE_ON_SCENE = false
this.STATUS_MOUSE_ON_VIEWER = false
this.handleResize()
this.startAnimation()
@@ -172,41 +151,6 @@ class Load3d {
}, 100)
}
getEventManager(): EventManager {
return this.eventManager
}
getNodeStorage(): NodeStorage {
return this.nodeStorage
}
getSceneManager(): SceneManager {
return this.sceneManager
}
getCameraManager(): CameraManager {
return this.cameraManager
}
getControlsManager(): ControlsManager {
return this.controlsManager
}
getLightingManager(): LightingManager {
return this.lightingManager
}
getViewHelperManager(): ViewHelperManager {
return this.viewHelperManager
}
getPreviewManager(): PreviewManager {
return this.previewManager
}
getLoaderManager(): LoaderManager {
return this.loaderManager
}
getModelManager(): SceneModelManager {
return this.modelManager
}
getRecordingManager(): RecordingManager {
return this.recordingManager
}
forceRender(): void {
const delta = this.clock.getDelta()
this.viewHelperManager.update(delta)
@@ -228,43 +172,12 @@ class Load3d {
}
renderMainScene(): void {
const containerWidth = this.renderer.domElement.clientWidth
const containerHeight = this.renderer.domElement.clientHeight
const width = this.renderer.domElement.clientWidth
const height = this.renderer.domElement.clientHeight
if (this.isViewerMode) {
const containerAspectRatio = containerWidth / containerHeight
let renderWidth: number
let renderHeight: number
let offsetX: number = 0
let offsetY: number = 0
if (containerAspectRatio > this.targetAspectRatio) {
renderHeight = containerHeight
renderWidth = renderHeight * this.targetAspectRatio
offsetX = (containerWidth - renderWidth) / 2
} else {
renderWidth = containerWidth
renderHeight = renderWidth / this.targetAspectRatio
offsetY = (containerHeight - renderHeight) / 2
}
this.renderer.setViewport(0, 0, containerWidth, containerHeight)
this.renderer.setScissor(0, 0, containerWidth, containerHeight)
this.renderer.setScissorTest(true)
this.renderer.setClearColor(0x0a0a0a)
this.renderer.clear()
this.renderer.setViewport(offsetX, offsetY, renderWidth, renderHeight)
this.renderer.setScissor(offsetX, offsetY, renderWidth, renderHeight)
const renderAspectRatio = renderWidth / renderHeight
this.cameraManager.updateAspectRatio(renderAspectRatio)
} else {
this.renderer.setViewport(0, 0, containerWidth, containerHeight)
this.renderer.setScissor(0, 0, containerWidth, containerHeight)
this.renderer.setScissorTest(true)
}
this.renderer.setViewport(0, 0, width, height)
this.renderer.setScissor(0, 0, width, height)
this.renderer.setScissorTest(true)
this.sceneManager.renderBackground()
this.renderer.render(
@@ -330,15 +243,10 @@ class Load3d {
this.STATUS_MOUSE_ON_SCENE = onScene
}
updateStatusMouseOnViewer(onViewer: boolean): void {
this.STATUS_MOUSE_ON_VIEWER = onViewer
}
isActive(): boolean {
return (
this.STATUS_MOUSE_ON_NODE ||
this.STATUS_MOUSE_ON_SCENE ||
this.STATUS_MOUSE_ON_VIEWER ||
this.isRecording() ||
!this.INITIAL_RENDER_DONE
)
@@ -400,34 +308,6 @@ class Load3d {
this.sceneManager.backgroundTexture
)
if (
this.isViewerMode &&
this.sceneManager.backgroundTexture &&
this.sceneManager.backgroundMesh
) {
const containerWidth = this.renderer.domElement.clientWidth
const containerHeight = this.renderer.domElement.clientHeight
const containerAspectRatio = containerWidth / containerHeight
let renderWidth: number
let renderHeight: number
if (containerAspectRatio > this.targetAspectRatio) {
renderHeight = containerHeight
renderWidth = renderHeight * this.targetAspectRatio
} else {
renderWidth = containerWidth
renderHeight = renderWidth / this.targetAspectRatio
}
this.sceneManager.updateBackgroundSize(
this.sceneManager.backgroundTexture,
this.sceneManager.backgroundMesh,
renderWidth,
renderHeight
)
}
this.forceRender()
}
@@ -460,10 +340,6 @@ class Load3d {
return this.cameraManager.getCurrentCameraType()
}
getCurrentModel(): THREE.Object3D | null {
return this.modelManager.currentModel
}
setCameraState(state: CameraState): void {
this.cameraManager.setCameraState(state)
@@ -521,9 +397,6 @@ class Load3d {
}
setTargetSize(width: number, height: number): void {
this.targetWidth = width
this.targetHeight = height
this.targetAspectRatio = width / height
this.previewManager.setTargetSize(width, height)
this.forceRender()
}
@@ -549,30 +422,13 @@ class Load3d {
return
}
const containerWidth = parentElement.clientWidth
const containerHeight = parentElement.clientHeight
const width = parentElement.clientWidth
const height = parentElement.clientHeight
if (this.isViewerMode) {
const containerAspectRatio = containerWidth / containerHeight
let renderWidth: number
let renderHeight: number
this.cameraManager.handleResize(width, height)
this.sceneManager.handleResize(width, height)
if (containerAspectRatio > this.targetAspectRatio) {
renderHeight = containerHeight
renderWidth = renderHeight * this.targetAspectRatio
} else {
renderWidth = containerWidth
renderHeight = renderWidth / this.targetAspectRatio
}
this.cameraManager.handleResize(renderWidth, renderHeight)
this.sceneManager.handleResize(renderWidth, renderHeight)
} else {
this.cameraManager.handleResize(containerWidth, containerHeight)
this.sceneManager.handleResize(containerWidth, containerHeight)
}
this.renderer.setSize(containerWidth, containerHeight)
this.renderer.setSize(width, height)
this.previewManager.handleResize()
this.forceRender()

View File

@@ -27,6 +27,10 @@ class Load3dAnimation extends Load3d {
this.overrideAnimationLoop()
}
private getCurrentModel(): THREE.Object3D | null {
return this.modelManager.currentModel
}
private overrideAnimationLoop(): void {
if (this.animationFrameId !== null) {
cancelAnimationFrame(this.animationFrameId)

View File

@@ -18,7 +18,7 @@ import {
UpDirection
} from './interfaces'
export class SceneModelManager implements ModelManagerInterface {
export class ModelManager implements ModelManagerInterface {
currentModel: THREE.Object3D | null = null
originalModel:
| THREE.Object3D
@@ -663,12 +663,6 @@ export class SceneModelManager implements ModelManagerInterface {
this.originalMaterials = new WeakMap()
}
addModelToScene(model: THREE.Object3D): void {
this.currentModel = model
this.scene.add(this.currentModel)
}
async setupModel(model: THREE.Object3D): Promise<void> {
this.currentModel = model

View File

@@ -37,8 +37,6 @@ export interface EventCallback {
export interface Load3DOptions {
node?: LGraphNode
inputSpec?: CustomInputSpec
disablePreview?: boolean
isViewerMode?: boolean
}
export interface CaptureResult {
@@ -161,7 +159,6 @@ export interface ModelManagerInterface {
clearModel(): void
reset(): void
setupModel(model: THREE.Object3D): Promise<void>
addModelToScene(model: THREE.Object3D): void
setOriginalModel(model: THREE.Object3D | THREE.BufferGeometry | GLTF): void
setUpDirection(direction: UpDirection): void
materialMode: MaterialMode

View File

@@ -3976,19 +3976,13 @@ class UIManager {
const mainImageFilename =
new URL(mainImageUrl).searchParams.get('filename') ?? undefined
let combinedImageFilename: string | null | undefined
if (
const combinedImageFilename =
ComfyApp.clipspace?.combinedIndex !== undefined &&
ComfyApp.clipspace?.imgs &&
ComfyApp.clipspace.combinedIndex < ComfyApp.clipspace.imgs.length &&
ComfyApp.clipspace.imgs[ComfyApp.clipspace.combinedIndex]?.src
) {
combinedImageFilename = new URL(
ComfyApp.clipspace.imgs[ComfyApp.clipspace.combinedIndex].src
).searchParams.get('filename')
} else {
combinedImageFilename = undefined
}
ComfyApp.clipspace?.imgs?.[ComfyApp.clipspace.combinedIndex]?.src
? new URL(
ComfyApp.clipspace.imgs[ComfyApp.clipspace.combinedIndex].src
).searchParams.get('filename')
: undefined
const imageLayerFilenames =
mainImageFilename !== undefined

View File

@@ -191,7 +191,7 @@ export abstract class SubgraphIONodeBase<
* @param event The event that triggered the context menu.
*/
protected showSlotContextMenu(slot: TSlot, event: CanvasPointerEvent): void {
const options = this.#getSlotMenuOptions(slot)
const options: IContextMenuValue[] = this.#getSlotMenuOptions(slot)
if (!(options.length > 0)) return
new LiteGraph.ContextMenu(options, {
@@ -208,8 +208,8 @@ export abstract class SubgraphIONodeBase<
* @param slot The slot to get the context menu options for.
* @returns The context menu options.
*/
#getSlotMenuOptions(slot: TSlot): (IContextMenuValue | null)[] {
const options: (IContextMenuValue | null)[] = []
#getSlotMenuOptions(slot: TSlot): IContextMenuValue[] {
const options: IContextMenuValue[] = []
// Disconnect option if slot has connections
if (slot !== this.emptySlot && slot.linkIds.length > 0) {
@@ -218,10 +218,10 @@ export abstract class SubgraphIONodeBase<
// Remove / rename slot option (except for the empty slot)
if (slot !== this.emptySlot) {
options.push({ content: 'Rename Slot', value: 'rename' }, null, {
content: 'Remove Slot',
value: 'remove'
})
options.push(
{ content: 'Remove Slot', value: 'remove' },
{ content: 'Rename Slot', value: 'rename' }
)
}
return options

View File

@@ -1,5 +1,3 @@
import { pull } from 'lodash'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import { LLink } from '@/lib/litegraph/src/LLink'
import type { RerouteId } from '@/lib/litegraph/src/Reroute'
@@ -11,6 +9,7 @@ import type {
} from '@/lib/litegraph/src/interfaces'
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
import { NodeSlotType } from '@/lib/litegraph/src/types/globalEnums'
import { removeFromArray } from '@/lib/litegraph/src/utils/collections'
import type { SubgraphInput } from './SubgraphInput'
import type { SubgraphOutputNode } from './SubgraphOutputNode'
@@ -60,7 +59,7 @@ export class SubgraphOutput extends SubgraphSlot {
existingLink.disconnect(subgraph, 'input')
const resolved = existingLink.resolve(subgraph)
const links = resolved.output?.links
if (links) pull(links, existingLink.id)
if (links) removeFromArray(links, existingLink.id)
}
const link = new LLink(

View File

@@ -112,3 +112,11 @@ export function findFreeSlotOfType<T extends { type: ISlotType }>(
}
return wildSlot ?? occupiedSlot ?? occupiedWildSlot
}
export function removeFromArray<T>(array: T[], value: T): boolean {
const index = array.indexOf(value)
const found = index !== -1
if (found) array.splice(index, 1)
return found
}

View File

@@ -35,9 +35,6 @@
"Comfy-Desktop_Restart": {
"label": "Restart"
},
"Comfy_3DViewer_Open3DViewer": {
"label": "Open 3D Viewer (Beta) for Selected Node"
},
"Comfy_BrowseTemplates": {
"label": "Browse Templates"
},
@@ -122,9 +119,6 @@
"Comfy_Graph_ConvertToSubgraph": {
"label": "Convert Selection to Subgraph"
},
"Comfy_Graph_ExitSubgraph": {
"label": "Exit Subgraph"
},
"Comfy_Graph_FitGroupToContents": {
"label": "Fit Group To Contents"
},

View File

@@ -73,6 +73,7 @@
"searchWorkflows": "Search Workflows",
"searchSettings": "Search Settings",
"searchNodes": "Search Nodes",
"searchCommands": "Search Commands",
"searchModels": "Search Models",
"searchKeybindings": "Search Keybindings",
"searchExtensions": "Search Extensions",
@@ -946,7 +947,6 @@
"Quit": "Quit",
"Reinstall": "Reinstall",
"Restart": "Restart",
"Open 3D Viewer (Beta) for Selected Node": "Open 3D Viewer (Beta) for Selected Node",
"Browse Templates": "Browse Templates",
"Add Edit Model Step": "Add Edit Model Step",
"Delete Selected Items": "Delete Selected Items",
@@ -975,7 +975,6 @@
"Export (API)": "Export (API)",
"Give Feedback": "Give Feedback",
"Convert Selection to Subgraph": "Convert Selection to Subgraph",
"Exit Subgraph": "Exit Subgraph",
"Fit Group To Contents": "Fit Group To Contents",
"Group Selected Nodes": "Group Selected Nodes",
"Convert selected nodes to group node": "Convert selected nodes to group node",
@@ -1081,8 +1080,7 @@
"User": "User",
"Credits": "Credits",
"API Nodes": "API Nodes",
"Notification Preferences": "Notification Preferences",
"3DViewer": "3DViewer"
"Notification Preferences": "Notification Preferences"
},
"serverConfigItems": {
"listen": {
@@ -1434,31 +1432,12 @@
"depth": "Depth",
"lineart": "Lineart"
},
"upDirections": {
"original": "Original"
},
"startRecording": "Start Recording",
"stopRecording": "Stop Recording",
"exportRecording": "Export Recording",
"clearRecording": "Clear Recording",
"resizeNodeMatchOutput": "Resize Node to match output",
"loadingBackgroundImage": "Loading Background Image",
"cameraType": {
"perspective": "Perspective",
"orthographic": "Orthographic"
},
"viewer": {
"title": "3D Viewer (Beta)",
"apply": "Apply",
"cancel": "Cancel",
"cameraType": "Camera Type",
"sceneSettings": "Scene Settings",
"cameraSettings": "Camera Settings",
"lightSettings": "Light Settings",
"exportSettings": "Export Settings",
"modelSettings": "Model Settings"
},
"openIn3DViewer": "Open in 3D Viewer"
"loadingBackgroundImage": "Loading Background Image"
},
"toastMessages": {
"nothingToQueue": "Nothing to queue",
@@ -1496,8 +1475,7 @@
"useApiKeyTip": "Tip: Can't access normal login? Use the Comfy API Key option.",
"nothingSelected": "Nothing selected",
"cannotCreateSubgraph": "Cannot create subgraph",
"failedToConvertToSubgraph": "Failed to convert items to subgraph",
"failedToInitializeLoad3dViewer": "Failed to initialize 3D Viewer"
"failedToConvertToSubgraph": "Failed to convert items to subgraph"
},
"auth": {
"apiKey": {

View File

@@ -119,10 +119,6 @@
"Hidden": "Hidden"
}
},
"Comfy_Load3D_3DViewerEnable": {
"name": "Enable 3D Viewer (Beta)",
"tooltip": "Enables the 3D Viewer (Beta) for selected nodes. This feature allows you to visualize and interact with 3D models directly within the full size 3d viewer."
},
"Comfy_Load3D_BackgroundColor": {
"name": "Initial Background Color",
"tooltip": "Controls the default background color of the 3D scene. This setting determines the background appearance when a new 3D widget is created, but can be adjusted individually for each widget after creation."

View File

@@ -35,9 +35,6 @@
"Comfy-Desktop_Restart": {
"label": "Reiniciar"
},
"Comfy_3DViewer_Open3DViewer": {
"label": "Abrir visor 3D (Beta) para el nodo seleccionado"
},
"Comfy_BrowseTemplates": {
"label": "Explorar plantillas"
},
@@ -122,9 +119,6 @@
"Comfy_Graph_ConvertToSubgraph": {
"label": "Convertir selección en subgrafo"
},
"Comfy_Graph_ExitSubgraph": {
"label": "Salir de subgrafo"
},
"Comfy_Graph_FitGroupToContents": {
"label": "Ajustar grupo al contenido"
},
@@ -249,7 +243,7 @@
"label": "Alternar panel inferior de controles de vista"
},
"Workspace_ToggleBottomPanel_Shortcuts": {
"label": "Mostrar diálogo de atajos de teclado"
"label": "Mostrar diálogo de combinaciones de teclas"
},
"Workspace_ToggleFocusMode": {
"label": "Alternar Modo de Enfoque"

View File

@@ -370,6 +370,7 @@
"resultsCount": "Encontrados {count} resultados",
"save": "Guardar",
"saving": "Guardando",
"searchCommands": "Buscar comandos",
"searchExtensions": "Buscar extensiones",
"searchFailedMessage": "No pudimos encontrar ninguna configuración que coincida con tu búsqueda. Intenta ajustar tus términos de búsqueda.",
"searchKeybindings": "Buscar combinaciones de teclas",
@@ -569,10 +570,6 @@
"applyingTexture": "Aplicando textura...",
"backgroundColor": "Color de fondo",
"camera": "Cámara",
"cameraType": {
"orthographic": "Ortográfica",
"perspective": "Perspectiva"
},
"clearRecording": "Borrar grabación",
"edgeThreshold": "Umbral de borde",
"export": "Exportar",
@@ -593,7 +590,6 @@
"wireframe": "Malla"
},
"model": "Modelo",
"openIn3DViewer": "Abrir en el visor 3D",
"previewOutput": "Vista previa de salida",
"removeBackgroundImage": "Eliminar imagen de fondo",
"resizeNodeMatchOutput": "Redimensionar nodo para coincidir con la salida",
@@ -604,22 +600,8 @@
"switchCamera": "Cambiar cámara",
"switchingMaterialMode": "Cambiando modo de material...",
"upDirection": "Dirección hacia arriba",
"upDirections": {
"original": "Original"
},
"uploadBackgroundImage": "Subir imagen de fondo",
"uploadTexture": "Subir textura",
"viewer": {
"apply": "Aplicar",
"cameraSettings": "Configuración de la cámara",
"cameraType": "Tipo de cámara",
"cancel": "Cancelar",
"exportSettings": "Configuración de exportación",
"lightSettings": "Configuración de la luz",
"modelSettings": "Configuración del modelo",
"sceneSettings": "Configuración de la escena",
"title": "Visor 3D (Beta)"
}
"uploadTexture": "Subir textura"
},
"loadWorkflowWarning": {
"coreNodesFromVersion": "Requiere ComfyUI {version}:",
@@ -784,7 +766,6 @@
"Desktop User Guide": "Guía de usuario de escritorio",
"Duplicate Current Workflow": "Duplicar flujo de trabajo actual",
"Edit": "Editar",
"Exit Subgraph": "Salir de subgrafo",
"Export": "Exportar",
"Export (API)": "Exportar (API)",
"Fit Group To Contents": "Ajustar grupo a contenidos",
@@ -804,7 +785,6 @@
"New": "Nuevo",
"Next Opened Workflow": "Siguiente flujo de trabajo abierto",
"Open": "Abrir",
"Open 3D Viewer (Beta) for Selected Node": "Abrir visor 3D (Beta) para el nodo seleccionado",
"Open Custom Nodes Folder": "Abrir carpeta de nodos personalizados",
"Open DevTools": "Abrir DevTools",
"Open Inputs Folder": "Abrir carpeta de entradas",
@@ -1118,7 +1098,6 @@
},
"settingsCategories": {
"3D": "3D",
"3DViewer": "Visor 3D",
"API Nodes": "Nodos API",
"About": "Acerca de",
"Appearance": "Apariencia",
@@ -1595,7 +1574,6 @@
"failedToExportModel": "Error al exportar modelo como {format}",
"failedToFetchBalance": "No se pudo obtener el saldo: {error}",
"failedToFetchLogs": "Error al obtener los registros del servidor",
"failedToInitializeLoad3dViewer": "No se pudo inicializar el visor 3D",
"failedToInitiateCreditPurchase": "No se pudo iniciar la compra de créditos: {error}",
"failedToPurchaseCredits": "No se pudo comprar créditos: {error}",
"fileLoadError": "No se puede encontrar el flujo de trabajo en {fileName}",

View File

@@ -119,10 +119,6 @@
"Straight": "Recto"
}
},
"Comfy_Load3D_3DViewerEnable": {
"name": "Habilitar visor 3D (Beta)",
"tooltip": "Activa el visor 3D (Beta) para los nodos seleccionados. Esta función te permite visualizar e interactuar con modelos 3D directamente dentro del visor 3D a tamaño completo."
},
"Comfy_Load3D_BackgroundColor": {
"name": "Color de fondo inicial",
"tooltip": "Controla el color de fondo predeterminado de la escena 3D. Esta configuración determina la apariencia del fondo cuando se crea un nuevo widget 3D, pero puede ajustarse individualmente para cada widget después de su creación."

View File

@@ -35,9 +35,6 @@
"Comfy-Desktop_Restart": {
"label": "Redémarrer"
},
"Comfy_3DViewer_Open3DViewer": {
"label": "Ouvrir le visualiseur 3D (bêta) pour le nœud sélectionné"
},
"Comfy_BrowseTemplates": {
"label": "Parcourir les modèles"
},
@@ -122,9 +119,6 @@
"Comfy_Graph_ConvertToSubgraph": {
"label": "Convertir la sélection en sous-graphe"
},
"Comfy_Graph_ExitSubgraph": {
"label": "Quitter le sous-graphe"
},
"Comfy_Graph_FitGroupToContents": {
"label": "Ajuster le groupe au contenu"
},

View File

@@ -370,6 +370,7 @@
"resultsCount": "{count} Résultats Trouvés",
"save": "Enregistrer",
"saving": "Enregistrement",
"searchCommands": "Rechercher des commandes",
"searchExtensions": "Rechercher des extensions",
"searchFailedMessage": "Nous n'avons trouvé aucun paramètre correspondant à votre recherche. Essayez d'ajuster vos termes de recherche.",
"searchKeybindings": "Rechercher des raccourcis clavier",
@@ -569,10 +570,6 @@
"applyingTexture": "Application de la texture...",
"backgroundColor": "Couleur de fond",
"camera": "Caméra",
"cameraType": {
"orthographic": "Orthographique",
"perspective": "Perspective"
},
"clearRecording": "Effacer l'enregistrement",
"edgeThreshold": "Seuil de Bordure",
"export": "Exportation",
@@ -593,7 +590,6 @@
"wireframe": "Fil de fer"
},
"model": "Modèle",
"openIn3DViewer": "Ouvrir dans la visionneuse 3D",
"previewOutput": "Aperçu de la sortie",
"removeBackgroundImage": "Supprimer l'image de fond",
"resizeNodeMatchOutput": "Redimensionner le nœud pour correspondre à la sortie",
@@ -604,22 +600,8 @@
"switchCamera": "Changer de caméra",
"switchingMaterialMode": "Changement de mode de matériau...",
"upDirection": "Direction Haut",
"upDirections": {
"original": "Original"
},
"uploadBackgroundImage": "Télécharger l'image de fond",
"uploadTexture": "Télécharger Texture",
"viewer": {
"apply": "Appliquer",
"cameraSettings": "Paramètres de la caméra",
"cameraType": "Type de caméra",
"cancel": "Annuler",
"exportSettings": "Paramètres dexportation",
"lightSettings": "Paramètres de léclairage",
"modelSettings": "Paramètres du modèle",
"sceneSettings": "Paramètres de la scène",
"title": "Visionneuse 3D (Bêta)"
}
"uploadTexture": "Télécharger Texture"
},
"loadWorkflowWarning": {
"coreNodesFromVersion": "Nécessite ComfyUI {version} :",
@@ -784,7 +766,6 @@
"Desktop User Guide": "Guide de l'utilisateur de bureau",
"Duplicate Current Workflow": "Dupliquer le flux de travail actuel",
"Edit": "Éditer",
"Exit Subgraph": "Quitter le sous-graphe",
"Export": "Exporter",
"Export (API)": "Exporter (API)",
"Fit Group To Contents": "Ajuster le groupe au contenu",
@@ -804,7 +785,6 @@
"New": "Nouveau",
"Next Opened Workflow": "Prochain flux de travail ouvert",
"Open": "Ouvrir",
"Open 3D Viewer (Beta) for Selected Node": "Ouvrir le visualiseur 3D (bêta) pour le nœud sélectionné",
"Open Custom Nodes Folder": "Ouvrir le dossier des nœuds personnalisés",
"Open DevTools": "Ouvrir DevTools",
"Open Inputs Folder": "Ouvrir le dossier des entrées",
@@ -1118,7 +1098,6 @@
},
"settingsCategories": {
"3D": "3D",
"3DViewer": "Visionneuse 3D",
"API Nodes": "Nœuds API",
"About": "À Propos",
"Appearance": "Apparence",
@@ -1179,7 +1158,7 @@
"node": "Nœud",
"panelControls": "Contrôles du panneau",
"queue": "File dattente",
"view": "Vue",
"view": "Affichage",
"workflow": "Flux de travail"
},
"viewControls": "Contrôles daffichage"
@@ -1595,7 +1574,6 @@
"failedToExportModel": "Échec de l'exportation du modèle en {format}",
"failedToFetchBalance": "Échec de la récupération du solde : {error}",
"failedToFetchLogs": "Échec de la récupération des journaux du serveur",
"failedToInitializeLoad3dViewer": "Échec de l'initialisation du visualiseur 3D",
"failedToInitiateCreditPurchase": "Échec de l'initiation de l'achat de crédits : {error}",
"failedToPurchaseCredits": "Échec de l'achat de crédits : {error}",
"fileLoadError": "Impossible de trouver le flux de travail dans {fileName}",

View File

@@ -119,10 +119,6 @@
"Straight": "Droit"
}
},
"Comfy_Load3D_3DViewerEnable": {
"name": "Activer le visualiseur 3D (Bêta)",
"tooltip": "Active le visualiseur 3D (Bêta) pour les nœuds sélectionnés. Cette fonctionnalité vous permet de visualiser et dinteragir avec des modèles 3D directement dans le visualiseur 3D en taille réelle."
},
"Comfy_Load3D_BackgroundColor": {
"name": "Couleur de fond initiale",
"tooltip": "Contrôle la couleur de fond par défaut de la scène 3D. Ce paramètre détermine l'apparence du fond lors de la création d'un nouveau widget 3D, mais peut être ajusté individuellement pour chaque widget après la création."

View File

@@ -35,9 +35,6 @@
"Comfy-Desktop_Restart": {
"label": "再起動"
},
"Comfy_3DViewer_Open3DViewer": {
"label": "選択したードの3Dビューアーベータを開く"
},
"Comfy_BrowseTemplates": {
"label": "テンプレートを参照"
},
@@ -122,9 +119,6 @@
"Comfy_Graph_ConvertToSubgraph": {
"label": "選択範囲をサブグラフに変換"
},
"Comfy_Graph_ExitSubgraph": {
"label": "サブグラフを終了"
},
"Comfy_Graph_FitGroupToContents": {
"label": "グループを内容に合わせて調整"
},

View File

@@ -370,6 +370,7 @@
"resultsCount": "{count}件の結果が見つかりました",
"save": "保存",
"saving": "保存中",
"searchCommands": "コマンドを検索",
"searchExtensions": "拡張機能を検索",
"searchFailedMessage": "検索に一致する設定が見つかりませんでした。検索キーワードを調整してみてください。",
"searchKeybindings": "キーバインディングを検索",
@@ -569,10 +570,6 @@
"applyingTexture": "テクスチャを適用中...",
"backgroundColor": "背景色",
"camera": "カメラ",
"cameraType": {
"orthographic": "オルソグラフィック",
"perspective": "パースペクティブ"
},
"clearRecording": "録画をクリア",
"edgeThreshold": "エッジ閾値",
"export": "エクスポート",
@@ -593,7 +590,6 @@
"wireframe": "ワイヤーフレーム"
},
"model": "モデル",
"openIn3DViewer": "3Dビューアで開く",
"previewOutput": "出力のプレビュー",
"removeBackgroundImage": "背景画像を削除",
"resizeNodeMatchOutput": "ノードを出力に合わせてリサイズ",
@@ -604,22 +600,8 @@
"switchCamera": "カメラを切り替える",
"switchingMaterialMode": "マテリアルモードの切り替え中...",
"upDirection": "上方向",
"upDirections": {
"original": "オリジナル"
},
"uploadBackgroundImage": "背景画像をアップロード",
"uploadTexture": "テクスチャをアップロード",
"viewer": {
"apply": "適用",
"cameraSettings": "カメラ設定",
"cameraType": "カメラタイプ",
"cancel": "キャンセル",
"exportSettings": "エクスポート設定",
"lightSettings": "ライト設定",
"modelSettings": "モデル設定",
"sceneSettings": "シーン設定",
"title": "3Dビューアベータ"
}
"uploadTexture": "テクスチャをアップロード"
},
"loadWorkflowWarning": {
"coreNodesFromVersion": "ComfyUI {version} が必要です:",
@@ -784,7 +766,6 @@
"Desktop User Guide": "デスクトップユーザーガイド",
"Duplicate Current Workflow": "現在のワークフローを複製",
"Edit": "編集",
"Exit Subgraph": "サブグラフを終了",
"Export": "エクスポート",
"Export (API)": "エクスポート (API)",
"Fit Group To Contents": "グループを内容に合わせる",
@@ -804,7 +785,6 @@
"New": "新規",
"Next Opened Workflow": "次に開いたワークフロー",
"Open": "開く",
"Open 3D Viewer (Beta) for Selected Node": "選択したードの3Dビューアーベータを開く",
"Open Custom Nodes Folder": "カスタムノードフォルダを開く",
"Open DevTools": "DevToolsを開く",
"Open Inputs Folder": "入力フォルダを開く",
@@ -1118,7 +1098,6 @@
},
"settingsCategories": {
"3D": "3D",
"3DViewer": "3Dビューア",
"API Nodes": "APIード",
"About": "情報",
"Appearance": "外観",
@@ -1173,7 +1152,7 @@
"shortcuts": {
"essentials": "基本",
"keyboardShortcuts": "キーボードショートカット",
"manageShortcuts": "ショートカット管理",
"manageShortcuts": "ショートカット管理",
"noKeybinding": "キー割り当てなし",
"subcategories": {
"node": "ノード",
@@ -1595,7 +1574,6 @@
"failedToExportModel": "{format}としてモデルのエクスポートに失敗しました",
"failedToFetchBalance": "残高の取得に失敗しました: {error}",
"failedToFetchLogs": "サーバーログの取得に失敗しました",
"failedToInitializeLoad3dViewer": "3Dビューアの初期化に失敗しました",
"failedToInitiateCreditPurchase": "クレジット購入の開始に失敗しました: {error}",
"failedToPurchaseCredits": "クレジットの購入に失敗しました: {error}",
"fileLoadError": "{fileName}でワークフローが見つかりません",

View File

@@ -119,10 +119,6 @@
"Straight": "ストレート"
}
},
"Comfy_Load3D_3DViewerEnable": {
"name": "3Dビューアーを有効化ベータ",
"tooltip": "選択したードで3Dビューアーベータを有効にします。この機能により、フルサイズの3Dビューアー内で3Dモデルを直接可視化し、操作できます。"
},
"Comfy_Load3D_BackgroundColor": {
"name": "初期背景色",
"tooltip": "3Dシーンのデフォルト背景色を設定します。この設定は新しい3Dウィジェット作成時の背景の見た目を決定しますが、作成後に各ウィジェットごとに個別に調整できます。"

View File

@@ -35,9 +35,6 @@
"Comfy-Desktop_Restart": {
"label": "재시작"
},
"Comfy_3DViewer_Open3DViewer": {
"label": "선택한 노드에 대해 3D 뷰어(베타) 열기"
},
"Comfy_BrowseTemplates": {
"label": "템플릿 탐색"
},
@@ -122,9 +119,6 @@
"Comfy_Graph_ConvertToSubgraph": {
"label": "선택 영역을 서브그래프로 변환"
},
"Comfy_Graph_ExitSubgraph": {
"label": "서브그래프 종료"
},
"Comfy_Graph_FitGroupToContents": {
"label": "그룹을 내용에 맞게 맞추기"
},

View File

@@ -370,6 +370,7 @@
"resultsCount": "{count} 개의 결과를 찾았습니다",
"save": "저장",
"saving": "저장 중",
"searchCommands": "명령어 검색",
"searchExtensions": "확장 검색",
"searchFailedMessage": "검색어와 일치하는 설정을 찾을 수 없습니다. 검색어를 조정해 보세요.",
"searchKeybindings": "키 바인딩 검색",
@@ -569,10 +570,6 @@
"applyingTexture": "텍스처 적용 중...",
"backgroundColor": "배경색",
"camera": "카메라",
"cameraType": {
"orthographic": "직교",
"perspective": "원근"
},
"clearRecording": "녹화 지우기",
"edgeThreshold": "엣지 임계값",
"export": "내보내기",
@@ -593,7 +590,6 @@
"wireframe": "와이어프레임"
},
"model": "모델",
"openIn3DViewer": "3D 뷰어에서 열기",
"previewOutput": "출력 미리보기",
"removeBackgroundImage": "배경 이미지 제거",
"resizeNodeMatchOutput": "노드 크기를 출력에 맞추기",
@@ -604,22 +600,8 @@
"switchCamera": "카메라 전환",
"switchingMaterialMode": "재질 모드 전환 중...",
"upDirection": "위 방향",
"upDirections": {
"original": "원본"
},
"uploadBackgroundImage": "배경 이미지 업로드",
"uploadTexture": "텍스처 업로드",
"viewer": {
"apply": "적용",
"cameraSettings": "카메라 설정",
"cameraType": "카메라 유형",
"cancel": "취소",
"exportSettings": "내보내기 설정",
"lightSettings": "조명 설정",
"modelSettings": "모델 설정",
"sceneSettings": "씬 설정",
"title": "3D 뷰어 (베타)"
}
"uploadTexture": "텍스처 업로드"
},
"loadWorkflowWarning": {
"coreNodesFromVersion": "ComfyUI {version} 이상 필요:",
@@ -784,7 +766,6 @@
"Desktop User Guide": "데스크톱 사용자 가이드",
"Duplicate Current Workflow": "현재 워크플로 복제",
"Edit": "편집",
"Exit Subgraph": "서브그래프 종료",
"Export": "내보내기",
"Export (API)": "내보내기 (API)",
"Fit Group To Contents": "그룹을 내용에 맞게 조정",
@@ -804,7 +785,6 @@
"New": "새로 만들기",
"Next Opened Workflow": "다음 열린 워크플로",
"Open": "열기",
"Open 3D Viewer (Beta) for Selected Node": "선택한 노드에 대해 3D 뷰어(베타) 열기",
"Open Custom Nodes Folder": "사용자 정의 노드 폴더 열기",
"Open DevTools": "개발자 도구 열기",
"Open Inputs Folder": "입력 폴더 열기",
@@ -829,7 +809,7 @@
"Restart": "재시작",
"Save": "저장",
"Save As": "다른 이름으로 저장",
"Show Keybindings Dialog": "키 바인딩 대화상자 표시",
"Show Keybindings Dialog": "단축키 대화상자 표시",
"Show Settings Dialog": "설정 대화상자 표시",
"Sign Out": "로그아웃",
"Toggle Bottom Panel": "하단 패널 전환",
@@ -1118,7 +1098,6 @@
},
"settingsCategories": {
"3D": "3D",
"3DViewer": "3D뷰어",
"API Nodes": "API 노드",
"About": "정보",
"Appearance": "모양",
@@ -1171,7 +1150,7 @@
"Workflow": "워크플로"
},
"shortcuts": {
"essentials": "필수",
"essentials": "기본",
"keyboardShortcuts": "키보드 단축키",
"manageShortcuts": "단축키 관리",
"noKeybinding": "단축키 없음",
@@ -1595,7 +1574,6 @@
"failedToExportModel": "{format} 형식으로 모델 내보내기에 실패했습니다",
"failedToFetchBalance": "잔액을 가져오지 못했습니다: {error}",
"failedToFetchLogs": "서버 로그를 가져오는 데 실패했습니다",
"failedToInitializeLoad3dViewer": "3D 뷰어 초기화에 실패했습니다",
"failedToInitiateCreditPurchase": "크레딧 구매를 시작하지 못했습니다: {error}",
"failedToPurchaseCredits": "크레딧 구매에 실패했습니다: {error}",
"fileLoadError": "{fileName}에서 워크플로를 찾을 수 없습니다",

View File

@@ -119,10 +119,6 @@
"Straight": "직선"
}
},
"Comfy_Load3D_3DViewerEnable": {
"name": "3D 뷰어 활성화 (베타)",
"tooltip": "선택한 노드에 대해 3D 뷰어(베타)를 활성화합니다. 이 기능을 통해 전체 크기의 3D 뷰어에서 3D 모델을 직접 시각화하고 상호작용할 수 있습니다."
},
"Comfy_Load3D_BackgroundColor": {
"name": "초기 배경색",
"tooltip": "3D 장면의 기본 배경색을 설정합니다. 이 설정은 새 3D 위젯이 생성될 때 배경의 모양을 결정하지만, 생성 후 각 위젯별로 개별적으로 조정할 수 있습니다."

View File

@@ -35,9 +35,6 @@
"Comfy-Desktop_Restart": {
"label": "Перезагрузить"
},
"Comfy_3DViewer_Open3DViewer": {
"label": "Открыть 3D-просмотрщик (бета) для выбранного узла"
},
"Comfy_BrowseTemplates": {
"label": "Просмотр шаблонов"
},
@@ -122,9 +119,6 @@
"Comfy_Graph_ConvertToSubgraph": {
"label": "Преобразовать выделенное в подграф"
},
"Comfy_Graph_ExitSubgraph": {
"label": "Выйти из подграфа"
},
"Comfy_Graph_FitGroupToContents": {
"label": "Подогнать группу к содержимому"
},
@@ -246,7 +240,7 @@
"label": "Показать/скрыть основную нижнюю панель"
},
"Workspace_ToggleBottomPanelTab_shortcuts-view-controls": {
"label": "Показать или скрыть нижнюю панель управления просмотром"
"label": "Показать/скрыть нижнюю панель управления просмотром"
},
"Workspace_ToggleBottomPanel_Shortcuts": {
"label": "Показать диалог клавиш"

View File

@@ -370,6 +370,7 @@
"resultsCount": "Найдено {count} результатов",
"save": "Сохранить",
"saving": "Сохранение",
"searchCommands": "Поиск команд",
"searchExtensions": "Поиск расширений",
"searchFailedMessage": "Мы не смогли найти настройки, соответствующие вашему запросу. Попробуйте изменить поисковые термины.",
"searchKeybindings": "Поиск сочетаний клавиш",
@@ -569,10 +570,6 @@
"applyingTexture": "Применение текстуры...",
"backgroundColor": "Цвет фона",
"camera": "Камера",
"cameraType": {
"orthographic": "Ортографическая",
"perspective": "Перспективная"
},
"clearRecording": "Очистить запись",
"edgeThreshold": "Пороговое значение края",
"export": "Экспорт",
@@ -593,7 +590,6 @@
"wireframe": "Каркас"
},
"model": "Модель",
"openIn3DViewer": "Открыть в 3D просмотрщике",
"previewOutput": "Предварительный просмотр",
"removeBackgroundImage": "Удалить фоновое изображение",
"resizeNodeMatchOutput": "Изменить размер узла под вывод",
@@ -604,22 +600,8 @@
"switchCamera": "Переключить камеру",
"switchingMaterialMode": "Переключение режима материала...",
"upDirection": "Направление Вверх",
"upDirections": {
"original": "Оригинал"
},
"uploadBackgroundImage": "Загрузить фоновое изображение",
"uploadTexture": "Загрузить текстуру",
"viewer": {
"apply": "Применить",
"cameraSettings": "Настройки камеры",
"cameraType": "Тип камеры",
"cancel": "Отмена",
"exportSettings": "Настройки экспорта",
"lightSettings": "Настройки освещения",
"modelSettings": "Настройки модели",
"sceneSettings": "Настройки сцены",
"title": "3D Просмотрщик (Бета)"
}
"uploadTexture": "Загрузить текстуру"
},
"loadWorkflowWarning": {
"coreNodesFromVersion": "Требуется ComfyUI {version}:",
@@ -784,7 +766,6 @@
"Desktop User Guide": "Руководство пользователя для настольных ПК",
"Duplicate Current Workflow": "Дублировать текущий рабочий процесс",
"Edit": "Редактировать",
"Exit Subgraph": "Выйти из подграфа",
"Export": "Экспортировать",
"Export (API)": "Экспорт (API)",
"Fit Group To Contents": "Подогнать группу под содержимое",
@@ -804,7 +785,6 @@
"New": "Новый",
"Next Opened Workflow": "Следующий открытый рабочий процесс",
"Open": "Открыть",
"Open 3D Viewer (Beta) for Selected Node": "Открыть 3D-просмотрщик (бета) для выбранного узла",
"Open Custom Nodes Folder": "Открыть папку пользовательских нод",
"Open DevTools": "Открыть инструменты разработчика",
"Open Inputs Folder": "Открыть папку входных данных",
@@ -1118,7 +1098,6 @@
},
"settingsCategories": {
"3D": "3D",
"3DViewer": "3D-просмотрщик",
"API Nodes": "API-узлы",
"About": "О программе",
"Appearance": "Внешний вид",
@@ -1174,7 +1153,7 @@
"essentials": "Основные",
"keyboardShortcuts": "Горячие клавиши",
"manageShortcuts": "Управление горячими клавишами",
"noKeybinding": "Нет сочетания клавиш",
"noKeybinding": "Нет привязки клавиши",
"subcategories": {
"node": "Узел",
"panelControls": "Управление панелью",
@@ -1595,7 +1574,6 @@
"failedToExportModel": "Не удалось экспортировать модель как {format}",
"failedToFetchBalance": "Не удалось получить баланс: {error}",
"failedToFetchLogs": "Не удалось получить серверные логи",
"failedToInitializeLoad3dViewer": "Не удалось инициализировать 3D просмотрщик",
"failedToInitiateCreditPurchase": "Не удалось начать покупку кредитов: {error}",
"failedToPurchaseCredits": "Не удалось купить кредиты: {error}",
"fileLoadError": "Не удалось найти рабочий процесс в {fileName}",

View File

@@ -119,10 +119,6 @@
"Straight": "Прямой"
}
},
"Comfy_Load3D_3DViewerEnable": {
"name": "Включить 3D-просмотрщик (Бета)",
"tooltip": "Включает 3D-просмотрщик (Бета) для выбранных узлов. Эта функция позволяет визуализировать и взаимодействовать с 3D-моделями прямо в полноразмерном 3D-просмотрщике."
},
"Comfy_Load3D_BackgroundColor": {
"name": "Начальный цвет фона",
"tooltip": "Управляет цветом фона по умолчанию для 3D-сцены. Этот параметр определяет внешний вид фона при создании нового 3D-виджета, но может быть изменён индивидуально для каждого виджета после создания."

View File

@@ -35,9 +35,6 @@
"Comfy-Desktop_Restart": {
"label": "重新啟動"
},
"Comfy_3DViewer_Open3DViewer": {
"label": "為選取的節點開啟 3D 檢視器Beta"
},
"Comfy_BrowseTemplates": {
"label": "瀏覽範本"
},
@@ -122,9 +119,6 @@
"Comfy_Graph_ConvertToSubgraph": {
"label": "將選取內容轉換為子圖"
},
"Comfy_Graph_ExitSubgraph": {
"label": "離開子圖"
},
"Comfy_Graph_FitGroupToContents": {
"label": "調整群組以符合內容"
},

View File

@@ -370,6 +370,7 @@
"resultsCount": "找到 {count} 筆結果",
"save": "儲存",
"saving": "儲存中",
"searchCommands": "搜尋指令",
"searchExtensions": "搜尋擴充套件",
"searchFailedMessage": "找不到符合您搜尋的設定。請嘗試調整搜尋條件。",
"searchKeybindings": "搜尋快捷鍵",
@@ -569,10 +570,6 @@
"applyingTexture": "正在套用材質貼圖...",
"backgroundColor": "背景顏色",
"camera": "相機",
"cameraType": {
"orthographic": "正交",
"perspective": "透視"
},
"clearRecording": "清除錄影",
"edgeThreshold": "邊緣閾值",
"export": "匯出",
@@ -593,7 +590,6 @@
"wireframe": "線框"
},
"model": "模型",
"openIn3DViewer": "在 3D 檢視器中開啟",
"previewOutput": "預覽輸出",
"removeBackgroundImage": "移除背景圖片",
"resizeNodeMatchOutput": "調整節點以符合輸出",
@@ -604,22 +600,8 @@
"switchCamera": "切換相機",
"switchingMaterialMode": "正在切換材質模式...",
"upDirection": "上方方向",
"upDirections": {
"original": "原始"
},
"uploadBackgroundImage": "上傳背景圖片",
"uploadTexture": "上傳材質貼圖",
"viewer": {
"apply": "套用",
"cameraSettings": "相機設定",
"cameraType": "相機類型",
"cancel": "取消",
"exportSettings": "匯出設定",
"lightSettings": "燈光設定",
"modelSettings": "模型設定",
"sceneSettings": "場景設定",
"title": "3D 檢視器(測試版)"
}
"uploadTexture": "上傳材質貼圖"
},
"loadWorkflowWarning": {
"coreNodesFromVersion": "需要 ComfyUI {version}",
@@ -784,7 +766,6 @@
"Desktop User Guide": "桌面應用程式使用指南",
"Duplicate Current Workflow": "複製目前工作流程",
"Edit": "編輯",
"Exit Subgraph": "離開子圖",
"Export": "匯出",
"Export (API)": "匯出API",
"Fit Group To Contents": "群組貼合內容",
@@ -804,7 +785,6 @@
"New": "新增",
"Next Opened Workflow": "下一個已開啟的工作流程",
"Open": "開啟",
"Open 3D Viewer (Beta) for Selected Node": "為選取的節點開啟 3D 檢視器Beta 版)",
"Open Custom Nodes Folder": "開啟自訂節點資料夾",
"Open DevTools": "開啟開發者工具",
"Open Inputs Folder": "開啟輸入資料夾",
@@ -1118,7 +1098,6 @@
},
"settingsCategories": {
"3D": "3D",
"3DViewer": "3D 檢視器",
"API Nodes": "API 節點",
"About": "關於",
"Appearance": "外觀",
@@ -1595,7 +1574,6 @@
"failedToExportModel": "無法將模型匯出為 {format}",
"failedToFetchBalance": "取得餘額失敗:{error}",
"failedToFetchLogs": "無法取得伺服器日誌",
"failedToInitializeLoad3dViewer": "初始化 3D 檢視器失敗",
"failedToInitiateCreditPurchase": "啟動點數購買失敗:{error}",
"failedToPurchaseCredits": "購買點數失敗:{error}",
"fileLoadError": "無法在 {fileName} 中找到工作流程",

View File

@@ -119,10 +119,6 @@
"Straight": "直線"
}
},
"Comfy_Load3D_3DViewerEnable": {
"name": "啟用 3D 檢視器(測試版)",
"tooltip": "為所選節點啟用 3D 檢視器(測試版)。此功能可讓您在全尺寸 3D 檢視器中直接瀏覽與互動 3D 模型。"
},
"Comfy_Load3D_BackgroundColor": {
"name": "初始背景顏色",
"tooltip": "控制 3D 場景的預設背景顏色。此設定決定新建立 3D 元件時的背景外觀,但每個元件在建立後都可單獨調整。"

View File

@@ -35,9 +35,6 @@
"Comfy-Desktop_Restart": {
"label": "重启"
},
"Comfy_3DViewer_Open3DViewer": {
"label": "為所選節點開啟 3D 檢視器Beta 版)"
},
"Comfy_BrowseTemplates": {
"label": "浏览模板"
},
@@ -75,7 +72,7 @@
"label": "锁定视图"
},
"Comfy_Canvas_ToggleMinimap": {
"label": "布切小地"
"label": "布切小地"
},
"Comfy_Canvas_ToggleSelectedNodes_Bypass": {
"label": "忽略/取消忽略选中节点"
@@ -122,9 +119,6 @@
"Comfy_Graph_ConvertToSubgraph": {
"label": "将选区转换为子图"
},
"Comfy_Graph_ExitSubgraph": {
"label": "退出子圖"
},
"Comfy_Graph_FitGroupToContents": {
"label": "适应节点框到内容"
},
@@ -243,13 +237,13 @@
"label": "切换日志底部面板"
},
"Workspace_ToggleBottomPanelTab_shortcuts-essentials": {
"label": "切基本下方面板"
"label": "切基本下方面板"
},
"Workspace_ToggleBottomPanelTab_shortcuts-view-controls": {
"label": "切換檢視控制底部面板"
"label": "切换检视控制底部面板"
},
"Workspace_ToggleBottomPanel_Shortcuts": {
"label": "示快捷鍵對話框"
"label": "示快捷键对话框"
},
"Workspace_ToggleFocusMode": {
"label": "切换焦点模式"

View File

@@ -370,6 +370,7 @@
"resultsCount": "找到 {count} 个结果",
"save": "保存",
"saving": "正在保存",
"searchCommands": "搜尋指令",
"searchExtensions": "搜索扩展",
"searchFailedMessage": "我们找不到任何与您的搜索匹配的设置。请尝试调整您的搜索词。",
"searchKeybindings": "搜索快捷键",
@@ -410,7 +411,7 @@
"resetView": "重置视图",
"selectMode": "选择模式",
"toggleLinkVisibility": "切换连线可见性",
"toggleMinimap": "切小地",
"toggleMinimap": "切小地",
"zoomIn": "放大",
"zoomOut": "缩小"
},
@@ -569,10 +570,6 @@
"applyingTexture": "应用纹理中...",
"backgroundColor": "背景颜色",
"camera": "摄影机",
"cameraType": {
"orthographic": "正交",
"perspective": "透视"
},
"clearRecording": "清除录制",
"edgeThreshold": "边缘阈值",
"export": "导出",
@@ -593,7 +590,6 @@
"wireframe": "线框"
},
"model": "模型",
"openIn3DViewer": "在 3D 檢視器中開啟",
"previewOutput": "预览输出",
"removeBackgroundImage": "移除背景图片",
"resizeNodeMatchOutput": "调整节点以匹配输出",
@@ -604,22 +600,8 @@
"switchCamera": "切换摄影机类型",
"switchingMaterialMode": "切换材质模式中...",
"upDirection": "上方向",
"upDirections": {
"original": "原始"
},
"uploadBackgroundImage": "上传背景图片",
"uploadTexture": "上传纹理",
"viewer": {
"apply": "套用",
"cameraSettings": "相機設定",
"cameraType": "相機類型",
"cancel": "取消",
"exportSettings": "匯出設定",
"lightSettings": "燈光設定",
"modelSettings": "模型設定",
"sceneSettings": "場景設定",
"title": "3D 檢視器(測試版)"
}
"uploadTexture": "上传纹理"
},
"loadWorkflowWarning": {
"coreNodesFromVersion": "需要 ComfyUI {version}",
@@ -765,7 +747,7 @@
"Bypass/Unbypass Selected Nodes": "忽略/取消忽略选定节点",
"Canvas Toggle Link Visibility": "切换连线可见性",
"Canvas Toggle Lock": "切换视图锁定",
"Canvas Toggle Minimap": "布切小地",
"Canvas Toggle Minimap": "布切小地",
"Check for Updates": "检查更新",
"Clear Pending Tasks": "清除待处理任务",
"Clear Workflow": "清除工作流",
@@ -784,7 +766,6 @@
"Desktop User Guide": "桌面端用户指南",
"Duplicate Current Workflow": "复制当前工作流",
"Edit": "编辑",
"Exit Subgraph": "退出子圖",
"Export": "导出",
"Export (API)": "导出 (API)",
"Fit Group To Contents": "适应组内容",
@@ -804,7 +785,6 @@
"New": "新建",
"Next Opened Workflow": "下一个打开的工作流",
"Open": "打开",
"Open 3D Viewer (Beta) for Selected Node": "為所選節點開啟 3D 檢視器Beta 版)",
"Open Custom Nodes Folder": "打开自定义节点文件夹",
"Open DevTools": "打开开发者工具",
"Open Inputs Folder": "打开输入文件夹",
@@ -829,21 +809,21 @@
"Restart": "重启",
"Save": "保存",
"Save As": "另存为",
"Show Keybindings Dialog": "示快捷鍵對話框",
"Show Keybindings Dialog": "示快捷键对话框",
"Show Settings Dialog": "显示设置对话框",
"Sign Out": "退出登录",
"Toggle Bottom Panel": "切换底部面板",
"Toggle Essential Bottom Panel": "切基本下方面板",
"Toggle Essential Bottom Panel": "切基本下方面板",
"Toggle Focus Mode": "切换专注模式",
"Toggle Logs Bottom Panel": "切换日志底部面板",
"Toggle Model Library Sidebar": "切模型庫側邊欄",
"Toggle Node Library Sidebar": "切換節點庫側邊欄",
"Toggle Queue Sidebar": "切換佇列側邊欄",
"Toggle Model Library Sidebar": "切模型库侧边栏",
"Toggle Node Library Sidebar": "切换节点库侧边栏",
"Toggle Queue Sidebar": "切换队列侧边栏",
"Toggle Search Box": "切换搜索框",
"Toggle Terminal Bottom Panel": "切换终端底部面板",
"Toggle Theme (Dark/Light)": "切换主题(暗/亮)",
"Toggle View Controls Bottom Panel": "切換檢視控制下方面板",
"Toggle Workflows Sidebar": "切工作流程側邊欄",
"Toggle View Controls Bottom Panel": "切换检视控制下方面板",
"Toggle Workflows Sidebar": "切工作流程侧边栏",
"Toggle the Custom Nodes Manager": "切换自定义节点管理器",
"Toggle the Custom Nodes Manager Progress Bar": "切换自定义节点管理器进度条",
"Undo": "撤销",
@@ -1118,7 +1098,6 @@
},
"settingsCategories": {
"3D": "3D",
"3DViewer": "3D 檢視器",
"API Nodes": "API 节点",
"About": "关于",
"Appearance": "外观",
@@ -1171,7 +1150,7 @@
"Workflow": "工作流"
},
"shortcuts": {
"essentials": "基本功能",
"essentials": "基本",
"keyboardShortcuts": "鍵盤快捷鍵",
"manageShortcuts": "管理快捷鍵",
"noKeybinding": "無快捷鍵",
@@ -1595,7 +1574,6 @@
"failedToExportModel": "无法将模型导出为 {format}",
"failedToFetchBalance": "获取余额失败:{error}",
"failedToFetchLogs": "无法获取服务器日志",
"failedToInitializeLoad3dViewer": "初始化 3D 檢視器失敗",
"failedToInitiateCreditPurchase": "发起积分购买失败:{error}",
"failedToPurchaseCredits": "购买积分失败:{error}",
"fileLoadError": "无法在 {fileName} 中找到工作流",

View File

@@ -119,10 +119,6 @@
"Straight": "直角线"
}
},
"Comfy_Load3D_3DViewerEnable": {
"name": "啟用 3D 檢視器(測試版)",
"tooltip": "為所選節點啟用 3D 檢視器(測試版)。此功能可讓您直接在全尺寸 3D 檢視器中瀏覽並互動 3D 模型。"
},
"Comfy_Load3D_BackgroundColor": {
"name": "初始背景颜色",
"tooltip": "控制3D场景的默认背景颜色。此设置决定新建3D组件时的背景外观但每个组件在创建后都可以单独调整。"

View File

@@ -489,7 +489,6 @@ const zSettings = z.object({
'Comfy.Load3D.LightIntensityMinimum': z.number(),
'Comfy.Load3D.LightAdjustmentIncrement': z.number(),
'Comfy.Load3D.CameraType': z.enum(['perspective', 'orthographic']),
'Comfy.Load3D.3DViewerEnable': z.boolean(),
'pysssss.SnapToGrid': z.boolean(),
/** VHS setting is used for queue video preview support. */
'VHS.AdvancedPreviews': z.string(),

View File

@@ -385,15 +385,8 @@ export class ComfyApp {
static pasteFromClipspace(node: LGraphNode) {
if (ComfyApp.clipspace) {
// image paste
let combinedImgSrc: string | undefined
if (
ComfyApp.clipspace.combinedIndex !== undefined &&
ComfyApp.clipspace.imgs &&
ComfyApp.clipspace.combinedIndex < ComfyApp.clipspace.imgs.length
) {
combinedImgSrc =
ComfyApp.clipspace.imgs[ComfyApp.clipspace.combinedIndex].src
}
const combinedImgSrc =
ComfyApp.clipspace.imgs?.[ComfyApp.clipspace.combinedIndex].src
if (ComfyApp.clipspace.imgs && node.imgs) {
if (node.images && ComfyApp.clipspace.images) {
if (ComfyApp.clipspace['img_paste_mode'] == 'selected') {

View File

@@ -0,0 +1,74 @@
import Fuse from 'fuse.js'
import type { ComfyCommandImpl } from '@/stores/commandStore'
export interface CommandSearchOptions {
limit?: number
}
export class CommandSearchService {
private fuse: Fuse<ComfyCommandImpl>
private commands: ComfyCommandImpl[]
constructor(commands: ComfyCommandImpl[]) {
this.commands = commands
this.fuse = new Fuse(commands, {
keys: [
{
name: 'translatedLabel',
weight: 2,
getFn: (command: ComfyCommandImpl) => command.getTranslatedLabel()
},
{ name: 'id', weight: 1 }
],
includeScore: true,
threshold: 0.4,
shouldSort: true,
minMatchCharLength: 1
})
}
public updateCommands(commands: ComfyCommandImpl[]) {
this.commands = commands
const options = {
keys: [
{
name: 'translatedLabel',
weight: 2,
getFn: (command: ComfyCommandImpl) => command.getTranslatedLabel()
},
{ name: 'id', weight: 1 }
],
includeScore: true,
threshold: 0.4,
shouldSort: true,
minMatchCharLength: 1
}
this.fuse = new Fuse(commands, options)
}
public searchCommands(
query: string,
options?: CommandSearchOptions
): ComfyCommandImpl[] {
// Remove the leading ">" if present
const searchQuery = query.startsWith('>') ? query.slice(1).trim() : query
// If empty query, return all commands sorted alphabetically by translated label
if (!searchQuery) {
const sortedCommands = [...this.commands].sort((a, b) => {
const labelA = a.getTranslatedLabel()
const labelB = b.getTranslatedLabel()
return labelA.localeCompare(labelB)
})
return options?.limit
? sortedCommands.slice(0, options.limit)
: sortedCommands
}
const results = this.fuse.search(searchQuery)
const commands = results.map((result) => result.item)
return options?.limit ? commands.slice(0, options.limit) : commands
}
}

View File

@@ -1,6 +1,5 @@
import { CORE_KEYBINDINGS } from '@/constants/coreKeybindings'
import { useCommandStore } from '@/stores/commandStore'
import { useDialogStore } from '@/stores/dialogStore'
import {
KeyComboImpl,
KeybindingImpl,
@@ -12,7 +11,6 @@ export const useKeybindingService = () => {
const keybindingStore = useKeybindingStore()
const commandStore = useCommandStore()
const settingStore = useSettingStore()
const dialogStore = useDialogStore()
const keybindHandler = async function (event: KeyboardEvent) {
const keyCombo = KeyComboImpl.fromEvent(event)
@@ -34,19 +32,6 @@ export const useKeybindingService = () => {
const keybinding = keybindingStore.getKeybinding(keyCombo)
if (keybinding && keybinding.targetElementId !== 'graph-canvas') {
// Special handling for Escape key - let dialogs handle it first
if (
event.key === 'Escape' &&
!event.ctrlKey &&
!event.altKey &&
!event.metaKey
) {
// If dialogs are open, don't execute the keybinding - let the dialog handle it
if (dialogStore.dialogStack.length > 0) {
return
}
}
// Prevent default browser behavior first, then execute the command
event.preventDefault()
await commandStore.execute(keybinding.commandId)

View File

@@ -1,16 +1,12 @@
import { toRaw } from 'vue'
import { useLoad3dViewer } from '@/composables/useLoad3dViewer'
import Load3d from '@/extensions/core/load3d/Load3d'
import Load3dAnimation from '@/extensions/core/load3d/Load3dAnimation'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { NodeId } from '@/schemas/comfyWorkflowSchema'
import type { CustomInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
type Load3dReadyCallback = (load3d: Load3d | Load3dAnimation) => void
const viewerInstances = new Map<NodeId, any>()
export class Load3dService {
private static instance: Load3dService
private nodeToLoad3dMap = new Map<LGraphNode, Load3d | Load3dAnimation>()
@@ -130,110 +126,6 @@ export class Load3dService {
}
this.pendingCallbacks.clear()
}
getOrCreateViewer(node: LGraphNode) {
if (!viewerInstances.has(node.id)) {
viewerInstances.set(node.id, useLoad3dViewer(node))
}
return viewerInstances.get(node.id)
}
removeViewer(node: LGraphNode) {
const viewer = viewerInstances.get(node.id)
if (viewer) {
viewer.cleanup()
}
viewerInstances.delete(node.id)
}
async copyLoad3dState(source: Load3d, target: Load3d | Load3dAnimation) {
const sourceModel = source.modelManager.currentModel
if (sourceModel) {
const modelClone = sourceModel.clone()
target.getModelManager().currentModel = modelClone
target.getSceneManager().scene.add(modelClone)
target.getModelManager().materialMode =
source.getModelManager().materialMode
target.getModelManager().currentUpDirection =
source.getModelManager().currentUpDirection
target.setMaterialMode(source.getModelManager().materialMode)
target.setUpDirection(source.getModelManager().currentUpDirection)
if (source.getModelManager().appliedTexture) {
target.getModelManager().appliedTexture =
source.getModelManager().appliedTexture
}
}
const sourceCameraType = source.getCurrentCameraType()
const sourceCameraState = source.getCameraState()
target.toggleCamera(sourceCameraType)
target.setCameraState(sourceCameraState)
target.setBackgroundColor(source.getSceneManager().currentBackgroundColor)
target.toggleGrid(source.getSceneManager().gridHelper.visible)
const sourceBackgroundInfo = source
.getSceneManager()
.getCurrentBackgroundInfo()
if (sourceBackgroundInfo.type === 'image') {
const sourceNode = this.getNodeByLoad3d(source)
const backgroundPath = sourceNode?.properties?.[
'Background Image'
] as string
if (backgroundPath) {
await target.setBackgroundImage(backgroundPath)
}
}
target.setLightIntensity(
source.getLightingManager().lights[1]?.intensity || 1
)
if (sourceCameraType === 'perspective') {
target.setFOV(source.getCameraManager().perspectiveCamera.fov)
}
const sourceNode = this.getNodeByLoad3d(source)
if (sourceNode?.properties?.['Edge Threshold']) {
target.setEdgeThreshold(sourceNode.properties['Edge Threshold'] as number)
}
}
handleViewportRefresh(load3d: Load3d | null) {
if (!load3d) return
load3d.handleResize()
const currentType = load3d.getCurrentCameraType()
load3d.toggleCamera(
currentType === 'perspective' ? 'orthographic' : 'perspective'
)
load3d.toggleCamera(currentType)
load3d.getControlsManager().controls.update()
}
async handleViewerClose(node: LGraphNode) {
const viewer = useLoad3dService().getOrCreateViewer(node)
if (viewer.needApplyChanges.value) {
await viewer.applyChanges()
}
useLoad3dService().removeViewer(node)
}
}
export const useLoad3dService = () => {

View File

@@ -2,7 +2,9 @@ import { defineStore } from 'pinia'
import { computed, ref } from 'vue'
import { useErrorHandling } from '@/composables/useErrorHandling'
import { t } from '@/i18n'
import type { ComfyExtension } from '@/types/comfy'
import { normalizeI18nKey } from '@/utils/formatUtil'
import { type KeybindingImpl, useKeybindingStore } from './keybindingStore'
@@ -66,6 +68,19 @@ export class ComfyCommandImpl implements ComfyCommand {
get keybinding(): KeybindingImpl | null {
return useKeybindingStore().getKeybindingByCommandId(this.id)
}
getTranslatedLabel(): string {
// Use the same pattern as KeybindingPanel to get translated labels
return t(`commands.${normalizeI18nKey(this.id)}.label`, this.label ?? '')
}
getTranslatedMenubarLabel(): string {
// Use the same pattern but for menubar labels
return t(
`commands.${normalizeI18nKey(this.id)}.menubarLabel`,
this.menubarLabel ?? this.getTranslatedLabel()
)
}
}
export const useCommandStore = defineStore('command', () => {

View File

@@ -54,7 +54,7 @@ export const useMenuItemStore = defineStore('menuItem', () => {
(command) =>
({
command: () => commandStore.execute(command.id),
label: command.menubarLabel,
label: command.getTranslatedMenubarLabel(),
icon: command.icon,
tooltip: command.tooltip,
comfyCommand: command

View File

@@ -239,11 +239,3 @@ function compressSubgraphWidgetInputSlots(
compressSubgraphWidgetInputSlots(subgraph.definitions?.subgraphs, visited)
}
}
export function isLoad3dNode(node: LGraphNode) {
return (
node &&
node.type &&
(node.type === 'Load3D' || node.type === 'Load3DAnimation')
)
}

View File

@@ -0,0 +1,131 @@
import { describe, expect, it } from 'vitest'
import { CommandSearchService } from '@/services/commandSearchService'
import { ComfyCommandImpl } from '@/stores/commandStore'
describe('CommandSearchService', () => {
// Mock commands
const mockCommands: ComfyCommandImpl[] = [
new ComfyCommandImpl({
id: 'Comfy.NewBlankWorkflow',
label: 'New Blank Workflow',
icon: 'pi pi-plus',
function: () => {}
}),
new ComfyCommandImpl({
id: 'Comfy.SaveWorkflow',
label: 'Save Workflow',
icon: 'pi pi-save',
function: () => {}
}),
new ComfyCommandImpl({
id: 'Comfy.OpenWorkflow',
label: 'Open Workflow',
icon: 'pi pi-folder-open',
function: () => {}
}),
new ComfyCommandImpl({
id: 'Comfy.ClearWorkflow',
label: 'Clear Workflow',
icon: 'pi pi-trash',
function: () => {}
}),
new ComfyCommandImpl({
id: 'Comfy.Undo',
label: 'Undo',
icon: 'pi pi-undo',
function: () => {}
})
]
describe('searchCommands', () => {
it('should return all commands sorted alphabetically when query is empty', () => {
const service = new CommandSearchService(mockCommands)
const results = service.searchCommands('')
expect(results).toHaveLength(mockCommands.length)
expect(results[0].label).toBe('Clear Workflow')
expect(results[1].label).toBe('New Blank Workflow')
expect(results[2].label).toBe('Open Workflow')
expect(results[3].label).toBe('Save Workflow')
expect(results[4].label).toBe('Undo')
})
it('should handle query with leading ">"', () => {
const service = new CommandSearchService(mockCommands)
const results = service.searchCommands('>workflow')
expect(results.length).toBeGreaterThan(0)
expect(
results.every(
(cmd) =>
cmd.label?.toLowerCase().includes('workflow') ||
cmd.id.toLowerCase().includes('workflow')
)
).toBe(true)
})
it('should search by label', () => {
const service = new CommandSearchService(mockCommands)
const results = service.searchCommands('save')
expect(results).toHaveLength(1)
expect(results[0].label).toBe('Save Workflow')
})
it('should search by id', () => {
const service = new CommandSearchService(mockCommands)
const results = service.searchCommands('ClearWorkflow')
expect(results.length).toBeGreaterThan(0)
expect(results[0].id).toBe('Comfy.ClearWorkflow')
})
it('should respect search limit', () => {
const service = new CommandSearchService(mockCommands)
const results = service.searchCommands('', { limit: 2 })
expect(results).toHaveLength(2)
})
it('should handle partial matches', () => {
const service = new CommandSearchService(mockCommands)
const results = service.searchCommands('work')
expect(results.length).toBeGreaterThan(1)
expect(
results.every(
(cmd) =>
cmd.label?.toLowerCase().includes('work') ||
cmd.id.toLowerCase().includes('work')
)
).toBe(true)
})
it('should return empty array for no matches', () => {
const service = new CommandSearchService(mockCommands)
const results = service.searchCommands('xyz123')
expect(results).toHaveLength(0)
})
})
describe('updateCommands', () => {
it('should update the commands list', () => {
const service = new CommandSearchService(mockCommands)
const newCommands = [
new ComfyCommandImpl({
id: 'Test.Command',
label: 'Test Command',
function: () => {}
})
]
service.updateCommands(newCommands)
const results = service.searchCommands('')
expect(results).toHaveLength(1)
expect(results[0].id).toBe('Test.Command')
})
})
})

View File

@@ -41,7 +41,9 @@ const mockCommands: ComfyCommandImpl[] = [
icon: 'pi pi-test',
tooltip: 'Test tooltip',
menubarLabel: 'Other Command',
keybinding: null
keybinding: null,
getTranslatedLabel: () => 'Other Command',
getTranslatedMenubarLabel: () => 'Other Command'
} as ComfyCommandImpl
]

View File

@@ -32,7 +32,8 @@ describe('ShortcutsList', () => {
combo: {
getKeySequences: () => ['Control', 'n']
}
}
},
getTranslatedLabel: () => 'New Workflow'
} as ComfyCommandImpl,
{
id: 'Node.Add',
@@ -42,7 +43,8 @@ describe('ShortcutsList', () => {
combo: {
getKeySequences: () => ['Shift', 'a']
}
}
},
getTranslatedLabel: () => 'Add Node'
} as ComfyCommandImpl,
{
id: 'Queue.Clear',
@@ -52,7 +54,8 @@ describe('ShortcutsList', () => {
combo: {
getKeySequences: () => ['Control', 'Shift', 'c']
}
}
},
getTranslatedLabel: () => 'Clear Queue'
} as ComfyCommandImpl
]
@@ -104,7 +107,8 @@ describe('ShortcutsList', () => {
id: 'No.Keybinding',
label: 'No Keybinding',
category: 'essentials',
keybinding: null
keybinding: null,
getTranslatedLabel: () => 'No Keybinding'
} as ComfyCommandImpl
]
@@ -130,7 +134,8 @@ describe('ShortcutsList', () => {
combo: {
getKeySequences: () => ['Meta', 'ArrowUp', 'Enter', 'Escape', ' ']
}
}
},
getTranslatedLabel: () => 'Special Keys'
} as ComfyCommandImpl
const wrapper = mount(ShortcutsList, {

View File

@@ -1,606 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick } from 'vue'
import { useLoad3dViewer } from '@/composables/useLoad3dViewer'
import Load3d from '@/extensions/core/load3d/Load3d'
import Load3dUtils from '@/extensions/core/load3d/Load3dUtils'
import { useLoad3dService } from '@/services/load3dService'
import { useToastStore } from '@/stores/toastStore'
vi.mock('@/services/load3dService', () => ({
useLoad3dService: vi.fn()
}))
vi.mock('@/stores/toastStore', () => ({
useToastStore: vi.fn()
}))
vi.mock('@/extensions/core/load3d/Load3dUtils', () => ({
default: {
uploadFile: vi.fn()
}
}))
vi.mock('@/i18n', () => ({
t: vi.fn((key) => key)
}))
vi.mock('@/extensions/core/load3d/Load3d', () => ({
default: vi.fn()
}))
describe('useLoad3dViewer', () => {
let mockLoad3d: any
let mockSourceLoad3d: any
let mockLoad3dService: any
let mockToastStore: any
let mockNode: any
beforeEach(() => {
vi.clearAllMocks()
mockNode = {
properties: {
'Background Color': '#282828',
'Show Grid': true,
'Camera Type': 'perspective',
FOV: 75,
'Light Intensity': 1,
'Camera Info': null,
'Background Image': '',
'Up Direction': 'original',
'Material Mode': 'original',
'Edge Threshold': 85
},
graph: {
setDirtyCanvas: vi.fn()
}
} as any
mockLoad3d = {
setBackgroundColor: vi.fn(),
toggleGrid: vi.fn(),
toggleCamera: vi.fn(),
setFOV: vi.fn(),
setLightIntensity: vi.fn(),
setBackgroundImage: vi.fn().mockResolvedValue(undefined),
setUpDirection: vi.fn(),
setMaterialMode: vi.fn(),
setEdgeThreshold: vi.fn(),
exportModel: vi.fn().mockResolvedValue(undefined),
handleResize: vi.fn(),
updateStatusMouseOnViewer: vi.fn(),
getCameraState: vi.fn().mockReturnValue({
position: { x: 0, y: 0, z: 0 },
target: { x: 0, y: 0, z: 0 },
zoom: 1,
cameraType: 'perspective'
}),
forceRender: vi.fn(),
remove: vi.fn()
}
mockSourceLoad3d = {
getCurrentCameraType: vi.fn().mockReturnValue('perspective'),
getCameraState: vi.fn().mockReturnValue({
position: { x: 1, y: 1, z: 1 },
target: { x: 0, y: 0, z: 0 },
zoom: 1,
cameraType: 'perspective'
}),
sceneManager: {
currentBackgroundColor: '#282828',
gridHelper: { visible: true },
getCurrentBackgroundInfo: vi.fn().mockReturnValue({
type: 'color',
value: '#282828'
})
},
lightingManager: {
lights: [null, { intensity: 1 }]
},
cameraManager: {
perspectiveCamera: { fov: 75 }
},
modelManager: {
currentUpDirection: 'original',
materialMode: 'original'
},
setBackgroundImage: vi.fn().mockResolvedValue(undefined),
forceRender: vi.fn()
}
vi.mocked(Load3d).mockImplementation(() => mockLoad3d)
mockLoad3dService = {
copyLoad3dState: vi.fn().mockResolvedValue(undefined),
handleViewportRefresh: vi.fn(),
getLoad3d: vi.fn().mockReturnValue(mockSourceLoad3d)
}
vi.mocked(useLoad3dService).mockReturnValue(mockLoad3dService)
mockToastStore = {
addAlert: vi.fn()
}
vi.mocked(useToastStore).mockReturnValue(mockToastStore)
})
afterEach(() => {
vi.restoreAllMocks()
})
describe('initialization', () => {
it('should initialize with default values', () => {
const viewer = useLoad3dViewer(mockNode)
expect(viewer.backgroundColor.value).toBe('')
expect(viewer.showGrid.value).toBe(true)
expect(viewer.cameraType.value).toBe('perspective')
expect(viewer.fov.value).toBe(75)
expect(viewer.lightIntensity.value).toBe(1)
expect(viewer.backgroundImage.value).toBe('')
expect(viewer.hasBackgroundImage.value).toBe(false)
expect(viewer.upDirection.value).toBe('original')
expect(viewer.materialMode.value).toBe('original')
expect(viewer.edgeThreshold.value).toBe(85)
})
it('should initialize viewer with source Load3d state', async () => {
const viewer = useLoad3dViewer(mockNode)
const containerRef = document.createElement('div')
await viewer.initializeViewer(containerRef, mockSourceLoad3d)
expect(Load3d).toHaveBeenCalledWith(containerRef, {
disablePreview: true,
isViewerMode: true,
node: mockNode
})
expect(mockLoad3dService.copyLoad3dState).toHaveBeenCalledWith(
mockSourceLoad3d,
mockLoad3d
)
expect(viewer.cameraType.value).toBe('perspective')
expect(viewer.backgroundColor.value).toBe('#282828')
expect(viewer.showGrid.value).toBe(true)
expect(viewer.lightIntensity.value).toBe(1)
expect(viewer.fov.value).toBe(75)
expect(viewer.upDirection.value).toBe('original')
expect(viewer.materialMode.value).toBe('original')
expect(viewer.edgeThreshold.value).toBe(85)
})
it('should handle background image during initialization', async () => {
mockSourceLoad3d.sceneManager.getCurrentBackgroundInfo.mockReturnValue({
type: 'image',
value: ''
})
mockNode.properties['Background Image'] = 'test-image.jpg'
const viewer = useLoad3dViewer(mockNode)
const containerRef = document.createElement('div')
await viewer.initializeViewer(containerRef, mockSourceLoad3d)
expect(viewer.backgroundImage.value).toBe('test-image.jpg')
expect(viewer.hasBackgroundImage.value).toBe(true)
})
it('should handle initialization errors', async () => {
vi.mocked(Load3d).mockImplementationOnce(() => {
throw new Error('Load3d creation failed')
})
const viewer = useLoad3dViewer(mockNode)
const containerRef = document.createElement('div')
await viewer.initializeViewer(containerRef, mockSourceLoad3d)
expect(mockToastStore.addAlert).toHaveBeenCalledWith(
'toastMessages.failedToInitializeLoad3dViewer'
)
})
})
describe('state watchers', () => {
it('should update background color when state changes', async () => {
const viewer = useLoad3dViewer(mockNode)
const containerRef = document.createElement('div')
await viewer.initializeViewer(containerRef, mockSourceLoad3d)
viewer.backgroundColor.value = '#ff0000'
await nextTick()
expect(mockLoad3d.setBackgroundColor).toHaveBeenCalledWith('#ff0000')
})
it('should update grid visibility when state changes', async () => {
const viewer = useLoad3dViewer(mockNode)
const containerRef = document.createElement('div')
await viewer.initializeViewer(containerRef, mockSourceLoad3d)
viewer.showGrid.value = false
await nextTick()
expect(mockLoad3d.toggleGrid).toHaveBeenCalledWith(false)
})
it('should update camera type when state changes', async () => {
const viewer = useLoad3dViewer(mockNode)
const containerRef = document.createElement('div')
await viewer.initializeViewer(containerRef, mockSourceLoad3d)
viewer.cameraType.value = 'orthographic'
await nextTick()
expect(mockLoad3d.toggleCamera).toHaveBeenCalledWith('orthographic')
})
it('should update FOV when state changes', async () => {
const viewer = useLoad3dViewer(mockNode)
const containerRef = document.createElement('div')
await viewer.initializeViewer(containerRef, mockSourceLoad3d)
viewer.fov.value = 90
await nextTick()
expect(mockLoad3d.setFOV).toHaveBeenCalledWith(90)
})
it('should update light intensity when state changes', async () => {
const viewer = useLoad3dViewer(mockNode)
const containerRef = document.createElement('div')
await viewer.initializeViewer(containerRef, mockSourceLoad3d)
viewer.lightIntensity.value = 2
await nextTick()
expect(mockLoad3d.setLightIntensity).toHaveBeenCalledWith(2)
})
it('should update background image when state changes', async () => {
const viewer = useLoad3dViewer(mockNode)
const containerRef = document.createElement('div')
await viewer.initializeViewer(containerRef, mockSourceLoad3d)
viewer.backgroundImage.value = 'new-bg.jpg'
await nextTick()
expect(mockLoad3d.setBackgroundImage).toHaveBeenCalledWith('new-bg.jpg')
expect(viewer.hasBackgroundImage.value).toBe(true)
})
it('should update up direction when state changes', async () => {
const viewer = useLoad3dViewer(mockNode)
const containerRef = document.createElement('div')
await viewer.initializeViewer(containerRef, mockSourceLoad3d)
viewer.upDirection.value = '+y'
await nextTick()
expect(mockLoad3d.setUpDirection).toHaveBeenCalledWith('+y')
})
it('should update material mode when state changes', async () => {
const viewer = useLoad3dViewer(mockNode)
const containerRef = document.createElement('div')
await viewer.initializeViewer(containerRef, mockSourceLoad3d)
viewer.materialMode.value = 'wireframe'
await nextTick()
expect(mockLoad3d.setMaterialMode).toHaveBeenCalledWith('wireframe')
})
it('should update edge threshold when state changes', async () => {
const viewer = useLoad3dViewer(mockNode)
const containerRef = document.createElement('div')
await viewer.initializeViewer(containerRef, mockSourceLoad3d)
viewer.edgeThreshold.value = 90
await nextTick()
expect(mockLoad3d.setEdgeThreshold).toHaveBeenCalledWith(90)
})
it('should handle watcher errors gracefully', async () => {
mockLoad3d.setBackgroundColor.mockImplementationOnce(() => {
throw new Error('Color update failed')
})
const viewer = useLoad3dViewer(mockNode)
const containerRef = document.createElement('div')
await viewer.initializeViewer(containerRef, mockSourceLoad3d)
viewer.backgroundColor.value = '#ff0000'
await nextTick()
expect(mockToastStore.addAlert).toHaveBeenCalledWith(
'toastMessages.failedToUpdateBackgroundColor'
)
})
})
describe('exportModel', () => {
it('should export model successfully', async () => {
const viewer = useLoad3dViewer(mockNode)
const containerRef = document.createElement('div')
await viewer.initializeViewer(containerRef, mockSourceLoad3d)
await viewer.exportModel('glb')
expect(mockLoad3d.exportModel).toHaveBeenCalledWith('glb')
})
it('should handle export errors', async () => {
mockLoad3d.exportModel.mockRejectedValueOnce(new Error('Export failed'))
const viewer = useLoad3dViewer(mockNode)
const containerRef = document.createElement('div')
await viewer.initializeViewer(containerRef, mockSourceLoad3d)
await viewer.exportModel('glb')
expect(mockToastStore.addAlert).toHaveBeenCalledWith(
'toastMessages.failedToExportModel'
)
})
it('should not export when load3d is not initialized', async () => {
const viewer = useLoad3dViewer(mockNode)
await viewer.exportModel('glb')
expect(mockLoad3d.exportModel).not.toHaveBeenCalled()
})
})
describe('UI interaction methods', () => {
it('should handle resize', async () => {
const viewer = useLoad3dViewer(mockNode)
const containerRef = document.createElement('div')
await viewer.initializeViewer(containerRef, mockSourceLoad3d)
viewer.handleResize()
expect(mockLoad3d.handleResize).toHaveBeenCalled()
})
it('should handle mouse enter', async () => {
const viewer = useLoad3dViewer(mockNode)
const containerRef = document.createElement('div')
await viewer.initializeViewer(containerRef, mockSourceLoad3d)
viewer.handleMouseEnter()
expect(mockLoad3d.updateStatusMouseOnViewer).toHaveBeenCalledWith(true)
})
it('should handle mouse leave', async () => {
const viewer = useLoad3dViewer(mockNode)
const containerRef = document.createElement('div')
await viewer.initializeViewer(containerRef, mockSourceLoad3d)
viewer.handleMouseLeave()
expect(mockLoad3d.updateStatusMouseOnViewer).toHaveBeenCalledWith(false)
})
})
describe('restoreInitialState', () => {
it('should restore all properties to initial values', async () => {
const viewer = useLoad3dViewer(mockNode)
const containerRef = document.createElement('div')
await viewer.initializeViewer(containerRef, mockSourceLoad3d)
mockNode.properties['Background Color'] = '#ff0000'
mockNode.properties['Show Grid'] = false
viewer.restoreInitialState()
expect(mockNode.properties['Background Color']).toBe('#282828')
expect(mockNode.properties['Show Grid']).toBe(true)
expect(mockNode.properties['Camera Type']).toBe('perspective')
expect(mockNode.properties['FOV']).toBe(75)
expect(mockNode.properties['Light Intensity']).toBe(1)
})
})
describe('applyChanges', () => {
it('should apply all changes to source and node', async () => {
const viewer = useLoad3dViewer(mockNode)
const containerRef = document.createElement('div')
await viewer.initializeViewer(containerRef, mockSourceLoad3d)
viewer.backgroundColor.value = '#ff0000'
viewer.showGrid.value = false
const result = await viewer.applyChanges()
expect(result).toBe(true)
expect(mockNode.properties['Background Color']).toBe('#ff0000')
expect(mockNode.properties['Show Grid']).toBe(false)
expect(mockLoad3dService.copyLoad3dState).toHaveBeenCalledWith(
mockLoad3d,
mockSourceLoad3d
)
expect(mockSourceLoad3d.forceRender).toHaveBeenCalled()
expect(mockNode.graph.setDirtyCanvas).toHaveBeenCalledWith(true, true)
})
it('should handle background image during apply', async () => {
const viewer = useLoad3dViewer(mockNode)
const containerRef = document.createElement('div')
await viewer.initializeViewer(containerRef, mockSourceLoad3d)
viewer.backgroundImage.value = 'new-bg.jpg'
await viewer.applyChanges()
expect(mockSourceLoad3d.setBackgroundImage).toHaveBeenCalledWith(
'new-bg.jpg'
)
})
it('should return false when no load3d instances', async () => {
const viewer = useLoad3dViewer(mockNode)
const result = await viewer.applyChanges()
expect(result).toBe(false)
})
})
describe('refreshViewport', () => {
it('should refresh viewport', async () => {
const viewer = useLoad3dViewer(mockNode)
const containerRef = document.createElement('div')
await viewer.initializeViewer(containerRef, mockSourceLoad3d)
viewer.refreshViewport()
expect(mockLoad3dService.handleViewportRefresh).toHaveBeenCalledWith(
mockLoad3d
)
})
})
describe('handleBackgroundImageUpdate', () => {
it('should upload and set background image', async () => {
vi.mocked(Load3dUtils.uploadFile).mockResolvedValue('uploaded-image.jpg')
const viewer = useLoad3dViewer(mockNode)
const containerRef = document.createElement('div')
await viewer.initializeViewer(containerRef, mockSourceLoad3d)
const file = new File([''], 'test.jpg', { type: 'image/jpeg' })
await viewer.handleBackgroundImageUpdate(file)
expect(Load3dUtils.uploadFile).toHaveBeenCalledWith(file, '3d')
expect(viewer.backgroundImage.value).toBe('uploaded-image.jpg')
expect(viewer.hasBackgroundImage.value).toBe(true)
})
it('should use resource folder for upload', async () => {
mockNode.properties['Resource Folder'] = 'subfolder'
vi.mocked(Load3dUtils.uploadFile).mockResolvedValue('uploaded-image.jpg')
const viewer = useLoad3dViewer(mockNode)
const containerRef = document.createElement('div')
await viewer.initializeViewer(containerRef, mockSourceLoad3d)
const file = new File([''], 'test.jpg', { type: 'image/jpeg' })
await viewer.handleBackgroundImageUpdate(file)
expect(Load3dUtils.uploadFile).toHaveBeenCalledWith(file, '3d/subfolder')
})
it('should clear background image when file is null', async () => {
const viewer = useLoad3dViewer(mockNode)
const containerRef = document.createElement('div')
await viewer.initializeViewer(containerRef, mockSourceLoad3d)
viewer.backgroundImage.value = 'existing.jpg'
viewer.hasBackgroundImage.value = true
await viewer.handleBackgroundImageUpdate(null)
expect(viewer.backgroundImage.value).toBe('')
expect(viewer.hasBackgroundImage.value).toBe(false)
})
it('should handle upload errors', async () => {
vi.mocked(Load3dUtils.uploadFile).mockRejectedValueOnce(
new Error('Upload failed')
)
const viewer = useLoad3dViewer(mockNode)
const containerRef = document.createElement('div')
await viewer.initializeViewer(containerRef, mockSourceLoad3d)
const file = new File([''], 'test.jpg', { type: 'image/jpeg' })
await viewer.handleBackgroundImageUpdate(file)
expect(mockToastStore.addAlert).toHaveBeenCalledWith(
'toastMessages.failedToUploadBackgroundImage'
)
})
})
describe('cleanup', () => {
it('should clean up resources', async () => {
const viewer = useLoad3dViewer(mockNode)
const containerRef = document.createElement('div')
await viewer.initializeViewer(containerRef, mockSourceLoad3d)
viewer.cleanup()
expect(mockLoad3d.remove).toHaveBeenCalled()
})
it('should handle cleanup when not initialized', () => {
const viewer = useLoad3dViewer(mockNode)
expect(() => viewer.cleanup()).not.toThrow()
})
})
describe('edge cases', () => {
it('should handle missing container ref', async () => {
const viewer = useLoad3dViewer(mockNode)
await viewer.initializeViewer(null as any, mockSourceLoad3d)
expect(Load3d).not.toHaveBeenCalled()
})
it('should handle orthographic camera', async () => {
mockSourceLoad3d.getCurrentCameraType.mockReturnValue('orthographic')
mockSourceLoad3d.cameraManager = {} // No perspective camera
const viewer = useLoad3dViewer(mockNode)
const containerRef = document.createElement('div')
await viewer.initializeViewer(containerRef, mockSourceLoad3d)
expect(viewer.cameraType.value).toBe('orthographic')
})
it('should handle missing lights', async () => {
mockSourceLoad3d.lightingManager.lights = []
const viewer = useLoad3dViewer(mockNode)
const containerRef = document.createElement('div')
await viewer.initializeViewer(containerRef, mockSourceLoad3d)
expect(viewer.lightIntensity.value).toBe(1) // Default value
})
})
})

View File

@@ -1,202 +0,0 @@
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { CORE_KEYBINDINGS } from '@/constants/coreKeybindings'
import { useKeybindingService } from '@/services/keybindingService'
import { useCommandStore } from '@/stores/commandStore'
import { useDialogStore } from '@/stores/dialogStore'
import {
KeyComboImpl,
KeybindingImpl,
useKeybindingStore
} from '@/stores/keybindingStore'
// Mock stores
vi.mock('@/stores/settingStore', () => ({
useSettingStore: vi.fn(() => ({
get: vi.fn(() => [])
}))
}))
vi.mock('@/stores/dialogStore', () => ({
useDialogStore: vi.fn(() => ({
dialogStack: []
}))
}))
describe('keybindingService - Escape key handling', () => {
let keybindingService: ReturnType<typeof useKeybindingService>
let mockCommandExecute: ReturnType<typeof vi.fn>
beforeEach(() => {
vi.clearAllMocks()
setActivePinia(createPinia())
// Mock command store execute
mockCommandExecute = vi.fn()
const commandStore = useCommandStore()
commandStore.execute = mockCommandExecute
// Reset dialog store mock to empty
vi.mocked(useDialogStore).mockReturnValue({
dialogStack: []
} as any)
keybindingService = useKeybindingService()
keybindingService.registerCoreKeybindings()
})
it('should register Escape key for ExitSubgraph command', () => {
const keybindingStore = useKeybindingStore()
// Check that the Escape keybinding exists in core keybindings
const escapeKeybinding = CORE_KEYBINDINGS.find(
(kb) =>
kb.combo.key === 'Escape' && kb.commandId === 'Comfy.Graph.ExitSubgraph'
)
expect(escapeKeybinding).toBeDefined()
// Check that it was registered in the store
const registeredBinding = keybindingStore.getKeybinding(
new KeyComboImpl({ key: 'Escape' })
)
expect(registeredBinding).toBeDefined()
expect(registeredBinding?.commandId).toBe('Comfy.Graph.ExitSubgraph')
})
it('should execute ExitSubgraph command when Escape is pressed', async () => {
const event = new KeyboardEvent('keydown', {
key: 'Escape',
bubbles: true,
cancelable: true
})
// Mock event methods
event.preventDefault = vi.fn()
event.composedPath = vi.fn(() => [document.body])
await keybindingService.keybindHandler(event)
expect(event.preventDefault).toHaveBeenCalled()
expect(mockCommandExecute).toHaveBeenCalledWith('Comfy.Graph.ExitSubgraph')
})
it('should not execute command when Escape is pressed with modifiers', async () => {
const event = new KeyboardEvent('keydown', {
key: 'Escape',
ctrlKey: true,
bubbles: true,
cancelable: true
})
event.preventDefault = vi.fn()
event.composedPath = vi.fn(() => [document.body])
await keybindingService.keybindHandler(event)
expect(mockCommandExecute).not.toHaveBeenCalled()
})
it('should not execute command when typing in input field', async () => {
const inputElement = document.createElement('input')
const event = new KeyboardEvent('keydown', {
key: 'Escape',
bubbles: true,
cancelable: true
})
event.preventDefault = vi.fn()
event.composedPath = vi.fn(() => [inputElement])
await keybindingService.keybindHandler(event)
expect(mockCommandExecute).not.toHaveBeenCalled()
})
it('should close dialogs when no keybinding is registered', async () => {
// Remove the Escape keybinding
const keybindingStore = useKeybindingStore()
keybindingStore.unsetKeybinding(
new KeybindingImpl({
commandId: 'Comfy.Graph.ExitSubgraph',
combo: { key: 'Escape' }
})
)
// Create a mock dialog
const dialog = document.createElement('dialog')
dialog.open = true
dialog.close = vi.fn()
document.body.appendChild(dialog)
const event = new KeyboardEvent('keydown', {
key: 'Escape',
bubbles: true,
cancelable: true
})
event.composedPath = vi.fn(() => [document.body])
await keybindingService.keybindHandler(event)
expect(dialog.close).toHaveBeenCalled()
expect(mockCommandExecute).not.toHaveBeenCalled()
// Cleanup
document.body.removeChild(dialog)
})
it('should allow user to rebind Escape key to different command', async () => {
const keybindingStore = useKeybindingStore()
// Add a user keybinding for Escape to a different command
keybindingStore.addUserKeybinding(
new KeybindingImpl({
commandId: 'Custom.Command',
combo: { key: 'Escape' }
})
)
const event = new KeyboardEvent('keydown', {
key: 'Escape',
bubbles: true,
cancelable: true
})
event.preventDefault = vi.fn()
event.composedPath = vi.fn(() => [document.body])
await keybindingService.keybindHandler(event)
expect(event.preventDefault).toHaveBeenCalled()
expect(mockCommandExecute).toHaveBeenCalledWith('Custom.Command')
expect(mockCommandExecute).not.toHaveBeenCalledWith(
'Comfy.Graph.ExitSubgraph'
)
})
it('should not execute Escape keybinding when dialogs are open', async () => {
// Mock dialog store to have open dialogs
vi.mocked(useDialogStore).mockReturnValue({
dialogStack: [{ key: 'test-dialog' }]
} as any)
// Re-create keybinding service to pick up new mock
keybindingService = useKeybindingService()
const event = new KeyboardEvent('keydown', {
key: 'Escape',
bubbles: true,
cancelable: true
})
event.preventDefault = vi.fn()
event.composedPath = vi.fn(() => [document.body])
await keybindingService.keybindHandler(event)
// Should not call preventDefault or execute command
expect(event.preventDefault).not.toHaveBeenCalled()
expect(mockCommandExecute).not.toHaveBeenCalled()
})
})