Compare commits

..

2 Commits

Author SHA1 Message Date
bymyself
71401b1059 remove unused export 2025-12-13 03:43:58 -08:00
bymyself
076acf1b31 graph state store impl 2025-12-13 03:32:48 -08:00
130 changed files with 3711 additions and 3465 deletions

View File

@@ -361,42 +361,6 @@ jobs:
if: steps.filter-targets.outputs.skip != 'true' && failure() && steps.backport.outputs.failed
env:
GH_TOKEN: ${{ github.token }}
BACKPORT_AGENT_PROMPT_TEMPLATE: |
Backport PR #${PR_NUMBER} (${PR_URL}) to ${target}.
Cherry-pick merge commit ${MERGE_COMMIT} onto new branch
${BACKPORT_BRANCH} from origin/${target}.
Resolve conflicts in: ${CONFLICTS_INLINE}.
For test snapshots (browser_tests/**/*-snapshots/), accept PR version if
changed in original PR, else keep target. For package.json versions, keep
target branch. For pnpm-lock.yaml, regenerate with pnpm install.
Ask user for non-obvious conflicts.
Create PR titled "[backport ${target}] <original title>" with label "backport".
See .github/workflows/pr-backport.yaml for workflow details.
COMMENT_BODY_TEMPLATE: |
### ⚠️ Backport to `${target}` failed
**Reason:** Merge conflicts detected during cherry-pick of `${MERGE_COMMIT_SHORT}`
<details>
<summary>📄 Conflicting files</summary>
```
${CONFLICTS_BLOCK}
```
</details>
<details>
<summary>🤖 Prompt for AI Agents</summary>
```
${AGENT_PROMPT}
```
</details>
---
cc @${PR_AUTHOR}
run: |
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
PR_DATA=$(gh pr view ${{ inputs.pr_number }} --json author,mergeCommit)
@@ -419,27 +383,10 @@ jobs:
gh pr comment "${PR_NUMBER}" --body "@${PR_AUTHOR} Commit \`${MERGE_COMMIT}\` already exists on branch \`${target}\`. No backport needed."
elif [ "${reason}" = "conflicts" ]; then
CONFLICTS_INLINE=$(echo "${conflicts}" | tr ',' ' ')
SAFE_TARGET=$(echo "$target" | tr '/' '-')
BACKPORT_BRANCH="backport-${PR_NUMBER}-to-${SAFE_TARGET}"
PR_URL="${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/pull/${PR_NUMBER}"
export PR_NUMBER PR_URL MERGE_COMMIT target BACKPORT_BRANCH CONFLICTS_INLINE
# envsubst is provided by gettext-base
if ! command -v envsubst >/dev/null 2>&1; then
sudo apt-get update && sudo apt-get install -y gettext-base
fi
AGENT_PROMPT=$(envsubst '${PR_NUMBER} ${PR_URL} ${target} ${MERGE_COMMIT} ${BACKPORT_BRANCH} ${CONFLICTS_INLINE}' <<<"$BACKPORT_AGENT_PROMPT_TEMPLATE")
# Use fenced code block for conflicts to handle special chars in filenames
CONFLICTS_BLOCK=$(echo "${conflicts}" | tr ',' '\n')
MERGE_COMMIT_SHORT="${MERGE_COMMIT:0:7}"
export target MERGE_COMMIT_SHORT CONFLICTS_BLOCK AGENT_PROMPT PR_AUTHOR
COMMENT_BODY=$(envsubst '${target} ${MERGE_COMMIT_SHORT} ${CONFLICTS_BLOCK} ${AGENT_PROMPT} ${PR_AUTHOR}' <<<"$COMMENT_BODY_TEMPLATE")
# Convert comma-separated conflicts back to newlines for display
CONFLICTS_LIST=$(echo "${conflicts}" | tr ',' '\n' | sed 's/^/- /')
COMMENT_BODY="@${PR_AUTHOR} Backport to \`${target}\` failed: Merge conflicts detected."$'\n\n'"Please manually cherry-pick commit \`${MERGE_COMMIT}\` to the \`${target}\` branch."$'\n\n'"<details><summary>Conflicting files</summary>"$'\n\n'"${CONFLICTS_LIST}"$'\n\n'"</details>"
gh pr comment "${PR_NUMBER}" --body "${COMMENT_BODY}"
fi
done

View File

@@ -20,13 +20,6 @@ on:
required: true
default: 'main'
type: string
schedule:
# 00:00 UTC ≈ 4:00 PM PST / 5:00 PM PDT on the previous calendar day
- cron: '0 0 * * *'
concurrency:
group: release-version-bump
cancel-in-progress: true
jobs:
bump-version:
@@ -36,99 +29,15 @@ jobs:
pull-requests: write
steps:
- name: Prepare inputs
id: prepared-inputs
shell: bash
env:
RAW_VERSION_TYPE: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.version_type || '' }}
RAW_PRE_RELEASE: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.pre_release || '' }}
RAW_BRANCH: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.branch || '' }}
run: |
set -euo pipefail
VERSION_TYPE="$RAW_VERSION_TYPE"
PRE_RELEASE="$RAW_PRE_RELEASE"
TARGET_BRANCH="$RAW_BRANCH"
if [[ -z "$VERSION_TYPE" ]]; then
VERSION_TYPE='patch'
fi
if [[ -z "$TARGET_BRANCH" ]]; then
TARGET_BRANCH='main'
fi
{
echo "version_type=$VERSION_TYPE"
echo "pre_release=$PRE_RELEASE"
echo "branch=$TARGET_BRANCH"
} >> "$GITHUB_OUTPUT"
- name: Close stale nightly version bump PRs
if: github.event_name == 'schedule'
uses: actions/github-script@v7
with:
github-token: ${{ github.token }}
script: |
const prefix = 'version-bump-'
const closed = []
const prs = await github.paginate(github.rest.pulls.list, {
owner: context.repo.owner,
repo: context.repo.repo,
state: 'open',
per_page: 100
})
for (const pr of prs) {
if (!pr.head?.ref?.startsWith(prefix)) {
continue
}
if (pr.user?.login !== 'github-actions[bot]') {
continue
}
// Only clean up stale nightly PRs targeting main.
// Adjust here if other target branches should be cleaned.
if (pr.base?.ref !== 'main') {
continue
}
await github.rest.pulls.update({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: pr.number,
state: 'closed'
})
try {
await github.rest.git.deleteRef({
owner: context.repo.owner,
repo: context.repo.repo,
ref: `heads/${pr.head.ref}`
})
} catch (error) {
if (![404, 422].includes(error.status)) {
throw error
}
}
closed.push(pr.number)
}
core.info(`Closed ${closed.length} stale PR(s).`)
- name: Checkout repository
uses: actions/checkout@v5
with:
ref: ${{ steps.prepared-inputs.outputs.branch }}
ref: ${{ github.event.inputs.branch }}
fetch-depth: 0
persist-credentials: false
- name: Validate branch exists
env:
TARGET_BRANCH: ${{ steps.prepared-inputs.outputs.branch }}
run: |
BRANCH="$TARGET_BRANCH"
BRANCH="${{ github.event.inputs.branch }}"
if ! git show-ref --verify --quiet "refs/heads/$BRANCH" && ! git show-ref --verify --quiet "refs/remotes/origin/$BRANCH"; then
echo "❌ Branch '$BRANCH' does not exist"
echo ""
@@ -142,7 +51,7 @@ jobs:
echo "✅ Branch '$BRANCH' exists"
- name: Install pnpm
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061
uses: pnpm/action-setup@v4
with:
version: 10
@@ -153,31 +62,16 @@ jobs:
- name: Bump version
id: bump-version
env:
VERSION_TYPE: ${{ steps.prepared-inputs.outputs.version_type }}
PRE_RELEASE: ${{ steps.prepared-inputs.outputs.pre_release }}
run: |
set -euo pipefail
if [[ -n "$PRE_RELEASE" && ! "$VERSION_TYPE" =~ ^pre(major|minor|patch)$ && "$VERSION_TYPE" != "prerelease" ]]; then
echo "❌ pre_release was provided but version_type='$VERSION_TYPE' does not support --preid"
exit 1
fi
if [[ -n "$PRE_RELEASE" ]]; then
pnpm version "$VERSION_TYPE" --preid "$PRE_RELEASE" --no-git-tag-version
else
pnpm version "$VERSION_TYPE" --no-git-tag-version
fi
pnpm version ${{ github.event.inputs.version_type }} --preid ${{ github.event.inputs.pre_release }} --no-git-tag-version
NEW_VERSION=$(node -p "require('./package.json').version")
echo "NEW_VERSION=$NEW_VERSION" >> "$GITHUB_OUTPUT"
echo "NEW_VERSION=$NEW_VERSION" >> $GITHUB_OUTPUT
- name: Format PR string
id: capitalised
env:
VERSION_TYPE: ${{ steps.prepared-inputs.outputs.version_type }}
run: |
CAPITALISED_TYPE="$VERSION_TYPE"
echo "capitalised=${CAPITALISED_TYPE@u}" >> "$GITHUB_OUTPUT"
CAPITALISED_TYPE=${{ github.event.inputs.version_type }}
echo "capitalised=${CAPITALISED_TYPE@u}" >> $GITHUB_OUTPUT
- name: Create Pull Request
uses: peter-evans/create-pull-request@271a8d0340265f705b14b6d32b9829c1cb33d45e
@@ -188,8 +82,8 @@ jobs:
body: |
${{ steps.capitalised.outputs.capitalised }} version increment to ${{ steps.bump-version.outputs.NEW_VERSION }}
**Base branch:** `${{ steps.prepared-inputs.outputs.branch }}`
**Base branch:** `${{ github.event.inputs.branch }}`
branch: version-bump-${{ steps.bump-version.outputs.NEW_VERSION }}
base: ${{ steps.prepared-inputs.outputs.branch }}
base: ${{ github.event.inputs.branch }}
labels: |
Release

View File

@@ -51,6 +51,8 @@ defineProps<{
canProceed: boolean
/** Whether the location step should be disabled */
disableLocationStep: boolean
/** Whether the migration step should be disabled */
disableMigrationStep: boolean
/** Whether the settings step should be disabled */
disableSettingsStep: boolean
}>()

View File

@@ -1,92 +0,0 @@
{
"id": "2ba0b800-2f13-4f21-b8d6-c6cdb0152cae",
"revision": 0,
"last_node_id": 17,
"last_link_id": 9,
"nodes": [
{
"id": 17,
"type": "VAEDecode",
"pos": [
318.8446183157076,
355.3961392345528
],
"size": [
225,
102
],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [
{
"name": "samples",
"type": "LATENT",
"link": null
},
{
"name": "vae",
"type": "VAE",
"link": null
}
],
"outputs": [
{
"name": "IMAGE",
"type": "IMAGE",
"links": null
}
],
"properties": {
"Node name for S&R": "VAEDecode"
},
"widgets_values": []
}
],
"links": [],
"groups": [
{
"id": 4,
"title": "Outer Group",
"bounding": [
-46.25245366331014,
-150.82497138023245,
1034.4034361963616,
1007.338460439933
],
"color": "#3f789e",
"font_size": 24,
"flags": {}
},
{
"id": 3,
"title": "Inner Group",
"bounding": [
80.96059074101554,
28.123757436778178,
718.286373661183,
691.2397164539732
],
"color": "#3f789e",
"font_size": 24,
"flags": {}
}
],
"config": {},
"extra": {
"ds": {
"scale": 0.7121393732101533,
"offset": [
289.18242848011835,
367.0747755524199
]
},
"frontendVersion": "1.35.5",
"VHS_latentpreview": false,
"VHS_latentpreviewrate": 0,
"VHS_MetadataImage": true,
"VHS_KeepIntermediate": true,
"workflowRendererVersion": "Vue"
},
"version": 0.4
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 422 B

View File

@@ -1653,55 +1653,6 @@ export class ComfyPage {
}, focusMode)
await this.nextFrame()
}
/**
* Get the position of a group by title.
* @param title The title of the group to find
* @returns The group's canvas position
* @throws Error if group not found
*/
async getGroupPosition(title: string): Promise<Position> {
const pos = await this.page.evaluate((title) => {
const groups = window['app'].graph.groups
const group = groups.find((g: { title: string }) => g.title === title)
if (!group) return null
return { x: group.pos[0], y: group.pos[1] }
}, title)
if (!pos) throw new Error(`Group "${title}" not found`)
return pos
}
/**
* Drag a group by its title.
* @param options.name The title of the group to drag
* @param options.deltaX Horizontal drag distance in screen pixels
* @param options.deltaY Vertical drag distance in screen pixels
*/
async dragGroup(options: {
name: string
deltaX: number
deltaY: number
}): Promise<void> {
const { name, deltaX, deltaY } = options
const screenPos = await this.page.evaluate((title) => {
const app = window['app']
const groups = app.graph.groups
const group = groups.find((g: { title: string }) => g.title === title)
if (!group) return null
// Position in the title area of the group
const clientPos = app.canvasPosToClientPos([
group.pos[0] + 50,
group.pos[1] + 15
])
return { x: clientPos[0], y: clientPos[1] }
}, name)
if (!screenPos) throw new Error(`Group "${name}" not found`)
await this.dragAndDrop(screenPos, {
x: screenPos.x + deltaX,
y: screenPos.y + deltaY
})
}
}
export const testComfySnapToGridGridSize = 50

View File

@@ -160,7 +160,7 @@ export class VueNodeHelpers {
return {
input: widget.locator('input'),
incrementButton: widget.locator('button').first(),
decrementButton: widget.locator('button').nth(1)
decrementButton: widget.locator('button').last()
}
}
}

View File

@@ -12,7 +12,6 @@ test.describe('Load Workflow in Media', () => {
'edited_workflow.webp',
'no_workflow.webp',
'large_workflow.webp',
'workflow_prompt_parameters.png',
'workflow.webm',
// Skipped due to 3d widget unstable visual result.
// 3d widget shows grid after fully loaded.

View File

@@ -1,29 +0,0 @@
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
import { expect } from '@playwright/test'
test.describe('Mobile Baseline Snapshots', () => {
test('@mobile empty canvas', async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.ConfirmClear', false)
await comfyPage.executeCommand('Comfy.ClearWorkflow')
await expect(async () => {
expect(await comfyPage.getGraphNodesCount()).toBe(0)
}).toPass({ timeout: 256 })
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot('mobile-empty-canvas.png')
})
test('@mobile default workflow', async ({ comfyPage }) => {
await comfyPage.loadWorkflow('default')
await expect(comfyPage.canvas).toHaveScreenshot(
'mobile-default-workflow.png'
)
})
test('@mobile settings dialog', async ({ comfyPage }) => {
await comfyPage.settingDialog.open()
await comfyPage.nextFrame()
await expect(comfyPage.settingDialog.root).toHaveScreenshot(
'mobile-settings-dialog.png'
)
})
})

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

View File

@@ -32,42 +32,4 @@ test.describe('Vue Node Groups', () => {
'vue-groups-fit-to-contents.png'
)
})
test('should move nested groups together when dragging outer group', async ({
comfyPage
}) => {
await comfyPage.loadWorkflow('groups/nested-groups-1-inner-node')
// Get initial positions with null guards
const outerInitial = await comfyPage.getGroupPosition('Outer Group')
const innerInitial = await comfyPage.getGroupPosition('Inner Group')
const initialOffsetX = innerInitial.x - outerInitial.x
const initialOffsetY = innerInitial.y - outerInitial.y
// Drag the outer group
const dragDelta = { x: 100, y: 80 }
await comfyPage.dragGroup({
name: 'Outer Group',
deltaX: dragDelta.x,
deltaY: dragDelta.y
})
// Use retrying assertion to wait for positions to update
await expect(async () => {
const outerFinal = await comfyPage.getGroupPosition('Outer Group')
const innerFinal = await comfyPage.getGroupPosition('Inner Group')
const finalOffsetX = innerFinal.x - outerFinal.x
const finalOffsetY = innerFinal.y - outerFinal.y
// Both groups should have moved
expect(outerFinal.x).not.toBe(outerInitial.x)
expect(innerFinal.x).not.toBe(innerInitial.x)
// The relative offset should be maintained (inner group moved with outer)
expect(finalOffsetX).toBeCloseTo(initialOffsetX, 0)
expect(finalOffsetY).toBeCloseTo(initialOffsetY, 0)
}).toPass({ timeout: 5000 })
})
})

Binary file not shown.

Before

Width:  |  Height:  |  Size: 118 KiB

After

Width:  |  Height:  |  Size: 117 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 63 KiB

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 63 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 65 KiB

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 67 KiB

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 64 KiB

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 61 KiB

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 64 KiB

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 63 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 65 KiB

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 101 KiB

After

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 112 KiB

After

Width:  |  Height:  |  Size: 112 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 112 KiB

After

Width:  |  Height:  |  Size: 111 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 142 KiB

After

Width:  |  Height:  |  Size: 141 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 144 KiB

After

Width:  |  Height:  |  Size: 143 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 112 KiB

After

Width:  |  Height:  |  Size: 111 KiB

View File

