27 Commits

Author SHA1 Message Date
  GitHub Action ee04b33e48 [automated] Apply ESLint and Prettier fixes 1 day ago
  Johnpaul Chiwetelu 2ac7aad7e1
Merge branch 'main' into feat/primevue-node-context-menu 1 day ago
  Johnpaul 9c7198da3f fix: remove duplicate menu option calls in useMoreOptionsMenu 1 day ago
  Johnpaul db164fbbd3 fix: align ColorPickerMenu toggle signature with PrimeVue API 1 day ago
  Johnpaul c435335c28 fix: use optional chaining for option.action and simplify toggle 1 day ago
  Johnpaul 43eb5eccb4 refactor: simplify LGraphNode toggle call 1 day ago
  Johnpaul cf695dbf6f refactor: simplify NodeOptionsButton toggle call 1 day ago
  Johnpaul c8774de6f3 refactor: simplify toggleNodeOptions to single event parameter 1 day ago
  Johnpaul 3ec74339e3 test: replace waitForTimeout with animation frame loop 1 day ago
  AustinMroz 0ad5509037
Fix selecting loras on cloud (#7560) 1 day ago
  Johnpaul 2af166cc56 fix: only update menu position when canvas transform changes 1 day ago
  Kelly Yang 97386b0a14
Fix: the wrong selection under the hand mode (#7541) 1 day ago
  Alexander Brown d448421263
Fix: Restore assets API short-circuit in WidgetSelectDropdown (#7563) 2 days ago
  Christian Byrne 6391bd89bf
feat: auto-focus the searchbox in asset dropdowns (#7554) 2 days ago
  Christian Byrne 786c3b8c12
cleanup: remove now-unused stripe pricing table remnants (#7557) 2 days ago
  Christian Byrne a64597b4f8
feat: improve vue node video preview loading and a11y (#7558) 2 days ago
  Alexander Brown 89e67b1558
Deps: Update Storybook to v10 (#7559) 2 days ago
  Christian Byrne 480711deb7
i18n: add missing translation keys (#7436) 2 days ago
  Benjamin Lu 93195d3274
feat(server-config): restart required toast (#7479) 2 days ago
  Alexander Piskun 5d1bf6dfb3
fix(api-nodes-pricing): adjust prices for Tripo3D (#6828) 2 days ago
  AustinMroz 3ee6d53423
Make node inputs reactive in vue (#7546) 2 days ago
  AustinMroz 4e257bedca
Fix widget reactivity (#7539) 2 days ago
  AustinMroz 108cfaaa4b
Support display of multitype slots (#7457) 2 days ago
  AustinMroz 5d745c952a
Feat: Fixed option for control after/before generate (#7517) 2 days ago
  Comfy Org PR Bot fddd703c4e
1.36.2 (#7533) 2 days ago
  Terry Jia dec929909b
live selection (#7465) 2 days ago
  Alexander Brown 18ce8e940a
Fix: Add slot for footer (used by Assets Sidebar) (#7532) 2 days ago
58 changed files with 2539 additions and 3511 deletions
Split View
  1. +0
    -4
      .env_example
  2. +2
    -1
      .storybook/preview.ts
  3. +1
    -1
      CLAUDE.md
  4. +7
    -4
      browser_tests/tests/selectionToolboxSubmenus.spec.ts
  5. +3
    -2
      eslint.config.ts
  6. +0
    -2
      global.d.ts
  7. +3
    -3
      package.json
  8. +19
    -0
      packages/design-system/src/icons/nodeSlot2.svg
  9. +20
    -0
      packages/design-system/src/icons/nodeSlot3.svg
  10. +1773
    -448
      pnpm-lock.yaml
  11. +7
    -6
      pnpm-workspace.yaml
  12. +21
    -8
      src/components/graph/NodeContextMenu.vue
  13. +1
    -1
      src/components/graph/selectionToolbox/ColorPickerMenu.vue
  14. +1
    -9
      src/components/graph/selectionToolbox/NodeOptionsButton.vue
  15. +1
    -0
      src/components/sidebar/tabs/SidebarTabTemplate.vue
  16. +13
    -4
      src/composables/graph/useGraphNodeManager.ts
  17. +18
    -48
      src/composables/graph/useMoreOptionsMenu.ts
  18. +249
    -166
      src/composables/node/useNodePricing.ts
  19. +135
    -39
      src/lib/litegraph/src/LGraphCanvas.ts
  20. +48
    -8
      src/lib/litegraph/src/node/NodeSlot.ts
  21. +0
    -1
      src/locales/ar/main.json
  22. +0
    -259
      src/locales/ar/nodeDefs.json
  23. +10
    -8
      src/locales/en/main.json
  24. +0
    -259
      src/locales/en/nodeDefs.json
  25. +0
    -1
      src/locales/es/main.json
  26. +0
    -259
      src/locales/es/nodeDefs.json
  27. +0
    -1
      src/locales/fr/main.json
  28. +0
    -259
      src/locales/fr/nodeDefs.json
  29. +0
    -1
      src/locales/ja/main.json
  30. +0
    -259
      src/locales/ja/nodeDefs.json
  31. +0
    -1
      src/locales/ko/main.json
  32. +0
    -259
      src/locales/ko/nodeDefs.json
  33. +0
    -1
      src/locales/ru/main.json
  34. +0
    -259
      src/locales/ru/nodeDefs.json
  35. +0
    -1
      src/locales/tr/main.json
  36. +0
    -259
      src/locales/tr/nodeDefs.json
  37. +0
    -1
      src/locales/zh-TW/main.json
  38. +0
    -259
      src/locales/zh-TW/nodeDefs.json
  39. +0
    -1
      src/locales/zh/main.json
  40. +0
    -259
      src/locales/zh/nodeDefs.json
  41. +0
    -2
      src/platform/remoteConfig/types.ts
  42. +24
    -1
      src/platform/settings/components/ServerConfigPanel.vue
  43. +6
    -0
      src/platform/settings/composables/useLitegraphSettings.ts
  44. +10
    -0
      src/platform/settings/constants/coreSettings.ts
  45. +42
    -19
      src/renderer/extensions/vueNodes/VideoPreview.vue
  46. +1
    -9
      src/renderer/extensions/vueNodes/components/InputSlot.vue
  47. +2
    -4
      src/renderer/extensions/vueNodes/components/LGraphNode.vue
  48. +2
    -6
      src/renderer/extensions/vueNodes/components/NodeWidgets.vue
  49. +1
    -5
      src/renderer/extensions/vueNodes/components/OutputSlot.vue
  50. +47
    -15
      src/renderer/extensions/vueNodes/components/SlotConnectionDot.vue
  51. +16
    -49
      src/renderer/extensions/vueNodes/widgets/components/NumberControlPopover.vue
  52. +1
    -1
      src/renderer/extensions/vueNodes/widgets/components/WidgetInputNumberInput.vue
  53. +3
    -11
      src/renderer/extensions/vueNodes/widgets/components/WidgetInputNumberWithControl.vue
  54. +3
    -0
      src/renderer/extensions/vueNodes/widgets/components/WidgetSelectDropdown.vue
  55. +1
    -0
      src/renderer/extensions/vueNodes/widgets/components/form/dropdown/FormDropdownMenuActions.vue
  56. +1
    -0
      src/schemas/apiSchema.ts
  57. +1
    -2
      src/vite-env.d.ts
  58. +46
    -26
      tests-ui/tests/composables/node/useNodePricing.test.ts

+ 0
- 4
.env_example View File

@@ -42,7 +42,3 @@ ALGOLIA_API_KEY=684d998c36b67a9a9fce8fc2d8860579
# SENTRY_AUTH_TOKEN=private-token # get from sentry
# SENTRY_ORG=comfy-org
# SENTRY_PROJECT=cloud-frontend-staging

# Stripe pricing table configuration (used by feature-flagged subscription tiers UI)
# VITE_STRIPE_PUBLISHABLE_KEY=pk_test_123
# VITE_STRIPE_PRICING_TABLE_ID=prctbl_123

+ 2
- 1
.storybook/preview.ts View File

@@ -9,9 +9,9 @@ import ConfirmationService from 'primevue/confirmationservice'
import ToastService from 'primevue/toastservice'
import Tooltip from 'primevue/tooltip'

import '@/assets/css/style.css'
import { i18n } from '@/i18n'
import '@/lib/litegraph/public/css/litegraph.css'
import '@/assets/css/style.css'

const ComfyUIPreset = definePreset(Aura, {
semantic: {
@@ -58,6 +58,7 @@ export const withTheme = (Story: StoryFn, context: StoryContext) => {
document.documentElement.classList.remove('dark-theme')
document.body.classList.remove('dark-theme')
}
document.body.classList.add('[&_*]:!font-inter')

return Story(context.args, context)
}


+ 1
- 1
CLAUDE.md View File

@@ -12,7 +12,7 @@ For first-time setup, use the Claude command:

This bootstraps the monorepo with dependencies, builds, tests, and dev server verification.

**Prerequisites:** Node.js >= 24, Git repository, available ports (5173, 6006)
**Prerequisites:** Node.js >= 24, Git repository, available ports for dev server, storybook, etc.

## Development Workflow



+ 7
- 4
browser_tests/tests/selectionToolboxSubmenus.spec.ts View File

@@ -136,10 +136,13 @@ test.describe('Selection Toolbox - More Options Submenus', () => {
comfyPage
}) => {
await openMoreOptions(comfyPage)
await expect(
comfyPage.page.getByText('Rename', { exact: true })
).toBeVisible({ timeout: 5000 })
await comfyPage.page.waitForTimeout(500)
const renameItem = comfyPage.page.getByText('Rename', { exact: true })
await expect(renameItem).toBeVisible({ timeout: 5000 })

// Wait for multiple frames to allow PrimeVue's outside click handler to initialize
for (let i = 0; i < 30; i++) {
await comfyPage.nextFrame()
}

await comfyPage.page
.locator('#graph-canvas')


+ 3
- 2
eslint.config.ts View File

@@ -5,7 +5,7 @@ import { createTypeScriptImportResolver } from 'eslint-import-resolver-typescrip
import { importX } from 'eslint-plugin-import-x'
import oxlint from 'eslint-plugin-oxlint'
import eslintPluginPrettierRecommended from 'eslint-plugin-prettier/recommended'
import storybook from 'eslint-plugin-storybook'
import { configs as storybookConfigs } from 'eslint-plugin-storybook'
import unusedImports from 'eslint-plugin-unused-imports'
import pluginVue from 'eslint-plugin-vue'
import { defineConfig } from 'eslint/config'
@@ -109,7 +109,8 @@ export default defineConfig([
// Difference in typecheck on CI vs Local
pluginVue.configs['flat/recommended'],
eslintPluginPrettierRecommended,
storybook.configs['flat/recommended'],
// @ts-expect-error Type incompatibility between storybook plugin and ESLint config types
storybookConfigs['flat/recommended'],
// @ts-expect-error Type incompatibility between import-x plugin and ESLint config types
importX.flatConfigs.recommended,
// @ts-expect-error Type incompatibility between import-x plugin and ESLint config types


+ 0
- 2
global.d.ts View File

@@ -13,8 +13,6 @@ interface Window {
max_upload_size?: number
comfy_api_base_url?: string
comfy_platform_base_url?: string
stripe_publishable_key?: string
stripe_pricing_table_id?: string
firebase_config?: {
apiKey: string
authDomain: string


+ 3
- 3
package.json View File

@@ -1,7 +1,7 @@
{
"name": "@comfyorg/comfyui-frontend",
"private": true,
"version": "1.36.1",
"version": "1.36.2",
"type": "module",
"repository": "https://github.com/Comfy-Org/ComfyUI_frontend",
"homepage": "https://comfy.org",
@@ -40,7 +40,7 @@
"preinstall": "pnpm dlx only-allow pnpm",
"prepare": "husky || true && git config blame.ignoreRevsFile .git-blame-ignore-revs || true",
"preview": "nx preview",
"storybook": "nx storybook -p 6006",
"storybook": "nx storybook",
"stylelint:fix": "stylelint --cache --fix '{apps,packages,src}/**/*.{css,vue}'",
"stylelint": "stylelint --cache '{apps,packages,src}/**/*.{css,vue}'",
"test:browser": "pnpm exec nx e2e",
@@ -175,7 +175,7 @@
"pinia": "catalog:",
"primeicons": "catalog:",
"primevue": "catalog:",
"reka-ui": "^2.5.0",
"reka-ui": "catalog:",
"semver": "^7.7.2",
"three": "^0.170.0",
"tiptap-markdown": "^0.8.10",


+ 19
- 0
packages/design-system/src/icons/nodeSlot2.svg View File

@@ -0,0 +1,19 @@
<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>

+ 20
- 0
packages/design-system/src/icons/nodeSlot3.svg View File

@@ -0,0 +1,20 @@
<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>

+ 1773
- 448
pnpm-lock.yaml
File diff suppressed because it is too large
View File


+ 7
- 6
pnpm-workspace.yaml View File

@@ -13,7 +13,7 @@ catalog:
'@lobehub/i18n-cli': ^1.25.1
'@nx/eslint': 21.4.1
'@nx/playwright': 21.4.1
'@nx/storybook': 21.4.1
'@nx/storybook': 22.2.4
'@nx/vite': 21.4.1
'@pinia/testing': ^0.1.5
'@playwright/test': ^1.52.0
@@ -27,9 +27,9 @@ catalog:
'@primevue/themes': ^4.2.5
'@sentry/vite-plugin': ^4.6.0
'@sentry/vue': ^8.48.0
'@storybook/addon-docs': ^9.1.1
'@storybook/vue3': ^9.1.1
'@storybook/vue3-vite': ^9.1.1
'@storybook/addon-docs': ^10.1.9
'@storybook/vue3': ^10.1.9
'@storybook/vue3-vite': ^10.1.9
'@tailwindcss/vite': ^4.1.12
'@trivago/prettier-plugin-sort-imports': ^5.2.0
'@types/fs-extra': ^11.0.4
@@ -54,7 +54,7 @@ catalog:
eslint-plugin-import-x: ^4.16.1
eslint-plugin-oxlint: 1.25.0
eslint-plugin-prettier: ^5.5.4
eslint-plugin-storybook: ^9.1.16
eslint-plugin-storybook: ^10.1.9
eslint-plugin-unused-imports: ^4.3.0
eslint-plugin-vue: ^10.6.2
firebase: ^11.6.0
@@ -77,8 +77,9 @@ catalog:
pretty-bytes: ^7.1.0
primeicons: ^7.0.0
primevue: ^4.2.5
reka-ui: ^2.5.0
rollup-plugin-visualizer: ^6.0.4
storybook: ^9.1.16
storybook: ^10.1.9
stylelint: ^16.26.1
tailwindcss: ^4.1.12
tailwindcss-primeui: ^0.6.1


+ 21
- 8
src/components/graph/NodeContextMenu.vue View File

@@ -76,6 +76,11 @@ const worldPosition = ref({ x: 0, y: 0 })
const lgCanvas = canvasStore.getCanvas()
const { left: canvasLeft, top: canvasTop } = useElementBounding(lgCanvas.canvas)

// Track last canvas transform to detect actual changes
let lastScale = 0
let lastOffsetX = 0
let lastOffsetY = 0

// Update menu position based on canvas transform
const updateMenuPosition = () => {
if (!isOpen.value) return
@@ -88,6 +93,19 @@ const updateMenuPosition = () => {

const { scale, offset } = lgCanvas.ds

// Only update if canvas transform actually changed
if (
scale === lastScale &&
offset[0] === lastOffsetX &&
offset[1] === lastOffsetY
) {
return
}

lastScale = scale
lastOffsetX = offset[0]
lastOffsetY = offset[1]

// Convert world position to screen position
const screenX = (worldPosition.value.x + offset[0]) * scale + canvasLeft.value
const screenY = (worldPosition.value.y + offset[1]) * scale + canvasTop.value
@@ -173,7 +191,7 @@ function convertToMenuItem(option: MenuOption): ExtendedMenuItem {
// Regular action items
if (!option.hasSubmenu && option.action) {
item.command = () => {
option.action!()
option.action?.()
hide()
}
}
@@ -211,12 +229,7 @@ function hide() {
contextMenu.value?.hide()
}

// Toggle function for compatibility
function toggle(
event: Event,
_element?: HTMLElement,
_clickedFromToolbox?: boolean
) {
function toggle(event: Event) {
if (isOpen.value) {
hide()
} else {
@@ -232,7 +245,7 @@ function showColorPopover(event: MouseEvent) {
const target = Array.from((event.currentTarget as HTMLElement).children).find(
(el) => el.classList.contains('icon-[lucide--chevron-right]')
) as HTMLElement
colorPickerMenu.value?.toggle(target, event)
colorPickerMenu.value?.toggle(event, target)
}

// Handle color selection


+ 1
- 1
src/components/graph/selectionToolbox/ColorPickerMenu.vue View File

@@ -87,7 +87,7 @@ const { getCurrentShape } = useNodeCustomization()

const popoverRef = ref<InstanceType<typeof Popover>>()

const toggle = (target: HTMLElement, event: Event) => {
const toggle = (event: Event, target?: HTMLElement) => {
popoverRef.value?.toggle(event, target)
}
defineExpose({


+ 1
- 9
src/components/graph/selectionToolbox/NodeOptionsButton.vue View File

@@ -1,6 +1,5 @@
<template>
<Button
ref="buttonRef"
v-tooltip.top="{
value: $t('g.moreOptions'),
showDelay: 1000
@@ -17,17 +16,10 @@

<script setup lang="ts">
import Button from 'primevue/button'
import { ref } from 'vue'

import { toggleNodeOptions } from '@/composables/graph/useMoreOptionsMenu'

const buttonRef = ref<InstanceType<typeof Button> | null>(null)

const handleClick = (event: Event) => {
const el = (buttonRef.value as any)?.$el || buttonRef.value
const buttonEl = el instanceof HTMLElement ? el : null
if (buttonEl) {
toggleNodeOptions(event, buttonEl, true)
}
toggleNodeOptions(event)
}
</script>

+ 1
- 0
src/components/sidebar/tabs/SidebarTabTemplate.vue View File

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



+ 13
- 4
src/composables/graph/useGraphNodeManager.ts View File

@@ -218,6 +218,15 @@ 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) => {
@@ -252,7 +261,7 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
badges,
hasErrors: !!node.has_errors,
widgets: safeWidgets,
inputs: node.inputs ? [...node.inputs] : undefined,
inputs: reactiveInputs,
outputs: node.outputs ? [...node.outputs] : undefined,
flags: node.flags ? { ...node.flags } : undefined,
color: node.color || undefined,
@@ -328,7 +337,7 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
* Creates a wrapped callback for a widget that maintains LiteGraph/Vue sync
*/
const createWrappedWidgetCallback = (
widget: { value?: unknown; name: string }, // LiteGraph widget with minimal typing
widget: IBaseWidget, // LiteGraph widget with minimal typing
originalCallback: ((value: unknown) => void) | undefined,
nodeId: string
) => {
@@ -355,10 +364,10 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
}

// Always update widget.value to ensure sync
widget.value = value
widget.value = value ?? undefined

// 2. Call the original callback if it exists
if (originalCallback) {
if (originalCallback && widget.type !== 'asset') {
originalCallback.call(widget, value)
}



+ 18
- 48
src/composables/graph/useMoreOptionsMenu.ts View File

@@ -48,15 +48,10 @@ let nodeOptionsInstance: null | NodeOptionsInstance = null
/**
* Toggle the node options popover
* @param event - The trigger event
* @param element - The target element (button) that triggered the popover
*/
export function toggleNodeOptions(
event: Event,
element: HTMLElement,
clickedFromToolbox: boolean = false
) {
export function toggleNodeOptions(event: Event) {
if (nodeOptionsInstance?.toggle) {
nodeOptionsInstance.toggle(event, element, clickedFromToolbox)
nodeOptionsInstance.toggle(event)
}
}

@@ -64,11 +59,7 @@ export function toggleNodeOptions(
* Hide the node options popover
*/
interface NodeOptionsInstance {
toggle: (
event: Event,
element: HTMLElement,
clickedFromToolbox: boolean
) => void
toggle: (event: Event) => void
hide: () => void
isOpen: Ref<boolean>
}
@@ -203,51 +194,32 @@ export function useMoreOptionsMenu() {
options.push({ type: 'divider' })

// Section 3: Structure operations (Convert to Subgraph, Frame selection, Minimize Node)
const subgraphOps = getSubgraphOptions({
hasSubgraphs: hasSubgraphsSelected,
hasMultipleSelection: hasMultipleNodes.value
})
options.push(...subgraphOps)
if (hasMultipleNodes.value) {
const multiOps = getMultipleNodesOptions()
options.push(...multiOps)
}
if (groupContext) {
const fitGroup = getFitGroupToNodesOption(groupContext)
options.push(fitGroup)
} else {
// Node context: Expand/Minimize, Shape, Color, Divider
options.push(...getNodeVisualOptions(states, bump))
options.push({ type: 'divider' })
}

// Section 4: Image operations (if image node)
if (hasImageNode.value && selectedNodes.value.length > 0) {
options.push(...getImageMenuOptions(selectedNodes.value[0]))
}

// Section 5: Subgraph operations
options.push(
...getSubgraphOptions({
hasSubgraphs: hasSubgraphsSelected,
hasMultipleSelection: hasMultipleNodes.value
})
)

// Section 6: Multiple nodes operations
if (hasMultipleNodes.value) {
options.push(...getMultipleNodesOptions())
}
if (groupContext) {
options.push(getFitGroupToNodesOption(groupContext))
} else {
// Node context: Expand/Minimize
const visualOptions = getNodeVisualOptions(states, bump)
if (visualOptions.length > 0) {
options.push(visualOptions[0]) // Expand/Minimize (index 0)
}
}
options.push({ type: 'divider' })

// Section 4: Node properties (Node Info, Color)
// Section 4: Node properties (Node Info, Shape, Color)
if (nodeDef.value) {
const nodeInfo = getNodeInfoOption(showNodeHelp)
options.push(nodeInfo)
options.push(getNodeInfoOption(showNodeHelp))
}
if (groupContext) {
const groupColor = getGroupColorOptions(groupContext, bump)
options.push(groupColor)
options.push(getGroupColorOptions(groupContext, bump))
} else {
// Add shape and color options
const visualOptions = getNodeVisualOptions(states, bump)
@@ -260,18 +232,16 @@ export function useMoreOptionsMenu() {
}
options.push({ type: 'divider' })

// Section 5: Node-specific options (image operations)
// Section 5: Image operations (if image node)
if (hasImageNode.value && selectedNodes.value.length > 0) {
const imageOps = getImageMenuOptions(selectedNodes.value[0])
options.push(...imageOps)
options.push(...getImageMenuOptions(selectedNodes.value[0]))
options.push({ type: 'divider' })
}
// Section 6 & 7: Extensions and Delete are handled by buildStructuredMenu

// Mark all Vue options with source
const markedVueOptions = markAsVueOptions(options)
// For single node selection, merge LiteGraph options with Vue options
// Vue options will take precedence during deduplication in buildStructuredMenu

if (litegraphOptions.length > 0) {
// Merge: LiteGraph options first, then Vue options (Vue will win in dedup)
const merged = [...litegraphOptions, ...markedVueOptions]


+ 249
- 166
src/composables/node/useNodePricing.ts View File

@@ -329,6 +329,123 @@ const sora2PricingCalculator: PricingFunction = (node: LGraphNode): string => {
return formatRunPrice(perSec, duration)
}

/**
* Pricing for Tripo 3D generation nodes (Text / Image / Multiview)
* based on Tripo credits:
*
* Turbo / V3 / V2.5 / V2.0:
* Text -> 10 (no texture) / 20 (standard texture)
* Image -> 20 (no texture) / 30 (standard texture)
* Multiview -> 20 (no texture) / 30 (standard texture)
*
* V1.4:
* Text -> 20
* Image -> 30
* (Multiview treated same as Image if used)
*
* Advanced extras (added on top of generation credits):
* quad -> +5 credits
* style -> +5 credits (if style != "None")
* HD texture -> +10 credits (texture_quality = "detailed")
* detailed geometry -> +20 credits (geometry_quality = "detailed")
*
* 1 credit = $0.01
*/
const calculateTripo3DGenerationPrice = (
node: LGraphNode,
task: 'text' | 'image' | 'multiview'
): string => {
const getWidget = (name: string): IComboWidget | undefined =>
node.widgets?.find((w) => w.name === name) as IComboWidget | undefined

const getString = (name: string, defaultValue: string): string => {
const widget = getWidget(name)
if (!widget || widget.value === undefined || widget.value === null) {
return defaultValue
}
return String(widget.value)
}

const getBool = (name: string, defaultValue: boolean): boolean => {
const widget = getWidget(name)
if (!widget || widget.value === undefined || widget.value === null) {
return defaultValue
}

const v = widget.value
if (typeof v === 'number') return v !== 0
const lower = String(v).toLowerCase()
if (lower === 'true') return true
if (lower === 'false') return false

return defaultValue
}

// ---- read widget values with sensible defaults (mirroring backend) ----
const modelVersionRaw = getString('model_version', '').toLowerCase()
if (modelVersionRaw === '')
return '$0.1-0.65/Run (varies with quad, style, texture & quality)'
const styleRaw = getString('style', 'None')
const hasStyle = styleRaw.toLowerCase() !== 'none'

// Backend defaults: texture=true, pbr=true, quad=false, qualities="standard"
const hasTexture = getBool('texture', false)
const hasPbr = getBool('pbr', false)
const quad = getBool('quad', false)

const textureQualityRaw = getString(
'texture_quality',
'standard'
).toLowerCase()
const geometryQualityRaw = getString(
'geometry_quality',
'standard'
).toLowerCase()

const isHdTexture = textureQualityRaw === 'detailed'
const isDetailedGeometry = geometryQualityRaw === 'detailed'

const withTexture = hasTexture || hasPbr

let baseCredits: number

if (modelVersionRaw.includes('v1.4')) {
// V1.4 model: Text=20, Image=30, Refine=30
if (task === 'text') {
baseCredits = 20
} else {
// treat Multiview same as Image if V1.4 is ever used there
baseCredits = 30
}
} else {
// V3.0, V2.5, V2.0 models
if (!withTexture) {
if (task === 'text') {
baseCredits = 10 // Text to 3D without texture
} else {
baseCredits = 20 // Image/Multiview to 3D without texture
}
} else {
if (task === 'text') {
baseCredits = 20 // Text to 3D with standard texture
} else {
baseCredits = 30 // Image/Multiview to 3D with standard texture
}
}
}

// ---- advanced extras on top of base generation ----
let credits = baseCredits

if (hasStyle) credits += 5 // Style
if (quad) credits += 5 // Quad Topology
if (isHdTexture) credits += 10 // HD Texture
if (isDetailedGeometry) credits += 20 // Detailed Geometry Quality

const dollars = credits * 0.01
return `$${dollars.toFixed(2)}/Run`
}

/**
* Static pricing data for API nodes, now supporting both strings and functions
*/
@@ -1482,119 +1599,16 @@ const apiNodeCosts: Record<string, { displayPrice: string | PricingFunction }> =
},
// Tripo nodes - using actual node names from ComfyUI
TripoTextToModelNode: {
displayPrice: (node: LGraphNode): string => {
const quadWidget = node.widgets?.find(
(w) => w.name === 'quad'
) as IComboWidget
const styleWidget = node.widgets?.find(
(w) => w.name === 'style'
) as IComboWidget
const textureWidget = node.widgets?.find(
(w) => w.name === 'texture'
) as IComboWidget
const textureQualityWidget = node.widgets?.find(
(w) => w.name === 'texture_quality'
) as IComboWidget

if (!quadWidget || !styleWidget || !textureWidget)
return '$0.1-0.4/Run (varies with quad, style, texture & quality)'

const quad = String(quadWidget.value).toLowerCase() === 'true'
const style = String(styleWidget.value).toLowerCase()
const texture = String(textureWidget.value).toLowerCase() === 'true'
const textureQuality = String(
textureQualityWidget?.value || 'standard'
).toLowerCase()

// Pricing logic based on CSV data
if (style.includes('none')) {
if (!quad) {
if (!texture) return '$0.10/Run'
else return '$0.15/Run'
} else {
if (textureQuality.includes('detailed')) {
if (!texture) return '$0.30/Run'
else return '$0.35/Run'
} else {
if (!texture) return '$0.20/Run'
else return '$0.25/Run'
}
}
} else {
// any style
if (!quad) {
if (!texture) return '$0.15/Run'
else return '$0.20/Run'
} else {
if (textureQuality.includes('detailed')) {
if (!texture) return '$0.35/Run'
else return '$0.40/Run'
} else {
if (!texture) return '$0.25/Run'
else return '$0.30/Run'
}
}
}
}
displayPrice: (node: LGraphNode): string =>
calculateTripo3DGenerationPrice(node, 'text')
},
TripoImageToModelNode: {
displayPrice: (node: LGraphNode): string => {
const quadWidget = node.widgets?.find(
(w) => w.name === 'quad'
) as IComboWidget
const styleWidget = node.widgets?.find(
(w) => w.name === 'style'
) as IComboWidget
const textureWidget = node.widgets?.find(
(w) => w.name === 'texture'
) as IComboWidget
const textureQualityWidget = node.widgets?.find(
(w) => w.name === 'texture_quality'
) as IComboWidget

if (!quadWidget || !styleWidget || !textureWidget)
return '$0.2-0.5/Run (varies with quad, style, texture & quality)'

const quad = String(quadWidget.value).toLowerCase() === 'true'
const style = String(styleWidget.value).toLowerCase()
const texture = String(textureWidget.value).toLowerCase() === 'true'
const textureQuality = String(
textureQualityWidget?.value || 'standard'
).toLowerCase()

// Pricing logic based on CSV data for Image to Model
if (style.includes('none')) {
if (!quad) {
if (!texture) return '$0.20/Run'
else return '$0.25/Run'
} else {
if (textureQuality.includes('detailed')) {
if (!texture) return '$0.40/Run'
else return '$0.45/Run'
} else {
if (!texture) return '$0.30/Run'
else return '$0.35/Run'
}
}
} else {
// any style
if (!quad) {
if (!texture) return '$0.25/Run'
else return '$0.30/Run'
} else {
if (textureQuality.includes('detailed')) {
if (!texture) return '$0.45/Run'
else return '$0.50/Run'
} else {
if (!texture) return '$0.35/Run'
else return '$0.40/Run'
}
}
}
}
displayPrice: (node: LGraphNode): string =>
calculateTripo3DGenerationPrice(node, 'image')
},
TripoRefineNode: {
displayPrice: '$0.3/Run'
TripoMultiviewToModelNode: {
displayPrice: (node: LGraphNode): string =>
calculateTripo3DGenerationPrice(node, 'multiview')
},
TripoTextureNode: {
displayPrice: (node: LGraphNode): string => {
@@ -1608,68 +1622,94 @@ const apiNodeCosts: Record<string, { displayPrice: string | PricingFunction }> =
return textureQuality.includes('detailed') ? '$0.2/Run' : '$0.1/Run'
}
},
TripoConvertModelNode: {
displayPrice: '$0.10/Run'
},
TripoRetargetRiggedModelNode: {
displayPrice: '$0.10/Run'
TripoRigNode: {
displayPrice: '$0.25/Run'
},
TripoMultiviewToModelNode: {
TripoConversionNode: {
displayPrice: (node: LGraphNode): string => {
const quadWidget = node.widgets?.find(
(w) => w.name === 'quad'
) as IComboWidget
const styleWidget = node.widgets?.find(
(w) => w.name === 'style'
) as IComboWidget
const textureWidget = node.widgets?.find(
(w) => w.name === 'texture'
) as IComboWidget
const textureQualityWidget = node.widgets?.find(
(w) => w.name === 'texture_quality'
) as IComboWidget
const getWidgetValue = (name: string) =>
node.widgets?.find((w) => w.name === name)?.value

const getNumber = (name: string, defaultValue: number): number => {
const raw = getWidgetValue(name)
if (raw === undefined || raw === null || raw === '')
return defaultValue
if (typeof raw === 'number')
return Number.isFinite(raw) ? raw : defaultValue
const n = Number(raw)
return Number.isFinite(n) ? n : defaultValue
}

const getBool = (name: string, defaultValue: boolean): boolean => {
const v = getWidgetValue(name)
if (v === undefined || v === null) return defaultValue

if (!quadWidget || !styleWidget || !textureWidget)
return '$0.2-0.5/Run (varies with quad, style, texture & quality)'
if (typeof v === 'number') return v !== 0
const lower = String(v).toLowerCase()
if (lower === 'true') return true
if (lower === 'false') return false
return defaultValue
}

const quad = String(quadWidget.value).toLowerCase() === 'true'
const style = String(styleWidget.value).toLowerCase()
const texture = String(textureWidget.value).toLowerCase() === 'true'
const textureQuality = String(
textureQualityWidget?.value || 'standard'
let hasAdvancedParam = false

// ---- booleans that trigger advanced when true ----
if (getBool('quad', false)) hasAdvancedParam = true
if (getBool('force_symmetry', false)) hasAdvancedParam = true
if (getBool('flatten_bottom', false)) hasAdvancedParam = true
if (getBool('pivot_to_center_bottom', false)) hasAdvancedParam = true
if (getBool('with_animation', false)) hasAdvancedParam = true
if (getBool('pack_uv', false)) hasAdvancedParam = true
if (getBool('bake', false)) hasAdvancedParam = true
if (getBool('export_vertex_colors', false)) hasAdvancedParam = true
if (getBool('animate_in_place', false)) hasAdvancedParam = true

// ---- numeric params with special default sentinels ----
const faceLimit = getNumber('face_limit', -1)
if (faceLimit !== -1) hasAdvancedParam = true

const textureSize = getNumber('texture_size', 4096)
if (textureSize !== 4096) hasAdvancedParam = true

const flattenBottomThreshold = getNumber(
'flatten_bottom_threshold',
0.0
)
if (flattenBottomThreshold !== 0.0) hasAdvancedParam = true

const scaleFactor = getNumber('scale_factor', 1.0)
if (scaleFactor !== 1.0) hasAdvancedParam = true

// ---- string / combo params with non-default values ----
const textureFormatRaw = String(
getWidgetValue('texture_format') ?? 'JPEG'
).toUpperCase()
if (textureFormatRaw !== 'JPEG') hasAdvancedParam = true

const partNamesRaw = String(getWidgetValue('part_names') ?? '')
if (partNamesRaw.trim().length > 0) hasAdvancedParam = true

const fbxPresetRaw = String(
getWidgetValue('fbx_preset') ?? 'blender'
).toLowerCase()
if (fbxPresetRaw !== 'blender') hasAdvancedParam = true

// Pricing logic based on CSV data for Multiview to Model (same as Image to Model)
if (style.includes('none')) {
if (!quad) {
if (!texture) return '$0.20/Run'
else return '$0.25/Run'
} else {
if (textureQuality.includes('detailed')) {
if (!texture) return '$0.40/Run'
else return '$0.45/Run'
} else {
if (!texture) return '$0.30/Run'
else return '$0.35/Run'
}
}
} else {
// any style
if (!quad) {
if (!texture) return '$0.25/Run'
else return '$0.30/Run'
} else {
if (textureQuality.includes('detailed')) {
if (!texture) return '$0.45/Run'
else return '$0.50/Run'
} else {
if (!texture) return '$0.35/Run'
else return '$0.40/Run'
}
}
}
const exportOrientationRaw = String(
getWidgetValue('export_orientation') ?? 'default'
).toLowerCase()
if (exportOrientationRaw !== 'default') hasAdvancedParam = true

const credits = hasAdvancedParam ? 10 : 5
const dollars = credits * 0.01
return `$${dollars.toFixed(2)}/Run`
}
},
TripoRetargetNode: {
displayPrice: '$0.10/Run'
},
TripoRefineNode: {
displayPrice: '$0.30/Run'
},
// Google/Gemini nodes
GeminiNode: {
displayPrice: (node: LGraphNode): string => {
@@ -2019,8 +2059,51 @@ export const useNodePricing = () => {
RunwayImageToVideoNodeGen4: ['duration'],
RunwayFirstLastFrameNode: ['duration'],
// Tripo nodes
TripoTextToModelNode: ['quad', 'style', 'texture', 'texture_quality'],
TripoImageToModelNode: ['quad', 'style', 'texture', 'texture_quality'],
TripoTextToModelNode: [
'model_version',
'quad',
'style',
'texture',
'pbr',
'texture_quality',
'geometry_quality'
],
TripoImageToModelNode: [
'model_version',
'quad',
'style',
'texture',
'pbr',
'texture_quality',
'geometry_quality'
],
TripoMultiviewToModelNode: [
'model_version',
'quad',
'texture',
'pbr',
'texture_quality',
'geometry_quality'
],
TripoConversionNode: [
'quad',
'face_limit',
'texture_size',
'texture_format',
'force_symmetry',
'flatten_bottom',
'flatten_bottom_threshold',
'pivot_to_center_bottom',
'scale_factor',
'with_animation',
'pack_uv',
'bake',
'part_names',
'fbx_preset',
'export_vertex_colors',
'export_orientation',
'animate_in_place'
],
TripoTextureNode: ['texture_quality'],
// Google/Gemini nodes
GeminiNode: ['model'],


+ 135
- 39
src/lib/litegraph/src/LGraphCanvas.ts View File

@@ -707,6 +707,9 @@ 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,
@@ -2627,7 +2630,20 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
this.processSelect(clickedItem, eUp)
}
pointer.onDragStart = () => (this.dragging_rectangle = dragRect)
pointer.onDragEnd = (upEvent) => this.#handleMultiSelect(upEvent, 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.finally = () => (this.dragging_rectangle = null)
}

@@ -4087,76 +4103,156 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
this.setDirty(true)
}

#handleMultiSelect(e: CanvasPointerEvent, dragRect: Rect) {
// Process drag
// Convert Point pair (pos, offset) to Rect
const { graph, selectedItems, subgraph } = this
if (!graph) throw new NullGraphError()
/**
* 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 {
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()

// Select nodes - any part of the node is in the select area
const isSelected = new Set<Positionable>()
const notSelected: Positionable[] = []
const items = new Set<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)
for (const group of graph.groups) {
if (containsRect(rect, group._bounding)) {
group.recomputeInsideNodes()
items.add(group)
}
}

// Select groups - the group is wholly inside the select area
for (const group of graph.groups) {
if (!containsRect(dragRect, group._bounding)) continue
// Check reroutes (center point must be inside)
for (const reroute of graph.reroutes.values()) {
if (isPointInRect(reroute.pos, rect)) items.add(reroute)
}

group.recomputeInsideNodes()
addPositionable(group)
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)
}

// Select reroutes - the centre point is inside the select area
for (const reroute of graph.reroutes.values()) {
if (!isPointInRect(reroute.pos, dragRect)) continue
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
}
}

selectedItems.add(reroute)
reroute.selected = true
addPositionable(reroute)
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 notSelected) this.select(item)
for (const item of itemsInRect) this.select(item)
} else if (e.altKey) {
// Remove from selection
for (const item of isSelected) this.deselect(item)
for (const item of itemsInRect) this.deselect(item)
} else {
// Replace selection
for (const item of selectedItems.values()) {
if (!isSelected.has(item)) this.deselect(item)
if (!itemsInRect.has(item)) this.deselect(item)
}
for (const item of notSelected) this.select(item)
for (const item of itemsInRect) 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)
}
this.onSelectionChange?.(this.selected_nodes)
}

/**


+ 48
- 8
src/lib/litegraph/src/node/NodeSlot.ts View File

@@ -30,6 +30,8 @@ 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
@@ -130,6 +132,7 @@ 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

@@ -163,16 +166,52 @@ 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
doStroke = true
ctx.lineWidth = 3
ctx.strokeStyle = ctx.fillStyle
radius = highlight ? 4 : 3
} else {
// Normal circle
radius = highlight ? 5 : 4
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()
}
ctx.arc(pos[0], pos[1], radius, 0, Math.PI * 2)
}
@@ -180,6 +219,7 @@ 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


+ 0
- 1
src/locales/ar/main.json View File

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


+ 0
- 259
src/locales/ar/nodeDefs.json View File

@@ -8286,265 +8286,6 @@
}
}
},
"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": "بيكسفيرس صورة إلى فيديو",


+ 10
- 8
src/locales/en/main.json View File

@@ -633,7 +633,9 @@
"serverConfig": {
"modifiedConfigs": "You have modified the following server configurations. Restart to apply changes.",
"revertChanges": "Revert Changes",
"restart": "Restart"
"restart": "Restart",
"restartRequiredToastSummary": "Restart required",
"restartRequiredToastDetail": "Restart the app to apply server configuration changes."
},
"shape": {
"default": "Default",
@@ -1435,7 +1437,6 @@
"Sora": "Sora",
"cond pair": "cond pair",
"photomaker": "photomaker",
"Pika": "Pika",
"PixVerse": "PixVerse",
"primitive": "primitive",
"qwen": "qwen",
@@ -1561,6 +1562,11 @@
"updateFrontend": "Update Frontend",
"dismiss": "Dismiss"
},
"loadWorkflowWarning": {
"outdatedVersion": "This workflow was created with a newer version of ComfyUI ({version}). Some nodes may not work correctly.",
"outdatedVersionGeneric": "This workflow was created with a newer version of ComfyUI. Some nodes may not work correctly.",
"coreNodesFromVersion": "Core nodes from version {version}:"
},
"errorDialog": {
"defaultTitle": "An error occurred",
"loadWorkflowTitle": "Loading aborted due to error reloading workflow data",
@@ -1992,12 +1998,6 @@
"waitingForSubscription": "Complete your subscription in the new tab. We'll automatically detect when you're done!",
"subscribe": "Subscribe"
},
"pricingTable": {
"description": "Access cloud-powered ComfyUI workflows with straightforward, usage-based pricing.",
"loading": "Loading pricing options...",
"loadError": "We couldn't load the pricing table. Please refresh and try again.",
"missingConfig": "Stripe pricing table configuration missing. Provide the publishable key and pricing table ID via remote config or .env."
},
"subscribeToRun": "Subscribe",
"subscribeToRunFull": "Subscribe to Run",
"subscribeNow": "Subscribe Now",
@@ -2076,6 +2076,8 @@
"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"
}
},


+ 0
- 259
src/locales/en/nodeDefs.json View File

@@ -9426,265 +9426,6 @@
}
}
},
"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.",


+ 0
- 1
src/locales/es/main.json View File

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


+ 0
- 259
src/locales/es/nodeDefs.json View File

@@ -8286,265 +8286,6 @@
}
}
},
"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",


+ 0
- 1
src/locales/fr/main.json View File

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


+ 0
- 259
src/locales/fr/nodeDefs.json View File

@@ -8286,265 +8286,6 @@
}
}
},
"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",


+ 0
- 1
src/locales/ja/main.json View File

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


+ 0
- 259
src/locales/ja/nodeDefs.json View File

@@ -8286,265 +8286,6 @@
}
}
},
"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 画像から動画へ",


+ 0
- 1
src/locales/ko/main.json View File

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


+ 0
- 259
src/locales/ko/nodeDefs.json View File

@@ -8286,265 +8286,6 @@
}
}
},
"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 이미지에서 비디오로",


+ 0
- 1
src/locales/ru/main.json View File

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


+ 0
- 259
src/locales/ru/nodeDefs.json View File

@@ -8286,265 +8286,6 @@
}
}
},
"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: изображение в видео",


+ 0
- 1
src/locales/tr/main.json View File

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


+ 0
- 259
src/locales/tr/nodeDefs.json View File

@@ -8286,265 +8286,6 @@
}
}
},
"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",


+ 0
- 1
src/locales/zh-TW/main.json View File

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


+ 0
- 259
src/locales/zh-TW/nodeDefs.json View File

@@ -8286,265 +8286,6 @@
}
}
},
"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 影像轉影片",


+ 0
- 1
src/locales/zh/main.json View File

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


+ 0
- 259
src/locales/zh/nodeDefs.json View File

@@ -8286,265 +8286,6 @@
}
}
},
"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 图像转视频",


+ 0
- 2
src/platform/remoteConfig/types.ts View File

@@ -39,6 +39,4 @@ export type RemoteConfig = {
private_models_enabled?: boolean
subscription_tiers_enabled?: boolean
onboarding_survey_enabled?: boolean
stripe_publishable_key?: string
stripe_pricing_table_id?: string
}

+ 24
- 1
src/platform/settings/components/ServerConfigPanel.vue View File

@@ -70,7 +70,7 @@ import { storeToRefs } from 'pinia'
import Button from 'primevue/button'
import Divider from 'primevue/divider'
import Message from 'primevue/message'
import { watch } from 'vue'
import { onBeforeUnmount, watch } from 'vue'
import { useI18n } from 'vue-i18n'

import FormItem from '@/components/common/FormItem.vue'
@@ -79,11 +79,13 @@ import { useCopyToClipboard } from '@/composables/useCopyToClipboard'
import type { ServerConfig } from '@/constants/serverConfig'
import { useSettingStore } from '@/platform/settings/settingStore'
import type { FormItem as FormItemType } from '@/platform/settings/types'
import { useToastStore } from '@/platform/updates/common/toastStore'
import { useServerConfigStore } from '@/stores/serverConfigStore'
import { electronAPI } from '@/utils/envUtil'

const settingStore = useSettingStore()
const serverConfigStore = useServerConfigStore()
const toastStore = useToastStore()
const {
serverConfigsByCategory,
serverConfigValues,
@@ -92,11 +94,14 @@ const {
modifiedConfigs
} = storeToRefs(serverConfigStore)

let restartTriggered = false

const revertChanges = () => {
serverConfigStore.revertChanges()
}

const restartApp = async () => {
restartTriggered = true
await electronAPI().restartApp()
}

@@ -114,6 +119,24 @@ const copyCommandLineArgs = async () => {
}

const { t } = useI18n()

onBeforeUnmount(() => {
if (restartTriggered) {
return
}

if (modifiedConfigs.value.length === 0) {
return
}

toastStore.add({
severity: 'warn',
summary: t('serverConfig.restartRequiredToastSummary'),
detail: t('serverConfig.restartRequiredToastDetail'),
life: 10_000
})
})

const translateItem = (item: ServerConfig<any>): FormItemType => {
return {
...item,


+ 6
- 0
src/platform/settings/composables/useLitegraphSettings.ts View File

@@ -93,6 +93,12 @@ 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'


+ 10
- 0
src/platform/settings/constants/coreSettings.ts View File

@@ -706,6 +706,16 @@ 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'],


+ 42
- 19
src/renderer/extensions/vueNodes/VideoPreview.vue View File

@@ -2,16 +2,20 @@
<div
v-if="imageUrls.length > 0"
class="video-preview group relative flex size-full min-h-16 min-w-16 flex-col px-2"
tabindex="0"
role="region"
:aria-label="$t('g.videoPreview')"
@mouseenter="handleMouseEnter"
@mouseleave="handleMouseLeave"
@keydown="handleKeyDown"
>
<!-- Video Wrapper -->
<div
ref="videoWrapperEl"
class="relative h-full w-full grow overflow-hidden rounded-[5px] bg-node-component-surface"
tabindex="0"
role="region"
:aria-label="$t('g.videoPreview')"
:aria-busy="showLoader"
@mouseenter="handleMouseEnter"
@mouseleave="handleMouseLeave"
@focusin="handleFocusIn"
@focusout="handleFocusOut"
>
<!-- Error State -->
<div
@@ -27,18 +31,18 @@

<!-- Loading State -->
<Skeleton
v-if="isLoading && !videoError"
v-if="showLoader && !videoError"
class="absolute inset-0 size-full"
border-radius="5px"
width="16rem"
height="16rem"
width="100%"
height="100%"
/>

<!-- Main Video -->
<video
v-if="!videoError"
:src="currentVideoUrl"
:class="cn('block size-full object-contain', isLoading && 'invisible')"
:class="cn('block size-full object-contain', showLoader && 'invisible')"
controls
loop
playsinline
@@ -47,10 +51,13 @@
/>

<!-- Floating Action Buttons (appear on hover) -->
<div v-if="isHovered" class="actions absolute top-2 right-2 flex gap-1">
<div
v-if="isHovered || isFocused"
class="actions absolute top-2 right-2 flex gap-2.5"
>
<!-- Download Button -->
<button
class="action-btn cursor-pointer rounded-lg border-0 bg-white p-2 text-black shadow-sm transition-all duration-200 hover:bg-smoke-100"
:class="actionButtonClass"
:title="$t('g.downloadVideo')"
:aria-label="$t('g.downloadVideo')"
@click="handleDownload"
@@ -60,7 +67,7 @@

<!-- Close Button -->
<button
class="action-btn cursor-pointer rounded-lg border-0 bg-white p-2 text-black shadow-sm transition-all duration-200 hover:bg-smoke-100"
:class="actionButtonClass"
:title="$t('g.removeVideo')"
:aria-label="$t('g.removeVideo')"
@click="handleRemove"
@@ -94,7 +101,7 @@
<span v-if="videoError" class="text-red-400">
{{ $t('g.errorLoadingVideo') }}
</span>
<span v-else-if="isLoading" class="text-smoke-400">
<span v-else-if="showLoader" class="text-smoke-400">
{{ $t('g.loading') }}...
</span>
<span v-else>
@@ -126,12 +133,18 @@ const props = defineProps<VideoPreviewProps>()
const { t } = useI18n()
const nodeOutputStore = useNodeOutputStore()

const actionButtonClass =
'flex h-8 min-h-8 items-center justify-center gap-2.5 rounded-lg border-0 bg-button-surface px-2 py-2 text-button-surface-contrast shadow-sm transition-colors duration-200 hover:bg-button-hover-surface focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-button-surface-contrast focus-visible:ring-offset-2 focus-visible:ring-offset-transparent cursor-pointer'

// Component state
const currentIndex = ref(0)
const isHovered = ref(false)
const isFocused = ref(false)
const actualDimensions = ref<string | null>(null)
const videoError = ref(false)
const isLoading = ref(false)
const showLoader = ref(false)

const videoWrapperEl = ref<HTMLDivElement>()

// Computed values
const currentVideoUrl = computed(() => props.imageUrls[currentIndex.value])
@@ -149,16 +162,16 @@ watch(
// Reset loading and error states when URLs change
actualDimensions.value = null
videoError.value = false
isLoading.value = newUrls.length > 0
showLoader.value = newUrls.length > 0
},
{ deep: true }
{ deep: true, immediate: true }
)

// Event handlers
const handleVideoLoad = (event: Event) => {
if (!event.target || !(event.target instanceof HTMLVideoElement)) return
const video = event.target
isLoading.value = false
showLoader.value = false
videoError.value = false
if (video.videoWidth && video.videoHeight) {
actualDimensions.value = `${video.videoWidth} x ${video.videoHeight}`
@@ -166,7 +179,7 @@ const handleVideoLoad = (event: Event) => {
}

const handleVideoError = () => {
isLoading.value = false
showLoader.value = false
videoError.value = true
actualDimensions.value = null
}
@@ -194,7 +207,7 @@ const setCurrentIndex = (index: number) => {
if (index >= 0 && index < props.imageUrls.length) {
currentIndex.value = index
actualDimensions.value = null
isLoading.value = true
showLoader.value = true
videoError.value = false
}
}
@@ -207,6 +220,16 @@ const handleMouseLeave = () => {
isHovered.value = false
}

const handleFocusIn = () => {
isFocused.value = true
}

const handleFocusOut = (event: FocusEvent) => {
if (!videoWrapperEl.value?.contains(event.relatedTarget as Node)) {
isFocused.value = false
}
}

const getNavigationDotClass = (index: number) => {
return [
'w-2 h-2 rounded-full transition-all duration-200 border-0 cursor-pointer',


+ 1
- 9
src/renderer/extensions/vueNodes/components/InputSlot.vue View File

@@ -20,13 +20,13 @@
<!-- Connection Dot -->
<SlotConnectionDot
ref="connectionDotRef"
:color="slotColor"
:class="
cn(
'-translate-x-1/2 w-3',
hasSlotError && 'ring-2 ring-error ring-offset-0 rounded-full'
)
"
:slot-data
@click="onClick"
@dblclick="onDoubleClick"
@pointerdown="onPointerDown"
@@ -54,7 +54,6 @@ 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'
@@ -111,13 +110,6 @@ 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)


+ 2
- 4
src/renderer/extensions/vueNodes/components/LGraphNode.vue View File

@@ -290,10 +290,7 @@ const handleContextMenu = (event: MouseEvent) => {
handleNodeRightClick(event as PointerEvent, nodeData.id)

// Show the node options menu at the cursor position
const targetElement = event.currentTarget as HTMLElement
if (targetElement) {
toggleNodeOptions(event, targetElement, false)
}
toggleNodeOptions(event)
}

onMounted(() => {
@@ -335,6 +332,7 @@ const { startResize } = useNodeResize((result, element) => {

const handleResizePointerDown = (event: PointerEvent) => {
if (event.button !== 0) return
if (!shouldHandleNodePointerEvents.value) return
if (nodeData.flags?.pinned) return
startResize(event)
}


+ 2
- 6
src/renderer/extensions/vueNodes/components/NodeWidgets.vue View File

@@ -53,9 +53,9 @@
<!-- Widget Component -->
<component
:is="widget.vueComponent"
v-model="widget.value"
v-tooltip.left="widget.tooltipConfig"
:widget="widget.simplified"
:model-value="widget.value"
:node-id="nodeData?.id != null ? String(nodeData.id) : ''"
:node-type="nodeType"
class="col-span-2"
@@ -180,11 +180,7 @@ const processedWidgets = computed((): ProcessedWidget[] => {
// Update the widget value directly
widget.value = value

// Skip callback for asset widgets - their callback opens the modal,
// but Vue asset mode handles selection through the dropdown
if (widget.type !== 'asset') {
widget.callback?.(value)
}
widget.callback?.(value)
}

const tooltipText = getWidgetTooltip(widget)


+ 1
- 5
src/renderer/extensions/vueNodes/components/OutputSlot.vue View File

@@ -10,8 +10,8 @@
<!-- Connection Dot -->
<SlotConnectionDot
ref="connectionDotRef"
:color="slotColor"
class="w-3 translate-x-1/2"
:slot-data
@pointerdown="onPointerDown"
/>
</div>
@@ -22,7 +22,6 @@ 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'
@@ -67,9 +66,6 @@ onErrorCaptured((error) => {
return false
})

// Get slot color based on type
const slotColor = computed(() => getSlotColor(props.slotData.type))

const { state: dragState } = useSlotLinkDragUIState()
const slotKey = computed(() =>
getSlotKey(props.nodeId ?? '', props.index, false)


+ 47
- 15
src/renderer/extensions/vueNodes/components/SlotConnectionDot.vue View File

@@ -1,20 +1,45 @@
<script setup lang="ts">
import { useTemplateRef } from 'vue'
import { computed, useTemplateRef } from 'vue'

import { getSlotColor } from '@/constants/slotColors'
import type { INodeSlot } from '@/lib/litegraph/src/litegraph'
import { cn } from '@/utils/tailwindUtil'
import type { ClassValue } from '@/utils/tailwindUtil'

const props = defineProps<{
color?: string
multi?: boolean
slotData?: INodeSlot
class?: ClassValue
hasError?: boolean
multi?: boolean
}>()

const slotElRef = useTemplateRef('slot-el')

function getTypes() {
if (props.hasError) return ['var(--color-error)']
//TODO Support connected/disconnected colors?
if (!props.slotData) return [getSlotColor()]
const typesSet = new Set(
`${props.slotData.type}`.split(',').map(getSlotColor)
)
return [...typesSet].slice(0, 3)
}
const types = getTypes()

defineExpose({
slotElRef
})

const slotClass = computed(() =>
cn(
'bg-slate-300 rounded-full slot-dot',
'transition-all duration-150',
'border border-solid border-node-component-slot-dot-outline',
props.multi
? 'w-3 h-6'
: 'size-3 cursor-crosshair group-hover/slot:[--node-component-slot-dot-outline-opacity-mult:5] group-hover/slot:scale-125'
)
)
</script>

<template>
@@ -27,19 +52,26 @@ defineExpose({
"
>
<div
v-if="types.length === 1"
ref="slot-el"
class="slot-dot"
:style="{ backgroundColor: color }"
:class="
cn(
'bg-slate-300 rounded-full',
'transition-all duration-150',
'border border-solid border-node-component-slot-dot-outline',
!multi &&
'cursor-crosshair group-hover/slot:[--node-component-slot-dot-outline-opacity-mult:5] group-hover/slot:scale-125',
multi ? 'w-3 h-6' : 'size-3'
)
"
:style="{ backgroundColor: types[0] }"
:class="slotClass"
/>
<div
v-else
ref="slot-el"
:style="{
'--type1': types[0],
'--type2': types[1],
'--type3': types[2]
}"
:class="slotClass"
>
<i-comfy:node-slot2
v-if="types.length === 2"
class="size-full -translate-y-1/2"
/>
<i-comfy:node-slot3 v-else class="size-full -translate-y-1/2" />
</div>
</div>
</template>

+ 16
- 49
src/renderer/extensions/vueNodes/widgets/components/NumberControlPopover.vue View File

@@ -1,11 +1,9 @@
<script setup lang="ts">
import Button from 'primevue/button'
import Popover from 'primevue/popover'
import ToggleSwitch from 'primevue/toggleswitch'
import RadioButton from 'primevue/radiobutton'
import { computed, ref } from 'vue'

import { useSettingStore } from '@/platform/settings/settingStore'
import { useDialogService } from '@/services/dialogService'

import { NumberControlMode } from '../composables/useStepperControl'

@@ -19,7 +17,6 @@ type ControlOption = {

const popover = ref()
const settingStore = useSettingStore()
const dialogService = useDialogService()

const toggle = (event: Event) => {
popover.value.toggle(event)
@@ -40,10 +37,10 @@ const controlOptions: ControlOption[] = [
] as ControlOption[])
: []),
{
mode: NumberControlMode.RANDOMIZE,
icon: 'icon-[lucide--shuffle]',
title: 'randomize',
description: 'randomizeDesc'
mode: NumberControlMode.FIXED,
icon: 'icon-[lucide--pencil-off]',
title: 'fixed',
description: 'fixedDesc'
},
{
mode: NumberControlMode.INCREMENT,
@@ -56,6 +53,12 @@ const controlOptions: ControlOption[] = [
text: '-1',
title: 'decrement',
description: 'decrementDesc'
},
{
mode: NumberControlMode.RANDOMIZE,
icon: 'icon-[lucide--shuffle]',
title: 'randomize',
description: 'randomizeDesc'
}
]

@@ -63,27 +66,7 @@ const widgetControlMode = computed(() =>
settingStore.get('Comfy.WidgetControlMode')
)

const props = defineProps<{
controlMode: NumberControlMode
}>()

const emit = defineEmits<{
'update:controlMode': [mode: NumberControlMode]
}>()

const handleToggle = (mode: NumberControlMode) => {
if (props.controlMode === mode) return
emit('update:controlMode', mode)
}

const isActive = (mode: NumberControlMode) => {
return props.controlMode === mode
}

const handleEditSettings = () => {
popover.value.hide()
dialogService.showSettingsDialog()
}
const controlMode = defineModel<NumberControlMode>()
</script>

<template>
@@ -147,30 +130,14 @@ const handleEditSettings = () => {
</div>
</div>

<ToggleSwitch
:model-value="isActive(option.mode)"
<RadioButton
v-model="controlMode"
class="flex-shrink-0"
@update:model-value="
(v) =>
v
? handleToggle(option.mode)
: handleToggle(NumberControlMode.FIXED)
"
:input-id="option.mode"
:value="option.mode"
/>
</div>
</div>
<div class="border-t border-border-subtle"></div>
<Button
class="w-full bg-secondary-background hover:bg-secondary-background-hover border-0 rounded-lg p-2 text-sm"
@click="handleEditSettings"
>
<div class="flex items-center justify-center gap-1">
<i class="pi pi-cog text-xs text-muted-foreground" />
<span class="font-normal text-base-foreground">{{
$t('widgets.numberControl.editSettings')
}}</span>
</div>
</Button>
</div>
</Popover>
</template>

+ 1
- 1
src/renderer/extensions/vueNodes/widgets/components/WidgetInputNumberInput.vue View File

@@ -110,7 +110,7 @@ const buttonTooltip = computed(() => {
<span class="pi pi-minus text-sm" />
</template>
</InputNumber>
<div class="absolute top-5 right-8 h-4 w-7 -translate-y-4/5">
<div class="absolute top-5 right-8 h-4 w-7 -translate-y-4/5 flex">
<slot />
</div>
</WidgetLayoutField>


+ 3
- 11
src/renderer/extensions/vueNodes/widgets/components/WidgetInputNumberWithControl.vue View File

@@ -1,10 +1,9 @@
<script setup lang="ts">
import Button from 'primevue/button'
import { defineAsyncComponent, ref } from 'vue'
import { defineAsyncComponent, ref, watch } from 'vue'

import type { SimplifiedWidget } from '@/types/simplifiedWidget'

import type { NumberControlMode } from '../composables/useStepperControl'
import { useStepperControl } from '../composables/useStepperControl'
import WidgetInputNumberInput from './WidgetInputNumberInput.vue'

@@ -32,10 +31,7 @@ const { controlMode, controlButtonIcon } = useStepperControl(
props.widget.controlWidget!.value
)

const setControlMode = (mode: NumberControlMode) => {
controlMode.value = mode
props.widget.controlWidget!.update(mode)
}
watch(controlMode, props.widget.controlWidget!.update)

const togglePopover = (event: Event) => {
popover.value.toggle(event)
@@ -58,10 +54,6 @@ const togglePopover = (event: Event) => {
<i :class="`${controlButtonIcon} text-blue-100 text-xs`" />
</Button>
</WidgetInputNumberInput>
<NumberControlPopover
ref="popover"
:control-mode
@update:control-mode="setControlMode"
/>
<NumberControlPopover ref="popover" v-model="controlMode" />
</div>
</template>

+ 3
- 0
src/renderer/extensions/vueNodes/widgets/components/WidgetSelectDropdown.vue View File

@@ -146,6 +146,9 @@ const outputItems = computed<DropdownItem[]>(() => {
})

const allItems = computed<DropdownItem[]>(() => {
if (props.isAssetMode && assetData) {
return assetData.dropdownItems.value
}
return [...inputItems.value, ...outputItems.value]
})



+ 1
- 0
src/renderer/extensions/vueNodes/widgets/components/form/dropdown/FormDropdownMenuActions.vue View File

@@ -66,6 +66,7 @@ function handleSortSelected(item: SortOption) {
<input
v-model="searchQuery"
type="text"
autofocus
:class="resetInputStyle"
:placeholder="$t('g.search')"
/>


+ 1
- 0
src/schemas/apiSchema.ts View File

@@ -400,6 +400,7 @@ const zSettings = z.object({
'Comfy.Graph.CanvasInfo': z.boolean(),
'Comfy.Graph.CanvasMenu': z.boolean(),
'Comfy.Graph.CtrlShiftZoom': z.boolean(),
'Comfy.Graph.LiveSelection': z.boolean(),
'Comfy.Graph.LinkMarkers': z.nativeEnum(LinkMarkerShape),
'Comfy.Graph.ZoomSpeed': z.number(),
'Comfy.Group.DoubleClickTitleToEdit': z.boolean(),


+ 1
- 2
src/vite-env.d.ts View File

@@ -18,8 +18,7 @@ declare global {
}

interface ImportMetaEnv {
readonly VITE_STRIPE_PUBLISHABLE_KEY?: string
readonly VITE_STRIPE_PRICING_TABLE_ID?: string
VITE_APP_VERSION?: string
}

interface ImportMeta {


+ 46
- 26
tests-ui/tests/composables/node/useNodePricing.test.ts View File

@@ -1414,16 +1414,22 @@ describe('useNodePricing', () => {
'duration'
])
expect(getRelevantWidgetNames('TripoTextToModelNode')).toEqual([
'model_version',
'quad',
'style',
'texture',
'texture_quality'
'pbr',
'texture_quality',
'geometry_quality'
])
expect(getRelevantWidgetNames('TripoImageToModelNode')).toEqual([
'model_version',
'quad',
'style',
'texture',
'texture_quality'
'pbr',
'texture_quality',
'geometry_quality'
])
})
})
@@ -1507,6 +1513,7 @@ describe('useNodePricing', () => {
it('should return v2.5 standard pricing for TripoTextToModelNode', () => {
const { getNodeDisplayPrice } = useNodePricing()
const node = createMockNode('TripoTextToModelNode', [
{ name: 'model_version', value: 'v2.5' },
{ name: 'quad', value: false },
{ name: 'style', value: 'any style' },
{ name: 'texture', value: false },
@@ -1520,6 +1527,7 @@ describe('useNodePricing', () => {
it('should return v2.5 detailed pricing for TripoTextToModelNode', () => {
const { getNodeDisplayPrice } = useNodePricing()
const node = createMockNode('TripoTextToModelNode', [
{ name: 'model_version', value: 'v2.5' },
{ name: 'quad', value: true },
{ name: 'style', value: 'any style' },
{ name: 'texture', value: false },
@@ -1527,12 +1535,13 @@ describe('useNodePricing', () => {
])

const price = getNodeDisplayPrice(node)
expect(price).toBe('$0.35/Run') // any style, quad, no texture, detailed
expect(price).toBe('$0.30/Run') // any style, quad, no texture, detailed
})

it('should return v2.0 detailed pricing for TripoImageToModelNode', () => {
const { getNodeDisplayPrice } = useNodePricing()
const node = createMockNode('TripoImageToModelNode', [
{ name: 'model_version', value: 'v2.0' },
{ name: 'quad', value: true },
{ name: 'style', value: 'any style' },
{ name: 'texture', value: false },
@@ -1540,12 +1549,13 @@ describe('useNodePricing', () => {
])

const price = getNodeDisplayPrice(node)
expect(price).toBe('$0.45/Run') // any style, quad, no texture, detailed
expect(price).toBe('$0.40/Run') // any style, quad, no texture, detailed
})

it('should return legacy pricing for TripoTextToModelNode', () => {
const { getNodeDisplayPrice } = useNodePricing()
const node = createMockNode('TripoTextToModelNode', [
{ name: 'model_version', value: 'v2.0' },
{ name: 'quad', value: false },
{ name: 'style', value: 'none' },
{ name: 'texture', value: false },
@@ -1561,7 +1571,7 @@ describe('useNodePricing', () => {
const node = createMockNode('TripoRefineNode')

const price = getNodeDisplayPrice(node)
expect(price).toBe('$0.3/Run')
expect(price).toBe('$0.30/Run')
})

it('should return fallback for TripoTextToModelNode without model', () => {
@@ -1570,7 +1580,7 @@ describe('useNodePricing', () => {

const price = getNodeDisplayPrice(node)
expect(price).toBe(
'$0.1-0.4/Run (varies with quad, style, texture & quality)'
'$0.1-0.65/Run (varies with quad, style, texture & quality)'
)
})

@@ -1592,24 +1602,39 @@ describe('useNodePricing', () => {

// Test different parameter combinations
const testCases = [
{ quad: false, style: 'none', texture: false, expected: '$0.10/Run' },
{
model_version: 'v3.0',
quad: false,
style: 'none',
texture: false,
expected: '$0.10/Run'
},
{
model_version: 'v3.0',
quad: false,
style: 'any style',
texture: false,
expected: '$0.15/Run'
},
{ quad: true, style: 'none', texture: false, expected: '$0.20/Run' },
{
model_version: 'v3.0',
quad: true,
style: 'any style',
texture: false,
expected: '$0.25/Run'
expected: '$0.20/Run'
},
{
model_version: 'v3.0',
quad: true,
style: 'any style',
texture: true,
expected: '$0.30/Run'
}
]

testCases.forEach(({ quad, style, texture, expected }) => {
const node = createMockNode('TripoTextToModelNode', [
{ name: 'model_version', value: 'v2.0' },
{ name: 'quad', value: quad },
{ name: 'style', value: style },
{ name: 'texture', value: texture },
@@ -1619,17 +1644,9 @@ describe('useNodePricing', () => {
})
})

it('should return static price for TripoConvertModelNode', () => {
it('should return static price for TripoRetargetNode', () => {
const { getNodeDisplayPrice } = useNodePricing()
const node = createMockNode('TripoConvertModelNode')

const price = getNodeDisplayPrice(node)
expect(price).toBe('$0.10/Run')
})

it('should return static price for TripoRetargetRiggedModelNode', () => {
const { getNodeDisplayPrice } = useNodePricing()
const node = createMockNode('TripoRetargetRiggedModelNode')
const node = createMockNode('TripoRetargetNode')

const price = getNodeDisplayPrice(node)
expect(price).toBe('$0.10/Run')
@@ -1640,6 +1657,7 @@ describe('useNodePricing', () => {

// Test basic case - no style, no quad, no texture
const basicNode = createMockNode('TripoMultiviewToModelNode', [
{ name: 'model_version', value: 'v3.0' },
{ name: 'quad', value: false },
{ name: 'style', value: 'none' },
{ name: 'texture', value: false },
@@ -1649,6 +1667,7 @@ describe('useNodePricing', () => {

// Test high-end case - any style, quad, texture, detailed
const highEndNode = createMockNode('TripoMultiviewToModelNode', [
{ name: 'model_version', value: 'v3.0' },
{ name: 'quad', value: true },
{ name: 'style', value: 'stylized' },
{ name: 'texture', value: true },
@@ -1663,7 +1682,7 @@ describe('useNodePricing', () => {

const price = getNodeDisplayPrice(node)
expect(price).toBe(
'$0.2-0.5/Run (varies with quad, style, texture & quality)'
'$0.1-0.65/Run (varies with quad, style, texture & quality)'
)
})
})
@@ -1870,7 +1889,7 @@ describe('useNodePricing', () => {

const testCases = [
{ quad: false, style: 'none', texture: false, expected: '$0.20/Run' },
{ quad: false, style: 'none', texture: true, expected: '$0.25/Run' },
{ quad: false, style: 'none', texture: true, expected: '$0.30/Run' },
{
quad: true,
style: 'any style',
@@ -1879,9 +1898,9 @@ describe('useNodePricing', () => {
expected: '$0.50/Run'
},
{
quad: true,
quad: false,
style: 'any style',
texture: false,
texture: true,
textureQuality: 'standard',
expected: '$0.35/Run'
}
@@ -1890,6 +1909,7 @@ describe('useNodePricing', () => {
testCases.forEach(
({ quad, style, texture, textureQuality, expected }) => {
const widgets = [
{ name: 'model_version', value: 'v3.0' },
{ name: 'quad', value: quad },
{ name: 'style', value: style },
{ name: 'texture', value: texture }
@@ -1909,7 +1929,7 @@ describe('useNodePricing', () => {

const price = getNodeDisplayPrice(node)
expect(price).toBe(
'$0.2-0.5/Run (varies with quad, style, texture & quality)'
'$0.1-0.65/Run (varies with quad, style, texture & quality)'
)
})

@@ -1919,7 +1939,7 @@ describe('useNodePricing', () => {

const price = getNodeDisplayPrice(node)
expect(price).toBe(
'$0.1-0.4/Run (varies with quad, style, texture & quality)'
'$0.1-0.65/Run (varies with quad, style, texture & quality)'
)
})

@@ -1931,7 +1951,7 @@ describe('useNodePricing', () => {

const price = getNodeDisplayPrice(node)
expect(price).toBe(
'$0.1-0.4/Run (varies with quad, style, texture & quality)'
'$0.1-0.65/Run (varies with quad, style, texture & quality)'
)
})



Loading…
Cancel
Save
Baidu
map