Compare commits

..

1 Commits

Author SHA1 Message Date
Benjamin Lu
7c9951c330 Disable desktop release notifications 2025-12-18 17:44:14 -08:00
183 changed files with 3702 additions and 4681 deletions

View File

@@ -45,7 +45,6 @@ jobs:
playwright-tests-chromium-sharded:
needs: setup
runs-on: ubuntu-latest
timeout-minutes: 60
permissions:
contents: read
strategy:

View File

@@ -266,16 +266,3 @@ When referencing Comfy-Org repos:
- Always use `import { cn } from '@/utils/tailwindUtil'`
- e.g. `<div :class="cn('text-node-component-header-icon', hasError && 'text-danger')" />`
- Use `cn()` inline in the template when feasible instead of creating a `computed` to hold the value
- NEVER use `!important` or the `!` important prefix for tailwind classes
- Find existing `!important` classes that are interfering with the styling and propose corrections of those instead.
## Agent-only rules
Rules for agent-based coding tasks.
### Temporary Files
- Put planning documents under `/temp/plans/`
- Put scripts used under `/temp/scripts/`
- Put summaries of work performed under `/temp/summaries/`
- Put TODOs and status updates under `/temp/in_progress/`

View File

@@ -46,6 +46,7 @@
# Mask Editor
/src/extensions/core/maskeditor.ts @trsommer @brucew4yn3rp
/src/extensions/core/maskEditorLayerFilenames.ts @trsommer @brucew4yn3rp
/src/extensions/core/maskEditorOld.ts @trsommer @brucew4yn3rp
# 3D
/src/extensions/core/load3d.ts @jtydhr88

View File

@@ -181,10 +181,6 @@ pnpm format
- Use Tailwind CSS classes instead of custom CSS
- NEVER use `dark:` or `dark-theme:` tailwind variants. Instead use a semantic value from the [style.css](packages/design-system/src/css/style.css) like `bg-node-component-surface`
## Design Team Approval (Required for Notable UI Changes)
Changes that materially affect the default UI must be approved or requested by our design team before they can be merged. This is generally a blocking requirement and applies to internal contributors and OSS contributors alike.
### Internationalization
- All user-facing strings must use vue-i18n

View File

