Compare commits
1 Commits
prbot-impr
...
codex/hide
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7c9951c330 |
1
.github/workflows/ci-tests-e2e.yaml
vendored
@@ -45,7 +45,6 @@ jobs:
|
||||
playwright-tests-chromium-sharded:
|
||||
needs: setup
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 60
|
||||
permissions:
|
||||
contents: read
|
||||
strategy:
|
||||
|
||||
13
AGENTS.md
@@ -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/`
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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[]> {
|
||||
|
||||
|
Before Width: | Height: | Size: 81 KiB After Width: | Height: | Size: 86 KiB |
|
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 22 KiB |
@@ -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(
|
||||
|
||||
|
Before Width: | Height: | Size: 26 KiB After Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 91 KiB After Width: | Height: | Size: 97 KiB |
|
Before Width: | Height: | Size: 95 KiB After Width: | Height: | Size: 103 KiB |
|
Before Width: | Height: | Size: 97 KiB After Width: | Height: | Size: 104 KiB |
@@ -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
|
||||
|
||||
|
Before Width: | Height: | Size: 75 KiB After Width: | Height: | Size: 80 KiB |
|
Before Width: | Height: | Size: 115 KiB After Width: | Height: | Size: 118 KiB |
|
Before Width: | Height: | Size: 56 KiB After Width: | Height: | Size: 58 KiB |
|
Before Width: | Height: | Size: 26 KiB After Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 29 KiB After Width: | Height: | Size: 31 KiB |
|
Before Width: | Height: | Size: 62 KiB After Width: | Height: | Size: 63 KiB |
|
Before Width: | Height: | Size: 60 KiB After Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 61 KiB After Width: | Height: | Size: 63 KiB |
|
Before Width: | Height: | Size: 63 KiB After Width: | Height: | Size: 65 KiB |
|
Before Width: | Height: | Size: 63 KiB After Width: | Height: | Size: 67 KiB |
|
Before Width: | Height: | Size: 62 KiB After Width: | Height: | Size: 64 KiB |
|
Before Width: | Height: | Size: 59 KiB After Width: | Height: | Size: 61 KiB |
|
Before Width: | Height: | Size: 59 KiB After Width: | Height: | Size: 60 KiB |
|
Before Width: | Height: | Size: 59 KiB After Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 62 KiB After Width: | Height: | Size: 64 KiB |
|
Before Width: | Height: | Size: 61 KiB After Width: | Height: | Size: 63 KiB |
|
Before Width: | Height: | Size: 63 KiB After Width: | Height: | Size: 65 KiB |
|
Before Width: | Height: | Size: 98 KiB After Width: | Height: | Size: 101 KiB |
|
Before Width: | Height: | Size: 27 KiB After Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 109 KiB After Width: | Height: | Size: 112 KiB |
|
Before Width: | Height: | Size: 109 KiB After Width: | Height: | Size: 112 KiB |
|
Before Width: | Height: | Size: 139 KiB After Width: | Height: | Size: 142 KiB |
|
Before Width: | Height: | Size: 140 KiB After Width: | Height: | Size: 144 KiB |
|
Before Width: | Height: | Size: 109 KiB After Width: | Height: | Size: 112 KiB |
|
Before Width: | Height: | Size: 81 KiB After Width: | Height: | Size: 84 KiB |
@@ -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'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
|
Before Width: | Height: | Size: 423 B |
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
301
packages/registry-types/src/comfyRegistryTypes.ts
generated
@@ -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
@@ -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
|
||||
|
||||
@@ -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/**
|
||||
@@ -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 }
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
213
src/components/button/IconTextButton.stories.ts
Normal 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 }
|
||||
}
|
||||
}
|
||||
58
src/components/button/IconTextButton.vue
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
@@ -21,7 +21,6 @@
|
||||
@keyup.enter.capture.stop="blurInputElement"
|
||||
@keyup.escape.stop="cancelEditing"
|
||||
@click.stop
|
||||
@contextmenu.stop
|
||||
@pointerdown.stop.capture
|
||||
@pointermove.stop.capture
|
||||
/>
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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>
|
||||
|
||||
119
src/components/dialog/content/credit/LegacyCreditTopUpOption.vue
Normal 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>
|
||||
@@ -263,7 +263,7 @@ function onMenuHide() {
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
registerNodeOptionsInstance({ toggle, show, hide, isOpen })
|
||||
registerNodeOptionsInstance({ toggle, hide, isOpen })
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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']
|
||||
})
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
})
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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[] }>()
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 = () => {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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', {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 ??
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
},
|
||||
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
])
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
1204
src/extensions/core/maskEditorOld.ts
Normal 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) => {
|
||||
|
||||
@@ -54,8 +54,6 @@ useExtensionService().registerExtension({
|
||||
const load3d = useLoad3dService().getLoad3d(node)
|
||||
if (!load3d) return []
|
||||
|
||||
if (load3d.isSplatModel()) return []
|
||||
|
||||
return createExportMenuItems(load3d)
|
||||
},
|
||||
|
||||
|
||||