Compare commits

...

31 Commits

Author SHA1 Message Date
Comfy Org PR Bot
62175277fc [backport cloud/1.34] fix: remove custom LoRA from subscription benefits display (#7398)
Backport of #7396 to `cloud/1.34`

Automatically created by backport workflow.

Co-authored-by: Luke Mino-Altherr <luke@comfy.org>
Co-authored-by: Claude <noreply@anthropic.com>
2025-12-11 15:58:27 -08:00
Comfy Org PR Bot
e907545e39 [backport cloud/1.34] fix: remove custom LoRA feature from standard tier (#7392)
Backport of #7391 to `cloud/1.34`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7392-backport-cloud-1-34-fix-remove-custom-LoRA-feature-from-standard-tier-2c66d73d3650814fb37de338ce016931)
by [Unito](https://www.unito.io)

Co-authored-by: Luke Mino-Altherr <luke@comfy.org>
Co-authored-by: Claude <noreply@anthropic.com>
2025-12-11 14:51:50 -08:00
Comfy Org PR Bot
44a1c7a194 [backport cloud/1.34] increase some API nodes pricing (#7386)
Backport of #7156 to `cloud/1.34`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7386-backport-cloud-1-34-increase-some-API-nodes-pricing-2c66d73d365081b68fe5d12062d7f0f5)
by [Unito](https://www.unito.io)

Co-authored-by: Alexander Piskun <13381981+bigcat88@users.noreply.github.com>
Co-authored-by: Luke Mino-Altherr <luke@comfy.org>
Co-authored-by: Hunter <huntcsg@users.noreply.github.com>
2025-12-11 14:18:26 -05:00
Comfy Org PR Bot
a1086d5df8 [backport cloud/1.34] fix: remove incorrect tooltip on remaining credit balance (#7384)
Backport of #7383 to `cloud/1.34`

Automatically created by backport workflow.

Co-authored-by: Luke Mino-Altherr <luke@comfy.org>
Co-authored-by: Claude <noreply@anthropic.com>
2025-12-11 10:56:05 -08:00
Christian Byrne
350044dd91 [backport cloud/1.34] remove useless/misleading toast in topup dialog (#7380)
## Summary
Backports the fix from main that removes the misleading "purchase
successful" toast that appears immediately after clicking the buy button
in the credit top-up dialog.

## Details
- Purchase actually happens on Stripe page, not immediately after
clicking
- Toast was confusing users into thinking purchase completed when it
hadn't
- Only error toast remains for actual failures
- Fixed merge conflict in `useCoreCommands.ts` by keeping cloud/1.34's
`SubgraphNode` import

## Related
- Original PR: #7375 
- Cherry-picked from: 29af56c154

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7380-backport-cloud-1-34-remove-useless-misleading-toast-in-topup-dialog-2c66d73d365081a1b46bdce6e3860a86)
by [Unito](https://www.unito.io)

Co-authored-by: GitHub Action <action@github.com>
2025-12-11 08:22:09 -07:00
Comfy Org PR Bot
d987d08ac9 [backport cloud/1.34] fix: consistent subscription dialog width (#7379)
Backport of #7378 to `cloud/1.34`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7379-backport-cloud-1-34-fix-consistent-subscription-dialog-width-2c66d73d36508178a944d2dcf8b2e275)
by [Unito](https://www.unito.io)

Co-authored-by: Christian Byrne <cbyrne@comfy.org>
Co-authored-by: GitHub Action <action@github.com>
2025-12-11 08:09:11 -07:00
Christian Byrne
719ebe1189 [backport cloud/1.34] Improve import model copy and examples (#7372)
## Summary
Backport of #7339 to cloud/1.34

Updates user-facing copy in the import model feature for clarity and
better examples.

## Changes
- **Example Link**: Changed from direct download URL to model page URL
(easier to find and copy)
- **Success Message**: Removed emoji for more professional tone
- **Support Documentation**: Updated Civitai link to include `/models`
path

Original PR: https://github.com/Comfy-Org/ComfyUI_frontend/pull/7339

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7372-backport-cloud-1-34-docs-Improve-import-model-copy-and-examples-2c66d73d365081b5ae9bd59d25b0ce65)
by [Unito](https://www.unito.io)

Co-authored-by: Luke Mino-Altherr <luke@comfy.org>
Co-authored-by: Claude <noreply@anthropic.com>
2025-12-11 06:27:17 -07:00
Comfy Org PR Bot
5c24bb2258 [backport cloud/1.34] fix: hardcoded color tokens (not theme-aware) (#7368)
Backport of #7366 to `cloud/1.34`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7368-backport-cloud-1-34-fix-hardcoded-color-tokens-not-theme-aware-2c66d73d365081b8a75ec5e12e9c6061)
by [Unito](https://www.unito.io)

Co-authored-by: Christian Byrne <cbyrne@comfy.org>
2025-12-11 04:29:48 -07:00
Comfy Org PR Bot
5e8cba5559 [backport cloud/1.34] feat: add popover with link to Wan Fun Control template on pricing table (#7364)
Backport of #7363 to `cloud/1.34`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7364-backport-cloud-1-34-feat-add-popover-with-link-to-Wan-Fun-Control-template-on-pricing--2c66d73d365081a1a86afc3d8b1a0d0a)
by [Unito](https://www.unito.io)

Co-authored-by: Christian Byrne <cbyrne@comfy.org>
Co-authored-by: GitHub Action <action@github.com>
2025-12-11 02:42:05 -07:00
Comfy Org PR Bot
bade95b2c5 [backport cloud/1.34] feat: replace Stripe pricing table with custom implementation (#7361)
Backport of #7359 to `cloud/1.34`

Automatically created by backport workflow.

Co-authored-by: Christian Byrne <cbyrne@comfy.org>
Co-authored-by: Claude <noreply@anthropic.com>
2025-12-11 02:12:57 -07:00
Comfy Org PR Bot
801ab024e5 [backport cloud/1.34] feat: show subscription tier below name on cloud (#7360)
Backport of #7356 to `cloud/1.34`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7360-backport-cloud-1-34-feat-show-subscription-tier-below-name-on-cloud-2c66d73d3650817093b2ed141f477629)
by [Unito](https://www.unito.io)

Co-authored-by: Christian Byrne <cbyrne@comfy.org>
2025-12-11 02:01:28 -07:00
Christian Byrne
2b4d3484b8 [backport cloud/1.34] fix: make subscription panel reactive to actual tier (#7357)
## Summary
Backport of #7354 to cloud/1.34

- Update CloudSubscriptionStatusResponse to use generated types from
comfyRegistryTypes which includes subscription_tier
- Add subscriptionTier computed to useSubscription composable
- Make SubscriptionPanel tierName, tierPrice, and tierBenefits reactive
to actual subscription tier from API
- Normalize i18n tier structure with consistent value/label format
- Add FOUNDERS_EDITION tier support

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7357-fix-make-subscription-panel-reactive-to-actual-tier-backport-to-cloud-1-34-2c66d73d365081a99695fa1fb7901120)
by [Unito](https://www.unito.io)

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2025-12-11 01:18:33 -07:00
bymyself
4e68a266a0 Revert "fix: make subscription panel reactive to actual tier (#7354)"
This reverts commit d3d02d0d4b.
2025-12-10 23:54:22 -08:00
Christian Byrne
d3d02d0d4b fix: make subscription panel reactive to actual tier (#7354)
Was previously hard-coded, now is actually reactive to value returned
from server

- Update CloudSubscriptionStatusResponse to use generated types from
comfyRegistryTypes which includes subscription_tier
- Add subscriptionTier computed to useSubscription composable
- Make SubscriptionPanel tierName, tierPrice, and tierBenefits reactive
to actual subscription tier from API
- Normalize i18n tier structure with consistent value/label format
- Add FOUNDERS_EDITION tier support

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7354-fix-make-subscription-panel-reactive-to-actual-tier-2c66d73d365081059a7be875c13fdd0c)
by [Unito](https://www.unito.io)

---------

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2025-12-10 23:50:40 -08:00
Comfy Org PR Bot
4b008eeaf7 [backport cloud/1.34] fix: credits loading skeleton in user popover (#7350)
Backport of #7347 to `cloud/1.34`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7350-backport-cloud-1-34-fix-credits-loading-skeleton-in-user-popover-2c66d73d36508130b251f38bc8a8f66d)
by [Unito](https://www.unito.io)

Co-authored-by: Christian Byrne <cbyrne@comfy.org>
2025-12-10 23:20:59 -07:00
Comfy Org PR Bot
3f203002b9 [backport cloud/1.34] fix: subscribe button overflow on cloud (#7346)
Backport of #7343 to `cloud/1.34`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7346-backport-cloud-1-34-fix-subscribe-button-overflow-on-cloud-2c66d73d36508195920ce922265c0a0d)
by [Unito](https://www.unito.io)

Co-authored-by: Christian Byrne <cbyrne@comfy.org>
2025-12-10 23:20:50 -07:00
Comfy Org PR Bot
cc60dfd910 [backport cloud/1.34] remove fraction digits on topup credit number (#7349)
Backport of #7345 to `cloud/1.34`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7349-backport-cloud-1-34-remove-fraction-digits-on-topup-credit-number-2c66d73d365081f1a635e7a4b55cfaf0)
by [Unito](https://www.unito.io)

Co-authored-by: Christian Byrne <cbyrne@comfy.org>
2025-12-10 23:08:07 -07:00
Comfy Org PR Bot
913875d745 [backport cloud/1.34] [chore] Update Comfy Registry API types from comfy-api@e1e32b5 (#7348)
Backport of #7344 to `cloud/1.34`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7348-backport-cloud-1-34-chore-Update-Comfy-Registry-API-types-from-comfy-api-e1e32b5-2c66d73d3650816d8565df2c3df17215)
by [Unito](https://www.unito.io)

Co-authored-by: christian-byrne <72887196+christian-byrne@users.noreply.github.com>
2025-12-10 22:59:51 -07:00
Comfy Org PR Bot
0751a13df7 [backport cloud/1.34] Improve subscription dialog width for laptop screens (#7329)
Backport of #7324 to `cloud/1.34`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7329-backport-cloud-1-34-Improve-subscription-dialog-width-for-laptop-screens-2c66d73d36508160884be66e159c2ad2)
by [Unito](https://www.unito.io)

Co-authored-by: Christian Byrne <cbyrne@comfy.org>
Co-authored-by: GitHub Action <action@github.com>
2025-12-10 17:45:55 -07:00
Comfy Org PR Bot
562db3b0d9 [backport cloud/1.34] fix: allow dots in template URL parameter for version numbers (#7328)
Backport of #7325 to `cloud/1.34`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7328-backport-cloud-1-34-fix-allow-dots-in-template-URL-parameter-for-version-numbers-2c56d73d36508192b2b6f90a0562029d)
by [Unito](https://www.unito.io)

Co-authored-by: Johnpaul Chiwetelu <49923152+Myestery@users.noreply.github.com>
2025-12-10 17:13:06 -07:00
Comfy Org PR Bot
432c1e8e33 [backport cloud/1.34] Fix compatibility with older browsers (#7314)
Backport of #7205 to `cloud/1.34`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7314-backport-cloud-1-34-Fix-compatibility-with-older-browsers-2c56d73d3650816cbe75dcdca276edb2)
by [Unito](https://www.unito.io)

Co-authored-by: AustinMroz <austin@comfy.org>
2025-12-09 23:31:15 -07:00
Comfy Org PR Bot
a660c55da9 [backport cloud/1.34] feat: display and upload Civitai preview images in model upload flow (#7301)
Backport of #7274 to `cloud/1.34`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7301-backport-cloud-1-34-feat-display-and-upload-Civitai-preview-images-in-model-upload-flo-2c56d73d3650814caedbc0b64480cb9c)
by [Unito](https://www.unito.io)

Co-authored-by: Luke Mino-Altherr <luke@comfy.org>
Co-authored-by: Claude <noreply@anthropic.com>
2025-12-09 23:30:58 -07:00
Comfy Org PR Bot
fdda9cc752 [backport cloud/1.34] feat: update subscription panel with tier-based design and improved UX (#7312)
Backport of #7307 to `cloud/1.34`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7312-backport-cloud-1-34-feat-update-subscription-panel-with-tier-based-design-and-improved-2c56d73d365081e28400d30170266e85)
by [Unito](https://www.unito.io)

Co-authored-by: Christian Byrne <cbyrne@comfy.org>
Co-authored-by: Alexander Brown <drjkl@comfy.org>
2025-12-09 22:30:12 -07:00
Comfy Org PR Bot
ab74061bc6 [backport cloud/1.34] style: redesign TopUpCredits dialog (#7313)
Backport of #7305 to `cloud/1.34`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7313-backport-cloud-1-34-style-redesign-TopUpCredits-dialog-2c56d73d36508172b633f5602a8b967d)
by [Unito](https://www.unito.io)

Co-authored-by: Christian Byrne <cbyrne@comfy.org>
2025-12-09 22:30:05 -07:00
Comfy Org PR Bot
c82f4272a7 [backport cloud/1.34] style: redesign user popover with improved layout and integration with design system (#7311)
Backport of #7303 to `cloud/1.34`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7311-backport-cloud-1-34-style-redesign-user-popover-with-improved-layout-and-integration-w-2c56d73d365081019e0beea29f06e362)
by [Unito](https://www.unito.io)

Co-authored-by: Christian Byrne <cbyrne@comfy.org>
Co-authored-by: Alexander Brown <drjkl@comfy.org>
2025-12-09 21:30:20 -07:00
Comfy Org PR Bot
32c525a4a6 [backport cloud/1.34] add shared comfy credit conversion helpers (#7293)
Backport of #7061 to `cloud/1.34`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7293-backport-cloud-1-34-add-shared-comfy-credit-conversion-helpers-2c46d73d365081a9b1bcfb82462c3d7f)
by [Unito](https://www.unito.io)

Co-authored-by: Christian Byrne <cbyrne@comfy.org>
Co-authored-by: Claude <noreply@anthropic.com>
2025-12-09 05:26:00 -07:00
Comfy Org PR Bot
27dcb152ff [backport cloud/1.34] feat: add Stripe pricing table integration for subscription dialog (conditional on feature flag) (#7292)
Backport of #7288 to `cloud/1.34`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7292-backport-cloud-1-34-feat-add-Stripe-pricing-table-integration-for-subscription-dialog--2c46d73d36508111869ddf32de921b29)
by [Unito](https://www.unito.io)

Co-authored-by: Christian Byrne <cbyrne@comfy.org>
2025-12-09 05:03:59 -07:00
Comfy Org PR Bot
d2f5f0dce1 [backport cloud/1.34] feat: Enable system notifications on cloud (#7287)
Backport of #7277 to `cloud/1.34`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7287-backport-cloud-1-34-feat-Enable-system-notifications-on-cloud-2c46d73d365081aaaf90d4ff96d0ca52)
by [Unito](https://www.unito.io)

Co-authored-by: Christian Byrne <cbyrne@comfy.org>
2025-12-09 03:41:29 -07:00
Comfy Org PR Bot
47d8f022ec [backport cloud/1.34] change credits icons and tooltips (conditional on feature flag) (#7291)
Backport of #7276 to `cloud/1.34`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7291-backport-cloud-1-34-change-credits-icons-and-tooltips-conditional-on-feature-flag-2c46d73d36508163b2d6cad792078e4c)
by [Unito](https://www.unito.io)

Co-authored-by: Christian Byrne <cbyrne@comfy.org>
2025-12-09 03:41:09 -07:00
Comfy Org PR Bot
271c69f261 [backport cloud/1.34] Add label to open subgraph button (#7290)
Backport of #7244 to `cloud/1.34`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7290-backport-cloud-1-34-Add-label-to-open-subgraph-button-2c46d73d3650813f914ae58916047178)
by [Unito](https://www.unito.io)

Co-authored-by: AustinMroz <austin@comfy.org>
2025-12-09 03:22:11 -07:00
Comfy Org PR Bot
218a7f24a6 [backport cloud/1.34] Fix cloud queue cancel to target specific jobs (#7283)
Backport of #7176 to `cloud/1.34`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7283-backport-cloud-1-34-Fix-cloud-queue-cancel-to-target-specific-jobs-2c46d73d365081e99fbddbd615e858b3)
by [Unito](https://www.unito.io)

Co-authored-by: Benjamin Lu <benjaminlu1107@gmail.com>
2025-12-09 01:20:54 -07:00
54 changed files with 2613 additions and 661 deletions

View File

@@ -42,3 +42,7 @@ 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
global.d.ts vendored
View File

@@ -13,6 +13,8 @@ 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

View File

@@ -1945,6 +1945,40 @@ export interface paths {
patch?: never;
trace?: never;
};
"/proxy/kling/v1/images/omni-image": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/** KlingAI Create Omni-Image Task */
post: operations["klingCreateOmniImage"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/proxy/kling/v1/images/omni-image/{id}": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** KlingAI Query Single Omni-Image Task */
get: operations["klingOmniImageQuerySingleTask"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/proxy/kling/v1/images/kolors-virtual-try-on": {
parameters: {
query?: never;
@@ -3876,7 +3910,7 @@ export interface components {
* @description The subscription tier level
* @enum {string}
*/
SubscriptionTier: "STANDARD" | "CREATOR" | "PRO";
SubscriptionTier: "STANDARD" | "CREATOR" | "PRO" | "FOUNDERS_EDITION";
FeaturesResponse: {
/**
* @description The conversion rate for partner nodes
@@ -5096,6 +5130,71 @@ export interface components {
};
};
};
KlingOmniImageRequest: {
/**
* @description Model Name
* @default kling-image-o1
* @enum {string}
*/
model_name: "kling-image-o1";
/** @description Text prompt words, which can include positive and negative descriptions. Must not exceed 2,500 characters. The Omni model can achieve various capabilities through Prompt with elements and images. Specify an image in the format of <<<>>>, such as <<<image_1>>>. */
prompt: string;
/** @description Reference Image List. Supports inputting image Base64 encoding or image URL (ensure accessibility). Supported formats include .jpg/.jpeg/.png. File size cannot exceed 10MB. Width and height dimensions shall not be less than 300px, aspect ratio between 1:2.5 ~ 2.5:1. Maximum 10 images. */
image_list?: {
/** @description Image Base64 encoding or image URL (ensure accessibility) */
image?: string;
}[];
/**
* @description Image generation resolution. 1k is 1K standard, 2k is 2K high-res, 4k is 4K high-res.
* @default 1k
* @enum {string}
*/
resolution: "1k" | "2k" | "4k";
/**
* @description Number of generated images. Value range [1,9].
* @default 1
*/
n: number;
/**
* @description Aspect ratio of the generated images (width:height). auto is to intelligently generate images based on incoming content.
* @default auto
* @enum {string}
*/
aspect_ratio: "16:9" | "9:16" | "1:1" | "4:3" | "3:4" | "3:2" | "2:3" | "21:9" | "auto";
/**
* Format: uri
* @description The callback notification address for the result of this task. If configured, the server will actively notify when the task status changes.
*/
callback_url?: string;
/** @description Customized Task ID. Must be unique within a single user account. */
external_task_id?: string;
};
KlingOmniImageResponse: {
/** @description Error code */
code?: number;
/** @description Error message */
message?: string;
/** @description Request ID */
request_id?: string;
data?: {
/** @description Task ID */
task_id?: string;
task_status?: components["schemas"]["KlingTaskStatus"];
/** @description Task status information, displaying the failure reason when the task fails (such as triggering the content risk control of the platform, etc.) */
task_status_msg?: string;
task_info?: {
/** @description Customer-defined task ID */
external_task_id?: string;
};
/** @description Task creation time, Unix timestamp in milliseconds */
created_at?: number;
/** @description Task update time, Unix timestamp in milliseconds */
updated_at?: number;
task_result?: {
images?: components["schemas"]["KlingImageResult"][];
};
};
};
KlingLipSyncInputObject: {
/** @description The ID of the video generated by Kling AI. Only supports 5-second and 10-second videos generated within the last 30 days. */
video_id?: string;
@@ -10065,7 +10164,7 @@ export interface components {
};
BytePlusImageGenerationRequest: {
/** @enum {string} */
model: "seedream-3-0-t2i-250415" | "seededit-3-0-i2i-250628" | "seedream-4-0-250828";
model: "seedream-3-0-t2i-250415" | "seededit-3-0-i2i-250628" | "seedream-4-0-250828" | "seedream-4-5-251128";
/** @description Text description for image generation or transformation */
prompt: string;
/**
@@ -10170,10 +10269,10 @@ export interface components {
};
BytePlusVideoGenerationRequest: {
/**
* @description The ID of the model to call. Available models include seedance-1-0-pro-250528, seedance-1-0-lite-t2v-250428, seedance-1-0-lite-i2v-250428
* @description The ID of the model to call. Available models include seedance-1-0-pro-250528, seedance-1-0-pro-fast-251015, seedance-1-0-lite-t2v-250428, seedance-1-0-lite-i2v-250428
* @enum {string}
*/
model: "seedance-1-0-pro-250528" | "seedance-1-0-lite-t2v-250428" | "seedance-1-0-lite-i2v-250428";
model: "seedance-1-0-pro-250528" | "seedance-1-0-lite-t2v-250428" | "seedance-1-0-lite-i2v-250428" | "seedance-1-0-pro-fast-251015";
/** @description The input content for the model to generate a video */
content: components["schemas"]["BytePlusVideoGenerationContent"][];
/**
@@ -13947,6 +14046,15 @@ export interface operations {
"application/json": components["schemas"]["Node"];
};
};
/** @description Redirect to node with normalized name match */
302: {
headers: {
/** @description URL of the node with the correct ID */
Location?: string;
[name: string]: unknown;
};
content?: never;
};
/** @description Forbidden */
403: {
headers: {
@@ -18345,6 +18453,198 @@ export interface operations {
};
};
};
klingCreateOmniImage: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** @description Create task for generating omni-image */
requestBody: {
content: {
"application/json": components["schemas"]["KlingOmniImageRequest"];
};
};
responses: {
/** @description Successful response (Request successful) */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["KlingOmniImageResponse"];
};
};
/** @description Invalid request parameters */
400: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["KlingErrorResponse"];
};
};
/** @description Authentication failed */
401: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["KlingErrorResponse"];
};
};
/** @description Unauthorized access to requested resource */
403: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["KlingErrorResponse"];
};
};
/** @description Resource not found */
404: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["KlingErrorResponse"];
};
};
/** @description Account exception or Rate limit exceeded */
429: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["KlingErrorResponse"];
};
};
/** @description Internal server error */
500: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["KlingErrorResponse"];
};
};
/** @description Service temporarily unavailable */
503: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["KlingErrorResponse"];
};
};
/** @description Server timeout */
504: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["KlingErrorResponse"];
};
};
};
};
klingOmniImageQuerySingleTask: {
parameters: {
query?: never;
header?: never;
path: {
/** @description Task ID or External Task ID. Can query by either task_id (generated by system) or external_task_id (customized task ID) */
id: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful response (Request successful) */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["KlingOmniImageResponse"];
};
};
/** @description Invalid request parameters */
400: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["KlingErrorResponse"];
};
};
/** @description Authentication failed */
401: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["KlingErrorResponse"];
};
};
/** @description Unauthorized access to requested resource */
403: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["KlingErrorResponse"];
};
};
/** @description Resource not found */
404: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["KlingErrorResponse"];
};
};
/** @description Account exception or Rate limit exceeded */
429: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["KlingErrorResponse"];
};
};
/** @description Internal server error */
500: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["KlingErrorResponse"];
};
};
/** @description Service temporarily unavailable */
503: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["KlingErrorResponse"];
};
};
/** @description Server timeout */
504: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["KlingErrorResponse"];
};
};
};
};
klingVirtualTryOnQueryTaskList: {
parameters: {
query?: {

View File

@@ -0,0 +1,125 @@
const DEFAULT_NUMBER_FORMAT: Intl.NumberFormatOptions = {
minimumFractionDigits: 2,
maximumFractionDigits: 2
}
const formatNumber = ({
value,
locale,
options
}: {
value: number
locale?: string
options?: Intl.NumberFormatOptions
}): string => {
const merged: Intl.NumberFormatOptions = {
...DEFAULT_NUMBER_FORMAT,
...options
}
if (
typeof merged.maximumFractionDigits === 'number' &&
typeof merged.minimumFractionDigits === 'number' &&
merged.maximumFractionDigits < merged.minimumFractionDigits
) {
merged.minimumFractionDigits = merged.maximumFractionDigits
}
return new Intl.NumberFormat(locale, merged).format(value)
}
export const CREDITS_PER_USD = 211
export const COMFY_CREDIT_RATE_CENTS = CREDITS_PER_USD / 100 // credits per cent
export const usdToCents = (usd: number): number => Math.round(usd * 100)
export const centsToCredits = (cents: number): number =>
Math.round(cents * COMFY_CREDIT_RATE_CENTS)
export const creditsToCents = (credits: number): number =>
Math.round(credits / COMFY_CREDIT_RATE_CENTS)
export const usdToCredits = (usd: number): number =>
Math.round(usd * CREDITS_PER_USD)
export const creditsToUsd = (credits: number): number =>
Math.round((credits / CREDITS_PER_USD) * 100) / 100
export type FormatOptions = {
value: number
locale?: string
numberOptions?: Intl.NumberFormatOptions
}
export type FormatFromCentsOptions = {
cents: number
locale?: string
numberOptions?: Intl.NumberFormatOptions
}
export type FormatFromUsdOptions = {
usd: number
locale?: string
numberOptions?: Intl.NumberFormatOptions
}
export const formatCredits = ({
value,
locale,
numberOptions
}: FormatOptions): string =>
formatNumber({ value, locale, options: numberOptions })
export const formatCreditsFromCents = ({
cents,
locale,
numberOptions
}: FormatFromCentsOptions): string =>
formatCredits({
value: centsToCredits(cents),
locale,
numberOptions
})
export const formatCreditsFromUsd = ({
usd,
locale,
numberOptions
}: FormatFromUsdOptions): string =>
formatCredits({
value: usdToCredits(usd),
locale,
numberOptions
})
export const formatUsd = ({
value,
locale,
numberOptions
}: FormatOptions): string =>
formatNumber({
value,
locale,
options: numberOptions
})
export const formatUsdFromCents = ({
cents,
locale,
numberOptions
}: FormatFromCentsOptions): string =>
formatUsd({
value: cents / 100,
locale,
numberOptions
})
/**
* Clamps a USD value to the allowed range for credit purchases
* @param value - The USD amount to clamp
* @returns The clamped value between $1 and $1000, or 0 if NaN
*/
export const clampUsd = (value: number): number => {
if (Number.isNaN(value)) return 0
return Math.min(1000, Math.max(1, value))
}

View File

@@ -8,12 +8,24 @@
</div>
<div v-else class="flex items-center gap-1">
<Tag
v-if="!showCreditsOnly"
severity="secondary"
icon="pi pi-dollar"
rounded
class="p-1 text-amber-400"
/>
<div :class="textClass">{{ formattedBalance }}</div>
>
<template #icon>
<i
:class="
flags.subscriptionTiersEnabled
? 'icon-[lucide--component]'
: 'pi pi-dollar'
"
/>
</template>
</Tag>
<div :class="textClass">
{{ showCreditsOnly ? formattedCreditsOnly : formattedBalance }}
</div>
</div>
</template>
@@ -21,19 +33,40 @@
import Skeleton from 'primevue/skeleton'
import Tag from 'primevue/tag'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { formatCreditsFromCents } from '@/base/credits/comfyCredits'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
import { formatMetronomeCurrency } from '@/utils/formatUtil'
const { textClass } = defineProps<{
const { textClass, showCreditsOnly } = defineProps<{
textClass?: string
showCreditsOnly?: boolean
}>()
const authStore = useFirebaseAuthStore()
const { flags } = useFeatureFlags()
const balanceLoading = computed(() => authStore.isFetchingBalance)
const { t, locale } = useI18n()
const formattedBalance = computed(() => {
if (!authStore.balance) return '0.00'
return formatMetronomeCurrency(authStore.balance.amount_micros, 'usd')
// Backend returns cents despite the *_micros naming convention.
const cents = authStore.balance?.amount_micros ?? 0
const amount = formatCreditsFromCents({
cents,
locale: locale.value
})
return `${amount} ${t('credits.credits')}`
})
const formattedCreditsOnly = computed(() => {
// Backend returns cents despite the *_micros naming convention.
const cents = authStore.balance?.amount_micros ?? 0
const amount = formatCreditsFromCents({
cents,
locale: locale.value,
numberOptions: { minimumFractionDigits: 0, maximumFractionDigits: 0 }
})
return amount
})
</script>

View File

@@ -1,5 +1,74 @@
<template>
<div class="flex w-96 flex-col gap-10 p-2">
<!-- New Credits Design (default) -->
<div v-if="useNewDesign" class="flex w-112 flex-col gap-8 p-8">
<!-- Header -->
<div class="flex flex-col gap-4">
<h1 class="text-2xl font-semibold text-base-foreground m-0">
{{
isInsufficientCredits
? $t('credits.topUp.addMoreCreditsToRun')
: $t('credits.topUp.addMoreCredits')
}}
</h1>
<div v-if="isInsufficientCredits" class="flex flex-col gap-2">
<p class="text-sm text-muted-foreground m-0 w-96">
{{ $t('credits.topUp.insufficientWorkflowMessage') }}
</p>
</div>
<div v-else class="flex flex-col gap-2">
<p class="text-sm text-muted-foreground m-0">
{{ $t('credits.topUp.creditsDescription') }}
</p>
</div>
</div>
<!-- Current Balance Section -->
<div class="flex flex-col gap-4">
<div class="flex items-baseline gap-2">
<UserCredit text-class="text-3xl font-bold" show-credits-only />
<span class="text-sm text-muted-foreground">{{
$t('credits.creditsAvailable')
}}</span>
</div>
<div v-if="formattedRenewalDate" class="text-sm text-muted-foreground">
{{ $t('credits.refreshes', { date: formattedRenewalDate }) }}
</div>
</div>
<!-- Credit Options Section -->
<div class="flex flex-col gap-4">
<span class="text-sm text-muted-foreground">
{{ $t('credits.topUp.howManyCredits') }}
</span>
<div class="flex flex-col gap-2">
<CreditTopUpOption
v-for="option in creditOptions"
:key="option.credits"
:credits="option.credits"
:description="option.description"
:selected="selectedCredits === option.credits"
@select="selectedCredits = option.credits"
/>
</div>
<div class="text-xs text-muted-foreground w-96">
{{ $t('credits.topUp.templateNote') }}
</div>
</div>
<!-- Buy Button -->
<Button
:disabled="!selectedCredits || loading"
:loading="loading"
severity="primary"
:label="$t('credits.topUp.buy')"
:class="['w-full', { 'opacity-30': !selectedCredits || loading }]"
:pt="{ label: { class: 'text-primary-foreground' } }"
@click="handleBuy"
/>
</div>
<!-- Legacy Design -->
<div v-else class="flex w-96 flex-col gap-10 p-2">
<div v-if="isInsufficientCredits" class="flex flex-col gap-4">
<h1 class="my-0 text-2xl leading-normal font-medium">
{{ $t('credits.topUp.insufficientTitle') }}
@@ -34,14 +103,14 @@
>{{ $t('credits.topUp.quickPurchase') }}:</span
>
<div class="grid grid-cols-[2fr_1fr] gap-2">
<CreditTopUpOption
<LegacyCreditTopUpOption
v-for="amount in amountOptions"
:key="amount"
:amount="amount"
:preselected="amount === preselectedAmountOption"
/>
<CreditTopUpOption :amount="100" :preselected="false" editable />
<LegacyCreditTopUpOption :amount="100" :preselected="false" editable />
</div>
</div>
</div>
@@ -49,11 +118,24 @@
<script setup lang="ts">
import Button from 'primevue/button'
import { useToast } from 'primevue/usetoast'
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { creditsToUsd } from '@/base/credits/comfyCredits'
import UserCredit from '@/components/common/UserCredit.vue'
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
import { useTelemetry } from '@/platform/telemetry'
import CreditTopUpOption from './credit/CreditTopUpOption.vue'
import LegacyCreditTopUpOption from './credit/LegacyCreditTopUpOption.vue'
interface CreditOption {
credits: number
description: string
}
const {
isInsufficientCredits = false,
@@ -65,7 +147,61 @@ const {
preselectedAmountOption?: number
}>()
const { flags } = useFeatureFlags()
const { formattedRenewalDate } = useSubscription()
// Use feature flag to determine design - defaults to true (new design)
const useNewDesign = computed(() => flags.subscriptionTiersEnabled)
const { t } = useI18n()
const authActions = useFirebaseAuthActions()
const telemetry = useTelemetry()
const toast = useToast()
const selectedCredits = ref<number | null>(null)
const loading = ref(false)
const creditOptions: CreditOption[] = [
{
credits: 1055, // $5.00
description: t('credits.topUp.videosEstimate', { count: 41 })
},
{
credits: 2110, // $10.00
description: t('credits.topUp.videosEstimate', { count: 82 })
},
{
credits: 4220, // $20.00
description: t('credits.topUp.videosEstimate', { count: 184 })
},
{
credits: 10550, // $50.00
description: t('credits.topUp.videosEstimate', { count: 412 })
}
]
const handleBuy = async () => {
if (!selectedCredits.value) return
loading.value = true
try {
const usdAmount = creditsToUsd(selectedCredits.value)
telemetry?.trackApiCreditTopupButtonPurchaseClicked(usdAmount)
await authActions.purchaseCredits(usdAmount)
} catch (error) {
console.error('Purchase failed:', error)
const errorMessage =
error instanceof Error ? error.message : t('credits.topUp.unknownError')
toast.add({
severity: 'error',
summary: t('credits.topUp.purchaseError'),
detail: t('credits.topUp.purchaseErrorDetail', { error: errorMessage }),
life: 5000
})
} finally {
loading.value = false
}
}
const handleSeeDetails = async () => {
await authActions.accessBillingPortal()

View File

@@ -1,81 +1,45 @@
<template>
<div class="flex items-center gap-2">
<Tag
severity="secondary"
icon="pi pi-dollar"
rounded
class="p-1 text-amber-400"
/>
<InputNumber
v-if="editable"
v-model="customAmount"
:min="1"
:max="1000"
:step="1"
show-buttons
:allow-empty="false"
:highlight-on-focus="true"
pt:pc-input-text:root="w-24"
@blur="(e: InputNumberBlurEvent) => (customAmount = Number(e.value))"
@input="(e: InputNumberInputEvent) => (customAmount = Number(e.value))"
/>
<span v-else class="text-xl">{{ amount }}</span>
<div
class="flex items-center justify-between p-2 rounded-lg cursor-pointer transition-all duration-200"
:class="[
selected
? 'bg-secondary-background border-2 border-border-default'
: 'bg-component-node-disabled hover:bg-secondary-background border-2 border-transparent'
]"
@click="$emit('select')"
>
<span class="text-base font-bold text-base-foreground">
{{ formattedCredits }}
</span>
<span class="text-sm font-normal text-muted-foreground">
{{ description }}
</span>
</div>
<ProgressSpinner v-if="loading" class="h-8 w-8" />
<Button
v-else
:severity="preselected ? 'primary' : 'secondary'"
:outlined="!preselected"
:label="$t('credits.topUp.buyNow')"
@click="handleBuyNow"
/>
</template>
<script setup lang="ts">
import Button from 'primevue/button'
import InputNumber from 'primevue/inputnumber'
import type {
InputNumberBlurEvent,
InputNumberInputEvent
} from 'primevue/inputnumber'
import ProgressSpinner from 'primevue/progressspinner'
import Tag from 'primevue/tag'
import { onBeforeUnmount, ref } from 'vue'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
import { useTelemetry } from '@/platform/telemetry'
import { formatCredits } from '@/base/credits/comfyCredits'
const authActions = useFirebaseAuthActions()
const telemetry = useTelemetry()
const {
amount,
preselected,
editable = false
} = defineProps<{
amount: number
preselected: boolean
editable?: boolean
const { credits, description, selected } = defineProps<{
credits: number
description: string
selected: boolean
}>()
const customAmount = ref(amount)
const didClickBuyNow = ref(false)
const loading = ref(false)
defineEmits<{
select: []
}>()
const handleBuyNow = async () => {
const creditAmount = editable ? customAmount.value : amount
telemetry?.trackApiCreditTopupButtonPurchaseClicked(creditAmount)
const { locale } = useI18n()
loading.value = true
await authActions.purchaseCredits(creditAmount)
loading.value = false
didClickBuyNow.value = true
}
onBeforeUnmount(() => {
if (didClickBuyNow.value) {
// If clicked buy now, then returned back to the dialog and closed, fetch the balance
void authActions.fetchBalance()
}
const formattedCredits = computed(() => {
return formatCredits({
value: credits,
locale: locale.value,
numberOptions: { minimumFractionDigits: 0, maximumFractionDigits: 0 }
})
})
</script>

View File

@@ -0,0 +1,119 @@
<template>
<div class="flex items-center gap-2">
<Tag
severity="secondary"
icon="pi pi-wallet"
rounded
class="p-1 text-amber-400"
/>
<div v-if="editable" class="flex items-center gap-2">
<InputNumber
v-model="customAmount"
:min="1"
:max="1000"
:step="1"
show-buttons
:allow-empty="false"
:highlight-on-focus="true"
prefix="$"
pt:pc-input-text:root="w-28"
@blur="
(e: InputNumberBlurEvent) =>
(customAmount = clampUsd(Number(e.value)))
"
@input="
(e: InputNumberInputEvent) =>
(customAmount = clampUsd(Number(e.value)))
"
/>
<span class="text-xs text-muted">{{ formattedCredits }}</span>
</div>
<div v-else class="flex flex-col leading-tight">
<span class="text-xl font-semibold">{{ formattedCredits }}</span>
<span class="text-xs text-muted">{{ formattedUsd }}</span>
</div>
</div>
<ProgressSpinner v-if="loading" class="h-8 w-8" />
<Button
v-else
:severity="preselected ? 'primary' : 'secondary'"
:outlined="!preselected"
:label="$t('credits.topUp.buyNow')"
@click="handleBuyNow"
/>
</template>
<script setup lang="ts">
import Button from 'primevue/button'
import InputNumber from 'primevue/inputnumber'
import type {
InputNumberBlurEvent,
InputNumberInputEvent
} from 'primevue/inputnumber'
import ProgressSpinner from 'primevue/progressspinner'
import Tag from 'primevue/tag'
import { computed, onBeforeUnmount, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import {
clampUsd,
formatCreditsFromUsd,
formatUsd
} from '@/base/credits/comfyCredits'
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
import { useTelemetry } from '@/platform/telemetry'
const authActions = useFirebaseAuthActions()
const telemetry = useTelemetry()
const {
amount,
preselected,
editable = false
} = defineProps<{
amount: number
preselected: boolean
editable?: boolean
}>()
const customAmount = ref(amount)
const didClickBuyNow = ref(false)
const loading = ref(false)
const { t, locale } = useI18n()
const displayUsdAmount = computed(() =>
editable ? clampUsd(Number(customAmount.value)) : clampUsd(amount)
)
const formattedCredits = computed(
() =>
`${formatCreditsFromUsd({
usd: displayUsdAmount.value,
locale: locale.value
})} ${t('credits.credits')}`
)
const formattedUsd = computed(
() => `$${formatUsd({ value: displayUsdAmount.value, locale: locale.value })}`
)
const handleBuyNow = async () => {
const creditAmount = displayUsdAmount.value
telemetry?.trackApiCreditTopupButtonPurchaseClicked(creditAmount)
loading.value = true
try {
await authActions.purchaseCredits(creditAmount)
didClickBuyNow.value = true
} finally {
loading.value = false
}
}
onBeforeUnmount(() => {
if (didClickBuyNow.value) {
// If clicked buy now, then returned back to the dialog and closed, fetch the balance
void authActions.fetchBalance()
}
})
</script>

View File

@@ -1,5 +1,6 @@
<template>
<TabPanel value="Credits" class="credits-container h-full">
<!-- Legacy Design -->
<div class="flex h-full flex-col">
<h2 class="mb-2 text-2xl font-bold">
{{ $t('credits.credits') }}

View File

@@ -75,6 +75,7 @@ import { useQueueProgress } from '@/composables/queue/useQueueProgress'
import { useResultGallery } from '@/composables/queue/useResultGallery'
import { useErrorHandling } from '@/composables/useErrorHandling'
import { useAssetSelectionStore } from '@/platform/assets/composables/useAssetSelectionStore'
import { isCloud } from '@/platform/distribution/types'
import { api } from '@/scripts/api'
import { useAssetsStore } from '@/stores/assetsStore'
import { useCommandStore } from '@/stores/commandStore'
@@ -263,11 +264,21 @@ const cancelQueuedWorkflows = wrapWithErrorHandlingAsync(async () => {
const interruptAll = wrapWithErrorHandlingAsync(async () => {
const tasks = queueStore.runningTasks
await Promise.all(
tasks
.filter((task) => task.promptId != null)
.map((task) => api.interrupt(task.promptId))
)
const promptIds = tasks
.map((task) => task.promptId)
.filter((id): id is string => typeof id === 'string' && id.length > 0)
if (!promptIds.length) return
// Cloud backend supports cancelling specific jobs via /queue delete,
// while /interrupt always targets the "first" job. Use the targeted API
// on cloud to ensure we cancel the workflow the user clicked.
if (isCloud) {
await Promise.all(promptIds.map((id) => api.deleteItem('queue', id)))
return
}
await Promise.all(promptIds.map((id) => api.interrupt(id)))
})
const showClearHistoryDialog = () => {

View File

@@ -1,10 +1,10 @@
import type { VueWrapper } from '@vue/test-utils'
import { mount } from '@vue/test-utils'
import Button from 'primevue/button'
import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'
import { h } from 'vue'
import { createI18n } from 'vue-i18n'
import { formatCreditsFromCents } from '@/base/credits/comfyCredits'
import enMessages from '@/locales/en/main.json' with { type: 'json' }
import CurrentUserPopover from './CurrentUserPopover.vue'
@@ -74,7 +74,9 @@ vi.mock('@/stores/firebaseAuthStore', () => ({
useFirebaseAuthStore: vi.fn(() => ({
getAuthHeader: vi
.fn()
.mockResolvedValue({ Authorization: 'Bearer mock-token' })
.mockResolvedValue({ Authorization: 'Bearer mock-token' }),
balance: { amount_micros: 100_000 }, // 100,000 cents = ~211,000 credits
isFetchingBalance: false
}))
}))
@@ -107,6 +109,39 @@ vi.mock('@/components/common/UserCredit.vue', () => ({
}
}))
// Mock formatCreditsFromCents
vi.mock('@/base/credits/comfyCredits', () => ({
formatCreditsFromCents: vi.fn(({ cents }) => (cents / 100).toString())
}))
// Mock useExternalLink
vi.mock('@/composables/useExternalLink', () => ({
useExternalLink: vi.fn(() => ({
buildDocsUrl: vi.fn((path) => `https://docs.comfy.org${path}`)
}))
}))
// Mock useFeatureFlags
vi.mock('@/composables/useFeatureFlags', () => ({
useFeatureFlags: vi.fn(() => ({
flags: {
subscriptionTiersEnabled: true
}
}))
}))
// Mock useTelemetry
vi.mock('@/platform/telemetry', () => ({
useTelemetry: vi.fn(() => ({
trackAddApiCreditButtonClicked: vi.fn()
}))
}))
// Mock isCloud
vi.mock('@/platform/distribution/types', () => ({
isCloud: true
}))
vi.mock('@/platform/cloud/subscription/components/SubscribeButton.vue', () => ({
default: {
name: 'SubscribeButtonMock',
@@ -145,27 +180,37 @@ describe('CurrentUserPopover', () => {
expect(wrapper.text()).toContain('test@example.com')
})
it('renders logout button with correct props', () => {
it('calls formatCreditsFromCents with correct parameters and displays formatted credits', () => {
const wrapper = mountComponent()
// Find all buttons and get the logout button (last button)
const buttons = wrapper.findAllComponents(Button)
const logoutButton = buttons[4]
expect(formatCreditsFromCents).toHaveBeenCalledWith({
cents: 100_000,
locale: 'en',
numberOptions: {
minimumFractionDigits: 0,
maximumFractionDigits: 2
}
})
// Check that logout button has correct props
expect(logoutButton.props('label')).toBe('Log Out')
expect(logoutButton.props('icon')).toBe('pi pi-sign-out')
// Verify the formatted credit string (1000) is rendered in the DOM
expect(wrapper.text()).toContain('1000')
})
it('opens user settings and emits close event when settings button is clicked', async () => {
it('renders logout menu item with correct text', () => {
const wrapper = mountComponent()
// Find all buttons and get the settings button (third button)
const buttons = wrapper.findAllComponents(Button)
const settingsButton = buttons[2]
const logoutItem = wrapper.find('[data-testid="logout-menu-item"]')
expect(logoutItem.exists()).toBe(true)
expect(wrapper.text()).toContain('Log Out')
})
// Click the settings button
await settingsButton.trigger('click')
it('opens user settings and emits close event when settings item is clicked', async () => {
const wrapper = mountComponent()
const settingsItem = wrapper.find('[data-testid="user-settings-menu-item"]')
expect(settingsItem.exists()).toBe(true)
await settingsItem.trigger('click')
// Verify showSettingsDialog was called with 'user'
expect(mockShowSettingsDialog).toHaveBeenCalledWith('user')
@@ -175,15 +220,13 @@ describe('CurrentUserPopover', () => {
expect(wrapper.emitted('close')!.length).toBe(1)
})
it('calls logout function and emits close event when logout button is clicked', async () => {
it('calls logout function and emits close event when logout item is clicked', async () => {
const wrapper = mountComponent()
// Find all buttons and get the logout button (last button)
const buttons = wrapper.findAllComponents(Button)
const logoutButton = buttons[4]
const logoutItem = wrapper.find('[data-testid="logout-menu-item"]')
expect(logoutItem.exists()).toBe(true)
// Click the logout button
await logoutButton.trigger('click')
await logoutItem.trigger('click')
// Verify handleSignOut was called
expect(mockHandleSignOut).toHaveBeenCalled()
@@ -193,15 +236,15 @@ describe('CurrentUserPopover', () => {
expect(wrapper.emitted('close')!.length).toBe(1)
})
it('opens API pricing docs and emits close event when API pricing button is clicked', async () => {
it('opens API pricing docs and emits close event when partner nodes item is clicked', async () => {
const wrapper = mountComponent()
// Find all buttons and get the Partner Nodes info button (first one)
const buttons = wrapper.findAllComponents(Button)
const partnerNodesButton = buttons[0]
const partnerNodesItem = wrapper.find(
'[data-testid="partner-nodes-menu-item"]'
)
expect(partnerNodesItem.exists()).toBe(true)
// Click the Partner Nodes button
await partnerNodesButton.trigger('click')
await partnerNodesItem.trigger('click')
// Verify window.open was called with the correct URL
expect(window.open).toHaveBeenCalledWith(
@@ -217,11 +260,9 @@ describe('CurrentUserPopover', () => {
it('opens top-up dialog and emits close event when top-up button is clicked', async () => {
const wrapper = mountComponent()
// Find all buttons and get the top-up button (second one)
const buttons = wrapper.findAllComponents(Button)
const topUpButton = buttons[1]
const topUpButton = wrapper.find('[data-testid="add-credits-button"]')
expect(topUpButton.exists()).toBe(true)
// Click the top-up button
await topUpButton.trigger('click')
// Verify showTopUpCreditsDialog was called

View File

@@ -1,112 +1,152 @@
<!-- A popover that shows current user information and actions -->
<template>
<div class="current-user-popover w-72">
<div
class="current-user-popover w-80 -m-3 p-2 rounded-lg border border-border-default bg-base-background shadow-[1px_1px_8px_0_rgba(0,0,0,0.4)]"
>
<!-- User Info Section -->
<div class="p-3">
<div class="flex flex-col items-center">
<UserAvatar
class="mb-3"
:photo-url="userPhotoUrl"
:pt:icon:class="{
'text-2xl!': !userPhotoUrl
}"
size="large"
/>
<div class="flex flex-col items-center px-0 py-3 mb-4">
<UserAvatar
class="mb-1"
:photo-url="userPhotoUrl"
:pt:icon:class="{
'text-2xl!': !userPhotoUrl
}"
size="large"
/>
<!-- User Details -->
<h3 class="my-0 mb-1 truncate text-lg font-semibold">
{{ userDisplayName || $t('g.user') }}
</h3>
<p v-if="userEmail" class="my-0 truncate text-sm text-muted">
{{ userEmail }}
</p>
</div>
<!-- User Details -->
<h3 class="my-0 mb-1 truncate text-base font-bold text-base-foreground">
{{ userDisplayName || $t('g.user') }}
</h3>
<p v-if="userEmail" class="my-0 truncate text-sm text-muted">
{{ userEmail }}
</p>
<p v-if="subscriptionTierName" class="my-0 truncate text-sm text-muted">
{{ subscriptionTierName }}
</p>
</div>
<div v-if="isActiveSubscription" class="flex items-center justify-between">
<div class="flex flex-col gap-1">
<UserCredit text-class="text-2xl" />
<Button
:label="$t('subscription.partnerNodesCredits')"
severity="secondary"
text
size="small"
class="pl-6 p-0 h-auto justify-start"
:pt="{
root: {
class: 'hover:bg-transparent active:bg-transparent'
}
}"
@click="handleOpenPartnerNodesInfo"
/>
</div>
<!-- Credits Section -->
<div v-if="isActiveSubscription" class="flex items-center gap-2 px-4 py-2">
<i class="icon-[lucide--component] text-amber-400 text-sm" />
<Skeleton
v-if="authStore.isFetchingBalance"
width="4rem"
height="1.25rem"
class="flex-1"
/>
<span v-else class="text-base font-normal text-base-foreground flex-1">{{
formattedBalance
}}</span>
<Button
:label="$t('credits.topUp.topUp')"
:label="$t('subscription.addCredits')"
severity="secondary"
size="small"
class="text-base-foreground"
data-testid="add-credits-button"
@click="handleTopUp"
/>
</div>
<SubscribeButton
v-else
:label="$t('subscription.subscribeToComfyCloud')"
size="small"
variant="gradient"
@subscribed="handleSubscribed"
/>
<Divider class="my-2" />
<div v-else class="flex justify-center px-4">
<SubscribeButton
:fluid="false"
:label="$t('subscription.subscribeToComfyCloud')"
size="small"
variant="gradient"
@subscribed="handleSubscribed"
/>
</div>
<Button
class="justify-start"
:label="$t('userSettings.title')"
icon="pi pi-cog"
text
fluid
severity="secondary"
@click="handleOpenUserSettings"
/>
<!-- Credits info row -->
<div
v-if="flags.subscriptionTiersEnabled && isActiveSubscription"
class="flex items-center gap-2 px-4 py-0"
>
<i
v-tooltip="{
value: $t('credits.unified.tooltip'),
showDelay: 300,
hideDelay: 300
}"
class="icon-[lucide--circle-help] cursor-help text-xs text-muted-foreground"
/>
<span class="text-sm text-muted-foreground">{{
$t('credits.unified.message')
}}</span>
</div>
<Button
<Divider class="my-2 mx-0" />
<div
v-if="isActiveSubscription"
class="justify-start"
:label="$t(planSettingsLabel)"
icon="pi pi-receipt"
text
fluid
severity="secondary"
class="flex items-center gap-2 px-4 py-2 cursor-pointer hover:bg-secondary-background-hover"
data-testid="partner-nodes-menu-item"
@click="handleOpenPartnerNodesInfo"
>
<i class="icon-[lucide--tag] text-muted-foreground text-sm" />
<span class="text-sm text-base-foreground flex-1">{{
$t('subscription.partnerNodesCredits')
}}</span>
</div>
<div
v-if="isActiveSubscription"
class="flex items-center gap-2 px-4 py-2 cursor-pointer hover:bg-secondary-background-hover"
data-testid="plan-credits-menu-item"
@click="handleOpenPlanAndCreditsSettings"
/>
>
<i class="icon-[lucide--receipt-text] text-muted-foreground text-sm" />
<span class="text-sm text-base-foreground flex-1">{{
$t(planSettingsLabel)
}}</span>
</div>
<Divider class="my-2" />
<div
class="flex items-center gap-2 px-4 py-2 cursor-pointer hover:bg-secondary-background-hover"
data-testid="user-settings-menu-item"
@click="handleOpenUserSettings"
>
<i class="icon-[lucide--settings-2] text-muted-foreground text-sm" />
<span class="text-sm text-base-foreground flex-1">{{
$t('userSettings.title')
}}</span>
</div>
<Button
class="justify-start"
:label="$t('auth.signOut.signOut')"
icon="pi pi-sign-out"
text
fluid
severity="secondary"
<Divider class="my-2 mx-0" />
<div
class="flex items-center gap-2 px-4 py-2 cursor-pointer hover:bg-secondary-background-hover"
data-testid="logout-menu-item"
@click="handleLogout"
/>
>
<i class="icon-[lucide--log-out] text-muted-foreground text-sm" />
<span class="text-sm text-base-foreground flex-1">{{
$t('auth.signOut.signOut')
}}</span>
</div>
</div>
</template>
<script setup lang="ts">
import Button from 'primevue/button'
import Divider from 'primevue/divider'
import { onMounted } from 'vue'
import Skeleton from 'primevue/skeleton'
import { computed, onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { formatCreditsFromCents } from '@/base/credits/comfyCredits'
import UserAvatar from '@/components/common/UserAvatar.vue'
import UserCredit from '@/components/common/UserCredit.vue'
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
import { useExternalLink } from '@/composables/useExternalLink'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import SubscribeButton from '@/platform/cloud/subscription/components/SubscribeButton.vue'
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
import { isCloud } from '@/platform/distribution/types'
import { useTelemetry } from '@/platform/telemetry'
import { useDialogService } from '@/services/dialogService'
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
const emit = defineEmits<{
close: []
@@ -121,8 +161,25 @@ const planSettingsLabel = isCloud
const { userDisplayName, userEmail, userPhotoUrl, handleSignOut } =
useCurrentUser()
const authActions = useFirebaseAuthActions()
const authStore = useFirebaseAuthStore()
const dialogService = useDialogService()
const { isActiveSubscription, fetchStatus } = useSubscription()
const { isActiveSubscription, subscriptionTierName, fetchStatus } =
useSubscription()
const { flags } = useFeatureFlags()
const { locale } = useI18n()
const formattedBalance = computed(() => {
// Backend returns cents despite the *_micros naming convention.
const cents = authStore.balance?.amount_micros ?? 0
return formatCreditsFromCents({
cents,
locale: locale.value,
numberOptions: {
minimumFractionDigits: 0,
maximumFractionDigits: 2
}
})
})
const handleOpenUserSettings = () => {
dialogService.showSettingsDialog('user')

View File

@@ -40,12 +40,12 @@ const calculateRunwayDurationPrice = (node: LGraphNode): string => {
(w) => w.name === 'duration'
) as IComboWidget
if (!durationWidget) return '$0.05/second'
if (!durationWidget) return '$0.0715/second'
const duration = Number(durationWidget.value)
// If duration is 0 or NaN, don't fall back to 5 seconds - just use 0
const validDuration = isNaN(duration) ? 5 : duration
const cost = (0.05 * validDuration).toFixed(2)
const cost = (0.0715 * validDuration).toFixed(2)
return `$${cost}/Run`
}
@@ -377,11 +377,11 @@ const apiNodeCosts: Record<string, { displayPrice: string | PricingFunction }> =
(w) => w.name === 'turbo'
) as IComboWidget
if (!numImagesWidget) return '$0.02-0.06 x num_images/Run'
if (!numImagesWidget) return '$0.03-0.09 x num_images/Run'
const numImages = Number(numImagesWidget.value) || 1
const turbo = String(turboWidget?.value).toLowerCase() === 'true'
const basePrice = turbo ? 0.02 : 0.06
const basePrice = turbo ? 0.0286 : 0.0858
const cost = (basePrice * numImages).toFixed(2)
return `$${cost}/Run`
}
@@ -395,11 +395,11 @@ const apiNodeCosts: Record<string, { displayPrice: string | PricingFunction }> =
(w) => w.name === 'turbo'
) as IComboWidget
if (!numImagesWidget) return '$0.05-0.08 x num_images/Run'
if (!numImagesWidget) return '$0.07-0.11 x num_images/Run'
const numImages = Number(numImagesWidget.value) || 1
const turbo = String(turboWidget?.value).toLowerCase() === 'true'
const basePrice = turbo ? 0.05 : 0.08
const basePrice = turbo ? 0.0715 : 0.1144
const cost = (basePrice * numImages).toFixed(2)
return `$${cost}/Run`
}
@@ -420,29 +420,29 @@ const apiNodeCosts: Record<string, { displayPrice: string | PricingFunction }> =
characterInput.link != null
if (!renderingSpeedWidget)
return '$0.03-0.08 x num_images/Run (varies with rendering speed & num_images)'
return '$0.04-0.11 x num_images/Run (varies with rendering speed & num_images)'
const numImages = Number(numImagesWidget?.value) || 1
let basePrice = 0.06 // default balanced price
let basePrice = 0.0858 // default balanced price
const renderingSpeed = String(renderingSpeedWidget.value)
if (renderingSpeed.toLowerCase().includes('quality')) {
if (hasCharacter) {
basePrice = 0.2
basePrice = 0.286
} else {
basePrice = 0.09
basePrice = 0.1287
}
} else if (renderingSpeed.toLowerCase().includes('default')) {
if (hasCharacter) {
basePrice = 0.15
basePrice = 0.2145
} else {
basePrice = 0.06
basePrice = 0.0858
}
} else if (renderingSpeed.toLowerCase().includes('turbo')) {
if (hasCharacter) {
basePrice = 0.1
basePrice = 0.143
} else {
basePrice = 0.03
basePrice = 0.0429
}
}
@@ -755,7 +755,7 @@ const apiNodeCosts: Record<string, { displayPrice: string | PricingFunction }> =
) as IComboWidget
if (!modelWidget || !resolutionWidget || !durationWidget) {
return '$0.14-11.47/Run (varies with model, resolution & duration)'
return '$0.20-16.40/Run (varies with model, resolution & duration)'
}
const model = String(modelWidget.value)
@@ -764,33 +764,33 @@ const apiNodeCosts: Record<string, { displayPrice: string | PricingFunction }> =
if (model.includes('ray-flash-2')) {
if (duration.includes('5s')) {
if (resolution.includes('4k')) return '$2.19/Run'
if (resolution.includes('1080p')) return '$0.55/Run'
if (resolution.includes('720p')) return '$0.24/Run'
if (resolution.includes('540p')) return '$0.14/Run'
if (resolution.includes('4k')) return '$3.13/Run'
if (resolution.includes('1080p')) return '$0.79/Run'
if (resolution.includes('720p')) return '$0.34/Run'
if (resolution.includes('540p')) return '$0.20/Run'
} else if (duration.includes('9s')) {
if (resolution.includes('4k')) return '$3.95/Run'
if (resolution.includes('1080p')) return '$0.99/Run'
if (resolution.includes('720p')) return '$0.43/Run'
if (resolution.includes('540p')) return '$0.252/Run'
if (resolution.includes('4k')) return '$5.65/Run'
if (resolution.includes('1080p')) return '$1.42/Run'
if (resolution.includes('720p')) return '$0.61/Run'
if (resolution.includes('540p')) return '$0.36/Run'
}
} else if (model.includes('ray-2')) {
if (duration.includes('5s')) {
if (resolution.includes('4k')) return '$6.37/Run'
if (resolution.includes('1080p')) return '$1.59/Run'
if (resolution.includes('720p')) return '$0.71/Run'
if (resolution.includes('540p')) return '$0.40/Run'
if (resolution.includes('4k')) return '$9.11/Run'
if (resolution.includes('1080p')) return '$2.27/Run'
if (resolution.includes('720p')) return '$1.02/Run'
if (resolution.includes('540p')) return '$0.57/Run'
} else if (duration.includes('9s')) {
if (resolution.includes('4k')) return '$11.47/Run'
if (resolution.includes('1080p')) return '$2.87/Run'
if (resolution.includes('720p')) return '$1.28/Run'
if (resolution.includes('540p')) return '$0.72/Run'
if (resolution.includes('4k')) return '$16.40/Run'
if (resolution.includes('1080p')) return '$4.10/Run'
if (resolution.includes('720p')) return '$1.83/Run'
if (resolution.includes('540p')) return '$1.03/Run'
}
} else if (model.includes('ray-1.6')) {
return '$0.35/Run'
} else if (model.includes('ray-1-6')) {
return '$0.50/Run'
}
return '$0.55/Run'
return '$0.79/Run'
}
},
LumaVideoNode: {
@@ -806,7 +806,7 @@ const apiNodeCosts: Record<string, { displayPrice: string | PricingFunction }> =
) as IComboWidget
if (!modelWidget || !resolutionWidget || !durationWidget) {
return '$0.14-11.47/Run (varies with model, resolution & duration)'
return '$0.20-16.40/Run (varies with model, resolution & duration)'
}
const model = String(modelWidget.value)
@@ -815,33 +815,33 @@ const apiNodeCosts: Record<string, { displayPrice: string | PricingFunction }> =
if (model.includes('ray-flash-2')) {
if (duration.includes('5s')) {
if (resolution.includes('4k')) return '$2.19/Run'
if (resolution.includes('1080p')) return '$0.55/Run'
if (resolution.includes('720p')) return '$0.24/Run'
if (resolution.includes('540p')) return '$0.14/Run'
if (resolution.includes('4k')) return '$3.13/Run'
if (resolution.includes('1080p')) return '$0.79/Run'
if (resolution.includes('720p')) return '$0.34/Run'
if (resolution.includes('540p')) return '$0.20/Run'
} else if (duration.includes('9s')) {
if (resolution.includes('4k')) return '$3.95/Run'
if (resolution.includes('1080p')) return '$0.99/Run'
if (resolution.includes('720p')) return '$0.43/Run'
if (resolution.includes('540p')) return '$0.252/Run'
if (resolution.includes('4k')) return '$5.65/Run'
if (resolution.includes('1080p')) return '$1.42/Run'
if (resolution.includes('720p')) return '$0.61/Run'
if (resolution.includes('540p')) return '$0.36/Run'
}
} else if (model.includes('ray-2')) {
if (duration.includes('5s')) {
if (resolution.includes('4k')) return '$6.37/Run'
if (resolution.includes('1080p')) return '$1.59/Run'
if (resolution.includes('720p')) return '$0.71/Run'
if (resolution.includes('540p')) return '$0.40/Run'
if (resolution.includes('4k')) return '$9.11/Run'
if (resolution.includes('1080p')) return '$2.27/Run'
if (resolution.includes('720p')) return '$1.02/Run'
if (resolution.includes('540p')) return '$0.57/Run'
} else if (duration.includes('9s')) {
if (resolution.includes('4k')) return '$11.47/Run'
if (resolution.includes('1080p')) return '$2.87/Run'
if (resolution.includes('720p')) return '$1.28/Run'
if (resolution.includes('540p')) return '$0.72/Run'
if (resolution.includes('4k')) return '$16.40/Run'
if (resolution.includes('1080p')) return '$4.10/Run'
if (resolution.includes('720p')) return '$1.83/Run'
if (resolution.includes('540p')) return '$1.03/Run'
}
} else if (model.includes('ray-1-6')) {
return '$0.35/Run'
return '$0.50/Run'
}
return '$0.55/Run'
return '$0.79/Run'
}
},
MinimaxImageToVideoNode: {
@@ -1323,18 +1323,18 @@ const apiNodeCosts: Record<string, { displayPrice: string | PricingFunction }> =
) as IComboWidget
if (!modelWidget || !aspectRatioWidget) {
return '$0.0045-0.0182/Run (varies with model & aspect ratio)'
return '$0.0064-0.026/Run (varies with model & aspect ratio)'
}
const model = String(modelWidget.value)
if (model.includes('photon-flash-1')) {
return '$0.0019/Run'
return '$0.0027/Run'
} else if (model.includes('photon-1')) {
return '$0.0073/Run'
return '$0.0104/Run'
}
return '$0.0172/Run'
return '$0.0246/Run'
}
},
LumaImageModifyNode: {
@@ -1344,18 +1344,18 @@ const apiNodeCosts: Record<string, { displayPrice: string | PricingFunction }> =
) as IComboWidget
if (!modelWidget) {
return '$0.0019-0.0073/Run (varies with model)'
return '$0.0027-0.0104/Run (varies with model)'
}
const model = String(modelWidget.value)
if (model.includes('photon-flash-1')) {
return '$0.0019/Run'
return '$0.0027/Run'
} else if (model.includes('photon-1')) {
return '$0.0073/Run'
return '$0.0104/Run'
}
return '$0.0172/Run'
return '$0.0246/Run'
}
},
MoonvalleyTxt2VideoNode: {
@@ -1417,7 +1417,7 @@ const apiNodeCosts: Record<string, { displayPrice: string | PricingFunction }> =
},
// Runway nodes - using actual node names from ComfyUI
RunwayTextToImageNode: {
displayPrice: '$0.08/Run'
displayPrice: '$0.11/Run'
},
RunwayImageToVideoNodeGen3a: {
displayPrice: calculateRunwayDurationPrice

View File

@@ -1,10 +1,16 @@
import type { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
import { LGraphBadge } from '@/lib/litegraph/src/litegraph'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
import { adjustColor } from '@/utils/colorUtil'
const componentIconSvg = new Image()
componentIconSvg.src =
"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24'%3E%3Cpath fill='none' stroke='oklch(83.01%25 0.163 83.16)' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M15.536 11.293a1 1 0 0 0 0 1.414l2.376 2.377a1 1 0 0 0 1.414 0l2.377-2.377a1 1 0 0 0 0-1.414l-2.377-2.377a1 1 0 0 0-1.414 0zm-13.239 0a1 1 0 0 0 0 1.414l2.377 2.377a1 1 0 0 0 1.414 0l2.377-2.377a1 1 0 0 0 0-1.414L6.088 8.916a1 1 0 0 0-1.414 0zm6.619 6.619a1 1 0 0 0 0 1.415l2.377 2.376a1 1 0 0 0 1.414 0l2.377-2.376a1 1 0 0 0 0-1.415l-2.377-2.376a1 1 0 0 0-1.414 0zm0-13.238a1 1 0 0 0 0 1.414l2.377 2.376a1 1 0 0 0 1.414 0l2.377-2.376a1 1 0 0 0 0-1.414l-2.377-2.377a1 1 0 0 0-1.414 0z'/%3E%3C/svg%3E"
export const usePriceBadge = () => {
const { flags } = useFeatureFlags()
function updateSubgraphCredits(node: LGraphNode) {
if (!node.isSubgraphNode()) return
node.badges = node.badges.filter((b) => !isCreditsBadge(b))
@@ -33,34 +39,54 @@ export const usePriceBadge = () => {
}
function isCreditsBadge(badge: LGraphBadge | (() => LGraphBadge)): boolean {
return (
(typeof badge === 'function' ? badge() : badge).icon?.unicode === '\ue96b'
)
const badgeInstance = typeof badge === 'function' ? badge() : badge
if (flags.subscriptionTiersEnabled) {
return badgeInstance.icon?.image === componentIconSvg
} else {
return badgeInstance.icon?.unicode === '\ue96b'
}
}
const colorPaletteStore = useColorPaletteStore()
function getCreditsBadge(price: string): LGraphBadge {
const isLightTheme = colorPaletteStore.completedActivePalette.light_theme
return new LGraphBadge({
text: price,
iconOptions: {
unicode: '\ue96b',
fontFamily: 'PrimeIcons',
color: isLightTheme
? adjustColor('#FABC25', { lightness: 0.5 })
: '#FABC25',
if (flags.subscriptionTiersEnabled) {
return new LGraphBadge({
text: price,
iconOptions: {
image: componentIconSvg,
size: 8
},
fgColor:
colorPaletteStore.completedActivePalette.colors.litegraph_base
.BADGE_FG_COLOR,
bgColor: isLightTheme
? adjustColor('#654020', { lightness: 0.5 })
: '#654020',
fontSize: 8
},
fgColor:
colorPaletteStore.completedActivePalette.colors.litegraph_base
.BADGE_FG_COLOR,
bgColor: isLightTheme
? adjustColor('#8D6932', { lightness: 0.5 })
: '#8D6932'
})
? adjustColor('#8D6932', { lightness: 0.5 })
: '#8D6932'
})
} else {
return new LGraphBadge({
text: price,
iconOptions: {
unicode: '\ue96b',
fontFamily: 'PrimeIcons',
color: isLightTheme
? adjustColor('#FABC25', { lightness: 0.5 })
: '#FABC25',
bgColor: isLightTheme
? adjustColor('#654020', { lightness: 0.5 })
: '#654020',
fontSize: 8
},
fgColor:
colorPaletteStore.completedActivePalette.colors.litegraph_base
.BADGE_FG_COLOR,
bgColor: isLightTheme
? adjustColor('#8D6932', { lightness: 0.5 })
: '#8D6932'
})
}
}
return {
getCreditsBadge,

View File

@@ -12,7 +12,8 @@ export enum ServerFeatureFlag {
MANAGER_SUPPORTS_V4 = 'extension.manager.supports_v4',
MODEL_UPLOAD_BUTTON_ENABLED = 'model_upload_button_enabled',
ASSET_UPDATE_OPTIONS_ENABLED = 'asset_update_options_enabled',
PRIVATE_MODELS_ENABLED = 'private_models_enabled'
PRIVATE_MODELS_ENABLED = 'private_models_enabled',
SUBSCRIPTION_TIERS_ENABLED = 'subscription_tiers_enabled'
}
/**
@@ -55,6 +56,16 @@ export function useFeatureFlags() {
remoteConfig.value.private_models_enabled ??
api.getServerFeature(ServerFeatureFlag.PRIVATE_MODELS_ENABLED, false)
)
},
get subscriptionTiersEnabled() {
// Check remote config first (from /api/features), fall back to websocket feature flags
return (
remoteConfig.value.subscription_tiers_enabled ??
api.getServerFeature(
ServerFeatureFlag.SUBSCRIPTION_TIERS_ENABLED,
true // Default to true (new design)
)
)
}
})

View File

@@ -66,8 +66,12 @@ export class LGraphBadge {
const { font } = ctx
let iconWidth = 0
if (this.icon) {
ctx.font = `${this.icon.fontSize}px '${this.icon.fontFamily}'`
iconWidth = ctx.measureText(this.icon.unicode).width + this.padding
if (this.icon.image) {
iconWidth = this.icon.size + this.padding
} else if (this.icon.unicode) {
ctx.font = `${this.icon.fontSize}px '${this.icon.fontFamily}'`
iconWidth = ctx.measureText(this.icon.unicode).width + this.padding
}
}
ctx.font = `${this.fontSize}px sans-serif`
const textWidth = this.text ? ctx.measureText(this.text).width : 0
@@ -104,7 +108,8 @@ export class LGraphBadge {
// Draw icon if present
if (this.icon) {
this.icon.draw(ctx, drawX, centerY)
drawX += this.icon.fontSize + this.padding / 2 + 4
const iconWidth = this.icon.image ? this.icon.size : this.icon.fontSize
drawX += iconWidth + this.padding / 2 + 4
}
// Draw badge text

View File

@@ -1,20 +1,24 @@
export interface LGraphIconOptions {
unicode: string
unicode?: string
fontFamily?: string
image?: HTMLImageElement
color?: string
bgColor?: string
fontSize?: number
size?: number
circlePadding?: number
xOffset?: number
yOffset?: number
}
export class LGraphIcon {
unicode: string
unicode?: string
fontFamily: string
image?: HTMLImageElement
color: string
bgColor?: string
fontSize: number
size: number
circlePadding: number
xOffset: number
yOffset: number
@@ -22,18 +26,22 @@ export class LGraphIcon {
constructor({
unicode,
fontFamily = 'PrimeIcons',
image,
color = '#e6c200',
bgColor,
fontSize = 16,
size,
circlePadding = 2,
xOffset = 0,
yOffset = 0
}: LGraphIconOptions) {
this.unicode = unicode
this.fontFamily = fontFamily
this.image = image
this.color = color
this.bgColor = bgColor
this.fontSize = fontSize
this.size = size ?? fontSize
this.circlePadding = circlePadding
this.xOffset = xOffset
this.yOffset = yOffset
@@ -43,26 +51,44 @@ export class LGraphIcon {
x += this.xOffset
y += this.yOffset
const { font, textBaseline, textAlign, fillStyle } = ctx
if (this.image) {
const iconSize = this.size
const iconRadius = iconSize / 2 + this.circlePadding
ctx.font = `${this.fontSize}px '${this.fontFamily}'`
ctx.textBaseline = 'middle'
ctx.textAlign = 'center'
const iconRadius = this.fontSize / 2 + this.circlePadding
// Draw icon background circle if bgColor is set
if (this.bgColor) {
ctx.beginPath()
ctx.arc(x + iconRadius, y, iconRadius, 0, 2 * Math.PI)
ctx.fillStyle = this.bgColor
ctx.fill()
if (this.bgColor) {
const { fillStyle } = ctx
ctx.beginPath()
ctx.arc(x + iconRadius, y, iconRadius, 0, 2 * Math.PI)
ctx.fillStyle = this.bgColor
ctx.fill()
ctx.fillStyle = fillStyle
}
const imageX = x + this.circlePadding
const imageY = y - iconSize / 2
ctx.drawImage(this.image, imageX, imageY, iconSize, iconSize)
} else if (this.unicode) {
const { font, textBaseline, textAlign, fillStyle } = ctx
ctx.font = `${this.fontSize}px '${this.fontFamily}'`
ctx.textBaseline = 'middle'
ctx.textAlign = 'center'
const iconRadius = this.fontSize / 2 + this.circlePadding
if (this.bgColor) {
ctx.beginPath()
ctx.arc(x + iconRadius, y, iconRadius, 0, 2 * Math.PI)
ctx.fillStyle = this.bgColor
ctx.fill()
}
ctx.fillStyle = this.color
ctx.fillText(this.unicode, x + iconRadius, y)
ctx.font = font
ctx.textBaseline = textBaseline
ctx.textAlign = textAlign
ctx.fillStyle = fillStyle
}
// Draw icon
ctx.fillStyle = this.color
ctx.fillText(this.unicode, x + iconRadius, y)
ctx.font = font
ctx.textBaseline = textBaseline
ctx.textAlign = textAlign
ctx.fillStyle = fillStyle
}
}

View File

@@ -851,13 +851,12 @@ export class LGraphNode
}
if (info.widgets_values) {
const widgetsWithValue = this.widgets
.values()
.filter((w) => w.serialize !== false)
.filter((_w, idx) => idx < info.widgets_values!.length)
widgetsWithValue.forEach(
(widget, i) => (widget.value = info.widgets_values![i])
)
let i = 0
for (const widget of this.widgets ?? []) {
if (widget.serialize === false) continue
if (i >= info.widgets_values.length) break
widget.value = info.widgets_values[i++]
}
}
}

View File

@@ -97,6 +97,7 @@
"no": "No",
"cancel": "Cancel",
"close": "Close",
"or": "or",
"pressKeysForNewBinding": "Press keys for new binding",
"defaultBanner": "default banner",
"enableOrDisablePack": "Enable or disable pack",
@@ -1833,15 +1834,32 @@
"maxAmount": "(Max. $1,000 USD)",
"buyNow": "Buy now",
"seeDetails": "See details",
"topUp": "Top Up"
"topUp": "Top Up",
"addMoreCredits": "Add more credits",
"addMoreCreditsToRun": "Add more credits to run",
"insufficientWorkflowMessage": "You don't have enough credits to run this workflow.",
"creditsDescription": "Credits are used to run workflows or partner nodes.",
"howManyCredits": "How many credits would you like to add?",
"videosEstimate": "~{count} videos*",
"templateNote": "*Generated with Wan Fun Control template",
"buy": "Buy",
"purchaseError": "Purchase Failed",
"purchaseErrorDetail": "Failed to purchase credits: {error}",
"unknownError": "An unknown error occurred"
},
"creditsAvailable": "Credits available",
"refreshes": "Refreshes {date}",
"eventType": "Event Type",
"details": "Details",
"time": "Time",
"additionalInfo": "Additional Info",
"model": "Model",
"added": "Added",
"accountInitialized": "Account initialized"
"accountInitialized": "Account initialized",
"unified": {
"message": "Credits have been unified",
"tooltip": "We've unified payments across Comfy. Everything now runs on Comfy Credits:\n- Partner Nodes (formerly API nodes)\n- Cloud workflows\n\nYour existing Partner node balance has been converted into credits."
}
},
"subscription": {
"title": "Subscription",
@@ -1849,9 +1867,9 @@
"comfyCloud": "Comfy Cloud",
"comfyCloudLogo": "Comfy Cloud Logo",
"beta": "BETA",
"perMonth": "USD / month",
"perMonth": "/ month",
"usdPerMonth": "USD / month",
"renewsDate": "Renews {date}",
"refreshesOn": "Refreshes to ${monthlyCreditBonusUsd} on {date}",
"expiresDate": "Expires {date}",
"manageSubscription": "Manage subscription",
"partnerNodesBalance": "\"Partner Nodes\" Credit Balance",
@@ -1864,6 +1882,10 @@
"monthlyBonusDescription": "Monthly credit bonus",
"prepaidDescription": "Pre-paid credits",
"prepaidCreditsInfo": "Pre-paid credits expire after 1 year from purchase date.",
"creditsRemainingThisMonth": "Credits remaining this month",
"creditsYouveAdded": "Credits you've added",
"monthlyCreditsInfo": "These credits refresh monthly and don't roll over",
"viewMoreDetailsPlans": "View more details about plans & pricing",
"nextBillingCycle": "next billing cycle",
"yourPlanIncludes": "Your plan includes:",
"viewMoreDetails": "View more details",
@@ -1874,19 +1896,110 @@
"benefit1": "$10 in monthly credits for Partner Nodes — top up when needed",
"benefit2": "Up to 30 min runtime per job"
},
"tiers": {
"founder": {
"name": "Founder's Edition",
"price": "20.00",
"benefits": {
"monthlyCredits": "5,460",
"monthlyCreditsLabel": "monthly credits",
"maxDuration": "30 min",
"maxDurationLabel": "max duration of each workflow run",
"gpuLabel": "RTX 6000 Pro (96GB VRAM)",
"addCreditsLabel": "Add more credits whenever",
"customLoRAsLabel": "Import your own LoRAs"
}
},
"standard": {
"name": "Standard",
"price": "20.00",
"benefits": {
"monthlyCredits": "4,200",
"monthlyCreditsLabel": "monthly credits",
"maxDuration": "30 min",
"maxDurationLabel": "max duration of each workflow run",
"gpuLabel": "RTX 6000 Pro (96GB VRAM)",
"addCreditsLabel": "Add more credits whenever",
"customLoRAsLabel": "Import your own LoRAs",
"videoEstimate": "120"
}
},
"creator": {
"name": "Creator",
"price": "35.00",
"benefits": {
"monthlyCredits": "7,400",
"monthlyCreditsLabel": "monthly credits",
"maxDuration": "30 min",
"maxDurationLabel": "max duration of each workflow run",
"gpuLabel": "RTX 6000 Pro (96GB VRAM)",
"addCreditsLabel": "Add more credits whenever",
"customLoRAsLabel": "Import your own LoRAs",
"videoEstimate": "288"
}
},
"pro": {
"name": "Pro",
"price": "100.00",
"benefits": {
"monthlyCredits": "21,100",
"monthlyCreditsLabel": "monthly credits",
"maxDuration": "1 hr",
"maxDurationLabel": "max duration of each workflow run",
"gpuLabel": "RTX 6000 Pro (96GB VRAM)",
"addCreditsLabel": "Add more credits whenever",
"customLoRAsLabel": "Import your own LoRAs",
"videoEstimate": "815"
}
}
},
"required": {
"title": "Subscribe to",
"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",
"subscribeToComfyCloud": "Subscribe to Comfy Cloud",
"partnerNodesCredits": "Partner Nodes pricing table"
"description": "Choose the best plan for you",
"haveQuestions": "Have questions or wondering about enterprise?",
"contactUs": "Contact us",
"viewEnterprise": "view enterprise",
"partnerNodesCredits": "Partner nodes pricing",
"mostPopular": "Most popular",
"currentPlan": "Current Plan",
"subscribeTo": "Subscribe to {plan}",
"monthlyCreditsLabel": "Monthly credits",
"maxDurationLabel": "Max duration of each workflow run",
"gpuLabel": "RTX 6000 Pro (96GB VRAM)",
"addCreditsLabel": "Add more credits whenever",
"customLoRAsLabel": "Import your own LoRAs",
"videoEstimateLabel": "Approx. amount of 5s videos generated with Wan Fun Control template",
"videoEstimateHelp": "What is this?",
"videoEstimateExplanation": "These estimates are based on the Wan Fun Control template for 5-second videos.",
"videoEstimateTryTemplate": "Try the Wan Fun Control template →",
"upgradeTo": "Upgrade to {plan}",
"changeTo": "Change to {plan}",
"credits": {
"standard": "4,200",
"creator": "7,400",
"pro": "21,100"
},
"maxDuration": {
"standard": "30 min",
"creator": "30 min",
"pro": "1 hr"
}
},
"userSettings": {
"title": "User Settings",
"title": "My Account Settings",
"name": "Name",
"email": "Email",
"provider": "Sign-in Provider",
@@ -2097,11 +2210,11 @@
"uploadModelFailedToRetrieveMetadata": "Failed to retrieve metadata. Please check the link and try again.",
"onlyCivitaiUrlsSupported": "Only Civitai URLs are supported",
"uploadModelDescription1": "Paste a Civitai model download link to add it to your library.",
"uploadModelDescription2": "Only links from <a href=\"https://civitai.com\" target=\"_blank\" class=\"text-muted-foreground\">https://civitai.com</a> are supported at the moment",
"uploadModelDescription2": "Only links from <a href=\"https://civitai.com/models\" target=\"_blank\" class=\"text-muted-foreground\">https://civitai.com/models</a> are supported at the moment",
"uploadModelDescription3": "Max file size: <strong>1 GB</strong>",
"civitaiLinkLabel": "Civitai model <span class=\"font-bold italic\">download</span> link",
"civitaiLinkPlaceholder": "Paste link here",
"civitaiLinkExample": "<strong>Example:</strong> <a href=\"https://civitai.com/api/download/models/833921?type=Model&format=SafeTensor\" target=\"_blank\" class=\"text-muted-foreground\">https://civitai.com/api/download/models/833921?type=Model&format=SafeTensor</a>",
"civitaiLinkExample": "<strong>Example:</strong> <a href=\"https://civitai.com/models/10706/luisap-z-image-and-qwen-pixel-art-refiner?modelVersionId=2225295\" target=\"_blank\" class=\"text-muted-foreground\">https://civitai.com/models/10706/luisap-z-image-and-qwen-pixel-art-refiner?modelVersionId=2225295</a>",
"confirmModelDetails": "Confirm Model Details",
"fileName": "File Name",
"fileSize": "File Size",
@@ -2121,7 +2234,7 @@
"modelTypeSelectorPlaceholder": "Select model type",
"selectModelType": "Select model type",
"notSureLeaveAsIs": "Not sure? Just leave this as is",
"modelUploaded": "Model imported! 🎉",
"modelUploaded": "Model successfully imported.",
"findInLibrary": "Find it in the {type} section of the models library.",
"finish": "Finish",
"upgradeToUnlockFeature": "Upgrade to unlock this feature",

View File

@@ -1,13 +1,22 @@
<template>
<div class="flex flex-col gap-4 text-sm text-muted-foreground">
<!-- Model Info Section -->
<div class="flex flex-col gap-2">
<p class="m-0">
{{ $t('assetBrowser.modelAssociatedWithLink') }}
</p>
<p class="mt-0 text-base-foreground rounded-lg">
{{ metadata?.filename || metadata?.name }}
</p>
<div
class="flex items-center gap-3 bg-secondary-background p-3 rounded-lg"
>
<img
v-if="previewImage"
:src="previewImage"
:alt="metadata?.filename || metadata?.name || 'Model preview'"
class="w-14 h-14 rounded object-cover flex-shrink-0"
/>
<p class="m-0 text-base-foreground">
{{ metadata?.filename || metadata?.name }}
</p>
</div>
</div>
<!-- Model Type Selection -->
@@ -40,7 +49,8 @@ import { useModelTypes } from '@/platform/assets/composables/useModelTypes'
import type { AssetMetadata } from '@/platform/assets/schemas/assetSchema'
defineProps<{
metadata: AssetMetadata | null
metadata?: AssetMetadata
previewImage?: string
}>()
const modelValue = defineModel<string | undefined>()

View File

@@ -14,6 +14,7 @@
v-else-if="currentStep === 2"
v-model="selectedModelType"
:metadata="wizardData.metadata"
:preview-image="wizardData.previewImage"
/>
<!-- Step 3: Upload Progress -->
@@ -23,6 +24,7 @@
:error="uploadError"
:metadata="wizardData.metadata"
:model-type="selectedModelType"
:preview-image="wizardData.previewImage"
/>
<!-- Navigation Footer -->

View File

@@ -25,8 +25,14 @@
</p>
<div
class="flex flex-row items-start p-4 bg-modal-card-background rounded-lg"
class="flex flex-row items-center gap-3 p-4 bg-modal-card-background rounded-lg"
>
<img
v-if="previewImage"
:src="previewImage"
:alt="metadata?.filename || metadata?.name || 'Model preview'"
class="w-14 h-14 rounded object-cover flex-shrink-0"
/>
<div class="flex flex-col justify-center items-start gap-1 flex-1">
<p class="text-base-foreground m-0">
{{ metadata?.filename || metadata?.name }}
@@ -63,7 +69,8 @@ import type { AssetMetadata } from '@/platform/assets/schemas/assetSchema'
defineProps<{
status: 'idle' | 'uploading' | 'success' | 'error'
error?: string
metadata: AssetMetadata | null
modelType: string | undefined
metadata?: AssetMetadata
modelType?: string
previewImage?: string
}>()
</script>

View File

@@ -9,9 +9,10 @@ import { useModelToNodeStore } from '@/stores/modelToNodeStore'
interface WizardData {
url: string
metadata: AssetMetadata | null
metadata?: AssetMetadata
name: string
tags: string[]
previewImage?: string
}
interface ModelTypeOption {
@@ -30,7 +31,6 @@ export function useUploadModelWizard(modelTypes: Ref<ModelTypeOption[]>) {
const wizardData = ref<WizardData>({
url: '',
metadata: null,
name: '',
tags: []
})
@@ -91,6 +91,9 @@ export function useUploadModelWizard(modelTypes: Ref<ModelTypeOption[]>) {
// Pre-fill name from metadata
wizardData.value.name = metadata.filename || metadata.name || ''
// Store preview image if available
wizardData.value.previewImage = metadata.preview_image
// Pre-fill model type from metadata tags if available
if (metadata.tags && metadata.tags.length > 0) {
wizardData.value.tags = metadata.tags
@@ -134,6 +137,34 @@ export function useUploadModelWizard(modelTypes: Ref<ModelTypeOption[]>) {
wizardData.value.metadata?.name ||
'model'
let previewId: string | undefined
// Upload preview image first if available
if (wizardData.value.previewImage) {
try {
const baseFilename = filename.split('.')[0]
// Extract extension from data URL MIME type
let extension = 'png'
const mimeMatch = wizardData.value.previewImage.match(
/^data:image\/([^;]+);/
)
if (mimeMatch) {
extension = mimeMatch[1] === 'jpeg' ? 'jpg' : mimeMatch[1]
}
const previewAsset = await assetService.uploadAssetFromBase64({
data: wizardData.value.previewImage,
name: `${baseFilename}_preview.${extension}`,
tags: ['preview']
})
previewId = previewAsset.id
} catch (error) {
console.error('Failed to upload preview image:', error)
// Continue with model upload even if preview fails
}
}
await assetService.uploadAssetFromUrl({
url: wizardData.value.url,
name: filename,
@@ -142,7 +173,8 @@ export function useUploadModelWizard(modelTypes: Ref<ModelTypeOption[]>) {
source: 'civitai',
source_url: wizardData.value.url,
model_type: selectedModelType.value
}
},
preview_id: previewId
})
uploadStatus.value = 'success'

View File

@@ -54,6 +54,7 @@ const zAssetMetadata = z.object({
name: z.string().optional(),
tags: z.array(z.string()).optional(),
preview_url: z.string().optional(),
preview_image: z.string().optional(),
validation: zValidationResult.optional()
})

View File

@@ -392,6 +392,59 @@ function createAssetService() {
return await res.json()
}
/**
* Uploads an asset from base64 data
*
* @param params - Upload parameters
* @param params.data - Base64 data URL (e.g., "data:image/png;base64,...")
* @param params.name - Display name (determines extension)
* @param params.tags - Optional freeform tags
* @param params.user_metadata - Optional custom metadata object
* @returns Promise<AssetItem & { created_new: boolean }> - Asset object with created_new flag
* @throws Error if upload fails
*/
async function uploadAssetFromBase64(params: {
data: string
name: string
tags?: string[]
user_metadata?: Record<string, any>
}): Promise<AssetItem & { created_new: boolean }> {
// Validate that data is a data URL
if (!params.data || !params.data.startsWith('data:')) {
throw new Error(
'Invalid data URL: expected a string starting with "data:"'
)
}
// Convert base64 data URL to Blob
const blob = await fetch(params.data).then((r) => r.blob())
// Create FormData and append the blob
const formData = new FormData()
formData.append('file', blob, params.name)
if (params.tags) {
formData.append('tags', JSON.stringify(params.tags))
}
if (params.user_metadata) {
formData.append('user_metadata', JSON.stringify(params.user_metadata))
}
const res = await api.fetchApi(ASSETS_ENDPOINT, {
method: 'POST',
body: formData
})
if (!res.ok) {
throw new Error(
`Failed to upload asset from base64: ${res.status} ${res.statusText}`
)
}
return await res.json()
}
return {
getAssetModelFolders,
getAssetModels,
@@ -402,7 +455,8 @@ function createAssetService() {
deleteAsset,
updateAsset,
getAssetMetadata,
uploadAssetFromUrl
uploadAssetFromUrl,
uploadAssetFromBase64
}
}

View File

@@ -0,0 +1,336 @@
<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>
</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" />
<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.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.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.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-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-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') }}
</span>
<div class="flex flex-row items-center gap-2 opacity-50">
<i
class="pi pi-question-circle text-xs text-muted-foreground"
/>
<span
class="text-sm font-normal text-muted-foreground cursor-pointer hover:text-base-foreground"
@click="togglePopover"
>
{{ t('subscription.videoEstimateHelp') }}
</span>
</div>
</div>
<span
class="font-inter text-sm font-bold leading-normal text-base-foreground"
>
{{ tier.videoEstimate }}
</span>
</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:
'font-inter text-sm font-bold leading-normal text-primary-foreground'
}
}"
@click="() => handleSubscribe(tier.key)"
/>
</div>
</div>
</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 Button from 'primevue/button'
import Popover from 'primevue/popover'
import { computed, ref } from 'vue'
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
import { useErrorHandling } from '@/composables/useErrorHandling'
import { getComfyApiBaseUrl } from '@/config/comfyApi'
import { t } from '@/i18n'
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
import { isCloud } from '@/platform/distribution/types'
import {
FirebaseAuthStoreError,
useFirebaseAuthStore
} from '@/stores/firebaseAuthStore'
import type { components } from '@/types/comfyRegistryTypes'
type SubscriptionTier = components['schemas']['SubscriptionTier']
type TierKey = 'standard' | 'creator' | 'pro'
interface PricingTierConfig {
id: SubscriptionTier
key: TierKey
name: string
price: string
credits: string
maxDuration: string
customLoRAs: boolean
videoEstimate: string
isPopular?: boolean
}
const TIER_TO_KEY: Record<SubscriptionTier, TierKey> = {
STANDARD: 'standard',
CREATOR: 'creator',
PRO: 'pro',
FOUNDERS_EDITION: 'standard'
}
const tiers: PricingTierConfig[] = [
{
id: 'STANDARD',
key: 'standard',
name: t('subscription.tiers.standard.name'),
price: t('subscription.tiers.standard.price'),
credits: t('subscription.credits.standard'),
maxDuration: t('subscription.maxDuration.standard'),
customLoRAs: false,
videoEstimate: t('subscription.tiers.standard.benefits.videoEstimate'),
isPopular: false
},
{
id: 'CREATOR',
key: 'creator',
name: t('subscription.tiers.creator.name'),
price: t('subscription.tiers.creator.price'),
credits: t('subscription.credits.creator'),
maxDuration: t('subscription.maxDuration.creator'),
customLoRAs: true,
videoEstimate: t('subscription.tiers.creator.benefits.videoEstimate'),
isPopular: true
},
{
id: 'PRO',
key: 'pro',
name: t('subscription.tiers.pro.name'),
price: t('subscription.tiers.pro.price'),
credits: t('subscription.credits.pro'),
maxDuration: t('subscription.maxDuration.pro'),
customLoRAs: true,
videoEstimate: t('subscription.tiers.pro.benefits.videoEstimate'),
isPopular: false
}
]
const { getAuthHeader } = useFirebaseAuthStore()
const { isActiveSubscription, subscriptionTier } = useSubscription()
const { accessBillingPortal, reportError } = useFirebaseAuthActions()
const { wrapWithErrorHandlingAsync } = useErrorHandling()
const isLoading = ref(false)
const loadingTier = ref<TierKey | null>(null)
const popover = ref()
const currentTierKey = computed<TierKey | null>(() =>
subscriptionTier.value ? TIER_TO_KEY[subscriptionTier.value] : null
)
const isCurrentPlan = (tierKey: TierKey): boolean =>
currentTierKey.value === tierKey
const togglePopover = (event: Event) => {
popover.value.toggle(event)
}
const getButtonLabel = (tier: PricingTierConfig): string => {
if (isCurrentPlan(tier.key)) return t('subscription.currentPlan')
if (!isActiveSubscription.value)
return t('subscription.subscribeTo', { plan: tier.name })
return t('subscription.changeTo', { plan: tier.name })
}
const getButtonSeverity = (tier: PricingTierConfig): 'primary' | 'secondary' =>
isCurrentPlan(tier.key)
? 'secondary'
: tier.key === 'creator'
? 'primary'
: 'secondary'
const initiateCheckout = async (tierKey: TierKey) => {
const authHeader = await getAuthHeader()
if (!authHeader) {
throw new FirebaseAuthStoreError(t('toastMessages.userNotAuthenticated'))
}
const response = await fetch(
`${getComfyApiBaseUrl()}/customers/cloud-subscription-checkout/${tierKey}`,
{
method: 'POST',
headers: { ...authHeader, 'Content-Type': 'application/json' }
}
)
if (!response.ok) {
let errorMessage = 'Failed to initiate checkout'
try {
const errorData = await response.json()
errorMessage = errorData.message || errorMessage
} catch {
// If JSON parsing fails, try to get text response or use HTTP status
try {
const errorText = await response.text()
errorMessage =
errorText || `HTTP ${response.status} ${response.statusText}`
} catch {
errorMessage = `HTTP ${response.status} ${response.statusText}`
}
}
throw new FirebaseAuthStoreError(
t('toastMessages.failedToInitiateSubscription', {
error: errorMessage
})
)
}
return await response.json()
}
const handleSubscribe = wrapWithErrorHandlingAsync(async (tierKey: TierKey) => {
if (!isCloud || isLoading.value || isCurrentPlan(tierKey)) return
isLoading.value = true
loadingTier.value = tierKey
try {
if (isActiveSubscription.value) {
await accessBillingPortal()
} else {
const response = await initiateCheckout(tierKey)
if (response.checkout_url) {
window.open(response.checkout_url, '_blank')
}
}
} finally {
isLoading.value = false
loadingTier.value = null
}
}, reportError)
</script>

View File

@@ -24,8 +24,9 @@
<script setup lang="ts">
import Button from 'primevue/button'
import { computed, onBeforeUnmount, ref } from 'vue'
import { computed, onBeforeUnmount, ref, watch } from 'vue'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
import { isCloud } from '@/platform/distribution/types'
import { useTelemetry } from '@/platform/telemetry'
@@ -51,12 +52,18 @@ const emit = defineEmits<{
subscribed: []
}>()
const { subscribe, isActiveSubscription, fetchStatus } = useSubscription()
const { subscribe, isActiveSubscription, fetchStatus, showSubscriptionDialog } =
useSubscription()
const { flags } = useFeatureFlags()
const shouldUseStripePricing = computed(
() => isCloud && Boolean(flags.subscriptionTiersEnabled)
)
const telemetry = useTelemetry()
const isLoading = ref(false)
const isPolling = ref(false)
let pollInterval: number | null = null
const isAwaitingStripeSubscription = ref(false)
const POLL_INTERVAL_MS = 3000 // Poll every 3 seconds
const MAX_POLL_DURATION_MS = 5 * 60 * 1000 // Stop polling after 5 minutes
@@ -102,11 +109,27 @@ const stopPolling = () => {
isLoading.value = false
}
watch(
[isAwaitingStripeSubscription, isActiveSubscription],
([awaiting, isActive]) => {
if (shouldUseStripePricing.value && awaiting && isActive) {
emit('subscribed')
isAwaitingStripeSubscription.value = false
}
}
)
const handleSubscribe = async () => {
if (isCloud) {
useTelemetry()?.trackSubscription('subscribe_clicked')
}
if (shouldUseStripePricing.value) {
isAwaitingStripeSubscription.value = true
showSubscriptionDialog()
return
}
isLoading.value = true
try {
await subscribe()
@@ -120,5 +143,6 @@ const handleSubscribe = async () => {
onBeforeUnmount(() => {
stopPolling()
isAwaitingStripeSubscription.value = false
})
</script>

View File

@@ -1,42 +1,19 @@
<template>
<div class="flex flex-col items-start gap-1 self-stretch">
<div class="flex items-start gap-2">
<i class="pi pi-check mt-1 text-xs text-text-primary" />
<div class="flex flex-col items-start gap-0 self-stretch">
<div class="flex items-center gap-2 py-2">
<i class="pi pi-check text-xs text-text-primary" />
<span class="text-sm text-text-primary">
{{ $t('subscription.benefits.benefit1') }}
</span>
</div>
<div class="flex items-start gap-2">
<i class="pi pi-check mt-1 text-xs text-text-primary" />
<div class="flex items-center gap-2 py-2">
<i class="pi pi-check text-xs text-text-primary" />
<span class="text-sm text-text-primary">
{{ $t('subscription.benefits.benefit2') }}
</span>
</div>
<Button
:label="$t('subscription.viewMoreDetails')"
text
icon="pi pi-external-link"
icon-pos="left"
class="flex h-8 min-h-6 py-2 px-0 items-center gap-2 rounded text-text-secondary"
:pt="{
icon: {
class: 'text-xs text-text-secondary'
},
label: {
class: 'text-sm text-text-secondary'
}
}"
@click="handleViewMoreDetails"
/>
</div>
</template>
<script setup lang="ts">
import Button from 'primevue/button'
const handleViewMoreDetails = () => {
window.open('https://www.comfy.org/cloud/pricing', '_blank')
}
</script>
<script setup lang="ts"></script>

View File

@@ -19,9 +19,12 @@
<div class="rounded-2xl border border-interface-stroke p-6">
<div>
<div class="flex items-center justify-between">
<div>
<div class="flex flex-col gap-2">
<div class="text-sm font-bold text-text-primary">
{{ subscriptionTierName }}
</div>
<div class="flex items-baseline gap-1 font-inter font-semibold">
<span class="text-2xl">{{ formattedMonthlyPrice }}</span>
<span class="text-2xl">${{ tierPrice }}</span>
<span class="text-base">{{
$t('subscription.perMonth')
}}</span>
@@ -59,7 +62,7 @@
class: 'text-text-primary'
}
}"
@click="manageSubscription"
@click="showSubscriptionDialog"
/>
<SubscribeButton
v-else
@@ -75,17 +78,6 @@
<div class="grid grid-cols-1 gap-6 pt-9 lg:grid-cols-2">
<div class="flex flex-col flex-1">
<div class="flex flex-col gap-3">
<div class="flex flex-col">
<div class="text-sm">
{{ $t('subscription.partnerNodesBalance') }}
</div>
<div class="flex items-center">
<div class="text-sm text-muted">
{{ $t('subscription.partnerNodesDescription') }}
</div>
</div>
</div>
<div
:class="
cn(
@@ -112,7 +104,7 @@
/>
<div class="flex flex-col gap-2">
<div class="text-sm text-text-secondary">
<div class="text-sm text-muted">
{{ $t('subscription.totalCredits') }}
</div>
<Skeleton
@@ -121,7 +113,7 @@
height="2rem"
/>
<div v-else class="text-2xl font-bold">
${{ totalCredits }}
{{ totalCredits }}
</div>
</div>
@@ -133,26 +125,19 @@
width="3rem"
height="1rem"
/>
<div v-else class="text-sm text-text-secondary font-bold">
${{ monthlyBonusCredits }}
<div
v-else
class="text-sm font-bold w-12 shrink-0 text-left text-muted"
>
{{ monthlyBonusCredits }}
</div>
<div class="flex items-center gap-1">
<div class="text-sm text-text-secondary">
{{ $t('subscription.monthlyBonusDescription') }}
<div class="flex items-center gap-1 min-w-0">
<div
class="text-sm truncate text-muted"
:title="$t('subscription.creditsRemainingThisMonth')"
>
{{ $t('subscription.creditsRemainingThisMonth') }}
</div>
<Button
v-tooltip="refreshTooltip"
icon="pi pi-question-circle"
text
rounded
size="small"
class="h-4 w-4"
:pt="{
icon: {
class: 'text-text-secondary text-xs'
}
}"
/>
</div>
</div>
<div class="flex items-center gap-4">
@@ -161,12 +146,18 @@
width="3rem"
height="1rem"
/>
<div v-else class="text-sm text-text-secondary font-bold">
${{ prepaidCredits }}
<div
v-else
class="text-sm font-bold w-12 shrink-0 text-left text-muted"
>
{{ prepaidCredits }}
</div>
<div class="flex items-center gap-1">
<div class="text-sm text-text-secondary">
{{ $t('subscription.prepaidDescription') }}
<div class="flex items-center gap-1 min-w-0">
<div
class="text-sm truncate text-muted"
:title="$t('subscription.creditsYouveAdded')"
>
{{ $t('subscription.creditsYouveAdded') }}
</div>
<Button
v-tooltip="$t('subscription.prepaidCreditsInfo')"
@@ -174,7 +165,7 @@
text
rounded
size="small"
class="h-4 w-4"
class="h-4 w-4 shrink-0"
:pt="{
icon: {
class: 'text-text-secondary text-xs'
@@ -190,8 +181,7 @@
href="https://platform.comfy.org/profile/usage"
target="_blank"
rel="noopener noreferrer"
class="text-sm text-text-secondary underline hover:text-text-secondary"
style="text-decoration: underline"
class="text-sm underline text-center text-muted"
>
{{ $t('subscription.viewUsageHistory') }}
</a>
@@ -216,14 +206,47 @@
</div>
<div class="flex flex-col gap-2 flex-1">
<div class="text-sm">
<div class="text-sm text-text-primary">
{{ $t('subscription.yourPlanIncludes') }}
</div>
<SubscriptionBenefits />
<div class="flex flex-col gap-0">
<div
v-for="benefit in tierBenefits"
:key="benefit.key"
class="flex items-center gap-2 py-2"
>
<i
v-if="benefit.type === 'feature'"
class="pi pi-check text-xs text-text-primary"
/>
<span
v-else-if="benefit.type === 'metric' && benefit.value"
class="text-sm font-normal whitespace-nowrap text-text-primary"
>
{{ benefit.value }}
</span>
<span class="text-sm text-muted">
{{ benefit.label }}
</span>
</div>
</div>
</div>
</div>
</div>
<!-- View More Details - Outside main content -->
<div class="flex items-center gap-2 py-4">
<i class="pi pi-external-link text-muted"></i>
<a
href="https://www.comfy.org/cloud/pricing"
target="_blank"
rel="noopener noreferrer"
class="text-sm underline hover:opacity-80 text-muted"
>
{{ $t('subscription.viewMoreDetailsPlans') }}
</a>
</div>
</div>
<div
@@ -307,34 +330,115 @@
import Button from 'primevue/button'
import Skeleton from 'primevue/skeleton'
import TabPanel from 'primevue/tabpanel'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import CloudBadge from '@/components/topbar/CloudBadge.vue'
import { useExternalLink } from '@/composables/useExternalLink'
import SubscribeButton from '@/platform/cloud/subscription/components/SubscribeButton.vue'
import SubscriptionBenefits from '@/platform/cloud/subscription/components/SubscriptionBenefits.vue'
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
import { useSubscriptionActions } from '@/platform/cloud/subscription/composables/useSubscriptionActions'
import { useSubscriptionCredits } from '@/platform/cloud/subscription/composables/useSubscriptionCredits'
import { useSubscriptionDialog } from '@/platform/cloud/subscription/composables/useSubscriptionDialog'
import type { components } from '@/types/comfyRegistryTypes'
import { cn } from '@/utils/tailwindUtil'
type SubscriptionTier = components['schemas']['SubscriptionTier']
/** Maps API subscription tier values to i18n translation keys */
const TIER_TO_I18N_KEY = {
STANDARD: 'standard',
CREATOR: 'creator',
PRO: 'pro',
FOUNDERS_EDITION: 'founder'
} as const satisfies Record<SubscriptionTier, string>
type TierKey = (typeof TIER_TO_I18N_KEY)[SubscriptionTier]
const DEFAULT_TIER_KEY: TierKey = 'standard'
const { buildDocsUrl } = useExternalLink()
const { t } = useI18n()
const {
isActiveSubscription,
isCancelled,
formattedRenewalDate,
formattedEndDate,
formattedMonthlyPrice,
manageSubscription,
subscriptionTier,
subscriptionTierName,
handleInvoiceHistory
} = useSubscription()
const { show: showSubscriptionDialog } = useSubscriptionDialog()
const tierKey = computed(() => {
const tier = subscriptionTier.value
if (!tier) return DEFAULT_TIER_KEY
return TIER_TO_I18N_KEY[tier] ?? DEFAULT_TIER_KEY
})
const tierPrice = computed(() => t(`subscription.tiers.${tierKey.value}.price`))
// Tier benefits for v-for loop
type BenefitType = 'metric' | 'feature'
interface Benefit {
key: string
type: BenefitType
label: string
value?: string
}
const BENEFITS_BY_TIER: Record<
TierKey,
ReadonlyArray<Omit<Benefit, 'label' | 'value'>>
> = {
standard: [
{ key: 'monthlyCredits', type: 'metric' },
{ key: 'maxDuration', type: 'metric' },
{ key: 'gpu', type: 'feature' },
{ key: 'addCredits', type: 'feature' }
],
creator: [
{ key: 'monthlyCredits', type: 'metric' },
{ key: 'maxDuration', type: 'metric' },
{ key: 'gpu', type: 'feature' },
{ key: 'addCredits', type: 'feature' },
{ key: 'customLoRAs', type: 'feature' }
],
pro: [
{ key: 'monthlyCredits', type: 'metric' },
{ key: 'maxDuration', type: 'metric' },
{ key: 'gpu', type: 'feature' },
{ key: 'addCredits', type: 'feature' },
{ key: 'customLoRAs', type: 'feature' }
],
founder: [
{ key: 'monthlyCredits', type: 'metric' },
{ key: 'maxDuration', type: 'metric' },
{ key: 'gpu', type: 'feature' },
{ key: 'addCredits', type: 'feature' }
]
}
const tierBenefits = computed(() => {
const key = tierKey.value
const benefitConfig = BENEFITS_BY_TIER[key]
return benefitConfig.map((config) => ({
...config,
...(config.type === 'metric' && {
value: t(`subscription.tiers.${key}.benefits.${config.key}`)
}),
label: t(`subscription.tiers.${key}.benefits.${config.key}Label`)
}))
})
const { totalCredits, monthlyBonusCredits, prepaidCredits, isLoadingBalance } =
useSubscriptionCredits()
const {
isLoadingSupport,
refreshTooltip,
handleAddApiCredits,
handleMessageSupport,
handleRefresh,

View File

@@ -1,5 +1,68 @@
<template>
<div class="relative grid h-full grid-cols-5">
<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"
>
<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"
/>
</div>
<PricingTable class="flex-1" />
<!-- Contact and Enterprise Links -->
<div class="flex flex-col items-center">
<p class="text-sm text-text-secondary">
{{ $t('subscription.haveQuestions') }}
</p>
<div class="flex items-center gap-2">
<Button
:label="$t('subscription.contactUs')"
text
severity="secondary"
icon="pi pi-comments"
icon-pos="right"
class="h-6 p-1 text-sm text-text-secondary hover:text-base-foreground"
@click="handleContactUs"
/>
<span class="text-sm text-text-secondary">{{ $t('g.or') }}</span>
<Button
:label="$t('subscription.viewEnterprise')"
text
severity="secondary"
icon="pi pi-external-link"
icon-pos="right"
class="h-6 p-1 text-sm text-text-secondary hover:text-base-foreground"
@click="handleViewEnterprise"
/>
</div>
</div>
</div>
<div v-else class="legacy-dialog relative grid h-full grid-cols-5">
<!-- Custom close button -->
<Button
icon="pi pi-times"
@@ -7,7 +70,7 @@
rounded
class="absolute top-2.5 right-2.5 z-10 h-8 w-8 p-0 text-white hover:bg-white/20"
:aria-label="$t('g.close')"
@click="onClose"
@click="handleClose"
/>
<div
@@ -72,13 +135,19 @@
<script setup lang="ts">
import Button from 'primevue/button'
import { computed, onBeforeUnmount, watch } from 'vue'
import CloudBadge from '@/components/topbar/CloudBadge.vue'
import { MONTHLY_SUBSCRIPTION_PRICE } from '@/config/subscriptionPricesConfig'
import PricingTable from '@/platform/cloud/subscription/components/PricingTable.vue'
import SubscribeButton from '@/platform/cloud/subscription/components/SubscribeButton.vue'
import SubscriptionBenefits from '@/platform/cloud/subscription/components/SubscriptionBenefits.vue'
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
import { isCloud } from '@/platform/distribution/types'
import { useTelemetry } from '@/platform/telemetry'
import { useCommandStore } from '@/stores/commandStore'
defineProps<{
const props = defineProps<{
onClose: () => void
}>()
@@ -86,19 +155,132 @@ const emit = defineEmits<{
close: [subscribed: boolean]
}>()
const { formattedMonthlyPrice } = useSubscription()
const { fetchStatus, isActiveSubscription } = useSubscription()
// Legacy price for non-tier flow with locale-aware formatting
const formattedMonthlyPrice = new Intl.NumberFormat(
navigator.language || 'en-US',
{
style: 'currency',
currency: 'USD',
minimumFractionDigits: 0,
maximumFractionDigits: 0
}
).format(MONTHLY_SUBSCRIPTION_PRICE)
const commandStore = useCommandStore()
const telemetry = useTelemetry()
// Always show custom pricing table for cloud subscriptions
const showCustomPricingTable = computed(
() => isCloud && window.__CONFIG__?.subscription_required
)
const POLL_INTERVAL_MS = 3000
const MAX_POLL_ATTEMPTS = 3
let pollInterval: number | null = null
let pollAttempts = 0
const stopPolling = () => {
if (pollInterval) {
clearInterval(pollInterval)
pollInterval = null
}
}
const startPolling = () => {
stopPolling()
pollAttempts = 0
const poll = async () => {
try {
await fetchStatus()
pollAttempts++
if (pollAttempts >= MAX_POLL_ATTEMPTS) {
stopPolling()
}
} catch (error) {
console.error(
'[SubscriptionDialog] Failed to poll subscription status',
error
)
stopPolling()
}
}
void poll()
pollInterval = window.setInterval(() => {
void poll()
}, POLL_INTERVAL_MS)
}
const handleWindowFocus = () => {
if (showCustomPricingTable.value) {
startPolling()
}
}
watch(
showCustomPricingTable,
(enabled) => {
if (enabled) {
window.addEventListener('focus', handleWindowFocus)
} else {
window.removeEventListener('focus', handleWindowFocus)
stopPolling()
}
},
{ immediate: true }
)
watch(
() => isActiveSubscription.value,
(isActive) => {
if (isActive && showCustomPricingTable.value) {
emit('close', true)
}
}
)
const handleSubscribed = () => {
emit('close', true)
}
const handleClose = () => {
stopPolling()
props.onClose()
}
const handleContactUs = async () => {
telemetry?.trackHelpResourceClicked({
resource_type: 'help_feedback',
is_external: true,
source: 'subscription'
})
await commandStore.execute('Comfy.ContactSupport')
}
const handleViewEnterprise = () => {
telemetry?.trackHelpResourceClicked({
resource_type: 'docs',
is_external: true,
source: 'subscription'
})
window.open('https://www.comfy.org/cloud/enterprise', '_blank')
}
onBeforeUnmount(() => {
stopPolling()
window.removeEventListener('focus', handleWindowFocus)
})
</script>
<style scoped>
:deep(.bg-comfy-menu-secondary) {
.legacy-dialog :deep(.bg-comfy-menu-secondary) {
background-color: transparent;
}
:deep(.p-button) {
.legacy-dialog :deep(.p-button) {
color: white;
}
</style>

View File

@@ -5,26 +5,32 @@ import { useCurrentUser } from '@/composables/auth/useCurrentUser'
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
import { useErrorHandling } from '@/composables/useErrorHandling'
import { getComfyApiBaseUrl, getComfyPlatformBaseUrl } from '@/config/comfyApi'
import { MONTHLY_SUBSCRIPTION_PRICE } from '@/config/subscriptionPricesConfig'
import { t } from '@/i18n'
import { isCloud } from '@/platform/distribution/types'
import { useTelemetry } from '@/platform/telemetry'
import { useDialogService } from '@/services/dialogService'
import {
FirebaseAuthStoreError,
useFirebaseAuthStore
} from '@/stores/firebaseAuthStore'
import { useDialogService } from '@/services/dialogService'
import type { components, operations } from '@/types/comfyRegistryTypes'
import { useSubscriptionCancellationWatcher } from './useSubscriptionCancellationWatcher'
type CloudSubscriptionCheckoutResponse = {
checkout_url: string
}
export type CloudSubscriptionStatusResponse = {
is_active: boolean
subscription_id: string
renewal_date: string | null
end_date?: string | null
export type CloudSubscriptionStatusResponse = NonNullable<
operations['GetCloudSubscriptionStatus']['responses']['200']['content']['application/json']
>
type SubscriptionTier = components['schemas']['SubscriptionTier']
const TIER_TO_I18N_KEY: Record<SubscriptionTier, string> = {
STANDARD: 'standard',
CREATOR: 'creator',
PRO: 'pro',
FOUNDERS_EDITION: 'founder'
}
function useSubscriptionInternal() {
@@ -37,7 +43,7 @@ function useSubscriptionInternal() {
return subscriptionStatus.value?.is_active ?? false
})
const { reportError, accessBillingPortal } = useFirebaseAuthActions()
const dialogService = useDialogService()
const { showSubscriptionRequiredDialog } = useDialogService()
const { getAuthHeader } = useFirebaseAuthStore()
const { wrapWithErrorHandlingAsync } = useErrorHandling()
@@ -72,10 +78,17 @@ function useSubscriptionInternal() {
})
})
const formattedMonthlyPrice = computed(
() => `$${MONTHLY_SUBSCRIPTION_PRICE.toFixed(0)}`
const subscriptionTier = computed(
() => subscriptionStatus.value?.subscription_tier ?? null
)
const subscriptionTierName = computed(() => {
const tier = subscriptionTier.value
if (!tier) return ''
const key = TIER_TO_I18N_KEY[tier] ?? 'standard'
return t(`subscription.tiers.${key}.name`)
})
const buildApiUrl = (path: string) => `${getComfyApiBaseUrl()}${path}`
const fetchStatus = wrapWithErrorHandlingAsync(
@@ -102,7 +115,7 @@ function useSubscriptionInternal() {
useTelemetry()?.trackSubscription('modal_opened')
}
void dialogService.showSubscriptionRequiredDialog()
void showSubscriptionRequiredDialog()
}
const shouldWatchCancellation = (): boolean =>
@@ -227,7 +240,9 @@ function useSubscriptionInternal() {
isCancelled,
formattedRenewalDate,
formattedEndDate,
formattedMonthlyPrice,
subscriptionTier,
subscriptionTierName,
subscriptionStatus,
// Actions
subscribe,

View File

@@ -1,5 +1,4 @@
import { computed, onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { onMounted, ref } from 'vue'
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
@@ -8,30 +7,18 @@ import { useTelemetry } from '@/platform/telemetry'
import { useDialogService } from '@/services/dialogService'
import { useCommandStore } from '@/stores/commandStore'
const MONTHLY_CREDIT_BONUS_USD = 10
/**
* Composable for handling subscription panel actions and loading states
*/
export function useSubscriptionActions() {
const { t } = useI18n()
const dialogService = useDialogService()
const authActions = useFirebaseAuthActions()
const commandStore = useCommandStore()
const telemetry = useTelemetry()
const { fetchStatus, formattedRenewalDate } = useSubscription()
const { fetchStatus } = useSubscription()
const isLoadingSupport = ref(false)
const refreshTooltip = computed(() => {
const date =
formattedRenewalDate.value || t('subscription.nextBillingCycle')
return t('subscription.refreshesOn', {
monthlyCreditBonusUsd: MONTHLY_CREDIT_BONUS_USD,
date
})
})
onMounted(() => {
void handleRefresh()
})
@@ -72,7 +59,6 @@ export function useSubscriptionActions() {
return {
isLoadingSupport,
refreshTooltip,
handleAddApiCredits,
handleMessageSupport,
handleRefresh,

View File

@@ -1,58 +1,41 @@
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { formatCreditsFromCents } from '@/base/credits/comfyCredits'
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
import { formatMetronomeCurrency } from '@/utils/formatUtil'
/**
* Composable for handling subscription credit calculations and formatting
*/
export function useSubscriptionCredits() {
const authStore = useFirebaseAuthStore()
const { locale } = useI18n()
const totalCredits = computed(() => {
if (!authStore.balance?.amount_micros) return '0.00'
try {
return formatMetronomeCurrency(authStore.balance.amount_micros, 'usd')
} catch (error) {
console.error(
'[useSubscriptionCredits] Error formatting total credits:',
error
)
return '0.00'
}
})
const formatBalance = (maybeCents?: number) => {
// Backend returns cents despite the *_micros naming convention.
const cents = maybeCents ?? 0
const amount = formatCreditsFromCents({
cents,
locale: locale.value,
numberOptions: {
minimumFractionDigits: 0,
maximumFractionDigits: 0
}
})
return amount
}
const monthlyBonusCredits = computed(() => {
if (!authStore.balance?.cloud_credit_balance_micros) return '0.00'
try {
return formatMetronomeCurrency(
authStore.balance.cloud_credit_balance_micros,
'usd'
)
} catch (error) {
console.error(
'[useSubscriptionCredits] Error formatting monthly bonus credits:',
error
)
return '0.00'
}
})
const totalCredits = computed(() =>
formatBalance(authStore.balance?.amount_micros)
)
const prepaidCredits = computed(() => {
if (!authStore.balance?.prepaid_balance_micros) return '0.00'
try {
return formatMetronomeCurrency(
authStore.balance.prepaid_balance_micros,
'usd'
)
} catch (error) {
console.error(
'[useSubscriptionCredits] Error formatting prepaid credits:',
error
)
return '0.00'
}
})
const monthlyBonusCredits = computed(() =>
formatBalance(authStore.balance?.cloud_credit_balance_micros)
)
const prepaidCredits = computed(() =>
formatBalance(authStore.balance?.prepaid_balance_micros)
)
const isLoadingBalance = computed(() => authStore.isFetchingBalance)

View File

@@ -25,7 +25,15 @@ export const useSubscriptionDialog = () => {
onClose: hide
},
dialogComponentProps: {
style: 'width: 700px;'
style: 'width: min(1200px, 95vw); max-height: 90vh;',
pt: {
root: {
class: '!rounded-[32px] overflow-visible'
},
content: {
class: '!p-0 bg-transparent'
}
}
}
})
}

View File

@@ -37,4 +37,7 @@ export type RemoteConfig = {
model_upload_button_enabled?: boolean
asset_update_options_enabled?: boolean
private_models_enabled?: boolean
subscription_tiers_enabled?: boolean
stripe_publishable_key?: string
stripe_pricing_table_id?: string
}

View File

@@ -3,6 +3,7 @@ import type { Component } from 'vue'
import { useI18n } from 'vue-i18n'
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import { isCloud } from '@/platform/distribution/types'
import type { SettingTreeNode } from '@/platform/settings/settingStore'
import { useSettingStore } from '@/platform/settings/settingStore'
@@ -11,6 +12,7 @@ import { isElectron } from '@/utils/envUtil'
import { normalizeI18nKey } from '@/utils/formatUtil'
import { buildTree } from '@/utils/treeUtil'
import { useVueFeatureFlags } from '@/composables/useVueFeatureFlags'
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
interface SettingPanelItem {
node: SettingTreeNode
@@ -33,6 +35,8 @@ export function useSettingUI(
const activeCategory = ref<SettingTreeNode | null>(null)
const { shouldRenderVueNodes } = useVueFeatureFlags()
const { isActiveSubscription } = useSubscription()
const { flags } = useFeatureFlags()
const settingRoot = computed<SettingTreeNode>(() => {
const root = buildTree(
@@ -81,7 +85,7 @@ export function useSettingUI(
children: []
},
component: defineAsyncComponent(
() => import('@/components/dialog/content/setting/CreditsPanel.vue')
() => import('@/components/dialog/content/setting/LegacyCreditsPanel.vue')
)
}
@@ -102,6 +106,12 @@ export function useSettingUI(
)
}
const shouldShowPlanCreditsPanel = computed(() => {
if (!subscriptionPanel) return false
if (!flags.subscriptionTiersEnabled) return true
return isActiveSubscription.value
})
const userPanel: SettingPanelItem = {
node: {
key: 'user',
@@ -154,9 +164,7 @@ export function useSettingUI(
keybindingPanel,
extensionPanel,
...(isElectron() ? [serverConfigPanel] : []),
...(isCloud &&
window.__CONFIG__?.subscription_required &&
subscriptionPanel
...(shouldShowPlanCreditsPanel.value && subscriptionPanel
? [subscriptionPanel]
: [])
].filter((panel) => panel.component)
@@ -191,8 +199,7 @@ export function useSettingUI(
children: [
userPanel.node,
...(isLoggedIn.value &&
isCloud &&
window.__CONFIG__?.subscription_required &&
shouldShowPlanCreditsPanel.value &&
subscriptionPanel
? [subscriptionPanel.node]
: []),

View File

@@ -1,6 +1,6 @@
import { until } from '@vueuse/core'
import { defineStore } from 'pinia'
import { compare } from 'semver'
import { compare, valid } from 'semver'
import { computed, ref } from 'vue'
import { isCloud } from '@/platform/distribution/types'
@@ -24,10 +24,12 @@ export const useReleaseStore = defineStore('release', () => {
const systemStatsStore = useSystemStatsStore()
const settingStore = useSettingStore()
// Current ComfyUI version
const currentComfyUIVersion = computed(
() => systemStatsStore?.systemStats?.system?.comfyui_version ?? ''
)
const currentVersion = computed(() => {
if (isCloud) {
return systemStatsStore?.systemStats?.system?.cloud_version ?? ''
}
return systemStatsStore?.systemStats?.system?.comfyui_version ?? ''
})
// Release data from settings
const locale = computed(() => settingStore.get('Comfy.Locale'))
@@ -55,22 +57,33 @@ export const useReleaseStore = defineStore('release', () => {
// Helper constants
const THREE_DAYS_MS = 3 * 24 * 60 * 60 * 1000 // 3 days
const compareVersions = (
releaseVersion: string,
currentVer: string
): number => {
if (valid(releaseVersion) && valid(currentVer)) {
return compare(releaseVersion, currentVer)
}
// Non-semver (e.g. git hash): assume different = newer
return releaseVersion === currentVer ? 0 : 1
}
// New version available?
const isNewVersionAvailable = computed(
() =>
!!recentRelease.value &&
compare(
compareVersions(
recentRelease.value.version,
currentComfyUIVersion.value || '0.0.0'
currentVersion.value || '0.0.0'
) > 0
)
const isLatestVersion = computed(
() =>
!!recentRelease.value &&
compare(
compareVersions(
recentRelease.value.version,
currentComfyUIVersion.value || '0.0.0'
currentVersion.value || '0.0.0'
) === 0
)
@@ -158,23 +171,25 @@ export const useReleaseStore = defineStore('release', () => {
return true
})
// Show "What's New" popup
const shouldShowPopup = computed(() => {
// Only show on desktop version
if (!isElectron() || isCloud) {
if (!isElectron() && !isCloud) {
return false
}
// Skip if notifications are disabled
if (!showVersionUpdates.value) {
return false
}
if (!isLatestVersion.value) {
if (!recentRelease.value) {
return false
}
// Skip version check if current version isn't semver (e.g. git hash)
const skipVersionCheck = !valid(currentVersion.value)
if (!skipVersionCheck && !isLatestVersion.value) {
return false
}
// Hide if already seen
if (
releaseVersion.value === recentRelease.value.version &&
releaseStatus.value === "what's new seen"
@@ -225,8 +240,7 @@ export const useReleaseStore = defineStore('release', () => {
return
}
// Skip fetching if notifications are disabled
if (!showVersionUpdates.value) {
if (!isCloud && !showVersionUpdates.value) {
return
}
@@ -248,8 +262,8 @@ export const useReleaseStore = defineStore('release', () => {
}
const fetchedReleases = await releaseService.getReleases({
project: 'comfyui',
current_version: currentComfyUIVersion.value,
project: isCloud ? 'cloud' : 'comfyui',
current_version: currentVersion.value,
form_factor: systemStatsStore.getFormFactor(),
locale: stringToLocale(locale.value)
})

View File

@@ -33,9 +33,11 @@ export function useTemplateUrlLoader() {
/**
* Validates parameter format to prevent path traversal and injection attacks
* Allows: letters, numbers, underscores, hyphens, and dots (for version numbers)
* Blocks: path separators (/, \), special chars that could enable injection
*/
const isValidParameter = (param: string): boolean => {
return /^[a-zA-Z0-9_-]+$/.test(param)
return /^[a-zA-Z0-9_.-]+$/.test(param)
}
/**

View File

@@ -39,7 +39,15 @@
</div>
<div v-if="isSubgraphNode" class="icon-[comfy--workflow] size-4" />
<div v-if="isApiNode" class="icon-[lucide--dollar-sign] size-4" />
<div
v-if="isApiNode"
:class="
flags.subscriptionTiersEnabled
? 'icon-[lucide--component]'
: 'icon-[lucide--dollar-sign]'
"
class="size-4"
/>
<!-- Node Title -->
<div
@@ -76,13 +84,16 @@
v-tooltip.top="enterSubgraphTooltipConfig"
type="transparent"
data-testid="subgraph-enter-button"
class="size-5"
class="ml-2 text-node-component-header h-5"
@click.stop="handleEnterSubgraph"
@dblclick.stop
>
<i
class="icon-[lucide--picture-in-picture] size-5 text-node-component-header-icon"
></i>
<div
class="min-w-max rounded-sm bg-node-component-surface px-1 py-0.5 text-xs flex items-center gap-1"
>
{{ $t('g.edit') }}
<i class="icon-[lucide--scaling] size-5"></i>
</div>
</IconButton>
</div>
</div>
@@ -96,6 +107,7 @@ import IconButton from '@/components/button/IconButton.vue'
import EditableText from '@/components/common/EditableText.vue'
import type { VueNodeData } from '@/composables/graph/useGraphNodeManager'
import { useErrorHandling } from '@/composables/useErrorHandling'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import { st } from '@/i18n'
import { LGraphEventMode } from '@/lib/litegraph/src/litegraph'
import { useSettingStore } from '@/platform/settings/settingStore'
@@ -126,6 +138,8 @@ const emit = defineEmits<{
'enter-subgraph': []
}>()
const { flags } = useFeatureFlags()
// Error boundary implementation
const renderError = ref<string | null>(null)
const { toastErrorHandler } = useErrorHandling()

View File

@@ -386,11 +386,12 @@ export const useDialogService = () => {
return dialogStore.showDialog({
key: 'top-up-credits',
component: TopUpCreditsDialogContent,
headerComponent: ComfyOrgHeader,
props: options,
dialogComponentProps: {
headless: true,
pt: {
header: { class: 'p-3!' }
header: { class: 'p-0! hidden' },
content: { class: 'p-0! m-0!' }
}
}
})

View File

@@ -1,6 +1,7 @@
import { useAsyncState } from '@vueuse/core'
import { defineStore } from 'pinia'
import { isCloud } from '@/platform/distribution/types'
import type { SystemStats } from '@/schemas/apiSchema'
import { api } from '@/scripts/api'
import { isElectron } from '@/utils/envUtil'
@@ -30,6 +31,10 @@ export const useSystemStatsStore = defineStore('systemStats', () => {
)
function getFormFactor(): string {
if (isCloud) {
return 'cloud'
}
if (!systemStats.value?.system?.os) {
return 'other'
}

9
src/vite-env.d.ts vendored
View File

@@ -16,6 +16,15 @@ declare global {
interface Window {
__COMFYUI_FRONTEND_VERSION__: string
}
interface ImportMetaEnv {
readonly VITE_STRIPE_PUBLISHABLE_KEY?: string
readonly VITE_STRIPE_PRICING_TABLE_ID?: string
}
interface ImportMeta {
readonly env: ImportMetaEnv
}
}
export {}

View File

@@ -0,0 +1,46 @@
import { describe, expect, test } from 'vitest'
import {
CREDITS_PER_USD,
COMFY_CREDIT_RATE_CENTS,
centsToCredits,
creditsToCents,
creditsToUsd,
formatCredits,
formatCreditsFromCents,
formatCreditsFromUsd,
formatUsd,
formatUsdFromCents,
usdToCents,
usdToCredits
} from '@/base/credits/comfyCredits'
describe('comfyCredits helpers', () => {
test('exposes the fixed conversion rate', () => {
expect(CREDITS_PER_USD).toBe(211)
expect(COMFY_CREDIT_RATE_CENTS).toBeCloseTo(2.11) // credits per cent
})
test('converts between USD and cents', () => {
expect(usdToCents(1.23)).toBe(123)
expect(formatUsdFromCents({ cents: 123, locale: 'en-US' })).toBe('1.23')
})
test('converts cents to credits and back', () => {
expect(centsToCredits(100)).toBe(211) // 100 cents = 211 credits
expect(creditsToCents(211)).toBe(100) // 211 credits = 100 cents
})
test('converts USD to credits and back', () => {
expect(usdToCredits(1)).toBe(211) // 1 USD = 211 credits
expect(creditsToUsd(211)).toBe(1) // 211 credits = 1 USD
})
test('formats credits and USD values using en-US locale', () => {
const locale = 'en-US'
expect(formatCredits({ value: 1234.567, locale })).toBe('1,234.57')
expect(formatCreditsFromCents({ cents: 100, locale })).toBe('211.00')
expect(formatCreditsFromUsd({ usd: 1, locale })).toBe('211.00')
expect(formatUsd({ value: 4.2, locale })).toBe('4.20')
})
})

View File

@@ -0,0 +1,48 @@
import { mount } from '@vue/test-utils'
import { createI18n } from 'vue-i18n'
import { describe, expect, it } from 'vitest'
import CreditTopUpOption from '@/components/dialog/content/credit/CreditTopUpOption.vue'
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: { en: {} }
})
const mountOption = (
props?: Partial<{ credits: number; description: string; selected: boolean }>
) =>
mount(CreditTopUpOption, {
props: {
credits: 1000,
description: '~100 videos*',
selected: false,
...props
},
global: {
plugins: [i18n]
}
})
describe('CreditTopUpOption', () => {
it('renders credit amount and description', () => {
const wrapper = mountOption({ credits: 5000, description: '~500 videos*' })
expect(wrapper.text()).toContain('5,000')
expect(wrapper.text()).toContain('~500 videos*')
})
it('applies unselected styling when not selected', () => {
const wrapper = mountOption({ selected: false })
expect(wrapper.find('div').classes()).toContain(
'bg-component-node-disabled'
)
expect(wrapper.find('div').classes()).toContain('border-transparent')
})
it('emits select event when clicked', async () => {
const wrapper = mountOption()
await wrapper.find('div').trigger('click')
expect(wrapper.emitted('select')).toHaveLength(1)
})
})

View File

@@ -17,6 +17,14 @@ vi.mock('@/stores/workspace/colorPaletteStore', () => ({
})
}))
vi.mock('@/composables/useFeatureFlags', () => ({
useFeatureFlags: () => ({
flags: {
subscriptionTiersEnabled: false // Test legacy badge behavior
}
})
}))
const { updateSubgraphCredits } = usePriceBadge()
const mockNode = new LGraphNode('mock node')

View File

@@ -577,32 +577,32 @@ describe('useNodePricing', () => {
{
rendering_speed: 'Quality',
character_image: false,
expected: '$0.09/Run'
expected: '$0.13/Run'
},
{
rendering_speed: 'Quality',
character_image: true,
expected: '$0.20/Run'
expected: '$0.29/Run'
},
{
rendering_speed: 'Default',
character_image: false,
expected: '$0.06/Run'
expected: '$0.09/Run'
},
{
rendering_speed: 'Default',
character_image: true,
expected: '$0.15/Run'
expected: '$0.21/Run'
},
{
rendering_speed: 'Turbo',
character_image: false,
expected: '$0.03/Run'
expected: '$0.04/Run'
},
{
rendering_speed: 'Turbo',
character_image: true,
expected: '$0.10/Run'
expected: '$0.14/Run'
}
]
@@ -623,7 +623,7 @@ describe('useNodePricing', () => {
const price = getNodeDisplayPrice(node)
expect(price).toBe(
'$0.03-0.08 x num_images/Run (varies with rendering speed & num_images)'
'$0.04-0.11 x num_images/Run (varies with rendering speed & num_images)'
)
})
@@ -635,7 +635,7 @@ describe('useNodePricing', () => {
])
const price = getNodeDisplayPrice(node)
expect(price).toBe('$0.27/Run') // 0.09 * 3
expect(price).toBe('$0.39/Run') // 0.09 * 3 * 1.43
})
it('should multiply price by num_images for Turbo rendering speed', () => {
@@ -646,7 +646,7 @@ describe('useNodePricing', () => {
])
const price = getNodeDisplayPrice(node)
expect(price).toBe('$0.15/Run') // 0.03 * 5
expect(price).toBe('$0.21/Run') // 0.03 * 5 * 1.43
})
})
@@ -770,7 +770,7 @@ describe('useNodePricing', () => {
])
const price = getNodeDisplayPrice(node)
expect(price).toBe('$2.19/Run')
expect(price).toBe('$3.13/Run')
})
it('should return $6.37 for ray-2 4K 5s', () => {
@@ -782,7 +782,7 @@ describe('useNodePricing', () => {
])
const price = getNodeDisplayPrice(node)
expect(price).toBe('$6.37/Run')
expect(price).toBe('$9.11/Run')
})
it('should return $0.35 for ray-1-6 model', () => {
@@ -794,7 +794,7 @@ describe('useNodePricing', () => {
])
const price = getNodeDisplayPrice(node)
expect(price).toBe('$0.35/Run')
expect(price).toBe('$0.50/Run')
})
it('should return range when widgets are missing', () => {
@@ -803,7 +803,7 @@ describe('useNodePricing', () => {
const price = getNodeDisplayPrice(node)
expect(price).toBe(
'$0.14-11.47/Run (varies with model, resolution & duration)'
'$0.20-16.40/Run (varies with model, resolution & duration)'
)
})
})
@@ -1192,7 +1192,7 @@ describe('useNodePricing', () => {
])
const price = getNodeDisplayPrice(node)
expect(price).toBe('$0.18/Run') // 0.06 * 3
expect(price).toBe('$0.26/Run') // 0.06 * 3 * 1.43
})
it('should calculate dynamic pricing for IdeogramV2 based on num_images value', () => {
@@ -1202,7 +1202,7 @@ describe('useNodePricing', () => {
])
const price = getNodeDisplayPrice(node)
expect(price).toBe('$0.32/Run') // 0.08 * 4
expect(price).toBe('$0.46/Run') // 0.08 * 4 * 1.43
})
it('should fall back to static display when num_images widget is missing for IdeogramV1', () => {
@@ -1210,7 +1210,7 @@ describe('useNodePricing', () => {
const node = createMockNode('IdeogramV1', [])
const price = getNodeDisplayPrice(node)
expect(price).toBe('$0.02-0.06 x num_images/Run')
expect(price).toBe('$0.03-0.09 x num_images/Run')
})
it('should fall back to static display when num_images widget is missing for IdeogramV2', () => {
@@ -1218,7 +1218,7 @@ describe('useNodePricing', () => {
const node = createMockNode('IdeogramV2', [])
const price = getNodeDisplayPrice(node)
expect(price).toBe('$0.05-0.08 x num_images/Run')
expect(price).toBe('$0.07-0.11 x num_images/Run')
})
it('should handle edge case when num_images value is 1 for IdeogramV1', () => {
@@ -1228,7 +1228,7 @@ describe('useNodePricing', () => {
])
const price = getNodeDisplayPrice(node)
expect(price).toBe('$0.06/Run') // 0.06 * 1 (turbo=false by default)
expect(price).toBe('$0.09/Run') // 0.06 * 1 * 1.43 (turbo=false by default)
})
})
@@ -1435,7 +1435,7 @@ describe('useNodePricing', () => {
const node = createMockNode('RunwayTextToImageNode')
const price = getNodeDisplayPrice(node)
expect(price).toBe('$0.08/Run')
expect(price).toBe('$0.11/Run')
})
it('should calculate dynamic pricing for RunwayImageToVideoNodeGen3a', () => {
@@ -1445,7 +1445,7 @@ describe('useNodePricing', () => {
])
const price = getNodeDisplayPrice(node)
expect(price).toBe('$0.50/Run') // 0.05 * 10
expect(price).toBe('$0.71/Run') // 0.05 * 10 * 1.43
})
it('should return fallback for RunwayImageToVideoNodeGen3a without duration', () => {
@@ -1453,7 +1453,7 @@ describe('useNodePricing', () => {
const node = createMockNode('RunwayImageToVideoNodeGen3a', [])
const price = getNodeDisplayPrice(node)
expect(price).toBe('$0.05/second')
expect(price).toBe('$0.0715/second')
})
it('should handle zero duration for RunwayImageToVideoNodeGen3a', () => {
@@ -1473,7 +1473,7 @@ describe('useNodePricing', () => {
])
const price = getNodeDisplayPrice(node)
expect(price).toBe('$0.25/Run') // Falls back to 5 seconds: 0.05 * 5
expect(price).toBe('$0.36/Run') // Falls back to 5 seconds: 0.05 * 5 * 1.43
})
})
@@ -1810,8 +1810,8 @@ describe('useNodePricing', () => {
// Test edge cases
const testCases = [
{ duration: 0, expected: '$0.00/Run' }, // Now correctly handles 0 duration
{ duration: 1, expected: '$0.05/Run' },
{ duration: 30, expected: '$1.50/Run' }
{ duration: 1, expected: '$0.07/Run' },
{ duration: 30, expected: '$2.15/Run' }
]
testCases.forEach(({ duration, expected }) => {
@@ -1828,7 +1828,7 @@ describe('useNodePricing', () => {
{ name: 'duration', value: 'invalid-string' }
])
// When Number('invalid-string') returns NaN, it falls back to 5 seconds
expect(getNodeDisplayPrice(node)).toBe('$0.25/Run')
expect(getNodeDisplayPrice(node)).toBe('$0.36/Run')
})
it('should handle missing duration widget gracefully', () => {
@@ -1841,7 +1841,7 @@ describe('useNodePricing', () => {
nodes.forEach((nodeType) => {
const node = createMockNode(nodeType, [])
expect(getNodeDisplayPrice(node)).toBe('$0.05/second')
expect(getNodeDisplayPrice(node)).toBe('$0.0715/second')
})
})
})

View File

@@ -1,31 +1,47 @@
import { createTestingPinia } from '@pinia/testing'
import { mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { computed, ref } from 'vue'
import { createI18n } from 'vue-i18n'
import SubscriptionPanel from '@/platform/cloud/subscription/components/SubscriptionPanel.vue'
// Mock composables
// Mock state refs that can be modified between tests
const mockIsActiveSubscription = ref(false)
const mockIsCancelled = ref(false)
const mockSubscriptionTier = ref<
'STANDARD' | 'CREATOR' | 'PRO' | 'FOUNDERS_EDITION' | null
>('CREATOR')
const TIER_TO_NAME: Record<string, string> = {
STANDARD: 'Standard',
CREATOR: 'Creator',
PRO: 'Pro',
FOUNDERS_EDITION: "Founder's Edition"
}
// Mock composables - using computed to match composable return types
const mockSubscriptionData = {
isActiveSubscription: false,
isCancelled: false,
formattedRenewalDate: '2024-12-31',
formattedEndDate: '2024-12-31',
formattedMonthlyPrice: '$9.99',
manageSubscription: vi.fn(),
isActiveSubscription: computed(() => mockIsActiveSubscription.value),
isCancelled: computed(() => mockIsCancelled.value),
formattedRenewalDate: computed(() => '2024-12-31'),
formattedEndDate: computed(() => '2024-12-31'),
subscriptionTier: computed(() => mockSubscriptionTier.value),
subscriptionTierName: computed(() =>
mockSubscriptionTier.value ? TIER_TO_NAME[mockSubscriptionTier.value] : ''
),
handleInvoiceHistory: vi.fn()
}
const mockCreditsData = {
totalCredits: '10.00',
monthlyBonusCredits: '5.00',
prepaidCredits: '5.00',
totalCredits: '10.00 Credits',
monthlyBonusCredits: '5.00 Credits',
prepaidCredits: '5.00 Credits',
isLoadingBalance: false
}
const mockActionsData = {
isLoadingSupport: false,
refreshTooltip: 'Refreshes on 2024-12-31',
handleAddApiCredits: vi.fn(),
handleMessageSupport: vi.fn(),
handleRefresh: vi.fn(),
@@ -50,6 +66,15 @@ vi.mock(
})
)
vi.mock(
'@/platform/cloud/subscription/composables/useSubscriptionDialog',
() => ({
useSubscriptionDialog: () => ({
show: vi.fn()
})
})
)
// Create i18n instance for testing
const i18n = createI18n({
legacy: false,
@@ -58,12 +83,15 @@ const i18n = createI18n({
en: {
subscription: {
title: 'Subscription',
titleUnsubscribed: 'Subscribe',
perMonth: '/ month',
subscribeNow: 'Subscribe Now',
manageSubscription: 'Manage Subscription',
partnerNodesBalance: 'Partner Nodes Balance',
partnerNodesDescription: 'Credits for partner nodes',
totalCredits: 'Total Credits',
creditsRemainingThisMonth: 'Credits remaining this month',
creditsYouveAdded: "Credits you've added",
monthlyBonusDescription: 'Monthly bonus',
prepaidDescription: 'Prepaid credits',
monthlyCreditsRollover: 'Monthly credits rollover info',
@@ -71,11 +99,67 @@ const i18n = createI18n({
viewUsageHistory: 'View Usage History',
addCredits: 'Add Credits',
yourPlanIncludes: 'Your plan includes',
viewMoreDetailsPlans: 'View more details about plans & pricing',
learnMore: 'Learn More',
messageSupport: 'Message Support',
invoiceHistory: 'Invoice History',
partnerNodesCredits: 'Partner nodes pricing',
renewsDate: 'Renews {date}',
expiresDate: 'Expires {date}'
expiresDate: 'Expires {date}',
tiers: {
founder: {
name: "Founder's Edition",
price: '20.00',
benefits: {
monthlyCredits: '5,460',
monthlyCreditsLabel: 'monthly credits',
maxDuration: '30 min',
maxDurationLabel: 'max duration of each workflow run',
gpuLabel: 'RTX 6000 Pro (96GB VRAM)',
addCreditsLabel: 'Add more credits whenever',
customLoRAsLabel: 'Import your own LoRAs'
}
},
standard: {
name: 'Standard',
price: '20.00',
benefits: {
monthlyCredits: '4,200',
monthlyCreditsLabel: 'monthly credits',
maxDuration: '30 min',
maxDurationLabel: 'max duration of each workflow run',
gpuLabel: 'RTX 6000 Pro (96GB VRAM)',
addCreditsLabel: 'Add more credits whenever',
customLoRAsLabel: 'Import your own LoRAs'
}
},
creator: {
name: 'Creator',
price: '35.00',
benefits: {
monthlyCredits: '7,400',
monthlyCreditsLabel: 'monthly credits',
maxDuration: '30 min',
maxDurationLabel: 'max duration of each workflow run',
gpuLabel: 'RTX 6000 Pro (96GB VRAM)',
addCreditsLabel: 'Add more credits whenever',
customLoRAsLabel: 'Import your own LoRAs'
}
},
pro: {
name: 'Pro',
price: '100.00',
benefits: {
monthlyCredits: '21,100',
monthlyCreditsLabel: 'monthly credits',
maxDuration: '1 hr',
maxDurationLabel: 'max duration of each workflow run',
gpuLabel: 'RTX 6000 Pro (96GB VRAM)',
addCreditsLabel: 'Add more credits whenever',
customLoRAsLabel: 'Import your own LoRAs'
}
}
}
}
}
}
@@ -116,18 +200,22 @@ function createWrapper(overrides = {}) {
describe('SubscriptionPanel', () => {
beforeEach(() => {
vi.clearAllMocks()
// Reset mock state
mockIsActiveSubscription.value = false
mockIsCancelled.value = false
mockSubscriptionTier.value = 'CREATOR'
})
describe('subscription state functionality', () => {
it('shows correct UI for active subscription', () => {
mockSubscriptionData.isActiveSubscription = true
mockIsActiveSubscription.value = true
const wrapper = createWrapper()
expect(wrapper.text()).toContain('Manage Subscription')
expect(wrapper.text()).toContain('Add Credits')
})
it('shows correct UI for inactive subscription', () => {
mockSubscriptionData.isActiveSubscription = false
mockIsActiveSubscription.value = false
const wrapper = createWrapper()
expect(wrapper.findComponent({ name: 'SubscribeButton' }).exists()).toBe(
true
@@ -137,25 +225,39 @@ describe('SubscriptionPanel', () => {
})
it('shows renewal date for active non-cancelled subscription', () => {
mockSubscriptionData.isActiveSubscription = true
mockSubscriptionData.isCancelled = false
mockIsActiveSubscription.value = true
mockIsCancelled.value = false
const wrapper = createWrapper()
expect(wrapper.text()).toContain('Renews 2024-12-31')
})
it('shows expiry date for cancelled subscription', () => {
mockSubscriptionData.isActiveSubscription = true
mockSubscriptionData.isCancelled = true
mockIsActiveSubscription.value = true
mockIsCancelled.value = true
const wrapper = createWrapper()
expect(wrapper.text()).toContain('Expires 2024-12-31')
})
it('displays FOUNDERS_EDITION tier correctly', () => {
mockSubscriptionTier.value = 'FOUNDERS_EDITION'
const wrapper = createWrapper()
expect(wrapper.text()).toContain("Founder's Edition")
expect(wrapper.text()).toContain('5,460')
})
it('displays CREATOR tier correctly', () => {
mockSubscriptionTier.value = 'CREATOR'
const wrapper = createWrapper()
expect(wrapper.text()).toContain('Creator')
expect(wrapper.text()).toContain('7,400')
})
})
describe('credit display functionality', () => {
it('displays dynamic credit values correctly', () => {
const wrapper = createWrapper()
expect(wrapper.text()).toContain('$10.00') // totalCredits
expect(wrapper.text()).toContain('$5.00') // both monthlyBonus and prepaid
expect(wrapper.text()).toContain('10.00 Credits')
expect(wrapper.text()).toContain('5.00 Credits')
})
it('shows loading skeleton when fetching balance', () => {

View File

@@ -7,23 +7,6 @@ const mockFetchBalance = vi.fn()
const mockFetchStatus = vi.fn()
const mockShowTopUpCreditsDialog = vi.fn()
const mockExecute = vi.fn()
const mockT = vi.fn((key: string, values?: any) => {
if (key === 'subscription.nextBillingCycle') return 'next billing cycle'
if (key === 'subscription.refreshesOn') {
return `Refreshes to $${values?.monthlyCreditBonusUsd} on ${values?.date}`
}
return key
})
vi.mock('vue-i18n', async (importOriginal) => {
const actual = await importOriginal<typeof import('vue-i18n')>()
return {
...actual,
useI18n: () => ({
t: mockT
})
}
})
vi.mock('@/composables/auth/useFirebaseAuthActions', () => ({
useFirebaseAuthActions: () => ({
@@ -31,12 +14,9 @@ vi.mock('@/composables/auth/useFirebaseAuthActions', () => ({
})
}))
const mockFormattedRenewalDate = { value: '2024-12-31' }
vi.mock('@/platform/cloud/subscription/composables/useSubscription', () => ({
useSubscription: () => ({
fetchStatus: mockFetchStatus,
formattedRenewalDate: mockFormattedRenewalDate
fetchStatus: mockFetchStatus
})
}))
@@ -62,23 +42,6 @@ Object.defineProperty(window, 'open', {
describe('useSubscriptionActions', () => {
beforeEach(() => {
vi.clearAllMocks()
mockFormattedRenewalDate.value = '2024-12-31'
})
describe('refreshTooltip', () => {
it('should format tooltip with renewal date', () => {
const { refreshTooltip } = useSubscriptionActions()
expect(refreshTooltip.value).toBe('Refreshes to $10 on 2024-12-31')
})
it('should use fallback text when no renewal date', () => {
mockFormattedRenewalDate.value = ''
const { refreshTooltip } = useSubscriptionActions()
expect(refreshTooltip.value).toBe(
'Refreshes to $10 on next billing cycle'
)
expect(mockT).toHaveBeenCalledWith('subscription.nextBillingCycle')
})
})
describe('handleAddApiCredits', () => {

View File

@@ -1,8 +1,27 @@
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import * as comfyCredits from '@/base/credits/comfyCredits'
import { useSubscriptionCredits } from '@/platform/cloud/subscription/composables/useSubscriptionCredits'
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
import type { operations } from '@/types/comfyRegistryTypes'
type GetCustomerBalanceResponse =
operations['GetCustomerBalance']['responses']['200']['content']['application/json']
vi.mock(
'vue-i18n',
async (importOriginal: () => Promise<typeof import('vue-i18n')>) => {
const actual = await importOriginal()
return {
...actual,
useI18n: () => ({
t: () => 'Credits',
locale: { value: 'en-US' }
})
}
}
)
// Mock Firebase Auth and related modules
vi.mock('vuefire', () => ({
@@ -55,14 +74,6 @@ vi.mock('@/stores/apiKeyAuthStore', () => ({
})
}))
// Mock formatMetronomeCurrency
vi.mock('@/utils/formatUtil', () => ({
formatMetronomeCurrency: vi.fn((micros: number) => {
// Simple mock that converts micros to dollars
return (micros / 1000000).toFixed(2)
})
}))
describe('useSubscriptionCredits', () => {
let authStore: ReturnType<typeof useFirebaseAuthStore>
@@ -73,63 +84,66 @@ describe('useSubscriptionCredits', () => {
})
describe('totalCredits', () => {
it('should return "0.00" when balance is null', () => {
it('should return "0" when balance is null', () => {
authStore.balance = null
const { totalCredits } = useSubscriptionCredits()
expect(totalCredits.value).toBe('0.00')
expect(totalCredits.value).toBe('0')
})
it('should return "0.00" when amount_micros is missing', () => {
authStore.balance = {} as any
it('should return "0" when amount_micros is missing', () => {
authStore.balance = {} as GetCustomerBalanceResponse
const { totalCredits } = useSubscriptionCredits()
expect(totalCredits.value).toBe('0.00')
expect(totalCredits.value).toBe('0')
})
it('should format amount_micros correctly', () => {
authStore.balance = { amount_micros: 5000000 } as any
authStore.balance = { amount_micros: 100 } as GetCustomerBalanceResponse
const { totalCredits } = useSubscriptionCredits()
expect(totalCredits.value).toBe('5.00')
expect(totalCredits.value).toBe('211')
})
it('should handle formatting errors gracefully', async () => {
const mockFormatMetronomeCurrency = vi.mocked(
await import('@/utils/formatUtil')
).formatMetronomeCurrency
mockFormatMetronomeCurrency.mockImplementationOnce(() => {
it('should handle formatting errors by throwing', async () => {
const formatSpy = vi.spyOn(comfyCredits, 'formatCreditsFromCents')
formatSpy.mockImplementationOnce(() => {
throw new Error('Formatting error')
})
authStore.balance = { amount_micros: 5000000 } as any
authStore.balance = { amount_micros: 100 } as GetCustomerBalanceResponse
const { totalCredits } = useSubscriptionCredits()
expect(totalCredits.value).toBe('0.00')
expect(() => totalCredits.value).toThrow('Formatting error')
formatSpy.mockRestore()
})
})
describe('monthlyBonusCredits', () => {
it('should return "0.00" when cloud_credit_balance_micros is missing', () => {
authStore.balance = {} as any
it('should return "0" when cloud_credit_balance_micros is missing', () => {
authStore.balance = {} as GetCustomerBalanceResponse
const { monthlyBonusCredits } = useSubscriptionCredits()
expect(monthlyBonusCredits.value).toBe('0.00')
expect(monthlyBonusCredits.value).toBe('0')
})
it('should format cloud_credit_balance_micros correctly', () => {
authStore.balance = { cloud_credit_balance_micros: 2500000 } as any
authStore.balance = {
cloud_credit_balance_micros: 200
} as GetCustomerBalanceResponse
const { monthlyBonusCredits } = useSubscriptionCredits()
expect(monthlyBonusCredits.value).toBe('2.50')
expect(monthlyBonusCredits.value).toBe('422')
})
})
describe('prepaidCredits', () => {
it('should return "0.00" when prepaid_balance_micros is missing', () => {
authStore.balance = {} as any
it('should return "0" when prepaid_balance_micros is missing', () => {
authStore.balance = {} as GetCustomerBalanceResponse
const { prepaidCredits } = useSubscriptionCredits()
expect(prepaidCredits.value).toBe('0.00')
expect(prepaidCredits.value).toBe('0')
})
it('should format prepaid_balance_micros correctly', () => {
authStore.balance = { prepaid_balance_micros: 7500000 } as any
authStore.balance = {
prepaid_balance_micros: 300
} as GetCustomerBalanceResponse
const { prepaidCredits } = useSubscriptionCredits()
expect(prepaidCredits.value).toBe('7.50')
expect(prepaidCredits.value).toBe('633')
})
})

View File

@@ -152,10 +152,28 @@ describe('useSubscription', () => {
expect(formattedRenewalDate.value).toBe('')
})
it('should format monthly price correctly', () => {
const { formattedMonthlyPrice } = useSubscription()
it('should return subscription tier from status', async () => {
vi.mocked(global.fetch).mockResolvedValue({
ok: true,
json: async () => ({
is_active: true,
subscription_id: 'sub_123',
subscription_tier: 'CREATOR',
renewal_date: '2025-11-16T12:00:00Z'
})
} as Response)
expect(formattedMonthlyPrice.value).toBe('$20')
mockIsLoggedIn.value = true
const { subscriptionTier, fetchStatus } = useSubscription()
await fetchStatus()
expect(subscriptionTier.value).toBe('CREATOR')
})
it('should return null when subscription tier is not available', () => {
const { subscriptionTier } = useSubscription()
expect(subscriptionTier.value).toBeNull()
})
})

View File

@@ -187,7 +187,8 @@ describe('useTemplateUrlLoader', () => {
'flux_simple',
'flux-kontext-dev',
'template123',
'My_Template-2'
'My_Template-2',
'templates-1_click_multiple_scene_angles-v1.0' // template with version number containing dot
]
for (const template of validTemplates) {

View File

@@ -1,5 +1,5 @@
import { createPinia, setActivePinia } from 'pinia'
import { compare as semverCompare } from 'semver'
import { compare, valid } from 'semver'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useReleaseStore } from '@/platform/updates/common/releaseStore'
@@ -7,6 +7,7 @@ import { useReleaseStore } from '@/platform/updates/common/releaseStore'
// Mock the dependencies
vi.mock('semver')
vi.mock('@/utils/envUtil')
vi.mock('@/platform/distribution/types', () => ({ isCloud: false }))
vi.mock('@/platform/updates/common/releaseService')
vi.mock('@/platform/settings/settingStore')
vi.mock('@/stores/systemStatsStore')
@@ -72,6 +73,7 @@ describe('useReleaseStore', () => {
vi.mocked(useSettingStore).mockReturnValue(mockSettingStore)
vi.mocked(useSystemStatsStore).mockReturnValue(mockSystemStatsStore)
vi.mocked(isElectron).mockReturnValue(true)
vi.mocked(valid).mockReturnValue('1.0.0')
// Default showVersionUpdates to true
mockSettingStore.get.mockImplementation((key: string) => {
@@ -116,14 +118,14 @@ describe('useReleaseStore', () => {
})
it('should show update button (shouldShowUpdateButton)', () => {
vi.mocked(semverCompare).mockReturnValue(1) // newer version available
vi.mocked(compare).mockReturnValue(1) // newer version available
store.releases = [mockRelease]
expect(store.shouldShowUpdateButton).toBe(true)
})
it('should not show update button when no new version', () => {
vi.mocked(semverCompare).mockReturnValue(-1) // current version is newer
vi.mocked(compare).mockReturnValue(-1) // current version is newer
store.releases = [mockRelease]
expect(store.shouldShowUpdateButton).toBe(false)
@@ -144,14 +146,14 @@ describe('useReleaseStore', () => {
})
it('should show toast for medium/high attention releases', () => {
vi.mocked(semverCompare).mockReturnValue(1)
vi.mocked(compare).mockReturnValue(1)
store.releases = [mockRelease]
expect(store.shouldShowToast).toBe(true)
})
it('should not show toast for low attention releases', () => {
vi.mocked(semverCompare).mockReturnValue(1)
vi.mocked(compare).mockReturnValue(1)
const lowAttentionRelease = {
...mockRelease,
@@ -164,7 +166,7 @@ describe('useReleaseStore', () => {
})
it('should show red dot for new versions', () => {
vi.mocked(semverCompare).mockReturnValue(1)
vi.mocked(compare).mockReturnValue(1)
expect(store.shouldShowRedDot).toBe(true)
})
@@ -172,7 +174,7 @@ describe('useReleaseStore', () => {
it('should show popup for latest version', () => {
mockSystemStatsStore.systemStats.system.comfyui_version = '1.2.0'
vi.mocked(semverCompare).mockReturnValue(0)
vi.mocked(compare).mockReturnValue(0)
expect(store.shouldShowPopup).toBe(true)
})
@@ -200,13 +202,13 @@ describe('useReleaseStore', () => {
})
it('should not show toast even with new version available', () => {
vi.mocked(semverCompare).mockReturnValue(1)
vi.mocked(compare).mockReturnValue(1)
expect(store.shouldShowToast).toBe(false)
})
it('should not show red dot even with new version available', () => {
vi.mocked(semverCompare).mockReturnValue(1)
vi.mocked(compare).mockReturnValue(1)
expect(store.shouldShowRedDot).toBe(false)
})
@@ -214,7 +216,7 @@ describe('useReleaseStore', () => {
it('should not show popup even for latest version', () => {
mockSystemStatsStore.systemStats.system.comfyui_version = '1.2.0'
vi.mocked(semverCompare).mockReturnValue(0)
vi.mocked(compare).mockReturnValue(0)
expect(store.shouldShowPopup).toBe(false)
})
@@ -494,7 +496,7 @@ describe('useReleaseStore', () => {
return null
})
vi.mocked(semverCompare).mockReturnValue(1)
vi.mocked(compare).mockReturnValue(1)
store.releases = [mockRelease]
@@ -502,7 +504,7 @@ describe('useReleaseStore', () => {
})
it('should show red dot for new versions', () => {
vi.mocked(semverCompare).mockReturnValue(1)
vi.mocked(compare).mockReturnValue(1)
mockSettingStore.get.mockImplementation((key: string) => {
if (key === 'Comfy.Notification.ShowVersionUpdates') return true
return null
@@ -520,7 +522,7 @@ describe('useReleaseStore', () => {
return null
})
vi.mocked(semverCompare).mockReturnValue(0) // versions are equal (latest version)
vi.mocked(compare).mockReturnValue(0) // versions are equal (latest version)
store.releases = [mockRelease]
@@ -578,14 +580,14 @@ describe('useReleaseStore', () => {
})
it('should show toast when conditions are met', () => {
vi.mocked(semverCompare).mockReturnValue(1)
vi.mocked(compare).mockReturnValue(1)
store.releases = [mockRelease]
expect(store.shouldShowToast).toBe(true)
})
it('should show red dot when new version available', () => {
vi.mocked(semverCompare).mockReturnValue(1)
vi.mocked(compare).mockReturnValue(1)
expect(store.shouldShowRedDot).toBe(true)
})
@@ -593,7 +595,7 @@ describe('useReleaseStore', () => {
it('should show popup for latest version', () => {
mockSystemStatsStore.systemStats.system.comfyui_version = '1.2.0'
vi.mocked(semverCompare).mockReturnValue(0)
vi.mocked(compare).mockReturnValue(0)
expect(store.shouldShowPopup).toBe(true)
})
@@ -606,7 +608,7 @@ describe('useReleaseStore', () => {
})
it('should NOT show toast even when all other conditions are met', () => {
vi.mocked(semverCompare).mockReturnValue(1)
vi.mocked(compare).mockReturnValue(1)
// Set up all conditions that would normally show toast
store.releases = [mockRelease]
@@ -615,13 +617,13 @@ describe('useReleaseStore', () => {
})
it('should NOT show red dot even when new version available', () => {
vi.mocked(semverCompare).mockReturnValue(1)
vi.mocked(compare).mockReturnValue(1)
expect(store.shouldShowRedDot).toBe(false)
})
it('should NOT show toast regardless of attention level', () => {
vi.mocked(semverCompare).mockReturnValue(1)
vi.mocked(compare).mockReturnValue(1)
// Test with high attention releases
const highRelease = {
@@ -640,7 +642,7 @@ describe('useReleaseStore', () => {
})
it('should NOT show red dot even with high attention release', () => {
vi.mocked(semverCompare).mockReturnValue(1)
vi.mocked(compare).mockReturnValue(1)
store.releases = [{ ...mockRelease, attention: 'high' as const }]
@@ -650,7 +652,7 @@ describe('useReleaseStore', () => {
it('should NOT show popup even for latest version', () => {
mockSystemStatsStore.systemStats.system.comfyui_version = '1.2.0'
vi.mocked(semverCompare).mockReturnValue(0)
vi.mocked(compare).mockReturnValue(0)
expect(store.shouldShowPopup).toBe(false)
})

View File

@@ -17,6 +17,8 @@ vi.mock('@/utils/envUtil', () => ({
isElectron: vi.fn()
}))
vi.mock('@/platform/distribution/types', () => ({ isCloud: false }))
describe('useSystemStatsStore', () => {
let store: ReturnType<typeof useSystemStatsStore>

View File

@@ -6,7 +6,6 @@
"lib": [
"ES2023",
"ES2023.Array",
"ESNext.Iterator",
"DOM",
"DOM.Iterable"
],