Compare commits
2 Commits
remove-que
...
graph-stat
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
71401b1059 | ||
|
|
076acf1b31 |
59
.github/workflows/pr-backport.yaml
vendored
@@ -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
|
||||
|
||||
124
.github/workflows/release-version-bump.yaml
vendored
@@ -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
|
||||
|
||||
@@ -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
|
||||
}>()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
Before Width: | Height: | Size: 422 B |
@@ -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
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
Before Width: | Height: | Size: 44 KiB |
@@ -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'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
Before Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 22 KiB |
@@ -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 })
|
||||
})
|
||||
})
|
||||
|
||||
|
Before Width: | Height: | Size: 118 KiB After Width: | Height: | Size: 117 KiB |
|
Before Width: | Height: | Size: 58 KiB After Width: | Height: | Size: 57 KiB |
|
Before Width: | Height: | Size: 31 KiB After Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 63 KiB After Width: | Height: | Size: 63 KiB |
|
Before Width: | Height: | Size: 62 KiB After Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 63 KiB After Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 65 KiB After Width: | Height: | Size: 64 KiB |
|
Before Width: | Height: | Size: 67 KiB After Width: | Height: | Size: 64 KiB |
|
Before Width: | Height: | Size: 64 KiB After Width: | Height: | Size: 64 KiB |
|
Before Width: | Height: | Size: 61 KiB After Width: | Height: | Size: 61 KiB |
|
Before Width: | Height: | Size: 60 KiB After Width: | Height: | Size: 60 KiB |
|
Before Width: | Height: | Size: 62 KiB After Width: | Height: | Size: 61 KiB |
|
Before Width: | Height: | Size: 64 KiB After Width: | Height: | Size: 64 KiB |
|
Before Width: | Height: | Size: 63 KiB After Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 65 KiB After Width: | Height: | Size: 65 KiB |
|
Before Width: | Height: | Size: 101 KiB After Width: | Height: | Size: 100 KiB |
|
Before Width: | Height: | Size: 112 KiB After Width: | Height: | Size: 112 KiB |
|
Before Width: | Height: | Size: 112 KiB After Width: | Height: | Size: 111 KiB |
|
Before Width: | Height: | Size: 142 KiB After Width: | Height: | Size: 141 KiB |
|
Before Width: | Height: | Size: 144 KiB After Width: | Height: | Size: 143 KiB |
|
Before Width: | Height: | Size: 112 KiB After Width: | Height: | Size: 111 KiB |
@@ -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())
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 |
@@ -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 |
@@ -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 }
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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
|
||||
}"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
unstyled
|
||||
:pt="{
|
||||
root: {
|
||||
class: 'absolute z-60'
|
||||
class: 'absolute z-[60]'
|
||||
},
|
||||
content: {
|
||||
class: [
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
90
src/components/queue/dialogs/QueueClearHistoryDialog.vue
Normal 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>
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -27,7 +27,6 @@
|
||||
<ScrollPanel class="comfy-vue-side-bar-body h-0 grow">
|
||||
<slot name="body" />
|
||||
</ScrollPanel>
|
||||
<slot name="footer" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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'
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -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[] => {
|
||||
|
||||
@@ -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'],
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
})
|
||||
@@ -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.',
|
||||
|
||||
130
src/core/graph/state/graphStateStore.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
})
|
||||
79
src/core/graph/state/graphStateStore.ts
Normal 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
|
||||
}
|
||||
})
|
||||
45
src/core/graph/state/useGraphErrorState.ts
Normal 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 }
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 []
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1264,6 +1264,7 @@
|
||||
"MiniMax": "MiniMax",
|
||||
"Moonvalley Marey": "مون فالي ماري",
|
||||
"OpenAI": "OpenAI",
|
||||
"Pika": "Pika",
|
||||
"PixVerse": "PixVerse",
|
||||
"Recraft": "Recraft",
|
||||
"Rodin": "رودان",
|
||||
|
||||
@@ -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": "بيكسفيرس صورة إلى فيديو",
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -1264,6 +1264,7 @@
|
||||
"MiniMax": "MiniMax",
|
||||
"Moonvalley Marey": "Moonvalley Marey",
|
||||
"OpenAI": "OpenAI",
|
||||
"Pika": "Pika",
|
||||
"PixVerse": "PixVerse",
|
||||
"Recraft": "Recraft",
|
||||
"Rodin": "Rodin",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1264,6 +1264,7 @@
|
||||
"MiniMax": "MiniMax",
|
||||
"Moonvalley Marey": "Moonvalley Marey",
|
||||
"OpenAI": "OpenAI",
|
||||
"Pika": "Pika",
|
||||
"PixVerse": "PixVerse",
|
||||
"Recraft": "Recraft",
|
||||
"Rodin": "Rodin",
|
||||
|
||||
@@ -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 d’arrivée, et laissez l’IA créer une transition fluide entre elles.",
|
||||
"display_name": "Pika Début et Fin d’Image 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": "L’image 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 n’importe 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 d’objet vidéo)",
|
||||
"inputs": {
|
||||
"control_after_generate": {
|
||||
"name": "contrôle après génération"
|
||||
},
|
||||
"image": {
|
||||
"name": "image",
|
||||
"tooltip": "L’image utilisée pour remplacer l’objet 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 d’invite"
|
||||
},
|
||||
"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",
|
||||
|
||||
@@ -1264,6 +1264,7 @@
|
||||
"MiniMax": "MiniMax",
|
||||
"Moonvalley Marey": "Moonvalley Marey",
|
||||
"OpenAI": "OpenAI",
|
||||
"Pika": "Pika",
|
||||
"PixVerse": "PixVerse",
|
||||
"Recraft": "Recraft",
|
||||
"Rodin": "Rodin",
|
||||
|
||||
@@ -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を使ってビデオを生成します。対応している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": "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 画像から動画へ",
|
||||
|
||||
@@ -1264,6 +1264,7 @@
|
||||
"MiniMax": "MiniMax",
|
||||
"Moonvalley Marey": "Moonvalley Marey",
|
||||
"OpenAI": "OpenAI",
|
||||
"Pika": "Pika",
|
||||
"PixVerse": "PixVerse",
|
||||
"Recraft": "Recraft",
|
||||
"Rodin": "Rodin",
|
||||
|
||||
@@ -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 이미지에서 비디오로",
|
||||
|
||||
@@ -1264,6 +1264,7 @@
|
||||
"MiniMax": "MiniMax",
|
||||
"Moonvalley Marey": "Moonvalley Marey",
|
||||
"OpenAI": "OpenAI",
|
||||
"Pika": "Pika",
|
||||
"PixVerse": "PixVerse",
|
||||
"Recraft": "Recraft",
|
||||
"Rodin": "Rodin",
|
||||
|
||||
@@ -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: изображение в видео",
|
||||
|
||||
@@ -1264,6 +1264,7 @@
|
||||
"MiniMax": "MiniMax",
|
||||
"Moonvalley Marey": "Moonvalley Marey",
|
||||
"OpenAI": "OpenAI",
|
||||
"Pika": "Pika",
|
||||
"PixVerse": "PixVerse",
|
||||
"Recraft": "Recraft",
|
||||
"Rodin": "Rodin",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1264,6 +1264,7 @@
|
||||
"MiniMax": "MiniMax",
|
||||
"Moonvalley Marey": "月谷馬雷",
|
||||
"OpenAI": "OpenAI",
|
||||
"Pika": "Pika",
|
||||
"PixVerse": "PixVerse",
|
||||
"Recraft": "Recraft",
|
||||
"Rodin": "羅丹",
|
||||
|
||||
@@ -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 影像轉影片",
|
||||
|
||||
@@ -1264,6 +1264,7 @@
|
||||
"MiniMax": "MiniMax",
|
||||
"Moonvalley Marey": "Moonvalley Marey",
|
||||
"OpenAI": "OpenAI",
|
||||
"Pika": "Pika",
|
||||
"PixVerse": "PixVerse",
|
||||
"Recraft": "Recraft",
|
||||
"Rodin": "罗丹",
|
||||
|
||||
@@ -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 图像转视频",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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'],
|
||||
|
||||
@@ -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)
|
||||
|
||||