@@ -26,9 +26,10 @@ export class ComfyTemplates {
}
async loadTemplate(id: string) {
const templateCard = this.content.getByTestId(`template-workflow-${id}`)
await templateCard.scrollIntoViewIfNeeded()
await templateCard.getByRole('img').click()
await this.content
.getByTestId(`template-workflow-${id}`)
.getByRole('img')
.click()
}
async getAllTemplates(): Promise<TemplateInfo[]> {

Binary file not shown.

Before

Width:  |  Height:  |  Size: 81 KiB

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 22 KiB

View File

@@ -553,6 +553,12 @@ This is English documentation.
)
await selectNodeWithPan(comfyPage, checkpointNodes[0])
// Click help button again
const helpButton2 = comfyPage.page.locator(
'.selection-toolbox button[data-testid="info-button"]'
)
await helpButton2.click()
// Content should update
await expect(helpPage).toContainText('Checkpoint Loader Help')
await expect(helpPage).toContainText(

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 91 KiB

After

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 95 KiB

After

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 97 KiB

After

Width:  |  Height:  |  Size: 104 KiB

View File

@@ -109,14 +109,14 @@ test.describe('Templates', () => {
})
test('Uses proper locale files for templates', async ({ comfyPage }) => {
// Set locale to French before opening templates
await comfyPage.setSetting('Comfy.Locale', 'fr')
// Load the templates dialog and wait for the French index file request
const requestPromise = comfyPage.page.waitForRequest(
'**/templates/index.fr.json'
)
// Set locale to French before opening templates
await comfyPage.setSetting('Comfy.Locale', 'fr')
await comfyPage.executeCommand('Comfy.BrowseTemplates')
const request = await requestPromise

Binary file not shown.

Before

Width:  |  Height:  |  Size: 75 KiB

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 115 KiB

After

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 56 KiB

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 61 KiB

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 63 KiB

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 63 KiB

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 59 KiB

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 59 KiB

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 59 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 61 KiB

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 63 KiB

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 98 KiB

After

Width:  |  Height:  |  Size: 101 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 109 KiB

After

Width:  |  Height:  |  Size: 112 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 109 KiB

After

Width:  |  Height:  |  Size: 112 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 139 KiB

After

Width:  |  Height:  |  Size: 142 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 140 KiB

After

Width:  |  Height:  |  Size: 144 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 109 KiB

After

Width:  |  Height:  |  Size: 112 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 81 KiB

After

Width:  |  Height:  |  Size: 84 KiB

View File

@@ -205,32 +205,6 @@ test.describe('Image widget', () => {
const filename = await fileComboWidget.getValue()
expect(filename).toBe('image32x32.webp')
})
test('Displays buttons when viewing single image of batch', async ({
comfyPage
}) => {
const [x, y] = await comfyPage.page.evaluate(() => {
const src =
"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='768' height='512' viewBox='0 0 1 1'%3E%3Crect width='1' height='1' stroke='black'/%3E%3C/svg%3E"
const image1 = new Image()
image1.src = src
const image2 = new Image()
image2.src = src
const targetNode = graph.nodes[6]
targetNode.imgs = [image1, image2]
targetNode.imageIndex = 1
app.canvas.setDirty(true)
const x = targetNode.pos[0] + targetNode.size[0] - 41
const y = targetNode.pos[1] + targetNode.widgets.at(-1).last_y + 30
return app.canvasPosToClientPos([x, y])
})
const clip = { x, y, width: 35, height: 35 }
await expect(comfyPage.page).toHaveScreenshot(
'image_preview_close_button.png',
{ clip }
)
})
})
test.describe('Animated image widget', () => {
@@ -288,7 +262,13 @@ test.describe('Animated image widget', () => {
expect(filename).toContain('animated_webp.webp')
})
test('Can preview saved animated webp image', async ({ comfyPage }) => {
// FIXME: This test keeps flip-flopping because it relies on animated webp timing,
// which is inherently unreliable in CI environments. The test asset is an animated
// webp with 2 frames, and the test depends on animation frame timing to verify that
// animated webp images are properly displayed (as opposed to being treated as static webp).
// While the underlying functionality works (animated webp are correctly distinguished
// from static webp), the test is flaky due to timing dependencies with webp animation frames.
test.fixme('Can preview saved animated webp image', async ({ comfyPage }) => {
await comfyPage.loadWorkflow('widgets/save_animated_webp')
// Get position of the load animated webp node
@@ -315,13 +295,18 @@ test.describe('Animated image widget', () => {
([loadId, saveId]) => {
// Set the output of the SaveAnimatedWEBP node to equal the loader node's image
window['app'].nodeOutputs[saveId] = window['app'].nodeOutputs[loadId]
app.canvas.setDirty(true)
},
[loadAnimatedWebpNode.id, saveAnimatedWebpNode.id]
)
await expect(
comfyPage.page.locator('.dom-widget').locator('img')
).toHaveCount(2)
await comfyPage.nextFrame()
// Move mouse and click on canvas to trigger render
await comfyPage.page.mouse.click(64, 64)
// Expect the SaveAnimatedWEBP node to have an output preview
await expect(comfyPage.canvas).toHaveScreenshot(
'animated_image_preview_saved_webp.png'
)
})
})

Binary file not shown.

Before

Width:  |  Height:  |  Size: 423 B

View File

@@ -9,7 +9,11 @@ interface ShimResult {
const SKIP_WARNING_FILES = new Set(['scripts/app', 'scripts/api'])
/** Files that will be removed in v1.34 */
const DEPRECATED_FILES = ['scripts/ui', 'extensions/core/groupNode'] as const
const DEPRECATED_FILES = [
'scripts/ui',
'extensions/core/maskEditorOld',
'extensions/core/groupNode'
] as const
function getWarningMessage(
fileKey: string,

View File

@@ -41,6 +41,7 @@ The following table lists ALL core extensions in the system as of 2025-01-30:
| groupOptions.ts | Handles group node configuration options | Graph |
| index.ts | Main extension registration and coordination | Core |
| load3d.ts | Supports 3D model loading and visualization | 3D |
| maskEditorOld.ts | Legacy mask editor implementation | Image |
| maskeditor.ts | Implements the mask editor for image masking operations | Image |
| nodeTemplates.ts | Provides node template functionality | Templates |
| noteNode.ts | Adds note nodes for documentation within workflows | Graph |
@@ -177,4 +178,4 @@ For more detailed information about ComfyUI's extension system, refer to the off
- [JavaScript Settings](https://docs.comfy.org/custom-nodes/js/javascript_settings)
- [JavaScript Examples](https://docs.comfy.org/custom-nodes/js/javascript_examples)
Also, check the main [README.md](https://github.com/Comfy-Org/ComfyUI_frontend#developer-apis) section on Developer APIs for the latest information on extension APIs and features.
Also, check the main [README.md](https://github.com/Comfy-Org/ComfyUI_frontend#developer-apis) section on Developer APIs for the latest information on extension APIs and features.

View File

@@ -4,10 +4,7 @@ import pluginI18n from '@intlify/eslint-plugin-vue-i18n'
import { createTypeScriptImportResolver } from 'eslint-import-resolver-typescript'
import { importX } from 'eslint-plugin-import-x'
import oxlint from 'eslint-plugin-oxlint'
// WORKAROUND: eslint-plugin-prettier causes segfault on Node.js 24 + Windows
// See: https://github.com/nodejs/node/issues/58690
// Prettier is still run separately in lint-staged, so this is safe to disable
import eslintConfigPrettier from 'eslint-config-prettier'
import eslintPluginPrettierRecommended from 'eslint-plugin-prettier/recommended'
import { configs as storybookConfigs } from 'eslint-plugin-storybook'
import unusedImports from 'eslint-plugin-unused-imports'
import pluginVue from 'eslint-plugin-vue'
@@ -111,8 +108,7 @@ export default defineConfig([
tseslintConfigs.recommended,
// Difference in typecheck on CI vs Local
pluginVue.configs['flat/recommended'],
// Use eslint-config-prettier instead of eslint-plugin-prettier to avoid Node 24 segfault
eslintConfigPrettier,
eslintPluginPrettierRecommended,
// @ts-expect-error Type incompatibility between storybook plugin and ESLint config types
storybookConfigs['flat/recommended'],
// @ts-expect-error Type incompatibility between import-x plugin and ESLint config types

View File

@@ -1,7 +1,7 @@
{
"name": "@comfyorg/comfyui-frontend",
"private": true,
"version": "1.36.8",
"version": "1.36.3",
"type": "module",
"repository": "https://github.com/Comfy-Org/ComfyUI_frontend",
"homepage": "https://comfy.org",
@@ -19,7 +19,6 @@
"dev:cloud": "cross-env DEV_SERVER_COMFYUI_URL='https://testcloud.comfy.org/' nx serve",
"dev:desktop": "nx dev @comfyorg/desktop-ui",
"dev:electron": "nx serve --config vite.electron.config.mts",
"dev:no-vue": "cross-env DISABLE_VUE_PLUGINS=true nx serve",
"dev": "nx serve",
"devtools:pycheck": "python3 -m compileall -q tools/devtools",
"format:check:no-cache": "prettier --check './**/*.{js,ts,tsx,vue,mts}'",
@@ -85,6 +84,7 @@
"eslint-import-resolver-typescript": "catalog:",
"eslint-plugin-import-x": "catalog:",
"eslint-plugin-oxlint": "catalog:",
"eslint-plugin-prettier": "catalog:",
"eslint-plugin-storybook": "catalog:",
"eslint-plugin-unused-imports": "catalog:",
"eslint-plugin-vue": "catalog:",
@@ -146,7 +146,6 @@
"@primevue/icons": "catalog:",
"@primevue/themes": "catalog:",
"@sentry/vue": "catalog:",
"@sparkjsdev/spark": "catalog:",
"@tiptap/core": "^2.10.4",
"@tiptap/extension-link": "^2.10.4",
"@tiptap/extension-table": "^2.10.4",

View File

@@ -217,28 +217,6 @@ export interface paths {
patch?: never;
trace?: never;
};
"/admin/verify-api-key": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/**
* Verify a ComfyUI API key and return customer details
* @description Validates a ComfyUI API key and returns the associated customer information.
* This endpoint is used by cloud.comfy.org to authenticate users via API keys
* instead of Firebase tokens.
*/
post: operations["VerifyApiKey"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/admin/customers/{customer_id}/cloud-subscription-status": {
parameters: {
query?: never;
@@ -2176,26 +2154,6 @@ export interface paths {
patch?: never;
trace?: never;
};
"/proxy/bfl/flux-2-max/generate": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/**
* Proxy request to BFL Flux 2 Max for image generation
* @description Forwards image generation requests to BFL's Flux 2 Max API and returns the results. Supports image-to-image generation with up to 8 input images.
*/
post: operations["bflFlux2MaxGenerate"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/proxy/bfl/flux-pro-1.0-expand/generate": {
parameters: {
query?: never;
@@ -3953,11 +3911,6 @@ export interface components {
* @enum {string}
*/
SubscriptionTier: "STANDARD" | "CREATOR" | "PRO" | "FOUNDERS_EDITION";
/**
* @description The subscription billing duration
* @enum {string}
*/
SubscriptionDuration: "MONTHLY" | "ANNUAL";
FeaturesResponse: {
/**
* @description The conversion rate for partner nodes
@@ -4804,13 +4757,13 @@ export interface components {
* @default kling-v1
* @enum {string}
*/
KlingTextToVideoModelName: "kling-v1" | "kling-v1-5" | "kling-v1-6" | "kling-v2-master" | "kling-v2-1-master" | "kling-v2-5-turbo" | "kling-v2-6";
KlingTextToVideoModelName: "kling-v1" | "kling-v1-5" | "kling-v1-6" | "kling-v2-master" | "kling-v2-1-master" | "kling-v2-5-turbo";
/**
* @description Model Name
* @default kling-v2-master
* @enum {string}
*/
KlingVideoGenModelName: "kling-v1" | "kling-v1-5" | "kling-v1-6" | "kling-v2-master" | "kling-v2-1" | "kling-v2-1-master" | "kling-v2-5-turbo" | "kling-v2-6";
KlingVideoGenModelName: "kling-v1" | "kling-v1-5" | "kling-v1-6" | "kling-v2-master" | "kling-v2-1" | "kling-v2-1-master" | "kling-v2-5-turbo";
/**
* @description Video generation mode. std: Standard Mode, which is cost-effective. pro: Professional Mode, generates videos with longer duration but higher quality output.
* @default std
@@ -4955,12 +4908,6 @@ export interface components {
camera_control?: components["schemas"]["KlingCameraControl"];
aspect_ratio?: components["schemas"]["KlingVideoGenAspectRatio"];
duration?: components["schemas"]["KlingVideoGenDuration"];
/**
* @description Whether to generate sound simultaneously when generating videos. Only V2.6 and subsequent versions of the model support this parameter.
* @default off
* @enum {string}
*/
sound: "on" | "off";
/**
* Format: uri
* @description The callback notification address
@@ -5023,12 +4970,6 @@ export interface components {
camera_control?: components["schemas"]["KlingCameraControl"];
aspect_ratio?: components["schemas"]["KlingVideoGenAspectRatio"];
duration?: components["schemas"]["KlingVideoGenDuration"];
/**
* @description Whether to generate sound simultaneously when generating videos. Only V2.6 and subsequent versions of the model support this parameter.
* @default off
* @enum {string}
*/
sound: "on" | "off";
/**
* Format: uri
* @description The callback notification address. Server will notify when the task status changes.
@@ -5818,7 +5759,7 @@ export interface components {
width: number;
/**
* @description Height of the image.
* @default 1024
* @default 768
*/
height: number;
/** @description Seed for reproducibility. */
@@ -5834,11 +5775,6 @@ export interface components {
* @enum {string}
*/
output_format: "jpeg" | "png";
/**
* @description Moderation tolerance level (Flux 2 Max only).
* @default 2
*/
safety_tolerance: number;
};
/** FluxProFillInputs */
BFLFluxProFillInputs: {
@@ -7037,10 +6973,6 @@ export interface components {
image_tokens?: number;
};
output_tokens?: number;
output_tokens_details?: {
text_tokens?: number;
image_tokens?: number;
};
total_tokens?: number;
};
};
@@ -10424,76 +10356,40 @@ export interface components {
* @description The ID of the model to call
* @enum {string}
*/
model: "wan2.5-t2v-preview" | "wan2.5-i2v-preview" | "wan2.6-t2v" | "wan2.6-i2v";
model: "wan2.5-t2v-preview" | "wan2.5-i2v-preview";
/** @description Enter basic information, such as prompt words, etc. */
input: {
/**
* @description Text prompt words. Support Chinese and English, length not exceeding 800 characters.
* For wan2.6-r2v with multiple reference videos, use 'character1', 'character2', etc. to refer to subjects
* in the order of reference videos. Example: "Character1 sings on the roadside, Character2 dances beside it"
*/
/** @description Text prompt words. Support Chinese and English, length not exceeding 800 characters */
prompt: string;
/** @description Reverse prompt words are used to describe content that you do not want to see in the video screen */
negative_prompt?: string;
/** @description Audio file download URL. Supported formats: mp3 and wav. Cannot be used with reference_video_urls. */
/** @description Audio file download URL. Supported formats: mp3 and wav. */
audio_url?: string;
/** @description First frame image URL or Base64 encoded data. Required for I2V models. Image formats: JPEG, JPG, PNG, BMP, WEBP. Resolution: 360-2000 pixels. File size: max 10MB. */
img_url?: string;
/** @description Video effect template name. Optional. Currently supported: squish, flying, carousel. When used, prompt parameter is ignored. */
template?: string;
/**
* @description Reference video URLs for wan2.6-r2v model only. Array of 1-3 video URLs.
* Input restrictions:
* - Format: mp4, mov
* - Quantity: 1-3 videos
* - Single video length: 2-30 seconds
* - Single file size: max 30MB
* - Cannot be used with audio_url
* Reference duration: Single video max 5s, two videos max 2.5s each, three videos proportionally less.
* Billing: Based on actual reference duration used.
*/
reference_video_urls?: string[];
};
/** @description Video processing parameters */
parameters?: {
/**
* @description Video resolution in format width*height. Supported resolutions vary by model:
* For wan2.5 T2V: 480P (480*832, 832*480, 624*624), 720P, 1080P sizes
* For wan2.6 T2V/R2V (no 480P):
* 720P: 1280*720, 720*1280, 960*960, 1088*832, 832*1088
* 1080P: 1920*1080, 1080*1920, 1440*1440, 1632*1248, 1248*1632
*/
/** @description Used to specify the video resolution in the format of 宽*高. Supported resolutions vary by model (for T2V models) */
size?: string;
/**
* @description Resolution level for I2V models. Supported values vary by model:
* - wan2.5-i2v-preview: 480P, 720P, 1080P
* - wan2.6-i2v: 720P, 1080P only (no 480P support)
* @description Resolution level for I2V models. Supported values vary by model: 480P, 720P, 1080P
* @enum {string}
*/
resolution?: "480P" | "720P" | "1080P";
/**
* @description The duration of the video generated, in seconds:
* - wan2.5 models: 5 or 10 seconds
* - wan2.6-t2v, wan2.6-i2v: 5, 10, or 15 seconds
* - wan2.6-r2v: 5 or 10 seconds only (no 15s support)
* @description The duration of the video generated, in seconds
* @default 5
* @enum {integer}
*/
duration?: 5 | 10 | 15;
duration?: 5 | 10;
/**
* @description Is it enabled prompt intelligent rewriting. Default is true
* @default true
*/
prompt_extend?: boolean;
/**
* @description Intelligent multi-lens control. Only active when prompt_extend is enabled.
* For wan2.6 models only.
* - multi: Intelligent disassembly into multiple lenses (default)
* - single: Single lens generation
* @default multi
* @enum {string}
*/
shot_type?: "multi" | "single";
/** @description Random number seed, used to control the randomness of the model generated content */
seed?: number;
/**
@@ -11910,8 +11806,6 @@ export interface operations {
"application/json": {
/** @description Optional URL to redirect the customer after they're done with the billing portal */
return_url?: string;
/** @description Optional target subscription tier. When provided, creates a deep link directly to the subscription update confirmation screen with this tier pre-selected. */
target_tier?: "standard" | "creator" | "pro" | "standard-yearly" | "creator-yearly" | "pro-yearly";
};
};
};
@@ -12008,8 +11902,8 @@ export interface operations {
query?: never;
header?: never;
path: {
/** @description The subscription tier (standard, creator, or pro) with optional yearly billing (standard-yearly, creator-yearly, pro-yearly) */
tier: "standard" | "creator" | "pro" | "standard-yearly" | "creator-yearly" | "pro-yearly";
/** @description The subscription tier (standard, creator, or pro) */
tier: "standard" | "creator" | "pro";
};
cookie?: never;
};
@@ -12075,7 +11969,6 @@ export interface operations {
/** @description The active subscription ID if one exists */
subscription_id?: string | null;
subscription_tier?: components["schemas"]["SubscriptionTier"] | null;
subscription_duration?: components["schemas"]["SubscriptionDuration"] | null;
/** @description Whether the customer has funds/credits available */
has_fund?: boolean;
/**
@@ -12109,72 +12002,6 @@ export interface operations {
};
};
};
VerifyApiKey: {
parameters: {
query?: never;
header: {
/** @description Admin API secret used to authorize this request */
"X-Comfy-Admin-Secret": string;
};
path?: never;
cookie?: never;
};
requestBody: {
content: {
"application/json": {
/** @description The ComfyUI API key to verify (e.g., comfy_xxx...) */
api_key: string;
};
};
};
responses: {
/** @description API key is valid */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": {
/** @description Whether the API key is valid */
valid: boolean;
/** @description The Firebase UID of the user */
firebase_uid: string;
/** @description The customer's email address */
email?: string;
/** @description The customer's name */
name?: string;
/** @description Whether the customer is an admin */
is_admin?: boolean;
};
};
};
/** @description Unauthorized or missing admin API secret */
401: {
headers: {
[name: string]: unknown;
};
content?: never;
};
/** @description API key not found or invalid */
404: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ErrorResponse"];
};
};
/** @description Internal server error */
500: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ErrorResponse"];
};
};
};
};
GetAdminCustomerCloudSubscriptionStatus: {
parameters: {
query?: never;
@@ -12202,7 +12029,6 @@ export interface operations {
/** @description The active subscription ID if one exists */
subscription_id?: string | null;
subscription_tier?: components["schemas"]["SubscriptionTier"] | null;
subscription_duration?: components["schemas"]["SubscriptionDuration"] | null;
/** @description Whether the customer has funds/credits available */
has_fund?: boolean;
/**
@@ -12320,16 +12146,6 @@ export interface operations {
* @description The remaining balance from cloud credits in microamount
*/
cloud_credit_balance_micros?: number;
/**
* Format: double
* @description The total amount of pending/unbilled charges from draft invoices in microamount. Only included when the show_negative_balances feature flag is enabled.
*/
pending_charges_micros?: number;
/**
* Format: double
* @description The effective balance (total balance minus pending charges). Can be negative if pending charges exceed the balance. Only included when the show_negative_balances feature flag is enabled.
*/
effective_balance_micros?: number;
/** @description The currency code (e.g., "usd") */
currency: string;
};
@@ -12396,16 +12212,6 @@ export interface operations {
* @description The remaining balance from cloud credits in microamount
*/
cloud_credit_balance_micros?: number;
/**
* Format: double
* @description The total amount of pending/unbilled charges from draft invoices in microamount. Only included when the show_negative_balances feature flag is enabled.
*/
pending_charges_micros?: number;
/**
* Format: double
* @description The effective balance (total balance minus pending charges). Can be negative if pending charges exceed the balance. Only included when the show_negative_balances feature flag is enabled.
*/
effective_balance_micros?: number;
/** @description The currency code (e.g., "usd") */
currency: string;
};
@@ -19611,89 +19417,6 @@ export interface operations {
};
};
};
bflFlux2MaxGenerate: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["BFLFlux2ProGenerateRequest"];
};
};
responses: {
/** @description Successful response from BFL Flux 2 Max proxy */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["BFLFluxProGenerateResponse"];
};
};
/** @description Bad Request (invalid input to proxy) */
400: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ErrorResponse"];
};
};
/** @description Unauthorized */
401: {
headers: {
[name: string]: unknown;
};
content?: never;
};
/** @description Payment Required */
402: {
headers: {
[name: string]: unknown;
};
content?: never;
};
/** @description Rate limit exceeded (either from proxy or BFL) */
429: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ErrorResponse"];
};
};
/** @description Internal Server Error (proxy or upstream issue) */
500: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ErrorResponse"];
};
};
/** @description Bad Gateway (error communicating with BFL) */
502: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ErrorResponse"];
};
};
/** @description Gateway Timeout (BFL took too long to respond) */
504: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ErrorResponse"];
};
};
};
};
BFLExpand_v1_flux_pro_1_0_expand_post: {
parameters: {
query?: never;

97
pnpm-lock.yaml generated
View File

@@ -46,8 +46,8 @@ catalogs:
specifier: ^0.1.5
version: 0.1.5
'@playwright/test':
specifier: ^1.57.0
version: 1.57.0
specifier: ^1.52.0
version: 1.52.0
'@prettier/plugin-oxc':
specifier: ^0.1.3
version: 0.1.3
@@ -78,9 +78,6 @@ catalogs:
'@sentry/vue':
specifier: ^8.48.0
version: 8.48.0
'@sparkjsdev/spark':
specifier: ^0.1.10
version: 0.1.10
'@storybook/addon-docs':
specifier: ^10.1.9
version: 10.1.9
@@ -162,6 +159,9 @@ catalogs:
eslint-plugin-oxlint:
specifier: 1.25.0
version: 1.25.0
eslint-plugin-prettier:
specifier: ^5.5.4
version: 5.5.4
eslint-plugin-storybook:
specifier: ^10.1.9
version: 10.1.9
@@ -377,9 +377,6 @@ importers:
'@sentry/vue':
specifier: 'catalog:'
version: 8.48.0(pinia@2.2.2(typescript@5.9.3)(vue@3.5.13(typescript@5.9.3)))(vue@3.5.13(typescript@5.9.3))
'@sparkjsdev/spark':
specifier: 'catalog:'
version: 0.1.10
'@tiptap/core':
specifier: ^2.10.4
version: 2.10.4(@tiptap/pm@2.10.4)
@@ -521,7 +518,7 @@ importers:
version: 22.2.6(@babel/traverse@7.28.5)(@zkochan/js-yaml@0.0.7)(eslint@9.39.1(jiti@2.6.1))(nx@22.2.6)
'@nx/playwright':
specifier: 'catalog:'
version: 22.2.6(@babel/traverse@7.28.5)(@playwright/test@1.57.0)(@zkochan/js-yaml@0.0.7)(eslint@9.39.1(jiti@2.6.1))(nx@22.2.6)
version: 22.2.6(@babel/traverse@7.28.5)(@playwright/test@1.52.0)(@zkochan/js-yaml@0.0.7)(eslint@9.39.1(jiti@2.6.1))(nx@22.2.6)
'@nx/storybook':
specifier: 'catalog:'
version: 22.2.4(@babel/traverse@7.28.5)(@zkochan/js-yaml@0.0.7)(eslint@9.39.1(jiti@2.6.1))(nx@22.2.6)(storybook@10.1.9(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(typescript@5.9.3)
@@ -533,7 +530,7 @@ importers:
version: 0.1.5(pinia@2.2.2(typescript@5.9.3)(vue@3.5.13(typescript@5.9.3)))(vue@3.5.13(typescript@5.9.3))
'@playwright/test':
specifier: 'catalog:'
version: 1.57.0
version: 1.52.0
'@prettier/plugin-oxc':
specifier: 'catalog:'
version: 0.1.3
@@ -603,6 +600,9 @@ importers:
eslint-plugin-oxlint:
specifier: 'catalog:'
version: 1.25.0
eslint-plugin-prettier:
specifier: 'catalog:'
version: 5.5.4(eslint-config-prettier@10.1.8(eslint@9.39.1(jiti@2.6.1)))(eslint@9.39.1(jiti@2.6.1))(prettier@3.7.4)
eslint-plugin-storybook:
specifier: 'catalog:'
version: 10.1.9(eslint@9.39.1(jiti@2.6.1))(storybook@10.1.9(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(typescript@5.9.3)
@@ -2778,8 +2778,8 @@ packages:
resolution: {integrity: sha512-QNqXyfVS2wm9hweSYD2O7F0G06uurj9kZ96TRQE5Y9hU7+tgdZwIkbAKc5Ocy1HxEY2kuDQa6cQ1WRs/O5LFKA==}
engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0}
'@playwright/test@1.57.0':
resolution: {integrity: sha512-6TyEnHgd6SArQO8UO2OMTxshln3QMWBtPGrOCgs3wVEmQmwyuNtB10IZMfmYDE0riwNR1cu4q+pPcxMVtaG3TA==}
'@playwright/test@1.52.0':
resolution: {integrity: sha512-uh6W7sb55hl7D6vsAeA+V2p5JnlAqzhqFyF0VcJkKZXkgnFcVG9PziERRHQfPLfNGx1C292a4JqbWzhR8L4R1g==}
engines: {node: '>=18'}
hasBin: true
@@ -3118,9 +3118,6 @@ packages:
'@sinclair/typebox@0.34.40':
resolution: {integrity: sha512-gwBNIP8ZAYev/ORDWW0QvxdwPXwxBtLsdsJgSc7eDIRt8ubP+rxUBzPsrwnu16fgEF8Bx4lh/+mvQvJzcTM6Kw==}
'@sparkjsdev/spark@0.1.10':
resolution: {integrity: sha512-CiijdZQuj7KPDUqIZPiEqyUkJCYo1JqR05vq/V+ElxMwqR7L70ZuZDyIKcasjZHSiPB8pGRMH8HZGqUKO9aRPQ==}
'@storybook/addon-docs@10.1.9':
resolution: {integrity: sha512-SvwEZ32lyk5p3PRmE3pmfAhs4HMiVo5zxjTBVmK9kgz9zGgWCTlikb56tJ998hVe52CFyCvt3I9rkHeYMCKPww==}
peerDependencies:
@@ -5054,6 +5051,20 @@ packages:
eslint-plugin-oxlint@1.25.0:
resolution: {integrity: sha512-grS4KdR9FAxoQC+wMkepeQHL4osMhoYfUI11Pot6Gitqr4wWi+JZrX0Shr8Bs9fjdWhEjtaZIV6cr4mbfytmyw==}
eslint-plugin-prettier@5.5.4:
resolution: {integrity: sha512-swNtI95SToIz05YINMA6Ox5R057IMAmWZ26GqPxusAp1TZzj+IdY9tXNWWD3vkF/wEqydCONcwjTFpxybBqZsg==}
engines: {node: ^14.18.0 || >=16.0.0}
peerDependencies:
'@types/eslint': '*'
eslint: '>=8.0.0'
eslint-config-prettier: '>= 7.0.0 <10.0.0 || >=10.1.0'
prettier: '>=3.0.0'
peerDependenciesMeta:
'@types/eslint':
optional: true
eslint-config-prettier:
optional: true
eslint-plugin-storybook@10.1.9:
resolution: {integrity: sha512-2XCnHhu+9ShW8U/MsvnlT4ZkzADIPtlfYVD/GBBbs8loWu0x9IZ3EfNg1LEImjvvNVDhwpd5K04lK4CAP+2bWA==}
peerDependencies:
@@ -5179,6 +5190,9 @@ packages:
fast-deep-equal@3.1.3:
resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==}
fast-diff@1.3.0:
resolution: {integrity: sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==}
fast-glob@3.3.3:
resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==}
engines: {node: '>=8.6.0'}
@@ -6784,13 +6798,13 @@ packages:
pkg-types@2.3.0:
resolution: {integrity: sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig==}
playwright-core@1.57.0:
resolution: {integrity: sha512-agTcKlMw/mjBWOnD6kFZttAAGHgi/Nw0CZ2o6JqWSbMlI219lAFLZZCyqByTsvVAJq5XA5H8cA6PrvBRpBWEuQ==}
playwright-core@1.52.0:
resolution: {integrity: sha512-l2osTgLXSMeuLZOML9qYODUQoPPnUsKsb5/P6LJ2e6uPKXUdPK5WYhN4z03G+YNbWmGDY4YENauNu4ZKczreHg==}
engines: {node: '>=18'}
hasBin: true
playwright@1.57.0:
resolution: {integrity: sha512-ilYQj1s8sr2ppEJ2YVadYBN0Mb3mdo9J0wQ+UuDhzYqURwSoW4n1Xs5vs7ORwgDGmyEh33tRMeS8KhdkMoLXQw==}
playwright@1.52.0:
resolution: {integrity: sha512-JAwMNMBlxJ2oD1kce4KPtMkDeKGHQstdpFPcPH3maElAXon/QZeTvtsfXmTMRyO9TslfoYOXkSsvao2nE1ilTw==}
engines: {node: '>=18'}
hasBin: true
@@ -6832,6 +6846,10 @@ packages:
resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==}
engines: {node: '>= 0.8.0'}
prettier-linter-helpers@1.0.0:
resolution: {integrity: sha512-GbK2cP9nraSSUF9N2XwUwqfzlAFlMNYYl+ShE/V+H8a9uNl/oUqB1w2EL54Jh0OlyRSd8RfWYJ3coVS4TROP2w==}
engines: {node: '>=6.0.0'}
prettier@3.7.4:
resolution: {integrity: sha512-v6UNi1+3hSlVvv8fSaoUbggEM5VErKmmpGA7Pl3HF8V6uKY7rvClBOJlH6yNwQtfTueNkGVpOv/mtWL9L4bgRA==}
engines: {node: '>=14'}
@@ -7481,6 +7499,10 @@ packages:
resolution: {integrity: sha512-2SG1TnJGjMkD4+gblONMGYSrwAzYi+ymOitD+Jb/iMYm57nH20PlkVeMQRah3yDMKEa0QQYUF/QPWpdW7C6zNg==}
engines: {node: ^14.18.0 || >=16.0.0}
synckit@0.11.11:
resolution: {integrity: sha512-MeQTA1r0litLUf0Rp/iisCaL8761lKAZHaimlbGK4j0HysC4PLfqygQj9srcs0m2RdtDYnF8UuYyKpbjHYp7Jw==}
engines: {node: ^14.18.0 || >=16.0.0}
table@6.9.0:
resolution: {integrity: sha512-9kY+CygyYM6j02t5YFHbNz2FN5QmYGv9zAjVp4lCDjlCw7amdckXlEt/bjMhUIfj4ThGRE4gCUH5+yGnNuPo5A==}
engines: {node: '>=10.0.0'}
@@ -10268,7 +10290,7 @@ snapshots:
'@nx/nx-win32-x64-msvc@22.2.6':
optional: true
'@nx/playwright@22.2.6(@babel/traverse@7.28.5)(@playwright/test@1.57.0)(@zkochan/js-yaml@0.0.7)(eslint@9.39.1(jiti@2.6.1))(nx@22.2.6)':
'@nx/playwright@22.2.6(@babel/traverse@7.28.5)(@playwright/test@1.52.0)(@zkochan/js-yaml@0.0.7)(eslint@9.39.1(jiti@2.6.1))(nx@22.2.6)':
dependencies:
'@nx/devkit': 22.2.6(nx@22.2.6)
'@nx/eslint': 22.2.6(@babel/traverse@7.28.5)(@zkochan/js-yaml@0.0.7)(eslint@9.39.1(jiti@2.6.1))(nx@22.2.6)
@@ -10276,7 +10298,7 @@ snapshots:
minimatch: 9.0.3
tslib: 2.8.1
optionalDependencies:
'@playwright/test': 1.57.0
'@playwright/test': 1.52.0
transitivePeerDependencies:
- '@babel/traverse'
- '@swc-node/register'
@@ -10560,9 +10582,9 @@ snapshots:
'@pkgr/core@0.2.9': {}
'@playwright/test@1.57.0':
'@playwright/test@1.52.0':
dependencies:
playwright: 1.57.0
playwright: 1.52.0
'@pnpm/config.env-replace@1.1.0': {}
@@ -10866,10 +10888,6 @@ snapshots:
'@sinclair/typebox@0.34.40': {}
'@sparkjsdev/spark@0.1.10':
dependencies:
fflate: 0.8.2
'@storybook/addon-docs@10.1.9(@types/react@19.1.9)(esbuild@0.27.1)(rollup@4.53.5)(storybook@10.1.9(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))':
dependencies:
'@mdx-js/react': 3.1.1(@types/react@19.1.9)(react@19.2.3)
@@ -13110,6 +13128,15 @@ snapshots:
dependencies:
jsonc-parser: 3.3.1
eslint-plugin-prettier@5.5.4(eslint-config-prettier@10.1.8(eslint@9.39.1(jiti@2.6.1)))(eslint@9.39.1(jiti@2.6.1))(prettier@3.7.4):
dependencies:
eslint: 9.39.1(jiti@2.6.1)
prettier: 3.7.4
prettier-linter-helpers: 1.0.0
synckit: 0.11.11
optionalDependencies:
eslint-config-prettier: 10.1.8(eslint@9.39.1(jiti@2.6.1))
eslint-plugin-storybook@10.1.9(eslint@9.39.1(jiti@2.6.1))(storybook@10.1.9(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(typescript@5.9.3):
dependencies:
'@typescript-eslint/utils': 8.50.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3)
@@ -13268,6 +13295,8 @@ snapshots:
fast-deep-equal@3.1.3: {}
fast-diff@1.3.0: {}
fast-glob@3.3.3:
dependencies:
'@nodelib/fs.stat': 2.0.5
@@ -15228,11 +15257,11 @@ snapshots:
exsolve: 1.0.8
pathe: 2.0.3
playwright-core@1.57.0: {}
playwright-core@1.52.0: {}
playwright@1.57.0:
playwright@1.52.0:
dependencies:
playwright-core: 1.57.0
playwright-core: 1.52.0
optionalDependencies:
fsevents: 2.3.2
@@ -15271,6 +15300,10 @@ snapshots:
prelude-ls@1.2.1: {}
prettier-linter-helpers@1.0.0:
dependencies:
fast-diff: 1.3.0
prettier@3.7.4: {}
pretty-bytes@7.1.0: {}
@@ -16158,6 +16191,10 @@ snapshots:
'@pkgr/core': 0.2.9
tslib: 2.8.1
synckit@0.11.11:
dependencies:
'@pkgr/core': 0.2.9
table@6.9.0:
dependencies:
ajv: 8.17.1

View File

@@ -1,7 +1,3 @@
packages:
- apps/**
- packages/**
catalog:
'@alloc/quick-lru': ^5.2.0
'@comfyorg/comfyui-electron-types': 0.5.5
@@ -16,7 +12,7 @@ catalog:
'@nx/storybook': 22.2.4
'@nx/vite': 22.2.6
'@pinia/testing': ^0.1.5
'@playwright/test': ^1.57.0
'@playwright/test': ^1.52.0
'@prettier/plugin-oxc': ^0.1.3
'@primeuix/forms': 0.0.2
'@primeuix/styled': 0.3.2
@@ -27,7 +23,6 @@ catalog:
'@primevue/themes': ^4.2.5
'@sentry/vite-plugin': ^4.6.0
'@sentry/vue': ^8.48.0
'@sparkjsdev/spark': ^0.1.10
'@storybook/addon-docs': ^10.1.9
'@storybook/vue3': ^10.1.9
'@storybook/vue3-vite': ^10.1.9
@@ -55,6 +50,7 @@ catalog:
eslint-import-resolver-typescript: ^4.4.4
eslint-plugin-import-x: ^4.16.1
eslint-plugin-oxlint: 1.25.0
eslint-plugin-prettier: ^5.5.4
eslint-plugin-storybook: ^10.1.9
eslint-plugin-unused-imports: ^4.3.0
eslint-plugin-vue: ^10.6.2
@@ -108,15 +104,12 @@ catalog:
zod: ^3.23.8
zod-to-json-schema: ^3.24.1
zod-validation-error: ^3.3.0
cleanupUnusedCatalogs: true
ignoredBuiltDependencies:
- '@firebase/util'
- protobufjs
- unrs-resolver
- vue-demi
onlyBuiltDependencies:
- '@playwright/browser-chromium'
- '@playwright/browser-firefox'
@@ -126,6 +119,8 @@ onlyBuiltDependencies:
- esbuild
- nx
- oxc-resolver
overrides:
'@types/eslint': '-'
packages:
- apps/**
- packages/**

View File

@@ -10,56 +10,8 @@ interface TestStats {
finished?: number
}
interface TestLocation {
file: string
line: number
column: number
}
interface TestAttachment {
name: string
path?: string
contentType: string
}
interface TestResult {
status: string
duration: number
errors?: Array<{ message?: string; stack?: string }>
attachments?: TestAttachment[]
}
interface Test {
title: string
location?: TestLocation
results?: TestResult[]
}
interface Suite {
title: string
suites?: Suite[]
tests?: Test[]
}
interface ReportData {
stats?: TestStats
suites?: Suite[]
}
interface FailingTest {
name: string
filePath: string
line: number
error: string
tracePath?: string
failureType?: 'screenshot' | 'expectation' | 'timeout' | 'other'
}
interface FailureTypeCounts {
screenshot: number
expectation: number
timeout: number
other: number
}
interface TestCounts {
@@ -68,106 +20,12 @@ interface TestCounts {
flaky: number
skipped: number
total: number
failingTests?: FailingTest[]
failureTypes?: FailureTypeCounts
}
/**
* Categorize the failure type based on error message
*/
function categorizeFailureType(
error: string,
status: string
): 'screenshot' | 'expectation' | 'timeout' | 'other' {
if (status === 'timedOut') {
return 'timeout'
}
const errorLower = error.toLowerCase()
// Screenshot-related errors
if (
errorLower.includes('screenshot') ||
errorLower.includes('snapshot') ||
errorLower.includes('toHaveScreenshot') ||
errorLower.includes('image comparison') ||
errorLower.includes('pixel') ||
errorLower.includes('visual')
) {
return 'screenshot'
}
// Expectation errors
if (
errorLower.includes('expect') ||
errorLower.includes('assertion') ||
errorLower.includes('toEqual') ||
errorLower.includes('toBe') ||
errorLower.includes('toContain') ||
errorLower.includes('toHave') ||
errorLower.includes('toMatch')
) {
return 'expectation'
}
return 'other'
}
/**
* Recursively extract failing tests from suite structure
*/
function extractFailingTests(suite: Suite, failingTests: FailingTest[]): void {
// Process tests in this suite
if (suite.tests) {
for (const test of suite.tests) {
if (!test.results) continue
for (const result of test.results) {
if (result.status === 'failed' || result.status === 'timedOut') {
const error =
result.errors?.[0]?.message ||
result.errors?.[0]?.stack ||
'Test failed'
// Find trace attachment
let tracePath: string | undefined
if (result.attachments) {
const traceAttachment = result.attachments.find(
(att) =>
att.name === 'trace' || att.contentType === 'application/zip'
)
if (traceAttachment?.path) {
tracePath = traceAttachment.path
}
}
const failureType = categorizeFailureType(error, result.status)
failingTests.push({
name: test.title,
filePath: test.location?.file || 'unknown',
line: test.location?.line || 0,
error: error.split('\n')[0], // First line of error
tracePath,
failureType
})
}
}
}
}
// Recursively process nested suites
if (suite.suites) {
for (const nestedSuite of suite.suites) {
extractFailingTests(nestedSuite, failingTests)
}
}
}
/**
* Extract test counts from Playwright HTML report
* @param reportDir - Path to the playwright-report directory
* @returns Test counts { passed, failed, flaky, skipped, total, failingTests }
* @returns Test counts { passed, failed, flaky, skipped, total }
*/
function extractTestCounts(reportDir: string): TestCounts {
const counts: TestCounts = {
@@ -175,14 +33,7 @@ function extractTestCounts(reportDir: string): TestCounts {
failed: 0,
flaky: 0,
skipped: 0,
total: 0,
failingTests: [],
failureTypes: {
screenshot: 0,
expectation: 0,
timeout: 0,
other: 0
}
total: 0
}
try {
@@ -203,22 +54,6 @@ function extractTestCounts(reportDir: string): TestCounts {
counts.failed = stats.unexpected || 0
counts.flaky = stats.flaky || 0
counts.skipped = stats.skipped || 0
// Extract failing test details
if (reportJson.suites) {
for (const suite of reportJson.suites) {
extractFailingTests(suite, counts.failingTests)
}
}
// Count failure types
if (counts.failingTests) {
for (const test of counts.failingTests) {
const type = test.failureType || 'other'
counts.failureTypes![type]++
}
}
return counts
}
}
@@ -251,22 +86,6 @@ function extractTestCounts(reportDir: string): TestCounts {
counts.failed = stats.unexpected || 0
counts.flaky = stats.flaky || 0
counts.skipped = stats.skipped || 0
// Extract failing test details
if (reportData.suites) {
for (const suite of reportData.suites) {
extractFailingTests(suite, counts.failingTests!)
}
}
// Count failure types
if (counts.failingTests) {
for (const test of counts.failingTests) {
const type = test.failureType || 'other'
counts.failureTypes![type]++
}
}
return counts
}
} catch (e) {
@@ -294,22 +113,6 @@ function extractTestCounts(reportDir: string): TestCounts {
counts.failed = stats.unexpected || 0
counts.flaky = stats.flaky || 0
counts.skipped = stats.skipped || 0
// Extract failing test details
if (reportData.suites) {
for (const suite of reportData.suites) {
extractFailingTests(suite, counts.failingTests!)
}
}
// Count failure types
if (counts.failingTests) {
for (const test of counts.failingTests) {
const type = test.failureType || 'other'
counts.failureTypes![type]++
}
}
return counts
}
} catch (e) {
@@ -358,7 +161,7 @@ function extractTestCounts(reportDir: string): TestCounts {
}
}
} catch (error) {
process.stderr.write(`Error reading report from ${reportDir}: ${error}\n`)
console.error(`Error reading report from ${reportDir}:`, error)
}
return counts
@@ -368,15 +171,13 @@ function extractTestCounts(reportDir: string): TestCounts {
const reportDir = process.argv[2]
if (!reportDir) {
process.stderr.write(
'Usage: extract-playwright-counts.ts <report-directory>\n'
)
console.error('Usage: extract-playwright-counts.ts <report-directory>')
process.exit(1)
}
const counts = extractTestCounts(reportDir)
// Output as JSON for easy parsing in shell script
process.stdout.write(JSON.stringify(counts) + '\n')
console.log(JSON.stringify(counts))
export { extractTestCounts }

View File

@@ -252,10 +252,6 @@ else
total_flaky=0
total_skipped=0
total_tests=0
total_screenshot_failures=0
total_expectation_failures=0
total_timeout_failures=0
total_other_failures=0
# Parse counts and calculate totals
IFS='|' read -r -a counts_array <<< "$all_counts"
@@ -269,10 +265,6 @@ else
flaky=$(echo "$counts_json" | jq -r '.flaky // 0')
skipped=$(echo "$counts_json" | jq -r '.skipped // 0')
total=$(echo "$counts_json" | jq -r '.total // 0')
screenshot=$(echo "$counts_json" | jq -r '.failureTypes.screenshot // 0')
expectation=$(echo "$counts_json" | jq -r '.failureTypes.expectation // 0')
timeout=$(echo "$counts_json" | jq -r '.failureTypes.timeout // 0')
other=$(echo "$counts_json" | jq -r '.failureTypes.other // 0')
else
# Fallback parsing without jq
passed=$(echo "$counts_json" | sed -n 's/.*"passed":\([0-9]*\).*/\1/p')
@@ -280,21 +272,13 @@ else
flaky=$(echo "$counts_json" | sed -n 's/.*"flaky":\([0-9]*\).*/\1/p')
skipped=$(echo "$counts_json" | sed -n 's/.*"skipped":\([0-9]*\).*/\1/p')
total=$(echo "$counts_json" | sed -n 's/.*"total":\([0-9]*\).*/\1/p')
screenshot=0
expectation=0
timeout=0
other=0
fi
total_passed=$((total_passed + ${passed:-0}))
total_failed=$((total_failed + ${failed:-0}))
total_flaky=$((total_flaky + ${flaky:-0}))
total_skipped=$((total_skipped + ${skipped:-0}))
total_tests=$((total_tests + ${total:-0}))
total_screenshot_failures=$((total_screenshot_failures + ${screenshot:-0}))
total_expectation_failures=$((total_expectation_failures + ${expectation:-0}))
total_timeout_failures=$((total_timeout_failures + ${timeout:-0}))
total_other_failures=$((total_other_failures + ${other:-0}))
fi
done
unset IFS
@@ -318,98 +302,35 @@ else
comment="$COMMENT_MARKER
## 🎭 Playwright Test Results
$status_icon **$status_text** • ⏰ $(date -u '+%m/%d/%Y, %I:%M:%S %p') UTC"
$status_icon **$status_text**
⏰ Completed at: $(date -u '+%m/%d/%Y, %I:%M:%S %p') UTC"
# Add summary counts if we have test data
if [ $total_tests -gt 0 ]; then
comment="$comment
**$total_passed** ✅ • **$total_failed** $([ $total_failed -gt 0 ] && echo '❌' || echo '✅') • **$total_flaky** $([ $total_flaky -gt 0 ] && echo '⚠️' || echo '✅') • **$total_skipped** ⏭️ • **$total_tests** total"
# Add failure breakdown if there are failures
if [ $total_failed -gt 0 ]; then
comment="$comment
**Failure Breakdown:** 📸 $total_screenshot_failures screenshot • ✓ $total_expectation_failures expectation • ⏱️ $total_timeout_failures timeout • ❓ $total_other_failures other"
fi
### 📈 Summary
- **Total Tests:** $total_tests
- **Passed:** $total_passed
- **Failed:** $total_failed $([ $total_failed -gt 0 ] && echo '❌' || echo '')
- **Flaky:** $total_flaky $([ $total_flaky -gt 0 ] && echo '⚠️' || echo '')
- **Skipped:** $total_skipped $([ $total_skipped -gt 0 ] && echo '⏭️' || echo '')"
fi
# Collect all failing tests across browsers
all_failing_tests=""
i=0
IFS=' ' read -r -a browser_array <<< "$BROWSERS"
for counts_json in "${counts_array[@]}"; do
[ -z "$counts_json" ] && { i=$((i + 1)); continue; }
browser="${browser_array[$i]:-}"
if [ "$counts_json" != "{}" ] && [ -n "$counts_json" ]; then
if command -v jq > /dev/null 2>&1; then
failing_tests=$(echo "$counts_json" | jq -r '.failingTests // [] | .[]' 2>/dev/null || echo "")
if [ -n "$failing_tests" ]; then
# Process each failing test
while IFS= read -r test_json; do
[ -z "$test_json" ] && continue
test_name=$(echo "$test_json" | jq -r '.name // "Unknown test"')
test_file=$(echo "$test_json" | jq -r '.filePath // "unknown"')
test_line=$(echo "$test_json" | jq -r '.line // 0')
trace_path=$(echo "$test_json" | jq -r '.tracePath // ""')
# Build GitHub source link (assumes ComfyUI_frontend repo)
source_link="https://github.com/$GITHUB_REPOSITORY/blob/$BRANCH_NAME/$test_file#L$test_line"
# Build trace viewer link if trace exists
if [ -n "$trace_path" ] && [ "$trace_path" != "null" ]; then
# Extract trace filename from path
trace_file=$(basename "$trace_path")
url="${url_array[$i]:-}"
if [ "$url" != "failed" ] && [ -n "$url" ]; then
base_url="${url%/index.html}"
trace_viewer_link="${base_url}/trace/?trace=${base_url}/data/${trace_file}"
fi
fi
# Format failing test entry
if [ -n "$all_failing_tests" ]; then
all_failing_tests="$all_failing_tests
"
fi
if [ -n "$trace_viewer_link" ]; then
all_failing_tests="${all_failing_tests}- **[$test_name]($source_link)** \`$browser\` • [View trace]($trace_viewer_link)"
else
all_failing_tests="${all_failing_tests}- **[$test_name]($source_link)** \`$browser\`"
fi
done < <(echo "$counts_json" | jq -c '.failingTests[]?' 2>/dev/null || echo "")
fi
fi
fi
i=$((i + 1))
done
unset IFS
# Add failing tests section if there are failures
if [ $total_failed -gt 0 ] && [ -n "$all_failing_tests" ]; then
comment="$comment
### ❌ Failed Tests
$all_failing_tests"
fi
comment="$comment
<details>
<summary>📊 Test Reports by Browser</summary>
"
### 📊 Test Reports by Browser"
# Add browser results with individual counts
i=0
IFS=' ' read -r -a browser_array <<< "$BROWSERS"
IFS=' ' read -r -a url_array <<< "$urls"
for counts_json in "${counts_array[@]}"; do
[ -z "$counts_json" ] && { i=$((i + 1)); continue; }
browser="${browser_array[$i]:-}"
url="${url_array[$i]:-}"
if [ "$url" != "failed" ] && [ -n "$url" ]; then
# Parse individual browser counts
if [ "$counts_json" != "{}" ] && [ -n "$counts_json" ]; then
@@ -426,7 +347,7 @@ $all_failing_tests"
b_skipped=$(echo "$counts_json" | sed -n 's/.*"skipped":\([0-9]*\).*/\1/p')
b_total=$(echo "$counts_json" | sed -n 's/.*"total":\([0-9]*\).*/\1/p')
fi
if [ -n "$b_total" ] && [ "$b_total" != "0" ]; then
counts_str=" • ✅ $b_passed / ❌ $b_failed / ⚠️ $b_flaky / ⏭️ $b_skipped"
else
@@ -435,20 +356,21 @@ $all_failing_tests"
else
counts_str=""
fi
comment="$comment
- **${browser}**: [View Report](${url})${counts_str}"
- **${browser}**: [View Report](${url})${counts_str}"
else
comment="$comment
- **${browser}**: Deployment failed"
- **${browser}**: Deployment failed"
fi
i=$((i + 1))
done
unset IFS
comment="$comment
</details>"
---
🎉 Click on the links above to view detailed test results for each browser configuration."
post_comment "$comment"
fi

View File

@@ -12,7 +12,7 @@
<div class="mx-1 flex flex-col items-end gap-1">
<div class="flex items-center gap-2">
<div
v-if="managerState.shouldShowManagerButtons.value"
v-if="managerState.shouldShowManagerButtons.value && isDesktop"
class="pointer-events-auto flex h-12 shrink-0 items-center rounded-lg border border-interface-stroke bg-comfy-menu-bg px-2 shadow-interface"
>
<Button

View File

@@ -0,0 +1,213 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import IconTextButton from './IconTextButton.vue'
const meta: Meta<typeof IconTextButton> = {
title: 'Components/Button/IconTextButton',
component: IconTextButton,
tags: ['autodocs'],
argTypes: {
label: {
control: 'text'
},
size: {
control: { type: 'select' },
options: ['sm', 'md']
},
type: {
control: { type: 'select' },
options: ['primary', 'secondary', 'transparent']
},
border: {
control: 'boolean',
description: 'Toggle border attribute'
},
disabled: {
control: 'boolean',
description: 'Toggle disable status'
},
iconPosition: {
control: { type: 'select' },
options: ['left', 'right']
},
onClick: { action: 'clicked' }
}
}
export default meta
type Story = StoryObj<typeof meta>
export const Primary: Story = {
render: (args) => ({
components: { IconTextButton },
setup() {
return { args }
},
template: `
<IconTextButton v-bind="args">
<template #icon>
<i class="icon-[lucide--package] size-4" />
</template>
</IconTextButton>
`
}),
args: {
label: 'Deploy',
type: 'primary',
size: 'md'
}
}
export const Secondary: Story = {
render: (args) => ({
components: { IconTextButton },
setup() {
return { args }
},
template: `
<IconTextButton v-bind="args">
<template #icon>
<i class="icon-[lucide--settings] size-4" />
</template>
</IconTextButton>
`
}),
args: {
label: 'Settings',
type: 'secondary',
size: 'md'
}
}
export const Transparent: Story = {
render: (args) => ({
components: { IconTextButton },
setup() {
return { args }
},
template: `
<IconTextButton v-bind="args">
<template #icon>
<i class="icon-[lucide--x] size-4" />
</template>
</IconTextButton>
`
}),
args: {
label: 'Cancel',
type: 'transparent',
size: 'md'
}
}
export const WithIconRight: Story = {
render: (args) => ({
components: { IconTextButton },
setup() {
return { args }
},
template: `
<IconTextButton v-bind="args">
<template #icon>
<i class="icon-[lucide--chevron-right] size-4" />
</template>
</IconTextButton>
`
}),
args: {
label: 'Next',
type: 'primary',
size: 'md',
iconPosition: 'right'
}
}
export const Small: Story = {
render: (args) => ({
components: { IconTextButton },
setup() {
return { args }
},
template: `
<IconTextButton v-bind="args">
<template #icon>
<i class="icon-[lucide--save] size-3" />
</template>
</IconTextButton>
`
}),
args: {
label: 'Save',
type: 'primary',
size: 'sm'
}
}
export const AllVariants: Story = {
render: () => ({
components: {
IconTextButton
},
template: `
<div class="flex flex-col gap-4">
<div class="flex gap-2 items-center">
<IconTextButton label="Download" type="primary" size="sm" @click="() => {}">
<template #icon>
<i class="icon-[lucide--download] size-3" />
</template>
</IconTextButton>
<IconTextButton label="Download" type="primary" size="md" @click="() => {}">
<template #icon>
<i class="icon-[lucide--download] size-4" />
</template>
</IconTextButton>
</div>
<div class="flex gap-2 items-center">
<IconTextButton label="Settings" type="secondary" size="sm" @click="() => {}">
<template #icon>
<i class="icon-[lucide--settings] size-3" />
</template>
</IconTextButton>
<IconTextButton label="Settings" type="secondary" size="md" @click="() => {}">
<template #icon>
<i class="icon-[lucide--settings] size-4" />
</template>
</IconTextButton>
</div>
<div class="flex gap-2 items-center">
<IconTextButton label="Delete" type="transparent" size="sm" @click="() => {}">
<template #icon>
<i class="icon-[lucide--trash-2] size-3" />
</template>
</IconTextButton>
<IconTextButton label="Delete" type="transparent" size="md" @click="() => {}">
<template #icon>
<i class="icon-[lucide--trash-2] size-4" />
</template>
</IconTextButton>
</div>
<div class="flex gap-2 items-center">
<IconTextButton label="Next" type="primary" size="md" iconPosition="right" @click="() => {}">
<template #icon>
<i class="icon-[lucide--chevron-right] size-4" />
</template>
</IconTextButton>
<IconTextButton label="Previous" type="secondary" size="md" @click="() => {}">
<template #icon>
<i class="icon-[lucide--chevron-left] size-4" />
</template>
</IconTextButton>
<IconTextButton label="Save File" type="primary" size="md" @click="() => {}">
<template #icon>
<i class="icon-[lucide--save] size-4" />
</template>
</IconTextButton>
</div>
</div>
`
}),
parameters: {
controls: { disable: true },
actions: { disable: true }
}
}

View File

@@ -0,0 +1,58 @@
<template>
<Button
v-bind="$attrs"
unstyled
:class="buttonStyle"
:disabled="disabled"
@click="onClick"
>
<slot v-if="iconPosition !== 'right'" name="icon"></slot>
<span>{{ label }}</span>
<slot v-if="iconPosition === 'right'" name="icon"></slot>
</Button>
</template>
<script setup lang="ts">
import Button from 'primevue/button'
import { computed } from 'vue'
import type { BaseButtonProps } from '@/types/buttonTypes'
import {
getBaseButtonClasses,
getBorderButtonTypeClasses,
getButtonSizeClasses,
getButtonTypeClasses
} from '@/types/buttonTypes'
import { cn } from '@/utils/tailwindUtil'
defineOptions({
inheritAttrs: false
})
interface IconTextButtonProps extends BaseButtonProps {
iconPosition?: 'left' | 'right'
label: string
onClick?: () => void
}
const {
size = 'md',
type = 'primary',
border = false,
disabled = false,
class: className,
iconPosition = 'left',
label,
onClick
} = defineProps<IconTextButtonProps>()
const buttonStyle = computed(() => {
const baseClasses = `${getBaseButtonClasses()} justify-start gap-2`
const sizeClasses = getButtonSizeClasses(size)
const typeClasses = border
? getBorderButtonTypeClasses(type)
: getButtonTypeClasses(type)
return cn(baseClasses, sizeClasses, typeClasses, className)
})
</script>

View File

@@ -1,6 +1,6 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import Button from '@/components/ui/button/Button.vue'
import IconTextButton from './IconTextButton.vue'
import MoreButton from './MoreButton.vue'
const meta: Meta<typeof MoreButton> = {
@@ -17,26 +17,30 @@ type Story = StoryObj<typeof MoreButton>
export const Basic: Story = {
render: () => ({
components: { MoreButton, Button },
components: { MoreButton, IconTextButton },
template: `
<div style="height: 200px; display: flex; align-items: center; justify-content: center;">
<MoreButton>
<template #default="{ close }">
<Button
variant="textonly"
<IconTextButton
type="transparent"
label="Settings"
@click="() => { close() }"
>
<i class="icon-[lucide--download] size-4" />
<span>Settings</span>
</Button>
<template #icon>
<i class="icon-[lucide--download] size-4" />
</template>
</IconTextButton>
<Button
variant="textonly"
<IconTextButton
type="transparent"
label="Profile"
@click="() => { close() }"
>
<i class="icon-[lucide--scroll-text] size-4" />
<span>Profile</span>
</Button>
<template #icon>
<i class="icon-[lucide--scroll-text] size-4" />
</template>
</IconTextButton>
</template>
</MoreButton>
</div>

View File

@@ -21,7 +21,6 @@
@keyup.enter.capture.stop="blurInputElement"
@keyup.escape.stop="cancelEditing"
@click.stop
@contextmenu.stop
@pointerdown.stop.capture
@pointermove.stop.capture
/>

View File

@@ -1,134 +0,0 @@
import { mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
import enMessages from '@/locales/en/main.json' with { type: 'json' }
import UserCredit from './UserCredit.vue'
vi.mock('firebase/app', () => ({
initializeApp: vi.fn(),
getApp: vi.fn()
}))
vi.mock('firebase/auth', () => ({
getAuth: vi.fn(),
setPersistence: vi.fn(),
browserLocalPersistence: {},
onAuthStateChanged: vi.fn(),
signInWithEmailAndPassword: vi.fn(),
signOut: vi.fn()
}))
vi.mock('pinia')
const mockBalance = vi.hoisted(() => ({
value: {
amount_micros: 100_000,
effective_balance_micros: 100_000,
currency: 'usd'
}
}))
const mockIsFetchingBalance = vi.hoisted(() => ({ value: false }))
vi.mock('@/stores/firebaseAuthStore', () => ({
useFirebaseAuthStore: vi.fn(() => ({
balance: mockBalance.value,
isFetchingBalance: mockIsFetchingBalance.value
}))
}))
describe('UserCredit', () => {
beforeEach(() => {
vi.clearAllMocks()
mockBalance.value = {
amount_micros: 100_000,
effective_balance_micros: 100_000,
currency: 'usd'
}
mockIsFetchingBalance.value = false
})
const mountComponent = (props = {}) => {
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: { en: enMessages }
})
return mount(UserCredit, {
props,
global: {
plugins: [i18n],
stubs: {
Skeleton: true,
Tag: true
}
}
})
}
describe('effective_balance_micros handling', () => {
it('uses effective_balance_micros when present (positive balance)', () => {
mockBalance.value = {
amount_micros: 200_000,
effective_balance_micros: 150_000,
currency: 'usd'
}
const wrapper = mountComponent()
expect(wrapper.text()).toContain('Credits')
})
it('uses effective_balance_micros when zero', () => {
mockBalance.value = {
amount_micros: 100_000,
effective_balance_micros: 0,
currency: 'usd'
}
const wrapper = mountComponent()
expect(wrapper.text()).toContain('0')
})
it('uses effective_balance_micros when negative', () => {
mockBalance.value = {
amount_micros: 0,
effective_balance_micros: -50_000,
currency: 'usd'
}
const wrapper = mountComponent()
expect(wrapper.text()).toContain('-')
})
it('falls back to amount_micros when effective_balance_micros is missing', () => {
mockBalance.value = {
amount_micros: 100_000,
currency: 'usd'
} as typeof mockBalance.value
const wrapper = mountComponent()
expect(wrapper.text()).toContain('Credits')
})
it('falls back to 0 when both effective_balance_micros and amount_micros are missing', () => {
mockBalance.value = {
currency: 'usd'
} as typeof mockBalance.value
const wrapper = mountComponent()
expect(wrapper.text()).toContain('0')
})
})
describe('loading state', () => {
it('shows skeleton when loading', () => {
mockIsFetchingBalance.value = true
const wrapper = mountComponent()
expect(wrapper.findComponent({ name: 'Skeleton' }).exists()).toBe(true)
})
})
})

View File

@@ -14,7 +14,13 @@
class="p-1 text-amber-400"
>
<template #icon>
<i class="icon-[lucide--component]" />
<i
:class="
flags.subscriptionTiersEnabled
? 'icon-[lucide--component]'
: 'pi pi-dollar'
"
/>
</template>
</Tag>
<div :class="textClass">
@@ -30,6 +36,7 @@ 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'
const { textClass, showCreditsOnly } = defineProps<{
@@ -38,14 +45,13 @@ const { textClass, showCreditsOnly } = defineProps<{
}>()
const authStore = useFirebaseAuthStore()
const { flags } = useFeatureFlags()
const balanceLoading = computed(() => authStore.isFetchingBalance)
const { t, locale } = useI18n()
const formattedBalance = computed(() => {
const cents =
authStore.balance?.effective_balance_micros ??
authStore.balance?.amount_micros ??
0
// Backend returns cents despite the *_micros naming convention.
const cents = authStore.balance?.amount_micros ?? 0
const amount = formatCreditsFromCents({
cents,
locale: locale.value
@@ -54,10 +60,8 @@ const formattedBalance = computed(() => {
})
const formattedCreditsOnly = computed(() => {
const cents =
authStore.balance?.effective_balance_micros ??
authStore.balance?.amount_micros ??
0
// Backend returns cents despite the *_micros naming convention.
const cents = authStore.balance?.amount_micros ?? 0
const amount = formatCreditsFromCents({
cents,
locale: locale.value,

View File

@@ -22,17 +22,16 @@
<template #header-right-area>
<div class="flex gap-2">
<Button
<IconTextButton
v-if="filteredCount !== totalCount"
variant="secondary"
size="lg"
type="secondary"
:label="$t('templateWorkflows.resetFilters', 'Clear Filters')"
@click="resetFilters"
>
<i class="icon-[lucide--filter-x]" />
<span>{{
$t('templateWorkflows.resetFilters', 'Clear Filters')
}}</span>
</Button>
<template #icon>
<i class="icon-[lucide--filter-x]" />
</template>
</IconTextButton>
</div>
</template>
@@ -383,6 +382,7 @@ import ProgressSpinner from 'primevue/progressspinner'
import { computed, onBeforeUnmount, onMounted, provide, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import IconTextButton from '@/components/button/IconTextButton.vue'
import CardBottom from '@/components/card/CardBottom.vue'
import CardContainer from '@/components/card/CardContainer.vue'
import CardTop from '@/components/card/CardTop.vue'

View File

@@ -4,17 +4,20 @@
v-if="isCloud"
class="flex w-full items-center justify-between gap-2 py-2 px-4"
>
<Button
variant="textonly"
<IconTextButton
:label="$t('missingNodes.cloud.learnMore')"
type="transparent"
size="sm"
icon-position="left"
as="a"
href="https://www.comfy.org/cloud"
target="_blank"
rel="noopener noreferrer"
>
<i class="icon-[lucide--info]"></i>
<span>{{ $t('missingNodes.cloud.learnMore') }}</span>
</Button>
<template #icon>
<i class="icon-[lucide--info]"></i>
</template>
</IconTextButton>
<Button variant="secondary" size="md" @click="handleGotItClick">{{
$t('missingNodes.cloud.gotIt')
}}</Button>
@@ -47,6 +50,7 @@
import { computed, nextTick, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import IconTextButton from '@/components/button/IconTextButton.vue'
import Button from '@/components/ui/button/Button.vue'
import { isCloud } from '@/platform/distribution/types'
import { useToastStore } from '@/platform/updates/common/toastStore'

View File

@@ -1,5 +1,6 @@
<template>
<div class="flex w-112 flex-col gap-8 p-8">
<!-- 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">
@@ -65,32 +66,91 @@
@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') }}
</h1>
<p class="my-0 text-base">
{{ $t('credits.topUp.insufficientMessage') }}
</p>
</div>
<!-- Balance Section -->
<div class="flex items-center justify-between">
<div class="flex w-full flex-col gap-2">
<div class="text-base text-muted">
{{ $t('credits.yourCreditBalance') }}
</div>
<div class="flex w-full items-center justify-between">
<UserCredit text-class="text-2xl" />
<Button
outlined
severity="secondary"
:label="$t('credits.topUp.seeDetails')"
icon="pi pi-arrow-up-right"
@click="handleSeeDetails"
/>
</div>
</div>
</div>
<!-- Amount Input Section -->
<div class="flex flex-col gap-2">
<span class="text-sm text-muted"
>{{ $t('credits.topUp.quickPurchase') }}:</span
>
<div class="grid grid-cols-[2fr_1fr] gap-2">
<LegacyCreditTopUpOption
v-for="amount in amountOptions"
:key="amount"
:amount="amount"
:preselected="amount === preselectedAmountOption"
/>
<LegacyCreditTopUpOption :amount="100" :preselected="false" editable />
</div>
</div>
</div>
</template>
<script setup lang="ts">
import Button from 'primevue/button'
import { useToast } from 'primevue/usetoast'
import { ref } from 'vue'
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 } = defineProps<{
const {
isInsufficientCredits = false,
amountOptions = [5, 10, 20, 50],
preselectedAmountOption = 10
} = defineProps<{
isInsufficientCredits?: boolean
amountOptions?: number[]
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()
@@ -142,4 +202,8 @@ const handleBuy = async () => {
loading.value = false
}
}
const handleSeeDetails = async () => {
await authActions.accessBillingPortal()
}
</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

@@ -263,7 +263,7 @@ function onMenuHide() {
}
onMounted(() => {
registerNodeOptionsInstance({ toggle, show, hide, isOpen })
registerNodeOptionsInstance({ toggle, hide, isOpen })
})
onUnmounted(() => {

View File

@@ -22,8 +22,6 @@
v-model:model-config="modelConfig"
v-model:camera-config="cameraConfig"
v-model:light-config="lightConfig"
:is-splat-model="isSplatModel"
:is-ply-model="isPlyModel"
@update-background-image="handleBackgroundImageUpdate"
@export-model="handleExportModel"
/>
@@ -111,8 +109,6 @@ const {
// other state
isRecording,
isPreview,
isSplatModel,
isPlyModel,
hasRecording,
recordingDuration,
animations,

View File

@@ -47,8 +47,6 @@
v-if="showModelControls"
v-model:material-mode="modelConfig!.materialMode"
v-model:up-direction="modelConfig!.upDirection"
:hide-material-mode="isSplatModel"
:is-ply-model="isPlyModel"
/>
<CameraControls
@@ -87,11 +85,6 @@ import type {
SceneConfig
} from '@/extensions/core/load3d/interfaces'
const { isSplatModel = false, isPlyModel = false } = defineProps<{
isSplatModel?: boolean
isPlyModel?: boolean
}>()
const sceneConfig = defineModel<SceneConfig>('sceneConfig')
const modelConfig = defineModel<ModelConfig>('modelConfig')
const cameraConfig = defineModel<CameraConfig>('cameraConfig')
@@ -108,10 +101,6 @@ const categoryLabels: Record<string, string> = {
}
const availableCategories = computed(() => {
if (isSplatModel) {
return ['scene', 'model', 'camera']
}
return ['scene', 'model', 'camera', 'light', 'export']
})

View File

@@ -46,8 +46,6 @@
<ModelControls
v-model:up-direction="viewer.upDirection.value"
v-model:material-mode="viewer.materialMode.value"
:hide-material-mode="viewer.isSplatModel.value"
:is-ply-model="viewer.isPlyModel.value"
/>
</div>
@@ -58,13 +56,13 @@
/>
</div>
<div v-if="!viewer.isSplatModel.value" class="space-y-4 p-2">
<div class="space-y-4 p-2">
<LightControls
v-model:light-intensity="viewer.lightIntensity.value"
/>
</div>
<div v-if="!viewer.isSplatModel.value" class="space-y-4 p-2">
<div class="space-y-4 p-2">
<ExportControls @export-model="viewer.exportModel" />
</div>
</div>

View File

@@ -28,7 +28,7 @@
</div>
</div>
<div v-if="!hideMaterialMode" class="show-material-mode relative">
<div class="show-material-mode relative">
<Button
class="p-button-rounded p-button-text"
@click="toggleMaterialMode"
@@ -71,11 +71,6 @@ import type {
} from '@/extensions/core/load3d/interfaces'
import { t } from '@/i18n'
const { hideMaterialMode = false, isPlyModel = false } = defineProps<{
hideMaterialMode?: boolean
isPlyModel?: boolean
}>()
const materialMode = defineModel<MaterialMode>('materialMode')
const upDirection = defineModel<UpDirection>('upDirection')
@@ -100,11 +95,6 @@ const materialModes = computed(() => {
//'depth' disable for now
]
// Only show pointCloud mode for PLY files (point cloud rendering)
if (isPlyModel) {
modes.splice(1, 0, 'pointCloud')
}
return modes
})

View File

@@ -10,7 +10,7 @@
/>
</div>
<div v-if="!hideMaterialMode">
<div>
<label>{{ $t('load3d.materialMode') }}</label>
<Select
v-model="materialMode"
@@ -32,11 +32,6 @@ import type {
} from '@/extensions/core/load3d/interfaces'
import { t } from '@/i18n'
const { hideMaterialMode = false, isPlyModel = false } = defineProps<{
hideMaterialMode?: boolean
isPlyModel?: boolean
}>()
const upDirection = defineModel<UpDirection>('upDirection')
const materialMode = defineModel<MaterialMode>('materialMode')
@@ -51,22 +46,10 @@ const upDirectionOptions = [
]
const materialModeOptions = computed(() => {
const options = [
{ label: t('load3d.materialModes.original'), value: 'original' }
]
if (isPlyModel) {
options.push({
label: t('load3d.materialModes.pointCloud'),
value: 'pointCloud'
})
}
options.push(
return [
{ label: t('load3d.materialModes.original'), value: 'original' },
{ label: t('load3d.materialModes.normal'), value: 'normal' },
{ label: t('load3d.materialModes.wireframe'), value: 'wireframe' }
)
return options
]
})
</script>

View File

@@ -2,11 +2,7 @@
https://github.com/Nuked88/ComfyUI-N-Sidebar/blob/7ae7da4a9761009fb6629bc04c683087a3e168db/app/js/functions/sb_fn.js#L149
-->
<template>
<LGraphNodePreview
v-if="shouldRenderVueNodes"
:node-def="nodeDef"
:position="position"
/>
<LGraphNodePreview v-if="shouldRenderVueNodes" :node-def="nodeDef" />
<div v-else class="_sb_node_preview bg-component-node-background">
<div class="_sb_table">
<div
@@ -96,9 +92,8 @@ import { useWidgetStore } from '@/stores/widgetStore'
import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
import { renderMarkdownToHtml } from '@/utils/markdownRendererUtil'
const { nodeDef, position = 'absolute' } = defineProps<{
const { nodeDef } = defineProps<{
nodeDef: ComfyNodeDefV2
position?: 'absolute' | 'relative'
}>()
const { shouldRenderVueNodes } = useVueFeatureFlags()

View File

@@ -8,15 +8,20 @@
/>
<div class="flex items-center justify-between px-3">
<Button
class="grow gap-1 justify-center"
variant="secondary"
size="sm"
<IconTextButton
class="grow gap-1 p-2 text-center font-inter text-[12px] leading-none hover:opacity-90 justify-center"
type="secondary"
:label="t('sideToolbar.queueProgressOverlay.showAssets')"
:aria-label="t('sideToolbar.queueProgressOverlay.showAssets')"
@click="$emit('showAssets')"
>
<i class="icon-[comfy--image-ai-edit] size-4" />
<span>{{ t('sideToolbar.queueProgressOverlay.showAssets') }}</span>
</Button>
<template #icon>
<div
class="pointer-events-none block size-4 shrink-0 leading-none icon-[comfy--image-ai-edit]"
aria-hidden="true"
/>
</template>
</IconTextButton>
<div class="ml-4 inline-flex items-center">
<div
class="inline-flex h-6 items-center text-[12px] leading-none text-text-primary opacity-90"
@@ -73,6 +78,7 @@
import { ref } from 'vue'
import { useI18n } from 'vue-i18n'
import IconTextButton from '@/components/button/IconTextButton.vue'
import Button from '@/components/ui/button/Button.vue'
import type {
JobGroup,

View File

@@ -46,18 +46,19 @@
<div
class="flex flex-col items-stretch rounded-lg border border-interface-stroke bg-interface-panel-surface px-2 py-3 font-inter"
>
<Button
class="w-full justify-start"
variant="textonly"
size="sm"
<IconTextButton
class="w-full justify-start gap-2 bg-transparent p-2 font-inter text-[12px] leading-none text-text-primary hover:bg-transparent hover:opacity-90"
type="transparent"
:label="t('sideToolbar.queueProgressOverlay.clearHistory')"
:aria-label="t('sideToolbar.queueProgressOverlay.clearHistory')"
@click="onClearHistoryFromMenu"
>
<i class="icon-[lucide--file-x-2] size-4 text-muted" />
<span>{{
t('sideToolbar.queueProgressOverlay.clearHistory')
}}</span>
</Button>
<template #icon>
<i
class="icon-[lucide--file-x-2] block size-4 leading-none text-text-secondary"
/>
</template>
</IconTextButton>
</div>
</Popover>
</div>
@@ -70,6 +71,7 @@ import type { PopoverMethods } from 'primevue/popover'
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import IconTextButton from '@/components/button/IconTextButton.vue'
import Button from '@/components/ui/button/Button.vue'
import { buildTooltipConfig } from '@/composables/useTooltipConfig'
import { isCloud } from '@/platform/distribution/types'

View File

@@ -20,23 +20,24 @@
<div v-if="entry.kind === 'divider'" class="px-2 py-1">
<div class="h-px bg-interface-stroke" />
</div>
<Button
<IconTextButton
v-else
class="w-full justify-start bg-transparent"
variant="textonly"
size="sm"
class="w-full justify-start gap-2 bg-transparent p-2 font-inter text-[12px] leading-none text-text-primary hover:bg-interface-panel-hover-surface"
type="transparent"
:label="entry.label"
:aria-label="entry.label"
@click="onEntry(entry)"
>
<i
v-if="entry.icon"
:class="[
entry.icon,
'block size-4 shrink-0 leading-none text-text-secondary'
]"
/>
<span>{{ entry.label }}</span>
</Button>
<template #icon>
<i
v-if="entry.icon"
:class="[
entry.icon,
'block size-4 shrink-0 leading-none text-text-secondary'
]"
/>
</template>
</IconTextButton>
</template>
</div>
</Popover>
@@ -46,7 +47,7 @@
import Popover from 'primevue/popover'
import { ref } from 'vue'
import Button from '@/components/ui/button/Button.vue'
import IconTextButton from '@/components/button/IconTextButton.vue'
import type { MenuEntry } from '@/composables/queue/useJobMenu'
defineProps<{ entries: MenuEntry[] }>()

View File

@@ -58,28 +58,31 @@
{{ t('queue.jobDetails.errorMessage') }}
</div>
<div class="flex items-center justify-between gap-4">
<Button
class="justify-start px-0"
variant="muted-textonly"
size="sm"
<IconTextButton
class="h-6 justify-start gap-2 bg-transparent px-0 text-[0.75rem] leading-none text-text-secondary hover:opacity-90"
type="transparent"
:label="copyAriaLabel"
:aria-label="copyAriaLabel"
icon-position="right"
@click.stop="copyErrorMessage"
>
<span>{{ copyAriaLabel }}</span>
<i class="icon-[lucide--copy] block size-3.5 leading-none" />
</Button>
<Button
class="justify-start px-0"
variant="muted-textonly"
size="sm"
<template #icon>
<i class="icon-[lucide--copy] block size-3.5 leading-none" />
</template>
</IconTextButton>
<IconTextButton
class="h-6 justify-start gap-2 bg-transparent px-0 text-[0.75rem] leading-none text-text-secondary hover:opacity-90"
type="transparent"
:label="t('queue.jobDetails.report')"
icon-position="right"
@click.stop="reportJobError"
>
<span>{{ t('queue.jobDetails.report') }}</span>
<i
class="icon-[lucide--message-circle-warning] block size-3.5 leading-none"
/>
</Button>
<template #icon>
<i
class="icon-[lucide--message-circle-warning] block size-3.5 leading-none"
/>
</template>
</IconTextButton>
</div>
<div
class="col-span-2 mt-2 rounded bg-interface-panel-hover-surface px-4 py-2 text-[0.75rem] leading-normal text-text-secondary"
@@ -95,6 +98,7 @@
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import IconTextButton from '@/components/button/IconTextButton.vue'
import Button from '@/components/ui/button/Button.vue'
import { useCopyToClipboard } from '@/composables/useCopyToClipboard'
import { isCloud } from '@/platform/distribution/types'

View File

@@ -47,34 +47,41 @@
<div
class="flex min-w-[12rem] flex-col items-stretch rounded-lg border border-interface-stroke bg-interface-panel-surface px-2 py-3"
>
<Button
class="w-full justify-between"
variant="textonly"
size="sm"
<IconTextButton
class="w-full justify-between gap-1 bg-transparent p-2 font-inter text-[12px] leading-none text-text-primary hover:bg-transparent hover:opacity-90"
type="transparent"
icon-position="right"
:label="t('sideToolbar.queueProgressOverlay.filterAllWorkflows')"
:aria-label="
t('sideToolbar.queueProgressOverlay.filterAllWorkflows')
"
@click="selectWorkflowFilter('all')"
>
<span>{{
t('sideToolbar.queueProgressOverlay.filterAllWorkflows')
}}</span>
<i
v-if="selectedWorkflowFilter === 'all'"
class="icon-[lucide--check] size-4"
/>
</Button>
<template #icon>
<i
v-if="selectedWorkflowFilter === 'all'"
class="icon-[lucide--check] block size-4 leading-none text-text-secondary"
/>
</template>
</IconTextButton>
<div class="mx-2 mt-1 h-px" />
<Button
class="w-full justify-between"
variant="textonly"
<IconTextButton
class="w-full justify-between gap-1 bg-transparent p-2 font-inter text-[12px] leading-none text-text-primary hover:bg-transparent hover:opacity-90"
type="transparent"
icon-position="right"
:label="t('sideToolbar.queueProgressOverlay.filterCurrentWorkflow')"
:aria-label="
t('sideToolbar.queueProgressOverlay.filterCurrentWorkflow')
"
@click="selectWorkflowFilter('current')"
>
<span>{{
t('sideToolbar.queueProgressOverlay.filterCurrentWorkflow')
}}</span>
<i
v-if="selectedWorkflowFilter === 'current'"
class="icon-[lucide--check] block size-4 leading-none text-text-secondary"
/>
</Button>
<template #icon>
<i
v-if="selectedWorkflowFilter === 'current'"
class="icon-[lucide--check] block size-4 leading-none text-text-secondary"
/>
</template>
</IconTextButton>
</div>
</Popover>
<Button
@@ -108,18 +115,21 @@
class="flex min-w-[12rem] flex-col items-stretch rounded-lg border border-interface-stroke bg-interface-panel-surface px-2 py-3"
>
<template v-for="(mode, index) in jobSortModes" :key="mode">
<Button
class="w-full justify-between"
variant="textonly"
size="sm"
<IconTextButton
class="w-full justify-between gap-1 bg-transparent p-2 font-inter text-[12px] leading-none text-text-primary hover:bg-transparent hover:opacity-90"
type="transparent"
icon-position="right"
:label="sortLabel(mode)"
:aria-label="sortLabel(mode)"
@click="selectSortMode(mode)"
>
<span>{{ sortLabel(mode) }}</span>
<i
v-if="selectedSortMode === mode"
class="icon-[lucide--check] size-4 text-text-secondary"
/>
</Button>
<template #icon>
<i
v-if="selectedSortMode === mode"
class="icon-[lucide--check] block size-4 leading-none text-text-secondary"
/>
</template>
</IconTextButton>
<div
v-if="index < jobSortModes.length - 1"
class="mx-2 mt-1 h-px"
@@ -136,6 +146,7 @@ import Popover from 'primevue/popover'
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import IconTextButton from '@/components/button/IconTextButton.vue'
import Button from '@/components/ui/button/Button.vue'
import { jobSortModes, jobTabs } from '@/composables/queue/useJobList'
import type { JobSortMode, JobTab } from '@/composables/queue/useJobList'

View File

@@ -37,10 +37,15 @@
<template #header>
<!-- Job Detail View Header -->
<div v-if="isInFolderView" class="px-2 2xl:px-4">
<Button variant="secondary" size="lg" @click="exitFolderView">
<i class="icon-[lucide--arrow-left] size-4" />
<span>{{ $t('sideToolbar.backToAssets') }}</span>
</Button>
<IconTextButton
:label="$t('sideToolbar.backToAssets')"
type="secondary"
@click="exitFolderView"
>
<template #icon>
<i class="icon-[lucide--arrow-left] size-4" />
</template>
</IconTextButton>
</div>
<!-- Filter Bar -->
@@ -123,7 +128,7 @@
</Button>
</div>
</div>
<div class="flex shrink gap-2 pr-4 items-center-safe justify-end-safe">
<div class="flex shrink gap-2 pr-4 items-center-safe">
<template v-if="isCompact">
<!-- Compact mode: Icon only -->
<Button
@@ -139,18 +144,27 @@
</template>
<template v-else>
<!-- Normal mode: Icon + Text -->
<Button
<IconTextButton
v-if="shouldShowDeleteButton"
variant="secondary"
:label="$t('mediaAsset.selection.deleteSelected')"
type="secondary"
icon-position="right"
@click="handleDeleteSelected"
>
<span>{{ $t('mediaAsset.selection.deleteSelected') }}</span>
<i class="icon-[lucide--trash-2] size-4" />
</Button>
<Button variant="secondary" @click="handleDownloadSelected">
<span>{{ $t('mediaAsset.selection.downloadSelected') }}</span>
<i class="icon-[lucide--download] size-4" />
</Button>
<template #icon>
<i class="icon-[lucide--trash-2] size-4" />
</template>
</IconTextButton>
<IconTextButton
:label="$t('mediaAsset.selection.downloadSelected')"
type="secondary"
icon-position="right"
@click="handleDownloadSelected"
>
<template #icon>
<i class="icon-[lucide--download] size-4" />
</template>
</IconTextButton>
</template>
</div>
</div>
@@ -170,6 +184,7 @@ import { useToast } from 'primevue/usetoast'
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import IconTextButton from '@/components/button/IconTextButton.vue'
import NoResultsPlaceholder from '@/components/common/NoResultsPlaceholder.vue'
import VirtualGrid from '@/components/common/VirtualGrid.vue'
import Load3dViewerContent from '@/components/load3d/Load3dViewerContent.vue'

View File

@@ -15,7 +15,7 @@
</template>
<template #end>
<div
class="touch:w-auto touch:opacity-100 flex flex-row overflow-hidden transition-all duration-200 motion-safe:w-0 motion-safe:opacity-0 motion-safe:group-focus-within/sidebar-tab:w-auto motion-safe:group-focus-within/sidebar-tab:opacity-100 motion-safe:group-hover/sidebar-tab:w-auto motion-safe:group-hover/sidebar-tab:opacity-100"
class="touch:w-auto touch:opacity-100 flex flex-row transition-all duration-200 motion-safe:w-0 motion-safe:opacity-0 motion-safe:group-focus-within/sidebar-tab:w-auto motion-safe:group-focus-within/sidebar-tab:opacity-100 motion-safe:group-hover/sidebar-tab:w-auto motion-safe:group-hover/sidebar-tab:opacity-100"
>
<slot name="tool-buttons" />
</div>

View File

@@ -19,32 +19,14 @@
</template>
<script setup lang="ts">
import { whenever } from '@vueuse/core'
import Button from 'primevue/button'
import { computed } from 'vue'
import NodeHelpContent from '@/components/node/NodeHelpContent.vue'
import { useSelectionState } from '@/composables/graph/useSelectionState'
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
import { useNodeHelpStore } from '@/stores/workspace/nodeHelpStore'
const { node } = defineProps<{ node: ComfyNodeDefImpl }>()
defineEmits<{
(e: 'close'): void
}>()
const nodeHelpStore = useNodeHelpStore()
const { nodeDef } = useSelectionState()
const activeHelpDef = computed(() =>
nodeHelpStore.isHelpOpen ? nodeDef.value : null
)
// Keep the open help page synced with the current selection while help is open.
whenever(activeHelpDef, (def) => {
const currentHelpNode = nodeHelpStore.currentHelpNode
if (currentHelpNode?.nodePath === def.nodePath) return
nodeHelpStore.openHelp(def)
})
</script>

View File

@@ -69,27 +69,14 @@ vi.mock('@/services/dialogService', () => ({
}))
}))
// Mock the firebaseAuthStore with hoisted state for per-test manipulation
const mockAuthStoreState = vi.hoisted(() => ({
balance: {
amount_micros: 100_000,
effective_balance_micros: 100_000,
currency: 'usd'
} as {
amount_micros?: number
effective_balance_micros?: number
currency: string
},
isFetchingBalance: false
}))
// Mock the firebaseAuthStore
vi.mock('@/stores/firebaseAuthStore', () => ({
useFirebaseAuthStore: vi.fn(() => ({
getAuthHeader: vi
.fn()
.mockResolvedValue({ Authorization: 'Bearer mock-token' }),
balance: mockAuthStoreState.balance,
isFetchingBalance: mockAuthStoreState.isFetchingBalance
balance: { amount_micros: 100_000 }, // 100,000 cents = ~211,000 credits
isFetchingBalance: false
}))
}))
@@ -151,6 +138,15 @@ vi.mock('@/composables/useExternalLink', () => ({
}))
}))
// Mock useFeatureFlags
vi.mock('@/composables/useFeatureFlags', () => ({
useFeatureFlags: vi.fn(() => ({
flags: {
subscriptionTiersEnabled: true
}
}))
}))
// Mock useTelemetry
vi.mock('@/platform/telemetry', () => ({
useTelemetry: vi.fn(() => ({
@@ -175,12 +171,6 @@ vi.mock('@/platform/cloud/subscription/components/SubscribeButton.vue', () => ({
describe('CurrentUserPopover', () => {
beforeEach(() => {
vi.clearAllMocks()
mockAuthStoreState.balance = {
amount_micros: 100_000,
effective_balance_micros: 100_000,
currency: 'usd'
}
mockAuthStoreState.isFetchingBalance = false
})
const mountComponent = (): VueWrapper => {
@@ -317,103 +307,4 @@ describe('CurrentUserPopover', () => {
expect(wrapper.emitted('close')).toBeTruthy()
expect(wrapper.emitted('close')!.length).toBe(1)
})
describe('effective_balance_micros handling', () => {
it('uses effective_balance_micros when present (positive balance)', () => {
mockAuthStoreState.balance = {
amount_micros: 200_000,
effective_balance_micros: 150_000,
currency: 'usd'
}
const wrapper = mountComponent()
expect(formatCreditsFromCents).toHaveBeenCalledWith({
cents: 150_000,
locale: 'en',
numberOptions: {
minimumFractionDigits: 0,
maximumFractionDigits: 2
}
})
expect(wrapper.text()).toContain('1500')
})
it('uses effective_balance_micros when zero', () => {
mockAuthStoreState.balance = {
amount_micros: 100_000,
effective_balance_micros: 0,
currency: 'usd'
}
const wrapper = mountComponent()
expect(formatCreditsFromCents).toHaveBeenCalledWith({
cents: 0,
locale: 'en',
numberOptions: {
minimumFractionDigits: 0,
maximumFractionDigits: 2
}
})
expect(wrapper.text()).toContain('0')
})
it('uses effective_balance_micros when negative', () => {
mockAuthStoreState.balance = {
amount_micros: 0,
effective_balance_micros: -50_000,
currency: 'usd'
}
const wrapper = mountComponent()
expect(formatCreditsFromCents).toHaveBeenCalledWith({
cents: -50_000,
locale: 'en',
numberOptions: {
minimumFractionDigits: 0,
maximumFractionDigits: 2
}
})
expect(wrapper.text()).toContain('-500')
})
it('falls back to amount_micros when effective_balance_micros is missing', () => {
mockAuthStoreState.balance = {
amount_micros: 100_000,
currency: 'usd'
}
const wrapper = mountComponent()
expect(formatCreditsFromCents).toHaveBeenCalledWith({
cents: 100_000,
locale: 'en',
numberOptions: {
minimumFractionDigits: 0,
maximumFractionDigits: 2
}
})
expect(wrapper.text()).toContain('1000')
})
it('falls back to 0 when both effective_balance_micros and amount_micros are missing', () => {
mockAuthStoreState.balance = {
currency: 'usd'
}
const wrapper = mountComponent()
expect(formatCreditsFromCents).toHaveBeenCalledWith({
cents: 0,
locale: 'en',
numberOptions: {
minimumFractionDigits: 0,
maximumFractionDigits: 2
}
})
expect(wrapper.text()).toContain('0')
})
})
})

View File

@@ -42,6 +42,7 @@
formattedBalance
}}</span>
<i
v-if="flags.subscriptionTiersEnabled"
v-tooltip="{ value: $t('credits.unified.tooltip'), showDelay: 300 }"
class="icon-[lucide--circle-help] cursor-help text-base text-muted-foreground mr-auto"
/>
@@ -146,6 +147,7 @@ import UserAvatar from '@/components/common/UserAvatar.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 { useSubscriptionDialog } from '@/platform/cloud/subscription/composables/useSubscriptionDialog'
@@ -172,13 +174,12 @@ const {
fetchStatus
} = useSubscription()
const subscriptionDialog = useSubscriptionDialog()
const { flags } = useFeatureFlags()
const { locale } = useI18n()
const formattedBalance = computed(() => {
const cents =
authStore.balance?.effective_balance_micros ??
authStore.balance?.amount_micros ??
0
// Backend returns cents despite the *_micros naming convention.
const cents = authStore.balance?.amount_micros ?? 0
return formatCreditsFromCents({
cents,
locale: locale.value,
@@ -191,9 +192,7 @@ const formattedBalance = computed(() => {
const canUpgrade = computed(() => {
const tier = subscriptionTier.value
return (
tier === 'FOUNDERS_EDITION' || tier === 'STANDARD' || tier === 'CREATOR'
)
return tier === 'STANDARD' || tier === 'CREATOR'
})
const handleOpenUserSettings = () => {

View File

@@ -1,20 +1,10 @@
import type {
Meta,
StoryObj,
ComponentPropsAndSlots
} from '@storybook/vue3-vite'
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import Button from './Button.vue'
import { FOR_STORIES } from '@/components/ui/button/button.variants'
interface ButtonPropsAndStoryArgs extends ComponentPropsAndSlots<
typeof Button
> {
icon?: 'left' | 'right'
}
const { variants, sizes } = FOR_STORIES
const meta: Meta<ButtonPropsAndStoryArgs> = {
const meta: Meta<typeof Button> = {
title: 'Components/Button/Button',
component: Button,
tags: ['autodocs'],
@@ -32,19 +22,13 @@ const meta: Meta<ButtonPropsAndStoryArgs> = {
as: { defaultValue: 'button' },
asChild: { defaultValue: false },
default: {
control: { type: 'text' },
defaultValue: 'Button'
},
icon: {
control: { type: 'select' },
options: [undefined, 'left', 'right']
}
},
args: {
variant: 'secondary',
size: 'md',
default: 'Button',
icon: undefined
default: 'Button'
}
}
@@ -52,18 +36,10 @@ export default meta
type Story = StoryObj<typeof meta>
export const SingleButton: Story = {
render: (args) => ({
components: { Button },
setup() {
return { args }
},
template: `
<Button v-bind="args">
<i v-if="args.icon === 'left'" class="icon-[lucide--settings]" />
{{args.default}}
<i v-if="args.icon === 'right'" class="icon-[lucide--settings]" />
</Button>`
})
args: {
variant: 'primary',
size: 'lg'
}
}
function generateVariants() {

View File

@@ -17,34 +17,43 @@
<template #header-right-area>
<div class="flex gap-2">
<Button variant="primary" @click="() => {}">
<i class="icon-[lucide--upload]" />
<span>{{ $t('g.upload') }}</span>
</Button>
<IconTextButton
type="primary"
:label="$t('g.upload')"
@click="() => {}"
>
<template #icon>
<i class="icon-[lucide--upload]" />
</template>
</IconTextButton>
<MoreButton>
<template #default="{ close }">
<Button
variant="secondary"
<IconTextButton
type="secondary"
:label="$t('g.settings')"
@click="
() => {
close()
}
"
>
<i class="icon-[lucide--download]" />
<span>{{ $t('g.settings') }}</span>
</Button>
<Button
variant="primary"
<template #icon>
<i class="icon-[lucide--download]" />
</template>
</IconTextButton>
<IconTextButton
type="primary"
:label="$t('g.profile')"
@click="
() => {
close()
}
"
>
<i class="icon-[lucide--scroll]" />
<span>{{ $t('g.profile') }}</span>
</Button>
<template #icon>
<i class="icon-[lucide--scroll]" />
</template>
</IconTextButton>
</template>
</MoreButton>
</div>
@@ -125,6 +134,7 @@
<script setup lang="ts">
import { computed, provide, ref } from 'vue'
import IconTextButton from '@/components/button/IconTextButton.vue'
import MoreButton from '@/components/button/MoreButton.vue'
import CardBottom from '@/components/card/CardBottom.vue'
import CardContainer from '@/components/card/CardContainer.vue'

View File

@@ -2,6 +2,7 @@ import type { Meta, StoryObj } from '@storybook/vue3-vite'
import { computed, provide, ref } from 'vue'
import Button from '@/components/ui/button/Button.vue'
import IconTextButton from '@/components/button/IconTextButton.vue'
import MoreButton from '@/components/button/MoreButton.vue'
import CardBottom from '@/components/card/CardBottom.vue'
import CardContainer from '@/components/card/CardContainer.vue'
@@ -74,6 +75,7 @@ const createStoryTemplate = (args: StoryArgs) => ({
MultiSelect,
SingleSelect,
Button,
IconTextButton,
MoreButton,
CardContainer,
CardTop,
@@ -200,32 +202,33 @@ const createStoryTemplate = (args: StoryArgs) => ({
<!-- Header Right Area -->
<template v-if="args.hasHeaderRightArea" #header-right-area>
<div class="flex gap-2">
<Button variant="primary" @click="() => {}">
<IconTextButton type="primary" label="Upload Model" @click="() => {}">
<template #icon>
<i class="icon-[lucide--upload] size-3" />
<span> Upload Model </span>
</Button>
</template>
</IconTextButton>
<MoreButton>
<template #default="{ close }">
<Button
variant="secondary"
<IconTextButton
type="secondary"
label="Settings"
@click="() => { close() }"
>
<template #icon>
<i class="icon-[lucide--download] size-3" />
</template>
</Button>
</IconTextButton>
<Button
variant="primary"
<IconTextButton
type="primary"
label="Profile"
@click="() => { close() }"
>
<template #icon>
<i class="icon-[lucide--scroll] size-3" />
</template>
</Button>
</IconTextButton>
</template>
</MoreButton>
</div>
@@ -324,28 +327,33 @@ const createStoryTemplate = (args: StoryArgs) => ({
<!-- Header Right Area -->
<template v-if="args.hasHeaderRightArea" #header-right-area>
<div class="flex gap-2">
<Button variant="primary" @click="() => {}">
<IconTextButton type="primary" label="Upload Model" @click="() => {}">
<template #icon>
<i class="icon-[lucide--upload] size-3" />
<span>Upload Model</span>
</Button>
</template>
</IconTextButton>
<MoreButton>
<template #default="{ close }">
<Button
variant="secondary"
<IconTextButton
type="secondary"
label="Settings"
@click="() => { close() }"
>
<template #icon>
<i class="icon-[lucide--download] size-3" />
<span>Settings</span>
</Button>
</template>
</IconTextButton>
<Button
variant="primary"
<IconTextButton
type="primary"
label="Profile"
@click="() => { close() }"
>
<template #icon>
<i class="icon-[lucide--scroll] size-3" />
<span>Profile</span>
</Button>
</template>
</IconTextButton>
</template>
</MoreButton>
</div>

View File

@@ -11,7 +11,6 @@ import { useTelemetry } from '@/platform/telemetry'
import { useToastStore } from '@/platform/updates/common/toastStore'
import { useDialogService } from '@/services/dialogService'
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
import type { BillingPortalTargetTier } from '@/stores/firebaseAuthStore'
import { usdToMicros } from '@/utils/formatUtil'
/**
@@ -103,11 +102,8 @@ export const useFirebaseAuthActions = () => {
window.open(response.checkout_url, '_blank')
}, reportError)
const accessBillingPortal = wrapWithErrorHandlingAsync<
[targetTier?: BillingPortalTargetTier],
void
>(async (targetTier) => {
const response = await authStore.accessBillingPortal(targetTier)
const accessBillingPortal = wrapWithErrorHandlingAsync(async () => {
const response = await authStore.accessBillingPortal()
if (!response.billing_portal_url) {
throw new Error(
t('toastMessages.failedToAccessBillingPortal', {

View File

@@ -31,9 +31,8 @@ import type {
LGraphTriggerAction,
LGraphTriggerEvent,
LGraphTriggerParam
} from '@/lib/litegraph/src/litegraph'
import type { TitleMode } from '@/lib/litegraph/src/types/globalEnums'
import { NodeSlotType } from '@/lib/litegraph/src/types/globalEnums'
} from '../../lib/litegraph/src/litegraph'
import { NodeSlotType } from '../../lib/litegraph/src/types/globalEnums'
export interface WidgetSlotMetadata {
index: number
@@ -56,27 +55,26 @@ export interface SafeWidgetData {
}
export interface VueNodeData {
executing: boolean
id: NodeId
mode: number
selected: boolean
title: string
type: string
mode: number
selected: boolean
executing: boolean
apiNode?: boolean
badges?: (LGraphBadge | (() => LGraphBadge))[]
bgcolor?: string
color?: string
subgraphId?: string | null
widgets?: SafeWidgetData[]
inputs?: INodeInputSlot[]
outputs?: INodeOutputSlot[]
hasErrors?: boolean
flags?: {
collapsed?: boolean
pinned?: boolean
}
hasErrors?: boolean
inputs?: INodeInputSlot[]
outputs?: INodeOutputSlot[]
color?: string
bgcolor?: string
shape?: number
subgraphId?: string | null
titleMode?: TitleMode
widgets?: SafeWidgetData[]
}
export interface GraphNodeManager {
@@ -264,7 +262,6 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
title: typeof node.title === 'string' ? node.title : '',
type: nodeType,
mode: node.mode || 0,
titleMode: node.title_mode,
selected: node.selected || false,
executing: false, // Will be updated separately based on execution state
subgraphId,

View File

@@ -55,23 +55,11 @@ export function toggleNodeOptions(event: Event) {
}
}
/**
* Show the node options popover (always shows, doesn't toggle)
* Use this for contextmenu events where we always want to show at the new position
* @param event - The trigger event (must be MouseEvent for position)
*/
export function showNodeOptions(event: MouseEvent) {
if (nodeOptionsInstance?.show) {
nodeOptionsInstance.show(event)
}
}
/**
* Hide the node options popover
*/
interface NodeOptionsInstance {
toggle: (event: Event) => void
show: (event: MouseEvent) => void
hide: () => void
isOpen: Ref<boolean>
}

View File

@@ -105,11 +105,12 @@ export function useSelectionState() {
const isSidebarActive =
sidebarTabStore.activeSidebarTabId === nodeLibraryTabId
const currentHelpNode = nodeHelpStore.currentHelpNode
const currentHelpNode: any = nodeHelpStore.currentHelpNode
const isSameNodeHelpOpen =
isSidebarActive &&
nodeHelpStore.isHelpOpen &&
currentHelpNode?.nodePath === def.nodePath
currentHelpNode &&
currentHelpNode.nodePath === def.nodePath
if (isSameNodeHelpOpen) {
nodeHelpStore.closeHelp()

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,7 @@
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'
@@ -9,6 +10,7 @@ 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))
@@ -38,26 +40,53 @@ export const usePriceBadge = () => {
function isCreditsBadge(badge: LGraphBadge | (() => LGraphBadge)): boolean {
const badgeInstance = typeof badge === 'function' ? badge() : badge
return badgeInstance.icon?.image === componentIconSvg
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: {
image: componentIconSvg,
size: 8
},
fgColor:
colorPaletteStore.completedActivePalette.colors.litegraph_base
.BADGE_FG_COLOR,
bgColor: isLightTheme
? adjustColor('#8D6932', { lightness: 0.5 })
: '#8D6932'
})
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('#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

@@ -13,6 +13,7 @@ export enum ServerFeatureFlag {
MODEL_UPLOAD_BUTTON_ENABLED = 'model_upload_button_enabled',
ASSET_UPDATE_OPTIONS_ENABLED = 'asset_update_options_enabled',
PRIVATE_MODELS_ENABLED = 'private_models_enabled',
SUBSCRIPTION_TIERS_ENABLED = 'subscription_tiers_enabled',
ONBOARDING_SURVEY_ENABLED = 'onboarding_survey_enabled'
}
@@ -57,6 +58,16 @@ export function useFeatureFlags() {
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)
)
)
},
get onboardingSurveyEnabled() {
return (
remoteConfig.value.onboarding_survey_enabled ??

View File

@@ -63,8 +63,6 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
const loading = ref(false)
const loadingMessage = ref('')
const isPreview = ref(false)
const isSplatModel = ref(false)
const isPlyModel = ref(false)
const initializeLoad3d = async (containerRef: HTMLElement) => {
const rawNode = toRaw(nodeRef.value)
@@ -492,8 +490,6 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
modelLoadingEnd: () => {
loadingMessage.value = ''
loading.value = false
isSplatModel.value = load3d?.isSplatModel() ?? false
isPlyModel.value = load3d?.isPlyModel() ?? false
},
exportLoadingStart: (message: string) => {
loadingMessage.value = message || t('load3d.exportingModel')
@@ -565,8 +561,6 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
lightConfig,
isRecording,
isPreview,
isSplatModel,
isPlyModel,
hasRecording,
recordingDuration,
animations,

View File

@@ -46,8 +46,6 @@ export const useLoad3dViewer = (node?: LGraphNode) => {
const needApplyChanges = ref(true)
const isPreview = ref(false)
const isStandaloneMode = ref(false)
const isSplatModel = ref(false)
const isPlyModel = ref(false)
let load3d: Load3d | null = null
let sourceLoad3d: Load3d | null = null
@@ -189,18 +187,17 @@ export const useLoad3dViewer = (node?: LGraphNode) => {
const width = node.widgets?.find((w) => w.name === 'width')
const height = node.widgets?.find((w) => w.name === 'height')
const hasTargetDimensions = !!(width && height)
load3d = new Load3d(containerRef, {
width: width ? (toRaw(width).value as number) : undefined,
height: height ? (toRaw(height).value as number) : undefined,
getDimensions: hasTargetDimensions
? () => ({
width: width.value as number,
height: height.value as number
})
: undefined,
isViewerMode: hasTargetDimensions
getDimensions:
width && height
? () => ({
width: width.value as number,
height: height.value as number
})
: undefined,
isViewerMode: true
})
await useLoad3dService().copyLoad3dState(source, load3d)
@@ -255,9 +252,6 @@ export const useLoad3dViewer = (node?: LGraphNode) => {
modelConfig.materialMode || source.modelManager.materialMode
}
isSplatModel.value = source.isSplatModel()
isPlyModel.value = source.isPlyModel()
initialState.value = {
backgroundColor: backgroundColor.value,
showGrid: showGrid.value,
@@ -306,8 +300,6 @@ export const useLoad3dViewer = (node?: LGraphNode) => {
backgroundRenderMode.value = 'tiled'
upDirection.value = 'original'
materialMode.value = 'original'
isSplatModel.value = load3d.isSplatModel()
isPlyModel.value = load3d.isPlyModel()
isPreview.value = true
} catch (error) {
@@ -524,8 +516,6 @@ export const useLoad3dViewer = (node?: LGraphNode) => {
needApplyChanges,
isPreview,
isStandaloneMode,
isSplatModel,
isPlyModel,
// Methods
initializeViewer,

View File

@@ -198,17 +198,6 @@ useExtensionService().registerExtension({
type: 'boolean',
defaultValue: false,
experimental: true
},
{
id: 'Comfy.Load3D.PLYEngine',
category: ['3D', 'PLY', 'PLY Engine'],
name: 'PLY Engine',
tooltip:
'Select the engine for loading PLY files. "threejs" uses the native Three.js PLYLoader (best for mesh PLY files). "fastply" uses an optimized loader for ASCII point cloud PLY files. "sparkjs" uses Spark.js for 3D Gaussian Splatting PLY files.',
type: 'combo',
options: ['threejs', 'fastply', 'sparkjs'],
defaultValue: 'threejs',
experimental: true
}
],
commands: [
@@ -249,10 +238,7 @@ useExtensionService().registerExtension({
getCustomWidgets() {
return {
LOAD_3D(node) {
const fileInput = createFileInput(
'.gltf,.glb,.obj,.fbx,.stl,.ply,.spz,.splat,.ksplat',
false
)
const fileInput = createFileInput('.gltf,.glb,.obj,.fbx,.stl', false)
node.properties['Resource Folder'] = ''
@@ -315,8 +301,6 @@ useExtensionService().registerExtension({
const load3d = useLoad3dService().getLoad3d(node)
if (!load3d) return []
if (load3d.isSplatModel()) return []
return createExportMenuItems(load3d)
},
@@ -425,8 +409,6 @@ useExtensionService().registerExtension({
const load3d = useLoad3dService().getLoad3d(node)
if (!load3d) return []
if (load3d.isSplatModel()) return []
return createExportMenuItems(load3d)
},

View File

@@ -43,8 +43,8 @@ class Load3d {
STATUS_MOUSE_ON_VIEWER: boolean
INITIAL_RENDER_DONE: boolean = false
targetWidth: number = 0
targetHeight: number = 0
targetWidth: number = 512
targetHeight: number = 512
targetAspectRatio: number = 1
isViewerMode: boolean = false
@@ -72,13 +72,7 @@ class Load3d {
this.renderer.setClearColor(0x282828)
this.renderer.autoClear = false
this.renderer.outputColorSpace = THREE.SRGBColorSpace
this.renderer.domElement.classList.add(
'absolute',
'inset-0',
'h-full',
'w-full',
'outline-none'
)
this.renderer.domElement.classList.add('flex', '!h-full', '!w-full')
container.appendChild(this.renderer.domElement)
this.eventManager = new EventManager()
@@ -577,14 +571,6 @@ class Load3d {
this.loadingPromise = null
}
isSplatModel(): boolean {
return this.modelManager.containsSplatMesh()
}
isPlyModel(): boolean {
return this.modelManager.originalModel instanceof THREE.BufferGeometry
}
clearModel(): void {
this.animationManager.dispose()
this.modelManager.clearModel()
@@ -623,7 +609,7 @@ class Load3d {
}
handleResize(): void {
const parentElement = this.renderer?.domElement?.parentElement
const parentElement = this.renderer?.domElement
if (!parentElement) {
console.warn('Parent element not found')

View File

@@ -1,24 +1,18 @@
import { SplatMesh } from '@sparkjsdev/spark'
import * as THREE from 'three'
import { FBXLoader } from 'three/examples/jsm/loaders/FBXLoader'
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader'
import { MTLLoader } from 'three/examples/jsm/loaders/MTLLoader'
import { OBJLoader } from 'three/examples/jsm/loaders/OBJLoader'
import { PLYLoader } from 'three/examples/jsm/loaders/PLYLoader'
import { STLLoader } from 'three/examples/jsm/loaders/STLLoader'
import { t } from '@/i18n'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useToastStore } from '@/platform/updates/common/toastStore'
import { api } from '@/scripts/api'
import { isPLYAsciiFormat } from '@/scripts/metadata/ply'
import {
type EventManagerInterface,
type LoaderManagerInterface,
type ModelManagerInterface
} from './interfaces'
import { FastPLYLoader } from './loader/FastPLYLoader'
export class LoaderManager implements LoaderManagerInterface {
gltfLoader: GLTFLoader
@@ -26,12 +20,9 @@ export class LoaderManager implements LoaderManagerInterface {
mtlLoader: MTLLoader
fbxLoader: FBXLoader
stlLoader: STLLoader
plyLoader: PLYLoader
fastPlyLoader: FastPLYLoader
private modelManager: ModelManagerInterface
private eventManager: EventManagerInterface
private currentLoadId: number = 0
constructor(
modelManager: ModelManagerInterface,
@@ -45,8 +36,6 @@ export class LoaderManager implements LoaderManagerInterface {
this.mtlLoader = new MTLLoader()
this.fbxLoader = new FBXLoader()
this.stlLoader = new STLLoader()
this.plyLoader = new PLYLoader()
this.fastPlyLoader = new FastPLYLoader()
}
init(): void {}
@@ -54,8 +43,6 @@ export class LoaderManager implements LoaderManagerInterface {
dispose(): void {}
async loadModel(url: string, originalFileName?: string): Promise<void> {
const loadId = ++this.currentLoadId
try {
this.eventManager.emitEvent('modelLoadingStart', null)
@@ -85,11 +72,7 @@ export class LoaderManager implements LoaderManagerInterface {
return
}
const model = await this.loadModelInternal(url, fileExtension)
if (loadId !== this.currentLoadId) {
return
}
let model = await this.loadModelInternal(url, fileExtension)
if (model) {
await this.modelManager.setupModel(model)
@@ -97,11 +80,9 @@ export class LoaderManager implements LoaderManagerInterface {
this.eventManager.emitEvent('modelLoadingEnd', null)
} catch (error) {
if (loadId === this.currentLoadId) {
this.eventManager.emitEvent('modelLoadingEnd', null)
console.error('Error loading model:', error)
useToastStore().addAlert(t('toastMessages.errorLoadingModel'))
}
this.eventManager.emitEvent('modelLoadingEnd', null)
console.error('Error loading model:', error)
useToastStore().addAlert(t('toastMessages.errorLoadingModel'))
}
}
@@ -206,132 +187,8 @@ export class LoaderManager implements LoaderManagerInterface {
}
})
break
case 'ply':
model = await this.loadPLY(path, filename)
break
case 'spz':
case 'splat':
case 'ksplat':
model = await this.loadSplat(path, filename)
break
}
return model
}
private async fetchModelData(path: string, filename: string) {
const route =
'/' + path.replace(/^api\//, '') + encodeURIComponent(filename)
const response = await api.fetchApi(route)
if (!response.ok) {
throw new Error(`Failed to fetch model: ${response.status}`)
}
return response.arrayBuffer()
}
private async loadSplat(
path: string,
filename: string
): Promise<THREE.Object3D> {
const arrayBuffer = await this.fetchModelData(path, filename)
const splatMesh = new SplatMesh({ fileBytes: arrayBuffer })
this.modelManager.setOriginalModel(splatMesh)
const splatGroup = new THREE.Group()
splatGroup.add(splatMesh)
return splatGroup
}
private async loadPLY(
path: string,
filename: string
): Promise<THREE.Object3D | null> {
const plyEngine = useSettingStore().get('Comfy.Load3D.PLYEngine') as string
if (plyEngine === 'sparkjs') {
return this.loadSplat(path, filename)
}
// Use Three.js PLYLoader or FastPLYLoader for point cloud PLY files
const arrayBuffer = await this.fetchModelData(path, filename)
const isASCII = isPLYAsciiFormat(arrayBuffer)
let plyGeometry: THREE.BufferGeometry
if (isASCII && plyEngine === 'fastply') {
plyGeometry = this.fastPlyLoader.parse(arrayBuffer)
} else {
this.plyLoader.setPath(path)
plyGeometry = this.plyLoader.parse(arrayBuffer)
}
this.modelManager.setOriginalModel(plyGeometry)
plyGeometry.computeVertexNormals()
const hasVertexColors = plyGeometry.attributes.color !== undefined
const materialMode = this.modelManager.materialMode
// Use Points rendering for pointCloud mode (better for point clouds)
if (materialMode === 'pointCloud') {
plyGeometry.computeBoundingSphere()
if (plyGeometry.boundingSphere) {
const center = plyGeometry.boundingSphere.center
const radius = plyGeometry.boundingSphere.radius
plyGeometry.translate(-center.x, -center.y, -center.z)
if (radius > 0) {
const scale = 1.0 / radius
plyGeometry.scale(scale, scale, scale)
}
}
const pointMaterial = hasVertexColors
? new THREE.PointsMaterial({
size: 0.005,
vertexColors: true,
sizeAttenuation: true
})
: new THREE.PointsMaterial({
size: 0.005,
color: 0xcccccc,
sizeAttenuation: true
})
const plyPoints = new THREE.Points(plyGeometry, pointMaterial)
this.modelManager.originalMaterials.set(
plyPoints as unknown as THREE.Mesh,
pointMaterial
)
const plyGroup = new THREE.Group()
plyGroup.add(plyPoints)
return plyGroup
}
// Use Mesh rendering for other modes
let plyMaterial: THREE.Material
if (hasVertexColors) {
plyMaterial = new THREE.MeshStandardMaterial({
vertexColors: true,
metalness: 0.0,
roughness: 0.5,
side: THREE.DoubleSide
})
} else {
plyMaterial = this.modelManager.standardMaterial.clone()
plyMaterial.side = THREE.DoubleSide
}
const plyMesh = new THREE.Mesh(plyGeometry, plyMaterial)
this.modelManager.originalMaterials.set(plyMesh, plyMaterial)
const plyGroup = new THREE.Group()
plyGroup.add(plyMesh)
return plyGroup
}
}

View File

@@ -1,4 +1,3 @@
import { SplatMesh } from '@sparkjsdev/spark'
import * as THREE from 'three'
import { type GLTF } from 'three/examples/jsm/loaders/GLTFLoader'
@@ -99,145 +98,6 @@ export class SceneModelManager implements ModelManagerInterface {
})
}
private handlePLYModeSwitch(mode: MaterialMode): void {
if (!(this.originalModel instanceof THREE.BufferGeometry)) {
return
}
const plyGeometry = this.originalModel.clone()
const hasVertexColors = plyGeometry.attributes.color !== undefined
// Find and remove ALL MainModel instances by name to ensure deletion
const oldMainModels: THREE.Object3D[] = []
this.scene.traverse((obj) => {
if (obj.name === 'MainModel') {
oldMainModels.push(obj)
}
})
// Remove and dispose all found MainModels
oldMainModels.forEach((oldModel) => {
oldModel.traverse((child) => {
if (child instanceof THREE.Mesh || child instanceof THREE.Points) {
child.geometry?.dispose()
if (Array.isArray(child.material)) {
child.material.forEach((m) => m.dispose())
} else {
child.material?.dispose()
}
}
})
this.scene.remove(oldModel)
})
this.currentModel = null
let newModel: THREE.Object3D
if (mode === 'pointCloud') {
// Use Points rendering for point cloud mode
plyGeometry.computeBoundingSphere()
if (plyGeometry.boundingSphere) {
const center = plyGeometry.boundingSphere.center
const radius = plyGeometry.boundingSphere.radius
plyGeometry.translate(-center.x, -center.y, -center.z)
if (radius > 0) {
const scale = 1.0 / radius
plyGeometry.scale(scale, scale, scale)
}
}
const pointMaterial = hasVertexColors
? new THREE.PointsMaterial({
size: 0.005,
vertexColors: true,
sizeAttenuation: true
})
: new THREE.PointsMaterial({
size: 0.005,
color: 0xcccccc,
sizeAttenuation: true
})
const points = new THREE.Points(plyGeometry, pointMaterial)
newModel = new THREE.Group()
newModel.add(points)
} else {
// Use Mesh rendering for other modes
let meshMaterial: THREE.Material = hasVertexColors
? new THREE.MeshStandardMaterial({
vertexColors: true,
metalness: 0.0,
roughness: 0.5,
side: THREE.DoubleSide
})
: this.standardMaterial.clone()
if (
!hasVertexColors &&
meshMaterial instanceof THREE.MeshStandardMaterial
) {
meshMaterial.side = THREE.DoubleSide
}
const mesh = new THREE.Mesh(plyGeometry, meshMaterial)
this.originalMaterials.set(mesh, meshMaterial)
newModel = new THREE.Group()
newModel.add(mesh)
// Apply the requested material mode
if (mode === 'normal') {
mesh.material = new THREE.MeshNormalMaterial({
flatShading: false,
side: THREE.DoubleSide
})
} else if (mode === 'wireframe') {
mesh.material = new THREE.MeshBasicMaterial({
color: 0xffffff,
wireframe: true
})
}
}
// Double check: remove any remaining MainModel before adding new one
const remainingMainModels: THREE.Object3D[] = []
this.scene.traverse((obj) => {
if (obj.name === 'MainModel') {
remainingMainModels.push(obj)
}
})
remainingMainModels.forEach((obj) => this.scene.remove(obj))
this.currentModel = newModel
newModel.name = 'MainModel'
// Setup the new model
if (mode === 'pointCloud') {
this.scene.add(newModel)
} else {
const box = new THREE.Box3().setFromObject(newModel)
const size = box.getSize(new THREE.Vector3())
const center = box.getCenter(new THREE.Vector3())
const maxDim = Math.max(size.x, size.y, size.z)
const targetSize = 5
const scale = targetSize / maxDim
newModel.scale.multiplyScalar(scale)
box.setFromObject(newModel)
box.getCenter(center)
box.getSize(size)
newModel.position.set(-center.x, -box.min.y, -center.z)
this.scene.add(newModel)
}
this.eventManager.emitEvent('materialModeChange', mode)
}
setMaterialMode(mode: MaterialMode): void {
if (!this.currentModel || mode === this.materialMode) {
return
@@ -245,12 +105,6 @@ export class SceneModelManager implements ModelManagerInterface {
this.materialMode = mode
// Handle PLY files specially - they need to be recreated for mode switch
if (this.originalModel instanceof THREE.BufferGeometry) {
this.handlePLYModeSwitch(mode)
return
}
if (mode === 'depth') {
this.renderer.outputColorSpace = THREE.LinearSRGBColorSpace
} else {
@@ -332,7 +186,6 @@ export class SceneModelManager implements ModelManagerInterface {
})
break
case 'original':
case 'pointCloud':
const originalMaterial = this.originalMaterials.get(child)
if (originalMaterial) {
child.material = originalMaterial
@@ -419,25 +272,12 @@ export class SceneModelManager implements ModelManagerInterface {
addModelToScene(model: THREE.Object3D): void {
this.currentModel = model
model.name = 'MainModel'
this.scene.add(this.currentModel)
}
async setupModel(model: THREE.Object3D): Promise<void> {
this.currentModel = model
model.name = 'MainModel'
// Check if model is or contains a SplatMesh (3D Gaussian Splatting)
const isSplatModel = this.containsSplatMesh(model)
if (isSplatModel) {
// SplatMesh handles its own rendering, just add to scene
this.scene.add(model)
// Set a default camera distance for splat models
this.setupCamera(new THREE.Vector3(5, 5, 5))
return
}
const box = new THREE.Box3().setFromObject(model)
const size = box.getSize(new THREE.Vector3())
@@ -468,17 +308,6 @@ export class SceneModelManager implements ModelManagerInterface {
this.setupCamera(size)
}
containsSplatMesh(model?: THREE.Object3D | null): boolean {
const target = model ?? this.currentModel
if (!target) return false
if (target instanceof SplatMesh) return true
let found = false
target.traverse((child) => {
if (child instanceof SplatMesh) found = true
})
return found
}
setOriginalModel(model: THREE.Object3D | THREE.BufferGeometry | GLTF): void {
this.originalModel = model
}

View File

@@ -7,12 +7,7 @@ import { MTLLoader } from 'three/examples/jsm/loaders/MTLLoader'
import { OBJLoader } from 'three/examples/jsm/loaders/OBJLoader'
import { STLLoader } from 'three/examples/jsm/loaders/STLLoader'
export type MaterialMode =
| 'original'
| 'pointCloud'
| 'normal'
| 'wireframe'
| 'depth'
export type MaterialMode = 'original' | 'normal' | 'wireframe' | 'depth'
export type UpDirection = 'original' | '-x' | '+x' | '-y' | '+y' | '-z' | '+z'
export type CameraType = 'perspective' | 'orthographic'
export type BackgroundRenderModeType = 'tiled' | 'panorama'
@@ -191,9 +186,5 @@ export const SUPPORTED_EXTENSIONS = new Set([
'.glb',
'.obj',
'.fbx',
'.stl',
'.spz',
'.splat',
'.ply',
'.ksplat'
'.stl'
])

View File

@@ -1,33 +0,0 @@
import * as THREE from 'three'
import { parseASCIIPLY } from '@/scripts/metadata/ply'
/**
* Fast ASCII PLY Loader
* Optimized for simple ASCII PLY files with position and color data
* 4-5x faster than Three.js PLYLoader for ASCII files
*/
export class FastPLYLoader {
parse(arrayBuffer: ArrayBuffer): THREE.BufferGeometry {
const plyData = parseASCIIPLY(arrayBuffer)
if (!plyData) {
throw new Error('Failed to parse PLY data')
}
const geometry = new THREE.BufferGeometry()
geometry.setAttribute(
'position',
new THREE.BufferAttribute(plyData.positions, 3)
)
if (plyData.colors) {
geometry.setAttribute(
'color',
new THREE.BufferAttribute(plyData.colors, 3)
)
}
return geometry
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,10 +1,26 @@
import _ from 'es-toolkit/compat'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { app } from '@/scripts/app'
import { useMaskEditorStore } from '@/stores/maskEditorStore'
import { useDialogStore } from '@/stores/dialogStore'
import { t } from '@/i18n'
import { useMaskEditor } from '@/composables/maskeditor/useMaskEditor'
import { useToastStore } from '@/platform/updates/common/toastStore'
import { app } from '@/scripts/app'
import { ComfyApp } from '@/scripts/app'
import { useDialogStore } from '@/stores/dialogStore'
import { useMaskEditorStore } from '@/stores/maskEditorStore'
import { ClipspaceDialog } from './clipspace'
import { MaskEditorDialogOld } from './maskEditorOld'
const warnLegacyMaskEditorDeprecation = () => {
const warningMessage = t('toastMessages.legacyMaskEditorDeprecated')
console.warn(`[Comfy.MaskEditor] ${warningMessage}`)
useToastStore().add({
severity: 'warn',
summary: 'Alert',
detail: warningMessage,
life: 4096
})
}
function openMaskEditor(node: LGraphNode): void {
if (!node) {
@@ -17,23 +33,56 @@ function openMaskEditor(node: LGraphNode): void {
return
}
useMaskEditor().openMaskEditor(node)
const useNewEditor = app.extensionManager.setting.get(
'Comfy.MaskEditor.UseNewEditor'
)
if (useNewEditor) {
useMaskEditor().openMaskEditor(node)
} else {
warnLegacyMaskEditorDeprecation()
// Use old editor
ComfyApp.copyToClipspace(node)
// @ts-expect-error clipspace_return_node is an extension property added at runtime
ComfyApp.clipspace_return_node = node
const dlg = MaskEditorDialogOld.getInstance() as any
if (dlg?.isOpened && !dlg.isOpened()) {
dlg.show()
}
}
}
// Check if the dialog is already opened
function isOpened(): boolean {
return useDialogStore().isDialogOpen('global-mask-editor')
const useNewEditor = app.extensionManager.setting.get(
'Comfy.MaskEditor.UseNewEditor'
)
if (useNewEditor) {
return useDialogStore().isDialogOpen('global-mask-editor')
} else {
return (MaskEditorDialogOld.instance as any)?.isOpened?.() ?? false
}
}
app.registerExtension({
name: 'Comfy.MaskEditor',
settings: [
{
id: 'Comfy.MaskEditor.UseNewEditor',
category: ['Mask Editor', 'NewEditor'],
name: 'Use new mask editor',
tooltip: 'Switch to the new mask editor interface',
type: 'boolean',
defaultValue: true,
experimental: true
},
{
id: 'Comfy.MaskEditor.BrushAdjustmentSpeed',
category: ['Mask Editor', 'BrushAdjustment', 'Sensitivity'],
name: 'Brush adjustment speed multiplier',
tooltip:
'Controls how quickly the brush size and hardness change when adjusting. Higher values mean faster changes.',
experimental: true,
type: 'slider',
attrs: {
min: 0.1,
@@ -50,7 +99,8 @@ app.registerExtension({
tooltip:
'When enabled, brush adjustments will only affect size OR hardness based on which direction you move more',
type: 'boolean',
defaultValue: true
defaultValue: true,
experimental: true
}
],
commands: [
@@ -78,7 +128,36 @@ app.registerExtension({
label: 'Decrease Brush Size in MaskEditor',
function: () => changeBrushSize((old) => _.clamp(old - 4, 1, 100))
}
]
],
init() {
// Support for old editor clipspace integration
const openMaskEditorFromClipspace = () => {
const useNewEditor = app.extensionManager.setting.get(
'Comfy.MaskEditor.UseNewEditor'
)
if (!useNewEditor) {
warnLegacyMaskEditorDeprecation()
const dlg = MaskEditorDialogOld.getInstance() as any
if (dlg?.isOpened && !dlg.isOpened()) {
dlg.show()
}
}
}
const context_predicate = (): boolean => {
return !!(
ComfyApp.clipspace &&
ComfyApp.clipspace.imgs &&
ComfyApp.clipspace.imgs.length > 0
)
}
ClipspaceDialog.registerButton(
'MaskEditor',
context_predicate,
openMaskEditorFromClipspace
)
}
})
const changeBrushSize = async (sizeChanger: (oldSize: number) => number) => {

View File

@@ -54,8 +54,6 @@ useExtensionService().registerExtension({
const load3d = useLoad3dService().getLoad3d(node)
if (!load3d) return []
if (load3d.isSplatModel()) return []
return createExportMenuItems(load3d)
},

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