@@ -15,9 +15,7 @@ test.describe('Vue Integer Widget', () => {
await comfyPage.loadWorkflow('vueNodes/linked-int-widget')
await comfyPage.vueNodes.waitForNodes()
const seedWidget = comfyPage.vueNodes
.getWidgetByName('KSampler', 'seed')
.first()
const seedWidget = comfyPage.vueNodes.getWidgetByName('KSampler', 'seed')
const controls = comfyPage.vueNodes.getInputNumberControls(seedWidget)
const initialValue = Number(await controls.input.inputValue())

View File

@@ -1,97 +0,0 @@
# 5. Remove Import Map for Vue Extensions
Date: 2025-12-13
## Status
Accepted
## Context
ComfyUI frontend previously used a Vite plugin (`generateImportMapPlugin`) to inject an HTML import map exposing shared modules to extensions. This allowed Vue-based extensions to mark dependencies as external in their Vite configs:
```typescript
// Extension vite.config.ts (old pattern)
rollupOptions: {
external: ['vue', 'vue-i18n', 'pinia', /^primevue\/?.*/, ...]
}
```
The import map resolved bare specifiers like `import { ref } from 'vue'` at runtime by mapping them to pre-built ESM files served from `/assets/lib/`.
**Modules exposed via import map:**
- `vue` (vue.esm-browser.prod.js)
- `vue-i18n` (vue-i18n.esm-browser.prod.js)
- `primevue/*` (all PrimeVue components)
- `@primevue/themes/*`
- `@primevue/forms/*`
**Problems with import map approach:**
1. **Blocked tree shaking**: Vue and PrimeVue loaded as remote modules at runtime, preventing bundler optimizations. The entire Vue runtime was loaded even if only a few APIs were used.
2. **Poor code splitting**: PrimeVue's component library split into hundreds of small chunks, each requiring a separate network request on mount. This significantly impacted initial page load.
3. **Cold start performance**: Each externalized module required a separate HTTP request and browser module resolution step. This compounded on lower-end systems and slower networks.
4. **Version alignment complexity**: Extensions relied on the frontend's Vue version at runtime. Subtle version mismatches between build-time types and runtime code caused debugging difficulties.
5. **Incompatible with Cloud distribution**: The Cloud deployment model requires fully bundled, optimized assets. Import maps added a layer of indirection incompatible with our CDN and caching strategy.
## Decision
Remove the `generateImportMapPlugin` and require Vue-based extensions to bundle their own Vue instance.
**Implementation (PR #6899):**
- Deleted `build/plugins/generateImportMapPlugin.ts`
- Removed plugin configuration from `vite.config.mts`
- Removed `fast-glob` dependency used by the plugin
**Extension migration path:**
1. Remove `external: ['vue', ...]` from Vite rollup options
2. Vue and related dependencies will be bundled into the extension output
3. No code changes required in extension source files
The import map was already disabled for Cloud builds (PR #6559) before complete removal. Removal aligns all distribution channels on the same bundling strategy.
## Consequences
### Positive
- **Improved page load**: Full tree shaking and optimal code splitting now apply to Vue and PrimeVue
- **Faster development**: No import map generation step; simplified build pipeline
- **Better debugging**: Extension's bundled Vue matches build-time expectations exactly
- **Cloud compatibility**: All assets fully bundled and CDN-optimizable
- **Consistent behavior**: Same bundling strategy across desktop, localhost, and cloud distributions
- **Reduced network requests**: Fewer module fetches on initial page load
### Negative
- **Breaking change for existing extensions**: Extensions using `external: ['vue']` pattern fail with "Failed to resolve module specifier 'vue'" error
- **Larger extension bundles**: Each extension now includes its own Vue instance (~30KB gzipped)
- **Potential version fragmentation**: Different extensions may bundle different Vue versions (mitigated by Vue's stable API)
### Migration Impact
Extensions affected must update their build configuration. The migration is straightforward:
```diff
// vite.config.ts
rollupOptions: {
- external: ['vue', 'vue-i18n', 'primevue', ...]
}
```
Affected versions:
- **v1.32.x - v1.33.8**: Import map present, external pattern works
- **v1.33.9+**: Import map removed, bundling required
## Notes
- [ComfyUI_frontend_vue_basic](https://github.com/jtydhr88/ComfyUI_frontend_vue_basic) has been updated to demonstrate the new bundled pattern
- Issue #7267 documents the user-facing impact and migration discussion
- Future Extension API v2 (Issue #4668) may provide alternative mechanisms for shared dependencies

View File

@@ -14,7 +14,6 @@ An Architecture Decision Record captures an important architectural decision mad
| [0002](0002-monorepo-conversion.md) | Restructure as a Monorepo | Accepted | 2025-08-25 |
| [0003](0003-crdt-based-layout-system.md) | Centralized Layout Management with CRDT | Proposed | 2025-08-27 |
| [0004](0004-fork-primevue-ui-library.md) | Fork PrimeVue UI Library | Rejected | 2025-08-27 |
| [0005](0005-remove-importmap-for-vue-extensions.md) | Remove Import Map for Vue Extensions | Accepted | 2025-12-13 |
## Creating a New ADR

View File

@@ -5,6 +5,8 @@
<title>ComfyUI</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
<link rel="stylesheet" type="text/css" href="materialdesignicons.min.css" />
<link rel="stylesheet" type="text/css" href="user.css" />
<link rel="stylesheet" type="text/css" href="api/userdata/user.css" />
<!-- Fullscreen mode on mobile browsers -->
<meta name="mobile-web-app-capable" content="yes">

View File

@@ -1,7 +1,7 @@
{
"name": "@comfyorg/comfyui-frontend",
"private": true,
"version": "1.36.2",
"version": "1.35.4",
"type": "module",
"repository": "https://github.com/Comfy-Org/ComfyUI_frontend",
"homepage": "https://comfy.org",

View File

@@ -1,19 +0,0 @@
<svg viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg">
<defs>
<clipPath id="hollow">
<path
d="M -50 50
A 100 100, 0, 0, 1, 150 50
A 100 100, 0, 0, 1, -50 50
M 30 50
A 20 20, 0, 0, 0, 70 50
A 20 20, 0, 0, 0, 30 50"/>
</clipPath>
</defs>
<g clip-path="var(--shape)" stroke-width="4">
<path d="M 50 0 A 50 50, 0, 0, 1, 50 100" fill="var(--type1, red)"/>
<path d="M 50 100 A 50 50, 0, 0, 1, 50 0" fill="var(--type2, blue)"/>
<path d="M50 0L50 100" stroke="var(--inner-stroke, black)"/>
<path d="M50 2A48 48 0 0 1 50 98A48 48 0 0 1 50 2" fill="transparent" stroke="var(--outer-stroke, transparent)"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 693 B

View File

@@ -1,20 +0,0 @@
<svg viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg">
<defs>
<clipPath id="hollow">
<path
d="M-50 50
A100 100 0 0 1 150 50
A100 100 0 0 1 -50 50
M30 50
A20 20 0 0 0 70 50
A20 20 0 0 0 30 50"/>
</clipPath>
</defs>
<g clip-path="var(--shape)" stroke-width="4">
<path d="M50 0A50 50 0 0 1 93 75L50 50" fill="var(--type1, red)"/>
<path d="M93 75A50 50 0 0 1 7 75L50 50" fill="var(--type2, blue)"/>
<path d="M7 75A50 50 0 0 1 50 0L50 50" fill="var(--type3, green)"/>
<path d="M50 50L50 0M50 50L93 75M50 50L7 75" stroke="var(--inner-stroke, black)"/>
<path d="M50 2A48 48 0 0 1 50 98A48 48 0 0 1 50 2" fill="transparent" stroke="var(--outer-stroke, transparent)"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 763 B

View File

@@ -264,7 +264,7 @@ if (!releaseInfo) {
}
// Output as JSON for GitHub Actions
// eslint-disable-next-line no-console
console.log(JSON.stringify(releaseInfo, null, 2))
export { resolveRelease }

View File

@@ -10,66 +10,48 @@
</div>
<div class="mx-1 flex flex-col items-end gap-1">
<div class="flex items-center gap-2">
<div
class="actionbar-container pointer-events-auto flex h-12 items-center rounded-lg border border-interface-stroke px-2 shadow-interface"
>
<ActionBarButtons />
<!-- Support for legacy topbar elements attached by custom scripts, hidden if no elements present -->
<div
v-if="managerState.shouldShowManagerButtons.value && isDesktop"
class="pointer-events-auto flex h-12 shrink-0 items-center rounded-lg border border-interface-stroke bg-comfy-menu-bg px-2 shadow-interface"
ref="legacyCommandsContainerRef"
class="[&:not(:has(*>*:not(:empty)))]:hidden"
></div>
<ComfyActionbar />
<IconButton
v-tooltip.bottom="queueHistoryTooltipConfig"
type="transparent"
size="sm"
class="relative mr-2 text-base-foreground transition-colors duration-200 ease-in-out bg-secondary-background hover:bg-secondary-background-hover focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary-background"
:aria-pressed="isQueueOverlayExpanded"
:aria-label="
t('sideToolbar.queueProgressOverlay.expandCollapsedQueue')
"
@click="toggleQueueOverlay"
>
<IconButton
v-tooltip.bottom="customNodesManagerTooltipConfig"
type="transparent"
size="sm"
class="text-base-foreground transition-colors duration-200 ease-in-out bg-secondary-background hover:bg-secondary-background-hover focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary-background"
:aria-label="t('menu.customNodesManager')"
@click="openCustomNodeManager"
<i class="icon-[lucide--history] size-4" />
<span
v-if="queuedCount > 0"
class="absolute -top-1 -right-1 min-w-[16px] rounded-full bg-primary-background py-0.25 text-[10px] font-medium leading-[14px] text-white"
>
<i class="icon-[lucide--puzzle] size-4" />
</IconButton>
</div>
<div
class="actionbar-container pointer-events-auto flex h-12 items-center rounded-lg border border-interface-stroke bg-comfy-menu-bg px-2 shadow-interface"
{{ queuedCount }}
</span>
</IconButton>
<CurrentUserButton v-if="isLoggedIn" class="shrink-0" />
<LoginButton v-else-if="isDesktop" />
<IconButton
v-if="!isRightSidePanelOpen"
v-tooltip.bottom="rightSidePanelTooltipConfig"
type="transparent"
size="sm"
class="mr-2 text-base-foreground transition-colors duration-200 ease-in-out bg-secondary-background hover:bg-secondary-background-hover focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary-background"
:aria-label="t('rightSidePanel.togglePanel')"
@click="rightSidePanelStore.togglePanel"
>
<ActionBarButtons />
<!-- Support for legacy topbar elements attached by custom scripts, hidden if no elements present -->
<div
ref="legacyCommandsContainerRef"
class="[&:not(:has(*>*:not(:empty)))]:hidden"
></div>
<ComfyActionbar />
<IconButton
v-tooltip.bottom="queueHistoryTooltipConfig"
type="transparent"
size="sm"
class="relative mr-2 text-base-foreground transition-colors duration-200 ease-in-out bg-secondary-background hover:bg-secondary-background-hover focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary-background"
:aria-pressed="isQueueOverlayExpanded"
:aria-label="
t('sideToolbar.queueProgressOverlay.expandCollapsedQueue')
"
@click="toggleQueueOverlay"
>
<i class="icon-[lucide--history] size-4" />
<span
v-if="queuedCount > 0"
class="absolute -top-1 -right-1 min-w-[16px] rounded-full bg-primary-background py-0.25 text-[10px] font-medium leading-[14px] text-white"
>
{{ queuedCount }}
</span>
</IconButton>
<CurrentUserButton v-if="isLoggedIn" class="shrink-0" />
<LoginButton v-else-if="isDesktop" />
<IconButton
v-if="!isRightSidePanelOpen"
v-tooltip.bottom="rightSidePanelTooltipConfig"
type="transparent"
size="sm"
class="mr-2 text-base-foreground transition-colors duration-200 ease-in-out bg-secondary-background hover:bg-secondary-background-hover focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary-background"
:aria-label="t('rightSidePanel.togglePanel')"
@click="rightSidePanelStore.togglePanel"
>
<i class="icon-[lucide--panel-right] size-4" />
</IconButton>
</div>
<i class="icon-[lucide--panel-right] size-4" />
</IconButton>
</div>
<QueueProgressOverlay
v-model:expanded="isQueueOverlayExpanded"
@@ -92,23 +74,18 @@ import ActionBarButtons from '@/components/topbar/ActionBarButtons.vue'
import CurrentUserButton from '@/components/topbar/CurrentUserButton.vue'
import LoginButton from '@/components/topbar/LoginButton.vue'
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
import { useErrorHandling } from '@/composables/useErrorHandling'
import { buildTooltipConfig } from '@/composables/useTooltipConfig'
import { app } from '@/scripts/app'
import { useQueueStore } from '@/stores/queueStore'
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
import { useWorkspaceStore } from '@/stores/workspaceStore'
import { isElectron } from '@/utils/envUtil'
import { useManagerState } from '@/workbench/extensions/manager/composables/useManagerState'
import { ManagerTab } from '@/workbench/extensions/manager/types/comfyManagerTypes'
const workspaceStore = useWorkspaceStore()
const rightSidePanelStore = useRightSidePanelStore()
const managerState = useManagerState()
const { isLoggedIn } = useCurrentUser()
const isDesktop = isElectron()
const { t } = useI18n()
const { toastErrorHandler } = useErrorHandling()
const isQueueOverlayExpanded = ref(false)
const queueStore = useQueueStore()
const isTopMenuHovered = ref(false)
@@ -116,9 +93,6 @@ const queuedCount = computed(() => queueStore.pendingTasks.length)
const queueHistoryTooltipConfig = computed(() =>
buildTooltipConfig(t('sideToolbar.queueProgressOverlay.viewJobHistory'))
)
const customNodesManagerTooltipConfig = computed(() =>
buildTooltipConfig(t('menu.customNodesManager'))
)
// Right side panel toggle
const { isOpen: isRightSidePanelOpen } = storeToRefs(rightSidePanelStore)
@@ -138,20 +112,10 @@ onMounted(() => {
const toggleQueueOverlay = () => {
isQueueOverlayExpanded.value = !isQueueOverlayExpanded.value
}
const openCustomNodeManager = async () => {
try {
await managerState.openManager({
initialTab: ManagerTab.All,
showToastOnLegacyError: false
})
} catch (error) {
try {
toastErrorHandler(error)
} catch (toastError) {
console.error(error)
console.error(toastError)
}
}
}
</script>
<style scoped>
.actionbar-container {
background-color: var(--comfy-menu-bg);
}
</style>

View File

@@ -15,7 +15,9 @@
<script setup lang="ts">
import Tag from 'primevue/tag'
import { isStaging } from '@/config/staging'
// Global variable from vite build defined in global.d.ts
// eslint-disable-next-line no-undef
const isStaging = !__USE_PROD_CONFIG__
</script>
<style scoped>

View File

@@ -76,8 +76,8 @@
/>
</TransformPane>
<!-- Selection rectangle overlay - rendered in DOM layer to appear above DOM widgets -->
<SelectionRectangle v-if="comfyAppReady" />
<!-- Selection rectangle overlay for Vue nodes mode -->
<SelectionRectangle v-if="shouldRenderVueNodes && comfyAppReady" />
<NodeTooltip v-if="tooltipEnabled" />
<NodeSearchboxPopover ref="nodeSearchboxPopoverRef" />

View File

@@ -10,7 +10,7 @@
></div>
<ButtonGroup
class="absolute right-0 bottom-0 z-1200 flex-row gap-1 border-[1px] border-interface-stroke bg-comfy-menu-bg p-2"
class="absolute right-0 bottom-0 z-[1200] flex-row gap-1 border-[1px] border-interface-stroke bg-comfy-menu-bg p-2"
:style="{
...stringifiedMinimapStyles.buttonGroupStyles
}"

View File

@@ -1,7 +1,7 @@
<template>
<div
v-if="isVisible"
class="pointer-events-none absolute z-9999 border border-blue-400 bg-blue-500/20"
class="pointer-events-none absolute border border-blue-400 bg-blue-500/20"
:style="rectangleStyle"
/>
</template>

View File

@@ -8,7 +8,7 @@
unstyled
:pt="{
root: {
class: 'absolute z-60'
class: 'absolute z-[60]'
},
content: {
class: [

View File

@@ -1,5 +1,5 @@
<template>
<div class="h-full z-8888 flex flex-col justify-between bg-comfy-menu-bg">
<div class="h-full z-[8888] flex flex-col justify-between bg-comfy-menu-bg">
<div class="flex flex-col">
<div
v-for="tool in allTools"

View File

@@ -4,6 +4,7 @@
:header-title="headerTitle"
:show-concurrent-indicator="showConcurrentIndicator"
:concurrent-workflow-count="concurrentWorkflowCount"
@clear-history="$emit('clearHistory')"
/>
<div class="flex items-center justify-between px-3">
@@ -109,6 +110,7 @@ defineProps<{
const emit = defineEmits<{
(e: 'showAssets'): void
(e: 'clearHistory'): void
(e: 'clearQueued'): void
(e: 'update:selectedJobTab', value: JobTab): void
(e: 'update:selectedWorkflowFilter', value: 'all' | 'current'): void

View File

@@ -1,17 +1,47 @@
import { mount } from '@vue/test-utils'
import { describe, expect, it } from 'vitest'
import { describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
import { defineComponent } from 'vue'
const popoverToggleSpy = vi.fn()
const popoverHideSpy = vi.fn()
vi.mock('primevue/popover', () => {
const PopoverStub = defineComponent({
name: 'Popover',
setup(_, { slots, expose }) {
const toggle = (event: Event) => {
popoverToggleSpy(event)
}
const hide = () => {
popoverHideSpy()
}
expose({ toggle, hide })
return () => slots.default?.()
}
})
return { default: PopoverStub }
})
import QueueOverlayHeader from './QueueOverlayHeader.vue'
import * as tooltipConfig from '@/composables/useTooltipConfig'
const tooltipDirectiveStub = {
mounted: vi.fn(),
updated: vi.fn()
}
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
g: { more: 'More' },
sideToolbar: {
queueProgressOverlay: {
running: 'running'
running: 'running',
moreOptions: 'More options',
clearHistory: 'Clear history'
}
}
}
@@ -27,7 +57,8 @@ const mountHeader = (props = {}) =>
...props
},
global: {
plugins: [i18n]
plugins: [i18n],
directives: { tooltip: tooltipDirectiveStub }
}
})
@@ -48,4 +79,20 @@ describe('QueueOverlayHeader', () => {
expect(wrapper.text()).toContain('Job queue')
expect(wrapper.find('.inline-flex.items-center.gap-1').exists()).toBe(false)
})
it('toggles popover and emits clear history', async () => {
const spy = vi.spyOn(tooltipConfig, 'buildTooltipConfig')
const wrapper = mountHeader()
const moreButton = wrapper.get('button[aria-label="More options"]')
await moreButton.trigger('click')
expect(popoverToggleSpy).toHaveBeenCalledTimes(1)
expect(spy).toHaveBeenCalledWith('More')
const clearHistoryButton = wrapper.get('button[aria-label="Clear history"]')
await clearHistoryButton.trigger('click')
expect(popoverHideSpy).toHaveBeenCalledTimes(1)
expect(wrapper.emitted('clearHistory')).toHaveLength(1)
})
})

View File

@@ -17,17 +17,85 @@
</span>
</span>
</div>
<div class="flex items-center gap-1">
<IconButton
v-tooltip.top="moreTooltipConfig"
type="transparent"
size="sm"
class="size-6 bg-transparent hover:bg-secondary-background hover:opacity-100"
:aria-label="t('sideToolbar.queueProgressOverlay.moreOptions')"
@click="onMoreClick"
>
<i
class="icon-[lucide--more-horizontal] block size-4 leading-none text-text-secondary"
/>
</IconButton>
<Popover
ref="morePopoverRef"
:dismissable="true"
:close-on-escape="true"
unstyled
:pt="{
root: { class: 'absolute z-50' },
content: {
class: [
'bg-transparent border-none p-0 pt-2 rounded-lg shadow-lg font-inter'
]
}
}"
>
<div
class="flex flex-col items-stretch rounded-lg border border-interface-stroke bg-interface-panel-surface px-2 py-3 font-inter"
>
<IconTextButton
class="w-full justify-start gap-2 bg-transparent p-2 font-inter text-[12px] leading-none text-text-primary hover:bg-transparent hover:opacity-90"
type="transparent"
:label="t('sideToolbar.queueProgressOverlay.clearHistory')"
:aria-label="t('sideToolbar.queueProgressOverlay.clearHistory')"
@click="onClearHistoryFromMenu"
>
<template #icon>
<i
class="icon-[lucide--file-x-2] block size-4 leading-none text-text-secondary"
/>
</template>
</IconTextButton>
</div>
</Popover>
</div>
</div>
</template>
<script setup lang="ts">
import Popover from 'primevue/popover'
import type { PopoverMethods } from 'primevue/popover'
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import IconButton from '@/components/button/IconButton.vue'
import IconTextButton from '@/components/button/IconTextButton.vue'
import { buildTooltipConfig } from '@/composables/useTooltipConfig'
defineProps<{
headerTitle: string
showConcurrentIndicator: boolean
concurrentWorkflowCount: number
}>()
const emit = defineEmits<{
(e: 'clearHistory'): void
}>()
const { t } = useI18n()
const morePopoverRef = ref<PopoverMethods | null>(null)
const moreTooltipConfig = computed(() => buildTooltipConfig(t('g.more')))
const onMoreClick = (event: MouseEvent) => {
morePopoverRef.value?.toggle(event)
}
const onClearHistoryFromMenu = () => {
morePopoverRef.value?.hide()
emit('clearHistory')
}
</script>

View File

@@ -23,6 +23,7 @@
:displayed-job-groups="displayedJobGroups"
:has-failed-jobs="hasFailedJobs"
@show-assets="openAssetsSidebar"
@clear-history="onClearHistoryFromMenu"
@clear-queued="cancelQueuedWorkflows"
@cancel-item="onCancelItem"
@delete-item="onDeleteItem"
@@ -65,6 +66,7 @@ import { useI18n } from 'vue-i18n'
import QueueOverlayActive from '@/components/queue/QueueOverlayActive.vue'
import QueueOverlayEmpty from '@/components/queue/QueueOverlayEmpty.vue'
import QueueOverlayExpanded from '@/components/queue/QueueOverlayExpanded.vue'
import QueueClearHistoryDialog from '@/components/queue/dialogs/QueueClearHistoryDialog.vue'
import ResultGallery from '@/components/sidebar/tabs/queue/ResultGallery.vue'
import { useCompletionSummary } from '@/composables/queue/useCompletionSummary'
import { useJobList } from '@/composables/queue/useJobList'
@@ -77,6 +79,7 @@ import { isCloud } from '@/platform/distribution/types'
import { api } from '@/scripts/api'
import { useAssetsStore } from '@/stores/assetsStore'
import { useCommandStore } from '@/stores/commandStore'
import { useDialogStore } from '@/stores/dialogStore'
import { useExecutionStore } from '@/stores/executionStore'
import { useQueueStore } from '@/stores/queueStore'
import { useSidebarTabStore } from '@/stores/workspace/sidebarTabStore'
@@ -102,6 +105,7 @@ const queueStore = useQueueStore()
const commandStore = useCommandStore()
const executionStore = useExecutionStore()
const sidebarTabStore = useSidebarTabStore()
const dialogStore = useDialogStore()
const assetsStore = useAssetsStore()
const assetSelectionStore = useAssetSelectionStore()
const { wrapWithErrorHandlingAsync } = useErrorHandling()
@@ -276,4 +280,29 @@ const interruptAll = wrapWithErrorHandlingAsync(async () => {
await Promise.all(promptIds.map((id) => api.interrupt(id)))
})
const showClearHistoryDialog = () => {
dialogStore.showDialog({
key: 'queue-clear-history',
component: QueueClearHistoryDialog,
dialogComponentProps: {
headless: true,
closable: false,
closeOnEscape: true,
dismissableMask: true,
pt: {
root: {
class: 'max-w-[360px] w-auto bg-transparent border-none shadow-none'
},
content: {
class: '!p-0 bg-transparent'
}
}
}
})
}
const onClearHistoryFromMenu = () => {
showClearHistoryDialog()
}
</script>

View File

@@ -0,0 +1,90 @@
<template>
<section
class="w-[360px] rounded-2xl border border-interface-stroke bg-interface-panel-surface text-text-primary shadow-interface font-inter"
>
<header
class="flex items-center justify-between border-b border-interface-stroke px-4 py-4"
>
<p class="m-0 text-[14px] font-normal leading-none">
{{ t('sideToolbar.queueProgressOverlay.clearHistoryDialogTitle') }}
</p>
<IconButton
type="transparent"
size="sm"
class="size-6 bg-transparent text-text-secondary hover:bg-secondary-background hover:opacity-100"
:aria-label="t('g.close')"
@click="onCancel"
>
<i class="icon-[lucide--x] block size-4 leading-none" />
</IconButton>
</header>
<div class="flex flex-col gap-4 px-4 py-4 text-[14px] text-text-secondary">
<p class="m-0">
{{
t('sideToolbar.queueProgressOverlay.clearHistoryDialogDescription')
}}
</p>
<p class="m-0">
{{ t('sideToolbar.queueProgressOverlay.clearHistoryDialogAssetsNote') }}
</p>
</div>
<footer class="flex items-center justify-end px-4 py-4">
<div class="flex items-center gap-4 text-[14px] leading-none">
<TextButton
class="min-h-[24px] px-1 py-1 text-[14px] leading-[1] text-text-secondary hover:text-text-primary"
type="transparent"
:label="t('g.cancel')"
@click="onCancel"
/>
<TextButton
class="min-h-[32px] px-4 py-2 text-[12px] font-normal leading-[1]"
type="secondary"
:label="t('g.clear')"
:disabled="isClearing"
@click="onConfirm"
/>
</div>
</footer>
</section>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { useI18n } from 'vue-i18n'
import IconButton from '@/components/button/IconButton.vue'
import TextButton from '@/components/button/TextButton.vue'
import { useErrorHandling } from '@/composables/useErrorHandling'
import { useDialogStore } from '@/stores/dialogStore'
import { useQueueStore } from '@/stores/queueStore'
const dialogStore = useDialogStore()
const queueStore = useQueueStore()
const { t } = useI18n()
const { wrapWithErrorHandlingAsync } = useErrorHandling()
const isClearing = ref(false)
const clearHistory = wrapWithErrorHandlingAsync(
async () => {
await queueStore.clear(['history'])
dialogStore.closeDialog()
},
undefined,
() => {
isClearing.value = false
}
)
const onConfirm = async () => {
if (isClearing.value) return
isClearing.value = true
await clearHistory()
}
const onCancel = () => {
dialogStore.closeDialog()
}
</script>

View File

@@ -67,7 +67,7 @@
/>
</div>
<div class="relative z-1 flex items-center gap-1">
<div class="relative z-[1] flex items-center gap-1">
<div class="relative inline-flex items-center justify-center">
<div
class="absolute left-1/2 top-1/2 size-10 -translate-x-1/2 -translate-y-1/2"
@@ -90,7 +90,7 @@
</div>
</div>
<div class="relative z-1 min-w-0 flex-1">
<div class="relative z-[1] min-w-0 flex-1">
<div class="truncate opacity-90" :title="props.title">
<slot name="primary">{{ props.title }}</slot>
</div>
@@ -113,7 +113,7 @@
This would eliminate the current duplication where the cancel button exists
both outside (for running) and inside (for pending) the Transition.
-->
<div class="relative z-1 flex items-center gap-2 text-text-secondary">
<div class="relative z-[1] flex items-center gap-2 text-text-secondary">
<Transition
mode="out-in"
enter-active-class="transition-opacity transition-transform duration-150 ease-out"

View File

@@ -56,13 +56,15 @@
class="pb-1 px-2 2xl:px-4"
:show-generation-time-sort="activeTab === 'output'"
/>
<Divider type="dashed" class="my-2" />
</template>
<template #body>
<div v-if="loading && !displayAssets.length">
<Divider type="dashed" class="m-2" />
<!-- Loading state -->
<div v-if="loading">
<ProgressSpinner class="absolute left-1/2 w-[50px] -translate-x-1/2" />
</div>
<div v-else-if="!loading && !displayAssets.length">
<!-- Empty state -->
<div v-else-if="!displayAssets.length">
<NoResultsPlaceholder
icon="pi pi-info-circle"
:title="
@@ -75,6 +77,7 @@
:message="$t('sideToolbar.noFilesFoundMessage')"
/>
</div>
<!-- Content -->
<div v-else class="relative size-full" @click="handleEmptySpaceClick">
<VirtualGrid
:items="mediaAssetsWithKey"

View File

@@ -27,7 +27,6 @@
<ScrollPanel class="comfy-vue-side-bar-body h-0 grow">
<slot name="body" />
</ScrollPanel>
<slot name="footer" />
</div>
</template>

View File

@@ -1,190 +0,0 @@
import { describe, it, expect } from 'vitest'
import type { MenuOption } from './useMoreOptionsMenu'
import {
buildStructuredMenu,
convertContextMenuToOptions
} from './contextMenuConverter'
describe('contextMenuConverter', () => {
describe('buildStructuredMenu', () => {
it('should order core items before extension items', () => {
const options: MenuOption[] = [
{ label: 'Custom Extension Item', source: 'litegraph' },
{ label: 'Copy', source: 'vue' },
{ label: 'Rename', source: 'vue' }
]
const result = buildStructuredMenu(options)
// Core items (Rename, Copy) should come before extension items
const renameIndex = result.findIndex((opt) => opt.label === 'Rename')
const copyIndex = result.findIndex((opt) => opt.label === 'Copy')
const extensionIndex = result.findIndex(
(opt) => opt.label === 'Custom Extension Item'
)
expect(renameIndex).toBeLessThan(extensionIndex)
expect(copyIndex).toBeLessThan(extensionIndex)
})
it('should add Extensions category label before extension items', () => {
const options: MenuOption[] = [
{ label: 'Copy', source: 'vue' },
{ label: 'My Custom Extension', source: 'litegraph' }
]
const result = buildStructuredMenu(options)
const extensionsLabel = result.find(
(opt) => opt.label === 'Extensions' && opt.type === 'category'
)
expect(extensionsLabel).toBeDefined()
expect(extensionsLabel?.disabled).toBe(true)
})
it('should place Delete at the very end', () => {
const options: MenuOption[] = [
{ label: 'Delete', action: () => {}, source: 'vue' },
{ label: 'Copy', source: 'vue' },
{ label: 'Rename', source: 'vue' }
]
const result = buildStructuredMenu(options)
const lastNonDivider = [...result]
.reverse()
.find((opt) => opt.type !== 'divider')
expect(lastNonDivider?.label).toBe('Delete')
})
it('should deduplicate items with same label, preferring vue source', () => {
const options: MenuOption[] = [
{ label: 'Copy', action: () => {}, source: 'litegraph' },
{ label: 'Copy', action: () => {}, source: 'vue' }
]
const result = buildStructuredMenu(options)
const copyItems = result.filter((opt) => opt.label === 'Copy')
expect(copyItems).toHaveLength(1)
expect(copyItems[0].source).toBe('vue')
})
it('should preserve dividers between sections', () => {
const options: MenuOption[] = [
{ label: 'Rename', source: 'vue' },
{ label: 'Copy', source: 'vue' },
{ label: 'Pin', source: 'vue' }
]
const result = buildStructuredMenu(options)
const dividers = result.filter((opt) => opt.type === 'divider')
expect(dividers.length).toBeGreaterThan(0)
})
it('should handle empty input', () => {
const result = buildStructuredMenu([])
expect(result).toEqual([])
})
it('should handle only dividers', () => {
const options: MenuOption[] = [{ type: 'divider' }, { type: 'divider' }]
const result = buildStructuredMenu(options)
// Should be empty since dividers are filtered initially
expect(result).toEqual([])
})
it('should recognize Remove as equivalent to Delete', () => {
const options: MenuOption[] = [
{ label: 'Remove', action: () => {}, source: 'vue' },
{ label: 'Copy', source: 'vue' }
]
const result = buildStructuredMenu(options)
// Remove should be placed at the end like Delete
const lastNonDivider = [...result]
.reverse()
.find((opt) => opt.type !== 'divider')
expect(lastNonDivider?.label).toBe('Remove')
})
it('should group core items in correct section order', () => {
const options: MenuOption[] = [
{ label: 'Color', source: 'vue' },
{ label: 'Node Info', source: 'vue' },
{ label: 'Pin', source: 'vue' },
{ label: 'Rename', source: 'vue' }
]
const result = buildStructuredMenu(options)
// Get indices of items (excluding dividers and categories)
const getIndex = (label: string) =>
result.findIndex((opt) => opt.label === label)
// Rename (section 1) should come before Pin (section 2)
expect(getIndex('Rename')).toBeLessThan(getIndex('Pin'))
// Pin (section 2) should come before Node Info (section 4)
expect(getIndex('Pin')).toBeLessThan(getIndex('Node Info'))
// Node Info (section 4) should come before or with Color (section 4)
expect(getIndex('Node Info')).toBeLessThanOrEqual(getIndex('Color'))
})
})
describe('convertContextMenuToOptions', () => {
it('should convert empty array to empty result', () => {
const result = convertContextMenuToOptions([])
expect(result).toEqual([])
})
it('should convert null items to dividers', () => {
const result = convertContextMenuToOptions([null], undefined, false)
expect(result).toHaveLength(1)
expect(result[0].type).toBe('divider')
})
it('should skip blacklisted items like Properties', () => {
const items = [{ content: 'Properties', callback: () => {} }]
const result = convertContextMenuToOptions(items, undefined, false)
expect(result.find((opt) => opt.label === 'Properties')).toBeUndefined()
})
it('should convert basic menu items with content', () => {
const items = [{ content: 'Test Item', callback: () => {} }]
const result = convertContextMenuToOptions(items, undefined, false)
expect(result).toHaveLength(1)
expect(result[0].label).toBe('Test Item')
})
it('should mark items as litegraph source', () => {
const items = [{ content: 'Test Item', callback: () => {} }]
const result = convertContextMenuToOptions(items, undefined, false)
expect(result[0].source).toBe('litegraph')
})
it('should pass through disabled state', () => {
const items = [{ content: 'Disabled Item', disabled: true }]
const result = convertContextMenuToOptions(items, undefined, false)
expect(result[0].disabled).toBe(true)
})
it('should apply structuring by default', () => {
const items = [
{ content: 'Copy', callback: () => {} },
{ content: 'Custom Extension', callback: () => {} }
]
const result = convertContextMenuToOptions(items)
// With structuring, there should be Extensions category
const hasExtensionsCategory = result.some(
(opt) => opt.label === 'Extensions' && opt.type === 'category'
)
expect(hasExtensionsCategory).toBe(true)
})
})
})

View File

@@ -1,620 +0,0 @@
import { default as DOMPurify } from 'dompurify'
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
import type {
IContextMenuValue,
LGraphNode,
IContextMenuOptions,
ContextMenu
} from '@/lib/litegraph/src/litegraph'
import type { MenuOption, SubMenuOption } from './useMoreOptionsMenu'
import type { ContextMenuDivElement } from '@/lib/litegraph/src/interfaces'
/**
* Hard blacklist - items that should NEVER be included
*/
const HARD_BLACKLIST = new Set([
'Properties', // Never include Properties submenu
'Colors', // Use singular "Color" instead
'Shapes', // Use singular "Shape" instead
'Title',
'Mode',
'Properties Panel',
'Copy (Clipspace)'
])
/**
* Core menu items - items that should appear in the main menu, not under Extensions
* Includes both LiteGraph base menu items and ComfyUI built-in functionality
*/
const CORE_MENU_ITEMS = new Set([
// Basic operations
'Rename',
'Copy',
'Duplicate',
'Clone',
// Node state operations
'Run Branch',
'Pin',
'Unpin',
'Bypass',
'Remove Bypass',
'Mute',
// Structure operations
'Convert to Subgraph',
'Frame selection',
'Minimize Node',
'Expand',
'Collapse',
// Info and adjustments
'Node Info',
'Resize',
'Title',
'Properties Panel',
'Adjust Size',
// Visual
'Color',
'Colors',
'Shape',
'Shapes',
'Mode',
// Built-in node operations (node-specific)
'Open Image',
'Copy Image',
'Save Image',
'Open in Mask Editor',
'Edit Subgraph Widgets',
'Unpack Subgraph',
'Copy (Clipspace)',
'Paste (Clipspace)',
// Selection and alignment
'Align Selected To',
'Distribute Nodes',
// Deletion
'Delete',
'Remove',
// LiteGraph base items
'Show Advanced',
'Hide Advanced'
])
/**
* Normalize menu item label for duplicate detection
* Handles variations like Colors/Color, Shapes/Shape, Pin/Unpin, Remove/Delete
*/
function normalizeLabel(label: string): string {
return label
.toLowerCase()
.replace(/^un/, '') // Remove 'un' prefix (Unpin -> Pin)
.trim()
}
/**
* Check if a similar menu item already exists in the results
* Returns true if an item with the same normalized label exists
*/
function isDuplicateItem(label: string, existingItems: MenuOption[]): boolean {
const normalizedLabel = normalizeLabel(label)
// Map of equivalent items
const equivalents: Record<string, string[]> = {
color: ['color', 'colors'],
shape: ['shape', 'shapes'],
pin: ['pin', 'unpin'],
delete: ['remove', 'delete'],
duplicate: ['clone', 'duplicate']
}
return existingItems.some((item) => {
if (!item.label) return false
const existingNormalized = normalizeLabel(item.label)
// Check direct match
if (existingNormalized === normalizedLabel) return true
// Check if they're in the same equivalence group
for (const values of Object.values(equivalents)) {
if (
values.includes(normalizedLabel) &&
values.includes(existingNormalized)
) {
return true
}
}
return false
})
}
/**
* Check if a menu item is a core menu item (not an extension)
* Core items include LiteGraph base items and ComfyUI built-in functionality
*/
function isCoreMenuItem(label: string): boolean {
return CORE_MENU_ITEMS.has(label)
}
/**
* Filter out duplicate menu items based on label
* Gives precedence to Vue hardcoded options over LiteGraph options
*/
function removeDuplicateMenuOptions(options: MenuOption[]): MenuOption[] {
// Group items by label
const itemsByLabel = new Map<string, MenuOption[]>()
const itemsWithoutLabel: MenuOption[] = []
for (const opt of options) {
// Always keep dividers and category items
if (opt.type === 'divider' || opt.type === 'category') {
itemsWithoutLabel.push(opt)
continue
}
// Items without labels are kept as-is
if (!opt.label) {
itemsWithoutLabel.push(opt)
continue
}
// Group by label
if (!itemsByLabel.has(opt.label)) {
itemsByLabel.set(opt.label, [])
}
itemsByLabel.get(opt.label)!.push(opt)
}
// Select best item for each label (prefer vue over litegraph)
const result: MenuOption[] = []
const seenLabels = new Set<string>()
for (const opt of options) {
// Add non-labeled items in original order
if (opt.type === 'divider' || opt.type === 'category' || !opt.label) {
if (itemsWithoutLabel.includes(opt)) {
result.push(opt)
const idx = itemsWithoutLabel.indexOf(opt)
itemsWithoutLabel.splice(idx, 1)
}
continue
}
// Skip if we already processed this label
if (seenLabels.has(opt.label)) {
continue
}
seenLabels.add(opt.label)
// Get all items with this label
const duplicates = itemsByLabel.get(opt.label)!
// If only one item, add it
if (duplicates.length === 1) {
result.push(duplicates[0])
continue
}
// Multiple items: prefer vue source over litegraph
const vueItem = duplicates.find((item) => item.source === 'vue')
if (vueItem) {
result.push(vueItem)
} else {
// No vue item, just take the first one
result.push(duplicates[0])
}
}
return result
}
/**
* Order groups for menu items - defines the display order of sections
*/
const MENU_ORDER: string[] = [
// Section 1: Basic operations
'Rename',
'Copy',
'Duplicate',
// Section 2: Node actions
'Run Branch',
'Pin',
'Unpin',
'Bypass',
'Remove Bypass',
'Mute',
// Section 3: Structure operations
'Convert to Subgraph',
'Frame selection',
'Minimize Node',
'Expand',
'Collapse',
'Resize',
'Clone',
// Section 4: Node properties
'Node Info',
'Color',
// Section 5: Node-specific operations
'Open in Mask Editor',
'Open Image',
'Copy Image',
'Save Image',
'Copy (Clipspace)',
'Paste (Clipspace)',
// Fallback for other core items
'Convert to Group Node (Deprecated)'
]
/**
* Get the order index for a menu item (lower = earlier in menu)
*/
function getMenuItemOrder(label: string): number {
const index = MENU_ORDER.indexOf(label)
return index === -1 ? 999 : index
}
/**
* Build structured menu with core items first, then extensions under a labeled section
* Ensures Delete always appears at the bottom
*/
export function buildStructuredMenu(options: MenuOption[]): MenuOption[] {
// First, remove duplicates (giving precedence to Vue hardcoded options)
const deduplicated = removeDuplicateMenuOptions(options)
const coreItemsMap = new Map<string, MenuOption>()
const extensionItems: MenuOption[] = []
let deleteItem: MenuOption | undefined
// Separate items into core and extension categories
for (const option of deduplicated) {
// Skip dividers for now - we'll add them between sections later
if (option.type === 'divider') {
continue
}
// Skip category labels (they'll be added separately)
if (option.type === 'category') {
continue
}
// Check if this is the Delete/Remove item - save it for the end
const isDeleteItem = option.label === 'Delete' || option.label === 'Remove'
if (isDeleteItem && !option.hasSubmenu) {
deleteItem = option
continue
}
// Categorize based on label
if (option.label && isCoreMenuItem(option.label)) {
coreItemsMap.set(option.label, option)
} else {
extensionItems.push(option)
}
}
// Build ordered core items based on MENU_ORDER
const orderedCoreItems: MenuOption[] = []
const coreLabels = Array.from(coreItemsMap.keys())
coreLabels.sort((a, b) => getMenuItemOrder(a) - getMenuItemOrder(b))
// Section boundaries based on MENU_ORDER indices
// Section 1: 0-2 (Rename, Copy, Duplicate)
// Section 2: 3-8 (Run Branch, Pin, Unpin, Bypass, Remove Bypass, Mute)
// Section 3: 9-15 (Convert to Subgraph, Frame selection, Minimize Node, Expand, Collapse, Resize, Clone)
// Section 4: 16-17 (Node Info, Color)
// Section 5: 18+ (Image operations and fallback items)
const getSectionNumber = (index: number): number => {
if (index <= 2) return 1
if (index <= 8) return 2
if (index <= 15) return 3
if (index <= 17) return 4
return 5
}
let lastSection = 0
for (const label of coreLabels) {
const item = coreItemsMap.get(label)!
const itemIndex = getMenuItemOrder(label)
const currentSection = getSectionNumber(itemIndex)
// Add divider when moving to a new section
if (lastSection > 0 && currentSection !== lastSection) {
orderedCoreItems.push({ type: 'divider' })
}
orderedCoreItems.push(item)
lastSection = currentSection
}
// Build the final menu structure
const result: MenuOption[] = []
// Add ordered core items with their dividers
result.push(...orderedCoreItems)
// Add extensions section if there are extension items
if (extensionItems.length > 0) {
// Add divider before Extensions section
result.push({ type: 'divider' })
// Add non-clickable Extensions label
result.push({
label: 'Extensions',
type: 'category',
disabled: true
})
// Add extension items
result.push(...extensionItems)
}
// Add Delete at the bottom if it exists
if (deleteItem) {
result.push({ type: 'divider' })
result.push(deleteItem)
}
return result
}
/**
* Convert LiteGraph IContextMenuValue items to Vue MenuOption format
* Used to bridge LiteGraph context menus into Vue node menus
* @param items - The LiteGraph menu items to convert
* @param node - The node context (optional)
* @param applyStructuring - Whether to apply menu structuring (core/extensions separation). Defaults to true.
*/
export function convertContextMenuToOptions(
items: (IContextMenuValue | null)[],
node?: LGraphNode,
applyStructuring: boolean = true
): MenuOption[] {
const result: MenuOption[] = []
for (const item of items) {
// Null items are separators in LiteGraph
if (item === null) {
result.push({ type: 'divider' })
continue
}
// Skip items without content (shouldn't happen, but be safe)
if (!item.content) {
continue
}
// Skip hard blacklisted items
if (HARD_BLACKLIST.has(item.content)) {
continue
}
// Skip if a similar item already exists in results
if (isDuplicateItem(item.content, result)) {
continue
}
const option: MenuOption = {
label: item.content,
source: 'litegraph'
}
// Pass through disabled state
if (item.disabled) {
option.disabled = true
}
// Handle submenus
if (item.has_submenu) {
// Static submenu with pre-defined options
if (item.submenu?.options) {
option.hasSubmenu = true
option.submenu = convertSubmenuToOptions(item.submenu.options)
}
// Dynamic submenu - callback creates it on-demand
else if (item.callback && !item.disabled) {
option.hasSubmenu = true
// Intercept the callback to capture dynamic submenu items
const capturedSubmenu = captureDynamicSubmenu(item, node)
if (capturedSubmenu) {
option.submenu = capturedSubmenu
} else {
console.warn(
'[ContextMenuConverter] Failed to capture submenu for:',
item.content
)
}
}
}
// Handle callback (only if not disabled and not a submenu)
else if (item.callback && !item.disabled) {
// Wrap the callback to match the () => void signature
option.action = () => {
try {
void item.callback?.call(
item as unknown as ContextMenuDivElement,
item.value,
{},
undefined,
undefined,
item
)
} catch (error) {
console.error('Error executing context menu callback:', error)
}
}
}
result.push(option)
}
// Apply structured menu with core items and extensions section (if requested)
if (applyStructuring) {
return buildStructuredMenu(result)
}
return result
}
/**
* Capture submenu items from a dynamic submenu callback
* Intercepts ContextMenu constructor to extract items without creating HTML menu
*/
function captureDynamicSubmenu(
item: IContextMenuValue,
node?: LGraphNode
): SubMenuOption[] | undefined {
let capturedItems: readonly (IContextMenuValue | string | null)[] | undefined
let capturedOptions: IContextMenuOptions | undefined
// Store original ContextMenu constructor
const OriginalContextMenu = LiteGraph.ContextMenu
try {
// Mock ContextMenu constructor to capture submenu items and options
LiteGraph.ContextMenu = function (
items: readonly (IContextMenuValue | string | null)[],
options?: IContextMenuOptions
) {
// Capture both items and options
capturedItems = items
capturedOptions = options
// Return a minimal mock object to prevent errors
return {
close: () => {},
root: document.createElement('div')
} as unknown as ContextMenu
} as unknown as typeof ContextMenu
// Execute the callback to trigger submenu creation
try {
// Create a mock MouseEvent for the callback
const mockEvent = new MouseEvent('click', {
bubbles: true,
cancelable: true,
clientX: 0,
clientY: 0
})
// Create a mock parent menu
const mockMenu = {
close: () => {},
root: document.createElement('div')
} as unknown as ContextMenu
// Call the callback which should trigger ContextMenu constructor
// Callback signature varies, but typically: (value, options, event, menu, node)
void item.callback?.call(
item as unknown as ContextMenuDivElement,
item.value,
{},
mockEvent,
mockMenu,
node // Pass the node context for callbacks that need it
)
} catch (error) {
console.warn(
'[ContextMenuConverter] Error executing callback for:',
item.content,
error
)
}
} finally {
// Always restore original constructor
LiteGraph.ContextMenu = OriginalContextMenu
}
// Convert captured items to Vue submenu format
if (capturedItems) {
const converted = convertSubmenuToOptions(capturedItems, capturedOptions)
return converted
}
console.warn('[ContextMenuConverter] No items captured for:', item.content)
return undefined
}
/**
* Convert LiteGraph submenu items to Vue SubMenuOption format
*/
function convertSubmenuToOptions(
items: readonly (IContextMenuValue | string | null)[],
options?: IContextMenuOptions
): SubMenuOption[] {
const result: SubMenuOption[] = []
for (const item of items) {
// Skip null separators
if (item === null) {
continue
}
// Handle string items (simple labels like in Mode/Shapes menus)
if (typeof item === 'string') {
const subOption: SubMenuOption = {
label: item,
action: () => {
try {
// Call the options callback with the string value
if (options?.callback) {
void options.callback.call(
null,
item,
options,
undefined,
undefined,
options.extra
)
}
} catch (error) {
console.error('Error executing string item callback:', error)
}
}
}
result.push(subOption)
continue
}
// Handle object items
if (!item.content) {
continue
}
// Extract text content from HTML if present
const content = stripHtmlTags(item.content)
const subOption: SubMenuOption = {
label: content,
action: () => {
try {
void item.callback?.call(
item as unknown as ContextMenuDivElement,
item.value,
{},
undefined,
undefined,
item
)
} catch (error) {
console.error('Error executing submenu callback:', error)
}
}
}
// Pass through disabled state
if (item.disabled) {
subOption.disabled = true
}
result.push(subOption)
}
return result
}
/**
* Strip HTML tags from content string safely
* LiteGraph menu items often include HTML for styling
*/
function stripHtmlTags(html: string): string {
// Use DOMPurify to sanitize and strip all HTML tags
const sanitized = DOMPurify.sanitize(html, { ALLOWED_TAGS: [] })
const result = sanitized.trim()
return result || html.replace(/<[^>]*>/g, '').trim() || html
}

View File

@@ -20,8 +20,7 @@ import type { NodeId } from '@/renderer/core/layout/types'
import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
import { isDOMWidget } from '@/scripts/domWidget'
import { useNodeDefStore } from '@/stores/nodeDefStore'
import type { WidgetValue, SafeControlWidget } from '@/types/simplifiedWidget'
import { normalizeControlOption } from '@/types/simplifiedWidget'
import type { WidgetValue } from '@/types/simplifiedWidget'
import type {
LGraph,
@@ -48,7 +47,6 @@ export interface SafeWidgetData {
spec?: InputSpec
slotMetadata?: WidgetSlotMetadata
isDOMWidget?: boolean
controlWidget?: SafeControlWidget
borderStyle?: string
}
@@ -86,17 +84,6 @@ export interface GraphNodeManager {
cleanup(): void
}
function getControlWidget(widget: IBaseWidget): SafeControlWidget | undefined {
const cagWidget = widget.linkedWidgets?.find(
(w) => w.name == 'control_after_generate'
)
if (!cagWidget) return
return {
value: normalizeControlOption(cagWidget.value),
update: (value) => (cagWidget.value = normalizeControlOption(value))
}
}
export function safeWidgetMapper(
node: LGraphNode,
slotMetadata: Map<string, WidgetSlotMetadata>
@@ -135,8 +122,7 @@ export function safeWidgetMapper(
label: widget.label,
options: widget.options,
spec,
slotMetadata: slotInfo,
controlWidget: getControlWidget(widget)
slotMetadata: slotInfo
}
} catch (error) {
return {
@@ -218,15 +204,6 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
reactiveWidgets.splice(0, reactiveWidgets.length, ...v)
}
})
const reactiveInputs = shallowReactive<INodeInputSlot[]>(node.inputs ?? [])
Object.defineProperty(node, 'inputs', {
get() {
return reactiveInputs
},
set(v) {
reactiveInputs.splice(0, reactiveInputs.length, ...v)
}
})
const safeWidgets = reactiveComputed<SafeWidgetData[]>(() => {
node.inputs?.forEach((input, index) => {
@@ -261,7 +238,7 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
badges,
hasErrors: !!node.has_errors,
widgets: safeWidgets,
inputs: reactiveInputs,
inputs: node.inputs ? [...node.inputs] : undefined,
outputs: node.outputs ? [...node.outputs] : undefined,
flags: node.flags ? { ...node.flags } : undefined,
color: node.color || undefined,

View File

@@ -15,13 +15,10 @@ export interface MenuOption {
icon?: string
shortcut?: string
hasSubmenu?: boolean
type?: 'divider' | 'category'
type?: 'divider'
action?: () => void
submenu?: SubMenuOption[]
badge?: BadgeVariant
disabled?: boolean
source?: 'litegraph' | 'vue'
isColorPicker?: boolean
}
export interface SubMenuOption {
@@ -29,7 +26,6 @@ export interface SubMenuOption {
icon?: string
action: () => void
color?: string
disabled?: boolean
}
export enum BadgeVariant {
@@ -177,12 +173,7 @@ export function useMoreOptionsMenu() {
}
// Section 5: Subgraph operations
options.push(
...getSubgraphOptions({
hasSubgraphs: hasSubgraphsSelected,
hasMultipleSelection: hasMultipleNodes.value
})
)
options.push(...getSubgraphOptions(hasSubgraphsSelected))
// Section 6: Multiple nodes operations
if (hasMultipleNodes.value) {

View File

@@ -1,106 +0,0 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useSelectionMenuOptions } from '@/composables/graph/useSelectionMenuOptions'
const subgraphMocks = vi.hoisted(() => ({
convertToSubgraph: vi.fn(),
unpackSubgraph: vi.fn(),
addSubgraphToLibrary: vi.fn(),
createI18nMock: vi.fn(() => ({
global: {
t: vi.fn(),
te: vi.fn(),
d: vi.fn()
}
}))
}))
vi.mock('vue-i18n', () => ({
useI18n: () => ({
t: (key: string) => key
}),
createI18n: subgraphMocks.createI18nMock
}))
vi.mock('@/composables/graph/useSelectionOperations', () => ({
useSelectionOperations: () => ({
copySelection: vi.fn(),
duplicateSelection: vi.fn(),
deleteSelection: vi.fn(),
renameSelection: vi.fn()
})
}))
vi.mock('@/composables/graph/useNodeArrangement', () => ({
useNodeArrangement: () => ({
alignOptions: [{ localizedName: 'align-left', icon: 'align-left' }],
distributeOptions: [{ localizedName: 'distribute', icon: 'distribute' }],
applyAlign: vi.fn(),
applyDistribute: vi.fn()
})
}))
vi.mock('@/composables/graph/useSubgraphOperations', () => ({
useSubgraphOperations: () => ({
convertToSubgraph: subgraphMocks.convertToSubgraph,
unpackSubgraph: subgraphMocks.unpackSubgraph,
addSubgraphToLibrary: subgraphMocks.addSubgraphToLibrary
})
}))
vi.mock('@/composables/graph/useFrameNodes', () => ({
useFrameNodes: () => ({
frameNodes: vi.fn()
})
}))
describe('useSelectionMenuOptions - subgraph options', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('returns only convert option when no subgraphs are selected', () => {
const { getSubgraphOptions } = useSelectionMenuOptions()
const options = getSubgraphOptions({
hasSubgraphs: false,
hasMultipleSelection: true
})
expect(options).toHaveLength(1)
expect(options[0]?.label).toBe('contextMenu.Convert to Subgraph')
expect(options[0]?.action).toBe(subgraphMocks.convertToSubgraph)
})
it('includes convert, add to library, and unpack when subgraphs are selected', () => {
const { getSubgraphOptions } = useSelectionMenuOptions()
const options = getSubgraphOptions({
hasSubgraphs: true,
hasMultipleSelection: true
})
const labels = options.map((option) => option.label)
expect(labels).toContain('contextMenu.Convert to Subgraph')
expect(labels).toContain('contextMenu.Add Subgraph to Library')
expect(labels).toContain('contextMenu.Unpack Subgraph')
const convertOption = options.find(
(option) => option.label === 'contextMenu.Convert to Subgraph'
)
expect(convertOption?.action).toBe(subgraphMocks.convertToSubgraph)
})
it('hides convert option when only a single subgraph is selected', () => {
const { getSubgraphOptions } = useSelectionMenuOptions()
const options = getSubgraphOptions({
hasSubgraphs: true,
hasMultipleSelection: false
})
const labels = options.map((option) => option.label)
expect(labels).not.toContain('contextMenu.Convert to Subgraph')
expect(labels).toEqual([
'contextMenu.Add Subgraph to Library',
'contextMenu.Unpack Subgraph'
])
})
})

View File

@@ -63,29 +63,9 @@ export function useSelectionMenuOptions() {
}
]
const getSubgraphOptions = ({
hasSubgraphs,
hasMultipleSelection
}: {
hasSubgraphs: boolean
hasMultipleSelection: boolean
}): MenuOption[] => {
const convertOption: MenuOption = {
label: t('contextMenu.Convert to Subgraph'),
icon: 'icon-[lucide--shrink]',
action: convertToSubgraph,
badge: BadgeVariant.NEW
}
const options: MenuOption[] = []
const showConvertOption = !hasSubgraphs || hasMultipleSelection
if (showConvertOption) {
options.push(convertOption)
}
const getSubgraphOptions = (hasSubgraphs: boolean): MenuOption[] => {
if (hasSubgraphs) {
options.push(
return [
{
label: t('contextMenu.Add Subgraph to Library'),
icon: 'icon-[lucide--folder-plus]',
@@ -96,10 +76,17 @@ export function useSelectionMenuOptions() {
icon: 'icon-[lucide--expand]',
action: unpackSubgraph
}
)
]
} else {
return [
{
label: t('contextMenu.Convert to Subgraph'),
icon: 'icon-[lucide--shrink]',
action: convertToSubgraph,
badge: BadgeVariant.NEW
}
]
}
return options
}
const getMultipleNodesOptions = (): MenuOption[] => {

View File

@@ -231,36 +231,6 @@ const ltxvPricingCalculator = (node: LGraphNode): string => {
return `$${cost}/Run`
}
const klingVideoWithAudioPricingCalculator: PricingFunction = (
node: LGraphNode
): string => {
const durationWidget = node.widgets?.find(
(w) => w.name === 'duration'
) as IComboWidget
const generateAudioWidget = node.widgets?.find(
(w) => w.name === 'generate_audio'
) as IComboWidget
if (!durationWidget || !generateAudioWidget) {
return '$0.35-1.40/Run (varies with duration & audio)'
}
const duration = String(durationWidget.value)
const generateAudio =
String(generateAudioWidget.value).toLowerCase() === 'true'
if (duration === '5') {
return generateAudio ? '$0.70/Run' : '$0.35/Run'
}
if (duration === '10') {
return generateAudio ? '$1.40/Run' : '$0.70/Run'
}
// Fallback for unexpected duration values
return '$0.35-1.40/Run (varies with duration & audio)'
}
// ---- constants ----
const SORA_SIZES = {
BASIC: new Set(['720x1280', '1280x720']),
@@ -774,12 +744,6 @@ const apiNodeCosts: Record<string, { displayPrice: string | PricingFunction }> =
KlingOmniProImageNode: {
displayPrice: '$0.028/Run'
},
KlingTextToVideoWithAudio: {
displayPrice: klingVideoWithAudioPricingCalculator
},
KlingImageToVideoWithAudio: {
displayPrice: klingVideoWithAudioPricingCalculator
},
LumaImageToVideoNode: {
displayPrice: (node: LGraphNode): string => {
// Same pricing as LumaVideoNode per CSV
@@ -1967,8 +1931,6 @@ export const useNodePricing = () => {
KlingDualCharacterVideoEffectNode: ['mode', 'model_name', 'duration'],
KlingSingleImageVideoEffectNode: ['effect_scene'],
KlingStartEndFrameNode: ['mode', 'model_name', 'duration'],
KlingTextToVideoWithAudio: ['duration', 'generate_audio'],
KlingImageToVideoWithAudio: ['duration', 'generate_audio'],
KlingOmniProTextToVideoNode: ['duration'],
KlingOmniProFirstLastFrameNode: ['duration'],
KlingOmniProImageToVideoNode: ['duration'],

View File

@@ -22,10 +22,7 @@ export const useContextMenuTranslation = () => {
this: LGraphCanvas,
...args: Parameters<typeof getCanvasMenuOptions>
) {
const res: (IContextMenuValue | null)[] = getCanvasMenuOptions.apply(
this,
args
)
const res: IContextMenuValue[] = getCanvasMenuOptions.apply(this, args)
// Add items from new extension API
const newApiItems = app.collectCanvasMenuItems(this)
@@ -61,16 +58,13 @@ export const useContextMenuTranslation = () => {
LGraphCanvas.prototype
)
// Install compatibility layer for getNodeMenuOptions
legacyMenuCompat.install(LGraphCanvas.prototype, 'getNodeMenuOptions')
// Wrap getNodeMenuOptions to add new API items
const nodeMenuFn = LGraphCanvas.prototype.getNodeMenuOptions
const getNodeMenuOptionsWithExtensions = function (
this: LGraphCanvas,
...args: Parameters<typeof nodeMenuFn>
) {
const res = nodeMenuFn.apply(this, args) as (IContextMenuValue | null)[]
const res = nodeMenuFn.apply(this, args)
// Add items from new extension API
const node = args[0]
@@ -79,28 +73,11 @@ export const useContextMenuTranslation = () => {
res.push(item)
}
// Add legacy monkey-patched items
const legacyItems = legacyMenuCompat.extractLegacyItems(
'getNodeMenuOptions',
this,
...args
)
for (const item of legacyItems) {
res.push(item)
}
return res
}
LGraphCanvas.prototype.getNodeMenuOptions = getNodeMenuOptionsWithExtensions
legacyMenuCompat.registerWrapper(
'getNodeMenuOptions',
getNodeMenuOptionsWithExtensions,
nodeMenuFn,
LGraphCanvas.prototype
)
function translateMenus(
values: readonly (IContextMenuValue | string | null)[] | undefined,
options: IContextMenuOptions

View File

@@ -1,20 +0,0 @@
import { computed } from 'vue'
import { isCloud } from '@/platform/distribution/types'
import { remoteConfig } from '@/platform/remoteConfig/remoteConfig'
const BUILD_TIME_IS_STAGING = !__USE_PROD_CONFIG__
/**
* Returns whether the current environment is staging.
* - Cloud builds use runtime configuration (firebase_config.projectId containing '-dev')
* - OSS / localhost builds fall back to the build-time config determined by __USE_PROD_CONFIG__
*/
export const isStaging = computed(() => {
if (!isCloud) {
return BUILD_TIME_IS_STAGING
}
const projectId = remoteConfig.value.firebase_config?.projectId
return projectId?.includes('-dev') ?? BUILD_TIME_IS_STAGING
})

View File

@@ -389,13 +389,6 @@ export const SERVER_CONFIG_ITEMS: ServerConfig<any>[] = [
type: 'boolean',
defaultValue: false
},
{
id: 'enable-manager-legacy-ui',
name: 'Use legacy Manager UI',
tooltip: 'Uses the legacy ComfyUI-Manager UI instead of the new UI.',
type: 'boolean',
defaultValue: false
},
{
id: 'disable-all-custom-nodes',
name: 'Disable loading all custom nodes.',

View File

@@ -0,0 +1,130 @@
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it } from 'vitest'
import { useGraphStateStore } from './graphStateStore'
describe('graphStateStore', () => {
beforeEach(() => {
setActivePinia(createPinia())
})
describe('execute SetNodeError command', () => {
it('sets hasError on new node', () => {
const store = useGraphStateStore()
store.execute({
type: 'SetNodeError',
version: 1,
nodeId: '123',
hasError: true
})
expect(store.getNodeState('123')?.hasError).toBe(true)
})
it('updates hasError on existing node', () => {
const store = useGraphStateStore()
store.execute({
type: 'SetNodeError',
version: 1,
nodeId: '123',
hasError: true
})
store.execute({
type: 'SetNodeError',
version: 1,
nodeId: '123',
hasError: false
})
expect(store.getNodeState('123')?.hasError).toBe(false)
})
it('handles subgraph node locator IDs', () => {
const store = useGraphStateStore()
store.execute({
type: 'SetNodeError',
version: 1,
nodeId: 'uuid-123:456',
hasError: true
})
expect(store.getNodeState('uuid-123:456')?.hasError).toBe(true)
})
})
describe('execute ClearAllErrors command', () => {
it('clears all error flags', () => {
const store = useGraphStateStore()
store.execute({
type: 'SetNodeError',
version: 1,
nodeId: '1',
hasError: true
})
store.execute({
type: 'SetNodeError',
version: 1,
nodeId: '2',
hasError: true
})
store.execute({ type: 'ClearAllErrors', version: 1 })
expect(store.getNodeState('1')?.hasError).toBe(false)
expect(store.getNodeState('2')?.hasError).toBe(false)
})
})
describe('getNodesWithErrors', () => {
it('returns only nodes with errors', () => {
const store = useGraphStateStore()
store.execute({
type: 'SetNodeError',
version: 1,
nodeId: '1',
hasError: true
})
store.execute({
type: 'SetNodeError',
version: 1,
nodeId: '2',
hasError: false
})
store.execute({
type: 'SetNodeError',
version: 1,
nodeId: '3',
hasError: true
})
const nodesWithErrors = store.getNodesWithErrors()
expect(nodesWithErrors).toHaveLength(2)
expect(nodesWithErrors).toContain('1')
expect(nodesWithErrors).toContain('3')
expect(nodesWithErrors).not.toContain('2')
})
})
describe('stateRef reactivity', () => {
it('increments revision on command execution', () => {
const store = useGraphStateStore()
const initialRevision = store.stateRef
store.execute({
type: 'SetNodeError',
version: 1,
nodeId: '1',
hasError: true
})
expect(store.stateRef).not.toBe(initialRevision)
})
})
})

View File

@@ -0,0 +1,79 @@
import { defineStore } from 'pinia'
import { customRef } from 'vue'
import type { NodeLocatorId } from '@/types/nodeIdentification'
interface NodeState {
hasError: boolean
}
interface SetNodeErrorCommand {
type: 'SetNodeError'
version: 1
nodeId: NodeLocatorId
hasError: boolean
}
interface ClearAllErrorsCommand {
type: 'ClearAllErrors'
version: 1
}
type GraphStateCommand = SetNodeErrorCommand | ClearAllErrorsCommand
export const useGraphStateStore = defineStore('graphState', () => {
const nodes = new Map<NodeLocatorId, NodeState>()
let revision = 0
const stateRef = customRef<number>((track, trigger) => ({
get() {
track()
return revision
},
set() {
revision++
trigger()
}
}))
const execute = (command: GraphStateCommand): void => {
switch (command.type) {
case 'SetNodeError': {
const existing = nodes.get(command.nodeId)
if (existing) {
existing.hasError = command.hasError
} else {
nodes.set(command.nodeId, { hasError: command.hasError })
}
break
}
case 'ClearAllErrors': {
for (const state of nodes.values()) {
state.hasError = false
}
break
}
}
stateRef.value = revision + 1
}
const getNodeState = (nodeId: NodeLocatorId): NodeState | undefined => {
return nodes.get(nodeId)
}
const getNodesWithErrors = (): NodeLocatorId[] => {
const result: NodeLocatorId[] = []
for (const [nodeId, state] of nodes) {
if (state.hasError) result.push(nodeId)
}
return result
}
return {
stateRef,
nodes,
execute,
getNodeState,
getNodesWithErrors
}
})

View File

@@ -0,0 +1,45 @@
import { watch } from 'vue'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { app } from '@/scripts/app'
import {
forEachNode,
forEachSubgraphNode,
getNodeByLocatorId
} from '@/utils/graphTraversalUtil'
import { useGraphStateStore } from './graphStateStore'
const propagateErrorToParents = (node: LGraphNode): void => {
const subgraph = node.graph
if (!subgraph || subgraph.isRootGraph) return
forEachSubgraphNode(app.rootGraph, subgraph.id, (subgraphNode) => {
subgraphNode.has_errors = true
propagateErrorToParents(subgraphNode)
})
}
export const useGraphErrorState = () => {
const store = useGraphStateStore()
watch(
() => store.stateRef,
() => {
if (!app.rootGraph) return
forEachNode(app.rootGraph, (node) => {
node.has_errors = false
})
for (const locatorId of store.getNodesWithErrors()) {
const node = getNodeByLocatorId(app.rootGraph, locatorId)
if (!node) continue
node.has_errors = true
propagateErrorToParents(node)
}
},
{ immediate: true }
)
}

View File

@@ -1,6 +1,5 @@
import { remove } from 'es-toolkit'
import { useChainCallback } from '@/composables/functional/useChainCallback'
import { NodeSlotType } from '@/lib/litegraph/src/types/globalEnums'
import type {
ISlotType,
INodeInputSlot,
@@ -24,41 +23,22 @@ import type { ComfyApp } from '@/scripts/app'
const INLINE_INPUTS = false
type MatchTypeNode = LGraphNode &
Pick<Required<LGraphNode>, 'onConnectionsChange'> & {
comfyDynamic: { matchType: Record<string, Record<string, string>> }
}
type AutogrowNode = LGraphNode &
Pick<Required<LGraphNode>, 'onConnectionsChange' | 'widgets'> & {
comfyDynamic: {
autogrow: Record<
string,
{
min: number
max: number
inputSpecs: InputSpecV2[]
prefix?: string
names?: string[]
}
>
}
}
Pick<Required<LGraphNode>, 'comfyMatchType' | 'onConnectionsChange'>
function ensureWidgetForInput(node: LGraphNode, input: INodeInputSlot) {
if (input.widget?.name) return
node.widgets ??= []
const { widget } = input
if (widget && node.widgets.some((w) => w.name === widget.name)) return
node.widgets.push({
name: input.name,
y: 0,
type: 'shim',
options: {},
draw(ctx, _n, _w, y) {
ctx.save()
ctx.fillStyle = LiteGraph.NODE_TEXT_COLOR
ctx.fillText(input.label ?? input.name, 20, y + 15)
ctx.restore()
},
name: input.name,
options: {},
serialize: false,
type: 'shim',
y: 0
}
})
input.alwaysVisible = true
input.widget = { name: input.name }
@@ -86,47 +66,72 @@ function dynamicComboWidget(
appArg,
widgetName
)
function isInGroup(e: { name: string }): boolean {
return e.name.startsWith(inputName + '.')
}
let currentDynamicNames: string[] = []
const updateWidgets = (value?: string) => {
if (!node.widgets) throw new Error('Not Reachable')
const newSpec = value ? options[value] : undefined
const removedInputs = remove(node.inputs, isInGroup)
remove(node.widgets, isInGroup)
if (!newSpec) return
const inputsToRemove: Record<string, INodeInputSlot> = {}
for (const name of currentDynamicNames) {
const input = node.inputs.find((input) => input.name === name)
if (input) inputsToRemove[input.name] = input
const widgetIndex = node.widgets.findIndex(
(widget) => widget.name === name
)
if (widgetIndex === -1) continue
node.widgets[widgetIndex].value = undefined
node.widgets.splice(widgetIndex, 1)
}
currentDynamicNames = []
if (!newSpec) {
for (const input of Object.values(inputsToRemove)) {
const inputIndex = node.inputs.findIndex((inp) => inp === input)
if (inputIndex === -1) continue
node.removeInput(inputIndex)
}
return
}
const insertionPoint = node.widgets.findIndex((w) => w === widget) + 1
const startingLength = node.widgets.length
const startingInputLength = node.inputs.length
const initialInputIndex =
node.inputs.findIndex((i) => i.name === widget.name) + 1
let startingInputLength = node.inputs.length
if (insertionPoint === 0)
throw new Error("Dynamic widget doesn't exist on node")
const inputTypes: (Record<string, InputSpec> | undefined)[] = [
newSpec.required,
newSpec.optional
const inputTypes: [Record<string, InputSpec> | undefined, boolean][] = [
[newSpec.required, false],
[newSpec.optional, true]
]
inputTypes.forEach((inputType, idx) => {
for (const [inputType, isOptional] of inputTypes)
for (const key in inputType ?? {}) {
const name = `${widget.name}.${key}`
const specToAdd = transformInputSpecV1ToV2(inputType![key], {
name,
isOptional: idx !== 0
isOptional
})
specToAdd.display_name = key
addNodeInput(node, specToAdd)
const newInputs = node.inputs
.slice(startingInputLength)
.filter((inp) => inp.name.startsWith(name))
for (const newInput of newInputs) {
if (INLINE_INPUTS && !newInput.widget)
ensureWidgetForInput(node, newInput)
}
currentDynamicNames.push(name)
if (INLINE_INPUTS) ensureWidgetForInput(node, node.inputs.at(-1)!)
if (
!inputsToRemove[name] ||
Array.isArray(inputType![key][0]) ||
!LiteGraph.isValidConnection(
inputsToRemove[name].type,
inputType![key][0]
)
)
continue
node.inputs.at(-1)!.link = inputsToRemove[name].link
inputsToRemove[name].link = null
}
})
for (const input of Object.values(inputsToRemove)) {
const inputIndex = node.inputs.findIndex((inp) => inp === input)
if (inputIndex === -1) continue
if (inputIndex < initialInputIndex) startingInputLength--
node.removeInput(inputIndex)
}
const inputInsertionPoint =
node.inputs.findIndex((i) => i.name === widget.name) + 1
const addedWidgets = node.widgets.splice(startingLength)
@@ -152,28 +157,6 @@ function dynamicComboWidget(
)
//assume existing inputs are in correct order
spliceInputs(node, inputInsertionPoint, 0, ...addedInputs)
for (const input of removedInputs) {
const inputIndex = node.inputs.findIndex((inp) => inp.name === input.name)
if (inputIndex === -1) {
node.inputs.push(input)
node.removeInput(node.inputs.length - 1)
} else {
node.inputs[inputIndex].link = input.link
if (!input.link) continue
const link = node.graph?.links?.[input.link]
if (!link) continue
link.target_slot = inputIndex
node.onConnectionsChange?.(
LiteGraph.INPUT,
inputIndex,
true,
link,
node.inputs[inputIndex]
)
}
}
node.size[1] = node.computeSize([...node.size])[1]
if (!node.graph) return
node._setConcreteSlots()
@@ -260,9 +243,8 @@ function changeOutputType(
}
function withComfyMatchType(node: LGraphNode): asserts node is MatchTypeNode {
if (node.comfyDynamic?.matchType) return
node.comfyDynamic ??= {}
node.comfyDynamic.matchType = {}
if (node.comfyMatchType) return
node.comfyMatchType = {}
const outputGroups = node.constructor.nodeData?.output_matchtypes
node.onConnectionsChange = useChainCallback(
@@ -276,9 +258,9 @@ function withComfyMatchType(node: LGraphNode): asserts node is MatchTypeNode {
) {
const input = this.inputs[slot]
if (contype !== LiteGraph.INPUT || !this.graph || !input) return
const [matchKey, matchGroup] = Object.entries(
this.comfyDynamic.matchType
).find(([, group]) => input.name in group) ?? ['', undefined]
const [matchKey, matchGroup] = Object.entries(this.comfyMatchType).find(
([, group]) => input.name in group
) ?? ['', undefined]
if (!matchGroup) return
if (iscon && linf) {
const { output, subgraphInput } = linf.resolve(this.graph)
@@ -335,8 +317,8 @@ function applyMatchType(node: LGraphNode, inputSpec: InputSpecV2) {
const typedSpec = { ...inputSpec, type: allowed_types }
addNodeInput(node, typedSpec)
withComfyMatchType(node)
node.comfyDynamic.matchType[template_id] ??= {}
node.comfyDynamic.matchType[template_id][name] = allowed_types
node.comfyMatchType[template_id] ??= {}
node.comfyMatchType[template_id][name] = allowed_types
//TODO: instead apply on output add?
//ensure outputs get updated
@@ -347,215 +329,160 @@ function applyMatchType(node: LGraphNode, inputSpec: InputSpecV2) {
)
}
function autogrowOrdinalToName(
ordinal: number,
key: string,
groupName: string,
node: AutogrowNode
) {
const {
names,
prefix = '',
inputSpecs
} = node.comfyDynamic.autogrow[groupName]
const baseName = names
? names[ordinal]
: (inputSpecs.length == 1 ? prefix : key) + ordinal
return { name: `${groupName}.${baseName}`, display_name: baseName }
}
function addAutogrowGroup(
ordinal: number,
groupName: string,
node: AutogrowNode
) {
function applyAutogrow(node: LGraphNode, untypedInputSpec: InputSpecV2) {
const { addNodeInput } = useLitegraphService()
const { max, min, inputSpecs } = node.comfyDynamic.autogrow[groupName]
if (ordinal >= max) return
const namedSpecs = inputSpecs.map((input) => ({
...input,
isOptional: ordinal >= (min ?? 0) || input.isOptional,
...autogrowOrdinalToName(ordinal, input.name, groupName, node)
}))
const parseResult = zAutogrowOptions.safeParse(untypedInputSpec)
if (!parseResult.success) throw new Error('invalid Autogrow spec')
const inputSpec = parseResult.data
const newInputs = namedSpecs
.filter(
(namedSpec) => !node.inputs.some((inp) => inp.name === namedSpec.name)
const { input, min, names, prefix, max } = inputSpec.template
const inputTypes: [Record<string, InputSpec> | undefined, boolean][] = [
[input.required, false],
[input.optional, true]
]
const inputsV2 = inputTypes.flatMap(([inputType, isOptional]) =>
Object.entries(inputType ?? {}).map(([name, v]) =>
transformInputSpecV1ToV2(v, { name, isOptional })
)
.map((namedSpec) => {
addNodeInput(node, namedSpec)
const input = spliceInputs(node, node.inputs.length - 1, 1)[0]
if (inputSpecs.length !== 1 || (INLINE_INPUTS && !input.widget))
ensureWidgetForInput(node, input)
return input
})
const lastIndex = node.inputs.findLastIndex((inp) =>
inp.name.startsWith(groupName)
)
const insertionIndex = lastIndex === -1 ? node.inputs.length : lastIndex + 1
spliceInputs(node, insertionIndex, 0, ...newInputs)
app.canvas?.setDirty(true, true)
}
const ORDINAL_REGEX = /\d+$/
function resolveAutogrowOrdinal(
inputName: string,
groupName: string,
node: AutogrowNode
): number | undefined {
//TODO preslice groupname?
const name = inputName.slice(groupName.length + 1)
const { names } = node.comfyDynamic.autogrow[groupName]
if (names) {
const ordinal = names.findIndex((s) => s === name)
return ordinal === -1 ? undefined : ordinal
function nameToInputIndex(name: string) {
const index = node.inputs.findIndex((input) => input.name === name)
if (index === -1) throw new Error('Failed to find input')
return index
}
const match = name.match(ORDINAL_REGEX)
if (!match) return undefined
const ordinal = parseInt(match[0])
return ordinal !== ordinal ? undefined : ordinal
}
function autogrowInputConnected(index: number, node: AutogrowNode) {
const input = node.inputs[index]
const groupName = input.name.slice(0, input.name.lastIndexOf('.'))
const lastInput = node.inputs.findLast((inp) =>
inp.name.startsWith(groupName)
)
const ordinal = resolveAutogrowOrdinal(input.name, groupName, node)
if (
!lastInput ||
ordinal == undefined ||
ordinal !== resolveAutogrowOrdinal(lastInput.name, groupName, node)
)
return
addAutogrowGroup(ordinal + 1, groupName, node)
}
function autogrowInputDisconnected(index: number, node: AutogrowNode) {
const input = node.inputs[index]
if (!input) return
const groupName = input.name.slice(0, input.name.lastIndexOf('.'))
const { min = 1, inputSpecs } = node.comfyDynamic.autogrow[groupName]
const ordinal = resolveAutogrowOrdinal(input.name, groupName, node)
if (ordinal == undefined || ordinal + 1 < min) return
//resolve all inputs in group
const groupInputs = node.inputs.filter(
(inp) =>
inp.name.startsWith(groupName + '.') &&
inp.name.lastIndexOf('.') === groupName.length
)
const stride = inputSpecs.length
if (groupInputs.length % stride !== 0) {
console.error('Failed to group multi-input autogrow inputs')
return
function nameToInput(name: string) {
return node.inputs[nameToInputIndex(name)]
}
app.canvas?.setDirty(true, true)
//groupBy would be nice here, but may not be supported
for (let column = 0; column < stride; column++) {
for (
let bubbleOrdinal = ordinal * stride + column;
bubbleOrdinal + stride < groupInputs.length;
bubbleOrdinal += stride
) {
const curInput = groupInputs[bubbleOrdinal]
curInput.link = groupInputs[bubbleOrdinal + stride].link
if (!curInput.link) continue
const link = node.graph?.links[curInput.link]
if (!link) continue
const curIndex = node.inputs.findIndex((inp) => inp === curInput)
if (curIndex === -1) throw new Error('missing input')
link.target_slot = curIndex
//In the distance, someone shouting YAGNI
const trackedInputs: string[][] = []
function addInputGroup(insertionIndex: number) {
const ordinal = trackedInputs.length
const inputGroup = inputsV2.map((input) => ({
...input,
name: names
? names[ordinal]
: ((inputsV2.length == 1 ? prefix : input.name) ?? '') + ordinal,
isOptional: ordinal >= (min ?? 0) || input.isOptional
}))
const newInputs = inputGroup
.filter(
(namedSpec) => !node.inputs.some((inp) => inp.name === namedSpec.name)
)
.map((namedSpec) => {
addNodeInput(node, namedSpec)
const input = spliceInputs(node, node.inputs.length - 1, 1)[0]
if (inputsV2.length !== 1) ensureWidgetForInput(node, input)
return input
})
spliceInputs(node, insertionIndex, 0, ...newInputs)
trackedInputs.push(inputGroup.map((inp) => inp.name))
app.canvas?.setDirty(true, true)
}
for (let i = 0; i < (min || 1); i++) addInputGroup(node.inputs.length)
function removeInputGroup(inputName: string) {
const groupIndex = trackedInputs.findIndex((ig) =>
ig.some((inpName) => inpName === inputName)
)
if (groupIndex == -1) throw new Error('Failed to find group')
const group = trackedInputs[groupIndex]
for (const nameToRemove of group) {
const inputIndex = nameToInputIndex(nameToRemove)
const input = spliceInputs(node, inputIndex, 1)[0]
if (!input.widget?.name) continue
const widget = node.widgets?.find((w) => w.name === input.widget!.name)
if (!widget) return
widget.value = undefined
node.removeWidget(widget)
}
const lastInput = groupInputs.at(column - stride)
if (!lastInput) continue
lastInput.link = null
trackedInputs.splice(groupIndex, 1)
node.size[1] = node.computeSize([...node.size])[1]
app.canvas?.setDirty(true, true)
}
const removalChecks = groupInputs.slice((min - 1) * stride)
let i
for (i = removalChecks.length - stride; i >= 0; i -= stride) {
if (removalChecks.slice(i, i + stride).some((inp) => inp.link)) break
}
const toRemove = removalChecks.slice(i + stride * 2)
remove(node.inputs, (inp) => toRemove.includes(inp))
for (const input of toRemove) {
const widgetName = input?.widget?.name
if (!widgetName) continue
remove(node.widgets, (w) => w.name === widgetName)
}
node.size[1] = node.computeSize([...node.size])[1]
}
function withComfyAutogrow(node: LGraphNode): asserts node is AutogrowNode {
if (node.comfyDynamic?.autogrow) return
node.comfyDynamic ??= {}
node.comfyDynamic.autogrow = {}
function inputConnected(index: number) {
const input = node.inputs[index]
const groupIndex = trackedInputs.findIndex((ig) =>
ig.some((inputName) => inputName === input.name)
)
if (groupIndex == -1) throw new Error('Failed to find group')
if (
groupIndex + 1 === trackedInputs.length &&
trackedInputs.length < (max ?? names?.length ?? 100)
) {
const lastInput = trackedInputs[groupIndex].at(-1)
if (!lastInput) return
const insertionIndex = nameToInputIndex(lastInput) + 1
if (insertionIndex === 0) throw new Error('Failed to find Input')
addInputGroup(insertionIndex)
}
}
function inputDisconnected(index: number) {
const input = node.inputs[index]
if (trackedInputs.length === 1) return
const groupIndex = trackedInputs.findIndex((ig) =>
ig.some((inputName) => inputName === input.name)
)
if (groupIndex == -1) throw new Error('Failed to find group')
if (
trackedInputs[groupIndex].some(
(inputName) => nameToInput(inputName).link != null
)
)
return
if (groupIndex + 1 < (min ?? 0)) return
//For each group from here to last group, bubble swap links
for (let column = 0; column < trackedInputs[0].length; column++) {
let prevInput = nameToInputIndex(trackedInputs[groupIndex][column])
for (let i = groupIndex + 1; i < trackedInputs.length; i++) {
const curInput = nameToInputIndex(trackedInputs[i][column])
const linkId = node.inputs[curInput].link
node.inputs[prevInput].link = linkId
const link = linkId && node.graph?.links?.[linkId]
if (link) link.target_slot = prevInput
prevInput = curInput
}
node.inputs[prevInput].link = null
}
if (
trackedInputs.at(-2) &&
!trackedInputs.at(-2)?.some((name) => !!nameToInput(name).link)
)
removeInputGroup(trackedInputs.at(-1)![0])
}
let pendingConnection: number | undefined
let swappingConnection = false
const originalOnConnectInput = node.onConnectInput
node.onConnectInput = function (slot: number, ...args) {
pendingConnection = slot
requestAnimationFrame(() => (pendingConnection = undefined))
return originalOnConnectInput?.apply(this, [slot, ...args]) ?? true
}
node.onConnectionsChange = useChainCallback(
node.onConnectionsChange,
function (
this: AutogrowNode,
contype: ISlotType,
slot: number,
(
type: ISlotType,
index: number,
iscon: boolean,
linf: LLink | null | undefined
) {
const input = this.inputs[slot]
if (contype !== LiteGraph.INPUT || !input) return
//Return if input isn't known autogrow
const key = input.name.slice(0, input.name.lastIndexOf('.'))
const autogrowGroup = this.comfyDynamic.autogrow[key]
if (!autogrowGroup) return
if (app.configuringGraph && input.widget)
ensureWidgetForInput(node, input)
if (iscon && linf) {
) => {
if (type !== NodeSlotType.INPUT) return
const inputName = node.inputs[index].name
if (!trackedInputs.flat().some((name) => name === inputName)) return
if (iscon) {
if (swappingConnection || !linf) return
autogrowInputConnected(slot, this)
inputConnected(index)
} else {
if (pendingConnection === slot) {
if (pendingConnection === index) {
swappingConnection = true
requestAnimationFrame(() => (swappingConnection = false))
return
}
requestAnimationFrame(() => autogrowInputDisconnected(slot, this))
requestAnimationFrame(() => inputDisconnected(index))
}
}
)
}
function applyAutogrow(node: LGraphNode, inputSpecV2: InputSpecV2) {
withComfyAutogrow(node)
const parseResult = zAutogrowOptions.safeParse(inputSpecV2)
if (!parseResult.success) throw new Error('invalid Autogrow spec')
const inputSpec = parseResult.data
const { input, min = 1, names, prefix, max = 100 } = inputSpec.template
const inputTypes: (Record<string, InputSpec> | undefined)[] = [
input.required,
input.optional
]
const inputsV2 = inputTypes.flatMap((inputType, index) =>
Object.entries(inputType ?? {}).map(([name, v]) =>
transformInputSpecV1ToV2(v, { name, isOptional: index === 1 })
)
)
node.comfyDynamic.autogrow[inputSpecV2.name] = {
names,
min,
max: names?.length ?? max,
prefix,
inputSpecs: inputsV2
}
for (let i = 0; i < min; i++) addAutogrowGroup(i, inputSpecV2.name, node)
}

View File

@@ -707,14 +707,11 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
/** The start position of the drag zoom and original read-only state. */
#dragZoomStart: { pos: Point; scale: number; readOnly: boolean } | null = null
/** If true, enable live selection during drag. Nodes are selected/deselected in real-time. */
liveSelection: boolean = false
getMenuOptions?(): IContextMenuValue<string>[]
getExtraMenuOptions?(
canvas: LGraphCanvas,
options: (IContextMenuValue<string> | null)[]
): (IContextMenuValue<string> | null)[]
options: IContextMenuValue<string>[]
): IContextMenuValue<string>[]
static active_node: LGraphNode
/** called before modifying the graph */
onBeforeChange?(graph: LGraph): void
@@ -2630,20 +2627,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
this.processSelect(clickedItem, eUp)
}
pointer.onDragStart = () => (this.dragging_rectangle = dragRect)
if (this.liveSelection) {
const initialSelection = new Set(this.selectedItems)
pointer.onDrag = (eMove) =>
this.handleLiveSelect(eMove, dragRect, initialSelection)
pointer.onDragEnd = () => this.finalizeLiveSelect()
} else {
// Classic mode: select only when drag ends
pointer.onDragEnd = (upEvent) =>
this.#handleMultiSelect(upEvent, dragRect)
}
pointer.onDragEnd = (upEvent) => this.#handleMultiSelect(upEvent, dragRect)
pointer.finally = () => (this.dragging_rectangle = null)
}
@@ -4103,156 +4087,76 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
this.setDirty(true)
}
/**
* Normalizes a drag rectangle to have positive width and height.
* @param dragRect The drag rectangle to normalize (modified in place)
* @returns The normalized rectangle
*/
#normalizeDragRect(dragRect: Rect): Rect {
#handleMultiSelect(e: CanvasPointerEvent, dragRect: Rect) {
// Process drag
// Convert Point pair (pos, offset) to Rect
const { graph, selectedItems, subgraph } = this
if (!graph) throw new NullGraphError()
const w = Math.abs(dragRect[2])
const h = Math.abs(dragRect[3])
if (dragRect[2] < 0) dragRect[0] -= w
if (dragRect[3] < 0) dragRect[1] -= h
dragRect[2] = w
dragRect[3] = h
return dragRect
}
/**
* Gets all positionable items that overlap with the given rectangle.
* @param rect The rectangle to check against
* @returns Set of positionable items that overlap with the rectangle
*/
#getItemsInRect(rect: Rect): Set<Positionable> {
const { graph, subgraph } = this
if (!graph) throw new NullGraphError()
const items = new Set<Positionable>()
// Select nodes - any part of the node is in the select area
const isSelected = new Set<Positionable>()
const notSelected: Positionable[] = []
if (subgraph) {
const { inputNode, outputNode } = subgraph
if (overlapBounding(rect, inputNode.boundingRect)) items.add(inputNode)
if (overlapBounding(rect, outputNode.boundingRect)) items.add(outputNode)
if (overlapBounding(dragRect, inputNode.boundingRect)) {
addPositionable(inputNode)
}
if (overlapBounding(dragRect, outputNode.boundingRect)) {
addPositionable(outputNode)
}
}
for (const node of graph._nodes) {
if (overlapBounding(rect, node.boundingRect)) items.add(node)
for (const nodeX of graph._nodes) {
if (overlapBounding(dragRect, nodeX.boundingRect)) {
addPositionable(nodeX)
}
}
// Check groups (must be wholly inside)
// Select groups - the group is wholly inside the select area
for (const group of graph.groups) {
if (containsRect(rect, group._bounding)) {
group.recomputeInsideNodes()
items.add(group)
}
if (!containsRect(dragRect, group._bounding)) continue
group.recomputeInsideNodes()
addPositionable(group)
}
// Check reroutes (center point must be inside)
// Select reroutes - the centre point is inside the select area
for (const reroute of graph.reroutes.values()) {
if (isPointInRect(reroute.pos, rect)) items.add(reroute)
if (!isPointInRect(reroute.pos, dragRect)) continue
selectedItems.add(reroute)
reroute.selected = true
addPositionable(reroute)
}
return items
}
/**
* Handles live selection updates during drag. Called on each pointer move.
* @param e The pointer move event
* @param dragRect The current drag rectangle
* @param initialSelection The selection state before the drag started
*/
private handleLiveSelect(
e: CanvasPointerEvent,
dragRect: Rect,
initialSelection: Set<Positionable>
): void {
// Ensure rect is current even if pointer.onDrag fires before processMouseMove updates it
dragRect[2] = e.canvasX - dragRect[0]
dragRect[3] = e.canvasY - dragRect[1]
// Create a normalized copy for overlap checking
const normalizedRect: Rect = [
dragRect[0],
dragRect[1],
dragRect[2],
dragRect[3]
]
this.#normalizeDragRect(normalizedRect)
const itemsInRect = this.#getItemsInRect(normalizedRect)
const desired = new Set<Positionable>()
if (e.shiftKey && !e.altKey) {
for (const item of initialSelection) desired.add(item)
for (const item of itemsInRect) desired.add(item)
} else if (e.altKey && !e.shiftKey) {
for (const item of initialSelection)
if (!itemsInRect.has(item)) desired.add(item)
} else {
for (const item of itemsInRect) desired.add(item)
}
let changed = false
for (const item of [...this.selectedItems]) {
if (!desired.has(item)) {
this.deselect(item)
changed = true
}
}
for (const item of desired) {
if (!this.selectedItems.has(item)) {
this.select(item)
changed = true
}
}
if (changed) {
this.onSelectionChange?.(this.selected_nodes)
this.setDirty(true)
}
}
/**
* Finalizes the live selection when drag ends.
*/
private finalizeLiveSelect(): void {
// Selection is already updated by handleLiveSelect
// Just trigger the final selection change callback
this.onSelectionChange?.(this.selected_nodes)
}
/**
* Handles multi-select when drag ends (classic mode).
* @param e The pointer up event
* @param dragRect The drag rectangle
*/
#handleMultiSelect(e: CanvasPointerEvent, dragRect: Rect): void {
const normalizedRect: Rect = [
dragRect[0],
dragRect[1],
dragRect[2],
dragRect[3]
]
this.#normalizeDragRect(normalizedRect)
const itemsInRect = this.#getItemsInRect(normalizedRect)
const { selectedItems } = this
if (e.shiftKey) {
// Add to selection
for (const item of itemsInRect) this.select(item)
for (const item of notSelected) this.select(item)
} else if (e.altKey) {
// Remove from selection
for (const item of itemsInRect) this.deselect(item)
for (const item of isSelected) this.deselect(item)
} else {
// Replace selection
for (const item of selectedItems.values()) {
if (!itemsInRect.has(item)) this.deselect(item)
if (!isSelected.has(item)) this.deselect(item)
}
for (const item of itemsInRect) this.select(item)
for (const item of notSelected) this.select(item)
}
this.onSelectionChange?.(this.selected_nodes)
function addPositionable(item: Positionable): void {
if (!item.selected || !selectedItems.has(item)) notSelected.push(item)
else isSelected.add(item)
}
}
/**
@@ -4892,6 +4796,30 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
this.#renderSnapHighlight(ctx, highlightPos)
}
// Area-selection rectangle
// In Vue nodes mode, selection rectangle is rendered in DOM layer
if (this.dragging_rectangle && !LiteGraph.vueNodesMode) {
const { eDown, eMove } = this.pointer
ctx.strokeStyle = '#FFF'
if (eDown && eMove) {
// Do not scale the selection box
const transform = ctx.getTransform()
const ratio = Math.max(1, window.devicePixelRatio)
ctx.setTransform(ratio, 0, 0, ratio, 0, 0)
const x = eDown.safeOffsetX
const y = eDown.safeOffsetY
ctx.strokeRect(x, y, eMove.safeOffsetX - x, eMove.safeOffsetY - y)
ctx.setTransform(transform)
} else {
// Fallback to legacy behaviour
const [x, y, w, h] = this.dragging_rectangle
ctx.strokeRect(x, y, w, h)
}
}
// on top of link center
if (
!this.isDragging &&
@@ -8091,8 +8019,8 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
}
}
getCanvasMenuOptions(): (IContextMenuValue | null)[] {
let options: (IContextMenuValue<string> | null)[]
getCanvasMenuOptions(): IContextMenuValue[] {
let options: IContextMenuValue<string>[]
if (this.getMenuOptions) {
options = this.getMenuOptions()
} else {
@@ -8636,11 +8564,9 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
node,
newPos: this.calculateNewPosition(node, deltaX, deltaY)
})
} else if (!(child instanceof LGraphGroup)) {
// Non-node, non-group children (reroutes, etc.)
// Skip groups here - they're already in allItems and will be
// processed in the main loop of moveChildNodesInGroupVueMode
child.move(deltaX, deltaY, true)
} else {
// Non-node children (nested groups, reroutes)
child.move(deltaX, deltaY)
}
}
}

View File

@@ -416,7 +416,7 @@ export class LGraphNode
selected?: boolean
showAdvanced?: boolean
declare comfyDynamic?: Record<string, object>
declare comfyMatchType?: Record<string, Record<string, string>>
declare comfyClass?: string
declare isVirtualNode?: boolean
applyToGraph?(extraLinks?: LLink[]): void

View File

@@ -7,9 +7,7 @@ import type { IContextMenuValue } from './interfaces'
*/
const ENABLE_LEGACY_SUPPORT = true
type ContextMenuValueProvider = (
...args: unknown[]
) => (IContextMenuValue | null)[]
type ContextMenuValueProvider = (...args: unknown[]) => IContextMenuValue[]
class LegacyMenuCompat {
private originalMethods = new Map<string, ContextMenuValueProvider>()
@@ -39,22 +37,16 @@ class LegacyMenuCompat {
* @param preWrapperFn The method that existed before the wrapper
* @param prototype The prototype to verify wrapper installation
*/
registerWrapper<K extends keyof LGraphCanvas>(
methodName: K,
wrapperFn: LGraphCanvas[K],
preWrapperFn: LGraphCanvas[K],
registerWrapper(
methodName: keyof LGraphCanvas,
wrapperFn: ContextMenuValueProvider,
preWrapperFn: ContextMenuValueProvider,
prototype?: LGraphCanvas
) {
this.wrapperMethods.set(
methodName as string,
wrapperFn as unknown as ContextMenuValueProvider
)
this.preWrapperMethods.set(
methodName as string,
preWrapperFn as unknown as ContextMenuValueProvider
)
this.wrapperMethods.set(methodName, wrapperFn)
this.preWrapperMethods.set(methodName, preWrapperFn)
const isInstalled = prototype && prototype[methodName] === wrapperFn
this.wrapperInstalled.set(methodName as string, !!isInstalled)
this.wrapperInstalled.set(methodName, !!isInstalled)
}
/**
@@ -62,17 +54,11 @@ class LegacyMenuCompat {
* @param prototype The prototype to install on
* @param methodName The method name to track
*/
install<K extends keyof LGraphCanvas>(
prototype: LGraphCanvas,
methodName: K
) {
install(prototype: LGraphCanvas, methodName: keyof LGraphCanvas) {
if (!ENABLE_LEGACY_SUPPORT) return
const originalMethod = prototype[methodName]
this.originalMethods.set(
methodName as string,
originalMethod as unknown as ContextMenuValueProvider
)
this.originalMethods.set(methodName, originalMethod)
let currentImpl = originalMethod
@@ -80,13 +66,13 @@ class LegacyMenuCompat {
get() {
return currentImpl
},
set: (newImpl: LGraphCanvas[K]) => {
const fnKey = `${methodName as string}:${newImpl.toString().slice(0, 100)}`
set: (newImpl: ContextMenuValueProvider) => {
const fnKey = `${methodName}:${newImpl.toString().slice(0, 100)}`
if (!this.hasWarned.has(fnKey) && this.currentExtension) {
this.hasWarned.add(fnKey)
console.warn(
`%c[DEPRECATED]%c Monkey-patching ${methodName as string} is deprecated. (Extension: "${this.currentExtension}")\n` +
`%c[DEPRECATED]%c Monkey-patching ${methodName} is deprecated. (Extension: "${this.currentExtension}")\n` +
`Please use the new context menu API instead.\n\n` +
`See: https://docs.comfy.org/custom-nodes/js/context-menu-migration`,
'color: orange; font-weight: bold',
@@ -99,15 +85,7 @@ class LegacyMenuCompat {
}
/**
* Extract items that were added by legacy monkey patches.
*
* Uses set-based diffing by reference to reliably detect additions regardless
* of item reordering or replacement. Items present in patchedItems but not in
* originalItems (by reference equality) are considered additions.
*
* Note: If a monkey patch removes items (patchedItems has fewer unique items
* than originalItems), a warning is logged but we still return any new items.
*
* Extract items that were added by legacy monkey patches
* @param methodName The method name that was monkey-patched
* @param context The context to call methods with
* @param args Arguments to pass to the methods
@@ -117,7 +95,7 @@ class LegacyMenuCompat {
methodName: keyof LGraphCanvas,
context: LGraphCanvas,
...args: unknown[]
): (IContextMenuValue | null)[] {
): IContextMenuValue[] {
if (!ENABLE_LEGACY_SUPPORT) return []
if (this.isExtracting) return []
@@ -128,7 +106,7 @@ class LegacyMenuCompat {
this.isExtracting = true
const originalItems = originalMethod.apply(context, args) as
| (IContextMenuValue | null)[]
| IContextMenuValue[]
| undefined
if (!originalItems) return []
@@ -149,26 +127,15 @@ class LegacyMenuCompat {
const methodToCall = shouldSkipWrapper ? preWrapperMethod : currentMethod
const patchedItems = methodToCall.apply(context, args) as
| (IContextMenuValue | null)[]
| IContextMenuValue[]
| undefined
if (!patchedItems) return []
// Use set-based diff to detect additions by reference
const originalSet = new Set<IContextMenuValue | null>(originalItems)
const addedItems = patchedItems.filter((item) => !originalSet.has(item))
// Warn if items were removed (patched has fewer original items than expected)
const retainedOriginalCount = patchedItems.filter((item) =>
originalSet.has(item)
).length
if (retainedOriginalCount < originalItems.length) {
console.warn(
`[Context Menu Compat] Monkey patch for ${methodName} removed ${originalItems.length - retainedOriginalCount} original menu item(s). ` +
`This may cause unexpected behavior.`
)
if (patchedItems.length > originalItems.length) {
return patchedItems.slice(originalItems.length) as IContextMenuValue[]
}
return addedItems
return []
} catch (e) {
console.error('[Context Menu Compat] Failed to extract legacy items:', e)
return []

View File

@@ -30,8 +30,6 @@ export interface IDrawOptions {
highlight?: boolean
}
const ROTATION_OFFSET = -Math.PI / 2
/** Shared base class for {@link LGraphNode} input and output slots. */
export abstract class NodeSlot extends SlotBase implements INodeSlot {
pos?: Point
@@ -132,7 +130,6 @@ export abstract class NodeSlot extends SlotBase implements INodeSlot {
slot_type === SlotType.Array ? SlotShape.Grid : this.shape
) as SlotShape
ctx.save()
ctx.beginPath()
let doFill = true
@@ -166,52 +163,16 @@ export abstract class NodeSlot extends SlotBase implements INodeSlot {
if (lowQuality) {
ctx.rect(pos[0] - 4, pos[1] - 4, 8, 8)
} else {
let radius: number
if (slot_shape === SlotShape.HollowCircle) {
const path = new Path2D()
path.arc(pos[0], pos[1], 10, 0, Math.PI * 2)
path.arc(pos[0], pos[1], highlight ? 2.5 : 1.5, 0, Math.PI * 2)
ctx.clip(path, 'evenodd')
}
const radius = highlight ? 5 : 4
const typesSet = new Set(
`${this.type}`
.split(',')
.map(
this.isConnected
? (type) => colorContext.getConnectedColor(type)
: (type) => colorContext.getDisconnectedColor(type)
)
)
const types = [...typesSet].slice(0, 3)
if (types.length > 1) {
doFill = false
const arcLen = (Math.PI * 2) / types.length
types.forEach((type, idx) => {
ctx.moveTo(pos[0], pos[1])
ctx.fillStyle = type
ctx.arc(
pos[0],
pos[1],
radius,
arcLen * idx + ROTATION_OFFSET,
Math.PI * 2 + ROTATION_OFFSET
)
ctx.fill()
ctx.beginPath()
})
//add stroke dividers
ctx.save()
ctx.strokeStyle = 'black'
ctx.lineWidth = 0.5
types.forEach((_, idx) => {
ctx.moveTo(pos[0], pos[1])
const xOffset = Math.cos(arcLen * idx + ROTATION_OFFSET) * radius
const yOffset = Math.sin(arcLen * idx + ROTATION_OFFSET) * radius
ctx.lineTo(pos[0] + xOffset, pos[1] + yOffset)
})
ctx.stroke()
ctx.restore()
ctx.beginPath()
doStroke = true
ctx.lineWidth = 3
ctx.strokeStyle = ctx.fillStyle
radius = highlight ? 4 : 3
} else {
// Normal circle
radius = highlight ? 5 : 4
}
ctx.arc(pos[0], pos[1], radius, 0, Math.PI * 2)
}
@@ -219,7 +180,6 @@ export abstract class NodeSlot extends SlotBase implements INodeSlot {
if (doFill) ctx.fill()
if (!lowQuality && doStroke) ctx.stroke()
ctx.restore()
// render slot label
const hideLabel = lowQuality || this.isWidgetInputSlot

View File

@@ -1264,6 +1264,7 @@
"MiniMax": "MiniMax",
"Moonvalley Marey": "مون فالي ماري",
"OpenAI": "OpenAI",
"Pika": "Pika",
"PixVerse": "PixVerse",
"Recraft": "Recraft",
"Rodin": "رودان",

View File

@@ -8286,6 +8286,265 @@
}
}
},
"PikaImageToVideoNode2_2": {
"description": "يرسل صورة ونص وصف إلى واجهة برمجة تطبيقات بيك v2.2 لإنشاء فيديو.",
"display_name": "تحويل صورة إلى فيديو بيك",
"inputs": {
"control_after_generate": {
"name": "التحكم بعد الإنشاء"
},
"duration": {
"name": "المدة"
},
"image": {
"name": "الصورة",
"tooltip": "الصورة المراد تحويلها إلى فيديو"
},
"negative_prompt": {
"name": "الوصف السلبي"
},
"prompt_text": {
"name": "نص الوصف"
},
"resolution": {
"name": "الدقة"
},
"seed": {
"name": "البذرة"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"PikaScenesV2_2": {
"description": "ادمج صورك لإنشاء فيديو يحتوي على الكائنات الموجودة فيها. قم برفع عدة صور كمكونات وأنشئ فيديو عالي الجودة يدمج جميعها.",
"display_name": "مشاهد بيك (تكوين فيديو من الصور)",
"inputs": {
"aspect_ratio": {
"name": "نسبة العرض إلى الارتفاع",
"tooltip": "نسبة العرض إلى الارتفاع (العرض / الارتفاع)"
},
"control_after_generate": {
"name": "التحكم بعد الإنشاء"
},
"duration": {
"name": "المدة"
},
"image_ingredient_1": {
"name": "مكون الصورة 1",
"tooltip": "الصورة التي ستُستخدم كمكون لإنشاء الفيديو."
},
"image_ingredient_2": {
"name": "مكون الصورة 2",
"tooltip": "الصورة التي ستُستخدم كمكون لإنشاء الفيديو."
},
"image_ingredient_3": {
"name": "مكون الصورة 3",
"tooltip": "الصورة التي ستُستخدم كمكون لإنشاء الفيديو."
},
"image_ingredient_4": {
"name": "مكون الصورة 4",
"tooltip": "الصورة التي ستُستخدم كمكون لإنشاء الفيديو."
},
"image_ingredient_5": {
"name": "مكون الصورة 5",
"tooltip": "الصورة التي ستُستخدم كمكون لإنشاء الفيديو."
},
"ingredients_mode": {
"name": "وضع المكونات"
},
"negative_prompt": {
"name": "الوصف السلبي"
},
"prompt_text": {
"name": "نص الوصف"
},
"resolution": {
"name": "الدقة"
},
"seed": {
"name": "البذرة"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"PikaStartEndFrameNode2_2": {
"description": "أنشئ فيديوً بدمج أول وآخر إطارين. قم برفع صورتين لتحديد نقاط البداية والنهاية، ودع الذكاء الاصطناعي ينشئ انتقالاً سلساً بينهما.",
"display_name": "إطارات بداية ونهاية بيك إلى فيديو",
"inputs": {
"control_after_generate": {
"name": "التحكم بعد الإنشاء"
},
"duration": {
"name": "المدة"
},
"image_end": {
"name": "صورة النهاية",
"tooltip": "الصورة الأخيرة للدمج."
},
"image_start": {
"name": "صورة البداية",
"tooltip": "الصورة الأولى للدمج."
},
"negative_prompt": {
"name": "الوصف السلبي"
},
"prompt_text": {
"name": "نص الوصف"
},
"resolution": {
"name": "الدقة"
},
"seed": {
"name": "البذرة"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"PikaTextToVideoNode2_2": {
"description": "يرسل نص المطالبة إلى واجهة برمجة تطبيقات بيكا الإصدار 2.2 لتوليد فيديو.",
"display_name": "بيكا نص إلى فيديو",
"inputs": {
"aspect_ratio": {
"name": "نسبة العرض إلى الارتفاع",
"tooltip": "نسبة العرض إلى الارتفاع (العرض / الارتفاع)"
},
"control_after_generate": {
"name": "التحكم بعد التوليد"
},
"duration": {
"name": "المدة"
},
"negative_prompt": {
"name": "نص المطالبة السلبية"
},
"prompt_text": {
"name": "نص المطالبة"
},
"resolution": {
"name": "الدقة"
},
"seed": {
"name": "البذرة"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"Pikadditions": {
"description": "أضف أي كائن أو صورة إلى الفيديو الخاص بك. قم برفع فيديو وحدد ما تريد إضافته لإنشاء نتيجة مدمجة بسلاسة.",
"display_name": "إضافات بيك (إدخال كائن فيديو)",
"inputs": {
"control_after_generate": {
"name": "التحكم بعد الإنشاء"
},
"image": {
"name": "الصورة",
"tooltip": "الصورة التي تريد إضافتها إلى الفيديو."
},
"negative_prompt": {
"name": "الوصف السلبي"
},
"prompt_text": {
"name": "نص الوصف"
},
"seed": {
"name": "البذرة"
},
"video": {
"name": "الفيديو",
"tooltip": "الفيديو الذي تريد إضافة صورة إليه."
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"Pikaffects": {
"description": "أنشئ فيديو مع تأثير بيك محدد. التأثيرات المدعومة: تزيين الكيك، التفتيت، السحق، القطع، الانكماش، الذوبان، الانفجار، بروز العين، النفخ، التعليق، الذوبان، التقشير، الوخز، السحق، تعبير المفاجأة، التمزق",
"display_name": "تأثيرات بيك (تأثيرات الفيديو)",
"inputs": {
"control_after_generate": {
"name": "التحكم بعد الإنشاء"
},
"image": {
"name": "الصورة",
"tooltip": "الصورة المرجعية لتطبيق التأثير عليها."
},
"negative_prompt": {
"name": "الوصف السلبي"
},
"pikaffect": {
"name": "تأثير بيك"
},
"prompt_text": {
"name": "نص الوصف"
},
"seed": {
"name": "البذرة"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"Pikaswaps": {
"description": "استبدل أي كائن أو منطقة في الفيديو الخاص بك بصورة أو كائن جديد. عرّف المناطق التي تريد استبدالها إما بقناع أو بإحداثيات.",
"display_name": "بيكا سوابس (استبدال كائن الفيديو)",
"inputs": {
"control_after_generate": {
"name": "التحكم بعد التوليد"
},
"image": {
"name": "الصورة",
"tooltip": "الصورة المستخدمة لاستبدال الكائن المقنع في الفيديو."
},
"mask": {
"name": "القناع",
"tooltip": "استخدم القناع لتحديد المناطق التي سيتم استبدالها في الفيديو."
},
"negative_prompt": {
"name": "نص المطالبة السلبية"
},
"prompt_text": {
"name": "نص المطالبة"
},
"region_to_modify": {
"name": "المنطقة المراد تعديلها",
"tooltip": "وصف نصي بسيط للكائن / المنطقة المراد تعديلها."
},
"seed": {
"name": "البذرة"
},
"video": {
"name": "الفيديو",
"tooltip": "الفيديو الذي سيتم استبدال كائن فيه."
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"PixverseImageToVideoNode": {
"description": "ينتج فيديوهات بشكل متزامن بناءً على النص المطلوب وحجم المخرج.",
"display_name": "بيكسفيرس صورة إلى فيديو",

View File

@@ -807,7 +807,6 @@
"dark": "Dark",
"light": "Light",
"manageExtensions": "Manage Extensions",
"customNodesManager": "Custom Nodes Manager",
"settings": "Settings",
"help": "Help",
"queue": "Queue Panel"
@@ -1334,10 +1333,6 @@
"disable-metadata": {
"name": "Disable saving prompt metadata in files."
},
"enable-manager-legacy-ui": {
"name": "Use legacy Manager UI",
"tooltip": "Uses the legacy ComfyUI-Manager UI instead of the new UI."
},
"disable-all-custom-nodes": {
"name": "Disable loading all custom nodes."
},
@@ -1433,6 +1428,7 @@
"Sora": "Sora",
"cond pair": "cond pair",
"photomaker": "photomaker",
"Pika": "Pika",
"PixVerse": "PixVerse",
"primitive": "primitive",
"qwen": "qwen",
@@ -2056,26 +2052,6 @@
"placeholderVideo": "Select video...",
"placeholderModel": "Select model...",
"placeholderUnknown": "Select media..."
},
"numberControl": {
"header": {
"prefix": "Automatically update the value",
"after": "AFTER",
"before": "BEFORE",
"postfix": "running the workflow:"
},
"linkToGlobal": "Link to",
"linkToGlobalSeed": "Global Value",
"linkToGlobalDesc": "Unique value linked to the Global Value's control setting",
"randomize": "Randomize Value",
"randomizeDesc": "Shuffles the value randomly after each generation",
"increment": "Increment Value",
"incrementDesc": "Adds 1 to the value number",
"decrement": "Decrement Value",
"decrementDesc": "Subtracts 1 from the value number",
"fixed": "Fixed Value",
"fixedDesc": "Leaves value unchanged",
"editSettings": "Edit control settings"
}
},
"widgetFileUpload": {

View File

@@ -9426,6 +9426,265 @@
}
}
},
"Pikadditions": {
"display_name": "Pikadditions (Video Object Insertion)",
"description": "Add any object or image into your video. Upload a video and specify what you'd like to add to create a seamlessly integrated result.",
"inputs": {
"video": {
"name": "video",
"tooltip": "The video to add an image to."
},
"image": {
"name": "image",
"tooltip": "The image to add to the video."
},
"prompt_text": {
"name": "prompt_text"
},
"negative_prompt": {
"name": "negative_prompt"
},
"seed": {
"name": "seed"
},
"control_after_generate": {
"name": "control after generate"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"Pikaffects": {
"display_name": "Pikaffects (Video Effects)",
"description": "Generate a video with a specific Pikaffect. Supported Pikaffects: Cake-ify, Crumble, Crush, Decapitate, Deflate, Dissolve, Explode, Eye-pop, Inflate, Levitate, Melt, Peel, Poke, Squish, Ta-da, Tear",
"inputs": {
"image": {
"name": "image",
"tooltip": "The reference image to apply the Pikaffect to."
},
"pikaffect": {
"name": "pikaffect"
},
"prompt_text": {
"name": "prompt_text"
},
"negative_prompt": {
"name": "negative_prompt"
},
"seed": {
"name": "seed"
},
"control_after_generate": {
"name": "control after generate"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"PikaImageToVideoNode2_2": {
"display_name": "Pika Image to Video",
"description": "Sends an image and prompt to the Pika API v2.2 to generate a video.",
"inputs": {
"image": {
"name": "image",
"tooltip": "The image to convert to video"
},
"prompt_text": {
"name": "prompt_text"
},
"negative_prompt": {
"name": "negative_prompt"
},
"seed": {
"name": "seed"
},
"resolution": {
"name": "resolution"
},
"duration": {
"name": "duration"
},
"control_after_generate": {
"name": "control after generate"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"PikaScenesV2_2": {
"display_name": "Pika Scenes (Video Image Composition)",
"description": "Combine your images to create a video with the objects in them. Upload multiple images as ingredients and generate a high-quality video that incorporates all of them.",
"inputs": {
"prompt_text": {
"name": "prompt_text"
},
"negative_prompt": {
"name": "negative_prompt"
},
"seed": {
"name": "seed"
},
"resolution": {
"name": "resolution"
},
"duration": {
"name": "duration"
},
"ingredients_mode": {
"name": "ingredients_mode"
},
"aspect_ratio": {
"name": "aspect_ratio",
"tooltip": "Aspect ratio (width / height)"
},
"image_ingredient_1": {
"name": "image_ingredient_1",
"tooltip": "Image that will be used as ingredient to create a video."
},
"image_ingredient_2": {
"name": "image_ingredient_2",
"tooltip": "Image that will be used as ingredient to create a video."
},
"image_ingredient_3": {
"name": "image_ingredient_3",
"tooltip": "Image that will be used as ingredient to create a video."
},
"image_ingredient_4": {
"name": "image_ingredient_4",
"tooltip": "Image that will be used as ingredient to create a video."
},
"image_ingredient_5": {
"name": "image_ingredient_5",
"tooltip": "Image that will be used as ingredient to create a video."
},
"control_after_generate": {
"name": "control after generate"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"PikaStartEndFrameNode2_2": {
"display_name": "Pika Start and End Frame to Video",
"description": "Generate a video by combining your first and last frame. Upload two images to define the start and end points, and let the AI create a smooth transition between them.",
"inputs": {
"image_start": {
"name": "image_start",
"tooltip": "The first image to combine."
},
"image_end": {
"name": "image_end",
"tooltip": "The last image to combine."
},
"prompt_text": {
"name": "prompt_text"
},
"negative_prompt": {
"name": "negative_prompt"
},
"seed": {
"name": "seed"
},
"resolution": {
"name": "resolution"
},
"duration": {
"name": "duration"
},
"control_after_generate": {
"name": "control after generate"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"Pikaswaps": {
"display_name": "Pika Swaps (Video Object Replacement)",
"description": "Swap out any object or region of your video with a new image or object. Define areas to replace either with a mask or coordinates.",
"inputs": {
"video": {
"name": "video",
"tooltip": "The video to swap an object in."
},
"image": {
"name": "image",
"tooltip": "The image used to replace the masked object in the video."
},
"mask": {
"name": "mask",
"tooltip": "Use the mask to define areas in the video to replace."
},
"prompt_text": {
"name": "prompt_text"
},
"negative_prompt": {
"name": "negative_prompt"
},
"seed": {
"name": "seed"
},
"region_to_modify": {
"name": "region_to_modify",
"tooltip": "Plaintext description of the object / region to modify."
},
"control_after_generate": {
"name": "control after generate"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"PikaTextToVideoNode2_2": {
"display_name": "Pika Text to Video",
"description": "Sends a text prompt to the Pika API v2.2 to generate a video.",
"inputs": {
"prompt_text": {
"name": "prompt_text"
},
"negative_prompt": {
"name": "negative_prompt"
},
"seed": {
"name": "seed"
},
"resolution": {
"name": "resolution"
},
"duration": {
"name": "duration"
},
"aspect_ratio": {
"name": "aspect_ratio",
"tooltip": "Aspect ratio (width / height)"
},
"control_after_generate": {
"name": "control after generate"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"PixverseImageToVideoNode": {
"display_name": "PixVerse Image to Video",
"description": "Generates videos based on prompt and output_size.",
@@ -11066,31 +11325,6 @@
}
}
},
"SamplerSEEDS2": {
"display_name": "SamplerSEEDS2",
"inputs": {
"solver_type": {
"name": "solver_type"
},
"eta": {
"name": "eta",
"tooltip": "Stochastic strength"
},
"s_noise": {
"name": "s_noise",
"tooltip": "SDE noise multiplier"
},
"r": {
"name": "r",
"tooltip": "Relative step size for the intermediate stage (c2 node)"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"SamplingPercentToSigma": {
"display_name": "SamplingPercentToSigma",
"inputs": {

View File

@@ -1264,6 +1264,7 @@
"MiniMax": "MiniMax",
"Moonvalley Marey": "Moonvalley Marey",
"OpenAI": "OpenAI",
"Pika": "Pika",
"PixVerse": "PixVerse",
"Recraft": "Recraft",
"Rodin": "Rodin",

View File

@@ -8286,6 +8286,265 @@
}
}
},
"PikaImageToVideoNode2_2": {
"description": "Envía una imagen y un prompt a la API de Pika v2.2 para generar un video.",
"display_name": "Pika Imagen a Video",
"inputs": {
"control_after_generate": {
"name": "control después de generar"
},
"duration": {
"name": "duración"
},
"image": {
"name": "imagen",
"tooltip": "La imagen a convertir en video"
},
"negative_prompt": {
"name": "prompt negativo"
},
"prompt_text": {
"name": "texto del prompt"
},
"resolution": {
"name": "resolución"
},
"seed": {
"name": "semilla"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"PikaScenesV2_2": {
"description": "Combina tus imágenes para crear un video con los objetos que contienen. Sube varias imágenes como ingredientes y genera un video de alta calidad que las incorpore todas.",
"display_name": "Pika Scenes (Composición de Imágenes en Video)",
"inputs": {
"aspect_ratio": {
"name": "aspect_ratio",
"tooltip": "Relación de aspecto (ancho / alto)"
},
"control_after_generate": {
"name": "control after generate"
},
"duration": {
"name": "duration"
},
"image_ingredient_1": {
"name": "image_ingredient_1",
"tooltip": "Imagen que se usará como ingrediente para crear un video."
},
"image_ingredient_2": {
"name": "image_ingredient_2",
"tooltip": "Imagen que se usará como ingrediente para crear un video."
},
"image_ingredient_3": {
"name": "image_ingredient_3",
"tooltip": "Imagen que se usará como ingrediente para crear un video."
},
"image_ingredient_4": {
"name": "image_ingredient_4",
"tooltip": "Imagen que se usará como ingrediente para crear un video."
},
"image_ingredient_5": {
"name": "image_ingredient_5",
"tooltip": "Imagen que se usará como ingrediente para crear un video."
},
"ingredients_mode": {
"name": "ingredients_mode"
},
"negative_prompt": {
"name": "negative_prompt"
},
"prompt_text": {
"name": "prompt_text"
},
"resolution": {
"name": "resolution"
},
"seed": {
"name": "seed"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"PikaStartEndFrameNode2_2": {
"description": "Genera un video combinando tu primer y último fotograma. Sube dos imágenes para definir los puntos de inicio y fin, y deja que la IA cree una transición suave entre ellas.",
"display_name": "Pika: Fotograma Inicial y Final a Video",
"inputs": {
"control_after_generate": {
"name": "control después de generar"
},
"duration": {
"name": "duración"
},
"image_end": {
"name": "imagen_final",
"tooltip": "La última imagen a combinar."
},
"image_start": {
"name": "imagen_inicial",
"tooltip": "La primera imagen a combinar."
},
"negative_prompt": {
"name": "prompt_negativo"
},
"prompt_text": {
"name": "texto_de_prompt"
},
"resolution": {
"name": "resolución"
},
"seed": {
"name": "semilla"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"PikaTextToVideoNode2_2": {
"description": "Envía un prompt de texto a la API de Pika v2.2 para generar un video.",
"display_name": "Pika Texto a Video",
"inputs": {
"aspect_ratio": {
"name": "relación de aspecto",
"tooltip": "Relación de aspecto (ancho / alto)"
},
"control_after_generate": {
"name": "controlar después de generar"
},
"duration": {
"name": "duración"
},
"negative_prompt": {
"name": "prompt negativo"
},
"prompt_text": {
"name": "texto del prompt"
},
"resolution": {
"name": "resolución"
},
"seed": {
"name": "semilla"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"Pikadditions": {
"description": "Agrega cualquier objeto o imagen a tu video. Sube un video y especifica lo que deseas añadir para crear un resultado perfectamente integrado.",
"display_name": "Pikadditions (Inserción de Objetos en Video)",
"inputs": {
"control_after_generate": {
"name": "control después de generar"
},
"image": {
"name": "imagen",
"tooltip": "La imagen que se añadirá al video."
},
"negative_prompt": {
"name": "indicación negativa"
},
"prompt_text": {
"name": "texto de indicación"
},
"seed": {
"name": "semilla"
},
"video": {
"name": "video",
"tooltip": "El video al que se añadirá una imagen."
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"Pikaffects": {
"description": "Genera un video con un Pikaffect específico. Pikaffects soportados: Cake-ify, Crumble, Crush, Decapitate, Deflate, Dissolve, Explode, Eye-pop, Inflate, Levitate, Melt, Peel, Poke, Squish, Ta-da, Tear",
"display_name": "Pikaffects (Efectos de Video)",
"inputs": {
"control_after_generate": {
"name": "control después de generar"
},
"image": {
"name": "imagen",
"tooltip": "La imagen de referencia a la que se aplicará el Pikaffect."
},
"negative_prompt": {
"name": "prompt negativo"
},
"pikaffect": {
"name": "pikaffect"
},
"prompt_text": {
"name": "texto de prompt"
},
"seed": {
"name": "semilla"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"Pikaswaps": {
"description": "Sustituye cualquier objeto o región de tu video con una nueva imagen u objeto. Define las áreas a reemplazar usando una máscara o coordenadas.",
"display_name": "Pika Swaps (Reemplazo de Objetos en Video)",
"inputs": {
"control_after_generate": {
"name": "control después de generar"
},
"image": {
"name": "imagen",
"tooltip": "La imagen utilizada para reemplazar el objeto enmascarado en el video."
},
"mask": {
"name": "máscara",
"tooltip": "Usa la máscara para definir las áreas del video a reemplazar"
},
"negative_prompt": {
"name": "prompt negativo"
},
"prompt_text": {
"name": "texto de prompt"
},
"region_to_modify": {
"name": "región_a_modificar",
"tooltip": "Descripción en texto plano del objeto/región a modificar."
},
"seed": {
"name": "semilla"
},
"video": {
"name": "video",
"tooltip": "El video en el que se va a intercambiar un objeto."
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"PixverseImageToVideoNode": {
"description": "Genera videos de forma sincrónica según el prompt y el tamaño de salida.",
"display_name": "PixVerse Imagen a Video",

View File

@@ -1264,6 +1264,7 @@
"MiniMax": "MiniMax",
"Moonvalley Marey": "Moonvalley Marey",
"OpenAI": "OpenAI",
"Pika": "Pika",
"PixVerse": "PixVerse",
"Recraft": "Recraft",
"Rodin": "Rodin",

View File

@@ -8286,6 +8286,265 @@
}
}
},
"PikaImageToVideoNode2_2": {
"description": "Envoie une image et une invite à l'API Pika v2.2 pour générer une vidéo.",
"display_name": "Pika Image vers Vidéo",
"inputs": {
"control_after_generate": {
"name": "contrôle après génération"
},
"duration": {
"name": "durée"
},
"image": {
"name": "image",
"tooltip": "L'image à convertir en vidéo"
},
"negative_prompt": {
"name": "invite négative"
},
"prompt_text": {
"name": "texte de l'invite"
},
"resolution": {
"name": "résolution"
},
"seed": {
"name": "graine"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"PikaScenesV2_2": {
"description": "Combinez vos images pour créer une vidéo avec les objets qu'elles contiennent. Téléchargez plusieurs images comme ingrédients et générez une vidéo de haute qualité qui les intègre toutes.",
"display_name": "Pika Scenes (Composition Vidéo Image)",
"inputs": {
"aspect_ratio": {
"name": "aspect_ratio",
"tooltip": "Rapport d'aspect (largeur / hauteur)"
},
"control_after_generate": {
"name": "control after generate"
},
"duration": {
"name": "duration"
},
"image_ingredient_1": {
"name": "image_ingredient_1",
"tooltip": "Image qui sera utilisée comme ingrédient pour créer une vidéo."
},
"image_ingredient_2": {
"name": "image_ingredient_2",
"tooltip": "Image qui sera utilisée comme ingrédient pour créer une vidéo."
},
"image_ingredient_3": {
"name": "image_ingredient_3",
"tooltip": "Image qui sera utilisée comme ingrédient pour créer une vidéo."
},
"image_ingredient_4": {
"name": "image_ingredient_4",
"tooltip": "Image qui sera utilisée comme ingrédient pour créer une vidéo."
},
"image_ingredient_5": {
"name": "image_ingredient_5",
"tooltip": "Image qui sera utilisée comme ingrédient pour créer une vidéo."
},
"ingredients_mode": {
"name": "ingredients_mode"
},
"negative_prompt": {
"name": "negative_prompt"
},
"prompt_text": {
"name": "prompt_text"
},
"resolution": {
"name": "resolution"
},
"seed": {
"name": "seed"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"PikaStartEndFrameNode2_2": {
"description": "Générez une vidéo en combinant votre première et dernière image. Téléversez deux images pour définir les points de départ et darrivée, et laissez lIA créer une transition fluide entre elles.",
"display_name": "Pika Début et Fin dImage en Vidéo",
"inputs": {
"control_after_generate": {
"name": "contrôle après génération"
},
"duration": {
"name": "duration"
},
"image_end": {
"name": "image_end",
"tooltip": "La dernière image à combiner."
},
"image_start": {
"name": "image_start",
"tooltip": "La première image à combiner."
},
"negative_prompt": {
"name": "negative_prompt"
},
"prompt_text": {
"name": "prompt_text"
},
"resolution": {
"name": "resolution"
},
"seed": {
"name": "seed"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"PikaTextToVideoNode2_2": {
"description": "Envoie une invite textuelle à l'API Pika v2.2 pour générer une vidéo.",
"display_name": "Pika Texte en Vidéo",
"inputs": {
"aspect_ratio": {
"name": "rapport d'aspect",
"tooltip": "Rapport d'aspect (largeur / hauteur)"
},
"control_after_generate": {
"name": "contrôle après génération"
},
"duration": {
"name": "durée"
},
"negative_prompt": {
"name": "invite négative"
},
"prompt_text": {
"name": "texte de l'invite"
},
"resolution": {
"name": "résolution"
},
"seed": {
"name": "graine"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"Pikadditions": {
"description": "Ajoutez n'importe quel objet ou image dans votre vidéo. Téléchargez une vidéo et spécifiez ce que vous souhaitez ajouter pour obtenir un résultat parfaitement intégré.",
"display_name": "Pikadditions (Insertion d'objet vidéo)",
"inputs": {
"control_after_generate": {
"name": "contrôle après génération"
},
"image": {
"name": "image",
"tooltip": "L'image à ajouter à la vidéo."
},
"negative_prompt": {
"name": "invite négative"
},
"prompt_text": {
"name": "texte d'invite"
},
"seed": {
"name": "graine"
},
"video": {
"name": "vidéo",
"tooltip": "La vidéo à laquelle ajouter une image."
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"Pikaffects": {
"description": "Générez une vidéo avec un Pikaffect spécifique. Pikaffects pris en charge : Cake-ify, Crumble, Crush, Decapitate, Deflate, Dissolve, Explode, Eye-pop, Inflate, Levitate, Melt, Peel, Poke, Squish, Ta-da, Tear",
"display_name": "Pikaffects (Effets vidéo)",
"inputs": {
"control_after_generate": {
"name": "contrôle après génération"
},
"image": {
"name": "image",
"tooltip": "Limage de référence à laquelle appliquer le Pikaffect."
},
"negative_prompt": {
"name": "negative_prompt"
},
"pikaffect": {
"name": "pikaffect"
},
"prompt_text": {
"name": "prompt_text"
},
"seed": {
"name": "seed"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"Pikaswaps": {
"description": "Remplacez nimporte quel objet ou région de votre vidéo par une nouvelle image ou un nouvel objet. Définissez les zones à remplacer soit avec un mask, soit avec des coordonnées.",
"display_name": "Pika Swaps (Remplacement dobjet vidéo)",
"inputs": {
"control_after_generate": {
"name": "contrôle après génération"
},
"image": {
"name": "image",
"tooltip": "Limage utilisée pour remplacer lobjet masqué dans la vidéo."
},
"mask": {
"name": "mask",
"tooltip": "Utilisez le mask pour définir les zones à remplacer dans la vidéo"
},
"negative_prompt": {
"name": "invite négative"
},
"prompt_text": {
"name": "texte dinvite"
},
"region_to_modify": {
"name": "région_à_modifier",
"tooltip": "Description en texte brut de l'objet / de la région à modifier."
},
"seed": {
"name": "graine"
},
"video": {
"name": "vidéo",
"tooltip": "La vidéo dans laquelle remplacer un objet."
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"PixverseImageToVideoNode": {
"description": "Génère des vidéos de manière synchrone à partir du prompt et de la taille de sortie.",
"display_name": "PixVerse Image vers Vidéo",

View File

@@ -1264,6 +1264,7 @@
"MiniMax": "MiniMax",
"Moonvalley Marey": "Moonvalley Marey",
"OpenAI": "OpenAI",
"Pika": "Pika",
"PixVerse": "PixVerse",
"Recraft": "Recraft",
"Rodin": "Rodin",

View File

@@ -8286,6 +8286,265 @@
}
}
},
"PikaImageToVideoNode2_2": {
"description": "画像とプロンプトをPika API v2.2に送信して動画を生成します。",
"display_name": "Pika画像から動画へ",
"inputs": {
"control_after_generate": {
"name": "生成後のコントロール"
},
"duration": {
"name": "再生時間"
},
"image": {
"name": "画像",
"tooltip": "動画に変換する画像"
},
"negative_prompt": {
"name": "ネガティブプロンプト"
},
"prompt_text": {
"name": "プロンプトテキスト"
},
"resolution": {
"name": "解像度"
},
"seed": {
"name": "シード"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"PikaScenesV2_2": {
"description": "複数の画像を組み合わせて、そこに含まれるオブジェクトを使ったビデオを作成します。複数の画像を素材としてアップロードし、それらすべてを取り入れた高品質なビデオを生成します。",
"display_name": "Pika Scenesビデオ画像合成",
"inputs": {
"aspect_ratio": {
"name": "アスペクト比",
"tooltip": "アスペクト比(幅 / 高さ)"
},
"control_after_generate": {
"name": "生成後のコントロール"
},
"duration": {
"name": "再生時間"
},
"image_ingredient_1": {
"name": "画像素材1",
"tooltip": "ビデオ作成の素材として使用する画像です。"
},
"image_ingredient_2": {
"name": "画像素材2",
"tooltip": "ビデオ作成の素材として使用する画像です。"
},
"image_ingredient_3": {
"name": "画像素材3",
"tooltip": "ビデオ作成の素材として使用する画像です。"
},
"image_ingredient_4": {
"name": "画像素材4",
"tooltip": "ビデオ作成の素材として使用する画像です。"
},
"image_ingredient_5": {
"name": "画像素材5",
"tooltip": "ビデオ作成の素材として使用する画像です。"
},
"ingredients_mode": {
"name": "素材モード"
},
"negative_prompt": {
"name": "ネガティブプロンプト"
},
"prompt_text": {
"name": "プロンプト"
},
"resolution": {
"name": "解像度"
},
"seed": {
"name": "シード"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"PikaStartEndFrameNode2_2": {
"description": "最初と最後のフレームを組み合わせて動画を生成します。2枚の画像をアップロードして開始点と終了点を定義し、AIがその間を滑らかに遷移させます。",
"display_name": "Pika 開始・終了フレームから動画生成",
"inputs": {
"control_after_generate": {
"name": "生成後のコントロール"
},
"duration": {
"name": "duration"
},
"image_end": {
"name": "image_end",
"tooltip": "組み合わせる最後の画像。"
},
"image_start": {
"name": "image_start",
"tooltip": "組み合わせる最初の画像。"
},
"negative_prompt": {
"name": "negative_prompt"
},
"prompt_text": {
"name": "prompt_text"
},
"resolution": {
"name": "resolution"
},
"seed": {
"name": "seed"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"PikaTextToVideoNode2_2": {
"description": "テキストプロンプトをPika API v2.2に送信してビデオを生成します。",
"display_name": "Pika テキストからビデオへ",
"inputs": {
"aspect_ratio": {
"name": "アスペクト比",
"tooltip": "アスペクト比(幅 / 高さ)"
},
"control_after_generate": {
"name": "生成後のコントロール"
},
"duration": {
"name": "再生時間"
},
"negative_prompt": {
"name": "ネガティブプロンプト"
},
"prompt_text": {
"name": "プロンプトテキスト"
},
"resolution": {
"name": "解像度"
},
"seed": {
"name": "シード"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"Pikadditions": {
"description": "任意のオブジェクトや画像をビデオに追加できます。ビデオをアップロードし、追加したい内容を指定して、シームレスに統合された結果を作成します。",
"display_name": "Pikadditionsビデオオブジェクト挿入",
"inputs": {
"control_after_generate": {
"name": "生成後のコントロール"
},
"image": {
"name": "画像",
"tooltip": "ビデオに追加する画像です。"
},
"negative_prompt": {
"name": "ネガティブプロンプト"
},
"prompt_text": {
"name": "プロンプトテキスト"
},
"seed": {
"name": "シード"
},
"video": {
"name": "ビデオ",
"tooltip": "画像を追加するビデオです。"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"Pikaffects": {
"description": "特定のPikaffectを使ってビデオを生成します。対応しているPikaffectCake-ify、Crumble、Crush、Decapitate、Deflate、Dissolve、Explode、Eye-pop、Inflate、Levitate、Melt、Peel、Poke、Squish、Ta-da、Tear",
"display_name": "Pikaffectsビデオエフェクト",
"inputs": {
"control_after_generate": {
"name": "生成後のコントロール"
},
"image": {
"name": "画像",
"tooltip": "Pikaffectを適用する参照画像。"
},
"negative_prompt": {
"name": "ネガティブプロンプト"
},
"pikaffect": {
"name": "Pikaffect"
},
"prompt_text": {
"name": "プロンプトテキスト"
},
"seed": {
"name": "シード"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"Pikaswaps": {
"description": "ビデオ内の任意のオブジェクトや領域を新しい画像やオブジェクトで置き換えます。置換する領域はマスクまたは座標で指定できます。",
"display_name": "Pika Swapsビデオオブジェクト置換",
"inputs": {
"control_after_generate": {
"name": "生成後のコントロール"
},
"image": {
"name": "image",
"tooltip": "ビデオ内のマスクされたオブジェクトを置き換えるために使用する画像。"
},
"mask": {
"name": "mask",
"tooltip": "ビデオ内で置換する領域を定義するためのマスクを使用します"
},
"negative_prompt": {
"name": "negative_prompt"
},
"prompt_text": {
"name": "prompt_text"
},
"region_to_modify": {
"name": "変更対象領域",
"tooltip": "変更するオブジェクト/領域の平文での説明。"
},
"seed": {
"name": "seed"
},
"video": {
"name": "video",
"tooltip": "オブジェクトを置換するビデオ。"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"PixverseImageToVideoNode": {
"description": "プロンプトと出力サイズに基づいて同期的に動画を生成します。",
"display_name": "PixVerse 画像から動画へ",

View File

@@ -1264,6 +1264,7 @@
"MiniMax": "MiniMax",
"Moonvalley Marey": "Moonvalley Marey",
"OpenAI": "OpenAI",
"Pika": "Pika",
"PixVerse": "PixVerse",
"Recraft": "Recraft",
"Rodin": "Rodin",

View File

@@ -8286,6 +8286,265 @@
}
}
},
"PikaImageToVideoNode2_2": {
"description": "이미지와 프롬프트를 Pika API v2.2에 전송하여 비디오를 생성합니다.",
"display_name": "Pika 비디오 생성 (이미지 → 비디오)",
"inputs": {
"control_after_generate": {
"name": "생성 후 제어"
},
"duration": {
"name": "길이"
},
"image": {
"name": "이미지",
"tooltip": "비디오로 변환할 이미지"
},
"negative_prompt": {
"name": "부정 프롬프트"
},
"prompt_text": {
"name": "프롬프트"
},
"resolution": {
"name": "해상도"
},
"seed": {
"name": "시드"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"PikaScenesV2_2": {
"description": "여러 이미지를 결합하여 이미지 속 객체들이 포함된 비디오를 만듭니다. 여러 이미지를 재료로 업로드하고, 이 모든 이미지를 반영한 고품질 비디오를 생성하세요.",
"display_name": "Pika Scenes (비디오 이미지 합성)",
"inputs": {
"aspect_ratio": {
"name": "종횡비",
"tooltip": "종횡비 (가로 / 세로)"
},
"control_after_generate": {
"name": "생성 후 제어"
},
"duration": {
"name": "길이"
},
"image_ingredient_1": {
"name": "이미지 재료 1",
"tooltip": "비디오 생성을 위한 재료로 사용할 이미지입니다."
},
"image_ingredient_2": {
"name": "이미지 재료 2",
"tooltip": "비디오 생성을 위한 재료로 사용할 이미지입니다."
},
"image_ingredient_3": {
"name": "이미지 재료 3",
"tooltip": "비디오 생성을 위한 재료로 사용할 이미지입니다."
},
"image_ingredient_4": {
"name": "이미지 재료 4",
"tooltip": "비디오 생성을 위한 재료로 사용할 이미지입니다."
},
"image_ingredient_5": {
"name": "이미지 재료 5",
"tooltip": "비디오 생성을 위한 재료로 사용할 이미지입니다."
},
"ingredients_mode": {
"name": "재료 모드"
},
"negative_prompt": {
"name": "부정 프롬프트"
},
"prompt_text": {
"name": "프롬프트 텍스트"
},
"resolution": {
"name": "해상도"
},
"seed": {
"name": "시드"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"PikaStartEndFrameNode2_2": {
"description": "첫 번째 프레임과 마지막 프레임을 결합하여 비디오를 생성합니다. 시작점과 종료점을 정의할 두 이미지를 업로드하면, AI가 그 사이를 부드럽게 전환하는 영상을 만들어줍니다.",
"display_name": "Pika 비디오 생성 (시작-끝 프레임)",
"inputs": {
"control_after_generate": {
"name": "생성 후 제어"
},
"duration": {
"name": "길이"
},
"image_end": {
"name": "끝 이미지",
"tooltip": "결합할 마지막 이미지입니다."
},
"image_start": {
"name": "시작 이미지",
"tooltip": "결합할 첫 번째 이미지입니다."
},
"negative_prompt": {
"name": "부정 프롬프트"
},
"prompt_text": {
"name": "프롬프트 텍스트"
},
"resolution": {
"name": "해상도"
},
"seed": {
"name": "시드"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"PikaTextToVideoNode2_2": {
"description": "텍스트 프롬프트를 Pika API v2.2에 전송하여 비디오를 생성합니다.",
"display_name": "Pika 비디오 생성 (텍스트 → 비디오)",
"inputs": {
"aspect_ratio": {
"name": "종횡비",
"tooltip": "종횡비 (가로 / 세로)"
},
"control_after_generate": {
"name": "생성 후 제어"
},
"duration": {
"name": "길이"
},
"negative_prompt": {
"name": "부정 프롬프트"
},
"prompt_text": {
"name": "프롬프트 텍스트"
},
"resolution": {
"name": "해상도"
},
"seed": {
"name": "시드"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"Pikadditions": {
"description": "비디오에 원하는 객체나 이미지를 추가하세요. 비디오를 업로드하고 추가하고 싶은 내용을 지정하면 자연스럽게 통합된 결과를 얻을 수 있습니다.",
"display_name": "Pikadditions (비디오 객체 삽입)",
"inputs": {
"control_after_generate": {
"name": "생성 후 제어"
},
"image": {
"name": "이미지",
"tooltip": "비디오에 추가할 이미지입니다."
},
"negative_prompt": {
"name": "부정 프롬프트"
},
"prompt_text": {
"name": "프롬프트 텍스트"
},
"seed": {
"name": "시드"
},
"video": {
"name": "비디오",
"tooltip": "이미지를 추가할 비디오입니다."
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"Pikaffects": {
"description": "특정 Pikaffect로 비디오를 생성합니다. 지원되는 Pikaffect: Cake-ify, Crumble, Crush, Decapitate, Deflate, Dissolve, Explode, Eye-pop, Inflate, Levitate, Melt, Peel, Poke, Squish, Ta-da, Tear",
"display_name": "Pikaffects (비디오 효과)",
"inputs": {
"control_after_generate": {
"name": "생성 후 제어"
},
"image": {
"name": "이미지",
"tooltip": "Pikaffect를 적용할 기준 이미지입니다."
},
"negative_prompt": {
"name": "부정 프롬프트"
},
"pikaffect": {
"name": "pikaffect"
},
"prompt_text": {
"name": "프롬프트 텍스트"
},
"seed": {
"name": "시드"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"Pikaswaps": {
"description": "비디오의 어떤 객체나 영역도 새로운 이미지나 객체로 교체하세요. 마스크나 좌표를 사용해 교체할 영역을 정의할 수 있습니다.",
"display_name": "Pika Swaps (비디오 객체 교체)",
"inputs": {
"control_after_generate": {
"name": "생성 후 제어"
},
"image": {
"name": "이미지",
"tooltip": "비디오에서 마스킹된 객체를 교체하는 데 사용되는 이미지입니다."
},
"mask": {
"name": "마스크",
"tooltip": "비디오에서 교체할 영역을 정의하려면 마스크를 사용하세요."
},
"negative_prompt": {
"name": "부정 프롬프트"
},
"prompt_text": {
"name": "프롬프트 텍스트"
},
"region_to_modify": {
"name": "수정할 영역",
"tooltip": "수정할 객체/영역의 일반 텍스트 설명."
},
"seed": {
"name": "시드"
},
"video": {
"name": "비디오",
"tooltip": "객체를 교체할 비디오입니다."
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"PixverseImageToVideoNode": {
"description": "프롬프트와 output_size에 따라 동기적으로 비디오를 생성합니다.",
"display_name": "PixVerse 이미지에서 비디오로",

View File

@@ -1264,6 +1264,7 @@
"MiniMax": "MiniMax",
"Moonvalley Marey": "Moonvalley Marey",
"OpenAI": "OpenAI",
"Pika": "Pika",
"PixVerse": "PixVerse",
"Recraft": "Recraft",
"Rodin": "Rodin",

View File

@@ -8286,6 +8286,265 @@
}
}
},
"PikaImageToVideoNode2_2": {
"description": "Отправляет изображение и подсказку в Pika API v2.2 для генерации видео.",
"display_name": "Pika: преобразование изображения в видео",
"inputs": {
"control_after_generate": {
"name": "контроль после генерации"
},
"duration": {
"name": "длительность"
},
"image": {
"name": "изображение",
"tooltip": "Изображение для преобразования в видео"
},
"negative_prompt": {
"name": "негативная подсказка"
},
"prompt_text": {
"name": "текст подсказки"
},
"resolution": {
"name": "разрешение"
},
"seed": {
"name": "seed"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"PikaScenesV2_2": {
"description": "Объединяйте ваши изображения для создания видео с содержащимися в них объектами. Загрузите несколько изображений в качестве ингредиентов и создайте высококачественное видео, включающее все из них.",
"display_name": "Pika Scenes (Видеокомпозиция изображений)",
"inputs": {
"aspect_ratio": {
"name": "aspect_ratio",
"tooltip": "Соотношение сторон (ширина / высота)"
},
"control_after_generate": {
"name": "control after generate"
},
"duration": {
"name": "duration"
},
"image_ingredient_1": {
"name": "image_ingredient_1",
"tooltip": "Изображение, которое будет использовано как ингредиент для создания видео."
},
"image_ingredient_2": {
"name": "image_ingredient_2",
"tooltip": "Изображение, которое будет использовано как ингредиент для создания видео."
},
"image_ingredient_3": {
"name": "image_ingredient_3",
"tooltip": "Изображение, которое будет использовано как ингредиент для создания видео."
},
"image_ingredient_4": {
"name": "image_ingredient_4",
"tooltip": "Изображение, которое будет использовано как ингредиент для создания видео."
},
"image_ingredient_5": {
"name": "image_ingredient_5",
"tooltip": "Изображение, которое будет использовано как ингредиент для создания видео."
},
"ingredients_mode": {
"name": "ingredients_mode"
},
"negative_prompt": {
"name": "negative_prompt"
},
"prompt_text": {
"name": "prompt_text"
},
"resolution": {
"name": "resolution"
},
"seed": {
"name": "seed"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"PikaStartEndFrameNode2_2": {
"description": "Создайте видео, объединив первый и последний кадры. Загрузите два изображения, чтобы определить начальную и конечную точки, и позвольте ИИ создать плавный переход между ними.",
"display_name": "Pika: видео из начального и конечного кадров",
"inputs": {
"control_after_generate": {
"name": "control after generate"
},
"duration": {
"name": "duration"
},
"image_end": {
"name": "image_end",
"tooltip": "Последнее изображение для объединения."
},
"image_start": {
"name": "image_start",
"tooltip": "Первое изображение для объединения."
},
"negative_prompt": {
"name": "negative_prompt"
},
"prompt_text": {
"name": "prompt_text"
},
"resolution": {
"name": "resolution"
},
"seed": {
"name": "seed"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"PikaTextToVideoNode2_2": {
"description": "Отправляет текстовый запрос в Pika API v2.2 для генерации видео.",
"display_name": "Pika: Текст в видео",
"inputs": {
"aspect_ratio": {
"name": "соотношение сторон",
"tooltip": "Соотношение сторон (ширина / высота)"
},
"control_after_generate": {
"name": "управление после генерации"
},
"duration": {
"name": "длительность"
},
"negative_prompt": {
"name": "negative_prompt"
},
"prompt_text": {
"name": "prompt_text"
},
"resolution": {
"name": "разрешение"
},
"seed": {
"name": "seed"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"Pikadditions": {
"description": "Добавьте любой объект или изображение в ваше видео. Загрузите видео и укажите, что вы хотите добавить, чтобы получить гармонично интегрированный результат.",
"display_name": "Pikadditions (Вставка объектов в видео)",
"inputs": {
"control_after_generate": {
"name": "контроль после генерации"
},
"image": {
"name": "изображение",
"tooltip": "Изображение, которое будет добавлено в видео."
},
"negative_prompt": {
"name": "негативный запрос"
},
"prompt_text": {
"name": "текстовый запрос"
},
"seed": {
"name": "seed"
},
"video": {
"name": "видео",
"tooltip": "Видео, в которое будет добавлено изображение."
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"Pikaffects": {
"description": "Создайте видео с определённым Pikaffect. Поддерживаемые Pikaffects: Cake-ify, Crumble, Crush, Decapitate, Deflate, Dissolve, Explode, Eye-pop, Inflate, Levitate, Melt, Peel, Poke, Squish, Ta-da, Tear",
"display_name": "Pikaffects (Видеоэффекты)",
"inputs": {
"control_after_generate": {
"name": "контроль после генерации"
},
"image": {
"name": "изображение",
"tooltip": "Референсное изображение, к которому будет применён Pikaffect."
},
"negative_prompt": {
"name": "негативный запрос"
},
"pikaffect": {
"name": "pikaffect"
},
"prompt_text": {
"name": "текст запроса"
},
"seed": {
"name": "seed"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"Pikaswaps": {
"description": "Заменяйте любой объект или область на вашем видео новым изображением или объектом. Определяйте области для замены с помощью маски или координат.",
"display_name": "Pika Swaps (Замена объектов на видео)",
"inputs": {
"control_after_generate": {
"name": "контроль после генерации"
},
"image": {
"name": "изображение",
"tooltip": "Изображение, используемое для замены замаскированного объекта на видео."
},
"mask": {
"name": "маска",
"tooltip": "Используйте маску для определения областей на видео, которые нужно заменить"
},
"negative_prompt": {
"name": "негативный запрос"
},
"prompt_text": {
"name": "текстовый запрос"
},
"region_to_modify": {
"name": "область_для_изменения",
"tooltip": "Текстовое описание объекта / области для изменения."
},
"seed": {
"name": "seed"
},
"video": {
"name": "видео",
"tooltip": "Видео, в котором будет заменён объект."
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"PixverseImageToVideoNode": {
"description": "Синхронно генерирует видео на основе запроса и размера вывода.",
"display_name": "PixVerse: изображение в видео",

View File

@@ -1264,6 +1264,7 @@
"MiniMax": "MiniMax",
"Moonvalley Marey": "Moonvalley Marey",
"OpenAI": "OpenAI",
"Pika": "Pika",
"PixVerse": "PixVerse",
"Recraft": "Recraft",
"Rodin": "Rodin",

View File

@@ -8286,6 +8286,265 @@
}
}
},
"PikaImageToVideoNode2_2": {
"description": "Bir video oluşturmak için Pika API v2.2'ye bir görüntü ve istem gönderir.",
"display_name": "Pika Görüntüden Videoya",
"inputs": {
"control_after_generate": {
"name": "oluşturduktan sonra kontrol et"
},
"duration": {
"name": "süre"
},
"image": {
"name": "görüntü",
"tooltip": "Videoya dönüştürülecek görüntü"
},
"negative_prompt": {
"name": "negatif_istem"
},
"prompt_text": {
"name": "istem_metni"
},
"resolution": {
"name": "çözünürlük"
},
"seed": {
"name": "tohum"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"PikaScenesV2_2": {
"description": "İçlerindeki nesnelerle bir video oluşturmak için görüntülerinizi birleştirin. Malzeme olarak birden fazla görüntü yükleyin ve hepsini içeren yüksek kaliteli bir video oluşturun.",
"display_name": "Pika Sahneleri (Video Görüntü Kompozisyonu)",
"inputs": {
"aspect_ratio": {
"name": "en_boy_oranı",
"tooltip": "En boy oranı (genişlik / yükseklik)"
},
"control_after_generate": {
"name": "oluşturduktan sonra kontrol et"
},
"duration": {
"name": "süre"
},
"image_ingredient_1": {
"name": "görüntü_malzemesi_1",
"tooltip": "Video oluşturmak için malzeme olarak kullanılacak görüntü."
},
"image_ingredient_2": {
"name": "görüntü_malzemesi_2",
"tooltip": "Video oluşturmak için malzeme olarak kullanılacak görüntü."
},
"image_ingredient_3": {
"name": "görüntü_malzemesi_3",
"tooltip": "Video oluşturmak için malzeme olarak kullanılacak görüntü."
},
"image_ingredient_4": {
"name": "görüntü_malzemesi_4",
"tooltip": "Video oluşturmak için malzeme olarak kullanılacak görüntü."
},
"image_ingredient_5": {
"name": "görüntü_malzemesi_5",
"tooltip": "Video oluşturmak için malzeme olarak kullanılacak görüntü."
},
"ingredients_mode": {
"name": "malzemeler_modu"
},
"negative_prompt": {
"name": "negatif_istem"
},
"prompt_text": {
"name": "istem_metni"
},
"resolution": {
"name": "çözünürlük"
},
"seed": {
"name": "tohum"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"PikaStartEndFrameNode2_2": {
"description": "İlk ve son karenizi birleştirerek bir video oluşturun. Başlangıç ve bitiş noktalarını tanımlamak için iki görüntü yükleyin ve yapay zekanın aralarında pürüzsüz bir geçiş oluşturmasına izin verin.",
"display_name": "Pika Başlangıç ve Bitiş Karesinden Videoya",
"inputs": {
"control_after_generate": {
"name": "oluşturduktan sonra kontrol et"
},
"duration": {
"name": "süre"
},
"image_end": {
"name": "bitiş_görüntüsü",
"tooltip": "Birleştirilecek son görüntü."
},
"image_start": {
"name": "başlangıç_görüntüsü",
"tooltip": "Birleştirilecek ilk görüntü."
},
"negative_prompt": {
"name": "negatif_istem"
},
"prompt_text": {
"name": "istem_metni"
},
"resolution": {
"name": "çözünürlük"
},
"seed": {
"name": "tohum"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"PikaTextToVideoNode2_2": {
"description": "Bir video oluşturmak için Pika API v2.2'ye bir metin istemi gönderir.",
"display_name": "Pika Metinden Videoya",
"inputs": {
"aspect_ratio": {
"name": "en_boy_oranı",
"tooltip": "En boy oranı (genişlik / yükseklik)"
},
"control_after_generate": {
"name": "oluşturduktan sonra kontrol et"
},
"duration": {
"name": "süre"
},
"negative_prompt": {
"name": "negatif_istem"
},
"prompt_text": {
"name": "istem_metni"
},
"resolution": {
"name": "çözünürlük"
},
"seed": {
"name": "tohum"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"Pikadditions": {
"description": "Videonuzun içine herhangi bir nesne veya görüntü ekleyin. Bir video yükleyin ve sorunsuz bir şekilde entegre edilmiş bir sonuç oluşturmak için ne eklemek istediğinizi belirtin.",
"display_name": "Pikadditions (Video Nesne Ekleme)",
"inputs": {
"control_after_generate": {
"name": "oluşturduktan sonra kontrol et"
},
"image": {
"name": "görüntü",
"tooltip": "Videoya eklenecek görüntü."
},
"negative_prompt": {
"name": "negatif_istem"
},
"prompt_text": {
"name": "istem_metni"
},
"seed": {
"name": "tohum"
},
"video": {
"name": "video",
"tooltip": "Görüntü eklenecek video."
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"Pikaffects": {
"description": "Belirli bir Pikaffect ile bir video oluşturun. Desteklenen Pikaffect'ler: Cake-ify, Crumble, Crush, Decapitate, Deflate, Dissolve, Explode, Eye-pop, Inflate, Levitate, Melt, Peel, Poke, Squish, Ta-da, Tear",
"display_name": "Pikaffects (Video Efektleri)",
"inputs": {
"control_after_generate": {
"name": "oluşturduktan sonra kontrol et"
},
"image": {
"name": "görüntü",
"tooltip": "Pikaffect'in uygulanacağı referans görüntü."
},
"negative_prompt": {
"name": "negatif_istem"
},
"pikaffect": {
"name": "pikaffect"
},
"prompt_text": {
"name": "istem_metni"
},
"seed": {
"name": "tohum"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"Pikaswaps": {
"description": "Videonuzdaki herhangi bir nesneyi veya bölgeyi yeni bir görüntü veya nesneyle değiştirin. Değiştirilecek alanları bir maske veya koordinatlarla tanımlayın.",
"display_name": "Pika Değişimleri (Video Nesne Değiştirme)",
"inputs": {
"control_after_generate": {
"name": "oluşturduktan sonra kontrol et"
},
"image": {
"name": "görüntü",
"tooltip": "Videodaki maskelenmiş nesneyi değiştirmek için kullanılan görüntü."
},
"mask": {
"name": "maske",
"tooltip": "Videoda değiştirilecek alanları tanımlamak için maskeyi kullanın"
},
"negative_prompt": {
"name": "negatif_istem"
},
"prompt_text": {
"name": "istem_metni"
},
"region_to_modify": {
"name": "değiştirilecek_bölge",
"tooltip": "Değiştirilecek nesnenin/bölgenin düz metin açıklaması."
},
"seed": {
"name": "tohum"
},
"video": {
"name": "video",
"tooltip": "İçinde bir nesne değiştirilecek video."
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"PixverseImageToVideoNode": {
"description": "İstem ve çıktı_boyutuna göre videoları eşzamanlı olarak oluşturur.",
"display_name": "PixVerse Görüntüden Videoya",

View File

@@ -1264,6 +1264,7 @@
"MiniMax": "MiniMax",
"Moonvalley Marey": "月谷馬雷",
"OpenAI": "OpenAI",
"Pika": "Pika",
"PixVerse": "PixVerse",
"Recraft": "Recraft",
"Rodin": "羅丹",

View File

@@ -8286,6 +8286,265 @@
}
}
},
"PikaImageToVideoNode2_2": {
"description": "將圖像與提示詞發送至 Pika API v2.2 以生成影片。",
"display_name": "Pika 影像轉影片",
"inputs": {
"control_after_generate": {
"name": "生成後控制"
},
"duration": {
"name": "時長"
},
"image": {
"name": "圖像",
"tooltip": "要轉換為影片的圖像"
},
"negative_prompt": {
"name": "負向提示詞"
},
"prompt_text": {
"name": "提示詞"
},
"resolution": {
"name": "解析度"
},
"seed": {
"name": "種子"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"PikaScenesV2_2": {
"description": "結合您的圖片,創建包含其中物件的影片。上傳多張圖片作為素材,生成高品質且融合所有圖片內容的影片。",
"display_name": "Pika Scenes影片影像合成",
"inputs": {
"aspect_ratio": {
"name": "長寬比",
"tooltip": "長寬比(寬度 / 高度)"
},
"control_after_generate": {
"name": "生成後控制"
},
"duration": {
"name": "時長"
},
"image_ingredient_1": {
"name": "影像素材_1",
"tooltip": "將用作影片素材的圖片。"
},
"image_ingredient_2": {
"name": "影像素材_2",
"tooltip": "將用作影片素材的圖片。"
},
"image_ingredient_3": {
"name": "影像素材_3",
"tooltip": "將用作影片素材的圖片。"
},
"image_ingredient_4": {
"name": "影像素材_4",
"tooltip": "將用作影片素材的圖片。"
},
"image_ingredient_5": {
"name": "影像素材_5",
"tooltip": "將用作影片素材的圖片。"
},
"ingredients_mode": {
"name": "素材模式"
},
"negative_prompt": {
"name": "負向提示詞"
},
"prompt_text": {
"name": "提示文字"
},
"resolution": {
"name": "解析度"
},
"seed": {
"name": "種子"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"PikaStartEndFrameNode2_2": {
"description": "結合您的第一張與最後一張影像來產生影片。上傳兩張圖片以定義起點與終點,讓 AI 在它們之間創造平滑的過渡效果。",
"display_name": "Pika 首尾影格轉影片",
"inputs": {
"control_after_generate": {
"name": "生成後控制"
},
"duration": {
"name": "時長"
},
"image_end": {
"name": "結束影像",
"tooltip": "要結合的最後一張圖片。"
},
"image_start": {
"name": "起始影像",
"tooltip": "要結合的第一張圖片。"
},
"negative_prompt": {
"name": "負向提示詞"
},
"prompt_text": {
"name": "提示文字"
},
"resolution": {
"name": "解析度"
},
"seed": {
"name": "種子"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"PikaTextToVideoNode2_2": {
"description": "將文字提示發送至 Pika API v2.2 以生成影片。",
"display_name": "Pika 文字轉影片",
"inputs": {
"aspect_ratio": {
"name": "長寬比",
"tooltip": "長寬比(寬度 / 高度)"
},
"control_after_generate": {
"name": "生成後控制"
},
"duration": {
"name": "時長"
},
"negative_prompt": {
"name": "負向提示"
},
"prompt_text": {
"name": "提示文字"
},
"resolution": {
"name": "解析度"
},
"seed": {
"name": "種子"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"Pikadditions": {
"description": "將任何物件或圖片加入您的影片。上傳影片並指定您想加入的內容,創造無縫整合的效果。",
"display_name": "Pikadditions影片物件插入",
"inputs": {
"control_after_generate": {
"name": "生成後控制"
},
"image": {
"name": "影像",
"tooltip": "要加入到影片中的圖片。"
},
"negative_prompt": {
"name": "負向提示"
},
"prompt_text": {
"name": "提示文字"
},
"seed": {
"name": "種子"
},
"video": {
"name": "影片",
"tooltip": "要加入圖片的影片。"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"Pikaffects": {
"description": "以特定的 Pikaffect 產生影片。支援的 Pikaffect 包含Cake-ify、Crumble、Crush、Decapitate、Deflate、Dissolve、Explode、Eye-pop、Inflate、Levitate、Melt、Peel、Poke、Squish、Ta-da、Tear",
"display_name": "Pikaffects影片特效",
"inputs": {
"control_after_generate": {
"name": "生成後控制"
},
"image": {
"name": "影像",
"tooltip": "要套用 Pikaffect 的參考圖片。"
},
"negative_prompt": {
"name": "負向提示"
},
"pikaffect": {
"name": "Pikaffect"
},
"prompt_text": {
"name": "提示文字"
},
"seed": {
"name": "種子"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"Pikaswaps": {
"description": "將影片中的任何物件或區域以新圖片或物件進行替換。可使用遮罩或座標來定義要替換的區域。",
"display_name": "Pika Swaps影片物件替換",
"inputs": {
"control_after_generate": {
"name": "生成後控制"
},
"image": {
"name": "影像",
"tooltip": "用來替換影片中被遮罩物件的圖片。"
},
"mask": {
"name": "遮罩",
"tooltip": "使用遮罩來定義影片中要替換的區域"
},
"negative_prompt": {
"name": "負向提示"
},
"prompt_text": {
"name": "提示文字"
},
"region_to_modify": {
"name": "region_to_modify",
"tooltip": "要修改的物件/區域的純文字描述。"
},
"seed": {
"name": "種子"
},
"video": {
"name": "影片",
"tooltip": "要進行物件替換的影片。"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"PixverseImageToVideoNode": {
"description": "根據提示詞與輸出尺寸同步生成影片。",
"display_name": "PixVerse 影像轉影片",

View File

@@ -1264,6 +1264,7 @@
"MiniMax": "MiniMax",
"Moonvalley Marey": "Moonvalley Marey",
"OpenAI": "OpenAI",
"Pika": "Pika",
"PixVerse": "PixVerse",
"Recraft": "Recraft",
"Rodin": "罗丹",

View File

@@ -8286,6 +8286,265 @@
}
}
},
"PikaImageToVideoNode2_2": {
"description": "将图像和提示词发送到 Pika API v2.2 以生成视频。",
"display_name": "Pika 图像转视频",
"inputs": {
"control_after_generate": {
"name": "生成后控制"
},
"duration": {
"name": "时长"
},
"image": {
"name": "图像",
"tooltip": "要转换为视频的图像"
},
"negative_prompt": {
"name": "反向提示词"
},
"prompt_text": {
"name": "提示词"
},
"resolution": {
"name": "分辨率"
},
"seed": {
"name": "种子"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"PikaScenesV2_2": {
"description": "将你的图像组合在一起,生成包含所有物体的高质量视频。上传多张图片作为素材,生成融合所有内容的视频。",
"display_name": "Pika 场景(视频图像合成)",
"inputs": {
"aspect_ratio": {
"name": "宽高比",
"tooltip": "宽高比(宽 / 高)"
},
"control_after_generate": {
"name": "生成后控制"
},
"duration": {
"name": "时长"
},
"image_ingredient_1": {
"name": "图片素材 1",
"tooltip": "用于生成视频的图片素材。"
},
"image_ingredient_2": {
"name": "图片素材 2",
"tooltip": "用于生成视频的图片素材。"
},
"image_ingredient_3": {
"name": "图片素材 3",
"tooltip": "用于生成视频的图片素材。"
},
"image_ingredient_4": {
"name": "图片素材 4",
"tooltip": "用于生成视频的图片素材。"
},
"image_ingredient_5": {
"name": "图片素材 5",
"tooltip": "用于生成视频的图片素材。"
},
"ingredients_mode": {
"name": "素材模式"
},
"negative_prompt": {
"name": "反向提示词"
},
"prompt_text": {
"name": "提示词"
},
"resolution": {
"name": "分辨率"
},
"seed": {
"name": "随机种子"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"PikaStartEndFrameNode2_2": {
"description": "通过合成首帧和尾帧生成视频。上传两张图片以定义起点和终点,让 AI 在它们之间创建平滑过渡。",
"display_name": "Pika 首尾帧合成视频",
"inputs": {
"control_after_generate": {
"name": "生成后控制"
},
"duration": {
"name": "duration"
},
"image_end": {
"name": "image_end",
"tooltip": "要合成的最后一张图片。"
},
"image_start": {
"name": "image_start",
"tooltip": "要合成的第一张图片。"
},
"negative_prompt": {
"name": "negative_prompt"
},
"prompt_text": {
"name": "prompt_text"
},
"resolution": {
"name": "resolution"
},
"seed": {
"name": "seed"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"PikaTextToVideoNode2_2": {
"description": "将文本提示发送到 Pika API v2.2 以生成视频。",
"display_name": "Pika 文本转视频",
"inputs": {
"aspect_ratio": {
"name": "宽高比",
"tooltip": "宽高比(宽 / 高)"
},
"control_after_generate": {
"name": "生成后控制"
},
"duration": {
"name": "时长"
},
"negative_prompt": {
"name": "反向提示"
},
"prompt_text": {
"name": "提示文本"
},
"resolution": {
"name": "分辨率"
},
"seed": {
"name": "种子"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"Pikadditions": {
"description": "将任意对象或图像添加到你的视频中。上传一个视频并指定你想要添加的内容,实现无缝集成的效果。",
"display_name": "Pikadditions视频对象插入",
"inputs": {
"control_after_generate": {
"name": "生成后控制"
},
"image": {
"name": "图像",
"tooltip": "要添加到视频中的图像。"
},
"negative_prompt": {
"name": "反向提示词"
},
"prompt_text": {
"name": "提示词"
},
"seed": {
"name": "种子"
},
"video": {
"name": "视频",
"tooltip": "要添加图像的视频。"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"Pikaffects": {
"description": "生成带有特定Pikaffect的视频。支持的Pikaffect有Cake-ify、Crumble、Crush、Decapitate、Deflate、Dissolve、Explode、Eye-pop、Inflate、Levitate、Melt、Peel、Poke、Squish、Ta-da、Tear",
"display_name": "Pikaffects视频特效",
"inputs": {
"control_after_generate": {
"name": "生成后控制"
},
"image": {
"name": "image",
"tooltip": "要应用Pikaffect的参考图像。"
},
"negative_prompt": {
"name": "negative_prompt"
},
"pikaffect": {
"name": "pikaffect"
},
"prompt_text": {
"name": "prompt_text"
},
"seed": {
"name": "seed"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"Pikaswaps": {
"description": "用新图像或对象替换视频中的任意对象或区域。可通过 mask 或坐标定义需要替换的区域。",
"display_name": "Pika Swaps视频对象替换",
"inputs": {
"control_after_generate": {
"name": "生成后控制"
},
"image": {
"name": "图像",
"tooltip": "用于替换视频中被 mask 的对象的图像。"
},
"mask": {
"name": "mask",
"tooltip": "使用 mask 定义视频中需要替换的区域"
},
"negative_prompt": {
"name": "反向提示词"
},
"prompt_text": {
"name": "提示词"
},
"region_to_modify": {
"name": "region_to_modify",
"tooltip": "要修改的对象/区域的纯文本描述。"
},
"seed": {
"name": "种子"
},
"video": {
"name": "视频",
"tooltip": "要在其中替换对象的视频。"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"PixverseImageToVideoNode": {
"description": "根据提示词和输出尺寸同步生成视频。",
"display_name": "PixVerse 图像转视频",

View File

@@ -55,6 +55,7 @@
variant="gray"
:label="formattedDuration"
/>
<SquareChip v-if="fileFormat" variant="gray" :label="fileFormat" />
</div>
<!-- Media actions - show on hover or when playing -->
@@ -265,6 +266,12 @@ const formattedDuration = computed(() => {
return formatDuration(Number(duration))
})
const fileFormat = computed(() => {
if (!asset?.name) return ''
const parts = asset.name.split('.')
return parts.length > 1 ? parts[parts.length - 1].toUpperCase() : ''
})
const durationChipClasses = computed(() => {
if (fileKind.value === 'audio') {
return '-translate-y-11'
@@ -282,7 +289,7 @@ const showStaticChips = computed(
!!asset &&
!isHovered.value &&
!isVideoPlaying.value &&
formattedDuration.value
(formattedDuration.value || fileFormat.value)
)
// Show action overlay when hovered OR playing

View File

@@ -93,12 +93,6 @@ export const useLitegraphSettings = () => {
if (canvas) canvas.dragZoomEnabled = dragZoomEnabled
})
watchEffect(() => {
const liveSelection = settingStore.get('Comfy.Graph.LiveSelection')
const { canvas } = canvasStore
if (canvas) canvas.liveSelection = liveSelection
})
watchEffect(() => {
CanvasPointer.doubleClickTime = settingStore.get(
'Comfy.Pointer.DoubleClickTime'

View File

@@ -706,16 +706,6 @@ export const CORE_SETTINGS: SettingParams[] = [
defaultValue: true,
versionAdded: '1.4.0'
},
{
id: 'Comfy.Graph.LiveSelection',
category: ['LiteGraph', 'Canvas', 'LiveSelection'],
name: 'Live selection',
tooltip:
'When enabled, nodes are selected/deselected in real-time as you drag the selection rectangle, similar to other design tools.',
type: 'boolean',
defaultValue: false,
versionAdded: '1.36.1'
},
{
id: 'Comfy.Pointer.ClickDrift',
category: ['LiteGraph', 'Pointer', 'ClickDrift'],

View File

@@ -20,13 +20,13 @@
<!-- Connection Dot -->
<SlotConnectionDot
ref="connectionDotRef"
:color="slotColor"
:class="
cn(
'-translate-x-1/2 w-3',
hasSlotError && 'ring-2 ring-error ring-offset-0 rounded-full'
)
"
:slot-data
@click="onClick"
@dblclick="onDoubleClick"
@pointerdown="onPointerDown"
@@ -54,6 +54,7 @@ import { computed, onErrorCaptured, ref, watchEffect } from 'vue'
import type { ComponentPublicInstance } from 'vue'
import { useErrorHandling } from '@/composables/useErrorHandling'
import { getSlotColor } from '@/constants/slotColors'
import type { INodeSlot } from '@/lib/litegraph/src/litegraph'
import { useSlotLinkDragUIState } from '@/renderer/core/canvas/links/slotLinkDragUIState'
import { getSlotKey } from '@/renderer/core/layout/slots/slotIdentifier'
@@ -110,6 +111,13 @@ onErrorCaptured((error) => {
return false
})
const slotColor = computed(() => {
if (hasSlotError.value) {
return 'var(--color-error)'
}
return getSlotColor(props.slotData.type)
})
const { state: dragState } = useSlotLinkDragUIState()
const slotKey = computed(() =>
getSlotKey(props.nodeId ?? '', props.index, true)

Some files were not shown because too many files have changed in this diff Show More