14 Commits

Author SHA1 Message Date
  Simula_r fa37112caf
feat(cloud): yearly pricing (#7572) 2 days ago
  Alexander Brown ed7dce84e0
🤖 Add code review and function declaration/expression preference to AGENTS.md (#7577) 2 days ago
  Comfy Org PR Bot 02d3b38a26
1.36.3 (#7575) 2 days ago
  Terry Jia 84561a1eb2
feat: upgrade Vite from v5 to v7 (#7566) 2 days ago
  Benjamin Lu e1294d66cc
Hide queue overlay header menu on cloud (#7571) 2 days ago
  AustinMroz 0ad5509037
Fix selecting loras on cloud (#7560) 2 days ago
  Kelly Yang 97386b0a14
Fix: the wrong selection under the hand mode (#7541) 2 days 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
27 changed files with 2454 additions and 2025 deletions
Split View
  1. +0
    -4
      .env_example
  2. +2
    -1
      .storybook/preview.ts
  3. +25
    -1
      AGENTS.md
  4. +1
    -1
      CLAUDE.md
  5. +3
    -2
      eslint.config.ts
  6. +0
    -2
      global.d.ts
  7. +3
    -3
      package.json
  8. +1
    -1
      packages/design-system/src/css/style.css
  9. +1899
    -1743
      pnpm-lock.yaml
  10. +16
    -15
      pnpm-workspace.yaml
  11. +2
    -1
      src/components/queue/QueueOverlayHeader.vue
  12. +3
    -3
      src/composables/graph/useGraphNodeManager.ts
  13. +34
    -14
      src/locales/en/main.json
  14. +106
    -24
      src/locales/en/nodeDefs.json
  15. +4
    -0
      src/locales/en/settings.json
  16. +256
    -146
      src/platform/cloud/subscription/components/PricingTable.vue
  17. +20
    -32
      src/platform/cloud/subscription/components/SubscriptionRequiredDialogContent.vue
  18. +4
    -3
      src/platform/cloud/subscription/composables/useSubscriptionDialog.ts
  19. +0
    -2
      src/platform/remoteConfig/types.ts
  20. +24
    -1
      src/platform/settings/components/ServerConfigPanel.vue
  21. +42
    -19
      src/renderer/extensions/vueNodes/VideoPreview.vue
  22. +1
    -0
      src/renderer/extensions/vueNodes/components/LGraphNode.vue
  23. +1
    -5
      src/renderer/extensions/vueNodes/components/NodeWidgets.vue
  24. +3
    -0
      src/renderer/extensions/vueNodes/widgets/components/WidgetSelectDropdown.vue
  25. +1
    -0
      src/renderer/extensions/vueNodes/widgets/components/form/dropdown/FormDropdownMenuActions.vue
  26. +1
    -2
      src/vite-env.d.ts
  27. +2
    -0
      vite.config.mts

+ 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)
}


+ 25
- 1
AGENTS.md View File

@@ -150,7 +150,8 @@ The project uses **Nx** for build orchestration and task management
21. Minimize [nesting](https://wiki.c2.com/?ArrowAntiPattern), e.g. `if () { ... }` or `for () { ... }`
22. Avoid mutable state, prefer immutability and assignment at point of declaration
23. Favor pure functions (especially testable ones)
24. Watch out for [Code Smells](https://wiki.c2.com/?CodeSmell) and refactor to avoid them
24. Do not use function expressions if it's possible to use function declarations instead
25. Watch out for [Code Smells](https://wiki.c2.com/?CodeSmell) and refactor to avoid them

## Testing Guidelines

@@ -214,6 +215,29 @@ The project uses **Nx** for build orchestration and task management
- Thousands of users and extensions
- Prioritize clean interfaces that restrict extension access

### Code Review

In doing a code review, you should make sure that:

- The code is well-designed.
- The functionality is good for the users of the code.
- Any UI changes are sensible and look good.
- Any parallel programming is done safely.
- The code isn’t more complex than it needs to be.
- The developer isn’t implementing things they might need in the future but don’t know they need now.
- Code has appropriate unit tests.
- Tests are well-designed.
- The developer used clear names for everything.
- Comments are clear and useful, and mostly explain why instead of what.
- Code is appropriately documented (generally in g3doc).
- The code conforms to our style guides.

#### [Complexity](https://google.github.io/eng-practices/review/reviewer/looking-for.html#complexity)

Is the CL more complex than it should be? Check this at every level of the CL—are individual lines too complex? Are functions too complex? Are classes too complex? “Too complex” usually means “can’t be understood quickly by code readers.” It can also mean “developers are likely to introduce bugs when they try to call or modify this code.”

A particular type of complexity is over-engineering, where developers have made the code more generic than it needs to be, or added functionality that isn’t presently needed by the system. Reviewers should be especially vigilant about over-engineering. Encourage developers to solve the problem they know needs to be solved now, not the problem that the developer speculates might need to be solved in the future. The future problem should be solved once it arrives and you can see its actual shape and requirements in the physical universe.

## Repository Navigation

- Check README files in key folders (tests-ui, browser_tests, composables, etc.)


+ 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



+ 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.2",
"version": "1.36.3",
"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",


+ 1
- 1
packages/design-system/src/css/style.css View File

@@ -235,7 +235,7 @@
--brand-yellow: var(--color-electric-400);
--brand-blue: var(--color-sapphire-700);
--secondary-background: var(--color-smoke-200);
--secondary-background-hover: var(--color-smoke-200);
--secondary-background-hover: var(--color-smoke-400);
--secondary-background-selected: var(--color-smoke-600);
--base-background: var(--color-white);
--primary-background: var(--color-azure-400);


+ 1899
- 1743
pnpm-lock.yaml
File diff suppressed because it is too large
View File


+ 16
- 15
pnpm-workspace.yaml View File

@@ -11,10 +11,10 @@ catalog:
'@iconify/tailwind': ^1.1.3
'@intlify/eslint-plugin-vue-i18n': ^4.1.0
'@lobehub/i18n-cli': ^1.25.1
'@nx/eslint': 21.4.1
'@nx/playwright': 21.4.1
'@nx/storybook': 21.4.1
'@nx/vite': 21.4.1
'@nx/eslint': 22.2.6
'@nx/playwright': 22.2.6
'@nx/storybook': 22.2.4
'@nx/vite': 22.2.6
'@pinia/testing': ^0.1.5
'@playwright/test': ^1.52.0
'@prettier/plugin-oxc': ^0.1.3
@@ -27,19 +27,19 @@ 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
'@types/jsdom': ^21.1.7
'@types/node': ^20.14.8
'@types/node': ^20.19.0
'@types/semver': ^7.7.0
'@types/three': ^0.169.0
'@vitejs/plugin-vue': ^5.1.4
'@vitejs/plugin-vue': ^6.0.0
'@vitest/coverage-v8': ^3.2.4
'@vitest/ui': ^3.0.0
'@vitest/ui': ^3.2.0
'@vue/test-utils': ^2.4.6
'@vueuse/core': ^11.0.0
'@vueuse/integrations': ^13.9.0
@@ -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
@@ -67,7 +67,7 @@ catalog:
lint-staged: ^15.5.2
markdown-table: ^3.0.4
mixpanel-browser: ^2.71.0
nx: 21.4.1
nx: 22.2.6
oxlint: ^1.32.0
oxlint-tsgolint: ^0.8.4
picocolors: ^1.1.1
@@ -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
@@ -90,10 +91,10 @@ catalog:
unplugin-icons: ^0.22.0
unplugin-typegpu: 0.8.0
unplugin-vue-components: ^0.28.0
vite: ^5.4.19
vite: ^7.0.0
vite-plugin-dts: ^4.5.4
vite-plugin-html: ^3.2.2
vite-plugin-vue-devtools: ^7.7.6
vite-plugin-vue-devtools: ^8.0.0
vitest: ^3.2.4
vue: ^3.5.13
vue-component-type-helpers: ^3.0.7


+ 2
- 1
src/components/queue/QueueOverlayHeader.vue View File

@@ -17,7 +17,7 @@
</span>
</span>
</div>
<div class="flex items-center gap-1">
<div v-if="!isCloud" class="flex items-center gap-1">
<IconButton
v-tooltip.top="moreTooltipConfig"
type="transparent"
@@ -75,6 +75,7 @@ import { useI18n } from 'vue-i18n'
import IconButton from '@/components/button/IconButton.vue'
import IconTextButton from '@/components/button/IconTextButton.vue'
import { buildTooltipConfig } from '@/composables/useTooltipConfig'
import { isCloud } from '@/platform/distribution/types'

defineProps<{
headerTitle: string


+ 3
- 3
src/composables/graph/useGraphNodeManager.ts View File

@@ -337,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
) => {
@@ -364,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)
}



+ 34
- 14
src/locales/en/main.json View File

@@ -631,7 +631,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",
@@ -1455,7 +1457,8 @@
"Vidu": "Vidu",
"": "",
"camera": "camera",
"Wan": "Wan"
"Wan": "Wan",
"zimage": "zimage"
},
"dataTypes": {
"*": "*",
@@ -1558,6 +1561,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",
@@ -1899,7 +1907,7 @@
"comfyCloudLogo": "Comfy Cloud Logo",
"beta": "BETA",
"perMonth": "/ month",
"usdPerMonth": "USD / month",
"usdPerMonth": "USD / mo",
"renewsDate": "Renews {date}",
"expiresDate": "Expires {date}",
"manageSubscription": "Manage subscription",
@@ -1921,16 +1929,21 @@
"yourPlanIncludes": "Your plan includes:",
"viewMoreDetails": "View more details",
"learnMore": "Learn more",
"billedMonthly": "Billed monthly",
"billedAnnually": "{total} Billed annually",
"monthly": "Monthly",
"yearly": "Yearly",
"messageSupport": "Message support",
"invoiceHistory": "Invoice history",
"benefits": {
"benefit1": "$10 in monthly credits for Partner Nodes — top up when needed",
"benefit2": "Up to 30 min runtime per job"
},
"yearlyDiscount": "20% DISCOUNT",
"tiers": {
"founder": {
"name": "Founder's Edition",
"price": "20.00",
"price": "20",
"benefits": {
"monthlyCredits": "5,460",
"monthlyCreditsLabel": "monthly credits",
@@ -1943,7 +1956,11 @@
},
"standard": {
"name": "Standard",
"price": "20.00",
"price": {
"monthly": "20",
"yearly": "16",
"annualTotal": "$192"
},
"benefits": {
"monthlyCredits": "4,200",
"monthlyCreditsLabel": "monthly credits",
@@ -1957,7 +1974,12 @@
},
"creator": {
"name": "Creator",
"price": "35.00",
"price": {
"monthly": "35",
"yearly": "28",
"annualTotal": "$336"
},

"benefits": {
"monthlyCredits": "7,400",
"monthlyCreditsLabel": "monthly credits",
@@ -1971,7 +1993,11 @@
},
"pro": {
"name": "Pro",
"price": "100.00",
"price": {
"monthly": "100",
"yearly": "80",
"annualTotal": "$960"
},
"benefits": {
"monthlyCredits": "21,100",
"monthlyCreditsLabel": "monthly credits",
@@ -1989,12 +2015,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",
@@ -2002,7 +2022,7 @@
"description": "Choose the best plan for you",
"haveQuestions": "Have questions or wondering about enterprise?",
"contactUs": "Contact us",
"viewEnterprise": "view enterprise",
"viewEnterprise": "View enterprise",
"partnerNodesCredits": "Partner nodes pricing",
"mostPopular": "Most popular",
"currentPlan": "Current Plan",


+ 106
- 24
src/locales/en/nodeDefs.json View File

@@ -2617,7 +2617,7 @@
}
},
"FluxKontextMultiReferenceLatentMethod": {
"display_name": "FluxKontextMultiReferenceLatentMethod",
"display_name": "Edit Model Reference Method",
"inputs": {
"conditioning": {
"name": "conditioning"
@@ -13022,6 +13022,45 @@
},
"texture_format": {
"name": "texture_format"
},
"force_symmetry": {
"name": "force_symmetry"
},
"flatten_bottom": {
"name": "flatten_bottom"
},
"flatten_bottom_threshold": {
"name": "flatten_bottom_threshold"
},
"pivot_to_center_bottom": {
"name": "pivot_to_center_bottom"
},
"scale_factor": {
"name": "scale_factor"
},
"with_animation": {
"name": "with_animation"
},
"pack_uv": {
"name": "pack_uv"
},
"bake": {
"name": "bake"
},
"part_names": {
"name": "part_names"
},
"fbx_preset": {
"name": "fbx_preset"
},
"export_vertex_colors": {
"name": "export_vertex_colors"
},
"export_orientation": {
"name": "export_orientation"
},
"animate_in_place": {
"name": "animate_in_place"
}
}
},
@@ -13064,6 +13103,9 @@
},
"quad": {
"name": "quad"
},
"geometry_quality": {
"name": "geometry_quality"
}
},
"outputs": {
@@ -13122,6 +13164,9 @@
},
"quad": {
"name": "quad"
},
"geometry_quality": {
"name": "geometry_quality"
}
},
"outputs": {
@@ -13232,6 +13277,9 @@
},
"quad": {
"name": "quad"
},
"geometry_quality": {
"name": "geometry_quality"
}
},
"outputs": {
@@ -14524,7 +14572,7 @@
},
"WanImageToImageApi": {
"display_name": "Wan Image to Image",
"description": "Generates an image from one or two input images and a text prompt. The output image is currently fixed at 1.6 MP; its aspect ratio matches the input image(s).",
"description": "Generates an image from one or two input images and a text prompt. The output image is currently fixed at 1.6 MP, and its aspect ratio matches the input image(s).",
"inputs": {
"model": {
"name": "model",
@@ -14532,15 +14580,15 @@
},
"image": {
"name": "image",
"tooltip": "Single-image editing or multi-image fusion, maximum 2 images."
"tooltip": "Single-image editing or multi-image fusion. Maximum 2 images."
},
"prompt": {
"name": "prompt",
"tooltip": "Prompt used to describe the elements and visual features, supports English/Chinese."
"tooltip": "Prompt describing the elements and visual features. Supports English and Chinese."
},
"negative_prompt": {
"name": "negative_prompt",
"tooltip": "Negative text prompt to guide what to avoid."
"tooltip": "Negative prompt describing what to avoid."
},
"seed": {
"name": "seed",
@@ -14548,7 +14596,7 @@
},
"watermark": {
"name": "watermark",
"tooltip": "Whether to add an \"AI generated\" watermark to the result."
"tooltip": "Whether to add an AI-generated watermark to the result."
},
"control_after_generate": {
"name": "control after generate"
@@ -14608,7 +14656,7 @@
},
"WanImageToVideoApi": {
"display_name": "Wan Image to Video",
"description": "Generates video based on the first frame and text prompt.",
"description": "Generates a video from the first frame and a text prompt.",
"inputs": {
"model": {
"name": "model",
@@ -14619,22 +14667,22 @@
},
"prompt": {
"name": "prompt",
"tooltip": "Prompt used to describe the elements and visual features, supports English/Chinese."
"tooltip": "Prompt describing the elements and visual features. Supports English and Chinese."
},
"negative_prompt": {
"name": "negative_prompt",
"tooltip": "Negative text prompt to guide what to avoid."
"tooltip": "Negative prompt describing what to avoid."
},
"resolution": {
"name": "resolution"
},
"duration": {
"name": "duration",
"tooltip": "Available durations: 5 and 10 seconds"
"tooltip": "Duration 15 available only for WAN2.6 model."
},
"audio": {
"name": "audio",
"tooltip": "Audio must contain a clear, loud voice, without extraneous noise, background music."
"tooltip": "Audio must contain a clear, loud voice, without extraneous noise or background music."
},
"seed": {
"name": "seed",
@@ -14642,7 +14690,7 @@
},
"generate_audio": {
"name": "generate_audio",
"tooltip": "If there is no audio input, generate audio automatically."
"tooltip": "If no audio input is provided, generate audio automatically."
},
"prompt_extend": {
"name": "prompt_extend",
@@ -14650,7 +14698,11 @@
},
"watermark": {
"name": "watermark",
"tooltip": "Whether to add an \"AI generated\" watermark to the result."
"tooltip": "Whether to add an AI-generated watermark to the result."
},
"shot_type": {
"name": "shot_type",
"tooltip": "Specifies the shot type for the generated video, that is, whether the video is a single continuous shot or multiple shots with cuts. This parameter takes effect only when prompt_extend is True."
},
"control_after_generate": {
"name": "control after generate"
@@ -14923,7 +14975,7 @@
},
"WanTextToImageApi": {
"display_name": "Wan Text to Image",
"description": "Generates image based on text prompt.",
"description": "Generates an image based on a text prompt.",
"inputs": {
"model": {
"name": "model",
@@ -14931,11 +14983,11 @@
},
"prompt": {
"name": "prompt",
"tooltip": "Prompt used to describe the elements and visual features, supports English/Chinese."
"tooltip": "Prompt describing the elements and visual features. Supports English and Chinese."
},
"negative_prompt": {
"name": "negative_prompt",
"tooltip": "Negative text prompt to guide what to avoid."
"tooltip": "Negative prompt describing what to avoid."
},
"width": {
"name": "width"
@@ -14953,7 +15005,7 @@
},
"watermark": {
"name": "watermark",
"tooltip": "Whether to add an \"AI generated\" watermark to the result."
"tooltip": "Whether to add an AI-generated watermark to the result."
},
"control_after_generate": {
"name": "control after generate"
@@ -14967,7 +15019,7 @@
},
"WanTextToVideoApi": {
"display_name": "Wan Text to Video",
"description": "Generates video based on text prompt.",
"description": "Generates a video based on a text prompt.",
"inputs": {
"model": {
"name": "model",
@@ -14975,22 +15027,22 @@
},
"prompt": {
"name": "prompt",
"tooltip": "Prompt used to describe the elements and visual features, supports English/Chinese."
"tooltip": "Prompt describing the elements and visual features. Supports English and Chinese."
},
"negative_prompt": {
"name": "negative_prompt",
"tooltip": "Negative text prompt to guide what to avoid."
"tooltip": "Negative prompt describing what to avoid."
},
"size": {
"name": "size"
},
"duration": {
"name": "duration",
"tooltip": "Available durations: 5 and 10 seconds"
"tooltip": "A 15-second duration is available only for the Wan 2.6 model."
},
"audio": {
"name": "audio",
"tooltip": "Audio must contain a clear, loud voice, without extraneous noise, background music."
"tooltip": "Audio must contain a clear, loud voice, without extraneous noise or background music."
},
"seed": {
"name": "seed",
@@ -14998,7 +15050,7 @@
},
"generate_audio": {
"name": "generate_audio",
"tooltip": "If there is no audio input, generate audio automatically."
"tooltip": "If no audio input is provided, generate audio automatically."
},
"prompt_extend": {
"name": "prompt_extend",
@@ -15006,7 +15058,11 @@
},
"watermark": {
"name": "watermark",
"tooltip": "Whether to add an \"AI generated\" watermark to the result."
"tooltip": "Whether to add an AI-generated watermark to the result."
},
"shot_type": {
"name": "shot_type",
"tooltip": "Specifies the shot type for the generated video, that is, whether the video is a single continuous shot or multiple shots with cuts. This parameter takes effect only when prompt_extend is True."
},
"control_after_generate": {
"name": "control after generate"
@@ -15146,5 +15202,31 @@
},
"waiting for camera___": {}
}
},
"ZImageFunControlnet": {
"display_name": "ZImageFunControlnet",
"inputs": {
"model": {
"name": "model"
},
"model_patch": {
"name": "model_patch"
},
"vae": {
"name": "vae"
},
"strength": {
"name": "strength"
},
"image": {
"name": "image"
},
"inpaint_image": {
"name": "inpaint_image"
},
"mask": {
"name": "mask"
}
}
}
}

+ 4
- 0
src/locales/en/settings.json View File

@@ -111,6 +111,10 @@
"Arrow": "Arrow"
}
},
"Comfy_Graph_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."
},
"Comfy_Graph_ZoomSpeed": {
"name": "Canvas zoom speed"
},


+ 256
- 146
src/platform/cloud/subscription/components/PricingTable.vue View File

@@ -1,171 +1,246 @@
<template>
<div class="flex flex-row items-stretch gap-6">
<div
v-for="tier in tiers"
:key="tier.id"
class="flex-1 flex flex-col rounded-2xl border border-interface-stroke bg-interface-panel-surface shadow-[0_0_12px_rgba(0,0,0,0.1)]"
>
<div class="flex flex-col gap-6 p-8">
<div class="flex flex-row items-center gap-2">
<span
class="font-inter text-base font-bold leading-normal text-base-foreground"
>
{{ tier.name }}
</span>
<div
v-if="tier.isPopular"
class="rounded-full bg-background px-1 text-xs font-semibold uppercase tracking-wide text-foreground h-[13px] leading-[13px]"
>
{{ t('subscription.mostPopular') }}
<div class="flex flex-col gap-8">
<div class="flex justify-center">
<SelectButton
v-model="currentBillingCycle"
:options="billingCycleOptions"
option-label="label"
option-value="value"
:allow-empty="false"
unstyled
:pt="{
root: {
class: 'flex gap-1 bg-secondary-background rounded-lg p-1.5'
},
pcToggleButton: {
root: ({ context }: ToggleButtonPassThroughMethodOptions) => ({
class: [
'w-36 h-8 rounded-md transition-colors cursor-pointer border-none outline-none ring-0 text-sm font-medium flex items-center justify-center',
context.active
? 'bg-base-foreground text-base-background'
: 'bg-transparent text-muted-foreground hover:bg-secondary-background-hover'
]
}),
label: { class: 'flex items-center gap-2 ' }
}
}"
>
<template #option="{ option }">
<div class="flex items-center gap-2">
<span>{{ option.label }}</span>
<div
v-if="option.value === 'yearly'"
class="bg-primary-background text-white text-[11px] px-1 py-0.5 rounded-full flex items-center font-bold"
>
-20%
</div>
</div>
</div>
<div class="flex flex-row items-baseline gap-2">
<span
class="font-inter text-[32px] font-semibold leading-normal text-base-foreground"
>
${{ tier.price }}
</span>
<span
class="font-inter text-base font-normal leading-normal text-base-foreground"
>
{{ t('subscription.usdPerMonth') }}
</span>
</div>
</div>

<div class="flex flex-col gap-4 px-8 pb-0 flex-1">
<div class="flex flex-row items-center justify-between">
<span
class="font-inter text-sm font-normal leading-normal text-muted-foreground"
>
{{ t('subscription.monthlyCreditsLabel') }}
</span>
<div class="flex flex-row items-center gap-1">
<i class="icon-[lucide--component] text-amber-400 text-sm" />
</template>
</SelectButton>
</div>
<div class="flex flex-col xl:flex-row items-stretch gap-6">
<div
v-for="tier in tiers"
:key="tier.id"
:class="
cn(
'flex-1 flex flex-col rounded-2xl border border-border-default bg-base-background shadow-[0_0_12px_rgba(0,0,0,0.1)]',
tier.isPopular ? 'border-muted-foreground' : ''
)
"
>
<div class="p-8 pb-0 flex flex-col gap-8">
<div class="flex flex-row items-center gap-2 justify-between">
<span
class="font-inter text-sm font-bold leading-normal text-base-foreground"
class="font-inter text-base font-bold leading-normal text-base-foreground"
>
{{ tier.credits }}
{{ tier.name }}
</span>
<div
v-if="tier.isPopular"
class="rounded-full bg-base-foreground px-1.5 text-[11px] font-bold uppercase text-base-background h-5 tracking-tight flex items-center"
>
{{ t('subscription.mostPopular') }}
</div>
</div>
<div class="flex flex-col">
<div class="flex flex-col gap-2">
<div class="flex flex-row items-baseline gap-2">
<span
class="font-inter text-[32px] font-semibold leading-normal text-base-foreground"
>
<span
v-show="currentBillingCycle === 'yearly'"
class="line-through text-2xl text-muted-foreground"
>
${{ tier.price.monthly }}
</span>
${{ getPrice(tier) }}
</span>
<span
class="font-inter text-xl leading-normal text-base-foreground"
>
{{ t('subscription.usdPerMonth') }}
</span>
</div>
<div class="flex items-center gap-2">
<span class="text-sm text-muted-foreground">
{{
currentBillingCycle === 'yearly'
? t('subscription.billedAnnually', {
total: tier.price.annualTotal
})
: t('subscription.billedMonthly')
}}
</span>
</div>
</div>
</div>
</div>

<div class="flex flex-row items-center justify-between">
<span class="text-sm font-normal text-muted-foreground">
{{ t('subscription.maxDurationLabel') }}
</span>
<span
class="font-inter text-sm font-bold leading-normal text-base-foreground"
>
{{ tier.maxDuration }}
</span>
</div>
<div class="flex flex-col gap-4 pb-0 flex-1">
<div class="flex flex-row items-center justify-between">
<span
class="font-inter text-sm font-normal leading-normal text-foreground"
>
{{ t('subscription.monthlyCreditsLabel') }}
</span>
<div class="flex flex-row items-center gap-1">
<i class="icon-[lucide--component] text-amber-400 text-sm" />
<span
class="font-inter text-sm font-bold leading-normal text-base-foreground"
>
{{ tier.credits }}
</span>
</div>
</div>

<div class="flex flex-row items-center justify-between">
<span class="text-sm font-normal text-muted-foreground">
{{ t('subscription.gpuLabel') }}
</span>
<i class="pi pi-check text-xs text-success-foreground" />
</div>
<div class="flex flex-row items-center justify-between">
<span class="text-sm font-normal text-foreground">
{{ t('subscription.maxDurationLabel') }}
</span>
<span
class="font-inter text-sm font-bold leading-normal text-base-foreground"
>
{{ tier.maxDuration }}
</span>
</div>

<div class="flex flex-row items-center justify-between">
<span class="text-sm font-normal text-muted-foreground">
{{ t('subscription.addCreditsLabel') }}
</span>
<i class="pi pi-check text-xs text-success-foreground" />
</div>
<div class="flex flex-row items-center justify-between">
<span class="text-sm font-normal text-foreground">
{{ t('subscription.gpuLabel') }}
</span>
<i class="pi pi-check text-xs text-success-foreground" />
</div>

<div class="flex flex-row items-center justify-between">
<span class="text-sm font-normal text-muted-foreground">
{{ t('subscription.customLoRAsLabel') }}
</span>
<i
v-if="tier.customLoRAs"
class="pi pi-check text-xs text-success-foreground"
/>
<i v-else class="pi pi-times text-xs text-muted-foreground" />
</div>
<div class="flex flex-row items-center justify-between">
<span class="text-sm font-normal text-foreground">
{{ t('subscription.addCreditsLabel') }}
</span>
<i class="pi pi-check text-xs text-success-foreground" />
</div>

<div class="flex flex-col gap-2">
<div class="flex flex-row items-start justify-between">
<div class="flex flex-col gap-2">
<span class="text-sm font-normal text-muted-foreground">
{{ t('subscription.videoEstimateLabel') }}
<div class="flex flex-row items-center justify-between">
<span class="text-sm font-normal text-foreground">
{{ t('subscription.customLoRAsLabel') }}
</span>
<div class="flex flex-row items-center gap-2 opacity-50">
<i
class="pi pi-question-circle text-xs text-muted-foreground"
/>
<i
v-if="tier.customLoRAs"
class="pi pi-check text-xs text-success-foreground"
/>
<i v-else class="pi pi-times text-xs text-foreground" />
</div>

<div class="flex flex-col gap-2">
<div class="flex flex-row items-start justify-between">
<div class="flex flex-col gap-2">
<span class="text-sm font-normal text-foreground">
{{ t('subscription.videoEstimateLabel') }}
</span>
<div class="flex flex-row items-center gap-2 group pt-2">
<i
class="pi pi-question-circle text-xs text-muted-foreground group-hover:text-base-foreground"
/>
<span
class="text-sm font-normal text-muted-foreground cursor-pointer group-hover:text-base-foreground"
@click="togglePopover"
>
{{ t('subscription.videoEstimateHelp') }}
</span>
</div>
</div>
<span
class="text-sm font-normal text-muted-foreground cursor-pointer hover:text-base-foreground"
@click="togglePopover"
class="font-inter text-sm font-bold leading-normal text-base-foreground"
>
{{ t('subscription.videoEstimateHelp') }}
{{ tier.videoEstimate }}
</span>
</div>
</div>
<span
class="font-inter text-sm font-bold leading-normal text-base-foreground"
>
{{ tier.videoEstimate }}
</span>
</div>
</div>
<div class="flex flex-col p-8">
<Button
:label="getButtonLabel(tier)"
:severity="getButtonSeverity(tier)"
:disabled="isLoading || isCurrentPlan(tier.key)"
:loading="loadingTier === tier.key"
:class="
cn(
'h-10 w-full',
tier.key === 'creator'
? 'bg-base-foreground border-transparent hover:bg-inverted-background-hover'
: 'bg-secondary-background border-transparent hover:bg-secondary-background-hover focus:bg-secondary-background-selected'
)
"
:pt="{
label: {
class: getButtonTextClass(tier)
}
}"
@click="() => handleSubscribe(tier.key)"
/>
</div>
</div>
</div>

<div class="flex flex-col p-8">
<Button
:label="getButtonLabel(tier)"
:severity="getButtonSeverity(tier)"
:disabled="isLoading || isCurrentPlan(tier.key)"
:loading="loadingTier === tier.key"
class="h-10 w-full"
:pt="{
label: {
class: getButtonTextClass(tier)
}
}"
@click="() => handleSubscribe(tier.key)"
/>
<!-- Video Estimate Help Popover -->
<Popover
ref="popover"
append-to="body"
:auto-z-index="true"
:base-z-index="1000"
:dismissable="true"
:close-on-escape="true"
unstyled
:pt="{
root: {
class:
'rounded-lg border border-interface-stroke bg-interface-panel-surface shadow-lg p-4 max-w-xs'
}
}"
>
<div class="flex flex-col gap-2">
<p class="text-sm text-base-foreground">
{{ t('subscription.videoEstimateExplanation') }}
</p>
<a
href="https://cloud.comfy.org/?template=video_wan2_2_14B_fun_camera"
target="_blank"
rel="noopener noreferrer"
class="text-sm text-azure-600 hover:text-azure-400 underline"
>
{{ t('subscription.videoEstimateTryTemplate') }}
</a>
</div>
</div>
</Popover>
</div>

<!-- Video Estimate Help Popover -->
<Popover
ref="popover"
append-to="body"
:auto-z-index="true"
:base-z-index="1000"
:dismissable="true"
:close-on-escape="true"
unstyled
:pt="{
root: {
class:
'rounded-lg border border-interface-stroke bg-interface-panel-surface shadow-lg p-4 max-w-xs'
}
}"
>
<div class="flex flex-col gap-2">
<p class="text-sm text-base-foreground">
{{ t('subscription.videoEstimateExplanation') }}
</p>
<a
href="https://cloud.comfy.org/?template=video_wan2_2_14B_fun_camera"
target="_blank"
rel="noopener noreferrer"
class="text-sm text-azure-600 hover:text-azure-400 underline"
>
{{ t('subscription.videoEstimateTryTemplate') }}
</a>
</div>
</Popover>
</template>

<script setup lang="ts">
import { cn } from '@comfyorg/tailwind-utils'
import Button from 'primevue/button'
import Popover from 'primevue/popover'
import SelectButton from 'primevue/selectbutton'
import type { ToggleButtonPassThroughMethodOptions } from 'primevue/togglebutton'
import { computed, ref } from 'vue'

import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
@@ -182,12 +257,25 @@ import type { components } from '@/types/comfyRegistryTypes'

type SubscriptionTier = components['schemas']['SubscriptionTier']
type TierKey = 'standard' | 'creator' | 'pro'
type CheckoutTier = TierKey | `${TierKey}-yearly`

type BillingCycle = 'monthly' | 'yearly'

const getCheckoutTier = (
tierKey: TierKey,
billingCycle: BillingCycle
): CheckoutTier => (billingCycle === 'yearly' ? `${tierKey}-yearly` : tierKey)

interface BillingCycleOption {
label: string
value: BillingCycle
}

interface PricingTierConfig {
id: SubscriptionTier
key: TierKey
name: string
price: string
price: Record<BillingCycle, string> & { annualTotal: string }
credits: string
maxDuration: string
customLoRAs: boolean
@@ -195,6 +283,11 @@ interface PricingTierConfig {
isPopular?: boolean
}

const billingCycleOptions: BillingCycleOption[] = [
{ label: t('subscription.yearly'), value: 'yearly' },
{ label: t('subscription.monthly'), value: 'monthly' }
]

const TIER_TO_KEY: Record<SubscriptionTier, TierKey> = {
STANDARD: 'standard',
CREATOR: 'creator',
@@ -207,7 +300,11 @@ const tiers: PricingTierConfig[] = [
id: 'STANDARD',
key: 'standard',
name: t('subscription.tiers.standard.name'),
price: t('subscription.tiers.standard.price'),
price: {
monthly: t('subscription.tiers.standard.price.monthly'),
yearly: t('subscription.tiers.standard.price.yearly'),
annualTotal: t('subscription.tiers.standard.price.annualTotal')
},
credits: t('subscription.credits.standard'),
maxDuration: t('subscription.maxDuration.standard'),
customLoRAs: false,
@@ -218,7 +315,11 @@ const tiers: PricingTierConfig[] = [
id: 'CREATOR',
key: 'creator',
name: t('subscription.tiers.creator.name'),
price: t('subscription.tiers.creator.price'),
price: {
monthly: t('subscription.tiers.creator.price.monthly'),
yearly: t('subscription.tiers.creator.price.yearly'),
annualTotal: t('subscription.tiers.creator.price.annualTotal')
},
credits: t('subscription.credits.creator'),
maxDuration: t('subscription.maxDuration.creator'),
customLoRAs: true,
@@ -229,7 +330,11 @@ const tiers: PricingTierConfig[] = [
id: 'PRO',
key: 'pro',
name: t('subscription.tiers.pro.name'),
price: t('subscription.tiers.pro.price'),
price: {
monthly: t('subscription.tiers.pro.price.monthly'),
yearly: t('subscription.tiers.pro.price.yearly'),
annualTotal: t('subscription.tiers.pro.price.annualTotal')
},
credits: t('subscription.credits.pro'),
maxDuration: t('subscription.maxDuration.pro'),
customLoRAs: true,
@@ -246,6 +351,7 @@ const { wrapWithErrorHandlingAsync } = useErrorHandling()
const isLoading = ref(false)
const loadingTier = ref<TierKey | null>(null)
const popover = ref()
const currentBillingCycle = ref<BillingCycle>('yearly')

const currentTierKey = computed<TierKey | null>(() =>
subscriptionTier.value ? TIER_TO_KEY[subscriptionTier.value] : null
@@ -274,17 +380,21 @@ const getButtonSeverity = (tier: PricingTierConfig): 'primary' | 'secondary' =>

const getButtonTextClass = (tier: PricingTierConfig): string =>
tier.key === 'creator'
? 'font-inter text-sm font-bold leading-normal text-white'
? 'font-inter text-sm font-bold leading-normal text-base-background'
: 'font-inter text-sm font-bold leading-normal text-primary-foreground'

const getPrice = (tier: PricingTierConfig): string =>
tier.price[currentBillingCycle.value]

const initiateCheckout = async (tierKey: TierKey) => {
const authHeader = await getAuthHeader()
if (!authHeader) {
throw new FirebaseAuthStoreError(t('toastMessages.userNotAuthenticated'))
}

const checkoutTier = getCheckoutTier(tierKey, currentBillingCycle.value)
const response = await fetch(
`${getComfyApiBaseUrl()}/customers/cloud-subscription-checkout/${tierKey}`,
`${getComfyApiBaseUrl()}/customers/cloud-subscription-checkout/${checkoutTier}`,
{
method: 'POST',
headers: { ...authHeader, 'Content-Type': 'application/json' }


+ 20
- 32
src/platform/cloud/subscription/components/SubscriptionRequiredDialogContent.vue View File

@@ -1,45 +1,33 @@
<template>
<div
v-if="showCustomPricingTable"
class="flex flex-col gap-6 rounded-[24px] border border-interface-stroke bg-[var(--p-dialog-background)] p-4 shadow-[0_25px_80px_rgba(5,6,12,0.45)] md:p-6"
class="relative flex flex-col p-4 pt-8 md:p-16 !overflow-y-auto h-full gap-8"
>
<div
class="flex flex-col gap-6 md:flex-row md:items-start md:justify-between"
>
<div class="flex flex-col gap-2 text-left md:max-w-2xl">
<div
class="inline-flex items-center gap-2 text-xs font-semibold uppercase tracking-[0.3em] text-text-secondary"
>
{{ $t('subscription.required.title') }}
<CloudBadge
reverse-order
no-padding
background-color="var(--p-dialog-background)"
use-subscription
/>
</div>
<div class="text-3xl font-semibold leading-tight md:text-4xl">
{{ $t('subscription.description') }}
</div>
</div>
<Button
icon="pi pi-times"
text
rounded
class="h-10 w-10 shrink-0 text-text-secondary hover:bg-white/10"
:aria-label="$t('g.close')"
@click="handleClose"
/>
<Button
:pt="{
icon: { class: 'text-xl' }
}"
icon="pi pi-times"
text
rounded
class="shrink-0 text-text-secondary hover:bg-white/10 absolute right-2.5 top-2.5"
:aria-label="$t('g.close')"
@click="handleClose"
/>
<div class="text-center">
<h2 class="text-xl lg:text-2xl text-muted-foreground m-0">
{{ $t('subscription.description') }}
</h2>
</div>

<PricingTable class="flex-1" />

<!-- Contact and Enterprise Links -->
<div class="flex flex-col items-center">
<p class="text-sm text-text-secondary">
<div class="flex flex-col items-center gap-2">
<p class="text-sm text-text-secondary m-0">
{{ $t('subscription.haveQuestions') }}
</p>
<div class="flex items-center gap-2">
<div class="flex items-center gap-1.5">
<Button
:label="$t('subscription.contactUs')"
text
@@ -95,7 +83,7 @@
<div>
<div class="flex flex-col gap-6">
<div class="inline-flex items-center gap-2">
<div class="text-sm text-muted text-text-primary">
<div class="text-sm text-text-primary">
{{ $t('subscription.required.title') }}
</div>
<CloudBadge


+ 4
- 3
src/platform/cloud/subscription/composables/useSubscriptionDialog.ts View File

@@ -23,13 +23,14 @@ export const useSubscriptionDialog = () => {
onClose: hide
},
dialogComponentProps: {
style: 'width: min(1200px, 95vw); max-height: 90vh;',
style: 'width: min(1328px, 95vw); max-height: 90vh;',
pt: {
root: {
class: '!rounded-[32px] overflow-visible'
class: 'rounded-2xl bg-transparent'
},
content: {
class: '!p-0 bg-transparent'
class:
'!p-0 rounded-2xl border border-border-default bg-base-background/60 backdrop-blur-md shadow-[0_25px_80px_rgba(5,6,12,0.45)]'
}
}
}


+ 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,


+ 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
- 0
src/renderer/extensions/vueNodes/components/LGraphNode.vue View File

@@ -335,6 +335,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)
}


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

@@ -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)


+ 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
- 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 {


+ 2
- 0
vite.config.mts View File

@@ -170,6 +170,8 @@ export default defineConfig({
target: DEV_SERVER_COMFYUI_URL,
...cloudProxyConfig,
bypass: (req, res, _options) => {
if (!res) return null

// Return empty array for extensions API as these modules
// are not on vite's dev server.
if (req.url === '/api/extensions') {


Loading…
Cancel
Save
Baidu
map