mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-05-27 08:25:50 +00:00
Compare commits
73 Commits
sno-fix-pl
...
bl-slots-w
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b163d17d78 | ||
|
|
c52c798817 | ||
|
|
f6ae1b6417 | ||
|
|
896e44f66b | ||
|
|
8e098fc325 | ||
|
|
85fa2f4559 | ||
|
|
07b7ed9385 | ||
|
|
817f4d036a | ||
|
|
6eeba70f10 | ||
|
|
7d8bdcb05a | ||
|
|
0aed837ff4 | ||
|
|
b7fd1f476c | ||
|
|
358d98e268 | ||
|
|
df36693ecb | ||
|
|
f6051f6634 | ||
|
|
e8dae57a6a | ||
|
|
1e307564f0 | ||
|
|
9ab075f11c | ||
|
|
3e5effeafc | ||
|
|
0b941583d2 | ||
|
|
7149af612a | ||
|
|
4fc89847ab | ||
|
|
2f512b847d | ||
|
|
b0f2a1d00a | ||
|
|
73a1feea99 | ||
|
|
7130794cba | ||
|
|
7b7f9bbb17 | ||
|
|
0f5315f24c | ||
|
|
2425f653e4 | ||
|
|
1480dd748a | ||
|
|
f99c9de72f | ||
|
|
2a64f538f3 | ||
|
|
6a3c075df1 | ||
|
|
c30f5a41a9 | ||
|
|
8a10387fdf | ||
|
|
32cffa6a83 | ||
|
|
1dbbf20124 | ||
|
|
f83801e998 | ||
|
|
969c8e6325 | ||
|
|
c6fc8e6f0f | ||
|
|
3ce3b67155 | ||
|
|
da042ae829 | ||
|
|
62096d46c1 | ||
|
|
2a5e0d231e | ||
|
|
08309595e0 | ||
|
|
3982f29fda | ||
|
|
57db10f408 | ||
|
|
1447b15f4c | ||
|
|
4a7c955a15 | ||
|
|
ba1fa1be25 | ||
|
|
5171decd8b | ||
|
|
5a74c019c7 | ||
|
|
934c650790 | ||
|
|
b2a828dda6 | ||
|
|
d1ed5ecc0d | ||
|
|
bfcbcf4873 | ||
|
|
0dd4ff2087 | ||
|
|
889d136154 | ||
|
|
c773230b21 | ||
|
|
8df41ab040 | ||
|
|
2b9a9e2371 | ||
|
|
4171219402 | ||
|
|
301355e018 | ||
|
|
ac17752c0b | ||
|
|
fc6943cdd3 | ||
|
|
9db96f2dcc | ||
|
|
19084e2799 | ||
|
|
6e04cb72b0 | ||
|
|
9c4d782b30 | ||
|
|
ac60d1ceb4 | ||
|
|
06d0a63a2f | ||
|
|
2dcfe84e99 | ||
|
|
f42a4dd6cc |
62
.github/workflows/pr-playwright-deploy.yaml
vendored
62
.github/workflows/pr-playwright-deploy.yaml
vendored
@@ -37,8 +37,6 @@ jobs:
|
||||
}
|
||||
|
||||
const pr = pullRequests[0];
|
||||
console.log(`✅ Found PR #${pr.number} for branch: ${context.payload.workflow_run.head_branch}`);
|
||||
console.log(`PR number is: ${pr.number}`);
|
||||
const branchName = context.payload.workflow_run.head_branch;
|
||||
const sanitizedBranch = branchName.toLowerCase().replace(/[^a-z0-9-]/g, '-').replace(/--+/g, '-').replace(/^-|-$/g, '');
|
||||
|
||||
@@ -80,38 +78,14 @@ jobs:
|
||||
RETRY_COUNT=0
|
||||
MAX_RETRIES=3
|
||||
SUCCESS=false
|
||||
DEPLOYMENT_URL=""
|
||||
|
||||
while [ $RETRY_COUNT -lt $MAX_RETRIES ] && [ $SUCCESS = false ]; do
|
||||
RETRY_COUNT=$((RETRY_COUNT + 1))
|
||||
echo "Deployment attempt $RETRY_COUNT of $MAX_RETRIES..."
|
||||
|
||||
# Capture wrangler output to extract deployment URL
|
||||
OUTPUT=$(npx wrangler pages deploy playwright-report --project-name=${{ steps.project-name.outputs.name }} --branch=${{ steps.project-name.outputs.branch }} 2>&1)
|
||||
EXIT_CODE=$?
|
||||
|
||||
echo "$OUTPUT"
|
||||
|
||||
if [ $EXIT_CODE -eq 0 ]; then
|
||||
if npx wrangler pages deploy playwright-report --project-name=${{ steps.project-name.outputs.name }} --branch=${{ steps.project-name.outputs.branch }}; then
|
||||
SUCCESS=true
|
||||
echo "Deployment successful on attempt $RETRY_COUNT"
|
||||
# Extract the deployment URL from wrangler output
|
||||
# Look for the URL in various formats that wrangler might output
|
||||
DEPLOYMENT_URL=$(echo "$OUTPUT" | grep -oE 'https://[a-z0-9.-]+\.pages\.dev(/[^[:space:]]*)?$' | head -1)
|
||||
if [ -z "$DEPLOYMENT_URL" ]; then
|
||||
# Try another pattern if the first one fails
|
||||
DEPLOYMENT_URL=$(echo "$OUTPUT" | grep -oE 'https://[^[:space:]]+\.pages\.dev' | head -1)
|
||||
fi
|
||||
if [ -n "$DEPLOYMENT_URL" ]; then
|
||||
echo "Deployment URL: $DEPLOYMENT_URL"
|
||||
echo "url=$DEPLOYMENT_URL" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "Warning: Could not extract deployment URL from wrangler output"
|
||||
# Construct expected URL as fallback
|
||||
FALLBACK_URL="https://${{ steps.project-name.outputs.name }}-${{ steps.project-name.outputs.branch }}.pages.dev"
|
||||
echo "Using fallback URL: $FALLBACK_URL"
|
||||
echo "url=$FALLBACK_URL" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
else
|
||||
echo "Deployment failed on attempt $RETRY_COUNT"
|
||||
if [ $RETRY_COUNT -lt $MAX_RETRIES ]; then
|
||||
@@ -129,32 +103,6 @@ jobs:
|
||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||
|
||||
- name: Save deployment info
|
||||
if: fromJSON(steps.pr-info.outputs.result).number != null && always()
|
||||
run: |
|
||||
# Read test exit code from the artifact
|
||||
TEST_EXIT_CODE="1"
|
||||
if [ -f "playwright-report/test-exit-code.txt" ]; then
|
||||
TEST_EXIT_CODE=$(cat playwright-report/test-exit-code.txt)
|
||||
fi
|
||||
|
||||
# Use deployment URL if available, otherwise use a fallback
|
||||
URL="${{ steps.cloudflare-deploy.outputs.url }}"
|
||||
if [ -z "$URL" ] || [ "${{ steps.cloudflare-deploy.outcome }}" != "success" ]; then
|
||||
URL="https://${{ steps.project-name.outputs.name }}-${{ steps.project-name.outputs.branch }}.pages.dev"
|
||||
fi
|
||||
|
||||
echo "${{ matrix.browser }}|$TEST_EXIT_CODE|$URL" > deployment-info.txt
|
||||
echo "Saved deployment info: ${{ matrix.browser }}|$TEST_EXIT_CODE|$URL"
|
||||
|
||||
- name: Upload deployment info
|
||||
if: fromJSON(steps.pr-info.outputs.result).number != null && always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: deployment-info-${{ matrix.browser }}
|
||||
path: deployment-info.txt
|
||||
retention-days: 1
|
||||
|
||||
comment-tests-starting:
|
||||
runs-on: ubuntu-latest
|
||||
if: github.repository == 'Comfy-Org/ComfyUI_frontend' && github.event.workflow_run.event == 'pull_request' && github.event.action == 'requested'
|
||||
@@ -179,9 +127,7 @@ jobs:
|
||||
return null;
|
||||
}
|
||||
|
||||
const prNumber = pullRequests[0].number;
|
||||
console.log(`✅ Found PR #${prNumber} for branch: ${context.payload.workflow_run.head_branch}`);
|
||||
return prNumber;
|
||||
return pullRequests[0].number;
|
||||
|
||||
- name: Get completion time
|
||||
id: completion-time
|
||||
@@ -242,9 +188,7 @@ jobs:
|
||||
return null;
|
||||
}
|
||||
|
||||
const prNumber = pullRequests[0].number;
|
||||
console.log(`✅ Found PR #${prNumber} for branch: ${context.payload.workflow_run.head_branch}`);
|
||||
return prNumber;
|
||||
return pullRequests[0].number;
|
||||
|
||||
- name: Download all deployment info
|
||||
if: steps.pr.outputs.result != 'null'
|
||||
|
||||
11
.github/workflows/test-ui.yaml
vendored
11
.github/workflows/test-ui.yaml
vendored
@@ -231,21 +231,12 @@ jobs:
|
||||
id: playwright
|
||||
run: npx playwright test --project=${{ matrix.browser }} --reporter=html
|
||||
working-directory: ComfyUI_frontend
|
||||
continue-on-error: true
|
||||
|
||||
- name: Save test exit code
|
||||
if: always()
|
||||
run: |
|
||||
echo "${{ steps.playwright.outcome == 'success' && '0' || '1' }}" > test-exit-code.txt
|
||||
echo "Test outcome: ${{ steps.playwright.outcome }}"
|
||||
|
||||
- uses: actions/upload-artifact@v4
|
||||
if: always()
|
||||
with:
|
||||
name: playwright-report-${{ matrix.browser }}
|
||||
path: |
|
||||
ComfyUI_frontend/playwright-report/
|
||||
test-exit-code.txt
|
||||
path: ComfyUI_frontend/playwright-report/
|
||||
retention-days: 30
|
||||
|
||||
# Merge sharded test reports
|
||||
|
||||
12
.mcp.json
Normal file
12
.mcp.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"playwright": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "@executeautomation/playwright-mcp-server"]
|
||||
},
|
||||
"context7": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "@upstash/context7-mcp"]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,156 +0,0 @@
|
||||
# 4. Centralized Layout Management with CRDT
|
||||
|
||||
Date: 2025-08-27
|
||||
|
||||
## Status
|
||||
|
||||
Proposed
|
||||
|
||||
## Context
|
||||
|
||||
ComfyUI's node graph editor currently suffers from fundamental architectural limitations around spatial data management that prevent us from achieving key product goals.
|
||||
|
||||
### Current Architecture Problems
|
||||
|
||||
The existing system allows each node to directly mutate its position within LiteGraph's canvas renderer. This creates several critical issues:
|
||||
|
||||
1. **Performance Bottlenecks**: UI updates require full graph traversals to detect position changes. Large workflows (100+ nodes) can create bottlenecks during interactions due to this O(n) polling approach.
|
||||
|
||||
2. **Position Conflicts**: Multiple systems (LiteGraph canvas, DOMwidgets.ts overlays) currently compete to control node positions. Future Vue widget overlays will compound this maintenance burden.
|
||||
|
||||
3. **No Collaboration Foundation**: Direct position mutations make concurrent editing impossible—there's no mechanism to merge conflicting position updates from multiple users.
|
||||
|
||||
4. **Renderer Lock-in**: Spatial data is tightly coupled to LiteGraph's canvas implementation, preventing alternative rendering approaches (WebGL, DOM, other libraries, hybrid approaches).
|
||||
|
||||
5. **Inefficient Change Detection**: While LiteGraph provides some events, many operations require polling via changeTracker.ts. The current undo/redo system performs expensive diffs on every interaction rather than using reactive push/pull signals, creating performance bottlenecks and blocking efficient animations and viewport culling.
|
||||
|
||||
This represents a fundamental architectural limitation: diff-based systems scale O(n) with graph complexity (traverse entire structure to detect changes), while signal-based reactive systems scale O(1) with actual changes (data mutations automatically notify subscribers). Modern frameworks (Vue 3, Angular signals, SolidJS) have moved to reactive approaches for precisely this performance reason.
|
||||
|
||||
### Business Context
|
||||
|
||||
- Performance issues emerge with workflow complexity (100+ nodes)
|
||||
- The AI workflow community increasingly expects collaborative features (similar to Figma, Miro)
|
||||
- Accessibility requirements will necessitate DOM-based rendering options
|
||||
- Technical debt compounds with each new spatial feature
|
||||
|
||||
This decision builds on [ADR-0001 (Merge LiteGraph)](0001-merge-litegraph-into-frontend.md), which enables the architectural restructuring proposed here.
|
||||
|
||||
## Decision
|
||||
|
||||
We will implement a centralized layout management system using CRDT (Conflict-free Replicated Data Types) with command pattern architecture to separate spatial data from rendering behavior.
|
||||
|
||||
### Centralized State Management Foundation
|
||||
|
||||
This solution applies proven centralized state management patterns:
|
||||
|
||||
- **Centralized Store**: All spatial data (position, size, bounds, transform) managed in a single CRDT-backed store
|
||||
- **Command Interface**: All mutations flow through explicit commands rather than direct property access
|
||||
- **Observer Pattern**: Independent systems (rendering, interaction, layout) subscribe to state changes
|
||||
- **Domain Separation**: Layout logic completely separated from rendering and UI concerns
|
||||
|
||||
This provides single source of truth, predictable state updates, and natural system decoupling—solving our core architectural problems.
|
||||
|
||||
### Core Architecture
|
||||
|
||||
1. **Centralized Layout Store**: A Yjs CRDT maintains all spatial data in a single authoritative store:
|
||||
```typescript
|
||||
// Instead of: node.position = {x, y}
|
||||
layoutStore.moveNode(nodeId, {x, y})
|
||||
```
|
||||
|
||||
2. **Command Pattern**: All spatial mutations flow through explicit commands:
|
||||
```
|
||||
User Input → Commands → Layout Store → Observer Notifications → Renderers
|
||||
```
|
||||
|
||||
3. **Observer-Based Systems**: Multiple independent systems subscribe to layout changes:
|
||||
- **Rendering Systems**: LiteGraph canvas, WebGL, DOM accessibility renderers
|
||||
- **Interaction Systems**: Drag handlers, selection, hover states
|
||||
- **Layout Systems**: Auto-layout, alignment, distribution
|
||||
- **Animation Systems**: Smooth transitions, physics simulations
|
||||
|
||||
4. **Reactive Updates**: Store changes propagate through observers, eliminating polling and enabling efficient system coordination.
|
||||
|
||||
### Implementation Strategy
|
||||
|
||||
**Phase 1: Parallel System**
|
||||
- Build CRDT layout store alongside existing system
|
||||
- Layout store initially mirrors LiteGraph changes via observers
|
||||
- Gradually migrate user interactions to use command interface
|
||||
- Maintain full backward compatibility
|
||||
|
||||
**Phase 2: Inversion of Control**
|
||||
- CRDT store becomes single source of truth
|
||||
- LiteGraph receives position updates via reactive subscriptions
|
||||
- Enable alternative renderers and advanced features
|
||||
|
||||
### Why Centralized State + CRDT?
|
||||
|
||||
This combination provides both architectural and technical benefits:
|
||||
|
||||
**Centralized State Benefits:**
|
||||
- **Single Source of Truth**: All layout data managed in one place, eliminating conflicts
|
||||
- **System Decoupling**: Rendering, interaction, and layout systems operate independently
|
||||
- **Predictable Updates**: Clear data flow makes debugging and testing easier
|
||||
- **Extensibility**: Easy to add new layout behaviors without modifying existing systems
|
||||
|
||||
**CRDT Benefits:**
|
||||
- **Conflict Resolution**: Automatic merging eliminates position conflicts between systems
|
||||
- **Collaboration-Ready**: Built-in support for multi-user editing
|
||||
- **Eventual Consistency**: Guaranteed convergence to same state across all clients
|
||||
|
||||
**Yjs-Specific Benefits:**
|
||||
- **Event-Driven**: Native observer pattern removes need for polling
|
||||
- **Selective Updates**: Only changed nodes trigger system updates
|
||||
- **Fine-Grained Changes**: Efficient delta synchronization
|
||||
|
||||
## Consequences
|
||||
|
||||
### Positive
|
||||
|
||||
- **Eliminates Polling**: Observer pattern removes O(n) graph traversals, improving performance
|
||||
- **System Modularity**: Independent systems can be developed, tested, and optimized separately
|
||||
- **Renderer Flexibility**: Easy to add WebGL, DOM accessibility, or hybrid rendering systems
|
||||
- **Rich Interactions**: Command pattern enables robust undo/redo, macros, and interaction history
|
||||
- **Collaboration-Ready**: CRDT foundation enables real-time multi-user editing
|
||||
- **Conflict Resolution**: Eliminates position "snap-back" behavior between competing systems
|
||||
- **Better Developer Experience**: Clear separation of concerns and predictable data flow patterns
|
||||
|
||||
### Negative
|
||||
|
||||
- **Learning Curve**: Team must understand CRDT concepts and centralized state management
|
||||
- **Migration Complexity**: Gradual migration of existing direct property access requires careful coordination
|
||||
- **Memory Overhead**: Yjs library (~30KB) plus operation history storage
|
||||
- **CRDT Performance**: CRDTs have computational overhead compared to direct property access
|
||||
- **Increased Abstraction**: Additional layer between user interactions and visual updates
|
||||
|
||||
### Risk Mitigations
|
||||
|
||||
- Provide comprehensive migration documentation and examples
|
||||
- Build compatibility layer for gradual, low-risk migration
|
||||
- Implement operation history pruning for long-running sessions
|
||||
- Phase implementation to validate approach before full migration
|
||||
|
||||
## Notes
|
||||
|
||||
This centralized state + CRDT architecture follows patterns from modern collaborative applications:
|
||||
|
||||
**Centralized State Management**: Similar to Redux/Vuex patterns in complex web applications, but with CRDT backing for collaboration. This provides predictable state updates while enabling real-time multi-user features.
|
||||
|
||||
**CRDT in Collaboration**: Tools like Figma, Linear, and Notion use similar approaches for real-time collaboration, demonstrating the effectiveness of separating authoritative data from presentation logic.
|
||||
|
||||
**Future Capabilities**: This foundation enables advanced features that would be difficult with the current architecture:
|
||||
- Macro recording and workflow automation
|
||||
- Programmatic layout optimization and constraints
|
||||
- API-driven workflow construction
|
||||
- Multiple simultaneous renderers (canvas + accessibility DOM)
|
||||
- Real-time collaborative editing
|
||||
- Advanced spatial features (physics, animations, auto-layout)
|
||||
|
||||
The architecture provides immediate single-user benefits while creating infrastructure for collaborative and advanced spatial features.
|
||||
|
||||
## References
|
||||
|
||||
- [Yjs Documentation](https://docs.yjs.dev/)
|
||||
- [CRDTs: The Hard Parts](https://martin.kleppmann.com/2020/07/06/crdt-hard-parts-hydra.html) by Martin Kleppmann
|
||||
- [Figma's Multiplayer Technology](https://www.figma.com/blog/how-figmas-multiplayer-technology-works/)
|
||||
@@ -11,7 +11,7 @@ An Architecture Decision Record captures an important architectural decision mad
|
||||
| ADR | Title | Status | Date |
|
||||
|-----|-------|--------|------|
|
||||
| [0001](0001-merge-litegraph-into-frontend.md) | Merge LiteGraph.js into ComfyUI Frontend | Accepted | 2025-08-05 |
|
||||
| [0002](0002-monorepo-conversion.md) | Restructure as a Monorepo | Accepted | 2025-08-25 |
|
||||
| [0002](0002-monorepo-conversion.md) | Restructure as a Monorepo | Proposed | 2025-08-25 |
|
||||
|
||||
## Creating a New ADR
|
||||
|
||||
@@ -77,4 +77,4 @@ Optional section for additional information, references, or clarifications.
|
||||
## Further Reading
|
||||
|
||||
- [Documenting Architecture Decisions](https://cognitect.com/blog/2011/11/15/documenting-architecture-decisions) by Michael Nygard
|
||||
- [Architecture Decision Records](https://adr.github.io/) - Collection of ADR resources
|
||||
- [Architecture Decision Records](https://adr.github.io/) - Collection of ADR resources
|
||||
@@ -16,7 +16,6 @@ const config: KnipConfig = {
|
||||
'tests-ui/**/*.{js,ts,vue}',
|
||||
'*.{js,ts,mts}'
|
||||
],
|
||||
ignoreBinaries: ['only-allow', 'openapi-typescript'],
|
||||
ignoreDependencies: [
|
||||
'@primeuix/forms',
|
||||
'@primeuix/styled',
|
||||
@@ -26,6 +25,7 @@ const config: KnipConfig = {
|
||||
'tailwindcss',
|
||||
'tailwindcss-primeui', // Need to figure out why tailwind plugin isn't applying
|
||||
// Dev
|
||||
'@executeautomation/playwright-mcp-server',
|
||||
'@trivago/prettier-plugin-sort-imports'
|
||||
],
|
||||
ignore: [
|
||||
@@ -59,11 +59,7 @@ const config: KnipConfig = {
|
||||
'src/components/button/TextButton.vue',
|
||||
'src/components/card/CardTitle.vue',
|
||||
'src/components/card/CardDescription.vue',
|
||||
'src/components/input/SingleSelect.vue',
|
||||
// Used by a custom node (that should move off of this)
|
||||
'src/scripts/ui/components/splitButton.ts',
|
||||
// Generated file: openapi
|
||||
'src/types/comfyRegistryTypes.ts'
|
||||
'src/components/input/SingleSelect.vue'
|
||||
],
|
||||
ignoreExportsUsedInFile: true,
|
||||
// Vue-specific configuration
|
||||
@@ -72,12 +68,15 @@ const config: KnipConfig = {
|
||||
// Only check for unused files, disable all other rules
|
||||
// TODO: Gradually enable other rules - see https://github.com/Comfy-Org/ComfyUI_frontend/issues/4888
|
||||
rules: {
|
||||
classMembers: 'off'
|
||||
binaries: 'off',
|
||||
classMembers: 'off',
|
||||
duplicates: 'off',
|
||||
enumMembers: 'off',
|
||||
exports: 'off',
|
||||
nsExports: 'off',
|
||||
nsTypes: 'off',
|
||||
types: 'off'
|
||||
},
|
||||
tags: [
|
||||
'-knipIgnoreUnusedButUsedByCustomNodes',
|
||||
'-knipIgnoreUnusedButUsedByVueNodesBranch'
|
||||
],
|
||||
// Include dependencies analysis
|
||||
includeEntryExports: true
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@comfyorg/comfyui-frontend",
|
||||
"private": true,
|
||||
"version": "1.27.1",
|
||||
"version": "1.27.0",
|
||||
"type": "module",
|
||||
"repository": "https://github.com/Comfy-Org/ComfyUI_frontend",
|
||||
"homepage": "https://comfy.org",
|
||||
@@ -39,6 +39,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.8.0",
|
||||
"@executeautomation/playwright-mcp-server": "^1.0.6",
|
||||
"@iconify/tailwind": "^1.2.0",
|
||||
"@intlify/eslint-plugin-vue-i18n": "^3.2.0",
|
||||
"@lobehub/i18n-cli": "^1.25.1",
|
||||
|
||||
801
pnpm-lock.yaml
generated
801
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -183,6 +183,7 @@ let cleanupNodeManager: (() => void) | null = null
|
||||
|
||||
// Slot layout sync management
|
||||
let slotSync: ReturnType<typeof useSlotLayoutSync> | null = null
|
||||
let slotSyncStarted = false
|
||||
let linkSync: ReturnType<typeof useLinkLayoutSync> | null = null
|
||||
const vueNodeData = ref<ReadonlyMap<string, VueNodeData>>(new Map())
|
||||
const nodeState = ref<ReadonlyMap<string, NodeState>>(new Map())
|
||||
@@ -240,12 +241,6 @@ const initializeNodeManager = () => {
|
||||
const { startSync } = useLayoutSync()
|
||||
startSync(canvasStore.canvas)
|
||||
|
||||
// Initialize slot layout sync for hit detection
|
||||
slotSync = useSlotLayoutSync()
|
||||
if (canvasStore.canvas) {
|
||||
slotSync.start(canvasStore.canvas as LGraphCanvas)
|
||||
}
|
||||
|
||||
// Initialize link layout sync for event-driven updates
|
||||
linkSync = useLinkLayoutSync()
|
||||
if (canvasStore.canvas) {
|
||||
@@ -266,12 +261,6 @@ const disposeNodeManagerAndSyncs = () => {
|
||||
nodeManager = null
|
||||
cleanupNodeManager = null
|
||||
|
||||
// Clean up slot layout sync
|
||||
if (slotSync) {
|
||||
slotSync.stop()
|
||||
slotSync = null
|
||||
}
|
||||
|
||||
// Clean up link layout sync
|
||||
if (linkSync) {
|
||||
linkSync.stop()
|
||||
@@ -298,6 +287,68 @@ watch(
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
// Ensure slot layout sync starts whenever a canvas is available (LiteGraph mode)
|
||||
watch(
|
||||
() => canvasStore.canvas,
|
||||
(canvas, oldCanvas) => {
|
||||
if (!canvas) {
|
||||
// Canvas was removed - stop sync if active
|
||||
if (slotSync && slotSyncStarted) {
|
||||
slotSync.stop()
|
||||
slotSyncStarted = false
|
||||
}
|
||||
// Clear any stale slot layouts when canvas is torn down
|
||||
layoutStore.clearAllSlotLayouts()
|
||||
return
|
||||
}
|
||||
|
||||
// Canvas changed - restart sync
|
||||
if (oldCanvas && oldCanvas !== canvas) {
|
||||
if (slotSync && slotSyncStarted) {
|
||||
slotSync.stop()
|
||||
slotSyncStarted = false
|
||||
}
|
||||
}
|
||||
|
||||
// Start sync if not in Vue mode and not already started
|
||||
if (!slotSync) slotSync = useSlotLayoutSync()
|
||||
if (!slotSyncStarted && !isVueNodesEnabled.value) {
|
||||
const started = slotSync.start(canvas as LGraphCanvas)
|
||||
slotSyncStarted = started
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
// On rendering mode change, clear slot layouts and manage slot sync
|
||||
watch(
|
||||
() => isVueNodesEnabled.value,
|
||||
(enabled) => {
|
||||
// Always clear invalid slot layouts from the prior mode
|
||||
layoutStore.clearAllSlotLayouts()
|
||||
|
||||
if (enabled) {
|
||||
// Switching TO Vue: Stop slot sync to avoid duplicate registration
|
||||
if (slotSync && slotSyncStarted) {
|
||||
slotSync.stop()
|
||||
slotSyncStarted = false
|
||||
}
|
||||
// DOM will re-register via useDomSlotRegistration
|
||||
} else {
|
||||
// Switching TO LiteGraph
|
||||
if (canvasStore.canvas && comfyApp.graph) {
|
||||
// Ensure slot sync is active
|
||||
if (!slotSync) slotSync = useSlotLayoutSync()
|
||||
if (!slotSyncStarted) {
|
||||
const started = slotSync.start(canvasStore.canvas as LGraphCanvas)
|
||||
slotSyncStarted = started
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{ immediate: false }
|
||||
)
|
||||
|
||||
// Transform state for viewport culling
|
||||
const { syncWithCanvas } = useTransformState()
|
||||
|
||||
@@ -726,6 +777,7 @@ onUnmounted(() => {
|
||||
if (slotSync) {
|
||||
slotSync.stop()
|
||||
slotSync = null
|
||||
slotSyncStarted = false
|
||||
}
|
||||
if (linkSync) {
|
||||
linkSync.stop()
|
||||
|
||||
@@ -2,8 +2,7 @@ import { type Ref, computed, ref } from 'vue'
|
||||
|
||||
import type { TemplateInfo } from '@/types/workflowTemplateTypes'
|
||||
|
||||
// @ts-expect-error unused (To be used later?)
|
||||
interface TemplateFilterOptions {
|
||||
export interface TemplateFilterOptions {
|
||||
searchQuery?: string
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import {
|
||||
AutoLaunch,
|
||||
CrossAttentionMethod,
|
||||
CudaMalloc,
|
||||
FloatingPointPrecision,
|
||||
@@ -19,6 +20,32 @@ export interface ServerConfig<T> extends FormItem {
|
||||
getValue?: (value: T) => Record<string, ServerConfigValue>
|
||||
}
|
||||
|
||||
export const WEB_ONLY_CONFIG_ITEMS: ServerConfig<any>[] = [
|
||||
// Launch behavior
|
||||
{
|
||||
id: 'auto-launch',
|
||||
name: 'Automatically opens in the browser on startup',
|
||||
category: ['Launch'],
|
||||
type: 'combo',
|
||||
options: Object.values(AutoLaunch),
|
||||
defaultValue: AutoLaunch.Auto,
|
||||
getValue: (value: AutoLaunch) => {
|
||||
switch (value) {
|
||||
case AutoLaunch.Auto:
|
||||
return {}
|
||||
case AutoLaunch.Enable:
|
||||
return {
|
||||
['auto-launch']: true
|
||||
}
|
||||
case AutoLaunch.Disable:
|
||||
return {
|
||||
['disable-auto-launch']: true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
export const SERVER_CONFIG_ITEMS: ServerConfig<any>[] = [
|
||||
// Network settings
|
||||
{
|
||||
|
||||
@@ -185,3 +185,12 @@ export interface LoaderManagerInterface {
|
||||
dispose(): void
|
||||
loadModel(url: string, originalFileName?: string): Promise<void>
|
||||
}
|
||||
|
||||
export interface RecordingManagerInterface extends BaseManager {
|
||||
startRecording(): Promise<void>
|
||||
stopRecording(): void
|
||||
hasRecording(): boolean
|
||||
getRecordingDuration(): number
|
||||
exportRecording(filename?: string): void
|
||||
clearRecording(): void
|
||||
}
|
||||
|
||||
@@ -422,7 +422,6 @@ function getConfig(this: LGraphNode, widgetName: string) {
|
||||
* @param node The node to convert the widget to an input slot for.
|
||||
* @param widget The widget to convert to an input slot.
|
||||
* @returns The input slot that was converted from the widget or undefined if the widget is not found.
|
||||
* @knipIgnoreUnusedButUsedByCustomNodes
|
||||
*/
|
||||
export function convertToInput(
|
||||
node: LGraphNode,
|
||||
@@ -593,7 +592,7 @@ app.registerExtension({
|
||||
const node = LiteGraph.createNode('PrimitiveNode')
|
||||
if (!node) return r
|
||||
|
||||
this.graph?.add(node)
|
||||
app.graph.add(node)
|
||||
|
||||
// Calculate a position that wont directly overlap another node
|
||||
const pos: [number, number] = [
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { Rectangle } from './infrastructure/Rectangle'
|
||||
import type { CanvasColour, Rect } from './interfaces'
|
||||
import { LiteGraph } from './litegraph'
|
||||
import { RenderShape, TitleMode } from './types/globalEnums'
|
||||
import { LinkDirection, RenderShape, TitleMode } from './types/globalEnums'
|
||||
|
||||
const ELLIPSIS = '\u2026'
|
||||
const TWO_DOT_LEADER = '\u2025'
|
||||
@@ -22,7 +22,12 @@ export enum SlotShape {
|
||||
}
|
||||
|
||||
/** @see LinkDirection */
|
||||
export enum SlotDirection {}
|
||||
export enum SlotDirection {
|
||||
Up = LinkDirection.UP,
|
||||
Right = LinkDirection.RIGHT,
|
||||
Down = LinkDirection.DOWN,
|
||||
Left = LinkDirection.LEFT
|
||||
}
|
||||
|
||||
export enum LabelPosition {
|
||||
Left = 'left',
|
||||
|
||||
@@ -278,6 +278,9 @@ export type KeysOfType<T, Match> = Exclude<
|
||||
undefined
|
||||
>
|
||||
|
||||
/** A new type that contains only the properties of T that are of type Match */
|
||||
export type PickByType<T, Match> = { [P in keyof T]: Extract<T[P], Match> }
|
||||
|
||||
/** The names of all (optional) methods and functions in T */
|
||||
export type MethodNames<T> = KeysOfType<T, ((...args: any) => any) | undefined>
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { ContextMenu } from './ContextMenu'
|
||||
import type { LGraphNode } from './LGraphNode'
|
||||
import { LiteGraphGlobal } from './LiteGraphGlobal'
|
||||
import type { ConnectingLink, Point } from './interfaces'
|
||||
import type { IContextMenuOptions, Size } from './interfaces'
|
||||
import type { IContextMenuOptions, INodeSlot, Size } from './interfaces'
|
||||
import { loadPolyfills } from './polyfills'
|
||||
import type { CanvasEventDetail } from './types/events'
|
||||
import type { RenderShape, TitleMode } from './types/globalEnums'
|
||||
@@ -22,6 +22,8 @@ loadPolyfills()
|
||||
// Definitions by: NateScarlet <https://github.com/NateScarlet>
|
||||
/** @deprecated Use {@link Point} instead. */
|
||||
export type Vector2 = Point
|
||||
/** @deprecated Use {@link Rect} instead. */
|
||||
export type Vector4 = [number, number, number, number]
|
||||
|
||||
export interface IContextMenuItem {
|
||||
content: string
|
||||
@@ -44,6 +46,14 @@ export type ContextMenuEventListener = (
|
||||
node: LGraphNode
|
||||
) => boolean | void
|
||||
|
||||
export interface LinkReleaseContext {
|
||||
node_to?: LGraphNode
|
||||
node_from?: LGraphNode
|
||||
slot_from: INodeSlot
|
||||
type_filter_in?: string
|
||||
type_filter_out?: string
|
||||
}
|
||||
|
||||
export interface LinkReleaseContextExtended {
|
||||
links: ConnectingLink[]
|
||||
}
|
||||
@@ -107,6 +117,7 @@ export type {
|
||||
LinkNetwork,
|
||||
LinkSegment,
|
||||
MethodNames,
|
||||
PickByType,
|
||||
Point,
|
||||
Positionable,
|
||||
ReadonlyLinkNetwork,
|
||||
|
||||
@@ -84,6 +84,10 @@ export function isINodeInputSlot(slot: INodeSlot): slot is INodeInputSlot {
|
||||
return 'link' in slot
|
||||
}
|
||||
|
||||
export function isINodeOutputSlot(slot: INodeSlot): slot is INodeOutputSlot {
|
||||
return 'links' in slot
|
||||
}
|
||||
|
||||
/**
|
||||
* Type guard: Whether this input slot is attached to a widget.
|
||||
* @param slot The slot to check.
|
||||
|
||||
@@ -50,6 +50,9 @@ export interface CanvasMouseEvent
|
||||
Readonly<CanvasPointerExtensions>,
|
||||
LegacyMouseEvent {}
|
||||
|
||||
/** DragEvent with canvasX/Y and deltaX/Y properties */
|
||||
export interface CanvasDragEvent extends DragEvent, CanvasPointerExtensions {}
|
||||
|
||||
export type CanvasEventDetail =
|
||||
| GenericEventDetail
|
||||
| GroupDoubleClickEventDetail
|
||||
|
||||
@@ -89,6 +89,9 @@ export enum LGraphEventMode {
|
||||
}
|
||||
|
||||
export enum EaseFunction {
|
||||
LINEAR = 'linear',
|
||||
EASE_IN_QUAD = 'easeInQuad',
|
||||
EASE_OUT_QUAD = 'easeOutQuad',
|
||||
EASE_IN_OUT_QUAD = 'easeInOutQuad'
|
||||
}
|
||||
|
||||
|
||||
@@ -179,6 +179,14 @@ export interface ISerialisedGroup {
|
||||
flags?: IGraphGroupFlags
|
||||
}
|
||||
|
||||
export type TClipboardLink = [
|
||||
targetRelativeIndex: number,
|
||||
originSlot: number,
|
||||
nodeRelativeIndex: number,
|
||||
targetSlot: number,
|
||||
targetNodeId: NodeId
|
||||
]
|
||||
|
||||
/** Items copied from the canvas */
|
||||
export interface ClipboardItems {
|
||||
nodes?: ISerialisedNode[]
|
||||
@@ -188,6 +196,12 @@ export interface ClipboardItems {
|
||||
subgraphs?: ExportedSubgraph[]
|
||||
}
|
||||
|
||||
/** @deprecated */
|
||||
export interface IClipboardContents {
|
||||
nodes?: ISerialisedNode[]
|
||||
links?: TClipboardLink[]
|
||||
}
|
||||
|
||||
export interface SerialisableReroute {
|
||||
id: RerouteId
|
||||
parentId?: RerouteId
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
import type { LinkId } from '@/lib/litegraph/src/LLink'
|
||||
import { parseSlotTypes } from '@/lib/litegraph/src/strings'
|
||||
|
||||
import type { ISlotType, Positionable } from '../interfaces'
|
||||
import type { ConnectingLink, ISlotType, Positionable } from '../interfaces'
|
||||
|
||||
/**
|
||||
* Creates a flat set of all positionable items by recursively iterating through all child items.
|
||||
@@ -44,6 +45,19 @@ export function findFirstNode(
|
||||
}
|
||||
}
|
||||
|
||||
/** @returns `true` if the provided link ID is currently being dragged. */
|
||||
export function isDraggingLink(
|
||||
linkId: LinkId,
|
||||
connectingLinks: ConnectingLink[] | null | undefined
|
||||
): ConnectingLink | undefined {
|
||||
if (connectingLinks == null) return
|
||||
|
||||
for (const connectingLink of connectingLinks) {
|
||||
if (connectingLink.link == null) continue
|
||||
if (linkId === connectingLink.link.id) return connectingLink
|
||||
}
|
||||
}
|
||||
|
||||
type FreeSlotResult<T extends { type: ISlotType }> =
|
||||
| { index: number; slot: T }
|
||||
| undefined
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
import type {
|
||||
IBaseWidget,
|
||||
IBooleanWidget,
|
||||
IButtonWidget,
|
||||
IComboWidget,
|
||||
ICustomWidget,
|
||||
IKnobWidget,
|
||||
INumericWidget,
|
||||
ISliderWidget,
|
||||
IStringWidget,
|
||||
IWidget,
|
||||
TWidgetType
|
||||
} from '@/lib/litegraph/src/types/widgets'
|
||||
@@ -123,9 +130,49 @@ export function toConcreteWidget<TWidget extends IWidget | IBaseWidget>(
|
||||
|
||||
// #region Type Guards
|
||||
|
||||
/** Type guard: Narrow **from {@link IBaseWidget}** to {@link IButtonWidget}. */
|
||||
export function isButtonWidget(widget: IBaseWidget): widget is IButtonWidget {
|
||||
return widget.type === 'button'
|
||||
}
|
||||
|
||||
/** Type guard: Narrow **from {@link IBaseWidget}** to {@link IBooleanWidget}. */
|
||||
export function isBooleanWidget(widget: IBaseWidget): widget is IBooleanWidget {
|
||||
return widget.type === 'toggle'
|
||||
}
|
||||
|
||||
/** Type guard: Narrow **from {@link IBaseWidget}** to {@link ISliderWidget}. */
|
||||
export function isSliderWidget(widget: IBaseWidget): widget is ISliderWidget {
|
||||
return widget.type === 'slider'
|
||||
}
|
||||
|
||||
/** Type guard: Narrow **from {@link IBaseWidget}** to {@link IKnobWidget}. */
|
||||
export function isKnobWidget(widget: IBaseWidget): widget is IKnobWidget {
|
||||
return widget.type === 'knob'
|
||||
}
|
||||
|
||||
/** Type guard: Narrow **from {@link IBaseWidget}** to {@link IComboWidget}. */
|
||||
export function isComboWidget(widget: IBaseWidget): widget is IComboWidget {
|
||||
return widget.type === 'combo'
|
||||
}
|
||||
|
||||
/** Type guard: Narrow **from {@link IBaseWidget}** to {@link INumericWidget}. */
|
||||
export function isNumberWidget(widget: IBaseWidget): widget is INumericWidget {
|
||||
return widget.type === 'number'
|
||||
}
|
||||
|
||||
/** Type guard: Narrow **from {@link IBaseWidget}** to {@link IStringWidget}. */
|
||||
export function isStringWidget(widget: IBaseWidget): widget is IStringWidget {
|
||||
return widget.type === 'string'
|
||||
}
|
||||
|
||||
/** Type guard: Narrow **from {@link IBaseWidget}** to {@link ITextWidget}. */
|
||||
export function isTextWidget(widget: IBaseWidget): widget is IStringWidget {
|
||||
return widget.type === 'text'
|
||||
}
|
||||
|
||||
/** Type guard: Narrow **from {@link IBaseWidget}** to {@link ICustomWidget}. */
|
||||
export function isCustomWidget(widget: IBaseWidget): widget is ICustomWidget {
|
||||
return widget.type === 'custom'
|
||||
}
|
||||
|
||||
// #endregion Type Guards
|
||||
|
||||
@@ -169,3 +169,140 @@ export const subgraphTest = test.extend<SubgraphFixtures>({
|
||||
capture.cleanup()
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* Fixtures that test edge cases and error conditions.
|
||||
* These may leave the system in an invalid state and should be used carefully.
|
||||
*/
|
||||
export interface EdgeCaseFixtures {
|
||||
/** Subgraph with circular references (for testing recursion detection) */
|
||||
circularSubgraph: {
|
||||
rootGraph: LGraph
|
||||
subgraphA: Subgraph
|
||||
subgraphB: Subgraph
|
||||
nodeA: SubgraphNode
|
||||
nodeB: SubgraphNode
|
||||
}
|
||||
|
||||
/** Deeply nested subgraphs approaching the theoretical limit */
|
||||
deeplyNestedSubgraph: ReturnType<typeof createNestedSubgraphs>
|
||||
|
||||
/** Subgraph with maximum inputs and outputs */
|
||||
maxIOSubgraph: Subgraph
|
||||
}
|
||||
|
||||
/**
|
||||
* Test with edge case fixtures. Use sparingly and with caution.
|
||||
* These tests may intentionally create invalid states.
|
||||
*/
|
||||
export const edgeCaseTest = subgraphTest.extend<EdgeCaseFixtures>({
|
||||
// @ts-expect-error TODO: Fix after merge - fixture use parameter type
|
||||
// eslint-disable-next-line no-empty-pattern
|
||||
circularSubgraph: async ({}, use: (value: unknown) => Promise<void>) => {
|
||||
const rootGraph = new LGraph()
|
||||
|
||||
// Create two subgraphs that will reference each other
|
||||
const subgraphA = createTestSubgraph({
|
||||
name: 'Subgraph A',
|
||||
inputs: [{ name: 'input', type: '*' }],
|
||||
outputs: [{ name: 'output', type: '*' }]
|
||||
})
|
||||
|
||||
const subgraphB = createTestSubgraph({
|
||||
name: 'Subgraph B',
|
||||
inputs: [{ name: 'input', type: '*' }],
|
||||
outputs: [{ name: 'output', type: '*' }]
|
||||
})
|
||||
|
||||
// Create instances (this doesn't create circular refs by itself)
|
||||
const nodeA = createTestSubgraphNode(subgraphA, { pos: [100, 100] })
|
||||
const nodeB = createTestSubgraphNode(subgraphB, { pos: [300, 100] })
|
||||
|
||||
// Add nodes to root graph
|
||||
rootGraph.add(nodeA)
|
||||
rootGraph.add(nodeB)
|
||||
|
||||
await use({
|
||||
rootGraph,
|
||||
subgraphA,
|
||||
subgraphB,
|
||||
nodeA,
|
||||
nodeB
|
||||
})
|
||||
},
|
||||
|
||||
// @ts-expect-error TODO: Fix after merge - fixture use parameter type
|
||||
// eslint-disable-next-line no-empty-pattern
|
||||
deeplyNestedSubgraph: async ({}, use: (value: unknown) => Promise<void>) => {
|
||||
// Create a very deep nesting structure (but not exceeding MAX_NESTED_SUBGRAPHS)
|
||||
const nested = createNestedSubgraphs({
|
||||
depth: 50, // Deep but reasonable
|
||||
nodesPerLevel: 1,
|
||||
inputsPerSubgraph: 1,
|
||||
outputsPerSubgraph: 1
|
||||
})
|
||||
|
||||
await use(nested)
|
||||
},
|
||||
|
||||
// @ts-expect-error TODO: Fix after merge - fixture use parameter type
|
||||
// eslint-disable-next-line no-empty-pattern
|
||||
maxIOSubgraph: async ({}, use: (value: unknown) => Promise<void>) => {
|
||||
// Create a subgraph with many inputs and outputs
|
||||
const inputs = Array.from({ length: 20 }, (_, i) => ({
|
||||
name: `input_${i}`,
|
||||
type: i % 2 === 0 ? 'number' : ('string' as const)
|
||||
}))
|
||||
|
||||
const outputs = Array.from({ length: 20 }, (_, i) => ({
|
||||
name: `output_${i}`,
|
||||
type: i % 2 === 0 ? 'number' : ('string' as const)
|
||||
}))
|
||||
|
||||
const subgraph = createTestSubgraph({
|
||||
name: 'Max IO Subgraph',
|
||||
inputs,
|
||||
outputs,
|
||||
nodeCount: 10
|
||||
})
|
||||
|
||||
await use(subgraph)
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* Helper to verify fixture integrity.
|
||||
* Use this in tests to ensure fixtures are properly set up.
|
||||
*/
|
||||
export function verifyFixtureIntegrity<T extends Record<string, unknown>>(
|
||||
fixture: T,
|
||||
expectedProperties: (keyof T)[]
|
||||
): void {
|
||||
for (const prop of expectedProperties) {
|
||||
if (!(prop in fixture)) {
|
||||
throw new Error(`Fixture missing required property: ${String(prop)}`)
|
||||
}
|
||||
if (fixture[prop] === undefined || fixture[prop] === null) {
|
||||
throw new Error(`Fixture property ${String(prop)} is null or undefined`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a snapshot-friendly representation of a subgraph for testing.
|
||||
* Useful for serialization tests and regression detection.
|
||||
*/
|
||||
export function createSubgraphSnapshot(subgraph: Subgraph) {
|
||||
return {
|
||||
id: subgraph.id,
|
||||
name: subgraph.name,
|
||||
inputCount: subgraph.inputs.length,
|
||||
outputCount: subgraph.outputs.length,
|
||||
nodeCount: subgraph.nodes.length,
|
||||
linkCount: subgraph.links.size,
|
||||
inputs: subgraph.inputs.map((i) => ({ name: i.name, type: i.type })),
|
||||
outputs: subgraph.outputs.map((o) => ({ name: o.name, type: o.type })),
|
||||
hasInputNode: !!subgraph.inputNode,
|
||||
hasOutputNode: !!subgraph.outputNode
|
||||
}
|
||||
}
|
||||
|
||||
@@ -382,6 +382,76 @@ export function createTestSubgraphData(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a complex subgraph with multiple nodes and connections.
|
||||
* Useful for testing realistic scenarios.
|
||||
* @param nodeCount Number of internal nodes to create
|
||||
* @returns Complex subgraph data structure
|
||||
*/
|
||||
export function createComplexSubgraphData(
|
||||
nodeCount: number = 5
|
||||
): ExportedSubgraph {
|
||||
const nodes = []
|
||||
const links: Record<
|
||||
string,
|
||||
{
|
||||
id: number
|
||||
origin_id: number
|
||||
origin_slot: number
|
||||
target_id: number
|
||||
target_slot: number
|
||||
type: string
|
||||
}
|
||||
> = {}
|
||||
|
||||
// Create internal nodes
|
||||
for (let i = 0; i < nodeCount; i++) {
|
||||
nodes.push({
|
||||
id: i + 1, // Start from 1 to avoid conflicts with IO nodes
|
||||
type: 'basic/test',
|
||||
pos: [100 + i * 150, 200],
|
||||
size: [120, 60],
|
||||
inputs: [{ name: 'in', type: '*', link: null }],
|
||||
outputs: [{ name: 'out', type: '*', links: [] }],
|
||||
properties: { value: i },
|
||||
flags: {},
|
||||
mode: 0
|
||||
})
|
||||
}
|
||||
|
||||
// Create some internal links
|
||||
for (let i = 0; i < nodeCount - 1; i++) {
|
||||
const linkId = i + 1
|
||||
links[linkId] = {
|
||||
id: linkId,
|
||||
origin_id: i + 1,
|
||||
origin_slot: 0,
|
||||
target_id: i + 2,
|
||||
target_slot: 0,
|
||||
type: '*'
|
||||
}
|
||||
}
|
||||
|
||||
return createTestSubgraphData({
|
||||
// @ts-expect-error TODO: Fix after merge - nodes parameter type
|
||||
nodes,
|
||||
// @ts-expect-error TODO: Fix after merge - links parameter type
|
||||
links,
|
||||
inputs: [
|
||||
// @ts-expect-error TODO: Fix after merge - input object type
|
||||
{ name: 'input1', type: 'number', pos: [0, 0] },
|
||||
// @ts-expect-error TODO: Fix after merge - input object type
|
||||
{ name: 'input2', type: 'string', pos: [0, 1] }
|
||||
],
|
||||
outputs: [
|
||||
// @ts-expect-error TODO: Fix after merge - output object type
|
||||
{ name: 'output1', type: 'number', pos: [0, 0] },
|
||||
// @ts-expect-error TODO: Fix after merge - output object type
|
||||
{ name: 'output2', type: 'string', pos: [0, 1] }
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an event capture system for testing event sequences.
|
||||
* @param eventTarget The event target to monitor
|
||||
@@ -423,5 +493,39 @@ export function createEventCapture<T = unknown>(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Utility to log subgraph structure for debugging tests.
|
||||
* @param subgraph The subgraph to inspect
|
||||
* @param label Optional label for the log output
|
||||
*/
|
||||
export function logSubgraphStructure(
|
||||
subgraph: Subgraph,
|
||||
label: string = 'Subgraph'
|
||||
): void {
|
||||
console.log(`\n=== ${label} Structure ===`)
|
||||
console.log(`Name: ${subgraph.name}`)
|
||||
console.log(`ID: ${subgraph.id}`)
|
||||
console.log(`Inputs: ${subgraph.inputs.length}`)
|
||||
console.log(`Outputs: ${subgraph.outputs.length}`)
|
||||
console.log(`Nodes: ${subgraph.nodes.length}`)
|
||||
console.log(`Links: ${subgraph.links.size}`)
|
||||
|
||||
if (subgraph.inputs.length > 0) {
|
||||
console.log(
|
||||
'Input details:',
|
||||
subgraph.inputs.map((i) => ({ name: i.name, type: i.type }))
|
||||
)
|
||||
}
|
||||
|
||||
if (subgraph.outputs.length > 0) {
|
||||
console.log(
|
||||
'Output details:',
|
||||
subgraph.outputs.map((o) => ({ name: o.name, type: o.type }))
|
||||
)
|
||||
}
|
||||
|
||||
console.log('========================\n')
|
||||
}
|
||||
|
||||
// Re-export expect from vitest for convenience
|
||||
export { expect } from 'vitest'
|
||||
|
||||
@@ -371,7 +371,19 @@ class LayoutStoreImpl implements LayoutStore {
|
||||
updateSlotLayout(key: string, layout: SlotLayout): void {
|
||||
const existing = this.slotLayouts.get(key)
|
||||
|
||||
if (!existing) {
|
||||
if (existing) {
|
||||
// Short-circuit if bounds and position unchanged (prevents spatial index churn)
|
||||
if (
|
||||
existing.bounds.x === layout.bounds.x &&
|
||||
existing.bounds.y === layout.bounds.y &&
|
||||
existing.bounds.width === layout.bounds.width &&
|
||||
existing.bounds.height === layout.bounds.height &&
|
||||
existing.position.x === layout.position.x &&
|
||||
existing.position.y === layout.position.y
|
||||
) {
|
||||
return
|
||||
}
|
||||
} else {
|
||||
logger.debug('Adding slot:', {
|
||||
nodeId: layout.nodeId,
|
||||
type: layout.type,
|
||||
@@ -419,6 +431,15 @@ class LayoutStoreImpl implements LayoutStore {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all slot layouts and their spatial index (O(1) operations)
|
||||
* Used when switching rendering modes (Vue ↔ LiteGraph)
|
||||
*/
|
||||
clearAllSlotLayouts(): void {
|
||||
this.slotLayouts.clear()
|
||||
this.slotSpatialIndex.clear()
|
||||
}
|
||||
|
||||
/**
|
||||
* Update reroute layout data
|
||||
*/
|
||||
|
||||
@@ -16,7 +16,7 @@ import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
|
||||
* Compute and register slot layouts for a node
|
||||
* @param node LiteGraph node to process
|
||||
*/
|
||||
function computeAndRegisterSlots(node: LGraphNode): void {
|
||||
export function computeAndRegisterSlots(node: LGraphNode): void {
|
||||
const nodeId = String(node.id)
|
||||
const nodeLayout = layoutStore.getNodeLayoutRef(nodeId).value
|
||||
|
||||
@@ -59,18 +59,19 @@ export function useSlotLayoutSync() {
|
||||
/**
|
||||
* Start slot layout sync with full event-driven functionality
|
||||
* @param canvas LiteGraph canvas instance
|
||||
* @returns true if sync was actually started, false if early-returned
|
||||
*/
|
||||
function start(canvas: LGraphCanvas): void {
|
||||
function start(canvas: LGraphCanvas): boolean {
|
||||
// When Vue nodes are enabled, slot DOM registers exact positions.
|
||||
// Skip calculated registration to avoid conflicts.
|
||||
if (LiteGraph.vueNodesMode) {
|
||||
return
|
||||
return false
|
||||
}
|
||||
const graph = canvas?.graph
|
||||
if (!graph) return
|
||||
if (!graph) return false
|
||||
|
||||
// Initial registration for all nodes in the current graph
|
||||
for (const node of graph.nodes) {
|
||||
for (const node of graph._nodes) {
|
||||
computeAndRegisterSlots(node)
|
||||
}
|
||||
|
||||
@@ -135,6 +136,8 @@ export function useSlotLayoutSync() {
|
||||
graph.onTrigger = origTrigger || undefined
|
||||
graph.onAfterChange = origAfterChange || undefined
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -297,6 +297,7 @@ export interface LayoutStore {
|
||||
deleteSlotLayout(key: string): void
|
||||
deleteNodeSlotLayouts(nodeId: NodeId): void
|
||||
deleteRerouteLayout(rerouteId: RerouteId): void
|
||||
clearAllSlotLayouts(): void
|
||||
|
||||
// Get layout data
|
||||
getLinkLayout(linkId: LinkId): LinkLayout | null
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { z } from 'zod'
|
||||
import { fromZodError } from 'zod-validation-error'
|
||||
|
||||
import { LinkMarkerShape } from '@/lib/litegraph/src/litegraph'
|
||||
import { colorPalettesSchema } from '@/schemas/colorPaletteSchema'
|
||||
@@ -279,6 +280,18 @@ export type PendingTaskItem = z.infer<typeof zPendingTaskItem>
|
||||
export type HistoryTaskItem = z.infer<typeof zHistoryTaskItem>
|
||||
export type TaskItem = z.infer<typeof zTaskItem>
|
||||
|
||||
export function validateTaskItem(taskItem: unknown) {
|
||||
const result = zTaskItem.safeParse(taskItem)
|
||||
if (!result.success) {
|
||||
const zodError = fromZodError(result.error)
|
||||
// TODO accept a callback to report error.
|
||||
console.warn(
|
||||
`Invalid TaskItem: ${JSON.stringify(taskItem)}\n${zodError.message}`
|
||||
)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
const zEmbeddingsResponse = z.array(z.string())
|
||||
const zExtensionsResponse = z.array(z.string())
|
||||
const zError = z.object({
|
||||
|
||||
@@ -447,11 +447,14 @@ export const zSubgraphDefinition = zComfyWorkflow1
|
||||
.passthrough()
|
||||
|
||||
export type ModelFile = z.infer<typeof zModelFile>
|
||||
export type NodeInput = z.infer<typeof zNodeInput>
|
||||
export type NodeOutput = z.infer<typeof zNodeOutput>
|
||||
export type ComfyLink = z.infer<typeof zComfyLink>
|
||||
export type ComfyLinkObject = z.infer<typeof zComfyLinkObject>
|
||||
export type ComfyNode = z.infer<typeof zComfyNode>
|
||||
export type Reroute = z.infer<typeof zReroute>
|
||||
export type WorkflowJSON04 = z.infer<typeof zComfyWorkflow>
|
||||
export type WorkflowJSON10 = z.infer<typeof zComfyWorkflow1>
|
||||
export type ComfyWorkflowJSON = z.infer<
|
||||
typeof zComfyWorkflow | typeof zComfyWorkflow1
|
||||
>
|
||||
|
||||
@@ -214,7 +214,9 @@ export type StringInputSpec = z.infer<typeof zStringInputSpec>
|
||||
export type ComboInputSpec = z.infer<typeof zComboInputSpec>
|
||||
export type ColorInputSpec = z.infer<typeof zColorInputSpec>
|
||||
export type FileUploadInputSpec = z.infer<typeof zFileUploadInputSpec>
|
||||
export type ImageInputSpec = z.infer<typeof zImageInputSpec>
|
||||
export type ImageCompareInputSpec = z.infer<typeof zImageCompareInputSpec>
|
||||
export type MarkdownInputSpec = z.infer<typeof zMarkdownInputSpec>
|
||||
export type TreeSelectInputSpec = z.infer<typeof zTreeSelectInputSpec>
|
||||
export type MultiSelectInputSpec = z.infer<typeof zMultiSelectInputSpec>
|
||||
export type ChartInputSpec = z.infer<typeof zChartInputSpec>
|
||||
|
||||
@@ -128,12 +128,30 @@ export function isFloatInputSpec(
|
||||
return inputSpec[0] === 'FLOAT'
|
||||
}
|
||||
|
||||
export function isBooleanInputSpec(
|
||||
inputSpec: InputSpec
|
||||
): inputSpec is BooleanInputSpec {
|
||||
return inputSpec[0] === 'BOOLEAN'
|
||||
}
|
||||
|
||||
export function isStringInputSpec(
|
||||
inputSpec: InputSpec
|
||||
): inputSpec is StringInputSpec {
|
||||
return inputSpec[0] === 'STRING'
|
||||
}
|
||||
|
||||
export function isComboInputSpecV2(
|
||||
inputSpec: InputSpec
|
||||
): inputSpec is ComboInputSpecV2 {
|
||||
return inputSpec[0] === 'COMBO'
|
||||
}
|
||||
|
||||
export function isCustomInputSpec(
|
||||
inputSpec: InputSpec
|
||||
): inputSpec is CustomInputSpec {
|
||||
return typeof inputSpec[0] === 'string' && !excludedLiterals.has(inputSpec[0])
|
||||
}
|
||||
|
||||
export function isComboInputSpec(
|
||||
inputSpec: InputSpec
|
||||
): inputSpec is ComboInputSpec | ComboInputSpecV2 {
|
||||
@@ -229,13 +247,22 @@ export type ComfyOutputTypesSpec = z.infer<typeof zComfyOutputTypesSpec>
|
||||
export type ComfyNodeDef = z.infer<typeof zComfyNodeDef>
|
||||
export type RemoteWidgetConfig = z.infer<typeof zRemoteWidgetConfig>
|
||||
|
||||
// Input specs
|
||||
export type IntInputOptions = z.infer<typeof zIntInputOptions>
|
||||
export type FloatInputOptions = z.infer<typeof zFloatInputOptions>
|
||||
export type BooleanInputOptions = z.infer<typeof zBooleanInputOptions>
|
||||
export type StringInputOptions = z.infer<typeof zStringInputOptions>
|
||||
export type ComboInputOptions = z.infer<typeof zComboInputOptions>
|
||||
export type BaseInputOptions = z.infer<typeof zBaseInputOptions>
|
||||
export type NumericInputOptions = z.infer<typeof zNumericInputOptions>
|
||||
|
||||
export type IntInputSpec = z.infer<typeof zIntInputSpec>
|
||||
export type FloatInputSpec = z.infer<typeof zFloatInputSpec>
|
||||
export type BooleanInputSpec = z.infer<typeof zBooleanInputSpec>
|
||||
export type StringInputSpec = z.infer<typeof zStringInputSpec>
|
||||
export type ComboInputSpec = z.infer<typeof zComboInputSpec>
|
||||
export type ComboInputSpecV2 = z.infer<typeof zComboInputSpecV2>
|
||||
export type CustomInputSpec = z.infer<typeof zCustomInputSpec>
|
||||
export type InputSpec = z.infer<typeof zInputSpec>
|
||||
|
||||
export function validateComfyNodeDef(
|
||||
|
||||
@@ -10,6 +10,8 @@ export const apiKeySchema = z.object({
|
||||
.length(72, t('validation.length', { length: 72 }))
|
||||
})
|
||||
|
||||
export type ApiKeyData = z.infer<typeof apiKeySchema>
|
||||
|
||||
export const signInSchema = z.object({
|
||||
email: z
|
||||
.string()
|
||||
@@ -40,6 +42,8 @@ export const updatePasswordSchema = passwordSchema.refine(
|
||||
}
|
||||
)
|
||||
|
||||
export type UpdatePasswordData = z.infer<typeof updatePasswordSchema>
|
||||
|
||||
export const signUpSchema = passwordSchema
|
||||
.extend({
|
||||
email: z
|
||||
|
||||
@@ -375,3 +375,17 @@ LGraphNode.prototype.addDOMWidget = function <
|
||||
|
||||
return widget
|
||||
}
|
||||
|
||||
/**
|
||||
* Prunes widgets that are no longer in the graph.
|
||||
* @param nodes The nodes to prune widgets for.
|
||||
*/
|
||||
export const pruneWidgets = (nodes: LGraphNode[]) => {
|
||||
const nodeSet = new Set(nodes)
|
||||
const domWidgetStore = useDomWidgetStore()
|
||||
for (const { widget } of domWidgetStore.widgetStates.values()) {
|
||||
if (!nodeSet.has(widget.node)) {
|
||||
domWidgetStore.unregisterWidget(widget.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,7 +17,6 @@ export function clone<T>(obj: T): T {
|
||||
}
|
||||
|
||||
/**
|
||||
* @knipIgnoreUnusedButUsedByCustomNodes
|
||||
* @deprecated Use `applyTextReplacements` from `@/utils/searchAndReplace` instead
|
||||
* There are external callers to this function, so we need to keep it for now
|
||||
*/
|
||||
@@ -25,7 +24,6 @@ export function applyTextReplacements(app: ComfyApp, value: string): string {
|
||||
return _applyTextReplacements(app.graph, value)
|
||||
}
|
||||
|
||||
/** @knipIgnoreUnusedButUsedByCustomNodes */
|
||||
export async function addStylesheet(
|
||||
urlOrFile: string,
|
||||
relativeTo?: string
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import type { BaseSearchParamsWithoutQuery } from 'algoliasearch/dist/lite/browser'
|
||||
import type {
|
||||
BaseSearchParamsWithoutQuery,
|
||||
Hit
|
||||
} from 'algoliasearch/dist/lite/browser'
|
||||
|
||||
import type { components } from '@/types/comfyRegistryTypes'
|
||||
|
||||
@@ -10,6 +13,15 @@ type SafeNestedProperty<
|
||||
|
||||
type RegistryNodePack = components['schemas']['Node']
|
||||
|
||||
/**
|
||||
* Result of searching the Algolia index.
|
||||
* Represents the entire result of a search query.
|
||||
*/
|
||||
export type SearchPacksResult = {
|
||||
nodePacks: Hit<AlgoliaNodePack>[]
|
||||
querySuggestions: Hit<NodesIndexSuggestion>[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Node pack record after it has been mapped to Algolia index format.
|
||||
* @see https://github.com/Comfy-Org/comfy-api/blob/main/mapper/algolia.go
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { InjectionKey, Ref } from 'vue'
|
||||
|
||||
import type { AlgoliaNodePack } from '@/types/algoliaTypes'
|
||||
import type { components } from '@/types/comfyRegistryTypes'
|
||||
import type { components as managerComponents } from '@/types/generatedManagerTypes'
|
||||
|
||||
export type RegistryPack = components['schemas']['Node']
|
||||
export type MergedNodePack = RegistryPack & AlgoliaNodePack
|
||||
@@ -9,9 +10,16 @@ export const isMergedNodePack = (
|
||||
nodePack: RegistryPack | AlgoliaNodePack
|
||||
): nodePack is MergedNodePack => 'comfy_nodes' in nodePack
|
||||
|
||||
export type PackField = keyof RegistryPack | null
|
||||
|
||||
export const IsInstallingKey: InjectionKey<Ref<boolean>> =
|
||||
Symbol('isInstalling')
|
||||
|
||||
export enum ManagerWsQueueStatus {
|
||||
DONE = 'all-done',
|
||||
IN_PROGRESS = 'in_progress'
|
||||
}
|
||||
|
||||
export enum ManagerTab {
|
||||
All = 'all',
|
||||
Installed = 'installed',
|
||||
@@ -26,12 +34,31 @@ export interface TabItem {
|
||||
icon: string
|
||||
}
|
||||
|
||||
export enum ManagerSortField {
|
||||
Author = 'author',
|
||||
CreateDate = 'creation_date',
|
||||
LastUpdateDate = 'last_update',
|
||||
Name = 'name',
|
||||
Stars = 'stars',
|
||||
Size = 'size'
|
||||
}
|
||||
|
||||
export enum PackEnableState {
|
||||
Enabled,
|
||||
Disabled,
|
||||
NotInstalled
|
||||
}
|
||||
|
||||
export type TaskLog = {
|
||||
taskName: string
|
||||
taskId: string
|
||||
logs: string[]
|
||||
}
|
||||
|
||||
export interface ManagerQueueOptions {
|
||||
maxConcurrent?: number
|
||||
}
|
||||
|
||||
export interface UseNodePacksOptions {
|
||||
immediate?: boolean
|
||||
maxConcurrent?: number
|
||||
@@ -56,3 +83,13 @@ export interface ManagerState {
|
||||
searchMode: 'nodes' | 'packs'
|
||||
sortField: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Types for import failure information API
|
||||
*/
|
||||
export type ImportFailInfoBulkRequest =
|
||||
managerComponents['schemas']['ImportFailInfoBulkRequest']
|
||||
export type ImportFailInfoBulkResponse =
|
||||
managerComponents['schemas']['ImportFailInfoBulkResponse']
|
||||
export type ImportFailInfoItem =
|
||||
managerComponents['schemas']['ImportFailInfoItem']
|
||||
|
||||
@@ -9,6 +9,9 @@ import type { components } from './comfyRegistryTypes'
|
||||
|
||||
// Re-export core types from Registry API
|
||||
export type Node = components['schemas']['Node']
|
||||
export type NodeVersion = components['schemas']['NodeVersion']
|
||||
export type NodeStatus = components['schemas']['NodeStatus']
|
||||
export type NodeVersionStatus = components['schemas']['NodeVersionStatus']
|
||||
|
||||
/**
|
||||
* Conflict types that can be detected in the system
|
||||
@@ -24,6 +27,22 @@ export type ConflictType =
|
||||
| 'banned' // Banned package
|
||||
| 'pending' // Security verification pending
|
||||
|
||||
/**
|
||||
* Version comparison operators
|
||||
* @enum {string}
|
||||
*/
|
||||
export type VersionOperator = '>=' | '>' | '<=' | '<' | '==' | '!='
|
||||
|
||||
/**
|
||||
* Version requirement specification
|
||||
*/
|
||||
export interface VersionRequirement {
|
||||
/** @description Comparison operator for version checking */
|
||||
operator: VersionOperator
|
||||
/** @description Target version string */
|
||||
version: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Node Pack requirements from Registry API
|
||||
* Extends Node type with additional installation and compatibility metadata
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
export enum LinkReleaseTriggerMode {
|
||||
ALWAYS = 'always',
|
||||
HOLD_SHIFT = 'hold shift',
|
||||
NOT_HOLD_SHIFT = 'NOT hold shift'
|
||||
}
|
||||
|
||||
export enum LinkReleaseTriggerAction {
|
||||
CONTEXT_MENU = 'context menu',
|
||||
SEARCH_BOX = 'search box',
|
||||
|
||||
@@ -20,6 +20,15 @@ export enum HashFunction {
|
||||
SHA512 = 'sha512'
|
||||
}
|
||||
|
||||
export enum AutoLaunch {
|
||||
// Let server decide whether to auto launch based on the current environment
|
||||
Auto = 'auto',
|
||||
// Disable auto launch
|
||||
Disable = 'disable',
|
||||
// Enable auto launch
|
||||
Enable = 'enable'
|
||||
}
|
||||
|
||||
export enum CudaMalloc {
|
||||
// Let server decide whether to use CUDA malloc based on the current environment
|
||||
Auto = 'auto',
|
||||
|
||||
@@ -25,6 +25,13 @@ export interface SettingOption {
|
||||
value?: any
|
||||
}
|
||||
|
||||
export interface Setting {
|
||||
id: keyof Settings
|
||||
onChange?: (value: any, oldValue?: any) => void
|
||||
name: string
|
||||
render: () => HTMLElement
|
||||
}
|
||||
|
||||
export interface SettingParams<TValue = unknown> extends FormItem {
|
||||
id: keyof Settings
|
||||
defaultValue: any | (() => any)
|
||||
|
||||
@@ -33,6 +33,10 @@ export function appendJsonExt(path: string) {
|
||||
return path
|
||||
}
|
||||
|
||||
export function trimJsonExt(path?: string) {
|
||||
return path?.replace(/\.json$/, '')
|
||||
}
|
||||
|
||||
export function highlightQuery(text: string, query: string) {
|
||||
if (!query) return text
|
||||
|
||||
@@ -76,6 +80,28 @@ export function formatSize(value?: number) {
|
||||
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(2))} ${sizes[i]}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds the common directory prefix between two paths
|
||||
* @example
|
||||
* findCommonPrefix('a/b/c', 'a/b/d') // returns 'a/b'
|
||||
* findCommonPrefix('x/y/z', 'a/b/c') // returns ''
|
||||
* findCommonPrefix('a/b/c', 'a/b/c/d') // returns 'a/b/c'
|
||||
*/
|
||||
export function findCommonPrefix(path1: string, path2: string): string {
|
||||
const parts1 = path1.split('/')
|
||||
const parts2 = path2.split('/')
|
||||
|
||||
const commonParts: string[] = []
|
||||
for (let i = 0; i < Math.min(parts1.length, parts2.length); i++) {
|
||||
if (parts1[i] === parts2[i]) {
|
||||
commonParts.push(parts1[i])
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
return commonParts.join('/')
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns various filename components.
|
||||
* Example:
|
||||
@@ -397,6 +423,26 @@ export function compareVersions(
|
||||
return 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a currency amount to Metronome's integer representation.
|
||||
* For USD, converts to cents (multiplied by 100).
|
||||
* For all other currencies (including custom pricing units), returns the amount as is.
|
||||
* This is specific to Metronome's API requirements.
|
||||
*
|
||||
* @param amount - The amount in currency to convert
|
||||
* @param currency - The currency to convert
|
||||
* @returns The amount in Metronome's integer format (cents for USD, base units for others)
|
||||
* @example
|
||||
* toMetronomeCurrency(1.23, 'usd') // returns 123 (cents)
|
||||
* toMetronomeCurrency(1000, 'jpy') // returns 1000 (yen)
|
||||
*/
|
||||
export function toMetronomeCurrency(amount: number, currency: string): number {
|
||||
if (currency === 'usd') {
|
||||
return Math.round(amount * 100)
|
||||
}
|
||||
return amount
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts Metronome's integer amount back to a formatted currency string.
|
||||
* For USD, converts from cents to dollars.
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
isFloatInputSpec,
|
||||
isIntInputSpec
|
||||
} from '@/schemas/nodeDefSchema'
|
||||
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
|
||||
|
||||
import { lcm } from './mathUtil'
|
||||
|
||||
@@ -138,3 +139,11 @@ export const mergeInputSpec = (
|
||||
|
||||
return mergeCommonInputSpec(spec1, spec2)
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a node definition represents a subgraph node.
|
||||
* Subgraph nodes are created with category='subgraph' and python_module='nodes'.
|
||||
*/
|
||||
export const isSubgraphNode = (nodeDef: ComfyNodeDefImpl): boolean => {
|
||||
return nodeDef.category === 'subgraph' && nodeDef.python_module === 'nodes'
|
||||
}
|
||||
|
||||
@@ -29,6 +29,34 @@ export function satisfiesVersion(version: string, range: string): boolean {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Compares two versions and returns the difference type
|
||||
* @param version1 First version
|
||||
* @param version2 Second version
|
||||
* @returns Difference type or null if comparison fails
|
||||
*/
|
||||
export function getVersionDifference(
|
||||
version1: string,
|
||||
version2: string
|
||||
): semver.ReleaseType | null {
|
||||
try {
|
||||
const clean1 = cleanVersion(version1)
|
||||
const clean2 = cleanVersion(version2)
|
||||
return semver.diff(clean1, clean2)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a version is valid according to semver
|
||||
* @param version Version string to validate
|
||||
* @returns true if version is valid
|
||||
*/
|
||||
export function isValidVersion(version: string): boolean {
|
||||
return semver.valid(version) !== null
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks version compatibility and returns conflict details.
|
||||
* Supports all semver ranges including >=, <=, >, <, ~, ^ operators.
|
||||
|
||||
@@ -113,7 +113,16 @@ LiteGraphGlobal {
|
||||
"Reroute": [Function],
|
||||
"SPLINE_LINK": 2,
|
||||
"STRAIGHT_LINK": 0,
|
||||
"SlotDirection": {},
|
||||
"SlotDirection": {
|
||||
"1": "Up",
|
||||
"2": "Down",
|
||||
"3": "Left",
|
||||
"4": "Right",
|
||||
"Down": 2,
|
||||
"Left": 3,
|
||||
"Right": 4,
|
||||
"Up": 1,
|
||||
},
|
||||
"SlotShape": {
|
||||
"1": "Box",
|
||||
"3": "Circle",
|
||||
|
||||
@@ -169,3 +169,140 @@ export const subgraphTest = test.extend<SubgraphFixtures>({
|
||||
capture.cleanup()
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* Fixtures that test edge cases and error conditions.
|
||||
* These may leave the system in an invalid state and should be used carefully.
|
||||
*/
|
||||
export interface EdgeCaseFixtures {
|
||||
/** Subgraph with circular references (for testing recursion detection) */
|
||||
circularSubgraph: {
|
||||
rootGraph: LGraph
|
||||
subgraphA: Subgraph
|
||||
subgraphB: Subgraph
|
||||
nodeA: SubgraphNode
|
||||
nodeB: SubgraphNode
|
||||
}
|
||||
|
||||
/** Deeply nested subgraphs approaching the theoretical limit */
|
||||
deeplyNestedSubgraph: ReturnType<typeof createNestedSubgraphs>
|
||||
|
||||
/** Subgraph with maximum inputs and outputs */
|
||||
maxIOSubgraph: Subgraph
|
||||
}
|
||||
|
||||
/**
|
||||
* Test with edge case fixtures. Use sparingly and with caution.
|
||||
* These tests may intentionally create invalid states.
|
||||
*/
|
||||
export const edgeCaseTest = subgraphTest.extend<EdgeCaseFixtures>({
|
||||
// @ts-expect-error TODO: Fix after merge - fixture use parameter type
|
||||
// eslint-disable-next-line no-empty-pattern
|
||||
circularSubgraph: async ({}, use: (value: unknown) => Promise<void>) => {
|
||||
const rootGraph = new LGraph()
|
||||
|
||||
// Create two subgraphs that will reference each other
|
||||
const subgraphA = createTestSubgraph({
|
||||
name: 'Subgraph A',
|
||||
inputs: [{ name: 'input', type: '*' }],
|
||||
outputs: [{ name: 'output', type: '*' }]
|
||||
})
|
||||
|
||||
const subgraphB = createTestSubgraph({
|
||||
name: 'Subgraph B',
|
||||
inputs: [{ name: 'input', type: '*' }],
|
||||
outputs: [{ name: 'output', type: '*' }]
|
||||
})
|
||||
|
||||
// Create instances (this doesn't create circular refs by itself)
|
||||
const nodeA = createTestSubgraphNode(subgraphA, { pos: [100, 100] })
|
||||
const nodeB = createTestSubgraphNode(subgraphB, { pos: [300, 100] })
|
||||
|
||||
// Add nodes to root graph
|
||||
rootGraph.add(nodeA)
|
||||
rootGraph.add(nodeB)
|
||||
|
||||
await use({
|
||||
rootGraph,
|
||||
subgraphA,
|
||||
subgraphB,
|
||||
nodeA,
|
||||
nodeB
|
||||
})
|
||||
},
|
||||
|
||||
// @ts-expect-error TODO: Fix after merge - fixture use parameter type
|
||||
// eslint-disable-next-line no-empty-pattern
|
||||
deeplyNestedSubgraph: async ({}, use: (value: unknown) => Promise<void>) => {
|
||||
// Create a very deep nesting structure (but not exceeding MAX_NESTED_SUBGRAPHS)
|
||||
const nested = createNestedSubgraphs({
|
||||
depth: 50, // Deep but reasonable
|
||||
nodesPerLevel: 1,
|
||||
inputsPerSubgraph: 1,
|
||||
outputsPerSubgraph: 1
|
||||
})
|
||||
|
||||
await use(nested)
|
||||
},
|
||||
|
||||
// @ts-expect-error TODO: Fix after merge - fixture use parameter type
|
||||
// eslint-disable-next-line no-empty-pattern
|
||||
maxIOSubgraph: async ({}, use: (value: unknown) => Promise<void>) => {
|
||||
// Create a subgraph with many inputs and outputs
|
||||
const inputs = Array.from({ length: 20 }, (_, i) => ({
|
||||
name: `input_${i}`,
|
||||
type: i % 2 === 0 ? 'number' : ('string' as const)
|
||||
}))
|
||||
|
||||
const outputs = Array.from({ length: 20 }, (_, i) => ({
|
||||
name: `output_${i}`,
|
||||
type: i % 2 === 0 ? 'number' : ('string' as const)
|
||||
}))
|
||||
|
||||
const subgraph = createTestSubgraph({
|
||||
name: 'Max IO Subgraph',
|
||||
inputs,
|
||||
outputs,
|
||||
nodeCount: 10
|
||||
})
|
||||
|
||||
await use(subgraph)
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* Helper to verify fixture integrity.
|
||||
* Use this in tests to ensure fixtures are properly set up.
|
||||
*/
|
||||
export function verifyFixtureIntegrity<T extends Record<string, unknown>>(
|
||||
fixture: T,
|
||||
expectedProperties: (keyof T)[]
|
||||
): void {
|
||||
for (const prop of expectedProperties) {
|
||||
if (!(prop in fixture)) {
|
||||
throw new Error(`Fixture missing required property: ${String(prop)}`)
|
||||
}
|
||||
if (fixture[prop] === undefined || fixture[prop] === null) {
|
||||
throw new Error(`Fixture property ${String(prop)} is null or undefined`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a snapshot-friendly representation of a subgraph for testing.
|
||||
* Useful for serialization tests and regression detection.
|
||||
*/
|
||||
export function createSubgraphSnapshot(subgraph: Subgraph) {
|
||||
return {
|
||||
id: subgraph.id,
|
||||
name: subgraph.name,
|
||||
inputCount: subgraph.inputs.length,
|
||||
outputCount: subgraph.outputs.length,
|
||||
nodeCount: subgraph.nodes.length,
|
||||
linkCount: subgraph.links.size,
|
||||
inputs: subgraph.inputs.map((i) => ({ name: i.name, type: i.type })),
|
||||
outputs: subgraph.outputs.map((o) => ({ name: o.name, type: o.type })),
|
||||
hasInputNode: !!subgraph.inputNode,
|
||||
hasOutputNode: !!subgraph.outputNode
|
||||
}
|
||||
}
|
||||
|
||||
@@ -382,6 +382,76 @@ export function createTestSubgraphData(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a complex subgraph with multiple nodes and connections.
|
||||
* Useful for testing realistic scenarios.
|
||||
* @param nodeCount Number of internal nodes to create
|
||||
* @returns Complex subgraph data structure
|
||||
*/
|
||||
export function createComplexSubgraphData(
|
||||
nodeCount: number = 5
|
||||
): ExportedSubgraph {
|
||||
const nodes = []
|
||||
const links: Record<
|
||||
string,
|
||||
{
|
||||
id: number
|
||||
origin_id: number
|
||||
origin_slot: number
|
||||
target_id: number
|
||||
target_slot: number
|
||||
type: string
|
||||
}
|
||||
> = {}
|
||||
|
||||
// Create internal nodes
|
||||
for (let i = 0; i < nodeCount; i++) {
|
||||
nodes.push({
|
||||
id: i + 1, // Start from 1 to avoid conflicts with IO nodes
|
||||
type: 'basic/test',
|
||||
pos: [100 + i * 150, 200],
|
||||
size: [120, 60],
|
||||
inputs: [{ name: 'in', type: '*', link: null }],
|
||||
outputs: [{ name: 'out', type: '*', links: [] }],
|
||||
properties: { value: i },
|
||||
flags: {},
|
||||
mode: 0
|
||||
})
|
||||
}
|
||||
|
||||
// Create some internal links
|
||||
for (let i = 0; i < nodeCount - 1; i++) {
|
||||
const linkId = i + 1
|
||||
links[linkId] = {
|
||||
id: linkId,
|
||||
origin_id: i + 1,
|
||||
origin_slot: 0,
|
||||
target_id: i + 2,
|
||||
target_slot: 0,
|
||||
type: '*'
|
||||
}
|
||||
}
|
||||
|
||||
return createTestSubgraphData({
|
||||
// @ts-expect-error TODO: Fix after merge - nodes parameter type
|
||||
nodes,
|
||||
// @ts-expect-error TODO: Fix after merge - links parameter type
|
||||
links,
|
||||
inputs: [
|
||||
// @ts-expect-error TODO: Fix after merge - input object type
|
||||
{ name: 'input1', type: 'number', pos: [0, 0] },
|
||||
// @ts-expect-error TODO: Fix after merge - input object type
|
||||
{ name: 'input2', type: 'string', pos: [0, 1] }
|
||||
],
|
||||
outputs: [
|
||||
// @ts-expect-error TODO: Fix after merge - output object type
|
||||
{ name: 'output1', type: 'number', pos: [0, 0] },
|
||||
// @ts-expect-error TODO: Fix after merge - output object type
|
||||
{ name: 'output2', type: 'string', pos: [0, 1] }
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an event capture system for testing event sequences.
|
||||
* @param eventTarget The event target to monitor
|
||||
@@ -423,5 +493,39 @@ export function createEventCapture<T = unknown>(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Utility to log subgraph structure for debugging tests.
|
||||
* @param subgraph The subgraph to inspect
|
||||
* @param label Optional label for the log output
|
||||
*/
|
||||
export function logSubgraphStructure(
|
||||
subgraph: Subgraph,
|
||||
label: string = 'Subgraph'
|
||||
): void {
|
||||
console.log(`\n=== ${label} Structure ===`)
|
||||
console.log(`Name: ${subgraph.name}`)
|
||||
console.log(`ID: ${subgraph.id}`)
|
||||
console.log(`Inputs: ${subgraph.inputs.length}`)
|
||||
console.log(`Outputs: ${subgraph.outputs.length}`)
|
||||
console.log(`Nodes: ${subgraph.nodes.length}`)
|
||||
console.log(`Links: ${subgraph.links.size}`)
|
||||
|
||||
if (subgraph.inputs.length > 0) {
|
||||
console.log(
|
||||
'Input details:',
|
||||
subgraph.inputs.map((i) => ({ name: i.name, type: i.type }))
|
||||
)
|
||||
}
|
||||
|
||||
if (subgraph.outputs.length > 0) {
|
||||
console.log(
|
||||
'Output details:',
|
||||
subgraph.outputs.map((o) => ({ name: o.name, type: o.type }))
|
||||
)
|
||||
}
|
||||
|
||||
console.log('========================\n')
|
||||
}
|
||||
|
||||
// Re-export expect from vitest for convenience
|
||||
export { expect } from 'vitest'
|
||||
|
||||
Reference in New Issue
Block a user